Tier 2 complete: digging, treasure rooms, the Archive, infinite mazes

- Digging: hold E (or DIG touch button) by a crate wall, 2s rooted channel, once
  per tile. Loot: 70% +200 vibe, 25% gold record, 4% grail (full vibe), 1%
  bootleg (every bin on the level erupts).
- Treasure rooms: every 3rd level, 20s bonus room strewn with gold, no enemies,
  booth exits instantly, "CLOSING TIME" when the timer dies.
- THE ARCHIVE (4th theme): FLUX-generated dark tileset, real darkness via
  RenderTexture with radial glow holes at player + lamps, dust mites spawn in
  packs of 3, Mite Queen miniboss (12 HP, broods mites every 5s, airhorn-immune,
  drops 3 gold + 1000 pts). Archive tune pool: 195 dnb/hardcore/breakbeat/
  electro 12"s (718 total tunes now).
- Infinite: levels past the 4 authored maps are deterministic seeded mazes
  (recursive backtracker + loops + rooms), difficulty scales with depth.

All verified in-browser via engine stepping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-16 17:35:42 +10:00
parent a1f4588deb
commit 8e967407c1
13 changed files with 376 additions and 51 deletions

View File

@ -20,7 +20,7 @@ import server.db as db # noqa: E402
import server.runner as runner # noqa: E402
HERE = Path(__file__).resolve().parent
q = json.loads((HERE / "queue.json").read_text())
q = json.loads((HERE / (sys.argv[1] if len(sys.argv) > 1 else "queue.json")).read_text())
styles = q["style"]
defaults = q["defaults"]
@ -36,7 +36,7 @@ for i, item in enumerate(q["items"]):
"steps": defaults["steps"],
"width": item["w"],
"height": item["h"],
"seed": 7 + i, # per-asset fixed seed = reproducible
"seed": item.get("seed", 7 + i), # per-asset fixed seed = reproducible
}
job_id = db.new_id()
outdir = runner.JOBS_DIR / job_id

View File

@ -34,6 +34,12 @@ POOLS = {
("Techno", False, 1985, 2005, 70, True),
("Jungle", False, 1990, 2000, 50, True),
],
"archive": [ # THE ARCHIVE: the deep stuff, 12"s only
("Drum n Bass", False, 1992, 2005, 60, True),
("Hardcore", False, 1990, 1997, 50, True),
("Breakbeat", False, 1988, 2005, 50, True),
("Electro", False, 1982, 2005, 50, True),
],
}
GRAIL_FRACTION = 0.08 # top of each ranked chunk = certified classic

View File

