feat: expand campaign and refine puzzle visuals and controls

This commit is contained in:
cabinw
2026-09-09 11:02:45 +08:00
parent 9f8990b579
commit 70b021d4cc
66 changed files with 41108 additions and 10553 deletions
+40
View File
@@ -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` 也会同步生成。
File diff suppressed because one or more lines are too long
+356
View File
@@ -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 名称属于待落实规范,不能称为现有运行时的唯一数值来源。
![视频采样色板](reference/color-study/palette.svg)
## 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;无法从视频确定的素材色、字体、遮罩参数、声音与时长均已明确区分。
+21
View File
@@ -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;
}
+34
View File
@@ -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>
File diff suppressed because one or more lines are too long
+173
View File
@@ -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);
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

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))
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB