From 6429a4aa4b5bc3951a1bda52dc9c595b94766028 Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 16 Oct 2026 09:35:23 -0600 Subject: [PATCH 1/3] A replay opens at the game's own scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Replay view opened at the map's 100% — as many rows of the map as the frame is tall — which on a tall frame is well zoomed out from the game. It now opens at the game's 480 rows across the frame's height, on the first player's start. Switching between the map and the replay still keeps the scale. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- gameview.ts | 8 +++++--- tests/gameview.test.ts | 14 +++++++------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 7c78910..f01f774 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,8 @@ the other keys stay Aftermath's. The game is shown without its console: the edit minimap shows the game's units as the game's own minimap would, its box is where the game looks, and a press or a drag on it moves the game's camera there. Switching between the replay and the map keeps your place: each opens where the other was looking, at the same -scale. View ▸ Zoom In and Zoom Out, the zoom levels, Zoom to Fit, the toolbar's magnifiers +scale. A replay first opens at the game's own scale, showing as much of the map top to +bottom as the game's screen does, on the first player's start. View ▸ Zoom In and Zoom Out, the zoom levels, Zoom to Fit, the toolbar's magnifiers and their keys zoom the replay while it is shown, and so does Ctrl with the mouse wheel over it (or the wheel alone, if the editor's preferences say the wheel zooms). Spectate's camera keeps following at the new scale. The replay can show from half to four times as much of the diff --git a/gameview.ts b/gameview.ts index 0be9968..3b446f1 100644 --- a/gameview.ts +++ b/gameview.ts @@ -31,6 +31,8 @@ const SEEK_STALL_MS = 4000; 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). */ const SETTLE_MS = 600; +/** The rows of the map the game shows top to bottom (its 640 by 480 screen): the scale a replay opens at. */ +const GAME_ROWS = 480; /** What the Game view needs of a replay. */ export interface Held { @@ -316,15 +318,15 @@ export class GameView { } /** - * Offer the Game view on a replay's tab, and show it there when `show`: looking at `start` (map pixels) at the map's - * own scale, rather than where the editor's view stood when the tab came in. + * Offer the Game view on a replay's tab, and show it there when `show`: looking at `start` (map pixels) at the game's + * own scale (`GAME_ROWS` across the frame's height), rather than where the editor's view stood when the tab came in. */ async offer(entry: Held, show: boolean, start?: { x: number; y: number }): Promise { if (entry.docId === null || !(await this.available())) return false; this.handle.offer(true, entry.docId); if (show) this.handle.show(true, entry.docId); // (After the show: the editor's `onShow` comes in during it.) - if (show && start && !this.holds(entry.id)) this.look = { x: start.x, y: start.y, width: 0, height: 0 }; + if (show && start && !this.holds(entry.id)) this.look = { x: start.x, y: start.y, width: 0, height: GAME_ROWS }; return true; } diff --git a/tests/gameview.test.ts b/tests/gameview.test.ts index 0bf7f53..1d86099 100644 --- a/tests/gameview.test.ts +++ b/tests/gameview.test.ts @@ -72,18 +72,18 @@ describe("the Game view opening a replay", () => { const entry = { id: 1, fileName: "a.rep", bytes: new Uint8Array(1), docId: 7, eyes: null }; const report = (height: number) => { for (const l of cameras) l({ x: 0, y: 0, width: height * 1.7, height, byUser: false }); }; - it("looks at the start at the map's scale, not where the editor stood, and hides the game until it does", async () => { + it("looks at the start at the game's own scale, not where the editor stood, and hides the game until it does", async () => { await game.offer(entry, true, { x: 336, y: 1840 }); const held = game.hold(entry, 0, false, 1); expect(element.classList.contains("afm-settling")).toBe(true); finishLoad(); await held; - // The frame is 700 pixels tall: 700 map pixels at the map's own scale. - expect(sent).toEqual([{ x: 336, y: 1840, height: 700 }]); - // Where the game opens on its own (480 rows) is not shown. - report(480); - expect(element.classList.contains("afm-settling")).toBe(true); + // The game's 480 rows, whatever the frame's height (700 here, which would be the map's own scale). + expect(sent).toEqual([{ x: 336, y: 1840, height: 480 }]); + // Until the game says it looks at that scale, it stays hidden. report(700); + expect(element.classList.contains("afm-settling")).toBe(true); + report(480); expect(element.classList.contains("afm-settling")).toBe(false); }); @@ -92,7 +92,7 @@ describe("the Game view opening a replay", () => { const held = game.hold(entry, 0, false, 1); finishLoad(); await held; - report(700); + report(480); sent.length = 0; spec.onZoom?.({ x: 1000, y: 900, width: 2400, height: 1400 }); expect(sent).toEqual([{ x: 1000, y: 900, height: 1400 }]); From cc3da536239a72f1040f430f16c65708c72a18cc Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 16 Oct 2026 10:01:47 -0600 Subject: [PATCH 2/3] 0.16.0: the editor's grid, locations, fog and pointer carry over to the Replay view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The game is drawn under the editor's grid, the map's locations and the other plugins' overlays (MapViewHandle.paintOver, redrawn on onRepaint), below Aftermath's own marks and not in the cinematic view. View ▸ Fog of War is the game's fog: on is a player's eyes (the last chosen, the first player at first), off is everyone's, and the toggle shows what the Vision menu says (setFog). The pointer's map pixel from scm-sim 0.10.0 goes to the editor's status bar. Needs plugin-api 1.54 for the types; works with older editors and play pages, which leave each piece out. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++++++- gameview.ts | 20 +++++++++++++++++--- package-lock.json | 4 ++-- package.json | 2 +- plugin.json | 2 +- plugin.ts | 19 +++++++++++++++++++ tests/gameview.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++-- vendor/scm-sim.d.ts | 11 +++++++++++ 8 files changed, 97 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index f01f774..6142dda 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,12 @@ and their keys zoom the replay while it is shown, and so does Ctrl with the mous it (or the wheel alone, if the editor's preferences say the wheel zooms). Spectate's camera keeps following at the new scale. The replay can show from half to four times as much of the map as the game itself does (twice, on a play page from before scm-sim 0.9.0), so it stops -short of a map zoomed further in or out, and opens at the nearest it can. +short of a map zoomed further in or out, and opens at the nearest it can. The editor's grid, +locations and other plugins' overlays are drawn over the game as over the map, under +Aftermath's own marks, and View ▸ Grid and Locations turn them on and off. View ▸ Fog of War +is the game's fog: on, the game is seen through a player's eyes (the one last chosen in the +Vision menu, the first player at first); off, through everyone's. The status bar's tile and +position follow the pointer over the game. Map stats shows on the map, so opening it switches to the map, and closing it goes back to the game. One game is shown at a time: switching to another replay's tab starts that one. diff --git a/gameview.ts b/gameview.ts index 3b446f1..9e28abb 100644 --- a/gameview.ts +++ b/gameview.ts @@ -61,6 +61,8 @@ export interface GameViewHooks { seeking(): void; /** What the pointer is on over the game (null: nothing, or the game is not shown), for `tip`. */ hover(hover: ViewHover | null): void; + /** View ▸ Fog of War over the game: see it through a player's eyes (`eyes`), or everyone's. */ + fog?(on: boolean): void; } export class GameView { @@ -162,12 +164,15 @@ export class GameView { }, onShow: (look) => this.lookAt(look), onZoom: (look) => this.zoomTo(look), + // The editor's grid, locations and the other plugins' overlays, over the game (`redrawOver`). + onRepaint: () => this.redrawOver(), + onFog: (on) => hooks.fog?.(on), minimap: { draw: (ctx, m) => this.drawUnits(ctx, m), press: (x, y) => { if (this.view && this.loaded) { this.view.camera(x, y); hooks.movedByUser(); } }, }, }); - this.changed = this.handle.onChange((on) => { hooks.shown(on); this.redrawOver(); if (!on) this.hooks.hover(null); }); + this.changed = this.handle.onChange((on) => { hooks.shown(on); this.redrawOver(); if (!on) { this.hooks.hover(null); this.handle.setPointer?.(null); } }); } /** @@ -379,6 +384,8 @@ export class GameView { // (scm-sim before 0.6.0 sends no squares: the editor's minimap then shows the ground alone.) // (scm-sim before 0.6.0 says nothing of the pointer: there is no tooltip.) if (typeof view.onHover === "function") this.subs.push(view.onHover((h) => this.hooks.hover(this.loaded && this.shown ? h : null))); + // (scm-sim before 0.10.0 says the pointer only over a unit, and an editor before 1.54 has no status bar to give it to.) + if (typeof view.onPointer === "function") this.subs.push(view.onPointer((p) => this.handle.setPointer?.(this.loaded && this.shown ? p : null))); // (scm-sim before 0.9.0 keeps the wheel; an editor before map views zoomed has no wheel to hand it to.) if (typeof view.onWheel === "function") this.subs.push(view.onWheel((w) => { if (this.loaded && this.shown && typeof this.handle.wheel === "function") this.handle.wheel(w); })); if (typeof view.onUnits === "function") this.subs.push(view.onUnits((u) => { if (!this.loaded) return; this.units = u; this.handle.redrawMinimap?.(); this.hooks.units?.(); })); @@ -450,6 +457,8 @@ export class GameView { /** See the game through a player's eyes, with the fog of war (their slot), or everyone's with none (null). */ eyes(slot: number | null): void { + // (The editor's Fog of War toggle shows the game's fog: on through a player's eyes.) + this.handle.setFog?.(slot !== null); const view = this.view; if (!view || !this.loaded || slot === this.eyesTold) return; this.eyesTold = slot; @@ -513,7 +522,7 @@ export class GameView { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, cw, ch); const at = this.looking; - if (!this.drawOver || !at || !this.loaded || !this.shown) return; + if (!at || !this.loaded || !this.shown) return; // (The game fills its frame: what it shows of the map, left to right and top to bottom, is the frame's width and height.) const w = el.clientWidth, h = el.clientHeight, kx = w / at.width, ky = h / at.height; const x0 = at.x - at.width / 2, y0 = at.y - at.height / 2; @@ -525,7 +534,12 @@ export class GameView { visible: { x0: Math.floor(x0 / 32), y0: Math.floor(y0 / 32), x1: Math.ceil((x0 + at.width) / 32), y1: Math.ceil((y0 + at.height) / 32) }, }; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - this.drawOver(ctx, view); + // The editor's grid, locations and the other plugins' overlays on the game, under Aftermath's own marks; not in the + // cinematic view, which is the game full screen without the editor. (An editor from before map views drew them has + // no paintOver.) + const cinematic = typeof document !== "undefined" && document.fullscreenElement === el; + if (!cinematic && typeof this.handle.paintOver === "function") { ctx.save(); this.handle.paintOver(ctx, view); ctx.restore(); } + this.drawOver?.(ctx, view); } play(on: boolean): void { this.view?.play(on); } diff --git a/package-lock.json b/package-lock.json index 46a2f1e..e3d98a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scmjs-plugin-aftermath", - "version": "0.15.1", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scmjs-plugin-aftermath", - "version": "0.15.1", + "version": "0.16.0", "license": "MIT", "dependencies": { "mopaq": "^1.5.0" diff --git a/package.json b/package.json index 975695a..fb13872 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scmjs-plugin-aftermath", - "version": "0.15.1", + "version": "0.16.0", "private": true, "description": "Aftermath — a plugin for the scmJS StarCraft map editor.", "author": "Jeany", diff --git a/plugin.json b/plugin.json index 708b67e..1116573 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "Aftermath", "id": "aftermath", - "version": "0.15.1", + "version": "0.16.0", "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", diff --git a/plugin.ts b/plugin.ts index 6080180..b8c0111 100644 --- a/plugin.ts +++ b/plugin.ts @@ -508,10 +508,28 @@ export function activate(api: PluginApi) { state: (text) => { gameState = text; strip?.update(); showBusy(); }, seeking: () => { strip?.update(); showBusy(); }, hover: (h) => showHover(h), + fog: (on) => fogFromEditor(on), }, "Replay", { loading: (name) => t("Loading {name}…", { name }), failed: (message) => t("The game could not be shown: {message}", { message }), }); + /** Whose eyes each replay was last seen through, for View ▸ Fog of War turned on again. */ + const lastEyes = new Map(); + /** + * View ▸ Fog of War (and its button) over the game: the fog is a player's eyes', so on is through the eyes last + * chosen for this replay (the first player's at first) and off is everyone's, as the Vision menu has them. + */ + function fogFromEditor(on: boolean): void { + const entry = current; + if (!entry || !game.holds(entry.id) || on === (entry.eyes !== null)) return; + if (on) entry.eyes = lastEyes.get(entry.id) ?? entry.report.players[0]?.player.slot ?? null; + else { if (entry.eyes !== null) lastEyes.set(entry.id, entry.eyes); entry.eyes = null; } + game.eyes(entry.eyes); + follow(); + redraw(); + strip?.render(); + } + /* ── The tooltip over the game: what the console would have said of the unit under the pointer ── */ let lastHover: ViewHover | null = null; @@ -3212,6 +3230,7 @@ export function activate(api: PluginApi) { ], { value: entry.eyes ?? -1, title: t("Whose eyes the game is seen through: one player's shows only what they could see, with the fog of war"), onChange: (v) => { const slot = Number(v); entry.eyes = slot < 0 ? null : slot; + if (entry.eyes !== null) lastEyes.set(entry.id, entry.eyes); if (game.holds(entry.id)) game.eyes(entry.eyes); // (Spectate follows the player whose eyes these are.) follow(); diff --git a/tests/gameview.test.ts b/tests/gameview.test.ts index 1d86099..c28e558 100644 --- a/tests/gameview.test.ts +++ b/tests/gameview.test.ts @@ -8,7 +8,9 @@ function fakeElement(): HTMLElement { const classes = new Set(); return { className: "", hidden: false, style: {}, clientWidth: 1200, clientHeight: 700, - append() {}, setAttribute() {}, getContext: () => null, + append() {}, setAttribute() {}, + // (A canvas's context that draws nothing: what is asked to be drawn is the handle's to keep.) + getContext: () => ({ setTransform() {}, clearRect() {}, save() {}, restore() {} }), classList: { contains: (c: string) => classes.has(c), toggle: (c: string, on: boolean) => { if (on) classes.add(c); else classes.delete(c); return on; } }, } as unknown as HTMLElement; } @@ -24,6 +26,11 @@ describe("the Game view opening a replay", () => { let zooms: number; let frames: ((f: ViewFrame) => void)[]; let told: ViewFrame[]; + let fogs: (boolean | null)[]; + let pointers: ({ x: number; y: number } | null)[]; + let painted: number; + let pointerListeners: ((p: { x: number; y: number } | null) => void)[]; + let fogAsked: boolean[]; beforeEach(() => { vi.useFakeTimers(); @@ -36,6 +43,11 @@ describe("the Game view opening a replay", () => { zooms = 0; frames = []; told = []; + fogs = []; + pointers = []; + painted = 0; + pointerListeners = []; + fogAsked = []; const none = { dispose() {} }; const view = { load: () => new Promise((resolve) => { finishLoad = () => resolve({ name: "", frames: 100, players: [], map: { width: 128, height: 128 }, rewinds: false, looks: {}, follows: false }); }), @@ -45,6 +57,7 @@ describe("the Game view opening a replay", () => { onFrame: (l: (f: ViewFrame) => void) => { frames.push(l); return none; }, onKey: () => none, onUnits: () => none, onCamera: (l: (c: ViewCamera) => void) => { cameras.push(l); return none; }, onWheel: (l: (w: ViewWheel) => void) => { wheels.push(l); return none; }, + onPointer: (l: (p: { x: number; y: number } | null) => void) => { pointerListeners.push(l); return none; }, } as unknown as ScmSimView; const sim = { present: true, view: () => view, status: () => Promise.resolve({ available: true }) } as unknown as ScmSimService; let shown = false; @@ -57,13 +70,16 @@ describe("the Game view opening a replay", () => { return { offer() {}, onChange: () => none, isShown: () => shown, dispose() {}, setCamera() {}, redrawMinimap() {}, wheel: (w: ViewWheel) => { handed.push(w); return true; }, + setFog: (on: boolean | null) => { fogs.push(on); }, + setPointer: (p: { x: number; y: number } | null) => { pointers.push(p); }, + paintOver: () => { painted++; }, // The editor tells the view where its map was looking as it is shown: here, a stale place far out. show: (on: boolean) => { shown = on; if (on) spec.onShow?.({ x: 4000, y: 4000, width: 4000, height: 3000 } satisfies MapViewCamera); }, }; }, }, } as unknown as PluginApi; - game = new GameView(api, { frame: (_id, f) => { told.push(f); }, movedByUser() {}, zoomed: () => { zooms++; }, shown() {}, state() {}, seeking() {}, hover() {} }, "Replay", { loading: (n) => n, failed: (m) => m }); + game = new GameView(api, { frame: (_id, f) => { told.push(f); }, movedByUser() {}, zoomed: () => { zooms++; }, shown() {}, state() {}, seeking() {}, hover() {}, fog: (on) => { fogAsked.push(on); } }, "Replay", { loading: (n) => n, failed: (m) => m }); game.setService(sim); }); afterEach(() => { vi.useRealTimers(); delete (globalThis as { document?: unknown }).document; delete (globalThis as { window?: unknown }).window; }); @@ -111,6 +127,28 @@ describe("the Game view opening a replay", () => { expect(handed).toEqual([w]); }); + it("carries the editor's grid and overlays, its fog toggle and the pointer over to the game", async () => { + await game.offer(entry, true, { x: 336, y: 1840 }); + const held = game.hold(entry, 0, false, 1); + finishLoad(); + await held; + // Everyone's eyes: the editor's Fog of War shows off, and a press on it is Aftermath's to answer. + expect(fogs).toEqual([false]); + spec.onFog?.(true); + expect(fogAsked).toEqual([true]); + game.eyes(2); + expect(fogs).toEqual([false, true]); + // What the editor draws over a map is drawn under Aftermath's marks each time the game moves, and when asked. + report(480); + expect(painted).toBe(1); + spec.onRepaint?.(); + expect(painted).toBe(2); + // The pointer's map pixel goes to the status bar while the game is shown. + for (const l of pointerListeners) l({ x: 100, y: 200 }); + for (const l of pointerListeners) l(null); + expect(pointers).toEqual([{ x: 100, y: 200 }, null]); + }); + it("shows the game anyway when it never says it got there", async () => { await game.offer(entry, true, { x: 336, y: 1840 }); const held = game.hold(entry, 0, false, 1); diff --git a/vendor/scm-sim.d.ts b/vendor/scm-sim.d.ts index d457026..c220304 100644 --- a/vendor/scm-sim.d.ts +++ b/vendor/scm-sim.d.ts @@ -497,6 +497,12 @@ export interface ViewHover { y: number; } +/** The map pixel under the pointer over the view (`onPointer`). */ +export interface ViewPointer { + x: number; + y: number; +} + /** A key pressed while the view had the keyboard that it does not use itself (all but the arrows, which scroll). */ export interface ViewKey { key: string; @@ -562,6 +568,11 @@ export interface ScmSimView { onHover(listener: (hover: ViewHover | null) => void): Disposable; /** The wheel turned over the view (Ctrl and the wheel included: the page keeps it from the browser's own zoom). From scm-sim 0.9.0. */ onWheel(listener: (wheel: ViewWheel) => void): Disposable; + /** + * The map pixel under the pointer, at most thirty times a second while it moves over the map, for a + * status bar's tile and position; null once it is off the map or the view. From scm-sim 0.10.0. + */ + onPointer(listener: (pointer: ViewPointer | null) => void): Disposable; /** * A key the view handed back. It is also pressed again on the editor's window, so the * editor's hotkeys and every plugin's act on it as if it had been pressed there. From cc1976f944269b24a0c8836fcc4746918368ece8 Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 16 Oct 2026 10:21:12 -0600 Subject: [PATCH 3/3] plugin-api 1.54.0: paintOver, onRepaint, onFog, setFog and setPointer Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index e3d98a9..b5815a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "mopaq": "^1.5.0" }, "devDependencies": { - "@scm-js/plugin-api": "^1.53.0", + "@scm-js/plugin-api": "^1.54.0", "esbuild": "^0.28.2", "typescript": "~5.9.2", "vitest": "^3.2.4" @@ -835,9 +835,9 @@ ] }, "node_modules/@scm-js/plugin-api": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/@scm-js/plugin-api/-/plugin-api-1.53.0.tgz", - "integrity": "sha512-lIMKbaFS54mJ9VPqIaqytvwI8FCY7OdmopaMY/u2nCQQ51zGuZcZterA9YLouOs+1l+CU2gyzs7uAzOpozm7sQ==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/@scm-js/plugin-api/-/plugin-api-1.54.0.tgz", + "integrity": "sha512-lf7anM8DFD2Kh27ACTEi0TyJLutufH7rT63WmRrPsLTrXlNhuStntpFiq+FFjpwuVTUcrM6Yo2cx85rWpayMMQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index fb13872..a946c67 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "mopaq": "^1.5.0" }, "devDependencies": { - "@scm-js/plugin-api": "^1.53.0", + "@scm-js/plugin-api": "^1.54.0", "esbuild": "^0.28.2", "typescript": "~5.9.2", "vitest": "^3.2.4"