Add textures for sprites
Images are uploaded to a content-addressed blob endpoint and stored as data/textures/<sha256>.<ext>; the project only keeps the file name. The sprite points at a texture asset through an ordinary property of the new kind `asset`, so instance overrides, "Apply to container", "Reset to asset", undo and `Set property` work without any extra code. The new kind also replaces the ad-hoc asset picker the graph nodes faked as an enum. Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V. Images wider than 2048px are downscaled client-side and re-encoded to WebP if needed, because nginx caps uploads at 1MB; an image that already fits is uploaded byte for byte so pixel art is never resampled. The sprite colour default changes from red to white, since colour tints the texture multiplicatively and a tint of white shows an image as it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6168df41f8
commit
5a6af74e44
47 files changed
+1995
-110
No files matched your search
@@ -3,4 +3,7 @@ dist/
|
||||
index.html
|
||||
data/projects/*.json
|
||||
data/projects/*.tmp
|
||||
# Uploaded images are user data like the projects: content-addressed, written by the API,
|
||||
# reproducible only by uploading them again.
|
||||
data/textures/
|
||||
.DS_Store
|
||||
@@ -10,7 +10,8 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`) and typed properties. Assets have **no position**. |
|
||||
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `text`, `behavior`, `collider`, `container`, `texture`) and typed properties. Assets have **no position**. |
|
||||
| **Texture** | An uploaded image. Scenes and textures are the two types that cannot be placed: a texture has no position, it is *worn* by a sprite through the ordinary property `texture`. See *Textures* below. |
|
||||
| **Text** | An asset that draws a line of text (`text`, `fontSize`, `color`, `align`) – titles, HUD readouts, button labels. Its content is an ordinary property, so `Set text` (or `Set property`) changes it while playing. |
|
||||
| **Enabled** | Every placeable asset has the switch `enabled` (default on). A disabled object is not drawn, does not collide and its behavior does not run – **and neither does anything parented below it**. Switching one instance off is a normal override, which makes a container a *screen*: switch it on to show it, off to hide it. `Set enabled` / `Is enabled` do it from a graph. |
|
||||
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
|
||||
@@ -160,6 +161,47 @@ fast object cannot jump across a thin wall and end up on the other side. Collide
|
||||
moving object and of anything parented under it travel with it and cannot
|
||||
block it. `Move` without the setting and `Set position` ignore colliders entirely.
|
||||
|
||||
## Textures
|
||||
|
||||
A sprite is a coloured box until it is given a picture. The picture is a **texture asset**,
|
||||
and the sprite points at it through the ordinary property `texture` — which is the whole
|
||||
design: because it is an ordinary property, instance overrides, **Apply to container**,
|
||||
**Reset to asset**, undo and `Set property` work on it without a single line written for them.
|
||||
One coin instance can wear a different picture than its nine siblings, and swapping a texture
|
||||
at runtime is a graph node that already exists.
|
||||
|
||||
**Getting an image in.** Three ways, all ending in the same import: **+ → Texture** in the
|
||||
asset panel, **dropping image files onto the canvas**, or **Ctrl+V** pasting one. Several files
|
||||
at once are imported one after another; a file that fails does not stop the rest.
|
||||
|
||||
**Where it goes.** The image is uploaded as raw bytes to `POST ?r=textures` and stored as
|
||||
`data/textures/{sha256}.{ext}` — the file is named after its own content. That name buys four
|
||||
things: the same picture uploaded twice is one file, a stored file can be cached forever
|
||||
(a name cannot come to mean different bytes), the project document holds a short string
|
||||
instead of megabytes of base64, and deleting a texture asset is undoable for free because
|
||||
nothing on disk is removed. The price is that files outlive the projects that referenced them.
|
||||
|
||||
**Colour tints the picture.** `color` multiplies the texture, so it defaults to **white**
|
||||
(= no tint) and a picture shows its own colours. Tinting is worth having: one white coin
|
||||
picture becomes ten differently coloured coins, per instance, for nothing. Without a texture
|
||||
`color` is simply the fill of the box, as before.
|
||||
|
||||
**`filter`** is the texture's only property: *Smooth* (default) or *Pixel art*, which is the
|
||||
difference between crisp and mush when a small image is scaled up.
|
||||
|
||||
**Nothing ever breaks on a missing picture.** While an image loads, and for a reference that
|
||||
dangles because the texture was deleted, the sprite draws its plain colour — exactly what it
|
||||
looked like before textures existed. So there is no placeholder, no flicker and no error path.
|
||||
Deleting a texture warns how many sprites reference it (a texture is never *placed*, so
|
||||
counting placed instances would report "unused" for a picture a dozen sprites are showing).
|
||||
|
||||
**Limits.** nginx caps request bodies at 1 MB, so the editor shrinks images over 2048 px per
|
||||
side and re-encodes to WebP when they are still too big — but an image that already fits is
|
||||
uploaded byte for byte, because re-encoding pixel art would destroy it. Anything still too
|
||||
large is refused with a clear message instead of a broken upload. The server accepts PNG,
|
||||
JPEG, WebP and GIF, deriving the type from the content rather than from anything the client
|
||||
says, and rejects images over 8192 px per side.
|
||||
|
||||
## Screens without scene switching
|
||||
|
||||
A **screen** (title, win, lose, a pause overlay) is just a container whose `enabled` is
|
||||
@@ -278,13 +320,21 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
|
||||
| GET | `projects/{id}` | `{id,name,revision,updatedAt,project}` |
|
||||
| PUT | `projects/{id}` | `{revision, project}` → `{revision,updatedAt}` or **409** `{revision}` when stale |
|
||||
| DELETE | `projects/{id}` | 204 |
|
||||
| GET | `textures` | `[{file,bytes,updatedAt}]` |
|
||||
| POST | `textures` | **raw image bytes** → `{file,width,height,bytes}` (201) |
|
||||
| DELETE | `textures/{file}` | 204 |
|
||||
|
||||
Uploads send the bytes as the body rather than `multipart/form-data`: no boundary parsing in
|
||||
PHP, and PHP's `upload_max_filesize` (2 MB) never applies — only `post_max_size`. The stored
|
||||
files are served statically from `data/textures/`, so reading an image never runs PHP.
|
||||
|
||||
There is no authentication yet; `data/` is world-readable. Both are phase-2 topics.
|
||||
|
||||
## Keyboard
|
||||
|
||||
`Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo · `Delete` remove selection · `Ctrl+G` create
|
||||
container from the selection · `Ctrl+Enter` play · `Esc` stop · `Tab` cycles fields · number
|
||||
container from the selection · `Ctrl+Enter` play · `Esc` stop · `Ctrl+V` import an image from
|
||||
the clipboard as a texture (ignored while a text field has focus) · `Tab` cycles fields · number
|
||||
fields: drag to scrub (`Shift` ×10, `Alt` ×0.1, snapping to the grid of that increment, so a
|
||||
step-1 field lands on whole numbers), `↑`/`↓` step, always shown with three
|
||||
decimals (`ui/math.ts`, `NUMBER_DECIMALS`) so a fractional value never reads as a whole
|
||||
@@ -294,7 +344,9 @@ object in the selection (hierarchy rows too).
|
||||
|
||||
## Not yet
|
||||
|
||||
Textures on sprites, editing a container's template directly (today changes reach it only
|
||||
via **Apply to container** from an instance), reordering the hierarchy, several scenes in one
|
||||
run (a `Load scene` node – screens are containers today), sound, auth, touch-optimised layout,
|
||||
graph comments/groups.
|
||||
Editing a container's template directly (today changes reach it only via **Apply to
|
||||
container** from an instance), reordering the hierarchy, several scenes in one run (a
|
||||
`Load scene` node – screens are containers today), sound, auth, touch-optimised layout,
|
||||
graph comments/groups. Textures exist but are always stretched to the sprite's `width` ×
|
||||
`height`: no sprite sheets, no animation, no nine-slice, and no "size from texture" button.
|
||||
Unreferenced image files are never collected.
|
||||
+70
-13
@@ -2,7 +2,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Game Maker project API.
|
||||
* Game Maker API.
|
||||
*
|
||||
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
|
||||
* site's nginx configuration does not pass PATH_INFO to PHP.
|
||||
@@ -12,30 +12,32 @@ declare(strict_types=1);
|
||||
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
|
||||
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
|
||||
* DELETE ?r=projects/{id} → 204
|
||||
*
|
||||
* GET ?r=textures → [{file,bytes,updatedAt}]
|
||||
* POST ?r=textures raw image bytes → {file,width,height,bytes}
|
||||
* DELETE ?r=textures/{file} → 204
|
||||
*
|
||||
* Stored images are served straight from `data/textures/` as static files; the API only
|
||||
* writes them.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/lib/Response.php';
|
||||
require __DIR__ . '/lib/Store.php';
|
||||
require __DIR__ . '/lib/TextureStore.php';
|
||||
|
||||
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/** Upload ceiling for a single image. The editor downscales larger pictures before sending. */
|
||||
const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
try {
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
|
||||
Response::error('Request body too large', 413);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeProjects(array $parts, string $method): never
|
||||
{
|
||||
$store = new Store(dirname(__DIR__) . '/data/projects');
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$route = trim((string) ($_GET['r'] ?? ''), '/');
|
||||
$parts = $route === '' ? [] : explode('/', $route);
|
||||
|
||||
if (($parts[0] ?? '') !== 'projects' || count($parts) > 2) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
|
||||
// Collection: /projects
|
||||
if (count($parts) === 1) {
|
||||
@@ -91,6 +93,61 @@ try {
|
||||
}
|
||||
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeTextures(array $parts, string $method): never
|
||||
{
|
||||
$store = new TextureStore(dirname(__DIR__) . '/data/textures');
|
||||
|
||||
// Collection: /textures
|
||||
if (count($parts) === 1) {
|
||||
if ($method === 'GET') {
|
||||
Response::json($store->list());
|
||||
}
|
||||
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);
|
||||
}
|
||||
try {
|
||||
Response::json($store->store($bytes), 201);
|
||||
} catch (InvalidImageException $e) {
|
||||
Response::error($e->getMessage(), 422);
|
||||
}
|
||||
}
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// Item: /textures/{file}
|
||||
if ($method === 'DELETE') {
|
||||
if (!$store->delete($parts[1])) {
|
||||
Response::error('Texture not found', 404);
|
||||
}
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
try {
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
|
||||
Response::error('Request body too large', 413);
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$route = trim((string) ($_GET['r'] ?? ''), '/');
|
||||
$parts = $route === '' ? [] : explode('/', $route);
|
||||
|
||||
if (count($parts) < 1 || count($parts) > 2) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
|
||||
match ($parts[0]) {
|
||||
'projects' => routeProjects($parts, $method),
|
||||
'textures' => routeTextures($parts, $method),
|
||||
default => Response::error('Not found', 404),
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
error_log('[game-maker api] ' . $e->getMessage());
|
||||
Response::error('Internal server error', 500);
|
||||
|
||||
+17
-5
@@ -28,14 +28,26 @@ final class Response
|
||||
/** Decoded JSON request body or an error response when it is not an object. */
|
||||
public static function body(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
self::error('Request body must be JSON', 400);
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
$data = json_decode(self::rawBody(), true);
|
||||
if (!is_array($data)) {
|
||||
self::error('Request body must be a JSON object', 400);
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request body as bytes (uploads send images, not JSON).
|
||||
*
|
||||
* An empty body is answered here rather than by every caller, because PHP also reports
|
||||
* a body that exceeded `post_max_size` as empty – a truncated upload must not look like
|
||||
* a malformed one.
|
||||
*/
|
||||
public static function rawBody(): string
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
self::error('Request body is empty or exceeds the upload limit', 400);
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Content-addressed image store: `data/textures/{sha256}.{ext}`.
|
||||
*
|
||||
* 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; `tools/gc-textures.mjs`
|
||||
* collects the unreferenced ones.
|
||||
*/
|
||||
final class TextureStore
|
||||
{
|
||||
/** Accepted formats. SVG is deliberately absent: it can carry script. */
|
||||
private const EXTENSIONS = [
|
||||
IMAGETYPE_PNG => 'png',
|
||||
IMAGETYPE_JPEG => 'jpg',
|
||||
IMAGETYPE_WEBP => 'webp',
|
||||
IMAGETYPE_GIF => 'gif',
|
||||
];
|
||||
|
||||
private const MIME_TYPES = [
|
||||
'image/png' => IMAGETYPE_PNG,
|
||||
'image/jpeg' => IMAGETYPE_JPEG,
|
||||
'image/webp' => IMAGETYPE_WEBP,
|
||||
'image/gif' => IMAGETYPE_GIF,
|
||||
];
|
||||
|
||||
/**
|
||||
* Refuses images no display can use anyway. Dimensions come from the header, so a
|
||||
* "decompression bomb" (tiny file, gigantic canvas) is rejected before anything
|
||||
* allocates memory for it.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the raw bytes of an image and returns its record.
|
||||
*
|
||||
* The extension is derived from the sniffed content, never from anything the client
|
||||
* says, so no request can choose the name or the type of the file it creates.
|
||||
*
|
||||
* @return array{file:string,width:int,height:int,bytes:int}
|
||||
* @throws InvalidImageException when the bytes are not a supported image
|
||||
*/
|
||||
public function store(string $bytes): array
|
||||
{
|
||||
$size = @getimagesizefromstring($bytes);
|
||||
if ($size === false) {
|
||||
throw new InvalidImageException('Not a readable image');
|
||||
}
|
||||
[$width, $height, $type] = $size;
|
||||
$extension = self::EXTENSIONS[$type] ?? '';
|
||||
if ($extension === '') {
|
||||
throw new InvalidImageException('Unsupported image format – use PNG, JPEG, WebP or GIF');
|
||||
}
|
||||
|
||||
// Two independent sniffs must agree; a file that reads as one type by header and as
|
||||
// another by content is a polyglot, and we would rather not find out what it is.
|
||||
$mime = (new finfo(FILEINFO_MIME_TYPE))->buffer($bytes);
|
||||
if ((self::MIME_TYPES[$mime] ?? 0) !== $type) {
|
||||
throw new InvalidImageException('Image content does not match its format');
|
||||
}
|
||||
|
||||
if ($width < 1 || $height < 1 || $width > self::MAX_DIMENSION || $height > self::MAX_DIMENSION) {
|
||||
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);
|
||||
}
|
||||
|
||||
return ['file' => $file, 'width' => $width, 'height' => $height, 'bytes' => strlen($bytes)];
|
||||
}
|
||||
|
||||
/** Every stored file, newest first – the input of the garbage collector. */
|
||||
/** @return list<array{file:string,bytes:int,updatedAt:string}> */
|
||||
public function list(): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (glob($this->dir . '/*.*') ?: [] as $path) {
|
||||
$file = basename($path);
|
||||
if (!self::isValidName($file)) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'file' => $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;
|
||||
return is_file($path) && unlink($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by TextureStore::store when the uploaded bytes are not a usable image. */
|
||||
final class InvalidImageException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -1,21 +1,28 @@
|
||||
import { createEmptyGraph } from '../graph/Graph';
|
||||
import { newId } from '../ids';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { Asset, TextureAsset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { objectHolders, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile } from '../model/texture';
|
||||
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
|
||||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requirePropDef } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
|
||||
/** A new asset of the given type with default properties (and empty content for scenes, behaviors, containers). */
|
||||
/**
|
||||
* A new asset of the given type with default properties (and empty content for scenes,
|
||||
* behaviors, containers). A texture starts without a picture; the upload fills it in, which
|
||||
* is why `CreateTextureCommand` exists rather than a plain create followed by an edit.
|
||||
*/
|
||||
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
const base = { id, name, props: defaultsFor(type) };
|
||||
switch (type) {
|
||||
case 'texture':
|
||||
return { ...base, type: 'texture', file: '', width: 0, height: 0 };
|
||||
case 'scene':
|
||||
return { ...base, type: 'scene', objects: [] };
|
||||
case 'camera':
|
||||
@@ -56,6 +63,40 @@ export class CreateAssetCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** The upload result a texture asset is built from (see `TextureStore.upload`). */
|
||||
export interface TextureImage {
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a texture asset for an image that is already stored on the server. Uploading is I/O and
|
||||
* therefore happens before the command: this one only records the result, so undo and redo of
|
||||
* an import cost nothing and never touch the network.
|
||||
*/
|
||||
export class CreateTextureCommand implements Command {
|
||||
readonly label: string;
|
||||
readonly asset: TextureAsset;
|
||||
|
||||
constructor(name: string, image: TextureImage) {
|
||||
this.asset = { ...buildAsset('texture', name), type: 'texture', ...image };
|
||||
this.label = `Import texture "${name}"`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
// The name is the hash the server derived from the bytes; anything else never was uploaded
|
||||
// and would leave a texture that silently draws nothing.
|
||||
if (!isTextureFile(this.asset.file))
|
||||
throw new CommandError(`"${this.asset.file}" is not a stored image file`);
|
||||
project.assets.push(this.asset);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
project.assets = removeById(project.assets, this.asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an asset and, as a cascade, every scene object referencing it – together with the
|
||||
* objects parented below those (a subtree is removed as a whole). Container templates
|
||||
|
||||
@@ -36,14 +36,11 @@ export const variableParam: PropertyDef = {
|
||||
options: [],
|
||||
};
|
||||
|
||||
/** Options are empty on purpose: the graph inspector fills in the project's placeable assets. */
|
||||
export const assetParam: PropertyDef = {
|
||||
key: 'asset',
|
||||
label: 'Asset',
|
||||
kind: 'enum',
|
||||
default: '',
|
||||
options: [],
|
||||
};
|
||||
/**
|
||||
* A reference to an asset the node acts on (spawns, compares against). No `assetTypes`: any
|
||||
* placeable asset is a valid answer, and restricting them would be guessing for the user.
|
||||
*/
|
||||
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
|
||||
|
||||
// --- node factories --------------------------------------------------------------------------
|
||||
|
||||
|
||||
+33
-1
@@ -65,9 +65,41 @@ export interface ContainerAsset extends AssetBase {
|
||||
objects: SceneObject[];
|
||||
}
|
||||
|
||||
/**
|
||||
* An uploaded image. `file` names it in `data/textures/` and is content-addressed
|
||||
* (`{sha256}.{ext}`), so the same picture uploaded twice is one file and a stored file can be
|
||||
* cached forever – its name cannot ever come to mean different bytes.
|
||||
*
|
||||
* Like a scene's objects and a behavior's graph, this is intrinsic data rather than a
|
||||
* property: nothing about it is editable or overridable per instance. What *is* a property
|
||||
* is the reference to it – sprites carry `texture` (see PropertySchema), which is how
|
||||
* instance overrides, "Apply to container" and `Set property` work on it for free.
|
||||
*
|
||||
* `width`/`height` are the pixel size as uploaded, kept so a sprite can be sized to its
|
||||
* picture without waiting for the image to load. An empty `file` means the upload failed or
|
||||
* has not happened yet; such a texture draws as nothing.
|
||||
*/
|
||||
export interface TextureAsset extends AssetBase {
|
||||
type: 'texture';
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type Asset =
|
||||
SpriteAsset | CameraAsset | TextAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
|
||||
| SpriteAsset
|
||||
| CameraAsset
|
||||
| TextAsset
|
||||
| SceneAsset
|
||||
| BehaviorAsset
|
||||
| ColliderAsset
|
||||
| ContainerAsset
|
||||
| TextureAsset;
|
||||
|
||||
export function isSceneAsset(asset: Asset): asset is SceneAsset {
|
||||
return asset.type === 'scene';
|
||||
}
|
||||
|
||||
export function isTextureAsset(asset: Asset): asset is TextureAsset {
|
||||
return asset.type === 'texture';
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
|
||||
/** All asset types. Scenes and textures are assets too, but cannot be placed in scenes. */
|
||||
export const ASSET_TYPES = [
|
||||
'scene',
|
||||
'camera',
|
||||
@@ -7,6 +7,7 @@ export const ASSET_TYPES = [
|
||||
'behavior',
|
||||
'collider',
|
||||
'container',
|
||||
'texture',
|
||||
] as const;
|
||||
|
||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||
@@ -20,13 +21,18 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
behavior: 'Behavior',
|
||||
collider: 'Collider',
|
||||
container: 'Container',
|
||||
texture: 'Texture',
|
||||
};
|
||||
|
||||
/** Types that can be placed into a scene (everything except scenes themselves). */
|
||||
export type PlaceableAssetType = Exclude<AssetType, 'scene'>;
|
||||
/**
|
||||
* Types that can be placed into a scene. A texture is not one of them: it is picture data,
|
||||
* not something with a position – a sprite wears it (see the `texture` property). Dropping a
|
||||
* texture into the scene therefore creates a sprite, it does not place the texture itself.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<AssetType, 'scene' | 'texture'>;
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
return type !== 'scene';
|
||||
return type !== 'scene' && type !== 'texture';
|
||||
}
|
||||
|
||||
export function isAssetType(value: unknown): value is AssetType {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { schemaForAsset } from '../properties/PropertySchema';
|
||||
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
@@ -63,3 +64,40 @@ export function objectsWhere(
|
||||
export function findUsages(project: Project, assetId: string): SceneObjectRef[] {
|
||||
return objectsWhere(project, (o) => o.assetId === assetId);
|
||||
}
|
||||
|
||||
/** One place where an `asset`-kind property points at an asset. */
|
||||
export type PropertyUsage =
|
||||
| { on: 'asset'; asset: Asset; key: string }
|
||||
| { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
|
||||
|
||||
/**
|
||||
* Every property that references `assetId`, on assets and on the overrides of placed objects.
|
||||
*
|
||||
* This is the other half of "where is this asset used": `findUsages` finds assets that were
|
||||
* *placed*, which is meaningless for a texture – a texture is never placed, it is worn by
|
||||
* sprites through their `texture` property. Deleting one therefore has to count both, or it
|
||||
* reports "0 uses" for a picture that ten sprites are showing.
|
||||
*
|
||||
* A reference is deliberately left dangling rather than cleared when its asset goes: a
|
||||
* dangling one resolves to "no picture" everywhere it is read, and it comes back by itself
|
||||
* when the delete is undone.
|
||||
*/
|
||||
export function findPropertyUsages(project: Project, assetId: string): PropertyUsage[] {
|
||||
if (!assetId) return [];
|
||||
const result: PropertyUsage[] = [];
|
||||
const assetKeys = new Map<string, string[]>();
|
||||
for (const asset of project.assets) {
|
||||
const keys = schemaForAsset(asset)
|
||||
.filter((def) => def.kind === 'asset')
|
||||
.map((def) => def.key);
|
||||
assetKeys.set(asset.id, keys);
|
||||
for (const key of keys) if (asset.props[key] === assetId) result.push({ on: 'asset', asset, key });
|
||||
}
|
||||
for (const holder of objectHolders(project)) {
|
||||
for (const object of holder.objects) {
|
||||
for (const key of assetKeys.get(object.assetId) ?? [])
|
||||
if (object.overrides[key] === assetId) result.push({ on: 'object', holder, object, key });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { PropertyBag } from './Properties';
|
||||
import { findAsset, type Project } from './Project';
|
||||
|
||||
/**
|
||||
* A stored image file name: `{sha256}.{png|jpg|webp|gif}`. Mirrors `TextureStore::isValidName`
|
||||
* on the server – the name is derived from the bytes there, so anything else in a document
|
||||
* was hand-written and must not be turned into a request.
|
||||
*/
|
||||
const FILE_PATTERN = /^[a-f0-9]{64}\.(png|jpg|webp|gif)$/;
|
||||
|
||||
export function isTextureFile(name: string): boolean {
|
||||
return FILE_PATTERN.test(name);
|
||||
}
|
||||
|
||||
/** A picture ready to be drawn: which file, and whether to smooth it when scaled. */
|
||||
export interface TextureRef {
|
||||
file: string;
|
||||
/** False for pixel art, where interpolation is the difference between crisp and mush. */
|
||||
smooth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture a bag of properties points at, or null when it points at nothing usable –
|
||||
* no reference, a dangling one (the texture was deleted), or one whose upload never
|
||||
* finished. Callers draw their plain colour in that case, which is what a sprite looked
|
||||
* like before textures existed.
|
||||
*/
|
||||
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
|
||||
const id = String(props.texture ?? '');
|
||||
if (!id) return null;
|
||||
const asset = findAsset(project, id);
|
||||
if (asset?.type !== 'texture' || !isTextureFile(asset.file)) return null;
|
||||
return { file: asset.file, smooth: String(asset.props.filter ?? 'linear') !== 'nearest' };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
|
||||
/**
|
||||
@@ -14,7 +14,16 @@ export type PropertyDef =
|
||||
/** One of a fixed set of string values, shown as a dropdown. */
|
||||
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
|
||||
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
|
||||
| { key: string; label: string; kind: 'key'; default: string };
|
||||
| { key: string; label: string; kind: 'key'; default: string }
|
||||
/**
|
||||
* A reference to another asset, stored as its id (`''` = none) and picked from a
|
||||
* searchable dropdown. `assetTypes` limits what the picker offers; without it every
|
||||
* placeable asset is offered, which is what graph nodes acting on an object want.
|
||||
*
|
||||
* The id is not validated on load – a reference can dangle, exactly like a graph node's
|
||||
* asset parameter, and the places that read one have to cope with that.
|
||||
*/
|
||||
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] };
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
|
||||
@@ -50,7 +59,31 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
sprite: [
|
||||
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
|
||||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xe0553f },
|
||||
/*
|
||||
* The colour of the sprite: the fill of the box while it has no picture, and a multiplier
|
||||
* on the picture once it has one. White therefore means "no tint" – which is why it is the
|
||||
* default. A coloured default would silently stain every texture the moment it is applied,
|
||||
* and the first thing anyone would do is set it back to white.
|
||||
*
|
||||
* Tinting is worth keeping rather than suppressing for textured sprites: it is how one
|
||||
* white coin picture becomes ten differently coloured coins, per instance, for free.
|
||||
*/
|
||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
|
||||
{ key: 'texture', label: 'Texture', kind: 'asset', default: '', assetTypes: ['texture'] },
|
||||
],
|
||||
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property:
|
||||
// only how it is sampled is worth deciding, and that decision is the whole schema.
|
||||
texture: [
|
||||
{
|
||||
key: 'filter',
|
||||
label: 'Filter',
|
||||
kind: 'enum',
|
||||
default: 'linear',
|
||||
options: [
|
||||
{ value: 'linear', label: 'Smooth' },
|
||||
{ value: 'nearest', label: 'Pixel art' },
|
||||
],
|
||||
},
|
||||
],
|
||||
// Behaviors declare their own properties (variables) per asset – see `schemaForAsset`.
|
||||
behavior: [],
|
||||
@@ -91,11 +124,15 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Type schema with the common properties in front – the effective schema of an asset type. */
|
||||
/**
|
||||
* Type schema with the common properties in front – the effective schema of an asset type.
|
||||
* Only placeable types get them: `enabled` switches off a placed object, and a scene or a
|
||||
* texture is never placed, so the switch would have nothing to act on.
|
||||
*/
|
||||
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
|
||||
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
|
||||
type,
|
||||
type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]],
|
||||
isPlaceableType(type) ? [...COMMON_PROPS, ...ASSET_SCHEMAS[type]] : ASSET_SCHEMAS[type],
|
||||
]),
|
||||
) as Record<AssetType, PropertySchema>;
|
||||
|
||||
@@ -153,8 +190,11 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
||||
}
|
||||
case 'boolean':
|
||||
return Boolean(value);
|
||||
// An asset reference is an id like the other two are plain strings; whether it still
|
||||
// resolves is not decidable from a single property definition.
|
||||
case 'text':
|
||||
case 'key':
|
||||
case 'asset':
|
||||
return String(value);
|
||||
case 'enum': {
|
||||
const s = String(value);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
|
||||
/** Shown for an `asset` property that references nothing. */
|
||||
export const NO_ASSET_LABEL = 'None';
|
||||
|
||||
/**
|
||||
* How an `asset` reference reads wherever it is displayed: the referenced asset's name, "None"
|
||||
* for the empty id, and `<id>?` when the id no longer resolves.
|
||||
*
|
||||
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
|
||||
* only trace left of what was meant, and blanking it would make a deleted asset look like a
|
||||
* field that was never filled in. `project` may be null while a view has no project yet.
|
||||
*/
|
||||
export function assetRefLabel(project: Project | null, id: string): string {
|
||||
if (id === '') return NO_ASSET_LABEL;
|
||||
return (project ? findAsset(project, id) : undefined)?.name ?? `${id}?`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The assets an `asset` property offers, in menu order. `assetTypes` restricts them to those
|
||||
* types; without it every *placeable* asset is offered, which is what a graph node acting on
|
||||
* an object means by "asset". Sorted by name (then type) so the order does not depend on when
|
||||
* the assets happened to be created.
|
||||
*/
|
||||
export function assetRefChoices(project: Project, assetTypes?: readonly AssetType[]): Asset[] {
|
||||
const allowed = (asset: Asset): boolean =>
|
||||
assetTypes ? assetTypes.includes(asset.type) : isPlaceableType(asset.type);
|
||||
return project.assets
|
||||
.filter(allowed)
|
||||
.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
|
||||
}
|
||||
|
||||
/** Menu entry for one candidate – the type disambiguates assets that share a name. */
|
||||
export function assetChoiceLabel(asset: Asset): string {
|
||||
return `${asset.name} (${asset.type})`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Project } from '../model/Project';
|
||||
import { statusMessage } from './http';
|
||||
import {
|
||||
type LoadedProject,
|
||||
type ProjectStore,
|
||||
@@ -62,14 +63,20 @@ export class HttpProjectStore implements ProjectStore {
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
|
||||
// Not every failure comes from our API: nginx rejects an oversized body itself and
|
||||
// answers with an HTML page. Such a response is explained by its status, and saying
|
||||
// "invalid JSON" instead of "too large" would send the user looking in the wrong place.
|
||||
// A *successful* response that is not JSON stays a defect and keeps its old message.
|
||||
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
|
||||
data = null;
|
||||
}
|
||||
if (response.status === 409) {
|
||||
const rev = (data as { revision?: number } | null)?.revision ?? -1;
|
||||
throw new StaleRevisionError(rev);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = (data as { error?: string } | null)?.error ?? response.statusText;
|
||||
const message =
|
||||
(data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText);
|
||||
throw new Error(`API ${method} ${route}: ${message} (${response.status})`);
|
||||
}
|
||||
return data as T;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { statusMessage } from './http';
|
||||
import type { StoredTexture, TextureStore, UploadedTexture } from './TextureStore';
|
||||
|
||||
/**
|
||||
* Talks to the texture routes of `api/index.php`. Routing goes through the `r` query parameter
|
||||
* (`?r=textures/{file}`) for the same reason as `HttpProjectStore`: nginx does not pass
|
||||
* PATH_INFO to PHP.
|
||||
*/
|
||||
export class HttpTextureStore implements TextureStore {
|
||||
constructor(private readonly endpoint: string) {}
|
||||
|
||||
async upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedTexture> {
|
||||
return this.request<UploadedTexture>('POST', 'textures', { bytes, type });
|
||||
}
|
||||
|
||||
async list(): Promise<StoredTexture[]> {
|
||||
return this.request<StoredTexture[]>('GET', 'textures');
|
||||
}
|
||||
|
||||
async delete(file: string): Promise<void> {
|
||||
await this.request<void>('DELETE', `textures/${encodeURIComponent(file)}`);
|
||||
}
|
||||
|
||||
/** One round trip; every failure becomes an `Error` carrying the server's message. */
|
||||
private async request<T>(
|
||||
method: string,
|
||||
route: string,
|
||||
payload?: { bytes: Blob | ArrayBuffer; type: string },
|
||||
): Promise<T> {
|
||||
const url = `${this.endpoint}?r=${encodeURIComponent(route)}`;
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: payload ? { 'Content-Type': payload.type } : {},
|
||||
body: payload?.bytes,
|
||||
});
|
||||
if (response.status === 204) return undefined as T;
|
||||
const text = await response.text();
|
||||
let data: unknown;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
// An upload is exactly the request nginx refuses before PHP sees it, and its answer is
|
||||
// an HTML page. Explain such a failure from the status; a successful non-JSON body is a defect.
|
||||
if (response.ok) throw new Error(`API ${method} ${route}: invalid JSON (${response.status})`);
|
||||
data = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(data as { error?: string } | null)?.error ?? statusMessage(response.status, response.statusText);
|
||||
throw new Error(`API ${method} ${route}: ${message} (${response.status})`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** What the server made of an uploaded image; the payload a texture asset is built from. */
|
||||
export interface UploadedTexture {
|
||||
/** Content-addressed name in `data/textures/`: `{sha256}.{png|jpg|webp|gif}` (see `isTextureFile`). */
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** One stored image as the server lists it. */
|
||||
export interface StoredTexture {
|
||||
file: string;
|
||||
bytes: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload boundary for images, the counterpart of `ProjectStore`. The editor only talks to this
|
||||
* interface. An upload is raw bytes rather than a multipart form: name, size and format are
|
||||
* derived from the image itself on the server, so there is nothing else to send.
|
||||
*/
|
||||
export interface TextureStore {
|
||||
/**
|
||||
* Stores an image and returns its file name and pixel size. `type` only fills the
|
||||
* Content-Type header – the server trusts the bytes, not the label – and uploading the same
|
||||
* picture twice yields the same file, because the name is the hash of its content.
|
||||
*/
|
||||
upload(bytes: Blob | ArrayBuffer, type: string): Promise<UploadedTexture>;
|
||||
list(): Promise<StoredTexture[]>;
|
||||
delete(file: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* The bit of HTTP handling both stores share. It exists because a failed response does not
|
||||
* always come from our API: nginx caps request bodies at 1 MB and answers a larger one with
|
||||
* its own HTML page, so there is no `{error}` object to read the reason from.
|
||||
*/
|
||||
|
||||
/** Reasons for the statuses that can arrive without an API body of their own. */
|
||||
const STATUS_MESSAGES: Record<number, string> = {
|
||||
400: 'Bad request',
|
||||
403: 'Forbidden',
|
||||
404: 'Not found',
|
||||
413: 'Request too large',
|
||||
500: 'Server error',
|
||||
502: 'Server unavailable',
|
||||
503: 'Server unavailable',
|
||||
504: 'Server timed out',
|
||||
};
|
||||
|
||||
/** What to tell the user about a failed response whose body is not our JSON – the status is all there is. */
|
||||
export function statusMessage(status: number, statusText: string): string {
|
||||
return STATUS_MESSAGES[status] ?? (statusText || `HTTP ${status}`);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { isAssetType } from '../model/AssetTypes';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile } from '../model/texture';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import {
|
||||
coerceValue,
|
||||
@@ -62,6 +63,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
variables: variables.get(id) ?? [],
|
||||
graph: normalizeGraph(a.graph),
|
||||
});
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a) });
|
||||
} else {
|
||||
assets.push({ id, type, name, props });
|
||||
}
|
||||
@@ -181,6 +184,18 @@ function normalizeGraph(raw: unknown): Graph {
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture of a texture asset. A file name that is not the server's content-addressed form
|
||||
* is dropped rather than kept: it can only have been hand-written, and a request built from it
|
||||
* would 404. Such a texture loads as "no picture", which draws as the sprite's plain colour.
|
||||
*/
|
||||
function normalizeTexture(raw: Record<string, unknown>): { file: string; width: number; height: number } {
|
||||
const file = typeof raw.file === 'string' && isTextureFile(raw.file) ? raw.file : '';
|
||||
const size = (value: unknown): number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
||||
return { file, width: size(raw.width), height: size(raw.height) };
|
||||
}
|
||||
|
||||
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
|
||||
const r = asRecord(raw);
|
||||
const bag: PropertyBag = {};
|
||||
|
||||
+17
-2
@@ -1,16 +1,31 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import { HttpProjectStore } from '../core/store/HttpProjectStore';
|
||||
import { HttpTextureStore } from '../core/store/HttpTextureStore';
|
||||
import type { VStack } from '../ui';
|
||||
import { textureLibrary } from '../viewport/TextureLibrary';
|
||||
import { installDebugHook } from './debugHook';
|
||||
import { Editor } from './Editor';
|
||||
import { EditorState } from './state';
|
||||
|
||||
const API_ENDPOINT = '/game-maker/api/index.php';
|
||||
|
||||
/** Wires state, store and the editor composition together and opens the first project. */
|
||||
/**
|
||||
* Where uploaded images live. The API writes them; the browser reads them straight off disk,
|
||||
* with no PHP in the path – they are static files whose names are their content hashes.
|
||||
*/
|
||||
const TEXTURE_BASE = '/game-maker/data/textures';
|
||||
|
||||
/** Wires state, stores and the editor composition together and opens the first project. */
|
||||
export async function startEditor(app: Application, root: VStack): Promise<void> {
|
||||
const state = new EditorState();
|
||||
const editor = new Editor(app, root, state, new HttpProjectStore(API_ENDPOINT));
|
||||
textureLibrary.configure(TEXTURE_BASE);
|
||||
const editor = new Editor(
|
||||
app,
|
||||
root,
|
||||
state,
|
||||
new HttpProjectStore(API_ENDPOINT),
|
||||
new HttpTextureStore(API_ENDPOINT),
|
||||
);
|
||||
installDebugHook(app, state, editor);
|
||||
await editor.boot();
|
||||
}
|
||||
+13
-2
@@ -1,5 +1,6 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
|
||||
import { GameViewport } from '../viewport/GameViewport';
|
||||
import { SceneViewport } from '../viewport/SceneViewport';
|
||||
@@ -13,6 +14,7 @@ import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
|
||||
import { ProjectSession } from './ProjectSession';
|
||||
import { installShortcuts } from './shortcuts';
|
||||
import type { EditorState } from './state';
|
||||
import { installTextureImport } from './textureImport';
|
||||
|
||||
export type CentreTab = 'scene' | 'graph' | 'game';
|
||||
|
||||
@@ -51,7 +53,13 @@ export class Editor implements PlayHost {
|
||||
private leftWidth = 240;
|
||||
private rightWidth = 300;
|
||||
|
||||
constructor(app: Application, root: VStack, state: EditorState, store: ProjectStore) {
|
||||
constructor(
|
||||
app: Application,
|
||||
root: VStack,
|
||||
state: EditorState,
|
||||
store: ProjectStore,
|
||||
textures: TextureStore,
|
||||
) {
|
||||
this.viewport = new SceneViewport(state);
|
||||
this.gameView = new GameViewport(state);
|
||||
this.graphEditor = new GraphEditor(state);
|
||||
@@ -75,7 +83,10 @@ export class Editor implements PlayHost {
|
||||
|
||||
this.workspace = new HStack();
|
||||
this.leftColumn = new VStack({ gap: 1 });
|
||||
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
|
||||
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter(), textures), 1);
|
||||
// Dropping an image on the canvas or pasting one imports it, wherever the pointer is –
|
||||
// the same import the asset panel's "Texture" entry runs.
|
||||
installTextureImport(app.canvas, { state, textures });
|
||||
this.leftColumn.add(this.hierarchy, 1);
|
||||
|
||||
// Centre: a tab strip (Scene | Game | Graph) above the scene view, the game view or the graph editor.
|
||||
|
||||
+11
-6
@@ -6,7 +6,7 @@ import { DeleteAssetCommand } from '../core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../core/commands/colliderCommands';
|
||||
import { CreateContainerCommand } from '../core/commands/containerCommands';
|
||||
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { findAsset, findUsages } from '../core/model/Project';
|
||||
import { findAsset, findPropertyUsages, findUsages } from '../core/model/Project';
|
||||
import { Dialog } from '../ui';
|
||||
import { type EditorState, objectSelection } from './state';
|
||||
|
||||
@@ -40,14 +40,19 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset) return;
|
||||
const usages = findUsages(state.project, assetId).length;
|
||||
// A texture is never placed – it is worn through a property – so counting placed instances
|
||||
// would report "unused" for a picture a dozen sprites are showing.
|
||||
const references = findPropertyUsages(state.project, assetId).length;
|
||||
const detail =
|
||||
asset.type === 'scene'
|
||||
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
||||
: usages === 0
|
||||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||||
: asset.type === 'container'
|
||||
? `${usages} placed container(s) are removed together with their content.`
|
||||
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
||||
: references > 0
|
||||
? `${references} sprite(s) reference it and fall back to their plain colour.`
|
||||
: usages === 0
|
||||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||||
: asset.type === 'container'
|
||||
? `${usages} placed container(s) are removed together with their content.`
|
||||
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
||||
new Dialog(`Delete "${asset.name}"?`, detail, [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
|
||||
|
||||
+17
-1
@@ -1,4 +1,4 @@
|
||||
import { type Application, type Container, Text } from 'pixi.js';
|
||||
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
|
||||
import { ui, Widget } from '../ui';
|
||||
import type { Editor } from './Editor';
|
||||
import type { EditorState } from './state';
|
||||
@@ -70,6 +70,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
drawOrder: () => viewport.drawOrder(),
|
||||
/** Screen position of an object in the Scene tab (its drawn position through the view). */
|
||||
objectScreen: (id: string) => viewport.objectScreen(id),
|
||||
/** The rectangle it covers on screen – for sampling colours inside it with `pixelAt`. */
|
||||
objectRect: (id: string) => viewport.objectRect(id),
|
||||
/** The same for the Game tab (through the camera), so tests can click what the player sees. */
|
||||
gameScreen: (id: string) => gameView.objectScreen(id),
|
||||
graph: {
|
||||
@@ -85,6 +87,20 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
hierarchyChevron: (id: string) => hierarchy.chevronPosition(id),
|
||||
hierarchyExpandAll: () => hierarchy.expandAll(),
|
||||
hierarchyCollapseAll: () => hierarchy.collapseAll(),
|
||||
/**
|
||||
* The colour actually on screen at a point, as `#rrggbb`. Everything else here reports what
|
||||
* the model *says*; this is the only way a check can assert what was really drawn – which is
|
||||
* the whole question for a texture, since a textured and an untextured sprite are identical
|
||||
* in the model apart from one property.
|
||||
*/
|
||||
pixelAt: (x: number, y: number) => {
|
||||
const { pixels } = app.renderer.extract.pixels({
|
||||
target: app.stage,
|
||||
frame: new Rectangle(x, y, 1, 1),
|
||||
});
|
||||
const hex = (value: number | undefined) => (value ?? 0).toString(16).padStart(2, '0');
|
||||
return `#${hex(pixels[0])}${hex(pixels[1])}${hex(pixels[2])}`;
|
||||
},
|
||||
fps: () => editor.fps,
|
||||
tab: () => editor.tab,
|
||||
showTab: (tab: 'scene' | 'graph' | 'game') => editor.showTab(tab),
|
||||
|
||||
@@ -13,8 +13,9 @@ import {
|
||||
type Value,
|
||||
} from '../../core/graph';
|
||||
import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { assetRefLabel } from '../../core/properties/assetRef';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { formatNumber, Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
|
||||
import { keyLabel } from '../inspector/fieldFactory';
|
||||
@@ -251,10 +252,7 @@ function formatParam(
|
||||
): string {
|
||||
if (value === undefined || value === '') return '—';
|
||||
if (param.kind === 'key') return keyLabel(String(value));
|
||||
if (param.kind === 'enum' && param.key === 'asset') {
|
||||
const target = project ? findAsset(project, String(value)) : undefined;
|
||||
return target ? target.name : `${value}?`;
|
||||
}
|
||||
if (param.kind === 'asset') return assetRefLabel(project, String(value));
|
||||
if (
|
||||
param.kind === 'enum' &&
|
||||
param.key === 'variable' &&
|
||||
|
||||
@@ -40,6 +40,8 @@ export class Inspector extends Panel {
|
||||
private emptyHost(): InspectorHost {
|
||||
return {
|
||||
state: this.state,
|
||||
// Read through the state on every call: load and play mode replace the project object.
|
||||
project: () => this.state.project,
|
||||
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
|
||||
@@ -29,8 +29,10 @@ const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
|
||||
boolean: 'Boolean',
|
||||
text: 'Text',
|
||||
};
|
||||
// Keyed by the wide `PropertyDef['kind']` on purpose: `variables` is a `PropertyDef[]`, so a
|
||||
// document could carry a kind that is not a VariableKind – that renders as '?' rather than blank.
|
||||
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`. Only the
|
||||
// three VariableKinds ever reach it – the loader clamps any other kind (normalizeVariables) and
|
||||
// the "+ Variable" menu offers no other – so the '?' fallback is a safety net, not a case to
|
||||
// fill in: entries for kinds a variable cannot have (asset, color, enum, key) would be dead.
|
||||
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
|
||||
number: '#',
|
||||
boolean: '✓',
|
||||
@@ -87,6 +89,7 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
|
||||
const values = resolveAssetProps(asset);
|
||||
for (const def of schema) {
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
def,
|
||||
values[def.key] ?? def.default,
|
||||
assetHandlers(host, asset.id, def),
|
||||
@@ -153,7 +156,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const field = createField(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
|
||||
const field = createField(host, def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Spacer(), 1);
|
||||
row.add(
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import {
|
||||
assetChoiceLabel,
|
||||
assetRefChoices,
|
||||
assetRefLabel,
|
||||
NO_ASSET_LABEL,
|
||||
} from '../../core/properties/assetRef';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import {
|
||||
Button,
|
||||
@@ -8,6 +15,7 @@ import {
|
||||
HStack,
|
||||
Label,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
NumberField,
|
||||
Spacer,
|
||||
TextField,
|
||||
@@ -16,6 +24,17 @@ import {
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
|
||||
/**
|
||||
* What a field needs beyond its own value: the project, to resolve an asset reference (an id)
|
||||
* to a name and to list what the picker may offer.
|
||||
*
|
||||
* A getter rather than the project itself – loading a document and entering play mode replace
|
||||
* the object, and a row that outlives either must not keep answering from the old one.
|
||||
*/
|
||||
export interface FieldContext {
|
||||
project(): Project;
|
||||
}
|
||||
|
||||
export interface FieldHandlers {
|
||||
/** Live value while dragging/typing – apply as a preview, no undo entry. */
|
||||
onInput: (value: PropertyValue) => void;
|
||||
@@ -69,6 +88,7 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
|
||||
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
|
||||
*/
|
||||
export function createFieldRow(
|
||||
ctx: FieldContext,
|
||||
def: PropertyDef,
|
||||
value: PropertyValue,
|
||||
handlers: FieldHandlers,
|
||||
@@ -76,7 +96,7 @@ export function createFieldRow(
|
||||
apply?: FieldActions,
|
||||
): FieldRow {
|
||||
const { row, label } = labelledRow(def.label);
|
||||
const field = createField(def, value, handlers);
|
||||
const field = createField(ctx, def, value, handlers);
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Spacer(), 1);
|
||||
|
||||
@@ -135,7 +155,12 @@ interface Field {
|
||||
}
|
||||
|
||||
/** The bare widget for a property definition (no label, no reset). */
|
||||
export function createField(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers): Field {
|
||||
export function createField(
|
||||
ctx: FieldContext,
|
||||
def: PropertyDef,
|
||||
value: PropertyValue,
|
||||
handlers: FieldHandlers,
|
||||
): Field {
|
||||
switch (def.kind) {
|
||||
case 'number': {
|
||||
const field = new NumberField(Number(value), {
|
||||
@@ -175,6 +200,34 @@ export function createField(def: PropertyDef, value: PropertyValue, handlers: Fi
|
||||
const button = new KeyCaptureButton(String(value), (key) => handlers.onChange(key));
|
||||
return { widget: button, grow: true, setValue: (v) => button.setKey(String(v)) };
|
||||
}
|
||||
case 'asset': {
|
||||
// The current id is kept here rather than read back off the label: the label is a name,
|
||||
// two assets may share one, and a dangling reference has no name at all.
|
||||
let current = String(value);
|
||||
const button = new Dropdown(assetRefLabel(ctx.project(), current));
|
||||
button.onClick = () => {
|
||||
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
|
||||
const items: MenuItem[] = [
|
||||
{ label: mark('', NO_ASSET_LABEL), onSelect: () => handlers.onChange('') },
|
||||
// Built on click, not once per row: assets are created, renamed and deleted while
|
||||
// the inspector stands, and the picker has to show the project as it is now.
|
||||
...assetRefChoices(ctx.project(), def.assetTypes).map((asset) => ({
|
||||
label: mark(asset.id, assetChoiceLabel(asset)),
|
||||
onSelect: () => handlers.onChange(asset.id),
|
||||
})),
|
||||
];
|
||||
// Searchable: the list grows with the project, like the parent picker.
|
||||
new Menu(items, { searchable: true }).openBelow(button);
|
||||
};
|
||||
return {
|
||||
widget: button,
|
||||
grow: true,
|
||||
setValue: (v) => {
|
||||
current = String(v);
|
||||
button.setText(assetRefLabel(ctx.project(), current));
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Label, type TextField, type VStack } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import type { FieldRow } from './fieldFactory';
|
||||
import type { FieldContext, FieldRow } from './fieldFactory';
|
||||
|
||||
/** What the inspector sections build into: the panel body and the registry of rows. */
|
||||
export interface InspectorHost {
|
||||
/**
|
||||
* What the inspector sections build into: the panel body and the registry of rows. It is also
|
||||
* the FieldContext the sections hand to the field factory, so a row can resolve an asset
|
||||
* reference without the factory knowing anything about the editor state.
|
||||
*/
|
||||
export interface InspectorHost extends FieldContext {
|
||||
readonly state: EditorState;
|
||||
readonly body: VStack;
|
||||
/** Rows by key; live refreshes and highlight updates find them here. */
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
|
||||
import { edgesInto, type GraphNode, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { isPlaceableType } from '../../core/model/AssetTypes';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, Label } from '../../ui';
|
||||
@@ -38,8 +37,9 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
|
||||
if (def.params.length > 0) addTitle(host, 'SETTINGS');
|
||||
for (const param of def.params) {
|
||||
const effective = resolveParamDef(host.state.project, param, asset);
|
||||
const effective = resolveParamDef(param, asset);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
effective,
|
||||
node.params[param.key] ?? effective.default,
|
||||
paramHandlers(host, assetId, nodeId, param.key),
|
||||
@@ -53,6 +53,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
for (const port of free) {
|
||||
const inline = inlineInputDef(port);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
inline,
|
||||
node.params[port.key] ?? inline.default,
|
||||
paramHandlers(host, assetId, nodeId, port.key),
|
||||
@@ -87,23 +88,13 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
|
||||
}
|
||||
|
||||
/**
|
||||
* Variable params pick from the asset's variables, asset params (Spawn, Is asset) from the
|
||||
* project's placeable assets; everything else is used as declared.
|
||||
* Variable params are the one kind of setting the node cannot declare on its own: their
|
||||
* options are the variables of the behavior the node sits in. Everything else, asset
|
||||
* references included, is used exactly as declared.
|
||||
*/
|
||||
function resolveParamDef(project: Project, param: PropertyDef, asset: Asset): PropertyDef {
|
||||
if (param.kind !== 'enum') return param;
|
||||
if (param.key === 'variable' && asset.type === 'behavior') {
|
||||
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
|
||||
}
|
||||
if (param.key === 'asset') {
|
||||
return {
|
||||
...param,
|
||||
options: project.assets
|
||||
.filter((a) => isPlaceableType(a.type))
|
||||
.map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })),
|
||||
};
|
||||
}
|
||||
return param;
|
||||
function resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef {
|
||||
if (param.kind !== 'enum' || param.key !== 'variable' || asset.type !== 'behavior') return param;
|
||||
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
|
||||
}
|
||||
|
||||
/** Previews straight into the node's params; the command records the value from before the preview. */
|
||||
|
||||
@@ -264,6 +264,7 @@ function componentRow<V extends object>(
|
||||
): void {
|
||||
const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) });
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
|
||||
value,
|
||||
previewingHandlers<SceneObject, V>(host, {
|
||||
@@ -300,6 +301,7 @@ function propertyRow(
|
||||
): FieldRow {
|
||||
const bases = () => deviations(host.state, sceneId, objectId, def.key);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
def,
|
||||
values[def.key] ?? def.default,
|
||||
previewingHandlers<SceneObject>(host, {
|
||||
|
||||
@@ -4,9 +4,11 @@ import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import type { TextureStore } from '../../core/store/TextureStore';
|
||||
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
|
||||
import { confirmDeleteAsset } from '../actions';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
import { pickAndImport } from '../textureImport';
|
||||
|
||||
/** Lists all assets of the project; creates, places and deletes them. */
|
||||
export class AssetPanel extends Panel {
|
||||
@@ -16,6 +18,8 @@ export class AssetPanel extends Panel {
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
private readonly placementPoint: () => Vec2,
|
||||
/** Where imported images go. Without it the Texture entry has nothing to upload to. */
|
||||
private readonly textures: TextureStore | null = null,
|
||||
) {
|
||||
super({ title: 'Assets', padding: 0, gap: 0 });
|
||||
this.minWidth = 160;
|
||||
@@ -25,7 +29,7 @@ export class AssetPanel extends Panel {
|
||||
new Menu(
|
||||
ASSET_TYPES.map((type) => ({
|
||||
label: ASSET_TYPE_LABELS[type],
|
||||
onSelect: () => this.createAsset(type),
|
||||
onSelect: () => (type === 'texture' ? this.importTextures() : this.createAsset(type)),
|
||||
})),
|
||||
).openBelow(add);
|
||||
this.header.add(add);
|
||||
@@ -67,6 +71,18 @@ export class AssetPanel extends Panel {
|
||||
if (this.state.execute(command)) this.state.select({ kind: 'asset', assetId: command.asset.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* A texture is an uploaded picture, so its menu entry opens the file dialog instead of
|
||||
* creating an empty asset – a texture without an image would draw nothing.
|
||||
*/
|
||||
private importTextures(): void {
|
||||
if (!this.textures) {
|
||||
this.state.setNotice('Image upload is not available.');
|
||||
return;
|
||||
}
|
||||
pickAndImport({ state: this.state, textures: this.textures });
|
||||
}
|
||||
|
||||
/** Double-click / Enter: scenes become active, everything else is placed. */
|
||||
private activate(id: string): void {
|
||||
const asset = findAsset(this.state.project, id);
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Getting a picture from the user's machine into the project: pick, drop or paste a file,
|
||||
* shrink it if it is too big to send, upload the bytes, add a texture asset. The three ways in
|
||||
* share every step after the file – so a dropped and a picked image behave identically.
|
||||
*/
|
||||
import { CreateTextureCommand } from '../core/commands/assetCommands';
|
||||
import { nextAssetName } from '../core/commands/naming';
|
||||
import type { TextureAsset } from '../core/model/Asset';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/** What the picker offers and a drop accepts; the server rejects everything else anyway. */
|
||||
export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
||||
|
||||
/**
|
||||
* Longest side a stored texture may have. A 4000×3000 phone photo has no business being a
|
||||
* sprite texture: it costs 48 MB of video memory to draw something a fraction of that size.
|
||||
*/
|
||||
export const MAX_TEXTURE_SIDE = 2048;
|
||||
|
||||
/**
|
||||
* Byte budget for one upload. The API would take 4 MB, but nginx cuts every request off at
|
||||
* 1 MB and answers with its own HTML page, so nginx is the wall we have to stay under.
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES = 950 * 1024;
|
||||
|
||||
/** Tried in order when an image has to be re-encoded; the first result that fits wins. */
|
||||
const WEBP_QUALITIES = [0.9, 0.75, 0.6] as const;
|
||||
|
||||
export type ImagePlan = { kind: 'keep' } | { kind: 'reencode'; width: number; height: number };
|
||||
|
||||
/**
|
||||
* How to get an image within both limits. Re-encoding is a last resort – it would turn crisp
|
||||
* pixel art into mush – so anything that is already small enough in both senses is sent byte
|
||||
* for byte as it lies on disk. `bytes` is the encoded file size, not the pixel count.
|
||||
*/
|
||||
export function planImageFit(width: number, height: number, bytes: number): ImagePlan {
|
||||
const scale = Math.min(1, MAX_TEXTURE_SIDE / Math.max(width, height));
|
||||
if (scale === 1 && bytes <= MAX_UPLOAD_BYTES) return { kind: 'keep' };
|
||||
return {
|
||||
kind: 'reencode',
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes at decreasing quality and stops at the first result within `MAX_UPLOAD_BYTES`. When
|
||||
* none fits, the last (lowest quality) attempt comes back, so the caller can report how far
|
||||
* off it was. `encode` is a parameter to keep the ladder testable without a canvas.
|
||||
*/
|
||||
export async function encodeWithinBudget(encode: (quality: number) => Promise<Blob>): Promise<Blob> {
|
||||
let result = await encode(WEBP_QUALITIES[0]);
|
||||
for (const quality of WEBP_QUALITIES.slice(1)) {
|
||||
if (result.size <= MAX_UPLOAD_BYTES) return result;
|
||||
result = await encode(quality);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface PreparedImage {
|
||||
blob: Blob;
|
||||
/** Content-Type for the upload; `image/webp` whenever the image had to be re-encoded. */
|
||||
type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes to upload for one picked file: the file itself whenever it already fits (see
|
||||
* `planImageFit`), otherwise a downscaled WebP. Rejects when the file is not a decodable image
|
||||
* or stays too large even at the lowest quality.
|
||||
*/
|
||||
export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
} catch {
|
||||
throw new Error('The file is not a readable image.');
|
||||
}
|
||||
try {
|
||||
const plan = planImageFit(bitmap.width, bitmap.height, file.size);
|
||||
if (plan.kind === 'keep') {
|
||||
// Untouched on purpose: re-encoding costs quality and would break pixel art.
|
||||
return {
|
||||
blob: file,
|
||||
type: file.type || 'application/octet-stream',
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
};
|
||||
}
|
||||
// Only the first frame of an animated GIF survives this, which is all the renderer ever
|
||||
// draws – a texture is one still picture.
|
||||
const canvas = new OffscreenCanvas(plan.width, plan.height);
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) throw new Error('This browser cannot resize images.');
|
||||
context.drawImage(bitmap, 0, 0, plan.width, plan.height);
|
||||
const blob = await encodeWithinBudget((quality) => canvas.convertToBlob({ type: 'image/webp', quality }));
|
||||
if (blob.size > MAX_UPLOAD_BYTES) {
|
||||
throw new Error(
|
||||
`The image is too large to upload: still ${formatBytes(blob.size)} at ${plan.width}×${plan.height}. Shrink it before importing.`,
|
||||
);
|
||||
}
|
||||
return { blob, type: 'image/webp', width: plan.width, height: plan.height };
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
export interface TextureImportDeps {
|
||||
state: EditorState;
|
||||
textures: TextureStore;
|
||||
}
|
||||
|
||||
/** One file's outcome; exactly one of `assetId` and `error` is set. */
|
||||
export interface ImportOutcome {
|
||||
name: string;
|
||||
assetId: string | null;
|
||||
error: string | null;
|
||||
/** The project already had this exact picture, so its existing asset was used instead. */
|
||||
reused?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports the files one after another – prepare, upload, create the asset – selects the last
|
||||
* one that worked and sums the result up as a notice. Sequential on purpose: each asset is
|
||||
* named against the project as it stands *after* the previous one. A file that fails is
|
||||
* reported and the remaining ones are still imported.
|
||||
*/
|
||||
export async function importImages(
|
||||
files: readonly File[],
|
||||
deps: TextureImportDeps,
|
||||
): Promise<ImportOutcome[]> {
|
||||
if (files.length === 0) return [];
|
||||
const outcomes: ImportOutcome[] = [];
|
||||
for (const file of files) outcomes.push(await importImage(file, deps));
|
||||
|
||||
const created = outcomes.filter((outcome) => outcome.assetId !== null);
|
||||
const last = created[created.length - 1];
|
||||
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
|
||||
deps.state.setNotice(importNotice(outcomes));
|
||||
return outcomes;
|
||||
}
|
||||
|
||||
async function importImage(file: File, deps: TextureImportDeps): Promise<ImportOutcome> {
|
||||
try {
|
||||
const image = await prepareImage(file);
|
||||
const uploaded = await deps.textures.upload(image.blob, image.type);
|
||||
|
||||
// The server stores by content hash, so importing a picture the project already has gives
|
||||
// back the file it already references. Making a second asset for it would be clutter with a
|
||||
// trap in it: two assets on one file can disagree about `filter`, while the scale mode
|
||||
// belongs to the single shared source. Reuse instead – and say so, rather than silently
|
||||
// doing nothing visible.
|
||||
const existing = deps.state.project.assets.find(
|
||||
(asset): asset is TextureAsset => asset.type === 'texture' && asset.file === uploaded.file,
|
||||
);
|
||||
if (existing) {
|
||||
return { name: file.name, assetId: existing.id, error: null, reused: true };
|
||||
}
|
||||
|
||||
const name = nextAssetName(deps.state.project, assetNameFor(file));
|
||||
const command = new CreateTextureCommand(name, uploaded);
|
||||
// A rejected command has already put its reason into the notice; that is the message here.
|
||||
if (!deps.state.execute(command)) return { name: file.name, assetId: null, error: deps.state.notice };
|
||||
return { name: file.name, assetId: command.asset.id, error: null, reused: false };
|
||||
} catch (error) {
|
||||
return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One line for the whole batch: how many arrived, how many were already there, and why the
|
||||
* first casualty did not. Reuse gets its own words – "Imported 1 image" would be a lie about a
|
||||
* picture the project already had, and the user would look for a new asset that never appeared.
|
||||
*/
|
||||
export function importNotice(outcomes: readonly ImportOutcome[]): string {
|
||||
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
|
||||
const reused = outcomes.filter((outcome) => outcome.reused).length;
|
||||
const failed = outcomes.filter((outcome) => outcome.error !== null);
|
||||
const first = failed[0];
|
||||
|
||||
const known = reused === 0 ? '' : ` ${reused === 1 ? 'One was' : `${reused} were`} already in the project.`;
|
||||
|
||||
if (!first) {
|
||||
if (added === 0 && reused > 0)
|
||||
return reused === 1
|
||||
? 'That image is already in the project.'
|
||||
: `Those ${reused} images are already in the project.`;
|
||||
return `Imported ${added} ${added === 1 ? 'image' : 'images'}.${known}`;
|
||||
}
|
||||
const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : '';
|
||||
const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`;
|
||||
if (added + reused === 0) return problem;
|
||||
return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`;
|
||||
}
|
||||
|
||||
/** "player_ship.png" → "player_ship"; a pasted screenshot arrives as "image.png". */
|
||||
function assetNameFor(file: File): string {
|
||||
return file.name.replace(/\.[^.]+$/, '').trim() || 'Texture';
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
// --- ways in ---------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens the browser's file dialog and resolves with what the user chose – empty when the
|
||||
* dialog was cancelled. The `<input>` lives only for this one call.
|
||||
*/
|
||||
export function pickImageFiles(): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = IMAGE_ACCEPT;
|
||||
input.multiple = true;
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
const finish = (files: File[]) => {
|
||||
input.remove();
|
||||
resolve(files);
|
||||
};
|
||||
input.addEventListener('change', () => finish([...(input.files ?? [])]));
|
||||
// Without `cancel` a dismissed dialog would leave both the element and this promise behind.
|
||||
input.addEventListener('cancel', () => finish([]));
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** The `+` menu's Texture entry: pick files and import them, reporting anything that goes wrong. */
|
||||
export function pickAndImport(deps: TextureImportDeps): void {
|
||||
void pickImageFiles()
|
||||
.then((files) => importImages(files, deps))
|
||||
.catch((error: unknown) => deps.state.setNotice(String(error)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the two ways of importing an image that need no menu: dropping files onto `target`
|
||||
* (the Pixi canvas) and pasting one from the clipboard. Returns the function that removes both
|
||||
* again.
|
||||
*
|
||||
* Paste is bound to the window because the canvas cannot hold focus, and is ignored while a
|
||||
* DOM text field has it (the `KeyboardBridge` input): Ctrl+V belongs to the text being edited.
|
||||
*/
|
||||
export function installTextureImport(target: HTMLElement, deps: TextureImportDeps): () => void {
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
if (!hasImageItems(event.dataTransfer)) return;
|
||||
// Without this the browser navigates away to the dropped file.
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
const onDrop = (event: DragEvent) => {
|
||||
const files = imageFilesOf(event.dataTransfer);
|
||||
if (files.length === 0) {
|
||||
if (!event.dataTransfer?.files.length) return;
|
||||
event.preventDefault();
|
||||
deps.state.setNotice('Only image files can be dropped into the editor.');
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
run(files, deps);
|
||||
};
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (isTextFieldFocused()) return;
|
||||
const files = imageFilesOf(event.clipboardData);
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
run(files, deps);
|
||||
};
|
||||
|
||||
target.addEventListener('dragover', onDragOver);
|
||||
target.addEventListener('drop', onDrop);
|
||||
window.addEventListener('paste', onPaste);
|
||||
return () => {
|
||||
target.removeEventListener('dragover', onDragOver);
|
||||
target.removeEventListener('drop', onDrop);
|
||||
window.removeEventListener('paste', onPaste);
|
||||
};
|
||||
}
|
||||
|
||||
function run(files: readonly File[], deps: TextureImportDeps): void {
|
||||
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error)));
|
||||
}
|
||||
|
||||
function imageFilesOf(data: DataTransfer | null): File[] {
|
||||
return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/'));
|
||||
}
|
||||
|
||||
/** During a drag the files themselves are not readable yet – only the kinds and types are. */
|
||||
function hasImageItems(data: DataTransfer | null): boolean {
|
||||
return [...(data?.items ?? [])].some((item) => item.kind === 'file' && item.type.startsWith('image/'));
|
||||
}
|
||||
|
||||
function isTextFieldFocused(): boolean {
|
||||
const active = document.activeElement;
|
||||
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Assets, type Texture } from 'pixi.js';
|
||||
import type { TextureRef } from '../core/model/texture';
|
||||
|
||||
const DEFAULT_BASE = '/game-maker/data/textures';
|
||||
|
||||
/**
|
||||
* Keeps uploaded images on the GPU, keyed by their file name.
|
||||
*
|
||||
* Nothing is ever evicted, and that is safe rather than sloppy: file names are content
|
||||
* addressed, so a name cannot come to mean different bytes and a cached texture cannot go
|
||||
* stale. It also means the same picture used by a hundred sprites is uploaded once.
|
||||
*
|
||||
* A miss is not an error – it starts the load and reports "not yet", so the caller draws its
|
||||
* plain colour in the meantime and gets a redraw when the picture arrives. That is why a
|
||||
* sprite never flickers and why nothing here needs to be awaited.
|
||||
*/
|
||||
export class TextureLibrary {
|
||||
private base = DEFAULT_BASE;
|
||||
private readonly loaded = new Map<string, Texture>();
|
||||
private readonly loading = new Set<string>();
|
||||
/** Files whose load failed; retried never, so one broken reference cannot spam the network. */
|
||||
private readonly failed = new Set<string>();
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
/** Where the files are served from. Set once at start-up, before anything draws. */
|
||||
configure(base: string): void {
|
||||
this.base = base.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture if it is already in memory, otherwise null while it loads.
|
||||
*
|
||||
* Caveat of content addressing: two texture *assets* can name the same file (the same image
|
||||
* uploaded twice) while disagreeing about `smooth`. The scale mode belongs to the shared
|
||||
* source, so the last one drawn wins – deterministically, since draw order is stable. The
|
||||
* upload avoids creating that situation by reusing an existing asset for a known file.
|
||||
*/
|
||||
get(ref: TextureRef): Texture | null {
|
||||
const texture = this.loaded.get(ref.file);
|
||||
if (!texture) {
|
||||
this.startLoading(ref.file);
|
||||
return null;
|
||||
}
|
||||
const mode = ref.smooth ? 'linear' : 'nearest';
|
||||
if (texture.source.scaleMode !== mode) texture.source.scaleMode = mode;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/** Runs `fn` whenever a picture has arrived. Returns the unsubscribe. */
|
||||
onChange(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
private startLoading(file: string): void {
|
||||
if (this.loading.has(file) || this.failed.has(file)) return;
|
||||
this.loading.add(file);
|
||||
void Assets.load<Texture>(`${this.base}/${file}`).then(
|
||||
(texture) => {
|
||||
this.loading.delete(file);
|
||||
this.loaded.set(file, texture);
|
||||
for (const listener of this.listeners) listener();
|
||||
},
|
||||
(error: unknown) => {
|
||||
this.loading.delete(file);
|
||||
this.failed.add(file);
|
||||
console.error(`[textures] cannot load ${file}`, error);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one library. A second instance would only duplicate GPU uploads – the cache is keyed by
|
||||
* content, so there is nothing an instance could hold that another should not share.
|
||||
*/
|
||||
export const textureLibrary = new TextureLibrary();
|
||||
@@ -15,6 +15,7 @@ import type { EditorState } from '../editor/state';
|
||||
import { type Size, Theme, Widget } from '../ui';
|
||||
import type { DrawItem } from './items';
|
||||
import { drawItem } from './symbols';
|
||||
import { textureLibrary } from './TextureLibrary';
|
||||
import { type View, worldBounds, worldToScreen } from './view';
|
||||
|
||||
/** What a redraw draws: the items and the colour behind them. */
|
||||
@@ -58,6 +59,9 @@ export abstract class WorldViewport extends Widget {
|
||||
state.on('runtime', () => {
|
||||
if (this.visible) this.redraw();
|
||||
});
|
||||
// A picture that finished loading changes what is on screen without anything in the model
|
||||
// having moved. Unconditional: this fires once per image, not per frame.
|
||||
textureLibrary.onChange(() => this.redraw());
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
@@ -128,6 +132,26 @@ export abstract class WorldViewport extends Widget {
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
/**
|
||||
* The screen rectangle an object occupies: its footprint through the current view. Checks
|
||||
* sample colours inside it to assert what was actually drawn, which `objectScreen` alone
|
||||
* cannot support because it reports a point.
|
||||
*/
|
||||
objectRect(objectId: string): { x: number; y: number; w: number; h: number } | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!item || !view) return null;
|
||||
const local = worldToScreen(view, item.x, item.y);
|
||||
const origin = this.getGlobalPosition();
|
||||
const size = footprintOf(item.type, item.props);
|
||||
return {
|
||||
x: origin.x + local.x,
|
||||
y: origin.y + local.y,
|
||||
w: size.w * view.scale,
|
||||
h: size.h * view.scale,
|
||||
};
|
||||
}
|
||||
|
||||
// --- drawing ---------------------------------------------------------------------------------
|
||||
|
||||
override redraw(): void {
|
||||
@@ -176,7 +200,7 @@ export abstract class WorldViewport extends Widget {
|
||||
if (!onScreen) continue;
|
||||
this.visibleCount++;
|
||||
node.position.set(item.x, item.y);
|
||||
drawItem(node, item, size, view.scale);
|
||||
drawItem(node, item, size, view.scale, textureLibrary);
|
||||
}
|
||||
this.afterItems(items, view);
|
||||
for (const [id, node] of this.nodes) {
|
||||
|
||||
+13
-1
@@ -3,6 +3,7 @@ import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
|
||||
import { editorPosition, treeOrder } from '../core/model/hierarchy';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import { findAsset } from '../core/model/Project';
|
||||
import { resolveTexture, type TextureRef } from '../core/model/texture';
|
||||
import { enabledAlongChain } from '../core/properties/enabled';
|
||||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||
import type { EditorState } from '../editor/state';
|
||||
@@ -26,6 +27,13 @@ export interface DrawItem {
|
||||
parentId: string;
|
||||
/** Effective switch: this object and all its parents are on (see `properties/enabled.ts`). */
|
||||
enabled: boolean;
|
||||
/**
|
||||
* Picture to draw a sprite with, already resolved from the `texture` property – null for
|
||||
* every other type and for a sprite without a usable one. It is resolved here because
|
||||
* drawing has no access to the project: a `DrawItem` is meant to carry everything the
|
||||
* renderer needs.
|
||||
*/
|
||||
texture: TextureRef | null;
|
||||
}
|
||||
|
||||
interface DrawList {
|
||||
@@ -41,14 +49,16 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
|
||||
for (const { object } of treeOrder(scene)) {
|
||||
const asset = findAsset(state.project, object.assetId);
|
||||
if (!asset) continue;
|
||||
const props = resolveSceneObjectProps(asset, object);
|
||||
items.push({
|
||||
id: object.id,
|
||||
type: asset.type,
|
||||
name: object.name || asset.name,
|
||||
...editorPosition(scene, object),
|
||||
props: resolveSceneObjectProps(asset, object),
|
||||
props,
|
||||
parentId: object.parentId,
|
||||
enabled: true,
|
||||
texture: asset.type === 'sprite' ? resolveTexture(state.project, props) : null,
|
||||
});
|
||||
}
|
||||
const byId = new Map(items.map((i) => [i.id, i]));
|
||||
@@ -73,6 +83,8 @@ export function collectWorldItems(world: World): DrawList {
|
||||
props: e.props,
|
||||
parentId: e.parent?.objectId ?? '',
|
||||
enabled: true,
|
||||
// Resolved per frame on purpose: a behavior may swap `texture` with `Set property`.
|
||||
texture: e.type === 'sprite' ? resolveTexture(world.project, e.props) : null,
|
||||
}));
|
||||
return { items, background: world.backgroundColor };
|
||||
}
|
||||
+12
-2
@@ -8,6 +8,7 @@ import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, textAnchor } from '../core/propertie
|
||||
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
|
||||
import { Theme } from '../ui';
|
||||
import type { DrawItem } from './items';
|
||||
import type { TextureLibrary } from './TextureLibrary';
|
||||
|
||||
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
|
||||
const COLLIDER_GRAB_BAND = 6;
|
||||
@@ -21,13 +22,22 @@ export function drawItem(
|
||||
item: DrawItem,
|
||||
size: { w: number; h: number },
|
||||
scale: number,
|
||||
textures: TextureLibrary,
|
||||
): void {
|
||||
node.clear();
|
||||
node.hitArea = null;
|
||||
switch (item.type) {
|
||||
case 'sprite':
|
||||
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(Number(item.props.color ?? 0xffffff));
|
||||
case 'sprite': {
|
||||
const color = Number(item.props.color ?? 0xffffff);
|
||||
// Null while the picture is still loading, so the box shows its plain colour until then –
|
||||
// which is exactly what a sprite looked like before textures existed. No flicker, no
|
||||
// placeholder. With a picture the colour tints it (verified: white x 0x66aaff reads back
|
||||
// as rgb(98,163,245)), and Pixi fits an unmatrixed texture fill to the shape's bounds,
|
||||
// so nothing here has to compute a texture matrix.
|
||||
const texture = item.texture ? textures.get(item.texture) : null;
|
||||
node.rect(-size.w / 2, -size.h / 2, size.w, size.h).fill(texture ? { texture, color } : color);
|
||||
return;
|
||||
}
|
||||
case 'camera':
|
||||
node
|
||||
.rect(-size.w / 2, -size.h / 2, size.w, size.h)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The two pure halves of the `asset` property kind: how a stored id reads, and which assets a
|
||||
* picker may offer for it. Both are used by the inspector row and by the graph node view, so
|
||||
* they are pinned here rather than through a widget.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import type { AssetType } from '../../src/core/model/AssetTypes';
|
||||
import type { Project } from '../../src/core/model/Project';
|
||||
import { assetChoiceLabel, assetRefChoices, assetRefLabel } from '../../src/core/properties/assetRef';
|
||||
import { makeProject, type ProjectFixture } from '../helpers';
|
||||
|
||||
/** Adds an asset and returns its id, so a test can reference it. */
|
||||
function add(fixture: ProjectFixture, type: AssetType, name: string): string {
|
||||
const command = new CreateAssetCommand(type, name);
|
||||
fixture.stack.execute(command);
|
||||
return command.asset.id;
|
||||
}
|
||||
|
||||
/** A project holding one asset of every type, each named after its type. */
|
||||
function oneOfEach(): { project: Project; ids: Record<AssetType, string> } {
|
||||
const fixture = makeProject();
|
||||
const types: AssetType[] = [
|
||||
'scene',
|
||||
'camera',
|
||||
'sprite',
|
||||
'text',
|
||||
'behavior',
|
||||
'collider',
|
||||
'container',
|
||||
'texture',
|
||||
];
|
||||
const ids = Object.fromEntries(types.map((type) => [type, add(fixture, type, type)])) as Record<
|
||||
AssetType,
|
||||
string
|
||||
>;
|
||||
return { project: fixture.project, ids };
|
||||
}
|
||||
|
||||
describe('assetRefLabel', () => {
|
||||
it('reads as the referenced asset name', () => {
|
||||
const fixture = makeProject();
|
||||
const id = add(fixture, 'sprite', 'Coin');
|
||||
expect(assetRefLabel(fixture.project, id)).toBe('Coin');
|
||||
});
|
||||
|
||||
it('reads as "None" when nothing is referenced', () => {
|
||||
expect(assetRefLabel(makeProject().project, '')).toBe('None');
|
||||
});
|
||||
|
||||
it('keeps showing the id of a reference that no longer resolves', () => {
|
||||
expect(assetRefLabel(makeProject().project, 'gone')).toBe('gone?');
|
||||
});
|
||||
|
||||
it('marks a deleted asset as dangling rather than falling back to "None"', () => {
|
||||
const fixture = makeProject();
|
||||
const id = add(fixture, 'sprite', 'Coin');
|
||||
fixture.stack.undo();
|
||||
expect(assetRefLabel(fixture.project, id)).toBe(`${id}?`);
|
||||
});
|
||||
|
||||
it('dangles without a project, but the empty reference is still "None"', () => {
|
||||
expect(assetRefLabel(null, 'x')).toBe('x?');
|
||||
expect(assetRefLabel(null, '')).toBe('None');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetRefChoices', () => {
|
||||
it('offers every placeable asset when no types are given', () => {
|
||||
const { project } = oneOfEach();
|
||||
expect(assetRefChoices(project).map((a) => a.type)).toEqual([
|
||||
'behavior',
|
||||
'camera',
|
||||
'collider',
|
||||
'container',
|
||||
'sprite',
|
||||
'text',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never offers a scene or a texture as a placeable asset', () => {
|
||||
const { project } = oneOfEach();
|
||||
const types = assetRefChoices(project).map((a) => a.type);
|
||||
expect(types).not.toContain('scene');
|
||||
expect(types).not.toContain('texture');
|
||||
});
|
||||
|
||||
it('offers exactly the given types, placeable or not', () => {
|
||||
const { project, ids } = oneOfEach();
|
||||
expect(assetRefChoices(project, ['texture']).map((a) => a.id)).toEqual([ids.texture]);
|
||||
expect(assetRefChoices(project, ['scene', 'sprite']).map((a) => a.id)).toEqual([ids.scene, ids.sprite]);
|
||||
});
|
||||
|
||||
it('offers nothing when no asset has one of the given types', () => {
|
||||
const fixture = makeProject();
|
||||
add(fixture, 'sprite', 'Coin');
|
||||
expect(assetRefChoices(fixture.project, ['texture'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts by name, not by creation order', () => {
|
||||
const fixture = makeProject();
|
||||
add(fixture, 'sprite', 'Zebra');
|
||||
add(fixture, 'sprite', 'Apple');
|
||||
add(fixture, 'text', 'Mango');
|
||||
expect(assetRefChoices(fixture.project).map((a) => a.name)).toEqual(['Apple', 'Mango', 'Zebra']);
|
||||
});
|
||||
|
||||
it('sorts namesakes by type so the order does not depend on creation order', () => {
|
||||
const fixture = makeProject();
|
||||
add(fixture, 'text', 'Coin');
|
||||
add(fixture, 'sprite', 'Coin');
|
||||
expect(assetRefChoices(fixture.project).map(assetChoiceLabel)).toEqual(['Coin (sprite)', 'Coin (text)']);
|
||||
});
|
||||
|
||||
it('leaves the asset order of the project alone', () => {
|
||||
const fixture = makeProject();
|
||||
add(fixture, 'sprite', 'Zebra');
|
||||
add(fixture, 'sprite', 'Apple');
|
||||
assetRefChoices(fixture.project);
|
||||
expect(fixture.project.assets.map((a) => a.name)).toEqual(['Zebra', 'Apple']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node asset params', () => {
|
||||
// There used to be a second notion of an asset reference: an enum with empty options that the
|
||||
// graph inspector filled in. This keeps the nodes on the real kind.
|
||||
it('use the asset kind and offer every placeable asset', () => {
|
||||
const params = BUILT_IN_NODES.flatMap((def) => def.params).filter((p) => p.key === 'asset');
|
||||
expect(params.length).toBeGreaterThan(0);
|
||||
for (const param of params) {
|
||||
expect(param.kind).toBe('asset');
|
||||
expect(param.kind === 'asset' && param.assetTypes).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,8 @@ describe('asset commands', () => {
|
||||
enabled: true,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0xe0553f,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
|
||||
import { HttpTextureStore } from '../../src/core/store/HttpTextureStore';
|
||||
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
const FILE = `${'a'.repeat(64)}.png`;
|
||||
/** What nginx answers when it refuses a request body larger than its 1 MB limit. */
|
||||
const NGINX_413 = '<html><head><title>413 Request Entity Too Large</title></head><body></body></html>';
|
||||
|
||||
/** Replaces `fetch` with one that always answers the same way; the mock records the request. */
|
||||
function stubFetch(body: BodyInit | null, init: ResponseInit) {
|
||||
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('HttpProjectStore', () => {
|
||||
it('reads a JSON body and keeps the server message on failure', async () => {
|
||||
stubFetch(JSON.stringify([{ id: 'p', name: 'P', revision: 1, updatedAt: 'now' }]), { status: 200 });
|
||||
await expect(new HttpProjectStore('/api').list()).resolves.toHaveLength(1);
|
||||
|
||||
stubFetch(JSON.stringify({ error: 'Project not found' }), { status: 404 });
|
||||
await expect(new HttpProjectStore('/api').load('p')).rejects.toThrow(/Project not found \(404\)/);
|
||||
});
|
||||
|
||||
it('explains a non-JSON error body by its status instead of calling it invalid JSON', async () => {
|
||||
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
|
||||
const { project } = makeProject();
|
||||
await expect(new HttpProjectStore('/api').save(project, 1)).rejects.toThrow(/too large \(413\)/i);
|
||||
});
|
||||
|
||||
it('still reports a successful response that is not JSON as a defect', async () => {
|
||||
stubFetch('<html>hello</html>', { status: 200 });
|
||||
await expect(new HttpProjectStore('/api').list()).rejects.toThrow(/invalid JSON \(200\)/);
|
||||
});
|
||||
|
||||
it('turns a 409 into a StaleRevisionError', async () => {
|
||||
stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 7 }), { status: 409 });
|
||||
const { project } = makeProject();
|
||||
await expect(new HttpProjectStore('/api').save(project, 3)).rejects.toBeInstanceOf(StaleRevisionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpTextureStore', () => {
|
||||
it('uploads the raw bytes and returns what the server stored', async () => {
|
||||
const mock = stubFetch(JSON.stringify({ file: FILE, width: 2, height: 3, bytes: 4 }), { status: 201 });
|
||||
const bytes = new Blob([new Uint8Array([1, 2, 3, 4])]);
|
||||
const stored = await new HttpTextureStore('/api').upload(bytes, 'image/png');
|
||||
|
||||
expect(stored).toEqual({ file: FILE, width: 2, height: 3, bytes: 4 });
|
||||
const [url, init] = mock.mock.calls[0]!;
|
||||
expect(url).toBe('/api?r=textures');
|
||||
expect(init?.method).toBe('POST');
|
||||
// The body *is* the image: no multipart wrapper, no JSON.
|
||||
expect(init?.body).toBe(bytes);
|
||||
expect(init?.headers).toEqual({ 'Content-Type': 'image/png' });
|
||||
});
|
||||
|
||||
it('reports the nginx size limit as a size problem', async () => {
|
||||
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
|
||||
const bytes = new Blob([new Uint8Array(8)]);
|
||||
await expect(new HttpTextureStore('/api').upload(bytes, 'image/png')).rejects.toThrow(
|
||||
/too large \(413\)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('lists and deletes', async () => {
|
||||
stubFetch(JSON.stringify([{ file: FILE, bytes: 12, updatedAt: 'now' }]), { status: 200 });
|
||||
await expect(new HttpTextureStore('/api').list()).resolves.toEqual([
|
||||
{ file: FILE, bytes: 12, updatedAt: 'now' },
|
||||
]);
|
||||
|
||||
const mock = stubFetch(null, { status: 204 });
|
||||
await new HttpTextureStore('/api').delete(FILE);
|
||||
expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`);
|
||||
expect(mock.mock.calls[0]![1]?.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,13 @@ describe('PropertySchema', () => {
|
||||
});
|
||||
|
||||
it('provides defaults for every key', () => {
|
||||
expect(defaultsFor('sprite')).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
|
||||
expect(defaultsFor('sprite')).toEqual({
|
||||
enabled: true,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces values against the definition', () => {
|
||||
@@ -41,7 +47,13 @@ describe('resolve', () => {
|
||||
it('fills asset props with defaults and ignores unknown keys', () => {
|
||||
const sprite = buildAsset('sprite', 'Box');
|
||||
sprite.props = { width: 50, bogus: 1 };
|
||||
expect(resolveAssetProps(sprite)).toEqual({ enabled: true, width: 50, height: 100, color: 0xe0553f });
|
||||
expect(resolveAssetProps(sprite)).toEqual({
|
||||
enabled: true,
|
||||
width: 50,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies sparse overrides on top of asset values', () => {
|
||||
@@ -53,6 +65,7 @@ describe('resolve', () => {
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0x00ff00,
|
||||
texture: '',
|
||||
});
|
||||
|
||||
object.overrides.color = 0x0000ff;
|
||||
|
||||
@@ -34,7 +34,8 @@ describe('serialize', () => {
|
||||
],
|
||||
},
|
||||
{ id: 'sp1', type: 'sprite', props: { width: 10 } },
|
||||
{ id: 'x', type: 'texture' },
|
||||
// An asset type this build does not know (a newer document, a renamed type).
|
||||
{ id: 'x', type: 'sound' },
|
||||
{ type: 'camera' },
|
||||
],
|
||||
},
|
||||
@@ -58,6 +59,12 @@ describe('serialize', () => {
|
||||
templateId: '',
|
||||
});
|
||||
expect(project.assets[1]!.name).toBe('sprite');
|
||||
expect(project.assets[1]!.props).toEqual({ enabled: true, width: 10, height: 100, color: 0xe0553f });
|
||||
expect(project.assets[1]!.props).toEqual({
|
||||
enabled: true,
|
||||
width: 10,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { isTextureAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
const FILE = `${'b'.repeat(64)}.webp`;
|
||||
|
||||
describe('CreateTextureCommand', () => {
|
||||
it('adds a texture asset carrying the uploaded image, and removes it again', () => {
|
||||
const { project, stack } = makeProject();
|
||||
const command = new CreateTextureCommand('Hero', { file: FILE, width: 64, height: 32 });
|
||||
stack.execute(command);
|
||||
|
||||
const asset = findAsset(project, command.asset.id);
|
||||
expect(asset && isTextureAsset(asset)).toBe(true);
|
||||
expect(asset).toMatchObject({ type: 'texture', name: 'Hero', file: FILE, width: 64, height: 32 });
|
||||
// The schema default comes along, so the new texture is a complete asset – nothing to fill in later.
|
||||
expect(asset?.props).toEqual({ filter: 'linear' });
|
||||
|
||||
stack.undo();
|
||||
expect(project.assets).toHaveLength(0);
|
||||
stack.redo();
|
||||
expect(findAsset(project, command.asset.id)?.name).toBe('Hero');
|
||||
});
|
||||
|
||||
it('rejects a file name the server cannot have produced – in execute, not in the constructor', () => {
|
||||
const { project, stack } = makeProject();
|
||||
const command = new CreateTextureCommand('Hero', { file: '../secrets.png', width: 1, height: 1 });
|
||||
expect(() => stack.execute(command)).toThrow(CommandError);
|
||||
expect(project.assets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps two assets for the same file apart', () => {
|
||||
const { project, stack } = makeProject();
|
||||
stack.execute(new CreateTextureCommand('Hero', { file: FILE, width: 8, height: 8 }));
|
||||
stack.execute(new CreateTextureCommand('Hero copy', { file: FILE, width: 8, height: 8 }));
|
||||
expect(project.assets.map((a) => a.id)).toHaveLength(2);
|
||||
expect(new Set(project.assets.map((a) => a.id)).size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||||
import type { TextureAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset, findPropertyUsages, findUsages } from '../../src/core/model/Project';
|
||||
import { isTextureFile, resolveTexture } from '../../src/core/model/texture';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
|
||||
/** A syntactically valid stored file name (64 hex characters plus an extension). */
|
||||
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
|
||||
const OTHER_FILE = `${'0f'.repeat(32)}.webp`;
|
||||
|
||||
/** Adds a texture asset carrying `file` and returns it. */
|
||||
function addTexture(fixture: ReturnType<typeof makeSpriteScene>, file = FILE, name = 'Bricks') {
|
||||
const command = new CreateTextureCommand(name, { file, width: 64, height: 48 });
|
||||
fixture.stack.execute(command);
|
||||
return command.asset;
|
||||
}
|
||||
|
||||
describe('texture file names', () => {
|
||||
it('accepts the content-addressed form the server produces', () => {
|
||||
expect(isTextureFile(FILE)).toBe(true);
|
||||
expect(isTextureFile(OTHER_FILE)).toBe(true);
|
||||
expect(isTextureFile(`${'ab'.repeat(32)}.jpg`)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects anything a request must not be built from', () => {
|
||||
expect(isTextureFile('')).toBe(false);
|
||||
expect(isTextureFile('bricks.png')).toBe(false);
|
||||
// Too short, wrong case, an unsupported extension, and the two traversal shapes.
|
||||
expect(isTextureFile('abc.png')).toBe(false);
|
||||
expect(isTextureFile(`${'A1'.repeat(32)}.png`)).toBe(false);
|
||||
expect(isTextureFile(`${'ab'.repeat(32)}.svg`)).toBe(false);
|
||||
expect(isTextureFile(`../${'ab'.repeat(32)}.png`)).toBe(false);
|
||||
expect(isTextureFile(`${'ab'.repeat(32)}.png/../x`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTexture', () => {
|
||||
it('resolves a sprite property to the file and the filter', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = addTexture(fixture);
|
||||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||||
sprite.props.texture = texture.id;
|
||||
|
||||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ file: FILE, smooth: true });
|
||||
texture.props.filter = 'nearest';
|
||||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ file: FILE, smooth: false });
|
||||
});
|
||||
|
||||
it('yields nothing rather than throwing when the reference cannot be used', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = addTexture(fixture);
|
||||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||||
|
||||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||||
sprite.props.texture = 'does-not-exist';
|
||||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||||
// Pointing at an asset that is not a texture at all.
|
||||
sprite.props.texture = fixture.sceneId;
|
||||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||||
// A texture whose upload never produced a file.
|
||||
sprite.props.texture = texture.id;
|
||||
texture.file = '';
|
||||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||||
});
|
||||
|
||||
it('survives the round trip, and a hand-written file name does not', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = addTexture(fixture);
|
||||
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
|
||||
|
||||
const loaded = roundTrip(fixture.project);
|
||||
const reloaded = findAsset(loaded, texture.id) as TextureAsset;
|
||||
expect(reloaded.file).toBe(FILE);
|
||||
expect(reloaded.width).toBe(64);
|
||||
expect(resolveTexture(loaded, findAsset(loaded, fixture.spriteId)!.props)?.file).toBe(FILE);
|
||||
|
||||
const tampered = deserializeProject({
|
||||
assets: [{ id: 't', type: 'texture', name: 'X', file: '/etc/passwd', width: -3, height: 'big' }],
|
||||
});
|
||||
const dropped = findAsset(tampered, 't') as TextureAsset;
|
||||
expect(dropped.file).toBe('');
|
||||
expect(dropped.width).toBe(0);
|
||||
expect(dropped.height).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CreateTextureCommand', () => {
|
||||
it('adds and removes the asset', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = addTexture(fixture);
|
||||
expect(findAsset(fixture.project, texture.id)).toBeDefined();
|
||||
|
||||
fixture.stack.undo();
|
||||
expect(findAsset(fixture.project, texture.id)).toBeUndefined();
|
||||
fixture.stack.redo();
|
||||
expect((findAsset(fixture.project, texture.id) as TextureAsset).file).toBe(FILE);
|
||||
});
|
||||
|
||||
it('refuses a file name that was never uploaded', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
expect(() =>
|
||||
fixture.stack.execute(new CreateTextureCommand('Bad', { file: 'bricks.png', width: 1, height: 1 })),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPropertyUsages', () => {
|
||||
it('finds a texture that is worn but never placed', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = addTexture(fixture);
|
||||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||||
sprite.props.texture = texture.id;
|
||||
const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||||
|
||||
// A texture is never placed, so the "is it used?" question `findUsages` answers is blind
|
||||
// to it – that is exactly the gap this function fills.
|
||||
expect(findUsages(fixture.project, texture.id)).toHaveLength(0);
|
||||
|
||||
const usages = findPropertyUsages(fixture.project, texture.id);
|
||||
expect(usages).toHaveLength(1);
|
||||
expect(usages[0]).toMatchObject({ on: 'asset', key: 'texture' });
|
||||
|
||||
fixture.object(objectId).overrides.texture = texture.id;
|
||||
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(2);
|
||||
expect(findPropertyUsages(fixture.project, texture.id).some((u) => u.on === 'object')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unrelated ids and an empty reference', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const texture = addTexture(fixture);
|
||||
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
|
||||
|
||||
expect(findPropertyUsages(fixture.project, 'other')).toHaveLength(0);
|
||||
// An empty reference must not report every sprite that has no texture.
|
||||
expect(findPropertyUsages(fixture.project, '')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
encodeWithinBudget,
|
||||
type ImportOutcome,
|
||||
importNotice,
|
||||
MAX_TEXTURE_SIDE,
|
||||
MAX_UPLOAD_BYTES,
|
||||
planImageFit,
|
||||
} from '../../src/editor/textureImport';
|
||||
|
||||
/** A blob of exactly `size` bytes – `encodeWithinBudget` only ever looks at the size. */
|
||||
function blobOf(size: number): Blob {
|
||||
return new Blob([new Uint8Array(size)]);
|
||||
}
|
||||
|
||||
function outcome(name: string, error: string | null = null): ImportOutcome {
|
||||
return { name, assetId: error ? null : `id-${name}`, error, reused: false };
|
||||
}
|
||||
|
||||
/** A file whose picture the project already had, so no asset was created for it. */
|
||||
function reused(name: string): ImportOutcome {
|
||||
return { name, assetId: `id-${name}`, error: null, reused: true };
|
||||
}
|
||||
|
||||
describe('planImageFit', () => {
|
||||
it('leaves an image alone that fits in both senses', () => {
|
||||
expect(planImageFit(32, 32, 900)).toEqual({ kind: 'keep' });
|
||||
expect(planImageFit(MAX_TEXTURE_SIDE, 1024, MAX_UPLOAD_BYTES)).toEqual({ kind: 'keep' });
|
||||
});
|
||||
|
||||
it('caps the longest side and keeps the aspect ratio', () => {
|
||||
expect(planImageFit(4000, 3000, 3_000_000)).toEqual({ kind: 'reencode', width: 2048, height: 1536 });
|
||||
expect(planImageFit(1000, 4096, 100)).toEqual({ kind: 'reencode', width: 500, height: 2048 });
|
||||
});
|
||||
|
||||
it('re-encodes at the original size when only the byte count is over budget', () => {
|
||||
expect(planImageFit(800, 600, MAX_UPLOAD_BYTES + 1)).toEqual({
|
||||
kind: 'reencode',
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it('never scales a picture away to nothing', () => {
|
||||
expect(planImageFit(20000, 3, 9_000_000)).toEqual({ kind: 'reencode', width: 2048, height: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeWithinBudget', () => {
|
||||
it('stops at the first quality that fits', async () => {
|
||||
const encode = vi.fn(() => Promise.resolve(blobOf(MAX_UPLOAD_BYTES - 1)));
|
||||
const blob = await encodeWithinBudget(encode);
|
||||
expect(blob.size).toBe(MAX_UPLOAD_BYTES - 1);
|
||||
expect(encode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('steps the quality down until the result fits', async () => {
|
||||
const sizes = [MAX_UPLOAD_BYTES + 1000, MAX_UPLOAD_BYTES - 1000];
|
||||
const encode = vi.fn((_quality: number) => Promise.resolve(blobOf(sizes.shift() ?? 0)));
|
||||
const blob = await encodeWithinBudget(encode);
|
||||
expect(blob.size).toBe(MAX_UPLOAD_BYTES - 1000);
|
||||
expect(encode).toHaveBeenCalledTimes(2);
|
||||
// Decreasing quality, so a later attempt is never larger than an earlier one.
|
||||
const qualities = encode.mock.calls.map(([quality]) => quality);
|
||||
expect(qualities).toEqual([...qualities].sort((a, b) => b - a));
|
||||
});
|
||||
|
||||
it('returns the last attempt when nothing fits, so the caller can say how far off it was', async () => {
|
||||
const encode = vi.fn(() => Promise.resolve(blobOf(MAX_UPLOAD_BYTES + 5)));
|
||||
const blob = await encodeWithinBudget(encode);
|
||||
expect(blob.size).toBe(MAX_UPLOAD_BYTES + 5);
|
||||
expect(encode.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importNotice', () => {
|
||||
it('counts what arrived', () => {
|
||||
expect(importNotice([outcome('a.png')])).toBe('Imported 1 image.');
|
||||
expect(importNotice([outcome('a.png'), outcome('b.png')])).toBe('Imported 2 images.');
|
||||
});
|
||||
|
||||
it('names the first casualty and how many followed', () => {
|
||||
expect(importNotice([outcome('a.png', 'too large')])).toBe('Could not import "a.png": too large');
|
||||
expect(importNotice([outcome('a.png', 'too large'), outcome('b.png', 'broken')])).toBe(
|
||||
'Could not import "a.png": too large (and 1 more)',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a partial import as both', () => {
|
||||
expect(importNotice([outcome('a.png'), outcome('b.png', 'too large')])).toBe(
|
||||
'Imported 1 of 2. Could not import "b.png": too large',
|
||||
);
|
||||
});
|
||||
|
||||
it('says so when the picture was already in the project', () => {
|
||||
// "Imported 1 image" would send the user looking for an asset that was never created.
|
||||
expect(importNotice([reused('a.png')])).toBe('That image is already in the project.');
|
||||
expect(importNotice([reused('a.png'), reused('b.png')])).toBe(
|
||||
'Those 2 images are already in the project.',
|
||||
);
|
||||
expect(importNotice([outcome('a.png'), reused('b.png')])).toBe(
|
||||
'Imported 1 image. One was already in the project.',
|
||||
);
|
||||
expect(importNotice([outcome('a.png'), reused('b.png'), outcome('c.png', 'broken')])).toBe(
|
||||
'Imported 2 of 3. One was already in the project. Could not import "c.png": broken',
|
||||
);
|
||||
});
|
||||
});
|
||||
+27
-8
@@ -19,21 +19,36 @@ export async function assets(t) {
|
||||
check('+ menu creates a sprite asset', names.includes('Sprite'), JSON.stringify(names));
|
||||
await shot('12-new-asset');
|
||||
|
||||
// 12. Asset defaults: the Crate asset has height 60 (schema default 100), width and color at
|
||||
// their defaults → exactly one reset button is visible. Clicking it puts height back to
|
||||
// 100 and hides the button; undo brings 60 back.
|
||||
// 12. Asset defaults: the Crate asset deviates in two properties – height 60 (schema default
|
||||
// 100) and its red colour (the sprite default is white, i.e. "no tint") – so the rows
|
||||
// carry two resets. Resetting height puts it back to 100 and removes *its* button while
|
||||
// the colour keeps one; undo brings 60 back. Rows run Enabled, Width, Height, Color,
|
||||
// Texture, so height's reset is the first and colour's the last.
|
||||
await select({ kind: 'asset', assetId: 'crate' });
|
||||
await sleep(200);
|
||||
const resetButton = await evaluate(`__gm.locate('↺')`);
|
||||
check('asset inspector shows a reset for the non-default height', Boolean(resetButton));
|
||||
await click(resetButton.x, resetButton.y);
|
||||
const heightReset = await evaluate(`__gm.locate('↺')`);
|
||||
const colorReset = await evaluate(`__gm.locate('↺', true)`);
|
||||
check('asset inspector shows a reset per deviating property', Boolean(heightReset));
|
||||
check(
|
||||
'the two deviations have two separate resets',
|
||||
Boolean(colorReset) && colorReset.y > heightReset.y,
|
||||
JSON.stringify({ heightReset, colorReset }),
|
||||
);
|
||||
await click(heightReset.x, heightReset.y);
|
||||
await sleep(100);
|
||||
check(
|
||||
'reset puts the asset property back to its default',
|
||||
(await asset('crate')).props.height === 100,
|
||||
`height=${(await asset('crate')).props.height}`,
|
||||
);
|
||||
check('reset button hidden once the value is at default', !(await evaluate(`__gm.locate('↺')`)));
|
||||
const remaining = await evaluate(`__gm.locate('↺')`);
|
||||
check(
|
||||
'the reset of the property now at its default is gone, the other one stays',
|
||||
Boolean(remaining) &&
|
||||
remaining.y === colorReset.y &&
|
||||
remaining.y === (await evaluate(`__gm.locate('↺', true)`)).y,
|
||||
JSON.stringify({ remaining, colorReset }),
|
||||
);
|
||||
await shot('15-asset-reset');
|
||||
await undo();
|
||||
check(
|
||||
@@ -41,5 +56,9 @@ export async function assets(t) {
|
||||
(await asset('crate')).props.height === 60,
|
||||
`height=${(await asset('crate')).props.height}`,
|
||||
);
|
||||
check('reset button visible again after undo', Boolean(await evaluate(`__gm.locate('↺')`)));
|
||||
check(
|
||||
'the height reset is back after undo',
|
||||
(await evaluate(`__gm.locate('↺')`))?.y === heightReset.y,
|
||||
JSON.stringify(await evaluate(`__gm.locate('↺')`)),
|
||||
);
|
||||
}
|
||||
+20
-4
@@ -38,16 +38,32 @@ export async function menus(t) {
|
||||
await shot('31-long-parent-menu');
|
||||
|
||||
// 18a. The entries scroll under the wheel; the search box above them does not move.
|
||||
const firstBefore = await locate('None', true);
|
||||
//
|
||||
// Anchored on a filler row rather than the list's "None" entry: "None" is also what an empty
|
||||
// dropdown reads, so `locate('None', true)` could just as well find a *field* in the
|
||||
// inspector behind the menu – one that never moves, whatever the wheel does. "Filler 1"
|
||||
// exists only inside this list.
|
||||
const firstBefore = await locate('Filler 1', true);
|
||||
const searchBefore = await locate('Search…', true);
|
||||
// Move onto the list before turning the wheel. A wheel event is delivered to whatever is
|
||||
// *hovered*, and the last thing the pointer touched was the field above the menu – without
|
||||
// this the scroll is routed at that field instead, which is what a user could never do.
|
||||
await mouse('mouseMoved', rect.x + rect.w / 2, rect.y + rect.h / 2);
|
||||
await sleep(50);
|
||||
await mouse('mouseWheel', rect.x + rect.w / 2, rect.y + rect.h / 2, 'none', { deltaX: 0, deltaY: 240 });
|
||||
await sleep(150);
|
||||
const firstAfter = await locate('None', true);
|
||||
// Read twice, and wait properly. The scroll lands a frame or two later than one might
|
||||
// expect: measured here, the list had not moved 40 ms after the wheel and had moved at
|
||||
// 340 ms. A single early reading made this check fail in the full suite while passing on
|
||||
// its own — the two readings keep that visible instead of turning it back into a mystery.
|
||||
await sleep(40);
|
||||
const rightAfter = await locate('Filler 1', true);
|
||||
await sleep(300);
|
||||
const firstAfter = await locate('Filler 1', true);
|
||||
const searchAfter = await locate('Search…', true);
|
||||
check(
|
||||
'the wheel scrolls the entries',
|
||||
firstAfter.y < firstBefore.y - 100,
|
||||
JSON.stringify({ firstBefore, firstAfter }),
|
||||
JSON.stringify({ firstBefore, rightAfter, firstAfter }),
|
||||
);
|
||||
check('the search box stays put while the entries scroll', searchAfter.y === searchBefore.y);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Uploading a picture and wearing it. Runs last because it adds an asset and repaints the
|
||||
* scene; the checks that count assets live in earlier steps.
|
||||
*
|
||||
* The image is built in the page and handed over as a paste, which is a real user gesture and
|
||||
* exercises the whole chain: decode → upload → the server storing it under its content hash →
|
||||
* the asset → the renderer. Nothing here is stubbed.
|
||||
*/
|
||||
|
||||
/** Paints a four-quadrant PNG in the page and pastes it. The quadrants make the mapping readable. */
|
||||
const PASTE_IMAGE = `(async () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 48;
|
||||
const g = canvas.getContext('2d');
|
||||
g.fillStyle = '#e63c3c'; g.fillRect(0, 0, 32, 24);
|
||||
g.fillStyle = '#3cc85a'; g.fillRect(32, 0, 32, 24);
|
||||
g.fillStyle = '#466ef0'; g.fillRect(0, 24, 32, 24);
|
||||
g.fillStyle = '#f5f5f5'; g.fillRect(32, 24, 32, 24);
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
const transfer = new DataTransfer();
|
||||
transfer.items.add(new File([blob], 'pasted.png', { type: 'image/png' }));
|
||||
window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true }));
|
||||
})()`;
|
||||
|
||||
/** How far apart two `#rrggbb` colours are, summed over the channels. */
|
||||
function colourDistance(a, b) {
|
||||
const channels = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
||||
const [ar, ag, ab] = channels(a);
|
||||
const [br, bg, bb] = channels(b);
|
||||
return Math.abs(ar - br) + Math.abs(ag - bg) + Math.abs(ab - bb);
|
||||
}
|
||||
|
||||
export async function textures(t) {
|
||||
const { check, sleep, json, evaluate, shot, mutate, press } = t;
|
||||
|
||||
// The previous step leaves a picker open and a text field focused. Both matter here: paste is
|
||||
// deliberately ignored while an input has focus, so that Ctrl+V inside a field still edits
|
||||
// text. Closing and blurring is what clicking the canvas would do.
|
||||
await press('Escape');
|
||||
await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()');
|
||||
await sleep(200);
|
||||
|
||||
// 19. Pasting an image imports it: uploaded, stored under its hash, and made an asset.
|
||||
await evaluate(PASTE_IMAGE);
|
||||
await sleep(2500);
|
||||
|
||||
const imported = await json(
|
||||
"__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => ({ id: a.id, name: a.name, file: a.file, w: a.width, h: a.height }))",
|
||||
);
|
||||
check('pasting an image creates a texture asset', imported.length === 1, JSON.stringify(imported));
|
||||
const texture = imported[0] ?? {};
|
||||
check(
|
||||
'the file is named after its content hash',
|
||||
/^[a-f0-9]{64}\.png$/.test(texture.file ?? ''),
|
||||
String(texture.file),
|
||||
);
|
||||
check(
|
||||
'the pixel size comes back from the server',
|
||||
texture.w === 64 && texture.h === 48,
|
||||
JSON.stringify({ w: texture.w, h: texture.h }),
|
||||
);
|
||||
|
||||
// 19a. Importing the same picture again is a no-op with an explanation: the server hands back
|
||||
// the file it already has (the name is the hash), so the existing asset is reused rather
|
||||
// than a second one made for the same bytes.
|
||||
await evaluate(PASTE_IMAGE);
|
||||
await sleep(2500);
|
||||
const again = await json(
|
||||
"__gm.state.project.assets.filter((a) => a.type === 'texture').map((a) => a.file)",
|
||||
);
|
||||
check(
|
||||
'the same image imported twice stays one asset on one file',
|
||||
again.length === 1,
|
||||
JSON.stringify(again),
|
||||
);
|
||||
check(
|
||||
'and the notice says it was already there',
|
||||
(await json('__gm.state.notice')) === 'That image is already in the project.',
|
||||
JSON.stringify(await json('__gm.state.notice')),
|
||||
);
|
||||
|
||||
// 19b. The picture is actually drawn – a sprite wearing it is no longer one flat colour.
|
||||
//
|
||||
// On a sprite of its own, deliberately: eleven steps of edits have been through the fixture's
|
||||
// objects by now (resized, re-parented, cloned forty times, covered by colliders and gizmos),
|
||||
// and a colour read off the screen must not depend on any of that. This one is white (so the
|
||||
// picture shows its own colours instead of a tint), large enough that a quarter-width sample
|
||||
// sits far from every quadrant border, and on top of everything else.
|
||||
// Both are clones of things that already exist rather than hand-written literals: a scene
|
||||
// object has fields a literal keeps forgetting (`editorOffset` was the one that bit here –
|
||||
// the position went NaN and the object was culled without an error).
|
||||
await mutate(`
|
||||
const project = __gm.state.project;
|
||||
const sprite = structuredClone(project.assets.find((a) => a.type === 'sprite'));
|
||||
sprite.id = 'tex-sprite';
|
||||
sprite.name = 'Textured';
|
||||
sprite.props = { ...sprite.props, width: 160, height: 120, color: 0xffffff, texture: ${JSON.stringify(texture.id)} };
|
||||
project.assets.push(sprite);
|
||||
|
||||
const scene = __gm.state.activeScene;
|
||||
const object = structuredClone(scene.objects.find((o) => o.id === 'o1'));
|
||||
object.id = 'otex';
|
||||
object.name = 'Textured';
|
||||
object.assetId = 'tex-sprite';
|
||||
object.templateId = '';
|
||||
object.overrides = {};
|
||||
object.position = { ...object.position, z: 900 };
|
||||
scene.objects.push(object);
|
||||
__gm.state.select(null)`);
|
||||
await sleep(1200);
|
||||
|
||||
const box = await json("__gm.objectRect('otex')");
|
||||
check('the textured sprite is on screen', Boolean(box) && box.w > 8, JSON.stringify(box));
|
||||
if (box) {
|
||||
// The quadrants sit at the corners of the sprite's box; sample well inside each one.
|
||||
const at = async (fx, fy) =>
|
||||
json(`__gm.pixelAt(${Math.round(box.x + fx * box.w)}, ${Math.round(box.y + fy * box.h)})`);
|
||||
const [topLeft, topRight, bottomLeft, bottomRight] = [
|
||||
await at(-0.3, -0.3),
|
||||
await at(0.3, -0.3),
|
||||
await at(-0.3, 0.3),
|
||||
await at(0.3, 0.3),
|
||||
];
|
||||
const sampled = { topLeft, topRight, bottomLeft, bottomRight };
|
||||
check(
|
||||
'each quadrant of the picture lands where the picture has it',
|
||||
colourDistance(topLeft, '#e63c3c') < 60 &&
|
||||
colourDistance(topRight, '#3cc85a') < 60 &&
|
||||
colourDistance(bottomLeft, '#466ef0') < 60 &&
|
||||
colourDistance(bottomRight, '#f5f5f5') < 60,
|
||||
JSON.stringify(sampled),
|
||||
);
|
||||
check(
|
||||
'the sprite is a picture rather than one flat colour',
|
||||
new Set(Object.values(sampled)).size === 4,
|
||||
JSON.stringify(sampled),
|
||||
);
|
||||
await shot('32-textured-sprite');
|
||||
|
||||
// 19c. The colour multiplies the picture, so a tinted white quadrant stops being white.
|
||||
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.color = 0x3366ff");
|
||||
await sleep(600);
|
||||
const tinted = await at(0.3, 0.3);
|
||||
check(
|
||||
'the sprite colour tints the picture',
|
||||
colourDistance(tinted, '#f5f5f5') > 100 && colourDistance(tinted, '#3366ff') < 90,
|
||||
JSON.stringify({ was: bottomRight, tinted }),
|
||||
);
|
||||
|
||||
// 19d. A reference that no longer resolves falls back to the plain colour instead of failing.
|
||||
await mutate("__gm.state.project.assets.find((a) => a.id === 'tex-sprite').props.texture = 'gone'");
|
||||
await sleep(600);
|
||||
const fallback = await at(0.3, 0.3);
|
||||
check(
|
||||
'a dangling texture falls back to the plain colour',
|
||||
colourDistance(fallback, '#3366ff') < 60,
|
||||
JSON.stringify({ fallback }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { colliders } from './08-colliders.mjs';
|
||||
import { containers } from './09-containers.mjs';
|
||||
import { textAndEnabled } from './10-text-and-enabled.mjs';
|
||||
import { menus } from './11-menus.mjs';
|
||||
import { textures } from './12-textures.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -27,4 +28,5 @@ export const STEPS = [
|
||||
containers,
|
||||
textAndEnabled,
|
||||
menus,
|
||||
textures,
|
||||
];
|
||||
Reference in new issue
Block a user