All 50 notes are now issues #1-#50 in meleki/game-maker, open ones open and finished ones closed. Every issue keeps the note verbatim and adds a footer with the commit it was filed in, the commit that finished it and who raised it; the 37 notes the user wrote are posted as `admin`, the 13 I raised as `meleki`. Label `todo-note` marks the whole batch. todo-notes/ is gone - the tracker is the backlog from here on. README says where they live now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
941 lines
78 KiB
Markdown
941 lines
78 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 a `Properties` node of type Text with `text` switched to Set 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. From a graph, `Properties` with `enabled` switched to Set does it, and `Is enabled` asks (including the parents). |
|
||
| **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`/`Shift`-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. |
|
||
| **Run in editor** | A switch only behaviors have (default off). With it on the behavior keeps running while the project is **not** playing – and unlike Play it then edits the *saved* model: what it moves or writes is a real change, autosaved and not on the undo stack. See *Running without Play*. |
|
||
| **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 paints their label gold 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 gold in the inspector and can be reset (↺) the same way an override can. The asset's name in the **Assets** panel carries the same gold while any of them does (`assetDeviates`, `src/core/properties/deviation.ts` – the inspector marks the rows of that same schema, so the list cannot claim a deviation the inspector then fails to show). A behavior's variables are not in it: their default is declared on the variable itself, so the asset value cannot differ from it. |
|
||
| **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 the **template as the containers in between leave it**; ↑ ▾ 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. Gold 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. Of several cameras the first
|
||
one in hierarchy order that is **switched on** (`enabled`, its parents included) is the active
|
||
one, so switching it off hands the view to the next; with none on the tab stays empty and says
|
||
*No active camera*. Without playing the tab previews the scene through that same camera, so it
|
||
shows what Play would show, minus the editor symbols. Play has no built-in behaviour (no input,
|
||
no movement): everything that happens is defined by behavior graphs.
|
||
|
||
**Toolbar and panels.** The toolbar holds the **Project** menu (new project, switch project,
|
||
*Delete this project…*), the project name (type to rename), the **Scene** picker for the active
|
||
scene, Undo/Redo, Play/Pause, and on the right the notice line, the save status and the frame
|
||
rate. The **Assets** panel's `+` creates an asset; right-clicking a row offers *Set as active
|
||
scene* (double-click or `Enter` does the same), *Create sprite from texture*, *Create animation
|
||
from texture* and *Delete…*. In
|
||
**Scene objects** it offers *Select asset*, *Add collider*, *Create container*, *Unpack
|
||
container*, *Unparent*, *Expand all* / *Collapse all* and *Remove*.
|
||
|
||
**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 (gold, ↺ 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.
|
||
|
||
**Pausing.** The toolbar's *Pause* button (or `Ctrl+Enter`) holds the running world: it is no
|
||
longer ticked, so behaviors, timers and collisions stand still and the world clock stops with
|
||
them. It is a sub-state of play mode, not a third mode – the runtime copy, the panels and the
|
||
keyboard behave exactly as while playing, and everything the editor can do during play it can
|
||
do during a hold. Keys and clicks that arrive while it is held are **dropped** rather than
|
||
queued, so resuming does not fire a frame's worth of input at once. Stop clears the hold, so a
|
||
paused game can never outlive its run.
|
||
|
||
**Running without Play.** A behavior switched to **Run in editor** goes on running while the
|
||
project is not playing (`src/editor/EditorRun.ts`, ticked by the Editor next to the play session).
|
||
The switch is an ordinary property, so it can be set on the behavior asset and overridden per
|
||
object. What it runs is a `World` over the **saved** model, and that is the whole difference to
|
||
Play: a position or variable it changes is a real edit – it is autosaved, the panels show it, and
|
||
**undo does not take it back**. Which is why the switch is off by default and why it belongs on
|
||
behaviors that arrange or build content rather than on ones that simulate.
|
||
|
||
**Input goes to the tab you are looking at.** While the **Scene** tab is on screen the keyboard and
|
||
the mouse are the editor's – typing a name or walking the hierarchy with the arrow keys must not
|
||
steer a game at the same time. In the **Game** tab, where no key means anything to the editor, they
|
||
reach the run instead: `On key …`, `Key is down` and `On click` work there, and the tab says so in
|
||
its top-left corner. Leaving the tab (or focusing a field in it) hands the keyboard back and lets
|
||
every held key go, so nothing keeps walking with nobody pressing anything. What the run does *not*
|
||
get:
|
||
|
||
- **structural changes to the content of a placed container** – such content is resolved from the
|
||
template, not stored (see *Containers*), and the editor run must not bake it out, since that is
|
||
itself an edit. Its behaviors do run, and what they move or set is kept as an `instanceEdits`
|
||
entry on the instance; but a `Destroy` of resolved content is dropped from the running world
|
||
without being saved, and comes back the next time the world is rebuilt.
|
||
- **a second life** – only the behaviors of the **loaded** scene run, and only in edit mode; Play
|
||
stops the editor run, since that world runs every behavior anyway. An exception in a graph, or a
|
||
`Stop game` node, ends the run and puts the reason in the notice line instead of repeating it
|
||
every frame.
|
||
|
||
## 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`, `list`, `struct`, `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. One node reads and writes them, `Variables`: its settings are a *list*
|
||
of variables, and each one is an output that can be read at any time – no flow has to reach it –
|
||
and is read at the moment it is asked, so a read after a write sees the new value. A variable
|
||
switched to **Set** in the inspector is an input too, written when the flow arrives; the node has
|
||
its flow pins only while at least one is. Unconnected and with nothing typed, it writes the zero
|
||
of its kind (0, false, empty), and the node shows that value beside the name. All of them are written together, their inputs read
|
||
first, so one node swaps two variables. `count = count + 1` is one `Variables` node feeding its
|
||
own output through an `Add` back into its input. The ports are picked from the behavior's
|
||
variables rather than named, they take their type from the declaration, and renaming a variable carries them (and their edges) along.
|
||
`Properties` is the same node for the
|
||
object it is handed (fixed input `object`). Its **Type** setting, above the list, decides what
|
||
`+ Property` offers: *Sprite*, *Text*, *Collider*, *Camera* and *Container* offer their fields,
|
||
*Behavior* the variables of the behavior picked below it (renaming such a variable carries the
|
||
port along, in any graph), and *Any* offers the kinds (Number, Text, …) and leaves the name to be
|
||
typed. A picked property keeps its field and default – an unconnected `color` is a colour field
|
||
starting white, shown on the node as `#ffffff`, and a camera's `width` is its whole-pixel view
|
||
width. Switching the type keeps the ports; one the new type lacks is marked. The node shows its
|
||
type as a setting row (*Type: Sprite*, plus *Behavior: Game* under that type). Textures and
|
||
scenes are no type here: they are not objects in a running game. A variable is a
|
||
**number, a yes/no, a text, a colour, a texture, a list or a struct**; a list declares **nothing** about its items –
|
||
each one is a number, a text, a yes/no, a colour, a texture or a struct and says so itself, so one list may
|
||
hold several kinds at once. A list is
|
||
compared by its items, never by identity – otherwise every reload would look like an override.
|
||
There is no list *of objects*: a list is stored in the project file and an object is a live
|
||
handle that no reload survives. A set of objects is a list of their names, walked with `Repeat`
|
||
and `Object by name`. A list property is shown as a button saying how many items it holds;
|
||
clicking it opens the **List** tab, which gives each item a row with the same widget the
|
||
inspector would use for that kind – a struct item is its fields as rows under each other. The inspector stays visible beside the tab, so what the list
|
||
belongs to is still on screen. Which list the tab shows is a `ListSlot` (asset property, object
|
||
override or node setting, plus the `field` of a struct that holds one) on the editor state,
|
||
because no tab may reach into the `Editor` to switch itself on. An **unconnected `list` input**
|
||
of a node is the same button and the same tab: its inline value lives in the very same params
|
||
bag a setting does, and the slot stops resolving the moment an edge feeds the port. What an item holds
|
||
is **its own** and is chosen there: the **kind mark** in front of a row (`#`, `Aa`, `✓`, `◧`, `▦`, `⊞`)
|
||
is the picker – a click opens the menu, with no chevron, since the mark has to stay as narrow as
|
||
its column – and `+ Item ▾` asks for the kind of the one it appends. Because the kind lives in the
|
||
value, every such change is an ordinary **value edit**: one command, one undo step, and no
|
||
neighbouring item touched. A **struct item** carries its own shape the same way: its fields are
|
||
renamed, retyped, added (`+ Field ▾`) and removed on its own rows, which grows that one item and
|
||
not the list. Two kinds need saying, because JSON cannot tell them from the value beside them: a
|
||
**colour** is a number and a **texture** is an id, which is a text. Such a *tagged* kind wears its
|
||
name as the one key of a small object – `{"$color": 16711680}`, `{"$texture": "walk-up"}` (the
|
||
picker a sprite's `texture` property has – a whole picture, one frame of a sheet, or an animation).
|
||
A field name cannot start with `$` (`isValidFieldName`), so a tag can never collide with a struct
|
||
item of one field. Either reads as what it wraps on a data
|
||
edge (`Item at`), the graph having neither colours nor pictures of its own, and `Set item` leaves
|
||
the slot the kind it was. A **texture is a kind wherever a kind is picked** – a variable, a struct
|
||
field, a list item, an Any port of `Properties`, a field of `Pack` – and the `Texture` node hands
|
||
one into the graph, as `Number` and `Text` do a number and a text. **Every kind is one row of one
|
||
table** (`core/properties/kinds.ts`): its word and mark, what it travels as on an edge, its empty
|
||
value, how a value is read as it, whether it is tagged and where it may be picked. The kind lists,
|
||
the `+ …` menus, the tags, the port fields and the literal nodes all follow from the row, so a new
|
||
kind is that row plus, if it names something in the project, its picker. Declared (a variable, a struct
|
||
field of one, a port) it needs no tag: the declaration says what it is, and the value is the id –
|
||
the way a colour field is a number. A declared port of kind colour or texture carries the field
|
||
that edits it and travels as a number or a text, since the graph has neither; a port declared as
|
||
the number a colour travels as still names the property (`isPortOf`). A **struct** variable is the other way round: few fields, so they fit under
|
||
each other right in the inspector. Its head row renames it and adds fields (`+ Field`), each
|
||
field row below carries its own name, its value and a remove – the port editor `Pack` already
|
||
has, one level down and with a value column – and a field may itself be a **list**, which is
|
||
the one count button that opens the tab one level deeper. Adding, renaming or removing a field reaches the
|
||
declaration, the behavior's own value **and every instance override** in one command, so no
|
||
stored value keeps a shape its declaration no longer promises. A placed instance draws every
|
||
variable, whatever its kind, with the very same rows (`buildVariable`), minus the renaming, the
|
||
kind chooser and the `+ Field` / `×` buttons: it may override the values, not declare them. A
|
||
struct's ↺ / ↑ sit on its head row, because an override covers the whole bundle rather than a
|
||
single field. All of it sits on **one column grid** with the rest of the inspector: mark (28 px),
|
||
gap and name (90 px) are exactly as wide as a plain row's label (124 px), a nested field's name gives up what
|
||
its indent takes, and names are cut with `…` rather than pushing their value out of line.
|
||
- **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.
|
||
- **Auto-repeat on `On key down`**: two settings on the node, *Repeat after (ms)* and *Repeat
|
||
interval (ms)* – milliseconds, so both stay whole numbers. A delay of 0 leaves the node as it was – one fire per press; with a delay set, a
|
||
key that stays held fires again after it and then every interval (0 = every frame). That is the
|
||
sideways slide of a Tetris piece, or a held arrow walking a menu. The browser's own key repeat is
|
||
deliberately not used: its delay and rate are an operating-system setting, not a game's. Both
|
||
rows stay off the node body while the delay is 0, so a plain key node keeps its single line.
|
||
- **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), `On animation finished`, `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · 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),
|
||
`Variables`, `Properties`, `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`, `To number`, `Join text`,
|
||
`Substring`, `Find text`, `Equals`,
|
||
lists `List` (a constant), `Fill` (one value repeated – how an empty board is made), `Count`,
|
||
`Item at`, `Set item` (a *new* list, the one that came in is left alone), `Sub list`,
|
||
`Join lists`, `Index of`, structs `Pack`/`Expand`,
|
||
`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`, `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.
|
||
- **Ports that are not fixed by the type**: a `NodeDef` may say `declaresPorts` (the node carries
|
||
its own list, edited in the inspector) or `derivePorts` (the node works them out from its
|
||
settings and from the graph around it). `resolveDef(node, graph, lookup)` is what applies both,
|
||
so the canvas and the inspector see a definition that fits *this* node; a node whose ports are
|
||
derived reads them back off that definition, which is why `run` is handed it. `Sequence` keeps
|
||
one free output below the last connected one – connecting the spare grows it, unplugging the
|
||
bottom edge shrinks it again (keys `a`, `b`, `c`, … up to 26). `Run behavior` takes its ports
|
||
from the behavior it names, and a behavior's `Start`/`End` node declares them. Pointing a call
|
||
at a different behavior **takes the edges of the ports that go away** and gives them back on
|
||
undo: an edge on a port that no longer exists draws as nothing but survives every save, and
|
||
would come back to life once the port returns.
|
||
- **Structs**: several named values travelling as one, so a bundle needs one edge instead of one
|
||
per part. `Pack` declares its fields as inputs, `Expand` reads them back out as outputs, a
|
||
behavior port may be declared `struct` – which is how a bundle crosses a call – and a
|
||
**variable** may be one, which is how a bundle is stored. A `Pack`/`Expand` can take its fields
|
||
from a variable that already has the shape (**From variable**: a struct variable, or the first
|
||
struct item a list holds – a list declares nothing, so its value is the only source) instead of
|
||
having them typed again; that adds the fields it does not
|
||
have yet as one undo step and leaves the ones it has alone. Writing a struct variable keeps the
|
||
fields it declares: what arrives fills the ones it names, the rest stay put (`mergeStruct`), so
|
||
one node can set part of a bundle without erasing it – "the same piece, one row lower" is a
|
||
`Pack` of that one field. Depth is bounded by the *value*, not by convention: a list item is a primitive, a
|
||
tagged colour or a struct of flat ones (`listItems.ts`), a struct field says what it holds
|
||
(`StructFieldKind`, primitives plus `list`), and the list a *field* holds takes flat items only.
|
||
Forbidding just that one combination is what keeps the shapes finite:
|
||
`list → struct → primitive` and `struct → list → primitive` are the two deepest a stored value
|
||
goes – exactly what `asStoredValue` lets through an untyped slot – so the deep compare and the
|
||
editor stay finite. The List tab **offers only the kinds its slot can hold** (`ListAccess.kinds`):
|
||
on a list of its own all five, on a list a struct field holds the four flat ones. Offering the
|
||
struct there would add an item the next write drops – which reads as the menu entry doing nothing. A live entity is
|
||
an object on a data edge too, so it carries a `Symbol` brand (`ENTITY_HANDLE`) that no struct
|
||
field name can impersonate; nothing may hand a property value to `String()` (`textOf` instead),
|
||
or a struct would read as `[object Object]`. A struct port has no inline value (its parts come
|
||
from a `Pack`), and compatibility is per *kind*, not per shape – any struct fits any struct port.
|
||
- **Loops**: `Repeat` runs its `body` output a number of times, counting `index` up from 0, and
|
||
continues at `done`. Data nodes are cached for the length of one event pulse, but a loop round
|
||
drops that cache, so an expression built on `index` really changes with it. That is what makes
|
||
a graph able to work through a list: the Tetris example draws its 200 board cells that way,
|
||
`Object by name` taking the name the round put together (`"c" + index`). Two guards, not one:
|
||
a pulse may take 100 000 steps (a loop legitimately runs long), but exec edges may only nest
|
||
1000 deep – that is what catches a flow feeding back into itself before the call stack does.
|
||
- **A behavior as a node** (nesting): a behavior that has a **Start** and an **End** node can be
|
||
run as a step of another behavior's graph. Every input declared on the start node becomes an
|
||
input port of the calling node, every output declared on the end node one of its outputs; the
|
||
node is drawn under the behavior's own name, and the palette lists it there too, so a call is
|
||
one pick. Declaring a port keeps both sides in step – renaming carries the typed-in value along,
|
||
removing takes the edges hanging on it in *every* graph that calls the behavior. A call is a
|
||
**call, not an inlining**: the nested graph runs in its own pulse cache with a fresh copy of its
|
||
variable defaults, so values travel in and out through the ports and nothing else. That makes a
|
||
call repeatable and keeps a nested behavior free of hidden state – for state, use a behavior
|
||
object in the scene (see *Cross-object state* below). A behavior that would run itself raises an
|
||
error instead of looping; there is one start and one end node per behavior.
|
||
Three ways in and out of a call: **drag** a behavior out of the asset panel onto the graph and it
|
||
lands as a call where you let go; **double-click** a call node to open the graph it runs. The
|
||
way down stays above the graph as a **breadcrumb** (`Game › Move › Other`): click a crumb to go
|
||
back there; the first one returns to where you started and the bar goes away. Opening a behavior
|
||
any other way – the asset panel, the scene, the picker – ends the trail. And a call that cannot
|
||
do anything **says so on the node** – no behavior picked, the behavior deleted,
|
||
or no start node in it. The last one is the one worth naming: without a start node there is no
|
||
way in, so the call is skipped and the flow simply carries on at its `exec` output. A missing
|
||
*end* node is not a defect – a call is always synchronous, "done" is the nested flow running out,
|
||
and the end node only decides what comes back.
|
||
- **Cross-object state**: a behavior's variables *are* the properties of its object, so another
|
||
graph reads and writes them with `Object by name` → `Properties` of type *Behavior*. That is
|
||
how the example game keeps one coin counter for the whole run: picking a coin up is one node,
|
||
`coins` fed through `Add` back into itself.
|
||
- **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 = speed, dt = On update.dt)`, `speed` being a `Variables` node.
|
||
- **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 selection, wheel zooms. Everything is
|
||
undoable.
|
||
- **Mouse and selection work as in the scene view.** The **middle button pans** from anywhere,
|
||
a node under the pointer included (shift-drag on empty canvas does too); the left button never
|
||
pans. Dragging on empty canvas pulls a **rubber band** instead, which selects every node it
|
||
touches – nodes are large rectangles, so touching counts, unlike the scene view's test of an
|
||
object's position. Ctrl/Shift-click adds or removes single nodes, dragging any node of a
|
||
selection moves all of them, and `Delete` removes all of them – one undo step either way.
|
||
- **Dragging an edge tells you where it would land** before you let go: the port it would connect
|
||
to wears a white ring and the line snaps to it. Dropping on a node's *body* rather than a port
|
||
takes that node's first free compatible port – or, when none is free, the last compatible one,
|
||
whose link is then replaced. A drop into empty space opens the palette filtered to the node
|
||
types this edge could reach at all, and the picked node arrives already wired; node and link go
|
||
in as **one** undo step (`core/graph/dropTarget.ts` decides all of this, the canvas only draws
|
||
it). The dangling edge stays on screen while that palette is up, so the connection being made is
|
||
still visible while its node is chosen.
|
||
- **A link can be hidden**, so a connection across the whole graph does not have to be drawn as a
|
||
line across the whole graph. Right-click a port → *Hide link*; the curve is replaced by a short
|
||
stub at each end, ending in a dot captioned with the node at the *other* end. The connection is
|
||
untouched – hiding is drawing only, and `folded` on the edge is the entire change. The curve
|
||
comes back, dimmed, **while either of its two nodes is selected**, and always while a pulse is
|
||
travelling it, so the live trace never loses its thread. Clicking a dot selects the node at the
|
||
far end, which both answers "where does this go?" and lends the curve back;
|
||
right-clicking it offers *Show link* and *Disconnect* (`editor/graph/folding.ts` holds the
|
||
visibility rule, the canvas only draws it). **A port's hidden links share one mark** – drawn per
|
||
link, several of them would stack stub, dot and caption on the very same pixels. The mark then
|
||
grows a little, is captioned `Add +2`, and one click selects *every* far end at once, which is
|
||
also what lends all of its curves back; the menu counts what it acts on (*Show 3 links*), and
|
||
does it as one undo step.
|
||
- **A node can be collapsed** to its title bar: the chevron in front of the title (it leaves the
|
||
selection alone, like the hierarchy's), or right-click → *Collapse node* / *Expand node*, which
|
||
acts on the whole selection as one undo step. Every link of one side then meets at a single
|
||
point on the title bar, and its hidden links share one mark per side. It narrows to its title
|
||
(never wider than an open node, where a long title is cut). A collapsed node has no
|
||
ports to grab, so a link dropped on it goes nowhere – expand it to wire it. Like hiding a link,
|
||
this is drawing only: `collapsed` on the node is saved with the project. A collapsed
|
||
`Variables` or `Properties` node, titled just that while open, is titled by what it does, since
|
||
its ports are out of sight:
|
||
*Set 2 variables* when more than one is written, else *Get 2 variables* when more than one is
|
||
only read, else *Set speed* / *Get speed*, and *No variables* when it has none.
|
||
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
|
||
**Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
|
||
height – `editor/framing.ts`), and the size of the graph as "N nodes · M links".
|
||
- **Snap** (header checkbox, off by default): a drag drops each node it carries on the nearest
|
||
24 px grid line – the same grid the background draws. It takes effect on the next drag only, so
|
||
switching it on never shuffles a graph that is already laid out, and a node nobody drags never
|
||
moves. Every node of a selection snaps **for itself**, so dragging a group is how you line one
|
||
up; the offset is a decision about rectangles and lives with the others in `editor/framing.ts`.
|
||
- **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. Each fire of an exec edge also sends a bead along it (0.3 s, at most
|
||
one per 0.15 s and edge): a key press is a single bead, `On update` an even chain. Beads run
|
||
on game time, so a paused game holds them where they are. Every data output shows, in the same colour, the value it last
|
||
carried (`0.017 dt`, `Pause box object`), refreshed ten times a second. With several objects
|
||
running the same behavior it is the one that produced last; in a loop, the last round. An
|
||
output nothing has asked for yet shows nothing. The graph stays editable meanwhile: the interpreter reads the runtime
|
||
copy's graph on every pulse, so an edit takes effect on the next frame and is discarded with
|
||
the run. A new `On start` node does not fire for a behavior that has already started.
|
||
- **Runtime**: `BehaviorSystem` creates one `GraphInterpreter` per behavior entity; graphs only
|
||
touch world entities. A runaway pulse throws (see *Loops* for the two limits) and stops play
|
||
with a notice.
|
||
|
||
**Placing an asset: drag it in.** Drag a row out of the **Assets** panel and drop it on the
|
||
view – the Scene tab places into the active scene, the Container tab into the open template, so
|
||
what you see is what you fill. While the pointer is over a view, an outline shows the footprint
|
||
the object will have and a crosshair the origin it snaps to; it turns **red** when the drop would
|
||
be refused, which today means a container that would end up inside itself (`placementRejection`,
|
||
`src/core/model/placement.ts` – the same function the command runs, so the preview cannot promise
|
||
a placement the drop then denies). Picking a container up selects it, but the Container tab does
|
||
*not* follow that while a drag is in flight: the target must not swap out from under the pointer.
|
||
Dragging is the only way to place: a double-click on a scene makes it the active one, on anything
|
||
else it does nothing.
|
||
|
||
**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.
|
||
A row's **name turns gold** when the object deviates from what it follows – its container
|
||
template, or its asset – and also when anything below it does, so a folded subtree still shows
|
||
that something inside was changed. It is the same gold the inspector paints an overridden
|
||
label in.
|
||
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** → *Add
|
||
collider* 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 `Properties` 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.** **Dropping image files onto the canvas** or **Ctrl+V** pasting one
|
||
uploads it and makes the asset in one go; several files at once are imported one after another,
|
||
and a file that fails does not stop the rest.
|
||
|
||
**+ → Texture** asks a different question and so opens a different dialog: not the machine's
|
||
file system, but **every picture the server already holds**, as a grid of thumbnails. Files are
|
||
never collected (below), so a picture one project dropped is still there for the next to pick
|
||
up, and uploading the same bytes twice buys nothing. Picking one and pressing **Use image**
|
||
makes a texture asset on that file; **Upload…** is the way in for a picture the server does not
|
||
have yet, and lands in the same import as a drop. A tile captioned with an asset name is one
|
||
this project already wears – picking it selects that asset rather than making a second one.
|
||
|
||
**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.
|
||
|
||
**A sprite straight from a texture.** Right-click a texture in the asset list → *Create sprite
|
||
from texture* makes a sprite asset that wears the whole picture at its pixel size, named after the
|
||
texture (numbered like any name clash, so `Coin` gives `Coin 2`) and selected. It is the same as creating a sprite, picking the texture and clicking *Set to
|
||
W × H px*, but in one undo step.
|
||
|
||
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).
|
||
|
||
### Animations
|
||
|
||
An **animation asset** is a list of pictures played one after another. Each entry is what a
|
||
sprite's `texture` can hold – a whole texture or one of its frames – so an animation may mix
|
||
frames of several sheets, and a picture listed twice is held twice as long; there is no
|
||
per-frame duration beyond that. Its two properties are **Frames per second** (default 10) and
|
||
**Playback**: *Loop*, *Once* (stops on the last frame) or *Ping-pong* (back and forth, without
|
||
showing the turning frames twice).
|
||
|
||
**A sprite wears it through `texture`** – the same property, the same picker (animations are
|
||
listed first, marked "(animation)") and the same instance overrides as a texture or a frame.
|
||
The sprite stays what is drawn, sized, tinted and collided with; the animation is only a picture
|
||
that changes over time. *Set to W × H px* takes the size of the first frame. The fastest way in
|
||
is right-clicking a texture → **Create animation from texture**, which plays its frames in
|
||
order (the whole picture when it has none), named after the texture, in one undo step.
|
||
|
||
**The Animation tab** plays the animation above a strip of its frames. `▶` / `❚❚` pauses on the
|
||
frame on screen, and resumes from the selected one. Clicking a tile pauses on it, dragging a
|
||
tile into another gap reorders the frames, `Delete` removes the selected one, right-click offers
|
||
*Duplicate frame* and *Remove frame*, and **+ Frames ▾** inserts after the selection: each
|
||
sheet's frames at once, or single pictures. The selected tile belongs to the tab, not to the
|
||
editor's selection, so the inspector keeps showing the animation's two properties meanwhile.
|
||
The preview fits each frame on its own until the wheel zooms it about the pointer; from then on
|
||
every frame is drawn at that one scale, centred, so frames of different sizes show their real
|
||
proportion. The middle button (or `Shift`+drag) pans, **Fit** returns to fitting, and over the
|
||
strip the wheel scrolls the tiles instead.
|
||
|
||
**It runs in play only.** The Scene and Container tabs show the first frame: the edit mode has
|
||
no clock and redraws on changes only. The `AnimationSystem` gives every sprite its own clock, kept on the entity and
|
||
not in `props` (the world writes changed props back as overrides). The one rule that makes it
|
||
usable from a graph: **a different animation starts from its first frame, the same one set again
|
||
runs on** – so `On update → Properties (Sprite, texture on Set)` may choose Run or Idle every
|
||
frame without freezing the animation on frame one. The system ticks before the behaviors, so
|
||
**`On animation finished`** fires in the frame a *Once* animation reaches its end, exactly once,
|
||
with the sprite as `object`. Like collisions it is heard by the behaviors of the sprite's owner
|
||
subtree – the sprite itself, or a sprite placed next to the behavior below the same object – and
|
||
by a behavior without an owner for every sprite; its *Animation* setting narrows it to one.
|
||
There is no node to restart an animation that is already running; that and a state machine are
|
||
left for when a game needs them.
|
||
|
||
Deleting a texture counts the animations that play its frames besides the sprites wearing it;
|
||
deleting an animation counts the sprites wearing it. Either leaves dangling references that draw
|
||
the plain colour and come back on undo.
|
||
|
||
**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: an
|
||
asset dragged from the panel onto this view goes into the template, and a container dragged onto
|
||
its own template is refused while it hovers rather than at the drop.
|
||
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 its
|
||
base, which is what keeps "untouched follows, touched keeps" exact without any bookkeeping. That
|
||
base is the template **with the edits of every container in between woven in** (`InstanceTarget.base`):
|
||
inside nested containers the innermost template is not what a value falls back to, so comparing
|
||
against it would drop an edit that does deviate – and the value would jump to the value of the
|
||
container in between.
|
||
|
||
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 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. A world built **without** baking (the editor run, `WorldOptions.bakeContainers`) runs
|
||
that content all the same and writes through `objectRef` into the instance edits; only a removal
|
||
has nowhere to go there.
|
||
|
||
## 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 – selected, it keeps a faint outline, so where it sits is still
|
||
visible, but it cannot be dragged. 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/`.
|
||
`main.ts` creates the WebGL context itself with `desynchronized`, which Pixi has no option for:
|
||
frames skip the compositor queue, so the UI keeps up with the pointer (click to picture measured
|
||
72–77 ms before, 16–18 ms after). The price is possible tearing while things move fast;
|
||
`?vsync` falls back to Pixi's own context. A browser without the mode simply ignores it.
|
||
- **Own canvas widget toolkit** (`src/ui/`) – panels, buttons, lists, text/number/color fields,
|
||
popups. The only lasting DOM element is a hidden `<input>` used as a keyboard bridge (IME,
|
||
mobile keyboard, clipboard) while a canvas text field is focused; *Upload…* adds a short-lived
|
||
file input.
|
||
Double-clicks are counted in `clicks.ts`, not read off the event: Pixi only puts a click count
|
||
on the `click` it synthesises and leaves `detail` at 0 on `pointerdown`, so every press handler
|
||
that trusted `e.detail` never saw a second click. Presses are counted per key rather than per
|
||
widget, because a list rebuilds its rows when the first click changes the selection.
|
||
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 Graph, Texture, Animation and Container pickers keep their size whatever they show; the tab
|
||
bar uses 90 px as a floor. A button that can draw its label bold – on an accent fill: primary,
|
||
danger, a toggled tab – is measured bold, or a dialog's `Delete` comes out as `Del…`.
|
||
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 core, toolkit and editor logic.
|
||
- **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), usages (where an asset is used), container (the cycle guard), placement (what may be dropped where)
|
||
src/core/properties/ PropertySchema, resolve (the only override merge), deviation (what is gold), collider/camera/bounds geometry, aspect, enabled, editorRun
|
||
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, dropTarget, nested (behaviors as nodes),
|
||
variablePorts, keyRepeat; nodes/{events,flow,data,action,variable}/<Name>.ts
|
||
src/core/store/ serialize (untrusted JSON → Project), http (one API call), HttpProjectStore
|
||
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, AnimationSystem, BehaviorSystem
|
||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||
src/viewport/ view (transforms), items (what to draw), symbols (how to draw it),
|
||
textureFill, TextureLibrary – no editor concepts, so tests reach it freely
|
||
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
|
||
EditorRun (run in editor), state, actions, shortcuts, debugHook, autosave, textureImport,
|
||
framing (fit/marquee/snap maths), AssetTabEditor (the shared shell of the three asset tabs)
|
||
src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editing tools, hence not in src/viewport/
|
||
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command), valueSpecs
|
||
(where a value lives – shared with the List tab),
|
||
one section per selection kind (asset/object/node/frame), variableRows (one row shape for
|
||
every variable), portSection, aspectAction, objectQueries/vectorRows/parentRow
|
||
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
|
||
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, EdgeLayer (curves and fold marks), graphMenus,
|
||
NodeView, NodePalette + paletteEntries, folding (hidden links, collapsed nodes)
|
||
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),
|
||
ImagePicker + imageGrid (the "+ → Texture" dialog), checker (the transparency board)
|
||
src/editor/animation/ AnimationEditor (header + picker), AnimationCanvas (preview + frame strip), strip (its geometry)
|
||
src/editor/list/ ListEditor (a row per item), slot (which list is being edited, and how it is written back)
|
||
api/ PHP backend data/projects/ persisted projects
|
||
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, remote/
|
||
```
|
||
|
||
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 (`/usr/bin/node`, in the default PATH).
|
||
|
||
```bash
|
||
npm install
|
||
npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production
|
||
npm test # unit tests (core, editor, ui, viewport)
|
||
npm run typecheck
|
||
npm run lint # ESLint (type-aware for src and tests); lint:fix for the fixable half
|
||
npm run format # Prettier over the repo, minus what .prettierignore lists
|
||
npm run check # typecheck + lint + format:check + tests, what CI would run
|
||
./check.sh # the same, ending in `=== CHECK EXIT n ===`: piped into `tail` npm's exit
|
||
# code is lost (tail's is reported), so a red run reads as green
|
||
npm run build # writes dist/ and regenerates index.html
|
||
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
|
||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 633 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
|
||
./e2e.sh start [GM_STEPS] # the same run detached, see below
|
||
./e2e.sh status # live progress, or the last run's result
|
||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end
|
||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs # 20 checks: the walk demo (the seed uploads its own sheet)
|
||
```
|
||
|
||
**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 about four minutes 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.)
|
||
|
||
**Run it here by default.** With the GPU in use the full suite costs about four minutes and roughly 0.8 of the
|
||
four cores of this box (60% renderer, 18% GPU process, load ~1.0), which it carries fine. While one
|
||
step is being worked on, `GM_STEPS` keeps a run to seconds – take the whole suite before a commit,
|
||
or when a change touches drawing, the widget toolkit or anything else every step sees.
|
||
|
||
**`./e2e.sh start` runs the suite detached**, in a transient `game-maker-e2e.service`, and returns at
|
||
once; output goes to `logs/e2e-<date>_<time>.log` (gitignored) and ends in `=== E2E EXIT n ===`.
|
||
Backgrounding it from a shell instead is not enough – four runs in a row died mid-suite with no
|
||
closing line, because the whole process group is killed when the calling shell exits (`nohup` only
|
||
covers SIGHUP, and `/proc/vmstat` confirmed `oom_kill 0`, so it was never memory). The unit outlives
|
||
its caller. It takes the same `GM_STEPS` argument, refuses to start a second run over a live one, and
|
||
`systemctl stop game-maker-e2e.service` ends it.
|
||
|
||
**`./e2e.sh status`** reports the live run (elapsed, PASS/FAIL so far, the check it is on) or, when
|
||
nothing is running, the newest log's result plus its FAIL lines. A log without a closing line is
|
||
reported as `ABORTED`, not as its PASS count: a run that was cut short is not a result.
|
||
|
||
**`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: **the full suite in ~130 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). Worth setting while the box is
|
||
busy with something else; it is not required, and an unreachable host is no reason to skip a run.
|
||
|
||
`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, the **jump-run** and **tetris** projects by their seeders. 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, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
|
||
variable nodes, property nodes, animations) 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.
|
||
|
||
`GM_STEPS=20`, `GM_STEPS=1-12` or `GM_STEPS=6,20` runs a subset – seconds instead of minutes
|
||
while one step is being written. Because a step lives off what the earlier ones left behind, a
|
||
subset that skips a prerequisite fails on purpose; take `1-N` when in doubt.
|
||
|
||
**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 30-second 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 second example** (`tools/seed-tetris.mjs`, project *Tetris*) is the same idea with data
|
||
instead of physics. The board is a **list of 200 numbers**, one per cell – `-1` for empty,
|
||
otherwise the shape that came to rest there. A cell is `Item at(grid, row · 10 + column)`, a
|
||
landing is `Set item`, and clearing a full line is an empty row joined in front of everything
|
||
above and below it. What the player sees is a pool of objects the graphs address by name –
|
||
`c0…c199` for the well, `p0…p3` for the piece that is falling – so nothing is spawned or
|
||
destroyed while playing. Every frame tries exactly **one** move (turning beats moving beats
|
||
falling), which is why the game needs only a single "does it fit?" test instead of one per key.
|
||
|
||
What is *not* loose numbers is the piece. `piece` and the candidate `next` are **structs** of
|
||
shape/rot/x/y, and the shape table is a **list of `{x, y}` cells** – so `Next move` hands back a
|
||
whole candidate for `Fits` to judge and the master to accept in one write, rather than three
|
||
parallel variables copied back and forth, and each strand packs only the field it changes
|
||
(`mergeStruct` keeps the rest). Reading a cell is `((shape · 4 + rot) · 4 + corner)` into that
|
||
list instead of the same index doubled and read twice.
|
||
|
||
Where a step needs several variables at once, one `Variables` node stands for the
|
||
lot: the call to `Next move` has **one node at each side** of it rather than five, and the master
|
||
graph is **44 nodes / 64 edges** where the same game in single nodes was 69 / 76. Inside `Next
|
||
move` each strand writes its whole answer – the candidate, what kind of move it was and the wish
|
||
it used up – in one node rather than three to five, which is **30 nodes** against 40. A writing
|
||
node reads all its inputs before it writes any, so an input fed from a variable the same node
|
||
writes sees the value from before.
|
||
|
||
It is **ten behaviors**, not one graph. `Tetris` owns the state and does none of the work: each
|
||
step of a frame is a behavior of its own, called as a node – `Spawn piece`, `Next move`, `Fits`,
|
||
`Draw piece`, `Lock piece`, `Clear lines`, `Show lines`, `Draw board`, with `Piece cell` (where
|
||
one of the four cells of a piece sits) used by two of them. None of them holds state: what they
|
||
need arrives through their inputs and every answer leaves through their outputs, which is what
|
||
lets `Fits` answer for turning, moving and falling alike, and `Piece cell` serve the fit test
|
||
and the drawing instead of being the same twenty nodes twice. What is left in
|
||
the master graph is the order the steps run in and which variable each answer lands in – it holds
|
||
no loop and no list access of its own, which `tools/smoke-tetris.mjs` checks. That script plays
|
||
the game in a headless browser (fall, move, turn, drop, land, clear a line, fill the well) – it
|
||
is the regression test for loops, lists and behaviors used as nodes.
|
||
|
||
**The walk demo** (`tools/seed-actor.mjs`, project *Actor demo*) is the smallest of the three and the
|
||
only one that ships its own picture: it uploads `tools/assets/Actor1.png` and slices the 576×384
|
||
sheet into the 96 cells of a 12×8 grid. That is the usual RPG-Maker layout – eight characters, each
|
||
three columns (left foot / standing / right foot) by four rows (down, left, right, up) – and the demo
|
||
wears the one in the top left corner. Walking is four **ping-pong** animations of three frames, which
|
||
is exactly the original walk cycle; standing is no animation at all but the middle frame of the row.
|
||
The graph picks what the sprite wears the way the editor does: two **list variables** hold the four
|
||
walk animations and the four standing frames, every item chosen with the texture picker, and
|
||
`facing` is the row of the sheet, which indexes both (`Item at`). No id is spelled anywhere in it,
|
||
so the whole graph can be rebuilt by hand. Which way it looks is the key pressed last, corrected whenever only one axis is held – otherwise letting go of one key of a
|
||
diagonal would leave it walking backwards. It is the worked example for animations, for a seed that
|
||
brings its own texture, and for `Axis input`, whose vector is already normalized so a diagonal is not
|
||
faster than a straight line. `tools/smoke-actor.mjs` walks it in a headless browser; that the
|
||
animation really advances it reads off the **drawn pixels**, standing at the edge of the camera where
|
||
he walks on the spot – a clock that ticks without the picture following would pass otherwise.
|
||
|
||
The workspace folder itself is what nginx serves, so `npm run build` is the deployment.
|
||
|
||
### Ideas, gaps and bugs
|
||
|
||
They live in the issue tracker of the Gitea repo: <https://git.nockal.com/meleki/game-maker/issues>.
|
||
The folder `todo-notes/` that held them as files until 2026-09-12 is gone; all 50 notes were moved
|
||
into issues #1–#50, closed ones included, each carrying the commits it was filed and finished with.
|
||
|
||
## 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** `{error,revision}` when stale |
|
||
| DELETE | `projects/{id}` | 204 |
|
||
| GET | `textures` | `[{file,width,height,bytes,updatedAt}]` – what the image picker offers |
|
||
| 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 API has its
|
||
own caps behind nginx's: 8 MB per request, 4 MB per image (413). 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 (`Cmd` works wherever `Ctrl` does) · `Delete` or
|
||
`Backspace` remove the selection (objects, nodes, frames, a tile of the Animation tab; an asset asks first) · `Ctrl+G` create
|
||
container from the selection · `Ctrl+Enter` play, and pause/resume while playing · `Esc` stop ·
|
||
`Ctrl+V` import an image from
|
||
the clipboard as a texture (ignored while a text field has focus) · `Tab` / `Shift+Tab` cycle fields · lists: `↑`/`↓` move,
|
||
`←`/`→` fold, `Enter` activates · 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, a camera's view size, font
|
||
size, the key-repeat milliseconds), 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`/`Shift`-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 nine-slice. Animations have no state machine, no restart node
|
||
and no preview in the Scene tab. Slicing cuts a regular grid only — no detection of where the sprites actually
|
||
sit on the sheet. Unreferenced image files are never collected – deliberately, since the image
|
||
picker offers them again; nothing deletes a stored image except `DELETE ?r=textures/{file}`.
|