🌟 起因:一个“教学痛点”引发的开发冲动

事情是这样的:某天晚上,我老婆在备课时抱怨:

“现在公开课要用七巧板演示图形变换,但网上找不到资源!要么功能太简陋,要么界面太老旧,根本没法用。”

我第一反应是:这还不简单?搜一个不就行了?结果逛遍小红书、淘宝、甚至 GitHub,发现:

  • 要么是“挂羊头卖狗肉”——项目名写着“七巧板”,实际是地图可视化;
  • 要么是十年前的老项目,依赖过时、交互卡顿;
  • 即使有能用的,也缺乏题目管理、贴边吸附、完成反馈等教学刚需功能。

于是,我决定:自己动手,丰衣足食!

公开课就在下周,我只有一周不到时间开发。幸运的是——有AI赋能一切皆有可能。结果不到一周,一个功能完整、体验流畅的七巧板教学小游戏就上线了!

✨ 本文将带你复盘整个开发过程:需求分析 → 技术选型 → 核心算法实现 → 部署上线

🌠 效果:废话不多说,上图

七巧板游戏效果图

🎯 需求分析:教学场景下的真实痛点

在编码前,我们先明确 老师和学生真正需要什么

角色 需求
老师 能创建题目、保存拼图、快速切换题目
学生 可拖拽/旋转七巧板、图形能“贴边吸附”、完成后有正向反馈
系统 支持离线使用、大屏适配、操作流畅

💡 关键挑战

  • 七巧板是不规则多边形,传统矩形碰撞检测无效
  • 图形需支持自由旋转 + 精准吸附
  • 完成判断不能只看“位置”,而要看是否完全覆盖目标轮廓

🛠️ 技术选型:现代前端栈 + AI 辅助 = 高效开发

为了快速交付 + 易于维护,我选择了这套轻量但强大的组合:

类别 技术 选择理由
前端框架 Next.js 15 支持 output: 'export' 导出静态页,零服务器部署
图形引擎 Konva.js Canvas 上的 React-like API,完美支持拖拽、旋转、层级控制
状态管理 Zustand 比 Redux 轻量,比 Context 灵活
样式方案 Tailwind CSS 快速构建响应式 UI,Copilot 能智能生成 className
本地存储 idb-keyval (IndexedDB 封装) 持久化保存题目,支持离线使用
AI 辅助 GitHub Copilot 自动生成算法、组件、API 调用,效率翻倍

最终成果:一个纯静态网站,可直接部署到 GitHub Pages,老师用教室大屏打开即用!

🔧 核心功能实现:那些“看似简单实则烧脑”的细节

1️⃣ 七巧板图形定义:坐标即一切

七巧板由 7 个固定形状组成(2大三角、1中三角、2小三角、1正方形、1平行四边形)。我们用顶点坐标数组精确描述每个图形:

export const TANGRAM_PIECES = [
    { id: 1, points: [0, 0, 100, 0, 0, 100], color: '#FF6B6B' }, // 大三角
    { id: 2, points: [0, 0, 100, 100, 0, 100], color: '#4ECDC4' },
    { id: 3, points: [0, 0, 50, 50, 0, 100], color: '#45B7D1' }, // 中三角
    // ... 其他图形
];

📌 技巧:所有坐标以“局部原点”定义,后续通过 Konva.Group 实现整体平移/旋转

2️⃣ 拖拽 & 旋转:Konva.js 的优雅 API

<Konva.Group draggable onDragMove={onDragMove} onTransform={onRotate}>
    <Konva.Polygon points={piece.points} fill={piece.color} />
    <Konva.Text text={piece.id} />
</Konva.Group>
  • 拖拽:监听 onDragMove,实时更新位置
  • 旋转:点击编号触发 45° 顺时针旋转(教学场景不需要任意角度)

3️⃣ 碰撞检测:分离轴定理(SAT)

不能使用矩形的包围盒做碰撞检测,七巧板是任意多边形!我们引入 sat 库,实现精准多边形相交检测:

// 返回两个多边形(以世界坐标点数组表示)是否相交
import SAT from 'sat';

export const polygonIntersectionSAT = (ptsA: number[], ptsB: number[]) => {
    const toSATPolygon = (pts: number[]) => {
        const vertices = [];
        for (let i = 0; i < pts.length; i += 2) {
            vertices.push(new SAT.Vector(pts[i], pts[i + 1]));
        }
        return new SAT.Polygon(new SAT.Vector(0, 0), vertices);
    };

    const polyA = toSATPolygon(ptsA);
    const polyB = toSATPolygon(ptsB);
    const response = new SAT.Response();
    return SAT.testPolygonPolygon(polyA, polyB, response);
};

