game-maker / suite (push) Failing after 3m59s
A shader is an asset carrying a node graph (Texture color, Output, Property, Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite wears a shader through its Shader picker, and the rows below it are the graph's Property nodes. Property schema, resolution and deviation take the project because a sprite's rows now live in another asset. The built-in registry of the prototype is gone; "Adjust colors" ships as a graph in the fixture and the three example games. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
253 lines
9.7 KiB
TypeScript
253 lines
9.7 KiB
TypeScript
/**
|
||
* "Run in editor": a behavior that keeps running while the project is not playing. Unlike play
|
||
* mode this one edits the saved model, so these tests watch what lands in the scene itself.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||
import { instanceChildId } from '../../src/core/model/instance';
|
||
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import type { BehaviorAsset, ContainerAsset } from '../../src/core/model/Asset';
|
||
import { hasEditorRun, runsInEditor } from '../../src/core/properties/editorRun';
|
||
import { schemaFor } from '../../src/core/properties/PropertySchema';
|
||
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { EditorRun } from '../../src/editor/EditorRun';
|
||
import type { World } from '../../src/runtime/World';
|
||
import { EditorState } from '../../src/editor/state';
|
||
import { graphEditor, makeBehavior, makeSpriteScene, place, type SceneFixture } from '../helpers';
|
||
|
||
/** Wires `On update → Move(owner, dx = 1, scale = 100)`: the owner drifts 100 units per second. */
|
||
function drift(f: SceneFixture, behavior: BehaviorAsset): void {
|
||
const edit = graphEditor(f, behavior);
|
||
const update = edit.addNode('OnUpdate');
|
||
const owner = edit.addNode('Owner');
|
||
const move = edit.addNode('Move', { dx: 1, scale: 100 });
|
||
edit.wire([
|
||
[update, 'exec', move, 'exec'],
|
||
[update, 'dt', move, 'dt'],
|
||
[owner, 'object', move, 'object'],
|
||
]);
|
||
}
|
||
|
||
/** A box with a drifting behavior below it, in a state the editor run can be pointed at. */
|
||
function scene() {
|
||
const f = makeSpriteScene('Box');
|
||
const behavior = makeBehavior(f, 'Drift');
|
||
drift(f, behavior);
|
||
const box = place(f, f.spriteId, { x: 0, y: 0 });
|
||
const logic = place(f, behavior.id, { x: 0, y: 0 }, box);
|
||
|
||
const state = new EditorState();
|
||
state.loadProject(f.project, 1);
|
||
/** Stands in for the game view being the focused tab; flipped by the keyboard tests. */
|
||
const keyboard = { focused: false, world: null as World | null };
|
||
const run = new EditorRun(state, {
|
||
focused: () => keyboard.focused,
|
||
onWorld: (world) => (keyboard.world = world),
|
||
});
|
||
return {
|
||
...f,
|
||
behavior,
|
||
box,
|
||
logic,
|
||
state,
|
||
run,
|
||
keyboard,
|
||
/** One frame, after telling the run that the model may have changed (the Editor's wiring). */
|
||
frame(dt = 1 / 60) {
|
||
run.invalidate();
|
||
run.tick(dt);
|
||
},
|
||
boxX: () => f.object(box).position.x,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The shape a real level has: the box and its behavior packed into a container asset and placed
|
||
* once, so the scene stores the container object alone and the content is resolved (`instance.ts`).
|
||
*/
|
||
function containerScene() {
|
||
const f = scene();
|
||
const create = new CreateContainerCommand(f.sceneId, [f.box]);
|
||
f.stack.execute(create);
|
||
const asset = () => findAsset(f.project, create.assetId) as ContainerAsset;
|
||
const [tBox, tLogic] = asset().objects.map((t) => t.id) as [string, string];
|
||
const child = (templateId: string) => instanceChildId(create.objectId, templateId);
|
||
return {
|
||
...f,
|
||
containerId: create.assetId,
|
||
tBox,
|
||
tLogic,
|
||
child,
|
||
template: (id: string) => asset().objects.find((t) => t.id === id)!,
|
||
instanceEdits: () => f.sceneOf().objects[0]!.instanceEdits,
|
||
switchOn: () => f.state.execute(new SetOverrideCommand(f.sceneId, child(tLogic), 'runInEditor', true)),
|
||
boxX: () => f.object(child(tBox)).position.x,
|
||
};
|
||
}
|
||
|
||
describe('the run-in-editor switch', () => {
|
||
it('is a behavior property, off unless a scene object asks for it', () => {
|
||
const f = scene();
|
||
expect(schemaFor('behavior').map((d) => d.key)).toContain('runInEditor');
|
||
const asset = findAsset(f.project, f.behavior.id)!;
|
||
|
||
expect(runsInEditor(resolveSceneObjectProps(f.project, asset, f.object(f.logic)))).toBe(false);
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(false);
|
||
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
expect(runsInEditor(resolveSceneObjectProps(f.project, asset, f.object(f.logic)))).toBe(true);
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(true);
|
||
});
|
||
|
||
it('leaves everything alone until it is switched on', () => {
|
||
const f = scene();
|
||
for (let i = 0; i < 10; i++) f.frame();
|
||
expect(f.run.debugInfo()).toEqual({ running: false, time: 0, behaviors: [], keyboard: false });
|
||
expect(f.boxX()).toBe(0);
|
||
});
|
||
|
||
it('drives the object in the saved model once it is on, and marks the project for saving', () => {
|
||
const f = scene();
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
// The command itself dirtied the project; the question is whether the *run* does too.
|
||
f.state.setSaveStatus('saved');
|
||
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
// Not a copy, not a preview: the stored scene object moved, and that has to be saved.
|
||
expect(f.sceneOf().objects.find((o) => o.id === f.box)!.position.x).toBeCloseTo(10);
|
||
expect(f.state.saveStatus).toBe('dirty');
|
||
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
});
|
||
|
||
it('runs only the behaviors that ask for it', () => {
|
||
const f = scene();
|
||
const second = makeBehavior(f, 'Idle');
|
||
drift(f, second);
|
||
place(f, second.id, { x: 0, y: 0 }, f.box);
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
|
||
f.frame(0.1);
|
||
// Both behaviors own the same box: were the second one running too, it would drift twice as far.
|
||
expect(f.run.debugInfo().behaviors).toEqual([f.logic]);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
});
|
||
|
||
it('stops the moment the switch is taken back', () => {
|
||
const f = scene();
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
|
||
f.state.execute(new ClearOverrideCommand(f.sceneId, f.logic, 'runInEditor'));
|
||
f.frame(0.1);
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().running).toBe(false);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
});
|
||
|
||
it('holds still while the project is playing – that world runs every behavior already', () => {
|
||
const f = scene();
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
f.state.enterPlay();
|
||
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().running).toBe(false);
|
||
|
||
// The play copy carries the same ids, so the saved model must be the one left untouched.
|
||
f.state.exitPlay();
|
||
expect(f.boxX()).toBe(0);
|
||
});
|
||
|
||
it('does not run a behavior whose scene is not the loaded one', () => {
|
||
const f = scene();
|
||
f.state.execute(new SetOverrideCommand(f.sceneId, f.logic, 'runInEditor', true));
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().running).toBe(true);
|
||
|
||
const other = new CreateAssetCommand('scene', 'Level 2');
|
||
f.state.execute(other);
|
||
f.project.activeSceneId = other.asset.id;
|
||
f.frame(0.1);
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().running).toBe(false);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
});
|
||
});
|
||
|
||
describe('a behavior inside a placed container', () => {
|
||
it('is found although the scene only stores the container object', () => {
|
||
const f = containerScene();
|
||
expect(f.sceneOf().objects).toHaveLength(1);
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(false);
|
||
|
||
f.switchOn();
|
||
expect(hasEditorRun(f.project, f.sceneOf())).toBe(true);
|
||
});
|
||
|
||
it('drives the content, and the move is kept as an instance edit instead of baking it out', () => {
|
||
const f = containerScene();
|
||
f.switchOn();
|
||
f.state.setSaveStatus('saved');
|
||
|
||
f.frame(0.1);
|
||
expect(f.run.debugInfo().behaviors).toEqual([f.child(f.tLogic)]);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
expect(f.state.saveStatus).toBe('dirty');
|
||
|
||
// Baking would spell the content out as stored objects and drop every instance edit, so what
|
||
// moved has to land on the instance – where the template still reaches the rest of it.
|
||
expect(f.sceneOf().objects).toHaveLength(1);
|
||
expect(f.instanceEdits()[f.tBox]?.position.x).toBeCloseTo(10);
|
||
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(20);
|
||
expect(f.instanceEdits()[f.tBox]?.position.x).toBeCloseTo(20);
|
||
});
|
||
|
||
it('leaves the template alone, so a second instance of the same level stays put', () => {
|
||
const f = containerScene();
|
||
const second = place(f, f.containerId, { x: 500, y: 0 });
|
||
f.switchOn();
|
||
|
||
f.frame(0.1);
|
||
expect(f.boxX()).toBeCloseTo(10);
|
||
// The switch is an edit of the first instance only: the same box in the second one has neither
|
||
// the behavior running nor the movement.
|
||
expect(f.object(instanceChildId(second, f.tBox)).position.x).toBe(0);
|
||
expect(f.template(f.tBox).position.x).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('the keyboard of an editor run', () => {
|
||
it('is the game view’s, and only while something is running there', () => {
|
||
const f = containerScene();
|
||
expect(f.run.debugInfo().keyboard).toBe(false);
|
||
|
||
f.switchOn();
|
||
f.frame(0.1);
|
||
// Running, but the editor still owns the keys: the game view is not the focused tab.
|
||
expect(f.run.debugInfo()).toMatchObject({ running: true, keyboard: false });
|
||
|
||
f.keyboard.focused = true;
|
||
expect(f.run.debugInfo().keyboard).toBe(true);
|
||
});
|
||
|
||
it('hands the world to the game view while it runs, so clicks can reach it', () => {
|
||
const f = containerScene();
|
||
f.switchOn();
|
||
f.frame(0.1);
|
||
expect(f.keyboard.world).not.toBeNull();
|
||
|
||
f.state.execute(new ClearOverrideCommand(f.sceneId, f.child(f.tLogic), 'runInEditor'));
|
||
f.frame(0.1);
|
||
f.frame(0.1);
|
||
expect(f.keyboard.world).toBeNull();
|
||
});
|
||
});
|