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:
commit
0a71e07078
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal 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
54
assets/enqueue.py
Normal 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
33
assets/queue.json
Normal 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
17
index.html
Normal 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
1161
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal 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
182
plan.md
Normal 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,
|
||||
2–4 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 1–6 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
525
src/GameScene.js
Normal 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
47
src/announcer.js
Normal 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
86
src/levels.js
Normal 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
16
src/main.js
Normal 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],
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user