diff --git a/gameview.ts b/gameview.ts index c28d37a..8b688ad 100644 --- a/gameview.ts +++ b/gameview.ts @@ -22,6 +22,9 @@ import type { Disposable, MapView, MapViewCamera, MapViewHandle, MinimapView, Pl import type { ScmSimService, ScmSimView, ViewFrame, ViewHover, ViewTrack, ViewUnits } from "./vendor/scm-sim"; import { FRAME_MS } from "./replay"; +/** A seek the viewer says is over but that has shown no frame near where it went is settled after this long. */ +const SEEK_GIVE_UP_MS = 2500; + /** The editor's minimap follows the game's camera at most this often (each move redraws it). */ const MINIMAP_MS = 66; /** A game that has not said it looks where it was sent is shown anyway after this long (a look the game cannot take). */ @@ -72,7 +75,9 @@ export class GameView { * A seek asked for and not yet arrived: until the viewer shows a frame there, what it says it shows is the moment * it is leaving (it reports the frame on screen while it runs ahead), which is not the clock any more. */ - private aiming: { frame: number; sawSeeking: boolean; from: number | null; at: number | null } | null = null; + private aiming: { frame: number; sawSeeking: boolean; from: number | null; at: number | null; sent: number; timer: number } | null = null; + /** The viewer's last word on the frame, and when (performance.now): what a seek that never arrives settles for. */ + private heard: { f: ViewFrame; at: number } | null = null; /** * Where the game is to look once it holds the replay (the map's place when the switch came), in pixels. A height of * 0 is the map's own scale: as much of the map top to bottom as the frame is tall. @@ -286,6 +291,7 @@ export class GameView { this.subs = [ view.onFrame((f) => { if (this.holding === null || this.wanted !== null) return; + this.heard = { f, at: performance.now() }; const a = this.aiming; if (a) { // (On its way the viewer says how far the engine has run: `seeking` is that frame.) @@ -296,7 +302,7 @@ export class GameView { const near = Math.abs(f.frame - a.frame) <= 48; const ended = a.sawSeeking && a.at !== null && f.frame >= a.at && f.frame <= a.frame + 48; if (!near && !ended) return; - this.aiming = null; + this.arrived(); } this.said = { frame: f.frame, at: performance.now(), playing: f.playing }; this.hooks.frame(this.holding, f); @@ -471,9 +477,27 @@ export class GameView { speed(multiplier: number): void { this.view?.speed(multiplier); } seek(frame: number): void { if (!this.view) return; - this.aiming = { frame: Math.round(frame), sawSeeking: false, from: null, at: null }; + this.arrived(); + const a = { frame: Math.round(frame), sawSeeking: false, from: null, at: null, sent: performance.now(), timer: 0 }; + // A seek the viewer says is over without ever showing a frame near where it was sent — a play page from before + // 2026-10-16 showed an old frame for a seek that took no step (to 0:00, or to where the game stood), and paused, + // nothing came after — is taken as over at the frame it shows, rather than waited on for good. + a.timer = window.setInterval(() => { + const h = this.heard, now = performance.now(); + if (this.aiming !== a || !h || h.at < a.sent || h.f.seeking !== null) return; + if (now - a.sent < SEEK_GIVE_UP_MS || now - h.at < 1000) return; + this.arrived(); + this.said = { frame: h.f.frame, at: now, playing: h.f.playing }; + if (this.holding !== null && this.wanted === null) this.hooks.frame(this.holding, h.f); + }, 500); + this.aiming = a; this.view.seek(Math.round(frame)); } + /** The seek in hand is over (or another replaces it). */ + private arrived(): void { + if (this.aiming) clearInterval(this.aiming.timer); + this.aiming = null; + } /** A seek is on its way: the clock is where it was sent, not where the game still shows. */ get seeking(): boolean { return this.aiming !== null; } /** How far a jump has run, 0 to 1, once the viewer has said; null before (or for a jump with no way to go). */ @@ -519,6 +543,7 @@ export class GameView { } dispose(): void { + this.arrived(); if (this.busyTimer) clearTimeout(this.busyTimer); if (this.telling) clearTimeout(this.telling); this.settle(false); diff --git a/package-lock.json b/package-lock.json index ec65934..23d32aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scmjs-plugin-aftermath", - "version": "0.13.1", + "version": "0.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scmjs-plugin-aftermath", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "dependencies": { "mopaq": "^1.5.0" diff --git a/package.json b/package.json index 3c88df5..30ac00d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scmjs-plugin-aftermath", - "version": "0.13.1", + "version": "0.13.2", "private": true, "description": "Aftermath — a plugin for the scmJS StarCraft map editor.", "author": "Jeany", diff --git a/plugin.json b/plugin.json index 2fbb421..da7c370 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "Aftermath", "id": "aftermath", - "version": "0.13.1", + "version": "0.13.2", "description": "Opens StarCraft replays over the map they were played on: where buildings went down and when, where units were sent, heat maps of the orders across one game or many, build orders, actions per minute and chat, on a timeline that plays back. At scmjs.dev it also shows the game itself in the map's place, played by the game's own code with its own graphics, and plays it through to show what happened: the units where they stood, what was really built and lost, and each player's army, economy and tech through the game.", "requires": ["github:scm-js/plugin-scm-sim"], "author": "Jeany",