perf(climate-sim): Optimize climate simulation rendering and engine runtime by refactoring WebGL shaders, terrain legend, and stats dashboard for faster performance; improve engine runner task processing and enhance sprite generation tooling with new prompts and database optimizations

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-26 12:58:50 -07:00
parent 9895dc5638
commit 2c10e94c1a
7 changed files with 153 additions and 137 deletions

View file

@ -157,16 +157,15 @@ interface TerrainGroup {
// Biome groups (Life mode chart) — groups biome_ids into visual categories
// Must cover all ids in TERRAIN_ORDER (runner.ts)
const BIOME_GROUPS: TerrainGroup[] = [
{ label: 'Ocean', abbr: 'Ocn', ids: ['ocean', 'coast', 'deep_ocean', 'shallow_ocean', 'coral_reef', 'estuary', 'mangrove'], color: 'rgb(61,120,209)' },
{ label: 'Fresh', abbr: 'Frs', ids: ['lake', 'pond', 'river', 'inland_sea'], color: 'rgb(100,160,230)' },
{ label: 'Ice', abbr: 'Ice', ids: ['ice', 'snow', 'permanent_ice', 'polar_desert'], color: 'rgb(224,240,255)' },
{ label: 'Ocean', abbr: 'Ocn', ids: ['deep_ocean', 'shallow_ocean', 'coral_reef', 'estuary', 'mangrove', 'ocean', 'coast', 'inland_sea'], color: 'rgb(61,120,209)' },
{ label: 'Fresh', abbr: 'Frs', ids: ['lake', 'pond', 'river'], color: 'rgb(100,160,230)' },
{ label: 'Ice', abbr: 'Ice', ids: ['permanent_ice', 'polar_desert', 'ice', 'snow'], color: 'rgb(224,240,255)' },
{ label: 'Tundra', abbr: 'Tnd', ids: ['tundra', 'alpine_tundra'], color: 'rgb(184,194,166)' },
{ label: 'Arid', abbr: 'Ard', ids: ['desert', 'chaparral', 'savanna'], color: 'rgb(222,199,128)' },
{ label: 'Grass', abbr: 'Grs', ids: ['plains', 'grassland', 'temperate_grassland', 'alpine_meadow'], color: 'rgb(141,197,112)' },
{ label: 'Forest', abbr: 'For', ids: ['forest', 'temperate_forest', 'boreal_forest', 'tropical_rainforest', 'tropical_dry_forest', 'temperate_rainforest', 'montane_forest', 'cloud_forest', 'jungle', 'enchanted_forest'], color: 'rgb(51,140,64)' },
{ label: 'Rough', abbr: 'Rgh', ids: ['hills', 'mountains'], color: 'rgb(158,153,148)' },
{ label: 'Grass', abbr: 'Grs', ids: ['temperate_grassland', 'alpine_meadow', 'plains', 'grassland'], color: 'rgb(141,197,112)' },
{ label: 'Forest', abbr: 'For', ids: ['temperate_forest', 'boreal_forest', 'tropical_rainforest', 'tropical_dry_forest', 'montane_forest', 'cloud_forest', 'forest', 'jungle', 'temperate_rainforest', 'enchanted_forest'], color: 'rgb(51,140,64)' },
{ label: 'Wetland', abbr: 'Wet', ids: ['swamp', 'bog'], color: 'rgb(61,79,36)' },
{ label: 'Volcanic', abbr: 'Vol', ids: ['volcano'], color: 'rgb(191,51,20)' },
{ label: 'Volcanic', abbr: 'Vol', ids: ['volcanic', 'volcano'], color: 'rgb(191,51,20)' },
{ label: 'Cave', abbr: 'Cav', ids: ['subterranean'], color: 'rgb(90,70,60)' },
]

View file

@ -6,19 +6,19 @@ import styled from 'styled-components'
// Only the first 31 (non-magic) entries are shown in the legend
const WATER_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, number, number] }> = [
{ id: 'ocean', label: 'Ocean', rgb: [0.24, 0.47, 0.82] },
{ id: 'deep_ocean', label: 'Deep Ocean', rgb: [0.12, 0.27, 0.63] },
{ id: 'coast', label: 'Coast', rgb: [0.31, 0.71, 0.86] },
{ id: 'coral_reef', label: 'Coral Reef', rgb: [0.25, 0.75, 0.67] },
{ id: 'lake', label: 'Lake', rgb: [0.31, 0.63, 0.84] },
{ id: 'inland_sea', label: 'Inland Sea', rgb: [0.25, 0.51, 0.80] },
{ id: 'estuary', label: 'Estuary', rgb: [0.35, 0.59, 0.67] },
{ id: 'deep_ocean', label: 'Deep Ocean', rgb: [0.12, 0.27, 0.63] },
{ id: 'shallow_ocean', label: 'Shallow Ocean', rgb: [0.24, 0.47, 0.82] },
{ id: 'coral_reef', label: 'Coral Reef', rgb: [0.25, 0.75, 0.67] },
{ id: 'estuary', label: 'Estuary', rgb: [0.35, 0.59, 0.67] },
{ id: 'lake', label: 'Lake', rgb: [0.31, 0.63, 0.84] },
{ id: 'pond', label: 'Pond', rgb: [0.45, 0.70, 0.88] },
{ id: 'river', label: 'River', rgb: [0.35, 0.60, 0.90] },
{ id: 'mangrove', label: 'Mangrove', rgb: [0.28, 0.47, 0.35] },
]
const ICE_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, number, number] }> = [
{ id: 'ice', label: 'Ice', rgb: [0.88, 0.94, 1.00] },
{ id: 'snow', label: 'Snow', rgb: [0.94, 0.96, 1.00] },
{ id: 'polar_desert', label: 'Polar Desert', rgb: [0.78, 0.80, 0.73] },
{ id: 'permanent_ice', label: 'Permanent Ice', rgb: [0.88, 0.94, 1.00] },
{ id: 'polar_desert', label: 'Polar Desert', rgb: [0.78, 0.80, 0.73] },
]
const COLD_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, number, number] }> = [
@ -29,10 +29,8 @@ const COLD_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, numb
const TEMPERATE_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, number, number] }> = [
{ id: 'chaparral', label: 'Chaparral', rgb: [0.69, 0.62, 0.42] },
{ id: 'plains', label: 'Plains', rgb: [0.73, 0.82, 0.53] },
{ id: 'grassland', label: 'Grassland', rgb: [0.38, 0.72, 0.35] },
{ id: 'forest', label: 'Forest', rgb: [0.20, 0.55, 0.25] },
{ id: 'temperate_rainforest', label: 'Temperate Rainforest', rgb: [0.10, 0.44, 0.20] },
{ id: 'temperate_grassland', label: 'Temperate Grassland', rgb: [0.55, 0.77, 0.44] },
{ id: 'temperate_forest', label: 'Temperate Forest', rgb: [0.20, 0.55, 0.25] },
]
const TROPICAL_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, number, number] }> = [
@ -40,16 +38,12 @@ const TROPICAL_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number,
{ id: 'savanna', label: 'Savanna', rgb: [0.74, 0.70, 0.40] },
{ id: 'tropical_dry_forest', label: 'Tropical Dry Forest', rgb: [0.50, 0.61, 0.25] },
{ id: 'tropical_rainforest', label: 'Tropical Rainforest', rgb: [0.09, 0.45, 0.18] },
{ id: 'jungle', label: 'Jungle', rgb: [0.14, 0.40, 0.14] },
{ id: 'mangrove', label: 'Mangrove', rgb: [0.28, 0.47, 0.35] },
]
const ELEVATION_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, number, number] }> = [
{ id: 'hills', label: 'Hills', rgb: [0.58, 0.52, 0.38] },
{ id: 'mountains', label: 'Mountains', rgb: [0.62, 0.60, 0.58] },
{ id: 'alpine_meadow', label: 'Alpine Meadow', rgb: [0.56, 0.69, 0.47] },
{ id: 'cloud_forest', label: 'Cloud Forest', rgb: [0.25, 0.47, 0.35] },
{ id: 'montane_forest', label: 'Montane Forest', rgb: [0.18, 0.42, 0.28] },
{ id: 'montane_forest', label: 'Montane Forest', rgb: [0.18, 0.42, 0.28] },
{ id: 'cloud_forest', label: 'Cloud Forest', rgb: [0.25, 0.47, 0.35] },
]
const WETLAND_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, number, number] }> = [
@ -58,7 +52,8 @@ const WETLAND_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, n
]
const SPECIAL_BIOMES: ReadonlyArray<{ id: string; label: string; rgb: [number, number, number] }> = [
{ id: 'volcano', label: 'Volcano', rgb: [0.75, 0.20, 0.08] },
{ id: 'volcanic', label: 'Volcanic', rgb: [0.75, 0.20, 0.08] },
{ id: 'subterranean', label: 'Subterranean', rgb: [0.40, 0.32, 0.28] },
]
const TERRAIN_SECTIONS: ReadonlyArray<{ label: string; biomes: ReadonlyArray<{ id: string; label: string; rgb: [number, number, number] }> }> = [

View file

@ -26,21 +26,21 @@ uniform int uViewCenter; // 0 = equator, 1 = north pole, 2 = south pole
uniform float uPolarRows; // how many rows from the pole are visible (mouse-wheel zoom)
uniform float uTime;
// ── terrain colour lookup ──────────────────────────────────────────────────
// Indices must match TERRAIN_ORDER in runner.ts (40 entries)
// ── biome colour lookup ──────────────────────────────────────────────────
// Indices must match TERRAIN_ORDER in runner.ts (41 entries)
vec3 terrainColor(float encoded) {
int idx = int(encoded * 39.0 + 0.5);
// Water
if (idx == 0) return vec3(0.24, 0.47, 0.82); // ocean
if (idx == 1) return vec3(0.12, 0.27, 0.63); // deep_ocean
if (idx == 2) return vec3(0.31, 0.71, 0.86); // coast
if (idx == 3) return vec3(0.25, 0.75, 0.67); // coral_reef
int idx = int(encoded * 40.0 + 0.5);
// Aquatic biomes
if (idx == 0) return vec3(0.12, 0.27, 0.63); // deep_ocean
if (idx == 1) return vec3(0.24, 0.47, 0.82); // shallow_ocean
if (idx == 2) return vec3(0.25, 0.75, 0.67); // coral_reef
if (idx == 3) return vec3(0.35, 0.59, 0.67); // estuary
if (idx == 4) return vec3(0.31, 0.63, 0.84); // lake
if (idx == 5) return vec3(0.25, 0.51, 0.80); // inland_sea
if (idx == 6) return vec3(0.35, 0.59, 0.67); // estuary
if (idx == 5) return vec3(0.45, 0.70, 0.88); // pond
if (idx == 6) return vec3(0.35, 0.60, 0.90); // river
if (idx == 7) return vec3(0.28, 0.47, 0.35); // mangrove
// Ice / Polar
if (idx == 7) return vec3(0.88, 0.94, 1.00); // ice
if (idx == 8) return vec3(0.94, 0.96, 1.00); // snow
if (idx == 8) return vec3(0.88, 0.94, 1.00); // permanent_ice
if (idx == 9) return vec3(0.78, 0.80, 0.73); // polar_desert
// Cold
if (idx == 10) return vec3(0.72, 0.76, 0.65); // tundra
@ -48,37 +48,39 @@ vec3 terrainColor(float encoded) {
if (idx == 12) return vec3(0.22, 0.44, 0.30); // boreal_forest
// Temperate
if (idx == 13) return vec3(0.69, 0.62, 0.42); // chaparral
if (idx == 14) return vec3(0.73, 0.82, 0.53); // plains
if (idx == 15) return vec3(0.38, 0.72, 0.35); // grassland
if (idx == 16) return vec3(0.20, 0.55, 0.25); // forest
if (idx == 17) return vec3(0.10, 0.44, 0.20); // temperate_rainforest
// Warm / Tropical
if (idx == 18) return vec3(0.87, 0.78, 0.50); // desert
if (idx == 19) return vec3(0.74, 0.70, 0.40); // savanna
if (idx == 20) return vec3(0.50, 0.61, 0.25); // tropical_dry_forest
if (idx == 21) return vec3(0.09, 0.45, 0.18); // tropical_rainforest
if (idx == 22) return vec3(0.14, 0.40, 0.14); // jungle
if (idx == 23) return vec3(0.28, 0.47, 0.35); // mangrove
if (idx == 14) return vec3(0.55, 0.77, 0.44); // temperate_grassland
if (idx == 15) return vec3(0.20, 0.55, 0.25); // temperate_forest
// Tropical
if (idx == 16) return vec3(0.87, 0.78, 0.50); // desert
if (idx == 17) return vec3(0.74, 0.70, 0.40); // savanna
if (idx == 18) return vec3(0.50, 0.61, 0.25); // tropical_dry_forest
if (idx == 19) return vec3(0.09, 0.45, 0.18); // tropical_rainforest
// Elevation
if (idx == 24) return vec3(0.58, 0.52, 0.38); // hills
if (idx == 25) return vec3(0.62, 0.60, 0.58); // mountains
if (idx == 26) return vec3(0.56, 0.69, 0.47); // alpine_meadow
if (idx == 27) return vec3(0.25, 0.47, 0.35); // cloud_forest
if (idx == 28) return vec3(0.18, 0.42, 0.28); // montane_forest
if (idx == 20) return vec3(0.56, 0.69, 0.47); // alpine_meadow
if (idx == 21) return vec3(0.18, 0.42, 0.28); // montane_forest
if (idx == 22) return vec3(0.25, 0.47, 0.35); // cloud_forest
// Wetland
if (idx == 29) return vec3(0.24, 0.31, 0.14); // swamp
if (idx == 30) return vec3(0.45, 0.39, 0.22); // bog
if (idx == 23) return vec3(0.24, 0.31, 0.14); // swamp
if (idx == 24) return vec3(0.45, 0.39, 0.22); // bog
// Special
if (idx == 31) return vec3(0.75, 0.20, 0.08); // volcano
// Magic compat (hidden from legend)
if (idx == 32) return vec3(0.42, 0.85, 0.55); // enchanted_forest
if (idx == 33) return vec3(0.85, 0.70, 1.00); // mana_node
if (idx == 34) return vec3(0.60, 0.95, 0.70); // ley_nexus
if (idx == 35) return vec3(0.70, 0.70, 0.85); // lodestone_spire
if (idx == 36) return vec3(0.75, 0.85, 1.00); // crystal_cavern
if (idx == 37) return vec3(0.55, 0.40, 0.25); // worldroot
if (idx == 38) return vec3(0.40, 0.80, 0.75); // primordial_spring
return vec3(0.30, 0.50, 0.80); // abyssal_vortex (fallback)
if (idx == 25) return vec3(0.75, 0.20, 0.08); // volcanic
if (idx == 26) return vec3(0.40, 0.32, 0.28); // subterranean
// Map gen legacy fallbacks (pre-classification)
if (idx == 27) return vec3(0.24, 0.47, 0.82); // ocean → shallow_ocean color
if (idx == 28) return vec3(0.31, 0.71, 0.86); // coast
if (idx == 29) return vec3(0.88, 0.94, 1.00); // ice → permanent_ice color
if (idx == 30) return vec3(0.94, 0.96, 1.00); // snow → ice color
if (idx == 31) return vec3(0.73, 0.82, 0.53); // plains → grassland color
if (idx == 32) return vec3(0.55, 0.77, 0.44); // grassland → temperate_grassland color
if (idx == 33) return vec3(0.20, 0.55, 0.25); // forest → temperate_forest color
if (idx == 34) return vec3(0.14, 0.40, 0.14); // jungle → tropical_rainforest color
if (idx == 35) return vec3(0.58, 0.52, 0.38); // hills
if (idx == 36) return vec3(0.62, 0.60, 0.58); // mountains
if (idx == 37) return vec3(0.25, 0.51, 0.80); // inland_sea
if (idx == 38) return vec3(0.10, 0.44, 0.20); // temperate_rainforest
if (idx == 39) return vec3(0.42, 0.85, 0.55); // enchanted_forest
if (idx == 40) return vec3(0.75, 0.20, 0.08); // volcano → volcanic color
return vec3(0.30, 0.15, 0.40); // unknown (dark purple = visible error)
}
// ── gradient helpers ───────────────────────────────────────────────────────
@ -354,7 +356,7 @@ void main() {
// Marine health overlay (coast/ocean tiles only — reef > 0 is a proxy)
if (showMarine) {
int tidx = int(terrainEnc * 39.0 + 0.5);
int tidx = int(terrainEnc * 40.0 + 0.5);
if (tidx <= 3) {
col = mix(col, marineColor(reef), 0.80);
}
@ -386,7 +388,7 @@ void main() {
}
if (showFish) {
// Blue dots on water tiles with fish
int tidx2 = int(terrainEnc * 39.0 + 0.5);
int tidx2 = int(terrainEnc * 40.0 + 0.5);
if (tidx2 <= 3 && reef > 0.01) {
col = mix(col, vec3(0.20, 0.50, 0.95), 0.7 * reef);
}

View file

@ -11,7 +11,7 @@ import type {
} from './types'
import { GRID_WIDTH, GRID_HEIGHT, solarByRow } from './HexGrid'
import { ClimatePhysics } from './ClimatePhysics.generated'
import { EcologyPhysics } from './EcologyPhysics.generated'
import { EcologyPhysics, classifyBiome } from './EcologyPhysics.generated'
import { generate as generateMap } from './MapGenerator.generated'
import { WORLD_SEED, DEFAULT_SCENARIO_TURNS } from './configs'
@ -50,26 +50,26 @@ function computeTileAlbedo(tile: TileState, isWater: boolean): number {
// ---------------------------------------------------------------------------
export const TERRAIN_ORDER: readonly string[] = [
// Water
'ocean', 'deep_ocean', 'coast', 'coral_reef', 'lake', 'inland_sea', 'estuary',
// Aquatic biomes (classifier outputs)
'deep_ocean', 'shallow_ocean', 'coral_reef', 'estuary', 'lake', 'pond', 'river', 'mangrove',
// Ice / Polar
'ice', 'snow', 'polar_desert',
'permanent_ice', 'polar_desert',
// Cold
'tundra', 'alpine_tundra', 'boreal_forest',
// Temperate
'chaparral', 'plains', 'grassland', 'forest', 'temperate_rainforest',
// Warm / Tropical
'desert', 'savanna', 'tropical_dry_forest', 'tropical_rainforest', 'jungle', 'mangrove',
'chaparral', 'temperate_grassland', 'temperate_forest',
// Tropical
'desert', 'savanna', 'tropical_dry_forest', 'tropical_rainforest',
// Elevation
'hills', 'mountains', 'alpine_meadow', 'cloud_forest', 'montane_forest',
'alpine_meadow', 'montane_forest', 'cloud_forest',
// Wetland
'swamp', 'bog',
// Special
'volcano',
// Magic (hidden from legend — kept for ClimatePhysics.generated.ts compat)
'enchanted_forest',
'mana_node', 'ley_nexus', 'lodestone_spire', 'crystal_cavern',
'worldroot', 'primordial_spring', 'abyssal_vortex',
'volcanic', 'subterranean',
// Map gen legacy IDs (pre-classification fallbacks)
'ocean', 'coast', 'ice', 'snow', 'plains', 'grassland', 'forest', 'jungle',
'hills', 'mountains', 'inland_sea', 'temperate_rainforest',
'enchanted_forest', 'volcano',
] as const
const TERRAIN_INDEX = new Map<string, number>(
@ -302,6 +302,12 @@ export function runScenarioSync(
// Generate map from seed using the transpiled GDScript pipeline
const grid = generateMap(worldSeed, GRID_WIDTH, GRID_HEIGHT, terrainCache, params, 'continents')
// Classify map gen terrain IDs into proper biome IDs
for (const tile of grid.tiles) {
tile.biome_id = classifyBiome(tile)
}
const physics = new ClimatePhysics(params, terrainCache)
const ecology = new EcologyPhysics()
const snapshots: GridSnapshot[] = []

View file

@ -32,15 +32,14 @@ STYLE_PREFIXES: dict[str, str] = {
"biome_grid": _BG_STYLE,
"edges": _BG_STYLE + ", transition gradient between two terrain types",
# --- LAYERS: UNITS (default — infantry on foot) ---
# --- LAYERS: UNITS (default fallback — combat-type templates preferred) ---
"units": (
"on solid bright green chroma key background, "
"single character game sprite, isometric view from above, "
"character facing bottom-left, walking toward southwest, "
"ONE person only, seen from elevated strategy game camera angle, "
"like an Age of Empires II or Warcraft III unit sprite, "
"painted fantasy game art, clean readable silhouette, "
"clean crisp edges, masterpiece, best quality"
"solid bright green screen background, "
"character walking toward bottom-left corner of frame, facing southwest, "
"seen from above at 45-degree elevated angle, "
"DOTA 2 hero art style, bold painterly fantasy, rich saturated colors, "
"single game unit sprite, ONE character only, full body visible head to feet, "
"clean readable silhouette, masterpiece, best quality"
),
"buildings": (
"isometric game building sprite, "
@ -108,7 +107,7 @@ NEGATIVES: dict[str, str] = {
"terrain": _BG_NEG,
"biome_grid": _BG_NEG,
"edges": _BG_NEG,
"units": _LAYER_NEG + ", multiple characters, crowd, group, turnaround, reference sheet, model sheet, concept art, multiple poses, multiple views, multiple angles, character sheet, grey background",
"units": _LAYER_NEG + ", multiple characters, crowd, group, turnaround, reference sheet, model sheet, concept art, multiple poses, multiple views, multiple angles, character sheet, grey background, white background, facing camera, facing forward, facing right, front view, side view, top-down view, photorealistic, photo, 3d render, anime, pixel art, chibi",
"buildings": _LAYER_NEG + ", front elevation, straight-on view, multiple buildings, city, street",
"resources": _LAYER_NEG + ", seamless texture, tileable pattern, inventory item, floating object, full-frame texture, raw meat, food",
"improvements": _LAYER_NEG + ", complex scene, multiple structures, city, village, town, farm scene, landscape, panorama, multiple buildings, people, farmers, vehicles, tractor",
@ -223,56 +222,68 @@ SCHOOL_ENERGY_COLORS: dict[str, str] = {
# Overrides the generic "units" STYLE_PREFIX when a combat_type matches
# ---------------------------------------------------------------------------
_UNIT_GREEN_BG = "on solid bright green chroma key background"
_UNIT_COMMON = (
"ONE only, seen from elevated strategy game camera angle, "
"painted fantasy game art, clean readable silhouette, "
"clean crisp edges, masterpiece, best quality"
# SDXL weights early tokens heaviest. Front-load the 5 weakest scoring dimensions:
# 1. BRIGHT GREEN BG (background_compliance: 27%)
# 2. FACING DIRECTION (facing_direction: 15%)
# 3. CAMERA ANGLE (camera_angle: 25%)
# 4. ART STYLE (art_style: 23%)
# 5. Equipment comes from combat type template
_UNIT_CORE = (
"solid bright green screen background, "
"character walking toward bottom-left corner of frame, facing southwest, "
"seen from above at 45-degree elevated angle, "
"DOTA 2 hero art style, bold painterly fantasy, rich saturated colors, "
"single game unit sprite, ONE character only, full body visible head to feet, "
"clean readable silhouette, masterpiece, best quality"
)
UNIT_STYLE_BY_COMBAT_TYPE: dict[str, str] = {
"melee": (
f"{_UNIT_GREEN_BG}, "
"single armored warrior game sprite, isometric view from above, "
f"character facing bottom-left walking southwest, {_UNIT_COMMON}"
f"{_UNIT_CORE}, "
"armored warrior holding melee weapon, heavy armor and shield"
),
"ranged": (
f"{_UNIT_GREEN_BG}, "
"single ranged fighter game sprite holding weapon ready, isometric view from above, "
f"character facing bottom-left, {_UNIT_COMMON}"
f"{_UNIT_CORE}, "
"ranged fighter holding ranged weapon ready to fire, quiver or ammo pouch"
),
"cavalry": (
f"{_UNIT_GREEN_BG}, "
"single mounted rider on horse or war beast, isometric view from above, "
f"riding toward bottom-left southwest, {_UNIT_COMMON}"
"solid bright green screen background, "
"mounted rider on armored warhorse walking toward bottom-left corner, facing southwest, "
"seen from above at 45-degree elevated angle, "
"DOTA 2 hero art style, bold painterly fantasy, rich saturated colors, "
"single mounted unit sprite, ONE rider only, full mount visible, "
"clean readable silhouette, masterpiece, best quality"
),
"siege": (
f"{_UNIT_GREEN_BG}, "
"single war machine or siege engine, isometric view from above, "
f"facing bottom-left, no people, {_UNIT_COMMON}"
"solid bright green screen background, "
"war machine facing bottom-left corner of frame, "
"seen from above at 45-degree elevated angle, "
"DOTA 2 art style, bold painterly fantasy, "
"single siege engine sprite, no people, heavy wood and iron, "
"clean readable silhouette, masterpiece, best quality"
),
"flying": (
f"{_UNIT_GREEN_BG}, "
"single flying creature or aircraft in flight, isometric view from above, "
f"flying toward bottom-left, wings spread, {_UNIT_COMMON}"
"solid bright green screen background, "
"flying creature soaring toward bottom-left corner, wings spread, "
"seen from above at 45-degree elevated angle, "
"DOTA 2 art style, bold painterly fantasy, "
"single flying unit sprite, clean readable silhouette, masterpiece, best quality"
),
"marine": (
f"{_UNIT_GREEN_BG}, "
"single ship or boat on water, isometric view from above, "
"small vessel game sprite like a Civilization V naval unit, "
f"sailing toward bottom-left, {_UNIT_COMMON}"
"solid bright green screen background, "
"small ship sailing toward bottom-left corner of frame, "
"seen from above at 45-degree elevated angle, "
"Civilization V naval unit style, bold painterly, "
"single vessel sprite, clean readable silhouette, masterpiece, best quality"
),
"civilian": (
f"{_UNIT_GREEN_BG}, "
"single civilian character game sprite, isometric view from above, "
"character facing bottom-left walking southwest, carrying tools or supplies, "
f"{_UNIT_COMMON}"
f"{_UNIT_CORE}, "
"civilian carrying tools or supplies, traveler clothes, no armor"
),
"specialist": (
f"{_UNIT_GREEN_BG}, "
"single support character game sprite, isometric view from above, "
"character facing bottom-left, carrying specialized equipment, "
f"{_UNIT_COMMON}"
f"{_UNIT_CORE}, "
"support character with specialized equipment, tactical gear"
),
}
@ -467,16 +478,22 @@ def compose_prompt(
) -> str:
"""Build the full prompt for a sprite.
SDXL weights early tokens most heavily, so composition order is:
1. SUBJECT FIRST what the image depicts (name + visual description)
2. Category-specific attributes (combat type, school, race, keywords)
3. Style/perspective constraints (the style prefix)
4. Quality modifier
SDXL weights early tokens most heavily. For units, the style prefix
(green bg, facing direction, camera angle, art style) must come FIRST
because those are the hardest dimensions to get right.
"""
dims = dimensions or {}
parts: list[str] = []
# 1 — SUBJECT FIRST (most important tokens for SDXL)
# For units: STYLE PREFIX FIRST (green bg + direction + camera + art style)
# These are the weakest scoring dimensions and need maximum SDXL weight.
if category == "units":
combat_type = entity_data.get("combat_type", "")
prefix = get_unit_style(combat_type)
if prefix:
parts.append(prefix)
# Subject — what the image depicts (name + visual description)
name = entity_data.get("name", "")
description = entity_data.get("description", "")
if name:
@ -520,14 +537,11 @@ def compose_prompt(
elif gender in GENDER_MODIFIERS:
parts.append(GENDER_MODIFIERS[gender])
# 3 — STYLE PREFIX (perspective + art style constraints come after subject)
if category == "units":
combat_type = entity_data.get("combat_type", "")
prefix = get_unit_style(combat_type)
else:
# 3 — STYLE PREFIX (non-units only — units already got theirs at position 0)
if category != "units":
prefix = STYLE_PREFIXES.get(category, "")
if prefix:
parts.append(prefix)
if prefix:
parts.append(prefix)
# 4 — quality modifier
quality = dims.get("quality")