// Goo Tower — level bank writer. // // Wave 1: the six hand-designed teaching levels below are authored here and // emitted verbatim. They are HAND-TUNED, not generated -- the script exists so // the repetitive parts (goo piles, spike strips, the manifest) are constructed // rather than typed, and so the bank is byte-reproducible. // // Wave 4 will add generated drill/sandbox tiers alongside these; when it does, // levels 1-6 must be carried over untouched, exactly as genPeggleLevels.js // freezes Peggle's hand-tuned 1-25. // // Usage: node tools/genGooTower.js [outDir] import { writeFileSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const outDir = process.argv[2] || join(__dirname, '..', 'assets', 'gamedata', 'gootower'); const W = 1600; const H = 1000; const GROUND = 850; 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); // A loose heap of goo resting on a surface. Laid out in rows so the balls start // apart -- dumping them on one spot just makes the collision solver do work. function pile(type, n, x, surfaceY, { perRow = 8, gap = 30 } = {}) { const out = []; for (let i = 0; i < n; i += 1) { const row = Math.floor(i / perRow); const col = i % perRow; out.push({ x: x + col * gap, y: surfaceY - 16 - row * 30, type }); } return out; } // A small 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]], }; } const LEVELS = []; // ── 1 ─────────────────────────────────────────────────────────────────────── { const f = footing(771, GROUND - 14); LEVELS.push({ 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: pile('common', 16, 980, GROUND), required: 4, ocdTarget: 9, }); } // ── 2 ─────────────────────────────────────────────────────────────────────── { const f = footing(430, 686); LEVELS.push({ 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 before the player // has touched anything. rect(0, 620, 40, 700), ], ...f, pipe: { x: 980, y: 640, r: 46 }, // perRow 7 keeps the heap clear of the footing at x=430; at 10 the last // column landed 9px from a foot and the two were ejected apart on frame one. pile: pile('common', 20, 150, 700, { perRow: 7 }), required: 5, ocdTarget: 12, }); } // ── 3 ─────────────────────────────────────────────────────────────────────── { // The footing sits UNDER the overhang, so the only way up is to walk the // structure out sideways first. Keep the run short -- this is level 3, and // an earlier draft asked for a 677px diagonal that nothing could build. const f = footing(560, GROUND - 14); LEVELS.push({ 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: pile('common', 24, 1020, GROUND, { perRow: 8 }), required: 5, ocdTarget: 12, }); } // ── 4 ─────────────────────────────────────────────────────────────────────── { const f = footing(331, GROUND - 14, 'common'); LEVELS.push({ 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, // The heap must comfortably outlast the climb. An earlier draft gave // exactly enough goo to REACH the pipe and nothing to feed it, so the // level opened the pipe and was then unwinnable. pipe: { x: 660, y: 560, r: 46 }, pile: [ ...pile('ivy', 12, 540, GROUND, { perRow: 6 }), ...pile('common', 8, 860, GROUND, { perRow: 8 }), ], required: 4, ocdTarget: 9, }); } // ── 5 ─────────────────────────────────────────────────────────────────────── { const f = footing(791, GROUND - 14); LEVELS.push({ 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: pile('common', 18, 1000, GROUND, { perRow: 9 }), required: 5, ocdTarget: 11, }); } // ── 6 ─────────────────────────────────────────────────────────────────────── { const f = footing(791, GROUND - 14); LEVELS.push({ 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: pile('common', 26, 1020, GROUND, { perRow: 9 }), required: 6, ocdTarget: 14, }); } // ── Sandbox tier (7-12) ───────────────────────────────────────────────────── // One level per Wave 3 mechanic, so each can be played and judged on its own. // This is THROWAWAY content, explicitly not the final curriculum -- Wave 4 // replaces it with a designed 5-chapter ramp. // ── 7 · anchors ───────────────────────────────────────────────────────────── { const f = footing(780, GROUND - 14); LEVELS.push({ level: 7, name: 'Anchors Aweigh', chapter: 2, sandbox: true, tip: 'Brown anchor goo sticks straight to rock and needs no friends. Start a span from a wall.', // Pillars must not stand BETWEEN the heap and the structure: loose goo // walks along the ground, and a 550px wall is a wall. terrain: [ground(), rect(400, 300, 460, GROUND), rect(1120, 300, 1180, GROUND)], ...f, pipe: { x: 780, y: 400, r: 46 }, pile: [ ...pile('anchor', 6, 860, GROUND, { perRow: 6 }), ...pile('common', 20, 860, GROUND - 30, { perRow: 6 }), ], required: 5, ocdTarget: 12, }); } // ── 8 · pokey (wall starts) ───────────────────────────────────────────────── { LEVELS.push({ level: 8, name: 'Sticking Point', chapter: 2, sandbox: true, tip: 'Pokey goo grips rock. There is no ground here worth building from.', terrain: [ground(), rect(240, 200, 320, 760), rect(1120, 200, 1200, 760)], balls: [ { x: 340, y: 560, type: 'pokey', pinned: true }, { x: 340, y: 498, type: 'pokey', pinned: true }, { x: 398, y: 530, type: 'common' }, ], strands: [[2, 0], [2, 1], [0, 1]], pipe: { x: 1080, y: 430, r: 46 }, pile: [ ...pile('pokey', 6, 620, GROUND, { perRow: 6 }), ...pile('common', 22, 620, GROUND - 30, { perRow: 8 }), ], required: 5, ocdTarget: 12, }); } // ── 9 · bombs, fire and destructible rock ─────────────────────────────────── { const f = footing(400, GROUND - 14); LEVELS.push({ level: 9, name: 'Demolition', chapter: 2, sandbox: true, tip: 'Drop a bomb by the fire and the soft rock goes with it. Or take the long way over.', terrain: [ ground(), // Soft rock in the way -- blow it, or climb over. The fire sits BESIDE // the rock at ground level, not on the climbing route: an earlier draft // had it capping the rock, which made the bomb the only survivable way // through and left the level unwinnable by ordinary building. { ...rect(700, 660, 780, GROUND), destructible: true }, rect(600, 806, 680, GROUND, 'fire'), ], ...f, pipe: { x: 980, y: 700, r: 46 }, // Greedy will not climb a wall unprompted -- that means building away from // the pipe first -- so route it over the top. waypoints: [{ x: 740, y: 590 }], pile: [ ...pile('bomb', 4, 160, GROUND, { perRow: 4 }), ...pile('common', 24, 160, GROUND - 30, { perRow: 7 }), ], required: 5, ocdTarget: 12, }); } // ── 10 · fans ─────────────────────────────────────────────────────────────── { const f = footing(360, 686); LEVELS.push({ level: 10, name: 'Updraft', chapter: 2, sandbox: true, tip: 'The updraft takes weight off everything inside it. Spans reach further in the wind.', terrain: [ rect(0, 700, 600, H), rect(940, 700, W, H), rect(600, 930, 940, H, 'spike'), rect(0, 620, 40, 700), ], fans: [{ x: 600, y: 300, w: 340, h: 630, dx: 0, dy: -1, force: 1150 }], ...f, pipe: { x: 1060, y: 630, r: 46 }, pile: pile('common', 26, 120, 700, { perRow: 6 }), required: 4, ocdTarget: 12, }); } // ── 11 · gears ────────────────────────────────────────────────────────────── { const f = footing(300, GROUND - 14); LEVELS.push({ level: 11, name: 'Cogs', chapter: 2, sandbox: true, tip: 'The cog is solid and it is turning. Anything it touches gets dragged along.', terrain: [ground()], gears: [{ x: 800, y: 470, r: 110, teeth: 9, omega: 1.4 }], ...f, pipe: { x: 1240, y: 560, r: 46 }, pile: pile('common', 28, 1000, GROUND, { perRow: 9 }), required: 5, ocdTarget: 13, }); } // ── 12 · balloons, blocks and bits ────────────────────────────────────────── { const f = footing(400, GROUND - 14); LEVELS.push({ level: 12, name: 'Lighter Than Air', chapter: 2, sandbox: true, tip: 'Balloons pull up, blocks hold rigid, bits are cheap and weak. Mix them.', terrain: [ground()], ...f, pipe: { x: 400, y: 300, r: 46 }, pile: [ ...pile('balloon', 6, 700, GROUND, { perRow: 6 }), ...pile('block', 8, 700, GROUND - 34, { perRow: 8 }), ...pile('bit', 6, 700, GROUND - 68, { perRow: 6 }), ...pile('common', 18, 700, GROUND - 100, { perRow: 9 }), ], required: 5, ocdTarget: 12, }); } mkdirSync(outDir, { recursive: true }); const manifest = { version: 1, generatedAt: new Date(0).toISOString(), levels: [] }; for (const lv of LEVELS) { const file = `level-${String(lv.level).padStart(3, '0')}.json`; const body = { world: { w: W, h: H }, ...lv }; writeFileSync(join(outDir, file), `${JSON.stringify(body, null, 2)}\n`); manifest.levels.push({ level: lv.level, name: lv.name, chapter: lv.chapter, file, required: lv.required, ocdTarget: lv.ocdTarget, sandbox: lv.sandbox || undefined, }); } manifest.chapters = [ { id: 1, name: 'The Goo Filled Hills', blurb: 'Learn to build.', from: 1, to: 6 }, { id: 2, name: 'Sandbox', blurb: 'One level per mechanic — throwaway, replaced in Wave 4.', from: 7, to: LEVELS.length }, ]; writeFileSync(join(outDir, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`); console.log(`[gen] wrote ${LEVELS.length} levels + manifest to ${outDir}`);