feat(levels): boards whose interior is packed and whose rim is left empty

The game has never had a board you simply clear out, and the request for one
turned out to need a construction of its own rather than a denser deep lock.
Filling cells at random can never work: a packed board has nothing that can
move — measured over forty random fills of a 37-cell board, zero pieces free in
all six orientations, every time, because a piece steps to the next cell before
it can leave and on a full board that cell is taken.

So packBoard builds backwards. Start empty, add only a piece that could leave
right now, and the placement order reversed is a removal order — solvability
becomes a property of the construction. Which cell to fill next decides
everything: at random it stalls near half, while filling the most constrained
cell first takes a 19-cell interior to 96% and fills it outright.

The rim is deliberately empty. Those pieces leave first and ask nothing, and
clearing from the rim inward gives a packed board its own difficulty curve
inside one level — the outer cells read easily, and it tightens as it opens.

Two things were tried against the ceiling and failed, and are recorded as such:
depth-first backtracking made it worse on both counts (65-78% filled against
greedy's 89%, at 2000-3000 seconds a board) because recomputing every cell's
legal directions per node spends the budget three levels deep. And the audit's
dead-turn gate could not be trusted here at all — on a packed board its breadth
search truncates, so it certified levels the rule bot then walked through three
blind turns in a row. The pool is filtered by playing it instead: a full pass
takes one second and does not lie.

The campaign is 32 packed levels to 18 deep locks, alternating, with the closing
six at a completely full interior. Same rule bot, both editions: worst blind
streak 1 either way, dead taps zero, and half again as much to do per level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-15 00:23:18 +08:00
co-authored by Claude Opus 5
parent de485c0c34
commit 7607f48cb1
11 changed files with 1263242 additions and 36 deletions
+981788
View File
File diff suppressed because it is too large Load Diff
+214368
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,11 +1,11 @@
[ [
{ "name": "起步 · 小盘看清规则", "count": 6, "rotations": [3], "maxDensity": 0.28 }, { "name": "起步 · 小满盘", "count": 6, "packed": true, "maxCells": 37 },
{ "name": "清空的手感", "count": 6, "rotations": [3], "minDensity": 0.36 }, { "name": "深锁初见", "count": 4, "rotations": [3], "packed": false, "maxDensity": 0.28 },
{ "name": "变奏 · 深一点", "count": 5, "rotations": [4, 5] }, { "name": "满盘 · 由外向内", "count": 7, "packed": true },
{ "name": "再来一盘满的", "count": 7, "rotations": [3], "minDensity": 0.40 }, { "name": "变奏 · 深一点", "count": 5, "rotations": [4, 5], "packed": false },
{ "name": "中段 · 稀而绕", "count": 6, "rotations": [3], "maxDensity": 0.30 }, { "name": "大满盘", "count": 7, "packed": true, "minCells": 61 },
{ "name": "变奏 · 深锁", "count": 5, "rotations": [4, 5] }, { "name": "中段 · 稀而绕", "count": 4, "rotations": [3], "packed": false, "maxDensity": 0.30 },
{ "name": "后段 · 满盘", "count": 7, "rotations": [3], "minDensity": 0.42 }, { "name": "满盘 · 转得更深", "count": 6, "packed": true, "minRotations": 3 },
{ "name": "收束前的深锁", "count": 4, "rotations": [4, 5] }, { "name": "收束前的深锁", "count": 5, "rotations": [4, 5], "packed": false },
{ "name": "终章 · 最满的一盘", "count": 4, "rotations": [3], "minDensity": 0.44 } { "name": "终章 · 最满的几盘", "count": 6, "packed": true, "minFill": 1 }
] ]
+36 -8
View File
@@ -58,7 +58,14 @@ const pool = String(args.pool ?? 'bench/pool.json,bench/pool-easy.json').split('
return Array.isArray(raw) ? raw : raw.levels; return Array.isArray(raw) ? raw : raw.levels;
} catch { return []; } } catch { return []; }
}); });
const family = id => id.startsWith('trail-') ? 'v05' : id.replace('proto-d5-', '').replace(/-r\d+-s\d+-d\d+$/, ''); // The route grammar, dug out of the prototype id. It must survive every suffix
// the sweep adds: when placement density became a swept dimension the ids grew
// a -p8 and this stopped matching, so every level became its own family and the
// cap — the guard against one grammar filling the campaign — silently did
// nothing. Read the field when it is there and fall back to trimming.
const family = level => level.prototype?.family
?? (String(level.id).startsWith('trail-') ? 'v05'
: String(level.id).replace('proto-d5-', '').replace(/-r\d+-s\d+(-[a-z]\d+)*$/, ''));
const scored = []; const scored = [];
const seen = new Set(); const seen = new Set();
@@ -69,12 +76,15 @@ for (const level of pool) {
const p = level.prototype ?? {}; const p = level.prototype ?? {};
const s = p.stats ?? {}; const s = p.stats ?? {};
const show = p.show ?? showStats(level); const show = p.show ?? showStats(level);
// Packed boards do not carry deep-lock statistics — lockedShare is 1 by
// definition and there is no per-turn release — so the score falls back to
// what they do have rather than reading a missing field as zero.
const score = WEIGHTS.rotations * (s.rotations ?? 3) const score = WEIGHTS.rotations * (s.rotations ?? 3)
+ WEIGHTS.pieces * (s.pieces ?? level.tiles.length) + WEIGHTS.pieces * (s.pieces ?? level.tiles.length)
+ WEIGHTS.lockedShare * (s.lockedShare ?? 0) + WEIGHTS.lockedShare * (s.lockedShare ?? 0)
+ WEIGHTS.monkeyAPT * (p.monkeyActionsPerPiece ?? 0) + WEIGHTS.monkeyAPT * (p.monkeyActionsPerPiece ?? 0)
+ WEIGHTS.cells * level.cells.length; + WEIGHTS.cells * level.cells.length;
scored.push({ level, score, family: family(level.id), show, stats: s, contrast: p.contrast?.contrast ?? 0 }); scored.push({ level, score, family: family(level), show, stats: s, contrast: p.contrast?.contrast ?? 0 });
} }
scored.sort((a, b) => a.score - b.score); scored.sort((a, b) => a.score - b.score);
@@ -125,18 +135,36 @@ const spread = (rows, want) => {
const shortfall = []; const shortfall = [];
if (PLAN) { if (PLAN) {
const density = row => row.level.tiles.length / row.level.cells.length; const density = row => row.level.tiles.length / row.level.cells.length;
for (const seg of PLAN) { const matches = seg => scored.filter(r => {
const want = Number(seg.count); const p = r.level.prototype ?? {};
const rows = scored.filter(r => { // Packed boards are a different construction, not a denser deep lock: the
// rim is left empty and the interior filled, so the level carries its own
// difficulty curve from the outside in. A segment asks for one kind or the
// other outright rather than inferring it from density.
if (seg.packed != null && Boolean(p.packed) !== seg.packed) return false;
if (seg.minFill != null && (p.fill ?? 0) < seg.minFill) return false;
if (seg.minRotations != null && (r.stats.rotations ?? 0) < seg.minRotations) return false;
if (seg.minCells != null && r.level.cells.length < seg.minCells) return false;
if (seg.maxCells != null && r.level.cells.length > seg.maxCells) return false;
if (seg.rotations && !seg.rotations.includes(r.stats.rotations)) return false; if (seg.rotations && !seg.rotations.includes(r.stats.rotations)) return false;
if (seg.minDensity != null && density(r) < seg.minDensity) return false; if (seg.minDensity != null && density(r) < seg.minDensity) return false;
if (seg.maxDensity != null && density(r) > seg.maxDensity) return false; if (seg.maxDensity != null && density(r) > seg.maxDensity) return false;
return true; return true;
}); });
const got = spread(rows, want); // Fill the scarcest segment first. Filling in reading order lets the roomy
picked.push(...got); // segments spend the family cap before a tight one is reached: the closing
if (got.length < want) shortfall.push({ depth: seg.name ?? '段', want, got: got.length, pool: rows.length }); // segment, which asks for a completely full interior, got 1 of its 6 that way
// while 44 candidates sat unused behind a cap two other segments had filled.
const order = PLAN.map((seg, index) => ({ seg, index, supply: matches(seg).length }))
.sort((a, b) => a.supply / a.seg.count - b.supply / b.seg.count);
const bySegment = new Map();
for (const { seg, index } of order) {
const got = spread(matches(seg), Number(seg.count));
bySegment.set(index, got);
if (got.length < seg.count) shortfall.push({ depth: seg.name ?? '段', want: seg.count, got: got.length, pool: matches(seg).length + got.length });
} }
// Played in the order the plan was written, whatever order they were chosen in.
for (let i = 0; i < PLAN.length; i++) picked.push(...(bySegment.get(i) ?? []));
if (picked.length < COUNT) picked.push(...spread(scored, COUNT - picked.length)); if (picked.length < COUNT) picked.push(...spread(scored, COUNT - picked.length));
} else if (CURVE) { } else if (CURVE) {
// Deepest first: the deep end is the scarce one, so it gets to pick before a // Deepest first: the deep end is the scarce one, so it gets to pick before a
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
// Harvest boards with a piece on every cell.
//
// node scripts/sweep-packed.mjs [--seeds=40] [--seedFrom=0] [--radii=3,4]
// [--minRotations=2] [--minFill=0.85] [--out=bench/pool-packed.json]
//
// A packed board is the plainest form of what this game is, and until now the
// project had none: the deep-lock construction tops out near a fifth pieces.
// See src/packed-board.js for why filling at random cannot work.
//
// The outermost ring is left empty on purpose. Those pieces leave first and ask
// nothing, and clearing from the rim inward is what gives a packed board its own
// difficulty curve inside a single level — the outer cells are easy to read, and
// the board tightens as it opens. The empty ring is also the runway everything
// departs along, and it makes the fill reach further.
//
// The gates here are deliberately not the deep-lock ones. A packed board pays
// out as a cascade — peel the rim, watch the middle open — so it is judged on
// whether the board is actually full, whether clearing it needs the dial at all,
// and whether the drawing is quiet enough to read. Rotation depth is not asked
// for: removal in this game is monotonic, so a board empties into more and more
// freedom and the requirement collapses on its own. These are the levels that
// decompress, not the ones that puzzle.
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { createRouteBoard, ROUTE_FAMILIES } from '../src/route-grammar.js';
import { packBoard } from '../src/packed-board.js';
import { deadTurnAudit, showStats, trackShape } from '../src/deep-lock.js';
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, ...v] = a.replace(/^--/, '').split('='); return [k, v.length ? v.join('=') : true]; }));
const SEEDS = Number(args.seeds ?? 40);
const SEED_FROM = Number(args.seedFrom ?? 0);
const RADII = String(args.radii ?? '3,4').split(',').map(Number);
const MIN_ROTATIONS = Number(args.minRotations ?? 2);
const MAX_CROSS = Number(args.maxCrossPerCell ?? 1.0);
const MIN_FILL = Number(args.minFill ?? 0.85);
const MAX_DEAD_TURNS = Number(args.maxDeadTurns ?? 1);
const OUT = resolve(args.out ?? 'bench/pool-packed.json');
const rows = [];
const tally = { tried: 0, filled: 0, turned: 0, quiet: 0, lively: 0 };
for (const { id: family } of ROUTE_FAMILIES) for (const radius of RADII) {
for (let seed = SEED_FROM; seed < SEED_FROM + SEEDS; seed++) {
tally.tried++;
const base = createRouteBoard(seed, { family, radius, fill: 'curve' });
const packed = packBoard(base, seed, { turnEvery: 3, restarts: 30, minRotations: MIN_ROTATIONS, skipRing: true });
if (packed.filled < MIN_FILL || !packed.solvable) continue;
tally.filled++;
if (packed.rotations < MIN_ROTATIONS) continue; // clearable by tapping alone
tally.turned++;
const shape = trackShape(packed.level);
if (shape.crossPerCell > MAX_CROSS) continue;
tally.quiet++;
// The one deep-lock gate that does carry over: a turn that opens nothing is
// boring on any board. Skipping it cost the first packed campaign its
// dead-turn record — the rule bot's worst blind streak went from 1 to 3.
const audit = deadTurnAudit(packed.level);
if (audit.maxDeadTurns > MAX_DEAD_TURNS) continue;
tally.lively++;
const level = packed.level;
level.id = `packed-${family}-r${radius}-s${seed}`;
level.name = `满盘 · ${family}`;
const show = showStats(level);
level.prototype = {
family, radius, seed, packed: true,
stats: { rotations: packed.rotations, pieces: level.tiles.length, lockedShare: 1, minRelease: null },
audit, shape, show,
fill: packed.filled, density: packed.density, buildTurns: packed.turns,
};
rows.push(level);
}
}
const q = (a, p) => [...a].sort((x, y) => x - y)[Math.floor(a.length * p)];
console.log(`试了 ${tally.tried} 个盘面 → 内部填充≥${Math.round(MIN_FILL*100)}% 的 ${tally.filled} → 需要转≥${MIN_ROTATIONS} 的 ${tally.turned} → 交叉/格≤${MAX_CROSS} 的 ${tally.quiet} → 连续空转≤${MAX_DEAD_TURNS} 的 ${tally.lively}`);
if (rows.length) {
console.log(`旋转次数 ${JSON.stringify(rows.reduce((m, l) => (m[l.prototype.stats.rotations] = (m[l.prototype.stats.rotations] || 0) + 1, m), {}))}`);
console.log(`家族 ${JSON.stringify(rows.reduce((m, l) => (m[l.prototype.family] = (m[l.prototype.family] || 0) + 1, m), {}))}`);
console.log(`格子数 ${JSON.stringify(rows.reduce((m, l) => (m[l.cells.length] = (m[l.cells.length] || 0) + 1, m), {}))}`);
console.log(`内部填满 100% 的 ${rows.filter(l => l.prototype.fill >= 1).length} 关 · 棋子数中位 ${q(rows.map(l => l.tiles.length), .5)}`);
console.log(`交叉/格 中位 ${q(rows.map(l => l.prototype.shape.crossPerCell), .5).toFixed(2)} · 直线占比 中位 ${(q(rows.map(l => l.prototype.shape.straightShare), .5) * 100).toFixed(0)}%`);
const runs = rows.map(l => l.prototype.show?.openingRunMs ?? 0);
console.log(`开局连锁 中位 ${(q(runs, .5) / 1000).toFixed(1)}s · 开局可点 中位 ${q(rows.map(l => l.prototype.show?.openingChoices ?? 0), .5)} 枚`);
}
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify({ generated: 'sweep-packed', count: rows.length, levels: rows }, null, 1));
console.log(`已写入 ${OUT}(${rows.length} 关)`);
+6
View File
@@ -4,6 +4,7 @@ import { EXPERIENCE_CAMPAIGN, EXPERIENCE_LEVELS } from './levels-v04.js';
import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from './levels-v05.js'; import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from './levels-v05.js';
import { TUTORIAL_LEVELS } from './tutorial-levels.js'; import { TUTORIAL_LEVELS } from './tutorial-levels.js';
import { C50_CAMPAIGN, C50_LEVELS } from './levels-c50.js'; import { C50_CAMPAIGN, C50_LEVELS } from './levels-c50.js';
import { C50B_CAMPAIGN, C50B_LEVELS } from './levels-c50b.js';
// Public layout versions are distinct from the level JSON schema's `version: 1`. // Public layout versions are distinct from the level JSON schema's `version: 1`.
// Historical layouts stay intact; all editions share the current game rules. // Historical layouts stay intact; all editions share the current game rules.
@@ -38,6 +39,11 @@ export const CAMPAIGN_VERSIONS = [
chapters:C50_CAMPAIGN.chapters, chapterSize:C50_CAMPAIGN.chapterSize, levels:C50_LEVELS, chapters:C50_CAMPAIGN.chapters, chapterSize:C50_CAMPAIGN.chapterSize, levels:C50_LEVELS,
legacyStorageKeys:[], legacyStorageKeys:[],
}, },
{
id:C50B_CAMPAIGN.id, name:C50B_CAMPAIGN.name, description:C50B_CAMPAIGN.description,
chapters:C50B_CAMPAIGN.chapters, chapterSize:C50B_CAMPAIGN.chapterSize, levels:C50B_LEVELS,
legacyStorageKeys:[],
},
]; ];
export const DEFAULT_CAMPAIGN_VERSION = 'v0.5'; export const DEFAULT_CAMPAIGN_VERSION = 'v0.5';
export function campaignVersion(id) { export function campaignVersion(id) {
File diff suppressed because one or more lines are too long
+127
View File
@@ -0,0 +1,127 @@
import { key, random, rotateBoard, solveRotations, trace } from './core.js';
// A board with a piece on every cell. Clearing one is the plainest version of
// what this game is — peel the outside, watch the inside open up — and no level
// in the project has ever offered it: the deep-lock construction tops out
// around a fifth pieces, and filling the cells at random never works, because a
// packed board has nothing that can move. Measured over forty random fills of a
// 37-cell board: zero pieces free, in all six orientations, every time. A piece
// steps to the next cell along its track before it can leave, and on a full
// board that cell is always occupied.
//
// So a packed board is built backwards. Start empty and add only a piece that
// could leave *right now*, given what is already down. Placing P onto board S
// means the player, standing on S ∪ {P}, can remove P and reach S — so the
// placement order reversed is a removal order, and solvability is a property of
// the construction rather than something to search for afterwards.
//
// Which cell to fill next is the whole difficulty. Picking at random among the
// legal placements fills about half the board and stalls, because the roomy
// cells get spent early and the awkward ones are left with no legal direction.
// Filling the most constrained cell first — fewest working directions, ties
// broken at random — takes a 19-cell board to 96% on average and fills 37-cell
// boards outright.
// Distance from the middle, in hexes.
const ringOf = (q, r) => (Math.abs(q) + Math.abs(q + r) + Math.abs(r)) / 2;
// Which cells the fill is allowed to use. Leaving the outermost ring empty is
// not a concession — the rim pieces are the ones that leave first and ask
// nothing of the player, and an empty rim is the runway everything else departs
// along. It also makes the fill easier, so bigger boards become reachable.
function fillable(base, skipRing) {
if (!skipRing) return base.cells;
const edge = Math.max(...base.cells.map(c => ringOf(c.q, c.r)));
return base.cells.filter(c => ringOf(c.q, c.r) < edge);
}
export function packBoard(base, seed, { maxTurns = 60, restarts = 25, turnEvery = 4, minRotations = 3, skipRing = false } = {}) {
let best = null;
for (let attempt = 0; attempt < restarts; attempt++) {
const run = attemptPack(base, seed * 1000 + attempt, maxTurns, turnEvery, skipRing);
const better = !best || run.filled > best.filled
|| (run.filled === best.filled && run.rotations > best.rotations);
if (better) best = run;
// A full board that needs no turning is not this game — it is a board you
// clear by tapping, which the design brief rules out and the monkey test
// catches. Keep looking until the removal order actually needs the dial.
if (best.filled >= 1 && best.rotations >= minRotations) break;
}
return best;
}
// Greedy filling has a ceiling that more attempts cannot lift: on a 61-cell
// board it stops at 89% of the interior whether given 30 restarts or 80, because
// the last few cells are left with no direction that can reach an edge through
// what is already down.
//
// Depth-first backtracking was tried against that ceiling and was worse on both
// counts — 65-78% filled, at 2000-3000 seconds per board. Recomputing every
// cell's legal directions at every node costs a trace per cell per orientation,
// so the node budget is spent a few levels deep and the search never reaches the
// end it was added to reach. The ceiling stands; boards that want a completely
// full interior use a radius the greedy fill can finish.
function attemptPack(base, seed, maxTurns, turnEvery, skipRing) {
const rng = random(seed * 2654435761 % 2147483647 + 11);
const cells = fillable(base, skipRing);
const tiles = [], taken = new Set();
let orientation = 0, turns = 0, stuck = 0, placedSinceTurn = 0;
while (taken.size < cells.length && turns < maxTurns) {
// Every legal placement, grouped by cell, so the scarcest cell can go first.
const byCell = [];
for (const cell of cells) {
if (taken.has(key(cell.q, cell.r))) continue;
const options = [];
for (let dir = 0; dir < 6; dir++) {
const piece = { id: `t${tiles.length}`, q: cell.q, r: cell.r, dir, color: tiles.length % 6 };
const board = rotateBoard({ ...base, tiles: [...tiles, piece] }, orientation);
if (trace(board, board.tiles[board.tiles.length - 1], board.tiles).status === 'exit') options.push(piece);
}
if (options.length) byCell.push(options);
}
if (!byCell.length) {
// Nothing fits at this orientation; the player would have turned here too.
orientation = (orientation + 1) % 6;
turns++;
if (++stuck > 6) break; // all six offer nothing: done
continue;
}
stuck = 0;
// Turn on purpose every so often, not only when cornered. Placing every
// piece at one orientation builds a board that comes apart without ever
// touching the dial: the first packed board built this way solved in zero
// rotations. Turning mid-build puts pieces behind an orientation the player
// has to find.
if (turnEvery > 0 && tiles.length && tiles.length % turnEvery === 0 && placedSinceTurn > 0) {
orientation = (orientation + (rng() < .5 ? 1 : 5)) % 6;
turns++;
placedSinceTurn = 0;
continue;
}
byCell.sort((a, b) => a.length - b.length);
const scarcest = byCell.filter(options => options.length === byCell[0].length);
const cell = scarcest[Math.floor(rng() * scarcest.length)];
const piece = cell[Math.floor(rng() * cell.length)];
tiles.push(piece);
taken.add(key(piece.q, piece.r));
placedSinceTurn++;
}
// Ids run in play order — the last piece placed is the first that can leave.
const ordered = [...tiles].reverse().map((t, i) => ({ ...t, id: `t${i}` }));
const level = { ...base, tiles: ordered, moveLimit: ordered.length };
const solution = solveRotations(level);
return {
level,
filled: cells.length ? taken.size / cells.length : 0,
density: base.cells.length ? taken.size / base.cells.length : 0,
turns,
rotations: solution.solvable ? solution.rotations : -1,
solvable: solution.solvable,
};
}
// Construction guarantees a removal order exists, but the solver is what the
// game and every gate rely on, so a packed board is not shipped until it agrees.
export function verifyPacked(level) {
const solution = solveRotations(level);
return { solvable: solution.solvable, rotations: solution.rotations, exhausted: solution.exhausted };
}
+9 -18
View File
@@ -11,8 +11,6 @@ export const COLORS=PIGMENTS.map(p=>p.color);
// Macaron's exit ends in a bubble dissolve that needs more time than the shared 180 ms effect window. // Macaron's exit ends in a bubble dissolve that needs more time than the shared 180 ms effect window.
const MACARON_DISSOLVE_MS=640; const MACARON_DISSOLVE_MS=640;
export function exitDuration(route,artStyle){const timing=exitTiming(route);return artStyle==='macaron'?timing.travelMs+MACARON_DISSOLVE_MS:timing.duration;} export function exitDuration(route,artStyle){const timing=exitTiming(route);return artStyle==='macaron'?timing.travelMs+MACARON_DISSOLVE_MS:timing.duration;}
// Four-point sparkle star used by the macaron win celebration.
const sparkle=(x,y,r)=>Array.from({length:8},(_,j)=>{const b=j*Math.PI/4-Math.PI/2,rr=j%2?r*.38:r;return {x:x+Math.cos(b)*rr,y:y+Math.sin(b)*rr};});
// Scene commands remain engine-independent. sprite() is an optional atlas adapter. // Scene commands remain engine-independent. sprite() is an optional atlas adapter.
export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,scale=1) { export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,scale=1) {
const r=size*(tile.artStyle==='paper'?.88:tile.artStyle==='signpost'?.90:tile.artStyle==='macaron'?.90:TILE_RADIUS)*scale; const r=size*(tile.artStyle==='paper'?.88:tile.artStyle==='signpost'?.90:tile.artStyle==='macaron'?.90:TILE_RADIUS)*scale;
@@ -58,12 +56,15 @@ export function drawScene(p,level,tiles,view,state={}) {
return cell=>{const t=Math.max(0,Math.min(1,(state.intro-order.get(key(cell.q,cell.r))*.55)/.45)),u=t-1;return t<=0?0:1+2.2*u*u*u+1.2*u*u;}; return cell=>{const t=Math.max(0,Math.min(1,(state.intro-order.get(key(cell.q,cell.r))*.55)/.45)),u=t-1;return t<=0?0:1+2.2*u*u*u+1.2*u*u;};
})(); })();
// Macaron: cell tints follow concentric rings from the board centre, so they stay put through every rotation. // Macaron: cell tints follow concentric rings from the board centre, so they stay put through every rotation.
const ringTint=(()=>{ const ringIndex=(()=>{
if(!macaron)return ()=>materials.board; if(!macaron)return ()=>0;
const rings=materials.rings||[materials.board],cq=level.cells.reduce((a,c)=>a+c.q,0)/level.cells.length,cr=level.cells.reduce((a,c)=>a+c.r,0)/level.cells.length; const cq=level.cells.reduce((a,c)=>a+c.q,0)/level.cells.length,cr=level.cells.reduce((a,c)=>a+c.r,0)/level.cells.length;
let origin=level.cells[0],best=Infinity;for(const c of level.cells){const d=Math.hypot(c.q-cq,c.r-cr);if(d<best){best=d;origin=c;}} let origin=level.cells[0],best=Infinity;for(const c of level.cells){const d=Math.hypot(c.q-cq,c.r-cr);if(d<best){best=d;origin=c;}}
return cell=>{const dq=cell.q-origin.q,dr=cell.r-origin.r;return rings[Math.max(Math.abs(dq),Math.abs(dr),Math.abs(dq+dr))%rings.length];}; return cell=>{const dq=cell.q-origin.q,dr=cell.r-origin.r;return Math.max(Math.abs(dq),Math.abs(dr),Math.abs(dq+dr));};
})(); })();
const ringTint=cell=>{const rings=materials.rings||[materials.board];return rings[ringIndex(cell)%rings.length];};
// Macaron: when the board is cleared, a ripple runs from the centre outward: each brick swells 8% and brightens, then settles.
const pulse=cell=>{if(!macaron||!(state.celebrate<1))return 0;const u=(state.celebrate-ringIndex(cell)*.07)/.45;return u>0&&u<1?Math.sin(u*Math.PI):0;};
// Macaron: cells along a previewed or travelling route blush in the token's own tint; a blocked cell blushes orange-cream. // Macaron: cells along a previewed or travelling route blush in the token's own tint; a blocked cell blushes orange-cream.
// A tapped route carries its tile; a traced preview does not, so the tile is looked up at the route's first step. // A tapped route carries its tile; a traced preview does not, so the tile is looked up at the route's first step.
const routeFill=route=>{const start=route.steps?.[0],tile=route.tile||(start&&tiles.find(t=>t.q===start.q&&t.r===start.r));return (tile&&visualTile(tile).fill)||materials.track;}; const routeFill=route=>{const start=route.steps?.[0],tile=route.tile||(start&&tiles.find(t=>t.q===start.q&&t.r===start.r));return (tile&&visualTile(tile).fill)||materials.track;};
@@ -95,7 +96,7 @@ export function drawScene(p,level,tiles,view,state={}) {
// styles overlap slightly to avoid antialiasing seams. // styles overlap slightly to avoid antialiasing seams.
for(const cell of sorted){ for(const cell of sorted){
const {x,y}=center(cell,view); const {x,y}=center(cell,view);
if(macaron){const sc=cellScale(cell);if(sc>.02)base.polygon(roundedHex(x,y,size*.90*sc,.16),blushCells.get(key(cell.q,cell.r))||ringTint(cell));continue;} if(macaron){const sc=cellScale(cell),ripple=pulse(cell),fill=blushCells.get(key(cell.q,cell.r))||ringTint(cell);if(sc>.02)base.polygon(roundedHex(x,y,size*.90*sc*(1+.08*ripple),.16),ripple>0?tint(fill,.35*ripple):fill);continue;}
base.polygon(hexPoints(x,y,paper?size*.97:size+.2),materials.board); base.polygon(hexPoints(x,y,paper?size*.97:size+.2),materials.board);
} }
// Cell boundaries stay beneath the connected track endpoints. Macaron separates cells with white gutters instead. // Cell boundaries stay beneath the connected track endpoints. Macaron separates cells with white gutters instead.
@@ -154,9 +155,7 @@ export function drawScene(p,level,tiles,view,state={}) {
} }
for(const tile of [...tiles].filter(t=>!movingIds.has(t.id)).sort((a,b)=>center(a,view).y-center(b,view).y)) { for(const tile of [...tiles].filter(t=>!movingIds.has(t.id)).sort((a,b)=>center(a,view).y-center(b,view).y)) {
const c=center(tile,view),sc=cellScale(tile); const c=center(tile,view),sc=cellScale(tile);
// Macaron: a tray-white seat under every resting token covers the rail crossings in the gutters around its brick, // Macaron: the token fills its brick, so the rails run under it and stay visible right up to its white rim.
// so no rail end shows at the token's edge; when the token leaves, the continuous rail reappears.
if(macaron&&sc>=.3)p.polygon(roundedHex(c.x,c.y,size*1.12*sc,.16),materials.tray);
if(tile.id===state.hint) { if(tile.id===state.hint) {
if(macaron){p.polygon(roundedHex(c.x,c.y,size*1.02,.16),materials.hintGlow);p.polygon(roundedHex(c.x,c.y,size*.94,.16),materials.hint);} if(macaron){p.polygon(roundedHex(c.x,c.y,size*1.02,.16),materials.hintGlow);p.polygon(roundedHex(c.x,c.y,size*.94,.16),materials.hint);}
else if(signpost){const ring=Array.from({length:48},(_,i)=>({x:c.x+Math.cos(i/48*Math.PI*2)*size*1.1,y:c.y+Math.sin(i/48*Math.PI*2)*size*1.1}));p.line([...ring,ring[0]],state.theme.route?.hint||THEME.reward,Math.max(2.5,size*.08));} else if(signpost){const ring=Array.from({length:48},(_,i)=>({x:c.x+Math.cos(i/48*Math.PI*2)*size*1.1,y:c.y+Math.sin(i/48*Math.PI*2)*size*1.1}));p.line([...ring,ring[0]],state.theme.route?.hint||THEME.reward,Math.max(2.5,size*.08));}
@@ -176,14 +175,6 @@ export function drawScene(p,level,tiles,view,state={}) {
} }
if(signpost)drawPath(); if(signpost)drawPath();
afterTokens?.(); afterTokens?.();
// Macaron: when the last token has left, twelve candy stars and a honey ring burst out of the tray before the result opens.
if(macaron&&state.celebrate!==undefined&&state.celebrate<1){
const t=state.celebrate,ease=1-Math.pow(1-t,3),cs=level.cells.map(c=>center(c,view)),cx=cs.reduce((a,c)=>a+c.x,0)/cs.length,cy=cs.reduce((a,c)=>a+c.y,0)/cs.length;
const reach=Math.max(...cs.map(c=>Math.hypot(c.x-cx,c.y-cy)))+size*1.3,colors=(state.theme.palette||[]).map(i=>state.theme.pigments[i].color);
const ringR=reach*(.45+.6*ease),ring=Array.from({length:48},(_,i)=>({x:cx+Math.cos(i/48*Math.PI*2)*ringR,y:cy+Math.sin(i/48*Math.PI*2)*ringR}));
p.line([...ring,ring[0]],materials.hint+Math.round((1-t)*200).toString(16).padStart(2,'0'),Math.max(1,size*.14*(1-t)));
for(let i=0;i<12;i++){const a=i/12*Math.PI*2-Math.PI/2+ease*.4,d=reach*(.3+.8*ease),r=size*.34*(1-smoothMotion(t)),sx=cx+Math.cos(a)*d,sy=cy+Math.sin(a)*d;if(r>0)p.polygon(sparkle(sx,sy,r),colors.length?colors[i%colors.length]:materials.hint);}
}
for(const {route,progress,target,phase,movingPath,contact,impactAngle} of motions) { for(const {route,progress,target,phase,movingPath,contact,impactAngle} of motions) {
const blocked=route.status!=='exit'; const blocked=route.status!=='exit';
if(!blocked){ if(!blocked){
+1 -1
View File
@@ -16,7 +16,7 @@ test('every selectable edition retains its own level count, order and chapters',
// Historical editions keep their exact shape; new ones are appended, because // Historical editions keep their exact shape; new ones are appended, because
// other tests address the originals by position. // other tests address the originals by position.
assert.deepEqual(CAMPAIGN_VERSIONS.map(c=>[c.id,c.levels.length,c.chapterSize,c.chapters.length]),[ assert.deepEqual(CAMPAIGN_VERSIONS.map(c=>[c.id,c.levels.length,c.chapterSize,c.chapters.length]),[
['v0.1',24,8,3],['v0.2',48,6,8],['v0.3',48,6,8],['v0.4',20,5,4],['v0.5',1000,50,20],['c50',50,10,5], ['v0.1',24,8,3],['v0.2',48,6,8],['v0.3',48,6,8],['v0.4',20,5,4],['v0.5',1000,50,20],['c50',50,10,5],['c50b',50,10,5],
]); ]);
assert.equal(campaignVersion('v0.1').levels[0].name,'转一转,路就通了'); assert.equal(campaignVersion('v0.1').levels[0].name,'转一转,路就通了');
assert.equal(campaignVersion('v0.1').levels.at(-1).name,'六角大满贯'); assert.equal(campaignVersion('v0.1').levels.at(-1).name,'六角大满贯');