204 lines
15 KiB
Markdown
204 lines
15 KiB
Markdown
# Game Maker
|
||
|
||
A browser-based tool "one step before a game engine": create **assets** (scenes, cameras,
|
||
sprites), place them into scenes, tweak their properties in an inspector and press **Play** –
|
||
all in one program, all drawn inside a single WebGL canvas.
|
||
|
||
Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com/game-maker/?playground>
|
||
|
||
## Concepts
|
||
|
||
| Term | Meaning |
|
||
|---|---|
|
||
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`, `collider`, `container`) and typed properties. Assets have **no position**. |
|
||
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
|
||
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. **Create container** turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). The template is edited from its instances (see *Instance*). |
|
||
| **Selection** | One or more scene objects. Click selects, `Ctrl`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove from scene** for several objects. |
|
||
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
|
||
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
|
||
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺) and an **apply** (↑) that writes the value into the asset instead. |
|
||
| **Default** | Every property has a schema default. Asset values that differ from it are marked bold in the inspector and can be reset (↺) the same way an override can. |
|
||
| **Instance** | An object that is part of a placed container. Its base is the **template**, not just the asset: position and overrides that differ from the template are bold, ↺ reverts to the template, ↑ ▾ offers **Apply to container** (template; copies still at the old value follow, copies changed on their own keep theirs) or **Apply to asset**. Position rows apply to the container directly. |
|
||
| **Edit mode** | The Scene tab draws the project model; panels, grid, gizmos and drag-to-move are active. |
|
||
| **Play mode** | The editor switches to a **runtime copy** of the project (same ids). A `World` is instantiated from its active scene (one entity per scene object, resolved properties as mutable state) and shown in the **Game** tab. Systems tick it and mirror their changes into the copy; every panel stays open and edits the copy, which the world picks up immediately. **Stop discards world and copy** – the saved model is never touched. |
|
||
|
||
**The camera defines what is rendered.** A camera asset has a view size (`width` × `height`,
|
||
default 640×360) and a `zoom`; the frame it covers in world units is `size / zoom`. The Scene
|
||
tab draws that frame as an outline. The Game tab renders only that frame – fitted into the
|
||
view with black letterboxing – and objects outside it are culled. A scene without a camera
|
||
renders nothing in the Game tab. Play has no built-in behaviour (no input, no movement):
|
||
everything that happens is defined by behavior graphs.
|
||
|
||
**Editing while playing.** Play opens the Game tab but leaves the editor fully usable: the
|
||
Scene tab shows the live positions (symbols, gizmos and dragging included), the hierarchy and
|
||
inspector show and edit the running objects, the graph can be changed, undo/redo work on the
|
||
play session's own history. All of it happens in the runtime copy: values a behavior changes
|
||
appear as overrides (bold, ↺ resets to the asset value), objects a graph destroys vanish from
|
||
the lists, objects a graph spawns (`Spawn` node) appear in them – selectable, editable, still
|
||
only in the runtime copy –, and Esc/Stop brings back exactly what was saved. Autosave pauses
|
||
while playing.
|
||
|
||
## Behaviors (node graphs)
|
||
|
||
A behavior asset holds a Blueprint-style graph edited in the **Graph** tab of the centre
|
||
area (the **Scene** tab shows the editable scene, the **Game** tab the running game). Ports come in two kinds: **exec** (white
|
||
arrows, control flow) and **data** (coloured circles; `number`, `boolean`, `string`,
|
||
`object`, `any`). Event nodes start a pulse; the interpreter follows exec edges and pulls
|
||
data inputs lazily from connected outputs. Unconnected data inputs use the inline value
|
||
shown on the node and editable in the inspector.
|
||
|
||
- **Variables** are the behavior's own property schema (`BehaviorAsset.variables`): they
|
||
appear in the inspector, can be overridden per placed instance, and each running instance
|
||
gets its own mutable copy. `Get variable` / `Set variable` read and write them.
|
||
- **Owner** = parent: a placed behavior is usually parented to the object it controls
|
||
(inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the
|
||
viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up
|
||
from any object.
|
||
- **Node set** (`src/core/graph/nodes.ts`): events `On start`, `On update`, `On key down/up/held`,
|
||
`On timer`, `On collision enter/stay/exit` · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Get/Set
|
||
variable`, `Get/Set property`, `Get position` (local and world), literals, `Add/Subtract/Multiply/Divide`,
|
||
`Min/Max/Abs/Sqrt/Clamp`, `Length`, `Distance`, `Normalize`, `Axis input` (four keys → a
|
||
normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down`, `Is touching` ·
|
||
actions `Move` (dx, dy, scale, dt – connect an event's dt for per-second movement), `Set position`, `Spawn`
|
||
(asset setting + x/y/z, optional parent → outputs the new object), `Destroy`, `Log`, `Stop game`. A node type is one `registerNode()` call; palette, ports, validation and
|
||
inspector fields are generated from it. A port may declare a `default` for its inline value
|
||
(e.g. `Move.scale = 1`), shown on the node and in the inspector.
|
||
- **Idiomatic movement** (the demo's "Mover", five nodes): `On update → Move(Owner, dx = Axis
|
||
input.x, dy = Axis input.y, scale = Get variable speed, dt = On update.dt)`.
|
||
- **Editing**: right-click the canvas for the searchable palette, drag port → port to connect
|
||
(types are checked; a data input holds one link and is replaced), drag nodes, right-click a
|
||
node or port to delete/disconnect, `Delete` removes the selected node, wheel zooms, dragging
|
||
the empty canvas pans. Everything is undoable.
|
||
- **Selection-bound**: the Graph tab shows the behavior the current selection refers to
|
||
(asset, placed object or node); with nothing or a non-behavior selected it is empty.
|
||
- **Live view**: Play always switches to the Game tab; switch to Graph while playing and
|
||
the exec edges and nodes that just ran light up – the same canvas draws editor and game, so
|
||
this costs nothing extra.
|
||
- **Runtime**: `BehaviorSystem` creates one `GraphInterpreter` per behavior entity; graphs only
|
||
touch world entities. A runaway loop throws after 1000 steps and stops play with a notice.
|
||
|
||
**Editor symbols are always on top.** The viewport draws scene content (sprites) in an
|
||
`objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a
|
||
sprite can never hide a symbol regardless of z or hierarchy order.
|
||
|
||
**Z-order is part of the position.** `position.z` is the draw order of a scene object: higher
|
||
`z` is drawn on top; objects with equal `z` keep their hierarchy (tree) order. It is edited in the
|
||
inspector next to X/Y, undoable like any move, and applies in edit and play mode alike.
|
||
|
||
**Hierarchy (parenting).** Every scene object may have a parent in the same scene
|
||
(`SceneObject.parentId`). Positions – including `z` – are **local to the parent**; the world
|
||
position is the sum along the chain (`src/core/model/hierarchy.ts`). Children move with their
|
||
parent and are removed with it (also when the parent's asset is deleted); undo brings the whole
|
||
subtree back. Re-parenting keeps the world position by recomputing the local one, and cycles
|
||
are rejected. In the **Scene objects** panel children are indented below their parent; drag a
|
||
row onto another row to parent it, onto the empty space below the rows (or right-click →
|
||
Unparent) to move it to the top level; the inspector offers the same as a **Parent** menu. In
|
||
play mode entities carry the same `parent` link: moving a parent entity moves its subtree,
|
||
`Destroy` removes it. `Get position` exposes both the local and the world coordinates.
|
||
|
||
## Colliders and collisions
|
||
|
||
A **collider** is an asset (`shape` box/circle, `width`/`height` or `radius`) placed like any
|
||
other object – typically as a child of the sprite it belongs to, so its position is the offset
|
||
from that *body* and it moves with it. Right-click an object in **Scene objects** (or press
|
||
**+ Add collider** in its inspector) to get one with one click: a box collider sized like the
|
||
sprite, parented at its centre. Colliders can be reused across objects, overridden per instance
|
||
and stacked (hitbox + hurtbox on one sprite; colliders of the same body never collide with each
|
||
other). They are editor symbols: green outlines in edit mode, invisible in play mode.
|
||
|
||
Play runs a `CollisionSystem` (`src/runtime/systems/CollisionSystem.ts`) before the behaviors:
|
||
every frame it tests all collider pairs (world positions applied; touching edges do not count)
|
||
and sorts them into *enter*, *stay* and *exit*. Detection never pushes anything apart. A
|
||
behavior receives `On collision enter/stay/exit` for the colliders of its **owner** (its
|
||
parent; itself when top-level): the outputs are `other` (the other body), `other collider`
|
||
and `collider` (the own one). `Is touching(a, b)` answers the same question on demand – an
|
||
object stands for its collider children, a collider for itself. The geometry lives in
|
||
`src/core/properties/collider.ts` so editor and runtime agree on it.
|
||
|
||
**Blocking** is opt-in per movement: the `Move` node has a **Collide with solids** setting
|
||
(default off). When enabled it moves an object one axis at a time and, after each axis, pushes
|
||
its *solid* colliders back to just touch any solid collider they ran into – so the other axis
|
||
still moves and the object slides along walls. Its `blocked x` / `blocked y` outputs tell which
|
||
axes hit (always `false` with the setting off). A collider with
|
||
**Trigger (no blocking)** enabled (`isTrigger`, drawn amber instead of green) still produces
|
||
collision events but never blocks. Pairs that already overlapped before the step are left
|
||
alone, so an object that starts inside a wall can leave it and stacked objects never jolt apart.
|
||
Colliders of the moving object and of anything parented under it travel with it and cannot
|
||
block it. `Move` without the setting and `Set position` ignore colliders entirely.
|
||
|
||
## Stack
|
||
|
||
- **PixiJS v8** – rendering and pointer events. No game engine; runtime systems live in `src/runtime/`.
|
||
- **Own canvas widget toolkit** (`src/ui/`) – panels, buttons, lists, text/number/color fields,
|
||
popups. The only DOM element is a hidden `<input>` used as a keyboard bridge (IME, mobile
|
||
keyboard, clipboard) while a canvas text field is focused.
|
||
- **Vite + TypeScript**, **Vitest** for the pure core.
|
||
- **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with
|
||
atomic writes and an integer revision that rejects stale saves (HTTP 409).
|
||
|
||
## Layout
|
||
|
||
```
|
||
web/index.html Vite source page (dev) index.html built page (generated, served)
|
||
src/core/ model, property schema, resolve, commands, store – no Pixi imports
|
||
src/core/graph/ graph model, node registry + built-in nodes, interpreter (headless, unit-tested)
|
||
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
|
||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||
src/viewport/ scene renderer: Scene tab (model, editable) and Game tab (world through its camera)
|
||
src/editor/ App composition, state, panels, inspector field factory
|
||
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
|
||
api/ PHP backend data/projects/ persisted projects
|
||
tests/ Vitest unit tests tools/ e2e.mjs (CDP smoke test), seed-demo.mjs
|
||
```
|
||
|
||
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
|
||
twice a second) in edit and play mode alike; `__gm.fps()` exposes the same number to tests.
|
||
|
||
Rules that keep it organised: `core/` imports nothing from other layers; `ui/` never sees
|
||
assets; position exists only on scene objects; overrides are merged in exactly one place
|
||
(`core/properties/resolve.ts`); the inspector is generated from `PropertySchema` – a new
|
||
property is one schema line.
|
||
|
||
## Development
|
||
|
||
Node 22 is required (on this machine: `PATH=/opt/node22/bin:$PATH`).
|
||
|
||
```bash
|
||
npm install
|
||
npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production
|
||
npm test # unit tests (core)
|
||
npm run typecheck
|
||
npm run build # writes dist/ and regenerates index.html
|
||
node tools/seed-demo.mjs <projectId> # reset the demo project to the state the e2e run expects
|
||
node tools/e2e.mjs <projectId> # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png
|
||
```
|
||
|
||
The workspace folder itself is what nginx serves, so `npm run build` is the deployment.
|
||
|
||
## API
|
||
|
||
All routes go through `api/index.php?r=…` (the site's nginx does not pass `PATH_INFO`).
|
||
|
||
| Method | Route | Body → Result |
|
||
|---|---|---|
|
||
| GET | `projects` | `[{id,name,revision,updatedAt}]` |
|
||
| POST | `projects` | `{name}` → project document (201) |
|
||
| GET | `projects/{id}` | `{id,name,revision,updatedAt,project}` |
|
||
| PUT | `projects/{id}` | `{revision, project}` → `{revision,updatedAt}` or **409** `{revision}` when stale |
|
||
| DELETE | `projects/{id}` | 204 |
|
||
|
||
There is no authentication yet; `data/` is world-readable. Both are phase-2 topics.
|
||
|
||
## Keyboard
|
||
|
||
`Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo · `Delete` remove selection · `Ctrl+G` create
|
||
container from the selection · `Ctrl+Enter` play · `Esc` stop · `Tab` cycles fields · number
|
||
fields: drag to scrub (`Shift` ×10, `Alt` ×0.1), `↑`/`↓` step · viewport: wheel zooms,
|
||
middle-drag or `Shift`+drag pans, drag on empty space rubber-bands, `Ctrl`-click toggles an
|
||
object in the selection (hierarchy rows too).
|
||
|
||
## Not yet
|
||
|
||
Textures on sprites, editing a container's template directly (today changes reach it only
|
||
via **Apply to container** from an instance), reordering the hierarchy, auth, touch-optimised layout, graph comments/groups.
|