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; 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, masterVolume: 1, masterFx: '', }; 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']); }); });