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 <input> 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 <noreply@anthropic.com>
This commit is contained in:
commit
5a46124454
65 files changed
+7193
No files matched your search
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
index.html
|
||||
data/projects/*.json
|
||||
data/projects/*.tmp
|
||||
.DS_Store
|
||||
@@ -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: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com/game-maker/?playground>
|
||||
|
||||
## Concepts
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`) 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 `<input>` used as a keyboard bridge (IME, mobile
|
||||
keyboard, clipboard) while a canvas text field is focused.
|
||||
- **Vite + TypeScript**, **Vitest** for the pure core.
|
||||
- **PHP 8** JSON API (`api/`) storing one JSON file per project in `data/projects/`, with
|
||||
atomic writes and an integer revision that rejects stale saves (HTTP 409).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
web/index.html Vite source page (dev) index.html built page (generated, served)
|
||||
src/core/ model, property schema, resolve, commands, store – no Pixi imports
|
||||
src/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 <projectId> # headless end-to-end run with screenshots in /tmp/gm-e2e-*.png
|
||||
```
|
||||
|
||||
The workspace folder itself is what nginx serves, so `npm run build` is the deployment.
|
||||
|
||||
## API
|
||||
|
||||
All routes go through `api/index.php?r=…` (the site's nginx does not pass `PATH_INFO`).
|
||||
|
||||
| Method | Route | Body → Result |
|
||||
|---|---|---|
|
||||
| GET | `projects` | `[{id,name,revision,updatedAt}]` |
|
||||
| POST | `projects` | `{name}` → project document (201) |
|
||||
| GET | `projects/{id}` | `{id,name,revision,updatedAt,project}` |
|
||||
| PUT | `projects/{id}` | `{revision, project}` → `{revision,updatedAt}` or **409** `{revision}` when stale |
|
||||
| DELETE | `projects/{id}` | 204 |
|
||||
|
||||
There is no authentication yet; `data/` is world-readable. Both are phase-2 topics.
|
||||
|
||||
## Keyboard
|
||||
|
||||
`Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo · `Delete` remove selection · `Ctrl+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.
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Game Maker project API.
|
||||
*
|
||||
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
|
||||
* site's nginx configuration does not pass PATH_INFO to PHP.
|
||||
*
|
||||
* GET ?r=projects → [{id,name,revision,updatedAt}]
|
||||
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
||||
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
|
||||
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
|
||||
* DELETE ?r=projects/{id} → 204
|
||||
*/
|
||||
|
||||
require __DIR__ . '/lib/Response.php';
|
||||
require __DIR__ . '/lib/Store.php';
|
||||
|
||||
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
try {
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > 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);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** JSON response helpers. Every API answer goes through here. */
|
||||
final class Response
|
||||
{
|
||||
public static function json(mixed $data, int $status = 200): never
|
||||
{
|
||||
http_response_code($status);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function error(string $message, int $status, array $extra = []): never
|
||||
{
|
||||
self::json(['error' => $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Thrown by Store::save when the client's revision is older than the stored one. */
|
||||
final class StaleRevisionException extends RuntimeException
|
||||
{
|
||||
public function __construct(public readonly int $currentRevision)
|
||||
{
|
||||
parent::__construct("Project was modified elsewhere (revision {$currentRevision})");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File-based project store: one JSON document per project in `data/projects/{id}.json`.
|
||||
* Each file wraps the project with metadata: { id, name, revision, updatedAt, project }.
|
||||
* Writes are atomic (temp file + rename) and guarded by an integer revision so that a
|
||||
* stale client (second tab, old session) cannot silently overwrite newer work.
|
||||
*/
|
||||
final class Store
|
||||
{
|
||||
public function __construct(private readonly string $dir)
|
||||
{
|
||||
if (!is_dir($this->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<array{id:string,name:string,revision:int,updatedAt:string}> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1741
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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<CommandListener>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<AssetType, 'scene'>;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, PropertyValue>;
|
||||
|
||||
export interface Vec2 {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
@@ -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: {} };
|
||||
}
|
||||
@@ -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<AssetType, PropertySchema> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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<ProjectSummary[]> {
|
||||
return this.request<ProjectSummary[]>('GET', 'projects');
|
||||
}
|
||||
|
||||
async load(id: string): Promise<LoadedProject> {
|
||||
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<number> {
|
||||
const body = await this.request<{ revision: number }>('PUT', `projects/${encodeURIComponent(project.id)}`, {
|
||||
revision,
|
||||
project: serializeProject(project),
|
||||
});
|
||||
return body.revision;
|
||||
}
|
||||
|
||||
async create(name: string): Promise<LoadedProject> {
|
||||
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<void> {
|
||||
await this.request<void>('DELETE', `projects/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
private async request<T>(method: string, route: string, payload?: unknown): Promise<T> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<ProjectSummary[]>;
|
||||
load(id: string): Promise<LoadedProject>;
|
||||
/** Saves the project and returns the new revision. Rejects with StaleRevisionError on conflict. */
|
||||
save(project: Project, revision: number): Promise<number>;
|
||||
create(name: string): Promise<LoadedProject>;
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
||||
@@ -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<string, AssetType>();
|
||||
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<string, AssetType>): 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<string, unknown> {
|
||||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void> } {
|
||||
let timer = 0;
|
||||
let saving: Promise<void> | null = null;
|
||||
let pending = false;
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
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<void> => {
|
||||
window.clearTimeout(timer);
|
||||
await save();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (state.saveStatus === 'dirty' || state.saveStatus === 'saving') {
|
||||
void flush();
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return { flush };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<AssetType, string> = { 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<string, FieldRow>();
|
||||
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;
|
||||
}
|
||||
@@ -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<SaveStatus, string> = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<EditorEvent, Set<Listener>>();
|
||||
|
||||
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;
|
||||
}
|
||||
+43
@@ -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<void> {
|
||||
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 = `<pre style="color:#e0553f;padding:16px;font:13px monospace">${String(error)}</pre>`;
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Keyboard state tracker for play mode. Only records keys; systems interpret them. */
|
||||
export class Input {
|
||||
private readonly down = new Set<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The one piece of DOM in the whole application: an invisible, off-screen `<input>` 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<CSSStyleDeclaration>);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<string, TextStyle>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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<StackOptions['align']>;
|
||||
|
||||
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<T extends Widget>(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';
|
||||
}
|
||||
}
|
||||
@@ -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<ButtonOptions['variant']>;
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<LabelOptions['align']>;
|
||||
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) + '…';
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<TextFieldOptions, 'onInput' | 'onChange'> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Widget['destroy']>[0]): void {
|
||||
this.stopEditing(false);
|
||||
super.destroy(options);
|
||||
}
|
||||
}
|
||||
@@ -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<string, Graphics>();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
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<string>();
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
+242
@@ -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 <projectId> [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 <projectId> [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);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user