Skip to content
Merged
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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,12 +291,18 @@ 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
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.
Expand Down
28 changes: 22 additions & 6 deletions gameview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -59,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 {
Expand Down Expand Up @@ -160,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); } });
}

/**
Expand Down Expand Up @@ -316,15 +323,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<boolean> {
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;
}

Expand Down Expand Up @@ -377,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?.(); }));
Expand Down Expand Up @@ -448,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;
Expand Down Expand Up @@ -511,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;
Expand All @@ -523,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); }
Expand Down
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 19 additions & 0 deletions plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, number>();
/**
* 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;
Expand Down Expand Up @@ -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();
Expand Down
56 changes: 47 additions & 9 deletions tests/gameview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ function fakeElement(): HTMLElement {
const classes = new Set<string>();
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;
}
Expand All @@ -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();
Expand All @@ -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 }); }),
Expand All @@ -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;
Expand All @@ -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; });
Expand All @@ -72,18 +88,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);
});

Expand All @@ -92,7 +108,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 }]);
Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions vendor/scm-sim.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
Loading