Tier 1: classes, themes, pirates/ghouls, score, vibe states, touch
- 4 Gauntlet classes (stat tweaks + tint) with title-screen select (keys 1-4 or tap)
- Level themes cycle shop/fair/club: per-theme tilesets (9 idle sprites wired) and
weighted enemy spawn tables
- Record Pirate (steals pickups, flees to its bin; kill = loot back + gold bonus)
and Crate Ghoul (3 HP tank)
- Gold records ('R' tiles + pirate drops), score (kills/gold/clears), local top-5
high-score table with initials on the title screen
- Vibe states: peak time = faster vinyl, burning out = slower + red vignette
- "Selector shot the espresso" (vinyl destroys food) + new announcer lines
- Mobile: Scale.FIT canvas, twin-stick touch (left = move, right = fire), HORN button
All mechanics regression-tested in browser via engine stepping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ -19,7 +19,14 @@ OUT.mkdir(parents=True, exist_ok=True)
|
||||
# game texture key -> (source file, size px, is_tile)
|
||||
TABLE = {
|
||||
"floor": ("floor_parquet.png", 96, True),
|
||||
"floor_carpet": ("floor_carpet.png", 96, True),
|
||||
"floor_checker": ("floor_checker.png", 96, True),
|
||||
"wall": ("wall_crate.png", 48, True),
|
||||
"wall_shelf": ("wall_shelf.png", 48, True),
|
||||
"wall_poster": ("wall_poster.png", 48, True),
|
||||
"pirate": ("enemy_record_pirate.png", 48, False),
|
||||
"ghoul": ("enemy_crate_ghoul.png", 48, False),
|
||||
"gold": ("pickup_goldrecord.png", 38, False),
|
||||
"player": ("player_digger.png", 48, False),
|
||||
"poser": ("enemy_poser.png", 48, False),
|
||||
"nerd": ("enemy_gearnerd.png", 48, False),
|
||||
|
||||
BIN
public/sprites/floor_carpet.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/sprites/floor_checker.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/sprites/ghoul.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/sprites/gold.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/sprites/pirate.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/sprites/wall_poster.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
public/sprites/wall_shelf.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
335
src/GameScene.js
@ -6,25 +6,48 @@ const TILE = 48;
|
||||
const VIBE_MAX = 2000;
|
||||
const VIBE_DRAIN_PER_SEC = 1; // the Gauntlet heartbeat
|
||||
const ESPRESSO_VIBE = 500;
|
||||
const POSER_TOUCH_DAMAGE = 30;
|
||||
const NERD_TOUCH_DAMAGE = 80;
|
||||
const SOUNDGUY_DRAIN = 10; // per 250ms tick while overlapping
|
||||
const GEN_HP = 3;
|
||||
const GEN_SPAWN_MS = 2500; // base; effective interval shrinks with density
|
||||
const GEN_RANGE = 1050; // px; scaled with TILE so range stays ~same in tiles
|
||||
const PLAYER_SPEED = 270;
|
||||
const POSER_SPEED = 180;
|
||||
const NERD_SPEED = 105;
|
||||
const SOUNDGUY_SPEED = 82;
|
||||
const VINYL_SPEED = 600;
|
||||
const VINYL_LIFE_MS = 1200;
|
||||
const FIRE_COOLDOWN_MS = 180;
|
||||
const TRAINSPOT_MS = 3000;
|
||||
const GOLD_SCORE = 100;
|
||||
const KILL_SCORE = 10;
|
||||
const CLEAR_SCORE = 500;
|
||||
|
||||
// texture keys loaded from public/sprites/<key>.png (see assets/process_sprites.py)
|
||||
const TEXTURE_KEYS = [
|
||||
'floor', 'wall', 'player', 'poser', 'nerd', 'soundguy', 'generator',
|
||||
'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl',
|
||||
'floor', 'floor_carpet', 'floor_checker',
|
||||
'wall', 'wall_shelf', 'wall_poster',
|
||||
'player', 'poser', 'nerd', 'pirate', 'ghoul', 'soundguy', 'generator',
|
||||
'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl', 'gold',
|
||||
];
|
||||
|
||||
// Gauntlet II classes: stat tweaks only, no new mechanics.
|
||||
const CLASSES = {
|
||||
digger: { name: 'THE DIGGER', desc: '+25% speed', speed: 1.25, dmg: 1, lanyards: 0, airhorns: 1, sgDrain: 1, tint: 0xffffff },
|
||||
selector: { name: 'THE SELECTOR', desc: 'double damage', speed: 1, dmg: 2, lanyards: 0, airhorns: 1, sgDrain: 1, tint: 0xffe080 },
|
||||
promoter: { name: 'THE PROMOTER', desc: 'starts loaded', speed: 1, dmg: 1, lanyards: 1, airhorns: 2, sgDrain: 1, tint: 0xffa0ff },
|
||||
soundtech: { name: 'THE SOUND TECH', desc: 'sound guy resistant', speed: 1, dmg: 1, lanyards: 0, airhorns: 1, sgDrain: 0.4, tint: 0xa0ffa0 },
|
||||
};
|
||||
|
||||
const ENEMY_TYPES = {
|
||||
poser: { tex: 'poser', speed: 180, touch: 30, hp: 1 },
|
||||
nerd: { tex: 'nerd', speed: 105, touch: 80, hp: 1 },
|
||||
pirate: { tex: 'pirate', speed: 210, touch: 20, hp: 1 }, // steals pickups, flees home
|
||||
ghoul: { tex: 'ghoul', speed: 70, touch: 120, hp: 3 }, // slow tank
|
||||
};
|
||||
|
||||
// Level themes cycle with level index; mix = weighted generator spawn table.
|
||||
const THEMES = [
|
||||
{ name: 'RECORD SHOP', floor: 'floor', wall: 'wall', mix: [['poser', 0.7], ['nerd', 0.3]] },
|
||||
{ name: 'RECORD FAIR', floor: 'floor_carpet', wall: 'wall_shelf', mix: [['poser', 0.5], ['nerd', 0.25], ['pirate', 0.25]] },
|
||||
{ name: 'CLUB NIGHT', floor: 'floor_checker', wall: 'wall_poster', mix: [['poser', 0.4], ['nerd', 0.2], ['pirate', 0.2], ['ghoul', 0.2]] },
|
||||
];
|
||||
|
||||
export default class GameScene extends Phaser.Scene {
|
||||
@ -40,6 +63,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
|
||||
create() {
|
||||
this.cameras.main.setBackgroundColor(0x1a1a1a);
|
||||
this.cls = CLASSES.digger;
|
||||
|
||||
// Persistent groups — created once, refilled each level so colliders survive.
|
||||
this.walls = this.physics.add.staticGroup();
|
||||
@ -62,15 +86,23 @@ export default class GameScene extends Phaser.Scene {
|
||||
this.physics.add.collider(this.vinyl, this.walls, this.killVinyl, null, this);
|
||||
this.physics.add.overlap(this.vinyl, this.enemies, this.hitEnemy, null, this);
|
||||
this.physics.add.overlap(this.vinyl, this.generators, this.hitGenerator, null, this);
|
||||
this.physics.add.overlap(this.vinyl, this.pickups, this.vinylHitsPickup, null, this);
|
||||
this.physics.add.overlap(this.player, this.enemies, this.touchEnemy, 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.enemies, this.pickups, this.pirateGrab, null, this);
|
||||
|
||||
// Input.
|
||||
this.cursors = this.input.keyboard.createCursorKeys();
|
||||
this.wasd = this.input.keyboard.addKeys('W,A,S,D');
|
||||
this.keySpace = this.input.keyboard.addKey('SPACE');
|
||||
this.keyR = this.input.keyboard.addKey('R');
|
||||
this.input.keyboard.on('keydown', (ev) => {
|
||||
if (this.state !== 'TITLE') return;
|
||||
const i = ['1', '2', '3', '4'].indexOf(ev.key);
|
||||
if (i >= 0) this.startGame(Object.keys(CLASSES)[i]);
|
||||
});
|
||||
this.setupTouch();
|
||||
|
||||
// HUD + overlays (fixed to camera).
|
||||
this.hud = this.add
|
||||
@ -78,7 +110,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
.setScrollFactor(0)
|
||||
.setDepth(100);
|
||||
this.overlay = this.add
|
||||
.text(480, 340, '', {
|
||||
.text(480, 190, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '40px',
|
||||
color: '#ff00ff',
|
||||
@ -87,6 +119,32 @@ export default class GameScene extends Phaser.Scene {
|
||||
.setOrigin(0.5)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(100);
|
||||
this.sub = this.add
|
||||
.text(480, 330, '', {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '20px',
|
||||
color: '#ffffff',
|
||||
align: 'center',
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(100);
|
||||
this.classButtons = Object.entries(CLASSES).map(([key, c], i) => {
|
||||
const b = this.add
|
||||
.text(480, 470 + i * 45, `${i + 1} ${c.name} — ${c.desc}`, {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '22px',
|
||||
color: '#00e5ff',
|
||||
backgroundColor: '#00000080',
|
||||
padding: { x: 12, y: 4 },
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(100)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
b.on('pointerdown', () => this.startGame(key));
|
||||
return b;
|
||||
});
|
||||
this.barBg = this.add
|
||||
.rectangle(480, 680, 306, 26, 0x000000)
|
||||
.setScrollFactor(0)
|
||||
@ -98,6 +156,12 @@ export default class GameScene extends Phaser.Scene {
|
||||
.setScrollFactor(0)
|
||||
.setDepth(100)
|
||||
.setVisible(false);
|
||||
// burning-out vignette (vibe < 25%)
|
||||
this.vignette = this.add
|
||||
.rectangle(480, 360, 960, 720, 0xff0000, 1)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(90)
|
||||
.setAlpha(0);
|
||||
|
||||
// Vibe heartbeat.
|
||||
this.time.addEvent({
|
||||
@ -111,20 +175,55 @@ export default class GameScene extends Phaser.Scene {
|
||||
},
|
||||
});
|
||||
|
||||
this.input.keyboard.on('keydown', () => {
|
||||
if (this.state === 'TITLE') this.startGame();
|
||||
});
|
||||
|
||||
this.state = 'TITLE';
|
||||
this.resetStats();
|
||||
this.buildLevel(0);
|
||||
this.showOverlay('VINYL GAUNTLET\n\npress any key');
|
||||
this.showTitle();
|
||||
}
|
||||
|
||||
setupTouch() {
|
||||
this.input.addPointer(2);
|
||||
this.touchMove = null;
|
||||
this.touchFire = null;
|
||||
const overHorn = (p) => this.hornBtn && Phaser.Math.Distance.Between(p.x, p.y, 870, 630) < 60;
|
||||
this.input.on('pointerdown', (p) => {
|
||||
if (this.state !== 'PLAYING' || overHorn(p)) return;
|
||||
const stick = { id: p.id, ox: p.x, oy: p.y, dx: 0, dy: 0 };
|
||||
if (p.x < 480) this.touchMove = stick;
|
||||
else this.touchFire = stick;
|
||||
});
|
||||
this.input.on('pointermove', (p) => {
|
||||
for (const s of [this.touchMove, this.touchFire]) {
|
||||
if (s && s.id === p.id) {
|
||||
s.dx = p.x - s.ox;
|
||||
s.dy = p.y - s.oy;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.input.on('pointerup', (p) => {
|
||||
if (this.touchMove && this.touchMove.id === p.id) this.touchMove = null;
|
||||
if (this.touchFire && this.touchFire.id === p.id) this.touchFire = null;
|
||||
});
|
||||
if (this.sys.game.device.input.touch) {
|
||||
this.hornBtn = this.add
|
||||
.circle(870, 630, 42, 0xffffff, 0.25)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(100)
|
||||
.setStrokeStyle(2, 0xffffff)
|
||||
.setInteractive();
|
||||
this.add
|
||||
.text(870, 630, 'HORN', { fontFamily: 'monospace', fontSize: '14px', color: '#ffffff' })
|
||||
.setOrigin(0.5)
|
||||
.setScrollFactor(0)
|
||||
.setDepth(101);
|
||||
this.hornBtn.on('pointerdown', () => this.doAirhorn());
|
||||
}
|
||||
}
|
||||
|
||||
resetStats() {
|
||||
this.vibe = VIBE_MAX;
|
||||
this.lanyards = 0;
|
||||
this.airhorns = 1;
|
||||
this.score = 0;
|
||||
this.lanyards = this.cls.lanyards;
|
||||
this.airhorns = this.cls.airhorns;
|
||||
this.densityBase = 1;
|
||||
this.nextFire = 0;
|
||||
this.soundGuyTick = 0;
|
||||
@ -138,6 +237,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
if (row.length !== w0) throw new Error(`level ${index}: ragged row (${row.length} vs ${w0})`);
|
||||
|
||||
this.levelIndex = index;
|
||||
this.theme = THEMES[index % THEMES.length];
|
||||
this.walls.clear(true, true);
|
||||
this.doors.clear(true, true);
|
||||
this.pickups.clear(true, true);
|
||||
@ -158,10 +258,10 @@ export default class GameScene extends Phaser.Scene {
|
||||
// Tiled floor behind everything.
|
||||
if (this.floor) this.floor.destroy();
|
||||
this.floor = this.add
|
||||
.tileSprite(0, 0, worldW, worldH, 'floor')
|
||||
.tileSprite(0, 0, worldW, worldH, this.theme.floor)
|
||||
.setOrigin(0)
|
||||
.setDepth(-10)
|
||||
.setTint(0x555555); // darken so the brown crate walls read as raised
|
||||
.setTint(0x555555); // darken so walls read as raised
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
@ -170,7 +270,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
const y = r * TILE + TILE / 2;
|
||||
switch (ch) {
|
||||
case 'W':
|
||||
this.walls.create(x, y, 'wall');
|
||||
this.walls.create(x, y, this.theme.wall);
|
||||
break;
|
||||
case 'D':
|
||||
this.doors.create(x, y, 'door');
|
||||
@ -187,6 +287,9 @@ export default class GameScene extends Phaser.Scene {
|
||||
case 'A':
|
||||
this.pickups.create(x, y, 'airhorn').setData('type', 'airhorn');
|
||||
break;
|
||||
case 'R':
|
||||
this.pickups.create(x, y, 'gold').setData('type', 'gold');
|
||||
break;
|
||||
case 'G': {
|
||||
const g = this.generators.create(x, y, 'generator');
|
||||
g.setData('hp', GEN_HP);
|
||||
@ -221,7 +324,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
update(time, delta) {
|
||||
if (this.state === 'TITLE') return;
|
||||
if (this.state === 'GAME_OVER') {
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keyR)) this.startGame();
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keyR)) this.showTitle();
|
||||
return;
|
||||
}
|
||||
if (this.state === 'LEVEL_CLEAR') return;
|
||||
@ -233,7 +336,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
this.updateGenerators(delta);
|
||||
this.updateSoundGuys();
|
||||
this.density = this.densityBase + 0.05 * Math.floor((time - this.levelStartTime) / 10000);
|
||||
this.handleAirhorn();
|
||||
if (Phaser.Input.Keyboard.JustDown(this.keySpace)) this.doAirhorn();
|
||||
this.handleTrainspot(delta);
|
||||
this.updateHud();
|
||||
}
|
||||
@ -247,7 +350,16 @@ export default class GameScene extends Phaser.Scene {
|
||||
vx *= inv;
|
||||
vy *= inv;
|
||||
}
|
||||
this.player.setVelocity(vx * PLAYER_SPEED, vy * PLAYER_SPEED);
|
||||
if (vx === 0 && vy === 0 && this.touchMove) {
|
||||
const m = Math.hypot(this.touchMove.dx, this.touchMove.dy);
|
||||
if (m > 12) {
|
||||
vx = this.touchMove.dx / m;
|
||||
vy = this.touchMove.dy / m;
|
||||
}
|
||||
}
|
||||
// burning out (< 25% vibe) = sluggish
|
||||
const spd = PLAYER_SPEED * this.cls.speed * (this.vibe < VIBE_MAX * 0.25 ? 0.85 : 1);
|
||||
this.player.setVelocity(vx * spd, vy * spd);
|
||||
}
|
||||
|
||||
handleFiring(time) {
|
||||
@ -255,18 +367,27 @@ export default class GameScene extends Phaser.Scene {
|
||||
const c = this.cursors;
|
||||
let dx = (c.right.isDown ? 1 : 0) - (c.left.isDown ? 1 : 0);
|
||||
let dy = (c.down.isDown ? 1 : 0) - (c.up.isDown ? 1 : 0);
|
||||
if (dx === 0 && dy === 0) return;
|
||||
if (dx !== 0 && dy !== 0) {
|
||||
const inv = 1 / Math.sqrt(2);
|
||||
dx *= inv;
|
||||
dy *= inv;
|
||||
}
|
||||
if (dx === 0 && dy === 0 && this.touchFire) {
|
||||
const m = Math.hypot(this.touchFire.dx, this.touchFire.dy);
|
||||
if (m > 15) {
|
||||
dx = this.touchFire.dx / m;
|
||||
dy = this.touchFire.dy / m;
|
||||
}
|
||||
}
|
||||
if (dx === 0 && dy === 0) return;
|
||||
const v = this.vinyl.get(this.player.x, this.player.y, 'vinyl');
|
||||
if (!v) return;
|
||||
v.setActive(true).setVisible(true);
|
||||
v.body.enable = true;
|
||||
v.setPosition(this.player.x, this.player.y);
|
||||
v.setVelocity(dx * VINYL_SPEED, dy * VINYL_SPEED);
|
||||
// peak time (> 75% vibe) = hotter throws
|
||||
const vs = VINYL_SPEED * (this.vibe > VIBE_MAX * 0.75 ? 1.2 : 1);
|
||||
v.setVelocity(dx * vs, dy * vs);
|
||||
v.setData('dieAt', time + VINYL_LIFE_MS);
|
||||
this.nextFire = time + FIRE_COOLDOWN_MS;
|
||||
}
|
||||
@ -277,9 +398,36 @@ export default class GameScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
nearestPickup(from) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
this.pickups.getChildren().forEach((p) => {
|
||||
const d = Phaser.Math.Distance.Between(from.x, from.y, p.x, p.y);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = p;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
updateEnemies() {
|
||||
this.enemies.getChildren().forEach((e) => {
|
||||
if (e.active) this.physics.moveToObject(e, this.player, e.getData('speed'));
|
||||
if (!e.active) return;
|
||||
let target = this.player;
|
||||
if (e.getData('type') === 'pirate') {
|
||||
if (e.getData('carry')) {
|
||||
const home = e.getData('home');
|
||||
if (Phaser.Math.Distance.Between(e.x, e.y, home.x, home.y) < 24) {
|
||||
this.kill(e); // escaped with the loot
|
||||
return;
|
||||
}
|
||||
target = home;
|
||||
} else {
|
||||
target = this.nearestPickup(e) || this.player;
|
||||
}
|
||||
}
|
||||
this.physics.moveToObject(e, target, e.getData('speed'));
|
||||
});
|
||||
}
|
||||
|
||||
@ -296,17 +444,32 @@ export default class GameScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
spawnEnemy(g) {
|
||||
const poser = Math.random() < 0.7;
|
||||
const e = this.enemies.get(g.x, g.y, poser ? 'poser' : 'nerd');
|
||||
spawnEnemy(g, forceType) {
|
||||
let type = forceType;
|
||||
if (!type) {
|
||||
let roll = Math.random();
|
||||
for (const [t, w] of this.theme.mix) {
|
||||
roll -= w;
|
||||
type = t;
|
||||
if (roll <= 0) break;
|
||||
}
|
||||
}
|
||||
const def = ENEMY_TYPES[type];
|
||||
const e = this.enemies.get(g.x, g.y, def.tex);
|
||||
if (!e) return; // pool full — skip
|
||||
e.setTexture(poser ? 'poser' : 'nerd');
|
||||
e.setTexture(def.tex);
|
||||
e.setActive(true).setVisible(true);
|
||||
e.clearTint();
|
||||
e.body.enable = true;
|
||||
e.body.setSize(34, 34, true); // body smaller than 48px sprite
|
||||
e.setPosition(g.x, g.y);
|
||||
e.setData('touch', poser ? POSER_TOUCH_DAMAGE : NERD_TOUCH_DAMAGE);
|
||||
e.setData('speed', poser ? POSER_SPEED : NERD_SPEED);
|
||||
e.setData('type', type);
|
||||
e.setData('touch', def.touch);
|
||||
e.setData('speed', def.speed);
|
||||
e.setData('hp', def.hp);
|
||||
e.setData('carry', null);
|
||||
e.setData('home', { x: g.x, y: g.y });
|
||||
if (type === 'pirate') this.say(LINES.pirate);
|
||||
}
|
||||
|
||||
updateSoundGuys() {
|
||||
@ -315,14 +478,13 @@ export default class GameScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
handleAirhorn() {
|
||||
if (!Phaser.Input.Keyboard.JustDown(this.keySpace)) return;
|
||||
if (this.airhorns <= 0) return;
|
||||
doAirhorn() {
|
||||
if (this.state !== 'PLAYING' || this.airhorns <= 0) return;
|
||||
this.airhorns -= 1;
|
||||
this.cameras.main.flash(200, 255, 255, 255);
|
||||
this.cameras.main.shake(200, 0.01);
|
||||
this.enemies.getChildren().forEach((e) => {
|
||||
if (e.active) this.kill(e);
|
||||
if (e.active) this.dropLoot(e) || this.kill(e);
|
||||
});
|
||||
this.soundGuys.clear(true, true);
|
||||
this.airhornBlast();
|
||||
@ -346,9 +508,10 @@ export default class GameScene extends Phaser.Scene {
|
||||
updateHud() {
|
||||
const v = Math.max(0, Math.ceil(this.vibe));
|
||||
this.hud.setText(
|
||||
`VIBE ${v} LANYARDS ${this.lanyards} AIRHORNS ${this.airhorns} LEVEL ${this.levelIndex + 1}`,
|
||||
`SCORE ${this.score} VIBE ${v} LANYARDS ${this.lanyards} AIRHORNS ${this.airhorns} LEVEL ${this.levelIndex + 1}`,
|
||||
);
|
||||
this.hud.setColor(this.vibe < 200 ? '#ff4040' : this.vibe < 500 ? '#ffd11a' : '#ffffff');
|
||||
this.vignette.setAlpha(this.vibe < VIBE_MAX * 0.25 ? 0.12 : 0);
|
||||
}
|
||||
|
||||
// --- collision callbacks ---
|
||||
@ -357,16 +520,35 @@ export default class GameScene extends Phaser.Scene {
|
||||
this.kill(v);
|
||||
}
|
||||
|
||||
// pirate killed while carrying: put the pickup back + a gold record bonus
|
||||
dropLoot(e) {
|
||||
const carry = e.getData('carry');
|
||||
if (!carry) return false;
|
||||
this.pickups.create(e.x, e.y, carry.tex).setData('type', carry.type);
|
||||
this.pickups.create(e.x + TILE, e.y, 'gold').setData('type', 'gold');
|
||||
e.setData('carry', null);
|
||||
this.kill(e);
|
||||
return true;
|
||||
}
|
||||
|
||||
hitEnemy(v, e) {
|
||||
if (!v.active || !e.active) return;
|
||||
this.kill(v);
|
||||
this.kill(e);
|
||||
const hp = e.getData('hp') - this.cls.dmg;
|
||||
if (hp > 0) {
|
||||
e.setData('hp', hp);
|
||||
e.setTintFill(0xffffff);
|
||||
this.time.delayedCall(60, () => e.active && e.clearTint());
|
||||
return;
|
||||
}
|
||||
this.score += KILL_SCORE;
|
||||
if (!this.dropLoot(e)) this.kill(e);
|
||||
}
|
||||
|
||||
hitGenerator(v, g) {
|
||||
if (!v.active || !g.active) return;
|
||||
this.kill(v);
|
||||
const hp = g.getData('hp') - 1;
|
||||
const hp = g.getData('hp') - this.cls.dmg;
|
||||
g.setData('hp', hp);
|
||||
g.setTintFill(0xffffff);
|
||||
this.time.delayedCall(60, () => g.active && g.clearTint());
|
||||
@ -376,17 +558,31 @@ export default class GameScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
vinylHitsPickup(v, item) {
|
||||
// Gauntlet's "Elf shot the food!" — only food is destructible
|
||||
if (!v.active || item.getData('type') !== 'espresso') return;
|
||||
this.kill(v);
|
||||
item.destroy();
|
||||
this.say(LINES.shotEspresso);
|
||||
}
|
||||
|
||||
pirateGrab(e, item) {
|
||||
if (!e.active || e.getData('type') !== 'pirate' || e.getData('carry')) return;
|
||||
e.setData('carry', { type: item.getData('type'), tex: item.texture.key });
|
||||
item.destroy();
|
||||
}
|
||||
|
||||
touchEnemy(p, e) {
|
||||
if (!e.active) return;
|
||||
const dmg = e.getData('touch');
|
||||
this.kill(e);
|
||||
if (!this.dropLoot(e)) this.kill(e);
|
||||
this.takeDamage(dmg);
|
||||
}
|
||||
|
||||
touchSoundGuy() {
|
||||
if (this.time.now - this.soundGuyTick > 250) {
|
||||
this.soundGuyTick = this.time.now;
|
||||
this.takeDamage(SOUNDGUY_DRAIN);
|
||||
this.takeDamage(SOUNDGUY_DRAIN * this.cls.sgDrain);
|
||||
}
|
||||
}
|
||||
|
||||
@ -395,6 +591,10 @@ export default class GameScene extends Phaser.Scene {
|
||||
if (type === 'espresso') this.vibe = Math.min(VIBE_MAX, this.vibe + ESPRESSO_VIBE);
|
||||
else if (type === 'lanyard') this.lanyards += 1;
|
||||
else if (type === 'airhorn') this.airhorns += 1;
|
||||
else if (type === 'gold') {
|
||||
this.score += GOLD_SCORE;
|
||||
this.say(LINES.gold);
|
||||
}
|
||||
item.destroy();
|
||||
}
|
||||
|
||||
@ -419,8 +619,8 @@ export default class GameScene extends Phaser.Scene {
|
||||
takeDamage(amount) {
|
||||
if (this.state !== 'PLAYING') return;
|
||||
this.vibe -= amount;
|
||||
this.player.setTint(0xff0000);
|
||||
this.time.delayedCall(100, () => this.player.clearTint());
|
||||
this.player.setTintFill(0xff0000);
|
||||
this.time.delayedCall(100, () => this.player.setTint(this.cls.tint));
|
||||
if (this.trainspotProgress > 0) this.trainspotProgress = 0; // trainspot breaks on any hit
|
||||
this.checkThresholds();
|
||||
if (this.vibe <= 0) this.gameOver();
|
||||
@ -434,37 +634,75 @@ export default class GameScene extends Phaser.Scene {
|
||||
levelClear() {
|
||||
if (this.state !== 'PLAYING') return;
|
||||
this.state = 'LEVEL_CLEAR';
|
||||
this.score += CLEAR_SCORE;
|
||||
this.physics.pause();
|
||||
this.barBg.setVisible(false);
|
||||
this.barFill.setVisible(false);
|
||||
const next = (this.levelIndex + 1) % LEVELS.length;
|
||||
this.showOverlay('TUNE IDENTIFIED');
|
||||
this.sub.setText(`next: ${THEMES[next % THEMES.length].name}`).setVisible(true);
|
||||
this.say(LINES.levelClear);
|
||||
this.time.delayedCall(1500, () => {
|
||||
this.physics.resume();
|
||||
let next = this.levelIndex + 1;
|
||||
if (next >= LEVELS.length) {
|
||||
next = 0;
|
||||
this.densityBase += 0.5; // loop back harder
|
||||
}
|
||||
if (this.levelIndex + 1 >= LEVELS.length) this.densityBase += 0.5; // loop back harder
|
||||
this.hideOverlay();
|
||||
this.state = 'PLAYING';
|
||||
this.buildLevel(next);
|
||||
});
|
||||
}
|
||||
|
||||
// --- high scores (localStorage) ---
|
||||
|
||||
loadScores() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('vg_hiscores')) || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
maybeSaveScore() {
|
||||
if (this.score <= 0) return;
|
||||
const scores = this.loadScores();
|
||||
if (scores.length >= 5 && this.score <= scores[scores.length - 1].score) return;
|
||||
// ponytail: window.prompt beats building a text-entry widget; revisit if it grates
|
||||
const raw = window.prompt(`High score: ${this.score}! Your initials:`, 'AAA');
|
||||
const initials = (raw || 'AAA').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 3) || 'AAA';
|
||||
scores.push({ initials, score: this.score });
|
||||
scores.sort((a, b) => b.score - a.score);
|
||||
localStorage.setItem('vg_hiscores', JSON.stringify(scores.slice(0, 5)));
|
||||
}
|
||||
|
||||
gameOver() {
|
||||
if (this.state !== 'PLAYING') return;
|
||||
this.state = 'GAME_OVER';
|
||||
this.player.setVelocity(0, 0);
|
||||
this.physics.pause();
|
||||
this.say(LINES.gameOver);
|
||||
this.showOverlay('TRAINWRECK\n\npress R');
|
||||
this.maybeSaveScore();
|
||||
this.showOverlay('TRAINWRECK');
|
||||
this.sub.setText(`score ${this.score}\n\npress R`).setVisible(true);
|
||||
}
|
||||
|
||||
startGame() {
|
||||
showTitle() {
|
||||
this.state = 'TITLE';
|
||||
this.physics.pause();
|
||||
this.buildLevel(0);
|
||||
const hs = this.loadScores()
|
||||
.map((s, i) => `${i + 1}. ${s.initials} ${s.score}`)
|
||||
.join('\n');
|
||||
this.showOverlay('VINYL GAUNTLET');
|
||||
this.sub.setText(hs ? `HIGH SCORES\n${hs}` : 'dig deep. name every tune.').setVisible(true);
|
||||
this.classButtons.forEach((b) => b.setVisible(true));
|
||||
}
|
||||
|
||||
startGame(clsKey) {
|
||||
this.cls = CLASSES[clsKey] || CLASSES.digger;
|
||||
this.player.setTint(this.cls.tint);
|
||||
this.physics.resume();
|
||||
this.resetStats();
|
||||
this.hideOverlay();
|
||||
this.classButtons.forEach((b) => b.setVisible(false));
|
||||
this.state = 'PLAYING';
|
||||
this.buildLevel(0);
|
||||
}
|
||||
@ -475,6 +713,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
|
||||
hideOverlay() {
|
||||
this.overlay.setVisible(false);
|
||||
this.sub.setVisible(false);
|
||||
}
|
||||
|
||||
say(line) {
|
||||
|
||||
@ -44,4 +44,7 @@ export const LINES = {
|
||||
doorOpen: ['doorOpen', 'Velvet rope unlocked.'],
|
||||
levelClear: ['levelClear', 'Tune identified. Selector levels up.'],
|
||||
trainspot: ['trainspot', 'Trainspotting.'],
|
||||
shotEspresso: ['shotEspresso', 'Selector shot the espresso.'],
|
||||
pirate: ['pirate', 'Record pirate. Protect the wax.'],
|
||||
gold: ['gold', 'Wax acquired.'],
|
||||
};
|
||||
|
||||
@ -33,8 +33,8 @@ const level1 = [
|
||||
edge('..........WWWWW'),
|
||||
edge(''),
|
||||
edge(''),
|
||||
edge('.....................E'),
|
||||
edge('.........................X'),
|
||||
edge('..............R......E'),
|
||||
edge('.....R...................X'),
|
||||
edge(''),
|
||||
wall,
|
||||
];
|
||||
@ -55,8 +55,8 @@ const level2 = [
|
||||
edge('....WW....WWWW....WW'),
|
||||
edge('....WW....W..G....'),
|
||||
edge('....WW....W.......E'),
|
||||
edge('..........WWWWWW..'),
|
||||
edge('.E................'),
|
||||
edge('..........WWWWWW..R'),
|
||||
edge('.E......R.........'),
|
||||
edge('...................X'),
|
||||
wall,
|
||||
];
|
||||
@ -77,8 +77,8 @@ const level3 = [
|
||||
edge('.WWWW.WWWW.WWWW.WWWW'),
|
||||
edge('.W.G....E....G....E.'),
|
||||
edge('.WWWW.WWWW.WWWW.WWW'),
|
||||
edge('.......S...........'),
|
||||
edge('.E.................'),
|
||||
edge('.......S.......R....'),
|
||||
edge('.E....R............'),
|
||||
edge('.................X'),
|
||||
wall,
|
||||
];
|
||||
|
||||
@ -8,6 +8,7 @@ window.game = new Phaser.Game({
|
||||
parent: 'game',
|
||||
backgroundColor: '#0a0a0a',
|
||||
pixelArt: true,
|
||||
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }, // phones get a fitted canvas
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: { gravity: { x: 0, y: 0 }, debug: false },
|
||||
|
||||