refactor(@projects/@magic-civilization): ✂️ split procedural drawing into procedural_painter.gd (p2-10k)

Extract the Image-space drawing layer — role/category classifiers, fill
primitives, and unit/building/wonder/city silhouette painters + the
ROLE_*/WONDER_SHAPE_* visual constants (~330 LOC, all static & private to the
render flow) — into a self-contained ProceduralPainter helper. procedural_
renderer.gd keeps orchestration, texture caching, env toggle, and colour
derivation. 554 → 221 lines (under the 500 cap); painter 347.

Verified: gdlint clean on both; headless boot exit 0; procedural_renderer_proof
scene renders all 20 units / 10 buildings / 5 wonders / 5 cities correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-19 18:39:13 -05:00
parent 2c4b97a2f0
commit 8fd238b3ae
2 changed files with 358 additions and 344 deletions

View file

@ -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))

View file

@ -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))