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>
This commit is contained in:
type-two 2026-07-25 22:56:22 +10:00
parent ea2bfd0eaa
commit df25531893
43 changed files with 199 additions and 24 deletions

View File

@ -5,7 +5,8 @@
"name": "dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
"port": 5173,
"autoPort": true
}
]
}

View File

@ -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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
public/promo/cabinet.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
public/sprites/scalper.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/sprites/speaker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
public/sprites/strobe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
public/sprites/wall_b.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

BIN
public/sprites/wall_c.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

BIN
public/sprites/wall_d.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -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();

View File

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