Show each camera only its layers and lay overlay cameras on top (#302)
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:
1 parent
f03ca0d382
commit
b2326085ed
33 files changed
+802
-115
No files matched your search
@@ -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<LayerList, string> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 FlatKind> = K extends 'number'
|
||||
? { min?: number; max?: number; step?: number; decimals?: number }
|
||||
@@ -119,7 +121,9 @@ type Extras<K extends FlatKind> = 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<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. */
|
||||
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: '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<AssetType, PropertySchema> = Object.fromEntries(
|
||||
...COMMON_PROPS,
|
||||
...(hasTransformProps(type) ? TRANSFORM_PROPS : []),
|
||||
...sortPropsOf(type),
|
||||
...renderLayerPropsOf(type),
|
||||
...ASSET_SCHEMAS[type],
|
||||
]
|
||||
: ASSET_SCHEMAS[type],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<GameLinkZoom, string> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<EditorState> {
|
||||
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<EditorState> {
|
||||
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<EditorState> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
+15
-4
@@ -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. */
|
||||
|
||||
@@ -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<S extends ViewSource = ViewSource> 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<string, OverlayCameraView>();
|
||||
|
||||
protected constructor(protected readonly state: S) {
|
||||
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. */
|
||||
override destroy(options?: Parameters<Widget['destroy']>[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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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<S extends ViewSource = ViewSource> 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user