Skip to content

Commit 6be0f42

Browse files
author
DavidQ
committed
Add Collision Inspector V2 and wire existing background color into render flow - PR_26133_110-collision-inspector-and-background-flow
1 parent f0d860b commit 6be0f42

22 files changed

Lines changed: 1636 additions & 52 deletions

games/Asteroids/game.manifest.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@
8989
"name": "Asteroids Asset Manager V2 Registry",
9090
"source": "manifest",
9191
"assets": {
92+
"assets.color.background.game": {
93+
"path": "palette://workspace/space-black",
94+
"type": "color",
95+
"kind": "hex",
96+
"role": "background",
97+
"source": "manifest",
98+
"color": {
99+
"hex": "#020617",
100+
"name": "Space Black",
101+
"symbol": "!",
102+
"source": "manifest"
103+
}
104+
},
92105
"assets.audio.music.beat-1": {
93106
"path": "assets/audio/beat1.wav",
94107
"type": "audio",
@@ -176,6 +189,16 @@
176189
"uniformEdgeStretchPx": 10
177190
}
178191
},
192+
"assets.image.background.deluxe": {
193+
"path": "assets/images/deluxe.png",
194+
"type": "image",
195+
"kind": "png",
196+
"role": "background",
197+
"source": "manifest",
198+
"stretchOverride": {
199+
"uniformEdgeStretchPx": 0
200+
}
201+
},
179202
"assets.image.preview.preview": {
180203
"path": "assets/images/preview.png",
181204
"type": "image",

games/Asteroids/game/AsteroidsGameScene.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -729,12 +729,15 @@ export default class AsteroidsGameScene extends Scene {
729729
this.lastPPressed = pPressed;
730730
}
731731

732-
render(renderer, engine) {
733-
const leaderboardTopScore = this.highScoreService.getTopScore(this.highScoreRows);
734-
const liveHudHighScore = Math.max(this.session.highScore, leaderboardTopScore);
732+
renderBackgroundEffects(renderer) {
735733
this.world.starfield.forEach((star) => {
736734
renderer.drawRect(star.x, star.y, star.size, star.size, '#94a3b8');
737735
});
736+
}
737+
738+
render(renderer, engine) {
739+
const leaderboardTopScore = this.highScoreService.getTopScore(this.highScoreRows);
740+
const liveHudHighScore = Math.max(this.session.highScore, leaderboardTopScore);
738741

739742
if (this.session.mode !== 'menu') {
740743
const flashOn = this.session.isTurnIntroActive()

src/engine/core/Engine.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import FixedTicker from './FixedTicker.js';
1111
import EventBus from '../events/EventBus.js';
1212
import { Camera3D } from '../camera/index.js';
1313
import {
14+
backgroundColor,
1415
backgroundImage,
1516
fullscreenBezel,
1617
FullscreenService,
@@ -33,6 +34,7 @@ export default class Engine {
3334
frameClock = null,
3435
fixedTicker = null,
3536
fullscreen = null,
37+
backgroundColorLayer = null,
3638
backgroundImageLayer = null,
3739
fullscreenBezelLayer = null,
3840
audio = null,
@@ -68,6 +70,9 @@ export default class Engine {
6870
documentRef: this.documentRef,
6971
target: this.fullscreenTarget,
7072
});
73+
this.backgroundColorLayer = backgroundColorLayer || new backgroundColor({
74+
documentRef: this.documentRef
75+
});
7176
this.backgroundImageLayer = backgroundImageLayer || new backgroundImage({
7277
documentRef: this.documentRef
7378
});
@@ -321,6 +326,15 @@ export default class Engine {
321326

322327
const renderStart = performance.now();
323328
this.renderer.clear();
329+
this.backgroundColorLayer?.render?.(this.renderer, { scene: this.scene, engine: this });
330+
if (this.scene && typeof this.scene.renderBackgroundEffects === 'function') {
331+
try {
332+
this.scene.renderBackgroundEffects(this.renderer, this);
333+
} catch (error) {
334+
this.trackRuntimeError('scene.renderBackgroundEffects', error, { severity: 'error' });
335+
throw error;
336+
}
337+
}
324338
this.backgroundImageLayer?.render?.(this.renderer, { scene: this.scene, engine: this });
325339
if (this.scene && typeof this.scene.render === 'function') {
326340
try {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
resolveGameImageConventionPaths,
3+
resolveManifestChromeAssetPaths
4+
} from "./gameImageConvention.js";
5+
6+
const COLOR_HEX_PATTERN = /^#([0-9a-f]{6}|[0-9a-f]{8})$/i;
7+
8+
function createLayerState({ assetId = "", hex = "", name = "", path = "" } = {}) {
9+
return {
10+
assetId,
11+
hex,
12+
name,
13+
path,
14+
status: hex ? "ready" : "unavailable"
15+
};
16+
}
17+
18+
function normalizeHex(value) {
19+
const color = typeof value === "string" ? value.trim() : "";
20+
return COLOR_HEX_PATTERN.test(color) ? color : "";
21+
}
22+
23+
export default class backgroundColor {
24+
constructor(options = {}) {
25+
this.documentRef = options.documentRef || globalThis.document || null;
26+
const resolved = resolveGameImageConventionPaths({
27+
gameId: options.gameId,
28+
documentRef: this.documentRef,
29+
manifestPath: options.manifestPath
30+
});
31+
this.gameId = resolved.gameId;
32+
this.manifestPath = resolved.manifestPath;
33+
this.layer = createLayerState({
34+
assetId: resolved.backgroundColorAssetId,
35+
hex: normalizeHex(resolved.backgroundColorHex),
36+
name: resolved.backgroundColorName,
37+
path: resolved.backgroundColorPath
38+
});
39+
this.manifestResolved = false;
40+
this.manifestResolvePromise = null;
41+
}
42+
43+
getState() {
44+
return {
45+
gameId: this.gameId,
46+
manifestPath: this.manifestPath,
47+
manifestResolved: this.manifestResolved,
48+
assetId: this.layer.assetId,
49+
hex: this.layer.hex,
50+
name: this.layer.name,
51+
path: this.layer.path,
52+
status: this.layer.status
53+
};
54+
}
55+
56+
ensureManifestResolved() {
57+
if (this.manifestResolved || this.manifestResolvePromise) {
58+
return;
59+
}
60+
61+
this.manifestResolvePromise = resolveManifestChromeAssetPaths({
62+
gameId: this.gameId,
63+
manifestPath: this.manifestPath,
64+
documentRef: this.documentRef
65+
})
66+
.then((resolved) => {
67+
this.gameId = resolved.gameId || this.gameId;
68+
this.manifestPath = resolved.manifestPath || this.manifestPath;
69+
const hex = normalizeHex(resolved.backgroundColorHex);
70+
this.layer = createLayerState({
71+
assetId: resolved.backgroundColorAssetId,
72+
hex,
73+
name: resolved.backgroundColorName,
74+
path: resolved.backgroundColorPath
75+
});
76+
})
77+
.catch(() => {
78+
this.layer = createLayerState();
79+
})
80+
.finally(() => {
81+
this.manifestResolved = true;
82+
this.manifestResolvePromise = null;
83+
});
84+
}
85+
86+
render(renderer) {
87+
this.ensureManifestResolved();
88+
if (!this.layer.hex) {
89+
return {
90+
drawn: false,
91+
reason: this.layer.status,
92+
assetId: this.layer.assetId
93+
};
94+
}
95+
96+
const canvasSize = renderer?.getCanvasSize?.() || { width: 0, height: 0 };
97+
const width = Number(canvasSize.width) > 0 ? Number(canvasSize.width) : 0;
98+
const height = Number(canvasSize.height) > 0 ? Number(canvasSize.height) : 0;
99+
if (width <= 0 || height <= 0 || typeof renderer?.drawRect !== "function") {
100+
return {
101+
drawn: false,
102+
reason: "renderer-unavailable",
103+
assetId: this.layer.assetId
104+
};
105+
}
106+
107+
renderer.drawRect(0, 0, width, height, this.layer.hex);
108+
return {
109+
drawn: true,
110+
reason: "drawn",
111+
assetId: this.layer.assetId,
112+
hex: this.layer.hex
113+
};
114+
}
115+
}

src/engine/runtime/backgroundImage.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,13 @@ export default class backgroundImage {
213213

214214
render(renderer, options = {}) {
215215
this.ensureLoaded();
216+
if (!this.isGameplayState(options.scene)) {
217+
return {
218+
drawn: false,
219+
reason: "non-gameplay-state",
220+
path: this.layer.path
221+
};
222+
}
216223
if (this.layer.status !== "ready" || !this.layer.image) {
217224
return {
218225
drawn: false,

src/engine/runtime/gameImageConvention.js

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ function normalizeAssetEntry(rawEntry, fallbackId = "", manifestPath = "") {
8181
};
8282
}
8383

84+
function normalizeColorEntry(rawEntry, fallbackId = "") {
85+
const entry = toObject(rawEntry);
86+
const color = toObject(entry.color);
87+
const hex = safeText(color.hex, "");
88+
if (!hex) {
89+
return null;
90+
}
91+
return {
92+
id: safeText(entry.id, "") || safeText(fallbackId, ""),
93+
hex,
94+
kind: safeText(entry.kind, "").toLowerCase(),
95+
name: safeText(color.name, ""),
96+
path: safeText(entry.path || entry.runtimePath || entry.href, ""),
97+
role: safeText(entry.role, "").toLowerCase(),
98+
type: safeText(entry.type, "").toLowerCase()
99+
};
100+
}
101+
84102
function collectImageEntriesFromManifest(manifestPayload, { manifestPath = "" } = {}) {
85103
const payload = toObject(manifestPayload);
86104
const entries = [];
@@ -115,6 +133,19 @@ function collectImageEntriesFromManifest(manifestPayload, { manifestPath = "" }
115133
return entries;
116134
}
117135

136+
function collectColorEntriesFromManifest(manifestPayload) {
137+
const payload = toObject(manifestPayload);
138+
const entries = [];
139+
const assetManagerAssets = toObject(payload?.tools?.["asset-manager-v2"]?.assets);
140+
Object.entries(assetManagerAssets).forEach(([assetId, rawEntry]) => {
141+
const entry = normalizeColorEntry(rawEntry, assetId);
142+
if (entry?.type === "color") {
143+
entries.push(entry);
144+
}
145+
});
146+
return entries;
147+
}
148+
118149
function chooseSemanticImagePath(entries, semanticToken) {
119150
const token = safeText(semanticToken, "").toLowerCase();
120151
if (!token) {
@@ -143,6 +174,13 @@ function chooseSemanticImagePath(entries, semanticToken) {
143174
return "";
144175
}
145176

177+
function chooseGameBackgroundColor(entries) {
178+
const normalizedEntries = Array.isArray(entries) ? entries : [];
179+
return normalizedEntries.find((entry) => entry.id === "assets.color.background.game")
180+
|| normalizedEntries.find((entry) => entry.role === "background" && entry.id.includes(".background.game"))
181+
|| null;
182+
}
183+
146184
const manifestCache = new Map();
147185

148186
async function readManifestPayload(manifestPath, documentRef = null) {
@@ -214,8 +252,12 @@ export function resolveGameImageConventionPaths(options = {}) {
214252
return {
215253
gameId,
216254
manifestPath,
217-
backgroundPath: "",
218-
bezelPath: ""
255+
backgroundColorAssetId: "",
256+
backgroundColorHex: "",
257+
backgroundColorName: "",
258+
backgroundColorPath: "",
259+
backgroundPath: gameId ? `games/${gameId}/assets/images/background.png` : "",
260+
bezelPath: gameId ? `games/${gameId}/assets/images/bezel.png` : ""
219261
};
220262
}
221263

@@ -228,16 +270,19 @@ export async function resolveManifestChromeAssetPaths(options = {}) {
228270
if (!manifestPayload) {
229271
return {
230272
...base,
231-
backgroundPath: "",
232-
bezelPath: "",
233273
manifestPayload: null
234274
};
235275
}
236276

237277
const imageEntries = collectImageEntriesFromManifest(manifestPayload, { manifestPath: base.manifestPath });
278+
const backgroundColor = chooseGameBackgroundColor(collectColorEntriesFromManifest(manifestPayload));
238279
return {
239280
...base,
240281
manifestPayload,
282+
backgroundColorAssetId: backgroundColor?.id || "",
283+
backgroundColorHex: backgroundColor?.hex || "",
284+
backgroundColorName: backgroundColor?.name || "",
285+
backgroundColorPath: backgroundColor?.path || "",
241286
backgroundPath: chooseSemanticImagePath(imageEntries, "background"),
242287
bezelPath: chooseSemanticImagePath(imageEntries, "bezel")
243288
};

src/engine/runtime/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ index.js
77
export { default as MobileRuntimeTweaks } from './MobileRuntimeTweaks.js';
88
export { default as FullscreenService } from './FullscreenService.js';
99
export { default as BrowserDownloadService } from './BrowserDownloadService.js';
10+
export { default as backgroundColor } from './backgroundColor.js';
1011
export { default as backgroundImage } from './backgroundImage.js';
1112
export { default as fullscreenBezel, resolvePreferredFullscreenTarget } from './fullscreenBezel.js';
1213
export { resolveGameImageConventionPaths } from './gameImageConvention.js';

tests/playwright/tools/AssetManagerV2.spec.mjs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,11 @@ test.describe("Asset Manager V2", () => {
11031103
await expect(page.locator("#assetPathInput")).toHaveValue("palette://workspace/signal-violet");
11041104
await expect(page.locator("#statusLog")).toHaveValue(/FAIL Selected color validation failed: Color usage is required for color assets\./);
11051105
await expect(page.locator("#addAssetButton")).toBeDisabled();
1106+
await page.locator("#assetRoleSelect").selectOption("background");
1107+
await page.locator("#assetUsageInput").fill("game");
1108+
await expect(page.locator("#assetIdInput")).toHaveValue("assets.color.background.game");
1109+
await expect(page.locator("#assetUsageField")).toHaveCount(1);
1110+
await page.locator("#assetRoleSelect").selectOption("hud");
11061111
await page.locator("#assetUsageInput").fill("Menu Highlight");
11071112
await expect(page.locator("#assetIdInput")).toHaveValue("assets.color.hud.menu-highlight.signal-violet");
11081113
await expect(page.locator("#assetPathInput")).toHaveValue("palette://workspace/signal-violet");
@@ -1257,7 +1262,7 @@ test.describe("Asset Manager V2", () => {
12571262
});
12581263

12591264
try {
1260-
await expect(page.locator("#workspaceToolTiles [data-workspace-tool-id]")).toHaveCount(4);
1265+
await expect(page.locator("#workspaceToolTiles [data-workspace-tool-id]")).toHaveCount(7);
12611266
await expect(page.locator('[data-workspace-tool-id="workspace-manager-v2"]')).toHaveCount(0);
12621267
await page.locator("#activeGameSelect").selectOption("Asteroids");
12631268
await expect(page.locator("#workspaceContextOutput")).toHaveValue(/"gameRoot": "games\/Asteroids\/"/);
@@ -1278,14 +1283,14 @@ test.describe("Asset Manager V2", () => {
12781283
await expect(page.locator("#returnToWorkspaceButton")).toBeEnabled();
12791284
await expect(page.locator("#workspaceInsertAssetsButton")).toHaveCount(0);
12801285
await expect(page.locator("#workspaceCopyManifestButton")).toHaveCount(0);
1281-
await expect(page.locator("#statusLog")).toHaveValue(/Workspace Manager V2 loaded 14 validated assets from tools\.asset-manager-v2\.assets/);
1286+
await expect(page.locator("#statusLog")).toHaveValue(/Workspace Manager V2 loaded 15 validated assets from tools\.asset-manager-v2\.assets/);
12821287
await expect(page.locator("#statusLog")).toHaveValue(/Workspace Manager V2 loaded \d+ palette colors from active palette context/);
12831288
const hostContextId = await page.evaluate(() => new URL(window.location.href).searchParams.get("hostContextId"));
12841289
const initialAssetCount = await page.evaluate((id) => {
12851290
const context = JSON.parse(sessionStorage.getItem(id));
12861291
return Object.keys(context.tools["asset-manager-v2"].assets).length;
12871292
}, hostContextId);
1288-
expect(initialAssetCount).toBe(14);
1293+
expect(initialAssetCount).toBe(15);
12891294
const workspacePreviewContext = await page.evaluate(async () => {
12901295
const { WorkspaceBridge } = await import("/tools/asset-manager-v2/js/services/WorkspaceBridge.js");
12911296
return new WorkspaceBridge({ windowRef: window }).readWorkspacePreviewContext();
@@ -1478,18 +1483,25 @@ test.describe("Asset Manager V2", () => {
14781483
const assetManagerCard = page.locator(".tools-platform-card").filter({
14791484
has: page.locator("h3 a", { hasText: "Asset Manager V2" })
14801485
});
1486+
const collisionInspectorLink = page.locator(".tools-platform-card h3 a", { hasText: "Collision Inspector V2" });
1487+
const collisionInspectorCard = page.locator(".tools-platform-card").filter({
1488+
has: page.locator("h3 a", { hasText: "Collision Inspector V2" })
1489+
});
14811490
await expect(assetManagerLink).toBeVisible();
14821491
await expect(assetManagerLink).toHaveAttribute("href", "/tools/asset-manager-v2/index.html");
14831492
await expect(assetManagerCard).toContainText("Schema Validated");
1493+
await expect(collisionInspectorLink).toBeVisible();
1494+
await expect(collisionInspectorLink).toHaveAttribute("href", "/tools/collision-inspector-v2/index.html");
1495+
await expect(collisionInspectorCard).toContainText("Manifest-driven collision QA");
14841496
const plannedToolNames = await page.locator("[data-planned-tools-grid] h3").allTextContents();
14851497
for (const plannedToolName of [
14861498
"Asset Manager V2",
14871499
"Animation / Flipbook Editor",
1488-
"Audio / SFX Playground",
1489-
"Collision / Hitbox Editor"
1500+
"Audio / SFX Playground"
14901501
]) {
14911502
expect(plannedToolNames).toContain(plannedToolName);
14921503
}
1504+
expect(plannedToolNames).not.toContain("Collision / Hitbox Editor");
14931505
expect(pageErrors).toEqual([]);
14941506
} finally {
14951507
await coverageReporter.stop(page);

0 commit comments

Comments
 (0)