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>
This commit is contained in:
1 parent
e3d47ca9c1
commit
cb32b4bc23
22 files changed
+414
-176
No files matched your search
@@ -22,6 +22,11 @@ export class ContainerEditor extends AssetTabEditor<ContainerAsset, ContainerCan
|
||||
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 {
|
||||
return this.state.selectedContainerId ?? '';
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ export function sceneHooks({ state, editor }: HookContext) {
|
||||
rect: () => containerEditor.canvasRect(),
|
||||
/** Where a template object is drawn, so a check can click or sample it. */
|
||||
objectScreen: (id: string) => containerEditor.objectScreen(id),
|
||||
objectRect: (id: string) => containerEditor.objectRect(id),
|
||||
dropPreview: () => containerEditor.dropInfo(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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. -->
|
||||
<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" />
|
||||
</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
|
||||
* 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 { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { hasSceneIcon } from '../../core/model/AssetTypes';
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
apply as applyPoint,
|
||||
applyVector,
|
||||
type Extent,
|
||||
IDENTITY,
|
||||
invert,
|
||||
linearOf,
|
||||
type Mat,
|
||||
translation,
|
||||
} from '../../core/model/matrix';
|
||||
import { placementRejection } from '../../core/model/placement';
|
||||
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 { openObjectMenu } from '../objectMenu';
|
||||
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
|
||||
import { colliderShape } from '../../core/properties/collider';
|
||||
import { colliderColor, dashedLine } from '../../viewport/symbols';
|
||||
import { drawSymbolIcon } from '../../viewport/symbolIcons';
|
||||
import { dashedLine } from '../../viewport/symbols';
|
||||
import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons';
|
||||
import { layoutSymbols, type SymbolSpot } from '../../viewport/symbolLayout';
|
||||
import { screenToWorld, type View, worldToScreen } from '../../viewport/view';
|
||||
import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World';
|
||||
import { FreeViewport } from './FreeViewport';
|
||||
@@ -82,10 +82,12 @@ export interface GizmoInfo {
|
||||
|
||||
const OFF_GIZMO_ALPHA = 0.45;
|
||||
|
||||
/** World units a collider's icon stands left of the collider's centre. */
|
||||
const ICON_CLEARANCE = 28;
|
||||
/** Half the side of a collider icon's clickable square, in world units – the SVG's view box. */
|
||||
const ICON_HALF = 13;
|
||||
/** Side of a symbol's icon in world units: it grows and shrinks with the zoom like the scene. */
|
||||
const SYMBOL_SIZE = 18;
|
||||
/** World units between the centres of icons that share a spot – an icon and its outline, and a hair. */
|
||||
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. */
|
||||
const LINK_COLORS: Partial<Record<DrawItem['type'], number>> = {
|
||||
@@ -100,7 +102,11 @@ export class SceneViewport extends FreeViewport {
|
||||
private readonly gizmos = new Graphics();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
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 host: SceneViewHost = {
|
||||
state: this.state,
|
||||
@@ -132,6 +138,8 @@ export class SceneViewport extends FreeViewport {
|
||||
this.world.addChildAt(this.links, 1);
|
||||
this.content.addChild(this.gizmos);
|
||||
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('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
@@ -436,7 +444,7 @@ export class SceneViewport extends FreeViewport {
|
||||
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 {
|
||||
const icon = this.icons.get(id);
|
||||
if (!icon?.visible || icon.eventMode === 'none' || !icon.hitArea) return false;
|
||||
@@ -484,8 +492,9 @@ export class SceneViewport extends FreeViewport {
|
||||
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 {
|
||||
this.placeIcons(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.solids.sync(item, onScreen, scale);
|
||||
this.syncLabel(item, onScreen, scale, extent);
|
||||
this.syncLabel(item, onScreen, scale);
|
||||
}
|
||||
|
||||
protected override disposeDecorations(id: string): void {
|
||||
@@ -532,7 +546,6 @@ export class SceneViewport extends FreeViewport {
|
||||
this.labels.delete(id);
|
||||
this.icons.get(id)?.destroy();
|
||||
this.icons.delete(id);
|
||||
this.iconColors.delete(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));
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon of a particle source or collider, in world units, the name beside it and whether it
|
||||
* hangs off its owner (test hook).
|
||||
*/
|
||||
/** The icon of a symbol in world units, the name beside it and whether it hangs off its owner (test hook). */
|
||||
iconInfo(id: string): {
|
||||
layer: string;
|
||||
visible: boolean;
|
||||
@@ -552,7 +562,6 @@ export class SceneViewport extends FreeViewport {
|
||||
link: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
color: number | null;
|
||||
screen: Vec2;
|
||||
clickable: boolean;
|
||||
} | null {
|
||||
@@ -561,31 +570,27 @@ export class SceneViewport extends FreeViewport {
|
||||
const label = this.labels.get(id);
|
||||
const screen = icon.getGlobalPosition();
|
||||
return {
|
||||
layer: icon.parent === this.overlay ? 'overlay' : 'objects',
|
||||
layer: icon.parent === this.iconLayer ? 'overlay' : 'objects',
|
||||
visible: icon.visible,
|
||||
label: label?.visible ? label.text : '',
|
||||
link: this.lastLinks.includes(id),
|
||||
x: icon.position.x,
|
||||
y: icon.position.y,
|
||||
color: this.iconColors.get(id) ?? null,
|
||||
screen: { x: screen.x, y: screen.y },
|
||||
clickable: icon.eventMode !== 'none',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A source draws only its particles, so without a mark of its own an empty one is invisible and
|
||||
* cannot be found (#89). The mark is a decoration rather than a symbol: `GameViewport` never syncs
|
||||
* decorations, so it is editor-only without the source leaving the running game. A collider gets
|
||||
* one too, like every other symbol: a thin outline around a sprite is easily missed. Its icon is
|
||||
* clicked like the outline – a source's square already covers its own – and stands left of the
|
||||
* 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).
|
||||
* Every symbol shows the asset panel's icon of its type, above everything in the scene, and is
|
||||
* picked by it: a behavior, container or rigidbody is nothing else, and a thin collider outline
|
||||
* or an empty particle source is easily missed (#89). The icons are decorations, so
|
||||
* `GameViewport` – which never syncs any – leaves them out, and a source's particles still reach
|
||||
* the running game. Where an icon stands is decided for all of them at once (`placeIcons`).
|
||||
*/
|
||||
private syncIcon(item: DrawItem, show: boolean): void {
|
||||
let icon = this.icons.get(item.id);
|
||||
const type = item.type === 'particles' || item.type === 'collider' ? item.type : null;
|
||||
if (!show || !type) {
|
||||
if (!show || !hasSceneIcon(item.type)) {
|
||||
if (icon) {
|
||||
icon.visible = false;
|
||||
icon.eventMode = 'none';
|
||||
@@ -594,29 +599,41 @@ export class SceneViewport extends FreeViewport {
|
||||
}
|
||||
if (!icon) {
|
||||
icon = new Graphics();
|
||||
icon.eventMode = 'none';
|
||||
if (type === 'collider') {
|
||||
icon.hitArea = new Rectangle(-ICON_HALF, -ICON_HALF, ICON_HALF * 2, ICON_HALF * 2);
|
||||
icon.cursor = 'move';
|
||||
icon.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
||||
}
|
||||
drawSymbolIcon(icon, item.type as SymbolIcon);
|
||||
icon.scale.set(SYMBOL_SIZE / SYMBOL_GRID);
|
||||
const half = SYMBOL_GRID / 2 + SYMBOL_OUTLINE;
|
||||
icon.hitArea = new Rectangle(-half, -half, half * 2, half * 2);
|
||||
icon.cursor = 'move';
|
||||
icon.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
||||
this.icons.set(item.id, icon);
|
||||
this.overlay.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);
|
||||
this.iconLayer.addChild(icon);
|
||||
}
|
||||
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 {
|
||||
@@ -649,13 +666,16 @@ export class SceneViewport extends FreeViewport {
|
||||
if (!parent || !parent.enabled) continue;
|
||||
this.lastLinks.push(item.id);
|
||||
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.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);
|
||||
if (!show || !hasSceneIcon(item.type)) {
|
||||
if (label) label.visible = false;
|
||||
@@ -665,14 +685,11 @@ export class SceneViewport extends FreeViewport {
|
||||
label = new Text({ text: '', style: textStyle('viewportLabelMuted') });
|
||||
label.eventMode = 'none';
|
||||
this.labels.set(item.id, label);
|
||||
this.overlay.addChild(label);
|
||||
this.labelLayer.addChild(label);
|
||||
}
|
||||
label.visible = true;
|
||||
label.text = item.name;
|
||||
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) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
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(
|
||||
footprintCorners(item.type, item.props, item.m, 2 / view.scale, item.frame),
|
||||
footprintCorners(item.type, item.props, m, 2 / view.scale, item.frame),
|
||||
view,
|
||||
);
|
||||
// A switched-off object draws nothing, so its outline is the only way to see where it
|
||||
|
||||
Reference in new issue
Block a user