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, ];