Tier 3: bosses, warehouse+festival themes, portrait title screen

- Title: FLUX marquee logo + 4 portrait class-select cards (keys 1-4 or tap)
- Themes 4-5: WAREHOUSE RAVE (shazam zombie turrets firing slow requests,
  gatekeeper tanks) and FESTIVAL GROUNDS (wobbling warped records); tune pools
  extracted for both (acid/tech-house/minimal, trance/big-beat/prog) -> 1008 tunes
- Spectral Conductor boss (level 8 of each 12-cycle): 20 HP, teleports to a
  corner every 5 hits, doubles generator rate + 1.5x enemy speed while alive
- The Monstrous 78 RPM (level 12): 30 HP, bounces Pong-style, spits warped
  shards, only vulnerable while "changing sides" (dimmed); killing it reveals
  THE WHITE LABEL (??? - ???) in the notebook and starts NG+ density
- Boss arenas have no exit until the boss falls - the booth appears where it died
- Fix: makeGlow crashes on scene restart (createCanvas returns null if key exists)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-16 19:27:34 +10:00
parent 7a37348d9a
commit ea2bfd0eaa
20 changed files with 283 additions and 29 deletions

View File

@ -34,6 +34,16 @@ 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),
], ],
"warehouse": [ # WAREHOUSE RAVE: acid / minimal / tech house, 12"s only
("Acid House", False, 1987, 1995, 50, True),
("Tech House", False, 1992, 2005, 50, True),
("Minimal", False, 1992, 2005, 50, True),
],
"festival": [ # FESTIVAL GROUNDS: trance / big beat / prog, 12"s only
("Trance", False, 1992, 2005, 60, True),
("Big Beat", False, 1994, 2002, 40, True),
("Progressive House", False, 1992, 2005, 50, True),
],
"archive": [ # THE ARCHIVE: the deep stuff, 12"s only "archive": [ # THE ARCHIVE: the deep stuff, 12"s only
("Drum n Bass", False, 1992, 2005, 60, True), ("Drum n Bass", False, 1992, 2005, 60, True),
("Hardcore", False, 1990, 1997, 50, True), ("Hardcore", False, 1990, 1997, 50, True),

View File

@ -43,6 +43,24 @@ TABLE = {
"lanyard": ("pickup_lanyard.png", 38, False), "lanyard": ("pickup_lanyard.png", 38, False),
"airhorn": ("pickup_airhorn.png", 38, False), "airhorn": ("pickup_airhorn.png", 38, False),
"vinyl": ("pickup_vinyl.png", 24, False), "vinyl": ("pickup_vinyl.png", 24, False),
"floor_warehouse": ("floor_warehouse.png", 96, True),
"wall_warehouse": ("wall_warehouse.png", 48, True),
"floor_festival": ("floor_festival.png", 96, True),
"wall_festival": ("wall_festival.png", 48, True),
"gatekeeper": ("enemy_gatekeeper.png", 52, False),
"shazam": ("enemy_shazam_zombie.png", 48, False),
"warped": ("enemy_warped_record.png", 44, False),
"conductor": ("boss_spectral_conductor.png", 96, False),
"rpm78": ("boss_78rpm.png", 120, False),
"port_digger": ("portrait_digger.png", 112, False),
"port_selector": ("portrait_selector.png", 112, False),
"port_promoter": ("portrait_promoter.png", 112, False),
"port_soundtech": ("portrait_soundtech.png", 112, False),
}
# full-art images: downscale + copy, never chroma-keyed (they aren't sprites)
ART = {
"logo": ("logo_marquee_b.png", 800),
} }
@ -73,4 +91,10 @@ for key, (fname, size, is_tile) in TABLE.items():
im.save(OUT / f"{key}.png") im.save(OUT / f"{key}.png")
print(f"{key:10s} <- {fname:24s} {size}px {'tile' if is_tile else 'keyed'}") print(f"{key:10s} <- {fname:24s} {size}px {'tile' if is_tile else 'keyed'}")
print(f"\nwrote {len(TABLE)} textures to {OUT}") for key, (fname, width) in ART.items():
im = Image.open(SRC / fname).convert("RGB")
im.thumbnail((width, width * 2), Image.LANCZOS)
im.save(OUT / f"{key}.png")
print(f"{key:10s} <- {fname:24s} art {im.width}x{im.height}")
print(f"\nwrote {len(TABLE) + len(ART)} textures to {OUT}")

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/sprites/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
public/sprites/rpm78.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/sprites/shazam.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
public/sprites/warped.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { LEVELS, BONUS_LEVEL } from './levels.js'; import { LEVELS, BONUS_LEVEL, BOSS_CONDUCTOR, BOSS_78RPM } from './levels.js';
import { announce, LINES } from './announcer.js'; import { announce, LINES } from './announcer.js';
const TILE = 48; const TILE = 48;
@ -27,11 +27,21 @@ 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_archive', 'floor', 'floor_carpet', 'floor_checker', 'floor_archive', 'floor_warehouse', 'floor_festival',
'wall', 'wall_shelf', 'wall_poster', 'wall_archive', 'wall', 'wall_shelf', 'wall_poster', 'wall_archive', 'wall_warehouse', 'wall_festival',
'player', 'poser', 'nerd', 'pirate', 'ghoul', 'mite', 'queen', 'soundguy', 'generator', 'player', 'poser', 'nerd', 'pirate', 'ghoul', 'mite', 'queen', 'soundguy', 'generator',
'gatekeeper', 'shazam', 'warped', 'conductor', 'rpm78',
'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl', 'gold', 'lamp', 'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl', 'gold', 'lamp',
'port_digger', 'port_selector', 'port_promoter', 'port_soundtech', 'logo',
]; ];
const BOSS_SCORE = { conductor: 5000, rpm78: 10000 };
const SHAZAM_SHOT_SPEED = 170;
const SHAZAM_SHOT_DAMAGE = 12;
const SHAZAM_RANGE = 520;
const SHAZAM_COOLDOWN_MS = 2500;
const RPM_SPIN_MS = 8000; // invulnerable, bouncing
const RPM_REST_MS = 3000; // "changing sides" — vulnerable
const RPM_SHARDS_MS = 6000;
// Gauntlet II classes: stat tweaks only, no new mechanics. // Gauntlet II classes: stat tweaks only, no new mechanics.
const CLASSES = { const CLASSES = {
@ -48,6 +58,11 @@ const ENEMY_TYPES = {
ghoul: { tex: 'ghoul', speed: 70, touch: 120, hp: 3, size: 34 }, // 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 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 queen: { tex: 'queen', speed: 40, touch: 60, hp: 12, size: 70, boss: true }, // broods mites
gatekeeper: { tex: 'gatekeeper', speed: 60, touch: 50, hp: 4, size: 40 }, // heavy blocker
shazam: { tex: 'shazam', speed: 0, touch: 20, hp: 1, size: 34, ranged: true }, // turret: "what song is this?"
warped: { tex: 'warped', speed: 150, touch: 40, hp: 1, size: 30, wobble: true }, // wobbles like a bad press
conductor: { tex: 'conductor', speed: 55, touch: 60, hp: 20, size: 60, boss: true }, // buffs the room
rpm78: { tex: 'rpm78', speed: 0, touch: 80, hp: 30, size: 84, boss: true, bounce: true }, // the White Label
}; };
// Level themes cycle with level index; mix = weighted generator spawn table. // Level themes cycle with level index; mix = weighted generator spawn table.
@ -55,8 +70,12 @@ 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: 'WAREHOUSE RAVE', pool: 'warehouse', floor: 'floor_warehouse', wall: 'wall_warehouse', mix: [['poser', 0.3], ['shazam', 0.25], ['gatekeeper', 0.25], ['nerd', 0.2]] },
{ name: 'FESTIVAL GROUNDS', pool: 'festival', floor: 'floor_festival', wall: 'wall_festival', mix: [['poser', 0.3], ['warped', 0.3], ['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]] }, { name: 'THE ARCHIVE', pool: 'archive', floor: 'floor_archive', wall: 'wall_archive', dark: true, mix: [['mite', 0.45], ['ghoul', 0.3], ['poser', 0.25]] },
]; ];
// 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);
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
const GRAIL_ID_SCORE = 2000; // first ID of a certified classic const GRAIL_ID_SCORE = 2000; // first ID of a certified classic
@ -86,6 +105,7 @@ export default class GameScene extends Phaser.Scene {
this.soundGuys = this.physics.add.group(); this.soundGuys = this.physics.add.group();
this.enemies = this.physics.add.group({ maxSize: 80 }); this.enemies = this.physics.add.group({ maxSize: 80 });
this.vinyl = this.physics.add.group({ maxSize: 30 }); this.vinyl = this.physics.add.group({ maxSize: 30 });
this.shots = this.physics.add.group({ maxSize: 24 }); // shazam "what song is this?" projectiles
this.player = this.physics.add.sprite(100, 100, 'player'); this.player = this.physics.add.sprite(100, 100, 'player');
this.player.setCollideWorldBounds(true); this.player.setCollideWorldBounds(true);
@ -103,6 +123,12 @@ export default class GameScene extends Phaser.Scene {
this.physics.add.overlap(this.player, this.soundGuys, this.touchSoundGuy, null, this); this.physics.add.overlap(this.player, this.soundGuys, this.touchSoundGuy, null, this);
this.physics.add.overlap(this.player, this.pickups, this.grabPickup, null, this); this.physics.add.overlap(this.player, this.pickups, this.grabPickup, null, this);
this.physics.add.overlap(this.enemies, this.pickups, this.pirateGrab, null, this); this.physics.add.overlap(this.enemies, this.pickups, this.pirateGrab, null, this);
this.physics.add.collider(this.shots, this.walls, (s) => this.kill(s));
this.physics.add.overlap(this.player, this.shots, (p, s) => {
if (!s.active) return;
this.kill(s);
this.takeDamage(SHAZAM_SHOT_DAMAGE);
});
// Input. // Input.
this.cursors = this.input.keyboard.createCursorKeys(); this.cursors = this.input.keyboard.createCursorKeys();
@ -146,21 +172,34 @@ export default class GameScene extends Phaser.Scene {
.setOrigin(0.5) .setOrigin(0.5)
.setScrollFactor(0) .setScrollFactor(0)
.setDepth(100); .setDepth(100);
this.classButtons = Object.entries(CLASSES).map(([key, c], i) => { // Title: marquee logo + four portrait cards (keys 1-4 or tap).
const b = this.add this.titleLogo = this.add
.text(480, 470 + i * 45, `${i + 1} ${c.name}${c.desc}`, { .image(480, 130, 'logo')
fontFamily: 'monospace',
fontSize: '22px',
color: '#00e5ff',
backgroundColor: '#00000080',
padding: { x: 12, y: 4 },
})
.setOrigin(0.5)
.setScrollFactor(0) .setScrollFactor(0)
.setDepth(100) .setDepth(100)
.setDisplaySize(640, 270);
const PORTRAITS = { digger: 'port_digger', selector: 'port_selector', promoter: 'port_promoter', soundtech: 'port_soundtech' };
this.classButtons = Object.entries(CLASSES).flatMap(([key, c], i) => {
const x = 480 + (i - 1.5) * 190;
const card = this.add
.rectangle(x, 465, 170, 210, 0x000000, 0.75)
.setScrollFactor(0)
.setDepth(99)
.setStrokeStyle(2, 0x00e5ff)
.setInteractive({ useHandCursor: true }); .setInteractive({ useHandCursor: true });
b.on('pointerdown', () => this.startGame(key)); card.on('pointerdown', () => this.startGame(key));
return b; const port = this.add.image(x, 430, PORTRAITS[key]).setScrollFactor(0).setDepth(100);
const name = this.add
.text(x, 510, `${i + 1}. ${c.name}`, { fontFamily: 'monospace', fontSize: '15px', color: '#00e5ff' })
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(100);
const desc = this.add
.text(x, 535, c.desc, { fontFamily: 'monospace', fontSize: '12px', color: '#aaaaaa' })
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(100);
return [card, port, name, desc];
}); });
this.barBg = this.add this.barBg = this.add
.rectangle(480, 680, 306, 26, 0x000000) .rectangle(480, 680, 306, 26, 0x000000)
@ -182,6 +221,7 @@ export default class GameScene extends Phaser.Scene {
// The Archive's darkness: black RT with radial holes erased at player + lamps. // The Archive's darkness: black RT with radial holes erased at player + lamps.
const makeGlow = (key, size) => { const makeGlow = (key, size) => {
if (this.textures.exists(key)) return; // scene restarts must not re-create
const glow = this.textures.createCanvas(key, size, size); const glow = this.textures.createCanvas(key, size, size);
const ctx = glow.getContext(); const ctx = glow.getContext();
const half = size / 2; const half = size / 2;
@ -283,7 +323,18 @@ export default class GameScene extends Phaser.Scene {
} }
buildLevel(index, bonusMap = null) { buildLevel(index, bonusMap = null) {
const map = bonusMap || (index < LEVELS.length ? LEVELS[index] : this.genLevel(index)); const boss = bonusMap ? null : bossFor(index);
const map =
bonusMap ||
(boss === 'conductor'
? BOSS_CONDUCTOR
: boss === 'rpm78'
? BOSS_78RPM
: index < LEVELS.length
? LEVELS[index]
: this.genLevel(index));
this.bossLevel = boss;
this.conductorAlive = false;
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})`);
@ -308,6 +359,7 @@ export default class GameScene extends Phaser.Scene {
this.soundGuys.clear(true, true); this.soundGuys.clear(true, true);
this.enemies.getChildren().slice().forEach((e) => this.kill(e)); this.enemies.getChildren().slice().forEach((e) => this.kill(e));
this.vinyl.getChildren().slice().forEach((v) => this.kill(v)); this.vinyl.getChildren().slice().forEach((v) => this.kill(v));
this.shots.getChildren().slice().forEach((s) => this.kill(s));
const cols = w0; const cols = w0;
const rows = map.length; const rows = map.length;
@ -368,6 +420,17 @@ export default class GameScene extends Phaser.Scene {
this.say(LINES.queen); this.say(LINES.queen);
break; break;
} }
case 'C': {
this.spawnEnemy({ x, y }, 'conductor');
this.conductorAlive = true;
this.say(LINES.conductor);
break;
}
case 'M': {
this.spawnEnemy({ x, y }, 'rpm78');
this.say(LINES.rpm78);
break;
}
case 'L': { case 'L': {
this.lamps.push({ x, y }); this.lamps.push({ x, y });
this.lampSprites.push(this.add.image(x, y, 'lamp').setDepth(-5)); this.lampSprites.push(this.add.image(x, y, 'lamp').setDepth(-5));
@ -649,15 +712,61 @@ export default class GameScene extends Phaser.Scene {
} }
updateEnemies() { updateEnemies() {
const now = this.time.now;
const speedBuff = this.conductorAlive ? 1.5 : 1; // the conductor conducts
this.enemies.getChildren().forEach((e) => { this.enemies.getChildren().forEach((e) => {
if (!e.active) return; if (!e.active) return;
const type = e.getData('type');
const def = ENEMY_TYPES[type];
let target = this.player; let target = this.player;
if (e.getData('type') === 'queen' && this.time.now > e.getData('nextBrood')) { if (type === 'queen' && now > e.getData('nextBrood')) {
e.setData('nextBrood', this.time.now + QUEEN_BROOD_MS); e.setData('nextBrood', now + QUEEN_BROOD_MS);
this.spawnEnemy({ x: e.x, y: e.y }, 'mite'); this.spawnEnemy({ x: e.x, y: e.y }, 'mite');
this.spawnEnemy({ x: e.x + 24, y: e.y }, 'mite'); this.spawnEnemy({ x: e.x + 24, y: e.y }, 'mite');
} }
if (e.getData('type') === 'pirate') { if (type === 'shazam') {
// turret: rooted, lobs a slow request at the player
if (
now > e.getData('nextShot') &&
Phaser.Math.Distance.Between(e.x, e.y, this.player.x, this.player.y) < SHAZAM_RANGE
) {
e.setData('nextShot', now + SHAZAM_COOLDOWN_MS);
const s = this.shots.get(e.x, e.y, 'vinyl');
if (s) {
s.setActive(true).setVisible(true);
s.setTint(0x66ccff).setScale(0.8);
s.body.enable = true;
s.setPosition(e.x, e.y);
this.physics.moveToObject(s, this.player, SHAZAM_SHOT_SPEED);
}
}
return;
}
if (type === 'rpm78') {
// spin (bounce, invulnerable) <-> rest ("changing sides", vulnerable)
if (now > e.getData('phaseUntil')) {
const resting = !e.getData('resting');
e.setData('resting', resting);
e.setData('phaseUntil', now + (resting ? RPM_REST_MS : RPM_SPIN_MS));
if (resting) {
e.setVelocity(0, 0);
e.setAlpha(0.6); // tell: dimmed = hit it now
} else {
e.setAlpha(1);
const a = Math.random() * Math.PI * 2;
e.setVelocity(Math.cos(a) * 260, Math.sin(a) * 260);
}
}
if (!e.getData('resting') && now > e.getData('nextShards')) {
e.setData('nextShards', now + RPM_SHARDS_MS);
for (let i = 0; i < 3; i++) this.spawnEnemy({ x: e.x + (i - 1) * 30, y: e.y }, 'warped');
}
return; // bounce physics handles movement
}
if (type === 'conductor') {
// teleports away every 5 hits — handled in hitEnemy; drifts slowly here
}
if (type === 'pirate') {
if (e.getData('carry')) { if (e.getData('carry')) {
const home = e.getData('home'); const home = e.getData('home');
if (Phaser.Math.Distance.Between(e.x, e.y, home.x, home.y) < 24) { if (Phaser.Math.Distance.Between(e.x, e.y, home.x, home.y) < 24) {
@ -669,7 +778,14 @@ export default class GameScene extends Phaser.Scene {
target = this.nearestPickup(e) || this.player; target = this.nearestPickup(e) || this.player;
} }
} }
this.physics.moveToObject(e, target, e.getData('speed')); this.physics.moveToObject(e, target, e.getData('speed') * speedBuff);
if (def.wobble) {
// bad pressing: add a perpendicular wobble to the homing vector
const v = e.body.velocity;
const wob = Math.sin(now / 140 + e.getData('wobbleSeed')) * 100;
const len = Math.hypot(v.x, v.y) || 1;
e.setVelocity(v.x - (v.y / len) * wob * -1, v.y + (v.x / len) * wob * -1);
}
}); });
} }
@ -677,7 +793,7 @@ export default class GameScene extends Phaser.Scene {
this.generators.getChildren().forEach((g) => { this.generators.getChildren().forEach((g) => {
if (!g.active) return; if (!g.active) return;
if (Phaser.Math.Distance.Between(g.x, g.y, this.player.x, this.player.y) > GEN_RANGE) return; if (Phaser.Math.Distance.Between(g.x, g.y, this.player.x, this.player.y) > GEN_RANGE) return;
let accum = g.getData('accum') + delta * this.density; let accum = g.getData('accum') + delta * this.density * (this.conductorAlive ? 2 : 1);
if (accum >= GEN_SPAWN_MS) { if (accum >= GEN_SPAWN_MS) {
accum -= GEN_SPAWN_MS; accum -= GEN_SPAWN_MS;
this.spawnEnemy(g); this.spawnEnemy(g);
@ -705,6 +821,8 @@ export default class GameScene extends Phaser.Scene {
e.setTexture(def.tex); e.setTexture(def.tex);
e.setActive(true).setVisible(true); e.setActive(true).setVisible(true);
e.clearTint(); e.clearTint();
e.setAlpha(1);
e.setBounce(def.bounce ? 1 : 0); // pooled sprites may have been the 78
e.body.enable = true; e.body.enable = true;
e.body.setSize(def.size, def.size, true); // body smaller than sprite e.body.setSize(def.size, def.size, true); // body smaller than sprite
e.setPosition(g.x + i * 20, g.y); e.setPosition(g.x + i * 20, g.y);
@ -715,6 +833,17 @@ export default class GameScene extends Phaser.Scene {
e.setData('carry', null); e.setData('carry', null);
e.setData('home', { x: g.x, y: g.y }); e.setData('home', { x: g.x, y: g.y });
e.setData('nextBrood', this.time.now + QUEEN_BROOD_MS); e.setData('nextBrood', this.time.now + QUEEN_BROOD_MS);
e.setData('hits', 0);
e.setData('nextShot', this.time.now + SHAZAM_COOLDOWN_MS);
e.setData('wobbleSeed', Math.random() * Math.PI * 2);
if (def.bounce) {
// the 78 starts spinning around the arena
e.setBounce(1).setVelocity(220, 160);
e.setData('phaseUntil', this.time.now + RPM_SPIN_MS);
e.setData('resting', false);
e.setData('nextShards', this.time.now + RPM_SHARDS_MS);
}
if (def.speed === 0 && !def.bounce) e.setVelocity(0, 0);
} }
if (type === 'pirate') this.say(LINES.pirate); if (type === 'pirate') this.say(LINES.pirate);
} }
@ -787,24 +916,60 @@ export default class GameScene extends Phaser.Scene {
hitEnemy(v, e) { hitEnemy(v, e) {
if (!v.active || !e.active) return; if (!v.active || !e.active) return;
this.kill(v); this.kill(v);
const type = e.getData('type');
// the 78 only takes damage while "changing sides"
if (type === 'rpm78' && !e.getData('resting')) {
this.cameras.main.shake(60, 0.003);
return;
}
const hp = e.getData('hp') - this.cls.dmg; const hp = e.getData('hp') - this.cls.dmg;
if (hp > 0) { if (hp > 0) {
e.setData('hp', hp); e.setData('hp', hp);
e.setTintFill(0xffffff); e.setTintFill(0xffffff);
this.time.delayedCall(60, () => e.active && e.clearTint()); this.time.delayedCall(60, () => e.active && e.clearTint());
if (type === 'conductor') {
const hits = e.getData('hits') + 1;
e.setData('hits', hits);
if (hits % 5 === 0) {
// flees to another corner of the arena
const w = this.physics.world.bounds;
const corners = [
[TILE * 2.5, TILE * 2.5],
[w.width - TILE * 2.5, TILE * 2.5],
[TILE * 2.5, w.height - TILE * 2.5],
[w.width - TILE * 2.5, w.height - TILE * 2.5],
];
const [cx, cy] = Phaser.Utils.Array.GetRandom(corners);
e.setPosition(cx, cy);
e.body.reset(cx, cy);
this.cameras.main.flash(120, 120, 120, 255);
}
}
return; return;
} }
this.score += KILL_SCORE; this.score += KILL_SCORE;
if (e.getData('type') === 'queen') { if (type === 'queen') {
this.score += QUEEN_SCORE; this.score += QUEEN_SCORE;
for (let i = 0; i < 3; i++) for (let i = 0; i < 3; i++)
this.pickups.create(e.x + (i - 1) * TILE, e.y, 'gold').setData('type', 'gold'); this.pickups.create(e.x + (i - 1) * TILE, e.y, 'gold').setData('type', 'gold');
this.cameras.main.shake(300, 0.008); this.cameras.main.shake(300, 0.008);
this.say(LINES.queenDead); this.say(LINES.queenDead);
} }
if (ENEMY_TYPES[type]?.boss && this.bossLevel) this.bossDown(e, type);
if (!this.dropLoot(e)) this.kill(e); if (!this.dropLoot(e)) this.kill(e);
} }
bossDown(e, type) {
this.score += BOSS_SCORE[type] || 0;
if (type === 'conductor') this.conductorAlive = false;
this.cameras.main.shake(500, 0.012);
this.cameras.main.flash(400, 255, 255, 255);
// the DJ booth reveals itself where the boss fell
const ex = this.exits.create(e.x, e.y, 'exit');
this.tweens.add({ targets: ex, alpha: 0.4, duration: 600, yoyo: true, repeat: -1 });
this.say(type === 'rpm78' ? LINES.rpm78Dead : LINES.conductorDead);
}
hitGenerator(v, g) { hitGenerator(v, g) {
if (!v.active || !g.active) return; if (!v.active || !g.active) return;
this.kill(v); this.kill(v);
@ -914,6 +1079,17 @@ export default class GameScene extends Phaser.Scene {
// Pick a tune from this theme's pool, preferring ones not yet in the notebook. // Pick a tune from this theme's pool, preferring ones not yet in the notebook.
spotTune() { spotTune() {
if (this.bossLevel === 'rpm78') {
// the one record no one has ever identified
const notebook = this.loadNotebook();
const first = !notebook.has('???|???');
if (first) {
notebook.add('???|???');
localStorage.setItem('vg_notebook', JSON.stringify([...notebook]));
}
this.densityBase += 0.5; // NG+: the shop reopens in your dreams
return { a: '???', t: '???', l: 'White Label', y: '????', g: 1, first, whiteLabel: true };
}
const pools = this.cache.json.get('tunes'); const pools = this.cache.json.get('tunes');
const pool = pools && pools[this.theme.pool]; const pool = pools && pools[this.theme.pool];
if (!pool || !pool.length) return null; if (!pool || !pool.length) return null;
@ -941,7 +1117,7 @@ export default class GameScene extends Phaser.Scene {
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;
if (tune.g) this.cameras.main.flash(600, 255, 215, 0); // gold flash for a classic if (tune.g) this.cameras.main.flash(600, 255, 215, 0); // gold flash for a classic
this.showOverlay(tune.g ? 'CERTIFIED CLASSIC' : 'TUNE IDENTIFIED'); this.showOverlay(tune.whiteLabel ? 'THE WHITE LABEL' : tune.g ? 'CERTIFIED CLASSIC' : 'TUNE IDENTIFIED');
const nb = this.notebookTotals(); const nb = this.notebookTotals();
this.sub this.sub
.setText( .setText(
@ -1014,11 +1190,13 @@ export default class GameScene extends Phaser.Scene {
.map((s, i) => `${i + 1}. ${s.initials} ${s.score}`) .map((s, i) => `${i + 1}. ${s.initials} ${s.score}`)
.join('\n'); .join('\n');
const nb = this.notebookTotals(); const nb = this.notebookTotals();
const nbLine = nb && nb.spotted ? `\n\nNOTEBOOK ${nb.spotted}/${nb.total} tunes spotted` : ''; const nbLine = nb && nb.spotted ? ` · NOTEBOOK ${nb.spotted}/${nb.total}` : '';
this.showOverlay('VINYL GAUNTLET');
this.sub this.sub
.setText((hs ? `HIGH SCORES\n${hs}` : 'dig deep. name every tune.') + nbLine) .setText((hs ? `HIGH SCORES ${hs.replace(/\n/g, ' ')}` : 'dig deep. name every tune.') + nbLine)
.setPosition(480, 630)
.setFontSize(15)
.setVisible(true); .setVisible(true);
this.titleLogo.setVisible(true);
this.classButtons.forEach((b) => b.setVisible(true)); this.classButtons.forEach((b) => b.setVisible(true));
} }
@ -1028,6 +1206,7 @@ export default class GameScene extends Phaser.Scene {
this.physics.resume(); this.physics.resume();
this.resetStats(); this.resetStats();
this.hideOverlay(); this.hideOverlay();
this.titleLogo.setVisible(false);
this.classButtons.forEach((b) => b.setVisible(false)); this.classButtons.forEach((b) => b.setVisible(false));
this.state = 'PLAYING'; this.state = 'PLAYING';
this.buildLevel(0); this.buildLevel(0);
@ -1035,6 +1214,7 @@ export default class GameScene extends Phaser.Scene {
showOverlay(text) { showOverlay(text) {
this.overlay.setText(text).setVisible(true); this.overlay.setText(text).setVisible(true);
this.sub.setPosition(480, 330).setFontSize(20); // title screen moves it; game states restore
} }
hideOverlay() { hideOverlay() {

View File

@ -52,4 +52,8 @@ export const LINES = {
queen: ['queen', 'The mite queen... is awake.'], queen: ['queen', 'The mite queen... is awake.'],
queenDead: ['queenDead', 'The queen is dead. Dig in peace.'], queenDead: ['queenDead', 'The queen is dead. Dig in peace.'],
bonus: ['bonus', 'Fill your boots. Twenty seconds.'], bonus: ['bonus', 'Fill your boots. Twenty seconds.'],
conductor: ['conductor', 'The founder still conducts this floor.'],
conductorDead: ['conductorDead', 'The baton has fallen. The floor is yours.'],
rpm78: ['rpm78', 'Seventy eight revolutions. No name. No mercy.'],
rpm78Dead: ['rpm78Dead', 'It has a name now. Yours.'],
}; };

View File

@ -107,6 +107,42 @@ const level4 = [
export const LEVELS = [level1, level2, level3, level4]; export const LEVELS = [level1, level2, level3, level4];
// Boss arenas — no X: the exit booth appears where the boss dies.
// C = Spectral Conductor, M = the Monstrous 78 RPM.
export const BOSS_CONDUCTOR = [
'WWWWWWWWWWWWWWWWWWWWWWWW',
'WG....................GW',
'W......................W',
'W..P...................W',
'W...........C..........W',
'W......................W',
'W..WWW............WWW..W',
'W..W.E............E.W..W',
'W..WWW............WWW..W',
'W......................W',
'W..........A...........W',
'W......................W',
'WG....................GW',
'WWWWWWWWWWWWWWWWWWWWWWWW',
];
export const BOSS_78RPM = [
'WWWWWWWWWWWWWWWWWWWWWWWW',
'W..L................L..W',
'W......................W',
'W..P...................W',
'W......................W',
'W..........M...........W',
'W......................W',
'W..WW..............WW..W',
'W..WE..............EW..W',
'W..WW..............WW..W',
'W..........L...........W',
'W......................W',
'W..A................A..W',
'WWWWWWWWWWWWWWWWWWWWWWWW',
];
// Treasure room: 20 seconds, no enemies, floor strewn with gold. Greed is good. // Treasure room: 20 seconds, no enemies, floor strewn with gold. Greed is good.
export const BONUS_LEVEL = [ export const BONUS_LEVEL = [
'WWWWWWWWWWWWWWWWWWWW', 'WWWWWWWWWWWWWWWWWWWW',