Step 7: wire FLUX sprites into the game

Chroma-key magenta -> alpha, autocrop, downscale (assets/process_sprites.py ->
public/sprites/). GameScene loads image textures instead of drawing primitives:
tiled darkened floor, crate walls, digger/poser/nerd/sound-guy/booth/pickup
sprites. Bodies sized under the 32px sprites so corridors still fit. Mechanics
verified unchanged (combat, trainspot, level flow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-14 23:39:40 +10:00
parent 0a71e07078
commit 9a42e0eaf8
16 changed files with 115 additions and 43 deletions

33
assets/collect.py Normal file
View File

@ -0,0 +1,33 @@
#!/usr/bin/env python3
"""Copy finished FLUX assets out of MODELBEAST into assets/generated/<name>.png.
Reads queue.lock.json (job_id/name/seed written by enqueue.py), finds each
generated image in the MB asset store by its seed-based filename, and copies it
in under the friendly asset name. Run after the MB jobs finish.
/opt/homebrew/bin/uv run python assets/collect.py
"""
import json, sys, shutil
from pathlib import Path
MB = Path.home() / "MODELBEAST"
sys.path.insert(0, str(MB))
import server.db as db # noqa: E402
HERE = Path(__file__).resolve().parent
lock = json.loads((HERE / "queue.lock.json").read_text())
outdir = HERE / "generated"
outdir.mkdir(exist_ok=True)
con = db.connect()
done = missing = 0
for it in lock:
fname = f"flux_flux2-klein-4b_s{it['seed']}.png"
row = con.execute("SELECT path FROM assets WHERE name=? ORDER BY created_at DESC LIMIT 1", (fname,)).fetchone()
if row and Path(row["path"]).exists():
shutil.copy(row["path"], outdir / f"{it['name']}.png")
done += 1
else:
print(f" not ready: {it['name']}")
missing += 1
print(f"collected {done}/{len(lock)} into {outdir}" + (f" ({missing} not ready)" if missing else ""))

62
assets/process_sprites.py Normal file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Turn the raw FLUX assets into game-ready textures in public/sprites/.
Sprites (magenta #ff00ff background) get chroma-keyed to transparency, autocropped,
and centered on a square canvas. Tiles (full-bleed textures) are just resized square.
Output filename = the texture key GameScene.js loads.
/opt/homebrew/bin/uv run --with pillow --with numpy python assets/process_sprites.py
"""
from pathlib import Path
from PIL import Image
import numpy as np
HERE = Path(__file__).resolve().parent
SRC = HERE / "generated"
OUT = HERE.parent / "public" / "sprites"
OUT.mkdir(parents=True, exist_ok=True)
# game texture key -> (source file, size px, is_tile)
TABLE = {
"floor": ("floor_parquet.png", 64, True),
"wall": ("wall_crate.png", 32, True),
"player": ("player_digger.png", 32, False),
"poser": ("enemy_poser.png", 32, False),
"nerd": ("enemy_gearnerd.png", 32, False),
"soundguy": ("enemy_soundguy.png", 32, False),
"generator": ("gen_discount_bin.png", 32, False),
"exit": ("prop_djbooth.png", 32, False),
"door": ("prop_velvet_rope.png", 32, False),
"espresso": ("pickup_espresso.png", 26, False),
"lanyard": ("pickup_lanyard.png", 26, False),
"airhorn": ("pickup_airhorn.png", 26, False),
"vinyl": ("pickup_vinyl.png", 18, False),
}
def chroma_key(im):
arr = np.array(im.convert("RGBA"))
r, g, b = arr[..., 0].astype(int), arr[..., 1].astype(int), arr[..., 2].astype(int)
# magenta: high red+blue, low green, red~=blue (catches the darker shadow halo too)
mask = (r > 135) & (b > 135) & (g < 100) & (np.abs(r - b) < 70)
arr[..., 3][mask] = 0
return Image.fromarray(arr, "RGBA")
for key, (fname, size, is_tile) in TABLE.items():
im = Image.open(SRC / fname).convert("RGBA")
if is_tile:
im = im.resize((size, size), Image.LANCZOS)
else:
im = chroma_key(im)
bbox = im.getbbox()
if bbox:
im = im.crop(bbox)
im.thumbnail((size, size), Image.LANCZOS)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
canvas.paste(im, ((size - im.width) // 2, (size - im.height) // 2), im)
im = canvas
im.save(OUT / f"{key}.png")
print(f"{key:10s} <- {fname:24s} {size}px {'tile' if is_tile else 'keyed'}")
print(f"\nwrote {len(TABLE)} textures to {OUT}")

BIN
public/sprites/airhorn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

BIN
public/sprites/door.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 B

BIN
public/sprites/espresso.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
public/sprites/exit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/sprites/floor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
public/sprites/lanyard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

BIN
public/sprites/nerd.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
public/sprites/player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/sprites/poser.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/sprites/soundguy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
public/sprites/vinyl.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 B

BIN
public/sprites/wall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -21,14 +21,23 @@ const VINYL_LIFE_MS = 1200;
const FIRE_COOLDOWN_MS = 180;
const TRAINSPOT_MS = 3000;
// texture keys loaded from public/sprites/<key>.png (see assets/process_sprites.py)
const TEXTURE_KEYS = [
'floor', 'wall', 'player', 'poser', 'nerd', 'soundguy', 'generator',
'exit', 'door', 'espresso', 'lanyard', 'airhorn', 'vinyl',
];
export default class GameScene extends Phaser.Scene {
constructor() {
super('game');
}
preload() {
for (const key of TEXTURE_KEYS) this.load.image(key, `sprites/${key}.png`);
}
create() {
this.cameras.main.setBackgroundColor(0x1a1a1a);
this.makeTextures();
// Persistent groups — created once, refilled each level so colliders survive.
this.walls = this.physics.add.staticGroup();
@ -42,6 +51,7 @@ export default class GameScene extends Phaser.Scene {
this.player = this.physics.add.sprite(100, 100, 'player');
this.player.setCollideWorldBounds(true);
this.player.body.setSize(22, 22, true); // body smaller than 32px sprite -> fits corridors
// Colliders / overlaps (persistent group refs).
this.physics.add.collider(this.player, this.walls);
@ -109,48 +119,6 @@ export default class GameScene extends Phaser.Scene {
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;
@ -185,6 +153,14 @@ export default class GameScene extends Phaser.Scene {
this.cameras.main.setBounds(0, 0, worldW, worldH);
this.cameras.main.startFollow(this.player, true, 0.15, 0.15);
// Tiled floor behind everything.
if (this.floor) this.floor.destroy();
this.floor = this.add
.tileSprite(0, 0, worldW, worldH, 'floor')
.setOrigin(0)
.setDepth(-10)
.setTint(0x555555); // darken so the brown crate walls read as raised
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const ch = map[r][c];
@ -325,6 +301,7 @@ export default class GameScene extends Phaser.Scene {
e.setTexture(poser ? 'poser' : 'nerd');
e.setActive(true).setVisible(true);
e.body.enable = true;
e.body.setSize(22, 22, true); // body smaller than 32px sprite
e.setPosition(g.x, g.y);
e.setData('touch', poser ? POSER_TOUCH_DAMAGE : NERD_TOUCH_DAMAGE);
e.setData('speed', poser ? POSER_SPEED : NERD_SPEED);