From 2c86f8bf9a7d5e743c1dd62c0201a42b37e265f6 Mon Sep 17 00:00:00 2001 From: James Boggs Date: Sun, 11 Oct 2026 01:38:52 -0700 Subject: [PATCH] let embedded replay viewers opt into native camera input Coaching hosts want the game's own camera gestures while a camera tool is active, but embed=1 blocks all pointer input. Add an opt-in switch that lets pointer input through to the camera only, with orbit, while keys stay with the host. - replay.html: nativeCamera=1 URL option, setNativeCamera(on) and the nativeCamera postMessage command; state() reports nativeCamera. The capture-phase blocker skips pointer events while it is on. - replaycontrols.nim: replayNativeCamera(); fixed camera requests take optional yaw and pitch (NaN keeps the current angle). - rtscameras.nim: rtsCameraEye takes yaw/pitch (defaults unchanged) and rtsDragPan pans relative to an orbited camera. - GotA: embedded input goes through updateEmbeddedCamera (drag pans, right-drag orbits, wheel/pinch zooms, never selects); drags start only on a press seen while enabled. The director resets the orbit to north. Selection following moved into followSelectedCamera unchanged. - Tests: rtscameras orbit/pan cases, replay control camera angles, browser native camera check (pan, orbit, zoom, keys still forwarded, restore north, off again). - Docs: docs/replay-control.md. Co-Authored-By: Claude Opus 5.5 --- coworld/tools/test_browser_with_playwright.py | 45 ++++++ docs/replay-control.md | 25 ++- examples/gods_of_the_arena/graphics.nim | 144 ++++++++++++------ src/polyworld/replay.html | 25 ++- src/polyworld/replaycontrols.nim | 19 ++- src/polyworld/rtscameras.nim | 33 +++- tests/test_replaycontrols.nim | 12 +- tests/test_rtscameras.nim | 22 +++ 8 files changed, 261 insertions(+), 64 deletions(-) diff --git a/coworld/tools/test_browser_with_playwright.py b/coworld/tools/test_browser_with_playwright.py index 93d4ec02..a68d80f1 100644 --- a/coworld/tools/test_browser_with_playwright.py +++ b/coworld/tools/test_browser_with_playwright.py @@ -263,6 +263,51 @@ def check_control(base, executable, output, gpu=False): assert any(k['event'] == 'keydown' and k['code'] == 'Space' for k in keys), keys page.screenshot(path=str(output / 'gota-embed.png')) print('control: embed input and key forwarding passed', flush=True) + + # Native camera input lets pointer gestures reach the camera only. + frame.evaluate(replay + ".camera({mode: 'fixed', x: 0, z: 0, distance: 60})") + frame.evaluate('nextFrames(2)') + assert frame.evaluate(replay + '.setNativeCamera(true)') + frame.evaluate('nextFrames(1)') + assert frame.evaluate(replay + '.state().nativeCamera') + north = frame.evaluate(replay + '.state().viewProjection') + + def drag(button, dx, dy): + page.mouse.move(640, 288) + page.mouse.down(button=button) + for step in range(1, 9): + page.mouse.move(640 + dx * step / 8, 288 + dy * step / 8) + frame.evaluate('nextFrames(1)') + page.mouse.up(button=button) + frame.evaluate('nextFrames(2)') + + center = frame.evaluate(replay + '.unproject({x: 640, y: 288})') + drag('left', 160, 0) + panned = frame.evaluate(replay + '.unproject({x: 640, y: 288})') + assert panned['x'] < center['x'] - 10, (center, panned) + matrix = frame.evaluate(replay + '.state().viewProjection') + drag('right', 200, 40) + orbited = frame.evaluate(replay + '.state().viewProjection') + # The camera turned: the view matrix's x axis now has a z component. + assert abs(orbited[2]) > abs(matrix[2]) + 0.1, (matrix, orbited) + page.mouse.wheel(0, -400) + frame.evaluate('nextFrames(3)') + assert frame.evaluate(replay + '.state().viewProjection') != orbited + page.keyboard.press('Space') + frame.evaluate('nextFrames(2)') + assert not frame.evaluate(replay + '.state().playing') + frame.evaluate(replay + ".camera({mode: 'fixed', x: 0, z: 0, distance: 60, yaw: 0, pitch: 0.92})") + frame.evaluate('nextFrames(2)') + restored = frame.evaluate(replay + '.state().viewProjection') + assert max(abs(a - b) for a, b in zip(restored, north)) < 1e-4, (north, restored) + page.screenshot(path=str(output / 'gota-native-camera.png')) + page.evaluate("() => sendReplay({type: 'nativeCamera', on: false})") + frame.evaluate('nextFrames(2)') + assert not frame.evaluate(replay + '.state().nativeCamera') + page.mouse.wheel(0, -400) + drag('left', 160, 0) + assert frame.evaluate(replay + '.state().viewProjection') == restored + print('control: native camera input passed', flush=True) assert not errors, errors assert not frame.evaluate('failed') browser.close() diff --git a/docs/replay-control.md b/docs/replay-control.md index 5bef0133..a36ae08d 100644 --- a/docs/replay-control.md +++ b/docs/replay-control.md @@ -18,6 +18,7 @@ Options are read from the hash, then the query, like `replay`. | Option | Effect | | --- | --- | | `embed=1` | Hides the in-canvas HUD (transport bar, scoreboard, minimap, panels) and keeps mouse, wheel, touch and key input from the game. Key presses are forwarded to the host instead. Seeks use 33 ms slices. | +| `nativeCamera=1` | With `embed=1`, lets mouse, wheel and touch input through to the camera, as `setNativeCamera(true)` does. Keys stay with the host. | | `loop=0` or `loop=1` | Overrides looping. Replay builds loop by default. | | `seekSlice=MS` | Simulation time per frame while seeking. The default is 100 ms, or 33 ms with `embed=1`. | @@ -25,6 +26,26 @@ Shorter slices keep the page responsive during long seeks but finish them later. On an M-series Mac with a GPU, a cold seek over a 28,909-tick GotA match took 28 s at 12 ms, 17 s at 33 ms and 12 s at 100 ms. +### Native camera input + +An embedded viewer ignores the pointer by default, so the host can draw over it. +A host that wants the game's own camera controls, for example while a camera +tool is active, turns them on with `nativeCamera=1` or `setNativeCamera(true)` +and off again with `setNativeCamera(false)`. While on, pointer input moves only +the camera; it never selects units, and key presses still go to the host. + +| Gesture | Effect in GotA | +| --- | --- | +| Drag (left or middle button) | Pans; the ground follows the pointer. | +| Right-drag | Orbits around the camera target: sideways turns, up and down tilts. | +| Wheel or pinch | Zooms. | + +Any gesture stops the director. A pan also stops following a unit; an orbit or +zoom keeps following it. GotA's camera otherwise always faces north, so the +director resets the orbit, and `camera({mode: 'fixed', yaw: 0, pitch: 0.92})` +restores the default angle. +The browser test's `--control-only` run drives these gestures with a real mouse. + ## Same-origin hosts `iframe.contentWindow.polyworldReplay` is synchronous. Screen positions are CSS @@ -42,7 +63,8 @@ GotA that is `simulation / WorldScale`, centred on the map, with y up. | `play()`, `pause()` | Play rewinds first when already at the end. | | `setSpeed(x)` | Applies the fastest of 1, 2, 4 or 16 at or below `x` and returns it. | | `setLoop(on)`, `setSeekSlice(ms)` | As the URL options. | -| `camera({mode, ...})` | `director`; `fixed` with `x`, `z` and optional `distance`; `follow` with unit `id`; `freeze` holds the current view. Every mode except `director` stops the automatic camera. | +| `setNativeCamera(on)` | Lets pointer input through to the camera (see above). Returns the applied value, which is always `false` without `embed=1`; `state().nativeCamera` reports it. | +| `camera({mode, ...})` | `director`; `fixed` with `x`, `z` and optional `distance`, `yaw` and `pitch` (radians; omitted values keep the current view); `follow` with unit `id`; `freeze` holds the current view. Every mode except `director` stops the automatic camera. | | `freezeCamera()` | Same as `camera({mode: 'freeze'})`. | | `project({x, y, z})` | Screen `{x, y}` in the last drawn frame, or `null` behind the camera. | | `unproject({x, y})` | The drawn ground point `{x, y, z}` under a screen position, or `null`. | @@ -75,6 +97,7 @@ ready run once it is. | `speed` | `speed` | | `loop` | `loop` | | `seekSlice` | `milliseconds` | +| `nativeCamera` | `on` | | `camera` | as `camera()` above | | `project`, `unproject`, `pick`, `unit`, `state` | as above; `unit` takes `id` | | `subscribe` | `tick: true` for tick events, `frame: true` for frame events | diff --git a/examples/gods_of_the_arena/graphics.nim b/examples/gods_of_the_arena/graphics.nim index 6ea06b48..64a4ccdf 100644 --- a/examples/gods_of_the_arena/graphics.nim +++ b/examples/gods_of_the_arena/graphics.nim @@ -1000,6 +1000,8 @@ proc runGraphics*() = hashCheck: run.hashCheck ) + # A host page drives an embedded viewer; its HUD and direct input are off. + let embedded = replayEmbedded() var cameraDistance = DefaultCameraDistance cameraTarget = vec3(0, 0, 0) @@ -1018,6 +1020,11 @@ proc runGraphics*() = cameraEase: CameraEase focusPlayerHero = false groupCameraScale = 1.0'f32 + cameraYaw = RtsCameraYaw + cameraPitch = RtsCameraPitch + ## Orbit set by an embedding host; the director always faces north. + embeddedPanning = false + embeddedOrbiting = false viewingDt = 0.0'f viewingSeeking = false actionCam = initActionCam( @@ -1435,6 +1442,83 @@ proc runGraphics*() = if playerMode(): cameraTarget = playerHeroFrame() + proc followSelectedCamera(dt: float32) = + ## Eases the camera onto the followed selection unless the director owns it. + if actionCam.enabled: + return + let targetCount = selectedTargetCount() + if followSelection and targetCount == 1: + let target = selectionTarget(selectedIds[0]) + cameraTarget = mix( + cameraTarget, + target.position + vec3(0, target.focusHeight, 0), + damping(5.0'f32, dt) + ) + elif followSelection and targetCount > 1: + let + groupCenter = selectedCenter() + groupDistance = clamp( + 12.0'f32 + selectedRadius(groupCenter) * 2.8'f32, + 20.0'f32, + 400.0'f32 + ) * groupCameraScale + cameraTarget = mix( + cameraTarget, + groupCenter, + damping(4.0'f32, dt) + ) + cameraDistance = mix( + cameraDistance, + groupDistance, + damping(2.0'f32, dt) + ) + elif followSelection: + followSelection = false + + proc updateEmbeddedCamera() = + ## Applies pointer input that an embedding host lets through: drag pans, + ## right-drag orbits, and the wheel or a pinch zooms. Nothing selects. + const OrbitRadiansPerPixel = 0.004'f32 + if not replayNativeCamera(): + embeddedPanning = false + embeddedOrbiting = false + return + # Drags start only on a press seen while input is enabled, so a button + # released while the host blocked input cannot leave a drag running. + if window.buttonPressed[MouseLeft] or window.buttonPressed[MouseMiddle]: + embeddedPanning = true + elif window.buttonPressed[MouseRight]: + embeddedOrbiting = true + if not window.buttonDown[MouseLeft] and not window.buttonDown[MouseMiddle]: + embeddedPanning = false + if not window.buttonDown[MouseRight]: + embeddedOrbiting = false + let delta = window.mouseDelta.vec2 + if embeddedPanning and delta != vec2(0): + followSelection = false + actionCam.takeManual() + cancelCameraEase(cameraEase) + cameraTarget.rtsDragPan(delta, cameraDistance, cameraYaw, mapHalfSize()) + elif embeddedOrbiting and delta != vec2(0): + actionCam.takeManual() + cancelCameraEase(cameraEase) + cameraYaw -= delta.x * OrbitRadiansPerPixel + cameraPitch = clamp( + cameraPitch + delta.y * OrbitRadiansPerPixel, + RtsOrbitPitchMin, + RtsOrbitPitchMax + ) + if window.scrollDelta.y != 0: + actionCam.takeManual() + cancelCameraEase(cameraEase) + # Gentler than the native 0.92 per 3 steps: a macOS wheel notch reports + # about 100 steps, which would jump straight to the closest zoom. + cameraDistance = clamp( + cameraDistance * exp(-window.scrollDelta.y * 0.002'f32), + 5.0'f32, + 400.0'f32 + ) + proc updateCamera(dt: float32) = ## Applies fixed-north RTS pan, zoom, and selection following. pruneSelection() @@ -1447,6 +1531,10 @@ proc runGraphics*() = if focusPlayerHero: focusPlayerHero = false startCameraEase(cameraEase, cameraTarget) + if embedded: + updateEmbeddedCamera() + followSelectedCamera(dt) + return updateMinimapCamera( window, sk.mousePos, @@ -1498,11 +1586,7 @@ proc runGraphics*() = ) if panning: cancelCameraEase(cameraEase) - let panSpeed = cameraDistance * 0.0015 - cameraTarget.x -= delta.x * panSpeed - cameraTarget.z -= delta.y * panSpeed - cameraTarget.x = clamp(cameraTarget.x, -mapHalfSize(), mapHalfSize()) - cameraTarget.z = clamp(cameraTarget.z, -mapHalfSize(), mapHalfSize()) + cameraTarget.rtsDragPan(delta, cameraDistance, cameraYaw, mapHalfSize()) elif applyRtsPan( cameraTarget, rtsPanDir(window), @@ -1522,12 +1606,7 @@ proc runGraphics*() = if panning: followSelection = false actionCam.takeManual() - let - panSpeed = cameraDistance * 0.0015 - cameraTarget.x -= delta.x * panSpeed - cameraTarget.z -= delta.y * panSpeed - cameraTarget.x = clamp(cameraTarget.x, -mapHalfSize(), mapHalfSize()) - cameraTarget.z = clamp(cameraTarget.z, -mapHalfSize(), mapHalfSize()) + cameraTarget.rtsDragPan(delta, cameraDistance, cameraYaw, mapHalfSize()) if not minimapPanning and applyRtsPan( cameraTarget, @@ -1558,37 +1637,7 @@ proc runGraphics*() = 5.0'f32, 400.0'f32 ) - - if actionCam.enabled: - return - let targetCount = selectedTargetCount() - if followSelection and targetCount == 1: - let target = selectionTarget(selectedIds[0]) - cameraTarget = mix( - cameraTarget, - target.position + vec3(0, target.focusHeight, 0), - damping(5.0'f32, dt) - ) - elif followSelection and targetCount > 1: - let - groupCenter = selectedCenter() - groupDistance = clamp( - 12.0'f32 + selectedRadius(groupCenter) * 2.8'f32, - 20.0'f32, - 400.0'f32 - ) * groupCameraScale - cameraTarget = mix( - cameraTarget, - groupCenter, - damping(4.0'f32, dt) - ) - cameraDistance = mix( - cameraDistance, - groupDistance, - damping(2.0'f32, dt) - ) - elif followSelection: - followSelection = false + followSelectedCamera(dt) proc issueGroundOrder(viewProjection: Mat4, attackMove: bool) = ## Walks or attack-moves the human hero onto the tile under the pointer. @@ -1752,7 +1801,11 @@ proc runGraphics*() = proc cameraView(): Mat4 = ## Updates the camera eye and returns its view matrix. - cameraEye = rtsCameraEye(cameraTarget, cameraDistance) + if actionCam.enabled: + cameraYaw = RtsCameraYaw + cameraPitch = RtsCameraPitch + cameraEye = rtsCameraEye(cameraTarget, cameraDistance, cameraYaw, + cameraPitch) lookAt(cameraEye, cameraTarget, vec3(0, 1, 0)) ## Frame @@ -2039,13 +2092,16 @@ proc runGraphics*() = ) if request.distance > 0: cameraDistance = request.distance + if not math.isNaN(request.yaw): + cameraYaw = request.yaw + if not math.isNaN(request.pitch): + cameraPitch = clamp(request.pitch, RtsOrbitPitchMin, RtsOrbitPitchMax) of FollowCamera: selectEntity(request.id) of FrozenCamera: actionCam.takeManual() followSelection = false - let embedded = replayEmbedded() installReplayControl(transport, ReplayControlHooks( groundY: controlGroundY, pick: proc(screen: Vec2, viewProjection: Mat4): int32 = diff --git a/src/polyworld/replay.html b/src/polyworld/replay.html index 3da3c0fd..a4ed113d 100644 --- a/src/polyworld/replay.html +++ b/src/polyworld/replay.html @@ -32,17 +32,21 @@ } // embed=1 hides the viewer's HUD and keeps input from the viewer, so the host // page drives it through polyworldReplay or postMessage. Key presses go to the -// host instead, so it can own every shortcut. loop=0|1 overrides looping; +// host instead, so it can own every shortcut. nativeCamera=1 (or +// setNativeCamera) lets pointer input through to the camera only: drag pans, +// right-drag orbits, wheel or pinch zooms. loop=0|1 overrides looping; // seekSlice=MS sets how long each frame may simulate while seeking. const embed = option('embed') === '1'; const loop = option('loop'); const seekSlice = option('seekSlice'); const keyListeners = new Set(); +const keyTypes = ['keydown', 'keyup', 'keypress']; if (embed) { - for (const type of ['keydown', 'keyup', 'keypress', 'mousedown', 'mouseup', - 'mousemove', 'click', 'dblclick', 'wheel', 'pointerdown', 'pointerup', - 'pointermove', 'touchstart', 'touchmove', 'touchend']) { + for (const type of [...keyTypes, 'mousedown', 'mouseup', 'mousemove', 'click', + 'dblclick', 'wheel', 'pointerdown', 'pointerup', 'pointermove', + 'touchstart', 'touchmove', 'touchend']) { window.addEventListener(type, event => { + if (Module.polyworldNativeCamera && !keyTypes.includes(type)) return; event.stopImmediatePropagation(); if (type !== 'keydown' && type !== 'keyup') return; const {key, code, altKey, ctrlKey, metaKey, shiftKey, repeat} = event; @@ -67,6 +71,7 @@ var Module = { canvas: document.getElementById('canvas'), polyworldEmbed: embed, + polyworldNativeCamera: embed && option('nativeCamera') === '1', polyworldLoop: loop === null ? undefined : loop !== '0', arguments: ['--replay', '/episode.replay'], print: text => console.info(text), @@ -136,7 +141,8 @@ const listeners = new Set(); let state = null; Module.polyworldControlFrame = frame => { - state = {...frame, cssWidth: Module.canvas.clientWidth, cssHeight: Module.canvas.clientHeight}; + state = {...frame, cssWidth: Module.canvas.clientWidth, cssHeight: Module.canvas.clientHeight, + nativeCamera: Module.polyworldNativeCamera}; for (const listener of listeners) listener(state); }; return { @@ -151,11 +157,13 @@ setSpeed: speed => call('set_speed', Math.round(speed)), setLoop: on => call('set_loop', on ? 1 : 0), setSeekSlice: milliseconds => call('set_seek_slice', milliseconds), - camera: ({mode, id = 0, x = 0, z = 0, distance = 0}) => { + // Only embedded viewers block input, so only they can switch it on. + setNativeCamera: on => (Module.polyworldNativeCamera = embed && !!on), + camera: ({mode, id = 0, x = 0, z = 0, distance = 0, yaw = NaN, pitch = NaN}) => { if (!(mode in cameraModes)) throw new Error('Unknown camera mode: ' + mode); - call('camera', cameraModes[mode], id, x, z, distance); + call('camera', cameraModes[mode], id, x, z, distance, yaw, pitch); }, - freezeCamera: () => call('camera', cameraModes.freeze, 0, 0, 0, 0), + freezeCamera: () => call('camera', cameraModes.freeze, 0, 0, 0, 0, NaN, NaN), project: ({x, y, z}) => { if (!call('project', x, y, z)) return null; const s = scale(); @@ -190,6 +198,7 @@ speed: data => control.setSpeed(data.speed), loop: data => control.setLoop(data.loop), seekSlice: data => control.setSeekSlice(data.milliseconds), + nativeCamera: data => control.setNativeCamera(data.on), camera: data => control.camera(data), project: data => control.project(data), unproject: data => control.unproject(data), diff --git a/src/polyworld/replaycontrols.nim b/src/polyworld/replaycontrols.nim index 80021c3d..2a785a1e 100644 --- a/src/polyworld/replaycontrols.nim +++ b/src/polyworld/replaycontrols.nim @@ -34,6 +34,8 @@ type ## Render-space x and z for `FixedCamera`. distance*: float32 ## Camera distance for `FixedCamera`; zero or less keeps the current one. + yaw*, pitch*: float32 + ## Orbit angles in radians for `FixedCamera`; NaN keeps the current one. id*: int32 ## Unit for `FollowCamera`. @@ -87,6 +89,16 @@ proc replayEmbedded*(): bool = else: false +proc replayNativeCamera*(): bool = + ## Returns whether the host lets pointer input through to an embedded + ## viewer's camera, so it can pan, orbit and zoom natively. + when defined(emscripten): + var native: cint + {.emit: "`native` = EM_ASM_INT({ return Module.polyworldNativeCamera ? 1 : 0; });".} + native != 0 + else: + false + proc replayLoopOption(): int32 = ## Returns 0 or 1 when the page chose looping, or -1 to keep the default. when defined(emscripten): @@ -244,16 +256,19 @@ proc polyworld_replay_set_seek_slice*(milliseconds: float32) {.replayExport.} = proc polyworld_replay_camera*( mode, id: int32, - x, z, distance: float32 + x, z, distance, yaw, pitch: float32 ) {.replayExport.} = ## Selects the director (0), a fixed view over x and z (1), follows - ## unit `id` (2), or holds the current view (3). + ## unit `id` (2), or holds the current view (3). A fixed view also takes + ## orbit angles; NaN keeps the current ones. if not installed() or mode notin 0'i32 .. ReplayCameraMode.high.ord.int32: return control.hooks.camera(ReplayCameraRequest( mode: ReplayCameraMode(mode), target: vec2(x, z), distance: distance, + yaw: yaw, + pitch: pitch, id: id )) diff --git a/src/polyworld/rtscameras.nim b/src/polyworld/rtscameras.nim index 9e118dec..feb751d6 100644 --- a/src/polyworld/rtscameras.nim +++ b/src/polyworld/rtscameras.nim @@ -20,6 +20,9 @@ const ## GOTA's HUD is shorter, so the hero sits closer to center. RtsPortraitEase* = 0.55'f32 ## Ease-in-out duration when a HUD portrait pans to a hero. + RtsOrbitPitchMin* = 0.35'f32 + RtsOrbitPitchMax* = 1.45'f32 + ## Pitch range for an orbited camera: low oblique to nearly top down. type MinimapViewRect* = object @@ -33,12 +36,17 @@ type elapsed*: float32 duration*: float32 -proc rtsCameraEye*(target: Vec3, distance: float32): Vec3 = - ## Returns the locked north-facing RTS camera position. +proc rtsCameraEye*( + target: Vec3, + distance: float32, + yaw = RtsCameraYaw, + pitch = RtsCameraPitch +): Vec3 = + ## Returns the RTS camera position, north-facing unless orbited. target + vec3( - sin(RtsCameraYaw) * cos(RtsCameraPitch), - sin(RtsCameraPitch), - cos(RtsCameraYaw) * cos(RtsCameraPitch) + sin(yaw) * cos(pitch), + sin(pitch), + cos(yaw) * cos(pitch) ) * distance proc rtsFollowFrame*( @@ -266,6 +274,21 @@ proc applyRtsPan*( ) true +proc rtsDragPan*( + target: var Vec3, + delta: Vec2, + distance, yaw, halfSpan: float32 +) = + ## Moves the target so the ground follows a pointer drag of `delta` + ## pixels, for a camera orbited to `yaw`. + let + speed = distance * 0.0015'f32 + right = vec3(cos(yaw), 0, -sin(yaw)) + forward = vec3(-sin(yaw), 0, -cos(yaw)) + target += (forward * delta.y - right * delta.x) * speed + target.x = clamp(target.x, -halfSpan, halfSpan) + target.z = clamp(target.z, -halfSpan, halfSpan) + proc rtsEaseT*(t: float32): float32 = ## Smoothstep ease-in-out in 0 .. 1. let u = clamp(t, 0.0'f32, 1.0'f32) diff --git a/tests/test_replaycontrols.nim b/tests/test_replaycontrols.nim index efba7749..9ddb29dc 100644 --- a/tests/test_replaycontrols.nim +++ b/tests/test_replaycontrols.nim @@ -58,12 +58,16 @@ block: doAssert polyworld_replay_result(2) == 3 doAssert polyworld_replay_result(3) == 4 doAssert polyworld_replay_result(4) == 0 - polyworld_replay_camera(3, 0, 0, 0, 0) + polyworld_replay_camera(3, 0, 0, 0, 0, NaN, NaN) doAssert camera.mode == FrozenCamera - polyworld_replay_camera(1, 0, 5, 6, 30) + polyworld_replay_camera(1, 0, 5, 6, 30, NaN, NaN) doAssert camera.mode == FixedCamera and camera.target == vec2(5, 6) doAssert camera.distance == 30 - polyworld_replay_camera(9, 0, 0, 0, 0) - doAssert camera.mode == FixedCamera + doAssert camera.yaw.isNaN and camera.pitch.isNaN + polyworld_replay_camera(1, 0, 5, 6, 30, 1.5, 0.75) + doAssert camera.yaw == 1.5 and camera.pitch == 0.75 + polyworld_replay_camera(9, 0, 0, 0, 0, NaN, NaN) + doAssert camera.yaw == 1.5 + doAssert not replayNativeCamera() echo "Replay control tests passed" diff --git a/tests/test_rtscameras.nim b/tests/test_rtscameras.nim index 4e0dec14..6b99b404 100644 --- a/tests/test_rtscameras.nim +++ b/tests/test_rtscameras.nim @@ -1,4 +1,5 @@ import + std/math, vmath, polyworld/rtscameras @@ -90,4 +91,25 @@ block: doAssert tile[0] == 0 doAssert tile[1] == 68 +echo "Testing orbited camera eyes and drag pans" +block: + let target = vec3(1, 0, 2) + doAssert rtsCameraEye(target, 10) == + rtsCameraEye(target, 10, RtsCameraYaw, RtsCameraPitch) + let side = rtsCameraEye(target, 10, PI.float32 / 2, RtsCameraPitch) + doAssert abs(side.z - target.z) < 1e-4 and side.x > target.x + let top = rtsCameraEye(target, 10, 0, PI.float32 / 2) + doAssert abs(top.y - 10) < 1e-4 + var + north = vec3(0, 0, 0) + turned = vec3(0, 0, 0) + north.rtsDragPan(vec2(10, 0), 100, 0, 64) + doAssert abs(north.x + 1.5) < 1e-4 and north.z == 0 + # Turned a quarter, a rightward drag moves the target along +z. + turned.rtsDragPan(vec2(10, 0), 100, PI.float32 / 2, 64) + doAssert abs(turned.z - 1.5) < 1e-4 and abs(turned.x) < 1e-4 + var edge = vec3(63.9, 0, 0) + edge.rtsDragPan(vec2(-1000, 0), 100, 0, 64) + doAssert edge.x == 64 + echo "RTS camera tests passed"