Canvas-only 2D game editor/runtime built on PixiJS v8 + Vite + TypeScript. - core: asset model (Scene/Camera/Sprite), scene objects with sparse overrides, property schema + resolve, command stack (undo/redo), serialization and project store interface (16 unit tests) - ui: custom in-canvas widget toolkit (panels, fields, color picker, list/scroll, popup) with a hidden <input> keyboard bridge - editor: asset/hierarchy panels, schema-driven inspector, toolbar - viewport: box rendering, camera frame, selection gizmo, drag/zoom/pan - runtime: world/entity instantiation for play mode, input, camera follow - api: PHP JSON project store with revision counter (409 on conflict) - tools: CDP-based e2e smoke test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
135 lines
5.4 KiB
TypeScript
135 lines
5.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
CreateAssetCommand,
|
|
DeleteAssetCommand,
|
|
RenameAssetCommand,
|
|
SetActiveSceneCommand,
|
|
SetAssetPropertyCommand,
|
|
} from '../../src/core/commands/assetCommands';
|
|
import { CommandStack } from '../../src/core/commands/CommandStack';
|
|
import {
|
|
ClearOverrideCommand,
|
|
MoveSceneObjectCommand,
|
|
PlaceAssetCommand,
|
|
RemoveSceneObjectCommand,
|
|
SetOverrideCommand,
|
|
} from '../../src/core/commands/sceneObjectCommands';
|
|
import { createEmptyProject, findScene } from '../../src/core/model/Project';
|
|
|
|
function setup() {
|
|
const project = createEmptyProject('p1', 'Test');
|
|
const stack = new CommandStack(project);
|
|
const scene = new CreateAssetCommand('scene', 'Level 1');
|
|
const sprite = new CreateAssetCommand('sprite', 'Box');
|
|
stack.execute(scene);
|
|
stack.execute(sprite);
|
|
return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id };
|
|
}
|
|
|
|
describe('asset commands', () => {
|
|
it('creates assets with defaults and activates the first scene', () => {
|
|
const { project, sceneId, spriteId } = setup();
|
|
expect(project.assets).toHaveLength(2);
|
|
expect(project.activeSceneId).toBe(sceneId);
|
|
expect(project.assets.find((a) => a.id === spriteId)?.props).toEqual({ width: 100, height: 100, color: 0xe0553f });
|
|
});
|
|
|
|
it('renames and sets properties with undo/redo', () => {
|
|
const { project, stack, spriteId } = setup();
|
|
stack.execute(new RenameAssetCommand(spriteId, 'Crate'));
|
|
stack.execute(new SetAssetPropertyCommand(spriteId, 'width', 250));
|
|
const sprite = () => project.assets.find((a) => a.id === spriteId)!;
|
|
expect(sprite().name).toBe('Crate');
|
|
expect(sprite().props.width).toBe(250);
|
|
stack.undo();
|
|
expect(sprite().props.width).toBe(100);
|
|
stack.undo();
|
|
expect(sprite().name).toBe('Box');
|
|
stack.redo();
|
|
stack.redo();
|
|
expect(sprite().name).toBe('Crate');
|
|
expect(sprite().props.width).toBe(250);
|
|
});
|
|
|
|
it('rejects unknown properties', () => {
|
|
const { stack, spriteId } = setup();
|
|
expect(() => stack.execute(new SetAssetPropertyCommand(spriteId, 'x', 5))).toThrow(/not defined/);
|
|
});
|
|
|
|
it('deletes an asset together with its scene objects and restores both on undo', () => {
|
|
const { project, stack, sceneId, spriteId } = setup();
|
|
stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 1, y: 2 }));
|
|
stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 3, y: 4 }));
|
|
expect(findScene(project, sceneId)!.objects).toHaveLength(2);
|
|
stack.execute(new DeleteAssetCommand(spriteId));
|
|
expect(project.assets).toHaveLength(1);
|
|
expect(findScene(project, sceneId)!.objects).toHaveLength(0);
|
|
stack.undo();
|
|
expect(project.assets).toHaveLength(2);
|
|
expect(findScene(project, sceneId)!.objects.map((o) => o.position)).toEqual([
|
|
{ x: 1, y: 2 },
|
|
{ x: 3, y: 4 },
|
|
]);
|
|
});
|
|
|
|
it('switches the active scene when the active one is deleted', () => {
|
|
const { project, stack, sceneId } = setup();
|
|
const second = new CreateAssetCommand('scene', 'Level 2');
|
|
stack.execute(second);
|
|
stack.execute(new SetActiveSceneCommand(second.asset.id));
|
|
stack.execute(new DeleteAssetCommand(second.asset.id));
|
|
expect(project.activeSceneId).toBe(sceneId);
|
|
stack.undo();
|
|
expect(project.activeSceneId).toBe(second.asset.id);
|
|
});
|
|
});
|
|
|
|
describe('scene object commands', () => {
|
|
it('places assets and refuses scenes', () => {
|
|
const { project, stack, sceneId, spriteId } = setup();
|
|
const place = new PlaceAssetCommand(sceneId, spriteId, { x: 5, y: 6 });
|
|
stack.execute(place);
|
|
const object = findScene(project, sceneId)!.objects[0]!;
|
|
expect(object.id).toBe(place.objectId);
|
|
expect(object.assetId).toBe(spriteId);
|
|
expect(object.position).toEqual({ x: 5, y: 6 });
|
|
expect(object.overrides).toEqual({});
|
|
|
|
expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(/cannot be placed/);
|
|
expect(findScene(project, sceneId)!.objects).toHaveLength(1);
|
|
});
|
|
|
|
it('moves, overrides, clears and removes with undo', () => {
|
|
const { project, stack, sceneId, spriteId } = setup();
|
|
const place = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 });
|
|
stack.execute(place);
|
|
const object = () => findScene(project, sceneId)!.objects.find((o) => o.id === place.objectId)!;
|
|
|
|
stack.execute(new MoveSceneObjectCommand(sceneId, place.objectId, { x: 40, y: 50 }));
|
|
expect(object().position).toEqual({ x: 40, y: 50 });
|
|
|
|
stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'color', 0x123456));
|
|
expect(object().overrides).toEqual({ color: 0x123456 });
|
|
expect(() => stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'nope', 1))).toThrow();
|
|
|
|
stack.execute(new ClearOverrideCommand(sceneId, place.objectId, 'color'));
|
|
expect(object().overrides).toEqual({});
|
|
stack.undo();
|
|
expect(object().overrides).toEqual({ color: 0x123456 });
|
|
|
|
stack.execute(new RemoveSceneObjectCommand(sceneId, place.objectId));
|
|
expect(findScene(project, sceneId)!.objects).toHaveLength(0);
|
|
stack.undo();
|
|
expect(object().position).toEqual({ x: 40, y: 50 });
|
|
});
|
|
|
|
it('clears the redo stack on a new command', () => {
|
|
const { stack, sceneId, spriteId } = setup();
|
|
stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 }));
|
|
stack.undo();
|
|
expect(stack.canRedo).toBe(true);
|
|
stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 1, y: 1 }));
|
|
expect(stack.canRedo).toBe(false);
|
|
});
|
|
});
|