commit 5a46124454b0436c164b8398ca748759e3e549f0 Author: root Date: Mon Sep 7 10:07:06 2026 +0200 Initial commit: Game Maker phase 1 Canvas-only 2D game editor/runtime built on PixiJS v8 + Vite + TypeScript. - core: asset model (Scene/Camera/Sprite), scene objects with sparse overrides, property schema + resolve, command stack (undo/redo), serialization and project store interface (16 unit tests) - ui: custom in-canvas widget toolkit (panels, fields, color picker, list/scroll, popup) with a hidden keyboard bridge - editor: asset/hierarchy panels, schema-driven inspector, toolbar - viewport: box rendering, camera frame, selection gizmo, drag/zoom/pan - runtime: world/entity instantiation for play mode, input, camera follow - api: PHP JSON project store with revision counter (409 on conflict) - tools: CDP-based e2e smoke test Co-Authored-By: Claude Fable 5.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7124339a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +index.html +data/projects/*.json +data/projects/*.tmp +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 00000000..3bddfce2 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Game Maker + +A browser-based tool "one step before a game engine": create **assets** (scenes, cameras, +sprites), place them into scenes, tweak their properties in an inspector and press **Play** – +all in one program, all drawn inside a single WebGL canvas. + +Live: · Widget playground: + +## Concepts + +| Term | Meaning | +|---|---| +| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`) and typed properties. Assets have **no position**. | +| **Scene object** | An asset placed into a scene: a link to the asset + a **position** + sparse **overrides** of the asset's properties. Scenes cannot be placed. | +| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺). | +| **Edit mode** | The viewport draws the project model; panels, grid, gizmos and drag-to-move are active. | +| **Play mode** | A `World` is instantiated from the active scene (one entity per scene object, resolved properties as mutable state). Systems tick it; nothing writes back. **Stop discards the world.** | + +Phase-1 demo behaviour in Play: the arrow keys move the sprite that was selected when Play was +pressed (or the first sprite); the first camera in the scene follows it. + +## Stack + +- **PixiJS v8** – rendering and pointer events. No game engine; runtime systems live in `src/runtime/`. +- **Own canvas widget toolkit** (`src/ui/`) – panels, buttons, lists, text/number/color fields, + popups. The only DOM element is a hidden `` used as a keyboard bridge (IME, mobile + keyboard, clipboard) while a canvas text field is focused. +- **Vite + TypeScript**, **Vitest** for the pure core. +- **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with + atomic writes and an integer revision that rejects stale saves (HTTP 409). + +## Layout + +``` +web/index.html Vite source page (dev) index.html built page (generated, served) +src/core/ model, property schema, resolve, commands, store – no Pixi imports +src/runtime/ World, Entity, systems (Input, PlayerControl, CameraFollow) +src/ui/ widget toolkit (generic, knows nothing about assets) +src/viewport/ scene renderer: model in edit mode, world in play mode +src/editor/ App composition, state, panels, inspector field factory +api/ PHP backend data/projects/ persisted projects +tests/ Vitest unit tests tools/e2e.mjs headless CDP smoke test +``` + +Rules that keep it organised: `core/` imports nothing from other layers; `ui/` never sees +assets; position exists only on scene objects; overrides are merged in exactly one place +(`core/properties/resolve.ts`); the inspector is generated from `PropertySchema` – a new +property is one schema line. + +## Development + +Node 22 is required (on this machine: `PATH=/opt/node22/bin:$PATH`). + +```bash +npm install +npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production +npm test # unit tests (core) +npm run typecheck +npm run build # writes dist/ and regenerates index.html +node tools/e2e.mjs # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png +``` + +The workspace folder itself is what nginx serves, so `npm run build` is the deployment. + +## API + +All routes go through `api/index.php?r=…` (the site's nginx does not pass `PATH_INFO`). + +| Method | Route | Body → Result | +|---|---|---| +| GET | `projects` | `[{id,name,revision,updatedAt}]` | +| POST | `projects` | `{name}` → project document (201) | +| GET | `projects/{id}` | `{id,name,revision,updatedAt,project}` | +| PUT | `projects/{id}` | `{revision, project}` → `{revision,updatedAt}` or **409** `{revision}` when stale | +| DELETE | `projects/{id}` | 204 | + +There is no authentication yet; `data/` is world-readable. Both are phase-2 topics. + +## Keyboard + +`Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo · `Delete` remove selection · `Ctrl+Enter` play · +`Esc` stop · `Tab` cycles fields · number fields: drag to scrub (`Shift` ×10, `Alt` ×0.1), +`↑`/`↓` step · viewport: wheel zooms, middle-drag or `Shift`+drag pans. + +## Out of scope for phase 1 + +Textures on sprites, physics, scripting, multi-select, z-order UI, auth, editing while playing, +touch-optimised layout. diff --git a/api/index.php b/api/index.php new file mode 100644 index 00000000..2bb0df06 --- /dev/null +++ b/api/index.php @@ -0,0 +1,97 @@ + MAX_BODY_BYTES) { + Response::error('Request body too large', 413); + } + + $store = new Store(dirname(__DIR__) . '/data/projects'); + $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; + $route = trim((string) ($_GET['r'] ?? ''), '/'); + $parts = $route === '' ? [] : explode('/', $route); + + if (($parts[0] ?? '') !== 'projects' || count($parts) > 2) { + Response::error('Not found', 404); + } + + // Collection: /projects + if (count($parts) === 1) { + if ($method === 'GET') { + Response::json($store->list()); + } + if ($method === 'POST') { + $body = Response::body(); + $name = trim((string) ($body['name'] ?? '')); + if ($name === '' || mb_strlen($name) > 120) { + Response::error('Project name must be 1–120 characters', 422); + } + Response::json($store->create($name), 201); + } + Response::error('Method not allowed', 405); + } + + // Item: /projects/{id} + $id = $parts[1]; + if (!Store::isValidId($id)) { + Response::error('Invalid project id', 400); + } + + if ($method === 'GET') { + $doc = $store->load($id); + if ($doc === null) { + Response::error('Project not found', 404); + } + Response::json($doc); + } + + if ($method === 'PUT') { + $body = Response::body(); + if (!isset($body['revision']) || !is_int($body['revision']) || !is_array($body['project'] ?? null)) { + Response::error('Body must contain integer "revision" and object "project"', 422); + } + try { + $result = $store->save($id, $body['revision'], $body['project']); + } catch (StaleRevisionException $e) { + Response::error('Project was modified elsewhere', 409, ['revision' => $e->currentRevision]); + } + if ($result === null) { + Response::error('Project not found', 404); + } + Response::json(['revision' => $result['revision'], 'updatedAt' => $result['updatedAt']]); + } + + if ($method === 'DELETE') { + if (!$store->delete($id)) { + Response::error('Project not found', 404); + } + Response::noContent(); + } + + Response::error('Method not allowed', 405); +} catch (Throwable $e) { + error_log('[game-maker api] ' . $e->getMessage()); + Response::error('Internal server error', 500); +} diff --git a/api/lib/Response.php b/api/lib/Response.php new file mode 100644 index 00000000..a03fe844 --- /dev/null +++ b/api/lib/Response.php @@ -0,0 +1,41 @@ + $message] + $extra, $status); + } + + public static function noContent(): never + { + http_response_code(204); + header('Cache-Control: no-store'); + exit; + } + + /** Decoded JSON request body or an error response when it is not an object. */ + public static function body(): array + { + $raw = file_get_contents('php://input'); + if ($raw === false || $raw === '') { + self::error('Request body must be JSON', 400); + } + $data = json_decode($raw, true); + if (!is_array($data)) { + self::error('Request body must be a JSON object', 400); + } + return $data; + } +} diff --git a/api/lib/Store.php b/api/lib/Store.php new file mode 100644 index 00000000..53c1efd9 --- /dev/null +++ b/api/lib/Store.php @@ -0,0 +1,165 @@ +dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) { + throw new RuntimeException("Cannot create data directory {$this->dir}"); + } + } + + public static function isValidId(string $id): bool + { + return (bool) preg_match('/^[a-z0-9]{6,32}$/', $id); + } + + public static function newId(): string + { + return substr(bin2hex(random_bytes(8)), 0, 12); + } + + /** @return list */ + public function list(): array + { + $items = []; + foreach (glob($this->dir . '/*.json') ?: [] as $file) { + $doc = $this->readFile($file); + if ($doc === null) { + continue; + } + $items[] = [ + 'id' => $doc['id'], + 'name' => $doc['name'], + 'revision' => $doc['revision'], + 'updatedAt' => $doc['updatedAt'], + ]; + } + usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt'])); + return $items; + } + + /** @return array{id:string,name:string,revision:int,updatedAt:string,project:array}|null */ + public function load(string $id): ?array + { + return $this->readFile($this->path($id)); + } + + /** @return array{id:string,name:string,revision:int,updatedAt:string,project:array} */ + public function create(string $name): array + { + do { + $id = self::newId(); + } while (file_exists($this->path($id))); + + $project = [ + 'formatVersion' => 1, + 'id' => $id, + 'name' => $name, + 'assets' => [], + 'activeSceneId' => '', + ]; + $doc = $this->document($id, $name, 1, $project); + $this->writeFile($this->path($id), $doc); + return $doc; + } + + /** + * Saves a project when `$expectedRevision` matches the stored one. + * Returns the new document, null when the project does not exist, or throws + * StaleRevisionException (carrying the current revision) on a conflict. + */ + public function save(string $id, int $expectedRevision, array $project): ?array + { + $path = $this->path($id); + $lock = fopen($path . '.lock', 'c'); + if ($lock === false || !flock($lock, LOCK_EX)) { + throw new RuntimeException('Cannot acquire project lock'); + } + try { + $current = $this->readFile($path); + if ($current === null) { + return null; + } + if ($current['revision'] !== $expectedRevision) { + throw new StaleRevisionException($current['revision']); + } + $name = is_string($project['name'] ?? null) && $project['name'] !== '' ? $project['name'] : $current['name']; + $project['id'] = $id; + $doc = $this->document($id, $name, $expectedRevision + 1, $project); + $this->writeFile($path, $doc); + return $doc; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + @unlink($path . '.lock'); + } + } + + public function delete(string $id): bool + { + $path = $this->path($id); + return file_exists($path) && unlink($path); + } + + private function path(string $id): string + { + return $this->dir . '/' . $id . '.json'; + } + + private function document(string $id, string $name, int $revision, array $project): array + { + return [ + 'id' => $id, + 'name' => $name, + 'revision' => $revision, + 'updatedAt' => gmdate('c'), + 'project' => $project, + ]; + } + + private function readFile(string $path): ?array + { + if (!is_file($path)) { + return null; + } + $raw = file_get_contents($path); + $doc = $raw === false ? null : json_decode($raw, true); + if (!is_array($doc) || !isset($doc['id'], $doc['revision'], $doc['project'])) { + return null; + } + $doc['name'] = is_string($doc['name'] ?? null) ? $doc['name'] : 'Untitled'; + $doc['updatedAt'] = is_string($doc['updatedAt'] ?? null) ? $doc['updatedAt'] : ''; + $doc['revision'] = (int) $doc['revision']; + return $doc; + } + + private function writeFile(string $path, array $doc): void + { + $json = json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new RuntimeException('Cannot encode project'); + } + $tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; + if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) { + @unlink($tmp); + throw new RuntimeException('Cannot write project file'); + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..7a2aede8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1741 @@ +{ + "name": "game-maker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "game-maker", + "version": "0.1.0", + "dependencies": { + "pixi.js": "^8.6.0" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@pixi/colord": { + "version": "2.9.6", + "resolved": "https://registry.npmjs.org/@pixi/colord/-/colord-2.9.6.tgz", + "integrity": "sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/earcut": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-3.0.0.tgz", + "integrity": "sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.72", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz", + "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==", + "license": "BSD-3-Clause" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, + "node_modules/ismobilejs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ismobilejs/-/ismobilejs-1.1.1.tgz", + "integrity": "sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==", + "license": "MIT" + }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse-svg-path": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/parse-svg-path/-/parse-svg-path-0.2.0.tgz", + "integrity": "sha512-Tf7FFIrguPKQwzD4pWnYkR2VOv3raoHeKED80Bm+BYHI3KxC8KsgsGC5+fSMzAGDA6UEk4bHvmi+RsjmL3khpg==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pixi.js": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.20.1.tgz", + "integrity": "sha512-akLVBMLvQbaEViqsmVraK0Njer1q4Q4lm4iNKuzvBiFrV8q+6/+HluJN3sWIwO663IBeQtUUS/CaXFJZs6ffXQ==", + "license": "MIT", + "workspaces": [ + "examples", + "playground" + ], + "dependencies": { + "@pixi/colord": "^2.9.6", + "@types/earcut": "^3.0.0", + "@webgpu/types": "^0.1.69", + "@xmldom/xmldom": "^0.8.15", + "earcut": "^3.0.2", + "eventemitter3": "^5.0.1", + "gifuct-js": "^2.1.2", + "ismobilejs": "^1.1.1", + "parse-svg-path": "^0.2.0", + "tiny-lru": "^11.4.7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/pixijs" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tiny-lru": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..d25de080 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "game-maker", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "A browser-based 2D project builder: editor and game are one program, everything drawn in a WebGL canvas.", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.20.1", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + }, + "dependencies": { + "pixi.js": "^8.6.0" + } +} diff --git a/src/core/commands/Command.ts b/src/core/commands/Command.ts new file mode 100644 index 00000000..e4307c03 --- /dev/null +++ b/src/core/commands/Command.ts @@ -0,0 +1,19 @@ +import type { Project } from '../model/Project'; + +/** + * An undoable edit of the project. Commands mutate the project in place and remember + * whatever they need to revert. They must be self-validating: `execute` throws on an + * invalid operation (e.g. placing a scene into a scene) before touching anything. + */ +export interface Command { + readonly label: string; + execute(project: Project): void; + undo(project: Project): void; +} + +export class CommandError extends Error { + constructor(message: string) { + super(message); + this.name = 'CommandError'; + } +} diff --git a/src/core/commands/CommandStack.ts b/src/core/commands/CommandStack.ts new file mode 100644 index 00000000..4785368c --- /dev/null +++ b/src/core/commands/CommandStack.ts @@ -0,0 +1,56 @@ +import type { Project } from '../model/Project'; +import type { Command } from './Command'; + +export type CommandListener = (command: Command, action: 'execute' | 'undo' | 'redo') => void; + +/** Linear undo/redo history. Every project change in the editor goes through here. */ +export class CommandStack { + private readonly undoStack: Command[] = []; + private readonly redoStack: Command[] = []; + private readonly listeners = new Set(); + + constructor(private readonly project: Project, private readonly limit = 200) {} + + execute(command: Command): void { + command.execute(this.project); + this.undoStack.push(command); + if (this.undoStack.length > this.limit) this.undoStack.shift(); + this.redoStack.length = 0; + this.emit(command, 'execute'); + } + + undo(): boolean { + const command = this.undoStack.pop(); + if (!command) return false; + command.undo(this.project); + this.redoStack.push(command); + this.emit(command, 'undo'); + return true; + } + + redo(): boolean { + const command = this.redoStack.pop(); + if (!command) return false; + command.execute(this.project); + this.undoStack.push(command); + this.emit(command, 'redo'); + return true; + } + + get canUndo(): boolean { + return this.undoStack.length > 0; + } + + get canRedo(): boolean { + return this.redoStack.length > 0; + } + + subscribe(listener: CommandListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emit(command: Command, action: 'execute' | 'undo' | 'redo'): void { + for (const listener of this.listeners) listener(command, action); + } +} diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts new file mode 100644 index 00000000..667e4a49 --- /dev/null +++ b/src/core/commands/assetCommands.ts @@ -0,0 +1,152 @@ +import type { Asset } from '../model/Asset'; +import type { AssetType } from '../model/AssetTypes'; +import type { PropertyValue } from '../model/Properties'; +import { findAsset, findUsages, type Project } from '../model/Project'; +import type { SceneObject } from '../model/SceneObject'; +import { coerceValue, defaultsFor, schemaFor } from '../properties/PropertySchema'; +import { newId } from '../ids'; +import { type Command, CommandError } from './Command'; + +export function buildAsset(type: AssetType, name: string, id = newId()): Asset { + const base = { id, name, props: defaultsFor(type) }; + switch (type) { + case 'scene': + return { ...base, type: 'scene', objects: [] }; + case 'camera': + return { ...base, type: 'camera' }; + case 'sprite': + return { ...base, type: 'sprite' }; + } +} + +export class CreateAssetCommand implements Command { + readonly label: string; + readonly asset: Asset; + private previousActiveScene = ''; + + constructor(type: AssetType, name: string) { + this.asset = buildAsset(type, name); + this.label = `Create ${type} "${name}"`; + } + + execute(project: Project): void { + project.assets.push(this.asset); + // The first scene of a project becomes active automatically. + this.previousActiveScene = project.activeSceneId; + if (this.asset.type === 'scene' && !project.activeSceneId) project.activeSceneId = this.asset.id; + } + + undo(project: Project): void { + project.assets = project.assets.filter((a) => a.id !== this.asset.id); + project.activeSceneId = this.previousActiveScene; + } +} + +/** Deletes an asset and, as a cascade, every scene object referencing it. */ +export class DeleteAssetCommand implements Command { + readonly label: string; + private removed?: { asset: Asset; index: number }; + private removedUsages: { sceneId: string; object: SceneObject; index: number }[] = []; + private previousActiveScene = ''; + + constructor(private readonly assetId: string) { + this.label = 'Delete asset'; + } + + execute(project: Project): void { + const index = project.assets.findIndex((a) => a.id === this.assetId); + const asset = project.assets[index]; + if (!asset) throw new CommandError(`Asset ${this.assetId} not found`); + this.removedUsages = findUsages(project, this.assetId).map(({ scene, object }) => ({ + sceneId: scene.id, + object, + index: scene.objects.indexOf(object), + })); + for (const usage of this.removedUsages) { + const scene = findAsset(project, usage.sceneId); + if (scene && scene.type === 'scene') scene.objects = scene.objects.filter((o) => o.id !== usage.object.id); + } + this.removed = { asset, index }; + project.assets.splice(index, 1); + this.previousActiveScene = project.activeSceneId; + if (project.activeSceneId === this.assetId) { + const next = project.assets.find((a) => a.type === 'scene'); + project.activeSceneId = next ? next.id : ''; + } + } + + undo(project: Project): void { + if (!this.removed) return; + project.assets.splice(this.removed.index, 0, this.removed.asset); + // Restore usages in ascending index order so the original positions come back. + for (const usage of [...this.removedUsages].sort((a, b) => a.index - b.index)) { + const scene = findAsset(project, usage.sceneId); + if (scene && scene.type === 'scene') scene.objects.splice(usage.index, 0, usage.object); + } + project.activeSceneId = this.previousActiveScene; + } +} + +export class RenameAssetCommand implements Command { + readonly label = 'Rename asset'; + private previousName = ''; + + constructor(private readonly assetId: string, private readonly name: string) {} + + execute(project: Project): void { + const asset = requireAsset(project, this.assetId); + this.previousName = asset.name; + asset.name = this.name; + } + + undo(project: Project): void { + requireAsset(project, this.assetId).name = this.previousName; + } +} + +export class SetAssetPropertyCommand implements Command { + readonly label: string; + private previous: PropertyValue | undefined; + + constructor(private readonly assetId: string, private readonly key: string, private readonly value: PropertyValue) { + this.label = `Set ${key}`; + } + + execute(project: Project): void { + const asset = requireAsset(project, this.assetId); + const def = schemaFor(asset.type).find((d) => d.key === this.key); + if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`); + this.previous = asset.props[this.key]; + asset.props[this.key] = coerceValue(def, this.value); + } + + undo(project: Project): void { + const asset = requireAsset(project, this.assetId); + if (this.previous === undefined) delete asset.props[this.key]; + else asset.props[this.key] = this.previous; + } +} + +export class SetActiveSceneCommand implements Command { + readonly label = 'Switch scene'; + private previous = ''; + + constructor(private readonly sceneId: string) {} + + execute(project: Project): void { + const asset = requireAsset(project, this.sceneId); + if (asset.type !== 'scene') throw new CommandError(`${asset.name} is not a scene`); + this.previous = project.activeSceneId; + project.activeSceneId = this.sceneId; + } + + undo(project: Project): void { + project.activeSceneId = this.previous; + } +} + +export function requireAsset(project: Project, assetId: string): Asset { + const asset = findAsset(project, assetId); + if (!asset) throw new CommandError(`Asset ${assetId} not found`); + return asset; +} diff --git a/src/core/commands/projectCommands.ts b/src/core/commands/projectCommands.ts new file mode 100644 index 00000000..e1d042da --- /dev/null +++ b/src/core/commands/projectCommands.ts @@ -0,0 +1,28 @@ +import type { Project } from '../model/Project'; +import type { Command } from './Command'; + +export class RenameProjectCommand implements Command { + readonly label = 'Rename project'; + private previous = ''; + + constructor(private readonly name: string) {} + + execute(project: Project): void { + this.previous = project.name; + project.name = this.name; + } + + undo(project: Project): void { + project.name = this.previous; + } +} + +/** "Sprite", "Sprite 2", "Sprite 3" … – the first name not yet used by an asset. */ +export function nextAssetName(project: Project, base: string): string { + const taken = new Set(project.assets.map((a) => a.name)); + if (!taken.has(base)) return base; + for (let i = 2; ; i++) { + const candidate = `${base} ${i}`; + if (!taken.has(candidate)) return candidate; + } +} diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts new file mode 100644 index 00000000..032b620d --- /dev/null +++ b/src/core/commands/sceneObjectCommands.ts @@ -0,0 +1,153 @@ +import { isPlaceableType } from '../model/AssetTypes'; +import type { PropertyValue, Vec2 } from '../model/Properties'; +import { findScene, findSceneObject, type Project } from '../model/Project'; +import { createSceneObject, type SceneObject } from '../model/SceneObject'; +import { coerceValue, schemaFor } from '../properties/PropertySchema'; +import { newId } from '../ids'; +import { type Command, CommandError } from './Command'; +import { requireAsset } from './assetCommands'; + +function requireScene(project: Project, sceneId: string) { + const scene = findScene(project, sceneId); + if (!scene) throw new CommandError(`Scene ${sceneId} not found`); + return scene; +} + +function requireObject(project: Project, sceneId: string, objectId: string): SceneObject { + const object = findSceneObject(requireScene(project, sceneId), objectId); + if (!object) throw new CommandError(`Scene object ${objectId} not found`); + return object; +} + +/** Places an asset into a scene, creating a scene object. Scenes cannot be placed. */ +export class PlaceAssetCommand implements Command { + readonly label = 'Place asset'; + readonly objectId = newId(); + + constructor(private readonly sceneId: string, private readonly assetId: string, private readonly position: Vec2) {} + + execute(project: Project): void { + const scene = requireScene(project, this.sceneId); + const asset = requireAsset(project, this.assetId); + if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene'); + scene.objects.push(createSceneObject(this.objectId, this.assetId, this.position)); + } + + undo(project: Project): void { + const scene = requireScene(project, this.sceneId); + scene.objects = scene.objects.filter((o) => o.id !== this.objectId); + } +} + +export class RemoveSceneObjectCommand implements Command { + readonly label = 'Remove object'; + private removed?: { object: SceneObject; index: number }; + + constructor(private readonly sceneId: string, private readonly objectId: string) {} + + execute(project: Project): void { + const scene = requireScene(project, this.sceneId); + const index = scene.objects.findIndex((o) => o.id === this.objectId); + const object = scene.objects[index]; + if (!object) throw new CommandError(`Scene object ${this.objectId} not found`); + this.removed = { object, index }; + scene.objects.splice(index, 1); + } + + undo(project: Project): void { + if (!this.removed) return; + requireScene(project, this.sceneId).objects.splice(this.removed.index, 0, this.removed.object); + } +} + +export class MoveSceneObjectCommand implements Command { + readonly label = 'Move object'; + private previous: Vec2 = { x: 0, y: 0 }; + + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly position: Vec2, + previous?: Vec2, + ) { + if (previous) this.previous = { ...previous }; + } + + execute(project: Project): void { + const object = requireObject(project, this.sceneId, this.objectId); + this.previous = { ...object.position }; + object.position = { x: this.position.x, y: this.position.y }; + } + + undo(project: Project): void { + requireObject(project, this.sceneId, this.objectId).position = { ...this.previous }; + } +} + +export class RenameSceneObjectCommand implements Command { + readonly label = 'Rename object'; + private previous = ''; + + constructor(private readonly sceneId: string, private readonly objectId: string, private readonly name: string) {} + + execute(project: Project): void { + const object = requireObject(project, this.sceneId, this.objectId); + this.previous = object.name; + object.name = this.name; + } + + undo(project: Project): void { + requireObject(project, this.sceneId, this.objectId).name = this.previous; + } +} + +/** Sets a per-object override of an asset property. */ +export class SetOverrideCommand implements Command { + readonly label: string; + private previous: PropertyValue | undefined; + + constructor( + private readonly sceneId: string, + private readonly objectId: string, + private readonly key: string, + private readonly value: PropertyValue, + ) { + this.label = `Override ${key}`; + } + + execute(project: Project): void { + const object = requireObject(project, this.sceneId, this.objectId); + const asset = requireAsset(project, object.assetId); + const def = schemaFor(asset.type).find((d) => d.key === this.key); + if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`); + this.previous = object.overrides[this.key]; + object.overrides[this.key] = coerceValue(def, this.value); + } + + undo(project: Project): void { + const object = requireObject(project, this.sceneId, this.objectId); + if (this.previous === undefined) delete object.overrides[this.key]; + else object.overrides[this.key] = this.previous; + } +} + +/** Removes an override so the object follows its asset again. */ +export class ClearOverrideCommand implements Command { + readonly label: string; + private previous: PropertyValue | undefined; + + constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) { + this.label = `Reset ${key}`; + } + + execute(project: Project): void { + const object = requireObject(project, this.sceneId, this.objectId); + this.previous = object.overrides[this.key]; + delete object.overrides[this.key]; + } + + undo(project: Project): void { + if (this.previous === undefined) return; + requireObject(project, this.sceneId, this.objectId).overrides[this.key] = this.previous; + } +} diff --git a/src/core/ids.ts b/src/core/ids.ts new file mode 100644 index 00000000..1b0d951f --- /dev/null +++ b/src/core/ids.ts @@ -0,0 +1,8 @@ +/** Short, URL-safe random ids (10 chars, ~60 bits). Works in browsers and Node. */ +export function newId(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + let id = ''; + for (const b of bytes) id += b.toString(36).padStart(2, '0'); + return id.slice(0, 10); +} diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts new file mode 100644 index 00000000..ee0b2927 --- /dev/null +++ b/src/core/model/Asset.ts @@ -0,0 +1,31 @@ +import type { AssetType } from './AssetTypes'; +import type { PropertyBag } from './Properties'; +import type { SceneObject } from './SceneObject'; + +/** Every asset has an id, a name, a type and a bag of typed properties (see PropertySchema). */ +export interface AssetBase { + id: string; + type: AssetType; + name: string; + props: PropertyBag; +} + +export interface SpriteAsset extends AssetBase { + type: 'sprite'; +} + +export interface CameraAsset extends AssetBase { + type: 'camera'; +} + +/** A scene is an asset that additionally owns the objects placed into it. */ +export interface SceneAsset extends AssetBase { + type: 'scene'; + objects: SceneObject[]; +} + +export type Asset = SpriteAsset | CameraAsset | SceneAsset; + +export function isSceneAsset(asset: Asset): asset is SceneAsset { + return asset.type === 'scene'; +} diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts new file mode 100644 index 00000000..076bf03d --- /dev/null +++ b/src/core/model/AssetTypes.ts @@ -0,0 +1,15 @@ +/** All asset types known in phase 1. Scenes are assets too, but cannot be placed in scenes. */ +export const ASSET_TYPES = ['scene', 'camera', 'sprite'] as const; + +export type AssetType = (typeof ASSET_TYPES)[number]; + +/** Types that can be placed into a scene (everything except scenes themselves). */ +export type PlaceableAssetType = Exclude; + +export function isPlaceableType(type: AssetType): type is PlaceableAssetType { + return type !== 'scene'; +} + +export function isAssetType(value: unknown): value is AssetType { + return typeof value === 'string' && (ASSET_TYPES as readonly string[]).includes(value); +} diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts new file mode 100644 index 00000000..c66c2df8 --- /dev/null +++ b/src/core/model/Project.ts @@ -0,0 +1,47 @@ +import type { Asset, SceneAsset } from './Asset'; +import { isSceneAsset } from './Asset'; +import type { SceneObject } from './SceneObject'; + +/** Bumped whenever the persisted shape changes; the loader migrates older documents. */ +export const PROJECT_FORMAT_VERSION = 1; + +export interface Project { + formatVersion: number; + id: string; + name: string; + assets: Asset[]; + /** Scene shown in the viewport and instantiated on Play. Empty string = none. */ + activeSceneId: string; +} + +export function createEmptyProject(id: string, name: string): Project { + return { formatVersion: PROJECT_FORMAT_VERSION, id, name, assets: [], activeSceneId: '' }; +} + +export function findAsset(project: Project, assetId: string): Asset | undefined { + return project.assets.find((a) => a.id === assetId); +} + +export function findScene(project: Project, sceneId: string): SceneAsset | undefined { + const asset = findAsset(project, sceneId); + return asset && isSceneAsset(asset) ? asset : undefined; +} + +export function listScenes(project: Project): SceneAsset[] { + return project.assets.filter(isSceneAsset); +} + +export function findSceneObject(scene: SceneAsset, objectId: string): SceneObject | undefined { + return scene.objects.find((o) => o.id === objectId); +} + +/** Every scene object (across all scenes) that references the given asset. */ +export function findUsages(project: Project, assetId: string): { scene: SceneAsset; object: SceneObject }[] { + const usages: { scene: SceneAsset; object: SceneObject }[] = []; + for (const scene of listScenes(project)) { + for (const object of scene.objects) { + if (object.assetId === assetId) usages.push({ scene, object }); + } + } + return usages; +} diff --git a/src/core/model/Properties.ts b/src/core/model/Properties.ts new file mode 100644 index 00000000..23d976c1 --- /dev/null +++ b/src/core/model/Properties.ts @@ -0,0 +1,10 @@ +/** A single property value. Colors are stored as 0xRRGGBB numbers. */ +export type PropertyValue = number | string | boolean; + +/** Sparse map of property key → value (asset properties or scene-object overrides). */ +export type PropertyBag = Record; + +export interface Vec2 { + x: number; + y: number; +} diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts new file mode 100644 index 00000000..37951d04 --- /dev/null +++ b/src/core/model/SceneObject.ts @@ -0,0 +1,19 @@ +import type { PropertyBag, Vec2 } from './Properties'; + +/** + * An asset placed inside a scene. It links to the asset by id, owns a position + * (assets have none) and may override individual asset properties. Overrides are + * sparse: only keys the user changed are stored; everything else follows the asset. + */ +export interface SceneObject { + id: string; + assetId: string; + /** Optional display name; falls back to the asset name when empty. */ + name: string; + position: Vec2; + overrides: PropertyBag; +} + +export function createSceneObject(id: string, assetId: string, position: Vec2): SceneObject { + return { id, assetId, name: '', position: { x: position.x, y: position.y }, overrides: {} }; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts new file mode 100644 index 00000000..b87da5b1 --- /dev/null +++ b/src/core/properties/PropertySchema.ts @@ -0,0 +1,64 @@ +import type { AssetType } from '../model/AssetTypes'; +import type { PropertyBag, PropertyValue } from '../model/Properties'; + +/** + * Declarative description of one editable property. The inspector is generated from + * these entries, so adding a property to a type is a single line in a schema below. + */ +export type PropertyDef = + | { key: string; label: string; kind: 'number'; default: number; min?: number; max?: number; step?: number } + | { key: string; label: string; kind: 'text'; default: string } + | { key: string; label: string; kind: 'color'; default: number } + | { key: string; label: string; kind: 'boolean'; default: boolean }; + +export type PropertyKind = PropertyDef['kind']; + +export type PropertySchema = readonly PropertyDef[]; + +/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */ +export const ASSET_SCHEMAS: Record = { + scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }], + camera: [{ key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 }], + sprite: [ + { key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 }, + { key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 }, + { key: 'color', label: 'Color', kind: 'color', default: 0xe0553f }, + ], +}; + +/** Properties every scene object has regardless of its asset type. */ +export const SCENE_OBJECT_SCHEMA: PropertySchema = [ + { key: 'x', label: 'X', kind: 'number', default: 0, step: 1 }, + { key: 'y', label: 'Y', kind: 'number', default: 0, step: 1 }, +]; + +export function schemaFor(type: AssetType): PropertySchema { + return ASSET_SCHEMAS[type]; +} + +export function defaultsFor(type: AssetType): PropertyBag { + const bag: PropertyBag = {}; + for (const def of ASSET_SCHEMAS[type]) bag[def.key] = def.default; + return bag; +} + +/** Clamp / coerce a raw value so it satisfies the definition. Unknown kinds are returned as-is. */ +export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue { + switch (def.kind) { + case 'number': { + let n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n)) n = def.default; + if (def.min !== undefined) n = Math.max(def.min, n); + if (def.max !== undefined) n = Math.min(def.max, n); + return n; + } + case 'color': { + const n = typeof value === 'number' ? value : parseInt(String(value).replace('#', ''), 16); + return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default; + } + case 'boolean': + return Boolean(value); + case 'text': + return String(value); + } +} diff --git a/src/core/properties/resolve.ts b/src/core/properties/resolve.ts new file mode 100644 index 00000000..76de0377 --- /dev/null +++ b/src/core/properties/resolve.ts @@ -0,0 +1,39 @@ +import type { Asset } from '../model/Asset'; +import type { PropertyBag, PropertyValue } from '../model/Properties'; +import type { SceneObject } from '../model/SceneObject'; +import { schemaFor } from './PropertySchema'; + +/** + * Effective property values of an asset: schema defaults filled with the asset's own values. + * Keys unknown to the schema are ignored, so stale data never leaks into the editor. + */ +export function resolveAssetProps(asset: Asset): PropertyBag { + const bag: PropertyBag = {}; + for (const def of schemaFor(asset.type)) { + const own = asset.props[def.key]; + bag[def.key] = own !== undefined ? own : def.default; + } + return bag; +} + +/** + * Effective property values of a scene object: asset values with the object's sparse + * overrides applied on top. This is the ONLY place where overrides are merged. + */ +export function resolveSceneObjectProps(asset: Asset, object: SceneObject): PropertyBag { + const bag = resolveAssetProps(asset); + for (const def of schemaFor(asset.type)) { + const override = object.overrides[def.key]; + if (override !== undefined) bag[def.key] = override; + } + return bag; +} + +export function isOverridden(object: SceneObject, key: string): boolean { + return object.overrides[key] !== undefined; +} + +/** Value the object would have without its override for `key` (the asset's value). */ +export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined { + return resolveAssetProps(asset)[key]; +} diff --git a/src/core/store/HttpProjectStore.ts b/src/core/store/HttpProjectStore.ts new file mode 100644 index 00000000..23b7dee3 --- /dev/null +++ b/src/core/store/HttpProjectStore.ts @@ -0,0 +1,64 @@ +import type { Project } from '../model/Project'; +import { type LoadedProject, type ProjectStore, type ProjectSummary, StaleRevisionError } from './ProjectStore'; +import { deserializeProject, serializeProject } from './serialize'; + +/** + * Talks to the PHP backend in `api/index.php`. Routing goes through the `r` query + * parameter (`?r=projects/{id}`) because the site's nginx config rejects PATH_INFO. + */ +export class HttpProjectStore implements ProjectStore { + constructor(private readonly endpoint: string) {} + + async list(): Promise { + return this.request('GET', 'projects'); + } + + async load(id: string): Promise { + const body = await this.request<{ revision: number; project: unknown }>('GET', `projects/${encodeURIComponent(id)}`); + return { project: deserializeProject(body.project, id), revision: body.revision }; + } + + async save(project: Project, revision: number): Promise { + const body = await this.request<{ revision: number }>('PUT', `projects/${encodeURIComponent(project.id)}`, { + revision, + project: serializeProject(project), + }); + return body.revision; + } + + async create(name: string): Promise { + const body = await this.request<{ revision: number; project: unknown }>('POST', 'projects', { name }); + const project = deserializeProject(body.project); + return { project, revision: body.revision }; + } + + async delete(id: string): Promise { + await this.request('DELETE', `projects/${encodeURIComponent(id)}`); + } + + private async request(method: string, route: string, payload?: unknown): Promise { + const url = `${this.endpoint}?r=${encodeURIComponent(route)}`; + const response = await fetch(url, { + method, + headers: payload !== undefined ? { 'Content-Type': 'application/json' } : {}, + body: payload !== undefined ? JSON.stringify(payload) : undefined, + }); + if (response.status === 204) return undefined as T; + const text = await response.text(); + let data: unknown = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`); + } + if (response.status === 409) { + const rev = (data as { revision?: number } | null)?.revision ?? -1; + throw new StaleRevisionError(rev); + } + if (!response.ok) { + const message = (data as { error?: string } | null)?.error ?? response.statusText; + throw new Error(`API ${method} ${route}: ${message} (${response.status})`); + } + return data as T; + } +} diff --git a/src/core/store/ProjectStore.ts b/src/core/store/ProjectStore.ts new file mode 100644 index 00000000..5fbcc8e2 --- /dev/null +++ b/src/core/store/ProjectStore.ts @@ -0,0 +1,31 @@ +import type { Project } from '../model/Project'; + +export interface ProjectSummary { + id: string; + name: string; + revision: number; + updatedAt: string; +} + +export interface LoadedProject { + project: Project; + revision: number; +} + +/** Thrown by `save` when the server holds a newer revision (edited elsewhere). */ +export class StaleRevisionError extends Error { + constructor(readonly serverRevision: number) { + super(`Project was modified elsewhere (server revision ${serverRevision})`); + this.name = 'StaleRevisionError'; + } +} + +/** Persistence boundary. The editor only talks to this interface. */ +export interface ProjectStore { + list(): Promise; + load(id: string): Promise; + /** Saves the project and returns the new revision. Rejects with StaleRevisionError on conflict. */ + save(project: Project, revision: number): Promise; + create(name: string): Promise; + delete(id: string): Promise; +} diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts new file mode 100644 index 00000000..834a1764 --- /dev/null +++ b/src/core/store/serialize.ts @@ -0,0 +1,98 @@ +import type { Asset } from '../model/Asset'; +import { type AssetType, isAssetType } from '../model/AssetTypes'; +import type { PropertyBag, PropertyValue } from '../model/Properties'; +import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; +import type { SceneObject } from '../model/SceneObject'; +import { coerceValue, schemaFor } from '../properties/PropertySchema'; + +/** + * Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are + * dropped, missing ones get defaults, values are coerced against the schema, dangling + * scene objects are removed. Future format migrations live here as well. + */ +export function deserializeProject(raw: unknown, fallbackId = ''): Project { + const r = asRecord(raw); + const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord); + + // Pass 1: asset ids and types, needed to coerce scene-object overrides in pass 2. + const types = new Map(); + for (const a of rawAssets) { + if (typeof a.id === 'string' && a.id && isAssetType(a.type)) types.set(a.id, a.type); + } + + // Pass 2: build assets. + const assets: Asset[] = []; + for (const a of rawAssets) { + const id = typeof a.id === 'string' ? a.id : ''; + const type = types.get(id); + if (!type) continue; + const name = typeof a.name === 'string' ? a.name : type; + const props = normalizeBag(a.props, type, false); + if (type === 'scene') { + assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, types) }); + } else { + assets.push({ id, type, name, props }); + } + } + + let activeSceneId = typeof r.activeSceneId === 'string' ? r.activeSceneId : ''; + if (!assets.some((a) => a.type === 'scene' && a.id === activeSceneId)) { + const first = assets.find((a) => a.type === 'scene'); + activeSceneId = first ? first.id : ''; + } + + return { + formatVersion: PROJECT_FORMAT_VERSION, + id: typeof r.id === 'string' && r.id ? r.id : fallbackId, + name: typeof r.name === 'string' ? r.name : 'Untitled', + assets, + activeSceneId, + }; +} + +/** Plain JSON-ready copy of the project (deep clone, stable key order for readable diffs). */ +export function serializeProject(project: Project): unknown { + return JSON.parse(JSON.stringify(project)); +} + +function normalizeObjects(raw: unknown, types: Map): SceneObject[] { + const objects: SceneObject[] = []; + for (const item of Array.isArray(raw) ? raw : []) { + const o = asRecord(item); + if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue; + const type = types.get(o.assetId); + if (!type || type === 'scene') continue; // dangling reference or illegal scene-in-scene + const p = asRecord(o.position); + objects.push({ + id: o.id, + assetId: o.assetId, + name: typeof o.name === 'string' ? o.name : '', + position: { x: toNumber(p.x), y: toNumber(p.y) }, + overrides: normalizeBag(o.overrides, type, true), + }); + } + return objects; +} + +function normalizeBag(raw: unknown, type: AssetType, sparse: boolean): PropertyBag { + const r = asRecord(raw); + const bag: PropertyBag = {}; + for (const def of schemaFor(type)) { + const value = r[def.key]; + if (value === undefined || value === null) { + if (!sparse) bag[def.key] = def.default; + continue; + } + bag[def.key] = coerceValue(def, value as PropertyValue); + } + return bag; +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' ? (value as Record) : {}; +} + +function toNumber(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} diff --git a/src/editor/App.ts b/src/editor/App.ts new file mode 100644 index 00000000..fb3c0729 --- /dev/null +++ b/src/editor/App.ts @@ -0,0 +1,262 @@ +import { type Application, Container, Text } from 'pixi.js'; +import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands'; +import { RemoveSceneObjectCommand } from '../core/commands/sceneObjectCommands'; +import { listScenes } from '../core/model/Project'; +import { HttpProjectStore } from '../core/store/HttpProjectStore'; +import type { ProjectStore } from '../core/store/ProjectStore'; +import { CameraFollowSystem } from '../runtime/systems/CameraFollowSystem'; +import { PlayerControlSystem } from '../runtime/systems/PlayerControlSystem'; +import { World } from '../runtime/World'; +import { Dialog, HStack, Menu, type MenuItem, Splitter, ui, VStack, type Widget } from '../ui'; +import { Viewport } from '../viewport/Viewport'; +import { attachAutosave } from './autosave'; +import { AssetPanel } from './panels/AssetPanel'; +import { HierarchyPanel } from './panels/HierarchyPanel'; +import { Inspector } from './panels/Inspector'; +import { Toolbar } from './panels/Toolbar'; +import { EditorState } from './state'; + +const API_ENDPOINT = '/game-maker/api/index.php'; + +/** Composes panels, viewport and runtime into the one application that edits and plays. */ +export async function startEditor(app: Application, root: VStack): Promise { + const state = new EditorState(); + const store: ProjectStore = new HttpProjectStore(API_ENDPOINT); + const editor = new Editor(app, root, state, store); + installDebugHook(app, state, editor.viewport); + await editor.boot(); +} + +/** + * `window.__gm` – used by tools/e2e.mjs to find widgets by their visible text and to + * inspect state. Harmless in production (read-only access). + */ +function installDebugHook(app: Application, state: EditorState, viewport: Viewport): void { + const viewportCenter = () => { + const p = viewport.getGlobalPosition(); + return { x: p.x + viewport.w / 2, y: p.y + viewport.h / 2 }; + }; + const locate = (text: string): { x: number; y: number } | null => { + let found: Text | null = null; + const walk = (node: Container): void => { + if (found || !node.visible) return; + if (node instanceof Text && node.text === text) { + found = node; + return; + } + for (const child of node.children) walk(child); + }; + walk(app.stage); + if (!found) return null; + const node = found as Text; + const p = node.getGlobalPosition(); + return { x: p.x + node.width / 2, y: p.y + node.height / 2 }; + }; + (window as unknown as { __gm: unknown }).__gm = { state, locate, viewportCenter }; +} + +class Editor { + readonly viewport: Viewport; + private readonly leftColumn: VStack; + private readonly inspector: Inspector; + private readonly leftSplitter = new Splitter('x'); + private readonly rightSplitter = new Splitter('x'); + private readonly workspace: HStack; + private world: World | null = null; + private leftWidth = 240; + private rightWidth = 300; + + constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) { + const toolbar = new Toolbar(state, { + openProjectMenu: (anchor) => this.openProjectMenu(anchor), + openSceneMenu: (anchor) => this.openSceneMenu(anchor), + togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()), + }); + root.add(toolbar); + + this.workspace = new HStack(); + this.viewport = new Viewport(state); + this.leftColumn = new VStack({ gap: 1 }); + this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1); + this.leftColumn.add(new HierarchyPanel(state), 1); + this.inspector = new Inspector(state); + + this.leftColumn.minWidth = this.leftWidth; + this.inspector.minWidth = this.rightWidth; + this.leftSplitter.onDrag = (delta) => { + this.leftWidth = Math.max(160, Math.min(500, this.leftWidth + delta)); + this.leftColumn.minWidth = this.leftWidth; + this.workspace.relayout(); + }; + this.rightSplitter.onDrag = (delta) => { + this.rightWidth = Math.max(200, Math.min(600, this.rightWidth - delta)); + this.inspector.minWidth = this.rightWidth; + this.workspace.relayout(); + }; + this.workspace.add(this.leftColumn); + this.workspace.add(this.leftSplitter); + this.workspace.add(this.viewport, 1); + this.workspace.add(this.rightSplitter); + this.workspace.add(this.inspector); + root.add(this.workspace, 1); + root.resize(app.screen.width, app.screen.height); + + attachAutosave(state, store); + this.installShortcuts(); + app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000)); + state.on('mode', () => this.applyMode()); + } + + // --- project lifecycle --------------------------------------------------------------- + + async boot(): Promise { + const wanted = new URLSearchParams(location.search).get('project'); + try { + if (wanted) { + await this.open(wanted); + return; + } + const list = await this.store.list(); + if (list[0]) await this.open(list[0].id); + else await this.createProject('My first project'); + } catch (error) { + this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private async open(id: string): Promise { + const loaded = await this.store.load(id); + this.state.loadProject(loaded.project, loaded.revision); + const url = new URL(location.href); + url.searchParams.set('project', id); + history.replaceState(null, '', url); + } + + private async createProject(name: string): Promise { + const created = await this.store.create(name); + await this.open(created.project.id); + } + + private openProjectMenu(anchor: Widget): void { + const g = anchor.getGlobalPosition(); + const open = async () => { + const list = await this.store.list(); + const items: MenuItem[] = [ + { label: 'New project', onSelect: () => void this.createProject(`Project ${list.length + 1}`) }, + ...list.map((p) => ({ + label: p.id === this.state.project.id ? `● ${p.name}` : p.name, + onSelect: () => void this.open(p.id), + disabled: p.id === this.state.project.id, + })), + { + label: 'Delete this project…', + danger: true, + onSelect: () => + new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [ + { label: 'Cancel' }, + { label: 'Delete', danger: true, onClick: () => void this.deleteCurrentProject() }, + ]).openCentered(), + }, + ]; + new Menu(items).open(g.x, g.y + anchor.h + 2); + }; + void open().catch((error) => this.state.setNotice(String(error))); + } + + private async deleteCurrentProject(): Promise { + await this.store.delete(this.state.project.id); + const list = await this.store.list(); + if (list[0]) await this.open(list[0].id); + else await this.createProject('My first project'); + } + + private openSceneMenu(anchor: Widget): void { + const scenes = listScenes(this.state.project); + const g = anchor.getGlobalPosition(); + const items: MenuItem[] = scenes.map((scene) => ({ + label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name, + onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)), + })); + if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true }); + new Menu(items).open(g.x, g.y + anchor.h + 2); + } + + // --- play mode ------------------------------------------------------------------------- + + private play(): void { + const scene = this.state.activeScene; + if (!scene) return; + ui().focus.blur(); + const selected = this.state.selection?.kind === 'object' ? this.state.selection.objectId : null; + const control = new PlayerControlSystem(selected); + this.world = World.instantiate(this.state.project, scene.id) + .addSystem(control) + .addSystem(new CameraFollowSystem(() => control.controlled)); + this.viewport.setWorld(this.world); + this.state.setMode('play'); + this.state.setNotice('Arrow keys move the sprite · Esc stops'); + } + + private stop(): void { + this.world?.destroy(); + this.world = null; + this.viewport.setWorld(null); + this.state.setMode('edit'); + this.state.setNotice(''); + } + + private tick(dt: number): void { + if (!this.world) return; + this.world.tick(Math.min(dt, 0.1)); + this.viewport.redraw(); + } + + private applyMode(): void { + const editing = this.state.mode === 'edit'; + this.leftColumn.visible = editing; + this.inspector.visible = editing; + this.leftSplitter.visible = editing; + this.rightSplitter.visible = editing; + this.workspace.relayout(); + ui().focus.validate(); + } + + // --- keyboard -------------------------------------------------------------------------- + + private installShortcuts(): void { + ui().focus.globalHandlers.push((event) => { + const mod = event.ctrlKey || event.metaKey; + if (event.key === 'Escape' && this.state.mode === 'play') { + this.stop(); + return true; + } + if (this.state.mode !== 'edit') return false; + if (mod && event.key.toLowerCase() === 'z') { + if (event.shiftKey) this.state.redo(); + else this.state.undo(); + return true; + } + if (mod && event.key.toLowerCase() === 'y') { + this.state.redo(); + return true; + } + if (event.key === 'Delete' || event.key === 'Backspace') { + const s = this.state.selection; + if (s?.kind === 'object') this.state.execute(new RemoveSceneObjectCommand(s.sceneId, s.objectId)); + else if (s?.kind === 'asset') { + const asset = this.state.project.assets.find((a) => a.id === s.assetId); + if (asset) new Dialog(`Delete "${asset.name}"?`, 'Placed instances are removed as well.', [ + { label: 'Cancel' }, + { label: 'Delete', danger: true, onClick: () => this.state.execute(new DeleteAssetCommand(s.assetId)) }, + ]).openCentered(); + } + return s !== null; + } + if (mod && event.key === 'Enter') { + this.play(); + return true; + } + return false; + }); + } +} diff --git a/src/editor/autosave.ts b/src/editor/autosave.ts new file mode 100644 index 00000000..6b13c6ed --- /dev/null +++ b/src/editor/autosave.ts @@ -0,0 +1,66 @@ +import { type ProjectStore, StaleRevisionError } from '../core/store/ProjectStore'; +import type { EditorState } from './state'; + +/** + * Debounced autosave: every model change schedules a save ~1 s later. Saves never run + * during Play (the model does not change then anyway) and never overlap; a change that + * arrives while a save is in flight triggers another save afterwards. + */ +export function attachAutosave(state: EditorState, store: ProjectStore, delayMs = 1000): { flush: () => Promise } { + let timer = 0; + let saving: Promise | null = null; + let pending = false; + + const save = async (): Promise => { + if (saving) { + pending = true; + return saving; + } + if (state.saveStatus !== 'dirty' || !state.project.id) return; + state.setSaveStatus('saving'); + saving = (async () => { + try { + state.revision = await store.save(state.project, state.revision); + state.setSaveStatus(pending ? 'dirty' : 'saved'); + } catch (error) { + if (error instanceof StaleRevisionError) { + state.setSaveStatus('conflict'); + state.setNotice('This project was changed elsewhere. Reload to continue.'); + } else { + state.setSaveStatus('error'); + state.setNotice(`Saving failed: ${error instanceof Error ? error.message : String(error)}`); + } + } finally { + saving = null; + if (pending) { + pending = false; + if (state.saveStatus === 'dirty') schedule(); + } + } + })(); + return saving; + }; + + const schedule = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void save(), delayMs); + }; + + state.on('save', () => { + if (state.saveStatus === 'dirty') schedule(); + }); + + const flush = async (): Promise => { + window.clearTimeout(timer); + await save(); + }; + + window.addEventListener('beforeunload', (event) => { + if (state.saveStatus === 'dirty' || state.saveStatus === 'saving') { + void flush(); + event.preventDefault(); + } + }); + + return { flush }; +} diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts new file mode 100644 index 00000000..0bbfadd7 --- /dev/null +++ b/src/editor/inspector/fieldFactory.ts @@ -0,0 +1,87 @@ +import type { PropertyValue } from '../../core/model/Properties'; +import type { PropertyDef } from '../../core/properties/PropertySchema'; +import { Button, Checkbox, ColorField, HStack, Label, NumberField, TextField, Theme, type Widget } from '../../ui'; + +export interface FieldHandlers { + /** Live value while dragging/typing – apply as a preview, no undo entry. */ + onInput: (value: PropertyValue) => void; + /** Final value – commit as a command. */ + onChange: (value: PropertyValue) => void; +} + +export interface FieldRow { + row: Widget; + /** Push a model value into the widget (no events fired). */ + setValue(value: PropertyValue): void; + /** Highlight the row as overridden (scene objects) and show the reset button. */ + setOverridden(overridden: boolean): void; +} + +/** + * Maps one PropertySchema entry to a labelled row containing the matching widget. + * The inspector is entirely generated through this function: adding a new property + * kind means adding a case here, adding a property means adding a schema line. + */ +export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, onReset?: () => void): FieldRow { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + const label = new Label(def.label, { style: 'muted' }); + label.minWidth = 64; + row.add(label); + + let setValue: (v: PropertyValue) => void; + + switch (def.kind) { + case 'number': { + const field = new NumberField(Number(value), { + min: def.min, + max: def.max, + step: def.step ?? 1, + onInput: handlers.onInput, + onChange: handlers.onChange, + }); + row.add(field, 1); + setValue = (v) => field.setNumber(Number(v)); + break; + } + case 'text': { + const field = new TextField(String(value), { onInput: handlers.onInput, onChange: handlers.onChange }); + row.add(field, 1); + setValue = (v) => field.setValue(String(v)); + break; + } + case 'color': { + const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange }); + row.add(field, 1); + setValue = (v) => field.setValue(Number(v)); + break; + } + case 'boolean': { + const field = new Checkbox(Boolean(value)); + field.onChange = handlers.onChange; + row.add(field); + row.add(new Label(''), 1); + setValue = (v) => field.setChecked(Boolean(v)); + break; + } + } + + let reset: Button | null = null; + if (onReset) { + reset = new Button('↺', { variant: 'ghost', width: Theme.controlHeight, onClick: onReset }); + reset.visible = false; + row.add(reset); + } + + return { + row, + setValue, + setOverridden(overridden) { + label.setStyle(overridden ? 'bodyBold' : 'muted'); + if (reset && reset.visible !== overridden) { + reset.visible = overridden; + row.relayout(); + } + }, + }; +} diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts new file mode 100644 index 00000000..01887faf --- /dev/null +++ b/src/editor/panels/AssetPanel.ts @@ -0,0 +1,112 @@ +import { CreateAssetCommand, DeleteAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands'; +import { nextAssetName } from '../../core/commands/projectCommands'; +import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; +import { ASSET_TYPES, type AssetType } from '../../core/model/AssetTypes'; +import type { Vec2 } from '../../core/model/Properties'; +import { findAsset, findUsages } from '../../core/model/Project'; +import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui'; +import type { EditorState } from '../state'; + +const TYPE_LABELS: Record = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite' }; + +/** Lists all assets of the project; creates, places and deletes them. */ +export class AssetPanel extends Panel { + private readonly list = new ListView(); + + constructor(private readonly state: EditorState, private readonly placementPoint: () => Vec2) { + super({ title: 'Assets', padding: 0, gap: 0 }); + this.minWidth = 160; + + const add = new Button('+', { variant: 'ghost', width: 28 }); + add.onClick = () => { + const g = add.getGlobalPosition(); + new Menu(ASSET_TYPES.map((type) => ({ label: TYPE_LABELS[type], onSelect: () => this.createAsset(type) }))).open(g.x, g.y + add.h + 2); + }; + this.header.add(add); + + this.list.emptyText = 'No assets – press +'; + this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null); + this.list.onActivate = (id) => this.activate(id); + this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y); + const scroll = new ScrollContainer(); + scroll.setContent(this.list); + this.content.add(scroll, 1); + + state.on('project', () => this.refresh()); + state.on('load', () => this.refresh()); + state.on('selection', () => this.syncSelection()); + state.on('mode', () => this.setEnabled(state.mode === 'edit')); + this.refresh(); + } + + private refresh(): void { + const active = this.state.project.activeSceneId; + this.list.setItems( + this.state.project.assets.map((asset) => ({ + id: asset.id, + label: asset.name, + hint: asset.type === 'scene' && asset.id === active ? 'scene ●' : asset.type, + swatch: asset.type === 'sprite' ? Number(asset.props.color ?? 0) : undefined, + })), + ); + this.syncSelection(); + (this.content.items[0] as ScrollContainer | undefined)?.relayoutContent(); + } + + private syncSelection(): void { + const s = this.state.selection; + this.list.select(s?.kind === 'asset' ? s.assetId : null, true); + } + + private createAsset(type: AssetType): void { + const command = new CreateAssetCommand(type, nextAssetName(this.state.project, TYPE_LABELS[type])); + if (this.state.execute(command)) this.state.select({ kind: 'asset', assetId: command.asset.id }); + } + + /** Double-click / Enter: scenes become active, everything else is placed. */ + private activate(id: string): void { + const asset = findAsset(this.state.project, id); + if (!asset) return; + if (asset.type === 'scene') this.state.execute(new SetActiveSceneCommand(id)); + else this.place(id); + } + + private place(assetId: string): void { + const scene = this.state.activeScene; + if (!scene) { + this.state.setNotice('Create a scene first, then place assets into it.'); + return; + } + const command = new PlaceAssetCommand(scene.id, assetId, this.placementPoint()); + if (this.state.execute(command)) this.state.select({ kind: 'object', sceneId: scene.id, objectId: command.objectId }); + } + + private openContextMenu(id: string, x: number, y: number): void { + const asset = findAsset(this.state.project, id); + if (!asset) return; + const items: MenuItem[] = []; + if (asset.type === 'scene') { + items.push({ label: 'Set as active scene', onSelect: () => this.state.execute(new SetActiveSceneCommand(id)), disabled: this.state.project.activeSceneId === id }); + } else { + items.push({ label: 'Place in scene', onSelect: () => this.place(id), disabled: !this.state.activeScene }); + } + items.push({ label: 'Delete…', danger: true, onSelect: () => this.confirmDelete(id) }); + new Menu(items).open(x, y); + } + + private confirmDelete(id: string): void { + const asset = findAsset(this.state.project, id); + if (!asset) return; + const usages = findUsages(this.state.project, id).length; + const detail = + asset.type === 'scene' + ? `The scene and its ${asset.objects.length} object(s) are removed.` + : usages > 0 + ? `${usages} placed instance(s) are removed from scenes as well.` + : 'This cannot be undone with Ctrl+Z after reload.'; + new Dialog(`Delete "${asset.name}"?`, detail, [ + { label: 'Cancel' }, + { label: 'Delete', danger: true, onClick: () => this.state.execute(new DeleteAssetCommand(id)) }, + ]).openCentered(); + } +} diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts new file mode 100644 index 00000000..872e38aa --- /dev/null +++ b/src/editor/panels/HierarchyPanel.ts @@ -0,0 +1,62 @@ +import { RemoveSceneObjectCommand } from '../../core/commands/sceneObjectCommands'; +import { findAsset } from '../../core/model/Project'; +import { resolveSceneObjectProps } from '../../core/properties/resolve'; +import { ListView, Menu, Panel, ScrollContainer } from '../../ui'; +import type { EditorState } from '../state'; + +/** Objects placed in the active scene. */ +export class HierarchyPanel extends Panel { + private readonly list = new ListView(); + private readonly scroll = new ScrollContainer(); + + constructor(private readonly state: EditorState) { + super({ title: 'Scene objects', padding: 0, gap: 0 }); + this.minWidth = 160; + this.list.emptyText = 'Place assets here'; + this.list.onSelect = (id) => { + const scene = state.activeScene; + state.select(id && scene ? { kind: 'object', sceneId: scene.id, objectId: id } : null); + }; + this.list.onContextMenu = (id, x, y) => { + const scene = state.activeScene; + if (!scene) return; + const object = scene.objects.find((o) => o.id === id); + new Menu([ + { label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) }, + { label: 'Remove from scene', danger: true, onSelect: () => state.execute(new RemoveSceneObjectCommand(scene.id, id)) }, + ]).open(x, y); + }; + this.scroll.setContent(this.list); + this.content.add(this.scroll, 1); + + state.on('project', () => this.refresh()); + state.on('load', () => this.refresh()); + state.on('selection', () => this.syncSelection()); + state.on('mode', () => this.setEnabled(state.mode === 'edit')); + this.refresh(); + } + + private refresh(): void { + const scene = this.state.activeScene; + this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects'); + this.list.setItems( + (scene?.objects ?? []).map((object) => { + const asset = findAsset(this.state.project, object.assetId); + const props = asset ? resolveSceneObjectProps(asset, object) : {}; + return { + id: object.id, + label: object.name || asset?.name || '?', + hint: asset?.type ?? '', + swatch: asset?.type === 'sprite' ? Number(props.color ?? 0) : undefined, + }; + }), + ); + this.syncSelection(); + this.scroll.relayoutContent(); + } + + private syncSelection(): void { + const s = this.state.selection; + this.list.select(s?.kind === 'object' ? s.objectId : null, true); + } +} diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts new file mode 100644 index 00000000..97082f18 --- /dev/null +++ b/src/editor/panels/Inspector.ts @@ -0,0 +1,230 @@ +import { RenameAssetCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands'; +import { + ClearOverrideCommand, + MoveSceneObjectCommand, + RenameSceneObjectCommand, + SetOverrideCommand, +} from '../../core/commands/sceneObjectCommands'; +import type { PropertyValue } from '../../core/model/Properties'; +import { findAsset, findScene, findSceneObject } from '../../core/model/Project'; +import { schemaFor } from '../../core/properties/PropertySchema'; +import { isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve'; +import { Button, HStack, Label, Panel, ScrollContainer, TextField, Theme, VStack } from '../../ui'; +import { createFieldRow, type FieldRow } from '../inspector/fieldFactory'; +import type { EditorState, Selection } from '../state'; + +/** + * Shows the properties of the current selection. The field list is generated from the + * PropertySchema; scene objects additionally get Position and per-property overrides. + */ +export class Inspector extends Panel { + private readonly scroll = new ScrollContainer(); + private body = new VStack({ padding: Theme.padding, gap: Theme.gap }); + private fields = new Map(); + private nameField: TextField | null = null; + private builtFor: Selection = null; + + constructor(private readonly state: EditorState) { + super({ title: 'Inspector', padding: 0, gap: 0 }); + this.minWidth = 220; + this.scroll.setContent(this.body); + this.content.add(this.scroll, 1); + state.on('selection', () => this.rebuild()); + state.on('load', () => this.rebuild()); + state.on('project', () => this.refreshValues()); + state.on('mode', () => this.setEnabled(state.mode === 'edit')); + this.rebuild(); + } + + private rebuild(): void { + this.builtFor = this.state.selection; + this.body.destroy({ children: true }); + this.body = new VStack({ padding: Theme.padding, gap: Theme.gap }); + this.fields = new Map(); + this.nameField = null; + + const s = this.state.selection; + if (!s) { + this.body.add(new Label('Select an asset or a scene object.', { style: 'muted' })); + } else if (s.kind === 'asset') { + this.buildAsset(s.assetId); + } else { + this.buildObject(s.sceneId, s.objectId); + } + this.scroll.setContent(this.body); + } + + private buildAsset(assetId: string): void { + const asset = findAsset(this.state.project, assetId); + if (!asset) return; + this.body.add(new Label(`${asset.type.toUpperCase()} ASSET`, { style: 'title' })); + this.nameField = new TextField(asset.name, { + onChange: (name) => { + if (name.trim()) this.state.execute(new RenameAssetCommand(assetId, name.trim())); + else this.nameField?.setValue(asset.name); + }, + }); + this.body.add(this.labelled('Name', this.nameField)); + + const values = resolveAssetProps(asset); + for (const def of schemaFor(asset.type)) { + let original: PropertyValue | undefined; + const row = createFieldRow(def, values[def.key] ?? def.default, { + onInput: (value) => { + const target = findAsset(this.state.project, assetId); + if (!target) return; + if (original === undefined) original = target.props[def.key]; + target.props[def.key] = value; + this.state.preview(); + }, + onChange: (value) => { + const target = findAsset(this.state.project, assetId); + if (!target) return; + if (original !== undefined) target.props[def.key] = original; + original = undefined; + this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value)); + }, + }); + this.fields.set(def.key, row); + this.body.add(row.row); + } + } + + private buildObject(sceneId: string, objectId: string): void { + const scene = findScene(this.state.project, sceneId); + const object = scene && findSceneObject(scene, objectId); + const asset = object && findAsset(this.state.project, object.assetId); + if (!scene || !object || !asset) return; + + this.body.add(new Label(`${asset.type.toUpperCase()} OBJECT`, { style: 'title' })); + this.nameField = new TextField(object.name, { + placeholder: asset.name, + onChange: (name) => this.state.execute(new RenameSceneObjectCommand(sceneId, objectId, name.trim())), + }); + this.body.add(this.labelled('Name', this.nameField)); + + const assetRow = new HStack({ gap: Theme.gap, align: 'center' }); + const assetLabel = new Label('Asset', { style: 'muted' }); + assetLabel.minWidth = 64; + assetRow.add(assetLabel); + assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1); + this.body.add(assetRow); + + // Position – belongs to the scene object only. + this.body.add(new Label('POSITION', { style: 'title' })); + for (const axis of ['x', 'y'] as const) { + let original: { x: number; y: number } | undefined; + const row = createFieldRow({ key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 }, object.position[axis], { + onInput: (value) => { + const target = this.currentObject(sceneId, objectId); + if (!target) return; + if (!original) original = { ...target.position }; + target.position[axis] = Number(value); + this.state.preview(); + }, + onChange: (value) => { + const target = this.currentObject(sceneId, objectId); + if (!target) return; + const previous = original ?? { ...target.position }; + if (original) target.position = { ...original }; + original = undefined; + const next = { ...previous, [axis]: Number(value) }; + this.state.execute(new MoveSceneObjectCommand(sceneId, objectId, next, previous)); + }, + }); + this.fields.set(axis, row); + this.body.add(row.row); + } + + // Asset properties with overrides. + this.body.add(new Label('PROPERTIES', { style: 'title' })); + const values = resolveSceneObjectProps(asset, object); + for (const def of schemaFor(asset.type)) { + let original: PropertyValue | undefined; + let hadOverride = false; + const row = createFieldRow( + def, + values[def.key] ?? def.default, + { + onInput: (value) => { + const target = this.currentObject(sceneId, objectId); + if (!target) return; + if (original === undefined) { + hadOverride = isOverridden(target, def.key); + original = target.overrides[def.key] ?? values[def.key] ?? def.default; + } + target.overrides[def.key] = value; + this.state.preview(); + }, + onChange: (value) => { + const target = this.currentObject(sceneId, objectId); + if (!target) return; + if (original !== undefined) { + if (hadOverride) target.overrides[def.key] = original; + else delete target.overrides[def.key]; + } + original = undefined; + this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)); + }, + }, + () => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)), + ); + row.setOverridden(isOverridden(object, def.key)); + this.fields.set(def.key, row); + this.body.add(row.row); + } + } + + /** Push current model values into the widgets (after undo, drag in viewport, …). */ + private refreshValues(): void { + const s = this.state.selection; + if (!sameTarget(s, this.builtFor)) { + this.rebuild(); + return; + } + if (!s) return; + if (s.kind === 'asset') { + const asset = findAsset(this.state.project, s.assetId); + if (!asset) return this.rebuild(); + this.nameField?.setValue(asset.name); + const values = resolveAssetProps(asset); + for (const [key, row] of this.fields) row.setValue(values[key] ?? 0); + return; + } + const object = this.currentObject(s.sceneId, s.objectId); + const asset = object && findAsset(this.state.project, object.assetId); + if (!object || !asset) return this.rebuild(); + this.nameField?.setValue(object.name); + this.fields.get('x')?.setValue(object.position.x); + this.fields.get('y')?.setValue(object.position.y); + const values = resolveSceneObjectProps(asset, object); + for (const def of schemaFor(asset.type)) { + const row = this.fields.get(def.key); + row?.setValue(values[def.key] ?? def.default); + row?.setOverridden(isOverridden(object, def.key)); + } + } + + private currentObject(sceneId: string, objectId: string) { + const scene = findScene(this.state.project, sceneId); + return scene && findSceneObject(scene, objectId); + } + + private labelled(text: string, field: TextField): HStack { + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.minHeight = Theme.rowHeight; + const label = new Label(text, { style: 'muted' }); + label.minWidth = 64; + row.add(label); + row.add(field, 1); + return row; + } +} + +function sameTarget(a: Selection, b: Selection): boolean { + if (!a || !b) return a === b; + if (a.kind !== b.kind) return false; + if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId; + if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId; + return false; +} diff --git a/src/editor/panels/Toolbar.ts b/src/editor/panels/Toolbar.ts new file mode 100644 index 00000000..d94927c8 --- /dev/null +++ b/src/editor/panels/Toolbar.ts @@ -0,0 +1,104 @@ +import { Graphics } from 'pixi.js'; +import { RenameProjectCommand } from '../../core/commands/projectCommands'; +import { listScenes } from '../../core/model/Project'; +import { Button, HStack, Label, Spacer, TextField, Theme, type Widget } from '../../ui'; +import type { EditorState, SaveStatus } from '../state'; + +export interface ToolbarActions { + openProjectMenu(anchor: Widget): void; + openSceneMenu(anchor: Widget): void; + togglePlay(): void; +} + +const SAVE_LABELS: Record = { + saved: 'Saved', + dirty: 'Unsaved changes', + saving: 'Saving…', + error: 'Save failed', + conflict: 'Conflict – reload', +}; + +/** Top bar: project, scene selector, undo/redo, Play/Stop, save status. */ +export class Toolbar extends HStack { + private readonly bg = new Graphics(); + private readonly projectName: TextField; + private readonly sceneButton: Button; + private readonly undoButton: Button; + private readonly redoButton: Button; + private readonly playButton: Button; + private readonly status: Label; + private readonly notice: Label; + + constructor(private readonly state: EditorState, actions: ToolbarActions) { + super({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); + this.minHeight = Theme.controlHeight + Theme.padding * 2; + this.addChildAt(this.bg, 0); + + const projectButton = new Button('Project ▾', { onClick: () => actions.openProjectMenu(projectButton) }); + this.add(projectButton); + this.projectName = new TextField('', { + width: 180, + placeholder: 'Project name', + onChange: (name) => { + if (name.trim()) state.execute(new RenameProjectCommand(name.trim())); + else this.projectName.setValue(state.project.name); + }, + }); + this.add(this.projectName); + this.add(new Spacer(Theme.padding)); + + this.add(new Label('Scene', { style: 'muted' })); + this.sceneButton = new Button('—', { width: 160, onClick: () => actions.openSceneMenu(this.sceneButton) }); + this.add(this.sceneButton); + this.add(new Spacer(Theme.padding)); + + this.undoButton = new Button('Undo', { onClick: () => state.undo() }); + this.redoButton = new Button('Redo', { onClick: () => state.redo() }); + this.add(this.undoButton); + this.add(this.redoButton); + + this.add(new Spacer(), 1); + this.playButton = new Button('▶ Play', { variant: 'primary', width: 110, onClick: () => actions.togglePlay() }); + this.add(this.playButton); + this.add(new Spacer(), 1); + + this.notice = new Label('', { style: 'small', align: 'right' }); + this.notice.minWidth = 260; + this.add(this.notice); + this.status = new Label('', { style: 'small', align: 'right' }); + this.status.minWidth = 110; + this.add(this.status); + + state.on('project', () => this.refresh()); + state.on('load', () => this.refresh()); + state.on('mode', () => this.refresh()); + state.on('save', () => this.refresh()); + state.on('notice', () => this.notice.setText(state.notice)); + this.refresh(); + } + + private refresh(): void { + const { state } = this; + this.projectName.setValue(state.project.name); + const scene = state.activeScene; + const scenes = listScenes(state.project); + this.sceneButton.setText(scene ? `${scene.name} ▾` : scenes.length ? 'Select scene ▾' : 'No scene ▾'); + const editing = state.mode === 'edit'; + this.undoButton.setEnabled(editing && state.commands.canUndo); + this.redoButton.setEnabled(editing && state.commands.canRedo); + this.playButton.setText(editing ? '▶ Play' : '■ Stop'); + this.playButton.variant = editing ? 'primary' : 'danger'; + this.playButton.setEnabled(scene !== undefined); + this.playButton.invalidate(); + this.projectName.setEnabled(editing); + this.sceneButton.setEnabled(editing); + this.status.setText(SAVE_LABELS[state.saveStatus]); + this.status.setStyle(state.saveStatus === 'error' || state.saveStatus === 'conflict' ? 'body' : 'small'); + } + + override redraw(): void { + this.bg.clear(); + this.bg.rect(0, 0, this.w, this.h).fill(Theme.colors.panelHeader); + this.bg.rect(0, this.h - 1, this.w, 1).fill(Theme.colors.border); + } +} diff --git a/src/editor/state.ts b/src/editor/state.ts new file mode 100644 index 00000000..c4694363 --- /dev/null +++ b/src/editor/state.ts @@ -0,0 +1,160 @@ +import { type Command, CommandError } from '../core/commands/Command'; +import { CommandStack } from '../core/commands/CommandStack'; +import type { SceneAsset } from '../core/model/Asset'; +import { createEmptyProject, findScene, type Project } from '../core/model/Project'; + +export type Selection = + | { kind: 'asset'; assetId: string } + | { kind: 'object'; sceneId: string; objectId: string } + | null; + +export type EditorMode = 'edit' | 'play'; +export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict'; + +export type EditorEvent = + | 'project' // the model changed (command, undo, preview, load) + | 'load' // a different project was loaded + | 'selection' + | 'mode' + | 'save' + | 'notice'; + +type Listener = () => void; + +/** + * Single source of truth for the editor: the project, undo stack, selection, mode and + * save status. Panels subscribe to coarse events and refresh themselves; there is no + * framework and no virtual DOM. + */ +export class EditorState { + project: Project = createEmptyProject('', 'Untitled'); + revision = 0; + commands = new CommandStack(this.project); + selection: Selection = null; + mode: EditorMode = 'edit'; + saveStatus: SaveStatus = 'saved'; + /** Last user-facing message (errors, hints). */ + notice = ''; + + private readonly listeners = new Map>(); + + on(event: EditorEvent, listener: Listener): () => void { + let set = this.listeners.get(event); + if (!set) this.listeners.set(event, (set = new Set())); + set.add(listener); + return () => set.delete(listener); + } + + emit(event: EditorEvent): void { + for (const listener of this.listeners.get(event) ?? []) listener(); + } + + // --- project lifecycle --------------------------------------------------------------- + + loadProject(project: Project, revision: number): void { + this.project = project; + this.revision = revision; + this.commands = new CommandStack(project); + this.selection = null; + this.saveStatus = 'saved'; + this.emit('load'); + this.emit('project'); + this.emit('selection'); + this.emit('save'); + } + + get activeScene(): SceneAsset | undefined { + return findScene(this.project, this.project.activeSceneId); + } + + // --- editing ------------------------------------------------------------------------- + + /** Runs a command through the undo stack. Returns false (and sets a notice) when invalid. */ + execute(command: Command): boolean { + if (this.mode === 'play') return false; + try { + this.commands.execute(command); + } catch (error) { + if (error instanceof CommandError) { + this.setNotice(error.message); + return false; + } + throw error; + } + this.markDirty(); + return true; + } + + undo(): void { + if (this.mode === 'play' || !this.commands.undo()) return; + this.validateSelection(); + this.markDirty(); + } + + redo(): void { + if (this.mode === 'play' || !this.commands.redo()) return; + this.validateSelection(); + this.markDirty(); + } + + /** + * The model was mutated directly (live preview while scrubbing). Notifies listeners + * but does not touch the undo stack or the save status – the committing command does. + */ + preview(): void { + this.emit('project'); + } + + private markDirty(): void { + this.validateSelection(); + this.saveStatus = 'dirty'; + this.emit('project'); + this.emit('save'); + } + + // --- selection ----------------------------------------------------------------------- + + select(selection: Selection): void { + if (sameSelection(this.selection, selection)) return; + this.selection = selection; + this.emit('selection'); + } + + /** Drops a selection whose target no longer exists (after delete/undo). */ + private validateSelection(): void { + const s = this.selection; + if (!s) return; + if (s.kind === 'asset' && !this.project.assets.some((a) => a.id === s.assetId)) this.select(null); + if (s.kind === 'object') { + const scene = findScene(this.project, s.sceneId); + if (!scene || !scene.objects.some((o) => o.id === s.objectId)) this.select(null); + } + } + + // --- mode / status ------------------------------------------------------------------- + + setMode(mode: EditorMode): void { + if (this.mode === mode) return; + this.mode = mode; + this.emit('mode'); + } + + setSaveStatus(status: SaveStatus): void { + if (this.saveStatus === status) return; + this.saveStatus = status; + this.emit('save'); + } + + setNotice(message: string): void { + this.notice = message; + this.emit('notice'); + } +} + +function sameSelection(a: Selection, b: Selection): boolean { + if (a === b) return true; + if (!a || !b || a.kind !== b.kind) return false; + if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId; + if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId; + return false; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 00000000..fdf72c76 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,43 @@ +import { Application, Container } from 'pixi.js'; +// Static imports on purpose: Pixi registers render pipes (graphics, text, sprite) as a +// module side effect, and a renderer only knows the pipes that exist when it is created. +// Lazy chunks loaded after `app.init()` would therefore fail with "unknown render pipe". +import { startEditor } from './editor/App'; +import { mountPlayground } from './playground'; +import { initUI, Theme, VStack } from './ui'; + +async function main(): Promise { + const app = new Application(); + await app.init({ + resizeTo: window, + background: Theme.colors.background, + antialias: true, + resolution: Math.min(2, window.devicePixelRatio || 1), + autoDensity: true, + preference: 'webgl', + }); + document.body.appendChild(app.canvas); + + const root = new VStack(); + const overlay = new Container(); + app.stage.addChild(root, overlay); + app.stage.eventMode = 'static'; + app.stage.hitArea = app.screen; + initUI(app, root, overlay); + + const params = new URLSearchParams(location.search); + if (params.has('playground')) mountPlayground(app, root); + else await startEditor(app, root); + + app.renderer.on('resize', () => { + app.stage.hitArea = app.screen; + root.resize(app.screen.width, app.screen.height); + }); +} + +window.addEventListener('error', (event) => console.log('[uncaught]', event.error?.stack ?? event.message)); + +main().catch((error) => { + console.error(error); + document.body.innerHTML = `
${String(error)}
`; +}); diff --git a/src/playground.ts b/src/playground.ts new file mode 100644 index 00000000..a2432683 --- /dev/null +++ b/src/playground.ts @@ -0,0 +1,99 @@ +import type { Application } from 'pixi.js'; +import { + Button, + Checkbox, + ColorField, + Dialog, + HStack, + Label, + ListView, + Menu, + NumberField, + Panel, + ScrollContainer, + TextField, + Theme, + VStack, + toHex, +} from './ui'; + +/** + * Developer page (`?playground`) that shows every widget of the toolkit for manual + * testing: focus cycling, typing, scrubbing, color picking, scrolling, popups. + */ +export function mountPlayground(app: Application, root: VStack): void { + const status = new Label('Interact with the widgets – events are logged here.', { style: 'muted', padding: Theme.padding }); + const log = (message: string) => status.setText(message); + + const row = new HStack({ gap: Theme.padding, padding: Theme.padding }); + + // Column 1: inputs + const inputs = new Panel({ title: 'Inputs' }); + inputs.content.add(new Label('Text field')); + inputs.content.add(new TextField('Crate', { placeholder: 'Name', onInput: (v) => log(`text input: ${v}`), onChange: (v) => log(`text change: ${v}`) })); + inputs.content.add(new Label('Number field (drag to scrub)')); + inputs.content.add(new NumberField(100, { min: 1, step: 1, onInput: (v) => log(`number input: ${v}`), onChange: (v) => log(`number change: ${v}`) })); + inputs.content.add(new NumberField(1, { min: 0.1, max: 10, step: 0.1, onChange: (v) => log(`zoom change: ${v}`) })); + inputs.content.add(new Label('Color field')); + inputs.content.add(new ColorField(0xe0553f, { onInput: (c) => log(`color input: ${toHex(c)}`), onChange: (c) => log(`color change: ${toHex(c)}`) })); + const checkRow = new HStack({ gap: Theme.gap, align: 'center' }); + const checkbox = new Checkbox(true); + checkbox.onChange = (v) => log(`checkbox: ${v}`); + checkRow.add(checkbox); + checkRow.add(new Label('Checkbox'), 1); + inputs.content.add(checkRow); + row.add(inputs, 1); + + // Column 2: buttons + popups + const actions = new Panel({ title: 'Buttons & popups' }); + actions.content.add(new Button('Default', { onClick: () => log('default clicked') })); + actions.content.add(new Button('Primary', { variant: 'primary', onClick: () => log('primary clicked') })); + const toggle = new Button('Toggle me'); + toggle.onClick = () => { toggle.setToggled(!toggle.toggled); log(`toggled: ${toggle.toggled}`); }; + actions.content.add(toggle); + actions.content.add(new Button('Ghost', { variant: 'ghost', onClick: () => log('ghost clicked') })); + const disabled = new Button('Disabled'); + disabled.setEnabled(false); + actions.content.add(disabled); + const menuButton = new Button('Open menu…'); + menuButton.onClick = () => { + const g = menuButton.getGlobalPosition(); + new Menu([ + { label: 'Scene', onSelect: () => log('menu: scene') }, + { label: 'Camera', onSelect: () => log('menu: camera') }, + { label: 'Sprite', onSelect: () => log('menu: sprite') }, + { label: 'Delete', danger: true, onSelect: () => log('menu: delete') }, + ]).open(g.x, g.y + menuButton.h + 2); + }; + actions.content.add(menuButton); + actions.content.add(new Button('Open dialog…', { + onClick: () => new Dialog('Delete asset?', 'Every placed instance is removed as well.', [ + { label: 'Cancel' }, + { label: 'Delete', danger: true, onClick: () => log('dialog: deleted') }, + ]).openCentered(), + })); + row.add(actions, 1); + + // Column 3: list in a scroll container + const listPanel = new Panel({ title: 'List (scroll)', padding: 0 }); + const scroll = new ScrollContainer(); + const list = new ListView(); + list.setItems(Array.from({ length: 40 }, (_, i) => ({ + id: `item-${i}`, + label: `Item ${i + 1}${i % 7 === 0 ? ' with a rather long name that must truncate' : ''}`, + hint: i % 3 === 0 ? 'sprite' : i % 3 === 1 ? 'camera' : 'scene', + swatch: i % 3 === 0 ? (0x3fa0e0 + i * 0x040201) & 0xffffff : undefined, + depth: i % 5 === 4 ? 1 : 0, + }))); + list.onSelect = (id) => log(`list select: ${id}`); + list.onActivate = (id) => log(`list activate: ${id}`); + list.onContextMenu = (id, x, y) => new Menu([{ label: `Context for ${id}`, onSelect: () => log(`context: ${id}`) }]).open(x, y); + scroll.setContent(list); + listPanel.content.add(scroll, 1); + row.add(listPanel, 1); + + root.add(new Label('Game Maker – UI playground', { style: 'bodyBold', padding: Theme.padding })); + root.add(row, 1); + root.add(status); + root.resize(app.screen.width, app.screen.height); +} diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts new file mode 100644 index 00000000..331ce70d --- /dev/null +++ b/src/runtime/Entity.ts @@ -0,0 +1,19 @@ +import type { AssetType } from '../core/model/AssetTypes'; +import type { PropertyBag } from '../core/model/Properties'; + +/** + * A live instance created from a scene object when Play starts. Holds the *resolved* + * property values as mutable runtime state. Systems change entities freely; nothing here + * is ever written back to the project model. + */ +export interface Entity { + id: string; + /** Scene object this entity was instantiated from (for selection highlighting). */ + objectId: string; + assetId: string; + type: AssetType; + name: string; + x: number; + y: number; + props: PropertyBag; +} diff --git a/src/runtime/World.ts b/src/runtime/World.ts new file mode 100644 index 00000000..3149804f --- /dev/null +++ b/src/runtime/World.ts @@ -0,0 +1,72 @@ +import { findAsset, findScene, type Project } from '../core/model/Project'; +import { resolveSceneObjectProps } from '../core/properties/resolve'; +import type { Entity } from './Entity'; +import type { System } from './systems/System'; + +/** + * The running game: entities instantiated from one scene plus the systems ticking them. + * Created on Play, thrown away on Stop – the project model is never touched. + */ +export class World { + readonly entities: Entity[] = []; + readonly backgroundColor: number; + private readonly systems: System[] = []; + private elapsed = 0; + + private constructor(readonly sceneId: string, backgroundColor: number) { + this.backgroundColor = backgroundColor; + } + + /** Builds a world from the given scene. Throws when the scene does not exist. */ + static instantiate(project: Project, sceneId: string): World { + const scene = findScene(project, sceneId); + if (!scene) throw new Error(`Scene ${sceneId} not found`); + const background = scene.props.backgroundColor; + const world = new World(sceneId, typeof background === 'number' ? background : 0x000000); + for (const object of scene.objects) { + const asset = findAsset(project, object.assetId); + if (!asset || asset.type === 'scene') continue; + world.entities.push({ + id: `e-${object.id}`, + objectId: object.id, + assetId: asset.id, + type: asset.type, + name: object.name || asset.name, + x: object.position.x, + y: object.position.y, + props: resolveSceneObjectProps(asset, object), + }); + } + return world; + } + + addSystem(system: System): this { + this.systems.push(system); + system.start?.(this); + return this; + } + + /** Advances the simulation by `dt` seconds. */ + tick(dt: number): void { + this.elapsed += dt; + for (const system of this.systems) system.update(this, dt); + } + + get time(): number { + return this.elapsed; + } + + findByObjectId(objectId: string): Entity | undefined { + return this.entities.find((e) => e.objectId === objectId); + } + + firstOfType(type: Entity['type']): Entity | undefined { + return this.entities.find((e) => e.type === type); + } + + destroy(): void { + for (const system of this.systems) system.stop?.(this); + this.systems.length = 0; + this.entities.length = 0; + } +} diff --git a/src/runtime/systems/CameraFollowSystem.ts b/src/runtime/systems/CameraFollowSystem.ts new file mode 100644 index 00000000..4617db4d --- /dev/null +++ b/src/runtime/systems/CameraFollowSystem.ts @@ -0,0 +1,25 @@ +import type { Entity } from '../Entity'; +import type { World } from '../World'; +import type { System } from './System'; + +/** + * Makes the first camera entity follow a target entity (smoothly). If the scene has no + * camera the viewport falls back to a fixed view at the origin. + */ +export class CameraFollowSystem implements System { + private camera: Entity | undefined; + + constructor(private readonly target: () => Entity | undefined, private readonly smoothing = 8) {} + + start(world: World): void { + this.camera = world.firstOfType('camera'); + } + + update(_world: World, dt: number): void { + const target = this.target(); + if (!this.camera || !target) return; + const t = 1 - Math.exp(-this.smoothing * dt); + this.camera.x += (target.x - this.camera.x) * t; + this.camera.y += (target.y - this.camera.y) * t; + } +} diff --git a/src/runtime/systems/Input.ts b/src/runtime/systems/Input.ts new file mode 100644 index 00000000..f942cd45 --- /dev/null +++ b/src/runtime/systems/Input.ts @@ -0,0 +1,33 @@ +/** Keyboard state tracker for play mode. Only records keys; systems interpret them. */ +export class Input { + private readonly down = new Set(); + private readonly onKeyDown = (e: KeyboardEvent) => { + if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return; + this.down.add(e.key); + if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault(); + }; + private readonly onKeyUp = (e: KeyboardEvent) => this.down.delete(e.key); + private readonly onBlur = () => this.down.clear(); + + attach(): void { + window.addEventListener('keydown', this.onKeyDown); + window.addEventListener('keyup', this.onKeyUp); + window.addEventListener('blur', this.onBlur); + } + + detach(): void { + window.removeEventListener('keydown', this.onKeyDown); + window.removeEventListener('keyup', this.onKeyUp); + window.removeEventListener('blur', this.onBlur); + this.down.clear(); + } + + isDown(key: string): boolean { + return this.down.has(key); + } + + /** -1, 0 or 1 for a pair of keys. */ + axis(negative: string, positive: string): number { + return (this.isDown(positive) ? 1 : 0) - (this.isDown(negative) ? 1 : 0); + } +} diff --git a/src/runtime/systems/PlayerControlSystem.ts b/src/runtime/systems/PlayerControlSystem.ts new file mode 100644 index 00000000..75d867ac --- /dev/null +++ b/src/runtime/systems/PlayerControlSystem.ts @@ -0,0 +1,41 @@ +import type { Entity } from '../Entity'; +import type { World } from '../World'; +import { Input } from './Input'; +import type { System } from './System'; + +/** + * Phase-1 demo behaviour: the arrow keys (or WASD) move one sprite entity. Which one is + * decided at start: the entity of the scene object that was selected when Play was + * pressed, otherwise the first sprite in the scene. + */ +export class PlayerControlSystem implements System { + private readonly input = new Input(); + private player: Entity | undefined; + + constructor(private readonly preferredObjectId: string | null, private readonly speed = 240) {} + + start(world: World): void { + this.input.attach(); + const preferred = this.preferredObjectId ? world.findByObjectId(this.preferredObjectId) : undefined; + this.player = preferred && preferred.type === 'sprite' ? preferred : world.firstOfType('sprite'); + } + + update(_world: World, dt: number): void { + if (!this.player) return; + const dx = this.input.axis('ArrowLeft', 'ArrowRight') || this.input.axis('a', 'd'); + const dy = this.input.axis('ArrowUp', 'ArrowDown') || this.input.axis('w', 's'); + if (dx === 0 && dy === 0) return; + const length = Math.hypot(dx, dy); + this.player.x += (dx / length) * this.speed * dt; + this.player.y += (dy / length) * this.speed * dt; + } + + stop(): void { + this.input.detach(); + this.player = undefined; + } + + get controlled(): Entity | undefined { + return this.player; + } +} diff --git a/src/runtime/systems/System.ts b/src/runtime/systems/System.ts new file mode 100644 index 00000000..d6670897 --- /dev/null +++ b/src/runtime/systems/System.ts @@ -0,0 +1,8 @@ +import type { World } from '../World'; + +/** A behaviour that runs every frame over the world's entities. */ +export interface System { + start?(world: World): void; + update(world: World, dt: number): void; + stop?(world: World): void; +} diff --git a/src/ui/FocusManager.ts b/src/ui/FocusManager.ts new file mode 100644 index 00000000..e29f374c --- /dev/null +++ b/src/ui/FocusManager.ts @@ -0,0 +1,85 @@ +import type { Container } from 'pixi.js'; +import { collectFocusables, type Widget } from './Widget'; + +/** + * Tracks the single focused widget and routes keyboard events to it. Tab / Shift+Tab + * cycle through focusable widgets in tree order below `root`. + */ +export class FocusManager { + private focusedWidget: Widget | null = null; + private readonly onKeyDown = (event: KeyboardEvent) => this.handleKeyDown(event); + + constructor(private readonly root: Container) { + window.addEventListener('keydown', this.onKeyDown); + } + + get focused(): Widget | null { + return this.focusedWidget; + } + + focus(widget: Widget | null): void { + if (widget === this.focusedWidget) return; + const previous = this.focusedWidget; + this.focusedWidget = widget; + previous?.onBlur(); + widget?.onFocus(); + } + + blur(): void { + this.focus(null); + } + + /** Drop focus if the focused widget was removed from the tree or hidden. */ + validate(): void { + const w = this.focusedWidget; + if (w && (w.destroyed || !isAttached(w, this.root) || !w.enabled)) this.focus(null); + } + + cycle(backwards: boolean): void { + const list = collectFocusables(this.root); + if (list.length === 0) return this.focus(null); + const index = this.focusedWidget ? list.indexOf(this.focusedWidget) : -1; + const next = backwards + ? (index <= 0 ? list.length : index) - 1 + : (index + 1) % list.length; + this.focus(list[next] ?? null); + } + + /** Global handlers for keys nobody consumed (Delete, Ctrl+Z, …). */ + readonly globalHandlers: ((event: KeyboardEvent) => boolean)[] = []; + + private handleKeyDown(event: KeyboardEvent): void { + // Keys typed into the keyboard bridge input are forwarded by the bridge itself. + if ((event.target as HTMLElement | null)?.tagName === 'INPUT') return; + if (event.key === 'Tab') { + event.preventDefault(); + this.cycle(event.shiftKey); + return; + } + this.validate(); + if (this.focusedWidget?.onKeyDown(event)) { + event.preventDefault(); + return; + } + for (const handler of this.globalHandlers) { + if (handler(event)) { + event.preventDefault(); + return; + } + } + } + + destroy(): void { + window.removeEventListener('keydown', this.onKeyDown); + } +} + +function isAttached(node: Container, root: Container): boolean { + let current: Container | null = node; + while (current) { + if (current === root) return true; + if (!current.visible) return false; + current = current.parent; + } + return false; +} diff --git a/src/ui/KeyboardBridge.ts b/src/ui/KeyboardBridge.ts new file mode 100644 index 00000000..b6cd1d26 --- /dev/null +++ b/src/ui/KeyboardBridge.ts @@ -0,0 +1,98 @@ +/** + * The one piece of DOM in the whole application: an invisible, off-screen `` that + * receives keyboard focus while a canvas text field is being edited. The browser supplies + * IME composition, the mobile on-screen keyboard, clipboard and selection semantics; the + * canvas field mirrors `value` / selection from here. Nothing visible is DOM. + */ +export interface TextEditTarget { + /** Called whenever value or selection changed. */ + onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void; + /** Called for navigation keys (Enter, Escape, Tab, arrows …). Return true to consume. */ + onBridgeKey(event: KeyboardEvent): boolean; + /** The bridge lost DOM focus (user clicked elsewhere, window blur). */ + onBridgeBlur(): void; +} + +export class KeyboardBridge { + private readonly input: HTMLInputElement; + private target: TextEditTarget | null = null; + private suppressBlur = false; + + constructor() { + const input = document.createElement('input'); + input.type = 'text'; + input.setAttribute('aria-hidden', 'true'); + input.autocomplete = 'off'; + input.spellcheck = false; + Object.assign(input.style, { + position: 'fixed', + left: '0', + top: '0', + width: '1px', + height: '1px', + opacity: '0', + border: '0', + padding: '0', + margin: '0', + outline: 'none', + pointerEvents: 'none', + zIndex: '-1', + } satisfies Partial); + document.body.appendChild(input); + this.input = input; + + const sync = () => this.target?.onBridgeInput(input.value, input.selectionStart ?? 0, input.selectionEnd ?? 0); + input.addEventListener('input', sync); + input.addEventListener('keyup', sync); + document.addEventListener('selectionchange', () => { + if (document.activeElement === input) sync(); + }); + input.addEventListener('keydown', (event) => { + if (!this.target) return; + if (this.target.onBridgeKey(event)) event.preventDefault(); + // Let the browser handle editing keys; sync on next tick to catch the result. + queueMicrotask(sync); + }); + input.addEventListener('blur', () => { + if (this.suppressBlur) return; + const target = this.target; + this.target = null; + target?.onBridgeBlur(); + }); + } + + get activeTarget(): TextEditTarget | null { + return this.target; + } + + /** Start editing: mirror the field's value into the input and grab DOM focus. */ + attach(target: TextEditTarget, value: string, selectionStart = value.length, selectionEnd = selectionStart): void { + this.suppressBlur = true; + this.target = target; + this.input.value = value; + this.input.focus({ preventScroll: true }); + this.input.setSelectionRange(selectionStart, selectionEnd); + this.suppressBlur = false; + } + + /** Keep the input positioned near the field so mobile browsers scroll sensibly. */ + moveTo(x: number, y: number): void { + this.input.style.left = `${Math.max(0, x)}px`; + this.input.style.top = `${Math.max(0, y)}px`; + } + + setValue(value: string, selectionStart = value.length, selectionEnd = selectionStart): void { + this.input.value = value; + if (document.activeElement === this.input) this.input.setSelectionRange(selectionStart, selectionEnd); + } + + selectAll(): void { + this.input.select(); + } + + detach(target: TextEditTarget): void { + if (this.target !== target) return; + this.target = null; + if (document.activeElement === this.input) this.input.blur(); + } +} diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts new file mode 100644 index 00000000..326fa4b9 --- /dev/null +++ b/src/ui/Theme.ts @@ -0,0 +1,70 @@ +import { TextStyle } from 'pixi.js'; + +/** Design tokens for the canvas UI. Widgets never hard-code colors or sizes. */ +export const Theme = { + font: 'Inter, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + monoFont: '"JetBrains Mono", Consolas, "Courier New", monospace', + fontSize: 13, + smallFontSize: 11, + rowHeight: 26, + controlHeight: 24, + padding: 8, + gap: 6, + radius: 4, + colors: { + background: 0x1b1d22, + panel: 0x24272e, + panelHeader: 0x2c3038, + border: 0x383c46, + control: 0x16181d, + controlHover: 0x1e2127, + controlActive: 0x2a2e37, + accent: 0x4c8dff, + accentText: 0xffffff, + text: 0xe6e8ee, + textMuted: 0x8a90a0, + textDisabled: 0x5a5f6d, + selection: 0x2b4a80, + override: 0xf0b13d, + danger: 0xe0553f, + viewport: 0x121316, + grid: 0x1f2126, + gizmo: 0x4c8dff, + }, +} as const; + +const styleCache = new Map(); + +export type TextStyleToken = 'body' | 'bodyBold' | 'muted' | 'small' | 'title' | 'mono' | 'accent'; + +/** Shared TextStyle instances so Pixi can cache glyph measurements per token. */ +export function textStyle(token: TextStyleToken = 'body'): TextStyle { + let style = styleCache.get(token); + if (style) return style; + const base = { fontFamily: Theme.font, fontSize: Theme.fontSize, fill: Theme.colors.text }; + switch (token) { + case 'body': + style = new TextStyle(base); + break; + case 'bodyBold': + style = new TextStyle({ ...base, fontWeight: '600' }); + break; + case 'muted': + style = new TextStyle({ ...base, fill: Theme.colors.textMuted }); + break; + case 'small': + style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.textMuted }); + break; + case 'title': + style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fontWeight: '700', letterSpacing: 0.5, fill: Theme.colors.textMuted }); + break; + case 'mono': + style = new TextStyle({ ...base, fontFamily: Theme.monoFont }); + break; + case 'accent': + style = new TextStyle({ ...base, fill: Theme.colors.accentText, fontWeight: '600' }); + break; + } + styleCache.set(token, style); + return style; +} diff --git a/src/ui/UIContext.ts b/src/ui/UIContext.ts new file mode 100644 index 00000000..6be7e5ab --- /dev/null +++ b/src/ui/UIContext.ts @@ -0,0 +1,30 @@ +import type { Application, Container } from 'pixi.js'; +import { FocusManager } from './FocusManager'; +import { KeyboardBridge } from './KeyboardBridge'; + +/** + * Services shared by all widgets: the focus manager, the keyboard bridge and the overlay + * layer where popups render above everything else. Created once by `initUI()`. + */ +export interface UIContext { + app: Application; + focus: FocusManager; + keyboard: KeyboardBridge; + /** Top-most container for popups, menus and drag previews. */ + overlay: Container; +} + +let context: UIContext | null = null; + +export function initUI(app: Application, root: Container, overlay: Container): UIContext { + context = { app, focus: new FocusManager(root), keyboard: new KeyboardBridge(), overlay }; + // Clicking the canvas must not steal DOM focus from the keyboard bridge input. + app.canvas.addEventListener('mousedown', (e) => e.preventDefault()); + app.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + return context; +} + +export function ui(): UIContext { + if (!context) throw new Error('UI not initialised – call initUI() first'); + return context; +} diff --git a/src/ui/Widget.ts b/src/ui/Widget.ts new file mode 100644 index 00000000..5c186b46 --- /dev/null +++ b/src/ui/Widget.ts @@ -0,0 +1,103 @@ +import { Container, Rectangle } from 'pixi.js'; + +export interface Size { + w: number; + h: number; +} + +/** + * Base class of every canvas UI element. A widget owns a rectangular area (`w` × `h`) in + * its parent's coordinate space. Parents call `resize()`; the widget then lays out its + * children in `layout()` and repaints in `redraw()`. `measure()` reports the size the + * widget would like to have, used by the stack layouts. + * + * Note: Pixi's own `width`/`height` scale the container, so sizes live in `w`/`h`. + */ +export abstract class Widget extends Container { + w = 0; + h = 0; + /** Grow factor inside a stack layout (0 = use measured size). */ + flex = 0; + minWidth = 0; + minHeight = 0; + /** Whether the widget participates in Tab focus cycling. */ + focusable = false; + focused = false; + enabled = true; + + private dirty = false; + + constructor() { + super(); + this.eventMode = 'static'; + } + + /** Preferred size. Defaults to the minimums; override for content-driven sizes. */ + measure(): Size { + return { w: this.minWidth, h: this.minHeight }; + } + + resize(w: number, h: number): void { + const changed = w !== this.w || h !== this.h; + this.w = w; + this.h = h; + this.hitArea = new Rectangle(0, 0, w, h); + if (changed) this.layout(); + this.redraw(); + } + + /** Position children. Called after a resize; must be cheap and idempotent. */ + layout(): void {} + + /** Repaint own graphics. Called after resize and by `invalidate()`. */ + redraw(): void {} + + /** Request a repaint at the end of the current frame (coalesced). */ + invalidate(): void { + if (this.dirty) return; + this.dirty = true; + queueMicrotask(() => { + this.dirty = false; + if (!this.destroyed) this.redraw(); + }); + } + + setEnabled(enabled: boolean): void { + if (this.enabled === enabled) return; + this.enabled = enabled; + this.eventMode = enabled ? 'static' : 'none'; + this.alpha = enabled ? 1 : 0.5; + this.invalidate(); + } + + // Focus hooks – called by the FocusManager. + onFocus(): void { + this.focused = true; + this.invalidate(); + } + + onBlur(): void { + this.focused = false; + this.invalidate(); + } + + /** Return true when the key was consumed. */ + onKeyDown(_event: KeyboardEvent): boolean { + return false; + } +} + +/** A concrete, empty widget: a sized container for custom drawing or manual child placement. */ +export class Box extends Widget { + override measure(): Size { + return { w: this.minWidth, h: this.minHeight }; + } +} + +/** Depth-first collection of focusable, visible widgets in tree order. */ +export function collectFocusables(root: Container, out: Widget[] = []): Widget[] { + if (!root.visible) return out; + if (root instanceof Widget && root.focusable && root.enabled) out.push(root); + for (const child of root.children) collectFocusables(child, out); + return out; +} diff --git a/src/ui/color.ts b/src/ui/color.ts new file mode 100644 index 00000000..3c6597fa --- /dev/null +++ b/src/ui/color.ts @@ -0,0 +1,63 @@ +/** Color helpers. Colors are 0xRRGGBB numbers throughout the application. */ + +export interface HSV { + h: number; // 0..360 + s: number; // 0..1 + v: number; // 0..1 +} + +export function toHex(color: number): string { + return '#' + (color & 0xffffff).toString(16).padStart(6, '0'); +} + +/** Parses "#rgb", "#rrggbb", "rrggbb". Returns null when invalid. */ +export function parseHex(text: string): number | null { + const t = text.trim().replace(/^#/, ''); + if (/^[0-9a-f]{3}$/i.test(t)) { + const [r, g, b] = t.split('') as [string, string, string]; + return parseInt(r + r + g + g + b + b, 16); + } + if (/^[0-9a-f]{6}$/i.test(t)) return parseInt(t, 16); + return null; +} + +export function rgbToHsv(color: number): HSV { + const r = ((color >> 16) & 0xff) / 255; + const g = ((color >> 8) & 0xff) / 255; + const b = (color & 0xff) / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + let h = 0; + if (d > 0) { + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + h *= 60; + if (h < 0) h += 360; + } + return { h, s: max === 0 ? 0 : d / max, v: max }; +} + +export function hsvToRgb({ h, s, v }: HSV): number { + const c = v * s; + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); + const m = v - c; + let r = 0, g = 0, b = 0; + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + const to = (n: number) => Math.round((n + m) * 255); + return (to(r) << 16) | (to(g) << 8) | to(b); +} + +/** Perceived brightness 0..1, used to pick readable text on a swatch. */ +export function luminance(color: number): number { + const r = (color >> 16) & 0xff; + const g = (color >> 8) & 0xff; + const b = color & 0xff; + return (0.299 * r + 0.587 * g + 0.114 * b) / 255; +} diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 00000000..7a613e8a --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,16 @@ +export { Widget, Box, type Size } from './Widget'; +export { HStack, VStack, Spacer, Stack } from './layout/Stack'; +export { Theme, textStyle } from './Theme'; +export { initUI, ui, type UIContext } from './UIContext'; +export { Label } from './widgets/Label'; +export { Button } from './widgets/Button'; +export { Checkbox } from './widgets/Checkbox'; +export { Panel } from './widgets/Panel'; +export { Splitter } from './widgets/Splitter'; +export { ScrollContainer } from './widgets/ScrollContainer'; +export { ListView, type ListItem } from './widgets/ListView'; +export { Popup, Menu, Dialog, type MenuItem } from './widgets/Popup'; +export { TextField } from './widgets/TextField'; +export { NumberField } from './widgets/NumberField'; +export { ColorField, ColorPicker } from './widgets/ColorField'; +export * from './color'; diff --git a/src/ui/layout/Stack.ts b/src/ui/layout/Stack.ts new file mode 100644 index 00000000..ba28991a --- /dev/null +++ b/src/ui/layout/Stack.ts @@ -0,0 +1,114 @@ +import { type Size, Widget } from '../Widget'; + +export interface StackOptions { + gap?: number; + padding?: number; + /** Cross-axis alignment of children that are smaller than the stack. */ + align?: 'stretch' | 'start' | 'center' | 'end'; +} + +/** + * Lays out child widgets in a row or column. Children with `flex > 0` share the space + * left over after fixed children got their measured size (weighted by `flex`). + * Non-widget children are ignored by the layout but still rendered. + */ +export abstract class Stack extends Widget { + gap: number; + padding: number; + align: NonNullable; + + constructor(protected readonly axis: 'x' | 'y', options: StackOptions = {}) { + super(); + this.gap = options.gap ?? 0; + this.padding = options.padding ?? 0; + this.align = options.align ?? 'stretch'; + } + + get items(): Widget[] { + return this.children.filter((c): c is Widget => c instanceof Widget && c.visible); + } + + add(widget: T, flex = 0): T { + widget.flex = flex; + this.addChild(widget); + return widget; + } + + override measure(): Size { + let main = 0; + let cross = 0; + const items = this.items; + for (const item of items) { + const size = item.measure(); + main += this.axis === 'x' ? size.w : size.h; + cross = Math.max(cross, this.axis === 'x' ? size.h : size.w); + } + main += Math.max(0, items.length - 1) * this.gap + this.padding * 2; + cross += this.padding * 2; + const w = this.axis === 'x' ? main : cross; + const h = this.axis === 'x' ? cross : main; + return { w: Math.max(w, this.minWidth), h: Math.max(h, this.minHeight) }; + } + + override layout(): void { + const items = this.items; + const mainTotal = (this.axis === 'x' ? this.w : this.h) - this.padding * 2; + const crossTotal = (this.axis === 'x' ? this.h : this.w) - this.padding * 2; + const sizes = items.map((item) => item.measure()); + let fixed = Math.max(0, items.length - 1) * this.gap; + let flexSum = 0; + items.forEach((item, i) => { + if (item.flex > 0) flexSum += item.flex; + else fixed += this.axis === 'x' ? sizes[i]!.w : sizes[i]!.h; + }); + const free = Math.max(0, mainTotal - fixed); + + let cursor = this.padding; + items.forEach((item, i) => { + const size = sizes[i]!; + const mainSize = item.flex > 0 ? (free * item.flex) / flexSum : this.axis === 'x' ? size.w : size.h; + const preferredCross = this.axis === 'x' ? size.h : size.w; + const crossSize = this.align === 'stretch' ? crossTotal : Math.min(preferredCross, crossTotal); + let crossPos = this.padding; + if (this.align === 'center') crossPos += (crossTotal - crossSize) / 2; + else if (this.align === 'end') crossPos += crossTotal - crossSize; + + if (this.axis === 'x') { + item.position.set(Math.round(cursor), Math.round(crossPos)); + item.resize(Math.round(mainSize), Math.round(crossSize)); + } else { + item.position.set(Math.round(crossPos), Math.round(cursor)); + item.resize(Math.round(crossSize), Math.round(mainSize)); + } + cursor += mainSize + this.gap; + }); + } + + /** Re-run the layout with the current size (after children were added/removed/hidden). */ + relayout(): void { + this.layout(); + this.redraw(); + } +} + +export class VStack extends Stack { + constructor(options: StackOptions = {}) { + super('y', options); + } +} + +export class HStack extends Stack { + constructor(options: StackOptions = {}) { + super('x', options); + } +} + +/** Empty widget used as a flexible spacer or fixed-size gap. */ +export class Spacer extends Widget { + constructor(size = 0) { + super(); + this.minWidth = size; + this.minHeight = size; + this.eventMode = 'none'; + } +} diff --git a/src/ui/widgets/Button.ts b/src/ui/widgets/Button.ts new file mode 100644 index 00000000..e08fa815 --- /dev/null +++ b/src/ui/widgets/Button.ts @@ -0,0 +1,96 @@ +import { type FederatedPointerEvent, Graphics, Text, CanvasTextMetrics } from 'pixi.js'; +import { Theme, textStyle } from '../Theme'; +import { type Size, Widget } from '../Widget'; + +export interface ButtonOptions { + /** Visual weight: 'primary' uses the accent color, 'ghost' has no background until hovered. */ + variant?: 'default' | 'primary' | 'ghost' | 'danger'; + /** Fixed width; otherwise measured from the label. */ + width?: number; + onClick?: () => void; +} + +export class Button extends Widget { + private readonly bg = new Graphics(); + private readonly labelText: Text; + private hovered = false; + private pressed = false; + toggled = false; + variant: NonNullable; + onClick?: () => void; + + constructor(text: string, options: ButtonOptions = {}) { + super(); + this.variant = options.variant ?? 'default'; + this.onClick = options.onClick; + this.focusable = true; + this.cursor = 'pointer'; + this.minHeight = Theme.controlHeight; + if (options.width) this.minWidth = options.width; + this.labelText = new Text({ text, style: textStyle(this.variant === 'primary' ? 'accent' : 'body') }); + this.addChild(this.bg, this.labelText); + + this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); + this.on('pointerout', () => { this.hovered = false; this.pressed = false; this.invalidate(); }); + this.on('pointerdown', (e: FederatedPointerEvent) => { + if (e.button !== 0) return; + this.pressed = true; + this.invalidate(); + }); + this.on('pointerup', (e: FederatedPointerEvent) => { + if (e.button !== 0 || !this.pressed) return; + this.pressed = false; + this.invalidate(); + this.onClick?.(); + }); + this.on('pointerupoutside', () => { this.pressed = false; this.invalidate(); }); + } + + get text(): string { + return this.labelText.text; + } + + setText(text: string): void { + this.labelText.text = text; + this.invalidate(); + } + + setToggled(toggled: boolean): void { + if (this.toggled === toggled) return; + this.toggled = toggled; + this.invalidate(); + } + + override measure(): Size { + const metrics = CanvasTextMetrics.measureText(this.labelText.text, this.labelText.style); + return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + Theme.padding * 2), h: Math.max(this.minHeight, Theme.controlHeight) }; + } + + override onKeyDown(event: KeyboardEvent): boolean { + if (event.key === 'Enter' || event.key === ' ') { + this.onClick?.(); + return true; + } + return false; + } + + override redraw(): void { + const c = Theme.colors; + let fill: number | null = c.control; + let border: number | null = c.border; + if (this.variant === 'primary') { fill = c.accent; border = null; } + if (this.variant === 'danger') { fill = c.danger; border = null; } + if (this.variant === 'ghost') { fill = null; border = null; } + if (this.toggled) { fill = c.accent; border = null; } + if (this.hovered && !this.toggled) fill = this.variant === 'default' || this.variant === 'ghost' ? c.controlHover : fill; + if (this.pressed) fill = this.variant === 'default' || this.variant === 'ghost' ? c.controlActive : fill; + + this.bg.clear(); + if (fill !== null) this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(fill); + if (border !== null || this.focused) { + this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: this.focused ? c.accent : border ?? c.border }); + } + this.labelText.style = textStyle(this.toggled || this.variant === 'primary' || this.variant === 'danger' ? 'accent' : 'body'); + this.labelText.position.set(Math.round((this.w - this.labelText.width) / 2), Math.round((this.h - this.labelText.height) / 2)); + } +} diff --git a/src/ui/widgets/Checkbox.ts b/src/ui/widgets/Checkbox.ts new file mode 100644 index 00000000..40e760cd --- /dev/null +++ b/src/ui/widgets/Checkbox.ts @@ -0,0 +1,59 @@ +import { Graphics } from 'pixi.js'; +import { Theme } from '../Theme'; +import { type Size, Widget } from '../Widget'; + +/** A square toggle used for boolean properties. */ +export class Checkbox extends Widget { + private readonly box = new Graphics(); + private hovered = false; + onChange?: (checked: boolean) => void; + + constructor(public checked = false) { + super(); + this.focusable = true; + this.cursor = 'pointer'; + this.minWidth = Theme.controlHeight; + this.minHeight = Theme.controlHeight; + this.addChild(this.box); + this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); + this.on('pointerout', () => { this.hovered = false; this.invalidate(); }); + this.on('pointertap', () => this.toggle()); + } + + setChecked(checked: boolean): void { + if (this.checked === checked) return; + this.checked = checked; + this.invalidate(); + } + + toggle(): void { + this.checked = !this.checked; + this.invalidate(); + this.onChange?.(this.checked); + } + + override measure(): Size { + return { w: this.minWidth, h: this.minHeight }; + } + + override onKeyDown(event: KeyboardEvent): boolean { + if (event.key === ' ' || event.key === 'Enter') { + this.toggle(); + return true; + } + return false; + } + + override redraw(): void { + const c = Theme.colors; + const size = 16; + const x = 0; + const y = Math.round((this.h - size) / 2); + this.box.clear(); + this.box.roundRect(x, y, size, size, 3).fill(this.checked ? c.accent : this.hovered ? c.controlHover : c.control); + this.box.roundRect(x + 0.5, y + 0.5, size - 1, size - 1, 3).stroke({ width: 1, color: this.focused ? c.accent : c.border }); + if (this.checked) { + this.box.moveTo(x + 4, y + 8.5).lineTo(x + 7, y + 11.5).lineTo(x + 12.5, y + 5).stroke({ width: 2, color: c.accentText }); + } + } +} diff --git a/src/ui/widgets/ColorField.ts b/src/ui/widgets/ColorField.ts new file mode 100644 index 00000000..0b11def3 --- /dev/null +++ b/src/ui/widgets/ColorField.ts @@ -0,0 +1,229 @@ +import { CanvasSource, type FederatedPointerEvent, Graphics, Sprite, Texture } from 'pixi.js'; +import { hsvToRgb, parseHex, rgbToHsv, toHex, type HSV } from '../color'; +import { HStack } from '../layout/Stack'; +import { Theme } from '../Theme'; +import { Box, type Size, Widget } from '../Widget'; +import { Popup } from './Popup'; +import { TextField } from './TextField'; + +export interface ColorFieldOptions { + onInput?: (color: number) => void; + onChange?: (color: number) => void; +} + +/** Swatch + hex input. Clicking the swatch opens an HSV picker popup drawn in the canvas. */ +export class ColorField extends Widget { + private color: number; + private readonly swatch = new Graphics(); + private readonly hex: TextField; + private readonly row: HStack; + private picker: ColorPicker | null = null; + onInput?: (color: number) => void; + onChange?: (color: number) => void; + + constructor(color = 0x000000, options: ColorFieldOptions = {}) { + super(); + this.color = color; + this.onInput = options.onInput; + this.onChange = options.onChange; + this.minHeight = Theme.controlHeight; + this.minWidth = 120; + this.row = new HStack({ gap: Theme.gap, align: 'stretch' }); + const swatchHolder = new SwatchButton(this.swatch, () => this.openPicker()); + this.row.add(swatchHolder); + this.hex = new TextField(toHex(color), { + style: 'mono', + onInput: (text) => { + const parsed = parseHex(text); + if (parsed !== null) { + this.color = parsed; + this.invalidate(); + this.onInput?.(parsed); + } + }, + onChange: (text) => { + const parsed = parseHex(text); + if (parsed === null) { + this.hex.setValue(toHex(this.color)); + return; + } + this.color = parsed; + this.hex.setValue(toHex(parsed)); + this.invalidate(); + this.onChange?.(parsed); + }, + }); + this.row.add(this.hex, 1); + this.addChild(this.row); + } + + get value(): number { + return this.color; + } + + setValue(color: number): void { + if (color === this.color) return; + this.color = color; + this.hex.setValue(toHex(color)); + this.picker?.setColor(color); + this.invalidate(); + } + + private openPicker(): void { + const global = this.getGlobalPosition(); + const picker = new ColorPicker(this.color); + this.picker = picker; + picker.onInput = (c) => { + this.color = c; + this.hex.setValue(toHex(c)); + this.invalidate(); + this.onInput?.(c); + }; + picker.onClose = () => { + this.picker = null; + if (picker.startColor !== this.color) this.onChange?.(this.color); + }; + picker.open(global.x, global.y + this.h + 4); + } + + override measure(): Size { + return { w: this.minWidth, h: this.minHeight }; + } + + override layout(): void { + this.row.resize(this.w, this.h); + } + + override redraw(): void { + this.swatch.clear(); + const size = this.h; + this.swatch.roundRect(0, 0, size, size, Theme.radius).fill(this.color); + this.swatch.roundRect(0.5, 0.5, size - 1, size - 1, Theme.radius).stroke({ width: 1, color: Theme.colors.border }); + } +} + +class SwatchButton extends Widget { + constructor(graphics: Graphics, onClick: () => void) { + super(); + this.cursor = 'pointer'; + this.addChild(graphics); + this.on('pointertap', onClick); + } + + override measure(): Size { + return { w: Theme.controlHeight, h: Theme.controlHeight }; + } +} + +const SV_SIZE = 168; +const HUE_HEIGHT = 14; + +/** + * HSV picker: saturation/value square + hue bar. Gradients are rasterised once into a + * small 2D canvas and shown as textures (Pixi has no 2D gradients worth the trouble). + */ +export class ColorPicker extends Popup { + readonly startColor: number; + private hsv: HSV; + private readonly svCanvas = document.createElement('canvas'); + private readonly svSource: CanvasSource; + private readonly svSprite: Sprite; + private readonly hueSprite: Sprite; + private readonly marks = new Graphics(); + private readonly body = new Box(); + private dragging: 'sv' | 'hue' | null = null; + onInput?: (color: number) => void; + + constructor(color: number) { + super({ padding: Theme.padding }); + this.startColor = color; + this.hsv = rgbToHsv(color); + + this.svCanvas.width = SV_SIZE; + this.svCanvas.height = SV_SIZE; + this.svSource = new CanvasSource({ resource: this.svCanvas }); + this.svSprite = new Sprite(new Texture({ source: this.svSource })); + + const hueCanvas = document.createElement('canvas'); + hueCanvas.width = SV_SIZE; + hueCanvas.height = HUE_HEIGHT; + const ctx = hueCanvas.getContext('2d')!; + const grad = ctx.createLinearGradient(0, 0, SV_SIZE, 0); + for (let i = 0; i <= 6; i++) grad.addColorStop(i / 6, `hsl(${i * 60}, 100%, 50%)`); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, SV_SIZE, HUE_HEIGHT); + this.hueSprite = new Sprite(Texture.from(hueCanvas)); + this.hueSprite.position.set(0, SV_SIZE + Theme.gap); + + this.body.minWidth = SV_SIZE; + this.body.minHeight = SV_SIZE + Theme.gap + HUE_HEIGHT; + this.body.addChild(this.svSprite, this.hueSprite, this.marks); + this.body.on('pointerdown', (e: FederatedPointerEvent) => { + const local = this.body.toLocal(e.global); + this.dragging = local.y <= SV_SIZE ? 'sv' : 'hue'; + this.applyPointer(local.x, local.y); + }); + this.body.on('globalpointermove', (e: FederatedPointerEvent) => { + if (!this.dragging) return; + const local = this.body.toLocal(e.global); + this.applyPointer(local.x, local.y); + }); + const stop = () => { this.dragging = null; }; + this.body.on('pointerup', stop); + this.body.on('pointerupoutside', stop); + this.content.add(this.body); + this.renderSv(); + } + + setColor(color: number): void { + this.hsv = rgbToHsv(color); + this.renderSv(); + this.drawMarks(); + } + + private applyPointer(x: number, y: number): void { + if (this.dragging === 'sv') { + this.hsv.s = Math.max(0, Math.min(1, x / SV_SIZE)); + this.hsv.v = 1 - Math.max(0, Math.min(1, y / SV_SIZE)); + } else { + this.hsv.h = Math.max(0, Math.min(359.99, (x / SV_SIZE) * 360)); + this.renderSv(); + } + this.drawMarks(); + this.onInput?.(hsvToRgb(this.hsv)); + } + + private renderSv(): void { + const ctx = this.svCanvas.getContext('2d')!; + ctx.fillStyle = `hsl(${this.hsv.h}, 100%, 50%)`; + ctx.fillRect(0, 0, SV_SIZE, SV_SIZE); + const white = ctx.createLinearGradient(0, 0, SV_SIZE, 0); + white.addColorStop(0, 'rgba(255,255,255,1)'); + white.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = white; + ctx.fillRect(0, 0, SV_SIZE, SV_SIZE); + const black = ctx.createLinearGradient(0, 0, 0, SV_SIZE); + black.addColorStop(0, 'rgba(0,0,0,0)'); + black.addColorStop(1, 'rgba(0,0,0,1)'); + ctx.fillStyle = black; + ctx.fillRect(0, 0, SV_SIZE, SV_SIZE); + this.svSource.update(); + } + + private drawMarks(): void { + const { h, s, v } = this.hsv; + this.marks.clear(); + const x = s * SV_SIZE; + const y = (1 - v) * SV_SIZE; + this.marks.circle(x, y, 5).stroke({ width: 2, color: 0xffffff }); + this.marks.circle(x, y, 6).stroke({ width: 1, color: 0x000000, alpha: 0.6 }); + const hx = (h / 360) * SV_SIZE; + const hy = SV_SIZE + Theme.gap; + this.marks.rect(hx - 2, hy - 1, 4, HUE_HEIGHT + 2).stroke({ width: 2, color: 0xffffff }); + } + + override layout(): void { + super.layout(); + this.drawMarks(); + } +} diff --git a/src/ui/widgets/Label.ts b/src/ui/widgets/Label.ts new file mode 100644 index 00000000..fe25152b --- /dev/null +++ b/src/ui/widgets/Label.ts @@ -0,0 +1,73 @@ +import { CanvasTextMetrics, Text } from 'pixi.js'; +import { Theme, textStyle, type TextStyleToken } from '../Theme'; +import { type Size, Widget } from '../Widget'; + +export interface LabelOptions { + style?: TextStyleToken; + align?: 'left' | 'center' | 'right'; + /** Horizontal padding inside the label bounds. */ + padding?: number; +} + +/** Single-line text that truncates with an ellipsis when narrower than its content. */ +export class Label extends Widget { + private readonly textNode: Text; + private fullText: string; + private readonly align: NonNullable; + private readonly padding: number; + + constructor(text = '', options: LabelOptions = {}) { + super(); + this.eventMode = 'none'; + this.fullText = text; + this.align = options.align ?? 'left'; + this.padding = options.padding ?? 0; + this.textNode = new Text({ text, style: textStyle(options.style ?? 'body') }); + this.addChild(this.textNode); + this.minHeight = Theme.rowHeight; + } + + get text(): string { + return this.fullText; + } + + setText(text: string): void { + if (text === this.fullText) return; + this.fullText = text; + this.invalidate(); + } + + setStyle(token: TextStyleToken): void { + this.textNode.style = textStyle(token); + this.invalidate(); + } + + override measure(): Size { + const metrics = CanvasTextMetrics.measureText(this.fullText, this.textNode.style); + return { w: Math.max(this.minWidth, Math.ceil(metrics.width) + this.padding * 2), h: Math.max(this.minHeight, Math.ceil(metrics.height)) }; + } + + override redraw(): void { + const available = this.w - this.padding * 2; + this.textNode.text = truncate(this.fullText, this.textNode.style, available); + const tw = this.textNode.width; + let x = this.padding; + if (this.align === 'center') x = (this.w - tw) / 2; + else if (this.align === 'right') x = this.w - this.padding - tw; + this.textNode.position.set(Math.round(x), Math.round((this.h - this.textNode.height) / 2)); + } +} + +/** Cuts `text` to fit `maxWidth` in the given style, appending "…" when shortened. */ +export function truncate(text: string, style: Text['style'], maxWidth: number): string { + if (maxWidth <= 0) return ''; + if (CanvasTextMetrics.measureText(text, style).width <= maxWidth) return text; + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (CanvasTextMetrics.measureText(text.slice(0, mid) + '…', style).width <= maxWidth) lo = mid; + else hi = mid - 1; + } + return lo === 0 ? '' : text.slice(0, lo) + '…'; +} diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts new file mode 100644 index 00000000..2ea126b9 --- /dev/null +++ b/src/ui/widgets/ListView.ts @@ -0,0 +1,151 @@ +import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { Theme, textStyle } from '../Theme'; +import { type Size, Widget } from '../Widget'; +import { truncate } from './Label'; + +export interface ListItem { + id: string; + label: string; + /** Small secondary text on the right (e.g. the asset type). */ + hint?: string; + /** Left indent in levels (hierarchies). */ + depth?: number; + /** Optional marker color drawn as a small square before the label. */ + swatch?: number; +} + +/** + * Single-select list of rows. Rows are rebuilt from `setItems()`; selection is by id so + * it survives rebuilds. Emits select, activate (double-click / Enter) and context events. + */ +export class ListView extends Widget { + private items: ListItem[] = []; + private rows: Row[] = []; + private selectedId: string | null = null; + onSelect?: (id: string | null) => void; + onActivate?: (id: string) => void; + onContextMenu?: (id: string, globalX: number, globalY: number) => void; + rowHeight = Theme.rowHeight; + emptyText = 'Nothing here yet'; + private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') }); + + constructor() { + super(); + this.focusable = true; + this.addChild(this.emptyLabel); + this.on('pointerdown', (e: FederatedPointerEvent) => { + if (e.target === this) this.select(null); + }); + } + + get selected(): string | null { + return this.selectedId; + } + + setItems(items: ListItem[]): void { + this.items = items; + for (const row of this.rows) row.destroy({ children: true }); + this.rows = items.map((item, index) => { + const row = new Row(item, this.rowHeight); + row.on('pointerdown', (e: FederatedPointerEvent) => { + if (e.button === 2) { + this.select(item.id); + this.onContextMenu?.(item.id, e.global.x, e.global.y); + } else { + this.select(item.id); + if (e.detail === 2) this.onActivate?.(item.id); + } + }); + row.position.set(0, index * this.rowHeight); + this.addChild(row); + return row; + }); + if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null; + this.layout(); + this.redraw(); + } + + select(id: string | null, silent = false): void { + if (id === this.selectedId) return; + this.selectedId = id; + this.redraw(); + if (!silent) this.onSelect?.(id); + } + + override measure(): Size { + return { w: this.minWidth, h: Math.max(this.minHeight, this.items.length * this.rowHeight) }; + } + + override onKeyDown(event: KeyboardEvent): boolean { + const index = this.items.findIndex((i) => i.id === this.selectedId); + if (event.key === 'ArrowDown') { + const next = this.items[Math.min(this.items.length - 1, index + 1)]; + if (next) this.select(next.id); + return true; + } + if (event.key === 'ArrowUp') { + const prev = this.items[Math.max(0, index - 1)]; + if (prev) this.select(prev.id); + return true; + } + if (event.key === 'Enter' && this.selectedId) { + this.onActivate?.(this.selectedId); + return true; + } + return false; + } + + override layout(): void { + for (const row of this.rows) row.resize(this.w, this.rowHeight); + } + + override redraw(): void { + for (const row of this.rows) row.setSelected(row.item.id === this.selectedId, this.focused); + this.emptyLabel.text = this.items.length === 0 ? this.emptyText : ''; + this.emptyLabel.position.set(Theme.padding, Math.round((this.rowHeight - this.emptyLabel.height) / 2)); + } +} + +class Row extends Widget { + private readonly bg = new Graphics(); + private readonly labelText = new Text({ text: '', style: textStyle('body') }); + private readonly hint = new Text({ text: '', style: textStyle('small') }); + private hovered = false; + private selected = false; + private listFocused = false; + + constructor(readonly item: ListItem, height: number) { + super(); + this.cursor = 'pointer'; + this.minHeight = height; + this.addChild(this.bg, this.labelText, this.hint); + this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); + this.on('pointerout', () => { this.hovered = false; this.invalidate(); }); + } + + setSelected(selected: boolean, listFocused: boolean): void { + if (selected === this.selected && listFocused === this.listFocused) return; + this.selected = selected; + this.listFocused = listFocused; + this.invalidate(); + } + + override redraw(): void { + const c = Theme.colors; + const indent = Theme.padding + (this.item.depth ?? 0) * 14; + this.bg.clear(); + if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive); + else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover); + let x = indent; + if (this.item.swatch !== undefined) { + this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch); + this.bg.roundRect(x + 0.5, (this.h - 12) / 2 + 0.5, 11, 11, 2).stroke({ width: 1, color: c.border }); + x += 18; + } + this.hint.text = this.item.hint ?? ''; + const hintW = this.hint.text ? this.hint.width + Theme.padding : 0; + this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding); + this.labelText.position.set(x, Math.round((this.h - this.labelText.height) / 2)); + this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), Math.round((this.h - this.hint.height) / 2)); + } +} diff --git a/src/ui/widgets/NumberField.ts b/src/ui/widgets/NumberField.ts new file mode 100644 index 00000000..cc2e7138 --- /dev/null +++ b/src/ui/widgets/NumberField.ts @@ -0,0 +1,127 @@ +import type { FederatedPointerEvent } from 'pixi.js'; +import { ui } from '../UIContext'; +import { TextField, type TextFieldOptions } from './TextField'; + +export interface NumberFieldOptions extends Omit { + min?: number; + max?: number; + /** Increment per pixel while scrubbing and for arrow keys. */ + step?: number; + /** Fires continuously while scrubbing or typing a valid number. */ + onInput?: (value: number) => void; + /** Fires once when a scrub ends or an edit is committed. */ + onChange?: (value: number) => void; +} + +/** + * Numeric input: click to type, drag horizontally to scrub (Shift = ×10, Alt = ×0.1), + * arrow keys step. Values are clamped to min/max and formatted with the step's precision. + */ +export class NumberField extends TextField { + private numeric: number; + private readonly min: number; + private readonly max: number; + private readonly step: number; + private readonly decimals: number; + private scrub: { startX: number; startValue: number; moved: boolean; event: FederatedPointerEvent } | null = null; + onNumberInput?: (value: number) => void; + onNumberChange?: (value: number) => void; + + constructor(value = 0, options: NumberFieldOptions = {}) { + super('', { placeholder: options.placeholder, style: options.style ?? 'mono', width: options.width }); + this.min = options.min ?? -Infinity; + this.max = options.max ?? Infinity; + this.step = options.step ?? 1; + this.decimals = Math.max(0, Math.min(6, Math.ceil(-Math.log10(this.step)))); + this.onNumberInput = options.onInput; + this.onNumberChange = options.onChange; + this.numeric = this.clamp(value); + this.valueText = this.format(this.numeric); + this.cursor = 'ew-resize'; + + this.onInput = (text) => { + const n = Number(text.trim()); + if (text.trim() !== '' && Number.isFinite(n)) { + this.numeric = this.clamp(n); + this.onNumberInput?.(this.numeric); + } + }; + this.onChange = (text) => { + const n = Number(text.trim()); + this.numeric = this.clamp(Number.isFinite(n) && text.trim() !== '' ? n : this.numeric); + this.valueText = this.format(this.numeric); + this.invalidate(); + this.onNumberChange?.(this.numeric); + }; + + this.on('globalpointermove', (e: FederatedPointerEvent) => { + if (!this.scrub) return; + const dx = e.global.x - this.scrub.startX; + if (!this.scrub.moved && Math.abs(dx) < 3) return; + this.scrub.moved = true; + const factor = e.shiftKey ? 10 : e.altKey ? 0.1 : 1; + this.setNumber(this.scrub.startValue + dx * this.step * factor); + this.onNumberInput?.(this.numeric); + }); + const finish = () => { + const scrub = this.scrub; + this.scrub = null; + if (!scrub) return; + if (scrub.moved) { + this.onNumberChange?.(this.numeric); + } else { + // A plain click: enter text editing with everything selected. + ui().focus.focus(this); + this.selectAll(); + } + }; + this.on('pointerup', finish); + this.on('pointerupoutside', finish); + } + + get number(): number { + return this.numeric; + } + + setNumber(value: number): void { + this.numeric = this.clamp(value); + if (!this.isEditing) { + this.valueText = this.format(this.numeric); + this.invalidate(); + } + } + + protected override handlePointerDown(e: FederatedPointerEvent): void { + if (e.button !== 0) return; + if (this.isEditing) { + super.handlePointerDown(e); + return; + } + e.stopPropagation(); + this.scrub = { startX: e.global.x, startValue: this.numeric, moved: false, event: e }; + } + + override onBridgeKey(event: KeyboardEvent): boolean { + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + const dir = event.key === 'ArrowUp' ? 1 : -1; + const factor = event.shiftKey ? 10 : event.altKey ? 0.1 : 1; + this.numeric = this.clamp(this.numeric + dir * this.step * factor); + this.valueText = this.format(this.numeric); + ui().keyboard.setValue(this.valueText, 0, this.valueText.length); + this.selectAll(); + this.onNumberInput?.(this.numeric); + return true; + } + return super.onBridgeKey(event); + } + + private clamp(value: number): number { + if (!Number.isFinite(value)) return this.numeric ?? 0; + return Math.max(this.min, Math.min(this.max, value)); + } + + private format(value: number): string { + const rounded = Number(value.toFixed(this.decimals)); + return String(rounded); + } +} diff --git a/src/ui/widgets/Panel.ts b/src/ui/widgets/Panel.ts new file mode 100644 index 00000000..cbf55e43 --- /dev/null +++ b/src/ui/widgets/Panel.ts @@ -0,0 +1,69 @@ +import { Graphics } from 'pixi.js'; +import { HStack, VStack } from '../layout/Stack'; +import { Theme } from '../Theme'; +import { type Size, Widget } from '../Widget'; +import { Label } from './Label'; + +export interface PanelOptions { + title?: string; + padding?: number; + gap?: number; + /** Draw background and border (off for transparent grouping). */ + chrome?: boolean; +} + +/** + * A titled box with a vertical content stack. The optional header row can hold extra + * widgets (e.g. a "+" button) via `header.add()`. + */ +export class Panel extends Widget { + private readonly bg = new Graphics(); + readonly header: HStack; + readonly content: VStack; + private readonly titleLabel: Label | null = null; + private readonly chrome: boolean; + + constructor(options: PanelOptions = {}) { + super(); + this.chrome = options.chrome ?? true; + this.addChild(this.bg); + this.header = new HStack({ padding: 0, gap: Theme.gap, align: 'center' }); + this.header.minHeight = Theme.rowHeight + 4; + if (options.title !== undefined) { + this.titleLabel = new Label(options.title.toUpperCase(), { style: 'title', padding: Theme.padding }); + this.header.add(this.titleLabel, 1); + this.addChild(this.header); + } + this.content = new VStack({ padding: options.padding ?? Theme.padding, gap: options.gap ?? Theme.gap }); + this.addChild(this.content); + } + + setTitle(title: string): void { + this.titleLabel?.setText(title.toUpperCase()); + } + + override measure(): Size { + const c = this.content.measure(); + const headerH = this.titleLabel ? this.header.minHeight : 0; + return { w: Math.max(this.minWidth, c.w), h: Math.max(this.minHeight, c.h + headerH) }; + } + + override layout(): void { + const headerH = this.titleLabel ? this.header.minHeight : 0; + if (this.titleLabel) { + this.header.position.set(0, 0); + this.header.resize(this.w, headerH); + } + this.content.position.set(0, headerH); + this.content.resize(this.w, Math.max(0, this.h - headerH)); + } + + override redraw(): void { + this.bg.clear(); + if (!this.chrome) return; + const c = Theme.colors; + this.bg.rect(0, 0, this.w, this.h).fill(c.panel); + if (this.titleLabel) this.bg.rect(0, 0, this.w, this.header.minHeight).fill(c.panelHeader); + this.bg.rect(0.5, 0.5, this.w - 1, this.h - 1).stroke({ width: 1, color: c.border }); + } +} diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts new file mode 100644 index 00000000..65bb3e5d --- /dev/null +++ b/src/ui/widgets/Popup.ts @@ -0,0 +1,135 @@ +import { type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js'; +import { HStack, VStack } from '../layout/Stack'; +import { Theme } from '../Theme'; +import { ui } from '../UIContext'; +import { type Size, Widget } from '../Widget'; +import { Button } from './Button'; +import { Label } from './Label'; + +/** + * A floating box on the overlay layer with a click-away backdrop. Base for menus, + * pickers and dialogs. `open()` positions it at global coordinates and keeps it inside + * the screen; `close()` removes it and calls `onClose`. + */ +export class Popup extends Widget { + private readonly backdrop = new Graphics(); + private readonly bg = new Graphics(); + readonly content: VStack; + onClose?: () => void; + private modal = false; + + constructor(options: { padding?: number; gap?: number; modal?: boolean } = {}) { + super(); + this.modal = options.modal ?? false; + this.content = new VStack({ padding: options.padding ?? Theme.padding, gap: options.gap ?? Theme.gap }); + this.addChild(this.bg, this.content); + this.backdrop.eventMode = 'static'; + this.backdrop.on('pointerdown', (e: FederatedPointerEvent) => { + e.stopPropagation(); + if (!this.modal) this.close(); + }); + this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation()); + } + + get isOpen(): boolean { + return this.parent !== null; + } + + open(globalX: number, globalY: number): void { + const { overlay, app, focus } = ui(); + const screen = app.screen; + this.backdrop.clear().rect(0, 0, screen.width, screen.height).fill({ color: 0x000000, alpha: this.modal ? 0.4 : 0.001 }); + this.backdrop.hitArea = new Rectangle(0, 0, screen.width, screen.height); + overlay.addChild(this.backdrop, this); + const size = this.measure(); + this.resize(size.w, size.h); + const x = Math.max(0, Math.min(screen.width - size.w, globalX)); + const y = Math.max(0, Math.min(screen.height - size.h, globalY)); + this.position.set(Math.round(x), Math.round(y)); + focus.blur(); + window.addEventListener('keydown', this.onEscape, { capture: true }); + } + + /** Open centered on screen (dialogs). */ + openCentered(): void { + const screen = ui().app.screen; + const size = this.measure(); + this.open((screen.width - size.w) / 2, (screen.height - size.h) / 2); + } + + close(): void { + if (!this.isOpen) return; + window.removeEventListener('keydown', this.onEscape, { capture: true }); + ui().overlay.removeChild(this.backdrop, this); + this.onClose?.(); + } + + private readonly onEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.stopPropagation(); + this.close(); + } + }; + + override measure(): Size { + const c = this.content.measure(); + return { w: Math.max(this.minWidth, c.w), h: Math.max(this.minHeight, c.h) }; + } + + override layout(): void { + this.content.resize(this.w, this.h); + } + + override redraw(): void { + const c = Theme.colors; + this.bg.clear(); + this.bg.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); + this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.panelHeader); + this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: c.border }); + } +} + +export interface MenuItem { + label: string; + onSelect: () => void; + danger?: boolean; + disabled?: boolean; +} + +/** A vertical list of clickable actions. */ +export class Menu extends Popup { + constructor(items: MenuItem[]) { + super({ padding: 4, gap: 2 }); + this.minWidth = 160; + for (const item of items) { + const button = new Button(item.label, { variant: item.danger ? 'danger' : 'ghost' }); + button.setEnabled(!item.disabled); + button.onClick = () => { + this.close(); + item.onSelect(); + }; + this.content.add(button); + } + } +} + +/** A modal box with title, message and buttons. */ +export class Dialog extends Popup { + constructor(title: string, message: string, buttons: { label: string; primary?: boolean; danger?: boolean; onClick?: () => void }[]) { + super({ modal: true, padding: Theme.padding * 2, gap: Theme.padding }); + this.minWidth = 320; + this.content.add(new Label(title, { style: 'bodyBold' })); + if (message) this.content.add(new Label(message, { style: 'muted' })); + const row = new HStack({ gap: Theme.gap, align: 'center' }); + row.add(new Label(''), 1); // pushes the buttons to the right + for (const b of buttons) { + const button = new Button(b.label, { variant: b.primary ? 'primary' : b.danger ? 'danger' : 'default' }); + button.onClick = () => { + this.close(); + b.onClick?.(); + }; + row.add(button); + } + this.content.add(row); + } +} diff --git a/src/ui/widgets/ScrollContainer.ts b/src/ui/widgets/ScrollContainer.ts new file mode 100644 index 00000000..1de6d902 --- /dev/null +++ b/src/ui/widgets/ScrollContainer.ts @@ -0,0 +1,99 @@ +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js'; +import { Theme } from '../Theme'; +import { type Size, Widget } from '../Widget'; + +/** + * Vertically scrolls a single content widget that is taller than the container. + * The content is given the container's width and its own measured height. + */ +export class ScrollContainer extends Widget { + private readonly maskShape = new Graphics(); + private readonly scrollbar = new Graphics(); + private readonly viewportNode = new Container(); + private content: Widget | null = null; + private scrollY = 0; + private draggingBar = false; + private dragStart = 0; + private dragScrollStart = 0; + + constructor() { + super(); + this.addChild(this.viewportNode, this.maskShape, this.scrollbar); + this.viewportNode.mask = this.maskShape; + this.on('wheel', (e: FederatedWheelEvent) => { + this.scrollTo(this.scrollY + e.deltaY); + e.preventDefault(); + }); + this.scrollbar.eventMode = 'static'; + this.scrollbar.cursor = 'default'; + this.scrollbar.on('pointerdown', (e: FederatedPointerEvent) => { + this.draggingBar = true; + this.dragStart = e.global.y; + this.dragScrollStart = this.scrollY; + e.stopPropagation(); + }); + this.scrollbar.on('globalpointermove', (e: FederatedPointerEvent) => { + if (!this.draggingBar) return; + const ratio = this.contentHeight / Math.max(1, this.h); + this.scrollTo(this.dragScrollStart + (e.global.y - this.dragStart) * ratio); + }); + const stop = () => { this.draggingBar = false; }; + this.scrollbar.on('pointerup', stop); + this.scrollbar.on('pointerupoutside', stop); + } + + setContent(content: Widget): void { + if (this.content) this.viewportNode.removeChild(this.content); + this.content = content; + this.viewportNode.addChild(content); + this.relayoutContent(); + } + + get contentHeight(): number { + return this.content ? this.content.h : 0; + } + + get maxScroll(): number { + return Math.max(0, this.contentHeight - this.h); + } + + scrollTo(y: number): void { + const clamped = Math.max(0, Math.min(this.maxScroll, y)); + if (clamped === this.scrollY) return; + this.scrollY = clamped; + this.applyScroll(); + } + + /** Re-measure the content after it changed (rows added/removed). */ + relayoutContent(): void { + if (!this.content) return; + const size = this.content.measure(); + this.content.resize(this.w, Math.max(size.h, this.h)); + this.scrollTo(this.scrollY); // re-clamp + this.applyScroll(); + } + + override measure(): Size { + return { w: this.minWidth, h: this.minHeight }; + } + + override layout(): void { + this.relayoutContent(); + } + + private applyScroll(): void { + if (this.content) this.content.position.set(0, -Math.round(this.scrollY)); + this.redraw(); + } + + override redraw(): void { + this.maskShape.clear().rect(0, 0, this.w, this.h).fill(0xffffff); + this.scrollbar.clear(); + if (this.maxScroll <= 0) return; + const trackW = 6; + const x = this.w - trackW - 2; + const barH = Math.max(20, (this.h / this.contentHeight) * this.h); + const barY = (this.scrollY / this.maxScroll) * (this.h - barH); + this.scrollbar.roundRect(x, barY, trackW, barH, 3).fill({ color: Theme.colors.textMuted, alpha: 0.6 }); + } +} diff --git a/src/ui/widgets/Splitter.ts b/src/ui/widgets/Splitter.ts new file mode 100644 index 00000000..9ba4636f --- /dev/null +++ b/src/ui/widgets/Splitter.ts @@ -0,0 +1,47 @@ +import { type FederatedPointerEvent, Graphics } from 'pixi.js'; +import { Theme } from '../Theme'; +import { type Size, Widget } from '../Widget'; + +/** Thin draggable divider between two panes. Reports the drag delta; the parent resizes. */ +export class Splitter extends Widget { + static readonly THICKNESS = 5; + private readonly bar = new Graphics(); + private hovered = false; + private dragging = false; + private last = 0; + onDrag?: (delta: number) => void; + + constructor(private readonly axis: 'x' | 'y') { + super(); + this.cursor = axis === 'x' ? 'col-resize' : 'row-resize'; + this.minWidth = axis === 'x' ? Splitter.THICKNESS : 0; + this.minHeight = axis === 'y' ? Splitter.THICKNESS : 0; + this.addChild(this.bar); + this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); + this.on('pointerout', () => { this.hovered = false; this.invalidate(); }); + this.on('pointerdown', (e: FederatedPointerEvent) => { + this.dragging = true; + this.last = this.axis === 'x' ? e.global.x : e.global.y; + e.stopPropagation(); + }); + this.on('globalpointermove', (e: FederatedPointerEvent) => { + if (!this.dragging) return; + const now = this.axis === 'x' ? e.global.x : e.global.y; + const delta = now - this.last; + this.last = now; + if (delta !== 0) this.onDrag?.(delta); + }); + const stop = () => { this.dragging = false; this.invalidate(); }; + this.on('pointerup', stop); + this.on('pointerupoutside', stop); + } + + override measure(): Size { + return { w: this.minWidth, h: this.minHeight }; + } + + override redraw(): void { + this.bar.clear(); + this.bar.rect(0, 0, this.w, this.h).fill(this.hovered || this.dragging ? Theme.colors.accent : Theme.colors.border); + } +} diff --git a/src/ui/widgets/TextField.ts b/src/ui/widgets/TextField.ts new file mode 100644 index 00000000..9920fb35 --- /dev/null +++ b/src/ui/widgets/TextField.ts @@ -0,0 +1,274 @@ +import { CanvasTextMetrics, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import type { TextEditTarget } from '../KeyboardBridge'; +import { Theme, textStyle, type TextStyleToken } from '../Theme'; +import { ui } from '../UIContext'; +import { type Size, Widget } from '../Widget'; + +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; +} + +/** + * Single-line text input drawn in the canvas. Keyboard handling is delegated to the + * KeyboardBridge while the field is focused; this class only mirrors value, selection + * and caret and handles pointer interaction (caret placement, drag selection). + */ +export class TextField extends Widget implements TextEditTarget { + protected readonly bg = new Graphics(); + protected readonly selectionShape = new Graphics(); + protected readonly caret = new Graphics(); + protected readonly textNode: Text; + protected readonly placeholderNode: Text; + protected valueText = ''; + protected editing = false; + protected selStart = 0; + protected selEnd = 0; + private originalValue = ''; + private scrollX = 0; + private hovered = false; + private caretVisible = true; + private blinkTimer = 0; + private dragSelecting = false; + private readonly innerPad = 6; + placeholder: string; + onInput?: (value: string) => void; + onChange?: (value: string) => void; + + constructor(value = '', options: TextFieldOptions = {}) { + super(); + this.valueText = value; + this.placeholder = options.placeholder ?? ''; + this.onInput = options.onInput; + this.onChange = options.onChange; + this.focusable = true; + this.cursor = 'text'; + this.minHeight = Theme.controlHeight; + this.minWidth = options.width ?? 80; + this.textNode = new Text({ text: value, style: textStyle(options.style ?? 'body') }); + this.placeholderNode = new Text({ text: this.placeholder, style: textStyle('muted') }); + const clip = new Graphics(); + this.addChild(this.bg, this.selectionShape, this.placeholderNode, this.textNode, this.caret, clip); + this.mask = clip; + this.clipShape = clip; + + this.on('pointerover', () => { this.hovered = true; this.invalidate(); }); + this.on('pointerout', () => { this.hovered = false; this.invalidate(); }); + this.on('pointerdown', (e: FederatedPointerEvent) => this.handlePointerDown(e)); + this.on('globalpointermove', (e: FederatedPointerEvent) => { + if (!this.dragSelecting) return; + const index = this.indexAt(this.toLocal(e.global).x); + this.setSelection(this.selStart, index, true); + }); + const stop = () => { this.dragSelecting = false; }; + this.on('pointerup', stop); + this.on('pointerupoutside', stop); + } + + private readonly clipShape: Graphics; + + get value(): string { + return this.valueText; + } + + /** Programmatic update (from the model). Ignored while the user is editing. */ + setValue(value: string): void { + if (this.editing || value === this.valueText) return; + this.valueText = value; + this.invalidate(); + } + + get isEditing(): boolean { + return this.editing; + } + + protected handlePointerDown(e: FederatedPointerEvent): void { + if (e.button !== 0) return; + e.stopPropagation(); + const index = this.indexAt(this.toLocal(e.global).x); + if (!this.editing) { + ui().focus.focus(this); + if (e.detail >= 2) this.selectAll(); + else this.setSelection(index, index, true); + } else if (e.detail >= 2) { + this.selectAll(); + } else { + this.setSelection(index, index, true); + } + this.dragSelecting = true; + } + + // --- focus --------------------------------------------------------------------------- + + override onFocus(): void { + super.onFocus(); + this.startEditing(); + } + + override onBlur(): void { + super.onBlur(); + this.stopEditing(true); + } + + protected startEditing(): void { + if (this.editing) return; + this.editing = true; + this.originalValue = this.valueText; + this.selStart = 0; + this.selEnd = this.valueText.length; + const global = this.getGlobalPosition(); + ui().keyboard.moveTo(global.x, global.y); + ui().keyboard.attach(this, this.valueText, this.selStart, this.selEnd); + this.startBlink(); + this.invalidate(); + } + + protected stopEditing(commit: boolean): void { + if (!this.editing) return; + this.editing = false; + this.stopBlink(); + ui().keyboard.detach(this); + if (commit) { + if (this.valueText !== this.originalValue) this.onChange?.(this.valueText); + } else if (this.valueText !== this.originalValue) { + this.valueText = this.originalValue; + this.onInput?.(this.valueText); + } + this.scrollX = 0; + this.invalidate(); + } + + selectAll(): void { + this.setSelection(0, this.valueText.length, true); + } + + private setSelection(start: number, end: number, pushToBridge: boolean): void { + this.selStart = Math.max(0, Math.min(this.valueText.length, start)); + this.selEnd = Math.max(0, Math.min(this.valueText.length, end)); + if (pushToBridge && this.editing) { + const lo = Math.min(this.selStart, this.selEnd); + const hi = Math.max(this.selStart, this.selEnd); + ui().keyboard.setValue(this.valueText, lo, hi); + // Keep our anchor/focus order; the DOM only knows lo/hi. + } + this.caretVisible = true; + this.invalidate(); + } + + // --- TextEditTarget -------------------------------------------------------------------- + + onBridgeInput(value: string, selectionStart: number, selectionEnd: number): void { + const changed = value !== this.valueText; + this.valueText = value; + this.selStart = selectionStart; + this.selEnd = selectionEnd; + this.caretVisible = true; + this.invalidate(); + if (changed) this.onInput?.(value); + } + + onBridgeKey(event: KeyboardEvent): boolean { + if (event.key === 'Enter') { + ui().focus.blur(); // triggers onBlur → commit + return true; + } + if (event.key === 'Escape') { + this.stopEditing(false); + ui().focus.blur(); + return true; + } + if (event.key === 'Tab') { + ui().focus.cycle(event.shiftKey); + return true; + } + return false; + } + + onBridgeBlur(): void { + // DOM focus went elsewhere (browser UI, another tab). Commit and release focus. + this.stopEditing(true); + if (ui().focus.focused === this) ui().focus.blur(); + } + + // --- geometry -------------------------------------------------------------------------- + + private textWidth(text: string): number { + return CanvasTextMetrics.measureText(text, this.textNode.style).width; + } + + /** Character index closest to a local x coordinate. */ + protected indexAt(localX: number): number { + const x = localX - this.innerPad + this.scrollX; + let best = 0; + let bestDist = Infinity; + for (let i = 0; i <= this.valueText.length; i++) { + const dist = Math.abs(this.textWidth(this.valueText.slice(0, i)) - x); + if (dist < bestDist) { bestDist = dist; best = i; } + } + return best; + } + + private startBlink(): void { + this.stopBlink(); + this.caretVisible = true; + this.blinkTimer = window.setInterval(() => { + this.caretVisible = !this.caretVisible; + this.invalidate(); + }, 530); + } + + private stopBlink(): void { + if (this.blinkTimer) window.clearInterval(this.blinkTimer); + this.blinkTimer = 0; + } + + override measure(): Size { + return { w: this.minWidth, h: Math.max(this.minHeight, Theme.controlHeight) }; + } + + override redraw(): void { + const c = Theme.colors; + const innerW = this.w - this.innerPad * 2; + this.bg.clear(); + this.bg.roundRect(0, 0, this.w, this.h, Theme.radius).fill(this.hovered && !this.editing ? c.controlHover : c.control); + this.bg.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: 1, color: this.focused ? c.accent : c.border }); + this.clipShape.clear().rect(1, 1, this.w - 2, this.h - 2).fill(0xffffff); + + this.textNode.text = this.valueText; + this.placeholderNode.text = this.valueText ? '' : this.placeholder; + + // Horizontal scroll so the caret stays visible. + const caretX = this.textWidth(this.valueText.slice(0, this.selEnd)); + if (!this.editing) this.scrollX = 0; + else if (caretX - this.scrollX > innerW) this.scrollX = caretX - innerW; + else if (caretX - this.scrollX < 0) this.scrollX = caretX; + const textX = this.innerPad - this.scrollX; + const textY = Math.round((this.h - this.textNode.height) / 2); + this.textNode.position.set(Math.round(textX), textY); + this.placeholderNode.position.set(this.innerPad, textY); + + this.selectionShape.clear(); + this.caret.clear(); + if (!this.editing) return; + const lo = Math.min(this.selStart, this.selEnd); + const hi = Math.max(this.selStart, this.selEnd); + if (hi > lo) { + const x1 = textX + this.textWidth(this.valueText.slice(0, lo)); + const x2 = textX + this.textWidth(this.valueText.slice(0, hi)); + this.selectionShape.rect(x1, textY, x2 - x1, this.textNode.height).fill({ color: c.accent, alpha: 0.45 }); + } + if (this.caretVisible) { + this.caret.rect(Math.round(textX + caretX), textY, 1, this.textNode.height).fill(c.text); + } + } + + override destroy(options?: Parameters[0]): void { + this.stopEditing(false); + super.destroy(options); + } +} diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts new file mode 100644 index 00000000..bdd1e31f --- /dev/null +++ b/src/viewport/Viewport.ts @@ -0,0 +1,273 @@ +import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; +import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands'; +import type { AssetType } from '../core/model/AssetTypes'; +import type { PropertyBag, Vec2 } from '../core/model/Properties'; +import { findAsset } from '../core/model/Project'; +import { resolveSceneObjectProps } from '../core/properties/resolve'; +import type { World } from '../runtime/World'; +import { Theme, textStyle, Widget, type Size } from '../ui'; +import type { EditorState } from '../editor/state'; + +/** What the viewport draws: the same shape whether it comes from the model or the world. */ +interface DrawItem { + id: string; + type: AssetType; + name: string; + x: number; + y: number; + props: PropertyBag; +} + +/** Reference frame drawn for camera objects in edit mode (world units at zoom 1). */ +const CAMERA_FRAME = { w: 640, h: 360 }; +const GRID_STEP = 100; + +/** + * Draws the active scene (edit mode) or the running world (play mode) as coloured boxes. + * Edit mode adds a grid, selection outline, drag-to-move, middle-drag panning and wheel + * zoom. The camera object of the scene defines the view in play mode. + */ +export class Viewport extends Widget { + private readonly bg = new Graphics(); + private readonly grid = new Graphics(); + private readonly world = new Container(); + private readonly gizmos = new Graphics(); + private readonly nodes = new Map(); + private readonly labels = new Map(); + private playWorld: World | null = null; + /** Edit-mode view: world point at the viewport centre and zoom. */ + private view = { x: 0, y: 0, zoom: 1 }; + private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null; + private dragging: { objectId: string; sceneId: string; start: Vec2; pointer: Vec2; moved: boolean } | null = null; + + constructor(private readonly state: EditorState) { + super(); + this.addChild(this.bg, this.grid, this.world, this.gizmos); + this.cursor = 'default'; + + this.on('pointerdown', (e: FederatedPointerEvent) => { + if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) { + this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y }; + e.stopPropagation(); + return; + } + if (e.button === 0 && e.target === this && this.state.mode === 'edit') this.state.select(null); + }); + this.on('globalpointermove', (e: FederatedPointerEvent) => { + if (this.panning) { + this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom; + this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom; + this.redraw(); + } else if (this.dragging) { + const local = this.toLocal(e.global); + const dx = (local.x - this.dragging.pointer.x) / this.view.zoom; + const dy = (local.y - this.dragging.pointer.y) / this.view.zoom; + if (!this.dragging.moved && Math.hypot(dx, dy) < 2) return; + this.dragging.moved = true; + const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId); + if (!object) return; + object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy) }; + this.state.preview(); + } + }); + const finish = () => { + this.panning = null; + const drag = this.dragging; + this.dragging = null; + if (!drag || !drag.moved) return; + const object = this.state.activeScene?.objects.find((o) => o.id === drag.objectId); + if (!object) return; + const next = { ...object.position }; + object.position = { ...drag.start }; + this.state.execute(new MoveSceneObjectCommand(drag.sceneId, drag.objectId, next, drag.start)); + }; + this.on('pointerup', finish); + this.on('pointerupoutside', finish); + this.on('wheel', (e: FederatedWheelEvent) => { + if (this.state.mode !== 'edit') return; + const local = this.toLocal(e.global); + const before = this.screenToWorld(local.x, local.y); + const factor = Math.exp(-e.deltaY * 0.0015); + this.view.zoom = Math.max(0.1, Math.min(8, this.view.zoom * factor)); + const after = this.screenToWorld(local.x, local.y); + this.view.x += before.x - after.x; + this.view.y += before.y - after.y; + this.redraw(); + e.preventDefault(); + }); + + state.on('project', () => this.redraw()); + state.on('load', () => { this.view = { x: 0, y: 0, zoom: 1 }; this.redraw(); }); + state.on('selection', () => this.redraw()); + state.on('mode', () => this.redraw()); + } + + /** Switch between drawing the model (null) and a running world. */ + setWorld(world: World | null): void { + this.playWorld = world; + this.redraw(); + } + + /** World coordinates at the centre of the current edit view (used to place assets). */ + worldCenter(): Vec2 { + return { x: Math.round(this.view.x), y: Math.round(this.view.y) }; + } + + override measure(): Size { + return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; + } + + // --- transforms ------------------------------------------------------------------------ + + private currentCamera(): { x: number; y: number; zoom: number } { + if (this.playWorld) { + const camera = this.playWorld.firstOfType('camera'); + return camera ? { x: camera.x, y: camera.y, zoom: Number(camera.props.zoom ?? 1) } : { x: 0, y: 0, zoom: 1 }; + } + return this.view; + } + + private screenToWorld(sx: number, sy: number): Vec2 { + const cam = this.currentCamera(); + return { x: (sx - this.w / 2) / cam.zoom + cam.x, y: (sy - this.h / 2) / cam.zoom + cam.y }; + } + + private worldToScreen(wx: number, wy: number): Vec2 { + const cam = this.currentCamera(); + return { x: (wx - cam.x) * cam.zoom + this.w / 2, y: (wy - cam.y) * cam.zoom + this.h / 2 }; + } + + // --- drawing --------------------------------------------------------------------------- + + private collectItems(): { items: DrawItem[]; background: number } { + if (this.playWorld) { + return { + background: this.playWorld.backgroundColor, + items: this.playWorld.entities.filter((e) => e.type !== 'camera').map((e) => ({ id: e.objectId, type: e.type, name: e.name, x: e.x, y: e.y, props: e.props })), + }; + } + const scene = this.state.activeScene; + if (!scene) return { items: [], background: Theme.colors.viewport }; + const items: DrawItem[] = []; + for (const object of scene.objects) { + const asset = findAsset(this.state.project, object.assetId); + if (!asset) continue; + items.push({ id: object.id, type: asset.type, name: object.name || asset.name, x: object.position.x, y: object.position.y, props: resolveSceneObjectProps(asset, object) }); + } + return { items, background: Number(scene.props.backgroundColor ?? Theme.colors.viewport) }; + } + + override redraw(): void { + const { items, background } = this.collectItems(); + const cam = this.currentCamera(); + const editing = !this.playWorld; + + this.bg.clear().rect(0, 0, this.w, this.h).fill(this.state.activeScene || this.playWorld ? background : Theme.colors.viewport); + this.drawGrid(editing); + + this.world.position.set(this.w / 2 - cam.x * cam.zoom, this.h / 2 - cam.y * cam.zoom); + this.world.scale.set(cam.zoom); + + // Sync display objects with items. + const seen = new Set(); + for (const item of items) { + seen.add(item.id); + let node = this.nodes.get(item.id); + if (!node) { + node = new Graphics(); + node.eventMode = 'static'; + node.cursor = 'move'; + node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e)); + this.nodes.set(item.id, node); + this.world.addChild(node); + } + node.position.set(item.x, item.y); + node.clear(); + if (item.type === 'sprite') { + const w = Number(item.props.width ?? 1); + const h = Number(item.props.height ?? 1); + node.rect(-w / 2, -h / 2, w, h).fill(Number(item.props.color ?? 0xffffff)); + } else if (item.type === 'camera') { + const zoom = Number(item.props.zoom ?? 1); + const w = CAMERA_FRAME.w / zoom; + const h = CAMERA_FRAME.h / zoom; + node.rect(-w / 2, -h / 2, w, h).stroke({ width: 2 / cam.zoom, color: Theme.colors.textMuted }); + node.rect(-12, -8, 24, 16).fill(Theme.colors.textMuted); + node.moveTo(12, 0).lineTo(20, -6).lineTo(20, 6).closePath().fill(Theme.colors.textMuted); + } + node.eventMode = editing ? 'static' : 'none'; + this.syncLabel(item, editing, cam.zoom); + } + for (const [id, node] of this.nodes) { + if (!seen.has(id)) { + node.destroy(); + this.nodes.delete(id); + this.labels.get(id)?.destroy(); + this.labels.delete(id); + } + } + + this.drawGizmos(items, editing); + } + + private syncLabel(item: DrawItem, editing: boolean, zoom: number): void { + let label = this.labels.get(item.id); + if (!editing || item.type !== 'camera') { + if (label) label.visible = false; + return; + } + if (!label) { + label = new Text({ text: '', style: textStyle('small') }); + this.labels.set(item.id, label); + this.world.addChild(label); + } + label.visible = true; + label.text = item.name; + label.scale.set(1 / zoom); + const h = CAMERA_FRAME.h / Number(item.props.zoom ?? 1); + label.position.set(item.x - (CAMERA_FRAME.w / Number(item.props.zoom ?? 1)) / 2, item.y - h / 2 - 16 / zoom); + } + + private drawGrid(editing: boolean): void { + this.grid.clear(); + if (!editing) return; + const cam = this.view; + const step = GRID_STEP * cam.zoom; + if (step < 12) return; + const origin = this.worldToScreen(0, 0); + const startX = ((origin.x % step) + step) % step; + const startY = ((origin.y % step) + step) % step; + for (let x = startX; x < this.w; x += step) this.grid.moveTo(x, 0).lineTo(x, this.h); + for (let y = startY; y < this.h; y += step) this.grid.moveTo(0, y).lineTo(this.w, y); + this.grid.stroke({ width: 1, color: 0xffffff, alpha: 0.05 }); + if (origin.x >= 0 && origin.x <= this.w) this.grid.moveTo(origin.x, 0).lineTo(origin.x, this.h).stroke({ width: 1, color: 0xffffff, alpha: 0.15 }); + if (origin.y >= 0 && origin.y <= this.h) this.grid.moveTo(0, origin.y).lineTo(this.w, origin.y).stroke({ width: 1, color: 0xffffff, alpha: 0.15 }); + } + + private drawGizmos(items: DrawItem[], editing: boolean): void { + this.gizmos.clear(); + const s = this.state.selection; + if (!editing || !s || s.kind !== 'object') return; + const item = items.find((i) => i.id === s.objectId); + if (!item) return; + const zoom = this.view.zoom; + const w = item.type === 'sprite' ? Number(item.props.width ?? 1) : CAMERA_FRAME.w / Number(item.props.zoom ?? 1); + const h = item.type === 'sprite' ? Number(item.props.height ?? 1) : CAMERA_FRAME.h / Number(item.props.zoom ?? 1); + const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2); + this.gizmos.rect(tl.x - 2, tl.y - 2, w * zoom + 4, h * zoom + 4).stroke({ width: 2, color: Theme.colors.gizmo }); + for (const [cx, cy] of [[tl.x, tl.y], [tl.x + w * zoom, tl.y], [tl.x, tl.y + h * zoom], [tl.x + w * zoom, tl.y + h * zoom]] as const) { + this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo); + } + } + + private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void { + if (this.state.mode !== 'edit' || e.button !== 0) return; + const scene = this.state.activeScene; + const object = scene?.objects.find((o) => o.id === objectId); + if (!scene || !object) return; + e.stopPropagation(); + this.state.select({ kind: 'object', sceneId: scene.id, objectId }); + const local = this.toLocal(e.global); + this.dragging = { objectId, sceneId: scene.id, start: { ...object.position }, pointer: { x: local.x, y: local.y }, moved: false }; + } +} diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts new file mode 100644 index 00000000..b6422744 --- /dev/null +++ b/tests/core/commands.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { + CreateAssetCommand, + DeleteAssetCommand, + RenameAssetCommand, + SetActiveSceneCommand, + SetAssetPropertyCommand, +} from '../../src/core/commands/assetCommands'; +import { CommandStack } from '../../src/core/commands/CommandStack'; +import { + ClearOverrideCommand, + MoveSceneObjectCommand, + PlaceAssetCommand, + RemoveSceneObjectCommand, + SetOverrideCommand, +} from '../../src/core/commands/sceneObjectCommands'; +import { createEmptyProject, findScene } from '../../src/core/model/Project'; + +function setup() { + const project = createEmptyProject('p1', 'Test'); + const stack = new CommandStack(project); + const scene = new CreateAssetCommand('scene', 'Level 1'); + const sprite = new CreateAssetCommand('sprite', 'Box'); + stack.execute(scene); + stack.execute(sprite); + return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id }; +} + +describe('asset commands', () => { + it('creates assets with defaults and activates the first scene', () => { + const { project, sceneId, spriteId } = setup(); + expect(project.assets).toHaveLength(2); + expect(project.activeSceneId).toBe(sceneId); + expect(project.assets.find((a) => a.id === spriteId)?.props).toEqual({ width: 100, height: 100, color: 0xe0553f }); + }); + + it('renames and sets properties with undo/redo', () => { + const { project, stack, spriteId } = setup(); + stack.execute(new RenameAssetCommand(spriteId, 'Crate')); + stack.execute(new SetAssetPropertyCommand(spriteId, 'width', 250)); + const sprite = () => project.assets.find((a) => a.id === spriteId)!; + expect(sprite().name).toBe('Crate'); + expect(sprite().props.width).toBe(250); + stack.undo(); + expect(sprite().props.width).toBe(100); + stack.undo(); + expect(sprite().name).toBe('Box'); + stack.redo(); + stack.redo(); + expect(sprite().name).toBe('Crate'); + expect(sprite().props.width).toBe(250); + }); + + it('rejects unknown properties', () => { + const { stack, spriteId } = setup(); + expect(() => stack.execute(new SetAssetPropertyCommand(spriteId, 'x', 5))).toThrow(/not defined/); + }); + + it('deletes an asset together with its scene objects and restores both on undo', () => { + const { project, stack, sceneId, spriteId } = setup(); + stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 1, y: 2 })); + stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 3, y: 4 })); + expect(findScene(project, sceneId)!.objects).toHaveLength(2); + stack.execute(new DeleteAssetCommand(spriteId)); + expect(project.assets).toHaveLength(1); + expect(findScene(project, sceneId)!.objects).toHaveLength(0); + stack.undo(); + expect(project.assets).toHaveLength(2); + expect(findScene(project, sceneId)!.objects.map((o) => o.position)).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); + }); + + it('switches the active scene when the active one is deleted', () => { + const { project, stack, sceneId } = setup(); + const second = new CreateAssetCommand('scene', 'Level 2'); + stack.execute(second); + stack.execute(new SetActiveSceneCommand(second.asset.id)); + stack.execute(new DeleteAssetCommand(second.asset.id)); + expect(project.activeSceneId).toBe(sceneId); + stack.undo(); + expect(project.activeSceneId).toBe(second.asset.id); + }); +}); + +describe('scene object commands', () => { + it('places assets and refuses scenes', () => { + const { project, stack, sceneId, spriteId } = setup(); + const place = new PlaceAssetCommand(sceneId, spriteId, { x: 5, y: 6 }); + stack.execute(place); + const object = findScene(project, sceneId)!.objects[0]!; + expect(object.id).toBe(place.objectId); + expect(object.assetId).toBe(spriteId); + expect(object.position).toEqual({ x: 5, y: 6 }); + expect(object.overrides).toEqual({}); + + expect(() => stack.execute(new PlaceAssetCommand(sceneId, sceneId, { x: 0, y: 0 }))).toThrow(/cannot be placed/); + expect(findScene(project, sceneId)!.objects).toHaveLength(1); + }); + + it('moves, overrides, clears and removes with undo', () => { + const { project, stack, sceneId, spriteId } = setup(); + const place = new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 }); + stack.execute(place); + const object = () => findScene(project, sceneId)!.objects.find((o) => o.id === place.objectId)!; + + stack.execute(new MoveSceneObjectCommand(sceneId, place.objectId, { x: 40, y: 50 })); + expect(object().position).toEqual({ x: 40, y: 50 }); + + stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'color', 0x123456)); + expect(object().overrides).toEqual({ color: 0x123456 }); + expect(() => stack.execute(new SetOverrideCommand(sceneId, place.objectId, 'nope', 1))).toThrow(); + + stack.execute(new ClearOverrideCommand(sceneId, place.objectId, 'color')); + expect(object().overrides).toEqual({}); + stack.undo(); + expect(object().overrides).toEqual({ color: 0x123456 }); + + stack.execute(new RemoveSceneObjectCommand(sceneId, place.objectId)); + expect(findScene(project, sceneId)!.objects).toHaveLength(0); + stack.undo(); + expect(object().position).toEqual({ x: 40, y: 50 }); + }); + + it('clears the redo stack on a new command', () => { + const { stack, sceneId, spriteId } = setup(); + stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 0, y: 0 })); + stack.undo(); + expect(stack.canRedo).toBe(true); + stack.execute(new PlaceAssetCommand(sceneId, spriteId, { x: 1, y: 1 })); + expect(stack.canRedo).toBe(false); + }); +}); diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts new file mode 100644 index 00000000..c75cbe03 --- /dev/null +++ b/tests/core/resolve.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { buildAsset } from '../../src/core/commands/assetCommands'; +import { createSceneObject } from '../../src/core/model/SceneObject'; +import { coerceValue, defaultsFor, schemaFor } from '../../src/core/properties/PropertySchema'; +import { inheritedValue, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../src/core/properties/resolve'; + +describe('PropertySchema', () => { + it('has no position on any asset type', () => { + for (const type of ['scene', 'camera', 'sprite'] as const) { + const keys = schemaFor(type).map((d) => d.key); + expect(keys).not.toContain('x'); + expect(keys).not.toContain('y'); + expect(keys).not.toContain('position'); + } + }); + + it('provides defaults for every key', () => { + expect(defaultsFor('sprite')).toEqual({ width: 100, height: 100, color: 0xe0553f }); + }); + + it('coerces values against the definition', () => { + const width = schemaFor('sprite').find((d) => d.key === 'width')!; + expect(coerceValue(width, -5)).toBe(1); + expect(coerceValue(width, '42')).toBe(42); + expect(coerceValue(width, 'abc')).toBe(100); + const color = schemaFor('sprite').find((d) => d.key === 'color')!; + expect(coerceValue(color, '#ff0000')).toBe(0xff0000); + expect(coerceValue(color, 0x1ffffff)).toBe(0xffffff); + }); +}); + +describe('resolve', () => { + it('fills asset props with defaults and ignores unknown keys', () => { + const sprite = buildAsset('sprite', 'Box'); + sprite.props = { width: 50, bogus: 1 }; + expect(resolveAssetProps(sprite)).toEqual({ width: 50, height: 100, color: 0xe0553f }); + }); + + it('applies sparse overrides on top of asset values', () => { + const sprite = buildAsset('sprite', 'Box'); + sprite.props.color = 0x00ff00; + const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 }); + expect(resolveSceneObjectProps(sprite, object)).toEqual({ width: 100, height: 100, color: 0x00ff00 }); + + object.overrides.color = 0x0000ff; + expect(resolveSceneObjectProps(sprite, object).color).toBe(0x0000ff); + expect(isOverridden(object, 'color')).toBe(true); + expect(isOverridden(object, 'width')).toBe(false); + expect(inheritedValue(sprite, 'color')).toBe(0x00ff00); + }); + + it('keeps position on the scene object only', () => { + const sprite = buildAsset('sprite', 'Box'); + const object = createSceneObject('o1', sprite.id, { x: 10, y: 20 }); + expect(object.position).toEqual({ x: 10, y: 20 }); + expect('position' in sprite).toBe(false); + expect(resolveSceneObjectProps(sprite, object)).not.toHaveProperty('x'); + }); +}); diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts new file mode 100644 index 00000000..a1a1946e --- /dev/null +++ b/tests/core/serialize.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { CommandStack } from '../../src/core/commands/CommandStack'; +import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { createEmptyProject } from '../../src/core/model/Project'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; + +describe('serialize', () => { + it('round-trips a project', () => { + const project = createEmptyProject('p1', 'Round trip'); + const stack = new CommandStack(project); + const scene = new CreateAssetCommand('scene', 'Level'); + const sprite = new CreateAssetCommand('sprite', 'Box'); + stack.execute(scene); + stack.execute(sprite); + const place = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 7, y: 8 }); + stack.execute(place); + stack.execute(new SetOverrideCommand(scene.asset.id, place.objectId, 'width', 33)); + + const copy = deserializeProject(JSON.parse(JSON.stringify(serializeProject(project)))); + expect(copy).toEqual(project); + }); + + it('repairs broken documents', () => { + const project = deserializeProject( + { + name: 'Broken', + activeSceneId: 'missing', + assets: [ + { id: 's1', type: 'scene', name: 'Scene', props: { backgroundColor: '#000000', junk: 1 }, objects: [ + { id: 'o1', assetId: 'sp1', position: { x: '5', y: null }, overrides: { color: '#00ff00', width: -3, foo: 1 } }, + { id: 'o2', assetId: 'nope', position: {} }, + { id: 'o3', assetId: 's1', position: {} }, + ] }, + { id: 'sp1', type: 'sprite', props: { width: 10 } }, + { id: 'x', type: 'texture' }, + { type: 'camera' }, + ], + }, + 'fallback-id', + ); + expect(project.id).toBe('fallback-id'); + expect(project.activeSceneId).toBe('s1'); + expect(project.assets.map((a) => a.id)).toEqual(['s1', 'sp1']); + const scene = project.assets[0]!; + expect(scene.type === 'scene' && scene.props).toEqual({ backgroundColor: 0 }); + const objects = scene.type === 'scene' ? scene.objects : []; + expect(objects).toHaveLength(1); + expect(objects[0]).toEqual({ + id: 'o1', + assetId: 'sp1', + name: '', + position: { x: 5, y: 0 }, + overrides: { color: 0x00ff00, width: 1 }, + }); + expect(project.assets[1]!.name).toBe('sprite'); + expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f }); + }); +}); diff --git a/tools/e2e.mjs b/tools/e2e.mjs new file mode 100644 index 00000000..e41e2506 --- /dev/null +++ b/tools/e2e.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env node +/** + * Headless end-to-end smoke test driven over the Chrome DevTools Protocol. + * Usage: PATH=/opt/node22/bin:$PATH node tools/e2e.mjs [baseUrl] + * Writes screenshots to /tmp/gm-e2e-*.png and prints PASS/FAIL checks. No dependencies. + * + * Widgets are located through `window.__gm.locate(text)` (installed by the editor), so + * the test does not depend on pixel coordinates. + */ +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; + +const projectId = process.argv[2]; +const baseUrl = process.argv[3] ?? 'https://nockal.com/game-maker/'; +if (!projectId) { + console.error('usage: node tools/e2e.mjs [baseUrl]'); + process.exit(2); +} +const PORT = 9333; +const W = 1280; +const H = 800; + +const chrome = spawn('chromium', [ + '--headless', '--no-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader', + `--remote-debugging-port=${PORT}`, `--window-size=${W},${H}`, '--hide-scrollbars', 'about:blank', +], { stdio: 'ignore' }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function waitForChrome() { + for (let i = 0; i < 50; i++) { + try { + const res = await fetch(`http://127.0.0.1:${PORT}/json/version`); + if (res.ok) return res.json(); + } catch {} + await sleep(200); + } + throw new Error('chromium did not start'); +} + +class CDP { + constructor(ws) { + this.ws = ws; + this.id = 0; + this.pending = new Map(); + this.listeners = []; + ws.addEventListener('message', (ev) => { + const msg = JSON.parse(ev.data); + if (msg.id && this.pending.has(msg.id)) { + const { resolve, reject } = this.pending.get(msg.id); + this.pending.delete(msg.id); + msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result); + } else if (msg.method) { + for (const l of this.listeners) l(msg); + } + }); + } + send(method, params = {}) { + const id = ++this.id; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject })); + } + on(fn) { this.listeners.push(fn); } +} + +const consoleLog = []; +let failures = 0; +function check(name, ok, detail = '') { + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' – ' + detail : ''}`); + if (!ok) failures++; +} + +try { + await waitForChrome(); + const target = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: 'PUT' })).json(); + const ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((r) => ws.addEventListener('open', r)); + const cdp = new CDP(ws); + await cdp.send('Page.enable'); + await cdp.send('Runtime.enable'); + await cdp.send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 1, mobile: false }); + cdp.on((msg) => { + if (msg.method === 'Runtime.consoleAPICalled') consoleLog.push(msg.params.args.map((a) => a.value ?? a.description).join(' ')); + if (msg.method === 'Runtime.exceptionThrown') consoleLog.push('EXCEPTION ' + (msg.params.exceptionDetails.exception?.description ?? msg.params.exceptionDetails.text)); + }); + + const evaluate = async (expression) => (await cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })).result.value; + const locate = async (text) => { + const p = await evaluate(`window.__gm && window.__gm.locate(${JSON.stringify(text)})`); + if (!p) throw new Error(`widget with text "${text}" not found`); + return p; + }; + const shot = async (name) => { + const { data } = await cdp.send('Page.captureScreenshot', { format: 'png' }); + writeFileSync(`/tmp/gm-e2e-${name}.png`, Buffer.from(data, 'base64')); + }; + const mouse = (type, x, y, button = 'left', extra = {}) => + cdp.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount: type === 'mousePressed' || type === 'mouseReleased' ? 1 : 0, ...extra }); + const click = async (x, y, button = 'left') => { + await mouse('mouseMoved', x, y); + await mouse('mousePressed', x, y, button); + await mouse('mouseReleased', x, y, button); + await sleep(150); + }; + const clickText = async (text, button = 'left') => { + const p = await locate(text); + await click(p.x, p.y, button); + }; + const drag = async (x1, y1, x2, y2, steps = 12) => { + await mouse('mouseMoved', x1, y1); + await mouse('mousePressed', x1, y1); + for (let i = 1; i <= steps; i++) { + await mouse('mouseMoved', x1 + ((x2 - x1) * i) / steps, y1 + ((y2 - y1) * i) / steps, 'left', { buttons: 1 }); + await sleep(16); + } + await mouse('mouseReleased', x2, y2); + await sleep(150); + }; + const keyPress = async (key, code, opts = {}) => { + await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key, code, ...opts }); + await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key, code, ...opts }); + }; + const typeText = async (text) => { + for (const ch of text) await cdp.send('Input.insertText', { text: ch }); + }; + const api = async (route) => (await fetch(`${baseUrl}api/index.php?r=${route}`)).json(); + const sceneOf = (doc) => doc.project.assets.find((a) => a.type === 'scene'); + const stateJson = () => evaluate('JSON.stringify({ selection: __gm.state.selection, mode: __gm.state.mode, save: __gm.state.saveStatus, notice: __gm.state.notice })'); + + await cdp.send('Page.navigate', { url: `${baseUrl}?project=${projectId}` }); + await sleep(4000); + await shot('01-loaded'); + check('page loaded without exceptions', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).join(' | ')); + check('debug hook present', Boolean(await evaluate('Boolean(window.__gm)'))); + + // 1. Select the blue crate in the hierarchy. + await clickText('Blue crate'); + let s = JSON.parse(await stateJson()); + check('hierarchy click selects object', s.selection?.kind === 'object' && s.selection.objectId === 'o2', JSON.stringify(s.selection)); + await shot('02-object-selected'); + + // 2. 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 keyPress('a', 'KeyA', { modifiers: 2, windowsVirtualKeyCode: 65 }); // Ctrl+A in the bridge input + await typeText('Renamed crate'); + await shot('03-typing'); + await keyPress('Enter', 'Enter', { windowsVirtualKeyCode: 13 }); + await sleep(200); + s = JSON.parse(await stateJson()); + const nameNow = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`); + check('typing + Enter renamed the object', nameNow === 'Renamed crate', JSON.stringify(nameNow)); + await shot('04-renamed'); + + // 3. 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 evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); + check('scrubbing X moved the object', xNow > 120, `x=${xNow}`); + await shot('05-scrubbed'); + + // 4. 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 widget bounds. + const center = await evaluate('__gm.viewportCenter()'); + await drag(center.x - 150, center.y, center.x - 50, center.y - 70); + const o1Now = await evaluate(`JSON.stringify(__gm.state.activeScene.objects.find(o => o.id === 'o1').position)`); + check('viewport drag moved the red crate', JSON.parse(o1Now).x > -150, o1Now); + await shot('06-dragged'); + + // 5. Autosave → server. + await sleep(1800); + const saved = await api(`projects/${projectId}`); + const renamed = sceneOf(saved).objects.find((o) => o.id === 'o2'); + check('rename persisted', renamed.name === 'Renamed crate', renamed.name); + check('scrub persisted', renamed.position.x > 120, JSON.stringify(renamed.position)); + check('override kept', renamed.overrides.color === 4491519, JSON.stringify(renamed.overrides)); + check('save status is saved', (JSON.parse(await stateJson())).save === 'saved'); + + // 6. Undo three times (drag, 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 < 3; i++) { + await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 }); + await sleep(80); + } + const afterUndo = JSON.parse(await evaluate(`JSON.stringify(__gm.state.activeScene.objects.map(o => ({ id: o.id, name: o.name, x: o.position.x })))`)); + check('undo reverted drag, scrub and rename', afterUndo[0].x === -150 && afterUndo[1].x === 120 && afterUndo[1].name === 'Blue crate', JSON.stringify(afterUndo)); + await keyPress('z', 'KeyZ', { modifiers: 10, windowsVirtualKeyCode: 90 }); // Ctrl+Shift+Z + const afterRedo = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').name`); + check('redo restored the rename', afterRedo === 'Renamed crate', afterRedo); + await shot('07-undo-redo'); + + // 7. Play with the renamed crate selected; arrow keys move it; Esc stops. + await clickText('Renamed crate'); + const xBeforePlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); + await clickText('▶ Play'); + await sleep(300); + s = JSON.parse(await stateJson()); + check('play mode entered', s.mode === 'play', JSON.stringify(s)); + await shot('08-playing'); + await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); + await sleep(700); + await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'ArrowRight', code: 'ArrowRight', windowsVirtualKeyCode: 39 }); + await sleep(100); + await shot('09-moved-in-play'); + await keyPress('Escape', 'Escape', { windowsVirtualKeyCode: 27 }); + await sleep(300); + s = JSON.parse(await stateJson()); + check('escape stopped play', s.mode === 'edit', JSON.stringify(s)); + await shot('10-stopped'); + const o2AfterPlay = await evaluate(`__gm.state.activeScene.objects.find(o => o.id === 'o2').position.x`); + check('play did not write back to the model', o2AfterPlay === xBeforePlay, `${o2AfterPlay} vs ${xBeforePlay}`); + + // 8. Context menu on the Crate asset → menu shows "Place in scene". + await clickText('Crate', 'right'); + await sleep(200); + const menuVisible = await evaluate(`Boolean(__gm.locate('Place in scene'))`); + check('asset context menu opens', menuVisible); + await shot('11-context-menu'); + await clickText('Place in scene'); + await sleep(200); + const count = await evaluate('__gm.state.activeScene.objects.length'); + check('placing from the menu adds an object', count === 4, `objects=${count}`); + + // 9. Create a new asset through the + menu. + await clickText('+'); + await sleep(150); + await clickText('Sprite'); + await sleep(200); + const assets = await evaluate(`JSON.stringify(__gm.state.project.assets.map(a => a.name))`); + check('+ menu creates a sprite asset', JSON.parse(assets).includes('Sprite'), assets); + await shot('12-new-asset'); + + check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | ')); + ws.close(); +} catch (error) { + console.error(error); + failures++; +} finally { + chrome.kill('SIGTERM'); +} +if (consoleLog.length) console.log('--- console ---\n' + consoleLog.slice(0, 20).join('\n')); +process.exit(failures ? 1 : 0); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..fc7f9eb5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "tests", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..1075b37a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,50 @@ +import { copyFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, type Plugin } from 'vite'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * The workspace folder itself is what nginx serves under https://nockal.com/game-maker/. + * The Vite source page lives in `web/index.html`; the build writes hashed assets into + * `dist/` and this plugin copies the built HTML to the folder root so that the site URL + * loads the production bundle while `api/` and `data/` sit next to it. + */ +function publishIndexHtml(): Plugin { + return { + name: 'game-maker:publish-index-html', + apply: 'build', + closeBundle() { + copyFileSync(resolve(__dirname, 'dist/index.html'), resolve(__dirname, 'index.html')); + }, + }; +} + +export default defineConfig({ + root: 'web', + base: '/game-maker/dist/', + publicDir: false, + plugins: [publishIndexHtml()], + build: { + outDir: '../dist', + emptyOutDir: true, + target: 'es2022', + sourcemap: true, + }, + server: { + port: 5175, + // During development the PHP API is served by the production nginx; proxy it. + proxy: { + '/game-maker/api': { + target: 'https://nockal.com', + changeOrigin: true, + }, + }, + }, + test: { + root: '.', + include: ['tests/**/*.test.ts'], + environment: 'node', + }, +});