Vinyl Gauntlet: core game (Gauntlet II re-skin) + FLUX asset queue

Top-down arcade maze shooter in Phaser 3. Steps 1-6 of plan.md complete and
verified: vibe drain, generators/swarm, pooling, combat, Sound Guy, airhorn,
trainspot exit, level flow, announcer. assets/ holds the FLUX prompt manifest
+ MODELBEAST enqueuer for the Step 7 sprite pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-14 23:17:19 +10:00
commit 0a71e07078
12 changed files with 2161 additions and 0 deletions

11
.claude/launch.json Normal file
View File

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules/
dist/
.DS_Store
*.log
# generated FLUX assets + ephemeral queue lock (regenerate via assets/enqueue.py)
assets/*.png
assets/generated/
assets/queue.lock.json

54
assets/enqueue.py Normal file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Enqueue Vinyl Gauntlet's asset queue into MODELBEAST as flux_local jobs.
Runs from the MODELBEAST checkout (imports its server modules) and inserts jobs
straight into the jobs table as the owner the jobs table IS the queue. After
running, restart the MB server (scripts/serve.sh) so the runner picks them up.
cd ~/MODELBEAST && ./venvs/... no use the app env:
/opt/homebrew/bin/uv run python /path/to/vinylgauntlet/assets/enqueue.py
Writes assets/queue.lock.json mapping job_id -> asset name so outputs can be
collected later from data/jobs/<job_id>/.
"""
import json, sys
from pathlib import Path
MB = Path.home() / "MODELBEAST"
sys.path.insert(0, str(MB))
import server.db as db # noqa: E402
import server.runner as runner # noqa: E402
HERE = Path(__file__).resolve().parent
q = json.loads((HERE / "queue.json").read_text())
styles = q["style"]
defaults = q["defaults"]
con = db.connect()
owner = con.execute("SELECT id FROM users WHERE role='owner' LIMIT 1").fetchone()["id"]
lock = []
for i, item in enumerate(q["items"]):
prompt = f"{item['prompt']}. {styles[item['style']]}"
params = {
"prompt": prompt,
"model": defaults["model"],
"steps": defaults["steps"],
"width": item["w"],
"height": item["h"],
"seed": 7 + i, # per-asset fixed seed = reproducible
}
job_id = db.new_id()
outdir = runner.JOBS_DIR / job_id
outdir.mkdir(parents=True, exist_ok=True)
con.execute(
"INSERT INTO jobs (id, operator, status, asset_id, asset_ids, params, outdir, created_at, user_id) "
"VALUES (?, 'flux_local', 'queued', NULL, '[]', ?, ?, ?, ?)",
(job_id, json.dumps(params), str(outdir), db.now(), owner),
)
lock.append({"job_id": job_id, "name": item["name"], "seed": params["seed"]})
print(f"queued {item['name']:20s} {job_id}")
con.commit()
(HERE / "queue.lock.json").write_text(json.dumps(lock, indent=2))
print(f"\n{len(lock)} jobs queued. Restart MB to run: cd ~/MODELBEAST && scripts/serve.sh")

33
assets/queue.json Normal file
View File

@ -0,0 +1,33 @@
{
"_comment": "FLUX asset queue for Vinyl Gauntlet. Edit prompts/seeds and re-run assets/enqueue.py to (re)generate via MODELBEAST flux_local (klein-4b, local M-series GPU). Sprites render on flat magenta #ff00ff for chroma-keying; run bg_remove_local afterwards for transparency.",
"style": {
"sprite": "16-bit SNES-era pixel art game sprite, top-down view, bold clean black outline, vibrant saturated colors, single centered subject, flat solid magenta #ff00ff background, no text, no watermark, no drop shadow",
"tile": "16-bit pixel art, seamless tileable top-down floor texture, flat even lighting, no objects, no perspective, no text",
"wall": "16-bit pixel art, top-down game wall tile, seamless, dark, no text"
},
"defaults": { "model": "flux2-klein-4b", "steps": 4 },
"items": [
{ "name": "floor_parquet", "style": "tile", "w": 512, "h": 512, "prompt": "wooden parquet record-shop floor" },
{ "name": "floor_checker", "style": "tile", "w": 512, "h": 512, "prompt": "black and white checkerboard nightclub floor tiles" },
{ "name": "floor_carpet", "style": "tile", "w": 512, "h": 512, "prompt": "worn dark red record-store carpet, subtle pattern" },
{ "name": "wall_crate", "style": "wall", "w": 512, "h": 512, "prompt": "stacked wooden vinyl record crates seen from above, brown" },
{ "name": "wall_shelf", "style": "wall", "w": 512, "h": 512, "prompt": "shelf packed tight with vinyl record spines" },
{ "name": "wall_poster", "style": "wall", "w": 512, "h": 512, "prompt": "brick wall plastered with gig flyers and rave posters" },
{ "name": "player_digger", "style": "sprite", "w": 768, "h": 768, "prompt": "a cool crate-digger DJ hero wearing headphones and a snapback cap, clutching a box of vinyl records" },
{ "name": "enemy_poser", "style": "sprite", "w": 768, "h": 768, "prompt": "a drunk clubber poser spilling a pint, goofy stumbling pose" },
{ "name": "enemy_gearnerd", "style": "sprite", "w": 768, "h": 768, "prompt": "a nerdy audiophile with huge glasses clutching a vintage synthesizer, cornering you to talk gear" },
{ "name": "enemy_soundguy", "style": "sprite", "w": 768, "h": 768, "prompt": "a menacing sound engineer specter in dark clothes with glowing eyes and a clipboard, unstoppable boss" },
{ "name": "enemy_record_pirate", "style": "sprite", "w": 768, "h": 768, "prompt": "a skeleton record pirate in a tricorn hat brandishing a black vinyl record like a cutlass" },
{ "name": "enemy_crate_ghoul", "style": "sprite", "w": 768, "h": 768, "prompt": "a green zombie crate-digger ghoul rising out of a record bin, arms outstretched" },
{ "name": "gen_discount_bin", "style": "sprite", "w": 768, "h": 768, "prompt": "a cardboard dump bargain bin overflowing with cheap dusty vinyl records" },
{ "name": "pickup_espresso", "style": "sprite", "w": 512, "h": 512, "prompt": "a steaming double espresso in a small cup, glowing energy icon" },
{ "name": "pickup_lanyard", "style": "sprite", "w": 512, "h": 512, "prompt": "a VIP backstage laminate pass on a lanyard, shiny" },
{ "name": "pickup_airhorn", "style": "sprite", "w": 512, "h": 512, "prompt": "a red and chrome handheld air horn can" },
{ "name": "pickup_vinyl", "style": "sprite", "w": 512, "h": 512, "prompt": "a single black 12 inch vinyl record with a red center label" },
{ "name": "pickup_goldrecord", "style": "sprite", "w": 512, "h": 512, "prompt": "a shiny gold vinyl record trophy, sparkling grail, rare" },
{ "name": "prop_djbooth", "style": "sprite", "w": 768, "h": 768, "prompt": "a glowing DJ booth with twin turntables and a mixer, magenta and cyan neon glow, the exit portal" },
{ "name": "prop_turntable", "style": "sprite", "w": 512, "h": 512, "prompt": "a professional black turntable with a spinning vinyl record and tonearm" },
{ "name": "prop_velvet_rope", "style": "sprite", "w": 512, "h": 512, "prompt": "a red velvet rope barrier strung between two brass stanchion posts" },
{ "name": "prop_lamp", "style": "sprite", "w": 512, "h": 512, "prompt": "a hanging industrial pendant lamp casting a warm glow" }
]
}

17
index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vinyl Gauntlet</title>
<style>
html, body { margin: 0; background: #000; height: 100%; }
body { display: flex; align-items: center; justify-content: center; }
canvas { image-rendering: pixelated; }
</style>
</head>
<body>
<div id="game"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1161
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "vinylgauntlet",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^6.0.0"
},
"dependencies": {
"phaser": "^3.87.0"
},
"allowScripts": {
"esbuild@0.25.12": true
}
}

182
plan.md Normal file
View File

@ -0,0 +1,182 @@
# VINYL GAUNTLET — Implementation Plan
A top-down arcade maze shooter that clones the mechanics of **Gauntlet II (1986)**,
re-skinned as record-collecting / DJ / trainspotting culture. You dig through record
fairs and warehouse clubs, fight off swarming clubbers, and reach the DJ Booth to
trainspot the track and clear the level.
**Execute the steps IN ORDER. Each step ends with an acceptance check — do not start
the next step until the check passes in the browser. Do not add features not listed
here. Placeholder geometric shapes throughout; no sprite art until Step 7.**
## Tech stack
- Vite + vanilla JS (no TypeScript, no framework)
- Phaser 3 (`npm install phaser`), Arcade Physics, zero gravity, top-down
- One canvas 960x720. Tile size 32px.
- No external assets: shapes are `add.rectangle`/`add.circle` converted to physics
bodies (or generated textures via `make.graphics`). Voice via `window.speechSynthesis`.
The single sound effect (airhorn) via WebAudio oscillator — no audio files.
## File layout (keep it this small)
```
index.html
src/main.js — Phaser config + scene registration
src/GameScene.js — the whole game (it's fine, it's an arcade game)
src/levels.js — level maps as 2D arrays + legend
src/announcer.js — speechSynthesis wrapper + line table
```
## Core constants (put at top of GameScene.js, tune later)
```js
const TILE = 32;
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; shrinks with crowd density
const PLAYER_SPEED = 180;
const POSER_SPEED = 120;
const NERD_SPEED = 70;
const SOUNDGUY_SPEED = 55;
const VINYL_SPEED = 400;
const FIRE_COOLDOWN_MS = 180;
const TRAINSPOT_MS = 3000;
```
---
## Step 1 — Project, tilemap, movement
- `npm create vite@latest . -- --template vanilla`, then `npm install phaser`.
- Phaser config: 960x720, arcade physics, `gravity: {y: 0}`, pixelArt true.
- **Levels are 2D arrays of single characters** in `src/levels.js`:
```
W = wall (crate stack, brown) . = floor (dark grey #1a1a1a)
P = player start E = espresso (green square)
K = lanyard/key (yellow square) D = velvet rope door (red block)
G = generator/discount bin (purple) X = DJ booth exit (magenta, pulsing alpha tween)
S = sound guy spawn A = airhorn pickup (white square)
```
Export `LEVELS = [level1, level2, level3]` — author 3 maps by hand, each ~30x25
tiles, Gauntlet-style: rooms, corridors, a keyed door between player and exit,
24 generators. Level 1 easy (1 generator, no sound guy), level 3 nasty.
- Build the level in `buildLevel(index)`: walls into a `staticGroup`, everything else
into groups/objects. This function must fully tear down the previous level first
(clear groups, timers, colliders) — it gets called on every level change and restart.
- Player: cyan circle, 8-way WASD movement, normalized diagonal speed, collides with
walls. Camera follows player with lerp; world bounds = map size.
**Check:** player drives around level 1, can't pass through walls, camera follows.
## Step 2 — Vibe drain, pickups, doors, HUD
- `this.vibe = VIBE_MAX`. A 1-second looped timer subtracts `VIBE_DRAIN_PER_SEC`.
- HUD (fixed to camera): `VIBE 1987 LANYARDS 1 AIRHORNS 1 LEVEL 1`.
Vibe text turns yellow below 500, red below 200.
- Espresso overlap: +`ESPRESSO_VIBE` (cap at `VIBE_MAX`), destroy pickup.
- Lanyard overlap: `lanyards++`, destroy pickup.
- Velvet rope: static red blocks that collide like walls; on collide, if
`lanyards > 0` then `lanyards--` and destroy the block.
- Vibe <= 0 → GAME_OVER state: freeze physics, "TRAINWRECK" centered, press R to
restart at level 1 with fresh vibe.
- Game states as a plain string field: `TITLE | PLAYING | GAME_OVER | LEVEL_CLEAR`.
Title screen = level shown behind a "VINYL GAUNTLET — press any key" overlay.
**Check:** vibe visibly ticks down, espresso refills it, a lanyard opens a rope,
letting vibe hit 0 shows game over, R restarts.
## Step 3 — Shooting, generators, the swarm
- **Firing:** arrow keys fire vinyl (small black circle with white center dot) in 8
directions (combinations of arrows), independent of movement direction — this is
the Gauntlet control scheme. Rate-limit with `FIRE_COOLDOWN_MS`. Vinyl dies on
wall impact or after 1200ms.
- **Object pooling (CRITICAL):** one `physics.add.group` for vinyl (size 30), one for
enemies (size 80). Spawn with `group.get()`, kill with
`setActive(false).setVisible(false)` + disable body. Never `new`/`destroy` per shot.
- **Generators (Discount Bins):** purple squares, `GEN_HP` hits. Each has its own
looped spawn timer (`GEN_SPAWN_MS`): if it's within ~700px of the player, spawn an
enemy on an adjacent floor tile. Vinyl hit → hp--, brief white flash tween; at 0,
destroy generator and its timer.
- **Two spawned enemy types** (generator picks 70/30):
- **Poser** (red triangle): fast, `moveToObject` toward player every 400ms.
- **Gear Nerd** (orange square): slow, same homing, bigger touch damage — the
"ghost" that corners you and drains you with synth talk.
- **Crowd density:** `this.density = 1`, +0.05 every 10s on the current level;
effective spawn interval = `GEN_SPAWN_MS / density`. Resets each level.
**Check:** shooting feels like Gauntlet (move one way, shoot another), bins pour out
enemies until destroyed, 50+ enemies on screen holds 60fps.
## Step 4 — Combat, the Sound Guy, the Airhorn
- Vinyl overlaps enemy → both deactivated (back to pool).
- Enemy overlaps player → enemy deactivated, vibe -= its touch damage, 100ms red
tint flash on player. (Enemy dies on contact = authentic Gauntlet grunt behavior
and prevents per-frame drain stacking.)
- **The Sound Guy** (Gauntlet's Death): black square with a white "SND" label, one
per level at the `S` tile (levels 2+). No wall collider — phases through
everything. Homes at `SOUNDGUY_SPEED`. Immune to vinyl (vinyl passes through).
While overlapping the player, drain `SOUNDGUY_DRAIN` per 250ms tick — NOT per
frame. Only the airhorn removes him.
- **Airhorn (smart bomb):** spacebar, if `airhorns > 0`: white full-screen flash
(200ms), camera shake, deactivate every active enemy AND the sound guy
(generators survive), play a two-tone WebAudio oscillator blast (BWAA-BWAA).
`A` pickups grant +1; player starts with 1.
**Check:** shooting clears swarms, sound guy walks through walls and is only
stoppable by airhorn, airhorn wipes the screen.
## Step 5 — Trainspotting exit + level flow
- **DJ Booth (`X`):** while the player overlaps it, a progress bar fills over
`TRAINSPOT_MS` ("TRAINSPOTTING..."). Taking ANY damage or leaving the tile resets
the bar to zero. This is the level's tension peak — enemies keep coming.
- Bar full → LEVEL_CLEAR: freeze 1.5s, "TUNE IDENTIFIED", then `buildLevel(next)`.
Vibe, lanyards, airhorns CARRY OVER (Gauntlet rule — the drain is the difficulty
curve). After the last level, loop back to level 1 with `density` starting higher.
**Check:** standing at the booth under pressure fills the bar, getting hit resets
it, level 2 loads with vibe carried over.
## Step 6 — The Announcer
`src/announcer.js`: wrapper around `speechSynthesis` — cancel any current utterance
before speaking, pick the most robotic-sounding available voice, `rate 0.9, pitch 0.6`.
Per-line cooldowns (min 8s per line key) so it never spams. Lines:
| Trigger | Line |
|---|---|
| vibe crosses below 500 | "Selector needs espresso badly." |
| vibe crosses below 200 | "Selector... is about to die." |
| game over | "Trainwreck. Absolute trainwreck." |
| shot a generator to death | "Discount bin... destroyed." |
| sound guy spawns | "The sound guy... is coming." |
| airhorn used | (no speech — the oscillator blast IS the line) |
| door opened | "Velvet rope... unlocked." |
| level clear | "Tune identified. Selector levels up." |
| touching booth, bar filling | "Trainspotting..." (once per attempt) |
**Check:** lines fire at the right moments, never overlap or spam.
## Step 7 — Juice pass (only after 16 are flawless)
Small, in this order, stop when it feels good:
1. Vibe < 200: red vignette (a camera-fixed rectangle with low alpha).
2. Enemy death: 3-particle puff (pooled).
3. Booth pulses brighter as the trainspot bar fills.
4. Only now, optionally swap shapes for sprites — the game must already be fun as
rectangles.
## Explicit NON-GOALS (do not build, do not scaffold for)
Multiplayer. Digging minigame. Finite ammo / vinyl-as-currency. Ranged enemies.
Level editors or Tiled/JSON maps. Vibe-based movement penalties. Saving/persistence.
Mobile controls. More than 3 enemy types. Any menu beyond title/game-over text.
If any of these seem needed, they aren't — finish the loop first.

525
src/GameScene.js Normal file
View File

@ -0,0 +1,525 @@
import Phaser from 'phaser';
import { LEVELS } from './levels.js';
import { announce, LINES } from './announcer.js';
const TILE = 32;
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 = 700;
const PLAYER_SPEED = 180;
const POSER_SPEED = 120;
const NERD_SPEED = 70;
const SOUNDGUY_SPEED = 55;
const VINYL_SPEED = 400;
const VINYL_LIFE_MS = 1200;
const FIRE_COOLDOWN_MS = 180;
const TRAINSPOT_MS = 3000;
export default class GameScene extends Phaser.Scene {
constructor() {
super('game');
}
create() {
this.cameras.main.setBackgroundColor(0x1a1a1a);
this.makeTextures();
// Persistent groups — created once, refilled each level so colliders survive.
this.walls = this.physics.add.staticGroup();
this.doors = this.physics.add.staticGroup();
this.pickups = this.physics.add.staticGroup();
this.generators = this.physics.add.staticGroup();
this.exits = this.physics.add.staticGroup();
this.soundGuys = this.physics.add.group();
this.enemies = this.physics.add.group({ maxSize: 80 });
this.vinyl = this.physics.add.group({ maxSize: 30 });
this.player = this.physics.add.sprite(100, 100, 'player');
this.player.setCollideWorldBounds(true);
// Colliders / overlaps (persistent group refs).
this.physics.add.collider(this.player, this.walls);
this.physics.add.collider(this.enemies, this.walls);
this.physics.add.collider(this.player, this.doors, this.hitDoor, null, this);
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.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);
// 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');
// HUD + overlays (fixed to camera).
this.hud = this.add
.text(12, 10, '', { fontFamily: 'monospace', fontSize: '18px', color: '#ffffff' })
.setScrollFactor(0)
.setDepth(100);
this.overlay = this.add
.text(480, 340, '', {
fontFamily: 'monospace',
fontSize: '40px',
color: '#ff00ff',
align: 'center',
})
.setOrigin(0.5)
.setScrollFactor(0)
.setDepth(100);
this.barBg = this.add
.rectangle(480, 680, 306, 26, 0x000000)
.setScrollFactor(0)
.setDepth(100)
.setVisible(false);
this.barFill = this.add
.rectangle(330, 680, 0, 20, 0xff00ff)
.setOrigin(0, 0.5)
.setScrollFactor(0)
.setDepth(100)
.setVisible(false);
// Vibe heartbeat.
this.time.addEvent({
delay: 1000,
loop: true,
callback: () => {
if (this.state !== 'PLAYING') return;
this.vibe -= VIBE_DRAIN_PER_SEC;
this.checkThresholds();
if (this.vibe <= 0) this.gameOver();
},
});
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');
}
makeTextures() {
const tex = (key, w, h, draw) => {
const g = this.make.graphics({ x: 0, y: 0, add: false });
draw(g);
g.generateTexture(key, w, h);
g.destroy();
};
tex('wall', TILE, TILE, (g) => {
g.fillStyle(0x6b4423).fillRect(0, 0, TILE, TILE);
g.lineStyle(2, 0x3d2814).strokeRect(1, 1, TILE - 2, TILE - 2);
});
tex('door', TILE, TILE, (g) => {
g.fillStyle(0xb02020).fillRect(0, 0, TILE, TILE);
g.lineStyle(2, 0x600).strokeRect(1, 1, TILE - 2, TILE - 2);
});
tex('espresso', 24, 24, (g) => g.fillStyle(0x3ad13a).fillRect(0, 0, 24, 24));
tex('lanyard', 24, 24, (g) => g.fillStyle(0xffd11a).fillRect(0, 0, 24, 24));
tex('airhorn', 24, 24, (g) => {
g.fillStyle(0xffffff).fillRect(0, 0, 24, 24);
g.lineStyle(2, 0x888888).strokeRect(1, 1, 22, 22);
});
tex('generator', 30, 30, (g) => {
g.fillStyle(0x9b30ff).fillRect(0, 0, 30, 30);
g.lineStyle(2, 0x4b0082).strokeRect(1, 1, 28, 28);
});
tex('exit', TILE, TILE, (g) => {
g.fillStyle(0xff00ff).fillRect(0, 0, TILE, TILE);
g.lineStyle(2, 0xffffff).strokeRect(1, 1, TILE - 2, TILE - 2);
});
tex('soundguy', 24, 24, (g) => {
g.fillStyle(0x000000).fillRect(0, 0, 24, 24);
g.lineStyle(2, 0xffffff).strokeRect(1, 1, 22, 22);
});
tex('vinyl', 12, 12, (g) => {
g.fillStyle(0x000000).fillCircle(6, 6, 6);
g.fillStyle(0xffffff).fillCircle(6, 6, 2);
});
tex('poser', 24, 24, (g) => g.fillStyle(0xff3030).fillTriangle(12, 0, 24, 24, 0, 24));
tex('nerd', 24, 24, (g) => g.fillStyle(0xff8c1a).fillRect(0, 0, 24, 24));
tex('player', 26, 26, (g) => g.fillStyle(0x00e5ff).fillCircle(13, 13, 13));
}
resetStats() {
this.vibe = VIBE_MAX;
this.lanyards = 0;
this.airhorns = 1;
this.densityBase = 1;
this.nextFire = 0;
this.soundGuyTick = 0;
this.trainspotProgress = 0;
}
buildLevel(index) {
const map = LEVELS[index];
const w0 = map[0].length;
for (const row of map)
if (row.length !== w0) throw new Error(`level ${index}: ragged row (${row.length} vs ${w0})`);
this.levelIndex = index;
this.walls.clear(true, true);
this.doors.clear(true, true);
this.pickups.clear(true, true);
this.generators.clear(true, true);
this.exits.clear(true, true);
this.soundGuys.clear(true, true);
this.enemies.getChildren().slice().forEach((e) => this.kill(e));
this.vinyl.getChildren().slice().forEach((v) => this.kill(v));
const cols = w0;
const rows = map.length;
const worldW = cols * TILE;
const worldH = rows * TILE;
this.physics.world.setBounds(0, 0, worldW, worldH);
this.cameras.main.setBounds(0, 0, worldW, worldH);
this.cameras.main.startFollow(this.player, true, 0.15, 0.15);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const ch = map[r][c];
const x = c * TILE + TILE / 2;
const y = r * TILE + TILE / 2;
switch (ch) {
case 'W':
this.walls.create(x, y, 'wall');
break;
case 'D':
this.doors.create(x, y, 'door');
break;
case 'P':
this.player.setPosition(x, y);
break;
case 'E':
this.pickups.create(x, y, 'espresso').setData('type', 'espresso');
break;
case 'K':
this.pickups.create(x, y, 'lanyard').setData('type', 'lanyard');
break;
case 'A':
this.pickups.create(x, y, 'airhorn').setData('type', 'airhorn');
break;
case 'G': {
const g = this.generators.create(x, y, 'generator');
g.setData('hp', GEN_HP);
g.setData('accum', 0);
break;
}
case 'S': {
this.soundGuys.create(x, y, 'soundguy');
this.say(LINES.soundGuy);
break;
}
case 'X': {
const ex = this.exits.create(x, y, 'exit');
this.tweens.add({
targets: ex,
alpha: 0.4,
duration: 600,
yoyo: true,
repeat: -1,
});
break;
}
}
}
}
this.density = this.densityBase;
this.levelStartTime = this.time.now;
this.trainspotProgress = 0;
}
update(time, delta) {
if (this.state === 'TITLE') return;
if (this.state === 'GAME_OVER') {
if (Phaser.Input.Keyboard.JustDown(this.keyR)) this.startGame();
return;
}
if (this.state === 'LEVEL_CLEAR') return;
this.handleMovement();
this.handleFiring(time);
this.cullVinyl(time);
this.updateEnemies();
this.updateGenerators(delta);
this.updateSoundGuys();
this.density = this.densityBase + 0.05 * Math.floor((time - this.levelStartTime) / 10000);
this.handleAirhorn();
this.handleTrainspot(delta);
this.updateHud();
}
handleMovement() {
const { W, A, S, D } = this.wasd;
let vx = (D.isDown ? 1 : 0) - (A.isDown ? 1 : 0);
let vy = (S.isDown ? 1 : 0) - (W.isDown ? 1 : 0);
if (vx !== 0 && vy !== 0) {
const inv = 1 / Math.sqrt(2);
vx *= inv;
vy *= inv;
}
this.player.setVelocity(vx * PLAYER_SPEED, vy * PLAYER_SPEED);
}
handleFiring(time) {
if (time < this.nextFire) return;
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;
}
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);
v.setData('dieAt', time + VINYL_LIFE_MS);
this.nextFire = time + FIRE_COOLDOWN_MS;
}
cullVinyl(time) {
this.vinyl.getChildren().forEach((v) => {
if (v.active && time > v.getData('dieAt')) this.kill(v);
});
}
updateEnemies() {
this.enemies.getChildren().forEach((e) => {
if (e.active) this.physics.moveToObject(e, this.player, e.getData('speed'));
});
}
updateGenerators(delta) {
this.generators.getChildren().forEach((g) => {
if (!g.active) 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;
if (accum >= GEN_SPAWN_MS) {
accum -= GEN_SPAWN_MS;
this.spawnEnemy(g);
}
g.setData('accum', accum);
});
}
spawnEnemy(g) {
const poser = Math.random() < 0.7;
const e = this.enemies.get(g.x, g.y, poser ? 'poser' : 'nerd');
if (!e) return; // pool full — skip
e.setTexture(poser ? 'poser' : 'nerd');
e.setActive(true).setVisible(true);
e.body.enable = true;
e.setPosition(g.x, g.y);
e.setData('touch', poser ? POSER_TOUCH_DAMAGE : NERD_TOUCH_DAMAGE);
e.setData('speed', poser ? POSER_SPEED : NERD_SPEED);
}
updateSoundGuys() {
this.soundGuys.getChildren().forEach((sg) => {
if (sg.active) this.physics.moveToObject(sg, this.player, SOUNDGUY_SPEED);
});
}
handleAirhorn() {
if (!Phaser.Input.Keyboard.JustDown(this.keySpace)) return;
if (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);
});
this.soundGuys.clear(true, true);
this.airhornBlast();
}
handleTrainspot(delta) {
if (this.physics.overlap(this.player, this.exits)) {
if (this.trainspotProgress === 0) this.say(LINES.trainspot);
this.trainspotProgress += delta;
this.barBg.setVisible(true);
this.barFill.setVisible(true);
this.barFill.setSize(300 * Math.min(1, this.trainspotProgress / TRAINSPOT_MS), 20);
if (this.trainspotProgress >= TRAINSPOT_MS) this.levelClear();
} else {
this.trainspotProgress = 0;
this.barBg.setVisible(false);
this.barFill.setVisible(false);
}
}
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}`,
);
this.hud.setColor(this.vibe < 200 ? '#ff4040' : this.vibe < 500 ? '#ffd11a' : '#ffffff');
}
// --- collision callbacks ---
killVinyl(v) {
this.kill(v);
}
hitEnemy(v, e) {
if (!v.active || !e.active) return;
this.kill(v);
this.kill(e);
}
hitGenerator(v, g) {
if (!v.active || !g.active) return;
this.kill(v);
const hp = g.getData('hp') - 1;
g.setData('hp', hp);
g.setTintFill(0xffffff);
this.time.delayedCall(60, () => g.active && g.clearTint());
if (hp <= 0) {
g.destroy();
this.say(LINES.genDown);
}
}
touchEnemy(p, e) {
if (!e.active) return;
const dmg = e.getData('touch');
this.kill(e);
this.takeDamage(dmg);
}
touchSoundGuy() {
if (this.time.now - this.soundGuyTick > 250) {
this.soundGuyTick = this.time.now;
this.takeDamage(SOUNDGUY_DRAIN);
}
}
grabPickup(p, item) {
const type = item.getData('type');
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;
item.destroy();
}
hitDoor(p, door) {
if (this.lanyards > 0) {
this.lanyards -= 1;
door.destroy();
this.say(LINES.doorOpen);
}
}
// --- helpers ---
kill(sprite) {
sprite.setActive(false).setVisible(false);
if (sprite.body) {
sprite.body.stop();
sprite.body.enable = false;
}
}
takeDamage(amount) {
if (this.state !== 'PLAYING') return;
this.vibe -= amount;
this.player.setTint(0xff0000);
this.time.delayedCall(100, () => this.player.clearTint());
if (this.trainspotProgress > 0) this.trainspotProgress = 0; // trainspot breaks on any hit
this.checkThresholds();
if (this.vibe <= 0) this.gameOver();
}
checkThresholds() {
if (this.vibe < 200) this.say(LINES.criticalVibe);
else if (this.vibe < 500) this.say(LINES.lowVibe);
}
levelClear() {
if (this.state !== 'PLAYING') return;
this.state = 'LEVEL_CLEAR';
this.physics.pause();
this.barBg.setVisible(false);
this.barFill.setVisible(false);
this.showOverlay('TUNE IDENTIFIED');
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
}
this.hideOverlay();
this.state = 'PLAYING';
this.buildLevel(next);
});
}
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');
}
startGame() {
this.physics.resume();
this.resetStats();
this.hideOverlay();
this.state = 'PLAYING';
this.buildLevel(0);
}
showOverlay(text) {
this.overlay.setText(text).setVisible(true);
}
hideOverlay() {
this.overlay.setVisible(false);
}
say(line) {
announce(line[0], line[1], this.time.now);
}
airhornBlast() {
try {
const ac = this._ac || (this._ac = new (window.AudioContext || window.webkitAudioContext)());
const t = ac.currentTime;
[0, 0.18].forEach((off) => {
const o = ac.createOscillator();
const g = ac.createGain();
o.type = 'sawtooth';
o.frequency.value = 180;
g.gain.setValueAtTime(0.0001, t + off);
g.gain.exponentialRampToValueAtTime(0.3, t + off + 0.02);
g.gain.exponentialRampToValueAtTime(0.0001, t + off + 0.15);
o.connect(g).connect(ac.destination);
o.start(t + off);
o.stop(t + off + 0.16);
});
} catch (e) {
/* no audio available — silent */
}
}
}

