Skip to content

Commit 897302e

Browse files
author
DavidQ
committed
Resolve game preview thumbnails from manifest assets instead of hardcoded preview.svg - PR_26139_017-game-index-preview-manifest-resolution
1 parent cc31a77 commit 897302e

8 files changed

Lines changed: 348 additions & 9 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# PR_26139_017-game-index-preview-manifest-resolution Report
2+
3+
## Summary
4+
5+
- Added shared manifest preview resolution for game thumbnails.
6+
- `games/index.html` now renders card thumbnails from each game manifest Asset Manager V2 image asset with `role: "preview"`.
7+
- `games/Pong/index.html` now uses the same manifest preview helper for its page thumbnail.
8+
- Pong manifest preview path now points to `assets/images/preview1.svg`, matching the actual file.
9+
- Missing preview-role assets show the existing safe `No Preview` placeholder and do not request a guessed thumbnail.
10+
11+
## Files Changed
12+
13+
- `games/shared/gameManifestPreviewResolver.js`
14+
- `games/index.render.js`
15+
- `games/index.css`
16+
- `games/pong/game.manifest.json`
17+
- `games/pong/index.html`
18+
- `games/Pong/boot.js`
19+
- `tests/playwright/games/GameIndexPreviewManifestResolution.spec.mjs`
20+
- `docs/dev/reports/PR_26139_017-game-index-preview-manifest-resolution_report.md`
21+
22+
## Resolution Behavior
23+
24+
- Active preview source: `tools.asset-manager-v2.assets[*]` with `type: "image"` and `role: "preview"`.
25+
- `games/index.render.js` ignores legacy metadata `preview` strings for active thumbnail rendering.
26+
- `games/shared/gameManifestPreviewResolver.js` derives each game manifest path from the game href and delegates chrome asset lookup to the shared manifest chrome resolver from PR_016.
27+
- Pong thumbnail source is `/games/Pong/assets/images/preview1.svg` only because `games/pong/game.manifest.json` now points to `assets/images/preview1.svg`.
28+
- No fallback/default `assets/images/preview.svg` path is created.
29+
30+
## Validation
31+
32+
- PASS: `node --check games/shared/gameManifestPreviewResolver.js`
33+
- PASS: `node --check games/index.render.js`
34+
- PASS: `node --check games/Pong/boot.js`
35+
- PASS: `node --check tests/playwright/games/GameIndexPreviewManifestResolution.spec.mjs`
36+
- PASS: `npm run build:manifest`
37+
- PASS: `npx playwright test tests/playwright/games/GameIndexPreviewManifestResolution.spec.mjs --project=playwright --workers=1 --reporter=list`
38+
- 3 passed.
39+
- Verified `games/index.html` resolves Pong card thumbnail to `/games/Pong/assets/images/preview1.svg`.
40+
- Verified `games/Pong/index.html` resolves its page thumbnail to `/games/Pong/assets/images/preview1.svg`.
41+
- Verified removing the Pong preview role keeps the safe placeholder and does not request preview image files.
42+
- Verified no request or 404 for `/games/Pong/assets/images/preview.svg`.
43+
- PASS: `git diff --check`
44+
- Git emitted line-ending normalization warnings for touched files; command exit code was 0.
45+
46+
## Manual Validation
47+
48+
1. Open `/games/index.html`.
49+
2. Confirm the Pong card thumbnail loads from `/games/Pong/assets/images/preview1.svg`.
50+
3. Confirm the browser network log has no request for `/games/Pong/assets/images/preview.svg`.
51+
4. Open `/games/Pong/index.html`.
52+
5. Confirm the Pong page preview thumbnail loads from `/games/Pong/assets/images/preview1.svg`.
53+
6. Temporarily remove the Pong Asset Manager preview role and confirm the Pong page shows `No Preview` with no missing thumbnail request.
54+
55+
## Full Samples Smoke
56+
57+
- Skipped as requested; this PR changes only game index/page preview thumbnail resolution and does not broadly change sample loading.

games/index.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@
3434
margin-bottom: 10px;
3535
}
3636

37+
.game-preview-link [hidden] {
38+
display: none !important;
39+
}
40+
3741
.game-thumb {
3842
display: block;
3943
width: 100%;

games/index.render.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getToolRegistry } from "../tools/toolRegistry.js";
2+
import { resolveGamePreviewMap } from "./shared/gameManifestPreviewResolver.js";
23
import { launchWithExternalToolWorkspaceReset, resolveGameWorkspaceLaunchHref } from "../tools/shared/toolLaunchSSoT.js";
34

45
const METADATA_PATH = "./metadata/games.index.metadata.json";
@@ -123,7 +124,7 @@ function writePinnedSet(pinnedSet) {
123124
window.localStorage.setItem(GAMES_PINNED_KEY, JSON.stringify([...pinnedSet].sort()));
124125
}
125126

