chore: accumulate trackpad pan delta before camera zoom steps.

Mac trackpads emit many pan events per gesture; stepping one band per
event jumped across all zoom levels. Accumulate delta with a threshold,
cap one step per event, and add a 150 ms cooldown between trackpad steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/131/head
VinPropane 2026-05-27 23:24:30 -04:00
parent 9b8593c145
commit 9fa0fbef4b
3 changed files with 88 additions and 59 deletions

View File

@ -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.

View File

@ -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).

View File

@ -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)