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:
parent
2c4b97a2f0
commit
8fd238b3ae
2 changed files with 358 additions and 344 deletions
347
src/game/engine/src/world/procedural_painter.gd
Normal file
347
src/game/engine/src/world/procedural_painter.gd
Normal 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))
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue