Let the scene view hide symbols, names and shapes per asset kind
game-maker / gate (push) Successful in 3m15s
game-maker / suite (push) Failing after 9m33s

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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-26 23:32:58 +02:00
1 parent 66868de10a
commit 6015869297
19 files changed
+547 -25

No files matched your search

+1 -1
View File
@@ -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
+4
View File
@@ -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(),
};
}
+71
View File
@@ -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<VisibilityPart, boolean>;
export type SceneVisibility = Record<PlaceableAssetType, TypeVisibility>;
/** 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<string, unknown>)[type];
if (!entry || typeof entry !== 'object') continue;
for (const part of Object.keys(out[type]) as VisibilityPart[]) {
const value = (entry as Record<string, unknown>)[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<Record<string, TypeVisibility>>)[type];
if (!entry) return part !== 'label';
if (!visibilityParts(type as PlaceableAssetType).includes(part)) return part === 'shape';
return entry[part];
}
+2
View File
@@ -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;
}
+8
View File
@@ -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),
+100
View File
@@ -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<VisibilityPart, string> = { 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<string, Button>();
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;
}
+15 -5
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="eye" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-7 0 C-4.5 -4.75, 4.5 -4.75, 7 0 C4.5 4.75, -4.5 4.75, -7 0 Z" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="0" cy="0" r="2.25" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 289 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" id="show-label" viewBox="-8 -8 16 16" width="16" height="16">
<rect x="-6.75" y="-4.25" width="13.5" height="8.5" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M-3.75 0 H3.75" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 311 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" id="show-shape" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-6 -2 V-6 H-2 M2 -6 H6 V-2 M6 2 V6 H2 M-2 6 H-6 V2" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 229 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" id="show-symbol" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M0 -6.5 L1.8 -1.8 L6.5 0 L1.8 1.8 L0 6.5 L-1.8 1.8 L-6.5 0 L-1.8 -1.8 Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 217 B

+13 -1
View File
@@ -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<VisibilityPart, string> = {
symbol: showSymbol,
label: showLabel,
shape: showShape,
};
+11
View File
@@ -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`).
+40 -12
View File
@@ -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();
+10 -3
View File
@@ -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,
+2
View File
@@ -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;
+108
View File
@@ -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);
});
});
+17 -3
View File
@@ -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
+131
View File
@@ -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();
}