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:
parent
a1f4588deb
commit
8e967407c1
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
15
assets/queue2.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
BIN
public/sprites/floor_archive.png
Normal file
BIN
public/sprites/floor_archive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
public/sprites/lamp.png
Normal file
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
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
BIN
public/sprites/queen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
public/sprites/wall_archive.png
Normal file
BIN
public/sprites/wall_archive.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
File diff suppressed because one or more lines are too long
349
src/GameScene.js
349
src/GameScene.js
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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.'],
|
||||
};
|
||||
|
||||
@ -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',
|
||||
];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user