The issue asked for Set render layer, Set sees and Render layer of. As with #211, a probe showed that writing already works. A Properties node of the object's type writes Render layer during a run, Inherit included, and one of type Camera writes Sees. The next picture follows both. As agreed, only Render layer of is built, and writing stays with Properties. - Render layer of is a data node with the input object and the outputs name and number. It answers the layer that holds, so an object without a choice of its own names its parent's. - NodeContext.renderLayerOf answers the number with the same rule the drawing uses (renderLayerAlong). The new field renderLayers on GraphLookup brings the names to the node. - hasRenderLayer in PropertySchema says which types are drawn. - Decided here: for an object that is not drawn (a camera, a behavior, a collider) the name is empty and the number -1, as Layer of does without a collider. Changing the render layer or Sees during a run had no test so far. Three unit tests now hold the proof from the issue. Tests: - Gate: 231 files, 3534 tests (3526 + 8), exit 0 - e2e 6: 149 checks passed, e2e 63: 13 checks passed (+1) - Unit: the coin moves into the overlay with Set and back with Inherit, a HUD container takes its sprite along, the main camera sees a second layer once its Sees is set, and Render layer of names the inherited layer, and none for a behavior - Node catalog: four cases for Render layer of - 63c3 moves a sprite onto UI in a Play run: the main camera no longer draws it, and the log reads "UI" and "1" - Three counter-probes fail 1, 1 and 2 tests: without inheriting in the node, without the check for a drawn type, and without inheriting in the drawing - "layer of" in the palette still finds Layer of first (6z-x) - The e2e sum is 1731 (1730 plus 1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||
import { SetLayerNamesCommand } from '../../src/core/commands/projectCommands';
|
||
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import type { GraphNode } from '../../src/core/graph';
|
||
import { OBJECT_TYPE_PARAM } from '../../src/core/graph/propertyPorts';
|
||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||
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,
|
||
cameraOfWorld,
|
||
overlaysOfItems,
|
||
overlaysOfWorld,
|
||
} from '../../src/viewport/activeCamera';
|
||
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
|
||
import { cameraView, overlayView } from '../../src/viewport/view';
|
||
import { type GraphEditor, graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
|
||
|
||
function sceneWithCamera() {
|
||
const fixture = makeSpriteScene();
|
||
const camera = new CreateAssetCommand('camera', 'Cam');
|
||
fixture.stack.execute(camera);
|
||
const container = new CreateAssetCommand('container', 'Hud');
|
||
fixture.stack.execute(container);
|
||
return {
|
||
...fixture,
|
||
cameraId: camera.asset.id,
|
||
containerId: container.asset.id,
|
||
set: (objectId: string, key: string, value: number | boolean) =>
|
||
fixture.stack.execute(new SetOverrideCommand(fixture.sceneId, objectId, key, value)),
|
||
items: () => collectHolderItems(fixture.project, fixture.sceneOf(), 0).items,
|
||
};
|
||
}
|
||
|
||
describe('render layers (#302)', () => {
|
||
it('reads the names like the collision layers, "Default" for a project from before', () => {
|
||
expect(renderLayersOf(null)).toEqual(['Default']);
|
||
expect(renderLayersOf({ renderLayers: ['World', '', 'UI'] })).toEqual(['World', 'Layer 1', 'UI']);
|
||
const f = sceneWithCamera();
|
||
const stored = serializeProject(f.project) as Record<string, unknown>;
|
||
delete stored.renderLayers;
|
||
expect(deserializeProject(stored).renderLayers).toEqual(['Default']);
|
||
});
|
||
|
||
it('reads an own choice, and anything unreadable as none', () => {
|
||
for (const value of [undefined, null, '', ' ', 'x', -1, -5, 16, true])
|
||
expect(ownRenderLayer(value)).toBe(INHERIT_LAYER);
|
||
expect(ownRenderLayer(0)).toBe(0);
|
||
expect(ownRenderLayer(3)).toBe(3);
|
||
expect(ownRenderLayer('2')).toBe(2);
|
||
});
|
||
|
||
it('puts an object without a choice on its parent’s layer, and the top on layer 0', () => {
|
||
const f = sceneWithCamera();
|
||
const hud = place(f, f.containerId, { x: 0, y: 0 });
|
||
const inside = place(f, f.spriteId, { x: 0, y: 0 }, hud);
|
||
const own = place(f, f.spriteId, { x: 0, y: 0 }, hud);
|
||
const loose = place(f, f.spriteId, { x: 0, y: 0 });
|
||
f.set(hud, RENDER_LAYER_KEY, 1);
|
||
f.set(own, RENDER_LAYER_KEY, 2);
|
||
const layerOf = (id: string) => f.items().find((item) => item.id === id)!.renderLayer;
|
||
expect([layerOf(hud), layerOf(inside), layerOf(own), layerOf(loose)]).toEqual([1, 1, 2, 0]);
|
||
|
||
const { world } = startWorld(f);
|
||
const running = (id: string) =>
|
||
collectWorldItems(world).items.find((item) => item.id === id)!.renderLayer;
|
||
expect([running(inside), running(own), running(loose)]).toEqual([1, 2, 0]);
|
||
});
|
||
|
||
it('lets a camera without `Sees` see every layer, and one with it only those', () => {
|
||
expect(cameraSees({}, 0)).toBe(true);
|
||
expect(cameraSees({}, 15)).toBe(true);
|
||
expect(cameraSees({ [SEES_KEY]: 0b10 }, 0)).toBe(false);
|
||
expect(cameraSees({ [SEES_KEY]: 0b10 }, 1)).toBe(true);
|
||
});
|
||
|
||
it('keeps the first camera without Overlay as the main one, the overlays apart in hierarchy order', () => {
|
||
const f = sceneWithCamera();
|
||
const over = place(f, f.cameraId, { x: 0, y: 0 });
|
||
const main = place(f, f.cameraId, { x: 100, y: 0 });
|
||
const plain = place(f, f.cameraId, { x: 200, y: 0 });
|
||
const over2 = place(f, f.cameraId, { x: 300, y: 0 });
|
||
f.set(over, OVERLAY_KEY, true);
|
||
f.set(over2, OVERLAY_KEY, true);
|
||
const { world } = startWorld(f);
|
||
expect(world.activeCamera()?.objectId).toBe(main);
|
||
expect(world.overlayCameras().map((c) => c.objectId)).toEqual([over, over2]);
|
||
expect(overlaysOfWorld(world).map((c) => c.x)).toEqual([0, 300]);
|
||
expect(cameraOfItems(f.items())?.id).toBe(main);
|
||
expect(overlaysOfItems(f.items()).map((c) => c.id)).toEqual([over, over2]);
|
||
// Two plain cameras on draw as before: through the first only, the second is no overlay.
|
||
expect(world.overlayCameras().map((c) => c.objectId)).not.toContain(plain);
|
||
|
||
f.set(over2, 'enabled', false);
|
||
world.syncFromModel();
|
||
expect(world.overlayCameras().map((c) => c.objectId)).toEqual([over]);
|
||
});
|
||
|
||
it('fits an overlay into the main picture, and gives the same camera the very same view', () => {
|
||
const props = { width: 640, height: 360, zoom: 1 };
|
||
const main = cameraView({ x: 0, y: 0, props }, 1280, 720, 2);
|
||
expect(overlayView({ x: 0, y: 0, props }, main, { w: 640, h: 360 })).toEqual(main);
|
||
|
||
// Narrower and zoomed: the frame is 160x180 world units, fitted to the 720 px height.
|
||
const narrow = overlayView({ x: 50, y: 7, props: { width: 320, height: 360, zoom: 2 } }, main, {
|
||
w: 640,
|
||
h: 360,
|
||
});
|
||
expect(narrow).toEqual({ x: 50, y: 7, scale: 4, rect: { x: 320, y: 0, w: 640, h: 720 } });
|
||
});
|
||
|
||
it('keeps the render layers an undo step and a change of the settings of their own', () => {
|
||
const f = sceneWithCamera();
|
||
f.stack.execute(new SetLayerNamesCommand('renderLayers', ['World', 'UI']));
|
||
expect(f.project.renderLayers).toEqual(['World', 'UI']);
|
||
expect(f.project.collisionLayers).toEqual(['Default']);
|
||
f.stack.undo();
|
||
expect(f.project.renderLayers).toEqual(['Default']);
|
||
|
||
const before = {
|
||
name: 'P',
|
||
playFrom: 'start' as const,
|
||
whilePlaying: 'live' as const,
|
||
gameLinkZoom: 'auto' as const,
|
||
collisionLayers: ['Default'],
|
||
renderLayers: ['Default'],
|
||
symbolScale: 1,
|
||
};
|
||
expect(settingsChanges(before, { ...before, renderLayers: ['Default'] })).toEqual({});
|
||
expect(settingsChanges(before, { ...before, renderLayers: ['Default', 'UI'] })).toEqual({
|
||
renderLayers: ['Default', 'UI'],
|
||
});
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A coin on `Default` and a HUD container with a sprite in it, a main camera that sees `Default` and
|
||
* an overlay that sees `UI`. Behaviors change the layers with Properties nodes, the way a graph does
|
||
* it (#305): the coin goes to `UI` on `u` and back to Inherit on `i`, the HUD to `UI` on `h`, and the
|
||
* main camera sees both layers on `s`. On `l` the HUD's sprite asks `Render layer of` of itself, of
|
||
* the HUD and of its behavior, which is not drawn.
|
||
*/
|
||
function layerSwitchScene() {
|
||
const f = sceneWithCamera();
|
||
f.stack.execute(new SetLayerNamesCommand('renderLayers', ['Default', 'UI']));
|
||
const coin = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const hud = place(f, f.containerId, { x: 0, y: 0 });
|
||
const inside = place(f, f.spriteId, { x: 0, y: 0 }, hud);
|
||
const main = place(f, f.cameraId, { x: 0, y: 0 });
|
||
const over = place(f, f.cameraId, { x: 0, y: 0 });
|
||
f.set(main, SEES_KEY, 0b01);
|
||
f.set(over, SEES_KEY, 0b10);
|
||
f.set(over, OVERLAY_KEY, true);
|
||
|
||
const behaviorOn = (objectId: string, name: string) => {
|
||
const behavior = makeBehavior(f, name);
|
||
f.stack.execute(new PlaceAssetCommand(f.sceneId, behavior.id, { x: 0, y: 0 }, objectId));
|
||
return graphEditor(f, behavior);
|
||
};
|
||
const setOnKey = (edit: GraphEditor, key: string, type: string, port: string, value: number) => {
|
||
const set = edit.properties([{ key: port, kind: 'number', write: true }], {
|
||
[OBJECT_TYPE_PARAM]: type,
|
||
[port]: value,
|
||
});
|
||
edit.connect(edit.addNode('Owner'), 'object', set, 'object');
|
||
edit.connect(edit.addNode('OnKeyDown', { key }), 'exec', set, 'exec');
|
||
};
|
||
const onCoin = behaviorOn(coin, 'Coin');
|
||
setOnKey(onCoin, 'u', 'sprite', RENDER_LAYER_KEY, 1);
|
||
setOnKey(onCoin, 'i', 'sprite', RENDER_LAYER_KEY, INHERIT_LAYER);
|
||
const onHud = behaviorOn(hud, 'Hud');
|
||
setOnKey(onHud, 'h', 'container', RENDER_LAYER_KEY, 1);
|
||
const onCamera = behaviorOn(main, 'Cam');
|
||
setOnKey(onCamera, 's', 'camera', SEES_KEY, 0b11);
|
||
|
||
const onInside = behaviorOn(inside, 'Inside');
|
||
let from = onInside.addNode('OnKeyDown', { key: 'l' });
|
||
const ask = (source: GraphNode, port: string) => {
|
||
const of = onInside.addNode('RenderLayerOf');
|
||
onInside.connect(source, port, of, 'object');
|
||
for (const output of ['name', 'number']) {
|
||
const log = onInside.addNode('Log');
|
||
onInside.connect(of, output, log, 'message');
|
||
onInside.connect(from, 'exec', log, 'exec');
|
||
from = log;
|
||
}
|
||
};
|
||
const owner = onInside.addNode('Owner');
|
||
const parent = onInside.addNode('Parent');
|
||
onInside.connect(owner, 'object', parent, 'object');
|
||
ask(owner, 'object');
|
||
ask(parent, 'parent');
|
||
ask(onInside.addNode('Self'), 'object');
|
||
return { ...f, coin, hud, inside };
|
||
}
|
||
|
||
describe('a graph changes render layers and what a camera sees during the run (#305)', () => {
|
||
const start = () => {
|
||
const s = layerSwitchScene();
|
||
const started = startWorld(s, { behaviors: true });
|
||
const press = (key: string) => {
|
||
started.input.press(key);
|
||
started.frame();
|
||
started.input.release(key);
|
||
};
|
||
const seenBy = (camera: { props: PropertyBag } | null) => {
|
||
const items = collectWorldItems(started.world).items.filter((item) => item.type === 'sprite');
|
||
const seen = items.filter((item) => camera && cameraSees(camera.props, item.renderLayer));
|
||
return seen.map((item) => (item.id === s.coin ? 'coin' : item.id === s.inside ? 'inside' : item.id));
|
||
};
|
||
const main = () => seenBy(cameraOfWorld(started.world));
|
||
const overlay = () => seenBy(overlaysOfWorld(started.world)[0]!);
|
||
started.frame();
|
||
return { ...s, ...started, press, main, overlay };
|
||
};
|
||
|
||
it('moves the coin into the overlay with Set, and back with Inherit', () => {
|
||
const r = start();
|
||
expect([r.main(), r.overlay()]).toEqual([['coin', 'inside'], []]);
|
||
r.press('u');
|
||
expect([r.main(), r.overlay()]).toEqual([['inside'], ['coin']]);
|
||
r.press('i');
|
||
expect([r.main(), r.overlay()]).toEqual([['coin', 'inside'], []]);
|
||
});
|
||
|
||
it('takes the HUD container’s sprite along when the container changes', () => {
|
||
const r = start();
|
||
r.press('h');
|
||
expect([r.main(), r.overlay()]).toEqual([['coin'], ['inside']]);
|
||
});
|
||
|
||
it('lets the main camera see a second layer once its Sees is set', () => {
|
||
const r = start();
|
||
r.press('h');
|
||
r.press('s');
|
||
expect(r.main()).toEqual(['coin', 'inside']);
|
||
});
|
||
|
||
it('names the layer that holds, the inherited one too, and none for what is not drawn', () => {
|
||
const r = start();
|
||
r.press('l');
|
||
expect(r.world.log).toEqual(['Default', '0', 'Default', '0', '', '-1']);
|
||
r.press('h');
|
||
r.press('l');
|
||
expect(r.world.log.slice(6)).toEqual(['UI', '1', 'UI', '1', '', '-1']);
|
||
});
|
||
});
|