diff --git a/README.md b/README.md index 35c77af2..6473184c 100644 --- a/README.md +++ b/README.md @@ -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: ```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 node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/projectCommands.ts b/src/core/commands/projectCommands.ts index 677ffab4..0a48053b 100644 --- a/src/core/commands/projectCommands.ts +++ b/src/core/commands/projectCommands.ts @@ -1,4 +1,4 @@ -import { collisionLayersOf } from '../model/collisionLayers'; +import { layerNamesOf } from '../model/collisionLayers'; import { DEFAULT_GAME_LINK_ZOOM, DEFAULT_WHILE_PLAYING, @@ -26,24 +26,36 @@ export class RenameProjectCommand implements Command { } } +export type LayerList = 'collisionLayers' | 'renderLayers'; + +const LAYER_LIST_LABEL: Record = { + collisionLayers: 'Collision layers', + renderLayers: 'Render layers', +}; + /** - * The named collision layers of the project (#152). Colliders name a layer by number, so removing - * one shifts what every collider below it points at – which is why the dialog only ever hands over - * a whole list, and why the list is read through `collisionLayersOf` here as well. + * The named collision layers (#152) or render layers (#302) of the project. Objects name a layer by + * number, so removing one shifts what every object below it points at – which is why the dialog only + * ever hands over a whole list, and why the list is read through `layerNamesOf` here as well. */ -export class SetCollisionLayersCommand implements Command { - readonly label = 'Collision layers'; +export class SetLayerNamesCommand implements Command { + readonly label: 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 { - this.previous = project.collisionLayers; - project.collisionLayers = collisionLayersOf({ collisionLayers: this.names }); + this.previous = project[this.list]; + project[this.list] = layerNamesOf(this.names); } undo(project: Project): void { - project.collisionLayers = this.previous; + project[this.list] = this.previous; } } diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index 0fec6984..9da578f1 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.ts @@ -48,6 +48,11 @@ export interface Project { * which is also what every collider starts on, so nothing collides differently than it did. */ 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 * 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: '', plugins: [], collisionLayers: [DEFAULT_LAYER_NAME], + renderLayers: [DEFAULT_LAYER_NAME], symbolScale: DEFAULT_SYMBOL_SCALE, playFrom: DEFAULT_PLAY_FROM, whilePlaying: DEFAULT_WHILE_PLAYING, diff --git a/src/core/model/collisionLayers.ts b/src/core/model/collisionLayers.ts index 4c48823b..dffbdb76 100644 --- a/src/core/model/collisionLayers.ts +++ b/src/core/model/collisionLayers.ts @@ -33,7 +33,11 @@ export interface CollisionFilter { * is dropped. A project written before there were layers has exactly one, "Default". */ 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 read = names.map((name, i) => (typeof name === 'string' && name !== '' ? name : layerFallback(i))); return read.length > 0 ? read : [DEFAULT_LAYER_NAME]; diff --git a/src/core/model/renderLayers.ts b/src/core/model/renderLayers.ts new file mode 100644 index 00000000..af45a863 --- /dev/null +++ b/src/core/model/renderLayers.ts @@ -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( + 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; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index 6404ddca..cf4a5db8 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -23,6 +23,7 @@ import { } from './kinds'; import { findAssetOf, type Project } from '../model/Project'; 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 { TILE_BOUNCINESS_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 * 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 - * 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 'number' ? { min?: number; max?: number; step?: number; decimals?: number } @@ -119,7 +121,9 @@ type Extras = K extends 'number' ? { assetTypes?: readonly AssetType[] } : K extends 'texture' ? { uploadedOnly?: boolean } - : unknown; + : K extends 'layer' | 'layers' + ? { renderLayers?: boolean } + : unknown; /** * The definition of a flat kind: its default is a value of that kind, as the table says. Not an @@ -377,7 +381,33 @@ export function isSortProp(key: string): boolean { 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 = 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. */ export function isCommonProp(key: string): boolean { @@ -421,6 +451,9 @@ const ASSET_SCHEMAS: Record = { { 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: '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: [ { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, @@ -897,6 +930,7 @@ const FULL_SCHEMAS: Record = Object.fromEntries( ...COMMON_PROPS, ...(hasTransformProps(type) ? TRANSFORM_PROPS : []), ...sortPropsOf(type), + ...renderLayerPropsOf(type), ...ASSET_SCHEMAS[type], ] : ASSET_SCHEMAS[type], diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index e9526af4..6f5b4551 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -45,6 +45,7 @@ import type { import { parseCellKey } from '../model/tilemap'; import { pluginsOf } from './projectPlugins'; import { collisionLayersOf } from '../model/collisionLayers'; +import { renderLayersOf } from '../model/renderLayers'; import { gameLinkZoomOf, gameScaleOf, playFromOf, whilePlayingOf } from '../model/playSettings'; import { sceneVisibilityOf } from '../model/sceneVisibility'; import { symbolScaleOf } from '../model/symbolScale'; @@ -284,6 +285,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { // is actually running. plugins: pluginsOf(r), collisionLayers: collisionLayersOf(r), + renderLayers: renderLayersOf(r), symbolScale: symbolScaleOf(r), playFrom: playFromOf(r), whilePlaying: whilePlayingOf(r), diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts index 085ea37e..85acaef6 100644 --- a/src/editor/ProjectSession.ts +++ b/src/editor/ProjectSession.ts @@ -3,8 +3,8 @@ */ import { RenameProjectCommand, - SetCollisionLayersCommand, SetGameLinkZoomCommand, + SetLayerNamesCommand, SetPlayFromCommand, SetSymbolScaleCommand, 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 - * 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. */ openProjectSettings(): ProjectSettingsDialog { @@ -334,6 +334,7 @@ export class ProjectSession { whilePlaying: project.whilePlaying, gameLinkZoom: project.gameLinkZoom, collisionLayers: [...project.collisionLayers], + renderLayers: [...project.renderLayers], symbolScale: project.symbolScale, }; return this.settingsDialog.open( @@ -341,7 +342,9 @@ export class ProjectSession { const change = settingsChanges(before, settings); if (change.name !== undefined) this.state.execute(new RenameProjectCommand(change.name)); 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) this.state.execute(new SetSymbolScaleCommand(change.symbolScale)); if (change.playFrom !== undefined) this.state.execute(new SetPlayFromCommand(change.playFrom)); diff --git a/src/editor/ProjectSettingsDialog.ts b/src/editor/ProjectSettingsDialog.ts index a701e240..1873a21b 100644 --- a/src/editor/ProjectSettingsDialog.ts +++ b/src/editor/ProjectSettingsDialog.ts @@ -1,6 +1,6 @@ import { layerFallback, MAX_COLLISION_LAYERS } from '../core/model/collisionLayers'; 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 { GAME_LINK_ZOOMS, type GameLinkZoom, @@ -17,6 +17,7 @@ export interface ProjectSettings { whilePlaying: WhilePlaying; gameLinkZoom: GameLinkZoom; collisionLayers: string[]; + renderLayers: string[]; symbolScale: number; } @@ -33,6 +34,7 @@ export function settingsChanges( ): { name?: string; collisionLayers?: string[]; + renderLayers?: string[]; symbolScale?: number; playFrom?: PlayFrom; whilePlaying?: WhilePlaying; @@ -45,6 +47,9 @@ export function settingsChanges( ...(JSON.stringify(after.collisionLayers) === JSON.stringify(before.collisionLayers) ? {} : { collisionLayers: [...after.collisionLayers] }), + ...(JSON.stringify(after.renderLayers) === JSON.stringify(before.renderLayers) + ? {} + : { renderLayers: [...after.renderLayers] }), ...(symbolScale === before.symbolScale ? {} : { symbolScale }), ...(after.playFrom === before.playFrom ? {} : { playFrom: after.playFrom }), ...(after.whilePlaying === before.whilePlaying ? {} : { whilePlaying: after.whilePlaying }), @@ -68,30 +73,87 @@ const GAME_LINK_ZOOM_LABEL: Record = { 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 - * how closely the panels follow it (#265), the zoom of the game link (#279), the - * size of the scene view's symbol icons, and the collision layers (#152). The top bar carries 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. + * how closely the panels follow it (#265), the zoom of the game link (#279), the size of the scene + * view's symbol icons, the collision layers (#152) and the render layers (#302). The top bar carries + * icons only, so this is the one place they are edited. */ export class ProjectSettingsDialog extends FormDialog { private projectName: string; private playFrom: PlayFrom; private whilePlaying: WhilePlaying; private gameLinkZoom: GameLinkZoom; - private layers: string[]; private symbolScale: number; private readonly nameField: TextField; private readonly playFromButton: Button; private readonly whilePlayingButton: Button; private readonly gameLinkZoomButton: Button; private readonly symbolScaleField: NumberField; - private readonly rows = new VStack({ gap: Theme.gap }); - private readonly addButton: Button; + private readonly collisionLayers: LayerNameRows; + private readonly renderLayers: LayerNameRows; constructor(settings: ProjectSettings, onApply: (settings: ProjectSettings) => void) { super(340, 'Project settings'); @@ -99,8 +161,16 @@ export class ProjectSettingsDialog extends FormDialog { this.playFrom = settings.playFrom; this.whilePlaying = settings.whilePlaying; this.gameLinkZoom = settings.gameLinkZoom; - this.layers = [...settings.collisionLayers]; 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, { placeholder: 'Project name', @@ -173,9 +243,13 @@ export class ProjectSettingsDialog extends FormDialog { style: 'muted', }), ); - this.content.add(this.rows); - this.addButton = new Button('Add layer', { onClick: () => this.addLayer() }); - this.content.add(this.formRow(this.addButton)); + this.content.add(this.collisionLayers.rows); + this.content.add(this.formRow(this.collisionLayers.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', () => onApply({ @@ -183,16 +257,23 @@ export class ProjectSettingsDialog extends FormDialog { playFrom: this.playFrom, whilePlaying: this.whilePlaying, gameLinkZoom: this.gameLinkZoom, - collisionLayers: this.layers, + collisionLayers: this.collisionLayers.names, + renderLayers: this.renderLayers.names, 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[] { - 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). */ @@ -278,48 +359,4 @@ export class ProjectSettingsDialog extends FormDialog { })), ).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); - } } diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index 99bbe0f5..c9a795a2 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -107,6 +107,16 @@ export function sceneHooks({ state, editor }: HookContext) { gameScreen: (id: string) => gameView.objectScreen(id), gameRect: (id: string) => gameView.objectRect(id), 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). */ gameScale: () => { const origin = gameView.getGlobalPosition(); diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index d3116cfe..36687edb 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -291,6 +291,11 @@ export function uiHooks({ app, state, editor }: HookContext) { project: [...state.project.collisionLayers], 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. */ projectSettings: () => editor.projectSettingsDialog()?.values() ?? null, /** Where to click the start-scene switch inside that dialog. */ diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index 5309d786..c6881be8 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -29,6 +29,7 @@ import { type VariableOwnerAsset, type VariablesAsset, } from '../../core/model/Asset'; +import { RENDER_LAYER_KEY } from '../../core/model/renderLayers'; import { MakeListItemTypeCommand, MakeStructTypeCommand, @@ -181,7 +182,9 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole 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. */ function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[], title: string): void { diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index a8a45168..3e67436d 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -9,6 +9,7 @@ import { maskLabel, maskWith, } from '../../core/model/collisionLayers'; +import { INHERIT_LABEL, INHERIT_LAYER, ownRenderLayer } from '../../core/model/renderLayers'; import { assetChoiceLabel, assetRefChoices, @@ -634,18 +635,23 @@ export function createField( } case 'layer': { // The names live in the project, not in the row: renaming a layer renames it everywhere. - const names = () => ctx.project().collisionLayers; - const labelOf = (v: PropertyValue) => layerName(names(), layerIndexOf(v)); + const render = def.renderLayers === true; + 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)); button.onClick = () => { - new Menu(names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) }))).openBelow( - button, - ); + const layers = names().map((name, i) => ({ label: name, onSelect: () => handlers.onChange(i) })); + 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)) }; } 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()); let mask = layerMaskOf(value); const button = new Dropdown(labelOf(value)); diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index e60c3cf4..4c0dc244 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -9,6 +9,7 @@ import type { ValuePath } from '../../core/properties/valuePath'; import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands'; import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap'; import { findUsages } from '../../core/model/usages'; +import { RENDER_LAYER_KEY } from '../../core/model/renderLayers'; import { ClearOverrideCommand, MoveSceneObjectCommand, @@ -177,6 +178,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object if (asset.type === 'behavior') for (const def of schemaFor('behavior').filter((d) => !isCommonProp(d.key))) 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'); for (const axis of POSITION_AXES) { diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index 52c22fb4..9b735e92 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -6,7 +6,13 @@ import { isSymbolType } from '../../core/model/AssetTypes'; import type { EditorState } from '../state'; import type { World } from '../../runtime/World'; 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 { collectHolderItems, collectWorldItems } from '../../viewport/items'; import { routePointer } from '../../viewport/gamePointer'; @@ -35,6 +41,9 @@ export class GameViewport extends WorldViewport { private performanceShown = false; /** Milliseconds spent in `redraw` so far; the meter takes the difference over a frame. */ 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) { super(state); @@ -107,7 +116,13 @@ export class GameViewport extends WorldViewport { override redraw(): void { if (!this.visible) return; const start = performance.now(); - super.redraw(); + this.redrawing = true; + try { + super.redraw(); + } finally { + this.redrawing = false; + this.redrawFrame = null; + } this.drawTime += performance.now() - start; } @@ -133,10 +148,22 @@ export class GameViewport extends WorldViewport { 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 { + if (this.redrawFrame) return this.redrawFrame; const state = this.state; 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; } /** diff --git a/src/player/GameScreen.ts b/src/player/GameScreen.ts index 66bd0ed6..72699157 100644 --- a/src/player/GameScreen.ts +++ b/src/player/GameScreen.ts @@ -1,7 +1,7 @@ import type { TextStyle } from 'pixi.js'; import type { World } from '../runtime/World'; 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 { routePointer } from '../viewport/gamePointer'; import { collectWorldItems } from '../viewport/items'; @@ -89,11 +89,19 @@ export class GameScreen extends WorldViewport { } protected override currentView(): View | null { - const camera = this.running ? cameraOfWorld(this.running) : null; + const camera = this.mainCamera(); const zoom = this.state.project.gameLinkZoom; 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 { return this.running ? collectWorldItems(this.running) : { items: [], background: this.baseColor }; } diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 0ad6da38..bccbb7fa 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -10,6 +10,7 @@ import { liveTileAt, withTile } from '../core/model/liveTilemap'; import { referenceCandidates } from '../core/model/objectRefs'; import { type Cell, presentTile } from '../core/model/tilemap'; import { descendantsBelow } from '../core/model/walk'; +import { isOverlayCamera } from '../core/model/renderLayers'; import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project'; import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject'; 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 - * parent, see `properties/enabled.ts`) the first one in hierarchy order. Undefined while none - * is on, and then nothing is rendered – switching one off is how a scene hands the view over. + * parent, see `properties/enabled.ts`) and no overlay (#302) the first one in hierarchy order. + * Undefined while none is on, and then nothing is rendered – switching one off is how a scene + * hands the view over. */ 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)); - 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. const scene = findScene(this.project, this.sceneId); const order = scene ? treeOrder(scene).map(({ object }) => object.id) : []; @@ -601,7 +612,7 @@ export class World { const index = order.indexOf(camera.objectId); 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. */ diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index 5f6acd30..441fd199 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -3,7 +3,7 @@ import { isSymbolType } from '../core/model/AssetTypes'; import { type Extent, scaleOf } from '../core/model/matrix'; import type { Vec2 } from '../core/model/Properties'; 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 { Project } from '../core/model/Project'; import { type Size, ui, Widget } from '../ui'; @@ -21,7 +21,9 @@ import { TextPicture } from './textPicture'; import { cellCentre, type LayerFocus, TilemapField } from './tilemap'; import { textureLibrary } from './TextureLibrary'; 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). */ const CLIP_GHOST_ALPHA = 0.3; @@ -85,6 +87,8 @@ export abstract class WorldViewport extends W private redraws = 0; private cameraMoves = 0; 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(); protected constructor(protected readonly state: S) { super(); @@ -111,6 +115,7 @@ export abstract class WorldViewport extends W /** The libraries outlive a signed-out editor; a stale listener would redraw destroyed graphics. */ override destroy(options?: Parameters[0]): void { for (const stop of this.stopFiles) stop(); + for (const overlay of this.overlays.values()) overlay.destroy(); super.destroy(options); } @@ -120,6 +125,16 @@ export abstract class WorldViewport extends W 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. */ protected abstract collectFrame(): Frame; @@ -308,10 +323,16 @@ export abstract class WorldViewport extends W this.visibleCount = 0; this.animated = false; this.drawn = null; - if (!view) return; + if (!view) { + this.syncOverlays(null, null, null); + return; + } 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; const { rect } = view; if (this.paintsBackground()) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background); @@ -384,6 +405,43 @@ export abstract class WorldViewport extends W } this.syncDrawOrder(items); 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 extends W 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; + } +} diff --git a/src/viewport/activeCamera.ts b/src/viewport/activeCamera.ts index 4e2f7418..a9a5ecac 100644 --- a/src/viewport/activeCamera.ts +++ b/src/viewport/activeCamera.ts @@ -1,6 +1,8 @@ /** Which camera the game view frames, and where it sits – from the running world or the model. */ import { worldPositionOf } from '../core/graph'; 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 { 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 * first camera that is on is simply the first match. */ 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. */ export function cameraOfWorld(world: World): ActiveCamera | null { 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); return { id: entity.objectId, x, y, props: entity.props }; } diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 2e93e18d..13fbcfa6 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -29,6 +29,7 @@ import { fontFileOf, fontSmoothOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; import type { FrameOutline } from '../core/model/frameOutline'; import { liveTilemap, type TileEdits } from '../core/model/liveTilemap'; +import { renderLayerAlong } from '../core/model/renderLayers'; import { type ClipRegion, clipRegions } from '../core/properties/clip'; import { colliderFrame } from '../core/properties/collider'; import { enabledAlongChain } from '../core/properties/enabled'; @@ -75,6 +76,8 @@ export interface DrawItem { frame: FrameOutline | null; /** Where the clip areas above it let it be seen and hit (#260), in world units; null when none is. */ clip: ClipRegion | null; + /** The render layer it is on, its parents' choice included (#302): which cameras see it. */ + renderLayer: number; } interface DrawList { @@ -235,6 +238,7 @@ export function collectHolderItems( emits: 0, frame: asset.type === 'collider' ? colliderFrame(project, props) : null, clip: null, + renderLayer: 0, }); } 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.sortsWith = passedOnTo(parentOf(item), parentOf)?.id ?? ''; item.clip = regions.get(item) ?? null; + item.renderLayer = renderLayerAlong(item, (i) => i.props, parentOf); } return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) }; } @@ -303,6 +308,11 @@ export function collectWorldItems(world: World): DrawList { emits: e.emits ?? 0, frame: e.wornFrame?.() ?? null, clip: regions.get(placed.get(e)!) ?? null, + renderLayer: renderLayerAlong( + e, + (at) => at.props, + (at) => at.parent ?? null, + ), })); return { items, background: world.backgroundColor }; } diff --git a/src/viewport/view.ts b/src/viewport/view.ts index 8675c31b..a9ab4e4c 100644 --- a/src/viewport/view.ts +++ b/src/viewport/view.ts @@ -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` * does in a view of the same size: same scale, and the camera's centre on the same screen pixel, diff --git a/tests/core/collision-layers.test.ts b/tests/core/collision-layers.test.ts index d5f22c83..03ed717d 100644 --- a/tests/core/collision-layers.test.ts +++ b/tests/core/collision-layers.test.ts @@ -1,6 +1,6 @@ import { beforeAll, describe, expect, it } from 'vitest'; 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 type { DeclaredPort, GraphNode } from '../../src/core/graph'; 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', () => { const f = makeSpriteScene('Thing'); 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']); f.stack.undo(); 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', () => { 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']); }); }); @@ -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', () => { 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.press('l'); expect(r.world.log).toEqual(['Ground', '0']); diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts index e1040168..63c82f2c 100644 --- a/tests/core/commands.test.ts +++ b/tests/core/commands.test.ts @@ -30,6 +30,7 @@ describe('asset commands', () => { mirrorX: false, mirrorY: false, sortWithParent: true, + renderLayer: -1, width: 100, height: 100, color: 0xffffff, diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts index 3a38542a..802b1f87 100644 --- a/tests/core/holders.test.ts +++ b/tests/core/holders.test.ts @@ -152,6 +152,7 @@ describe('CommandStack', () => { startSceneId: '', plugins: [], collisionLayers: ['Default'], + renderLayers: ['Default'], symbolScale: 1, playFrom: 'start' as const, whilePlaying: 'live' as const, diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts index a816355f..726d9afd 100644 --- a/tests/core/resolve.test.ts +++ b/tests/core/resolve.test.ts @@ -31,6 +31,7 @@ describe('PropertySchema', () => { mirrorX: false, mirrorY: false, sortWithParent: false, + renderLayer: -1, width: 100, height: 100, color: 0xffffff, @@ -63,6 +64,7 @@ describe('resolve', () => { mirrorX: false, mirrorY: false, sortWithParent: false, + renderLayer: -1, width: 50, height: 100, color: 0xffffff, @@ -83,6 +85,7 @@ describe('resolve', () => { mirrorX: false, mirrorY: false, sortWithParent: true, + renderLayer: -1, width: 100, height: 100, color: 0x00ff00, diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts index f8df1f2c..80584e46 100644 --- a/tests/core/serialize.test.ts +++ b/tests/core/serialize.test.ts @@ -178,6 +178,7 @@ describe('serialize', () => { rotation: 0, mirrorX: false, mirrorY: false, + renderLayer: -1, width: 10, height: 100, color: 0xffffff, diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 2abf5cba..a0a90b70 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -722,6 +722,7 @@ describe('a sprite wearing a shader', () => { 'mirrorX', 'mirrorY', 'sortWithParent', + 'renderLayer', 'width', 'height', 'color', @@ -742,6 +743,7 @@ describe('a sprite wearing a shader', () => { 'mirrorX', 'mirrorY', 'sortWithParent', + 'renderLayer', 'width', 'height', 'color', @@ -754,14 +756,14 @@ describe('a sprite wearing a shader', () => { expect(assetDeviates(f.project, f.sprite())).toBe(true); // A dangling id adds no rows and draws nothing – the picker still shows it. 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', () => { const f = wearing(); const id = place(f, f.spriteId, { x: 0, y: 0 }); 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)); expect(schemaForObject(f.project, f.sprite(), object()).map((d) => d.key)).toContain('saturation'); expect(resolveSceneObjectProps(f.project, f.sprite(), object())).toMatchObject({ diff --git a/tests/core/texture-commands.test.ts b/tests/core/texture-commands.test.ts index 949eea79..bc930386 100644 --- a/tests/core/texture-commands.test.ts +++ b/tests/core/texture-commands.test.ts @@ -69,6 +69,7 @@ describe('CreateAssetCommand with starting props', () => { mirrorX: false, mirrorY: false, sortWithParent: true, + renderLayer: -1, width: 64, height: 32, color: 0xffffff, diff --git a/tests/editor/project-settings.test.ts b/tests/editor/project-settings.test.ts index afbbad5f..c214fbdf 100644 --- a/tests/editor/project-settings.test.ts +++ b/tests/editor/project-settings.test.ts @@ -11,6 +11,7 @@ const before: ProjectSettings = { whilePlaying: 'balanced', gameLinkZoom: 'auto', collisionLayers: ['Default', 'Enemies'], + renderLayers: ['Default'], symbolScale: 1, }; diff --git a/tests/editor/sections.test.ts b/tests/editor/sections.test.ts index 77f5cff1..7b7b7b98 100644 --- a/tests/editor/sections.test.ts +++ b/tests/editor/sections.test.ts @@ -333,7 +333,7 @@ describe('the groups of the inspector', () => { const host = makeHost(state); buildObjectSection(host, f.sceneId, objectId); const shown = outline(host); - expect(shown.slice(0, 7)).toEqual([ + expect(shown.slice(0, 8)).toEqual([ '[SPRITE OBJECT]', 'Name', 'Asset', @@ -341,8 +341,9 @@ describe('the groups of the inspector', () => { '[SWITCHES]', 'Enabled', 'Keep on scene load', + 'Render layer', ]); - expect(shown[7]).toBe('[POSITION]'); + expect(shown[8]).toBe('[POSITION]'); expect(shown.filter((line) => line.startsWith('['))).toEqual([ '[SPRITE OBJECT]', '[SWITCHES]', @@ -385,15 +386,16 @@ describe('the groups of the inspector', () => { const host = makeHost(state); buildAssetSection(host, f.spriteId); const shown = outline(host); - expect(shown.slice(0, 5)).toEqual([ + expect(shown.slice(0, 6)).toEqual([ '[SPRITE ASSET]', 'Name', '[SWITCHES]', 'Enabled', 'Keep on scene load', + 'Render layer', ]); - expect(shown[5]).toBe('[PROPERTIES]'); - expect(shown.length).toBeGreaterThan(6); + expect(shown[6]).toBe('[PROPERTIES]'); + expect(shown.length).toBeGreaterThan(7); }); it('heads the rows of a behavior asset as switches, with no empty group after them', () => { diff --git a/tests/viewport/render-layers.test.ts b/tests/viewport/render-layers.test.ts new file mode 100644 index 00000000..7b5985a8 --- /dev/null +++ b/tests/viewport/render-layers.test.ts @@ -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; + 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'], + }); + }); +}); diff --git a/tools/e2e/63-render-layers.mjs b/tools/e2e/63-render-layers.mjs new file mode 100644 index 00000000..6fb3fbd1 --- /dev/null +++ b/tools/e2e/63-render-layers.mjs @@ -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; + `); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index 0c12c187..2fe20d4e 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -65,6 +65,7 @@ import { whilePlaying } from './59-while-playing.mjs'; import { release } from './60-release.mjs'; import { fullscreen } from './61-fullscreen.mjs'; import { devicePixels } from './62-device-pixels.mjs'; +import { renderLayers } from './63-render-layers.mjs'; export const STEPS = [ hierarchy, @@ -129,4 +130,5 @@ export const STEPS = [ release, fullscreen, devicePixels, + renderLayers, ];