From c99716b1d52569e3a8970f6e62a7152ff8830d4f Mon Sep 17 00:00:00 2001 From: toshiocn <48112360+toshiocn@users.noreply.github.com> Date: Mon, 21 Sep 2026 17:55:06 +0800 Subject: [PATCH] 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. --- docs/airport-design.md | 14 +- labs/airport/README.md | 25 +- labs/airport/app.js | 72 +- labs/airport/core.js | 26 +- labs/airport/index.html | 2 +- labs/airport/levels.js | 2098 ++++++++++++++--------------- scripts/design-airport-levels.mjs | 10 +- tests/airport.test.js | 38 +- 8 files changed, 1157 insertions(+), 1128 deletions(-) diff --git a/docs/airport-design.md b/docs/airport-design.md index 94fb6d5..6de544b 100644 --- a/docs/airport-design.md +++ b/docs/airport-design.md @@ -74,9 +74,9 @@ ### 2.5 场外停机坪 -来机停靠在棋盘六条边外侧,不悬停在正北等罗盘方位。现工程是平顶六边形:六条边是上方、右上方、右下方、下方、左下方、左上方。竖直的左侧、右侧是尖顶六边形才有的边,对应本棋盘的左右两个顶角,不是入口。 +来机停靠在棋盘轮廓六条平边外侧(右上方、右方、右下方、左下方、左方、左上方),不悬停在正北等罗盘方位。这六条平边就是入口边;角上的端口只归其中一边,不能两边同时进。 -同侧可有多架、多色来机,同色来机也可分处不同侧。停机坪上的排列不绑定精确入口。每个边界入口和出口必须唯一归属某一条边;角部不同端口各有唯一归属。画面分区和规则归属必须一致。 +同侧可有多架、多色来机,同色来机也可分处不同侧。停机坪上的排列不绑定精确入口。每个边界入口和出口按端口位置落入的 60° 平边扇区唯一归属;画面上的停机坪边和规则上的进场边必须是同一条。来机只能从当前所在平边的开口进场,不能跨边。 ## 3. 起飞、降落与碰撞 @@ -91,7 +91,7 @@ ### 3.2 场外来机降落 -点击后检查当前所在侧的全部边界入口。合法降落需同时满足:入口属于当前侧、轨道通往预设同色机位、机位未占用或预占、路径有效、完整运动过程不碰撞任何在场飞机的六角占位。 +点击后只检查当前所在平边的边界入口,不把邻边开口算作本侧通路。合法降落需同时满足:入口属于当前平边、轨道通往预设同色机位、机位未占用或预占、路径有效、完整运动过程不碰撞任何在场飞机的六角占位。 | 结果 | 行为 | |---|---| @@ -171,10 +171,10 @@ 盘边跑道与停机坪航线(2026-09-21): -- 每条板边轨道不再在糖砖内收口,而是沿端口法线延伸到托盘外缘,并在开口处画一道垂直门槛,让"开到盘边的出口"和"中途断掉的线"一眼可分。 -- 托盘外、六个侧的法线方向画固定浅色停机坪航线,等待机停在其上;航线不随底盘旋转,跑道与门槛随底盘旋转。 -- 当某架来机的当前侧在当前朝向下接得上同色机位时,用中性强调色(马卡龙 `hint`,不是飞机或机位颜色)把"飞机 → 盘边开口 → 机位"整条通道点亮,并在开口处加一圈标记、保留进场箭头;被挡时阻挡物仍用 `blocked` 标红。常显跑道负责说明"这里有没有开口",高亮负责说明"这架能不能进"。 -- 停机坪机位仍按其原有平边法线摆放(与入口法线差半步),不做位移;错位由高亮通道跨越,不靠移动机位。 +- 每条板边轨道不再在糖砖内收口,而是沿端口法线延伸到托盘外缘(圆头收回半个线宽,不伸出棋盘)。 +- 托盘外、六个侧的法线方向画固定浅色停机坪航线,等待机停在其上;航线不随底盘旋转,跑道随底盘旋转。 +- 当某架来机的当前侧在当前朝向下接得上同色机位时,用中性强调色(马卡龙 `hint`,不是飞机或机位颜色)点亮盘内通道,开口加圈、保留进场箭头;被挡时阻挡物仍用 `blocked` 标红。跑道线始终裁在托盘内。常显跑道负责说明"这里有没有开口",高亮负责说明"这架能不能进"。 +- 停机坪机位仍按其原有平边法线摆放(与入口法线差半步),不做位移。 ### 5.2 颜色与身份 diff --git a/labs/airport/README.md b/labs/airport/README.md index fac213b..6d0e649 100644 --- a/labs/airport/README.md +++ b/labs/airport/README.md @@ -1,6 +1,6 @@ # 六角机场实验室 -独立的起降调度原型。前三关是 19 格教学;视觉使用原工程马卡龙方案。不覆盖主线关卡或存档。 +独立的起降调度原型。前四关是 19 格教学;视觉使用原工程马卡龙方案。不覆盖主线关卡或存档。 ## 运行 @@ -8,24 +8,25 @@ 点击地面飞机起飞,点击场外停机坪上的飞机降落;左右转每次 60°。成功、失败与旋转都消耗一次。灰色轨道不分颜色;来机匹配彩色专用机位。P 标记表示已停好的来机。它再次起飞后,停到实际出口那条边的停机坪,仍需重新停好。 -板边轨道一直画到托盘外缘并带一道门槛,托盘外的浅色停机坪航线固定不动。当等待机在当前朝向下接得上同色机位时,整条"飞机 → 盘边开口 → 机位"的通道用中性强调色点亮;旋转时通道会亮灭,据此判断能否进场。 +板边轨道一直画到托盘外缘,不伸出棋盘。托盘外的浅色停机坪航线固定不动。当等待机在当前朝向下接得上同色机位时,盘内通道用中性强调色点亮;旋转时通道会亮灭,据此判断能否进场。 ## 关卡证据 -所有数据冻结在 `levels.js`。`solution` 是最优免费解,`experience` 是制作意图回放;第三关后者演示来机阻挡及再次起飞换侧。 +所有数据冻结在 `levels.js`。`solution` 是最优免费解,`experience` 是制作意图回放。 | 关卡 | 格子 | 最少操作 | 初始额度 | 体验回放 | 先清全盘再接机最少操作 | 最优解保留原有飞机 | |---|---:|---:|---:|---:|---:|---:| -| 留一点空间 | 19 | 5 | 8 | 5 | 10 | 3 | -| 同色,不同方向 | 19 | 7 | 10 | 7 | 11 | 4 | -| 让路与换侧 | 19 | 6 | 13 | 11 | 16 | 6 | -| 更大的停机坪 | 37 | 8 | 12 | 8 | 18 | 6 | -| 绕一圈再接入 | 37 | 11 | 15 | 11 | 17 | 3 | -| 空港全景 | 61 | 10 | 14 | 10 | 20 | 8 | +| 留一点空间 | 19 | 1 | 3 | 1 | 4 | 3 | +| 同色才停靠 | 19 | 1 | 3 | 1 | 4 | 3 | +| 转一下再接入 | 19 | 2 | 4 | 2 | 5 | 3 | +| 清障再接入 | 19 | 5 | 7 | 5 | 11 | 4 | +| 更大的停机坪 | 37 | 6 | 10 | 6 | 19 | 7 | +| 绕一圈再接入 | 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 npm run test:airport @@ -35,7 +36,7 @@ npm run airport:practice npm run build:airport ``` -教学三关生成器使用固定种子 9172026。练习关生成器使用 9172027,追加两个 37 格关与一个 61 格关,不改写前三关。不会在玩家进入页面时随机生成。 +教学四关生成器使用固定种子 9172026。练习关生成器使用 9172027,追加两个 37 格关与一个 61 格关,不改写前四关。不会在玩家进入页面时随机生成。 ## 结构 diff --git a/labs/airport/app.js b/labs/airport/app.js index 4df9b49..09dce02 100644 --- a/labs/airport/app.js +++ b/labs/airport/app.js @@ -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 {} } 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; -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; const worker = new Worker(new URL('./solver-worker.js', import.meta.url), { type: 'module' }); worker.onmessage = ({ data }) => { @@ -56,7 +56,7 @@ function loadLevel(next) { for (const dialog of document.querySelectorAll('dialog[open]')) dialog.close(); $('level-title').textContent = `第 ${index + 1} 关`; $('level-name').textContent = game.level.name; - notice(game.level.lesson); sync(); diagnose(); + notice(game.level.lesson); resize(); diagnose(); } function waitingLocation(plane, state = game.state) { 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); if (!route) continue; // no entrance / no connected runway from this edge 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; } @@ -130,14 +130,14 @@ function perform(action) { if (result.status === 'land') { const start = waitingLocation(plane, before); // 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 }; points = [start, approach, ...result.points]; kind = 'land'; sound('land'); notice('停靠成功。留意新飞机是否挡住其他通路。'); } else if (result.status === 'exit') { points = [...result.points]; kind = 'exit'; sound('exit'); 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)); notice(`来机已起飞,将在${SIDES[result.side]}停机坪等待。`); } 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);} return [...lower.slice(0,-1),...upper.slice(0,-1)]; } +function insideHull(poly,x,y){for(let i=0;ikey(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 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, - // so an off-board plane can be read as joining one specific opening. The reach rotates - // with the base, exactly like the track it belongs to. - const gateReach=(point,dir)=>{ + // A boundary runway reaches the tray outline instead of stopping inside the brick, + // but never leaves the board: the round cap is pulled back by half the stroke. + const rimEnd=(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}); - 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){ const p=position(cell.q,cell.r); 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 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;}); - if(exterior(cell,d)){const g=gateReach(points[0],d);if(g){points.unshift(g);gates.push(g);}} - if(exterior(cell,b)){const g=gateReach(points.at(-1),b);if(g){points.push(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=rimEnd(points.at(-1),b);if(g)points.push(g);} 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(); // Stands, like the aircraft, never turn with the base. 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); } - // Runway read: when a waiting plane's edge connects to a same-colour stand, draw the - // whole channel from the plane, across the tray edge, to the stand. The highlight is a - // neutral hint colour (never the plane colour: the runway does not carry the match), - // a ripple at the plane and a ring at the opening, and an arrow gliding inbound. A - // blocker still ripples red. This only guides; the player still performs the landing. + // Runway read: when a waiting plane's edge connects to a same-colour stand, light the + // channel in a neutral hint colour from the plane, across the apron, onto the board + // path. Board runways themselves stay inside the tray. A ripple at the plane, a ring + // at the opening, and an inbound arrow still guide; a blocker ripples red. if (!motions.length && !rotation && !document.querySelector('dialog[open]')) { const hints = approachHints(), T = now / 1000; for (const h of hints) { - const pts = h.points; - line(pts, materials.hintGlow, .19); - line(pts, materials.hint, .085); - 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); - const mouth = pts[1] || h.from; - ctx.save(); ctx.globalAlpha = .9; ctx.beginPath(); ctx.arc(mouth.x, mouth.y, .34, 0, Math.PI * 2); - ctx.strokeStyle = materials.hint; ctx.lineWidth = .10; ctx.stroke(); ctx.restore(); + 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.hint, .085); + 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); + const ring = onboard[0] || along(pts, .08); + 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(); + } for (const phase of [T % 1, (T + .5) % 1]) { 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); @@ -395,7 +407,9 @@ function draw(now){ } function resize(){ 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}; canvas.width=Math.round(rect.width*dpr);canvas.height=Math.round(rect.height*dpr);if(game)sync(); } diff --git a/labs/airport/core.js b/labs/airport/core.js index 6919662..639afe7 100644 --- a/labs/airport/core.js +++ b/labs/airport/core.js @@ -2,8 +2,8 @@ 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'; -// Flat-top edges in screen space. Compass names hid that waiting bays sit on the six sides. -export const SIDES = ['上方', '右上方', '右下方', '下方', '左下方', '左上方']; +// Six visual flats of the pointy-top outline (bay normals at -60/0/60/120/180/-120). +export const SIDES = ['右上方', '右方', '右下方', '左下方', '左方', '左上方']; 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)); } @@ -25,6 +25,19 @@ export function waitingPose(plane, planes, radius) { const d = apronDistance(radius); 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 // 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) { const route = trace(board, plane, [], true); if (route.status !== 'exit') return { ...route, points: pathPoints(route) }; - const side = route.steps.at(-1).exit; - return { ...route, side, points: pathPoints(route) }; + const last = route.steps.at(-1), exitDir = last.exit; + return { ...route, exitDir, side: visualSide(last.q, last.r, exitDir), points: pathPoints(route) }; } export function catalogs(level) { if (catalogCache.has(level)) return catalogCache.get(level); @@ -91,7 +104,7 @@ export function catalogs(level) { if (departure.status !== 'exit') continue; arrivals.push({ 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(), signature: `${stand.id}:${dir}:${departure.side}`, }); @@ -138,8 +151,9 @@ export function checkDeparture(level, state, planeId) { const points = route.points; // Preserve the original long exit-ray collision check, without drawing a // 24-unit flight across the screen. + const out = route.exitDir ?? route.side; 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; const hit = sweepPath(swept, obstacles(state, planeId)); if (hit) return { ...route, status: 'blocked', plane: { ...plane }, blocker: hit.blocker, collisionPoint: hit.point, travelPoints: hit.points }; diff --git a/labs/airport/index.html b/labs/airport/index.html index 0a2bd79..52a9212 100644 --- a/labs/airport/index.html +++ b/labs/airport/index.html @@ -29,7 +29,7 @@

