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

+17 -2
View File
@@ -1,16 +1,31 @@
import type { Application } from 'pixi.js';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import { HttpTextureStore } from '../core/store/HttpTextureStore';
import type { VStack } from '../ui';
import { textureLibrary } from '../viewport/TextureLibrary';
import { installDebugHook } from './debugHook';
import { Editor } from './Editor';
import { EditorState } from './state';
const API_ENDPOINT = '/game-maker/api/index.php';
/** Wires state, store and the editor composition together and opens the first project. */
/**
* Where uploaded images live. The API writes them; the browser reads them straight off disk,
* with no PHP in the path – they are static files whose names are their content hashes.
*/
const TEXTURE_BASE = '/game-maker/data/textures';
/** Wires state, stores and the editor composition together and opens the first project. */
export async function startEditor(app: Application, root: VStack): Promise<void> {
const state = new EditorState();
const editor = new Editor(app, root, state, new HttpProjectStore(API_ENDPOINT));
textureLibrary.configure(TEXTURE_BASE);
const editor = new Editor(
app,
root,
state,
new HttpProjectStore(API_ENDPOINT),
new HttpTextureStore(API_ENDPOINT),
);
installDebugHook(app, state, editor);
await editor.boot();
}
+13 -2
View File
@@ -1,5 +1,6 @@
import type { Application } from 'pixi.js';
import type { ProjectStore } from '../core/store/ProjectStore';
import type { TextureStore } from '../core/store/TextureStore';
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
import { GameViewport } from '../viewport/GameViewport';
import { SceneViewport } from '../viewport/SceneViewport';
@@ -13,6 +14,7 @@ import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
import { ProjectSession } from './ProjectSession';
import { installShortcuts } from './shortcuts';
import type { EditorState } from './state';
import { installTextureImport } from './textureImport';
export type CentreTab = 'scene' | 'graph' | 'game';
@@ -51,7 +53,13 @@ export class Editor implements PlayHost {
private leftWidth = 240;
private rightWidth = 300;
constructor(app: Application, root: VStack, state: EditorState, store: ProjectStore) {
constructor(
app: Application,
root: VStack,
state: EditorState,
store: ProjectStore,
textures: TextureStore,
) {
this.viewport = new SceneViewport(state);
this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state);
@@ -75,7 +83,10 @@ export class Editor implements PlayHost {
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter(), textures), 1);
// Dropping an image on the canvas or pasting one imports it, wherever the pointer is –
// the same import the asset panel's "Texture" entry runs.
installTextureImport(app.canvas, { state, textures });
this.leftColumn.add(this.hierarchy, 1);
// Centre: a tab strip (Scene | Game | Graph) above the scene view, the game view or the graph editor.
+11 -6
View File
@@ -6,7 +6,7 @@ import { DeleteAssetCommand } from '../core/commands/assetCommands';
import { AddColliderCommand } from '../core/commands/colliderCommands';
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { findAsset, findUsages } from '../core/model/Project';
import { findAsset, findPropertyUsages, findUsages } from '../core/model/Project';
import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
@@ -40,14 +40,19 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
const asset = findAsset(state.project, assetId);
if (!asset) return;
const usages = findUsages(state.project, assetId).length;
// A texture is never placed – it is worn through a property – so counting placed instances
// would report "unused" for a picture a dozen sprites are showing.
const references = findPropertyUsages(state.project, assetId).length;
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: usages === 0
? 'This cannot be undone with Ctrl+Z after reload.'
: asset.type === 'container'
? `${usages} placed container(s) are removed together with their content.`
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
: references > 0
? `${references} sprite(s) reference it and fall back to their plain colour.`
: usages === 0
? 'This cannot be undone with Ctrl+Z after reload.'
: asset.type === 'container'
? `${usages} placed container(s) are removed together with their content.`
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
new Dialog(`Delete "${asset.name}"?`, detail, [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
+17 -1
View File
@@ -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),
+3 -5
View File
@@ -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' &&
+2
View File
@@ -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,
+6 -3
View File
@@ -29,8 +29,10 @@ const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
boolean: 'Boolean',
text: 'Text',
};
// Keyed by the wide `PropertyDef['kind']` on purpose: `variables` is a `PropertyDef[]`, so a
// document could carry a kind that is not a VariableKind – that renders as '?' rather than blank.
// Keyed by the wide `PropertyDef['kind']` because `variables` is a `PropertyDef[]`. Only the
// three VariableKinds ever reach it – the loader clamps any other kind (normalizeVariables) and
// the "+ Variable" menu offers no other – so the '?' fallback is a safety net, not a case to
// fill in: entries for kinds a variable cannot have (asset, color, enum, key) would be dead.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
@@ -87,6 +89,7 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
const values = resolveAssetProps(asset);
for (const def of schema) {
const row = createFieldRow(
host,
def,
values[def.key] ?? def.default,
assetHandlers(host, asset.id, def),
@@ -153,7 +156,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
},
}),
);
const field = createField(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
const field = createField(host, def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(
+55 -2
View File
@@ -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));
},
};
}
}
}
+7 -3
View File
@@ -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. */
+9 -18
View File
@@ -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. */
+2
View File
@@ -264,6 +264,7 @@ function componentRow<V extends object>(
): void {
const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) });
const row = createFieldRow(
host,
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
previewingHandlers<SceneObject, V>(host, {
@@ -300,6 +301,7 @@ function propertyRow(
): FieldRow {
const bases = () => deviations(host.state, sceneId, objectId, def.key);
const row = createFieldRow(
host,
def,
values[def.key] ?? def.default,
previewingHandlers<SceneObject>(host, {
+17 -1
View File
@@ -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);
+299
View File
@@ -0,0 +1,299 @@
/**
* Getting a picture from the user's machine into the project: pick, drop or paste a file,
* shrink it if it is too big to send, upload the bytes, add a texture asset. The three ways in
* share every step after the file – so a dropped and a picked image behave identically.
*/
import { CreateTextureCommand } from '../core/commands/assetCommands';
import { nextAssetName } from '../core/commands/naming';
import type { TextureAsset } from '../core/model/Asset';
import type { TextureStore } from '../core/store/TextureStore';
import type { EditorState } from './state';
/** What the picker offers and a drop accepts; the server rejects everything else anyway. */
export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
/**
* Longest side a stored texture may have. A 4000×3000 phone photo has no business being a
* sprite texture: it costs 48 MB of video memory to draw something a fraction of that size.
*/
export const MAX_TEXTURE_SIDE = 2048;
/**
* Byte budget for one upload. The API would take 4 MB, but nginx cuts every request off at
* 1 MB and answers with its own HTML page, so nginx is the wall we have to stay under.
*/
export const MAX_UPLOAD_BYTES = 950 * 1024;
/** Tried in order when an image has to be re-encoded; the first result that fits wins. */
const WEBP_QUALITIES = [0.9, 0.75, 0.6] as const;
export type ImagePlan = { kind: 'keep' } | { kind: 'reencode'; width: number; height: number };
/**
* How to get an image within both limits. Re-encoding is a last resort – it would turn crisp
* pixel art into mush – so anything that is already small enough in both senses is sent byte
* for byte as it lies on disk. `bytes` is the encoded file size, not the pixel count.
*/
export function planImageFit(width: number, height: number, bytes: number): ImagePlan {
const scale = Math.min(1, MAX_TEXTURE_SIDE / Math.max(width, height));
if (scale === 1 && bytes <= MAX_UPLOAD_BYTES) return { kind: 'keep' };
return {
kind: 'reencode',
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}
/**
* Encodes at decreasing quality and stops at the first result within `MAX_UPLOAD_BYTES`. When
* none fits, the last (lowest quality) attempt comes back, so the caller can report how far
* off it was. `encode` is a parameter to keep the ladder testable without a canvas.
*/
export async function encodeWithinBudget(encode: (quality: number) => Promise<Blob>): Promise<Blob> {
let result = await encode(WEBP_QUALITIES[0]);
for (const quality of WEBP_QUALITIES.slice(1)) {
if (result.size <= MAX_UPLOAD_BYTES) return result;
result = await encode(quality);
}
return result;
}
export interface PreparedImage {
blob: Blob;
/** Content-Type for the upload; `image/webp` whenever the image had to be re-encoded. */
type: string;
width: number;
height: number;
}
/**
* The bytes to upload for one picked file: the file itself whenever it already fits (see
* `planImageFit`), otherwise a downscaled WebP. Rejects when the file is not a decodable image
* or stays too large even at the lowest quality.
*/
export async function prepareImage(file: File): Promise<PreparedImage> {
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
throw new Error('The file is not a readable image.');
}
try {
const plan = planImageFit(bitmap.width, bitmap.height, file.size);
if (plan.kind === 'keep') {
// Untouched on purpose: re-encoding costs quality and would break pixel art.
return {
blob: file,
type: file.type || 'application/octet-stream',
width: bitmap.width,
height: bitmap.height,
};
}
// Only the first frame of an animated GIF survives this, which is all the renderer ever
// draws – a texture is one still picture.
const canvas = new OffscreenCanvas(plan.width, plan.height);
const context = canvas.getContext('2d');
if (!context) throw new Error('This browser cannot resize images.');
context.drawImage(bitmap, 0, 0, plan.width, plan.height);
const blob = await encodeWithinBudget((quality) => canvas.convertToBlob({ type: 'image/webp', quality }));
if (blob.size > MAX_UPLOAD_BYTES) {
throw new Error(
`The image is too large to upload: still ${formatBytes(blob.size)} at ${plan.width}×${plan.height}. Shrink it before importing.`,
);
}
return { blob, type: 'image/webp', width: plan.width, height: plan.height };
} finally {
bitmap.close();
}
}
export interface TextureImportDeps {
state: EditorState;
textures: TextureStore;
}
/** One file's outcome; exactly one of `assetId` and `error` is set. */
export interface ImportOutcome {
name: string;
assetId: string | null;
error: string | null;
/** The project already had this exact picture, so its existing asset was used instead. */
reused?: boolean;
}
/**
* Imports the files one after another – prepare, upload, create the asset – selects the last
* one that worked and sums the result up as a notice. Sequential on purpose: each asset is
* named against the project as it stands *after* the previous one. A file that fails is
* reported and the remaining ones are still imported.
*/
export async function importImages(
files: readonly File[],
deps: TextureImportDeps,
): Promise<ImportOutcome[]> {
if (files.length === 0) return [];
const outcomes: ImportOutcome[] = [];
for (const file of files) outcomes.push(await importImage(file, deps));
const created = outcomes.filter((outcome) => outcome.assetId !== null);
const last = created[created.length - 1];
if (last?.assetId) deps.state.select({ kind: 'asset', assetId: last.assetId });
deps.state.setNotice(importNotice(outcomes));
return outcomes;
}
async function importImage(file: File, deps: TextureImportDeps): Promise<ImportOutcome> {
try {
const image = await prepareImage(file);
const uploaded = await deps.textures.upload(image.blob, image.type);
// The server stores by content hash, so importing a picture the project already has gives
// back the file it already references. Making a second asset for it would be clutter with a
// trap in it: two assets on one file can disagree about `filter`, while the scale mode
// belongs to the single shared source. Reuse instead – and say so, rather than silently
// doing nothing visible.
const existing = deps.state.project.assets.find(
(asset): asset is TextureAsset => asset.type === 'texture' && asset.file === uploaded.file,
);
if (existing) {
return { name: file.name, assetId: existing.id, error: null, reused: true };
}
const name = nextAssetName(deps.state.project, assetNameFor(file));
const command = new CreateTextureCommand(name, uploaded);
// A rejected command has already put its reason into the notice; that is the message here.
if (!deps.state.execute(command)) return { name: file.name, assetId: null, error: deps.state.notice };
return { name: file.name, assetId: command.asset.id, error: null, reused: false };
} catch (error) {
return { name: file.name, assetId: null, error: error instanceof Error ? error.message : String(error) };
}
}
/**
* One line for the whole batch: how many arrived, how many were already there, and why the
* first casualty did not. Reuse gets its own words – "Imported 1 image" would be a lie about a
* picture the project already had, and the user would look for a new asset that never appeared.
*/
export function importNotice(outcomes: readonly ImportOutcome[]): string {
const added = outcomes.filter((outcome) => outcome.assetId !== null && !outcome.reused).length;
const reused = outcomes.filter((outcome) => outcome.reused).length;
const failed = outcomes.filter((outcome) => outcome.error !== null);
const first = failed[0];
const known = reused === 0 ? '' : ` ${reused === 1 ? 'One was' : `${reused} were`} already in the project.`;
if (!first) {
if (added === 0 && reused > 0)
return reused === 1
? 'That image is already in the project.'
: `Those ${reused} images are already in the project.`;
return `Imported ${added} ${added === 1 ? 'image' : 'images'}.${known}`;
}
const more = failed.length > 1 ? ` (and ${failed.length - 1} more)` : '';
const problem = `Could not import "${first.name}": ${first.error ?? ''}${more}`;
if (added + reused === 0) return problem;
return `Imported ${added + reused} of ${outcomes.length}.${known} ${problem}`;
}
/** "player_ship.png" → "player_ship"; a pasted screenshot arrives as "image.png". */
function assetNameFor(file: File): string {
return file.name.replace(/\.[^.]+$/, '').trim() || 'Texture';
}
function formatBytes(bytes: number): string {
return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
}
// --- ways in ---------------------------------------------------------------------------------
/**
* Opens the browser's file dialog and resolves with what the user chose – empty when the
* dialog was cancelled. The `<input>` lives only for this one call.
*/
export function pickImageFiles(): Promise<File[]> {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = IMAGE_ACCEPT;
input.multiple = true;
input.style.display = 'none';
document.body.appendChild(input);
const finish = (files: File[]) => {
input.remove();
resolve(files);
};
input.addEventListener('change', () => finish([...(input.files ?? [])]));
// Without `cancel` a dismissed dialog would leave both the element and this promise behind.
input.addEventListener('cancel', () => finish([]));
input.click();
});
}
/** The `+` menu's Texture entry: pick files and import them, reporting anything that goes wrong. */
export function pickAndImport(deps: TextureImportDeps): void {
void pickImageFiles()
.then((files) => importImages(files, deps))
.catch((error: unknown) => deps.state.setNotice(String(error)));
}
/**
* Wires the two ways of importing an image that need no menu: dropping files onto `target`
* (the Pixi canvas) and pasting one from the clipboard. Returns the function that removes both
* again.
*
* Paste is bound to the window because the canvas cannot hold focus, and is ignored while a
* DOM text field has it (the `KeyboardBridge` input): Ctrl+V belongs to the text being edited.
*/
export function installTextureImport(target: HTMLElement, deps: TextureImportDeps): () => void {
const onDragOver = (event: DragEvent) => {
if (!hasImageItems(event.dataTransfer)) return;
// Without this the browser navigates away to the dropped file.
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
};
const onDrop = (event: DragEvent) => {
const files = imageFilesOf(event.dataTransfer);
if (files.length === 0) {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
deps.state.setNotice('Only image files can be dropped into the editor.');
return;
}
event.preventDefault();
run(files, deps);
};
const onPaste = (event: ClipboardEvent) => {
if (isTextFieldFocused()) return;
const files = imageFilesOf(event.clipboardData);
if (files.length === 0) return;
event.preventDefault();
run(files, deps);
};
target.addEventListener('dragover', onDragOver);
target.addEventListener('drop', onDrop);
window.addEventListener('paste', onPaste);
return () => {
target.removeEventListener('dragover', onDragOver);
target.removeEventListener('drop', onDrop);
window.removeEventListener('paste', onPaste);
};
}
function run(files: readonly File[], deps: TextureImportDeps): void {
void importImages(files, deps).catch((error: unknown) => deps.state.setNotice(String(error)));
}
function imageFilesOf(data: DataTransfer | null): File[] {
return [...(data?.files ?? [])].filter((file) => file.type.startsWith('image/'));
}
/** During a drag the files themselves are not readable yet – only the kinds and types are. */
function hasImageItems(data: DataTransfer | null): boolean {
return [...(data?.items ?? [])].some((item) => item.kind === 'file' && item.type.startsWith('image/'));
}
function isTextFieldFocused(): boolean {
const active = document.activeElement;
return active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement;
}