Add textures for sprites

Images are uploaded to a content-addressed blob endpoint and stored as
data/textures/<sha256>.<ext>; the project only keeps the file name. The
sprite points at a texture asset through an ordinary property of the new
kind `asset`, so instance overrides, "Apply to container", "Reset to
asset", undo and `Set property` work without any extra code. The new kind
also replaces the ad-hoc asset picker the graph nodes faked as an enum.

Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V.
Images wider than 2048px are downscaled client-side and re-encoded to
WebP if needed, because nginx caps uploads at 1MB; an image that already
fits is uploaded byte for byte so pixel art is never resampled.

The sprite colour default changes from red to white, since colour tints
the texture multiplicatively and a tint of white shows an image as it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 13:43:08 +02:00
1 parent 6168df41f8
commit 5a6af74e44
47 files changed
+1995 -110

No files matched your search

+45 -5
View File
@@ -1,5 +1,5 @@
import type { Asset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import type { PropertyBag, PropertyValue } from '../model/Properties';
/**
@@ -14,7 +14,16 @@ export type PropertyDef =
/** One of a fixed set of string values, shown as a dropdown. */
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
| { key: string; label: string; kind: 'key'; default: string };
| { key: string; label: string; kind: 'key'; default: string }
/**
* A reference to another asset, stored as its id (`''` = none) and picked from a
* searchable dropdown. `assetTypes` limits what the picker offers; without it every
* placeable asset is offered, which is what graph nodes acting on an object want.
*
* The id is not validated on load – a reference can dangle, exactly like a graph node's
* asset parameter, and the places that read one have to cope with that.
*/
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] };
export type PropertySchema = readonly PropertyDef[];
@@ -50,7 +59,31 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
sprite: [
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
{ key: 'color', label: 'Color', kind: 'color', default: 0xe0553f },
/*
* The colour of the sprite: the fill of the box while it has no picture, and a multiplier
* on the picture once it has one. White therefore means "no tint" – which is why it is the
* default. A coloured default would silently stain every texture the moment it is applied,
* and the first thing anyone would do is set it back to white.
*
* Tinting is worth keeping rather than suppressing for textured sprites: it is how one
* white coin picture becomes ten differently coloured coins, per instance, for free.
*/
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
{ key: 'texture', label: 'Texture', kind: 'asset', default: '', assetTypes: ['texture'] },
],
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property:
// only how it is sampled is worth deciding, and that decision is the whole schema.
texture: [
{
key: 'filter',
label: 'Filter',
kind: 'enum',
default: 'linear',
options: [
{ value: 'linear', label: 'Smooth' },
{ value: 'nearest', label: 'Pixel art' },
],
},
],
// Behaviors declare their own properties (variables) per asset – see `schemaForAsset`.
behavior: [],
@@ -91,11 +124,15 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
};
/** Type schema with the common properties in front – the effective schema of an asset type. */
/**
* Type schema with the common properties in front – the effective schema of an asset type.
* Only placeable types get them: `enabled` switches off a placed object, and a scene or a
* texture is never placed, so the switch would have nothing to act on.
*/
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
type,
type === 'scene' ? ASSET_SCHEMAS[type] : [...COMMON_PROPS, ...ASSET_SCHEMAS[type]],
isPlaceableType(type) ? [...COMMON_PROPS, ...ASSET_SCHEMAS[type]] : ASSET_SCHEMAS[type],
]),
) as Record<AssetType, PropertySchema>;
@@ -153,8 +190,11 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
}
case 'boolean':
return Boolean(value);
// An asset reference is an id like the other two are plain strings; whether it still
// resolves is not decidable from a single property definition.
case 'text':
case 'key':
case 'asset':
return String(value);
case 'enum': {
const s = String(value);
+38
View File
@@ -0,0 +1,38 @@
import type { Asset } from '../model/Asset';
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
import { findAsset, type Project } from '../model/Project';
/** Shown for an `asset` property that references nothing. */
export const NO_ASSET_LABEL = 'None';
/**
* How an `asset` reference reads wherever it is displayed: the referenced asset's name, "None"
* for the empty id, and `<id>?` when the id no longer resolves.
*
* A dangling reference keeps showing its id instead of falling back to "None": the id is the
* only trace left of what was meant, and blanking it would make a deleted asset look like a
* field that was never filled in. `project` may be null while a view has no project yet.
*/
export function assetRefLabel(project: Project | null, id: string): string {
if (id === '') return NO_ASSET_LABEL;
return (project ? findAsset(project, id) : undefined)?.name ?? `${id}?`;
}
/**
* The assets an `asset` property offers, in menu order. `assetTypes` restricts them to those
* types; without it every *placeable* asset is offered, which is what a graph node acting on
* an object means by "asset". Sorted by name (then type) so the order does not depend on when
* the assets happened to be created.
*/
export function assetRefChoices(project: Project, assetTypes?: readonly AssetType[]): Asset[] {
const allowed = (asset: Asset): boolean =>
assetTypes ? assetTypes.includes(asset.type) : isPlaceableType(asset.type);
return project.assets
.filter(allowed)
.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
}
/** Menu entry for one candidate – the type disambiguates assets that share a name. */
export function assetChoiceLabel(asset: Asset): string {
return `${asset.name} (${asset.type})`;
}