diff --git a/src/game/engine/src/world/procedural_painter.gd b/src/game/engine/src/world/procedural_painter.gd new file mode 100644 index 00000000..daeb1c64 --- /dev/null +++ b/src/game/engine/src/world/procedural_painter.gd @@ -0,0 +1,347 @@ +extends RefCounted +## Deterministic Image-space drawing for the procedural sprite fallback +## (extracted from procedural_renderer.gd, p2-10k). Owns the visual vocabulary +## (role / wonder-shape constants), the id→role/category classifiers, and the +## pixel-level shape painters. All methods are static and side-effect-free apart +## from mutating the passed-in Image. Pure presentation — no game-rule logic. +## +## Shape / colour / insignia conventions are documented in +## `src/game/engine/docs/PROCEDURAL_RENDERER.md`. + +# -- Role silhouettes (driven by combat_type or substring match on unit id) -- +const ROLE_WARRIOR: String = "warrior" +const ROLE_ARCHER: String = "archer" +const ROLE_SCOUT: String = "scout" +const ROLE_WORKER: String = "worker" +const ROLE_FOUNDER: String = "founder" +const ROLE_WAGON: String = "wagon" +const ROLE_CAVALRY: String = "cavalry" +const ROLE_SIEGE: String = "siege" +const ROLE_NAVAL: String = "naval" +const ROLE_GENERIC: String = "generic" + +# -- Wonder shape families (3 distinct silhouettes, picked by id hash) -- +const WONDER_SHAPE_TOWER: int = 0 +const WONDER_SHAPE_DOME: int = 1 +const WONDER_SHAPE_ZIGGURAT: int = 2 + + +# -- Role / category classifiers (id-string substring match) ------------------ + +static func classify_unit_role(unit_id: String, seed_val: int) -> String: + var lid: String = unit_id.to_lower() + if "warrior" in lid or "hammer" in lid or "soldier" in lid or "guard" in lid: + return ROLE_WARRIOR + if "archer" in lid or "bow" in lid or "ranger" in lid or "crossbow" in lid: + return ROLE_ARCHER + if "scout" in lid or "explorer" in lid: + return ROLE_SCOUT + if "worker" in lid or "smith" in lid or "miner" in lid or "labour" in lid: + return ROLE_WORKER + if "founder" in lid or "settler" in lid or "pioneer" in lid: + return ROLE_FOUNDER + if "wagon" in lid or "caravan" in lid or "trader" in lid or "merchant" in lid: + return ROLE_WAGON + if "cavalry" in lid or "rider" in lid or "horse" in lid or "ram" in lid: + return ROLE_CAVALRY + if "siege" in lid or "cannon" in lid or "catapult" in lid or "ballista" in lid: + return ROLE_SIEGE + if "ship" in lid or "boat" in lid or "naval" in lid or "galley" in lid: + return ROLE_NAVAL + # Fallback: pick a deterministic family from the id hash so unknown + # units still get visual variety rather than collapsing to one shape. + var families: Array[String] = [ + ROLE_WARRIOR, ROLE_ARCHER, ROLE_SCOUT, ROLE_WORKER, ROLE_GENERIC + ] + return families[seed_val % families.size()] + + +static func classify_building_category(building_id: String) -> String: + var lid: String = building_id.to_lower() + if "market" in lid or "trade" in lid or "bank" in lid or "guild" in lid: + return "economy" + if "forge" in lid or "workshop" in lid or "mill" in lid or "factory" in lid: + return "production" + if "barrack" in lid or "armory" in lid or "stable" in lid or "garrison" in lid: + return "military" + if "library" in lid or "academy" in lid or "school" in lid or "lab" in lid: + return "science" + if "temple" in lid or "shrine" in lid or "monument" in lid or "theatre" in lid: + return "culture" + if "granary" in lid or "farm" in lid or "orchard" in lid or "fishery" in lid: + return "food" + if "wall" in lid or "tower" in lid or "fort" in lid or "citadel" in lid: + return "defence" + return "generic" + + +# -- Image-space drawing primitives ------------------------------------------- +# +# We work directly on Image (Godot's CanvasItem.draw_* functions are only +# valid inside a `_draw()` callback and won't paint into a texture). These +# helpers paint solid filled shapes pixel-by-pixel — fine at the texture +# sizes we use here (≤256²). + +static func _fill_rect(img: Image, x0: int, y0: int, x1: int, y1: int, c: Color) -> void: + var w: int = img.get_width() + var h: int = img.get_height() + var lx: int = clampi(mini(x0, x1), 0, w - 1) + var rx: int = clampi(maxi(x0, x1), 0, w - 1) + var ty: int = clampi(mini(y0, y1), 0, h - 1) + var by: int = clampi(maxi(y0, y1), 0, h - 1) + for y: int in range(ty, by + 1): + for x: int in range(lx, rx + 1): + img.set_pixel(x, y, c) + + +static func _fill_circle(img: Image, cx: int, cy: int, r: int, c: Color) -> void: + var w: int = img.get_width() + var h: int = img.get_height() + var rr: int = r * r + for y: int in range(maxi(0, cy - r), mini(h, cy + r + 1)): + for x: int in range(maxi(0, cx - r), mini(w, cx + r + 1)): + var dx: int = x - cx + var dy: int = y - cy + if dx * dx + dy * dy <= rr: + img.set_pixel(x, y, c) + + +static func _fill_triangle( + img: Image, ax: int, ay: int, bx: int, by: int, cx: int, cy: int, col: Color +) -> void: + ## Half-plane scanline fill for an arbitrary triangle. + var w: int = img.get_width() + var h: int = img.get_height() + var lx: int = clampi(mini(ax, mini(bx, cx)), 0, w - 1) + var rx: int = clampi(maxi(ax, maxi(bx, cx)), 0, w - 1) + var ty: int = clampi(mini(ay, mini(by, cy)), 0, h - 1) + var bottom_y: int = clampi(maxi(ay, maxi(by, cy)), 0, h - 1) + for y: int in range(ty, bottom_y + 1): + for x: int in range(lx, rx + 1): + var s: float = float((bx - ax) * (y - ay) - (by - ay) * (x - ax)) + var t: float = float((cx - bx) * (y - by) - (cy - by) * (x - bx)) + var u: float = float((ax - cx) * (y - cy) - (ay - cy) * (x - cx)) + if (s >= 0.0 and t >= 0.0 and u >= 0.0) or (s <= 0.0 and t <= 0.0 and u <= 0.0): + img.set_pixel(x, y, col) + + +# -- Unit silhouettes --------------------------------------------------------- + +static func paint_unit_silhouette( + img: Image, role: String, primary: Color, secondary: Color, seed_val: int +) -> void: + var size: int = img.get_width() + var cx: int = size / 2 + var cy: int = size / 2 + # Body: large roundish base coloured by race. + _fill_circle(img, cx, cy + 6, int(size * 0.32), primary) + # Role-specific overlay shape. Each is intentionally distinct in + # silhouette so the proof scene shows them at-a-glance separable. + match role: + ROLE_WARRIOR: + # Sword: vertical bar + short crossguard. + _fill_rect(img, cx - 3, cy - int(size * 0.34), cx + 3, cy + int(size * 0.05), secondary) + _fill_rect(img, cx - 12, cy + int(size * 0.05), cx + 12, cy + int(size * 0.10), secondary) + ROLE_ARCHER: + # Bow: tall arc represented by two narrow rects + diagonal arrow. + _fill_rect( + img, + cx - int(size * 0.30), cy - int(size * 0.25), + cx - int(size * 0.26), cy + int(size * 0.25), + secondary + ) + _fill_rect( + img, + cx - int(size * 0.30), cy - int(size * 0.04), + cx + int(size * 0.30), cy, + secondary + ) + ROLE_SCOUT: + # Triangle (compass / arrow) pointing up. + _fill_triangle(img, cx, cy - int(size * 0.32), + cx - int(size * 0.20), cy + int(size * 0.10), + cx + int(size * 0.20), cy + int(size * 0.10), secondary) + ROLE_WORKER: + # Hammer: vertical handle + horizontal head. + _fill_rect(img, cx - 3, cy - int(size * 0.30), cx + 3, cy + int(size * 0.10), secondary) + _fill_rect( + img, + cx - int(size * 0.18), cy - int(size * 0.32), + cx + int(size * 0.18), cy - int(size * 0.20), + secondary + ) + ROLE_FOUNDER: + # House: square body + triangular roof. + _fill_rect( + img, + cx - int(size * 0.18), cy - int(size * 0.05), + cx + int(size * 0.18), cy + int(size * 0.20), + secondary + ) + _fill_triangle(img, cx, cy - int(size * 0.30), + cx - int(size * 0.22), cy - int(size * 0.05), + cx + int(size * 0.22), cy - int(size * 0.05), secondary) + ROLE_WAGON: + # Wagon: long rect body + two wheels. + _fill_rect( + img, + cx - int(size * 0.28), cy - int(size * 0.10), + cx + int(size * 0.28), cy + int(size * 0.10), + secondary + ) + _fill_circle(img, cx - int(size * 0.18), cy + int(size * 0.20), 7, secondary) + _fill_circle(img, cx + int(size * 0.18), cy + int(size * 0.20), 7, secondary) + ROLE_CAVALRY: + # Horse-and-rider stylisation: tall body + diagonal lance. + _fill_rect( + img, + cx - int(size * 0.04), cy - int(size * 0.32), + cx + int(size * 0.04), cy + int(size * 0.10), + secondary + ) + _fill_circle(img, cx + int(size * 0.18), cy - int(size * 0.10), 6, secondary) + ROLE_SIEGE: + # Square engine + small barrel. + _fill_rect( + img, + cx - int(size * 0.22), cy - int(size * 0.18), + cx + int(size * 0.22), cy + int(size * 0.18), + secondary + ) + _fill_circle(img, cx + int(size * 0.26), cy, 6, secondary) + ROLE_NAVAL: + # Hull (trapezoid via triangles) + mast. + _fill_triangle(img, cx - int(size * 0.30), cy + int(size * 0.10), + cx + int(size * 0.30), cy + int(size * 0.10), + cx + int(size * 0.20), cy + int(size * 0.25), secondary) + _fill_rect(img, cx - 3, cy - int(size * 0.30), cx + 3, cy + int(size * 0.10), secondary) + _: + # Generic: deterministic dot pattern off the seed. + var n: int = 3 + (seed_val % 4) + for i: int in range(n): + var off: int = i - n / 2 + _fill_circle(img, cx + off * 8, cy - int(size * 0.20), 4, secondary) + + +static func paint_gender_insignia(img: Image, gender: String, c: Color) -> void: + ## Small marker in the top-left corner identifying gender at a glance. + var size: int = img.get_width() + var x: int = int(size * 0.12) + var y: int = int(size * 0.12) + match gender.to_lower(): + "f", "female", "fem": + # Circle (Venus glyph stub). + _fill_circle(img, x, y, 6, c) + "m", "male", "masc": + # Triangle (Mars glyph stub). + _fill_triangle(img, x, y - 6, x - 6, y + 5, x + 6, y + 5, c) + _: + # Neutral square. + _fill_rect(img, x - 5, y - 5, x + 5, y + 5, c) + + +# -- Building drawing --------------------------------------------------------- + +static func paint_building( + img: Image, roof: Color, wall: Color, door: Color, seed_val: int +) -> void: + var size: int = img.get_width() + # Footprint: trapezoid walls + triangle roof + door. + var wx0: int = int(size * 0.18) + var wx1: int = int(size * 0.82) + var wy0: int = int(size * 0.42) + var wy1: int = int(size * 0.88) + _fill_rect(img, wx0, wy0, wx1, wy1, wall) + # Roof. + _fill_triangle(img, wx0 - 6, wy0, wx1 + 6, wy0, + int(size * 0.5), int(size * 0.16), roof) + # Door (centre). + var dx: int = int(size * 0.5) + var dy_top: int = int(size * 0.62) + _fill_rect(img, dx - 8, dy_top, dx + 8, wy1 - 4, door) + # Window pattern from seed (1-3 windows). + var n_windows: int = 1 + (seed_val % 3) + for i: int in range(n_windows): + var wx: int = wx0 + int(float(i + 1) * float(wx1 - wx0) / float(n_windows + 1)) + _fill_rect(img, wx - 6, wy0 + 8, wx + 6, wy0 + 22, door) + + +# -- Wonder drawing ----------------------------------------------------------- + +static func paint_wonder_halo(img: Image, halo: Color) -> void: + var size: int = img.get_width() + # Soft glow disk behind the wonder. + _fill_circle(img, size / 2, size / 2, int(size * 0.42), halo) + + +static func paint_wonder( + img: Image, shape: int, primary: Color, accent: Color, seed_val: int +) -> void: + var size: int = img.get_width() + var cx: int = size / 2 + match shape: + WONDER_SHAPE_TOWER: + # Tall stepped tower: 3 stacked diminishing rects + spire. + _fill_rect(img, int(size * 0.26), int(size * 0.70), int(size * 0.74), int(size * 0.92), accent) + _fill_rect(img, int(size * 0.32), int(size * 0.50), int(size * 0.68), int(size * 0.70), primary) + _fill_rect(img, int(size * 0.38), int(size * 0.30), int(size * 0.62), int(size * 0.50), accent) + _fill_triangle(img, cx, int(size * 0.10), + int(size * 0.38), int(size * 0.30), + int(size * 0.62), int(size * 0.30), primary) + WONDER_SHAPE_DOME: + # Wide base + dome on top. + _fill_rect(img, int(size * 0.18), int(size * 0.62), int(size * 0.82), int(size * 0.92), accent) + _fill_circle(img, cx, int(size * 0.62), int(size * 0.30), primary) + # Pillars. + _fill_rect(img, int(size * 0.22), int(size * 0.62), int(size * 0.30), int(size * 0.92), primary) + _fill_rect(img, int(size * 0.70), int(size * 0.62), int(size * 0.78), int(size * 0.92), primary) + WONDER_SHAPE_ZIGGURAT: + # 4 stacked terraces. + _fill_rect(img, int(size * 0.14), int(size * 0.78), int(size * 0.86), int(size * 0.92), accent) + _fill_rect(img, int(size * 0.22), int(size * 0.66), int(size * 0.78), int(size * 0.78), primary) + _fill_rect(img, int(size * 0.30), int(size * 0.54), int(size * 0.70), int(size * 0.66), accent) + _fill_rect(img, int(size * 0.38), int(size * 0.42), int(size * 0.62), int(size * 0.54), primary) + _fill_rect(img, int(size * 0.46), int(size * 0.30), int(size * 0.54), int(size * 0.42), accent) + # Deterministic crowning detail off seed. + var detail: int = (seed_val >> 8) % 3 + if detail == 0: + _fill_circle(img, cx, int(size * 0.20), 6, Color(1, 1, 1, 0.85)) + elif detail == 1: + _fill_rect(img, cx - 3, int(size * 0.06), cx + 3, int(size * 0.14), Color(1, 1, 1, 0.85)) + + +# -- City drawing ------------------------------------------------------------- + +static func paint_city(img: Image, tier: int, roof: Color, wall: Color, seed_val: int) -> void: + var size: int = img.get_width() + var cx: int = size / 2 + var cy: int = size / 2 + # Outer ground disk. + _fill_circle(img, cx, cy + int(size * 0.10), int(size * 0.42), Color8(90, 80, 60)) + # Wall ring (stylised: octagonal-ish via filled circle border). + _fill_circle(img, cx, cy + int(size * 0.10), int(size * 0.36), wall) + _fill_circle(img, cx, cy + int(size * 0.10), int(size * 0.30), Color8(140, 120, 90)) + # House clusters scaled by tier. + var house_count: int = 2 + tier * 3 + var radius: float = float(size) * (0.06 + 0.04 * float(tier)) + for i: int in range(house_count): + var ang: float = (float(i) / float(house_count)) * TAU + # Deterministic per-id phase + per-house jitter. + ang += float((seed_val + i * 13) % 360) * (PI / 180.0) * 0.05 + var hx: int = cx + int(cos(ang) * radius * 1.2) + var hy: int = cy + int(sin(ang) * radius * 1.2) + int(size * 0.04) + var house_w: int = 10 + tier * 2 + var house_h: int = 12 + tier * 2 + _fill_rect(img, hx - house_w / 2, hy - house_h / 2, hx + house_w / 2, hy + house_h / 2, wall) + _fill_triangle(img, hx, hy - house_h / 2 - 6, + hx - house_w / 2 - 2, hy - house_h / 2, + hx + house_w / 2 + 2, hy - house_h / 2, roof) + # Central keep — bigger for higher tier. + var keep: int = 14 + tier * 4 + _fill_rect(img, cx - keep / 2, cy - keep / 2, cx + keep / 2, cy + keep / 2, wall) + _fill_triangle(img, cx, cy - keep / 2 - 10, + cx - keep / 2 - 4, cy - keep / 2, + cx + keep / 2 + 4, cy - keep / 2, roof) + # Tier dots in the bottom-right corner (population indicator). + for i: int in range(tier): + _fill_circle(img, size - 16 - i * 12, size - 14, 4, Color(1, 1, 1, 0.85)) diff --git a/src/game/engine/src/world/procedural_renderer.gd b/src/game/engine/src/world/procedural_renderer.gd index 5b23cd98..83623423 100644 --- a/src/game/engine/src/world/procedural_renderer.gd +++ b/src/game/engine/src/world/procedural_renderer.gd @@ -24,17 +24,6 @@ const BUILDING_TEX_SIZE: int = 192 const WONDER_TEX_SIZE: int = 256 const CITY_TEX_SIZE: int = 224 -# -- Role silhouettes (driven by combat_type or substring match on unit id) -- -const ROLE_WARRIOR: String = "warrior" -const ROLE_ARCHER: String = "archer" -const ROLE_SCOUT: String = "scout" -const ROLE_WORKER: String = "worker" -const ROLE_FOUNDER: String = "founder" -const ROLE_WAGON: String = "wagon" -const ROLE_CAVALRY: String = "cavalry" -const ROLE_SIEGE: String = "siege" -const ROLE_NAVAL: String = "naval" -const ROLE_GENERIC: String = "generic" # -- Building category colour ramp (sourced from id substring; see docs) -- const BUILDING_CATEGORY_COLOURS: Dictionary = { @@ -48,10 +37,9 @@ const BUILDING_CATEGORY_COLOURS: Dictionary = { "generic": Color8(160, 140, 110), } -# -- Wonder shape families (3 distinct silhouettes, picked by id hash) -- -const WONDER_SHAPE_TOWER: int = 0 -const WONDER_SHAPE_DOME: int = 1 -const WONDER_SHAPE_ZIGGURAT: int = 2 + +# -- Drawing primitives + silhouette painters (p2-10k extraction). -- +const ProceduralPainterScript = preload("res://engine/src/world/procedural_painter.gd") # -- Process-wide cache so re-requesting the same id is free. -- var _texture_cache: Dictionary = {} @@ -98,15 +86,15 @@ func make_unit_texture(unit_id: String, race_id: String, gender: String) -> Text return _texture_cache[key] var seed_val: int = _stable_hash(unit_id) - var role: String = _classify_unit_role(unit_id, seed_val) + var role: String = ProceduralPainterScript.classify_unit_role(unit_id, seed_val) var primary: Color = _race_colour(race_id, seed_val) var secondary: Color = _shift(primary, 0.35) var insignia: Color = _gender_insignia_colour(gender) var img: Image = Image.create(UNIT_TEX_SIZE, UNIT_TEX_SIZE, false, Image.FORMAT_RGBA8) img.fill(Color(0, 0, 0, 0)) - _paint_unit_silhouette(img, role, primary, secondary, seed_val) - _paint_gender_insignia(img, gender, insignia) + ProceduralPainterScript.paint_unit_silhouette(img, role, primary, secondary, seed_val) + ProceduralPainterScript.paint_gender_insignia(img, gender, insignia) var tex: ImageTexture = ImageTexture.create_from_image(img) _texture_cache[key] = tex @@ -121,14 +109,14 @@ func make_building_texture(building_id: String) -> Texture2D: return _texture_cache[key] var seed_val: int = _stable_hash(building_id) - var category: String = _classify_building_category(building_id) + var category: String = ProceduralPainterScript.classify_building_category(building_id) var roof: Color = BUILDING_CATEGORY_COLOURS.get(category, BUILDING_CATEGORY_COLOURS["generic"]) var wall: Color = _shift(roof, -0.25) var door: Color = _shift(roof, -0.55) var img: Image = Image.create(BUILDING_TEX_SIZE, BUILDING_TEX_SIZE, false, Image.FORMAT_RGBA8) img.fill(Color(0, 0, 0, 0)) - _paint_building(img, roof, wall, door, seed_val) + ProceduralPainterScript.paint_building(img, roof, wall, door, seed_val) var tex: ImageTexture = ImageTexture.create_from_image(img) _texture_cache[key] = tex @@ -150,8 +138,8 @@ func make_wonder_texture(wonder_id: String) -> Texture2D: var img: Image = Image.create(WONDER_TEX_SIZE, WONDER_TEX_SIZE, false, Image.FORMAT_RGBA8) img.fill(Color(0, 0, 0, 0)) - _paint_wonder_halo(img, halo) - _paint_wonder(img, shape, primary, accent, seed_val) + ProceduralPainterScript.paint_wonder_halo(img, halo) + ProceduralPainterScript.paint_wonder(img, shape, primary, accent, seed_val) var tex: ImageTexture = ImageTexture.create_from_image(img) _texture_cache[key] = tex @@ -172,7 +160,7 @@ func make_city_texture(city_id: String, pop_tier: int) -> Texture2D: var img: Image = Image.create(CITY_TEX_SIZE, CITY_TEX_SIZE, false, Image.FORMAT_RGBA8) img.fill(Color(0, 0, 0, 0)) - _paint_city(img, tier, roof, wall, seed_val) + ProceduralPainterScript.paint_city(img, tier, roof, wall, seed_val) var tex: ImageTexture = ImageTexture.create_from_image(img) _texture_cache[key] = tex @@ -188,55 +176,6 @@ static func _stable_hash(s: String) -> int: return hash(s) & 0x7fffffff -# -- Role / category classifiers (id-string substring match) ------------------ - -static func _classify_unit_role(unit_id: String, seed_val: int) -> String: - var lid: String = unit_id.to_lower() - if "warrior" in lid or "hammer" in lid or "soldier" in lid or "guard" in lid: - return ROLE_WARRIOR - if "archer" in lid or "bow" in lid or "ranger" in lid or "crossbow" in lid: - return ROLE_ARCHER - if "scout" in lid or "explorer" in lid: - return ROLE_SCOUT - if "worker" in lid or "smith" in lid or "miner" in lid or "labour" in lid: - return ROLE_WORKER - if "founder" in lid or "settler" in lid or "pioneer" in lid: - return ROLE_FOUNDER - if "wagon" in lid or "caravan" in lid or "trader" in lid or "merchant" in lid: - return ROLE_WAGON - if "cavalry" in lid or "rider" in lid or "horse" in lid or "ram" in lid: - return ROLE_CAVALRY - if "siege" in lid or "cannon" in lid or "catapult" in lid or "ballista" in lid: - return ROLE_SIEGE - if "ship" in lid or "boat" in lid or "naval" in lid or "galley" in lid: - return ROLE_NAVAL - # Fallback: pick a deterministic family from the id hash so unknown - # units still get visual variety rather than collapsing to one shape. - var families: Array[String] = [ - ROLE_WARRIOR, ROLE_ARCHER, ROLE_SCOUT, ROLE_WORKER, ROLE_GENERIC - ] - return families[seed_val % families.size()] - - -static func _classify_building_category(building_id: String) -> String: - var lid: String = building_id.to_lower() - if "market" in lid or "trade" in lid or "bank" in lid or "guild" in lid: - return "economy" - if "forge" in lid or "workshop" in lid or "mill" in lid or "factory" in lid: - return "production" - if "barrack" in lid or "armory" in lid or "stable" in lid or "garrison" in lid: - return "military" - if "library" in lid or "academy" in lid or "school" in lid or "lab" in lid: - return "science" - if "temple" in lid or "shrine" in lid or "monument" in lid or "theatre" in lid: - return "culture" - if "granary" in lid or "farm" in lid or "orchard" in lid or "fishery" in lid: - return "food" - if "wall" in lid or "tower" in lid or "fort" in lid or "citadel" in lid: - return "defence" - return "generic" - - # -- Race colour -------------------------------------------------------------- func _race_colour(race_id: String, fallback_seed: int) -> Color: @@ -280,275 +219,3 @@ static func _shift(c: Color, amount: float) -> Color: if amount >= 0.0: return c.lerp(Color(1, 1, 1, c.a), clampf(amount, 0.0, 1.0)) return c.lerp(Color(0, 0, 0, c.a), clampf(-amount, 0.0, 1.0)) - - -# -- Image-space drawing primitives ------------------------------------------- -# -# We work directly on Image (Godot's CanvasItem.draw_* functions are only -# valid inside a `_draw()` callback and won't paint into a texture). These -# helpers paint solid filled shapes pixel-by-pixel — fine at the texture -# sizes we use here (≤256²). - -static func _fill_rect(img: Image, x0: int, y0: int, x1: int, y1: int, c: Color) -> void: - var w: int = img.get_width() - var h: int = img.get_height() - var lx: int = clampi(mini(x0, x1), 0, w - 1) - var rx: int = clampi(maxi(x0, x1), 0, w - 1) - var ty: int = clampi(mini(y0, y1), 0, h - 1) - var by: int = clampi(maxi(y0, y1), 0, h - 1) - for y: int in range(ty, by + 1): - for x: int in range(lx, rx + 1): - img.set_pixel(x, y, c) - - -static func _fill_circle(img: Image, cx: int, cy: int, r: int, c: Color) -> void: - var w: int = img.get_width() - var h: int = img.get_height() - var rr: int = r * r - for y: int in range(maxi(0, cy - r), mini(h, cy + r + 1)): - for x: int in range(maxi(0, cx - r), mini(w, cx + r + 1)): - var dx: int = x - cx - var dy: int = y - cy - if dx * dx + dy * dy <= rr: - img.set_pixel(x, y, c) - - -static func _fill_triangle( - img: Image, ax: int, ay: int, bx: int, by: int, cx: int, cy: int, col: Color -) -> void: - ## Half-plane scanline fill for an arbitrary triangle. - var w: int = img.get_width() - var h: int = img.get_height() - var lx: int = clampi(mini(ax, mini(bx, cx)), 0, w - 1) - var rx: int = clampi(maxi(ax, maxi(bx, cx)), 0, w - 1) - var ty: int = clampi(mini(ay, mini(by, cy)), 0, h - 1) - var bottom_y: int = clampi(maxi(ay, maxi(by, cy)), 0, h - 1) - for y: int in range(ty, bottom_y + 1): - for x: int in range(lx, rx + 1): - var s: float = float((bx - ax) * (y - ay) - (by - ay) * (x - ax)) - var t: float = float((cx - bx) * (y - by) - (cy - by) * (x - bx)) - var u: float = float((ax - cx) * (y - cy) - (ay - cy) * (x - cx)) - if (s >= 0.0 and t >= 0.0 and u >= 0.0) or (s <= 0.0 and t <= 0.0 and u <= 0.0): - img.set_pixel(x, y, col) - - -# -- Unit silhouettes --------------------------------------------------------- - -static func _paint_unit_silhouette( - img: Image, role: String, primary: Color, secondary: Color, seed_val: int -) -> void: - var size: int = img.get_width() - var cx: int = size / 2 - var cy: int = size / 2 - # Body: large roundish base coloured by race. - _fill_circle(img, cx, cy + 6, int(size * 0.32), primary) - # Role-specific overlay shape. Each is intentionally distinct in - # silhouette so the proof scene shows them at-a-glance separable. - match role: - ROLE_WARRIOR: - # Sword: vertical bar + short crossguard. - _fill_rect(img, cx - 3, cy - int(size * 0.34), cx + 3, cy + int(size * 0.05), secondary) - _fill_rect(img, cx - 12, cy + int(size * 0.05), cx + 12, cy + int(size * 0.10), secondary) - ROLE_ARCHER: - # Bow: tall arc represented by two narrow rects + diagonal arrow. - _fill_rect( - img, - cx - int(size * 0.30), cy - int(size * 0.25), - cx - int(size * 0.26), cy + int(size * 0.25), - secondary - ) - _fill_rect( - img, - cx - int(size * 0.30), cy - int(size * 0.04), - cx + int(size * 0.30), cy, - secondary - ) - ROLE_SCOUT: - # Triangle (compass / arrow) pointing up. - _fill_triangle(img, cx, cy - int(size * 0.32), - cx - int(size * 0.20), cy + int(size * 0.10), - cx + int(size * 0.20), cy + int(size * 0.10), secondary) - ROLE_WORKER: - # Hammer: vertical handle + horizontal head. - _fill_rect(img, cx - 3, cy - int(size * 0.30), cx + 3, cy + int(size * 0.10), secondary) - _fill_rect( - img, - cx - int(size * 0.18), cy - int(size * 0.32), - cx + int(size * 0.18), cy - int(size * 0.20), - secondary - ) - ROLE_FOUNDER: - # House: square body + triangular roof. - _fill_rect( - img, - cx - int(size * 0.18), cy - int(size * 0.05), - cx + int(size * 0.18), cy + int(size * 0.20), - secondary - ) - _fill_triangle(img, cx, cy - int(size * 0.30), - cx - int(size * 0.22), cy - int(size * 0.05), - cx + int(size * 0.22), cy - int(size * 0.05), secondary) - ROLE_WAGON: - # Wagon: long rect body + two wheels. - _fill_rect( - img, - cx - int(size * 0.28), cy - int(size * 0.10), - cx + int(size * 0.28), cy + int(size * 0.10), - secondary - ) - _fill_circle(img, cx - int(size * 0.18), cy + int(size * 0.20), 7, secondary) - _fill_circle(img, cx + int(size * 0.18), cy + int(size * 0.20), 7, secondary) - ROLE_CAVALRY: - # Horse-and-rider stylisation: tall body + diagonal lance. - _fill_rect( - img, - cx - int(size * 0.04), cy - int(size * 0.32), - cx + int(size * 0.04), cy + int(size * 0.10), - secondary - ) - _fill_circle(img, cx + int(size * 0.18), cy - int(size * 0.10), 6, secondary) - ROLE_SIEGE: - # Square engine + small barrel. - _fill_rect( - img, - cx - int(size * 0.22), cy - int(size * 0.18), - cx + int(size * 0.22), cy + int(size * 0.18), - secondary - ) - _fill_circle(img, cx + int(size * 0.26), cy, 6, secondary) - ROLE_NAVAL: - # Hull (trapezoid via triangles) + mast. - _fill_triangle(img, cx - int(size * 0.30), cy + int(size * 0.10), - cx + int(size * 0.30), cy + int(size * 0.10), - cx + int(size * 0.20), cy + int(size * 0.25), secondary) - _fill_rect(img, cx - 3, cy - int(size * 0.30), cx + 3, cy + int(size * 0.10), secondary) - _: - # Generic: deterministic dot pattern off the seed. - var n: int = 3 + (seed_val % 4) - for i: int in range(n): - var off: int = i - n / 2 - _fill_circle(img, cx + off * 8, cy - int(size * 0.20), 4, secondary) - - -static func _paint_gender_insignia(img: Image, gender: String, c: Color) -> void: - ## Small marker in the top-left corner identifying gender at a glance. - var size: int = img.get_width() - var x: int = int(size * 0.12) - var y: int = int(size * 0.12) - match gender.to_lower(): - "f", "female", "fem": - # Circle (Venus glyph stub). - _fill_circle(img, x, y, 6, c) - "m", "male", "masc": - # Triangle (Mars glyph stub). - _fill_triangle(img, x, y - 6, x - 6, y + 5, x + 6, y + 5, c) - _: - # Neutral square. - _fill_rect(img, x - 5, y - 5, x + 5, y + 5, c) - - -# -- Building drawing --------------------------------------------------------- - -static func _paint_building( - img: Image, roof: Color, wall: Color, door: Color, seed_val: int -) -> void: - var size: int = img.get_width() - # Footprint: trapezoid walls + triangle roof + door. - var wx0: int = int(size * 0.18) - var wx1: int = int(size * 0.82) - var wy0: int = int(size * 0.42) - var wy1: int = int(size * 0.88) - _fill_rect(img, wx0, wy0, wx1, wy1, wall) - # Roof. - _fill_triangle(img, wx0 - 6, wy0, wx1 + 6, wy0, - int(size * 0.5), int(size * 0.16), roof) - # Door (centre). - var dx: int = int(size * 0.5) - var dy_top: int = int(size * 0.62) - _fill_rect(img, dx - 8, dy_top, dx + 8, wy1 - 4, door) - # Window pattern from seed (1-3 windows). - var n_windows: int = 1 + (seed_val % 3) - for i: int in range(n_windows): - var wx: int = wx0 + int(float(i + 1) * float(wx1 - wx0) / float(n_windows + 1)) - _fill_rect(img, wx - 6, wy0 + 8, wx + 6, wy0 + 22, door) - - -# -- Wonder drawing ----------------------------------------------------------- - -static func _paint_wonder_halo(img: Image, halo: Color) -> void: - var size: int = img.get_width() - # Soft glow disk behind the wonder. - _fill_circle(img, size / 2, size / 2, int(size * 0.42), halo) - - -static func _paint_wonder( - img: Image, shape: int, primary: Color, accent: Color, seed_val: int -) -> void: - var size: int = img.get_width() - var cx: int = size / 2 - match shape: - WONDER_SHAPE_TOWER: - # Tall stepped tower: 3 stacked diminishing rects + spire. - _fill_rect(img, int(size * 0.26), int(size * 0.70), int(size * 0.74), int(size * 0.92), accent) - _fill_rect(img, int(size * 0.32), int(size * 0.50), int(size * 0.68), int(size * 0.70), primary) - _fill_rect(img, int(size * 0.38), int(size * 0.30), int(size * 0.62), int(size * 0.50), accent) - _fill_triangle(img, cx, int(size * 0.10), - int(size * 0.38), int(size * 0.30), - int(size * 0.62), int(size * 0.30), primary) - WONDER_SHAPE_DOME: - # Wide base + dome on top. - _fill_rect(img, int(size * 0.18), int(size * 0.62), int(size * 0.82), int(size * 0.92), accent) - _fill_circle(img, cx, int(size * 0.62), int(size * 0.30), primary) - # Pillars. - _fill_rect(img, int(size * 0.22), int(size * 0.62), int(size * 0.30), int(size * 0.92), primary) - _fill_rect(img, int(size * 0.70), int(size * 0.62), int(size * 0.78), int(size * 0.92), primary) - WONDER_SHAPE_ZIGGURAT: - # 4 stacked terraces. - _fill_rect(img, int(size * 0.14), int(size * 0.78), int(size * 0.86), int(size * 0.92), accent) - _fill_rect(img, int(size * 0.22), int(size * 0.66), int(size * 0.78), int(size * 0.78), primary) - _fill_rect(img, int(size * 0.30), int(size * 0.54), int(size * 0.70), int(size * 0.66), accent) - _fill_rect(img, int(size * 0.38), int(size * 0.42), int(size * 0.62), int(size * 0.54), primary) - _fill_rect(img, int(size * 0.46), int(size * 0.30), int(size * 0.54), int(size * 0.42), accent) - # Deterministic crowning detail off seed. - var detail: int = (seed_val >> 8) % 3 - if detail == 0: - _fill_circle(img, cx, int(size * 0.20), 6, Color(1, 1, 1, 0.85)) - elif detail == 1: - _fill_rect(img, cx - 3, int(size * 0.06), cx + 3, int(size * 0.14), Color(1, 1, 1, 0.85)) - - -# -- City drawing ------------------------------------------------------------- - -static func _paint_city(img: Image, tier: int, roof: Color, wall: Color, seed_val: int) -> void: - var size: int = img.get_width() - var cx: int = size / 2 - var cy: int = size / 2 - # Outer ground disk. - _fill_circle(img, cx, cy + int(size * 0.10), int(size * 0.42), Color8(90, 80, 60)) - # Wall ring (stylised: octagonal-ish via filled circle border). - _fill_circle(img, cx, cy + int(size * 0.10), int(size * 0.36), wall) - _fill_circle(img, cx, cy + int(size * 0.10), int(size * 0.30), Color8(140, 120, 90)) - # House clusters scaled by tier. - var house_count: int = 2 + tier * 3 - var radius: float = float(size) * (0.06 + 0.04 * float(tier)) - for i: int in range(house_count): - var ang: float = (float(i) / float(house_count)) * TAU - # Deterministic per-id phase + per-house jitter. - ang += float((seed_val + i * 13) % 360) * (PI / 180.0) * 0.05 - var hx: int = cx + int(cos(ang) * radius * 1.2) - var hy: int = cy + int(sin(ang) * radius * 1.2) + int(size * 0.04) - var house_w: int = 10 + tier * 2 - var house_h: int = 12 + tier * 2 - _fill_rect(img, hx - house_w / 2, hy - house_h / 2, hx + house_w / 2, hy + house_h / 2, wall) - _fill_triangle(img, hx, hy - house_h / 2 - 6, - hx - house_w / 2 - 2, hy - house_h / 2, - hx + house_w / 2 + 2, hy - house_h / 2, roof) - # Central keep — bigger for higher tier. - var keep: int = 14 + tier * 4 - _fill_rect(img, cx - keep / 2, cy - keep / 2, cx + keep / 2, cy + keep / 2, wall) - _fill_triangle(img, cx, cy - keep / 2 - 10, - cx - keep / 2 - 4, cy - keep / 2, - cx + keep / 2 + 4, cy - keep / 2, roof) - # Tier dots in the bottom-right corner (population indicator). - for i: int in range(tier): - _fill_circle(img, size - 16 - i * 12, size - 14, 4, Color(1, 1, 1, 0.85))