Rendering and presentation - Shared surface-material module (src/game/materials.gd) with per-surface roughness/metallic/uv profiles; ACES tonemapping, glow, fog and phase-driven sky energy. WorldBuilder rebuilt on those profiles with facade windows that light up, door trim/handles, curb, lane dashes, bollards, bench, bin and planter foliage. - NPC silhouettes: per-role clothing palettes, caps/hoods/backpacks/sunglasses, hands and shoes, arm/leg walk swing. - Six generated low-poly GLB props (bollards, bench, bin, planter, newspaper box, hydrant) built by tools/model_pipeline.py, loaded by WorldBuilder with a procedural fallback; ADR-0012. Selection and movement - Focus ranking by view angle then distance (NpcFocus.rank_indices); Tab and the mouse wheel cycle nearby people initially, right click clears focus with a re-acquire grace, HUD shows the candidate count, Shift walks at half speed. ADR-0011. Windowed smoke extended to cover wheel cycling and clearing. QA tooling - screen_designs.py catalogue grown to 19 screens (8 scene, 11 UI) plus a `list` subcommand; draft generation keeps existing AI references unless --force. Web export - New Web preset using the non-threaded Godot template (static hosting, no COOP/COEP). tools/build_releases.sh exports build/web and packages build/step-line-web-<ver>.zip; publish_release.sh uploads it with the desktop artifacts. test_web_export.gd covers the preset and release wiring. Verification - Headless suite 177 -> 181, Python tooling 53 tests, all eight smokes pass (including the windowed focus/wheel smoke). Web bundle serves index.html, index.wasm (35 MB) and index.pck (30 MB) with HTTP 200 from a static server. - Version 0.3.0; README, PLAN, RELEASING, TODO/DONE and MEMORY updated.
89 lines
4.0 KiB
GDScript
89 lines
4.0 KiB
GDScript
extends RefCounted
|
|
## Headless tests for focus ranking, aim-free cycling helpers, and the NPC
|
|
## outfit/gait helpers added in the control re-evaluation (ADR-0011).
|
|
|
|
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
|
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
|
|
const Rules := preload("res://src/core/rules.gd")
|
|
|
|
func test_rank_orders_by_angle_then_distance() -> bool:
|
|
var origin := Vector3.ZERO
|
|
var forward := Vector3(0, 0, -1)
|
|
# Index 2 is dead ahead and near, index 0 dead ahead but far, index 1 off to
|
|
# the side. Ranking must be angle first, then distance.
|
|
var heads := [Vector3(0, 0, -9), Vector3(0.9, 0, -6), Vector3(0, 0, -5)]
|
|
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
|
|
var a := TestUtils.check_eq(ranked, [2, 0, 1], "angle first, then distance")
|
|
var b := TestUtils.check_eq(NpcFocus.pick_index(origin, forward, heads), 2,
|
|
"pick_index returns the best-ranked head")
|
|
return a and b
|
|
|
|
func test_rank_excludes_out_of_cone_and_range() -> bool:
|
|
var origin := Vector3.ZERO
|
|
var forward := Vector3(0, 0, -1)
|
|
var heads := [
|
|
Vector3(0, 0, 40), # too far
|
|
Vector3(8, 0, -5), # too wide
|
|
Vector3(0, 0, 6), # behind the camera
|
|
Vector3(0.2, 0, -7), # valid
|
|
]
|
|
var ranked: Array = NpcFocus.rank_indices(origin, forward, heads)
|
|
return TestUtils.check_eq(ranked, [3], "only the in-cone head is ranked")
|
|
|
|
func test_rank_is_empty_when_nobody_is_in_the_cone() -> bool:
|
|
var ranked: Array = NpcFocus.rank_indices(
|
|
Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
|
|
var picked := NpcFocus.pick_index(Vector3.ZERO, Vector3(0, 0, -1), [Vector3(0, 0, 30)])
|
|
return TestUtils.check(ranked.is_empty() and picked == -1, "empty ranking and -1 pick")
|
|
|
|
func test_limb_swing_is_opposite_per_side() -> bool:
|
|
var left := NpcAvatar.limb_swing(PI / 2.0, 0, 1.0)
|
|
var right := NpcAvatar.limb_swing(PI / 2.0, 1, 1.0)
|
|
var still := NpcAvatar.limb_swing(0.0, 0, 1.0)
|
|
var a := TestUtils.check(absf(left - 1.0) < 0.001, "left limb swings to +amplitude")
|
|
var b := TestUtils.check(absf(right + 1.0) < 0.001, "right limb swings the other way")
|
|
var c := TestUtils.check(absf(still) < 0.001, "phase 0 is the neutral pose")
|
|
return a and b and c
|
|
|
|
func test_outfit_is_deterministic_per_seed() -> bool:
|
|
var first: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
|
|
var second: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 991)
|
|
var a := TestUtils.check(first["shirt"] == second["shirt"], "same seed, same shirt")
|
|
var b := TestUtils.check(first["pants"] == second["pants"], "same seed, same trousers")
|
|
var c := TestUtils.check(first["accessory"] == second["accessory"], "same seed, same accessory")
|
|
return a and b and c
|
|
|
|
func test_outfit_varies_across_seeds() -> bool:
|
|
var shirts := {}
|
|
var pants := {}
|
|
for seed_value in [1, 2, 3, 4, 5, 6, 7, 8]:
|
|
var outfit: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 977)
|
|
shirts[outfit["shirt"]] = true
|
|
pants[outfit["pants"]] = true
|
|
var a := TestUtils.check(shirts.size() > 1, "shirt colour varies between people")
|
|
var b := TestUtils.check(pants.size() > 1, "trouser colour varies between people")
|
|
return a and b
|
|
|
|
func test_accessories_follow_the_role() -> bool:
|
|
var a := TestUtils.check_eq(
|
|
NpcAvatar.outfit_for(Rules.NpcType.RESPONDER, 5)["accessory"], "cap",
|
|
"responders wear a cap")
|
|
var b := TestUtils.check_eq(
|
|
NpcAvatar.outfit_for(Rules.NpcType.TRESPASSER, 5)["accessory"], "hood",
|
|
"trespassers wear a hood")
|
|
var c := TestUtils.check_eq(
|
|
NpcAvatar.outfit_for(Rules.NpcType.AUTHORIZED_GUEST, 5)["accessory"], "lanyard",
|
|
"guests wear a lanyard")
|
|
var d := TestUtils.check_eq(
|
|
NpcAvatar.outfit_for(Rules.NpcType.INTOXICATED, 5)["accessory"], "none",
|
|
"roles without an accessory say none")
|
|
return a and b and c and d
|
|
|
|
func test_height_scale_stays_human() -> bool:
|
|
var ok := true
|
|
for seed_value in range(1, 40):
|
|
var scale_y: float = float(NpcAvatar.outfit_for(Rules.NpcType.TOURIST, seed_value * 131)["height_scale"])
|
|
if scale_y < 0.9 or scale_y > 1.1:
|
|
ok = false
|
|
return TestUtils.check(ok, "avatar height scale stays between 0.9 and 1.1")
|