feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+153
View File
@@ -0,0 +1,153 @@
import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from '../../src/levels-v05.js';
import { campaignPage, levelIndexFromInput, productionPresentation, featuredTrackEmphasis } from '../../src/campaign-browser.js';
import { Game } from '../../src/core.js';
import { layout } from '../../src/geometry.js';
import { CanvasPainter, drawScene } from '../../src/renderer.js';
import { exitTiming } from '../../src/exit-animation.js';
const $=id=>document.getElementById(id),escape=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const campaign={...PRODUCTION_CAMPAIGN,levels:PRODUCTION_LEVELS},records=new Map(),manualRecords=new Map();
let chapter=0,observer,detailIndex=0,replay,step=0,motion=null,playing=false,frame=0;
const board=$('review-board'),painter=new CanvasPainter(board.getContext('2d'));
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
const initial=levelIndexFromInput(new URLSearchParams(location.search).get('level')||'',PRODUCTION_LEVELS.length);
if(initial!==null)chapter=Math.floor(initial/campaign.chapterSize);
$('chapter').innerHTML=campaign.chapters.map((name,i)=>`<option value="${i}">第 ${i+1} 章 · ${escape(name)}</option>`).join('');
const kinds=new Map(PRODUCTION_LEVELS.map(level=>[level.experience.kind,level.beat]));
$('kind').innerHTML+=[...kinds].map(([id,name])=>`<option value="${escape(id)}">${escape(name)}</option>`).join('');
const tiers=new Map(PRODUCTION_LEVELS.map(level=>{const p=productionPresentation(level);return [p.tier,p.label];}));
$('difficulty').innerHTML+=[...tiers].sort((a,b)=>a[0]-b[0]).map(([tier,label])=>`<option value="${tier}">${escape(label)}</option>`).join('');
function thumbnail(canvas){
const level=PRODUCTION_LEVELS[Number(canvas.dataset.index)];
const width=300;canvas.width=width;canvas.height=width;
drawScene(new CanvasPainter(canvas.getContext('2d')),level,level.tiles,layout(level.cells,width,width),{reduced:true,trackEmphasis:featuredTrackEmphasis(level)});
}
// Export every level in a chapter, independently of the on-screen filters and
// thumbnail visibility. The same entry point is available to review automation.
export function createChapterContactSheet(chapterIndex, {allTracks=false}={}){
const page=campaignPage(campaign,chapterIndex),cols=5,padding=30,cell=280,gap=16,header=136,caption=75;
const sheet=document.createElement('canvas');
sheet.width=padding*2+cols*cell+(cols-1)*gap;
sheet.height=header+Math.ceil(page.levels.length/cols)*(cell+caption+gap)+padding;
const context=sheet.getContext('2d');context.fillStyle='#f3f2eb';context.fillRect(0,0,sheet.width,sheet.height);
context.fillStyle='#253d45';context.font='bold 33px system-ui, sans-serif';context.fillText(`千段轨迹 · 第 ${page.chapter+1} 章 · ${page.title}`,padding,53);
context.font='19px system-ui, sans-serif';context.fillStyle='#65776c';context.fillText(`v0.5 / ${page.start+1}–${page.end} 关 / 共 ${page.levels.length} 关 / 难度为结构预估 / ${allTracks?'显示全部轨道':'突出设计路线,保留次要轨道'}`,padding,91);
const fitText=(text,x,y,maxWidth,size)=>{
context.font=`${size}px system-ui, sans-serif`;
let value=String(text);while(value.length>1&&context.measureText(value).width>maxWidth)value=value.slice(0,-2)+'…';
context.fillText(value,x,y);
};
for(const [offset,{level,index}] of page.levels.entries()){
const x=padding+(offset%cols)*(cell+gap),y=header+Math.floor(offset/cols)*(cell+caption+gap),info=productionPresentation(level);
context.fillStyle='#dce3d8';context.fillRect(x,y,cell,cell);
context.save();context.translate(x,y);
drawScene(new CanvasPainter(context),level,level.tiles,layout(level.cells,cell,cell),{reduced:true,trackEmphasis:allTracks?null:featuredTrackEmphasis(level)});
context.restore();context.fillStyle='#253d45';fitText(`${String(index+1).padStart(3,'0')} ${info.displayName}`,x,y+cell+25,cell,18);
context.fillStyle='#667d6d';fitText(`${info.experience} · ${info.label} · ${level.tiles.length} 枚`,x,y+cell+48,cell,14);
fitText(info.reward,x,y+cell+68,cell,13);
}
return sheet;
}
function downloadBlob(blob,filename){
const url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download=filename;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
function render(){
observer?.disconnect();
const page=campaignPage(campaign,chapter);chapter=page.chapter;$('chapter').value=String(chapter);
const levels=page.levels.filter(({level})=>(!$('kind').value||level.experience.kind===$('kind').value)&&(!$('difficulty').value||String(level.production.difficulty.tier)===$('difficulty').value));
$('chapter-title').textContent=page.title;$('chapter-count').textContent=`CHAPTER ${String(chapter+1).padStart(2,'0')} / 20 · ${page.start+1}–${page.end} 关 · 显示 ${levels.length} 关`;
$('previous').disabled=chapter===0;$('next').disabled=chapter===page.count-1;$('empty').hidden=levels.length>0;
$('grid').innerHTML=levels.map(({level,index})=>{const info=productionPresentation(level);return `<button class="card" data-level="${index}" aria-label="查看第 ${index+1} 关 ${escape(info.displayName)} 的制作记录"><canvas data-index="${index}" width="300" height="300" aria-hidden="true"></canvas><div class="card-meta"><strong>${String(index+1).padStart(3,'0')}</strong><span class="badge" data-tier="${info.tier}">${escape(info.label)} · 预估</span></div><h3>${escape(info.displayName)}</h3><p>${escape(info.experience)} · ${level.tiles.length} 枚棋子</p><p>${escape(info.reward||info.intent)}</p></button>`;}).join('');
observer=new IntersectionObserver(entries=>{for(const entry of entries)if(entry.isIntersecting){thumbnail(entry.target);observer.unobserve(entry.target);}},{rootMargin:'300px'});
document.querySelectorAll('#grid canvas').forEach(canvas=>observer.observe(canvas));
}
function evidence(){
const level=PRODUCTION_LEVELS[detailIndex],record=records.get(level.id);
const automatic=record||{id:level.id,production:level.production,experience:level.experience,recordStatus:'完整验证记录尚未载入'};
$('detail-evidence').textContent=JSON.stringify({...automatic,sourceName:level.name,manualReview:manualRecords.get(level.id)||{visual:'尚未记录',browserPlay:'尚未记录'}},null,2);
}
function detailMetrics(){
const level=PRODUCTION_LEVELS[detailIndex],info=productionPresentation(level),reward=level.production.reward||{};
const values=[['难度 · 结构预估',info.label],['棋子',`${level.tiles.length} 枚`],['最长巡游',Number.isFinite(reward.longestSteps)?`${reward.longestSteps} 格`:'见回放记录'],['最多转弯',Number.isFinite(reward.longestBends)?`${reward.longestBends} 次`:'见回放记录']];
$('detail-metrics').innerHTML=values.map(([label,value])=>`<div><span>${escape(label)}</span><strong>${escape(value)}</strong></div>`).join('');
}
function openDetail(index){
detailIndex=index;const level=PRODUCTION_LEVELS[index],info=productionPresentation(level);
$('detail-eyebrow').textContent=`第 ${index+1} 关 · ${level.chapterName||campaign.chapters[Math.floor(index/campaign.chapterSize)]} · ${info.experience}`;
$('detail-title').textContent=info.displayName;$('detail-intent').textContent=info.intent;$('play-level').href=`../../index.html?campaign=v0.5&level=${index+1}`;
detailMetrics();evidence();resetReplay();if(!$('detail').open)$('detail').showModal();
}
function actions(){return PRODUCTION_LEVELS[detailIndex].experience.actions||[];}
function stop(){playing=false;motion=null;cancelAnimationFrame(frame);$('replay-play').textContent='播放全程';}
function resetReplay(){
stop();replay=new Game(PRODUCTION_LEVELS[detailIndex]);step=0;
$('replay-actions').innerHTML=actions().map((action,i)=>`<li data-action="${i}">${action.type==='rotate'?`底盘${action.direction===1?'右':'左'}转 60°`:`放出棋子 ${escape(action.id)}`}</li>`).join('');
$('replay-status').textContent=`已准备 ${actions().length} 步回放。点击下一步可以逐个查看放行关系。`;
updateReplayControls();drawReplay();
}
function updateReplayControls(){
$('replay-step').disabled=!!motion||step>=actions().length;
$('replay-play').disabled=step>=actions().length&&!playing;
$('replay-play').textContent=playing?'暂停回放':'播放全程';
document.querySelectorAll('#replay-actions li').forEach((el,i)=>el.classList.toggle('active',i===step-1));
}
function advance(){
if(motion||step>=actions().length)return;
const action=actions()[step],now=performance.now();
if(action.type==='rotate'){
const result=replay.rotate(action.direction);
if(result.status!=='rotated'){stop();$('replay-status').textContent=`第 ${step+1} 步无法旋转,请检查制作记录。`;return;}
motion={type:'rotate',direction:action.direction,start:now,duration:reduced?100:300};
$('replay-status').textContent=`第 ${step+1} 步:${action.direction===1?'右':'左'}转底盘,棋子与箭头保持原位。`;
}else{
const route=replay.tap(action.id);
if(route.status!=='exit'){stop();$('replay-status').textContent=`第 ${step+1} 步未能离场(${route.status}),请检查制作记录。`;return;}
motion={type:'tap',route,start:now,duration:reduced?200:exitTiming(route).duration};
$('replay-status').textContent=`第 ${step+1} 步:棋子 ${action.id} 沿 ${route.steps.length} 格轨道离场。`;
}
step++;updateReplayControls();drawReplay();
}
function drawReplay(now=performance.now()){
if(!replay)return;
painter.ctx.clearRect(0,0,board.width,board.height);
const level=PRODUCTION_LEVELS[detailIndex],state={reduced,trackEmphasis:$('all-tracks').checked?null:featuredTrackEmphasis(level,replay.orientation)};
if(motion){
const progress=Math.min(1,(now-motion.start)/motion.duration);
if(motion.type==='tap')state.animations=[{route:motion.route,progress}];
else state.boardAngle=-motion.direction*Math.PI/3*(1-Math.pow(progress, .5));
if(progress>=1)motion=null;
}
drawScene(painter,replay.level,replay.tiles,layout(replay.level.cells,board.width,board.height),state);
if(motion){frame=requestAnimationFrame(drawReplay);return;}
if(replay.won){playing=false;$('replay-status').textContent=`${step} 步回放完成,${level.tiles.length} 枚棋子全部离场。`;}
updateReplayControls();
if(playing&&step<actions().length)advance();
}
$('chapter').onchange=()=>{chapter=Number($('chapter').value);render();};
for(const id of ['kind','difficulty'])$(id).onchange=render;
$('previous').onclick=()=>{chapter--;render();};$('next').onclick=()=>{chapter++;render();};
$('export-sheet').onclick=()=>{
const number=chapter;$('export-sheet').disabled=true;
createChapterContactSheet(number).toBlob(blob=>{if(blob)downloadBlob(blob,`hex-trails-v0.5-chapter-${String(number+1).padStart(2,'0')}.png`);$('export-sheet').disabled=false;},'image/png');
};
$('grid').onclick=e=>{const card=e.target.closest('[data-level]');if(card)openDetail(Number(card.dataset.level));};
$('jump').onsubmit=e=>{e.preventDefault();const index=levelIndexFromInput($('level-number').value,PRODUCTION_LEVELS.length);if(index===null)return;chapter=Math.floor(index/campaign.chapterSize);$('kind').value='';$('difficulty').value='';render();openDetail(index);};
$('close-detail').onclick=()=>$('detail').close();$('detail').addEventListener('close',stop);
$('replay-reset').onclick=resetReplay;$('replay-step').onclick=advance;
$('replay-play').onclick=()=>{playing=!playing;updateReplayControls();if(playing&&!motion)advance();};
$('all-tracks').onchange=()=>{cancelAnimationFrame(frame);drawReplay();};
$('download-level').onclick=()=>{const level=PRODUCTION_LEVELS[detailIndex];downloadBlob(new Blob([JSON.stringify(level,null,2)],{type:'application/json'}),`${level.id}.json`);};
render();if(initial!==null)openDetail(initial);
fetch('../../levels/v0.5-analysis.json').then(response=>{if(!response.ok)throw new Error('验证记录暂不可用');return response.json();}).then(data=>{
for(const record of data.levels||[])records.set(record.id,record);
$('summary').textContent=`${PRODUCTION_LEVELS.length} 关 · ${campaign.chapters.length} 章 · 已载入 ${records.size} 份验证记录。每章最多显示 ${campaign.chapterSize} 关;点击缩略图查看制作意图与保存的清盘回放。`;
if($('detail').open)evidence();
}).catch(error=>{$('summary').textContent=`${PRODUCTION_LEVELS.length} 关已载入。${error.message},关卡内保存的回放仍可查看。`;});
fetch('../../levels/v0.5-reviews.json').then(response=>response.ok?response.json():null).then(data=>{
for(const record of data?.levels||[])manualRecords.set(record.id,record);
if($('detail').open)evidence();
}).catch(()=>{});
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>千段轨迹 · 关卡制作记录</title><link rel="stylesheet" href="style.css"></head>
<body>
<header class="page-header"><div><p class="eyebrow">HEX TRAILS / v0.5</p><h1>千段轨迹</h1><p>按章看路线,逐关回放,找到值得继续打磨的地方。</p></div><a class="button primary" href="../../index.html?campaign=v0.5">进入游戏 ↗</a></header>
<main>
<div id="summary" class="summary" role="status">正在读取制作记录…</div>
<section class="browser" aria-label="浏览关卡">
<div class="controls"><label>章节<select id="chapter"></select></label><label>体验<select id="kind"><option value="">全部体验</option></select></label><label>难度预估<select id="difficulty"><option value="">全部难度</option></select></label><form id="jump"><label>关卡编号<input id="level-number" inputmode="numeric" type="number" min="1" max="1000" placeholder="1–1000" required></label><button type="submit">前往</button></form></div>
<div class="chapter-heading"><div><p id="chapter-count" class="eyebrow"></p><h2 id="chapter-title"></h2></div><nav aria-label="章节翻页"><button id="export-sheet">导出本章总览 PNG</button><button id="previous">← 上一章</button><button id="next">下一章 →</button></nav></div>
<div id="grid" class="grid"></div><p id="empty" hidden>本章没有符合筛选条件的关卡。可以更换体验或难度。</p>
</section>
<p class="footnote">结构分档和自动回放提供制作证据;玩家是否觉得清楚、轻巧和新鲜,仍需连续试玩校准。<a href="../../levels/v0.5-analysis.json">查看完整回放记录 JSON</a></p>
</main>
<dialog id="detail" aria-labelledby="detail-title"><button id="close-detail" class="close" aria-label="关闭制作记录">×</button><div class="detail-heading"><p id="detail-eyebrow" class="eyebrow"></p><h2 id="detail-title"></h2><p id="detail-intent"></p></div><div class="detail-columns"><div><canvas id="review-board" width="650" height="650" aria-label="关卡回放棋盘"></canvas><div class="replay-controls"><button id="replay-reset">重置回放</button><button id="replay-step">下一步</button><button id="replay-play" class="primary">播放全程</button></div><p id="replay-status" role="status"></p><label class="track-toggle"><input id="all-tracks" type="checkbox"> 同等显示全部轨道</label></div><div><div id="detail-metrics" class="detail-metrics"></div><p class="hint">这是制作时保存的一条清盘路线。实际操作可以选择其他顺序。</p><ol id="replay-actions" class="replay-actions"></ol><div class="detail-links"><a id="play-level" class="button primary">亲自试玩 ↗</a><button id="download-level">下载本关 JSON</button></div><details><summary>查看本关验证记录</summary><pre id="detail-evidence"></pre></details></div></div></dialog>
<script type="module" src="app.js"></script>
</body></html>
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +1,5 @@
# Car Loop Lab
This is an isolated gameplay experiment. Keep its experimental grid model, geometry,
rendering and controls inside this directory; do not change the production hex rules.
Use dependency-free ES modules. Node tests belong in tests/car-loop.test.js.
+50
View File
@@ -0,0 +1,50 @@
# 环路车场 / Car Loop Lab
运行根目录 `npm run dev`,访问 <http://localhost:4173/labs/car-loop/index.html>。
**研发状态(2026-09-10):转盘发车玩法暂缓。** 保留当前 Demo 和测试。研发演进、试玩反馈、已验证内容及尚未实施的优化方向见 [研发记录与阶段结论](../../docs/car-loop-research.md)。当前阶段判断:通行与表现已改善,但策略乐趣尚未成立。
## 操作
- **点击即刻出发**,不加入发车队列,也不等上一辆离盘。多辆车同时行驶。
- 未点击的车停在原位;已出发车辆会在前车或交叉冲突前刹停,通行空间清空后,从停下的位置自动继续。
- 路线冲突按让路依赖确定通行优先级,保护尚未通过的交叉区域;不允许高速穿车。
- 六角 37 格 / 方格 7×7 切换,各有 10、16、22 辆车样本。方格选图额外筛选实际长绕行,默认 #107 的参考解平均 9.4 弯、最长 20 弯。
- 方格每次旋转 90°,六角 60°。盘内行驶车辆在换路前归位,转好后自动重新出发;已离盘车辆继续在固定环路上行驶。闭环路线提示换路,不自动选方向。
- 环路贴合棋盘轮廓:方形配方形,六角配六角,小圆角平滑过弯。离盘经短引道顺时针汇流,进入右上角终点。
- 盘内速度 480 画布像素/秒,环路及并线 360 像素/秒;碰撞细分最大 1.5 像素,速度提高不减少检测精度。
- 悬停可查看路径;路线提示和自动演示用于验证;车辆列表支持键盘发车。
## 设计
采用只读参考 `../hex-video/designv2.md` 的视觉方向:近白环境、冷灰底板及 HUD、七色车辆和白箭头、红橙旋转器、蓝色提示器、绿色主操作。到达后切换蓝青奖励背景、金黄完成标记和再玩按钮。
车辆绘制使用 `theme.js` 的独立色族 face / side / light,保留一条薄侧边,不使用叠片或实体拖尾。道路、车辆、即时交通及终点都是本 Lab 的玩法适配,并非参考视频的原始规则。
## 结构与验证
- `model.js`:网格、配对轨道、连续曲线、静态碰撞、关卡生成与求解。
- `motion.js`:轨迹采样、实时碰撞停车、交叉通行优先级。
- `perimeter.js`:顺时针方形/六边形环路、距离采样及出盘射线交点。
- `app.js`:并行车辆运行、并线、操作与 Canvas 表现。
测试:`node --test tests/car-loop.test.js`,完整回归:`npm test`。`npm run build` 会打包本实验及设计参考 HTML。
车辆采用半径 0.36 格的统一净空,未模拟不同车长与真实转弯半径。路口保护采用保守的路线通行权,允许车辆提前停在交叉冲突区外;不将路线交叉画成可穿过其他车辆的立交。换路时归位是实验规则,避免在正在变化的路面上保留失效轨迹。
独立实验不修改主游戏规则、主线关卡或存档。
### 超大桌面关卡
打开 `index.html?size=xl&kind=hex`(169 格)或 `index.html?size=xl&kind=square`(15×15 格)。各有 96 辆车,保留即时发车、路口等候和道路旋转。棋盘自动适配窗口剩余空间,顶部状态与底部操作浮于画布角落,页面和棋盘均无滚动条。预生成样本位于 `large-levels.js`,来自 `generate(kind, 107, 96, 7)`;“换个朝向”切换道路的初始方向。静态道路使用离屏缓存。
### 转盘发车 Demo
`index.html?mode=batch&kind=hex` 直接进入新玩法,也可在页面“玩法”中切换回自由点车。
- 底部为左转 / 发车 / 右转。每轮一次安排当前朝向下可放行的全部车辆,互不冲突的车并行出发,有冲突的车在原位倒计时后出发,途中连续通行,系统不会转盘。
- `schedule.js` 为实际盘内曲线、汇入口、外环和车库预订通行时段,通过连续线段的相对运动计算车距。调度包含上一轮仍在外环的车辆;统一模拟时钟保证 1–3× 调速后时段和间隔不变。待发车辆仍计入“待出发”,状态单列待发数量。
- 当前无车可驶出时停下并提示玩家转盘。转盘本身不触发发车;已进入外环的车继续前往终点。
- 采用原来的绿色悬停提示样式统一显示盘内路线,驶出棋盘立即收起,外环不绘制尾迹。状态明确区分可发车、放行中、需转盘和等待到站。
- 每种棋盘有 3 个、各 16 辆的测试关卡。`batch.js` 按“单次左/右转 + 当前朝向放行”做广度优先搜索,得出目标次数。所有关卡的第一次左转与右转的最少总次数不同,第二关需要折返转向。目标只作评价,不限制尝试。
- 真实程序帧循环测试覆盖卡点停止、禁止自动转盘、转后等待再次发车及全部到站。
+457
View File
@@ -0,0 +1,457 @@
import { drainOrientation, rotationPlan, batchTitles, batchSeeds } from './batch.js';
import { departure as buildDeparture, garagePath } from './routes.js';
import { journey, journeyPosition, scheduleDepartures } from './schedule.js';
import { carPaints, theme } from './theme.js';
import { at, advance, activeCorridors, passageOrder, resolveGridlock } from './motion.js';
import { createPerimeter } from './perimeter.js';
import { center, port, curve, rotate, trace, generateSample, generate, suggestion, solve, directions } from './model.js';
const $ = id => document.getElementById(id);
const canvas = $('game');
let ctx = canvas.getContext('2d'), backdrop = null;
let W = 768, H = 740, CX = 384, CY = 360;
const TAU = Math.PI * 2, FINISH = 0;
const params = new URLSearchParams(location.search);
let mode = params.get('mode') === 'batch' ? 'batch' : 'free';
let turnCount = 0, turnGoal = 0, roundStarted = 0, batchAvailable = [];
let batchClock = 0, pendingDepartures = [];
const freeOptions = ['01 · 连弯初见 / 10 辆', '02 · 交织长路 / 16 辆', '03 · 满盘绕行 / 22 辆', '04 · 超大桌面 / 96 辆'];
let kind = params.get('kind') === 'hex' ? 'hex' : 'square', scenario = params.get('size') === 'xl' ? 3 : 1, variation = 0, board, cars, total, delivered = 0;
if (mode === 'batch') scenario = 0;
let resumeAfterTurn = [], templates = new Map();
let traffic = [], hover = null, selected = null, auto = false, lastAuto = 0, speed = 2;
let turnAnimation = null, last = performance.now(), stats, idleFor = 0;
const mod = a => (a % TAU + TAU) % TAU;
const scale = () => kind === 'square' ? 68 : 74;
const screen = p => ({ x: CX + p.x * scale(), y: CY + p.y * scale() });
const carCenter = car => screen(center(kind, car.x, car.y));
let perimeter, finishPath;
const ring = a => perimeter.at(a);
function poly(points, color, width = 1, dash = []) {
if (!points.length) return;
ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y);
for (const p of points.slice(1)) ctx.lineTo(p.x, p.y);
ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.setLineDash(dash); ctx.stroke(); ctx.setLineDash([]);
}
function roundRect(x, y, w, h, r, fill, stroke) {
ctx.beginPath(); ctx.roundRect(x, y, w, h, r);
if (fill) { ctx.fillStyle = fill; ctx.fill(); }
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); }
}
function text(value, x, y, size = 12, color = '#6e8574', align = 'center') {
ctx.fillStyle = color; ctx.font = `500 ${size}px -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif`; ctx.textAlign = align; ctx.fillText(value, x, y);
}
const departure = route => buildDeparture(route, kind, screen, perimeter);
function busy() { return !!turnAnimation || pendingDepartures.length > 0 || traffic.some(t => ['board', 'waiting', 'joining'].includes(t.stage) && t.distance < t.boardLength + 27); }
function say(message) { $('status').textContent = message; }
function ui() {
$('remaining').textContent = cars.length; $('transit').textContent = traffic.length; $('arrived').textContent = delivered;
$('meter').style.width = `${delivered / total * 100}%`;
$('left').disabled = $('right').disabled = (mode === 'batch' && busy()) || !!turnAnimation || (!cars.length && !traffic.some(t => t.distance < t.boardLength + 27));
if (mode === 'batch') batchStatus();
$('auto').textContent = auto ? 'Ⅱ 暂停演示' : '▶ 自动演示'; $('auto').setAttribute('aria-pressed', String(auto));
}
function batchStatus() {
const internal = traffic.filter(t => ['board', 'waiting', 'joining'].includes(t.stage) && t.distance < t.boardLength + 27);
const outside = traffic.length - internal.length;
let phase, label, message;
if (turnAnimation) {
phase = 'turning'; label = '转动中'; message = '道路转动中,转好后即可查看能放行的车辆。';
} else if (pendingDepartures.length || internal.length) {
phase = 'running'; label = '放行中';
message = `本轮放行 ${roundStarted} 辆 · 待发 ${pendingDepartures.length} 辆 · 盘内 ${internal.length} 辆 · 外环 ${outside} 辆。已错开交汇时间,出发后连续通行。`;
} else if (!cars.length) {
phase = traffic.length ? 'arriving' : 'complete'; label = traffic.length ? '到站中' : '已清空';
message = traffic.length ? `棋盘已清空,外环还有 ${traffic.length} 辆正在前往终点。` : '全部抵达!';
} else if (batchAvailable.length) {
phase = 'ready'; label = '发车'; message = `当前方向可放行 ${batchAvailable.length} 辆,点击中间按钮统一放行,自动错开路线冲突。`;
} else {
phase = 'blocked'; label = '需转盘'; message = `当前方向无车可走,剩余 ${cars.length} 辆。请左转或右转棋盘。`;
}
$('batch-hud').dataset.phase = phase;
$('hint-label').textContent = label;
$('hint').disabled = phase !== 'ready';
$('hint').setAttribute('aria-label', phase === 'ready' ? `统一发车 ${batchAvailable.length} 辆` : label);
$('hint').setAttribute('aria-pressed', String(phase === 'running'));
$('run-symbol').setAttribute('d', 'M24 17L43 30 24 43Z');
$('turn-count').textContent = `已转 ${turnCount} 次`;
$('batch-result').textContent = phase === 'running' ? `放行中 · 待发 ${pendingDepartures.length} / 盘内 ${internal.length} / 外环 ${outside}`
: phase === 'ready' ? `可发车 ${batchAvailable.length} 辆` : message;
if ($('status').textContent !== message) say(message);
}
function listCars() {
$('car-list').replaceChildren(...cars.map(car => {
const button = document.createElement('button'); button.textContent = `车 ${Number(car.id.slice(1)) + 1}`;
button.addEventListener('click', () => { if (mode === 'batch') selected = car.id; else fire(car.id); }); return button;
}));
}
function cacheRoutes() {
const order = passageOrder(board, board.cars, trace);
templates = new Map(board.cars.map(car => {
const route = trace(board, car, []);
return [car.id, route.status === 'exit' ? { car, ...departure(route), distance: 0, order: order.get(car.id) } : null];
}));
}
function reset() {
const batch = mode === 'batch';
const large = !batch && scenario === 3;
W = large ? 1320 : 768; H = large ? 1380 : 740; CX = W / 2; CY = large ? 690 : 360;
canvas.width = W; canvas.height = H; backdrop = null;
document.body.classList.toggle('desktop-level', large || batch);
document.body.classList.toggle('batch-mode', batch);
canvas.style.setProperty('--board-aspect', String(W / H));
$('mode').value = mode;
canvas.setAttribute('aria-label', batch ? '转盘发车棋盘,点击车辆查看路线,点击中间发车按钮统一放行。' : '交互车场,点击车辆立即发车。也可用车辆操作列表。');
const labels = batch ? batchTitles.map((title, i) => `0${i + 1} · ${title} / 16 辆`) : freeOptions;
$('scenario').replaceChildren(...labels.map((label, i) => { const option = document.createElement('option'); option.value = String(i); option.textContent = label; return option; }));
$('auto').hidden = batch; $('batch-help').hidden = $('batch-hud').hidden = !batch;
$('hint').disabled = false;
$('hint-label').textContent = batch ? '发车' : '路线提示';
$('hint').setAttribute('aria-label', batch ? '统一发车' : '查看路线提示');
turnCount = 0; roundStarted = 0;
batchClock = 0; pendingDepartures = [];
$('batch-result').textContent = '点击发车开始';
$('scenario').value = String(scenario);
$('new').textContent = batch ? '下一关' : large ? '换个朝向' : '换张路网';
perimeter = createPerimeter(kind, CX, CY, scale(), large ? 7 : 3);
finishPath = garagePath(perimeter, W);
board = batch ? generate(kind, batchSeeds[kind][scenario], 16) : generateSample(kind, scenario, variation);
if (batch) { turnGoal = rotationPlan(board).turns.length; $('turn-goal').textContent = `目标 ${turnGoal} 次`; }
cars = [...board.cars]; batchAvailable = batch ? drainOrientation(board, cars).departed : []; total = cars.length; traffic = []; resumeAfterTurn = []; idleFor = 0; delivered = 0; hover = selected = null; auto = false; turnAnimation = null;
cacheRoutes();
$('win').hidden = true;
$('level-label').textContent = batch ? batchTitles[scenario] : scenario === 3 ? '超大桌面关卡' : `关卡 ${scenario + 1}`;
document.querySelectorAll('.level-progress b').forEach((b,i) => b.classList.toggle('current', i === scenario));
const solution = solve(board), multi = solution.paths.filter(p => p.bends >= 2).length;
stats = { mean: (solution.paths.reduce((sum,p) => sum+p.bends,0)/total).toFixed(1), multi, max: Math.max(...solution.paths.map(p => p.bends)), solvable: solution.solvable };
$('stats').textContent = `参考路线:平均 ${stats.mean} 弯,最长 ${stats.max} 弯;${multi}/${total} 辆经过多弯。路网 #${board.seed}。`;
$('board-label').textContent = kind === 'square' ? `${large ? 15 : 7} × ${large ? 15 : 7} · 四向道路` : `${board.cells.length} 格 · 六向道路`;
document.querySelectorAll('[data-kind]').forEach(b => { b.classList.toggle('active', b.dataset.kind === kind); b.setAttribute('aria-pressed', String(b.dataset.kind === kind)); });
say(batch ? '点击中间发车;没有可通行车辆时,由你转动棋盘,再继续发车。' : '点击立即开车;遇到前车或交叉冲突会刹停,路空后自动继续。'); listCars(); ui();
}
function fire(id) {
const car = cars.find(c => c.id === id); if (!car) return;
if (turnAnimation) { if (!resumeAfterTurn.includes(id)) resumeAfterTurn.push(id); return; }
// Occupancy never prevents departure: contact is checked against actual positions while driving.
const route = trace(board, car, []);
if (route.status !== 'exit') {
selected = id; say('这条路线是闭环,请转动底盘换路。'); return;
}
traffic.push({ ...templates.get(id), car, stage: 'board', stopped: false });
cars = cars.filter(c => c.id !== id); selected = hover = null;
say(mode === 'batch' ? '沿绿色提示路线发车中;本轮不会自动转动棋盘。' : '已经出发,可以继续点其他车;前方有车时会停下等候。');
listCars(); ui();
}
function turn(direction) {
if (turnAnimation || (mode === 'batch' && busy())) return;
if (mode === 'batch' && cars.length) { auto = false; turnCount++; $('batch-result').textContent = '已调整方向,点击发车'; }
// Re-seat cars still inside the rotating panel before changing its route geometry.
const returning = traffic.filter(t => ['board','waiting','joining'].includes(t.stage) && t.distance < t.boardLength + 27);
resumeAfterTurn = returning.map(t => t.car.id);
cars.push(...returning.map(t => t.car));
traffic = traffic.filter(t => !returning.includes(t));
if (!cars.length) return;
listCars();
turnAnimation = { from: board, direction, time: 0 };
selected = hover = null;
say(mode === 'batch' ? '道路转动中,转好后点击中间发车。' : `内部道路${direction > 0 ? '顺' : '逆'}时针转动 ${kind === 'square' ? 90 : 60}°;盘内行驶车辆归位,转好后重新出发。`); ui();
}
function startBatch() {
if (turnAnimation || busy() || !batchAvailable.length) return;
const ids = batchAvailable;
const candidates = ids.map(id => ({ ...templates.get(id), journey: journey(templates.get(id), perimeter, finishPath) }));
pendingDepartures = scheduleDepartures(candidates, {
now: batchClock, active: traffic, parked: cars.map(car => ({ id: car.id, ...carCenter(car) })),
clearance: scale() * .92 + 2, parkedClearance: scale() * .8 + 1,
});
batchAvailable = []; roundStarted = ids.length; auto = true; selected = hover = null;
tickBatch(0); listCars(); ui();
}
function tickBatch(dt) {
batchClock += dt * speed;
const starting = pendingDepartures.filter(t => t.start <= batchClock + 1e-8);
if (starting.length) {
const ids = new Set(starting.map(t => t.car.id));
cars = cars.filter(c => !ids.has(c.id));
pendingDepartures = pendingDepartures.filter(t => !ids.has(t.car.id));
traffic.push(...starting); listCars();
}
for (const actor of traffic) {
const elapsed = batchClock - actor.start;
actor.pose = journeyPosition(actor.journey, elapsed);
actor.distance = actor.pose.distance;
actor.stage = actor.distance < actor.journey.ringStart ? 'board'
: actor.distance < actor.journey.finishStart ? 'ring' : 'finish';
if (elapsed >= actor.journey.duration) { actor.stage = 'done'; delivered++; }
}
}
function hint() {
if (busy() || !cars.length) return;
const next = suggestion(board, cars);
if (!next) { say('没有可用路线,请换张路网。'); return; }
if (next.turn) { turn(Math.sign(next.turn)); return; }
selected = next.id;
const car = cars.find(c => c.id === next.id), route = trace(board, car, cars);
say(`已标出车 ${Number(car.id.slice(1)) + 1} 的路线 · ${route.bends} 处弯道。点击这辆车发车。`);
}
function drawCar(car, x, y, angle, glow = false) {
const size = scale() / 37 * 1.1;
const paint = carPaints[car.color % carPaints.length];
ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.scale(size, size);
if (glow) { ctx.beginPath(); ctx.arc(0, 0, 18, 0, TAU); ctx.fillStyle = '#ffcc0030'; ctx.fill(); ctx.strokeStyle = '#f5bd1d'; ctx.lineWidth = 1.4; ctx.stroke(); }
// Symmetric top-down silhouette: no offset colored slab beneath the tires.
// Rounded body and compact wheels still fit the model's .36-radius envelope.
for (const side of [-1, 1]) for (const axle of [-5.5, 5.5]) roundRect(axle - 2.5, side * 7 - 2, 5, 4, 1.5, '#354841');
roundRect(-11.5, -7, 23, 14, 4, paint.side);
roundRect(-11, -6.5, 22, 13, 3.5, paint.face);
poly([{x:-6,y:-5},{x:7,y:-5}], paint.light, 1.5);
roundRect(-5, -4.5, 11, 9, 2.5, paint.side);
roundRect(2, -4, 4, 8, 1.2, '#355578');
roundRect(-4, -4, 3, 8, 1, paint.light);
for (const sy of [-4, 3]) roundRect(10, sy, 1, 2, .5, '#fffbd6');
// High-contrast forward arrow stays legible on every body color.
ctx.beginPath();
[[-7,-2],[1,-2],[1,-5],[8,0],[1,5],[1,2],[-7,2]].forEach(([px,py], i) => i ? ctx.lineTo(px,py) : ctx.moveTo(px,py));
ctx.closePath(); ctx.fillStyle = '#F9FFFF'; ctx.fill(); ctx.strokeStyle = '#56637b'; ctx.lineWidth = .7; ctx.stroke();
ctx.restore();
}
function drawRing() {
const points = perimeter.points;
poly(points, theme.ringEdge, 48); poly(points, theme.ring, 38); poly(points, theme.ringMark, 1.3, [16, 24]);
for (let i = 0; i < 12; i++) {
const a = i / 12 * TAU, p = ring(a); ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.angle);
poly([{ x: -5, y: -4 }, { x: 0, y: 0 }, { x: -5, y: 4 }], theme.ringMark, 2); ctx.restore();
}
poly(finishPath.points, theme.ringEdge, 42); poly(finishPath.points, theme.ring, 32);
poly(finishPath.points, theme.ringMark, 1.5, [6, 9]);
text('ONE WAY / 顺时针', CX, H - 28, 11, '#8e97a6');
}
function drawRamps() {
if (kind === 'hex') return;
// Fixed exterior ramps cover every boundary port in both rotationally symmetric grids.
const occupied = new Set(board.cells.map(c => `${c.x},${c.y}`));
for (const cell of board.cells) directions(kind).forEach(([dx, dy], d) => {
if (occupied.has(`${cell.x + dx},${cell.y + dy}`)) return;
const c = center(kind, cell.x, cell.y), p = port(kind, d);
const route = { points: [c, { x: c.x + p.x, y: c.y + p.y }], exitDir: d };
const ramp = departure(route).connector;
poly(ramp, theme.ringEdge, 12); poly(ramp, theme.roadEdge, 8);
});
}
function clipHexRoads() {
// Keep road strokes and round caps inside the actual outer tile boundary.
if (kind !== 'hex') return;
ctx.beginPath();
for (const cell of board.cells) {
const c = screen(center(kind, cell.x, cell.y)), radius = scale() / Math.sqrt(3);
for (let i = 0; i < 6; i++) {
const x = c.x + Math.cos(i * TAU / 6) * radius;
const y = c.y + Math.sin(i * TAU / 6) * radius;
if (i) ctx.lineTo(x,y); else ctx.moveTo(x,y);
}
ctx.closePath();
}
ctx.clip();
}
function drawBoard() {
ctx.save();
// The rotating road layer remains underneath the fixed, close-fitting frame.
ctx.beginPath();
perimeter.points.forEach((p, i) => {
const x = CX + (p.x - CX) * (scenario === 3 ? .96 : .9), y = CY + (p.y - CY) * (scenario === 3 ? .96 : .9);
if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y);
});
ctx.closePath(); ctx.clip();
if (turnAnimation) {
const t = Math.min(1, turnAnimation.time / .45), ease = t * t * (3 - 2 * t);
ctx.translate(CX, CY); ctx.rotate(ease * turnAnimation.direction * TAU / directions(kind).length); ctx.translate(-CX, -CY);
}
const s = scale();
for (const cell of board.cells) {
const c = screen(center(kind, cell.x, cell.y));
if (kind === 'square') roundRect(c.x - s / 2 + .25, c.y - s / 2 + .25, s - .5, s - .5, 2, theme.board, theme.boardEdge);
else {
const points = Array.from({ length: 7 }, (_, i) => ({ x: c.x + Math.cos(i * TAU / 6) * (s / Math.sqrt(3) - .15), y: c.y + Math.sin(i * TAU / 6) * (s / Math.sqrt(3) - .15) }));
ctx.beginPath(); points.forEach((p, i) => i ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)); ctx.fillStyle = theme.board; ctx.fill(); ctx.strokeStyle = theme.boardEdge; ctx.lineWidth = 1; ctx.stroke();
}
}
const tracks = [];
for (const cell of board.cells) cell.tracks.forEach((b, a) => {
if (a < b) tracks.push(curve(kind, cell, a, b).map(screen));
});
clipHexRoads();
// Draw each paired lane with its own outline. At crossings, the small overpass
// edge keeps the two connections visually separate rather than a four-way junction.
for (const points of tracks) {
poly(points, theme.roadEdge, 10); poly(points, theme.road, 8);
}
ctx.restore();
}
function drawPreview() {
if (turnAnimation) return;
const id = hover || selected, car = cars.find(c => c.id === id); if (!car) return;
const route = trace(board, car, cars);
const color = mode === 'batch' || route.status === 'exit' ? '#329378' : '#cc725d';
const points = route.points.map(screen);
ctx.save(); clipHexRoads();
poly(points, `${color}25`, 17); poly(points, color, 4);
ctx.restore();
if (route.status === 'exit' && kind !== 'hex') poly(departure(route).connector, '#32937880', 3, [5, 7]);
const p = points.at(-1); ctx.beginPath(); ctx.arc(p.x, p.y, 5, 0, TAU); ctx.fillStyle = color; ctx.fill();
if (route.blocker) {
const c = carCenter(cars.find(c => c.id === route.blocker)); ctx.beginPath(); ctx.arc(c.x, c.y, 21, 0, TAU); ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.stroke();
}
}
function drawActiveRoutes() {
if (mode !== 'batch' || turnAnimation) return;
for (const actor of traffic) {
if (actor.stage !== 'board' || actor.distance >= actor.boardLength) continue;
const color = '#329378';
const points = actor.path.points.filter((p, i) => actor.path.lengths[i] <= actor.boardLength);
ctx.save(); clipHexRoads();
// Match the hover guide: a soft halo and one solid line, without a moving tail.
poly(points, `${color}25`, 17); poly(points, color, 4);
const end = points.at(-1);
ctx.beginPath(); ctx.arc(end.x, end.y, 5, 0, TAU); ctx.fillStyle = color; ctx.fill();
ctx.restore();
}
}
function drawTerminal() {
const end = finishPath.points.at(-1), x = end.x - 33, y = end.y - 32;
roundRect(x, y, 66, 49, 9, '#4787ED');
roundRect(x + 8, y + 19, 50, 30, 4, '#2456a7');
for (let i = 0; i < 6; i++) for (let j = 0; j < 2; j++) roundRect(x + 9 + i * 8, y + 19 + j * 7, 8, 7, 0, (i + j) % 2 ? '#f9ffff' : '#3273d1');
text('终 点', end.x, y - 9, 16, '#4787ED');
text('GARAGE', end.x, y + 13, 8, '#ffffff');
}
function draw(now) {
ctx.clearRect(0, 0, W, H);
if (turnAnimation) { drawRamps(); drawRing(); drawBoard(); }
else {
if (!backdrop) {
backdrop = document.createElement('canvas'); backdrop.width = W; backdrop.height = H;
const main = ctx; ctx = backdrop.getContext('2d');
drawRamps(); drawRing(); drawBoard(); ctx = main;
}
ctx.drawImage(backdrop, 0, 0);
}
drawPreview();
drawActiveRoutes();
for (const car of cars) {
const c = carCenter(car), angle = -Math.PI / 2 + car.dir * TAU / directions(kind).length;
drawCar(car, c.x, c.y, angle, car.id === selected || car.id === hover);
const pending = pendingDepartures.find(t => t.car.id === car.id);
if (pending) {
const countdown = Math.max(0, (pending.start - batchClock) / speed).toFixed(1);
roundRect(c.x - 20, c.y - 37, 40, 17, 6, '#F9FFFFe8');
text(`${countdown}s`, c.x, c.y - 25, 11, '#4882B3');
}
}
for (const t of traffic) {
const p = t.pose || (t.stage === 'ring' ? ring(t.angle)
: at(t.stage === 'finish' ? finishPath : t.path, t.distance));
drawCar(t.car, p.x, p.y, p.angle);
if (t.stopped) text(t.retreating ? '↶' : 'Ⅱ', p.x, p.y - 29, 14, '#b96549');
}
drawTerminal();
}
function tickFree(dt) {
const reversed = resolveGridlock(traffic, cars.map(carCenter), t => t.stage === 'ring' ? ring(t.angle) : at(t.stage === 'finish' ? finishPath : t.path, t.distance), scale() * .72 + 1, dt * 140 * speed);
const lane = traffic.filter(t => t.stage === 'ring' || t.stage === 'joining');
for (const t of traffic) {
if (t.stage === 'board') {
if (reversed.has(t)) continue;
const obstacles = cars.map(carCenter);
// Parked cars occupy their bodies, never roads they might use later.
const corridors = activeCorridors(traffic, t);
for (const other of traffic) {
if (other === t || other.stage === 'done') continue;
obstacles.push(other.stage === 'ring' ? ring(other.angle) : at(other.stage === 'finish' ? finishPath : other.path, other.distance));
}
const target = Math.min(t.path.length - 34, t.distance + dt * 240 * speed);
const next = advance(t.path, t.distance, target, obstacles, corridors, scale() * .72 + 1);
t.stopped = next < target - .1;
t.blockedFor = t.stopped ? (t.blockedFor || 0) + dt : 0;
t.distance = next;
if (t.distance >= t.path.length - 34 - .01) { t.stage = 'waiting'; t.stopped = false; }
} else if (t.stage === 'waiting') {
const clear = lane.every(o => Math.min(mod(o.angle - t.entry), mod(t.entry - o.angle)) * perimeter.unit > 98);
if (clear) { t.stage = 'joining'; t.angle = t.entry; lane.push(t); }
} else if (t.stage === 'joining') {
t.distance += dt * 180 * speed;
if (t.distance >= t.path.length) { t.stage = 'ring'; t.angle = t.entry; t.remaining = mod(FINISH - t.entry) * perimeter.unit; }
} else if (t.stage === 'ring') {
let advance = dt * 180 * speed;
for (const other of lane) if (other !== t) advance = Math.min(advance, Math.max(0, mod(other.angle - t.angle) * perimeter.unit - 80));
advance = Math.min(advance, t.remaining); t.angle += advance / perimeter.unit; t.remaining -= advance;
if (t.remaining <= .01) { t.stage = 'finish'; t.distance = 0; }
} else if (t.stage === 'finish') {
t.distance += dt * 180 * speed;
if (t.distance >= finishPath.length) { t.stage = 'done'; delivered++; }
}
}
}
function tick(dt, now) {
const before = traffic.filter(t => t.stage === 'board').reduce((sum,t) => sum+t.distance,0);
if (turnAnimation) {
turnAnimation.time += dt;
if (turnAnimation.time >= .45) {
board = rotate(board, turnAnimation.direction); backdrop = null; cacheRoutes(); turnAnimation = null;
const ids = resumeAfterTurn; resumeAfterTurn = []; ids.forEach(fire);
if (mode === 'batch') batchAvailable = drainOrientation(board, cars).departed;
}
}
if (mode === 'batch') tickBatch(dt); else tickFree(dt);
const progressed = traffic.filter(t => t.stage === 'board').reduce((sum,t) => sum+t.distance,0) > before + .1;
idleFor = progressed ? 0 : idleFor + dt;
traffic = traffic.filter(t => t.stage !== 'done');
if (delivered === total && traffic.length === 0) { say('全部抵达!'); $('win-count').textContent = mode === 'batch' ? `${total} 辆全部抵达 · 转动 ${turnCount} 次 / 目标 ${turnGoal} 次` : `${total} 辆小车,全部到齐`; $('win').hidden = false; auto = false; }
// Recover a pre-existing manual pile-up even after every car has been clicked.
// Re-seat only internal vehicles; delivered cars and perimeter traffic continue.
if (mode === 'free' && auto && !turnAnimation && busy() && idleFor > 3) {
const returning = traffic.filter(t => ['board', 'waiting', 'joining'].includes(t.stage) && t.distance < t.boardLength + 27);
if (returning.length) {
cars.push(...returning.map(t => t.car));
traffic = traffic.filter(t => !returning.includes(t));
idleFor = 0; listCars();
say('检测到盘内拥堵,车辆归位后按可通行顺序继续演示。');
}
}
if (mode === 'batch' && auto && !busy()) auto = false;
// Free-play demonstration keeps its automatic rotation and sequential dispatch.
if (mode === 'free' && auto && !turnAnimation && !busy() && now - lastAuto > 220) {
const next = suggestion(board, cars); lastAuto = now;
if (!next) { if (!traffic.length) auto = false; }
else if (next.turn) turn(Math.sign(next.turn));
else fire(next.id);
}
ui();
}
canvas.addEventListener('pointermove', e => {
if (!$('preview').checked || e.pointerType === 'touch') return;
const rect = canvas.getBoundingClientRect(), x = (e.clientX - rect.left) * W / rect.width, y = (e.clientY - rect.top) * H / rect.height;
hover = cars.find(car => { const c = carCenter(car); return Math.hypot(x - c.x, y - c.y) < scale() * .39; })?.id || null;
canvas.style.cursor = hover ? 'pointer' : 'default';
});
canvas.addEventListener('pointerleave', () => { hover = null; });
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect(), x = (e.clientX - rect.left) * W / rect.width, y = (e.clientY - rect.top) * H / rect.height;
const closest = cars.map(car => ({ car, distance: Math.hypot(x-carCenter(car).x,y-carCenter(car).y) })).sort((a,b) => a.distance-b.distance)[0];
const car = closest && closest.distance < Math.max(scale()*.42, 22*W/rect.width) ? closest.car : null;
if (car) { if (mode === 'batch') { selected = car.id; say('已标出这辆车的路线;点击中间按钮统一发车。'); } else fire(car.id); }
});
$('left').onclick = () => turn(-1); $('right').onclick = () => turn(1); $('hint').onclick = () => mode === 'batch' ? startBatch() : hint();
$('reset').onclick = $('restart').onclick = $('play-again').onclick = reset; $('new').onclick = () => { if (mode === 'batch') scenario = (scenario + 1) % batchTitles.length; else variation++; reset(); };
$('mode').onchange = e => { mode = e.target.value; scenario = mode === 'batch' ? 0 : 1; variation = 0; reset(); };
$('scenario').onchange = e => { scenario = Number(e.target.value); variation = 0; reset(); };
$('speed').oninput = e => { speed = Number(e.target.value); $('speed-label').textContent = `${speed}×`; };
$('preview').onchange = () => { hover = null; };
$('auto').onclick = () => { auto = !auto && (cars.length > 0 || traffic.length > 0); ui(); };
document.querySelectorAll('[data-kind]').forEach(b => b.onclick = () => { kind = b.dataset.kind; reset(); });
reset();
requestAnimationFrame(function frame(now) { const dt = Math.min(.05, (now - last) / 1000); last = now; tick(dt, now); draw(now); requestAnimationFrame(frame); });
+39
View File
@@ -0,0 +1,39 @@
import { rotate, trace, directions } from './model.js';
// A run only removes cars reachable in the current orientation. It never turns.
export function currentDeparture(board, cars) {
return cars.find(car => trace(board, car, cars).status === 'exit') || null;
}
export function drainOrientation(board, cars = board.cars) {
let remaining = [...cars];
const departed = [];
for (let car; (car = currentDeparture(board, remaining));) {
departed.push(car.id); remaining = remaining.filter(c => c.id !== car.id);
}
return { remaining, departed };
}
// Every edge is one player turn followed by a run. Search both directions to
// measure actual player effort, rather than counting rotations in a car solver.
export function rotationPlan(board, cars = board.cars) {
const n = directions(board.kind).length;
const initial = drainOrientation(board, cars);
const queue = [{ orientation: 0, remaining: initial.remaining, turns: [], waves: [initial.departed.length] }];
const visited = new Set();
for (let head = 0; head < queue.length; head++) {
const state = queue[head];
if (!state.remaining.length) return state;
for (const turn of [-1, 1]) {
const orientation = (state.orientation + turn + n) % n;
const next = drainOrientation(rotate(board, orientation), state.remaining);
const key = `${orientation}:${next.remaining.map(c => c.id).sort().join(',')}`;
if (visited.has(key)) continue;
visited.add(key);
queue.push({ orientation, remaining: next.remaining, turns: [...state.turns, turn], waves: [...state.waves, next.departed.length] });
}
}
return null;
}
export const batchTitles = ['转向开路', '折返放行', '连续调度'];
export const batchSeeds = { hex: [3, 14, 8], square: [6, 12, 19] };
+15
View File
@@ -0,0 +1,15 @@
# Car Loop · Video Lab v2 适配
本次采用只读参考 `../hex-video/designv2.md`(2026-09-09),将其视觉体系用于现有环路车场。没有修改参考 Lab。
- 解谜:背景 `#F2F2F4`,顶部灰色过渡;底格 `#A6AAB9`,HUD `#B2B4B8`,计数白底 `#F7F9FD` / 深灰数字 `#62635E`。
- 车辆:`theme.js` 定义七个明确色族,面色、薄侧边和窄高光分开;方向独立于颜色,固定粗白箭头,暗边增强浅色车身上的可读性。
- 道路:浅冷灰路线与较暗边缘;固定成对通行,交叉显示连续线,不增加可选岔路。道路配色属于此 Lab 适配,并非视频取样对象。
- 控件:圆形薄倒角底座、独立小标签;红橙旋转轮、浅蓝提示灯;侧栏绿色切角演示按钮、车速滑杆。
- 完成:蓝青背景与浅青放射光,金黄完成标记和再玩按钮。展示真实到达车数,不伪造金币奖励。
- 布局:桌面左侧实验控件、右侧竖向游戏预览;窄屏将棋盘类型、关卡、车速和演示收拢在棋盘上方。游戏内 HUD 与实际状态对应。
- 操作:点击马上沿路线行驶,接近前车和冲突区域时等待,不在起点排发车队列。停等用小暂停标记表示,不给整车叠大面积光晕。
- 速度:默认 2×,盘内 480 / 环路 360 画布像素每秒;滑杆 1–3×。细分检测步长不随车速放大。
- 减少动态效果:CSS 关闭领奖光束旋转;车辆路线运动保留以解释碰撞与方向。
- 六角外缘:不绘制伸向环路的引道线,轨道笔画裁剪到实际底格边界,避免圆端点伸出;路线预览也不画外接虚线。车辆并线轨迹保持连续。
+50
View File
@@ -0,0 +1,50 @@
<!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>
<header class="lab-header"><a href="/">HEX TRAILS <span>/ CAR LAB</span></a><a href="../hex-video/designv2.html">Video Lab Design v2 ↗</a></header>
<main class="workspace">
<aside class="inspector">
<p class="eyebrow">PLAY EXPERIMENT / 02</p><h1>环路车场</h1><p class="intro">点一下,即刻出发。<br>解开弯路,让小车一路开到终点。</p>
<label class="section-title" for="mode">玩法</label><select id="mode" aria-label="玩法"><option value="free">自由点车</option><option value="batch">转盘发车 · 新玩法</option></select>
<p class="section-title">棋盘形状</p>
<div class="switch" aria-label="棋盘类型"><button data-kind="square" class="active" aria-pressed="true"><span class="shape square"></span>方格路网</button><button data-kind="hex" aria-pressed="false"><span class="shape hex"></span>六角路网</button></div>
<div class="settings-body">
<label class="section-title" for="scenario">关卡样本</label><select id="scenario"><option value="0">01 · 连弯初见 / 10 辆</option><option value="1" selected>02 · 交织长路 / 16 辆</option><option value="2">03 · 满盘绕行 / 22 辆</option><option value="3">04 · 超大桌面 / 96 辆</option></select>
<div class="actions"><button id="new">换张路网</button><button id="restart">重新开始</button></div>
<button class="primary" id="auto" aria-pressed="false">▶ 自动演示</button>
<label class="speed-control" for="speed"><span>行驶速度 <output id="speed-label">2×</output></span><input id="speed" type="range" min="1" max="3" step="0.5" value="2" aria-label="行驶速度倍率"></label>
<label class="check"><input type="checkbox" id="preview" checked> 悬停查看完整路线</label>
<div id="batch-help" hidden><strong>转盘 → 发车 → 再转盘</strong><p>点击发车,本轮车辆自动错峰,互不冲突的车一起走。倒计时结束后出发,途中连续通行。</p><p>绿色提示线标出盘内路线,驶入外环后收起。看到“需转盘”时,由你决定转向。</p></div>
<details class="guide"><summary>怎么玩</summary><p>点车立即出发,多辆车可以同时行驶。遇到前车或交叉冲突时会停下,路空后自行继续。</p><p>弯道自动转向,交叉处不换线。转动底盘时,盘内行驶车辆先归位,换路后再出发。</p><p>车从边缘汇入同形环路,顺时针开到右上角终点。闭环路线需要先换路。</p></details>
<details class="car-access"><summary>车辆操作列表</summary><p>也可以用键盘选择车辆,立即发车。</p><div id="car-list"></div></details>
<div class="footnote"><span class="live-dot"></span> 独立玩法实验 · 本地运行<p id="stats"></p></div>
</div>
</aside>
<section class="preview-area" aria-label="环路车场游戏">
<div class="preview-label"><span>PLAY VIEW</span><span id="board-label">7 × 7 · 四向道路</span></div>
<div class="stage">
<div class="ice" aria-hidden="true"></div>
<div class="game-hud">
<div class="level-progress"><span id="level-label">关卡 2</span><div aria-hidden="true"><b>1</b><i></i><b class="current">2</b><i></i><b>3</b><i></i><b>4</b></div></div>
<div class="remaining"><span>待出发</span><strong id="remaining">16</strong></div>
<button class="reset-icon" id="reset" aria-label="重新开始本关"><svg viewBox="0 0 32 32" aria-hidden="true"><path d="M25 13A10 10 0 1 0 26 20M25 5v8h-8" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
</div>
<div class="trip-hud"><span><i class="travel-dot"></i>行驶中 <strong id="transit">0</strong></span><span>已抵达 <strong id="arrived">0</strong> <span class="arrival-mark">✓</span></span></div>
<div class="batch-hud" id="batch-hud" hidden><span id="turn-count">已转 0 次</span><span id="turn-goal"></span><span id="batch-result">点击发车开始</span></div>
<div class="meter" aria-hidden="true"><i id="meter"></i></div>
<div class="board-scroll"><canvas id="game" width="768" height="740" aria-label="交互车场,点击车辆立即发车。也可用车辆操作列表。"></canvas></div>
<div class="status" id="status" role="status" aria-live="polite">点击出发,遇车等候,路空继续。</div>
<div class="controls">
<button id="left" aria-label="逆时针旋转内部道路"><svg viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="24" fill="#747B87"/><circle cx="30" cy="29" r="18" fill="#F56B37" stroke="#BC451E" stroke-width="3"/><path d="M20 21h10a10 10 0 1 1-10 10M25 16l-5 5 5 5" fill="none" stroke="#FFF1D5" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg><small>左转底盘</small></button>
<button id="hint" aria-label="查看路线提示"><svg id="hint-icon" viewBox="0 0 60 60" aria-hidden="true"><path d="M21 38c0-6-7-8-7-17a16 16 0 0 1 32 0c0 9-7 11-7 17" fill="#64BDF8" stroke="#438BD0" stroke-width="3"/><path d="M23 39h14v6H23zM26 49h8" fill="#B0BDCD" stroke="#738498" stroke-width="3" stroke-linecap="round"/><path d="M25 29l5 6 5-6" fill="none" stroke="white" stroke-width="3" stroke-linecap="round"/></svg><svg id="run-icon" viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="23" fill="#378EE9" stroke="#2468B0" stroke-width="3"/><path id="run-symbol" d="M24 17L43 30 24 43Z" fill="#fff"/></svg><small id="hint-label">路线提示</small></button>
<button id="right" aria-label="顺时针旋转内部道路"><svg viewBox="0 0 60 60" aria-hidden="true"><circle cx="30" cy="30" r="24" fill="#747B87"/><circle cx="30" cy="29" r="18" fill="#F56B37" stroke="#BC451E" stroke-width="3"/><path d="M40 21H30a10 10 0 1 0 10 10M35 16l5 5-5 5" fill="none" stroke="#FFF1D5" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/></svg><small>右转底盘</small></button>
</div>
<section class="win" id="win" hidden aria-label="全部车辆已抵达">
<div class="sunburst" aria-hidden="true"></div><h2>全部抵达!</h2><p>弯弯绕绕,顺利到站</p><div class="win-medal" aria-hidden="true">✓</div><strong id="win-count">16 辆小车,全部到齐</strong><button id="play-again" class="gold-button">再玩一局</button>
</section>
</div>
<div class="preview-footer"><span>点击即走 · 碰撞等候 · 顺时针归途</span><span>DESIGN V2</span></div>
</section>
</main><script type="module" src="./app.js"></script>
</body></html>
File diff suppressed because one or more lines are too long
+157
View File
@@ -0,0 +1,157 @@
import { largeLevels } from './large-levels.js';
// Isolated experimental model. Cars occupy a small circular clearance envelope.
// The same sampled curves drive drawing, travel and collision checking.
export const key = (x, y) => `${x},${y}`;
export const directions = kind => kind === 'square'
? [[0, -1], [1, 0], [0, 1], [-1, 0]]
: [[0, -1], [1, -1], [1, 0], [0, 1], [-1, 1], [-1, 0]];
export function center(kind, x, y) {
return kind === 'square' ? { x, y } : { x: x * Math.sqrt(3) / 2, y: y + x / 2 };
}
export function port(kind, d) {
const angle = -Math.PI / 2 + d * Math.PI * 2 / directions(kind).length;
return { x: Math.cos(angle) / 2, y: Math.sin(angle) / 2 };
}
export function curve(kind, cell, a, b) {
const c = center(kind, cell.x, cell.y), p = port(kind, a), q = port(kind, b);
return Array.from({ length: 19 }, (_, i) => {
const t = i / 18, u = 1 - t;
return {
x: c.x + u ** 3 * p.x + 3 * u * u * t * p.x * .35 + 3 * u * t * t * q.x * .35 + t ** 3 * q.x,
y: c.y + u ** 3 * p.y + 3 * u * u * t * p.y * .35 + 3 * u * t * t * q.y * .35 + t ** 3 * q.y,
};
});
}
export function rotate(board, turns = 1) {
const n = directions(board.kind).length, count = ((turns % n) + n) % n;
let cells = board.cells;
for (let i = 0; i < count; i++) cells = cells.map(c => {
const [x, y] = board.kind === 'square' ? [-c.y, c.x] : [-c.y, c.x + c.y];
const tracks = [];
c.tracks.forEach((b, a) => { tracks[(a + 1) % n] = (b + 1) % n; });
return { x: x || 0, y: y || 0, tracks };
});
return { ...board, cells };
}
function segmentHit(a, b, c, radius) {
const dx = b.x - a.x, dy = b.y - a.y, length2 = dx * dx + dy * dy;
if (!length2) return null;
const px = a.x - c.x, py = a.y - c.y;
const v = px * dx + py * dy, w = px * px + py * py - radius * radius;
if (w <= 0) return 0;
const disc = v * v - length2 * w;
if (disc < 0) return null;
const t = (-v - Math.sqrt(disc)) / length2;
return t >= 0 && t <= 1 ? t : null;
}
export function trace(board, car, cars = board.cars) {
const dirs = directions(board.kind), n = dirs.length;
const cells = new Map(board.cells.map(c => [key(c.x, c.y), c]));
let x = car.x, y = car.y, d = car.dir, bends = 0;
const start = center(board.kind, x, y), p = port(board.kind, d);
let points = [start, { x: start.x + p.x, y: start.y + p.y }];
const seen = new Set();
let status = 'loop';
for (let step = 0; step <= board.cells.length * n; step++) {
const [dx, dy] = dirs[d]; x += dx; y += dy;
const cell = cells.get(key(x, y));
if (!cell) { status = 'exit'; break; }
const a = (d + n / 2) % n, b = cell.tracks[a];
const signature = `${x},${y},${a}`;
if (seen.has(signature)) break;
seen.add(signature);
if (b !== d) bends++;
points.push(...curve(board.kind, cell, a, b)); d = b;
}
// Short vehicles fit inside radius .36; two cars require .72 clearance.
for (let i = 1; i < points.length; i++) {
let hit = null;
for (const other of cars) {
if (other.id === car.id) continue;
const t = segmentHit(points[i - 1], points[i], center(board.kind, other.x, other.y), .72);
if (t !== null && (!hit || t < hit.t)) hit = { t, id: other.id };
}
if (hit) {
const a = points[i - 1], b = points[i];
points = [...points.slice(0, i), { x: a.x + (b.x - a.x) * hit.t, y: a.y + (b.y - a.y) * hit.t }];
return { status: 'blocked', points, blocker: hit.id, bends, steps: seen.size };
}
}
return { status, points, bends, steps: seen.size, exitDir: d };
}
function rng(seed) {
return () => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed / 4294967296; };
}
export function generate(kind, seed = 71, target = 14, radius = 3) {
const random = rng(seed), n = directions(kind).length, cells = [];
for (let x = -radius; x <= radius; x++) for (let y = -radius; y <= radius; y++) {
if (kind === 'hex' && Math.abs(x + y) > radius) continue;
const ports = Array.from({ length: n }, (_, i) => i), tracks = [];
while (ports.length) {
const a = ports.shift(), b = ports.splice(Math.floor(random() * ports.length), 1)[0];
tracks[a] = b; tracks[b] = a;
}
cells.push({ x, y, tracks });
}
const board = { kind, cells, cars: [], seed, radius }, boards = Array.from({ length: n }, (_, i) => rotate(board, i));
// Reverse insertion across orientations guarantees a solution, without forbidding loops
// in other orientations. Bias towards real, multi-bend routes rather than decoration.
for (let i = 0; i < target; i++) {
const occupied = new Set(board.cars.map(c => key(c.x, c.y))), candidates = [];
for (const cell of cells) {
if (occupied.has(key(cell.x, cell.y))) continue;
for (let dir = 0; dir < n; dir++) {
const car = { id: `c${i}`, x: cell.x, y: cell.y, dir, color: i % 7 };
const orientation = Math.floor(random() * n);
const route = trace(boards[orientation], car, board.cars);
if (route.status === 'exit' && route.steps <= (radius > 3 ? 80 : 24)) {
candidates.push({ car, weight: Math.min(route.bends, 8) * 2 + Math.min(route.steps, 14) * .3 + random() * 4 });
}
}
}
if (!candidates.length) break;
candidates.sort((a, b) => b.weight - a.weight);
board.cars.push(candidates[0].car);
}
return board;
}
export function suggestion(board, cars) {
const n = directions(board.kind).length;
for (const turn of [0, 1, -1, 2, -2, 3].slice(0, n)) {
const candidate = rotate(board, turn);
for (const car of cars) if (trace(candidate, car, cars).status === 'exit') return { turn, id: car.id };
}
return null;
}
export function solve(board) {
let state = board, cars = [...board.cars];
const actions = [], paths = [];
while (cars.length) {
const next = suggestion(state, cars);
if (!next) return { solvable: false, actions, paths };
if (next.turn) { state = rotate(state, next.turn); actions.push({ turn: next.turn }); }
const car = cars.find(c => c.id === next.id);
paths.push(trace(state, car, cars)); actions.push({ id: car.id });
cars = cars.filter(c => c.id !== car.id);
}
return { solvable: true, actions, paths };
}
export function generateSample(kind, scenario = 1, variation = 0) {
if (scenario === 3) return rotate(structuredClone(largeLevels[kind]), variation % directions(kind).length);
const target = [10, 16, 22][scenario];
const base = (kind === 'square' ? [59, 107, 107] : [71, 233, 907])[scenario];
if (kind !== 'square' || variation === 0) return generate(kind, base + variation * 37, target);
let best = null, score = -Infinity;
// Screen real solution travel rather than counting decorative road curves.
for (let i = 0; i < 16; i++) {
const candidate = generate(kind, base + variation * 37 + i * 17, target);
const solution = solve(candidate);
const mean = solution.paths.reduce((sum,p) => sum+p.bends,0) / target;
const maximum = Math.max(...solution.paths.map(p => p.bends));
if (!solution.solvable || candidate.cars.length !== target || mean < 6 || maximum < 12) continue;
const value = mean + solution.paths.filter(p => p.bends >= 5).length / target;
if (value > score) { score = value; best = candidate; }
}
return best || generate(kind, 107, target);
}
+115
View File
@@ -0,0 +1,115 @@
// Shared distance-based trajectories and local traffic clearance, in canvas pixels.
export function measure(points) {
const lengths = [0];
for (let i = 1; i < points.length; i++) lengths.push(lengths[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y));
return { points, lengths, length: lengths.at(-1) };
}
export function at(path, distance) {
const d = Math.max(0, Math.min(path.length, distance));
let i = path.lengths.findIndex((v, index) => index && v >= d);
if (i < 1) i = path.points.length - 1;
const a = path.points[i - 1], b = path.points[i], len = path.lengths[i] - path.lengths[i - 1];
const t = len ? (d - path.lengths[i - 1]) / len : 0;
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, angle: Math.atan2(b.y - a.y, b.x - a.x) };
}
export function pointSegmentDistance(p, a, b) {
const dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy;
const t = length ? Math.max(0, Math.min(1, ((p.x-a.x)*dx+(p.y-a.y)*dy)/length)) : 0;
return Math.hypot(p.x-a.x-dx*t,p.y-a.y-dy*t);
}
export function hasPriority(a, b) {
if (a.yieldingTo === b) return false;
if (b.yieldingTo === a) return true;
return a.order < b.order;
}
// A car reserves road conflicts only after departure. Parked cars are physical
// obstacles, not reservations for every road they may eventually travel through.
export function activeCorridors(traffic, actor) {
return traffic.filter(other => other !== actor
&& ['board', 'waiting', 'joining'].includes(other.stage)
&& other.distance < other.boardLength && hasPriority(other, actor));
}
// Only the untraveled part of a priority vehicle's route reserves a conflict area.
// Other cars travel up to that area, wait there, and resume as it clears.
export function advance(path, current, target, obstacles, corridors, clearance) {
let distance = current;
while (distance < target - 1e-6) {
const next = Math.min(target, distance + 1.5), p = at(path, next);
if (obstacles.some(o => Math.hypot(p.x-o.x,p.y-o.y) < clearance)) break;
let conflict = false;
for (const corridor of corridors) {
let a = at(corridor.path, corridor.distance);
for (let i = 1; i < corridor.path.points.length; i++) {
if (corridor.path.lengths[i] <= corridor.distance) continue;
if (corridor.path.lengths[i-1] > corridor.boardLength + clearance) break;
const b = corridor.path.points[i];
if (pointSegmentDistance(p,a,b) < clearance) { conflict = true; break; }
a = b;
}
if (conflict) break;
}
if (conflict) break;
distance = next;
}
return distance;
}
// Stable passage priority respects the parked-car dependency graph, avoiding
// circular reservations when several intersecting cars are clicked together.
export function passageOrder(board, cars, trace) {
let remaining = [...cars], rank = 0;
const order = new Map();
while (remaining.length) {
const free = remaining.filter(car => trace(board, car, remaining).status === 'exit');
if (!free.length) { remaining.forEach(car => order.set(car.id, rank++)); break; }
const ids = new Set(free.map(car => car.id));
free.forEach(car => order.set(car.id, rank++));
remaining = remaining.filter(car => !ids.has(car.id));
}
return order;
}
// Back out of a mutual standstill, using the same swept body clearance as forward travel.
// The yielding car keeps its priority override until the other has left the board.
export function resolveGridlock(traffic, fixedObstacles, positionOf, clearance, step) {
const reversed = new Set();
const internal = traffic.filter(t => t.stage === 'board');
for (const actor of internal) {
if (actor.yieldingTo && !internal.includes(actor.yieldingTo)) {
actor.yieldingTo = null; actor.retreating = false;
}
}
const retreat = actor => {
const obstacles = [...fixedObstacles, ...traffic.filter(o => o !== actor && o.stage !== 'done').map(positionOf)];
let distance = actor.distance;
const target = Math.max(0, distance - step);
while (distance > target + 1e-6) {
const next = Math.max(target, distance - 1.5), p = at(actor.path, next);
if (obstacles.some(o => Math.hypot(p.x - o.x, p.y - o.y) < clearance)) break;
distance = next;
}
return distance;
};
for (let i = 0; i < internal.length; i++) for (let j = i + 1; j < internal.length; j++) {
const a = internal[i], b = internal[j];
if (a.yieldingTo || b.yieldingTo || !a.stopped || !b.stopped || Math.min(a.blockedFor || 0, b.blockedFor || 0) < .5) continue;
const p = positionOf(a), q = positionOf(b);
if (Math.hypot(p.x - q.x, p.y - q.y) > clearance * 1.6) continue;
const candidates = hasPriority(a, b) ? [b, a] : [a, b];
const loser = candidates.find(t => retreat(t) < t.distance - .01);
if (loser) { loser.yieldingTo = loser === a ? b : a; loser.retreating = true; }
}
for (const actor of internal) {
if (!actor.retreating || !actor.yieldingTo) continue;
// A short probe tests whether the whole body has cleared the winning route.
const p = positionOf(actor), probe = measure([p, { x: p.x + .01, y: p.y }]);
if (advance(probe, 0, probe.length, [], [actor.yieldingTo], clearance + 3) >= probe.length) {
actor.retreating = false;
continue;
}
const next = retreat(actor);
actor.distance = next; actor.stopped = true; reversed.add(actor);
if (next <= 0) actor.retreating = false;
}
return reversed;
}
+37
View File
@@ -0,0 +1,37 @@
// Clockwise, rounded polygon lane; normalized parameters measure distance, not polar angle.
const TAU = Math.PI * 2;
export function createPerimeter(kind, cx, cy, size, radius = 3) {
const vertices = kind === 'square'
? [[1, -1], [1, 1], [-1, 1], [-1, -1]].map(([x, y]) => ({ x: cx + x * ((radius + .5) * size + 32), y: cy + y * ((radius + .5) * size + 32) }))
: Array.from({ length: 6 }, (_, i) => { const a = -Math.PI / 6 + i * TAU / 6; return { x: cx + Math.cos(a) * size * (radius + 1.12), y: cy + Math.sin(a) * size * (radius + 1.12) }; });
const points = [], inset = 15;
for (let i = 0; i < vertices.length; i++) {
const p = vertices[i], before = vertices[(i + vertices.length - 1) % vertices.length], after = vertices[(i + 1) % vertices.length];
const toward = q => { const d = Math.hypot(q.x - p.x, q.y - p.y); return { x: p.x + (q.x - p.x) * inset / d, y: p.y + (q.y - p.y) * inset / d }; };
const a = toward(before), b = toward(after);
for (let j = 0; j <= 16; j++) { const t = j / 16, u = 1 - t; points.push({ x: u * u * a.x + 2 * u * t * p.x + t * t * b.x, y: u * u * a.y + 2 * u * t * p.y + t * t * b.y }); }
}
points.push(points[0]);
const lengths = [0];
for (let i = 1; i < points.length; i++) lengths.push(lengths[i - 1] + Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y));
const length = lengths.at(-1), unit = length / TAU;
function at(parameter) {
const distance = ((parameter % TAU + TAU) % TAU) * unit;
let i = lengths.findIndex((v, j) => j > 0 && v >= distance); if (i < 1) i = 1;
const a = points[i - 1], b = points[i], t = (distance - lengths[i - 1]) / (lengths[i] - lengths[i - 1]);
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, angle: Math.atan2(b.y - a.y, b.x - a.x) };
}
function intersect(origin, direction) {
let nearest = null;
for (let i = 1; i < points.length; i++) {
const a = points[i - 1], b = points[i], sx = b.x - a.x, sy = b.y - a.y;
const cross = direction.x * sy - direction.y * sx; if (Math.abs(cross) < 1e-9) continue;
const dx = a.x - origin.x, dy = a.y - origin.y;
const ray = (dx * sy - dy * sx) / cross, t = (dx * direction.y - dy * direction.x) / cross;
if (ray >= 0 && t >= 0 && t <= 1 && (!nearest || ray < nearest.distance)) nearest = { distance: ray, parameter: (lengths[i - 1] + t * (lengths[i] - lengths[i - 1])) / unit };
}
if (!nearest) throw new Error('Exit ray must intersect perimeter');
return nearest;
}
return { points, length, unit, at, intersect };
}
+27
View File
@@ -0,0 +1,27 @@
import { port } from './model.js';
import { measure } from './motion.js';
function bezier(a, b, c, d) {
return Array.from({ length: 37 }, (_, i) => {
const t = i / 36, u = 1 - t;
return { x: u ** 3 * a.x + 3 * u * u * t * b.x + 3 * u * t * t * c.x + t ** 3 * d.x,
y: u ** 3 * a.y + 3 * u * u * t * b.y + 3 * u * t * t * c.y + t ** 3 * d.y };
});
}
export function departure(route, kind, screen, perimeter) {
const points = route.points.map(screen), end = points.at(-1), dir = port(kind, route.exitDir);
const direction = { x: dir.x * 2, y: dir.y * 2 };
const hit = perimeter.intersect(end, direction);
const a = hit.parameter + 26 / perimeter.unit;
const entry = perimeter.at(a), tangent = { x: Math.cos(entry.angle), y: Math.sin(entry.angle) };
const join = bezier(end, { x: end.x + direction.x * hit.distance * .65, y: end.y + direction.y * hit.distance * .65 },
{ x: entry.x - tangent.x * 18, y: entry.y - tangent.y * 18 }, entry);
return { path: measure([...points, ...join.slice(1)]), boardLength: measure(points).length, entry: a, connector: join };
}
export function garagePath(perimeter, width) {
const finish = perimeter.at(0);
return measure(bezier(finish,
{ x: finish.x + Math.cos(finish.angle) * 22, y: finish.y + Math.sin(finish.angle) * 22 },
{ x: width - 76, y: finish.y - 8 }, { x: width - 62, y: finish.y - 36 }));
}
+92
View File
@@ -0,0 +1,92 @@
import { measure, at, pointSegmentDistance } from './motion.js';
const TAU = Math.PI * 2;
const mod = value => (value % TAU + TAU) % TAU;
// One continuous trip includes the merge, perimeter and garage. Booking only
// the internal road would move the same queue to the perimeter entrance.
export function journey(departure, perimeter, finishPath) {
const lane = measure(perimeter.points);
const ringLength = mod(-departure.entry) * perimeter.unit;
const entryDistance = mod(departure.entry) * perimeter.unit;
const lanePoints = lane.points.slice(0, -1).map((point, i) => ({
point, ahead: mod((lane.lengths[i] - entryDistance) / perimeter.unit) * perimeter.unit,
})).filter(p => p.ahead > 1e-6 && p.ahead < ringLength - 1e-6).sort((a, b) => a.ahead - b.ahead);
const points = [...departure.path.points, ...lanePoints.map(p => p.point), ...finishPath.points];
// Remove coincident track endpoints so every timed segment has a duration.
const path = measure(points.filter((p, i) => !i || Math.hypot(p.x - points[i - 1].x, p.y - points[i - 1].y) > 1e-7));
const boardLength = departure.boardLength;
const timeAt = d => Math.min(d, boardLength) / 240 + Math.max(0, d - boardLength) / 180;
return { path, boardLength, ringStart: departure.path.length,
finishStart: departure.path.length + ringLength,
times: path.lengths.map(timeAt), duration: timeAt(path.length) };
}
export function journeyPosition(trip, elapsed) {
const boardTime = trip.boardLength / 240;
const distance = Math.min(trip.path.length, Math.max(0, Math.min(elapsed, boardTime) * 240 + Math.max(0, elapsed - boardTime) * 180));
return { ...at(trip.path, distance), distance };
}
function cursor(actor, time) {
const elapsed = time - actor.start, times = actor.journey.times;
let lo = 0, hi = times.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (times[mid] <= elapsed + 1e-9) lo = mid + 1; else hi = mid;
}
return lo;
}
function position(actor, i, time) {
const { path, times } = actor.journey;
if (!i) return path.points[0];
const a = path.points[i - 1], b = path.points[i];
const t = (time - actor.start - times[i - 1]) / (times[i] - times[i - 1]);
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
}
// Exact closest approach on each overlapping pair of linear time segments.
// This checks the swept interval, including between rendered frames at 3×.
export function tripsConflict(a, b, now, clearance, parkedClearance = clearance) {
const end = Math.min(a.start + a.journey.duration, b.start + b.journey.duration);
let time = now, i = cursor(a, time), j = cursor(b, time);
while (time < end - 1e-8) {
const nextA = a.start + a.journey.times[i], nextB = b.start + b.journey.times[j];
const next = Math.min(end, nextA, nextB);
const p = position(a, i, time), q = position(b, j, time);
const r = position(a, i, next), s = position(b, j, next);
const gap = time < a.start - 1e-8 || time < b.start - 1e-8 ? parkedClearance : clearance;
if (pointSegmentDistance({ x: 0, y: 0 }, { x: p.x - q.x, y: p.y - q.y }, { x: r.x - s.x, y: r.y - s.y }) < gap) return true;
time = next;
if (nextA <= next + 1e-8) i++;
if (nextB <= next + 1e-8) j++;
}
return false;
}
// The caller supplies the parked-car dependency order. Each trip gets the first
// free time slot; unrelated trips can share a start or overlap most of a trip.
// All times use one simulation clock, so changing playback speed preserves gaps.
export function scheduleDepartures(candidates, { now = 0, active = [], parked = [], clearance, parkedClearance = clearance }) {
const planned = [], reservations = [...active];
const released = new Set();
for (const candidate of candidates) {
released.add(candidate.car.id);
const fixed = parked.filter(p => !released.has(p.id));
const points = candidate.journey.path.points;
if (fixed.some(p => points.some((b, i) => i && pointSegmentDistance(p, points[i - 1], b) < parkedClearance))) {
throw new Error(`Departure order crosses a parked car: ${candidate.car.id}`);
}
const actor = { ...candidate, start: now };
// After the last reserved arrival, a statically clear route is always free.
const horizon = Math.max(now, ...reservations.map(r => r.start + r.journey.duration));
while (actor.start < horizon && reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) {
actor.start = Math.min(horizon, actor.start + .06);
}
if (reservations.some(other => tripsConflict(actor, other, now, clearance, parkedClearance))) {
throw new Error(`Existing traffic crosses a waiting car: ${candidate.car.id}`);
}
planned.push(actor); reservations.push(actor);
}
return planned;
}
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
// Adapted from labs/hex-video/designv2.md; roads and vehicle geometry are Lab additions.
export const carPaints = [
{ face: '#4787ED', side: '#1E6AD5', light: '#80B6FF' },
{ face: '#7BE637', side: '#66C617', light: '#ADF474' },
{ face: '#F9BE02', side: '#D89600', light: '#FFE271' },
{ face: '#C460FD', side: '#A538E9', light: '#DF9FFF' },
{ face: '#E4413F', side: '#C81C1D', light: '#FF8883' },
{ face: '#FE8A16', side: '#DB6008', light: '#FFB966' },
{ face: '#00C6FE', side: '#009ED3', light: '#78E5FF' },
];
export const theme = {
background: '#F2F2F4', board: '#D1D7DF', boardEdge: '#D1D7DF',
road: '#EDF1F5', roadEdge: '#BDC7D2', roadMark: '#B5BDCC',
ring: '#A3AFBE', ringEdge: '#D8DEE6', ringMark: '#EAF0F6',
ink: '#62635E', blue: '#4787ED', arrow: '#F9FFFF',
};
+111
View File
@@ -0,0 +1,111 @@
import { FLOWER_LEVEL, flowerTrackEmphasis } from '../../src/flower-study.js';
import { Game, available, trace, solveRotations } from '../../src/core.js';
import { TapQueue } from '../../src/tap-queue.js';
import { layout, hitCell } from '../../src/geometry.js';
import { CanvasPainter, drawScene } from '../../src/renderer.js';
import { TILE_ATLAS } from '../../src/sprites.js';
import { exitTiming } from '../../src/exit-animation.js';
import { MOTION } from '../../src/design-tokens.js';
import { unlockAudio, playSound } from '../../src/audio.js';
const $ = id => document.getElementById(id);
const canvas = $('board'), context = canvas.getContext('2d'), painter = new CanvasPainter(context);
const total = FLOWER_LEVEL.tiles.length;
const taps = new TapQueue();
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
let game = new Game(FLOWER_LEVEL), view, width, height, animation = null, turning = null, hint = null, path = null, frame = 0;
function stats() {
const busy = !!(animation || turning), preview = $('pattern-only').checked;
$('remaining').textContent = game.tiles.length;
$('progress').textContent = game.won ? '一朵花,全部盛放' : `已放行 ${total - game.tiles.length} / ${total}${taps.ids.length ? ` · 待出发 ${taps.ids.length}` : ''}`;
for (const id of ['left', 'right', 'hint']) $(id).disabled = busy || game.won || preview;
$('undo').disabled = busy || !game.history.length || preview;
$('pattern-only').disabled = busy;
if (game.won && !busy) $('message').textContent = '十枚棋子都已离场,完整的重瓣花开了。';
}
function draw(now = performance.now()) {
cancelAnimationFrame(frame);
if (!view) return;
context.clearRect(0, 0, width, height);
const state = { hint, path, reduced, queued: taps.ids, trackEmphasis: $('all-tracks').checked ? null : flowerTrackEmphasis(game.orientation), secondaryTrackColor: '#c6c9c7' };
if (animation) {
const progress = Math.min(1, (now - animation.start) / animation.duration);
state.animation = { route: animation.route, progress };
if (progress >= 1) animation = null;
}
if (turning) {
const progress = Math.min(1, (now - turning.start) / turning.duration);
state.boardAngle = -turning.direction * Math.PI / 3 * Math.pow(1 - progress, 3);
if (progress >= 1) turning = null;
}
drawScene(painter, game.level, $('pattern-only').checked ? [] : game.tiles, view, state);
if (!animation && !turning && !$('pattern-only').checked) {
const next = taps.take(game.tiles);
if (next) { tap(next); return; }
}
stats();
if (animation || turning) frame = requestAnimationFrame(draw);
}
function resize() {
const rect = canvas.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 3);
width = rect.width; height = rect.height;
canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
view = layout(game.level.cells, width, height); draw();
}
function clearEffects() { taps.clear(); cancelAnimationFrame(frame); animation = null; turning = null; hint = null; path = null; }
function tap(tile) {
if (game.won || $('pattern-only').checked) return;
if (animation || turning) {
if (taps.add(tile.id, animation?.route.tile.id)) draw();
return;
}
hint = null; path = null;
const result = game.tap(tile.id);
if (result.status === 'exit') {
animation = { route: result, start: performance.now(), duration: reduced ? 100 : exitTiming(result).duration };
$('message').textContent = result.steps.length > 2 ? '沿着内外花瓣和环带,绕到另一侧的出口。' : '先让开这个出口,花瓣里的长路就有了空间。';
playSound('exit');
} else {
taps.clear();
if (result.travelPoints) animation = { route: result, start: performance.now(), duration: reduced ? 150 : MOTION.collisionMs };
$('message').textContent = '出口还有一枚棋子。先给它让路,或转一下底盘再试。';
playSound('hit');
}
draw();
}
function rotate(direction) {
if (animation || turning || game.won || $('pattern-only').checked) return;
taps.clear();
const before = new Set(available(game.level, game.tiles).map(t => t.id));
game.rotate(direction); hint = null; path = null;
const opened = available(game.level, game.tiles).filter(t => !before.has(t.id));
turning = { direction, start: performance.now(), duration: reduced ? 100 : MOTION.rotationMs };
$('message').textContent = opened.length ? '花心换了一种连接,新的出口打开了。' : '花瓣仍然整齐,花心的连接已经换了方向。';
playSound('rotate'); draw();
}
canvas.addEventListener('pointerup', event => {
const rect = canvas.getBoundingClientRect(), cell = hitCell(game.level.cells, view, event.clientX - rect.left, event.clientY - rect.top);
const tile = cell && game.tiles.find(t => t.q === cell.q && t.r === cell.r);
if (tile) tap(tile);
});
$('left').onclick = () => rotate(-1);
$('right').onclick = () => rotate(1);
$('undo').onclick = () => { if (!animation && !turning && game.undo()) { clearEffects(); $('message').textContent = '回到上一步,再看看花瓣的出口。'; draw(); } };
$('reset').onclick = () => { clearEffects(); $('pattern-only').checked = false; game = new Game(FLOWER_LEVEL); $('message').textContent = '先放走朝外的两枚,给花瓣里的长路让位置。'; draw(); };
$('hint').onclick = () => {
const action = solveRotations({ ...game.level, tiles: game.tiles, moveLimit: game.moves }).actions[0];
if (!action) return;
if (action.type === 'rotate') $('message').textContent = `向${action.direction === 1 ? '右' : '左'}转一次,花心就会接上空出的出口。`;
else { hint = action.id; path = trace(game.level, game.tiles.find(t => t.id === hint), game.tiles); $('message').textContent = '这一枚的花径已经畅通,可以出发。'; }
draw();
};
$('all-tracks').onchange = () => draw();
$('pattern-only').onchange = () => {
if (animation || turning) $('pattern-only').checked = false;
hint = null; path = null; draw();
};
document.addEventListener('pointerdown', unlockAudio, { passive: true });
const atlas = new Image(); atlas.onload = () => { painter.atlas = atlas; draw(); }; atlas.src = '../../' + TILE_ATLAS;
new ResizeObserver(resize).observe(canvas);
+38
View File
@@ -0,0 +1,38 @@
<!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="./style.css">
</head>
<body>
<main>
<header>
<a class="back" href="../../">← 返回主线</a>
<span class="badge">图案关 · TEST 01 · REV 02</span>
</header>
<section class="intro">
<p class="eyebrow">A FLOWER IN MOTION</p>
<h1>花有出路 <small>重瓣</small></h1>
<p>双层花瓣,三重环带。一朵由长路线编织的花。</p>
</section>
<div class="status"><span>待离场 <strong id="remaining">10</strong></span><span id="progress">已放行 0 / 10</span></div>
<div class="board-wrap"><canvas id="board" aria-label="双层十二瓣花形轨道棋盘,点击箭头棋子离场"></canvas></div>
<p class="message" id="message" role="status">先放走朝外的两枚,给花瓣里的长路让位置。</p>
<nav class="controls" aria-label="棋盘操作">
<button id="left" type="button">↶ <span>左转</span></button>
<button id="undo" type="button">↩ <span>撤销</span></button>
<button id="hint" type="button">✧ <span>提示</span></button>
<button id="reset" type="button">↻ <span>重来</span></button>
<button id="right" type="button">↷ <span>右转</span></button>
</nav>
<footer>
<label><input id="pattern-only" type="checkbox"> 暂隐棋子,看完整花纹</label>
<label><input id="all-tracks" type="checkbox"> 全部轨道等粗显示</label>
<p>花瓣与环带都能通行,交叉处沿原轨道前进。旋转底盘,棋子与箭头留在原位。</p>
</footer>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
:root{font-family:system-ui,-apple-system,"PingFang SC",sans-serif;color:#3c4d5b;background:#f1e7e5;font-synthesis:none}
*{box-sizing:border-box}
body{margin:0}
main{max-width:900px;margin:auto;padding:16px 28px 20px}
header{display:flex;align-items:center;justify-content:space-between;gap:12px}
.back{color:#526575;text-decoration:none;font-size:13px}
.badge{font-size:11px;letter-spacing:.08em;color:#7d5864;border:1px solid #d6bac2;border-radius:20px;padding:7px 12px}
.intro{text-align:center;margin:10px 0 12px}
.eyebrow{font-size:10px;letter-spacing:.24em;color:#8b6873}
h1{font-size:28px;letter-spacing:.15em;margin:7px 0 10px;font-weight:650}
.intro>p:last-child{font-size:13px;margin:0;color:#65717b}
.status{display:flex;align-items:center;justify-content:space-between;gap:12px;max-width:620px;margin:auto;font-size:12px;color:#65717b}
.status strong{font-size:24px;color:#3c4d5b;margin-left:6px}
#progress{font-size:11px}
.board-wrap{width:100%;height:clamp(400px,calc(100svh - 310px),740px);min-height:380px}
canvas{width:100%;height:100%;display:block;touch-action:manipulation}
.message{margin:4px 0 18px;text-align:center;font-size:13px;min-height:36px;line-height:1.6}
.controls{display:flex;justify-content:center;gap:14px}
button{font:24px system-ui;color:#526575;background:#fffaf5;border:1px solid #dfd4cf;border-radius:18px;min-width:74px;min-height:66px;padding:6px 12px;cursor:pointer;box-shadow:0 3px 0 #d7c8c5}
button span{display:block;font-size:11px}
button:hover{background:#fff}
button:active{transform:translateY(2px);box-shadow:0 1px 0 #d7c8c5}
button:disabled{opacity:.4;cursor:default}
button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #98708a;outline-offset:4px}
footer{text-align:center;margin:18px auto 0;max-width:560px;font-size:11px;color:#6b757e;line-height:1.7}
h1 small{font-size:13px;letter-spacing:.1em;font-weight:500;vertical-align:middle;border-left:1px solid #c9b0b8;margin-left:5px;padding-left:12px;color:#8b6873}
footer label{display:inline-flex;align-items:center;gap:7px;margin:0 8px;font-size:12px;color:#526575;cursor:pointer}
footer p{margin:8px 0 0}
input{accent-color:#8b6873}
@media(max-width:540px){main{padding:16px 14px 20px}.intro{margin:12px 0}h1{font-size:28px}.board-wrap{height:380px;min-height:0}.controls{gap:9px}button{min-width:57px;min-height:60px;padding:5px 9px}.message{font-size:12px;margin-bottom:12px}footer{margin-top:22px}}
@media(prefers-reduced-motion:reduce){button:active{transform:none}}