Draw scene symbols as white panel icons that make room for each other
The scene view drew its symbols from its own SVGs, each in a type colour and some on a plate. They looked heavier than the panel icons and were hard to read on busy ground. Symbols of different objects also sat on top of each other whenever their positions met. - Every symbol is now the panel icon of its kind, one file for both places. The scene-only SVGs in src/viewport/icons are gone. - Symbols are white in a thin dark outline with no plate behind them. haloSvg widens each shape of the file by its own stroke and draws that silhouette dark under the icon, so no second file is needed. - A butt stroke end would leave its end without outline. The halo caps such strokes square, and the rigidbody arrow shaft is now a filled rect so its top edge is outlined exactly. - Symbols are 18 world units across, stay upright and are clickable. A click selects the object, a drag moves it. - symbolLayout places every symbol on its object's position. Symbols that overlap stand on a ring round the middle of their positions, evenly spaced one icon apart. A ring that reaches into another symbol takes it in until nothing overlaps. Ownership plays no part. - Names sit beside their symbol, on the outside of a ring. Selection frames and parent links follow the symbol. - A collider icon no longer shows trigger or invalid state. The outline colour of the shape still does. - A lone collider sits on its sprite's middle again. A press there on an unselected sprite drags the collider, so e2e 02 and 16 now grab the sprite beside its middle. Hook __gm.container.objectRect helps 16 find that spot. Tests: - check.sh: 2245 tests, CHECK EXIT 0 (+8 for layout and halo) - e2e 1-16,31,39-43: 670 PASS, 2 FAIL. Both were sampling too strictly for the thinner, smaller icons. 5c looked for the exact outline colour, which the thin outline only reaches blended at this zoom. 31n started the ring scan one pixel past the smaller ring. Both are fixed, and e2e 5,31 then passed with 48 checks. - 5c checks white with an outline and none of the old colours, 41b checks the ring positions and that a click on a symbol selects its object, 31n checks the now white particle ring. - Counter-checks: without the halo only 5c fails, also with the looser outline match. Without the layout the ring and click checks in 41b fail. Originals restored by cmp. - The e2e check total stays 1351. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -22,6 +22,11 @@ export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCan
|
|||||||
return this.canvas.objectScreen(objectId);
|
return this.canvas.objectScreen(objectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The screen rectangle around a template object, centre plus size (test hook). */
|
||||||
|
objectRect(objectId: string): ReturnType<ContainerCanvas['objectRect']> {
|
||||||
|
return this.canvas.objectRect(objectId);
|
||||||
|
}
|
||||||
|
|
||||||
override selectedId(): string {
|
override selectedId(): string {
|
||||||
return this.state.selectedContainerId ?? '';
|
return this.state.selectedContainerId ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,6 +191,7 @@ export function sceneHooks({ state, editor }: HookContext) {
|
|||||||
rect: () => containerEditor.canvasRect(),
|
rect: () => containerEditor.canvasRect(),
|
||||||
/** Where a template object is drawn, so a check can click or sample it. */
|
/** Where a template object is drawn, so a check can click or sample it. */
|
||||||
objectScreen: (id: string) => containerEditor.objectScreen(id),
|
objectScreen: (id: string) => containerEditor.objectScreen(id),
|
||||||
|
objectRect: (id: string) => containerEditor.objectRect(id),
|
||||||
dropPreview: () => containerEditor.dropInfo(),
|
dropPreview: () => containerEditor.dropInfo(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||||
<!-- A weight, as in the scene view: a ring with an arrow pointing the way gravity pulls. -->
|
<!-- A weight, as in the scene view: a ring with an arrow pointing the way gravity pulls. -->
|
||||||
<circle cx="0" cy="0" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
<circle cx="0" cy="0" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
||||||
<path d="M0 -3.5 V1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
|
<rect x="-0.75" y="-3.5" width="1.5" height="5" fill="currentColor" />
|
||||||
<path d="M-3 0.5 L0 4 L3 0.5 Z" fill="currentColor" />
|
<path d="M-3 0.5 L0 4 L3 0.5 Z" fill="currentColor" />
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 407 B |
@@ -3,7 +3,7 @@
|
|||||||
* copy, so live positions are visible and stay editable. A container's template is the same
|
* copy, so live positions are visible and stay editable. A container's template is the same
|
||||||
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
|
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
|
||||||
*/
|
*/
|
||||||
import { type FederatedPointerEvent, Graphics, Rectangle, Text } from 'pixi.js';
|
import { Container, type FederatedPointerEvent, Graphics, Rectangle, Text } from 'pixi.js';
|
||||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||||
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||||
import { hasSceneIcon } from '../../core/model/AssetTypes';
|
import { hasSceneIcon } from '../../core/model/AssetTypes';
|
||||||
@@ -13,10 +13,10 @@ import {
|
|||||||
apply as applyPoint,
|
apply as applyPoint,
|
||||||
applyVector,
|
applyVector,
|
||||||
type Extent,
|
type Extent,
|
||||||
IDENTITY,
|
|
||||||
invert,
|
invert,
|
||||||
linearOf,
|
linearOf,
|
||||||
type Mat,
|
type Mat,
|
||||||
|
translation,
|
||||||
} from '../../core/model/matrix';
|
} from '../../core/model/matrix';
|
||||||
import { placementRejection } from '../../core/model/placement';
|
import { placementRejection } from '../../core/model/placement';
|
||||||
import type { Position, Vec2 } from '../../core/model/Properties';
|
import type { Position, Vec2 } from '../../core/model/Properties';
|
||||||
@@ -32,9 +32,9 @@ import { collectHolderItems, type DrawItem } from '../../viewport/items';
|
|||||||
import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick';
|
import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick';
|
||||||
import { openObjectMenu } from '../objectMenu';
|
import { openObjectMenu } from '../objectMenu';
|
||||||
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
|
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
|
||||||
import { colliderShape } from '../../core/properties/collider';
|
import { dashedLine } from '../../viewport/symbols';
|
||||||
import { colliderColor, dashedLine } from '../../viewport/symbols';
|
import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons';
|
||||||
import { drawSymbolIcon } from '../../viewport/symbolIcons';
|
import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout';
|
||||||
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||||||
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
|
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
|
||||||
import { FreeViewport } from './FreeViewport';
|
import { FreeViewport } from './FreeViewport';
|
||||||
@@ -82,10 +82,12 @@ export interface GizmoInfo {
|
|||||||
|
|
||||||
const OFF_GIZMO_ALPHA = 0.45;
|
const OFF_GIZMO_ALPHA = 0.45;
|
||||||
|
|
||||||
/** World units a collider's icon stands left of the collider's centre. */
|
/** Side of a symbol's icon in world units: it grows and shrinks with the zoom like the scene. */
|
||||||
const ICON_CLEARANCE = 28;
|
const SYMBOL_SIZE = 18;
|
||||||
/** Half the side of a collider icon's clickable square, in world units – the SVG's view box. */
|
/** World units between the centres of icons that share a spot – an icon and its outline, and a hair. */
|
||||||
const ICON_HALF = 13;
|
const SYMBOL_SPACING = 21;
|
||||||
|
/** Types whose icon is all there is of them: they are selected and outlined where it stands. */
|
||||||
|
const ICON_ONLY = new Set<DrawItem['type']>(['behavior', 'container', 'rigidbody']);
|
||||||
|
|
||||||
/** Owner links in the colour of the icon they leave; anything else in the muted default. */
|
/** Owner links in the colour of the icon they leave; anything else in the muted default. */
|
||||||
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
|
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
|
||||||
@@ -100,7 +102,11 @@ export class SceneViewport extends FreeViewport {
|
|||||||
private readonly gizmos = new Graphics();
|
private readonly gizmos = new Graphics();
|
||||||
private readonly labels = new Map<string, Text>();
|
private readonly labels = new Map<string, Text>();
|
||||||
private readonly icons = new Map<string, Graphics>();
|
private readonly icons = new Map<string, Graphics>();
|
||||||
private readonly iconColors = new Map<string, number>();
|
/** Names under icons, icons above everything else in the overlay – outlines and frames included. */
|
||||||
|
private readonly labelLayer = new Container();
|
||||||
|
private readonly iconLayer = new Container();
|
||||||
|
/** Where each icon stood at the last redraw, in world units (see `layoutSymbols`). */
|
||||||
|
private spots = new Map<string, SymbolSpot>();
|
||||||
private readonly solids = new TilemapSolidOutlines(this.overlay);
|
private readonly solids = new TilemapSolidOutlines(this.overlay);
|
||||||
private readonly host: SceneViewHost = {
|
private readonly host: SceneViewHost = {
|
||||||
state: this.state,
|
state: this.state,
|
||||||
@@ -132,6 +138,8 @@ export class SceneViewport extends FreeViewport {
|
|||||||
this.world.addChildAt(this.links, 1);
|
this.world.addChildAt(this.links, 1);
|
||||||
this.content.addChild(this.gizmos);
|
this.content.addChild(this.gizmos);
|
||||||
this.links.eventMode = 'none';
|
this.links.eventMode = 'none';
|
||||||
|
this.labelLayer.eventMode = 'none';
|
||||||
|
this.overlay.addChild(this.labelLayer, this.iconLayer);
|
||||||
|
|
||||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||||
@@ -436,7 +444,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
return hits;
|
return hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the pointer is on the clickable icon of this object – a collider's. */
|
/** Whether the pointer is on this object's icon, wherever `placeIcons` put it. */
|
||||||
private onIcon(id: string, global: Vec2): boolean {
|
private onIcon(id: string, global: Vec2): boolean {
|
||||||
const icon = this.icons.get(id);
|
const icon = this.icons.get(id);
|
||||||
if (!icon?.visible || icon.eventMode === 'none' || !icon.hitArea) return false;
|
if (!icon?.visible || icon.eventMode === 'none' || !icon.hitArea) return false;
|
||||||
@@ -484,8 +492,9 @@ export class SceneViewport extends FreeViewport {
|
|||||||
return this.lastGizmos;
|
return this.lastGizmos;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The links are in world units, so a pan leaves them where they are. */
|
/** Icons and links are in world units, so a pan leaves them where they are. */
|
||||||
protected override afterItems(items: DrawItem[], view: View): void {
|
protected override afterItems(items: DrawItem[], view: View): void {
|
||||||
|
this.placeIcons(items, view.scale);
|
||||||
this.drawParentLinks(items, view.scale);
|
this.drawParentLinks(items, view.scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,10 +530,15 @@ export class SceneViewport extends FreeViewport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, extent: Extent): void {
|
protected override syncDecorations(
|
||||||
|
item: DrawItem,
|
||||||
|
onScreen: boolean,
|
||||||
|
scale: number,
|
||||||
|
_extent: Extent,
|
||||||
|
): void {
|
||||||
this.syncIcon(item, onScreen);
|
this.syncIcon(item, onScreen);
|
||||||
this.solids.sync(item, onScreen, scale);
|
this.solids.sync(item, onScreen, scale);
|
||||||
this.syncLabel(item, onScreen, scale, extent);
|
this.syncLabel(item, onScreen, scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override disposeDecorations(id: string): void {
|
protected override disposeDecorations(id: string): void {
|
||||||
@@ -532,7 +546,6 @@ export class SceneViewport extends FreeViewport {
|
|||||||
this.labels.delete(id);
|
this.labels.delete(id);
|
||||||
this.icons.get(id)?.destroy();
|
this.icons.get(id)?.destroy();
|
||||||
this.icons.delete(id);
|
this.icons.delete(id);
|
||||||
this.iconColors.delete(id);
|
|
||||||
this.solids.dispose(id);
|
this.solids.dispose(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,10 +554,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
return this.solids.info(this.lastItems.find((i) => i.id === id));
|
return this.solids.info(this.lastItems.find((i) => i.id === id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The icon of a symbol in world units, the name beside it and whether it hangs off its owner (test hook). */
|
||||||
* The icon of a particle source or collider, in world units, the name beside it and whether it
|
|
||||||
* hangs off its owner (test hook).
|
|
||||||
*/
|
|
||||||
iconInfo(id: string): {
|
iconInfo(id: string): {
|
||||||
layer: string;
|
layer: string;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -552,7 +562,6 @@ export class SceneViewport extends FreeViewport {
|
|||||||
link: boolean;
|
link: boolean;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
color: number | null;
|
|
||||||
screen: Vec2;
|
screen: Vec2;
|
||||||
clickable: boolean;
|
clickable: boolean;
|
||||||
} | null {
|
} | null {
|
||||||
@@ -561,31 +570,27 @@ export class SceneViewport extends FreeViewport {
|
|||||||
const label = this.labels.get(id);
|
const label = this.labels.get(id);
|
||||||
const screen = icon.getGlobalPosition();
|
const screen = icon.getGlobalPosition();
|
||||||
return {
|
return {
|
||||||
layer: icon.parent === this.overlay ? 'overlay' : 'objects',
|
layer: icon.parent === this.iconLayer ? 'overlay' : 'objects',
|
||||||
visible: icon.visible,
|
visible: icon.visible,
|
||||||
label: label?.visible ? label.text : '',
|
label: label?.visible ? label.text : '',
|
||||||
link: this.lastLinks.includes(id),
|
link: this.lastLinks.includes(id),
|
||||||
x: icon.position.x,
|
x: icon.position.x,
|
||||||
y: icon.position.y,
|
y: icon.position.y,
|
||||||
color: this.iconColors.get(id) ?? null,
|
|
||||||
screen: { x: screen.x, y: screen.y },
|
screen: { x: screen.x, y: screen.y },
|
||||||
clickable: icon.eventMode !== 'none',
|
clickable: icon.eventMode !== 'none',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A source draws only its particles, so without a mark of its own an empty one is invisible and
|
* Every symbol shows the asset panel's icon of its type, above everything in the scene, and is
|
||||||
* cannot be found (#89). The mark is a decoration rather than a symbol: `GameViewport` never syncs
|
* picked by it: a behavior, container or rigidbody is nothing else, and a thin collider outline
|
||||||
* decorations, so it is editor-only without the source leaving the running game. A collider gets
|
* or an empty particle source is easily missed (#89). The icons are decorations, so
|
||||||
* one too, like every other symbol: a thin outline around a sprite is easily missed. Its icon is
|
* `GameViewport` – which never syncs any – leaves them out, and a source's particles still reach
|
||||||
* clicked like the outline – a source's square already covers its own – and stands left of the
|
* the running game. Where an icon stands is decided for all of them at once (`placeIcons`).
|
||||||
* collider's centre: the middle of the sprite it sits on stays free for grabbing the sprite, and
|
|
||||||
* a rigidbody's icon on the same centre stays uncovered (its name stands to its right).
|
|
||||||
*/
|
*/
|
||||||
private syncIcon(item: DrawItem, show: boolean): void {
|
private syncIcon(item: DrawItem, show: boolean): void {
|
||||||
let icon = this.icons.get(item.id);
|
let icon = this.icons.get(item.id);
|
||||||
const type = item.type === 'particles' || item.type === 'collider' ? item.type : null;
|
if (!show || !hasSceneIcon(item.type)) {
|
||||||
if (!show || !type) {
|
|
||||||
if (icon) {
|
if (icon) {
|
||||||
icon.visible = false;
|
icon.visible = false;
|
||||||
icon.eventMode = 'none';
|
icon.eventMode = 'none';
|
||||||
@@ -594,29 +599,41 @@ export class SceneViewport extends FreeViewport {
|
|||||||
}
|
}
|
||||||
if (!icon) {
|
if (!icon) {
|
||||||
icon = new Graphics();
|
icon = new Graphics();
|
||||||
icon.eventMode = 'none';
|
drawSymbolIcon(icon, item.type as SymbolIcon);
|
||||||
if (type === 'collider') {
|
icon.scale.set(SYMBOL_SIZE / SYMBOL_GRID);
|
||||||
icon.hitArea = new Rectangle(-ICON_HALF, -ICON_HALF, ICON_HALF * 2, ICON_HALF * 2);
|
const half = SYMBOL_GRID / 2 + SYMBOL_OUTLINE;
|
||||||
|
icon.hitArea = new Rectangle(-half, -half, half * 2, half * 2);
|
||||||
icon.cursor = 'move';
|
icon.cursor = 'move';
|
||||||
icon.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
icon.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
||||||
}
|
|
||||||
this.icons.set(item.id, icon);
|
this.icons.set(item.id, icon);
|
||||||
this.overlay.addChild(icon);
|
this.iconLayer.addChild(icon);
|
||||||
}
|
|
||||||
if (type === 'collider') icon.eventMode = 'static';
|
|
||||||
// A collider's icon takes the colour of its outline: a trigger's, or red for a shape that
|
|
||||||
// collides with nothing.
|
|
||||||
const color =
|
|
||||||
type === 'collider'
|
|
||||||
? colliderColor(colliderShape(item.props, IDENTITY, item.frame), item.props)
|
|
||||||
: Theme.colors.particles;
|
|
||||||
if (this.iconColors.get(item.id) !== color) {
|
|
||||||
icon.clear();
|
|
||||||
drawSymbolIcon(icon, type, color);
|
|
||||||
this.iconColors.set(item.id, color);
|
|
||||||
}
|
}
|
||||||
icon.visible = true;
|
icon.visible = true;
|
||||||
icon.position.set(type === 'collider' ? item.x - ICON_CLEARANCE : item.x, item.y);
|
icon.eventMode = 'static';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts every icon where `layoutSymbols` says – on its object, or on a ring round a spot several
|
||||||
|
* want – and each name beside its icon, on the outer side of a ring. Switched-off objects draw no
|
||||||
|
* icon and take no room; ones off screen do, so an icon does not jump while the view pans.
|
||||||
|
*/
|
||||||
|
private placeIcons(items: DrawItem[], scale: number): void {
|
||||||
|
const last = this.overlay.children.length - 1;
|
||||||
|
this.overlay.setChildIndex(this.labelLayer, last);
|
||||||
|
this.overlay.setChildIndex(this.iconLayer, last);
|
||||||
|
const anchors = items
|
||||||
|
.filter((item) => item.enabled && hasSceneIcon(item.type))
|
||||||
|
.map((item) => ({ id: item.id, x: item.x, y: item.y }));
|
||||||
|
this.spots = layoutSymbols(anchors, SYMBOL_SPACING);
|
||||||
|
const gap = SYMBOL_SIZE / 2 + 4 / scale;
|
||||||
|
for (const [id, spot] of this.spots) {
|
||||||
|
this.icons.get(id)?.position.set(spot.x, spot.y);
|
||||||
|
const label = this.labels.get(id);
|
||||||
|
if (!label?.visible) continue;
|
||||||
|
const left = spot.angle !== null && Math.cos(spot.angle) < -1e-6;
|
||||||
|
label.anchor.set(left ? 1 : 0, 0.5);
|
||||||
|
label.position.set(spot.x + (left ? -gap : gap), spot.y);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncGrid(): void {
|
private syncGrid(): void {
|
||||||
@@ -649,13 +666,16 @@ export class SceneViewport extends FreeViewport {
|
|||||||
if (!parent || !parent.enabled) continue;
|
if (!parent || !parent.enabled) continue;
|
||||||
this.lastLinks.push(item.id);
|
this.lastLinks.push(item.id);
|
||||||
const color = LINK_COLORS[item.type] ?? Theme.colors.textMuted;
|
const color = LINK_COLORS[item.type] ?? Theme.colors.textMuted;
|
||||||
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
|
const from = this.spots.get(item.id) ?? item;
|
||||||
|
const to = this.spots.get(parent.id) ?? parent;
|
||||||
|
dashedLine(this.links, from.x, from.y, to.x, to.y, 8 / scale, 6 / scale);
|
||||||
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
|
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
|
||||||
this.links.circle(parent.x, parent.y, 3 / scale).fill(color);
|
this.links.circle(to.x, to.y, 3 / scale).fill(color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncLabel(item: DrawItem, show: boolean, scale: number, extent: Extent): void {
|
/** The name of a symbol; `placeIcons` puts it beside the icon once every icon has its spot. */
|
||||||
|
private syncLabel(item: DrawItem, show: boolean, scale: number): void {
|
||||||
let label = this.labels.get(item.id);
|
let label = this.labels.get(item.id);
|
||||||
if (!show || !hasSceneIcon(item.type)) {
|
if (!show || !hasSceneIcon(item.type)) {
|
||||||
if (label) label.visible = false;
|
if (label) label.visible = false;
|
||||||
@@ -665,14 +685,11 @@ export class SceneViewport extends FreeViewport {
|
|||||||
label = new Text({ text: '', style: textStyle('viewportLabelMuted') });
|
label = new Text({ text: '', style: textStyle('viewportLabelMuted') });
|
||||||
label.eventMode = 'none';
|
label.eventMode = 'none';
|
||||||
this.labels.set(item.id, label);
|
this.labels.set(item.id, label);
|
||||||
this.overlay.addChild(label);
|
this.labelLayer.addChild(label);
|
||||||
}
|
}
|
||||||
label.visible = true;
|
label.visible = true;
|
||||||
label.text = item.name;
|
label.text = item.name;
|
||||||
label.scale.set(1 / scale);
|
label.scale.set(1 / scale);
|
||||||
if (item.type === 'camera' || item.type === 'collider')
|
|
||||||
label.position.set(extent.left, extent.top - 16 / scale);
|
|
||||||
else label.position.set(extent.right + 4 / scale, item.y - 8 / scale);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -710,9 +727,12 @@ export class SceneViewport extends FreeViewport {
|
|||||||
for (const id of s.objectIds) {
|
for (const id of s.objectIds) {
|
||||||
const item = items.find((i) => i.id === id);
|
const item = items.find((i) => i.id === id);
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
// Two screen pixels outside the footprint, whichever way it is turned.
|
// Two screen pixels outside the footprint, whichever way it is turned. A symbol that is
|
||||||
|
// only its icon is outlined where the icon stands, which need not be its position.
|
||||||
|
const spot = ICON_ONLY.has(item.type) ? this.spots.get(id) : undefined;
|
||||||
|
const m = spot ? translation(spot.x, spot.y) : item.m;
|
||||||
const corners = this.screenCorners(
|
const corners = this.screenCorners(
|
||||||
footprintCorners(item.type, item.props, item.m, 2 / view.scale, item.frame),
|
footprintCorners(item.type, item.props, m, 2 / view.scale, item.frame),
|
||||||
view,
|
view,
|
||||||
);
|
);
|
||||||
// A switched-off object draws nothing, so its outline is the only way to see where it
|
// A switched-off object draws nothing, so its outline is the only way to see where it
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ export const Theme = {
|
|||||||
danger: 0xe0553f,
|
danger: 0xe0553f,
|
||||||
viewport: 0x121316,
|
viewport: 0x121316,
|
||||||
gizmo: 0x4c8dff,
|
gizmo: 0x4c8dff,
|
||||||
/** Behavior symbols and owner links. */
|
/** Every symbol in the scene view: white, held off any ground by a dark outline. */
|
||||||
|
symbol: 0xffffff,
|
||||||
|
symbolOutline: 0x0b0c0f,
|
||||||
|
/** Owner links of behaviors. */
|
||||||
behavior: 0xb066ff,
|
behavior: 0xb066ff,
|
||||||
/** Collider outlines: solid (blocking) and trigger (events only). */
|
/** Collider outlines: solid (blocking) and trigger (events only). */
|
||||||
collider: 0x4fd18b,
|
collider: 0x4fd18b,
|
||||||
|
|||||||
@@ -71,4 +71,4 @@ export {
|
|||||||
toHex,
|
toHex,
|
||||||
} from './color';
|
} from './color';
|
||||||
export { pressCount } from './clicks';
|
export { pressCount } from './clicks';
|
||||||
export { drawSvg, tintSvg } from './svg';
|
export { drawSvg, haloSvg, tintSvg } from './svg';
|
||||||
@@ -9,6 +9,32 @@ export function tintSvg(svg: string, color: number): string {
|
|||||||
return svg.replaceAll('currentColor', `#${color.toString(16).padStart(6, '0')}`);
|
return svg.replaceAll('currentColor', `#${color.toString(16).padStart(6, '0')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHAPE = /<(rect|circle|ellipse|path|polygon|polyline|line)\b([^>]*?)\s*\/>/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same file as a silhouette `width` units wider on every side: each shape keeps its fill and
|
||||||
|
* gets a stroke of its own stroke plus twice `width`, all in `currentColor`. Drawn in a dark colour
|
||||||
|
* under the file itself it is an outline that keeps a light icon readable on any ground.
|
||||||
|
* A stroke that ends flat (`butt`, the default) would leave its ends without an outline, so the
|
||||||
|
* halo caps it square – a little long by half the own stroke. An icon that wants an exact outline
|
||||||
|
* at an open end draws that part as a filled shape.
|
||||||
|
*/
|
||||||
|
export function haloSvg(svg: string, width: number): string {
|
||||||
|
return svg.replace(SHAPE, (_, tag: string, attrs: string) => {
|
||||||
|
const fill = /\sfill="([^"]*)"/.exec(attrs)?.[1] ?? 'none';
|
||||||
|
const stroke = /\sstroke="([^"]*)"/.exec(attrs)?.[1] ?? 'none';
|
||||||
|
const own = stroke === 'none' ? 0 : Number(/\sstroke-width="([^"]*)"/.exec(attrs)?.[1] ?? 1);
|
||||||
|
const cap = /\sstroke-linecap="([^"]*)"/.exec(attrs)?.[1] ?? 'butt';
|
||||||
|
const geometry = attrs.replace(
|
||||||
|
/\s(fill|stroke|stroke-width|stroke-linecap|fill-opacity|stroke-opacity)="[^"]*"/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const paint = fill === 'none' ? 'none' : 'currentColor';
|
||||||
|
const ends = cap === 'butt' ? 'square' : cap;
|
||||||
|
return `<${tag}${geometry} fill="${paint}" stroke="currentColor" stroke-width="${own + 2 * width}" stroke-linecap="${ends}" />`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Parsed once per file and colour: a scene symbol is redrawn with every frame. */
|
/** Parsed once per file and colour: a scene symbol is redrawn with every frame. */
|
||||||
const parsed = new Map<string, Map<number, SVGElement>>();
|
const parsed = new Map<string, Map<number, SVGElement>>();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 334 B |
@@ -1,4 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 220 B |
@@ -1,8 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-13 -13 26 26" width="26" height="26">
|
|
||||||
<!-- A bounding box with its corner handles, as in the asset panel. -->
|
|
||||||
<rect x="-9" y="-9" width="18" height="18" fill="currentColor" fill-opacity="0.25" stroke="currentColor" stroke-width="2" />
|
|
||||||
<rect x="-12" y="-12" width="6" height="6" fill="currentColor" />
|
|
||||||
<rect x="6" y="-12" width="6" height="6" fill="currentColor" />
|
|
||||||
<rect x="-12" y="6" width="6" height="6" fill="currentColor" />
|
|
||||||
<rect x="6" y="6" width="6" height="6" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 560 B |
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 491 B |
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 749 B |
@@ -1,6 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 410 B |
@@ -1,12 +1,16 @@
|
|||||||
/** The fixed pictures of the scene view's symbols, kept as SVG files in `icons/` (#175). */
|
/**
|
||||||
|
* The pictures of the scene view's symbols. They are the asset panel's icons (#176), so a thing
|
||||||
|
* looks the same in the panel and in the scene: white, in a dark outline that keeps it readable on
|
||||||
|
* any ground, and without a plate behind it.
|
||||||
|
*/
|
||||||
import type { Graphics } from 'pixi.js';
|
import type { Graphics } from 'pixi.js';
|
||||||
import { drawSvg, Theme } from '../ui';
|
import { drawSvg, haloSvg, Theme } from '../ui';
|
||||||
import behavior from './icons/behavior.svg?raw';
|
import behavior from '../editor/panels/icons/behavior.svg?raw';
|
||||||
import camera from './icons/camera.svg?raw';
|
import camera from '../editor/panels/icons/camera.svg?raw';
|
||||||
import collider from './icons/collider.svg?raw';
|
import collider from '../editor/panels/icons/collider.svg?raw';
|
||||||
import container from './icons/container.svg?raw';
|
import container from '../editor/panels/icons/container.svg?raw';
|
||||||
import particles from './icons/particles.svg?raw';
|
import particles from '../editor/panels/icons/particles.svg?raw';
|
||||||
import rigidbody from './icons/rigidbody.svg?raw';
|
import rigidbody from '../editor/panels/icons/rigidbody.svg?raw';
|
||||||
|
|
||||||
export type SymbolIcon = 'behavior' | 'camera' | 'collider' | 'container' | 'particles' | 'rigidbody';
|
export type SymbolIcon = 'behavior' | 'camera' | 'collider' | 'container' | 'particles' | 'rigidbody';
|
||||||
|
|
||||||
@@ -19,16 +23,18 @@ export const SYMBOL_SVG: Record<SymbolIcon, string> = {
|
|||||||
rigidbody,
|
rigidbody,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SYMBOL_COLOR: Record<SymbolIcon, number> = {
|
/** The icons are drawn on a grid of this many units, centred on their origin. */
|
||||||
behavior: Theme.colors.behavior,
|
export const SYMBOL_GRID = 16;
|
||||||
camera: Theme.colors.textMuted,
|
/** How far the outline reaches past the icon, in the icon's own units. */
|
||||||
collider: Theme.colors.collider,
|
export const SYMBOL_OUTLINE = 0.75;
|
||||||
container: Theme.colors.container,
|
|
||||||
particles: Theme.colors.particles,
|
|
||||||
rigidbody: Theme.colors.collider,
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Adds the icon to what `node` already draws; `color` for one that changes, like a trigger's. */
|
const outlines = new Map<string, string>();
|
||||||
export function drawSymbolIcon(node: Graphics, icon: SymbolIcon, color = SYMBOL_COLOR[icon]): void {
|
|
||||||
drawSvg(node, SYMBOL_SVG[icon], color);
|
/** Adds the icon, outline first, to what `node` already draws – in the icon's own units. */
|
||||||
|
export function drawSymbolIcon(node: Graphics, icon: SymbolIcon): void {
|
||||||
|
const svg = SYMBOL_SVG[icon];
|
||||||
|
let outline = outlines.get(svg);
|
||||||
|
if (!outline) outlines.set(svg, (outline = haloSvg(svg, SYMBOL_OUTLINE)));
|
||||||
|
drawSvg(node, outline, Theme.colors.symbolOutline);
|
||||||
|
drawSvg(node, svg, Theme.colors.symbol);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Where the scene view's symbols stand. Every symbol wants the position of its object; symbols that
|
||||||
|
* would overlap there – whoever owns them – share the spot instead: they stand on a ring round the
|
||||||
|
* middle of their positions, evenly spaced, the ring just wide enough that neighbours keep `spacing`
|
||||||
|
* between their centres. A ring that reaches into another symbol or ring takes that one in as well,
|
||||||
|
* until nothing overlaps.
|
||||||
|
*/
|
||||||
|
import type { Vec2 } from '../core/model/Properties';
|
||||||
|
|
||||||
|
export interface SymbolAnchor {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SymbolSpot {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
/** Direction from the middle of the ring in radians, null for a symbol standing alone. */
|
||||||
|
angle: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
members: SymbolAnchor[];
|
||||||
|
centre: Vec2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Radius of the ring `count` symbols stand on so neighbours are `spacing` apart. */
|
||||||
|
export function ringRadius(count: number, spacing: number): number {
|
||||||
|
return count <= 1 ? 0 : spacing / (2 * Math.sin(Math.PI / count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far a group reaches from its centre: the ring plus half a symbol. */
|
||||||
|
function reach(group: Group, spacing: number): number {
|
||||||
|
return ringRadius(group.members.length, spacing) + spacing / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centreOf(members: readonly SymbolAnchor[]): Vec2 {
|
||||||
|
const x = members.reduce((sum, m) => sum + m.x, 0) / members.length;
|
||||||
|
const y = members.reduce((sum, m) => sum + m.y, 0) / members.length;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The spot of every anchor, in the order given. A ring starts on the left of its middle and goes
|
||||||
|
* round clockwise on screen (y points down), so two symbols stand left and right of it and keep
|
||||||
|
* the middle itself free.
|
||||||
|
*/
|
||||||
|
export function layoutSymbols(anchors: readonly SymbolAnchor[], spacing: number): Map<string, SymbolSpot> {
|
||||||
|
const order = new Map(anchors.map((anchor, k) => [anchor, k]));
|
||||||
|
let groups: Group[] = anchors.map((a) => ({ members: [a], centre: { x: a.x, y: a.y } }));
|
||||||
|
for (let merged = true; merged;) {
|
||||||
|
merged = false;
|
||||||
|
outer: for (let i = 0; i < groups.length; i++)
|
||||||
|
for (let j = i + 1; j < groups.length; j++) {
|
||||||
|
const a = groups[i]!;
|
||||||
|
const b = groups[j]!;
|
||||||
|
// A hair of slack: two groups that just touch are fine, and rounding must not merge them.
|
||||||
|
const gap = Math.hypot(a.centre.x - b.centre.x, a.centre.y - b.centre.y);
|
||||||
|
if (gap >= reach(a, spacing) + reach(b, spacing) - 1e-6) continue;
|
||||||
|
const members = [...a.members, ...b.members];
|
||||||
|
members.sort((p, q) => order.get(p)! - order.get(q)!);
|
||||||
|
groups = [...groups.slice(0, i), { members, centre: centreOf(members) }, ...groups.slice(i + 1)];
|
||||||
|
groups.splice(j, 1);
|
||||||
|
merged = true;
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const spots = new Map<string, SymbolSpot>();
|
||||||
|
for (const { members, centre } of groups) {
|
||||||
|
if (members.length === 1) {
|
||||||
|
spots.set(members[0]!.id, { x: centre.x, y: centre.y, angle: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const r = ringRadius(members.length, spacing);
|
||||||
|
members.forEach((m, k) => {
|
||||||
|
const angle = Math.PI + (2 * Math.PI * k) / members.length;
|
||||||
|
spots.set(m.id, { x: centre.x + Math.cos(angle) * r, y: centre.y + Math.sin(angle) * r, angle });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return spots;
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ import { Theme } from '../ui';
|
|||||||
import type { DrawItem } from './items';
|
import type { DrawItem } from './items';
|
||||||
import { sliceGeometry, sliceKey } from './nineSlice';
|
import { sliceGeometry, sliceKey } from './nineSlice';
|
||||||
import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders';
|
import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders';
|
||||||
import { drawSymbolIcon } from './symbolIcons';
|
|
||||||
import type { TextureLibrary } from './TextureLibrary';
|
import type { TextureLibrary } from './TextureLibrary';
|
||||||
|
|
||||||
/** Screen-pixel half-width of the grab band along a collider's outline. */
|
/** Screen-pixel half-width of the grab band along a collider's outline. */
|
||||||
@@ -129,16 +128,16 @@ export function drawItem(
|
|||||||
case 'tilemap':
|
case 'tilemap':
|
||||||
node.hitArea = new Rectangle(-size.w / 2, -size.h / 2, size.w, size.h);
|
node.hitArea = new Rectangle(-size.w / 2, -size.h / 2, size.w, size.h);
|
||||||
return;
|
return;
|
||||||
|
// What stands for these is their icon, which the scene view draws and places on its own (see
|
||||||
|
// `layoutSymbols`) – off the object's position when another icon wants the same spot.
|
||||||
case 'camera':
|
case 'camera':
|
||||||
node
|
node
|
||||||
.rect(-size.w / 2, -size.h / 2, size.w, size.h)
|
.rect(-size.w / 2, -size.h / 2, size.w, size.h)
|
||||||
.stroke({ width: 2 / scale, color: Theme.colors.textMuted });
|
.stroke({ width: 2 / scale, color: Theme.colors.textMuted });
|
||||||
drawSymbolIcon(node, 'camera');
|
|
||||||
return;
|
return;
|
||||||
case 'behavior':
|
case 'behavior':
|
||||||
case 'container':
|
case 'container':
|
||||||
case 'rigidbody':
|
case 'rigidbody':
|
||||||
drawSymbolIcon(node, item.type);
|
|
||||||
return;
|
return;
|
||||||
case 'text':
|
case 'text':
|
||||||
node.hitArea = new Rectangle(
|
node.hitArea = new Rectangle(
|
||||||
@@ -170,7 +169,7 @@ export function drawItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The colour of a collider's outline and icon. A polygon that crosses itself collides with nothing,
|
* The colour of a collider's outline. A polygon that crosses itself collides with nothing,
|
||||||
* and says so in red – as does a frame outline without a frame or without a drawn outline (#193).
|
* and says so in red – as does a frame outline without a frame or without a drawn outline (#193).
|
||||||
*/
|
*/
|
||||||
export function colliderColor(shape: ColliderShape, props: PropertyBag): number {
|
export function colliderColor(shape: ColliderShape, props: PropertyBag): number {
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { layoutSymbols, ringRadius } from '../../src/viewport/symbolLayout';
|
||||||
|
import { haloSvg } from '../../src/ui/svg';
|
||||||
|
|
||||||
|
const SPACING = 28;
|
||||||
|
|
||||||
|
function distance(a: { x: number; y: number }, b: { x: number; y: number }): number {
|
||||||
|
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('layoutSymbols', () => {
|
||||||
|
it('leaves a symbol with room around it on its own position', () => {
|
||||||
|
const spots = layoutSymbols(
|
||||||
|
[
|
||||||
|
{ id: 'a', x: 0, y: 0 },
|
||||||
|
{ id: 'b', x: 100, y: 0 },
|
||||||
|
],
|
||||||
|
SPACING,
|
||||||
|
);
|
||||||
|
expect(spots.get('a')).toEqual({ x: 0, y: 0, angle: null });
|
||||||
|
expect(spots.get('b')).toEqual({ x: 100, y: 0, angle: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts two symbols on one spot left and right of it, the middle free', () => {
|
||||||
|
const spots = layoutSymbols(
|
||||||
|
[
|
||||||
|
{ id: 'body', x: 50, y: 20 },
|
||||||
|
{ id: 'collider', x: 50, y: 20 },
|
||||||
|
],
|
||||||
|
SPACING,
|
||||||
|
);
|
||||||
|
const body = spots.get('body')!;
|
||||||
|
const collider = spots.get('collider')!;
|
||||||
|
expect(body.x).toBeCloseTo(36);
|
||||||
|
expect(body.y).toBeCloseTo(20);
|
||||||
|
expect(collider.x).toBeCloseTo(64);
|
||||||
|
expect(collider.y).toBeCloseTo(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spaces any number evenly on a ring round the middle, neighbours a symbol apart', () => {
|
||||||
|
for (const count of [3, 4, 5, 8]) {
|
||||||
|
const anchors = Array.from({ length: count }, (_, i) => ({ id: `s${i}`, x: 10, y: -5 }));
|
||||||
|
const spots = [...layoutSymbols(anchors, SPACING).values()];
|
||||||
|
const r = ringRadius(count, SPACING);
|
||||||
|
for (const spot of spots) expect(distance(spot, { x: 10, y: -5 })).toBeCloseTo(r);
|
||||||
|
for (let i = 0; i < count; i++)
|
||||||
|
expect(distance(spots[i]!, spots[(i + 1) % count]!)).toBeCloseTo(SPACING);
|
||||||
|
const mean = {
|
||||||
|
x: spots.reduce((s, p) => s + p.x, 0) / count,
|
||||||
|
y: spots.reduce((s, p) => s + p.y, 0) / count,
|
||||||
|
};
|
||||||
|
expect(distance(mean, { x: 10, y: -5 })).toBeCloseTo(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares a spot whoever owns the symbols, and rings round the middle of close positions', () => {
|
||||||
|
const spots = layoutSymbols(
|
||||||
|
[
|
||||||
|
{ id: 'a', x: 0, y: 0 },
|
||||||
|
{ id: 'b', x: 10, y: 0 },
|
||||||
|
],
|
||||||
|
SPACING,
|
||||||
|
);
|
||||||
|
expect(spots.get('a')!.x).toBeCloseTo(-9);
|
||||||
|
expect(spots.get('b')!.x).toBeCloseTo(19);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes in a symbol the ring would cover, until nothing overlaps', () => {
|
||||||
|
// Two on the origin reach out to ±28 – the third at 40 would sit on the right one.
|
||||||
|
const anchors = [
|
||||||
|
{ id: 'a', x: 0, y: 0 },
|
||||||
|
{ id: 'b', x: 0, y: 0 },
|
||||||
|
{ id: 'c', x: 40, y: 0 },
|
||||||
|
];
|
||||||
|
const spots = [...layoutSymbols(anchors, SPACING).values()];
|
||||||
|
for (let i = 0; i < spots.length; i++)
|
||||||
|
for (let j = i + 1; j < spots.length; j++)
|
||||||
|
expect(distance(spots[i]!, spots[j]!)).toBeGreaterThanOrEqual(SPACING - 1e-9);
|
||||||
|
expect(spots.every((s) => s.angle !== null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places the same input the same way every time', () => {
|
||||||
|
const anchors = [
|
||||||
|
{ id: 'a', x: 0, y: 0 },
|
||||||
|
{ id: 'b', x: 3, y: 1 },
|
||||||
|
{ id: 'c', x: -2, y: 4 },
|
||||||
|
];
|
||||||
|
expect([...layoutSymbols(anchors, SPACING)]).toEqual([...layoutSymbols(anchors, SPACING)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('haloSvg', () => {
|
||||||
|
it('widens every shape by its outline in currentColor, keeping hollow shapes hollow', () => {
|
||||||
|
const svg = [
|
||||||
|
'<svg viewBox="-8 -8 16 16">',
|
||||||
|
' <rect x="-5" y="-5" width="10" height="10" fill="none" stroke="currentColor" stroke-width="1.5" />',
|
||||||
|
' <circle cx="0" cy="0" r="2" fill="currentColor" />',
|
||||||
|
'</svg>',
|
||||||
|
].join('\n');
|
||||||
|
const halo = haloSvg(svg, 1.25);
|
||||||
|
expect(halo).toContain(
|
||||||
|
'<rect x="-5" y="-5" width="10" height="10" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="square" />',
|
||||||
|
);
|
||||||
|
expect(halo).toContain(
|
||||||
|
'<circle cx="0" cy="0" r="2" fill="currentColor" stroke="currentColor" stroke-width="2.5" stroke-linecap="square" />',
|
||||||
|
);
|
||||||
|
expect(halo.startsWith('<svg viewBox="-8 -8 16 16">')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outlines the open ends of a stroke too, keeping a round cap round', () => {
|
||||||
|
const svg = [
|
||||||
|
'<svg viewBox="-8 -8 16 16">',
|
||||||
|
' <path d="M0 -3 V3" fill="none" stroke="currentColor" stroke-width="1.5" />',
|
||||||
|
' <path d="M-3 0 H3" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" />',
|
||||||
|
'</svg>',
|
||||||
|
].join('\n');
|
||||||
|
const halo = haloSvg(svg, 1);
|
||||||
|
expect(halo).toContain(
|
||||||
|
'<path d="M0 -3 V3" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="square" />',
|
||||||
|
);
|
||||||
|
expect(halo).toContain(
|
||||||
|
'<path d="M-3 0 H3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" />',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -130,8 +130,9 @@ export async function inspector(t) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
|
// 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
|
||||||
// on the origin, so compute its screen position from the viewport centre.
|
// on the origin, so compute its screen position from the viewport centre. Pressed right of
|
||||||
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
|
// its middle: the crate wears a collider, whose icon stands on the middle.
|
||||||
|
await drag(center.x - 120, center.y, center.x - 20, center.y - 70);
|
||||||
const o1Now = (await object('o1')).position;
|
const o1Now = (await object('o1')).position;
|
||||||
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
|
check('viewport drag moved the red crate', o1Now.x > -150, JSON.stringify(o1Now));
|
||||||
await shot('06-dragged');
|
await shot('06-dragged');
|
||||||
|
|||||||
@@ -51,21 +51,11 @@ export async function drawing(t) {
|
|||||||
await undo();
|
await undo();
|
||||||
check('undo restores z', (await object('o1')).position.z === 0, `z=${(await object('o1')).position.z}`);
|
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
|
// 5c. The symbols are the asset panel's icons, white in a dark outline and without a plate
|
||||||
// solid colour of the theme. Offsets are in world units from the symbol's centre.
|
// behind them: one of each in a lane of its own, the pixels read on a grid over each icon
|
||||||
|
// (±12 world units round its centre). Each must show white and the outline, and none of the
|
||||||
|
// colours the symbols had before.
|
||||||
const PLACED = { 's-beh': 'mover', 's-box': 's-box-a', 's-rb': 's-rb-a', 's-cam': 'cam', 's-pt': 's-pt-a' };
|
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);
|
const ids = Object.keys(PLACED);
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const p = __gm.state.project;
|
const p = __gm.state.project;
|
||||||
@@ -83,18 +73,31 @@ export async function drawing(t) {
|
|||||||
});
|
});
|
||||||
__gm.state.select(null);
|
__gm.state.select(null);
|
||||||
__gm.lookAt(0, ${LANES.symbols});`);
|
__gm.lookAt(0, ${LANES.symbols});`);
|
||||||
const wrong = await json(`(() => {
|
const looks = await json(`(() => {
|
||||||
const zoom = __gm.objectRect('s-beh').w / 28;
|
const zoom = __gm.objectRect('s-beh').w / 28;
|
||||||
return ${JSON.stringify(SPOTS)}.flatMap(([id, dx, dy, want]) => {
|
const old = ['#b066ff', '#3fb8c9', '#4fd18b', '#c26a1f'];
|
||||||
const r = __gm.objectRect(id);
|
const rgb = (c) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16));
|
||||||
const got = __gm.pixelAt(Math.floor(r.x + dx * zoom), Math.floor(r.y + dy * zoom));
|
const near = (a, b, max) => Math.hypot(...rgb(a).map((v, i) => v - rgb(b)[i])) < max;
|
||||||
return got === want ? [] : [id + ' ' + dx + ',' + dy + ': ' + got + ' != ' + want];
|
return ${JSON.stringify(ids)}.map((id) => {
|
||||||
|
const icon = __gm.sceneIcon(id);
|
||||||
|
const seen = { id, white: 0, outline: 0, old: [] };
|
||||||
|
for (let dx = -12; dx <= 12; dx += 1.5)
|
||||||
|
for (let dy = -12; dy <= 12; dy += 1.5) {
|
||||||
|
const got = __gm.pixelAt(Math.floor(icon.screen.x + dx * zoom), Math.floor(icon.screen.y + dy * zoom));
|
||||||
|
if (got === '#ffffff') seen.white++;
|
||||||
|
// The outline is thin, so at this zoom it is mostly an edge pixel blended into the
|
||||||
|
// background (#111216 and the like), rarely the exact #0b0c0f.
|
||||||
|
else if (near(got, '#0b0c0f', 20)) seen.outline++;
|
||||||
|
else if (old.includes(got)) seen.old.push(got);
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
});
|
});
|
||||||
})()`);
|
})()`);
|
||||||
|
const wrong = looks.filter((s) => s.white < 4 || s.outline < 4 || s.old.length > 0);
|
||||||
check(
|
check(
|
||||||
'every symbol draws its SVG in the theme colour',
|
'every symbol draws its icon white in a dark outline, in none of the old colours',
|
||||||
wrong.length === 0,
|
wrong.length === 0,
|
||||||
wrong.join('; ') || `${SPOTS.length} spots`,
|
JSON.stringify(wrong.length ? wrong : looks.map((s) => `${s.id} ${s.white}/${s.outline}`)),
|
||||||
);
|
);
|
||||||
await shot('15-symbols');
|
await shot('15-symbols');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
|
|||||||
@@ -70,9 +70,11 @@ export async function containerTab(t) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 16c. Clicking a template object selects it against the container as its holder – the same
|
// 16c. Clicking a template object selects it against the container as its holder – the same
|
||||||
// id set as the scene would give, but a different holder.
|
// id set as the scene would give, but a different holder. Pressed right of its middle: the
|
||||||
|
// crate wears a collider, whose icon stands on the middle.
|
||||||
const root = template.find((o) => o.parentId === '');
|
const root = template.find((o) => o.parentId === '');
|
||||||
const at = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
|
const rect = await json(`__gm.container.objectRect(${JSON.stringify(root.id)})`);
|
||||||
|
const at = { x: rect.x + rect.w * 0.3, y: rect.y };
|
||||||
await click(at.x, at.y);
|
await click(at.x, at.y);
|
||||||
let s = await state();
|
let s = await state();
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -515,13 +515,15 @@ export async function particles(t) {
|
|||||||
|
|
||||||
// 31n. (#89) The ring is hollow on purpose – the mark may not hide the effect it marks. Scanned
|
// 31n. (#89) The ring is hollow on purpose – the mark may not hide the effect it marks. Scanned
|
||||||
// outward from the source's own point, because a 2-unit stroke at an exact radius would be a
|
// outward from the source's own point, because a 2-unit stroke at an exact radius would be a
|
||||||
// test of rounding rather than of drawing.
|
// test of rounding rather than of drawing. The ring is white like every symbol's icon, with
|
||||||
const ICON_COLOUR = '#c26a1f';
|
// a radius of 3.94 world units: its thin stroke starts at about 3.1, so the scan takes the
|
||||||
|
// first white from 2.5 units out, and the dark outline leaves the middle two units free.
|
||||||
|
const ICON_COLOUR = '#ffffff';
|
||||||
const unit = (await json(`__gm.objectRect(${JSON.stringify(SOURCE)})`)).w / 24;
|
const unit = (await json(`__gm.objectRect(${JSON.stringify(SOURCE)})`)).w / 24;
|
||||||
const row = [];
|
const row = [];
|
||||||
for (let dx = 0; dx <= Math.ceil(19 * unit); dx++)
|
for (let dx = 0; dx <= Math.ceil(19 * unit); dx++)
|
||||||
row.push(await json(`__gm.pixelAt(${Math.round(spotOn.x + dx)}, ${Math.round(spotOn.y)})`));
|
row.push(await json(`__gm.pixelAt(${Math.round(spotOn.x + dx)}, ${Math.round(spotOn.y)})`));
|
||||||
const ringAt = row.findIndex((c, dx) => dx > 4 * unit && colourDistance(c, ICON_COLOUR) < 12);
|
const ringAt = row.findIndex((c, dx) => dx >= 2.5 * unit && colourDistance(c, ICON_COLOUR) < 12);
|
||||||
check(
|
check(
|
||||||
'the icon rings the source and leaves its middle to the particles',
|
'the icon rings the source and leaves its middle to the particles',
|
||||||
ringAt > 0 && colourDistance(row[0], ICON_COLOUR) > 60 && row[0] !== '#22252c',
|
ringAt > 0 && colourDistance(row[0], ICON_COLOUR) > 60 && row[0] !== '#22252c',
|
||||||
|
|||||||
@@ -154,55 +154,58 @@ export async function physics(t) {
|
|||||||
await evaluate(`__gm.lookAt(0, ${Y + 100})`);
|
await evaluate(`__gm.lookAt(0, ${Y + 100})`);
|
||||||
await settle();
|
await settle();
|
||||||
await shot('41-rigidbody');
|
await shot('41-rigidbody');
|
||||||
// A collider has an icon like every other symbol. It stands left of the collider's centre, so the
|
// Every symbol has an icon and wants its object's position. One with room stands there; icons
|
||||||
// middle of its sprite stays free, and so does a rigidbody's icon on the same spot.
|
// that want the same spot share it on a ring round it, whoever owns them – two stand left and
|
||||||
|
// right of it, in hierarchy order: the seeded collider first, the rigidbody added above second.
|
||||||
const icons = {
|
const icons = {
|
||||||
ground: await json(`__gm.sceneIcon('phys-ground-col')`),
|
ground: await json(`__gm.sceneIcon('phys-ground-col')`),
|
||||||
crate: await json(`__gm.sceneIcon('phys-crate-col')`),
|
crate: await json(`__gm.sceneIcon('phys-crate-col')`),
|
||||||
|
crateBody: await json(`__gm.sceneIcon('${crateBody?.id}')`),
|
||||||
ball: await json(`__gm.sceneIcon('phys-ball-col')`),
|
ball: await json(`__gm.sceneIcon('phys-ball-col')`),
|
||||||
|
ballBody: await json(`__gm.sceneIcon('${ballBody?.id}')`),
|
||||||
};
|
};
|
||||||
|
const near = (icon, x, y) =>
|
||||||
|
icon?.visible === true && Math.abs(icon.x - x) < 0.01 && Math.abs(icon.y - y) < 0.01;
|
||||||
check(
|
check(
|
||||||
'a collider without a rigidbody shows its icon left of its centre',
|
'a symbol with room around it stands on its object, in the overlay',
|
||||||
icons.ground?.visible === true &&
|
near(icons.ground, 0, Y + 200) && icons.ground.layer === 'overlay',
|
||||||
icons.ground.layer === 'overlay' &&
|
|
||||||
icons.ground.x === -28 &&
|
|
||||||
icons.ground.y === Y + 200 &&
|
|
||||||
icons.ground.color !== null,
|
|
||||||
JSON.stringify(icons.ground),
|
JSON.stringify(icons.ground),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
"a collider's icon steps aside from the rigidbody on the same body, in the same colour",
|
'icons on one spot stand left and right of it, a symbol apart',
|
||||||
[
|
near(icons.crate, -110.5, Y) &&
|
||||||
[icons.crate, -100],
|
near(icons.crateBody, -89.5, Y) &&
|
||||||
[icons.ball, 100],
|
near(icons.ball, 89.5, Y) &&
|
||||||
].every(([icon, x]) => icon?.visible === true && icon.x === x - 28 && icon.y === Y) &&
|
near(icons.ballBody, 110.5, Y),
|
||||||
icons.crate.color === icons.ground.color,
|
|
||||||
JSON.stringify(icons),
|
JSON.stringify(icons),
|
||||||
);
|
);
|
||||||
// The icon is clicked like the outline: it selects the collider, even over its sprite. On the
|
// An icon is clicked like its object: it selects it, even over a sprite. Beside the icon the
|
||||||
// middle of the sprite, beside the icon, the sprite is picked as before.
|
// sprite under it is picked as before.
|
||||||
const selectedId = async () => {
|
const selectedId = async () => {
|
||||||
const s = await state();
|
const s = await state();
|
||||||
return s.selection?.kind === 'object' ? s.selection.objectId : null;
|
return s.selection?.kind === 'object' ? s.selection.objectId : null;
|
||||||
};
|
};
|
||||||
|
const zoom = (icons.crateBody.screen.x - icons.crate.screen.x) / 21;
|
||||||
const picks = {};
|
const picks = {};
|
||||||
for (const [name, id, dx] of [
|
for (const [name, id, dx] of [
|
||||||
['groundIcon', 'phys-ground-col', 0],
|
['groundIcon', 'phys-ground-col', 0],
|
||||||
['crateIcon', 'phys-crate-col', 0],
|
['crateIcon', 'phys-crate-col', 0],
|
||||||
['groundMiddle', 'phys-ground', null],
|
['crateBodyIcon', crateBody?.id, 0],
|
||||||
|
['groundBeside', 'phys-ground-col', 40],
|
||||||
]) {
|
]) {
|
||||||
await select(null);
|
await select(null);
|
||||||
const at = await json(dx === null ? `__gm.objectScreen('${id}')` : `__gm.sceneIcon('${id}')?.screen`);
|
const at = await json(`__gm.sceneIcon('${id}')?.screen`);
|
||||||
await click(at.x + (dx ?? 0), at.y);
|
await click(at.x + dx * zoom, at.y);
|
||||||
picks[name] = { selected: await selectedId() };
|
picks[name] = { selected: await selectedId() };
|
||||||
}
|
}
|
||||||
check(
|
check(
|
||||||
"a click on a collider's icon selects the collider, on the sprite's middle the sprite",
|
'a click on an icon selects its object, beside it the sprite below',
|
||||||
icons.ground.clickable === true &&
|
icons.ground.clickable === true &&
|
||||||
picks.groundIcon.selected === 'phys-ground-col' &&
|
picks.groundIcon.selected === 'phys-ground-col' &&
|
||||||
picks.crateIcon.selected === 'phys-crate-col' &&
|
picks.crateIcon.selected === 'phys-crate-col' &&
|
||||||
picks.groundMiddle.selected === 'phys-ground',
|
picks.crateBodyIcon.selected === crateBody?.id &&
|
||||||
JSON.stringify(picks),
|
picks.groundBeside.selected === 'phys-ground',
|
||||||
|
JSON.stringify({ zoom, picks }),
|
||||||
);
|
);
|
||||||
|
|
||||||
await selectObject('phys-ball-col');
|
await selectObject('phys-ball-col');
|
||||||
|
|||||||