305 lines
13 KiB
JavaScript
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}`);
|