fertig-classic-games/tools/genGooTower.js

305 lines
13 KiB
JavaScript

// 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}`);