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>
@ -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),
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
BIN
public/sprites/conductor.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
public/sprites/floor_festival.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/sprites/floor_warehouse.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/sprites/gatekeeper.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/sprites/logo.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
public/sprites/port_digger.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/sprites/port_promoter.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/sprites/port_selector.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/sprites/port_soundtech.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/sprites/rpm78.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
public/sprites/shazam.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
public/sprites/wall_festival.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/sprites/wall_warehouse.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/sprites/warped.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
232
src/GameScene.js
@ -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() {
|
||||||
|
|||||||
@ -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.'],
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||