@ -27,6 +27,11 @@ TABLE = {
"pirate": ("enemy_record_pirate.png", 48, False),
"ghoul": ("enemy_crate_ghoul.png", 48, False),
"gold": ("pickup_goldrecord.png", 38, False),
"floor_archive": ("floor_archive.png", 96, True),
"wall_archive": ("wall_archive.png", 48, True),
"mite": ("enemy_dust_mite.png", 26, False),
"queen": ("enemy_mite_queen.png", 96, False),
"lamp": ("prop_lamp.png", 40, False),
"player": ("player_digger.png", 48, False),
"poser": ("enemy_poser.png", 48, False),
"nerd": ("enemy_gearnerd.png", 48, False),

15
assets/queue2.json Normal file
View File

@ -0,0 +1,15 @@
{
"_comment": "Batch 2: the Archive. Explicit seeds so they never collide with batch 1 (seeds 7-28).",
"style": {
"sprite": "16-bit SNES-era pixel art game sprite, top-down view, bold clean black outline, vibrant saturated colors, single centered subject, flat solid magenta #ff00ff background, no text, no watermark, no drop shadow",
"tile": "16-bit pixel art, seamless tileable top-down floor texture, flat even lighting, no objects, no perspective, no text",
"wall": "16-bit pixel art, top-down game wall tile, seamless, dark, no text"
},
"defaults": { "model": "flux2-klein-4b", "steps": 4 },
"items": [
{ "name": "enemy_dust_mite", "seed": 40, "style": "sprite", "w": 512, "h": 512, "prompt": "a tiny angry dust mite bug monster, round fuzzy brown body, six legs, beady eyes" },
{ "name": "enemy_mite_queen", "seed": 41, "style": "sprite", "w": 768, "h": 768, "prompt": "a bloated giant dust mite queen monster with a tiny gold crown, fat segmented fuzzy body, menacing boss creature" },
{ "name": "floor_archive", "seed": 42, "style": "tile", "w": 512, "h": 512, "prompt": "dark dusty stone basement floor, cracked flagstones, very dark and moody" },
{ "name": "wall_archive", "seed": 43, "style": "wall", "w": 512, "h": 512, "prompt": "dark basement archive shelving stuffed with decaying cardboard record boxes and cobwebs" }
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/sprites/lamp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/sprites/mite.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
public/sprites/queen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser';
import { LEVELS } from './levels.js';
import { LEVELS, BONUS_LEVEL } from './levels.js';
import { announce, LINES } from './announcer.js';
const TILE = 48;
@ -19,13 +19,18 @@ const TRAINSPOT_MS = 3000;
const GOLD_SCORE = 100;
const KILL_SCORE = 10;
const CLEAR_SCORE = 500;
const QUEEN_SCORE = 1000;
const DIG_MS = 2000; // hold E next to a crate wall
const DIG_RANGE = 70;
const BONUS_MS = 20000; // treasure room timer
const QUEEN_BROOD_MS = 5000; // queen spits mites this often
// texture keys loaded from public/sprites/<key>.png (see assets/process_sprites.py)
const TEXTURE_KEYS = [
'floor', 'floor_carpet', 'floor_checker',
'wall', 'wall_shelf', 'wall_poster',
'player', 'poser', 'nerd', 'pirate', 'ghoul', 'soundguy', 'generator',
'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl', 'gold',
'floor', 'floor_carpet', 'floor_checker', 'floor_archive',
'wall', 'wall_shelf', 'wall_poster', 'wall_archive',
'player', 'poser', 'nerd', 'pirate', 'ghoul', 'mite', 'queen', 'soundguy', 'generator',
'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl', 'gold', 'lamp',
];
// Gauntlet II classes: stat tweaks only, no new mechanics.
@ -37,10 +42,12 @@ const CLASSES = {
};
const ENEMY_TYPES = {
poser: { tex: 'poser', speed: 180, touch: 30, hp: 1 },
nerd: { tex: 'nerd', speed: 105, touch: 80, hp: 1 },
pirate: { tex: 'pirate', speed: 210, touch: 20, hp: 1 }, // steals pickups, flees home
ghoul: { tex: 'ghoul', speed: 70, touch: 120, hp: 3 }, // slow tank
poser: { tex: 'poser', speed: 180, touch: 30, hp: 1, size: 34 },
nerd: { tex: 'nerd', speed: 105, touch: 80, hp: 1, size: 34 },
pirate: { tex: 'pirate', speed: 210, touch: 20, hp: 1, size: 34 }, // steals pickups, flees home
ghoul: { tex: 'ghoul', speed: 70, touch: 120, hp: 3, size: 34 }, // slow tank
mite: { tex: 'mite', speed: 240, touch: 15, hp: 1, size: 18 }, // tiny, fast, spawns in packs
queen: { tex: 'queen', speed: 40, touch: 60, hp: 12, size: 70, boss: true }, // broods mites
};
// Level themes cycle with level index; mix = weighted generator spawn table.
@ -48,6 +55,7 @@ const THEMES = [
{ name: 'RECORD SHOP', pool: 'shop', floor: 'floor', wall: 'wall', mix: [['poser', 0.7], ['nerd', 0.3]] },
{ name: 'RECORD FAIR', pool: 'fair', floor: 'floor_carpet', wall: 'wall_shelf', mix: [['poser', 0.5], ['nerd', 0.25], ['pirate', 0.25]] },
{ name: 'CLUB NIGHT', pool: 'club', floor: 'floor_checker', wall: 'wall_poster', mix: [['poser', 0.4], ['nerd', 0.2], ['pirate', 0.2], ['ghoul', 0.2]] },
{ name: 'THE ARCHIVE', pool: 'archive', floor: 'floor_archive', wall: 'wall_archive', dark: true, mix: [['mite', 0.45], ['ghoul', 0.3], ['poser', 0.25]] },
];
const ID_SCORE = 100; // re-identifying a tune you already know
const FIRST_ID_SCORE = 1000; // new notebook entry
@ -101,6 +109,7 @@ export default class GameScene extends Phaser.Scene {
this.wasd = this.input.keyboard.addKeys('W,A,S,D');
this.keySpace = this.input.keyboard.addKey('SPACE');
this.keyR = this.input.keyboard.addKey('R');
this.keyE = this.input.keyboard.addKey('E');
this.input.keyboard.on('keydown', (ev) => {
if (this.state !== 'TITLE') return;
const i = ['1', '2', '3', '4'].indexOf(ev.key);
@ -171,6 +180,29 @@ export default class GameScene extends Phaser.Scene {
.setDepth(90)
.setAlpha(0);
// The Archive's darkness: black RT with radial holes erased at player + lamps.
const makeGlow = (key, size) => {
const glow = this.textures.createCanvas(key, size, size);
const ctx = glow.getContext();
const half = size / 2;
const grad = ctx.createRadialGradient(half, half, size / 12, half, half, half);
grad.addColorStop(0, 'rgba(255,255,255,1)');
grad.addColorStop(0.6, 'rgba(255,255,255,0.7)');
grad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, size, size);
glow.refresh();
};
makeGlow('glow', 512);
makeGlow('glow_sm', 256);
this.darkRT = this.add
.renderTexture(0, 0, 960, 720)
.setOrigin(0)
.setScrollFactor(0)
.setDepth(85)
.setVisible(false);
this.lamps = []; // world coords of L tiles, light holes in the dark
// Vibe heartbeat.
this.time.addEvent({
delay: 1000,
@ -192,7 +224,10 @@ export default class GameScene extends Phaser.Scene {
this.input.addPointer(2);
this.touchMove = null;
this.touchFire = null;
const overHorn = (p) => this.hornBtn && Phaser.Math.Distance.Between(p.x, p.y, 870, 630) < 60;
const overHorn = (p) =>
this.hornBtn &&
(Phaser.Math.Distance.Between(p.x, p.y, 870, 630) < 60 ||
Phaser.Math.Distance.Between(p.x, p.y, 770, 630) < 60);
this.input.on('pointerdown', (p) => {
if (this.state !== 'PLAYING' || overHorn(p)) return;
const stick = { id: p.id, ox: p.x, oy: p.y, dx: 0, dy: 0 };
@ -212,18 +247,27 @@ export default class GameScene extends Phaser.Scene {
if (this.touchFire && this.touchFire.id === p.id) this.touchFire = null;
});
if (this.sys.game.device.input.touch) {
this.hornBtn = this.add
.circle(870, 630, 42, 0xffffff, 0.25)
.setScrollFactor(0)
.setDepth(100)
.setStrokeStyle(2, 0xffffff)
.setInteractive();
this.add
.text(870, 630, 'HORN', { fontFamily: 'monospace', fontSize: '14px', color: '#ffffff' })
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(101);
this.hornBtn.on('pointerdown', () => this.doAirhorn());
const btn = (x, label, cb, hold) => {
const c = this.add
.circle(x, 630, 42, 0xffffff, 0.25)
.setScrollFactor(0)
.setDepth(100)
.setStrokeStyle(2, 0xffffff)
.setInteractive();
this.add
.text(x, 630, label, { fontFamily: 'monospace', fontSize: '14px', color: '#ffffff' })
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(101);
if (hold) {
c.on('pointerdown', () => (this.touchDig = true));
c.on('pointerup', () => (this.touchDig = false));
c.on('pointerout', () => (this.touchDig = false));
} else c.on('pointerdown', cb);
return c;
};
this.hornBtn = btn(870, 'HORN', () => this.doAirhorn());
this.digBtn = btn(770, 'DIG', null, true);
}
}
@ -238,14 +282,24 @@ export default class GameScene extends Phaser.Scene {
this.trainspotProgress = 0;
}
buildLevel(index) {
const map = LEVELS[index];
buildLevel(index, bonusMap = null) {
const map = bonusMap || (index < LEVELS.length ? LEVELS[index] : this.genLevel(index));
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})`);
this.levelIndex = index;
this.isBonus = !!bonusMap;
this.theme = THEMES[index % THEMES.length];
this.dugTiles = new Set();
this.digging = null;
if (this.bonusTimer) {
this.bonusTimer.remove();
this.bonusTimer = null;
}
this.lamps = [];
if (this.lampSprites) this.lampSprites.forEach((l) => l.destroy());
this.lampSprites = [];
this.walls.clear(true, true);
this.doors.clear(true, true);
this.pickups.clear(true, true);
@ -309,6 +363,16 @@ export default class GameScene extends Phaser.Scene {
this.say(LINES.soundGuy);
break;
}
case 'Q': {
this.spawnEnemy({ x, y }, 'queen');
this.say(LINES.queen);
break;
}
case 'L': {
this.lamps.push({ x, y });
this.lampSprites.push(this.add.image(x, y, 'lamp').setDepth(-5));
break;
}
case 'X': {
const ex = this.exits.create(x, y, 'exit');
this.tweens.add({
@ -324,9 +388,90 @@ export default class GameScene extends Phaser.Scene {
}
}
this.density = this.densityBase;
this.density = this.densityBase + index * 0.08; // deeper = busier
this.levelStartTime = this.time.now;
this.trainspotProgress = 0;
this.darkRT.setVisible(!!this.theme.dark && !this.isBonus);
if (this.isBonus) {
this.bonusTimer = this.time.delayedCall(BONUS_MS, () => this.endBonus());
this.say(LINES.bonus);
}
}
// Deterministic maze for levels past the hand-authored ones. Same level = same maze.
genLevel(index) {
const rnd = new Phaser.Math.RandomDataGenerator([String(index * 7919)]);
const C = 31; // odd
const R = 21; // odd
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;
}
}
if (!carved) stack.pop();
}
// 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 y = rnd.between(1, R - 2);
const x = rnd.between(1, C - 2);
if (g[y][x] === '.' && (y > 3 || x > 3)) {
g[y][x] = ch;
return;
}
}
};
g[1][1] = 'P';
g[R - 2][C - 2] = 'X';
const gens = 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');
drop('A');
drop('S');
const theme = THEMES[index % THEMES.length];
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
return g.map((row) => row.join(''));
}
endBonus() {
if (!this.isBonus || this.state !== 'PLAYING') return;
const next = this.pendingNext;
this.state = 'LEVEL_CLEAR';
this.physics.pause();
this.showOverlay('CLOSING TIME');
this.time.delayedCall(1200, () => {
this.physics.resume();
this.hideOverlay();
this.state = 'PLAYING';
this.buildLevel(next);
});
}
update(time, delta) {
@ -337,18 +482,102 @@ export default class GameScene extends Phaser.Scene {
}
if (this.state === 'LEVEL_CLEAR') return;
this.handleDig(delta);
this.handleMovement();
this.handleFiring(time);
this.cullVinyl(time);
this.updateEnemies();
this.updateGenerators(delta);
this.updateSoundGuys();
this.density = this.densityBase + 0.05 * Math.floor((time - this.levelStartTime) / 10000);
this.density =
this.densityBase + this.levelIndex * 0.08 + 0.05 * Math.floor((time - this.levelStartTime) / 10000);
if (Phaser.Input.Keyboard.JustDown(this.keySpace)) this.doAirhorn();
this.handleTrainspot(delta);
this.renderDarkness();
this.updateHud();
}
renderDarkness() {
if (!this.darkRT.visible) return;
const cam = this.cameras.main;
this.darkRT.clear();
this.darkRT.fill(0x000000, 0.94);
const px = this.player.x - cam.scrollX;
const py = this.player.y - cam.scrollY;
this.darkRT.erase('glow', px - 256, py - 256);
for (const l of this.lamps) {
const lx = l.x - cam.scrollX;
const ly = l.y - cam.scrollY;
if (lx > -300 && lx < 1260 && ly > -300 && ly < 1020) {
this.darkRT.erase('glow_sm', lx - 128, ly - 128); // smaller pool of light per lamp
}
}
}
// Hold E next to a crate wall: 2s channel, once per tile, loot table inside.
handleDig(delta) {
const digHeld = this.keyE.isDown || this.touchDig;
if (!digHeld || this.isBonus) {
this.digging = null;
return;
}
if (!this.digging) {
let best = null;
let bestD = DIG_RANGE;
this.walls.getChildren().forEach((w) => {
const key = `${w.x},${w.y}`;
if (this.dugTiles.has(key)) return;
const d = Phaser.Math.Distance.Between(this.player.x, this.player.y, w.x, w.y);
if (d < bestD) {
bestD = d;
best = w;
}
});
if (!best) return;
this.digging = { wall: best, progress: 0 };
}
this.player.setVelocity(0, 0); // rooted while digging — that's the risk
this.digging.progress += delta;
this.barBg.setVisible(true);
this.barFill.setVisible(true);
this.barFill.setFillStyle(0x3ad13a);
this.barFill.setSize(300 * Math.min(1, this.digging.progress / DIG_MS), 20);
if (this.digging.progress >= DIG_MS) {
const w = this.digging.wall;
this.dugTiles.add(`${w.x},${w.y}`);
w.setTint(0x666666); // visibly picked-over
this.digging = null;
this.barFill.setFillStyle(0xff00ff);
this.barBg.setVisible(false);
this.barFill.setVisible(false);
this.digLoot(w);
}
}
digLoot(w) {
const roll = Math.random();
if (roll < 0.7) {
this.vibe = Math.min(VIBE_MAX, this.vibe + 200);
this.cameras.main.flash(120, 60, 200, 60);
} else if (roll < 0.95) {
this.pickups.create(w.x, w.y - TILE, 'gold').setData('type', 'gold');
this.say(LINES.gold);
} else if (roll < 0.99) {
this.vibe = VIBE_MAX;
this.cameras.main.flash(500, 255, 215, 0);
this.say(LINES.grail);
} else {
// bootleg: every bin on the level erupts
this.generators.getChildren().forEach((g) => {
if (g.active) {
this.spawnEnemy(g);
this.spawnEnemy(g);
}
});
this.say(LINES.bootleg);
}
}
handleMovement() {
const { W, A, S, D } = this.wasd;
let vx = (D.isDown ? 1 : 0) - (A.isDown ? 1 : 0);
@ -423,6 +652,11 @@ export default class GameScene extends Phaser.Scene {
this.enemies.getChildren().forEach((e) => {
if (!e.active) return;
let target = this.player;
if (e.getData('type') === 'queen' && this.time.now > e.getData('nextBrood')) {
e.setData('nextBrood', this.time.now + QUEEN_BROOD_MS);
this.spawnEnemy({ x: e.x, y: e.y }, 'mite');
this.spawnEnemy({ x: e.x + 24, y: e.y }, 'mite');
}
if (e.getData('type') === 'pirate') {
if (e.getData('carry')) {
const home = e.getData('home');
@ -462,21 +696,26 @@ export default class GameScene extends Phaser.Scene {
if (roll <= 0) break;
}
}
const def = ENEMY_TYPES[type];
const e = this.enemies.get(g.x, g.y, def.tex);
if (!e) return; // pool full — skip
e.setTexture(def.tex);
e.setActive(true).setVisible(true);
e.clearTint();
e.body.enable = true;
e.body.setSize(34, 34, true); // body smaller than 48px sprite
e.setPosition(g.x, g.y);
e.setData('type', type);
e.setData('touch', def.touch);
e.setData('speed', def.speed);
e.setData('hp', def.hp);
e.setData('carry', null);
e.setData('home', { x: g.x, y: g.y });
// mites come in packs of 3
const count = type === 'mite' && !forceType ? 3 : 1;
for (let i = 0; i < count; i++) {
const def = ENEMY_TYPES[type];
const e = this.enemies.get(g.x, g.y, def.tex);
if (!e) return; // pool full — skip
e.setTexture(def.tex);
e.setActive(true).setVisible(true);
e.clearTint();
e.body.enable = true;
e.body.setSize(def.size, def.size, true); // body smaller than sprite
e.setPosition(g.x + i * 20, g.y);
e.setData('type', type);
e.setData('touch', def.touch);
e.setData('speed', def.speed);
e.setData('hp', def.hp);
e.setData('carry', null);
e.setData('home', { x: g.x, y: g.y });
e.setData('nextBrood', this.time.now + QUEEN_BROOD_MS);
}
if (type === 'pirate') this.say(LINES.pirate);
}
@ -492,7 +731,9 @@ export default class GameScene extends Phaser.Scene {
this.cameras.main.flash(200, 255, 255, 255);
this.cameras.main.shake(200, 0.01);
this.enemies.getChildren().forEach((e) => {
if (e.active) this.dropLoot(e) || this.kill(e);
if (!e.active) return;
if (ENEMY_TYPES[e.getData('type')]?.boss) return; // the queen ignores airhorns
this.dropLoot(e) || this.kill(e);
});
this.soundGuys.clear(true, true);
this.airhornBlast();
@ -500,6 +741,7 @@ export default class GameScene extends Phaser.Scene {
handleTrainspot(delta) {
if (this.physics.overlap(this.player, this.exits)) {
if (this.isBonus) return this.levelClear(); // instant exit in a treasure room
if (this.trainspotProgress === 0) this.say(LINES.trainspot);
this.trainspotProgress += delta;
this.barBg.setVisible(true);
@ -515,8 +757,11 @@ export default class GameScene extends Phaser.Scene {
updateHud() {
const v = Math.max(0, Math.ceil(this.vibe));
const tail = this.isBonus
? `BONUS ${Math.max(0, Math.ceil(this.bonusTimer.getRemainingSeconds()))}s`
: `LEVEL ${this.levelIndex + 1}`;
this.hud.setText(
`SCORE ${this.score} VIBE ${v} LANYARDS ${this.lanyards} AIRHORNS ${this.airhorns} LEVEL ${this.levelIndex + 1}`,
`SCORE ${this.score} VIBE ${v} LANYARDS ${this.lanyards} AIRHORNS ${this.airhorns} ${tail}`,
);
this.hud.setColor(this.vibe < 200 ? '#ff4040' : this.vibe < 500 ? '#ffd11a' : '#ffffff');
this.vignette.setAlpha(this.vibe < VIBE_MAX * 0.25 ? 0.12 : 0);
@ -550,6 +795,13 @@ export default class GameScene extends Phaser.Scene {
return;
}
this.score += KILL_SCORE;
if (e.getData('type') === 'queen') {
this.score += QUEEN_SCORE;
for (let i = 0; i < 3; i++)
this.pickups.create(e.x + (i - 1) * TILE, e.y, 'gold').setData('type', 'gold');
this.cameras.main.shake(300, 0.008);
this.say(LINES.queenDead);
}
if (!this.dropLoot(e)) this.kill(e);
}
@ -678,12 +930,13 @@ export default class GameScene extends Phaser.Scene {
levelClear() {
if (this.state !== 'PLAYING') return;
if (this.isBonus) return this.endBonus(); // booth in a treasure room just leaves
this.state = 'LEVEL_CLEAR';
this.score += CLEAR_SCORE;
this.physics.pause();
this.barBg.setVisible(false);
this.barFill.setVisible(false);
const next = (this.levelIndex + 1) % LEVELS.length;
const next = this.levelIndex + 1; // infinite — procgen past the authored maps
const tune = this.spotTune();
if (tune) {
this.score += tune.first ? (tune.g ? GRAIL_ID_SCORE : FIRST_ID_SCORE) : ID_SCORE;
@ -704,10 +957,14 @@ export default class GameScene extends Phaser.Scene {
}
this.time.delayedCall(2800, () => {
this.physics.resume();
if (this.levelIndex + 1 >= LEVELS.length) this.densityBase += 0.5; // loop back harder
this.hideOverlay();
this.state = 'PLAYING';
this.buildLevel(next);
if (next % 3 === 0) {
this.pendingNext = next; // every 3rd level: treasure room first
this.buildLevel(this.levelIndex, BONUS_LEVEL);
} else {
this.buildLevel(next);
}
});
}

View File

@ -47,4 +47,9 @@ export const LINES = {
shotEspresso: ['shotEspresso', 'Selector shot the espresso.'],
pirate: ['pirate', 'Record pirate. Protect the wax.'],
gold: ['gold', 'Wax acquired.'],
grail: ['grail', 'Grailz. Absolute grailz.'],
bootleg: ['bootleg', 'Bootleg. Everybody heard that.'],
queen: ['queen', 'The mite queen... is awake.'],
queenDead: ['queenDead', 'The queen is dead. Dig in peace.'],
bonus: ['bonus', 'Fill your boots. Twenty seconds.'],
};

View File

@ -1,7 +1,8 @@
// Levels are arrays of equal-length strings. Legend:
// W wall (crate) . floor P player start X DJ booth exit
// E espresso K lanyard/key D velvet rope G generator (discount bin)
// S sound guy A airhorn
// S sound guy A airhorn R gold record Q mite queen (miniboss)
// L lamp (light source in dark levels)
//
// Authoring: edit the strings inside edge("..."). `edge` wraps each row in side
// walls and pads the right with floor, so rows are always the right width — you
@ -83,4 +84,40 @@ const level3 = [
wall,
];
export const LEVELS = [level1, level2, level3];
// THE ARCHIVE — dark. Lamps are the only landmarks; the queen guards the booth.
const level4 = [
wall,
edge('..P........L'),
edge('.WWWW.WWWWWW.WWWW.WWWW'),
edge('.W..............G..W'),
edge('.W.WWWW.L.WWWWWW.W.W'),
edge('...W..........W....W'),
edge('.E.W..WWWWWW..W.G.'),
edge('.WWW..W....W..WWWWWW'),
edge('......W.L..W........L'),
edge('.G....W....WWWWWW.WW'),
edge('.WWWWWW..........K.W'),
edge('.W......WWWWWWWW...W'),
edge('.W.R.L..W......WWWWW'),
edge('.WWWWW..W..Q...D...'),
edge('....A...W......W.R.'),
edge('.S......W..L...W...X'),
wall,
];
export const LEVELS = [level1, level2, level3, level4];
// Treasure room: 20 seconds, no enemies, floor strewn with gold. Greed is good.
export const BONUS_LEVEL = [
'WWWWWWWWWWWWWWWWWWWW',
'WP...R....R....R...W',
'W..R....R....R..R..W',
'W....R....R....R...W',
'W.R....R....R....R.W',
'W....R....R..R.....W',
'W..R....R....R..R..W',
'W....R....R....R...W',
'W.R....R.....R....EW',
'W....R....R....R..XW',
'WWWWWWWWWWWWWWWWWWWW',
];