47
src/announcer.js Normal file
View File

@ -0,0 +1,47 @@
// Robotic Gauntlet-style announcer via the browser Speech Synthesis API.
// No audio files. Cancels the previous line so calls never pile up, and
// each line key has an 8s cooldown so events can't spam it.
const COOLDOWN_MS = 8000;
const lastSpoken = {};
let robotVoice = null;
function pickVoice() {
const voices = window.speechSynthesis?.getVoices() || [];
// Prefer something that sounds synthetic; fall back to the first available.
robotVoice =
voices.find((v) => /zarvox|trinoids|albert|cellos|robot/i.test(v.name)) ||
voices.find((v) => v.lang?.startsWith('en')) ||
voices[0] ||
null;
}
if (typeof window !== 'undefined' && window.speechSynthesis) {
pickVoice();
window.speechSynthesis.onvoiceschanged = pickVoice;
}
// key = dedupe/cooldown bucket; text = what to say. Same key within 8s is dropped.
export function announce(key, text, now) {
const synth = window.speechSynthesis;
if (!synth) return;
if (lastSpoken[key] && now - lastSpoken[key] < COOLDOWN_MS) return;
lastSpoken[key] = now;
synth.cancel();
const u = new SpeechSynthesisUtterance(text);
if (robotVoice) u.voice = robotVoice;
u.rate = 0.9;
u.pitch = 0.6;
synth.speak(u);
}
export const LINES = {
lowVibe: ['lowVibe', 'Selector needs espresso badly.'],
criticalVibe: ['criticalVibe', 'Selector is about to die.'],
gameOver: ['gameOver', 'Trainwreck. Absolute trainwreck.'],
genDown: ['genDown', 'Discount bin destroyed.'],
soundGuy: ['soundGuy', 'The sound guy is coming.'],
doorOpen: ['doorOpen', 'Velvet rope unlocked.'],
levelClear: ['levelClear', 'Tune identified. Selector levels up.'],
trainspot: ['trainspot', 'Trainspotting.'],
};

