commit 0a71e0707849ad25477da0e387257e3628d03d2e Author: type-two Date: Tue Jul 14 23:17:19 2026 +1000 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 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..8b30f3b --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..457bd6b --- /dev/null +++ b/.gitignore @@ -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 diff --git a/assets/enqueue.py b/assets/enqueue.py new file mode 100644 index 0000000..8018f4b --- /dev/null +++ b/assets/enqueue.py @@ -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//. +""" +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") diff --git a/assets/queue.json b/assets/queue.json new file mode 100644 index 0000000..8cbd6f6 --- /dev/null +++ b/assets/queue.json @@ -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" } + ] +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..c38c8ee --- /dev/null +++ b/index.html @@ -0,0 +1,17 @@ + + + + + + Vinyl Gauntlet + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b470749 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1161 @@ +{ + "name": "vinylgauntlet", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vinylgauntlet", + "version": "0.0.0", + "dependencies": { + "phaser": "^3.87.0" + }, + "devDependencies": { + "vite": "^6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/phaser": { + "version": "3.90.0", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-3.90.0.tgz", + "integrity": "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..297ddf0 --- /dev/null +++ b/package.json @@ -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 + } +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..e6bd2b3 --- /dev/null +++ b/plan.md @@ -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. diff --git a/src/GameScene.js b/src/GameScene.js new file mode 100644 index 0000000..6988e3d --- /dev/null +++ b/src/GameScene.js @@ -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 */ + } + } +} diff --git a/src/announcer.js b/src/announcer.js new file mode 100644 index 0000000..674284f --- /dev/null +++ b/src/announcer.js @@ -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.'], +}; diff --git a/src/levels.js b/src/levels.js new file mode 100644 index 0000000..2946e6d --- /dev/null +++ b/src/levels.js @@ -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]; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..6fcee0c --- /dev/null +++ b/src/main.js @@ -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], +});