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),
|
||||
|
||||
Reference in new issue
Block a user