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
|
import server.runner as runner # noqa: E402
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
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"]
|
styles = q["style"]
|
||||||
defaults = q["defaults"]
|
defaults = q["defaults"]
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ for i, item in enumerate(q["items"]):
|
|||||||
"steps": defaults["steps"],
|
"steps": defaults["steps"],
|
||||||
"width": item["w"],
|
"width": item["w"],
|
||||||
"height": item["h"],
|
"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()
|
job_id = db.new_id()
|
||||||
outdir = runner.JOBS_DIR / job_id
|
outdir = runner.JOBS_DIR / job_id
|
||||||
|
|||||||
@ -34,6 +34,12 @@ POOLS = {
|
|||||||
("Techno", False, 1985, 2005, 70, True),
|
("Techno", False, 1985, 2005, 70, True),
|
||||||
("Jungle", False, 1990, 2000, 50, 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
|
GRAIL_FRACTION = 0.08 # top of each ranked chunk = certified classic
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,11 @@ TABLE = {
|
|||||||
"pirate": ("enemy_record_pirate.png", 48, False),
|
"pirate": ("enemy_record_pirate.png", 48, False),
|
||||||
"ghoul": ("enemy_crate_ghoul.png", 48, False),
|
"ghoul": ("enemy_crate_ghoul.png", 48, False),
|
||||||
"gold": ("pickup_goldrecord.png", 38, 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),
|
"player": ("player_digger.png", 48, False),
|
||||||
"poser": ("enemy_poser.png", 48, False),
|
"poser": ("enemy_poser.png", 48, False),
|
||||||
"nerd": ("enemy_gearnerd.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 Phaser from 'phaser';
|
||||||
import { LEVELS } from './levels.js';
|
import { LEVELS, BONUS_LEVEL } from './levels.js';
|
||||||
import { announce, LINES } from './announcer.js';
|
import { announce, LINES } from './announcer.js';
|
||||||
|
|
||||||
const TILE = 48;
|
const TILE = 48;
|
||||||
@ -19,13 +19,18 @@ const TRAINSPOT_MS = 3000;
|
|||||||
const GOLD_SCORE = 100;
|
const GOLD_SCORE = 100;
|
||||||
const KILL_SCORE = 10;
|
const KILL_SCORE = 10;
|
||||||
const CLEAR_SCORE = 500;
|
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)
|
// texture keys loaded from public/sprites/<key>.png (see assets/process_sprites.py)
|
||||||
const TEXTURE_KEYS = [
|
const TEXTURE_KEYS = [
|
||||||
'floor', 'floor_carpet', 'floor_checker',
|
'floor', 'floor_carpet', 'floor_checker', 'floor_archive',
|
||||||
'wall', 'wall_shelf', 'wall_poster',
|
'wall', 'wall_shelf', 'wall_poster', 'wall_archive',
|
||||||
'player', 'poser', 'nerd', 'pirate', 'ghoul', 'soundguy', 'generator',
|
'player', 'poser', 'nerd', 'pirate', 'ghoul', 'mite', 'queen', 'soundguy', 'generator',
|
||||||
'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl', 'gold',
|
'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl', 'gold', 'lamp',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Gauntlet II classes: stat tweaks only, no new mechanics.
|
// Gauntlet II classes: stat tweaks only, no new mechanics.
|
||||||
@ -37,10 +42,12 @@ const CLASSES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ENEMY_TYPES = {
|
const ENEMY_TYPES = {
|
||||||
poser: { tex: 'poser', speed: 180, touch: 30, hp: 1 },
|
poser: { tex: 'poser', speed: 180, touch: 30, hp: 1, size: 34 },
|
||||||
nerd: { tex: 'nerd', speed: 105, touch: 80, hp: 1 },
|
nerd: { tex: 'nerd', speed: 105, touch: 80, hp: 1, size: 34 },
|
||||||
pirate: { tex: 'pirate', speed: 210, touch: 20, hp: 1 }, // steals pickups, flees home
|
pirate: { tex: 'pirate', speed: 210, touch: 20, hp: 1, size: 34 }, // steals pickups, flees home
|
||||||
ghoul: { tex: 'ghoul', speed: 70, touch: 120, hp: 3 }, // slow tank
|
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.
|
// 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 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: '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: '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 ID_SCORE = 100; // re-identifying a tune you already know
|
||||||
const FIRST_ID_SCORE = 1000; // new notebook entry
|
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.wasd = this.input.keyboard.addKeys('W,A,S,D');
|
||||||
this.keySpace = this.input.keyboard.addKey('SPACE');
|
this.keySpace = this.input.keyboard.addKey('SPACE');
|
||||||
this.keyR = this.input.keyboard.addKey('R');
|
this.keyR = this.input.keyboard.addKey('R');
|
||||||
|
this.keyE = this.input.keyboard.addKey('E');
|
||||||
this.input.keyboard.on('keydown', (ev) => {
|
this.input.keyboard.on('keydown', (ev) => {
|
||||||
if (this.state !== 'TITLE') return;
|
if (this.state !== 'TITLE') return;
|
||||||
const i = ['1', '2', '3', '4'].indexOf(ev.key);
|
const i = ['1', '2', '3', '4'].indexOf(ev.key);
|
||||||
@ -171,6 +180,29 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
.setDepth(90)
|
.setDepth(90)
|
||||||
.setAlpha(0);
|
.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.
|
// Vibe heartbeat.
|
||||||
this.time.addEvent({
|
this.time.addEvent({
|
||||||
delay: 1000,
|
delay: 1000,
|
||||||
@ -192,7 +224,10 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
this.input.addPointer(2);
|
this.input.addPointer(2);
|
||||||
this.touchMove = null;
|
this.touchMove = null;
|
||||||
this.touchFire = 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) => {
|
this.input.on('pointerdown', (p) => {
|
||||||
if (this.state !== 'PLAYING' || overHorn(p)) return;
|
if (this.state !== 'PLAYING' || overHorn(p)) return;
|
||||||
const stick = { id: p.id, ox: p.x, oy: p.y, dx: 0, dy: 0 };
|
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.touchFire && this.touchFire.id === p.id) this.touchFire = null;
|
||||||
});
|
});
|
||||||
if (this.sys.game.device.input.touch) {
|
if (this.sys.game.device.input.touch) {
|
||||||
this.hornBtn = this.add
|
const btn = (x, label, cb, hold) => {
|
||||||
.circle(870, 630, 42, 0xffffff, 0.25)
|
const c = this.add
|
||||||
.setScrollFactor(0)
|
.circle(x, 630, 42, 0xffffff, 0.25)
|
||||||
.setDepth(100)
|
.setScrollFactor(0)
|
||||||
.setStrokeStyle(2, 0xffffff)
|
.setDepth(100)
|
||||||
.setInteractive();
|
.setStrokeStyle(2, 0xffffff)
|
||||||
this.add
|
.setInteractive();
|
||||||
.text(870, 630, 'HORN', { fontFamily: 'monospace', fontSize: '14px', color: '#ffffff' })
|
this.add
|
||||||
.setOrigin(0.5)
|
.text(x, 630, label, { fontFamily: 'monospace', fontSize: '14px', color: '#ffffff' })
|
||||||
.setScrollFactor(0)
|
.setOrigin(0.5)
|
||||||
.setDepth(101);
|
.setScrollFactor(0)
|
||||||
this.hornBtn.on('pointerdown', () => this.doAirhorn());
|
.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;
|
this.trainspotProgress = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildLevel(index) {
|
buildLevel(index, bonusMap = null) {
|
||||||
const map = LEVELS[index];
|
const map = bonusMap || (index < LEVELS.length ? LEVELS[index] : this.genLevel(index));
|
||||||
const w0 = map[0].length;
|
const w0 = map[0].length;
|
||||||
for (const row of map)
|
for (const row of map)
|
||||||
if (row.length !== w0) throw new Error(`level ${index}: ragged row (${row.length} vs ${w0})`);
|
if (row.length !== w0) throw new Error(`level ${index}: ragged row (${row.length} vs ${w0})`);
|
||||||
|
|
||||||
this.levelIndex = index;
|
this.levelIndex = index;
|
||||||
|
this.isBonus = !!bonusMap;
|
||||||
this.theme = THEMES[index % THEMES.length];
|
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.walls.clear(true, true);
|
||||||
this.doors.clear(true, true);
|
this.doors.clear(true, true);
|
||||||
this.pickups.clear(true, true);
|
this.pickups.clear(true, true);
|
||||||
@ -309,6 +363,16 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
this.say(LINES.soundGuy);
|
this.say(LINES.soundGuy);
|
||||||
break;
|
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': {
|
case 'X': {
|
||||||
const ex = this.exits.create(x, y, 'exit');
|
const ex = this.exits.create(x, y, 'exit');
|
||||||
this.tweens.add({
|
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.levelStartTime = this.time.now;
|
||||||
this.trainspotProgress = 0;
|
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) {
|
update(time, delta) {
|
||||||
@ -337,18 +482,102 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
}
|
}
|
||||||
if (this.state === 'LEVEL_CLEAR') return;
|
if (this.state === 'LEVEL_CLEAR') return;
|
||||||
|
|
||||||
|
this.handleDig(delta);
|
||||||
this.handleMovement();
|
this.handleMovement();
|
||||||
this.handleFiring(time);
|
this.handleFiring(time);
|
||||||
this.cullVinyl(time);
|
this.cullVinyl(time);
|
||||||
this.updateEnemies();
|
this.updateEnemies();
|
||||||
this.updateGenerators(delta);
|
this.updateGenerators(delta);
|
||||||
this.updateSoundGuys();
|
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();
|
if (Phaser.Input.Keyboard.JustDown(this.keySpace)) this.doAirhorn();
|
||||||
this.handleTrainspot(delta);
|
this.handleTrainspot(delta);
|
||||||
|
this.renderDarkness();
|
||||||
this.updateHud();
|
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() {
|
handleMovement() {
|
||||||
const { W, A, S, D } = this.wasd;
|
const { W, A, S, D } = this.wasd;
|
||||||
let vx = (D.isDown ? 1 : 0) - (A.isDown ? 1 : 0);
|
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) => {
|
this.enemies.getChildren().forEach((e) => {
|
||||||
if (!e.active) return;
|
if (!e.active) return;
|
||||||
let target = this.player;
|
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('type') === 'pirate') {
|
||||||
if (e.getData('carry')) {
|
if (e.getData('carry')) {
|
||||||
const home = e.getData('home');
|
const home = e.getData('home');
|
||||||
@ -462,21 +696,26 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
if (roll <= 0) break;
|
if (roll <= 0) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const def = ENEMY_TYPES[type];
|
// mites come in packs of 3
|
||||||
const e = this.enemies.get(g.x, g.y, def.tex);
|
const count = type === 'mite' && !forceType ? 3 : 1;
|
||||||
if (!e) return; // pool full — skip
|
for (let i = 0; i < count; i++) {
|
||||||
e.setTexture(def.tex);
|
const def = ENEMY_TYPES[type];
|
||||||
e.setActive(true).setVisible(true);
|
const e = this.enemies.get(g.x, g.y, def.tex);
|
||||||
e.clearTint();
|
if (!e) return; // pool full — skip
|
||||||
e.body.enable = true;
|
e.setTexture(def.tex);
|
||||||
e.body.setSize(34, 34, true); // body smaller than 48px sprite
|
e.setActive(true).setVisible(true);
|
||||||
e.setPosition(g.x, g.y);
|
e.clearTint();
|
||||||
e.setData('type', type);
|
e.body.enable = true;
|
||||||
e.setData('touch', def.touch);
|
e.body.setSize(def.size, def.size, true); // body smaller than sprite
|
||||||
e.setData('speed', def.speed);
|
e.setPosition(g.x + i * 20, g.y);
|
||||||
e.setData('hp', def.hp);
|
e.setData('type', type);
|
||||||
e.setData('carry', null);
|
e.setData('touch', def.touch);
|
||||||
e.setData('home', { x: g.x, y: g.y });
|
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);
|
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.flash(200, 255, 255, 255);
|
||||||
this.cameras.main.shake(200, 0.01);
|
this.cameras.main.shake(200, 0.01);
|
||||||
this.enemies.getChildren().forEach((e) => {
|
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.soundGuys.clear(true, true);
|
||||||
this.airhornBlast();
|
this.airhornBlast();
|
||||||
@ -500,6 +741,7 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
handleTrainspot(delta) {
|
handleTrainspot(delta) {
|
||||||
if (this.physics.overlap(this.player, this.exits)) {
|
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);
|
if (this.trainspotProgress === 0) this.say(LINES.trainspot);
|
||||||
this.trainspotProgress += delta;
|
this.trainspotProgress += delta;
|
||||||
this.barBg.setVisible(true);
|
this.barBg.setVisible(true);
|
||||||
@ -515,8 +757,11 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
updateHud() {
|
updateHud() {
|
||||||
const v = Math.max(0, Math.ceil(this.vibe));
|
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(
|
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.hud.setColor(this.vibe < 200 ? '#ff4040' : this.vibe < 500 ? '#ffd11a' : '#ffffff');
|
||||||
this.vignette.setAlpha(this.vibe < VIBE_MAX * 0.25 ? 0.12 : 0);
|
this.vignette.setAlpha(this.vibe < VIBE_MAX * 0.25 ? 0.12 : 0);
|
||||||
@ -550,6 +795,13 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.score += KILL_SCORE;
|
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);
|
if (!this.dropLoot(e)) this.kill(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -678,12 +930,13 @@ export default class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
levelClear() {
|
levelClear() {
|
||||||
if (this.state !== 'PLAYING') return;
|
if (this.state !== 'PLAYING') return;
|
||||||
|
if (this.isBonus) return this.endBonus(); // booth in a treasure room just leaves
|
||||||
this.state = 'LEVEL_CLEAR';
|
this.state = 'LEVEL_CLEAR';
|
||||||
this.score += CLEAR_SCORE;
|
this.score += CLEAR_SCORE;
|
||||||
this.physics.pause();
|
this.physics.pause();
|
||||||
this.barBg.setVisible(false);
|
this.barBg.setVisible(false);
|
||||||
this.barFill.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();
|
const tune = this.spotTune();
|
||||||
if (tune) {
|
if (tune) {
|
||||||
this.score += tune.first ? (tune.g ? GRAIL_ID_SCORE : FIRST_ID_SCORE) : ID_SCORE;
|
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.time.delayedCall(2800, () => {
|
||||||
this.physics.resume();
|
this.physics.resume();
|
||||||
if (this.levelIndex + 1 >= LEVELS.length) this.densityBase += 0.5; // loop back harder
|
|
||||||
this.hideOverlay();
|
this.hideOverlay();
|
||||||
this.state = 'PLAYING';
|
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.'],
|
shotEspresso: ['shotEspresso', 'Selector shot the espresso.'],
|
||||||
pirate: ['pirate', 'Record pirate. Protect the wax.'],
|
pirate: ['pirate', 'Record pirate. Protect the wax.'],
|
||||||
gold: ['gold', 'Wax acquired.'],
|
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:
|
// Levels are arrays of equal-length strings. Legend:
|
||||||
// W wall (crate) . floor P player start X DJ booth exit
|
// W wall (crate) . floor P player start X DJ booth exit
|
||||||
// E espresso K lanyard/key D velvet rope G generator (discount bin)
|
// 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
|
// 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
|
// walls and pads the right with floor, so rows are always the right width — you
|
||||||
@ -83,4 +84,40 @@ const level3 = [
|
|||||||
wall,
|
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