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:
@@ -4,6 +4,7 @@ import { EXPERIENCE_CAMPAIGN, EXPERIENCE_LEVELS } from './levels-v04.js';
|
||||
import { PRODUCTION_CAMPAIGN, PRODUCTION_LEVELS } from './levels-v05.js';
|
||||
import { TUTORIAL_LEVELS } from './tutorial-levels.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`.
|
||||
// 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,
|
||||
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 function campaignVersion(id) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -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.
|
||||
const MACARON_DISSOLVE_MS=640;
|
||||
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.
|
||||
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;
|
||||
@@ -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;};
|
||||
})();
|
||||
// Macaron: cell tints follow concentric rings from the board centre, so they stay put through every rotation.
|
||||
const ringTint=(()=>{
|
||||
if(!macaron)return ()=>materials.board;
|
||||
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 ringIndex=(()=>{
|
||||
if(!macaron)return ()=>0;
|
||||
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;}}
|
||||
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.
|
||||
// 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;};
|
||||
@@ -95,7 +96,7 @@ export function drawScene(p,level,tiles,view,state={}) {
|
||||
// styles overlap slightly to avoid antialiasing seams.
|
||||
for(const cell of sorted){
|
||||
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);
|
||||
}
|
||||
// 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)) {
|
||||
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,
|
||||
// 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);
|
||||
// Macaron: the token fills its brick, so the rails run under it and stay visible right up to its white rim.
|
||||
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);}
|
||||
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();
|
||||
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) {
|
||||
const blocked=route.status!=='exit';
|
||||
if(!blocked){
|
||||
|
||||
Reference in New Issue
Block a user