feat: graphical inventory with generated pixel art icons

- 11 artifact icons generated via Z-Image Turbo (128x128)
- Inventory displays as icon grid with hover label strip
- Artifacts in rooms show icon thumbnails alongside name/type
- Icon generation script with skip-existing logic
- Prompt reference JSON for all artifact icons
- Fix inventory not resetting on Play Again
- Add user-select: none to presentational elements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Brown
2026-03-24 12:31:57 -07:00
committed by DrJKL
parent 06739fc4b0
commit 629513579e
14 changed files with 380 additions and 29 deletions

View File

@@ -0,0 +1,65 @@
{
"meta": {
"style": "Pixel art icon on transparent black background, 128x128, clean edges, glowing accent color, game inventory item style",
"usage": "Each key is an artifact ID used in adventure.html. Generate one icon per artifact.",
"model": "Z-Image Turbo (no LoRA)",
"resolution": "128x128"
},
"artifacts": {
"graphview": {
"name": "GraphView.vue",
"type": "Component",
"prompt": "Pixel art icon of a glowing canvas frame with connected nodes and wires inside, blue accent glow, dark background, game inventory item"
},
"widgetvaluestore": {
"name": "widgetValueStore.ts",
"type": "Proto-ECS Store",
"prompt": "Pixel art icon of a vault door with a glowing slider widget embossed on it, purple and gold accents, dark background, game inventory item"
},
"layoutstore": {
"name": "layoutStore.ts",
"type": "Proto-ECS Store",
"prompt": "Pixel art icon of a grid blueprint with glowing position markers, purple accent lines, dark background, game inventory item"
},
"litegraphservice": {
"name": "litegraphService.ts",
"type": "Service",
"prompt": "Pixel art icon of a gear with a graph node symbol in the center, copper and blue metallic glow, dark background, game inventory item"
},
"lgraphcanvas": {
"name": "LGraphCanvas.ts",
"type": "God Object",
"prompt": "Pixel art icon of a massive cracked monolith radiating red warning light, labeled 9100, ominous dark background, game inventory item"
},
"lgraphnode": {
"name": "LGraphNode.ts",
"type": "God Object",
"prompt": "Pixel art icon of an oversized cube with tangled wires bursting from every face, red and amber glow, dark background, game inventory item"
},
"world-registry": {
"name": "World Registry",
"type": "ECS Core",
"prompt": "Pixel art icon of a glowing crystalline orb containing tiny entity symbols, bright blue and white aura, dark background, game inventory item"
},
"branded-ids": {
"name": "Branded Entity IDs",
"type": "Type Safety",
"prompt": "Pixel art icon of a set of ID cards with distinct colored borders and brand stamps, green checkmark glow, dark background, game inventory item"
},
"quadtree": {
"name": "QuadTree Spatial Index",
"type": "Data Structure",
"prompt": "Pixel art icon of a square recursively divided into four quadrants with glowing dots at intersections, teal accent, dark background, game inventory item"
},
"yjs-crdt": {
"name": "Y.js CRDT Layout",
"type": "Collaboration",
"prompt": "Pixel art icon of two overlapping document layers merging with sync arrows, purple and green glow, dark background, game inventory item"
},
"usecorecommands": {
"name": "useCoreCommands.ts",
"type": "Composable",
"prompt": "Pixel art icon of a hook tool with keyboard key symbols orbiting it, yellow and blue glow, dark background, game inventory item"
}
}
}

View File

