Show each camera only its layers and lay overlay cameras on top (#302)
game-maker / gate (push) Successful in 3m2s
game-maker / suite (push) Successful in 14m26s

A camera could only show the whole scene, so a HUD drawn in the world
would run through a camera shader (#299) with everything else. Render
layers say which cameras see an object, and overlay cameras draw their
own picture over the main one.

- The project has a list of up to 16 render layers, edited in Project
  settings next to the collision layers. It is a list of its own, read
  by the same rules (`layerNamesOf`).
- Sprites, texts, tilemaps, particle sources and containers carry a row
  `Render layer` under their switches. Its default `Inherit` takes the
  parent's layer, at the top the first one.
- A camera has `Sees` (all layers by default) and `Overlay`. The main
  camera is the first one switched on without Overlay, so two plain
  cameras still draw through the first one only.
- Every view through a camera keeps only what that camera sees and lays
  one see-through view per overlay camera over itself. That view is a
  `WorldViewport` of its own, handed its view and frame from below.
- An overlay is fitted into the main camera's whole picture without
  distortion. One of the same size and zoom covers it exactly.
- `z` sorts within one camera only. The pictures lie on top of each
  other as a whole, which is what a shader per camera (#299) needs.
- The Game tab's preview now collects the scene once per redraw. Before
  it collected it twice, with the cameras it would have been five times.
- `SetCollisionLayersCommand` became `SetLayerNamesCommand`, which
  takes the list it sets.

Follow-ups: #304 (the mouse through a chosen camera) and #305 (change
the render layer at run time).

Tests:
- Gate: 226 files, 3487 tests (3480 + 7)
- e2e 63 (new): 4 checks. A green overlay sprite at z -10 covers a
  red main sprite at z 1000, in the preview and in the running game.
  A `UI` sprite in front of the main camera and a `Default` one in
  front of the overlay stay away. Without Overlay the second camera
  draws nothing. The settings dialog adds a render layer at the end.
- Counter-checks: inheriting from parents switched off fails 1 unit
  test. Overlays never drawn fails the two overlay checks in e2e 63.
- e2e total 1720 (1716 + 4), computed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 15:32:52 +02:00
1 parent f03ca0d382
commit b2326085ed
33 files changed
+800 -113

No files matched your search

+1 -1
View File
@@ -45,7 +45,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: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1716 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1720 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+22 -10
View File
@@ -1,4 +1,4 @@
import { collisionLayersOf } from '../model/collisionLayers'; import { layerNamesOf } from '../model/collisionLayers';
import { import {
DEFAULT_GAME_LINK_ZOOM, DEFAULT_GAME_LINK_ZOOM,
DEFAULT_WHILE_PLAYING, DEFAULT_WHILE_PLAYING,
@@ -26,24 +26,36 @@ export class RenameProjectCommand implements Command {
} }
} }
export type LayerList = 'collisionLayers' | 'renderLayers';
const LAYER_LIST_LABEL: Record<LayerList, string> = {
collisionLayers: 'Collision layers',
renderLayers: 'Render layers',
};
/** /**
* The named collision layers of the project (#152). Colliders name a layer by number, so removing * The named collision layers (#152) or render layers (#302) of the project. Objects name a layer by
* one shifts what every collider below it points at – which is why the dialog only ever hands over * number, so removing one shifts what every object below it points at – which is why the dialog only
* a whole list, and why the list is read through `collisionLayersOf` here as well. * ever hands over a whole list, and why the list is read through `layerNamesOf` here as well.
*/ */
export class SetCollisionLayersCommand implements Command { export class SetLayerNamesCommand implements Command {
readonly label = 'Collision layers'; readonly label: string;
private previous: string[] = []; private previous: string[] = [];
constructor(private readonly names: readonly string[]) {} constructor(
private readonly list: LayerList,
private readonly names: readonly string[],
) {
this.label = LAYER_LIST_LABEL[list];
}
execute(project: Project): void { execute(project: Project): void {
this.previous = project.collisionLayers; this.previous = project[this.list];
project.collisionLayers = collisionLayersOf({ collisionLayers: this.names }); project[this.list] = layerNamesOf(this.names);
} }
undo(project: Project): void { undo(project: Project): void {
project.collisionLayers = this.previous; project[this.list] = this.previous;
} }
} }
+6
View File
@@ -48,6 +48,11 @@ export interface Project {
* which is also what every collider starts on, so nothing collides differently than it did. * which is also what every collider starts on, so nothing collides differently than it did.
*/ */
collisionLayers: string[]; collisionLayers: string[];
/**
* The named render layers (#302), read the same way: which cameras see an object. A project
* written before there were any has just "Default", which every camera sees.
*/
renderLayers: string[];
/** /**
* Size of the scene view's symbol icons as a factor, 0.1 to 4 (see `symbolScale.ts`). A project * Size of the scene view's symbol icons as a factor, 0.1 to 4 (see `symbolScale.ts`). A project
* written before there was one reads as 1, the size the icons always had. * written before there was one reads as 1, the size the icons always had.
@@ -75,6 +80,7 @@ export function createEmptyProject(id: string, name: string): Project {
startSceneId: '', startSceneId: '',
plugins: [], plugins: [],
collisionLayers: [DEFAULT_LAYER_NAME], collisionLayers: [DEFAULT_LAYER_NAME],
renderLayers: [DEFAULT_LAYER_NAME],
symbolScale: DEFAULT_SYMBOL_SCALE, symbolScale: DEFAULT_SYMBOL_SCALE,
playFrom: DEFAULT_PLAY_FROM, playFrom: DEFAULT_PLAY_FROM,
whilePlaying: DEFAULT_WHILE_PLAYING, whilePlaying: DEFAULT_WHILE_PLAYING,
+5 -1
View File
@@ -33,7 +33,11 @@ export interface CollisionFilter {
* is dropped. A project written before there were layers has exactly one, "Default". * is dropped. A project written before there were layers has exactly one, "Default".
*/ */
export function collisionLayersOf(raw: unknown): string[] { export function collisionLayersOf(raw: unknown): string[] {
const list = (raw as { collisionLayers?: unknown } | null)?.collisionLayers; return layerNamesOf((raw as { collisionLayers?: unknown } | null)?.collisionLayers);
}
/** A list of layer names read off untrusted JSON, by the rules of `collisionLayersOf`. */
export function layerNamesOf(list: unknown): string[] {
const names = (Array.isArray(list) ? list : []).slice(0, MAX_COLLISION_LAYERS); const names = (Array.isArray(list) ? list : []).slice(0, MAX_COLLISION_LAYERS);
const read = names.map((name, i) => (typeof name === 'string' && name !== '' ? name : layerFallback(i))); const read = names.map((name, i) => (typeof name === 'string' && name !== '' ? name : layerFallback(i)));
return read.length > 0 ? read : [DEFAULT_LAYER_NAME]; return read.length > 0 ? read : [DEFAULT_LAYER_NAME];
+52
View File
@@ -0,0 +1,52 @@
/**
* Render layers (#302): which cameras see an object. A drawn object sits on one layer – its own
* choice, else its parent's, at the top `Default` – and a camera shows only the layers it `Sees`.
* They are a list of their own beside the collision layers (#152): what meets and what is seen are
* two questions. The names and masks follow the same rules, 16 at most, so the helpers are shared.
*/
import type { PropertyBag } from './Properties';
import { layerMaskOf, layerNamesOf, MAX_COLLISION_LAYERS, maskHas } from './collisionLayers';
export const RENDER_LAYER_KEY = 'renderLayer';
export const SEES_KEY = 'sees';
export const OVERLAY_KEY = 'overlay';
/** The value of an object that made no choice of its own: it is on its parent's layer. */
export const INHERIT_LAYER = -1;
export const INHERIT_LABEL = 'Inherit';
/** The names as stored; a project written before there were render layers has one, "Default". */
export function renderLayersOf(raw: unknown): string[] {
return layerNamesOf((raw as { renderLayers?: unknown } | null)?.renderLayers);
}
/** An object's own choice: a layer number, or `INHERIT_LAYER` for none – and for anything unreadable. */
export function ownRenderLayer(value: unknown): number {
if (typeof value !== 'number' && !(typeof value === 'string' && value.trim() !== '')) return INHERIT_LAYER;
const n = Math.trunc(Number(value));
return Number.isFinite(n) && n >= 0 && n < MAX_COLLISION_LAYERS ? n : INHERIT_LAYER;
}
/** The layer an object is on: the first own choice going up its parents, layer 0 when none has one. */
export function renderLayerAlong<T>(
item: T,
props: (at: T) => PropertyBag,
parentOf: (at: T) => T | null,
): number {
for (let at: T | null = item; at; at = parentOf(at)) {
const own = ownRenderLayer(props(at)[RENDER_LAYER_KEY]);
if (own !== INHERIT_LAYER) return own;
}
return 0;
}
/** Whether a camera with these properties shows that layer; a camera without `Sees` shows them all. */
export function cameraSees(props: PropertyBag, layer: number): boolean {
return maskHas(layerMaskOf(props[SEES_KEY]), layer);
}
/** Whether a camera draws over the main one instead of being a candidate for it. */
export function isOverlayCamera(props: PropertyBag): boolean {
return props[OVERLAY_KEY] === true;
}
+36 -2
View File
@@ -23,6 +23,7 @@ import {
} from './kinds'; } from './kinds';
import { findAssetOf, type Project } from '../model/Project'; import { findAssetOf, type Project } from '../model/Project';
import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers'; import { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers';
import { INHERIT_LAYER, OVERLAY_KEY, RENDER_LAYER_KEY, SEES_KEY } from '../model/renderLayers';
import { import {
TILE_BOUNCINESS_KEY, TILE_BOUNCINESS_KEY,
TILE_COLLISION_SCALE_KEY, TILE_COLLISION_SCALE_KEY,
@@ -109,7 +110,8 @@ import { Theme } from '../../ui';
* properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging * properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging
* produces fractions, and the field rounds the value away rather than hiding it. Without * produces fractions, and the field rounds the value away rather than hiding it. Without
* `assetTypes` an asset picker offers every placeable asset; `uploadedOnly` keeps drawn pictures out * `assetTypes` an asset picker offers every placeable asset; `uploadedOnly` keeps drawn pictures out
* of a texture picker (#186). * of a texture picker (#186). `renderLayers` makes a layer row pick from the project's render layers
* instead of its collision layers (#302), with "Inherit" as the first choice of a single layer.
*/ */
type Extras<K extends FlatKind> = K extends 'number' type Extras<K extends FlatKind> = K extends 'number'
? { min?: number; max?: number; step?: number; decimals?: number } ? { min?: number; max?: number; step?: number; decimals?: number }
@@ -119,6 +121,8 @@ type Extras<K extends FlatKind> = K extends 'number'
? { assetTypes?: readonly AssetType[] } ? { assetTypes?: readonly AssetType[] }
: K extends 'texture' : K extends 'texture'
? { uploadedOnly?: boolean } ? { uploadedOnly?: boolean }
: K extends 'layer' | 'layers'
? { renderLayers?: boolean }
: unknown; : unknown;
/** /**
@@ -377,7 +381,33 @@ export function isSortProp(key: string): boolean {
return key === SORT_WITH_PARENT_KEY; return key === SORT_WITH_PARENT_KEY;
} }
const COMMON_KEYS = new Set([...COMMON_PROPS, ...TRANSFORM_PROPS, ...SORT_PROPS].map((d) => d.key)); /**
* Which cameras see a drawn object (#302). Without a choice of its own it is on its parent's layer,
* so a HUD container on `UI` takes everything in it along.
*/
export const RENDER_LAYER_ROW: FlatDef<'layer'> = {
key: RENDER_LAYER_KEY,
label: 'Render layer',
kind: 'layer',
default: INHERIT_LAYER,
renderLayers: true,
};
const RENDER_LAYER_TYPES: ReadonlySet<AssetType> = new Set([
'sprite',
'text',
'particles',
'container',
'tilemap',
]);
function renderLayerPropsOf(type: AssetType): PropertySchema {
return RENDER_LAYER_TYPES.has(type) ? [RENDER_LAYER_ROW] : [];
}
const COMMON_KEYS = new Set(
[...COMMON_PROPS, ...TRANSFORM_PROPS, ...SORT_PROPS, RENDER_LAYER_ROW].map((d) => d.key),
);
/** The switches, the transform and sort rows: reserved names, shown apart from the type's own properties. */ /** The switches, the transform and sort rows: reserved names, shown apart from the type's own properties. */
export function isCommonProp(key: string): boolean { export function isCommonProp(key: string): boolean {
@@ -421,6 +451,9 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1, decimals: 0 }, { key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1, decimals: 0 },
{ key: 'height', label: 'View height', kind: 'number', default: 360, min: 16, step: 1, decimals: 0 }, { key: 'height', label: 'View height', kind: 'number', default: 360, min: 16, step: 1, decimals: 0 },
{ key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 }, { key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 },
{ key: SEES_KEY, label: 'Sees', kind: 'layers', default: ALL_LAYERS, renderLayers: true },
// Off: a candidate for the main camera (#5). On: drawn over the main one, see-through (#302).
{ key: OVERLAY_KEY, label: 'Overlay', kind: 'boolean', default: false },
], ],
sprite: [ sprite: [
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
@@ -897,6 +930,7 @@ const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
...COMMON_PROPS, ...COMMON_PROPS,
...(hasTransformProps(type) ? TRANSFORM_PROPS : []), ...(hasTransformProps(type) ? TRANSFORM_PROPS : []),
...sortPropsOf(type), ...sortPropsOf(type),
...renderLayerPropsOf(type),
...ASSET_SCHEMAS[type], ...ASSET_SCHEMAS[type],
] ]
: ASSET_SCHEMAS[type], : ASSET_SCHEMAS[type],
+2
View File
@@ -45,6 +45,7 @@ import type {
import { parseCellKey } from '../model/tilemap'; import { parseCellKey } from '../model/tilemap';
import { pluginsOf } from './projectPlugins'; import { pluginsOf } from './projectPlugins';
import { collisionLayersOf } from '../model/collisionLayers'; import { collisionLayersOf } from '../model/collisionLayers';
import { renderLayersOf } from '../model/renderLayers';
import { gameLinkZoomOf, gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings'; import { gameLinkZoomOf, gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings';
import { sceneVisibilityOf } from '../model/sceneVisibility'; import { sceneVisibilityOf } from '../model/sceneVisibility';
import { symbolScaleOf } from '../model/symbolScale'; import { symbolScaleOf } from '../model/symbolScale';
@@ -284,6 +285,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
// is actually running. // is actually running.
plugins: pluginsOf(r), plugins: pluginsOf(r),
collisionLayers: collisionLayersOf(r), collisionLayers: collisionLayersOf(r),
renderLayers: renderLayersOf(r),
symbolScale: symbolScaleOf(r), symbolScale: symbolScaleOf(r),
playFrom: playFromOf(r), playFrom: playFromOf(r),
whilePlaying: whilePlayingOf(r), whilePlaying: whilePlayingOf(r),
+6 -3
View File
@@ -3,8 +3,8 @@
*/ */
import { import {
RenameProjectCommand, RenameProjectCommand,
SetCollisionLayersCommand,
SetGameLinkZoomCommand, SetGameLinkZoomCommand,
SetLayerNamesCommand,
SetPlayFromCommand, SetPlayFromCommand,
SetSymbolScaleCommand, SetSymbolScaleCommand,
SetWhilePlayingCommand, SetWhilePlayingCommand,
@@ -323,7 +323,7 @@ export class ProjectSession {
/** /**
* Name, the scene Play starts, how closely the panels follow it, the zoom of the game link, the * Name, the scene Play starts, how closely the panels follow it, the zoom of the game link, the
* symbol scale and the collision layers (#152) in one dialog * symbol scale, the collision layers (#152) and the render layers (#302) in one dialog
* (#183). Each is an undo step of its own – one each, and only for what actually changed. * (#183). Each is an undo step of its own – one each, and only for what actually changed.
*/ */
openProjectSettings(): ProjectSettingsDialog { openProjectSettings(): ProjectSettingsDialog {
@@ -334,6 +334,7 @@ export class ProjectSession {
whilePlaying: project.whilePlaying, whilePlaying: project.whilePlaying,
gameLinkZoom: project.gameLinkZoom, gameLinkZoom: project.gameLinkZoom,
collisionLayers: [...project.collisionLayers], collisionLayers: [...project.collisionLayers],
renderLayers: [...project.renderLayers],
symbolScale: project.symbolScale, symbolScale: project.symbolScale,
}; };
return this.settingsDialog.open( return this.settingsDialog.open(
@@ -341,7 +342,9 @@ export class ProjectSession {
const change = settingsChanges(before, settings); const change = settingsChanges(before, settings);
if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name)); if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name));
if (change.collisionLayers !== undefined) if (change.collisionLayers !== undefined)
this.state.execute(new SetCollisionLayersCommand(change.collisionLayers)); this.state.execute(new SetLayerNamesCommand('collisionLayers', change.collisionLayers));
if (change.renderLayers !== undefined)
this.state.execute(new SetLayerNamesCommand('renderLayers', change.renderLayers));
if (change.symbolScale !== undefined) if (change.symbolScale !== undefined)
this.state.execute(new SetSymbolScaleCommand(change.symbolScale)); this.state.execute(new SetSymbolScaleCommand(change.symbolScale));
if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom)); if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom));
+100 -63
View File
@@ -1,6 +1,6 @@
import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers';
import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale'; import { clampSymbolScale, SYMBOL_SCALE_MAX, SYMBOL_SCALE_MIN } from '../core/model/symbolScale';
import { Button, HStack, Label, Menu, NumberField, TextField, Theme, VStack } from '../ui'; import { Button, HStack, Label, Menu, NumberField, TextField, Theme, VStack, type Widget } from '../ui';
import { import {
GAME_LINK_ZOOMS, GAME_LINK_ZOOMS,
type GameLinkZoom, type GameLinkZoom,
@@ -17,6 +17,7 @@ export interface ProjectSettings {
whilePlaying: WhilePlaying; whilePlaying: WhilePlaying;
gameLinkZoom: GameLinkZoom; gameLinkZoom: GameLinkZoom;
collisionLayers: string[]; collisionLayers: string[];
renderLayers: string[];
symbolScale: number; symbolScale: number;
} }
@@ -33,6 +34,7 @@ export function settingsChanges(
): { ): {
name?: string; name?: string;
collisionLayers?: string[]; collisionLayers?: string[];
renderLayers?: string[];
symbolScale?: number; symbolScale?: number;
playFrom?: PlayFrom; playFrom?: PlayFrom;
whilePlaying?: WhilePlaying; whilePlaying?: WhilePlaying;
@@ -45,6 +47,9 @@ export function settingsChanges(
...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers) ...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers)
? {} ? {}
: { collisionLayers: [...after.collisionLayers] }), : { collisionLayers: [...after.collisionLayers] }),
...(JSON.stringify(after.renderLayers) === JSON.stringify(before.renderLayers)
? {}
: { renderLayers: [...after.renderLayers] }),
...(symbolScale === before.symbolScale ? {} : { symbolScale }), ...(symbolScale === before.symbolScale ? {} : { symbolScale }),
...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }), ...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }),
...(after.whilePlaying === before.whilePlaying ? {} : { whilePlaying: after.whilePlaying }), ...(after.whilePlaying === before.whilePlaying ? {} : { whilePlaying: after.whilePlaying }),
@@ -68,30 +73,87 @@ const GAME_LINK_ZOOM_LABEL: Record<GameLinkZoom, string> = {
fit: 'Fit', fit: 'Fit',
}; };
/**
* One list of layer names with its rows and its add button. Objects name a layer by its *number*,
* so a layer taken out of the middle would silently move every object below it onto another one;
* only the last layer can be removed, and the list is otherwise grown at the end.
*/
class LayerNameRows {
readonly names: string[];
readonly rows = new VStack({ gap: Theme.gap });
readonly addButton: Button;
constructor(
names: readonly string[],
addLabel: string,
private readonly label: (widget: string) => Widget,
private readonly changed: () => void,
) {
this.names = [...names];
this.addButton = new Button(addLabel, { onClick: () => this.add() });
}
private add(): void {
if (this.names.length >= MAX_COLLISION_LAYERS) return;
this.names.push(layerFallback(this.names.length));
this.build();
}
build(): void {
this.rows.removeChildren();
for (const [i, name] of this.names.entries()) {
const field = new TextField(name, {
onChange: (value) => {
// An empty name would leave the layer nameless, which `layerNamesOf` reads back as its
// number – so the field shows that number rather than staying blank.
const name = String(value).trim() || layerFallback(i);
this.names[i] = name;
if (name !== String(value)) field.setValue(name);
},
});
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(this.label(String(i)));
row.add(field, 1);
// Only the last one: removing any other would move every object below it onto a new layer.
if (i > 0 && i === this.names.length - 1)
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
tooltip: 'Remove layer',
onClick: () => {
this.names.pop();
this.build();
},
}),
);
this.rows.add(row);
}
this.addButton.setEnabled(this.names.length < MAX_COLLISION_LAYERS);
this.changed();
}
}
/** /**
* Everything about the project that is not an asset (#183): its name, which scene Play starts and * Everything about the project that is not an asset (#183): its name, which scene Play starts and
* how closely the panels follow it (#265), the zoom of the game link (#279), the * how closely the panels follow it (#265), the zoom of the game link (#279), the size of the scene
* size of the scene view's symbol icons, and the collision layers (#152). The top bar carries icons * view's symbol icons, the collision layers (#152) and the render layers (#302). The top bar carries
* only, so this is the one place they are edited. * icons only, so this is the one place they are edited.
*
* Colliders name a layer by its *number*, so a layer taken out of the middle would silently move
* every collider below it onto another one; only the last layer can be removed, and the list is
* otherwise grown at the end.
*/ */
export class ProjectSettingsDialog extends FormDialog { export class ProjectSettingsDialog extends FormDialog {
private projectName: string; private projectName: string;
private playFrom: PlayFrom; private playFrom: PlayFrom;
private whilePlaying: WhilePlaying; private whilePlaying: WhilePlaying;
private gameLinkZoom: GameLinkZoom; private gameLinkZoom: GameLinkZoom;
private layers: string[];
private symbolScale: number; private symbolScale: number;
private readonly nameField: TextField; private readonly nameField: TextField;
private readonly playFromButton: Button; private readonly playFromButton: Button;
private readonly whilePlayingButton: Button; private readonly whilePlayingButton: Button;
private readonly gameLinkZoomButton: Button; private readonly gameLinkZoomButton: Button;
private readonly symbolScaleField: NumberField; private readonly symbolScaleField: NumberField;
private readonly rows = new VStack({ gap: Theme.gap }); private readonly collisionLayers: LayerNameRows;
private readonly addButton: Button; private readonly renderLayers: LayerNameRows;
constructor(settings: ProjectSettings, onApply: (settings: ProjectSettings) => void) { constructor(settings: ProjectSettings, onApply: (settings: ProjectSettings) => void) {
super(340, 'Project settings'); super(340, 'Project settings');
@@ -99,8 +161,16 @@ export class ProjectSettingsDialog extends FormDialog {
this.playFrom = settings.playFrom; this.playFrom = settings.playFrom;
this.whilePlaying = settings.whilePlaying; this.whilePlaying = settings.whilePlaying;
this.gameLinkZoom = settings.gameLinkZoom; this.gameLinkZoom = settings.gameLinkZoom;
this.layers = [...settings.collisionLayers];
this.symbolScale = clampSymbolScale(settings.symbolScale); this.symbolScale = clampSymbolScale(settings.symbolScale);
// A row more or less makes the dialog taller or shorter; `resize` only lays out on a change,
// which is exactly what happened here.
const fit = () => {
const size = this.measure();
this.resize(size.w, size.h);
};
const label = (text: string) => this.fieldLabel(text);
this.collisionLayers = new LayerNameRows(settings.collisionLayers, 'Add layer', label, fit);
this.renderLayers = new LayerNameRows(settings.renderLayers, 'Add render layer', label, fit);
this.nameField = new TextField(this.projectName, { this.nameField = new TextField(this.projectName, {
placeholder: 'Project name', placeholder: 'Project name',
@@ -173,9 +243,13 @@ export class ProjectSettingsDialog extends FormDialog {
style: 'muted', style: 'muted',
}), }),
); );
this.content.add(this.rows); this.content.add(this.collisionLayers.rows);
this.addButton = new Button('Add layer', { onClick: () => this.addLayer() }); this.content.add(this.formRow(this.collisionLayers.addButton));
this.content.add(this.formRow(this.addButton));
this.content.add(new Label('Render layers', { style: 'bodyBold' }));
this.content.add(new Label('A camera shows only the layers it sees.', { style: 'muted' }));
this.content.add(this.renderLayers.rows);
this.content.add(this.formRow(this.renderLayers.addButton));
this.addButtons('Apply', 'primary', () => this.addButtons('Apply', 'primary', () =>
onApply({ onApply({
@@ -183,16 +257,23 @@ export class ProjectSettingsDialog extends FormDialog {
playFrom: this.playFrom, playFrom: this.playFrom,
whilePlaying: this.whilePlaying, whilePlaying: this.whilePlaying,
gameLinkZoom: this.gameLinkZoom, gameLinkZoom: this.gameLinkZoom,
collisionLayers: this.layers, collisionLayers: this.collisionLayers.names,
renderLayers: this.renderLayers.names,
symbolScale: this.symbolScale, symbolScale: this.symbolScale,
}), }),
); );
this.buildLayers(); this.collisionLayers.build();
this.renderLayers.build();
} }
/** The names as the fields stand (test hook). */ /** The collision layer names as the fields stand (test hook). */
layerNames(): string[] { layerNames(): string[] {
return [...this.layers]; return [...this.collisionLayers.names];
}
/** The render layer names as the fields stand (test hook). */
renderLayerNames(): string[] {
return [...this.renderLayers.names];
} }
/** Name, start-scene switch, panel preset, link zoom and symbol scale as they stand (test hook). */ /** Name, start-scene switch, panel preset, link zoom and symbol scale as they stand (test hook). */
@@ -278,48 +359,4 @@ export class ProjectSettingsDialog extends FormDialog {
})), })),
).openBelow(this.gameLinkZoomButton); ).openBelow(this.gameLinkZoomButton);
} }
private addLayer(): void {
if (this.layers.length >= MAX_COLLISION_LAYERS) return;
this.layers.push(layerFallback(this.layers.length));
this.buildLayers();
}
private buildLayers(): void {
this.rows.removeChildren();
for (const [i, name] of this.layers.entries()) {
const field = new TextField(name, {
onChange: (value) => {
// An empty name would leave the layer nameless, which `collisionLayersOf` reads back as
// its number – so the field shows that number rather than staying blank.
const name = String(value).trim() || layerFallback(i);
this.layers[i] = name;
if (name !== String(value)) field.setValue(name);
},
});
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
row.add(this.fieldLabel(String(i)));
row.add(field, 1);
// Only the last one: removing any other would move every collider below it onto a new layer.
if (i > 0 && i === this.layers.length - 1)
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
tooltip: 'Remove layer',
onClick: () => {
this.layers.pop();
this.buildLayers();
},
}),
);
this.rows.add(row);
}
this.addButton.setEnabled(this.layers.length < MAX_COLLISION_LAYERS);
// A row more or less makes the dialog taller or shorter; `resize` only lays out on a change,
// which is exactly what happened here.
const size = this.measure();
this.resize(size.w, size.h);
}
} }
+10
View File
@@ -107,6 +107,16 @@ export function sceneHooks({ state, editor }: HookContext) {
gameScreen: (id: string) => gameView.objectScreen(id), gameScreen: (id: string) => gameView.objectScreen(id),
gameRect: (id: string) => gameView.objectRect(id), gameRect: (id: string) => gameView.objectRect(id),
gameCenter: () => centreOf(gameView), gameCenter: () => centreOf(gameView),
/** What the main camera draws in the Game tab, back to front (#302). */
gameDrawOrder: () => gameView.drawOrder(),
/** The overlay cameras laid over it (#302): what each draws and its picture in global pixels. */
gameOverlays: () => {
const origin = gameView.getGlobalPosition();
return gameView.overlayInfo().map(({ rect, ...overlay }) => ({
...overlay,
rect: { ...rect, x: origin.x + rect.x, y: origin.y + rect.y },
}));
},
/** The game view's scale mode and what it came to, the view's box and the picker in its header (#225). */ /** The game view's scale mode and what it came to, the view's box and the picker in its header (#225). */
gameScale: () => { gameScale: () => {
const origin = gameView.getGlobalPosition(); const origin = gameView.getGlobalPosition();
+5
View File
@@ -291,6 +291,11 @@ export function uiHooks({ app, state, editor }: HookContext) {
project: [...state.project.collisionLayers], project: [...state.project.collisionLayers],
dialog: editor.projectSettingsDialog()?.layerNames() ?? null, dialog: editor.projectSettingsDialog()?.layerNames() ?? null,
}), }),
/** The same for the render layers (#302). */
renderLayers: () => ({
project: [...state.project.renderLayers],
dialog: editor.projectSettingsDialog()?.renderLayerNames() ?? null,
}),
/** The "Project settings" dialog (#183): name, start-scene switch and symbol scale as its fields stand. */ /** The "Project settings" dialog (#183): name, start-scene switch and symbol scale as its fields stand. */
projectSettings: () => editor.projectSettingsDialog()?.values() ?? null, projectSettings: () => editor.projectSettingsDialog()?.values() ?? null,
/** Where to click the start-scene switch inside that dialog. */ /** Where to click the start-scene switch inside that dialog. */
+4 -1
View File
@@ -29,6 +29,7 @@ import {
type VariableOwnerAsset, type VariableOwnerAsset,
type VariablesAsset, type VariablesAsset,
} from '../../core/model/Asset'; } from '../../core/model/Asset';
import { RENDER_LAYER_KEY } from '../../core/model/renderLayers';
import { import {
MakeListItemTypeCommand, MakeListItemTypeCommand,
MakeStructTypeCommand, MakeStructTypeCommand,
@@ -181,7 +182,9 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
return true; return true;
} }
const isSwitch = (key: string): boolean => COMMON_PROPS.some((def) => def.key === key); /** The switches, and the render layer (#302): both say whether the object is seen at all. */
const isSwitch = (key: string): boolean =>
key === RENDER_LAYER_KEY || COMMON_PROPS.some((def) => def.key === key);
/** The rows of `schema` that are shown, under `title` – which is left out with them. */ /** The rows of `schema` that are shown, under `title` – which is left out with them. */
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[], title: string): void { function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[], title: string): void {
+12 -6
View File
@@ -9,6 +9,7 @@ import {
maskLabel, maskLabel,
maskWith, maskWith,
} from '../../core/model/collisionLayers'; } from '../../core/model/collisionLayers';
import { INHERIT_LABEL, INHERIT_LAYER, ownRenderLayer } from '../../core/model/renderLayers';
import { import {
assetChoiceLabel, assetChoiceLabel,
assetRefChoices, assetRefChoices,
@@ -634,18 +635,23 @@ export function createField(
} }
case 'layer': { case 'layer': {
// The names live in the project, not in the row: renaming a layer renames it everywhere. // The names live in the project, not in the row: renaming a layer renames it everywhere.
const names = () => ctx.project().collisionLayers; const render = def.renderLayers === true;
const labelOf = (v: PropertyValue) => layerName(names(), layerIndexOf(v)); const names = () => (render ? ctx.project().renderLayers : ctx.project().collisionLayers);
const labelOf = (v: PropertyValue) => {
if (!render) return layerName(names(), layerIndexOf(v));
const own = ownRenderLayer(v);
return own === INHERIT_LAYER ? INHERIT_LABEL : layerName(names(), own);
};
const button = new Dropdown(labelOf(value)); const button = new Dropdown(labelOf(value));
button.onClick = () => { button.onClick = () => {
new Menu(names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) }))).openBelow( const layers = names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) }));
button, const inherit = { label: INHERIT_LABEL, onSelect: () => handlers.onChange(INHERIT_LAYER) };
); new Menu(render ? [inherit, ...layers] : layers).openBelow(button);
}; };
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) }; return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
} }
case 'layers': { case 'layers': {
const names = () => ctx.project().collisionLayers; const names = () => (def.renderLayers ? ctx.project().renderLayers : ctx.project().collisionLayers);
const labelOf = (v: PropertyValue) => maskLabel(layerMaskOf(v), names()); const labelOf = (v: PropertyValue) => maskLabel(layerMaskOf(v), names());
let mask = layerMaskOf(value); let mask = layerMaskOf(value);
const button = new Dropdown(labelOf(value)); const button = new Dropdown(labelOf(value));
+3
View File
@@ -9,6 +9,7 @@ import type { ValuePath } from '../../core/properties/valuePath';
import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands'; import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap'; import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap';
import { findUsages } from '../../core/model/usages'; import { findUsages } from '../../core/model/usages';
import { RENDER_LAYER_KEY } from '../../core/model/renderLayers';
import { import {
ClearOverrideCommand, ClearOverrideCommand,
MoveSceneObjectCommand, MoveSceneObjectCommand,
@@ -177,6 +178,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
if (asset.type === 'behavior') if (asset.type === 'behavior')
for (const def of schemaFor('behavior').filter((d) => !isCommonProp(d.key))) for (const def of schemaFor('behavior').filter((d) => !isCommonProp(d.key)))
propertyRow(host, holderId, objectId, asset, def, resolved); propertyRow(host, holderId, objectId, asset, def, resolved);
for (const def of schemaForObject(project, asset, object).filter((d) => d.key === RENDER_LAYER_KEY))
propertyRow(host, holderId, objectId, asset, def, resolved);
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION'); addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) { for (const axis of POSITION_AXES) {
+29 -2
View File
@@ -6,7 +6,13 @@ import { isSymbolType } from '../../core/model/AssetTypes';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
import type { World } from '../../runtime/World'; import type { World } from '../../runtime/World';
import { requestRender, Theme, textStyle, ui, crispText } from '../../ui'; import { requestRender, Theme, textStyle, ui, crispText } from '../../ui';
import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; import {
type ActiveCamera,
cameraOfItems,
cameraOfWorld,
overlaysOfItems,
overlaysOfWorld,
} from '../../viewport/activeCamera';
import { FullscreenCorner } from '../../viewport/FullscreenCorner'; import { FullscreenCorner } from '../../viewport/FullscreenCorner';
import { collectHolderItems, collectWorldItems } from '../../viewport/items'; import { collectHolderItems, collectWorldItems } from '../../viewport/items';
import { routePointer } from '../../viewport/gamePointer'; import { routePointer } from '../../viewport/gamePointer';
@@ -35,6 +41,9 @@ export class GameViewport extends WorldViewport<EditorState> {
private performanceShown = false; private performanceShown = false;
/** Milliseconds spent in `redraw` so far; the meter takes the difference over a frame. */ /** Milliseconds spent in `redraw` so far; the meter takes the difference over a frame. */
drawTime = 0; drawTime = 0;
/** The model collected once per redraw of the preview: view, cameras and items all read it. */
private redrawFrame: Frame | null = null;
private redrawing = false;
constructor(state: EditorState) { constructor(state: EditorState) {
super(state); super(state);
@@ -107,7 +116,13 @@ export class GameViewport extends WorldViewport<EditorState> {
override redraw(): void { override redraw(): void {
if (!this.visible) return; if (!this.visible) return;
const start = performance.now(); const start = performance.now();
this.redrawing = true;
try {
super.redraw(); super.redraw();
} finally {
this.redrawing = false;
this.redrawFrame = null;
}
this.drawTime += performance.now() - start; this.drawTime += performance.now() - start;
} }
@@ -133,10 +148,22 @@ export class GameViewport extends WorldViewport<EditorState> {
return world ? cameraOfWorld(world) : cameraOfItems(this.modelFrame().items); return world ? cameraOfWorld(world) : cameraOfItems(this.modelFrame().items);
} }
protected override mainCamera(): ActiveCamera | null {
return this.activeCamera();
}
protected override overlayCameras(): ActiveCamera[] {
const world = this.playWorld;
return world ? overlaysOfWorld(world) : overlaysOfItems(this.modelFrame().items);
}
private modelFrame(): Frame { private modelFrame(): Frame {
if (this.redrawFrame) return this.redrawFrame;
const state = this.state; const state = this.state;
const background = Theme.colors.background; const background = Theme.colors.background;
return collectHolderItems(state.project, state.activeScene, background, this.editorWorld); const frame = collectHolderItems(state.project, state.activeScene, background, this.editorWorld);
if (this.redrawing) this.redrawFrame = frame;
return frame;
} }
/** /**
+10 -2
View File
@@ -1,7 +1,7 @@
import type { TextStyle } from 'pixi.js'; import type { TextStyle } from 'pixi.js';
import type { World } from '../runtime/World'; import type { World } from '../runtime/World';
import { Theme, textStyle, ui, crispText } from '../ui'; import { Theme, textStyle, ui, crispText } from '../ui';
import { cameraOfWorld } from '../viewport/activeCamera'; import { type ActiveCamera, cameraOfWorld, overlaysOfWorld } from '../viewport/activeCamera';
import { FullscreenCorner } from '../viewport/FullscreenCorner'; import { FullscreenCorner } from '../viewport/FullscreenCorner';
import { routePointer } from '../viewport/gamePointer'; import { routePointer } from '../viewport/gamePointer';
import { collectWorldItems } from '../viewport/items'; import { collectWorldItems } from '../viewport/items';
@@ -89,11 +89,19 @@ export class GameScreen extends WorldViewport {
} }
protected override currentView(): View | null { protected override currentView(): View | null {
const camera = this.running ? cameraOfWorld(this.running) : null; const camera = this.mainCamera();
const zoom = this.state.project.gameLinkZoom; const zoom = this.state.project.gameLinkZoom;
return camera ? cameraView(camera, this.w, this.h, zoom, ui().app.renderer.resolution) : null; return camera ? cameraView(camera, this.w, this.h, zoom, ui().app.renderer.resolution) : null;
} }
protected override mainCamera(): ActiveCamera | null {
return this.running ? cameraOfWorld(this.running) : null;
}
protected override overlayCameras(): ActiveCamera[] {
return this.running ? overlaysOfWorld(this.running) : [];
}
protected override collectFrame(): Frame { protected override collectFrame(): Frame {
return this.running ? collectWorldItems(this.running) : { items: [], background: this.baseColor }; return this.running ? collectWorldItems(this.running) : { items: [], background: this.baseColor };
} }
+15 -4
View File
@@ -10,6 +10,7 @@ import { liveTileAt, withTile } from '../core/model/liveTilemap';
import { referenceCandidates } from '../core/model/objectRefs'; import { referenceCandidates } from '../core/model/objectRefs';
import { type Cell, presentTile } from '../core/model/tilemap'; import { type Cell, presentTile } from '../core/model/tilemap';
import { descendantsBelow } from '../core/model/walk'; import { descendantsBelow } from '../core/model/walk';
import { isOverlayCamera } from '../core/model/renderLayers';
import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project'; import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project';
import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject'; import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject';
import { apply, IDENTITY } from '../core/model/matrix'; import { apply, IDENTITY } from '../core/model/matrix';
@@ -588,12 +589,22 @@ export class World {
/** /**
* The camera the game is seen through: of the cameras that are switched on (itself and every * The camera the game is seen through: of the cameras that are switched on (itself and every
* parent, see `properties/enabled.ts`) the first one in hierarchy order. Undefined while none * parent, see `properties/enabled.ts`) and no overlay (#302) the first one in hierarchy order.
* is on, and then nothing is rendered – switching one off is how a scene hands the view over. * Undefined while none is on, and then nothing is rendered – switching one off is how a scene
* hands the view over.
*/ */
activeCamera(): Entity | undefined { activeCamera(): Entity | undefined {
return this.camerasOn().find((camera) => !isOverlayCamera(camera.props));
}
/** The overlay cameras that are switched on (#302), in hierarchy order: the order they are laid on. */
overlayCameras(): Entity[] {
return this.camerasOn().filter((camera) => isOverlayCamera(camera.props));
}
private camerasOn(): Entity[] {
const on = this.entities.filter((e) => e.type === 'camera' && isHandleEnabled(e)); const on = this.entities.filter((e) => e.type === 'camera' && isHandleEnabled(e));
if (on.length < 2) return on[0]; if (on.length < 2) return on;
// Ranked only when the order can decide something, since this runs per frame. // Ranked only when the order can decide something, since this runs per frame.
const scene = findScene(this.project, this.sceneId); const scene = findScene(this.project, this.sceneId);
const order = scene ? treeOrder(scene).map(({ object }) => object.id) : []; const order = scene ? treeOrder(scene).map(({ object }) => object.id) : [];
@@ -601,7 +612,7 @@ export class World {
const index = order.indexOf(camera.objectId); const index = order.indexOf(camera.objectId);
return index < 0 ? order.length : index; return index < 0 ? order.length : index;
}; };
return on.reduce((first, camera) => (rank(camera) < rank(first) ? camera : first)); return on.sort((a, b) => rank(a) - rank(b));
} }
/** Removes an entity and, recursively, everything parented to it. */ /** Removes an entity and, recursively, everything parented to it. */
+122 -4
View File
@@ -3,7 +3,7 @@ import { isSymbolType } from '../core/model/AssetTypes';
import { type Extent, scaleOf } from '../core/model/matrix'; import { type Extent, scaleOf } from '../core/model/matrix';
import type { Vec2 } from '../core/model/Properties'; import type { Vec2 } from '../core/model/Properties';
import { footprintExtent, footprintOf, placementOf } from '../core/properties/bounds'; import { footprintExtent, footprintOf, placementOf } from '../core/properties/bounds';
import { rectsIntersect, type WorldRect } from '../core/properties/camera'; import { cameraFrame, rectsIntersect, type WorldRect } from '../core/properties/camera';
import type { ClipRegion } from '../core/properties/clip'; import type { ClipRegion } from '../core/properties/clip';
import type { Project } from '../core/model/Project'; import type { Project } from '../core/model/Project';
import { type Size, ui, Widget } from '../ui'; import { type Size, ui, Widget } from '../ui';
@@ -21,7 +21,9 @@ import { TextPicture } from './textPicture';
import { cellCentre, type LayerFocus, TilemapField } from './tilemap'; import { cellCentre, type LayerFocus, TilemapField } from './tilemap';
import { textureLibrary } from './TextureLibrary'; import { textureLibrary } from './TextureLibrary';
import { gameClock } from '../core/time'; import { gameClock } from '../core/time';
import { type View, worldBounds, worldToScreen } from './view'; import { overlayView, type View, worldBounds, worldToScreen } from './view';
import type { ActiveCamera } from './activeCamera';
import { cameraSees } from '../core/model/renderLayers';
/** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */ /** How strongly the cut-off part of an object shows while the scene view opens its clip area (#260). */
const CLIP_GHOST_ALPHA = 0.3; const CLIP_GHOST_ALPHA = 0.3;
@@ -85,6 +87,8 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
private redraws = 0; private redraws = 0;
private cameraMoves = 0; private cameraMoves = 0;
private readonly stopFiles: (() => void)[]; private readonly stopFiles: (() => void)[];
/** One view per overlay camera (#302), by camera id, laid over this one in camera order. */
private readonly overlays = new Map<string, OverlayCameraView>();
protected constructor(protected readonly state: S) { protected constructor(protected readonly state: S) {
super(); super();
@@ -111,6 +115,7 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
/** The libraries outlive a signed-out editor; a stale listener would redraw destroyed graphics. */ /** The libraries outlive a signed-out editor; a stale listener would redraw destroyed graphics. */
override destroy(options?: Parameters<Widget['destroy']>[0]): void { override destroy(options?: Parameters<Widget['destroy']>[0]): void {
for (const stop of this.stopFiles) stop(); for (const stop of this.stopFiles) stop();
for (const overlay of this.overlays.values()) overlay.destroy();
super.destroy(options); super.destroy(options);
} }
@@ -120,6 +125,16 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
protected abstract currentView(): View | null; protected abstract currentView(): View | null;
/** The camera the view is through, whose `Sees` decides what is drawn (#302); none in the scene view. */
protected mainCamera(): ActiveCamera | null {
return null;
}
/** The overlay cameras laid over the main one (#302), in the order they are laid on. */
protected overlayCameras(): ActiveCamera[] {
return [];
}
/** Only called when `currentView()` is non-null. */ /** Only called when `currentView()` is non-null. */
protected abstract collectFrame(): Frame; protected abstract collectFrame(): Frame;
@@ -308,10 +323,16 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
this.visibleCount = 0; this.visibleCount = 0;
this.animated = false; this.animated = false;
this.drawn = null; this.drawn = null;
if (!view) return; if (!view) {
this.syncOverlays(null, null, null);
return;
}
this.redraws++; this.redraws++;
const { items, background } = this.collectFrame(); const frame = this.collectFrame();
const { background } = frame;
const camera = this.mainCamera();
const items = camera ? seenBy(camera, frame.items) : frame.items;
this.lastItems = items; this.lastItems = items;
const { rect } = view; const { rect } = view;
if (this.paintsBackground()) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background); if (this.paintsBackground()) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
@@ -384,6 +405,43 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
} }
this.syncDrawOrder(items); this.syncDrawOrder(items);
this.sweepMasks(); this.sweepMasks();
this.syncOverlays(frame, view, camera);
}
/**
* Lays one view per overlay camera over this one (#302): each draws what its camera sees of the
* same frame, see-through, fitted into the main picture. Without a main view there are none.
*/
private syncOverlays(frame: Frame | null, main: View | null, camera: ActiveCamera | null): void {
const cameras = frame && main && camera ? this.overlayCameras() : [];
const wanted = new Set(cameras.map((c) => c.id));
for (const [id, overlay] of this.overlays)
if (!wanted.has(id)) {
overlay.destroy();
this.overlays.delete(id);
}
let at = this.getChildIndex(this.contentMask) + 1;
for (const overlayCamera of cameras) {
let overlay = this.overlays.get(overlayCamera.id);
if (!overlay) {
overlay = new OverlayCameraView(() => this.state.project);
this.overlays.set(overlayCamera.id, overlay);
this.addChild(overlay);
}
this.setChildIndex(overlay, at++);
const view = overlayView(overlayCamera, main!, cameraFrame(camera!.props));
overlay.show(overlayCamera, view, frame!, this.w, this.h);
if (overlay.animates) this.animated = true;
}
}
/** What each overlay camera draws, in the order they are laid on (#302, test hook). */
overlayInfo(): { camera: string; drawOrder: string[]; rect: View['rect'] }[] {
return [...this.overlays].map(([camera, overlay]) => ({
camera,
drawOrder: overlay.drawOrder(),
rect: overlay.shownRect(),
}));
} }
/** /**
@@ -642,3 +700,63 @@ export abstract class WorldViewport<S extends ViewSource = ViewSource> extends W
ghost.sync(item, scale, shaders); ghost.sync(item, scale, shaders);
} }
} }
/** Only what a camera sees (#302): the items on the render layers in its `Sees`. */
function seenBy(camera: ActiveCamera, items: DrawItem[]): DrawItem[] {
return items.filter((item) => cameraSees(camera.props, item.renderLayer));
}
/**
* What one overlay camera sees (#302), drawn like any view but handed its view and frame by the view
* it lies on, which also redraws it. See-through, and never hit: the mouse belongs to the main
* camera's view below (#304).
*/
class OverlayCameraView extends WorldViewport {
private camera: ActiveCamera | null = null;
private view: View | null = null;
private frame: Frame = { items: [], background: 0 };
constructor(project: () => Project) {
super({
get project() {
return project();
},
on: () => undefined,
});
this.eventMode = 'none';
this.bg.visible = false;
}
show(camera: ActiveCamera, view: View, frame: Frame, w: number, h: number): void {
this.camera = camera;
this.view = view;
this.frame = frame;
this.w = w;
this.h = h;
this.redraw();
}
shownRect(): View['rect'] {
return this.view?.rect ?? { x: 0, y: 0, w: 0, h: 0 };
}
protected override currentView(): View | null {
return this.view;
}
protected override mainCamera(): ActiveCamera | null {
return this.camera;
}
protected override collectFrame(): Frame {
return this.frame;
}
protected override get baseColor(): number {
return 0;
}
protected override paintsBackground(): boolean {
return false;
}
}
+22 -3
View File
@@ -1,6 +1,8 @@
/** Which camera the game view frames, and where it sits – from the running world or the model. */ /** Which camera the game view frames, and where it sits – from the running world or the model. */
import { worldPositionOf } from '../core/graph'; import { worldPositionOf } from '../core/graph';
import type { PropertyBag } from '../core/model/Properties'; import type { PropertyBag } from '../core/model/Properties';
import { isOverlayCamera } from '../core/model/renderLayers';
import type { Entity } from '../runtime/Entity';
import type { World } from '../runtime/World'; import type { World } from '../runtime/World';
import type { DrawItem } from './items'; import type { DrawItem } from './items';
@@ -13,18 +15,35 @@ export interface ActiveCamera {
} }
/** /**
* The model's camera, for the preview shown while not playing. Same rule as `World.activeCamera`: * The model's main camera, for the preview shown while not playing. Same rule as `World.activeCamera`:
* `collectHolderItems` already delivers hierarchy order with the switch chain resolved, so the * `collectHolderItems` already delivers hierarchy order with the switch chain resolved, so the
* first camera that is on is simply the first match. * first camera that is on is simply the first match.
*/ */
export function cameraOfItems(items: DrawItem[]): ActiveCamera | null { export function cameraOfItems(items: DrawItem[]): ActiveCamera | null {
return items.find((item) => item.type === 'camera' && item.enabled) ?? null; return items.find((item) => isCameraOn(item) && !isOverlayCamera(item.props)) ?? null;
}
/** The model's overlay cameras (#302), in the order `World.overlayCameras` lays them on. */
export function overlaysOfItems(items: DrawItem[]): ActiveCamera[] {
return items.filter((item) => isCameraOn(item) && isOverlayCamera(item.props));
}
function isCameraOn(item: DrawItem): boolean {
return item.type === 'camera' && item.enabled;
} }
/** The running camera. Its own `x`/`y` are relative to its parent, so it is placed by its world position. */ /** The running camera. Its own `x`/`y` are relative to its parent, so it is placed by its world position. */
export function cameraOfWorld(world: World): ActiveCamera | null { export function cameraOfWorld(world: World): ActiveCamera | null {
const entity = world.activeCamera(); const entity = world.activeCamera();
if (!entity) return null; return entity ? placed(entity) : null;
}
/** The running overlay cameras (#302), placed the same way. */
export function overlaysOfWorld(world: World): ActiveCamera[] {
return world.overlayCameras().map(placed);
}
function placed(entity: Entity): ActiveCamera {
const { x, y } = worldPositionOf(entity); const { x, y } = worldPositionOf(entity);
return { id: entity.objectId, x, y, props: entity.props }; return { id: entity.objectId, x, y, props: entity.props };
} }
+10
View File
@@ -29,6 +29,7 @@ import { fontFileOf, fontSmoothOf } from '../core/model/font';
import { resolveTexture, type TextureRef } from '../core/model/texture'; import { resolveTexture, type TextureRef } from '../core/model/texture';
import type { FrameOutline } from '../core/model/frameOutline'; import type { FrameOutline } from '../core/model/frameOutline';
import { liveTilemap, type TileEdits } from '../core/model/liveTilemap'; import { liveTilemap, type TileEdits } from '../core/model/liveTilemap';
import { renderLayerAlong } from '../core/model/renderLayers';
import { type ClipRegion, clipRegions } from '../core/properties/clip'; import { type ClipRegion, clipRegions } from '../core/properties/clip';
import { colliderFrame } from '../core/properties/collider'; import { colliderFrame } from '../core/properties/collider';
import { enabledAlongChain } from '../core/properties/enabled'; import { enabledAlongChain } from '../core/properties/enabled';
@@ -75,6 +76,8 @@ export interface DrawItem {
frame: FrameOutline | null; frame: FrameOutline | null;
/** Where the clip areas above it let it be seen and hit (#260), in world units; null when none is. */ /** Where the clip areas above it let it be seen and hit (#260), in world units; null when none is. */
clip: ClipRegion | null; clip: ClipRegion | null;
/** The render layer it is on, its parents' choice included (#302): which cameras see it. */
renderLayer: number;
} }
interface DrawList { interface DrawList {
@@ -235,6 +238,7 @@ export function collectHolderItems(
emits: 0, emits: 0,
frame: asset.type === 'collider' ? colliderFrame(project, props) : null, frame: asset.type === 'collider' ? colliderFrame(project, props) : null,
clip: null, clip: null,
renderLayer: 0,
}); });
} }
const byId = new Map(items.map((i) => [i.id, i])); const byId = new Map(items.map((i) => [i.id, i]));
@@ -244,6 +248,7 @@ export function collectHolderItems(
item.enabled = enabledAlongChain(item, (i) => i.props, parentOf); item.enabled = enabledAlongChain(item, (i) => i.props, parentOf);
item.sortsWith = passedOnTo(parentOf(item), parentOf)?.id ?? ''; item.sortsWith = passedOnTo(parentOf(item), parentOf)?.id ?? '';
item.clip = regions.get(item) ?? null; item.clip = regions.get(item) ?? null;
item.renderLayer = renderLayerAlong(item, (i) => i.props, parentOf);
} }
return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) }; return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) };
} }
@@ -303,6 +308,11 @@ export function collectWorldItems(world: World): DrawList {
emits: e.emits ?? 0, emits: e.emits ?? 0,
frame: e.wornFrame?.() ?? null, frame: e.wornFrame?.() ?? null,
clip: regions.get(placed.get(e)!) ?? null, clip: regions.get(placed.get(e)!) ?? null,
renderLayer: renderLayerAlong(
e,
(at) => at.props,
(at) => at.parent ?? null,
),
})); }));
return { items, background: world.backgroundColor }; return { items, background: world.backgroundColor };
} }
+21
View File
@@ -133,6 +133,27 @@ export function cameraView(
}; };
} }
/**
* An overlay camera's picture (#302), laid on the main camera's: fitted into the main picture whole,
* centred, never distorted – the same size and zoom give the very same view. What the main picture
* loses to the edge of the screen the overlay loses with it.
*/
export function overlayView(
camera: { x: number; y: number; props: PropertyBag },
main: View,
mainFrame: { w: number; h: number },
): View {
const frame = cameraFrame(camera.props);
const pictureW = mainFrame.w * main.scale;
const pictureH = mainFrame.h * main.scale;
const scale = Math.min(pictureW / frame.w, pictureH / frame.h);
const w = Math.min(frame.w * scale, main.rect.w);
const h = Math.min(frame.h * scale, main.rect.h);
const cx = main.rect.x + main.rect.w / 2;
const cy = main.rect.y + main.rect.h / 2;
return { x: camera.x, y: camera.y, scale, rect: { x: cx - w / 2, y: cy - h / 2, w, h } };
}
/** /**
* The free view (centre and zoom in device pixels) that shows the world exactly as `cameraView` * The free view (centre and zoom in device pixels) that shows the world exactly as `cameraView`
* does in a view of the same size: same scale, and the camera's centre on the same screen pixel, * does in a view of the same size: same scale, and the camera's centre on the same screen pixel,
+4 -4
View File
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands'; import { AddColliderCommand, AddRigidbodyCommand } from '../../src/core/commands/colliderCommands';
import { SetCollisionLayersCommand } from '../../src/core/commands/projectCommands'; import { SetLayerNamesCommand } from '../../src/core/commands/projectCommands';
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import type { DeclaredPort, GraphNode } from '../../src/core/graph'; import type { DeclaredPort, GraphNode } from '../../src/core/graph';
import { OBJECT_TYPE_PARAM } from '../../src/core/graph/propertyPorts'; import { OBJECT_TYPE_PARAM } from '../../src/core/graph/propertyPorts';
@@ -120,7 +120,7 @@ describe('the project owns the list of layers (#152)', () => {
it('a new project has one layer, and setting them is one undo step', () => { it('a new project has one layer, and setting them is one undo step', () => {
const f = makeSpriteScene('Thing'); const f = makeSpriteScene('Thing');
expect(f.project.collisionLayers).toEqual(['Default']); expect(f.project.collisionLayers).toEqual(['Default']);
f.stack.execute(new SetCollisionLayersCommand(['Ground', 'Player'])); f.stack.execute(new SetLayerNamesCommand('collisionLayers', ['Ground', 'Player']));
expect(f.project.collisionLayers).toEqual(['Ground', 'Player']); expect(f.project.collisionLayers).toEqual(['Ground', 'Player']);
f.stack.undo(); f.stack.undo();
expect(f.project.collisionLayers).toEqual(['Default']); expect(f.project.collisionLayers).toEqual(['Default']);
@@ -130,7 +130,7 @@ describe('the project owns the list of layers (#152)', () => {
it('a nameless layer is stored under its number rather than blank', () => { it('a nameless layer is stored under its number rather than blank', () => {
const f = makeSpriteScene('Thing'); const f = makeSpriteScene('Thing');
f.stack.execute(new SetCollisionLayersCommand(['Ground', ''])); f.stack.execute(new SetLayerNamesCommand('collisionLayers', ['Ground', '']));
expect(f.project.collisionLayers).toEqual(['Ground', 'Layer 1']); expect(f.project.collisionLayers).toEqual(['Ground', 'Layer 1']);
}); });
}); });
@@ -317,7 +317,7 @@ describe.each(COLLISION_SYSTEMS)(
it('Layer of names the layer of the object’s collider, already in the pulse that changed it', () => { it('Layer of names the layer of the object’s collider, already in the pulse that changed it', () => {
const r = start(); const r = start();
r.stack.execute(new SetCollisionLayersCommand(['Ground', 'Layer 1', 'Layer 2', 'Ghost'])); r.stack.execute(new SetLayerNamesCommand('collisionLayers', ['Ground', 'Layer 1', 'Layer 2', 'Ghost']));
r.frame(); r.frame();
r.press('l'); r.press('l');
expect(r.world.log).toEqual(['Ground', '0']); expect(r.world.log).toEqual(['Ground', '0']);
+1
View File
@@ -30,6 +30,7 @@ describe('asset commands', () => {
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: true, sortWithParent: true,
renderLayer: -1,
width: 100, width: 100,
height: 100, height: 100,
color: 0xffffff, color: 0xffffff,
+1
View File
@@ -152,6 +152,7 @@ describe('CommandStack', () => {
startSceneId: '', startSceneId: '',
plugins: [], plugins: [],
collisionLayers: ['Default'], collisionLayers: ['Default'],
renderLayers: ['Default'],
symbolScale: 1, symbolScale: 1,
playFrom: 'start' as const, playFrom: 'start' as const,
whilePlaying: 'live' as const, whilePlaying: 'live' as const,
+3
View File
@@ -31,6 +31,7 @@ describe('PropertySchema', () => {
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: false, sortWithParent: false,
renderLayer: -1,
width: 100, width: 100,
height: 100, height: 100,
color: 0xffffff, color: 0xffffff,
@@ -63,6 +64,7 @@ describe('resolve', () => {
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: false, sortWithParent: false,
renderLayer: -1,
width: 50, width: 50,
height: 100, height: 100,
color: 0xffffff, color: 0xffffff,
@@ -83,6 +85,7 @@ describe('resolve', () => {
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: true, sortWithParent: true,
renderLayer: -1,
width: 100, width: 100,
height: 100, height: 100,
color: 0x00ff00, color: 0x00ff00,
+1
View File
@@ -178,6 +178,7 @@ describe('serialize', () => {
rotation: 0, rotation: 0,
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
renderLayer: -1,
width: 10, width: 10,
height: 100, height: 100,
color: 0xffffff, color: 0xffffff,
+4 -2
View File
@@ -722,6 +722,7 @@ describe('a sprite wearing a shader', () => {
'mirrorX', 'mirrorX',
'mirrorY', 'mirrorY',
'sortWithParent', 'sortWithParent',
'renderLayer',
'width', 'width',
'height', 'height',
'color', 'color',
@@ -742,6 +743,7 @@ describe('a sprite wearing a shader', () => {
'mirrorX', 'mirrorX',
'mirrorY', 'mirrorY',
'sortWithParent', 'sortWithParent',
'renderLayer',
'width', 'width',
'height', 'height',
'color', 'color',
@@ -754,14 +756,14 @@ describe('a sprite wearing a shader', () => {
expect(assetDeviates(f.project, f.sprite())).toBe(true); expect(assetDeviates(f.project, f.sprite())).toBe(true);
// A dangling id adds no rows and draws nothing – the picker still shows it. // A dangling id adds no rows and draws nothing – the picker still shows it.
f.sprite().props[SHADER_KEY] = 'gone'; f.sprite().props[SHADER_KEY] = 'gone';
expect(keys()).toHaveLength(12); expect(keys()).toHaveLength(13);
}); });
it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => { it('gives a placed object the rows of the shader it overrides, filled with the node defaults', () => {
const f = wearing(); const f = wearing();
const id = place(f, f.spriteId, { x: 0, y: 0 }); const id = place(f, f.spriteId, { x: 0, y: 0 });
const object = () => f.object(id); const object = () => f.object(id);
expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(12); expect(schemaForObject(f.project, f.sprite(), object())).toHaveLength(13);
f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.s.id)); f.stack.execute(new SetOverrideCommand(f.sceneId, id, SHADER_KEY, f.s.id));
expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation'); expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation');
expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({ expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({
+1
View File
@@ -69,6 +69,7 @@ describe('CreateAssetCommand with starting props', () => {
mirrorX: false, mirrorX: false,
mirrorY: false, mirrorY: false,
sortWithParent: true, sortWithParent: true,
renderLayer: -1,
width: 64, width: 64,
height: 32, height: 32,
color: 0xffffff, color: 0xffffff,
+1
View File
@@ -11,6 +11,7 @@ const before: ProjectSettings = {
whilePlaying: 'balanced', whilePlaying: 'balanced',
gameLinkZoom: 'auto', gameLinkZoom: 'auto',
collisionLayers: ['Default', 'Enemies'], collisionLayers: ['Default', 'Enemies'],
renderLayers: ['Default'],
symbolScale: 1, symbolScale: 1,
}; };
+7 -5
View File
@@ -333,7 +333,7 @@ describe('the groups of the inspector', () => {
const host = makeHost(state); const host = makeHost(state);
buildObjectSection(host, f.sceneId, objectId); buildObjectSection(host, f.sceneId, objectId);
const shown = outline(host); const shown = outline(host);
expect(shown.slice(0, 7)).toEqual([ expect(shown.slice(0, 8)).toEqual([
'[SPRITE OBJECT]', '[SPRITE OBJECT]',
'Name', 'Name',
'Asset', 'Asset',
@@ -341,8 +341,9 @@ describe('the groups of the inspector', () => {
'[SWITCHES]', '[SWITCHES]',
'Enabled', 'Enabled',
'Keep on scene load', 'Keep on scene load',
'Render layer',
]); ]);
expect(shown[7]).toBe('[POSITION]'); expect(shown[8]).toBe('[POSITION]');
expect(shown.filter((line) => line.startsWith('['))).toEqual([ expect(shown.filter((line) => line.startsWith('['))).toEqual([
'[SPRITE OBJECT]', '[SPRITE OBJECT]',
'[SWITCHES]', '[SWITCHES]',
@@ -385,15 +386,16 @@ describe('the groups of the inspector', () => {
const host = makeHost(state); const host = makeHost(state);
buildAssetSection(host, f.spriteId); buildAssetSection(host, f.spriteId);
const shown = outline(host); const shown = outline(host);
expect(shown.slice(0, 5)).toEqual([ expect(shown.slice(0, 6)).toEqual([
'[SPRITE ASSET]', '[SPRITE ASSET]',
'Name', 'Name',
'[SWITCHES]', '[SWITCHES]',
'Enabled', 'Enabled',
'Keep on scene load', 'Keep on scene load',
'Render layer',
]); ]);
expect(shown[5]).toBe('[PROPERTIES]'); expect(shown[6]).toBe('[PROPERTIES]');
expect(shown.length).toBeGreaterThan(6); expect(shown.length).toBeGreaterThan(7);
}); });
it('heads the rows of a behavior asset as switches, with no empty group after them', () => { it('heads the rows of a behavior asset as switches, with no empty group after them', () => {
+136
View File
@@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { SetLayerNamesCommand } from '../../src/core/commands/projectCommands';
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import {
cameraSees,
INHERIT_LAYER,
OVERLAY_KEY,
ownRenderLayer,
RENDER_LAYER_KEY,
renderLayersOf,
SEES_KEY,
} from '../../src/core/model/renderLayers';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { settingsChanges } from '../../src/editor/ProjectSettingsDialog';
import { cameraOfItems, overlaysOfItems, overlaysOfWorld } from '../../src/viewport/activeCamera';
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
import { cameraView, overlayView } from '../../src/viewport/view';
import { makeSpriteScene, place, startWorld } from '../helpers';
function sceneWithCamera() {
const fixture = makeSpriteScene();
const camera = new CreateAssetCommand('camera', 'Cam');
fixture.stack.execute(camera);
const container = new CreateAssetCommand('container', 'Hud');
fixture.stack.execute(container);
return {
...fixture,
cameraId: camera.asset.id,
containerId: container.asset.id,
set: (objectId: string, key: string, value: number | boolean) =>
fixture.stack.execute(new SetOverrideCommand(fixture.sceneId, objectId, key, value)),
items: () => collectHolderItems(fixture.project, fixture.sceneOf(), 0).items,
};
}
describe('render layers (#302)', () => {
it('reads the names like the collision layers, "Default" for a project from before', () => {
expect(renderLayersOf(null)).toEqual(['Default']);
expect(renderLayersOf({ renderLayers: ['World', '', 'UI'] })).toEqual(['World', 'Layer 1', 'UI']);
const f = sceneWithCamera();
const stored = serializeProject(f.project) as Record<string, unknown>;
delete stored.renderLayers;
expect(deserializeProject(stored).renderLayers).toEqual(['Default']);
});
it('reads an own choice, and anything unreadable as none', () => {
for (const value of [undefined, null, '', ' ', 'x', -1, -5, 16, true])
expect(ownRenderLayer(value)).toBe(INHERIT_LAYER);
expect(ownRenderLayer(0)).toBe(0);
expect(ownRenderLayer(3)).toBe(3);
expect(ownRenderLayer('2')).toBe(2);
});
it('puts an object without a choice on its parent’s layer, and the top on layer 0', () => {
const f = sceneWithCamera();
const hud = place(f, f.containerId, { x: 0, y: 0 });
const inside = place(f, f.spriteId, { x: 0, y: 0 }, hud);
const own = place(f, f.spriteId, { x: 0, y: 0 }, hud);
const loose = place(f, f.spriteId, { x: 0, y: 0 });
f.set(hud, RENDER_LAYER_KEY, 1);
f.set(own, RENDER_LAYER_KEY, 2);
const layerOf = (id: string) => f.items().find((item) => item.id === id)!.renderLayer;
expect([layerOf(hud), layerOf(inside), layerOf(own), layerOf(loose)]).toEqual([1, 1, 2, 0]);
const { world } = startWorld(f);
const running = (id: string) =>
collectWorldItems(world).items.find((item) => item.id === id)!.renderLayer;
expect([running(inside), running(own), running(loose)]).toEqual([1, 2, 0]);
});
it('lets a camera without `Sees` see every layer, and one with it only those', () => {
expect(cameraSees({}, 0)).toBe(true);
expect(cameraSees({}, 15)).toBe(true);
expect(cameraSees({ [SEES_KEY]: 0b10 }, 0)).toBe(false);
expect(cameraSees({ [SEES_KEY]: 0b10 }, 1)).toBe(true);
});
it('keeps the first camera without Overlay as the main one, the overlays apart in hierarchy order', () => {
const f = sceneWithCamera();
const over = place(f, f.cameraId, { x: 0, y: 0 });
const main = place(f, f.cameraId, { x: 100, y: 0 });
const plain = place(f, f.cameraId, { x: 200, y: 0 });
const over2 = place(f, f.cameraId, { x: 300, y: 0 });
f.set(over, OVERLAY_KEY, true);
f.set(over2, OVERLAY_KEY, true);
const { world } = startWorld(f);
expect(world.activeCamera()?.objectId).toBe(main);
expect(world.overlayCameras().map((c) => c.objectId)).toEqual([over, over2]);
expect(overlaysOfWorld(world).map((c) => c.x)).toEqual([0, 300]);
expect(cameraOfItems(f.items())?.id).toBe(main);
expect(overlaysOfItems(f.items()).map((c) => c.id)).toEqual([over, over2]);
// Two plain cameras on draw as before: through the first only, the second is no overlay.
expect(world.overlayCameras().map((c) => c.objectId)).not.toContain(plain);
f.set(over2, 'enabled', false);
world.syncFromModel();
expect(world.overlayCameras().map((c) => c.objectId)).toEqual([over]);
});
it('fits an overlay into the main picture, and gives the same camera the very same view', () => {
const props = { width: 640, height: 360, zoom: 1 };
const main = cameraView({ x: 0, y: 0, props }, 1280, 720, 2);
expect(overlayView({ x: 0, y: 0, props }, main, { w: 640, h: 360 })).toEqual(main);
// Narrower and zoomed: the frame is 160x180 world units, fitted to the 720 px height.
const narrow = overlayView({ x: 50, y: 7, props: { width: 320, height: 360, zoom: 2 } }, main, {
w: 640,
h: 360,
});
expect(narrow).toEqual({ x: 50, y: 7, scale: 4, rect: { x: 320, y: 0, w: 640, h: 720 } });
});
it('keeps the render layers an undo step and a change of the settings of their own', () => {
const f = sceneWithCamera();
f.stack.execute(new SetLayerNamesCommand('renderLayers', ['World', 'UI']));
expect(f.project.renderLayers).toEqual(['World', 'UI']);
expect(f.project.collisionLayers).toEqual(['Default']);
f.stack.undo();
expect(f.project.renderLayers).toEqual(['Default']);
const before = {
name: 'P',
playFrom: 'start' as const,
whilePlaying: 'live' as const,
gameLinkZoom: 'auto' as const,
collisionLayers: ['Default'],
renderLayers: ['Default'],
symbolScale: 1,
};
expect(settingsChanges(before, { ...before, renderLayers: ['Default'] })).toEqual({});
expect(settingsChanges(before, { ...before, renderLayers: ['Default', 'UI'] })).toEqual({
renderLayers: ['Default', 'UI'],
});
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* Render layers and overlay cameras (#302). The main camera sees only `Default`, an overlay camera
* 2000 units to the right sees only `UI`. Each draws its own picture, the overlay's laid on top:
* a green sprite of the overlay with z -10 covers a red one of the main camera with z 1000, and
* what a camera does not see stays away – a yellow `UI` sprite in front of the main camera, a blue
* `Default` one in front of the overlay. Switched to no overlay, the second camera draws nothing.
*
* Everything it adds carries an `rl63` id and goes again at the end.
*/
import { MAKE_OBJECT } from './lib/scaffold.mjs';
const OVERLAY = 'rl63-cam';
export async function renderLayers(t) {
const { check, json, evaluate, mutate, sleep, shot, showTab, play, stop, clickText, press } = t;
await showTab('scene');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
const main = scene.objects.find((o) => o.id === 'o3');
window.__rl63 = { layers: p.renderLayers, overrides: structuredClone(main.overrides) };
p.renderLayers = ['Default', 'UI'];
main.overrides = { ...main.overrides, sees: 0b01 };
${MAKE_OBJECT}
const sprite = p.assets.find((a) => a.type === 'sprite');
const square = (id, name, dx, z, size, color, layer, from) => {
const o = make(id, sprite.id, '', 0, name);
Object.assign(o.position, { x: from.x + dx, y: from.y, z });
o.overrides = { width: size, height: size, color, texture: '', shader: '', renderLayer: layer };
return o;
};
const overlay = structuredClone(main);
Object.assign(overlay, { id: '${OVERLAY}', name: 'Overlay 63', instanceEdits: {} });
overlay.position = { ...main.position, x: main.position.x + 2000 };
overlay.overrides = { ...main.overrides, overlay: true, sees: 0b10 };
scene.objects.push(
overlay,
square('rl63-a', 'A 63', 70, 1000, 60, 0xff0000, -1, main.position),
square('rl63-d', 'D 63', -70, 1000, 60, 0xffff00, 1, main.position),
square('rl63-b', 'B 63', 0, -10, 40, 0x00ff00, 1, overlay.position),
square('rl63-c', 'C 63', 70, 1000, 60, 0x0000ff, 0, overlay.position),
);
`);
await evaluate('__gm.state.select(null)');
await showTab('game');
await sleep(300);
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
const is = (hex, [r, g, b]) => {
const [pr, pg, pb] = channels(hex);
return Math.abs(pr - r) < 30 && Math.abs(pg - g) < 30 && Math.abs(pb - b) < 30;
};
/** The three points of the picture the squares sit on: the centre and 70 units either side. */
const picture = async () => {
const { frame, scale } = await json('__gm.gameScale()');
const cx = Math.round(frame.x + frame.w / 2);
const cy = Math.round(frame.y + frame.h / 2);
const at = (dx) => json(`__gm.pixelAt(${Math.round(cx + dx * scale)}, ${cy})`);
return {
centre: await at(0),
right: await at(70),
left: await at(-70),
main: await json('__gm.gameDrawOrder()'),
overlays: await json('__gm.gameOverlays()'),
};
};
const GREEN = [0, 255, 0];
const RED = [255, 0, 0];
const YELLOW = [255, 255, 0];
const stacked = (seen) =>
seen.overlays.length === 1 &&
seen.overlays[0].camera === OVERLAY &&
seen.overlays[0].drawOrder.includes('rl63-b') &&
!seen.overlays[0].drawOrder.includes('rl63-c') &&
seen.main.includes('rl63-a') &&
!seen.main.includes('rl63-d') &&
!seen.main.includes('rl63-b') &&
is(seen.centre, GREEN) &&
is(seen.right, RED) &&
!is(seen.left, YELLOW);
// 63a. The preview of the Game tab: each camera draws what it sees, the overlay on top.
const preview = await picture();
await shot('63-overlay-preview');
check(
'each camera draws only its layers, and the overlay lies on top whatever the z',
stacked(preview),
JSON.stringify(preview),
);
// 63b. Without Overlay the second camera is a plain one after the first: it draws nothing.
await mutate(`
const cam = __gm.state.activeScene.objects.find((o) => o.id === '${OVERLAY}');
cam.overrides = { ...cam.overrides, overlay: false };
`);
await sleep(200);
const plain = await picture();
check(
'two plain cameras switched on draw through the first one only',
plain.overlays.length === 0 && !is(plain.centre, GREEN) && is(plain.right, RED),
JSON.stringify(plain),
);
await mutate(`
const cam = __gm.state.activeScene.objects.find((o) => o.id === '${OVERLAY}');
cam.overrides = { ...cam.overrides, overlay: true };
`);
// 63c. The running game stacks them the same way.
await play();
await sleep(400);
const running = await picture();
check(
'the running game lays the overlay camera on top the same way',
stacked(running),
JSON.stringify(running),
);
await stop();
// 63d. The project settings list the render layers, apart from the collision layers.
await t.clickToolbar('menu');
await clickText('Project settings…');
const opened = await json('__gm.renderLayers()');
await clickText('Add render layer');
const added = await json('__gm.renderLayers()');
const collision = await json('__gm.collisionLayers()');
await press('Escape');
await sleep(150);
check(
'the project settings list the render layers and add one at the end',
JSON.stringify(opened.dialog) === JSON.stringify(['Default', 'UI']) &&
JSON.stringify(added.dialog) === JSON.stringify(['Default', 'UI', 'Layer 2']) &&
!collision.dialog.includes('UI'),
JSON.stringify({ opened, added, collision }),
);
// Clean up.
await showTab('scene');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => !o.id.startsWith('rl63-'));
scene.objects.find((o) => o.id === 'o3').overrides = window.__rl63.overrides;
p.renderLayers = window.__rl63.layers;
`);
}
+2
View File
@@ -65,6 +65,7 @@ import { whilePlaying } from './59-while-playing.mjs';
import { release } from './60-release.mjs'; import { release } from './60-release.mjs';
import { fullscreen } from './61-fullscreen.mjs'; import { fullscreen } from './61-fullscreen.mjs';
import { devicePixels } from './62-device-pixels.mjs'; import { devicePixels } from './62-device-pixels.mjs';
import { renderLayers } from './63-render-layers.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -129,4 +130,5 @@ export const STEPS = [
release, release,
fullscreen, fullscreen,
devicePixels, devicePixels,
renderLayers,
]; ];