reikhelm/combat/tools/portraits.py
Parley Hatch 5813ee3b5a feat(game): REIKHELM: DESCENT — playable first-person roguelike
The pieces finally meet: procgen dungeons + the baked EoB atlas pipeline +
the Vigor combat engine become one game. Three fixed pre-baked floors
(seeds 7 → 69 → 59, 208 frames), monsters/loot/encounters rerolled per run
and composited over the empty scenes; the Ember Sovereign waits at the
bottom.

- game-wasm: run state machine in Rust (manifest nav graph as dungeon
  truth, seeded spawning by theme/depth, embedded combat-core Encounter,
  vigor/fatigue persistence between fights, rest/potions/relics/flee,
  permadeath + victory). 8 native tests incl. a whole-run bot and a
  ScriptedPlayer boss-winnability probe used for tuning.
- game-web: no-framework front-end — title/death/victory screens, baked
  frames with screen-blend monster portraits, combat overlay (dual vigor
  bars, cast telegraphs, cooldown sweeps, floaters), minimap with fog,
  all SFX synthesized in WebAudio.
- balance: floor-scaled levels with a hero's edge over trash, no packs on
  floor 1, boss tuned by sim sweep (~35%/55% win at skill 50/80).
- campaign atlases now versioned (tools/.gitignore exception); boss
  portrait added to the Z-Image roster.

Verified end-to-end in-browser: an autopilot beat the full game through
the real UI (10 fights, 2 flees, Sovereign down), and died plenty before
that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:30:50 -06:00

