Add three playable puzzle variants in an isolated lab
This commit is contained in:
@@ -117,3 +117,7 @@ Cocos 示例复用规则、碰撞、渲染命令和图集;HTML 控件、文件
|
|||||||
## 视频 UI 实验室
|
## 视频 UI 实验室
|
||||||
|
|
||||||
[Hex Video Lab](labs/hex-video/index.html) 是基于 `examples/hex.mp4` 的独立 UI / 特效复刻,可切换地图、棋盘和领奖场景,支持自动试玩、单片翻转、彩纸、金币飞入、慢速播放及参考帧对照。棋子按本项目要求保留单片薄侧边,不使用视频中的多片堆叠。运行 `npm run dev` 后访问 `/labs/hex-video/index.html`;详见 [复刻笔记](labs/hex-video/README.md)。
|
[Hex Video Lab](labs/hex-video/index.html) 是基于 `examples/hex.mp4` 的独立 UI / 特效复刻,可切换地图、棋盘和领奖场景,支持自动试玩、单片翻转、彩纸、金币飞入、慢速播放及参考帧对照。棋子按本项目要求保留单片薄侧边,不使用视频中的多片堆叠。运行 `npm run dev` 后访问 `/labs/hex-video/index.html`;详见 [复刻笔记](labs/hex-video/README.md)。
|
||||||
|
|
||||||
|
## 玩法变种 Lab
|
||||||
|
|
||||||
|
[打开独立实验室](http://localhost:4173/labs/variants/index.html):分色归站、机关换轨、接力停靠,各 5 个可解关卡,支持旋转、撤销、提示和重来。仅使用实验室数据,不写入主线存档。规则和验证范围见 [Lab 说明](labs/variants/README.md)。
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 玩法变种 Lab
|
||||||
|
|
||||||
|
运行 `npm run dev`,打开 http://localhost:4173/labs/variants/index.html 。
|
||||||
|
|
||||||
|
这是用户授权的独立玩法原型,包含三种模式,各 5 关。主线规则、界面、编号和存档不变。Lab 本身不保存进度;刷新后回到首关。模式可用 `?mode=color`、`?mode=switch`、`?mode=relay` 直达。
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
|
||||||
|
- 分色归站:所有边界端口分配橙色或蓝色终点,棋子只能从同色端口离场。外围终点不随底盘旋转。错色、碰撞和闭环均返回原位。
|
||||||
|
- 机关换轨:标记 A 的棋子离场后,切换金色 A 格的两套轨道配对。机关格及其两套配对随底盘旋转。每关一个一次性机关;冻结参考解中,机关会实际打开至少一枚剩余棋子的出口。
|
||||||
|
- 接力停靠:中央 P 点固定在世界坐标。经过 P 时先驶入格中心,停下后箭头变为该格轨道出口方向;再次点击从此方向出发。每次重新进入 P 都会停靠。P 被占用时不能进入。只检测抵达 P 之前的碰撞,不受 P 后方障碍影响。
|
||||||
|
|
||||||
|
全模式保留免费旋转、免费试路、弯轨自动转向和交叉不换轨。采用顺序动画输入,动画期间暂停发车和旋转;重来和切关可以取消动画。撤销恢复完整状态,包括机关、停靠位置和朝向。提示在当前局面重新搜索;无解或搜索超限有独立反馈。
|
||||||
|
|
||||||
|
## 实现与关卡
|
||||||
|
|
||||||
|
`model.js` 复用主线 `core.js` 的寻路、旋转和 `path-math.js` 的连续碰撞与曲线采样。实验规则仅在 Lab 模型中扩展,不修改主线模型。Canvas 负责实验标记与轨迹动画,棋子大小与共享碰撞体一致。
|
||||||
|
|
||||||
|
`levels.json` 冻结 15 关、确定性种子、完整动作解和机制证据。每关 19 格、3–5 枚棋子,先验证机制是否易懂;这些是原型样本,不代表经过真人标定的难度曲线。
|
||||||
|
|
||||||
|
`generate.mjs` 按确定性候选筛选:完整可解、实际经过弯轨、参考解需要旋转;机关关必须触发开路;接力关必须发生停靠。局面搜索包含朝向、剩余棋子位置与方向、机关状态,不能沿用主线“安全棋子全部贪心移除”的简化。
|
||||||
|
|
||||||
|
重新生成:`node labs/variants/generate.mjs`。
|
||||||
|
规则验证:`node --test tests/variants.test.js`。
|
||||||
|
静态打包:`node scripts/build.mjs`(Lab 已加入复制清单)。
|
||||||
|
|
||||||
|
## 验证范围
|
||||||
|
|
||||||
|
15 关数据校验、独立求解和冻结解回放;六次旋转恢复两套机关轨道;错色不改变状态;停靠截断路径、占位阻挡及继续出发;每步不修改历史快照。
|
||||||
|
|
||||||
|
浏览器检查桌面和 390×844 手机视口、模式切换、选关、提示与通关。当前仅 Web 原型,未移植或验收 Cocos、Android、iOS。尚未开展真人玩法偏好测试。
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { createState, act, solve } from './model.js';
|
||||||
|
import { position, hexPoints, trackPoints, PORTS } from '../../src/path-math.js';
|
||||||
|
const $ = id => document.getElementById(id), canvas = $('board'), ctx = canvas.getContext('2d');
|
||||||
|
const colors = ['#d66c4d', '#477fc0'];
|
||||||
|
const copy = {
|
||||||
|
color: ['分色归站', '橙色棋子进入橙色出口,蓝色棋子进入蓝色出口。外围短线就是终点;终点固定,旋转底盘改变去向。'],
|
||||||
|
switch: ['机关换轨', '带 A 标记的棋子离场,会切换 A 格的轨道连接。先观察它影响的路线,再安排出发顺序。'],
|
||||||
|
relay: ['接力停靠', '棋子经过中央 P 点时会停下,箭头沿后续轨道转向。再次点击继续出发,也可以先转盘或让其他棋子通过。']
|
||||||
|
};
|
||||||
|
let levels, mode = 'color', index = 0, state, history = [], animation = null, frame, size, scale, hint;
|
||||||
|
function poly(points, stroke, width = 1, fill) {
|
||||||
|
ctx.beginPath(); points.forEach((p,i) => i ? ctx.lineTo(p.x,p.y) : ctx.moveTo(p.x,p.y));
|
||||||
|
if(fill){ctx.closePath();ctx.fillStyle=fill;ctx.fill();}
|
||||||
|
if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=width;ctx.stroke();}
|
||||||
|
}
|
||||||
|
function piece(tile, point, alpha = 1) {
|
||||||
|
const p = point || position(tile.q,tile.r);ctx.save();ctx.globalAlpha=alpha;
|
||||||
|
poly(hexPoints(p.x,p.y,.88),hint===tile.id?'#fff':'#ffffffcc',hint===tile.id?.09:.035,colors[tile.color]);
|
||||||
|
ctx.save();ctx.translate(p.x,p.y);ctx.rotate(tile.dir*Math.PI/3);
|
||||||
|
poly([{x:0,y:.3},{x:0,y:-.3}], '#fff', .09);
|
||||||
|
poly([{x:-.17,y:-.08},{x:0,y:-.3},{x:.17,y:-.08}], '#fff', .09);ctx.restore();
|
||||||
|
ctx.fillStyle='#fff';ctx.font='bold .24px system-ui';ctx.textAlign='center';
|
||||||
|
ctx.fillText(tile.trigger || tile.id.slice(1)*1+1,p.x+.38,p.y+.37);ctx.restore();
|
||||||
|
}
|
||||||
|
function draw(now = performance.now()) {
|
||||||
|
if(!state)return;
|
||||||
|
ctx.setTransform(canvas.width/size,0,0,canvas.width/size,0,0);ctx.clearRect(0,0,size,size);
|
||||||
|
ctx.translate(size/2,size/2);ctx.scale(scale,scale);ctx.lineCap='round';ctx.lineJoin='round';
|
||||||
|
for(const c of state.cells){const p=position(c.q,c.r);poly(hexPoints(p.x,p.y,.97),'#d3dfe3',.025,'#fafbf7');}
|
||||||
|
for(const c of state.cells){const p=position(c.q,c.r);for(let a=0;a<6;a++)if(a<c.tracks[a])poly(trackPoints(p.x,p.y,1,a,c.tracks[a]),c.switchId?'#a97c39':'#b2c4ce',c.switchId?.085:.055);}
|
||||||
|
if(mode==='color')for(const g of state.gates){const p=position(g.q,g.r),v=PORTS[g.dir];poly([{x:p.x+v.x*1.14,y:p.y+v.y*1.14},{x:p.x+v.x*1.46,y:p.y+v.y*1.46}],colors[g.color],.16);}
|
||||||
|
for(const c of state.cells.filter(c=>c.switchId)){const p=position(c.q,c.r);ctx.fillStyle='#7d5824';ctx.font='bold .28px system-ui';ctx.textAlign='center';ctx.fillText('A',p.x,p.y+.1);}
|
||||||
|
if(mode==='relay')for(const c of state.stops){const p=position(c.q,c.r);ctx.beginPath();ctx.arc(p.x,p.y,.4,0,Math.PI*2);ctx.fillStyle='#e4edda';ctx.fill();ctx.strokeStyle='#65874d';ctx.lineWidth=.055;ctx.stroke();ctx.fillStyle='#507238';ctx.font='bold .35px system-ui';ctx.textAlign='center';ctx.fillText('P',p.x,p.y+.12);}
|
||||||
|
for(const t of state.tiles)if(animation?.tile.id!==t.id)piece(t);
|
||||||
|
if(animation){
|
||||||
|
let t=Math.min(1,(now-animation.start)/animation.duration);
|
||||||
|
const success=['exit','stop'].includes(animation.status);
|
||||||
|
const fraction=success?t:Math.sin(t*Math.PI);
|
||||||
|
const lengths=animation.lengths,total=lengths.at(-1),distance=fraction*total;
|
||||||
|
let i=1;while(i<lengths.length-1&&lengths[i]<distance)i++;
|
||||||
|
const a=animation.points[i-1],b=animation.points[i],u=(distance-lengths[i-1])/(lengths[i]-lengths[i-1]||1);
|
||||||
|
poly(animation.points,'#7399ad88',.07);
|
||||||
|
const moving={...animation.tile,dir:Math.atan2(b.x-a.x,-(b.y-a.y))*3/Math.PI};
|
||||||
|
piece(moving,{x:a.x+(b.x-a.x)*u,y:a.y+(b.y-a.y)*u},animation.status==='exit'?Math.min(1,(1-t)*5):1);
|
||||||
|
if(t>=1){animation=null;sync();}else frame=requestAnimationFrame(draw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resize(){size=canvas.getBoundingClientRect().width;scale=size/9.3;canvas.width=canvas.height=Math.round(size*Math.min(devicePixelRatio||1,2));draw();}
|
||||||
|
function sync(){
|
||||||
|
$('count').textContent=`剩余 ${state.tiles.length} 枚`;
|
||||||
|
const busy=!!animation;
|
||||||
|
for(const id of ['left','right','hint'])$(id).disabled=busy||!state.tiles.length;
|
||||||
|
$('undo').disabled=busy||!history.length;
|
||||||
|
$('next').hidden=busy||!!state.tiles.length||index===4;
|
||||||
|
$('pieces').replaceChildren(...state.tiles.map(t=>{const b=document.createElement('button');b.textContent=`${t.id.slice(1)*1+1} 号${t.trigger?' · 机关 A':''}`;b.disabled=busy;b.onclick=()=>perform({type:'tap',id:t.id});return b;}));
|
||||||
|
if(!state.tiles.length&&!busy)$('message').textContent=index===4?'这个模式的 5 关已完成,可以切换另一种玩法。':'全部到达!可以进入下一关。';
|
||||||
|
}
|
||||||
|
function load(){cancelAnimationFrame(frame);animation=null;history=[];hint=null;state=createState(levels.filter(l=>l.mode===mode)[index]);$('title').textContent=copy[mode][0];$('rule').textContent=copy[mode][1];$('message').textContent='点击箭头出发,遇阻可以免费试路或旋转。';$('level').value=index;sync();resize();}
|
||||||
|
function selectMode(next){mode=next;index=0;for(const b of document.querySelectorAll('[data-mode]'))b.setAttribute('aria-pressed',b.dataset.mode===mode);$('level').replaceChildren(...levels.filter(l=>l.mode===mode).map((l,i)=>new Option(`${i+1} · ${l.name}`,i)));load();}
|
||||||
|
function perform(action){
|
||||||
|
if(animation||!state.tiles.length)return;hint=null;
|
||||||
|
const tile=state.tiles.find(t=>t.id===action.id),result=act(state,action);
|
||||||
|
if(result.state!==state){history.push(state);state=result.state;}
|
||||||
|
const messages={rotate:'底盘已旋转,棋子与外围标记保持原位。',exit:tile?.trigger?'A 机关触发,金色轨道已经换路。':'已成功离场。',stop:'已停靠 P 点。可以转盘换路,再点它继续出发。',wrong:'出口颜色不匹配,试试旋转底盘。',blocked:'前方有棋子挡路,先让路或转盘再试。',loop:'这条路线绕回了原处,试试旋转底盘。'};
|
||||||
|
$('message').textContent=messages[result.status];
|
||||||
|
if(result.points?.length>1){const lengths=[0];for(let i=1;i<result.points.length;i++)lengths.push(lengths.at(-1)+Math.hypot(result.points[i].x-result.points[i-1].x,result.points[i].y-result.points[i-1].y));animation={...result,tile,points:result.points,lengths,start:performance.now(),duration:matchMedia('(prefers-reduced-motion: reduce)').matches?100:Math.min(1600,450+lengths.at(-1)*50)};}
|
||||||
|
sync();draw();
|
||||||
|
}
|
||||||
|
canvas.addEventListener('pointerup',e=>{const r=canvas.getBoundingClientRect(),x=(e.clientX-r.left-size/2)/scale,y=(e.clientY-r.top-size/2)/scale;const tile=state?.tiles.find(t=>{const p=position(t.q,t.r);return Math.hypot(x-p.x,y-p.y)<.85;});if(tile)perform({type:'tap',id:tile.id});});
|
||||||
|
$('left').onclick=()=>perform({type:'rotate',direction:-1});$('right').onclick=()=>perform({type:'rotate',direction:1});
|
||||||
|
$('reset').onclick=load;$('undo').onclick=()=>{if(!animation&&history.length){state=history.pop();hint=null;$('message').textContent='已恢复上一步,包括轨道机关与停靠位置。';sync();draw();}};
|
||||||
|
$('hint').onclick=()=>{const solution=solve(state);const action=solution.actions[0];if(!action){$('message').textContent=solution.exhausted?'搜索尚未完成,可以撤销再试。':'当前状态没有通关路径,请撤销或重来。';return;}hint=action.id;$('message').textContent=action.type==='rotate'?`向${action.direction===1?'右':'左'}旋转一次。`:`点击 ${action.id.slice(1)*1+1} 号棋子${state.tiles.find(t=>t.id===action.id)?.trigger?'(A 机关)':''}。`;draw();};
|
||||||
|
$('level').onchange=()=>{index=Number($('level').value);load();};$('next').onclick=()=>{index++;load();};
|
||||||
|
for(const b of document.querySelectorAll('[data-mode]'))b.onclick=()=>selectMode(b.dataset.mode);
|
||||||
|
window.addEventListener('resize',resize);
|
||||||
|
try{const response=await fetch('./levels.json');if(!response.ok)throw new Error(response.status);levels=await response.json();selectMode(new URLSearchParams(location.search).get('mode') in copy?new URLSearchParams(location.search).get('mode'):'color');}catch(error){$('message').textContent=`关卡加载失败,请通过 npm run dev 启动后刷新。${error.message}`;}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { hexagon, random, randomTracks, DIRS, key, trace } from '../../src/core.js';
|
||||||
|
import { createState, solve, act, routeFor } from './model.js';
|
||||||
|
const levels = [];
|
||||||
|
const names = { color: ['双色试航', '转向归站', '先让一路', '曲线分流', '各归其位'], switch: ['第一枚开关', '弯道解锁', '先清再切', '释放内圈', '换轨接力'], relay: ['中途停一停', '停靠再转向', '借位让路', '弯道接驳', '分段出发'] };
|
||||||
|
for (const mode of ['color', 'switch', 'relay']) {
|
||||||
|
for (let slot = 0; slot < 5; slot++) {
|
||||||
|
let accepted = false;
|
||||||
|
for (let attempt = 0; attempt < 2500 && !accepted; attempt++) {
|
||||||
|
const seed = 901 + slot * 10000 + attempt + ['color','switch','relay'].indexOf(mode) * 100000;
|
||||||
|
const rng = random(seed), cells = hexagon(2).map((c, i) => ({ ...c, id: i, tracks: randomTracks(rng) }));
|
||||||
|
const coords = new Set(cells.map(c => key(c.q, c.r)));
|
||||||
|
const gates = cells.flatMap(c => DIRS.flatMap(([dq, dr], dir) => coords.has(key(c.q+dq,c.r+dr)) ? [] : [{ q:c.q, r:c.r, dir, color: (dir + c.q - c.r + 12) % 2 }]));
|
||||||
|
const shuffled = [...cells]; // Fisher–Yates keeps recipes reproducible.
|
||||||
|
for(let i=shuffled.length-1;i>0;i--){const j=Math.floor(rng()*(i+1));[shuffled[i],shuffled[j]]=[shuffled[j],shuffled[i]];}
|
||||||
|
const tiles = shuffled.slice(0, 3 + Math.floor(slot / 2)).map((c,i) => ({ id:`p${i}`, q:c.q,r:c.r,dir:Math.floor(rng()*6),color:i%2 }));
|
||||||
|
if (mode === 'switch') {
|
||||||
|
tiles[0].trigger = 'A';
|
||||||
|
const target = cells.find(c => c.q === 0 && c.r === 0);
|
||||||
|
target.switchId = 'A'; target.alternate = randomTracks(rng);
|
||||||
|
}
|
||||||
|
const level = { version:1, id:`${mode}-${slot+1}`, mode, name:names[mode][slot], seed, cells, tiles, gates, stops:[{q:0,r:0}], moveLimit:tiles.length };
|
||||||
|
const start = createState(level), solution = solve(start, 2500);
|
||||||
|
if (!solution.solved) continue;
|
||||||
|
let state = start, stopCount = 0, bends = 0, changedRoute = false, openedRoute = false;
|
||||||
|
for (const action of solution.actions) {
|
||||||
|
const result = act(state, action);
|
||||||
|
if (result.status === 'stop') stopCount++;
|
||||||
|
if (result.status === 'exit') bends += result.steps.filter(s => s.enter !== null && (s.enter+3)%6 !== s.exit).length;
|
||||||
|
if (result.state.fired.length > state.fired.length) {
|
||||||
|
openedRoute = result.state.tiles.some(t => routeFor(state, t).status !== 'exit' && routeFor(result.state, t).status === 'exit');
|
||||||
|
changedRoute = result.state.tiles.some(t => JSON.stringify(trace(state,t,[],true).steps) !== JSON.stringify(trace(result.state,t,[],true).steps));
|
||||||
|
}
|
||||||
|
state = result.state;
|
||||||
|
}
|
||||||
|
if (!bends || !solution.actions.some(a => a.type === 'rotate')) continue;
|
||||||
|
if (mode === 'relay' && !stopCount) continue;
|
||||||
|
if (mode === 'switch' && (!changedRoute || !openedRoute)) continue;
|
||||||
|
levels.push({ ...level, solution:solution.actions, evidence:{ bends, stopCount, changedRoute, openedRoute, states:solution.states } });
|
||||||
|
console.log(level.id, seed, solution.actions.length);
|
||||||
|
accepted = true;
|
||||||
|
}
|
||||||
|
if (!accepted) throw new Error(`No candidate: ${mode} ${slot}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFileSync(new URL('./levels.json', import.meta.url), JSON.stringify(levels, null, 2)+'\n');
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>玩法变种 · HEX TRAILS Lab</title><link rel="stylesheet" href="./style.css"></head>
|
||||||
|
<body><main>
|
||||||
|
<header><a href="../../">← 返回主线</a><span>HEX TRAILS / 独立实验</span></header>
|
||||||
|
<h1>同一条轨道,三种新玩法</h1><p class="intro">沿着弯轨出发,转动底盘寻找新的可能。</p>
|
||||||
|
<nav class="modes" aria-label="玩法"><button data-mode="color">01 分色归站</button><button data-mode="switch">02 机关换轨</button><button data-mode="relay">03 接力停靠</button></nav>
|
||||||
|
<section class="brief"><h2 id="title"></h2><p id="rule"></p></section>
|
||||||
|
<div class="level-row"><label>关卡 <select id="level"></select></label><span id="count"></span></div>
|
||||||
|
<div class="board-wrap"><canvas id="board" aria-label="六角轨道棋盘,点击箭头出发"></canvas></div>
|
||||||
|
<p id="message" role="status" aria-live="polite"></p>
|
||||||
|
<div class="controls"><button id="left">↶ 左转</button><button id="undo">撤销</button><button id="hint">提示</button><button id="reset">重来</button><button id="right">右转 ↷</button></div>
|
||||||
|
<button id="next" hidden>下一关 →</button>
|
||||||
|
<details><summary>键盘操作 / 棋子列表</summary><div id="pieces"></div></details>
|
||||||
|
<footer>旋转时棋子位置与箭头不动。交叉不换轨,试路免费。<br>实验共 15 关 · 不写入主线进度</footer>
|
||||||
|
</main><script type="module" src="./app.js"></script></body></html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
import { clone, trace, rotateBoard, rotateTracks, key } from '../../src/core.js';
|
||||||
|
import { pathPoints, position, PORTS, sweepPath } from '../../src/path-math.js';
|
||||||
|
|
||||||
|
export function createState(level) {
|
||||||
|
return { ...clone(level), orientation: 0, fired: [] };
|
||||||
|
}
|
||||||
|
export function routeFor(state, tile) {
|
||||||
|
let route = trace(state, tile, state.tiles, true);
|
||||||
|
let points = pathPoints(route);
|
||||||
|
if (state.mode === 'relay') {
|
||||||
|
const index = route.steps.findIndex((s, i) => i > 0 && state.stops.some(p => p.q === s.q && p.r === s.r));
|
||||||
|
if (index >= 0) {
|
||||||
|
const stop = route.steps[index];
|
||||||
|
const center = position(stop.q, stop.r), port = PORTS[stop.enter];
|
||||||
|
points = [...pathPoints({ steps: route.steps.slice(0, index) }), { x: center.x + port.x, y: center.y + port.y }, center];
|
||||||
|
route = { ...route, status: 'stop', steps: route.steps.slice(0, index + 1), end: { q: stop.q, r: stop.r }, dir: stop.exit };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (route.status === 'exit') {
|
||||||
|
const last = points.at(-1), port = PORTS[route.steps.at(-1).exit];
|
||||||
|
points.push({ x: last.x + port.x * 4, y: last.y + port.y * 4 });
|
||||||
|
}
|
||||||
|
const collision = sweepPath(points, state.tiles.filter(t => t.id !== tile.id).map(t => ({ ...position(t.q, t.r), id: t.id })));
|
||||||
|
if (collision) return { ...route, status: 'blocked', blocker: collision.blocker, points: collision.points };
|
||||||
|
if (route.status === 'exit' && state.mode === 'color') {
|
||||||
|
const last = route.steps.at(-1);
|
||||||
|
const gate = state.gates.find(g => g.q === last.q && g.r === last.r && g.dir === last.exit);
|
||||||
|
if (!gate || gate.color !== tile.color) return { ...route, status: 'wrong', points };
|
||||||
|
}
|
||||||
|
return { ...route, points };
|
||||||
|
}
|
||||||
|
export function act(state, action) {
|
||||||
|
if (action.type === 'rotate') {
|
||||||
|
const next = rotateBoard(state, action.direction);
|
||||||
|
next.orientation = (state.orientation + action.direction + 6) % 6;
|
||||||
|
const count = (action.direction + 6) % 6;
|
||||||
|
next.cells = next.cells.map(c => {
|
||||||
|
if (!c.alternate) return c;
|
||||||
|
let alternate = c.alternate;
|
||||||
|
for (let i = 0; i < count; i++) alternate = rotateTracks(alternate);
|
||||||
|
return { ...c, alternate };
|
||||||
|
});
|
||||||
|
return { state: next, status: 'rotate' };
|
||||||
|
}
|
||||||
|
const tile = state.tiles.find(t => t.id === action.id);
|
||||||
|
if (!tile) return { state, status: 'invalid' };
|
||||||
|
const route = routeFor(state, tile);
|
||||||
|
if (!['exit', 'stop'].includes(route.status)) return { state, ...route };
|
||||||
|
const next = clone(state);
|
||||||
|
if (route.status === 'stop') {
|
||||||
|
Object.assign(next.tiles.find(t => t.id === tile.id), route.end, { dir: route.dir });
|
||||||
|
} else {
|
||||||
|
next.tiles = next.tiles.filter(t => t.id !== tile.id);
|
||||||
|
if (state.mode === 'switch' && tile.trigger) {
|
||||||
|
next.cells = next.cells.map(c => c.switchId === tile.trigger ? { ...c, tracks: c.alternate, alternate: c.tracks } : c);
|
||||||
|
next.fired.push(tile.trigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { state: next, ...route };
|
||||||
|
}
|
||||||
|
export function signature(state) {
|
||||||
|
return `${state.orientation}|${state.fired.join(',')}|${state.tiles.map(t => `${t.id}:${key(t.q, t.r)}:${t.dir}`).join(';')}`;
|
||||||
|
}
|
||||||
|
// Unlike the main campaign, departures can change tracks and stops change occupancy.
|
||||||
|
// Search every action; never greedily drain all currently available pieces.
|
||||||
|
export function solve(state, limit = 12000) {
|
||||||
|
const queue = [{ state, actions: [] }], seen = new Set([signature(state)]);
|
||||||
|
for (let head = 0; head < queue.length && head < limit; head++) {
|
||||||
|
const node = queue[head];
|
||||||
|
if (!node.state.tiles.length) return { solved: true, actions: node.actions, states: head + 1 };
|
||||||
|
const actions = [...node.state.tiles.map(t => ({ type: 'tap', id: t.id })), { type: 'rotate', direction: 1 }, { type: 'rotate', direction: -1 }];
|
||||||
|
for (const action of actions) {
|
||||||
|
const result = act(node.state, action);
|
||||||
|
if (result.state === node.state) continue;
|
||||||
|
const id = signature(result.state);
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
queue.push({ state: result.state, actions: [...node.actions, action] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { solved: false, exhausted: queue.length > limit, actions: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
:root{font-family:system-ui,-apple-system,"PingFang SC",sans-serif;color:#253b4a;background:#eaf0f3;font-synthesis:none}*{box-sizing:border-box}body{margin:0}main{max-width:640px;margin:auto;padding:24px 20px}header,.level-row{display:flex;align-items:center;justify-content:space-between;gap:12px}header{font-size:12px;color:#657986}a{color:inherit}h1{font-size:27px;margin:30px 0 8px;letter-spacing:-1px}.intro{color:#657986;margin:0 0 24px;font-size:14px}button,select{font:inherit;border:1px solid #cbd7dd;border-radius:12px;background:#fff;color:#253b4a;padding:12px;cursor:pointer;touch-action:manipulation}button:hover{background:#edf5f7}button:focus-visible,select:focus-visible{outline:3px solid #387b99;outline-offset:3px}button:disabled{opacity:.4;cursor:default}.modes{display:grid;grid-template-columns:repeat(3,1fr);gap:7px}.modes button{font-size:14px;padding:13px 3px}.modes button[aria-pressed=true]{background:#294d60;color:white;border-color:#294d60}.brief{padding:18px 0 12px;min-height:124px}.brief h2{font-size:19px;margin:0 0 8px}.brief p{margin:0;line-height:1.7;font-size:14px;color:#506674}.level-row{font-size:13px}.level-row select{padding:7px;max-width:220px}.board-wrap{width:100%;aspect-ratio:1;max-height:590px}canvas{display:block;width:100%;height:100%;touch-action:manipulation}#message{min-height:44px;margin:0 0 12px;text-align:center;font-size:14px;line-height:1.6}.controls{display:grid;grid-template-columns:repeat(5,1fr);gap:7px}.controls button{padding:12px 3px;font-size:14px}#next{width:100%;margin-top:12px;background:#294d60;color:#fff}details{margin-top:20px;font-size:12px;color:#657986}#pieces{display:flex;gap:8px;flex-wrap:wrap;padding-top:10px}footer{font-size:12px;color:#657986;text-align:center;line-height:1.9;margin:24px 0 10px}@media(max-width:380px){main{padding:16px 12px}h1{font-size:24px}.modes button{font-size:12px}}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { cp, mkdir, rm } from 'node:fs/promises';
|
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||||
const out=new URL('../dist/',import.meta.url);
|
const out=new URL('../dist/',import.meta.url);
|
||||||
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
||||||
for(const name of ['index.html','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});
|
for(const name of ['index.html','design-showcase.html','style.css','src','levels','assets','design','Design.md','Design.html','labs/variants'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||||
console.log('Static build ready in dist/');
|
console.log('Static build ready in dist/');
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createState, act, routeFor, solve } from '../labs/variants/model.js';
|
||||||
|
import { hexagon, validateLevel } from '../src/core.js';
|
||||||
|
const levels=JSON.parse(readFileSync(new URL('../labs/variants/levels.json',import.meta.url)));
|
||||||
|
test('15 variant levels validate, solve, replay and use their mechanics',()=>{
|
||||||
|
assert.equal(levels.length,15);
|
||||||
|
for(const level of levels){
|
||||||
|
assert.deepEqual(validateLevel(level),[]);
|
||||||
|
let state=createState(level),stops=0;
|
||||||
|
for(const action of level.solution){const result=act(state,action);assert.notEqual(result.state,state,level.id);if(result.status==='stop')stops++;state=result.state;}
|
||||||
|
assert.equal(state.tiles.length,0,level.id);
|
||||||
|
assert.equal(solve(createState(level)).solved,true,level.id);
|
||||||
|
assert.ok(level.evidence.bends>0);
|
||||||
|
if(level.mode==='relay')assert.ok(stops>0);
|
||||||
|
if(level.mode==='switch'){assert.deepEqual(state.fired,['A']);assert.ok(level.evidence.changedRoute);assert.ok(level.evidence.openedRoute);}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test('six rotations restore active and alternate tracks, gates and stops remain fixed',()=>{
|
||||||
|
for(const level of levels){let state=createState(level);for(let i=0;i<6;i++)state=act(state,{type:'rotate',direction:1}).state;assert.deepEqual(state,createState(level));}
|
||||||
|
});
|
||||||
|
test('wrong color rejects departure without mutating the snapshot',()=>{
|
||||||
|
const state=createState({mode:'color',cells:hexagon(2),tiles:[{id:'x',q:0,r:0,dir:0,color:0}],gates:[{q:0,r:-2,dir:0,color:1}]});
|
||||||
|
const result=act(state,{type:'tap',id:'x'});assert.equal(result.status,'wrong');assert.equal(result.state,state);
|
||||||
|
state.gates[0].color=0;assert.equal(act(state,{type:'tap',id:'x'}).state.tiles.length,0);
|
||||||
|
});
|
||||||
|
test('relay truncates collision checking at stop and resumes to exit',()=>{
|
||||||
|
const state=createState({mode:'relay',cells:hexagon(2),stops:[{q:0,r:0}],tiles:[{id:'x',q:0,r:2,dir:0,color:0},{id:'y',q:0,r:-2,dir:0,color:1}]});
|
||||||
|
const result=act(state,{type:'tap',id:'x'});assert.equal(result.status,'stop');assert.equal(result.state.tiles[0].r,0);assert.equal(state.tiles[0].r,2);
|
||||||
|
assert.equal(routeFor(result.state,result.state.tiles[0]).status,'blocked');
|
||||||
|
const cleared=act(result.state,{type:'tap',id:'y'}).state;assert.equal(act(cleared,{type:'tap',id:'x'}).status,'exit');
|
||||||
|
});
|
||||||
|
test('occupied stop blocks; state snapshots allow exact undo of switch and parking',()=>{
|
||||||
|
const state=createState({mode:'relay',cells:hexagon(2),stops:[{q:0,r:0}],tiles:[{id:'x',q:0,r:2,dir:0,color:0},{id:'y',q:0,r:0,dir:0,color:1}]});
|
||||||
|
assert.equal(act(state,{type:'tap',id:'x'}).status,'blocked');
|
||||||
|
for(const level of levels){let current=createState(level);for(const action of level.solution){const snapshot=JSON.stringify(current);const next=act(current,action).state;assert.equal(JSON.stringify(current),snapshot);current=next;}}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user