86
src/levels.js Normal file
View File

@ -0,0 +1,86 @@
// Levels are arrays of equal-length strings. Legend:
// W wall (crate) . floor P player start X DJ booth exit
// E espresso K lanyard/key D velvet rope G generator (discount bin)
// S sound guy A airhorn
//
// Authoring: edit the strings inside edge("..."). `edge` wraps each row in side
// walls and pads the right with floor, so rows are always the right width — you
// only type the interesting part on the left. A full wall row is `wall`.
// For an interior divider, pass a full-width (WIDTH-2) string to edge.
const WIDTH = 34;
const wall = 'W'.repeat(WIDTH);
const edge = (inner) => 'W' + inner.padEnd(WIDTH - 2, '.') + 'W';
// door at inner col 15 -> a solid horizontal divider with one velvet rope
const divider = 'W'.repeat(15) + 'D' + 'W'.repeat(WIDTH - 2 - 16);
const level1 = [
wall,
edge('..P'),
edge(''),
edge('.......E'),
edge('...WWWW'),
edge('............G'),
edge('...WWWW'),
edge('....K'),
edge(''),
edge('.........A'),
edge(''),
edge(divider),
edge(''),
edge('.......E'),
edge('..........WWWWW'),
edge(''),
edge(''),
edge('.....................E'),
edge('.........................X'),
edge(''),
wall,
];
const level2 = [
wall,
edge('..P.............E'),
edge('....WWWW....WW....WW'),
edge('....W..G....WW....'),
edge('....W.......WW....K'),
edge('....WWWWWW..WW..WWWW'),
edge('...........WW......'),
edge('.E.........WW....G'),
edge('.....S............'),
edge('WWWWWWWW......WWWWWWWWWW'),
edge('.....D'),
edge('.........A'),
edge('....WW....WWWW....WW'),
edge('....WW....W..G....'),
edge('....WW....W.......E'),
edge('..........WWWWWW..'),
edge('.E................'),
edge('...................X'),
wall,
];
const level3 = [
wall,
edge('..P'),
edge('.WWWW.WWWW.WWWW.WWWW.WW'),
edge('.W..G....E....G....W.A'),
edge('.W.WWWW.WWWW.WWWW.W.WW'),
edge('.W.................W'),
edge('.WWWWWWW..S..WWWWWWW'),
edge('.........WW........K'),
edge('.E.......WW.......G'),
edge('WWWW.WWW.WW.WWW.WWWW'),
edge(divider),
edge('.........A'),
edge('.WWWW.WWWW.WWWW.WWWW'),
edge('.W.G....E....G....E.'),
edge('.WWWW.WWWW.WWWW.WWW'),
edge('.......S...........'),
edge('.E.................'),
edge('.................X'),
wall,
];
export const LEVELS = [level1, level2, level3];

16
src/main.js Normal file
View File

@ -0,0 +1,16 @@
import Phaser from 'phaser';
import GameScene from './GameScene.js';
window.game = new Phaser.Game({
type: Phaser.AUTO,
width: 960,
height: 720,
parent: 'game',
backgroundColor: '#0a0a0a',
pixelArt: true,
physics: {
default: 'arcade',
arcade: { gravity: { x: 0, y: 0 }, debug: false },
},
scene: [GameScene],
});