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>
33
assets/collect.py
Normal 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
@ -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
|
After Width: | Height: | Size: 823 B |
BIN
public/sprites/door.png
Normal file
|
After Width: | Height: | Size: 969 B |
BIN
public/sprites/espresso.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
public/sprites/exit.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/sprites/floor.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
public/sprites/generator.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/sprites/lanyard.png
Normal file
|
After Width: | Height: | Size: 912 B |
BIN
public/sprites/nerd.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
public/sprites/player.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/sprites/poser.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/sprites/soundguy.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/sprites/vinyl.png
Normal file
|
After Width: | Height: | Size: 632 B |
BIN
public/sprites/wall.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
@ -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);
|
||||
|
||||