A real-time consumer of combat-core (the front-end the spec designs for, §14): pick a foe, manage your single Vigor pool, try not to spend your survival. - combat-core: a HumanController (input-driven, spec §5) behind a shared intent cell, plus Encounter::tick_once / drain_events so a wall-clock front-end can drive the engine a tick at a time (§10). Trivial abilities (difficulty 0, e.g. auto-attack) no longer fizzle. Engine unchanged otherwise; 48 tests still green. - combat-wasm: a wasm-bindgen Game bridge — tick-stepping, JSON world state, the player's ability list, and a flavorful MonsterAI that uses each monster's signature kit (the wraith curses your ceiling) on a coin-flip so big hits space into a readable duel. Own workspace member, kept out of the default host build. - combat-web: an atmospheric battler. The signature dual Vigor bar shows fill, the regen-headroom ceiling, the ceiling marker, and the dark cracked fatigue zone in one bar — the squeeze made legible. Floating damage, cast telegraphs, combat log, result overlay. Verified end-to-end in a real browser. - tools/portraits.py: stdlib text-to-image client reusing the proven LAN Z-Image stack (minus the depth ControlNet) to render the bestiary. 7 portraits committed. - Added skeleton / troll / wraith monster stat blocks; bumped the duelist's durability so fights are readable (even-con ~30s) and active play beats every monster while passive auto-attacking loses the hard ones. Re-validated the sim: the skill gradient and anti-turtle guardrail still hold (auto ~1% → skill100 34% at even con). README findings updated to match. Verified live: roster screen, win/lose, the dual bars, telegraphed casts, and a 3s active-play victory over the Skeleton Knight. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
151 lines
7.4 KiB
Python
151 lines
7.4 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",
|
|
}
|
|
|
|
|
|
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()
|