@@ -21,6 +21,15 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
#hud, .stat-bar-group, .choice-key, .challenge-badge, .artifact-type,
.sidebar-header, #room-layer, .ending-label, .scorecard-header,
#challenge-header, .map-room .room-layer, #challenge-progress,
.stat-delta, .ending-stat .label, #play-again-btn, #toggle-map,
.choice-btn .choice-hint, .challenge-choice-btn .choice-hint,
.inv-slot, .artifact-icon {
user-select: none;
}
body {
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
background: var(--bg);
@@ -389,14 +398,36 @@
padding: 10px 12px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
font-size: 13px;
}
.artifact:last-child { border-bottom: none; }
.artifact-name { color: var(--yellow); }
.artifact-type { font-size: 10px; color: var(--muted); text-transform: uppercase; }
.artifact-icon {
width: 36px;
height: 36px;
border-radius: 6px;
background: var(--bg);
border: 1px solid var(--border);
overflow: hidden;
flex-shrink: 0;
}
.artifact-icon img { width: 100%; height: 100%; object-fit: cover; display: block; }
.artifact-icon.placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--yellow);
}
.artifact-info { flex: 1; }
.artifact-name { color: var(--yellow); display: block; }
.artifact-type { font-size: 10px; color: var(--muted); text-transform: uppercase; display: block; }
/* --- Navigation Choices --- */
#choices {
@@ -490,15 +521,53 @@
.log-entry.error { color: var(--red); }
.log-entry.ending { color: var(--purple); }
.inv-item {
padding: 6px 8px;
display: flex;
justify-content: space-between;
align-items: center;
#inv-label {
padding: 4px 12px;
font-size: 11px;
color: var(--yellow);
min-height: 22px;
border-bottom: 1px solid var(--border);
user-select: none;
transition: color 0.15s;
}
#inv-label .inv-label-type {
color: var(--muted);
font-size: 9px;
text-transform: uppercase;
margin-left: 6px;
}
.inv-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 6px;
padding: 8px;
}
.inv-slot {
aspect-ratio: 1;
border-radius: 6px;
background: var(--bg);
border: 1px solid var(--border);
overflow: hidden;
cursor: default;
position: relative;
transition: border-color 0.15s;
}
.inv-slot:hover { border-color: var(--yellow); }
.inv-slot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.inv-slot.placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: var(--yellow);
}
.inv-item-name { color: var(--yellow); font-size: 12px; }
.inv-item-desc { color: var(--muted); font-size: 10px; }
/* --- Ending Screen --- */
#ending-overlay {
@@ -765,6 +834,7 @@
<span>Inventory</span>
<span id="inv-count">0</span>
</div>
<div id="inv-label">&nbsp;</div>
<div class="sidebar-body" id="inventory">
<div class="log-entry" style="color:var(--muted)">Empty &mdash; explore to find artifacts.</div>
</div>
@@ -862,7 +932,7 @@
the bottom panel shows queues and console output.
`,
artifacts: [
{ name: 'GraphView.vue', type: 'Component' },
{ name: 'GraphView.vue', type: 'Component', icon: 'graphview' },
],
challenge: {
title: 'The Circular Dependency',
@@ -917,8 +987,8 @@
<a href="${GH}/src/stores/nodeDefStore.ts" target="_blank">nodeDefStore</a> catalogs every node type known to the system.
`,
artifacts: [
{ name: 'widgetValueStore.ts', type: 'Proto-ECS Store' },
{ name: 'layoutStore.ts', type: 'Proto-ECS Store' },
{ name: 'widgetValueStore.ts', type: 'Proto-ECS Store', icon: 'widgetvaluestore' },
{ name: 'layoutStore.ts', type: 'Proto-ECS Store', icon: 'layoutstore' },
],
challenge: {
title: 'The Scattered Mutations',
@@ -974,7 +1044,7 @@
here bridges the presentation layer above with the stores below.
`,
artifacts: [
{ name: 'litegraphService.ts', type: 'Service' },
{ name: 'litegraphService.ts', type: 'Service', icon: 'litegraphservice' },
],
challenge: {
title: 'The Migration Question',
@@ -1028,8 +1098,8 @@
and scattered mutation sites. The ECS migration aims to tame this complexity.
`,
artifacts: [
{ name: 'LGraphCanvas.ts', type: 'God Object' },
{ name: 'LGraphNode.ts', type: 'God Object' },
{ name: 'LGraphCanvas.ts', type: 'God Object', icon: 'lgraphcanvas' },
{ name: 'LGraphNode.ts', type: 'God Object', icon: 'lgraphnode' },
],
challenge: {
title: 'The God Object Dilemma',
@@ -1085,8 +1155,8 @@
to operate on these components in clean, decoupled passes.
`,
artifacts: [
{ name: 'World Registry', type: 'ECS Core' },
{ name: 'Branded Entity IDs', type: 'Type Safety' },
{ name: 'World Registry', type: 'ECS Core', icon: 'world-registry' },
{ name: 'Branded Entity IDs', type: 'Type Safety', icon: 'branded-ids' },
],
challenge: {
title: 'The ID Crossroads',
@@ -1143,8 +1213,8 @@
paints nodes, links, and reroutes onto the HTML canvas.
`,
artifacts: [
{ name: 'QuadTree Spatial Index', type: 'Data Structure' },
{ name: 'Y.js CRDT Layout', type: 'Collaboration' },
{ name: 'QuadTree Spatial Index', type: 'Data Structure', icon: 'quadtree' },
{ name: 'Y.js CRDT Layout', type: 'Collaboration', icon: 'yjs-crdt' },
],
challenge: {
title: 'The Render-Time Mutation',
@@ -1192,7 +1262,7 @@
encapsulates logic that would otherwise clutter components.
`,
artifacts: [
{ name: 'useCoreCommands.ts', type: 'Composable' },
{ name: 'useCoreCommands.ts', type: 'Composable', icon: 'usecorecommands' },
],
challenge: {
title: 'The Collaboration Protocol',
@@ -1321,6 +1391,7 @@
artifactsList: $('#artifacts-list'),
inventory: $('#inventory'),
invCount: $('#inv-count'),
invLabel: $('#inv-label'),
log: $('#log'),
logCount: $('#log-count'),
barDebt: $('#bar-debt'),
@@ -1385,6 +1456,24 @@
.join(' &nbsp; ')
}
// --- Icon helper ---
const iconFallbacks = {
'Component': '&#9645;', 'Proto-ECS Store': '&#9671;', 'Service': '&#9881;',
'God Object': '&#9760;', 'ECS Core': '&#9672;', 'Type Safety': '&#9745;',
'Data Structure': '&#9638;', 'Collaboration': '&#8644;', 'Composable': '&#10038;',
}
function artifactIconHtml(a, size) {
const sz = size || 36
if (a.icon) {
return `<div class="artifact-icon" style="width:${sz}px;height:${sz}px">
<img src="icons/${a.icon}.png" alt="${a.name}"
onerror="this.parentElement.classList.add('placeholder');this.parentElement.innerHTML='${iconFallbacks[a.type] || '&#9670;'}'">
</div>`
}
return `<div class="artifact-icon placeholder" style="width:${sz}px;height:${sz}px">${iconFallbacks[a.type] || '&#9670;'}</div>`
}
// --- Rendering ---
function render(roomId) {
const room = rooms[roomId]
@@ -1430,8 +1519,11 @@
els.artifacts.classList.add('has-items')
els.artifactsList.innerHTML = room.artifacts.map(a => `
<div class="artifact">
<span class="artifact-name">${a.name}</span>
<span class="artifact-type">${a.type}</span>
${artifactIconHtml(a, 36)}
<div class="artifact-info">
<span class="artifact-name">${a.name}</span>
<span class="artifact-type">${a.type}</span>
</div>
</div>
`).join('')
@@ -1566,13 +1658,29 @@
function renderInventory() {
if (state.inventory.length > 0) {
els.inventory.innerHTML = state.inventory.map(i => `
<div class="inv-item">
<span class="inv-item-name">${i.name}</span>
<span class="inv-item-desc">${i.type}</span>
</div>
`).join('')
els.inventory.innerHTML = '<div class="inv-grid">' + state.inventory.map((item, idx) => {
const fallback = iconFallbacks[item.type] || '&#9670;'
const inner = item.icon
? `<img src="icons/${item.icon}.png" alt="${item.name}"
onerror="this.parentElement.classList.add('placeholder');this.parentElement.innerHTML='${fallback}'">`
: fallback
const cls = item.icon ? 'inv-slot' : 'inv-slot placeholder'
return `<div class="${cls}" data-inv-idx="${idx}">${inner}</div>`
}).join('') + '</div>'
els.inventory.querySelectorAll('.inv-slot').forEach(slot => {
slot.addEventListener('mouseenter', () => {
const item = state.inventory[slot.dataset.invIdx]
if (item) els.invLabel.innerHTML = `${item.name}<span class="inv-label-type">${item.type}</span>`
})
slot.addEventListener('mouseleave', () => {
els.invLabel.innerHTML = '&nbsp;'
})
})
} else {
els.inventory.innerHTML = '<div class="log-entry" style="color:var(--muted)">Empty &mdash; explore to find artifacts.</div>'
}
els.invLabel.innerHTML = '&nbsp;'
els.invCount.textContent = state.inventory.length
}

View File

@@ -0,0 +1,178 @@
"""
Generate pixel art inventory icons for the Architecture Adventure game.
Uses Z-Image Turbo pipeline via local ComfyUI server (no LoRA).
Skips icons that already exist on disk.
Usage: python docs/architecture/generate-icons.py
"""
import json
import os
import time
import urllib.request
import urllib.error
COMFY_URL = "http://localhost:8188"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROMPTS_FILE = os.path.join(SCRIPT_DIR, "adventure-icon-prompts.json")
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "icons")
BASE_SEED = 7777
WIDTH = 128
HEIGHT = 128
def build_workflow(prompt_text, seed, prefix):
return {
"1": {
"class_type": "UNETLoader",
"inputs": {
"unet_name": "ZIT\\z_image_turbo_bf16.safetensors",
"weight_dtype": "default",
},
},
"2": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"type": "lumina2",
"device": "default",
},
},
"3": {
"class_type": "VAELoader",
"inputs": {"vae_name": "ae.safetensors"},
},
"4": {
"class_type": "ModelSamplingAuraFlow",
"inputs": {"shift": 3, "model": ["1", 0]},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": prompt_text, "clip": ["2", 0]},
},
"7": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["6", 0]},
},
"8": {
"class_type": "EmptySD3LatentImage",
"inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1},
},
"9": {
"class_type": "KSampler",
"inputs": {
"seed": seed,
"control_after_generate": "fixed",
"steps": 8,
"cfg": 1,
"sampler_name": "res_multistep",
"scheduler": "simple",
"denoise": 1,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["8", 0],
},
},
"10": {
"class_type": "VAEDecode",
"inputs": {"samples": ["9", 0], "vae": ["3", 0]},
},
"11": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": prefix, "images": ["10", 0]},
},
}
def submit_prompt(workflow):
payload = json.dumps({"prompt": workflow}).encode("utf-8")
req = urllib.request.Request(
f"{COMFY_URL}/prompt",
data=payload,
headers={"Content-Type": "application/json"},
)
try:
resp = urllib.request.urlopen(req)
return json.loads(resp.read())
except urllib.error.HTTPError as e:
body = e.read().decode()
raise RuntimeError(f"HTTP {e.code}: {body}")
def poll_history(prompt_id, timeout=120):
start = time.time()
while time.time() - start < timeout:
try:
resp = urllib.request.urlopen(f"{COMFY_URL}/history/{prompt_id}")
data = json.loads(resp.read())
if prompt_id in data:
return data[prompt_id]
except Exception:
pass
time.sleep(2)
return None
def download_image(filename, subfolder, dest_path):
url = f"{COMFY_URL}/view?filename={urllib.request.quote(filename)}&subfolder={urllib.request.quote(subfolder)}&type=output"
urllib.request.urlretrieve(url, dest_path)
def main():
with open(PROMPTS_FILE) as f:
data = json.load(f)
artifacts = data["artifacts"]
os.makedirs(OUTPUT_DIR, exist_ok=True)
# Filter out already-generated icons
to_generate = {}
for artifact_id, artifact in artifacts.items():
dest = os.path.join(OUTPUT_DIR, f"{artifact_id}.png")
if os.path.exists(dest):
print(f" Skipping {artifact_id}.png (already exists)")
else:
to_generate[artifact_id] = artifact
if not to_generate:
print("All icons already generated. Nothing to do.")
return
# Submit jobs
jobs = []
for i, (artifact_id, artifact) in enumerate(to_generate.items()):
prefix = f"adventure-icons/{artifact_id}"
wf = build_workflow(artifact["prompt"], BASE_SEED + i, prefix)
result = submit_prompt(wf)
prompt_id = result["prompt_id"]
jobs.append((artifact_id, prompt_id))
print(f" Submitted: {artifact_id} -> {prompt_id}")
print(f"\n{len(jobs)} jobs queued. Polling for completion...\n")
# Poll for completion
completed = set()
while len(completed) < len(jobs):
for artifact_id, prompt_id in jobs:
if prompt_id in completed:
continue
history = poll_history(prompt_id, timeout=5)
if history:
completed.add(prompt_id)
outputs = history.get("outputs", {})
for node_out in outputs.values():
for img in node_out.get("images", []):
src_filename = img["filename"]
subfolder = img.get("subfolder", "")
dest = os.path.join(OUTPUT_DIR, f"{artifact_id}.png")
download_image(src_filename, subfolder, dest)
print(f" [{len(completed)}/{len(jobs)}] {artifact_id}.png downloaded")
if len(completed) < len(jobs):
time.sleep(2)
print(f"\nDone! {len(completed)} icons saved to {OUTPUT_DIR}/")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB