717 lines
29 KiB
JavaScript
717 lines
29 KiB
JavaScript
// Goo Tower — level bank generator.
|
|
//
|
|
// Wave 4 replaced the flat 12-level bank with a CURRICULUM: five named
|
|
// chapters of fifteen levels, built from blocks that introduce one mechanic,
|
|
// drill it, then combine it with what came before.
|
|
//
|
|
// A physics sandbox has no BFS solver, so levels are not random-filled the way
|
|
// Jell-o Monsters' are. Instead each level is instantiated from a HAND-DESIGNED
|
|
// TEMPLATE (tower, bridge, overhang, updraft, cogs, demolition, cliff…) with
|
|
// its parameters swept across a difficulty band, and then has to survive four
|
|
// gates:
|
|
//
|
|
// 1. CLEAN — settles without snapping a strand, losing goo, starting
|
|
// anything inside terrain, or starting two goo overlapping.
|
|
// 2. WINNABLE — the reference player in GooTowerAuto.js beats it. The
|
|
// gate is one-directional: if a naive builder can win, a
|
|
// human can. A failure means "redesign", never "impossible".
|
|
// 3. LOAD-BEARING — for enabling mechanics (updraft, wall-anchors), strip the
|
|
// element and the level must STOP being winnable. This is
|
|
// what keeps a "fan level" from being an ordinary level
|
|
// with a fan blowing somewhere irrelevant.
|
|
// 4. GENTLE — a teaching level may not open with an instant-fail: no
|
|
// hazard within reach of the starting structure.
|
|
//
|
|
// Levels 1-6 are the original hand-tuned teaching levels and are emitted
|
|
// VERBATIM, exactly as genPeggleLevels.js freezes Peggle's hand-tuned 1-25.
|
|
//
|
|
// Usage:
|
|
// node tools/genGooTower.js [seed] [outDir]
|
|
//
|
|
// Deterministic: same seed -> same bank.
|
|
|
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
createState, settlePhysics, insideSolid, gooType, liveStrands,
|
|
} from '../src/games/gootower/GooTowerLogic.js';
|
|
import { autoPlay } from '../src/games/gootower/GooTowerAuto.js';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x600d1e;
|
|
const outDir = process.argv[3] || join(__dirname, '..', 'assets', 'gamedata', 'gootower');
|
|
|
|
const W = 1600;
|
|
const H = 1000;
|
|
const GROUND = 850;
|
|
|
|
// ── Seeded RNG (mulberry32) ─────────────────────────────────────────────────
|
|
function mulberry32(seed) {
|
|
let a = seed >>> 0;
|
|
return function rng() {
|
|
a = (a + 0x6d2b79f5) >>> 0;
|
|
let t = a;
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
const lerp = (a, b, t) => a + (b - a) * t;
|
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
|
|
const rect = (x1, y1, x2, y2, kind = 'solid') => ({ kind, poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] });
|
|
const ground = (top = GROUND) => rect(0, top, W, H);
|
|
|
|
// Side walls. EVERY template includes these: a heap that topples and rolls off
|
|
// the edge of the world loses goo before the player has touched anything, and
|
|
// it is always the top row of the heap that goes -- which is exactly where the
|
|
// scarce goo types sit.
|
|
const bounds = () => [rect(-30, 0, 6, H), rect(W - 6, 0, W + 30, H)];
|
|
|
|
// A planted tripod: two pinned feet and a free apex, already triangulated so
|
|
// the player has something legal to attach to on frame one.
|
|
function footing(x, y, type = 'common') {
|
|
return {
|
|
balls: [
|
|
{ x: x - 31, y, type, pinned: true },
|
|
{ x: x + 31, y, type, pinned: true },
|
|
{ x, y: y - 54, type },
|
|
],
|
|
strands: [[2, 0], [2, 1], [0, 1]],
|
|
};
|
|
}
|
|
|
|
// A heap of loose goo. `mix` is [[type, count], ...]. Laid out in rows and kept
|
|
// clear of `avoid` (the footing's x): goo that starts inside other goo is
|
|
// ejected hard enough to snap its own strands.
|
|
function heap(mix, startX, surfaceY, { perRow = null, gap = 30, avoid = null } = {}) {
|
|
const out = [];
|
|
const total = mix.reduce((n, [, c]) => n + c, 0);
|
|
// Wide and low: a tall narrow heap topples and spills. Roughly 3 rows.
|
|
const cols = perRow || clamp(Math.round(total / 3), 6, 14);
|
|
// Interleave the types. Laid out in blocks, the rarest goo ends up in the
|
|
// top row -- the row most likely to roll away.
|
|
const order = [];
|
|
const pools = mix.map(([type, count]) => ({ type, left: count }));
|
|
while (pools.some((p) => p.left > 0)) {
|
|
for (const p of pools) {
|
|
if (p.left > 0) { order.push(p.type); p.left -= 1; }
|
|
}
|
|
}
|
|
let i = 0;
|
|
for (const [, count] of [[null, order.length]]) {
|
|
for (let k = 0; k < count; k += 1, i += 1) {
|
|
const type = order[k];
|
|
const row = Math.floor(i / cols);
|
|
const col = i % cols;
|
|
out.push({ x: startX + col * gap, y: surfaceY - 16 - row * 30, type });
|
|
}
|
|
}
|
|
// Shift the WHOLE heap clear of the footing rather than nudging individual
|
|
// balls: nudging piles two columns onto each other, and overlapping goo is
|
|
// ejected hard enough to snap strands.
|
|
if (avoid != null && out.length) {
|
|
const lo = Math.min(...out.map((b) => b.x));
|
|
const hi = Math.max(...out.map((b) => b.x));
|
|
const clearL = avoid - 110;
|
|
const clearR = avoid + 110;
|
|
if (hi > clearL && lo < clearR) {
|
|
const shift = (lo + hi) / 2 < avoid ? clearL - hi : clearR - lo;
|
|
for (const b of out) b.x += shift;
|
|
}
|
|
}
|
|
return out.filter((b) => b.x > 30 && b.x < W - 30);
|
|
}
|
|
|
|
const heapSize = (mix) => mix.reduce((n, [, c]) => n + c, 0);
|
|
|
|
// ── Templates ───────────────────────────────────────────────────────────────
|
|
// Each returns a level body. `d` is difficulty in 0..1 within its block.
|
|
// `gate` says how the load-bearing check should treat the featured element.
|
|
|
|
export const TEMPLATES = {
|
|
// Straight-up climb. The bread and butter.
|
|
tower(rng, d, mix) {
|
|
const x = Math.round(lerp(430, 860, rng()));
|
|
const climb = Math.round(lerp(200, 470, d));
|
|
const pileX = x + 260 < W - 300 ? x + 260 : x - 420;
|
|
return {
|
|
element: null, gate: 'none',
|
|
terrain: [ground(), ...bounds()],
|
|
...footing(x, GROUND - 14),
|
|
pipe: { x, y: GROUND - 68 - climb, r: 46 },
|
|
pile: heap(mix, clamp(pileX, 60, W - 300), GROUND, { avoid: x }),
|
|
tip: 'Brace as you go. An unbraced column leans, and once it leans it keeps leaning.',
|
|
};
|
|
},
|
|
|
|
// A spike pit to bridge.
|
|
bridge(rng, d, mix) {
|
|
const ledgeY = 700;
|
|
const gap = Math.round(lerp(190, 330, d));
|
|
const left = Math.round(lerp(560, 640, rng()));
|
|
const right = left + gap;
|
|
const fx = left - 190;
|
|
return {
|
|
element: null, gate: 'none',
|
|
terrain: [
|
|
...bounds(),
|
|
rect(0, ledgeY, left, H),
|
|
rect(right, ledgeY, W, H),
|
|
rect(left, 930, right, H, 'spike'),
|
|
rect(0, ledgeY - 90, 44, ledgeY),
|
|
rect(left - 44, ledgeY - 90, left, ledgeY),
|
|
],
|
|
...footing(fx, ledgeY - 14),
|
|
pipe: { x: right + 110, y: ledgeY - 60, r: 46 },
|
|
pile: heap(mix, 90, ledgeY, { perRow: 6, avoid: fx }),
|
|
waypoints: [{ x: (left + right) / 2, y: ledgeY - 95 }],
|
|
tip: 'Triangles carry weight across a gap. Long chains just sag into the spikes.',
|
|
};
|
|
},
|
|
|
|
// A slab overhead: come out from under it before climbing.
|
|
overhang(rng, d, mix) {
|
|
const fx = Math.round(lerp(470, 600, rng()));
|
|
const slabY = Math.round(lerp(620, 540, d));
|
|
const slabR = Math.round(lerp(740, 860, d));
|
|
return {
|
|
element: null, gate: 'none',
|
|
terrain: [ground(), ...bounds(), rect(260, slabY, slabR, slabY + 70)],
|
|
...footing(fx, GROUND - 14),
|
|
pipe: { x: slabR + 150, y: slabY - 60, r: 46 },
|
|
pile: heap(mix, slabR + 120, GROUND, { avoid: fx }),
|
|
waypoints: [{ x: slabR + 40, y: slabY + 20 }],
|
|
tip: 'You cannot build through rock. Come out from under it first.',
|
|
};
|
|
},
|
|
|
|
// Spiked ceiling: stop short of it.
|
|
ceiling(rng, d, mix) {
|
|
const x = Math.round(lerp(500, 900, rng()));
|
|
const ceilY = Math.round(lerp(340, 240, d));
|
|
return {
|
|
element: null, gate: 'none',
|
|
terrain: [ground(), ...bounds(), rect(clamp(x - 420, 0, W), ceilY, clamp(x + 420, 0, W), ceilY + 60, 'spike')],
|
|
...footing(x, GROUND - 14),
|
|
pipe: { x, y: ceilY + 165, r: 46 },
|
|
pile: heap(mix, clamp(x + 280, 60, W - 300), GROUND, { avoid: x }),
|
|
tip: 'The ceiling bites. Stop short of it.',
|
|
};
|
|
},
|
|
|
|
// An updraft that lightens everything inside it, so the span reaches further
|
|
// than bare goo could manage.
|
|
updraft(rng, d, mix) {
|
|
const ledgeY = 700;
|
|
const gap = Math.round(lerp(360, 470, d));
|
|
const left = 560;
|
|
const right = left + gap;
|
|
const fx = left - 190;
|
|
return {
|
|
element: 'fan', gate: 'enabling',
|
|
terrain: [
|
|
...bounds(),
|
|
rect(0, ledgeY, left, H),
|
|
rect(right, ledgeY, W, H),
|
|
rect(left, 930, right, H, 'spike'),
|
|
rect(0, ledgeY - 90, 44, ledgeY),
|
|
rect(left - 44, ledgeY - 90, left, ledgeY),
|
|
],
|
|
fans: [{
|
|
x: left, y: 260, w: gap, h: 660, dx: 0, dy: -1,
|
|
force: Math.round(lerp(1380, 1290, d)),
|
|
}],
|
|
...footing(fx, ledgeY - 14),
|
|
pipe: { x: clamp(right + 110, 0, W - 70), y: ledgeY - 60, r: 46 },
|
|
pile: heap(mix, 90, ledgeY, { perRow: 6, avoid: fx }),
|
|
waypoints: [{ x: (left + right) / 2, y: ledgeY - 120 }],
|
|
tip: 'The updraft takes weight off everything inside it. Spans reach further in the wind.',
|
|
};
|
|
},
|
|
|
|
// A turning cog in the way.
|
|
cogs(rng, d, mix) {
|
|
const fx = Math.round(lerp(280, 380, rng()));
|
|
const gx = Math.round(lerp(720, 820, rng()));
|
|
const omega = (rng() < 0.5 ? -1 : 1) * lerp(1.1, 2.0, d);
|
|
return {
|
|
element: 'gear', gate: 'obstructing',
|
|
terrain: [ground(), ...bounds()],
|
|
gears: [{
|
|
x: gx, y: Math.round(lerp(520, 450, d)),
|
|
r: Math.round(lerp(95, 125, d)), teeth: 9, omega,
|
|
}],
|
|
...footing(fx, GROUND - 14),
|
|
pipe: { x: Math.round(lerp(1160, 1290, d)), y: Math.round(lerp(620, 550, d)), r: 46 },
|
|
pile: heap(mix, 1000, GROUND, { perRow: 9, avoid: fx }),
|
|
tip: 'The cog is solid and it is turning. Whatever it touches gets dragged along.',
|
|
};
|
|
},
|
|
|
|
// Soft rock in the way, a fire pit beside it, bombs in the heap. The bomb is
|
|
// a SHORTCUT, never the only route -- a mechanic that is the sole solution is
|
|
// a level nobody can find their way through.
|
|
demolition(rng, d, mix) {
|
|
const fx = Math.round(lerp(360, 440, rng()));
|
|
const rockX = Math.round(lerp(680, 760, rng()));
|
|
const rockTop = Math.round(lerp(700, 640, d));
|
|
return {
|
|
element: 'bomb', gate: 'obstructing',
|
|
terrain: [
|
|
ground(), ...bounds(),
|
|
{ ...rect(rockX, rockTop, rockX + 80, GROUND), destructible: true },
|
|
rect(rockX - 110, GROUND - 44, rockX - 30, GROUND, 'fire'),
|
|
],
|
|
...footing(fx, GROUND - 14),
|
|
pipe: { x: Math.round(lerp(980, 1120, d)), y: 700, r: 46 },
|
|
pile: heap(mix, 120, GROUND, { perRow: 7, avoid: fx }),
|
|
waypoints: [{ x: rockX + 40, y: rockTop - 75 }],
|
|
tip: 'Drop a bomb by the fire and the soft rock goes with it. Or take the long way over.',
|
|
};
|
|
},
|
|
|
|
// A wall start: no ground worth building from, so goo that grips rock is the
|
|
// only way to begin.
|
|
cliff(rng, d, mix) {
|
|
const wallX = 300;
|
|
const startY = Math.round(lerp(600, 520, d));
|
|
return {
|
|
element: 'anchor', gate: 'enabling',
|
|
terrain: [
|
|
ground(), ...bounds(),
|
|
rect(wallX - 90, 180, wallX, 800),
|
|
rect(1200, 180, 1290, 800),
|
|
rect(wallX, 930, 1200, H, 'spike'),
|
|
// A shelf directly under the starting structure. Without it the heap
|
|
// sits on the ground far below a wall-mounted start, loose goo walks to
|
|
// the foot of the wall and can never climb, and the pipe drinks nothing.
|
|
rect(wallX, startY + 44, wallX + 300, startY + 120),
|
|
],
|
|
balls: [
|
|
{ x: wallX + 40, y: startY, type: 'pokey', pinned: true },
|
|
{ x: wallX + 40, y: startY - 62, type: 'pokey', pinned: true },
|
|
{ x: wallX + 98, y: startY - 31, type: 'common' },
|
|
],
|
|
strands: [[2, 0], [2, 1], [0, 1]],
|
|
pipe: { x: 1150, y: Math.round(lerp(460, 400, d)), r: 46 },
|
|
pile: heap(mix, wallX + 130, startY + 44, { perRow: 6 }),
|
|
waypoints: [{ x: 740, y: Math.round(lerp(450, 410, d)) }],
|
|
tip: 'Pokey goo grips bare rock and needs no friends. Start from the wall.',
|
|
};
|
|
},
|
|
|
|
// Pillars to anchor off on the way up.
|
|
pillars(rng, d, mix) {
|
|
const fx = 760;
|
|
const top = Math.round(lerp(360, 280, d));
|
|
return {
|
|
element: 'anchor', gate: 'none',
|
|
terrain: [ground(), ...bounds(), rect(420, top, 480, GROUND), rect(1040, top, 1100, GROUND)],
|
|
...footing(fx, GROUND - 14),
|
|
pipe: { x: fx, y: Math.round(lerp(420, 340, d)), r: 46 },
|
|
pile: heap(mix, 830, GROUND, { perRow: 6, avoid: fx }),
|
|
tip: 'Anchor goo sticks straight to rock. The pillars are fair game.',
|
|
};
|
|
},
|
|
};
|
|
|
|
// ── The curriculum ──────────────────────────────────────────────────────────
|
|
|
|
export const MIX = {
|
|
basic: [['common', 24]],
|
|
ivy: [['common', 18], ['ivy', 10]],
|
|
airy: [['common', 20], ['balloon', 6]],
|
|
windy: [['common', 22], ['balloon', 5], ['ivy', 4]],
|
|
works: [['common', 22], ['block', 8], ['bomb', 4]],
|
|
cog: [['common', 24], ['block', 8]],
|
|
sticky: [['pokey', 8], ['anchor', 5], ['common', 22]],
|
|
deep: [['common', 20], ['skull', 6], ['bit', 6], ['balloon', 4], ['block', 4]],
|
|
};
|
|
|
|
const CHAPTERS = [
|
|
{
|
|
id: 1,
|
|
name: 'The Rolling Hills',
|
|
blurb: 'Learn to build. Everything after this assumes you can.',
|
|
frozen: 6,
|
|
blocks: [
|
|
{ template: 'tower', count: 3, d0: 0.15, d1: 0.45, mix: 'basic' },
|
|
{ template: 'bridge', count: 3, d0: 0.10, d1: 0.40, mix: 'ivy' },
|
|
{ template: 'overhang', count: 3, d0: 0.10, d1: 0.45, mix: 'ivy' },
|
|
],
|
|
names: ['Uphill', 'Higher Ground', 'The Long Climb',
|
|
'Across', 'Wider Still', 'The Drop',
|
|
'Under It', 'Round the Back', 'Out and Up'],
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'Windward',
|
|
blurb: 'Wind, balloons, and things that would rather float away.',
|
|
blocks: [
|
|
{ template: 'tower', count: 2, d0: 0.35, d1: 0.55, mix: 'airy', teach: true },
|
|
{ template: 'ceiling', count: 3, d0: 0.20, d1: 0.55, mix: 'airy' },
|
|
{ template: 'updraft', count: 4, d0: 0.10, d1: 0.55, mix: 'windy', teach: true },
|
|
{ template: 'bridge', count: 3, d0: 0.45, d1: 0.70, mix: 'windy' },
|
|
{ template: 'updraft', count: 3, d0: 0.55, d1: 0.85, mix: 'windy' },
|
|
],
|
|
names: ['Light Head', 'Buoyant', 'Low Ceiling', 'Mind Your Head', 'Overhead',
|
|
'First Gust', 'Updraft', 'Riding the Wind', 'Thermals',
|
|
'Long Reach', 'The Wide Crossing', 'Windswept', 'Gale', 'Squall', 'Headwind'],
|
|
},
|
|
{
|
|
id: 3,
|
|
name: 'The Works',
|
|
blurb: 'Machinery. It does not care what you built.',
|
|
blocks: [
|
|
{ template: 'cogs', count: 4, d0: 0.10, d1: 0.55, mix: 'cog', teach: true },
|
|
{ template: 'tower', count: 2, d0: 0.55, d1: 0.75, mix: 'cog' },
|
|
{ template: 'demolition', count: 4, d0: 0.10, d1: 0.55, mix: 'works', teach: true },
|
|
{ template: 'cogs', count: 3, d0: 0.55, d1: 0.85, mix: 'works' },
|
|
{ template: 'overhang', count: 2, d0: 0.55, d1: 0.80, mix: 'works' },
|
|
],
|
|
names: ['First Cog', 'Gearworks', 'Clockwise', 'Widdershins', 'Rigid Thinking',
|
|
'Girders', 'Soft Rock', 'Demolition', 'Blast Radius', 'Controlled Burn',
|
|
'The Machine', 'Grinding Gears', 'Flywheel', 'Under the Works', 'Assembly'],
|
|
},
|
|
{
|
|
id: 4,
|
|
name: 'The Sticky Cliffs',
|
|
blurb: 'No ground worth standing on. Grip the rock.',
|
|
blocks: [
|
|
{ template: 'pillars', count: 3, d0: 0.10, d1: 0.45, mix: 'sticky', teach: true },
|
|
{ template: 'cliff', count: 4, d0: 0.10, d1: 0.50, mix: 'sticky', teach: true },
|
|
{ template: 'tower', count: 2, d0: 0.60, d1: 0.80, mix: 'sticky' },
|
|
{ template: 'cliff', count: 3, d0: 0.50, d1: 0.85, mix: 'sticky' },
|
|
{ template: 'bridge', count: 3, d0: 0.55, d1: 0.85, mix: 'sticky' },
|
|
],
|
|
names: ['Handhold', 'Two Pillars', 'Stepping Up', 'The Face', 'Sheer',
|
|
'Traverse', 'Grip', 'Overhanging', 'Cliffhanger', 'The Chimney',
|
|
'Freeclimb', 'The Notch', 'Crevasse', 'Exposure', 'Summit'],
|
|
},
|
|
{
|
|
id: 5,
|
|
name: 'Down the Deep',
|
|
blurb: 'Everything you have learned, all at once.',
|
|
blocks: [
|
|
{ template: 'ceiling', count: 3, d0: 0.60, d1: 0.90, mix: 'deep' },
|
|
{ template: 'updraft', count: 3, d0: 0.60, d1: 0.90, mix: 'deep' },
|
|
{ template: 'cogs', count: 3, d0: 0.65, d1: 0.95, mix: 'deep' },
|
|
{ template: 'demolition', count: 3, d0: 0.60, d1: 0.90, mix: 'deep' },
|
|
{ template: 'tower', count: 3, d0: 0.70, d1: 1.00, mix: 'deep' },
|
|
],
|
|
names: ['Descent', 'Low Roof', 'The Squeeze', 'Deep Wind', 'The Shaft',
|
|
'Downdraft', 'Deep Cogs', 'The Grinder', 'Millstone', 'Deep Rock',
|
|
'Cave-In', 'The Last Charge', 'The Long Way Up', 'Ascent', 'Out'],
|
|
},
|
|
];
|
|
|
|
// ── Gates ───────────────────────────────────────────────────────────────────
|
|
|
|
function cleanCheck(def) {
|
|
const st = createState(def);
|
|
for (const b of st.balls) {
|
|
if (insideSolid(st, b.x, b.y)) return 'goo starts inside terrain';
|
|
}
|
|
for (let i = 0; i < st.balls.length; i += 1) {
|
|
for (let j = i + 1; j < st.balls.length; j += 1) {
|
|
const a = st.balls[i];
|
|
const b = st.balls[j];
|
|
if (Math.hypot(b.x - a.x, b.y - a.y) < (a.r + b.r) * 0.6) return 'goo starts overlapping';
|
|
}
|
|
}
|
|
if (st.pipe.open) return 'pipe starts open';
|
|
settlePhysics(st, 10);
|
|
if (liveStrands(st).length !== st.strands.length) return 'a strand snaps on its own';
|
|
const lost = st.balls.filter((b) => b.dead);
|
|
if (lost.length) {
|
|
const kinds = [...new Set(lost.map((b) => b.type))].join('/');
|
|
return `goo dies before the player touches anything (${lost.length} ${kinds})`;
|
|
}
|
|
if (st.pile.length < def.ocdTarget) return 'not enough goo left for OCD';
|
|
return null;
|
|
}
|
|
|
|
// A teaching level may not open with an instant-fail: nothing lethal within
|
|
// reach of the starting structure.
|
|
function gentleCheck(def) {
|
|
const st = createState(def);
|
|
const start = st.balls.filter((b) => b.attached);
|
|
for (const t of st.terrain) {
|
|
if (t.kind !== 'spike' && t.kind !== 'fire') continue;
|
|
for (const b of start) {
|
|
const bb = t.bounds;
|
|
const dx = Math.max(bb.minX - b.x, 0, b.x - bb.maxX);
|
|
const dy = Math.max(bb.minY - b.y, 0, b.y - bb.maxY);
|
|
if (Math.hypot(dx, dy) < 140) return 'a hazard sits on the doorstep';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Strip the featured element; an "enabling" level must stop being winnable.
|
|
function loadBearingCheck(def, element) {
|
|
const stripped = JSON.parse(JSON.stringify(def));
|
|
if (element === 'fan') stripped.fans = [];
|
|
else if (element === 'anchor') {
|
|
stripped.pile = stripped.pile.filter((p) => !gooType(p.type).sticksToTerrain);
|
|
stripped.balls = stripped.balls.map((b) => (gooType(b.type).sticksToTerrain
|
|
? { ...b, type: 'common', pinned: false } : b));
|
|
} else return null;
|
|
const r = autoPlay(stripped, { maxBalls: 50, maxSeconds: 30 });
|
|
return r.won ? 'winnable without its own mechanic' : null;
|
|
}
|
|
|
|
// ── Build ───────────────────────────────────────────────────────────────────
|
|
|
|
const rng = mulberry32(SEED);
|
|
const levels = frozenLevels();
|
|
const stats = { tried: 0, rejected: {} };
|
|
const reject = (why) => { stats.rejected[why] = (stats.rejected[why] || 0) + 1; };
|
|
|
|
for (const chapter of CHAPTERS) {
|
|
let nameIdx = 0;
|
|
const wanted = 15 - (chapter.frozen || 0);
|
|
let made = 0;
|
|
|
|
for (const block of chapter.blocks) {
|
|
for (let k = 0; k < block.count && made < wanted; k += 1) {
|
|
const d = block.count > 1 ? lerp(block.d0, block.d1, k / (block.count - 1)) : block.d0;
|
|
const mix = MIX[block.mix];
|
|
let accepted = null;
|
|
|
|
for (let attempt = 0; attempt < 14 && !accepted; attempt += 1) {
|
|
stats.tried += 1;
|
|
// Jitter difficulty per attempt so retries actually differ.
|
|
const dd = clamp(d + (rng() - 0.5) * 0.12, 0, 1);
|
|
const body = TEMPLATES[block.template](rng, dd, mix);
|
|
const size = heapSize(mix);
|
|
const required = Math.round(lerp(4, 8, dd));
|
|
const ocdTarget = clamp(Math.round(lerp(required + 4, required + 9, dd)), required, size - 5);
|
|
|
|
const def = {
|
|
world: { w: W, h: H },
|
|
level: levels.length + 1,
|
|
name: chapter.names[nameIdx] || `${chapter.name} ${nameIdx + 1}`,
|
|
chapter: chapter.id,
|
|
tip: body.tip,
|
|
element: body.element || undefined,
|
|
terrain: body.terrain,
|
|
balls: body.balls,
|
|
strands: body.strands,
|
|
pile: body.pile,
|
|
pipe: body.pipe,
|
|
fans: body.fans,
|
|
gears: body.gears,
|
|
waypoints: body.waypoints,
|
|
required,
|
|
ocdTarget,
|
|
};
|
|
|
|
const dirty = cleanCheck(def);
|
|
if (dirty) { reject(dirty); continue; }
|
|
if (block.teach) {
|
|
const harsh = gentleCheck(def);
|
|
if (harsh) { reject(harsh); continue; }
|
|
}
|
|
const r = autoPlay(def, { maxBalls: 70, maxSeconds: 45 });
|
|
if (!r.won) { reject(`unwinnable (${r.pipeOpen ? 'pipe open' : 'pipe shut'})`); continue; }
|
|
if (body.gate === 'enabling') {
|
|
const weak = loadBearingCheck(def, body.element);
|
|
if (weak) { reject(weak); continue; }
|
|
}
|
|
accepted = def;
|
|
}
|
|
|
|
if (!accepted) {
|
|
// Never leave a hole in the curriculum: fall back to a plain tower at
|
|
// the same difficulty rather than shipping a 13-level chapter.
|
|
for (let attempt = 0; attempt < 10 && !accepted; attempt += 1) {
|
|
const dd = clamp(d * 0.8 + (rng() - 0.5) * 0.1, 0, 1);
|
|
const body = TEMPLATES.tower(rng, dd, mix);
|
|
const size = heapSize(mix);
|
|
const required = Math.round(lerp(4, 8, dd));
|
|
const def = {
|
|
world: { w: W, h: H },
|
|
level: levels.length + 1,
|
|
name: chapter.names[nameIdx] || `${chapter.name} ${nameIdx + 1}`,
|
|
chapter: chapter.id,
|
|
tip: body.tip,
|
|
terrain: body.terrain,
|
|
balls: body.balls,
|
|
strands: body.strands,
|
|
pile: body.pile,
|
|
pipe: body.pipe,
|
|
required,
|
|
ocdTarget: clamp(Math.round(lerp(required + 4, required + 9, dd)), required, size - 5),
|
|
};
|
|
if (cleanCheck(def)) continue;
|
|
if (!autoPlay(def, { maxBalls: 70, maxSeconds: 45 }).won) continue;
|
|
accepted = def;
|
|
}
|
|
if (accepted) reject(`fell back to tower (${block.template})`);
|
|
}
|
|
if (!accepted) {
|
|
console.warn(`\n[gen] ! ch${chapter.id} ${block.template} slot ${k} — no candidate survived`);
|
|
continue;
|
|
}
|
|
levels.push(accepted);
|
|
nameIdx += 1;
|
|
made += 1;
|
|
process.stdout.write(`\r[gen] ${levels.length} levels… `);
|
|
}
|
|
}
|
|
|
|
// Top-up: every chapter ships exactly fifteen. If the blocks came up short,
|
|
// fill the remainder with plain towers rather than a ragged chapter.
|
|
let guard = 0;
|
|
while (made < wanted && guard < 60) {
|
|
guard += 1;
|
|
const dd = clamp(0.3 + rng() * 0.4, 0, 1);
|
|
const mix = MIX[chapter.blocks[0].mix];
|
|
const body = TEMPLATES.tower(rng, dd, mix);
|
|
const size = heapSize(mix);
|
|
const required = Math.round(lerp(4, 8, dd));
|
|
const def = {
|
|
world: { w: W, h: H },
|
|
level: levels.length + 1,
|
|
name: chapter.names[nameIdx] || `${chapter.name} ${nameIdx + 1}`,
|
|
chapter: chapter.id,
|
|
tip: body.tip,
|
|
terrain: body.terrain,
|
|
balls: body.balls,
|
|
strands: body.strands,
|
|
pile: body.pile,
|
|
pipe: body.pipe,
|
|
required,
|
|
ocdTarget: clamp(Math.round(lerp(required + 4, required + 9, dd)), required, size - 5),
|
|
};
|
|
if (cleanCheck(def)) continue;
|
|
if (!autoPlay(def, { maxBalls: 70, maxSeconds: 45 }).won) continue;
|
|
levels.push(def);
|
|
nameIdx += 1;
|
|
made += 1;
|
|
reject('top-up tower');
|
|
process.stdout.write(`\r[gen] ${levels.length} levels… `);
|
|
}
|
|
}
|
|
|
|
// ── Emit ────────────────────────────────────────────────────────────────────
|
|
|
|
mkdirSync(outDir, { recursive: true });
|
|
|
|
const manifest = { version: 2, seed: SEED, levels: [] };
|
|
for (let i = 0; i < levels.length; i += 1) {
|
|
const lv = levels[i];
|
|
lv.level = i + 1;
|
|
const file = `level-${String(lv.level).padStart(3, '0')}.json`;
|
|
writeFileSync(join(outDir, file), `${JSON.stringify(lv, null, 2)}\n`);
|
|
manifest.levels.push({
|
|
level: lv.level, name: lv.name, chapter: lv.chapter, file,
|
|
required: lv.required, ocdTarget: lv.ocdTarget,
|
|
element: lv.element || undefined,
|
|
});
|
|
}
|
|
manifest.chapters = CHAPTERS.map((c) => {
|
|
const own = manifest.levels.filter((m) => m.chapter === c.id);
|
|
return {
|
|
id: c.id,
|
|
name: c.name,
|
|
blurb: c.blurb,
|
|
from: own.length ? own[0].level : 0,
|
|
to: own.length ? own[own.length - 1].level : 0,
|
|
};
|
|
}).filter((c) => c.from > 0);
|
|
writeFileSync(join(outDir, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
|
|
console.log(`\n[gen] wrote ${levels.length} levels + manifest to ${outDir}`);
|
|
console.log(`[gen] ${stats.tried} candidates tried`);
|
|
for (const [why, n] of Object.entries(stats.rejected).sort((a, b) => b[1] - a[1])) {
|
|
console.log(`[gen] rejected ${String(n).padStart(4)} ${why}`);
|
|
}
|
|
|
|
// ── The frozen hand-tuned openers ───────────────────────────────────────────
|
|
// Emitted verbatim. These were authored as the teaching set; the generator must
|
|
// never rewrite them.
|
|
function frozenLevels() {
|
|
const out = [];
|
|
{
|
|
const f = footing(771, GROUND - 14);
|
|
out.push({
|
|
world: { w: W, h: H }, level: 1, name: 'Ground Floor', chapter: 1,
|
|
tip: 'Drag goo from the heap onto the structure. Each ball needs two nearby friends to hold on to.',
|
|
terrain: [ground()], ...f,
|
|
pipe: { x: 771, y: 600, r: 46 },
|
|
pile: heap([['common', 16]], 980, GROUND),
|
|
required: 4, ocdTarget: 9,
|
|
});
|
|
}
|
|
{
|
|
const f = footing(430, 686);
|
|
out.push({
|
|
world: { w: W, h: H }, level: 2, name: 'Mind the Gap', chapter: 1,
|
|
tip: 'Spikes below. Build ACROSS — triangles carry weight, long chains do not.',
|
|
terrain: [
|
|
rect(0, 700, 600, H), rect(860, 700, W, H), rect(600, 930, 860, H, 'spike'),
|
|
// Lip on the outer rim: without it the heap spreads left, rolls off the
|
|
// edge of the world and the level quietly loses goo.
|
|
rect(0, 620, 40, 700),
|
|
],
|
|
...f,
|
|
pipe: { x: 980, y: 640, r: 46 },
|
|
pile: heap([['common', 20]], 150, 700, { perRow: 7 }),
|
|
required: 5, ocdTarget: 12,
|
|
});
|
|
}
|
|
{
|
|
const f = footing(560, GROUND - 14);
|
|
out.push({
|
|
world: { w: W, h: H }, level: 3, name: 'Headroom', chapter: 1,
|
|
tip: 'You cannot build through the slab. Come out from under it first.',
|
|
terrain: [ground(), rect(300, 540, 820, 610)], ...f,
|
|
pipe: { x: 980, y: 470, r: 46 },
|
|
pile: heap([['common', 24]], 1020, GROUND, { perRow: 8 }),
|
|
required: 5, ocdTarget: 12,
|
|
});
|
|
}
|
|
{
|
|
const f = footing(331, GROUND - 14);
|
|
out.push({
|
|
world: { w: W, h: H }, level: 4, name: 'Second Thoughts', chapter: 1,
|
|
tip: 'Green ivy goo can be pulled back off and used again. Grey goo is there for good.',
|
|
terrain: [ground()], ...f,
|
|
pipe: { x: 660, y: 560, r: 46 },
|
|
pile: [
|
|
...heap([['ivy', 12]], 540, GROUND, { perRow: 6 }),
|
|
...heap([['common', 8]], 860, GROUND, { perRow: 8 }),
|
|
],
|
|
required: 4, ocdTarget: 9,
|
|
});
|
|
}
|
|
{
|
|
const f = footing(791, GROUND - 14);
|
|
out.push({
|
|
world: { w: W, h: H }, level: 5, name: 'Overhead', chapter: 1,
|
|
tip: 'The ceiling bites. Stop short of it.',
|
|
terrain: [ground(), rect(400, 180, 1200, 240, 'spike')], ...f,
|
|
pipe: { x: 791, y: 340, r: 46 },
|
|
pile: heap([['common', 18]], 1000, GROUND, { perRow: 9 }),
|
|
required: 5, ocdTarget: 11,
|
|
});
|
|
}
|
|
{
|
|
const f = footing(791, GROUND - 14);
|
|
out.push({
|
|
world: { w: W, h: H }, level: 6, name: 'Tall Order', chapter: 1,
|
|
tip: 'A tower stands on compression and falls on sway. Brace every level.',
|
|
terrain: [ground()], ...f,
|
|
pipe: { x: 791, y: 180, r: 46 },
|
|
pile: heap([['common', 26]], 1020, GROUND, { perRow: 9 }),
|
|
required: 6, ocdTarget: 14,
|
|
});
|
|
}
|
|
return out;
|
|
}
|