From 6015869297914d42b07dc9639a3154f839b50c1d Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 26 Sep 2026 23:32:58 +0200 Subject: [PATCH] Let the scene view hide symbols, names and shapes per asset kind The scene view gets crowded once a scene holds many behaviors, colliders and tilemaps. A new eye button in the topbar opens a list of all placeable asset kinds, and each row has toggles for what the scene view draws for that kind. - The popup lists all 9 placeable kinds from Behavior to Tilemap, with the columns Symbol, Name and Shape. It stays open while switching. - Sprite, Text and Tilemap draw no symbol, so their symbol toggle is greyed out. Their name toggle shows a new centred name tag, which is off by default. - Shape exists for Collider and Tilemap. A hidden collider outline is neither drawn nor clickable. A hidden tilemap shape removes the collision outlines and the paint tint, the tiles stay visible. - A hidden symbol takes no slot in the ring and draws no dashed link to its parent. A name that stays on moves to the centre of the object. - The setting lives in the project as `sceneVisibility`, not in localStorage. Older projects load with the defaults, which match the view as it was. - Switching has no undo step, like the game scale. It only changes the view. A change made during Play survives Stop. - The rules sit in `src/core/model/sceneVisibility.ts`, the popup in `src/editor/panels/SceneVisibilityPopup.ts`. Four new SVG icons. Not covered by a check: symbol off with name on for a kind with a symbol, Text and Tilemap with names, and the paint tint with shape off. Tests: - ./check.sh: 173 files, 2515 tests, CHECK EXIT 0 - e2e 46: ALL PASSED, 17 checks. 46g clicks the popup, lays a small tilemap over the crate and removes it again. - e2e 8 alone on a fresh seed: ALL PASSED, 23 checks. A red check in the partial run 2,8,46 came from the module selection. - 8 new unit tests in tests/editor/scene-visibility.test.ts. - 4 counter-probes, each fails its test: load ignores the saved value, Play skips the saved model, scene view ignores collider shape, scene view ignores tilemap shape. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/model/Project.ts | 4 + src/core/model/sceneVisibility.ts | 71 ++++++++++++ src/core/store/serialize.ts | 2 + src/editor/debug/sceneHooks.ts | 8 ++ src/editor/panels/SceneVisibilityPopup.ts | 100 +++++++++++++++++ src/editor/panels/Toolbar.ts | 20 +++- src/editor/panels/icons/eye.svg | 4 + src/editor/panels/icons/show-label.svg | 4 + src/editor/panels/icons/show-shape.svg | 3 + src/editor/panels/icons/show-symbol.svg | 3 + src/editor/panels/toolbarIcons.ts | 14 ++- src/editor/state.ts | 11 ++ src/editor/viewport/SceneViewport.ts | 52 +++++++-- src/editor/viewport/WorldViewport.ts | 13 ++- tests/core/holders.test.ts | 2 + tests/editor/scene-visibility.test.ts | 108 ++++++++++++++++++ tests/ui/svg-icons.test.ts | 20 +++- tools/e2e/46-topbar.mjs | 131 ++++++++++++++++++++++ 19 files changed, 547 insertions(+), 25 deletions(-) create mode 100644 src/core/model/sceneVisibility.ts create mode 100644 src/editor/panels/SceneVisibilityPopup.ts create mode 100644 src/editor/panels/icons/eye.svg create mode 100644 src/editor/panels/icons/show-label.svg create mode 100644 src/editor/panels/icons/show-shape.svg create mode 100644 src/editor/panels/icons/show-symbol.svg create mode 100644 tests/editor/scene-visibility.test.ts diff --git a/README.md b/README.md index e090a5f8..7f1f8e13 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, 1418 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1422 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/core/model/Project.ts b/src/core/model/Project.ts index 70c4004e..ba93a9e1 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -8,6 +8,7 @@ import { } from './Asset'; import { DEFAULT_LAYER_NAME } from './collisionLayers'; import { DEFAULT_GAME_SCALE, DEFAULT_PLAY_FROM, type GameScale, type PlayFrom } from './playSettings'; +import { defaultSceneVisibility, type SceneVisibility } from './sceneVisibility'; import { DEFAULT_SYMBOL_SCALE } from './symbolScale'; /** 2: a placed container carries no copy of its template. */ @@ -47,6 +48,8 @@ export interface Project { playFrom: PlayFrom; /** The scale the game view draws the camera at. Saved, but not an undo step: it is how you look. */ gameScale: GameScale; + /** Which symbols, names and collider outlines the scene view shows, per type. Saved like `gameScale`. */ + sceneVisibility: SceneVisibility; } export function createEmptyProject(id: string, name: string): Project { @@ -62,6 +65,7 @@ export function createEmptyProject(id: string, name: string): Project { symbolScale: DEFAULT_SYMBOL_SCALE, playFrom: DEFAULT_PLAY_FROM, gameScale: DEFAULT_GAME_SCALE, + sceneVisibility: defaultSceneVisibility(), }; } diff --git a/src/core/model/sceneVisibility.ts b/src/core/model/sceneVisibility.ts new file mode 100644 index 00000000..1df66a19 --- /dev/null +++ b/src/core/model/sceneVisibility.ts @@ -0,0 +1,71 @@ +/** + * What the scene view shows of each placeable type: its symbol, its name, and for a collider its + * outline. It is saved with the project, so whoever opens it sees the same scene, and a project + * written before there was such a field reads as the defaults. + */ +import { hasSceneIcon, type PlaceableAssetType } from './AssetTypes'; + +export type VisibilityPart = 'symbol' | 'label' | 'shape'; + +export type TypeVisibility = Record; + +export type SceneVisibility = Record; + +/** Every placeable type, in the order its name sorts – Behavior to Tilemap. */ +export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [ + 'behavior', + 'camera', + 'collider', + 'container', + 'particles', + 'rigidbody', + 'sprite', + 'text', + 'tilemap', +]; + +/** + * The switches a type has. Only a type with a symbol can hide it, every type can show its name, + * and only what collides has an outline to hide: a collider its shape, a tilemap the outline of + * its solid cells – the map's art stays. A sprite without its picture would be nothing at all. + */ +export function visibilityParts(type: PlaceableAssetType): VisibilityPart[] { + const parts: VisibilityPart[] = hasSceneIcon(type) ? ['symbol', 'label'] : ['label']; + if (type === 'collider' || type === 'tilemap') parts.push('shape'); + return parts; +} + +/** What a type shows until it is switched: all of it, except a name nothing showed before. */ +export function defaultVisibility(type: PlaceableAssetType): TypeVisibility { + return { symbol: true, label: hasSceneIcon(type), shape: true }; +} + +export function defaultSceneVisibility(): SceneVisibility { + return Object.fromEntries( + VISIBILITY_TYPES.map((type) => [type, defaultVisibility(type)]), + ) as SceneVisibility; +} + +/** Read off untrusted JSON: every type and part that is missing or not a boolean is the default. */ +export function sceneVisibilityOf(raw: unknown): SceneVisibility { + const stored = (raw as { sceneVisibility?: unknown } | null)?.sceneVisibility; + const out = defaultSceneVisibility(); + if (!stored || typeof stored !== 'object') return out; + for (const type of VISIBILITY_TYPES) { + const entry = (stored as Record)[type]; + if (!entry || typeof entry !== 'object') continue; + for (const part of Object.keys(out[type]) as VisibilityPart[]) { + const value = (entry as Record)[part]; + if (typeof value === 'boolean') out[type][part] = value; + } + } + return out; +} + +/** Whether the scene view draws `part` of an object of `type`; a type without that part never does. */ +export function shows(visibility: SceneVisibility, type: string, part: VisibilityPart): boolean { + const entry = (visibility as Partial>)[type]; + if (!entry) return part !== 'label'; + if (!visibilityParts(type as PlaceableAssetType).includes(part)) return part === 'shape'; + return entry[part]; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 2aa18023..c07e83ba 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -41,6 +41,7 @@ import { parseCellKey } from '../model/tilemap'; import { pluginsOf } from './projectPlugins'; import { collisionLayersOf } from '../model/collisionLayers'; import { gameScaleOf, playFromOf } from '../model/playSettings'; +import { sceneVisibilityOf } from '../model/sceneVisibility'; import { symbolScaleOf } from '../model/symbolScale'; import { decodeCells, encodeCells } from './tilemapCells'; import { isAssetType } from '../model/AssetTypes'; @@ -255,6 +256,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { symbolScale: symbolScaleOf(r), playFrom: playFromOf(r), gameScale: gameScaleOf(r), + sceneVisibility: sceneVisibilityOf(r), }; return project; } diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index e88e86c2..63e16af9 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -1,6 +1,7 @@ import { findAssetOf, findObjectHolder } from '../../core/model/Project'; import { resolveObjects } from '../../core/model/instance'; import { findLayer, layerZ } from '../../core/model/tilemap'; +import { currentVisibilityPopup } from '../panels/SceneVisibilityPopup'; import { currentTilemapWidgets, paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; import type { TilemapTool } from '../state'; import { rectOf } from '../textProbe'; @@ -65,6 +66,13 @@ export function sceneHooks({ state, editor }: HookContext) { sceneIcon: (id: string) => viewport.iconInfo(id), /** The names shown beside the symbols, each with the plate under it, in world units and on screen. */ sceneLabels: () => viewport.labelInfo(), + /** A collider's outline in the Scene tab: hidden by the eye in the top bar, it is neither seen nor hit. */ + sceneShape: (id: string) => viewport.shapeInfo(id), + /** The switches behind the eye while it stands open: per type and part, on, enabled, where to click. */ + visibilityPopup: () => + currentVisibilityPopup() + ?.info() + .map(({ widget, ...entry }) => ({ ...entry, ...centreOf(widget) })) ?? null, drawOrder: () => viewport.drawOrder(), objectScreen: (id: string) => viewport.objectScreen(id), objectRect: (id: string) => viewport.objectRect(id), diff --git a/src/editor/panels/SceneVisibilityPopup.ts b/src/editor/panels/SceneVisibilityPopup.ts new file mode 100644 index 00000000..f377c65e --- /dev/null +++ b/src/editor/panels/SceneVisibilityPopup.ts @@ -0,0 +1,100 @@ +import { ASSET_TYPE_LABELS, type PlaceableAssetType } from '../../core/model/AssetTypes'; +import { VISIBILITY_TYPES, visibilityParts, type VisibilityPart } from '../../core/model/sceneVisibility'; +import { Button, HStack, Icon, Label, Popup, Spacer, Theme, VStack, type Widget } from '../../ui'; +import type { EditorState } from '../state'; +import { ASSET_ICON_SVG } from './assetIcons'; +import { VISIBILITY_ICON_SVG } from './toolbarIcons'; + +const PARTS: readonly VisibilityPart[] = ['symbol', 'label', 'shape']; +const PART_CAPTIONS: Record = { symbol: 'Symbol', label: 'Name', shape: 'Shape' }; +/** One column per switch, wide enough for its caption. */ +const COLUMN = 44; +const NAME_WIDTH = 90; + +/** A gap of `w` that does not make the row taller – a plain `Spacer` is square. */ +function gap(w: number): Spacer { + const spacer = new Spacer(w); + spacer.minHeight = 0; + return spacer; +} + +/** + * The eye in the top bar: every type that can be placed, and per type a switch for its symbol, its + * name and – a collider only – its outline in the scene view. A switch a type does not have is + * greyed out, or missing where no other type has it either. Clicks keep it open: one usually + * switches several. + */ +export class SceneVisibilityPopup extends Popup { + private readonly switches = new Map(); + + constructor(private readonly state: EditorState) { + super({ padding: 6, gap: 2 }); + const header = new HStack({ gap: Theme.gap, align: 'center' }); + header.add(gap(16 + Theme.gap + NAME_WIDTH)); + for (const part of PARTS) + header.add(new Label(PART_CAPTIONS[part], { style: 'small', align: 'center', width: COLUMN })); + this.content.add(header); + const rows = new VStack({ gap: 2 }); + for (const type of VISIBILITY_TYPES) rows.add(this.row(type)); + this.content.add(rows); + } + + private row(type: PlaceableAssetType): HStack { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.add(new Icon(ASSET_ICON_SVG[type])); + row.add(new Label(ASSET_TYPE_LABELS[type], { width: NAME_WIDTH })); + const parts = visibilityParts(type); + for (const part of PARTS) { + if (part === 'shape' && !parts.includes(part)) { + row.add(gap(COLUMN)); + continue; + } + const button = new Button('', { + variant: 'ghost', + width: COLUMN, + icon: VISIBILITY_ICON_SVG[part], + onClick: () => this.flip(type, part), + }); + button.setEnabled(parts.includes(part)); + button.setToggled(parts.includes(part) && this.state.project.sceneVisibility[type][part]); + this.switches.set(`${type}:${part}`, button); + row.add(button); + } + return row; + } + + private flip(type: PlaceableAssetType, part: VisibilityPart): void { + const on = !this.state.project.sceneVisibility[type][part]; + this.state.setSceneVisibility(type, part, on); + this.switches.get(`${type}:${part}`)?.setToggled(on); + } + + override open(globalX: number, globalY: number): void { + super.open(globalX, globalY); + setCurrent(this); + } + + override close(): void { + if (current === this) current = null; + super.close(); + } + + /** Per type and switch: whether it is on, whether it takes a click, and where to click it (test hook). */ + info(): { type: string; part: VisibilityPart; on: boolean; enabled: boolean; widget: Widget }[] { + return [...this.switches].map(([key, button]) => { + const [type, part] = key.split(':') as [string, VisibilityPart]; + return { type, part, on: button.toggled, enabled: button.enabled, widget: button }; + }); + } +} + +let current: SceneVisibilityPopup | null = null; + +function setCurrent(popup: SceneVisibilityPopup): void { + current = popup; +} + +/** The popup standing open, for the test hook. */ +export function currentVisibilityPopup(): SceneVisibilityPopup | null { + return current; +} diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts index c2984d97..6eb04951 100644 --- a/src/editor/panels/Toolbar.ts +++ b/src/editor/panels/Toolbar.ts @@ -1,6 +1,7 @@ import { Graphics } from 'pixi.js'; import { Button, drawHeaderBar, Dropdown, HStack, Label, Spacer, Theme, type Widget } from '../../ui'; import type { EditorState, SaveStatus } from '../state'; +import { SceneVisibilityPopup } from './SceneVisibilityPopup'; import { TOOLBAR_ICON_SVG } from './toolbarIcons'; /** What the toolbar buttons trigger; the editor supplies the implementations. */ @@ -37,8 +38,8 @@ function iconButton(name: IconName, onClick: () => void): Button { } /** - * Top bar (#183): menu, undo and redo on the left, play and pause in the middle, the account on - * the right – icons, no captions. Everything with words is behind the menu: the project name and + * Top bar (#183): menu, undo, redo and the scene view's eye on the left, play and pause in the + * middle, the account on the right – icons, no captions. Everything with words is behind the menu: the project name and * the start-scene switch in Project settings, the project list in Open project. The scene is * picked where the scenes are, in the Assets panel. */ @@ -47,6 +48,8 @@ export class Toolbar extends HStack { private readonly menuButton: Button; private readonly undoButton: Button; private readonly redoButton: Button; + /** What the scene view shows of each type – symbols, names, collider outlines. */ + private readonly visibilityButton: Dropdown; private readonly playButton: Button; private readonly pauseButton: Button; private readonly status: Label; @@ -71,6 +74,12 @@ export class Toolbar extends HStack { this.redoButton = iconButton('redo', () => state.redo()); this.add(this.undoButton); this.add(this.redoButton); + this.add(new Spacer(Theme.padding)); + this.visibilityButton = new Dropdown('', { + icon: TOOLBAR_ICON_SVG.eye, + onClick: () => new SceneVisibilityPopup(state).openBelow(this.visibilityButton), + }); + this.add(this.visibilityButton); this.add(new Spacer(), 1); this.playButton = iconButton('play', () => actions.togglePlay()); @@ -113,12 +122,13 @@ export class Toolbar extends HStack { }; } - /** The five icon buttons, so a check can click what carries no caption. */ - get controls(): Record<'menu' | 'undo' | 'redo' | 'play' | 'pause', Button> { + /** The icon buttons, so a check can click what carries no caption. */ + get controls(): Record<'menu' | 'undo' | 'redo' | 'visibility' | 'play' | 'pause', Button> { return { menu: this.menuButton, undo: this.undoButton, redo: this.redoButton, + visibility: this.visibilityButton, play: this.playButton, pause: this.pauseButton, }; @@ -158,7 +168,7 @@ export class Toolbar extends HStack { override layout(): void { super.layout(); const width = this.pauseButton.x + this.pauseButton.w - this.playButton.x; - const leftMost = this.redoButton.x + this.redoButton.w + this.gap; + const leftMost = this.visibilityButton.x + this.visibilityButton.w + this.gap; const rightMost = this.notice.x - this.gap - width; const x = Math.round(Math.max(leftMost, Math.min((this.w - width) / 2, rightMost))); const shift = x - this.playButton.x; diff --git a/src/editor/panels/icons/eye.svg b/src/editor/panels/icons/eye.svg new file mode 100644 index 00000000..838bee08 --- /dev/null +++ b/src/editor/panels/icons/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/show-label.svg b/src/editor/panels/icons/show-label.svg new file mode 100644 index 00000000..1eb7f937 --- /dev/null +++ b/src/editor/panels/icons/show-label.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/editor/panels/icons/show-shape.svg b/src/editor/panels/icons/show-shape.svg new file mode 100644 index 00000000..588f5a75 --- /dev/null +++ b/src/editor/panels/icons/show-shape.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/icons/show-symbol.svg b/src/editor/panels/icons/show-symbol.svg new file mode 100644 index 00000000..25920091 --- /dev/null +++ b/src/editor/panels/icons/show-symbol.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/editor/panels/toolbarIcons.ts b/src/editor/panels/toolbarIcons.ts index 901e5daa..864803ec 100644 --- a/src/editor/panels/toolbarIcons.ts +++ b/src/editor/panels/toolbarIcons.ts @@ -2,11 +2,23 @@ * The top bar's icons (#183). It carries no captions any more: menu, undo and redo on the left, * play and pause in the middle, so each button is its picture and nothing else. */ +import type { VisibilityPart } from '../../core/model/sceneVisibility'; +import eye from './icons/eye.svg?raw'; import menu from './icons/menu.svg?raw'; import pause from './icons/pause.svg?raw'; import play from './icons/play.svg?raw'; import redo from './icons/redo.svg?raw'; +import showLabel from './icons/show-label.svg?raw'; +import showShape from './icons/show-shape.svg?raw'; +import showSymbol from './icons/show-symbol.svg?raw'; import stop from './icons/stop.svg?raw'; import undo from './icons/undo.svg?raw'; -export const TOOLBAR_ICON_SVG = { menu, undo, redo, play, pause, stop }; +export const TOOLBAR_ICON_SVG = { menu, undo, redo, eye, play, pause, stop }; + +/** The switches behind the eye: a type's symbol, its name, a collider's outline. */ +export const VISIBILITY_ICON_SVG: Record = { + symbol: showSymbol, + label: showLabel, + shape: showShape, +}; diff --git a/src/editor/state.ts b/src/editor/state.ts index a47f6f31..4251d4f5 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -20,6 +20,8 @@ import { deserializeProject, serializeProject } from '../core/store/serialize'; import { requestRender } from '../ui'; import { consoleLog } from './console/log'; import type { GameScale, PlayFrom } from '../core/model/playSettings'; +import type { PlaceableAssetType } from '../core/model/AssetTypes'; +import type { VisibilityPart } from '../core/model/sceneVisibility'; import type { ValuePath } from '../core/properties/valuePath'; export type Selection = @@ -343,6 +345,15 @@ export class EditorState { this.setSaveStatus('dirty'); } + /** Shows or hides one part of a type in the scene view – saved, and like the game scale no undo step. */ + setSceneVisibility(type: PlaceableAssetType, part: VisibilityPart, on: boolean): void { + if (this.project.sceneVisibility[type][part] === on) return; + this.project.sceneVisibility[type][part] = on; + if (this.edit) this.edit.project.sceneVisibility[type][part] = on; + this.setSaveStatus('dirty'); + this.emit('project'); + } + /** * Runs a command through the undo stack. Returns false (and sets a notice) when invalid. * In play mode the command changes the runtime copy only (see `enterPlay`). diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 8f6b6ad1..d35ec42a 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -32,6 +32,7 @@ import { sortRowOf } from '../../viewport/drawOrder'; import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick'; import { openObjectMenu } from '../objectMenu'; import { type LayerFocus, layerAlpha } from '../../viewport/tilemap'; +import { shows, type VisibilityPart } from '../../core/model/sceneVisibility'; import { dashedLine } from '../../viewport/symbols'; import { drawSymbolIcon, SYMBOL_GRID, SYMBOL_OUTLINE, type SymbolIcon } from '../../viewport/symbolIcons'; import { type LabelBox, stackLabels, type WantedLabel } from '../../viewport/labelRows'; @@ -586,7 +587,8 @@ export class SceneViewport extends FreeViewport { _extent: Extent, ): void { this.syncIcon(item, onScreen); - this.solids.sync(item, onScreen, scale, onScreen ? this.paintedSolids(item) : null); + const solids = onScreen && this.shows(item, 'shape'); + this.solids.sync(item, solids, scale, solids ? this.paintedSolids(item) : null); this.syncLabel(item, onScreen, scale); } @@ -597,6 +599,16 @@ export class SceneViewport extends FreeViewport { return item.tilemap!.layerSolids(paintedLayerId(this.state, target.asset)); } + /** What the project's scene visibility (the eye in the top bar) says about this part of `item`. */ + private shows(item: DrawItem, part: VisibilityPart): boolean { + return shows(this.state.project.sceneVisibility, item.type, part); + } + + /** A tilemap's shape switch hides only the outline of its solid cells, never the map itself. */ + protected override hidesShape(item: DrawItem): boolean { + return item.type === 'collider' && !this.shows(item, 'shape'); + } + protected override disposeDecorations(id: string): void { this.labels.get(id)?.destroy(); this.labels.delete(id); @@ -648,7 +660,7 @@ export class SceneViewport extends FreeViewport { */ private syncIcon(item: DrawItem, show: boolean): void { let icon = this.icons.get(item.id); - if (!show || !hasSceneIcon(item.type)) { + if (!show || !hasSceneIcon(item.type) || !this.shows(item, 'symbol')) { if (icon) { icon.visible = false; icon.eventMode = 'none'; @@ -685,19 +697,20 @@ export class SceneViewport extends FreeViewport { this.overlay.setChildIndex(this.labelLayer, last); this.overlay.setChildIndex(this.iconLayer, last); const anchors = items - .filter((item) => item.enabled && hasSceneIcon(item.type)) + .filter((item) => item.enabled && hasSceneIcon(item.type) && this.shows(item, 'symbol')) .map((item) => ({ id: item.id, x: item.x, y: item.y })); this.spots = layoutSymbols(anchors, SYMBOL_SPACING * this.symbolScale()); for (const [id, spot] of this.spots) this.icons.get(id)?.position.set(spot.x, spot.y); - this.placeLabels(scale); + this.placeLabels(items, scale); } /** * Each name beside its icon – on the outer side of a ring – on a dark plate, and a row up or down - * where it would cover another name or icon (`stackLabels`). Names off screen take their row as - * well, so none jumps while the view pans. + * where it would cover another name or icon (`stackLabels`). A name without an icon beside it – a + * sprite's, or a symbol's whose icon is hidden – is centred on its object. Names off screen take + * their row as well, so none jumps while the view pans. */ - private placeLabels(scale: number): void { + private placeLabels(items: DrawItem[], scale: number): void { const side = SYMBOL_SIZE * this.symbolScale(); const gap = side / 2 + 4 / scale; const padX = LABEL_PAD_X / scale; @@ -716,6 +729,14 @@ export class SceneViewport extends FreeViewport { const x = left ? spot.x - gap - label.width - padX : spot.x + gap - padX; wanted.push({ id, x, y: spot.y - h / 2, w, h }); } + for (const item of items) { + const label = this.labels.get(item.id); + if (!label || !item.enabled || this.spots.has(item.id)) continue; + lefts.set(item.id, false); + const w = label.width + padX * 2; + const h = label.height + padY * 2; + wanted.push({ id: item.id, x: item.x - w / 2, y: item.y - h / 2, w, h }); + } const step = (wanted[0]?.h ?? 0) + LABEL_ROW_GAP / scale; const tops = stackLabels(wanted, icons, step); this.plates.clear(); @@ -761,7 +782,7 @@ export class SceneViewport extends FreeViewport { this.lastLinks = []; const byId = new Map(items.map((i) => [i.id, i])); for (const item of items) { - if (!hasSceneIcon(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue; + if (!this.spots.has(item.id) || !item.parentId || item.type === 'collider') continue; const parent = byId.get(item.parentId); if (!parent || !parent.enabled) continue; this.lastLinks.push(item.id); @@ -775,13 +796,14 @@ export class SceneViewport extends FreeViewport { } /** - * The name of a symbol; `placeLabels` puts it beside the icon once every icon has its spot. One - * off screen is kept up to date but hidden: it still takes its row. + * The name of an object whose type shows names; `placeLabels` puts it beside the icon once every + * icon has its spot. One off screen is kept up to date but hidden: it still takes its row. */ private syncLabel(item: DrawItem, show: boolean, scale: number): void { let label = this.labels.get(item.id); - if (!hasSceneIcon(item.type)) { - if (label) label.visible = false; + if (!this.shows(item, 'label')) { + label?.destroy(); + this.labels.delete(item.id); return; } if (!label) { @@ -795,6 +817,12 @@ export class SceneViewport extends FreeViewport { label.scale.set(1 / scale); } + /** Whether an object's own drawing – a collider's outline – is on screen and takes a click (test hook). */ + shapeInfo(id: string): { visible: boolean; clickable: boolean } | null { + const node = this.nodes.get(id); + return node ? { visible: node.visible, clickable: node.eventMode !== 'none' } : null; + } + /** The names shown and their plates in world units, `left` for one on the left of its icon (test hook). */ labelInfo(): (WantedLabel & { left: boolean; screen: { x: number; y: number; w: number; h: number } })[] { const view = this.currentView(); diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index 69d9708c..3db65d24 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -135,6 +135,11 @@ export abstract class WorldViewport extends Widget { /** An item disappeared: drop whatever `syncDecorations` created for it. */ protected disposeDecorations(_id: string): void {} + /** Whether the item's own drawing is switched off – it is then neither seen nor hit. */ + protected hidesShape(_item: DrawItem): boolean { + return false; + } + protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {} /** Whether the object itself is selected – a selected collider shows its area filled. */ @@ -312,9 +317,10 @@ export abstract class WorldViewport extends Widget { this.nodes.set(item.id, node); (isSymbolType(item.type) ? this.overlay : this.objects).addChild(node); } - node.visible = onScreen; - if (item.enabled) culled.push({ node, extent: item.type === 'particles' ? null : extent }); - node.eventMode = this.itemsInteractive && onScreen ? 'static' : 'none'; + const hidden = this.hidesShape(item); + node.visible = onScreen && !hidden; + if (item.enabled && !hidden) culled.push({ node, extent: item.type === 'particles' ? null : extent }); + node.eventMode = this.itemsInteractive && node.visible ? 'static' : 'none'; this.syncDecorations(item, onScreen, view.scale, extent); this.syncText(item, onScreen, view.scale); this.syncSprite(item, onScreen, size, shaders); @@ -322,6 +328,7 @@ export abstract class WorldViewport extends Widget { this.syncTilemap(item, onScreen); if (!onScreen) continue; this.visibleCount++; + if (hidden) continue; drawItem( node, item, diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 526104b2..43665379 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -10,6 +10,7 @@ import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands' import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands'; import type { ContainerAsset } from '../../src/core/model/Asset'; import { findAsset, findObjectHolder, objectHolders } from '../../src/core/model/Project'; +import { defaultSceneVisibility } from '../../src/core/model/sceneVisibility'; import { instanceChildId } from '../../src/core/model/instance'; import { findUsages } from '../../src/core/model/usages'; import { makeBehavior, makeSpriteScene, place } from '../helpers'; @@ -154,6 +155,7 @@ describe('CommandStack', () => { symbolScale: 1, playFrom: 'start' as const, gameScale: 1, + sceneVisibility: defaultSceneVisibility(), }; const stack = new CommandStack(project); let failing = false; diff --git a/tests/editor/scene-visibility.test.ts b/tests/editor/scene-visibility.test.ts new file mode 100644 index 00000000..43931f01 --- /dev/null +++ b/tests/editor/scene-visibility.test.ts @@ -0,0 +1,108 @@ +/** + * What the scene view shows of each placeable type – symbol, name, a collider's outline. It lives + * in the project like the game scale: saved, but never an undo step. + */ +import { describe, expect, it } from 'vitest'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { + defaultSceneVisibility, + sceneVisibilityOf, + shows, + VISIBILITY_TYPES, + visibilityParts, +} from '../../src/core/model/sceneVisibility'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { EditorState } from '../../src/editor/state'; + +function loaded() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + return state; +} + +describe('scene visibility', () => { + it('lists every placeable type, Behavior to Tilemap', () => { + expect(VISIBILITY_TYPES).toEqual([ + 'behavior', + 'camera', + 'collider', + 'container', + 'particles', + 'rigidbody', + 'sprite', + 'text', + 'tilemap', + ]); + }); + + it('gives a symbol switch only to types with a symbol, and an outline switch to what collides', () => { + expect(visibilityParts('behavior')).toEqual(['symbol', 'label']); + expect(visibilityParts('collider')).toEqual(['symbol', 'label', 'shape']); + expect(visibilityParts('sprite')).toEqual(['label']); + expect(visibilityParts('tilemap')).toEqual(['label', 'shape']); + }); + + it('shows what it always showed in a new project and in one written before there was any', () => { + for (const visibility of [ + createEmptyProject('p', 'P').sceneVisibility, + deserializeProject({ id: 'p', name: 'P', assets: [] }).sceneVisibility, + ]) { + expect(shows(visibility, 'behavior', 'symbol')).toBe(true); + expect(shows(visibility, 'behavior', 'label')).toBe(true); + expect(shows(visibility, 'collider', 'shape')).toBe(true); + expect(shows(visibility, 'tilemap', 'shape')).toBe(true); + expect(shows(visibility, 'sprite', 'label')).toBe(false); + expect(shows(visibility, 'sprite', 'shape')).toBe(true); + } + }); + + it('survives a save and a load', () => { + const project = createEmptyProject('p', 'P'); + project.sceneVisibility.collider.shape = false; + project.sceneVisibility.tilemap.shape = false; + project.sceneVisibility.rigidbody.label = false; + project.sceneVisibility.sprite.label = true; + const back = deserializeProject(JSON.parse(JSON.stringify(serializeProject(project)))).sceneVisibility; + expect(back.collider.shape).toBe(false); + expect(back.tilemap.shape).toBe(false); + expect(back.rigidbody.label).toBe(false); + expect(back.sprite.label).toBe(true); + expect(back.behavior).toEqual(defaultSceneVisibility().behavior); + }); + + it('reads untrusted JSON part by part, the rest as the defaults', () => { + const read = sceneVisibilityOf({ + sceneVisibility: { camera: { symbol: false, label: 'no' }, texture: { label: true }, text: 3 }, + }); + expect(read.camera).toEqual({ symbol: false, label: true, shape: true }); + expect(read.text).toEqual(defaultSceneVisibility().text); + expect('texture' in read).toBe(false); + expect(sceneVisibilityOf(null)).toEqual(defaultSceneVisibility()); + }); + + it('never hides what a type does not have to hide', () => { + const visibility = defaultSceneVisibility(); + visibility.sprite.symbol = false; + visibility.sprite.shape = false; + expect(shows(visibility, 'sprite', 'shape')).toBe(true); + visibility.behavior.shape = false; + expect(shows(visibility, 'behavior', 'shape')).toBe(true); + }); + + it('saves a switch without an undo step', () => { + const state = loaded(); + state.setSceneVisibility('collider', 'shape', false); + expect(state.project.sceneVisibility.collider.shape).toBe(false); + expect(state.saveStatus).toBe('dirty'); + expect(state.commands.canUndo).toBe(false); + }); + + it('keeps a switch flipped while playing after Stop', () => { + const state = loaded(); + state.enterPlay(); + state.setSceneVisibility('behavior', 'label', false); + expect(state.project.sceneVisibility.behavior.label).toBe(false); + state.exitPlay(); + expect(state.project.sceneVisibility.behavior.label).toBe(false); + }); +}); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index 788df767..2f767cc2 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -10,7 +10,7 @@ import { } from '../../src/editor/panels/assetIcons'; import { HEADER_ICON_SVG } from '../../src/editor/panels/headerIcons'; import { ARROW_ICON_SVG, KIND_ICON_SVG, ROW_ACTION_ICON_SVG } from '../../src/editor/panels/markIcons'; -import { TOOLBAR_ICON_SVG } from '../../src/editor/panels/toolbarIcons'; +import { TOOLBAR_ICON_SVG, VISIBILITY_ICON_SVG } from '../../src/editor/panels/toolbarIcons'; import { UI_ICON_SVG } from '../../src/ui/icons'; import { tintSvg } from '../../src/ui/svg'; import { SYMBOL_SVG } from '../../src/viewport/symbolIcons'; @@ -48,6 +48,7 @@ describe('icon files', () => { ['plugins tab', PLUGINS_ICON_SVG], ['user management tab', USERS_ICON_SVG], ...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]), + ...Object.entries(VISIBILITY_ICON_SVG).map(([name, svg]) => [`visibility ${name}`, svg]), ...Object.entries(HEADER_ICON_SVG).map(([name, svg]) => [`header ${name}`, svg]), ...Object.entries(UI_ICON_SVG).map(([name, svg]) => [`ui ${name}`, svg]), ...Object.entries(KIND_ICON_SVG).map(([name, svg]) => [`kind ${name}`, svg]), @@ -96,8 +97,21 @@ describe('icon files', () => { }); it('has one icon per button of the top bar, each its own picture (#183)', () => { - expect(Object.keys(TOOLBAR_ICON_SVG).sort()).toEqual(['menu', 'pause', 'play', 'redo', 'stop', 'undo']); - expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(6); + expect(Object.keys(TOOLBAR_ICON_SVG).sort()).toEqual([ + 'eye', + 'menu', + 'pause', + 'play', + 'redo', + 'stop', + 'undo', + ]); + expect(new Set(Object.values(TOOLBAR_ICON_SVG)).size).toBe(7); + }); + + it('has one picture per switch behind the eye', () => { + expect(Object.keys(VISIBILITY_ICON_SVG).sort()).toEqual(['label', 'shape', 'symbol']); + expect(new Set(Object.values(VISIBILITY_ICON_SVG)).size).toBe(3); }); // The bar carries no captions any more, so a picture shared with a tab or an asset type would be diff --git a/tools/e2e/46-topbar.mjs b/tools/e2e/46-topbar.mjs index c7cd5b24..ae89741b 100644 --- a/tools/e2e/46-topbar.mjs +++ b/tools/e2e/46-topbar.mjs @@ -167,5 +167,136 @@ export async function topbar(t) { Math.abs(restored.icon - 18) < 1e-9, JSON.stringify({ opened, scaled, restored }), ); + + // 46g. The eye beside redo: every placeable type with a switch for its symbol and its name, and a + // outline of a collider and of a tilemap's solid cells besides. Sprite, Text and Tilemap have no symbol, so that switch is + // greyed out. Real clicks flip the scene view, and the switches are saved but no undo step. + await json('(__gm.showTab("scene"), true)'); + const firstOf = (type) => + json(`(() => { + const p = __gm.state.project; + const scene = p.assets.find((a) => a.id === p.activeSceneId); + const o = (scene?.objects ?? []).find((o) => p.assets.find((a) => a.id === o.assetId)?.type === ${JSON.stringify(type)} && o.enabled !== false); + return o ? o.id : null; + })()`); + const ids = { + sprite: await firstOf('sprite'), + collider: await firstOf('collider'), + behavior: await firstOf('behavior'), + tilemap: 'vis-map-obj', + }; + // A map of its own with one solid row, laid over the sprite so it is on screen: the seed has none. + await t.mutate(` + const scene = __gm.state.activeScene; + const assets = __gm.state.project.assets; + const at = scene.objects.find((o) => o.id === ${JSON.stringify(ids.sprite)}).position; + assets.push({ id: 'vis-wall', type: 'tile', name: 'Vis wall', props: { kind: 'none', solid: true }, pictures: [] }); + assets.push({ id: 'vis-map', type: 'tilemap', name: 'Vis map', props: { columns: 3, rows: 1, tileSize: 32 }, + layers: [{ id: 'vis-layer', name: 'Layer 1', cells: { '0,0': 'vis-wall', '1,0': 'vis-wall', '2,0': 'vis-wall' } }] }); + const o = structuredClone(scene.objects[0]); + Object.assign(o, { id: 'vis-map-obj', assetId: 'vis-map', name: 'Vis map', position: { ...at }, + parentId: '', overrides: {}, instanceEdits: {} }); + scene.objects.push(o); + __gm.state.select(null)`); + const undoBefore = await json('__gm.state.commands.canUndo'); + await clickToolbar('visibility'); + const popup = await json('__gm.visibilityPopup()'); + const entry = (list, type, part) => list?.find((e) => e.type === type && e.part === part); + const types = [...new Set((popup ?? []).map((e) => e.type))]; + check( + 'the eye lists every placeable type, Symbol greyed out where there is none and Shape on collider and tilemap', + JSON.stringify(types) === + JSON.stringify([ + 'behavior', + 'camera', + 'collider', + 'container', + 'particles', + 'rigidbody', + 'sprite', + 'text', + 'tilemap', + ]) && + entry(popup, 'sprite', 'symbol')?.enabled === false && + entry(popup, 'tilemap', 'label')?.enabled === true && + entry(popup, 'sprite', 'label')?.on === false && + entry(popup, 'collider', 'shape')?.on === true && + entry(popup, 'tilemap', 'shape')?.on === true && + popup.filter((e) => e.part === 'shape').length === 2, + JSON.stringify(popup), + ); + const flip = async (type, part) => { + const at = entry(await json('__gm.visibilityPopup()'), type, part); + await t.click(at.x, at.y); + await sleep(150); + }; + const labelIds = async () => (await json('__gm.sceneLabels()')).map((l) => l.id); + const mapLooks = async () => ({ + solids: (await json(`__gm.tilemap.solids(${JSON.stringify(ids.tilemap)})`))?.visible ?? null, + drawn: (await json(`__gm.tilemap.info(${JSON.stringify(ids.tilemap)})`)) !== null, + }); + const before = { + shape: await json(`__gm.sceneShape(${JSON.stringify(ids.collider)})`), + labels: await labelIds(), + icon: (await json(`__gm.sceneIcon(${JSON.stringify(ids.behavior)})`))?.visible, + map: await mapLooks(), + }; + await flip('collider', 'shape'); + await flip('tilemap', 'shape'); + await flip('behavior', 'label'); + await flip('behavior', 'symbol'); + await flip('sprite', 'label'); + const after = { + shape: await json(`__gm.sceneShape(${JSON.stringify(ids.collider)})`), + labels: await labelIds(), + icon: (await json(`__gm.sceneIcon(${JSON.stringify(ids.behavior)})`))?.visible, + popup: await json('__gm.visibilityPopup()'), + saved: await json('__gm.state.project.sceneVisibility'), + map: await mapLooks(), + }; + await shot('46g-visibility'); + check( + "a tilemap's Shape hides the outline of its solid cells and leaves the map itself drawn", + before.map.solids === true && + before.map.drawn === true && + after.map.solids === false && + after.map.drawn === true && + after.saved.tilemap.shape === false, + JSON.stringify({ before: before.map, after: after.map }), + ); + check( + 'switching hides the collider outline and the behavior icon and name, and puts the sprite name on', + before.shape?.visible === true && + after.shape?.visible === false && + after.shape?.clickable === false && + before.icon === true && + after.icon === false && + before.labels.includes(ids.behavior) && + !after.labels.includes(ids.behavior) && + !before.labels.includes(ids.sprite) && + after.labels.includes(ids.sprite) && + entry(after.popup, 'collider', 'shape')?.on === false, + JSON.stringify({ ids, before, after: { ...after, popup: undefined } }), + ); + check( + 'the switches go into the project and not onto the undo stack', + after.saved.collider.shape === false && + after.saved.behavior.label === false && + after.saved.sprite.label === true && + (await json('__gm.state.commands.canUndo')) === undoBefore, + JSON.stringify({ saved: after.saved, undoBefore }), + ); + // Handed back as found, for whatever runs after this. + await flip('collider', 'shape'); + await flip('tilemap', 'shape'); + await flip('behavior', 'label'); + await flip('behavior', 'symbol'); + await flip('sprite', 'label'); + await press('Escape'); + await t.mutate(` + const scene = __gm.state.activeScene; + scene.objects.splice(scene.objects.findIndex((o) => o.id === 'vis-map-obj'), 1); + const assets = __gm.state.project.assets; + for (const id of ['vis-map', 'vis-wall']) assets.splice(assets.findIndex((a) => a.id === id), 1)`); await t.saved(); }