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:
commit
5a46124454
65 files changed
+7193
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user