选择关卡

接齐来机即可过关,无需清空棋盘。

-

起降调度

  1. 点击地面飞机,沿机头方向尝试起飞。
  2. 场外飞机停在棋盘六条边外侧的停机坪:上方、右上方、右下方、下方、左下方、左上方。
  3. 点击停机坪上的飞机,从它所在那条边寻找同色机位。
  4. 轨道不分颜色,飞机和机位按颜色匹配。
  5. 再次起飞的来机,会停到实际出口那条边的停机坪。
  6. 每次起降尝试和旋转都消耗一次操作。

标有 P 的是已经停靠的来机。所有来机同时停好,才算完成。

独立实验版 · 马卡龙视觉 · 进度单独保存

+

起降调度

  1. 点击地面飞机,沿机头方向尝试起飞。
  2. 场外飞机停在棋盘六条平边外侧的停机坪:右上方、右方、右下方、左下方、左方、左上方。
  3. 点击停机坪上的飞机,只从它所在那条边寻找同色机位,不能跨边进场。
  4. 轨道不分颜色,飞机和机位按颜色匹配。
  5. 再次起飞的来机,会停到实际出口那条边的停机坪。
  6. 每次起降尝试和旋转都消耗一次操作。

标有 P 的是已经停靠的来机。所有来机同时停好,才算完成。