✅ 效果:当两个图形重叠时,系统能准确判断是否“碰撞”

4️⃣ 贴边吸附:用户体验的关键

难点:如何让图形“靠近边缘时自动吸附”,但又不影响自由移动?

解决方案:状态机 + 阈值控制

// 碰撞状态:Allowed(允许穿过) / Blocked(阻止)
// 安全状态:Safe(无碰撞) / Stick(贴边) / Crossing(正在穿过)

if (overlap > THRESHOLD) {
    // 允许穿过(比如图形叠放)
    setState(['allowed', 'crossing']);
} else if (overlap > 0) {
    // 贴边吸附:回退到安全位置 + 沿边缘滑动
    setState(['blocked', 'stick']);
    slideToNearestEdge();
} else {
    // 安全区域
    setState(['allowed', 'safe']);
}

🎯 结果:学生拖动时,图形会“智能吸附”到目标边缘,拼图更轻松

5️⃣ 多边形合并:生成题目轮廓

老师拼好一个图形后,如何保存为“题目”?我们需要将 7 个图形合并成一个轮廓多边形。

方案:使用 polybooljs 做布尔并集运算:

import PolyBool from 'polybooljs';

export const computeDisplayTargetPieces = (
    targetPolys: { id: number; points: number[] }[] | undefined,
    tolerance = 5,
) => {
    const tp = targetPolys || [];
    if (tp.length === 0) return [] as { id: string; points: number[] }[];

    // helper: point-segment distance
    const pointSegDist = (
        px: number,
        py: number,
        ax: number,
        ay: number,
        bx: number,
        by: number,
    ) => {
        const vx = bx - ax;
        const vy = by - ay;
        const wx = px - ax;
        const wy = py - ay;
        const c1 = vx * wx + vy * wy;
        const c2 = vx * vx + vy * vy;
        let t = 0;
        if (c2 > 1e-12) t = Math.max(0, Math.min(1, c1 / c2));
        const cx = ax + vx * t;
        const cy = ay + vy * t;
        const dx = px - cx;
        const dy = py - cy;
        return Math.hypot(dx, dy);
    };

    // minimal distance between two polygons (vertex->edge and vertex->vertex approx)
    const polyMinDist = (a: number[], b: number[]) => {
        let best = Infinity;
        for (let i = 0; i < a.length; i += 2) {
            const px = a[i];
            const py = a[i + 1];
            for (let j = 0; j < b.length; j += 2) {
                // vertex to vertex
                const vx = b[j];
                const vy = b[j + 1];
                const dvv = Math.hypot(px - vx, py - vy);
                if (dvv < best) best = dvv;
            }
            // vertex to edges
            for (let j = 0; j < b.length; j += 2) {
                const ax = b[j];
                const ay = b[j + 1];
                const bx2 = b[(j + 2) % b.length];
                const by2 = b[(j + 3) % b.length];
                const d = pointSegDist(px, py, ax, ay, bx2, by2);
                if (d < best) best = d;
            }
        }
        // also check other direction vertex->edge
        for (let i = 0; i < b.length; i += 2) {
            const px = b[i];
            const py = b[i + 1];
            for (let j = 0; j < a.length; j += 2) {
                const ax = a[j];
                const ay = a[j + 1];
                const bx2 = a[(j + 2) % a.length];
                const by2 = a[(j + 3) % a.length];
                const d = pointSegDist(px, py, ax, ay, bx2, by2);
                if (d < best) best = d;
            }
        }
        return best;
    };

    // Build adjacency graph where two polys are connected if they overlap or are within tolerance
    const n = tp.length;
    const adj: number[][] = Array.from({ length: n }, () => []);
    for (let i = 0; i < n; i++) {
        for (let j = i + 1; j < n; j++) {
            const a = tp[i].points;
            const b = tp[j].points;
            let connected = false;
            try {
                // overlap check (any intersection)
                if (polygonIntersectionSAT(a, b)) connected = true;
            } catch {
                // ignore SAT failures
            }
            if (!connected) {
                const d = polyMinDist(a, b);
                if (d <= tolerance) connected = true;
            }
            if (connected) {
                adj[i].push(j);
                adj[j].push(i);
            }
        }
    }

    // find connected components
    const visited = Array.from({ length: n }, () => false);
    const groups: number[][] = [];
    for (let i = 0; i < n; i++) {
        if (visited[i]) continue;
        const stack = [i];
        const comp: number[] = [];
        visited[i] = true;
        while (stack.length) {
            const u = stack.pop()!;
            comp.push(u);
            for (const v of adj[u]) {
                if (!visited[v]) {
                    visited[v] = true;
                    stack.push(v);
                }
            }
        }
        groups.push(comp);
    }

    // Merge polygons within each connected component using polybooljs to preserve concavities.
    // polybooljs uses format: { regions: Array<Array<[x,y]>>, inverted: boolean }
    const toPoly = (pts: number[]) => {
        const region: [number, number][] = [];
        for (let i = 0; i < pts.length; i += 2) region.push([pts[i], pts[i + 1]]);
        return { regions: [region], inverted: false } as any;
    };

    const fromPoly = (poly: any) => {
        const out: { id: string; points: number[] }[] = [];
        if (!poly || !Array.isArray(poly.regions)) return out;
        for (let r = 0; r < poly.regions.length; r++) {
            const region = poly.regions[r] as [number, number][];
            if (!region || region.length === 0) continue;
            const pts: number[] = [];
            for (const v of region) pts.push(v[0], v[1]);
            out.push({ id: String(r), points: pts });
        }
        return out;
    };

    const result: { id: string; points: number[] }[] = [];
    for (const comp of groups) {
        // union all member polygons using polybooljs
        let accum: any | null = null;
        const ids: number[] = [];
        for (const idx of comp) {
            ids.push(tp[idx].id);
            const poly = toPoly(tp[idx].points);
            if (!accum) accum = poly;
            else accum = PolyBool.union(accum, poly);
        }

        if (!accum) continue;

        // convert union result into flattened rings; poly.regions is an array of rings
        const merged = fromPoly(accum);
        // merged may contain multiple regions (holes handled as separate regions by polybooljs)
        for (let k = 0; k < merged.length; k++) {
            const item = merged[k];
            // construct id from component ids and index
            const outId = ids.join('-') + (merged.length > 1 ? `-${k}` : '');
            result.push({ id: outId, points: item.points });
        }
    }

    return result;
};

