Scene view symbols come from SVG files: the behavior, container, rigidbody, camera and particles icons live in src/viewport/icons/ and are changed by editing the file and running npm run build; they are bundled, parsed once and drawn into a Graphics by the new module symbolIcons.ts, currentColor becomes the theme color of the symbol, the origin sits in the middle with a centered viewBox and the same units as before, so the symbols are pixel for pixel unchanged; what depends on the object (camera frame, collider outline, one-way cone) stays in code; unit tests keep the files to what Pixi's SVG parser and a browser draw alike (no transform, no polygon, fill always given), e2e 5c reads the five symbols in the theme color (#175)
game-maker / gate (push) Successful in 1m56s
game-maker / suite (push) Successful in 6m48s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-19 17:07:56 +02:00
1 parent 25ec7879a0
commit 1c212b1ccc
12 files changed
+195 -72

No files matched your search

+1 -1
View File
@@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1092 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1093 checks (GM_STEPS=20 for one step)
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
+3 -2
View File
@@ -61,7 +61,8 @@ import {
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import type { LayerFocus } from '../../viewport/tilemap';
import { CONE_LENGTH, dashedLine, drawSourceIcon, markBlockingSide, placeNode } from '../../viewport/symbols';
import { CONE_LENGTH, dashedLine, markBlockingSide, placeNode } from '../../viewport/symbols';
import { drawSymbolIcon } from '../../viewport/symbolIcons';
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
import { FreeViewport } from './FreeViewport';
@@ -922,7 +923,7 @@ export class SceneViewport extends FreeViewport {
if (!icon) {
icon = new Graphics();
icon.eventMode = 'none';
drawSourceIcon(icon);
drawSymbolIcon(icon, 'particles');
this.icons.set(item.id, icon);
this.overlay.addChild(icon);
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-14 -14 28 28" width="28" height="28">
<path d="M0 -14 L14 0 L0 14 L-14 0 Z" fill="currentColor" />
<path d="M-6 -3 H-1 V3 H6" fill="none" stroke="#ffffff" stroke-width="2" />
<circle cx="-6" cy="-3" r="2" fill="#ffffff" />
<circle cx="6" cy="3" r="2" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 334 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-20 -10 40 20" width="40" height="20">
<rect x="-12" y="-8" width="24" height="16" fill="currentColor" />
<path d="M12 0 L20 -6 L20 6 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 220 B

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -13 26 26" width="26" height="26">
<rect x="-12" y="-12" width="24" height="24" rx="3" fill="currentColor" fill-opacity="0.25" stroke="currentColor" stroke-width="2" />
<rect x="-7" y="-7" width="6" height="6" fill="currentColor" />
<rect x="1" y="-7" width="6" height="6" fill="currentColor" />
<rect x="-7" y="1" width="6" height="6" fill="currentColor" />
<rect x="1" y="1" width="6" height="6" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 491 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-15 -15 30 30" width="30" height="30">
<!-- A source draws itself, so the ring stays hollow and the sparks sit outside it: its effect shows through the middle. -->
<circle cx="0" cy="0" r="10" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M8.485 8.485 L11.314 11.314 M-8.485 8.485 L-11.314 11.314 M8.485 -8.485 L11.314 -11.314 M-8.485 -8.485 L-11.314 -11.314" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12.728" cy="12.728" r="2" fill="currentColor" />
<circle cx="-12.728" cy="12.728" r="2" fill="currentColor" />
<circle cx="12.728" cy="-12.728" r="2" fill="currentColor" />
<circle cx="-12.728" cy="-12.728" r="2" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 749 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -13 26 26" width="26" height="26">
<!-- A weight: a ring with an arrow pointing the way gravity pulls. -->
<circle cx="0" cy="0" r="12" fill="currentColor" fill-opacity="0.25" stroke="currentColor" stroke-width="2" />
<path d="M0 -6 V4" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M-5 1 L0 7 L5 1 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 410 B

+42
View File
@@ -0,0 +1,42 @@
/**
* The fixed pictures of the scene view's symbols, kept as SVG files in `icons/` (#175). A file
* draws in the symbol's own units around its origin, and `currentColor` stands for the colour the
* theme gives the symbol – so the file still shows in any SVG viewer, and a theme change reaches it.
*/
import { type Graphics, SVGParser } from 'pixi.js';
import { Theme } from '../ui';
import behavior from './icons/behavior.svg?raw';
import camera from './icons/camera.svg?raw';
import container from './icons/container.svg?raw';
import particles from './icons/particles.svg?raw';
import rigidbody from './icons/rigidbody.svg?raw';
export type SymbolIcon = 'behavior' | 'camera' | 'container' | 'particles' | 'rigidbody';
export const SYMBOL_SVG: Record<SymbolIcon, string> = { behavior, camera, container, particles, rigidbody };
const SYMBOL_COLOR: Record<SymbolIcon, number> = {
behavior: Theme.colors.behavior,
camera: Theme.colors.textMuted,
container: Theme.colors.container,
particles: Theme.colors.particles,
rigidbody: Theme.colors.collider,
};
export function tintSvg(svg: string, color: number): string {
return svg.replaceAll('currentColor', `#${color.toString(16).padStart(6, '0')}`);
}
/** Parsed once: a symbol is redrawn with every frame, and the text parse is what costs. */
const parsed = new Map<SymbolIcon, SVGElement>();
/** Adds the icon to what `node` already draws. */
export function drawSymbolIcon(node: Graphics, icon: SymbolIcon): void {
let svg = parsed.get(icon);
if (!svg) {
const text = tintSvg(SYMBOL_SVG[icon], SYMBOL_COLOR[icon]);
svg = new DOMParser().parseFromString(text, 'image/svg+xml').documentElement as unknown as SVGElement;
parsed.set(icon, svg);
}
SVGParser(svg, node.context);
}
+7 -69
View File
@@ -1,14 +1,7 @@
/** Everything here draws in the item's own units at its origin; `placeNode` puts it into the world. */
import { type Container, type Graphics, Mesh, MeshGeometry, Rectangle, Texture } from 'pixi.js';
import { determinant, IDENTITY, type Mat } from '../core/model/matrix';
import {
BEHAVIOR_SYMBOL,
CONTAINER_SYMBOL,
PARTICLES_SYMBOL,
placementOf,
RIGIDBODY_SYMBOL,
textAnchor,
} from '../core/properties/bounds';
import { placementOf, textAnchor } from '../core/properties/bounds';
import {
type ColliderShape,
colliderShape,
@@ -23,6 +16,7 @@ import { Theme } from '../ui';
import type { DrawItem } from './items';
import { sliceGeometry, sliceKey } from './nineSlice';
import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders';
import { drawSymbolIcon } from './symbolIcons';
import type { TextureLibrary } from './TextureLibrary';
/** Screen-pixel half-width of the grab band along a collider's outline. */
@@ -137,46 +131,13 @@ export function drawItem(
node
.rect(-size.w / 2, -size.h / 2, size.w, size.h)
.stroke({ width: 2 / scale, color: Theme.colors.textMuted });
node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted);
node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted);
drawSymbolIcon(node, 'camera');
return;
case 'behavior': {
const r = BEHAVIOR_SYMBOL / 2;
node.moveTo(0, -r).lineTo(r, 0).lineTo(0, r).lineTo(-r, 0).closePath().fill(Theme.colors.behavior);
node
.moveTo(-6, -3)
.lineTo(-1, -3)
.lineTo(-1, 3)
.lineTo(6, 3)
.stroke({ width: 2, color: Theme.colors.accentText });
node.circle(-6, -3, 2).fill(Theme.colors.accentText);
node.circle(6, 3, 2).fill(Theme.colors.accentText);
case 'behavior':
case 'container':
case 'rigidbody':
drawSymbolIcon(node, item.type);
return;
}
case 'container': {
const r = CONTAINER_SYMBOL / 2;
node
.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3)
.fill({ color: Theme.colors.container, alpha: 0.25 })
.stroke({ width: 2, color: Theme.colors.container });
for (const [bx, by] of [
[-7, -7],
[1, -7],
[-7, 1],
[1, 1],
] as const)
node.rect(bx, by, 6, 6).fill(Theme.colors.container);
return;
}
// A weight: a ring with an arrow pointing the way gravity pulls.
case 'rigidbody': {
const r = RIGIDBODY_SYMBOL / 2;
const color = Theme.colors.collider;
node.circle(0, 0, r).fill({ color, alpha: 0.25 }).stroke({ width: 2, color });
node.moveTo(0, -6).lineTo(0, 4).stroke({ width: 2, color });
node.moveTo(-5, 1).lineTo(0, 7).lineTo(5, 1).closePath().fill(color);
return;
}
case 'text':
node.hitArea = new Rectangle(
-size.w / 2 - (textAnchor(item.props) - 0.5) * size.w,
@@ -212,32 +173,9 @@ export function drawItem(
}
}
/**
* The icon of a particle source (#89), drawn at its position in world units like the other symbols.
* Unlike them it marks something that draws itself, so the ring stays hollow and the sparks sit
* outside it: the effect it belongs to shows through its middle.
*/
export function drawSourceIcon(node: Graphics): void {
const r = PARTICLES_SYMBOL / 2;
const color = Theme.colors.particles;
node.circle(0, 0, r - 2).stroke({ width: 2, color });
for (const [ux, uy] of DIAGONALS) {
node.moveTo(ux * r, uy * r).lineTo(ux * (r + 4), uy * (r + 4));
node.stroke({ width: 2, color });
node.circle(ux * (r + 6), uy * (r + 6), 2).fill(color);
}
}
/** How far the landing cone of a one-way side reaches, in screen pixels (#159). */
export const CONE_LENGTH = 24;
const DIAGONALS = [
[Math.SQRT1_2, Math.SQRT1_2],
[-Math.SQRT1_2, Math.SQRT1_2],
[Math.SQRT1_2, -Math.SQRT1_2],
[-Math.SQRT1_2, -Math.SQRT1_2],
] as const;
interface Bounds {
left: number;
top: number;
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { SYMBOL_SVG, tintSvg } from '../../src/viewport/symbolIcons';
/** Opening tags as name + attributes; the files are flat, so no real XML parser is needed. */
function tags(svg: string): { name: string; attrs: Record<string, string> }[] {
return [...svg.matchAll(/<([a-z]+)([^>]*?)\/?>/g)].map(([, name, rest]) => ({
name: name!,
attrs: Object.fromEntries([...rest!.matchAll(/([a-zA-Z-]+)="([^"]*)"/g)].map(([, k, v]) => [k!, v!])),
}));
}
describe('scene view symbol icons (#175)', () => {
const icons = Object.entries(SYMBOL_SVG);
it('has a file for every symbol', () => {
expect(Object.keys(SYMBOL_SVG).sort()).toEqual([
'behavior',
'camera',
'container',
'particles',
'rigidbody',
]);
});
// Pixi draws the file's coordinates as they are and ignores the viewBox, so the viewBox only
// shows the file right elsewhere – centred on the origin, it matches what the scene view draws.
it.each(icons)('%s is centred on its origin', (_, svg) => {
const root = tags(svg)[0]!;
expect(root.name).toBe('svg');
const [x, y, w, h] = root.attrs.viewBox!.split(' ').map(Number);
expect([x, y]).toEqual([-w! / 2, -h! / 2]);
expect([Number(root.attrs.width), Number(root.attrs.height)]).toEqual([w, h]);
});
it.each(icons)('%s uses only what the Pixi parser draws like a browser', (_, svg) => {
for (const { name, attrs } of tags(svg).slice(1)) {
// polygon and polyline read their points as whole numbers; groups would carry transforms,
// which the parser ignores.
expect(['path', 'circle', 'rect', 'ellipse', 'line']).toContain(name);
expect(attrs.transform).toBeUndefined();
// Without a fill a browser fills black, Pixi does not fill at all once there is a stroke.
expect(attrs.fill, `${name} states its fill`).toBeDefined();
for (const paint of [attrs.fill, attrs.stroke].filter((p) => p !== undefined))
expect(paint).toMatch(/^(none|currentColor|#[0-9a-f]{6})$/);
}
});
it('puts the theme colour where the file says currentColor', () => {
expect(tintSvg('<path fill="currentColor" stroke="currentColor"/>', 0x0000ff)).toBe(
'<path fill="#0000ff" stroke="#0000ff"/>',
);
for (const [, svg] of icons) expect(tintSvg(svg, 0xb066ff)).not.toContain('currentColor');
});
});
+55
View File
@@ -1,3 +1,5 @@
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
/** Draw order: symbols always above sprites, `z` above hierarchy order. */
export async function drawing(t) {
const { check, json, shot, click, locate, typeText, press, undo, mutate, select, object } = t;
@@ -48,4 +50,57 @@ export async function drawing(t) {
await shot('14-z-order');
await undo();
check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`);
// 5c. The symbols are SVG files (#175): one of each in a lane of its own, read where each has a
// solid colour of the theme. Offsets are in world units from the symbol's centre.
const PLACED = { 's-beh': 'mover', 's-box': 's-box-a', 's-rb': 's-rb-a', 's-cam': 'cam', 's-pt': 's-pt-a' };
const SPOTS = [
['s-beh', 8, 0, '#b066ff'],
['s-beh', -6, -3, '#ffffff'],
['s-box', -4, -4, '#3fb8c9'],
['s-box', 4, 4, '#3fb8c9'],
['s-rb', 0, 3, '#4fd18b'],
['s-rb', 0, -4, '#4fd18b'],
['s-cam', 0, 0, '#8a90a0'],
['s-cam', 17, 0, '#8a90a0'],
['s-pt', 12.728, 12.728, '#c26a1f'],
['s-pt', -12.728, -12.728, '#c26a1f'],
];
const ids = Object.keys(PLACED);
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
p.assets.push(
{ id: 's-box-a', type: 'container', name: 's-box-a', props: { enabled: true }, objects: [] },
{ id: 's-rb-a', type: 'rigidbody', name: 's-rb-a', props: { bodyType: 'static' } },
{ id: 's-pt-a', type: 'particles', name: 's-pt-a', props: {}, graph: { nodes: [], edges: [] } },
);
Object.entries(${JSON.stringify(PLACED)}).forEach(([id, assetId], i) => {
const o = make(id, assetId, '', ${LANES.symbols}, id);
o.position.x = (i - 2) * 120;
scene.objects.push(o);
});
__gm.state.select(null);
__gm.lookAt(0, ${LANES.symbols});`);
const wrong = await json(`(() => {
const zoom = __gm.objectRect('s-beh').w / 28;
return ${JSON.stringify(SPOTS)}.flatMap(([id, dx, dy, want]) => {
const r = __gm.objectRect(id);
const got = __gm.pixelAt(Math.floor(r.x + dx * zoom), Math.floor(r.y + dy * zoom));
return got === want ? [] : [id + ' ' + dx + ',' + dy + ': ' + got + ' != ' + want];
});
})()`);
check(
'every symbol draws its SVG in the theme colour',
wrong.length === 0,
wrong.join('; ') || `${SPOTS.length} spots`,
);
await shot('15-symbols');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !${JSON.stringify(ids)}.includes(o.id));
p.assets = p.assets.filter((a) => !['s-box-a', 's-rb-a', 's-pt-a'].includes(a.id));
__gm.lookAt(0, 0);`);
}
+1
View File
@@ -24,6 +24,7 @@ export const LANES = {
physics: -3500,
shapes: -3900,
queries: -4300,
symbols: -4700,
};
/**