diff --git a/client/README.md b/client/README.md index ec6fe40..f5036ad 100644 --- a/client/README.md +++ b/client/README.md @@ -284,7 +284,7 @@ On **Linux** (including GitHub Actions), the path must be **`gdUnit4`** with a c **Scope:** Unit tests cover **`player.gd`**, **`player_locomotion_wasd.gd`** (pure WASD seam helpers), **`position_authority_client.gd`**, **`inventory_client.gd`**, **`item_definitions_client.gd`** (NEO-72), **`skill_progression_client.gd`**, **`gig_progression_client.gd`** (NEO-86), **`prototype_interactable_picker.gd`**, extended **`interaction_request_client_test.gd`**, **`gather_feedback_refresh_test.gd`** (NEO-73), **`craft_client.gd`**, **`recipe_definitions_client.gd`**, **`craft_feedback_refresh_test.gd`** (NEO-74), **`prototype_economy_hud_section.gd`** (NEO-75), **`combat_targets_client.gd`**, **`combat_feedback_refresh_test.gd`** (NEO-85), **`gig_feedback_refresh_test.gd`** (NEO-86), **`isometric_follow_camera.gd`** (eye math + effective zoom distance), **`camera_state.gd`**, and **`zoom_band_config.gd`**. **`main.gd`** and scene wiring are **not** automated here—use manual checks above. -**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll ([`InputEventPanGesture`](https://docs.godotengine.org/en/stable/classes/class_inputeventpangesture.html)) and pinch ([`InputEventMagnifyGesture`](https://docs.godotengine.org/en/stable/classes/class_inputeventmagnifygesture.html)) to the same discrete zoom bands. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. +**Camera zoom:** Input actions **`camera_zoom_in`** / **`camera_zoom_out`** (mouse wheel, **`=`** / **`-`**, keypad **+** / **−**) are defined in **`project.godot`**. **`isometric_follow_camera.gd`** also maps **macOS trackpad** two-finger scroll and pinch to the same discrete zoom bands using **accumulated delta** (one band step per ~1.25 pan units, **150 ms** cooldown between trackpad steps) so small gestures do not traverse all bands at once. On layouts where **`+`** requires **Shift**, remap or add a binding under **Project → Project Settings → Input Map** if needed. **Dev (NEO-18):** **`dev_toggle_occluder_obstacle`** (default **Ctrl+Shift+K**) toggles the prototype `Obstacle` visibility/collision in **`main.gd`**. **F9** / **F10** are used by the embedded debugger; use this action instead when the game is running in the editor. diff --git a/client/scripts/isometric_follow_camera.gd b/client/scripts/isometric_follow_camera.gd index 9a0354b..69e2657 100644 --- a/client/scripts/isometric_follow_camera.gd +++ b/client/scripts/isometric_follow_camera.gd @@ -14,11 +14,14 @@ extends Node3D ## [signal zoom_band_changed] when schema exists; optional occlusion/perf counters per module doc. signal zoom_band_changed(new_index: int, distance: float) -## Minimum |delta.y| before a pan gesture advances one zoom band (macOS trackpad). -const PAN_ZOOM_DELTA_THRESHOLD: float = 0.05 +## Accumulated vertical pan before one discrete zoom band step (macOS trackpad). +const PAN_ZOOM_BAND_ACCUMULATOR_THRESHOLD: float = 1.25 -## Magnify factor must exceed 1.0 ± this before one zoom band step (trackpad pinch). -const MAGNIFY_ZOOM_FACTOR_EPSILON: float = 0.02 +## Accumulated log(factor) before one pinch zoom band step. +const MAGNIFY_ZOOM_LOG_ACCUMULATOR_THRESHOLD: float = 0.15 + +## Minimum ms between trackpad-driven zoom band steps (avoids flick bursts). +const TRACKPAD_ZOOM_STEP_COOLDOWN_MSEC: int = 150 const CameraStateScript := preload("res://scripts/camera_state.gd") const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd") @@ -55,6 +58,9 @@ var _state = null var _smoothed_eye: Vector3 = Vector3.ZERO var _orbit_yaw_rad: float = 0.0 var _zoom_band_index: int = 0 +var _pan_zoom_accumulator: float = 0.0 +var _magnify_zoom_accumulator: float = 0.0 +var _last_trackpad_zoom_step_msec: int = -1_000_000 var _warned_missing_follow_target: bool = false ## Keys: [code]int[/code] [method Object.get_instance_id] of the occluder body. Values: Array of ## {mesh, surface, original} restore entries. @@ -134,13 +140,19 @@ func _exit_tree() -> void: func _unhandled_input(event: InputEvent) -> void: if event.is_echo(): return - var pan_step: int = zoom_step_from_pan_gesture(event) - if pan_step != 0: - _apply_zoom_step(pan_step) + if event is InputEventPanGesture: + var pan: InputEventPanGesture = event as InputEventPanGesture + var pan_result: Dictionary = consume_pan_zoom_accumulator(_pan_zoom_accumulator, pan.delta) + _pan_zoom_accumulator = float(pan_result.get("accumulator", _pan_zoom_accumulator)) + _try_apply_trackpad_zoom_step(int(pan_result.get("step", 0))) return - var magnify_step: int = zoom_step_from_magnify_gesture(event) - if magnify_step != 0: - _apply_zoom_step(magnify_step) + if event is InputEventMagnifyGesture: + var mag: InputEventMagnifyGesture = event as InputEventMagnifyGesture + var mag_result: Dictionary = consume_magnify_zoom_accumulator( + _magnify_zoom_accumulator, mag.factor + ) + _magnify_zoom_accumulator = float(mag_result.get("accumulator", _magnify_zoom_accumulator)) + _try_apply_trackpad_zoom_step(int(mag_result.get("step", 0))) return if event.is_action_pressed("camera_zoom_in"): _apply_zoom_step(-1) @@ -148,30 +160,46 @@ func _unhandled_input(event: InputEvent) -> void: _apply_zoom_step(1) -## Maps macOS trackpad two-finger scroll to a discrete zoom band step, or **0** when ignored. -static func zoom_step_from_pan_gesture(event: InputEvent) -> int: - if event == null or not event is InputEventPanGesture: - return 0 - var pan: InputEventPanGesture = event as InputEventPanGesture - if absf(pan.delta.y) <= absf(pan.delta.x): - return 0 - if pan.delta.y <= -PAN_ZOOM_DELTA_THRESHOLD: - return -1 - if pan.delta.y >= PAN_ZOOM_DELTA_THRESHOLD: - return 1 - return 0 +func _try_apply_trackpad_zoom_step(step: int) -> void: + if step == 0: + return + var now_msec: int = Time.get_ticks_msec() + if now_msec - _last_trackpad_zoom_step_msec < TRACKPAD_ZOOM_STEP_COOLDOWN_MSEC: + return + _last_trackpad_zoom_step_msec = now_msec + _apply_zoom_step(step) -## Maps trackpad pinch ([InputEventMagnifyGesture]) to a discrete zoom band step, or **0**. -static func zoom_step_from_magnify_gesture(event: InputEvent) -> int: - if event == null or not event is InputEventMagnifyGesture: - return 0 - var mag: InputEventMagnifyGesture = event as InputEventMagnifyGesture - if mag.factor >= 1.0 + MAGNIFY_ZOOM_FACTOR_EPSILON: - return -1 - if mag.factor <= 1.0 - MAGNIFY_ZOOM_FACTOR_EPSILON: - return 1 - return 0 +## Accumulates trackpad pan delta; emits at most **one** band step per call. +static func consume_pan_zoom_accumulator( + accumulator: float, + delta: Vector2, + threshold: float = PAN_ZOOM_BAND_ACCUMULATOR_THRESHOLD +) -> Dictionary: + if absf(delta.y) <= absf(delta.x): + return {"accumulator": accumulator, "step": 0} + var next: float = accumulator + delta.y + if next <= -threshold: + return {"accumulator": next + threshold, "step": -1} + if next >= threshold: + return {"accumulator": next - threshold, "step": 1} + return {"accumulator": next, "step": 0} + + +## Accumulates pinch log-scale; emits at most **one** band step per call. +static func consume_magnify_zoom_accumulator( + accumulator: float, + factor: float, + threshold: float = MAGNIFY_ZOOM_LOG_ACCUMULATOR_THRESHOLD +) -> Dictionary: + if factor <= 0.0: + return {"accumulator": accumulator, "step": 0} + var next: float = accumulator + log(factor) + if next >= threshold: + return {"accumulator": next - threshold, "step": -1} + if next <= -threshold: + return {"accumulator": next + threshold, "step": 1} + return {"accumulator": next, "step": 0} ## **Near** = lower band index. Zoom in → closer → decrease index (clamped). diff --git a/client/test/isometric_follow_camera_test.gd b/client/test/isometric_follow_camera_test.gd index 5007ab7..6d1ca34 100644 --- a/client/test/isometric_follow_camera_test.gd +++ b/client/test/isometric_follow_camera_test.gd @@ -140,42 +140,43 @@ func test_occluder_override_key_is_valid_false_after_free() -> void: assert_that(ok).is_false() -func test_zoom_step_from_pan_gesture_ignores_non_pan() -> void: +func test_consume_pan_zoom_accumulator_ignores_horizontal_pan() -> void: # Arrange - var key := InputEventKey.new() # Act - var step: int = IsoScript.zoom_step_from_pan_gesture(key) + var result: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.2, 0.0)) # Assert - assert_that(step).is_equal(0) + assert_that(int(result.get("step", -9))).is_equal(0) + assert_that(float(result.get("accumulator", -1.0))).is_equal(0.0) -func test_zoom_step_from_pan_gesture_maps_vertical_scroll_to_bands() -> void: +func test_consume_pan_zoom_accumulator_requires_threshold_before_step() -> void: # Arrange - var scroll_up := InputEventPanGesture.new() - scroll_up.delta = Vector2(0.0, -0.2) - var scroll_down := InputEventPanGesture.new() - scroll_down.delta = Vector2(0.0, 0.2) - var horizontal := InputEventPanGesture.new() - horizontal.delta = Vector2(0.2, 0.0) # Act - var up_step: int = IsoScript.zoom_step_from_pan_gesture(scroll_up) - var down_step: int = IsoScript.zoom_step_from_pan_gesture(scroll_down) - var horiz_step: int = IsoScript.zoom_step_from_pan_gesture(horizontal) + var partial: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.0, -0.4)) + var complete: Dictionary = IsoScript.consume_pan_zoom_accumulator( + float(partial.get("accumulator", 0.0)), Vector2(0.0, -0.9) + ) # Assert - assert_that(up_step).is_equal(-1) - assert_that(down_step).is_equal(1) - assert_that(horiz_step).is_equal(0) + assert_that(int(partial.get("step", -9))).is_equal(0) + assert_that(int(complete.get("step", -9))).is_equal(-1) -func test_zoom_step_from_magnify_gesture_maps_pinch_to_bands() -> void: +func test_consume_pan_zoom_accumulator_emits_one_step_per_call_even_for_large_delta() -> void: # Arrange - var pinch_in := InputEventMagnifyGesture.new() - pinch_in.factor = 0.9 - var pinch_out := InputEventMagnifyGesture.new() - pinch_out.factor = 1.1 # Act - var in_step: int = IsoScript.zoom_step_from_magnify_gesture(pinch_in) - var out_step: int = IsoScript.zoom_step_from_magnify_gesture(pinch_out) + var result: Dictionary = IsoScript.consume_pan_zoom_accumulator(0.0, Vector2(0.0, -5.0)) # Assert - assert_that(in_step).is_equal(1) - assert_that(out_step).is_equal(-1) + assert_that(int(result.get("step", -9))).is_equal(-1) + assert_that(float(result.get("accumulator", 0.0))).is_equal(-3.75) + + +func test_consume_magnify_zoom_accumulator_requires_threshold_before_step() -> void: + # Arrange + # Act + var partial: Dictionary = IsoScript.consume_magnify_zoom_accumulator(0.0, 1.05) + var complete: Dictionary = IsoScript.consume_magnify_zoom_accumulator( + float(partial.get("accumulator", 0.0)), 1.12 + ) + # Assert + assert_that(int(partial.get("step", -9))).is_equal(0) + assert_that(int(complete.get("step", -9))).is_equal(-1)