diff --git a/client/scripts/autoloads/input_mapper.gd b/client/scripts/autoloads/input_mapper.gd index 655a632c9..eb4f7cf6e 100644 --- a/client/scripts/autoloads/input_mapper.gd +++ b/client/scripts/autoloads/input_mapper.gd @@ -117,6 +117,8 @@ func _process(_delta: float) -> void: # Discrete actions: fire once on key press (not held). func _unhandled_input(event: InputEvent) -> void: + if GameState.dialogue_active or GameState.free_camera_mode: + return var action: Action = -1 if event.is_action_pressed("interact"): diff --git a/client/scripts/main.gd b/client/scripts/main.gd index 202253104..46a20f5c6 100644 --- a/client/scripts/main.gd +++ b/client/scripts/main.gd @@ -210,9 +210,11 @@ func _unhandled_key_input(event: InputEvent) -> void: if not (event is InputEventKey) or not event.is_pressed() or event.is_echo(): return var key_event := event as InputEventKey - # #898: F4 toggles free camera mode. - if key_event.physical_keycode == KEY_F4: + # #898: F4 toggles free camera mode. Reset zoom to 1:1 on exit. + if Input.is_action_just_pressed("free_camera"): GameState.free_camera_mode = not GameState.free_camera_mode + if not GameState.free_camera_mode: + camera.zoom = Vector2.ONE return # Registry-driven toggle: each manifest declares its own default_key. for manifest: ImplantAppManifest in ImplantRegistry.get_manifests(): diff --git a/client/tests/test_free_camera.gd b/client/tests/test_free_camera.gd new file mode 100644 index 000000000..2952c6aa9 --- /dev/null +++ b/client/tests/test_free_camera.gd @@ -0,0 +1,88 @@ +## Free camera mode tests (#898). +## Covers GameState flag default, InputMapper suppression, and zoom clamping. +class_name TestFreeCamera +extends GdUnitTestSuite + + +func before_test() -> void: + GameState.free_camera_mode = false + + +func after_test() -> void: + GameState.free_camera_mode = false + + +# -- GameState.free_camera_mode default ---------------------------------------- + +func test_free_camera_mode_starts_false() -> void: + ## #898: Free camera is off by default — normal gameplay on startup. + assert_bool(GameState.free_camera_mode).override_failure_message( + "GameState.free_camera_mode must default to false" + ).is_false() + + +# -- InputMapper suppression --------------------------------------------------- + +func test_input_mapper_suppresses_movement_in_free_camera_mode() -> void: + ## #898: While free camera is active, InputMapper._process() returns early so + ## no movement actions enter the queue. + GameState.free_camera_mode = true + var before := InputMapper.input_queue.size() + InputMapper._process(0.016) + var after := InputMapper.input_queue.size() + assert_int(after).override_failure_message( + "InputMapper must not enqueue movement while free_camera_mode is true" + ).is_equal(before) + InputMapper.input_queue.clear() + + +func test_input_mapper_suppresses_discrete_actions_in_free_camera_mode() -> void: + ## #898: _unhandled_input returns early in free camera — INTERACT and stance + ## actions must not be queued. + GameState.free_camera_mode = true + var before := InputMapper.input_queue.size() + var fake_event := InputEventAction.new() + fake_event.action = "interact" + fake_event.pressed = true + InputMapper._unhandled_input(fake_event) + assert_int(InputMapper.input_queue.size()).override_failure_message( + "InputMapper must not enqueue discrete actions while free_camera_mode is true" + ).is_equal(before) + InputMapper.input_queue.clear() + + +func test_input_mapper_resumes_after_free_camera_off() -> void: + ## Turning free camera off lifts the suppression — _process runs normally again. + GameState.free_camera_mode = true + GameState.free_camera_mode = false + ## _process should no longer return early (queue may or may not grow depending + ## on held keys, but no crash and guard is lifted). + InputMapper._process(0.016) + assert_bool(true).is_true() # no crash = pass + InputMapper.input_queue.clear() + + +# -- Zoom clamp contract ------------------------------------------------------- + +func test_zoom_min_constant_is_0_5() -> void: + ## #898: Minimum zoom keeps the world recognisable. + var main_script = load("res://scripts/main.gd") + assert_float(main_script.FREE_CAMERA_ZOOM_MIN).override_failure_message( + "FREE_CAMERA_ZOOM_MIN must be 0.5" + ).is_equal_approx(0.5, 0.001) + + +func test_zoom_max_constant_is_8() -> void: + ## #898: Maximum zoom must not exceed 8× per spec. + var main_script = load("res://scripts/main.gd") + assert_float(main_script.FREE_CAMERA_ZOOM_MAX).override_failure_message( + "FREE_CAMERA_ZOOM_MAX must be 8.0" + ).is_equal_approx(8.0, 0.001) + + +func test_zoom_step_is_positive() -> void: + ## Zoom step must be > 0 so scroll wheel does something. + var main_script = load("res://scripts/main.gd") + assert_float(main_script.FREE_CAMERA_ZOOM_STEP).override_failure_message( + "FREE_CAMERA_ZOOM_STEP must be positive" + ).is_greater(0.0)