diff --git a/coworld/integration.md b/coworld/integration.md index 98c448ec..7fa98e11 100644 --- a/coworld/integration.md +++ b/coworld/integration.md @@ -3,7 +3,9 @@ `-d:coworld` selects a native headless server. The wrapper is imported only by Coworld builds. Desktop, ordinary headless, and WASM builds do not import Mummy. `-d:emscripten -d:replayViewer` selects the static replay bootstrap and enables -looping. Each game retains its simulation, BASIC resource limits and early endings. +looping by default. Each game retains its simulation, BASIC resource limits and early endings. +Embedding pages can seek, pause, pick and project through the +[replay control API](../docs/replay-control.md), which GotA supports. The packages are `coworld/gota`, `coworld/lvd`, `coworld/cta`, and `coworld/awm`. Each contains a manifest template, Compose definition, unchanged BASIC baseline, @@ -138,8 +140,11 @@ working-directory restoration, and failure logs. Serve the repository over HTTP to run `python3 coworld/tools/test_browser_with_playwright.py`. It checks actual WASM rendering and full replay hashes, seeking, speed, iframe resizing, readiness, -and visible errors. It supports a host Chrome executable or container Chromium for -ARM and x86 coverage. `tools/replay_probe.html` captures the Softmax iframe protocol. +visible errors and the GotA replay control API (`--control-only` runs only that; +`--gpu` adds seek responsiveness checks with hardware rendering). It supports a +host Chrome executable or container Chromium for ARM and x86 coverage. +`coworld/tools/replay_probe.html` captures the Softmax iframe protocol and sends +replay control commands. The GotA check expects a 28,909-tick recording. CTA stores authoritative per-hero banked gold and return flags. They are cloned, restored and hashed with the world. Surviving returned heroes tied diff --git a/coworld/tools/replay_probe.html b/coworld/tools/replay_probe.html index 38b23ccb..ce450bdc 100644 --- a/coworld/tools/replay_probe.html +++ b/coworld/tools/replay_probe.html @@ -11,5 +11,7 @@ window.replayMessages.push(event.data); } }); +// Sends one replay control command over the cross-origin postMessage bridge. +window.sendReplay = data => frame.contentWindow.postMessage({dst: 'coworld-replay', ...data}, '*'); frame.src = new URLSearchParams(location.search).get('viewer'); diff --git a/coworld/tools/test_browser_with_playwright.py b/coworld/tools/test_browser_with_playwright.py index e5e0e45b..93d4ec02 100644 --- a/coworld/tools/test_browser_with_playwright.py +++ b/coworld/tools/test_browser_with_playwright.py @@ -6,16 +6,17 @@ from urllib.parse import quote from playwright.sync_api import sync_playwright -GAMES = {'gota': ('gods_of_the_arena', 18680), 'lvd': ('light_vs_dark', 28800), +GAMES = {'gota': ('gods_of_the_arena', 28909), 'lvd': ('light_vs_dark', 28800), 'cta': ('call_to_adventure', 28800)} -def probe_url(base, game, replay): +def probe_url(base, game, replay, options=''): """Point a test iframe at a static viewer and a public replay file.""" directory = GAMES[game][0] viewer = f'{base}/examples/{directory}/emscripten/{game}.html' if replay: viewer += '#replay=' + quote(base + '/tmp/coworld/' + replay, safe='') + viewer += options return base + '/coworld/tools/replay_probe.html?viewer=' + quote(viewer, safe='') @@ -47,6 +48,10 @@ def check_browser(base, executable, output, games): frame = page.frames[1] assert not frame.evaluate('failed') assert frame.evaluate('Module.replayTick') >= 0 + if game == 'gota': + # Replay builds keep looping unless the page opts out. + frame.wait_for_function('polyworldReplay.ready()') + assert frame.evaluate('polyworldReplay.state().loop') page.screenshot(path=str(output / (game + '.png')), timeout=180000) print(game + ': first frame rendered', flush=True) # Turn looping off, then seek forward through every recorded tick. @@ -77,6 +82,193 @@ def check_browser(base, executable, output, games): (output / 'browser.json').write_text(json.dumps(report, indent=2) + '\n') +def wait_seek(frame, tick, seek_id): + """Wait until the viewer reports a seek as landed, then return its state.""" + frame.wait_for_function( + f'(s => s.seekId === {seek_id} && !s.seeking && s.tick === {tick})' + '(polyworldReplay.state())', timeout=600000) + return frame.evaluate('polyworldReplay.state()') + + +def check_control(base, executable, output, gpu=False): + """Exercise the GotA replay control API, its postMessage bridge and embed. + + Seek responsiveness depends on frame cost, so it is asserted only with a + hardware GPU. Software rendering takes seconds per frame late in a match; + there seeks use one-second slices and the timer gaps are only recorded. + """ + end_tick = GAMES['gota'][1] + report = {} + output.mkdir(parents=True, exist_ok=True) + with sync_playwright() as p: + args = (['--use-angle=metal', '--enable-gpu', '--ignore-gpu-blocklist'] if gpu + else ['--enable-unsafe-swiftshader', '--use-angle=swiftshader']) + options = {'headless': True, 'args': args} + if executable: + options['executable_path'] = executable + browser = p.chromium.launch(**options) + page = browser.new_page(viewport={'width': 1280, 'height': 576}) + page.set_default_timeout(120000) + errors = [] + page.on('pageerror', lambda error: errors.append(str(error))) + slice_option = '' if gpu else '&seekSlice=1000' + page.goto(probe_url(base, 'gota', 'gota.replay', '&embed=1&loop=0' + slice_option)) + page.wait_for_function('replayMessages.some(x => x.type === "ready")') + frame = page.frames[1] + frame.wait_for_function('polyworldReplay.ready()') + replay = 'polyworldReplay' + frame.evaluate('''() => { + window.nextFrames = count => new Promise(resolve => { + const stop = polyworldReplay.onFrame(() => { if (--count <= 0) { stop(); resolve(); } }); + }); + }''') + state = frame.evaluate(replay + '.state()') + assert not state['loop'] and len(state['viewProjection']) == 16, state + assert state['endTick'] == end_tick, state + frame.evaluate(replay + '.pause()') + + # A cold seek to the end runs in slices: page timers keep firing and + # frames report progress until the seek lands exactly. + frame.evaluate('''() => { + window.heartbeat = {last: performance.now(), worst: 0}; + setInterval(() => { + const now = performance.now(); + heartbeat.worst = Math.max(heartbeat.worst, now - heartbeat.last); + heartbeat.last = now; + }, 10); + window.seekTicks = new Set(); + polyworldReplay.onFrame(s => { if (s.seeking) seekTicks.add(s.tick); }); + }''') + frame.evaluate('nextFrames(2)') + frame.evaluate('() => { heartbeat.worst = 0; }') + started = time.monotonic() + frame.evaluate(f'{replay}.seek({end_tick}, 1)') + state = wait_seek(frame, end_tick, 1) + report['cold_end_seek'] = { + 'seconds': round(time.monotonic() - started, 1), + 'worst_timer_gap_ms': round(frame.evaluate('heartbeat.worst')), + 'progress_frames': frame.evaluate('seekTicks.size')} + assert not state['playing'], state + assert report['cold_end_seek']['progress_frames'] >= 3, report + if gpu: + # Unsliced, this seek blocked the page for the whole seek. + assert report['cold_end_seek']['worst_timer_gap_ms'] < 1000, report + assert report['cold_end_seek']['progress_frames'] >= 100, report + print('control: cold seek to last tick landed', report['cold_end_seek'], flush=True) + + # Exact seeks in both directions, then cancel a long forward seek. + for seek_id, tick in enumerate([3000, 12000, 500, end_tick, 1500], start=2): + frame.evaluate(f'{replay}.seek({tick}, {seek_id})') + wait_seek(frame, tick, seek_id) + frame.evaluate(f'{replay}.seek({end_tick}, 9)') + frame.wait_for_function('(s => s.seeking && s.tick > 1600)(polyworldReplay.state())') + frame.evaluate(replay + '.cancelSeek()') + frame.evaluate('nextFrames(1)') + cancelled = frame.evaluate(replay + '.state()') + assert not cancelled['seeking'] and cancelled['seekId'] == 9, cancelled + frame.evaluate('nextFrames(3)') + state = frame.evaluate(replay + '.state()') + assert state['tick'] == cancelled['tick'] < end_tick and not state['playing'], state + report['cancelled_at'] = cancelled['tick'] + print('control: exact seeks, pause and cancel passed', flush=True) + + # Speeds map onto the supported multipliers and playback advances. + assert frame.evaluate(replay + '.setSpeed(8)') == 4 + assert frame.evaluate(replay + '.setSpeed(2)') == 2 + frame.evaluate(replay + '.play()') + first = frame.evaluate(replay + '.state().tick') + page.wait_for_timeout(2000) + state = frame.evaluate(replay + '.state()') + frame.evaluate(replay + '.pause()') + assert state['playing'] and state['speed'] == 2 and state['tick'] > first, state + report['ticks_per_second_at_2x'] = (state['tick'] - first) / 2 + print('control: speed passed', flush=True) + + # A frozen camera holds still while playing; projection round-trips. + frame.evaluate(replay + ".camera({mode: 'fixed', x: 0, z: 0, distance: 40})") + frame.evaluate('nextFrames(2)') + frame.evaluate(replay + '.freezeCamera()') + frame.evaluate(replay + '.play()') + frame.evaluate('nextFrames(1)') + matrix = frame.evaluate(replay + '.state().viewProjection') + frame.evaluate('nextFrames(3)') + assert frame.evaluate(replay + '.state().viewProjection') == matrix + frame.evaluate(replay + '.pause()') + errors_px = frame.evaluate('''() => { + const out = []; + for (let x = 200; x <= 1080; x += 110) for (let y = 100; y <= 476; y += 94) { + const world = polyworldReplay.unproject({x, y}); + if (!world) continue; + const back = polyworldReplay.project(world); + out.push(Math.hypot(back.x - x, back.y - y)); + } + return out.sort((a, b) => a - b); + }''') + p90 = errors_px[len(errors_px) * 9 // 10] + assert len(errors_px) >= 30 and p90 < 0.5, errors_px + report['round_trip_px_p90'] = p90 + + # Picking at a projected hero torso returns that hero. + heroes = [(frame.evaluate(f'{replay}.unit({hero})') or {}) | {'id': hero} + for hero in range(100, 110)] + assert all(h.get('seat') == h['id'] - 100 for h in heroes), heroes + alive = [h['id'] for h in heroes if h['alive']] + hits = 0 + for hero in alive: + unit = frame.evaluate(f'{replay}.unit({hero})') + frame.evaluate(f"{replay}.camera({{mode: 'fixed', x: {unit['x']}, z: {unit['z']}, distance: 20}})") + frame.evaluate('nextFrames(2)') + unit = frame.evaluate(f'{replay}.unit({hero})') + screen = frame.evaluate(f"{replay}.project({{x: {unit['x']}, y: {unit['y']} + 0.8, z: {unit['z']}}})") + hits += frame.evaluate(f'{replay}.pick({json.dumps(screen)})') == hero + report['picks'] = {'alive': len(alive), 'hits': hits} + assert alive and hits >= len(alive) * 0.6, report['picks'] + print('control: projection and picking passed', report['picks'], flush=True) + + # The same commands work over postMessage, with throttled tick events. + page.evaluate('''() => { + replayMessages.length = 0; + sendReplay({type: 'subscribe', tick: true}); + sendReplay({type: 'seek', tick: 4000, seekId: 20}); + sendReplay({type: 'project', x: 0, y: 0, z: 0, requestId: 'p1'}); + }''') + page.wait_for_function('replayMessages.some(m => m.type === "tick" && ' + 'm.seekId === 20 && !m.seeking && m.tick === 4000)', timeout=600000) + page.wait_for_function('replayMessages.some(m => m.requestId === "p1")') + result = page.evaluate('replayMessages.find(m => m.requestId === "p1")') + assert result['result'] and 'x' in result['result'], result + page.evaluate("() => { replayMessages.length = 0; sendReplay({type: 'speed', speed: 16}); sendReplay({type: 'play'}); }") + page.wait_for_timeout(2000) + page.evaluate("() => sendReplay({type: 'pause'})") + page.wait_for_function('replayMessages.some(m => m.type === "tick" && !m.playing)') + ticks = page.evaluate('replayMessages.filter(m => m.type === "tick")') + assert 2 <= len(ticks) <= 25 and any(t['speed'] == 16 for t in ticks), ticks + assert not page.evaluate('replayMessages.some(m => m.type === "frame")') + print('control: postMessage bridge passed', flush=True) + + # Embedded viewers keep input from the game and forward keys. + page.evaluate('() => { replayMessages.length = 0; }') + frame.evaluate(replay + '.freezeCamera()') + frame.evaluate('nextFrames(2)') + matrix = frame.evaluate(replay + '.state().viewProjection') + page.mouse.move(640, 288) + page.mouse.wheel(0, -600) + page.mouse.click(640, 288) + frame.locator('canvas').focus() + page.keyboard.press('Space') + frame.evaluate('nextFrames(3)') + state = frame.evaluate(replay + '.state()') + assert not state['playing'] and state['viewProjection'] == matrix, state + keys = page.evaluate('replayMessages.filter(m => m.type === "key")') + 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) + assert not errors, errors + assert not frame.evaluate('failed') + browser.close() + (output / 'control.json').write_text(json.dumps(report, indent=2) + '\n') + + def check_director(base, executable, output, games, layout_only=False): """Check fixed framing and viewing-time scheduling in directorProbe builds.""" output.mkdir(parents=True, exist_ok=True) @@ -198,9 +390,17 @@ def check_director(base, executable, output, games, layout_only=False): help='Test viewers built with -d:directorProbe') parser.add_argument('--director-layout', action='store_true', help='Only inspect the three-column director layout') + parser.add_argument('--control-only', action='store_true', + help='Only check the GotA replay control API') + parser.add_argument('--gpu', action='store_true', + help='Check replay control with hardware rendering (macOS Metal)') args = parser.parse_args() if args.director or args.director_layout: check_director(args.base, args.executable, args.output, args.games, args.director_layout) + elif args.control_only: + check_control(args.base, args.executable, args.output, args.gpu) else: check_browser(args.base, args.executable, args.output, args.games) + if 'gota' in args.games: + check_control(args.base, args.executable, args.output, args.gpu) diff --git a/docs/replay-control.md b/docs/replay-control.md new file mode 100644 index 00000000..5bef0133 --- /dev/null +++ b/docs/replay-control.md @@ -0,0 +1,103 @@ +# Replay control + +Browser replay viewers (`-d:emscripten -d:replayViewer`) can be driven by the page +that embeds them: seek, play, pause, speed, looping, camera, and queries that map +between the screen and the world. Gods of the Arena installs it; other games load +normally but report `ready() === false`. + +The game side is `src/polyworld/replaycontrols.nim`. A game calls +`installReplayControl(transport, hooks)` once and `publishReplayControlFrame` after +presenting each frame. `ReplayControlHooks` supplies the game-specific parts: +ground height, picking, unit lookup and camera modes. The page side is +`src/polyworld/replay.html`. + +## Viewer URL options + +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. | +| `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. + +## Same-origin hosts + +`iframe.contentWindow.polyworldReplay` is synchronous. Screen positions are CSS +pixels in the viewer document. World positions are the game's render space; for +GotA that is `simulation / WorldScale`, centred on the map, with y up. + +| Call | Result | +| --- | --- | +| `ready()` | `true` once the first controlled frame has been drawn. | +| `state()` | The latest frame: `tick`, `endTick`, `playing`, `seeking`, `speed`, `loop`, `seekId`, `seekTick`, `viewProjection` (16 floats, column-major), framebuffer `width`/`height`, `cssWidth`/`cssHeight`. | +| `onFrame(fn)` | Calls `fn(state)` after every presented frame. Returns an unsubscribe function. | +| `onKey(fn)` | With `embed=1`, calls `fn({event, key, code, altKey, ctrlKey, metaKey, shiftKey, repeat})` for key presses. | +| `seek(tick, seekId = 0)` | Starts an exact seek and keeps the play state. Frames report `seekId` and `seekTick` until another seek replaces it; the seek has landed when `seekId` matches, `seeking` is false and `tick === seekTick`. | +| `cancelSeek()` | Stops a seek, including a pending backward restore, on the last simulated tick. | +| `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. | +| `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`. | +| `groundY(x, z)` | Drawn ground height. | +| `pick({x, y})` | The unit drawn under a screen position, or 0. Dying units are not pickable. | +| `unit(id)` | `{x, y, z, seat, alive}` for the drawn position, or `null` when absent. `seat` is -1 for units no player controls. | + +Overlays should reproject from `state().viewProjection` on every frame: the +director moves the camera continuously and units are interpolated between ticks. + +```js +const replay = iframe.contentWindow.polyworldReplay; +replay.pause(); +replay.seek(12000, 1); +replay.onFrame(frame => { + if (frame.seekId === 1 && !frame.seeking) drawOverlay(frame.viewProjection); +}); +``` + +## postMessage bridge + +Cross-origin hosts post `{dst: 'coworld-replay', type, ...}` to the iframe. Only +messages from the parent window are accepted. Commands sent before the viewer is +ready run once it is. + +| `type` | Fields | +| --- | --- | +| `seek` | `tick`, optional `seekId` | +| `cancelSeek`, `play`, `pause` | | +| `speed` | `speed` | +| `loop` | `loop` | +| `seekSlice` | `milliseconds` | +| `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 | + +Any command with a `requestId` is answered with +`{src: 'coworld-replay', type: 'result', requestId, result}` or `error`. + +Outbound messages keep the existing `loading`, `phase`, `ready` and `error` types. +Hosts that subscribe also receive: + +- `tick`: `{tick, endTick, playing, seeking, speed, loop, seekId, seekTick}`, sent + at once when anything except the tick changes and at most every 100 ms while + only the tick advances. +- `frame`: the full `state()` after every presented frame. +- `key`: forwarded key presses with `embed=1` (always sent; no subscription). + +## Verification + +`nim r tests/test_player.nim` and `nim r tests/test_replaycontrols.nim` cover the +transport changes and the exports natively. The browser test +(`coworld/tools/test_browser_with_playwright.py`, or `--control-only` for just +this part) checks a cold seek to the last tick for progress, exact seeks, cancel, +pause, speed, frozen cameras, projection round trips, picking, the postMessage +bridge and embed input handling. Page responsiveness during the seek is asserted +only with `--gpu` (hardware rendering through Metal): SwiftShader takes seconds +per frame late in a match, so there the timer gaps are only recorded. diff --git a/examples/gods_of_the_arena/graphics.nim b/examples/gods_of_the_arena/graphics.nim index a05d421a..6ea06b48 100644 --- a/examples/gods_of_the_arena/graphics.nim +++ b/examples/gods_of_the_arena/graphics.nim @@ -14,8 +14,8 @@ import polyworld/quadterrain, polyworld/shadows, polyworld/terrainsurfaces, polyworld/waters, - polyworld/[chrome, inputs, rtscameras, selectionoutlines, shapes, viewers, - visions, worldbars, worldtexts] + polyworld/[chrome, inputs, replaycontrols, rtscameras, selectionoutlines, + shapes, viewers, visions, worldbars, worldtexts] when defined(takeScreenshot): import std/[os, strutils] @@ -897,11 +897,12 @@ proc runGraphics*() = sk.atlasTextureId() ) - proc pickEntity(viewProjection: Mat4): int32 = + proc pickEntity(viewProjection: Mat4, + mouse = window.mousePos.vec2): int32 = ## Finds the closest visible mesh under the pointer by triangle hit. let (origin, dir) = mouseRay( - window.mousePos.vec2, + mouse, window.size.vec2, viewProjection ) @@ -1999,6 +2000,60 @@ proc runGraphics*() = followSelection = false actionCam.takeManual() + proc controlGroundY(x, z: float32): float32 = + ## Returns the drawn height of the topmost surface for replay control. + surfaceHeight(x, z) + groundOffset(x, z) + + proc controlUnit(id: int32): ReplayUnit = + ## Reports where replay control should find one selectable object. + let target = selectionTarget(id) + result = ReplayUnit( + found: target.found, + alive: target.found, + position: target.position, + seat: -1 + ) + for slot, hero in run.world.heroes: + if hero.id == id: + result.seat = int32(slot) + result.alive = target.found and hero.state != Dying and hero.hp > 0 + let footman = footmanById(run.world, id) + if footman.id != 0: + result.alive = target.found and footman.state != Dying and + footman.hp > 0 + + proc applyControlCamera(request: ReplayCameraRequest) = + ## Hands the camera to the director, a fixed view, or a followed unit. + cancelCameraEase(cameraEase) + case request.mode + of DirectorCamera: + if not actionCam.enabled: + actionCam.toggle(followSelection) + of FixedCamera: + actionCam.takeManual() + followSelection = false + cameraTarget = vec3( + request.target.x, + controlGroundY(request.target.x, request.target.y), + request.target.y + ) + if request.distance > 0: + cameraDistance = request.distance + 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 = + pickEntity(viewProjection, screen), + unit: controlUnit, + camera: applyControlCamera + )) + var viewingClock: ViewingClock cameraSeekSerial = -1 @@ -2382,7 +2437,7 @@ proc runGraphics*() = drawCreepWaypoints(viewProjection, barCameraRight, barCameraUp) profileBlock "ui": - if not cleanScreenshot: + if not cleanScreenshot and not embedded: glDisable(GL_DEPTH_TEST) glDisable(GL_CULL_FACE) glDisable(GL_BLEND) @@ -2419,6 +2474,7 @@ proc runGraphics*() = actionCam, transport, cameraDistance, int32(run.hashCheck.mismatches) ) reportReplayFrame(run.world.tick, int32(run.hashCheck.mismatches)) + publishReplayControlFrame(viewProjection, window.size) if noteProfileFrame(): when not defined(emscripten): window.closeRequested = true diff --git a/src/polyworld/player.nim b/src/polyworld/player.nim index 50bfc690..06805908 100644 --- a/src/polyworld/player.nim +++ b/src/polyworld/player.nim @@ -56,6 +56,8 @@ type accumulator*: float32 seekSerial*: int automaticSeek*: bool + catchUpSeconds*: float64 + ## Simulation time budget per frame while seeking. tickRate: int32 proc speed*(player: Player): int32 = @@ -97,6 +99,7 @@ proc initPlayer*( result.speedIndex = speedIndexOf(speed) result.restoreTick = -1 result.targetTick = -1 + result.catchUpSeconds = CatchUpSeconds proc sync*( player: var Player, @@ -160,6 +163,13 @@ proc play*(player: var Player) = else: player.playing = true +proc cancelSeek*(player: var Player) = + ## Abandons a seek, including a pending restore, on the last simulated + ## tick. The play state is unchanged. + player.targetTick = -1 + player.restoreTick = -1 + player.accumulator = 0 + proc togglePlay*(player: var Player) = ## Flips between play and pause, including during catch-up. if player.playing or player.targetTick >= 0: @@ -201,7 +211,8 @@ proc shouldTick*( frameStart: float64 ): bool = ## Returns whether one more simulation tick belongs in this frame. - ## Paused seeks run until they land so single-tick steps stay exact. + ## Seeks yield after `catchUpSeconds` so the page stays responsive, and + ## paused seeks stop exactly on their target. if player.reachedEnd: if player.repeating: player.seekTo(0, automatic = true) @@ -211,9 +222,9 @@ proc shouldTick*( player.accumulator = 0 return false if player.targetTick >= 0: - if not player.playing: - return player.tick < player.targetTick - return epochTime() - frameStart < CatchUpSeconds + if not player.playing and player.tick >= player.targetTick: + return false + return epochTime() - frameStart < player.catchUpSeconds if not player.playing: return false let step = 1.0'f32 / max(player.tickRate, 1).float32 diff --git a/src/polyworld/replay.html b/src/polyworld/replay.html index 84cd4e60..3da3c0fd 100644 --- a/src/polyworld/replay.html +++ b/src/polyworld/replay.html @@ -26,6 +26,32 @@ function message(type, extra = {}) { if (window.parent !== window) window.parent.postMessage({src:'coworld-replay', type, ...extra}, '*'); } +function option(name) { + return new URLSearchParams(location.hash.slice(1)).get(name) || + new URLSearchParams(location.search).get(name); +} +// 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; +// 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(); +if (embed) { + for (const type of ['keydown', 'keyup', 'keypress', 'mousedown', 'mouseup', + 'mousemove', 'click', 'dblclick', 'wheel', 'pointerdown', 'pointerup', + 'pointermove', 'touchstart', 'touchmove', 'touchend']) { + window.addEventListener(type, event => { + event.stopImmediatePropagation(); + if (type !== 'keydown' && type !== 'keyup') return; + const {key, code, altKey, ctrlKey, metaKey, shiftKey, repeat} = event; + const press = {event: type, key, code, altKey, ctrlKey, metaKey, shiftKey, repeat}; + for (const listener of keyListeners) listener(press); + message('key', press); + }, {capture: true}); + } +} function fail(reason) { if (failed) return; failed = true; @@ -40,6 +66,8 @@ window.addEventListener('unhandledrejection', event => fail(event.reason)); var Module = { canvas: document.getElementById('canvas'), + polyworldEmbed: embed, + polyworldLoop: loop === null ? undefined : loop !== '0', arguments: ['--replay', '/episode.replay'], print: text => console.info(text), printErr: text => { console.error(text); if (/Error|Exception|mismatch|fatal/i.test(text)) fail(text); }, @@ -61,8 +89,7 @@ addRunDependency('episode-replay'); (async () => { message('phase', {phase:'bundle_ready'}); - const uri = new URLSearchParams(location.hash.slice(1)).get('replay') || - new URLSearchParams(location.search).get('replay'); + const uri = option('replay'); if (!uri) throw new Error('No replay was supplied. Open a completed episode replay.'); message('phase', {phase:'replay_fetch_start'}); const response = await fetch(uri); @@ -95,6 +122,117 @@ })().catch(fail); }] }; + +// Replay control for host pages. Positions are CSS pixels in this document. +// Same-origin hosts call window.polyworldReplay; others use postMessage. +const control = (() => { + const call = (name, ...args) => Module['_polyworld_replay_' + name](...args); + const result = index => call('result', index); + const scale = () => ({ + x: Module.canvas.width / (Module.canvas.clientWidth || 1), + y: Module.canvas.height / (Module.canvas.clientHeight || 1) + }); + const cameraModes = {director: 0, fixed: 1, follow: 2, freeze: 3}; + const listeners = new Set(); + let state = null; + Module.polyworldControlFrame = frame => { + state = {...frame, cssWidth: Module.canvas.clientWidth, cssHeight: Module.canvas.clientHeight}; + for (const listener of listeners) listener(state); + }; + return { + ready: () => state !== null, + state: () => state, + onFrame: listener => { listeners.add(listener); return () => listeners.delete(listener); }, + onKey: listener => { keyListeners.add(listener); return () => keyListeners.delete(listener); }, + seek: (tick, seekId = 0) => call('seek', Math.round(tick), seekId | 0), + cancelSeek: () => call('cancel_seek'), + play: () => call('play'), + pause: () => call('pause'), + 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}) => { + if (!(mode in cameraModes)) throw new Error('Unknown camera mode: ' + mode); + call('camera', cameraModes[mode], id, x, z, distance); + }, + freezeCamera: () => call('camera', cameraModes.freeze, 0, 0, 0, 0), + project: ({x, y, z}) => { + if (!call('project', x, y, z)) return null; + const s = scale(); + return {x: result(0) / s.x, y: result(1) / s.y}; + }, + unproject: ({x, y}) => { + const s = scale(); + if (!call('unproject', x * s.x, y * s.y)) return null; + return {x: result(0), y: result(1), z: result(2)}; + }, + groundY: (x, z) => call('ground_y', x, z), + pick: ({x, y}) => { const s = scale(); return call('pick', x * s.x, y * s.y); }, + unit: id => { + if (!call('unit', id)) return null; + return {x: result(0), y: result(1), z: result(2), seat: result(3), alive: result(4) !== 0}; + } + }; +})(); +window.polyworldReplay = control; + +// postMessage bridge: {dst:'coworld-replay', type, ...} from the parent window. +// Commands queue until control is ready. Queries reply with a result message. +(() => { + const TickIntervalMs = 100; + const queue = []; + let tickEvents = false, frameEvents = false, lastTick = null, lastTickAt = 0; + const commands = { + seek: data => control.seek(data.tick, data.seekId), + cancelSeek: () => control.cancelSeek(), + play: () => control.play(), + pause: () => control.pause(), + speed: data => control.setSpeed(data.speed), + loop: data => control.setLoop(data.loop), + seekSlice: data => control.setSeekSlice(data.milliseconds), + camera: data => control.camera(data), + project: data => control.project(data), + unproject: data => control.unproject(data), + pick: data => control.pick(data), + unit: data => control.unit(data.id), + state: () => control.state() + }; + function run(data) { + try { + const value = commands[data.type](data); + if (data.requestId !== undefined) message('result', {requestId: data.requestId, result: value ?? null}); + } catch (error) { + message('result', {requestId: data.requestId, error: String(error?.message || error)}); + } + } + window.addEventListener('message', event => { + const data = event.data; + if (event.source !== window.parent || data?.dst !== 'coworld-replay') return; + if (data.type === 'subscribe') { + tickEvents = !!data.tick; + frameEvents = !!data.frame; + lastTick = null; + } else if (data.type in commands) { + if (control.ready()) run(data); else queue.push(data); + } + }); + if (seekSlice) queue.push({type: 'seekSlice', milliseconds: Number(seekSlice)}); + control.onFrame(frame => { + while (queue.length) run(queue.shift()); + if (frameEvents) message('frame', frame); + if (!tickEvents) return; + // Discrete changes post at once; tick advances post at most every 100 ms. + const {tick, endTick, playing, seeking, speed, loop, seekId, seekTick} = frame; + const next = {tick, endTick, playing, seeking, speed, loop, seekId, seekTick}; + const now = performance.now(); + const changed = lastTick === null || Object.keys(next).some(key => key !== 'tick' && next[key] !== lastTick[key]); + if (changed || (tick !== lastTick.tick && now - lastTickAt >= TickIntervalMs)) { + lastTick = next; + lastTickAt = now; + message('tick', next); + } + }); +})(); Module.canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); fail('The graphics context was lost. Reload the replay.'); }); diff --git a/src/polyworld/replaycontrols.nim b/src/polyworld/replaycontrols.nim new file mode 100644 index 00000000..80021c3d --- /dev/null +++ b/src/polyworld/replaycontrols.nim @@ -0,0 +1,301 @@ +## Programmatic control of the browser replay viewer for embedding pages. +## +## A game opts in by calling `installReplayControl` once its transport and +## camera exist, then `publishReplayControlFrame` after presenting each frame. +## The C exports below are what `replay.html` wraps as `window.polyworldReplay` +## and its postMessage bridge. They run between frames while the main loop +## waits for the display, so they may change the transport directly. +## +## Screen positions are framebuffer pixels with the origin at the top left. +## World positions are the game's render space. Vector results are read back +## one component at a time through `polyworld_replay_result`. + +import + vmath, + player, rtscameras + +const EmbeddedCatchUpSeconds* = 0.033 + ## Embedded viewers seek in short slices so the host page stays responsive. + +type + ReplayCameraMode* = enum + DirectorCamera + ## The game's automatic camera chooses the shots. + FixedCamera + ## A still camera over `target` at `distance`. + FollowCamera + ## The camera follows unit `id`. + FrozenCamera + ## The camera holds its current pose. + + ReplayCameraRequest* = object + mode*: ReplayCameraMode + target*: Vec2 + ## Render-space x and z for `FixedCamera`. + distance*: float32 + ## Camera distance for `FixedCamera`; zero or less keeps the current one. + id*: int32 + ## Unit for `FollowCamera`. + + ReplayUnit* = object + found*: bool + ## Whether the unit exists and is visible in the current view. + alive*: bool + position*: Vec3 + ## Drawn render-space position, including interpolation. + seat*: int32 + ## Zero-based player seat for a player-controlled unit, else -1. + + ReplayControlHooks* = object + ## Game callbacks for queries that need the game's own scene. + groundY*: proc(x, z: float32): float32 + ## Drawn ground height at a render-space x and z. + pick*: proc(screen: Vec2, viewProjection: Mat4): int32 + ## Returns the unit drawn under a framebuffer pixel, or 0. + unit*: proc(id: int32): ReplayUnit + camera*: proc(request: ReplayCameraRequest) + + ReplayControl = object + transport: ptr Player + hooks: ReplayControlHooks + viewProjection: Mat4 + viewport: Vec2 + seekId: int32 + ## Caller's identifier for the last seek requested through control. + seekTick: int32 + seekSerial: int + ## Transport seek serial of that request, to notice later seeks. + +var + control: ReplayControl + results: array[8, float32] + +when defined(emscripten): + {.emit: "#include ".} + {.pragma: replayExport, exportc, cdecl, + codegenDecl: "EMSCRIPTEN_KEEPALIVE $# $#$#".} +else: + {.pragma: replayExport, exportc, cdecl.} + +proc replayEmbedded*(): bool = + ## Returns whether the page asked for a bare canvas driven by its host. + ## Embedded viewers hide their HUD; `replay.html` blocks direct input. + when defined(emscripten): + var embedded: cint + {.emit: "`embedded` = EM_ASM_INT({ return Module.polyworldEmbed ? 1 : 0; });".} + embedded != 0 + else: + false + +proc replayLoopOption(): int32 = + ## Returns 0 or 1 when the page chose looping, or -1 to keep the default. + when defined(emscripten): + var loop: cint + {.emit: """`loop` = EM_ASM_INT({ + return Module.polyworldLoop === undefined ? -1 : (Module.polyworldLoop ? 1 : 0); + });""".} + loop.int32 + else: + -1 + +proc installReplayControl*(transport: var Player, hooks: ReplayControlHooks) = + ## Enables the exports for one viewer. `transport` must outlive the viewer. + control = ReplayControl(transport: transport.addr, hooks: hooks, seekTick: -1) + if replayEmbedded(): + transport.catchUpSeconds = EmbeddedCatchUpSeconds + let loop = replayLoopOption() + if loop >= 0: + transport.repeating = loop == 1 + +proc installed(): bool = + control.transport != nil + +proc projectToScreen*( + point: Vec3, + viewProjection: Mat4, + viewport: Vec2, + screen: var Vec2 +): bool = + ## Projects a world point to pixels. Returns false behind the camera. + let clip = viewProjection * vec4(point.x, point.y, point.z, 1) + if clip.w <= 0: + return false + screen = vec2( + (clip.x / clip.w * 0.5'f32 + 0.5'f32) * viewport.x, + (0.5'f32 - clip.y / clip.w * 0.5'f32) * viewport.y + ) + true + +proc unprojectToGround*( + screen: Vec2, + viewProjection: Mat4, + viewport: Vec2, + groundY: proc(x, z: float32): float32, + point: var Vec3 +): bool = + ## Intersects the pixel's camera ray with the drawn ground. + ## Each pass re-aims at the height under the previous hit, which converges + ## quickly on gentle terrain. Returns false for rays parallel to the ground. + let (origin, dir) = mouseRay(screen, viewport, viewProjection) + if abs(dir.y) < 1e-6'f32: + return false + var y = 0.0'f32 + for i in 0 ..< 6: + point = origin + dir * ((y - origin.y) / dir.y) + y = groundY(point.x, point.z) + point.y = y + true + +proc publishReplayControlFrame*(viewProjection: Mat4, viewport: IVec2) = + ## Records the presented camera and reports the frame to the page. + ## Call it after presenting, next to `reportReplayFrame`. + if not installed(): + return + control.viewProjection = viewProjection + control.viewport = viewport.vec2 + when defined(emscripten): + let + matrix = cast[ptr UncheckedArray[float32]](control.viewProjection.addr) + transport = control.transport[] + tick = transport.tick + endTick = transport.timelineEnd + playing = transport.playing + seeking = transport.targetTick >= 0 or transport.restoreTick >= 0 + speed = transport.speed + looping = transport.repeating + width = viewport.x + height = viewport.y + current = transport.seekSerial == control.seekSerial + seekId = if current: control.seekId else: 0'i32 + seekTick = if current: control.seekTick else: -1'i32 + {.emit: """ + EM_ASM({ + if (!Module.polyworldControlFrame) return; + var viewProjection = Array.from(HEAPF32.subarray($0 >> 2, ($0 >> 2) + 16)); + Module.polyworldControlFrame({ + tick: $1, endTick: $2, playing: !!$3, seeking: !!$4, speed: $5, + loop: !!$6, width: $7, height: $8, seekId: $9, seekTick: $10, + viewProjection: viewProjection + }); + }, `matrix`, `tick`, `endTick`, `playing`, `seeking`, `speed`, `looping`, + `width`, `height`, `seekId`, `seekTick`); + """.} + +proc polyworld_replay_ready*(): int32 {.replayExport.} = + ## Returns 1 once the game has installed replay control. + int32(installed()) + +proc polyworld_replay_result*(index: int32): float32 {.replayExport.} = + ## Reads one component written by the last vector-returning call. + results[clamp(index, 0, results.high)] + +proc polyworld_replay_tick*(): int32 {.replayExport.} = + ## Returns the current simulation tick, or -1 before installation. + if installed(): control.transport[].tick else: -1 + +proc polyworld_replay_end_tick*(): int32 {.replayExport.} = + ## Returns the last tick of the replay, or -1 before installation. + if installed(): control.transport[].timelineEnd else: -1 + +proc polyworld_replay_seek*(tick, seekId: int32) {.replayExport.} = + ## Starts an exact seek, keeping the play state. It lands over later + ## frames, which report `seekId` until another seek replaces it. + if not installed(): + return + let transport = control.transport + transport[].seekTo(tick, play = transport.playing) + control.seekId = seekId + control.seekTick = clamp(tick, 0'i32, transport[].timelineEnd) + control.seekSerial = transport.seekSerial + +proc polyworld_replay_cancel_seek*() {.replayExport.} = + ## Stops a seek on the last simulated tick, keeping the play state. + if installed(): + control.transport[].cancelSeek() + +proc polyworld_replay_play*() {.replayExport.} = + ## Starts playback, rewinding first when already at the end. + if installed(): + control.transport[].play() + +proc polyworld_replay_pause*() {.replayExport.} = + ## Stops playback and any seek in progress. + if installed(): + control.transport[].pause() + +proc polyworld_replay_set_speed*(speed: int32): int32 {.replayExport.} = + ## Selects the fastest supported multiplier at or below `speed`. + ## Returns the applied multiplier. + if not installed(): + return 0 + control.transport[].setSpeed(speedIndexOf(speed)) + control.transport[].speed + +proc polyworld_replay_set_loop*(loop: int32) {.replayExport.} = + ## Turns rewinding to the first tick at the end on or off. + if installed(): + control.transport.repeating = loop != 0 + +proc polyworld_replay_set_seek_slice*(milliseconds: float32) {.replayExport.} = + ## Sets how long each frame may simulate while seeking. Shorter slices keep + ## the page more responsive; longer ones finish seeks sooner. + if installed(): + control.transport.catchUpSeconds = clamp(milliseconds, 1, 1000) / 1000 + +proc polyworld_replay_camera*( + mode, id: int32, + x, z, distance: float32 +) {.replayExport.} = + ## Selects the director (0), a fixed view over x and z (1), follows + ## unit `id` (2), or holds the current view (3). + 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, + id: id + )) + +proc polyworld_replay_project*(x, y, z: float32): int32 {.replayExport.} = + ## Projects a world point to pixels in results 0 and 1. Returns 1 on success. + var screen: Vec2 + if installed() and projectToScreen( + vec3(x, y, z), control.viewProjection, control.viewport, screen): + results[0] = screen.x + results[1] = screen.y + return 1 + +proc polyworld_replay_unproject*(x, y: float32): int32 {.replayExport.} = + ## Finds the ground point under a pixel in results 0 to 2. + var point: Vec3 + if installed() and unprojectToGround(vec2(x, y), control.viewProjection, + control.viewport, control.hooks.groundY, point): + results[0] = point.x + results[1] = point.y + results[2] = point.z + return 1 + +proc polyworld_replay_ground_y*(x, z: float32): float32 {.replayExport.} = + ## Returns the drawn ground height at a world x and z. + if installed(): control.hooks.groundY(x, z) else: 0 + +proc polyworld_replay_pick*(x, y: float32): int32 {.replayExport.} = + ## Returns the unit drawn under a pixel, or 0. + if installed(): control.hooks.pick(vec2(x, y), control.viewProjection) + else: 0 + +proc polyworld_replay_unit*(id: int32): int32 {.replayExport.} = + ## Writes a unit's drawn position to results 0 to 2, its seat to 3 and + ## 1 or 0 for alive to 4. Returns 0 when the unit is not found. + if not installed(): + return 0 + let unit = control.hooks.unit(id) + if not unit.found: + return 0 + results[0] = unit.position.x + results[1] = unit.position.y + results[2] = unit.position.z + results[3] = unit.seat.float32 + results[4] = float32(unit.alive) + 1 diff --git a/tests/test_player.nim b/tests/test_player.nim index 5bece51f..548d34c0 100644 --- a/tests/test_player.nim +++ b/tests/test_player.nim @@ -102,18 +102,41 @@ block: doAssert p.accumulator == 0 discard p.takeRestore() p.sync(24, 50, false) - doAssert p.shouldTick(0) + doAssert p.shouldTick(epochTime()) p.sync(48, 50, false) - doAssert p.shouldTick(0) + doAssert p.shouldTick(epochTime()) p.sync(49, 50, false) - doAssert not p.shouldTick(0) + doAssert not p.shouldTick(epochTime()) p.stepForward() doAssert not p.playing doAssert p.restoreTick == -1 doAssert p.targetTick == 50 - doAssert p.shouldTick(0) + doAssert p.shouldTick(epochTime()) p.sync(50, 50, false) - doAssert not p.shouldTick(0) + doAssert not p.shouldTick(epochTime()) + +echo "Testing paused seeks yield after the frame budget" +block: + var p = initPlayer(live = false, durationTicks = 1000, playing = false) + p.sync(0, 1000, false) + p.seekTo(900, play = false) + doAssert p.shouldTick(epochTime()) + doAssert not p.shouldTick(epochTime() - p.catchUpSeconds - 0.01) + doAssert p.targetTick == 900 + doAssert not p.playing + +echo "Testing cancel seek clears the target and a pending restore" +block: + var p = initPlayer(live = false, durationTicks = 1000, playing = false) + p.sync(500, 1000, false) + p.seekTo(100, play = false) + doAssert p.restoreTick == 100 + p.cancelSeek() + doAssert p.targetTick == -1 + doAssert p.restoreTick == -1 + doAssert p.takeRestore() == -1 + doAssert not p.shouldTick(epochTime()) + doAssert p.tick == 500 echo "Testing speed buttons clamp to 1, 2, 4, 16" block: diff --git a/tests/test_replaycontrols.nim b/tests/test_replaycontrols.nim new file mode 100644 index 00000000..efba7749 --- /dev/null +++ b/tests/test_replaycontrols.nim @@ -0,0 +1,69 @@ +import + vmath, + polyworld/[player, replaycontrols] + +proc flatGround(x, z: float32): float32 = + 0.5'f32 + +echo "Testing projection round-trips through the drawn ground" +block: + let + viewport = vec2(1280, 576) + viewProjection = + perspective(45.0'f32, viewport.x / viewport.y, 0.1'f32, 1000.0'f32) * + lookAt(vec3(0, 30, 20), vec3(0, 0, 0), vec3(0, 1, 0)) + for screen in [vec2(640, 288), vec2(100, 500), vec2(1200, 90)]: + var + point: Vec3 + back: Vec2 + doAssert unprojectToGround(screen, viewProjection, viewport, flatGround, point) + doAssert abs(point.y - 0.5) < 1e-5 + doAssert projectToScreen(point, viewProjection, viewport, back) + doAssert (back - screen).length < 0.01 + var behind: Vec2 + doAssert not projectToScreen(vec3(0, 60, 60), viewProjection, viewport, behind) + +echo "Testing control exports drive the transport and game hooks" +block: + var + transport = initPlayer(live = false, durationTicks = 0, playing = true) + camera: ReplayCameraRequest + doAssert polyworld_replay_ready() == 0 + doAssert polyworld_replay_tick() == -1 + transport.sync(400, 1000, false) + installReplayControl(transport, ReplayControlHooks( + groundY: flatGround, + pick: proc(screen: Vec2, viewProjection: Mat4): int32 = 7, + unit: proc(id: int32): ReplayUnit = + ReplayUnit(found: id == 7, alive: false, position: vec3(1, 2, 3), seat: 4), + camera: proc(request: ReplayCameraRequest) = camera = request + )) + doAssert polyworld_replay_ready() == 1 + doAssert polyworld_replay_tick() == 400 + doAssert polyworld_replay_end_tick() == 1000 + polyworld_replay_pause() + polyworld_replay_seek(100, 5) + doAssert transport.restoreTick == 100 and transport.targetTick == 100 + doAssert not transport.playing + polyworld_replay_cancel_seek() + doAssert transport.restoreTick == -1 and transport.targetTick == -1 + doAssert polyworld_replay_set_speed(8) == 4 + polyworld_replay_set_loop(0) + doAssert not transport.repeating + polyworld_replay_set_seek_slice(12) + doAssert abs(transport.catchUpSeconds - 0.012) < 1e-6 + doAssert polyworld_replay_pick(10, 10) == 7 + doAssert polyworld_replay_unit(8) == 0 + doAssert polyworld_replay_unit(7) == 1 + 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) + doAssert camera.mode == FrozenCamera + polyworld_replay_camera(1, 0, 5, 6, 30) + 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 + +echo "Replay control tests passed" diff --git a/tests/tests.nim b/tests/tests.nim index 8eaffbae..cce824d6 100644 --- a/tests/tests.nim +++ b/tests/tests.nim @@ -89,6 +89,7 @@ import test_pathing, test_picking, test_player, + test_replaycontrols, test_profiles, test_prop_batches, test_props,