feat(airport): bind landings to the six visual flats
Entry side is the 60-degree wedge of the port, not the cell-edge direction. Levels regenerated; hint line joins the waiting plane to the on-board path.
This commit is contained in:
@@ -74,9 +74,9 @@
|
|||||||
|
|
||||||
### 2.5 场外停机坪
|
### 2.5 场外停机坪
|
||||||
|
|
||||||
来机停靠在棋盘六条边外侧,不悬停在正北等罗盘方位。现工程是平顶六边形:六条边是上方、右上方、右下方、下方、左下方、左上方。竖直的左侧、右侧是尖顶六边形才有的边,对应本棋盘的左右两个顶角,不是入口。
|
来机停靠在棋盘轮廓六条平边外侧(右上方、右方、右下方、左下方、左方、左上方),不悬停在正北等罗盘方位。这六条平边就是入口边;角上的端口只归其中一边,不能两边同时进。
|
||||||
|
|
||||||
同侧可有多架、多色来机,同色来机也可分处不同侧。停机坪上的排列不绑定精确入口。每个边界入口和出口必须唯一归属某一条边;角部不同端口各有唯一归属。画面分区和规则归属必须一致。
|
同侧可有多架、多色来机,同色来机也可分处不同侧。停机坪上的排列不绑定精确入口。每个边界入口和出口按端口位置落入的 60° 平边扇区唯一归属;画面上的停机坪边和规则上的进场边必须是同一条。来机只能从当前所在平边的开口进场,不能跨边。
|
||||||
|
|
||||||
## 3. 起飞、降落与碰撞
|
## 3. 起飞、降落与碰撞
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
|
|
||||||
### 3.2 场外来机降落
|
### 3.2 场外来机降落
|
||||||
|
|
||||||
点击后检查当前所在侧的全部边界入口。合法降落需同时满足:入口属于当前侧、轨道通往预设同色机位、机位未占用或预占、路径有效、完整运动过程不碰撞任何在场飞机的六角占位。
|
点击后只检查当前所在平边的边界入口,不把邻边开口算作本侧通路。合法降落需同时满足:入口属于当前平边、轨道通往预设同色机位、机位未占用或预占、路径有效、完整运动过程不碰撞任何在场飞机的六角占位。
|
||||||
|
|
||||||
| 结果 | 行为 |
|
| 结果 | 行为 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -171,10 +171,10 @@
|
|||||||
|
|
||||||
盘边跑道与停机坪航线(2026-09-21):
|
盘边跑道与停机坪航线(2026-09-21):
|
||||||
|
|
||||||
- 每条板边轨道不再在糖砖内收口,而是沿端口法线延伸到托盘外缘,并在开口处画一道垂直门槛,让"开到盘边的出口"和"中途断掉的线"一眼可分。
|
- 每条板边轨道不再在糖砖内收口,而是沿端口法线延伸到托盘外缘(圆头收回半个线宽,不伸出棋盘)。
|
||||||
- 托盘外、六个侧的法线方向画固定浅色停机坪航线,等待机停在其上;航线不随底盘旋转,跑道与门槛随底盘旋转。
|
- 托盘外、六个侧的法线方向画固定浅色停机坪航线,等待机停在其上;航线不随底盘旋转,跑道随底盘旋转。
|
||||||
- 当某架来机的当前侧在当前朝向下接得上同色机位时,用中性强调色(马卡龙 `hint`,不是飞机或机位颜色)把"飞机 → 盘边开口 → 机位"整条通道点亮,并在开口处加一圈标记、保留进场箭头;被挡时阻挡物仍用 `blocked` 标红。常显跑道负责说明"这里有没有开口",高亮负责说明"这架能不能进"。
|
- 当某架来机的当前侧在当前朝向下接得上同色机位时,用中性强调色(马卡龙 `hint`,不是飞机或机位颜色)点亮盘内通道,开口加圈、保留进场箭头;被挡时阻挡物仍用 `blocked` 标红。跑道线始终裁在托盘内。常显跑道负责说明"这里有没有开口",高亮负责说明"这架能不能进"。
|
||||||
- 停机坪机位仍按其原有平边法线摆放(与入口法线差半步),不做位移;错位由高亮通道跨越,不靠移动机位。
|
- 停机坪机位仍按其原有平边法线摆放(与入口法线差半步),不做位移。
|
||||||
|
|
||||||
### 5.2 颜色与身份
|
### 5.2 颜色与身份
|
||||||
|
|
||||||
|
|||||||
+13
-12
@@ -1,6 +1,6 @@
|
|||||||
# 六角机场实验室
|
# 六角机场实验室
|
||||||
|
|
||||||
独立的起降调度原型。前三关是 19 格教学;视觉使用原工程马卡龙方案。不覆盖主线关卡或存档。
|
独立的起降调度原型。前四关是 19 格教学;视觉使用原工程马卡龙方案。不覆盖主线关卡或存档。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
@@ -8,24 +8,25 @@
|
|||||||
|
|
||||||
点击地面飞机起飞,点击场外停机坪上的飞机降落;左右转每次 60°。成功、失败与旋转都消耗一次。灰色轨道不分颜色;来机匹配彩色专用机位。P 标记表示已停好的来机。它再次起飞后,停到实际出口那条边的停机坪,仍需重新停好。
|
点击地面飞机起飞,点击场外停机坪上的飞机降落;左右转每次 60°。成功、失败与旋转都消耗一次。灰色轨道不分颜色;来机匹配彩色专用机位。P 标记表示已停好的来机。它再次起飞后,停到实际出口那条边的停机坪,仍需重新停好。
|
||||||
|
|
||||||
板边轨道一直画到托盘外缘并带一道门槛,托盘外的浅色停机坪航线固定不动。当等待机在当前朝向下接得上同色机位时,整条"飞机 → 盘边开口 → 机位"的通道用中性强调色点亮;旋转时通道会亮灭,据此判断能否进场。
|
板边轨道一直画到托盘外缘,不伸出棋盘。托盘外的浅色停机坪航线固定不动。当等待机在当前朝向下接得上同色机位时,盘内通道用中性强调色点亮;旋转时通道会亮灭,据此判断能否进场。
|
||||||
|
|
||||||
## 关卡证据
|
## 关卡证据
|
||||||
|
|
||||||
所有数据冻结在 `levels.js`。`solution` 是最优免费解,`experience` 是制作意图回放;第三关后者演示来机阻挡及再次起飞换侧。
|
所有数据冻结在 `levels.js`。`solution` 是最优免费解,`experience` 是制作意图回放。
|
||||||
|
|
||||||
| 关卡 | 格子 | 最少操作 | 初始额度 | 体验回放 | 先清全盘再接机最少操作 | 最优解保留原有飞机 |
|
| 关卡 | 格子 | 最少操作 | 初始额度 | 体验回放 | 先清全盘再接机最少操作 | 最优解保留原有飞机 |
|
||||||
|---|---:|---:|---:|---:|---:|---:|
|
|---|---:|---:|---:|---:|---:|---:|
|
||||||
| 留一点空间 | 19 | 5 | 8 | 5 | 10 | 3 |
|
| 留一点空间 | 19 | 1 | 3 | 1 | 4 | 3 |
|
||||||
| 同色,不同方向 | 19 | 7 | 10 | 7 | 11 | 4 |
|
| 同色才停靠 | 19 | 1 | 3 | 1 | 4 | 3 |
|
||||||
| 让路与换侧 | 19 | 6 | 13 | 11 | 16 | 6 |
|
| 转一下再接入 | 19 | 2 | 4 | 2 | 5 | 3 |
|
||||||
| 更大的停机坪 | 37 | 8 | 12 | 8 | 18 | 6 |
|
| 清障再接入 | 19 | 5 | 7 | 5 | 11 | 4 |
|
||||||
| 绕一圈再接入 | 37 | 11 | 15 | 11 | 17 | 3 |
|
| 更大的停机坪 | 37 | 6 | 10 | 6 | 19 | 7 |
|
||||||
| 空港全景 | 61 | 10 | 14 | 10 | 20 | 8 |
|
| 绕一圈再接入 | 37 | 10 | 14 | 10 | 15 | 5 |
|
||||||
|
| 空港全景 | 61 | 8 | 12 | 8 | 18 | 10 |
|
||||||
|
|
||||||
前三关为教学。第 4、5 关是半径 3 的练习关,地面 8 架原有飞机。第 6 关把棋盘放大到半径 4(61 格),地面原有飞机增至 13 架,用来承载更多初始棋子,而不是加新规则。最优性通过统一动作成本 BFS 的完整找到最短路径证明;状态上限或深度上限只返回 unknown。第三关的 11 步体验回放保留 5 架原有飞机,与最优解保留 6 架不同。
|
前四关为教学。第 5、6 关是半径 3 的练习关。第 7 关把棋盘放大到半径 4(61 格)。最优性通过统一动作成本 BFS 的完整找到最短路径证明;状态上限或深度上限只返回 unknown。
|
||||||
|
|
||||||
每关通过空棋盘、六朝向、六边、每色完整结果的无歧义校验。入口侧按边界端口向外法线归类,名称依次为上方、右上方、右下方、下方、左下方、左上方;角部不同端口各有唯一归属。来机画在对应边外侧的停机坪上,不使用正北等罗盘悬停点。
|
每关通过空棋盘、六朝向、六边、每色完整结果的无歧义校验。入口侧按端口位置落入的平边扇区归类,名称依次为右上方、右方、右下方、左下方、左方、左上方;角部端口只归一边。来机只能从当前停机坪那条平边进场,画在对应边外侧,不使用正北等罗盘悬停点。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run test:airport
|
npm run test:airport
|
||||||
@@ -35,7 +36,7 @@ npm run airport:practice
|
|||||||
npm run build:airport
|
npm run build:airport
|
||||||
```
|
```
|
||||||
|
|
||||||
教学三关生成器使用固定种子 9172026。练习关生成器使用 9172027,追加两个 37 格关与一个 61 格关,不改写前三关。不会在玩家进入页面时随机生成。
|
教学四关生成器使用固定种子 9172026。练习关生成器使用 9172027,追加两个 37 格关与一个 61 格关,不改写前四关。不会在玩家进入页面时随机生成。
|
||||||
|
|
||||||
## 结构
|
## 结构
|
||||||
|
|
||||||
|
|||||||
+38
-24
@@ -22,7 +22,7 @@ try { const value = JSON.parse(localStorage.getItem(STORAGE)); if (value && Arra
|
|||||||
function persist() { try { localStorage.setItem(STORAGE, JSON.stringify(saved)); } catch {} }
|
function persist() { try { localStorage.setItem(STORAGE, JSON.stringify(saved)); } catch {} }
|
||||||
let index = Number.isInteger(saved.last) && saved.last >= 0 && saved.last < LEVELS.length ? saved.last : 0;
|
let index = Number.isInteger(saved.last) && saved.last >= 0 && saved.last < LEVELS.length ? saved.last : 0;
|
||||||
let game, motions = [], rotation = null, feedback = null, queue = [], requestId = 0, diagnosis = null, generation = 0;
|
let game, motions = [], rotation = null, feedback = null, queue = [], requestId = 0, diagnosis = null, generation = 0;
|
||||||
const GATE_OVERSHOOT = 0.12, GATE_HALF = 0.16, APRON_WIDTH = 1.9;
|
const APRON_WIDTH = 1.9;
|
||||||
let view = { width: 390, height: 460, size: 28, ox: 195, oy: 230 }, audioContext;
|
let view = { width: 390, height: 460, size: 28, ox: 195, oy: 230 }, audioContext;
|
||||||
const worker = new Worker(new URL('./solver-worker.js', import.meta.url), { type: 'module' });
|
const worker = new Worker(new URL('./solver-worker.js', import.meta.url), { type: 'module' });
|
||||||
worker.onmessage = ({ data }) => {
|
worker.onmessage = ({ data }) => {
|
||||||
@@ -56,7 +56,7 @@ function loadLevel(next) {
|
|||||||
for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
|
for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close();
|
||||||
$('level-title').textContent = `第 ${index + 1} 关`;
|
$('level-title').textContent = `第 ${index + 1} 关`;
|
||||||
$('level-name').textContent = game.level.name;
|
$('level-name').textContent = game.level.name;
|
||||||
notice(game.level.lesson); sync(); diagnose();
|
notice(game.level.lesson); resize(); diagnose();
|
||||||
}
|
}
|
||||||
function waitingLocation(plane, state = game.state) {
|
function waitingLocation(plane, state = game.state) {
|
||||||
return waitingPose(plane, state.planes, boardRadius(game.level.cells));
|
return waitingPose(plane, state.planes, boardRadius(game.level.cells));
|
||||||
@@ -79,7 +79,7 @@ function approachHints() {
|
|||||||
const route = routes.find(r => r.side === plane.side && r.color === plane.color);
|
const route = routes.find(r => r.side === plane.side && r.color === plane.color);
|
||||||
if (!route) continue; // no entrance / no connected runway from this edge
|
if (!route) continue; // no entrance / no connected runway from this edge
|
||||||
const hit = sweepPath(route.points, blockers.filter(b => b.id !== plane.id));
|
const hit = sweepPath(route.points, blockers.filter(b => b.id !== plane.id));
|
||||||
hints.push({ plane, from: planeLocation(plane), points: [planeLocation(plane), ...route.points], blocker: hit ? hit.blocker : null });
|
hints.push({ plane, from: planeLocation(plane), points: route.points, blocker: hit ? hit.blocker : null });
|
||||||
}
|
}
|
||||||
return hints;
|
return hints;
|
||||||
}
|
}
|
||||||
@@ -130,14 +130,14 @@ function perform(action) {
|
|||||||
if (result.status === 'land') {
|
if (result.status === 'land') {
|
||||||
const start = waitingLocation(plane, before);
|
const start = waitingLocation(plane, before);
|
||||||
// Exterior approach stays outside the board and joins the selected boundary port.
|
// Exterior approach stays outside the board and joins the selected boundary port.
|
||||||
const mouth = result.points[0], n = PORTS[plane.side], len = Math.hypot(n.x,n.y);
|
const mouth = result.points[0], n = PORTS[result.exitDir ?? plane.side], len = Math.hypot(n.x,n.y);
|
||||||
const approach = { x: mouth.x + n.x / len * 1.3, y: mouth.y + n.y / len * 1.3 };
|
const approach = { x: mouth.x + n.x / len * 1.3, y: mouth.y + n.y / len * 1.3 };
|
||||||
points = [start, approach, ...result.points]; kind = 'land'; sound('land');
|
points = [start, approach, ...result.points]; kind = 'land'; sound('land');
|
||||||
notice('停靠成功。留意新飞机是否挡住其他通路。');
|
notice('停靠成功。留意新飞机是否挡住其他通路。');
|
||||||
} else if (result.status === 'exit') {
|
} else if (result.status === 'exit') {
|
||||||
points = [...result.points]; kind = 'exit'; sound('exit');
|
points = [...result.points]; kind = 'exit'; sound('exit');
|
||||||
if (plane.kind === 'arrival') {
|
if (plane.kind === 'arrival') {
|
||||||
const last = points.at(-1), n = PORTS[result.side], len = Math.hypot(n.x,n.y);
|
const last = points.at(-1), n = PORTS[result.exitDir ?? result.side], len = Math.hypot(n.x,n.y);
|
||||||
points.push({ x:last.x+n.x/len*1.3, y:last.y+n.y/len*1.3 }, waitingLocation(updated));
|
points.push({ x:last.x+n.x/len*1.3, y:last.y+n.y/len*1.3 }, waitingLocation(updated));
|
||||||
notice(`来机已起飞,将在${SIDES[result.side]}停机坪等待。`);
|
notice(`来机已起飞,将在${SIDES[result.side]}停机坪等待。`);
|
||||||
} else notice('飞机已离场,空出的通路可以用于接机。');
|
} else notice('飞机已离场,空出的通路可以用于接机。');
|
||||||
@@ -230,6 +230,18 @@ function hull(points){
|
|||||||
for(const q of sorted.reverse()){while(upper.length>=2&&cross(upper.at(-2),upper.at(-1),q)<=0)upper.pop();upper.push(q);}
|
for(const q of sorted.reverse()){while(upper.length>=2&&cross(upper.at(-2),upper.at(-1),q)<=0)upper.pop();upper.push(q);}
|
||||||
return [...lower.slice(0,-1),...upper.slice(0,-1)];
|
return [...lower.slice(0,-1),...upper.slice(0,-1)];
|
||||||
}
|
}
|
||||||
|
function insideHull(poly,x,y){for(let i=0;i<poly.length;i++){const a=poly[i],b=poly[(i+1)%poly.length];if((b.x-a.x)*(y-a.y)-(b.y-a.y)*(x-a.x)<-1e-9)return false;}return true;}
|
||||||
|
function clipToHull(points,poly){
|
||||||
|
const out=[];
|
||||||
|
for(let i=0;i<points.length;i++){
|
||||||
|
const p=points[i],inn=insideHull(poly,p.x,p.y),prev=i?points[i-1]:null,prevIn=prev&&insideHull(poly,prev.x,prev.y);
|
||||||
|
if(inn){
|
||||||
|
if(prev&&!prevIn){const dx=p.x-prev.x,dy=p.y-prev.y,l=Math.hypot(dx,dy)||1,t=rimCrossing(poly,p,{x:-dx/l,y:-dy/l});if(t!==null)out.push({x:p.x-dx/l*t,y:p.y-dy/l*t});}
|
||||||
|
out.push(p);
|
||||||
|
}else if(prevIn){const dx=p.x-prev.x,dy=p.y-prev.y,l=Math.hypot(dx,dy)||1,t=rimCrossing(poly,prev,{x:dx/l,y:dy/l});if(t!==null)out.push({x:prev.x+dx/l*t,y:prev.y+dy/l*t});}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
function aircraft(plane,where,alpha=1){
|
function aircraft(plane,where,alpha=1){
|
||||||
ctx.save();ctx.translate(where.x,where.y);ctx.globalAlpha=alpha;
|
ctx.save();ctx.translate(where.x,where.y);ctx.globalAlpha=alpha;
|
||||||
const color=pigments[plane.color].color;
|
const color=pigments[plane.color].color;
|
||||||
@@ -274,14 +286,14 @@ function draw(now){
|
|||||||
const cellKeys=new Set(board.cells.map(c=>key(c.q,c.r)));
|
const cellKeys=new Set(board.cells.map(c=>key(c.q,c.r)));
|
||||||
const trackWidth=.11,portRadius=Math.hypot(PORTS[0].x,PORTS[0].y),visualRadius=Math.max(0,.90*FLAT-trackWidth/2-.01);
|
const trackWidth=.11,portRadius=Math.hypot(PORTS[0].x,PORTS[0].y),visualRadius=Math.max(0,.90*FLAT-trackWidth/2-.01);
|
||||||
const exterior=(cell,d)=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
|
const exterior=(cell,d)=>!cellKeys.has(key(cell.q+DIRS[d][0],cell.r+DIRS[d][1]));
|
||||||
// A boundary runway then reaches the tray outline instead of stopping inside the brick,
|
// A boundary runway reaches the tray outline instead of stopping inside the brick,
|
||||||
// so an off-board plane can be read as joining one specific opening. The reach rotates
|
// but never leaves the board: the round cap is pulled back by half the stroke.
|
||||||
// with the base, exactly like the track it belongs to.
|
const rimEnd=(point,dir)=>{
|
||||||
const gateReach=(point,dir)=>{
|
|
||||||
const n=PORTS[dir],l=Math.hypot(n.x,n.y),ux=n.x/l,uy=n.y/l,t=rimCrossing(hullPts,point,{x:ux,y:uy});
|
const n=PORTS[dir],l=Math.hypot(n.x,n.y),ux=n.x/l,uy=n.y/l,t=rimCrossing(hullPts,point,{x:ux,y:uy});
|
||||||
return t===null?null:{x:point.x+ux*(t+GATE_OVERSHOOT),y:point.y+uy*(t+GATE_OVERSHOOT),ux,uy,dir};
|
if(t===null)return null;
|
||||||
|
const reach=Math.max(0,t-trackWidth/2);
|
||||||
|
return {x:point.x+ux*reach,y:point.y+uy*reach};
|
||||||
};
|
};
|
||||||
const gates=[];
|
|
||||||
for(const cell of board.cells){
|
for(const cell of board.cells){
|
||||||
const p=position(cell.q,cell.r);
|
const p=position(cell.q,cell.r);
|
||||||
for(let d=0;d<6;d++){
|
for(let d=0;d<6;d++){
|
||||||
@@ -291,13 +303,11 @@ function draw(now){
|
|||||||
const dx0=exterior(cell,d)?(points[0].x-p.x)*shrink:0,dy0=exterior(cell,d)?(points[0].y-p.y)*shrink:0;
|
const dx0=exterior(cell,d)?(points[0].x-p.x)*shrink:0,dy0=exterior(cell,d)?(points[0].y-p.y)*shrink:0;
|
||||||
const dx1=exterior(cell,b)?(last.x-p.x)*shrink:0,dy1=exterior(cell,b)?(last.y-p.y)*shrink:0;
|
const dx1=exterior(cell,b)?(last.x-p.x)*shrink:0,dy1=exterior(cell,b)?(last.y-p.y)*shrink:0;
|
||||||
points.forEach((q,i)=>{const t=i/(points.length-1),u=1-t;q.x-=u*u*u*dx0+t*t*t*dx1;q.y-=u*u*u*dy0+t*t*t*dy1;});
|
points.forEach((q,i)=>{const t=i/(points.length-1),u=1-t;q.x-=u*u*u*dx0+t*t*t*dx1;q.y-=u*u*u*dy0+t*t*t*dy1;});
|
||||||
if(exterior(cell,d)){const g=gateReach(points[0],d);if(g){points.unshift(g);gates.push(g);}}
|
if(exterior(cell,d)){const g=rimEnd(points[0],d);if(g)points.unshift(g);}
|
||||||
if(exterior(cell,b)){const g=gateReach(points.at(-1),b);if(g){points.push(g);gates.push(g);}}
|
if(exterior(cell,b)){const g=rimEnd(points.at(-1),b);if(g)points.push(g);}
|
||||||
line(points,materials.track,trackWidth);
|
line(points,materials.track,trackWidth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A perpendicular threshold at each opening, so a runway end reads as a gate.
|
|
||||||
for(const g of gates)line([{x:g.x-g.uy*GATE_HALF,y:g.y+g.ux*GATE_HALF},{x:g.x+g.uy*GATE_HALF,y:g.y-g.ux*GATE_HALF}],materials.track,trackWidth);
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
// Stands, like the aircraft, never turn with the base.
|
// Stands, like the aircraft, never turn with the base.
|
||||||
for(const stand of game.level.stands){
|
for(const stand of game.level.stands){
|
||||||
@@ -355,22 +365,24 @@ function draw(now){
|
|||||||
line([{x:x-k,y:y-k},{x:x+k,y:y+k}],materials.blocked,.11);
|
line([{x:x-k,y:y-k},{x:x+k,y:y+k}],materials.blocked,.11);
|
||||||
line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,.11);
|
line([{x:x-k,y:y+k},{x:x+k,y:y-k}],materials.blocked,.11);
|
||||||
}
|
}
|
||||||
// Runway read: when a waiting plane's edge connects to a same-colour stand, draw the
|
// Runway read: when a waiting plane's edge connects to a same-colour stand, light the
|
||||||
// whole channel from the plane, across the tray edge, to the stand. The highlight is a
|
// channel in a neutral hint colour from the plane, across the apron, onto the board
|
||||||
// neutral hint colour (never the plane colour: the runway does not carry the match),
|
// path. Board runways themselves stay inside the tray. A ripple at the plane, a ring
|
||||||
// a ripple at the plane and a ring at the opening, and an arrow gliding inbound. A
|
// at the opening, and an inbound arrow still guide; a blocker ripples red.
|
||||||
// blocker still ripples red. This only guides; the player still performs the landing.
|
|
||||||
if (!motions.length && !rotation && !document.querySelector('dialog[open]')) {
|
if (!motions.length && !rotation && !document.querySelector('dialog[open]')) {
|
||||||
const hints = approachHints(), T = now / 1000;
|
const hints = approachHints(), T = now / 1000;
|
||||||
for (const h of hints) {
|
for (const h of hints) {
|
||||||
const pts = h.points;
|
const onboard = clipToHull(h.points, hullPts);
|
||||||
|
const pts = onboard.length ? [h.from, ...onboard] : [h.from, ...h.points];
|
||||||
|
if (pts.length > 1) {
|
||||||
line(pts, materials.hintGlow, .19);
|
line(pts, materials.hintGlow, .19);
|
||||||
line(pts, materials.hint, .085);
|
line(pts, materials.hint, .085);
|
||||||
const at = along(pts, (T * 0.6) % 1), dir = at.angle - Math.PI / 2, s = .26;
|
const at = along(pts, (T * 0.6) % 1), dir = at.angle - Math.PI / 2, s = .26;
|
||||||
polygon([{ x: at.x + Math.cos(dir) * s, y: at.y + Math.sin(dir) * s }, { x: at.x + Math.cos(dir + 2.4) * s, y: at.y + Math.sin(dir + 2.4) * s }, { x: at.x + Math.cos(dir - 2.4) * s, y: at.y + Math.sin(dir - 2.4) * s }], world.ink);
|
polygon([{ x: at.x + Math.cos(dir) * s, y: at.y + Math.sin(dir) * s }, { x: at.x + Math.cos(dir + 2.4) * s, y: at.y + Math.sin(dir + 2.4) * s }, { x: at.x + Math.cos(dir - 2.4) * s, y: at.y + Math.sin(dir - 2.4) * s }], world.ink);
|
||||||
const mouth = pts[1] || h.from;
|
const ring = onboard[0] || along(pts, .08);
|
||||||
ctx.save(); ctx.globalAlpha = .9; ctx.beginPath(); ctx.arc(mouth.x, mouth.y, .34, 0, Math.PI * 2);
|
ctx.save(); ctx.globalAlpha = .9; ctx.beginPath(); ctx.arc(ring.x, ring.y, .28, 0, Math.PI * 2);
|
||||||
ctx.strokeStyle = materials.hint; ctx.lineWidth = .10; ctx.stroke(); ctx.restore();
|
ctx.strokeStyle = materials.hint; ctx.lineWidth = .10; ctx.stroke(); ctx.restore();
|
||||||
|
}
|
||||||
for (const phase of [T % 1, (T + .5) % 1]) {
|
for (const phase of [T % 1, (T + .5) % 1]) {
|
||||||
ctx.save(); ctx.globalAlpha = (1 - phase) * .8;
|
ctx.save(); ctx.globalAlpha = (1 - phase) * .8;
|
||||||
ctx.beginPath(); ctx.arc(h.from.x, h.from.y, .5 + phase * 1.2, 0, Math.PI * 2);
|
ctx.beginPath(); ctx.arc(h.from.x, h.from.y, .5 + phase * 1.2, 0, Math.PI * 2);
|
||||||
@@ -395,7 +407,9 @@ function draw(now){
|
|||||||
}
|
}
|
||||||
function resize(){
|
function resize(){
|
||||||
const rect=$('airspace').getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2);
|
const rect=$('airspace').getBoundingClientRect(),dpr=Math.min(devicePixelRatio||1,2);
|
||||||
const span=(game?apronDistance(boardRadius(game.level.cells)):6)*2+1.8;
|
const radius=game?boardRadius(game.level.cells):2;
|
||||||
|
const stacks=game?Math.max(1,...[0,1,2,3,4,5].map(s=>game.level.arrivals.filter(a=>a.side===s).length)):1;
|
||||||
|
const span=(apronDistance(radius)+(stacks-1)*1.12+1.8)*2;
|
||||||
view={width:rect.width,height:rect.height,size:Math.min(rect.width/span,rect.height/span),ox:rect.width/2,oy:rect.height/2};
|
view={width:rect.width,height:rect.height,size:Math.min(rect.width/span,rect.height/span),ox:rect.width/2,oy:rect.height/2};
|
||||||
canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr);if(game)sync();
|
canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr);if(game)sync();
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-6
@@ -2,8 +2,8 @@
|
|||||||
import { DIRS, key, opposite, rotateBoard, trace, validateLevel as validateBase, canRotate } from '../../src/core.js';
|
import { DIRS, key, opposite, rotateBoard, trace, validateLevel as validateBase, canRotate } from '../../src/core.js';
|
||||||
import { PORTS, position, pathPoints, sweepPath, hexPoints } from '../../src/path-math.js';
|
import { PORTS, position, pathPoints, sweepPath, hexPoints } from '../../src/path-math.js';
|
||||||
|
|
||||||
// Flat-top edges in screen space. Compass names hid that waiting bays sit on the six sides.
|
// Six visual flats of the pointy-top outline (bay normals at -60/0/60/120/180/-120).
|
||||||
export const SIDES = ['上方', '右上方', '右下方', '下方', '左下方', '左上方'];
|
export const SIDES = ['右上方', '右方', '右下方', '左下方', '左方', '左上方'];
|
||||||
export function boardRadius(cells) {
|
export function boardRadius(cells) {
|
||||||
return Math.max(0, ...cells.map(c => (Math.abs(c.q) + Math.abs(c.r) + Math.abs(c.q + c.r)) / 2));
|
return Math.max(0, ...cells.map(c => (Math.abs(c.q) + Math.abs(c.r) + Math.abs(c.q + c.r)) / 2));
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,19 @@ export function waitingPose(plane, planes, radius) {
|
|||||||
const d = apronDistance(radius);
|
const d = apronDistance(radius);
|
||||||
return { x: bx * (d + offset), y: by * (d + offset), angle: Math.atan2(-by, -bx) + Math.PI / 2 };
|
return { x: bx * (d + offset), y: by * (d + offset), angle: Math.atan2(-by, -bx) + Math.PI / 2 };
|
||||||
}
|
}
|
||||||
|
// A boundary port belongs to the visual flat whose outward normal is nearest the
|
||||||
|
// port's position, in 60° wedges centred on the bay normals. Ties fall on the
|
||||||
|
// clockwise edge so a corner never belongs to two sides.
|
||||||
|
export function visualSideFromPoint(x, y) {
|
||||||
|
// 60° wedges starting at the top (-90°): that boundary belongs to side 0.
|
||||||
|
let t = Math.atan2(y, x) + Math.PI / 2;
|
||||||
|
t = ((t % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
||||||
|
return Math.min(5, Math.floor(t / (Math.PI / 3) + 1e-12));
|
||||||
|
}
|
||||||
|
export function visualSide(q, r, portDir) {
|
||||||
|
const p = position(q, r), n = PORTS[portDir];
|
||||||
|
return visualSideFromPoint(p.x + n.x, p.y + n.y);
|
||||||
|
}
|
||||||
|
|
||||||
// White tray outline in board-local units, shared by drawing, the edge-runway
|
// White tray outline in board-local units, shared by drawing, the edge-runway
|
||||||
// reach and the runway-read tests. A convex hull, so ray crossings are unambiguous.
|
// reach and the runway-read tests. A convex hull, so ray crossings are unambiguous.
|
||||||
@@ -79,8 +92,8 @@ export function boardAt(level, orientation) { return rotateBoard({ cells: level.
|
|||||||
export function departureGeometry(board, plane) {
|
export function departureGeometry(board, plane) {
|
||||||
const route = trace(board, plane, [], true);
|
const route = trace(board, plane, [], true);
|
||||||
if (route.status !== 'exit') return { ...route, points: pathPoints(route) };
|
if (route.status !== 'exit') return { ...route, points: pathPoints(route) };
|
||||||
const side = route.steps.at(-1).exit;
|
const last = route.steps.at(-1), exitDir = last.exit;
|
||||||
return { ...route, side, points: pathPoints(route) };
|
return { ...route, exitDir, side: visualSide(last.q, last.r, exitDir), points: pathPoints(route) };
|
||||||
}
|
}
|
||||||
export function catalogs(level) {
|
export function catalogs(level) {
|
||||||
if (catalogCache.has(level)) return catalogCache.get(level);
|
if (catalogCache.has(level)) return catalogCache.get(level);
|
||||||
@@ -91,7 +104,7 @@ export function catalogs(level) {
|
|||||||
if (departure.status !== 'exit') continue;
|
if (departure.status !== 'exit') continue;
|
||||||
arrivals.push({
|
arrivals.push({
|
||||||
status: 'land', standId: stand.id, color: stand.color, q: stand.q, r: stand.r,
|
status: 'land', standId: stand.id, color: stand.color, q: stand.q, r: stand.r,
|
||||||
dir: opposite(dir), side: departure.side,
|
dir: opposite(dir), side: departure.side, exitDir: departure.exitDir,
|
||||||
points: [...departure.points].reverse(), steps: [...departure.steps].reverse(),
|
points: [...departure.points].reverse(), steps: [...departure.steps].reverse(),
|
||||||
signature: `${stand.id}:${dir}:${departure.side}`,
|
signature: `${stand.id}:${dir}:${departure.side}`,
|
||||||
});
|
});
|
||||||
@@ -138,8 +151,9 @@ export function checkDeparture(level, state, planeId) {
|
|||||||
const points = route.points;
|
const points = route.points;
|
||||||
// Preserve the original long exit-ray collision check, without drawing a
|
// Preserve the original long exit-ray collision check, without drawing a
|
||||||
// 24-unit flight across the screen.
|
// 24-unit flight across the screen.
|
||||||
|
const out = route.exitDir ?? route.side;
|
||||||
const swept = route.status === 'exit'
|
const swept = route.status === 'exit'
|
||||||
? [...points, { x: points.at(-1).x + PORTS[route.side].x * 24, y: points.at(-1).y + PORTS[route.side].y * 24 }]
|
? [...points, { x: points.at(-1).x + PORTS[out].x * 24, y: points.at(-1).y + PORTS[out].y * 24 }]
|
||||||
: points;
|
: points;
|
||||||
const hit = sweepPath(swept, obstacles(state, planeId));
|
const hit = sweepPath(swept, obstacles(state, planeId));
|
||||||
if (hit) return { ...route, status: 'blocked', plane: { ...plane }, blocker: hit.blocker, collisionPoint: hit.point, travelPoints: hit.points };
|
if (hit) return { ...route, status: 'blocked', plane: { ...plane }, blocker: hit.blocker, collisionPoint: hit.point, travelPoints: hit.points };
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
<footer><button id="restart" class="restart"><canvas data-icon="restart" width="28" height="28"></canvas>重来</button><span id="level-name">留一点空间</span></footer>
|
<footer><button id="restart" class="restart"><canvas data-icon="restart" width="28" height="28"></canvas>重来</button><span id="level-name">留一点空间</span></footer>
|
||||||
</main>
|
</main>
|
||||||
<dialog id="menu"><button class="close" data-close aria-label="关闭">×</button><h2>选择关卡</h2><p>接齐来机即可过关,无需清空棋盘。</p><div id="level-list"></div></dialog>
|
<dialog id="menu"><button class="close" data-close aria-label="关闭">×</button><h2>选择关卡</h2><p>接齐来机即可过关,无需清空棋盘。</p><div id="level-list"></div></dialog>
|
||||||
<dialog id="help"><button class="close" data-close aria-label="关闭">×</button><h2>起降调度</h2><ol><li>点击地面飞机,沿机头方向尝试起飞。</li><li>场外飞机停在棋盘六条边外侧的停机坪:上方、右上方、右下方、下方、左下方、左上方。</li><li>点击停机坪上的飞机,从它所在那条边寻找同色机位。</li><li>轨道不分颜色,飞机和机位按颜色匹配。</li><li>再次起飞的来机,会停到实际出口那条边的停机坪。</li><li>每次起降尝试和旋转都消耗一次操作。</li></ol><p>标有 P 的是已经停靠的来机。所有来机同时停好,才算完成。</p><button id="sound" class="secondary">声音:开</button><p class="small">独立实验版 · 马卡龙视觉 · 进度单独保存</p></dialog>
|
<dialog id="help"><button class="close" data-close aria-label="关闭">×</button><h2>起降调度</h2><ol><li>点击地面飞机,沿机头方向尝试起飞。</li><li>场外飞机停在棋盘六条平边外侧的停机坪:右上方、右方、右下方、左下方、左方、左上方。</li><li>点击停机坪上的飞机,只从它所在那条边寻找同色机位,不能跨边进场。</li><li>轨道不分颜色,飞机和机位按颜色匹配。</li><li>再次起飞的来机,会停到实际出口那条边的停机坪。</li><li>每次起降尝试和旋转都消耗一次操作。</li></ol><p>标有 P 的是已经停靠的来机。所有来机同时停好,才算完成。</p><button id="sound" class="secondary">声音:开</button><p class="small">独立实验版 · 马卡龙视觉 · 进度单独保存</p></dialog>
|
||||||
<dialog id="result"><h2 id="result-title">全部到位</h2><p id="result-copy"></p><div class="dialog-actions"><button id="next" class="primary">下一关</button><button id="reward" class="primary" hidden></button><button id="retry" class="secondary">重新挑战</button></div><p id="reward-note" class="small" hidden>本实验版尚未接入真实广告,测试补充只用于验证奖励流程。</p></dialog>
|
<dialog id="result"><h2 id="result-title">全部到位</h2><p id="result-copy"></p><div class="dialog-actions"><button id="next" class="primary">下一关</button><button id="reward" class="primary" hidden></button><button id="retry" class="secondary">重新挑战</button></div><p id="reward-note" class="small" hidden>本实验版尚未接入真实广告,测试补充只用于验证奖励流程。</p></dialog>
|
||||||
<script type="module" src="./app.js"></script>
|
<script type="module" src="./app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+1033
-1065
File diff suppressed because it is too large
Load Diff
@@ -122,11 +122,13 @@ const output = [];
|
|||||||
for (let j = 0, n = 2 + Math.floor(rng() * 7); j < n; j++) { const c = cells[Math.floor(rng() * cells.length)]; c.tracks = randomTracks(rng); }
|
for (let j = 0, n = 2 + Math.floor(rng() * 7); j < n; j++) { const c = cells[Math.floor(rng() * cells.length)]; c.tracks = randomTracks(rng); }
|
||||||
const options = candidates(cells);
|
const options = candidates(cells);
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
const s0 = opt.sides[0];
|
const probe = base(cells, [{ id: 'P1', q: opt.q, r: opt.r, color: 8 }], []);
|
||||||
|
if (uniquenessIssues(probe).length) continue;
|
||||||
|
const cat = catalogs(probe);
|
||||||
for (let side = 0; side < 6; side++) {
|
for (let side = 0; side < 6; side++) {
|
||||||
if (s0.includes(side)) continue;
|
const at0 = cat[0].arrivals.some(r => r.side === side);
|
||||||
const d = opt.sides[1].includes(side) ? 1 : opt.sides[5].includes(side) ? -1 : 0;
|
const turn = cat[1].arrivals.some(r => r.side === side) ? 1 : cat[5].arrivals.some(r => r.side === side) ? -1 : 0;
|
||||||
if (!d) continue;
|
if (at0 || !turn) continue;
|
||||||
const level = base(cells, [{ id: 'P1', q: opt.q, r: opt.r, color: 8 }], [{ id: 'in1', color: 8, side }]);
|
const level = base(cells, [{ id: 'P1', q: opt.q, r: opt.r, color: 8 }], [{ id: 'in1', color: 8, side }]);
|
||||||
if (uniquenessIssues(level).length) continue;
|
if (uniquenessIssues(level).length) continue;
|
||||||
const st0 = createState(level); st0.orientation = 0;
|
const st0 = createState(level); st0.orientation = 0;
|
||||||
|
|||||||
+34
-4
@@ -2,7 +2,7 @@ import test from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { hexagon, trace, opposite, DIRS } from '../src/core.js';
|
import { hexagon, trace, opposite, DIRS } from '../src/core.js';
|
||||||
import { pathPoints, sweepPath, position, PORTS, hexPoints } from '../src/path-math.js';
|
import { pathPoints, sweepPath, position, PORTS, hexPoints } from '../src/path-math.js';
|
||||||
import { AirportGame, catalogs, checkLanding, createState, transition, solveAirport, replay, validateAirportLevel, uniquenessIssues, SIDES, boardRadius, apronDistance, waitingPose, boardAt, trayHull, rimCrossing, bayNormal, TRAY_PAD } from '../labs/airport/core.js';
|
import { AirportGame, catalogs, checkLanding, createState, transition, solveAirport, replay, validateAirportLevel, uniquenessIssues, SIDES, boardRadius, apronDistance, waitingPose, boardAt, trayHull, rimCrossing, bayNormal, TRAY_PAD, visualSide, visualSideFromPoint } from '../labs/airport/core.js';
|
||||||
import { LEVELS } from '../labs/airport/levels.js';
|
import { LEVELS } from '../labs/airport/levels.js';
|
||||||
import { requestReward } from '../labs/airport/rewards.js';
|
import { requestReward } from '../labs/airport/rewards.js';
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ test('frozen levels have unique routes at every side/orientation and exact budge
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
test('waiting aprons sit on the board outline flat sides, outside every cell',()=>{
|
test('waiting aprons sit on the board outline flat sides, outside every cell',()=>{
|
||||||
assert.deepEqual(SIDES,['上方','右上方','右下方','下方','左下方','左上方']);
|
assert.deepEqual(SIDES,['右上方','右方','右下方','左下方','左方','左上方']);
|
||||||
assert.equal(boardRadius(hexagon(2)),2);
|
assert.equal(boardRadius(hexagon(2)),2);
|
||||||
assert.equal(boardRadius(hexagon(3)),3);
|
assert.equal(boardRadius(hexagon(3)),3);
|
||||||
for(let side=0;side<6;side++){
|
for(let side=0;side<6;side++){
|
||||||
@@ -66,6 +66,36 @@ test('same-side aircraft stack outward along the flat-side normal, never tangent
|
|||||||
previous=dist;
|
previous=dist;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
test('each boundary port belongs to exactly one visual flat, matching the waiting bay',()=>{
|
||||||
|
const cells=hexagon(2),keys=new Set(cells.map(c=>`${c.q},${c.r}`));
|
||||||
|
const seen=new Map();
|
||||||
|
for(const cell of cells)for(let d=0;d<6;d++){
|
||||||
|
if(keys.has(`${cell.q+DIRS[d][0]},${cell.r+DIRS[d][1]}`))continue;
|
||||||
|
const p=position(cell.q,cell.r),n=PORTS[d],side=visualSide(cell.q,cell.r,d);
|
||||||
|
assert.ok(side>=0&&side<=5,`port ${cell.q},${cell.r} dir ${d}`);
|
||||||
|
assert.equal(side,visualSideFromPoint(p.x+n.x,p.y+n.y));
|
||||||
|
const bay=bayNormal(side),port={x:p.x+n.x,y:p.y+n.y};
|
||||||
|
const ang=Math.atan2(port.y,port.x),bang=Math.atan2(bay.y,bay.x);
|
||||||
|
let delta=ang-bang;while(delta>Math.PI)delta-=Math.PI*2;while(delta<-Math.PI)delta+=Math.PI*2;
|
||||||
|
assert.ok(Math.abs(delta)<=Math.PI/6+1e-9,`port ${cell.q},${cell.r} dir ${d} must sit in side ${side}'s 60° wedge`);
|
||||||
|
if(!seen.has(side))seen.set(side,0);seen.set(side,seen.get(side)+1);
|
||||||
|
}
|
||||||
|
assert.equal(seen.size,6,'all six flats receive at least one port');
|
||||||
|
});
|
||||||
|
test('a waiting plane cannot enter from a neighbouring visual edge',()=>{
|
||||||
|
const cells=hexagon(2).map(c=>({...c,tracks:[3,4,5,0,1,2]}));
|
||||||
|
const level={id:'edge',name:'edge',cells,stands:[{id:'P1',q:0,r:-2,color:8}],arrivals:[{id:'in1',color:8,side:0}],originals:[],moveLimit:10,reward:{steps:5,maxClaims:2}};
|
||||||
|
const routes=catalogs(level)[0].arrivals.filter(r=>r.color===8);
|
||||||
|
const occupied=new Set(routes.map(r=>r.side));
|
||||||
|
const home=routes.map(r=>r.side).find(s=>routes.filter(r=>r.side===s).length===1);
|
||||||
|
const other=[0,1,2,3,4,5].find(s=>!occupied.has(s));
|
||||||
|
assert.equal(typeof other,'number','need a visual edge this stand does not open onto');
|
||||||
|
const state=createState(level);state.planes[0].side=other;
|
||||||
|
assert.equal(checkLanding(level,state,'in1').status,'no-route');
|
||||||
|
state.planes[0].side=home;
|
||||||
|
assert.equal(checkLanding(level,state,'in1').status,'land');
|
||||||
|
assert.equal(checkLanding(level,state,'in1').side,home);
|
||||||
|
});
|
||||||
test('landing is the exact reverse of centre-out geometry and retains inward heading',()=>{
|
test('landing is the exact reverse of centre-out geometry and retains inward heading',()=>{
|
||||||
const level=fixture(),state=createState(level),route=checkLanding(level,state,'in1');
|
const level=fixture(),state=createState(level),route=checkLanding(level,state,'in1');
|
||||||
assert.equal(route.status,'land');assert.equal(route.dir,3);
|
assert.equal(route.status,'land');assert.equal(route.dir,3);
|
||||||
@@ -179,8 +209,8 @@ test('teaching arc: rotation connects the runway and blockers must be cleared',(
|
|||||||
// L3: the off-board plane is not connected at start; one rotation opens the runway.
|
// L3: the off-board plane is not connected at start; one rotation opens the runway.
|
||||||
const l3=LEVELS[2];let s3=createState(l3);
|
const l3=LEVELS[2];let s3=createState(l3);
|
||||||
assert.equal(checkLanding(l3,s3,'in1').status,'no-route');
|
assert.equal(checkLanding(l3,s3,'in1').status,'no-route');
|
||||||
s3=transition(l3,s3,{type:'rotate',direction:-1}).state;
|
const left=transition(l3,s3,{type:'rotate',direction:-1}).state,right=transition(l3,s3,{type:'rotate',direction:1}).state;
|
||||||
assert.equal(checkLanding(l3,s3,'in1').status,'land');
|
assert.ok(checkLanding(l3,left,'in1').status==='land'||checkLanding(l3,right,'in1').status==='land');
|
||||||
// L4: an original blocks a runway at the start; the proven replay rotates and clears a blocker.
|
// L4: an original blocks a runway at the start; the proven replay rotates and clears a blocker.
|
||||||
const l4=LEVELS[3];const s4=createState(l4);
|
const l4=LEVELS[3];const s4=createState(l4);
|
||||||
assert.ok(l4.arrivals.some(a=>checkLanding(l4,s4,a.id).status==='blocked'));
|
assert.ok(l4.arrivals.some(a=>checkLanding(l4,s4,a.id).status==='blocked'));
|
||||||
|
|||||||
Reference in New Issue
Block a user