magicciv/tools/sprite-generation/engine/scanner.py
Claude Code cabf9ba11c feat(sprite-generation): Update sprite generation algorithm and optimize file scanning logic
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-29 23:23:59 -07:00

538 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Scan game data JSON files and populate the sprite registry with all needed sprites.
Idempotent — running again adds new sprites without duplicating existing ones.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from engine.prompts import (
BIOME_VEGETATION,
compose_biome_prompt,
compose_prompt,
get_generation_size,
get_negative,
get_quality_range,
get_target_size,
is_valid_biome,
)
from engine.registry import SpriteRegistry
DEMO_RACES = ("high_elves", "humans", "dwarves", "orcs")
WILD_UNIT_FILES = {"wild_t1.json", "wild_t2t3.json"}
def _ui(id: str, name: str, desc: str) -> dict[str, str]:
return {"id": id, "name": name, "description": desc}
UI_ICONS: list[dict[str, str]] = [
_ui("race_high_elves", "High Elves Race Icon", "Elegant elven crest with crystalline spire and silver-blue palette"),
_ui("race_humans", "Humans Race Icon", "Noble heraldic shield with crown, warm gold and stone palette"),
_ui("race_dwarves", "Dwarves Race Icon", "Dwarven hammer and anvil crest, copper and bronze palette"),
_ui("race_orcs", "Orcs Race Icon", "Orcish tribal skull totem with crossed axes, dark iron palette"),
_ui("school_life", "Life School Icon", "Golden radiant sun with holy light, white and gold divine symbol"),
_ui("school_death", "Death School Icon", "Dark skull wreathed in purple-black necromantic energy"),
_ui("school_chaos", "Chaos School Icon", "Volcanic flame burst with demonic rune, red-orange fire symbol"),
_ui("school_nature", "Nature School Icon", "Living tree with deep roots and green leaves, druidic circle"),
_ui("school_aether", "Aether School Icon", "Crystalline blue-white arcane eye with geometric runes"),
_ui("era_1", "Age of Founding Icon", "Primitive campfire beside a standing stone, dawn sky"),
_ui("era_2", "Age of Discovery Icon", "Compass rose over an unfurled map, golden ink"),
_ui("era_3", "Age of Conflict Icon", "Crossed swords over a magical explosion, red and purple energy"),
_ui("era_4", "Age of Ascension Icon", "Spiraling tower reaching into the clouds, radiant light at top"),
_ui("era_5", "Age of Dominion Icon", "Crown floating above a conquered world, all five magic colors swirling"),
_ui("yield_food", "Food Yield Icon", "Sheaf of golden wheat, bountiful harvest symbol"),
_ui("yield_production", "Production Yield Icon", "Iron hammer striking an anvil, sparks flying"),
_ui("yield_gold", "Gold Yield Icon", "Stack of shining gold coins, gleaming treasure"),
_ui("yield_culture", "Culture Yield Icon", "Ancient scroll with quill pen, purple ink"),
_ui("yield_mana", "Mana Yield Icon", "Floating crystalline mana orb, blue-white arcane glow"),
_ui("yield_research", "Research Yield Icon", "Open tome with glowing runes, magical knowledge"),
]
@dataclass
class ScanReport:
new_sprites: int = 0
existing_sprites: int = 0
new_dimensions: int = 0
existing_dimensions: int = 0
categories: dict[str, int] = field(default_factory=dict)
class SpriteScanner:
def __init__(self, data_dir: Path, assets_dir: Path, registry: SpriteRegistry) -> None:
self.data_dir = data_dir
self.assets_dir = assets_dir
self.registry = registry
self.report = ScanReport()
def scan_all(
self,
skip_biome_grid: bool = False,
skip_ui: bool = False,
sprite_type: str | None = None,
) -> ScanReport:
"""Full scan of all data files. Idempotent.
Args:
skip_biome_grid: Skip biome grid generation
skip_ui: Skip UI icon scanning
sprite_type: If set, only scan this category (e.g. "units", "buildings")
"""
self.report = ScanReport()
_CATEGORY_SCANNERS = {
"terrain": lambda: self.scan_terrain(),
"biome_grid": lambda: None if skip_biome_grid else self.scan_biome_grid(),
"units": lambda: self.scan_units(),
"buildings": lambda: self.scan_buildings(),
"resources": lambda: self.scan_resources(),
"improvements": lambda: self.scan_improvements(),
"spells": lambda: self.scan_spells(),
"ui": lambda: None if skip_ui else self.scan_ui(),
}
if sprite_type:
if sprite_type not in _CATEGORY_SCANNERS:
raise ValueError(f"Unknown sprite_type '{sprite_type}'. Valid: {list(_CATEGORY_SCANNERS)}")
_CATEGORY_SCANNERS[sprite_type]()
else:
for fn in _CATEGORY_SCANNERS.values():
fn()
print(
f"\nScan complete: {self.report.new_sprites} new sprites, "
f"{self.report.existing_sprites} existing, "
f"{self.report.new_dimensions} new dimensions, "
f"{self.report.existing_dimensions} existing dimensions"
)
return self.report
# -- data loading ----------------------------------------------------------
def _load_json_entries(self, path: str) -> list[tuple[dict, str]]:
"""Load entries from a JSON file or directory.
If path is a directory, reads all *.json files.
If root is a dict, extracts all list values.
If root is a list, uses directly.
Returns list of (entry_dict, source_filename) tuples.
"""
full = self.data_dir / path
results: list[tuple[dict, str]] = []
if full.is_dir():
for json_file in sorted(full.glob("*.json")):
entries = self._parse_json_file(json_file)
for entry in entries:
results.append((entry, json_file.name))
elif full.is_file():
entries = self._parse_json_file(full)
for entry in entries:
results.append((entry, full.name))
else:
print(f" Warning: path not found: {full}")
return results
@staticmethod
def _parse_json_file(file_path: Path) -> list[dict]:
"""Parse a single JSON file into a list of entry dicts."""
raw = json.loads(file_path.read_text(encoding="utf-8"))
if isinstance(raw, list):
return raw
if isinstance(raw, dict):
# Extract all list values from the root dict
entries: list[dict] = []
for value in raw.values():
if isinstance(value, list):
entries.extend(item for item in value if isinstance(item, dict))
return entries
return []
# -- dimension helpers -----------------------------------------------------
def _register_quality_dimensions(
self, sprite_id: str, category: str, entity_data: dict,
) -> None:
"""Register quality dimension variants for a sprite."""
qr = get_quality_range(category, entity_data)
if qr is None:
return
qual_cat = "terrain" if category == "biome_grid" else category
from engine.prompts import QUALITY_MODIFIERS
qual_table = QUALITY_MODIFIERS.get(qual_cat, {})
for q in range(qr[0], qr[1] + 1):
self._upsert_dim(sprite_id, category, "quality", f"q{q}",
qual_table.get(q, f"quality level {q}"))
def _register_race_dimensions(self, sprite_id: str, category: str) -> None:
"""Register racial variant dimensions for a sprite."""
from engine.prompts import RACE_AESTHETICS
for race in DEMO_RACES:
self._upsert_dim(sprite_id, category, "race", race,
RACE_AESTHETICS.get(race, race))
def _register_gender_dimensions(self, sprite_id: str, category: str) -> None:
"""Register gender variant dimensions for a sprite."""
from engine.prompts import GENDER_MODIFIERS
for gender, modifier in GENDER_MODIFIERS.items():
self._upsert_dim(sprite_id, category, "gender", gender, modifier)
def _upsert_dim(
self, sprite_id: str, category: str,
dim_type: str, dim_value: str, modifier: str,
) -> None:
"""Upsert a single dimension and track it."""
install = self._install_path(category, sprite_id, dim_type, dim_value)
dim_id = self.registry.upsert_dimension(
sprite_id, dim_type, dim_value,
prompt_modifier=modifier, install_path=install,
)
self._track_dimension(dim_id)
def _track_dimension(self, dim_id: int) -> None:
"""Track whether the dimension upsert was new or existing."""
if not hasattr(self, "_max_dim_id"):
row = self.registry.conn.execute(
"SELECT MAX(id) as m FROM sprite_dimensions"
).fetchone()
self._max_dim_id = (row["m"] or 0) if row else 0
if dim_id > self._max_dim_id:
self._max_dim_id = dim_id
self.report.new_dimensions += 1
else:
self.report.existing_dimensions += 1
def _install_path(
self, category: str, sprite_id: str,
dim_type: str | None = None, dim_value: str | None = None,
) -> str:
"""Build the install path for a sprite or dimensional variant.
Follows the messy reference naming convention:
- race dimension: {id}_{race}.png (e.g. spearmen_dwarves.png)
- gender dimension: {id}_{g}.png where g = m/f (e.g. spearmen_m.png)
- quality dimension: {id}_q{n}.png
- no dimension: {id}.png
Note: compound paths (race × gender) are resolved at generation time
by combining dimensions — the individual dimension install paths serve
as templates that the pipeline composes into final paths like
spearmen_dwarves_m.png.
"""
entity_part = sprite_id.split("/", 1)[-1] if "/" in sprite_id else sprite_id
if dim_type == "race":
suffix = f"_{dim_value}"
elif dim_type == "gender":
suffix = f"_{'m' if dim_value == 'male' else 'f'}"
elif dim_type == "quality":
suffix = f"_{dim_value}"
elif dim_type and dim_value:
suffix = f"_{dim_value}"
else:
suffix = ""
return f"sprites/{category}/{entity_part}{suffix}.png"
def _register_sprite(
self,
sprite_id: str,
category: str,
entity_id: str,
entity_data: dict,
source_file: str,
*,
prompt_override: str | None = None,
add_quality: bool = True,
add_races: bool = False,
add_genders: bool = False,
) -> bool:
"""Register a base sprite and its dimensional variants.
Returns True if the sprite was new.
"""
prompt = prompt_override or compose_prompt(category, entity_data)
combat_type = entity_data.get("combat_type", "")
race = entity_data.get("race", "")
if not race and category == "units":
for r in ("dwarves", "humans", "high_elves", "orcs"):
if f"_{r}" in entity_id:
race = r
break
negative = get_negative(category, combat_type=combat_type, race=race)
gen_w, gen_h = get_generation_size(category)
tgt_w, tgt_h = get_target_size(category)
install = self._install_path(category, sprite_id)
is_new = self.registry.upsert_sprite(
id=sprite_id,
category=category,
entity_id=entity_id,
prompt=prompt,
negative_prompt=negative,
gen_width=gen_w,
gen_height=gen_h,
target_width=tgt_w,
target_height=tgt_h,
source_file=source_file,
install_path=install,
)
if is_new:
self.report.new_sprites += 1
self.report.categories[category] = self.report.categories.get(category, 0) + 1
else:
self.report.existing_sprites += 1
if add_quality:
self._register_quality_dimensions(sprite_id, category, entity_data)
if add_races:
self._register_race_dimensions(sprite_id, category)
if add_genders:
self._register_gender_dimensions(sprite_id, category)
return is_new
# -- category scanners -----------------------------------------------------
def scan_terrain(self) -> None:
"""Scan terrain/*.json. Register base sprites + visual variants + quality dimensions."""
entries = self._load_json_entries("terrain")
new_count = 0
total = 0
for entry, source in entries:
entity_id = entry.get("id")
if not entity_id:
continue
total += 1
sprite_id = f"terrain/{entity_id}"
is_new = self._register_sprite(
sprite_id, "terrain", entity_id, entry, source,
)
if is_new:
new_count += 1
# Visual variants for terrain with variant_count > 1
variant_count = entry.get("variant_count", 1)
for v in range(1, variant_count):
var_id = f"terrain/{entity_id}_v{v}"
var_data = {**entry, "name": f"{entry.get('name', entity_id)} variant {v}"}
is_new_v = self._register_sprite(
var_id, "terrain", f"{entity_id}_v{v}", var_data, source,
)
if is_new_v:
new_count += 1
total += 1
print(f"Scanning terrain... {total} found, {new_count} new sprites registered")
def scan_biome_grid(self) -> None:
"""Generate all valid biome grid combinations."""
new_count = 0
total = 0
elevations = ("lowland", "highland", "alpine")
for temp in range(5):
for moist in range(5):
for elev in elevations:
if not is_valid_biome(temp, moist, elev):
continue
total += 1
sprite_id = f"biome_grid/t{temp}_m{moist}_{elev}"
entity_id = f"t{temp}_m{moist}_{elev}"
# Compose prompt at base quality (q3) for the base sprite
prompt = compose_biome_prompt(temp, moist, elev, 3)
entity_data = {
"name": f"Biome t{temp} m{moist} {elev}",
"description": prompt or "",
"temp": temp,
"moist": moist,
"elev": elev,
}
is_new = self._register_sprite(
sprite_id, "biome_grid", entity_id, entity_data,
"generated",
prompt_override=prompt,
)
if is_new:
new_count += 1
print(f"Scanning biome grid... {total} found, {new_count} new sprites registered")
def scan_units(self) -> None:
"""Scan units/*.json. Register sprites with gender + race + quality dimensions."""
entries = self._load_json_entries("units")
new_count = 0
total = 0
for entry, source in entries:
entity_id = entry.get("id")
if not entity_id:
continue
total += 1
sprite_id = f"units/{entity_id}"
has_gender = isinstance(entry.get("gender"), dict)
needs_racial = self._unit_needs_racial_variants(entry, source)
# Tag entity data with type hint for quality range calculation
enriched = {**entry}
if source in WILD_UNIT_FILES:
enriched["type"] = "wild"
is_new = self._register_sprite(
sprite_id, "units", entity_id, enriched, source,
add_quality=True,
add_races=needs_racial,
add_genders=has_gender,
)
if is_new:
new_count += 1
print(f"Scanning units... {total} found, {new_count} new sprites registered")
def _unit_needs_racial_variants(self, entry: dict, source_file: str) -> bool:
"""Determine if a unit needs racial dimension variants."""
if entry.get("race_required"):
return False
school = entry.get("school")
if school and school != "mundane":
return False
if source_file in WILD_UNIT_FILES or entry.get("faction") == "wild":
return False
return True
def scan_buildings(self) -> None:
"""Scan buildings/*.json. Register with race + quality dimensions."""
entries = self._load_json_entries("buildings")
new_count = 0
total = 0
for entry, source in entries:
entity_id = entry.get("id")
if not entity_id:
continue
total += 1
sprite_id = f"buildings/{entity_id}"
needs_racial = self._building_needs_racial_variants(entry, source)
# Tag for quality range
enriched = {**entry}
if source == "wonders.json":
enriched["type"] = "wonder"
elif entry.get("category") in ("wonder",):
enriched["type"] = "wonder"
is_new = self._register_sprite(
sprite_id, "buildings", entity_id, enriched, source,
add_quality=True,
add_races=needs_racial,
)
if is_new:
new_count += 1
print(f"Scanning buildings... {total} found, {new_count} new sprites registered")
def _building_needs_racial_variants(self, entry: dict, source_file: str) -> bool:
"""Determine if a building needs racial dimension variants."""
if entry.get("race_required") or entry.get("race"):
return False
if entry.get("school"):
return False
if source_file in ("wonders.json", "npc.json"):
return False
if entry.get("wonder_type"):
return False
return True
def scan_resources(self) -> None:
"""Scan resources.json. Register with quality dimensions only."""
entries = self._load_json_entries("resources.json")
new_count = 0
total = 0
for entry, source in entries:
entity_id = entry.get("id")
if not entity_id:
continue
total += 1
sprite_id = f"resources/{entity_id}"
is_new = self._register_sprite(
sprite_id, "resources", entity_id, entry, source,
)
if is_new:
new_count += 1
print(f"Scanning resources... {total} found, {new_count} new sprites registered")
def scan_improvements(self) -> None:
"""Scan improvements.json. Register with quality dimensions only."""
entries = self._load_json_entries("improvements.json")
new_count = 0
total = 0
for entry, source in entries:
entity_id = entry.get("id")
if not entity_id:
continue
total += 1
sprite_id = f"improvements/{entity_id}"
is_new = self._register_sprite(
sprite_id, "improvements", entity_id, entry, source,
)
if is_new:
new_count += 1
print(f"Scanning improvements... {total} found, {new_count} new sprites registered")
def scan_spells(self) -> None:
"""Scan spells/*.json. Register with quality dimensions (casting power)."""
entries = self._load_json_entries("spells")
new_count = 0
total = 0
for entry, source in entries:
entity_id = entry.get("id")
if not entity_id:
continue
total += 1
sprite_id = f"spells/{entity_id}"
is_new = self._register_sprite(
sprite_id, "spells", entity_id, entry, source,
)
if is_new:
new_count += 1
print(f"Scanning spells... {total} found, {new_count} new sprites registered")
def scan_ui(self) -> None:
"""Register known UI icon sprites (race, school, era, etc).
No quality dimensions on UI icons.
"""
new_count = 0
total = len(UI_ICONS)
for icon in UI_ICONS:
sprite_id = f"ui/{icon['id']}"
is_new = self._register_sprite(
sprite_id, "ui", icon["id"], icon, "ui_icons",
add_quality=False,
)
if is_new:
new_count += 1
print(f"Scanning UI icons... {total} found, {new_count} new sprites registered")