独立实验版 · 马卡龙视觉 · 进度单独保存

全部到位

diff --git a/labs/airport/levels.js b/labs/airport/levels.js index 185bed9..93a451e 100644 --- a/labs/airport/levels.js +++ b/labs/airport/levels.js @@ -620,12 +620,12 @@ export const LEVELS = [ "q": -2, "r": 1, "tracks": [ - 3, - 4, - 5, - 0, 1, - 2 + 0, + 3, + 2, + 5, + 4 ] }, { @@ -727,18 +727,6 @@ export const LEVELS = [ { "q": 0, "r": 1, - "tracks": [ - 3, - 4, - 5, - 0, - 1, - 2 - ] - }, - { - "q": 0, - "r": 2, "tracks": [ 1, 0, @@ -748,6 +736,18 @@ export const LEVELS = [ 4 ] }, + { + "q": 0, + "r": 2, + "tracks": [ + 2, + 4, + 0, + 5, + 1, + 3 + ] + }, { "q": 1, "r": -2, @@ -764,10 +764,10 @@ export const LEVELS = [ "q": 1, "r": -1, "tracks": [ - 3, - 2, 1, 0, + 3, + 2, 5, 4 ] @@ -778,10 +778,10 @@ export const LEVELS = [ "tracks": [ 1, 0, - 3, - 2, 5, - 4 + 4, + 3, + 2 ] }, { @@ -800,10 +800,10 @@ export const LEVELS = [ "q": 2, "r": -2, "tracks": [ - 1, - 0, 3, 2, + 1, + 0, 5, 4 ] @@ -836,8 +836,8 @@ export const LEVELS = [ "stands": [ { "id": "P1", - "q": -1, - "r": 1, + "q": 1, + "r": 0, "color": 8 } ], @@ -845,28 +845,28 @@ export const LEVELS = [ { "id": "in1", "color": 8, - "side": 3 + "side": 2 } ], "originals": [ { "id": "out1", "q": 0, - "r": -2, - "dir": 4, + "r": 1, + "dir": 3, "color": 2 }, { "id": "out2", - "q": -2, - "r": 1, - "dir": 4, + "q": 2, + "r": -2, + "dir": 3, "color": 5 }, { "id": "out3", - "q": 2, - "r": -2, + "q": 0, + "r": 2, "dir": 3, "color": 3 } @@ -940,10 +940,10 @@ export const LEVELS = [ "q": -2, "r": 2, "tracks": [ - 2, - 3, - 0, 1, + 0, + 3, + 2, 5, 4 ] @@ -952,26 +952,26 @@ export const LEVELS = [ "q": -1, "r": -1, "tracks": [ - 5, - 3, - 4, 1, + 0, + 3, 2, + 5, + 4 + ] + }, + { + "q": -1, + "r": 0, + "tracks": [ + 5, + 4, + 3, + 2, + 1, 0 ] }, - { - "q": -1, - "r": 0, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, { "q": -1, "r": 1, @@ -1000,12 +1000,12 @@ export const LEVELS = [ "q": 0, "r": -2, "tracks": [ - 1, - 0, - 3, + 4, 2, + 1, 5, - 4 + 0, + 3 ] }, { @@ -1060,10 +1060,10 @@ export const LEVELS = [ "q": 1, "r": -2, "tracks": [ - 3, - 2, 1, 0, + 3, + 2, 5, 4 ] @@ -1096,12 +1096,12 @@ export const LEVELS = [ "q": 1, "r": 1, "tracks": [ - 1, - 0, + 5, + 4, 3, 2, - 5, - 4 + 1, + 0 ] }, { @@ -1144,14 +1144,14 @@ export const LEVELS = [ "stands": [ { "id": "P1", - "q": 0, - "r": -1, + "q": -1, + "r": 1, "color": 8 }, { "id": "P2", "q": 0, - "r": 1, + "r": -1, "color": 0 } ], @@ -1159,101 +1159,109 @@ export const LEVELS = [ { "id": "in1", "color": 8, - "side": 0 + "side": 3 }, { "id": "in2", "color": 0, - "side": 2 + "side": 0 } ], "originals": [ { "id": "out1", - "q": -2, - "r": 0, + "q": 0, + "r": -2, "dir": 0, "color": 2 }, { "id": "out2", - "q": -1, - "r": 2, - "dir": 2, + "q": 2, + "r": -2, + "dir": 0, "color": 5 }, { "id": "out3", "q": -1, - "r": 1, - "dir": 4, + "r": 0, + "dir": 0, "color": 3 }, { "id": "out4", - "q": 1, + "q": 2, "r": -1, "dir": 0, "color": 4 }, { "id": "out5", - "q": 2, - "r": -2, + "q": -1, + "r": -1, "dir": 0, "color": 6 } ], - "moveLimit": 6, + "moveLimit": 7, "reward": { "steps": 5, "maxClaims": 3 }, "lesson": "跑道要连通,通路也不能被飞机挡住。先移开阻挡,再旋转接入两架来机。", "solution": [ + { + "type": "tap", + "id": "out1" + }, + { + "type": "tap", + "id": "in2" + }, + { + "type": "rotate", + "direction": -1 + }, { "type": "rotate", "direction": -1 }, - { - "type": "tap", - "id": "out2" - }, { "type": "tap", "id": "in1" - }, - { - "type": "tap", - "id": "in2" } ], "experience": [ + { + "type": "tap", + "id": "out1" + }, + { + "type": "tap", + "id": "in2" + }, + { + "type": "rotate", + "direction": -1 + }, { "type": "rotate", "direction": -1 }, - { - "type": "tap", - "id": "out2" - }, { "type": "tap", "id": "in1" - }, - { - "type": "tap", - "id": "in2" } ], "evidence": { - "minimumActions": 4, + "minimumActions": 5, "remainingOriginals": 4, "clearFirst": { "status": "solved", - "minimumActions": 8 + "minimumActions": 11 }, - "experienceActions": 4, + "experienceActions": 5, "uniqueEmptyBoard": true } }, @@ -1265,10 +1273,10 @@ export const LEVELS = [ "q": -3, "r": 0, "tracks": [ - 1, - 0, - 3, 2, + 3, + 0, + 1, 5, 4 ] @@ -1277,12 +1285,12 @@ export const LEVELS = [ "q": -3, "r": 1, "tracks": [ - 1, - 0, - 3, 2, 5, - 4 + 0, + 4, + 3, + 1 ] }, { @@ -1303,10 +1311,10 @@ export const LEVELS = [ "tracks": [ 1, 0, - 4, - 5, + 3, 2, - 3 + 5, + 4 ] }, { @@ -1324,18 +1332,6 @@ export const LEVELS = [ { "q": -2, "r": 0, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -2, - "r": 1, "tracks": [ 2, 5, @@ -1345,16 +1341,28 @@ export const LEVELS = [ 1 ] }, + { + "q": -2, + "r": 1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, { "q": -2, "r": 2, "tracks": [ - 2, - 4, - 0, - 5, 1, - 3 + 0, + 3, + 2, + 5, + 4 ] }, { @@ -1397,12 +1405,12 @@ export const LEVELS = [ "q": -1, "r": 0, "tracks": [ - 3, - 5, - 4, + 1, 0, + 3, 2, - 1 + 5, + 4 ] }, { @@ -1421,12 +1429,12 @@ export const LEVELS = [ "q": -1, "r": 2, "tracks": [ - 3, - 2, - 1, - 0, 5, - 4 + 3, + 4, + 1, + 2, + 0 ] }, { @@ -1493,12 +1501,12 @@ export const LEVELS = [ "q": 0, "r": 1, "tracks": [ - 1, - 0, - 3, 2, + 4, + 0, 5, - 4 + 1, + 3 ] }, { @@ -1519,10 +1527,10 @@ export const LEVELS = [ "tracks": [ 1, 0, - 3, - 2, + 4, 5, - 4 + 2, + 3 ] }, { @@ -1541,10 +1549,10 @@ export const LEVELS = [ "q": 1, "r": -2, "tracks": [ - 1, - 0, - 3, 2, + 3, + 0, + 1, 5, 4 ] @@ -1577,12 +1585,12 @@ export const LEVELS = [ "q": 1, "r": 1, "tracks": [ - 4, - 2, 1, - 5, 0, - 3 + 3, + 2, + 5, + 4 ] }, { @@ -1649,12 +1657,12 @@ export const LEVELS = [ "q": 2, "r": 1, "tracks": [ - 1, - 0, - 3, - 2, 5, - 4 + 2, + 1, + 4, + 3, + 0 ] }, { @@ -1672,6 +1680,18 @@ export const LEVELS = [ { "q": 3, "r": -2, + "tracks": [ + 2, + 5, + 0, + 4, + 3, + 1 + ] + }, + { + "q": 3, + "r": -1, "tracks": [ 1, 0, @@ -1681,42 +1701,30 @@ export const LEVELS = [ 4 ] }, - { - "q": 3, - "r": -1, - "tracks": [ - 3, - 4, - 5, - 0, - 1, - 2 - ] - }, { "q": 3, "r": 0, "tracks": [ - 4, - 5, + 1, + 0, 3, 2, - 0, - 1 + 5, + 4 ] } ], "stands": [ { "id": "P1", - "q": -1, + "q": 0, "r": -1, "color": 8 }, { "id": "P2", - "q": -1, - "r": 2, + "q": 1, + "r": 0, "color": 8 } ], @@ -1729,21 +1737,21 @@ export const LEVELS = [ { "id": "in2", "color": 8, - "side": 4 + "side": 1 } ], "originals": [ { "id": "out1", - "q": 3, - "r": 0, - "dir": 1, + "q": 1, + "r": 2, + "dir": 4, "color": 2 }, { "id": "out2", - "q": 0, - "r": -3, + "q": -2, + "r": 2, "dir": 0, "color": 3 }, @@ -1751,46 +1759,46 @@ export const LEVELS = [ "id": "out3", "q": 3, "r": -3, - "dir": 0, + "dir": 2, "color": 4 }, { "id": "out4", - "q": 0, - "r": 3, - "dir": 0, + "q": 3, + "r": -2, + "dir": 4, "color": 5 }, { "id": "out5", - "q": -2, + "q": 3, "r": 0, - "dir": 0, + "dir": 2, "color": 1 }, { "id": "out6", "q": 2, - "r": -2, - "dir": 5, + "r": 1, + "dir": 3, "color": 6 }, { "id": "out7", - "q": -2, - "r": -1, - "dir": 0, + "q": -1, + "r": 2, + "dir": 5, "color": 7 }, { "id": "out8", - "q": 3, - "r": -2, - "dir": 2, + "q": -3, + "r": 3, + "dir": 0, "color": 9 } ], - "moveLimit": 12, + "moveLimit": 10, "reward": { "steps": 5, "maxClaims": 3 @@ -1799,19 +1807,11 @@ export const LEVELS = [ "solution": [ { "type": "rotate", - "direction": -1 - }, - { - "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "tap", - "id": "out2" - }, - { - "type": "tap", - "id": "out7" + "id": "out5" }, { "type": "tap", @@ -1819,11 +1819,11 @@ export const LEVELS = [ }, { "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "tap", @@ -1833,19 +1833,11 @@ export const LEVELS = [ "experience": [ { "type": "rotate", - "direction": -1 - }, - { - "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "tap", - "id": "out2" - }, - { - "type": "tap", - "id": "out7" + "id": "out5" }, { "type": "tap", @@ -1853,11 +1845,11 @@ export const LEVELS = [ }, { "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "tap", @@ -1865,16 +1857,16 @@ export const LEVELS = [ } ], "evidence": { - "seedAttempt": 86, + "seedAttempt": 531, "radius": 3, "cells": 37, - "minimumActions": 8, - "remainingOriginals": 6, + "minimumActions": 6, + "remainingOriginals": 7, "clearFirst": { "status": "solved", - "minimumActions": 18 + "minimumActions": 19 }, - "experienceActions": 8, + "experienceActions": 6, "uniqueEmptyBoard": true } }, @@ -1910,12 +1902,12 @@ export const LEVELS = [ "q": -3, "r": 2, "tracks": [ - 4, - 3, - 5, 1, 0, - 2 + 3, + 2, + 5, + 4 ] }, { @@ -1934,12 +1926,12 @@ export const LEVELS = [ "q": -2, "r": -1, "tracks": [ - 1, - 0, - 3, - 2, 5, - 4 + 2, + 1, + 4, + 3, + 0 ] }, { @@ -1982,10 +1974,10 @@ export const LEVELS = [ "q": -2, "r": 3, "tracks": [ - 3, - 2, 1, 0, + 3, + 2, 5, 4 ] @@ -2041,18 +2033,6 @@ export const LEVELS = [ { "q": -1, "r": 2, - "tracks": [ - 5, - 4, - 3, - 2, - 1, - 0 - ] - }, - { - "q": -1, - "r": 3, "tracks": [ 1, 0, @@ -2062,6 +2042,18 @@ export const LEVELS = [ 4 ] }, + { + "q": -1, + "r": 3, + "tracks": [ + 2, + 5, + 0, + 4, + 3, + 1 + ] + }, { "q": 0, "r": -3, @@ -2078,12 +2070,12 @@ export const LEVELS = [ "q": 0, "r": -2, "tracks": [ - 4, - 3, - 5, 1, 0, - 2 + 3, + 2, + 5, + 4 ] }, { @@ -2102,113 +2094,41 @@ export const LEVELS = [ "q": 0, "r": 0, "tracks": [ - 5, - 3, - 4, - 1, 2, + 5, + 0, + 4, + 3, + 1 + ] + }, + { + "q": 0, + "r": 1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 0, + "r": 2, + "tracks": [ + 5, + 2, + 1, + 4, + 3, 0 ] }, - { - "q": 0, - "r": 1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 0, - "r": 2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, { "q": 0, "r": 3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": -3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": -2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": -1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": 0, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": 1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": 2, "tracks": [ 4, 2, @@ -2218,6 +2138,78 @@ export const LEVELS = [ 3 ] }, + { + "q": 1, + "r": -3, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": -2, + "tracks": [ + 5, + 3, + 4, + 1, + 2, + 0 + ] + }, + { + "q": 1, + "r": -1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": 0, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": 1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": 2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, { "q": 2, "r": -3, @@ -2294,24 +2286,24 @@ export const LEVELS = [ "q": 3, "r": -2, "tracks": [ - 1, - 0, - 3, - 2, 5, - 4 + 2, + 1, + 4, + 3, + 0 ] }, { "q": 3, "r": -1, "tracks": [ + 4, + 3, + 5, 1, 0, - 3, - 2, - 5, - 4 + 2 ] }, { @@ -2330,14 +2322,14 @@ export const LEVELS = [ "stands": [ { "id": "P1", - "q": 0, - "r": -1, + "q": -1, + "r": 1, "color": 8 }, { "id": "P2", "q": 0, - "r": 1, + "r": -1, "color": 0 } ], @@ -2345,73 +2337,73 @@ export const LEVELS = [ { "id": "in1", "color": 8, - "side": 5 + "side": 3 }, { "id": "in2", "color": 0, - "side": 2 + "side": 5 } ], "originals": [ { "id": "out1", - "q": 0, + "q": 1, "r": -3, - "dir": 4, + "dir": 0, "color": 2 }, { "id": "out2", - "q": -2, - "r": -1, - "dir": 0, + "q": 0, + "r": 1, + "dir": 2, "color": 3 }, { "id": "out3", - "q": 1, - "r": 0, - "dir": 4, + "q": 2, + "r": 1, + "dir": 1, "color": 4 }, { "id": "out4", - "q": -1, - "r": 0, - "dir": 0, + "q": -2, + "r": -1, + "dir": 5, "color": 5 }, { "id": "out5", - "q": -1, - "r": 2, - "dir": 5, + "q": 2, + "r": -1, + "dir": 2, "color": 1 }, { "id": "out6", - "q": -3, + "q": -1, "r": 3, - "dir": 0, + "dir": 3, "color": 6 }, { "id": "out7", - "q": 1, - "r": 1, - "dir": 3, + "q": 2, + "r": -2, + "dir": 5, "color": 7 }, { "id": "out8", - "q": 3, - "r": -3, - "dir": 4, + "q": 1, + "r": 1, + "dir": 2, "color": 9 } ], - "moveLimit": 15, + "moveLimit": 14, "reward": { "steps": 5, "maxClaims": 3 @@ -2422,45 +2414,41 @@ export const LEVELS = [ "type": "rotate", "direction": -1 }, - { - "type": "tap", - "id": "out2" - }, - { - "type": "tap", - "id": "out7" - }, { "type": "tap", "id": "out3" }, { "type": "tap", - "id": "in2" + "id": "out8" }, { "type": "rotate", - "direction": -1 - }, - { - "type": "rotate", - "direction": -1 - }, - { - "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "tap", - "id": "out1" + "id": "out2" }, { - "type": "tap", - "id": "out4" + "type": "rotate", + "direction": 1 }, { "type": "tap", "id": "in1" + }, + { + "type": "rotate", + "direction": 1 + }, + { + "type": "rotate", + "direction": 1 + }, + { + "type": "tap", + "id": "in2" } ], "experience": [ @@ -2468,58 +2456,54 @@ export const LEVELS = [ "type": "rotate", "direction": -1 }, - { - "type": "tap", - "id": "out2" - }, - { - "type": "tap", - "id": "out7" - }, { "type": "tap", "id": "out3" }, { "type": "tap", - "id": "in2" + "id": "out8" }, { "type": "rotate", - "direction": -1 - }, - { - "type": "rotate", - "direction": -1 - }, - { - "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "tap", - "id": "out1" + "id": "out2" }, { - "type": "tap", - "id": "out4" + "type": "rotate", + "direction": 1 }, { "type": "tap", "id": "in1" + }, + { + "type": "rotate", + "direction": 1 + }, + { + "type": "rotate", + "direction": 1 + }, + { + "type": "tap", + "id": "in2" } ], "evidence": { - "seedAttempt": 106, + "seedAttempt": 951, "radius": 3, "cells": 37, - "minimumActions": 11, - "remainingOriginals": 3, + "minimumActions": 10, + "remainingOriginals": 5, "clearFirst": { "status": "solved", - "minimumActions": 17 + "minimumActions": 15 }, - "experienceActions": 11, + "experienceActions": 10, "uniqueEmptyBoard": true } }, @@ -2531,10 +2515,10 @@ export const LEVELS = [ "q": -4, "r": 0, "tracks": [ - 2, - 3, - 0, 1, + 0, + 3, + 2, 5, 4 ] @@ -2543,11 +2527,11 @@ export const LEVELS = [ "q": -4, "r": 1, "tracks": [ - 3, - 4, - 5, - 0, 1, + 0, + 5, + 4, + 3, 2 ] }, @@ -2555,12 +2539,12 @@ export const LEVELS = [ "q": -4, "r": 2, "tracks": [ - 3, - 5, - 4, + 1, 0, + 3, 2, - 1 + 5, + 4 ] }, { @@ -2578,546 +2562,6 @@ export const LEVELS = [ { "q": -4, "r": 4, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -3, - "r": -1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -3, - "r": 0, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -3, - "r": 1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -3, - "r": 2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -3, - "r": 3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -3, - "r": 4, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -2, - "r": -2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -2, - "r": -1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -2, - "r": 0, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -2, - "r": 1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -2, - "r": 2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -2, - "r": 3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -2, - "r": 4, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -1, - "r": -3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -1, - "r": -2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -1, - "r": -1, - "tracks": [ - 1, - 0, - 4, - 5, - 2, - 3 - ] - }, - { - "q": -1, - "r": 0, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -1, - "r": 1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -1, - "r": 2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -1, - "r": 3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": -1, - "r": 4, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 0, - "r": -4, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 0, - "r": -3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 0, - "r": -2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 0, - "r": -1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 0, - "r": 0, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 0, - "r": 1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 0, - "r": 2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 0, - "r": 3, - "tracks": [ - 3, - 5, - 4, - 0, - 2, - 1 - ] - }, - { - "q": 0, - "r": 4, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": -4, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": -3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": -2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": -1, - "tracks": [ - 4, - 5, - 3, - 2, - 0, - 1 - ] - }, - { - "q": 1, - "r": 0, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": 1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": 2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 1, - "r": 3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 2, - "r": -4, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 2, - "r": -3, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 2, - "r": -2, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 2, - "r": -1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 2, - "r": 0, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 2, - "r": 1, - "tracks": [ - 1, - 0, - 3, - 2, - 5, - 4 - ] - }, - { - "q": 2, - "r": 2, "tracks": [ 4, 3, @@ -3127,6 +2571,546 @@ export const LEVELS = [ 2 ] }, + { + "q": -3, + "r": -1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -3, + "r": 0, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -3, + "r": 1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -3, + "r": 2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -3, + "r": 3, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -3, + "r": 4, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -2, + "r": -2, + "tracks": [ + 5, + 2, + 1, + 4, + 3, + 0 + ] + }, + { + "q": -2, + "r": -1, + "tracks": [ + 1, + 0, + 4, + 5, + 2, + 3 + ] + }, + { + "q": -2, + "r": 0, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -2, + "r": 1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -2, + "r": 2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -2, + "r": 3, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -2, + "r": 4, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -1, + "r": -3, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -1, + "r": -2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -1, + "r": -1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -1, + "r": 0, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -1, + "r": 1, + "tracks": [ + 5, + 2, + 1, + 4, + 3, + 0 + ] + }, + { + "q": -1, + "r": 2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -1, + "r": 3, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": -1, + "r": 4, + "tracks": [ + 1, + 0, + 4, + 5, + 2, + 3 + ] + }, + { + "q": 0, + "r": -4, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 0, + "r": -3, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 0, + "r": -2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 0, + "r": -1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 0, + "r": 0, + "tracks": [ + 1, + 0, + 4, + 5, + 2, + 3 + ] + }, + { + "q": 0, + "r": 1, + "tracks": [ + 4, + 5, + 3, + 2, + 0, + 1 + ] + }, + { + "q": 0, + "r": 2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 0, + "r": 3, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 0, + "r": 4, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": -4, + "tracks": [ + 1, + 0, + 4, + 5, + 2, + 3 + ] + }, + { + "q": 1, + "r": -3, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": -2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": -1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": 0, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": 1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": 2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 1, + "r": 3, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 2, + "r": -4, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 2, + "r": -3, + "tracks": [ + 4, + 5, + 3, + 2, + 0, + 1 + ] + }, + { + "q": 2, + "r": -2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 2, + "r": -1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 2, + "r": 0, + "tracks": [ + 5, + 3, + 4, + 1, + 2, + 0 + ] + }, + { + "q": 2, + "r": 1, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, + { + "q": 2, + "r": 2, + "tracks": [ + 1, + 0, + 3, + 2, + 5, + 4 + ] + }, { "q": 3, "r": -4, @@ -3143,12 +3127,12 @@ export const LEVELS = [ "q": 3, "r": -3, "tracks": [ - 2, - 4, - 0, - 5, 1, - 3 + 0, + 3, + 2, + 5, + 4 ] }, { @@ -3229,10 +3213,10 @@ export const LEVELS = [ "tracks": [ 1, 0, - 4, - 5, + 3, 2, - 3 + 5, + 4 ] }, { @@ -3270,7 +3254,7 @@ export const LEVELS = [ { "id": "P2", "q": 3, - "r": 0, + "r": -3, "color": 0 } ], @@ -3283,103 +3267,103 @@ export const LEVELS = [ { "id": "in2", "color": 0, - "side": 3 + "side": 1 } ], "originals": [ { "id": "out1", - "q": 1, - "r": 2, - "dir": 2, + "q": 2, + "r": -4, + "dir": 0, "color": 2 }, { "id": "out2", - "q": 3, - "r": -2, + "q": 4, + "r": -4, "dir": 1, "color": 3 }, { "id": "out3", - "q": 2, - "r": 2, - "dir": 3, + "q": 4, + "r": -1, + "dir": 2, "color": 4 }, { "id": "out4", - "q": 3, - "r": 1, - "dir": 3, + "q": 2, + "r": 2, + "dir": 2, "color": 5 }, { "id": "out5", - "q": -4, - "r": 2, - "dir": 4, + "q": -1, + "r": -3, + "dir": 0, "color": 1 }, { "id": "out6", - "q": -4, - "r": 0, + "q": -1, + "r": 4, "dir": 4, "color": 6 }, { "id": "out7", - "q": 4, - "r": -3, - "dir": 1, + "q": 1, + "r": 3, + "dir": 2, "color": 7 }, { "id": "out8", - "q": 4, + "q": -2, "r": -2, - "dir": 1, + "dir": 5, "color": 9 }, { "id": "out9", - "q": 3, - "r": -4, - "dir": 0, + "q": -4, + "r": 0, + "dir": 5, "color": 10 }, { "id": "out10", - "q": 1, - "r": 3, - "dir": 3, + "q": -3, + "r": 1, + "dir": 4, "color": 11 }, { "id": "out11", - "q": 4, - "r": -4, - "dir": 1, + "q": 3, + "r": 1, + "dir": 2, "color": 0 }, { "id": "out12", - "q": -3, - "r": 2, - "dir": 5, + "q": 4, + "r": -3, + "dir": 2, "color": 8 }, { "id": "out13", - "q": -4, - "r": 4, - "dir": 4, + "q": -1, + "r": -2, + "dir": 0, "color": 2 } ], - "moveLimit": 14, + "moveLimit": 12, "reward": { "steps": 5, "maxClaims": 3 @@ -3387,24 +3371,20 @@ export const LEVELS = [ "lesson": "61 格空港:原有飞机更多,先在人群里清出通路,再把两色来机各自接好。", "solution": [ { - "type": "tap", - "id": "out5" + "type": "rotate", + "direction": 1 }, { "type": "tap", - "id": "out6" + "id": "out2" }, { "type": "tap", - "id": "out10" + "id": "out9" }, { "type": "tap", - "id": "out3" - }, - { - "type": "tap", - "id": "out4" + "id": "out12" }, { "type": "tap", @@ -3412,15 +3392,11 @@ export const LEVELS = [ }, { "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "rotate", - "direction": -1 - }, - { - "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "tap", @@ -3429,24 +3405,20 @@ export const LEVELS = [ ], "experience": [ { - "type": "tap", - "id": "out5" + "type": "rotate", + "direction": 1 }, { "type": "tap", - "id": "out6" + "id": "out2" }, { "type": "tap", - "id": "out10" + "id": "out9" }, { "type": "tap", - "id": "out3" - }, - { - "type": "tap", - "id": "out4" + "id": "out12" }, { "type": "tap", @@ -3454,15 +3426,11 @@ export const LEVELS = [ }, { "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "rotate", - "direction": -1 - }, - { - "type": "rotate", - "direction": -1 + "direction": 1 }, { "type": "tap", @@ -3470,16 +3438,16 @@ export const LEVELS = [ } ], "evidence": { - "seedAttempt": 17, + "seedAttempt": 27, "radius": 4, "cells": 61, - "minimumActions": 10, - "remainingOriginals": 8, + "minimumActions": 8, + "remainingOriginals": 10, "clearFirst": { "status": "solved", - "minimumActions": 20 + "minimumActions": 18 }, - "experienceActions": 10, + "experienceActions": 8, "uniqueEmptyBoard": true } } diff --git a/scripts/design-airport-levels.mjs b/scripts/design-airport-levels.mjs index 956a920..5db062e 100644 --- a/scripts/design-airport-levels.mjs +++ b/scripts/design-airport-levels.mjs @@ -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); } const options = candidates(cells); 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++) { - if (s0.includes(side)) continue; - const d = opt.sides[1].includes(side) ? 1 : opt.sides[5].includes(side) ? -1 : 0; - if (!d) continue; + const at0 = cat[0].arrivals.some(r => r.side === side); + const turn = cat[1].arrivals.some(r => r.side === side) ? 1 : cat[5].arrivals.some(r => r.side === side) ? -1 : 0; + if (at0 || !turn) continue; const level = base(cells, [{ id: 'P1', q: opt.q, r: opt.r, color: 8 }], [{ id: 'in1', color: 8, side }]); if (uniquenessIssues(level).length) continue; const st0 = createState(level); st0.orientation = 0; diff --git a/tests/airport.test.js b/tests/airport.test.js index f096eae..bd7321c 100644 --- a/tests/airport.test.js +++ b/tests/airport.test.js @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { hexagon, trace, opposite, DIRS } from '../src/core.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 { 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',()=>{ - assert.deepEqual(SIDES,['上方','右上方','右下方','下方','左下方','左上方']); + assert.deepEqual(SIDES,['右上方','右方','右下方','左下方','左方','左上方']); assert.equal(boardRadius(hexagon(2)),2); assert.equal(boardRadius(hexagon(3)),3); 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; } }); +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',()=>{ const level=fixture(),state=createState(level),route=checkLanding(level,state,'in1'); 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. const l3=LEVELS[2];let s3=createState(l3); assert.equal(checkLanding(l3,s3,'in1').status,'no-route'); - s3=transition(l3,s3,{type:'rotate',direction:-1}).state; - assert.equal(checkLanding(l3,s3,'in1').status,'land'); + const left=transition(l3,s3,{type:'rotate',direction:-1}).state,right=transition(l3,s3,{type:'rotate',direction:1}).state; + 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. const l4=LEVELS[3];const s4=createState(l4); assert.ok(l4.arrivals.some(a=>checkLanding(l4,s4,a.id).status==='blocked'));