From ca8a11dcfe7ad3d61d9550d3b94897771b0bb43e Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 20 Aug 2026 17:24:30 -0400 Subject: [PATCH] feat(forces): mark a hex whose machine has not arrived yet A fetched sprite leaves a gap between a hex existing and something standing in it, and an empty hex that fills in later reads as breakage. megamek-art.ts holds the shared decode cache, the placeholder and the three states a unit canvas can be in; the daily card also asks for every sprite at once, where it was awaiting them one at a time. --- web/scripts/megamek-assets.test.mjs | 45 ++++++++++ web/src/megamek-art.ts | 129 ++++++++++++++++++++++++++++ web/src/scenario/preview.ts | 36 ++------ web/src/screens/daily.ts | 65 ++++++++++---- web/src/styles.css | 46 ++++++++++ 5 files changed, 276 insertions(+), 45 deletions(-) create mode 100644 web/src/megamek-art.ts diff --git a/web/scripts/megamek-assets.test.mjs b/web/scripts/megamek-assets.test.mjs index 73a020d..453a089 100644 --- a/web/scripts/megamek-assets.test.mjs +++ b/web/scripts/megamek-assets.test.mjs @@ -75,3 +75,48 @@ test("the dev server proxies the assets prefix", async () => { const config = await readFile(`${web}astro.config.mjs`, "utf8"); assert.match(config, /"\/assets\/megamek":/); }); + +test("a unit that has not arrived is marked, not left blank", async () => { + // The sprites are fetched now, so a first visit has a gap between a hex + // existing and a machine standing in it. Three states and a rule for each: + // without them the gap reads as breakage. + const art = await readFile(`${web}src/megamek-art.ts`, "utf8"); + for (const state of ["loading", "ready", "missing"]) { + assert.match(art, new RegExp(`"${state}"`), `nothing sets ${state}`); + } + const css = await readFile(`${web}src/styles.css`, "utf8"); + for (const state of ["loading", "ready", "missing"]) { + assert.match( + css, + new RegExp(`\\.force-unit\\[data-state="${state}"\\]`), + `${state} has no rule, so the state is invisible`, + ); + } +}); + +test("waiting stops moving when motion is not wanted", async () => { + const css = await readFile(`${web}src/styles.css`, "utf8"); + const reduced = css + .split("@media (prefers-reduced-motion: reduce)") + .slice(1) + .join(""); + assert.match(reduced, /\.force-unit\[data-state="loading"\]/); +}); + +test("the daily card asks for every sprite before awaiting any", async () => { + // Awaiting inside the loop made six units six round trips end to end, and + // the board filled in one machine at a time. + const daily = await readFile(`${web}src/screens/daily.ts`, "utf8"); + const loop = daily.slice(daily.indexOf("function drawMap")); + assert.match( + loop, + /sprite: paintUnitSprite\(/, + "the fetches are not started together", + ); + assert.ok( + !/for \(const \{ unit, x, y \} of spotsFor\([\s\S]{0,200}await paintUnitSprite/.test( + loop, + ), + "a sprite is awaited inside the placement loop again", + ); +}); diff --git a/web/src/megamek-art.ts b/web/src/megamek-art.ts new file mode 100644 index 0000000..35a15bb --- /dev/null +++ b/web/src/megamek-art.ts @@ -0,0 +1,129 @@ +/** + * Drawing MegaMek's art while it is still arriving. + * + * The sprites are fetched now rather than bundled (`megamek.ts` says why), so + * on a first visit there is a moment between a hex existing and a machine + * standing in it. Cached immutably for a year afterwards, so this is the first + * visit's problem only — but the first visit is the one worth getting right, + * and a hex that fills in later reads as breakage rather than as loading. + * + * Two things live here, and both are shared so that every screen agrees: + * + * - one decode cache, so a sprite drawn on the daily card and again in the + * lobby is fetched once for the life of the page rather than once per + * screen; + * - one placeholder, so a machine that has not arrived looks the same + * wherever it is waiting. + * + * The placeholder is drawn here rather than fetched. Anything fetched to stand + * in for something being fetched is the same problem again, one round trip + * later. + */ + +import { SPRITE_H, SPRITE_W, loadTile } from "./camo/mech"; + +/** Decoded tiles, once each for the life of the page. */ +const tiles = new Map>(); + +/** + * One decoded sprite, shared across screens. + * + * A failed load is dropped from the cache rather than kept: a transient miss — + * a dev-server hiccup, a briefly starved network — must not poison a URL for + * the life of the page, and the next redraw simply tries again. + */ +export function tile(url: string): Promise { + let loading = tiles.get(url); + if (!loading) { + loading = loadTile(url).catch((error: unknown) => { + tiles.delete(url); + throw error; + }); + tiles.set(url, loading); + } + return loading; +} + +/** Whether a sprite is already decoded, so a caller can skip the placeholder + * entirely on a revisit rather than flashing one for a frame. */ +export function isLoaded(url: string): boolean { + return tiles.has(url); +} + +let ghostData: ImageData | null = null; + +/** + * The mark that stands where a machine is about to. + * + * A plinth rather than a silhouette of anything: it has to sit under every + * unit type in the library — a Mek, a tank, a dropship — without implying + * which one is coming, and without being mistaken for art that failed to + * paint. Built once and reused, because it is drawn per hex. + */ +export function ghost(): ImageData { + if (ghostData) return ghostData; + + const canvas = document.createElement("canvas"); + canvas.width = SPRITE_W; + canvas.height = SPRITE_H; + const ctx = canvas.getContext("2d")!; + + // Roughly the footprint a Mek sprite occupies in its tile, centred on the + // hex rather than on the canvas: the art sits slightly high, and a mark + // that does not sit where the machine will lands as a second thing on the + // board instead of a promise about the first. + const w = 30; + const h = 34; + const x = (SPRITE_W - w) / 2; + const y = (SPRITE_H - h) / 2 - 3; + + ctx.fillStyle = "rgba(150, 172, 205, 0.14)"; + ctx.strokeStyle = "rgba(150, 172, 205, 0.28)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.roundRect(x + 0.5, y + 0.5, w, h, 5); + ctx.fill(); + ctx.stroke(); + + ghostData = ctx.getImageData(0, 0, SPRITE_W, SPRITE_H); + return ghostData; +} + +/** + * Paint a unit's canvas: the placeholder now, the art when it arrives. + * + * `data-state` carries which of those is on screen, and styles.css does the + * rest — a slow pulse while waiting and a short fade on arrival, both of which + * stop under `prefers-reduced-motion`. The state is on the element rather than + * in the pixels because `putImageData` ignores `globalAlpha`, so a fade has to + * be CSS whatever else happens. + * + * `stale` lets a screen abandon a paint it has already replaced; the scenario + * preview redraws on every scenario change and a late sprite must not land on + * a newer board. + */ +export function paintWhenReady( + canvas: HTMLCanvasElement, + load: () => Promise, + stale: () => boolean = () => false, +): Promise { + const ctx = canvas.getContext("2d")!; + canvas.dataset.state = "loading"; + ctx.putImageData(ghost(), 0, 0); + + return load().then( + (art) => { + if (stale()) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.putImageData(art, 0, 0); + canvas.dataset.state = "ready"; + }, + (error: unknown) => { + if (stale()) return; + // The placeholder stays. A hex that cannot draw keeps its mark rather + // than emptying out, and the caption still names the unit. + canvas.dataset.state = "missing"; + throw error; + }, + ); +} diff --git a/web/src/scenario/preview.ts b/web/src/scenario/preview.ts index fad3ff9..af1fe32 100644 --- a/web/src/scenario/preview.ts +++ b/web/src/scenario/preview.ts @@ -15,7 +15,8 @@ */ import type { Scenario } from "../api"; -import { applyCamo, loadTile, over } from "../camo/mech"; +import { applyCamo, over } from "../camo/mech"; +import { paintWhenReady, tile } from "../megamek-art"; import { el } from "../dom"; import { unitSprite } from "../megamek"; import { UNIT_SPRITES } from "./unit-art"; @@ -41,24 +42,6 @@ const SPRITE_URLS: Record = Object.fromEntries( ]), ); -/** Decoded tiles, once each for the life of the page. */ -const tiles = new Map>(); - -function tile(url: string): Promise { - let loading = tiles.get(url); - if (!loading) { - loading = loadTile(url).catch((error: unknown) => { - // A transient miss - a dev-server hiccup, a briefly starved network - - // must not poison the cache for the life of the page: dropped here, the - // next redraw simply tries again. - tiles.delete(url); - throw error; - }); - tiles.set(url, loading); - } - return loading; -} - // The caption's short name, and the variant beside it. Defined in // designation.ts because the daily card wants them without this module's // sprite map; re-exported here so lobby.ts's force cards keep their import. @@ -156,14 +139,13 @@ export function scenarioPreview(): ScenarioPreview { ]); hexes.append(cell); - void (async () => { - const camo = await camoFor(slot.name); - const shown = await paintUnitIcon(unit, camo); - if (drawing !== seq) return; - canvas.getContext("2d")!.putImageData(shown, 0, 0); - })().catch((error: unknown) => { - // A hex that cannot draw stays empty; the caption still names the - // unit, so nothing is lost but the picture. + void paintWhenReady( + canvas, + async () => paintUnitIcon(unit, await camoFor(slot.name)), + () => drawing !== seq, + ).catch((error: unknown) => { + // A hex that cannot draw keeps its placeholder; the caption still + // names the unit, so nothing is lost but the picture. console.warn("scenario preview: could not draw", unit, error); }); } diff --git a/web/src/screens/daily.ts b/web/src/screens/daily.ts index 1978468..5e5900e 100644 --- a/web/src/screens/daily.ts +++ b/web/src/screens/daily.ts @@ -33,6 +33,7 @@ import { stashDailyPlay } from "../daily-play-stash"; import { el, render } from "../dom"; import { nav } from "../nav"; import { SPRITE_H, SPRITE_W } from "../camo/mech"; +import { ghost } from "../megamek-art"; import dailyJson from "../content/daily.json"; // From designation.ts and not preview.ts: that module names every unit // sprite the site has, and importing it here would put the whole map in the @@ -601,25 +602,53 @@ function drawMap(canvas: HTMLCanvasElement, challenge: Challenge): void { staging.height = SPRITE_H; const stage = staging.getContext("2d")!; - for (const [index, side] of challenge.sides.entries()) { + // Every machine at once, not one after another. These are separate + // fetches on a first visit, and awaiting them in the loop made six units + // six round trips end to end - the board filled in one machine at a time, + // which reads as breakage rather than as loading. + const placed = challenge.sides.flatMap((side, index) => { const camo = flatCamo(sideColour(challenge, index)); - for (const { unit, x, y } of spotsFor(challenge, side)) { - const sprite = await paintUnitSprite(unit.name, camo); - if (!sprite) continue; - stage.clearRect(0, 0, SPRITE_W, SPRITE_H); - stage.putImageData(sprite, 0, 0); - - if (facesSouth(unit.facing)) { - // Half a turn, written as scale(-1, -1) rather than rotate(PI): - // Math.sin(Math.PI) is 1.2e-16 and not 0, and that much shear is - // enough to make the canvas resample a sprite that should only - // be reversed. - ctx.setTransform(-1, 0, 0, -1, x + SPRITE_W, y + SPRITE_H); - ctx.drawImage(staging, 0, 0); - ctx.setTransform(1, 0, 0, 1, 0, 0); - } else { - ctx.drawImage(staging, x, y); - } + return spotsFor(challenge, side).map((spot) => ({ + ...spot, + sprite: paintUnitSprite(spot.unit.name, camo), + })); + }); + + // The mark each of them will stand on, drawn before any of them arrive so + // the board is the shape it will end up being from the first frame. + for (const { x, y } of placed) { + stage.clearRect(0, 0, SPRITE_W, SPRITE_H); + stage.putImageData(ghost(), 0, 0); + ctx.drawImage(staging, x, y); + } + + for (const { unit, x, y, sprite: pending } of placed) { + const sprite = await pending; + if (!sprite) continue; + stage.clearRect(0, 0, SPRITE_W, SPRITE_H); + stage.putImageData(sprite, 0, 0); + + // A sprite composites, so the mark under it would show through every + // transparent pixel. The field is repainted over that one tile first, + // clipped to it: the lattice is a function of the whole canvas, and + // redrawing a patch of it any other way would not meet at the edges. + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, SPRITE_W, SPRITE_H); + ctx.clip(); + drawHexField(ctx, canvas.width, canvas.height); + ctx.restore(); + + if (facesSouth(unit.facing)) { + // Half a turn, written as scale(-1, -1) rather than rotate(PI): + // Math.sin(Math.PI) is 1.2e-16 and not 0, and that much shear is + // enough to make the canvas resample a sprite that should only + // be reversed. + ctx.setTransform(-1, 0, 0, -1, x + SPRITE_W, y + SPRITE_H); + ctx.drawImage(staging, 0, 0); + ctx.setTransform(1, 0, 0, 1, 0, 0); + } else { + ctx.drawImage(staging, x, y); } } })().catch((error: unknown) => { diff --git a/web/src/styles.css b/web/src/styles.css index 857f637..5cb42d8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -3230,6 +3230,52 @@ footer .debug { image-rendering: pixelated; } +/* A hex whose machine has not arrived yet. + * + * The sprites are fetched rather than bundled, so on a first visit there is a + * moment between a hex existing and something standing in it. megamek-art.ts + * paints a mark there and carries which state the canvas is in; this is what + * makes waiting look deliberate — a slow breath while it waits, a short fade + * when the machine lands. `missing` keeps the mark and stops the pulse: a hex + * that will never fill should not look like one that still might. */ +.force-unit[data-state="loading"] { + animation: unit-waiting 1.6s ease-in-out infinite; +} + +.force-unit[data-state="ready"] { + animation: unit-arrived 0.18s ease-out; +} + +.force-unit[data-state="missing"] { + opacity: 0.55; +} + +@keyframes unit-waiting { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 0.9; + } +} + +@keyframes unit-arrived { + from { + opacity: 0.6; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .force-unit[data-state="loading"], + .force-unit[data-state="ready"] { + animation: none; + } +} + .force-unit-name { max-width: 84px; font-size: 0.62rem; -- 2.51.2