154 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Generate monster portraits for the combat front-end via the LAN Z-Image stack.
Reuses the proven Z-Image-Turbo text-to-image path from
``../../tools/comfy-spike/comfy.py`` (same unet/clip/vae + ModelSamplingAuraFlow
shift + KSampler), minus the depth-ControlNet nodes — these are free text→image
portraits, not depth-conditioned renders.
Pure stdlib (no pip). Writes PNGs to ``combat-web/portraits/<id>.png``.
python3 tools/portraits.py [--url http://192.168.1.26:8188] [--size 768]
[--only wraith,golem] [--seed 7]
"""
import argparse
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
REPO_COMBAT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT_DIR = os.path.join(REPO_COMBAT, "combat-web", "portraits")
# Z-Image-Turbo model names (from the proven comfy.py zrender defaults).
UNET = "z_image_turbo_bf16.safetensors"
CLIP = "qwen_3_4b.safetensors"
CLIP_TYPE = "qwen_image"
VAE = "ae.safetensors"
# A byte-identical style anchor across every portrait keeps lighting + palette
# coherent so the roster reads as one bestiary, not seven unrelated images.
STYLE = (
"dark fantasy character portrait, head and shoulders bust, centered, facing the viewer, "
"dramatic warm torchlight, deep black background, volumetric haze, painterly digital art, "
"ominous, highly detailed, sharp focus, "
)
NEGATIVE = (
"text, watermark, signature, letters, words, ui, hud, frame, border, blurry, "
"deformed, extra limbs, duplicate, low quality, modern, cartoon, cute, bright, washed out"
)
# id → the creature description. ids match the config stat_blocks so the front-end
# can map a chosen monster to its portrait.
ROSTER = {
"duelist": "a grizzled human duelist, worn leather armor and a steel pauldron, scarred jaw, "
"short beard, steady determined eyes, a sheathed blade, a hardened adventurer",
"rat": "an enormous dire rat, matted greasy brown fur, gleaming red eyes, bared yellow fangs, "
"twitching whiskers, ragged ears, a diseased sewer beast",
"skeleton": "an undead skeleton knight, pitted rusted plate armor, cold blue flames in hollow "
"eye sockets, cracked yellowed bone, a notched broadsword, grave-rot and cobwebs",
"golem": "a massive ancient stone golem, cracked granite and basalt body, molten orange runes "
"glowing deep in the fissures, moss and lichen, blunt boulder fists, a slow construct",
"troll": "a hulking cave troll, warty grey-green hide, hunched massive shoulders, a tusked "
"underbite, small cruel eyes, knotted muscle, club-like fists, brutish and dripping",
"wraith": "a spectral wraith, tattered black hooded burial shroud billowing, a hollow dark void "
"where a face should be, two faint points of cold blue light, wisps of freezing mist, "
"skeletal grasping hands reaching forward, incorporeal and dreadful",
"battlemage": "a gaunt dark sorcerer, deep hooded robe, sunken shadowed eyes, crackling violet "
"arcane lightning coiling around raised bony hands, glowing runic sigils, malice",
"boss": "an ancient undying sorcerer-king, a cracked obsidian crown fused to his brow, "
"ember-orange fire glowing through fissures in his grey withered skin, scorched regal "
"robes with molten gold trim, eyes of living flame, terrible majesty, the Ember Sovereign",
}
def post(base, path, payload):
req = urllib.request.Request(
base + path, data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"}, method="POST")
return json.loads(urllib.request.urlopen(req, timeout=30).read())
def get(base, path, timeout=120):
with urllib.request.urlopen(base + path, timeout=timeout) as r:
return r.read()
def t2i_graph(prompt, negative, size, seed, prefix):
"""Z-Image-Turbo text-to-image graph (no ControlNet)."""
return {
"unet": {"class_type": "UNETLoader", "inputs": {"unet_name": UNET, "weight_dtype": "default"}},
"clip": {"class_type": "CLIPLoader", "inputs": {"clip_name": CLIP, "type": CLIP_TYPE}},
"vae": {"class_type": "VAELoader", "inputs": {"vae_name": VAE}},
"shift": {"class_type": "ModelSamplingAuraFlow", "inputs": {"model": ["unet", 0], "shift": 3.0}},
"pos": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["clip", 0]}},
"neg": {"class_type": "CLIPTextEncode", "inputs": {"text": negative, "clip": ["clip", 0]}},
"latent": {"class_type": "EmptySD3LatentImage", "inputs": {"width": size, "height": size, "batch_size": 1}},
"ks": {"class_type": "KSampler", "inputs": {
"seed": seed, "steps": 8, "cfg": 1.0, "sampler_name": "res_multistep",
"scheduler": "simple", "denoise": 1.0, "model": ["shift", 0],
"positive": ["pos", 0], "negative": ["neg", 0], "latent_image": ["latent", 0]}},
"dec": {"class_type": "VAEDecode", "inputs": {"samples": ["ks", 0], "vae": ["vae", 0]}},
"save": {"class_type": "SaveImage", "inputs": {"images": ["dec", 0], "filename_prefix": prefix}},
}
def render_one(base, mid, prompt, size, seed, wait):
graph = t2i_graph(STYLE + prompt, NEGATIVE, size, seed, f"portrait_{mid}")
try:
pid = post(base, "/prompt", {"prompt": graph})["prompt_id"]
except urllib.error.HTTPError as e:
print(f" {mid}: /prompt rejected:\n{e.read().decode()[:1500]}")
return False
deadline = time.time() + wait
while time.time() < deadline:
hist = json.loads(get(base, "/history/" + pid))
if pid in hist:
entry = hist[pid]
if entry.get("status", {}).get("status_str") == "error":
print(f" {mid}: execution error:\n{json.dumps(entry['status'].get('messages'), indent=1)[:1500]}")
return False
for node_out in entry.get("outputs", {}).values():
for im in node_out.get("images", []):
q = urllib.parse.urlencode({"filename": im["filename"],
"subfolder": im.get("subfolder", ""),
"type": im.get("type", "output")})
blob = get(base, "/view?" + q)
out = os.path.join(OUT_DIR, f"{mid}.png")
with open(out, "wb") as f:
f.write(blob)
print(f" {mid}: -> {os.path.relpath(out, REPO_COMBAT)} ({len(blob)//1024} KB)")
return True
print(f" {mid}: finished but no image")
return False
time.sleep(1.5)
print(f" {mid}: timed out after {wait}s")
return False
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--url", default="http://192.168.1.26:8188")
ap.add_argument("--size", type=int, default=768)
ap.add_argument("--seed", type=int, default=7, help="base seed; each monster offsets from it")
ap.add_argument("--only", default="", help="comma-separated ids to (re)render")
ap.add_argument("--wait", type=int, default=180)
a = ap.parse_args()
os.makedirs(OUT_DIR, exist_ok=True)
base = a.url.rstrip("/")
only = {x.strip() for x in a.only.split(",") if x.strip()} or None
roster = {k: v for k, v in ROSTER.items() if only is None or k in only}
print(f"rendering {len(roster)} portraits at {a.size}px -> {os.path.relpath(OUT_DIR, REPO_COMBAT)}")
ok = 0
for i, (mid, prompt) in enumerate(roster.items()):
if render_one(base, mid, prompt, a.size, a.seed + i * 1000, a.wait):
ok += 1
print(f"done: {ok}/{len(roster)} portraits")
if __name__ == "__main__":
main()