Initial commit: Game Maker phase 1

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>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 10:07:06 +02:00
commit 5a46124454
65 files changed
+7193

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
import type { AssetType } from '../core/model/AssetTypes';
import type { PropertyBag } from '../core/model/Properties';
/**
* A live instance created from a scene object when Play starts. Holds the *resolved*
* property values as mutable runtime state. Systems change entities freely; nothing here
* is ever written back to the project model.
*/
export interface Entity {
id: string;
/** Scene object this entity was instantiated from (for selection highlighting). */
objectId: string;
assetId: string;
type: AssetType;
name: string;
x: number;
y: number;
props: PropertyBag;
}
+72
View File
@@ -0,0 +1,72 @@
import { findAsset, findScene, type Project } from '../core/model/Project';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { Entity } from './Entity';
import type { System } from './systems/System';
/**
* The running game: entities instantiated from one scene plus the systems ticking them.
* Created on Play, thrown away on Stop – the project model is never touched.
*/
export class World {
readonly entities: Entity[] = [];
readonly backgroundColor: number;
private readonly systems: System[] = [];
private elapsed = 0;
private constructor(readonly sceneId: string, backgroundColor: number) {
this.backgroundColor = backgroundColor;
}
/** Builds a world from the given scene. Throws when the scene does not exist. */
static instantiate(project: Project, sceneId: string): World {
const scene = findScene(project, sceneId);
if (!scene) throw new Error(`Scene ${sceneId} not found`);
const background = scene.props.backgroundColor;
const world = new World(sceneId, typeof background === 'number' ? background : 0x000000);
for (const object of scene.objects) {
const asset = findAsset(project, object.assetId);
if (!asset || asset.type === 'scene') continue;
world.entities.push({
id: `e-${object.id}`,
objectId: object.id,
assetId: asset.id,
type: asset.type,
name: object.name || asset.name,
x: object.position.x,
y: object.position.y,
props: resolveSceneObjectProps(asset, object),
});
}
return world;
}
addSystem(system: System): this {
this.systems.push(system);
system.start?.(this);
return this;
}
/** Advances the simulation by `dt` seconds. */
tick(dt: number): void {
this.elapsed += dt;
for (const system of this.systems) system.update(this, dt);
}
get time(): number {
return this.elapsed;
}
findByObjectId(objectId: string): Entity | undefined {
return this.entities.find((e) => e.objectId === objectId);
}
firstOfType(type: Entity['type']): Entity | undefined {
return this.entities.find((e) => e.type === type);
}
destroy(): void {
for (const system of this.systems) system.stop?.(this);
this.systems.length = 0;
this.entities.length = 0;
}
}
+25
View File
@@ -0,0 +1,25 @@
import type { Entity } from '../Entity';
import type { World } from '../World';
import type { System } from './System';
/**
* Makes the first camera entity follow a target entity (smoothly). If the scene has no
* camera the viewport falls back to a fixed view at the origin.
*/
export class CameraFollowSystem implements System {
private camera: Entity | undefined;
constructor(private readonly target: () => Entity | undefined, private readonly smoothing = 8) {}
start(world: World): void {
this.camera = world.firstOfType('camera');
}
update(_world: World, dt: number): void {
const target = this.target();
if (!this.camera || !target) return;
const t = 1 - Math.exp(-this.smoothing * dt);
this.camera.x += (target.x - this.camera.x) * t;
this.camera.y += (target.y - this.camera.y) * t;
}
}
+33
View File
@@ -0,0 +1,33 @@
/** Keyboard state tracker for play mode. Only records keys; systems interpret them. */
export class Input {
private readonly down = new Set<string>();
private readonly onKeyDown = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
this.down.add(e.key);
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
};
private readonly onKeyUp = (e: KeyboardEvent) => this.down.delete(e.key);
private readonly onBlur = () => this.down.clear();
attach(): void {
window.addEventListener('keydown', this.onKeyDown);
window.addEventListener('keyup', this.onKeyUp);
window.addEventListener('blur', this.onBlur);
}
detach(): void {
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
this.down.clear();
}
isDown(key: string): boolean {
return this.down.has(key);
}
/** -1, 0 or 1 for a pair of keys. */
axis(negative: string, positive: string): number {
return (this.isDown(positive) ? 1 : 0) - (this.isDown(negative) ? 1 : 0);
}
}
@@ -0,0 +1,41 @@
import type { Entity } from '../Entity';
import type { World } from '../World';
import { Input } from './Input';
import type { System } from './System';
/**
* Phase-1 demo behaviour: the arrow keys (or WASD) move one sprite entity. Which one is
* decided at start: the entity of the scene object that was selected when Play was
* pressed, otherwise the first sprite in the scene.
*/
export class PlayerControlSystem implements System {
private readonly input = new Input();
private player: Entity | undefined;
constructor(private readonly preferredObjectId: string | null, private readonly speed = 240) {}
start(world: World): void {
this.input.attach();
const preferred = this.preferredObjectId ? world.findByObjectId(this.preferredObjectId) : undefined;
this.player = preferred && preferred.type === 'sprite' ? preferred : world.firstOfType('sprite');
}
update(_world: World, dt: number): void {
if (!this.player) return;
const dx = this.input.axis('ArrowLeft', 'ArrowRight') || this.input.axis('a', 'd');
const dy = this.input.axis('ArrowUp', 'ArrowDown') || this.input.axis('w', 's');
if (dx === 0 && dy === 0) return;
const length = Math.hypot(dx, dy);
this.player.x += (dx / length) * this.speed * dt;
this.player.y += (dy / length) * this.speed * dt;
}
stop(): void {
this.input.detach();
this.player = undefined;
}
get controlled(): Entity | undefined {
return this.player;
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { World } from '../World';
/** A behaviour that runs every frame over the world's entities. */
export interface System {
start?(world: World): void;
update(world: World, dt: number): void;
stop?(world: World): void;
}