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>
This commit is contained in:
type-two 2026-07-16 16:58:25 +10:00
parent 2008668cb2
commit d903dd57ca
12 changed files with 304 additions and 54 deletions

View File

@ -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),

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
public/sprites/ghoul.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/sprites/gold.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/sprites/pirate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

@ -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) {

View File

@ -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.'],
};

View File

@ -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,
];

View File

@ -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 },