Character and movement pass
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:
1 parent
2a4e0d1998
commit
a27e8cb74c
35 files changed
+1608
-185
No files matched your search
@@ -188,6 +188,19 @@ func _advance() -> void:
|
||||
10:
|
||||
if state_frames >= 30:
|
||||
_shoot("09-body-camera-timeline.png")
|
||||
if gm != null:
|
||||
gm.event_timeline.close()
|
||||
gm.guard.global_position = Vector3(0, 0.1, 6.2)
|
||||
gm.guard.rotation.y = 0.0
|
||||
Input.action_press("move_forward")
|
||||
Input.action_press("walk_slow")
|
||||
state = 11
|
||||
state_frames = 0
|
||||
11:
|
||||
if state_frames >= 50:
|
||||
_shoot("11-precision-walk.png")
|
||||
Input.action_release("move_forward")
|
||||
Input.action_release("walk_slow")
|
||||
quit(0)
|
||||
|
||||
## Put the guard a couple of steps in front of the target so a staged UI capture
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
extends SceneTree
|
||||
## Movement smoke (windowed): walks the guard to the entrance to check step-up,
|
||||
## measures precision-walk speed, and checks footstep/landing cues. The guard
|
||||
## reads captured-mouse input, so run it windowed:
|
||||
##
|
||||
## godot --path . --script res://src/test/smoke_movement.gd
|
||||
##
|
||||
## Exit code 0 = pass, 1 = failure.
|
||||
|
||||
const MAX_FRAMES := 1600
|
||||
var frame := 0
|
||||
var gm: Node = null
|
||||
var started := false
|
||||
var step := 0
|
||||
var step_frame := 0
|
||||
var failures: Array[String] = []
|
||||
var start_pos := Vector3.ZERO
|
||||
var normal_distance := 0.0
|
||||
var footsteps_before := 0
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
root.add_child(load("res://scenes/main.tscn").instantiate())
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frame += 1
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
return false
|
||||
if not started:
|
||||
gm.call("_on_venue_selected", "hollywood_building")
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
started = true
|
||||
return false
|
||||
if frame > MAX_FRAMES:
|
||||
_fail("timeout before the movement checks finished")
|
||||
return true
|
||||
step_frame += 1
|
||||
match step:
|
||||
0:
|
||||
if step_frame < 20:
|
||||
return false
|
||||
_place_guard(8.5)
|
||||
footsteps_before = gm.audio.footsteps
|
||||
start_pos = gm.guard.global_position
|
||||
Input.action_press("move_forward")
|
||||
step = 1
|
||||
step_frame = 0
|
||||
1:
|
||||
if step_frame >= 280:
|
||||
Input.action_release("move_forward")
|
||||
var moved := start_pos.distance_to(gm.guard.global_position)
|
||||
_check(moved > 2.0, "guard walked toward the entrance (%.1f m)" % moved)
|
||||
_check(gm.guard.global_position.y > 0.35,
|
||||
"guard climbed the steps (y=%.2f)" % gm.guard.global_position.y)
|
||||
_check(gm.guard.global_position.z < 4.2,
|
||||
"guard reached the steps (z=%.1f)" % gm.guard.global_position.z)
|
||||
_check(gm.audio.footsteps > footsteps_before,
|
||||
"footsteps are emitted while walking")
|
||||
step = 2
|
||||
step_frame = 0
|
||||
2:
|
||||
if step_frame < 45:
|
||||
return false
|
||||
_place_guard(8.5)
|
||||
normal_distance = 0.0
|
||||
start_pos = gm.guard.global_position
|
||||
Input.action_press("move_forward")
|
||||
Input.action_press("walk_slow")
|
||||
step = 3
|
||||
step_frame = 0
|
||||
3:
|
||||
if step_frame >= 120:
|
||||
Input.action_release("walk_slow")
|
||||
Input.action_release("move_forward")
|
||||
var moved := start_pos.distance_to(gm.guard.global_position)
|
||||
_check(moved > 1.0 and moved < 4.6,
|
||||
"precision walk covers less ground (%.1f m in 2 s)" % moved)
|
||||
var chip = gm.hud.precision_label
|
||||
_check(chip != null and chip.visible,
|
||||
"precision walk shows its HUD chip")
|
||||
step = 4
|
||||
step_frame = 0
|
||||
4:
|
||||
if gm.audio.landings == 0:
|
||||
gm.guard.global_position += Vector3(0, 1.2, 0)
|
||||
if step_frame >= 90:
|
||||
_check(gm.audio.landings > 0, "landing cue fires after a drop")
|
||||
_finish()
|
||||
return true
|
||||
return false
|
||||
|
||||
func _place_guard(z: float) -> void:
|
||||
gm.guard.global_position = Vector3(0, 0.1, z)
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.velocity = Vector3.ZERO
|
||||
|
||||
func _check(condition: bool, message: String) -> void:
|
||||
if condition:
|
||||
print("ok: " + message)
|
||||
else:
|
||||
_fail(message)
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures.append(message)
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
Input.action_release("move_forward")
|
||||
Input.action_release("walk_slow")
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: movement feel (steps, precision walk, footsteps)")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -0,0 +1,57 @@
|
||||
extends SceneTree
|
||||
## NPC performance budget smoke: put the documented live-NPC count in the scene,
|
||||
## let the animations run, then report the average frame time. The budget is
|
||||
## 50 ms (20 FPS) so slow CI hardware does not fail on noise; the report is the
|
||||
## useful part when tuning the rig.
|
||||
##
|
||||
## Usage: godot --headless --path . --script res://src/test/smoke_npc_budget.gd
|
||||
|
||||
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")
|
||||
const COUNT := 16
|
||||
const WARMUP_FRAMES := 60
|
||||
const SAMPLE_FRAMES := 180
|
||||
const BUDGET_MS := 50.0
|
||||
|
||||
var frame := 0
|
||||
var avatars: Array = []
|
||||
var sample_ms := 0.0
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var stage := Node3D.new()
|
||||
stage.name = "BudgetStage"
|
||||
root.add_child(stage)
|
||||
for index in COUNT:
|
||||
var profile := NpcProfile.new()
|
||||
profile.id = "budget_%d" % index
|
||||
profile.npc_type = Rules.NpcType.TOURIST
|
||||
profile.display_name = "Budget %d" % index
|
||||
var avatar := NpcAvatar.new()
|
||||
avatar.setup(profile, "roam", index + 1,
|
||||
Vector3(-6.0 + (index % 8) * 1.7, 0.5, 4.0 + (index / 8) * 2.0),
|
||||
Vector3(0, 0.5, 8.0), Vector3(0, 0.5, 1.2), profile.id)
|
||||
stage.add_child(avatar)
|
||||
avatars.append(avatar)
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
frame += 1
|
||||
if frame > WARMUP_FRAMES:
|
||||
sample_ms += delta * 1000.0
|
||||
if frame >= WARMUP_FRAMES + SAMPLE_FRAMES:
|
||||
var average := sample_ms / SAMPLE_FRAMES
|
||||
var meshes := 0
|
||||
for avatar in avatars:
|
||||
meshes += avatar.find_children("*", "MeshInstance3D", true, false).size()
|
||||
print("NPC budget: %d avatars, %d meshes, avg frame %.2f ms (budget %.0f ms)" % [
|
||||
COUNT, meshes, average, BUDGET_MS])
|
||||
if average > BUDGET_MS:
|
||||
printerr("SMOKE FAIL: frame time %.1f ms exceeds the %.0f ms budget" % [
|
||||
average, BUDGET_MS])
|
||||
quit(1)
|
||||
return true
|
||||
print("SMOKE OK: %d avatars stay inside the frame budget" % COUNT)
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
@@ -50,9 +50,8 @@ func _process(_delta: float) -> bool:
|
||||
var start := bench_center + Vector3(0, 0, 2.2)
|
||||
start.y = 0.1
|
||||
gm.guard.global_position = start
|
||||
# Face the bench (-z): with the guard's basis, forward input at yaw PI
|
||||
# walks toward lower z.
|
||||
gm.guard.rotation.y = PI
|
||||
# Face the bench (-z): yaw 0 looks down -z, so W walks toward it.
|
||||
gm.guard.rotation.y = 0.0
|
||||
guard_start = start
|
||||
Input.action_press("move_forward")
|
||||
step = 1
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user