Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions coworld/tools/test_browser_with_playwright.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 24 additions & 1 deletion docs/replay-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,34 @@ 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`. |

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
Expand All @@ -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`. |
Expand Down Expand Up @@ -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 |
Expand Down
144 changes: 100 additions & 44 deletions examples/gods_of_the_arena/graphics.nim
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -1447,6 +1531,10 @@ proc runGraphics*() =
if focusPlayerHero:
focusPlayerHero = false
startCameraEase(cameraEase, cameraTarget)
if embedded:
updateEmbeddedCamera()
followSelectedCamera(dt)
return
updateMinimapCamera(
window,
sk.mousePos,
Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
25 changes: 17 additions & 8 deletions src/polyworld/replay.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand Down Expand Up @@ -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 {
Expand All @@ -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();
Expand Down Expand Up @@ -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),
Expand Down
Loading