feat: version campaigns and document original puzzle design
Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
This commit is contained in:
+3
-3
File diff suppressed because one or more lines are too long
@@ -2,7 +2,9 @@
|
||||
|
||||
一款竖屏六角轨道解谜游戏:观察路线,点击箭头棋子让它离场;免费旋转底盘和尝试路线,在清楚的反馈中逐步清空棋盘。
|
||||
|
||||
项目使用原生 JavaScript ES modules 和 Canvas,无第三方运行依赖,无在线字体或 CDN。主线按 48 关、8 章 × 6 拍制作,并提供关卡工坊、求解器、确定性关卡生成器,以及 Cocos Creator 适配示例。
|
||||
项目使用原生 JavaScript ES modules 和 Canvas,无第三方运行依赖,无在线字体或 CDN。选关窗口提供 v0.1 最初 24 关、v0.2 直线较多的 48 关与 v0.3 当前弯轨 48 关;默认 v0.3,按 8 章 × 6 拍制作。另有关卡工坊、求解器、确定性关卡生成器,以及 Cocos Creator 适配示例。
|
||||
|
||||
后续关卡优化的原始设计参照见 [v0.1 · 最初 24 关设计理念](docs/v0.1-design-philosophy.md):还原必要旋转、空间让路与分阶段解开轨道的结构,并区分历史规则、实际布局和本次设计归纳。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -30,9 +32,13 @@ npm run dev
|
||||
|
||||
主线每章按“进入 → 换路 → 展开 → 小挑战 → 释放 → 长局回报”安排,不按难度分数单调排序,也不要求每关必须旋转。早期就可使用 37 格棋盘,后期舒缓关使用 61 格延长熟悉操作的体验;19 格小棋盘也可承担局部挑战。棋盘规模与认知难度分别记录。
|
||||
|
||||
9 月 9 日版的 48 关以真正的弯轨消除为主:每关参考解答至少 65% 的成功离场棋子经过弯道,且不少于 3 枚。直线只作少量节奏调剂,不安排纯直出关,也不用空格上的装饰曲线充数。当前单关最多 25 枚棋子,后期长局增加的是沿弯轨观察与解开的实际内容。
|
||||
|
||||
第 1 关为 37 格、8 枚棋子,开局全部可离场,每枚参考路径恰好转弯一次,无需旋转;“全部可离场”不表示任意朝向都相同。前 7 关通过 `lesson` 字段依次学习箭头起步与弯轨转向、旋转换路而棋子箭头不动、交叉不换轨、阻挡先后、舒缓的一两弯、大盘连续解路径,以及完整路线观察。
|
||||
|
||||
主线发布要求包括完整可解、每枚棋子在六个朝向下的独立路线无闭环,以及参考解答中的阻挡可解释、旋转后有明确进展;这些门槛不代表玩家任意旋转或点击序列都不会遇阻。制作方向见 [玩法方向](docs/gameplay-direction.md),逐关生成与验收记录见 [主线审阅报告](docs/campaign-review.md),验证结果以实际生成报告和测试输出为准。
|
||||
|
||||
所有关卡可自由选择,通关进度保存在当前浏览器。音效使用 Web Audio 合成,首次交互后启用,可在设置中关闭并保存偏好。
|
||||
所有关卡可自由选择。在左上角选关窗口切换 v0.1 / v0.2 / v0.3,分别保存通关进度与上次选关;对应旧存档会自动迁移,旧数据继续保留。历史布局原样恢复,三版共用当前免费旋转与失败试路规则,详见 [关卡版本与来源](docs/campaign-versions.md)。以上弯轨比例与节奏门槛针对 v0.3。音效使用 Web Audio 合成,首次交互后启用,可在设置中关闭并保存偏好。
|
||||
|
||||
## 关卡工坊
|
||||
|
||||
@@ -51,7 +57,8 @@ npm run dev
|
||||
| `npm run dev` | 启动本地开发服务,默认端口 4173 |
|
||||
| `npm test` | 执行 Node 内置测试套件 |
|
||||
| `npm run build` | 生成设计资源并输出静态站点到 `dist/` |
|
||||
| `npm run levels` | 使用固定种子重新生成 48 关,同步关卡 JSON、共享模块和逐关验收报告 |
|
||||
| `npm run levels` | 读取主线种子清单重新生成 48 关,同步关卡 JSON、共享模块和实际路径验收报告 |
|
||||
| `npm run levels:versions` | 校验历史关卡归档并生成 v0.1 / v0.2 的浏览器模块 |
|
||||
| `npm run design` | 生成颜色 token、CSS、JSON、PNG、对比度报告和设计指南 HTML |
|
||||
| `node scripts/serve.mjs --dist` | 本地预览构建产物 |
|
||||
|
||||
@@ -59,6 +66,8 @@ npm run dev
|
||||
|
||||
修改语义颜色时编辑 `src/design-tokens.js`,然后运行 `npm run design` 并检查生成资源的差异。修改关卡生成逻辑后运行 `npm run levels` 和 `npm test`,确保关卡数据同步且全部可解。
|
||||
|
||||
主线布局来源为 `levels/campaign-seeds.json`,包含逐关种子和生成参数;不要直接编辑派生关卡。`levels/campaign-analysis.json` 的 `pathUsage` 记录参考解答中真正成功走过的弯轨/直线路径、转弯数量与逐棋子明细,不能用棋盘弯线总数替代。报告仍是结构与回放证据,不包含真人体验数据。
|
||||
|
||||
## 项目结构
|
||||
|
||||
| 路径 | 职责 |
|
||||
@@ -72,7 +81,9 @@ npm run dev
|
||||
| `src/collision-animation.js` | 碰撞反馈动画 |
|
||||
| `src/sprites.js`、`src/ui-skin.js` | 棋子与 UI 图集裁切、共享皮肤定义 |
|
||||
| `src/design-tokens.js`、`design/` | 语义颜色源与生成的设计资源 |
|
||||
| `levels/campaign.json`、`src/levels.js` | 主线关卡 JSON 与浏览器模块 |
|
||||
| `levels/campaign-seeds.json` | 48 关种子与生成参数的制作来源 |
|
||||
| `levels/campaign.json`、`src/levels.js` | 根据种子清单生成的主线 JSON 与浏览器模块 |
|
||||
| `levels/campaign-analysis.json` | 逐关回放、真实成功路径 `pathUsage` 与结构指标 |
|
||||
| `assets/` | PNG 图集、历史素材与生成提示词 |
|
||||
| `tests/`、`scripts/` | 自动测试、开发服务和生成脚本 |
|
||||
| `cocos/` | Cocos Creator 棋盘及 UI 适配示例 |
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<!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>
|
||||
<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"><link rel="stylesheet" href="design/notifications.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>
|
||||
<div class="board-size-control"><label>棋盘规模 <select id="board-size"><option value="regular">弯曲小盘 · 12 枚棋子</option><option value="5" selected>边长 6 · 91 格 · 24 枚棋子</option><option value="6">边长 7 · 127 格 · 32 枚棋子</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="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><div class="notice-slot"><div class="game-notice visible" id="phone-status" role="status" aria-live="polite" aria-atomic="true">点击棋子,沿轨道出发</div></div><canvas id="preview" aria-label="可点击棋子的真实规则演示棋盘"></canvas><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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/* Version selection uses the existing semantic input and text roles. */
|
||||
.campaign-picker{margin:0 0 12px;font-size:13px}
|
||||
.campaign-picker select{appearance:auto;cursor:pointer;min-height:44px;padding:10px 12px;font-size:14px}
|
||||
.campaign-description{margin:0 0 16px}
|
||||
.campaign-description strong{color:var(--color-text-primary)}
|
||||
.campaign-progress-note{display:block;margin-top:5px}
|
||||
.level-dialog-grid .level-btn small{font-size:10px}
|
||||
@@ -93,3 +93,25 @@ body:not(.editing) .rotation-controls{display:flex;position:absolute;left:0;righ
|
||||
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}
|
||||
|
||||
/* Expanded foldables, tablets and desktop windows share a height-aware stage. */
|
||||
@media(min-width:600px){
|
||||
body:not(.editing){padding:16px}
|
||||
body:not(.editing) .game-shell{width:min(100%,960px,calc(100dvh + 80px));height:calc(100dvh - 32px);max-height:1200px;padding:12px 20px 16px;margin:auto}
|
||||
body:not(.editing) .game-column{grid-template-rows:48px 44px minmax(0,1fr)}
|
||||
body:not(.editing) .game-top{top:14px;left:28px;right:28px}
|
||||
body:not(.editing) .game-card{grid-template-rows:minmax(0,1fr) 72px}
|
||||
body:not(.editing) .rotation-controls{bottom:88px;left:4%;right:4%;justify-content:space-between}
|
||||
body:not(.editing) .board-tools>button{justify-self:center;width:96px}
|
||||
body:not(.editing) .level-ribbon{font-size:28px}
|
||||
}
|
||||
@media(min-width:600px) and (max-height:600px){
|
||||
body:not(.editing){padding:6px}
|
||||
body:not(.editing) .game-shell{height:calc(100dvh - 12px);padding:4px 12px}
|
||||
body:not(.editing) .game-column{grid-template-rows:40px 32px minmax(0,1fr)}
|
||||
body:not(.editing) .game-top{top:4px}
|
||||
body:not(.editing) .game-card{grid-template-rows:minmax(0,1fr) 56px}
|
||||
body:not(.editing) .rotation-controls{bottom:66px;left:0;right:0}
|
||||
body:not(.editing) .rotate-button>.ui-icon{width:46px;height:46px}
|
||||
}
|
||||
body:not(.editing) .game-card{align-self:stretch}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/* A reserved status row keeps notifications stable and clear of the board. */
|
||||
.notice-slot{grid-column:1/-1;display:flex;align-items:center;justify-content:center;min-width:0;padding:3px 12px 7px;pointer-events:none}
|
||||
.game-notice{max-width:480px;width:fit-content;display:flex;align-items:center;gap:9px;padding:9px 14px;border:1px solid #52697e24;border-radius:14px;background:#f7faf7ed;color:#334c61;box-shadow:0 3px 10px #273b5008;font-size:12px;font-weight:500;line-height:1.5;text-align:left;overflow-wrap:anywhere;opacity:0;transform:translateY(-4px);transition:opacity .18s ease,transform .18s ease}
|
||||
.game-notice::before{content:'i';display:grid;place-items:center;flex:0 0 18px;width:18px;height:18px;border-radius:6px;background:#e0e9ee;color:#435f78;font-size:12px;font-weight:750;font-family:Georgia,serif}
|
||||
.game-notice.visible{opacity:1;transform:translateY(0)}
|
||||
.game-notice:empty{visibility:hidden}
|
||||
body:not(.editing) .game-column{grid-template-rows:44px 44px 64px minmax(0,1fr)}
|
||||
body:not(.editing) .notice-slot{grid-row:3;align-self:stretch}
|
||||
body:not(.editing) .game-card{grid-row:4}
|
||||
.editing .notice-slot{grid-column:1/-1;min-height:64px}
|
||||
.phone{grid-template-rows:44px 40px 64px minmax(0,1fr) 60px}
|
||||
.phone .notice-slot{grid-row:3}
|
||||
.phone canvas{grid-row:4}
|
||||
.phone .phone-tools button{grid-row:5}
|
||||
@media(min-width:600px){body:not(.editing) .game-column{grid-template-rows:48px 44px 64px minmax(0,1fr)}.game-notice{font-size:13px}}
|
||||
@media(min-width:600px) and (max-height:600px){body:not(.editing) .game-column{grid-template-rows:40px 32px 56px minmax(0,1fr)}.game-notice{padding:6px 11px;font-size:12px}}
|
||||
@media(prefers-reduced-motion:reduce){.game-notice{transition:none;transform:none}}
|
||||
@@ -24,3 +24,13 @@
|
||||
.phone .rotations button>span{width:56px;height:56px}.phone .rotations b{font-size:13px}
|
||||
.study-dialog{width:min(360px,calc(100% - 32px));max-height:80dvh;overflow:auto;border:0;border-radius:24px;padding:24px;background:#F7F6F2;color:#273B50}
|
||||
.study-dialog::backdrop{background:#273B507A;backdrop-filter:blur(5px)}.study-dialog h2{font-size:22px}.study-dialog button{min-height:44px;padding:10px 15px;border-radius:10px;background:#E4EAF0;color:#273B50}.study-levels{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:16px 0}.study-dialog label{display:block;margin:20px 0;font-size:14px}
|
||||
|
||||
/* Let the interactive board demonstrate the current device's usable size. */
|
||||
@media(min-width:651px){
|
||||
.studio{grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:32px;align-items:start}
|
||||
.studio .phone{grid-column:1/-1;grid-row:1;width:min(100%,960px,calc(100dvh + 80px));height:clamp(420px,calc(100dvh - 48px),1100px);justify-self:center;padding:12px 20px 16px;border-radius:28px}
|
||||
.studio .theme-story{grid-column:1;grid-row:2}
|
||||
.studio .motion-story{grid-column:2;grid-row:2}
|
||||
.phone .rotations{left:6%;right:6%;justify-content:space-between}
|
||||
.phone .phone-tools button{justify-self:center;width:96px}
|
||||
}
|
||||
|
||||
+3
-2
@@ -196,9 +196,10 @@
|
||||
"modalMs": 420,
|
||||
"starStartMs": 300,
|
||||
"starGapMs": 190,
|
||||
"collisionMs": 650,
|
||||
"collisionMs": 400,
|
||||
"rotationMs": 240,
|
||||
"exitMsPerUnit": 60,
|
||||
"exitMsPerUnit": 120,
|
||||
"exitRampMs": 80,
|
||||
"exitMinTravelMs": 140,
|
||||
"exitMaxTravelMs": 800,
|
||||
"exitEffectMs": 180,
|
||||
|
||||
+63
-51
@@ -1,58 +1,70 @@
|
||||
# 主线关卡验收与节奏表
|
||||
|
||||
自动生成:`npm run levels`。共 48 关,8 章,每章顺序为进入、换路、展开、小挑战、释放、长局回报。认知难度与棋盘规模分别记录。
|
||||
自动生成:`npm run levels`;固定布局参数见 `levels/campaign-seeds.json`。共 48 关,8 章,每章顺序为进入、换路、展开、小挑战、释放、长局回报。认知难度与棋盘规模分别记录。
|
||||
|
||||
全部关卡:可解且回放通关;每枚棋子在全部六朝向独立路线无闭环;参考解答无路线格外擦碰阻挡;相邻消除之间最多连续旋转一次。参考解答检查不等于穷尽玩家的任意旋转顺序,实际阻挡对象会在游戏中标记。
|
||||
|
||||
**每关至少 65% 的参考消除路径实际经过弯道,至少 3 枚弯路棋子;空格中没有走过的弯线不计入。** 全部参考解答合计 706 次消除,其中 530 次经过弯道(75.1%);纯直出关卡 0 个。这里统计的是求解器的实际离场路径,不是任意玩家操作顺序。
|
||||
|
||||
**预计时长只用于内部比较,没有真人测试数据。** 公式为 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 |
|
||||
| 关 | 名称 | 节奏 | 格数 | 棋子 | 弯路离场 | 弯路占比 | 认知难度 | 开局可解 | 必要旋转 | 预计秒数 |
|
||||
|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 1 | 跟着弯道走 | 进入 | 37 | 8 | 8 | 100.0% | 7 | 8 | 0 | 30 |
|
||||
| 2 | 转一下,路会变 | 换路 | 19 | 6 | 5 | 83.3% | 32 | 3 | 1 | 27 |
|
||||
| 3 | 交叉处不换线 | 展开 | 37 | 12 | 12 | 100.0% | 28 | 7 | 0 | 50 |
|
||||
| 4 | 先解开这道弯 | 小挑战 | 19 | 6 | 4 | 66.7% | 37 | 3 | 1 | 28 |
|
||||
| 5 | 一弯接一弯 | 释放 | 61 | 12 | 10 | 83.3% | 21 | 7 | 0 | 44 |
|
||||
| 6 | 把弯路连起来 | 长局回报 | 61 | 16 | 15 | 93.8% | 21 | 9 | 0 | 59 |
|
||||
| 7 | 沿着轨道看出口 | 进入 | 61 | 14 | 12 | 85.7% | 19 | 9 | 0 | 51 |
|
||||
| 8 | 换条短路 | 换路 | 19 | 6 | 4 | 66.7% | 33 | 3 | 1 | 24 |
|
||||
| 9 | 接连找到出口 | 展开 | 61 | 14 | 14 | 100.0% | 25 | 7 | 0 | 53 |
|
||||
| 10 | 小小转机 | 小挑战 | 19 | 7 | 5 | 71.4% | 42 | 3 | 1 | 34 |
|
||||
| 11 | 一路放行 | 释放 | 61 | 16 | 12 | 75.0% | 20 | 11 | 0 | 62 |
|
||||
| 12 | 铺开好心情 | 长局回报 | 61 | 18 | 14 | 77.8% | 24 | 9 | 0 | 65 |
|
||||
| 13 | 找到节奏 | 进入 | 61 | 16 | 12 | 75.0% | 17 | 11 | 0 | 57 |
|
||||
| 14 | 转出一条新路 | 换路 | 19 | 6 | 4 | 66.7% | 31 | 3 | 1 | 25 |
|
||||
| 15 | 继续向外走 | 展开 | 61 | 16 | 14 | 87.5% | 27 | 6 | 0 | 59 |
|
||||
| 16 | 先解哪一枚 | 小挑战 | 19 | 7 | 6 | 85.7% | 45 | 2 | 1 | 30 |
|
||||
| 17 | 宽阔的回报 | 释放 | 61 | 18 | 12 | 66.7% | 24 | 8 | 0 | 63 |
|
||||
| 18 | 长路也轻巧 | 长局回报 | 61 | 20 | 15 | 75.0% | 22 | 10 | 0 | 71 |
|
||||
| 19 | 慢慢看,都来得及 | 进入 | 61 | 18 | 13 | 72.2% | 23 | 9 | 0 | 64 |
|
||||
| 20 | 只换一个方向 | 换路 | 19 | 6 | 5 | 83.3% | 37 | 3 | 1 | 29 |
|
||||
| 21 | 连成顺畅的路 | 展开 | 61 | 18 | 16 | 88.9% | 29 | 6 | 0 | 66 |
|
||||
| 22 | 轻巧绕一个弯 | 小挑战 | 19 | 6 | 5 | 83.3% | 43 | 2 | 1 | 30 |
|
||||
| 23 | 松一口气 | 释放 | 61 | 20 | 13 | 65.0% | 23 | 11 | 0 | 76 |
|
||||
| 24 | 从容的大舞台 | 长局回报 | 61 | 22 | 15 | 68.2% | 23 | 10 | 0 | 77 |
|
||||
| 25 | 又是新的出口 | 进入 | 61 | 18 | 12 | 66.7% | 27 | 7 | 0 | 63 |
|
||||
| 26 | 看见新的可能 | 换路 | 19 | 6 | 6 | 100.0% | 38 | 2 | 1 | 27 |
|
||||
| 27 | 顺路解开 | 展开 | 61 | 20 | 14 | 70.0% | 29 | 6 | 0 | 71 |
|
||||
| 28 | 留意这个转角 | 小挑战 | 19 | 6 | 4 | 66.7% | 45 | 2 | 1 | 28 |
|
||||
| 29 | 满盘好走 | 释放 | 61 | 20 | 13 | 65.0% | 21 | 10 | 0 | 70 |
|
||||
| 30 | 长长的成就感 | 长局回报 | 61 | 22 | 17 | 77.3% | 26 | 9 | 0 | 79 |
|
||||
| 31 | 熟练地出发 | 进入 | 61 | 20 | 13 | 65.0% | 25 | 8 | 0 | 70 |
|
||||
| 32 | 一点小变化 | 换路 | 19 | 6 | 4 | 66.7% | 38 | 3 | 1 | 30 |
|
||||
| 33 | 接住这个节奏 | 展开 | 61 | 20 | 14 | 70.0% | 28 | 8 | 0 | 75 |
|
||||
| 34 | 两条路的相遇 | 小挑战 | 19 | 6 | 6 | 100.0% | 49 | 2 | 1 | 31 |
|
||||
| 35 | 把出口打开 | 释放 | 61 | 22 | 15 | 68.2% | 24 | 9 | 0 | 77 |
|
||||
| 36 | 顺畅走到最后 | 长局回报 | 61 | 24 | 16 | 66.7% | 25 | 10 | 0 | 84 |
|
||||
| 37 | 从容观察 | 进入 | 61 | 22 | 15 | 68.2% | 25 | 9 | 0 | 77 |
|
||||
| 38 | 换个角度看 | 换路 | 19 | 6 | 4 | 66.7% | 34 | 2 | 1 | 25 |
|
||||
| 39 | 一枚接着一枚 | 展开 | 61 | 22 | 15 | 68.2% | 28 | 8 | 0 | 80 |
|
||||
| 40 | 给路线松个结 | 小挑战 | 19 | 6 | 6 | 100.0% | 46 | 2 | 1 | 32 |
|
||||
| 41 | 轻轻一扫 | 释放 | 61 | 24 | 17 | 70.8% | 29 | 10 | 0 | 92 |
|
||||
| 42 | 大盘小心思 | 长局回报 | 61 | 25 | 21 | 84.0% | 30 | 9 | 0 | 98 |
|
||||
| 43 | 自在出发 | 进入 | 61 | 20 | 15 | 75.0% | 29 | 8 | 0 | 76 |
|
||||
| 44 | 最后一个新转角 | 换路 | 19 | 6 | 4 | 66.7% | 37 | 2 | 1 | 27 |
|
||||
| 45 | 熟悉的长路 | 展开 | 61 | 22 | 16 | 72.7% | 31 | 8 | 0 | 84 |
|
||||
| 46 | 小结轻解 | 小挑战 | 19 | 6 | 5 | 83.3% | 47 | 2 | 2 | 31 |
|
||||
| 47 | 收获一片畅通 | 释放 | 61 | 24 | 16 | 66.7% | 29 | 9 | 0 | 90 |
|
||||
| 48 | 自在回响 | 长局回报 | 61 | 25 | 17 | 68.0% | 26 | 11 | 0 | 94 |
|
||||
|
||||
## 前七关的路径教学
|
||||
|
||||
1. **跟着弯道走**:箭头决定起步方向,棋子会沿弯轨自动转向。
|
||||
2. **转一下,路会变**:转动底盘会换路,棋子和箭头留在原位。
|
||||
3. **交叉处不换线**:交叉处不会换轨,沿同一条线找到出口。
|
||||
4. **先解开这道弯**:先看谁挡住了路线,再决定消除或免费转盘。
|
||||
5. **一弯接一弯**:跟过一两处弯道,接连解开棋子的出口。
|
||||
6. **把弯路连起来**:盘面变大,仍然顺着每条弯轨逐一解开。
|
||||
7. **沿着轨道看出口**:先看完整路线,再选择出口或免费转盘。
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# 关卡版本
|
||||
|
||||
在游戏左上角打开选关窗口,通过“关卡版本”切换。首次打开默认使用 v0.3;选择其他版本后会记住选择。各版本有独立的上次选关位置与通关记录。切换时重新开始目标版本上次选择的关卡,不保存进行到一半的棋盘。
|
||||
|
||||
原始版本的设计理念、案例和逐关结构清单见 [v0.1 · 最初 24 关设计理念](v0.1-design-philosophy.md)。本文记录版本管理;两者共同为后续优化提供参照。
|
||||
|
||||
| 版本 | 内容 | 关数 | 章节 | 布局来源 |
|
||||
| --- | --- | ---: | --- | --- |
|
||||
| v0.1 · 最初设计 | 最初制作的完整关卡,保留名称、顺序、轨道、棋子和原离场额度 | 24 | 3 章 × 8 关 | `ef2724b` 的 `levels/campaign.json` |
|
||||
| v0.2 · 直线较多版 | 扩展为 48 关时的设计,直线较多 | 48 | 8 章 × 6 关 | `70b021d` 的 `levels/campaign.json` |
|
||||
| v0.3 · 弯轨观察版 | 当前的弯轨教学与观察版本 | 48 | 8 章 × 6 关 | `levels/campaign-seeds.json` 生成的当前主线 |
|
||||
|
||||
版本号指关卡布局。三版统一使用当前界面、碰撞检测、免费旋转、免费失败试路与撤销规则;没有恢复旧版代码或扣除旋转额度的规则。旧版文件的 `moveLimit` 原样保留,游戏主界面仍显示待解棋子数。JSON 内的 `version: 1` 是数据格式版本,与 v0.1 等布局版本号无关。
|
||||
|
||||
v0.1 的旧闭环与扫掠阻挡也原样保留,可以通过旋转和消除找到解答。24 关与 v0.2 的 48 关均已在当前引擎中求解并完整回放。v0.3 的“每关至少 65% 参考消除路径经过弯道”等发布门槛仅适用于该版,不套用于历史布局。
|
||||
|
||||
## 归档与生成
|
||||
|
||||
- `levels/versions/v0.1.json`、`v0.2.json` 是对应 Git 文件的原始字节,不重新序列化。
|
||||
- `levels/versions/provenance.json` 记录来源完整提交、Git blob、SHA-256、字节数、关数与回放结果。
|
||||
- `npm run levels:versions` 校验归档哈希,生成 `src/levels-archive.js`,供浏览器直接导入。
|
||||
- `src/campaign-versions.js` 注册版本、章节、关卡模块与旧存档键;`src/levels.js` 继续暴露最新版本,供生成器、设计指南与 Cocos 示例使用。
|
||||
- `npm run levels` 仅重新生成当前主线,再同步历史模块。`npm run build` 校验并同步历史模块,三版均进入静态站点。
|
||||
|
||||
后续发布新的布局版本时,先将当前主线与出处归档、登记哈希,再在注册表中添加新版本并更新默认版本。为新版本分配新的存档键,不复用旧版完成记录。历史归档保持不变;为历史布局改版应另发版本,不能直接覆盖。
|
||||
|
||||
## 进度隔离与迁移
|
||||
|
||||
版本选择保存在 `hex-trails-campaign-version`,各版进度保存在 `hex-trails-campaign-v0.1`、`hex-trails-campaign-v0.2`、`hex-trails-campaign-v0.3`。旧键仅在对应新键不存在时读取,写入新键,不删除或覆盖旧数据。
|
||||
|
||||
| 目标版本 | 对应旧存档键 |
|
||||
| --- | --- |
|
||||
| v0.1 | `hex-trails-v2` |
|
||||
| v0.2 | `hex-trails-observe-v3-r1`(若存在),否则 `hex-trails-observe-v3` |
|
||||
| v0.3 | `hex-trails-observe-v3-r2` |
|
||||
|
||||
v0.2 与 v0.3 内部关卡 ID 相同但布局不同,完成记录只按版本隔离,不能按 ID 合并。非法版本回到默认版本,越界关卡索引回到该版第一关,完成列表过滤越界值和重复值。浏览器不允许保存时,当前页面仍可切换并保留各版进度,刷新后无法保留。
|
||||
|
||||
自动测试验证历史文件与模块一致、72 关完整回放、版本数量与章节、默认选择、跨版本隔离、旧存档迁移与异常存储。Cocos 示例默认加载最新主线,本次未实现原生版本选择控件或完成原生实机验证。
|
||||
@@ -1,8 +1,10 @@
|
||||
# HEX TRAILS 玩法方向与主线制作要求
|
||||
|
||||
版本:2026-09-08。本文用于关卡制作、生成器筛选与玩法验收,不规定美术样式。
|
||||
版本:2026-09-09。本文用于关卡制作、生成器筛选与玩法验收,不规定美术样式。
|
||||
|
||||
当前方向是让喜欢轻松观察、逐步整理并获得连续反馈的玩家,在越来越丰富的棋盘上保持推进感。玩家偏好以观察行为、试路习惯和体验反馈描述,不按性别推断。48 关已通过生成器结构检查、完整解答回放与替代操作顺序测试;逐关记录见 [主线验收表](campaign-review.md)。这属于规则与结构验收,玩家体验和真实完成时长仍待真人测试。
|
||||
适用范围:本文记录后续 48 关、尤其 v0.3 的制作方向。最初 24 关的设计理念另见 [v0.1 设计理念](v0.1-design-philosophy.md);本文的弯轨比例、无闭环及六拍门槛不代表原版要求。下一轮优化将对照原版结构讨论,本轮未改关卡。
|
||||
|
||||
当前方向是让喜欢轻松观察、逐步整理并获得连续反馈的玩家,在越来越丰富的棋盘上保持推进感。玩家偏好以观察行为、试路习惯和体验反馈描述,不按性别推断。9 月 9 日版通过[种子清单](../levels/campaign-seeds.json)重新编排全部 48 关,将真正经过弯道的成功离场路径作为制作核心;逐关结构记录见 [主线验收表](campaign-review.md)及[完整分析数据](../levels/campaign-analysis.json)。验收结论以当前清单重新生成的报告与测试输出为准,不能沿用旧关卡结果;玩家体验和真实完成时长仍待真人测试。
|
||||
|
||||
## 1. 核心循环
|
||||
|
||||
@@ -12,6 +14,8 @@
|
||||
|
||||
试路是学习规则的一部分。一次没有成功的尝试应帮助玩家理解阻挡原因,不能同时带来隐性的通关成本。主线不靠最后一枚棋子的隐藏闭环、路线之外的擦碰或连续无进展旋转制造困难。
|
||||
|
||||
玩家需要顺着实际将要走过的轨道观察出口。每一关都以弯轨消除为主,直线路径只作为少量节奏调剂,不安排纯直出关。空格上画了多少弯线不代表玩家真的需要读这些路线;没有被成功离场棋子使用的装饰性曲线不计入这一目标。
|
||||
|
||||
## 2. 免费尝试与计数语义
|
||||
|
||||
- 左转、右转免费,不扣步,也不因多转几次而失败。
|
||||
@@ -39,6 +43,8 @@
|
||||
|
||||
增加底格数而没有增加有效内容,不算延长体验;增加棋子却把所有出口堵成唯一解,也不算舒缓长局。制作时应检查是否形成足够多的连续成功机会,以及玩家能否在若干局部区域之间自然选择。
|
||||
|
||||
当前种子清单的单关棋子数最多为 25。后期长局增加的是实际需要沿弯轨观察、解开的内容,不能只沿用更多直出棋子的数量目标。比较工作量时同时查看有效消除数量、真实离场路径的转弯与连续成功机会。
|
||||
|
||||
## 4. 48 关:8 章,每章 6 拍
|
||||
|
||||
主线按章节节奏编排,不按认知难度分数从低到高排序。每章允许自然回落;后期主要增加熟悉规则下的工作量,认知挑战上限不必随章节不断升高。
|
||||
@@ -52,7 +58,21 @@
|
||||
| 5 | 释放 | 明显低于上一关的认知负担,让出口、连续成功和自由选择重新变多。 |
|
||||
| 6 | 长局回报 | 使用熟悉关系提供更长的整理过程;可以更大、更长,但不自动更难。 |
|
||||
|
||||
第 1 关要有直接可见的进展,完整解答不依赖旋转。它不承担识别闭环的教学任务。后续换路关再通过短而明确的因果关系介绍旋转。
|
||||
第 1 关使用 37 格、8 枚棋子。开局朝向下全部棋子可直接离场,每枚成功路径恰好经过 1 处弯道,完整解答不依赖旋转或强制先后顺序。它教的是“箭头决定起步,弯轨决定后续转向”,不承担识别闭环的任务。“全部可离场”只描述开局朝向,不要求玩家转到任意朝向后也全部可出。
|
||||
|
||||
前 7 关的学习内容写入各关的 `lesson` 字段,并与实际参考路径一起验收:
|
||||
|
||||
| 关卡 | 学习重点 |
|
||||
| --- | --- |
|
||||
| 1 | 箭头决定起步方向,沿弯轨自动转向。 |
|
||||
| 2 | 旋转底盘改变路线,棋子位置与箭头方向不动。 |
|
||||
| 3 | 交叉处不换轨,沿同一条线找到出口。 |
|
||||
| 4 | 看清阻挡关系,再决定先后消除或免费转盘。 |
|
||||
| 5 | 跟过一两处弯道,获得舒缓的连续消除。 |
|
||||
| 6 | 在更大棋盘上仍逐条解开实际弯轨路径。 |
|
||||
| 7 | 先观察完整路线,再选择出口或免费转盘。 |
|
||||
|
||||
这些学习重点补充章节六拍,不改变小挑战后释放、再以长局回报收束的节奏。
|
||||
|
||||
第 5 关的释放应落实为结构差异,例如更多初始出口、更浅的依赖、更少的必要判断,不能只换一个轻松的名字。第 6 关应延长熟悉操作的愉快过程,不用反复试方向、难以解释的阻挡或长时间等待补足时长。
|
||||
|
||||
@@ -72,6 +92,12 @@
|
||||
|
||||
对每一枚初始棋子,分别在全部 6 个棋盘朝向下忽略其他棋子追踪独立路线。每条路线都必须离场,独立闭环数必须为 0。该要求覆盖主线所有章节,避免移除障碍后才发现最后一枚棋子一直绕圈。
|
||||
|
||||
### 真正使用弯轨
|
||||
|
||||
对参考解答逐步回放,只统计成功离场时实际走过的路线。每关至少 65% 的成功离场棋子经过 1 处或更多弯道,且这类棋子至少有 3 枚;完整回放的成功移除数应等于初始棋子数。
|
||||
|
||||
统计使用 `analyze().pathUsage`,不使用整张棋盘的弯线数量,也不以初始朝向忽略占用的路线统计替代真实回放。旋转会改变轨道,成功路径必须按每次实际消除时的棋盘朝向计算。单纯增加空白格上的曲线,或让绝大多数棋子从边缘直线离场,不能满足发布要求。
|
||||
|
||||
### 阻挡可以从路线解释
|
||||
|
||||
在已验证解答回放的每个决策状态,对所有剩余棋子的路线进行检查。阻挡对象必须位于该棋子的独立路线所经过的格子中,不能出现路线格子之外的侧面擦碰阻挡。
|
||||
@@ -88,6 +114,12 @@
|
||||
|
||||
每关记录章节、节奏角色、底格数、棋子数、认知指标与工作量。完成 48 关的组内检查:小挑战之后确有释放,长局回报的工作量增长没有被等量的认知负担增长抵消。这里要求的是候选结构和回放验证,玩家的实际感受仍需后续测试。
|
||||
|
||||
### 来源与可重复验证
|
||||
|
||||
`levels/campaign-seeds.json` 保存全部 48 关的种子与生成参数,是主线布局的制作来源;不能只编辑生成后的 `campaign.json`。修改清单后运行 `npm run levels`,同步关卡、共享模块与报告,再运行测试。
|
||||
|
||||
`levels/campaign-analysis.json` 中每关的 `pathUsage` 记录真实参考成功路径,包括直出/弯轨棋子数、弯轨比例、单弯/多弯数量、总转弯、最长连续直出段,以及逐棋子的路径明细。前 7 关还应核对 `lesson` 是否被实际动作支持。报告记录的是参考解答,不是穷尽玩家所有任意旋转和点击顺序。
|
||||
|
||||
## 6. 内部时长假设
|
||||
|
||||
当前仅用下式比较候选的潜在体验长度:
|
||||
@@ -98,6 +130,8 @@
|
||||
|
||||
`N` 是初始棋子数;总转弯数采用当前分析器对初始朝向、忽略占用时各棋子独立路线的转弯数求和;最少旋转数来自规则求解器。各系数都是内部制作假设,尚未经过真人用时标定。
|
||||
|
||||
上述总转弯口径与 `pathUsage.totalBends` 的实际参考成功路径口径不同,不得混用。后者用于验证弯轨体验与阅读负担,前者仍是现行估时公式的输入;即使弯轨比例达到发布要求,也不能据此宣称真人体验时长已增加。
|
||||
|
||||
该结果不是玩家平均通关时长、完成承诺或难度分数。它没有完整模拟玩家的扫描速度、主动停顿、错误尝试、动画等待、提示使用与设备差异。大棋盘的估计数值更高,只能说明当前结构模型认为工作量更大,不能据此宣称玩家一定会玩得更久或更开心。
|
||||
|
||||
候选尚未确认可解、出现独立闭环或搜索未确定时,不应将估时作为可发布关卡的数据。公式调整时保留版本,避免把不同计算口径下的数字当作体验提升证据。
|
||||
|
||||
@@ -66,10 +66,34 @@ minimumActions = N + R // 最少交互数:消除 + 旋转
|
||||
|
||||
基础生成器在碰撞规则下逆向放入可离场棋子,主线编排在候选基础上进行独立筛选与回放。主线制作规模为 48 关、8 章,每章 6 拍:进入、换路、展开、小挑战、释放、长局回报。顺序不按认知难度非递减排序,也不再要求每一关都必须旋转。
|
||||
|
||||
9 月 9 日版的全部布局由 `levels/campaign-seeds.json` 中的种子及 `options` 明确指定;`scripts/generate-levels.mjs` 据此生成关卡、共享模块和验收数据。修改种子或参数后运行 `npm run levels`,不要直接编辑派生的关卡文件。参考成功路径统计保存在 `levels/campaign-analysis.json` 的逐关 `pathUsage` 中。
|
||||
|
||||
`minBaseTurns` / `maxBaseTurns` 限制逆向放置时的基准朝向,`startRotation` 在此之后施加。发布门槛另按最终朝向的真实参考解答统计,不能把基准朝向的参数当成旋转后的路径保证。
|
||||
|
||||
大棋盘用于增加熟悉规则下的工作量:早期就可使用 37 格,后期舒缓关采用 61 格,19 格也可承载较难的局部关系。小挑战后必须有认知负担回落,长局回报不能仅靠更复杂的路线或反复空转延长时间。
|
||||
|
||||
当前清单单关最多 25 枚棋子,长局的工作量来自更多实际弯轨消除,不以直出棋子数量或空白格上的弯线充数。每关参考解答至少 65% 的成功离场棋子经过弯道,且至少 3 枚;直线只作少量调剂,主线不安排纯直出关。
|
||||
|
||||
第 1 关为 37 格、8 枚棋子,开局全部可离场,参考解答每枚恰好经过 1 处弯道,无需旋转或强制先后顺序;这不意味着转到任意朝向后仍全部可出。前 7 关通过 `lesson` 字段依次明确:箭头起步与弯轨转向、旋转换路而棋子箭头不动、交叉不换轨、阻挡先后、一两弯的舒缓消除、大盘连续解路径、完整路线观察。对应制作说明见 [前期学习与章节节奏](gameplay-direction.md)。
|
||||
|
||||
发布要求包括:每枚棋子在 6 个朝向下忽略占用的独立路线均无闭环;参考解答全程排除路线格外擦碰阻挡;换路和小挑战关在参考解答中最多连续旋转 1 次就打开下一次消除进展。它们是候选发布门槛,不能用来代替实际生成结果或玩家验收。完整制作方向见 [玩法方向](gameplay-direction.md),生成后逐关核对 [主线审阅报告](campaign-review.md)。
|
||||
|
||||
## 实际参考路径使用量
|
||||
|
||||
`solutionPathUsage(level, actions)` 按参考动作回放旋转和成功移除,在每次移除时调用同一 `trace()` 读取实际离场路径。它忽略没有成功离场的尝试,不统计棋盘上未使用的装饰曲线;正式通过验收的完整解答还必须满足 `pathUsage.removals === N`。
|
||||
|
||||
| `pathUsage` 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `removals` | 参考解答中的成功移除数量 |
|
||||
| `curvedRemovals` / `straightRemovals` | 经过至少 1 处弯道 / 没有经过弯道的成功棋子数 |
|
||||
| `curvedRatio` | `curvedRemovals / removals`;主线要求至少 0.65 |
|
||||
| `singleBendRemovals` / `multiBendRemovals` | 实际经过恰好 1 处 / 多于 1 处弯道的成功棋子数 |
|
||||
| `totalBends` / `maxBends` | 全部实际成功路径的总转弯数 / 单枚最大转弯数 |
|
||||
| `maxConsecutiveStraight` | 参考解答中连续直出棋子的最长段落 |
|
||||
| `paths` | 逐棋子的 `id`、`bends` 和路线步骤数 `cells` |
|
||||
|
||||
弯轨门槛同时检查 `curvedRatio >= 0.65` 与 `curvedRemovals >= 3`。它约束参考解答中真正发生的消除,不证明所有其他操作顺序具有相同比例。初始独立路线的平均转弯数也不能替代这个指标,尤其是必须先旋转才有出口的关卡。
|
||||
|
||||
## 认知难度(0–100)
|
||||
|
||||
`analyze()` 的分数只估计路线阅读与依赖判断,不直接给棋盘面积、棋子数或试错预算加分。设:
|
||||
@@ -105,4 +129,6 @@ estimatedSeconds = round(3 × N + 0.75 × 总转弯数 + 4 × R)
|
||||
|
||||
总转弯数是初始朝向下、忽略占用的各棋子独立路线转弯数之和。该公式仅为内部制作假设,没有真人用时数据支撑,不能称为平均通关时长或向玩家承诺的完成时间。搜索未确定或关卡不可解时,不应将其作为可发布关卡的估时。
|
||||
|
||||
此处总转弯数与 `pathUsage.totalBends` 口径不同:前者是初始独立路线,后者是逐步旋转后真正成功走过的参考路径。两者应分别保留,不将弯轨覆盖率、增加的路线内容或最多 25 枚棋子的规模直接换算成已验证的真人时长。
|
||||
|
||||
后续测试应观察主动停顿、成功消除间隔、旋转到下一次成功的间隔、失败试路原因、最后一枚棋子阶段,以及提示、撤销和重开的发生位置;明确区分观察停顿、动画占用和离开游戏的时间,再校准认知评分与工作量估计。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
# v0.1 · 最初 24 关的设计理念
|
||||
|
||||
**原始 24 关围绕“旋转换路、逐层让路、解开交织轨道”组织体验。** 玩家先找出当前能离场的棋子,移开它们改变占用关系,再通过旋转底盘改变路线,逐步把整盘解开。每一次操作都应能让玩家理解后续条件发生了什么变化。
|
||||
|
||||
这是对原始实现的设计归纳。本文以初始提交 `ef2724b` 的说明、生成器、规则、测试及原始 24 关布局为依据,记录已实现的结构与可继承的理念,供后续关卡优化对照。本次只整理文档和分析证据,未修改任何版本的关卡。
|
||||
|
||||
## 1. 先明确原版的核心体验
|
||||
|
||||
原版让玩家处理三个互相影响的问题:
|
||||
|
||||
1. **路线问题:** 这枚棋子从箭头方向出发,沿途会走到哪里?
|
||||
2. **空间问题:** 它的整个身体能否通过?需要先移开哪一枚棋子?
|
||||
3. **朝向问题:** 当前路线走不通,底盘换一个朝向后,哪些出口和阻挡关系会改变?
|
||||
|
||||
核心体验可以归纳为:**看懂关系 → 找到突破口 → 让局面松开 → 换一个朝向继续解。** 其中“局面松开”既可以来自移走障碍,也可以来自重新接通轨道。长路径的成功离场,则把之前逐步清理的结果展示出来。
|
||||
|
||||
原版采用轻量点击与 60° 整盘旋转,规则动作少,但同一套轨道与棋子会形成多阶段关系。以下章节将“历史明确规定”“布局中实际存在的结构”“本次体验解释”分别说明,不把后来的制作标准反写为原始要求。
|
||||
|
||||
## 2. 六项设计支柱
|
||||
|
||||
### 2.1 旋转承担解题作用
|
||||
|
||||
**历史事实:** 生成器会剔除不需要旋转的候选,原测试也要求每一关在不旋转时无法清空。24 关全部至少需要 1 次旋转;其中 12 关需要 1 次、8 关需要 2 次、4 关需要 3 次。
|
||||
|
||||
旋转时,上层棋子的位置和箭头保持不变,下层的格子位置与轨道连接整体变化。玩家重新判断的是路线、出口和阻挡,而不只是观看盘面转动。
|
||||
|
||||
**设计归纳:** 旋转是关卡内容的一部分,应与当前清场状态发生联系。原版存在“清掉一批,再换路”的阶段,也存在“转过去移走一个关键棋子,再转回来”的结构。后者说明同一个朝向在不同占用状态下会产生不同的解题机会。
|
||||
|
||||
### 2.2 棋子之间存在可推演的让路关系
|
||||
|
||||
**历史事实:** 最初 24 关开局合计 404 枚棋子,只有 125 枚可以直接离场,约占 30.9%。每关都有可行开局,但都无法仅靠初始朝向一路清空。参考解答包含 3–13 个消除批次。
|
||||
|
||||
这里的“批次”指求解器在一个状态下找到的全部可离场棋子;移除这批后,再寻找下一批。它是结构指标,不能当作玩家必须严格执行的唯一点击顺序,也不等于真人思考次数。
|
||||
|
||||
**设计归纳:** 棋子的价值同时来自自身路线和它为其他棋子占据的空间。一个容易移走的棋子也可能承担关键的让路作用。玩家的收获包含“原来移走它,就能放出后面这一串”。
|
||||
|
||||
原求解器依赖一个重要性质:安全移走棋子只会减少障碍,不会凭空破坏其他路线。因此,关系可以复杂,但并不要求玩家担心一次合法消除永久毁掉关卡。关卡可以有多种安全操作顺序。
|
||||
|
||||
### 2.3 判断完整的移动空间
|
||||
|
||||
**历史明确规定:** 棋子整个顶面参与连续碰撞检测,路径经过的格子为空,也可能在转角或经过相邻格之间时擦到另一枚棋子。交叉轨道不会自动换线,颜色不参与解题规则。
|
||||
|
||||
**布局事实:** 原版 24 关均有初始侧擦阻挡,共涉及 119 枚棋子。此处统计的是:实际发生阻挡,但阻挡棋子所在格不属于移动棋子的独立中心路线所经过的格子。
|
||||
|
||||
**设计归纳:** 玩家既要读线,也要判断棋子的宽度和通行空间。这里产生的是空间关系题。后续表现可以把接触位置和阻挡对象解释得更清楚;是否保留某类侧擦结构,需要结合可读性讨论,不能把“碰撞规则存在”和“所有擦碰都容易看懂”混为一谈。
|
||||
|
||||
### 2.4 短出口、让路棋子与长绕行共同组成内容
|
||||
|
||||
**历史事实:** 原生成器给候选路径的经过格数加权,偏向更长的路线;它并没有“每关至少多少比例经过弯道”的要求。固定原引擎回放 24 关,404 次成功消除中有 227 次经过弯道,占 56.2%;其余是直出。最长一条成功路径有 16 次转弯。
|
||||
|
||||
直出与弯轨在原版中共同工作。容易离场的外围棋子可能先释放空间;更深处的路线在障碍移开和朝向变化后才可贯通。不能仅从一枚棋子自身走得短,就判定它没有解题作用。
|
||||
|
||||
**设计归纳:** 需要关注“这条路线怎样参与整盘关系”。弯道数量可以描述阅读量,但无法单独代表旋转价值、解锁关系或阶段变化。原版长绕行所带来的观察内容,也不能用大量彼此独立的单弯路径完全替代。
|
||||
|
||||
### 2.5 同一盘面可以经历多个解题阶段
|
||||
|
||||
原版允许当前朝向下出现无法直接离场的状态,只要可以通过后续旋转完成。初始独立路线中共有 6 个闭环,分别出现在第 1、6、7、16、20、21 关。
|
||||
|
||||
其中第 1 关“清到最后一枚后,它的路线仍为闭环,向右转一次后离场”被明确写入历史测试。闭环在初始独立路线中就已存在,移除棋子不会让轨道新形成闭环。第 22 关的参考解还包含“转一次仍无出口,再转一次才有出口”的阶段。原版没有要求每一枚棋子在所有六个朝向下都能独立离场,也没有承诺每次必要旋转立刻获得出口。
|
||||
|
||||
**设计归纳:** 局部走不通可以构成换路理由,阶段之间可以有关联。但这种状态也可能使玩家不理解最后一枚为何走不了,需要清楚的路线反馈。闭环是否适合第一关、连续旋转如何给出线索,属于后续教学和反馈问题;原版存在这些结构,不等于必须原样复制它们的呈现缺点。
|
||||
|
||||
### 2.6 后期加深关系,盘面体量较早稳定
|
||||
|
||||
**历史事实:** 首章混合 19 格与 37 格,第 5、8 关已使用 37 格;第二、三章均为 37 格。最大棋子数出现在第 15 关,共 24 枚;最终关为 18 枚。第三章的全部关卡需要 2–3 次旋转。
|
||||
|
||||
**设计归纳:** 原版后期主要增加需要追踪的关系、绕行程度与旋转阶段。相同大小的盘面可以逐渐表达更复杂的题目,关卡体量不必不断增长。规模、空间密度、路线长度和关系深度应分别说明。
|
||||
|
||||
## 3. 用原关卡理解这些原则
|
||||
|
||||
### 第 1 关:先让路,再理解旋转换路
|
||||
|
||||
19 格、5 枚棋子,历史分数 25。以原数据 ID 标识棋子,参考解如下;ID 仅方便设计师在数据和工坊中核对。
|
||||
|
||||
| 阶段 | 实际状态 | 所表达的关系 |
|
||||
| --- | --- | --- |
|
||||
| 开局 | `t0`、`t2`、`t4` 可离场;`t1` 被 `t0` 挡住 | 有明确入口,同时有一层让路关系 |
|
||||
| 先清理 | 移走开局可行棋子,`t1` 获得出口 | 成功消除改变其他棋子的条件 |
|
||||
| 剩最后一枚 | `t3` 在这个朝向的独立轨道是闭环 | 清空障碍后,路线问题仍需处理 |
|
||||
| 向右转 60° | `t3` 获得出口,随后离场 | 旋转改变路线,完成整关 |
|
||||
|
||||
这关把“让路”与“换路”放在同一局里。最初版本的教学重点包含旋转的必要性。如今 v0.3 第 1 关的“8 枚全可走、各转一个弯”则承担另一种教学目标:认识沿轨转向。两者的结构功能不同。
|
||||
|
||||
### 第 17 关:回到原朝向,局面已经不同
|
||||
|
||||
20 枚棋子,最少需要 2 次旋转。参考解先清理到剩余 5 枚,再出现以下过程:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[原朝向:剩 5 枚,无出口] -->|向右转 60°| B[t1 获得出口]
|
||||
B -->|移走 t1| C[剩 4 枚,当前无出口]
|
||||
C -->|向左转 60°| D[回到原朝向,t0 获得出口]
|
||||
D --> E[继续逐步清空]
|
||||
```
|
||||
|
||||
这一例最能表达旋转与消除的相互作用:回到同一个朝向不等于回到同一个局面,因为棋子的占用已经变化。解题对象是“朝向与剩余棋子共同构成的状态”。
|
||||
|
||||
### 第 24 关:在固定体量中分阶段解开长路
|
||||
|
||||
37 格、18 枚棋子,历史分数 75。参考解先移走 7 枚,随后经历三次向右旋转;三次旋转分别发生在剩余 11、9、6 枚时。每一阶段先处理当时打开的出口,再继续换路。
|
||||
|
||||
最后一段中出现 12、13、14、15 次转弯的成功路径。这些长路径与先前清场、换路形成连续关系。最终关的棋子数少于第 15 关,却包含更多旋转阶段与更长的绕行,说明原版高潮来自关系组合。
|
||||
|
||||
## 4. 原始章节与难度递进
|
||||
|
||||
历史地图分为三章,每章 8 关。原版整体按旧评分非递减排列,没有后来的“挑战—释放—长局回报”六拍结构。
|
||||
|
||||
| 章节 | 历史分数 | 实际盘面 | 最少旋转 | 平均消除批次 | 开局可离场比例逐关均值 |
|
||||
| --- | --- | --- | --- | ---: | ---: |
|
||||
| 转盘初体验(1–8) | 25–49 | 19 / 37 格 | 1–2 次 | 5.75 | 40.6% |
|
||||
| 糖果回旋(9–16) | 50–59 | 37 格 | 1–2 次 | 8.63 | 30.8% |
|
||||
| 碰撞大挑战(17–24) | 60–75 | 37 格 | 2–3 次 | 10.38 | 27.3% |
|
||||
|
||||
上述逐关比例均值与全体棋子的加权比例 30.9% 使用不同口径。批次数也是参考解的结构信息;这些数字支持“后期关系更深、初始选择比例更少”的归纳,不能据此宣称已经验证玩家感到更有趣或更流畅。
|
||||
|
||||
章节名称提供了体验主题,但原制作过程主要是程序生成、可解性筛选和评分排序。没有证据证明 24 关最初就有逐关人工编写、逐项隔离新机制的教学剧本。本文的案例解读是对已存在结构的整理。
|
||||
|
||||
## 5. 原版怎样生成与筛选关卡
|
||||
|
||||
1. 为六边形格子随机生成完整的六端口配对,保持轨道连续和交叉不换线规则。
|
||||
2. 逆向插入棋子:新加入的棋子必须能在已有棋子存在时先离场,以建立可解布局。
|
||||
3. 候选位置和方向按随机项与经过格数加权,从靠前候选中抽取,让盘面保留变化与绕行。
|
||||
4. 将初始底盘旋转 1–3 次,重新求解,剔除不可达以及无需旋转即可完成的候选。
|
||||
5. 按旧步数规则,为最短解之外留出一定余量,再计算难度。
|
||||
6. 按评分排序,选取 24 关,并使用完整动作序列回放验证。
|
||||
|
||||
生成脚本中的目标棋子数不是最终棋子数:逆向插入可能提前停止。原始脚本给 37 格候选设置过 16–33 枚的目标范围,但实际发布的 24 关最多 24 枚。解释已发布版本时,以归档布局为准。
|
||||
|
||||
原版的硬要求是:**规则一致、确实可解、每关必须旋转、整体评分递进。** 弯路覆盖率、六朝向全部无闭环、参考决策状态不许侧擦、相邻消除间最多一次旋转,都不是当时的发布标准。
|
||||
|
||||
## 6. 历史评分与尝试成本
|
||||
|
||||
原评分同时考虑以下六项:
|
||||
|
||||
| 因素 | 上限 | 原始意图或作用 |
|
||||
| --- | ---: | --- |
|
||||
| 棋子规模 | 15 | 用棋子数量粗略描述处理量;没有直接按棋盘面积加分 |
|
||||
| 平均转弯 | 20 | 描述初始朝向下、忽略占用的路线追踪量 |
|
||||
| 解锁层数 | 20 | 描述参考解的分批释放过程 |
|
||||
| 初始阻塞 | 15 | 可立即离场的选择越少,分数越高 |
|
||||
| 必要旋转 | 20 | 将换路需求作为重要的挑战来源 |
|
||||
| 步数压力 | 10 | 可用步数越接近最短解,容错越少 |
|
||||
|
||||
历史最短步数是“棋子数 + 最少旋转数”。当时成功点击、失败试路和旋转都扣步,实际关卡在最优解以外留有 2–7 步余量。撤销恢复状态,没有倒计时。
|
||||
|
||||
这些是历史实现事实。**当前已经采用的免费旋转、免费失败试路规则继续有效;整理原版理念不构成恢复扣步惩罚的决定。** 旋转是否免费,与关卡是否需要玩家判断何时换路,是可以分别设计的两个问题。
|
||||
|
||||
同样,原版把规模和尝试压力计入难度,也不意味着后续必须恢复旧公式。保留清晰的空间与路线关系,可以与规模、工作量分开计量以及起伏式节奏同时成立。原评分从未经过真人通关率校准,不能被当作客观心理难度。
|
||||
|
||||
## 7. 后续两版具体改变了什么
|
||||
|
||||
下表统一使用固定的原始引擎复算,以隔离后续评分和碰撞实现变化。布局来源及哈希见[分析证据](v0.1-design-evidence.json)。后两版的参考回放使用充足额度,只比较路线结构;不将旧扣步规则重新应用到现行玩法。
|
||||
|
||||
| 结构指标 | v0.1 原始 24 关 | v0.2 直线较多版 | v0.3 弯轨观察版 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 必须旋转才能完成 | 24 / 24 关 | 16 / 48 关 | 16 / 48 关 |
|
||||
| 参考消除批次数范围 | 3–13 | 1–5 | 1–6 |
|
||||
| 初始可离场棋子占全部棋子比例 | 30.9% | 38.6% | 44.8% |
|
||||
| 成功消除路径经过弯道的比例 | 56.2% | 7.0% | 75.1% |
|
||||
| 每条弯轨成功路径的平均转弯数 | 3.75 | 1.45 | 1.25 |
|
||||
|
||||
这些结果说明:v0.2 明显增加了宽松直出;v0.3 增加了真实弯轨路径,但这些弯轨平均更短,必须换路的关卡占比和分批释放层次仍低于原版。**弯道占比提高,不能单独证明原来的解谜关系已经恢复。**
|
||||
|
||||
后续制作还采用了全六朝向独立路线无闭环、参考决策状态排除侧擦、限制连续无进展旋转等筛选。这些要求着眼于减少难以解释的反馈,也同时缩小了可用的空间和换路结构。后续优化需要分别审视可读性、关系深度和舒缓节奏,不能用一个“更好走”的条件替代全部目标。
|
||||
|
||||
这里记录的是设计方向的变化及结构证据,没有真人通关率或体验数据来决定哪组数值最优。
|
||||
|
||||
## 8. 留给下一轮优化的对照基准
|
||||
|
||||
以下是基于原版整理的检查问题,供下一轮逐关讨论,不是本轮自动执行的新生成门槛。
|
||||
|
||||
| 要保留的设计价值 | 评审时应能说明 |
|
||||
| --- | --- |
|
||||
| 旋转有解题意义 | 哪个局面需要换路?转动后改变了谁的出口或阻挡? |
|
||||
| 移除会改变局面 | 哪些棋子承担让路作用?它们离场后释放了什么? |
|
||||
| 路线承担实际内容 | 短出口与长绕行各自负责什么?弯道是否参与后续关系? |
|
||||
| 同一盘面有阶段 | 清理、换路、继续清理之间如何关联?是否存在值得理解的状态变化? |
|
||||
| 空间关系可读 | 玩家能否解释真实接触位置?反馈不足时,应改表现还是改布局? |
|
||||
| 难度可控 | 这一关主要增加的是关系深度、路线追踪、选择压力,还是工作量? |
|
||||
|
||||
需要在下一轮另行确定的内容包括:每关必转是否保留为全局要求,教学和舒缓关如何例外;闭环和连续旋转在什么条件下适用;碰撞可读性如何改进;长路线如何兼顾观察与动画节奏;原有解题结构怎样融入后续的大盘和起伏节奏。
|
||||
|
||||
免费旋转、免费失败试路,以及已经恢复的三版独立关卡与进度保持现状。后续可在此基准上优化并发布新版本,原始 24 关归档继续保留。
|
||||
|
||||
## 9. 原始 24 关结构清单
|
||||
|
||||
“开局可走”表示初始朝向下真实无碰撞离场的数量;“批次”来自原求解器参考解;“旧分数”用原评分重算,不能与当前界面分数直接比较。
|
||||
|
||||
| 关 | 名称 | 格数 | 棋子 | 开局可走 | 必要旋转 | 消除批次 | 旧分数 |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 转一转,路就通了 | 19 | 5 | 3 | 1 | 3 | 25 |
|
||||
| 2 | 换个方向 | 19 | 6 | 3 | 1 | 4 | 31 |
|
||||
| 3 | 弯道别碰头 | 19 | 12 | 6 | 2 | 5 | 38 |
|
||||
| 4 | 给我让条路 | 19 | 13 | 5 | 1 | 8 | 44 |
|
||||
| 5 | 粉色回旋 | 37 | 16 | 5 | 1 | 5 | 45 |
|
||||
| 6 | 蓝色小径 | 19 | 12 | 3 | 1 | 8 | 47 |
|
||||
| 7 | 向左看齐 | 19 | 11 | 4 | 2 | 7 | 49 |
|
||||
| 8 | 转角遇见光 | 37 | 18 | 6 | 1 | 6 | 49 |
|
||||
| 9 | 第一圈冒险 | 37 | 18 | 7 | 1 | 9 | 50 |
|
||||
| 10 | 旋转甜甜圈 | 37 | 21 | 8 | 1 | 8 | 51 |
|
||||
| 11 | 差一点擦肩 | 37 | 18 | 6 | 1 | 8 | 52 |
|
||||
| 12 | 紫色回廊 | 37 | 19 | 6 | 1 | 9 | 53 |
|
||||
| 13 | 再转一次 | 37 | 20 | 5 | 1 | 9 | 54 |
|
||||
| 14 | 绕开拥挤 | 37 | 17 | 5 | 2 | 8 | 55 |
|
||||
| 15 | 小心翼翼 | 37 | 24 | 5 | 1 | 11 | 57 |
|
||||
| 16 | 别挤在一起 | 37 | 17 | 5 | 2 | 7 | 59 |
|
||||
| 17 | 糖果交响曲 | 37 | 20 | 7 | 2 | 9 | 60 |
|
||||
| 18 | 盘旋之间 | 37 | 21 | 7 | 2 | 12 | 61 |
|
||||
| 19 | 左右逢源 | 37 | 17 | 4 | 2 | 8 | 62 |
|
||||
| 20 | 寻找缝隙 | 37 | 21 | 7 | 3 | 10 | 66 |
|
||||
| 21 | 回转大挑战 | 37 | 20 | 5 | 3 | 10 | 68 |
|
||||
| 22 | 紧密相连 | 37 | 21 | 4 | 3 | 13 | 68 |
|
||||
| 23 | 终极转盘 | 37 | 19 | 4 | 2 | 11 | 69 |
|
||||
| 24 | 六角大满贯 | 37 | 18 | 5 | 3 | 10 | 75 |
|
||||
|
||||
## 10. 来源、复算方法与边界
|
||||
|
||||
- 原始布局:[v0.1.json](../levels/versions/v0.1.json),404 枚棋子,24 关;与 `ef2724b` 中的 `levels/campaign.json` 字节一致。
|
||||
- 归档出处:[provenance.json](../levels/versions/provenance.json),记录完整提交、文件哈希与旧布局回放。
|
||||
- 本次证据:[v0.1-design-evidence.json](v0.1-design-evidence.json),记录原引擎来源哈希、三版布局哈希、24 关开局状态、参考动作、旋转前后出口、消除批次与实际成功路线。
|
||||
- 复算脚本:[analyze-v01-design.mjs](../scripts/analyze-v01-design.mjs)。运行 `node scripts/analyze-v01-design.mjs`;需要本地保留初始 Git 提交。脚本在临时目录加载历史引擎,仅输出证据 JSON,不改游戏规则或关卡。
|
||||
|
||||
历史源文件均以 `ef2724b` 为准,可通过 `git show ef2724b:文件路径` 查阅:
|
||||
|
||||
| 历史路径 | 主要依据 |
|
||||
| --- | --- |
|
||||
| `README.md`,21–27 行 | 路径、旋转、碰撞、尝试成本及全部关卡必须旋转 |
|
||||
| `docs/video-analysis.md`,3–9 行 | 区分视频观察、补充规则与项目实现约定 |
|
||||
| `docs/level-design.md`,34–72 行 | 旋转、连续碰撞、求解生成、旧评分及未做玩家标定的说明 |
|
||||
| `scripts/generate-levels.mjs`,6–18 行 | 候选规模、初始旋转、必须旋转筛选、余量、排序与选关 |
|
||||
| `src/core.js`,136–214 行 | 求解状态、消除批次、评分及逆向生成 |
|
||||
| `src/app.js`,21–26 行 | 三章与每章八关 |
|
||||
| `tests/core.test.js`,31–33、99–103 行 | 全 24 关验收,以及首关最后一枚闭环后旋转完成的预期行为 |
|
||||
|
||||
本文没有把参考视频中的遮挡轨道当作已完整复原的数据,也没有把生成规则推测成全部来自原视频。参考解答并非所有玩家顺序;统计描述结构,不代表真人体验已经验证。文中关于“让路”“阶段换路”“长路回报”的表达,是依据历史规则和回放所做的设计解释。
|
||||
+6
-5
@@ -1,16 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#A8BECF"><title>HEX TRAILS · 转转六角</title><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="design/tokens.css"><link rel="stylesheet" href="design/components.css"></head>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#A8BECF"><title>HEX TRAILS · 转转六角</title><link rel="stylesheet" href="style.css"><link rel="stylesheet" href="design/tokens.css"><link rel="stylesheet" href="design/components.css"><link rel="stylesheet" href="design/notifications.css"><link rel="stylesheet" href="design/campaign.css"></head>
|
||||
<body>
|
||||
|
||||
<div class="game-shell">
|
||||
<header class="game-top"><button class="game-icon" id="map-button" aria-label="选择关卡"><svg><use href="#i-grid"/></svg></button><button class="game-icon help-trigger" aria-label="玩法与设置"><svg><use href="#i-help"/></svg></button></header>
|
||||
<main class="workspace">
|
||||
<section class="game-column">
|
||||
<div class="level-heading"><div class="level-ribbon" id="level-eyebrow">LEVEL 01</div><h1 id="level-name">一眼看见出口</h1><button class="difficulty-pill" id="difficulty-button"><span id="difficulty-label">轻松 · 0</span><span>详情</span></button></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>
|
||||
<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">轻松 · 7</span><span>详情</span></button></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">8<small> / 8</small></strong></div></div><div class="moves-bubble"><span id="moves-title">待解棋子</span><strong id="moves">8</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="notice-slot"><div class="game-notice" id="board-toast" role="status" aria-live="polite" aria-atomic="true"></div></div>
|
||||
<article class="game-card">
|
||||
<div class="canvas-wrap"><canvas id="board" aria-label="六角轨道棋盘,点击棋子沿轨道离场,拖动边缘旋转底盘"></canvas><div class="board-toast" id="board-toast" role="status" aria-live="polite"></div></div>
|
||||
<div class="board-instruction" id="instruction">点击箭头,让棋子沿轨道离场</div>
|
||||
<div class="canvas-wrap"><canvas id="board" aria-label="六角轨道棋盘,点击棋子沿轨道离场,拖动边缘旋转底盘"></canvas></div>
|
||||
<div class="board-instruction" id="instruction">箭头决定起步方向,棋子会沿弯轨自动转向</div>
|
||||
<div class="rotation-controls" id="rotation-controls"><button id="rotate-left" class="rotate-button" aria-label="向左旋转底盘 60 度"><span class="ui-icon" data-icon="left"></span><b>左转</b></button><button id="rotate-right" class="rotate-button" aria-label="向右旋转底盘 60 度"><span class="ui-icon" data-icon="right"></span><b>右转</b></button></div>
|
||||
<div class="board-tools" id="game-tools"><button id="undo-button"><span class="tool-icon blue"><svg><use href="#i-undo"/></svg></span><b>撤销</b></button><button id="reset-button"><span class="tool-icon pink"><svg><use href="#i-reset"/></svg></span><b>重来</b></button><button id="hint-button"><span class="tool-icon gold"><svg><use href="#i-bulb"/></svg><small>免费</small></span><b>提示</b></button></div>
|
||||
<div class="editor-board-tools" id="editor-board-tools" hidden><button id="editor-undo"><span class="ui-icon" data-icon="undo"></span> 撤销编辑</button><button id="trial-button" class="primary"><span class="ui-icon" data-icon="play"></span> 试玩关卡</button></div>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/dist/
|
||||
/research/session/
|
||||
@@ -0,0 +1,10 @@
|
||||
# Hex Video Lab 工作范围
|
||||
|
||||
用户要求:本项工作的所有后续产出统一放在项目根目录 `labs/hex-video/` 内。
|
||||
|
||||
- 页面、代码、设计文档、参考帧、采样数据、脚本、测试、日志和构建输出均存放在本目录中。
|
||||
- 生成 HTML:`npm --prefix labs/hex-video run design`。
|
||||
- 测试:`npm --prefix labs/hex-video test`。
|
||||
- 构建:`npm --prefix labs/hex-video run build`,输出到 `labs/hex-video/dist/`。
|
||||
- 共享游戏模块从项目 `src/` 只读引用;不要为本实验室在本目录外的 `scripts/`、`tests/`、其他 `labs/` 子目录或根目录 `dist/` 新增产出。
|
||||
- 保持 `designv2.md` 与生成的 `designv2.html` 同步。
|
||||
@@ -1,6 +1,6 @@
|
||||
# HEX Video Lab 01
|
||||
|
||||
独立的 UI / 动效实验室,入口:`http://localhost:4173/labs/hex-video/index.html`。根目录运行 `npm run dev`;`npm run build` 会将 lab 一起输出到 `dist/labs/hex-video/`。
|
||||
独立的 UI / 动效实验室,入口:`http://localhost:4173/labs/hex-video/index.html`。根目录运行 `npm run dev`;`npm --prefix labs/hex-video run build` 会将独立站点输出到 `labs/hex-video/dist/`。
|
||||
|
||||
完整用色分析见 [designv2.md](designv2.md) 与 [离线 HTML 手册](designv2.html),按项目 Design.md 结构整理,并附原尺寸参考帧、采样记录与色板。
|
||||
|
||||
@@ -37,4 +37,15 @@
|
||||
|
||||
当前是浏览器 lab;未做 Cocos 或微信真机验证。
|
||||
|
||||
HTML 手册包含可搜索/复制色板、原尺寸参考帧灯箱、箭头对比示例和完整正文,图片全部内嵌。修改 Markdown 或采样 JSON 后运行 `npm run design:v2`;根项目 `npm run design` 也会同步生成。
|
||||
HTML 手册包含可搜索/复制色板、原尺寸参考帧灯箱、箭头对比示例和完整正文,图片全部内嵌。修改 Markdown 或采样 JSON 后运行 `npm --prefix labs/hex-video run design`(根目录的 `npm run design:v2` 保留兼容入口)。
|
||||
|
||||
## 目录约定
|
||||
|
||||
本项所有新增产出只放在 `labs/hex-video/`。`scripts/` 保存生成与构建脚本,`tests/` 保存测试,`reference/` 保存视频与颜色证据,`research/session/` 归档本次中间抽帧及检查日志,`dist/` 保存可部署站点。正式游戏 `../../src/` 仅作为共享依赖读取,原视频仍保留在用户提供的 `../../examples/hex.mp4`。
|
||||
|
||||
- 设计文档:`npm --prefix labs/hex-video run design`
|
||||
- 独立测试:`npm --prefix labs/hex-video test`
|
||||
- 独立构建:`npm --prefix labs/hex-video run build`
|
||||
- 浏览预览:在项目根目录运行 `npm run dev`,访问 `/labs/hex-video/index.html`。
|
||||
|
||||
根项目的常规构建不再向其 `dist/` 复制实验室,防止本项产出散落到第二个目录。
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<h3 id="section-341">8.3 验收步骤</h3>
|
||||
<ol><li>明确修改的是参考还原还是可读性适配,标记视频观察、采样与新增规则。</li><li>选择对应原尺寸参考帧,在同一 416×960 逻辑比例比较背景、底板、棋子和 HUD 的面积与位置。</li><li>对每个色族比较顶面、侧边和背面,确认只有一片,检查倒角是否误成宽白描边。</li><li>重算指定 ROI;若改动 ROI 或过滤阈值,保留说明。不要以几处亮边像素代表整个顶面。</li><li>检查地图→解谜→领奖→返回的彩度节奏;不把奖励背景留在解谜阶段。</li><li>检查所有箭头、数字、标签和主按钮前景/背景配对;透明图层与抗锯齿必须额外目视验收。</li><li>检查颜色在移动转向、旋转、撤销、重新开始后的稳定性;颜色本身不能代替合法行动判定。</li><li>若实施代码变更,再运行项目测试和构建,验证桌面、390×844、窄屏、模态与减少动态效果。本次文档变更不代表已完成这些新色值的浏览器验收。</li><li>原生/Cocos 采用同一角色与资源后再进行真机确认;本文没有原生渲染验证结论。</li></ol>
|
||||
<h3 id="section-353">8.4 完成范围</h3>
|
||||
<p>本次完成:十张新增 PNG 证据帧、36 个局部采样记录、五张帧的彩度面积统计、跨帧色族检查、采样色板,以及本文。没有修改 Lab 的运行配色、正式版资产或玩法。所有推荐值可追溯到帧与 ROI;无法从视频确定的素材色、字体、遮罩参数、声音与时长均已明确区分。</p></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>
|
||||
<p>本次完成:十张新增 PNG 证据帧、36 个局部采样记录、五张帧的彩度面积统计、跨帧色族检查、采样色板,以及本文。没有修改 Lab 的运行配色、正式版资产或玩法。所有推荐值可追溯到帧与 ROI;无法从视频确定的素材色、字体、遮罩参数、声音与时长均已明确区分。</p></div></article></main><footer>由 designv2.md 与 samples.json 同步生成 · npm --prefix labs/hex-video run design</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);}});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "hex-video-lab",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"design": "node scripts/build-design-v2.mjs",
|
||||
"test": "node --test tests/*.test.js",
|
||||
"build": "npm run design && node scripts/build.mjs"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, extname, dirname } from 'node:path';
|
||||
import { resolve, extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const root = fileURLToPath(new URL('../labs/hex-video/', import.meta.url));
|
||||
const root = fileURLToPath(new URL('../', 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]));
|
||||
@@ -40,7 +40,7 @@ const gallery=seconds.map(t=>`<button class="frame" data-frame="${t}"><img loadi
|
||||
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>
|
||||
</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 --prefix labs/hex-video run design</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);}});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
const root = new URL('../', import.meta.url);
|
||||
const out = new URL('dist/', root);
|
||||
await rm(out, { recursive: true, force: true });
|
||||
await mkdir(new URL('labs/hex-video/', out), { recursive: true });
|
||||
await mkdir(new URL('src/', out), { recursive: true });
|
||||
for (const name of ['index.html','lab.js','lab.css','fixture.js','designv2.md','designv2.html','README.md','reference']) {
|
||||
await cp(new URL(name, root), new URL('labs/hex-video/'+name, out), { recursive: true });
|
||||
}
|
||||
for (const name of ['core.js','geometry.js','path-math.js']) {
|
||||
await cp(new URL('../../src/'+name, root), new URL('src/'+name, out));
|
||||
}
|
||||
await cp(new URL('../../Design.md', root), new URL('Design.md', out));
|
||||
await writeFile(new URL('index.html', out), '<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=labs/hex-video/index.html"><title>HEX Video Lab</title><a href="labs/hex-video/index.html">打开 HEX Video Lab</a></html>');
|
||||
console.log('Hex Video build ready in labs/hex-video/dist/');
|
||||
@@ -1,7 +1,7 @@
|
||||
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';
|
||||
import { Game, solve, validateLevel } from '../../../src/core.js';
|
||||
import { makeLabLevel } from '../fixture.js';
|
||||
|
||||
test('video lab fixture validates and can complete through real game actions', () => {
|
||||
const level = makeLabLevel();
|
||||
+7497
-3366
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,648 @@
|
||||
{
|
||||
"revision": 2,
|
||||
"note": "Curated by actual curved removal paths, with challenge/release rhythm. Run npm run levels after changing a seed or its options.",
|
||||
"seeds": [
|
||||
{
|
||||
"level": 1,
|
||||
"seed": 217021,
|
||||
"options": {
|
||||
"radius": 3,
|
||||
"target": 8,
|
||||
"allFree": true,
|
||||
"minBaseTurns": 1,
|
||||
"maxBaseTurns": 1,
|
||||
"maxTurns": 3,
|
||||
"bendRate": 0.35,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"seed": 160112,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.4,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"seed": 251063,
|
||||
"options": {
|
||||
"radius": 3,
|
||||
"target": 12,
|
||||
"minBaseTurns": 1,
|
||||
"maxBaseTurns": 2,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"bendRate": 0.55,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 4,
|
||||
"seed": 160019,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.4,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 5,
|
||||
"seed": 2400258,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 12,
|
||||
"bendRate": 0.24,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 6,
|
||||
"seed": 2440508,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 16,
|
||||
"bendRate": 0.36,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 7,
|
||||
"seed": 2420276,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 14,
|
||||
"bendRate": 0.24,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 8,
|
||||
"seed": 160589,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.4,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 9,
|
||||
"seed": 2420811,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 14,
|
||||
"bendRate": 0.18,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 10,
|
||||
"seed": 170073,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 7,
|
||||
"bendRate": 0.5,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 11,
|
||||
"seed": 2440615,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 16,
|
||||
"bendRate": 0.42,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 2,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 12,
|
||||
"seed": 2460740,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 18,
|
||||
"bendRate": 0.48,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 13,
|
||||
"seed": 2440401,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 16,
|
||||
"bendRate": 0.3,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 14,
|
||||
"seed": 181169,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.4,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 15,
|
||||
"seed": 881321,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 16,
|
||||
"bendRate": 0.32,
|
||||
"maxTurns": 4,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 16,
|
||||
"seed": 170376,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 7,
|
||||
"bendRate": 0.5,
|
||||
"maxTurns": 5,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 17,
|
||||
"seed": 2460847,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 18,
|
||||
"bendRate": 0.18,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 18,
|
||||
"seed": 2481400,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 20,
|
||||
"bendRate": 0.48,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 19,
|
||||
"seed": 901335,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 18,
|
||||
"bendRate": 0.32,
|
||||
"maxTurns": 4,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 20,
|
||||
"seed": 187104,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.7,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 21,
|
||||
"seed": 2461275,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 18,
|
||||
"bendRate": 0.42,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 22,
|
||||
"seed": 172109,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.6,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 23,
|
||||
"seed": 2481721,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 20,
|
||||
"bendRate": 0.3,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 2,
|
||||
"curvePreference": 8,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 24,
|
||||
"seed": 2504521,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 22,
|
||||
"bendRate": 0.42,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 25,
|
||||
"seed": 2460526,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 18,
|
||||
"bendRate": 0.36,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 26,
|
||||
"seed": 160798,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.5,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 27,
|
||||
"seed": 921246,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 20,
|
||||
"bendRate": 0.25,
|
||||
"maxTurns": 4,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 28,
|
||||
"seed": 172099,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.6,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 29,
|
||||
"seed": 2482149,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 20,
|
||||
"bendRate": 0.18,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 30,
|
||||
"seed": 2500990,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 22,
|
||||
"bendRate": 0.24,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 31,
|
||||
"seed": 2482577,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 20,
|
||||
"bendRate": 0.42,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 32,
|
||||
"seed": 160759,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.6,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 33,
|
||||
"seed": 921452,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 20,
|
||||
"bendRate": 0.4,
|
||||
"maxTurns": 4,
|
||||
"maxBaseTurns": 2,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 34,
|
||||
"seed": 172309,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.6,
|
||||
"maxTurns": 5,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 35,
|
||||
"seed": 2508052,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 22,
|
||||
"bendRate": 0.24,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 36,
|
||||
"seed": 974458,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 24,
|
||||
"bendRate": 0.48,
|
||||
"maxTurns": 4,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 37,
|
||||
"seed": 2513188,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 22,
|
||||
"bendRate": 0.24,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 1,
|
||||
"curvePreference": 8,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 38,
|
||||
"seed": 190148,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.45,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 2,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 39,
|
||||
"seed": 2505336,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 22,
|
||||
"bendRate": 0.35,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 2,
|
||||
"curvePreference": 8,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 40,
|
||||
"seed": 172561,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.7,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 41,
|
||||
"seed": 967660,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 24,
|
||||
"bendRate": 0.4,
|
||||
"maxTurns": 4,
|
||||
"maxBaseTurns": 2,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 42,
|
||||
"seed": 1821618,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 25,
|
||||
"bendRate": 0.3,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 2,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 43,
|
||||
"seed": 2481813,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 20,
|
||||
"bendRate": 0.25,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 2,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 44,
|
||||
"seed": 188016,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.35,
|
||||
"maxTurns": 4,
|
||||
"curvePreference": 2,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 45,
|
||||
"seed": 2514582,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 22,
|
||||
"bendRate": 0.3,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 2,
|
||||
"curvePreference": 3,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 46,
|
||||
"seed": 172752,
|
||||
"options": {
|
||||
"radius": 2,
|
||||
"target": 6,
|
||||
"bendRate": 0.7,
|
||||
"maxTurns": 5,
|
||||
"curvePreference": 3,
|
||||
"minBaseTurns": 0,
|
||||
"clearBlockers": true,
|
||||
"startRotation": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 47,
|
||||
"seed": 981973,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 24,
|
||||
"bendRate": 0.35,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 2,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"level": 48,
|
||||
"seed": 1833086,
|
||||
"options": {
|
||||
"radius": 4,
|
||||
"target": 25,
|
||||
"bendRate": 0.25,
|
||||
"maxTurns": 6,
|
||||
"maxBaseTurns": 3,
|
||||
"curvePreference": 5,
|
||||
"clearBlockers": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+13002
-11520
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"note": "Exact historical campaign JSON bytes archived from Git. Layouts, names and original moveLimit values are unchanged. Validation uses the current shared game rules: rotations and failed probes are free; only successful removals consume budget. These are historical level layouts, not complete reproductions of historical application code or scoring rules.",
|
||||
"versions": [
|
||||
{
|
||||
"version": "v0.1",
|
||||
"sourceCommit": "ef2724bb2e6582c92ac26b3a45fcc18c406d6c21",
|
||||
"sourceRef": "ef2724b",
|
||||
"sourcePath": "levels/campaign.json",
|
||||
"sourceBlob": "bb7b2886092acc599c85034224348ff239209887",
|
||||
"archivePath": "levels/versions/v0.1.json",
|
||||
"sha256": "07f1ccdb985d37f6d1fc6e132aeab63423045427f00af2a7bf8bdcf6ecefc9d1",
|
||||
"bytes": 170975,
|
||||
"levelCount": 24,
|
||||
"firstLevelName": "转一转,路就通了",
|
||||
"lastLevelName": "六角大满贯",
|
||||
"legacyStorageKeys": [
|
||||
"hex-trails-v2"
|
||||
],
|
||||
"legacyDraftStorageKey": "hex-trails-draft",
|
||||
"historicalMoveLimit": {
|
||||
"preserved": true,
|
||||
"min": 8,
|
||||
"max": 32
|
||||
},
|
||||
"validation": {
|
||||
"engine": "current shared src/core.js",
|
||||
"validLevels": 24,
|
||||
"solvableLevels": 24,
|
||||
"replayedWins": 24,
|
||||
"failedReplayActions": 0,
|
||||
"minimumRotations": 1,
|
||||
"maximumRotations": 3,
|
||||
"minimumBudgetAfterWin": 3,
|
||||
"maximumBudgetAfterWin": 9,
|
||||
"initialIndependentLoopPieces": 6,
|
||||
"levelsWithInitialIndependentLoops": [
|
||||
1,
|
||||
6,
|
||||
7,
|
||||
16,
|
||||
20,
|
||||
21
|
||||
],
|
||||
"initialSweptOnlyBlockedPieces": 119,
|
||||
"levelsWithInitialSweptOnlyBlocks": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
19,
|
||||
20,
|
||||
21,
|
||||
22,
|
||||
23,
|
||||
24
|
||||
],
|
||||
"note": "Historical loops and swept collisions are intentionally preserved. Audited independently against the historical git blob."
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "v0.2",
|
||||
"sourceCommit": "70b021d4cc2fecc832bb3a601452953ed0bd6dd5",
|
||||
"sourceRef": "70b021d",
|
||||
"sourcePath": "levels/campaign.json",
|
||||
"sourceBlob": "601b4ae192935bd3e27ad4eb2bd33770988596a8",
|
||||
"archivePath": "levels/versions/v0.2.json",
|
||||
"sha256": "becf6422b9e49bda72a7b4c5f6ecd55e40a3bf2f90dd244f9e80a25a1a8589da",
|
||||
"bytes": 434460,
|
||||
"levelCount": 48,
|
||||
"firstLevelName": "一眼看见出口",
|
||||
"lastLevelName": "自在回响",
|
||||
"legacyStorageKeys": [
|
||||
"hex-trails-observe-v3"
|
||||
],
|
||||
"legacyDraftStorageKey": "hex-trails-draft",
|
||||
"historicalMoveLimit": {
|
||||
"preserved": true,
|
||||
"min": 4,
|
||||
"max": 33
|
||||
},
|
||||
"validation": {
|
||||
"engine": "current shared src/core.js",
|
||||
"validLevels": 48,
|
||||
"solvableLevels": 48,
|
||||
"replayedWins": 48,
|
||||
"failedReplayActions": 0,
|
||||
"initialIndependentLoopPieces": 0,
|
||||
"levelsWithInitialIndependentLoops": [],
|
||||
"initialSweptOnlyBlockedPieces": 0,
|
||||
"levelsWithInitialSweptOnlyBlocks": [],
|
||||
"referenceSweptOnlyBlockedStates": 0,
|
||||
"minimumRotations": 0,
|
||||
"maximumRotations": 1,
|
||||
"minimumBudgetAfterWin": 0,
|
||||
"maximumBudgetAfterWin": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -6,9 +6,10 @@
|
||||
"scripts": {
|
||||
"dev": "node scripts/serve.mjs",
|
||||
"test": "node --test",
|
||||
"build": "npm run design && node scripts/build.mjs",
|
||||
"levels": "node scripts/generate-levels.mjs",
|
||||
"design": "node scripts/build-design.mjs && node scripts/build-design-guide.mjs && npm run design:v2",
|
||||
"design:v2": "node scripts/build-design-v2.mjs"
|
||||
"build": "npm run levels:versions && npm run design && node scripts/build.mjs",
|
||||
"levels": "node scripts/generate-levels.mjs && npm run levels:versions",
|
||||
"levels:versions": "node scripts/build-campaign-versions.mjs",
|
||||
"design": "node scripts/build-design.mjs && node scripts/build-design-guide.mjs",
|
||||
"design:v2": "npm --prefix labs/hex-video run design"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// Read-only campaign audit: pin the original engine so later rule/scoring edits
|
||||
// cannot silently change our account of the first 24 levels.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const root=fileURLToPath(new URL('../',import.meta.url));
|
||||
const commit='ef2724bb2e6582c92ac26b3a45fcc18c406d6c21';
|
||||
const hash=data=>createHash('sha256').update(data).digest('hex');
|
||||
const scratch=await mkdtemp(join(tmpdir(),'hex-v01-design-'));
|
||||
try {
|
||||
const engineSources=[];
|
||||
await writeFile(join(scratch,'package.json'),'{"type":"module"}\n');
|
||||
for(const path of ['src/core.js','src/path-math.js']){
|
||||
const bytes=execFileSync('git',['show',`${commit}:${path}`],{cwd:root});
|
||||
await writeFile(join(scratch,path.split('/').at(-1)),bytes);
|
||||
engineSources.push({path,sha256:hash(bytes)});
|
||||
}
|
||||
const {analyze,trace,Game,available,opposite,validateLevel}=await import(pathToFileURL(join(scratch,'core.js')));
|
||||
const bendCount=route=>route.steps.filter(s=>s.enter!==null&&opposite(s.enter)!==s.exit).length;
|
||||
const auditLevel=(level,index)=>{
|
||||
const errors=validateLevel(level);if(errors.length)throw new Error(`${level.name}: ${errors.join('; ')}`);
|
||||
const a=analyze(level),game=new Game({...level,moveLimit:999});
|
||||
if(!a.reachable||a.exhausted)throw new Error(`Unconfirmed layout: ${level.name}`);
|
||||
const opening=level.tiles.map(t=>{
|
||||
const route=trace(level,t),solo=trace(level,t,level.tiles,true);
|
||||
const blocker=level.tiles.find(b=>b.id===route.blocker);
|
||||
const sweptOnly=route.status==='blocked'&&blocker&&!solo.steps.some(s=>s.q===blocker.q&&s.r===blocker.r);
|
||||
return {id:t.id,q:t.q,r:t.r,dir:t.dir,status:route.status,blocker:route.blocker,independentLoop:solo.status==='loop',sweptOnly:!!sweptOnly};
|
||||
});
|
||||
const removals=[],rotations=[];
|
||||
for(const action of a.actions){
|
||||
if(action.type==='rotate'){
|
||||
const before=available(game.level,game.tiles).map(t=>t.id);
|
||||
if(game.rotate(action.direction).status!=='rotated')throw new Error('Failed rotation replay');
|
||||
rotations.push({direction:action.direction,remaining:game.tiles.map(t=>t.id),before,after:available(game.level,game.tiles).map(t=>t.id)});
|
||||
}else{
|
||||
const route=game.tap(action.id);if(route.status!=='exit')throw new Error(`Failed removal: ${level.name}`);
|
||||
removals.push({id:action.id,orientation:game.orientation,bends:bendCount(route),cells:route.steps.length});
|
||||
}
|
||||
}
|
||||
if(!game.won)throw new Error('Incomplete replay');
|
||||
return {
|
||||
level:index+1,id:level.id,name:level.name,cells:level.cells.length,tiles:level.tiles.length,
|
||||
occupancy:level.tiles.length/level.cells.length,originalScore:a.score,originalMoveLimit:level.moveLimit,
|
||||
originalMinimumMoves:a.minimumMoves,originalBudgetSolvable:a.solvable,minimumRotations:a.rotations,
|
||||
free:a.free,blocked:opening.filter(p=>p.status==='blocked').length,initialIndependentLoops:opening.filter(p=>p.independentLoop).length,
|
||||
initialSweptOnlyBlockers:opening.filter(p=>p.sweptOnly).length,meanIndependentTurns:a.meanTurns,
|
||||
removalWaves:a.depth,opening,actions:a.actions,waves:a.waves,rotations,removals,
|
||||
curvedRemovals:removals.filter(p=>p.bends>0).length,totalSuccessfulBends:removals.reduce((s,p)=>s+p.bends,0),
|
||||
};
|
||||
};
|
||||
const sources=[['v0.1','levels/versions/v0.1.json'],['v0.2','levels/versions/v0.2.json'],['v0.3','levels/campaign.json']];
|
||||
const editions=[];
|
||||
for(const [version,path] of sources){
|
||||
const bytes=await readFile(join(root,path)),levels=JSON.parse(bytes),rows=levels.map(auditLevel);
|
||||
if(version==='v0.1'){
|
||||
const historical=execFileSync('git',['show',`${commit}:levels/campaign.json`],{cwd:root});
|
||||
if(!bytes.equals(historical)||rows.some(r=>!r.originalBudgetSolvable))throw new Error('Original layout or historical budget verification failed');
|
||||
}
|
||||
const sum=field=>rows.reduce((s,r)=>s+r[field],0),bounds=field=>[Math.min(...rows.map(r=>r[field])),Math.max(...rows.map(r=>r[field]))];
|
||||
editions.push({version,path,sha256:hash(bytes),count:rows.length,
|
||||
summary:{cells:[...new Set(rows.map(r=>r.cells))].sort((a,b)=>a-b),tiles:bounds('tiles'),totalTiles:sum('tiles'),
|
||||
requiresRotation:rows.filter(r=>r.minimumRotations>0).length,rotationRange:bounds('minimumRotations'),rotationCounts:Object.fromEntries([0,1,2,3].map(n=>[n,rows.filter(r=>r.minimumRotations===n).length])),
|
||||
initialFree:sum('free'),initialFreeRatio:sum('free')/sum('tiles'),zeroFreeLevels:rows.filter(r=>!r.free).map(r=>r.level),
|
||||
initialIndependentLoops:sum('initialIndependentLoops'),initialSweptOnlyBlockers:sum('initialSweptOnlyBlockers'),
|
||||
removalWaves:bounds('removalWaves'),curvedRemovals:sum('curvedRemovals'),curvedRatio:sum('curvedRemovals')/sum('tiles'),
|
||||
totalSuccessfulBends:sum('totalSuccessfulBends'),originalScores:version==='v0.1'?bounds('originalScore'):undefined,
|
||||
originalScoresNondecreasing:version==='v0.1'?rows.every((r,i)=>!i||r.originalScore>=rows[i-1].originalScore):undefined},
|
||||
levels:version==='v0.1'?rows:undefined});
|
||||
}
|
||||
const report={basis:{commit,engineSources,
|
||||
note:'All geometry, opening states and reference solutions use the pinned original engine. All reference replays use budget 999 to compare route structures; v0.1 additionally verifies its original move budget. Original scores and move budgets are interpreted only for v0.1; reference route usage is not all possible player orders. No player telemetry.'},editions};
|
||||
await writeFile(join(root,'docs/v0.1-design-evidence.json'),JSON.stringify(report,null,2)+'\n');
|
||||
for(const edition of editions)console.log(edition.version,JSON.stringify(edition.summary));
|
||||
console.log('Original 24:',editions[0].levels.map(r=>`${r.level} ${r.name}: ${r.cells}/${r.tiles}, free ${r.free}, turns ${r.minimumRotations}, waves ${r.removalWaves}, score ${r.originalScore}`).join('\n'));
|
||||
} finally { await rm(scratch,{recursive:true,force:true}); }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const root=new URL('../',import.meta.url);
|
||||
const manifest=JSON.parse(await readFile(new URL('levels/versions/provenance.json',root),'utf8'));
|
||||
const archives={};
|
||||
for(const source of manifest.versions) {
|
||||
const bytes=await readFile(new URL(source.archivePath,root));
|
||||
if(createHash('sha256').update(bytes).digest('hex')!==source.sha256)throw new Error(`Historical archive changed: ${source.version}`);
|
||||
const levels=JSON.parse(bytes.toString('utf8'));
|
||||
if(levels.length!==source.levelCount)throw new Error(`Historical level count changed: ${source.version}`);
|
||||
archives[source.version]=levels;
|
||||
}
|
||||
await writeFile(new URL('src/levels-archive.js',root),'// Generated by npm run levels:versions from byte-preserved historical JSON.\nexport const ARCHIVED_LEVELS = '+JSON.stringify(archives)+';\n');
|
||||
console.log('Historical versions synchronized: '+Object.entries(archives).map(([id,levels])=>`${id} (${levels.length} levels)`).join(', '));
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||
const out=new URL('../dist/',import.meta.url);
|
||||
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
||||
for(const name of ['index.html','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});
|
||||
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||
console.log('Static build ready in dist/');
|
||||
|
||||
+30
-35
@@ -1,5 +1,5 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { generateFlowLevel, generateLaneLevel, inspectFlowLevel, CAMPAIGN_PLAN } from '../src/campaign-generator.js';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { generateFlowLevel, inspectFlowLevel, CAMPAIGN_PLAN } from '../src/campaign-generator.js';
|
||||
import { validateLevel } from '../src/core.js';
|
||||
|
||||
const beats=['进入','换路','展开','小挑战','释放','长局回报'];
|
||||
@@ -12,8 +12,8 @@ const intents=[
|
||||
'用较大的棋盘和更多清晰路线收束;增加完成感与体验长度。',
|
||||
];
|
||||
const names=[
|
||||
['一眼看见出口','转一下,就通了','顺着线出发','解开小结','一片畅通','轻松清空大盘'],
|
||||
['熟悉的方向','换条短路','接连找到出口','小小转机','一路放行','铺开好心情'],
|
||||
['跟着弯道走','转一下,路会变','交叉处不换线','先解开这道弯','一弯接一弯','把弯路连起来'],
|
||||
['沿着轨道看出口','换条短路','接连找到出口','小小转机','一路放行','铺开好心情'],
|
||||
['找到节奏','转出一条新路','继续向外走','先解哪一枚','宽阔的回报','长路也轻巧'],
|
||||
['慢慢看,都来得及','只换一个方向','连成顺畅的路','轻巧绕一个弯','松一口气','从容的大舞台'],
|
||||
['又是新的出口','看见新的可能','顺路解开','留意这个转角','满盘好走','长长的成就感'],
|
||||
@@ -21,45 +21,38 @@ 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;
|
||||
}
|
||||
// Explicit curated seeds keep all 48 route designs reviewable and reproducible.
|
||||
const {seeds}=JSON.parse(await readFile(new URL('../levels/campaign-seeds.json',import.meta.url),'utf8'));
|
||||
if(seeds.length!==48||new Set(seeds.map(s=>s.seed)).size!==48)throw new Error('Expected 48 distinct curated seeds');
|
||||
const lessons=[
|
||||
'箭头决定起步方向,棋子会沿弯轨自动转向',
|
||||
'转动底盘会换路,棋子和箭头留在原位',
|
||||
'交叉处不会换轨,沿同一条线找到出口',
|
||||
'先看谁挡住了路线,再决定消除或免费转盘',
|
||||
'跟过一两处弯道,接连解开棋子的出口',
|
||||
'盘面变大,仍然顺着每条弯轨逐一解开',
|
||||
'先看完整路线,再选择出口或免费转盘',
|
||||
];
|
||||
|
||||
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),
|
||||
];
|
||||
const block=seeds.slice(chapter*6,chapter*6+6).map(p=>{
|
||||
const l=generateFlowLevel(p.seed,p.options);
|
||||
if(l.tiles.length!==p.options.target)throw new Error(`Incomplete generated layout for level ${p.level}`);
|
||||
return l;
|
||||
});
|
||||
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]};
|
||||
if(lessons[index]){level.lesson=lessons[index];level.designIntent=lessons[index]+'。在明确反馈中学习路径规则,直线路径只作少量节奏调剂。';}
|
||||
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}`);
|
||||
if(a.pathUsage.curvedRatio<CAMPAIGN_PLAN.minimumCurvedRatio||a.pathUsage.curvedRemovals<3)throw new Error(`Too many straight-only removals: ${level.name}`);
|
||||
if(index===0&&(a.free!==level.tiles.length||a.rotations||a.pathUsage.singleBendRemovals!==level.tiles.length))throw new Error('First level must teach one visible bend per piece without forced order or rotation');
|
||||
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};
|
||||
level.pacing={cognitiveDifficulty:a.score,removals:level.tiles.length,curvedRemovals:a.pathUsage.curvedRemovals,curvedRatio:a.pathUsage.curvedRatio,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});
|
||||
report.push({level:index+1,name:level.name,chapter:chapter+1,beat:beats[beat],lesson:level.lesson,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,pathUsage:a.pathUsage,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,noCurvedChoiceStates:q.noCurvedChoiceStates,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}`);
|
||||
@@ -67,7 +60,9 @@ for(let chapter=0;chapter<CAMPAIGN_PLAN.chapters.length;chapter++){
|
||||
}
|
||||
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. Free observation, spiral pacing; do not difficulty-sort.\nexport const CAMPAIGN = '+JSON.stringify(CAMPAIGN_PLAN)+';\nexport const LEVELS = '+JSON.stringify(levels)+';\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';
|
||||
const pathSummary={removals:report.reduce((n,r)=>n+r.tiles,0),curvedRemovals:report.reduce((n,r)=>n+r.pathUsage.curvedRemovals,0),pureStraightLevels:report.filter(r=>!r.pathUsage.curvedRemovals).length,minimumCurvedRatio:Math.min(...report.map(r=>r.pathUsage.curvedRatio))};
|
||||
pathSummary.curvedRatio=pathSummary.curvedRemovals/pathSummary.removals;
|
||||
await writeFile(new URL('../levels/campaign-analysis.json',import.meta.url),JSON.stringify({campaign:CAMPAIGN_PLAN,pathSummary,estimateNote:'Time values are structural heuristics, not measured player completion times.',levels:report},null,2)+'\n');
|
||||
const md='# 主线关卡验收与节奏表\n\n自动生成:`npm run levels`;固定布局参数见 `levels/campaign-seeds.json`。共 48 关,8 章,每章顺序为进入、换路、展开、小挑战、释放、长局回报。认知难度与棋盘规模分别记录。\n\n全部关卡:可解且回放通关;每枚棋子在全部六朝向独立路线无闭环;参考解答无路线格外擦碰阻挡;相邻消除之间最多连续旋转一次。参考解答检查不等于穷尽玩家的任意旋转顺序,实际阻挡对象会在游戏中标记。\n\n**每关至少 65% 的参考消除路径实际经过弯道,至少 3 枚弯路棋子;空格中没有走过的弯线不计入。** 全部参考解答合计 '+pathSummary.removals+' 次消除,其中 '+pathSummary.curvedRemovals+' 次经过弯道('+(pathSummary.curvedRatio*100).toFixed(1)+'%);纯直出关卡 '+pathSummary.pureStraightLevels+' 个。这里统计的是求解器的实际离场路径,不是任意玩家操作顺序。\n\n**预计时长只用于内部比较,没有真人测试数据。** 公式为 3 × 棋子数 + 0.75 × 初始独立路线总转弯 + 4 × 最少旋转。所有时间均为估计秒数,不能当作通关时限。\n\n| 关 | 名称 | 节奏 | 格数 | 棋子 | 弯路离场 | 弯路占比 | 认知难度 | 开局可解 | 必要旋转 | 预计秒数 |\n|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:|\n'+report.map(r=>`| ${r.level} | ${r.name} | ${r.beat} | ${r.cells} | ${r.tiles} | ${r.pathUsage.curvedRemovals} | ${(r.pathUsage.curvedRatio*100).toFixed(1)}% | ${r.difficulty} | ${r.free} | ${r.necessaryRotations} | ${r.estimatedSeconds} |`).join('\n')+'\n\n## 前七关的路径教学\n\n'+report.slice(0,7).map(r=>`${r.level}. **${r.name}**:${r.lesson}。`).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.');
|
||||
|
||||
+37
-18
@@ -3,9 +3,10 @@ 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 { icon, installUI } from './ui.js';
|
||||
import { icon, installUI, decorateUI } from './ui.js';
|
||||
import { Game, analyze, available, clone, generateLevel, hexagon, key, random, randomTracks, rotateTracks, rotateBoard, canRotate, solveRotations, trace, validateLevel } from './core.js';
|
||||
import { CAMPAIGN, LEVELS } from './levels.js';
|
||||
import { CAMPAIGN_VERSIONS, campaignVersion } from './campaign-versions.js';
|
||||
import { selectedCampaign, readCampaignProgress, writeCampaignProgress } from './campaign-progress.js';
|
||||
import { layout, hitCell } from './geometry.js';
|
||||
import { TILE_ATLAS } from './sprites.js';
|
||||
import { CanvasPainter, COLORS, drawScene } from './renderer.js';
|
||||
@@ -13,10 +14,12 @@ const taps=new TapQueue();
|
||||
const $=id=>document.getElementById(id);
|
||||
const canvas=$('board'),ctx=canvas.getContext('2d'),painter=new CanvasPainter(ctx);
|
||||
let boardViewport;
|
||||
const STORE=`hex-trails-${CAMPAIGN.id}`;
|
||||
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<LEVELS.length):[]);
|
||||
let current=Number.isInteger(saved.current)&&saved.current>=0&&saved.current<LEVELS.length?saved.current:0;
|
||||
let progressStorage;try{progressStorage=window.localStorage;}catch{}
|
||||
let saveUnavailable=!progressStorage;
|
||||
let campaign=selectedCampaign(progressStorage),LEVELS=campaign.levels;
|
||||
const sessionProgress=new Map();
|
||||
const saved=readCampaignProgress(progressStorage,campaign);
|
||||
let completed=new Set(saved.completed),current=saved.current;
|
||||
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 activeTheme=null;
|
||||
@@ -33,11 +36,19 @@ document.addEventListener('pointerdown',unlockAudio,{passive:true});
|
||||
document.addEventListener('click',e=>{if(e.target.closest('button'))playSound('tap');});
|
||||
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const metricLabels={bends:['平均弯道',22],longestRoute:['最多弯道',12],dependencies:['解锁依赖',18],blocked:['初始阻挡',28],sweptBlockers:['侧面阻挡',12],rotation:['必要旋转',8]};
|
||||
const chapters=CAMPAIGN.chapters,chapterSize=CAMPAIGN.chapterSize;
|
||||
let chapters=campaign.chapters,chapterSize=campaign.chapterSize;
|
||||
const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
function persist(){try{localStorage.setItem(STORE,JSON.stringify({current,completed:[...completed]}));}catch{toast('浏览器未允许保存,进度仅保留在当前页面');}}
|
||||
function persist(){const progress={current,completed:[...completed]};sessionProgress.set(campaign.id,progress);saveUnavailable=!writeCampaignProgress(progressStorage,campaign,progress);if(saveUnavailable)toast('浏览器未允许保存,进度仅保留在当前页面');}
|
||||
function switchCampaign(id){
|
||||
const next=campaignVersion(id);if(next.id===campaign.id)return;
|
||||
persist();
|
||||
campaign=next;LEVELS=campaign.levels;chapters=campaign.chapters;chapterSize=campaign.chapterSize;
|
||||
const progress=sessionProgress.get(campaign.id)||readCampaignProgress(progressStorage,campaign);
|
||||
completed=new Set(progress.completed);startLevel(progress.current);
|
||||
}
|
||||
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(){
|
||||
$('map-button').ariaLabel=`选择关卡,当前 ${campaign.id}`;
|
||||
$('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} / ${LEVELS.length}`;$('campaign-progress').style.width=`${completed.size/LEVELS.length*100}%`;
|
||||
}
|
||||
@@ -58,7 +69,7 @@ function renderStats(){
|
||||
$('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.lost||!canRotate(game.level)||game.level.rotationEnabled===false;
|
||||
$('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?(!trial&&level.lesson?escapeHtml(level.lesson):'点击箭头离场 · 左右旋转免费,慢慢观察'):'暂时没有出口 · 可以继续免费旋转,观察路线')}`;
|
||||
if(!editing&&!animation&&!rotationAnimation&&!game.won&&game.moves>0&&!available(game.level,game.tiles).length){
|
||||
const solution=solveRotations({...game.level,tiles:game.tiles,moveLimit:game.moves});
|
||||
const action=solution.actions[0];
|
||||
@@ -86,9 +97,10 @@ function draw(now=performance.now()){
|
||||
}
|
||||
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,duration=3200){$('board-toast').textContent=message;$('board-toast').classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('board-toast').classList.remove('visible'),duration);}
|
||||
function showLesson(){if(mode==='play'&&!trial&&game.initialLevel.lesson)toast(game.initialLevel.lesson,6500);}
|
||||
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){boardViewport?.camera.reset();current=i;trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();}
|
||||
function startLevel(i){if(!Number.isInteger(i)||i<0||i>=LEVELS.length)return;boardViewport?.camera.reset();current=i;trial=false;mode='play';game=new Game(LEVELS[i]);helpUsed=false;resetEffects();applyMode();persist();renderMap();renderStats();resize();showLesson();}
|
||||
function applyMode(){const edit=mode==='edit';document.body.classList.toggle('editing',edit);$('play-tab').classList.toggle('active',!edit);$('edit-tab').classList.toggle('active',edit);$('play-inspector').hidden=edit;$('editor-panel').hidden=!edit;$('game-tools').hidden=edit;$('editor-board-tools').hidden=!edit;$('return-editor').hidden=!trial;$('map-button').hidden=edit;$('rotation-controls').hidden=edit;}
|
||||
function enterEditor(){
|
||||
if(!draft){draft=clone(game.level);draft.id='custom';draft.name='我的第一条轨迹';editHistory=[];}
|
||||
@@ -110,7 +122,7 @@ function finish(){
|
||||
$('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();showLesson();}
|
||||
function undo(){if(animation||rotationAnimation)return;if(game.undo()){resetEffects();renderStats();draw();toast('已回到上一次操作前,可以继续观察');}}
|
||||
function playTile(tile){
|
||||
if(animation||rotationAnimation){
|
||||
@@ -124,10 +136,10 @@ function playTile(tile){
|
||||
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;
|
||||
taps.clear();path=null;
|
||||
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?'标记的棋子挡住了路线 · 可以先移走它':'标记的棋子也在等出口 · 试探免费,可以转盘换条路');
|
||||
toast(route.status==='loop'?'这条路线形成闭环 · 试探免费,可以转动底盘换条路':blockerCanExit?'前方棋子挡住了路线 · 可以先移走它':'前方棋子也在等出口 · 试探免费,可以转盘换条路');
|
||||
}
|
||||
renderStats();draw();if(!animation)finish();
|
||||
}
|
||||
@@ -204,19 +216,26 @@ 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 关卡');};
|
||||
$('import-button').onclick=()=>$('import-file').click();
|
||||
$('import-file').onchange=async e=>{const file=e.target.files[0];if(!file)return;try{if(file.size>200000)throw new Error('文件不能超过 200 KB');loadDraft(JSON.parse(await file.text()));toast('导入成功,已重新计算可解性与难度');}catch(error){showDialog(`<div class="eyebrow">IMPORT CHECK</div><h2>这份关卡需要调整</h2><p>${escapeHtml(error.message)}</p><p>当前编辑内容已保留。</p>`);}e.target.value='';};
|
||||
function showDialog(html){taps.clear();$('dialog-content').innerHTML=html;$('info-dialog').showModal();}
|
||||
function showDialog(html){taps.clear();$('dialog-content').innerHTML=html;decorateUI();if(!$('info-dialog').open)$('info-dialog').showModal();}
|
||||
$('info-dialog').querySelector('.dialog-close').onclick=()=>$('info-dialog').close();
|
||||
$('info-dialog').onclick=e=>{if(e.target===$('info-dialog')){const r=e.target.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)e.target.close();}};
|
||||
function showScore(){const 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;
|
||||
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()?'开':'关'}`;}
|
||||
const pick=e.target.closest('[data-pick]');if(pick){$('info-dialog').close();startLevel(Number(pick.dataset.pick));}
|
||||
});
|
||||
$('dialog-content').addEventListener('change',e=>{if(e.target.id==='campaign-version'){switchCampaign(e.target.value);showMapDialog();$('campaign-version').focus();}});
|
||||
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}" 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));}};};
|
||||
function showMapDialog(){
|
||||
showDialog(`<div class="eyebrow">CHOOSE YOUR NEXT TRAIL</div><h2>挑一关,慢慢解开</h2><label class="field campaign-picker">关卡版本<select id="campaign-version" aria-describedby="campaign-description">${CAMPAIGN_VERSIONS.map(c=>`<option value="${c.id}" ${c.id===campaign.id?'selected':''}>${c.id} · ${c.name} · ${c.levels.length} 关</option>`).join('')}</select></label><p class="campaign-description" id="campaign-description">${campaign.description}<span class="campaign-progress-note"><strong>${campaign.id} · 已完成 ${completed.size} / ${LEVELS.length} 关</strong> · ${chapters.length} 章</span></p><div class="level-dialog-grid">${LEVELS.map((l,i)=>`<button class="level-btn ${i===current?'current':''}" data-pick="${i}" aria-label="${campaign.id} 第 ${i+1} 关 ${escapeHtml(l.name)},${escapeHtml(l.chapterName||chapters[Math.floor(i/chapterSize)])}${completed.has(i)?',已完成':''}" ${i===current?'aria-current="true"':''}>${completed.has(i)?icon('star'):''}${String(i+1).padStart(2,'0')}<small>${analyze(l).label}</small></button>`).join('')}</div><p>${saveUnavailable?'浏览器未允许保存:各版本进度仅在本页临时保留,刷新后会丢失。':'各版本分别保存通关进度与上次选关。'}切换版本会重新开始该版本上次选择的关卡。</p>`);
|
||||
}
|
||||
$('map-button').onclick=showMapDialog;
|
||||
const atlas=new Image();atlas.onload=()=>{painter.atlas=atlas;draw();};atlas.onerror=()=>toast('棋子图片加载失败,已切换基础绘制');atlas.src=TILE_ATLAS;
|
||||
new ResizeObserver(resize).observe(canvas);
|
||||
applyMode();renderMap();renderStats();resize();
|
||||
|
||||
installUI();
|
||||
|
||||
if(new URLSearchParams(location.search).get('editor')==='1')enterEditor();
|
||||
if(new URLSearchParams(location.search).get('editor')==='1')enterEditor();else showLesson();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hexagon, random, randomTracks, rotateBoard, trace, available, analyze, key, opposite, Game } from './core.js';
|
||||
|
||||
export const CAMPAIGN_PLAN = {
|
||||
id:'observe-v3', chapterSize:6,
|
||||
id:'observe-v3', revision:2, chapterSize:6, minimumCurvedRatio:.65,
|
||||
chapters:['看见出口','转出新路','连成节奏','轻巧解结','展开天地','从容变换','长路常新','自在回响'],
|
||||
};
|
||||
const STRAIGHT=[3,4,5,0,1,2];
|
||||
@@ -26,7 +26,9 @@ export function generateLaneLevel(seed,{radius=3,target=18,axis=seed%3}={}) {
|
||||
// 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}={}) {
|
||||
// min/maxBaseTurns constrain the construction orientation, before startRotation.
|
||||
// Published route usage is measured again in the final reference solution.
|
||||
export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns=4,startRotation=0,allFree=false,clearBlockers=false,minBaseTurns=0,maxBaseTurns=maxTurns,curvePreference=0}={}) {
|
||||
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];});
|
||||
@@ -35,6 +37,7 @@ export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns
|
||||
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;
|
||||
if(turnCount(routes[0])<minBaseTurns||turnCount(routes[0])>maxBaseTurns)continue;
|
||||
candidates.push({t,steps:routes[0].steps.length,turns:turnCount(routes[0])});
|
||||
}
|
||||
for(let n=0;n<target;n++){
|
||||
@@ -50,7 +53,7 @@ export function generateFlowLevel(seed,{radius=3,target=18,bendRate=.12,maxTurns
|
||||
})))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;
|
||||
const weight=(allFree?rng()*2-c.steps*2:rng()*3+Math.min(c.steps,5)*.35-c.turns*.25)+Math.min(c.turns,2)*curvePreference;
|
||||
choices.push({tile,weight});
|
||||
}
|
||||
if(!choices.length)break;
|
||||
@@ -68,26 +71,28 @@ 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 game=new Game(level);let maxDryRotations=0,dry=0,maxUnlockDistance=0,hiddenBlockerStates=0,blockedStates=0,lowChoiceStates=0,noCurvedChoiceStates=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;
|
||||
const freeCurved=routes.filter(r=>r.status==='exit'&&turnCount(r)>0).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++;
|
||||
if(game.tiles.length>=4&&free>0&&!freeCurved)noCurvedChoiceStates++;
|
||||
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});
|
||||
samples.push({remaining:game.tiles.length,free,freeCurved,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};
|
||||
return {analysis:a,independentLoops,maxSoloTurns,maxDryRotations,maxUnlockDistance,hiddenBlockerStates,blockedStates,lowChoiceStates,noCurvedChoiceStates,states,samples,won:game.won};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { campaignVersion } from './campaign-versions.js';
|
||||
|
||||
export const CAMPAIGN_SELECTION_KEY = 'hex-trails-campaign-version';
|
||||
export const campaignProgressKey = version=>`hex-trails-campaign-${version}`;
|
||||
|
||||
function getItem(storage,key) { try { return storage?.getItem(key)??null; } catch { return null; } }
|
||||
export function selectedCampaign(storage) { return campaignVersion(getItem(storage,CAMPAIGN_SELECTION_KEY)); }
|
||||
export function normalizeProgress(value,count) {
|
||||
const valid=i=>Number.isInteger(i)&&i>=0&&i<count;
|
||||
return {
|
||||
current:valid(value?.current)?value.current:0,
|
||||
completed:[...new Set(Array.isArray(value?.completed)?value.completed.filter(valid):[])],
|
||||
};
|
||||
}
|
||||
export function readCampaignProgress(storage,campaign) {
|
||||
// Migrate only if the new slot is absent, leaving the historical keys untouched.
|
||||
const keys=[campaignProgressKey(campaign.id),...campaign.legacyStorageKeys];
|
||||
const raw=keys.map(key=>getItem(storage,key)).find(value=>value!==null);
|
||||
let value;try { value=JSON.parse(raw||'{}'); } catch {}
|
||||
return normalizeProgress(value,campaign.levels.length);
|
||||
}
|
||||
export function writeCampaignProgress(storage,campaign,progress) {
|
||||
try {
|
||||
if(!storage)return false;
|
||||
storage.setItem(campaignProgressKey(campaign.id),JSON.stringify(normalizeProgress(progress,campaign.levels.length)));
|
||||
storage.setItem(CAMPAIGN_SELECTION_KEY,campaign.id);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CAMPAIGN, LEVELS } from './levels.js';
|
||||
import { ARCHIVED_LEVELS } from './levels-archive.js';
|
||||
|
||||
// Public layout versions are distinct from the level JSON schema's `version: 1`.
|
||||
// Historical layouts stay intact; all editions share the current game rules.
|
||||
export const CAMPAIGN_VERSIONS = [
|
||||
{
|
||||
id:'v0.1', name:'最初设计', description:'最初的 24 关,保留原始布局与顺序。',
|
||||
chapters:['转盘初体验','糖果回旋','碰撞大挑战'], chapterSize:8,
|
||||
levels:ARCHIVED_LEVELS['v0.1'], legacyStorageKeys:['hex-trails-v2'],
|
||||
},
|
||||
{
|
||||
id:'v0.2', name:'直线较多版', description:'扩展为 48 关,直线路径较多,按舒展与挑战交替推进。',
|
||||
chapters:['看见出口','转出新路','连成节奏','轻巧解结','展开天地','从容变换','长路常新','自在回响'], chapterSize:6,
|
||||
levels:ARCHIVED_LEVELS['v0.2'], legacyStorageKeys:['hex-trails-observe-v3-r1','hex-trails-observe-v3'],
|
||||
},
|
||||
{
|
||||
id:'v0.3', name:'弯轨观察版', description:'当前的 48 关,以沿弯轨观察出口为主,保留起伏节奏。',
|
||||
chapters:CAMPAIGN.chapters, chapterSize:CAMPAIGN.chapterSize,
|
||||
levels:LEVELS, legacyStorageKeys:[`hex-trails-${CAMPAIGN.id}-r${CAMPAIGN.revision||1}`],
|
||||
},
|
||||
];
|
||||
export const DEFAULT_CAMPAIGN_VERSION = 'v0.3';
|
||||
export function campaignVersion(id) {
|
||||
return CAMPAIGN_VERSIONS.find(c=>c.id===id)||CAMPAIGN_VERSIONS.find(c=>c.id===DEFAULT_CAMPAIGN_VERSION);
|
||||
}
|
||||
@@ -3,8 +3,8 @@ import { segmentHexHit } from './path-math.js';
|
||||
export function collisionPhase(progress, reduced=false){
|
||||
const t=Math.max(0,Math.min(1,progress));
|
||||
const distance=t<.42?1-Math.pow(1-t/.42,2):t<.64?1:Math.pow(1-(t-.64)/.36,2);
|
||||
const impact=t>=.42&&t<.64?Math.sin((t-.42)/.22*Math.PI):0;
|
||||
return {distance,impact,kick:reduced?0:Math.sin(Math.max(0,t-.42)/.22*Math.PI*2)*impact};
|
||||
const impact=t>=.42&&t<.72?Math.sin((t-.42)/.30*Math.PI):0;
|
||||
return {distance,impact,kick:reduced?0:Math.sin(Math.max(0,t-.42)/.58*Math.PI)*Math.exp(-Math.max(0,t-.42)*2)};
|
||||
}
|
||||
// Stop the drawn silhouette at first contact, avoiding visible overlap from collider tolerance.
|
||||
export function contactPath(points,blocker,radius){
|
||||
|
||||
+32
-1
@@ -175,6 +175,37 @@ export function solveRotations(level, maxStates = 20000) {
|
||||
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 };
|
||||
}
|
||||
|
||||
// Count the paths that successful pieces really travel in the reference plan.
|
||||
// Decorative bends in unused cells do not count as route-reading practice.
|
||||
export function solutionPathUsage(level, actions) {
|
||||
let board = level, remaining = [...level.tiles], straightRun = 0;
|
||||
const paths = [];
|
||||
for (const action of actions) {
|
||||
if (action.type === 'rotate') { board = rotateBoard(board, action.direction); continue; }
|
||||
const tile = remaining.find(t => t.id === action.id);
|
||||
if (!tile) continue;
|
||||
const route = trace(board, tile, remaining);
|
||||
if (route.status !== 'exit') continue;
|
||||
const bends = route.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length;
|
||||
straightRun = bends ? 0 : straightRun + 1;
|
||||
paths.push({ id: tile.id, bends, cells: route.steps.length, straightRun });
|
||||
remaining = remaining.filter(t => t.id !== tile.id);
|
||||
}
|
||||
const curvedRemovals = paths.filter(p => p.bends > 0).length;
|
||||
return {
|
||||
removals: paths.length,
|
||||
curvedRemovals,
|
||||
straightRemovals: paths.length - curvedRemovals,
|
||||
curvedRatio: paths.length ? curvedRemovals / paths.length : 0,
|
||||
singleBendRemovals: paths.filter(p => p.bends === 1).length,
|
||||
multiBendRemovals: paths.filter(p => p.bends > 1).length,
|
||||
totalBends: paths.reduce((sum, p) => sum + p.bends, 0),
|
||||
maxBends: Math.max(0, ...paths.map(p => p.bends)),
|
||||
maxConsecutiveStraight: Math.max(0, ...paths.map(p => p.straightRun)),
|
||||
paths: paths.map(({ straightRun, ...path }) => path),
|
||||
};
|
||||
}
|
||||
|
||||
export function analyze(level) {
|
||||
const solution = solveRotations(level), n = level.tiles.length;
|
||||
const routes = level.tiles.map(t => trace(level, t, level.tiles, true));
|
||||
@@ -204,7 +235,7 @@ export function analyze(level) {
|
||||
// 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 ? '烧脑' : '挑战' };
|
||||
return { score, parts, meanTurns, maxTurns, free, freeRatio, sweptOnlyBlockers, dependencyRatio, workload, pathUsage: solutionPathUsage(level, solution.actions), depth: solution.waves.length, ...solution, label: score < 30 ? '轻松' : score < 50 ? '进阶' : score < 70 ? '烧脑' : '挑战' };
|
||||
}
|
||||
export function generateLevel(seed, radius = 3, target = 25, complexity = 0.7) {
|
||||
const rng = random(seed);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const SURFACES = Object.freeze({
|
||||
disabled: 'disabled', tileBlue: 'tileBlue', tilePink: 'tilePink', tileYellow: 'tileYellow', tilePurple: 'tilePurple',
|
||||
});
|
||||
export const TYPOGRAPHY = Object.freeze({level:28,moves:32,dialogTitle:24,body:14,label:12,caption:11});
|
||||
export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:650,rotationMs:240,exitMsPerUnit:60,exitMinTravelMs:140,exitMaxTravelMs:800,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:1700,exitLongStartCells:2,exitLongFullCells:5});
|
||||
export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:400,rotationMs:240,exitMsPerUnit:120,exitRampMs:80,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 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);}
|
||||
|
||||
+13
-7
@@ -5,13 +5,10 @@ import { routePoints } from './geometry.js';
|
||||
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};
|
||||
const rampMs=Math.min(MOTION.exitRampMs,distance*MOTION.exitMsPerUnit/3);
|
||||
const travelMs=distance*MOTION.exitMsPerUnit+rampMs;
|
||||
const duration=travelMs+MOTION.exitEffectMs;
|
||||
return {duration,travelMs,distance,travelFraction:travelMs/duration};
|
||||
}
|
||||
|
||||
// Zero endpoint velocity: a soft launch and a settled finish, without overshoot.
|
||||
@@ -19,3 +16,12 @@ export function smoothMotion(value) {
|
||||
const t=Math.max(0,Math.min(1,value));
|
||||
return t*t*(3-2*t);
|
||||
}
|
||||
|
||||
// Fixed-duration acceleration and braking; the middle travels at a shared speed.
|
||||
export function travelProgress(progress,travelMs){
|
||||
const t=Math.max(0,Math.min(1,progress))*travelMs;
|
||||
const ramp=Math.min(MOTION.exitRampMs,travelMs/4),area=travelMs-ramp;
|
||||
if(t<ramp)return t*t/(2*ramp*area);
|
||||
if(t>travelMs-ramp)return 1-(travelMs-t)**2/(2*ramp*area);
|
||||
return (t-ramp/2)/area;
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@ export function layout(cells,width,height) {
|
||||
const raw=cells.map(c=>position(c.q,c.r));
|
||||
const minX=Math.min(...raw.map(p=>p.x))-1,maxX=Math.max(...raw.map(p=>p.x))+1;
|
||||
const minY=Math.min(...raw.map(p=>p.y))-.866,maxY=Math.max(...raw.map(p=>p.y))+.866;
|
||||
const size=Math.min((width-48)/(maxX-minX),(height-50)/(maxY-minY),47);
|
||||
const cellLimit=Math.max(47,Math.min(76,Math.min(width,height)*.1));
|
||||
const size=Math.min((width-48)/(maxX-minX),(height-50)/(maxY-minY),cellLimit);
|
||||
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}};
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+6
-7
@@ -1,4 +1,4 @@
|
||||
import { exitTiming, smoothMotion } from './exit-animation.js';
|
||||
import { exitTiming, smoothMotion, travelProgress } from './exit-animation.js';
|
||||
import { THEME, PIGMENTS, pigmentIndex } from './design-tokens.js';
|
||||
import { DIRS, key } from './core.js';
|
||||
import { tileSample, roundedHex } from './tile-shape.js';
|
||||
@@ -77,8 +77,7 @@ export function drawScene(p,level,tiles,view,state={}) {
|
||||
if(tile.id===state.shake) dx=Math.sin(state.shakeTime*38)*5*(1-state.shakeTime);
|
||||
let dy=0;
|
||||
if(tile.id===blocker?.id){
|
||||
dx+=Math.cos(impactAngle)*phase.kick*size*.1;dy+=Math.sin(impactAngle)*phase.kick*size*.1;
|
||||
if(phase.impact>0){const ring=hexPoints(c.x+dx,c.y+dy,size*1.04);p.line([...ring,ring[0]],THEME.warning,2.5);}
|
||||
dx+=Math.cos(impactAngle)*phase.kick*size*.32;dy+=Math.sin(impactAngle)*phase.kick*size*.32;
|
||||
}
|
||||
drawTile(p,c.x+dx,c.y+dy,size,visualTile(tile));
|
||||
}
|
||||
@@ -89,11 +88,11 @@ export function drawScene(p,level,tiles,view,state={}) {
|
||||
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);
|
||||
drawTile(p,loc.x,loc.y,size,visualTile(route.tile),loc.angle,phase&&!state.reduced?1-phase.impact*.12:1);
|
||||
}
|
||||
if(target&&phase.impact>0){
|
||||
const x=(contact.x+target.x)/2,y=(contact.y+target.y)/2;
|
||||
for(const sign of [-1,1]){const a=impactAngle+sign*Math.PI/2,d=size*(.13+phase.impact*.12);p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+size*.15),y:y+Math.sin(a)*(d+size*.15)}],THEME.warning,2.5);}
|
||||
for(const offset of [-1.95,-1.2,1.2,1.95]){const a=impactAngle+offset,d=size*(.12+phase.impact*.16),length=size*.26*phase.impact;p.line([{x:x+Math.cos(a)*d,y:y+Math.sin(a)*d},{x:x+Math.cos(a)*(d+length),y:y+Math.sin(a)*(d+length)}],THEME.track,Math.max(1.5,size*.055));}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,9 +120,9 @@ export function exitGeometry(route,view){
|
||||
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;
|
||||
const timing=exitTiming(route),travel=timing.travelFraction;
|
||||
if(progress<travel){
|
||||
const at=pointAlong(points,smoothMotion(progress/travel));
|
||||
const at=pointAlong(points,travelProgress(progress/travel,timing.travelMs));
|
||||
drawTile(p,at.x,at.y,size,tile,at.angle);return;
|
||||
}
|
||||
const t=Math.min(1,(progress-travel)/(1-travel)),ease=smoothMotion(t);
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
import { attachBoardViewport } from './board-viewport.js';
|
||||
import { generateLaneLevel } from './campaign-generator.js';
|
||||
import { createStudyLevel } from './study-levels.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 { Game, available, solveRotations, trace } 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';
|
||||
@@ -13,8 +13,8 @@ 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});
|
||||
const regularLevel=createStudyLevel(3);
|
||||
let studyLevel=createStudyLevel(5);
|
||||
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();};
|
||||
@@ -72,7 +72,7 @@ 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;}
|
||||
if(free.length){free.sort((a,b)=>trace(game.level,b,game.tiles).steps.length-trace(game.level,a,game.tiles).steps.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='演示已重置,请再次播放。';}
|
||||
}
|
||||
@@ -86,7 +86,7 @@ $('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} 枚棋子`);};
|
||||
$('board-size').onchange=()=>{studyLevel=$('board-size').value==='regular'?regularLevel:createStudyLevel(Number($('board-size').value));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';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { generateFlowLevel } from './campaign-generator.js';
|
||||
// Curved, fully removable studies with a longer route ready on the first screen.
|
||||
export function createStudyLevel(radius=5){
|
||||
const presets={3:{seed:15,target:12},5:{seed:5,target:24},6:{seed:4,target:32}};
|
||||
const {seed,target}=presets[radius];
|
||||
return generateFlowLevel(seed,{radius,target,bendRate:.55,maxTurns:24,minBaseTurns:1,curvePreference:4});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Game, solveRotations, validateLevel } from '../src/core.js';
|
||||
import { LEVELS } from '../src/levels.js';
|
||||
import { CAMPAIGN_VERSIONS, campaignVersion } from '../src/campaign-versions.js';
|
||||
import { CAMPAIGN_SELECTION_KEY, campaignProgressKey, selectedCampaign, normalizeProgress, readCampaignProgress, writeCampaignProgress } from '../src/campaign-progress.js';
|
||||
|
||||
const storage=entries=>{
|
||||
const items=new Map(Object.entries(entries||{}));
|
||||
return {getItem:key=>items.get(key)??null,setItem:(key,value)=>items.set(key,value)};
|
||||
};
|
||||
|
||||
test('three selectable editions retain their own level counts, order and chapters',()=>{
|
||||
assert.deepEqual(CAMPAIGN_VERSIONS.map(c=>[c.id,c.levels.length,c.chapterSize,c.chapters.length]),[
|
||||
['v0.1',24,8,3],['v0.2',48,6,8],['v0.3',48,6,8],
|
||||
]);
|
||||
assert.equal(campaignVersion('v0.1').levels[0].name,'转一转,路就通了');
|
||||
assert.equal(campaignVersion('v0.1').levels.at(-1).name,'六角大满贯');
|
||||
assert.equal(campaignVersion('v0.2').levels[0].name,'一眼看见出口');
|
||||
assert.equal(campaignVersion('v0.3').levels,LEVELS);
|
||||
assert.equal(campaignVersion('v0.3').levels[0].name,'跟着弯道走');
|
||||
for(const c of CAMPAIGN_VERSIONS)assert.equal(c.chapterSize*c.chapters.length,c.levels.length);
|
||||
});
|
||||
|
||||
test('browser archive module matches the exact historical JSON bytes and provenance',async()=>{
|
||||
const manifest=JSON.parse(await readFile(new URL('../levels/versions/provenance.json',import.meta.url),'utf8'));
|
||||
for(const source of manifest.versions){
|
||||
const bytes=await readFile(new URL('../'+source.archivePath,import.meta.url));
|
||||
assert.equal(bytes.length,source.bytes);
|
||||
assert.equal(createHash('sha256').update(bytes).digest('hex'),source.sha256);
|
||||
assert.deepEqual(campaignVersion(source.version).levels,JSON.parse(bytes.toString('utf8')));
|
||||
}
|
||||
});
|
||||
|
||||
test('all 72 historical layouts solve and replay with the current free rotation rules',()=>{
|
||||
for(const c of CAMPAIGN_VERSIONS.slice(0,2))for(const [i,level] of c.levels.entries()){
|
||||
const label=`${c.id} level ${i+1}`;
|
||||
assert.deepEqual(validateLevel(level),[],label);
|
||||
const plan=solveRotations(level),game=new Game(level);
|
||||
assert.ok(plan.solvable&&!plan.exhausted,label);
|
||||
for(const action of plan.actions){
|
||||
const before=game.moves;
|
||||
if(action.type==='rotate'){
|
||||
assert.equal(game.rotate(action.direction).status,'rotated',label);
|
||||
assert.equal(game.moves,before,label);
|
||||
}else assert.equal(game.tap(action.id).status,'exit',label);
|
||||
}
|
||||
assert.ok(game.won,label);
|
||||
}
|
||||
});
|
||||
|
||||
test('new players default to v0.3 while an explicit version choice survives reload',()=>{
|
||||
const s=storage();assert.equal(selectedCampaign(s).id,'v0.3');
|
||||
assert.ok(writeCampaignProgress(s,campaignVersion('v0.1'),{current:23,completed:[0,22]}));
|
||||
assert.equal(selectedCampaign(s).id,'v0.1');
|
||||
assert.deepEqual(readCampaignProgress(s,selectedCampaign(s)),{current:23,completed:[0,22]});
|
||||
s.setItem(CAMPAIGN_SELECTION_KEY,'v9.9');assert.equal(selectedCampaign(s).id,'v0.3');
|
||||
});
|
||||
|
||||
test('versions keep progress separate even when v0.2 and v0.3 reuse level IDs',()=>{
|
||||
const s=storage(),v2=campaignVersion('v0.2'),v3=campaignVersion('v0.3');
|
||||
assert.equal(v2.levels[0].id,v3.levels[0].id);
|
||||
writeCampaignProgress(s,v2,{current:47,completed:[0,1,47]});
|
||||
assert.deepEqual(readCampaignProgress(s,v3),{current:0,completed:[]});
|
||||
writeCampaignProgress(s,v3,{current:7,completed:[6]});
|
||||
assert.deepEqual(readCampaignProgress(s,v2),{current:47,completed:[0,1,47]});
|
||||
assert.deepEqual(readCampaignProgress(s,v3),{current:7,completed:[6]});
|
||||
assert.deepEqual(readCampaignProgress(s,campaignVersion('v0.1')),{current:0,completed:[]});
|
||||
});
|
||||
|
||||
test('each edition migrates only its own legacy slot without changing the old data',()=>{
|
||||
const old={
|
||||
'hex-trails-v2':JSON.stringify({current:20,completed:[0,20]}),
|
||||
'hex-trails-observe-v3':JSON.stringify({current:45,completed:[0,45]}),
|
||||
'hex-trails-observe-v3-r2':JSON.stringify({current:6,completed:[6]}),
|
||||
};
|
||||
const s=storage(old);
|
||||
for(const [i,c] of CAMPAIGN_VERSIONS.entries()){
|
||||
const key=Object.keys(old)[i],expected=JSON.parse(old[key]);
|
||||
assert.deepEqual(readCampaignProgress(s,c),expected);
|
||||
writeCampaignProgress(s,c,{current:1,completed:[1]});
|
||||
assert.deepEqual(readCampaignProgress(s,c),{current:1,completed:[1]});
|
||||
assert.equal(s.getItem(key),old[key]);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid saves are bounded to each edition and do not resurrect legacy progress',()=>{
|
||||
assert.deepEqual(normalizeProgress({current:47,completed:[-1,0,0,23,24,47,1.5,'2',null]},24),{current:0,completed:[0,23]});
|
||||
const c=campaignVersion('v0.1');
|
||||
for(const raw of ['{','null','[]','true','"text"']){
|
||||
const s=storage({[campaignProgressKey(c.id)]:raw,'hex-trails-v2':JSON.stringify({current:20,completed:[20]})});
|
||||
assert.deepEqual(readCampaignProgress(s,c),{current:0,completed:[]});
|
||||
}
|
||||
});
|
||||
|
||||
test('unavailable browser storage still permits loading a campaign',()=>{
|
||||
const denied={getItem(){throw new Error('denied');},setItem(){throw new Error('denied');}};
|
||||
for(const s of [undefined,denied]){
|
||||
assert.equal(selectedCampaign(s).id,'v0.3');
|
||||
assert.deepEqual(readCampaignProgress(s,campaignVersion('v0.1')),{current:0,completed:[]});
|
||||
assert.equal(writeCampaignProgress(s,campaignVersion('v0.1'),{current:1,completed:[0]}),false);
|
||||
}
|
||||
});
|
||||
+42
-8
@@ -2,8 +2,8 @@ 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';
|
||||
import { Game, available, trace, random, validateLevel, opposite, key } from '../src/core.js';
|
||||
import { generateFlowLevel, inspectFlowLevel } from '../src/campaign-generator.js';
|
||||
|
||||
const reviewed=LEVELS.map(inspectFlowLevel);
|
||||
|
||||
@@ -12,14 +12,19 @@ test('48 authored levels share one campaign in the browser, JSON export and audi
|
||||
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'));
|
||||
const manifest=JSON.parse(await readFile(new URL('../levels/campaign-seeds.json',import.meta.url),'utf8'));
|
||||
assert.deepEqual(report.campaign,CAMPAIGN);assert.equal(report.levels.length,48);
|
||||
assert.equal(manifest.seeds.length,48);assert.equal(new Set(manifest.seeds.map(p=>p.seed)).size,48);
|
||||
LEVELS.forEach((l,i)=>{
|
||||
const profile=manifest.seeds[i],generated=generateFlowLevel(profile.seed,profile.options);
|
||||
assert.equal(profile.level,i+1);assert.deepEqual(generated.cells,l.cells);assert.deepEqual(generated.tiles,l.tiles);
|
||||
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);
|
||||
assert.deepEqual(report.levels[i].pathUsage,reviewed[i].analysis.pathUsage);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,20 +39,49 @@ test('every campaign solution finishes, avoids hidden blockers, and needs at mos
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
test('first level teaches one visible bend per piece without forced rotation or removal order',()=>{
|
||||
const l=LEVELS[0];assert.equal(l.cells.length,37);assert.equal(l.tiles.length,8);
|
||||
for(const reverse of [false,true]){
|
||||
const g=new Game(l),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.equal(g.moves,g.tiles.length);
|
||||
const route=g.tap(id);assert.equal(route.status,'exit');
|
||||
assert.equal(route.steps.filter(s=>s.enter!==null&&opposite(s.enter)!==s.exit).length,1,'The moving arrow must actually turn');
|
||||
}
|
||||
assert.ok(g.won);assert.equal(g.moves,0);
|
||||
}
|
||||
});
|
||||
|
||||
test('all 48 levels use actual bent removal paths, including the release and large-board levels',()=>{
|
||||
for(let i=0;i<LEVELS.length;i++){
|
||||
const p=reviewed[i].analysis.pathUsage;
|
||||
assert.equal(p.removals,LEVELS[i].tiles.length);
|
||||
assert.ok(p.curvedRatio>=.65,`${LEVELS[i].name}: straight exits must remain a minority`);
|
||||
assert.ok(p.curvedRemovals>=3);
|
||||
assert.equal(LEVELS[i].pacing.curvedRatio,p.curvedRatio);
|
||||
}
|
||||
});
|
||||
|
||||
test('the first seven lessons include real turning, crossing, and route-changing situations',()=>{
|
||||
assert.ok(LEVELS.slice(0,7).every(l=>typeof l.lesson==='string'&&l.lesson.length));
|
||||
assert.equal(reviewed[0].analysis.pathUsage.singleBendRemovals,8);
|
||||
assert.equal(reviewed[1].analysis.rotations,1);
|
||||
assert.ok(reviewed[2].analysis.pathUsage.multiBendRemovals>0);
|
||||
const g=new Game(LEVELS[2]);let crossings=0;
|
||||
for(const action of reviewed[2].analysis.actions){
|
||||
if(action.type==='rotate'){g.rotate(action.direction);continue;}
|
||||
for(const step of g.tap(action.id).steps){
|
||||
if(step.enter===null)continue;
|
||||
const cell=g.level.cells.find(c=>key(c.q,c.r)===key(step.q,step.r));
|
||||
const inside=p=>p!==step.enter&&(p-step.enter+6)%6<(step.exit-step.enter+6)%6;
|
||||
for(let p=0;p<6;p++)if(p<cell.tracks[p]&&p!==step.enter&&p!==step.exit&&inside(p)!==inside(cell.tracks[p]))crossings++;
|
||||
}
|
||||
}
|
||||
assert.ok(crossings>0,'The crossing lesson must pass through interleaved track pairs');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -16,6 +16,12 @@ test('bends follow the paired exit, never a crossing branch',()=>{
|
||||
const l=level([{q:0,r:0},{q:0,r:-1,tracks:[2,3,0,1,5,4]},{q:1,r:-2}],[tile('a',0,0)]);
|
||||
const path=trace(l,l.tiles[0]);assert.equal(path.status,'exit');assert.equal(path.steps[1].enter,3);assert.equal(path.steps[1].exit,1);assert.deepEqual(path.steps[2],{q:1,r:-2,enter:4,exit:1});
|
||||
});
|
||||
test('path usage counts successful bends and ignores decorative tracks outside the traveled route',()=>{
|
||||
const bent=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 p=analyze(bent).pathUsage;assert.equal(p.curvedRemovals,1);assert.equal(p.singleBendRemovals,1);assert.equal(p.totalBends,1);assert.equal(p.curvedRatio,1);
|
||||
const decoration=level([{q:0,r:0},{q:1,r:0,tracks:[1,0,3,2,5,4]}],[tile('a',0,0)]);
|
||||
const unused=analyze(decoration).pathUsage;assert.equal(unused.curvedRemovals,0);assert.equal(unused.straightRemovals,1);assert.equal(unused.curvedRatio,0);
|
||||
});
|
||||
test('occupied cell blocks even when its own arrow points elsewhere',()=>{
|
||||
const l=level([{q:0,r:0},{q:0,r:-1}],[tile('a',0,0),tile('b',0,-1,1)]);
|
||||
assert.equal(trace(l,l.tiles[0]).blocker,'b');assert.deepEqual(available(l).map(t=>t.id),['b']);
|
||||
|
||||
+25
-12
@@ -35,8 +35,8 @@ test('edge exits are brisk and longer routes preserve a bounded readable travel
|
||||
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);
|
||||
assert.ok(short.duration>260&&short.duration<400);
|
||||
assert.ok(long.duration>short.duration&&long.duration<1200);
|
||||
for(const timing of [short,long])assert.ok(Math.abs(timing.duration*(1-timing.travelFraction)-180)<.001);
|
||||
});
|
||||
|
||||
@@ -52,15 +52,28 @@ test('motion ramps gently at both ends, never reverses or overshoots',async()=>{
|
||||
});
|
||||
|
||||
|
||||
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);
|
||||
test('curved and long routes share the same cruising speed without a duration cap',async()=>{
|
||||
const {exitTiming,travelProgress}=await import('../src/exit-animation.js');
|
||||
for(const distance of [1,5,15,30]){
|
||||
const timing=exitTiming({travelPoints:[{x:0,y:0},{x:distance,y:0}],steps:[]});
|
||||
assert.equal(timing.travelMs,distance*120+Math.min(80,distance*40));
|
||||
const t=timing.travelMs*.5,dt=1;
|
||||
const speed=(travelProgress((t+dt)/timing.travelMs,timing.travelMs)-travelProgress(t/timing.travelMs,timing.travelMs))*distance;
|
||||
assert.ok(Math.abs(speed-1/120)<1e-9);
|
||||
assert.equal(travelProgress(0,timing.travelMs),0);assert.equal(travelProgress(1,timing.travelMs),1);
|
||||
assert.ok(Math.abs(timing.duration-timing.travelMs-180)<1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
test('showcase boards open with a long curved route and can be cleared completely',async()=>{
|
||||
const {createStudyLevel}=await import('../src/study-levels.js');
|
||||
const {available,trace,Game,validateLevel}=await import('../src/core.js');
|
||||
for(const [radius,minLength] of [[3,5],[5,8],[6,11]]){
|
||||
const level=createStudyLevel(radius),game=new Game(level);
|
||||
assert.deepEqual(validateLevel(level),[]);
|
||||
const routes=available(level,level.tiles).map(t=>trace(level,t,level.tiles));
|
||||
assert.ok(routes.some(r=>r.steps.length>=minLength&&r.steps.some(s=>s.enter!==null&&(s.enter+3)%6!==s.exit)));
|
||||
while(game.tiles.length){const tile=available(game.level,game.tiles)[0];assert.ok(tile);game.tap(tile.id);}
|
||||
assert.ok(game.won);
|
||||
}
|
||||
const durations=Array.from({length:17},(_,i)=>exitTiming(route(i+1)).duration);
|
||||
assert.ok(durations.every((t,i)=>i===0||t>=durations[i-1]));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user