Refactoring round 8: hide data files, safe saves, Esc as Cancel (#85)
game-maker / gate (push) Successful in 3m53s
game-maker / suite (push) Successful in 11m55s

Recurring cleanup round over the code since round 7 (bc688f3). The
issue now also asks to fix bugs and small inconsistencies found on the
way, so unlike the rounds before, this one changes behaviour in a few
places. Duplicated code in api went from 23 clones with 228 lines to
none (jscpd, at least 50 tokens). In src it stays at 22 clones with
271 lines, the same before and after.

Bugs:
- nginx served project and snapshot files as they were, so anyone who
  knew a project id could read it without signing in. Every file the
  API writes now goes through AtomicFile (temp file, mode, rename).
  Projects, snapshots, releases, accounts and sessions are 0640, and
  nginx answers 403 for them.
- Any signed-in account could delete a shared image, font or sound,
  which projects of other accounts may wear. Deleting one is now for
  administrators only. The editor never deleted a file, so the
  client's delete is gone.
- Two saves of one project could run at once. The lock file was
  removed after each save, so a waiting save and the next one locked
  different files. The lock file now stays, and deleting a project
  takes the lock as well.
- Bytes that are no picture got a 500 instead of a 422, because the
  error handler also threw for warnings silenced with @.
- A session file was rewritten in place. A request reading it at that
  moment found it empty and was signed out. It is replaced atomically.
- Delete did nothing for a shape picked in the vector art list. Delete
  now goes through removeSelection, one case per kind of selection,
  and a kind without a case fails the type check.

Inconsistencies:
- Escape on a question does what its Cancel does. Cancel on "Delete
  project?" and "Restore snapshot?" leads back to the picker or the
  list like the other questions did, and so does deleting a project.
- Editor texts use American spelling throughout, like the port names:
  48 British spellings in 45 lines (colour, centre, initialised).
- objectName and groupName replace eleven copies of "own name, else
  the asset's" and three of "Group". One copy fell back to the object
  id instead of "?".

Duplicates, dead code, comments:
- routeFiles serves images, fonts and sounds instead of three copies.
  HashedFileStore holds what the three stores shared: keeping a file
  under its hash with its name, listing and deleting. The stores keep
  only which bytes they take and what a record says.
- AtomicFile replaces nine copies of temp file plus rename.
- removeShapes is the one way the canvas, the inspector button and
  Delete take shapes out.
- GraphInterpreter.fire was called by tests only. The 54 calls use a
  helper in tests/helpers.ts now.
- FileStore.delete, HttpFileStore.delete and the PluginApi type were
  unused. A doc comment in actions.ts sat above the wrong function.

Checked and left as is:
- solidCells is used by tests only. It is the per-cell view before the
  merge into rectangles, the one place the largest-scale rule shows.
- An undo checker ran all 124 commands of the unit tests and compared
  the project before and after undo. The only differences are empty
  ports, groups and notes lists where there were none, which the file
  format drops anyway.

Docs:
- README: the check sum.
- Wiki, own commit: API (admin-only deletes, 422, file modes, lock
  file), Home (the "Not yet" list named three things that exist),
  Concepts, Keyboard, Vector art, Architecture and Development, and
  the 38 characters Gitea flagged as ambiguous on eight pages.
- .gitignore: the lock files, and pasted-*.png in any folder, which
  keeps the screenshot archive out.

Tests:
- ./check.sh: 195 files, 2817 tests, exit 0 (+2 for the name helpers).
- PHP: accounts 52 in the web container and 53 in the CI image with
  the race test (+2), snapshots 15 (+1), releases 20.
- Full e2e run: ALL PASSED, 1586 checks (+6 in 12, 47, 49 and 50).
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks. The example
  project files were restored byte for byte afterwards.
- The file stores of HEAD and of this commit give byte-identical
  output for the live folders and for store, list and delete in a
  throwaway folder.
- Counter-checks, all fail as expected: a 500 for bad bytes fails 12,
  no admin check fails 49, removing the lock file again loses saves
  in 3 of 3 runs (151 to 170 of 321), Delete ignoring shapes fails 47,
  Escape that only closes fails 50, and dropping the shape case fails
  the type check (TS2366).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-29 18:12:55 +02:00
1 parent bc688f3efc
commit 14972d057f
81 files changed
+661 -575

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
/data/projects/*.json /data/projects/*.json
/data/projects/*.tmp /data/projects/*.tmp
/data/projects/*.lock
/data/e2e-password.txt /data/e2e-password.txt
/data/e2e-admin-password.txt /data/e2e-admin-password.txt
/data/sessions/ /data/sessions/
@@ -19,4 +20,4 @@
/wiki/ /wiki/
/index.html /index.html
/play.html /play.html
/pasted-*.png pasted-*.png
+1 -1
View File
@@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1580 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1586 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+63 -81
View File
@@ -51,15 +51,15 @@ declare(strict_types=1);
* *
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt}] * GET ?r=textures → [{file,name,width,height,bytes,updatedAt}]
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes} * POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
* DELETE ?r=textures/{file} → 204 * DELETE ?r=textures/{file} → 204 (administrators only, 403 for everybody else)
* *
* GET ?r=fonts → [{file,name,bytes,updatedAt}] * GET ?r=fonts → [{file,name,bytes,updatedAt}]
* POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes} * POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes}
* DELETE ?r=fonts/{file} → 204 * DELETE ?r=fonts/{file} → 204 (administrators only)
* *
* GET ?r=sounds → [{file,name,bytes,updatedAt}] * GET ?r=sounds → [{file,name,bytes,updatedAt}]
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes} * POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
* DELETE ?r=sounds/{file} → 204 * DELETE ?r=sounds/{file} → 204 (administrators only)
* *
* POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130) * POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130)
* *
@@ -74,7 +74,9 @@ declare(strict_types=1);
*/ */
require __DIR__ . '/lib/AccountRules.php'; require __DIR__ . '/lib/AccountRules.php';
require __DIR__ . '/lib/AtomicFile.php';
require __DIR__ . '/lib/Auth.php'; require __DIR__ . '/lib/Auth.php';
require __DIR__ . '/lib/HashedFileStore.php'; // before the font, sound and texture stores built on it
require __DIR__ . '/lib/FontStore.php'; require __DIR__ . '/lib/FontStore.php';
require __DIR__ . '/lib/OriginalName.php'; require __DIR__ . '/lib/OriginalName.php';
require __DIR__ . '/lib/PixelStore.php'; require __DIR__ . '/lib/PixelStore.php';
@@ -101,6 +103,11 @@ const MAX_FONT_BYTES = 4 * 1024 * 1024;
const MAX_SOUND_BYTES = 16 * 1024 * 1024; const MAX_SOUND_BYTES = 16 * 1024 * 1024;
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool { set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
// PHP calls the handler for a call silenced with `@` as well, only with less reported: that
// warning was expected and is answered where it happens (an unreadable image is a 422).
if ((error_reporting() & $severity) === 0) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line); throw new ErrorException($message, 0, $severity, $file, $line);
}); });
@@ -467,23 +474,38 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
Response::error('Method not allowed', 405); Response::error('Method not allowed', 405);
} }
/** @param list<string> $parts */ /**
function routeTextures(array $parts, string $method): never * Images, fonts and sounds: shared between the accounts and stored under the hash of their bytes.
{ * Everybody signed in uploads and lists them. Deleting one is for administrators only, because a
$store = new TextureStore(dirname(__DIR__) . '/data/textures'); * project of another account may be wearing it – the editor itself never deletes a file.
*
* @param list<string> $parts
* @param class-string<RuntimeException> $invalid what the store throws for bytes it cannot take
*/
function routeFiles(
array $parts,
string $method,
Auth $auth,
HashedFileStore $store,
int $maxBytes,
string $noun,
string $invalid,
): never {
if (count($parts) === 1) { if (count($parts) === 1) {
if ($method === 'GET') { if ($method === 'GET') {
Response::json($store->list()); Response::json($store->list());
} }
if ($method === 'POST') { if ($method === 'POST') {
$bytes = Response::rawBody(); $bytes = Response::rawBody();
if (strlen($bytes) > MAX_TEXTURE_BYTES) { if (strlen($bytes) > $maxBytes) {
Response::error('Image must be at most ' . (MAX_TEXTURE_BYTES / 1024 / 1024) . ' MB', 413); Response::error("{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB', 413);
} }
try { try {
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201); Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
} catch (InvalidImageException $e) { } catch (RuntimeException $e) {
if (!$e instanceof $invalid) {
throw $e;
}
Response::error($e->getMessage(), 422); Response::error($e->getMessage(), 422);
} }
} }
@@ -491,74 +513,9 @@ function routeTextures(array $parts, string $method): never
} }
if ($method === 'DELETE') { if ($method === 'DELETE') {
$auth->requireAdmin();
if (!$store->delete($parts[1])) { if (!$store->delete($parts[1])) {
Response::error('Texture not found', 404); Response::error("{$noun} not found", 404);
}
Response::noContent();
}
Response::error('Method not allowed', 405);
}
/** @param list<string> $parts */
function routeFonts(array $parts, string $method): never
{
$store = new FontStore(dirname(__DIR__) . '/data/fonts');
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list());
}
if ($method === 'POST') {
$bytes = Response::rawBody();
if (strlen($bytes) > MAX_FONT_BYTES) {
Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413);
}
try {
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
} catch (InvalidFontException $e) {
Response::error($e->getMessage(), 422);
}
}
Response::error('Method not allowed', 405);
}
if ($method === 'DELETE') {
if (!$store->delete($parts[1])) {
Response::error('Font not found', 404);
}
Response::noContent();
}
Response::error('Method not allowed', 405);
}
/** @param list<string> $parts */
function routeSounds(array $parts, string $method): never
{
$store = new SoundStore(dirname(__DIR__) . '/data/sounds');
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list());
}
if ($method === 'POST') {
$bytes = Response::rawBody();
if (strlen($bytes) > MAX_SOUND_BYTES) {
Response::error('Sound must be at most ' . (MAX_SOUND_BYTES / 1024 / 1024) . ' MB', 413);
}
try {
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
} catch (InvalidSoundException $e) {
Response::error($e->getMessage(), 422);
}
}
Response::error('Method not allowed', 405);
}
if ($method === 'DELETE') {
if (!$store->delete($parts[1])) {
Response::error('Sound not found', 404);
} }
Response::noContent(); Response::noContent();
} }
@@ -642,12 +599,37 @@ try {
// Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the // Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the
// price of admission, so an uploaded file cannot be dropped in by a stranger. // price of admission, so an uploaded file cannot be dropped in by a stranger.
$user = $auth->require(); $user = $auth->require();
$data = dirname(__DIR__) . '/data';
match ($parts[0]) { match ($parts[0]) {
'projects' => routeProjects($parts, $method, $user, $auth->role($user) === 'admin'), 'projects' => routeProjects($parts, $method, $user, $auth->role($user) === 'admin'),
'textures' => routeTextures($parts, $method), 'textures' => routeFiles(
'fonts' => routeFonts($parts, $method), $parts,
'sounds' => routeSounds($parts, $method), $method,
$auth,
new TextureStore("{$data}/textures"),
MAX_TEXTURE_BYTES,
'Image',
InvalidImageException::class,
),
'fonts' => routeFiles(
$parts,
$method,
$auth,
new FontStore("{$data}/fonts"),
MAX_FONT_BYTES,
'Font',
InvalidFontException::class,
),
'sounds' => routeFiles(
$parts,
$method,
$auth,
new SoundStore("{$data}/sounds"),
MAX_SOUND_BYTES,
'Sound',
InvalidSoundException::class,
),
'pixels' => routePixels($parts, $method), 'pixels' => routePixels($parts, $method),
'plugins' => routePlugins($parts, $method), 'plugins' => routePlugins($parts, $method),
default => Response::error('Not found', 404), default => Response::error('Not found', 404),
+27
View File
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/**
* Every file the API writes goes through here: into a temp file beside it, given its mode, then
* renamed over the old one. A reader never sees half a file, and the name never shows up with the
* wrong mode – the web server serves `data/` as static files, so a private file that is readable
* for a moment is readable.
*/
final class AtomicFile
{
/** Read by PHP only: projects, snapshots, releases, accounts and sessions. */
public const HIDDEN = 0640;
/** Handed out by the web server as they are: images, fonts, sounds, cels and their names. */
public const SERVED = 0664;
/** @throws RuntimeException naming `$what` when the file cannot be written */
public static function write(string $path, string $bytes, int $mode, string $what): void
{
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !chmod($tmp, $mode) || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException("Cannot write {$what}");
}
}
}
+5 -72
View File
@@ -2,15 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Content-addressed font store: `data/fonts/{sha256}.{ext}` – the image store's twin, for the * Content-addressed font store: `data/fonts/{sha256}.{ext}` (see HashedFileStore).
* same reasons (see TextureStore): one file per typeface however often it is uploaded, a URL
* that can be cached forever, and deleting a font asset stays undoable because nothing here
* is removed when the last project stops referencing it.
* *
* A font is stored byte for byte. There is no re-encoding step as there is for images, so what * A font is stored byte for byte. There is no re-encoding step as there is for images, so what
* the browser loads is exactly what was picked. * the browser loads is exactly what was picked. The picker shows each face by rendering with it,
* so a record has nothing to read out of the file.
*/ */
final class FontStore final class FontStore extends HashedFileStore
{ {
/** /**
* Accepted formats, keyed by the four bytes every one of them starts with. Bitmap and * Accepted formats, keyed by the four bytes every one of them starts with. Bitmap and
@@ -25,14 +23,6 @@ final class FontStore
'wOF2' => 'woff2', 'wOF2' => 'woff2',
]; ];
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 font directory {$this->dir}");
}
}
/** A stored file name, as it appears in a project document. */
public static function isValidName(string $name): bool public static function isValidName(string $name): bool
{ {
return (bool) preg_match('/^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/', $name); return (bool) preg_match('/^[a-f0-9]{64}\.(ttf|otf|woff|woff2)$/', $name);
@@ -63,64 +53,7 @@ final class FontStore
throw new InvalidFontException('Font file is truncated'); throw new InvalidFontException('Font file is truncated');
} }
$file = hash('sha256', $bytes) . '.' . $extension; return $this->keep($bytes, $extension, $originalName);
$path = $this->dir . '/' . $file;
if (!is_file($path)) {
$this->writeFile($path, $bytes);
}
OriginalName::write($this->dir, $file, $originalName);
return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)];
}
/**
* Every stored file, newest first – what the font picker offers. Unlike an image there is
* nothing to read out of the header here: the picker shows each face by rendering with it.
*
* @return list<array{file:string,name:string,bytes:int,updatedAt:string}>
*/
public function list(): array
{
$items = [];
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
$file = basename($path);
// Sidecars share the directory and are skipped: `{hash}.ttf.json` is not a valid name.
if (!self::isValidName($file)) {
continue;
}
$items[] = [
'file' => $file,
'name' => OriginalName::read($this->dir, $file),
'bytes' => (int) filesize($path),
'updatedAt' => gmdate('c', (int) filemtime($path)),
];
}
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
return $items;
}
public function delete(string $file): bool
{
if (!self::isValidName($file)) {
return false;
}
$path = $this->dir . '/' . $file;
if (!is_file($path) || !unlink($path)) {
return false;
}
OriginalName::delete($this->dir, $file);
return true;
}
/** Temp file plus rename, so a reader never sees a half-written font (see TextureStore). */
private function writeFile(string $path, string $bytes): void
{
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write font file');
}
@chmod($path, 0664);
} }
} }
+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
/**
* The shared half of the image, font and sound stores: `{dir}/{sha256}.{ext}`.
*
* The name of a file is the hash of its bytes, which buys four things at once: uploading the same
* file twice costs one file, a stored file can be cached forever (its URL cannot ever mean
* different bytes), projects reference a short string instead of embedding megabytes of base64,
* and deleting an asset is undoable for free because nothing here is removed when it stops being
* referenced. What a store adds is which bytes it takes, and what its records say beyond the name.
*/
abstract class HashedFileStore
{
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 directory {$this->dir}");
}
}
/** A stored file name, as it appears in a project document. */
abstract public static function isValidName(string $name): bool;
/**
* Every stored file, newest first – what the picker of its kind offers.
*
* @return list<array<string,string|int>>
*/
public function list(): array
{
$items = [];
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
$file = basename($path);
// Sidecars share the directory and are skipped: `{hash}.png.json` is not a valid name.
if (!static::isValidName($file)) {
continue;
}
$items[] = [
'file' => $file,
'name' => OriginalName::read($this->dir, $file),
...$this->details($path),
'bytes' => (int) filesize($path),
'updatedAt' => gmdate('c', (int) filemtime($path)),
];
}
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
return $items;
}
public function delete(string $file): bool
{
if (!static::isValidName($file)) {
return false;
}
$path = $this->dir . '/' . $file;
if (!is_file($path) || !unlink($path)) {
return false;
}
OriginalName::delete($this->dir, $file);
return true;
}
/**
* Keeps bytes that passed the store's checks under their hash, with `$originalName` beside them
* as a caption (see OriginalName), and returns the record. Bytes stored before are not written
* again; a new name renames them.
*
* @param array<string,int> $details what the record says between the name and the size
* @return array<string,string|int>
*/
protected function keep(string $bytes, string $extension, string $originalName, array $details = []): array
{
$file = hash('sha256', $bytes) . '.' . $extension;
$path = $this->dir . '/' . $file;
if (!is_file($path)) {
AtomicFile::write($path, $bytes, AtomicFile::SERVED, 'stored file');
}
OriginalName::write($this->dir, $file, $originalName);
return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), ...$details, 'bytes' => strlen($bytes)];
}
/**
* What a record of a stored file says between its name and its size. An image reads its pixel
* size here; a font or a sound has nothing to read without decoding it.
*
* @return array<string,int>
*/
protected function details(string $path): array
{
return [];
}
}
+6 -7
View File
@@ -70,16 +70,15 @@ final class OriginalName
if ($clean === '') { if ($clean === '') {
return; return;
} }
$path = $dir . '/' . $file . '.json';
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
$json = json_encode(['name' => $clean], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $json = json_encode(['name' => $clean], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false || file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) { if ($json === false) {
if (is_file($tmp)) { return;
unlink($tmp);
} }
return; // A nameless file is a small loss; failing the upload over it would be worse. try {
AtomicFile::write($dir . '/' . $file . '.json', $json, AtomicFile::SERVED, 'name file');
} catch (RuntimeException) {
// A nameless file is a small loss; failing the upload over it would be worse.
} }
chmod($path, 0664);
} }
/** Removing a file that has no sidecar is the normal case, not a failure. */ /** Removing a file that has no sidecar is the normal case, not a failure. */
+1 -8
View File
@@ -54,14 +54,7 @@ final class PixelStore
$file = hash('sha256', $bytes) . '.px'; $file = hash('sha256', $bytes) . '.px';
$path = $this->dir . '/' . $file; $path = $this->dir . '/' . $file;
if (!is_file($path)) { if (!is_file($path)) {
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; AtomicFile::write($path, $bytes, AtomicFile::SERVED, 'pixel file');
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) {
if (is_file($tmp)) {
unlink($tmp);
}
throw new RuntimeException('Cannot write pixel file');
}
chmod($path, 0664);
} }
return ['file' => $file, 'bytes' => $length]; return ['file' => $file, 'bytes' => $length];
} }
+1 -10
View File
@@ -146,15 +146,6 @@ final class ReleaseStore
if ($json === false) { if ($json === false) {
throw new RuntimeException('Cannot encode release'); throw new RuntimeException('Cannot encode release');
} }
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'release file');
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
@unlink($tmp);
throw new RuntimeException('Cannot write release file');
}
chmod($tmp, 0640);
if (!rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write release file');
}
} }
} }
+5 -3
View File
@@ -110,9 +110,11 @@ final class SessionStore
throw new RuntimeException('Invalid session token'); throw new RuntimeException('Invalid session token');
} }
$json = json_encode($data, JSON_UNESCAPED_SLASHES); $json = json_encode($data, JSON_UNESCAPED_SLASHES);
if ($json === false || file_put_contents($path, $json, LOCK_EX) === false) { if ($json === false) {
throw new RuntimeException('Cannot write session file'); throw new RuntimeException('Cannot encode session');
} }
@chmod($path, 0640); // Replaced rather than rewritten in place: a request reading it meanwhile would find it
// empty, and an empty session is a signed-out one.
AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'session file');
} }
} }
+1 -6
View File
@@ -59,12 +59,7 @@ final class SnapshotStore
if ($json === false) { if ($json === false) {
throw new RuntimeException('Cannot encode snapshot'); throw new RuntimeException('Cannot encode snapshot');
} }
$path = $this->path($projectId, $id); AtomicFile::write($this->path($projectId, $id), $json, AtomicFile::HIDDEN, 'snapshot file');
$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 snapshot file');
}
return self::summary($snapshot); return self::summary($snapshot);
} }
+5 -72
View File
@@ -2,15 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Content-addressed sound store: `data/sounds/{sha256}.{ext}` – the font store's twin (see * Content-addressed sound store: `data/sounds/{sha256}.{ext}` (see HashedFileStore).
* FontStore), for the same reasons: one file per sound however often it is uploaded, a URL that
* can be cached forever, and deleting a sound asset stays undoable because nothing here is
* removed when the last project stops referencing it.
* *
* A sound is stored byte for byte. There is no re-encoding step as there is for images: the * A sound is stored byte for byte. There is no re-encoding step as there is for images: the
* browser decodes what was picked, and nothing here could encode audio anyway. * browser decodes what was picked, and nothing here could encode audio anyway. The picker plays a
* sound instead of showing it, and its length is not read, since only a decoder knows it.
*/ */
final class SoundStore final class SoundStore extends HashedFileStore
{ {
/** /**
* Accepted formats, keyed by the bytes they start with. MP3 is the exception with no single * Accepted formats, keyed by the bytes they start with. MP3 is the exception with no single
@@ -22,14 +20,6 @@ final class SoundStore
'ID3' => 'mp3', 'ID3' => 'mp3',
]; ];
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 sound directory {$this->dir}");
}
}
/** A stored file name, as it appears in a project document. */
public static function isValidName(string $name): bool public static function isValidName(string $name): bool
{ {
return (bool) preg_match('/^[a-f0-9]{64}\.(mp3|ogg|wav|m4a)$/', $name); return (bool) preg_match('/^[a-f0-9]{64}\.(mp3|ogg|wav|m4a)$/', $name);
@@ -55,53 +45,7 @@ final class SoundStore
throw new InvalidSoundException('Not a sound file – use MP3, OGG, WAV or M4A'); throw new InvalidSoundException('Not a sound file – use MP3, OGG, WAV or M4A');
} }
$file = hash('sha256', $bytes) . '.' . $extension; return $this->keep($bytes, $extension, $originalName);
$path = $this->dir . '/' . $file;
if (!is_file($path)) {
$this->writeFile($path, $bytes);
}
OriginalName::write($this->dir, $file, $originalName);
return ['file' => $file, 'name' => OriginalName::read($this->dir, $file), 'bytes' => strlen($bytes)];
}
/**
* Every stored file, newest first – what the sound picker offers. A sound has nothing to show,
* so the picker plays it instead; length is not read here, since only a decoder knows it.
*
* @return list<array{file:string,name:string,bytes:int,updatedAt:string}>
*/
public function list(): array
{
$items = [];
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
$file = basename($path);
// Sidecars share the directory and are skipped: `{hash}.mp3.json` is not a valid name.
if (!self::isValidName($file)) {
continue;
}
$items[] = [
'file' => $file,
'name' => OriginalName::read($this->dir, $file),
'bytes' => (int) filesize($path),
'updatedAt' => gmdate('c', (int) filemtime($path)),
];
}
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
return $items;
}
public function delete(string $file): bool
{
if (!self::isValidName($file)) {
return false;
}
$path = $this->dir . '/' . $file;
if (!is_file($path) || !unlink($path)) {
return false;
}
OriginalName::delete($this->dir, $file);
return true;
} }
/** The format these bytes are, `''` when they are none we take. */ /** The format these bytes are, `''` when they are none we take. */
@@ -138,17 +82,6 @@ final class SoundStore
$version = ($second >> 3) & 0b11; $version = ($second >> 3) & 0b11;
return $first === 0xFF && ($second & 0xE0) === 0xE0 && $version !== 0b01 ? 'mp3' : ''; return $first === 0xFF && ($second & 0xE0) === 0xE0 && $version !== 0b01 ? 'mp3' : '';
} }
/** Temp file plus rename, so a reader never sees a half-written sound (see TextureStore). */
private function writeFile(string $path, string $bytes): void
{
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write sound file');
}
@chmod($path, 0664);
}
} }
/** Thrown by SoundStore::store when the uploaded bytes are not a usable sound. */ /** Thrown by SoundStore::store when the uploaded bytes are not a usable sound. */
+11 -10
View File
@@ -123,11 +123,14 @@ final class Store
public function delete(string $id): bool public function delete(string $id): bool
{ {
if ($this->load($id) === null) {
return false;
}
$path = $this->path($id); $path = $this->path($id);
return file_exists($path) && unlink($path); // Under the save lock, or a save running at the same time writes the project back.
$deleted = $this->locked($path, fn(): bool => $this->load($id) !== null && unlink($path));
if ($deleted) {
// Whoever still waits for the lock finds no project and writes nothing.
@unlink($path . '.lock');
}
return $deleted;
} }
/** /**
@@ -154,6 +157,9 @@ final class Store
} }
/** /**
* The lock file stays when the work is done. Removing it would let two saves in at once: one
* still waiting holds the removed file, while the next one creates a new file and locks that.
*
* @template T * @template T
* @param callable():T $work * @param callable():T $work
* @return T * @return T
@@ -169,7 +175,6 @@ final class Store
} finally { } finally {
flock($lock, LOCK_UN); flock($lock, LOCK_UN);
fclose($lock); fclose($lock);
@unlink($path . '.lock');
} }
} }
@@ -221,10 +226,6 @@ final class Store
if ($json === false) { if ($json === false) {
throw new RuntimeException('Cannot encode project'); throw new RuntimeException('Cannot encode project');
} }
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp'; AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'project file');
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write project file');
}
} }
} }
+12 -86
View File
@@ -2,19 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Content-addressed image store: `data/textures/{sha256}.{ext}`. * Content-addressed image store: `data/textures/{sha256}.{ext}` (see HashedFileStore).
* *
* The name of a file is the hash of its bytes, which buys four things at once: * The flip side of hashed names is that files outlive the projects that used them: nothing
* uploading the same image twice costs one file, a stored file can be cached forever * collects the unreferenced ones. They stay pickable instead – `GET ?r=textures` is what the
* (its URL cannot ever mean different bytes), projects reference a short string instead * editor's image picker offers, so a picture one project dropped is still there for the next.
* of embedding megabytes of base64, and deleting a texture asset is undoable for free
* because nothing here is removed when it stops being referenced.
*
* The flip side is that files outlive the projects that used them: nothing collects the
* unreferenced ones. They stay pickable instead – `GET ?r=textures` is what the editor's
* image picker offers, so a picture one project dropped is still there for the next.
*/ */
final class TextureStore final class TextureStore extends HashedFileStore
{ {
/** Accepted formats. SVG is deliberately absent: it can carry script. */ /** Accepted formats. SVG is deliberately absent: it can carry script. */
private const EXTENSIONS = [ private const EXTENSIONS = [
@@ -38,14 +32,6 @@ final class TextureStore
*/ */
private const MAX_DIMENSION = 8192; private const MAX_DIMENSION = 8192;
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 texture directory {$this->dir}");
}
}
/** A stored file name, as it appears in a project document. */
public static function isValidName(string $name): bool public static function isValidName(string $name): bool
{ {
return (bool) preg_match('/^[a-f0-9]{64}\.(png|jpg|webp|gif)$/', $name); return (bool) preg_match('/^[a-f0-9]{64}\.(png|jpg|webp|gif)$/', $name);
@@ -87,80 +73,20 @@ final class TextureStore
throw new InvalidImageException('Image must be between 1 and ' . self::MAX_DIMENSION . ' pixels per side'); throw new InvalidImageException('Image must be between 1 and ' . self::MAX_DIMENSION . ' pixels per side');
} }
$file = hash('sha256', $bytes) . '.' . $extension; return $this->keep($bytes, $extension, $originalName, ['width' => $width, 'height' => $height]);
$path = $this->dir . '/' . $file;
if (!is_file($path)) {
$this->writeFile($path, $bytes);
}
OriginalName::write($this->dir, $file, $originalName);
return [
'file' => $file,
'name' => OriginalName::read($this->dir, $file),
'width' => $width,
'height' => $height,
'bytes' => strlen($bytes),
];
} }
/** /**
* Every stored file, newest first – what the image picker offers. * The pixel size, read from each header rather than left to the client: a texture asset records
* the size, so a picker that only knew the file name could not create one without decoding the
* picture first. An unreadable file reports 0 × 0 and the picker skips it.
* *
* The pixel size is read from each header rather than left to the client: a texture asset * @return array{width:int,height:int}
* records the size, so a picker that only knew the file name could not create one without
* decoding the picture first. An unreadable file reports 0 × 0 and the picker skips it.
*
* @return list<array{file:string,name:string,width:int,height:int,bytes:int,updatedAt:string}>
*/ */
public function list(): array protected function details(string $path): array
{ {
$items = [];
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
$file = basename($path);
// Sidecars live in the same directory and are skipped here: `{hash}.png.json` is not
// a valid stored name.
if (!self::isValidName($file)) {
continue;
}
$size = @getimagesize($path); $size = @getimagesize($path);
$items[] = [ return ['width' => (int) ($size[0] ?? 0), 'height' => (int) ($size[1] ?? 0)];
'file' => $file,
'name' => OriginalName::read($this->dir, $file),
'width' => (int) ($size[0] ?? 0),
'height' => (int) ($size[1] ?? 0),
'bytes' => (int) filesize($path),
'updatedAt' => gmdate('c', (int) filemtime($path)),
];
}
usort($items, static fn(array $a, array $b): int => strcmp($b['updatedAt'], $a['updatedAt']));
return $items;
}
public function delete(string $file): bool
{
if (!self::isValidName($file)) {
return false;
}
$path = $this->dir . '/' . $file;
if (!is_file($path) || !unlink($path)) {
return false;
}
OriginalName::delete($this->dir, $file);
return true;
}
/**
* Writes via a temp file in the same directory, so a reader never sees a half-written
* image: the name only ever appears once the bytes behind it are complete.
*/
private function writeFile(string $path, string $bytes): void
{
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $bytes, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write texture file');
}
@chmod($path, 0664);
} }
} }
+1 -6
View File
@@ -216,11 +216,6 @@ final class UserStore
if ($json === false) { if ($json === false) {
throw new RuntimeException('Cannot encode users'); throw new RuntimeException('Cannot encode users');
} }
$tmp = $this->file . '.' . bin2hex(random_bytes(4)) . '.tmp'; AtomicFile::write($this->file, $json, AtomicFile::HIDDEN, 'users file');
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $this->file)) {
@unlink($tmp);
throw new RuntimeException('Cannot write users file');
}
@chmod($this->file, 0640);
} }
} }
+1 -1
View File
@@ -270,7 +270,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand {
else key.interpolation = this.interpolation; else key.interpolation = this.interpolation;
changed += 1; changed += 1;
} }
if (changed === 0) throw new CommandError('Only a number or colour key can ease'); if (changed === 0) throw new CommandError('Only a number or color key can ease');
} }
} }
+2 -2
View File
@@ -4,7 +4,7 @@ import { isLiveInstance, type ObjectRef, resolvedHolder } from '../model/instanc
import type { Asset } from '../model/Asset'; import type { Asset } from '../model/Asset';
import type { PropertyBag } from '../model/Properties'; import type { PropertyBag } from '../model/Properties';
import { findAsset, type ObjectHolder, type Project } from '../model/Project'; import { findAsset, type ObjectHolder, type Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject'; import { createSceneObject, objectName, type SceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve'; import { resolveSceneObjectProps } from '../properties/resolve';
import { transformOfObject } from '../properties/transform'; import { transformOfObject } from '../properties/transform';
import { removeById } from '../util/list'; import { removeById } from '../util/list';
@@ -52,7 +52,7 @@ abstract class AddPartCommand implements Command {
if (requireAsset(project, this.assetId).type !== this.partType) if (requireAsset(project, this.assetId).type !== this.partType)
throw new CommandError(`That asset is not a ${this.partType}`); throw new CommandError(`That asset is not a ${this.partType}`);
} else { } else {
project.assets.push(this.build(project, target, targetAsset, target.name || targetAsset.name)); project.assets.push(this.build(project, target, targetAsset, objectName(target, targetAsset)));
} }
holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId)); holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
} }
+2 -2
View File
@@ -72,9 +72,9 @@ export class SetPaletteColorsCommand implements Command {
execute(project: Project): void { execute(project: Project): void {
const palette = requirePalette(project, this.paletteId); const palette = requirePalette(project, this.paletteId);
if (this.colors.length > PALETTE_MAX) if (this.colors.length > PALETTE_MAX)
throw new CommandError(`A palette holds at most ${PALETTE_MAX} colours`); throw new CommandError(`A palette holds at most ${PALETTE_MAX} colors`);
if (!this.colors.every((c) => Number.isInteger(c) && c >= 0 && c <= 0xffffff)) if (!this.colors.every((c) => Number.isInteger(c) && c >= 0 && c <= 0xffffff))
throw new CommandError('A colour is 0xRRGGBB'); throw new CommandError('A color is 0xRRGGBB');
this.before = [...palette.colors]; this.before = [...palette.colors];
palette.colors = [...this.colors]; palette.colors = [...this.colors];
} }
+1 -1
View File
@@ -328,7 +328,7 @@ export class SetVectorShapeStyleCommand implements Command {
throw new CommandError('An outline is wider than nothing'); throw new CommandError('An outline is wider than nothing');
for (const paint of [this.style.paint, this.style.stroke?.paint]) for (const paint of [this.style.paint, this.style.stroke?.paint])
if (paint && paint.type !== 'texture' && paint.stops.length < MIN_STOPS) if (paint && paint.type !== 'texture' && paint.stops.length < MIN_STOPS)
throw new CommandError('A gradient needs two colours'); throw new CommandError('A gradient needs two colors');
this.previous = { this.previous = {
fill: shape.fill, fill: shape.fill,
opacity: shape.opacity, opacity: shape.opacity,
+4 -18
View File
@@ -105,24 +105,10 @@ export class GraphInterpreter {
return this.indexOf(this.graph).ofType(type); return this.indexOf(this.graph).ofType(type);
} }
fire( /**
type: string, * Runs the flow of one event node, whichever it is. `port` is the exec output the pulse leaves
ctx: NodeContext, * through – a state event names one.
outputs: Record<string, Value> = {}, */
filter?: (node: GraphNode) => boolean,
/** Which of the event node's exec outputs the pulse leaves through – a state event names one. */
port = 'exec',
): number {
let fired = 0;
for (const node of this.eventNodes(type)) {
if (filter && !filter(node)) continue;
this.pulse(node, ctx, outputs, port);
fired++;
}
return fired;
}
/** Runs the flow of one event node, whichever it is; `fire` is this for every node of a type. */
pulse(node: GraphNode, ctx: NodeContext, outputs: Record<string, Value> = {}, port = 'exec'): void { pulse(node: GraphNode, ctx: NodeContext, outputs: Record<string, Value> = {}, port = 'exec'): void {
this.frame = this.emptyFrame(this.graph); this.frame = this.emptyFrame(this.graph);
this.callStack = []; this.callStack = [];
+1 -1
View File
@@ -54,7 +54,7 @@ export const PlaySound: NodeDef = {
description: description:
'Plays a sound asset. "volume" multiplies the volume the asset itself carries and "loop" either ' + 'Plays a sound asset. "volume" multiplies the volume the asset itself carries and "loop" either ' +
"leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " + "leaves the asset's own setting alone or overrules it, so the asset says what the sound is and " +
'the node only colours it. Each play is a voice of its own: firing this twice in a row is heard ' + 'the node only colors it. Each play is a voice of its own: firing this twice in a row is heard ' +
'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' + 'twice, overlapping. The sound belongs to this object and stops when the object is destroyed, ' +
'unless "detached" is on.', 'unless "detached" is on.',
inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort()], inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort()],
@@ -21,7 +21,7 @@ export const PlayAnimation: NodeDef = {
description: description:
'Plays an animation on an object: its rows reach the object and what is placed inside it, so every ' + 'Plays an animation on an object: its rows reach the object and what is placed inside it, so every ' +
'instance plays on its own. A paused one runs on from where it stands; one already running keeps its ' + 'instance plays on its own. A paused one runs on from where it stands; one already running keeps its ' +
'frame but wins again over the others on the object. Blend fades numbers and colours over that many ' + 'frame but wins again over the others on the object. Blend fades numbers and colors over that many ' +
'seconds from what they held when it took over; everything else switches at once. Blend out is for ' + 'seconds from what they held when it took over; everything else switches at once. Blend out is for ' +
'Once (reset): when its frames run out it fades back to where it started over that many seconds. ' + 'Once (reset): when its frames run out it fades back to where it started over that many seconds. ' +
'Restart starts one already running or paused over from its first frame – blended in from where it ' + 'Restart starts one already running or paused over from its first frame – blended in from where it ' +
+10
View File
@@ -42,6 +42,16 @@ export interface ObjectGroup {
name: string; name: string;
} }
/** What an object is called: its own name, else its asset's, and `?` while the asset is gone. */
export function objectName(object: { name: string }, asset: { name: string } | undefined): string {
return object.name || asset?.name || '?';
}
/** What a group is called; one that was never named is a "Group". */
export function groupName(group: ObjectGroup): string {
return group.name || 'Group';
}
export function createSceneObject( export function createSceneObject(
id: string, id: string,
assetId: string, assetId: string,
+2 -2
View File
@@ -16,7 +16,7 @@ import { treeOrder } from './hierarchy';
import { instanceChildId, resolveObjects } from './instance'; import { instanceChildId, resolveObjects } from './instance';
import { findAsset, type Project } from './Project'; import { findAsset, type Project } from './Project';
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties'; import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
import type { SceneObject } from './SceneObject'; import { objectName, type SceneObject } from './SceneObject';
import { mixColor } from '../util/color'; import { mixColor } from '../util/color';
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */ /** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
@@ -483,7 +483,7 @@ export function trackDef(
/** How the timeline names what a row aims at: the object (or the root itself) and the property. */ /** How the timeline names what a row aims at: the object (or the root itself) and the property. */
export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string { export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string {
const aimed = aimedAt(project, animation, track.target); const aimed = aimedAt(project, animation, track.target);
const who = !aimed ? '(missing)' : aimed.object ? aimed.object.name || aimed.asset.name : aimed.asset.name; const who = !aimed ? '(missing)' : aimed.object ? objectName(aimed.object, aimed.asset) : aimed.asset.name;
const def = const def =
aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property); aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property);
return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`; return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`;
+2 -2
View File
@@ -7,7 +7,7 @@ import { findAsset, type Project } from './Project';
import { treeOrder, type ObjectTree } from './hierarchy'; import { treeOrder, type ObjectTree } from './hierarchy';
import { resolveObjects } from './instance'; import { resolveObjects } from './instance';
import { mapNested, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties'; import { mapNested, type PropertyBag, type PropertyValue, tagged, tagOf, untag } from './Properties';
import type { SceneObject } from './SceneObject'; import { objectName, type SceneObject } from './SceneObject';
import { Recorder } from '../util/recorder'; import { Recorder } from '../util/recorder';
/** The id an object's place names; '' for none and for anything that is no object's place. */ /** The id an object's place names; '' for none and for anything that is no object's place. */
@@ -59,7 +59,7 @@ export function objectChoices(project: Project, holder: ObjectTree, objectId: st
const choices: ObjectChoice[] = []; const choices: ObjectChoice[] = [];
for (const { object, depth } of treeOrder({ ...holder, objects })) { for (const { object, depth } of treeOrder({ ...holder, objects })) {
if (!object.id.startsWith(scope)) continue; if (!object.id.startsWith(scope)) continue;
const label = object.name || findAsset(project, object.assetId)?.name || object.id; const label = objectName(object, findAsset(project, object.assetId));
choices.push({ ref: object.id.slice(scope.length), label, depth }); choices.push({ ref: object.id.slice(scope.length), label, depth });
} }
if (!scope) return choices; if (!scope) return choices;
+3 -3
View File
@@ -229,7 +229,7 @@ export const ParticleColor: ParticleNodeDef = {
type: 'ParticleColor', type: 'ParticleColor',
label: 'Color', label: 'Color',
category: 'particles', category: 'particles',
description: 'A constant colour.', description: 'A constant color.',
inputs: [], inputs: [],
outputs: [numberOut('color')], outputs: [numberOut('color')],
params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })],
@@ -262,7 +262,7 @@ export const ParticleOutput: ParticleNodeDef = {
label: 'Output', label: 'Output',
category: 'particles', category: 'particles',
description: description:
'What the particle looks like: x and y offset it from the source, the scales multiply its size, rotation turns it, colour tints it and alpha fades it.', 'What the particle looks like: x and y offset it from the source, the scales multiply its size, rotation turns it, color tints it and alpha fades it.',
inputs: [ inputs: [
{ key: 'x', label: 'x', type: 'number', inline: false }, { key: 'x', label: 'x', type: 'number', inline: false },
{ key: 'y', label: 'y', type: 'number', inline: false }, { key: 'y', label: 'y', type: 'number', inline: false },
@@ -423,7 +423,7 @@ export const ParticleMixColor: ParticleNodeDef = {
type: 'ParticleMixColor', type: 'ParticleMixColor',
label: 'Mix color', label: 'Mix color',
category: 'particles', category: 'particles',
description: 'Blends two colours channel by channel: t = 0 is a, t = 1 is b. A flame fading to smoke.', description: 'Blends two colors channel by channel: t = 0 is a, t = 1 is b. A flame fading to smoke.',
inputs: [colorIn('a', 0xffffff), colorIn('b', 0x000000), numberIn('t', 0.5)], inputs: [colorIn('a', 0xffffff), colorIn('b', 0x000000), numberIn('t', 0.5)],
outputs: [numberOut('color')], outputs: [numberOut('color')],
params: [], params: [],
+1 -1
View File
@@ -83,7 +83,7 @@ export function compileShader(graph: Graph): ShaderBuild {
}, },
conform: (node, port, expr) => { conform: (node, port, expr) => {
if (port.type === 'number' && expr.type === 'vec3') { if (port.type === 'number' && expr.type === 'vec3') {
walk.problem(node, `"${port.key}" needs a number, not a colour`); walk.problem(node, `"${port.key}" needs a number, not a color`);
return null; return null;
} }
if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' }; if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' };
+13 -13
View File
@@ -150,7 +150,7 @@ export const ShaderOutput: ShaderNodeDef = {
label: 'Output', label: 'Output',
category: 'shader', category: 'shader',
description: description:
'The colour and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.', 'The color and opacity the sprite is drawn with; unconnected, each is the texture’s. Edge says how the picture continues past its border when sampled outside it.',
inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }], inputs: [vec3Port('color'), { key: 'alpha', label: 'alpha', type: 'number', inline: false }],
outputs: [], outputs: [],
params: [{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS }], params: [{ key: EDGE_PARAM, label: 'Edge', kind: 'enum', default: DEFAULT_EDGE, options: EDGE_OPTIONS }],
@@ -241,7 +241,7 @@ export const ShaderColor: ShaderNodeDef = {
type: SHADER_COLOR, type: SHADER_COLOR,
label: 'Color', label: 'Color',
category: 'shader', category: 'shader',
description: 'A constant colour.', description: 'A constant color.',
inputs: [], inputs: [],
outputs: [vec3Port('color')], outputs: [vec3Port('color')],
params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })], params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })],
@@ -261,19 +261,19 @@ export const ShaderNumber: ShaderNodeDef = {
emit: (_inputs, params) => ({ value: { code: floatLiteral(Number(params.value ?? 0)), type: 'float' } }), emit: (_inputs, params) => ({ value: { code: floatLiteral(Number(params.value ?? 0)), type: 'float' } }),
}; };
export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colours.', 0); export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colors.', 0);
export const ShaderSubtract = arithmetic( export const ShaderSubtract = arithmetic(
'ShaderSubtract', 'ShaderSubtract',
'Subtract', 'Subtract',
'-', '-',
'a - b, per channel for colours.', 'a - b, per channel for colors.',
0, 0,
); );
export const ShaderMultiply = arithmetic( export const ShaderMultiply = arithmetic(
'ShaderMultiply', 'ShaderMultiply',
'Multiply', 'Multiply',
'*', '*',
'a × b, per channel for colours.', 'a × b, per channel for colors.',
1, 1,
); );
/** /**
@@ -291,7 +291,7 @@ export const ShaderDivide: ShaderNodeDef = {
type: 'ShaderDivide', type: 'ShaderDivide',
label: 'Divide', label: 'Divide',
category: 'shader', category: 'shader',
description: 'a / b, per channel for colours. Dividing by 0 is 0, not a pixel the driver picks.', description: 'a / b, per channel for colors. Dividing by 0 is 0, not a pixel the driver picks.',
inputs: [anyIn('a', 1), anyIn('b', 1)], inputs: [anyIn('a', 1), anyIn('b', 1)],
outputs: [anyOut('result')], outputs: [anyOut('result')],
params: [], params: [],
@@ -305,7 +305,7 @@ export const ShaderClamp: ShaderNodeDef = {
type: 'ShaderClamp', type: 'ShaderClamp',
label: 'Clamp', label: 'Clamp',
category: 'shader', category: 'shader',
description: 'Holds the value inside min..max, per channel for colours.', description: 'Holds the value inside min..max, per channel for colors.',
inputs: [anyIn('value', 0), floatIn('min', 0), floatIn('max', 1)], inputs: [anyIn('value', 0), floatIn('min', 0), floatIn('max', 1)],
outputs: [anyOut('result')], outputs: [anyOut('result')],
params: [], params: [],
@@ -332,7 +332,7 @@ export const ShaderLuminance: ShaderNodeDef = {
type: 'ShaderLuminance', type: 'ShaderLuminance',
label: 'Luminance', label: 'Luminance',
category: 'shader', category: 'shader',
description: 'How bright a colour is (Rec. 709 weights), as one number.', description: 'How bright a color is (Rec. 709 weights), as one number.',
inputs: [vec3Port('color')], inputs: [vec3Port('color')],
outputs: [{ key: 'value', label: 'value', type: 'number' }], outputs: [{ key: 'value', label: 'value', type: 'number' }],
params: [], params: [],
@@ -358,7 +358,7 @@ export const ShaderSin = unary(
'ShaderSin', 'ShaderSin',
'Sin', 'Sin',
'sin', 'sin',
'The sine of a number in radians: one full wave every 6.283. Per channel for colours.', 'The sine of a number in radians: one full wave every 6.283. Per channel for colors.',
); );
export const ShaderFloor = unary( export const ShaderFloor = unary(
'ShaderFloor', 'ShaderFloor',
@@ -376,14 +376,14 @@ export const ShaderAbs = unary(
'ShaderAbs', 'ShaderAbs',
'Abs', 'Abs',
'abs', 'abs',
'The distance from 0, sign dropped: -0.3 becomes 0.3. A value crossing 0 becomes a V. Per channel for colours.', 'The distance from 0, sign dropped: -0.3 becomes 0.3. A value crossing 0 becomes a V. Per channel for colors.',
); );
export const ShaderStep: ShaderNodeDef = { export const ShaderStep: ShaderNodeDef = {
type: 'ShaderStep', type: 'ShaderStep',
label: 'Step', label: 'Step',
category: 'shader', category: 'shader',
description: '0 while value is below edge, 1 from edge on: a hard cut. Per channel for colours.', description: '0 while value is below edge, 1 from edge on: a hard cut. Per channel for colors.',
inputs: [floatIn('edge', 0.5), anyIn('value', 0)], inputs: [floatIn('edge', 0.5), anyIn('value', 0)],
outputs: [anyOut('result')], outputs: [anyOut('result')],
params: [], params: [],
@@ -398,7 +398,7 @@ export const ShaderPow: ShaderNodeDef = {
label: 'Pow', label: 'Pow',
category: 'shader', category: 'shader',
description: description:
'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0. Per channel for colours.', 'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0. Per channel for colors.',
inputs: [anyIn('value', 0), floatIn('exponent', 2)], inputs: [anyIn('value', 0), floatIn('exponent', 2)],
outputs: [anyOut('result')], outputs: [anyOut('result')],
params: [], params: [],
@@ -414,7 +414,7 @@ export const ShaderSqrt: ShaderNodeDef = {
label: 'Sqrt', label: 'Sqrt',
category: 'shader', category: 'shader',
description: description:
'The square root: 0.25 becomes 0.5, 4 becomes 2. Below 0 the value counts as 0. Per channel for colours.', 'The square root: 0.25 becomes 0.5, 4 becomes 2. Below 0 the value counts as 0. Per channel for colors.',
inputs: [anyIn('value', 0)], inputs: [anyIn('value', 0)],
outputs: [anyOut('result')], outputs: [anyOut('result')],
params: [], params: [],
+1 -2
View File
@@ -19,10 +19,9 @@ export interface StoredFile extends UploadedFile {
* so there is nothing else to send. `type` only fills the Content-Type header – the server trusts * so there is nothing else to send. `type` only fills the Content-Type header – the server trusts
* the bytes, not the label – and uploading the same bytes twice yields the same file, the hash of * the bytes, not the label – and uploading the same bytes twice yields the same file, the hash of
* its content. `name` is the caption kept beside it; uploading the same bytes under a new name * its content. `name` is the caption kept beside it; uploading the same bytes under a new name
* renames what the pickers show. * renames what the pickers show. Nothing here deletes a file: another account's project may wear it.
*/ */
export interface FileStore<U extends UploadedFile = UploadedFile, S extends StoredFile = StoredFile> { export interface FileStore<U extends UploadedFile = UploadedFile, S extends StoredFile = StoredFile> {
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<U>; upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<U>;
list(): Promise<S[]>; list(): Promise<S[]>;
delete(file: string): Promise<void>;
} }
-4
View File
@@ -27,8 +27,4 @@ export class HttpFileStore<
async list(): Promise<S[]> { async list(): Promise<S[]> {
return apiRequest<S[]>(this.endpoint, 'GET', this.route, {}); return apiRequest<S[]>(this.endpoint, 'GET', this.route, {});
} }
async delete(file: string): Promise<void> {
await apiRequest<void>(this.endpoint, 'DELETE', `${this.route}/${encodeURIComponent(file)}`, {});
}
} }
+14 -3
View File
@@ -150,10 +150,21 @@ export class ProjectSession {
return this.pickerDialog.current(); return this.pickerDialog.current();
} }
/** The question stands in for the picker: Cancel goes back to it, and so does the deletion. */
private confirmDelete(id: string, name: string): void { private confirmDelete(id: string, name: string): void {
new Dialog(`Delete project "${name}"?`, 'All assets and scenes are removed permanently.', [ new Dialog(`Delete project "${name}"?`, 'All assets and scenes are removed permanently.', [
{ label: 'Cancel' }, { label: 'Cancel', onClick: () => this.openProjectPicker() },
{ label: 'Delete', danger: true, onClick: () => void this.run(this.deleteProject(id)) }, {
label: 'Delete',
danger: true,
onClick: () =>
void this.run(
(async () => {
await this.deleteProject(id);
this.openProjectPicker();
})(),
),
},
]).openCentered(); ]).openCentered();
} }
@@ -262,7 +273,7 @@ export class ProjectSession {
`Restore snapshot "${snapshot.name}"?`, `Restore snapshot "${snapshot.name}"?`,
'The project as it is now is kept as a snapshot of its own first, so this can be taken back.', 'The project as it is now is kept as a snapshot of its own first, so this can be taken back.',
[ [
{ label: 'Cancel' }, { label: 'Cancel', onClick: () => this.openSnapshots() },
{ {
label: 'Restore', label: 'Restore',
primary: true, primary: true,
+54 -2
View File
@@ -2,6 +2,7 @@
* Editor actions that several places trigger the same way (inspector, hierarchy context * Editor actions that several places trigger the same way (inspector, hierarchy context
* menu, keyboard): they run the command and put the selection where the user expects it. * menu, keyboard): they run the command and put the selection where the user expects it.
*/ */
import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands';
import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands'; import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands';
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands'; import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
import { import {
@@ -12,8 +13,10 @@ import {
} from '../core/commands/colliderCommands'; } from '../core/commands/colliderCommands';
import { oneStep } from '../core/commands/CompoundCommand'; import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands'; import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands'; import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands'; import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands';
import type { AssetType } from '../core/model/AssetTypes'; import type { AssetType } from '../core/model/AssetTypes';
import { curveUsages } from '../core/model/curve'; import { curveUsages } from '../core/model/curve';
import { structTypeUsages } from '../core/model/structTypes'; import { structTypeUsages } from '../core/model/structTypes';
@@ -29,7 +32,6 @@ import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../
import { Dialog } from '../ui'; import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state'; import { type EditorState, objectSelection } from './state';
/** Groups the objects into a new container asset placed in their stead; selects the container. */
/** /**
* What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link * What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link
* in the inspector): open its tab – and a scene, whose tab is the scene view, becomes the active one. * in the inspector): open its tab – and a scene, whose tab is the scene view, becomes the active one.
@@ -39,6 +41,7 @@ export function activateAsset(state: EditorState, assetId: string): void {
state.openAsset(assetId); state.openAsset(assetId);
} }
/** Groups the objects into a new container asset placed in their stead; selects the container. */
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean { export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
const command = new CreateContainerCommand(holderId, objectIds); const command = new CreateContainerCommand(holderId, objectIds);
if (!state.execute(command)) return false; if (!state.execute(command)) return false;
@@ -80,6 +83,18 @@ export function removeObjects(state: EditorState, holderId: string, objectIds: r
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds)); return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
} }
/** Takes shapes of a vector art out as one undo step; the drawing itself stays selected. */
export function removeShapes(state: EditorState, assetId: string, shapeIds: readonly string[]): boolean {
if (shapeIds.length === 0) return false;
const removed = state.execute(
shapeIds.length === 1
? new RemoveVectorShapeCommand(assetId, shapeIds[0]!)
: new RemoveVectorShapesCommand(assetId, shapeIds),
);
if (removed) state.select({ kind: 'asset', assetId });
return removed;
}
/** Deletes graph nodes as one undo step, however many were selected. */ /** Deletes graph nodes as one undo step, however many were selected. */
export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean { export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean {
const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id)); const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
@@ -115,6 +130,43 @@ export function removeGraphSelection(
return state.execute(oneStep(label, commands)); return state.execute(oneStep(label, commands));
} }
/**
* What Delete does to the selection; false when nothing is selected. Every kind is a case of its
* own, so a new kind of selection cannot slip through without saying what Delete takes away.
*/
export function removeSelection(state: EditorState): boolean {
const s = state.selection;
if (!s) return false;
switch (s.kind) {
case 'asset':
confirmDeleteAsset(state, s.assetId);
return true;
case 'object':
removeObjects(state, s.holderId, s.objectIds);
return true;
case 'node':
removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds);
return true;
case 'annotation':
removeGraphSelection(state, s.assetId, [], s.annotationIds);
return true;
case 'frame':
state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
return true;
case 'shape':
removeShapes(state, s.assetId, s.shapeIds);
return true;
case 'track':
// The keys when some are picked, else the row itself – like a frame of a texture.
state.execute(
s.keys.length > 0
? new RemoveAnimationKeysCommand(s.assetId, s.keys)
: new RemoveAnimationTracksCommand(s.assetId, [s.trackId]),
);
return true;
}
}
/** Asks before deleting an asset, spelling out what goes with it. */ /** Asks before deleting an asset, spelling out what goes with it. */
export function confirmDeleteAsset(state: EditorState, assetId: string): void { export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const asset = findAsset(state.project, assetId); const asset = findAsset(state.project, assetId);
@@ -167,5 +219,5 @@ function wornDetail(sprites: number, animations: number, tiles: number, type: As
// "a, b and c" rather than "a and b and c", which three kinds of user made reachable. // "a, b and c" rather than "a and b and c", which three kinds of user made reachable.
const list = const list =
who.length > 2 ? `${who.slice(0, -1).join(', ')} and ${who[who.length - 1]}` : who.join(' and '); who.length > 2 ? `${who.slice(0, -1).join(', ')} and ${who[who.length - 1]}` : who.join(' and ');
return `${list} reference it and show the plain colour in its place.`; return `${list} reference it and show the plain color in its place.`;
} }
+2 -1
View File
@@ -5,6 +5,7 @@ import {
import { animationTargets, targetDefs, trackDef } from '../../core/model/animation'; import { animationTargets, targetDefs, trackDef } from '../../core/model/animation';
import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset'; import type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project'; import { findAsset } from '../../core/model/Project';
import { objectName } from '../../core/model/SceneObject';
import type { PropertyDef } from '../../core/properties/PropertySchema'; import type { PropertyDef } from '../../core/properties/PropertySchema';
import { type Button, Dropdown, Label, Menu, type MenuItem } from '../../ui'; import { type Button, Dropdown, Label, Menu, type MenuItem } from '../../ui';
import { dialogSlot, FormDialog } from '../FormDialog'; import { dialogSlot, FormDialog } from '../FormDialog';
@@ -90,7 +91,7 @@ export class RowDialog extends FormDialog {
for (const { id, object, depth } of animationTargets(this.state.project, root)) for (const { id, object, depth } of animationTargets(this.state.project, root))
choices.push({ choices.push({
id, id,
label: `${' '.repeat(depth)}${object.name || findAsset(this.state.project, object.assetId)?.name || '?'}`, label: `${' '.repeat(depth)}${objectName(object, findAsset(this.state.project, object.assetId))}`,
}); });
return choices; return choices;
} }
+2 -2
View File
@@ -49,7 +49,7 @@ export function buildAnnotationSection(host: InspectorHost, assetId: string, ann
); );
addNote( addNote(
host, host,
'The text is drawn black or white, whichever reads better on this colour – so any colour stays legible.', 'The text is drawn black or white, whichever reads better on this color – so any color stays legible.',
'small', 'small',
); );
if (annotation.color !== undefined) if (annotation.color !== undefined)
@@ -103,7 +103,7 @@ function addRow(
function describe(kind: AnnotationKind): string { function describe(kind: AnnotationKind): string {
return kind === 'group' return kind === 'group'
? 'Nodes whose centre lies in the box move with it. Deleting the group leaves them where they are.' ? 'Nodes whose center lies in the box move with it. Deleting the group leaves them where they are.'
: 'A comment is read, not run: it frames nothing and moves alone.'; : 'A comment is read, not run: it frames nothing and moves alone.';
} }
+3 -3
View File
@@ -29,7 +29,7 @@ import {
import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs'; import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs';
import { type PropertyValue, textOf } from '../../core/model/Properties'; import { type PropertyValue, textOf } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject'; import { objectName, type SceneObject } from '../../core/model/SceneObject';
import { import {
COMMON_PROPS, COMMON_PROPS,
isCommonProp, isCommonProp,
@@ -120,7 +120,7 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
for (const id of objectIds) { for (const id of objectIds) {
const object = findObject(view, id); const object = findObject(view, id);
const asset = object && findAsset(host.state.project, object.assetId); const asset = object && findAsset(host.state.project, object.assetId);
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`); if (object && asset) addNote(host, `${objectName(object, asset)} · ${asset.type}`);
} }
addGap(host); addGap(host);
addNote(host, 'Ctrl-click adds or removes objects.', 'small'); addNote(host, 'Ctrl-click adds or removes objects.', 'small');
@@ -136,7 +136,7 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }), new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }),
); );
addNote(host, 'Groups the selection into a reusable container', 'small'); addNote(host, 'Groups the selection into a reusable container', 'small');
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small'); addNote(host, 'asset placed at its center (Ctrl+G).', 'small');
addGap(host); addGap(host);
host.body.add( host.body.add(
new Button('Remove', { new Button('Remove', {
+2 -2
View File
@@ -1,7 +1,7 @@
import { SetParentCommand } from '../../core/commands/sceneObjectCommands'; import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy'; import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { findAsset, type ObjectHolder } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject'; import { objectName, type SceneObject } from '../../core/model/SceneObject';
import { Dropdown, Label, Menu, type MenuItem } from '../../ui'; import { Dropdown, Label, Menu, type MenuItem } from '../../ui';
import { labelledRow } from './fieldFactory'; import { labelledRow } from './fieldFactory';
import type { InspectorHost } from './host'; import type { InspectorHost } from './host';
@@ -14,7 +14,7 @@ import { viewOf } from './objectQueries';
*/ */
export function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) { export function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) {
const { row } = labelledRow('Parent'); const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?'; const nameOf = (o: SceneObject) => objectName(o, findAsset(host.state.project, o.assetId));
const parent = findObject(holder, object.parentId); const parent = findObject(holder, object.parentId);
const name = parent ? nameOf(parent) : 'None'; const name = parent ? nameOf(parent) : 'None';
if (fixed) { if (fixed) {
+9 -12
View File
@@ -14,7 +14,6 @@ import {
DuplicateVectorShapeCommand, DuplicateVectorShapeCommand,
MoveVectorShapeCommand, MoveVectorShapeCommand,
RemoveVectorShapeCommand, RemoveVectorShapeCommand,
RemoveVectorShapesCommand,
RenameVectorShapeCommand, RenameVectorShapeCommand,
SetVectorShapeStyleCommand, SetVectorShapeStyleCommand,
} from '../../core/commands/vectorArtCommands'; } from '../../core/commands/vectorArtCommands';
@@ -52,6 +51,7 @@ import {
} from '../../core/model/vectorPaint'; } from '../../core/model/vectorPaint';
import type { PropertyDef } from '../../core/properties/PropertySchema'; import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, HStack, Theme } from '../../ui'; import { Button, HStack, Theme } from '../../ui';
import { removeShapes } from '../actions';
import { type EditorState, shapeSelection } from '../state'; import { type EditorState, shapeSelection } from '../state';
import { GradientBar } from '../vectorart/GradientBar'; import { GradientBar } from '../vectorart/GradientBar';
import { ShapeList } from '../vectorart/ShapeList'; import { ShapeList } from '../vectorart/ShapeList';
@@ -74,7 +74,7 @@ interface PreviewStyle {
export type PaintPart = 'fill' | 'stroke'; export type PaintPart = 'fill' | 'stroke';
const FILL_KIND_LABELS: Record<FillKind, string> = { const FILL_KIND_LABELS: Record<FillKind, string> = {
solid: 'Solid colour', solid: 'Solid color',
linear: 'Linear gradient', linear: 'Linear gradient',
radial: 'Radial gradient', radial: 'Radial gradient',
texture: 'Texture', texture: 'Texture',
@@ -112,7 +112,7 @@ const TEXTURE_DEF: PropertyDef = {
function stopDefs(part: PaintPart): { color: PropertyDef; alpha: PropertyDef; offset: PropertyDef } { function stopDefs(part: PaintPart): { color: PropertyDef; alpha: PropertyDef; offset: PropertyDef } {
const prefix = part === 'fill' ? 'fillStop' : 'lineStop'; const prefix = part === 'fill' ? 'fillStop' : 'lineStop';
return { return {
color: { key: `${prefix}Color`, label: 'Stop colour', kind: 'color', default: 0xffffff }, color: { key: `${prefix}Color`, label: 'Stop color', kind: 'color', default: 0xffffff },
alpha: { alpha: {
key: `${prefix}Alpha`, key: `${prefix}Alpha`,
label: 'Stop opacity', label: 'Stop opacity',
@@ -187,7 +187,7 @@ const OPACITY_DEF: PropertyDef = {
const OUTLINE_DEF: PropertyDef = { key: 'outline', label: 'Outline', kind: 'boolean', default: false }; const OUTLINE_DEF: PropertyDef = { key: 'outline', label: 'Outline', kind: 'boolean', default: false };
const STROKE_COLOR_DEF: PropertyDef = { const STROKE_COLOR_DEF: PropertyDef = {
key: 'strokeColor', key: 'strokeColor',
label: 'Line colour', label: 'Line color',
kind: 'color', kind: 'color',
default: DEFAULT_STROKE.color, default: DEFAULT_STROKE.color,
}; };
@@ -303,7 +303,7 @@ export function buildShapeSection(
STROKE_COLOR_DEF, STROKE_COLOR_DEF,
(s) => s.stroke?.color ?? DEFAULT_STROKE.color, (s) => s.stroke?.color ?? DEFAULT_STROKE.color,
(input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }), (input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }),
'Set line colour', 'Set line color',
); );
else gradientRows(host, assetId, shapeId, 'stroke'); else gradientRows(host, assetId, shapeId, 'stroke');
styleRow( styleRow(
@@ -323,10 +323,7 @@ export function buildShapeSection(
host.body.add( host.body.add(
new Button('Delete shapes', { new Button('Delete shapes', {
variant: 'danger', variant: 'danger',
onClick: () => { onClick: () => removeShapes(host.state, assetId, shapeIds),
host.state.execute(new RemoveVectorShapesCommand(assetId, shapeIds));
host.state.select({ kind: 'asset', assetId });
},
}), }),
); );
host.body.add(addShapeButton(host, assetId)); host.body.add(addShapeButton(host, assetId));
@@ -683,9 +680,9 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par
}, },
label, label,
); );
stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop colour'); stopRow(defs.color, (stop) => stop.color, 'color', 'Set stop color');
stopRow(defs.alpha, (stop) => stop.alpha, 'alpha', 'Set stop opacity'); stopRow(defs.alpha, (stop) => stop.alpha, 'alpha', 'Set stop opacity');
stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move colour stop'); stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move color stop');
const remove = new Button('Remove stop', { const remove = new Button('Remove stop', {
variant: 'ghost', variant: 'ghost',
@@ -701,7 +698,7 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par
assetId, assetId,
shapeId, shapeId,
withGradient(shape, part, next), withGradient(shape, part, next),
'Remove colour stop', 'Remove color stop',
), ),
); );
}, },
+3 -3
View File
@@ -1,4 +1,4 @@
import type { ObjectGroup } from '../../core/model/SceneObject'; import { groupName, type ObjectGroup } from '../../core/model/SceneObject';
import { type Button, Dropdown, Menu, TextField, ui } from '../../ui'; import { type Button, Dropdown, Menu, TextField, ui } from '../../ui';
import { dialogSlot, FormDialog } from '../FormDialog'; import { dialogSlot, FormDialog } from '../FormDialog';
@@ -72,8 +72,8 @@ export class GroupNameDialog extends FormDialog {
return [ return [
{ label: NEW_GROUP, onSelect: pick('', NEW_GROUP) }, { label: NEW_GROUP, onSelect: pick('', NEW_GROUP) },
...(this.join?.groups ?? []).map((g) => ({ ...(this.join?.groups ?? []).map((g) => ({
label: g.name || 'Group', label: groupName(g),
onSelect: pick(g.id, g.name || 'Group'), onSelect: pick(g.id, groupName(g)),
})), })),
]; ];
} }
+3 -2
View File
@@ -11,6 +11,7 @@ import {
} from '../../core/model/hierarchy'; } from '../../core/model/hierarchy';
import { resolvedHolder } from '../../core/model/instance'; import { resolvedHolder } from '../../core/model/instance';
import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { groupName, objectName } from '../../core/model/SceneObject';
import { deviationMarks } from '../../core/properties/deviation'; import { deviationMarks } from '../../core/properties/deviation';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { groupRowItems, selectGroup } from '../objectGroups'; import { groupRowItems, selectGroup } from '../objectGroups';
@@ -272,7 +273,7 @@ export class HierarchyPanel extends Panel {
if (row.kind === 'group') if (row.kind === 'group')
return { return {
id: groupRowId(row.group.id), id: groupRowId(row.group.id),
label: row.group.name || 'Group', label: groupName(row.group),
hint: String(row.memberIds.length), hint: String(row.memberIds.length),
icon: GROUP_ICON_SVG, icon: GROUP_ICON_SVG,
iconAtEnd: true, iconAtEnd: true,
@@ -284,7 +285,7 @@ export class HierarchyPanel extends Panel {
const asset = findAsset(this.state.project, object.assetId); const asset = findAsset(this.state.project, object.assetId);
return { return {
id: object.id, id: object.id,
label: object.name || asset?.name || '?', label: objectName(object, asset),
icon: asset && ASSET_ICON_SVG[asset.type], icon: asset && ASSET_ICON_SVG[asset.type],
iconAtEnd: true, iconAtEnd: true,
depth, depth,
+2 -2
View File
@@ -120,7 +120,7 @@ export class PixelArtSide extends ScrollContainer {
this.toolOptions = row(this.filledOption, this.contiguousOption, new Spacer(), this.brushButton); this.toolOptions = row(this.filledOption, this.contiguousOption, new Spacer(), this.brushButton);
c.add(this.toolOptions); c.add(this.toolOptions);
c.add(heading('Colours')); c.add(heading('Colors'));
this.palette.onPick = (index) => this.canvas.setColor(index); this.palette.onPick = (index) => this.canvas.setColor(index);
this.palette.onReflow = () => this.relayoutContent(); this.palette.onReflow = () => this.relayoutContent();
c.add(this.palette); c.add(this.palette);
@@ -395,7 +395,7 @@ export class PixelArtSide extends ScrollContainer {
this.addColorButton.setEnabled(palette !== null); this.addColorButton.setEnabled(palette !== null);
this.removeColorButton.setEnabled(editable); this.removeColorButton.setEnabled(editable);
this.paletteNote.setText( this.paletteNote.setText(
palette ? `${palette.name} · ${palette.colors.length} colours` : 'Default colours', palette ? `${palette.name} · ${palette.colors.length} colors` : 'Default colors',
); );
this.createPaletteButton.visible = asset !== null && palette === null; this.createPaletteButton.visible = asset !== null && palette === null;
+6 -6
View File
@@ -167,17 +167,17 @@ export function setPaletteColor(
if (index < 1 || index > palette.colors.length || palette.colors[index - 1] === color) return false; if (index < 1 || index > palette.colors.length || palette.colors[index - 1] === color) return false;
const colors = [...palette.colors]; const colors = [...palette.colors];
colors[index - 1] = color; colors[index - 1] = color;
return state.execute(new SetPaletteColorsCommand(palette.id, 'Palette colour', colors)); return state.execute(new SetPaletteColorsCommand(palette.id, 'Palette color', colors));
} }
/** Appends a colour; returns its index, 0 when the palette is full. */ /** Appends a colour; returns its index, 0 when the palette is full. */
export function addPaletteColor(state: EditorState, palette: PaletteAsset, color: number): number { export function addPaletteColor(state: EditorState, palette: PaletteAsset, color: number): number {
if (palette.colors.length >= PALETTE_MAX) { if (palette.colors.length >= PALETTE_MAX) {
state.setNotice(`A palette holds at most ${PALETTE_MAX} colours.`); state.setNotice(`A palette holds at most ${PALETTE_MAX} colors.`);
return 0; return 0;
} }
const colors = [...palette.colors, color]; const colors = [...palette.colors, color];
return state.execute(new SetPaletteColorsCommand(palette.id, 'Add colour', colors)) ? colors.length : 0; return state.execute(new SetPaletteColorsCommand(palette.id, 'Add color', colors)) ? colors.length : 0;
} }
/** /**
@@ -203,9 +203,9 @@ export function removePaletteColor(state: EditorState, palette: PaletteAsset, in
if (next) replaced[file] = pixelCels.put(next); if (next) replaced[file] = pixelCels.put(next);
} }
if (Object.keys(replaced).length > 0) if (Object.keys(replaced).length > 0)
rewrites.push(new EditPixelArtCommand(asset.id, 'Remove colour', (s) => withCelsReplaced(s, replaced))); rewrites.push(new EditPixelArtCommand(asset.id, 'Remove color', (s) => withCelsReplaced(s, replaced)));
} }
const colors = palette.colors.filter((_, i) => i !== index - 1); const colors = palette.colors.filter((_, i) => i !== index - 1);
const recolor = new SetPaletteColorsCommand(palette.id, 'Remove colour', colors); const recolor = new SetPaletteColorsCommand(palette.id, 'Remove color', colors);
return state.execute(oneStep('Remove colour', [recolor, ...rewrites])); return state.execute(oneStep('Remove color', [recolor, ...rewrites]));
} }
-2
View File
@@ -110,5 +110,3 @@ export function createPluginApi(pluginId: string, host: PluginHost) {
ui: Object.freeze({ NodeBoard }), ui: Object.freeze({ NodeBoard }),
}); });
} }
export type PluginApi = ReturnType<typeof createPluginApi>;
+2 -1
View File
@@ -2,6 +2,7 @@ import { findObject } from '../../core/model/hierarchy';
import { resolvedHolder } from '../../core/model/instance'; import { resolvedHolder } from '../../core/model/instance';
import type { PropertyBag } from '../../core/model/Properties'; import type { PropertyBag } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project'; import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import { objectName } from '../../core/model/SceneObject';
import { findFrame, resolvePicture, resolveTexture, type TextureRef } from '../../core/model/texture'; import { findFrame, resolvePicture, resolveTexture, type TextureRef } from '../../core/model/texture';
import type { FlatDef } from '../../core/properties/PropertySchema'; import type { FlatDef } from '../../core/properties/PropertySchema';
import { resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve'; import { resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
@@ -46,7 +47,7 @@ export function previewPicture(project: Project, subject: PictureSubject): Previ
const object = holder && findObject(resolvedHolder(project, holder), subject.objectId); const object = holder && findObject(resolvedHolder(project, holder), subject.objectId);
const asset = object && findAsset(project, object.assetId); const asset = object && findAsset(project, object.assetId);
if (!object || asset?.type !== 'sprite') return null; if (!object || asset?.type !== 'sprite') return null;
return spritePicture(project, object.name || asset.name, resolveSceneObjectProps(project, asset, object)); return spritePicture(project, objectName(object, asset), resolveSceneObjectProps(project, asset, object));
} }
if (subject.kind === 'frame') { if (subject.kind === 'frame') {
const found = findFrame(project, subject.frameId); const found = findFrame(project, subject.frameId);
+2 -19
View File
@@ -1,7 +1,5 @@
import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands';
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { ui } from '../ui'; import { ui } from '../ui';
import { confirmDeleteAsset, createContainer, removeGraphSelection, removeObjects } from './actions'; import { createContainer, removeSelection } from './actions';
import type { EditorState } from './state'; import type { EditorState } from './state';
export interface ShortcutActions { export interface ShortcutActions {
@@ -45,22 +43,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds); if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
return true; return true;
} }
if (event.key === 'Delete' || event.key === 'Backspace') { if (event.key === 'Delete' || event.key === 'Backspace') return removeSelection(state);
const s = state.selection;
if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds);
else if (s?.kind === 'node') removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds);
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
else if (s?.kind === 'annotation') removeGraphSelection(state, s.assetId, [], s.annotationIds);
// The keys when some are picked, else the row itself – like a frame of a texture.
else if (s?.kind === 'track')
state.execute(
s.keys.length > 0
? new RemoveAnimationKeysCommand(s.assetId, s.keys)
: new RemoveAnimationTracksCommand(s.assetId, [s.trackId]),
);
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
return s !== null;
}
// Ctrl+Enter starts play and then holds/resumes it. A plain letter would be taken away from // Ctrl+Enter starts play and then holds/resumes it. A plain letter would be taken away from
// the game, which owns the keyboard while playing. // the game, which owns the keyboard while playing.
if (mod && event.key === 'Enter') { if (mod && event.key === 'Enter') {
+2 -1
View File
@@ -7,6 +7,7 @@ import type { Container } from 'pixi.js';
import { isVariableOwner } from '../../core/model/Asset'; import { isVariableOwner } from '../../core/model/Asset';
import { findObject } from '../../core/model/hierarchy'; import { findObject } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project'; import { findAsset } from '../../core/model/Project';
import { objectName } from '../../core/model/SceneObject';
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui'; import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection'; import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
import type { InspectorHost } from '../inspector/host'; import type { InspectorHost } from '../inspector/host';
@@ -31,7 +32,7 @@ function ownerOf(state: EditorState, target: Extract<ValuesTarget, { kind: 'vari
const view = viewOf(state.project, owner.holderId); const view = viewOf(state.project, owner.holderId);
const object = view && findObject(view, owner.objectId); const object = view && findObject(view, owner.objectId);
const asset = object && findAsset(state.project, object.assetId); const asset = object && findAsset(state.project, object.assetId);
return object && asset?.type === 'behavior' ? { asset, name: object.name || asset.name } : null; return object && asset?.type === 'behavior' ? { asset, name: objectName(object, asset) } : null;
} }
/** What the tab of `target` says, or null once there is nothing left to show – the tab closes then. */ /** What the tab of `target` says, or null once there is nothing left to show – the tab closes then. */
+2 -2
View File
@@ -122,7 +122,7 @@ export class GradientBar extends Widget {
return; return;
} }
const after = withStopAdded(gradient, this.offsetAt(local.x)); const after = withStopAdded(gradient, this.offsetAt(local.x));
this.handlers.commit(cloneGradient(gradient), after, 'Add colour stop'); this.handlers.commit(cloneGradient(gradient), after, 'Add color stop');
this.handlers.select(after.stops.length - 1); this.handlers.select(after.stops.length - 1);
} }
@@ -139,7 +139,7 @@ export class GradientBar extends Widget {
private onRelease(): void { private onRelease(): void {
const drag = this.drag; const drag = this.drag;
this.drag = null; this.drag = null;
if (drag?.moved) this.handlers.commit(drag.before, drag.after, 'Move colour stop'); if (drag?.moved) this.handlers.commit(drag.before, drag.after, 'Move color stop');
} }
override redraw(): void { override redraw(): void {
+3 -9
View File
@@ -13,8 +13,6 @@
import { type FederatedPointerEvent, Graphics } from 'pixi.js'; import { type FederatedPointerEvent, Graphics } from 'pixi.js';
import { import {
AddVectorShapeCommand, AddVectorShapeCommand,
RemoveVectorShapeCommand,
RemoveVectorShapesCommand,
paintsOf, paintsOf,
SetVectorShapeRingsCommand, SetVectorShapeRingsCommand,
SetVectorShapeStyleCommand, SetVectorShapeStyleCommand,
@@ -50,6 +48,7 @@ import {
withSmoothToggled, withSmoothToggled,
} from '../polygonEdit'; } from '../polygonEdit';
import { DRAG_THRESHOLD, drawPointGrip, HANDLE_RADIUS, POINT_SIZE } from '../pointGrips'; import { DRAG_THRESHOLD, drawPointGrip, HANDLE_RADIUS, POINT_SIZE } from '../pointGrips';
import { removeShapes } from '../actions';
import { type EditorState, shapeSelection } from '../state'; import { type EditorState, shapeSelection } from '../state';
import { drawChecker } from '../texture/checker'; import { drawChecker } from '../texture/checker';
import { SheetCanvas } from '../texture/SheetCanvas'; import { SheetCanvas } from '../texture/SheetCanvas';
@@ -256,12 +255,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
const shapeIds = this.selectedShapeIds(); const shapeIds = this.selectedShapeIds();
if (shapeIds.length === 0) return super.onKeyDown(event); if (shapeIds.length === 0) return super.onKeyDown(event);
this.endPen(); this.endPen();
this.state.execute( removeShapes(this.state, this.assetId, shapeIds);
shapeIds.length === 1
? new RemoveVectorShapeCommand(this.assetId, shapeIds[0]!)
: new RemoveVectorShapesCommand(this.assetId, shapeIds),
);
this.state.select({ kind: 'asset', assetId: this.assetId });
return true; return true;
} }
@@ -784,7 +778,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
if (this.target === 'stroke' && !shape.stroke) return 'Paint: this shape has no outline.'; if (this.target === 'stroke' && !shape.stroke) return 'Paint: this shape has no outline.';
return this.shownPaint() return this.shownPaint()
? `Paint: drag the two handles of the ${part}.` ? `Paint: drag the two handles of the ${part}.`
: `Paint: the ${part} is one colour – pick a ${this.target === 'fill' ? 'gradient or texture' : 'gradient'} in the inspector.`; : `Paint: the ${part} is one color – pick a ${this.target === 'fill' ? 'gradient or texture' : 'gradient'} in the inspector.`;
} }
private syncHint(asset: VectorArtAsset | null): void { private syncHint(asset: VectorArtAsset | null): void {
+2 -2
View File
@@ -11,7 +11,7 @@ import { referenceCandidates } from '../core/model/objectRefs';
import type { Cell } from '../core/model/tilemap'; import type { Cell } from '../core/model/tilemap';
import { descendantsBelow } from '../core/model/walk'; import { descendantsBelow } from '../core/model/walk';
import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project'; import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project';
import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject';
import { apply, IDENTITY } from '../core/model/matrix'; import { apply, IDENTITY } from '../core/model/matrix';
import { hasTransformProps } from '../core/properties/PropertySchema'; import { hasTransformProps } from '../core/properties/PropertySchema';
import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform'; import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform';
@@ -247,7 +247,7 @@ export class World {
} }
entity.assetId = asset.id; entity.assetId = asset.id;
entity.type = asset.type; entity.type = asset.type;
entity.name = object.name || asset.name; entity.name = objectName(object, asset);
entity.x = object.position.x; entity.x = object.position.x;
entity.y = object.position.y; entity.y = object.position.y;
entity.z = object.position.z; entity.z = object.position.z;
+1 -1
View File
@@ -36,6 +36,6 @@ export function initUI(app: Application, root: Container, overlay: Container): U
} }
export function ui(): UIContext { export function ui(): UIContext {
if (!context) throw new Error('UI not initialised – call initUI() first'); if (!context) throw new Error('UI not initialized – call initUI() first');
return context; return context;
} }
+11 -1
View File
@@ -12,10 +12,16 @@ export interface DialogButton {
onClick?: () => void; onClick?: () => void;
} }
/** A modal box with title, message and a right-aligned button row. Any button closes it. */ /**
* A modal box with title, message and a right-aligned button row. Any button closes it, and Escape
* is the plain button – the one neither primary nor danger, the Cancel – so both lead back alike.
*/
export class Dialog extends Popup { export class Dialog extends Popup {
private readonly cancel: (() => void) | undefined;
constructor(title: string, message: string, buttons: DialogButton[]) { constructor(title: string, message: string, buttons: DialogButton[]) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.padding }); super({ modal: true, padding: Theme.padding * 2, gap: Theme.padding });
this.cancel = buttons.find((b) => !b.primary && !b.danger)?.onClick;
this.minWidth = 320; this.minWidth = 320;
this.content.add(new Label(title, { style: 'bodyBold' })); this.content.add(new Label(title, { style: 'bodyBold' }));
if (message) this.content.add(new Label(message, { style: 'muted' })); if (message) this.content.add(new Label(message, { style: 'muted' }));
@@ -30,4 +36,8 @@ export class Dialog extends Popup {
} }
this.content.add(row); this.content.add(row);
} }
protected override dismiss(): void {
this.closeThen(this.cancel)();
}
} }
+6 -1
View File
@@ -151,10 +151,15 @@ export class Popup extends Widget {
private readonly onEscape = (event: KeyboardEvent) => { private readonly onEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.stopPropagation(); event.stopPropagation();
this.close(); this.dismiss();
} }
}; };
/** What Escape does. A popup closes; a dialog makes it its Cancel. */
protected dismiss(): void {
this.close();
}
override measure(): Size { override measure(): Size {
const c = this.content.measure(); const c = this.content.measure();
return { w: Math.max(this.minWidth, c.w), h: Math.max(this.minHeight, c.h) }; return { w: Math.max(this.minWidth, c.w), h: Math.max(this.minHeight, c.h) };
+2 -1
View File
@@ -22,6 +22,7 @@ import {
solidRects, solidRects,
} from '../core/model/tilemapCollision'; } from '../core/model/tilemapCollision';
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project'; import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
import { objectName } from '../core/model/SceneObject';
import { fontFileOf, fontSmoothOf } from '../core/model/font'; import { fontFileOf, fontSmoothOf } from '../core/model/font';
import { resolveTexture, type TextureRef } from '../core/model/texture'; import { resolveTexture, type TextureRef } from '../core/model/texture';
import type { FrameOutline } from '../core/model/frameOutline'; import type { FrameOutline } from '../core/model/frameOutline';
@@ -206,7 +207,7 @@ export function collectHolderItems(
items.push({ items.push({
id: object.id, id: object.id,
type: asset.type, type: asset.type,
name: object.name || asset.name, name: objectName(object, asset),
x: m.tx, x: m.tx,
y: m.ty, y: m.ty,
z, z,
+3 -3
View File
@@ -13,7 +13,7 @@ import {
} from '../../src/core/graph'; } from '../../src/core/graph';
import { graphLookup } from '../../src/core/model/graphLookup'; import { graphLookup } from '../../src/core/model/graphLookup';
import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { BehaviorAsset } from '../../src/core/model/Asset';
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers';
function sequenceFixture() { function sequenceFixture() {
const fixture = makeProject(); const fixture = makeProject();
@@ -76,7 +76,7 @@ describe('a Sequence sized by what is plugged into it', () => {
edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec'); edit.connect(node, port, edit.addNode('Log', { message: port.toUpperCase() }), 'exec');
const ctx = fakeContext(); const ctx = fakeContext();
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx);
expect(ctx.logs).toEqual(['A', 'B']); expect(ctx.logs).toEqual(['A', 'B']);
}); });
}); });
@@ -122,7 +122,7 @@ describe('a struct travelling through a nested behavior', () => {
// One edge into the call, where two numbers would need two. // One edge into the call, where two numbers would need two.
expect(caller.graph.edges.filter((e) => e.to.node === call.id && e.to.port !== 'exec')).toHaveLength(1); expect(caller.graph.edges.filter((e) => e.to.node === call.id && e.to.port !== 'exec')).toHaveLength(1);
const ctx = fakeContext({ variables: { result: 0 } }); const ctx = fakeContext({ variables: { result: 0 } });
new GraphInterpreter(caller.graph, graphLookup(fixture.project)).fire('OnStart', ctx); fire(new GraphInterpreter(caller.graph, graphLookup(fixture.project)), 'OnStart', ctx);
expect(ctx.variables.result).toBe(42); expect(ctx.variables.result).toBe(42);
}); });
}); });
+3 -3
View File
@@ -14,7 +14,7 @@ import {
} from '../../src/core/graph'; } from '../../src/core/graph';
import type { PropertyValue } from '../../src/core/model/Properties'; import type { PropertyValue } from '../../src/core/model/Properties';
import { blockFrames, BLOCK_PAD } from '../../src/editor/graph/blockFrames'; import { blockFrames, BLOCK_PAD } from '../../src/editor/graph/blockFrames';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers';
/** A behavior with a list variable `names` and a Find over it, the Match it brought along beside it. */ /** A behavior with a list variable `names` and a Find over it, the Match it brought along beside it. */
function block() { function block() {
@@ -56,7 +56,7 @@ function search(names: readonly PropertyValue[], wanted: PropertyValue, by: 'ite
[none, 'text', miss, 'message'], [none, 'text', miss, 'message'],
]); ]);
const ctx = fakeContext({ variables: { names: [...names] } }); const ctx = fakeContext({ variables: { names: [...names] } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
return ctx.logs; return ctx.logs;
} }
@@ -91,7 +91,7 @@ describe('Find … Match at run time', () => {
[match, 'notFound', after, 'exec'], [match, 'notFound', after, 'exec'],
]); ]);
const ctx = fakeContext({ variables: { names: ['a'] } }); const ctx = fakeContext({ variables: { names: ['a'] } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.logs).toEqual(['Find: No Match tests its item']); expect(ctx.logs).toEqual(['Find: No Match tests its item']);
}); });
}); });
+2 -2
View File
@@ -13,7 +13,7 @@ import {
import { graphLookup } from '../../src/core/model/graphLookup'; import { graphLookup } from '../../src/core/model/graphLookup';
import type { PropertyValue } from '../../src/core/model/Properties'; import type { PropertyValue } from '../../src/core/model/Properties';
import { paletteEntries, rankedEntries } from '../../src/editor/graph/paletteEntries'; import { paletteEntries, rankedEntries } from '../../src/editor/graph/paletteEntries';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers';
/** A behavior with a list variable `names`, a Find over it testing `item == wanted`, and its Match. */ /** A behavior with a list variable `names`, a Find over it testing `item == wanted`, and its Match. */
function block(wanted: PropertyValue = 'b', mode?: FindMode) { function block(wanted: PropertyValue = 'b', mode?: FindMode) {
@@ -54,7 +54,7 @@ function answer(mode: FindMode, names: readonly PropertyValue[], ports: string[]
from = { node: log, port: 'exec' }; from = { node: log, port: 'exec' };
} }
const ctx = fakeContext({ variables: { names: [...names] } }); const ctx = fakeContext({ variables: { names: [...names] } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
return ctx.logs; return ctx.logs;
} }
+2 -2
View File
@@ -14,7 +14,7 @@ import {
foldMarkLabel, foldMarkLabel,
foldMarkRadius, foldMarkRadius,
} from '../../src/editor/graph/folding'; } from '../../src/editor/graph/folding';
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip, fire } from '../helpers';
/** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */ /** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */
function mover() { function mover() {
@@ -38,7 +38,7 @@ describe('folding an edge', () => {
expect(behavior.graph.edges.every((e) => e.folded === true)).toBe(true); expect(behavior.graph.edges.every((e) => e.folded === true)).toBe(true);
const ctx = fakeContext(); const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.self.x).toBe(7); expect(ctx.self.x).toBe(7);
// And the edges themselves are untouched apart from the flag. // And the edges themselves are untouched apart from the flag.
expect(findAsset(project, behavior.id)).toBe(behavior); expect(findAsset(project, behavior.id)).toBe(behavior);
+18 -17
View File
@@ -37,6 +37,7 @@ import {
makeSpriteScene, makeSpriteScene,
place, place,
roundTrip, roundTrip,
fire,
} from '../helpers'; } from '../helpers';
/** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */ /** Adds "On key held(ArrowRight) → Move(owner, dx = speed × dt)" to a project, built through commands like the editor would. */
@@ -103,10 +104,10 @@ describe('GraphInterpreter', () => {
const { behavior } = withMover(makeProject()); const { behavior } = withMover(makeProject());
const interpreter = new GraphInterpreter(behavior.graph); const interpreter = new GraphInterpreter(behavior.graph);
const ctx = fakeContext({ variables: { speed: 100 } }); const ctx = fakeContext({ variables: { speed: 100 } });
const fired = interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight'); const fired = fire(interpreter, 'OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowRight');
expect(fired).toBe(1); expect(fired).toBe(1);
expect(ctx.owner!.x).toBe(50); expect(ctx.owner!.x).toBe(50);
expect(interpreter.fire('OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0); expect(fire(interpreter, 'OnKeyHeld', ctx, { dt: 0.5 }, (n) => n.params.key === 'ArrowLeft')).toBe(0);
expect(ctx.owner!.x).toBe(50); expect(ctx.owner!.x).toBe(50);
}); });
@@ -120,7 +121,7 @@ describe('GraphInterpreter', () => {
[self, 'object', move, 'object'], [self, 'object', move, 'object'],
]); ]);
const ctx = fakeContext(); const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.self.x).toBe(7); expect(ctx.self.x).toBe(7);
expect(ctx.self.y).toBe(-3); expect(ctx.self.y).toBe(-3);
}); });
@@ -137,7 +138,7 @@ describe('GraphInterpreter', () => {
[clamp, 'result', move, 'dy'], [clamp, 'result', move, 'dy'],
]); ]);
const ctx = fakeContext(); const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0 expect(ctx.self.x).toBe(4); // scale defaulted to 1, not 0
expect(ctx.self.y).toBe(1); // clamp(5, 0, 1) expect(ctx.self.y).toBe(1); // clamp(5, 0, 1)
}); });
@@ -162,7 +163,7 @@ describe('GraphInterpreter', () => {
const interpreter = new GraphInterpreter(behavior.graph); const interpreter = new GraphInterpreter(behavior.graph);
const run = (keys: string[]) => { const run = (keys: string[]) => {
const ctx = fakeContext({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) }); const ctx = fakeContext({ variables: { speed: 100 }, dt: 0.5, isKeyDown: (k) => keys.includes(k) });
interpreter.fire('OnUpdate', ctx, { dt: 0.5 }); fire(interpreter, 'OnUpdate', ctx, { dt: 0.5 });
return { x: ctx.owner!.x, y: ctx.owner!.y }; return { x: ctx.owner!.x, y: ctx.owner!.y };
}; };
expect(run([])).toEqual({ x: 0, y: 0 }); expect(run([])).toEqual({ x: 0, y: 0 });
@@ -199,7 +200,7 @@ describe('GraphInterpreter', () => {
previous = log; previous = log;
}); });
const ctx = fakeContext(); const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']); expect(ctx.logs).toEqual(['0.6', '5', '4', '2', '-1', '3']);
}); });
@@ -226,7 +227,7 @@ describe('GraphInterpreter', () => {
const interpreter = new GraphInterpreter(behavior.graph); const interpreter = new GraphInterpreter(behavior.graph);
const ctx = fakeContext({ variables: { count: 0 } }); const ctx = fakeContext({ variables: { count: 0 } });
for (let i = 0; i < 5; i++) interpreter.fire('OnStart', ctx); for (let i = 0; i < 5; i++) fire(interpreter, 'OnStart', ctx);
expect(ctx.variables.count).toBe(3); expect(ctx.variables.count).toBe(3);
expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']); expect(ctx.logs).toEqual(['then', 'then', 'then', 'else', 'else']);
}); });
@@ -237,7 +238,7 @@ describe('GraphInterpreter', () => {
const edges: string[] = []; const edges: string[] = [];
const visited: string[] = []; const visited: string[] = [];
interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) }; interpreter.trace = { onEdge: (id) => edges.push(id), onNode: (id) => visited.push(id) };
interpreter.fire('OnKeyHeld', fakeContext(), { dt: 1 }); fire(interpreter, 'OnKeyHeld', fakeContext(), { dt: 1 });
expect(edges).toHaveLength(1); expect(edges).toHaveLength(1);
expect(visited).toContain(nodes.mul.id); expect(visited).toContain(nodes.mul.id);
expect(visited).toContain(nodes.move.id); expect(visited).toContain(nodes.move.id);
@@ -253,7 +254,7 @@ describe('GraphInterpreter', () => {
[a, 'a', b, 'exec'], [a, 'a', b, 'exec'],
[b, 'a', a, 'exec'], [b, 'a', a, 'exec'],
]); ]);
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow( expect(() => fire(new GraphInterpreter(behavior.graph), 'OnStart', fakeContext())).toThrow(
/nested too deeply/, /nested too deeply/,
); );
}); });
@@ -267,7 +268,7 @@ describe('GraphInterpreter', () => {
[start, 'exec', repeat, 'exec'], [start, 'exec', repeat, 'exec'],
[repeat, 'body', log, 'exec'], [repeat, 'body', log, 'exec'],
]); ]);
expect(() => new GraphInterpreter(behavior.graph).fire('OnStart', fakeContext())).toThrow(/step limit/); expect(() => fire(new GraphInterpreter(behavior.graph), 'OnStart', fakeContext())).toThrow(/step limit/);
}); });
}); });
@@ -287,7 +288,7 @@ describe('Object by name', () => {
edit.wire([[join, 'text', byName, 'name']]); edit.wire([[join, 'text', byName, 'name']]);
} }
const ctx = fakeContext({ findByName: (name) => (name === 'Target' ? fakeEntity('Target') : null) }); const ctx = fakeContext({ findByName: (name) => (name === 'Target' ? fakeEntity('Target') : null) });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
return ctx.logs; return ctx.logs;
} }
@@ -327,7 +328,7 @@ describe('Repeat', () => {
it('runs the body once per round, each round seeing its own index', () => { it('runs the body once per round, each round seeing its own index', () => {
const { behavior } = summingLoop(5); const { behavior } = summingLoop(5);
const ctx = fakeContext({ variables: { count: 0 } }); const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
// 0+1+2+3+4. A stale data cache would add the first round's index five times (0) instead. // 0+1+2+3+4. A stale data cache would add the first round's index five times (0) instead.
expect(ctx.variables.count).toBe(10); expect(ctx.variables.count).toBe(10);
}); });
@@ -335,7 +336,7 @@ describe('Repeat', () => {
it('runs nothing at all for a count of zero', () => { it('runs nothing at all for a count of zero', () => {
const { behavior } = summingLoop(0); const { behavior } = summingLoop(0);
const ctx = fakeContext({ variables: { count: 7 } }); const ctx = fakeContext({ variables: { count: 7 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.variables.count).toBe(7); expect(ctx.variables.count).toBe(7);
}); });
@@ -344,7 +345,7 @@ describe('Repeat', () => {
const done = edit.addNode('Log', { message: 'done' }); const done = edit.addNode('Log', { message: 'done' });
edit.wire([[nodes.repeat, 'done', done, 'exec']]); edit.wire([[nodes.repeat, 'done', done, 'exec']]);
const ctx = fakeContext({ variables: { count: 0 } }); const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect({ count: ctx.variables.count, logs: ctx.logs }).toEqual({ count: 3, logs: ['done'] }); expect({ count: ctx.variables.count, logs: ctx.logs }).toEqual({ count: 3, logs: ['done'] });
}); });
@@ -364,7 +365,7 @@ describe('Repeat', () => {
[add, 'result', set, 'count'], [add, 'result', set, 'count'],
]); ]);
const ctx = fakeContext({ variables: { count: 0 } }); const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.variables.count).toBe(12); expect(ctx.variables.count).toBe(12);
}); });
@@ -387,7 +388,7 @@ describe('Repeat', () => {
[afterAdd, 'result', after, 'count'], [afterAdd, 'result', after, 'count'],
]); ]);
const ctx = fakeContext({ variables: { count: 0 } }); const ctx = fakeContext({ variables: { count: 0 } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
// 3 rounds, then ×10 on the *current* count – a cache left over from the last round would give 20. // 3 rounds, then ×10 on the *current* count – a cache left over from the last round would give 20.
expect(ctx.variables.count).toBe(30); expect(ctx.variables.count).toBe(30);
}); });
@@ -429,7 +430,7 @@ describe('For each', () => {
]); ]);
} }
const ctx = fakeContext({ variables: { names: [...names] } }); const ctx = fakeContext({ variables: { names: [...names] } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
return ctx.logs; return ctx.logs;
} }
+2 -1
View File
@@ -26,6 +26,7 @@ import {
place, place,
roundTrip, roundTrip,
startWorld, startWorld,
fire,
} from '../helpers'; } from '../helpers';
/** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */ /** Three boxes, all top level: a at (100,100,z1), b at (10,0), c at (0,5). */
@@ -264,7 +265,7 @@ describe('runtime hierarchy', () => {
], ],
}; };
const ctx = fakeContext({ self: child, owner: parent, variables: {} }); const ctx = fakeContext({ self: child, owner: parent, variables: {} });
new GraphInterpreter(graph).fire('OnStart', ctx); fire(new GraphInterpreter(graph), 'OnStart', ctx);
expect(ctx.logs).toEqual(['105', '5', '100']); expect(ctx.logs).toEqual(['105', '5', '100']);
}); });
}); });
+1 -6
View File
@@ -105,17 +105,12 @@ describe('HttpFileStore', () => {
); );
}); });
it('lists and deletes', async () => { it('lists', async () => {
stubFetch(JSON.stringify([{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }]), { stubFetch(JSON.stringify([{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }]), {
status: 200, status: 200,
}); });
await expect(new HttpFileStore('/api', 'textures').list()).resolves.toEqual([ await expect(new HttpFileStore('/api', 'textures').list()).resolves.toEqual([
{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }, { file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' },
]); ]);
const mock = stubFetch(null, { status: 204 });
await new HttpFileStore('/api', 'textures').delete(FILE);
expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`);
expect(mock.mock.calls[0]![1]?.method).toBe('DELETE');
}); });
}); });
+2 -2
View File
@@ -8,7 +8,7 @@ import {
} from '../../src/core/graph'; } from '../../src/core/graph';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { graphLookup } from '../../src/core/model/graphLookup'; import { graphLookup } from '../../src/core/model/graphLookup';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers';
const untouched = { params: {} }; const untouched = { params: {} };
@@ -31,7 +31,7 @@ function logged(
...(source === text ? [] : ([[source, port, text, 'value']] as const)), ...(source === text ? [] : ([[source, port, text, 'value']] as const)),
]); ]);
const ctx = fakeContext(); const ctx = fakeContext();
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx);
return ctx.logs; return ctx.logs;
} }
+8 -7
View File
@@ -32,6 +32,7 @@ import {
type ProjectFixture, type ProjectFixture,
roundTrip, roundTrip,
startWorld, startWorld,
fire,
} from '../helpers'; } from '../helpers';
/** /**
@@ -98,7 +99,7 @@ function threeCalls(fixture: ProjectFixture, callee: BehaviorAsset): unknown[][]
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
const seen: unknown[][] = []; const seen: unknown[][] = [];
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
seen.push([ctx.variables.r0, ctx.variables.r1]); seen.push([ctx.variables.r0, ctx.variables.r1]);
} }
return seen; return seen;
@@ -151,9 +152,9 @@ describe('Keep on a behavior variable (#229)', () => {
const caller = callerOf(fixture, counter(fixture), 2); const caller = callerOf(fixture, counter(fixture), 2);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([1, 1]); expect([ctx.variables.r0, ctx.variables.r1]).toEqual([1, 1]);
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]); expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]);
// The caller's own bag never learns of `count`: the kept values stay the callee's. // The caller's own bag never learns of `count`: the kept values stay the callee's.
expect('count' in ctx.variables).toBe(false); expect('count' in ctx.variables).toBe(false);
@@ -196,8 +197,8 @@ describe('Keep on a behavior variable (#229)', () => {
const caller = callerOf(fixture, middle, 2); const caller = callerOf(fixture, middle, 2);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } }); const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
// One shared inner counter would have handed out 1, 2, 3, 4. // One shared inner counter would have handed out 1, 2, 3, 4.
expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]); expect([ctx.variables.r0, ctx.variables.r1]).toEqual([2, 2]);
}); });
@@ -234,8 +235,8 @@ describe('Keep on a behavior variable (#229)', () => {
); );
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext(); const ctx = fakeContext();
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
expect(ctx.logs).toEqual(['first', 'first']); expect(ctx.logs).toEqual(['first', 'first']);
// A local machine of the callee is none of the caller's business either. // A local machine of the callee is none of the caller's business either.
expect(ctx.states).toEqual({}); expect(ctx.states).toEqual({});
+11 -3
View File
@@ -15,7 +15,15 @@ import { graphLookup } from '../../src/core/model/graphLookup';
import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem'; import { BehaviorSystem } from '../../src/runtime/systems/BehaviorSystem';
import { Input } from '../../src/runtime/systems/Input'; import { Input } from '../../src/runtime/systems/Input';
import { World } from '../../src/runtime/World'; import { World } from '../../src/runtime/World';
import { fakeContext, graphEditor, makeBehavior, makeProject, makeSpriteScene, place } from '../helpers'; import {
fakeContext,
graphEditor,
makeBehavior,
makeProject,
makeSpriteScene,
place,
fire,
} from '../helpers';
function recorder() { function recorder() {
const seen = new Map<Graph, Map<string, Record<string, Value>>>(); const seen = new Map<Graph, Map<string, Record<string, Value>>>();
@@ -47,7 +55,7 @@ describe('live values', () => {
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
interpreter.trace = listener; interpreter.trace = listener;
const ctx = fakeContext({ variables: { count: 5, other: 9 } }); const ctx = fakeContext({ variables: { count: 5, other: 9 } });
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
ctx.variables.count = 99; ctx.variables.count = 99;
const values = seen.get(behavior.graph)!; const values = seen.get(behavior.graph)!;
@@ -77,7 +85,7 @@ describe('live values', () => {
const { seen, listener } = recorder(); const { seen, listener } = recorder();
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project)); const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
interpreter.trace = listener; interpreter.trace = listener;
interpreter.fire('OnStart', fakeContext()); fire(interpreter, 'OnStart', fakeContext());
expect(seen.get(callee.graph)?.get(start.id)).toEqual({ in1: 21 }); expect(seen.get(callee.graph)?.get(start.id)).toEqual({ in1: 21 });
expect(seen.get(callee.graph)?.get(times.id)).toEqual({ result: 42 }); expect(seen.get(callee.graph)?.get(times.id)).toEqual({ result: 42 });
+3 -3
View File
@@ -17,7 +17,7 @@ import {
import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup'; import { graphLookup } from '../../src/core/model/graphLookup';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers';
/** /**
* A behavior that doubles what it is given: `in1` → Multiply by 2 → `out1`, reached through its * A behavior that doubles what it is given: `in1` → Multiply by 2 → `out1`, reached through its
@@ -57,7 +57,7 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number)
function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) { function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) {
const interpreter = new GraphInterpreter(asset.graph, graphLookup(fixture.project)); const interpreter = new GraphInterpreter(asset.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables }); const ctx = fakeContext({ variables });
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
return { ctx, interpreter }; return { ctx, interpreter };
} }
@@ -367,7 +367,7 @@ describe('declared ports', () => {
const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset; const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset;
const interpreter = new GraphInterpreter(caller.graph, graphLookup(loaded)); const interpreter = new GraphInterpreter(caller.graph, graphLookup(loaded));
const ctx = fakeContext({ variables: { result: 0 } }); const ctx = fakeContext({ variables: { result: 0 } });
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
expect(ctx.variables.result).toBe(12); expect(ctx.variables.result).toBe(12);
}); });
+4 -3
View File
@@ -8,6 +8,7 @@ import {
makeSpriteScene, makeSpriteScene,
place, place,
startWorld, startWorld,
fire,
} from '../helpers'; } from '../helpers';
// Node-by-node behaviour lives in node-catalog.test.ts; what is left here needs a running // Node-by-node behaviour lives in node-catalog.test.ts; what is left here needs a running
@@ -73,7 +74,7 @@ describe('Spawn node', () => {
return fakeEntity('Crate', position.x); return fakeEntity('Crate', position.x);
}, },
}); });
new GraphInterpreter(graph).fire('OnStart', ctx); fire(new GraphInterpreter(graph), 'OnStart', ctx);
expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]); expect(calls).toEqual([{ assetId: 'crate', position: { x: 5, y: 7, z: 0 }, parent: null }]);
expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log expect(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log
}); });
@@ -84,10 +85,10 @@ describe('Spawn node', () => {
...graph, ...graph,
nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)), nodes: graph.nodes.map((n) => (n.id === 'spawn' ? { ...n, params: {} } : n)),
}; };
new GraphInterpreter(noAsset).fire('OnStart', ctx); fire(new GraphInterpreter(noAsset), 'OnStart', ctx);
expect(ctx.logs).toEqual(['Spawn: no asset selected', '']); expect(ctx.logs).toEqual(['Spawn: no asset selected', '']);
const ctx2 = fakeContext({ spawn: () => null }); const ctx2 = fakeContext({ spawn: () => null });
new GraphInterpreter(graph).fire('OnStart', ctx2); fire(new GraphInterpreter(graph), 'OnStart', ctx2);
expect(ctx2.logs[0]).toBe('Spawn: asset crate not found'); expect(ctx2.logs[0]).toBe('Spawn: asset crate not found');
}); });
}); });
+14
View File
@@ -21,6 +21,7 @@ import {
} from '../../src/core/model/hierarchy'; } from '../../src/core/model/hierarchy';
import { instanceChildId, resolvedHolder } from '../../src/core/model/instance'; import { instanceChildId, resolvedHolder } from '../../src/core/model/instance';
import { findAsset, findScene } from '../../src/core/model/Project'; import { findAsset, findScene } from '../../src/core/model/Project';
import { groupName, objectName } from '../../src/core/model/SceneObject';
import { serializeProject } from '../../src/core/store/serialize'; import { serializeProject } from '../../src/core/store/serialize';
import { makeSpriteScene, place, roundTrip } from '../helpers'; import { makeSpriteScene, place, roundTrip } from '../helpers';
@@ -211,3 +212,16 @@ describe('hierarchy groups (#241)', () => {
expect(content).toEqual([x, z, y].map((id) => instanceChildId(instance, id!))); expect(content).toEqual([x, z, y].map((id) => instanceChildId(instance, id!)));
}); });
}); });
describe('the names the lists show', () => {
it('call an object by its own name, else by its asset, and "?" once the asset is gone', () => {
expect(objectName({ name: 'Hero' }, { name: 'Sprite' })).toBe('Hero');
expect(objectName({ name: '' }, { name: 'Sprite' })).toBe('Sprite');
expect(objectName({ name: '' }, undefined)).toBe('?');
});
it('call a group that was never named a "Group"', () => {
expect(groupName({ id: 'g', name: '' })).toBe('Group');
expect(groupName({ id: 'g', name: 'Crates' })).toBe('Crates');
});
});
+2 -2
View File
@@ -26,7 +26,7 @@ import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup'; import { graphLookup } from '../../src/core/model/graphLookup';
import { outputStructType, structTypeUsages } from '../../src/core/model/structTypes'; import { outputStructType, structTypeUsages } from '../../src/core/model/structTypes';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture, fire } from '../helpers';
/** `Loot` = { Name: "Coin", Amount: 1 } */ /** `Loot` = { Name: "Coin", Amount: 1 } */
function lootType(f: ProjectFixture): StructAsset { function lootType(f: ProjectFixture): StructAsset {
@@ -126,7 +126,7 @@ describe('splitting an output', () => {
{ Name: 'Gem', Amount: 7 }, { Name: 'Gem', Amount: 7 },
]; ];
const ctx = fakeContext({ variables: { bag } }); const ctx = fakeContext({ variables: { bag } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.logs).toEqual(['7']); expect(ctx.logs).toEqual(['7']);
}); });
+3 -3
View File
@@ -20,7 +20,7 @@ import {
import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup'; import { graphLookup } from '../../src/core/model/graphLookup';
import type { PropertyBag } from '../../src/core/model/Properties'; import type { PropertyBag } from '../../src/core/model/Properties';
import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip, fire } from '../helpers';
const SPRITE = { [OBJECT_TYPE_PARAM]: 'sprite' }; const SPRITE = { [OBJECT_TYPE_PARAM]: 'sprite' };
const set = (key: string): DeclaredPort => ({ key, kind: 'number', write: true }); const set = (key: string): DeclaredPort => ({ key, kind: 'number', write: true });
@@ -46,7 +46,7 @@ function onSelf(ports: DeclaredPort[], params: PropertyBag = SPRITE) {
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>, props: PropertyBag) { function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>, props: PropertyBag) {
const self = { ...fakeEntity('self'), props: { ...props } }; const self = { ...fakeEntity('self'), props: { ...props } };
const ctx = fakeContext({ self, variables: { seen: 0 } }); const ctx = fakeContext({ self, variables: { seen: 0 } });
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx);
return { props: self.props, seen: ctx.variables.seen }; return { props: self.props, seen: ctx.variables.seen };
} }
@@ -81,7 +81,7 @@ describe('a node that reads and writes properties of an object', () => {
]); ]);
const self_ = { ...fakeEntity('self'), props: { coins: 5 } }; const self_ = { ...fakeEntity('self'), props: { coins: 5 } };
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
for (let i = 0; i < 2; i++) interpreter.fire('OnStart', fakeContext({ self: self_ })); for (let i = 0; i < 2; i++) fire(interpreter, 'OnStart', fakeContext({ self: self_ }));
expect(self_.props.coins).toBe(7); expect(self_.props.coins).toBe(7);
}); });
+3 -3
View File
@@ -329,7 +329,7 @@ describe('compileShader', () => {
wrong.link(doubled, 'result', out, 'alpha'); wrong.link(doubled, 'result', out, 'alpha');
const build = compileShader(wrong.asset().graph); const build = compileShader(wrong.asset().graph);
expect(build.compiled).toBeNull(); expect(build.compiled).toBeNull();
expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a colour' }); expect(build.problems).toEqual({ [out]: '"alpha" needs a number, not a color' });
}); });
it('names the problem on the node, and compiles nothing while there is one', () => { it('names the problem on the node, and compiles nothing while there is one', () => {
@@ -345,8 +345,8 @@ describe('compileShader', () => {
s.link(mixed, 'result', output, 'color'); s.link(mixed, 'result', output, 'color');
let build = compileShader(s.asset().graph); let build = compileShader(s.asset().graph);
expect(build.compiled).toBeNull(); expect(build.compiled).toBeNull();
expect(build.problems).toEqual({ [mixed]: '"t" needs a number, not a colour' }); expect(build.problems).toEqual({ [mixed]: '"t" needs a number, not a color' });
expect(build.reason).toBe('"t" needs a number, not a colour'); expect(build.reason).toBe('"t" needs a number, not a color');
const dup = shaderEditor(f, 'Dup'); const dup = shaderEditor(f, 'Dup');
dup.add(SHADER_OUTPUT); dup.add(SHADER_OUTPUT);
+10 -9
View File
@@ -51,6 +51,7 @@ import {
type ProjectFixture, type ProjectFixture,
roundTrip, roundTrip,
startWorld, startWorld,
fire,
} from '../helpers'; } from '../helpers';
/** A machine "Doors" with the states Open and Closed, added the way the tab does it. */ /** A machine "Doors" with the states Open and Closed, added the way the tab does it. */
@@ -250,12 +251,12 @@ describe('a running state machine', () => {
const behavior = build(f, machine.id, machine.ids); const behavior = build(f, machine.id, machine.ids);
const ctx = fakeContext(); const ctx = fakeContext();
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project)); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
interpreter.fire('OnUpdate', ctx); fire(interpreter, 'OnUpdate', ctx);
expect(ctx.logs).toEqual([]); expect(ctx.logs).toEqual([]);
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] }); expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
interpreter.fire('OnUpdate', ctx); fire(interpreter, 'OnUpdate', ctx);
interpreter.fire('OnUpdate', ctx); fire(interpreter, 'OnUpdate', ctx);
expect(ctx.logs).toEqual(['open', 'closed']); expect(ctx.logs).toEqual(['open', 'closed']);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] }); expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
}); });
@@ -296,7 +297,7 @@ describe('a running state machine', () => {
[set, 'exec', call, 'exec'], [set, 'exec', call, 'exec'],
]); ]);
const ctx = fakeContext(); const ctx = fakeContext();
new GraphInterpreter(outer.graph, graphLookup(f.project)).fire('OnStart', ctx); fire(new GraphInterpreter(outer.graph, graphLookup(f.project)), 'OnStart', ctx);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] }); expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
expect(ctx.logs).toEqual([]); expect(ctx.logs).toEqual([]);
}); });
@@ -328,12 +329,12 @@ describe('a running state machine', () => {
]); ]);
const ctx = fakeContext({ variables: { doors: machine.id } }); const ctx = fakeContext({ variables: { doors: machine.id } });
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project)); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
// The name moved the machine; the one nothing answers to took "not found" instead of ending. // The name moved the machine; the one nothing answers to took "not found" instead of ending.
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] }); expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
expect(ctx.logs).toEqual(['no such state']); expect(ctx.logs).toEqual(['no such state']);
// The branch has no state outputs at all here, so even a state that is set leaves through else. // The branch has no state outputs at all here, so even a state that is set leaves through else.
interpreter.fire('OnUpdate', ctx); fire(interpreter, 'OnUpdate', ctx);
expect(ctx.logs).toEqual(['no such state', 'unknown to the graph']); expect(ctx.logs).toEqual(['no such state', 'unknown to the graph']);
}); });
@@ -566,7 +567,7 @@ describe('reading the current state', () => {
[current, 'name', log, 'message'], [current, 'name', log, 'message'],
]); ]);
const ctx = fakeContext(); const ctx = fakeContext();
new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx);
expect(ctx.logs).toEqual(['Open']); expect(ctx.logs).toEqual(['Open']);
}); });
@@ -657,7 +658,7 @@ describe('transitions between states', () => {
[set, 'exec', next, 'exec'], [set, 'exec', next, 'exec'],
]); ]);
const ctx = fakeContext(); const ctx = fakeContext();
new GraphInterpreter(behavior.graph, graphLookup(f.project)).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph, graphLookup(f.project)), 'OnStart', ctx);
expect(ctx.states).toEqual({ [machine.id]: closed }); expect(ctx.states).toEqual({ [machine.id]: closed });
}); });
+2 -2
View File
@@ -5,7 +5,7 @@ import { AddVariableCommand, RenameVariableCommand } from '../../src/core/comman
import { type GraphNode, GraphInterpreter, inlineValue, NO_LOOKUP, resolveDef } from '../../src/core/graph'; import { type GraphNode, GraphInterpreter, inlineValue, NO_LOOKUP, resolveDef } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup'; import { graphLookup } from '../../src/core/model/graphLookup';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; import { fakeContext, graphEditor, makeBehavior, makeProject, fire } from '../helpers';
function setup(variables = ['count', 'copy']) { function setup(variables = ['count', 'copy']) {
const fixture = makeProject(); const fixture = makeProject();
@@ -19,7 +19,7 @@ function run(
variables: Record<string, number> = { count: 5, copy: 0 }, variables: Record<string, number> = { count: 5, copy: 0 },
) { ) {
const ctx = fakeContext({ variables: { ...variables } }); const ctx = fakeContext({ variables: { ...variables } });
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx);
return ctx.variables; return ctx.variables;
} }
+3 -3
View File
@@ -21,7 +21,7 @@ import {
} from '../../src/core/graph'; } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset'; import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup'; import { graphLookup } from '../../src/core/model/graphLookup';
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip, fire } from '../helpers';
/** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */ /** `px` and `py` are set in one node and read back in another, their sum landing in `sum`. */
function bothWays() { function bothWays() {
@@ -50,7 +50,7 @@ function run(
) { ) {
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables }); const ctx = fakeContext({ variables });
interpreter.fire('OnStart', ctx); fire(interpreter, 'OnStart', ctx);
return ctx.variables; return ctx.variables;
} }
@@ -147,7 +147,7 @@ describe('a node that reads or writes several variables at once', () => {
]); ]);
const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } }); const ctx = fakeContext({ variables: { piece: { shape: 3, rot: 0 } } });
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); fire(new GraphInterpreter(behavior.graph, graphLookup(fixture.project)), 'OnStart', ctx);
expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 }); expect(ctx.variables.piece).toEqual({ shape: 3, rot: 2 });
}); });
}); });
+1 -1
View File
@@ -127,7 +127,7 @@ describe('importStoredImage', () => {
const FILE = `${'a'.repeat(64)}.png`; const FILE = `${'a'.repeat(64)}.png`;
const image = { file: FILE, name: '', width: 64, height: 48, bytes: 900 }; const image = { file: FILE, name: '', width: 64, height: 48, bytes: 900 };
/** The picker takes the image from the list; nothing here ever uploads. */ /** The picker takes the image from the list; nothing here ever uploads. */
const textures = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as TextureStore; const textures = { upload: vi.fn(), list: vi.fn() } as unknown as TextureStore;
function setup(): EditorState { function setup(): EditorState {
const state = new EditorState(); const state = new EditorState();
+17
View File
@@ -22,6 +22,7 @@ import {
VARIABLES, VARIABLES,
VARIABLES_PARAM, VARIABLES_PARAM,
} from '../src/core/graph'; } from '../src/core/graph';
import type { GraphInterpreter } from '../src/core/graph/GraphInterpreter';
import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset'; import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset';
import { findObject } from '../src/core/model/hierarchy'; import { findObject } from '../src/core/model/hierarchy';
import { worldPosition } from '../src/core/properties/transform'; import { worldPosition } from '../src/core/properties/transform';
@@ -289,6 +290,22 @@ export function runDef(
return def.run!(ctx, inputs, params, def, entry, lookup); return def.run!(ctx, inputs, params, def, entry, lookup);
} }
/**
* Runs every event node of `type` in the graph, in the order the graph holds them, and answers how
* many ran. A game orders its events across all behaviors instead (#264); a test of one graph does not.
*/
export function fire(
interpreter: GraphInterpreter,
type: string,
ctx: NodeContext,
outputs: Record<string, Value> = {},
filter?: (node: GraphNode) => boolean,
): number {
const nodes = interpreter.eventNodes(type).filter((node) => !filter || filter(node));
for (const node of nodes) interpreter.pulse(node, ctx, outputs);
return nodes.length;
}
/** Runs one node definition in isolation and returns its data outputs. */ /** Runs one node definition in isolation and returns its data outputs. */
export function runNode( export function runNode(
type: string, type: string,
+38
View File
@@ -8,6 +8,7 @@ declare(strict_types=1);
*/ */
require __DIR__ . '/../../api/lib/AccountRules.php'; require __DIR__ . '/../../api/lib/AccountRules.php';
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/Store.php'; require __DIR__ . '/../../api/lib/Store.php';
require __DIR__ . '/../../api/lib/UserStore.php'; require __DIR__ . '/../../api/lib/UserStore.php';
@@ -112,6 +113,43 @@ check('transfer keeps the revision', $moved['revision'] === 2, $moved['revision'
$other = (new Store($projects, 'carl'))->create('Carl\'s game'); $other = (new Store($projects, 'carl'))->create('Carl\'s game');
check('an administrator deletes somebody else\'s project', $admin->delete($other['id']) && $admin->listOf('carl') === []); check('an administrator deletes somebody else\'s project', $admin->delete($other['id']) && $admin->listOf('carl') === []);
// The web server serves `data/` as static files, so a readable project would skip the sign-in.
check('a project file is not readable for the web server', (fileperms("{$projects}/{$id}.json") & 0o007) === 0);
// --- Store: saves at the same time -------------------------------------------------------------
// Every worker saves with the revision it just read and tries again when it was beaten to it, so
// no save may be lost: each one that succeeded has to show in the revision.
if (!function_exists('pcntl_fork')) {
echo "SKIP saves at the same time (this PHP has no pcntl)\n";
} else {
$raceDir = "{$dir}/race";
$raceId = (new Store($raceDir, 'anna'))->create('Race')['id'];
$workers = 8;
$rounds = 40;
$children = [];
for ($w = 0; $w < $workers; $w++) {
$pid = pcntl_fork();
if ($pid === 0) {
$store = new Store($raceDir, 'anna');
for ($won = 0; $won < $rounds;) {
try {
$store->save($raceId, $store->load($raceId)['revision'], ['name' => 'Race', 'by' => $w]);
$won++;
} catch (StaleRevisionException) {
}
}
exit(0);
}
$children[] = $pid;
}
foreach ($children as $pid) {
pcntl_waitpid($pid, $status);
}
$final = (new Store($raceDir, 'anna'))->load($raceId)['revision'];
check("{$workers} tabs saving at once lose none of the saves", $final === 1 + $workers * $rounds, $final);
}
// --- AccountRules ------------------------------------------------------------------------------ // --- AccountRules ------------------------------------------------------------------------------
check('renaming yourself is refused', AccountRules::renameRefusal('boss', 'BOSS') !== null); check('renaming yourself is refused', AccountRules::renameRefusal('boss', 'BOSS') !== null);
+1
View File
@@ -6,6 +6,7 @@ declare(strict_types=1);
* with `php tests/php/releases.test.php` and reads the last line. * with `php tests/php/releases.test.php` and reads the last line.
*/ */
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/Store.php'; require __DIR__ . '/../../api/lib/Store.php';
require __DIR__ . '/../../api/lib/ReleaseStore.php'; require __DIR__ . '/../../api/lib/ReleaseStore.php';
+2
View File
@@ -6,6 +6,7 @@ declare(strict_types=1);
* with `php tests/php/snapshots.test.php` and reads the last line. * with `php tests/php/snapshots.test.php` and reads the last line.
*/ */
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/Store.php'; require __DIR__ . '/../../api/lib/Store.php';
require __DIR__ . '/../../api/lib/SnapshotStore.php'; require __DIR__ . '/../../api/lib/SnapshotStore.php';
@@ -36,6 +37,7 @@ $second = $snapshots->create('p1', ['revision' => 5] + $doc, 'second');
check('a snapshot keeps its name and the revision it copied', $first['name'] === 'first' && $first['revision'] === 4, $first); check('a snapshot keeps its name and the revision it copied', $first['name'] === 'first' && $first['revision'] === 4, $first);
check('two snapshots get two ids', $first['id'] !== $second['id']); check('two snapshots get two ids', $first['id'] !== $second['id']);
check('the summary carries no project', !isset($first['project'])); check('the summary carries no project', !isset($first['project']));
check('the snapshot file is not readable for the web server', (fileperms("{$dir}/p1/{$first['id']}.json") & 0o007) === 0);
$listed = $snapshots->list('p1'); $listed = $snapshots->list('p1');
check( check(
+11
View File
@@ -416,6 +416,15 @@ export async function textures(t) {
(await json('__gm.state.notice')) === 'Added the image to the project.', (await json('__gm.state.notice')) === 'Added the image to the project.',
JSON.stringify(await json('__gm.state.notice')), JSON.stringify(await json('__gm.state.notice')),
); );
// Bytes that are no picture are the caller's mistake: a 422 that says so, not a server error.
const refusedUpload = await evaluate(`fetch('/game-maker/api/index.php?r=textures&name=x.png',
{ method: 'POST', headers: { 'Content-Type': 'image/png' }, body: 'hello' })
.then(async (r) => ({ status: r.status, error: (await r.json()).error }))`);
check(
'bytes that are no picture are turned away with 422, not a server error',
refusedUpload.status === 422 && refusedUpload.error === 'Not a readable image',
JSON.stringify(refusedUpload),
);
// Reusing a file already in the project must not make a second asset on it – two assets on one // Reusing a file already in the project must not make a second asset on it – two assets on one
// picture would disagree about its filter. // picture would disagree about its filter.
@@ -486,4 +495,6 @@ export async function textures(t) {
(await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length")) === spritesBefore, (await json("__gm.state.project.assets.filter((a) => a.type === 'sprite').length")) === spritesBefore,
); );
} }
// Saved before the next step: one that navigates would hang at the unsaved-changes prompt.
await t.saved();
} }
+20 -2
View File
@@ -398,6 +398,24 @@ export async function vectorArt(t) {
JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }), JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }),
); );
// Delete takes a shape away however it was picked. A row of the list leaves nothing focused, so the
// key reaches the editor's shortcuts rather than the canvas.
await clickText('Add shape');
await settle();
const spare = (await shapeIds()).at(-1);
const spareRow = await json(`__gm.vectorArt.shapeRowAt(${JSON.stringify(spare)})`);
await click(spareRow.x + spareRow.w / 2, spareRow.y + spareRow.h / 2);
await settle();
const pickedSpare = await json('__gm.vectorArt.selected()');
await press('Delete');
await settle();
const afterDelete = await shapeIds();
check(
'Delete removes a shape picked in the list',
pickedSpare === spare && afterDelete.length === before47e && !afterDelete.includes(spare),
JSON.stringify({ spare, pickedSpare, afterDelete }),
);
// 47e-ii. The duplicate mark puts a copy right above its shape and selects it; a double-click on // 47e-ii. The duplicate mark puts a copy right above its shape and selects it; a double-click on
// a name edits it in the row. Both are undone again, the blocks after count on the shapes. // a name edits it in the row. Both are undone again, the blocks after count on the shapes.
const fieldName = (await shapeOf('va-field')).name; const fieldName = (await shapeOf('va-field')).name;
@@ -883,7 +901,7 @@ export async function vectorArt(t) {
JSON.stringify(addedPaint?.stops), JSON.stringify(addedPaint?.stops),
); );
const stopField = await json("__gm.inspectorField('Stop colour', true)"); const stopField = await json("__gm.inspectorField('Stop color', true)");
await click(stopField.x, stopField.y); await click(stopField.x, stopField.y);
await press('a', { ctrl: true }); await press('a', { ctrl: true });
await typeText('ff0000'); await typeText('ff0000');
@@ -892,7 +910,7 @@ export async function vectorArt(t) {
const reddened = (await sky())?.paint; const reddened = (await sky())?.paint;
const redMiddle = await tabPixel(128, 128); const redMiddle = await tabPixel(128, 128);
check( check(
'the Stop colour row recolours the picked stop, and the middle of the shape turns red', 'the Stop color row recolors the picked stop, and the middle of the shape turns red',
reddened?.stops[2]?.color === 0xff0000 && colourDistance(redMiddle, '#ff0000') < 24, reddened?.stops[2]?.color === 0xff0000 && colourDistance(redMiddle, '#ff0000') < 24,
JSON.stringify({ stops: reddened?.stops, redMiddle }), JSON.stringify({ stops: reddened?.stops, redMiddle }),
); );
+12 -1
View File
@@ -99,12 +99,23 @@ export async function users(t) {
JSON.stringify(userMenu), JSON.stringify(userMenu),
); );
// A session of its own: step 45 changes the password of `e2e`, which ends the tools' cached one. // A session of its own: step 45 changes the password of `e2e`, which ends the tools' cached one.
const refused = await call(await logIn(baseUrl, E2E_USER, await e2ePassword()), 'GET', 'users'); const userCookie = await logIn(baseUrl, E2E_USER, await e2ePassword());
const refused = await call(userCookie, 'GET', 'users');
check( check(
`the API answers ${E2E_USER} 403 on the accounts`, `the API answers ${E2E_USER} 403 on the accounts`,
refused.status === 403 && (await refused.json()).error === 'Administrators only', refused.status === 403 && (await refused.json()).error === 'Administrators only',
String(refused.status), String(refused.status),
); );
// An image, font or sound is shared, and a project of another account may wear it. The name is
// valid but nobody's, so nothing is deleted either way.
const nobodys = `textures/${'0'.repeat(64)}.png`;
const byUser = (await call(userCookie, 'DELETE', nobodys)).status;
const byAdmin = (await asAdmin('DELETE', nobodys)).status;
check(
'only an administrator may delete a shared file',
byUser === 403 && byAdmin === 404,
JSON.stringify({ byUser, byAdmin }),
);
// 49b. Signed in as the administrator, the fixture – which belongs to `e2e` – opens all the // 49b. Signed in as the administrator, the fixture – which belongs to `e2e` – opens all the
// same, and the menu has the user management before the plugins. // same, and the menu has the user management before the plugins.
+50 -3
View File
@@ -97,7 +97,27 @@ export async function snapshots(t) {
revision: await json('__gm.state.revision'), revision: await json('__gm.state.revision'),
}; };
// 50c. Restore puts the snapshot back, keeps the renamed stand as "Before restoring …", reloads // 50c. Escape on the question is its Cancel: the list comes back and nothing is restored.
const cameBack = (expression, label) =>
waitUntil(expression, { label }).then(
() => true,
() => false,
);
await openSnapshots();
await clickLast('Clean start');
await clickLast('Restore');
await press('Escape');
const listBack = await cameBack('__gm.snapshots() !== null', 'the list came back');
check(
'Escape on "Restore snapshot?" leads back to the list and restores nothing',
listBack &&
(await json('__gm.state.project.name')) === RENAMED &&
(await json('__gm.state.revision')) === later.revision,
JSON.stringify({ listBack, name: await json('__gm.state.project.name') }),
);
if (listBack) await clickLast('Cancel');
// Restore puts the snapshot back, keeps the renamed stand as "Before restoring …", reloads
// the project with an empty undo history, and tells so. // the project with an empty undo history, and tells so.
await openSnapshots(); await openSnapshots();
await clickLast('Clean start'); await clickLast('Clean start');
@@ -159,8 +179,35 @@ export async function snapshots(t) {
); );
await clickLast('Cancel'); await clickLast('Cancel');
// 50f. Deleting the project deletes its snapshots: the route answers 404 afterwards. // 50f. The project goes by the picker. Its question stands in for the picker: Cancel leads back to
await api('DELETE', `projects/${made.id}`); // it, and so does the deletion, with the project gone from the list.
await clickToolbar('menu');
await clickText('Open project…');
await waitUntil('__gm.projectPicker() !== null', { label: 'the project picker opened' });
await clickLast('Delete');
await clickLast('Cancel');
const pickerBack = await cameBack('__gm.projectPicker() !== null', 'the picker came back');
check(
'Cancel on "Delete project?" leads back to the picker and keeps the project',
pickerBack && (await stored()).name === RENAMED,
JSON.stringify({ pickerBack, picker: await json('__gm.projectPicker()') }),
);
if (pickerBack) {
await clickLast('Delete');
await clickLast('Delete');
} else await api('DELETE', `projects/${made.id}`);
const pickerAgain = await cameBack(
`__gm.projectPicker() !== null && !__gm.projectPicker().names.includes(${JSON.stringify(RENAMED)})`,
'the picker came back without the project',
);
check(
'deleting from the picker leads back to it, without the project',
pickerAgain,
JSON.stringify(await json('__gm.projectPicker()')),
);
if (pickerAgain) await clickLast('Cancel');
// Deleting the project deletes its snapshots: the route answers 404 afterwards.
const gone = await api('GET', `projects/${made.id}/snapshots`); const gone = await api('GET', `projects/${made.id}/snapshots`);
check('deleting the project takes its snapshots along', gone.status === 404, String(gone.status)); check('deleting the project takes its snapshots along', gone.status === 404, String(gone.status));
} finally { } finally {
+1
View File
@@ -11,6 +11,7 @@ declare(strict_types=1);
* php tools/make-user.php --list * php tools/make-user.php --list
*/ */
require __DIR__ . '/../api/lib/AtomicFile.php';
require __DIR__ . '/../api/lib/UserStore.php'; require __DIR__ . '/../api/lib/UserStore.php';
$file = dirname(__DIR__) . '/data/users.json'; $file = dirname(__DIR__) . '/data/users.json';