feat: add hex trail puzzle, level editor, and visual design guide

This commit is contained in:
cabinw
2026-09-08 21:45:44 +08:00
commit ef2724bb2e
63 changed files with 15036 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
dist/
node_modules/
.DS_Store
*.log
# Separate Cocos Creator repository; manage its history inside that project.
/wechat/HexTrail/
+34
View File
@@ -0,0 +1,34 @@
# Repository Guidelines
## Project Structure & Module Organization
HEX TRAILS is a dependency-free JavaScript hexagonal track puzzle. `index.html` and `style.css` define the responsive browser interface. Keep gameplay, solving, generation, validation, and difficulty scoring in `src/core.js`; coordinate conversion and curve sampling belong in `src/geometry.js`. `src/renderer.js` exposes engine-independent drawing commands plus the Canvas adapter. Browser controls and persistence live in `src/app.js`.
`levels/campaign.json` and generated `src/levels.js` contain the campaign. `tests/` holds Node tests, `scripts/` provides development utilities, and `docs/` explains design and portability. `cocos/HexTrailsBoard.ts` is the native engine adapter example.
## Build, Test, and Development Commands
Use Node.js 20 or newer. No dependency installation is required.
- `npm run dev`: serve the application at `http://localhost:4173`.
- `npm test`: run the Node built-in test suite.
- `npm run build`: create a static deployment in `dist/`.
- `npm run levels`: regenerate both campaign data files using deterministic seeds.
Open the application over HTTP because it uses ES modules. Do not commit `dist/` or `node_modules/`.
## Coding Style & Naming Conventions
Use ES modules, two-space indentation, camelCase functions, and PascalCase classes. No formatter or linter is configured. Follow surrounding conventions and avoid unrelated formatting changes. Keep DOM and storage APIs out of the model and geometry modules. Keep gameplay rendering portable: polygons, lines, circles, and PNG atlas sprites. Store generated art and prompts in `assets/`; source rectangles live in `src/sprites.js`. `src/path-math.js` shares geometry between rendering and swept collision detection.
## Testing Guidelines
Name tests `tests/*.test.js`. Cover rule changes with meaningful fixtures, particularly board rotation, swept collisions along curves, blockers, loops, undo, and malformed imports. Campaign changes must validate and solve completely. Test responsive gameplay and editor flows after interface changes. No coverage percentage is enforced.
## Commit & Pull Request Guidelines
No Git history was available when this project was created. Use focused, imperative commit subjects. Describe behavior changes, link relevant issues, and include test results and screenshots for visual changes. State native-engine verification limits explicitly.
## Agent-Specific Instructions
Before calling MCP tools, read `/Users/wukaibing/.grok-go/agents-guide.md` for version-specific GrokGo instructions.
+125
View File
File diff suppressed because one or more lines are too long
+164
View File
@@ -0,0 +1,164 @@
# HEX TRAILS 设计规范
版本:1.0 · 2026-09-08 · 适用:网页、手机触摸界面与 Cocos 移植。
本文件规定设计角色和使用方式;`src/design-tokens.js` 是颜色数值的唯一来源。修改数值后运行 `npm run design`,同步生成 CSS、JSON、面板 PNG、对比度报告和可视化手册 `Design.html`。HTML 为单文件离线版本,包含原始图片、色值复制、组件示例、弹窗动效与本文全文。新界面不得另写一套 HEX 或用滤镜临时拼配色。
## 1. 设计方向
**清爽、明快、易读的哑光解谜游戏。** 环境采用冷蓝灰,棋盘采用暖白,主操作采用紫色。鲜艳的蓝、粉、黄、紫主要留给棋子;高饱和度不平均分配给每一个按钮。
- 棋子是第一视觉焦点,Level 和步数是第二层,工具是第三层。
- 纯色背景,无纹理、光晕、装饰圆盘。底板要看得出六边形分格。
- 棋子保持正顶视角、六边等长、小圆角;无侧面厚度和凹槽。
- 图标采用用户选定的**生成图集原始造型**,不得静默替换为另一套代码图形。
- 扁平面板搭配生成图标;保留图标原图细节,不用黑化、白化滤镜重塑外观。
## 2. 成熟模式如何用于本项目
采用 Material Design 3 的**语义颜色角色**:先定义颜色承担的职责,再给组件绑定角色;`primary / onPrimary`、`surface / textPrimary` 成对使用。采用层级明确的表面和主次操作,避免每个工具都争夺视觉注意力。[Material 3 主题与颜色角色](https://developer.android.com/codelabs/m3-design-theming?hl=en)
采用 W3C 的对比度方法作为验收约束:普通文字至少 4.5:1,大号文字和必要的非文字视觉标识至少 3:1。颜色之外还要用文字、图标、位移或选中标记表达状态。[文字对比度](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) · [非文字对比度](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html)
这些来源提供方法和约束;下面的 HEX、游戏形态、动效时长是本项目的设计选择,并非 Material 官方游戏模板。当前为固定浅色主题,不随系统壁纸自动换色。
## 3. 颜色体系
### 3.1 环境与内容
| Token | 色值 | 用途与配对 |
|---|---|---|
| `background` | `#A8BECF` | 页面背景,搭配主文字或辅助文字 |
| `board` | `#F2EEE5` | 棋盘底格,暖白区分冷色背景 |
| `boardGrid` | `#7C8B94` | 六边形边界,细线且清楚可见 |
| `track` | `#6D808B` | 轨道中心线 |
| `trackHalo` | `#DFE3E2` | 轨道浅色外缘,不能盖断接头 |
| `surface` | `#F7F6F2` | 弹窗与编辑面板 |
| `textPrimary` | `#273B50` | Level、步数、标题、主要标签 |
| `textSecondary` | `#314A5F` | 说明与次要标签;用字号和字重区分层级 |
辅助文字不能通过大幅降低透明度来变淡。小字必须用规定的文字色,不使用旧版的浅紫灰。棋盘与背景的区别还包括完整轮廓、清晰分格和轨道,不只依赖色相差。
### 3.2 操作与状态
| Token / 配对 | 色值 | 用法 |
|---|---|---|
| `primary / onPrimary` | `#6652B8 / #FFFFFF` | 下一关、试玩、主要确认、当前选中关卡 |
| `secondary / textPrimary` | `#E4EAF0 / #273B50` | 重玩、普通选关、编辑器次级动作 |
| `utility / onUtility` | `#52697E / #FFFFFF` | 选关、设置、撤销、重来、提示的图标底板 |
| `selection / onSelection` | `#DFD9F3 / #3F327A` | 可扩展的弱选中状态 |
| `warningSurface / warning` | `#F9E3D2 / #A44825` | 阻挡、导入失败、不可解提示 |
| `reward` | `#B9760C` | 星星轮廓与奖励强调,限制面积 |
| `input / textPrimary` | `#FFFFFF / #273B50` | 输入框、选择框 |
| `disabled / onDisabled` | `#CBD4DD / #526579` | 原生移植的禁用表面和文字参考 |
| `scrim` | `#273B50`,透明度 48% | 模态遮罩,背景模糊 5px |
| `focus` | `#3F327A` | 键盘焦点外框 |
一张弹窗只安排一个高强调主操作。工具按钮统一用蓝灰底板,不再分别使用蓝、粉、黄。提示无需绿色“免费”角标;现有角标沿用中性表面。成功不额外引入薄荷绿,使用主操作色和星星表达。
编辑器的工具、方向、旋转和文件操作同样使用 `utility / onUtility`,保证生成白色图标在底板上可辨认;选中工具与试玩使用 `primary / onPrimary`。
### 3.3 棋子色与图片事实
棋子颜色不代表功能、品质或阵营。现有四枚棋子来自已接受的图片;以下是采样区域中心的代表值,图片像素允许存在小幅变化:
| Token | 代表色 | 方向箭头 |
|---|---|---|
| `tileBlue` | `#2392FD` | 白色 |
| `tilePink` | `#FC3E80` | 白色 |
| `tileYellow` | `#FDBC28` | 深蓝灰 `textPrimary` |
| `tilePurple` | `#874FFD` | 白色 |
黄色上的白箭头对比不足,因此使用深色箭头。四种棋子均可通过方向箭头、位置和运动判断,不要求辨认颜色才能通关。未经用户确认,不重新生成或调色替换已接受的棋子。
### 3.4 禁止的搭配
- 白色小字放在黄色、浅蓝灰或浅绿色按钮上。
- 使用同样的浅灰同时承担背景、底板和轨道。
- 紫色选中、绿色确认、黄色提示、粉色重来同时作为同等级操作。
- 用 `brightness / invert / saturate` 临时调面板色;这样无法核对实际颜色。
- 将语义警告色铺满常规棋盘,或只通过红色表示阻挡。
## 4. 组件规范
### 游戏主界面
顶部仅显示居中的 Level 与其下方的剩余步数。左侧选关,右侧设置。没有 Logo、关卡副标题、难度、待离场数量、角度读数或底部导航。
旋转操作为放大的生成箭头,下方分别写“左转”“右转”,不加矩形底板。文字用主文字色;白色箭头允许轻微蓝灰轮廓阴影增强边缘识别,不能改写原图轮廓。它们须保留至少 44×44 的触摸区域。
撤销、重来、提示使用相同的工具底色和图标规格,下方标签不少于 12px。禁用时确实停止操作,网页透明度为 55%;不得只有颜色变化却仍可点击。
### 棋盘和轨道
先画全部底格,再画分格线,再统一画轨道外缘与中心线,最后画棋子。相邻格子连接端口必须完全重合。内部不留透出背景的缝;外边缘需为线帽预留空间。分格线宽取 `max(1.25px, 格子半径 × 0.038)`。
棋子保持微小圆角。所有游戏运动、碰撞和绘制使用同一坐标体系。图片只能在原图不透明、无杂点的区域采样;不得带入棋盘格底纹或透明噪点。
### 弹窗
所有弹窗使用暖白表面,标题主文字色、正文辅助文字色。主按钮紫色配白字,次按钮中性表面配深字。正文推荐 13–14px、行高 1.75;弹窗圆角 24px、左右留白至少 24px。
弹窗必须是真正的模态层:使用 `showModal()`,背景变暗并模糊,鼠标、触摸和键盘均不能操作后方控件。结果弹窗不能通过点击背景意外消失;下一关或重玩后关闭。设置/选关允许点击外部关闭,但该次点击不可传给后方按钮。
选关数字与难度说明保留,当前关卡使用主操作色,并附当前状态。未通关不以灰到不可读的文字表示。
## 5. 图标与图片资产
| 内容 | 当前资源 | 使用规则 |
|---|---|---|
| 生成图标 | `assets/ui-icons-generated.png` | 原始生成图的逐字节副本,1254×1254 |
| 图标裁切 | `src/ui-skin.js` 中 `ICONS` | 按主图形测量,四周留白后裁成正方形并居中 |
| 面板颜色 | `assets/ui-surfaces.png` | 从 token 输出的纯色 PNG,13 个 32×32 色块 |
| 棋子 | `assets/tiles-matte-clean-v8.png` | 中央干净色块采样 + 共用圆角正六边形遮罩 |
图标生成源文件:`exec-64bcb2ee-fa31-48f9-9a7e-3e2c8c327520.png`。这一套包括选关、设置、左右转、撤销、重来、提示、星星等。`ui-icons-flat.png` 是此前代码重绘版,保留作历史文件,**不再被当前界面引用**。
面板仅采样每个色块中央的 28×28 区域,四周预留 2px,防止放大和双线性过滤时混入相邻色块。
不重绘用户已选图标、不换形状、不强制黑白化。当前白色图标置于 `utility` 底板上;旋转箭头和星星可用 token 定义的轻微轮廓阴影。正方形 SpriteFrame 等比显示,不能把窄图标拉宽。SVG 和 Unicode 字符不得临时顶替正式图标。
生成原图透明区仍可能有细小边缘噪点;当前做的是主图形裁切,未宣称对原 PNG 完成像素级清理。若实机仍可见噪点,应清理同一素材并重新验收,不能静默更换图标系统。
## 6. 字体、间距与形状
| 层级 | 规格 |
|---|---|
| Level | 28px,粗体,居中 |
| 剩余步数数字 | 32px,粗体,居中 |
| 弹窗标题 | 24px,粗体 |
| 正文 | 13–14px,行高 1.75 |
| 操作标签 | 12–13px |
| 次要说明 | 至少 11px;不靠低透明度弱化 |
使用当前圆润系统字体回退栈,中文优先 PingFang SC / Microsoft YaHei。Cocos 上若要求字宽一致,应嵌入同一授权字体后验收,不能只复制字体名称。
间距使用 4px 基础单位,常用 8/12/16/24/32px。控件圆角 14px,弹窗 24px,小角标 8px。移动端安全区不得遮住底部操作,常规点击区至少 44×44px;图标本身可以小于点击区。
## 7. 动效、音效与反馈
- 按压:150ms,缩放到 94%;不移动命中区域。
- 弹窗:420ms,从上方 60px 和 96% 缩放进入;遮罩约 250ms。
- 星星:分别延迟 300/490/680ms 出现,每枚动画 400ms。
- 碰撞:总长 1250ms,先接近,再接触停顿并让阻挡棋子轻震,最后原路弹回;不改变模型中的位置。
- 音效:首次手势后启动,点击、旋转、离场、碰撞和通关各有短音;设置里可关闭并保存偏好。
- `prefers-reduced-motion` 下取消非必要位移、缩放和震动,仍保留状态文字。
- 手机棋盘禁用 WebKit tap highlight、选中文字和长按菜单,避免整块矩形高亮。
## 8. 验收与变更流程
当前生成的精确对比度见 `design/contrast-report.json`,来自 W3C sRGB 相对亮度公式;校验的是不透明 token 配对。代表结果:主文字/背景约 5.98:1,辅助文字/背景约 4.80:1,白字/主按钮约 6.06:1,轨道/底板约 3.55:1。
透明 PNG、抗锯齿、阴影、真实字体和禁用态仍需在实际画面检查。对比度报告不等于整个游戏已获得 WCAG 合规认证。
每次视觉修改按此顺序:
1. 明确修改哪个语义角色,优先复用现有角色。
2. 修改 `src/design-tokens.js`,运行 `npm run design`。
3. 检查自动生成的 PNG/CSS/JSON 和对比度报告。
4. 运行 `npm test`、`npm run build`。
5. 检查 390×844 手机布局,以及较矮屏幕;检查主界面、选关、设置、通关/失败和碰撞状态。
6. 验证弹窗不能穿透操作、图标居中、轨道接缝不断裂。
7. 如需替换已接受的图片,明确告知用户素材来源和变化,再更新本表。
Cocos 读取同一 JSON/JS token 和 PNG。`CandyUiSkin` 使用纯色 Sprite、圆角 Mask 和居中的图标;不得在原生侧另外猜颜色。本规范与适配组件已提供,原生场景尚需导入后做视觉验收。
+88
View File
@@ -0,0 +1,88 @@
# HEX TRAILS · 转转六角
竖屏休闲解谜游戏:让箭头棋子沿弯曲轨道离场,旋转底盘改变路线,避开沿途其他棋子。
## 运行与构建
Node.js 20+,无第三方运行依赖,无在线字体或 CDN。
```bash
npm run dev # http://localhost:4173
npm test # 规则、旋转、连续碰撞及关卡验证
npm run build # 静态网站输出到 dist/
npm run levels # 固定种子重新生成 24 关
npm run design # 从颜色 token 输出 CSS、JSON、PNG 与对比度报告
```
也可用 `node scripts/serve.mjs --dist` 预览构建产物。默认端口 4173,可用 `PORT=4174 npm run dev` 修改。请通过 HTTP 打开;同一局域网手机可以访问电脑 IP 和端口。
## 游戏规则
- 点击棋子,从箭头方向进入轨道;弯道自动转向,交叉处不换轨。
- 左右按钮或沿棋盘边缘拖动,将整块轨道底盘旋转 60°。**棋子位置与箭头保持不动**,底盘的格子位置与轨道配对一起旋转,实际路线改变。
- 棋子具有六边形占用范围。检测整段运动路径,包括弯道和擦碰;不能穿过其他棋子。碰撞时走到接触处,再退回原位。
- 点击或转盘各消耗 1 步,碰撞也扣步;撤销恢复棋盘、棋子、朝向和步数。没有倒计时。
- 提示会建议安全棋子,或高亮应该使用的旋转方向。
24 个关卡均按旋转与碰撞规则验证可解,且都至少需要一次旋转;难度 25–75,必要旋转 1–3 次。所有关卡可自由选择,通关进度保存在本机浏览器。配色不影响规则。
## 美术与界面
当前采用纯色蓝灰背景、暖白棋盘、明快哑光棋子与生成的扁平图标。界面采用单屏 HUD、大旋转箭头和底部工具栏;关卡地图、规则和难度放在弹窗里。配色、组件、素材和验收标准见 [Design.md](Design.md),颜色数值统一维护在 `src/design-tokens.js`。
设计师可直接打开 [Design.html](Design.html):包含可复制的色板、对比度表、实际图标与棋子、按钮状态、弹窗动效和完整规范。图片与样式已内嵌,分享这一个文件即可离线查看。开发服务中的访问地址为 `http://localhost:4173/Design.html`,同一局域网可使用电脑 IP 替换 localhost。修改 Design.md 或 token 后运行 `npm run design` 同步 HTML;`npm run build` 也会自动生成并复制到 dist。
390×844 下主要游戏内容完整显示。较小屏幕优先保留棋子可点击尺寸,必要时允许纵向滚动。
## 关卡工坊
可编辑 19/37 格底板、棋子方向和配色、15 种轨道配对、单格轨道旋转及整块底盘旋转。支持擦除、路径检查、撤销编辑、试玩、随机生成、本机单槽存档、JSON 导入导出。试玩不修改草稿、不计主线进度。
评分、生成器与游戏使用同一套碰撞规则,实时显示可解性、必要旋转和最短步数。不规则导入底板可以游玩,但如果不具备六重旋转对称性,会禁用整体旋转。导入失败保留现有草稿;不可解关卡仍可保存用于调试。
## 文件结构
| 文件 | 作用 |
| --- | --- |
| `src/core.js` | 规则、转盘、求解器、生成器、评分、验证 |
| `src/path-math.js` | 渲染与碰撞共享的曲线采样、连续扫掠计算 |
| `src/geometry.js` | 屏幕布局、命中检测与路径插值 |
| `src/renderer.js` | 引擎无关场景绘制及 Canvas 适配 |
| `src/sprites.js` | 图集源矩形与顶面锚点 |
| `src/app.js` | 浏览器 UI、输入、动画和存档 |
| `Design.md`、`src/design-tokens.js` | 设计规范与语义颜色的唯一数值来源 |
| `design/` | 生成的 token、对比度报告与组件样式绑定 |
| `assets/` | 实际游戏 PNG 素材及生成提示词 |
| `levels/campaign.json` | 24 关数据;生成脚本同步 `src/levels.js` |
| `cocos/HexTrailsBoard.ts` | Graphics + Sprite 图集适配示例 |
规则和数学层不依赖 DOM,碰撞不依赖浏览器物理引擎。Cocos 示例复用相同图集、碰撞、旋转与提示模型。浏览器文件选择/下载、本地存储及 HTML 控件在原生端需换成宿主接口。
## 验证范围
27 项自动测试通过,覆盖 24 关完整动作序列、100 个生成种子、旋转解锁、转盘撤销、弯轨寻路、邻格擦碰、连续扫掠防穿透、闭环、导入校验、16 组语义颜色对比度与生成图标裁切。浏览器验证了新图集、390×844 布局、碰撞回位和旋转扣步。Cocos 示例通过 TypeScript 语法检查,尚未在 Creator 场景或原生构建中运行验收。
[视频与规则说明](docs/video-analysis.md) · [难度与关卡数据](docs/level-design.md) · [Cocos 接入](docs/cocos-port.md) · [素材与提示词](assets/GENERATION.md)
### 图片 UI 资源
图标和棋子使用生成的 PNG 图集,按钮与弹窗使用 token 输出的纯色 PNG。网页通过 CSS 管理布局、圆角和状态;Cocos 使用 Sprite 与圆角 Mask。动态文字继续使用 Label,方便中文显示和本地化。
- `assets/tiles-matte-clean-v8.png`:新版平滑糖果棋子。
- `assets/ui-surfaces.png`:13 个由颜色 token 输出的纯色色块。
- `assets/ui-icons-generated.png`:用户选定的十六种生成图标,保留原图造型。
- `src/ui-skin.js`:两端共用的面板与图标裁切参数。
- `cocos/CandyUiSkin.ts`:Cocos SpriteFrame 与圆角 Mask 适配组件。
- `docs/ui-layout.json`:390×844 主界面位置参考。
Cocos 组件经过 TypeScript 语法检查,尚未在完整原生场景中逐像素验收。
当前背景为无花纹的蓝灰色 `#A8BECF`,工具统一蓝灰底色,主操作统一紫色。棋子采用明快哑光色与小圆角,黄色棋子的箭头使用深色以提高可读性。碰撞动画分为接近、接触停顿、被挡棋子震动和弹回,逻辑位置不变。
精简游戏界面只显示 Level、剩余步数、棋盘和操作区。关卡工坊仍可通过 `?editor=1` 访问,不占用游戏底部导航。
### 扁平控件、弹窗和音效
全套图标使用 `assets/ui-icons-generated.png`,按实际图形边界裁成正方形并居中,等比显示。旧的代码重绘版 `ui-icons-flat.png` 已退出当前界面。按钮和弹窗使用语义色面板。通关/失败结果为原生 modal dialog,背景遮罩及模糊,拦截底层触摸;星星按 0.30/0.49/0.68 秒依次出现。支持系统减少动态效果设置。
`src/audio.js` 用 Web Audio 合成轻量点击、离场、旋转、碰撞与结果音效,首次触摸后启用,可在设置里关闭并记住偏好。手机棋盘关闭 WebKit tap highlight 和长按菜单。
+73
View File
@@ -0,0 +1,73 @@
# 美术素材记录
本文件保留素材迭代记录,当前使用清单以 `Design.md` 和下方“当前图标与面板”章节为准。生成素材使用内置 image_gen;原始 PNG 保留在工作区,图集使用源矩形采样,与 Cocos SpriteFrame 的用法一致。
## 资源
- `tiles-candy-v2.png`:1254×1254,RGBA,四种厚边塑料六角棋子。箭头独立绘制,方向变化不会旋转光照。
- `background-candy-v2.png`:历史竖屏背景,当前已改用纯色背景。
- `logo-candy-v2.png`:历史透明立体标题,当前游戏顶部不显示 Logo。
- `../src/sprites.js`:四个棋子的源矩形及顶面锚点,可转换为 Cocos SpriteFrame。
设计借鉴休闲手游的鲜艳玩具材质、紧凑关卡 HUD、大触摸按钮与底部道具栏。参考产品:[Voodoo Block Jam 官方商店页面](https://apps.apple.com/us/app/block-jam-3d-block-puzzle/id1618805694)。未使用现有游戏的贴图、品牌或 UI 截图。
## 最终提示词
### tiles
Use case: stylized-concept. Asset type: production mobile casual puzzle GAME SPRITE ATLAS, transparent background. Create ONE 1024x1024 transparent PNG sprite sheet, clean 2 by 2 grid of four identical size hexagonal game tokens, centered respectively at (256,256), (768,256), (256,768), (768,768). Each token occupies at most 400x400 pixels, never crosses its 512px grid cell. Tokens are flat-top regular hexagons: sharp left/right points, horizontal upper and lower edge, with softly rounded corners. Top face viewed straight overhead, visually undistorted regular hexagon; thick extruded lower side wall visibly projects DOWN by 45 pixels, like a premium chunky plastic toy button. Strong broad bevel rim, softly domed smooth face, beautiful soft plastic specular highlights from upper left, subtle ambient occlusion in side wall. Four colors: top left electric sky blue, top right candy coral pink, bottom left golden apricot orange, bottom right violet purple. Same geometry and fixed upper-left lighting for all four. Center faces totally blank, NO arrow, no text, no number, no symbol. White arrows will be drawn by game code. Style: polished approachable 3D casual mobile puzzle game art, playful glossy toy assets at quality of leading mobile casual game studios. TRUE transparent alpha around all tokens, no checkerboard illustration, no background panel, no ground plane, no floor. Keep plenty of transparent margin, crisp silhouette. This is a usable sprite atlas, not a UI mockup.
### background
Use case: stylized-concept. Asset type: portrait casual mobile puzzle game BACKGROUND, production asset. A very polished soft 3D toy-world background with cool blue-lavender colors. Portrait 1024x1536. Central 85 percent of the canvas is beautifully uncluttered pale periwinkle gradient, near-white lavender in center, slightly richer lavender along edges. ONLY at very outer edges and corners, a few softly blurred chunky pastel plastic hexagons, small fluffy cloud puffs, rounded abstract pebble forms in blue, lilac, and peach. Gentle upper-left studio lighting, subtle magical floating atmosphere, rich yet calm rendered material. Design for a colorful hypercasual puzzle game, with strong mobile game character, not a website. Most central space will be overlaid by interactive game board and UI. No characters, no board, no buttons, no tiles in center, NO text, NO logos, no watermarks, no fake UI controls. Full bleed vertical background image.
### logo
Use case: logo-brand. Asset type: transparent title-logo sprite for a polished colorful 3D casual mobile puzzle game. Exact text: "HEX" on first line and "TRAILS" on second line. Wide compact two-line stacked lettering, thick rounded playful block capitals, bold white fronts with light ice-blue bevels, saturated periwinkle-blue extruded sides, thin deep-indigo outline, soft dimensional shadow. Attractive mobile puzzle game title, legible at small 160px width. Letters slightly bouncy but orderly. Add one small glossy blue hexagonal toy token subtly tucked behind upper-right of HEX; no other symbols. Fully transparent background, no rectangle, no checkerboard. Square 1024x1024 image, logo centered, wide composition filling 85% width and 65% height, generous alpha margins, no watermark. Consistent friendly premium toy aesthetic with blue, purple, pink and orange game tokens. Original branding; do not copy any existing game logo.
## V3:平滑棋子与图片 UI(2026-09-08)
使用内置 `image_gen.imagegen` 生成;完整初始提示词见 `prompts-v3.json`。PNG 原图未经脚本重绘或裁切;网页和 Cocos 使用 SpriteFrame 矩形采样。
- `tiles-candy-v3.png` ← `exec-069fc427-2abf-414e-961c-6c9f2e204a90.png`
- `ui-icons.png` ← `exec-817387b2-7cd4-4085-bc70-a0b63f39ba6b.png`
- `board-tray.png` ← `exec-6307d99d-cd3f-4b01-b982-6aaec1bf5424.png`
- `ui-panels.png` ← `exec-1b7c96a8-0445-4807-b17c-e7a2bc173dc7.png`
面板编辑提示词:
> Edit this exact 1024x1536 sprite sheet. Preserve the six rounded button shapes, colors, positions and sizes exactly. REMOVE ALL background and ALL colored glow outside the six buttons. Real fully transparent alpha=0 everywhere outside their rounded silhouettes, including between them. No dark backdrop, no gradient background, no ambient glow, no shadows outside buttons. Buttons themselves fully opaque. Clean production transparent PNG UI atlas.
该版本生成器未完全移除外部光晕,当时通过内部矩形采样使用;当前已替换为 token 输出的纯色面板,不再引用这些旧面板和旧图标。另一次无参考图尝试仍有光晕,未采用。
## V4:严格顶视角(2026-09-08)
内置 image_gen 生成 `tiles-candy-v4.png`,完整提示词见 `prompt-v4.txt`。源图 `exec-6aa596e4-1074-4c02-96a0-3c4e73457664.png` 为 1347×1167;未修改原始像素。运行时 Sprite 显示宽高比固定为 2:√3,中心锚点为 (0.5,0.5),半径为格子半径的 0.985;网页与 Cocos 共用参数。取消圆形底盘和底格侧面厚度。
## V6:明快哑光色(2026-09-08)
内置 image_gen 生成 `tiles-matte-v6.png`,完整提示词见 `prompt-v6.txt`。采样原图 `exec-a36f3ce3-18ba-4bf3-a354-a183dd970cf9.png`,1347×1167。湖蓝、蜜桃粉、杏黄、柔紫,小圆角,无侧面厚度。替代未采用的灰暗 V5。背景改为暖米色纯色 #e2ddd2。
## V7:提高纯度和背景对比(2026-09-08)
内置 image_gen 生成 `tiles-matte-v7.png`,完整提示词见 `prompt-v7.txt`。原图 `exec-e756eb74-154c-4712-8705-6f044e0dc5b5.png`,1293×1217,未经脚本修改像素。纯蓝、玫粉、橙黄、紫色,提高饱和度;保留哑光和小圆角。裁切参数由网页和 Cocos 共用。
## 边缘杂点处理
两次 image_gen 清理尝试分别生成了不透明棋盘格和仍有杂点的边缘,均未接入。最终继续使用 V7 的内部干净色块;`tileSample` 只采样中央 20%,`roundedHex` 在网页和 Cocos 中提供一致的渲染遮罩。源 PNG 未做脚本像素修改。此方案修复游戏显示,不代表原始整张 PNG 的边缘已被清理。
最终内部色块取自清理版 tiles-matte-clean-v8.png(RGB,1293×1216),原图外围有不透明棋盘格,但只采样中央颜色区域并施加轮廓遮罩,保证内部完全不透明且外围无杂点。V7 内部 alpha 也存在不均匀,故不再使用 V7 作为采样来源。
## 当前图标与面板(2026-09-08)
用户选定的扁平图标使用内置 `image_gen.imagegen` 文生图模式生成,无参考图片。返回源图 `exec-64bcb2ee-fa31-48f9-9a7e-3e2c8c327520.png` 为 1254×1254;逐字节复制为 `ui-icons-generated.png`。提示要求的 1024×1024 是请求尺寸,实际渲染与裁切采用返回尺寸。当前不引用此前代码重绘的 `ui-icons-flat.png`。
完整生成提示词:
```text
Flat minimal game UI icon atlas, 1024x1024, precise 4 columns x 4 rows of equal 256px cells. Each icon centered EXACTLY in its own cell with balanced optical weight, bounds maximum 160x160. TRUE transparent alpha background, no checkerboard. Monochrome SOLID WHITE icons, clean thick consistent vector-like silhouettes with subtly rounded line joins. NO 3D, no shine, no bevel, no outlines, no shadow, no glow, no gradients, no noise. Row1: gear, four-square grid, left chevron, X close. Row2: counterclockwise circular arrow, clockwise circular arrow, undo bent left arrow, restart circular arrow. Row3: lightbulb, five-point star, pencil, right-facing play triangle. Row4: straight up arrow, eraser, smooth S curve, regular hexagon. Exactly sixteen independent icons. No buttons or panels, no text.
```
网页与 Cocos 共用 `src/ui-skin.js` 中按主图形边界测量的正方形裁切,等比居中显示,不做颜色滤镜或造型替换。原 PNG 未做像素清理,透明区仍可能有边缘噪点;当前裁切以实际 UI 可见效果验收。
`ui-surfaces.png` 是由 `scripts/build-design.mjs` 直接按语义颜色 token 输出的 416×32 纯色 PNG,含 13 个 32×32 色块。它是确定性颜色数据资产,不属于生成图片,也没有修改生成图标。颜色规范见 `../Design.md`,数值来源为 `../src/design-tokens.js`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1015 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

+1
View File
@@ -0,0 +1 @@
Production game sprite atlas, four perfectly REGULAR flat-top hexagons in a 2x2 grid, blue pink orange purple. TRUE ORTHOGRAPHIC TOP VIEW, mathematically equal SIX side lengths and 120-degree corners, width to height = 2:sqrt(3). Flat top and bottom horizontal edges, vertices left and right. NO perspective, NO extrusion, NO visible side wall, NO lower lip, NO cast shadow, NO inset grooves, NO inner polygon, NO bevel outlines. Smooth solid candy-colored faces with only a very subtle soft highlight, nearly flat 2D style. Blank no arrows. Precisely centered each quadrant with equal generous padding. Transparent PNG background. All four identical geometry and size. Do not make squashed hexagons. Do not use 3D isometric button style.
+1
View File
@@ -0,0 +1 @@
Four cheerful MATTE game tokens as transparent PNG sprite atlas in a 2x2 grid. Top left fresh clear sky blue #69B9D0, top right peach coral pink #ED9CAE, bottom left warm apricot yellow #EFC16D, bottom right friendly light lilac #B3A0D7. Happy fresh medium-light pastel colors, NOT grey, NOT muddy or dark, NOT neon. Identical mathematically regular flat-top hexagons with small rounded corners (5% of side length). STRICT orthographic TOP view, width:height exactly 2:sqrt(3), equal six sides. Each hexagon is a uniform flat MATTE color, no sheen, no specular, no lighting effect, no gradient, no texture, no bevel, no extrusion, no shadow, no outline, no symbols. Four independent clean silhouettes, centered in quadrants with generous transparent gutters. Long straight sides and just subtly softened tips. White arrows will be added separately by the game. Actual transparent background.
+1
View File
@@ -0,0 +1 @@
undefined
+1
View File
@@ -0,0 +1 @@
Clean up this exact game sprite atlas. Remove ALL stray pixels, colored speckles, dirty fringe, edge noise, blotches and contamination around every hexagon and in every transparent region. Rebuild each silhouette with a perfectly smooth antialiased edge and the same small rounded corners. Exactly FOUR clean isolated hexagons, same layout, same sizes, same blue pink golden-yellow purple palette. Make each face uniformly matte solid color without grain, noise or mottling. Outside the four precise silhouettes alpha must be exactly ZERO. No colored rim, no outline, no shadow, no glow. Preserve top-down equal-sided regular hexagons. This is production UI art needing clean vector-like edges, not painterly texture.
+18
View File
@@ -0,0 +1,18 @@
[
[
"tiles-v3",
"Use case: stylized-concept. Production sprite atlas for a hexagonal casual puzzle game. ONE truly transparent PNG, square 1024x1024, clean 2x2 grid with one candy token in each quadrant. All four tokens have EXACTLY the same geometry and perfectly orthographic TOP-DOWN camera. A regular flat-top hexagon: horizontal top/bottom edges, left/right vertices, width to top-face-height ratio exactly 1.1547. Softly rounded corners. Four SOLID candy colors: sky blue top-left, strawberry pink top-right, tangerine orange bottom-left, grape purple bottom-right. Smooth gently pillowed candy face, soft restrained single highlight at upper left. NO inset, NO recess, NO groove, NO ring, NO frame, NO inner border, NO outline, NO embossed patterns. Absolutely blank face, no arrows or symbols. A VERY small thickness, only 3% of face height, subtly visible directly under the bottom edge, not an isometric extrusion. NOT tilted, no perspective, no trapezoid distortion. These pieces need to tile neatly together like a honeycomb; prioritize identical clean flat silhouettes and consistent scale. Each sprite centered in its 512x512 quadrant, face approximately 390px wide and 338px tall. Transparent padding around each, no contact between sprites. Soft plastic candy material, restrained polished render, minimal detail. True transparent alpha, no checkerboard illustration, no background, no floor shadow."
],
[
"ui-panels",
"Use case: stylized-concept. Production mobile game UI SKIN ATLAS, transparent PNG, portrait 1024x1536, exact 2 columns x 3 rows of equally sized 512x512 cells. Six completely blank rounded rectangular UI plates, one centered in each cell, approximately 420px wide x 270px tall, leaving transparent gutters. Consistent corner radius 65px, consistent size, and softly beveled candy plastic material with just a small thickness at bottom. No inset groove, no decorations, no symbols, NO TEXT. Top-left plate: very pale periwinkle-white, suitable as scalable dialog and panel background, opaque uniform center. Top-right plate: lavender purple. Middle-left: sky blue. Middle-right: strawberry pink. Bottom-left: sunny golden yellow. Bottom-right: fresh candy mint green. Very subtle lighter upper edge and slightly darker lower edge, flat central color area, clean simple mobile game look. Perfect front view, no perspective. Each blank plate must be separable from neighbors and suitable for NINE-SLICE scaling; central and straight edge portions have uniform color. True transparent alpha background, no checkerboard, no contact shadow outside the plates. This is usable raw UI art, not a mockup or composed game screen."
],
[
"ui-icons",
"Use case: stylized-concept. Production mobile casual game ICON SPRITE ATLAS. ONE square transparent PNG, 1024x1024, exact 4 columns x 4 rows, sixteen 256x256 cells. Exactly one centered icon per cell, icon occupies at most 160x160 pixels; ample alpha gutters. Simple soft candy plastic game icons, front facing with very subtle dimensional bevel, minimal detail, consistent visual weight, no surrounding buttons or frames. Row 1 left to right: pale violet settings gear; four rounded pale-blue squares as level grid; WHITE chevron left/back; WHITE X close. Row 2: WHITE counterclockwise curved rotation arrow; WHITE clockwise curved rotation arrow; WHITE undo arrow curling left; WHITE circular restart arrow. Row 3: golden yellow light bulb with white glow mark; golden five-point reward star; WHITE short diagonal pencil; WHITE play triangle pointing right. Row 4: WHITE straight upward arrow with thick shaft; WHITE eraser; WHITE single smooth S-shaped curved path; cyan diamond gem. NO text, NO letters, NO numbers, NO dark outlines, NO extra objects. All white icons should have barely perceptible pale blue side shading while remaining predominantly white. Truly transparent alpha background, do not draw a checkerboard. These exact sixteen icons will be cropped using their atlas cells."
],
[
"board-tray",
"Use case: stylized-concept. Asset type: single transparent PNG game board tray sprite. A perfectly circular shallow candy-plastic dish in cool pale periwinkle-white, viewed exactly orthographically from directly overhead. 1024x1024 square with transparent background. Dish fills 90% width, circular face remains a mathematically perfect circle (NOT ellipse), a single softly rounded outer edge, smooth flat pale lavender center, small darker lavender thickness visible straight below the bottom rim by only 20px. Very simple tasteful casual puzzle game board background. NO inner grooves, NO lines, NO hexagons, NO symbols, NO text. Center must be flat and uncluttered so track tiles will be drawn on it. Restrained light from upper left, no complex reflections, clean antialiased silhouette. True alpha transparency outside dish, no checkerboard, no floor plane."
]
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 B

+79
View File
@@ -0,0 +1,79 @@
import { _decorator, Component, Node, Sprite, SpriteFrame, Texture2D, UITransform, Mask, Graphics, Rect, Size, Vec2 } from 'cc';
// Copy src/ui-skin.js beside the shared core; do not re-enter crop values by hand.
import { PANELS, ICONS } from '../src/ui-skin.js';
const { ccclass, property } = _decorator;
type FrameRect = { x:number; y:number; w:number; h:number };
/** Apply to any Cocos UI node. Child labels stay native/localizable. */
@ccclass('CandyUiSkin')
export class CandyUiSkin extends Component {
@property(Texture2D) panels: Texture2D | null = null;
@property(Texture2D) icons: Texture2D | null = null;
@property panel = 'surface';
@property icon = '';
@property corner = 14;
private panelFrame: SpriteFrame | null = null;
private iconFrame: SpriteFrame | null = null;
private plate: Node | null = null;
private fill: Node | null = null;
private glyph: Node | null = null;
onLoad() {
this.refresh();
this.node.on(Node.EventType.SIZE_CHANGED, this.refresh, this);
this.node.on(Node.EventType.ANCHOR_CHANGED, this.refresh, this);
}
onDestroy() {
this.node.off(Node.EventType.SIZE_CHANGED, this.refresh, this);
this.node.off(Node.EventType.ANCHOR_CHANGED, this.refresh, this);
for(const n of [this.fill,this.glyph]) {
if(n?.isValid){const sp=n.getComponent(Sprite);if(sp)sp.spriteFrame=null;}
}
this.panelFrame?.destroy();this.iconFrame?.destroy();
this.plate?.destroy();this.glyph?.destroy();
}
private frame(sprite:Sprite, texture:Texture2D, f:FrameRect, current:SpriteFrame | null) {
const sf=current || new SpriteFrame(),r=sf.rect;
if(sf.texture!==texture || r.x!==f.x || r.y!==f.y || r.width!==f.w || r.height!==f.h) {
// Each component owns at most two frames, reused across style changes.
sprite.spriteFrame=null;
sf.texture=texture;sf.rect=new Rect(f.x,f.y,f.w,f.h);
sf.originalSize=new Size(f.w,f.h);sf.offset=new Vec2();sf.packable=false;
}
sprite.spriteFrame=sf;return sf;
}
private child(name:string, parent=this.node, sprite=true) {
const n=new Node(name);n.layer=this.node.layer;n.parent=parent;
n.addComponent(UITransform);
if(sprite){const sp=n.addComponent(Sprite);sp.sizeMode=Sprite.SizeMode.CUSTOM;sp.type=Sprite.Type.SIMPLE;}
return n;
}
refresh() {
const size=this.getComponent(UITransform);if(!size)return;
const width=Math.max(0,size.width),height=Math.max(0,size.height);
const x=(.5-size.anchorX)*width,y=(.5-size.anchorY)*height;
if(this.panels && PANELS[this.panel]) {
if(!this.plate) {
this.plate=this.child('Panel clip',this.node,false);
this.plate.addComponent(Mask).type=Mask.Type.GRAPHICS_STENCIL;
this.fill=this.child('Panel image',this.plate);
}
this.plate.active=true;this.plate.layer=this.fill!.layer=this.node.layer;
this.plate.setSiblingIndex(0);this.plate.setPosition(x,y);
this.plate.getComponent(UITransform)!.setContentSize(width,height);
this.fill!.getComponent(UITransform)!.setContentSize(width,height);
this.panelFrame=this.frame(this.fill!.getComponent(Sprite)!,this.panels,PANELS[this.panel],this.panelFrame);
// The 32px PNG contains a baked flat role color. Clip geometry supplies
// the corner radius; cap=0 is intentional and never used as a divisor.
const g=this.plate.getComponent(Mask)!.subComp as Graphics;
g.clear();
if(width && height){g.roundRect(-width/2,-height/2,width,height,Math.max(0,Math.min(this.corner,width/2,height/2)));g.fill();}
}else if(this.plate)this.plate.active=false;
if(this.icons && ICONS[this.icon]) {
this.glyph??=this.child('Image icon');const sp=this.glyph.getComponent(Sprite)!;
this.glyph.active=true;this.glyph.layer=this.node.layer;
const f=ICONS[this.icon];this.iconFrame=this.frame(sp,this.icons,f,this.iconFrame);
const scale=Math.min(width/f.w,height/f.h);
this.glyph.getComponent(UITransform)!.setContentSize(f.w*scale,f.h*scale);
this.glyph.setPosition(x,y);
}else if(this.glyph)this.glyph.active=false;
}
}
+165
View File
@@ -0,0 +1,165 @@
import { tileSample, roundedHex } from '../src/tile-shape.js';
import { _decorator, Component, Graphics, UITransform, Node, EventTouch, Vec3, Color, JsonAsset, Texture2D, Sprite, SpriteFrame, Rect, Mask } from 'cc';
import { Game, solveRotations, trace, validateLevel } from '../src/core.js';
import { layout, hitCell } from '../src/geometry.js';
import { drawScene } from '../src/renderer.js';
import { TILE_FRAMES, TILE_ASPECT } from '../src/sprites.js';
import { LEVELS } from '../src/levels.js';
const { ccclass, property, requireComponent } = _decorator;
type Point = { x: number; y: number };
type PooledSprite = { node: Node; sprite: Sprite; transform: UITransform; mask: Mask };
// The static tracks use Graphics; the exact generated textures use SpriteFrames.
// Arrows use a separate foreground Graphics so they stay above the sprite atlas.
class CocosPainter {
private cursor = 0;
private overlay = false;
private pool: PooledSprite[] = [];
private frames: SpriteFrame[] = [];
constructor(private root: Node, private board: Graphics, private arrows: Graphics, private w: number, private h: number, texture: Texture2D | null) {
if (texture) this.frames = TILE_FRAMES.map(f => { const frame = new SpriteFrame(); frame.texture = texture; const sample=tileSample(f); frame.rect = new Rect(sample.x, sample.y, sample.w, sample.h); return frame; });
}
begin(w: number, h: number) {
this.w = w; this.h = h; this.cursor = 0; this.overlay = false;
this.board.clear(); this.arrows.clear(); this.pool.forEach(p => p.node.active = false);
}
dispose() { this.frames.forEach(f => f.destroy()); }
private x(x: number) { return x - this.w / 2; }
private y(y: number) { return this.h / 2 - y; }
private color(hex: string) {
const rgb = parseInt(hex.slice(1,7),16), alpha = hex.length === 9 ? parseInt(hex.slice(7),16) : 255;
return new Color((rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255, alpha);
}
sprite(index: number, x: number, y: number, radius: number) {
if (!this.frames.length) return false;
if (!this.pool[this.cursor]) {
const node = new Node('Tile'); node.layer = this.root.layer; node.parent = this.root;
node.addComponent(UITransform);const mask=node.addComponent(Mask);mask.type=Mask.Type.GRAPHICS_STENCIL;
const face=new Node('Matte texture');face.layer=node.layer;face.parent=node;
const transform=face.addComponent(UITransform);const sprite=face.addComponent(Sprite);
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
this.pool.push({ node, transform, sprite, mask });
}
const p = this.pool[this.cursor++], f = TILE_FRAMES[index], w = radius * 2, h = w * TILE_ASPECT;
p.node.active = true; p.node.setSiblingIndex(this.cursor - 1);
p.node.getComponent(UITransform)!.setContentSize(w,h);
const stencil=p.mask.subComp as Graphics;stencil.clear();const shape=roundedHex(0,0,radius);
shape.forEach((v:Point,i:number)=>i?stencil.lineTo(v.x,v.y):stencil.moveTo(v.x,v.y));stencil.close();stencil.fill();
p.sprite.spriteFrame = this.frames[index];
p.transform.setAnchorPoint(f.anchorX, 1 - f.anchorY); p.transform.setContentSize(w, h);
p.node.setPosition(this.x(x), this.y(y), 0);
this.arrows.node.setSiblingIndex(this.root.children.length - 1);
this.overlay = true; return true;
}
polygon(points: Point[], fill: string) {
const g = this.overlay ? this.arrows : this.board;
points.forEach((p,i) => i ? g.lineTo(this.x(p.x),this.y(p.y)) : g.moveTo(this.x(p.x),this.y(p.y)));
g.close(); g.fillColor = this.color(fill); g.fill();
}
line(points: Point[], color: string, width: number) {
if (!points.length) return;
const g = this.overlay ? this.arrows : this.board;
points.forEach((p,i) => i ? g.lineTo(this.x(p.x),this.y(p.y)) : g.moveTo(this.x(p.x),this.y(p.y)));
g.lineWidth = width; g.strokeColor = this.color(color); g.stroke();
}
circle(x: number, y: number, r: number, fill: string) {
this.board.circle(this.x(x),this.y(y),r); this.board.fillColor = this.color(fill); this.board.fill();
}
}
@ccclass('HexTrailsBoard')
@requireComponent(UITransform)
@requireComponent(Graphics)
export class HexTrailsBoard extends Component {
@property(JsonAsset) levelAsset: JsonAsset | null = null;
@property(Texture2D) tileAtlas: Texture2D | null = null;
@property levelIndex = 0;
private game: any;
private viewport: any;
private elapsed = 0;
private moving: any = null;
private rotating: any = null;
private path: any = null;
private hintId: string | null = null;
private painter!: CocosPainter;
onLoad() {
const ui = this.getComponent(UITransform)!;
ui.setAnchorPoint(0.5,0.5);
const graphics = this.getComponent(Graphics)!;
graphics.lineCap = Graphics.LineCap.ROUND; graphics.lineJoin = Graphics.LineJoin.ROUND;
const arrowNode = new Node('ArrowOverlay'); arrowNode.layer = this.node.layer; arrowNode.parent = this.node;
arrowNode.addComponent(UITransform);
const arrowGraphics = arrowNode.addComponent(Graphics);
this.painter = new CocosPainter(this.node,graphics,arrowGraphics,ui.width,ui.height,this.tileAtlas);
this.node.on(Node.EventType.TOUCH_END,this.onTouch,this);
this.node.on(Node.EventType.SIZE_CHANGED,this.redraw,this);
this.loadLevel(this.levelAsset?.json ?? LEVELS[Math.max(0,Math.min(23,this.levelIndex))]);
}
onDestroy() {
this.node.off(Node.EventType.TOUCH_END,this.onTouch,this);
this.node.off(Node.EventType.SIZE_CHANGED,this.redraw,this);
this.painter?.dispose();
}
loadLevel(level: any) {
const errors = validateLevel(level); if (errors.length) throw new Error(errors.join('; '));
this.game = new Game(level); this.moving = null; this.rotating = null; this.path = null; this.hintId = null;
this.redraw(); this.emitState();
}
private onTouch(event: EventTouch) {
if (!this.game || this.moving || this.rotating) return;
const ui = this.getComponent(UITransform)!, loc = event.getUILocation();
const p = ui.convertToNodeSpaceAR(new Vec3(loc.x,loc.y,0));
const cell = hitCell(this.game.level.cells,this.viewport,p.x+ui.width/2,ui.height/2-p.y);
if (!cell) return;
const tile = this.game.tiles.find((t:any) => t.q===cell.q && t.r===cell.r); if (!tile) return;
const route = this.game.tap(tile.id); this.hintId = null; this.path = null; this.elapsed = 0;
if (route.status === 'exit') this.moving = { route,progress:0,duration:Math.min(1.7,0.55+route.steps.length*0.085) };
else if (route.status === 'blocked' || route.status === 'loop') {
this.path = route;
if (route.travelPoints) this.moving = { route,progress:0,duration:1.25 };
this.node.emit('trail-blocked',route);
}
this.redraw(); this.emitState();
}
rotateLeft() { this.rotate(-1); }
rotateRight() { this.rotate(1); }
private rotate(direction: number) {
if (this.moving || this.rotating) return;
if (this.game.rotate(direction).status !== 'rotated') return;
this.path = null; this.hintId = null; this.elapsed = 0;
this.rotating = { direction,angle:-direction*Math.PI/3 };
this.redraw(); this.emitState();
}
update(dt:number) {
if (!this.moving && !this.rotating) return;
this.elapsed += dt;
if (this.moving) { this.moving.progress = Math.min(1,this.elapsed/this.moving.duration); if (this.moving.progress >= 1) this.moving = null; }
if (this.rotating) { const t = Math.min(1,this.elapsed/0.42); this.rotating.angle = -this.rotating.direction*Math.PI/3*Math.pow(1-t,3); if (t>=1) this.rotating = null; }
this.redraw(); if (!this.moving && !this.rotating) this.emitState();
}
undo() {
if (this.moving || this.rotating) return;
this.game.undo(); this.path = null; this.hintId = null; this.redraw(); this.emitState();
}
restart() { this.loadLevel(this.game.initialLevel); }
hint() {
if (this.moving || this.rotating) return;
const solution = solveRotations({ ...this.game.level,tiles:this.game.tiles,moveLimit:this.game.moves });
const action = solution.actions[0];
if (!solution.solvable || !action) { this.node.emit('trail-no-hint',solution); return; }
if (action.type === 'rotate') this.node.emit('trail-hint-rotation',action.direction);
else { const t=this.game.tiles.find((p:any)=>p.id===action.id); this.path=trace(this.game.level,t,this.game.tiles); this.hintId=t.id; this.redraw(); }
}
private emitState() {
const busy=!!this.moving || !!this.rotating;
this.node.emit('trail-state',{moves:this.game.moves,remaining:this.game.tiles.length,orientation:this.game.orientation*60,won:this.game.won&&!busy,lost:this.game.lost,busy});
}
private redraw() {
if (!this.game) return;
const ui=this.getComponent(UITransform)!;
this.painter.begin(ui.width,ui.height);
this.viewport=layout(this.game.level.cells,ui.width,ui.height);
drawScene(this.painter,this.game.level,this.game.tiles,this.viewport,{path:this.path,hint:this.hintId,animation:this.moving,boardAngle:this.rotating?.angle||0});
}
}
+51
View File
@@ -0,0 +1,51 @@
/* Semantic role bindings. Layout remains in style.css; colors come from tokens.css. */
:root{color:var(--color-text-primary)}
body,.game-shell{background:var(--color-background)!important;color:var(--color-text-primary)}
button,input,select,dialog{color:var(--color-text-primary)}
.level-ribbon,.moves-bubble strong,h1,h2,h3,.win-overlay h3,.hud-pill strong{color:var(--color-text-primary)}
.moves-bubble>span,.level-heading h1,.board-instruction,.rotation-caption,.rotate-button,
.board-tools>button,.field,.section-label,.metric-row,.muted,.eyebrow,.editor-footnote,
.editor-footnote a,.editor-footnote a:visited,.selection-info,.editor-analysis p,.editor-analysis small,
.editor-analysis>div>span,.editor-footnote,.dialog-note,.win-overlay p,dialog p,dialog li,
.editor-panel .muted,.level-btn small,.tool-palette button,.directions button,.editor-rotation button,
.editor-actions button,.editor-board-tools button,.text-button,.return-editor,.hud-pill small,
.hud-pill>div>span{color:var(--color-text-secondary)}
.board-instruction{font-size:12px;line-height:1.5;min-height:20px}
.moves-bubble>span,.board-tools>button{font-size:12px}
.win-overlay p,dialog p,dialog li{font-size:13px;line-height:1.75}
.eyebrow{font-size:11px;letter-spacing:1px}
.level-btn small{font-size:10px}
[data-skin=secondary]{color:var(--color-text-primary)}
button[data-skin=primary],[data-skin=primary],.primary,.level-btn.current,.level-btn.current small,
.tool-palette button.active,.directions button.active{color:var(--color-on-primary)}
button[data-skin=utility],[data-skin=utility]{color:var(--color-on-utility)}
[data-skin=warning],.error{color:var(--color-warning)!important}
.ui-plate{filter:none!important;box-shadow:none!important}
.ui-icon img{filter:none!important;opacity:1!important}
.game-icon{width:44px;height:44px;flex-shrink:0}
.game-icon .ui-icon{width:28px;height:28px;margin:0;align-self:center}
.game-icon.help-trigger .ui-icon{width:30px;height:30px}
.tool-icon{width:56px;height:52px}
.tool-icon .ui-icon{width:28px;height:28px}
.rotate-button{color:var(--color-text-primary);min-width:92px;min-height:102px;gap:8px}
.rotate-button>.ui-icon{width:62px;height:62px;filter:drop-shadow(0 1px 1px var(--color-utility))}
.rotate-button b{font-size:13px}
.rotate-button.hinted{filter:none;animation:hint-bounce .7s infinite alternate}
.tool-icon small{color:var(--color-text-primary);font-size:9px;line-height:1.4;padding:3px 5px}
.result-emblem .ui-icon{width:54px;height:54px;filter:drop-shadow(0 1px 1px var(--color-reward))}
.level-btn .ui-icon{filter:drop-shadow(0 1px 1px var(--color-utility))}
button:hover:enabled{filter:none}
button:active:enabled{filter:brightness(.96);transform:scale(.94)}
button:disabled{opacity:.55;cursor:default}
button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid var(--color-focus);outline-offset:3px}
.primary{min-height:48px;font-size:14px}
.text-button{min-height:44px;font-size:12px}
.level-btn{min-height:60px;font-size:18px}
.dialog-close{width:36px;height:36px}
.dialog-close .ui-icon{width:22px;height:22px}
#sound-toggle{min-height:44px;padding:10px 14px;font-size:13px;margin-bottom:12px}
.board-toast{font-size:12px;line-height:1.5}
@media(min-width:700px){.editing .tool-palette{position:relative}}
dialog::backdrop{background:var(--color-scrim);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)}
@media(max-height:700px){.rotate-button>.ui-icon{width:52px;height:52px}.rotate-button{min-height:88px}.board-instruction{font-size:11px}}
@media(prefers-reduced-motion:reduce){.rotate-button.hinted{animation:none}}
+130
View File
@@ -0,0 +1,130 @@
[
{
"label": "正文 / 背景",
"foreground": "#273B50",
"background": "#A8BECF",
"ratio": 5.981,
"minimum": 4.5,
"pass": true
},
{
"label": "辅助文字 / 背景",
"foreground": "#314A5F",
"background": "#A8BECF",
"ratio": 4.804,
"minimum": 4.5,
"pass": true
},
{
"label": "正文 / 弹窗",
"foreground": "#273B50",
"background": "#F7F6F2",
"ratio": 10.625,
"minimum": 4.5,
"pass": true
},
{
"label": "辅助文字 / 弹窗",
"foreground": "#314A5F",
"background": "#F7F6F2",
"ratio": 8.535,
"minimum": 4.5,
"pass": true
},
{
"label": "主按钮文字",
"foreground": "#FFFFFF",
"background": "#6652B8",
"ratio": 6.062,
"minimum": 4.5,
"pass": true
},
{
"label": "工具图标",
"foreground": "#FFFFFF",
"background": "#52697E",
"ratio": 5.705,
"minimum": 3,
"pass": true
},
{
"label": "次按钮文字",
"foreground": "#273B50",
"background": "#E4EAF0",
"ratio": 9.48,
"minimum": 4.5,
"pass": true
},
{
"label": "选中标签",
"foreground": "#3F327A",
"background": "#DFD9F3",
"ratio": 7.881,
"minimum": 4.5,
"pass": true
},
{
"label": "阻挡提示文字",
"foreground": "#A44825",
"background": "#F9E3D2",
"ratio": 4.798,
"minimum": 4.5,
"pass": true
},
{
"label": "分格 / 棋盘",
"foreground": "#7C8B94",
"background": "#F2EEE5",
"ratio": 3.034,
"minimum": 3,
"pass": true
},
{
"label": "轨道 / 棋盘",
"foreground": "#6D808B",
"background": "#F2EEE5",
"ratio": 3.549,
"minimum": 3,
"pass": true
},
{
"label": "轨道 / 浅色外缘",
"foreground": "#6D808B",
"background": "#DFE3E2",
"ratio": 3.174,
"minimum": 3,
"pass": true
},
{
"label": "方向箭头 / 蓝棋子",
"foreground": "#FFFFFF",
"background": "#2392FD",
"ratio": 3.181,
"minimum": 3,
"pass": true
},
{
"label": "方向箭头 / 粉棋子",
"foreground": "#FFFFFF",
"background": "#FC3E80",
"ratio": 3.42,
"minimum": 3,
"pass": true
},
{
"label": "方向箭头 / 黄棋子",
"foreground": "#273B50",
"background": "#FDBC28",
"ratio": 6.785,
"minimum": 3,
"pass": true
},
{
"label": "方向箭头 / 紫棋子",
"foreground": "#FFFFFF",
"background": "#874FFD",
"ratio": 4.598,
"minimum": 3,
"pass": true
}
]
File diff suppressed because one or more lines are too long
+32
View File
@@ -0,0 +1,32 @@
/* Generated by npm run design. Edit src/design-tokens.js. */
:root{
--color-background:#A8BECF;
--color-board:#F2EEE5;
--color-board-grid:#7C8B94;
--color-track:#6D808B;
--color-track-halo:#DFE3E2;
--color-surface:#F7F6F2;
--color-text-primary:#273B50;
--color-text-secondary:#314A5F;
--color-primary:#6652B8;
--color-on-primary:#FFFFFF;
--color-secondary:#E4EAF0;
--color-utility:#52697E;
--color-on-utility:#FFFFFF;
--color-selection:#DFD9F3;
--color-on-selection:#3F327A;
--color-disabled:#CBD4DD;
--color-on-disabled:#526579;
--color-input:#FFFFFF;
--color-reward:#B9760C;
--color-warning:#A44825;
--color-warning-surface:#F9E3D2;
--color-scrim:#273B507A;
--color-focus:#3F327A;
--color-tile-blue:#2392FD;
--color-tile-pink:#FC3E80;
--color-tile-yellow:#FDBC28;
--color-tile-purple:#874FFD;
--color-arrow-light:#FFFFFF;
--color-arrow-dark:#273B50;
}
+68
View File
@@ -0,0 +1,68 @@
{
"theme": {
"background": "#A8BECF",
"board": "#F2EEE5",
"boardGrid": "#7C8B94",
"track": "#6D808B",
"trackHalo": "#DFE3E2",
"surface": "#F7F6F2",
"textPrimary": "#273B50",
"textSecondary": "#314A5F",
"primary": "#6652B8",
"onPrimary": "#FFFFFF",
"secondary": "#E4EAF0",
"utility": "#52697E",
"onUtility": "#FFFFFF",
"selection": "#DFD9F3",
"onSelection": "#3F327A",
"disabled": "#CBD4DD",
"onDisabled": "#526579",
"input": "#FFFFFF",
"reward": "#B9760C",
"warning": "#A44825",
"warningSurface": "#F9E3D2",
"scrim": "#273B507A",
"focus": "#3F327A",
"tileBlue": "#2392FD",
"tilePink": "#FC3E80",
"tileYellow": "#FDBC28",
"tilePurple": "#874FFD",
"arrowLight": "#FFFFFF",
"arrowDark": "#273B50"
},
"surfaces": {
"surface": "surface",
"primary": "primary",
"secondary": "secondary",
"utility": "utility",
"warning": "warningSurface",
"reward": "reward",
"input": "input",
"selection": "selection",
"disabled": "disabled",
"tileBlue": "tileBlue",
"tilePink": "tilePink",
"tileYellow": "tileYellow",
"tilePurple": "tilePurple"
},
"typography": {
"level": 28,
"moves": 32,
"dialogTitle": 24,
"body": 14,
"label": 12,
"caption": 11
},
"motion": {
"pressMs": 150,
"modalMs": 420,
"starStartMs": 300,
"starGapMs": 190,
"collisionMs": 1250
},
"radii": {
"control": 14,
"dialog": 24,
"small": 8
}
}
+94
View File
@@ -0,0 +1,94 @@
# Cocos Creator 3.8 接入
## 可复用部分
`core.js`、`path-math.js`、`geometry.js` 不依赖 DOM、Canvas 或浏览器物理引擎。转盘和连续碰撞都是普通数学运算,Cocos 与 HTML 直接复用同一套规则。场景绘制只要求多边形、线段、圆和图集 Sprite。
[Cocos 官方模块说明](https://docs.cocos.com/creator/3.8/manual/en/scripting/modules/index.html)与 [Graphics 说明](https://docs.cocos.com/creator/3.8/manual/en/ui-system/components/editor/graphics.html)对应本项目的 ESM/绘制适配方式。
## 接入步骤
复制这些目录到 Creator 的 `assets/hex-trails/`,保留相对路径:
```text
hex-trails/
cocos/HexTrailsBoard.ts
cocos/CandyUiSkin.ts
src/core.js
src/path-math.js
src/geometry.js
src/renderer.js
src/collision-animation.js
src/tile-shape.js
src/sprites.js
src/levels.js
src/design-tokens.js
src/ui-skin.js
design/tokens.json
assets/tiles-matte-clean-v8.png
assets/ui-surfaces.png
assets/ui-icons-generated.png
```
1. 在 Canvas 下建立 `Board` 节点,UITransform 设为 390×390,挂载 `HexTrailsBoard`,使用正常 UI 正交相机。
2. 将 `tiles-matte-clean-v8.png` 的 Texture2D 资源赋给 `tileAtlas`。组件根据 `TILE_FRAMES` 建立四个 SpriteFrame,复用原图,不需要重新生成贴图。未指定时使用基础几何绘制。
3. 棋盘 Graphics 负责轨道;Sprite 池与圆角六边形 Mask 负责哑光棋子;前景 Graphics 负责箭头。源矩形及顶面锚点在 `src/sprites.js` 中,箭头方向与贴图朝向分离。
4. `levelIndex` 选择内置关卡,或把单关 JSON 赋给 `levelAsset`。
5. 按钮绑定 `rotateLeft()`、`rotateRight()`、`undo()`、`restart()`、`hint()`。
6. 监听 `trail-state`,更新步数、剩余棋子、朝向、胜负和忙碌状态。监听 `trail-hint-rotation` 高亮对应旋转按钮;`trail-blocked` 可接声音/震动。
7. 按 `THEME.background` 设置纯色背景;当前主界面只保留居中的 Level、其下方的剩余步数,以及左侧选关、右侧设置。按钮与面板使用下述图片皮肤,动态文字与布局使用 Label、Button、Layout、Widget、EditBox、ScrollView 等节点搭建。原生端不需要 WebView。
## 坐标与动画
逻辑坐标从左上角出发、y 向下,Cocos 默认居中、y 向上。适配器已处理绘制和触摸两侧的转换,并使用相同的正六边形命中计算。
`update(dt)` 驱动转盘和路径动画。转盘动画只转轨道底板,棋子保持不动。失败动画沿计算得到的接触路径前进再回退,使用与浏览器相同的 `drawScene()` 和扫掠碰撞结果。
## 需要替换的宿主接口
| HTML | 原生接口 |
| --- | --- |
| Canvas 图集采样 | SpriteFrame.rect + Sprite,已提供 |
| requestAnimationFrame | Component.update(dt),已提供 |
| pointerup | TOUCH_END,已提供 |
| HTML 按钮、CSS 布局 | Button、Label、Widget、Layout |
| 浏览器 localStorage | 存储适配,可使用 sys.localStorage |
| 文件上传/下载 | Creator 资源系统或目标平台文件接口 |
| CSS 纯色页面背景 | 使用共享 background token 的 Graphics 或纯色 Sprite |
| CSS 面板与图标 | CandyUiSkin 的纯色 PNG、圆角 Mask 与图标 Sprite |
Cocos 示例提供按钮旋转;若需要同样的边缘拖动手势,可复用 `app.js` 中的角度差判定,接到 TOUCH_START/TOUCH_END。文件接口不属于游戏规则,不应直接复制浏览器 `app.js` 到原生环境。
## 验证限制
已核对本机 Creator 3.8.8 的相关接口,适配脚本通过 TypeScript 语法检查。当前交付不是完整 Creator 工程,尚未运行 Creator 场景或完成 Android/iOS 构建。接入后需验证相机缩放、SpriteFrame 裁切、触摸、Mask、目标平台存储与性能。静态轨道可单独缓存,降低动画时的绘制开销。
## 图片 UI 移植
配色与组件规范以 [Design.md](../Design.md) 为准。`src/design-tokens.js` 是颜色数值的唯一来源;改色后在网页工程运行 `npm run design`,再同步生成的 `design/tokens.json` 与 `assets/ui-surfaces.png`。`src/ui-skin.js` 依赖该 token 模块,并提供网页和 Cocos 共用的图集路径、尺寸和裁切矩形。
| 导入资源 | 规格与用途 |
| --- | --- |
| `assets/ui-surfaces.png` | 416×32,单行 13 个 32×32 纯色色块;颜色由 token 烘入 PNG |
| `assets/ui-icons-generated.png` | 1254×1254,用户选定的生成白色图标原图;按 `ICONS` 的正方形矩形采样 |
| `src/ui-skin.js` | `PANELS`、`ICONS` 与 `UI_REFERENCE` 的共用定义;不可手抄旧版裁切坐标 |
`CandyUiSkin` 挂在 UITransform 节点上,将两张图集的 Texture2D 分别赋给 `panels` 和 `icons`。`panel` 默认是 `surface`,`corner` 默认 14;弹窗设为 24,小角标设为 8。面板使用 `Sprite.Type.SIMPLE` 拉伸纯色色块,通过 `Mask.Type.GRAPHICS_STENCIL` 和 `Graphics.roundRect` 保持圆角;`PANELS` 的 `cap` 为 0,不采用九宫格。Sprite 保持原色,不再加 Color tint 或亮度滤镜。
面板角色包括 `surface`、`primary`、`secondary`、`utility`、`warning`、`reward`、`input`、`selection`、`disabled` 和四个 `tileBlue/tilePink/tileYellow/tilePurple` 色块。常规面板用 `surface`,下一关和当前关卡用 `primary`,次级动作与普通关卡用 `secondary`,选关、设置和道具图标底板统一用 `utility`。Label 颜色读取 token 中的配对角色,例如 `primary/onPrimary`、`surface/textPrimary`。
图标由 `icon` 名称选择,组件按原裁切比例适配节点并居中,不能拉伸为长方形。需要不同面板和图标尺寸时,分别使用背景节点与居中的图标子节点;图标子节点将 `panel` 留空。左右转只放大生成箭头并在下方放置 Label,同样将 `panel` 留空,不创建按钮底板。原图细节和颜色保持不变;轮廓阴影属于额外原生渲染效果,当前组件未实现,需单独对齐网页并验收。
尺寸及锚点变化会自动更新裁切形状与图标位置;运行时修改 `panel`、`icon`、`corner` 或纹理后调用 `refresh()`。组件复用至多两个 SpriteFrame,并在销毁时释放,不要在每次缩放时另外创建图集帧。按钮交互由宿主实现:禁用需同时关闭 Button 交互,若对齐网页透明度则用 UIOpacity 140(约 55%);按压在 150ms 内缩放至 94%,松开恢复。弹窗遮罩、禁止后方交互、入场动画和音效也由原生宿主实现,皮肤组件不负责这些行为。
Canvas 使用参考设计分辨率 390×844、适配宽度。`docs/ui-layout.json` 是旧版界面的历史坐标快照,其中包含已经移除的控件和旧面板名称,不能直接作为当前场景模板。若从当前网页重新量取左上角矩形,转换到 Cocos 居中锚点:x = rect.x + rect.width/2 - 195;y = 422 - rect.y - rect.height/2。所有动态文案仍用 Label;统一字体资源后再检查字宽。轨道与路径为运行时玩法几何,继续使用共享 renderer。
旧的 `ui-panels.png`、`ui-icons.png`、`ui-icons-flat.png`、`board-tray.png`、背景图和 Logo 均不属于当前 UI 导入清单。生成图标只做登记矩形采样,未对原 PNG 承诺像素级去噪,需检查实机透明边缘。当前已验证网页,Cocos 原生场景尚未完成视觉验收。
## 哑光版本与碰撞反馈
背景使用 `THEME.background`(当前 #A8BECF),移除旧背景图片节点。棋子采用 tiles-matte-clean-v8.png,共享 src/sprites.js 裁切。src/collision-animation.js 是 renderer.js 的依赖,也必须复制进 Cocos。碰撞总长 1.25 秒,前 42% 接近,42%–64% 接触停顿及双棋子反馈,最后 36% 原路退回。不会修改阻挡棋子的模型坐标;视觉接触位置依据 Sprite 外形提前截断,避免相互穿插。网页减少动态效果模式下取消阻挡棋子震动。
## 干净边缘与背景
背景采用冷蓝灰 `background`,棋盘采用暖白 `board`(当前 #F2EEE5),轨道与分格也读取同一 token 模块。棋子 PNG 只取每个色块中央 20% 的干净区域,运行时通过 `src/tile-shape.js` 的圆角正六边形遮罩显示;网页 Canvas.clip 和 Cocos Mask.GRAPHICS_STENCIL 共用轮廓。必须复制 tile-shape.js。原图边缘仍有生成杂点,不要直接显示整张原图或旧裁切矩形。
+72
View File
@@ -0,0 +1,72 @@
# 关卡、旋转、碰撞与难度
## 数据格式
平顶六边形使用轴向 `(q,r)`,逻辑 y 轴向下。中心为 `x=1.5qR`、`y=√3(r+q/2)R`。
| 方向 | 朝向 | 邻格变化 |
| --- | --- | --- |
| 0 | ↑ | (0,-1) |
| 1 | ↗ | (1,-1) |
| 2 | ↘ | (1,0) |
| 3 | ↓ | (0,1) |
| 4 | ↙ | (-1,1) |
| 5 | ↖ | (-1,0) |
```json
{
"version": 1,
"id": "my-level",
"name": "转个方向",
"rotationEnabled": true,
"moveLimit": 3,
"cells": [{ "q": 0, "r": 0, "tracks": [3, 4, 5, 0, 1, 2] }],
"tiles": [{ "id": "t0", "q": 0, "r": 0, "dir": 1, "color": 0 }]
}
```
`tracks[i]` 为进入 i 端口后使用的出口,必须满足 `tracks[tracks[i]]===i` 且不能与自己相连。六端口共有 15 种完整配对。`color` 为 0–3。`rotationEnabled` 可选,默认开启;旧版 JSON 仍可导入,但会按新的碰撞规则重新验证。
导入限制:1–61 格、坐标半径不超过 4、步数 1–999。编辑器提供半径 2/3 的常用板型。底板不具备六重旋转对称性时,禁用整体旋转,避免棋子旋转后悬空。
## 旋转是真实的规则变化
顺时针一步将所有底板格子变为 `(q,r) → (-r,q+r)`,同时将轨道端口和对应出口都加 1(模 6)。上层棋子的 `(q,r,dir)` 均不改变。六次旋转恢复原状。
每次旋转扣 1 步。撤销保存并恢复底板、棋子、步数、朝向和旋转计数。重开读取原始关卡,不能从已旋转的中间状态重开。
## 连续碰撞
`path-math.js` 同时服务渲染与碰撞:每条三次贝塞尔轨道采样为 20 段,碰撞针对这些实际绘制的折线进行连续扫掠,而不是仅检查当前动画帧的位置。
每个棋子的顶面碰撞体为半径 `0.88R` 的平顶六边形,阴影和视觉侧壁不计入碰撞。两个同向六边形的 Minkowski 和是半径 `1.76R` 的六边形。将移动中心线的每段与此凸六边形做半平面裁剪,即可得到最早接触时刻。长段移动也不会穿透。
例如:移动棋子在 `(1,1)` 向上走,另一棋子在 `(0,0)`。路线经过的格子不含 `(0,0)`,但经过相邻两格之间时,移动棋子的侧面可能擦到它,因此仍被阻挡。这比仅检查路线格子占用更严格。
判定发生在移动前,动画只展示结果:成功沿线离场;失败走到接触位置再退回。不允许多个棋子同时运动,避免时序歧义。生成器、求解器、提示与工坊都调用同一个 `trace()`。
## 旋转求解与生成
移走可行棋子只会减少障碍,不改变轨道,因此在任意朝向下先移除所有安全棋子不会损害后续可解性。求解器对“朝向+剩余棋子集合”做 BFS,每次转盘代价为 1,到达一个朝向后先消除所有可离场棋子。
最短步数 = 初始棋子数 + 最少旋转数。BFS 返回混合 `tap` / `rotate` 动作序列。最大扩展状态数为 20,000;若触及上限,则返回 `exhausted` 表示尚未确定,不能宣称无解。
关卡生成先在碰撞规则下逆向放入可离场棋子,再旋转初始底盘,筛选必须旋转才能完成的布局,最后重新求解验证。主线按评分非递减排列,共 24 关,必要旋转 1–3 次。
## 数字化难度(0–100)
设 N=棋子数、T=忽略占用的平均转弯数、W=解答中的消除层数、F=初始无碰撞可离场数量、R=最少旋转数、K=N+R=最短步数、M=上限:
```text
规模 = min(1, N/37) × 15
弯道追踪 = min(1, T/5) × 20
解锁层数 = min(1, max(0,W-1)/9) × 20
路线阻塞 = (1-F/N) × 15
必要旋转 = min(1, R/4) × 20
步数压力 = clamp(1-(M-K)/max(1,0.4N), 0, 1) × 10
总分 = 四舍五入各项未取整值之和
```
空关卡为 0 分且不能判为有效可解关卡。不可解、搜索未确定和步数不足分别展示。分项单独取整,显示值之和可能与总分略有差异。
0–29 轻松、30–49 进阶、50–69 烧脑、70–100 挑战。目前 24 关范围为 25–75。它是用于关卡排序的结构估计,没有经过玩家通关率标定;后续可用首次成功率、碰撞率、提示率、旋转次数、撤销率与耗时校准。
+21
View File
@@ -0,0 +1,21 @@
{
"designResolution": {"width":390,"height":844},
"origin":"top-left",
"note":"Browser reference at 390x844. Native Labels require the same bundled font for matching metrics.",
"nodes": {
"map-button":{"x":21,"y":19,"width":43,"height":43,"panel":"white","icon":"grid","corner":10},
"help-button":{"x":326,"y":19,"width":43,"height":43,"panel":"white","icon":"gear","corner":10},
"difficulty-button":{"x":299.3,"y":86.5,"width":73.7,"height":24.5,"panel":"white","corner":6},
"moves":{"x":181.2,"y":171.75,"width":19.6,"height":33,"type":"Label"},
"remaining":{"x":70,"y":176.2,"width":24.45,"height":21,"type":"Label"},
"orientation":{"x":305,"y":176.2,"width":14.66,"height":21,"type":"Label"},
"board":{"x":22.5,"y":220.5,"width":345,"height":345},
"rotate-left":{"x":28,"y":597.5,"width":128,"height":54,"panel":"purple","icon":"left","corner":15},
"rotate-right":{"x":234,"y":597.5,"width":128,"height":54,"panel":"purple","icon":"right","corner":15},
"undo-button":{"x":64.5,"y":674.5,"width":61,"height":75,"icon":"undo","panelChild":{"width":57,"height":52,"panel":"blue"}},
"reset-button":{"x":164.5,"y":674.5,"width":61,"height":75,"icon":"restart","panelChild":{"width":57,"height":52,"panel":"pink"}},
"hint-button":{"x":264.5,"y":674.5,"width":61,"height":75,"icon":"bulb","panelChild":{"width":57,"height":52,"panel":"gold"}},
"play-tab":{"x":35,"y":775.5,"width":58,"height":55.5,"panel":"white","icon":"gem","corner":12},
"edit-tab":{"x":291,"y":775.5,"width":64,"height":55.5,"panel":"white","icon":"edit","corner":12}
}
}
+11
View File
@@ -0,0 +1,11 @@
# 参考玩法与实现规则
参考:`/Users/wukaibing/Grok/dailyrun/x-videos-index/videos/53-x282665-tiles.mp4`,约 15.06 秒,720×900,60 fps。通过每 0.5 秒抽帧及清晰帧观察箭头沿弯轨离场、途中自动转向、离场后露出轨道以及顶部步数变化。
根据用户补充,玩法还包含旋转棋盘改变路线,以及运动途中不能碰撞其他棋子。当前实现将整块轨道底盘每次旋转 60°,上层棋子位置和箭头不变;底板格子与轨道一起旋转,形成不同路线。可使用左右按钮或沿棋盘边缘拖动。
棋子的整个顶面参与沿途碰撞检测;即使经过的格子为空,途中擦碰邻近棋子也不能通过。碰撞时显示沿线接触和回退动画,判定由共享数学模型完成。
片段没有完整展示所有底板、胜负规则、扣步规则和完整旋转过程。四种颜色只负责区分,轨道交叉处不换轨;每次点击或旋转扣 1 步,失败点击也扣步,撤销恢复状态。这些细节是明确的实现约定。内置关卡为重新设计的可解关卡,不声称复原了被棋子遮住的全部原始轨道。
美术以生成式 PNG 图集、背景和立体标题实现。棋子使用厚侧壁、倒角和高光,箭头单独绘制,移动转向不改变棋子材质的光照方向。UI 使用游戏 HUD、转盘按钮、道具栏和关卡弹窗。没有将原视频截图作为游戏贴图。
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#A8BECF"><title>HEX TRAILS · 转转六角</title><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="design/tokens.css"><link rel="stylesheet" href="design/components.css"></head>
<body>
<div class="game-shell">
<header class="game-top"><button class="game-icon" id="map-button" aria-label="选择关卡"><svg><use href="#i-grid"/></svg></button><button class="game-icon help-trigger" aria-label="玩法与设置"><svg><use href="#i-help"/></svg></button></header>
<main class="workspace">
<section class="game-column">
<div class="level-heading"><div class="level-ribbon" id="level-eyebrow">LEVEL 01</div><h1 id="level-name">转一转,路就通了</h1><button class="difficulty-pill" id="difficulty-button"><span id="difficulty-label">轻松 · 29</span><span>详情</span></button></div>
<div class="hud"><div class="hud-pill"><span class="ui-icon hud-symbol" data-icon="gem"></span><div><span id="remaining-title">待离场</span><strong id="remaining">7<small> / 7</small></strong></div></div><div class="moves-bubble"><span id="moves-title">剩余步数</span><strong id="moves">11</strong></div><div class="hud-pill turn-counter"><span class="ui-icon hud-symbol" data-icon="right"></span><div><span id="orientation-title">底盘朝向</span><strong id="orientation">0<small>°</small></strong></div></div></div>
<article class="game-card">
<div class="canvas-wrap"><canvas id="board" aria-label="六角轨道棋盘,点击棋子沿轨道离场,拖动边缘旋转底盘"></canvas><div class="board-toast" id="board-toast" role="status" aria-live="polite"></div></div>
<div class="board-instruction" id="instruction">点击箭头,让棋子沿轨道离场</div>
<div class="rotation-controls" id="rotation-controls"><button id="rotate-left" class="rotate-button" aria-label="向左旋转底盘 60 度"><span class="ui-icon" data-icon="left"></span><b>左转</b></button><button id="rotate-right" class="rotate-button" aria-label="向右旋转底盘 60 度"><span class="ui-icon" data-icon="right"></span><b>右转</b></button></div>
<div class="board-tools" id="game-tools"><button id="undo-button"><span class="tool-icon blue"><svg><use href="#i-undo"/></svg></span><b>撤销</b></button><button id="reset-button"><span class="tool-icon pink"><svg><use href="#i-reset"/></svg></span><b>重来</b></button><button id="hint-button"><span class="tool-icon gold"><svg><use href="#i-bulb"/></svg><small>免费</small></span><b>提示</b></button></div>
<div class="editor-board-tools" id="editor-board-tools" hidden><button id="editor-undo"><span class="ui-icon" data-icon="undo"></span> 撤销编辑</button><button id="trial-button" class="primary"><span class="ui-icon" data-icon="play"></span> 试玩关卡</button></div>
</article>
<button id="return-editor" class="return-editor" hidden><span class="ui-icon" data-icon="back"></span> 返回工坊</button>
</section>
<aside class="inspector panel editor-panel" id="editor-panel" hidden>
<div class="eyebrow">LEVEL WORKSHOP</div><h3>把灵感,连成轨迹</h3><p class="muted">选择工具,再轻点棋盘中的格子。</p>
<label class="field">关卡名称<input id="editor-name" maxlength="60" value="我的第一条轨迹"></label>
<div class="field-row"><label class="field">底板大小<select id="editor-radius"><option value="2">小 · 19 格</option><option value="3" selected>中 · 37 格</option></select></label><label class="field">步数上限<input id="editor-moves" type="number" min="1" max="999" value="30"></label></div>
<div class="editor-rotation"><button id="editor-turn-left"><span class="ui-icon" data-icon="left"></span> 底盘左转</button><button id="editor-turn-right"><span class="ui-icon" data-icon="right"></span> 底盘右转</button></div><div class="section-label">编辑工具</div><div class="tool-palette" id="tool-palette"><button data-tool="tile" class="active"><span class="ui-icon" data-icon="arrow"></span><span>放置棋子</span></button><button data-tool="rotate"><span class="ui-icon" data-icon="right"></span><span>转向棋子</span></button><button data-tool="track"><span class="ui-icon" data-icon="track"></span><span>切换轨道</span></button><button data-tool="rotate-track"><span class="ui-icon" data-icon="right"></span><span>旋转轨道</span></button><button data-tool="erase"><span class="ui-icon" data-icon="erase"></span><span>擦除棋子</span></button><button data-tool="inspect"><span class="ui-icon" data-icon="bulb"></span><span>查看路径</span></button></div>
<div class="field-row"><div><div class="section-label">箭头方向</div><div class="directions" id="directions"></div></div><div><div class="section-label">棋子配色</div><div class="swatches" id="swatches"></div></div></div>
<p id="selection-info" class="selection-info">还没有选择格子</p>
<div class="editor-analysis"><div><span id="editor-valid">✓ 可以解开</span><strong id="editor-score">0<small>/100</small></strong></div><p id="editor-analysis-text"></p><div class="score-bars" id="editor-bars"></div></div>
<div class="editor-actions"><button id="generate-button"><span class="ui-icon" data-icon="star"></span> 生成可解关卡</button><button id="blank-button">新建空白</button><button id="save-button">保存到本机</button><button id="load-button">读取存档</button><button id="export-button">导出 JSON <span class="ui-icon" data-icon="arrow"></span></button><button id="import-button">导入 JSON <span class="ui-icon" data-icon="back"></span></button></div><input type="file" accept=".json,application/json" id="import-file" hidden>
<p class="editor-footnote">轨道、棋子与评分均使用独立数据模型。JSON 可作为后续 Cocos Creator 关卡资源。碰撞检查与正式游戏保持一致。</p>
</aside>
</main>
<nav class="game-bottom"><button id="play-tab" class="active"><svg><use href="#i-hex"/></svg>游戏</button><span class="journey-badge"><span class="ui-icon" data-icon="star"></span> <b id="progress-label">0 / 24</b><small>已完成</small></span><button id="edit-tab"><svg><use href="#i-edit"/></svg>关卡工坊</button></nav>
</div>
<div class="compat" hidden><span id="breadcrumb"></span><span id="mini-caption"></span><div id="level-list"></div><i id="campaign-progress"></i><div id="play-inspector"><span id="score-value"></span><span id="score-name"></span><span id="score-summary"></span><div id="score-bars"></div><button id="score-help"></button><button id="workshop-link"></button></div></div>
<dialog class="win-overlay" id="result" aria-label="关卡结果" hidden></dialog>
<dialog id="info-dialog"><button class="dialog-close game-icon" aria-label="关闭"><svg><use href="#i-close"/></svg></button><div id="dialog-content"></div></dialog>
<script type="module" src="src/app.js"></script></body></html>
+12502
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "hex-arrow-trails",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hex-arrow-trails",
"version": "1.0.0"
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "hex-arrow-trails",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/serve.mjs",
"test": "node --test",
"build": "npm run design && node scripts/build.mjs",
"levels": "node scripts/generate-levels.mjs",
"design": "node scripts/build-design.mjs && node scripts/build-design-guide.mjs"
}
}
+62
View File
@@ -0,0 +1,62 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { THEME, CONTRAST_PAIRS, contrast, TYPOGRAPHY, MOTION, RADII } from '../src/design-tokens.js';
import { ICONS, ICON_SIZE, ICON_ATLAS } from '../src/ui-skin.js';
import { TILE_ATLAS, TILE_FRAMES, TILE_ASPECT } from '../src/sprites.js';
import { tileSample, roundedHex } from '../src/tile-shape.js';
import { drawScene, drawTile } from '../src/renderer.js';
import { layout } from '../src/geometry.js';
import { LEVELS } from '../src/levels.js';
const read=name=>readFileSync(new URL('../'+name,import.meta.url),'utf8');
const dataImage=name=>'data:image/png;base64,'+readFileSync(new URL('../'+name,import.meta.url)).toString('base64');
const esc=value=>String(value).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const markdown=read('Design.md');
// This intentionally small renderer supports the headings, lists, links and tables
// used by Design.md. Escape text before formatting; do not execute embedded HTML.
function inline(value){return esc(value).replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');}
function renderMarkdown(source){
const lines=source.split('\n'),out=[];let i=0,heading=0;
while(i<lines.length){const line=lines[i];
if(!line.trim()){i++;continue;}
const h=line.match(/^(#{1,6})\s+(.+)$/);
if(h){const level=Math.min(6,h[1].length+1);out.push(`<h${level} id="spec-${heading++}">${inline(h[2])}</h${level}>`);i++;continue;}
if(line.startsWith('|')){const rows=[];while(i<lines.length&&lines[i].startsWith('|'))rows.push(lines[i++].split('|').slice(1,-1).map(s=>s.trim()));const header=rows.shift();if(rows[0]?.every(s=>/^:?-+:?$/.test(s)))rows.shift();out.push(`<div class="table-scroll"><table><thead><tr>${header.map(s=>`<th>${inline(s)}</th>`).join('')}</tr></thead><tbody>${rows.map(row=>`<tr>${row.map(s=>`<td>${inline(s)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);continue;}
if(/^(- |\d+\. )/.test(line)){const ordered=/^\d/.test(line),items=[];while(i<lines.length&&(ordered?/^\d+\. /:/^- /).test(lines[i]))items.push(lines[i++].replace(/^(- |\d+\. )/,''));out.push(`<${ordered?'ol':'ul'}>${items.map(s=>`<li>${inline(s)}</li>`).join('')}</${ordered?'ol':'ul'}>`);continue;}
const p=[line];i++;while(i<lines.length&&lines[i].trim()&&!/^(#|\||- |\d+\. )/.test(lines[i]))p.push(lines[i++]);out.push(`<p>${inline(p.join(' '))}</p>`);
}
return out.join('\n');
}
const iconNames={gear:'设置',grid:'选关',back:'返回',close:'关闭',left:'左转',right:'右转',undo:'撤销',restart:'重来',bulb:'提示',star:'星星',edit:'编辑',play:'试玩',arrow:'方向',erase:'擦除',track:'轨道',gem:'六角'};
const icon=name=>`<span class="icon icon-${name}" aria-hidden="true"></span>`;
const iconCSS=Object.entries(ICONS).map(([name,f])=>`.icon-${name}{background-size:${ICON_SIZE.w/f.w*100}% ${ICON_SIZE.h/f.h*100}%;background-position:${f.x/(ICON_SIZE.w-f.w)*100}% ${f.y/(ICON_SIZE.h-f.h)*100}%}`).join('\n');
const meanings={background:'游戏背景',board:'棋盘底格',boardGrid:'六边形分格',track:'轨道中心线',trackHalo:'轨道浅色外缘',surface:'弹窗与编辑面板',textPrimary:'主要文字',textSecondary:'说明与辅助文字',primary:'主操作与当前选中',onPrimary:'主操作上的文字',secondary:'次级操作表面',utility:'工具图标底板',onUtility:'工具图标与文字',selection:'弱选中表面',onSelection:'弱选中上的文字',disabled:'原生禁用参考表面',onDisabled:'原生禁用参考文字',input:'输入框表面',reward:'奖励与星星强调',warning:'警告文字',warningSurface:'警告提示表面',scrim:'模态遮罩 · 约 48%',focus:'键盘焦点',tileBlue:'蓝棋子 · 代表采样值',tilePink:'粉棋子 · 代表采样值',tileYellow:'黄棋子 · 代表采样值',tilePurple:'紫棋子 · 代表采样值',arrowLight:'蓝 / 粉 / 紫棋子箭头',arrowDark:'黄色棋子箭头'};
const swatches=Object.entries(THEME).map(([name,hex])=>`<button class="swatch" data-color="${hex}" data-search="${name.toLowerCase()} ${hex.toLowerCase()} ${meanings[name]}" aria-label="复制 ${name} ${hex}"><span class="swatch-color" style="--swatch:${hex}"></span><span class="swatch-label"><strong>${meanings[name]}</strong><code>${name}</code><span class="hex">${hex}<span aria-hidden="true">复制</span></span></span></button>`).join('');
const reports=CONTRAST_PAIRS.map(([label,fg,bg,min])=>({label,fg,bg,min,ratio:contrast(THEME[fg],THEME[bg])}));
const pairRows=reports.map(r=>`<tr><td><span class="pair-example" style="color:${THEME[r.fg]};background:${THEME[r.bg]}">${r.min===3?'➜':'Aa'}</span>${r.label}</td><td><code>${r.fg}<br>${r.bg}</code></td><td><b>${r.ratio.toFixed(2)}:1</b></td><td>≥ ${r.min}:1</td><td>${r.ratio>=r.min?'通过':'需调整'}</td></tr>`).join('');
// Record the game's actual renderer commands as SVG for a static composition.
// Original PNG pixels are embedded unchanged; shared sampling and clip geometry
// match the game. This is a documentation adapter, not an alternative game UI.
let serial=0;
function sceneSVG(render,label){const nodes=[],defs=[];const points=pts=>pts.map(p=>`${p.x.toFixed(3)},${p.y.toFixed(3)}`).join(' ');
const painter={
polygon(pts,fill){nodes.push(`<polygon points="${points(pts)}" fill="${fill}"/>`);},
line(pts,color,width){nodes.push(`<polyline points="${points(pts)}" fill="none" stroke="${color}" stroke-width="${width}" stroke-linejoin="round" stroke-linecap="round"/>`);},
circle(x,y,r,fill){nodes.push(`<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}"/>`);},
sprite(index,x,y,r){const f=tileSample(TILE_FRAMES[index]),w=r*2,h=w*TILE_ASPECT,id=`tile-clip-${serial++}`;defs.push(`<clipPath id="${id}"><polygon points="${points(roundedHex(x,y,r))}"/></clipPath>`);nodes.push(`<g clip-path="url(#${id})"><svg x="${x-w/2}" y="${y-h/2}" width="${w}" height="${h}" viewBox="${f.x} ${f.y} ${f.w} ${f.h}" preserveAspectRatio="none"><use href="#tile-atlas-image"/></svg></g>`);return true;},
};
render(painter);return `<svg class="game-art" viewBox="0 0 360 360" role="img" aria-label="${esc(label)}"><defs>${defs.join('')}</defs>${nodes.join('')}</svg>`;
}
const level=LEVELS[1];
const board=sceneSVG(p=>drawScene(p,level,level.tiles,layout(level.cells,360,360)),'第二关棋盘静态展示,使用游戏实际轨道、棋子图片与颜色');
const tiles=sceneSVG(p=>[0,1,2,3].forEach((color,i)=>drawTile(p,95+(i%2)*170,95+Math.floor(i/2)*170,75,{color,dir:0})),'蓝、粉、黄、紫四种实际棋子,黄色使用深色箭头');
const replacements={
TOKENS:Object.entries(THEME).map(([k,v])=>`--${k}:${v};`).join('')+`--pressMs:${MOTION.pressMs}ms;--modalMs:${MOTION.modalMs}ms;--starOne:${MOTION.starStartMs}ms;--starTwo:${MOTION.starStartMs+MOTION.starGapMs}ms;--starThree:${MOTION.starStartMs+MOTION.starGapMs*2}ms;--controlRadius:${RADII.control}px;--dialogRadius:${RADII.dialog}px;`,ICON_ATLAS:dataImage(ICON_ATLAS),ICON_CSS:iconCSS,
TILE_IMAGE:`<svg width="0" height="0" aria-hidden="true" class="svg-defs"><defs><image id="tile-atlas-image" width="1293" height="1216" href="${dataImage(TILE_ATLAS)}"/></defs></svg>`,
BOARD:board,TILES:tiles,MOVES:level.moveLimit,SWATCHES:swatches,PAIR_ROWS:pairRows,
ICONS:Object.keys(ICONS).map(name=>`<div class="icon-item"><span class="icon-ground">${icon(name)}</span><b>${iconNames[name]}</b><code>${name}</code></div>`).join(''),
SPEC:renderMarkdown(markdown),PAYLOAD:JSON.stringify({markdown,theme:THEME,typography:TYPOGRAPHY,motion:MOTION,radii:RADII}).replace(/</g,'\\u003c'),
};
const template=read('design/guide-template.html');
const output=template.replace(/\{\{([A-Z_]+)\}\}/g,(_,key)=>{if(!(key in replacements))throw new Error(`Unknown guide placeholder ${key}`);return replacements[key];});
writeFileSync(new URL('../Design.html',import.meta.url),output);
console.log(`Design.html built: ${Object.keys(THEME).length} colors, ${Object.keys(ICONS).length} original icons, ${reports.length} contrast pairs, complete specification. Standalone / offline.`);
+19
View File
@@ -0,0 +1,19 @@
import { writeFileSync, mkdirSync } from 'node:fs';
import { deflateSync } from 'node:zlib';
import { THEME, SURFACES, TYPOGRAPHY, MOTION, RADII, CONTRAST_PAIRS, contrast } from '../src/design-tokens.js';
// Render a newly authored flat color texture from tokens, with no editing of image artwork.
const names=Object.keys(SURFACES),width=names.length*32,height=32,raw=Buffer.alloc((width*4+1)*height);
for(let y=0;y<height;y++)for(let x=0;x<width;x++){
const c=THEME[SURFACES[names[Math.floor(x/32)]]],v=c.slice(1).match(/../g).map(v=>parseInt(v,16)),o=y*(width*4+1)+1+x*4;
raw[o]=v[0];raw[o+1]=v[1];raw[o+2]=v[2];raw[o+3]=255;
}
function crc32(b){let c=0xffffffff;for(const v of b){c^=v;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}return(c^0xffffffff)>>>0;}
function chunk(type,b){const t=Buffer.from(type),n=Buffer.alloc(4),crc=Buffer.alloc(4);n.writeUInt32BE(b.length);crc.writeUInt32BE(crc32(Buffer.concat([t,b])));return Buffer.concat([n,t,b,crc]);}
const ihdr=Buffer.alloc(13);ihdr.writeUInt32BE(width);ihdr.writeUInt32BE(height,4);ihdr[8]=8;ihdr[9]=6;
writeFileSync(new URL('../assets/ui-surfaces.png',import.meta.url),Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',ihdr),chunk('IDAT',deflateSync(raw)),chunk('IEND',Buffer.alloc(0))]));
const kebab=s=>s.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());
writeFileSync(new URL('../design/tokens.css',import.meta.url),'/* Generated by npm run design. Edit src/design-tokens.js. */\n:root{\n'+Object.entries(THEME).map(([k,v])=>` --color-${kebab(k)}:${v};`).join('\n')+'\n}\n');
writeFileSync(new URL('../design/tokens.json',import.meta.url),JSON.stringify({theme:THEME,surfaces:SURFACES,typography:TYPOGRAPHY,motion:MOTION,radii:RADII},null,2)+'\n');
const report=CONTRAST_PAIRS.map(([label,fg,bg,min])=>({label,foreground:THEME[fg],background:THEME[bg],ratio:Number(contrast(THEME[fg],THEME[bg]).toFixed(3)),minimum:min,pass:contrast(THEME[fg],THEME[bg])>=min}));
writeFileSync(new URL('../design/contrast-report.json',import.meta.url),JSON.stringify(report,null,2)+'\n');
if(report.some(r=>!r.pass)){console.error('Contrast checks failed:',report.filter(r=>!r.pass));process.exitCode=1;}else console.log(`Design tokens built. ${report.length} contrast pairs pass.`);
+5
View File
@@ -0,0 +1,5 @@
import { cp, mkdir, rm } from 'node:fs/promises';
const out=new URL('../dist/',import.meta.url);
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
for(const name of ['index.html','style.css','src','levels','assets','design','Design.md','Design.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
console.log('Static build ready in dist/');
+21
View File
@@ -0,0 +1,21 @@
import { generateLevel, analyze, rotateBoard, solveRotations } from '../src/core.js';
import { writeFile } from 'node:fs/promises';
const names=['转一转,路就通了','换个方向','弯道别碰头','给我让条路','粉色回旋','蓝色小径','向左看齐','第一圈冒险','旋转甜甜圈','差一点擦肩','紫色回廊','再转一次','绕开拥挤','转角遇见光','别挤在一起','糖果交响曲','盘旋之间','左右逢源','小心翼翼','寻找缝隙','回转大挑战','紧密相连','终极转盘','六角大满贯'];
const candidates=[];
for(let i=0;i<100;i++){
const radius=i<25?2:3,target=i<25?5+i%10:16+i%18;
const level=rotateBoard(generateLevel(2+i*73,radius,target,.15+(i%10)/12),1+i%3);
const solution=solveRotations({...level,moveLimit:999});
if(!solution.reachable||!solution.rotations)continue;
level.rotationEnabled=true;
level.moveLimit=solution.minimumMoves+Math.max(2,Math.round(target*.2));
level.requiresRotation=true;
const analysis=analyze(level);
if(analysis.solvable)candidates.push({level,analysis});
}
candidates.sort((a,b)=>a.analysis.score-b.analysis.score);
if(candidates.length<24)throw new Error('Not enough verified rotation puzzles');
const levels=names.map((name,i)=>({...candidates[Math.round(i*(candidates.length-1)/23)].level,id:`turn-level-${i+1}`,name}));
await writeFile(new URL('../levels/campaign.json',import.meta.url),JSON.stringify(levels,null,2)+'\n');
await writeFile(new URL('../src/levels.js',import.meta.url),'// Generated by npm run levels. Rotation + swept collision campaign.\nexport const LEVELS = '+JSON.stringify(levels)+';\n');
console.log(levels.map((l,i)=>{const a=analyze(l);return `${i+1}. ${l.name} ${l.tiles.length} tiles ${a.score}/100 min ${a.rotations} turns (${a.minimumMoves} moves)`;}).join('\n'));
+35
View File
@@ -0,0 +1,35 @@
# Newly authored geometric icon artwork, rendered to a portable PNG atlas.
from PIL import Image,ImageDraw
from math import sin,cos,pi
S=4; im=Image.new('RGBA',(1024*S,1024*S));d=ImageDraw.Draw(im)
for i in range(16):
ox=(i%4)*256;oy=(i//4)*256
def p(x,y):return ((ox+32+x*8)*S,(oy+32+y*8)*S)
def poly(pts,fill='white'):d.polygon([p(*v) for v in pts],fill=fill)
def line(pts,w=2.3):
d.line([p(*v) for v in pts],fill='white',width=int(w*8*S),joint='curve')
for x,y in [pts[0],pts[-1]]:ell(x-w/2,y-w/2,x+w/2,y+w/2)
def ell(a,b,c,e,fill='white'):d.ellipse([p(a,b),p(c,e)],fill=fill)
def rect(a,b,c,e,r=1.4):d.rounded_rectangle([p(a,b),p(c,e)],radius=r*8*S,fill='white')
def turn(clock=True):
pts=[(12+7*cos((-60+j*290/60)*pi/180),12+7*sin((-60+j*290/60)*pi/180)) for j in range(61)]
if not clock:pts=[(24-x,y)for x,y in pts]
line(pts);x,y=pts[-1];vx=x-pts[-2][0];vy=y-pts[-2][1];n=(vx*vx+vy*vy)**.5;vx/=n;vy/=n
poly([(x+vx*2,y+vy*2),(x-vx*3-vy*3,y-vy*3+vx*3),(x-vx*3+vy*3,y-vy*3-vx*3)])
if i==0:
poly([(12+(9 if j%4 in [0,1] else 7)*cos(j*pi/16),12+(9 if j%4 in [0,1] else 7)*sin(j*pi/16))for j in range(32)]);ell(8.5,8.5,15.5,15.5,(0,0,0,0))
elif i==1:
for x,y in [(3,3),(14,3),(3,14),(14,14)]:rect(x,y,x+7,y+7)
elif i==2:line([(15,4),(7,12),(15,20)],3)
elif i==3:line([(5,5),(19,19)],3);line([(19,5),(5,19)],3)
elif i in [4,5,7]:turn(i!=4)
elif i==6:line([(5,8),(14,8),(18,10),(19,14),(17,18),(13,19)]);poly([(2,8),(9,2),(9,14)])
elif i==8:ell(5,2,19,16);rect(9,14,15,19);line([(10,22),(14,22)],2)
elif i==9:poly([(12+(10 if j%2==0 else 4.8)*cos(-pi/2+j*pi/5),12+(10 if j%2==0 else 4.8)*sin(-pi/2+j*pi/5))for j in range(10)])
elif i==10:poly([(4,16),(16,4),(20,8),(8,20),(3,21)])
elif i==11:poly([(6,3),(21,12),(6,21)])
elif i==12:poly([(12,2),(22,12),(16,12),(16,22),(8,22),(8,12),(2,12)])
elif i==13:poly([(3,15),(14,3),(22,11),(12,21),(9,21)])
elif i==14:line([(5,20),(8,19),(10,16),(12,8),(14,5),(19,4)],3)
else:poly([(12+10*cos(j*pi/3),12+10*sin(j*pi/3))for j in range(6)])
im.resize((1024,1024),Image.Resampling.LANCZOS).save('assets/ui-icons-flat.png')
+15
View File
@@ -0,0 +1,15 @@
import http from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { resolve, extname, sep } from 'node:path';
const root=resolve(process.argv.includes('--dist')?'dist':'.');
const port=Number(process.env.PORT||4173);
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.svg':'image/svg+xml','.png':'image/png'};
http.createServer(async(req,res)=>{
try {
const url=new URL(req.url,'http://localhost');
const path=resolve(root,'.'+decodeURIComponent(url.pathname==='/'?'/index.html':url.pathname));
if(path!==root&&!path.startsWith(root+sep)){res.writeHead(403);res.end('Forbidden');return;}
if(!(await stat(path)).isFile()){res.writeHead(404);res.end('Not found');return;}
const body=await readFile(path);res.writeHead(200,{'Content-Type':types[extname(path)]||'application/octet-stream','Cache-Control':'no-cache'});res.end(body);
}catch{res.writeHead(404);res.end('Not found');}
}).listen(port,'0.0.0.0',()=>console.log(`HEX TRAILS http://localhost:${port}`));
+192
View File
@@ -0,0 +1,192 @@
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
import { icon, installUI } from './ui.js';
import { Game, analyze, available, clone, generateLevel, hexagon, key, random, randomTracks, rotateTracks, rotateBoard, canRotate, solveRotations, trace, validateLevel } from './core.js';
import { LEVELS } from './levels.js';
import { layout, hitCell } from './geometry.js';
import { TILE_ATLAS } from './sprites.js';
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
const $=id=>document.getElementById(id);
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
const STORE='hex-trails-v2';
let saved={};try{saved=JSON.parse(localStorage.getItem(STORE)||'{}')||{};}catch{}
let completed=new Set(Array.isArray(saved.completed)?saved.completed.filter(i=>Number.isInteger(i)&&i>=0&&i<24):[]);
let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<24?saved.current:0;
let game=new Game(LEVELS[current]),mode='play',trial=false,draft=null,editHistory=[],tool='tile',direction=1,color=0,selected=null,path=null,hint=null,animation=null,rotationAnimation=null,shake=null,toastTimer,view,width=0,height=0,frame=0;
let helpUsed=false;
let hitSoundTimer;
document.addEventListener('pointerdown',unlockAudio,{passive:true});
document.addEventListener('click',e=>{if(e.target.closest('button'))playSound('tap');});
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
const metricLabels={scale:['棋子规模',15],turns:['弯道追踪',20],depth:['解锁层数',20],blocked:['路线阻塞',15],rotation:['必要旋转',20],precision:['步数压力',10]};
const chapters=['转盘初体验','糖果回旋','碰撞大挑战'];
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
function persist(){try{localStorage.setItem(STORE,JSON.stringify({current,completed:[...completed]}));}catch{toast('浏览器未允许保存,进度仅保留在当前页面');}}
function bars(a){return Object.entries(metricLabels).map(([k,[label,max]])=>`<div class="metric-row"><span>${label}</span><span class="metric-track"><i style="width:${a.parts[k]/max*100}%"></i></span><span>${a.parts[k].toFixed(0)}/${max}</span></div>`).join('');}
function renderMap(){
$('level-list').innerHTML=chapters.map((ch,c)=>`<div class="chapter-title">0${c+1} / ${ch}</div><div class="level-grid">${LEVELS.slice(c*8,c*8+8).map((l,j)=>{const i=c*8+j;return `<button class="level-btn ${i===current?'current':''} ${completed.has(i)?'done':''}" data-level="${i}" aria-label="第 ${i+1} 关 ${l.name}${completed.has(i)?',已通关':''}" ${i===current?'aria-current="true"':''}>${String(i+1).padStart(2,'0')}</button>`;}).join('')}</div>`).join('');
$('progress-label').textContent=`${completed.size} / 24`;$('campaign-progress').style.width=`${completed.size/24*100}%`;
}
function renderStats(){
const editing=mode==='edit',level=editing?draft:game.initialLevel,a=analyze(level);
$('level-name').textContent=level.name;$('level-eyebrow').textContent=editing?'YOUR CREATION':trial?'PLAYTEST':`LEVEL ${String(current+1).padStart(2,'0')}`;
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':`第${['一','二','三'][Math.floor(current/8)]}章 / ${chapters[Math.floor(current/8)]}`;
$('moves-title').textContent=editing?'步数上限':'剩余步数';$('remaining-title').textContent=editing?'已放置':'待离场';
$('moves').textContent=editing?level.moveLimit:game.moves;$('remaining').innerHTML=`${editing?level.tiles.length:game.tiles.length}<small> / ${editing?level.cells.length:level.tiles.length}</small>`;
$('mini-caption').innerHTML=editing?'MAKE YOUR<br>OWN WAY ↗':'FOLLOW<br>THE FLOW ↗';
$('difficulty-label').textContent=`${a.label} · ${a.score}`;$('score-value').textContent=a.score;$('score-name').textContent=a.label;$('score-bars').innerHTML=bars(a);$('score-summary').textContent=`${level.tiles.length} 枚棋子 · ${a.depth} 层解锁 · 平均 ${a.meanTurns.toFixed(1)} 次转弯`;
$('undo-button').disabled=game.history.length===0||!!animation||!!rotationAnimation;
$('orientation-title').textContent=editing?'最少旋转':'底盘朝向';$('orientation').innerHTML=editing?`${a.rotations}<small>次</small>`:`${game.orientation*60}<small>°</small>`;
for(const id of ['rotate-left','rotate-right'])$(id).disabled=!!animation||!!rotationAnimation||game.won||game.moves<=0||!canRotate(game.level);
$('hint-button').disabled=!!animation||!!rotationAnimation||game.won;
$('instruction').innerHTML=`<span class="pulse-dot"></span>${editing?({tile:'点击空格放置棋子;点击已有棋子更新方向和配色',rotate:'点击棋子,将箭头顺时针旋转 60°',track:'点击格子,循环切换 15 种轨道连接', 'rotate-track':'点击底板,整体旋转轨道 60°',erase:'点击棋子,将它从关卡中移除',inspect:'点击棋子,查看完整移动路线'}[tool]):(game.won?'所有棋子都找到出口了!':game.lost?'撤销一步,重新寻找出口':available(game.level,game.tiles).length?'点击箭头离场 · 拖动边缘或按按钮转动底盘':'暂时过不去?转一下底盘,换条路线!')}`;
if(!editing&&!animation&&!rotationAnimation&&!game.won&&game.moves>0&&!available(game.level,game.tiles).length){
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
const action=solution.actions[0];
if(solution.solvable&&action?.type==='rotate'){
const right=action.direction===1;
$(right?'rotate-right':'rotate-left').classList.add('hinted');
$('instruction').textContent=`这条路绕回了起点 · 点${right?'右':'左'}转 60°,再点击棋子`;
}else if(solution.reachable){$('instruction').textContent='步数不够了 · 撤销碰撞或重来,再按提示寻找出口';}
}
if(editing){$('editor-valid').textContent=level.tiles.length===0?'○ 等待放置棋子':a.exhausted?'… 尚未确定可解性':a.solvable?'✓ 已验证可解':'! 关卡暂不可解';$('editor-valid').classList.toggle('error',!a.solvable&&!a.exhausted&&level.tiles.length>0);$('editor-score').innerHTML=`${a.score}<small>/100</small>`;$('editor-analysis-text').textContent=`${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转 · ${a.minimumMoves??'—'} 步通关${a.exhausted?' · 搜索达到上限,尚未确定':!a.reachable?' · 存在无法避开的碰撞或闭环':!a.solvable?' · 步数不足':''}。`;$('editor-bars').innerHTML=bars(a);$('trial-button').disabled=!level.tiles.length;$('editor-undo').disabled=!editHistory.length;}
}
function resize(){const rect=canvas.getBoundingClientRect();width=rect.width;height=rect.height;const dpr=Math.min(devicePixelRatio||1,3);canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);view=layout((mode==='edit'?draft:game.level).cells,width,height);draw();}
function draw(now=performance.now()){
if(!view)return;
ctx.clearRect(0,0,width,height);
const state={selected:mode==='edit'?selected:null,path,hint,reduced};
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};if(progress>=1){animation=null;renderStats();finish();}}
if(rotationAnimation){const t=Math.min(1,(now-rotationAnimation.start)/rotationAnimation.duration);state.boardAngle=-rotationAnimation.direction*Math.PI/3*(1-(1-Math.pow(1-t,3)));if(t>=1){rotationAnimation=null;renderStats();finish();}}
if(shake){const t=(now-shake.start)/550;if(t>=1)shake=null;else{state.shake=shake.id;state.shakeTime=t;}}
drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state);
if(animation||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
}
function toast(message){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),3200);}
function resetEffects(){clearTimeout(hitSoundTimer);if($('result').open)$('result').close();clearTimeout(toastTimer);$('board-toast').classList.remove('visible');$('board-toast').textContent='';animation=null;rotationAnimation=null;for(const id of ['rotate-left','rotate-right'])$(id).classList.remove('hinted');shake=null;path=null;hint=null;cancelAnimationFrame(frame);$('result').hidden=true;}
function startLevel(i){current=i;trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();}
function applyMode(){const edit=mode==='edit';document.body.classList.toggle('editing',edit);$('play-tab').classList.toggle('active',!edit);$('edit-tab').classList.toggle('active',edit);$('play-inspector').hidden=edit;$('editor-panel').hidden=!edit;$('game-tools').hidden=edit;$('editor-board-tools').hidden=!edit;$('return-editor').hidden=!trial;$('map-button').hidden=edit;$('rotation-controls').hidden=edit;}
function enterEditor(){
if(!draft){draft=clone(game.level);draft.id='custom';draft.name='我的第一条轨迹';editHistory=[];}
mode='edit';trial=false;resetEffects();applyMode();syncEditor();renderStats();resize();
}
function syncEditor(){$('editor-name').value=draft.name;$('editor-moves').value=draft.moveLimit;$('editor-radius').value=draft.cells.length>19?'3':'2';}
function checkpoint(){editHistory.push(clone(draft));if(editHistory.length>100)editHistory.shift();}
function changed(){resetEffects();renderStats();resize();}
function openResult(){ $('result').hidden=false;$('result').showModal();playSound(game.won?'win':'lose');}
$('result').addEventListener('cancel',e=>e.preventDefault());
function finish(){
if($('result').open)return;
if(mode==='edit'||animation||rotationAnimation)return;
if(game.won){
if(!trial){completed.add(current);persist();renderMap();}
$('result').innerHTML=`<div class="result-emblem">${icon('star')}${icon('star')}${icon('star')}</div><div class="eyebrow">A LITTLE MOMENT OF JOY</div><h3>${trial?'这条轨迹,通了!':'漂亮,全部过关!'}</h3><p>${game.level.tiles.length} 枚棋子全部离场 · 转动 ${game.rotations} 次<br>${game.mistakes===0?'零失误,漂亮的路线。':`还有 ${game.moves} 步余量,做得不错。`}${helpUsed?'<br>在提示的陪伴下完成':''}</p><button class="primary" id="next-level">${trial?'返回关卡工坊':current===23?'完成旅程 · 回到第一关':'下一段轨迹'} <svg><use href="#i-arrow"/></svg></button><button class="text-button" id="replay-level">再走一遍</button>`;
openResult();$('next-level').onclick=()=>trial?enterEditor():startLevel((current+1)%24);$('replay-level').onclick=restart;
}else if(game.lost){
$('result').innerHTML=`<div class="result-emblem">${icon('track')}</div><h3>${game.moves<=0?'休息一下,再试一次':'轨迹暂时打了个结'}</h3><p>${game.moves<=0?'步数用完了。撤销一步,重新寻找出口。':'剩余棋子互相阻塞,或轨道形成了闭环。'}</p><button class="primary" id="recover">${game.history.length?'撤销上一步':trial?'返回编辑关卡':'重新开始'}</button><button class="text-button" id="retry">重新开始</button>`;openResult();$('recover').onclick=()=>game.history.length?undo():trial?enterEditor():restart();$('retry').onclick=restart;
}
}
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();}
function undo(){if(animation||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已撤销一步,步数已恢复');}}
function playTile(tile){
if(animation||rotationAnimation)return;
resetEffects();const route=game.tap(tile.id);
if(route.status==='exit'){playSound('exit');animation={route,start:performance.now(),duration:reduced?100:Math.min(1700,550+route.steps.length*85)};}
else if(route.status==='blocked'||route.status==='loop'){path=route;if(route.travelPoints){animation={route,start:performance.now(),duration:reduced?500:1250};hitSoundTimer=setTimeout(()=>playSound('hit'),(reduced?500:1250)*.42);}else shake={id:tile.id,start:performance.now()};toast(route.status==='loop'?'轨道形成闭环,试着旋转底盘':route.collision?'被这枚棋子挡住了 · 先移走它,或转动底盘 · −1 步':'这条路被挡住了,换个方向试试 · −1 步');}
renderStats();draw();if(!animation)finish();
}
const matchings=[];
function enumerate(ports,map=[]){if(!ports.length){matchings.push([...map]);return;}const a=ports[0];for(let i=1;i<ports.length;i++){const b=ports[i],next=[...map];next[a]=b;next[b]=a;enumerate(ports.filter(p=>p!==a&&p!==b),next);}}
enumerate([0,1,2,3,4,5]);
function editCell(cell){
selected={q:cell.q,r:cell.r};const tile=draft.tiles.find(t=>t.q===cell.q&&t.r===cell.r);
$('selection-info').textContent=`坐标 (${cell.q}, ${cell.r}) · 连接 ${cell.tracks.map((v,i)=>i<v?`${i}↔${v}`:null).filter(Boolean).join(' / ')}`;
if(tool==='inspect'){path=tile?trace(draft,tile):null;hint=null;toast(tile?(path.status==='exit'?'路径畅通,可以离场':path.status==='loop'?'这条路线是闭环':'路线被另一枚棋子阻挡'):'请选择一枚棋子查看路线');draw();return;}
if((tool==='erase'||tool==='rotate')&&!tile){toast('这里还没有棋子');draw();return;}
checkpoint();
if(tool==='tile'){if(tile){tile.dir=direction;tile.color=color;}else draft.tiles.push({id:`custom-${Date.now()}-${draft.tiles.length}`,q:cell.q,r:cell.r,dir:direction,color});}
if(tool==='rotate')tile.dir=(tile.dir+1)%6;
if(tool==='erase')draft.tiles=draft.tiles.filter(t=>t!==tile);
if(tool==='track'){const index=matchings.findIndex(m=>m.every((p,i)=>p===cell.tracks[i]));cell.tracks=[...matchings[(index+1)%matchings.length]];}
if(tool==='rotate-track')cell.tracks=rotateTracks(cell.tracks);
$('selection-info').textContent=`坐标 (${cell.q}, ${cell.r}) · 连接 ${cell.tracks.map((v,i)=>i<v?`${i}↔${v}`:null).filter(Boolean).join(' / ')}`;
changed();
}
function rotateGame(direction) {
if(animation||rotationAnimation||mode==='edit')return;
resetEffects();const result=game.rotate(direction);
if(result.status!=='rotated'){toast(result.status==='rotation-disabled'?'这个底板不支持整盘旋转':'当前无法旋转');return;}
playSound('rotate');
rotationAnimation={direction,start:performance.now(),duration:reduced?100:420};
renderStats();draw();
}
$('rotate-left').onclick=()=>rotateGame(-1);$('rotate-right').onclick=()=>rotateGame(1);
let pointerStart=null;
canvas.addEventListener('pointerdown',e=>{if(!view)return;const r=canvas.getBoundingClientRect();pointerStart={x:e.clientX-r.left,y:e.clientY-r.top};canvas.setPointerCapture(e.pointerId);});
canvas.addEventListener('pointercancel',()=>{pointerStart=null;});
canvas.addEventListener('pointerup',e=>{
if(animation||rotationAnimation||!view){pointerStart=null;return;}
const rect=canvas.getBoundingClientRect(),x=e.clientX-rect.left,y=e.clientY-rect.top;
if(pointerStart&&Math.hypot(x-pointerStart.x,y-pointerStart.y)>14){
const start=pointerStart;pointerStart=null;
if(mode!=='edit'&&Math.hypot(start.x-view.ox,start.y-view.oy)>width*.28){
let delta=Math.atan2(y-view.oy,x-view.ox)-Math.atan2(start.y-view.oy,start.x-view.ox);
if(delta>Math.PI)delta-=Math.PI*2;if(delta<-Math.PI)delta+=Math.PI*2;
if(Math.abs(delta)>.18)rotateGame(delta>0?1:-1);
}
return;
}
pointerStart=null;
const cell=hitCell((mode==='edit'?draft:game.level).cells,view,x,y);
if(!cell)return;if(mode==='edit')editCell(cell);else{const tile=game.tiles.find(t=>t.q===cell.q&&t.r===cell.r);if(tile)playTile(tile);}
});
$('level-list').onclick=e=>{const b=e.target.closest('[data-level]');if(b)startLevel(Number(b.dataset.level));};
$('play-tab').onclick=()=>{if(mode==='edit'||trial)startLevel(current);};$('edit-tab').onclick=enterEditor;$('workshop-link').onclick=enterEditor;
$('undo-button').onclick=undo;$('reset-button').onclick=restart;
$('hint-button').onclick=()=>{
if(animation||rotationAnimation)return;
resetEffects();helpUsed=true;
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
if(!solution.reachable){toast(solution.exhausted?'路线较复杂,暂未找到提示':'当前关卡无法避开所有碰撞,请撤销或重新编辑');return;}
if(!solution.solvable){toast(`至少还需要 ${solution.minimumMoves} 步,建议撤销恢复步数`);return;}
const action=solution.actions[0];if(!action)return;
if(action.type==='rotate'){$(action.direction===1?'rotate-right':'rotate-left').classList.add('hinted');toast(`下一步:${action.direction===1?'右转':'左转'}底盘 60°,新的路线就打开了`);}
else{const tile=game.tiles.find(t=>t.id===action.id);hint=tile.id;path=trace(game.level,tile,game.tiles);toast('这枚棋子沿途不会碰撞,可以离场');}
draw();
};
$('tool-palette').onclick=e=>{const b=e.target.closest('[data-tool]');if(!b)return;tool=b.dataset.tool;document.querySelectorAll('[data-tool]').forEach(x=>x.classList.toggle('active',x===b));path=null;hint=null;renderStats();draw();};
$('directions').innerHTML=['↑','↗','↘','↓','↙','↖'].map((s,i)=>`<button data-direction="${i}" class="${i===direction?'active':''}" aria-label="方向 ${s}">${icon('arrow',`style="transform:rotate(${i*60}deg)"`)}</button>`).join('');
$('directions').onclick=e=>{const b=e.target.closest('[data-direction]');if(!b)return;direction=Number(b.dataset.direction);document.querySelectorAll('[data-direction]').forEach(x=>x.classList.toggle('active',x===b));};
$('swatches').innerHTML=COLORS.map((c,i)=>`<button style="background:${c}" data-color="${i}" aria-label="${['冰蓝','糖果粉','蜜橙','葡萄紫'][i]}" class="${i===color?'active':''}"></button>`).join('');
$('swatches').onclick=e=>{const b=e.target.closest('[data-color]');if(!b)return;color=Number(b.dataset.color);document.querySelectorAll('[data-color]').forEach(x=>x.classList.toggle('active',x===b));};
$('editor-name').onchange=()=>{checkpoint();draft.name=$('editor-name').value.trim()||'未命名轨迹';syncEditor();changed();};
$('editor-moves').onchange=()=>{checkpoint();draft.moveLimit=Math.max(1,Math.min(999,Math.round(Number($('editor-moves').value)||1)));syncEditor();changed();};
$('editor-radius').onchange=()=>{checkpoint();const cells=hexagon(Number($('editor-radius').value)),old=new Map(draft.cells.map(c=>[key(c.q,c.r),c]));draft.cells=cells.map(c=>old.get(key(c.q,c.r))||c);const keys=new Set(cells.map(c=>key(c.q,c.r)));draft.tiles=draft.tiles.filter(t=>keys.has(key(t.q,t.r)));selected=null;changed();};
function rotateDraft(direction){if(!canRotate(draft)){toast('不规则底板不能整体旋转');return;}checkpoint();draft=rotateBoard(draft,direction);changed();}
$('editor-turn-left').onclick=()=>rotateDraft(-1);$('editor-turn-right').onclick=()=>rotateDraft(1);
$('editor-undo').onclick=()=>{if(!editHistory.length)return;draft=editHistory.pop();selected=null;syncEditor();changed();};
$('generate-button').onclick=()=>{checkpoint();const radius=Number($('editor-radius').value);draft=rotateBoard(generateLevel(Math.floor(Math.random()*1e8),radius,radius===2?13:27,.7),1);draft.moveLimit=draft.tiles.length+12;draft.name='偶遇的轨迹';draft.id='custom';selected=null;syncEditor();changed();toast('已生成,并通过旋转与碰撞规则验证');};
$('blank-button').onclick=()=>{checkpoint();const radius=Number($('editor-radius').value);draft={version:1,id:'custom',name:'一条新的轨迹',cells:hexagon(radius),tiles:[],moveLimit:30};selected=null;syncEditor();changed();};
$('trial-button').onclick=()=>{const errors=validateLevel(draft);if(errors.length){toast(errors[0]);return;}mode='play';trial=true;game=new Game(draft);helpUsed=false;resetEffects();applyMode();renderStats();resize();finish();window.scrollTo({top:0,behavior:reduced?'instant':'smooth'});};
$('return-editor').onclick=enterEditor;
$('save-button').onclick=()=>{try{localStorage.setItem('hex-trails-draft',JSON.stringify(draft));toast('关卡已保存到此浏览器');}catch{toast('本机存储不可用,请导出 JSON 保存');}};
$('load-button').onclick=()=>{try{const data=localStorage.getItem('hex-trails-draft');if(!data){toast('还没有本机存档');return;}loadDraft(JSON.parse(data));toast('已读取本机关卡');}catch(e){toast(`读取失败:${e.message}`);}};
function loadDraft(data){const errors=validateLevel(data);if(errors.length)throw new Error(errors.join(';'));checkpoint();draft=clone(data);selected=null;syncEditor();changed();}
$('export-button').onclick=()=>{const errors=validateLevel(draft);if(errors.length){toast(errors[0]);return;}const blob=new Blob([JSON.stringify(draft,null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='hex-trails-level.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast('已导出通用 JSON 关卡');};
$('import-button').onclick=()=>$('import-file').click();
$('import-file').onchange=async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>200000)throw new Error('文件不能超过 200 KB');loadDraft(JSON.parse(await file.text()));toast('导入成功,已重新计算可解性与难度');}catch(error){showDialog(`<div class="eyebrow">IMPORT CHECK</div><h2>这份关卡需要调整</h2><p>${escapeHtml(error.message)}</p><p>当前编辑内容已保留。</p>`);}e.target.value='';};
function showDialog(html){$('dialog-content').innerHTML=html;$('info-dialog').showModal();}
$('info-dialog').querySelector('.dialog-close').onclick=()=>$('info-dialog').close();
$('info-dialog').onclick=e=>{if(e.target===$('info-dialog')){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}};
function showScore(){const a=analyze(mode==='edit'?draft:game.initialLevel);showDialog(`<div class="eyebrow">LEVEL CHALLENGE</div><h2>这关有多难?</h2><p><b>${a.score}/100 · ${a.label}</b>,六项结构分相加后取整。</p>${bars(a)}<p>已计入沿途碰撞;最优解需要 ${a.rotations} 次旋转、${a.minimumMoves??'—'} 步。当前初始布局有 ${a.free} 枚棋子可以安全离场。</p><ul><li>棋子规模 15 分;轨道转弯 20 分。</li><li>解锁层次 20 分;初始碰撞比例 15 分。</li><li>必要旋转 20 分:最少旋转数 ÷ 4,封顶。</li><li>步数压力 10 分:按包含旋转的最短解计算余量。</li></ul><p class="dialog-note">评分是结构估计,未经真人通关率校准。不可解与搜索未确定会单独标识,不作为高难度关卡。</p>`);}
$('score-help').onclick=showScore;$('difficulty-button').onclick=showScore;
function showHelp(){showDialog(`<button id="sound-toggle" type="button">音效:${soundEnabled()?'开':'关'}</button>`+'<div class="eyebrow">HOW TO PLAY</div><h2>转转六角,轻松过关!</h2><p><b>① 点击箭头</b><br>棋子沿轨道出发,遇到弯道自动转向。</p><p><b>② 转动底盘</b><br>按左右按钮,或沿棋盘边缘拖动,每次旋转 60°。上层棋子位置和箭头不动,底下的轨道转到新位置,路线随之改变。</p><p><b>③ 小心碰撞</b><br>会检查棋子整段运动轨迹,包括弯道擦碰。棋子是有宽度的,不能穿过旁边的棋子;碰撞会让棋子退回原位。</p><p><b>④ 全部离场</b><br>点击棋子或转动底盘各花 1 步,碰撞也扣步。撤销可以恢复棋盘、方向与步数。没有倒计时。</p><p class="dialog-note">卡住了?提示既能找到安全棋子,也会告诉你什么时候该转盘。四种颜色只负责好看,不改变规则。</p>');}
$('dialog-content').addEventListener('click',e=>{if(e.target.closest('#sound-toggle')){toggleSound();$('sound-toggle').textContent=`音效:${soundEnabled()?'开':'关'}`;}});
for(const b of document.querySelectorAll('.help-trigger'))b.onclick=showHelp;
$('map-button').onclick=()=>{showDialog(`<div class="eyebrow">CHOOSE YOUR NEXT TRAIL</div><h2>挑一关,转起来</h2><div class="level-dialog-grid">${LEVELS.map((l,i)=>`<button class="level-btn ${i===current?'current':''}" data-pick="${i}">${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).score} / 100</small></button>`).join('')}</div><p>24 个旋转谜题 · 每关都已验证可以避开碰撞。</p>`);$('dialog-content').onclick=e=>{const b=e.target.closest('[data-pick]');if(b){$('info-dialog').close();startLevel(Number(b.dataset.pick));}};};
const atlas=new Image();atlas.onload=()=>{painter.atlas=atlas;draw();};atlas.onerror=()=>toast('棋子图片加载失败,已切换基础绘制');atlas.src=TILE_ATLAS;
new ResizeObserver(resize).observe(canvas);
applyMode();renderMap();renderStats();resize();
installUI();
if(new URLSearchParams(location.search).get('editor')==='1')enterEditor();
+15
View File
@@ -0,0 +1,15 @@
let ctx;let enabled=true;try{enabled=localStorage.getItem('hex-sound')!=='off';}catch{}
export function soundEnabled(){return enabled;}
export function toggleSound(){enabled=!enabled;try{localStorage.setItem('hex-sound',enabled?'on':'off');}catch{}return enabled;}
function tone(freq,when,duration,volume=.045,type='sine'){
if(!enabled||!ctx||ctx.state!=='running')return;const o=ctx.createOscillator(),g=ctx.createGain(),t=ctx.currentTime+when;o.type=type;o.frequency.setValueAtTime(freq,t);g.gain.setValueAtTime(0,t);g.gain.linearRampToValueAtTime(volume,t+.008);g.gain.exponentialRampToValueAtTime(.001,t+duration);o.connect(g);g.connect(ctx.destination);o.start(t);o.stop(t+duration+.02);
}
export function unlockAudio(){try{ctx??=new (window.AudioContext||window.webkitAudioContext)();if(ctx.state==='suspended')ctx.resume().catch(()=>{});}catch{}}
export function playSound(name){
if(name==='win'){[523,659,784].forEach((f,i)=>tone(f,.3+i*.19,.3,.055));return;}
if(name==='hit'){tone(145,0,.12,.075,'triangle');tone(85,.025,.16,.04);return;}
if(name==='rotate'){tone(330,0,.09);tone(440,.075,.13);return;}
if(name==='exit'){tone(660,0,.1);tone(880,.065,.13);return;}
if(name==='lose'){tone(294,0,.2);tone(220,.17,.25);return;}
tone(480,0,.06,.023);
}
+17
View File
@@ -0,0 +1,17 @@
import { segmentHexHit } from './path-math.js';
// Approach, readable contact pause, recoil. Used by both browser and Cocos.
export function collisionPhase(progress, reduced=false){
const t=Math.max(0,Math.min(1,progress));
const distance=t<.42?1-Math.pow(1-t/.42,2):t<.64?1:Math.pow(1-(t-.64)/.36,2);
const impact=t>=.42&&t<.64?Math.sin((t-.42)/.22*Math.PI):0;
return {distance,impact,kick:reduced?0:Math.sin(Math.max(0,t-.42)/.22*Math.PI*2)*impact};
}
// Stop the drawn silhouette at first contact, avoiding visible overlap from collider tolerance.
export function contactPath(points,blocker,radius){
if(!blocker)return points;
for(let i=1;i<points.length;i++){
const a=points[i-1],b=points[i],t=segmentHexHit(a,b,blocker,radius*2);
if(t!==null)return [...points.slice(0,i),{x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t}];
}
return points;
}
+259
View File
@@ -0,0 +1,259 @@
import { position, PORTS, pathPoints, sweepPath } from './path-math.js';
export { position } from './path-math.js';
// Engine-independent model. Coordinates: flat-top axial hexagons, y points down.
export const DIRS = [[0, -1], [1, -1], [1, 0], [0, 1], [-1, 1], [-1, 0]];
export const key = (q, r) => `${q},${r}`;
export const clone = value => JSON.parse(JSON.stringify(value));
export const opposite = d => (d + 3) % 6;
export function hexagon(radius) {
const cells = [];
for (let q = -radius; q <= radius; q++) {
for (let r = Math.max(-radius, -q - radius); r <= Math.min(radius, -q + radius); r++) {
cells.push({ q, r, tracks: [3, 4, 5, 0, 1, 2] });
}
}
return cells;
}
export function random(seed) {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export function randomTracks(rng) {
const ports = [0, 1, 2, 3, 4, 5], result = [];
while (ports.length) {
const a = ports.shift(), b = ports.splice(Math.floor(rng() * ports.length), 1)[0];
result[a] = b;
result[b] = a;
}
return result;
}
export function rotateTracks(tracks) {
const next = [];
tracks.forEach((p, i) => { next[(i + 1) % 6] = (p + 1) % 6; });
return next;
}
export function validateLevel(input) {
const errors = [];
if (!input || typeof input !== 'object') return ['关卡必须是 JSON 对象'];
if (input.rotationEnabled !== undefined && typeof input.rotationEnabled !== 'boolean') errors.push('rotationEnabled 必须是布尔值');
if (input.version !== 1) errors.push('version 必须为 1');
if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 60) errors.push('名称须为 1–60 个字符');
if (!Array.isArray(input.cells) || input.cells.length < 1 || input.cells.length > 61) return [...errors, '底板须为 1–61 格'];
if (!Array.isArray(input.tiles) || input.tiles.length > input.cells.length) return [...errors, '棋子数量不能超过底板'];
const coords = new Set();
for (const c of input.cells) {
if (!c || !Number.isInteger(c.q) || !Number.isInteger(c.r) || Math.max(Math.abs(c.q), Math.abs(c.r), Math.abs(c.q + c.r)) > 4) {
errors.push('坐标必须为半径 4 内的轴向整数坐标'); continue;
}
const k = key(c.q, c.r);
if (coords.has(k)) errors.push(`底板重复:${k}`);
coords.add(k);
if (!Array.isArray(c.tracks) || c.tracks.length !== 6 || c.tracks.some((v, i) => !Number.isInteger(v) || v < 0 || v > 5 || v === i || c.tracks[v] !== i)) errors.push(`轨道 ${k} 必须两两配对六个端口`);
}
const occupied = new Set(), ids = new Set();
for (const t of input.tiles) {
if (!t || !coords.has(key(t.q, t.r))) { errors.push('棋子必须位于底板上'); continue; }
const k = key(t.q, t.r);
if (occupied.has(k)) errors.push(`棋子重叠:${k}`);
occupied.add(k);
if (typeof t.id !== 'string' || !t.id || ids.has(t.id)) errors.push('棋子 id 须为唯一非空字符串');
ids.add(t.id);
if (!Number.isInteger(t.dir) || t.dir < 0 || t.dir > 5) errors.push('方向必须为 0–5');
if (!Number.isInteger(t.color) || t.color < 0 || t.color > 3) errors.push('颜色必须为 0–3');
}
if (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 999) errors.push('步数须为 1–999');
return [...new Set(errors)];
}
// At a crossing, tracks do not connect. Entering an occupied cell blocks movement.
export function trace(level, tile, tiles = level.tiles, ignoreOccupancy = false) {
const finish = route => {
if (ignoreOccupancy) return route;
const obstacles = tiles.filter(t => t.id !== tile.id).map(t => ({ ...position(t.q,t.r), id:t.id }));
const points=pathPoints(route);
// Continue the exit ray beyond holes/irregular edges, so escape animation cannot pass through a farther piece.
if(route.status==='exit'){const last=points[points.length-1],port=PORTS[route.steps[route.steps.length-1].exit];points.push({x:last.x+port.x*24,y:last.y+port.y*24});}
const collision = sweepPath(points, obstacles);
if (!collision) return route;
return { ...route, status:'blocked', blocker:collision.blocker, collision:true, collisionPoint:collision.point, travelPoints:collision.points };
};
const board = new Map(level.cells.map(c => [key(c.q, c.r), c]));
const occupied = new Map(tiles.filter(t => t.id !== tile.id).map(t => [key(t.q, t.r), t]));
let q = tile.q, r = tile.r, exit = tile.dir;
const steps = [{ q, r, enter: null, exit }], visited = new Set([`${q},${r},${exit}`]);
const blockers = new Set();
for (let n = 0; n < level.cells.length * 6 + 1; n++) {
const [dq, dr] = DIRS[exit]; q += dq; r += dr;
const cell = board.get(key(q, r));
if (!cell) return finish({ status: 'exit', steps, blockers: [...blockers], end: { q, r } });
const occupant = occupied.get(key(q, r));
if (occupant) {
blockers.add(occupant.id);
if (!ignoreOccupancy) return finish({ status: 'blocked', steps, blocker: occupant.id, blockers: [...blockers], end: { q, r } });
}
const enter = opposite(exit); exit = cell.tracks[enter];
steps.push({ q, r, enter, exit });
const state = `${q},${r},${exit}`;
if (visited.has(state)) return finish({ status: 'loop', steps, blockers: [...blockers] });
visited.add(state);
}
return finish({ status: 'loop', steps, blockers: [...blockers] });
}
export function available(level, tiles = level.tiles) {
return tiles.filter(t => trace(level, t, tiles).status === 'exit');
}
export function solve(level) {
let remaining = [...level.tiles];
const order = [], waves = [];
while (remaining.length) {
const free = available(level, remaining);
if (!free.length) break;
const ids = new Set(free.map(t => t.id));
waves.push(free.map(t => t.id)); order.push(...ids);
remaining = remaining.filter(t => !ids.has(t.id));
}
return { solvable: remaining.length === 0 && level.tiles.length > 0 && level.moveLimit >= level.tiles.length, order, waves, stuck: remaining.map(t => t.id) };
}
export function rotateBoard(level, turns = 1) {
let cells = clone(level.cells);
const count = ((turns % 6) + 6) % 6;
for (let i = 0; i < count; i++) {
cells = cells.map(c => ({ ...c, q: -c.r || 0, r: c.q + c.r || 0, tracks: rotateTracks(c.tracks) }));
}
return { ...level, cells };
}
export function canRotate(level) {
const coords = new Set(level.cells.map(c => key(c.q, c.r)));
return rotateBoard(level).cells.every(c => coords.has(key(c.q, c.r)));
}
// Rotation search: removing an available piece is always safe (occupancy only decreases).
// Exhaust available pieces at each orientation, then breadth-first search CW/CCW turns.
// Each edge costs one turn; total solution cost is tile count + shortest turn count.
export function solveRotations(level, maxStates = 20000) {
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
const rotating = canRotate(level) && level.rotationEnabled !== false;
function drain(orientation, remaining, actions, waves) {
let pieces = remaining;
const nextActions = [...actions], nextWaves = [...waves];
while (pieces.length) {
const free = available(boards[orientation], pieces);
if (!free.length) break;
const ids = new Set(free.map(t => t.id));
nextActions.push(...free.map(t => ({ type: 'tap', id: t.id })));
nextWaves.push([...ids]);
pieces = pieces.filter(t => !ids.has(t.id));
}
return { orientation, remaining: pieces, actions: nextActions, waves: nextWaves };
}
const first = drain(0, [...level.tiles], [], []);
const queue = [{ ...first, rotations: 0 }], seen = new Set();
let visited = 0;
for (let head = 0; head < queue.length && visited < maxStates; head++) {
const node = queue[head];
const signature = node.orientation + ':' + JSON.stringify(node.remaining.map(t => t.id).sort());
if (seen.has(signature)) continue;
seen.add(signature); visited++;
if (!node.remaining.length && level.tiles.length) {
const minimumMoves = level.tiles.length + node.rotations;
return { solvable: minimumMoves <= level.moveLimit, reachable: true, minimumMoves, rotations: node.rotations, actions: node.actions, order: node.actions.filter(a => a.type === 'tap').map(a => a.id), waves: node.waves, stuck: [], states: visited, exhausted: false };
}
if (!rotating) continue;
for (const direction of [1, -1]) {
const orientation = (node.orientation + direction + 6) % 6;
const child = drain(orientation, node.remaining, [...node.actions, { type: 'rotate', direction }], node.waves);
const signature = child.orientation + ':' + JSON.stringify(child.remaining.map(t => t.id).sort());
if (!seen.has(signature)) queue.push({ ...child, rotations: node.rotations + 1 });
}
}
return { solvable: false, reachable: false, minimumMoves: null, rotations: 0, actions: [], order: [], waves: first.waves, stuck: first.remaining.map(t => t.id), states: visited, exhausted: visited >= maxStates };
}
export function analyze(level) {
const solution = solveRotations(level), n = level.tiles.length;
const routes = level.tiles.map(t => trace(level, t, level.tiles, true));
const meanTurns = n ? routes.reduce((sum, p) => sum + p.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length, 0) / n : 0;
const free = available(level).length;
const parts = {
scale: Math.min(1, n / 37) * 15,
turns: Math.min(1, meanTurns / 5) * 20,
depth: Math.min(1, Math.max(0, solution.waves.length - 1) / 9) * 20,
blocked: (n ? 1 - free / n : 0) * 15,
rotation: Math.min(1, solution.rotations / 4) * 20,
precision: (n ? Math.min(1, Math.max(0, 1 - (level.moveLimit - (solution.minimumMoves ?? n)) / Math.max(1, n * 0.4))) : 0) * 10,
};
const score = Math.round(Object.values(parts).reduce((a, b) => a + b, 0));
return { score, parts, meanTurns, free, depth: solution.waves.length, ...solution, label: score < 30 ? '轻松' : score < 50 ? '进阶' : score < 70 ? '烧脑' : '挑战' };
}
export function generateLevel(seed, radius = 3, target = 25, complexity = 0.7) {
const rng = random(seed);
const level = { version: 1, id: `seed-${seed}`, name: '新生轨迹', seed, cells: hexagon(radius), tiles: [], moveLimit: target + 4 };
level.cells.forEach(c => { c.tracks = randomTracks(rng); });
// Reverse construction: each inserted tile can leave before every earlier tile.
for (let i = 0; i < target; i++) {
const occupied = new Set(level.tiles.map(t => key(t.q, t.r))), candidates = [];
for (const c of level.cells) {
if (occupied.has(key(c.q, c.r))) continue;
for (let dir = 0; dir < 6; dir++) {
const t = { id: `t${i}`, q: c.q, r: c.r, dir, color: Math.floor(rng() * 4) };
const p = trace(level, t, [...level.tiles, t]);
if (p.status === 'exit') candidates.push({ t, weight: rng() + p.steps.length * complexity });
}
}
if (!candidates.length) break;
candidates.sort((a, b) => b.weight - a.weight);
const pick = Math.floor(rng() * Math.min(6, candidates.length));
level.tiles.push(candidates[pick].t);
}
level.moveLimit = level.tiles.length + Math.round(level.tiles.length * (1 - complexity) * 0.4) + 1;
return level;
}
export class Game {
constructor(level) {
this.initialLevel = clone(level); this.level = clone(level); this.tiles = clone(level.tiles);
this.moves = level.moveLimit; this.history = []; this.mistakes = 0; this.orientation = 0; this.rotations = 0;
}
get won() { return this.tiles.length === 0; }
get lost() {
if (this.won) return false;
if (this.moves <= 0) return true;
// Lack of immediate exits is not failure: the board may be rotated to open a route.
if (canRotate(this.level) && this.level.rotationEnabled !== false) return false;
return available(this.level, this.tiles).length === 0;
}
snapshot() {
this.history.push({ level: clone(this.level), tiles: clone(this.tiles), moves: this.moves, mistakes: this.mistakes, orientation: this.orientation, rotations: this.rotations });
}
rotate(direction = 1) {
if (direction !== 1 && direction !== -1) return { status: 'invalid-direction' };
if (this.won || this.lost) return { status: 'finished' };
if (!canRotate(this.level) || this.level.rotationEnabled === false) return { status: 'rotation-disabled' };
this.snapshot();
this.level = rotateBoard(this.level, direction);
this.orientation = (this.orientation + direction + 6) % 6;
this.rotations++; this.moves--;
return { status: 'rotated', direction, orientation: this.orientation };
}
tap(id) {
if (this.won || this.lost) return { status: 'finished' };
const tile = this.tiles.find(t => t.id === id);
if (!tile) return { status: 'missing' };
const route = trace(this.level, tile, this.tiles);
this.snapshot(); this.moves--;
if (route.status === 'exit') this.tiles = this.tiles.filter(t => t.id !== id);
else this.mistakes++;
return { ...route, tile: clone(tile) };
}
undo() {
const previous = this.history.pop();
if (!previous) return false;
Object.assign(this, previous); return true;
}
}
+54
View File
@@ -0,0 +1,54 @@
// Source of truth for the palette. Run npm run design after changing values.
// See /Design.md for semantic pairings, ratios and component rules.
export const THEME = Object.freeze({
background: '#A8BECF',
board: '#F2EEE5',
boardGrid: '#7C8B94',
track: '#6D808B',
trackHalo: '#DFE3E2',
surface: '#F7F6F2',
textPrimary: '#273B50',
textSecondary: '#314A5F',
primary: '#6652B8',
onPrimary: '#FFFFFF',
secondary: '#E4EAF0',
utility: '#52697E',
onUtility: '#FFFFFF',
selection: '#DFD9F3',
onSelection: '#3F327A',
disabled: '#CBD4DD',
onDisabled: '#526579',
input: '#FFFFFF',
reward: '#B9760C',
warning: '#A44825',
warningSurface: '#F9E3D2',
scrim: '#273B507A',
focus: '#3F327A',
// Approximate center pixels of the accepted, unchanged generated token art.
tileBlue: '#2392FD',
tilePink: '#FC3E80',
tileYellow: '#FDBC28',
tilePurple: '#874FFD',
arrowLight: '#FFFFFF',
arrowDark: '#273B50',
});
export const SURFACES = Object.freeze({
surface: 'surface', primary: 'primary', secondary: 'secondary', utility: 'utility',
warning: 'warningSurface', reward: 'reward', input: 'input', selection: 'selection',
disabled: 'disabled', tileBlue: 'tileBlue', tilePink: 'tilePink', tileYellow: 'tileYellow', tilePurple: 'tilePurple',
});
export const TYPOGRAPHY = Object.freeze({level:28,moves:32,dialogTitle:24,body:14,label:12,caption:11});
export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:1250});
export const RADII = Object.freeze({control:14,dialog:24,small:8});
export function luminance(hex){return hex.slice(1,7).match(/../g).map(v=>parseInt(v,16)/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((a,v,i)=>a+v*[.2126,.7152,.0722][i],0);}
export function contrast(a,b){const x=luminance(a),y=luminance(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05);}
export const CONTRAST_PAIRS = [
['正文 / 背景','textPrimary','background',4.5], ['辅助文字 / 背景','textSecondary','background',4.5],
['正文 / 弹窗','textPrimary','surface',4.5], ['辅助文字 / 弹窗','textSecondary','surface',4.5],
['主按钮文字','onPrimary','primary',4.5], ['工具图标','onUtility','utility',3],
['次按钮文字','textPrimary','secondary',4.5], ['选中标签','onSelection','selection',4.5],
['阻挡提示文字','warning','warningSurface',4.5], ['分格 / 棋盘','boardGrid','board',3],
['轨道 / 棋盘','track','board',3], ['轨道 / 浅色外缘','track','trackHalo',3],
['方向箭头 / 蓝棋子','arrowLight','tileBlue',3], ['方向箭头 / 粉棋子','arrowLight','tilePink',3],
['方向箭头 / 黄棋子','arrowDark','tileYellow',3], ['方向箭头 / 紫棋子','arrowLight','tilePurple',3],
];
+36
View File
@@ -0,0 +1,36 @@
import { position, PORTS, trackPoints } from './path-math.js';
export { hexPoints, trackPoints, PORTS } from './path-math.js';
export function layout(cells,width,height) {
const raw=cells.map(c=>position(c.q,c.r));
const minX=Math.min(...raw.map(p=>p.x))-1,maxX=Math.max(...raw.map(p=>p.x))+1;
const minY=Math.min(...raw.map(p=>p.y))-.866,maxY=Math.max(...raw.map(p=>p.y))+.866;
const size=Math.min((width-48)/(maxX-minX),(height-50)/(maxY-minY),47);
return {size,ox:width/2-(minX+maxX)/2*size,oy:height/2-(minY+maxY)/2*size};
}
export function center(cell, view) { const p=position(cell.q,cell.r,view.size); return {x:p.x+view.ox,y:p.y+view.oy}; }
export function routePoints(route,view,extend=false) {
if(route.travelPoints)return route.travelPoints.map(p=>({x:p.x*view.size+view.ox,y:p.y*view.size+view.oy}));
const points=[];
route.steps.forEach(s=>{
const c=center(s,view), end=PORTS[s.exit];
if(s.enter===null) points.push(c,{x:c.x+end.x*view.size,y:c.y+end.y*view.size});
else points.push(...trackPoints(c.x,c.y,view.size,s.enter,s.exit));
});
if(route.end) {
const c=center(route.end,view);points.push(c);
if(extend&&route.status==='exit') {const port=PORTS[route.steps[route.steps.length-1].exit];points.push({x:c.x+port.x*120,y:c.y+port.y*120});}
}
return points;
}
export function pointAlong(points,ratio) {
const lengths=points.slice(1).map((p,i)=>Math.hypot(p.x-points[i].x,p.y-points[i].y));
let distance=lengths.reduce((a,b)=>a+b,0)*Math.max(0,Math.min(1,ratio));
for(let i=0;i<lengths.length;i++) {
if(distance<=lengths[i]&&lengths[i]>0) {const t=distance/lengths[i],a=points[i],b=points[i+1];return {x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,angle:Math.atan2(b.y-a.y,b.x-a.x)};}
distance-=lengths[i];
}
const a=points[points.length-2]||points[0],b=points[points.length-1];return {...b,angle:Math.atan2(b.y-a.y,b.x-a.x)};
}
export function hitCell(cells,view,x,y) {
return cells.find(cell=>{const c=center(cell,view),dx=Math.abs(x-c.x)/view.size,dy=Math.abs(y-c.y)/view.size;return dx<=1&&dy<=Math.sqrt(3)/2&&Math.sqrt(3)*dx+dy<=Math.sqrt(3);});
}
+2
View File
File diff suppressed because one or more lines are too long
+43
View File
@@ -0,0 +1,43 @@
// Shared geometry used by drawing AND swept collision detection. No host dependencies.
export const PORTS = [0,1,2,3,4,5].map(d => ({x:Math.cos(-Math.PI/2+d*Math.PI/3)*Math.sqrt(3)/2,y:Math.sin(-Math.PI/2+d*Math.PI/3)*Math.sqrt(3)/2}));
export function position(q,r,size=1){return{x:1.5*q*size,y:Math.sqrt(3)*(r+q/2)*size};}
export function hexPoints(x,y,r){return Array.from({length:6},(_,i)=>({x:x+Math.cos(i*Math.PI/3)*r,y:y+Math.sin(i*Math.PI/3)*r}));}
export function trackPoints(x,y,r,a,b){
const p=PORTS[a],q=PORTS[b],points=[];
for(let i=0;i<=20;i++){const t=i/20,u=1-t;points.push({x:x+r*(u*u*u*p.x+3*u*u*t*p.x*.28+3*u*t*t*q.x*.28+t*t*t*q.x),y:y+r*(u*u*u*p.y+3*u*u*t*p.y*.28+3*u*t*t*q.y*.28+t*t*t*q.y)});}
return points;
}
export function pathPoints(route){
const points=[];
for(const s of route.steps){const c=position(s.q,s.r),p=PORTS[s.exit];if(s.enter===null)points.push(c,{x:c.x+p.x,y:c.y+p.y});else points.push(...trackPoints(c.x,c.y,1,s.enter,s.exit));}
if(route.end)points.push(position(route.end.q,route.end.r));
return points;
}
// Collider is the solid top face, not the decorative drop shadow or extruded side.
export const COLLIDER_RADIUS = 0.88;
const NORMALS=[{x:0,y:1},{x:Math.sqrt(3)/2,y:.5},{x:Math.sqrt(3)/2,y:-.5}];
// Exact swept intersection for each rendered line segment against a Minkowski hexagon.
// This avoids tunneling between animation frames, including grazing a neighboring tile.
export function segmentHexHit(a,b,center,radius=COLLIDER_RADIUS*2){
const limit=radius*Math.sqrt(3)/2;
let enter=0,exit=1;
for(const n of NORMALS){
const value=(a.x-center.x)*n.x+(a.y-center.y)*n.y,delta=(b.x-a.x)*n.x+(b.y-a.y)*n.y;
if(Math.abs(delta)<1e-10){if(Math.abs(value)>limit)return null;continue;}
let lo=(-limit-value)/delta,hi=(limit-value)/delta;if(lo>hi)[lo,hi]=[hi,lo];
enter=Math.max(enter,lo);exit=Math.min(exit,hi);if(enter>exit)return null;
}
return enter<=1&&exit>=0?Math.max(0,enter):null;
}
export function sweepPath(points,obstacles){
for(let i=1;i<points.length;i++){
const a=points[i-1],b=points[i];let first=null;
for(const o of obstacles){
if(o.x<Math.min(a.x,b.x)-1.8||o.x>Math.max(a.x,b.x)+1.8||o.y<Math.min(a.y,b.y)-1.8||o.y>Math.max(a.y,b.y)+1.8)continue;
const t=segmentHexHit(a,b,o);
if(t!==null&&(!first||t<first.t))first={t,blocker:o.id};
}
if(first){const point={x:a.x+(b.x-a.x)*first.t,y:a.y+(b.y-a.y)*first.t};return{...first,point,points:[...points.slice(0,i),point]};}
}
return null;
}
+104
View File
@@ -0,0 +1,104 @@
import { THEME } from './design-tokens.js';
import { DIRS, key } from './core.js';
import { tileSample, roundedHex } from './tile-shape.js';
import { collisionPhase, contactPath } from './collision-animation.js';
import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geometry.js';
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
export const COLORS=[THEME.tileBlue,THEME.tilePink,THEME.tileYellow,THEME.tilePurple];
const DARK=COLORS.map(()=>THEME.arrowDark);
// Scene commands remain engine-independent. sprite() is an optional atlas adapter.
export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,scale=1) {
const r=size*TILE_RADIUS*scale;
const textured=p.sprite?.(tile.color,x,y,r)===true;
if(!textured){
p.polygon(roundedHex(x,y,r),COLORS[tile.color]);
}
const a=r*.58;
const shape=[[-.72,-.23],[.06,-.23],[.06,-.59],[.83,0],[.06,.59],[.06,.23],[-.72,.23]];
const arrow=shape.map(([u,v])=>({x:x+a*(u*Math.cos(angle)-v*Math.sin(angle)),y:y-1+a*(u*Math.sin(angle)+v*Math.cos(angle))}));
p.polygon(arrow,tile.color===2?THEME.arrowDark:THEME.arrowLight);
}
export function drawScene(p,level,tiles,view,state={}) {
const {size}=view;
const angle=state.boardAngle||0,cos=Math.cos(angle),sin=Math.sin(angle);
const rotate=points=>points.map(pt=>{const x=pt.x-view.ox,y=pt.y-view.oy;return {x:view.ox+x*cos-y*sin,y:view.oy+x*sin+y*cos};});
const base={polygon:(pts,c)=>p.polygon(rotate(pts),c),line:(pts,c,w)=>p.line(rotate(pts),c,w)};
const blocked=state.animation?.route.status==='blocked';
const blocker=blocked?tiles.find(t=>t.id===state.animation.route.blocker):null;
const target=blocker?center(blocker,view):null;
const phase=blocked?collisionPhase(state.animation.progress,state.reduced):null;
const movingPath=state.animation?contactPath(routePoints(state.animation.route,view,true),target,size*TILE_RADIUS):null;
const contact=movingPath?.[movingPath.length-1];
const impactAngle=target&&contact?Math.atan2(target.y-contact.y,target.x-contact.x):0;
const cellKeys=new Set(level.cells.map(c=>key(c.q,c.r)));
const sorted=[...level.cells].sort((a,b)=>center(a,view).y-center(b,view).y);
// Complete the contiguous board before drawing any rails. A tiny overlap
// prevents antialiasing seams between touching hexagons.
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2),THEME.board);}
// Subtle cell boundaries beneath the rails: retain the hex grid without
// opening background gaps or painting over connected track endpoints.
for(const cell of sorted){
const {x,y}=center(cell,view),edge=hexPoints(x,y,size);
base.line([...edge,edge[0]],THEME.boardGrid,Math.max(1.25,size*.038));
}
const rails=[];
for(const cell of sorted) {
const {x,y}=center(cell,view);
for(let a=0;a<6;a++) if(a<cell.tracks[a]) {
// Inset the centerline by half the widest stroke at each hex side.
// Rounded line caps must also remain inside the visible cell.
const trackWidth=Math.max(4,size*.16);
const visualRadius=Math.max(0,size*.965-trackWidth/Math.sqrt(3)-.5);
const b=cell.tracks[a],points=trackPoints(x,y,size,a,b);
const exterior=d=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
const first=points[0],last=points[points.length-1],shrink=1-visualRadius/size;
const dx0=exterior(a)?(first.x-x)*shrink:0,dy0=exterior(a)?(first.y-y)*shrink:0;
const dx1=exterior(b)?(last.x-x)*shrink:0,dy1=exterior(b)?(last.y-y)*shrink:0;
points.forEach((p,i)=>{const t=i/(points.length-1),u=1-t;p.x-=u*u*u*dx0+t*t*t*dx1;p.y-=u*u*u*dy0+t*t*t*dy1;});
rails.push(points);
}
}
for(const points of rails)base.line(points,THEME.trackHalo,Math.max(4,size*.16));
for(const points of rails)base.line(points,THEME.track,Math.max(2,size*.078));
if(state.path) p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12));
if(state.selected) {
const cell=level.cells.find(c=>c.q===state.selected.q&&c.r===state.selected.r);
if(cell){const c=center(cell,view),pts=hexPoints(c.x,c.y,size*.98);p.line([...pts,pts[0]],THEME.reward,3);}
}
for(const tile of [...tiles].filter(t=>t.id!==state.animation?.route.tile.id).sort((a,b)=>center(a,view).y-center(b,view).y)) {
const c=center(tile,view);
if(tile.id===state.hint) {p.circle(c.x,c.y,size*1.13,THEME.reward);p.circle(c.x,c.y,size*1.03,THEME.warningSurface);}
let dx=0;
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
let dy=0;
if(tile.id===blocker?.id){
dx+=Math.cos(impactAngle)*phase.kick*size*.1;dy+=Math.sin(impactAngle)*phase.kick*size*.1;
if(phase.impact>0){const ring=hexPoints(c.x+dx,c.y+dy,size*1.04);p.line([...ring,ring[0]],THEME.warning,2.5);}
}
drawTile(p,c.x+dx,c.y+dy,size,tile);
}
if(state.animation) {
const {route,progress}=state.animation;
const blocked=route.status!=='exit';
const loc=pointAlong(movingPath,phase?phase.distance:blocked?Math.sin(progress*Math.PI):progress);
drawTile(p,loc.x,loc.y,size,route.tile,loc.angle,phase?1-phase.impact*.045:!blocked&&progress>.9?Math.max(.2,(1-progress)/.1):1);
if(target&&phase.impact>0){
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
for(const sign of [-1,1]){const a=impactAngle+sign*Math.PI/2,d=size*(.13+phase.impact*.12);p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+size*.15),y:y+Math.sin(a)*(d+size*.15)}],THEME.warning,2.5);}
}
}
}
export class CanvasPainter {
constructor(context) { this.ctx=context; this.atlas=null; }
sprite(index,x,y,r) {
if(!this.atlas?.complete||!this.atlas.naturalWidth)return false;
const f=tileSample(TILE_FRAMES[index]),w=r*2,h=w*TILE_ASPECT;
const c=this.ctx,shape=roundedHex(x,y,r);c.save();c.beginPath();shape.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.closePath();c.clip();
this.ctx.drawImage(this.atlas,f.x,f.y,f.w,f.h,x-w/2,y-h/2,w,h);
c.restore();
return true;
}
polygon(points,fill) {const c=this.ctx;c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.closePath();c.fillStyle=fill;c.fill();}
line(points,color,width) {if(!points.length)return;const c=this.ctx;c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));c.strokeStyle=color;c.lineWidth=width;c.lineCap='round';c.lineJoin='round';c.stroke();}
circle(x,y,r,fill) {const c=this.ctx;c.beginPath();c.arc(x,y,r,0,Math.PI*2);c.fillStyle=fill;c.fill();}
}
+10
View File
@@ -0,0 +1,10 @@
// Source rectangles in untouched 1293x1216 PNG; shared by browser and Cocos.
export const TILE_FRAMES = [
{x:98, y:99, w:540, h:465, anchorX:.5, anchorY:.5},
{x:680, y:99, w:540, h:465, anchorX:.5, anchorY:.5},
{x:98, y:653, w:540, h:465, anchorX:.5, anchorY:.5},
{x:680, y:653, w:540, h:465, anchorX:.5, anchorY:.5},
];
export const TILE_ATLAS='assets/tiles-matte-clean-v8.png';
export const TILE_RADIUS=.985;
export const TILE_ASPECT=Math.sqrt(3)/2;
+10
View File
@@ -0,0 +1,10 @@
// Shared clean silhouette: sample color inside the PNG; never sample its noisy cutout edge.
export function tileSample(f){return {x:Math.round(f.x+f.w*.4),y:Math.round(f.y+f.h*.4),w:Math.round(f.w*.2),h:Math.round(f.h*.2)};}
export function roundedHex(x,y,r){
const vertices=Array.from({length:6},(_,i)=>({x:x+Math.cos(i*Math.PI/3)*r,y:y+Math.sin(i*Math.PI/3)*r})),out=[];
for(let i=0;i<6;i++){
const v=vertices[i],prev=vertices[(i+5)%6],next=vertices[(i+1)%6],a={x:v.x+(prev.x-v.x)*.075,y:v.y+(prev.y-v.y)*.075},b={x:v.x+(next.x-v.x)*.075,y:v.y+(next.y-v.y)*.075};
for(let j=0;j<=6;j++){const t=j/6,u=1-t;out.push({x:u*u*a.x+2*u*t*v.x+t*t*b.x,y:u*u*a.y+2*u*t*v.y+t*t*b.y});}
}
return out;
}
+17
View File
@@ -0,0 +1,17 @@
import { SURFACES } from './design-tokens.js';
// Source rectangles shared by browser and Cocos; never substitute artwork silently.
export const PANEL_ATLAS='assets/ui-surfaces.png';
export const PANEL_SIZE={w:416,h:32};
export const ICON_ATLAS='assets/ui-icons-generated.png';
export const ICON_SIZE={w:1254,h:1254};
// Sample inside each 32px swatch so bilinear filtering cannot bleed adjacent roles.
export const PANELS=Object.fromEntries(Object.keys(SURFACES).map((name,i)=>[name,{x:i*32+2,y:2,w:28,h:28,cap:0}]));
// Measured bounds of the main artwork, padded to a square around its optical center.
const bounds=[
[64,80,281,289],[378,93,568,283],[719,101,828,282],[1005,99,1187,279],
[63,374,255,594],[377,398,592,594],[686,426,891,587],[1000,374,1193,594],
[86,679,235,902],[366,689,576,887],[687,689,890,893],[1018,706,1169,883],
[94,986,224,1189],[367,981,575,1177],[669,1005,892,1170],[1003,982,1184,1185],
];
export const ICONS=Object.fromEntries(['gear','grid','back','close','left','right','undo','restart','bulb','star','edit','play','arrow','erase','track','gem'].map((name,i)=>{const [l,t,r,b]=bounds[i],size=Math.max(r-l,b-t)+12;return[name,{x:Math.floor((l+r-size)/2),y:Math.floor((t+b-size)/2),w:size,h:size}];}));
export const UI_REFERENCE={width:390,height:844,corner:14,font:'Arial Rounded MT Bold',disabledOpacity:.55,pressedScale:.94};
+21
View File
@@ -0,0 +1,21 @@
import { PANELS, ICONS, PANEL_ATLAS, ICON_ATLAS, PANEL_SIZE, ICON_SIZE } from './ui-skin.js';
export const icon=(name,extra='')=>`<span class="ui-icon" data-icon="${name}" ${extra}></span>`;
function piece(url,f,fullW,fullH){const box=document.createElement('span');box.className='sprite-crop';const img=document.createElement('img');img.src=url;img.alt='';img.draggable=false;img.style.cssText=`width:${fullW/f.w*100}%;height:${fullH/f.h*100}%;left:${-f.x/f.w*100}%;top:${-f.y/f.h*100}%`;box.append(img);return box;}
function skin(el,name){if(el.dataset.skin===name&&el.querySelector(':scope > .ui-plate'))return;el.querySelector(':scope > .ui-plate')?.remove();el.dataset.skin=name;const f=PANELS[name],plate=document.createElement('span');plate.className='ui-plate';plate.ariaHidden='true';plate.append(piece(PANEL_ATLAS,f,PANEL_SIZE.w,PANEL_SIZE.h));el.prepend(plate);}
const svgNames={hex:'gem',grid:'grid',edit:'edit',arrow:'play',undo:'undo',reset:'restart',bulb:'bulb',close:'close',help:'gear',check:'star'};
export function decorateUI(){
document.querySelectorAll('svg:not(.icon-defs)').forEach(svg=>{const id=svg.querySelector('use')?.getAttribute('href')?.replace('#i-','');svg.outerHTML=icon(svgNames[id]||'gear');});
document.querySelectorAll('[data-icon]:not([data-ready])').forEach(el=>{const f=ICONS[el.dataset.icon];if(!f)return;el.append(piece(ICON_ATLAS,f,ICON_SIZE.w,ICON_SIZE.h));el.dataset.ready='1';el.ariaHidden='true';});
document.querySelectorAll('button,.hud-pill,.moves-bubble,.win-overlay,dialog,.editor-panel,.editor-analysis,.selection-info,.dialog-note,.tool-icon,.tool-icon small,.rotate-button small,.board-toast,.game-bottom,.metric-track,.metric-track i,.tool-palette').forEach(el=>{
if(el.closest('.compat')||el.matches('.board-tools>button,.rotate-button,.rotate-button small,.moves-bubble'))return;
let name='secondary';
if(el.matches('.win-overlay,dialog,.editor-panel,.editor-analysis,.dialog-note,.tool-palette,.game-bottom'))name='surface';
if(el.matches('.game-icon,.tool-icon,.tool-palette button,.directions button,.editor-rotation button,.editor-board-tools button,.editor-actions button'))name='utility';
if(el.matches('.primary,.level-btn.current,.tool-palette button.active,.directions button.active,.metric-track i'))name='primary';
if(el.matches('.board-toast,.selection-info'))name='warning';
if(el.matches('[data-color]'))name=['tileBlue','tilePink','tileYellow','tilePurple'][+el.dataset.color];
skin(el,name);
});
document.querySelectorAll('input:not([type=file]),select').forEach(el=>{if(el.parentElement.classList.contains('input-skin'))return;const wrap=document.createElement('span');wrap.className='input-skin';el.before(wrap);wrap.append(el);skin(wrap,'input');});
}
export function installUI(){decorateUI();let queued=false;new MutationObserver(records=>{if(!records.some(r=>r.type==='attributes'||[...r.addedNodes].some(n=>n.nodeType===1&&!n.matches?.('.ui-plate,.sprite-crop,img'))))return;if(!queued){queued=true;queueMicrotask(()=>{queued=false;decorateUI();});}}).observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['class']});}
+86
View File
File diff suppressed because one or more lines are too long
+142
View File
@@ -0,0 +1,142 @@
import { THEME } from '../src/design-tokens.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { Game, analyze, available, clone, generateLevel, hexagon, rotateTracks, rotateBoard, solveRotations, canRotate, solve, trace, validateLevel } from '../src/core.js';
import { pathPoints, segmentHexHit } from '../src/path-math.js';
import { LEVELS } from '../src/levels.js';
import { center, hitCell, layout, pointAlong, routePoints } from '../src/geometry.js';
const straight=[3,4,5,0,1,2];
const level=(cells,tiles,moveLimit=10)=>({version:1,name:'test',cells:cells.map(c=>({tracks:[...straight],...c})),tiles,moveLimit});
const tile=(id,q,r,dir=0)=>({id,q,r,dir,color:0});
test('bends follow the paired exit, never a crossing branch',()=>{
const l=level([{q:0,r:0},{q:0,r:-1,tracks:[2,3,0,1,5,4]},{q:1,r:-2}],[tile('a',0,0)]);
const path=trace(l,l.tiles[0]);assert.equal(path.status,'exit');assert.equal(path.steps[1].enter,3);assert.equal(path.steps[1].exit,1);assert.deepEqual(path.steps[2],{q:1,r:-2,enter:4,exit:1});
});
test('occupied cell blocks even when its own arrow points elsewhere',()=>{
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1,1)]);
assert.equal(trace(l,l.tiles[0]).blocker,'b');assert.deepEqual(available(l).map(t=>t.id),['b']);
const g=new Game(l);const original=clone(g.tiles);assert.equal(g.tap('a').status,'blocked');assert.equal(g.moves,9);assert.deepEqual(g.tiles,original);assert.equal(g.mistakes,1);assert.ok(g.undo());assert.equal(g.moves,10);assert.equal(g.mistakes,0);
assert.equal(g.tap('b').status,'exit');assert.equal(g.tap('a').status,'exit');assert.ok(g.won);
});
test('closed loop returns to the starting occupied tile and is unsolvable',()=>{
const l=level([{q:0,r:0,tracks:[3,2,1,0,5,4]},{q:1,r:-1,tracks:[1,0,5,4,3,2]},{q:1,r:0,tracks:[5,2,1,4,3,0]}],[tile('a',0,0,1)]);
assert.equal(trace(l,l.tiles[0]).status,'loop');assert.equal(trace(l,l.tiles[0]).blocker,undefined);assert.equal(solve(l).solvable,false);
});
test('mutual blocking reaches a fixed point; move shortage is separate',()=>{
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1,3)]);
assert.equal(solve(l).solvable,false);assert.equal(solve(l).stuck.length,2);
const easy=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1)],1);
assert.equal(solve(easy).stuck.length,0);assert.equal(solve(easy).solvable,false);
});
test('all 24 campaign levels validate, solve and increase in score',()=>{
let previous=-1;
for(const l of LEVELS){assert.deepEqual(validateLevel(l),[]);const a=analyze(l);assert.ok(a.solvable,l.name);assert.ok(a.rotations>=1);assert.equal(solve(l).solvable,false);assert.ok(a.score>=previous);previous=a.score;const g=new Game(l);for(const action of a.actions){if(action.type==='rotate')assert.equal(g.rotate(action.direction).status,'rotated');else assert.equal(g.tap(action.id).status,'exit');}assert.ok(g.won);assert.ok(g.moves>=0);}
});
test('100 generated seeds are deterministic and solvable',()=>{
for(let seed=0;seed<100;seed++){const l=generateLevel(seed,seed%2?2:3,seed%2?15:32,.8);assert.deepEqual(validateLevel(l),[]);assert.ok(solve(l).solvable,`seed ${seed}`);assert.ok(l.tiles.length>0);}
assert.deepEqual(generateLevel(1234),generateLevel(1234));
});
test('imports reject duplicate positions, invalid matching, bad IDs and directions',()=>{
const good=clone(LEVELS[0]);assert.deepEqual(validateLevel(good),[]);
for(const mutate of [l=>l.cells.push(l.cells[0]),l=>l.cells[0].tracks=[1,2,3,4,5,0],l=>l.tiles.push({...l.tiles[0],id:'other'}),l=>l.tiles[0].dir=6,l=>l.tiles[0].color=4,l=>l.moveLimit=0,l=>l.version=2,l=>l.tiles[0].q=99,l=>l.tiles[0].id=null]){const l=clone(good);mutate(l);assert.ok(validateLevel(l).length);}
assert.ok(validateLevel(null).length);assert.ok(validateLevel({version:1,cells:[null],tiles:[null]}).length);
});
test('empty boards are not publishable wins and report a finite score',()=>{
const l=level([{q:0,r:0}],[]);const a=analyze(l);assert.equal(a.solvable,false);assert.equal(a.score,0);
});
test('six rotations preserve track connectivity',()=>{let tracks=[1,0,4,5,2,3];for(let n=0;n<6;n++)tracks=rotateTracks(tracks);assert.deepEqual(tracks,[1,0,4,5,2,3]);});
test('geometry hit testing selects cell centers and rejects outside points',()=>{
const cells=hexagon(3),v=layout(cells,390,390);for(const c of cells){const p=center(c,v);assert.equal(hitCell(cells,v,p.x,p.y),c);}assert.equal(hitCell(cells,v,-100,-100),undefined);
const l=LEVELS[0],t=available(l)[0],route=routePoints(trace(l,t),layout(l.cells,390,390),true);for(const p of [0,.5,1]){const point=pointAlong(route,p);assert.ok(Number.isFinite(point.x)&&Number.isFinite(point.angle));}
});
test('undo also restores a completed board; finished games ignore taps',()=>{
const g=new Game(level([{q:0,r:0}],[tile('a',0,0)],1));g.tap('a');assert.ok(g.won);assert.equal(g.tap('a').status,'finished');g.undo();assert.equal(g.won,false);assert.equal(g.moves,1);
});
test('difficulty stays within 0–100 even with insufficient moves',()=>{
const l=clone(LEVELS[23]);l.moveLimit=1;const a=analyze(l);assert.equal(a.solvable,false);assert.ok(a.score<=100);assert.ok(a.parts.precision<=10);
});
test('six board rotations restore tracks; tokens and arrows stay fixed',()=>{
const l=clone(LEVELS[0]);assert.ok(canRotate(l));
assert.deepEqual(rotateBoard(l,6),l);
let board=l;for(let i=0;i<6;i++)board=rotateBoard(board);
assert.deepEqual(board,l);
const g=new Game(l),original=clone(g.tiles),before=clone(g.level.cells);
assert.equal(g.rotate(1).status,'rotated');assert.deepEqual(g.tiles,original);assert.notDeepEqual(g.level.cells,before);assert.equal(g.orientation,1);assert.equal(g.moves,l.moveLimit-1);
assert.ok(g.undo());assert.equal(g.orientation,0);assert.deepEqual(g.level.cells,before);assert.equal(g.moves,l.moveLimit);
});
test('a stuck orientation can be rescued by rotating and must not immediately lose',()=>{
const l=LEVELS[0],g=new Game(l);
while(available(g.level,g.tiles).length)g.tap(available(g.level,g.tiles)[0].id);
assert.ok(g.tiles.length);assert.equal(g.lost,false);
const result=solveRotations({...g.level,tiles:g.tiles,moveLimit:g.moves});assert.ok(result.solvable);assert.equal(result.actions[0].type,'rotate');
const before=available(g.level,g.tiles).length;g.rotate(result.actions[0].direction);assert.ok(available(g.level,g.tiles).length>before);
});
test('swept collision blocks a neighboring piece even when the center-line visits only empty cells',()=>{
const l=level(hexagon(2),[tile('moving',1,1,0),tile('beside',0,0,0)]);
const centerLine=trace(l,l.tiles[0],l.tiles,true);assert.equal(centerLine.status,'exit');assert.deepEqual(centerLine.blockers,[]);
const route=trace(l,l.tiles[0]);assert.equal(route.status,'blocked');assert.equal(route.blocker,'beside');assert.ok(route.collision);assert.ok(route.travelPoints.length<pathPoints(centerLine).length);
const clear=trace(l,l.tiles[0],[l.tiles[0]]);assert.equal(clear.status,'exit');
const g=new Game(l),before=clone(g.tiles);g.tap('moving');assert.deepEqual(g.tiles,before);assert.equal(g.moves,l.moveLimit-1);
});
test('swept segment detection cannot tunnel through a collider between frames',()=>{
const hit=segmentHexHit({x:-10,y:0},{x:10,y:0},{x:0,y:0});assert.ok(hit>0&&hit<1);
assert.equal(segmentHexHit({x:-10,y:3},{x:10,y:3},{x:0,y:0}),null);
});
test('rotation step cost is included in the minimum move budget',()=>{
const l=clone(LEVELS[0]),a=solveRotations(l);assert.ok(a.rotations>0);
l.moveLimit=a.minimumMoves-1;const tooShort=solveRotations(l);assert.equal(tooShort.reachable,true);assert.equal(tooShort.solvable,false);
});
test('irregular boards retain classic play and do not rotate tiles off the base',()=>{
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0)]);const g=new Game(l);assert.equal(canRotate(l),false);assert.equal(g.rotate().status,'rotation-disabled');assert.equal(g.moves,l.moveLimit);
});
test('exit animation cannot pass through another piece beyond an irregular board gap',()=>{
const l=level([{q:0,r:0},{q:0,r:-2}],[tile('a',0,0,0),tile('b',0,-2,1)]);
assert.equal(trace(l,l.tiles[0],l.tiles,true).status,'exit');
const route=trace(l,l.tiles[0]);assert.equal(route.status,'blocked');assert.equal(route.blocker,'b');assert.ok(route.collision);
});
test('level 1 last piece reports a loop and wins after one clockwise turn',()=>{
const g=new Game(LEVELS[0]);while(available(g.level,g.tiles).length)g.tap(available(g.level,g.tiles)[0].id);
assert.equal(g.tiles.length,1);const last=g.tiles[0].id;
assert.equal(trace(g.level,g.tiles[0],g.tiles).status,'loop');
assert.equal(g.rotate(1).status,'rotated');assert.equal(g.tap(last).status,'exit');assert.ok(g.won);
});
test('moving tile does not collide with its vacated starting cell',()=>{
const l=level([{q:0,r:0,tracks:[3,4,5,0,1,2]},{q:1,r:-1,tracks:[1,0,5,4,3,2]},{q:1,r:0,tracks:[5,2,1,4,3,0]}],[tile('a',0,0,1)]);
const result=trace(l,l.tiles[0]);assert.equal(result.status,'exit');assert.equal(result.blocker,undefined);
});
test('collision animation pauses at contact, recoils, and returns without moving either logical tile',async()=>{
const {collisionPhase,contactPath}=await import('../src/collision-animation.js');
assert.equal(collisionPhase(0).distance,0);assert.equal(collisionPhase(.5).distance,1);assert.equal(collisionPhase(.6).distance,1);assert.equal(collisionPhase(1).distance,0);
assert.ok(collisionPhase(.5).impact>0);assert.equal(collisionPhase(.5,true).kick,0);
const p=contactPath([{x:-5,y:0},{x:0,y:0}],{x:0,y:0},1);assert.equal(p.at(-1).x,-2);
const g=new Game(LEVELS[0]),before=clone(g.tiles),blocked=g.tiles.find(t=>trace(g.level,t,g.tiles).status==='blocked');
assert.ok(blocked);g.tap(blocked.id);assert.deepEqual(g.tiles,before);
});
test('rendered collision moves the blocker only during impact and returns both sprites',async()=>{
const {drawScene}=await import('../src/renderer.js');const l=LEVELS[0],g=new Game(l),t=g.tiles.find(t=>trace(l,t).status==='blocked'),route=g.tap(t.id),v=layout(l.cells,390,390);
const capture=progress=>{const sprites=[];const p={sprite:(index,x,y,r)=>{sprites.push({index,x,y,r});return true;},polygon(){},line(){},circle(){}};drawScene(p,l,g.tiles,v,{animation:{route,progress}});return sprites;};
const a=capture(0),impact=capture(.48),end=capture(1);
assert.deepEqual(end,a);assert.ok(impact.some((s,i)=>Math.abs(s.x-a[i].x)+Math.abs(s.y-a[i].y)>.1));
const blocker=g.tiles.find(t=>t.id===route.blocker),b=center(blocker,v);
assert.ok(a.some(s=>s.x===b.x&&s.y===b.y));assert.ok(!impact.some(s=>s.x===b.x&&s.y===b.y));
});
test('exposed track strokes and their round caps stay inside the board edge',async()=>{
const {drawScene}=await import('../src/renderer.js');const l=level([{q:0,r:0}],[]),v=layout(l.cells,300,300),lines=[];
drawScene({polygon(){},circle(){},line(points,color,width){lines.push({points,width});}},l,[],v);
const normals=[{x:0,y:1},{x:Math.sqrt(3)/2,y:.5},{x:Math.sqrt(3)/2,y:-.5}],limit=v.size*.965*Math.sqrt(3)/2;
for(const {points,width} of lines.filter(l=>l.width>=2))for(const p of points)for(const n of normals)assert.ok(Math.abs((p.x-v.ox)*n.x+(p.y-v.oy)*n.y)+width/2<=limit+1e-8);
});
test('all board faces render before tracks and neighboring rail endpoints coincide',async()=>{
const {drawScene}=await import('../src/renderer.js');const l=level([{q:0,r:0},{q:0,r:-1}],[]),v=layout(l.cells,300,300),commands=[];
drawScene({polygon(){commands.push({kind:'face'});},circle(){},line(points,color){commands.push({kind:'rail',points,color});}},l,[],v);
assert.deepEqual(commands.slice(0,2).map(c=>c.kind),['face','face']);assert.ok(commands.slice(2).every(c=>c.kind==='rail'));
const a=center(l.cells[0],v),b=center(l.cells[1],v),joint={x:(a.x+b.x)/2,y:(a.y+b.y)/2};
const cores=commands.filter(c=>c.color===THEME.track);
assert.equal(cores.filter(c=>[c.points[0],c.points.at(-1)].some(p=>Math.hypot(p.x-joint.x,p.y-joint.y)<1e-6)).length,2);
});
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { THEME, CONTRAST_PAIRS, contrast } from '../src/design-tokens.js';
import { ICON_ATLAS, ICON_SIZE, ICONS, PANELS, PANEL_SIZE } from '../src/ui-skin.js';
test('semantic text and control color pairs meet their declared contrast requirements',()=>{
for(const [label,fg,bg,min] of CONTRAST_PAIRS)assert.ok(contrast(THEME[fg],THEME[bg])>=min,label);
});
test('accepted generated artwork is referenced and all optical-center crop frames fit the atlas',()=>{
assert.equal(ICON_ATLAS,'assets/ui-icons-generated.png');
const png=readFileSync(new URL('../'+ICON_ATLAS,import.meta.url));assert.equal(png.readUInt32BE(16),ICON_SIZE.w);assert.equal(png.readUInt32BE(20),ICON_SIZE.h);
for(const f of Object.values(ICONS)){assert.equal(f.w,f.h);assert.ok(f.x>=0&&f.y>=0&&f.x+f.w<=ICON_SIZE.w&&f.y+f.h<=ICON_SIZE.h);}
for(const f of Object.values(PANELS))assert.ok(f.x+f.w<=PANEL_SIZE.w&&f.y+f.h<=PANEL_SIZE.h);
});