diff --git a/README.md b/README.md index f1c811b9..0949f41a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,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, 821 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 824 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 diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts index 3cb3333a..a6262339 100644 --- a/src/core/model/AssetTypes.ts +++ b/src/core/model/AssetTypes.ts @@ -64,6 +64,15 @@ export function isSymbolType(type: AssetType): boolean { return type === 'camera' || type === 'behavior' || type === 'collider' || type === 'container'; } +/** + * Types the scene view marks with an icon of their own (#89). A source is *not* a symbol: its + * particles are scene content that has to survive into the running game, and `isSymbolType` is what + * throws a thing out of the game and out of the draw order. Only the icon is editor decoration. + */ +export function hasSceneIcon(type: AssetType): boolean { + return isSymbolType(type) || type === 'particles'; +} + export function hasColorSwatch(type: AssetType): boolean { return type === 'sprite' || type === 'text'; } diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 07458816..3ad7659b 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -3,7 +3,7 @@ * "Add collider", so all three agree on how big a thing is – and, through the object's transform, on * where its corners are. */ -import { type AssetType, isSymbolType } from '../model/AssetTypes'; +import { type AssetType, hasSceneIcon } from '../model/AssetTypes'; import { apply, boxCorners, @@ -71,11 +71,13 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h /** * How an item of this type is drawn: with its whole transform, or – for the icons of behaviors, - * containers and the camera frame – upright and unscaled at its position, since an icon that turns - * with its parent would only be harder to read. Colliders are symbols too but follow their body. + * containers, particle sources and the camera frame – upright and unscaled at its position, since an + * icon that turns with its parent would only be harder to read. A source's own frame still goes into + * every particle; what stays upright is the square that can be grabbed, so it covers its icon. + * Colliders are symbols too but follow their body. */ export function placementOf(type: AssetType, m: Mat): Mat { - return isSymbolType(type) && type !== 'collider' ? translation(m.tx, m.ty) : m; + return hasSceneIcon(type) && type !== 'collider' ? translation(m.tx, m.ty) : m; } function anchorOffset(type: AssetType, props: PropertyBag, w: number): number { diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index aab61008..a20ae23f 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -233,6 +233,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E /** Selection outlines in the Scene tab: a switched-off object is invisible but still framed. */ gizmos: () => viewport.gizmoInfo(), layerOf: (id: string) => viewport.layerOf(id), + /** The icon a particle source wears in the Scene tab, with its name and its owner link. */ + sceneIcon: (id: string) => viewport.iconInfo(id), drawOrder: () => viewport.drawOrder(), objectScreen: (id: string) => viewport.objectScreen(id), objectRect: (id: string) => viewport.objectRect(id), diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index a43d7cf9..9e2dea62 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -6,7 +6,7 @@ import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; import { oneStep } from '../../core/commands/CompoundCommand'; import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; -import { isSymbolType } from '../../core/model/AssetTypes'; +import { hasSceneIcon } from '../../core/model/AssetTypes'; import { findObject, subtreeRoots } from '../../core/model/hierarchy'; import { objectRef, resolvedHolder } from '../../core/model/instance'; import { applyVector, type Extent, invert, linearOf, type Mat } from '../../core/model/matrix'; @@ -29,7 +29,7 @@ import { zoomFactor, } from '../../ui'; import { collectHolderItems, type DrawItem } from '../../viewport/items'; -import { dashedLine } from '../../viewport/symbols'; +import { dashedLine, drawSourceIcon } from '../../viewport/symbols'; import { freeView, screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { type Frame, WorldViewport } from './WorldViewport'; @@ -75,11 +75,18 @@ export interface GizmoInfo { const OFF_GIZMO_ALPHA = 0.45; +/** Owner links in the colour of the icon they leave; anything else in the muted default. */ +const LINK_COLORS: Partial> = { + behavior: Theme.colors.behavior, + particles: Theme.colors.particles, +}; + export class SceneViewport extends WorldViewport { private readonly grid = new Graphics(); private readonly links = new Graphics(); private readonly gizmos = new Graphics(); private readonly labels = new Map(); + private readonly icons = new Map(); /** World point at the viewport centre, and zoom. */ private view = { x: 0, y: 0, zoom: 1 }; private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null; @@ -87,6 +94,8 @@ export class SceneViewport extends WorldViewport { private marquee: Marquee | null = null; private dropPreview: DropPreview | null = null; private lastGizmos: GizmoInfo[] = []; + /** Ids that got an owner link this frame (test hook). */ + private lastLinks: string[] = []; constructor(state: EditorState) { super(state); @@ -382,12 +391,50 @@ export class SceneViewport extends WorldViewport { } protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, extent: Extent): void { + this.syncIcon(item, onScreen); this.syncLabel(item, onScreen, scale, extent); } protected override disposeDecorations(id: string): void { this.labels.get(id)?.destroy(); this.labels.delete(id); + this.icons.get(id)?.destroy(); + this.icons.delete(id); + } + + /** The icon of a particle source, the name beside it and whether it hangs off its owner (test hook). */ + iconInfo(id: string): { layer: string; visible: boolean; label: string; link: boolean } | null { + const icon = this.icons.get(id); + if (!icon) return null; + const label = this.labels.get(id); + return { + layer: icon.parent === this.overlay ? 'overlay' : 'objects', + visible: icon.visible, + label: label?.visible ? label.text : '', + link: this.lastLinks.includes(id), + }; + } + + /** + * 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. + */ + private syncIcon(item: DrawItem, show: boolean): void { + let icon = this.icons.get(item.id); + if (!show || item.type !== 'particles') { + if (icon) icon.visible = false; + return; + } + if (!icon) { + icon = new Graphics(); + icon.eventMode = 'none'; + drawSourceIcon(icon); + this.icons.set(item.id, icon); + this.overlay.addChild(icon); + } + icon.visible = true; + icon.position.set(item.x, item.y); } private syncGrid(): void { @@ -412,12 +459,14 @@ export class SceneViewport extends WorldViewport { */ private drawParentLinks(items: DrawItem[], scale: number): void { this.links.clear(); + this.lastLinks = []; const byId = new Map(items.map((i) => [i.id, i])); for (const item of items) { - if (!isSymbolType(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue; + if (!hasSceneIcon(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue; const parent = byId.get(item.parentId); if (!parent || !parent.enabled) continue; - const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted; + 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); this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 }); this.links.circle(parent.x, parent.y, 3 / scale).fill(color); @@ -426,7 +475,7 @@ export class SceneViewport extends WorldViewport { private syncLabel(item: DrawItem, show: boolean, scale: number, extent: Extent): void { let label = this.labels.get(item.id); - if (!show || !isSymbolType(item.type)) { + if (!show || !hasSceneIcon(item.type)) { if (label) label.visible = false; return; } diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 0b0f867e..bba1d2c0 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -41,6 +41,8 @@ export const Theme = { collider: 0x4fd18b, trigger: 0xf2c84b, container: 0x3fb8c9, + /** Particle source icon and owner links; the same orange the graph gives the category. */ + particles: 0xc26a1f, marquee: 0x4c8dff, /** A value deviating from what it follows: an override on a placed object, a non-default on an asset. */ override: 0xf2c84b, diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 1e710938..03c0cb4d 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -1,7 +1,13 @@ /** 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, placementOf, textAnchor } from '../core/properties/bounds'; +import { + BEHAVIOR_SYMBOL, + CONTAINER_SYMBOL, + PARTICLES_SYMBOL, + placementOf, + textAnchor, +} from '../core/properties/bounds'; import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider'; import { SHEET_EDGE } from '../core/shader/nodes'; import { Theme } from '../ui'; @@ -149,6 +155,29 @@ 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); + } +} + +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; + /** Hit area covering only the outline band, in local coordinates and world units. */ function colliderOutlineHitArea( shape: ColliderShape, diff --git a/tests/core/transform.test.ts b/tests/core/transform.test.ts index d8a728ec..34844311 100644 --- a/tests/core/transform.test.ts +++ b/tests/core/transform.test.ts @@ -7,6 +7,7 @@ import { } from '../../src/core/commands/sceneObjectCommands'; import { worldPositionOf, worldTransformOf } from '../../src/core/graph'; import type { ContainerAsset } from '../../src/core/model/Asset'; +import { hasSceneIcon, isSymbolType } from '../../src/core/model/AssetTypes'; import { apply, decompose, @@ -180,5 +181,23 @@ describe('footprint under a transform', () => { // A behavior's diamond keeps its size and stays upright: the frame does not scale by 2. const symbol = footprintExtent('behavior', {}, m); expect(symbol.right - symbol.left).toBe(28); + // Since a source wears an icon of its own (#89), the square that can be grabbed covers it + // instead of growing with the source's scale. + const source = footprintExtent('particles', {}, m); + expect(source.right - source.left).toBe(24); + expect(footprintContains('particles', {}, m, { x: 111, y: 100 })).toBe(true); + expect(footprintContains('particles', {}, m, { x: 121, y: 100 })).toBe(false); + }); + + it('a source wears an icon without becoming a symbol', () => { + expect(hasSceneIcon('particles')).toBe(true); + // The decisive one: `isSymbolType` does not mean "has an icon", it means "editor decoration". + // Were a source one, `collectWorldItems` and the Game tab would both drop it and the effect + // would never be seen outside the Scene tab. + expect(isSymbolType('particles')).toBe(false); + for (const type of ['camera', 'behavior', 'collider', 'container'] as const) + expect(hasSceneIcon(type)).toBe(true); + expect(hasSceneIcon('sprite')).toBe(false); + expect(hasSceneIcon('text')).toBe(false); }); }); diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index 46181268..4a272921 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -1,3 +1,4 @@ +import { colourDistance } from '../lib/harness.mjs'; import { LANES } from './lib/scaffold.mjs'; /** @@ -517,7 +518,58 @@ export async function particles(t) { ); await shot('31-particles-scene'); - // 31m. The graph tab draws no Pixi complaint of its own. + // 31m. (#89) A source draws only its particles, so an empty one would be invisible: it wears an + // icon of its own, in the overlay above the objects, with its name beside it. + const icon = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); + check( + 'the source wears an icon in the overlay, named beside it', + icon?.layer === 'overlay' && icon.visible === true && icon.label === 'Fountain', + JSON.stringify(icon), + ); + + // 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 + // test of rounding rather than of drawing. + const ICON_COLOUR = '#c26a1f'; + const unit = (await json(`__gm.objectRect(${JSON.stringify(SOURCE)})`)).w / 24; + const row = []; + 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)})`)); + const ringAt = row.findIndex((c, dx) => dx > 4 * unit && colourDistance(c, ICON_COLOUR) < 12); + check( + 'the icon rings the source and leaves its middle to the particles', + ringAt > 0 && colourDistance(row[0], ICON_COLOUR) > 60 && row[0] !== '#22252c', + JSON.stringify({ centre: row[0], unit, ringAt, ring: row[ringAt] }), + ); + + // 31o. (#89) Parented, it hangs off its owner by a dashed line like a behavior does – otherwise a + // source that moves with something else is a mark in mid-air. + await mutate(` + const scene = __gm.state.activeScene; + const anchor = structuredClone(scene.objects[0]); + Object.assign(anchor, { id: 'pt-anchor', name: 'Anchor', parentId: '', + position: { x: 0, y: ${LANE}, z: 0 }, overrides: {}, instanceEdits: {} }); + scene.objects.push(anchor); + const source = scene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + source.parentId = 'pt-anchor'; + source.position = { x: 200, y: 0, z: 0 };`); + await sleep(300); + const linked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); + await mutate(` + const scene = __gm.state.activeScene; + const source = scene.objects.find((o) => o.id === ${JSON.stringify(SOURCE)}); + source.parentId = ''; + source.position = { x: 0, y: ${LANE}, z: 0 }; + scene.objects = scene.objects.filter((o) => o.id !== 'pt-anchor');`); + await sleep(300); + const unlinked = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`); + check( + 'a parented source hangs off its owner by a line, a free one by none', + linked?.link === true && unlinked?.link === false, + JSON.stringify({ linked, unlinked }), + ); + + // 31p. The graph tab draws no Pixi complaint of its own. const logged = await json('__gm.console.lines()'); check( 'nothing about the particle tab draws a complaint from Pixi', @@ -525,7 +577,7 @@ export async function particles(t) { JSON.stringify(logged.slice(0, 3)), ); - // 31n. The step takes back everything it made, so the steps after it see the fixture again. + // 31q. The step takes back everything it made, so the steps after it see the fixture again. await evaluate('__gm.particleTime(null)'); await evaluate('__gm.state.select(null)'); await mutate(`