Procgen: six Gauntlet II archetypes, depth-scaled size, solvability guarantee
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 <noreply@anthropic.com>
This commit is contained in:
parent
df25531893
commit
2266e9feeb
227
src/GameScene.js
227
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(''));
|
||||
}
|
||||
|
||||
|
||||
@ -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.'],
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user