Art pass: wall variants, floor decals, per-class sprites, 2 idle villains
Texture audit found the real weakness: one wall texture repeated up to 316x per maze, so levels read as wallpaper. Generated 26 more images on MODELBEAST (free, local) and wired what was already sitting unused. - 4 wall variants per theme (24 total), picked by a stable hash of tile position: neighbours differ, but a given tile keeps its texture across rebuilds - 8 floor decals scattered on open tiles (5% density, seeded per level, tinted back to 0.45 alpha so litter never competes with sprites) - Classes now have distinct SPRITES instead of colour tints (the 3 player variants were generated in batch 3 and never wired) - Scalper (hunts gold only, flees with it) joins club/festival spawn tables; Fire Marshal (2nd wall-phasing stalker) appears in procgen from level 7 - Readability fix: CLUB NIGHT's fine checker floor read as noise under sprites. Regenerated a coarse one + per-theme floorTint so busy floors get knocked back. - process_sprites skips not-yet-generated files instead of crashing the batch 39 -> 73 game textures. All verified in-browser: 316-wall maze splits evenly across 4 variants, deterministic across rebuilds, per-class sprites, scalper ignores espresso for gold, 71/71 assets load clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ -5,7 +5,8 @@
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 5173
|
||||
"port": 5173,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -20,7 +20,8 @@ OUT.mkdir(parents=True, exist_ok=True)
|
||||
TABLE = {
|
||||
"floor": ("floor_parquet.png", 96, True),
|
||||
"floor_carpet": ("floor_carpet.png", 96, True),
|
||||
"floor_checker": ("floor_checker.png", 96, True),
|
||||
# coarse checker: the original was fine-grained and read as noise under sprites
|
||||
"floor_checker": ("floor_club_b.png", 96, True),
|
||||
"wall": ("wall_crate.png", 48, True),
|
||||
"wall_shelf": ("wall_shelf.png", 48, True),
|
||||
"wall_poster": ("wall_poster.png", 48, True),
|
||||
@ -56,6 +57,45 @@ TABLE = {
|
||||
"port_selector": ("portrait_selector.png", 112, False),
|
||||
"port_promoter": ("portrait_promoter.png", 112, False),
|
||||
"port_soundtech": ("portrait_soundtech.png", 112, False),
|
||||
# per-class player sprites (were generated but never wired — classes were tint-only)
|
||||
"player_selector": ("player_selector.png", 48, False),
|
||||
"player_promoter": ("player_promoter.png", 48, False),
|
||||
"player_soundtech": ("player_soundtech.png", 48, False),
|
||||
# idle villains
|
||||
"scalper": ("enemy_scalper.png", 48, False),
|
||||
"firemarshal": ("enemy_fire_marshal.png", 48, False),
|
||||
# props
|
||||
"speaker": ("prop_speaker.png", 44, False),
|
||||
"strobe": ("prop_strobe.png", 40, False),
|
||||
"turntable": ("prop_turntable.png", 44, False),
|
||||
# --- batch 4: wall variants (3 per theme) kill the one-texture repetition ---
|
||||
"wall_b": ("wall_crate_b.png", 48, True),
|
||||
"wall_c": ("wall_crate_c.png", 48, True),
|
||||
"wall_d": ("wall_crate_d.png", 48, True),
|
||||
"wall_shelf_b": ("wall_shelf_b.png", 48, True),
|
||||
"wall_shelf_c": ("wall_shelf_c.png", 48, True),
|
||||
"wall_shelf_d": ("wall_shelf_d.png", 48, True),
|
||||
"wall_poster_b": ("wall_poster_b.png", 48, True),
|
||||
"wall_poster_c": ("wall_poster_c.png", 48, True),
|
||||
"wall_poster_d": ("wall_poster_d.png", 48, True),
|
||||
"wall_warehouse_b": ("wall_warehouse_b.png", 48, True),
|
||||
"wall_warehouse_c": ("wall_warehouse_c.png", 48, True),
|
||||
"wall_warehouse_d": ("wall_warehouse_d.png", 48, True),
|
||||
"wall_festival_b": ("wall_festival_b.png", 48, True),
|
||||
"wall_festival_c": ("wall_festival_c.png", 48, True),
|
||||
"wall_festival_d": ("wall_festival_d.png", 48, True),
|
||||
"wall_archive_b": ("wall_archive_b.png", 48, True),
|
||||
"wall_archive_c": ("wall_archive_c.png", 48, True),
|
||||
"wall_archive_d": ("wall_archive_d.png", 48, True),
|
||||
# floor decals (scattered, non-colliding)
|
||||
"decal_cables": ("decal_cables.png", 40, False),
|
||||
"decal_cups": ("decal_cups.png", 36, False),
|
||||
"decal_flyers": ("decal_flyers.png", 40, False),
|
||||
"decal_confetti": ("decal_confetti.png", 44, False),
|
||||
"decal_tape": ("decal_tape.png", 36, False),
|
||||
"decal_sleeve": ("decal_sleeve.png", 40, False),
|
||||
"decal_dust": ("decal_dust.png", 36, False),
|
||||
"decal_puddle": ("decal_puddle.png", 40, False),
|
||||
}
|
||||
|
||||
# full-art images: downscale + copy, never chroma-keyed (they aren't sprites)
|
||||
@ -73,7 +113,11 @@ def chroma_key(im):
|
||||
return Image.fromarray(arr, "RGBA")
|
||||
|
||||
|
||||
missing = []
|
||||
for key, (fname, size, is_tile) in TABLE.items():
|
||||
if not (SRC / fname).exists():
|
||||
missing.append(fname) # not generated yet — skip, don't crash the batch
|
||||
continue
|
||||
im = Image.open(SRC / fname).convert("RGBA")
|
||||
if is_tile:
|
||||
im = im.resize((size, size), Image.LANCZOS)
|
||||
@ -92,9 +136,14 @@ for key, (fname, size, is_tile) in TABLE.items():
|
||||
print(f"{key:10s} <- {fname:24s} {size}px {'tile' if is_tile else 'keyed'}")
|
||||
|
||||
for key, (fname, width) in ART.items():
|
||||
if not (SRC / fname).exists():
|
||||
missing.append(fname)
|
||||
continue
|
||||
im = Image.open(SRC / fname).convert("RGB")
|
||||
im.thumbnail((width, width * 2), Image.LANCZOS)
|
||||
im.save(OUT / f"{key}.png")
|
||||
print(f"{key:10s} <- {fname:24s} art {im.width}x{im.height}")
|
||||
|
||||
print(f"\nwrote {len(TABLE) + len(ART)} textures to {OUT}")
|
||||
if missing:
|
||||
print(f"\nskipped {len(missing)} not-yet-generated: {', '.join(sorted(missing))}")
|
||||
print(f"\nwrote {len(TABLE) + len(ART) - len(missing)} textures to {OUT}")
|
||||
|
||||
42
assets/queue4.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"_comment": "Batch 4: wall VARIANTS (kills tile repetition — 3 per theme) + floor decals. Seeds 120+.",
|
||||
"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",
|
||||
"wall": "16-bit pixel art, top-down game wall tile, seamless, dark, no text"
|
||||
},
|
||||
"defaults": { "model": "flux2-klein-4b", "steps": 4 },
|
||||
"items": [
|
||||
{ "name": "wall_crate_b", "seed": 120, "style": "wall", "w": 512, "h": 512, "prompt": "stacked wooden vinyl record crates from above, one crate tipped and spilling records, brown" },
|
||||
{ "name": "wall_crate_c", "seed": 121, "style": "wall", "w": 512, "h": 512, "prompt": "wooden record crates stacked with a hand-written SALE sign taped on, brown" },
|
||||
{ "name": "wall_crate_d", "seed": 122, "style": "wall", "w": 512, "h": 512, "prompt": "battered old wooden record crates, splintered corners, faded paint, brown" },
|
||||
|
||||
{ "name": "wall_shelf_b", "seed": 123, "style": "wall", "w": 512, "h": 512, "prompt": "record shop shelving with a gap where records are missing, colourful spines" },
|
||||
{ "name": "wall_shelf_c", "seed": 124, "style": "wall", "w": 512, "h": 512, "prompt": "record shelving crammed with LPs and a few sleeves face-out showing cover art" },
|
||||
{ "name": "wall_shelf_d", "seed": 125, "style": "wall", "w": 512, "h": 512, "prompt": "tall record shelf with alphabet divider cards poking up between the records" },
|
||||
|
||||
{ "name": "wall_poster_b", "seed": 126, "style": "wall", "w": 512, "h": 512, "prompt": "brick wall covered in torn half-peeled rave flyers, layers of old paper" },
|
||||
{ "name": "wall_poster_c", "seed": 127, "style": "wall", "w": 512, "h": 512, "prompt": "club wall of neon spray-painted graffiti tags over concrete" },
|
||||
{ "name": "wall_poster_d", "seed": 128, "style": "wall", "w": 512, "h": 512, "prompt": "black club wall with acoustic foam panels and a single red exit light" },
|
||||
|
||||
{ "name": "wall_warehouse_b", "seed": 129, "style": "wall", "w": 512, "h": 512, "prompt": "wall of stacked bass bins and subwoofers, black cones, rave sound system" },
|
||||
{ "name": "wall_warehouse_c", "seed": 130, "style": "wall", "w": 512, "h": 512, "prompt": "warehouse wall of flight cases and road cases stacked with cables draped over" },
|
||||
{ "name": "wall_warehouse_d", "seed": 131, "style": "wall", "w": 512, "h": 512, "prompt": "corrugated metal warehouse wall with rust streaks and a padlocked roller door" },
|
||||
|
||||
{ "name": "wall_festival_b", "seed": 132, "style": "wall", "w": 512, "h": 512, "prompt": "festival barrier fence with a torn sponsor banner flapping on it" },
|
||||
{ "name": "wall_festival_c", "seed": 133, "style": "wall", "w": 512, "h": 512, "prompt": "stack of hay bales and wooden pallets forming a festival barricade" },
|
||||
{ "name": "wall_festival_d", "seed": 134, "style": "wall", "w": 512, "h": 512, "prompt": "row of portable toilets and generator units seen from above, festival infrastructure" },
|
||||
|
||||
{ "name": "wall_archive_b", "seed": 135, "style": "wall", "w": 512, "h": 512, "prompt": "dark archive shelving of collapsing water-damaged record boxes, mould stains" },
|
||||
{ "name": "wall_archive_c", "seed": 136, "style": "wall", "w": 512, "h": 512, "prompt": "dark basement wall of rusted filing cabinets stuffed with paper sleeves" },
|
||||
{ "name": "wall_archive_d", "seed": 137, "style": "wall", "w": 512, "h": 512, "prompt": "dark stone basement wall draped in thick cobwebs and dangling cables" },
|
||||
|
||||
{ "name": "decal_cables", "seed": 140, "style": "sprite", "w": 384, "h": 384, "prompt": "a coiled tangle of black audio cables lying on the floor, seen from above" },
|
||||
{ "name": "decal_cups", "seed": 141, "style": "sprite", "w": 384, "h": 384, "prompt": "crushed empty plastic pint cups lying on the floor, seen from above" },
|
||||
{ "name": "decal_flyers", "seed": 142, "style": "sprite", "w": 384, "h": 384, "prompt": "scattered rave flyers dropped on the floor, seen from above" },
|
||||
{ "name": "decal_confetti", "seed": 143, "style": "sprite", "w": 384, "h": 384, "prompt": "a scatter of colourful confetti and streamers on the ground, seen from above" },
|
||||
{ "name": "decal_tape", "seed": 144, "style": "sprite", "w": 384, "h": 384, "prompt": "strips of silver gaffer tape stuck on the floor in an X, seen from above" },
|
||||
{ "name": "decal_sleeve", "seed": 145, "style": "sprite", "w": 384, "h": 384, "prompt": "a discarded empty record sleeve lying face down on the floor, seen from above" },
|
||||
{ "name": "decal_dust", "seed": 146, "style": "sprite", "w": 384, "h": 384, "prompt": "a pile of grey dust and cobweb fluff on a dark floor, seen from above" },
|
||||
{ "name": "decal_puddle", "seed": 147, "style": "sprite", "w": 384, "h": 384, "prompt": "a dark spilled drink puddle on the floor, seen from above" }
|
||||
]
|
||||
}
|
||||
9
assets/queue5.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"_comment": "Coarser club floor — the fine checker read as noise at 96px.",
|
||||
"style": { "tile": "16-bit pixel art, seamless tileable top-down floor texture, LARGE bold simple shapes, flat even lighting, no objects, no perspective, no text" },
|
||||
"defaults": { "model": "flux2-klein-4b", "steps": 4 },
|
||||
"items": [
|
||||
{ "name": "floor_club_b", "seed": 160, "style": "tile", "w": 512, "h": 512, "prompt": "nightclub dancefloor with very large bold black and white checker squares, only 4 big squares total, worn and scuffed" },
|
||||
{ "name": "floor_club_c", "seed": 161, "style": "tile", "w": 512, "h": 512, "prompt": "dark scuffed nightclub dancefloor, plain dark tiles with subtle scratches and scuff marks, minimal pattern" }
|
||||
]
|
||||
}
|
||||
BIN
public/promo/banner.jpg
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
public/promo/cabinet.jpg
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
public/sprites/decal_cables.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/sprites/decal_confetti.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/sprites/decal_cups.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/sprites/decal_dust.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/sprites/decal_flyers.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/sprites/decal_puddle.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/sprites/decal_sleeve.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
public/sprites/decal_tape.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/sprites/firemarshal.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 11 KiB |
BIN
public/sprites/player_promoter.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/sprites/player_selector.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/sprites/player_soundtech.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/sprites/scalper.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
public/sprites/speaker.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
public/sprites/strobe.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/sprites/turntable.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
public/sprites/wall_archive_b.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/sprites/wall_archive_c.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/sprites/wall_archive_d.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/sprites/wall_b.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
public/sprites/wall_c.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/sprites/wall_d.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/sprites/wall_festival_b.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/sprites/wall_festival_c.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/sprites/wall_festival_d.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
public/sprites/wall_poster_b.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
public/sprites/wall_poster_c.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
public/sprites/wall_poster_d.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/sprites/wall_shelf_b.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
public/sprites/wall_shelf_c.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
public/sprites/wall_shelf_d.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
public/sprites/wall_warehouse_b.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
public/sprites/wall_warehouse_c.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/sprites/wall_warehouse_d.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
114
src/GameScene.js
@ -28,10 +28,19 @@ const QUEEN_BROOD_MS = 5000; // queen spits mites this often
|
||||
// texture keys loaded from public/sprites/<key>.png (see assets/process_sprites.py)
|
||||
const TEXTURE_KEYS = [
|
||||
'floor', 'floor_carpet', 'floor_checker', 'floor_archive', 'floor_warehouse', 'floor_festival',
|
||||
'wall', 'wall_shelf', 'wall_poster', 'wall_archive', 'wall_warehouse', 'wall_festival',
|
||||
'player', 'poser', 'nerd', 'pirate', 'ghoul', 'mite', 'queen', 'soundguy', 'generator',
|
||||
'gatekeeper', 'shazam', 'warped', 'conductor', 'rpm78',
|
||||
// 4 wall variants per theme — picked per-tile so mazes don't read as wallpaper
|
||||
'wall', 'wall_b', 'wall_c', 'wall_d',
|
||||
'wall_shelf', 'wall_shelf_b', 'wall_shelf_c', 'wall_shelf_d',
|
||||
'wall_poster', 'wall_poster_b', 'wall_poster_c', 'wall_poster_d',
|
||||
'wall_warehouse', 'wall_warehouse_b', 'wall_warehouse_c', 'wall_warehouse_d',
|
||||
'wall_festival', 'wall_festival_b', 'wall_festival_c', 'wall_festival_d',
|
||||
'wall_archive', 'wall_archive_b', 'wall_archive_c', 'wall_archive_d',
|
||||
'player', 'player_selector', 'player_promoter', 'player_soundtech',
|
||||
'poser', 'nerd', 'pirate', 'ghoul', 'mite', 'queen', 'soundguy', 'generator',
|
||||
'gatekeeper', 'shazam', 'warped', 'scalper', 'firemarshal', 'conductor', 'rpm78',
|
||||
'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl', 'gold', 'lamp',
|
||||
'decal_cables', 'decal_cups', 'decal_flyers', 'decal_confetti',
|
||||
'decal_tape', 'decal_sleeve', 'decal_dust', 'decal_puddle',
|
||||
'port_digger', 'port_selector', 'port_promoter', 'port_soundtech', 'logo',
|
||||
];
|
||||
const BOSS_SCORE = { conductor: 5000, rpm78: 10000 };
|
||||
@ -45,10 +54,10 @@ const RPM_SHARDS_MS = 6000;
|
||||
|
||||
// Gauntlet II classes: stat tweaks only, no new mechanics.
|
||||
const CLASSES = {
|
||||
digger: { name: 'THE DIGGER', desc: '+25% speed', speed: 1.25, dmg: 1, lanyards: 0, airhorns: 1, sgDrain: 1, tint: 0xffffff },
|
||||
selector: { name: 'THE SELECTOR', desc: 'double damage', speed: 1, dmg: 2, lanyards: 0, airhorns: 1, sgDrain: 1, tint: 0xffe080 },
|
||||
promoter: { name: 'THE PROMOTER', desc: 'starts loaded', speed: 1, dmg: 1, lanyards: 1, airhorns: 2, sgDrain: 1, tint: 0xffa0ff },
|
||||
soundtech: { name: 'THE SOUND TECH', desc: 'sound guy resistant', speed: 1, dmg: 1, lanyards: 0, airhorns: 1, sgDrain: 0.4, tint: 0xa0ffa0 },
|
||||
digger: { name: 'THE DIGGER', desc: '+25% speed', speed: 1.25, dmg: 1, lanyards: 0, airhorns: 1, sgDrain: 1, tex: 'player' },
|
||||
selector: { name: 'THE SELECTOR', desc: 'double damage', speed: 1, dmg: 2, lanyards: 0, airhorns: 1, sgDrain: 1, tex: 'player_selector' },
|
||||
promoter: { name: 'THE PROMOTER', desc: 'starts loaded', speed: 1, dmg: 1, lanyards: 1, airhorns: 2, sgDrain: 1, tex: 'player_promoter' },
|
||||
soundtech: { name: 'THE SOUND TECH', desc: 'sound guy resistant', speed: 1, dmg: 1, lanyards: 0, airhorns: 1, sgDrain: 0.4, tex: 'player_soundtech' },
|
||||
};
|
||||
|
||||
const ENEMY_TYPES = {
|
||||
@ -59,6 +68,7 @@ const ENEMY_TYPES = {
|
||||
mite: { tex: 'mite', speed: 240, touch: 15, hp: 1, size: 18 }, // tiny, fast, spawns in packs
|
||||
queen: { tex: 'queen', speed: 40, touch: 60, hp: 12, size: 70, boss: true }, // broods mites
|
||||
gatekeeper: { tex: 'gatekeeper', speed: 60, touch: 50, hp: 4, size: 40 }, // heavy blocker
|
||||
scalper: { tex: 'scalper', speed: 230, touch: 25, hp: 2, size: 34 }, // goes for the gold, not you
|
||||
shazam: { tex: 'shazam', speed: 0, touch: 20, hp: 1, size: 34, ranged: true }, // turret: "what song is this?"
|
||||
warped: { tex: 'warped', speed: 150, touch: 40, hp: 1, size: 30, wobble: true }, // wobbles like a bad press
|
||||
conductor: { tex: 'conductor', speed: 55, touch: 60, hp: 20, size: 60, boss: true }, // buffs the room
|
||||
@ -66,13 +76,28 @@ const ENEMY_TYPES = {
|
||||
};
|
||||
|
||||
// Level themes cycle with level index; mix = weighted generator spawn table.
|
||||
// walls: 4 variants each, picked per-tile so a 300-tile maze doesn't read as wallpaper.
|
||||
// decals: scattered floor litter, purely decorative.
|
||||
const THEMES = [
|
||||
{ name: 'RECORD SHOP', pool: 'shop', floor: 'floor', wall: 'wall', mix: [['poser', 0.7], ['nerd', 0.3]] },
|
||||
{ name: 'RECORD FAIR', pool: 'fair', floor: 'floor_carpet', wall: 'wall_shelf', mix: [['poser', 0.5], ['nerd', 0.25], ['pirate', 0.25]] },
|
||||
{ name: 'CLUB NIGHT', pool: 'club', floor: 'floor_checker', wall: 'wall_poster', mix: [['poser', 0.4], ['nerd', 0.2], ['pirate', 0.2], ['ghoul', 0.2]] },
|
||||
{ name: 'WAREHOUSE RAVE', pool: 'warehouse', floor: 'floor_warehouse', wall: 'wall_warehouse', mix: [['poser', 0.3], ['shazam', 0.25], ['gatekeeper', 0.25], ['nerd', 0.2]] },
|
||||
{ name: 'FESTIVAL GROUNDS', pool: 'festival', floor: 'floor_festival', wall: 'wall_festival', mix: [['poser', 0.3], ['warped', 0.3], ['pirate', 0.2], ['ghoul', 0.2]] },
|
||||
{ name: 'THE ARCHIVE', pool: 'archive', floor: 'floor_archive', wall: 'wall_archive', dark: true, mix: [['mite', 0.45], ['ghoul', 0.3], ['poser', 0.25]] },
|
||||
{ name: 'RECORD SHOP', pool: 'shop', floor: 'floor', mix: [['poser', 0.7], ['nerd', 0.3]],
|
||||
walls: ['wall', 'wall_b', 'wall_c', 'wall_d'],
|
||||
decals: ['decal_sleeve', 'decal_flyers', 'decal_dust'] },
|
||||
{ name: 'RECORD FAIR', pool: 'fair', floor: 'floor_carpet', mix: [['poser', 0.5], ['nerd', 0.25], ['pirate', 0.25]],
|
||||
walls: ['wall_shelf', 'wall_shelf_b', 'wall_shelf_c', 'wall_shelf_d'],
|
||||
decals: ['decal_flyers', 'decal_sleeve', 'decal_cups'] },
|
||||
// floorTint: busy floors get knocked back hard so sprites stay readable on top
|
||||
{ name: 'CLUB NIGHT', pool: 'club', floor: 'floor_checker', floorTint: 0x3a3a3a, mix: [['poser', 0.35], ['nerd', 0.2], ['pirate', 0.2], ['scalper', 0.1], ['ghoul', 0.15]],
|
||||
walls: ['wall_poster', 'wall_poster_b', 'wall_poster_c', 'wall_poster_d'],
|
||||
decals: ['decal_cups', 'decal_puddle', 'decal_flyers', 'decal_tape'] },
|
||||
{ name: 'WAREHOUSE RAVE', pool: 'warehouse', floor: 'floor_warehouse', mix: [['poser', 0.3], ['shazam', 0.25], ['gatekeeper', 0.25], ['nerd', 0.2]],
|
||||
walls: ['wall_warehouse', 'wall_warehouse_b', 'wall_warehouse_c', 'wall_warehouse_d'],
|
||||
decals: ['decal_cables', 'decal_tape', 'decal_cups', 'decal_puddle'] },
|
||||
{ name: 'FESTIVAL GROUNDS', pool: 'festival', floor: 'floor_festival', mix: [['poser', 0.25], ['warped', 0.25], ['scalper', 0.2], ['pirate', 0.15], ['ghoul', 0.15]],
|
||||
walls: ['wall_festival', 'wall_festival_b', 'wall_festival_c', 'wall_festival_d'],
|
||||
decals: ['decal_confetti', 'decal_cups', 'decal_flyers'] },
|
||||
{ name: 'THE ARCHIVE', pool: 'archive', floor: 'floor_archive', dark: true, mix: [['mite', 0.45], ['ghoul', 0.3], ['poser', 0.25]],
|
||||
walls: ['wall_archive', 'wall_archive_b', 'wall_archive_c', 'wall_archive_d'],
|
||||
decals: ['decal_dust', 'decal_sleeve', 'decal_cables'] },
|
||||
];
|
||||
// boss arenas drop into the cycle at fixed depths (each 12-level loop has both)
|
||||
const bossFor = (index) => (index % 12 === 7 ? 'conductor' : index % 12 === 11 ? 'rpm78' : null);
|
||||
@ -351,6 +376,8 @@ export default class GameScene extends Phaser.Scene {
|
||||
this.lamps = [];
|
||||
if (this.lampSprites) this.lampSprites.forEach((l) => l.destroy());
|
||||
this.lampSprites = [];
|
||||
if (this.decals) this.decals.forEach((d) => d.destroy());
|
||||
this.decals = [];
|
||||
this.walls.clear(true, true);
|
||||
this.doors.clear(true, true);
|
||||
this.pickups.clear(true, true);
|
||||
@ -375,7 +402,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
.tileSprite(0, 0, worldW, worldH, this.theme.floor)
|
||||
.setOrigin(0)
|
||||
.setDepth(-10)
|
||||
.setTint(0x555555); // darken so walls read as raised
|
||||
.setTint(this.theme.floorTint || 0x555555); // darken so walls read as raised
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
@ -384,7 +411,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
const y = r * TILE + TILE / 2;
|
||||
switch (ch) {
|
||||
case 'W':
|
||||
this.walls.create(x, y, this.theme.wall);
|
||||
this.walls.create(x, y, this.wallTexAt(c, r));
|
||||
break;
|
||||
case 'D':
|
||||
this.doors.create(x, y, 'door');
|
||||
@ -415,6 +442,12 @@ export default class GameScene extends Phaser.Scene {
|
||||
this.say(LINES.soundGuy);
|
||||
break;
|
||||
}
|
||||
case 'F': {
|
||||
// the Fire Marshal: same unkillable wall-phasing stalker, deeper levels
|
||||
this.soundGuys.create(x, y, this.textures.exists('firemarshal') ? 'firemarshal' : 'soundguy');
|
||||
this.say(LINES.fireMarshal);
|
||||
break;
|
||||
}
|
||||
case 'Q': {
|
||||
this.spawnEnemy({ x, y }, 'queen');
|
||||
this.say(LINES.queen);
|
||||
@ -451,6 +484,8 @@ export default class GameScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
this.scatterDecals(map, cols, rows);
|
||||
|
||||
this.density = this.densityBase + index * 0.08; // deeper = busier
|
||||
this.levelStartTime = this.time.now;
|
||||
this.trainspotProgress = 0;
|
||||
@ -462,6 +497,37 @@ export default class GameScene extends Phaser.Scene {
|
||||
}
|
||||
}
|
||||
|
||||
// Stable per-tile wall variant: same tile always gets the same texture, but
|
||||
// neighbours differ — so a 300-tile maze stops reading as wallpaper.
|
||||
wallTexAt(col, row) {
|
||||
const variants = this.theme.walls.filter((k) => this.textures.exists(k));
|
||||
if (!variants.length) return 'wall';
|
||||
const h = Math.abs(Math.imul(col * 73856093 ^ row * 19349663, 0x45d9f3b));
|
||||
return variants[h % variants.length];
|
||||
}
|
||||
|
||||
// Scatter floor litter on open tiles. Decoration only — no bodies, no gameplay.
|
||||
scatterDecals(map, cols, rows) {
|
||||
const keys = (this.theme.decals || []).filter((k) => this.textures.exists(k));
|
||||
if (!keys.length) return;
|
||||
const rnd = new Phaser.Math.RandomDataGenerator([`decal${this.levelIndex}`]);
|
||||
const budget = Math.floor(cols * rows * 0.05);
|
||||
for (let i = 0; i < budget; i++) {
|
||||
const r = rnd.between(1, rows - 2);
|
||||
const c = rnd.between(1, cols - 2);
|
||||
if (map[r][c] !== '.') continue;
|
||||
this.decals.push(
|
||||
this.add
|
||||
.image(c * TILE + rnd.between(-10, 10) + TILE / 2, r * TILE + rnd.between(-10, 10) + TILE / 2,
|
||||
rnd.pick(keys))
|
||||
.setDepth(-8)
|
||||
.setAlpha(0.45)
|
||||
.setTint(0x888888) // litter sits into the floor, never competes with sprites
|
||||
.setAngle(rnd.between(0, 359)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic maze for levels past the hand-authored ones. Same level = same maze.
|
||||
genLevel(index) {
|
||||
const rnd = new Phaser.Math.RandomDataGenerator([String(index * 7919)]);
|
||||
@ -514,6 +580,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
for (let i = 0; i < 3; i++) drop('R');
|
||||
drop('A');
|
||||
drop('S');
|
||||
if (index >= 6) drop('F'); // the fire marshal starts showing up once it's late
|
||||
const theme = THEMES[index % THEMES.length];
|
||||
if (theme.dark) {
|
||||
drop('Q');
|
||||
@ -698,10 +765,11 @@ export default class GameScene extends Phaser.Scene {
|
||||
});
|
||||
}
|
||||
|
||||
nearestPickup(from) {
|
||||
nearestPickup(from, type) {
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
this.pickups.getChildren().forEach((p) => {
|
||||
if (type && p.getData('type') !== type) return;
|
||||
const d = Phaser.Math.Distance.Between(from.x, from.y, p.x, p.y);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
@ -766,7 +834,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
if (type === 'conductor') {
|
||||
// teleports away every 5 hits — handled in hitEnemy; drifts slowly here
|
||||
}
|
||||
if (type === 'pirate') {
|
||||
if (type === 'pirate' || type === 'scalper') {
|
||||
if (e.getData('carry')) {
|
||||
const home = e.getData('home');
|
||||
if (Phaser.Math.Distance.Between(e.x, e.y, home.x, home.y) < 24) {
|
||||
@ -775,7 +843,8 @@ export default class GameScene extends Phaser.Scene {
|
||||
}
|
||||
target = home;
|
||||
} else {
|
||||
target = this.nearestPickup(e) || this.player;
|
||||
// the scalper only wants the gold; the pirate grabs anything
|
||||
target = this.nearestPickup(e, type === 'scalper' ? 'gold' : null) || this.player;
|
||||
}
|
||||
}
|
||||
this.physics.moveToObject(e, target, e.getData('speed') * speedBuff);
|
||||
@ -992,7 +1061,9 @@ export default class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
pirateGrab(e, item) {
|
||||
if (!e.active || e.getData('type') !== 'pirate' || e.getData('carry')) return;
|
||||
const t = e.getData('type');
|
||||
if (!e.active || (t !== 'pirate' && t !== 'scalper') || e.getData('carry')) return;
|
||||
if (t === 'scalper' && item.getData('type') !== 'gold') return;
|
||||
e.setData('carry', { type: item.getData('type'), tex: item.texture.key });
|
||||
item.destroy();
|
||||
}
|
||||
@ -1045,7 +1116,7 @@ export default class GameScene extends Phaser.Scene {
|
||||
if (this.state !== 'PLAYING') return;
|
||||
this.vibe -= amount;
|
||||
this.player.setTintFill(0xff0000);
|
||||
this.time.delayedCall(100, () => this.player.setTint(this.cls.tint));
|
||||
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();
|
||||
@ -1202,7 +1273,8 @@ export default class GameScene extends Phaser.Scene {
|
||||
|
||||
startGame(clsKey) {
|
||||
this.cls = CLASSES[clsKey] || CLASSES.digger;
|
||||
this.player.setTint(this.cls.tint);
|
||||
this.player.clearTint();
|
||||
if (this.textures.exists(this.cls.tex)) this.player.setTexture(this.cls.tex);
|
||||
this.physics.resume();
|
||||
this.resetStats();
|
||||
this.hideOverlay();
|
||||
|
||||
@ -56,4 +56,6 @@ export const LINES = {
|
||||
conductorDead: ['conductorDead', 'The baton has fallen. The floor is yours.'],
|
||||
rpm78: ['rpm78', 'Seventy eight revolutions. No name. No mercy.'],
|
||||
rpm78Dead: ['rpm78Dead', 'It has a name now. Yours.'],
|
||||
fireMarshal: ['fireMarshal', 'The fire marshal is checking capacity.'],
|
||||
scalper: ['scalper', 'Scalper on the floor. Mind your grails.'],
|
||||
};
|
||||
|
||||