Character and movement pass
Full build / Full desktop build + optional release (push) Successful in 3m37s
Full build / Import + headless tests (push) Successful in 33s

NPC characters
- Articulated rig (hips/knees, shoulders/elbows/hands) replaces rigid capsules;
  the walk cycle bends knees and elbows and flees with a longer stride.
- Layered outfits (jacket/vest/coat/dress), extended accessories and hand items
  (bag, umbrella, phone, camera, clipboard, cup), face details and five hair
  silhouettes, all deterministic per profile seed.
- Idle behaviour layers (phone, clipboard, crossed arms, look-around), breathing
  and weight shift; escalation raises hands or points, and noticed NPCs turn
  toward the guard.
- Reaction symbols above the head (!!, ?!, !, ?), door-queue offsets assigned by
  GameManager, three LOD tiers with hysteresis and a billboard impostor.
- smoke_npc_budget.gd: 16 avatars = 534 meshes, ~6.9 ms average (50 ms budget).

Player movement
- Separate accel/decel, step-up assist for kerbs and the entrance steps, and a
  forward-axis fix: W was inverted (get_axis(forward, back) is negative).
- Head bob, landing dip and precision-walk FOV, gated by persisted head-bob and
  invert-Y settings; precision chip and tick; surface-aware footsteps and a
  landing cue forwarded to AudioFeedback.
- Gamepad support (sticks, deadzone, face buttons, bumpers), a control legend
  that follows the active device, and named actions for the remaining keys.
- Key/button remapping in Settings, persisted as input_bindings and applied by
  InputBindings (mouse and gamepad axes are never removed).
- smoke_movement.gd (windowed): climbs the steps, checks precision speed, chip,
  footsteps and landing; new 11-precision-walk capture and catalogue entry.

Verification: headless 208/208, Python tooling 56/56, all 11 smokes pass,
compare 11 good / 0 bad, movement judge match. Version 0.5.0. Only optional
Future ideas remain in TODO.
This commit is contained in:
zwitschi committed 2026-11-01 10:41:17 +01:00
1 parent 2a4e0d1998
commit a27e8cb74c
35 files changed
+1608 -185

No files matched your search

