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)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25ec7879a0
commit
1c212b1ccc
12 files changed
+195
-72
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);`);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const LANES = {
|
||||
physics: -3500,
|
||||
shapes: -3900,
|
||||
queries: -4300,
|
||||
symbols: -4700,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user