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

+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;