⚠️ 注意:需处理浮点误差,设置 tolerance=5px 合并邻近顶点

6️⃣ 完成检测 + 烟花特效 🎉

当图形覆盖目标区域 >98% 时,触发庆祝动画:

useEffect(() => {
    if (prev <= 98 && coverage > 98) {
        confetti.fire({ spread: 45, gravity: 0.5 }); // 使用 canvas-confetti
    }
}, [coverage]);

🌈 效果:学生拼对时,屏幕炸出彩色纸屑,成就感拉满

🤖 GitHub Copilot AI赋能

在整个开发中,Copilot 极大提升了效率:

场景 Copilot 的帮助
算法实现 输入注释“用 SAT 实现多边形碰撞检测”,自动生成完整函数
Konva API 不熟悉 onTransform?Copilot 给出示例代码
Tailwind 样式 “左侧题目列表,右侧画布,响应式布局” → 自动生成 className
状态管理 Zustand store 结构一键生成

⚠️ 但注意:

  • 几何计算、数值容差需人工校验
  • 业务逻辑要结合实际需求调整生成代码

🚀 自动化部署:一键上线 GitHub Pages

通过 GitHub Actions,每次 git push 自动构建并部署:

# .github/workflows/nextjs.yml
- name: Build with Next.js
  run: pnpm next build
- name: Upload artifact
  uses: actions/upload-pages-artifact@v3
  with:
      path: ./out # output: 'export' 生成的静态文件

✅ 结果:老师只需打开一个链接,即可在教室大屏使用

📦 如何运行项目?

git clone <项目地址>
cd tangram-app
pnpm install
pnpm dev        # 开发模式

# or
pnpm prod       # 导出静态页并本地预览

🌈 总结:技术 + 教育 = 无限可能

这个项目让我深刻体会到:

  • AI 工具不是替代开发者,而是放大生产力
  • 解决真实问题,比写“玩具项目”更有价值
  • 教育场景的技术需求,往往被低估但极其重要

💬 如果你也有一位“需要教学工具”的老师家人,不妨试试用代码帮他们解决问题!

🔗 开源 & 交流

posted on 2025-11-24 00:06  阿明Drift  阅读(0)  评论(0)    收藏  举报