Refactor round: ship web/index.html, drop the remote browser, fix stale comments and README

- `.gitignore` said `index.html` unanchored, which also caught the Vite source page;
  a fresh clone could neither `dev` nor `build`. Anchored to `/index.html`, page added.
- Removed GM_REMOTE (tools/remote/gm-browser.ps1 and the ssh/scp branch in browser.mjs):
  the Windows test machine is gone and the only flaky check that lived on it is closed (#37).
- Removed `npm run preview`, undocumented and unused.
- Comments: wrong number in collider.test.ts (250 → 200), the "declarations used to…"
  history in listItems.ts and friends, the 24 px/28 contradiction, section banners in
  nodes/index.ts, node-catalog.test.ts and api/index.php, name paraphrases, and the stale
  step numbers in tools/e2e/11-…23- from the old global count.
- README: asset types include `animation`, kind order Number/Text/Boolean, tagged colour
  and texture items, 6/5 kinds, the `Texture` node, `./build.sh`, PROJECT_FORMAT_VERSION,
  and the file tree (kinds.ts, listItems.ts, propertyPorts.ts, Breadcrumb.ts, beads.ts,
  tools/assets/).
- tests/helpers.ts takes the duplicated `findObject` and `sceneWithCamera`.

Proof: ./check.sh 797 tests, full e2e ALL PASSED 633 checks against this bundle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-12 15:44:05 +02:00
1 parent d86a655a4d
commit 6471671f7d
44 files changed
+196 -445

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
node_modules/
dist/
index.html
/index.html
data/projects/*.json
data/projects/*.tmp
# Uploaded images are user data like the projects: content-addressed, written by the API,
+20 -39
View File
@@ -10,8 +10,8 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| 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. |
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`, `texture`, `animation`) and typed properties. Assets have **no position**. |
| **Texture** | An uploaded image. Scenes, textures and animations are the three 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. |
@@ -119,7 +119,7 @@ shown on the node and editable in the inspector.
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 –
**number, a text, a yes/no, 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.
@@ -182,7 +182,7 @@ shown on the node and editable in the inspector.
- **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`,
`Variables`, `Properties`, `Get position` (local and world), `Is enabled`, literals `Number`/`Text`/`Boolean`/`Texture`, `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`,
@@ -218,13 +218,13 @@ shown on the node and editable in the inspector.
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
tagged colour or texture, 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
on a list of its own all six, on a list a struct field holds the five 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),
@@ -684,34 +684,34 @@ selected and switched back on.
```
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/model/ Project (object holders), Asset + AssetTypes, SceneObject, Properties (value types), 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), texture (frames and the slice grid), animation (which frame at time t), behaviorPrograms (what `Run behavior` needs)
src/core/properties/ PropertySchema, kinds (one row per value kind), listItems (an item's kind, read off its value), resolve (the only override merge), deviation (what is gold), collider/camera/bounds geometry, aspect, enabled, editorRun, assetRef/textureRef (how a reference reads)
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
variablePorts, propertyPorts, lookup (behavior programs), keyRepeat; nodes/{events,flow,data,action,variable}/<Name>.ts
src/core/store/ serialize (untrusted JSON → Project), http (one API call), ProjectStore/HttpProjectStore, TextureStore/HttpTextureStore
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
textureFill, TextureLibrary, activeCamera – 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
src/editor/inspector/ Inspector panel, host (what a section builds into), 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)
NodeView, NodePalette + paletteEntries, folding (hidden links, collapsed nodes), Breadcrumb + trail (the path into nested behaviors), beads (the run's pulses)
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/
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/, assets/ (the walk demo's sheet)
```
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
@@ -740,7 +740,7 @@ npm run check # typecheck + lint + format:check + tests, what CI would ru
./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
./build.sh # the same under `set -o pipefail`, for callers that pipe the output
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
@@ -775,29 +775,6 @@ its caller. It takes the same `GM_STEPS` argument, refuses to start a second run
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
@@ -906,6 +883,10 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) |
| DELETE | `textures/{file}` | 204 |
Every project document carries `formatVersion` (`PROJECT_FORMAT_VERSION` in `core/model/Project.ts`,
currently 2). There are no load-time migrations: `serialize.ts` reads what it recognises and drops
the rest.
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
@@ -925,7 +906,7 @@ fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that i
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
size, the key-repeat milliseconds, the index and count ports of list and text nodes), 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).
-4
View File
@@ -39,7 +39,6 @@ function routeProjects(array $parts, string $method): never
{
$store = new Store(dirname(__DIR__) . '/data/projects');
// Collection: /projects
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list());
@@ -55,7 +54,6 @@ function routeProjects(array $parts, string $method): never
Response::error('Method not allowed', 405);
}
// Item: /projects/{id}
$id = $parts[1];
if (!Store::isValidId($id)) {
Response::error('Invalid project id', 400);
@@ -100,7 +98,6 @@ function routeTextures(array $parts, string $method): never
{
$store = new TextureStore(dirname(__DIR__) . '/data/textures');
// Collection: /textures
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list());
@@ -119,7 +116,6 @@ function routeTextures(array $parts, string $method): never
Response::error('Method not allowed', 405);
}
// Item: /textures/{file}
if ($method === 'DELETE') {
if (!$store->delete($parts[1])) {
Response::error('Texture not found', 404);
-1
View File
@@ -7,7 +7,6 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
-1
View File
@@ -49,7 +49,6 @@ export interface PointerState {
y: number;
inside: boolean;
down: boolean;
/** Went down / up during this frame. */
pressed: boolean;
released: boolean;
}
-10
View File
@@ -83,7 +83,6 @@ import { Variables } from './variable/Variables';
* the order here is documentation only.
*/
export const BUILT_IN_NODES: readonly NodeDef[] = [
// events
OnStart,
OnUpdate,
OnKeyDown,
@@ -96,15 +95,12 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
OnClick,
OnAnimationFinished,
BehaviorStart,
// flow
Branch,
Sequence,
Repeat,
RunBehavior,
BehaviorEnd,
// variables
Variables,
// data: references and properties
Self,
Owner,
Parent,
@@ -114,7 +110,6 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
IsEnabled,
Properties,
GetPosition,
// data: literals and math
NumberLiteral,
BooleanLiteral,
StringLiteral,
@@ -134,7 +129,6 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
Modulo,
Random,
Time,
// data: text and comparison
ToText,
ToNumber,
Join,
@@ -145,7 +139,6 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
And,
Or,
Not,
// data: lists
ListLiteral,
Fill,
Count,
@@ -154,10 +147,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
SubList,
Concat,
IndexOf,
// data: structs
PackStruct,
ExpandStruct,
// data: vectors and input
Length,
Distance,
Normalize,
@@ -167,7 +158,6 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
MouseButton,
IsHovered,
IsTouching,
// actions
Move,
SetPosition,
Spawn,
+1 -4
View File
@@ -1,9 +1,6 @@
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
/**
* 2 since a placed container stopped copying its template into the scene. Old files are not
* migrated: the loader simply reads no container content from them any more.
*/
/** 2: a placed container carries no copy of its template. */
export const PROJECT_FORMAT_VERSION = 2;
export interface Project {
-1
View File
@@ -47,7 +47,6 @@ export function animationFrameAt(asset: AnimationAsset, time: number): FrameAt {
return frameAt(asset.frames.length, fpsOf(asset), playbackOf(asset), time);
}
/** Seconds for one pass through the frames. */
export function animationDuration(asset: AnimationAsset): number {
const fps = fpsOf(asset);
return fps > 0 ? asset.frames.length / fps : 0;
-1
View File
@@ -116,7 +116,6 @@ export interface SliceOptions {
/** Pixels skipped at the left/top edge of the sheet before the first cell. */
offsetX: number;
offsetY: number;
/** Pixels between neighbouring cells. */
spacingX: number;
spacingY: number;
}
-1
View File
@@ -3,7 +3,6 @@ import { defaultsFor } from './PropertySchema';
const DEFAULTS = defaultsFor('camera');
/** Axis-aligned rectangle in world units. */
export interface WorldRect {
left: number;
top: number;
-1
View File
@@ -17,7 +17,6 @@ type Circle = Extract<ColliderShape, { kind: 'circle' }>;
/** A movement axis in the plane (`z` is draw order, not geometry). */
export type PlaneAxis = 'x' | 'y';
/** Centred on `centre`, a world position. */
export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
if (props.shape === 'circle')
return {
+2 -3
View File
@@ -3,8 +3,7 @@
* value. JSON says what a number, a text, a boolean and a struct are; a tagged kind says so itself
* (`tagOf`), which keeps every list ever stored readable.
*
* Depth is capped the way the declarations used to cap it: a struct item holds flat items, and so
* does a list inside a struct field.
* Depth is capped: a struct item holds flat items, and so does a list inside a struct field.
*/
import {
type FieldValue,
@@ -75,7 +74,7 @@ export function listItemOf(value: PropertyValue | undefined): ListItem {
/**
* A flat position holds a primitive or a tagged item. Anything deeper is dropped rather than
* flattened – the same bound the declarations used to draw, and the one the store writes.
* flattened – the bound the store writes.
*/
export function flatItemOf(value: PropertyValue | undefined): FlatItem | undefined {
if (isList(value) || (isStruct(value) && !isTaggedKind(tagOf(value) ?? ''))) return undefined;
+1 -1
View File
@@ -69,7 +69,7 @@ export interface FieldRow {
/**
* The inspector's one column grid. A variable row is mark + name, a plain row a label spanning
* both, so every value starts at the same x. The mark column holds `≡Aa` (24 px measured).
* both, so every value starts at the same x. The mark column fits `≡Aa` (24 px measured) with air.
*/
export const MARK_WIDTH = 28;
export const NAME_WIDTH = 90;
-1
View File
@@ -208,7 +208,6 @@ export class World {
return this;
}
/** Advances the simulation by `dt` seconds. */
tick(dt: number): void {
this.elapsed += dt;
for (const system of this.systems) system.update(this, dt);
-1
View File
@@ -9,7 +9,6 @@ import type { Popup } from './Popup';
export interface ListItem {
id: string;
label: string;
/** Small secondary text on the right. */
hint?: string;
/** Left indent in levels. */
depth?: number;
-1
View File
@@ -11,7 +11,6 @@ export interface TextFieldOptions {
placeholder?: string;
style?: TextStyleToken;
width?: number;
/** Fires while typing. */
onInput?: (value: string) => void;
/** Fires on Enter / blur when the value changed since editing started. */
onChange?: (value: string) => void;
+1 -1
View File
@@ -159,7 +159,7 @@ describe('move and slide', () => {
it('stops flush at a solid collider and reports the blocked axis', () => {
const { world, collisions, crateA } = runtime();
// Crates are 100 wide, 300 apart: 250 of travel is possible before they touch (centres 100 apart).
// Crates are 100 wide, 300 apart: 200 of travel before they touch (centres 100 apart).
expect(collisions.moveAndSlide(world, crateA, 250, 0)).toEqual({ blockedX: true, blockedY: false });
expect(crateA.x).toBe(200);
expect(collisions.moveAndSlide(world, crateA, 50, 0)).toEqual({ blockedX: true, blockedY: false });
-15
View File
@@ -56,8 +56,6 @@ const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContex
});
const CASES: Record<string, Case[]> = {
// flow
Branch: [
{ name: 'true takes then', inputs: { condition: true }, next: 'then' },
{ name: 'false takes else', inputs: { condition: false }, next: 'else' },
@@ -104,8 +102,6 @@ const CASES: Record<string, Case[]> = {
},
],
// variables
// Reading needs the interpreter, which asks `read` at the moment: see variable-node.test.ts.
Variables: [
{
@@ -145,8 +141,6 @@ const CASES: Record<string, Case[]> = {
},
],
// references and properties
Self: [{ name: 'outputs the behavior object', outputs: { object: fakeContext().self } }],
Owner: [
{ name: 'outputs the object the behavior is parented to', outputs: { object: fakeContext().owner } },
@@ -217,8 +211,6 @@ const CASES: Record<string, Case[]> = {
},
],
// literals and math
Number: [
{ name: 'outputs its setting', params: { value: 7.5 }, outputs: { value: 7.5 } },
{ name: 'unset is 0', outputs: { value: 0 } },
@@ -290,8 +282,6 @@ const CASES: Record<string, Case[]> = {
},
],
// text and comparison
ToText: [
{ name: 'formats with decimals', inputs: { value: 12.345, decimals: 1 }, outputs: { text: '12.3' } },
{ name: 'text passes through unformatted', inputs: { value: 'x', decimals: 2 }, outputs: { text: 'x' } },
@@ -318,7 +308,6 @@ const CASES: Record<string, Case[]> = {
outputs: { text: 'b' },
},
],
// lists
List: [
{ name: 'hands out the list it was given', params: { value: [1, 2, 3] }, outputs: { value: [1, 2, 3] } },
@@ -445,8 +434,6 @@ const CASES: Record<string, Case[]> = {
{ name: '"0" as text counts as false', inputs: { value: '0' }, outputs: { result: true } },
],
// vectors and input
Length: [
{ name: '3-4-5 triangle', inputs: { x: 3, y: 4 }, outputs: { result: 5 } },
{ name: 'the zero vector has length 0', inputs: { x: 0, y: 0 }, outputs: { result: 0 } },
@@ -569,8 +556,6 @@ const CASES: Record<string, Case[]> = {
},
],
// actions
Move: [
{
name: 'moves by (dx, dy) × scale × dt',
+1 -1
View File
@@ -91,7 +91,7 @@ describe('a declared port of kind color or texture', () => {
expect(portDefOf({ key: 'n', kind: 'number' }).field).toBeUndefined();
});
/** A file from before the kind existed declares a sprite's `color` port as a number; it still is one. */
/** A `color` port declared as a number still names the property: colour travels as a number. */
it('names a property when it travels the way the property does', () => {
expect(isPortOf({ key: 'color', kind: 'color' }, COLOR)).toBe(true);
expect(isPortOf({ key: 'color', kind: 'number' }, COLOR)).toBe(true);
-1
View File
@@ -10,7 +10,6 @@ import {
tileRect,
} from '../../src/editor/texture/imageGrid';
/** The width the picker gives its grid. */
const WIDTH = 560;
describe('gridMetrics', () => {
-1
View File
@@ -48,7 +48,6 @@ export interface SceneFixture extends ProjectFixture {
world(id: string): Position;
}
/** An empty project with an undo stack. */
export function makeProject(): ProjectFixture {
const project = createEmptyProject('p', 'P');
return { project, stack: new CommandStack(project) };
+10 -10
View File
@@ -25,7 +25,7 @@ export async function inspector(t) {
projectId,
} = t;
// 1. Select the blue crate in the hierarchy.
// 2a. Select the blue crate in the hierarchy.
await clickText('Blue crate');
let s = await state();
check(
@@ -35,7 +35,7 @@ export async function inspector(t) {
);
await shot('02-object-selected');
// 1b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
// 2b. Deviations are shown in gold – on the inspector label and on the hierarchy row. The blue
// crate overrides width and color, the camera and the red crate's subtree nothing.
const GOLD = '#f2c84b';
const PLAIN = '#e6e8ee';
@@ -57,7 +57,7 @@ export async function inspector(t) {
`__gm.state.activeScene.objects.find(o => o.id === 'o2').overrides = { width: 140, color: 0x4488ff }`,
);
// 2. Rename via inspector: the name field sits right of the "Name" label.
// 2c. Rename via inspector: the name field sits right of the "Name" label.
const nameLabel = await locate('Name');
await click(nameLabel.x + 160, nameLabel.y);
await press('a', { ctrl: true });
@@ -72,7 +72,7 @@ export async function inspector(t) {
);
await shot('04-renamed');
// 2b. A double-click into a text field selects its content, so the next keystroke replaces the
// 2d. A double-click into a text field selects its content, so the next keystroke replaces the
// whole name instead of inserting into it. Renamed back, since later checks go by the name.
await doubleClick(nameLabel.x + 160, nameLabel.y);
await typeText('Twice');
@@ -83,7 +83,7 @@ export async function inspector(t) {
(await object('o2')).name === 'Twice',
JSON.stringify((await object('o2')).name),
);
// Undone rather than typed back: check 6 further down counts its undos, so this must not
// Undone rather than typed back: check 2h further down counts its undos, so this must not
// leave a step on the stack.
await undo();
await sleep(200);
@@ -93,14 +93,14 @@ export async function inspector(t) {
JSON.stringify((await object('o2')).name),
);
// 3. Scrub the X field (right of the "X" label) by 100 px.
// 2e. Scrub the X field (right of the "X" label) by 100 px.
const xLabel = await locate('X');
await drag(xLabel.x + 160, xLabel.y, xLabel.x + 260, xLabel.y);
const xNow = (await object('o2')).position.x;
check('scrubbing X moved the object', xNow > 120, `x=${xNow}`);
await shot('05-scrubbed');
// 3b. Type into X, then click straight into the empty viewport: the typed value must be
// 2f. Type into X, then click straight into the empty viewport: the typed value must be
// committed (not reverted) even though the click deselects and rebuilds the inspector.
const center = await json('__gm.viewportCenter()');
await click(xLabel.x + 160, xLabel.y); // plain click → text editing with everything selected
@@ -114,7 +114,7 @@ export async function inspector(t) {
`x=${xTyped} selection=${JSON.stringify(s.selection)}`,
);
// 4. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
// 2g. Drag the red crate in the viewport. It is at world (-150,0); the view is centred
// on the origin, so compute its screen position from the viewport centre.
await drag(center.x - 150, center.y, center.x - 50, center.y - 70);
const o1Now = (await object('o1')).position;
@@ -129,7 +129,7 @@ export async function inspector(t) {
check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides));
check('save status is saved', (await state()).save === 'saved');
// 6. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
// 2h. Undo four times (drag, typed X, scrub, rename) with Ctrl+Z, then redo once.
await click(center.x, center.y + 200); // click empty viewport → deselect, drop focus
for (let i = 0; i < 4; i++) await undo();
const afterUndo = (await t.objects()).map((o) => ({ id: o.id, name: o.name, x: o.position.x }));
@@ -142,7 +142,7 @@ export async function inspector(t) {
check('redo restored the rename', (await object('o2')).name === 'Renamed crate');
await shot('07-undo-redo');
// 7. A change to the *structure* of what is shown normally rebuilds the inspector, which
// 2i. A change to the *structure* of what is shown normally rebuilds the inspector, which
// destroys the widget being typed in. While a field in here has focus the rebuild has to
// wait – otherwise a field whose value decides the structure cannot be typed into at all.
await clickText('Renamed crate');
+6 -6
View File
@@ -31,7 +31,7 @@ export async function play(t) {
const gameInfo = () => json('__gm.gameInfo()');
const play = t.play;
// 7. Play: both crates lie inside the 640×360 frame at the origin; the behavior symbol is
// 3a. Play: both crates lie inside the 640×360 frame at the origin; the behavior symbol is
// editor-only. The "Mover" behavior owns the red crate: holding ArrowRight moves it in the
// world (Axis input × speed × dt), Esc stops without touching the model.
await clickText('Renamed crate');
@@ -72,7 +72,7 @@ export async function play(t) {
`${o2AfterPlay} vs ${xBeforePlay}, o1=${o1AfterPlay}`,
);
// 7a. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
// 3b. Editing while playing: Play opens the Game tab, the panels stay open and edit a runtime
// copy. Moving the blue crate (model hook + preview, as a scrub does) and deleting it with
// the Delete key change the world at once, undo brings it back, Esc restores the saved model.
await play();
@@ -151,7 +151,7 @@ export async function play(t) {
);
check('stop returns to the Scene tab', (await evaluate('__gm.tab()')) === 'scene');
// 7b. Culling: move the blue crate far outside the camera frame → only one item visible.
// 3c. Culling: move the blue crate far outside the camera frame → only one item visible.
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = 5000`);
await play();
info = await gameInfo();
@@ -159,7 +159,7 @@ export async function play(t) {
await stop();
await mutate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x = ${xBeforePlay}`);
// 7c. Without a camera nothing is rendered: delete the camera object, play, then undo.
// 3d. Without a camera nothing is rendered: delete the camera object, play, then undo.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
await press('Delete');
await sleep(200);
@@ -178,7 +178,7 @@ export async function play(t) {
await undo();
check('undo restored the camera object', (await objects()).length === 7);
// 7d. Which camera renders: a camera that is switched off is skipped like any other object, and
// 3e. Which camera renders: a camera that is switched off is skipped like any other object, and
// of those left the first in hierarchy order takes the view. The second camera is parked far
// from the origin, so "seen through it" is provable – both crates fall outside its frame.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o3' });
@@ -242,7 +242,7 @@ export async function play(t) {
JSON.stringify((await object('o3')).overrides),
);
// 7e. Not playing, the Game tab previews the scene through the same camera instead of waiting
// 3f. Not playing, the Game tab previews the scene through the same camera instead of waiting
// for Play. Proven by comparing it with what Play then draws – identical readout and an
// identical pixel on the red crate – so "it renders something" cannot pass for a preview.
const crateSpot = async () => {
+6 -6
View File
@@ -19,7 +19,7 @@ export async function assets(t) {
asset,
} = t;
// 8. Context menu on the Crate asset. Placing left the menu when it became a drag.
// 4a. Context menu on the Crate asset. Placing left the menu when it became a drag.
await clickText('Crate', 'right');
check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`)));
check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`)));
@@ -27,7 +27,7 @@ export async function assets(t) {
await press('Escape');
await sleep(150);
// 8b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
// 4b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
// with `drag()`, because the preview only exists while the button is still down.
const from = await json("__gm.assetRow('crate')");
const centre = await json('__gm.viewportCenter()');
@@ -73,7 +73,7 @@ export async function assets(t) {
await sleep(150);
check('redo puts it back', (await objects()).length === 8);
// 9. Create a new asset through the + menu.
// 4c. Create a new asset through the + menu.
await clickText('+');
await clickText('Sprite');
await sleep(100);
@@ -81,7 +81,7 @@ export async function assets(t) {
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
await shot('12-new-asset');
// 9b. Double-click on a scene row: the one thing activating an asset still does since placing
// 4d. Double-click on a scene row: the one thing activating an asset still does since placing
// became a drag. The second scene goes in through the model – the "+" menu's "Scene" entry
// shares its label with the Scene tab, so a click by text could land on either.
await mutate(
@@ -103,7 +103,7 @@ export async function assets(t) {
);
await mutate(`__gm.state.project.assets = __gm.state.project.assets.filter((a) => a.id !== 'scene2')`);
// 12. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour,
// 4e. Asset defaults: the Crate deviates in height (60 against the schema's 100) and colour,
// so two rows carry a reset. Rows run Enabled, Width, Height, Color, Texture, so height's
// reset is the first on screen and colour's the last.
await select({ kind: 'asset', assetId: 'crate' });
@@ -144,7 +144,7 @@ export async function assets(t) {
JSON.stringify(await evaluate(`__gm.locate('↺')`)),
);
// 12b. The same gold in the asset list: a row is marked while the asset carries anything the
// 4f. The same gold in the asset list: a row is marked while the asset carries anything the
// inspector marks. The Crate deviates twice, so it takes two resets to turn it plain.
const GOLD = '#f2c84b';
const PLAIN = '#e6e8ee';
+2 -2
View File
@@ -3,7 +3,7 @@ export async function drawing(t) {
const { check, sleep, json, shot, click, locate, typeText, press, undo, mutate, select, object } = t;
const orderOfCrates = () => json(`__gm.drawOrder().filter(id => id === 'o1' || id === 'o2')`);
// 10. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
// 5a. Symbols stay on top: put the camera first in the hierarchy (drawn first) and a crate
// right on top of its icon → the icon must still be drawn above the sprite.
await mutate(`
const objs = __gm.state.activeScene.objects;
@@ -20,7 +20,7 @@ export async function drawing(t) {
);
await shot('13-symbol-on-top');
// 11. Z-order: put both crates on the origin. With equal z the hierarchy order wins
// 5b. Z-order: put both crates on the origin. With equal z the hierarchy order wins
// (o1 before o2 → o2 on top); raising o1's z draws it above o2; undo restores it.
await mutate(
`const o2 = __gm.state.activeScene.objects.find(o => o.id === 'o2'); o2.position.x = 0; o2.position.y = 0`,
+18 -18
View File
@@ -34,7 +34,7 @@ export async function graph(t) {
};
const variableKeys = async () => (await asset('mover')).variables.map((v) => v.key).join(',');
// 13. Selecting the Mover asset binds the graph; the Graph tab shows it.
// 6a. Selecting the Mover asset binds the graph; the Graph tab shows it.
await clickText('Mover');
await evaluate(`__gm.showTab('graph')`);
await sleep(250);
@@ -44,7 +44,7 @@ export async function graph(t) {
);
await shot('16-graph');
// 13a. Drag a node: clicking its title selects it, dragging moves it (undoable).
// 6b. Drag a node: clicking its title selects it, dragging moves it (undoable).
const axisBefore = await nodeOf('n-axis');
const axisPos = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(axisPos.x, axisPos.y, axisPos.x + 60, axisPos.y + 40);
@@ -71,7 +71,7 @@ export async function graph(t) {
`${axisUndone.x},${axisUndone.y}`,
);
// 13b. Typing a query and then *clicking* a row adds exactly one node: the search field's
// 6c. Typing a query and then *clicking* a row adds exactly one node: the search field's
// blur-commit must not pick the first match as well.
const gcenter = await json('__gm.graph.center()');
const beforeClickPick = await graphCounts();
@@ -93,7 +93,7 @@ export async function graph(t) {
await undo(); // so the next step finds the same empty spot and the expected graph
check('undo removes the clicked node again', (await graphCounts()).nodes === beforeClickPick.nodes);
// 13c. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
// 6d. Right-click the empty canvas → palette → search "log" → Enter adds a Log node.
const before13 = await graphCounts();
await click(gcenter.x + 250, gcenter.y + 250, 'right');
await sleep(100);
@@ -112,7 +112,7 @@ export async function graph(t) {
);
await shot('17-node-added');
// 13d. Connect: drag from the Move node's exec output onto the Log node's exec input. An
// 6e. Connect: drag from the Move node's exec output onto the Log node's exec input. An
// exec output must not connect to a data input: dragging onto "message" does nothing.
const fromPort = await json(`__gm.graph.portAt('n-move', 'exec', true)`);
const toPort = await json(`__gm.graph.portAt(${JSON.stringify(newNode.id)}, 'exec', false)`);
@@ -231,7 +231,7 @@ export async function graph(t) {
JSON.stringify(cancelled),
);
// 13e. Delete removes the selected node together with its edge; undo brings both back.
// 6f. Delete removes the selected node together with its edge; undo brings both back.
await select({ kind: 'node', assetId: 'mover', nodeId: newNode.id });
await sleep(200);
check('the node inspector has no delete button of its own', !(await t.has('Delete node')));
@@ -251,7 +251,7 @@ export async function graph(t) {
JSON.stringify(restored),
);
// 13f. The fps readout runs in both modes. Play always starts on the Game tab; switching to
// 6g. The fps readout runs in both modes. Play always starts on the Game tab; switching to
// Graph while playing shows the live highlight: hold ArrowRight → the exec edge is hot.
const fpsEdit = await evaluate('__gm.fps()');
check('fps readout is measured in edit mode', typeof fpsEdit === 'number' && fpsEdit > 0, `fps=${fpsEdit}`);
@@ -281,7 +281,7 @@ export async function graph(t) {
await holding;
await stop();
// 13g. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
// 6h. Variables: the "+ Variable" dropdown → Number adds "var1" with default 0; undo removes it.
await select({ kind: 'asset', assetId: 'mover' });
await sleep(150);
await clickText('+ Variable');
@@ -296,7 +296,7 @@ export async function graph(t) {
await undo();
check('undo removes the variable', (await variableKeys()) === 'speed', await variableKeys());
// 13h. The tab keeps its subject: selecting something else leaves the graph alone, and the
// 6i. The tab keeps its subject: selecting something else leaves the graph alone, and the
// header picker switches behaviors without any selection at all.
const size = await graphCounts();
const readout = `${size.nodes} nodes · ${size.edges} links`;
@@ -337,7 +337,7 @@ export async function graph(t) {
(await evaluate(`__gm.graph.current()`)) === 'mover',
);
// 13i. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
// 6j. Mouse behaviour, as in the scene view: the middle button pans from anywhere – a node
// under it included – and the left button no longer pans at all.
await clickText('Fit');
await sleep(250);
@@ -374,7 +374,7 @@ export async function graph(t) {
JSON.stringify({ axisBeforeBand, axisAfterBand }),
);
// 13j. A rubber band across the whole canvas takes every node – the count comes from the model,
// 6k. A rubber band across the whole canvas takes every node – the count comes from the model,
// so this cannot agree with the canvas by sharing its rule.
const nodeCount = (await asset('mover')).graph.nodes.length;
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
@@ -386,7 +386,7 @@ export async function graph(t) {
);
await shot('21-graph-multiselect');
// 13k. The whole selection moves as one, and one undo takes all of it back.
// 6l. The whole selection moves as one, and one undo takes all of it back.
const beforeMove = (await asset('mover')).graph.nodes.map((n) => ({ id: n.id, x: n.x, y: n.y }));
const grabAt = await json(`__gm.graph.nodeAt('n-axis')`);
await drag(grabAt.x, grabAt.y, grabAt.x + 40, grabAt.y + 30);
@@ -479,7 +479,7 @@ export async function graph(t) {
await undo();
await sleep(150);
// 13l. Delete acts on the selection, not just the primary node.
// 6m. Delete acts on the selection, not just the primary node.
await drag(corner.x, corner.y, opposite.x, opposite.y, { steps: 16 });
const beforeDelete = await graphCounts();
await press('Delete');
@@ -498,7 +498,7 @@ export async function graph(t) {
JSON.stringify(restoredAll),
);
// 13m. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
// 6n. Ctrl-click adds and removes single nodes. Fit first, so every node is really on screen
// and a click cannot miss one that the earlier pan pushed over the edge.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
@@ -520,7 +520,7 @@ export async function graph(t) {
JSON.stringify(await json('__gm.graph.selected()')),
);
// 13n. Fit really fits: proven against a node parked far outside, and against the fact that the
// 6o. Fit really fits: proven against a node parked far outside, and against the fact that the
// graph is then wider than the canvas – otherwise centring alone would pass this check.
await evaluate(
`(() => { const n = __gm.state.project.assets.find(a => a.id === 'mover').graph.nodes.find(n => n.id === 'n-axis'); n.x += 2500; __gm.state.preview(); })()`,
@@ -553,7 +553,7 @@ export async function graph(t) {
await sleep(250);
check('the parked node is back where it started', (await positions()) === before13i, await positions());
// 13o. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
// 6p. Hiding a link: it stops being drawn across the graph but keeps carrying its value, and
// comes back while either of its two nodes is selected.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
@@ -628,7 +628,7 @@ export async function graph(t) {
JSON.stringify(shown),
);
// 13p. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on
// 6q. Two hidden links out of one port. Drawn per link they would stack stub, dot and name on
// the very same pixels – the names smearing over each other. One port, one mark.
const before13p = await graphCounts();
const forkPort = await json(
@@ -770,7 +770,7 @@ export async function graph(t) {
JSON.stringify({ offered: Boolean(offered), cut, after: await forkedNow() }),
);
// 13q. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
// 6r. Collapsing a node: the title bar alone, every link meeting at one point per side. No new
// link lands on it until it is expanded, and the state is saved with the project.
await select({ kind: 'asset', assetId: 'mover' });
await clickText('Fit');
+5 -5
View File
@@ -25,7 +25,7 @@ export async function parenting(t) {
stop,
} = t;
// 14. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
// 7a. Drag the blue crate's row onto the red crate's row: it becomes a child, keeps its world
// position (local becomes the difference) and is listed right below its parent.
await mutate(`
const objs = __gm.state.activeScene.objects;
@@ -53,7 +53,7 @@ export async function parenting(t) {
);
await shot('20-parented');
// 14a. Moving the parent (X field of the red crate) drags the child along in world space.
// 7b. Moving the parent (X field of the red crate) drags the child along in world space.
await clickText('Red crate');
const xField = await locate('X');
await click(xField.x + 160, xField.y);
@@ -70,7 +70,7 @@ export async function parenting(t) {
`${JSON.stringify(o2.position)} world=${JSON.stringify(worldOfO2)}`,
);
// 14b. Play: entities mirror the hierarchy; undo restores parent and position.
// 7c. Play: entities mirror the hierarchy; undo restores parent and position.
await select(null);
await press('Enter', { ctrl: true });
await sleep(300);
@@ -90,7 +90,7 @@ export async function parenting(t) {
JSON.stringify(o2),
);
// 14c. Parent via the inspector menu: the picker is searchable, Enter takes the single match.
// 7d. Parent via the inspector menu: the picker is searchable, Enter takes the single match.
await clickText('Blue crate');
await clickText('None'); // the Parent dropdown, labelled with the current parent
const entries = await json('__gm.overlayTexts()');
@@ -128,7 +128,7 @@ export async function parenting(t) {
JSON.stringify(o2),
);
// 14d. The gap above a row is a different drop than the row itself: it orders the dragged
// 7e. The gap above a row is a different drop than the row itself: it orders the dragged
// object among that row's siblings instead of putting it inside, and moves nothing.
const rowsBefore = await json('__gm.hierarchyOrder()');
check(
+3 -3
View File
@@ -26,7 +26,7 @@ export async function colliders(t) {
stop,
} = t;
// 15. Right-click the red crate → "Add collider": a collider asset sized like the crate
// 8a. Right-click the red crate → "Add collider": a collider asset sized like the crate
// (100×60) is placed as its child at the centre, selected and drawn in the overlay. The
// seed already gives both crates a "Crate collider" child (oc1/oc2), so the added one is
// undone again to keep exactly one collider per body for the checks below.
@@ -59,7 +59,7 @@ export async function colliders(t) {
(await objects()).length === objectsBefore && colliderAssetGone,
);
// 15a. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
// 8b. Wire "On collision enter → Log(other)" into the Mover (owner = red crate) and play with
// the crates overlapping: the log names the blue crate exactly once (enter, not stay),
// and colliders are not rendered in play mode.
await mutate(`
@@ -89,7 +89,7 @@ export async function colliders(t) {
);
await stop();
// 15b. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
// 8c. Solid vs trigger. Put the blue crate 150 to the right of the red one (red 100 wide, the
// blue collider 140 wide → 30 of free travel) and hold ArrowRight: the Mover's Move
// (Collide with solids) parks the red crate flush against the blue one at world x = −120.
// Making the blue collider a trigger lets it pass through instead (drawn amber). A frame
+16 -16
View File
@@ -55,7 +55,7 @@ export async function containers(t) {
await sleep(250);
};
// 16. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy:
// 9a. Reset both crates to known spots, then Ctrl-click the second row in the hierarchy:
// both are selected, the inspector shows the multi view.
await mutate(`
const objs = __gm.state.activeScene.objects;
@@ -85,7 +85,7 @@ export async function containers(t) {
JSON.stringify(s.selection),
);
// 16a. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
// 9b. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
// the objects whose position lies inside – the crate and its collider, not the Mover
// (at y = 110), not the camera (at the origin).
await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
@@ -97,7 +97,7 @@ export async function containers(t) {
);
await shot('25-marquee');
// 16b. Dragging one of several selected objects moves all of them as one undo step.
// 9c. Dragging one of several selected objects moves all of them as one undo step.
const redScreen = await json(`__gm.objectScreen('o1')`);
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
@@ -112,7 +112,7 @@ export async function containers(t) {
(await object('o1')).position.x === -150 && (await object('o2')).position.x === 120,
);
// 16c. Delete with a multi-selection removes all of them (with subtrees) as one step.
// 9d. Delete with a multi-selection removes all of them (with subtrees) as one step.
const listedBeforeDelete = (await objects()).length;
await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] });
await press('Delete');
@@ -124,7 +124,7 @@ export async function containers(t) {
await undo();
check('undo restores all of them', (await objects()).length === listedBeforeDelete);
// 16d. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
// 9e. Create container: both crates, their colliders and the Mover *leave the scene*; one stored
// container object stands in their stead and the five are resolved from its template.
const assetsBefore = (await json('__gm.state.project.assets')).length;
const storedBefore = (await storedObjects()).length;
@@ -201,7 +201,7 @@ export async function containers(t) {
);
await shot('24-container-created');
// 16e. Dragging the container symbol in the viewport moves the whole group; undo restores it.
// 9f. Dragging the container symbol in the viewport moves the whole group; undo restores it.
const cScreen = await json(`__gm.objectScreen('${instanceA.id}')`);
await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
check(
@@ -212,7 +212,7 @@ export async function containers(t) {
await undo();
check('undo moves the container back', (await object(instanceA.id)).position.x === -15);
// 16f. Placing the container asset again adds exactly ONE stored object – and five more that
// 9g. Placing the container asset again adds exactly ONE stored object – and five more that
// the editor lists. Against the old copying model the stored count would jump by six.
const spot = await json('__gm.viewportCenter()');
await dragAsset(containerAsset.id, spot.x + 90, spot.y + 60);
@@ -243,7 +243,7 @@ export async function containers(t) {
);
await shot('26-container-placed');
// 16g. A value edited on one instance is kept on that instance and nowhere else: the scene
// 9h. A value edited on one instance is kept on that instance and nowhere else: the scene
// grows by no object, the deviation lands in `instanceEdits` under the template path.
await selectObject(redB);
check('the inspector names the container the object comes from', await has(containerAsset.name));
@@ -262,7 +262,7 @@ export async function containers(t) {
JSON.stringify((await object(redA)).position),
);
// 16h. Editing the template reaches every instance that has not been touched there – and only
// 9i. Editing the template reaches every instance that has not been touched there – and only
// those. The untouched axis of the edited object follows along with the rest.
const setTemplate = (body) =>
mutate(
@@ -294,7 +294,7 @@ export async function containers(t) {
(await object(redA)).position.x === -135 && (await object(redA)).name === 'Red crate',
);
// 16i. Structure is the template's business: resolved content cannot be removed, re-parented,
// 9j. Structure is the template's business: resolved content cannot be removed, re-parented,
// given a collider or turned into a container of its own.
// The hierarchy menu is the only way to a collider, and a disabled entry must not even reach
// the command: the command refuses too, but with a notice.
@@ -385,7 +385,7 @@ export async function containers(t) {
);
await shot('26b-structure-refused');
// 16j. Unpack: the content is written into the scene as ordinary objects under the very same
// 9k. Unpack: the content is written into the scene as ordinary objects under the very same
// ids, the instance stops following the template – and now its structure *can* be changed.
await dragAsset(containerAsset.id, spot.x - 90, spot.y + 60);
const instanceC = (await storedObjects()).find(
@@ -465,7 +465,7 @@ export async function containers(t) {
JSON.stringify({ now: (await storedObjects()).length, was: storedBeforeUnpack }),
);
// 16k. Spawning a container at runtime spells it out the same way Unpack does, under the same
// 9l. Spawning a container at runtime spells it out the same way Unpack does, under the same
// derived ids – and Stop throws the whole run away with it.
const spawnParam = (assetId) =>
mutate(
@@ -495,7 +495,7 @@ export async function containers(t) {
check('stop discards the spawned instance', (await objects()).length === beforeSpawn.length);
await spawnParam('crate');
// 16l. Apply / revert on resolved content. The red crate of instance B was moved on x, the one
// 9m. Apply / revert on resolved content. The red crate of instance B was moved on x, the one
// of instance A still matches the template; ↑ writes the value into the template and A
// follows, ↺ drops the deviation so B follows again.
const templateRed = async () => (await asset(containerAsset.id)).objects.find((o) => o.id === redAPath);
@@ -528,7 +528,7 @@ export async function containers(t) {
JSON.stringify(revertedB.instanceEdits),
);
// 16m. An override on resolved content deviates from two bases at once – the template it
// 9n. An override on resolved content deviates from two bases at once – the template it
// follows and, one step further up, the asset – so apply offers a menu. Reset never goes
// past the nearest base, so it stays a single button.
await selectObject(redB);
@@ -574,7 +574,7 @@ export async function containers(t) {
JSON.stringify({ template: (await templateRed()).overrides, a: (await object(redA)).overrides }),
);
// 16n. redB matches its template again (both 77) and has nothing of its own left to reset –
// 9o. redB matches its template again (both 77) and has nothing of its own left to reset –
// but the value still differs from the asset (100), which apply can still reach past it.
await sleep(200);
check(
@@ -593,7 +593,7 @@ export async function containers(t) {
.objects.find(o => o.id === '${redAPath}').overrides.width`,
);
// 16o. Save and load: an instance is a container object plus its deviations, nothing else.
// 9p. Save and load: an instance is a container object plus its deviations, nothing else.
// The content must be nowhere in the saved scene, and reloading must resolve it back.
await selectObject(redB);
await setField('Y', 12);
+4 -4
View File
@@ -18,7 +18,7 @@ export async function textAndEnabled(t) {
typeText,
} = t;
// 17. Text assets: created through the + menu, placed, and rendered as real text in the scene.
// 10a. Text assets: created through the + menu, placed, and rendered as real text in the scene.
await clickText('+');
await clickText('Text');
await sleep(100);
@@ -35,7 +35,7 @@ export async function textAndEnabled(t) {
check('a placed text object draws its content in the scene', await has('Hello world'));
await shot('29-text-object');
// 17a. The enabled switch: every object has it, switching it off is a normal override, and a
// 10b. The enabled switch: every object has it, switching it off is a normal override, and a
// switched-off object disappears from the scene view too (it stays in the hierarchy).
const enabledRow = await json("__gm.inspectorField('Enabled')");
check('the inspector shows the Enabled switch for a scene object', Boolean(enabledRow));
@@ -89,7 +89,7 @@ export async function textAndEnabled(t) {
check('and it is still not drawn', !(await json('__gm.drawOrder()')).includes('otext'));
await shot('30b-disabled-gizmo');
// 17b. In the running game a switched-off object does not exist: it is not rendered, and
// 10c. In the running game a switched-off object does not exist: it is not rendered, and
// switching it back on brings it back.
await play();
await sleep(200);
@@ -105,7 +105,7 @@ export async function textAndEnabled(t) {
await stop();
check('stop leaves the play-mode switch behind', (await object('otext')).overrides.enabled === false);
// 17c. A field that shows no decimals must not store any either. Font size is such a field, so
// 10d. A field that shows no decimals must not store any either. Font size is such a field, so
// typing a fraction has to round: showing "33" while the project holds 32.6 would be a lie.
const fontLabel = await locate('Font size');
check('Font size reads as a whole number', Boolean(await has('32')) && !(await has('32.000')));
+2 -2
View File
@@ -24,7 +24,7 @@ export async function menus(t) {
__gm.state.select({ kind: 'object', holderId: 'scene1', objectIds: ['otext'], objectId: 'otext' })`);
await sleep(150);
// 18. A picker with 40+ entries is capped in height and stays fully on screen instead of
// 11a. A picker with 40+ entries is capped in height and stays fully on screen instead of
// running off the bottom edge.
const parentRow = await json("__gm.inspectorField('Parent')");
await click(parentRow.x, parentRow.y);
@@ -42,7 +42,7 @@ export async function menus(t) {
);
await shot('31-long-parent-menu');
// 18a. The entries scroll under the wheel; the search box above them does not move.
// 11b. The entries scroll under the wheel; the search box above them does not move.
// Anchored on a filler row read off the open menu: "None" could match an inspector field behind it,
// and which fillers are drawn depends on what earlier steps left in the scene.
const visibleFillers = (await json('__gm.overlayTexts()')).filter((e) => e.startsWith('Filler '));
+7 -7
View File
@@ -48,7 +48,7 @@ export async function textures(t) {
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
await sleep(200);
// 19. Pasting an image imports it: uploaded, stored under its hash, and made an asset.
// 12a. Pasting an image imports it: uploaded, stored under its hash, and made an asset.
await evaluate(PASTE_IMAGE);
await sleep(2500);
@@ -68,7 +68,7 @@ export async function textures(t) {
JSON.stringify({ w: texture.w, h: texture.h }),
);
// 19a. Importing the same picture again reuses the asset: the server hands back the file it
// 12b. Importing the same picture again reuses the asset: the server hands back the file it
// already has, since the name is the content hash.
await evaluate(PASTE_IMAGE);
await sleep(2500);
@@ -86,7 +86,7 @@ export async function textures(t) {
JSON.stringify(await json('__gm.state.notice')),
);
// 19b. The picture is actually drawn – a sprite wearing it is no longer one flat colour.
// 12c. The picture is actually drawn – a sprite wearing it is no longer one flat colour.
// On a fresh white sprite of its own, so the sample does not depend on eleven steps of edits
// to the fixture. Cloned rather than written as a literal: a literal missed `editorOffset`,
// which sent the position to NaN and culled the object without an error.
@@ -188,7 +188,7 @@ export async function textures(t) {
await mutate(enlarge(160, 120));
await sleep(400);
// 19c. The colour multiplies the picture, so a tinted white quadrant stops being white.
// 12d. The colour multiplies the picture, so a tinted white quadrant stops being white.
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0x3366ff");
await sleep(600);
const tinted = await at(0.3, 0.3);
@@ -198,7 +198,7 @@ export async function textures(t) {
JSON.stringify({ was: bottomRight, tinted }),
);
// 19d. A reference that no longer resolves falls back to the plain colour instead of failing.
// 12e. A reference that no longer resolves falls back to the plain colour instead of failing.
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.texture = 'gone'");
await sleep(600);
const fallback = await at(0.3, 0.3);
@@ -209,7 +209,7 @@ export async function textures(t) {
);
}
// 19e. "+ → Texture" offers the pictures the server already holds, unreferenced ones included:
// 12f. "+ → Texture" offers the pictures the server already holds, unreferenced ones included:
// paste a second picture, undo the asset (the file stays), then pick that file in the dialog.
await evaluate(PASTE_SPARE);
await sleep(2500);
@@ -315,7 +315,7 @@ export async function textures(t) {
(await json("__gm.state.project.assets.filter((a) => a.type === 'texture').length")) === 1,
);
// 19f. Right-clicking a texture asset makes a sprite wearing it at the picture's pixel size.
// 12g. Right-clicking a texture asset makes a sprite wearing it at the picture's pixel size.
const entryText = 'Create sprite from texture';
const spriteRow = await json("__gm.assetRow('crate')");
await click(spriteRow.x, spriteRow.y, 'right');
+6 -6
View File
@@ -36,7 +36,7 @@ function nearestColour(sample) {
export async function frames(t) {
const { check, sleep, json, evaluate, shot, clickText, drag, press, undo } = t;
// 20. The tab exists and shows the texture the selection points at.
// 13a. The tab exists and shows the texture the selection points at.
await press('Escape');
await evaluate('__gm.state.select(null)');
const texture = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
@@ -51,7 +51,7 @@ export async function frames(t) {
);
await shot('33-texture-tab');
// 21. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24),
// 13b. Dragging on the picture cuts a frame. Aimed at the top-right quadrant (32,0)-(64,24),
// inset by a pixel: a press on a frame edge is read as a resize, not a new rectangle.
const from = await json('__gm.texture.pointAt(33, 1)');
const to = await json('__gm.texture.pointAt(63, 23)');
@@ -81,7 +81,7 @@ export async function frames(t) {
);
await shot('34-frame-cut');
// 21a. It is one undo step, not one per pointer move.
// 13c. It is one undo step, not one per pointer move.
await undo();
await sleep(400);
check(
@@ -93,7 +93,7 @@ export async function frames(t) {
await t.redo();
await sleep(400);
// 22. A sprite wearing the frame shows that part of the sheet and nothing else.
// 13d. A sprite wearing the frame shows that part of the sheet and nothing else.
await evaluate(`(() => {
const sprite = __gm.state.project.assets.find((a) => a.id === 'tex-sprite');
sprite.props.color = 0xffffff;
@@ -120,7 +120,7 @@ export async function frames(t) {
await shot('35-sprite-wearing-a-frame');
}
// 23. Slicing offers a live count before it commits.
// 13e. Slicing offers a live count before it commits.
// Repainted magenta first: magenta is nowhere in the sheet, so the fallback check below
// cannot be confused with a part of the picture.
await evaluate(`(() => {
@@ -156,7 +156,7 @@ export async function frames(t) {
check('and numbers them in reading order', sliced[0]?.startsWith('Frame 1 '), JSON.stringify(sliced[0]));
await shot('36-sliced-sheet');
// 23a. A sprite pointed at a frame slicing removed falls back to its colour.
// 13f. A sprite pointed at a frame slicing removed falls back to its colour.
await evaluate("__gm.showTab('scene')");
await sleep(700);
if (box) {
+5 -5
View File
@@ -28,7 +28,7 @@ export async function overflow(t) {
__gm.state.select(null)`);
await sleep(600);
// 21. The pickers keep the width they were given instead of growing with the name they show.
// 14a. The pickers keep the width they were given instead of growing with the name they show.
const after = await pickerWidths();
for (const tab of ['graph', 'texture', 'container']) {
check(
@@ -38,7 +38,7 @@ export async function overflow(t) {
);
}
// 21a. Every tab, because each one owns a different set of controls.
// 14b. Every tab, because each one owns a different set of controls.
for (const tab of ['scene', 'graph', 'texture', 'game', 'container', 'list']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
await sleep(400);
@@ -50,7 +50,7 @@ export async function overflow(t) {
);
}
// 21b. Shortened, not simply dropped: the value must still be readable and marked as cut.
// 14c. Shortened, not simply dropped: the value must still be readable and marked as cut.
await evaluate("__gm.showTab('graph')");
await sleep(400);
const shown = (await json('__gm.textOverflows(true)')).find((e) => e.text.startsWith('Player movement'));
@@ -61,7 +61,7 @@ export async function overflow(t) {
);
await shot('36-long-labels');
// 21c. An inspector full of dropdowns – the parent row and the enum fields – with a selection
// 14d. An inspector full of dropdowns – the parent row and the enum fields – with a selection
// whose asset name is now overlong too.
await evaluate("__gm.showTab('scene')");
await sleep(300);
@@ -70,7 +70,7 @@ export async function overflow(t) {
const inspector = await json('__gm.textOverflows()');
check('no label spills in the inspector', inspector.length === 0, JSON.stringify(inspector));
// 21d. The other direction: a control narrow enough to swallow its whole label draws nothing
// 14e. The other direction: a control narrow enough to swallow its whole label draws nothing
// at all, which the overflow walk cannot see – an empty text spills over no edge.
for (const tab of ['scene', 'graph', 'texture', 'container', 'list']) {
await evaluate(`__gm.showTab(${JSON.stringify(tab)})`);
+8 -8
View File
@@ -24,7 +24,7 @@ export async function aspect(t) {
const sheet = await json("(__gm.state.project.assets.find((a) => a.type === 'texture') ?? {}).id ?? ''");
check('the imported sheet is still there', sheet !== '', sheet);
// 26. A sprite wearing nothing has nothing to be proportional to, so the buttons are absent.
// 15a. A sprite wearing nothing has nothing to be proportional to, so the buttons are absent.
// Edited as an asset: one base value per row instead of an override, so the numbers below
// say what they mean.
await mutate(`
@@ -38,7 +38,7 @@ export async function aspect(t) {
check('no texture, no aspect buttons', (await sizeButton('↔')) === '' && (await sizeButton('↕')) === '');
await shot('40-aspect-without-a-texture');
// 27. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size,
// 15b. Wearing the 64 x 48 sheet, a 100 x 100 sprite is neither proportional nor at pixel size,
// so both rows offer two things and show the menu form.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await sleep(500);
@@ -48,7 +48,7 @@ export async function aspect(t) {
);
await shot('41-aspect-buttons');
// 28. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
// 15c. Fit moves the width only, to the sheet's ratio: 100 x 64/48.
await clickText('↔ ▾');
await sleep(400);
await clickText('Fit to 64 × 48');
@@ -65,7 +65,7 @@ export async function aspect(t) {
);
await shot('42-aspect-fitted');
// 28a. One command, so it is one undo step – and the menu form comes back with the old shape.
// 15d. One command, so it is one undo step – and the menu form comes back with the old shape.
await undo();
await sleep(500);
check('undo takes the width back', (await json(spriteProp('width'))) === 100);
@@ -73,7 +73,7 @@ export async function aspect(t) {
await t.redo();
await sleep(500);
// 28b. What is left over is the pixel size, now a direct button: it writes *both* sides, and
// 15e. What is left over is the pixel size, now a direct button: it writes *both* sides, and
// the whole button goes because there is nothing left to offer.
await clickText('↔');
await sleep(500);
@@ -96,7 +96,7 @@ export async function aspect(t) {
await t.redo();
await sleep(500);
// 29. A frame is the picture the sprite wears, not the sheet it was cut from: 40 x 10 against
// 15f. A frame is the picture the sprite wears, not the sheet it was cut from: 40 x 10 against
// the sheet's 64 x 48, so the two ratios cannot be confused for one another.
await mutate(`
const asset = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(sheet)});
@@ -118,7 +118,7 @@ export async function aspect(t) {
);
check('and only the pixel size is left to offer', (await sizeButton('↕')) === 'plain');
// 30. The same rows on a placed object write an override rather than the asset value.
// 15g. The same rows on a placed object write an override rather than the asset value.
await mutate(`${spriteProp('texture')} = ${JSON.stringify(sheet)}`);
await select({ kind: 'object', holderId: 'scene1', objectIds: ['otex'], objectId: 'otex' });
await sleep(600);
@@ -140,7 +140,7 @@ export async function aspect(t) {
);
await shot('44-aspect-on-an-object');
// 30a. The pixel size on an object writes both overrides, and as one undo step.
// 15h. The pixel size on an object writes both overrides, and as one undo step.
await clickText('↔');
await sleep(500);
const sized = await json("__gm.state.activeScene.objects.find((o) => o.id === 'otex').overrides");
+11 -11
View File
@@ -37,7 +37,7 @@ export async function containerTab(t) {
);
await sleep(200);
// 22. The tab opens on the container the project has, and its header says which one.
// 16a. The tab opens on the container the project has, and its header says which one.
await clickText('Container');
await sleep(500);
const template = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
@@ -60,7 +60,7 @@ export async function containerTab(t) {
);
await shot('30-container-tab');
// 22a. The hierarchy follows the visible tab: it lists the template, not the scene.
// 16b. The hierarchy follows the visible tab: it lists the template, not the scene.
const rows = await json('__gm.hierarchyOrder()');
check(
'the hierarchy lists the template objects while the tab is open',
@@ -68,7 +68,7 @@ export async function containerTab(t) {
JSON.stringify(rows),
);
// 22b. Clicking a template object selects it against the container as its holder – the same
// 16c. Clicking a template object selects it against the container as its holder – the same
// id set as the scene would give, but a different holder.
const root = template.find((o) => o.parentId === '');
const at = await json(`__gm.container.objectScreen(${JSON.stringify(root.id)})`);
@@ -82,7 +82,7 @@ export async function containerTab(t) {
JSON.stringify(s.selection),
);
// 22c. The point of the tab: a drag writes the template, and every instance follows it – except
// 16d. The point of the tab: a drag writes the template, and every instance follows it – except
// one that was moved there on its own, whose deviation the instance keeps.
const templateX = () =>
json(
@@ -132,7 +132,7 @@ export async function containerTab(t) {
);
await setDeviation(`delete edits['${root.id}']`);
// 22d. An asset dragged from the panel lands in the view it is dropped on: dropping on this
// 16e. An asset dragged from the panel lands in the view it is dropped on: dropping on this
// tab adds one object to the template, not to the scene.
// What the scene *stores* – that must not move. What it *lists* has to grow by one per placed
// instance instead, since every one of them resolves the new template object too.
@@ -158,7 +158,7 @@ export async function containerTab(t) {
);
await shot('31-container-object-added');
// 22e. Delete works on a template object like on any other, as one undo step.
// 16f. Delete works on a template object like on any other, as one undo step.
await press('Delete');
await sleep(250);
check(
@@ -174,7 +174,7 @@ export async function containerTab(t) {
await press('Delete');
await sleep(250);
// 22f. Fit frames the content. Proven against an object parked far outside the view: without
// 16g. Fit frames the content. Proven against an object parked far outside the view: without
// fitting it is nowhere near the canvas.
await evaluate(
`(() => { const t = __gm.state.project.assets.find(a => a.id === '${containerId}').objects.find(o => o.id === '${root.id}'); t.position.x = 3000; __gm.state.preview(); })()`,
@@ -198,7 +198,7 @@ export async function containerTab(t) {
await sleep(300);
await shot('32-container-fitted');
// 22g. A template object exists only in this tab, so selecting one from elsewhere switches to
// 16h. A template object exists only in this tab, so selecting one from elsewhere switches to
// it – the rule a graph node and a texture frame already follow. A scene object does not.
await evaluate("__gm.showTab('scene')");
await sleep(300);
@@ -221,7 +221,7 @@ export async function containerTab(t) {
JSON.stringify(await json('__gm.hierarchyOrder()')),
);
// 22h. Switching to another container is a plain asset selection – the same thing the header's
// 16i. Switching to another container is a plain asset selection – the same thing the header's
// picker does. The hierarchy has to follow it, title and rows both.
await evaluate("__gm.showTab('container')");
await sleep(300);
@@ -286,7 +286,7 @@ export async function containerTab(t) {
JSON.stringify({ before: ownBefore.length, after: ownAfter.length }),
);
// 22i. Nesting: another container, though, may go in – and the tab it lands in stays put.
// 16j. Nesting: another container, though, may go in – and the tab it lands in stays put.
const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
await dragAsset(containerId, canvas2.x - 40, canvas2.y + 40);
const nestedAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
@@ -298,7 +298,7 @@ export async function containerTab(t) {
JSON.stringify({ before: nested.length, after: nestedAfter.length }),
);
// 22j. Nesting resolves all the way down: placing the outer container in the scene stores one
// 16k. Nesting resolves all the way down: placing the outer container in the scene stores one
// object, and the inner container's content appears under `<instance>~<outer>/<inner>`.
const innerObject = nestedAfter.find((o) => o.assetId === containerId);
const innerTemplate = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
+8 -8
View File
@@ -45,7 +45,7 @@ const driftGraph = {
export async function editorRun(t) {
const { check, sleep, evaluate, json, shot, click, holdKey, mutate, undo, object, api, projectId } = t;
// 18a. A box with a drifting behavior below it. Written into the model directly: what is under
// 17a. A box with a drifting behavior below it. Written into the model directly: what is under
// test is the switch, not another way of building a graph.
await mutate(`
const p = __gm.state.project;
@@ -74,7 +74,7 @@ export async function editorRun(t) {
`${JSON.stringify(idle)} x=${startX}`,
);
// 18b. The switch itself, through the inspector. It belongs with Enabled and above the
// 17b. The switch itself, through the inspector. It belongs with Enabled and above the
// VARIABLES section: it is a switch of the behavior, not something the behavior declares.
await evaluate(
`__gm.state.select({ kind: 'object', holderId: __gm.state.project.activeSceneId, objectId: 'er-logic' })`,
@@ -110,7 +110,7 @@ export async function editorRun(t) {
`${JSON.stringify(running)} mode=${mode}`,
);
// 18c. It really drives its owner, and it is the stored scene object that moves – no runtime
// 17c. It really drives its owner, and it is the stored scene object that moves – no runtime
// copy is involved here, which is the whole difference to playing.
const before = await boxX();
await sleep(600);
@@ -122,7 +122,7 @@ export async function editorRun(t) {
);
await shot('36-editor-run');
// 18d. The point of this semantic: the change is a real edit, so it reaches the server.
// 17d. The point of this semantic: the change is a real edit, so it reaches the server.
const first = await api(`projects/${projectId}`);
await sleep(2000);
const second = await api(`projects/${projectId}`);
@@ -142,7 +142,7 @@ export async function editorRun(t) {
`${saves} saves in 2 s`,
);
// 18e. Taking the switch back stops it again.
// 17e. Taking the switch back stops it again.
await click(runBox.x, runBox.y);
await sleep(300);
const stoppedAt = await boxX();
@@ -154,7 +154,7 @@ export async function editorRun(t) {
`${JSON.stringify(stopped)} x=${stoppedAt}`,
);
// 18f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
// 17f. Undo covers the two clicks on the switch but nothing the behavior did while on. The first
// undo re-enables it, so it briefly runs again: undo rewinds the switch, never the clock.
await undo();
await undo();
@@ -168,7 +168,7 @@ export async function editorRun(t) {
`overrides=${JSON.stringify((await object('er-logic')).overrides)} x=${afterUndo.position.x}, started at ${startX}`,
);
// 18g. The case a real project has: the behavior sits inside a *placed container* – a level
// 17g. The case a real project has: the behavior sits inside a *placed container* – a level
// packed up once and dropped into the scene. The scene stores the container object alone,
// so this only works if the run resolves the template instead of expecting stored objects.
await mutate(`
@@ -213,7 +213,7 @@ export async function editorRun(t) {
`stored=${JSON.stringify(stored.filter((id) => id.startsWith('er-')))} edit.x=${edits['t-box']?.position?.x} template.x=${template.x}`,
);
// 18h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the
// 17h. The keyboard. It is the editor's while the scene is on screen – a name being typed or the
// arrow keys walking the hierarchy must not steer a game at the same time. The game tab is
// where no key means anything to the editor, so that is where a behavior may have them.
await mutate(`
+7 -7
View File
@@ -51,7 +51,7 @@ export async function pause(t) {
const boxX = async () => (await entity('p-box'))?.x ?? NaN;
const keyHits = async () => (await world()).log.filter((line) => line.includes(KEY_LOG)).length;
// 19a. A box with a drifting behavior below it, written into the model directly: what is under
// 18a. A box with a drifting behavior below it, written into the model directly: what is under
// test is the pause, not another way of building a graph.
const rig = await scaffoldBehavior(t, {
assetId: 'pauser',
@@ -75,7 +75,7 @@ export async function pause(t) {
`x ${beforeDrift} → ${drifted}, ${JSON.stringify(running)}`,
);
// 19b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps
// 18b. The toolbar button holds it. Both the clock and the box have to stop – a world that keeps
// ticking while the picture happens to look the same would pass a position check alone.
await clickText('❚❚ Pause');
await sleep(200);
@@ -100,7 +100,7 @@ export async function pause(t) {
);
await shot('36-paused');
// 19c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for
// 18c. Keys pressed while it is held are dropped, not queued: resuming must not fire an edge for
// each of them. The count before proves the graph reacts at all.
const hitsBefore = await keyHits();
await press('ArrowRight');
@@ -110,7 +110,7 @@ export async function pause(t) {
await press('ArrowRight');
await sleep(200);
// 19d. Ctrl+Enter resumes, the same shortcut that started play.
// 18d. Ctrl+Enter resumes, the same shortcut that started play.
await press('Enter', { ctrl: true });
await sleep(400);
const resumed = await playInfo();
@@ -139,7 +139,7 @@ export async function pause(t) {
`${hitsBefore} → ${await keyHits()}`,
);
// 19e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts
// 18e. A hold must never outlive its play: Esc stops from a paused game, and the next run starts
// moving. A pause left standing would leave the editor unable to run anything.
await press('Enter', { ctrl: true });
await sleep(200);
@@ -159,7 +159,7 @@ export async function pause(t) {
`after stop ${JSON.stringify(afterStop)}, x ${restartedX} → ${await boxX()}`,
);
// 19f. The graph stays editable while the game runs, with the mouse as in edit mode: a click
// 18f. The graph stays editable while the game runs, with the mouse as in edit mode: a click
// selects the Move node, Delete removes it, and the running world stops drifting the box.
await select({ kind: 'asset', assetId: 'pauser' });
await evaluate(`__gm.showTab('graph')`);
@@ -244,7 +244,7 @@ export async function pause(t) {
JSON.stringify((await asset('pauser')).graph.nodes.map((n) => n.id)),
);
// 19g. Play discarded its copy, so the box is back at the position the model holds.
// 18g. Play discarded its copy, so the box is back at the position the model holds.
const left = await rig.remove();
check(
'the step leaves nothing of its own behind',
+12 -12
View File
@@ -33,7 +33,7 @@ export async function listTab(t) {
`);
await sleep(150);
// 1. The inspector shows the count, not a comma-separated line.
// 21a. The inspector shows the count, not a comma-separated line.
const button = await find('3 items');
check('a list property reads as its item count', button !== null, JSON.stringify(button));
check('the editor is still on the scene tab', (await json('__gm.tab()')) === 'scene');
@@ -48,7 +48,7 @@ export async function listTab(t) {
check('and the inspector is still beside it', (await find('VARIABLES')) !== null);
await shot('02-list-tab');
// 3. A row edits its item, and it is one undo step.
// 21b. A row edits its item, and it is one undo step.
const second = await json('__gm.list.fieldAt(1)');
await click(second.x, second.y);
await typeText('42');
@@ -60,7 +60,7 @@ export async function listTab(t) {
info = await json('__gm.list.info()');
check('undo takes just that item back', JSON.stringify(info.items) === '["30","11","44"]', info.items[1]);
// 4. A short list opened after a long one. Its rows leave the content height pinned to the
// 21c. A short list opened after a long one. Its rows leave the content height pinned to the
// viewport, so a layout skipped on "same size" would stack them all at the top.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'listy');
@@ -83,7 +83,7 @@ export async function listTab(t) {
JSON.stringify(shortRows),
);
// 5. `+ Item` asks for a kind, since the list does not hold one for it.
// 21d. `+ Item` asks for a kind, since the list does not hold one for it.
await clickText('+ Item');
await sleep(150);
check(
@@ -104,7 +104,7 @@ export async function listTab(t) {
check('undo removes it again', (await json('__gm.list.info()')).items.length === 3);
await shot('03-rows');
// 6. Back on the scene tab the button carries the current count.
// 21e. Back on the scene tab the button carries the current count.
await mutate('__gm.state.openList(null)');
await sleep(200);
check('closing the list leaves the tab', (await json('__gm.tab()')) === 'scene');
@@ -115,7 +115,7 @@ export async function listTab(t) {
await sleep(200);
check('and the count follows the value', (await find('2 items')) !== null);
// 6b. A list declares nothing about its items, so the kind sits on the row: the mark in front of
// 21f. A list declares nothing about its items, so the kind sits on the row: the mark in front of
// it is the picker, and picking changes that one item and no other.
const numsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'listy')`);
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
@@ -146,7 +146,7 @@ export async function listTab(t) {
JSON.stringify(listy.props.nums),
);
// 6c. A colour is the one kind a stored number cannot say for itself, so it is tagged.
// 21g. A colour is the one kind a stored number cannot say for itself, so it is tagged.
mark = await json('__gm.list.markAt(1)');
await click(mark.x, mark.y);
await sleep(150);
@@ -167,7 +167,7 @@ export async function listTab(t) {
await undo();
await sleep(200);
// 6d. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
// 21h. The second tagged kind: a picture is an id, which stored on its own would be a plain text.
// The row carries the picker the `texture` property has, frames of a sheet included.
// The pictures the earlier steps imported are set aside for this one: the menu lists every sheet
// with all of its frames, and a long one would put the entry to click outside the drawn menu.
@@ -241,7 +241,7 @@ export async function listTab(t) {
JSON.stringify(lefts),
);
// 7. A list whose target is gone empties the tab rather than showing a stale one.
// 21i. A list whose target is gone empties the tab rather than showing a stale one.
await mutate("__gm.state.openList({ kind: 'asset', assetId: 'listy', key: 'nums' })");
await sleep(200);
check('a list can be opened from outside the inspector too', (await json('__gm.tab()')) === 'list');
@@ -256,7 +256,7 @@ export async function listTab(t) {
check('a deleted target empties the tab', info.title === '' && info.items.length === 0, info.title);
await shot('04-empty');
// 8. A list whose items are structs: each row is the bundle's fields under each other, built by
// 21j. A list whose items are structs: each row is the bundle's fields under each other, built by
// the inspector's own struct renderer. Values are two digits so no row's index can match one.
await mutate(`
const p = __gm.state.project;
@@ -317,7 +317,7 @@ export async function listTab(t) {
JSON.stringify(back),
);
// 8b. A struct item carries its own shape, so it is named, typed and grown on its own row – and
// 21k. A struct item carries its own shape, so it is named, typed and grown on its own row – and
// primitives only, because a list inside a list item is the one shape the model does not store.
const cellsOf = () => json(`__gm.state.project.assets.find((a) => a.id === 'celly').props.cells`);
check('a struct item carries the add-a-field control', (await find('+ Field')) !== null);
@@ -387,7 +387,7 @@ export async function listTab(t) {
JSON.stringify(await cellsOf()),
);
// 9. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
// 21l. A node's unconnected `list` input lives in the same params bag as a setting, so it is a count
// button too.
await mutate(`
const a = __gm.state.project.assets.find((x) => x.id === 'celly');
+8 -8
View File
@@ -44,7 +44,7 @@ export async function structVariable(t) {
`);
await sleep(150);
// 1. The declaration is what the inspector draws: a head that can add fields, a row per field.
// 22a. The declaration is what the inspector draws: a head that can add fields, a row per field.
check('a struct variable carries the add-a-field control', (await find('+ Field')) !== null);
check('and a row for each declared field', (await find('x')) !== null && (await find('tag')) !== null);
check('whose value is shown, not a summary of the bundle', (await find('30.000')) !== null);
@@ -56,7 +56,7 @@ export async function structVariable(t) {
);
await shot('01-struct-rows');
// 2. Writing one field keeps the others. Two edits in a row without a rebuild in between is
// 22b. Writing one field keeps the others. Two edits in a row without a rebuild in between is
// exactly where handlers sharing one snapshot of the bundle would revive the first value.
const xField = await json("__gm.inspectorField('x')");
await click(xField.x, xField.y);
@@ -77,13 +77,13 @@ export async function structVariable(t) {
JSON.stringify(await piece()),
);
// 3. One undo step per field.
// 22c. One undo step per field.
await undo();
check('undo takes back just the last field', (await piece()).tag === 'tee' && (await piece()).x === 42);
await undo();
check('and the one before it', (await piece()).x === 30, JSON.stringify(await piece()));
// 4. Adding a field reaches the declaration and the value at once.
// 22d. Adding a field reaches the declaration and the value at once.
await clickText('+ Field');
await sleep(150);
await pickKind('Boolean');
@@ -96,7 +96,7 @@ export async function structVariable(t) {
await sleep(200);
check('undo takes it out of the value too', !('field1' in (await piece())), JSON.stringify(await piece()));
// 5. Renaming a field carries its value along.
// 22e. Renaming a field carries its value along.
const nameField = await json("__gm.locate('x')");
await click(nameField.x, nameField.y);
await press('a', { ctrl: true });
@@ -109,7 +109,7 @@ export async function structVariable(t) {
JSON.stringify(await piece()),
);
// 6. A field may itself be a list. Its row is the same "n items" button a list property has,
// 22f. A field may itself be a list. Its row is the same "n items" button a list property has,
// and it opens the List tab one level down – on the field, not on the whole bundle.
await clickText('+ Field');
await sleep(150);
@@ -160,7 +160,7 @@ export async function structVariable(t) {
await mutate('__gm.state.openList(null)');
await sleep(200);
// 7. The same bundle on a placed object. An override is sparse – it starts out unset – while a
// 22g. The same bundle on a placed object. An override is sparse – it starts out unset – while a
// field edit rebuilds the whole bundle, so the untouched fields have to come from what the row
// shows (the asset's values) rather than from the empty form of the declaration.
const placed = () => json("__gm.state.activeScene.objects.find((o) => o.id === 'ostruct').overrides.piece");
@@ -180,7 +180,7 @@ export async function structVariable(t) {
await sleep(200);
check('a placed behavior shows its bundle, filled from the asset', (await find('tee')) !== null);
// 8. It reads exactly like the asset does: a head carrying the struct mark, then one row per
// 22h. It reads exactly like the asset does: a head carrying the struct mark, then one row per
// field with its kind mark, its name and its value. What is missing is the shape editing –
// a placed object overrides values, the declaration stays with the asset that owns it.
const bundleMark = await find('⊞');
+10 -90
View File
@@ -1,14 +1,4 @@
/**
* Launches the check browser locally, or with `GM_REMOTE=user@host` on another machine with the CDP port
* forwarded over SSH, since this 8 GB host has little memory to spare.
*/
import { spawn, execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
const HERE = dirname(fileURLToPath(import.meta.url));
const LAUNCHER = join(HERE, '..', 'remote', 'gm-browser.ps1');
import { spawn } from 'node:child_process';
const FLAGS = [
'--headless',
@@ -29,89 +19,19 @@ const FLAGS = [
'--mute-audio',
];
function sshArgs(key) {
return [
'-i',
key,
'-o',
'IdentitiesOnly=yes',
'-o',
'BatchMode=yes',
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'ConnectTimeout=15',
// A dead peer must surface as a closed socket, not as a run that waits forever.
'-o',
'ServerAliveInterval=10',
'-o',
'ServerAliveCountMax=3',
];
}
/**
* Starts the browser and returns its process plus a `stop()` that ends it the way that host
* needs. `proc.stderr` carries the browser's own last words either way.
*/
/** Starts chromium with the CDP port open; `proc.stderr` carries its last words. */
export function launchBrowser({ port, width, height }) {
const remote = process.env.GM_REMOTE;
if (!remote) {
const proc = spawn(
'chromium',
// --no-sandbox: this container runs as root, where chromium refuses to start otherwise.
[
...FLAGS,
'--no-sandbox',
`--remote-debugging-port=${port}`,
`--window-size=${width},${height}`,
'about:blank',
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
);
return { proc, stop: () => proc.kill('SIGTERM'), where: 'local chromium' };
}
const key = process.env.GM_REMOTE_KEY ?? join(homedir(), '.ssh', 'gm-win.key');
// Copied every run so the remote never runs a stale launcher.
execFileSync('scp', [...sshArgs(key), LAUNCHER, `${remote}:gm-browser.ps1`], { stdio: 'pipe' });
const proc = spawn(
'ssh',
'chromium',
// --no-sandbox: this container runs as root, where chromium refuses to start otherwise.
[
...sshArgs(key),
'-o',
'ExitOnForwardFailure=yes',
'-L',
`${port}:127.0.0.1:${port}`,
remote,
`powershell -NoProfile -ExecutionPolicy Bypass -File gm-browser.ps1 -Port ${port} -Size ${width},${height}`,
...FLAGS,
'--no-sandbox',
`--remote-debugging-port=${port}`,
`--window-size=${width},${height}`,
'about:blank',
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
);
return {
proc,
/**
* Ends the browser now rather than after the launcher's idle window. Kill only this pid: the host is
* someone's desktop, and killing by name would close their browser.
*/
stop: () => {
const pidFile = `$env:TEMP\\gm-browser-${port}.pid`;
try {
execFileSync(
'ssh',
[
...sshArgs(key),
remote,
`powershell -NoProfile -Command "if (Test-Path ${pidFile}) { Stop-Process -Id (Get-Content ${pidFile}) -Force -ErrorAction SilentlyContinue }"`,
],
{ stdio: 'pipe', timeout: 20_000 },
);
} catch {
// Unreachable host, denied, already gone: the idle window still collects it.
}
proc.kill('SIGTERM');
},
where: `remote browser on ${remote}`,
};
return { proc, stop: () => proc.kill('SIGTERM'), where: 'local chromium' };
}
-1
View File
@@ -66,7 +66,6 @@ export const variable = (key, kind, value, fields) => ({
...(fields ? { fields } : {}),
});
/** A struct field declaration. */
export const field = (key, kind = 'number') => ({ key, kind });
/**
-120
View File
@@ -1,120 +0,0 @@
# Launches a headless browser with the CDP port open and ties its life to whoever is using it.
#
# Two measured facts shape this. Windows OpenSSH does not kill its child when the connection
# drops - waiting for the caller to die leaves the browser running forever (6 orphaned chrome.exe
# after a SIGKILLed tunnel). And stdin is no signal either: only the first chunk a caller writes
# ever arrives here, so a heartbeat over stdin looks identical to a dead caller.
#
# What is reliable is the CDP port itself: while a run is under way the forwarded connection is
# established, and it closes the moment the tunnel or the run dies. So the browser stays for as
# long as somebody is talking to it. The job object then makes the kill total - when this process
# ends for any reason, KILL_ON_JOB_CLOSE takes the whole browser tree with it.
param([int]$Port = 9333, [string]$Size = "1280,800", [int]$IdleSeconds = 45, [int]$MaxMinutes = 30)
$ErrorActionPreference = 'Stop'
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class GmJob {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
static extern IntPtr CreateJobObject(IntPtr attrs, string name);
[DllImport("kernel32.dll")]
static extern bool SetInformationJobObject(IntPtr job, int infoClass, IntPtr info, uint len);
[DllImport("kernel32.dll")]
static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
const int ExtendedLimitInformation = 9;
const int LimitKillOnJobClose = 0x2000;
// Offset of LimitFlags inside JOBOBJECT_EXTENDED_LIMIT_INFORMATION on x64, and the struct's size.
const int LimitFlagsOffset = 16;
const int StructSize = 144;
public static IntPtr Create() {
IntPtr job = CreateJobObject(IntPtr.Zero, null);
if (job == IntPtr.Zero) throw new Exception("CreateJobObject failed");
IntPtr info = Marshal.AllocHGlobal(StructSize);
try {
for (int i = 0; i < StructSize; i++) Marshal.WriteByte(info, i, 0);
Marshal.WriteInt32(info, LimitFlagsOffset, LimitKillOnJobClose);
if (!SetInformationJobObject(job, ExtendedLimitInformation, info, StructSize))
throw new Exception("SetInformationJobObject failed: " + Marshal.GetLastWin32Error());
} finally { Marshal.FreeHGlobal(info); }
return job;
}
public static void Assign(IntPtr job, IntPtr process) {
if (!AssignProcessToJobObject(job, process))
throw new Exception("AssignProcessToJobObject failed: " + Marshal.GetLastWin32Error());
}
}
'@
$candidates = @(
"C:\Program Files\Google\Chrome\Application\chrome.exe",
"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
)
$exe = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $exe) { [Console]::Error.WriteLine("no chrome or edge found"); exit 2 }
$udd = Join-Path $env:TEMP "gm-e2e-$Port"
Remove-Item -Recurse -Force $udd -ErrorAction SilentlyContinue
# No --disable-gpu here: forcing software WebGL on a desktop with a real card rasterises every
# frame on the CPU and made the host unusable. Let the machine use the card it has.
$flags = @(
'--headless', '--ignore-gpu-blocklist', '--renderer-process-limit=1',
"--remote-debugging-port=$Port", "--user-data-dir=`"$udd`"", "--window-size=$Size",
'--hide-scrollbars', '--disable-background-networking', '--disable-sync',
'--disable-extensions', '--no-first-run', '--mute-audio', '--no-default-browser-check',
'about:blank'
)
$job = [GmJob]::Create()
$proc = Start-Process -FilePath $exe -ArgumentList $flags -PassThru -NoNewWindow
[GmJob]::Assign($job, $proc.Handle)
# The pid file is how the caller ends this run's browser without touching the browser windows the
# person at this desk has open.
$pidFile = Join-Path $env:TEMP "gm-browser-$Port.pid"
Set-Content -Path $pidFile -Value $proc.Id
[Console]::Error.WriteLine("gm-browser: $exe pid=$($proc.Id) port=$Port job=on")
# Either the browser dies on its own, or nobody is driving it any more. Both must end this script.
# The window has to survive the gap before the first client connects, hence starting the clock now.
$reason = "the browser exited on its own with code"
$started = Get-Date
$lastClient = $started
while (-not $proc.HasExited) {
Start-Sleep -Seconds 3
if (((Get-Date) - $started).TotalMinutes -ge $MaxMinutes) {
$reason = "the $MaxMinutes-minute cap"
break
}
# .NET rather than Get-NetTCPConnection: that one goes through WMI, which is denied to this
# account - its CIM session broke mid-run and took the browser down with it.
try {
$tcp = [System.Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties().GetActiveTcpConnections()
$clients = @($tcp | Where-Object { $_.LocalEndPoint.Port -eq $Port -and $_.State -eq 'Established' })
} catch {
# A probe that fails says nothing about the run; the cap above is what bounds an orphan.
[Console]::Error.WriteLine("gm-browser: connection probe failed: $($_.Exception.Message)")
continue
}
if ($clients.Count -gt 0) {
$lastClient = Get-Date
} elseif (((Get-Date) - $lastClient).TotalSeconds -ge $IdleSeconds) {
$reason = "no CDP client for $IdleSeconds s"
break
}
}
if ($proc.HasExited) {
[Console]::Error.WriteLine("gm-browser: $reason $($proc.ExitCode)")
} else {
# By pid, never by name: this host is somebody's desktop and their own Chrome must survive a test
# run. taskkill is denied to this account, Stop-Process is not, and the job object takes the
# renderer children when this process ends - this just makes the kill immediate.
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
[Console]::Error.WriteLine("gm-browser: $reason, killed the browser")
}
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Game Maker</title>
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1b1d22; }
canvas { display: block; width: 100%; height: 100%; touch-action: none; }
</style>
</head>
<body>
<script type="module" src="../src/main.ts"></script>
</body>
</html>