126-
function buildRows(metadata, pinnedSet, toolLabelMap) {
127+
function buildRows(metadata, pinnedSet, toolLabelMap, previewMap) {
127128
const rows = asArray(metadata?.games)
128129
.map((game) => {
129130
const id = normalize(game?.id);
@@ -162,7 +163,7 @@ function buildRows(metadata, pinnedSet, toolLabelMap) {
162163
engineClassNames,
163164
toolTokens,
164165
tags,
165-
preview: normalize(game?.preview),
166+
preview: normalize(previewMap.get(id)),
166167
href,
167168
workspaceHref: workspaceLaunch.href,
168169
workspaceLaunchError: workspaceLaunch.error,
@@ -365,6 +366,7 @@ export async function initGamesIndex() {
365366
return;
366367
}
367368
const metadata = await response.json();
369+
const previewMap = await resolveGamePreviewMap(metadata?.games, { documentRef: document });
368370
const toolRegistry = getToolRegistry();
369371
const toolLabelMap = new Map(
370372
toolRegistry
@@ -373,7 +375,7 @@ export async function initGamesIndex() {
373375
.filter((entry) => entry[0] && entry[1])
374376
);
375377
let pinnedSet = readPinnedSet();
376-
let model = buildRows(metadata, pinnedSet, toolLabelMap);
378+
let model = buildRows(metadata, pinnedSet, toolLabelMap, previewMap);
377379
setSelect(levelSelect, model.levels, (value) => value);
378380
setSelect(classSelect, model.classes, (value) => value);
379381
setSelect(toolSelect, model.tools.map((entry) => entry.value), (value) => {
@@ -387,7 +389,7 @@ export async function initGamesIndex() {
387389
}
388390

389391
const apply = () => {
390-
model = buildRows(metadata, pinnedSet, toolLabelMap);
392+
model = buildRows(metadata, pinnedSet, toolLabelMap, previewMap);
391393
const state = {
392394
level: normalize(levelSelect.value),
393395
classValue: normalize(classSelect.value),

games/pong/boot.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { bootWorkspaceGame } from "/games/shared/workspaceGameBoot.js";
2+
import { hydrateGameManifestPreviewImage } from "/games/shared/gameManifestPreviewResolver.js";
3+
4+
bootWorkspaceGame("Pong");
5+
void hydrateGameManifestPreviewImage({
6+
gameId: "Pong",
7+
imageSelector: "#pong-preview-thumbnail",
8+
placeholderSelector: "#pong-preview-placeholder"
9+
});

games/pong/game.manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
"source": "manifest",
7171
"assets": {
7272
"assets.image.preview.preview": {
73-
"path": "assets/images/preview.svg",
73+
"path": "assets/images/preview1.svg",
7474
"type": "image",
7575
"kind": "svg",
7676
"role": "preview",

games/pong/index.html

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,22 @@
1111
<title>Pong</title>
1212
<link rel="stylesheet" href="/src/engine/ui/baseLayout.css" />
1313
<link rel="stylesheet" href="../../src/engine/theme/main.css" />
14-
<script type="module">
15-
import { bootWorkspaceGame } from "/games/shared/workspaceGameBoot.js";
16-
bootWorkspaceGame("Pong");
17-
</script>
14+
<link rel="stylesheet" href="../index.css" />
15+
<script type="module" src="./boot.js"></script>
1816

1917
</head>
2018
<body class="hub-page-games">
2119
<div id="shared-theme-header"></div>
2220
<main>
2321
<h1>Pong</h1>
2422
<p>Arcade-style Pong build with Tennis, Hockey, Handball, and Jai-Alai modes, paddle english, and keyboard/gamepad support.</p>
23+
<section>
24+
<h3>Preview</h3>
25+
<a class="game-preview-link" href="#game" aria-label="Pong preview thumbnail">
26+
<img id="pong-preview-thumbnail" class="game-thumb" loading="lazy" decoding="async" alt="Pong preview thumbnail" hidden>
27+
<span id="pong-preview-placeholder" class="game-preview-missing">No Preview</span>
28+
</a>
29+
</section>
2530
<canvas id="game" width="960" height="720"></canvas>
2631
<section>
2732
<h3>Controls</h3>
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {
2+
resolveManifestChromeAssetPaths,
3+
resolveRuntimeAssetUrl
4+
} from "/src/engine/runtime/gameImageConvention.js";
5+
6+
function normalizeText(value) {
7+
return typeof value === "string" ? value.trim() : "";
8+
}
9+
10+
function normalizePath(value) {
11+
return normalizeText(value).replace(/\\/g, "/");
12+
}
13+
14+
function hasProtocol(value) {
15+
return /^[a-z][a-z0-9+.-]*:/i.test(value);
16+
}
17+
18+
function asArray(value) {
19+
return Array.isArray(value) ? value : [];
20+
}
21+
22+
function documentRefOrNull(documentRef) {
23+
return documentRef || globalThis.document || null;
24+
}
25+
26+
async function waitForDocumentReady(documentRef) {
27+
if (!documentRef || documentRef.readyState !== "loading") {
28+
return;
29+
}
30+
await new Promise((resolve) => {
31+
documentRef.addEventListener("DOMContentLoaded", resolve, { once: true });
32+
});
33+
}
34+
35+
export function manifestPathFromGameHref(href) {
36+
const normalized = normalizePath(href);
37+
if (!normalized || hasProtocol(normalized) || normalized.includes("..")) {
38+
return "";
39+
}
40+
const match = normalized.match(/^\/?games\/([^/]+)\//i);
41+
return match ? `/games/${match[1]}/game.manifest.json` : "";
42+
}
43+
44+
export function manifestPathForGame(game) {
45+
const explicitPath = normalizePath(game?.manifestPath || game?.gameManifestPath);
46+
if (explicitPath && !hasProtocol(explicitPath) && !explicitPath.startsWith("//") && !explicitPath.includes("..")) {
47+
return explicitPath.startsWith("/") ? explicitPath : `/${explicitPath}`;
48+
}
49+
return manifestPathFromGameHref(game?.href);
50+
}
51+
52+
export async function resolveGameManifestPreview(options = {}) {
53+
const documentRef = documentRefOrNull(options.documentRef);
54+
const manifestPath = normalizePath(options.manifestPath);
55+
if (!manifestPath) {
56+
return {
57+
manifestPath: "",
58+
previewPath: "",
59+
previewUrl: ""
60+
};
61+
}
62+
const resolved = await resolveManifestChromeAssetPaths({
63+
gameId: options.gameId,
64+
manifestPath,
65+
manifestPayload: options.manifestPayload,
66+
documentRef
67+
});
68+
const previewPath = normalizePath(resolved.previewPath);
69+
return {
70+
manifestPath: resolved.manifestPath || manifestPath,
71+
previewPath,
72+
previewUrl: previewPath ? resolveRuntimeAssetUrl(previewPath, documentRef) : ""
73+
};
74+
}
75+
76+
export async function resolveGamePreviewMap(games, options = {}) {
77+
const entries = await Promise.all(asArray(games).map(async (game) => {
78+
const gameId = normalizeText(game?.id);
79+
const manifestPath = manifestPathForGame(game);
80+
if (!gameId || !manifestPath) {
81+
return null;
82+
}
83+
const resolved = await resolveGameManifestPreview({
84+
gameId,
85+
manifestPath,
86+
documentRef: options.documentRef
87+
});
88+
return resolved.previewUrl ? [gameId, resolved.previewUrl] : null;
89+
}));
90+
return new Map(entries.filter(Boolean));
91+
}
92+
93+
export async function hydrateGameManifestPreviewImage(options = {}) {
94+
const documentRef = documentRefOrNull(options.documentRef);
95+
if (!documentRef) {
96+
return null;
97+
}
98+
await waitForDocumentReady(documentRef);
99+
const image = typeof options.imageSelector === "string"
100+
? documentRef.querySelector(options.imageSelector)
101+
: options.image;
102+
const imageCtor = documentRef.defaultView?.HTMLImageElement || globalThis.HTMLImageElement;
103+
const isImage = imageCtor
104+
? image instanceof imageCtor
105+
: image?.tagName === "IMG";
106+
if (!isImage) {
107+
return null;
108+
}
109+
const placeholder = typeof options.placeholderSelector === "string"
110+
? documentRef.querySelector(options.placeholderSelector)
111+
: options.placeholder;
112+
const gameId = normalizeText(options.gameId);
113+
const resolved = await resolveGameManifestPreview({
114+
gameId,
115+
manifestPath: normalizePath(options.manifestPath) || (gameId ? `/games/${gameId}/game.manifest.json` : ""),
116+
documentRef
117+
});
118+
if (resolved.previewUrl) {
119+
image.src = resolved.previewUrl;
120+
image.hidden = false;
121+
image.dataset.gamePreviewStatus = "ready";
122+
placeholder?.setAttribute("hidden", "");
123+
} else {
124+
image.removeAttribute("src");
125+
image.hidden = true;
126+
image.dataset.gamePreviewStatus = "missing";
127+
placeholder?.removeAttribute("hidden");
128+
}
129+
return resolved;
130+
}

0 commit comments

Comments
 (0)