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

+70 -13
View File
@@ -2,7 +2,7 @@
declare(strict_types=1);
/**
* Game Maker project API.
* Game Maker API.
*
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
* site's nginx configuration does not pass PATH_INFO to PHP.
@@ -12,30 +12,32 @@ declare(strict_types=1);
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
* DELETE ?r=projects/{id} → 204
*
* GET ?r=textures → [{file,bytes,updatedAt}]
* POST ?r=textures raw image bytes → {file,width,height,bytes}
* DELETE ?r=textures/{file} → 204
*
* Stored images are served straight from `data/textures/` as static files; the API only
* writes them.
*/
require __DIR__ . '/lib/Response.php';
require __DIR__ . '/lib/Store.php';
require __DIR__ . '/lib/TextureStore.php';
const MAX_BODY_BYTES = 8 * 1024 * 1024;
/** Upload ceiling for a single image. The editor downscales larger pictures before sending. */
const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
Response::error('Request body too large', 413);
}
/** @param list<string> $parts */
function routeProjects(array $parts, string $method): never
{
$store = new Store(dirname(__DIR__) . '/data/projects');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$route = trim((string) ($_GET['r'] ?? ''), '/');
$parts = $route === '' ? [] : explode('/', $route);
if (($parts[0] ?? '') !== 'projects' || count($parts) > 2) {
Response::error('Not found', 404);
}
// Collection: /projects
if (count($parts) === 1) {
@@ -91,6 +93,61 @@ try {
}
Response::error('Method not allowed', 405);
}
/** @param list<string> $parts */
function routeTextures(array $parts, string $method): never
{
$store = new TextureStore(dirname(__DIR__) . '/data/textures');
// Collection: /textures
if (count($parts) === 1) {
if ($method === 'GET') {
Response::json($store->list());
}
if ($method === 'POST') {
$bytes = Response::rawBody();
if (strlen($bytes) > MAX_TEXTURE_BYTES) {
Response::error('Image must be at most ' . (MAX_TEXTURE_BYTES / 1024 / 1024) . ' MB', 413);
}
try {
Response::json($store->store($bytes), 201);
} catch (InvalidImageException $e) {
Response::error($e->getMessage(), 422);
}
}
Response::error('Method not allowed', 405);
}
// Item: /textures/{file}
if ($method === 'DELETE') {
if (!$store->delete($parts[1])) {
Response::error('Texture not found', 404);
}
Response::noContent();
}
Response::error('Method not allowed', 405);
}
try {
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
Response::error('Request body too large', 413);
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$route = trim((string) ($_GET['r'] ?? ''), '/');
$parts = $route === '' ? [] : explode('/', $route);
if (count($parts) < 1 || count($parts) > 2) {
Response::error('Not found', 404);
}
match ($parts[0]) {
'projects' => routeProjects($parts, $method),
'textures' => routeTextures($parts, $method),
default => Response::error('Not found', 404),
};
} catch (Throwable $e) {
error_log('[game-maker api] ' . $e->getMessage());
Response::error('Internal server error', 500);