Implement control pass: focus cone, action wheel, named inputs, boot fixes
- Venue-correct access artifacts (AccessLoadout) and screened-aware verify - Cone focus on NPC heads with billboarded ring + display name (ADR-0008) - Action wheel with de-escalation-first slices (ADR-0009); Esc/RMB cancel - Procedural NPC face/hair/neck/shoes (ADR-0010); named input actions - Fix GameManager parse error (radial typed as ActionRadial) - Fix first-person camera height and guard spawning inside the building - Move building volume back so the door/awning/marquee are visible - Pull the event scheduler once per simulated second; cap live NPCs - Regenerate README screenshots; update TODO/DONE/MEMORY/README; 77 tests Adds src/test/smoke_focus_radial.gd for the windowed focus/radial smoke.
This commit is contained in:
1 parent
69484866db
commit
f11b441c07
28 files changed
+1433
-249
No files matched your search
@@ -31,8 +31,9 @@ func _process(_delta: float) -> bool:
|
||||
quit(1)
|
||||
return true
|
||||
if gm != null and not camera_staged:
|
||||
gm.guard.global_position = Vector3(0, 1.6, 5.0)
|
||||
gm.guard.head.rotation.x = deg_to_rad(-8.0)
|
||||
gm.guard.global_position = Vector3(0, 0.1, 10.5)
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||
camera_staged = true
|
||||
if frames > MAX_FRAMES:
|
||||
print("timeout reached")
|
||||
@@ -54,6 +55,7 @@ func _advance() -> void:
|
||||
# Wait for a nearby NPC so the interaction HUD has a target.
|
||||
if gm != null and gm.npcs.size() > 0:
|
||||
gm.call("_cycle_nearby_target")
|
||||
_face_target()
|
||||
targeted = true
|
||||
state = 2
|
||||
state_frames = 0
|
||||
@@ -61,6 +63,7 @@ func _advance() -> void:
|
||||
if state_frames >= 30:
|
||||
_shoot("02-targeting-npc.png")
|
||||
if targeted and gm != null:
|
||||
_face_target()
|
||||
gm.call("_toggle_inspection")
|
||||
state = 3
|
||||
state_frames = 0
|
||||
@@ -80,6 +83,8 @@ func _advance() -> void:
|
||||
# Close the radio form, then jump the shift to the LATE phase.
|
||||
if gm != null:
|
||||
gm.radio_panel.hide()
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||
gm.coordinator.elapsed_s = 750.0
|
||||
state = 5
|
||||
state_frames = 0
|
||||
@@ -87,6 +92,8 @@ func _advance() -> void:
|
||||
# Let the phase transition (and street lamps / marquee) settle, then
|
||||
# take the night avenue shot.
|
||||
if state_frames >= 90:
|
||||
gm.guard.rotation.y = 0.0
|
||||
gm.guard.head.rotation.x = deg_to_rad(2.0)
|
||||
_shoot("05-late-night-avenue.png")
|
||||
if gm != null:
|
||||
gm.call("_toggle_flashlight")
|
||||
@@ -98,6 +105,17 @@ func _advance() -> void:
|
||||
state = 7
|
||||
quit(0)
|
||||
|
||||
func _face_target() -> void:
|
||||
var target: Node3D = gm.targeted_npc
|
||||
if target == null:
|
||||
return
|
||||
var to: Vector3 = target.global_position - gm.guard.global_position
|
||||
to.y = 0.0
|
||||
if to.length() < 0.01:
|
||||
return
|
||||
gm.guard.rotation.y = atan2(-to.x, -to.z)
|
||||
gm.guard.head.rotation.x = deg_to_rad(-4.0)
|
||||
|
||||
func _shoot(file_name: String) -> void:
|
||||
var dir := DirAccess.open("user://")
|
||||
if dir == null or not dir.dir_exists("screenshots"):
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
extends SceneTree
|
||||
## Windowed smoke helper for the ADR-0007–0010 control pass (focus ring + action
|
||||
## wheel). Boots the real main scene, stages one NPC in front of the guard, opens
|
||||
## the wheel, and saves two PNGs to user://screenshots/. Exits 1 if the focus or
|
||||
## radial state did not come up, so it can be used as a smoke check.
|
||||
## Usage: godot --path . --script res://src/test/smoke_focus_radial.gd
|
||||
|
||||
const OUT_DIR := "user://screenshots"
|
||||
const MAX_FRAMES := 1800
|
||||
const STAGE_DISTANCE := 2.4
|
||||
|
||||
var frames := 0
|
||||
var gm: Node = null
|
||||
var state := 0
|
||||
var state_frames := 0
|
||||
var stage_npc: Node3D = null
|
||||
var failures: Array[String] = []
|
||||
|
||||
func _initialize() -> void:
|
||||
root.size = Vector2i(1280, 720)
|
||||
var packed: PackedScene = load("res://scenes/main.tscn")
|
||||
root.add_child(packed.instantiate())
|
||||
|
||||
func _process(_delta: float) -> bool:
|
||||
frames += 1
|
||||
if gm == null:
|
||||
gm = root.find_child("GameManager", true, false)
|
||||
if gm == null:
|
||||
if frames > 600:
|
||||
_fail("GameManager not found")
|
||||
return false
|
||||
if frames > MAX_FRAMES:
|
||||
_fail("timeout before the radial state")
|
||||
return true
|
||||
_advance()
|
||||
return false
|
||||
|
||||
func _advance() -> void:
|
||||
state_frames += 1
|
||||
match state:
|
||||
0:
|
||||
# Wait for the guard to settle on the ground, then grab one NPC.
|
||||
if state_frames >= 40 and gm.npcs.size() > 0:
|
||||
print("smoke: spawn guard_y=%.2f z=%.2f" % [
|
||||
(gm.guard.global_position as Vector3).y,
|
||||
(gm.guard.global_position as Vector3).z,
|
||||
])
|
||||
_shoot("smoke-00-spawn.png")
|
||||
stage_npc = gm.npcs[0]
|
||||
_hide_other_npcs(stage_npc)
|
||||
state = 1
|
||||
state_frames = 0
|
||||
1:
|
||||
# Keep the staged NPC in front of the guard so the ring is visible.
|
||||
_stage_target()
|
||||
if state_frames >= 20:
|
||||
gm.call("_cycle_nearby_target")
|
||||
if gm.targeted_npc == null:
|
||||
_fail("no NPC focused")
|
||||
elif gm.radial == null:
|
||||
_fail("radial layer missing")
|
||||
else:
|
||||
print("smoke: guard_y=%.2f npc_y=%.2f" % [
|
||||
(gm.guard.global_position as Vector3).y,
|
||||
(gm.targeted_npc.global_position as Vector3).y,
|
||||
])
|
||||
_shoot("smoke-01-focus-ring.png")
|
||||
gm.call("_open_radial")
|
||||
state = 2
|
||||
state_frames = 0
|
||||
2:
|
||||
if state_frames >= 20:
|
||||
if not gm.radial.is_open:
|
||||
_fail("action wheel did not open")
|
||||
else:
|
||||
_shoot("smoke-02-action-wheel.png")
|
||||
gm.call("_cancel_radial")
|
||||
state = 3
|
||||
state_frames = 0
|
||||
3:
|
||||
if state_frames >= 5:
|
||||
if gm.radial.is_open:
|
||||
_fail("action wheel did not close on cancel")
|
||||
_finish()
|
||||
|
||||
func _stage_target() -> void:
|
||||
if stage_npc == null or not is_instance_valid(stage_npc):
|
||||
stage_npc = gm.npcs[0] if gm.npcs.size() > 0 else null
|
||||
if stage_npc == null:
|
||||
return
|
||||
_hide_other_npcs(stage_npc)
|
||||
var guard_pos: Vector3 = gm.guard.global_position
|
||||
var forward: Vector3 = -gm.guard.global_transform.basis.z
|
||||
forward.y = 0.0
|
||||
forward = forward.normalized()
|
||||
stage_npc.global_position = guard_pos + forward * STAGE_DISTANCE + Vector3(0, 0.4, 0)
|
||||
stage_npc.rotation.y = gm.guard.rotation.y + PI
|
||||
|
||||
func _hide_other_npcs(keep: Node3D) -> void:
|
||||
for npc in gm.npcs:
|
||||
if is_instance_valid(npc) and npc is Node3D and npc != keep:
|
||||
(npc as Node3D).visible = false
|
||||
|
||||
func _shoot(file_name: String) -> void:
|
||||
var dir := DirAccess.open("user://")
|
||||
if dir == null or not dir.dir_exists("screenshots"):
|
||||
dir.make_dir_recursive("screenshots")
|
||||
var img: Image = root.get_texture().get_image()
|
||||
var err: int = img.save_png(OUT_DIR.path_join(file_name))
|
||||
print("%s -> err=%d (%dx%d)" % [file_name, err, img.get_width(), img.get_height()])
|
||||
|
||||
func _fail(message: String) -> void:
|
||||
failures.append(message)
|
||||
_finish()
|
||||
|
||||
func _finish() -> void:
|
||||
if failures.is_empty():
|
||||
print("SMOKE OK: focus ring + action wheel opened and cancelled")
|
||||
quit(0)
|
||||
else:
|
||||
for message in failures:
|
||||
printerr("SMOKE FAIL: " + message)
|
||||
quit(1)
|
||||
@@ -0,0 +1,134 @@
|
||||
extends RefCounted
|
||||
## Venue-correct access artifacts (override_access, no free recognition on guests).
|
||||
|
||||
const Rules := preload("res://src/core/rules.gd")
|
||||
const AccessLoadout := preload("res://src/core/access_loadout.gd")
|
||||
const AccessVerifier := preload("res://src/core/access_verifier.gd")
|
||||
const ScenarioFactory := preload("res://src/content/scenario_factory.gd")
|
||||
const NpcProfile := preload("res://src/core/npc_profile.gd")
|
||||
|
||||
var verifier := AccessVerifier.new()
|
||||
|
||||
func _guest() -> NpcProfile:
|
||||
var p := NpcProfile.new()
|
||||
p.id = "authorized_guest"
|
||||
p.display_name = "Authorized Guest"
|
||||
p.npc_type = Rules.NpcType.AUTHORIZED_GUEST
|
||||
return p
|
||||
|
||||
func _staff() -> NpcProfile:
|
||||
var p := NpcProfile.new()
|
||||
p.id = "authorized_staff"
|
||||
p.display_name = "Staff Member"
|
||||
p.npc_type = Rules.NpcType.AUTHORIZED_STAFF
|
||||
return p
|
||||
|
||||
func _kinds(artifacts: Array) -> Array:
|
||||
var out: Array = []
|
||||
for a in artifacts:
|
||||
out.append(a.kind)
|
||||
return out
|
||||
|
||||
func test_joke_guest_valid_needs_screen_then_grants() -> bool:
|
||||
var policy = ScenarioFactory.joke_shop().access_policy
|
||||
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": true, "has_ticket": true})
|
||||
var has_rec := false
|
||||
for art in arts:
|
||||
if art.kind == Rules.AccessKind.RECOGNITION:
|
||||
has_rec = true
|
||||
var a := TestUtils.check(not has_rec, "no free recognition")
|
||||
var before := verifier.verify(arts, policy, false)
|
||||
var after := verifier.verify(arts, policy, true)
|
||||
var b := TestUtils.check_eq(before["granted"], false, "unscreened valid guest denied")
|
||||
var c := TestUtils.check_eq(after["granted"], true, "screened valid guest granted")
|
||||
return a and b and c
|
||||
|
||||
func test_joke_guest_no_id_denied_even_when_screened() -> bool:
|
||||
var policy = ScenarioFactory.joke_shop().access_policy
|
||||
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_id": false, "has_ticket": true})
|
||||
var result := verifier.verify(arts, policy, true)
|
||||
return TestUtils.check_eq(result["granted"], false, "no-id guest denied")
|
||||
|
||||
func test_joke_guest_expired_id_denied() -> bool:
|
||||
var policy = ScenarioFactory.joke_shop().access_policy
|
||||
var arts: Array = AccessLoadout.build(_guest(), policy, {
|
||||
"has_id": true, "has_ticket": true, "id_status": Rules.ArtifactStatus.EXPIRED,
|
||||
})
|
||||
var result := verifier.verify(arts, policy, true)
|
||||
return TestUtils.check_eq(result["granted"], false, "expired id denied")
|
||||
|
||||
func test_joke_event_payload_matches_factory() -> bool:
|
||||
var shop = ScenarioFactory.joke_shop()
|
||||
var found_expired := false
|
||||
var found_no_id := false
|
||||
for event in shop.event_pool:
|
||||
var ov: Dictionary = event.get("override_access", {})
|
||||
if event.get("id", "") == "guest_expired_id":
|
||||
found_expired = int(ov.get("id_status", -1)) == Rules.ArtifactStatus.EXPIRED
|
||||
if event.get("id", "") == "guest_no_id":
|
||||
found_no_id = ov.get("has_id", true) == false
|
||||
var a := TestUtils.check(found_expired, "guest_expired_id carries expired status")
|
||||
var b := TestUtils.check(found_no_id, "guest_no_id has has_id false")
|
||||
return a and b
|
||||
|
||||
func test_joke_staff_recognized_without_override() -> bool:
|
||||
var policy = ScenarioFactory.joke_shop().access_policy
|
||||
var arts: Array = AccessLoadout.build(_staff(), policy, {})
|
||||
var has_rec := false
|
||||
for a in arts:
|
||||
if a.kind == Rules.AccessKind.RECOGNITION and a.status == Rules.ArtifactStatus.VALID:
|
||||
has_rec = true
|
||||
var screened := verifier.verify(arts, policy, true)
|
||||
return TestUtils.check(has_rec, "staff are recognized") and TestUtils.check_eq(screened["granted"], true, "recognized staff enter after screen")
|
||||
|
||||
func test_building_guest_uses_wristband_not_recognition() -> bool:
|
||||
var policy = ScenarioFactory.hollywood_building().access_policy
|
||||
var arts: Array = AccessLoadout.build(_guest(), policy, {})
|
||||
var has_rec := false
|
||||
var has_band := false
|
||||
for a in arts:
|
||||
if a.kind == Rules.AccessKind.RECOGNITION:
|
||||
has_rec = true
|
||||
if a.kind == Rules.AccessKind.WRISTBAND and a.status == Rules.ArtifactStatus.VALID:
|
||||
has_band = true
|
||||
var result := verifier.verify(arts, policy)
|
||||
var a := TestUtils.check(not has_rec, "building guest is not auto-recognized")
|
||||
var b := TestUtils.check(has_band, "building guest presents a wristband")
|
||||
var c := TestUtils.check_eq(result["granted"], true, "valid wristband grants")
|
||||
return a and b and c
|
||||
|
||||
func test_studio_ticket_grants_without_recognition() -> bool:
|
||||
var policy = ScenarioFactory.tv_studio().access_policy
|
||||
var arts: Array = AccessLoadout.build(_guest(), policy, {"has_ticket": true})
|
||||
var has_rec := false
|
||||
for a in arts:
|
||||
if a.kind == Rules.AccessKind.RECOGNITION:
|
||||
has_rec = true
|
||||
var result := verifier.verify(arts, policy)
|
||||
return TestUtils.check(not has_rec, "audience ticket is not recognition") and TestUtils.check_eq(result["granted"], true, "ticket grants at studio")
|
||||
|
||||
func test_studio_counterfeit_denied() -> bool:
|
||||
var policy = ScenarioFactory.tv_studio().access_policy
|
||||
var arts: Array = AccessLoadout.build(_guest(), policy, {
|
||||
"wristband_status": Rules.ArtifactStatus.COUNTERFEIT,
|
||||
"credential_status": Rules.ArtifactStatus.COUNTERFEIT,
|
||||
})
|
||||
var result := verifier.verify(arts, policy)
|
||||
return TestUtils.check_eq(result["granted"], false, "counterfeit studio artifacts deny")
|
||||
|
||||
func test_carried_items_override_and_roll() -> bool:
|
||||
var forced: Array = AccessLoadout.roll_carried_items(1, {"carried_items": ["weapon"]})
|
||||
var a := TestUtils.check_eq(forced, ["weapon"], "override items win")
|
||||
var rolled: Array = AccessLoadout.roll_carried_items(42, {})
|
||||
var b := TestUtils.check(rolled is Array, "roll returns an array")
|
||||
var again: Array = AccessLoadout.roll_carried_items(42, {})
|
||||
var c := TestUtils.check_eq(rolled, again, "same seed same pocket")
|
||||
return a and b and c
|
||||
|
||||
func test_joke_shop_sidewalk_exists() -> bool:
|
||||
var shop = ScenarioFactory.joke_shop()
|
||||
var studio = ScenarioFactory.tv_studio()
|
||||
var a := TestUtils.check(shop.locations.has("sidewalk"), "joke shop sidewalk")
|
||||
var b := TestUtils.check(studio.locations.has("sidewalk"), "studio sidewalk")
|
||||
var c := TestUtils.check_eq(shop.waypoint_for_intent("film").z, shop.locations["sidewalk"].z, "film walks to sidewalk")
|
||||
return a and b and c
|
||||
@@ -0,0 +1,100 @@
|
||||
extends RefCounted
|
||||
## Focus cone math and radial menu wrap/cancel (ADR-0008, ADR-0009).
|
||||
|
||||
const NpcFocus := preload("res://src/game/npc_focus.gd")
|
||||
const ActionRadial := preload("res://src/game/ui/action_radial.gd")
|
||||
|
||||
func test_cone_picks_centered_head() -> bool:
|
||||
var origin := Vector3(0, 1.6, 0)
|
||||
var forward := Vector3(0, 0, -1)
|
||||
var heads: Array = [
|
||||
Vector3(3, 1.6, -4),
|
||||
Vector3(0.05, 1.6, -4),
|
||||
Vector3(-2, 1.6, -4),
|
||||
]
|
||||
var idx := NpcFocus.pick_index(origin, forward, heads)
|
||||
return TestUtils.check_eq(idx, 1, "head nearest the look axis wins")
|
||||
|
||||
func test_cone_rejects_behind_and_far() -> bool:
|
||||
var origin := Vector3(0, 1.6, 0)
|
||||
var forward := Vector3(0, 0, -1)
|
||||
var behind := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, 4))
|
||||
var far := NpcFocus.score_head(origin, forward, Vector3(0, 1.6, -40))
|
||||
var a := TestUtils.check_eq(behind, -1.0, "behind the camera is ignored")
|
||||
var b := TestUtils.check_eq(far, -1.0, "out of range is ignored")
|
||||
return a and b
|
||||
|
||||
func test_radial_wrap_and_skip_wand() -> bool:
|
||||
var menu: Node = ActionRadial.new()
|
||||
menu.open_menu("Guest", false)
|
||||
var start: int = menu.selected
|
||||
menu.step(-1)
|
||||
var a := TestUtils.check(menu.selected != start, "step wrap moves selection")
|
||||
var b := TestUtils.check(menu.is_slice_enabled(menu.selected), "landed on an enabled slice")
|
||||
# Walk forward until we would hit wand; it must skip.
|
||||
menu.selected = 4
|
||||
menu.step(1)
|
||||
var c := TestUtils.check_eq(menu.slice_id(menu.selected), "warning", "wand skipped when venue has none")
|
||||
menu.free()
|
||||
return a and b and c
|
||||
|
||||
func test_radial_cancel_does_not_confirm() -> bool:
|
||||
var menu: Node = ActionRadial.new()
|
||||
menu.open_menu("Guest", true)
|
||||
var got := {"id": ""}
|
||||
menu.confirmed.connect(func(id): got["id"] = id)
|
||||
menu.cancelled.connect(func(): got["id"] = "cancelled")
|
||||
menu.close_menu()
|
||||
var a := TestUtils.check_eq(got["id"], "", "closing without confirm emits nothing")
|
||||
var slice: String = menu.confirm_selected()
|
||||
var b := TestUtils.check_eq(slice, "", "confirm while closed is empty")
|
||||
menu.free()
|
||||
return a and b
|
||||
|
||||
func test_radial_confirm_returns_id() -> bool:
|
||||
var menu: Node = ActionRadial.new()
|
||||
menu.open_menu("Guest", true)
|
||||
menu.selected = 0
|
||||
var slice: String = menu.confirm_selected()
|
||||
menu.free()
|
||||
return TestUtils.check_eq(slice, "raise_hand", "space-confirm uses highlighted slice")
|
||||
|
||||
func test_angle_top_is_first_slice() -> bool:
|
||||
var menu: Node = ActionRadial.new()
|
||||
var idx: int = menu.angle_to_index(-PI / 2.0)
|
||||
menu.free()
|
||||
return TestUtils.check_eq(idx, 0, "top of the doughnut is slice 0")
|
||||
|
||||
func test_wand_not_on_w() -> bool:
|
||||
var src := FileAccess.get_file_as_string("res://src/game/game_manager.gd")
|
||||
var a := TestUtils.check(not src.contains("KEY_W:"), "W is not a game-manager action key")
|
||||
var b := TestUtils.check(src.contains("is_action_pressed(\"wand\")"), "wand goes through the named action")
|
||||
var c := TestUtils.check(not _action_has_key("wand", KEY_W), "wand action is not bound to W")
|
||||
var d := TestUtils.check(_action_has_key("wand", KEY_C), "wand action is bound to C")
|
||||
return a and b and c and d
|
||||
|
||||
func test_named_input_actions_exist() -> bool:
|
||||
var menu_ok := _action_has_key("action_menu", KEY_SPACE)
|
||||
var confirm_ok := _action_has_key("confirm", KEY_SPACE) and _action_has_key("confirm", KEY_ENTER)
|
||||
var cancel_ok := _action_has_key("cancel", KEY_ESCAPE)
|
||||
var inspect_ok := _action_has_key("inspect", KEY_E)
|
||||
return TestUtils.check(menu_ok, "action_menu has Space") \
|
||||
and TestUtils.check(confirm_ok, "confirm has Space + Enter") \
|
||||
and TestUtils.check(cancel_ok, "cancel has Esc") \
|
||||
and TestUtils.check(inspect_ok, "inspect has E")
|
||||
|
||||
func test_cancel_has_right_mouse() -> bool:
|
||||
var events: Array = InputMap.action_get_events("cancel")
|
||||
var found := false
|
||||
for event in events:
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_RIGHT:
|
||||
found = true
|
||||
return TestUtils.check(found, "cancel action includes right mouse button")
|
||||
|
||||
func _action_has_key(action: String, keycode: Key) -> bool:
|
||||
if not InputMap.has_action(action):
|
||||
return false
|
||||
for event in InputMap.action_get_events(action):
|
||||
if event is InputEventKey and event.physical_keycode == keycode:
|
||||
return true
|
||||
return false
|
||||
@@ -38,7 +38,7 @@ func test_tv_studio_has_all_npc_types() -> bool:
|
||||
|
||||
var _spawn_count := 0
|
||||
|
||||
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String) -> void:
|
||||
func _on_spawn(_profile: NpcProfile, _intent: String, _id: String, _event: Dictionary = {}) -> void:
|
||||
_spawn_count += 1
|
||||
|
||||
func test_late_phase_reaches_violent_event() -> bool:
|
||||
@@ -137,3 +137,17 @@ func test_waypoints_map_intent_to_location() -> bool:
|
||||
var d := TestUtils.check_eq(scenario.waypoint_for_intent("sit"), steps_end, "sit -> steps_end")
|
||||
var e := TestUtils.check_eq(scenario.waypoint_for_intent("unknown_intent"), sidewalk, "unknown -> sidewalk fallback")
|
||||
return a and b and c and d and e
|
||||
|
||||
func test_spawn_rate_is_time_based() -> bool:
|
||||
# Regression: tick() runs at physics rate. Rolling the event scheduler every
|
||||
# frame spawned >1000 NPCs in seconds. Ten simulated seconds at 60 Hz must
|
||||
# stay in the normal single-digit-per-second range.
|
||||
var scenario: VenueScenario = ScenarioFactory.hollywood_building()
|
||||
var coordinator := ShiftCoordinator.new()
|
||||
coordinator.setup(scenario, 60.0, 99)
|
||||
_spawn_count = 0
|
||||
coordinator.npc_spawn_requested.connect(_on_spawn)
|
||||
coordinator.start()
|
||||
for i in 600:
|
||||
coordinator.tick(1.0 / 60.0)
|
||||
return TestUtils.check(_spawn_count <= 60, "spawns stay time-based at physics rate (was %d)" % _spawn_count)
|
||||
Reference in new issue
Block a user