From 2266e9feeb836b8fdd6e666c197a61d20f53583f Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 25 Jul 2026 23:11:32 +1000 Subject: [PATCH] Procgen: six Gauntlet II archetypes, depth-scaled size, solvability guarantee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Levels past the authored maps were always the same 31x21 maze. Now the shape rotates through what Gauntlet II is actually remembered for: - arena — "IT'S A TRAP!": wide open, a few pillars, ringed with generators - fortress — concentric rings, single-gap vault door, exit at the locked core - invisible — walls at 0.05 alpha, revealed tile-by-tile as you bump them - cells — honeycomb of small rooms joined by punched doorways - quadrants — four rooms behind a wall cross, clutter blocks inside - maze — the original recursive backtracker, now one option of six Map size scales with depth (25x17 -> 45x31, 18 distinct sizes). Arena gets generator hell; deeper tiers get more gold and clutter. Two bugs caught by an in-browser solvability harness (flood-fill from start to booth across 80 generated levels): - 3 of 6 `cells` levels sealed the exit off entirely -> added a safety net that carves an L-corridor when the booth is unreachable (only turns wall into floor, so it can never eat a fixture; protects future archetypes too) - archetype was welded to theme forever (7 % 6 === 1, so shape index == theme index): every Archive level was invisible AND dark. Decoupled with a per-loop offset, plus an explicit guard against the dark+invisible combo. Verified: 80 levels, 0 unsolvable, every vault ships a reachable key outside it, each theme now sees 3 different shapes, 0 dark+invisible combos. Co-Authored-By: Claude Fable 5 --- src/GameScene.js | 227 +++++++++++++++++++++++++++++++++++++++-------- src/announcer.js | 3 + 2 files changed, 191 insertions(+), 39 deletions(-) diff --git a/src/GameScene.js b/src/GameScene.js index c5ae21f..98d631c 100644 --- a/src/GameScene.js +++ b/src/GameScene.js @@ -101,6 +101,8 @@ const THEMES = [ ]; // boss arenas drop into the cycle at fixed depths (each 12-level loop has both) const bossFor = (index) => (index % 12 === 7 ? 'conductor' : index % 12 === 11 ? 'rpm78' : null); +// The shapes Gauntlet II is remembered for — not one maze algorithm forever. +const ARCHETYPES = ['maze', 'fortress', 'quadrants', 'cells', 'arena', 'invisible']; const ID_SCORE = 100; // re-identifying a tune you already know const FIRST_ID_SCORE = 1000; // new notebook entry const GRAIL_ID_SCORE = 2000; // first ID of a certified classic @@ -137,7 +139,10 @@ export default class GameScene extends Phaser.Scene { this.player.body.setSize(34, 34, true); // body smaller than 48px sprite -> fits corridors // Colliders / overlaps (persistent group refs). - this.physics.add.collider(this.player, this.walls); + // bumping an invisible wall reveals that tile — Gauntlet II's feel-your-way mazes + this.physics.add.collider(this.player, this.walls, (p, w) => { + if (this.genKind === 'invisible') w.setAlpha(1); + }); this.physics.add.collider(this.enemies, this.walls); this.physics.add.collider(this.player, this.doors, this.hitDoor, null, this); this.physics.add.collider(this.vinyl, this.walls, this.killVinyl, null, this); @@ -360,6 +365,7 @@ export default class GameScene extends Phaser.Scene { : this.genLevel(index)); this.bossLevel = boss; this.conductorAlive = false; + if (bonusMap || boss || index < LEVELS.length) this.genKind = null; // authored maps aren't archetypes const w0 = map[0].length; for (const row of map) if (row.length !== w0) throw new Error(`level ${index}: ragged row (${row.length} vs ${w0})`); @@ -410,9 +416,11 @@ export default class GameScene extends Phaser.Scene { const x = c * TILE + TILE / 2; const y = r * TILE + TILE / 2; switch (ch) { - case 'W': - this.walls.create(x, y, this.wallTexAt(c, r)); + case 'W': { + const w = this.walls.create(x, y, this.wallTexAt(c, r)); + if (this.genKind === 'invisible') w.setAlpha(0.05); break; + } case 'D': this.doors.create(x, y, 'door'); break; @@ -495,6 +503,9 @@ export default class GameScene extends Phaser.Scene { this.bonusTimer = this.time.delayedCall(BONUS_MS, () => this.endBonus()); this.say(LINES.bonus); } + if (this.genKind === 'arena') this.say(LINES.trap); + else if (this.genKind === 'invisible') this.say(LINES.invisible); + else if (this.genKind === 'fortress') this.say(LINES.vault); } // Stable per-tile wall variant: same tile always gets the same texture, but @@ -528,65 +539,203 @@ export default class GameScene extends Phaser.Scene { } } - // Deterministic maze for levels past the hand-authored ones. Same level = same maze. + // Deterministic level for anything past the hand-authored maps. Same level number = + // same layout. Shape rotates through the Gauntlet II archetypes rather than "maze + // forever", and the whole map grows with depth. genLevel(index) { const rnd = new Phaser.Math.RandomDataGenerator([String(index * 7919)]); - const C = 31; // odd - const R = 21; // odd + const tier = Math.min(4, Math.floor(index / 6)); // 0..4, drives size + density + const odd = (n) => (n % 2 ? n : n + 1); + const C = odd(21 + tier * 6 + rnd.between(0, 4)); + const R = odd(15 + tier * 4 + rnd.between(0, 2)); + // Decouple shape from theme: both cycle every 6, so a plain multiple would weld + // them together forever (7 % 6 === 1 — every Archive level would be invisible). + // The per-loop offset keeps shape and theme drifting against each other. + const theme = THEMES[index % THEMES.length]; + let kind = ARCHETYPES[(index * 5 + Math.floor(index / 6) * 2) % ARCHETYPES.length]; + // dark + invisible is double blindness — pick the honest maze instead + if (theme.dark && kind === 'invisible') kind = 'maze'; + this.genKind = kind; + const g = Array.from({ length: R }, () => Array(C).fill('W')); - // recursive backtracker on odd cells - const stack = [[1, 1]]; - g[1][1] = '.'; - while (stack.length) { - const [cy, cx] = stack[stack.length - 1]; - const dirs = rnd.shuffle([[0, 2], [0, -2], [2, 0], [-2, 0]]); - let carved = false; - for (const [dy, dx] of dirs) { - const ny = cy + dy; - const nx = cx + dx; - if (ny > 0 && ny < R - 1 && nx > 0 && nx < C - 1 && g[ny][nx] === 'W') { - g[ny][nx] = '.'; - g[cy + dy / 2][cx + dx / 2] = '.'; - stack.push([ny, nx]); - carved = true; - break; + const openAll = () => { + for (let y = 1; y < R - 1; y++) for (let x = 1; x < C - 1; x++) g[y][x] = '.'; + }; + let vaultInset = null; // fortress: the locked core + + if (kind === 'maze' || kind === 'invisible') { + // recursive backtracker on odd cells, then loosened with loops + const stack = [[1, 1]]; + g[1][1] = '.'; + while (stack.length) { + const [cy, cx] = stack[stack.length - 1]; + const dirs = rnd.shuffle([[0, 2], [0, -2], [2, 0], [-2, 0]]); + let carved = false; + for (const [dy, dx] of dirs) { + const ny = cy + dy; + const nx = cx + dx; + if (ny > 0 && ny < R - 1 && nx > 0 && nx < C - 1 && g[ny][nx] === 'W') { + g[ny][nx] = '.'; + g[cy + dy / 2][cx + dx / 2] = '.'; + stack.push([ny, nx]); + carved = true; + break; + } + } + if (!carved) stack.pop(); + } + const loosen = kind === 'invisible' ? 0.2 : 0.12; // invisible mazes need mercy + for (let y = 1; y < R - 1; y++) + for (let x = 1; x < C - 1; x++) + if (g[y][x] === 'W' && rnd.frac() < loosen) g[y][x] = '.'; + for (let y = Math.floor(R / 2) - 1; y <= Math.floor(R / 2) + 1; y++) + for (let x = Math.floor(C / 2) - 2; x <= Math.floor(C / 2) + 2; x++) g[y][x] = '.'; + } else if (kind === 'fortress') { + // concentric rings; the innermost has a single gap = the vault door + openAll(); + const maxRing = Math.floor(Math.min(R, C) / 2) - 2; + for (let r = 2; r <= maxRing; r += 3) { + for (let x = r; x < C - r; x++) { + g[r][x] = 'W'; + g[R - 1 - r][x] = 'W'; + } + for (let y = r; y < R - r; y++) { + g[y][r] = 'W'; + g[y][C - 1 - r] = 'W'; + } + const innermost = r + 3 > maxRing; + const gaps = innermost ? 1 : 2; + for (let i = 0; i < gaps; i++) { + const side = rnd.between(0, 3); + let gy, gx; + if (side === 0) [gy, gx] = [r, rnd.between(r + 1, C - r - 2)]; + else if (side === 1) [gy, gx] = [R - 1 - r, rnd.between(r + 1, C - r - 2)]; + else if (side === 2) [gy, gx] = [rnd.between(r + 1, R - r - 2), r]; + else [gy, gx] = [rnd.between(r + 1, R - r - 2), C - 1 - r]; + g[gy][gx] = '.'; + if (innermost) { + vaultInset = r; + this.vaultDoor = [gy, gx]; + } } } - if (!carved) stack.pop(); + } else if (kind === 'quadrants') { + // four rooms behind a cross of walls with doorways punched through + openAll(); + const my = Math.floor(R / 2); + const mx = Math.floor(C / 2); + for (let x = 1; x < C - 1; x++) g[my][x] = 'W'; + for (let y = 1; y < R - 1; y++) g[y][mx] = 'W'; + g[my][rnd.between(1, mx - 1)] = '.'; + g[my][rnd.between(mx + 1, C - 2)] = '.'; + g[rnd.between(1, my - 1)][mx] = '.'; + g[rnd.between(my + 1, R - 2)][mx] = '.'; + for (let i = 0; i < 6 + tier * 4; i++) { + const by = rnd.between(1, R - 3); + const bx = rnd.between(1, C - 3); + if (g[by][bx] === '.' && g[by][bx + 1] === '.') { + g[by][bx] = 'W'; + g[by][bx + 1] = 'W'; + } + } + } else if (kind === 'cells') { + // honeycomb of small rooms joined by doorways + openAll(); + for (let y = 3; y < R - 1; y += 4) for (let x = 1; x < C - 1; x++) g[y][x] = 'W'; + for (let x = 4; x < C - 1; x += 5) for (let y = 1; y < R - 1; y++) g[y][x] = 'W'; + for (let y = 3; y < R - 1; y += 4) + for (let x = 1; x < C - 2; x += 5) g[y][rnd.between(x, Math.min(x + 3, C - 2))] = '.'; + for (let x = 4; x < C - 1; x += 5) + for (let y = 1; y < R - 2; y += 4) g[rnd.between(y, Math.min(y + 2, R - 2))][x] = '.'; + } else { + // arena — IT'S A TRAP: wide open, a few pillars, ringed with generators + openAll(); + for (let i = 0; i < 5 + tier * 2; i++) { + const py = rnd.between(2, R - 4); + const px = rnd.between(2, C - 4); + g[py][px] = 'W'; + g[py][px + 1] = 'W'; + g[py + 1][px] = 'W'; + g[py + 1][px + 1] = 'W'; + } } - // knock out ~12% of interior walls for loops, then clear two rooms - for (let y = 1; y < R - 1; y++) - for (let x = 1; x < C - 1; x++) - if (g[y][x] === 'W' && rnd.frac() < 0.12) g[y][x] = '.'; - for (const [ry, rx] of [[Math.floor(R / 2), Math.floor(C / 2)], [R - 5, 5]]) - for (let y = ry - 1; y <= ry + 1; y++) - for (let x = rx - 2; x <= rx + 2; x++) g[y][x] = '.'; - const drop = (ch) => { - for (let tries = 0; tries < 200; tries++) { + const inVault = (y, x) => + vaultInset != null && y > vaultInset && y < R - 1 - vaultInset && x > vaultInset && x < C - 1 - vaultInset; + const drop = (ch, ok) => { + for (let tries = 0; tries < 400; tries++) { const y = rnd.between(1, R - 2); const x = rnd.between(1, C - 2); - if (g[y][x] === '.' && (y > 3 || x > 3)) { + if (g[y][x] === '.' && (y > 3 || x > 3) && (!ok || ok(y, x))) { g[y][x] = ch; - return; + return true; } } + return false; }; + + // start + exit + g[1][1] = '.'; g[1][1] = 'P'; - g[R - 2][C - 2] = 'X'; - const gens = Math.min(6, 3 + Math.floor(index / 4)); + if (kind === 'fortress') g[Math.floor(R / 2)][Math.floor(C / 2)] = 'X'; + else { + g[R - 2][C - 2] = '.'; + g[R - 2][C - 2] = 'X'; + } + + // the trap arena is generator hell; everything else scales gently with depth + const gens = kind === 'arena' ? 6 + tier * 2 : Math.min(6, 3 + Math.floor(index / 4)); for (let i = 0; i < gens; i++) drop('G'); for (let i = 0; i < 4; i++) drop('E'); - for (let i = 0; i < 3; i++) drop('R'); + for (let i = 0; i < 3 + tier; i++) drop('R'); drop('A'); drop('S'); if (index >= 6) drop('F'); // the fire marshal starts showing up once it's late - const theme = THEMES[index % THEMES.length]; + if (kind === 'fortress') { + // keys must live OUTSIDE the vault or the level is unwinnable + drop('K', (y, x) => !inVault(y, x)); + drop('K', (y, x) => !inVault(y, x)); + const [dy, dx] = this.vaultDoor; + g[dy][dx] = 'D'; + } if (theme.dark) { drop('Q'); for (let i = 0; i < 5; i++) drop('L'); } - // ponytail: no keys/doors in procgen — reachability guarantees aren't worth it + + // Safety net: a generated level that can't be finished is a dead run. Flood from + // the start (velvet ropes count as passable — lanyards exist); if the booth isn't + // reachable, carve an L-corridor to it. Only ever turns wall into floor, so it + // can't eat a fixture. Catches sealed honeycomb cells and any future archetype. + const reachable = () => { + let start; + for (let y = 0; y < R; y++) for (let x = 0; x < C; x++) if (g[y][x] === 'P') start = [y, x]; + const seen = new Set([start.join(',')]); + const q = [start]; + while (q.length) { + const [y, x] = q.shift(); + for (const [dy, dx] of [[0, 1], [0, -1], [1, 0], [-1, 0]]) { + const ny = y + dy; + const nx = x + dx; + const k = `${ny},${nx}`; + if (ny < 1 || nx < 1 || ny >= R - 1 || nx >= C - 1 || seen.has(k)) continue; + if (g[ny][nx] === 'W') continue; + seen.add(k); + q.push([ny, nx]); + } + } + return seen; + }; + let ey, ex, sy, sx; + for (let y = 0; y < R; y++) + for (let x = 0; x < C; x++) { + if (g[y][x] === 'X') [ey, ex] = [y, x]; + if (g[y][x] === 'P') [sy, sx] = [y, x]; + } + if (!reachable().has(`${ey},${ex}`)) { + for (let x = Math.min(sx, ex); x <= Math.max(sx, ex); x++) if (g[sy][x] === 'W') g[sy][x] = '.'; + for (let y = Math.min(sy, ey); y <= Math.max(sy, ey); y++) if (g[y][ex] === 'W') g[y][ex] = '.'; + } return g.map((row) => row.join('')); } diff --git a/src/announcer.js b/src/announcer.js index de6b31b..5e3749c 100644 --- a/src/announcer.js +++ b/src/announcer.js @@ -58,4 +58,7 @@ export const LINES = { rpm78Dead: ['rpm78Dead', 'It has a name now. Yours.'], fireMarshal: ['fireMarshal', 'The fire marshal is checking capacity.'], scalper: ['scalper', 'Scalper on the floor. Mind your grails.'], + trap: ['trap', "It's a trap!"], // the line + invisible: ['invisible', 'The racks are hiding. Feel your way.'], + vault: ['vault', 'The back room is locked. Find the lanyard.'], };