AI协助 一周打造「七巧板益智小游戏」:从零高效开发教学工具

news/2025/11/24 0:07:44/文章来源:https://www.cnblogs.com/amingdrift/p/19261934

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

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

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

我第一反应是:这还不简单?搜一个不就行了?结果逛遍小红书、淘宝、甚至 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 distanceconst 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 vertexconst vx = b[j];const vy = b[j + 1];const dvv = Math.hypot(px - vx, py - vy);if (dvv < best) best = dvv;}// vertex to edgesfor (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->edgefor (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 toleranceconst 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 componentsconst 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 polybooljslet 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 ringsconst 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 indexconst 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.jsrun: pnpm next build
- name: Upload artifactuses: actions/upload-pages-artifact@v3with:path: ./out # output: 'export' 生成的静态文件

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

📦 如何运行项目?

git clone <项目地址>
cd tangram-app
pnpm install
pnpm dev        # 开发模式# or
pnpm prod       # 导出静态页并本地预览

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

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

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

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

🔗 开源 & 交流

  • GitHub 项目地址:👉 tangram-app
  • 在线体验地址:👉 https://amingdrift.github.io/tangram-app
  • 欢迎 Star / Fork / 提 Issue

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/974374.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【MCP系列】用 MCP 扩展 AI 编辑器:从零开发一个自己的MCP服务

【MCP系列】用 MCP 扩展 AI 编辑器:从零开发一个自己的MCP服务本文MCP协议,手把手教你构建一个自己的MCP Server,在AI编辑器实现通过自然语言指令调用自己写的工具脚本。随着 AI 编辑器(如 Copilot、通义灵码、Tra…

VB6版MP3文件信息编辑器 - 开源研究系列文章 - 个人小作品

VB6版MP3文件信息编辑器 - 开源研究系列文章 - 个人小作品Posted on 2025-11-24 00:00 lzhdim 阅读(0) 评论(0) 收藏 举报 这次整理VB6编写的MP3文件的ID3v1信息编辑器。该应用比较简单,主要是对于ID3v1信…

手把手教你用 React + Zustand 打造 Windows 风格可拖拽,缩放,多窗口 Modal 组件

手把手教你用 React + Zustand 打造 Windows 风格可拖拽,缩放,多窗口 Modal 组件记录仿Windows风格的可拖拽、缩放、多窗口 Modal 组件的实现还在为每个弹窗写重复的拖拽、缩放、Z轴代码而烦恼吗?还在复制粘贴 onMo…

ImGui Learn Data Day 1

ImGui Learn data Day 1ImGui::Begin("Hello Gui");static float u = 0;static bool an = 0;if (ImGui::CollapsingHeader("Settings"))//展开条{//如果点击就展开或者关闭ImGui::SliderFloat(&qu…

OI 笑传 #34

夜の東側今天是 bct Day4,赛时 \(75+30+40+0=115\),rk 54。 T1 挂分原因仍未知,直接原因是没有大样例,然后是用数据结构维护的贪心,比较恶心。 赛时比较爆炸,T1 连想带调用了 3h,导致比较简单的 T2,T3,T4 没有…

【MCP系列】介绍一个我自己开发的MCP工具:MCP Shipit

【MCP系列】介绍一个我自己开发的MCP工具:MCP Shipit介绍一个我自己开发的MCP工具:MCP Shipit,欢迎来github提issue,star!在AI编辑器日益普及的今天,我们可以通过自定义工具来扩展AI的能力。本文将介绍如何从零开…

第34天(简单题中等题 数据结构)

打卡第三十四天 1道简单题+两道中等题题目:思路:哈希表+遍历 代码: class Solution{ public:int maxSum(vector<int>& nums){unordered_map<int,int> hash;int ans = -1;for(int x: nums){int maxd…

3. Gin RESTful API 开发

3. Gin RESTful API 开发 3.1 RESTful API简介 3.1.1 RESTful API 定义REST(Representational State Transfer,表现层状态转换)是一种软件架风格、设计风格,而不是一种标准。它提供了一组设计原则和约束条件,主要用…

说课逐字稿2

尊敬的各位评委老师: 大家好!今天我说课的题目是《健康数据小哨兵——循环选择嵌套》。 面对海量健康数据,如何从“人工低效核对”跨越到“智能精准监测”?这是本课要解决的核心问题。我将从分析策略、教学过程、教…

Codeforces Round 1066 (Div. 1 + Div. 2) 做题记录

Dashboard - Codeforces Round 1066 (Div. 1 + Div. 2) - Codeforces Problem - A - Codeforces 题意: 平衡数组定义为:若 \(x\) 存在,则存在 \(x\) 个 \(x\),求给定数组至少删去多少数变成平衡数组。 题解: 若 \…

2025.11.23总结

一些思考和建议 1. 现状分析:从“稀缺”到“饱和”的本质 过去的“高工价”源于互联网和移动互联网的爆炸性增长,对代码的渴求是海量的。那时,一个能实现功能的程序员就是稀缺资源。 现在的“不稀缺”,准确地说,是…

字符串常见操作

字符串常见操作查找: find 字符是否包含在字符串中,在就返回这个子字符串开始位置的下标,否则就返回-1 count 返回出现的次数,返回某个子字符串在整个字符串中出现的次数,没有就返回0 index 和index一样但是会报…

广州比较靠谱的留学中介

广州比较靠谱的留学中介一、广州留学中介怎么选?这些疑问你有吗?作为从事12年国际教育规划师,我经常被广州的学生和家长问到:广州的留学中介哪家更靠谱?申请英国研究生,广州哪家中介成功率更高?广州本地口碑最好…

SpringBoot 2.x - 3.x 踩坑记录

一、现状:JDK17 SpringBoot 2.6.8 Gradle 7.6二、目标:JDK17 gradle 8.10.2 SpringBoot 3.5.6 无漏洞版本三、问题: 1. import javax.*报错 原因:SpringBoot 2.x 使用 javax 规范,SpringBoot 3.x 使用 jakarta 规…

广州比较好的留学机构

广州比较好的留学机构一、广州留学机构怎么选?这五个问题你考虑过吗?作为一位拥有十年经验的国际教育规划师,我经常被广州的学生和家长问及如何挑选留学中介。在选择过程中,许多人会陷入纠结:广州留学机构哪家更靠…

AtCoder Beginner Contest 433 题解

只写了 A-F。A - Happy Birthday! 4 开局就绷不住了,晚上脑子有点不清醒直接暴力 check 到 \(10^7\) 没想到直接过了。代码。但是正解还是要推式子的,设 \(k\) 年后为答案,则有 \(X + k = Z(Y + k)\),移项后得到 \…

使用 Lua 语言识别英文数字验证码

接下来我将使用 Lua 语言来实现类似的功能。Lua 是一种轻量级的嵌入式脚本语言,广泛应用于游戏开发、嵌入式系统等领域。 一、安装与配置安装 Lua 更多内容访问ttocr.com或联系1436423940 可以从 Lua 官网 下载并安装…

广州10大出国留学机构

广州10大出国留学机构作为从事国际教育规划工作十二年的资深顾问,张明,我经常需要为广州的学生和家长解答关于留学机构的选择问题。许多人在搜索时会输入各种疑问,比如广州的留学中介哪家更靠谱?申请美国研究生该选…