`decimals` only reached `formatNumber`, so it changed the label and nothing else: typing 640.5 into a field with `decimals: 0` stored 640.5 and displayed 641. The two existing users got away with it because rounding happened outside the widget (`normalizeFrameRect`, the slice dialog), which their comments had to point out. `NumberField.clampValue` now rounds before it clamps, and every path that sets a value goes through it (typing, scrubbing, arrow keys, the constructor), so the stored number matches the shown one and `min`/`max` stay exact. The rounding is `roundTo` in math.ts, which `snapToStep` already did inline. With that safe, camera `View width`/`View height` and text `Font size` become integers – they were showing 640.000 and 32.000. Everything else that looked integral is not: positions, sprite and collider sizes and zoom are all scrubbed, and `z` is a summed coordinate (walk.ts), not a layer. Verified remotely against the RTX 4070 host: 270 checks, all passed (step 10 gained a check that the field text has no decimal point and survives a reopen). `npm run check` green, 407 tests.
513 lines
40 KiB
Markdown
513 lines
40 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`, `text`, `behavior`, `collider`, `container`, `texture`) and typed properties. Assets have **no position**. |
|
||
| **Texture** | An uploaded image. Scenes and textures are the two types that cannot be placed: a texture has no position, it is *worn* by a sprite through the ordinary property `texture`. See *Textures* below. |
|
||
| **Text** | An asset that draws a line of text (`text`, `fontSize`, `color`, `align`) – titles, HUD readouts, button labels. Its content is an ordinary property, so `Set text` (or `Set property`) changes it while playing. |
|
||
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. `Set enabled` / `Is enabled` do it from a graph. |
|
||
| **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 stores the container object **and nothing else** – its content is resolved from the template on every read, so a template edit reaches every instance at once. **Create container** turns the current selection into such an asset: the selected objects move into the template and the scene keeps one placed container at their centre (world positions unchanged). The template is edited in the **Container** tab (see *Editing a container*) or from an instance through **Apply to container**. |
|
||
| **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** 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. Both appear only where the value really differs from its base – an override carrying the asset's own value is none. |
|
||
| **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 resolved from a placed container's template. Its **values** can be edited – position, name, property overrides – and only what actually deviates is stored, as a sparse `instanceEdits` entry on the container object keyed by the template path. Everything else keeps following the template. Its **structure** cannot: it can not be removed, re-parented or given a collider, and nothing of the scene can be parented into it. ↺ ▾ resets to the nearest base, which inside a container is always the **template**; ↑ ▾ offers **Apply to container** (write the value into the template, where every other instance still at the old value picks it up) and/or **Apply to asset** (which drops the template's override as well). With one base left the menu collapses to a plain ↺ / ↑, with none the buttons disappear. Bold marks the same thing the buttons offer. |
|
||
| **Unpack** | Writes a placed container's content into the scene as ordinary objects (hierarchy context menu, or the inspector's *Content* section). The ids survive it, so the selection does; from then on the structure is editable and the objects no longer follow the template. |
|
||
| **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/`, one file per node): events `On start`, `On update`, `On key down/up/held`,
|
||
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none) · flow `Branch`, `Sequence` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
|
||
`Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set
|
||
variable`, `Get/Set property`, `Get position` (local and world), `Is enabled`, literals, `Add/Subtract/Multiply/Divide`,
|
||
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `Join text`, `Equals`,
|
||
`Length`, `Distance`, `Normalize`, `Axis input` (four keys → a
|
||
normalized direction, so diagonals are not faster), `Compare`, `And/Or/Not`, `Key is down`,
|
||
`Mouse position`, `Mouse button`, `Is hovered`, `Is touching` ·
|
||
actions `Move` (dx, dy, scale, dt – connect an event's dt for per-second movement), `Set position`, `Set enabled`, `Set text`, `Spawn`
|
||
(asset setting + x/y/z, optional parent → outputs the new object), `Destroy`, `Log`, `Stop game`, `Restart game`. A node type is one file exporting a `NodeDef`
|
||
(ports, settings, `run`), listed in `nodes/index.ts`; 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.
|
||
- **Cross-object state**: a behavior's variables *are* the properties of its object, so another
|
||
graph reads and writes them with `Object by name` → `Get property` / `Set property`. That is
|
||
how the example game keeps one coin counter for the whole run.
|
||
- **Mouse**: the Game tab feeds the pointer to the world in world units. `On click` fires for
|
||
the behavior whose owner was hit – colliders decide when there are any, otherwise the sprites
|
||
and texts of the owner and everything below it, so a button made of a box plus a label works
|
||
as one. A switched-off screen cannot swallow clicks. A behavior **without an owner** has no
|
||
shape that could be hit, so it takes every click inside the camera frame instead (the
|
||
`world x`/`world y` outputs say where) – that is the "click anywhere" listener.
|
||
- **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)`.
|
||
- **Idiomatic platformer** (the example game's "Player"): `On update` adds `gravity · dt` to a
|
||
`vy` variable and moves the owner by `(axis.x · speed, vy)` with *Collide with solids* on;
|
||
`blocked y` while falling means standing, so `vy` goes back to 0 and `onGround` becomes true;
|
||
`On key down (Space)` sets `vy = −jumpSpeed` when `onGround`.
|
||
- **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.
|
||
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
|
||
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
|
||
finds), **Fit**, and the size of the graph as "N nodes · M links".
|
||
- **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior
|
||
or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at
|
||
all) leaves the tab on the behavior you were editing, because checking a sprite's position
|
||
must not empty your work. Only a behavior that no longer exists is dropped, and then the tab
|
||
falls back to another one – the picker is how you switch on purpose.
|
||
- **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. A viewport label keeps a
|
||
constant size on screen however far you zoom, and both kinds of them – the object names of the
|
||
Scene tab and the frame names of the Texture tab – read that size from one token
|
||
(`Theme.viewportLabelSize`); the two `viewportLabel` styles differ in colour only.
|
||
|
||
**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.
|
||
Dragging into the **gap** above or below a row – its outer quarter, marked with a line instead of
|
||
an outline – orders the object among *that* row's siblings rather than putting it inside, and
|
||
re-parents it on the way when the gap belongs to another parent. Since equal `z` falls back to
|
||
this order, that is also how two objects at the same depth are put in front of each other. A drag
|
||
that lands where it started changes nothing and costs no undo step.
|
||
Rows with children **fold**: click the chevron (or press ←/→, or double-click the row) to open
|
||
and close a subtree, right-click → **Expand all** / **Collapse all** for the whole scene. A
|
||
scene opens fully collapsed – which rows are open is view state of the panel, never stored in
|
||
the project – and selecting an object anywhere (viewport, `Add collider`, `Create container`)
|
||
unfolds the way down to it and scrolls it into view, so a selection is never hidden. 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.
|
||
|
||
**Editor offset (scene view only).** Every scene object additionally carries an
|
||
`editorOffset` – an x/y shift of where it is *drawn, selected and dragged* in the Scene tab,
|
||
and nothing else. The game, collisions and the graph never see it; it is there to pull apart
|
||
objects that sit on top of each other, such as the stacked title/win/lose screens of a game.
|
||
It cascades like a position (offsetting a container offsets its content) and is edited in the
|
||
inspector below X/Y/Z, undoable, with ↺ back to 0. It belongs to the placed object, so
|
||
`Create container` and placing a container never carry it into the template.
|
||
|
||
## 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. An overlap that already exists is never resolved (no jolt
|
||
when objects start stacked or inside a wall) but also never deepened, so such an object can
|
||
always leave. A move longer than the thinnest collider involved is split into sub-steps – a
|
||
fast object cannot jump across a thin wall and end up on the other side. 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.
|
||
|
||
## Textures
|
||
|
||
A sprite is a coloured box until it is given a picture. The picture is a **texture asset**,
|
||
and the sprite points at it through the ordinary property `texture` — which is the whole
|
||
design: because it is an ordinary property, instance overrides, **Apply to container**,
|
||
**Reset to asset**, undo and `Set property` work on it without a single line written for them.
|
||
One coin instance can wear a different picture than its nine siblings, and swapping a texture
|
||
at runtime is a graph node that already exists.
|
||
|
||
**Getting an image in.** Three ways, all ending in the same import: **+ → Texture** in the
|
||
asset panel, **dropping image files onto the canvas**, or **Ctrl+V** pasting one. Several files
|
||
at once are imported one after another; a file that fails does not stop the rest.
|
||
|
||
**Where it goes.** The image is uploaded as raw bytes to `POST ?r=textures` and stored as
|
||
`data/textures/{sha256}.{ext}` — the file is named after its own content. That name buys four
|
||
things: the same picture uploaded twice is one file, a stored file can be cached forever
|
||
(a name cannot come to mean different bytes), the project document holds a short string
|
||
instead of megabytes of base64, and deleting a texture asset is undoable for free because
|
||
nothing on disk is removed. The price is that files outlive the projects that referenced them.
|
||
|
||
**Colour tints the picture.** `color` multiplies the texture, so it defaults to **white**
|
||
(= no tint) and a picture shows its own colours. Tinting is worth having: one white coin
|
||
picture becomes ten differently coloured coins, per instance, for nothing. Without a texture
|
||
`color` is simply the fill of the box, as before.
|
||
|
||
**`filter`** is the texture's only property: *Smooth* (default) or *Pixel art*, which is the
|
||
difference between crisp and mush when a small image is scaled up. Switching it needs a
|
||
`style.update()` on the texture source, not just the assignment: Pixi's `scaleMode` setter
|
||
writes the filters but leaves the style's cached resource id in place, and that id is the key
|
||
the GPU sampler is looked up under — so without it the picture only changes on a reload.
|
||
|
||
### Frames — cutting a sheet up
|
||
|
||
The **Texture tab** shows one texture with its **frames** on top: named rectangles in image
|
||
pixels. Drag on empty picture to cut one, drag it or its eight handles to adjust it, and edit
|
||
the exact numbers in the inspector; `Delete` removes the selected frame. **Slice…** cuts a
|
||
regular grid at once — you give columns and rows (not a cell size: everyone knows how many
|
||
tiles their sheet has, and deriving the cell size from the picture cannot disagree with it),
|
||
with optional offset and spacing for padded sheets, and the dialog says how many frames of
|
||
what size the numbers would produce before it commits.
|
||
|
||
A sprite then wears **a frame instead of the whole picture** — but through the *same*
|
||
property. `texture` holds either a texture id (the whole image) or a frame id, because frame
|
||
ids come from the same generator and are unique across the document. That is what keeps one
|
||
picker, one property and one set of instance overrides, and what makes every sprite written
|
||
before frames existed still mean exactly what it meant. The picker lists each sheet followed
|
||
by its frames as "Sheet / Frame", and it is searchable, because one sliced sheet adds an entry
|
||
per cell.
|
||
|
||
Frames are intrinsic data on the texture, like a scene's objects and a behavior's graph — not
|
||
properties, since nothing about them is overridable per instance. All edits go through
|
||
commands, so cutting, dragging, slicing and re-slicing are undoable, and one drag is **one**
|
||
undo step. Deleting a frame leaves the sprites pointing at it: a dangling reference draws the
|
||
plain colour and comes back by itself on undo. Deleting the whole texture counts the sprites
|
||
wearing *any* of its frames, not just the ones naming the texture directly.
|
||
|
||
**Fitting the sprite to the picture.** The *Width* and *Height* rows of a sprite carry a ↔ / ↕
|
||
button offering two things: **Fit to 64 × 48** sets that one side from the other and the picture's
|
||
proportions — the frame's rectangle when it wears a frame, the whole image otherwise — and **Set
|
||
to 64 × 48 px** puts both sides at the picture's own pixel size. Like ↺ and ↑ the button exists
|
||
only when it would do something, and follows the same rule for how many: no offer means no button,
|
||
one is a direct click, both make it the menu form ↔ ▾. So a sprite without a picture, or one
|
||
already in proportion *and* at pixel size, carries nothing. Either action is an ordinary property
|
||
write, so on an object it becomes an override, and setting both sides is still one undo step. The
|
||
proportions are compared with a relative tolerance rather than exactly — of the 40 000 picture
|
||
sizes up to 200 × 200, 3135 do not survive the round trip through a division, and an exact test
|
||
would leave the button standing after its own click. The pixel size is compared exactly, because
|
||
those two numbers are written as they are.
|
||
|
||
Rendering a frame is a texture matrix on the fill, not a Pixi sub-texture — a Graphics fill
|
||
ignores a `Texture`'s `frame` (measured, see `viewport/textureFill.ts`). One consequence is
|
||
worth having: a sheet stays **one** GPU upload however many frames of it are on screen.
|
||
|
||
**Nothing ever breaks on a missing picture.** While an image loads, and for a reference that
|
||
dangles because the texture was deleted, the sprite draws its plain colour — exactly what it
|
||
looked like before textures existed. So there is no placeholder, no flicker and no error path.
|
||
Deleting a texture warns how many sprites reference it (a texture is never *placed*, so
|
||
counting placed instances would report "unused" for a picture a dozen sprites are showing).
|
||
|
||
**Limits.** nginx caps request bodies at 1 MB, so the editor shrinks images over 2048 px per
|
||
side and re-encodes to WebP when they are still too big — but an image that already fits is
|
||
uploaded byte for byte, because re-encoding pixel art would destroy it. Anything still too
|
||
large is refused with a clear message instead of a broken upload. The server accepts PNG,
|
||
JPEG, WebP and GIF, deriving the type from the content rather than from anything the client
|
||
says, and rejects images over 8192 px per side.
|
||
|
||
## Editing a container
|
||
|
||
The **Container tab** shows one container's template with the *same* viewport the Scene tab uses –
|
||
`ContainerCanvas` subclasses `SceneViewport` and swaps nothing but which asset holds the objects,
|
||
so picking, rubber band, dragging, gizmos, labels and parent lines are not written twice. It adds a
|
||
cross at the **origin**, because that is the point the container is held by when placed: where the
|
||
content sits relative to it is the thing being authored. A template has no background of its own –
|
||
painting one would invent the scene it is not in.
|
||
|
||
The **Scene objects** panel lists whatever the visible tab edits, so in the Container tab it lists
|
||
the template, with the same drag-to-parent, context menu and folding as a scene; the inspector,
|
||
`Delete` and `Ctrl+G` work on template objects like on placed ones. None of that is a special case:
|
||
a scene asset and a container asset are both `ObjectHolder`s, and every object command takes a
|
||
`holderId` that is either one.
|
||
|
||
**Header bar**: the container picker (searchable), **Fit** (frames the content, origin always
|
||
included) and the size of the template as "N objects". Placing needs no button of its own: the
|
||
Assets panel already places, and its entry follows the visible tab – **Place in scene** becomes
|
||
**Place in container** while this tab is open, and drops at the middle of *this* view. A right-click
|
||
marks a row without selecting it, so opening that menu on a container does not load it into the tab
|
||
first – otherwise nesting one container in another would place it into itself.
|
||
Like the Graph tab it follows the selection without being bound to it – the container asset, one of
|
||
its placed copies or one of its template objects shows it, anything else leaves the tab on what you
|
||
were editing, and only a deleted container makes it fall back to another one. Selecting a template
|
||
*object* also switches to the tab, the rule a graph node and a texture frame already follow: it
|
||
exists in no other view. Selecting the asset itself does not.
|
||
|
||
Placing a container anywhere creates **one** object. Its content is never copied: `resolveObjects`
|
||
(`src/core/model/instance.ts`) weaves the template in whenever the holder is read, one level per
|
||
nesting step, and the resolved objects get derived ids – `<instance id>~<template id>`, joined by
|
||
`/` for a container inside a container. That is what the hierarchy lists, what the viewport draws
|
||
and what a command addresses. A container that reaches this one, directly or through another, is
|
||
still refused – that cycle would resolve forever.
|
||
|
||
So **editing a template changes every instance**, structure included: add, delete or re-parent a
|
||
template object and every placed container shows it immediately. What an instance may deviate in
|
||
is values, and only per slot: an override key, one position axis, the name. Those live in
|
||
`instanceEdits` on the container object and are dropped again the moment the value matches the
|
||
template, which is what keeps "untouched follows, touched keeps" exact without any bookkeeping.
|
||
|
||
The other direction is therefore just as small: **Apply to container** writes the value into the
|
||
template and drops the edit, **Reset to container** drops the edit alone.
|
||
|
||
Two places do spell the content out. **Unpack** does it on purpose – the objects become ordinary
|
||
ones and stop following. And **play mode bakes** every placed container into stored objects, in
|
||
the throwaway copy of the model, because a running behavior may move or destroy one of them and a
|
||
resolved object has nowhere to remember that. Both keep the derived ids, so the selection and a
|
||
check script survive them.
|
||
|
||
## Screens without scene switching
|
||
|
||
A **screen** (title, win, lose, a pause overlay) is just a container whose `enabled` is
|
||
switched on and off. It keeps working while it is off in one important way: `Object by name`
|
||
still finds it, so a graph can switch it back on. A behavior inside a disabled screen has no
|
||
interpreter yet – it gets one, and its `On start`, the moment the screen is switched on, which
|
||
makes "the screen initialises itself when shown" the default. Its variables survive being
|
||
switched off and on again. A switched-off object is hidden in the Scene tab as well, so the
|
||
editor shows what the game shows; it stays listed in the hierarchy panel, which is where it is
|
||
selected and switched back on.
|
||
|
||
`Restart game` throws the run away and starts the scene again from the saved model – what the
|
||
"Play again" buttons of the example game do.
|
||
|
||
## 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.
|
||
Menus read as lists: entries are left-aligned and scroll once they pass 320 px instead of
|
||
running off the screen. A menu that grows with the project – the parent picker lists every
|
||
object of the scene – is opened with `{ searchable: true }`, which puts a filter box above
|
||
the entries (Enter takes the first match). A field that opens a menu is a `Dropdown`: value
|
||
flush left, chevron drawn flush right rather than written into the label.
|
||
Every label that can be wider than its control is cut with an ellipsis (`truncate` in
|
||
`Label.ts`, shared by `Label`, `ListRow` and `Button`). A `Button` keeps the full text and
|
||
measures from it – only what is drawn is shortened – and a subclass that puts something else
|
||
inside the control takes its room off `labelSlot()`, so a long value is cut rather than run
|
||
underneath the chevron. Given a `width`, a button is exactly that wide and truncates instead:
|
||
the tab-bar buttons and the Graph, Texture and Container pickers keep their size whatever they
|
||
show.
|
||
The padding is an inset rather than a claim, so a 24 px icon button spends it on the glyph
|
||
instead of truncating it away; a button that switches between label forms (`↺` and `↺ ▾`) asks
|
||
for `minWidth` and grows. `__gm.textOverflows()` guards the whole class of bug: it reports any
|
||
visible text drawn wider than the widget owning it, `__gm.labelsDropped()` guards the other
|
||
direction – a button too narrow to draw any of its label – and the `pickerWidth()` of
|
||
`__gm.graph` / `__gm.texture` / `__gm.container` catch a picker that resizes itself around its
|
||
content (e2e step 14).
|
||
- **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/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), container (the cycle guard)
|
||
src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled
|
||
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts
|
||
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes/<category>/<Type>.ts
|
||
src/core/store/ serialize (untrusted JSON → Project), HttpProjectStore
|
||
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
|
||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||
src/viewport/ WorldViewport base + SceneViewport/GameViewport, view (transforms),
|
||
items (what to draw), symbols (how to draw it)
|
||
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
|
||
state, actions, shortcuts, debugHook, autosave
|
||
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command),
|
||
one section per selection kind (asset/object/node)
|
||
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
|
||
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette
|
||
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
|
||
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths)
|
||
api/ PHP backend data/projects/ persisted projects
|
||
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, lib/
|
||
```
|
||
|
||
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; a new node is one file **plus a row in the table in
|
||
`tests/core/node-catalog.test.ts`**, which fails if a node has no case. Comments state contracts
|
||
and reasons, not what the code does: a `/** */` on every export whose name does not say it all,
|
||
`//` only for a decision or gotcha.
|
||
|
||
## 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 lint # ESLint, type-aware; --fix for the fixable half
|
||
npm run format # Prettier over src, tests and tools
|
||
npm run check # typecheck + lint + format:check + tests, what CI would run
|
||
npm run build # writes dist/ and regenerates index.html
|
||
export GM_REMOTE=user@host # where the browser runs; see below
|
||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 270 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
||
```
|
||
|
||
**A run needs a working GPU.** An idle editor page keeps rendering a picture that never changes, and
|
||
in software that is ruinous: measured here on an idle page over 20 s, software WebGL costs **373% of
|
||
a core at 8 fps**, the same page on the Intel HD 630 via ANGLE/Vulkan costs **69% at 56 fps**. The
|
||
full suite takes ~124 s that way. `tools/lib/browser.mjs` therefore always asks for the GPU and has
|
||
**no software fallback** – on a host without one, chromium should fail loudly rather than melt the
|
||
machine down. (`--use-angle=gl` and `--use-gl=egl` measure cheaper still, but get no WebGL context at
|
||
all, so the app never renders.)
|
||
|
||
**`GM_REMOTE=user@host` moves the browser to another machine** – `tools/lib/browser.mjs` starts it
|
||
over SSH and forwards its CDP port back to `127.0.0.1`, so the harness itself is unchanged. The
|
||
checks load the public base URL, so the remote side needs only a browser and internet: no repo, no
|
||
node. Setup is an SSH key (`GM_REMOTE_KEY`, default `~/.ssh/gm-win.key`). Measured against a Windows
|
||
desktop with an RTX 4070: **270 checks in 128 s and 24 smoke checks in 16 s, with this machine idle**
|
||
(0.4 s of CPU for the whole smoke run, load average 0.04). **That is how the suite is meant to be
|
||
run here.** A local run works and costs 124 s, but it pins the development box – set `GM_REMOTE` and
|
||
leave it set; without a reachable host, do not fall back to local, fix the host.
|
||
|
||
`tools/remote/gm-browser.ps1` is copied over on every run, and getting the browser to die with the
|
||
run took three tries, because two obvious leashes do not hold on Windows:
|
||
|
||
- **stdin is not a signal.** Only the first chunk a caller writes ever reaches the remote command,
|
||
so a heartbeat over stdin is indistinguishable from a dead caller – it killed a run at check 44.
|
||
- **A dropped connection does not kill the remote command.** Windows OpenSSH leaves it running;
|
||
measured, six orphaned `chrome.exe` survived a SIGKILLed tunnel.
|
||
|
||
What holds is the CDP port: while a run is under way the forwarded connection is established, and it
|
||
closes the moment the run or the tunnel dies. The launcher watches for that with .NET rather than
|
||
`Get-NetTCPConnection` – that one goes through WMI, which is denied to a non-admin account, and its
|
||
broken CIM session took a browser down mid-run. A job object with `KILL_ON_JOB_CLOSE` catches the
|
||
rest, and a 30-minute cap bounds anything still unforeseen. Measured: the browser survives 80 s of an
|
||
idle held connection and is gone 44 s after the local node process is killed with `-9`.
|
||
|
||
**Prettier owns the formatting** (110 columns, single quotes, `.prettierrc.json`); Markdown is
|
||
excluded because it pads table cells to equal width. ESLint therefore only ever means "this is a
|
||
mistake", never "this looks different": `eslint-config-prettier` switches every stylistic rule
|
||
off, and what is left is type-aware – unnecessary conditions, floating promises, duplicate
|
||
imports, base-to-string. Where a rule fires on something deliberate, the disable comment states
|
||
why (the `Log` node's `console.log`, a guard against untyped test hooks).
|
||
|
||
Each script writes to its own project (`tools/lib/projects.mjs`): the **fixture** project is
|
||
rewritten by every e2e run and the **jump-run** project by its seeder. The **playground**
|
||
project is the author's own and is maintained in the editor – every tool refuses to touch it.
|
||
|
||
The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, assets, drawing,
|
||
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
|
||
container tab) that build
|
||
on each other's scene state
|
||
and share the CDP driver in `tools/lib/harness.mjs` – `press`, `undo`, `play`, `object(id)`,
|
||
`world()`, `mutate(js)` and friends, so a check reads like the interaction it exercises.
|
||
|
||
**The example game** (`tools/seed-jumprun.mjs`, project *Jump and Run*) is a complete little
|
||
platformer built only from assets, containers and graphs: a title screen with a clickable Start
|
||
button, a level of boxes with ten coins, gravity and jumping, a two-minute clock in the HUD, and
|
||
a win / lose screen with "Play again". A coin is a container of its own (sprite plus trigger
|
||
collider) placed ten times inside the level container – nested containers resolve through both levels,
|
||
and picking one up destroys the container, not just the sprite the collider reported. `tools/smoke-jumprun.mjs` plays it in a headless browser
|
||
(start, jump, collect, win, restart, lose) – it is the regression test for everything screens,
|
||
text, mouse and platformer physics need.
|
||
|
||
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 |
|
||
| GET | `textures` | `[{file,bytes,updatedAt}]` |
|
||
| POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) |
|
||
| DELETE | `textures/{file}` | 204 |
|
||
|
||
Uploads send the bytes as the body rather than `multipart/form-data`: no boundary parsing in
|
||
PHP, and PHP's `upload_max_filesize` (2 MB) never applies — only `post_max_size`. The stored
|
||
files are served statically from `data/textures/`, so reading an image never runs PHP.
|
||
|
||
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 · `Ctrl+V` import an image from
|
||
the clipboard as a texture (ignored while a text field has focus) · `Tab` cycles fields · number
|
||
fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that increment, so a
|
||
step-1 field lands on whole numbers), `↑`/`↓` step, shown with three decimals (`ui/math.ts`,
|
||
`NUMBER_DECIMALS`) so a fractional value never reads as a whole number — except where the
|
||
value is integral by nature (frame rectangles, the slice dialog), which set
|
||
`NumberField`'s `decimals: 0` and show plain whole numbers · 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
|
||
|
||
Several scenes in one run (a `Load scene` node – screens are containers
|
||
today), sound, auth, touch-optimised layout, graph comments/groups. A frame is always stretched to
|
||
the sprite's `width` × `height`: no animation (nothing steps through a sheet's frames yet) and no
|
||
nine-slice. Slicing cuts a regular grid only — no detection of where the sprites actually
|
||
sit on the sheet. Unreferenced image files are never collected.
|