feat: expand campaign and refine puzzle visuals and controls
@@ -1,12 +1,12 @@
|
|||||||
# HEX TRAILS 设计规范
|
# HEX TRAILS 设计规范
|
||||||
|
|
||||||
版本:1.0 · 2026-09-08 · 适用:网页、手机触摸界面与 Cocos 移植。
|
版本:2.0 · 2026-09-08 · 适用:网页、手机触摸界面与 Cocos 移植。
|
||||||
|
|
||||||
本文件规定设计角色和使用方式;`src/design-tokens.js` 是颜色数值的唯一来源。修改数值后运行 `npm run design`,同步生成 CSS、JSON、面板 PNG、对比度报告和可视化手册 `Design.html`。HTML 为单文件离线版本,包含原始图片、色值复制、组件示例、弹窗动效与本文全文。新界面不得另写一套 HEX 或用滤镜临时拼配色。
|
本文件规定设计角色和使用方式;`src/design-tokens.js` 是颜色数值的唯一来源。修改数值后运行 `npm run design`,同步生成 CSS、JSON、面板 PNG、对比度报告和可视化手册 `Design.html`。HTML 为单文件离线版本,包含原始图片、色值复制、组件示例、弹窗动效与本文全文。新界面不得另写一套 HEX 或用滤镜临时拼配色。
|
||||||
|
|
||||||
## 1. 设计方向
|
## 1. 设计方向
|
||||||
|
|
||||||
**清爽、明快、易读的哑光解谜游戏。** 环境采用冷蓝灰,棋盘采用暖白,主操作采用紫色。鲜艳的蓝、粉、黄、紫主要留给棋子;高饱和度不平均分配给每一个按钮。
|
**清爽、明快、易读的哑光解谜游戏。** 环境使用按难度或活动组织的低饱和主题色,棋盘采用暖白,主操作采用紫色。全局建立 12 色棋子库,单关默认使用其中 7 色,支持 4 / 7 / 9 / 12 色对照;高饱和度不平均分配给每一个按钮。
|
||||||
|
|
||||||
- 棋子是第一视觉焦点,Level 和步数是第二层,工具是第三层。
|
- 棋子是第一视觉焦点,Level 和步数是第二层,工具是第三层。
|
||||||
- 纯色背景,无纹理、光晕、装饰圆盘。底板要看得出六边形分格。
|
- 纯色背景,无纹理、光晕、装饰圆盘。底板要看得出六边形分格。
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
采用 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)
|
采用 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 官方游戏模板。当前为固定浅色主题,不随系统壁纸自动换色。
|
这些来源提供方法和约束;下面的 HEX、游戏形态、动效时长是本项目的设计选择,并非 Material 官方游戏模板。当前有四套主线主题与一套每周挑战美术主题,不随系统壁纸自动换色。主题只改变美术表现,不改变难度判定。
|
||||||
|
|
||||||
## 3. 颜色体系
|
## 3. 颜色体系
|
||||||
|
|
||||||
@@ -28,11 +28,11 @@
|
|||||||
|
|
||||||
| Token | 色值 | 用途与配对 |
|
| Token | 色值 | 用途与配对 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `background` | `#A8BECF` | 页面背景,搭配主文字或辅助文字 |
|
| `background` | `#A8BECF` | 基础回退色;游戏场景使用下表主题背景与场景文字色 |
|
||||||
| `board` | `#F2EEE5` | 棋盘底格,暖白区分冷色背景 |
|
| `board` | `#F2EEE5` | 棋盘底格,暖白区分冷色背景 |
|
||||||
| `boardGrid` | `#7C8B94` | 六边形边界,细线且清楚可见 |
|
| `boardGrid` | `#89867D` | 六边形边界,细线且清楚可见 |
|
||||||
| `track` | `#6D808B` | 轨道中心线 |
|
| `track` | `#435F78` | 深蓝轨道中心线,线宽 `max(2.5px, 格子半径 × 0.10)` |
|
||||||
| `trackHalo` | `#DFE3E2` | 轨道浅色外缘,不能盖断接头 |
|
| `trackHalo` | `#F2EEE5` | 轨道浅色外缘,不能盖断接头 |
|
||||||
| `surface` | `#F7F6F2` | 弹窗与编辑面板 |
|
| `surface` | `#F7F6F2` | 弹窗与编辑面板 |
|
||||||
| `textPrimary` | `#273B50` | Level、步数、标题、主要标签 |
|
| `textPrimary` | `#273B50` | Level、步数、标题、主要标签 |
|
||||||
| `textSecondary` | `#314A5F` | 说明与次要标签;用字号和字重区分层级 |
|
| `textSecondary` | `#314A5F` | 说明与次要标签;用字号和字重区分层级 |
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
|
|
||||||
### 3.3 棋子色与图片事实
|
### 3.3 棋子色与图片事实
|
||||||
|
|
||||||
棋子颜色不代表功能、品质或阵营。现有四枚棋子来自已接受的图片;以下是采样区域中心的代表值,图片像素允许存在小幅变化:
|
棋子颜色不代表功能、品质或阵营。原有四枚棋子来自已接受的图片;以下是采样区域中心的代表值,图片像素允许存在小幅变化:
|
||||||
|
|
||||||
| Token | 代表色 | 方向箭头 |
|
| Token | 代表色 | 方向箭头 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -69,9 +69,29 @@
|
|||||||
| `tileYellow` | `#FDBC28` | 深蓝灰 `textPrimary` |
|
| `tileYellow` | `#FDBC28` | 深蓝灰 `textPrimary` |
|
||||||
| `tilePurple` | `#874FFD` | 白色 |
|
| `tilePurple` | `#874FFD` | 白色 |
|
||||||
|
|
||||||
黄色上的白箭头对比不足,因此使用深色箭头。四种棋子均可通过方向箭头、位置和运动判断,不要求辨认颜色才能通关。未经用户确认,不重新生成或调色替换已接受的棋子。
|
黄色上的白箭头对比不足,因此使用深色箭头。所有棋子均可通过方向箭头、位置和运动判断,不要求辨认颜色才能通关。未经用户确认,不重新生成或调色替换已接受的棋子。
|
||||||
|
|
||||||
### 3.4 禁止的搭配
|
### 3.4 场景主题与 12 色库
|
||||||
|
|
||||||
|
互动展示:在本地开发服务打开 `/design-showcase.html`,可切换主题、点击真实棋盘、播放离场效果与慢速观察。展示中的五套主题使用同一关,便于比较色彩。
|
||||||
|
|
||||||
|
| 难度 / 场景 | 主题 | 环境色 | 棋子搭配 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 轻松 0–29 | 薄荷浅湾 | `#C5E4DF` | 晴空蓝、湖水青、日光黄 |
|
||||||
|
| 进阶 30–49 | 杏色午后 | `#F1D7B8` | 珊瑚橙、鸢尾蓝、湖水青、蜜桃杏 |
|
||||||
|
| 烧脑 50–69 | 鸢尾花园 | `#D8D0EB` | 葡萄紫、嫩芽绿、玫瑰红、冰川青 |
|
||||||
|
| 挑战 70–100 | 玫瑰暮色 | `#E7C3CE` | 玫瑰红、鸢尾蓝、日光黄、兰花紫 |
|
||||||
|
| 每周挑战美术预览 | 午夜极光 | `#293956` | 冰川青、兰花紫、日光黄、莓果粉 |
|
||||||
|
|
||||||
|
四套浅色背景使用 `textPrimary` 场景文字;午夜背景使用 `surface` 色文字。弹窗内部继续使用暖白底和深色文字,避免场景文字色污染组件。每周挑战目前提供独立美术主题和预览入口,尚未接入周赛、排行榜或活动日历。
|
||||||
|
|
||||||
|
新增八色:湖水青 `#008B83`、珊瑚橙 `#EF704F`、嫩芽绿 `#91C95B`、鸢尾蓝 `#5267CF`、玫瑰红 `#C83F66`、蜜桃杏 `#F5AA83`、冰川青 `#58C9D5`、兰花紫 `#AE64C7`。前四色保留已接受的图片采样;新增色使用同一圆角六边形轮廓进行纯色绘制,没有改动原始图片。
|
||||||
|
|
||||||
|
`PIGMENTS` 与 `LEVEL_THEMES` 在 `src/design-tokens.js` 中维护,并导出到 `design/tokens.json`。箭头按实际色值选择白色或深蓝灰,最低对比度 3:1。主线默认将主题色组扩展为 7 色,URL 参数 `colors` 支持 4、7、9、12;主题表中的配色作为起始色,优先补足差异较大的色相。主线按初始棋子顺序循环分配主题色组,因此移走棋子、旋转和撤销不会让剩余棋子变色。编辑器开放完整 12 色库,自由创作不强制收敛。
|
||||||
|
|
||||||
|
颜色不代表功能、品质或阵营;难度也必须保留文字,不只靠背景判断。主题切换只发生在换关或主动预览时,不随剩余步数改变。背景保持纯色,用色彩变化建立旅程感。
|
||||||
|
|
||||||
|
### 3.5 禁止的搭配
|
||||||
|
|
||||||
- 白色小字放在黄色、浅蓝灰或浅绿色按钮上。
|
- 白色小字放在黄色、浅蓝灰或浅绿色按钮上。
|
||||||
- 使用同样的浅灰同时承担背景、底板和轨道。
|
- 使用同样的浅灰同时承担背景、底板和轨道。
|
||||||
@@ -83,7 +103,7 @@
|
|||||||
|
|
||||||
### 游戏主界面
|
### 游戏主界面
|
||||||
|
|
||||||
顶部仅显示居中的 Level 与其下方的剩余步数。左侧选关,右侧设置。没有 Logo、关卡副标题、难度、待离场数量、角度读数或底部导航。
|
游戏顶部恢复居中关卡计数,左侧选关、右侧设置;剩余步数位于其下方,不显示主题说明。棋盘视口占据顶部状态与底部紧凑操作栏之间的全部空间,不再限制为正方形。底部仅排列撤销、重来、提示;左右转恢复大箭头,悬浮于棋盘下部两侧,不占据额外布局高度。缩放按钮与常驻手势说明不占用棋盘空间;保留双指缩放、放大后拖动及桌面组合键滚轮。左侧选关,右侧设置。没有 Logo、关卡副标题、难度、待离场数量、角度读数或底部导航。
|
||||||
|
|
||||||
旋转操作为放大的生成箭头,下方分别写“左转”“右转”,不加矩形底板。文字用主文字色;白色箭头允许轻微蓝灰轮廓阴影增强边缘识别,不能改写原图轮廓。它们须保留至少 44×44 的触摸区域。
|
旋转操作为放大的生成箭头,下方分别写“左转”“右转”,不加矩形底板。文字用主文字色;白色箭头允许轻微蓝灰轮廓阴影增强边缘识别,不能改写原图轮廓。它们须保留至少 44×44 的触摸区域。
|
||||||
|
|
||||||
@@ -91,7 +111,9 @@
|
|||||||
|
|
||||||
### 棋盘和轨道
|
### 棋盘和轨道
|
||||||
|
|
||||||
先画全部底格,再画分格线,再统一画轨道外缘与中心线,最后画棋子。相邻格子连接端口必须完全重合。内部不留透出背景的缝;外边缘需为线帽预留空间。分格线宽取 `max(1.25px, 格子半径 × 0.038)`。
|
大棋盘测试支持边长 6(轴向半径 5,91 格)与边长 7(半径 6,127 格)。网页棋盘支持以双指中点为锚的 1–4 倍缩放,放大后单指拖动;双指手势及其余指抬起不触发棋子点击。100% 视图保留原有边缘拖动旋转,放大后用左右按钮旋转。桌面提供 Ctrl/Command + 滚轮缩放;收拢双指或缩小回 100% 恢复全盘视图。缩放与绘制、点击共用同一坐标变换,不改变规则坐标。
|
||||||
|
|
||||||
|
先画全部底格,再画分格线,再统一画轨道外缘与中心线,最后画棋子。相邻格子连接端口必须完全重合。内部不留透出背景的缝;外边缘需为线帽预留空间。分格线宽取 `max(1px, 格子半径 × 0.024)`。
|
||||||
|
|
||||||
棋子保持微小圆角。所有游戏运动、碰撞和绘制使用同一坐标体系。图片只能在原图不透明、无杂点的区域采样;不得带入棋盘格底纹或透明噪点。
|
棋子保持微小圆角。所有游戏运动、碰撞和绘制使用同一坐标体系。图片只能在原图不透明、无杂点的区域采样;不得带入棋盘格底纹或透明噪点。
|
||||||
|
|
||||||
@@ -137,10 +159,13 @@
|
|||||||
|
|
||||||
## 7. 动效、音效与反馈
|
## 7. 动效、音效与反馈
|
||||||
|
|
||||||
|
- 快速连点:棋子运动和转盘动画期间继续接收点击,待执行棋子以紫色描边反馈;按顺序重新判定路线,不重复记录同一棋子。碰撞、重来、换关、进入编辑器或打开弹窗时取消待执行点击。动画仍顺序播放,避免多个运动棋子产生碰撞歧义。
|
||||||
- 按压:150ms,缩放到 94%;不移动命中区域。
|
- 按压:150ms,缩放到 94%;不移动命中区域。
|
||||||
- 弹窗:420ms,从上方 60px 和 96% 缩放进入;遮罩约 250ms。
|
- 弹窗:420ms,从上方 60px 和 96% 缩放进入;遮罩约 250ms。
|
||||||
- 星星:分别延迟 300/490/680ms 出现,每枚动画 400ms。
|
- 星星:分别延迟 300/490/680ms 出现,每枚动画 400ms。
|
||||||
- 碰撞:总长 1250ms,先接近,再接触停顿并让阻挡棋子轻震,最后原路弹回;不改变模型中的位置。
|
- 离场:总时长随路径长度变化,按实际轨道长度以每单位 60ms 滑行,滑行段限制为 140–800ms,出口收束固定为 180ms。边缘棋子约 320ms 完成离场;从 3 格路线起平滑增加滑行时间,5 格及以上恢复初版总时长(550ms + 每格 85ms,最长 1700ms),新增时间全部用于滑行,出口收束仍为 180ms。不再给短路线添加固定等待。滑行与出口位移使用平滑 S 曲线(smoothstep),起止速度为零,中段流畅加速;缩小同样使用 S 曲线,避免末段突然消失。碎片采用快速展开、缓慢停下的 ease-out 曲线。棋子向外推进约 0.48 个格子半径,缩小后留下短尾迹与 6 枚同色六角碎片;效果结束后再显示结果。不得把消散阶段放在画布外,不引入额外彩虹色。减少动态效果时取消尾迹与碎片,仅保留短路线过渡与成功状态文字。
|
||||||
|
- 旋转:240ms,快速到位,棋子保持固定。
|
||||||
|
- 碰撞:总长 650ms,先接近,再接触停顿并让阻挡棋子轻震,最后原路弹回;不改变模型中的位置。
|
||||||
- 音效:首次手势后启动,点击、旋转、离场、碰撞和通关各有短音;设置里可关闭并保存偏好。
|
- 音效:首次手势后启动,点击、旋转、离场、碰撞和通关各有短音;设置里可关闭并保存偏好。
|
||||||
- `prefers-reduced-motion` 下取消非必要位移、缩放和震动,仍保留状态文字。
|
- `prefers-reduced-motion` 下取消非必要位移、缩放和震动,仍保留状态文字。
|
||||||
- 手机棋盘禁用 WebKit tap highlight、选中文字和长按菜单,避免整块矩形高亮。
|
- 手机棋盘禁用 WebKit tap highlight、选中文字和长按菜单,避免整块矩形高亮。
|
||||||
@@ -157,7 +182,7 @@
|
|||||||
2. 修改 `src/design-tokens.js`,运行 `npm run design`。
|
2. 修改 `src/design-tokens.js`,运行 `npm run design`。
|
||||||
3. 检查自动生成的 PNG/CSS/JSON 和对比度报告。
|
3. 检查自动生成的 PNG/CSS/JSON 和对比度报告。
|
||||||
4. 运行 `npm test`、`npm run build`。
|
4. 运行 `npm test`、`npm run build`。
|
||||||
5. 检查 390×844 手机布局,以及较矮屏幕;检查主界面、选关、设置、通关/失败和碰撞状态。
|
5. 对五套背景逐一检查场景文字与箭头,并检查离场、旋转和撤销后颜色稳定。检查 390×844 手机布局,以及较矮屏幕;检查主界面、选关、设置、通关/失败和碰撞状态。
|
||||||
6. 验证弹窗不能穿透操作、图标居中、轨道接缝不断裂。
|
6. 验证弹窗不能穿透操作、图标居中、轨道接缝不断裂。
|
||||||
7. 如需替换已接受的图片,明确告知用户素材来源和变化,再更新本表。
|
7. 如需替换已接受的图片,明确告知用户素材来源和变化,再更新本表。
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# HEX TRAILS · 转转六角
|
# HEX TRAILS · 转转六角
|
||||||
|
|
||||||
一款竖屏六角轨道解谜游戏:点击箭头棋子,让它沿轨道离场;旋转底盘改变路线,在有限步数内清空棋盘。
|
一款竖屏六角轨道解谜游戏:观察路线,点击箭头棋子让它离场;免费旋转底盘和尝试路线,在清楚的反馈中逐步清空棋盘。
|
||||||
|
|
||||||
项目使用原生 JavaScript ES modules 和 Canvas,无第三方运行依赖,无在线字体或 CDN。包含 24 个主线关卡、关卡工坊、求解器、确定性关卡生成器,以及 Cocos Creator 适配示例。
|
项目使用原生 JavaScript ES modules 和 Canvas,无第三方运行依赖,无在线字体或 CDN。主线按 48 关、8 章 × 6 拍制作,并提供关卡工坊、求解器、确定性关卡生成器,以及 Cocos Creator 适配示例。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -25,16 +25,20 @@ npm run dev
|
|||||||
- 点击棋子,从箭头方向进入轨道;弯道自动转向,交叉处不换轨。
|
- 点击棋子,从箭头方向进入轨道;弯道自动转向,交叉处不换轨。
|
||||||
- 点击左右旋转按钮,或沿棋盘边缘拖动,将底盘旋转 60°。棋子的位置与箭头方向保持不动,底板格子与轨道配对一起旋转。
|
- 点击左右旋转按钮,或沿棋盘边缘拖动,将底盘旋转 60°。棋子的位置与箭头方向保持不动,底板格子与轨道配对一起旋转。
|
||||||
- 棋子不能穿过其他棋子。碰撞检测覆盖整段运动路径,包括弯道与邻格擦碰;碰撞动画展示接触后退回原位。
|
- 棋子不能穿过其他棋子。碰撞检测覆盖整段运动路径,包括弯道与邻格擦碰;碰撞动画展示接触后退回原位。
|
||||||
- 点击棋子或旋转底盘各消耗 1 步,碰撞也扣步。撤销恢复棋盘、棋子、朝向和步数;没有倒计时。
|
- 只有成功移除棋子消耗 1 次预算;旋转和失败试路均免费。主线预算等于初始棋子数,只表示剩余消除工作量,不惩罚试错。撤销恢复棋盘、棋子、朝向和计数;没有倒计时。
|
||||||
- 提示会高亮可安全离场的棋子,或建议旋转方向。棋子配色不影响规则。
|
- 提示会高亮可安全离场的棋子,或建议旋转方向。棋子配色不影响规则。
|
||||||
|
|
||||||
主线关卡均经过求解验证,且需要旋转才能完成。所有关卡可自由选择,通关进度保存在当前浏览器。音效使用 Web Audio 合成,首次交互后启用,可在设置中关闭并保存偏好。
|
主线每章按“进入 → 换路 → 展开 → 小挑战 → 释放 → 长局回报”安排,不按难度分数单调排序,也不要求每关必须旋转。早期就可使用 37 格棋盘,后期舒缓关使用 61 格延长熟悉操作的体验;19 格小棋盘也可承担局部挑战。棋盘规模与认知难度分别记录。
|
||||||
|
|
||||||
|
主线发布要求包括完整可解、每枚棋子在六个朝向下的独立路线无闭环,以及参考解答中的阻挡可解释、旋转后有明确进展;这些门槛不代表玩家任意旋转或点击序列都不会遇阻。制作方向见 [玩法方向](docs/gameplay-direction.md),逐关生成与验收记录见 [主线审阅报告](docs/campaign-review.md),验证结果以实际生成报告和测试输出为准。
|
||||||
|
|
||||||
|
所有关卡可自由选择,通关进度保存在当前浏览器。音效使用 Web Audio 合成,首次交互后启用,可在设置中关闭并保存偏好。
|
||||||
|
|
||||||
## 关卡工坊
|
## 关卡工坊
|
||||||
|
|
||||||
工坊支持 19 格和 37 格底板、棋子方向与配色、15 种轨道配对、单格轨道旋转和整体底盘旋转;还提供擦除、路径检查、撤销编辑、试玩、随机生成、本机单槽存档及 JSON 导入导出。
|
工坊支持 19、37、61 格底板、棋子方向与配色、15 种轨道配对、单格轨道旋转和整体底盘旋转;还提供擦除、路径检查、撤销编辑、试玩、随机生成、本机单槽存档及 JSON 导入导出。
|
||||||
|
|
||||||
试玩不修改草稿,也不计入主线进度。工坊与正式游戏共用碰撞及求解规则,显示可解性、必要旋转数、最短步数与结构难度评分。搜索达到上限时显示“尚未确定”,不直接判定无解。
|
试玩不修改草稿,也不计入主线进度。工坊与正式游戏共用碰撞及求解规则。分析器区分最少消除成本 `minimumMoves=N` 与最少交互数 `minimumActions=N+R`,其中 `R` 是最少旋转数;自定义预算不足 `N` 时仍会报告预算不足。认知难度与工作量分别提供,搜索达到上限时显示“尚未确定”,不直接判定无解。
|
||||||
|
|
||||||
不具备六重旋转对称性的导入底板会禁用整体旋转。导入失败保留当前草稿;不可解关卡仍可保存用于调试。浏览器存档不会跨设备同步,需长期保存或分享时请导出 JSON。
|
不具备六重旋转对称性的导入底板会禁用整体旋转。导入失败保留当前草稿;不可解关卡仍可保存用于调试。浏览器存档不会跨设备同步,需长期保存或分享时请导出 JSON。
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ npm run dev
|
|||||||
| `npm run dev` | 启动本地开发服务,默认端口 4173 |
|
| `npm run dev` | 启动本地开发服务,默认端口 4173 |
|
||||||
| `npm test` | 执行 Node 内置测试套件 |
|
| `npm test` | 执行 Node 内置测试套件 |
|
||||||
| `npm run build` | 生成设计资源并输出静态站点到 `dist/` |
|
| `npm run build` | 生成设计资源并输出静态站点到 `dist/` |
|
||||||
| `npm run levels` | 使用固定种子重新生成关卡,同步两个关卡数据文件 |
|
| `npm run levels` | 使用固定种子重新生成 48 关,同步关卡 JSON、共享模块和逐关验收报告 |
|
||||||
| `npm run design` | 生成颜色 token、CSS、JSON、PNG、对比度报告和设计指南 HTML |
|
| `npm run design` | 生成颜色 token、CSS、JSON、PNG、对比度报告和设计指南 HTML |
|
||||||
| `node scripts/serve.mjs --dist` | 本地预览构建产物 |
|
| `node scripts/serve.mjs --dist` | 本地预览构建产物 |
|
||||||
|
|
||||||
@@ -89,8 +93,16 @@ Cocos 示例复用规则、碰撞、渲染命令和图集;HTML 控件、文件
|
|||||||
|
|
||||||
自动测试覆盖主线完整解答、生成关卡、旋转与撤销、弯轨寻路、连续碰撞、闭环、导入校验,以及设计颜色对比度和图标裁切。测试结果以当前 `npm test` 输出为准。
|
自动测试覆盖主线完整解答、生成关卡、旋转与撤销、弯轨寻路、连续碰撞、闭环、导入校验,以及设计颜色对比度和图标裁切。测试结果以当前 `npm test` 输出为准。
|
||||||
|
|
||||||
界面变更还需在浏览器检查竖屏布局、旋转、碰撞回位、弹窗和工坊导入导出;自动测试不替代实机交互验收。较小屏幕可能需要纵向滚动。难度评分是结构估计,尚未通过玩家数据标定。
|
界面变更还需在浏览器检查竖屏布局、旋转、碰撞回位、弹窗和工坊导入导出;自动测试不替代实机交互验收。较小屏幕可能需要纵向滚动。认知难度评分不直接加入棋盘规模,仍是未经玩家数据标定的结构估计。工作量中的估计秒数也只是内部制作假设,不能当作真人平均通关时长。
|
||||||
|
|
||||||
`cocos/` 是适配示例,不是完整 Creator 工程;原生场景及 Android/iOS 构建尚未完成验收。`docs/ui-layout.json` 是旧版布局快照,不应直接作为当前场景模板。
|
`cocos/` 是适配示例,不是完整 Creator 工程;原生场景及 Android/iOS 构建尚未完成验收。`docs/ui-layout.json` 是旧版布局快照,不应直接作为当前场景模板。
|
||||||
|
|
||||||
更多背景见 [视频与规则分析](docs/video-analysis.md)。
|
更多背景见 [视频与规则分析](docs/video-analysis.md)。
|
||||||
|
|
||||||
|
## V2 色彩与离场展示
|
||||||
|
|
||||||
|
打开 `http://localhost:4173/design-showcase.html`,切换五套关卡主题并播放离场动效。主线按难度使用四套背景,12 色棋子库在每关收敛为 3–4 色;编辑器开放全部 12 色。每周挑战目前为独立美术主题预览,尚未实现周赛功能。规范与色库详见 `Design.md`。
|
||||||
|
|
||||||
|
## 视频 UI 实验室
|
||||||
|
|
||||||
|
[Hex Video Lab](labs/hex-video/index.html) 是基于 `examples/hex.mp4` 的独立 UI / 特效复刻,可切换地图、棋盘和领奖场景,支持自动试玩、单片翻转、彩纸、金币飞入、慢速播放及参考帧对照。棋子按本项目要求保留单片薄侧边,不使用视频中的多片堆叠。运行 `npm run dev` 后访问 `/labs/hex-video/index.html`;详见 [复刻笔记](labs/hex-video/README.md)。
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class CocosPainter {
|
|||||||
return new Color((rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255, alpha);
|
return new Color((rgb >> 16) & 255, (rgb >> 8) & 255, rgb & 255, alpha);
|
||||||
}
|
}
|
||||||
sprite(index: number, x: number, y: number, radius: number) {
|
sprite(index: number, x: number, y: number, radius: number) {
|
||||||
if (!this.frames.length) return false;
|
if (!this.frames[index]) return false;
|
||||||
if (!this.pool[this.cursor]) {
|
if (!this.pool[this.cursor]) {
|
||||||
const node = new Node('Tile'); node.layer = this.root.layer; node.parent = this.root;
|
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;
|
node.addComponent(UITransform);const mask=node.addComponent(Mask);mask.type=Mask.Type.GRAPHICS_STENCIL;
|
||||||
@@ -94,7 +94,7 @@ export class HexTrailsBoard extends Component {
|
|||||||
this.painter = new CocosPainter(this.node,graphics,arrowGraphics,ui.width,ui.height,this.tileAtlas);
|
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.TOUCH_END,this.onTouch,this);
|
||||||
this.node.on(Node.EventType.SIZE_CHANGED,this.redraw,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))]);
|
this.loadLevel(this.levelAsset?.json ?? LEVELS[Math.max(0,Math.min(LEVELS.length-1,this.levelIndex))]);
|
||||||
}
|
}
|
||||||
onDestroy() {
|
onDestroy() {
|
||||||
this.node.off(Node.EventType.TOUCH_END,this.onTouch,this);
|
this.node.off(Node.EventType.TOUCH_END,this.onTouch,this);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HEX TRAILS · 色彩与离场设计</title><link rel="stylesheet" href="design/showcase.css"></head>
|
||||||
|
<body>
|
||||||
|
<header class="masthead"><a href="index.html">HEX TRAILS<span>转转六角</span></a><span>ART DIRECTION / 02</span><a class="game-link" id="game-link" href="index.html">进入游戏 ↗</a></header>
|
||||||
|
<main>
|
||||||
|
<section class="intro"><div><div class="eyebrow">COLORFUL WORLDS. FOCUSED PLAY.</div><h1>每一关,<br>都有自己的颜色。</h1><p>12 种棋子色彩,5 套关卡氛围。<br>同一棋盘,对照 4、7、9、12 色。</p></div><div class="intro-note"><span>01 / COLOR SYSTEM</span><p>选择下方主题,比较同一棋盘的不同配色。</p><div class="theme-tabs" id="theme-tabs" aria-label="关卡主题"></div></div></section>
|
||||||
|
<section class="density-section"><div class="section-heading"><div><div class="eyebrow">COLOR DENSITY STUDY</div><h2>七色,会不会更好玩?</h2></div><p>点击任一棋盘,在下方体验对应色数;布局保持一致。</p></div><div class="density-grid" id="density-grid"></div></section>
|
||||||
|
<div class="board-size-control"><label>棋盘规模 <select id="board-size"><option value="regular">常规对照</option><option value="5" selected>边长 6 · 91 格 · 42 枚棋子</option><option value="6">边长 7 · 127 格 · 60 枚棋子</option></select></label><span>双指缩放 · 放大后拖动 · 电脑可用 Ctrl/Command + 滚轮</span></div>
|
||||||
|
<section class="studio" aria-label="主题和动效互动展示">
|
||||||
|
<div class="theme-story"><div class="eyebrow" id="scene-label"></div><h2 id="scene-name"></h2><p id="scene-description"></p><div class="color-spec"><span>环境色</span><b id="background-value"></b><i id="background-chip"></i></div><div class="palette-title">本关棋子 <span id="palette-count"></span></div><div class="local-palette" id="local-palette"></div><p class="small-note">颜色只表达个性,不改变路线或碰撞规则。<br>难度同时保留文字标识。</p><a id="play-theme" href="index.html">用这套主题玩一关 ↗</a></div>
|
||||||
|
<div class="phone" id="phone"><div class="phone-top"><button class="icon-button" id="reset" aria-label="选择关卡" data-icon="grid"></button><div><strong>LEVEL 01</strong><span id="phone-label"></span></div><button class="icon-button" id="next-theme" aria-label="设置" data-icon="gear"></button></div><div class="moves"><span>剩余步数</span><strong id="moves">11</strong></div><canvas id="preview" aria-label="可点击棋子的真实规则演示棋盘"></canvas><div class="phone-status" id="phone-status" aria-live="polite">点击棋子,沿轨道出发</div><div class="rotations"><button id="left"><span data-icon="left"></span><b>左转</b></button><button id="right"><span data-icon="right"></span><b>右转</b></button></div><div class="phone-tools"><button id="undo"><span data-icon="undo"></span><b>撤销</b></button><button id="restart"><span data-icon="restart"></span><b>重来</b></button><button id="hint"><span data-icon="bulb"></span><b>提示</b></button></div></div>
|
||||||
|
<div class="motion-story"><div class="eyebrow">02 / EXIT CHOREOGRAPHY</div><h2>离开,也有回响。</h2><p>沿轨道出发,冲出边缘。<br>短尾迹跟随,同色六角碎片轻轻散开。</p><ol><li><b>01</b><div><strong>沿轨道滑行</strong><span>保留完整路线,读得懂运动。</span></div></li><li><b>02</b><div><strong>出口收束</strong><span>在棋盘边缘缩小,留下短尾迹。</span></div></li><li><b>03</b><div><strong>六角余韵</strong><span>6 枚同色碎片,向外展开后消失。</span></div></li></ol><button class="demo-button" id="demo">播放离场效果 <span>↗</span></button><label class="slow"><input type="checkbox" id="slow"> 慢速观察 ×0.4</label><p class="motion-status" id="motion-status" aria-live="polite">可反复播放,也可直接操作棋盘。</p><p class="small-note">系统开启减少动态效果时,关闭尾迹和碎片。</p></div>
|
||||||
|
</section>
|
||||||
|
<section class="theme-collection"><div class="section-heading"><div><div class="eyebrow">03 / ONE JOURNEY, FIVE MOODS</div><h2>从浅湾,到午夜。</h2></div><p>主线按难度自动匹配 · 每周挑战拥有独立主题</p></div><div class="theme-grid" id="theme-grid"></div><p class="collection-note">每周挑战展示为美术主题预览;这里的棋盘使用现有主线关卡。</p></section>
|
||||||
|
<section class="pigment-section"><div class="section-heading"><div><div class="eyebrow">04 / THE PIGMENT LIBRARY</div><h2>更多颜色,更有分寸。</h2></div><p>全局 12 色 · 单关开放 4 / 7 / 9 / 12 色 · 离场效果沿用棋子本色</p></div><div class="pigment-grid" id="pigment-grid"></div><div class="rules"><p><b>环境低饱和</b>背景营造关卡情绪,让棋子保持主角。</p><p><b>单关有取舍</b>以七色为默认起点,更多颜色也保持统一形状与箭头。</p><p><b>交互有秩序</b>工具统一蓝灰,主操作保持紫色。</p></div></section>
|
||||||
|
</main><footer>HEX TRAILS / DESIGN STUDY 02<span>实时 Canvas · 与游戏共用渲染与碰撞规则</span><a href="Design.html">查看设计规范 ↗</a></footer>
|
||||||
|
<dialog class="study-dialog" id="study-dialog"><div id="study-dialog-content"></div><button id="study-close">关闭</button></dialog>
|
||||||
|
<script type="module" src="src/showcase.js"></script></body></html>
|
||||||
@@ -49,3 +49,47 @@ button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid
|
|||||||
dialog::backdrop{background:var(--color-scrim);backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px)}
|
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(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}}
|
@media(prefers-reduced-motion:reduce){.rotate-button.hinted{animation:none}}
|
||||||
|
|
||||||
|
/* V2 scenes: environmental ink is independent of modal/utility ink. */
|
||||||
|
.level-ribbon,.moves-bubble strong,.moves-bubble>span,.board-instruction,.rotate-button,.board-tools>button,.theme-caption{color:var(--scene-ink,var(--color-text-primary))}
|
||||||
|
.theme-caption{grid-column:1;text-align:center;font-size:11px;letter-spacing:1px;margin-top:8px}
|
||||||
|
.swatches{grid-template-columns:repeat(4,1fr);padding:0;gap:7px}
|
||||||
|
.swatches button{min-height:36px}
|
||||||
|
.pigment-swatch>.ui-plate{background:var(--pigment)}
|
||||||
|
.pigment-swatch>.ui-plate>.sprite-crop{display:none}
|
||||||
|
.canvas-wrap{overflow:visible}
|
||||||
|
@media(max-height:700px){.canvas-wrap{width:min(100%,max(270px,calc(100dvh - 380px)))}}
|
||||||
|
.canvas-wrap{overflow:hidden}.board-zoom{display:flex;align-items:center;justify-content:center;gap:8px;padding:5px 0;position:relative;z-index:2;color:var(--scene-ink,var(--color-text-primary))}.canvas-wrap{aspect-ratio:auto}.canvas-wrap canvas{aspect-ratio:1;height:auto}.board-zoom button{min-width:38px;min-height:36px;font-size:13px}.board-zoom span{font-size:10px}
|
||||||
|
/* Board-first play screen: HUD, a tall viewport, one compact action row. */
|
||||||
|
.board-zoom{display:none!important}
|
||||||
|
body:not(.editing) .game-shell{height:100dvh;min-height:0;padding:8px 8px max(8px,env(safe-area-inset-bottom))}
|
||||||
|
body:not(.editing) .workspace,body:not(.editing) .game-column{height:100%;min-height:0}
|
||||||
|
body:not(.editing) .game-column{display:grid;grid-template-rows:52px minmax(0,1fr);gap:0}
|
||||||
|
body:not(.editing) .level-heading,body:not(.editing) .theme-caption,body:not(.editing) .board-instruction{display:none}
|
||||||
|
body:not(.editing) .hud{grid-row:1;margin:0;padding:0}
|
||||||
|
body:not(.editing) .moves-bubble{height:48px;display:flex;flex-direction:row;gap:8px}
|
||||||
|
body:not(.editing) .moves-bubble strong{font-size:28px}
|
||||||
|
body:not(.editing) .game-top{top:10px;left:14px;right:14px}
|
||||||
|
body:not(.editing) .game-card{grid-row:2;min-height:0;display:grid;grid-template-columns:repeat(5,1fr);grid-template-rows:minmax(0,1fr) 64px;gap:0}
|
||||||
|
body:not(.editing) .canvas-wrap{grid-column:1/-1;grid-row:1;width:100%;height:100%;min-height:0;margin:0;aspect-ratio:auto}
|
||||||
|
body:not(.editing) .canvas-wrap canvas{width:100%;height:100%;aspect-ratio:auto}
|
||||||
|
body:not(.editing) .rotation-controls,body:not(.editing) .board-tools{display:contents}
|
||||||
|
body:not(.editing) .rotate-button,body:not(.editing) .board-tools>button{grid-row:2;min-width:0;min-height:44px;padding:6px 0 0;margin:0;display:flex;flex-direction:column;justify-content:center;gap:4px}
|
||||||
|
body:not(.editing) .rotate-button>.ui-icon{width:34px;height:34px}
|
||||||
|
body:not(.editing) .rotate-button b,body:not(.editing) .board-tools b{font-size:11px;letter-spacing:0}
|
||||||
|
body:not(.editing) .tool-icon{width:40px;height:36px}
|
||||||
|
body:not(.editing) .tool-icon .ui-icon{width:24px;height:24px}
|
||||||
|
body:not(.editing) .tool-icon small{display:none}
|
||||||
|
body:not(.editing) .return-editor{position:absolute;top:12px;right:12px;margin:0;z-index:4}
|
||||||
|
@media(min-width:700px){body:not(.editing) .game-shell{height:calc(100dvh - 30px)}}
|
||||||
|
/* Restore navigation and float rotation controls over the tall board. */
|
||||||
|
body:not(.editing) .game-column{grid-template-rows:44px 44px minmax(0,1fr)}
|
||||||
|
body:not(.editing) .level-heading{display:block;grid-row:1;align-self:center;margin:0;padding:0 60px}
|
||||||
|
body:not(.editing) .level-ribbon{font-size:24px}
|
||||||
|
body:not(.editing) .hud{grid-row:2}
|
||||||
|
body:not(.editing) .moves-bubble{height:40px}
|
||||||
|
body:not(.editing) .game-card{grid-row:3;grid-template-columns:repeat(3,1fr);position:relative}
|
||||||
|
body:not(.editing) .rotation-controls{display:flex;position:absolute;left:0;right:0;bottom:76px;justify-content:space-around;margin:0;padding:0 24px;gap:50px;z-index:3;pointer-events:none}
|
||||||
|
body:not(.editing) .rotate-button{pointer-events:auto;min-width:80px;min-height:84px;gap:7px;padding:4px}
|
||||||
|
body:not(.editing) .rotate-button>.ui-icon{width:58px;height:58px}
|
||||||
|
body:not(.editing) .rotate-button b{font-size:13px}
|
||||||
|
|||||||
@@ -73,25 +73,25 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "分格 / 棋盘",
|
"label": "分格 / 棋盘",
|
||||||
"foreground": "#7C8B94",
|
"foreground": "#89867D",
|
||||||
"background": "#F2EEE5",
|
"background": "#F2EEE5",
|
||||||
"ratio": 3.034,
|
"ratio": 3.143,
|
||||||
"minimum": 3,
|
"minimum": 3,
|
||||||
"pass": true
|
"pass": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "轨道 / 棋盘",
|
"label": "轨道 / 棋盘",
|
||||||
"foreground": "#6D808B",
|
"foreground": "#435F78",
|
||||||
"background": "#F2EEE5",
|
"background": "#F2EEE5",
|
||||||
"ratio": 3.549,
|
"ratio": 5.764,
|
||||||
"minimum": 3,
|
"minimum": 3,
|
||||||
"pass": true
|
"pass": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "轨道 / 浅色外缘",
|
"label": "轨道 / 浅色外缘",
|
||||||
"foreground": "#6D808B",
|
"foreground": "#435F78",
|
||||||
"background": "#DFE3E2",
|
"background": "#F2EEE5",
|
||||||
"ratio": 3.174,
|
"ratio": 5.764,
|
||||||
"minimum": 3,
|
"minimum": 3,
|
||||||
"pass": true
|
"pass": true
|
||||||
},
|
},
|
||||||
@@ -126,5 +126,141 @@
|
|||||||
"ratio": 4.598,
|
"ratio": 4.598,
|
||||||
"minimum": 3,
|
"minimum": 3,
|
||||||
"pass": true
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "薄荷浅湾 / 场景文字",
|
||||||
|
"foreground": "#273B50",
|
||||||
|
"background": "#C5E4DF",
|
||||||
|
"ratio": 8.501,
|
||||||
|
"minimum": 4.5,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "杏色午后 / 场景文字",
|
||||||
|
"foreground": "#273B50",
|
||||||
|
"background": "#F1D7B8",
|
||||||
|
"ratio": 8.291,
|
||||||
|
"minimum": 4.5,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "鸢尾花园 / 场景文字",
|
||||||
|
"foreground": "#273B50",
|
||||||
|
"background": "#D8D0EB",
|
||||||
|
"ratio": 7.738,
|
||||||
|
"minimum": 4.5,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "玫瑰暮色 / 场景文字",
|
||||||
|
"foreground": "#273B50",
|
||||||
|
"background": "#E7C3CE",
|
||||||
|
"ratio": 7.165,
|
||||||
|
"minimum": 4.5,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "午夜极光 / 场景文字",
|
||||||
|
"foreground": "#F7F6F2",
|
||||||
|
"background": "#293956",
|
||||||
|
"ratio": 10.706,
|
||||||
|
"minimum": 4.5,
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "湖水青 / 箭头",
|
||||||
|
"foreground": "#FFFFFF",
|
||||||
|
"background": "#008B83",
|
||||||
|
"ratio": 4.183,
|
||||||
|
"minimum": 3,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "珊瑚橙 / 箭头",
|
||||||
|
"foreground": "#273B50",
|
||||||
|
"background": "#EF704F",
|
||||||
|
"ratio": 3.885,
|
||||||
|
"minimum": 3,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "嫩芽绿 / 箭头",
|
||||||
|
"foreground": "#273B50",
|
||||||
|
"background": "#91C95B",
|
||||||
|
"ratio": 5.86,
|
||||||
|
"minimum": 3,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "鸢尾蓝 / 箭头",
|
||||||
|
"foreground": "#FFFFFF",
|
||||||
|
"background": "#5267CF",
|
||||||
|
"ratio": 5,
|
||||||
|
"minimum": 3,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "玫瑰红 / 箭头",
|
||||||
|
"foreground": "#FFFFFF",
|
||||||
|
"background": "#C83F66",
|
||||||
|
"ratio": 4.818,
|
||||||
|
"minimum": 3,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "蜜桃杏 / 箭头",
|
||||||
|
"foreground": "#273B50",
|
||||||
|
"background": "#F5AA83",
|
||||||
|
"ratio": 5.997,
|
||||||
|
"minimum": 3,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "冰川青 / 箭头",
|
||||||
|
"foreground": "#273B50",
|
||||||
|
"background": "#58C9D5",
|
||||||
|
"ratio": 5.871,
|
||||||
|
"minimum": 3,
|
||||||
|
"pass": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "兰花紫 / 箭头",
|
||||||
|
"foreground": "#FFFFFF",
|
||||||
|
"background": "#AE64C7",
|
||||||
|
"ratio": 3.855,
|
||||||
|
"minimum": 3,
|
||||||
|
"pass": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
:root{
|
:root{
|
||||||
--color-background:#A8BECF;
|
--color-background:#A8BECF;
|
||||||
--color-board:#F2EEE5;
|
--color-board:#F2EEE5;
|
||||||
--color-board-grid:#7C8B94;
|
--color-board-grid:#89867D;
|
||||||
--color-track:#6D808B;
|
--color-track:#435F78;
|
||||||
--color-track-halo:#DFE3E2;
|
--color-track-halo:#F2EEE5;
|
||||||
--color-surface:#F7F6F2;
|
--color-surface:#F7F6F2;
|
||||||
--color-text-primary:#273B50;
|
--color-text-primary:#273B50;
|
||||||
--color-text-secondary:#314A5F;
|
--color-text-secondary:#314A5F;
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
"theme": {
|
"theme": {
|
||||||
"background": "#A8BECF",
|
"background": "#A8BECF",
|
||||||
"board": "#F2EEE5",
|
"board": "#F2EEE5",
|
||||||
"boardGrid": "#7C8B94",
|
"boardGrid": "#89867D",
|
||||||
"track": "#6D808B",
|
"track": "#435F78",
|
||||||
"trackHalo": "#DFE3E2",
|
"trackHalo": "#F2EEE5",
|
||||||
"surface": "#F7F6F2",
|
"surface": "#F7F6F2",
|
||||||
"textPrimary": "#273B50",
|
"textPrimary": "#273B50",
|
||||||
"textSecondary": "#314A5F",
|
"textSecondary": "#314A5F",
|
||||||
@@ -30,6 +30,144 @@
|
|||||||
"arrowLight": "#FFFFFF",
|
"arrowLight": "#FFFFFF",
|
||||||
"arrowDark": "#273B50"
|
"arrowDark": "#273B50"
|
||||||
},
|
},
|
||||||
|
"pigments": [
|
||||||
|
{
|
||||||
|
"name": "晴空蓝",
|
||||||
|
"color": "#2392FD",
|
||||||
|
"arrow": "#FFFFFF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "莓果粉",
|
||||||
|
"color": "#FC3E80",
|
||||||
|
"arrow": "#FFFFFF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "日光黄",
|
||||||
|
"color": "#FDBC28",
|
||||||
|
"arrow": "#273B50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "葡萄紫",
|
||||||
|
"color": "#874FFD",
|
||||||
|
"arrow": "#FFFFFF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "湖水青",
|
||||||
|
"color": "#008B83",
|
||||||
|
"arrow": "#FFFFFF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "珊瑚橙",
|
||||||
|
"color": "#EF704F",
|
||||||
|
"arrow": "#273B50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "嫩芽绿",
|
||||||
|
"color": "#91C95B",
|
||||||
|
"arrow": "#273B50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "鸢尾蓝",
|
||||||
|
"color": "#5267CF",
|
||||||
|
"arrow": "#FFFFFF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "玫瑰红",
|
||||||
|
"color": "#C83F66",
|
||||||
|
"arrow": "#FFFFFF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "蜜桃杏",
|
||||||
|
"color": "#F5AA83",
|
||||||
|
"arrow": "#273B50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "冰川青",
|
||||||
|
"color": "#58C9D5",
|
||||||
|
"arrow": "#273B50"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "兰花紫",
|
||||||
|
"color": "#AE64C7",
|
||||||
|
"arrow": "#FFFFFF"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"levelThemes": [
|
||||||
|
{
|
||||||
|
"id": "lagoon",
|
||||||
|
"name": "薄荷浅湾",
|
||||||
|
"label": "轻松",
|
||||||
|
"range": "0–29",
|
||||||
|
"background": "#C5E4DF",
|
||||||
|
"ink": "#273B50",
|
||||||
|
"palette": [
|
||||||
|
0,
|
||||||
|
4,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"description": "冷润的浅湾,配蓝、青与一点日光黄。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "apricot",
|
||||||
|
"name": "杏色午后",
|
||||||
|
"label": "进阶",
|
||||||
|
"range": "30–49",
|
||||||
|
"background": "#F1D7B8",
|
||||||
|
"ink": "#273B50",
|
||||||
|
"palette": [
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
4,
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"description": "温暖杏底,以珊瑚、鸢尾蓝和湖水青平衡。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lavender",
|
||||||
|
"name": "鸢尾花园",
|
||||||
|
"label": "烧脑",
|
||||||
|
"range": "50–69",
|
||||||
|
"background": "#D8D0EB",
|
||||||
|
"ink": "#273B50",
|
||||||
|
"palette": [
|
||||||
|
3,
|
||||||
|
6,
|
||||||
|
8,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"description": "柔紫空间,用嫩芽绿与冰川青拉开层次。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rose",
|
||||||
|
"name": "玫瑰暮色",
|
||||||
|
"label": "挑战",
|
||||||
|
"range": "70–100",
|
||||||
|
"background": "#E7C3CE",
|
||||||
|
"ink": "#273B50",
|
||||||
|
"palette": [
|
||||||
|
8,
|
||||||
|
7,
|
||||||
|
2,
|
||||||
|
11
|
||||||
|
],
|
||||||
|
"description": "低饱和玫瑰底,深色棋子与日光黄形成节奏。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "midnight",
|
||||||
|
"name": "午夜极光",
|
||||||
|
"label": "每周挑战",
|
||||||
|
"range": "特别主题",
|
||||||
|
"background": "#293956",
|
||||||
|
"ink": "#F7F6F2",
|
||||||
|
"palette": [
|
||||||
|
10,
|
||||||
|
11,
|
||||||
|
2,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"description": "深靛蓝舞台,冰川青、兰花紫和明亮暖色。"
|
||||||
|
}
|
||||||
|
],
|
||||||
"surfaces": {
|
"surfaces": {
|
||||||
"surface": "surface",
|
"surface": "surface",
|
||||||
"primary": "primary",
|
"primary": "primary",
|
||||||
@@ -58,7 +196,17 @@
|
|||||||
"modalMs": 420,
|
"modalMs": 420,
|
||||||
"starStartMs": 300,
|
"starStartMs": 300,
|
||||||
"starGapMs": 190,
|
"starGapMs": 190,
|
||||||
"collisionMs": 1250
|
"collisionMs": 650,
|
||||||
|
"rotationMs": 240,
|
||||||
|
"exitMsPerUnit": 60,
|
||||||
|
"exitMinTravelMs": 140,
|
||||||
|
"exitMaxTravelMs": 800,
|
||||||
|
"exitEffectMs": 180,
|
||||||
|
"exitLongBaseMs": 550,
|
||||||
|
"exitLongCellMs": 85,
|
||||||
|
"exitLongMaxMs": 1700,
|
||||||
|
"exitLongStartCells": 2,
|
||||||
|
"exitLongFullCells": 5
|
||||||
},
|
},
|
||||||
"radii": {
|
"radii": {
|
||||||
"control": 14,
|
"control": 14,
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 主线关卡验收与节奏表
|
||||||
|
|
||||||
|
自动生成:`npm run levels`。共 48 关,8 章,每章顺序为进入、换路、展开、小挑战、释放、长局回报。认知难度与棋盘规模分别记录。
|
||||||
|
|
||||||
|
全部关卡:可解且回放通关;每枚棋子在全部六朝向独立路线无闭环;参考解答无路线格外擦碰阻挡;相邻消除之间最多连续旋转一次。参考解答检查不等于穷尽玩家的任意旋转顺序,实际阻挡对象会在游戏中标记。
|
||||||
|
|
||||||
|
**预计时长只用于内部比较,没有真人测试数据。** 公式为 3 × 棋子数 + 0.75 × 初始独立路线总转弯 + 4 × 最少旋转。所有时间均为估计秒数,不能当作通关时限。
|
||||||
|
|
||||||
|
| 关 | 名称 | 节奏 | 格数 | 棋子 | 认知难度 | 开局可解 | 必要旋转 | 预计秒数 |
|
||||||
|
|---|---|---|---:|---:|---:|---:|---:|---:|
|
||||||
|
| 1 | 一眼看见出口 | 进入 | 37 | 12 | 0 | 12 | 0 | 36 |
|
||||||
|
| 2 | 转一下,就通了 | 换路 | 19 | 4 | 27 | 2 | 1 | 18 |
|
||||||
|
| 3 | 顺着线出发 | 展开 | 61 | 14 | 20 | 7 | 0 | 43 |
|
||||||
|
| 4 | 解开小结 | 小挑战 | 19 | 9 | 40 | 4 | 1 | 40 |
|
||||||
|
| 5 | 一片畅通 | 释放 | 37 | 16 | 21 | 6 | 0 | 48 |
|
||||||
|
| 6 | 轻松清空大盘 | 长局回报 | 37 | 18 | 18 | 8 | 0 | 54 |
|
||||||
|
| 7 | 熟悉的方向 | 进入 | 37 | 14 | 15 | 8 | 0 | 42 |
|
||||||
|
| 8 | 换条短路 | 换路 | 19 | 5 | 32 | 3 | 1 | 22 |
|
||||||
|
| 9 | 接连找到出口 | 展开 | 61 | 16 | 18 | 8 | 0 | 49 |
|
||||||
|
| 10 | 小小转机 | 小挑战 | 19 | 4 | 41 | 2 | 1 | 21 |
|
||||||
|
| 11 | 一路放行 | 释放 | 37 | 18 | 18 | 8 | 0 | 54 |
|
||||||
|
| 12 | 铺开好心情 | 长局回报 | 37 | 20 | 19 | 8 | 0 | 60 |
|
||||||
|
| 13 | 找到节奏 | 进入 | 37 | 16 | 21 | 6 | 0 | 48 |
|
||||||
|
| 14 | 转出一条新路 | 换路 | 19 | 4 | 34 | 2 | 1 | 18 |
|
||||||
|
| 15 | 继续向外走 | 展开 | 61 | 16 | 25 | 6 | 0 | 50 |
|
||||||
|
| 16 | 先解哪一枚 | 小挑战 | 19 | 7 | 45 | 2 | 1 | 30 |
|
||||||
|
| 17 | 宽阔的回报 | 释放 | 61 | 22 | 20 | 8 | 0 | 66 |
|
||||||
|
| 18 | 长路也轻巧 | 长局回报 | 61 | 24 | 21 | 8 | 0 | 72 |
|
||||||
|
| 19 | 慢慢看,都来得及 | 进入 | 37 | 18 | 18 | 8 | 0 | 54 |
|
||||||
|
| 20 | 只换一个方向 | 换路 | 19 | 4 | 33 | 2 | 1 | 19 |
|
||||||
|
| 21 | 连成顺畅的路 | 展开 | 61 | 18 | 26 | 6 | 0 | 57 |
|
||||||
|
| 22 | 轻巧绕一个弯 | 小挑战 | 19 | 6 | 45 | 3 | 1 | 30 |
|
||||||
|
| 23 | 松一口气 | 释放 | 61 | 24 | 21 | 8 | 0 | 72 |
|
||||||
|
| 24 | 从容的大舞台 | 长局回报 | 61 | 26 | 19 | 10 | 0 | 78 |
|
||||||
|
| 25 | 又是新的出口 | 进入 | 37 | 20 | 19 | 8 | 0 | 60 |
|
||||||
|
| 26 | 看见新的可能 | 换路 | 19 | 8 | 28 | 3 | 1 | 28 |
|
||||||
|
| 27 | 顺路解开 | 展开 | 61 | 18 | 25 | 7 | 0 | 56 |
|
||||||
|
| 28 | 留意这个转角 | 小挑战 | 19 | 6 | 44 | 2 | 1 | 30 |
|
||||||
|
| 29 | 满盘好走 | 释放 | 61 | 26 | 20 | 10 | 0 | 78 |
|
||||||
|
| 30 | 长长的成就感 | 长局回报 | 61 | 28 | 22 | 8 | 0 | 84 |
|
||||||
|
| 31 | 熟练地出发 | 进入 | 61 | 22 | 20 | 8 | 0 | 66 |
|
||||||
|
| 32 | 一点小变化 | 换路 | 19 | 10 | 32 | 6 | 1 | 42 |
|
||||||
|
| 33 | 接住这个节奏 | 展开 | 61 | 20 | 24 | 7 | 0 | 61 |
|
||||||
|
| 34 | 两条路的相遇 | 小挑战 | 19 | 5 | 47 | 2 | 1 | 24 |
|
||||||
|
| 35 | 把出口打开 | 释放 | 61 | 28 | 22 | 8 | 0 | 84 |
|
||||||
|
| 36 | 顺畅走到最后 | 长局回报 | 61 | 30 | 21 | 10 | 0 | 90 |
|
||||||
|
| 37 | 从容观察 | 进入 | 61 | 24 | 19 | 10 | 0 | 72 |
|
||||||
|
| 38 | 换个角度看 | 换路 | 19 | 7 | 32 | 3 | 1 | 30 |
|
||||||
|
| 39 | 一枚接着一枚 | 展开 | 61 | 20 | 24 | 7 | 0 | 61 |
|
||||||
|
| 40 | 给路线松个结 | 小挑战 | 19 | 4 | 45 | 2 | 1 | 24 |
|
||||||
|
| 41 | 轻轻一扫 | 释放 | 61 | 28 | 20 | 10 | 0 | 84 |
|
||||||
|
| 42 | 大盘小心思 | 长局回报 | 61 | 32 | 22 | 10 | 0 | 96 |
|
||||||
|
| 43 | 自在出发 | 进入 | 61 | 26 | 22 | 8 | 0 | 78 |
|
||||||
|
| 44 | 最后一个新转角 | 换路 | 19 | 8 | 38 | 3 | 1 | 34 |
|
||||||
|
| 45 | 熟悉的长路 | 展开 | 61 | 20 | 23 | 7 | 0 | 61 |
|
||||||
|
| 46 | 小结轻解 | 小挑战 | 19 | 9 | 40 | 4 | 1 | 43 |
|
||||||
|
| 47 | 收获一片畅通 | 释放 | 61 | 30 | 21 | 10 | 0 | 90 |
|
||||||
|
| 48 | 自在回响 | 长局回报 | 61 | 33 | 22 | 10 | 0 | 99 |
|
||||||
@@ -36,7 +36,7 @@ hex-trails/
|
|||||||
4. `levelIndex` 选择内置关卡,或把单关 JSON 赋给 `levelAsset`。
|
4. `levelIndex` 选择内置关卡,或把单关 JSON 赋给 `levelAsset`。
|
||||||
5. 按钮绑定 `rotateLeft()`、`rotateRight()`、`undo()`、`restart()`、`hint()`。
|
5. 按钮绑定 `rotateLeft()`、`rotateRight()`、`undo()`、`restart()`、`hint()`。
|
||||||
6. 监听 `trail-state`,更新步数、剩余棋子、朝向、胜负和忙碌状态。监听 `trail-hint-rotation` 高亮对应旋转按钮;`trail-blocked` 可接声音/震动。
|
6. 监听 `trail-state`,更新步数、剩余棋子、朝向、胜负和忙碌状态。监听 `trail-hint-rotation` 高亮对应旋转按钮;`trail-blocked` 可接声音/震动。
|
||||||
7. 按 `THEME.background` 设置纯色背景;当前主界面只保留居中的 Level、其下方的剩余步数,以及左侧选关、右侧设置。按钮与面板使用下述图片皮肤,动态文字与布局使用 Label、Button、Layout、Widget、EditBox、ScrollView 等节点搭建。原生端不需要 WebView。
|
7. 按 `THEME.background` 设置纯色背景;当前主界面只保留居中的 Level、其下方的待解棋子数量,以及左侧选关、右侧设置。按钮与面板使用下述图片皮肤,动态文字与布局使用 Label、Button、Layout、Widget、EditBox、ScrollView 等节点搭建。原生端不需要 WebView。
|
||||||
|
|
||||||
## 坐标与动画
|
## 坐标与动画
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# HEX TRAILS 玩法方向与主线制作要求
|
||||||
|
|
||||||
|
版本:2026-09-08。本文用于关卡制作、生成器筛选与玩法验收,不规定美术样式。
|
||||||
|
|
||||||
|
当前方向是让喜欢轻松观察、逐步整理并获得连续反馈的玩家,在越来越丰富的棋盘上保持推进感。玩家偏好以观察行为、试路习惯和体验反馈描述,不按性别推断。48 关已通过生成器结构检查、完整解答回放与替代操作顺序测试;逐关记录见 [主线验收表](campaign-review.md)。这属于规则与结构验收,玩家体验和真实完成时长仍待真人测试。
|
||||||
|
|
||||||
|
## 1. 核心循环
|
||||||
|
|
||||||
|
**观察 → 改变 → 得到可解释的反馈 → 再观察。**
|
||||||
|
|
||||||
|
玩家先辨认一个可能的出口,再点击棋子或旋转棋盘改变路线。成功时,棋子离场,剩余工作量减少;被挡时,移动棋子到达可理解的接触位置,与阻挡棋子产生明确反馈并返回。旋转后应能观察到路线或可行选择的变化。
|
||||||
|
|
||||||
|
试路是学习规则的一部分。一次没有成功的尝试应帮助玩家理解阻挡原因,不能同时带来隐性的通关成本。主线不靠最后一枚棋子的隐藏闭环、路线之外的擦碰或连续无进展旋转制造困难。
|
||||||
|
|
||||||
|
## 2. 免费尝试与计数语义
|
||||||
|
|
||||||
|
- 左转、右转免费,不扣步,也不因多转几次而失败。
|
||||||
|
- 失败试路免费,不扣步,不移动棋子的模型位置;可另行记录尝试次数用于内部分析。
|
||||||
|
- 每次成功移除一枚棋子计 1 次有效消除。
|
||||||
|
- 主线初始预算设为棋子数 `N`。此预算只表示还需要完成多少次有效消除,不是限制尝试的资源。
|
||||||
|
- 主线中,成功移除 `k` 枚后,剩余量应为 `N-k`;旋转、阻挡尝试与其他未成功的探路行为不改变它。
|
||||||
|
- 当前朝向没有可离场棋子,不应直接判负。旋转仍是可用的探索方式。
|
||||||
|
- 撤销应恢复真实的棋盘状态与对应计数,重开恢复原始关卡。
|
||||||
|
|
||||||
|
界面和关卡文案应传达剩余工作量,避免让玩家以为每次观察性尝试都会消耗通关机会。自定义关卡的数据兼容规则单独处理,不能反向成为主线的旋转限制。
|
||||||
|
|
||||||
|
## 3. 规模、认知难度与体验长度分开制作
|
||||||
|
|
||||||
|
| 维度 | 回答的问题 | 主要依据 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 棋盘规模 | 可操作的空间有多大? | 底格数、棋子数、占用率与区域分布 |
|
||||||
|
| 认知难度 | 找到下一步需要想多少? | 路线转弯、最长路线、可行选择比例、依赖关系与必要旋转判断 |
|
||||||
|
| 工作量 | 需要完成多少次有效动作? | 待移除棋子数、解答动作数、需要经过的路线量 |
|
||||||
|
| 体验长度估计 | 哪些候选可能更长? | 内部结构估计,后续由实测校准 |
|
||||||
|
|
||||||
|
认知难度不加入棋盘面积或棋子数量的直接加分项,也不加入免费尝试的步数压力。同样的规则可以放在更大棋盘上延长体验,不需要同步增加路线复杂度、堵塞比例和依赖深度。
|
||||||
|
|
||||||
|
早期就可使用 37 格棋盘,让玩家较早体验完整的整理过程。后期舒缓关可使用 61 格棋盘,提供更多熟悉而顺畅的消除。19 格小棋盘也可以承担较难的局部判断;它的挑战来自关系清楚但需要思考,不来自体积。
|
||||||
|
|
||||||
|
增加底格数而没有增加有效内容,不算延长体验;增加棋子却把所有出口堵成唯一解,也不算舒缓长局。制作时应检查是否形成足够多的连续成功机会,以及玩家能否在若干局部区域之间自然选择。
|
||||||
|
|
||||||
|
## 4. 48 关:8 章,每章 6 拍
|
||||||
|
|
||||||
|
主线按章节节奏编排,不按认知难度分数从低到高排序。每章允许自然回落;后期主要增加熟悉规则下的工作量,认知挑战上限不必随章节不断升高。
|
||||||
|
|
||||||
|
| 章内位置 | 节奏角色 | 制作意图 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | 进入 | 明显可行的开局,帮助玩家恢复观察节奏;可以有较大的盘面。 |
|
||||||
|
| 2 | 换路 | 用一次清楚的旋转或路线变化引出新进展,不堆叠多个新要求。 |
|
||||||
|
| 3 | 展开 | 扩展已经理解的关系,增加可连续处理的棋子和局部区域。 |
|
||||||
|
| 4 | 小挑战 | 章内认知峰值,可采用较小棋盘集中表达先后关系或一次必要旋转。 |
|
||||||
|
| 5 | 释放 | 明显低于上一关的认知负担,让出口、连续成功和自由选择重新变多。 |
|
||||||
|
| 6 | 长局回报 | 使用熟悉关系提供更长的整理过程;可以更大、更长,但不自动更难。 |
|
||||||
|
|
||||||
|
第 1 关要有直接可见的进展,完整解答不依赖旋转。它不承担识别闭环的教学任务。后续换路关再通过短而明确的因果关系介绍旋转。
|
||||||
|
|
||||||
|
第 5 关的释放应落实为结构差异,例如更多初始出口、更浅的依赖、更少的必要判断,不能只换一个轻松的名字。第 6 关应延长熟悉操作的愉快过程,不用反复试方向、难以解释的阻挡或长时间等待补足时长。
|
||||||
|
|
||||||
|
每章不要求所有关卡都必须旋转。进入、展开、释放和长局回报允许最少旋转数为 0。判断一个关卡是否适合当前节奏,应同时查看认知指标与工作量,而不是让较大棋盘天然获得更高难度标签。
|
||||||
|
|
||||||
|
## 5. 发布硬门槛
|
||||||
|
|
||||||
|
以下条件在生成候选后执行,并保存可重复验证的结果;任一必要检查未完成时,候选不能记为已验收。
|
||||||
|
|
||||||
|
### 规则与可解性
|
||||||
|
|
||||||
|
1. 关卡通过数据校验,旋转时棋子与底格关系保持有效,六次旋转恢复原始轨道状态。
|
||||||
|
2. 旋转和失败试路不扣主线预算;成功移除扣 1;完整回放后全部棋子离场。
|
||||||
|
3. 求解结果必须确认可达终局。触及搜索上限只能记为“未确定”,不能用作可解性通过证据。
|
||||||
|
|
||||||
|
### 不出现隐藏闭环
|
||||||
|
|
||||||
|
对每一枚初始棋子,分别在全部 6 个棋盘朝向下忽略其他棋子追踪独立路线。每条路线都必须离场,独立闭环数必须为 0。该要求覆盖主线所有章节,避免移除障碍后才发现最后一枚棋子一直绕圈。
|
||||||
|
|
||||||
|
### 阻挡可以从路线解释
|
||||||
|
|
||||||
|
在已验证解答回放的每个决策状态,对所有剩余棋子的路线进行检查。阻挡对象必须位于该棋子的独立路线所经过的格子中,不能出现路线格子之外的侧面擦碰阻挡。
|
||||||
|
|
||||||
|
这项检查不能只查看最终选中的成功移除路线,因为成功路线本来就没有阻挡,无法揭示其他可点击棋子的意外碰撞。每个 `blocked` 结果还必须指向真实存在且不是自身的棋子,并提供有效接触位置,供动画展示同一个阻挡原因。
|
||||||
|
|
||||||
|
### 旋转后立即出现进展
|
||||||
|
|
||||||
|
换路关和小挑战关的已验证解答中,相邻有效消除之间最多出现 1 次连续旋转。安排为必要步骤的这次旋转必须打开至少一个可消除选择,不能要求玩家先做一次没有进展的旋转,再猜第二次方向。
|
||||||
|
|
||||||
|
免费旋转不等于每个任意方向都保证成功,但正式解答和教学顺序必须有清晰因果。发布时同时检查旋转动画与模型更新一致,下一次点击使用旋转后的实际轨道。
|
||||||
|
|
||||||
|
### 节奏与计量
|
||||||
|
|
||||||
|
每关记录章节、节奏角色、底格数、棋子数、认知指标与工作量。完成 48 关的组内检查:小挑战之后确有释放,长局回报的工作量增长没有被等量的认知负担增长抵消。这里要求的是候选结构和回放验证,玩家的实际感受仍需后续测试。
|
||||||
|
|
||||||
|
## 6. 内部时长假设
|
||||||
|
|
||||||
|
当前仅用下式比较候选的潜在体验长度:
|
||||||
|
|
||||||
|
```text
|
||||||
|
估计秒数 = 3 × N + 0.75 × 总转弯数 + 4 × 最少旋转数
|
||||||
|
```
|
||||||
|
|
||||||
|
`N` 是初始棋子数;总转弯数采用当前分析器对初始朝向、忽略占用时各棋子独立路线的转弯数求和;最少旋转数来自规则求解器。各系数都是内部制作假设,尚未经过真人用时标定。
|
||||||
|
|
||||||
|
该结果不是玩家平均通关时长、完成承诺或难度分数。它没有完整模拟玩家的扫描速度、主动停顿、错误尝试、动画等待、提示使用与设备差异。大棋盘的估计数值更高,只能说明当前结构模型认为工作量更大,不能据此宣称玩家一定会玩得更久或更开心。
|
||||||
|
|
||||||
|
候选尚未确认可解、出现独立闭环或搜索未确定时,不应将估时作为可发布关卡的数据。公式调整时保留版本,避免把不同计算口径下的数字当作体验提升证据。
|
||||||
|
|
||||||
|
## 7. 后续玩家测试
|
||||||
|
|
||||||
|
先验证轻松观察与持续推进的体验,再决定是否需要调整公式或节奏。当前没有可引用的玩家统计结果。
|
||||||
|
|
||||||
|
| 观察项 | 记录与解释方式 |
|
||||||
|
| --- | --- |
|
||||||
|
| 主动停顿 | 区分玩家正在看路线的停顿、动画占用时间与离开游戏的空闲;不能把全部停顿都解释成困难。 |
|
||||||
|
| 成功消除间隔 | 观察连续成功是否顺畅,以及卡顿是否集中在少数阶段;结合动画时间判断。 |
|
||||||
|
| 旋转到下一次成功的间隔 | 记录是否能理解旋转带来的新机会,是否出现反复空转和方向试探。 |
|
||||||
|
| 失败试路与原因 | 区分明确的路线占用、无法解释的接触和误触;尝试次数多不自动意味着体验差。 |
|
||||||
|
| 最后一枚棋子阶段 | 观察是否出现突然的理解断点、长停顿或依赖提示结束。 |
|
||||||
|
| 提示、撤销与重开 | 结合发生阶段和玩家描述解释原因,避免仅用总次数判定关卡质量。 |
|
||||||
|
| 挑战后的感受 | 比较第 4、5、6 关的主动停顿、连续成功和主观轻松程度,确认释放与回报确实发生。 |
|
||||||
|
| 活跃完成时长 | 从首次有效交互到通关分段记录,并排除明确离开游戏的时间;与工作量、提示使用一起分析。 |
|
||||||
|
|
||||||
|
测试反馈应以可观察行为与玩家自己的描述为依据。需要延长后期单关时,优先增加熟悉且可顺畅处理的内容;只有在理解与反馈都清楚时,才增加少量新的判断要求。
|
||||||
@@ -19,21 +19,25 @@
|
|||||||
"id": "my-level",
|
"id": "my-level",
|
||||||
"name": "转个方向",
|
"name": "转个方向",
|
||||||
"rotationEnabled": true,
|
"rotationEnabled": true,
|
||||||
"moveLimit": 3,
|
"moveLimit": 1,
|
||||||
"cells": [{ "q": 0, "r": 0, "tracks": [3, 4, 5, 0, 1, 2] }],
|
"cells": [{ "q": 0, "r": 0, "tracks": [3, 4, 5, 0, 1, 2] }],
|
||||||
"tiles": [{ "id": "t0", "q": 0, "r": 0, "dir": 1, "color": 0 }]
|
"tiles": [{ "id": "t0", "q": 0, "r": 0, "dir": 1, "color": 0 }]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`tracks[i]` 为进入 i 端口后使用的出口,必须满足 `tracks[tracks[i]]===i` 且不能与自己相连。六端口共有 15 种完整配对。`color` 为 0–3。`rotationEnabled` 可选,默认开启;旧版 JSON 仍可导入,但会按新的碰撞规则重新验证。
|
`tracks[i]` 为进入 i 端口后使用的出口,必须满足 `tracks[tracks[i]]===i` 且不能与自己相连。六端口共有 15 种完整配对。`color` 为 0–11;旧文件的 0–3 仍有效。编辑器使用完整 12 色库;主线渲染按初始棋子顺序取主题色组,保持单关 3–4 色,颜色不参与规则。`rotationEnabled` 可选,默认开启;旧版 JSON 仍可导入,但会按新的碰撞规则重新验证。
|
||||||
|
|
||||||
导入限制:1–61 格、坐标半径不超过 4、步数 1–999。编辑器提供半径 2/3 的常用板型。底板不具备六重旋转对称性时,禁用整体旋转,避免棋子旋转后悬空。
|
导入限制:1–127 格、坐标半径不超过 6、`moveLimit` 为 1–999。该字段现在表示成功消除预算,只有移除棋子扣 1;旋转和失败试路不扣预算。主线将其设为初始棋子数 `N`,只表达剩余工作量。自定义关卡仍可导入不同预算,但预算不足 `N` 时无法完成全部消除。
|
||||||
|
|
||||||
|
编辑器提供半径 2/3/4(19/37/61 格)的板型,模型支持半径 4、61 格底板。底板不具备六重旋转对称性时,禁用整体旋转,避免棋子旋转后悬空。
|
||||||
|
|
||||||
## 旋转是真实的规则变化
|
## 旋转是真实的规则变化
|
||||||
|
|
||||||
顺时针一步将所有底板格子变为 `(q,r) → (-r,q+r)`,同时将轨道端口和对应出口都加 1(模 6)。上层棋子的 `(q,r,dir)` 均不改变。六次旋转恢复原状。
|
顺时针一步将所有底板格子变为 `(q,r) → (-r,q+r)`,同时将轨道端口和对应出口都加 1(模 6)。上层棋子的 `(q,r,dir)` 均不改变。六次旋转恢复原状。
|
||||||
|
|
||||||
每次旋转扣 1 步。撤销保存并恢复底板、棋子、步数、朝向和旋转计数。重开读取原始关卡,不能从已旋转的中间状态重开。
|
每次旋转免费;只有成功离场才扣 1 次消除预算。阻挡和闭环试路均不扣预算,也不改变棋子的模型位置。旋转次数与失败尝试次数仍可单独统计。撤销保存并恢复底板、棋子、预算、朝向和这些计数。重开读取原始关卡,不能从已旋转的中间状态重开。
|
||||||
|
|
||||||
|
可旋转关卡在当前朝向没有直接出口时,不会仅因此判负。主线预算为 `N`,玩家可以反复试路和转盘,不会因为这些尝试耗尽预算。
|
||||||
|
|
||||||
## 连续碰撞
|
## 连续碰撞
|
||||||
|
|
||||||
@@ -43,30 +47,62 @@
|
|||||||
|
|
||||||
例如:移动棋子在 `(1,1)` 向上走,另一棋子在 `(0,0)`。路线经过的格子不含 `(0,0)`,但经过相邻两格之间时,移动棋子的侧面可能擦到它,因此仍被阻挡。这比仅检查路线格子占用更严格。
|
例如:移动棋子在 `(1,1)` 向上走,另一棋子在 `(0,0)`。路线经过的格子不含 `(0,0)`,但经过相邻两格之间时,移动棋子的侧面可能擦到它,因此仍被阻挡。这比仅检查路线格子占用更严格。
|
||||||
|
|
||||||
|
上述擦碰仍属于通用模型规则,工坊及任意导入数据需要正确处理。新主线另设清晰度发布门槛:沿参考解答的各决策状态检查所有剩余棋子,排除路线经过格之外的擦碰阻挡。筛选参考解答不能证明所有任意旋转、点击序列都满足同样的清晰度要求,也不代表每次任意旋转都必然产生出口。
|
||||||
|
|
||||||
判定发生在移动前,动画只展示结果:成功沿线离场;失败走到接触位置再退回。不允许多个棋子同时运动,避免时序歧义。生成器、求解器、提示与工坊都调用同一个 `trace()`。
|
判定发生在移动前,动画只展示结果:成功沿线离场;失败走到接触位置再退回。不允许多个棋子同时运动,避免时序歧义。生成器、求解器、提示与工坊都调用同一个 `trace()`。
|
||||||
|
|
||||||
## 旋转求解与生成
|
## 旋转求解与生成
|
||||||
|
|
||||||
移走可行棋子只会减少障碍,不改变轨道,因此在任意朝向下先移除所有安全棋子不会损害后续可解性。求解器对“朝向+剩余棋子集合”做 BFS,每次转盘代价为 1,到达一个朝向后先消除所有可离场棋子。
|
移走可行棋子只会减少障碍,不改变轨道,因此在任意朝向下先移除所有安全棋子不会损害后续可解性。求解器对“朝向+剩余棋子集合”做 BFS,到达一个朝向后先消除所有可离场棋子。搜索仍将每次旋转计为一条代价为 1 的边,用于寻找最少旋转方案;这是搜索目标,不是玩家的预算扣费。
|
||||||
|
|
||||||
最短步数 = 初始棋子数 + 最少旋转数。BFS 返回混合 `tap` / `rotate` 动作序列。最大扩展状态数为 20,000;若触及上限,则返回 `exhausted` 表示尚未确定,不能宣称无解。
|
求解结果区分两种数量:
|
||||||
|
|
||||||
关卡生成先在碰撞规则下逆向放入可离场棋子,再旋转初始底盘,筛选必须旋转才能完成的布局,最后重新求解验证。主线按评分非递减排列,共 24 关,必要旋转 1–3 次。
|
|
||||||
|
|
||||||
## 数字化难度(0–100)
|
|
||||||
|
|
||||||
设 N=棋子数、T=忽略占用的平均转弯数、W=解答中的消除层数、F=初始无碰撞可离场数量、R=最少旋转数、K=N+R=最短步数、M=上限:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
规模 = min(1, N/37) × 15
|
minimumMoves = N // 最少成功消除成本
|
||||||
弯道追踪 = min(1, T/5) × 20
|
minimumActions = N + R // 最少交互数:消除 + 旋转
|
||||||
解锁层数 = min(1, max(0,W-1)/9) × 20
|
```
|
||||||
路线阻塞 = (1-F/N) × 15
|
|
||||||
必要旋转 = min(1, R/4) × 20
|
其中 `R` 是最少旋转数。BFS 返回混合 `tap` / `rotate` 动作序列;`reachable` 表示可以清空棋盘,`solvable` 还要求预算不少于 `N`。最大扩展状态数为 20,000;若触及上限,则返回 `exhausted` 表示尚未确定,不能宣称无解或已验证可解。
|
||||||
步数压力 = clamp(1-(M-K)/max(1,0.4N), 0, 1) × 10
|
|
||||||
|
基础生成器在碰撞规则下逆向放入可离场棋子,主线编排在候选基础上进行独立筛选与回放。主线制作规模为 48 关、8 章,每章 6 拍:进入、换路、展开、小挑战、释放、长局回报。顺序不按认知难度非递减排序,也不再要求每一关都必须旋转。
|
||||||
|
|
||||||
|
大棋盘用于增加熟悉规则下的工作量:早期就可使用 37 格,后期舒缓关采用 61 格,19 格也可承载较难的局部关系。小挑战后必须有认知负担回落,长局回报不能仅靠更复杂的路线或反复空转延长时间。
|
||||||
|
|
||||||
|
发布要求包括:每枚棋子在 6 个朝向下忽略占用的独立路线均无闭环;参考解答全程排除路线格外擦碰阻挡;换路和小挑战关在参考解答中最多连续旋转 1 次就打开下一次消除进展。它们是候选发布门槛,不能用来代替实际生成结果或玩家验收。完整制作方向见 [玩法方向](gameplay-direction.md),生成后逐关核对 [主线审阅报告](campaign-review.md)。
|
||||||
|
|
||||||
|
## 认知难度(0–100)
|
||||||
|
|
||||||
|
`analyze()` 的分数只估计路线阅读与依赖判断,不直接给棋盘面积、棋子数或试错预算加分。设:
|
||||||
|
|
||||||
|
- `N`:初始棋子数。
|
||||||
|
- `T`、`Tmax`:初始朝向忽略占用时,各棋子独立路线的平均、最大转弯数。
|
||||||
|
- `W`:参考解答中的消除层数;`D = max(0, W-1)/(N-1)`,当 `N≤1` 时为 0。
|
||||||
|
- `F`:初始无碰撞可离场棋子数。
|
||||||
|
- `S`:初始状态中因路线经过格之外的擦碰而被挡的棋子数。
|
||||||
|
- `R`:求解器得到的最少旋转数。
|
||||||
|
|
||||||
|
```text
|
||||||
|
弯道追踪 = min(1, T/4) × 22
|
||||||
|
最长路线 = min(1, Tmax/8) × 12
|
||||||
|
依赖比例 = min(1, D) × 18
|
||||||
|
初始阻塞 = (1-F/N) × 28
|
||||||
|
格外擦碰 = S/N × 12
|
||||||
|
必要旋转 = min(1, R/3) × 8
|
||||||
总分 = 四舍五入各项未取整值之和
|
总分 = 四舍五入各项未取整值之和
|
||||||
```
|
```
|
||||||
|
|
||||||
空关卡为 0 分且不能判为有效可解关卡。不可解、搜索未确定和步数不足分别展示。分项单独取整,显示值之和可能与总分略有差异。
|
`N=0` 时比例项按 0 处理,空关卡为 0 分且不能判为有效可解关卡。不可解、搜索未确定和消除预算不足分别处理。分项显示时若单独取整,显示值之和可能与总分略有差异。
|
||||||
|
|
||||||
0–29 轻松、30–49 进阶、50–69 烧脑、70–100 挑战。目前 24 关范围为 25–75。它是用于关卡排序的结构估计,没有经过玩家通关率标定;后续可用首次成功率、碰撞率、提示率、旋转次数、撤销率与耗时校准。
|
0–29 轻松、30–49 进阶、50–69 烧脑、70–100 挑战。分数是制作与比较用的结构估计,没有经过玩家通关率标定,不应直接决定主线排序。相同分数的两关可能拥有不同棋盘规模与工作量。
|
||||||
|
|
||||||
|
## 工作量与内部时长估计
|
||||||
|
|
||||||
|
`analyze().workload` 单独提供 `removals`(棋子数)、`rotations`(最少旋转数)、`minimumActions`(最少交互数)、`routeCells`(初始朝向独立路线的格步骤总数,可能包含重复访问)及 `estimatedSeconds`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
estimatedSeconds = round(3 × N + 0.75 × 总转弯数 + 4 × R)
|
||||||
|
```
|
||||||
|
|
||||||
|
总转弯数是初始朝向下、忽略占用的各棋子独立路线转弯数之和。该公式仅为内部制作假设,没有真人用时数据支撑,不能称为平均通关时长或向玩家承诺的完成时间。搜索未确定或关卡不可解时,不应将其作为可发布关卡的估时。
|
||||||
|
|
||||||
|
后续测试应观察主动停顿、成功消除间隔、旋转到下一次成功的间隔、失败试路原因、最后一枚棋子阶段,以及提示、撤销和重开的发生位置;明确区分观察停顿、动画占用和离开游戏的时间,再校准认知评分与工作量估计。
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
|
|
||||||
棋子的整个顶面参与沿途碰撞检测;即使经过的格子为空,途中擦碰邻近棋子也不能通过。碰撞时显示沿线接触和回退动画,判定由共享数学模型完成。
|
棋子的整个顶面参与沿途碰撞检测;即使经过的格子为空,途中擦碰邻近棋子也不能通过。碰撞时显示沿线接触和回退动画,判定由共享数学模型完成。
|
||||||
|
|
||||||
片段没有完整展示所有底板、胜负规则、扣步规则和完整旋转过程。四种颜色只负责区分,轨道交叉处不换轨;每次点击或旋转扣 1 步,失败点击也扣步,撤销恢复状态。这些细节是明确的实现约定。内置关卡为重新设计的可解关卡,不声称复原了被棋子遮住的全部原始轨道。
|
片段没有完整展示所有底板、胜负规则、扣步规则和完整旋转过程。颜色只负责区分,轨道交叉处不换轨。根据后续玩法方向,旋转和失败试路均免费,只有成功离场消耗 1 份额度;主线额度等于棋子数,因此没有试错步数压力。撤销恢复状态。当前主线采用 48 关的起伏节奏,详见 [玩法方向](gameplay-direction.md)。这些是本项目的设计约定,内置关卡不声称复原了被棋子遮住的全部原始轨道。
|
||||||
|
|
||||||
美术以生成式 PNG 图集、背景和立体标题实现。棋子使用厚侧壁、倒角和高光,箭头单独绘制,移动转向不改变棋子材质的光照方向。UI 使用游戏 HUD、转盘按钮、道具栏和关卡弹窗。没有将原视频截图作为游戏贴图。
|
美术以生成式 PNG 图集、背景和立体标题实现。棋子使用厚侧壁、倒角和高光,箭头单独绘制,移动转向不改变棋子材质的光照方向。UI 使用游戏 HUD、转盘按钮、道具栏和关卡弹窗。没有将原视频截图作为游戏贴图。
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
<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>
|
<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">
|
<main class="workspace">
|
||||||
<section class="game-column">
|
<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="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">轻松 · 0</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>
|
<div class="theme-caption" id="theme-caption"></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">12<small> / 12</small></strong></div></div><div class="moves-bubble"><span id="moves-title">待解棋子</span><strong id="moves">12</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">
|
<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="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="board-instruction" id="instruction">点击箭头,让棋子沿轨道离场</div>
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<aside class="inspector panel editor-panel" id="editor-panel" hidden>
|
<aside class="inspector panel editor-panel" id="editor-panel" hidden>
|
||||||
<div class="eyebrow">LEVEL WORKSHOP</div><h3>把灵感,连成轨迹</h3><p class="muted">选择工具,再轻点棋盘中的格子。</p>
|
<div class="eyebrow">LEVEL WORKSHOP</div><h3>把灵感,连成轨迹</h3><p class="muted">选择工具,再轻点棋盘中的格子。</p>
|
||||||
<label class="field">关卡名称<input id="editor-name" maxlength="60" value="我的第一条轨迹"></label>
|
<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="field-row"><label class="field">底板大小<select id="editor-radius"><option value="2">小 · 19 格</option><option value="3" selected>中 · 37 格</option><option value="4">大 · 61 格</option><option value="5">超大 · 边长 6 · 91 格</option><option value="6">极大 · 边长 7 · 127 格</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="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>
|
<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>
|
<p id="selection-info" class="selection-info">还没有选择格子</p>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<p class="editor-footnote">轨道、棋子与评分均使用独立数据模型。JSON 可作为后续 Cocos Creator 关卡资源。碰撞检查与正式游戏保持一致。</p>
|
<p class="editor-footnote">轨道、棋子与评分均使用独立数据模型。JSON 可作为后续 Cocos Creator 关卡资源。碰撞检查与正式游戏保持一致。</p>
|
||||||
</aside>
|
</aside>
|
||||||
</main>
|
</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>
|
<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 / 48</b><small>已完成</small></span><button id="edit-tab"><svg><use href="#i-edit"/></svg>关卡工坊</button></nav>
|
||||||
</div>
|
</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>
|
<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 class="win-overlay" id="result" aria-label="关卡结果" hidden></dialog>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# HEX Video Lab 01
|
||||||
|
|
||||||
|
独立的 UI / 动效实验室,入口:`http://localhost:4173/labs/hex-video/index.html`。根目录运行 `npm run dev`;`npm run build` 会将 lab 一起输出到 `dist/labs/hex-video/`。
|
||||||
|
|
||||||
|
完整用色分析见 [designv2.md](designv2.md) 与 [离线 HTML 手册](designv2.html),按项目 Design.md 结构整理,并附原尺寸参考帧、采样记录与色板。
|
||||||
|
|
||||||
|
## 视频研究
|
||||||
|
|
||||||
|
来源:`examples/hex.mp4`,416 × 960,约 30 fps,193.405 秒。已检查全片每 4–5 秒抽帧,并检查旋转器教学附近连续帧。
|
||||||
|
|
||||||
|
| 时间 | 可观察内容 | Lab 实现 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 00:00 | 蓝青径向背景、竖直关卡线、顶部货币/体力、绿色开始按钮 | 地图场景,可选择关卡编号并进入试玩 |
|
||||||
|
| 00:05–00:35 | 浅灰背景、顶部操作栏、六边形灰底与彩色箭头,离场后露出底板 | 共用现有 Game / trace / swept collision;18 片可解实验棋盘 |
|
||||||
|
| 00:10–00:11 | 同色多片连续翻转离场 | 按用户要求适配为**一片**翻转,侧壁仅 4 px(逻辑画布 416 px 宽),不画堆叠或副本 |
|
||||||
|
| 00:40–00:45 | 蓝色胜利页、放射光、金币堆、收集按钮 | 旋转光芒、金币浮动、10 枚金币沿二次曲线飞入余额 |
|
||||||
|
| 01:48–01:52 | 暗色遮罩、淡蓝圆角教学卡、旋转器示意 | 解锁弹窗;旋转操作使用本项目整块底盘规则 |
|
||||||
|
| 01:52–03:00 | 更多形状、旋转器关卡、重复彩纸反馈 | 保留统一实验棋盘;双侧粒子喷发可独立触发 |
|
||||||
|
| 03:00–03:13 | 领奖与返回地图,包含商店平台画面 | 收集后返回地图,实验余额及关卡编号更新 |
|
||||||
|
|
||||||
|
视频多片叠放不等于单片本体很厚。本 lab 的美术约束是:薄侧边、轻倒角、轻投影,移动中也只渲染一片;不模拟视频的堆叠玩法。
|
||||||
|
|
||||||
|
## 交互与参数
|
||||||
|
|
||||||
|
- 三个场景独立切换;自动演示通过真实可离场查询逐片清空,并自动进入领奖页。
|
||||||
|
- 被阻挡的棋子向接触位置轻碰后返回,不扣操作数。撤销、提示、底盘旋转可用。
|
||||||
|
- 动画速度 0.25×–1.5×;彩纸可单独触发;可开启轨道辅助线与参考帧对比。
|
||||||
|
- 设置中可打开简单合成反馈音;默认静音。尊重系统减少动态效果偏好。
|
||||||
|
- 桌面提供检查面板,窄屏上下排列;键盘 Tab 可聚焦棋子,Enter 操作;弹窗支持 Escape。
|
||||||
|
- 试玩数据不写入主项目存档;地图编号仅用于 UI 演示,所有节点使用同一可解棋盘。
|
||||||
|
|
||||||
|
## 文件与移植
|
||||||
|
|
||||||
|
`lab.js` 负责 Canvas 棋子、单片翻转、粒子、金币曲线与场景交互;`lab.css` 负责 UI;`fixture.js` 提供确定性可解关卡。共用 `src/core.js`、`src/geometry.js`、`src/path-math.js`,未更改正式游戏玩法。
|
||||||
|
|
||||||
|
`reference/` 的 JPG 是从视频提取的对比素材,不用作棋子或背景贴图。UI、棋子、金币、光芒均为 CSS / Canvas / SVG 重绘。图标和角色采用简化占位,没有复制原游戏角色、商店或平台 UI。色值、动效时长是视觉近似,不是从原游戏资源获取的精确参数。
|
||||||
|
|
||||||
|
当前是浏览器 lab;未做 Cocos 或微信真机验证。
|
||||||
|
|
||||||
|
HTML 手册包含可搜索/复制色板、原尺寸参考帧灯箱、箭头对比示例和完整正文,图片全部内嵌。修改 Markdown 或采样 JSON 后运行 `npm run design:v2`;根项目 `npm run design` 也会同步生成。
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
# HEX TRAILS · Video Lab 设计规范 v2
|
||||||
|
|
||||||
|
版本:2.0 · 2026-09-09 · 适用:`labs/hex-video` 视频研究、网页 UI 与后续美术适配。
|
||||||
|
|
||||||
|
本文沿用项目 [Design.md](../../Design.md) 的“设计方向 → 设计方法 → 颜色体系 → 组件 → 资产 → 字体与形状 → 动效 → 验收”结构,完整分析 `examples/hex.mp4` 的用色规律。**这是视频分析与 Lab 下一版的设计依据,不是已完成换肤的声明,也不覆盖正式游戏的设计规范。**
|
||||||
|
|
||||||
|
本文分开标记三种信息:**视频观察**是画面直接可见的现象;**采样值**是解码帧指定区域的代表像素;**适配建议**是为本项目单片棋子与可读性作出的设计选择。当前 Lab 的 HEX 仍分散在 `lab.js` / `lab.css` 中,尚未建立 v2 token 模块。本文提出的 token 名称属于待落实规范,不能称为现有运行时的唯一数值来源。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 1. 设计方向
|
||||||
|
|
||||||
|
**明亮、糖果色、轻玩具质感;观察时安静,奖励时鲜明。** 视频没有把同一套背景贯穿所有场景,而是以“低彩度解谜 / 高彩度旅程与奖励”形成节奏。
|
||||||
|
|
||||||
|
- **棋盘场景**:近白背景、冷灰六边形底板、中性灰 HUD;视觉注意力交给高饱和彩色棋子和白箭头。
|
||||||
|
- **地图场景**:蓝到青的亮背景,淡蓝关卡节点形成纵向路径;绿色当前节点和开始按钮构成同一操作焦点。
|
||||||
|
- **奖励场景**:保留蓝青环境,增加浅青放射光;金黄金币与收集按钮成为中心焦点,彩纸补充短暂的小面积对比。
|
||||||
|
- **教学场景**:深蓝黑遮罩降低整个背景的亮度;白色内容面、浅蓝标题栏、绿色确认按钮接管视线。
|
||||||
|
- **形体与色彩分工**:六边形、箭头和位置解释操作;色相帮助分组与识别。不能仅凭颜色推断可走、被阻挡、品质或阵营。
|
||||||
|
|
||||||
|
### 1.1 单片适配约束
|
||||||
|
|
||||||
|
视频中多条同色侧缘和多片连续翻转来自**叠放的多片棋子**,不能据此把单片建模成厚块。本项目只有一片:保留薄侧边、轻倒角和轻投影,取消多层横线、堆叠副本和同色实体拖尾。当前 Lab 的 4px 侧边是实现参数,不是对视频单片厚度的测量结论。
|
||||||
|
|
||||||
|
### 1.2 与正式版 Design.md 的关系
|
||||||
|
|
||||||
|
| 项目 | 正式版当前方向 | 视频方向 / 本 Lab 研究方向 |
|
||||||
|
|---|---|---|
|
||||||
|
| 环境 | 按主题组织的低饱和纯色背景 | 棋盘近白;地图和奖励采用蓝青渐变 |
|
||||||
|
| 底板 | 暖白、明显轨道 | 冷灰、轻分格,参考画面未显示本项目弯轨 |
|
||||||
|
| 主操作 | 紫色体系 | 绿色开始/确认;金黄色领奖 |
|
||||||
|
| 棋子 | 12 色库,默认单关 7 色 | 至少七个明确可见的色族,偏高饱和 |
|
||||||
|
| 形体 | 顶视哑光、无厚侧面 | 视频有堆叠;Lab 只借用单片薄倒角与明暗关系 |
|
||||||
|
| 图标 | 已接受的生成图集 | 本文分析原视频图标用色;不授权替换正式版资产 |
|
||||||
|
| 特效 | 同色离场碎片、克制反馈 | 翻转、彩纸、金黄奖励、青白放射光 |
|
||||||
|
|
||||||
|
不能把视频里的绿色确认、蓝青渐变直接混入正式版的一张紫色主操作界面。需要采用 v2 时,应整套切换场景角色,并保留正式版可读性与无障碍约束。
|
||||||
|
|
||||||
|
## 2. 分析方法与证据
|
||||||
|
|
||||||
|
### 2.1 视频与采样方法
|
||||||
|
|
||||||
|
视频为 416×960、约 30fps、193.405 秒。此前通过全片每 4–5 秒抽帧检查流程,本次额外提取 00:00、00:14、00:42、00:55、01:25、01:50、01:54、02:10、02:50、03:07 十张原尺寸 PNG,覆盖地图、空棋盘、不同色组、翻转、教学与领奖。
|
||||||
|
|
||||||
|
- 使用 ffmpeg 解码 PNG,避免新增 JPEG 压缩;原视频已有压缩、色彩转换与抗锯齿,PNG 不能恢复原游戏素材。
|
||||||
|
- 表中颜色取选定矩形 ROI 的 RGB 分通道中位数。彩色顶面筛选 HSV `S > 55%`、`V > 60%`,剔除白箭头与较暗侧壁;文字另用明/暗像素筛选。
|
||||||
|
- 坐标以原始 416×960 的左上角为原点,矩形右、下边界不包含。全部 ROI、筛选方式、有效像素数和色值保存在 [samples.json](reference/color-study/samples.json)。复算脚本为 [sample-colors.py](reference/color-study/sample-colors.py)。
|
||||||
|
- 矩形采样仍可能含少量高光、边缘、压缩混色,**HEX 是代表值,不是原材质常量**。HSV 的 V 是最大 RGB 通道值,不等于感知亮度。
|
||||||
|
- 跨帧色族检查使用棋盘大区域与色相分组,只验证色族稳定性;它会包含侧边和运动物体,不能代替精确顶面采样。
|
||||||
|
|
||||||
|
### 2.2 关键证据索引
|
||||||
|
|
||||||
|
| 帧 | 观察重点 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 00:00 | 蓝青地图、淡蓝节点、绿色当前关/开始、白色资源胶囊 | [原尺寸帧](reference/color-study/000.png) |
|
||||||
|
| 00:14 | 空棋盘露出全部冷灰底格,灰 HUD 保持不变 | [原尺寸帧](reference/color-study/014.png) |
|
||||||
|
| 00:42 | 金币、黄色收集按钮、青白光束和多色彩纸 | [原尺寸帧](reference/color-study/042.png) |
|
||||||
|
| 00:55 | 红黄绿蓝紫共存,红色翻转露背 | [原尺寸帧](reference/color-study/055.png) |
|
||||||
|
| 01:25 | 紫黄主导的一局、蓝色连续翻转、彩紙 | [原尺寸帧](reference/color-study/085.png) |
|
||||||
|
| 01:50 | 深遮罩、白面板、淡蓝头尾、绿标题和确认 | [原尺寸帧](reference/color-study/110.png) |
|
||||||
|
| 01:54 | 同一棋盘的青色与蓝色均配向上箭头;紫色与黄色有堆叠 | [原尺寸帧](reference/color-study/114.png) |
|
||||||
|
| 02:10 | 红绿蓝黄棋子、红橙旋转器、灰色锁定工具 | [原尺寸帧](reference/color-study/130.png) |
|
||||||
|
| 02:50 | 清楚的橙色顶面、红色翻转背面、下方彩纸 | [原尺寸帧](reference/color-study/170.png) |
|
||||||
|
| 03:07 | 金币飞向顶部余额栏,仍处于蓝青奖励页 | [原尺寸帧](reference/color-study/187.png) |
|
||||||
|
|
||||||
|
### 2.3 画面面积的用色节奏
|
||||||
|
|
||||||
|
以下为整帧 HSV 阈值计数;高彩度定义为 `S > 55%`,低彩度为 `S < 15%`,中间像素不属于这两组。这是屏幕像素占比,不是物体分割结果,也不是所有关卡的固定配额。
|
||||||
|
|
||||||
|
| 帧 / 状态 | 高彩度像素 | 低彩度像素 | 解读 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| 00:00 地图 | 92.0% | 3.2% | 环境本身承担明亮、高彩度的旅程氛围 |
|
||||||
|
| 02:10 有棋子的棋盘 | 13.3% | 84.9% | 高彩度集中在可操作对象,背景保持退后 |
|
||||||
|
| 00:14 空棋盘 | 0.3% | 99.0% | 清空后颜色自然退场,不需要再改背景宣告完成 |
|
||||||
|
| 00:42 奖励 | 92.1% | 1.8% | 由低彩度解谜跳入高彩度庆祝 |
|
||||||
|
| 01:50 教学 | 4.7% | 40.8% | 大部分剩余像素是暗蓝遮罩的中彩度;不能把它误判成明亮鲜艳区域 |
|
||||||
|
|
||||||
|
核心规律是**同一游戏在不同任务阶段分配不同的彩度面积**。不能仅提取一张“平均蓝色”来代表整段视频。
|
||||||
|
|
||||||
|
## 3. 颜色体系
|
||||||
|
|
||||||
|
### 3.1 棋盘环境与内容
|
||||||
|
|
||||||
|
下表的 `v2.*` 为建议语义名,色值为解码采样。
|
||||||
|
|
||||||
|
| 建议 Token | 代表色 | 来源 | 作用与配对 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `v2.play.background` | `#F2F2F4` | 02:10 | 主背景,几乎无彩度;不与棋子争抢注意力 |
|
||||||
|
| `v2.play.backgroundTop` | `#E4E4E5` | 02:10 | 顶部过渡的浅灰,缓慢过渡到主体近白 |
|
||||||
|
| `v2.play.ice` | `#E5E9EB` | 02:10 | 顶部冰块/软块装饰的浅色局部,并非整张纹理统一色 |
|
||||||
|
| `v2.board.face` | `#A6AAB9` | 02:10 | 偏蓝紫的中灰底格,托起彩色顶面 |
|
||||||
|
| `v2.hud.surface` | `#B2B4B8` | 02:10 | 灰色 HUD,彩度比棋盘更低 |
|
||||||
|
| `v2.hud.counterSurface` | `#F7F9FD` | 02:10 | 操作数字容器,明亮、接近白色 |
|
||||||
|
| `v2.hud.number` | `#62635E` | 02:10 | 操作数深灰字,给亮底提供清晰识别 |
|
||||||
|
| `v2.icon.ink` | `#5F5C5D` | 02:10 | 设置齿轮的中深灰,与白色按钮壳配对 |
|
||||||
|
|
||||||
|
**规律:同样是灰,底板稍冷、HUD 更中性、数字更深。** 环境层并非所有像素使用同一浅灰。底格分界靠轻暗边、形状与贴合关系形成,而非大幅色相变化。边界像素会混合多个表面,本次没有把单一压缩边缘像素提升为精确的 `boardGrid` token。
|
||||||
|
|
||||||
|
适配时可沿用原项目独立 `board / boardGrid / track` 角色,但本视频不能提供本项目轨道的原始配色。轨道应另作为“本项目添加项”进行验收,不可声称从参考还原。
|
||||||
|
|
||||||
|
### 3.2 地图的蓝青系统
|
||||||
|
|
||||||
|
| 建议 Token | 代表色 | 作用 |
|
||||||
|
|---|---|---|
|
||||||
|
| `v2.map.topBlue` | `#0C61E8` | 顶部深一些的明蓝,维持边缘层次 |
|
||||||
|
| `v2.map.edgeBlue` | `#0E78EF` | 两侧较深蓝色背景局部 |
|
||||||
|
| `v2.map.centerCyan` | `#40C8FE` | 中部更亮、更偏青的区域,托出关卡路线 |
|
||||||
|
| `v2.map.pathBlue` | `#0C8DF1` | 纵向连接线,比中心环境更暗、更纯 |
|
||||||
|
| `v2.map.nodeSurface` | `#A3C3FE` | 非当前节点的浅蓝六边形 |
|
||||||
|
| `v2.map.nodeInk` | `#2D5187` | 节点编号,深蓝与浅蓝配对 |
|
||||||
|
| `v2.currency.surface` | `#F7F3FA` | 顶部余额胶囊,接近白色,与深数字配对 |
|
||||||
|
|
||||||
|
地图不是蓝色纯底:**从边缘偏蓝、较暗,过渡到中部偏青、较亮**。背景还有同色系低对比六边形纹样,用变化的透明度/亮度提供质感,而不引入第三个强背景色。
|
||||||
|
|
||||||
|
渐变中心不是一枚发白的圆形光斑,视觉上更接近纵向亮带。上述样本来自不同空间位置,不是足以反推原始 shader 的精确 gradient stops。节点比背景低饱和且更浅,数字使用同色系深蓝;当前节点改绿,用色相跳变突出进度。
|
||||||
|
|
||||||
|
### 3.3 操作与状态
|
||||||
|
|
||||||
|
| 角色 | 采样 / 观察 | 用色规律 |
|
||||||
|
|---|---|---|
|
||||||
|
| 开始操作 `v2.action.start` | `#39DB2E` | 高饱和鲜绿,只承担明确进入/继续动作 |
|
||||||
|
| 当前进度 `v2.progress.current` | `#39DC30` | 与开始按钮近同色,连接“我在哪里”和“下一步做什么” |
|
||||||
|
| 教学确认 `v2.action.confirm` | `#3ADB2C` | 与开始按钮一致;微小 RGB 差异不需要拆成两种品牌绿 |
|
||||||
|
| 完成标记 | 绿色勾 + 浅底轮廓 | 颜色、勾形、进度位置共同表示完成 |
|
||||||
|
| 当前小阶段 | 亮蓝六边形 + 白数字 | 与 HUD 内浅灰的后续阶段区分 |
|
||||||
|
| 奖励收集 `v2.action.collect` | `#FFC80C` | 从“进入”的绿色切换到“领取”的金黄色 |
|
||||||
|
| 体力 | 红/粉红立体心形 + “已满”文字 | 对象图标本色,不能推导为红色全局危险状态 |
|
||||||
|
| 旋转器 | 深灰机械主体 + 红橙阀轮 | 红橙集中在可点部件,是功能焦点,不表示受阻错误 |
|
||||||
|
| 锁定工具 | 浅灰/白外壳 + 低饱和蓝锁 + 灰标签 | 通过锁形和关卡文字说明未解锁,弱化层级 |
|
||||||
|
| 商店入口 | 小面积红金图标 + 蓝紫底标签 | 作为附属入口,面积远小于绿色开始按钮 |
|
||||||
|
|
||||||
|
视频的绿色与奖励黄色分属不同任务时刻,因此不会出现“同一面板有两枚同等强度主操作”的冲突。不要将所有工具染成绿,也不要因为棋子是红色就把它标为失败。
|
||||||
|
|
||||||
|
### 3.4 七个棋子色族
|
||||||
|
|
||||||
|
以下取顶面局部并排除白箭头。HSV 用于分析相对关系,不应当作渲染材质设置。
|
||||||
|
|
||||||
|
| 建议 Token | 顶面代表色 | H / S / V | 帧 | 视觉作用 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `v2.tile.red` | `#E4413F` | 0.7° / 72.4% / 89.4% | 02:10 | 偏正红、略带暖感;较其他亮色沉一些 |
|
||||||
|
| `v2.tile.orange` | `#FE8A16` | 30.0° / 91.3% / 99.6% | 02:50 | 鲜橙,靠比黄色更红的色相区分 |
|
||||||
|
| `v2.tile.yellow` | `#F9BE02` | 45.7° / 99.2% / 97.6% | 02:10 | 偏金黄,不是浅奶油黄 |
|
||||||
|
| `v2.tile.green` | `#7BE637` | 96.7° / 76.1% / 90.2% | 02:10 | 黄绿色;比确认按钮的绿更偏黄、更浅 |
|
||||||
|
| `v2.tile.cyan` | `#00C6FE` | 193.2° / 100.0% / 99.6% | 01:54 | 高亮青蓝,和常规蓝棋子分开看待 |
|
||||||
|
| `v2.tile.blue` | `#4787ED` | 216.9° / 70.0% / 92.9% | 02:10 | 中亮蓝,低于青色的亮度感,保持白箭头清晰度 |
|
||||||
|
| `v2.tile.purple` | `#C460FD` | 278.2° / 62.1% / 99.2% | 01:54 | 明亮偏粉的紫,并非深紫或偏蓝紫 |
|
||||||
|
| `v2.tile.arrow` | `#F9FFFF` | 近白 | 01:54 | 各色棋子通用的粗箭头,略带阴影/边缘灰 |
|
||||||
|
|
||||||
|
**色族分布不是平均分成七等份的色轮。** 暖色红、橙、黄间距较密;蓝与青也相邻;绿色和紫色拉开较大间距。区分相邻色还依赖明暗、箭头与空间位置。不能把“七色”自动解释成需要每关平均使用七色。
|
||||||
|
|
||||||
|
跨帧检查中蓝色代表像素约在 `#4284EB`–`#4585EC` 附近,绿色约在 `#78E437`–`#7BE536` 附近,黄色约在 `#F7BC03`–`#F9BE01` 附近;这说明视频中的色族相对稳定。这里列的是若干实际中位值,不是允许任意组合 RGB 通道的上下界。橙色自动分组也会收进黄色暗侧边与红橙阀轮,因此橙色必须以 02:50 明确的橙棋子局部为准,不能机械采用整盘色相分箱结果。
|
||||||
|
|
||||||
|
#### 颜色与方向
|
||||||
|
|
||||||
|
常见组合为蓝/青向上、紫向下、红向左上、橙向左下、绿向右上、黄向右下,但这只是观察到的重复模式。01:54 蓝、青同时指向上;旋转器教学还展示棋子朝向随旋转变化。**不能根据视频证明“色相永远等于六个方向之一”。**
|
||||||
|
|
||||||
|
项目适配应将颜色存为独立于方向的稳定外观属性:棋子改变朝向时不应因查询 `colors[dir]` 而变色。当前 Lab 以初始方向绑定六色,并用该色保持运动外观,这是原型简化,未覆盖视频青色和旋转器的完整关系。
|
||||||
|
|
||||||
|
#### 色组与关卡节奏
|
||||||
|
|
||||||
|
00:55 可见红、黄、绿、蓝、紫;01:25 紫黄面积较大;02:10 红色占比更大;01:54 增加青蓝。色组和面积随棋子布局而变,而不是把底板和背景一起换主题。没有足够证据将某一色组定义为某个难度等级,也没有证据支持失败时全盘变红。
|
||||||
|
|
||||||
|
### 3.5 单片的顶面、侧边、高光与背面
|
||||||
|
|
||||||
|
| 色族 | 顶面样本 | 侧边局部样本 | 变化 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 蓝 | `#4787ED` | `#1E6AD5` | 保持约 215°–217° 色相,V 从 92.9% 降至 83.5%,饱和度上升 |
|
||||||
|
| 绿 | `#7BE637` | `#66C617` | 同属黄绿色,V 从 90.2% 降至 77.6% |
|
||||||
|
| 红 | `#E4413F` | `#C81C1D` | 保持正红,V 从 89.4% 降至 78.4% |
|
||||||
|
| 紫 | `#C460FD` | `#A538E9` | 保持紫色,V 从 99.2% 降至 91.4% |
|
||||||
|
|
||||||
|
这些侧边样本来自堆叠对象中的局部,可能含环境遮蔽;只适合说明**同色相、降低亮度、较高饱和度**的规律,不能用来测量单片几何厚度。
|
||||||
|
|
||||||
|
顶面并非完全纯色,有柔和亮度变化;上缘的窄高光用于区分斜切面。暗部仍然有颜色,不是一圈统一黑边。背面翻转时保持同色族、箭头消失;边缘朝向镜头时变窄且变暗。没有证据表明每次离场会换成随机色。
|
||||||
|
|
||||||
|
适配建议:每个色族显式定义 `face / bevelLight / side / edge`,不要对所有色统一做 RGB `+23 / -42 / -20` 后便视为最终材质。RGB 等量增减会使绿色、黄色和紫色的饱和度变化不一致。倒角高光要窄,避免做成宽白描边或发光选中圈。用户要求的单片只画一条薄侧面,不画视频中的多层暗线。
|
||||||
|
|
||||||
|
### 3.6 教学弹窗
|
||||||
|
|
||||||
|
| 建议 Token | 代表色 | 用法 |
|
||||||
|
|---|---|---|
|
||||||
|
| `v2.modal.scrimComposite` | `#191D2A` | 遮罩叠加后的暗背景样本,**不是遮罩源色和 alpha** |
|
||||||
|
| `v2.modal.header` | `#8FB1FD` | 淡蓝标题栏,承托白色标题 |
|
||||||
|
| `v2.modal.body` | `#FFFDFF` | 接近白的内容区,保证示意图是视觉中心 |
|
||||||
|
| `v2.modal.footer` | `#BBCBF1` | 更淡、更灰的蓝色底栏,与标题栏同色系 |
|
||||||
|
| `v2.action.confirm` | `#3ADB2C` | 底部确认按钮的高彩度绿 |
|
||||||
|
|
||||||
|
弹窗上方的“新功能!”是鲜绿色,呼应确认按钮;说明文字为灰色,关键词“旋转器”为橙色。文字颜色承担标题、正文、关键词三个等级,不把整段说明染成橙色。旋转器示意图继续使用灰底板和彩色棋子,教学与实际游戏保持同一对象语言。
|
||||||
|
|
||||||
|
遮罩明显压暗场景,但仅凭合成后的帧无法可靠分离原背景、遮罩色和透明度,也无法确认是否使用模糊。正式版 Design.md 的 48% / blur 5px 是其自身规范,不能写成视频测量结果。
|
||||||
|
|
||||||
|
### 3.7 奖励与特效颜色
|
||||||
|
|
||||||
|
| 对象 | 色彩组织 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| 金币顶面局部 | `#FFE236`,更亮的金黄 | 建立奖励的明亮中心;币边用橙金色形成浮雕 |
|
||||||
|
| 收集按钮 | `#FFC80C` | 比金币高光更实、更偏橙,与奖励对象同色族 |
|
||||||
|
| 放射光 | 浅青 / 青白,透明叠加 | 提高金币周围亮度,仍属于蓝青环境色系 |
|
||||||
|
| 奖励数“10” | 暖色数字配蓝色边缘 | 用暖冷轮廓把数字从金币和光芒中分离 |
|
||||||
|
| 彩纸 | 可辨认红、黄、青、蓝、绿、粉紫等 | 多色只占小碎片面积,运动中快速形成庆祝感 |
|
||||||
|
| 飞向余额的金币 | 维持金黄与橙色暗边 | 让“奖励被收进资源栏”的关联连续可见 |
|
||||||
|
|
||||||
|
彩纸与棋子色库不必完全相同:视频可见粉色碎片,不能因此推断存在粉色棋子。小碎片受到压缩与运动混色影响,本次不为彩纸捏造精确原始 HEX。适配建议使用单独 `fx.confettiPalette`,借用已验证的高彩度色族并允许少量粉色,避免把它当作棋子类别。
|
||||||
|
|
||||||
|
底部喷出的彩纸可以叠在仍未清空的棋盘下方;奖励页再扩大范围。视频并不是仅在最后一片离场后才出现彩纸。蓝青放射光属于奖励场景,不应长期铺在解谜棋盘上。
|
||||||
|
|
||||||
|
### 3.8 可读性:视频事实与项目要求分别处理
|
||||||
|
|
||||||
|
采用项目 Design.md 已使用的相对亮度计算方法,对**不透明代表色**进行诊断。计算为 `(L较亮 + 0.05) / (L较暗 + 0.05)`,sRGB 通道先线性化。下面白色取理想 `#FFFFFF`,深色取正式版 `textPrimary = #273B50`;不是对白箭头全部抗锯齿像素的实测。
|
||||||
|
|
||||||
|
| 底色 | 白色对比度 | 深蓝灰对比度 | Lab 正式化建议 |
|
||||||
|
|---|---:|---:|---|
|
||||||
|
| 蓝 `#4787ED` | 3.53:1 | 3.26:1 | 白箭头可保留,继续检查高光与缩小状态 |
|
||||||
|
| 红 `#E4413F` | 4.10:1 | 2.81:1 | 白箭头更合适 |
|
||||||
|
| 紫 `#C460FD` | 3.26:1 | 3.52:1 | 白箭头接近项目图形门槛,高光处仍需检查 |
|
||||||
|
| 橙 `#FE8A16` | 2.37:1 | 4.85:1 | 深箭头,或足够清晰的深色结构轮廓 |
|
||||||
|
| 黄 `#F9BE02` | 1.69:1 | 6.78:1 | 深箭头优先 |
|
||||||
|
| 绿 `#7BE637` | 1.59:1 | 7.23:1 | 深箭头优先 |
|
||||||
|
| 青 `#00C6FE` | 2.00:1 | 5.75:1 | 深箭头优先 |
|
||||||
|
| 开始绿 `#39DB2E` | 1.85:1 | 6.21:1 | 白色小字不足;深色字或单独加深按钮底色 |
|
||||||
|
| 领奖黄 `#FFC80C` | 1.55:1 | 7.40:1 | 深色字优先 |
|
||||||
|
| 弹窗标题蓝 `#8FB1FD` | 2.13:1 | 5.40:1 | 白色标题不自动满足项目大字门槛,建议深色标题 |
|
||||||
|
|
||||||
|
项目沿用普通文字 4.5:1、大字/必要图形 3:1 的验收目标。**视频还原模式**可以记录原本的白箭头、白字和阴影,但不能宣称已满足目标;**可读性适配模式**应明确标注改动,而不是把深箭头说成视频原样。阴影只有在形成足够可辨的结构边界时才有帮助,不能凭“有阴影”直接判定通过。
|
||||||
|
|
||||||
|
灰底板与近白环境的对比约 2.05:1,原视频还依赖格子轮廓和位置。如果格线承担必要识别,应单独增强边界,而不是只提高整块底板饱和度。
|
||||||
|
|
||||||
|
### 3.9 禁止与误读
|
||||||
|
|
||||||
|
- 把多片叠放误读为厚重单片,或为“立体感”添加多条深侧边。
|
||||||
|
- 把七个可见色族简化成六个方向的永久颜色编码,尤其漏掉青色。
|
||||||
|
- 把 RGB 采样值当成原游戏材质 token,或声称拿到了源 shader / 原始 alpha。
|
||||||
|
- 把 UI 的鲜绿确认色与偏黄绿棋子混成一个角色,只因它们都叫“绿”。
|
||||||
|
- 用整帧平均色决定所有场景;空棋盘、地图和领奖的彩度策略完全不同。
|
||||||
|
- 把红棋子或红橙旋转器当作错误提示,或让可走棋子统一变绿。
|
||||||
|
- 用奖励彩纸的粉色推断粉色棋子,用黄色侧边的橙色推断橙棋子。
|
||||||
|
- 将所有道具做成高饱和糖果按钮,与棋子争抢注意力。
|
||||||
|
- 只复制白字亮底的原貌,就宣称满足正式版可读性要求。
|
||||||
|
|
||||||
|
## 4. 组件规范
|
||||||
|
|
||||||
|
### 游戏主界面
|
||||||
|
|
||||||
|
灰色圆角 HUD 集中于画面顶部,左侧关卡和小阶段,居中白色操作数面板,右侧白壳灰齿轮。数字用深灰,标题/进度用浅蓝白绿点缀;不把整个 HUD 做成高饱和蓝。上方装饰逐渐退入近白环境,中部留给棋盘,底部工具保持灰白、低彩度。
|
||||||
|
|
||||||
|
底部工具的结构为完整圆形按钮,外圈与内圈保持同心;一条独立的浅灰文字条叠在圆形最下缘,遮住局部圆环。不能把圆形与文字区合成上圆下方的拱形外壳。Lab 使用 76×76px 圆形与底部重叠的 76×24px 标签条。
|
||||||
|
|
||||||
|
当前 Lab 中的“提示/撤销”是可用操作,视频对应位置在所见帧中是锁定工具。不能为了颜色一致,把可用功能也画成锁定态;应保留清晰标签与可操作状态差异。
|
||||||
|
|
||||||
|
### 棋盘与棋子
|
||||||
|
|
||||||
|
底板为冷灰,六边形紧密拼合;棋子高饱和、顶面有柔和层次、箭头粗而圆润。单片适配保留一条薄边。颜色对比优先通过顶面和灰底建立,不靠每片发光。运动、旋转与撤销应维持棋子身份色。
|
||||||
|
|
||||||
|
视频中未见本项目的可视弯轨;Lab 的轨道开关作为诊断工具,使用独立辅助色,不进入“参考原样”截图。选择、高亮提示、受阻反馈属于本项目状态设计,应单独记录。
|
||||||
|
|
||||||
|
### 地图
|
||||||
|
|
||||||
|
蓝青环境 → 更暗的蓝色路径 → 浅蓝节点 → 深蓝数字构成稳定背景层;当前节点与开始按钮用鲜绿连接。资源栏使用白胶囊和资源自身颜色,保持在蓝环境上可读。商店保留小面积,不能扩大成与开始按钮同等级的入口。
|
||||||
|
|
||||||
|
### 领奖
|
||||||
|
|
||||||
|
顶部货币信息、中上方胜利标题、中央金色奖励和下方黄色收集按钮形成单轴布局。白标题使用环境中的深蓝区域增强识别;金币周围的光束不能把全部背景冲成白色。领取后金币保持暖色飞入余额,余额数字更新的时间应跟随入账,而不是先更新再播放无关联动画。
|
||||||
|
|
||||||
|
### 教学弹窗
|
||||||
|
|
||||||
|
暗背景、浅蓝标题栏、白内容面、浅蓝底栏、鲜绿按钮。颜色层次必须与结构一致:关键词橙、标题绿/白、正文深灰。采用真模态限制背景操作。本文不要求修改已接受的正式版图标资源。
|
||||||
|
|
||||||
|
## 5. 图标与图片资产
|
||||||
|
|
||||||
|
| 内容 | 证据 / 当前资产 | 使用规则 |
|
||||||
|
|---|---|---|
|
||||||
|
| 原始视频 | `examples/hex.mp4` | 研究依据;不作为网页背景视频持续播放 |
|
||||||
|
| 本次十张 PNG | `reference/color-study/*.png` | 原尺寸色彩证据,不是游戏贴图 |
|
||||||
|
| 采样记录 | `reference/color-study/samples.json` | 可复核 ROI、筛选、HSV、对比度及面积数据 |
|
||||||
|
| 分析脚本 | `reference/color-study/sample-colors.py` | 重新采样与生成色板;依赖 Pillow,不参与网页运行 |
|
||||||
|
| 采样色板 | `reference/color-study/palette.svg` | 展示代表色,明确标为解码采样 |
|
||||||
|
| Lab 棋子 | `lab.js` Canvas | 当前为代码绘制;不称为原视频素材 |
|
||||||
|
| Lab UI | `lab.css`、`index.html` | 当前含简化符号/表情图标,其具体颜色随系统可能变化 |
|
||||||
|
| 正式版图标 | 正式版 Design.md 已记录的图集 | 沿用原资产规则,不在本研究中替换 |
|
||||||
|
|
||||||
|
设置齿轮是中深灰放在白壳上;锁是低饱和蓝放在浅灰壳上;体力心形为红粉;金币为金黄;旋转器为红橙/深灰机械组合。角色头像的灰白与黑眼睛是小面积身份识别,不是游戏主配色来源。本文未为角色和商店提供重绘授权或原资源导出。
|
||||||
|
|
||||||
|
## 6. 字体、间距与形状
|
||||||
|
|
||||||
|
本节按视频原尺寸 416×960 观察空间层级;没有从视频识别到可确认的字体名称,也不能将屏录字号等同于源项目字号。
|
||||||
|
|
||||||
|
| 层级 | 视频规律 | Lab 适配 |
|
||||||
|
|---|---|---|
|
||||||
|
| 胜利标题 | 粗白中文,明显大于操作数与标签 | 保留主次比例,检查其所在蓝背景局部的对比 |
|
||||||
|
| 操作数字 | 深灰粗体,位于白色容器中心 | 数字和容器保持高对比,避免蓝色装饰影响数字 |
|
||||||
|
| 主按钮 | 白色中文配阴影,底色鲜绿/金黄 | 原貌与可读性适配分开标记 |
|
||||||
|
| 棋子箭头 | 粗白线、圆端点,大小接近顶面中心区域 | 固定相对尺寸,不随颜色缩小箭头 |
|
||||||
|
| 锁定标签 | 小号深灰字,浅底 | 移植时放大到项目可读尺寸,不能复制模糊小字 |
|
||||||
|
|
||||||
|
界面圆角容器与六边形关卡/棋子并用,棋子为小圆角六边形;不把它们统一替换成圆按钮。地图开始按钮是横向六边形/切角长按钮,收集按钮为圆角长方形;同为主操作也保留对象身份差异。
|
||||||
|
|
||||||
|
正式化时继续沿用项目 4px 基础间距和至少 44×44px 触摸区域的要求,这些是本项目规范,不是视频量测结果。以 390×844 及更窄屏检查完整布局,浅色细字和窄高光在缩放后可能丢失。
|
||||||
|
|
||||||
|
## 7. 动效、音效与颜色反馈
|
||||||
|
|
||||||
|
- **离场翻转**:原物体颜色贯穿顶面、侧边、背面与离场;变化来自姿态与明暗。单片适配只翻转一个实体,不增加其他同色片以模拟原视频堆叠。
|
||||||
|
- **空位露出**:彩色对象移走后露出冷灰底板,画面高彩度面积逐步减少;不需要给空格添加绿色成功印记。
|
||||||
|
- **彩纸**:多色、小面积、短时运动,主要来自下部,领奖时扩大。全盘低彩度背景使少量彩纸也能明显可见。
|
||||||
|
- **金币飞入**:金黄颜色身份不变,通过曲线路径把中央奖励与顶部余额关联起来;暖币与冷蓝背景形成稳定分离。
|
||||||
|
- **奖励光芒**:浅青白光与蓝青背景同色系,不引入金黄全屏罩色;金黄主要留给金币和领奖按钮。
|
||||||
|
- **旋转器**:红橙轮保持可见的操作焦点;不要因旋转让棋子按新方向自动换色。视频旋转器局部机制与本项目整盘轨道旋转不同,不应混称完全复刻。
|
||||||
|
- **受阻/提示**:视频不足以定义精确的错误配色、抖动时长和提示色;当前 Lab 的回弹、白色提示轮廓是项目适配,需要按状态规范单独验收。
|
||||||
|
- **声音**:本文是色彩分析,不提供视频音频频率、采样或音效复刻结论。Lab 当前短合成音为原型反馈。
|
||||||
|
- **减少动态效果**:保留颜色、对象位置与完成文字,减少翻转、彩纸和持续光束运动;不能只降低透明度导致可读性下降。
|
||||||
|
|
||||||
|
当前 Lab 的 760ms 离场、410ms 受阻、420ms 底盘旋转、金币曲线时长都是实现参数,未逐帧标定成视频精确时长。动画速度控件用于观察,不等于实现了参考视频的每个阶段时序。
|
||||||
|
|
||||||
|
## 8. 验收与变更流程
|
||||||
|
|
||||||
|
### 8.1 当前 Lab 与视频的差异清单
|
||||||
|
|
||||||
|
| 当前实现 | 视频依据 | 下一版建议 |
|
||||||
|
|---|---|---|
|
||||||
|
| 六个棋子色 `#388EF2 / #62D62B / #FFBD00 / #B741F3 / #FF9018 / #ED3737` | 至少七色,含独立青色;绿和紫更明亮 | 建立七色族并按指定区域比较,优先校正紫/绿/青 |
|
||||||
|
| 初始方向查询颜色 | 蓝、青均向上,旋转机制改变朝向 | 独立持久化外观色,避免方向成为颜色唯一键 |
|
||||||
|
| 统一 RGB 加减色 | 同色族暗部的亮度与饱和度变化不一致 | 为每色定义明确顶面/侧边/高光角色 |
|
||||||
|
| `#ADB2BF` 底板 | 局部采样 `#A6AAB9`,更暗一些 | 用无棋子帧检查大面积灰底,不只比较小色块 |
|
||||||
|
| 全色白箭头 | 视频原貌也多为白箭头,但部分对比度不足 | 保留原貌研究;正式玩法单独提供高对比适配 |
|
||||||
|
| 地图 CSS 径向渐变 | 纵向中心亮带与边缘深蓝 | 对齐空间分布,不只替换渐变几个 HEX |
|
||||||
|
| 表情金币/心形和符号图标 | 视频是统一美术图标 | 如进入正式版,使用明确资产并逐图验收颜色 |
|
||||||
|
| 五色彩纸 | 视频还能看到粉色碎片 | 独立特效色板,不影响棋子身份色 |
|
||||||
|
| 可用提示/撤销按钮 | 原视频此位置为锁定工具 | 明确“玩法适配”,保留可用状态清晰度 |
|
||||||
|
|
||||||
|
### 8.2 建议的 token 管理方式
|
||||||
|
|
||||||
|
延续正式版“先语义、后组件”的方式,但为实验室建立独立命名空间:
|
||||||
|
|
||||||
|
- `play.*`:背景、顶端过渡、HUD、底板、格线;本项目轨道另列。
|
||||||
|
- `map.*`:边缘/中心蓝、路径、普通/当前节点、节点文字。
|
||||||
|
- `action.* / text.*`:开始、确认、收集、普通工具、禁用文字;明确成对的前景与背景。
|
||||||
|
- `tile.<family>.*`:七个色族各自的 face / side / bevelLight / edge / arrow。
|
||||||
|
- `reward.* / fx.*`:金币、光束、彩纸,不能与棋子共用一个无限扩大的数组。
|
||||||
|
- `modal.*`:遮罩源参数、标题栏、内容面、底栏;合成帧采样留在证据表中。
|
||||||
|
|
||||||
|
建议以后以 `labs/hex-video/design-tokens.js` 为运行时唯一数值来源,生成 JSON/色板/对比报告;**本次仅完成文档和证据资产,未创建或接入这一模块**。正式版 `src/design-tokens.js` 保持独立,未经明确采用 v2 不改写它。
|
||||||
|
|
||||||
|
### 8.3 验收步骤
|
||||||
|
|
||||||
|
1. 明确修改的是参考还原还是可读性适配,标记视频观察、采样与新增规则。
|
||||||
|
2. 选择对应原尺寸参考帧,在同一 416×960 逻辑比例比较背景、底板、棋子和 HUD 的面积与位置。
|
||||||
|
3. 对每个色族比较顶面、侧边和背面,确认只有一片,检查倒角是否误成宽白描边。
|
||||||
|
4. 重算指定 ROI;若改动 ROI 或过滤阈值,保留说明。不要以几处亮边像素代表整个顶面。
|
||||||
|
5. 检查地图→解谜→领奖→返回的彩度节奏;不把奖励背景留在解谜阶段。
|
||||||
|
6. 检查所有箭头、数字、标签和主按钮前景/背景配对;透明图层与抗锯齿必须额外目视验收。
|
||||||
|
7. 检查颜色在移动转向、旋转、撤销、重新开始后的稳定性;颜色本身不能代替合法行动判定。
|
||||||
|
8. 若实施代码变更,再运行项目测试和构建,验证桌面、390×844、窄屏、模态与减少动态效果。本次文档变更不代表已完成这些新色值的浏览器验收。
|
||||||
|
9. 原生/Cocos 采用同一角色与资源后再进行真机确认;本文没有原生渲染验证结论。
|
||||||
|
|
||||||
|
### 8.4 完成范围
|
||||||
|
|
||||||
|
本次完成:十张新增 PNG 证据帧、36 个局部采样记录、五张帧的彩度面积统计、跨帧色族检查、采样色板,以及本文。没有修改 Lab 的运行配色、正式版资产或玩法。所有推荐值可追溯到帧与 ROI;无法从视频确定的素材色、字体、遮罩参数、声音与时长均已明确区分。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { hexagon, solve, trace } from '../../src/core.js';
|
||||||
|
// A compact, deterministic visual fixture. Each inserted piece can escape before
|
||||||
|
// all earlier pieces, so reverse insertion always yields a complete solution.
|
||||||
|
export function makeLabLevel() {
|
||||||
|
const level = { version: 1, name: '薄片六边形实验', cells: hexagon(2), tiles: [], moveLimit: 18, rotationEnabled: true };
|
||||||
|
const candidates = [
|
||||||
|
[0,0,0],[0,-1,0],[0,1,3],[-1,0,5],[1,0,2],[1,-1,0],
|
||||||
|
[0,-2,0],[0,2,3],[-2,1,5],[2,-1,1],[-1,-1,4],[1,-2,0],
|
||||||
|
[-1,2,3],[1,1,2],[-2,2,4],[2,-2,5],[-2,0,5],[2,0,1]
|
||||||
|
];
|
||||||
|
for (const [q,r,preferred] of candidates) {
|
||||||
|
for (let offset = 0; offset < 6; offset++) {
|
||||||
|
const dir = (preferred + offset) % 6;
|
||||||
|
const tile = { id: `lab-${level.tiles.length}`, q, r, dir, color: dir };
|
||||||
|
if (trace(level, tile, [...level.tiles,tile]).status === 'exit') { level.tiles.push(tile); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
level.moveLimit = level.tiles.length;
|
||||||
|
if (!solve(level).solvable) throw new Error('Lab fixture must be solvable');
|
||||||
|
return level;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HEX / Motion Lab 01</title><link rel="stylesheet" href="lab.css"></head>
|
||||||
|
<body>
|
||||||
|
<header class="lab-header"><a href="../../index.html">⬡ <b>HEX TRAILS</b></a><span>EXPERIMENTS / 001</span><a href="README.md">复刻笔记 ↗</a></header>
|
||||||
|
<main class="workspace">
|
||||||
|
<aside class="inspector">
|
||||||
|
<p class="eyebrow">UI & MOTION STUDY</p><h1>小六边形,<br>大满足感。</h1><p class="intro">基于 hex.mp4 的交互复刻。<br>单片糖果棋子、翻转离场与满屏庆祝。</p>
|
||||||
|
<div class="section-title">01 <span>场景</span></div>
|
||||||
|
<nav class="scenes" aria-label="场景"><button data-scene="map">关卡地图 <span>01</span></button><button data-scene="play" class="active">棋盘试玩 <span>02</span></button><button data-scene="win">胜利领奖 <span>03</span></button></nav>
|
||||||
|
<div class="section-title">02 <span>特效试验</span></div>
|
||||||
|
<div class="effect-grid"><button id="demo">▶ 自动演示</button><button id="confetti">✦ 彩纸喷发</button><button id="unlock">⟳ 解锁弹窗</button><button id="reset">↺ 重置棋盘</button></div>
|
||||||
|
<label class="range-label" for="speed">动画速度 <output id="speed-value">1.0×</output></label><input id="speed" type="range" min="0.25" max="1.5" step="0.25" value="1">
|
||||||
|
<label class="toggle"><input type="checkbox" id="tracks"> 显示本项目轨道</label>
|
||||||
|
<label class="toggle"><input type="checkbox" id="reference-toggle"> 并排查看视频参考</label>
|
||||||
|
<p id="status" class="status" role="status">点击棋子,沿箭头方向离场。</p>
|
||||||
|
<div class="footnote"><i></i> LIVE CANVAS · 零依赖<br><span>共享 HEX TRAILS 路线与碰撞模型</span></div>
|
||||||
|
</aside>
|
||||||
|
<section class="preview-area" aria-label="交互预览"><div class="preview-label"><span id="scene-label">02 / PLAY</span><span>416 × 960</span></div><div class="device-wrap"><div class="device" id="device">
|
||||||
|
<section id="play-scene" class="game-scene">
|
||||||
|
<div class="ice"></div><div class="game-hud"><div class="level-progress">关卡 <span id="level-number">2</span><div><b>1</b><i></i><b>2</b><i></i><b>3</b></div></div><div class="moves">操作<strong id="moves">18</strong></div><button class="settings" aria-label="打开设置">⚙</button></div>
|
||||||
|
<canvas id="board" width="832" height="1920" aria-label="六边形棋盘;可使用下方键盘棋子列表操作"></canvas>
|
||||||
|
<div id="tile-controls" aria-label="棋子操作"></div>
|
||||||
|
<div class="tools"><button id="hint"><span>♧</span><small>提示</small></button><button id="undo"><span>↶</span><small>撤销</small></button></div>
|
||||||
|
<p class="game-tip" id="game-tip">轻点箭头,清空棋盘</p>
|
||||||
|
</section>
|
||||||
|
<section id="map-scene" class="blue-scene" hidden><div class="pattern"></div><div class="map-hud"><div class="mascot">⬡</div><div class="pill coin-balance">🟡 <b>510</b></div><div class="pill">❤️ <small>已满</small></div><button class="settings" aria-label="打开设置">⚙</button></div><div class="map-line"></div><div class="map-nodes" id="map-nodes"></div><button id="start" class="green-button">开始游戏</button><div class="map-caption">HEX TRAILS</div></section>
|
||||||
|
<section id="win-scene" class="blue-scene" hidden><div class="pattern"></div><div class="pill win-balance coin-balance">🟡 <b>510</b></div><div class="win-title"><h2>你赢了!</h2><p>已完成第 <span id="completed-level">2</span> 关</p></div><div class="sunburst"></div><div id="treasure" class="treasure"></div><strong class="reward">10</strong><button id="collect" class="gold-button">收集</button></section>
|
||||||
|
<canvas id="fx" width="832" height="1920" aria-hidden="true"></canvas>
|
||||||
|
<dialog id="modal"><div class="modal-card"><h2 id="modal-title">新功能!</h2><h3 id="modal-subtitle">旋转器</h3><div id="modal-content"></div><button id="modal-close" class="green-button">太棒了!</button></div></dialog>
|
||||||
|
</div></div><div class="preview-footer"><span>交互原型 / 独立实验室</span><button id="rotate">底盘旋转 ↻</button></div></section>
|
||||||
|
<section id="reference" class="reference" hidden><div class="preview-label"><span>VIDEO REFERENCE</span><span id="reference-time">00:10</span></div><img id="reference-image" src="reference/play.jpg" alt="视频棋盘参考帧"><p>原始视频抽帧 · 仅作视觉比较</p></section>
|
||||||
|
</main><script type="module" src="lab.js"></script>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { Game, available } from '../../src/core.js';
|
||||||
|
import { position, hexPoints, trackPoints } from '../../src/path-math.js';
|
||||||
|
import { routePoints, pointAlong, hitCell } from '../../src/geometry.js';
|
||||||
|
import { makeLabLevel } from './fixture.js';
|
||||||
|
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
const canvas = $('board'), ctx = canvas.getContext('2d'), fx = $('fx').getContext('2d');
|
||||||
|
const colors = ['#388ef2','#62d62b','#ffbd00','#b741f3','#ff9018','#ed3737'];
|
||||||
|
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
const view = { size: 44, ox: 208, oy: 448 };
|
||||||
|
let game = new Game(makeLabLevel()), scene = 'play', speed = 1, auto = false;
|
||||||
|
let motion = null, particles = [], flyingCoins = [], clock = 0, last = 0;
|
||||||
|
let nextAuto = 0, winAt = 0, coins = 510, level = 2, collecting = false, hintId = null, hintUntil = 0;
|
||||||
|
let rotation = null, muted = true, audioContext;
|
||||||
|
const setStatus = text => { $('status').textContent = text; };
|
||||||
|
function polygon(context, x,y,r) {
|
||||||
|
const points = hexPoints(x,y,r);
|
||||||
|
context.beginPath(); points.forEach((p,i) => i ? context.lineTo(p.x,p.y) : context.moveTo(p.x,p.y)); context.closePath();
|
||||||
|
}
|
||||||
|
function shade(hex, amount) {
|
||||||
|
const n = parseInt(hex.slice(1),16);
|
||||||
|
return `rgb(${[16,8,0].map(s => Math.max(0,Math.min(255,((n >> s)&255)+amount))).join(',')})`;
|
||||||
|
}
|
||||||
|
// One thin slab: 4px sidewall at the 416px reference resolution, no copies.
|
||||||
|
function tile(context, x,y,r,dir,color,flip=1,alpha=1) {
|
||||||
|
context.save(); context.globalAlpha = alpha; context.translate(x,y); context.scale(1,flip);
|
||||||
|
context.shadowColor = '#26304435'; context.shadowBlur = 4; context.shadowOffsetY = 3;
|
||||||
|
polygon(context,0,4,r); context.fillStyle = shade(color,-42); context.fill();
|
||||||
|
context.shadowColor = 'transparent';
|
||||||
|
polygon(context,0,0,r); const gradient = context.createLinearGradient(0,-r,0,r);
|
||||||
|
gradient.addColorStop(0,shade(color,23)); gradient.addColorStop(1,color); context.fillStyle = gradient; context.fill();
|
||||||
|
context.strokeStyle = shade(color,-20); context.lineWidth = 1.5; context.stroke();
|
||||||
|
polygon(context,0,-1,r-3); context.strokeStyle = '#ffffff32'; context.lineWidth = 1.5; context.stroke();
|
||||||
|
if (flip > .23) {
|
||||||
|
context.rotate(dir*Math.PI/3); context.strokeStyle = '#fff'; context.lineWidth = 5.5; context.lineCap = 'round'; context.lineJoin = 'round';
|
||||||
|
context.shadowColor = '#00000025'; context.shadowBlur = 1; context.shadowOffsetY = 1;
|
||||||
|
context.beginPath(); context.moveTo(0,13); context.lineTo(0,-12); context.moveTo(-9,-3); context.lineTo(0,-12); context.lineTo(9,-3); context.stroke();
|
||||||
|
}
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
function board() {
|
||||||
|
ctx.setTransform(2,0,0,2,0,0); ctx.clearRect(0,0,416,960);
|
||||||
|
ctx.save();
|
||||||
|
if (rotation) { const t = Math.min(1,(clock-rotation.start)/420); ctx.translate(view.ox,view.oy); ctx.rotate((-Math.PI/3)*(1-(1-(1-t)**3))); ctx.translate(-view.ox,-view.oy); if(t===1)rotation=null; }
|
||||||
|
for (const cell of game.level.cells) {
|
||||||
|
const p = position(cell.q,cell.r,view.size), x = p.x+view.ox, y = p.y+view.oy;
|
||||||
|
polygon(ctx,x,y+2,view.size-1);ctx.fillStyle='#9299a5';ctx.fill();
|
||||||
|
polygon(ctx,x,y,view.size-1);ctx.fillStyle='#adb2bf';ctx.fill();ctx.strokeStyle='#9ba2b0';ctx.lineWidth=1.2;ctx.stroke();
|
||||||
|
if ($('tracks').checked) for(let a=0;a<6;a++) if(a<cell.tracks[a]) {
|
||||||
|
const points=trackPoints(x,y,view.size,a,cell.tracks[a]);ctx.beginPath();points.forEach((p,i)=>i?ctx.lineTo(p.x,p.y):ctx.moveTo(p.x,p.y));ctx.strokeStyle='#828b9e';ctx.lineWidth=2;ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
for (const t of game.tiles) {
|
||||||
|
if(motion?.tile.id===t.id)continue;
|
||||||
|
const p=position(t.q,t.r,view.size),x=p.x+view.ox,y=p.y+view.oy;
|
||||||
|
if(t.id===hintId&&clock<hintUntil){polygon(ctx,x,y,42+Math.sin(clock/100)*2);ctx.fillStyle='#fff8';ctx.fill();}
|
||||||
|
tile(ctx,x,y,view.size*.88,t.dir,colors[t.dir]);
|
||||||
|
}
|
||||||
|
if(motion) {
|
||||||
|
const t=Math.min(1,(clock-motion.start)/motion.duration);
|
||||||
|
const progress=motion.blocked?Math.sin(t*Math.PI)*.92:t*t;
|
||||||
|
const p=pointAlong(motion.points,progress);
|
||||||
|
const flip=motion.blocked||reduced?1:Math.cos(t*Math.PI*5);
|
||||||
|
// Exactly one polygon slab is drawn for the moving piece, including its back.
|
||||||
|
tile(ctx,p.x,p.y,view.size*.88,(p.angle+Math.PI/2)/(Math.PI/3),colors[motion.tile.dir],Math.sign(flip||1)*Math.max(.09,Math.abs(flip)));
|
||||||
|
if(t>=1){const blocked=motion.blocked;motion=null;sync();if(!blocked){burst(26);if(game.won)winAt=clock+850;}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sync() {
|
||||||
|
$('moves').textContent=game.moves;$('level-number').textContent=level;
|
||||||
|
$('undo').disabled=!game.history.length||!!motion;$('rotate').disabled=scene!=='play'||!!motion||!!rotation||game.won;
|
||||||
|
$('tile-controls').replaceChildren(...game.tiles.map(t=>{
|
||||||
|
const button=document.createElement('button'),p=position(t.q,t.r,view.size);
|
||||||
|
button.className='tile-key';button.style.left=`${p.x+view.ox-25}px`;button.style.top=`${p.y+view.oy-25}px`;
|
||||||
|
button.setAttribute('aria-label',`棋子 ${t.id},方向 ${['上','右上','右下','下','左下','左上'][t.dir]}`);
|
||||||
|
button.disabled=!!motion;button.onclick=()=>tap(t.id);return button;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function tone(success) {
|
||||||
|
if(muted)return;
|
||||||
|
audioContext ||= new AudioContext(); audioContext.resume();
|
||||||
|
const osc=audioContext.createOscillator(),gain=audioContext.createGain();osc.connect(gain);gain.connect(audioContext.destination);
|
||||||
|
osc.frequency.setValueAtTime(success?650:180,audioContext.currentTime);osc.frequency.exponentialRampToValueAtTime(success?1100:100,audioContext.currentTime+.12);
|
||||||
|
gain.gain.setValueAtTime(.045,audioContext.currentTime);gain.gain.exponentialRampToValueAtTime(.001,audioContext.currentTime+.15);osc.start();osc.stop(audioContext.currentTime+.16);
|
||||||
|
}
|
||||||
|
function tap(id) {
|
||||||
|
if(scene!=='play'||motion||rotation||$('modal').open)return;
|
||||||
|
const result=game.tap(id);if(!result.tile)return;
|
||||||
|
const blocked=result.status!=='exit';
|
||||||
|
const points=routePoints(result,view,!blocked);
|
||||||
|
if (!blocked) {
|
||||||
|
const end=points.at(-1),prev=points.at(-2),dx=end.x-prev.x,dy=end.y-prev.y;
|
||||||
|
const distance=Math.min(...[dx>0?(476-end.x)/dx:dx<0?(-60-end.x)/dx:Infinity,dy>0?(1020-end.y)/dy:dy<0?(-60-end.y)/dy:Infinity].filter(t=>t>0));
|
||||||
|
if(Number.isFinite(distance))points.push({x:end.x+dx*distance,y:end.y+dy*distance});
|
||||||
|
}
|
||||||
|
motion={tile:result.tile,points,start:clock,duration:reduced?120:blocked?410:760,blocked};
|
||||||
|
hintId=null;tone(!blocked);setStatus(blocked?'前方有棋子:轻碰后回弹,操作数不减少。':'单片沿路径离场,露出灰色底板。');sync();
|
||||||
|
}
|
||||||
|
canvas.addEventListener('pointerdown',event=>{
|
||||||
|
const bounds=canvas.getBoundingClientRect(),x=(event.clientX-bounds.left)*416/bounds.width,y=(event.clientY-bounds.top)*960/bounds.height;
|
||||||
|
const cell=hitCell(game.tiles,view,x,y);if(cell)tap(cell.id);
|
||||||
|
});
|
||||||
|
function burst(count=100) {
|
||||||
|
if(reduced)count=Math.min(count,12);
|
||||||
|
for(let i=0;i<count;i++) {
|
||||||
|
const left=i%2===0;
|
||||||
|
particles.push({x:left?20:396,y:945,vx:(left?1:-1)*(40+Math.random()*150),vy:-250-Math.random()*400,age:0,life:1.5+Math.random()*1.5,color:['#ff4543','#ffe000','#10d9eb','#63df38','#3d93ff'][i%5],angle:Math.random()*6,spin:Math.random()*12-6,size:3+Math.random()*4});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function effects(dt) {
|
||||||
|
fx.setTransform(2,0,0,2,0,0);fx.clearRect(0,0,416,960);
|
||||||
|
particles=particles.filter(p=>p.age<p.life);
|
||||||
|
for(const p of particles){p.age+=dt;p.vy+=370*dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.angle+=p.spin*dt;fx.save();fx.globalAlpha=Math.min(1,(p.life-p.age)*2);fx.translate(p.x,p.y);fx.rotate(p.angle);fx.fillStyle=p.color;fx.fillRect(-p.size/2,-p.size,p.size*Math.cos(p.angle),p.size*1.8);fx.restore();}
|
||||||
|
flyingCoins=flyingCoins.filter(c=>clock-c.start<1000);
|
||||||
|
for(const c of flyingCoins){const t=Math.max(0,Math.min(1,(clock-c.start)/800)),u=1-t,x=u*u*c.x+2*u*t*c.cx+t*t*177,y=u*u*c.y+2*u*t*300+t*t*88;fx.save();fx.translate(x,y);fx.scale(Math.max(.15,Math.abs(Math.cos(t*12))),1);fx.beginPath();fx.ellipse(0,0,13,13,0,0,Math.PI*2);fx.fillStyle='#ffce16';fx.fill();fx.lineWidth=3;fx.strokeStyle='#ffe95c';fx.stroke();fx.restore();}
|
||||||
|
if(collecting&&!flyingCoins.length){collecting=false;coins+=10;updateCoins();level++;$('collect').disabled=false;showScene('map');}
|
||||||
|
}
|
||||||
|
function updateCoins(){document.querySelectorAll('.coin-balance b').forEach(el=>el.textContent=coins);}
|
||||||
|
function reset(){game=new Game(makeLabLevel());motion=null;rotation=null;auto=false;winAt=0;hintId=null;$('demo').textContent='▶ 自动演示';sync();setStatus('点击棋子,沿箭头方向离场。');}
|
||||||
|
function showScene(next) {
|
||||||
|
scene=next;auto=false;winAt=0;motion=null;rotation=null;particles=[];flyingCoins=[];collecting=false;$('collect').disabled=false;
|
||||||
|
$('demo').textContent='▶ 自动演示';
|
||||||
|
for(const s of ['map','play','win'])$(`${s}-scene`).hidden=s!==next;
|
||||||
|
document.querySelectorAll('[data-scene]').forEach(b=>b.classList.toggle('active',b.dataset.scene===next));
|
||||||
|
$('scene-label').textContent={map:'01 / LEVEL MAP',play:'02 / PLAY',win:'03 / REWARD'}[next];
|
||||||
|
$('reference-image').src=`reference/${next}.jpg`;$('reference-time').textContent={map:'00:00',play:'00:10',win:'00:42'}[next];
|
||||||
|
if(next==='win'){$('completed-level').textContent=level;burst(150);setStatus('胜利光芒与彩纸;点击「收集」查看金币飞入。');}
|
||||||
|
if(next==='map'){buildMap();setStatus('选择关卡节点,点击「开始游戏」进入实验棋盘。');}
|
||||||
|
if(next==='play'){if(game.won)reset();setStatus('点击棋子,沿箭头方向离场。');}
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
function buildMap() {
|
||||||
|
$('map-nodes').replaceChildren(...Array.from({length:5},(_,i)=>{
|
||||||
|
const n=level+4-i,b=document.createElement('button');b.className=`map-node${n===level?' current':''}`;b.style.top=`${98+i*148}px`;b.textContent=n;b.setAttribute('aria-label',`选择关卡 ${n}`);b.onclick=()=>{level=n;buildMap();setStatus(`已选择关卡 ${n},实验室使用同一可解棋盘展示。`);};return b;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function openModal(kind) {
|
||||||
|
auto=false;$('demo').textContent='▶ 自动演示';
|
||||||
|
if(kind==='unlock'){
|
||||||
|
$('modal-title').textContent='新功能!';$('modal-subtitle').textContent='旋转器';
|
||||||
|
$('modal-content').innerHTML='<p>轻触旋转按钮,让轨道转出新路</p><svg viewBox="0 0 240 175" aria-label="轨道旋转示意"><g fill="#acb2bd" stroke="#919aa8" stroke-width="2"><path d="M45 42h36l18 31-18 31H45L27 73Z"/><path d="M99 11h36l18 31-18 31H99L81 42Z"/><path d="M99 73h36l18 31-18 31H99L81 104Z"/><path d="M153 42h36l18 31-18 31h-36l-18-31Z"/></g><circle cx="117" cy="73" r="24" fill="#56595b"/><text x="117" y="89" text-anchor="middle" font-size="47" fill="#ff5425">↻</text><text x="63" y="82" text-anchor="middle" font-size="27" fill="white">↖</text><text x="171" y="82" text-anchor="middle" font-size="27" fill="white">↘</text></svg>';
|
||||||
|
$('modal-close').textContent='太棒了!';
|
||||||
|
} else {
|
||||||
|
$('modal-title').textContent='休息一下';$('modal-subtitle').textContent='设置';
|
||||||
|
$('modal-content').innerHTML=`<label><input type="checkbox" id="sound" ${muted?'':'checked'}> 开启合成音效</label><p>点击棋子试路,受阻会自动回弹。<br>提示寻找出口,撤销恢复上一步。</p>`;
|
||||||
|
$('sound').onchange=e=>muted=!e.target.checked;$('modal-close').textContent='继续游戏';
|
||||||
|
}
|
||||||
|
$('modal').showModal();
|
||||||
|
}
|
||||||
|
$('modal-close').onclick=()=>$('modal').close();
|
||||||
|
document.querySelectorAll('.settings').forEach(b=>b.onclick=()=>openModal('settings'));
|
||||||
|
document.querySelectorAll('[data-scene]').forEach(b=>b.onclick=()=>showScene(b.dataset.scene));
|
||||||
|
$('unlock').onclick=()=>openModal('unlock');$('confetti').onclick=()=>burst(130);$('reset').onclick=()=>{reset();showScene('play');};
|
||||||
|
$('demo').onclick=()=>{if(scene!=='play')showScene('play');if(game.won)reset();auto=!auto;nextAuto=clock;$('demo').textContent=auto?'Ⅱ 停止演示':'▶ 自动演示';};
|
||||||
|
$('speed').oninput=e=>{speed=Number(e.target.value);$('speed-value').textContent=`${speed.toFixed(2).replace(/0$/,'')}×`;};
|
||||||
|
$('reference-toggle').onchange=e=>$('reference').hidden=!e.target.checked;
|
||||||
|
$('start').onclick=()=>{reset();showScene('play');};
|
||||||
|
$('hint').onclick=()=>{const free=available(game.level,game.tiles);if(free.length){hintId=free[0].id;hintUntil=clock+2400;setStatus('白色闪光的棋子可以离场。');}else setStatus('当前没有出口,试试旋转底盘。');};
|
||||||
|
$('undo').onclick=()=>{if(motion)return;auto=false;$('demo').textContent='▶ 自动演示';winAt=0;if(game.undo()){sync();setStatus('已恢复上一步。');}};
|
||||||
|
$('rotate').onclick=()=>{if(motion||rotation)return;const result=game.rotate();if(result.status==='rotated'){rotation={start:clock};sync();setStatus('底盘旋转 60°;棋子的位置与朝向保持不变。');}};
|
||||||
|
$('collect').onclick=()=>{if(collecting)return;collecting=true;$('collect').disabled=true;for(let i=0;i<10;i++)flyingCoins.push({x:180+Math.random()*60,y:490+Math.random()*30,cx:80+Math.random()*220,start:clock+i*60});};
|
||||||
|
for(let i=0;i<13;i++){const c=document.createElement('i');c.className='coin';c.style.left=`${[12,50,91,42,76][i%5]}px`;c.style.top=`${73-Math.floor(i/3)*12}px`;c.style.zIndex=i;$('treasure').append(c);}
|
||||||
|
function frame(now) {
|
||||||
|
const dt=Math.min((now-last)/1000||0,.04)*speed;last=now;
|
||||||
|
if(!$('modal').open){clock+=dt*1000;if(scene==='play')board();effects(dt);
|
||||||
|
if(auto&&!motion&&!rotation&&clock>=nextAuto&&scene==='play'&&!game.won){const free=available(game.level,game.tiles);if(free.length){tap(free[0].id);nextAuto=clock+950;}else{auto=false;$('demo').textContent='▶ 自动演示';setStatus('当前没有直接出口,可旋转或重置棋盘。');}}
|
||||||
|
if(winAt&&clock>=winAt)showScene('win');
|
||||||
|
}
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
new ResizeObserver(entries => { $('device').style.transform=`scale(${entries[0].contentRect.width/416})`; }).observe(document.querySelector('.device-wrap'));
|
||||||
|
sync();buildMap();requestAnimationFrame(frame);
|
||||||
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 197 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="590" viewBox="0 0 1000 590">
|
||||||
|
<rect width="1000" height="590" rx="18" fill="#f6f7f9"/>
|
||||||
|
<text x="32" y="42" font-family="sans-serif" font-size="20" fill="#273b50">HEX VIDEO / DECODED PIXEL PALETTE</text>
|
||||||
|
<rect x="32" y="68" width="216" height="68" rx="9" fill="#4787ED" stroke="#273b5022"/>
|
||||||
|
<text x="32" y="156" font-family="monospace" font-size="13" fill="#273b50">tile.blue #4787ED</text>
|
||||||
|
<text x="32" y="175" font-family="monospace" font-size="11" fill="#667789">t=130s / decoded ROI median</text>
|
||||||
|
<rect x="272" y="68" width="216" height="68" rx="9" fill="#7BE637" stroke="#273b5022"/>
|
||||||
|
<text x="272" y="156" font-family="monospace" font-size="13" fill="#273b50">tile.green #7BE637</text>
|
||||||
|
<text x="272" y="175" font-family="monospace" font-size="11" fill="#667789">t=130s / decoded ROI median</text>
|
||||||
|
<rect x="512" y="68" width="216" height="68" rx="9" fill="#E4413F" stroke="#273b5022"/>
|
||||||
|
<text x="512" y="156" font-family="monospace" font-size="13" fill="#273b50">tile.red #E4413F</text>
|
||||||
|
<text x="512" y="175" font-family="monospace" font-size="11" fill="#667789">t=130s / decoded ROI median</text>
|
||||||
|
<rect x="752" y="68" width="216" height="68" rx="9" fill="#F9BE02" stroke="#273b5022"/>
|
||||||
|
<text x="752" y="156" font-family="monospace" font-size="13" fill="#273b50">tile.yellow #F9BE02</text>
|
||||||
|
<text x="752" y="175" font-family="monospace" font-size="11" fill="#667789">t=130s / decoded ROI median</text>
|
||||||
|
<rect x="32" y="193" width="216" height="68" rx="9" fill="#C460FD" stroke="#273b5022"/>
|
||||||
|
<text x="32" y="281" font-family="monospace" font-size="13" fill="#273b50">tile.purple #C460FD</text>
|
||||||
|
<text x="32" y="300" font-family="monospace" font-size="11" fill="#667789">t=114s / decoded ROI median</text>
|
||||||
|
<rect x="272" y="193" width="216" height="68" rx="9" fill="#00C6FE" stroke="#273b5022"/>
|
||||||
|
<text x="272" y="281" font-family="monospace" font-size="13" fill="#273b50">tile.cyan #00C6FE</text>
|
||||||
|
<text x="272" y="300" font-family="monospace" font-size="11" fill="#667789">t=114s / decoded ROI median</text>
|
||||||
|
<rect x="512" y="193" width="216" height="68" rx="9" fill="#FE8A16" stroke="#273b5022"/>
|
||||||
|
<text x="512" y="281" font-family="monospace" font-size="13" fill="#273b50">tile.orange #FE8A16</text>
|
||||||
|
<text x="512" y="300" font-family="monospace" font-size="11" fill="#667789">t=170s / decoded ROI median</text>
|
||||||
|
<rect x="752" y="193" width="216" height="68" rx="9" fill="#F2F2F4" stroke="#273b5022"/>
|
||||||
|
<text x="752" y="281" font-family="monospace" font-size="13" fill="#273b50">play.background #F2F2F4</text>
|
||||||
|
<text x="752" y="300" font-family="monospace" font-size="11" fill="#667789">t=130s / decoded ROI median</text>
|
||||||
|
<rect x="32" y="318" width="216" height="68" rx="9" fill="#A6AAB9" stroke="#273b5022"/>
|
||||||
|
<text x="32" y="406" font-family="monospace" font-size="13" fill="#273b50">board.face #A6AAB9</text>
|
||||||
|
<text x="32" y="425" font-family="monospace" font-size="11" fill="#667789">t=130s / decoded ROI median</text>
|
||||||
|
<rect x="272" y="318" width="216" height="68" rx="9" fill="#B2B4B8" stroke="#273b5022"/>
|
||||||
|
<text x="272" y="406" font-family="monospace" font-size="13" fill="#273b50">hud.surface #B2B4B8</text>
|
||||||
|
<text x="272" y="425" font-family="monospace" font-size="11" fill="#667789">t=130s / decoded ROI median</text>
|
||||||
|
<rect x="512" y="318" width="216" height="68" rx="9" fill="#0C61E8" stroke="#273b5022"/>
|
||||||
|
<text x="512" y="406" font-family="monospace" font-size="13" fill="#273b50">map.top #0C61E8</text>
|
||||||
|
<text x="512" y="425" font-family="monospace" font-size="11" fill="#667789">t=0s / decoded ROI median</text>
|
||||||
|
<rect x="752" y="318" width="216" height="68" rx="9" fill="#40C8FE" stroke="#273b5022"/>
|
||||||
|
<text x="752" y="406" font-family="monospace" font-size="13" fill="#273b50">map.center #40C8FE</text>
|
||||||
|
<text x="752" y="425" font-family="monospace" font-size="11" fill="#667789">t=0s / decoded ROI median</text>
|
||||||
|
<rect x="32" y="443" width="216" height="68" rx="9" fill="#39DB2E" stroke="#273b5022"/>
|
||||||
|
<text x="32" y="531" font-family="monospace" font-size="13" fill="#273b50">action.green #39DB2E</text>
|
||||||
|
<text x="32" y="550" font-family="monospace" font-size="11" fill="#667789">t=0s / decoded ROI median</text>
|
||||||
|
<rect x="272" y="443" width="216" height="68" rx="9" fill="#191D2A" stroke="#273b5022"/>
|
||||||
|
<text x="272" y="531" font-family="monospace" font-size="13" fill="#273b50">modal.scrimComposite #191D2A</text>
|
||||||
|
<text x="272" y="550" font-family="monospace" font-size="11" fill="#667789">t=110s / decoded ROI median</text>
|
||||||
|
<rect x="512" y="443" width="216" height="68" rx="9" fill="#8FB1FD" stroke="#273b5022"/>
|
||||||
|
<text x="512" y="531" font-family="monospace" font-size="13" fill="#273b50">modal.header #8FB1FD</text>
|
||||||
|
<text x="512" y="550" font-family="monospace" font-size="11" fill="#667789">t=110s / decoded ROI median</text>
|
||||||
|
<rect x="752" y="443" width="216" height="68" rx="9" fill="#FFC80C" stroke="#273b5022"/>
|
||||||
|
<text x="752" y="531" font-family="monospace" font-size="13" fill="#273b50">reward.button #FFC80C</text>
|
||||||
|
<text x="752" y="550" font-family="monospace" font-size="11" fill="#667789">t=42s / decoded ROI median</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,107 @@
|
|||||||
|
"""Sample decoded reference pixels, not source-game material colors.
|
||||||
|
Run from any directory: python3 sample-colors.py (requires Pillow).
|
||||||
|
Frames are ffmpeg -ss TIME -i examples/hex.mp4 -frames:v 1 TIME.png.
|
||||||
|
ROI uses [left, top, right, bottom], right/bottom exclusive, original 416x960.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from PIL import Image
|
||||||
|
from statistics import median
|
||||||
|
import colorsys, json
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
# Saturated face sampling rejects white arrows and deep sidewalls.
|
||||||
|
SAMPLES = [
|
||||||
|
('play.background',130,(25,270,75,295),False),
|
||||||
|
('play.backgroundTop',130,(150,150,250,180),False),
|
||||||
|
('play.ice',130,(210,20,250,40),False),
|
||||||
|
('board.face',130,(108,320,141,341),False),
|
||||||
|
('hud.surface',130,(285,85,325,110),False),
|
||||||
|
('hud.counter',130,(155,80,181,114),False),
|
||||||
|
('hud.number',130,(202,100,213,110),'dark'),
|
||||||
|
('settings.ink',130,(359,88,368,98),'dark'),
|
||||||
|
('tile.blue',130,(216,368,256,415),True),
|
||||||
|
('tile.green',130,(269,464,310,505),True),
|
||||||
|
('tile.red',130,(158,333,201,379),True),
|
||||||
|
('tile.yellow',130,(103,365,147,407),True),
|
||||||
|
('tile.purple',114,(179,602,235,645),True),
|
||||||
|
('tile.cyan',114,(179,309,236,355),True),
|
||||||
|
('tile.orange',170,(345,371,383,415),True),
|
||||||
|
('tile.arrow',114,(201,319,215,350),'light'),
|
||||||
|
('tile.blueSide',130,(224,545,244,551),False),
|
||||||
|
('tile.greenSide',130,(278,518,296,523),False),
|
||||||
|
('tile.redSide',130,(168,390,191,394),False),
|
||||||
|
('tile.purpleSide',114,(189,664,225,669),False),
|
||||||
|
('map.edge',0,(0,300,12,600),False),
|
||||||
|
('map.top',0,(100,10,300,35),False),
|
||||||
|
('map.center',0,(148,350,173,480),False),
|
||||||
|
('map.path',0,(200,340,214,390),False),
|
||||||
|
('map.node',0,(180,425,196,448),False),
|
||||||
|
('map.nodeInk',0,(203,432,214,444),'dark'),
|
||||||
|
('action.green',0,(122,815,143,850),True),
|
||||||
|
('progress.green',0,(181,725,195,745),True),
|
||||||
|
('currency.surface',0,(174,83,196,106),False),
|
||||||
|
('modal.scrimComposite',110,(40,140,100,180),False),
|
||||||
|
('modal.header',110,(40,260,130,283),False),
|
||||||
|
('modal.body',110,(45,361,365,390),False),
|
||||||
|
('modal.footer',110,(42,765,101,806),False),
|
||||||
|
('modal.confirm',110,(136,779,151,795),True),
|
||||||
|
('reward.button',42,(270,752,281,777),True),
|
||||||
|
('reward.gold',42,(191,446,210,458),True),
|
||||||
|
]
|
||||||
|
def pixels_of(image):
|
||||||
|
return image.get_flattened_data() if hasattr(image, 'get_flattened_data') else image.getdata()
|
||||||
|
def hexcolor(rgb):return '#'+''.join(f'{c:02X}' for c in rgb)
|
||||||
|
def lum(rgb):
|
||||||
|
v=[x/255 for x in rgb];v=[x/12.92 if x<=.04045 else ((x+.055)/1.055)**2.4 for x in v]
|
||||||
|
return sum(x*w for x,w in zip(v,[.2126,.7152,.0722]))
|
||||||
|
def contrast(a,b):
|
||||||
|
a,b=sorted([lum(a),lum(b)]);return round((b+.05)/(a+.05),2)
|
||||||
|
results=[]
|
||||||
|
for name,second,roi,mode in SAMPLES:
|
||||||
|
image=Image.open(ROOT/f'{second:03}.png').convert('RGB');pixels=list(pixels_of(image.crop(roi)));selected=[]
|
||||||
|
for p in pixels:
|
||||||
|
h,s,v=colorsys.rgb_to_hsv(*(c/255 for c in p))
|
||||||
|
if mode is True and not(s>.55 and v>.6):continue
|
||||||
|
if mode=='dark' and not(v<.55):continue
|
||||||
|
if mode=='light' and not(min(p)>225):continue
|
||||||
|
selected.append(p)
|
||||||
|
if not selected:raise ValueError(name)
|
||||||
|
rgb=tuple(round(median([p[i] for p in selected])) for i in range(3))
|
||||||
|
h,s,v=colorsys.rgb_to_hsv(*(c/255 for c in rgb))
|
||||||
|
results.append(dict(role=name,second=second,roi=roi,filter=mode,pixels=len(selected),hex=hexcolor(rgb),rgb=rgb,hsv=[round(h*360,1),round(s*100,1),round(v*100,1)],whiteContrast=contrast(rgb,(255,255,255)),darkContrast=contrast(rgb,(39,59,80))))
|
||||||
|
# Area measurements are image-wide HSV pixel counts, not object segmentation.
|
||||||
|
areas=[]
|
||||||
|
for second in [0,14,130,42,110]:
|
||||||
|
im=Image.open(ROOT/f'{second:03}.png').convert('RGB');px=list(pixels_of(im));n=len(px)
|
||||||
|
high=sum(colorsys.rgb_to_hsv(*(c/255 for c in p))[1]>.55 for p in px)
|
||||||
|
gray=sum(colorsys.rgb_to_hsv(*(c/255 for c in p))[1]<.15 for p in px)
|
||||||
|
areas.append(dict(second=second,highSaturationPercent=round(high/n*100,1),lowSaturationPercent=round(gray/n*100,1)))
|
||||||
|
(ROOT/'samples.json').write_text(json.dumps(dict(method='Median of decoded RGB ROI; HSV filters; not original game tokens.',samples=results,areaMeasurements=areas),ensure_ascii=False,indent=2)+'\n')
|
||||||
|
for r in results:print(r['role'],r['hex'],r['hsv'],'white',r['whiteContrast'],'dark',r['darkContrast'])
|
||||||
|
print(areas)
|
||||||
|
# Cross-frame check: coarse board ROI, excludes white arrows / neutral board.
|
||||||
|
# It still includes bevels, moving slabs and occlusions; not a material estimate.
|
||||||
|
hue_bands={'red':[(0,13),(345,360)],'orange':[(16,38)],'yellow':[(40,65)],'green':[(75,135)],'cyan':[(178,202)],'blue':[(205,239)],'purple':[(260,300)]}
|
||||||
|
cross=[]
|
||||||
|
for second in [55,85,114,130,170]:
|
||||||
|
im=Image.open(ROOT/f'{second:03}.png').convert('RGB').crop((20,280,396,690))
|
||||||
|
groups={k:[] for k in hue_bands}
|
||||||
|
for p in pixels_of(im):
|
||||||
|
h,s,v=colorsys.rgb_to_hsv(*(c/255 for c in p));h*=360
|
||||||
|
if s<.5 or v<.65:continue
|
||||||
|
for k,bands in hue_bands.items():
|
||||||
|
if any(lo<=h<hi for lo,hi in bands):groups[k].append(p)
|
||||||
|
for k,px in groups.items():
|
||||||
|
if len(px)<500:continue
|
||||||
|
rgb=tuple(round(median([p[i] for p in px])) for i in range(3))
|
||||||
|
cross.append(dict(second=second,family=k,pixels=len(px),hex=hexcolor(rgb)))
|
||||||
|
data=json.loads((ROOT/'samples.json').read_text());data['crossFrameHueGroups']=cross
|
||||||
|
(ROOT/'samples.json').write_text(json.dumps(data,ensure_ascii=False,indent=2)+'\n')
|
||||||
|
print('Cross-frame hue groups:',cross)
|
||||||
|
# A small source-derived palette illustration; no screenshot is used as game art.
|
||||||
|
from html import escape
|
||||||
|
svg=['<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="590" viewBox="0 0 1000 590">','<rect width="1000" height="590" rx="18" fill="#f6f7f9"/>','<text x="32" y="42" font-family="sans-serif" font-size="20" fill="#273b50">HEX VIDEO / DECODED PIXEL PALETTE</text>']
|
||||||
|
palette=[r for r in results if r['role'].startswith('tile.') and 'Side' not in r['role'] and r['role']!='tile.arrow']+[r for r in results if r['role'] in ['play.background','board.face','hud.surface','map.top','map.center','action.green','reward.button','modal.header','modal.scrimComposite']]
|
||||||
|
for i,r in enumerate(palette):
|
||||||
|
x=32+(i%4)*240;y=68+(i//4)*125
|
||||||
|
svg += [f'<rect x="{x}" y="{y}" width="216" height="68" rx="9" fill="{r["hex"]}" stroke="#273b5022"/>',f'<text x="{x}" y="{y+88}" font-family="monospace" font-size="13" fill="#273b50">{escape(r["role"])} {r["hex"]}</text>',f'<text x="{x}" y="{y+107}" font-family="monospace" font-size="11" fill="#667789">t={r["second"]}s / decoded ROI median</text>']
|
||||||
|
svg.append('</svg>');(ROOT/'palette.svg').write_text('\n'.join(svg))
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -8,6 +8,7 @@
|
|||||||
"test": "node --test",
|
"test": "node --test",
|
||||||
"build": "npm run design && node scripts/build.mjs",
|
"build": "npm run design && node scripts/build.mjs",
|
||||||
"levels": "node scripts/generate-levels.mjs",
|
"levels": "node scripts/generate-levels.mjs",
|
||||||
"design": "node scripts/build-design.mjs && node scripts/build-design-guide.mjs"
|
"design": "node scripts/build-design.mjs && node scripts/build-design-guide.mjs && npm run design:v2",
|
||||||
|
"design:v2": "node scripts/build-design-v2.mjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { resolve, extname, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
const root = fileURLToPath(new URL('../labs/hex-video/', import.meta.url));
|
||||||
|
const markdown = readFileSync(resolve(root, 'designv2.md'), 'utf8');
|
||||||
|
const samples = JSON.parse(readFileSync(resolve(root, 'reference/color-study/samples.json'), 'utf8'));
|
||||||
|
const esc = value => String(value).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const mime = {'.png':'image/png','.svg':'image/svg+xml','.json':'application/json','.py':'text/plain','.md':'text/plain'};
|
||||||
|
const assets = new Map();
|
||||||
|
function asset(path) {
|
||||||
|
const absolute = resolve(root, path);
|
||||||
|
if (!assets.has(absolute)) assets.set(absolute, `data:${mime[extname(path)] || 'application/octet-stream'};base64,${readFileSync(absolute).toString('base64')}`);
|
||||||
|
return assets.get(absolute);
|
||||||
|
}
|
||||||
|
function inline(text) {
|
||||||
|
const held = [];
|
||||||
|
const hold = html => `\u0000${held.push(html)-1}\u0000`;
|
||||||
|
text = text.replace(/`([^`]+)`/g, (_, value) => hold(`<code>${esc(value)}</code>`));
|
||||||
|
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, path) => hold(`<img class="palette" alt="${esc(alt)}" src="${asset(path)}">`));
|
||||||
|
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, path) => {
|
||||||
|
if (/^https?:\/\//.test(path)) return hold(`<a href="${esc(path)}" target="_blank" rel="noopener noreferrer">${esc(label)}</a>`);
|
||||||
|
const image = /\.(png|svg|jpg)$/.test(path);
|
||||||
|
return hold(`<a href="${asset(path)}" ${image ? 'data-image="true"' : `download="${esc(path.split('/').at(-1))}"`}>${esc(label)}</a>`);
|
||||||
|
});
|
||||||
|
return esc(text).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>').replace(/\u0000(\d+)\u0000/g, (_, n) => held[n]);
|
||||||
|
}
|
||||||
|
const lines = markdown.split('\n'), rendered = [], toc = [];
|
||||||
|
for (let i=0; i<lines.length;) {
|
||||||
|
const line=lines[i];
|
||||||
|
if (!line.trim()) { i++; continue; }
|
||||||
|
const h=line.match(/^(#{1,6})\s+(.+)$/);
|
||||||
|
if(h){const level=h[1].length,id=`section-${i}`;rendered.push(`<h${level} id="${id}">${inline(h[2])}</h${level}>`);if(level===2)toc.push(`<a href="#${id}">${esc(h[2])}</a>`);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();rendered.push(`<div class="table-wrap"><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+\. )/,''));const tag=ordered?'ol':'ul';rendered.push(`<${tag}>${items.map(s=>`<li>${inline(s)}</li>`).join('')}</${tag}>`);continue;}
|
||||||
|
const p=[line];i++;while(i<lines.length&&lines[i].trim()&&!/^(#|\||- |\d+\. )/.test(lines[i]))p.push(lines[i++]);rendered.push(`<p>${inline(p.join(' '))}</p>`);
|
||||||
|
}
|
||||||
|
const swatches=samples.samples.map(s=>`<button class="swatch" data-color="${s.hex}" data-search="${s.role} ${s.hex}" aria-label="复制 ${s.role} ${s.hex}"><span class="chip" style="background:${s.hex}"></span><b>${s.role}</b><code>${s.hex}</code><small>${s.second}s · ROI ${s.roi.join(', ')}</small></button>`).join('');
|
||||||
|
const seconds=[0,14,42,55,85,110,114,130,170,187];
|
||||||
|
const gallery=seconds.map(t=>`<button class="frame" data-frame="${t}"><img loading="lazy" src="${asset(`reference/color-study/${String(t).padStart(3,'0')}.png`)}" alt="视频参考帧 ${Math.floor(t/60)}:${String(t%60).padStart(2,'0')}"><span>${Math.floor(t/60)}:${String(t%60).padStart(2,'0')}</span></button>`).join('');
|
||||||
|
const contrast=samples.samples.filter(s=>/^tile\./.test(s.role)&&!s.role.includes('Side')&&s.role!=='tile.arrow').map(s=>`<tr><td>${s.role}</td><td><span class="arrow" style="background:${s.hex};color:white">↑</span>${s.whiteContrast}:1 ${s.whiteContrast<3?'· 需适配':''}</td><td><span class="arrow" style="background:${s.hex};color:#273B50">↑</span>${s.darkContrast}:1</td></tr>`).join('');
|
||||||
|
const html=`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HEX TRAILS · Design v2 视频用色手册</title><style>
|
||||||
|
:root{font-family:Inter,"PingFang SC","Microsoft YaHei",sans-serif;color:#273b50;background:#f4f6f9;line-height:1.8}*{box-sizing:border-box}body{margin:0}a{color:#1763ac;text-underline-offset:3px}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid #007ac5;outline-offset:3px}[hidden]{display:none!important}header{padding:48px max(24px,calc((100vw - 1240px)/2));background:linear-gradient(120deg,#0c61e8,#158fcc);color:white}header p{opacity:.9;margin:8px 0}header h1{font-size:34px;line-height:1.4;margin:12px 0}header a{color:white}.eyebrow{font-size:12px;letter-spacing:3px}main{display:grid;grid-template-columns:210px minmax(0,1fr);gap:40px;max-width:1290px;margin:36px auto;padding:0 24px}aside{position:sticky;top:24px;align-self:start}aside a{display:block;font-size:13px;text-decoration:none;padding:5px 8px;border-radius:6px}aside a:hover{background:#e3edf8}article{min-width:0}section,.spec{background:white;border:1px solid #e0e7ee;border-radius:18px;padding:28px;margin-bottom:28px}h2{font-size:24px;margin-top:8px}h3{font-size:19px;margin-top:32px}h4{font-size:16px}h1,h2,h3,h4{scroll-margin-top:24px}p,li{font-size:14px}code{font-family:ui-monospace,monospace;font-size:.9em;overflow-wrap:anywhere;background:#eff3f7;border-radius:4px;padding:2px 4px}.tools{display:flex;gap:12px;flex-wrap:wrap;align-items:center}.tools a,.tools button{border:1px solid #b9cce0;border-radius:8px;padding:7px 14px;text-decoration:none;background:white;color:#285578;font-size:13px}input{padding:10px 12px;width:100%;border:1px solid #bccbd8;border-radius:9px;margin-bottom:20px}.swatches{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.swatch{border:1px solid #dce3eb;border-radius:10px;padding:0 0 10px;overflow:hidden;text-align:left;background:white;color:#273b50;min-width:0}.chip{display:block;height:76px;border-bottom:1px solid #0001}.swatch b,.swatch code,.swatch small{display:block;margin:6px 10px 0;overflow-wrap:anywhere}.swatch b{font-size:11px}.swatch small{font-size:10px;color:#5e7182}.gallery{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}.frame{padding:0;border:1px solid #d9e2ed;border-radius:9px;overflow:hidden;background:#eef3f8;color:#334e64}.frame img{width:100%;display:block}.frame span{font-size:12px}.table-wrap{overflow:auto;margin:18px 0}table{border-collapse:collapse;width:100%;font-size:13px}td,th{padding:12px;text-align:left;border-bottom:1px solid #dfe6ee;vertical-align:top;min-width:90px}th{background:#edf3f8;color:#314d67}td code{white-space:normal}.arrow{display:inline-grid;place-items:center;width:44px;height:44px;clip-path:polygon(25% 0,75% 0,100% 50%,75% 100%,25% 100%,0 50%);font-size:27px;font-weight:bold;margin-right:12px;vertical-align:middle}.palette{width:100%;height:auto}.note{color:#617489;font-size:12px}.spec>h1{font-size:28px}.spec>h2{border-top:1px solid #dfe6ee;padding-top:28px;margin-top:40px}dialog{border:0;border-radius:14px;padding:14px;max-width:95vw;max-height:95vh;background:#f6f8fb}dialog::backdrop{background:#111c2ae0}dialog img{display:block;max-width:85vw;max-height:80vh;object-fit:contain;margin:auto}dialog button{display:block;margin:10px auto 0;border:0;border-radius:6px;background:#214f79;color:white;padding:6px 20px}#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#233f58;color:white;border-radius:9px;padding:10px 18px;z-index:9;font-size:13px}footer{text-align:center;padding:20px;color:#627487;font-size:12px}@media(max-width:900px){main{grid-template-columns:1fr}aside{position:static;display:flex;flex-wrap:wrap;gap:5px}.swatches{grid-template-columns:repeat(3,minmax(0,1fr))}.gallery{grid-template-columns:repeat(5,1fr)}}@media(max-width:520px){header{padding:30px 20px}header h1{font-size:26px}main{padding:0 12px;gap:16px}section,.spec{padding:18px}.swatches{grid-template-columns:repeat(2,minmax(0,1fr))}.gallery{grid-template-columns:repeat(3,1fr)}}@media print{aside,.tools,input,#toast,dialog{display:none!important}main{display:block;padding:0;margin:0}header{background:white;color:#273b50;padding:12px}section,.spec{border:0;padding:0}table{font-size:10px}tr{break-inside:avoid}*{print-color-adjust:exact}}
|
||||||
|
</style></head><body><header><div class="eyebrow">HEX TRAILS / VIDEO COLOR STUDY</div><h1>Design v2 · 视频用色手册</h1><p>七个棋子色族 · 三套场景节奏 · 单片薄倒角适配</p><p>10 张证据帧 / 36 个区域采样 / 完整规范 · 独立离线 HTML</p><div class="tools"><a download="designv2.md" href="${asset('designv2.md')}">下载 Markdown</a><a download="samples.json" href="${asset('reference/color-study/samples.json')}">下载采样 JSON</a><button id="print">打印 / 保存 PDF</button></div></header><main><aside><a href="#colors">采样色板</a><a href="#frames">视频证据帧</a><a href="#contrast">箭头对比</a>${toc.join('')}</aside><article><section id="colors"><h2>采样色板</h2><p class="note">点击复制色值。HEX 是解码帧区域中位数,不是原游戏材质常量;本页未改动 Lab 配色。</p><label for="search">搜索角色或 HEX</label><input id="search" type="search" placeholder="例如 tile、map、#4787ED"><div class="swatches">${swatches}</div><p id="empty" hidden>没有匹配的颜色。</p></section><section id="frames"><h2>视频证据帧</h2><p class="note">点击查看原尺寸参考图。图片已内嵌,断网也可查看。</p><div class="gallery">${gallery}</div></section><section id="contrast"><h2>白箭头与深色箭头</h2><p class="note">代表底色 + 不透明前景色的计算值。3:1 是项目必要图形的诊断目标,不代表整页已合规。</p><div class="table-wrap"><table><thead><tr><th>色族</th><th>白色 #FFFFFF</th><th>深色 #273B50</th></tr></thead><tbody>${contrast}</tbody></table></div></section><div class="spec">${rendered.join('\n')}</div></article></main><footer>由 designv2.md 与 samples.json 同步生成 · npm run design:v2</footer><dialog id="viewer" aria-label="视频原尺寸参考帧"><img id="viewer-image" alt="视频参考"><button id="close">关闭</button></dialog><div id="toast" role="status" hidden></div><script>
|
||||||
|
const toast=document.getElementById('toast');let timer;
|
||||||
|
function notify(message){toast.textContent=message;toast.hidden=false;clearTimeout(timer);timer=setTimeout(()=>toast.hidden=true,2300);}
|
||||||
|
document.querySelectorAll('[data-color]').forEach(button=>button.onclick=async()=>{const color=button.dataset.color;try{await navigator.clipboard.writeText(color);notify('已复制 '+color);}catch{notify('请手动复制 '+color);const range=document.createRange();range.selectNodeContents(button.querySelector('code'));const selection=getSelection();selection.removeAllRanges();selection.addRange(range);}});
|
||||||
|
document.getElementById('search').oninput=e=>{const query=e.target.value.trim().toLowerCase();let count=0;document.querySelectorAll('[data-search]').forEach(el=>{el.hidden=!el.dataset.search.toLowerCase().includes(query);if(!el.hidden)count++;});document.getElementById('empty').hidden=count>0;};
|
||||||
|
const viewer=document.getElementById('viewer'),picture=document.getElementById('viewer-image');function openImage(src,alt){picture.src=src;picture.alt=alt;viewer.showModal();}
|
||||||
|
document.querySelectorAll('[data-frame]').forEach(b=>b.onclick=()=>openImage(b.querySelector('img').src,b.querySelector('img').alt));document.querySelectorAll('[data-image]').forEach(a=>a.onclick=e=>{e.preventDefault();openImage(a.href,a.textContent);});document.getElementById('close').onclick=()=>viewer.close();viewer.onclick=e=>{if(e.target===viewer)viewer.close();};document.getElementById('print').onclick=()=>print();
|
||||||
|
</script></body></html>`;
|
||||||
|
writeFileSync(resolve(root,'designv2.html'),html);
|
||||||
|
console.log(`designv2.html: ${samples.samples.length} colors, ${seconds.length} frames, ${toc.length} chapters, standalone/offline, ${Math.round(Buffer.byteLength(html)/1024)} KiB.`);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||||
import { deflateSync } from 'node:zlib';
|
import { deflateSync } from 'node:zlib';
|
||||||
import { THEME, SURFACES, TYPOGRAPHY, MOTION, RADII, CONTRAST_PAIRS, contrast } from '../src/design-tokens.js';
|
import { THEME, SURFACES, TYPOGRAPHY, MOTION, RADII, CONTRAST_PAIRS, contrast, PIGMENTS, LEVEL_THEMES } from '../src/design-tokens.js';
|
||||||
// Render a newly authored flat color texture from tokens, with no editing of image artwork.
|
// 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);
|
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++){
|
for(let y=0;y<height;y++)for(let x=0;x<width;x++){
|
||||||
@@ -13,7 +13,8 @@ const ihdr=Buffer.alloc(13);ihdr.writeUInt32BE(width);ihdr.writeUInt32BE(height,
|
|||||||
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))]));
|
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());
|
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.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');
|
writeFileSync(new URL('../design/tokens.json',import.meta.url),JSON.stringify({theme:THEME,pigments:PIGMENTS,levelThemes:LEVEL_THEMES,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}));
|
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}));
|
||||||
|
report.push(...LEVEL_THEMES.map(t=>({label:`${t.name} / 场景文字`,foreground:t.ink,background:t.background,ratio:Number(contrast(t.ink,t.background).toFixed(3)),minimum:4.5,pass:contrast(t.ink,t.background)>=4.5})),...PIGMENTS.map(p=>({label:`${p.name} / 箭头`,foreground:p.arrow,background:p.color,ratio:Number(contrast(p.arrow,p.color).toFixed(3)),minimum:3,pass:contrast(p.arrow,p.color)>=3})));
|
||||||
writeFileSync(new URL('../design/contrast-report.json',import.meta.url),JSON.stringify(report,null,2)+'\n');
|
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.`);
|
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.`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cp, mkdir, rm } from 'node:fs/promises';
|
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||||
const out=new URL('../dist/',import.meta.url);
|
const out=new URL('../dist/',import.meta.url);
|
||||||
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
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});
|
for(const name of ['index.html','design-showcase.html','style.css','labs','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/');
|
console.log('Static build ready in dist/');
|
||||||
|
|||||||
@@ -1,21 +1,73 @@
|
|||||||
import { generateLevel, analyze, rotateBoard, solveRotations } from '../src/core.js';
|
|
||||||
import { writeFile } from 'node:fs/promises';
|
import { writeFile } from 'node:fs/promises';
|
||||||
const names=['转一转,路就通了','换个方向','弯道别碰头','给我让条路','粉色回旋','蓝色小径','向左看齐','第一圈冒险','旋转甜甜圈','差一点擦肩','紫色回廊','再转一次','绕开拥挤','转角遇见光','别挤在一起','糖果交响曲','盘旋之间','左右逢源','小心翼翼','寻找缝隙','回转大挑战','紧密相连','终极转盘','六角大满贯'];
|
import { generateFlowLevel, generateLaneLevel, inspectFlowLevel, CAMPAIGN_PLAN } from '../src/campaign-generator.js';
|
||||||
const candidates=[];
|
import { validateLevel } from '../src/core.js';
|
||||||
for(let i=0;i<100;i++){
|
|
||||||
const radius=i<25?2:3,target=i<25?5+i%10:16+i%18;
|
const beats=['进入','换路','展开','小挑战','释放','长局回报'];
|
||||||
const level=rotateBoard(generateLevel(2+i*73,radius,target,.15+(i%10)/12),1+i%3);
|
const intents=[
|
||||||
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});
|
];
|
||||||
|
const names=[
|
||||||
|
['一眼看见出口','转一下,就通了','顺着线出发','解开小结','一片畅通','轻松清空大盘'],
|
||||||
|
['熟悉的方向','换条短路','接连找到出口','小小转机','一路放行','铺开好心情'],
|
||||||
|
['找到节奏','转出一条新路','继续向外走','先解哪一枚','宽阔的回报','长路也轻巧'],
|
||||||
|
['慢慢看,都来得及','只换一个方向','连成顺畅的路','轻巧绕一个弯','松一口气','从容的大舞台'],
|
||||||
|
['又是新的出口','看见新的可能','顺路解开','留意这个转角','满盘好走','长长的成就感'],
|
||||||
|
['熟练地出发','一点小变化','接住这个节奏','两条路的相遇','把出口打开','顺畅走到最后'],
|
||||||
|
['从容观察','换个角度看','一枚接着一枚','给路线松个结','轻轻一扫','大盘小心思'],
|
||||||
|
['自在出发','最后一个新转角','熟悉的长路','小结轻解','收获一片畅通','自在回响'],
|
||||||
|
];
|
||||||
|
// Selected deterministic candidates preserve pacing; never difficulty-sort.
|
||||||
|
const turnSeeds=[324,541,45,2034,4684,7089,7104,7672];
|
||||||
|
const challengeSeeds=[104,1161,3171,3422,3980,2908,1017,6269];
|
||||||
|
const turnCandidate=seed=>generateFlowLevel(seed,{radius:2,target:4+seed%9,bendRate:.25+(seed%7)*.1,startRotation:1+seed%5,maxTurns:5});
|
||||||
|
|
||||||
|
function pickLanes(chapter,target,salt){
|
||||||
|
const radius=target>20?4:3;
|
||||||
|
for(let attempt=0;attempt<100;attempt++){
|
||||||
|
const l=generateLaneLevel(42000+chapter*199+salt*13+attempt,{radius,target}),q=inspectFlowLevel(l);
|
||||||
|
if(l.tiles.length===target&&q.analysis.score<=27&&q.analysis.free>=Math.min(8,Math.ceil(target*.3))&&q.hiddenBlockerStates===0)return l;
|
||||||
|
}
|
||||||
|
throw new Error(`No clear lane level for chapter ${chapter+1}, target ${target}`);
|
||||||
|
}
|
||||||
|
function pickFlow(chapter){
|
||||||
|
const [seed,target,bendRate]=[[100777,14,.10],[107993,16,.14],[115912,16,.14],[123868,18,.18],[131787,18,.18],[140076,20,.10],[140010,20,.15],[150011,20,.18]][chapter];
|
||||||
|
const l=generateFlowLevel(seed,{radius:4,target,bendRate,maxTurns:3,clearBlockers:true}),q=inspectFlowLevel(l),a=q.analysis;
|
||||||
|
if(l.tiles.length!==target||!a.solvable||a.score>30||a.freeRatio<.3||a.meanTurns<=0||a.meanTurns>1||q.hiddenBlockerStates||q.lowChoiceStates)throw new Error(`Flowing level no longer meets its pacing gate: chapter ${chapter+1}`);
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levels=[],report=[];
|
||||||
|
for(let chapter=0;chapter<CAMPAIGN_PLAN.chapters.length;chapter++){
|
||||||
|
const block=[
|
||||||
|
chapter===0?generateFlowLevel(3,{radius:3,target:12,bendRate:0,allFree:true}):pickLanes(chapter,12+chapter*2,0),
|
||||||
|
turnCandidate(turnSeeds[chapter]),
|
||||||
|
pickFlow(chapter),
|
||||||
|
turnCandidate(challengeSeeds[chapter]),
|
||||||
|
pickLanes(chapter,[16,18,22,24,26,28,28,30][chapter],4),
|
||||||
|
pickLanes(chapter,[18,20,24,26,28,30,32,33][chapter],5),
|
||||||
|
];
|
||||||
|
for(let beat=0;beat<block.length;beat++){
|
||||||
|
const index=levels.length,level={...block[beat],id:`observe-v3-${String(index+1).padStart(2,'0')}`,name:names[chapter][beat],chapter:chapter+1,chapterName:CAMPAIGN_PLAN.chapters[chapter],beat:beats[beat],designIntent:intents[beat]};
|
||||||
|
const q=inspectFlowLevel(level),a=q.analysis,errors=validateLevel(level);
|
||||||
|
if(errors.length||!a.solvable||a.exhausted||!q.won||q.independentLoops||q.hiddenBlockerStates||q.maxDryRotations>1||q.maxUnlockDistance>1)throw new Error(`Campaign gate failed: ${level.name} ${JSON.stringify({errors,q})}`);
|
||||||
|
if(a.free<2)throw new Error(`Not enough opening choices: ${level.name}`);
|
||||||
|
level.requiresRotation=a.rotations>0;
|
||||||
|
level.pacing={cognitiveDifficulty:a.score,removals:level.tiles.length,estimatedSeconds:a.workload.estimatedSeconds,estimateKind:'structural-heuristic-not-playtest',maxProgressGapRotations:q.maxDryRotations};
|
||||||
|
levels.push(level);
|
||||||
|
report.push({level:index+1,name:level.name,chapter:chapter+1,beat:beats[beat],cells:level.cells.length,tiles:level.tiles.length,difficulty:a.score,free:a.free,freeRatio:Number(a.freeRatio.toFixed(3)),meanTurns:Number(a.meanTurns.toFixed(3)),maxTurns:a.maxTurns,dependencyRatio:Number(a.dependencyRatio.toFixed(3)),necessaryRotations:a.rotations,estimatedSeconds:a.workload.estimatedSeconds,independentLoops:q.independentLoops,solutionHiddenBlockers:q.hiddenBlockerStates,maxProgressGapRotations:q.maxDryRotations,lowChoiceStates:q.lowChoiceStates,actions:a.actions,openingAndSolutionStates:q.samples});
|
||||||
|
}
|
||||||
|
const rows=report.slice(chapter*6,chapter*6+6);
|
||||||
|
if(rows[4].difficulty>rows[3].difficulty-12||rows[5].difficulty>rows[3].difficulty-10||rows[5].tiles<rows[3].tiles||rows[5].estimatedSeconds<=rows[3].estimatedSeconds)throw new Error(`Missing challenge/release rhythm in chapter ${chapter+1}`);
|
||||||
|
console.log(`${chapter+1}. ${CAMPAIGN_PLAN.chapters[chapter]}: difficulty ${rows.map(r=>r.difficulty).join(' → ')}; tiles ${rows.map(r=>r.tiles).join('/')}`);
|
||||||
}
|
}
|
||||||
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('../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');
|
await writeFile(new URL('../src/levels.js',import.meta.url),'// Generated by npm run levels. Free observation, spiral pacing; do not difficulty-sort.\nexport const CAMPAIGN = '+JSON.stringify(CAMPAIGN_PLAN)+';\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'));
|
await writeFile(new URL('../levels/campaign-analysis.json',import.meta.url),JSON.stringify({campaign:CAMPAIGN_PLAN,estimateNote:'Time values are structural heuristics, not measured player completion times.',levels:report},null,2)+'\n');
|
||||||
|
const md='# 主线关卡验收与节奏表\n\n自动生成:`npm run levels`。共 48 关,8 章,每章顺序为进入、换路、展开、小挑战、释放、长局回报。认知难度与棋盘规模分别记录。\n\n全部关卡:可解且回放通关;每枚棋子在全部六朝向独立路线无闭环;参考解答无路线格外擦碰阻挡;相邻消除之间最多连续旋转一次。参考解答检查不等于穷尽玩家的任意旋转顺序,实际阻挡对象会在游戏中标记。\n\n**预计时长只用于内部比较,没有真人测试数据。** 公式为 3 × 棋子数 + 0.75 × 初始独立路线总转弯 + 4 × 最少旋转。所有时间均为估计秒数,不能当作通关时限。\n\n| 关 | 名称 | 节奏 | 格数 | 棋子 | 认知难度 | 开局可解 | 必要旋转 | 预计秒数 |\n|---|---|---|---:|---:|---:|---:|---:|---:|\n'+report.map(r=>`| ${r.level} | ${r.name} | ${r.beat} | ${r.cells} | ${r.tiles} | ${r.difficulty} | ${r.free} | ${r.necessaryRotations} | ${r.estimatedSeconds} |`).join('\n')+'\n';
|
||||||
|
await writeFile(new URL('../docs/campaign-review.md',import.meta.url),md);
|
||||||
|
console.log('48 levels pass all publishing gates. Campaign, shared module and pacing report synchronized.');
|
||||||
|
|||||||
@@ -1,75 +1,100 @@
|
|||||||
|
import { attachBoardViewport } from './board-viewport.js';
|
||||||
|
import { TapQueue } from './tap-queue.js';
|
||||||
|
import { exitTiming } from './exit-animation.js';
|
||||||
|
import { PIGMENTS, LEVEL_THEMES, themeForScore, MOTION, themeWithColors } from './design-tokens.js';
|
||||||
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
|
import { unlockAudio, playSound, toggleSound, soundEnabled } from './audio.js';
|
||||||
import { icon, installUI } from './ui.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 { Game, analyze, available, clone, generateLevel, hexagon, key, random, randomTracks, rotateTracks, rotateBoard, canRotate, solveRotations, trace, validateLevel } from './core.js';
|
||||||
import { LEVELS } from './levels.js';
|
import { CAMPAIGN, LEVELS } from './levels.js';
|
||||||
import { layout, hitCell } from './geometry.js';
|
import { layout, hitCell } from './geometry.js';
|
||||||
import { TILE_ATLAS } from './sprites.js';
|
import { TILE_ATLAS } from './sprites.js';
|
||||||
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
|
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
|
||||||
|
const taps=new TapQueue();
|
||||||
const $=id=>document.getElementById(id);
|
const $=id=>document.getElementById(id);
|
||||||
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
|
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
|
||||||
const STORE='hex-trails-v2';
|
let boardViewport;
|
||||||
|
const STORE=`hex-trails-${CAMPAIGN.id}`;
|
||||||
let saved={};try{saved=JSON.parse(localStorage.getItem(STORE)||'{}')||{};}catch{}
|
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 completed=new Set(Array.isArray(saved.completed)?saved.completed.filter(i=>Number.isInteger(i)&&i>=0&&i<LEVELS.length):[]);
|
||||||
let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<24?saved.current:0;
|
let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<LEVELS.length?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 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 helpUsed=false;
|
||||||
|
let activeTheme=null;
|
||||||
|
const requestedTheme=LEVEL_THEMES.find(t=>t.id===new URLSearchParams(location.search).get('theme'));
|
||||||
|
function applyTheme(score){
|
||||||
|
activeTheme=mode==='edit'||trial?null:themeWithColors(requestedTheme||themeForScore(score),Number(new URLSearchParams(location.search).get('colors'))||7);
|
||||||
|
const theme=activeTheme||LEVEL_THEMES[0];
|
||||||
|
document.body.style.setProperty('--color-background',theme.background);
|
||||||
|
document.body.style.setProperty('--scene-ink',theme.ink);
|
||||||
|
$('theme-caption').textContent=activeTheme?`${requestedTheme?'配色预览':theme.label} · ${theme.name}`:'自由配色 · 12 色棋子库';
|
||||||
|
}
|
||||||
let hitSoundTimer;
|
let hitSoundTimer;
|
||||||
document.addEventListener('pointerdown',unlockAudio,{passive:true});
|
document.addEventListener('pointerdown',unlockAudio,{passive:true});
|
||||||
document.addEventListener('click',e=>{if(e.target.closest('button'))playSound('tap');});
|
document.addEventListener('click',e=>{if(e.target.closest('button'))playSound('tap');});
|
||||||
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
const metricLabels={scale:['棋子规模',15],turns:['弯道追踪',20],depth:['解锁层数',20],blocked:['路线阻塞',15],rotation:['必要旋转',20],precision:['步数压力',10]};
|
const metricLabels={bends:['平均弯道',22],longestRoute:['最多弯道',12],dependencies:['解锁依赖',18],blocked:['初始阻挡',28],sweptBlockers:['侧面阻挡',12],rotation:['必要旋转',8]};
|
||||||
const chapters=['转盘初体验','糖果回旋','碰撞大挑战'];
|
const chapters=CAMPAIGN.chapters,chapterSize=CAMPAIGN.chapterSize;
|
||||||
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
function persist(){try{localStorage.setItem(STORE,JSON.stringify({current,completed:[...completed]}));}catch{toast('浏览器未允许保存,进度仅保留在当前页面');}}
|
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 bars(a){return Object.entries(metricLabels).filter(([k])=>Number.isFinite(a.parts[k])).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(){
|
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('');
|
$('level-list').innerHTML=chapters.map((ch,c)=>`<div class="chapter-title">${String(c+1).padStart(2,'0')} / ${escapeHtml(ch)}</div><div class="level-grid">${LEVELS.slice(c*chapterSize,(c+1)*chapterSize).map((l,j)=>{const i=c*chapterSize+j;return `<button class="level-btn ${i===current?'current':''} ${completed.has(i)?'done':''}" data-level="${i}" aria-label="第 ${i+1} 关 ${escapeHtml(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}%`;
|
$('progress-label').textContent=`${completed.size} / ${LEVELS.length}`;$('campaign-progress').style.width=`${completed.size/LEVELS.length*100}%`;
|
||||||
|
}
|
||||||
|
function rotationHint(direction){
|
||||||
|
const turn=direction===1?'右':'左',exits=available(rotateBoard(game.level,direction),game.tiles).length;
|
||||||
|
return exits?`免费${turn}转 60°,${exits} 枚棋子的出口就打开了`:`先免费${turn}转 60°,再继续观察路线`;
|
||||||
}
|
}
|
||||||
function renderStats(){
|
function renderStats(){
|
||||||
const editing=mode==='edit',level=editing?draft:game.initialLevel,a=analyze(level);
|
const editing=mode==='edit',level=editing?draft:game.initialLevel,a=analyze(level);
|
||||||
|
applyTheme(a.score);
|
||||||
$('level-name').textContent=level.name;$('level-eyebrow').textContent=editing?'YOUR CREATION':trial?'PLAYTEST':`LEVEL ${String(current+1).padStart(2,'0')}`;
|
$('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)]}`;
|
$('breadcrumb').textContent=editing?'关卡工坊 / 自由创作':trial?'关卡工坊 / 试玩中':`第 ${Math.floor(current/chapterSize)+1} 章 / ${level.chapterName||chapters[Math.floor(current/chapterSize)]}`;
|
||||||
$('moves-title').textContent=editing?'步数上限':'剩余步数';$('remaining-title').textContent=editing?'已放置':'待离场';
|
$('moves-title').textContent=editing?'离场额度':trial?'可用额度':'待解棋子';$('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>`;
|
$('moves').textContent=editing?level.moveLimit:trial?game.moves:game.tiles.length;$('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 ↗';
|
$('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)} 次转弯`;
|
$('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=`观察难度 ${a.score}/100 · ${level.tiles.length} 枚棋子 · 约 ${a.workload.estimatedSeconds} 秒体验(估算,无计时限制)`;
|
||||||
$('undo-button').disabled=game.history.length===0||!!animation||!!rotationAnimation;
|
$('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>`;
|
$('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);
|
for(const id of ['rotate-left','rotate-right'])$(id).disabled=!!animation||!!rotationAnimation||game.won||game.lost||!canRotate(game.level)||game.level.rotationEnabled===false;
|
||||||
$('hint-button').disabled=!!animation||!!rotationAnimation||game.won;
|
$('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?'点击箭头离场 · 拖动边缘或按按钮转动底盘':'暂时过不去?转一下底盘,换条路线!')}`;
|
$('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){
|
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 solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
|
||||||
const action=solution.actions[0];
|
const action=solution.actions[0];
|
||||||
if(solution.solvable&&action?.type==='rotate'){
|
if(solution.solvable&&action?.type==='rotate'){
|
||||||
const right=action.direction===1;
|
const right=action.direction===1;
|
||||||
$(right?'rotate-right':'rotate-left').classList.add('hinted');
|
$(right?'rotate-right':'rotate-left').classList.add('hinted');
|
||||||
$('instruction').textContent=`这条路绕回了起点 · 点${right?'右':'左'}转 60°,再点击棋子`;
|
$('instruction').textContent=rotationHint(action.direction);
|
||||||
}else if(solution.reachable){$('instruction').textContent='步数不够了 · 撤销碰撞或重来,再按提示寻找出口';}
|
}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;}
|
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.score}/100 · ${a.free} 枚可直接离场 · 最少 ${a.rotations} 次旋转。工作量 ${a.minimumActions??'—'} 次操作,参考 ${a.workload.estimatedSeconds} 秒(估算)。仅成功离场使用 1 额度,旋转与试探免费${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 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);const base=layout((mode==='edit'?draft:game.level).cells,width,height);view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();draw();}
|
||||||
function draw(now=performance.now()){
|
function draw(now=performance.now()){
|
||||||
if(!view)return;
|
if(!view)return;
|
||||||
ctx.clearRect(0,0,width,height);
|
ctx.clearRect(0,0,width,height);
|
||||||
const state={selected:mode==='edit'?selected:null,path,hint,reduced};
|
const state={selected:mode==='edit'?selected:null,path,hint,reduced,theme:activeTheme,queued:taps.ids};
|
||||||
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(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};if(progress>=1){animation=null;renderStats();}}
|
||||||
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(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();}}
|
||||||
if(shake){const t=(now-shake.start)/550;if(t>=1)shake=null;else{state.shake=shake.id;state.shakeTime=t;}}
|
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);
|
drawScene(painter,mode==='edit'?draft:game.level,mode==='edit'?draft.tiles:game.tiles,view,state);
|
||||||
|
if(!animation&&!rotationAnimation){
|
||||||
|
const next=taps.take(game.tiles);
|
||||||
|
if(next&&mode==='play'&&!$('info-dialog').open){playTile(next);return;}
|
||||||
|
finish();
|
||||||
|
}
|
||||||
if(animation||rotationAnimation||shake){cancelAnimationFrame(frame);frame=requestAnimationFrame(draw);}
|
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 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 resetEffects(preserveTaps=false){if(!preserveTaps)taps.clear();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 startLevel(i){boardViewport?.camera.reset();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 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(){
|
function enterEditor(){
|
||||||
if(!draft){draft=clone(game.level);draft.id='custom';draft.name='我的第一条轨迹';editHistory=[];}
|
if(!draft){draft=clone(game.level);draft.id='custom';draft.name='我的第一条轨迹';editHistory=[];}
|
||||||
mode='edit';trial=false;resetEffects();applyMode();syncEditor();renderStats();resize();
|
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 syncEditor(){$('editor-name').value=draft.name;$('editor-moves').value=draft.moveLimit;$('editor-radius').value=String(Math.max(...draft.cells.map(c=>Math.max(Math.abs(c.q),Math.abs(c.r),Math.abs(c.q+c.r)))));}
|
||||||
function checkpoint(){editHistory.push(clone(draft));if(editHistory.length>100)editHistory.shift();}
|
function checkpoint(){editHistory.push(clone(draft));if(editHistory.length>100)editHistory.shift();}
|
||||||
function changed(){resetEffects();renderStats();resize();}
|
function changed(){resetEffects();renderStats();resize();}
|
||||||
function openResult(){ $('result').hidden=false;$('result').showModal();playSound(game.won?'win':'lose');}
|
function openResult(){ $('result').hidden=false;$('result').showModal();playSound(game.won?'win':'lose');}
|
||||||
@@ -79,19 +104,31 @@ function finish(){
|
|||||||
if(mode==='edit'||animation||rotationAnimation)return;
|
if(mode==='edit'||animation||rotationAnimation)return;
|
||||||
if(game.won){
|
if(game.won){
|
||||||
if(!trial){completed.add(current);persist();renderMap();}
|
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>`;
|
$('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} 枚棋子全部找到出口<br>一条接一条,你清空了整片棋盘。${helpUsed?'<br>在提示的陪伴下完成':''}</p><button class="primary" id="next-level">${trial?'返回关卡工坊':current===LEVELS.length-1?'完成旅程 · 回到第一关':'下一段轨迹'} <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;
|
openResult();$('next-level').onclick=()=>trial?enterEditor():startLevel((current+1)%LEVELS.length);$('replay-level').onclick=restart;
|
||||||
}else if(game.lost){
|
}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;
|
$('result').innerHTML=`<div class="result-emblem">${icon('track')}</div><h3>${game.moves<=0?'这份关卡的离场额度不足':'轨迹暂时打了个结'}</h3><p>${game.moves<=0?'仍有棋子等待离场,可以返回工坊增加额度。旋转和试探都没有扣除额度。':'剩余棋子的路线被阻挡,或轨道形成了闭环。'}</p><button class="primary" id="recover">${trial?'返回编辑关卡':game.history.length?'撤销上一步':'重新开始'}</button><button class="text-button" id="retry">重新开始</button>`;openResult();$('recover').onclick=()=>trial?enterEditor():game.history.length?undo():restart();$('retry').onclick=restart;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function restart(){game=new Game(game.initialLevel);helpUsed=false;resetEffects();renderStats();draw();finish();}
|
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 undo(){if(animation||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
|
||||||
function playTile(tile){
|
function playTile(tile){
|
||||||
if(animation||rotationAnimation)return;
|
if(animation||rotationAnimation){
|
||||||
resetEffects();const route=game.tap(tile.id);
|
if(taps.add(tile.id,animation?.route.tile.id)){draw();}
|
||||||
if(route.status==='exit'){playSound('exit');animation={route,start:performance.now(),duration:reduced?100:Math.min(1700,550+route.steps.length*85)};}
|
return;
|
||||||
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 步');}
|
}
|
||||||
|
resetEffects(true);const before=new Set(available(game.level,game.tiles).map(t=>t.id)),route=game.tap(tile.id);
|
||||||
|
if(route.status==='exit'){
|
||||||
|
playSound('exit');animation={route,start:performance.now(),duration:reduced?100:exitTiming(route).duration};
|
||||||
|
const opened=available(game.level,game.tiles).filter(t=>!before.has(t.id));
|
||||||
|
if(opened.length){hint=opened[0].id;toast(`移开这一枚,${opened.length} 枚棋子的路通了!`);}
|
||||||
|
}
|
||||||
|
else if(route.status==='blocked'||route.status==='loop'){
|
||||||
|
taps.clear();path=route;if(route.status==='blocked')hint=route.blocker;
|
||||||
|
if(route.travelPoints){animation={route,start:performance.now(),duration:reduced?150:MOTION.collisionMs};hitSoundTimer=setTimeout(()=>playSound('hit'),(reduced?150:MOTION.collisionMs)*.42);}else shake={id:tile.id,start:performance.now()};
|
||||||
|
const blocker=game.tiles.find(t=>t.id===route.blocker),blockerCanExit=blocker&&trace(game.level,blocker,game.tiles).status==='exit';
|
||||||
|
toast(route.status==='loop'?'这条路线形成闭环 · 试探免费,可以转动底盘换条路':blockerCanExit?'标记的棋子挡住了路线 · 可以先移走它':'标记的棋子也在等出口 · 试探免费,可以转盘换条路');
|
||||||
|
}
|
||||||
renderStats();draw();if(!animation)finish();
|
renderStats();draw();if(!animation)finish();
|
||||||
}
|
}
|
||||||
const matchings=[];
|
const matchings=[];
|
||||||
@@ -113,32 +150,25 @@ function editCell(cell){
|
|||||||
}
|
}
|
||||||
function rotateGame(direction) {
|
function rotateGame(direction) {
|
||||||
if(animation||rotationAnimation||mode==='edit')return;
|
if(animation||rotationAnimation||mode==='edit')return;
|
||||||
resetEffects();const result=game.rotate(direction);
|
resetEffects();const before=new Set(available(game.level,game.tiles).map(t=>t.id)),result=game.rotate(direction);
|
||||||
if(result.status!=='rotated'){toast(result.status==='rotation-disabled'?'这个底板不支持整盘旋转':'当前无法旋转');return;}
|
if(result.status!=='rotated'){toast(result.status==='rotation-disabled'?'这个底板不支持整盘旋转':'当前无法旋转');return;}
|
||||||
playSound('rotate');
|
playSound('rotate');
|
||||||
rotationAnimation={direction,start:performance.now(),duration:reduced?100:420};
|
const after=available(game.level,game.tiles),opened=after.filter(t=>!before.has(t.id));
|
||||||
|
if(opened.length){hint=opened[0].id;toast(`转一转,${opened.length} 枚棋子的出口打开了!`);}
|
||||||
|
else if(!after.length)toast('路线换好了 · 可以继续免费旋转,慢慢观察');
|
||||||
|
rotationAnimation={direction,start:performance.now(),duration:reduced?100:MOTION.rotationMs};
|
||||||
renderStats();draw();
|
renderStats();draw();
|
||||||
}
|
}
|
||||||
$('rotate-left').onclick=()=>rotateGame(-1);$('rotate-right').onclick=()=>rotateGame(1);
|
$('rotate-left').onclick=()=>rotateGame(-1);$('rotate-right').onclick=()=>rotateGame(1);
|
||||||
let pointerStart=null;
|
boardViewport=attachBoardViewport(canvas,{changed:resize,tap:({x,y})=>{
|
||||||
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);});
|
if(!view)return;const cell=hitCell((mode==='edit'?draft:game.level).cells,view,x,y);
|
||||||
canvas.addEventListener('pointercancel',()=>{pointerStart=null;});
|
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);}
|
||||||
canvas.addEventListener('pointerup',e=>{
|
},drag:(start,end)=>{
|
||||||
if(animation||rotationAnimation||!view){pointerStart=null;return;}
|
if(mode==='edit'||!view||Math.hypot(start.x-view.ox,start.y-view.oy)<=width*.28)return;
|
||||||
const rect=canvas.getBoundingClientRect(),x=e.clientX-rect.left,y=e.clientY-rect.top;
|
let delta=Math.atan2(end.y-view.oy,end.x-view.ox)-Math.atan2(start.y-view.oy,start.x-view.ox);
|
||||||
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(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);
|
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));};
|
$('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;
|
$('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;
|
$('undo-button').onclick=undo;$('reset-button').onclick=restart;
|
||||||
@@ -147,16 +177,16 @@ $('hint-button').onclick=()=>{
|
|||||||
resetEffects();helpUsed=true;
|
resetEffects();helpUsed=true;
|
||||||
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
|
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
|
||||||
if(!solution.reachable){toast(solution.exhausted?'路线较复杂,暂未找到提示':'当前关卡无法避开所有碰撞,请撤销或重新编辑');return;}
|
if(!solution.reachable){toast(solution.exhausted?'路线较复杂,暂未找到提示':'当前关卡无法避开所有碰撞,请撤销或重新编辑');return;}
|
||||||
if(!solution.solvable){toast(`至少还需要 ${solution.minimumMoves} 步,建议撤销恢复步数`);return;}
|
if(!solution.solvable){toast(`还有 ${solution.minimumMoves} 枚棋子,需要在工坊增加离场额度`);return;}
|
||||||
const action=solution.actions[0];if(!action)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°,新的路线就打开了`);}
|
if(action.type==='rotate'){$(action.direction===1?'rotate-right':'rotate-left').classList.add('hinted');toast(rotationHint(action.direction));}
|
||||||
else{const tile=game.tiles.find(t=>t.id===action.id);hint=tile.id;path=trace(game.level,tile,game.tiles);toast('这枚棋子沿途不会碰撞,可以离场');}
|
else{const tile=game.tiles.find(t=>t.id===action.id);hint=tile.id;path=trace(game.level,tile,game.tiles);toast('这枚棋子沿途不会碰撞,可以离场');}
|
||||||
draw();
|
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();};
|
$('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').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));};
|
$('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').innerHTML=COLORS.map((c,i)=>`<button style="background:${c}" data-color="${i}" aria-label="${PIGMENTS[i].name}" 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));};
|
$('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-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-moves').onchange=()=>{checkpoint();draft.moveLimit=Math.max(1,Math.min(999,Math.round(Number($('editor-moves').value)||1)));syncEditor();changed();};
|
||||||
@@ -174,15 +204,15 @@ function loadDraft(data){const errors=validateLevel(data);if(errors.length)throw
|
|||||||
$('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 关卡');};
|
$('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-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='';};
|
$('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();}
|
function showDialog(html){taps.clear();$('dialog-content').innerHTML=html;$('info-dialog').showModal();}
|
||||||
$('info-dialog').querySelector('.dialog-close').onclick=()=>$('info-dialog').close();
|
$('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();}};
|
$('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>`);}
|
function showScore(){const level=mode==='edit'?draft:game.initialLevel,a=analyze(level);showDialog(`<div class="eyebrow">OBSERVE & ENJOY</div><h2>观察难度与游玩节奏</h2><p><b>${a.score}/100 · ${a.label}</b>,反映读懂路线和解开依赖所需的观察。棋盘大小、棋子总数和旋转额度都不加难度分。</p>${bars(a)}<p>初始有 ${a.free} 枚棋子可以直接离场。工作量为 ${level.tiles.length} 次成功离场,加上最少 ${a.rotations} 次免费旋转;共 ${a.minimumActions??'—'} 次操作,参考时长约 ${a.workload.estimatedSeconds} 秒。大棋盘可以简单而充实,小棋盘也可以更需要观察。</p><ul><li>平均弯道 22 分:平均转弯数 ÷ 4;最多弯道 12 分:单条路线最多转弯数 ÷ 8。</li><li>解锁依赖 18 分:解锁波次按棋子数量归一化。</li><li>初始阻挡比例 28 分;仅由侧面擦碰形成的阻挡比例 12 分。</li><li>必要旋转 8 分:最少旋转数 ÷ 3。各项分别封顶。</li></ul><p class="dialog-note">分数和时长都是设计估计,尚未经真人试玩校准。参考时长不是倒计时。${a.exhausted?'搜索尚未确定这份关卡能否完成。':!a.reachable?'这份关卡存在无法解开的路线,需要调整。':!a.solvable?'这份关卡的成功离场额度不足,需要在工坊增加。':'所有路线已验证可解。'}</p>`);}
|
||||||
$('score-help').onclick=showScore;$('difficulty-button').onclick=showScore;
|
$('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>');}
|
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()?'开':'关'}`;}});
|
$('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;
|
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));}};};
|
$('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}" aria-label="第 ${i+1} 关 ${escapeHtml(l.name)},${escapeHtml(l.chapterName||chapters[Math.floor(i/chapterSize)])}${completed.has(i)?',已完成':''}">${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).label}</small></button>`).join('')}</div><p>${LEVELS.length} 个轨道谜题 · ${chapters.length} 章舒展与挑战交替的旅程 · 每关已验证可解。</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;
|
const atlas=new Image();atlas.onload=()=>{painter.atlas=atlas;draw();};atlas.onerror=()=>toast('棋子图片加载失败,已切换基础绘制');atlas.src=TILE_ATLAS;
|
||||||
new ResizeObserver(resize).observe(canvas);
|
new ResizeObserver(resize).observe(canvas);
|
||||||
applyMode();renderMap();renderStats();resize();
|
applyMode();renderMap();renderStats();resize();
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export class BoardViewport {
|
||||||
|
constructor(){this.reset();}
|
||||||
|
reset(){this.scale=1;this.x=0;this.y=0;}
|
||||||
|
fit(view,width,height){
|
||||||
|
this.bounds=view.bounds;
|
||||||
|
this.maxScale=this.bounds?Math.max(1,Math.min(4,height/(this.bounds.bottom-this.bounds.top))):4;
|
||||||
|
this.zoom(1,width/2,height/2,width,height);
|
||||||
|
return this.apply(view);
|
||||||
|
}
|
||||||
|
apply(view){return {...view,size:view.size*this.scale,ox:view.ox*this.scale+this.x,oy:view.oy*this.scale+this.y};}
|
||||||
|
constrain(width,height){this.x=Math.max(width*(1-this.scale),Math.min(0,this.x));this.y=this.bounds?Math.max(-this.bounds.top*this.scale,Math.min(height-this.bounds.bottom*this.scale,this.y)):Math.max(height*(1-this.scale),Math.min(0,this.y));}
|
||||||
|
zoom(factor,x,y,width,height){const next=Math.max(1,Math.min(this.maxScale??4,this.scale*factor)),ratio=next/this.scale;this.x=x-(x-this.x)*ratio;this.y=y-(y-this.y)*ratio;this.scale=next;this.constrain(width,height);}
|
||||||
|
pan(x,y,width,height){this.x+=x;this.y+=y;this.constrain(width,height);}
|
||||||
|
}
|
||||||
|
export function attachBoardViewport(canvas,{changed,tap,drag}){
|
||||||
|
const camera=new BoardViewport(),pointers=new Map();let gesture=false,start=null,last=null;
|
||||||
|
const point=e=>{const r=canvas.getBoundingClientRect();return {x:e.clientX-r.left,y:e.clientY-r.top};};
|
||||||
|
const size=()=>canvas.getBoundingClientRect();
|
||||||
|
const pair=()=>{const [a,b]=[...pointers.values()];return {x:(a.x+b.x)/2,y:(a.y+b.y)/2,d:Math.max(1,Math.hypot(a.x-b.x,a.y-b.y))};};
|
||||||
|
canvas.addEventListener('pointerdown',e=>{const p=point(e);if(!pointers.size){gesture=false;start=p;}pointers.set(e.pointerId,p);canvas.setPointerCapture(e.pointerId);last=pointers.size===2?pair():p;if(pointers.size>1)gesture=true;});
|
||||||
|
canvas.addEventListener('pointermove',e=>{
|
||||||
|
if(!pointers.has(e.pointerId))return;const p=point(e),old=pointers.get(e.pointerId),r=size();pointers.set(e.pointerId,p);
|
||||||
|
if(pointers.size===2){const next=pair();camera.zoom(next.d/last.d,last.x,last.y,r.width,r.height);camera.pan(next.x-last.x,next.y-last.y,r.width,r.height);last=next;gesture=true;changed();}
|
||||||
|
else if(pointers.size===1&&camera.scale>1){if(gesture||Math.hypot(p.x-start.x,p.y-start.y)>8){gesture=true;camera.pan(p.x-old.x,p.y-old.y,r.width,r.height);changed();}}
|
||||||
|
});
|
||||||
|
function end(e,cancel=false){if(!pointers.has(e.pointerId))return;const p=point(e);pointers.delete(e.pointerId);
|
||||||
|
if(!pointers.size){if(!cancel&&!gesture&&start){if(Math.hypot(p.x-start.x,p.y-start.y)>14)drag?.(start,p);else tap(p);}start=null;last=null;gesture=false;}
|
||||||
|
else{gesture=true;last=pointers.size===2?pair():[...pointers.values()][0];}
|
||||||
|
}
|
||||||
|
canvas.addEventListener('pointerup',e=>end(e));canvas.addEventListener('pointercancel',e=>end(e,true));
|
||||||
|
canvas.addEventListener('lostpointercapture',e=>{if(pointers.has(e.pointerId))end(e,true);});
|
||||||
|
canvas.addEventListener('wheel',e=>{if(!e.ctrlKey&&!e.metaKey)return;e.preventDefault();const p=point(e),r=size();camera.zoom(Math.exp(-e.deltaY*.01),p.x,p.y,r.width,r.height);changed();},{passive:false});
|
||||||
|
const toolbar=document.createElement('div');toolbar.className='board-zoom';toolbar.innerHTML='<button type="button" aria-label="缩小棋盘">−</button><button type="button" aria-label="重置棋盘视图">100%</button><button type="button" aria-label="放大棋盘">+</button><span>双指缩放 · 放大后拖动</span>';canvas.parentElement.insertBefore(toolbar,canvas.nextSibling);
|
||||||
|
const buttons=toolbar.querySelectorAll('button');
|
||||||
|
const update=()=>{buttons[1].textContent=`${Math.round(camera.scale*100)}%`;};
|
||||||
|
buttons.forEach((b,i)=>b.onclick=()=>{const r=size();if(i===1)camera.reset();else camera.zoom(i===0?1/1.35:1.35,r.width/2,r.height/2,r.width,r.height);changed();update();});
|
||||||
|
return {camera,update};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { hexagon, random, randomTracks, rotateBoard, trace, available, analyze, key, opposite, Game } from './core.js';
|
||||||
|
|
||||||
|
export const CAMPAIGN_PLAN = {
|
||||||
|
id:'observe-v3', chapterSize:6,
|
||||||
|
chapters:['看见出口','转出新路','连成节奏','轻巧解结','展开天地','从容变换','长路常新','自在回响'],
|
||||||
|
};
|
||||||
|
const STRAIGHT=[3,4,5,0,1,2];
|
||||||
|
const turnCount=route=>route.steps.filter(s=>s.enter!==null&&opposite(s.enter)!==s.exit).length;
|
||||||
|
|
||||||
|
// Spacious parallel lanes create long, readable release levels. Alternate
|
||||||
|
// columns leave enough physical clearance to avoid off-route grazing blockers;
|
||||||
|
// each lane splits outwards, so several independent choices stay available.
|
||||||
|
export function generateLaneLevel(seed,{radius=3,target=18,axis=seed%3}={}) {
|
||||||
|
const rng=random(seed),cells=hexagon(radius);
|
||||||
|
const sets=[0,1].map(parity=>cells.filter(c=>Math.abs(c.q)%2===parity));
|
||||||
|
let pool=sets[seed%2];if(pool.length<target)pool=sets[1-seed%2];
|
||||||
|
const selected=pool.map(c=>({c,w:rng()})).sort((a,b)=>a.w-b.w).slice(0,target).map(({c})=>c);
|
||||||
|
const tiles=selected.map((c,i)=>{
|
||||||
|
let q=c.q,r=c.r,dir=c.r+c.q/2<0?0:3;
|
||||||
|
for(let n=0;n<axis;n++){[q,r]=[-r||0,q+r||0];dir=(dir+1)%6;}
|
||||||
|
return{id:`t${i}`,q,r,dir,color:i%4};
|
||||||
|
});
|
||||||
|
return{version:1,id:`lanes-${seed}`,name:'从容的长路',seed,rotationEnabled:true,cells,tiles,moveLimit:tiles.length};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Independent board size, route vocabulary and piece count. Reverse insertion
|
||||||
|
// makes the unrotated board solvable; a selected starting rotation may add a
|
||||||
|
// small routing decision. No route is allowed to hide a solitary-piece loop.
|
||||||
|
export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns=4,startRotation=0,allFree=false,clearBlockers=false}={}) {
|
||||||
|
const rng=random(seed);
|
||||||
|
let level={version:1,id:`flow-${seed}`,name:'流动的轨迹',seed,rotationEnabled:true,cells:hexagon(radius),tiles:[],moveLimit:target};
|
||||||
|
level.cells.forEach(c=>{c.tracks=rng()<bendRate?randomTracks(rng):[...STRAIGHT];});
|
||||||
|
const boards=Array.from({length:6},(_,i)=>rotateBoard(level,i)),candidates=[];
|
||||||
|
for(const cell of level.cells)for(let dir=0;dir<6;dir++){
|
||||||
|
const t={id:'candidate',q:cell.q,r:cell.r,dir,color:0};
|
||||||
|
const routes=boards.map(b=>trace(b,t,[t],true));
|
||||||
|
if(routes.some(r=>r.status!=='exit'||turnCount(r)>maxTurns))continue;
|
||||||
|
candidates.push({t,steps:routes[0].steps.length,turns:turnCount(routes[0])});
|
||||||
|
}
|
||||||
|
for(let n=0;n<target;n++){
|
||||||
|
const occupied=new Set(level.tiles.map(t=>key(t.q,t.r))),choices=[];
|
||||||
|
for(const c of candidates){
|
||||||
|
if(occupied.has(key(c.t.q,c.t.r)))continue;
|
||||||
|
const tile={...c.t,id:`t${n}`,color:n%4},tiles=[...level.tiles,tile];
|
||||||
|
if(trace(level,tile,tiles).status!=='exit')continue;
|
||||||
|
if(allFree&&tiles.some(t=>trace(level,t,tiles).status!=='exit'))continue;
|
||||||
|
if(clearBlockers&&level.tiles.some(other=>[tile,other].some((t,i)=>{
|
||||||
|
const r=trace(level,t,[tile,other]);
|
||||||
|
return r.status==='blocked'&&!trace(level,t,[tile,other],true).blockers.includes((i?tile:other).id);
|
||||||
|
})))continue;
|
||||||
|
// Avoid filling the board with one-step edge exits; give readable routes
|
||||||
|
// a small preference, while random tie-breaking varies spatial patterns.
|
||||||
|
const weight=allFree?rng()*2-c.steps*2:rng()*3+Math.min(c.steps,5)*.35-c.turns*.25;
|
||||||
|
choices.push({tile,weight});
|
||||||
|
}
|
||||||
|
if(!choices.length)break;
|
||||||
|
choices.sort((a,b)=>b.weight-a.weight);
|
||||||
|
level.tiles.push(choices[0].tile);
|
||||||
|
}
|
||||||
|
level=rotateBoard(level,startRotation);
|
||||||
|
level.moveLimit=level.tiles.length;
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are structural checks, not playtest claims. Observe every solver state
|
||||||
|
// in all six orientations to find long no-progress stretches before shipping.
|
||||||
|
export function inspectFlowLevel(level) {
|
||||||
|
const a=analyze(level),boards=Array.from({length:6},(_,i)=>rotateBoard(level,i));
|
||||||
|
let independentLoops=0,maxSoloTurns=0;
|
||||||
|
for(const board of boards)for(const tile of level.tiles){const r=trace(board,tile,[tile],true);if(r.status!=='exit')independentLoops++;maxSoloTurns=Math.max(maxSoloTurns,turnCount(r));}
|
||||||
|
const game=new Game(level);let maxDryRotations=0,dry=0,maxUnlockDistance=0,hiddenBlockerStates=0,blockedStates=0,lowChoiceStates=0,states=0;
|
||||||
|
const samples=[];
|
||||||
|
function inspectState(){
|
||||||
|
if(!game.tiles.length)return;
|
||||||
|
const routes=game.tiles.map(t=>trace(game.level,t,game.tiles));
|
||||||
|
const free=routes.filter(r=>r.status==='exit').length;
|
||||||
|
for(let i=0;i<routes.length;i++)if(routes[i].status==='blocked'){
|
||||||
|
blockedStates++;const base=trace(game.level,game.tiles[i],game.tiles,true);
|
||||||
|
if(!base.blockers.includes(routes[i].blocker))hiddenBlockerStates++;
|
||||||
|
}
|
||||||
|
if(game.tiles.length>=6&&free<2)lowChoiceStates++;
|
||||||
|
let distance=0;
|
||||||
|
if(!free){distance=3;for(let turns=1;turns<=3;turns++)if(available(rotateBoard(game.level,turns),game.tiles).length||available(rotateBoard(game.level,-turns),game.tiles).length){distance=turns;break;}}
|
||||||
|
maxUnlockDistance=Math.max(maxUnlockDistance,distance);states++;
|
||||||
|
samples.push({remaining:game.tiles.length,free,rotationDistance:distance});
|
||||||
|
}
|
||||||
|
for(const action of a.actions){
|
||||||
|
inspectState();
|
||||||
|
if(action.type==='rotate'){dry++;maxDryRotations=Math.max(maxDryRotations,dry);game.rotate(action.direction);}
|
||||||
|
else{dry=0;game.tap(action.id);}
|
||||||
|
}
|
||||||
|
return {analysis:a,independentLoops,maxSoloTurns,maxDryRotations,maxUnlockDistance,hiddenBlockerStates,blockedStates,lowChoiceStates,states,samples,won:game.won};
|
||||||
|
}
|
||||||
@@ -44,12 +44,12 @@ export function validateLevel(input) {
|
|||||||
if (input.rotationEnabled !== undefined && typeof input.rotationEnabled !== 'boolean') errors.push('rotationEnabled 必须是布尔值');
|
if (input.rotationEnabled !== undefined && typeof input.rotationEnabled !== 'boolean') errors.push('rotationEnabled 必须是布尔值');
|
||||||
if (input.version !== 1) errors.push('version 必须为 1');
|
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 (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.cells) || input.cells.length < 1 || input.cells.length > 127) return [...errors, '底板须为 1–127 格'];
|
||||||
if (!Array.isArray(input.tiles) || input.tiles.length > input.cells.length) return [...errors, '棋子数量不能超过底板'];
|
if (!Array.isArray(input.tiles) || input.tiles.length > input.cells.length) return [...errors, '棋子数量不能超过底板'];
|
||||||
const coords = new Set();
|
const coords = new Set();
|
||||||
for (const c of input.cells) {
|
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) {
|
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)) > 6) {
|
||||||
errors.push('坐标必须为半径 4 内的轴向整数坐标'); continue;
|
errors.push('坐标必须为半径 6 内的轴向整数坐标'); continue;
|
||||||
}
|
}
|
||||||
const k = key(c.q, c.r);
|
const k = key(c.q, c.r);
|
||||||
if (coords.has(k)) errors.push(`底板重复:${k}`);
|
if (coords.has(k)) errors.push(`底板重复:${k}`);
|
||||||
@@ -65,9 +65,9 @@ export function validateLevel(input) {
|
|||||||
if (typeof t.id !== 'string' || !t.id || ids.has(t.id)) errors.push('棋子 id 须为唯一非空字符串');
|
if (typeof t.id !== 'string' || !t.id || ids.has(t.id)) errors.push('棋子 id 须为唯一非空字符串');
|
||||||
ids.add(t.id);
|
ids.add(t.id);
|
||||||
if (!Number.isInteger(t.dir) || t.dir < 0 || t.dir > 5) errors.push('方向必须为 0–5');
|
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(t.color) || t.color < 0 || t.color > 11) errors.push('颜色必须为 0–11');
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 999) errors.push('步数须为 1–999');
|
if (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 999) errors.push('成功离场额度须为 1–999');
|
||||||
return [...new Set(errors)];
|
return [...new Set(errors)];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ export function canRotate(level) {
|
|||||||
|
|
||||||
// Rotation search: removing an available piece is always safe (occupancy only decreases).
|
// 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.
|
// 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.
|
// Each search edge costs one turn, but only successful removals spend the move budget.
|
||||||
export function solveRotations(level, maxStates = 20000) {
|
export function solveRotations(level, maxStates = 20000) {
|
||||||
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
|
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
|
||||||
const rotating = canRotate(level) && level.rotationEnabled !== false;
|
const rotating = canRotate(level) && level.rotationEnabled !== false;
|
||||||
@@ -161,8 +161,8 @@ export function solveRotations(level, maxStates = 20000) {
|
|||||||
if (seen.has(signature)) continue;
|
if (seen.has(signature)) continue;
|
||||||
seen.add(signature); visited++;
|
seen.add(signature); visited++;
|
||||||
if (!node.remaining.length && level.tiles.length) {
|
if (!node.remaining.length && level.tiles.length) {
|
||||||
const minimumMoves = level.tiles.length + node.rotations;
|
const minimumMoves = level.tiles.length, minimumActions = minimumMoves + 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 };
|
return { solvable: minimumMoves <= level.moveLimit, reachable: true, minimumMoves, minimumActions, 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;
|
if (!rotating) continue;
|
||||||
for (const direction of [1, -1]) {
|
for (const direction of [1, -1]) {
|
||||||
@@ -172,24 +172,39 @@ export function solveRotations(level, maxStates = 20000) {
|
|||||||
if (!seen.has(signature)) queue.push({ ...child, rotations: node.rotations + 1 });
|
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 };
|
return { solvable: false, reachable: false, minimumMoves: null, minimumActions: null, rotations: 0, actions: [], order: [], waves: first.waves, stuck: first.remaining.map(t => t.id), states: visited, exhausted: visited >= maxStates };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function analyze(level) {
|
export function analyze(level) {
|
||||||
const solution = solveRotations(level), n = level.tiles.length;
|
const solution = solveRotations(level), n = level.tiles.length;
|
||||||
const routes = level.tiles.map(t => trace(level, t, level.tiles, true));
|
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 routeTurns = routes.map(p => p.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length);
|
||||||
const free = available(level).length;
|
const totalTurns = routeTurns.reduce((sum, turns) => sum + turns, 0);
|
||||||
|
const meanTurns = n ? totalTurns / n : 0, maxTurns = Math.max(0, ...routeTurns);
|
||||||
|
const occupiedRoutes = level.tiles.map(t => trace(level, t));
|
||||||
|
const free = occupiedRoutes.filter(p => p.status === 'exit').length, freeRatio = n ? free / n : 0;
|
||||||
|
const sweptOnlyBlockers = occupiedRoutes.filter((p, i) => p.collision && !routes[i].blockers.includes(p.blocker)).length;
|
||||||
|
const dependencyRatio = n > 1 ? Math.max(0, solution.waves.length - 1) / (n - 1) : 0;
|
||||||
|
// Cognitive difficulty measures route reading and dependencies, never board area,
|
||||||
|
// piece count or a penalty for experimenting. Workload is reported separately.
|
||||||
const parts = {
|
const parts = {
|
||||||
scale: Math.min(1, n / 37) * 15,
|
bends: Math.min(1, meanTurns / 4) * 22,
|
||||||
turns: Math.min(1, meanTurns / 5) * 20,
|
longestRoute: Math.min(1, maxTurns / 8) * 12,
|
||||||
depth: Math.min(1, Math.max(0, solution.waves.length - 1) / 9) * 20,
|
dependencies: Math.min(1, dependencyRatio) * 18,
|
||||||
blocked: (n ? 1 - free / n : 0) * 15,
|
blocked: (n ? 1 - freeRatio : 0) * 28,
|
||||||
rotation: Math.min(1, solution.rotations / 4) * 20,
|
sweptBlockers: (n ? sweptOnlyBlockers / n : 0) * 12,
|
||||||
precision: (n ? Math.min(1, Math.max(0, 1 - (level.moveLimit - (solution.minimumMoves ?? n)) / Math.max(1, n * 0.4))) : 0) * 10,
|
rotation: Math.min(1, solution.rotations / 3) * 8,
|
||||||
};
|
};
|
||||||
const score = Math.round(Object.values(parts).reduce((a, b) => a + b, 0));
|
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 ? '烧脑' : '挑战' };
|
const workload = {
|
||||||
|
removals: n,
|
||||||
|
rotations: solution.rotations,
|
||||||
|
minimumActions: solution.minimumActions,
|
||||||
|
routeCells: routes.reduce((sum, p) => sum + p.steps.length, 0),
|
||||||
|
// A pacing heuristic for comparing authored levels, not measured playtest time.
|
||||||
|
estimatedSeconds: Math.round(n * 3 + totalTurns * 0.75 + solution.rotations * 4),
|
||||||
|
};
|
||||||
|
return { score, parts, meanTurns, maxTurns, free, freeRatio, sweptOnlyBlockers, dependencyRatio, workload, depth: solution.waves.length, ...solution, label: score < 30 ? '轻松' : score < 50 ? '进阶' : score < 70 ? '烧脑' : '挑战' };
|
||||||
}
|
}
|
||||||
export function generateLevel(seed, radius = 3, target = 25, complexity = 0.7) {
|
export function generateLevel(seed, radius = 3, target = 25, complexity = 0.7) {
|
||||||
const rng = random(seed);
|
const rng = random(seed);
|
||||||
@@ -238,7 +253,7 @@ export class Game {
|
|||||||
this.snapshot();
|
this.snapshot();
|
||||||
this.level = rotateBoard(this.level, direction);
|
this.level = rotateBoard(this.level, direction);
|
||||||
this.orientation = (this.orientation + direction + 6) % 6;
|
this.orientation = (this.orientation + direction + 6) % 6;
|
||||||
this.rotations++; this.moves--;
|
this.rotations++;
|
||||||
return { status: 'rotated', direction, orientation: this.orientation };
|
return { status: 'rotated', direction, orientation: this.orientation };
|
||||||
}
|
}
|
||||||
tap(id) {
|
tap(id) {
|
||||||
@@ -246,8 +261,8 @@ export class Game {
|
|||||||
const tile = this.tiles.find(t => t.id === id);
|
const tile = this.tiles.find(t => t.id === id);
|
||||||
if (!tile) return { status: 'missing' };
|
if (!tile) return { status: 'missing' };
|
||||||
const route = trace(this.level, tile, this.tiles);
|
const route = trace(this.level, tile, this.tiles);
|
||||||
this.snapshot(); this.moves--;
|
this.snapshot();
|
||||||
if (route.status === 'exit') this.tiles = this.tiles.filter(t => t.id !== id);
|
if (route.status === 'exit') { this.tiles = this.tiles.filter(t => t.id !== id); this.moves--; }
|
||||||
else this.mistakes++;
|
else this.mistakes++;
|
||||||
return { ...route, tile: clone(tile) };
|
return { ...route, tile: clone(tile) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
export const THEME = Object.freeze({
|
export const THEME = Object.freeze({
|
||||||
background: '#A8BECF',
|
background: '#A8BECF',
|
||||||
board: '#F2EEE5',
|
board: '#F2EEE5',
|
||||||
boardGrid: '#7C8B94',
|
boardGrid: '#89867D',
|
||||||
track: '#6D808B',
|
track: '#435F78',
|
||||||
trackHalo: '#DFE3E2',
|
trackHalo: '#F2EEE5',
|
||||||
surface: '#F7F6F2',
|
surface: '#F7F6F2',
|
||||||
textPrimary: '#273B50',
|
textPrimary: '#273B50',
|
||||||
textSecondary: '#314A5F',
|
textSecondary: '#314A5F',
|
||||||
@@ -38,7 +38,7 @@ export const SURFACES = Object.freeze({
|
|||||||
disabled: 'disabled', tileBlue: 'tileBlue', tilePink: 'tilePink', tileYellow: 'tileYellow', tilePurple: 'tilePurple',
|
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 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 MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:650,rotationMs:240,exitMsPerUnit:60,exitMinTravelMs:140,exitMaxTravelMs:800,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:1700,exitLongStartCells:2,exitLongFullCells:5});
|
||||||
export const RADII = Object.freeze({control:14,dialog:24,small:8});
|
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 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 function contrast(a,b){const x=luminance(a),y=luminance(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05);}
|
||||||
@@ -52,3 +52,29 @@ export const CONTRAST_PAIRS = [
|
|||||||
['方向箭头 / 蓝棋子','arrowLight','tileBlue',3], ['方向箭头 / 粉棋子','arrowLight','tilePink',3],
|
['方向箭头 / 蓝棋子','arrowLight','tileBlue',3], ['方向箭头 / 粉棋子','arrowLight','tilePink',3],
|
||||||
['方向箭头 / 黄棋子','arrowDark','tileYellow',3], ['方向箭头 / 紫棋子','arrowLight','tilePurple',3],
|
['方向箭头 / 黄棋子','arrowDark','tileYellow',3], ['方向箭头 / 紫棋子','arrowLight','tilePurple',3],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// V2: global pigment library. A level uses only its curated subset.
|
||||||
|
export const PIGMENTS = Object.freeze([
|
||||||
|
{name:'晴空蓝', color:THEME.tileBlue}, {name:'莓果粉', color:THEME.tilePink},
|
||||||
|
{name:'日光黄', color:THEME.tileYellow}, {name:'葡萄紫', color:THEME.tilePurple},
|
||||||
|
{name:'湖水青', color:'#008B83'}, {name:'珊瑚橙', color:'#EF704F'},
|
||||||
|
{name:'嫩芽绿', color:'#91C95B'}, {name:'鸢尾蓝', color:'#5267CF'},
|
||||||
|
{name:'玫瑰红', color:'#C83F66'}, {name:'蜜桃杏', color:'#F5AA83'},
|
||||||
|
{name:'冰川青', color:'#58C9D5'}, {name:'兰花紫', color:'#AE64C7'},
|
||||||
|
].map(p=>Object.freeze({...p, arrow:contrast(THEME.arrowLight,p.color)>=3?THEME.arrowLight:THEME.arrowDark})));
|
||||||
|
export const LEVEL_THEMES = Object.freeze([
|
||||||
|
{id:'lagoon',name:'薄荷浅湾',label:'轻松',range:'0–29',background:'#C5E4DF',ink:'#273B50',palette:[0,4,2],description:'冷润的浅湾,配蓝、青与一点日光黄。'},
|
||||||
|
{id:'apricot',name:'杏色午后',label:'进阶',range:'30–49',background:'#F1D7B8',ink:'#273B50',palette:[5,7,4,9],description:'温暖杏底,以珊瑚、鸢尾蓝和湖水青平衡。'},
|
||||||
|
{id:'lavender',name:'鸢尾花园',label:'烧脑',range:'50–69',background:'#D8D0EB',ink:'#273B50',palette:[3,6,8,10],description:'柔紫空间,用嫩芽绿与冰川青拉开层次。'},
|
||||||
|
{id:'rose',name:'玫瑰暮色',label:'挑战',range:'70–100',background:'#E7C3CE',ink:'#273B50',palette:[8,7,2,11],description:'低饱和玫瑰底,深色棋子与日光黄形成节奏。'},
|
||||||
|
{id:'midnight',name:'午夜极光',label:'每周挑战',range:'特别主题',background:'#293956',ink:'#F7F6F2',palette:[10,11,2,1],description:'深靛蓝舞台,冰川青、兰花紫和明亮暖色。'},
|
||||||
|
].map(t=>Object.freeze({...t,palette:Object.freeze(t.palette)})));
|
||||||
|
export function themeForScore(score){return LEVEL_THEMES[score<30?0:score<50?1:score<70?2:3];}
|
||||||
|
export function pigmentIndex(color,theme){return theme?theme.palette[color%theme.palette.length]:color;}
|
||||||
|
|
||||||
|
// Keep each world's anchor colors, then add distinct hues before close relatives.
|
||||||
|
export function themeWithColors(theme,count=7){
|
||||||
|
const limit=[4,7,9,12].includes(count)?count:7;
|
||||||
|
const order=[...new Set([...theme.palette,0,1,2,3,4,5,6,7,8,9,10,11])];
|
||||||
|
return {...theme,palette:order.slice(0,limit)};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { MOTION } from './design-tokens.js';
|
||||||
|
import { routePoints } from './geometry.js';
|
||||||
|
|
||||||
|
// Timing follows actual path length, so a piece beside an exit has no fixed wait.
|
||||||
|
export function exitTiming(route) {
|
||||||
|
const points=routePoints({...route,end:null},{size:1,ox:0,oy:0});
|
||||||
|
const distance=points.slice(1).reduce((sum,p,i)=>sum+Math.hypot(p.x-points[i].x,p.y-points[i].y),0);
|
||||||
|
const travelMs=Math.max(MOTION.exitMinTravelMs,Math.min(MOTION.exitMaxTravelMs,distance*MOTION.exitMsPerUnit));
|
||||||
|
const shortDuration=travelMs+MOTION.exitEffectMs;
|
||||||
|
const longDuration=Math.min(MOTION.exitLongMaxMs,MOTION.exitLongBaseMs+route.steps.length*MOTION.exitLongCellMs);
|
||||||
|
// Blend into the original long-route pacing without slowing edge departures.
|
||||||
|
const blend=smoothMotion((route.steps.length-MOTION.exitLongStartCells)/(MOTION.exitLongFullCells-MOTION.exitLongStartCells));
|
||||||
|
const duration=shortDuration+(longDuration-shortDuration)*blend;
|
||||||
|
return {duration,travelFraction:(duration-MOTION.exitEffectMs)/duration};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero endpoint velocity: a soft launch and a settled finish, without overshoot.
|
||||||
|
export function smoothMotion(value) {
|
||||||
|
const t=Math.max(0,Math.min(1,value));
|
||||||
|
return t*t*(3-2*t);
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ export function layout(cells,width,height) {
|
|||||||
const minX=Math.min(...raw.map(p=>p.x))-1,maxX=Math.max(...raw.map(p=>p.x))+1;
|
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 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);
|
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};
|
const ox=width/2-(minX+maxX)/2*size,oy=height/2-(minY+maxY)/2*size;
|
||||||
|
return {size,ox,oy,bounds:{top:minY*size+oy,bottom:maxY*size+oy}};
|
||||||
}
|
}
|
||||||
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 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) {
|
export function routePoints(route,view,extend=false) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { THEME } from './design-tokens.js';
|
import { exitTiming, smoothMotion } from './exit-animation.js';
|
||||||
|
import { THEME, PIGMENTS, pigmentIndex } from './design-tokens.js';
|
||||||
import { DIRS, key } from './core.js';
|
import { DIRS, key } from './core.js';
|
||||||
import { tileSample, roundedHex } from './tile-shape.js';
|
import { tileSample, roundedHex } from './tile-shape.js';
|
||||||
import { collisionPhase, contactPath } from './collision-animation.js';
|
import { collisionPhase, contactPath } from './collision-animation.js';
|
||||||
import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geometry.js';
|
import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geometry.js';
|
||||||
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
|
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
|
||||||
export const COLORS=[THEME.tileBlue,THEME.tilePink,THEME.tileYellow,THEME.tilePurple];
|
export const COLORS=PIGMENTS.map(p=>p.color);
|
||||||
const DARK=COLORS.map(()=>THEME.arrowDark);
|
|
||||||
// Scene commands remain engine-independent. sprite() is an optional atlas adapter.
|
// 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) {
|
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 r=size*TILE_RADIUS*scale;
|
||||||
@@ -16,10 +16,11 @@ export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,sca
|
|||||||
const a=r*.58;
|
const a=r*.58;
|
||||||
const shape=[[-.72,-.23],[.06,-.23],[.06,-.59],[.83,0],[.06,.59],[.06,.23],[-.72,.23]];
|
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))}));
|
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);
|
p.polygon(arrow,PIGMENTS[tile.color]?.arrow||THEME.arrowDark);
|
||||||
}
|
}
|
||||||
export function drawScene(p,level,tiles,view,state={}) {
|
export function drawScene(p,level,tiles,view,state={}) {
|
||||||
const {size}=view;
|
const {size}=view;
|
||||||
|
const visualTile=tile=>({...tile,color:pigmentIndex(state.theme?Math.max(0,level.tiles.findIndex(t=>t.id===tile.id)):tile.color,state.theme)});
|
||||||
const angle=state.boardAngle||0,cos=Math.cos(angle),sin=Math.sin(angle);
|
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 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 base={polygon:(pts,c)=>p.polygon(rotate(pts),c),line:(pts,c,w)=>p.line(rotate(pts),c,w)};
|
||||||
@@ -39,7 +40,7 @@ export function drawScene(p,level,tiles,view,state={}) {
|
|||||||
// opening background gaps or painting over connected track endpoints.
|
// opening background gaps or painting over connected track endpoints.
|
||||||
for(const cell of sorted){
|
for(const cell of sorted){
|
||||||
const {x,y}=center(cell,view),edge=hexPoints(x,y,size);
|
const {x,y}=center(cell,view),edge=hexPoints(x,y,size);
|
||||||
base.line([...edge,edge[0]],THEME.boardGrid,Math.max(1.25,size*.038));
|
base.line([...edge,edge[0]],THEME.boardGrid,Math.max(1,size*.024));
|
||||||
}
|
}
|
||||||
const rails=[];
|
const rails=[];
|
||||||
for(const cell of sorted) {
|
for(const cell of sorted) {
|
||||||
@@ -59,7 +60,7 @@ export function drawScene(p,level,tiles,view,state={}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for(const points of rails)base.line(points,THEME.trackHalo,Math.max(4,size*.16));
|
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));
|
for(const points of rails)base.line(points,THEME.track,Math.max(2.5,size*.10));
|
||||||
if(state.path) p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12));
|
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) {
|
if(state.selected) {
|
||||||
const cell=level.cells.find(c=>c.q===state.selected.q&&c.r===state.selected.r);
|
const cell=level.cells.find(c=>c.q===state.selected.q&&c.r===state.selected.r);
|
||||||
@@ -68,6 +69,10 @@ export function drawScene(p,level,tiles,view,state={}) {
|
|||||||
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)) {
|
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);
|
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);}
|
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);}
|
||||||
|
if(state.queued?.includes(tile.id)){
|
||||||
|
const ring=hexPoints(c.x,c.y,size*1.03);
|
||||||
|
p.line([...ring,ring[0]],THEME.primary,3);
|
||||||
|
}
|
||||||
let dx=0;
|
let dx=0;
|
||||||
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
|
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
|
||||||
let dy=0;
|
let dy=0;
|
||||||
@@ -75,13 +80,17 @@ export function drawScene(p,level,tiles,view,state={}) {
|
|||||||
dx+=Math.cos(impactAngle)*phase.kick*size*.1;dy+=Math.sin(impactAngle)*phase.kick*size*.1;
|
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);}
|
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);
|
drawTile(p,c.x+dx,c.y+dy,size,visualTile(tile));
|
||||||
}
|
}
|
||||||
if(state.animation) {
|
if(state.animation) {
|
||||||
const {route,progress}=state.animation;
|
const {route,progress}=state.animation;
|
||||||
const blocked=route.status!=='exit';
|
const blocked=route.status!=='exit';
|
||||||
const loc=pointAlong(movingPath,phase?phase.distance:blocked?Math.sin(progress*Math.PI):progress);
|
if(!blocked){
|
||||||
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);
|
drawExit(p,route,view,progress,visualTile(route.tile),state.reduced);
|
||||||
|
}else{
|
||||||
|
const loc=pointAlong(movingPath,phase?phase.distance:Math.sin(progress*Math.PI));
|
||||||
|
drawTile(p,loc.x,loc.y,size,visualTile(route.tile),loc.angle,phase?1-phase.impact*.045:1);
|
||||||
|
}
|
||||||
if(target&&phase.impact>0){
|
if(target&&phase.impact>0){
|
||||||
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
|
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);}
|
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);}
|
||||||
@@ -91,7 +100,7 @@ export function drawScene(p,level,tiles,view,state={}) {
|
|||||||
export class CanvasPainter {
|
export class CanvasPainter {
|
||||||
constructor(context) { this.ctx=context; this.atlas=null; }
|
constructor(context) { this.ctx=context; this.atlas=null; }
|
||||||
sprite(index,x,y,r) {
|
sprite(index,x,y,r) {
|
||||||
if(!this.atlas?.complete||!this.atlas.naturalWidth)return false;
|
if(!TILE_FRAMES[index]||!this.atlas?.complete||!this.atlas.naturalWidth)return false;
|
||||||
const f=tileSample(TILE_FRAMES[index]),w=r*2,h=w*TILE_ASPECT;
|
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();
|
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);
|
this.ctx.drawImage(this.atlas,f.x,f.y,f.w,f.h,x-w/2,y-h/2,w,h);
|
||||||
@@ -102,3 +111,35 @@ export class CanvasPainter {
|
|||||||
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();}
|
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();}
|
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();}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exitGeometry(route,view){
|
||||||
|
const points=routePoints({...route,end:null},view);
|
||||||
|
const edge=points[points.length-1],before=points[points.length-2];
|
||||||
|
const angle=Math.atan2(edge.y-before.y,edge.x-before.x);
|
||||||
|
return {points,edge,angle};
|
||||||
|
}
|
||||||
|
export function drawExit(p,route,view,progress,tile,reduced=false){
|
||||||
|
const {points,edge,angle}=exitGeometry(route,view),size=view.size;
|
||||||
|
if(reduced){if(progress<.8){const at=pointAlong(points,progress/.8);drawTile(p,at.x,at.y,size,tile,at.angle);}return;}
|
||||||
|
const travel=exitTiming(route).travelFraction;
|
||||||
|
if(progress<travel){
|
||||||
|
const at=pointAlong(points,smoothMotion(progress/travel));
|
||||||
|
drawTile(p,at.x,at.y,size,tile,at.angle);return;
|
||||||
|
}
|
||||||
|
const t=Math.min(1,(progress-travel)/(1-travel)),ease=smoothMotion(t);
|
||||||
|
const color=COLORS[tile.color],dx=Math.cos(angle),dy=Math.sin(angle);
|
||||||
|
// Local exit plume: short enough to remain visible in the canvas gutter.
|
||||||
|
const at={x:edge.x+dx*size*.48*ease,y:edge.y+dy*size*.48*ease};
|
||||||
|
for(let i=3;i>=1;i--){
|
||||||
|
const d=size*(.10+i*.13)*ease,scale=(1-smoothMotion(t))*(.14-i*.025);
|
||||||
|
if(scale>0)p.circle(at.x-dx*d,at.y-dy*d,size*scale,color);
|
||||||
|
}
|
||||||
|
if(t<.82)drawTile(p,at.x,at.y,size,tile,angle,1-smoothMotion(t/.82));
|
||||||
|
const burst=Math.max(0,(t-.18)/.82);
|
||||||
|
if(burst>0&&burst<1){
|
||||||
|
for(let i=0;i<6;i++){
|
||||||
|
const a=angle+(i-2.5)*.58,d=size*(.12+.40*(1-Math.pow(1-burst,3))),r=size*.075*(1-smoothMotion(burst));
|
||||||
|
p.polygon(hexPoints(edge.x+dx*size*.18+Math.cos(a)*d,edge.y+dy*size*.18+Math.sin(a)*d,r),color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { attachBoardViewport } from './board-viewport.js';
|
||||||
|
import { generateLaneLevel } from './campaign-generator.js';
|
||||||
|
import { TapQueue } from './tap-queue.js';
|
||||||
|
import { exitTiming } from './exit-animation.js';
|
||||||
|
import { LEVEL_THEMES, PIGMENTS, MOTION, themeWithColors } from './design-tokens.js';
|
||||||
|
import { LEVELS } from './levels.js';
|
||||||
|
import { Game, available, solveRotations } from './core.js';
|
||||||
|
import { layout, hitCell } from './geometry.js';
|
||||||
|
import { CanvasPainter, drawScene, drawTile } from './renderer.js';
|
||||||
|
import { ICONS, ICON_SIZE, ICON_ATLAS } from './ui-skin.js';
|
||||||
|
import { TILE_ATLAS } from './sprites.js';
|
||||||
|
const taps=new TapQueue();
|
||||||
|
const $=id=>document.getElementById(id),canvas=$('preview'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
|
||||||
|
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
let boardViewport;
|
||||||
|
const regularLevel=LEVELS.find(l=>l.tiles.length>=16)||LEVELS.find(l=>l.tiles.length>=12)||LEVELS[0];
|
||||||
|
let studyLevel=generateLaneLevel(606,{radius:5,target:42,axis:0});
|
||||||
|
let colorCount=7;
|
||||||
|
let theme=themeWithColors(LEVEL_THEMES[0],colorCount),game=new Game(studyLevel),animation=null,rotation=null,frame=0,view=null,hint=null,afterRotation=null;
|
||||||
|
const atlas=new Image();atlas.src=TILE_ATLAS;atlas.onload=()=>{painter.atlas=atlas;draw();paintSamples();};
|
||||||
|
function decorateIcons(){
|
||||||
|
document.querySelectorAll('[data-icon]').forEach(el=>{
|
||||||
|
const f=ICONS[el.dataset.icon];
|
||||||
|
el.innerHTML=`<span class="art-icon" aria-hidden="true"><img src="${ICON_ATLAS}" alt="" style="width:${ICON_SIZE.w/f.w*100}%;height:${ICON_SIZE.h/f.h*100}%;left:${-f.x/f.w*100}%;top:${-f.y/f.h*100}%"></span>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$('theme-tabs').innerHTML=LEVEL_THEMES.map(t=>`<button data-theme="${t.id}" aria-pressed="false"><i style="background:${t.background}"></i>${t.label}</button>`).join('');
|
||||||
|
$('theme-grid').innerHTML=LEVEL_THEMES.map(t=>`<button class="theme-card" data-theme="${t.id}" aria-pressed="false"><div class="mini-scene" style="--bg:${t.background};--ink:${t.ink}"><span>${t.label} / ${t.range}</span><canvas data-sample="${t.id}" aria-label="${t.name}配色"></canvas></div><strong>${t.name}</strong><small>${t.palette.length} 色棋子 · ${t.background}</small></button>`).join('');
|
||||||
|
$('pigment-grid').innerHTML=PIGMENTS.map((p,i)=>`<div class="pigment-item"><canvas data-pigment="${i}" aria-label="${p.name}棋子"></canvas><strong>${p.name}</strong><small>${p.color}</small></div>`).join('');
|
||||||
|
function setTheme(next){
|
||||||
|
theme=themeWithColors(next,colorCount);$('phone').style.setProperty('--scene-bg',theme.background);$('density-grid').style.setProperty('--bg',theme.background);$('density-grid').style.setProperty('--ink',theme.ink);$('phone').style.setProperty('--scene-ink',theme.ink);
|
||||||
|
$('scene-label').textContent=`${theme.label} / ${theme.range}`;$('scene-name').textContent=theme.name;
|
||||||
|
$('scene-description').textContent=`${theme.description} 当前展示 ${colorCount} 色扩展搭配。`;$('phone-label').textContent=`${theme.label} · ${theme.name}`;
|
||||||
|
$('background-value').textContent=theme.background;$('background-chip').style.background=theme.background;
|
||||||
|
$('palette-count').textContent=`${theme.palette.length} / 12`;
|
||||||
|
$('local-palette').innerHTML=theme.palette.map(i=>`<span title="${PIGMENTS[i].name}" style="background:${PIGMENTS[i].color}"></span>`).join('');
|
||||||
|
document.querySelectorAll('[data-theme]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.theme===theme.id)));
|
||||||
|
for(const id of ['play-theme','game-link'])$(id).href=`index.html?theme=${theme.id}&colors=${colorCount}`;
|
||||||
|
paintSamples();draw();
|
||||||
|
}
|
||||||
|
document.querySelectorAll('[data-theme]').forEach(b=>b.onclick=()=>setTheme(LEVEL_THEMES.find(t=>t.id===b.dataset.theme)));
|
||||||
|
function fit(c){const rect=c.getBoundingClientRect(),w=rect.width,h=c===canvas?rect.height:w,dpr=Math.min(devicePixelRatio||1,2);c.width=Math.round(w*dpr);c.height=Math.round(h*dpr);const context=c.getContext('2d');context.setTransform(dpr,0,0,dpr,0,0);return {context,width:w,height:h};}
|
||||||
|
function paintSamples(){
|
||||||
|
document.querySelectorAll('[data-theme].theme-card>small').forEach(el=>{const t=LEVEL_THEMES.find(t=>t.id===el.parentElement.dataset.theme);el.textContent=`${colorCount} 色棋子 · ${t.background}`;});
|
||||||
|
document.querySelectorAll('[data-density]').forEach(c=>{const {context,width}=fit(c),p=new CanvasPainter(context);p.atlas=atlas;drawScene(p,studyLevel,studyLevel.tiles,layout(studyLevel.cells,width,width),{theme:themeWithColors(theme,Number(c.dataset.density))});});
|
||||||
|
document.querySelectorAll('[data-sample]').forEach(c=>{const {context,width}=fit(c),p=new CanvasPainter(context);p.atlas=atlas;const v=layout(studyLevel.cells,width,width);drawScene(p,studyLevel,studyLevel.tiles,v,{theme:themeWithColors(LEVEL_THEMES.find(t=>t.id===c.dataset.sample),colorCount)});});
|
||||||
|
document.querySelectorAll('[data-pigment]').forEach(c=>{const {context,width}=fit(c),p=new CanvasPainter(context);p.atlas=atlas;drawTile(p,width/2,width/2,width*.46,{color:Number(c.dataset.pigment),dir:1});});
|
||||||
|
}
|
||||||
|
function resize(){const {width,height}=fit(canvas);const base=layout(game.level.cells,width,height);view=boardViewport?boardViewport.camera.fit(base,width,height):base;boardViewport?.update();draw();paintSamples();}
|
||||||
|
function controls(){const busy=!!animation||!!rotation;for(const id of ['demo','left','right','undo','hint'])$(id).disabled=busy; $('undo').disabled=busy||!game.history.length;$('moves').textContent=game.moves;}
|
||||||
|
function draw(now=performance.now()){
|
||||||
|
if(!view)return;
|
||||||
|
cancelAnimationFrame(frame);ctx.clearRect(0,0,canvas.width,canvas.height);
|
||||||
|
const state={theme,reduced,hint,queued:taps.ids};let done=false,rotated=false;
|
||||||
|
if(animation){const progress=Math.min(1,(now-animation.start)/animation.duration);state.animation={route:animation.route,progress};done=progress>=1;}
|
||||||
|
if(rotation){const t=Math.min(1,(now-rotation.start)/rotation.duration);state.boardAngle=-rotation.direction*Math.PI/3*Math.pow(1-t,3);rotated=t>=1;}
|
||||||
|
drawScene(painter,game.level,game.tiles,view,state);
|
||||||
|
if(done){const status=animation.route.status;animation=null;$('phone-status').textContent=game.won?'全部离场,漂亮!':status==='exit'?'成功离场 · 可以继续点击棋子':'发生碰撞 · 可撤销或转动底盘';$('motion-status').textContent=status==='exit'?'播放完成 · 再播一次可观察尾迹与碎片':'这条路线被阻挡了';controls();}
|
||||||
|
if(rotated){rotation=null;controls();const cb=afterRotation;afterRotation=null;if(cb){cb();return;}}
|
||||||
|
if(!animation&&!rotation){const next=taps.take(game.tiles);if(next){move(next);return;}}
|
||||||
|
if(animation||rotation)frame=requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
function reset(){taps.clear();animation=null;rotation=null;afterRotation=null;hint=null;cancelAnimationFrame(frame);game=new Game(studyLevel);$('phone-status').textContent='点击棋子,沿轨道出发';$('motion-status').textContent='可反复播放,也可直接操作棋盘。';controls();draw();}
|
||||||
|
function move(tile){if(animation||rotation){if(taps.add(tile.id,animation?.route.tile.id))draw();return;}hint=null;const route=game.tap(tile.id);if(!['exit','blocked','loop'].includes(route.status))return;
|
||||||
|
if(route.status!=='exit')taps.clear();
|
||||||
|
const base=route.status==='exit'?exitTiming(route).duration:MOTION.collisionMs;
|
||||||
|
animation={route,start:performance.now(),duration:reduced?150:base*($('slow').checked?2.5:1)};
|
||||||
|
$('phone-status').textContent=route.status==='exit'?'沿着轨道,找到出口…':'接触后沿原路返回';$('motion-status').textContent=route.status==='exit'?'正在播放:滑行 → 出口收束 → 六角余韵':'正在展示碰撞反馈';controls();draw();
|
||||||
|
}
|
||||||
|
function turn(direction,callback=null){if(animation||rotation)return;hint=null;const r=game.rotate(direction);if(r.status!=='rotated')return;afterRotation=callback;rotation={direction,start:performance.now(),duration:reduced?100:MOTION.rotationMs};$('phone-status').textContent='棋子不动,底盘旋转 60°';controls();draw();}
|
||||||
|
function demonstrate(){
|
||||||
|
if(animation||rotation)return;
|
||||||
|
if(game.won||game.moves<=0)reset();
|
||||||
|
const free=available(game.level,game.tiles);
|
||||||
|
if(free.length){move(free[0]);return;}
|
||||||
|
const action=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves}).actions[0];
|
||||||
|
if(action?.type==='rotate')turn(action.direction,demonstrate);else{reset();$('motion-status').textContent='演示已重置,请再次播放。';}
|
||||||
|
}
|
||||||
|
$('demo').onclick=demonstrate;$('restart').onclick=reset;
|
||||||
|
$('left').onclick=()=>turn(-1);$('right').onclick=()=>turn(1);
|
||||||
|
$('undo').onclick=()=>{if(game.undo()){hint=null;controls();draw();$('phone-status').textContent='已撤销,状态恢复';}};
|
||||||
|
$('hint').onclick=()=>{const action=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves}).actions[0];if(action?.type==='tap'){hint=action.id;$('phone-status').textContent='点击标记的棋子,可以安全离场';draw();}else $('phone-status').textContent=action?.type==='rotate'?`试试${action.direction===1?'右':'左'}转底盘`:'全部完成,可重置演示';};
|
||||||
|
$('reset').onclick=()=>{taps.clear();$('study-dialog-content').innerHTML='<h2>选择关卡</h2><div class="study-levels">'+LEVELS.map((l,i)=>`<button data-level="${i}">${String(i+1).padStart(2,'0')}</button>`).join('')+'</div>';$('study-dialog').showModal();};
|
||||||
|
$('next-theme').onclick=()=>{taps.clear();$('study-dialog-content').innerHTML=`<h2>设置</h2><label><input id="study-slow" type="checkbox" ${$('slow').checked?'checked':''}> 慢速观察动画</label><button id="study-fit">恢复完整棋盘视图</button>`;$('study-dialog').showModal();};
|
||||||
|
$('study-close').onclick=()=>$('study-dialog').close();
|
||||||
|
$('study-dialog-content').onclick=e=>{const b=e.target.closest('[data-level]');if(b){studyLevel=LEVELS[Number(b.dataset.level)];document.querySelector('.phone-top strong').textContent=`LEVEL ${String(Number(b.dataset.level)+1).padStart(2,'0')}`;boardViewport.camera.reset();reset();resize();document.querySelectorAll('.density-card .mini-scene>span').forEach(el=>el.textContent=`${el.closest('[data-count]').dataset.count} 色 / ${studyLevel.tiles.length} 枚棋子`);$('study-dialog').close();}if(e.target.closest('#study-fit')){boardViewport.camera.reset();resize();$('study-dialog').close();}};
|
||||||
|
$('study-dialog-content').onchange=e=>{if(e.target.id==='study-slow')$('slow').checked=e.target.checked;};
|
||||||
|
boardViewport=attachBoardViewport(canvas,{changed:resize,tap:({x,y})=>{if(!view)return;const cell=hitCell(game.level.cells,view,x,y);const tile=cell&&game.tiles.find(t=>t.q===cell.q&&t.r===cell.r);if(tile)move(tile);}});
|
||||||
|
$('board-size').onchange=()=>{studyLevel=$('board-size').value==='regular'?regularLevel:generateLaneLevel(606,{radius:Number($('board-size').value),target:$('board-size').value==='5'?42:60,axis:0});boardViewport.camera.reset();reset();resize();document.querySelectorAll('.density-card .mini-scene>span').forEach(el=>el.textContent=`${el.closest('[data-count]').dataset.count} 色 / ${studyLevel.tiles.length} 枚棋子`);};
|
||||||
|
$('density-grid').innerHTML=[4,7,9,12].map(n=>`<button class="theme-card density-card" data-count="${n}" aria-pressed="${n===colorCount}"><div class="mini-scene"><span>${n} 色 / ${studyLevel.tiles.length} 枚棋子</span><canvas data-density="${n}" aria-label="${n}色棋盘对照"></canvas></div><strong>${n===4?'简洁':n===7?'丰富':n===9?'缤纷':'满色'} · ${n} 色</strong><small>${n===7?'建议优先体验':n===12?'观察相近色带来的复杂感':'同一布局,比较视觉密度'}</small></button>`).join('');
|
||||||
|
document.querySelectorAll('[data-count]').forEach(b=>b.onclick=()=>{colorCount=Number(b.dataset.count);document.querySelectorAll('[data-count]').forEach(x=>x.setAttribute('aria-pressed',String(Number(x.dataset.count)===colorCount)));setTheme(LEVEL_THEMES.find(t=>t.id===theme.id));reset();});
|
||||||
|
document.querySelector('.phone-top strong').textContent='测试关 01';
|
||||||
|
decorateIcons();setTheme(theme);controls();new ResizeObserver(resize).observe(canvas);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Store identities, not coordinates: re-check each move against the latest board.
|
||||||
|
export class TapQueue {
|
||||||
|
constructor(){this.ids=[];}
|
||||||
|
add(id,activeId){if(id===activeId||this.ids.includes(id))return false;this.ids.push(id);return true;}
|
||||||
|
take(tiles){while(this.ids.length){const id=this.ids.shift(),tile=tiles.find(t=>t.id===id);if(tile)return tile;}return null;}
|
||||||
|
clear(){this.ids.length=0;}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PIGMENTS } from './design-tokens.js';
|
||||||
import { PANELS, ICONS, PANEL_ATLAS, ICON_ATLAS, PANEL_SIZE, ICON_SIZE } from './ui-skin.js';
|
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>`;
|
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 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;}
|
||||||
@@ -13,7 +14,7 @@ export function decorateUI(){
|
|||||||
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('.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('.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('.board-toast,.selection-info'))name='warning';
|
||||||
if(el.matches('[data-color]'))name=['tileBlue','tilePink','tileYellow','tilePurple'][+el.dataset.color];
|
if(el.matches('[data-color]')){el.style.setProperty('--pigment',PIGMENTS[+el.dataset.color].color);if(!el.classList.contains('pigment-swatch'))el.classList.add('pigment-swatch');name='secondary';}
|
||||||
skin(el,name);
|
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');});
|
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');});
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { BoardViewport } from '../src/board-viewport.js';
|
||||||
|
import { generateLaneLevel } from '../src/campaign-generator.js';
|
||||||
|
import { Game, validateLevel, solveRotations } from '../src/core.js';
|
||||||
|
import { layout, center, hitCell } from '../src/geometry.js';
|
||||||
|
test('91 and 127 cell boards validate and their complete move sequences win',()=>{
|
||||||
|
for(const [radius,count,target] of [[5,91,42],[6,127,60]]){
|
||||||
|
const level=generateLaneLevel(606,{radius,target,axis:0});assert.equal(level.cells.length,count);assert.equal(level.tiles.length,target);assert.deepEqual(validateLevel(level),[]);
|
||||||
|
const plan=solveRotations(level);assert.ok(plan.solvable);const game=new Game(level);
|
||||||
|
for(const action of plan.actions){if(action.type==='tap')assert.equal(game.tap(action.id).status,'exit');else game.rotate(action.direction);}
|
||||||
|
assert.ok(game.won);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('pinch anchoring, panning, and hit testing use the same transformed coordinates',()=>{
|
||||||
|
const level=generateLaneLevel(606,{radius:5,target:42}),camera=new BoardViewport(),base=layout(level.cells,390,390);
|
||||||
|
camera.zoom(2,120,150,390,390);assert.equal(camera.x,-120);assert.equal(camera.y,-150);
|
||||||
|
camera.pan(30,-20,390,390);
|
||||||
|
const view=camera.apply(base);
|
||||||
|
for(const cell of level.cells){const p=center(cell,view);assert.equal(hitCell(level.cells,view,p.x,p.y),cell);}
|
||||||
|
camera.zoom(99,195,195,390,390);assert.equal(camera.scale,4);
|
||||||
|
camera.zoom(.001,195,195,390,390);assert.equal(camera.scale,1);assert.equal(camera.x,0);assert.equal(camera.y,0);
|
||||||
|
});
|
||||||
|
test('board zoom and vertical dragging keep the complete board visible after resizing',()=>{
|
||||||
|
const level=generateLaneLevel(606,{radius:5,target:42}),camera=new BoardViewport();
|
||||||
|
for(const [width,height] of [[334,620],[390,300],[334,720]]){
|
||||||
|
const base=layout(level.cells,width,height);
|
||||||
|
camera.fit(base,width,height);
|
||||||
|
camera.zoom(99,40,30,width,height);
|
||||||
|
assert.ok(camera.scale<=height/(base.bounds.bottom-base.bounds.top)+1e-9);
|
||||||
|
for(const dy of [-10000,10000]){
|
||||||
|
camera.pan(0,dy,width,height);
|
||||||
|
assert.ok(base.bounds.top*camera.scale+camera.y>=-1e-9);
|
||||||
|
assert.ok(base.bounds.bottom*camera.scale+camera.y<=height+1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { CAMPAIGN, LEVELS } from '../src/levels.js';
|
||||||
|
import { Game, available, rotateBoard, trace, random, validateLevel } from '../src/core.js';
|
||||||
|
import { inspectFlowLevel } from '../src/campaign-generator.js';
|
||||||
|
|
||||||
|
const reviewed=LEVELS.map(inspectFlowLevel);
|
||||||
|
|
||||||
|
test('48 authored levels share one campaign in the browser, JSON export and audit report',async()=>{
|
||||||
|
assert.equal(LEVELS.length,48);assert.equal(CAMPAIGN.chapterSize,6);assert.equal(CAMPAIGN.chapters.length,8);
|
||||||
|
assert.equal(new Set(LEVELS.map(l=>l.id)).size,48);
|
||||||
|
assert.deepEqual(JSON.parse(await readFile(new URL('../levels/campaign.json',import.meta.url),'utf8')),LEVELS);
|
||||||
|
const report=JSON.parse(await readFile(new URL('../levels/campaign-analysis.json',import.meta.url),'utf8'));
|
||||||
|
assert.deepEqual(report.campaign,CAMPAIGN);assert.equal(report.levels.length,48);
|
||||||
|
LEVELS.forEach((l,i)=>{
|
||||||
|
assert.deepEqual(validateLevel(l),[],l.name);
|
||||||
|
assert.equal(l.chapter,Math.floor(i/6)+1);assert.equal(l.chapterName,CAMPAIGN.chapters[Math.floor(i/6)]);
|
||||||
|
assert.equal(l.moveLimit,l.tiles.length);
|
||||||
|
assert.equal(l.pacing.cognitiveDifficulty,reviewed[i].analysis.score);
|
||||||
|
assert.equal(report.levels[i].difficulty,reviewed[i].analysis.score);
|
||||||
|
assert.equal(report.levels[i].tiles,l.tiles.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every campaign solution finishes, avoids hidden blockers, and needs at most one turn between removals',()=>{
|
||||||
|
reviewed.forEach((q,i)=>{
|
||||||
|
const label=LEVELS[i].name;
|
||||||
|
assert.ok(q.analysis.solvable&&!q.analysis.exhausted&&q.won,label);
|
||||||
|
assert.equal(q.independentLoops,0,`${label}: solitary piece must always have an exit in all six orientations`);
|
||||||
|
assert.equal(q.hiddenBlockerStates,0,`${label}: blocked routes must identify a piece on that route`);
|
||||||
|
assert.ok(q.maxDryRotations<=1&&q.maxUnlockDistance<=1,label);
|
||||||
|
assert.ok(q.analysis.free>=2,`${label}: at least two immediate opening choices`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('first level is spacious and every remaining tile can leave after arbitrary free rotations',()=>{
|
||||||
|
const l=LEVELS[0];assert.ok(l.cells.length>=37);assert.ok(l.tiles.length>=12);
|
||||||
|
for(let orientation=0;orientation<6;orientation++)for(const reverse of [false,true]){
|
||||||
|
const g=new Game(rotateBoard(l,orientation)),order=g.tiles.map(t=>t.id);
|
||||||
|
if(reverse)order.reverse();
|
||||||
|
for(const id of order){
|
||||||
|
for(let n=0;n<5;n++)assert.equal(g.rotate(reverse?-1:1).status,'rotated');
|
||||||
|
assert.equal(available(g.level,g.tiles).length,g.tiles.length,'No mysterious final-piece lock or forced removal order');
|
||||||
|
assert.equal(g.moves,g.tiles.length);assert.equal(g.tap(id).status,'exit');
|
||||||
|
}
|
||||||
|
assert.ok(g.won);assert.equal(g.moves,0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exploring orientations and alternative safe removal orders cannot soft-lock the campaign',()=>{
|
||||||
|
for(const l of LEVELS)for(let run=0;run<4;run++){
|
||||||
|
const rng=random(l.seed+run*997),g=new Game(l);
|
||||||
|
while(g.tiles.length){
|
||||||
|
const budget=g.moves;
|
||||||
|
for(let i=0,n=Math.floor(rng()*6);i<n;i++)assert.equal(g.rotate(rng()<.5?-1:1).status,'rotated');
|
||||||
|
assert.equal(g.moves,budget);
|
||||||
|
const blocked=g.tiles.find(t=>trace(g.level,t,g.tiles).status!=='exit');
|
||||||
|
if(blocked){g.tap(blocked.id);assert.equal(g.moves,budget);assert.equal(g.lost,false);}
|
||||||
|
let choices=available(g.level,g.tiles);
|
||||||
|
for(let turns=0;!choices.length&&turns<5;turns++){g.rotate(1);choices=available(g.level,g.tiles);}
|
||||||
|
assert.ok(choices.length,`${l.name}: exploration must retain a reachable exit`);
|
||||||
|
assert.equal(g.tap(choices[Math.floor(rng()*choices.length)].id).status,'exit');
|
||||||
|
assert.equal(g.moves,g.tiles.length);
|
||||||
|
}
|
||||||
|
assert.ok(g.won,l.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each chapter releases cognitive pressure after its small challenge while the larger finale lasts longer',()=>{
|
||||||
|
for(let chapter=0;chapter<8;chapter++){
|
||||||
|
const block=LEVELS.slice(chapter*6,chapter*6+6),q=reviewed.slice(chapter*6,chapter*6+6).map(r=>r.analysis);
|
||||||
|
assert.deepEqual(block.map(l=>l.beat),['进入','换路','展开','小挑战','释放','长局回报']);
|
||||||
|
assert.ok(q[1].rotations>0&&q[3].rotations>0,'small boards retain meaningful route changes');
|
||||||
|
assert.ok(q[4].score<=q[3].score-12&&q[5].score<=q[3].score-10,'challenge is followed by a clear release');
|
||||||
|
assert.ok(block[5].cells.length>block[3].cells.length&&block[5].tiles.length>block[3].tiles.length);
|
||||||
|
assert.ok(q[5].workload.estimatedSeconds>q[3].workload.estimatedSeconds,'length is a separate structural estimate');
|
||||||
|
assert.equal(q[5].rotations,0,'large finales allow a continuous sequence of removals');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -3,11 +3,15 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
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 { 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 { pathPoints, segmentHexHit } from '../src/path-math.js';
|
||||||
import { LEVELS } from '../src/levels.js';
|
|
||||||
import { center, hitCell, layout, pointAlong, routePoints } from '../src/geometry.js';
|
import { center, hitCell, layout, pointAlong, routePoints } from '../src/geometry.js';
|
||||||
const straight=[3,4,5,0,1,2];
|
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 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});
|
const tile=(id,q,r,dir=0)=>({id,q,r,dir,color:0});
|
||||||
|
const blockedFixture=()=>level(hexagon(2),[tile('moving',1,1,0),tile('beside',0,0,0)]);
|
||||||
|
const rotatingLoopFixture=()=>{
|
||||||
|
const bends=[{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]}];
|
||||||
|
return level(hexagon(1).map(c=>bends.find(b=>b.q===c.q&&b.r===c.r)??c),[tile('a',0,0,1)],1);
|
||||||
|
};
|
||||||
test('bends follow the paired exit, never a crossing branch',()=>{
|
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 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});
|
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});
|
||||||
@@ -15,7 +19,7 @@ test('bends follow the paired exit, never a crossing branch',()=>{
|
|||||||
test('occupied cell blocks even when its own arrow points elsewhere',()=>{
|
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)]);
|
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']);
|
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);
|
const g=new Game(l);const original=clone(g.tiles);assert.equal(g.tap('a').status,'blocked');assert.equal(g.moves,10);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);
|
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',()=>{
|
test('closed loop returns to the starting occupied tile and is unsolvable',()=>{
|
||||||
@@ -28,17 +32,20 @@ test('mutual blocking reaches a fixed point; move shortage is separate',()=>{
|
|||||||
const easy=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1)],1);
|
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);
|
assert.equal(solve(easy).stuck.length,0);assert.equal(solve(easy).solvable,false);
|
||||||
});
|
});
|
||||||
test('all 24 campaign levels validate, solve and increase in score',()=>{
|
test('a solver plan remains executable with exactly one move per successful removal',()=>{
|
||||||
let previous=-1;
|
const l=rotatingLoopFixture(),a=analyze(l),g=new Game(l);
|
||||||
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);}
|
assert.equal(a.solvable,true);assert.equal(a.rotations,1);assert.equal(a.minimumMoves,1);assert.equal(a.minimumActions,2);
|
||||||
|
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.equal(g.moves,0);
|
||||||
});
|
});
|
||||||
test('100 generated seeds are deterministic and solvable',()=>{
|
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);}
|
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));
|
assert.deepEqual(generateLevel(1234),generateLevel(1234));
|
||||||
});
|
});
|
||||||
test('imports reject duplicate positions, invalid matching, bad IDs and directions',()=>{
|
test('imports reject duplicate positions, invalid matching, bad IDs and directions',()=>{
|
||||||
const good=clone(LEVELS[0]);assert.deepEqual(validateLevel(good),[]);
|
const good=rotatingLoopFixture();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);}
|
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=12,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);}
|
||||||
|
for(let color=0;color<12;color++){const l=clone(good);l.tiles[0].color=color;assert.deepEqual(validateLevel(l),[]);}
|
||||||
assert.ok(validateLevel(null).length);assert.ok(validateLevel({version:1,cells:[null],tiles:[null]}).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',()=>{
|
test('empty boards are not publishable wins and report a finite score',()=>{
|
||||||
@@ -47,26 +54,35 @@ test('empty boards are not publishable wins and report a finite score',()=>{
|
|||||||
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('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',()=>{
|
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 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));}
|
const l=blockedFixture(),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',()=>{
|
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);
|
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',()=>{
|
test('difficulty ignores spare moves while custom removal shortages still fail',()=>{
|
||||||
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);
|
const l=blockedFixture(),before=analyze(l);l.moveLimit=1;const a=analyze(l);
|
||||||
|
assert.equal(a.solvable,false);assert.equal(a.reachable,true);assert.equal(a.score,before.score);assert.deepEqual(a.parts,before.parts);assert.ok(a.score>=0&&a.score<=100);
|
||||||
|
assert.equal(a.sweptOnlyBlockers,1);assert.equal(a.parts.sweptBlockers,6);
|
||||||
|
});
|
||||||
|
test('board area and piece count add workload without adding intrinsic difficulty',()=>{
|
||||||
|
const small=analyze(level(hexagon(1),[tile('a',0,0)])),large=analyze(level(hexagon(3),[tile('a',0,0)]));
|
||||||
|
assert.equal(small.score,0);assert.equal(large.score,small.score);assert.ok(large.workload.routeCells>small.workload.routeCells);
|
||||||
|
const outward=[tile('a',0,-1,0),tile('b',1,-1,1),tile('c',1,0,2),tile('d',0,1,3),tile('e',-1,1,4),tile('f',-1,0,5)];
|
||||||
|
const many=analyze(level(hexagon(1),outward));
|
||||||
|
assert.equal(many.score,small.score);assert.equal(many.freeRatio,1);assert.equal(many.workload.minimumActions,6);assert.ok(many.workload.estimatedSeconds>small.workload.estimatedSeconds);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('six board rotations restore tracks; tokens and arrows stay fixed',()=>{
|
test('six board rotations restore tracks; tokens and arrows stay fixed',()=>{
|
||||||
const l=clone(LEVELS[0]);assert.ok(canRotate(l));
|
const l=rotatingLoopFixture();assert.ok(canRotate(l));
|
||||||
assert.deepEqual(rotateBoard(l,6),l);
|
assert.deepEqual(rotateBoard(l,6),l);
|
||||||
let board=l;for(let i=0;i<6;i++)board=rotateBoard(board);
|
let board=l;for(let i=0;i<6;i++)board=rotateBoard(board);
|
||||||
assert.deepEqual(board,l);
|
assert.deepEqual(board,l);
|
||||||
const g=new Game(l),original=clone(g.tiles),before=clone(g.level.cells);
|
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.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);
|
||||||
assert.ok(g.undo());assert.equal(g.orientation,0);assert.deepEqual(g.level.cells,before);assert.equal(g.moves,l.moveLimit);
|
assert.ok(g.undo());assert.equal(g.orientation,0);assert.equal(g.rotations,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',()=>{
|
test('a stuck orientation can be rescued by rotating and must not immediately lose',()=>{
|
||||||
const l=LEVELS[0],g=new Game(l);
|
const l=rotatingLoopFixture(),g=new Game(l);
|
||||||
while(available(g.level,g.tiles).length)g.tap(available(g.level,g.tiles)[0].id);
|
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);
|
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 result=solveRotations({...g.level,tiles:g.tiles,moveLimit:g.moves});assert.ok(result.solvable);assert.equal(result.actions[0].type,'rotate');
|
||||||
@@ -77,15 +93,18 @@ test('swept collision blocks a neighboring piece even when the center-line visit
|
|||||||
const centerLine=trace(l,l.tiles[0],l.tiles,true);assert.equal(centerLine.status,'exit');assert.deepEqual(centerLine.blockers,[]);
|
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 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 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);
|
const g=new Game(l),before=clone(g.tiles);g.tap('moving');assert.deepEqual(g.tiles,before);assert.equal(g.moves,l.moveLimit);
|
||||||
});
|
});
|
||||||
test('swept segment detection cannot tunnel through a collider between frames',()=>{
|
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);
|
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);
|
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',()=>{
|
test('unlimited clockwise and counterclockwise observation preserves the removal budget',()=>{
|
||||||
const l=clone(LEVELS[0]),a=solveRotations(l);assert.ok(a.rotations>0);
|
const l=rotatingLoopFixture(),g=new Game(l);
|
||||||
l.moveLimit=a.minimumMoves-1;const tooShort=solveRotations(l);assert.equal(tooShort.reachable,true);assert.equal(tooShort.solvable,false);
|
for(let i=0;i<120;i++)assert.equal(g.rotate(i%2?1:-1).status,'rotated');
|
||||||
|
assert.equal(g.moves,1);assert.equal(g.rotations,120);assert.equal(g.orientation,0);assert.equal(g.lost,false);
|
||||||
|
assert.equal(g.rotate(0).status,'invalid-direction');assert.equal(g.history.length,120);
|
||||||
|
assert.ok(g.undo());assert.equal(g.moves,1);assert.equal(g.rotations,119);assert.equal(g.orientation,5);
|
||||||
});
|
});
|
||||||
test('irregular boards retain classic play and do not rotate tiles off the base',()=>{
|
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);
|
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);
|
||||||
@@ -96,11 +115,14 @@ test('exit animation cannot pass through another piece beyond an irregular board
|
|||||||
const route=trace(l,l.tiles[0]);assert.equal(route.status,'blocked');assert.equal(route.blocker,'b');assert.ok(route.collision);
|
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',()=>{
|
test('loop probes are free and undo restores probes, rotations, and successful removals',()=>{
|
||||||
const g=new Game(LEVELS[0]);while(available(g.level,g.tiles).length)g.tap(available(g.level,g.tiles)[0].id);
|
const g=new Game(rotatingLoopFixture()),initial=clone(g.level);
|
||||||
assert.equal(g.tiles.length,1);const last=g.tiles[0].id;
|
for(let i=0;i<4;i++)assert.equal(g.tap('a').status,'loop');
|
||||||
assert.equal(trace(g.level,g.tiles[0],g.tiles).status,'loop');
|
assert.equal(g.moves,1);assert.equal(g.mistakes,4);assert.equal(g.lost,false);
|
||||||
assert.equal(g.rotate(1).status,'rotated');assert.equal(g.tap(last).status,'exit');assert.ok(g.won);
|
assert.ok(g.undo());assert.equal(g.mistakes,3);assert.equal(g.moves,1);
|
||||||
|
assert.equal(g.rotate(1).status,'rotated');assert.equal(g.tap('a').status,'exit');assert.ok(g.won);assert.equal(g.moves,0);
|
||||||
|
assert.ok(g.undo());assert.equal(g.won,false);assert.equal(g.moves,1);assert.equal(g.orientation,1);assert.equal(g.rotations,1);assert.equal(g.mistakes,3);
|
||||||
|
assert.ok(g.undo());assert.equal(g.orientation,0);assert.equal(g.rotations,0);assert.deepEqual(g.level,initial);assert.equal(g.mistakes,3);
|
||||||
});
|
});
|
||||||
test('moving tile does not collide with its vacated starting cell',()=>{
|
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 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)]);
|
||||||
@@ -112,12 +134,12 @@ test('collision animation pauses at contact, recoils, and returns without moving
|
|||||||
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.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);
|
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 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');
|
const g=new Game(blockedFixture()),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);
|
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()=>{
|
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 {drawScene}=await import('../src/renderer.js');const l=blockedFixture(),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 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);
|
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));
|
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));
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { Game, solve, validateLevel } from '../src/core.js';
|
||||||
|
import { makeLabLevel } from '../labs/hex-video/fixture.js';
|
||||||
|
|
||||||
|
test('video lab fixture validates and can complete through real game actions', () => {
|
||||||
|
const level = makeLabLevel();
|
||||||
|
assert.deepEqual(validateLevel(level), []);
|
||||||
|
assert.equal(level.tiles.length, 18);
|
||||||
|
const solution = solve(level), game = new Game(level);
|
||||||
|
assert.equal(solution.solvable, true);
|
||||||
|
const blocked = game.tap('lab-0');
|
||||||
|
assert.equal(blocked.status, 'blocked');
|
||||||
|
assert.equal(game.moves, level.moveLimit);
|
||||||
|
for (const id of solution.order) assert.equal(game.tap(id).status, 'exit');
|
||||||
|
assert.equal(game.won, true);
|
||||||
|
assert.equal(game.moves, 0);
|
||||||
|
assert.equal(game.undo(), true);
|
||||||
|
assert.equal(game.won, false);
|
||||||
|
assert.equal(game.tiles.length, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { TapQueue } from '../src/tap-queue.js';
|
||||||
|
import { Game, available } from '../src/core.js';
|
||||||
|
import { LEVELS } from '../src/levels.js';
|
||||||
|
test('rapid taps retain order, ignore duplicate/active pieces, and discard stale IDs',()=>{
|
||||||
|
const queue=new TapQueue();
|
||||||
|
assert.equal(queue.add('a','a'),false);
|
||||||
|
queue.add('b');queue.add('gone');queue.add('c');assert.equal(queue.add('b'),false);
|
||||||
|
const tiles=[{id:'c'},{id:'b'}];
|
||||||
|
assert.equal(queue.take(tiles).id,'b');assert.equal(queue.take(tiles).id,'c');assert.equal(queue.take(tiles),null);
|
||||||
|
queue.add('b');queue.clear();assert.equal(queue.take(tiles),null);
|
||||||
|
});
|
||||||
|
test('buffered removals are evaluated in order against the updated game, with undo intact',()=>{
|
||||||
|
const game=new Game(LEVELS[0]),queue=new TapQueue();
|
||||||
|
const free=available(game.level,game.tiles).slice(0,3);assert.ok(free.length>=2);
|
||||||
|
for(const tile of free)queue.add(tile.id);
|
||||||
|
const start=game.moves;
|
||||||
|
while(queue.ids.length){const tile=queue.take(game.tiles);assert.equal(game.tap(tile.id).status,'exit');}
|
||||||
|
assert.equal(game.moves,start-free.length);
|
||||||
|
assert.ok(game.undo());assert.equal(game.moves,start-free.length+1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { PIGMENTS, LEVEL_THEMES, contrast, themeForScore } from '../src/design-tokens.js';
|
||||||
|
import { Game, clone, validateLevel, available } from '../src/core.js';
|
||||||
|
import { LEVELS } from '../src/levels.js';
|
||||||
|
import { drawExit, drawScene, CanvasPainter, exitGeometry } from '../src/renderer.js';
|
||||||
|
import { layout } from '../src/geometry.js';
|
||||||
|
test('new pigments import without changing route solutions; out-of-range values are rejected',()=>{
|
||||||
|
for(let color=0;color<12;color++){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.deepEqual(validateLevel(level),[]);assert.deepEqual(available(level).map(t=>t.id),available(LEVELS[0]).map(t=>t.id));}
|
||||||
|
for(const color of [-1,12,1.5]){const level=clone(LEVELS[0]);level.tiles[0].color=color;assert.ok(validateLevel(level).length);}
|
||||||
|
});
|
||||||
|
test('scene text and all directional arrows remain readable across curated themes',()=>{
|
||||||
|
for(const p of PIGMENTS)assert.ok(contrast(p.arrow,p.color)>=3,p.name);
|
||||||
|
for(const t of LEVEL_THEMES){assert.ok(contrast(t.ink,t.background)>=4.5,t.name);assert.ok(t.palette.length>=3&&t.palette.length<=4);for(const i of t.palette)assert.ok(PIGMENTS[i]);}
|
||||||
|
assert.deepEqual([29,30,49,50,69,70].map(n=>themeForScore(n).id),['lagoon','apricot','apricot','lavender','lavender','rose']);
|
||||||
|
});
|
||||||
|
test('theme assignments are stable after removal and undo; all new colors fall back from the four-frame atlas',()=>{
|
||||||
|
const game=new Game(LEVELS[0]),view=layout(game.level.cells,390,390),theme=LEVEL_THEMES[1];
|
||||||
|
const capture=()=>{const sprites=[];drawScene({polygon(){},line(){},circle(){},sprite(i,x,y){sprites.push([i,x,y]);return true;}},game.level,game.tiles,view,{theme});return sprites;};
|
||||||
|
const before=capture();game.tap(available(game.level,game.tiles)[0].id);const after=capture();for(const entry of after)assert.ok(before.some(b=>JSON.stringify(b)===JSON.stringify(entry)));game.undo();assert.deepEqual(capture(),before);
|
||||||
|
const painter=new CanvasPainter({});painter.atlas={complete:true,naturalWidth:1293};for(let i=4;i<12;i++)assert.equal(painter.sprite(i,0,0,10),false);
|
||||||
|
});
|
||||||
|
test('exit fragments stay near the actual board port, finish cleanly, and respect reduced motion',()=>{
|
||||||
|
const game=new Game(LEVELS[0]),route=game.tap(available(game.level,game.tiles)[0].id),view=layout(game.level.cells,390,390);
|
||||||
|
const shapes=[],p={polygon(pts,c){shapes.push({pts,c});},line(){},circle(){}};
|
||||||
|
const edge=exitGeometry(route,view).edge;
|
||||||
|
drawExit(p,route,view,.95,{...route.tile,color:10});assert.equal(shapes.length,6);
|
||||||
|
for(const s of shapes){assert.equal(s.c,PIGMENTS[10].color);for(const point of s.pts)assert.ok(Math.hypot(point.x-edge.x,point.y-edge.y)<view.size);}
|
||||||
|
shapes.length=0;drawExit(p,route,view,1,route.tile);assert.equal(shapes.length,0);
|
||||||
|
drawExit(p,route,view,.95,route.tile,true);assert.equal(shapes.length,0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edge exits are brisk and longer routes preserve a bounded readable travel phase',async()=>{
|
||||||
|
const {exitTiming}=await import('../src/exit-animation.js');
|
||||||
|
const edge={status:'exit',steps:[{q:0,r:0,enter:null,exit:0}],end:{q:0,r:-1}};
|
||||||
|
const short=exitTiming(edge);
|
||||||
|
const long=exitTiming({...edge,steps:[edge.steps[0],{q:0,r:-1,enter:3,exit:0},{q:0,r:-2,enter:3,exit:0}]});
|
||||||
|
assert.equal(short.duration,320);
|
||||||
|
assert.ok(long.duration>short.duration&&long.duration<=980);
|
||||||
|
for(const timing of [short,long])assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('motion ramps gently at both ends, never reverses or overshoots',async()=>{
|
||||||
|
const {smoothMotion}=await import('../src/exit-animation.js');
|
||||||
|
const positions=Array.from({length:101},(_,i)=>smoothMotion(i/100));
|
||||||
|
const steps=positions.slice(1).map((x,i)=>x-positions[i]);
|
||||||
|
assert.equal(positions[0],0);assert.equal(positions.at(-1),1);
|
||||||
|
assert.ok(steps.every(d=>d>=0));
|
||||||
|
assert.ok(steps[0]<steps[49]/10&&steps.at(-1)<steps[49]/10);
|
||||||
|
assert.equal(smoothMotion(-1),0);assert.equal(smoothMotion(2),1);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
test('long routes regain original pacing while edge exits and the finish stay brisk',async()=>{
|
||||||
|
const {exitTiming}=await import('../src/exit-animation.js');
|
||||||
|
const route=n=>({status:'exit',steps:Array.from({length:n},(_,i)=>({q:0,r:-i,enter:i?3:null,exit:0}))});
|
||||||
|
assert.equal(exitTiming(route(1)).duration,320);
|
||||||
|
for(const [cells,expected] of [[5,975],[10,1400],[17,1700]]){
|
||||||
|
const timing=exitTiming(route(cells));
|
||||||
|
assert.equal(timing.duration,expected);
|
||||||
|
assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
|
||||||
|
}
|
||||||
|
const durations=Array.from({length:17},(_,i)=>exitTiming(route(i+1)).duration);
|
||||||
|
assert.ok(durations.every((t,i)=>i===0||t>=durations[i-1]));
|
||||||
|
});
|
||||||