+12
View File
@@ -5,6 +5,18 @@ extends RefCounted
const AudioFeedback := preload("res://src/game/audio_feedback.gd")
const CrowdSystem := preload("res://src/core/crowd_system.gd")
func test_footstep_and_landing_cues_count() -> bool:
var audio := AudioFeedback.new()
audio.play_footstep("concrete")
audio.play_footstep("asphalt")
audio.play_land()
audio.play_precision_tick()
var a := TestUtils.check_eq(audio.footsteps, 2, "footsteps are counted")
var b := TestUtils.check_eq(audio.landings, 1, "landings are counted")
var c := TestUtils.check_eq(audio.precision_ticks, 1, "precision ticks are counted")
audio.free()
return a and b and c
func test_bed_volume_follows_mood() -> bool:
var quiet: float = AudioFeedback._level_to_db(0.0)
var loud: float = AudioFeedback._level_to_db(1.0)
+53
View File
@@ -0,0 +1,53 @@
extends RefCounted
## Headless tests for the key/button remap helper.
const InputBindings := preload("res://src/core/input_bindings.gd")
func test_descriptor_from_events() -> bool:
var key := InputEventKey.new()
key.physical_keycode = KEY_Z
var a := TestUtils.check_eq(InputBindings.descriptor_from_event(key),
{"type": "key", "code": int(KEY_Z)}, "keys store a code")
var button := InputEventJoypadButton.new()
button.button_index = JOY_BUTTON_Y
var b := TestUtils.check_eq(InputBindings.descriptor_from_event(button),
{"type": "joy", "button": int(JOY_BUTTON_Y)}, "pad buttons store an index")
var c := TestUtils.check(
InputBindings.descriptor_from_event(InputEventMouseButton.new()).is_empty(),
"mouse buttons are not remappable")
return a and b and c
func test_apply_rebinds_and_keeps_mouse_events() -> bool:
var old := InputBindings.current_descriptor("inspect")
var remap := {"inspect": {"type": "key", "code": int(KEY_Z)}}
InputBindings.apply(remap)
var rebound := InputBindings.current_descriptor("inspect")
var a := TestUtils.check_eq(rebound, {"type": "key", "code": int(KEY_Z)},
"inspect now uses the new key")
var action_menu_mouse := 0
for event in InputMap.action_get_events("action_menu"):
if event is InputEventMouseButton:
action_menu_mouse += 1
var b := TestUtils.check(action_menu_mouse > 0,
"remapping leaves mouse events alone")
# Restore the original binding for later tests.
if not old.is_empty():
InputBindings.apply({"inspect": old})
return a and b
func test_apply_accepts_pad_buttons() -> bool:
var remap := {"wand": {"type": "joy", "button": int(JOY_BUTTON_RIGHT_SHOULDER)}}
InputBindings.apply(remap)
var a := TestUtils.check_eq(InputBindings.current_descriptor("wand"),
{"type": "joy", "button": int(JOY_BUTTON_RIGHT_SHOULDER)},
"actions can be rebound to a pad button")
InputBindings.apply({"wand": {"type": "key", "code": int(KEY_C)}})
return a
func test_labels_are_human_readable() -> bool:
var a := TestUtils.check(InputBindings.label_for(
{"type": "key", "code": int(KEY_W)}) == "W", "key label")
var b := TestUtils.check(InputBindings.label_for(
{"type": "joy", "button": 3}) == "Pad 3", "pad label")
var c := TestUtils.check_eq(InputBindings.label_for({}), "", "empty label")
return a and b and c
+138
View File
@@ -0,0 +1,138 @@
extends RefCounted
## Headless tests for the character pass: layered outfits, hand items, idle
## styles, queue slots, LOD tiers/labels, and the articulated rig structure.
const NpcAvatar := preload("res://src/game/npc_avatar.gd")
const NpcProfile := preload("res://src/core/npc_profile.gd")
const Rules := preload("res://src/core/rules.gd")
func _avatar(npc_type: int, seed_value: int) -> NpcAvatar:
var profile := NpcProfile.new()
profile.id = "char_test"
profile.npc_type = npc_type
profile.display_name = "Test Person"
var avatar := NpcAvatar.new()
avatar.setup(profile, "enter", seed_value,
Vector3(0, 0.5, 6), Vector3(0, 0.5, 1.5), Vector3(0, 0.5, 1.2), "char_test")
return avatar
func test_outfit_layers_and_items_are_deterministic() -> bool:
var a: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242)
var b: Dictionary = NpcAvatar.outfit_for(Rules.NpcType.TOURIST, 4242)
return TestUtils.check(
a["layer"] == b["layer"] and a["hand_item"] == b["hand_item"]
and a["hair_style"] == b["hair_style"],
"outfit layers, hand item and hair style follow the seed")
func test_layers_follow_roles() -> bool:
var a := TestUtils.check_eq(
NpcAvatar.layer_for(Rules.NpcType.RESPONDER, 1), "vest",
"responders wear a hi-vis vest")
var b := TestUtils.check_eq(
NpcAvatar.layer_for(Rules.NpcType.INFLUENCER, 1), "coat",
"influencers wear a coat")
var c := TestUtils.check_eq(
NpcAvatar.layer_for(Rules.NpcType.AUTHORIZED_STAFF, 1), "jacket",
"staff wear a jacket")
return a and b and c
func test_hand_items_are_known_and_deterministic() -> bool:
var allowed := ["phone", "cup", "clipboard", "umbrella", "camera", "none"]
var ok := true
for seed_value in range(1, 30):
var item := NpcAvatar.hand_item_for(Rules.NpcType.TOURIST, seed_value * 31)
ok = ok and item in allowed
ok = ok and item == NpcAvatar.hand_item_for(
Rules.NpcType.TOURIST, seed_value * 31)
return TestUtils.check(ok, "hand items stay in the catalogue and are deterministic")
func test_idle_styles_are_known() -> bool:
var allowed := ["phone", "crossed", "look", "clipboard", "breathe"]
var ok := true
for seed_value in range(1, 30):
var style := NpcAvatar.idle_style_for(Rules.NpcType.TOURIST, seed_value) \
if seed_value > 1 else NpcAvatar.idle_style_for(Rules.NpcType.INFLUENCER, 1)
ok = ok and style in allowed
return TestUtils.check(ok, "idle styles stay in the catalogue")
func test_queue_slots_are_spaced_and_unique() -> bool:
var seen := {}
var ok := true
var previous := NpcAvatar.queue_slot_for(0)
for index in 6:
var slot := NpcAvatar.queue_slot_for(index)
ok = ok and not seen.has(slot)
seen[slot] = true
if index > 0:
ok = ok and slot.distance_to(previous) > 0.3
previous = slot
return TestUtils.check(ok and NpcAvatar.queue_slot_for(0) == Vector3.ZERO,
"queue slots are unique, spaced, and start at the door")
func test_lod_tiers_use_hysteresis() -> bool:
var avatar := NpcAvatar.new()
avatar._lod_tier = 0
var a := TestUtils.check_eq(avatar._lod_tier_for(5.0), 0, "near stays full detail")
var b := TestUtils.check_eq(avatar._lod_tier_for(16.0), 1,
"past the hysteresis band switches to simplified")
avatar._lod_tier = 1
var c := TestUtils.check_eq(avatar._lod_tier_for(13.0), 1,
"hysteresis keeps the simplified tier just inside the edge")
var d := TestUtils.check_eq(avatar._lod_tier_for(12.0), 0,
"clearing the hysteresis band returns to full detail")
avatar._lod_tier = 1
var e := TestUtils.check_eq(avatar._lod_tier_for(30.0), 2, "far uses the impostor")
avatar.free()
return a and b and c and d and e
func test_reaction_labels_match_state() -> bool:
var a := TestUtils.check_eq(NpcAvatar.reaction_label("flee", 0), "!!", "panic label")
var b := TestUtils.check_eq(NpcAvatar.reaction_label("watch", 0), "?!", "watching label")
var c := TestUtils.check_eq(NpcAvatar.reaction_label("", 3), "!", "blocked label")
var d := TestUtils.check_eq(NpcAvatar.reaction_label("", 0), "", "calm has no label")
return a and b and c and d
func test_walk_to_idle_animation_transition() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 21)
var hip := avatar.find_child("HipL", true, false) as Node3D
var a := TestUtils.check(hip != null, "rig has a hip pivot")
avatar.velocity = Vector3(0, 0, 1.2)
avatar._animate(0.016)
var walking := absf(hip.rotation.x) > 0.01
var b := TestUtils.check(walking, "walking swings the hip")
avatar.velocity = Vector3.ZERO
for i in 90:
avatar._animate(0.016)
var c := TestUtils.check(absf(hip.rotation.x) < 0.05,
"stopping settles the hip back to the idle pose (%.3f)" % hip.rotation.x)
avatar.free()
return a and b and c
func test_rig_is_articulated() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 77)
var a := TestUtils.check(avatar.find_child("HipL", true, false) != null
and avatar.find_child("Knee", true, false) != null,
"legs have hip and knee pivots")
var b := TestUtils.check(avatar.find_child("ShoulderR", true, false) != null
and avatar.find_child("Elbow", true, false) != null
and avatar.find_child("Hand", true, false) != null,
"arms have shoulder, elbow and hand pivots")
var c := TestUtils.check(avatar.get_node_or_null("Rig/Head") != null,
"the focus code can still find Rig/Head")
avatar.free()
return a and b and c
func test_avatar_mesh_budget_and_lod_swap() -> bool:
var avatar := _avatar(Rules.NpcType.TOURIST, 12)
var meshes := avatar.find_children("*", "MeshInstance3D", true, false)
var a := TestUtils.check(meshes.size() <= 40,
"avatar stays inside the mesh budget (%d)" % meshes.size())
avatar.apply_lod(2)
var impostor := avatar.get_node_or_null("Impostor") as Node3D
var rig := avatar.get_node_or_null("Rig") as Node3D
var b := TestUtils.check(impostor != null and impostor.visible, "far LOD shows the impostor")
var c := TestUtils.check(rig != null and not rig.visible, "far LOD hides the full rig")
avatar.apply_lod(0)
var d := TestUtils.check(rig.visible and not impostor.visible, "near LOD restores the rig")
avatar.free()
return a and b and c and d
+35 -1
View File
@@ -43,11 +43,45 @@ func test_settings_round_trip() -> bool:
save.free()
return a and b and c
func test_camera_settings_round_trip() -> bool:
var save := _save_instance()
var err: int = save.save_settings({
"look_sensitivity": 0.0028, "hud_font_scale": 1.0,
"invert_y": true, "head_bob": false,
})
var a := TestUtils.check_eq(err, OK, "camera settings save")
var loaded: Dictionary = save.load_settings()
var b := TestUtils.check_eq(bool(loaded.get("invert_y", false)), true,
"invert_y survives the round trip")
var c := TestUtils.check_eq(bool(loaded.get("head_bob", true)), false,
"head_bob survives the round trip")
save.save_settings(save.DEFAULT_SETTINGS)
save.free()
return a and b and c
func test_input_bindings_round_trip() -> bool:
var save := _save_instance()
var bindings := {"inspect": {"type": "key", "code": int(KEY_Z)}}
var err: int = save.save_settings({"input_bindings": bindings})
var a := TestUtils.check_eq(err, OK, "bindings save")
var loaded: Dictionary = save.load_settings()
var stored: Dictionary = loaded.get("input_bindings", {})
var inspect: Dictionary = stored.get("inspect", {})
var b := TestUtils.check_eq(int(inspect.get("code", 0)), int(KEY_Z),
"bindings survive the settings round trip")
var c := TestUtils.check_eq(str(inspect.get("type", "")), "key",
"binding type survives the settings round trip")
save.save_settings(save.DEFAULT_SETTINGS)
save.free()
return a and b and c
func test_settings_defaults_present() -> bool:
var save := _save_instance()
var defaults: Dictionary = save.load_settings()
var ok := TestUtils.check(
defaults.has("look_sensitivity") and defaults.has("hud_font_scale"),
defaults.has("look_sensitivity") and defaults.has("hud_font_scale")
and defaults.has("invert_y") and defaults.has("head_bob")
and defaults.has("input_bindings"),
"settings have documented defaults")
save.free()
return ok
+54
View File
@@ -0,0 +1,54 @@
extends RefCounted
## Headless tests for movement constants, surface lookup, gamepad bindings, and
## the settings that drive the camera feel.
const GuardController := preload("res://src/game/guard_controller.gd")
func test_surface_lookup_covers_the_block() -> bool:
var guard := GuardController.new()
var a := TestUtils.check_eq(guard._surface_at(Vector3(0, 0.2, 2.0)), "stone",
"the entrance steps are stone")
var b := TestUtils.check_eq(guard._surface_at(Vector3(3, 0.2, 8.0)), "concrete",
"the sidewalk is concrete")
var c := TestUtils.check_eq(guard._surface_at(Vector3(2, 0.1, 18.0)), "asphalt",
"the roadway is asphalt")
guard.free()
return a and b and c
func test_step_and_stride_stay_modest() -> bool:
var a := TestUtils.check(GuardController.STEP_HEIGHT <= 0.35,
"step-up stays at kerb height")
var b := TestUtils.check(GuardController.STRIDE_LENGTH >= 0.5
and GuardController.STRIDE_LENGTH <= 1.2, "stride stays human-sized")
var c := TestUtils.check(GuardController.SLOW_WALK_FACTOR < 1.0
and GuardController.SLOW_WALK_FACTOR > 0.3, "precision walk is slower but usable")
return a and b and c
func test_gamepad_bindings_are_added_once() -> bool:
GuardController._ensure_gamepad_bindings()
GuardController._ensure_gamepad_bindings()
var expected := ["look_left", "look_right", "look_up", "look_down",
"move_forward", "move_back", "move_left", "move_right"]
var problems: Array[String] = []
for action in expected:
if not InputMap.has_action(action):
problems.append("%s missing" % action)
continue
var motions := 0
for event in InputMap.action_get_events(action):
if event is InputEventJoypadMotion:
motions += 1
if motions == 0:
problems.append("%s has no joypad axis" % action)
if InputMap.action_get_deadzone(action) > 0.25:
problems.append("%s deadzone too high" % action)
var buttons := {"action_menu": JOY_BUTTON_A, "cancel": JOY_BUTTON_B,
"inspect": JOY_BUTTON_X, "walk_slow": JOY_BUTTON_LEFT_SHOULDER}
for action in buttons.keys():
var found := false
for event in InputMap.action_get_events(action):
if event is InputEventJoypadButton and event.button_index == buttons[action]:
found = true
if not found:
problems.append("%s has no gamepad button" % action)
return TestUtils.check(problems.is_empty(), "gamepad actions bound (%s)" % str(problems))