diff --git a/README.md b/README.md index dd8e233e..5d911656 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,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, 1348 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1351 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 0e69565b..4548bc78 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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, Text } from 'pixi.js'; +import { 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,6 +13,7 @@ import { apply as applyPoint, applyVector, type Extent, + IDENTITY, invert, linearOf, type Mat, @@ -31,7 +32,8 @@ 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 { dashedLine } from '../../viewport/symbols'; +import { colliderShape } from '../../core/properties/collider'; +import { colliderColor, dashedLine } from '../../viewport/symbols'; import { drawSymbolIcon } from '../../viewport/symbolIcons'; import { screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World'; @@ -80,6 +82,11 @@ 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; + /** Owner links in the colour of the icon they leave; anything else in the muted default. */ const LINK_COLORS: Partial> = { behavior: Theme.colors.behavior, @@ -93,6 +100,7 @@ export class SceneViewport extends FreeViewport { private readonly gizmos = new Graphics(); private readonly labels = new Map(); private readonly icons = new Map(); + private readonly iconColors = new Map(); private readonly solids = new TilemapSolidOutlines(this.overlay); private readonly host: SceneViewHost = { state: this.state, @@ -407,7 +415,11 @@ export class SceneViewport extends FreeViewport { const node = this.nodes.get(item.id); if (!node?.visible || node.eventMode === 'none') continue; const p = node.toLocal(global); - if (!(node.hitArea ? node.hitArea.contains(p.x, p.y) : node.containsPoint(p))) continue; + if ( + !this.onIcon(item.id, global) && + !(node.hitArea ? node.hitArea.contains(p.x, p.y) : node.containsPoint(p)) + ) + continue; if (item.type !== 'tilemap') { hits.push({ id: item.id, layers: null }); continue; @@ -424,6 +436,14 @@ export class SceneViewport extends FreeViewport { return hits; } + /** Whether the pointer is on the clickable icon of this object – a collider's. */ + private onIcon(id: string, global: Vec2): boolean { + const icon = this.icons.get(id); + if (!icon?.visible || icon.eventMode === 'none' || !icon.hitArea) return false; + const p = icon.toLocal(global); + return icon.hitArea.contains(p.x, p.y); + } + /** What is under the pointer, front to back as drawn (#203). */ private stackUnder(e: FederatedPointerEvent): string[] { return stackAt(this.lastItems, this.hitsUnder(e.global)); @@ -512,6 +532,7 @@ 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); } @@ -520,39 +541,82 @@ export class SceneViewport extends FreeViewport { return this.solids.info(this.lastItems.find((i) => i.id === 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 { + /** + * 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): { + layer: string; + visible: boolean; + label: string; + link: boolean; + x: number; + y: number; + color: number | null; + screen: Vec2; + clickable: boolean; + } | null { const icon = this.icons.get(id); if (!icon) return null; const label = this.labels.get(id); + const screen = icon.getGlobalPosition(); return { layer: icon.parent === this.overlay ? '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. + * 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). */ private syncIcon(item: DrawItem, show: boolean): void { let icon = this.icons.get(item.id); - if (!show || item.type !== 'particles') { - if (icon) icon.visible = false; + const type = item.type === 'particles' || item.type === 'collider' ? item.type : null; + if (!show || !type) { + if (icon) { + icon.visible = false; + icon.eventMode = 'none'; + } return; } if (!icon) { icon = new Graphics(); icon.eventMode = 'none'; - drawSymbolIcon(icon, 'particles'); + 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)); + } 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); + } icon.visible = true; - icon.position.set(item.x, item.y); + icon.position.set(type === 'collider' ? item.x - ICON_CLEARANCE : item.x, item.y); } private syncGrid(): void { diff --git a/src/viewport/icons/collider.svg b/src/viewport/icons/collider.svg new file mode 100644 index 00000000..8742733a --- /dev/null +++ b/src/viewport/icons/collider.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/viewport/symbolIcons.ts b/src/viewport/symbolIcons.ts index a8c3e128..e45c94e6 100644 --- a/src/viewport/symbolIcons.ts +++ b/src/viewport/symbolIcons.ts @@ -3,23 +3,32 @@ import type { Graphics } from 'pixi.js'; import { drawSvg, Theme } from '../ui'; import behavior from './icons/behavior.svg?raw'; import camera from './icons/camera.svg?raw'; +import collider from './icons/collider.svg?raw'; import container from './icons/container.svg?raw'; import particles from './icons/particles.svg?raw'; import rigidbody from './icons/rigidbody.svg?raw'; -export type SymbolIcon = 'behavior' | 'camera' | 'container' | 'particles' | 'rigidbody'; +export type SymbolIcon = 'behavior' | 'camera' | 'collider' | 'container' | 'particles' | 'rigidbody'; -export const SYMBOL_SVG: Record = { behavior, camera, container, particles, rigidbody }; +export const SYMBOL_SVG: Record = { + behavior, + camera, + collider, + container, + particles, + rigidbody, +}; const SYMBOL_COLOR: Record = { behavior: Theme.colors.behavior, camera: Theme.colors.textMuted, + collider: Theme.colors.collider, container: Theme.colors.container, particles: Theme.colors.particles, rigidbody: Theme.colors.collider, }; -/** Adds the icon to what `node` already draws. */ -export function drawSymbolIcon(node: Graphics, icon: SymbolIcon): void { - drawSvg(node, SYMBOL_SVG[icon], SYMBOL_COLOR[icon]); +/** Adds the icon to what `node` already draws; `color` for one that changes, like a trigger's. */ +export function drawSymbolIcon(node: Graphics, icon: SymbolIcon, color = SYMBOL_COLOR[icon]): void { + drawSvg(node, SYMBOL_SVG[icon], color); } diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts index 079c959f..8b43c6ca 100644 --- a/src/viewport/symbols.ts +++ b/src/viewport/symbols.ts @@ -12,7 +12,7 @@ import { } from '../core/properties/collider'; import { SHEET_EDGE } from '../core/shader/nodes'; import type { NineSlice } from '../core/model/texture'; -import type { Vec2 } from '../core/model/Properties'; +import type { PropertyBag, Vec2 } from '../core/model/Properties'; import { closestOnSegment } from '../core/util/vec'; import { Theme } from '../ui'; import type { DrawItem } from './items'; @@ -153,14 +153,7 @@ export function drawItem( // collider sits on. const shape = colliderShape(item.props, IDENTITY, item.frame); traceShape(node, shape); - // 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). - const color = - shape.kind === 'polygon' && shape.pieces.length === 0 - ? Theme.colors.danger - : isTrigger(item.props) - ? Theme.colors.trigger - : Theme.colors.collider; + const color = colliderColor(shape, item.props); node.fill({ color, alpha: 0.12 }).stroke({ width: 2 / scale, color }); const facing = oneWayFacing(item.props, IDENTITY); if (facing) @@ -176,6 +169,15 @@ export function drawItem( } } +/** + * The colour of a collider's outline and icon. 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). + */ +export function colliderColor(shape: ColliderShape, props: PropertyBag): number { + if (shape.kind === 'polygon' && shape.pieces.length === 0) return Theme.colors.danger; + return isTrigger(props) ? Theme.colors.trigger : Theme.colors.collider; +} + /** How far the landing cone of a one-way side reaches, in screen pixels (#159). */ export const CONE_LENGTH = 24; diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index a88f6e5d..788df767 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -28,6 +28,7 @@ describe('icon files', () => { expect(Object.keys(SYMBOL_SVG).sort()).toEqual([ 'behavior', 'camera', + 'collider', 'container', 'particles', 'rigidbody', diff --git a/tests/viewport/collider-color.test.ts b/tests/viewport/collider-color.test.ts new file mode 100644 index 00000000..07d4e0e5 --- /dev/null +++ b/tests/viewport/collider-color.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { IDENTITY } from '../../src/core/model/matrix'; +import type { PropertyBag } from '../../src/core/model/Properties'; +import { colliderShape } from '../../src/core/properties/collider'; +import { Theme } from '../../src/ui'; +import { colliderColor } from '../../src/viewport/symbols'; + +const colorOf = (props: PropertyBag) => colliderColor(colliderShape(props, IDENTITY), props); + +describe('the colour of a collider in the scene view, outline and icon alike', () => { + it('is the collider colour, the trigger colour for a trigger, red for a shape that collides with nothing', () => { + expect(colorOf({ shape: 'box', width: 40, height: 40 })).toBe(Theme.colors.collider); + expect(colorOf({ shape: 'circle', radius: 20, isTrigger: true })).toBe(Theme.colors.trigger); + const bowtie = [ + { x: -10, y: -10 }, + { x: 10, y: 10 }, + { x: 10, y: -10 }, + { x: -10, y: 10 }, + ]; + expect(colorOf({ shape: 'polygon', points: bowtie })).toBe(Theme.colors.danger); + expect(colorOf({ shape: 'polygon', points: bowtie, isTrigger: true })).toBe(Theme.colors.danger); + }); +}); diff --git a/tools/e2e/41-physics.mjs b/tools/e2e/41-physics.mjs index 584363a2..c23c1fd9 100644 --- a/tools/e2e/41-physics.mjs +++ b/tools/e2e/41-physics.mjs @@ -154,6 +154,56 @@ export async function physics(t) { await evaluate(`__gm.lookAt(0, ${Y + 100})`); await settle(); await shot('41-rigidbody'); + // A collider has an icon like every other symbol. It stands left of the collider's centre, so the + // middle of its sprite stays free, and so does a rigidbody's icon on the same spot. + const icons = { + ground: await json(`__gm.sceneIcon('phys-ground-col')`), + crate: await json(`__gm.sceneIcon('phys-crate-col')`), + ball: await json(`__gm.sceneIcon('phys-ball-col')`), + }; + check( + 'a collider without a rigidbody shows its icon left of its centre', + icons.ground?.visible === true && + icons.ground.layer === 'overlay' && + icons.ground.x === -28 && + icons.ground.y === Y + 200 && + icons.ground.color !== null, + JSON.stringify(icons.ground), + ); + check( + "a collider's icon steps aside from the rigidbody on the same body, in the same colour", + [ + [icons.crate, -100], + [icons.ball, 100], + ].every(([icon, x]) => icon?.visible === true && icon.x === x - 28 && icon.y === Y) && + icons.crate.color === icons.ground.color, + JSON.stringify(icons), + ); + // The icon is clicked like the outline: it selects the collider, even over its sprite. On the + // middle of the sprite, beside the icon, the sprite is picked as before. + const selectedId = async () => { + const s = await state(); + return s.selection?.kind === 'object' ? s.selection.objectId : null; + }; + const picks = {}; + for (const [name, id, dx] of [ + ['groundIcon', 'phys-ground-col', 0], + ['crateIcon', 'phys-crate-col', 0], + ['groundMiddle', 'phys-ground', null], + ]) { + await select(null); + const at = await json(dx === null ? `__gm.objectScreen('${id}')` : `__gm.sceneIcon('${id}')?.screen`); + await click(at.x + (dx ?? 0), at.y); + picks[name] = { selected: await selectedId() }; + } + check( + "a click on a collider's icon selects the collider, on the sprite's middle the sprite", + icons.ground.clickable === true && + picks.groundIcon.selected === 'phys-ground-col' && + picks.crateIcon.selected === 'phys-crate-col' && + picks.groundMiddle.selected === 'phys-ground', + JSON.stringify(picks), + ); await selectObject('phys-ball-col'); const field = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Bounciness')");