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
+663 -577

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
/data/projects/*.json
/data/projects/*.tmp
/data/projects/*.lock
/data/e2e-password.txt
/data/e2e-admin-password.txt
/data/sessions/
@@ -19,4 +20,4 @@
/wiki/
/index.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:
```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
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.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}]
* 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}]
* 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}]
* 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)
*
@@ -74,7 +74,9 @@ declare(strict_types=1);
*/
require __DIR__ . '/lib/AccountRules.php';
require __DIR__ . '/lib/AtomicFile.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/OriginalName.php';
require __DIR__ . '/lib/PixelStore.php';
@@ -101,6 +103,11 @@ const MAX_FONT_BYTES = 4 * 1024 * 1024;
const MAX_SOUND_BYTES = 16 * 1024 * 1024;
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);
});
@@ -467,23 +474,38 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
Response::error('Method not allowed', 405);
}
/** @param list<string> $parts */
function routeTextures(array $parts, string $method): never
{
$store = new TextureStore(dirname(__DIR__) . '/data/textures');
/**
* 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
* 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 ($method === 'GET') {
Response::json($store->list());
}
if ($method === 'POST') {
$bytes = Response::rawBody();
if (strlen($bytes) > MAX_TEXTURE_BYTES) {
Response::error('Image must be at most ' . (MAX_TEXTURE_BYTES / 1024 / 1024) . ' MB', 413);
if (strlen($bytes) > $maxBytes) {
Response::error("{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB', 413);
}
try {
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);
}
}
@@ -491,74 +513,9 @@ function routeTextures(array $parts, string $method): never
}
if ($method === 'DELETE') {
$auth->requireAdmin();
if (!$store->delete($parts[1])) {
Response::error('Texture 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::error("{$noun} not found", 404);
}
Response::noContent();
}
@@ -642,12 +599,37 @@ try {
// 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.
$user = $auth->require();
$data = dirname(__DIR__) . '/data';
match ($parts[0]) {
'projects' => routeProjects($parts, $method, $user, $auth->role($user) === 'admin'),
'textures' => routeTextures($parts, $method),
'fonts' => routeFonts($parts, $method),
'sounds' => routeSounds($parts, $method),
'textures' => routeFiles(
$parts,
$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),
'plugins' => routePlugins($parts, $method),
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);
/**
* Content-addressed font store: `data/fonts/{sha256}.{ext}` – the image store's twin, for the
* 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.
* Content-addressed font store: `data/fonts/{sha256}.{ext}` (see HashedFileStore).
*
* 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
@@ -25,14 +23,6 @@ final class FontStore
'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
{
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');
}
$file = hash('sha256', $bytes) . '.' . $extension;
$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);
return $this->keep($bytes, $extension, $originalName);
}
}
+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 [];
}
}
+7 -8
View File
@@ -70,16 +70,15 @@ final class OriginalName
if ($clean === '') {
return;
}
$path = $dir . '/' . $file . '.json';
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
$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 (is_file($tmp)) {
unlink($tmp);
}
return; // A nameless file is a small loss; failing the upload over it would be worse.
if ($json === false) {
return;
}
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. */
+1 -8
View File
@@ -54,14 +54,7 @@ final class PixelStore
$file = hash('sha256', $bytes) . '.px';
$path = $this->dir . '/' . $file;
if (!is_file($path)) {
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
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);
AtomicFile::write($path, $bytes, AtomicFile::SERVED, 'pixel file');
}
return ['file' => $file, 'bytes' => $length];
}
+1 -10
View File
@@ -146,15 +146,6 @@ final class ReleaseStore
if ($json === false) {
throw new RuntimeException('Cannot encode release');
}
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
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');
}
AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'release file');
}
}
+5 -3
View File
@@ -110,9 +110,11 @@ final class SessionStore
throw new RuntimeException('Invalid session token');
}
$json = json_encode($data, JSON_UNESCAPED_SLASHES);
if ($json === false || file_put_contents($path, $json, LOCK_EX) === false) {
throw new RuntimeException('Cannot write session file');
if ($json === false) {
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) {
throw new RuntimeException('Cannot encode snapshot');
}
$path = $this->path($projectId, $id);
$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');
}
AtomicFile::write($this->path($projectId, $id), $json, AtomicFile::HIDDEN, 'snapshot file');
return self::summary($snapshot);
}
+5 -72
View File
@@ -2,15 +2,13 @@
declare(strict_types=1);
/**
* Content-addressed sound store: `data/sounds/{sha256}.{ext}` – the font store's twin (see
* 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.
* Content-addressed sound store: `data/sounds/{sha256}.{ext}` (see HashedFileStore).
*
* 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
@@ -22,14 +20,6 @@ final class SoundStore
'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
{
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');
}
$file = hash('sha256', $bytes) . '.' . $extension;
$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;
return $this->keep($bytes, $extension, $originalName);
}
/** The format these bytes are, `''` when they are none we take. */
@@ -138,17 +82,6 @@ final class SoundStore
$version = ($second >> 3) & 0b11;
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. */
+11 -10
View File
@@ -123,11 +123,14 @@ final class Store
public function delete(string $id): bool
{
if ($this->load($id) === null) {
return false;
}
$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
* @param callable():T $work
* @return T
@@ -169,7 +175,6 @@ final class Store
} finally {
flock($lock, LOCK_UN);
fclose($lock);
@unlink($path . '.lock');
}
}
@@ -221,10 +226,6 @@ final class Store
if ($json === false) {
throw new RuntimeException('Cannot encode project');
}
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $json, LOCK_EX) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Cannot write project file');
}
AtomicFile::write($path, $json, AtomicFile::HIDDEN, 'project file');
}
}
+13 -87
View File
@@ -2,19 +2,13 @@
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:
* uploading the same image 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 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.
* The flip side of hashed names 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. */
private const EXTENSIONS = [
@@ -38,14 +32,6 @@ final class TextureStore
*/
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
{
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');
}
$file = hash('sha256', $bytes) . '.' . $extension;
$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),
];
return $this->keep($bytes, $extension, $originalName, ['width' => $width, 'height' => $height]);
}
/**
* 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
* 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}>
* @return array{width:int,height:int}
*/
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);
$items[] = [
'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);
$size = @getimagesize($path);
return ['width' => (int) ($size[0] ?? 0), 'height' => (int) ($size[1] ?? 0)];
}
}
+1 -6
View File
@@ -216,11 +216,6 @@ final class UserStore
if ($json === false) {
throw new RuntimeException('Cannot encode users');
}
$tmp = $this->file . '.' . bin2hex(random_bytes(4)) . '.tmp';
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);
AtomicFile::write($this->file, $json, AtomicFile::HIDDEN, 'users file');
}
}
+1 -1
View File
@@ -270,7 +270,7 @@ export class SetKeyInterpolationCommand extends TimelineCommand {
else key.interpolation = this.interpolation;
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 { PropertyBag } from '../model/Properties';
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 { transformOfObject } from '../properties/transform';
import { removeById } from '../util/list';
@@ -52,7 +52,7 @@ abstract class AddPartCommand implements Command {
if (requireAsset(project, this.assetId).type !== this.partType)
throw new CommandError(`That asset is not a ${this.partType}`);
} 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));
}
+2 -2
View File
@@ -72,9 +72,9 @@ export class SetPaletteColorsCommand implements Command {
execute(project: Project): void {
const palette = requirePalette(project, this.paletteId);
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))
throw new CommandError('A colour is 0xRRGGBB');
throw new CommandError('A color is 0xRRGGBB');
this.before = [...palette.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');
for (const paint of [this.style.paint, this.style.stroke?.paint])
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 = {
fill: shape.fill,
opacity: shape.opacity,
+4 -18
View File
@@ -105,24 +105,10 @@ export class GraphInterpreter {
return this.indexOf(this.graph).ofType(type);
}
fire(
type: string,
ctx: NodeContext,
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. */
/**
* Runs the flow of one event node, whichever it is. `port` is the exec output the pulse leaves
* through – a state event names one.
*/
pulse(node: GraphNode, ctx: NodeContext, outputs: Record<string, Value> = {}, port = 'exec'): void {
this.frame = this.emptyFrame(this.graph);
this.callStack = [];
+1 -1
View File
@@ -54,7 +54,7 @@ export const PlaySound: NodeDef = {
description:
'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 " +
'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, ' +
'unless "detached" is on.',
inputs: [exec(), soundPort(), withDefault(num('volume'), 1), loopPort(), detachedPort()],
@@ -21,7 +21,7 @@ export const PlayAnimation: NodeDef = {
description:
'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 ' +
'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 ' +
'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 ' +
+10
View File
@@ -42,6 +42,16 @@ export interface ObjectGroup {
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(
id: string,
assetId: string,
+2 -2
View File
@@ -16,7 +16,7 @@ import { treeOrder } from './hierarchy';
import { instanceChildId, resolveObjects } from './instance';
import { findAsset, type Project } from './Project';
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';
/** `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. */
export function trackLabel(project: Project, animation: AnimationAsset, track: AnimationTrack): string {
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 =
aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === 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 { resolveObjects } from './instance';
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';
/** 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[] = [];
for (const { object, depth } of treeOrder({ ...holder, objects })) {
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 });
}
if (!scope) return choices;
+3 -3
View File
@@ -229,7 +229,7 @@ export const ParticleColor: ParticleNodeDef = {
type: 'ParticleColor',
label: 'Color',
category: 'particles',
description: 'A constant colour.',
description: 'A constant color.',
inputs: [],
outputs: [numberOut('color')],
params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })],
@@ -262,7 +262,7 @@ export const ParticleOutput: ParticleNodeDef = {
label: 'Output',
category: 'particles',
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: [
{ key: 'x', label: 'x', type: 'number', inline: false },
{ key: 'y', label: 'y', type: 'number', inline: false },
@@ -423,7 +423,7 @@ export const ParticleMixColor: ParticleNodeDef = {
type: 'ParticleMixColor',
label: 'Mix color',
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)],
outputs: [numberOut('color')],
params: [],
+1 -1
View File
@@ -83,7 +83,7 @@ export function compileShader(graph: Graph): ShaderBuild {
},
conform: (node, port, expr) => {
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;
}
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',
category: 'shader',
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 }],
outputs: [],
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,
label: 'Color',
category: 'shader',
description: 'A constant colour.',
description: 'A constant color.',
inputs: [],
outputs: [vec3Port('color')],
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' } }),
};
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(
'ShaderSubtract',
'Subtract',
'-',
'a - b, per channel for colours.',
'a - b, per channel for colors.',
0,
);
export const ShaderMultiply = arithmetic(
'ShaderMultiply',
'Multiply',
'*',
'a × b, per channel for colours.',
'a × b, per channel for colors.',
1,
);
/**
@@ -291,7 +291,7 @@ export const ShaderDivide: ShaderNodeDef = {
type: 'ShaderDivide',
label: 'Divide',
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)],
outputs: [anyOut('result')],
params: [],
@@ -305,7 +305,7 @@ export const ShaderClamp: ShaderNodeDef = {
type: 'ShaderClamp',
label: 'Clamp',
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)],
outputs: [anyOut('result')],
params: [],
@@ -332,7 +332,7 @@ export const ShaderLuminance: ShaderNodeDef = {
type: 'ShaderLuminance',
label: 'Luminance',
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')],
outputs: [{ key: 'value', label: 'value', type: 'number' }],
params: [],
@@ -358,7 +358,7 @@ export const ShaderSin = unary(
'ShaderSin',
'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(
'ShaderFloor',
@@ -376,14 +376,14 @@ export const ShaderAbs = unary(
'ShaderAbs',
'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 = {
type: 'ShaderStep',
label: 'Step',
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)],
outputs: [anyOut('result')],
params: [],
@@ -398,7 +398,7 @@ export const ShaderPow: ShaderNodeDef = {
label: 'Pow',
category: 'shader',
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)],
outputs: [anyOut('result')],
params: [],
@@ -414,7 +414,7 @@ export const ShaderSqrt: ShaderNodeDef = {
label: 'Sqrt',
category: 'shader',
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)],
outputs: [anyOut('result')],
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
* 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
* 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> {
upload(bytes: Blob | ArrayBuffer, type: string, name?: string): Promise<U>;
list(): Promise<S[]>;
delete(file: string): Promise<void>;
}
-4
View File
@@ -27,8 +27,4 @@ export class HttpFileStore<
async list(): Promise<S[]> {
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();
}
/** The question stands in for the picker: Cancel goes back to it, and so does the deletion. */
private confirmDelete(id: string, name: string): void {
new Dialog(`Delete project "${name}"?`, 'All assets and scenes are removed permanently.', [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => void this.run(this.deleteProject(id)) },
{ label: 'Cancel', onClick: () => this.openProjectPicker() },
{
label: 'Delete',
danger: true,
onClick: () =>
void this.run(
(async () => {
await this.deleteProject(id);
this.openProjectPicker();
})(),
),
},
]).openCentered();
}
@@ -262,7 +273,7 @@ export class ProjectSession {
`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.',
[
{ label: 'Cancel' },
{ label: 'Cancel', onClick: () => this.openSnapshots() },
{
label: 'Restore',
primary: true,
+54 -2
View File
@@ -2,6 +2,7 @@
* 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.
*/
import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands';
import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands';
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
import {
@@ -12,8 +13,10 @@ import {
} from '../core/commands/colliderCommands';
import { oneStep } from '../core/commands/CompoundCommand';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveFrameCommand } from '../core/commands/frameCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands';
import type { AssetType } from '../core/model/AssetTypes';
import { curveUsages } from '../core/model/curve';
import { structTypeUsages } from '../core/model/structTypes';
@@ -29,7 +32,6 @@ import { animationsUsing, tilesUsing, findPropertyUsages, findUsages } from '../
import { Dialog } from '../ui';
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
* 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);
}
/** 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 {
const command = new CreateContainerCommand(holderId, objectIds);
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));
}
/** 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. */
export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean {
const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
@@ -115,6 +130,43 @@ export function removeGraphSelection(
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. */
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
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.
const list =
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 type { AnimationAsset, AnimationTrack } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import { objectName } from '../../core/model/SceneObject';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { type Button, Dropdown, Label, Menu, type MenuItem } from '../../ui';
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))
choices.push({
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;
}
+2 -2
View File
@@ -49,7 +49,7 @@ export function buildAnnotationSection(host: InspectorHost, assetId: string, ann
);
addNote(
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',
);
if (annotation.color !== undefined)
@@ -103,7 +103,7 @@ function addRow(
function describe(kind: AnnotationKind): string {
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.';
}
+3 -3
View File
@@ -29,7 +29,7 @@ import {
import { type ObjectChoice, objectChoices } from '../../core/model/objectRefs';
import { type PropertyValue, textOf } from '../../core/model/Properties';
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 {
COMMON_PROPS,
isCommonProp,
@@ -120,7 +120,7 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
for (const id of objectIds) {
const object = findObject(view, id);
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);
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) }),
);
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);
host.body.add(
new Button('Remove', {
+2 -2
View File
@@ -1,7 +1,7 @@
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
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 { labelledRow } from './fieldFactory';
import type { InspectorHost } from './host';
@@ -14,7 +14,7 @@ import { viewOf } from './objectQueries';
*/
export function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) {
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 name = parent ? nameOf(parent) : 'None';
if (fixed) {
+9 -12
View File
@@ -14,7 +14,6 @@ import {
DuplicateVectorShapeCommand,
MoveVectorShapeCommand,
RemoveVectorShapeCommand,
RemoveVectorShapesCommand,
RenameVectorShapeCommand,
SetVectorShapeStyleCommand,
} from '../../core/commands/vectorArtCommands';
@@ -52,6 +51,7 @@ import {
} from '../../core/model/vectorPaint';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, HStack, Theme } from '../../ui';
import { removeShapes } from '../actions';
import { type EditorState, shapeSelection } from '../state';
import { GradientBar } from '../vectorart/GradientBar';
import { ShapeList } from '../vectorart/ShapeList';
@@ -74,7 +74,7 @@ interface PreviewStyle {
export type PaintPart = 'fill' | 'stroke';
const FILL_KIND_LABELS: Record<FillKind, string> = {
solid: 'Solid colour',
solid: 'Solid color',
linear: 'Linear gradient',
radial: 'Radial gradient',
texture: 'Texture',
@@ -112,7 +112,7 @@ const TEXTURE_DEF: PropertyDef = {
function stopDefs(part: PaintPart): { color: PropertyDef; alpha: PropertyDef; offset: PropertyDef } {
const prefix = part === 'fill' ? 'fillStop' : 'lineStop';
return {
color: { key: `${prefix}Color`, label: 'Stop colour', kind: 'color', default: 0xffffff },
color: { key: `${prefix}Color`, label: 'Stop color', kind: 'color', default: 0xffffff },
alpha: {
key: `${prefix}Alpha`,
label: 'Stop opacity',
@@ -187,7 +187,7 @@ const OPACITY_DEF: PropertyDef = {
const OUTLINE_DEF: PropertyDef = { key: 'outline', label: 'Outline', kind: 'boolean', default: false };
const STROKE_COLOR_DEF: PropertyDef = {
key: 'strokeColor',
label: 'Line colour',
label: 'Line color',
kind: 'color',
default: DEFAULT_STROKE.color,
};
@@ -303,7 +303,7 @@ export function buildShapeSection(
STROKE_COLOR_DEF,
(s) => s.stroke?.color ?? DEFAULT_STROKE.color,
(input, s) => ({ stroke: { ...strokeOf(s), color: Number(input) } }),
'Set line colour',
'Set line color',
);
else gradientRows(host, assetId, shapeId, 'stroke');
styleRow(
@@ -323,10 +323,7 @@ export function buildShapeSection(
host.body.add(
new Button('Delete shapes', {
variant: 'danger',
onClick: () => {
host.state.execute(new RemoveVectorShapesCommand(assetId, shapeIds));
host.state.select({ kind: 'asset', assetId });
},
onClick: () => removeShapes(host.state, assetId, shapeIds),
}),
);
host.body.add(addShapeButton(host, assetId));
@@ -683,9 +680,9 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par
},
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.offset, (stop) => stop.offset, 'offset', 'Move colour stop');
stopRow(defs.offset, (stop) => stop.offset, 'offset', 'Move color stop');
const remove = new Button('Remove stop', {
variant: 'ghost',
@@ -701,7 +698,7 @@ function gradientRows(host: InspectorHost, assetId: string, shapeId: string, par
assetId,
shapeId,
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 { dialogSlot, FormDialog } from '../FormDialog';
@@ -72,8 +72,8 @@ export class GroupNameDialog extends FormDialog {
return [
{ label: NEW_GROUP, onSelect: pick('', NEW_GROUP) },
...(this.join?.groups ?? []).map((g) => ({
label: g.name || 'Group',
onSelect: pick(g.id, g.name || 'Group'),
label: groupName(g),
onSelect: pick(g.id, groupName(g)),
})),
];
}
+3 -2
View File
@@ -11,6 +11,7 @@ import {
} from '../../core/model/hierarchy';
import { resolvedHolder } from '../../core/model/instance';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { groupName, objectName } from '../../core/model/SceneObject';
import { deviationMarks } from '../../core/properties/deviation';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { groupRowItems, selectGroup } from '../objectGroups';
@@ -272,7 +273,7 @@ export class HierarchyPanel extends Panel {
if (row.kind === 'group')
return {
id: groupRowId(row.group.id),
label: row.group.name || 'Group',
label: groupName(row.group),
hint: String(row.memberIds.length),
icon: GROUP_ICON_SVG,
iconAtEnd: true,
@@ -284,7 +285,7 @@ export class HierarchyPanel extends Panel {
const asset = findAsset(this.state.project, object.assetId);
return {
id: object.id,
label: object.name || asset?.name || '?',
label: objectName(object, asset),
icon: asset && ASSET_ICON_SVG[asset.type],
iconAtEnd: true,
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);
c.add(this.toolOptions);
c.add(heading('Colours'));
c.add(heading('Colors'));
this.palette.onPick = (index) => this.canvas.setColor(index);
this.palette.onReflow = () => this.relayoutContent();
c.add(this.palette);
@@ -395,7 +395,7 @@ export class PixelArtSide extends ScrollContainer {
this.addColorButton.setEnabled(palette !== null);
this.removeColorButton.setEnabled(editable);
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;
+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;
const colors = [...palette.colors];
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. */
export function addPaletteColor(state: EditorState, palette: PaletteAsset, color: number): number {
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;
}
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 (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 recolor = new SetPaletteColorsCommand(palette.id, 'Remove colour', colors);
return state.execute(oneStep('Remove colour', [recolor, ...rewrites]));
const recolor = new SetPaletteColorsCommand(palette.id, 'Remove color', colors);
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 }),
});
}
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 type { PropertyBag } from '../../core/model/Properties';
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 type { FlatDef } from '../../core/properties/PropertySchema';
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 asset = object && findAsset(project, object.assetId);
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') {
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 { confirmDeleteAsset, createContainer, removeGraphSelection, removeObjects } from './actions';
import { createContainer, removeSelection } from './actions';
import type { EditorState } from './state';
export interface ShortcutActions {
@@ -45,22 +43,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
return true;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
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;
}
if (event.key === 'Delete' || event.key === 'Backspace') return removeSelection(state);
// 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.
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 { findObject } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { objectName } from '../../core/model/SceneObject';
import { Label, ScrollContainer, Theme, ui, VStack, HStack } from '../../ui';
import { assetSignature, buildVariables, refreshAssetSection } from '../inspector/assetSection';
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 object = view && findObject(view, owner.objectId);
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. */
+2 -2
View File
@@ -122,7 +122,7 @@ export class GradientBar extends Widget {
return;
}
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);
}
@@ -139,7 +139,7 @@ export class GradientBar extends Widget {
private onRelease(): void {
const drag = this.drag;
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 {
+3 -9
View File
@@ -13,8 +13,6 @@
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
import {
AddVectorShapeCommand,
RemoveVectorShapeCommand,
RemoveVectorShapesCommand,
paintsOf,
SetVectorShapeRingsCommand,
SetVectorShapeStyleCommand,
@@ -50,6 +48,7 @@ import {
withSmoothToggled,
} from '../polygonEdit';
import { DRAG_THRESHOLD, drawPointGrip, HANDLE_RADIUS, POINT_SIZE } from '../pointGrips';
import { removeShapes } from '../actions';
import { type EditorState, shapeSelection } from '../state';
import { drawChecker } from '../texture/checker';
import { SheetCanvas } from '../texture/SheetCanvas';
@@ -256,12 +255,7 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
const shapeIds = this.selectedShapeIds();
if (shapeIds.length === 0) return super.onKeyDown(event);
this.endPen();
this.state.execute(
shapeIds.length === 1
? new RemoveVectorShapeCommand(this.assetId, shapeIds[0]!)
: new RemoveVectorShapesCommand(this.assetId, shapeIds),
);
this.state.select({ kind: 'asset', assetId: this.assetId });
removeShapes(this.state, this.assetId, shapeIds);
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.';
return this.shownPaint()
? `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 {
+2 -2
View File
@@ -11,7 +11,7 @@ import { referenceCandidates } from '../core/model/objectRefs';
import type { Cell } from '../core/model/tilemap';
import { descendantsBelow } from '../core/model/walk';
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 { hasTransformProps } from '../core/properties/PropertySchema';
import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform';
@@ -247,7 +247,7 @@ export class World {
}
entity.assetId = asset.id;
entity.type = asset.type;
entity.name = object.name || asset.name;
entity.name = objectName(object, asset);
entity.x = object.position.x;
entity.y = object.position.y;
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 {
if (!context) throw new Error('UI not initialised – call initUI() first');
if (!context) throw new Error('UI not initialized – call initUI() first');
return context;
}
+11 -1
View File
@@ -12,10 +12,16 @@ export interface DialogButton {
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 {
private readonly cancel: (() => void) | undefined;
constructor(title: string, message: string, buttons: DialogButton[]) {
super({ modal: true, padding: Theme.padding * 2, gap: Theme.padding });
this.cancel = buttons.find((b) => !b.primary && !b.danger)?.onClick;
this.minWidth = 320;
this.content.add(new Label(title, { style: 'bodyBold' }));
if (message) this.content.add(new Label(message, { style: 'muted' }));
@@ -30,4 +36,8 @@ export class Dialog extends Popup {
}
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) => {
if (event.key === 'Escape') {
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 {
const c = this.content.measure();
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,
} from '../core/model/tilemapCollision';
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 { resolveTexture, type TextureRef } from '../core/model/texture';
import type { FrameOutline } from '../core/model/frameOutline';
@@ -206,7 +207,7 @@ export function collectHolderItems(
items.push({
id: object.id,
type: asset.type,
name: object.name || asset.name,
name: objectName(object, asset),
x: m.tx,
y: m.ty,
z,
+3 -3
View File
@@ -13,7 +13,7 @@ import {
} from '../../src/core/graph';
import { graphLookup } from '../../src/core/model/graphLookup';
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() {
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');
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']);
});
});
@@ -122,7 +122,7 @@ describe('a struct travelling through a nested behavior', () => {
// 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);
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);
});
});
+3 -3
View File
@@ -14,7 +14,7 @@ import {
} from '../../src/core/graph';
import type { PropertyValue } from '../../src/core/model/Properties';
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. */
function block() {
@@ -56,7 +56,7 @@ function search(names: readonly PropertyValue[], wanted: PropertyValue, by: 'ite
[none, 'text', miss, 'message'],
]);
const ctx = fakeContext({ variables: { names: [...names] } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
return ctx.logs;
}
@@ -91,7 +91,7 @@ describe('Find … Match at run time', () => {
[match, 'notFound', after, 'exec'],
]);
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']);
});
});
+2 -2
View File
@@ -13,7 +13,7 @@ import {
import { graphLookup } from '../../src/core/model/graphLookup';
import type { PropertyValue } from '../../src/core/model/Properties';
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. */
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' };
}
const ctx = fakeContext({ variables: { names: [...names] } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
return ctx.logs;
}
+2 -2
View File
@@ -14,7 +14,7 @@ import {
foldMarkLabel,
foldMarkRadius,
} 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. */
function mover() {
@@ -38,7 +38,7 @@ describe('folding an edge', () => {
expect(behavior.graph.edges.every((e) => e.folded === true)).toBe(true);
const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.self.x).toBe(7);
// And the edges themselves are untouched apart from the flag.
expect(findAsset(project, behavior.id)).toBe(behavior);
+18 -17
View File
@@ -37,6 +37,7 @@ import {
makeSpriteScene,
place,
roundTrip,
fire,
} from '../helpers';
/** 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 interpreter = new GraphInterpreter(behavior.graph);
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(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);
});
@@ -120,7 +121,7 @@ describe('GraphInterpreter', () => {
[self, 'object', move, 'object'],
]);
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.y).toBe(-3);
});
@@ -137,7 +138,7 @@ describe('GraphInterpreter', () => {
[clamp, 'result', move, 'dy'],
]);
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.y).toBe(1); // clamp(5, 0, 1)
});
@@ -162,7 +163,7 @@ describe('GraphInterpreter', () => {
const interpreter = new GraphInterpreter(behavior.graph);
const run = (keys: string[]) => {
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 };
};
expect(run([])).toEqual({ x: 0, y: 0 });
@@ -199,7 +200,7 @@ describe('GraphInterpreter', () => {
previous = log;
});
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']);
});
@@ -226,7 +227,7 @@ describe('GraphInterpreter', () => {
const interpreter = new GraphInterpreter(behavior.graph);
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.logs).toEqual(['then', 'then', 'then', 'else', 'else']);
});
@@ -237,7 +238,7 @@ describe('GraphInterpreter', () => {
const edges: string[] = [];
const visited: string[] = [];
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(visited).toContain(nodes.mul.id);
expect(visited).toContain(nodes.move.id);
@@ -253,7 +254,7 @@ describe('GraphInterpreter', () => {
[a, 'a', b, '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/,
);
});
@@ -267,7 +268,7 @@ describe('GraphInterpreter', () => {
[start, 'exec', repeat, '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']]);
}
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;
}
@@ -327,7 +328,7 @@ describe('Repeat', () => {
it('runs the body once per round, each round seeing its own index', () => {
const { behavior } = summingLoop(5);
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.
expect(ctx.variables.count).toBe(10);
});
@@ -335,7 +336,7 @@ describe('Repeat', () => {
it('runs nothing at all for a count of zero', () => {
const { behavior } = summingLoop(0);
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);
});
@@ -344,7 +345,7 @@ describe('Repeat', () => {
const done = edit.addNode('Log', { message: 'done' });
edit.wire([[nodes.repeat, 'done', done, 'exec']]);
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'] });
});
@@ -364,7 +365,7 @@ describe('Repeat', () => {
[add, 'result', set, 'count'],
]);
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);
});
@@ -387,7 +388,7 @@ describe('Repeat', () => {
[afterAdd, 'result', after, 'count'],
]);
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.
expect(ctx.variables.count).toBe(30);
});
@@ -429,7 +430,7 @@ describe('For each', () => {
]);
}
const ctx = fakeContext({ variables: { names: [...names] } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
return ctx.logs;
}
+2 -1
View File
@@ -26,6 +26,7 @@ import {
place,
roundTrip,
startWorld,
fire,
} from '../helpers';
/** 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: {} });
new GraphInterpreter(graph).fire('OnStart', ctx);
fire(new GraphInterpreter(graph), 'OnStart', ctx);
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' }]), {
status: 200,
});
await expect(new HttpFileStore('/api', 'textures').list()).resolves.toEqual([
{ 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';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
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: {} };
@@ -31,7 +31,7 @@ function logged(
...(source === text ? [] : ([[source, port, text, 'value']] as const)),
]);
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;
}
+8 -7
View File
@@ -32,6 +32,7 @@ import {
type ProjectFixture,
roundTrip,
startWorld,
fire,
} from '../helpers';
/**
@@ -98,7 +99,7 @@ function threeCalls(fixture: ProjectFixture, callee: BehaviorAsset): unknown[][]
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
const seen: unknown[][] = [];
for (let i = 0; i < 3; i++) {
interpreter.fire('OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
seen.push([ctx.variables.r0, ctx.variables.r1]);
}
return seen;
@@ -151,9 +152,9 @@ describe('Keep on a behavior variable (#229)', () => {
const caller = callerOf(fixture, counter(fixture), 2);
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
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]);
interpreter.fire('OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
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.
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 interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables: { r0: 0, r1: 0 } });
interpreter.fire('OnStart', ctx);
interpreter.fire('OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
// One shared inner counter would have handed out 1, 2, 3, 4.
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 ctx = fakeContext();
interpreter.fire('OnStart', ctx);
interpreter.fire('OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
expect(ctx.logs).toEqual(['first', 'first']);
// A local machine of the callee is none of the caller's business either.
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 { Input } from '../../src/runtime/systems/Input';
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() {
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));
interpreter.trace = listener;
const ctx = fakeContext({ variables: { count: 5, other: 9 } });
interpreter.fire('OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
ctx.variables.count = 99;
const values = seen.get(behavior.graph)!;
@@ -77,7 +85,7 @@ describe('live values', () => {
const { seen, listener } = recorder();
const interpreter = new GraphInterpreter(caller.graph, graphLookup(fixture.project));
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(times.id)).toEqual({ result: 42 });
+3 -3
View File
@@ -17,7 +17,7 @@ import {
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup';
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
@@ -57,7 +57,7 @@ function callerOf(fixture: ProjectFixture, callee: BehaviorAsset, input: number)
function runOnStart(fixture: ProjectFixture, asset: BehaviorAsset, variables: Record<string, number> = {}) {
const interpreter = new GraphInterpreter(asset.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables });
interpreter.fire('OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
return { ctx, interpreter };
}
@@ -367,7 +367,7 @@ describe('declared ports', () => {
const caller = loaded.assets.find((a) => a.name === 'Caller') as BehaviorAsset;
const interpreter = new GraphInterpreter(caller.graph, graphLookup(loaded));
const ctx = fakeContext({ variables: { result: 0 } });
interpreter.fire('OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
expect(ctx.variables.result).toBe(12);
});
+4 -3
View File
@@ -8,6 +8,7 @@ import {
makeSpriteScene,
place,
startWorld,
fire,
} from '../helpers';
// 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);
},
});
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(ctx.logs).toEqual(['Crate']); // the spawned object flowed into Log
});
@@ -84,10 +85,10 @@ describe('Spawn node', () => {
...graph,
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', '']);
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');
});
});
+14
View File
@@ -21,6 +21,7 @@ import {
} from '../../src/core/model/hierarchy';
import { instanceChildId, resolvedHolder } from '../../src/core/model/instance';
import { findAsset, findScene } from '../../src/core/model/Project';
import { groupName, objectName } from '../../src/core/model/SceneObject';
import { serializeProject } from '../../src/core/store/serialize';
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!)));
});
});
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 { outputStructType, structTypeUsages } from '../../src/core/model/structTypes';
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 } */
function lootType(f: ProjectFixture): StructAsset {
@@ -126,7 +126,7 @@ describe('splitting an output', () => {
{ Name: 'Gem', Amount: 7 },
];
const ctx = fakeContext({ variables: { bag } });
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
fire(new GraphInterpreter(behavior.graph), 'OnStart', ctx);
expect(ctx.logs).toEqual(['7']);
});
+3 -3
View File
@@ -20,7 +20,7 @@ import {
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { graphLookup } from '../../src/core/model/graphLookup';
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 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) {
const self = { ...fakeEntity('self'), props: { ...props } };
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 };
}
@@ -81,7 +81,7 @@ describe('a node that reads and writes properties of an object', () => {
]);
const self_ = { ...fakeEntity('self'), props: { coins: 5 } };
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);
});
+3 -3
View File
@@ -329,7 +329,7 @@ describe('compileShader', () => {
wrong.link(doubled, 'result', out, 'alpha');
const build = compileShader(wrong.asset().graph);
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', () => {
@@ -345,8 +345,8 @@ describe('compileShader', () => {
s.link(mixed, 'result', output, 'color');
let build = compileShader(s.asset().graph);
expect(build.compiled).toBeNull();
expect(build.problems).toEqual({ [mixed]: '"t" needs a number, not a colour' });
expect(build.reason).toBe('"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 color');
const dup = shaderEditor(f, 'Dup');
dup.add(SHADER_OUTPUT);
+10 -9
View File
@@ -51,6 +51,7 @@ import {
type ProjectFixture,
roundTrip,
startWorld,
fire,
} from '../helpers';
/** 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 ctx = fakeContext();
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(f.project));
interpreter.fire('OnUpdate', ctx);
fire(interpreter, 'OnUpdate', ctx);
expect(ctx.logs).toEqual([]);
interpreter.fire('OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[0] });
interpreter.fire('OnUpdate', ctx);
interpreter.fire('OnUpdate', ctx);
fire(interpreter, 'OnUpdate', ctx);
fire(interpreter, 'OnUpdate', ctx);
expect(ctx.logs).toEqual(['open', 'closed']);
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
});
@@ -296,7 +297,7 @@ describe('a running state machine', () => {
[set, 'exec', call, 'exec'],
]);
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.logs).toEqual([]);
});
@@ -328,12 +329,12 @@ describe('a running state machine', () => {
]);
const ctx = fakeContext({ variables: { doors: machine.id } });
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.
expect(ctx.states).toEqual({ [machine.id]: machine.ids[1] });
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.
interpreter.fire('OnUpdate', ctx);
fire(interpreter, 'OnUpdate', ctx);
expect(ctx.logs).toEqual(['no such state', 'unknown to the graph']);
});
@@ -566,7 +567,7 @@ describe('reading the current state', () => {
[current, 'name', log, 'message'],
]);
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']);
});
@@ -657,7 +658,7 @@ describe('transitions between states', () => {
[set, 'exec', next, 'exec'],
]);
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 });
});
+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 { BehaviorAsset } from '../../src/core/model/Asset';
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']) {
const fixture = makeProject();
@@ -19,7 +19,7 @@ function run(
variables: Record<string, number> = { count: 5, copy: 0 },
) {
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;
}
+3 -3
View File
@@ -21,7 +21,7 @@ import {
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
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`. */
function bothWays() {
@@ -50,7 +50,7 @@ function run(
) {
const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project));
const ctx = fakeContext({ variables });
interpreter.fire('OnStart', ctx);
fire(interpreter, 'OnStart', ctx);
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 } } });
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 });
});
});
+1 -1
View File
@@ -127,7 +127,7 @@ describe('importStoredImage', () => {
const FILE = `${'a'.repeat(64)}.png`;
const image = { file: FILE, name: '', width: 64, height: 48, bytes: 900 };
/** 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 {
const state = new EditorState();
+17
View File
@@ -22,6 +22,7 @@ import {
VARIABLES,
VARIABLES_PARAM,
} from '../src/core/graph';
import type { GraphInterpreter } from '../src/core/graph/GraphInterpreter';
import type { BehaviorAsset, SceneAsset, VariablesAsset } from '../src/core/model/Asset';
import { findObject } from '../src/core/model/hierarchy';
import { worldPosition } from '../src/core/properties/transform';
@@ -289,6 +290,22 @@ export function runDef(
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. */
export function runNode(
type: string,
+38
View File
@@ -8,6 +8,7 @@ declare(strict_types=1);
*/
require __DIR__ . '/../../api/lib/AccountRules.php';
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/Store.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');
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 ------------------------------------------------------------------------------
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.
*/
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/Store.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.
*/
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/Store.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('two snapshots get two ids', $first['id'] !== $second['id']);
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');
check(
+11
View File
@@ -416,6 +416,15 @@ export async function textures(t) {
(await json('__gm.state.notice')) === 'Added the image to the project.',
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
// 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,
);
}
// 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()') }),
);
// 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
// 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;
@@ -883,7 +901,7 @@ export async function vectorArt(t) {
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 press('a', { ctrl: true });
await typeText('ff0000');
@@ -892,7 +910,7 @@ export async function vectorArt(t) {
const reddened = (await sky())?.paint;
const redMiddle = await tabPixel(128, 128);
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,
JSON.stringify({ stops: reddened?.stops, redMiddle }),
);
+12 -1
View File
@@ -99,12 +99,23 @@ export async function users(t) {
JSON.stringify(userMenu),
);
// 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(
`the API answers ${E2E_USER} 403 on the accounts`,
refused.status === 403 && (await refused.json()).error === 'Administrators only',
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
// 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'),
};
// 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.
await openSnapshots();
await clickLast('Clean start');
@@ -159,8 +179,35 @@ export async function snapshots(t) {
);
await clickLast('Cancel');
// 50f. Deleting the project deletes its snapshots: the route answers 404 afterwards.
await api('DELETE', `projects/${made.id}`);
// 50f. The project goes by the picker. Its question stands in for the picker: Cancel leads back to
// 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`);
check('deleting the project takes its snapshots along', gone.status === 404, String(gone.status));
} finally {
+1
View File
@@ -11,6 +11,7 @@ declare(strict_types=1);
* php tools/make-user.php --list
*/
require __DIR__ . '/../api/lib/AtomicFile.php';
require __DIR__ . '/../api/lib/UserStore.php';
$file = dirname(__DIR__) . '/data/users.json';