244 lines
8.3 KiB
GDScript
244 lines
8.3 KiB
GDScript
extends GutTest
|
|
## Tests for p0-23 sprite rendering capability.
|
|
##
|
|
## Verifies the additive-overlay design rule: procedural draw primitives are
|
|
## the always-on baseline, and sprite textures (when present on disk) render
|
|
## on top — never replacing the baseline. With zero sprites in
|
|
## `assets/sprites/`, unit + city rendering must still produce the full
|
|
## procedural visual (circle + letter + HP bar) without crashing.
|
|
|
|
const UnitRendererScript: GDScript = preload(
|
|
"res://engine/src/rendering/unit_renderer.gd"
|
|
)
|
|
const CityRendererScript: GDScript = preload(
|
|
"res://engine/src/rendering/city_renderer.gd"
|
|
)
|
|
const UnitScript: GDScript = preload("res://engine/src/entities/unit.gd")
|
|
const CityScript: GDScript = preload("res://engine/src/entities/city.gd")
|
|
const PlayerScript: GDScript = preload("res://engine/src/entities/player.gd")
|
|
|
|
var _renderer: UnitRendererScript = null
|
|
var _city_renderer: CityRendererScript = null
|
|
var _player: PlayerScript = null
|
|
|
|
|
|
func before_all() -> void:
|
|
DataLoader.load_theme("age-of-dwarves")
|
|
|
|
|
|
func before_each() -> void:
|
|
_player = PlayerScript.new()
|
|
_player.index = 0
|
|
_player.race_id = "dwarf"
|
|
_player.gender_preset = "male"
|
|
_player.color = Color.BLUE
|
|
_player.units = []
|
|
_player.cities = []
|
|
GameState.players = [_player]
|
|
|
|
_renderer = UnitRendererScript.new()
|
|
add_child_autofree(_renderer)
|
|
_city_renderer = CityRendererScript.new()
|
|
add_child_autofree(_city_renderer)
|
|
|
|
|
|
func after_each() -> void:
|
|
GameState.players = []
|
|
|
|
|
|
# ── Named-constant contract ──────────────────────────────────────────
|
|
|
|
func test_unit_sprite_lookup_formats_are_named_constants() -> void:
|
|
# Both formats are publicly accessible so ThemeAssets' cache and any
|
|
# future sprite-generation tooling can compose the same paths.
|
|
assert_eq(
|
|
UnitRendererScript.SPRITE_LOOKUP_RACE_SEX_FORMAT,
|
|
"sprites/units/%s_%s_%s.png",
|
|
"race+sex lookup format must use type_id_race_sex ordering",
|
|
)
|
|
assert_eq(
|
|
UnitRendererScript.SPRITE_LOOKUP_GENERIC_FORMAT,
|
|
"sprites/units/%s.png",
|
|
"generic fallback format must be bare type_id",
|
|
)
|
|
|
|
|
|
func test_city_sprite_lookup_format_is_named_constant() -> void:
|
|
assert_eq(
|
|
CityRendererScript.SPRITE_LOOKUP_CITY_FORMAT,
|
|
"sprites/cities/city_q%d.png",
|
|
"city sprite lookup format must be population-tier keyed",
|
|
)
|
|
|
|
|
|
# ── Unit sprite cache + lookup ───────────────────────────────────────
|
|
|
|
func test_unit_sprite_cache_tries_race_sex_variant_first() -> void:
|
|
# With no files on disk the cache populates both keys as null, but both
|
|
# paths must be attempted — i.e. the cache dictionary must contain both
|
|
# entries after a warm call.
|
|
_renderer._cache_unit_sprites("warriors", "dwarf", "male")
|
|
var variant_path: String = UnitRendererScript.SPRITE_LOOKUP_RACE_SEX_FORMAT % [
|
|
"warriors", "dwarf", "male",
|
|
]
|
|
var generic_path: String = UnitRendererScript.SPRITE_LOOKUP_GENERIC_FORMAT % "warriors"
|
|
assert_true(
|
|
_renderer._unit_sprite_cache.has(variant_path),
|
|
"race+sex path '%s' must be cached" % variant_path,
|
|
)
|
|
assert_true(
|
|
_renderer._unit_sprite_cache.has(generic_path),
|
|
"generic path '%s' must be cached" % generic_path,
|
|
)
|
|
|
|
|
|
func test_unit_sprite_cache_skips_race_sex_when_unknown() -> void:
|
|
# When race and sex are empty (e.g. wild creatures), only the generic
|
|
# path is attempted — no bogus "<type>__.png" style keys are ever added.
|
|
_renderer._cache_unit_sprites("dire_wolf", "", "")
|
|
var generic_path: String = UnitRendererScript.SPRITE_LOOKUP_GENERIC_FORMAT % "dire_wolf"
|
|
assert_true(
|
|
_renderer._unit_sprite_cache.has(generic_path),
|
|
"generic path must be cached for raceless units",
|
|
)
|
|
for key: String in _renderer._unit_sprite_cache:
|
|
assert_false(
|
|
key.contains("__"),
|
|
"no malformed empty-race/sex keys permitted: found '%s'" % key,
|
|
)
|
|
|
|
|
|
func test_unit_sprite_lookup_falls_back_to_procedural_when_no_files_exist() -> void:
|
|
# After p2-62: when no authored sprite exists on disk, the lookup falls
|
|
# back to the procedural renderer rather than returning null. The legacy
|
|
# circle-and-letter baseline still renders below it as before.
|
|
var sprite: Texture2D = _renderer._get_unit_sprite("warriors", "dwarf", "male")
|
|
assert_not_null(
|
|
sprite,
|
|
"with empty sprites/ dir, lookup must return procedural fallback",
|
|
)
|
|
|
|
|
|
# ── Unit rendering: baseline never removed ───────────────────────────
|
|
|
|
func test_unit_sync_populates_data_without_crashing() -> void:
|
|
# sync_units should ingest units regardless of sprite presence.
|
|
var unit: UnitScript = _build_unit("u1", "warriors", 0, Vector2i(3, 3))
|
|
_renderer.sync_units([unit])
|
|
assert_true(
|
|
_renderer._units.has("u1"),
|
|
"sync_units must register unit even when sprite is missing",
|
|
)
|
|
var data: Dictionary = _renderer._units["u1"]
|
|
assert_eq(
|
|
String(data.get("type_id", "")),
|
|
"warriors",
|
|
"type_id must be stored for sprite lookup",
|
|
)
|
|
assert_eq(
|
|
String(data.get("race_id", "")),
|
|
"dwarf",
|
|
"race_id must resolve from owning Player when unit lacks its own",
|
|
)
|
|
assert_eq(
|
|
String(data.get("sex", "")),
|
|
"male",
|
|
"sex must resolve from Player.gender_preset fallback",
|
|
)
|
|
|
|
|
|
func test_unit_sync_queues_redraw_without_sprites() -> void:
|
|
# With zero sprites on disk, sync_units must register the unit and queue
|
|
# a redraw — the procedural baseline (circle + letter) is then drawn by
|
|
# the engine on the next frame. Calling _draw() directly isn't safe
|
|
# outside the engine's draw cycle, so we assert that the scheduling
|
|
# path works end-to-end instead.
|
|
var unit: UnitScript = _build_unit("u1", "warriors", 0, Vector2i(3, 3))
|
|
_renderer.sync_units([unit])
|
|
assert_true(
|
|
_renderer.is_queued_for_deletion() == false,
|
|
"renderer must remain live after sync",
|
|
)
|
|
assert_true(
|
|
_renderer._units.has("u1"),
|
|
"sync_units must keep the unit entry regardless of sprite presence",
|
|
)
|
|
var sprite: Texture2D = _renderer._get_unit_sprite("warriors", "dwarf", "male")
|
|
assert_not_null(
|
|
sprite,
|
|
"with zero sprites on disk, lookup returns procedural fallback (p2-62)",
|
|
)
|
|
|
|
|
|
# ── City rendering: baseline never removed ───────────────────────────
|
|
|
|
func test_city_sprite_lookup_returns_null_when_no_files_exist() -> void:
|
|
var city: CityScript = _build_city("ironhold", 3, 0)
|
|
var sprite: Texture2D = _city_renderer._get_city_sprite(city)
|
|
assert_null(
|
|
sprite,
|
|
"with empty sprites/ dir, city sprite lookup must return null",
|
|
)
|
|
|
|
|
|
func test_city_population_to_quality_bucket_respects_named_bounds() -> void:
|
|
# population=0 -> quality 1; population=25 -> quality 5; clamp at max.
|
|
for pop: int in [0, 4, 5, 24, 99]:
|
|
var q: int = clampi(
|
|
pop / CityRendererScript.CITY_QUALITY_BUCKET + 1,
|
|
1, CityRendererScript.CITY_QUALITY_MAX,
|
|
)
|
|
assert_between(
|
|
q, 1, CityRendererScript.CITY_QUALITY_MAX,
|
|
"quality bucket for pop=%d must be in [1,%d]" % [
|
|
pop, CityRendererScript.CITY_QUALITY_MAX,
|
|
],
|
|
)
|
|
|
|
|
|
func test_city_add_queues_redraw_without_sprites() -> void:
|
|
# With zero sprites on disk, add_city must register the city and leave
|
|
# the renderer ready to draw its procedural baseline (circle + outline
|
|
# + name letter + HP bar + cultural borders). Direct _draw() calls are
|
|
# not safe outside the engine's draw cycle.
|
|
var city: CityScript = _build_city("ironhold", 3, 0)
|
|
_city_renderer.add_city(city)
|
|
assert_true(
|
|
_city_renderer._cities.has("ironhold"),
|
|
"add_city must register the city regardless of sprite presence",
|
|
)
|
|
var sprite: Texture2D = _city_renderer._get_city_sprite(city)
|
|
assert_null(
|
|
sprite,
|
|
"with zero sprites on disk, city sprite lookup returns null",
|
|
)
|
|
|
|
|
|
# ── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
func _build_unit(
|
|
id: String, type_id: String, owner: int, pos: Vector2i,
|
|
) -> UnitScript:
|
|
var u: UnitScript = UnitScript.new()
|
|
u.id = id
|
|
u.unit_id = type_id
|
|
u.type_id = type_id
|
|
u.owner = owner
|
|
u.position = pos
|
|
u.hp = 80
|
|
u.max_hp = 80
|
|
u.unit_type = "melee"
|
|
return u
|
|
|
|
|
|
func _build_city(id: String, population: int, owner: int) -> CityScript:
|
|
var c: CityScript = CityScript.new()
|
|
c.id = id
|
|
c.city_name = id.capitalize()
|
|
c.owner_index = owner
|
|
c.position = Vector2i(5, 5)
|
|
c.population = population
|
|
c.hp = 100
|
|
c.max_hp = 100
|
|
c.owned_tiles = [Vector2i(5, 5)]
|
|
return c
|