diff --git a/.gitignore b/.gitignore
index 7124339a..47d118f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index fb3f7407..45e7e202 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@ Live: · Widget playground: MAX_BODY_BYTES) {
- Response::error('Request body too large', 413);
- }
-
+/** @param list $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 $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);
diff --git a/api/lib/Response.php b/api/lib/Response.php
index a03fe844..d1d04f74 100644
--- a/api/lib/Response.php
+++ b/api/lib/Response.php
@@ -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;
+ }
}
diff --git a/api/lib/TextureStore.php b/api/lib/TextureStore.php
new file mode 100644
index 00000000..e88d861c
--- /dev/null
+++ b/api/lib/TextureStore.php
@@ -0,0 +1,141 @@
+ '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 */
+ 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
+{
+}
diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts
index 76cd2998..55423822 100644
--- a/src/core/commands/assetCommands.ts
+++ b/src/core/commands/assetCommands.ts
@@ -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
diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts
index 4af432e6..cc9a486a 100644
--- a/src/core/graph/nodes/shared.ts
+++ b/src/core/graph/nodes/shared.ts
@@ -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 --------------------------------------------------------------------------
diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts
index c2c37595..98bcce83 100644
--- a/src/core/model/Asset.ts
+++ b/src/core/model/Asset.ts
@@ -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';
+}
diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts
index 25b39f15..cd01908f 100644
--- a/src/core/model/AssetTypes.ts
+++ b/src/core/model/AssetTypes.ts
@@ -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 = {
behavior: 'Behavior',
collider: 'Collider',
container: 'Container',
+ texture: 'Texture',
};
-/** Types that can be placed into a scene (everything except scenes themselves). */
-export type PlaceableAssetType = Exclude;
+/**
+ * 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;
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
- return type !== 'scene';
+ return type !== 'scene' && type !== 'texture';
}
export function isAssetType(value: unknown): value is AssetType {
diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts
index dd164ed5..40466134 100644
--- a/src/core/model/Project.ts
+++ b/src/core/model/Project.ts
@@ -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();
+ 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;
+}
diff --git a/src/core/model/texture.ts b/src/core/model/texture.ts
new file mode 100644
index 00000000..25e2f4af
--- /dev/null
+++ b/src/core/model/texture.ts
@@ -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' };
+}
diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts
index 22019348..408abe17 100644
--- a/src/core/properties/PropertySchema.ts
+++ b/src/core/properties/PropertySchema.ts
@@ -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 = {
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 = {
],
};
-/** 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 = 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;
@@ -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);
diff --git a/src/core/properties/assetRef.ts b/src/core/properties/assetRef.ts
new file mode 100644
index 00000000..77236590
--- /dev/null
+++ b/src/core/properties/assetRef.ts
@@ -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 `?` 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})`;
+}
diff --git a/src/core/store/HttpProjectStore.ts b/src/core/store/HttpProjectStore.ts
index 5a40e9fd..3f9e8a86 100644
--- a/src/core/store/HttpProjectStore.ts
+++ b/src/core/store/HttpProjectStore.ts
@@ -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;
diff --git a/src/core/store/HttpTextureStore.ts b/src/core/store/HttpTextureStore.ts
new file mode 100644
index 00000000..cd992eed
--- /dev/null
+++ b/src/core/store/HttpTextureStore.ts
@@ -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 {
+ return this.request('POST', 'textures', { bytes, type });
+ }
+
+ async list(): Promise {
+ return this.request('GET', 'textures');
+ }
+
+ async delete(file: string): Promise {
+ await this.request('DELETE', `textures/${encodeURIComponent(file)}`);
+ }
+
+ /** One round trip; every failure becomes an `Error` carrying the server's message. */
+ private async request(
+ method: string,
+ route: string,
+ payload?: { bytes: Blob | ArrayBuffer; type: string },
+ ): Promise {
+ 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;
+ }
+}
diff --git a/src/core/store/TextureStore.ts b/src/core/store/TextureStore.ts
new file mode 100644
index 00000000..4ec2f61b
--- /dev/null
+++ b/src/core/store/TextureStore.ts
@@ -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;
+ list(): Promise;
+ delete(file: string): Promise;
+}
diff --git a/src/core/store/http.ts b/src/core/store/http.ts
new file mode 100644
index 00000000..05efd610
--- /dev/null
+++ b/src/core/store/http.ts
@@ -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 = {
+ 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}`);
+}
diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts
index 94c7ff57..2c0b6d98 100644
--- a/src/core/store/serialize.ts
+++ b/src/core/store/serialize.ts
@@ -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): { 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 = {};
diff --git a/src/editor/App.ts b/src/editor/App.ts
index d03ead3e..043a425c 100644
--- a/src/editor/App.ts
+++ b/src/editor/App.ts
@@ -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 {
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();
}
diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts
index 2ea785de..1e0d2cf5 100644
--- a/src/editor/Editor.ts
+++ b/src/editor/Editor.ts
@@ -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.
diff --git a/src/editor/actions.ts b/src/editor/actions.ts
index 77554b13..3c18c867 100644
--- a/src/editor/actions.ts
+++ b/src/editor/actions.ts
@@ -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)) },
diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts
index 65488830..ae7acbde 100644
--- a/src/editor/debugHook.ts
+++ b/src/editor/debugHook.ts
@@ -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),
diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts
index 7ba3b127..aae20d40 100644
--- a/src/editor/graph/NodeView.ts
+++ b/src/editor/graph/NodeView.ts
@@ -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' &&
diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts
index 6ccfc120..76db9c66 100644
--- a/src/editor/inspector/Inspector.ts
+++ b/src/editor/inspector/Inspector.ts
@@ -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,
diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts
index 433b0124..b652e957 100644
--- a/src/editor/inspector/assetSection.ts
+++ b/src/editor/inspector/assetSection.ts
@@ -29,8 +29,10 @@ const VARIABLE_KIND_LABELS: Record = {
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> = {
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(
diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts
index 087dc6e1..1e364fb4 100644
--- a/src/editor/inspector/fieldFactory.ts
+++ b/src/editor/inspector/fieldFactory.ts
@@ -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));
+ },
+ };
+ }
}
}
diff --git a/src/editor/inspector/host.ts b/src/editor/inspector/host.ts
index 04772797..f3b535b7 100644
--- a/src/editor/inspector/host.ts
+++ b/src/editor/inspector/host.ts
@@ -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. */
diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts
index 2d7f7f68..c0524694 100644
--- a/src/editor/inspector/nodeSection.ts
+++ b/src/editor/inspector/nodeSection.ts
@@ -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. */
diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts
index 83365b9c..adefef25 100644
--- a/src/editor/inspector/objectSection.ts
+++ b/src/editor/inspector/objectSection.ts
@@ -264,6 +264,7 @@ function componentRow(
): 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(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(host, {
diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts
index e5bd170e..82a16fbd 100644
--- a/src/editor/panels/AssetPanel.ts
+++ b/src/editor/panels/AssetPanel.ts
@@ -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);
diff --git a/src/editor/textureImport.ts b/src/editor/textureImport.ts
new file mode 100644
index 00000000..e08acb74
--- /dev/null
+++ b/src/editor/textureImport.ts
@@ -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): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 `` lives only for this one call.
+ */
+export function pickImageFiles(): Promise {
+ 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;
+}
diff --git a/src/viewport/TextureLibrary.ts b/src/viewport/TextureLibrary.ts
new file mode 100644
index 00000000..87fdd201
--- /dev/null
+++ b/src/viewport/TextureLibrary.ts
@@ -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();
+ private readonly loading = new Set();
+ /** Files whose load failed; retried never, so one broken reference cannot spam the network. */
+ private readonly failed = new Set();
+ 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(`${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();
diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts
index 2ee83f80..5a8254d9 100644
--- a/src/viewport/WorldViewport.ts
+++ b/src/viewport/WorldViewport.ts
@@ -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) {
diff --git a/src/viewport/items.ts b/src/viewport/items.ts
index e2fc7c08..9e4648e1 100644
--- a/src/viewport/items.ts
+++ b/src/viewport/items.ts
@@ -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 };
}
diff --git a/src/viewport/symbols.ts b/src/viewport/symbols.ts
index 2fa38799..31eb5d4c 100644
--- a/src/viewport/symbols.ts
+++ b/src/viewport/symbols.ts
@@ -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)
diff --git a/tests/core/asset-ref.test.ts b/tests/core/asset-ref.test.ts
new file mode 100644
index 00000000..dfd938cc
--- /dev/null
+++ b/tests/core/asset-ref.test.ts
@@ -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 } {
+ 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();
+ }
+ });
+});
diff --git a/tests/core/commands.test.ts b/tests/core/commands.test.ts
index 1e6b9ebb..3070e0b1 100644
--- a/tests/core/commands.test.ts
+++ b/tests/core/commands.test.ts
@@ -26,7 +26,8 @@ describe('asset commands', () => {
enabled: true,
width: 100,
height: 100,
- color: 0xe0553f,
+ color: 0xffffff,
+ texture: '',
});
});
diff --git a/tests/core/http-store.test.ts b/tests/core/http-store.test.ts
new file mode 100644
index 00000000..a7dc0be4
--- /dev/null
+++ b/tests/core/http-store.test.ts
@@ -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 = '413 Request Entity Too Large';
+
+/** 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(() => 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('hello', { 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');
+ });
+});
diff --git a/tests/core/resolve.test.ts b/tests/core/resolve.test.ts
index 9ddf706e..b88309d7 100644
--- a/tests/core/resolve.test.ts
+++ b/tests/core/resolve.test.ts
@@ -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;
diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts
index a8b032d4..9a228df7 100644
--- a/tests/core/serialize.test.ts
+++ b/tests/core/serialize.test.ts
@@ -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: '',
+ });
});
});
diff --git a/tests/core/texture-commands.test.ts b/tests/core/texture-commands.test.ts
new file mode 100644
index 00000000..86a1f732
--- /dev/null
+++ b/tests/core/texture-commands.test.ts
@@ -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);
+ });
+});
diff --git a/tests/core/texture.test.ts b/tests/core/texture.test.ts
new file mode 100644
index 00000000..2ec0b970
--- /dev/null
+++ b/tests/core/texture.test.ts
@@ -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, 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);
+ });
+});
diff --git a/tests/editor/texture-import.test.ts b/tests/editor/texture-import.test.ts
new file mode 100644
index 00000000..6f72fd36
--- /dev/null
+++ b/tests/editor/texture-import.test.ts
@@ -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',
+ );
+ });
+});
diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs
index 036c0701..90eb0e97 100644
--- a/tools/e2e/04-assets.mjs
+++ b/tools/e2e/04-assets.mjs
@@ -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('↺')`)),
+ );
}
diff --git a/tools/e2e/11-menus.mjs b/tools/e2e/11-menus.mjs
index 765472ed..f7f91bb9 100644
--- a/tools/e2e/11-menus.mjs
+++ b/tools/e2e/11-menus.mjs
@@ -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);
}
diff --git a/tools/e2e/12-textures.mjs b/tools/e2e/12-textures.mjs
new file mode 100644
index 00000000..9cd0ea26
--- /dev/null
+++ b/tools/e2e/12-textures.mjs
@@ -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 }),
+ );
+ }
+}
diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs
index 8ff140f3..718b45ac 100644
--- a/tools/e2e/index.mjs
+++ b/tools/e2e/index.mjs
@@ -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,
];