diff --git a/DEPLOY_SHARED_JA.md b/DEPLOY_SHARED_JA.md
new file mode 100644
index 0000000..975bf99
--- /dev/null
+++ b/DEPLOY_SHARED_JA.md
@@ -0,0 +1,109 @@
+# Pixel Island shared test build
+
+この ZIP は `public_html` 直下に置く試験公開用です。HTTPS 前提で、同一フォルダ配下に DB を作成します。
+
+## 置き方
+
+`public_html` 直下が次の構成になるようにアップロードしてください。
+
+```text
+public_html/
+ index.html
+ app.js
+ styles.css
+ js/
+ api/
+ index.php
+ config.php
+ filedb.php
+ admin/
+ index.php
+ _data/
+ .htaccess
+ web.config
+ README.txt
+```
+
+## DB の場所
+
+標準は SQLite です。
+
+```text
+public_html/_data/pixel_island.sqlite
+```
+
+サーバーの PHP に `pdo_sqlite` が無い場合は、自動でロック付き JSON ファイル DB に切り替わります。
+
+```text
+public_html/_data/pixel_island_filedb.json
+```
+
+`_data` は Web から直接読まれないよう、Apache 用 `.htaccess` と IIS 用 `web.config` を同梱しています。サーバー設定によって効かない場合があるため、公開後に `https://あなたのURL/_data/` や `https://あなたのURL/_data/pixel_island_filedb.json` が直接見えないことを確認してください。
+
+## 必要なサーバー機能
+
+| 機能 | 必須度 | 用途 |
+|---|---:|---|
+| HTTPS | 必須 | アカウントID・パスワード送信保護 |
+| PHP 8.x | 必須 | API 実行 |
+| 書き込み権限 | 必須 | `_data` に DB を作成・更新 |
+| `pdo_sqlite` | 推奨 | SQLite DB 使用 |
+| `.htaccess` または `web.config` の deny | 推奨 | `_data` 直アクセス防止 |
+
+## 動作確認 URL
+
+```text
+https://あなたのURL/api/index.php?action=health
+```
+
+正常例:
+
+```json
+{"ok":true,"service":"pixel-island-api","sqlite":true}
+```
+
+`sqlite:false, fileDb:true` の場合、SQLite が使えず JSON ファイル DB で動いています。少人数テストなら可、本公開では SQLite 以上を推奨します。
+
+## 今回実装した段階
+
+### Stage 1: 共有バックエンド
+
+- `api/index.php` を追加
+- `_data` 配下に DB を作成
+- 作品・配置・投票・通報をサーバー保存
+- フロントエンドは起動時と約5秒ごとに共有データを同期
+- API が使えない場合はローカル専用にフォールバック
+
+### Stage 2: アカウント・所有者検証
+
+- ブラウザ側のローカルアカウントをサーバー登録
+- パスワードはサーバー側でハッシュ保存
+- 作品作成・削除・配置・移動は所有者のみ許可
+- 1時間あたりの投稿数をサーバー側で制限
+
+### Stage 3: 通報・管理画面
+
+- 通報を DB に保存
+- `admin/index.php` を追加
+- SQLite / JSON fallback の両方で管理画面が動作
+- 管理画面からオブジェクトを非表示 / 復元 / 通報クローズ可能
+
+管理画面を使う場合は `api/config.php` の `admin_token` に長いランダム文字列を設定し、次の形式で開きます。
+
+```text
+https://あなたのURL/admin/?token=設定したトークン
+```
+
+## 未実装・今後の段階
+
+| 段階 | 内容 |
+|---|---|
+| Stage 4 | 管理者ログイン、管理者ロール、CSRF 対策強化、操作監査ログ |
+| Stage 5 | 画像/投稿のより厳密なサーバー側バリデーション |
+| Stage 6 | 差分同期、ページング、重い島での高速化 |
+| Stage 7 | 自動モデレーション、NGワード、IP/端末単位レート制限 |
+| Stage 8 | バックアップ、エクスポート、DB復旧手順 |
+
+## 注意
+
+このビルドは「試験公開」用です。本公開する場合は少なくとも SQLite が使える状態にし、`_data` の直アクセス不可確認、定期バックアップ、管理トークン設定を行ってください。SQLite が無い環境でも JSON fallback で少人数テストは可能ですが、同時アクセスやデータ破損リスクを考えると長期運用には向きません。
diff --git a/_data/.htaccess b/_data/.htaccess
new file mode 100644
index 0000000..b66e808
--- /dev/null
+++ b/_data/.htaccess
@@ -0,0 +1 @@
+Require all denied
diff --git a/_data/README.txt b/_data/README.txt
new file mode 100644
index 0000000..9b45174
--- /dev/null
+++ b/_data/README.txt
@@ -0,0 +1,2 @@
+SQLite database files are created here on first API access.
+Do not delete .htaccess or web.config. They block direct web access to this directory.
diff --git a/_data/web.config b/_data/web.config
new file mode 100644
index 0000000..a570c7c
--- /dev/null
+++ b/_data/web.config
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/index.php b/admin/index.php
new file mode 100644
index 0000000..743e785
--- /dev/null
+++ b/admin/index.php
@@ -0,0 +1,190 @@
+query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('reports','objects','assets')");
+ $names = $stmt ? array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'name') : [];
+ return in_array('reports', $names, true) && in_array('objects', $names, true) && in_array('assets', $names, true);
+}
+function choose_store(array $config): array {
+ $sqlitePath = (string)$config['db_path'];
+ if (sqlite_available() && is_file($sqlitePath)) return ['type' => 'sqlite', 'path' => $sqlitePath];
+ $jsonPath = filedb_path($config);
+ if (is_file($jsonPath)) return ['type' => 'filedb', 'path' => $jsonPath];
+ if (sqlite_available()) return ['type' => 'sqlite_missing', 'path' => $sqlitePath];
+ return ['type' => 'filedb_missing', 'path' => $jsonPath];
+}
+function read_filedb(string $path): array {
+ if (!is_file($path)) return [];
+ $raw = file_get_contents($path);
+ $db = $raw ? json_decode($raw, true) : null;
+ return is_array($db) ? $db : [];
+}
+function write_filedb(string $path, callable $mutator): void {
+ $dir = dirname($path);
+ if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) throw new RuntimeException('data directory unavailable');
+ $fp = fopen($path, 'c+');
+ if (!$fp) throw new RuntimeException('file db unavailable');
+ try {
+ if (!flock($fp, LOCK_EX)) throw new RuntimeException('file db lock failed');
+ $raw = stream_get_contents($fp);
+ $db = $raw ? json_decode($raw, true) : null;
+ if (!is_array($db)) $db = ['schema'=>1,'accounts'=>[],'assets'=>[],'objects'=>[],'votes'=>[],'reports'=>[],'events'=>[],'commands'=>[],'nextEventId'=>1];
+ $mutator($db);
+ ftruncate($fp, 0);
+ rewind($fp);
+ fwrite($fp, json_encode($db, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+ fflush($fp);
+ flock($fp, LOCK_UN);
+ } finally {
+ fclose($fp);
+ }
+}
+function asset_name_from_json($json, string $fallback = 'Untitled'): string {
+ if (is_array($json)) return (string)($json['name'] ?? $fallback);
+ $d = json_decode((string)$json, true);
+ return is_array($d) ? (string)($d['name'] ?? $fallback) : $fallback;
+}
+function list_sqlite_reports(string $path): array {
+ $db = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
+ $db->exec('PRAGMA busy_timeout = 5000');
+ if (!sqlite_tables_ready($db)) return [];
+ $rows = $db->query("SELECT r.*, o.moderation_status, o.json AS object_json, a.json AS asset_json FROM reports r LEFT JOIN objects o ON o.id = r.object_id LEFT JOIN assets a ON a.id = r.asset_id WHERE r.status = 'open' ORDER BY r.created_at DESC LIMIT 200")->fetchAll();
+ return array_map(function($r) {
+ return [
+ 'id' => $r['id'] ?? '',
+ 'object_id' => $r['object_id'] ?? '',
+ 'asset_id' => $r['asset_id'] ?? '',
+ 'reason' => $r['reason'] ?? '',
+ 'reporter' => $r['reporter_account_id'] ?? '',
+ 'created_at' => (int)($r['created_at'] ?? 0),
+ 'moderation_status' => $r['moderation_status'] ?? 'missing',
+ 'asset_name' => asset_name_from_json($r['asset_json'] ?? '', 'Untitled'),
+ ];
+ }, $rows ?: []);
+}
+function list_filedb_reports(string $path): array {
+ $db = read_filedb($path);
+ $reports = is_array($db['reports'] ?? null) ? $db['reports'] : [];
+ $objects = is_array($db['objects'] ?? null) ? $db['objects'] : [];
+ $assets = is_array($db['assets'] ?? null) ? $db['assets'] : [];
+ $out = [];
+ foreach ($reports as $r) {
+ if (($r['status'] ?? 'open') !== 'open') continue;
+ $objectId = (string)($r['object_id'] ?? '');
+ $assetId = (string)($r['asset_id'] ?? '');
+ $object = $objects[$objectId] ?? [];
+ $asset = $assets[$assetId] ?? [];
+ $out[] = [
+ 'id' => $r['id'] ?? '',
+ 'object_id' => $objectId,
+ 'asset_id' => $assetId,
+ 'reason' => $r['reason'] ?? '',
+ 'reporter' => $r['reporter_account_id'] ?? '',
+ 'created_at' => (int)($r['created_at'] ?? 0),
+ 'moderation_status' => $object['moderation_status'] ?? 'missing',
+ 'asset_name' => asset_name_from_json($asset['json'] ?? [], 'Untitled'),
+ ];
+ }
+ usort($out, fn($a, $b) => ((int)$b['created_at']) <=> ((int)$a['created_at']));
+ return array_slice($out, 0, 200);
+}
+function apply_sqlite_action(string $path, string $objectId, string $action): void {
+ $db = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
+ $db->exec('PRAGMA busy_timeout = 5000');
+ if (!sqlite_tables_ready($db)) return;
+ $now = (int)floor(microtime(true) * 1000);
+ if ($action === 'hide') {
+ $db->prepare("UPDATE objects SET moderation_status = 'violation_hidden', updated_at = ? WHERE id = ?")->execute([$now, $objectId]);
+ } elseif ($action === 'restore') {
+ $db->prepare("UPDATE objects SET moderation_status = 'active', updated_at = ? WHERE id = ?")->execute([$now, $objectId]);
+ } elseif ($action === 'close_reports') {
+ $db->prepare("UPDATE reports SET status = 'closed' WHERE object_id = ?")->execute([$objectId]);
+ }
+}
+function apply_filedb_action(string $path, string $objectId, string $action): void {
+ write_filedb($path, function(array &$db) use ($objectId, $action) {
+ $now = (int)floor(microtime(true) * 1000);
+ if (($action === 'hide' || $action === 'restore') && isset($db['objects'][$objectId])) {
+ $db['objects'][$objectId]['moderation_status'] = $action === 'hide' ? 'violation_hidden' : 'active';
+ $db['objects'][$objectId]['updated_at'] = $now;
+ } elseif ($action === 'close_reports' && is_array($db['reports'] ?? null)) {
+ foreach ($db['reports'] as &$report) {
+ if (($report['object_id'] ?? '') === $objectId) $report['status'] = 'closed';
+ }
+ unset($report);
+ }
+ });
+}
+
+if ($token === '') {
+ http_response_code(403);
+ echo '
Admin disabledAdmin disabled
Set admin_token in api/config.php to enable this page.
';
+ exit;
+}
+if (!hash_equals($token, $given)) {
+ http_response_code(403);
+ echo 'ForbiddenForbidden
Invalid admin token.
';
+ exit;
+}
+
+$store = choose_store($config);
+$error = '';
+try {
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $objectId = clean_object_id($_POST['object_id'] ?? '');
+ $action = (string)($_POST['moderation_action'] ?? '');
+ if ($objectId !== '') {
+ if ($store['type'] === 'sqlite') apply_sqlite_action($store['path'], $objectId, $action);
+ elseif ($store['type'] === 'filedb') apply_filedb_action($store['path'], $objectId, $action);
+ }
+ header('Location: ./?token=' . rawurlencode($given));
+ exit;
+ }
+ if ($store['type'] === 'sqlite') $reports = list_sqlite_reports($store['path']);
+ elseif ($store['type'] === 'filedb') $reports = list_filedb_reports($store['path']);
+ else $reports = [];
+} catch (Throwable $e) {
+ $reports = [];
+ $error = $e->getMessage();
+}
+$storeLabel = $store['type'] === 'sqlite' ? 'SQLite' : ($store['type'] === 'filedb' ? 'JSON file DB' : 'DB not initialized yet');
+?>
+
+
+
+Pixel Island Admin
+
+Pixel Island Admin
+Store: =h($storeLabel)?> / Open reports: =count($reports)?>. Hiding an object removes it from public snapshots; restore makes it visible again.
+Admin error: =h($error)?>
+
+ No DB has been initialized yet. Open ../api/index.php?action=health and use the app once, then return here.
+
+
+
+
=h($r['asset_name'] ?? 'Untitled')?>
+
Reason: =h($r['reason'] ?? '')?> / Reporter: =h($r['reporter'] ?? '')?>
+
Object: =h($r['object_id'] ?? '')?> / Asset: =h($r['asset_id'] ?? '')?> / Status: =h($r['moderation_status'] ?? 'missing')?>
+
+
+
+No open reports.
diff --git a/api/config.php b/api/config.php
new file mode 100644
index 0000000..aa791aa
--- /dev/null
+++ b/api/config.php
@@ -0,0 +1,17 @@
+ dirname(__DIR__) . DIRECTORY_SEPARATOR . '_data' . DIRECTORY_SEPARATOR . 'pixel_island.sqlite',
+ 'world_width' => 144,
+ 'world_height' => 112,
+ 'max_assets_per_account' => 220,
+ 'max_world_objects' => 1000,
+ 'publish_limit_first_day' => 5,
+ 'publish_limit_trusted' => 10,
+ 'trusted_after_ms' => 24 * 60 * 60 * 1000,
+ 'max_json_bytes' => 5 * 1024 * 1024,
+ // Set an arbitrary long token here if you use admin/index.php.
+ // Example: 'admin_token' => 'change-this-long-random-string',
+ 'admin_token' => '',
+];
diff --git a/api/filedb.php b/api/filedb.php
new file mode 100644
index 0000000..3e5ca9e
--- /dev/null
+++ b/api/filedb.php
@@ -0,0 +1,176 @@
+ false, 'error' => $message] + $extra, $status); }
+function fb_now(): int { return (int) floor(microtime(true) * 1000); }
+function fb_clean_text($value, string $fallback = '', int $max = 80): string {
+ $text = trim((string)($value ?? ''));
+ $text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text) ?? '';
+ $text = preg_replace('/\s+/u', ' ', $text) ?? '';
+ if ($text === '') $text = $fallback;
+ return function_exists('mb_substr') ? mb_substr($text, 0, $max, 'UTF-8') : substr($text, 0, $max);
+}
+function fb_clean_id($value, string $fallback = ''): string {
+ $id = trim((string)($value ?? ''));
+ if ($id === '') return $fallback;
+ return substr(preg_replace('/[^A-Za-z0-9_:\-.]/', '', $id) ?? '', 0, 96);
+}
+function fb_request_json(int $maxBytes): array {
+ $raw = file_get_contents('php://input') ?: '';
+ if ($raw === '') return [];
+ if (strlen($raw) > $maxBytes) fb_fail('request_too_large', 413);
+ $data = json_decode($raw, true);
+ if (!is_array($data)) fb_fail('invalid_json', 400);
+ return $data;
+}
+function fb_default_db(): array {
+ return ['schema'=>1,'accounts'=>[],'assets'=>[],'objects'=>[],'votes'=>[],'reports'=>[],'events'=>[],'commands'=>[],'nextEventId'=>1];
+}
+function fb_db_path(array $config): string {
+ return dirname($config['db_path']) . DIRECTORY_SEPARATOR . 'pixel_island_filedb.json';
+}
+function fb_with_db(array $config, callable $callback): void {
+ $path = fb_db_path($config);
+ $dir = dirname($path);
+ if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fb_fail('db_directory_unavailable', 500);
+ $fp = fopen($path, 'c+');
+ if (!$fp) fb_fail('filedb_unavailable', 500);
+ try {
+ if (!flock($fp, LOCK_EX)) fb_fail('filedb_lock_failed', 503);
+ $raw = stream_get_contents($fp);
+ $db = $raw ? json_decode($raw, true) : null;
+ if (!is_array($db)) $db = fb_default_db();
+ $result = $callback($db);
+ ftruncate($fp, 0);
+ rewind($fp);
+ fwrite($fp, json_encode($db, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
+ fflush($fp);
+ flock($fp, LOCK_UN);
+ fclose($fp);
+ fb_respond($result);
+ } catch (Throwable $e) {
+ flock($fp, LOCK_UN);
+ fclose($fp);
+ fb_fail($e->getMessage(), 500);
+ }
+}
+function fb_auth(array &$db, array $account, bool $create = true): array {
+ $id = fb_clean_id($account['id'] ?? '');
+ $password = (string)($account['password'] ?? $account['pass'] ?? '');
+ $name = fb_clean_text($account['name'] ?? $id, $id, 32);
+ if ($id === '' || $password === '') fb_fail('account_required', 401);
+ $now = fb_now();
+ if (!isset($db['accounts'][$id])) {
+ if (!$create) fb_fail('account_not_found', 401);
+ $db['accounts'][$id] = ['id'=>$id,'name'=>$name,'password_hash'=>password_hash($password, PASSWORD_DEFAULT),'created_at'=>$now,'updated_at'=>$now,'disabled_at'=>null];
+ } else {
+ $row = $db['accounts'][$id];
+ if (!empty($row['disabled_at'])) fb_fail('account_disabled', 403);
+ if (!password_verify($password, $row['password_hash'] ?? '')) fb_fail('invalid_account_password', 401);
+ if ($name !== '' && $name !== ($row['name'] ?? '')) {
+ $db['accounts'][$id]['name'] = $name;
+ $db['accounts'][$id]['updated_at'] = $now;
+ }
+ }
+ return $db['accounts'][$id];
+}
+function fb_event(array &$db, string $type, array $event): int {
+ $id = (int)($db['nextEventId'] ?? 1);
+ $db['nextEventId'] = $id + 1;
+ $db['events'][] = ['id'=>$id,'type'=>$type,'json'=>$event,'created_at'=>fb_now()];
+ if (count($db['events']) > 5000) $db['events'] = array_slice($db['events'], -5000);
+ return $id;
+}
+function fb_normalize_asset(array $asset, array $actor): array {
+ $id = fb_clean_id($asset['id'] ?? '');
+ if ($id === '') fb_fail('asset_id_required');
+ $category = (($asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
+ $w = max(1, min(64, (int)($asset['width'] ?? $asset['w'] ?? $asset['size'] ?? 16)));
+ $h = max(1, min(64, (int)($asset['height'] ?? $asset['ht'] ?? $asset['size'] ?? $w)));
+ $asset['id']=$id; $asset['name']=fb_clean_text($asset['name'] ?? 'Untitled','Untitled',32);
+ $asset['category']=$category; $asset['subtype']=fb_clean_text($asset['subtype'] ?? ($category==='dynamic'?'human':'other'),'other',24);
+ $asset['size']=max($w,$h); $asset['width']=$w; $asset['height']=$h;
+ $asset['author']=fb_clean_text($actor['name'] ?? $actor['id'],$actor['id'],32); $asset['ownerAccountId']=$actor['id'];
+ $asset['version']=max(1,(int)($asset['version'] ?? 1)); $asset['createdAt']=(int)($asset['createdAt'] ?? fb_now()); $asset['updatedAt']=fb_now();
+ if (!isset($asset['pixels']) && !isset($asset['faces'])) fb_fail('asset_pixels_required');
+ if (strlen(json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) > 250000) fb_fail('asset_too_large', 413);
+ return $asset;
+}
+function fb_normalize_object(array $object, string $kind, array $actor, array $config): array {
+ $id=fb_clean_id($object['id'] ?? ''); $assetId=fb_clean_id($object['assetId'] ?? '');
+ if ($id==='' || $assetId==='') fb_fail('object_id_or_asset_required');
+ $object['id']=$id; $object['assetId']=$assetId; $object['ownerAccountId']=$actor['id']; $object['version']=max(1,(int)($object['version'] ?? 1)); $object['status']='active'; $object['publishedAt']=(int)($object['publishedAt'] ?? fb_now());
+ if ($kind==='dynamic') { $object['homeX']=max(0,min((int)$config['world_width']-1,(int)round((float)($object['homeX'] ?? $object['x'] ?? 0)))); $object['homeY']=max(0,min((int)$config['world_height']-1,(int)round((float)($object['homeY'] ?? $object['y'] ?? 0)))); $object['createdAt']=(int)($object['createdAt'] ?? fb_now()); }
+ else { $object['x']=max(0,min((int)$config['world_width']-1,(int)round((float)($object['x'] ?? 0)))); $object['y']=max(0,min((int)$config['world_height']-1,(int)round((float)($object['y'] ?? 0)))); $object['placedAt']=(int)($object['placedAt'] ?? fb_now()); }
+ return $object;
+}
+function fb_asset_owner(array $db, string $assetId, string $actorId): void {
+ $row = $db['assets'][$assetId] ?? null;
+ if (!$row || !empty($row['deleted_at'])) fb_fail('asset_not_found', 404);
+ if (($row['owner_account_id'] ?? '') !== $actorId) fb_fail('asset_owner_required', 403);
+}
+function fb_quota(array $db, array $actor, array $config): bool {
+ $now=fb_now(); $created=(int)($actor['created_at'] ?? $now); $limit=($now-$created)<(int)$config['trusted_after_ms'] ? (int)$config['publish_limit_first_day'] : (int)$config['publish_limit_trusted']; $since=$now-3600000; $used=0;
+ foreach ($db['objects'] as $o) if (($o['owner_account_id'] ?? '')===$actor['id'] && empty($o['deleted_at']) && (int)($o['published_at'] ?? 0) >= $since) $used++;
+ return $used < $limit;
+}
+function fb_process(array &$db, array $command, array $actor, array $config): array {
+ $cmdId=fb_clean_id($command['id'] ?? ''); $type=(string)($command['type'] ?? '');
+ if ($cmdId==='' || $type==='') return ['ok'=>false,'id'=>$cmdId,'error'=>'invalid_command'];
+ if (isset($db['commands'][$cmdId])) return ['ok'=>true,'id'=>$cmdId,'duplicate'=>true];
+ $now=fb_now();
+ if ($type==='asset.create') {
+ $asset=fb_normalize_asset(is_array($command['asset'] ?? null)?$command['asset']:[], $actor);
+ if (isset($db['assets'][$asset['id']]) && ($db['assets'][$asset['id']]['owner_account_id'] ?? '') !== $actor['id']) fb_fail('asset_id_already_owned',403);
+ $db['assets'][$asset['id']]=['id'=>$asset['id'],'owner_account_id'=>$actor['id'],'author_name'=>$asset['author'],'json'=>$asset,'version'=>$asset['version'],'created_at'=>$asset['createdAt'],'updated_at'=>$asset['updatedAt'],'deleted_at'=>null,'deleted_by'=>null,'content_hash'=>$asset['contentHash'] ?? null];
+ fb_event($db,'asset.upsert',['type'=>'asset.upsert','assetId'=>$asset['id'],'actorAccountId'=>$actor['id']]);
+ } elseif ($type==='asset.delete') {
+ $assetId=fb_clean_id($command['assetId'] ?? ''); fb_asset_owner($db,$assetId,$actor['id']);
+ $db['assets'][$assetId]['deleted_at']=$now; $db['assets'][$assetId]['deleted_by']=$actor['id']; $db['assets'][$assetId]['updated_at']=$now;
+ foreach ($db['objects'] as &$o) if (($o['asset_id'] ?? '')===$assetId && empty($o['deleted_at'])) { $o['deleted_at']=$now; $o['deleted_by']=$actor['id']; $o['updated_at']=$now; } unset($o);
+ fb_event($db,'asset.delete',['type'=>'asset.delete','assetId'=>$assetId,'actorAccountId'=>$actor['id']]);
+ } elseif ($type==='object.publish' || $type==='object.move') {
+ $kind=(($command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $object=fb_normalize_object(is_array($command['object'] ?? null)?$command['object']:[], $kind, $actor, $config); fb_asset_owner($db,$object['assetId'],$actor['id']);
+ if ($type==='object.publish' && !fb_quota($db,$actor,$config)) fb_fail('publish_limit_reached',429);
+ $active=0; foreach($db['objects'] as $o) if (empty($o['deleted_at']) && ($o['moderation_status'] ?? 'active')==='active') $active++;
+ if ($type==='object.publish' && $active >= (int)$config['max_world_objects']) fb_fail('world_object_limit_reached',409);
+ $db['objects'][$object['id']]=['id'=>$object['id'],'kind'=>$kind,'asset_id'=>$object['assetId'],'owner_account_id'=>$actor['id'],'json'=>$object,'version'=>$object['version'],'published_at'=>(int)$object['publishedAt'],'updated_at'=>$now,'deleted_at'=>null,'deleted_by'=>null,'moderation_status'=>'active'];
+ fb_event($db,'object.upsert',['type'=>'object.upsert','kind'=>$kind,'objectId'=>$object['id'],'actorAccountId'=>$actor['id']]);
+ } elseif ($type==='object.delete') {
+ $kind=(($command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $objectId=fb_clean_id($command['objectId'] ?? ''); $o=$db['objects'][$objectId] ?? null; if (!$o || ($o['kind'] ?? '')!==$kind || !empty($o['deleted_at'])) fb_fail('object_not_found',404); if (($o['owner_account_id'] ?? '')!==$actor['id']) fb_fail('object_owner_required',403);
+ $db['objects'][$objectId]['deleted_at']=$now; $db['objects'][$objectId]['deleted_by']=$actor['id']; $db['objects'][$objectId]['updated_at']=$now; fb_event($db,'object.delete',['type'=>'object.delete','kind'=>$kind,'objectId'=>$objectId,'actorAccountId'=>$actor['id']]);
+ } elseif ($type==='vote.asset' || $type==='vote.object') {
+ $targetType=$type==='vote.asset'?'asset':'object'; $targetId=fb_clean_id($command[$targetType.'Id'] ?? $command['targetId'] ?? ''); $value=max(-1,min(1,(int)($command['value'] ?? $command['delta'] ?? 0))); if($targetId==='') fb_fail('vote_target_required'); $key=$targetType.'|'.$targetId.'|'.$actor['id']; if($value===0) unset($db['votes'][$key]); else $db['votes'][$key]=['target_type'=>$targetType,'target_id'=>$targetId,'voter_account_id'=>$actor['id'],'value'=>$value,'updated_at'=>$now]; fb_event($db,'vote.'.$targetType,['type'=>'vote.'.$targetType,'targetId'=>$targetId,'actorAccountId'=>$actor['id'],'value'=>$value]);
+ } elseif ($type==='report.object') {
+ $objectId=fb_clean_id($command['objectId'] ?? ''); $assetId=fb_clean_id($command['assetId'] ?? ''); $kind=(($command['objectKind'] ?? $command['kind'] ?? '')==='dynamic')?'dynamic':'static'; $reason=fb_clean_text($command['reason'] ?? 'other','other',48); if($objectId==='' || $assetId==='') fb_fail('report_target_required'); $reportId=fb_clean_id($command['reportId'] ?? $command['id'] ?? ('report_'.bin2hex(random_bytes(6)))); $db['reports'][$reportId]=['id'=>$reportId,'object_id'=>$objectId,'asset_id'=>$assetId,'object_kind'=>$kind,'reporter_account_id'=>$actor['id'],'reason'=>$reason,'created_at'=>$now,'status'=>'open']; fb_event($db,'report.object',['type'=>'report.object','objectId'=>$objectId,'assetId'=>$assetId,'actorAccountId'=>$actor['id'],'reason'=>$reason]);
+ } else return ['ok'=>false,'id'=>$cmdId,'error'=>'unsupported_command'];
+ $db['commands'][$cmdId]=['id'=>$cmdId,'actor_account_id'=>$actor['id'],'created_at'=>(int)($command['createdAt'] ?? $now),'applied_at'=>$now];
+ return ['ok'=>true,'id'=>$cmdId];
+}
+function fb_votes(array $db, string $targetType): array { $out=[]; foreach($db['votes'] as $v){ if(($v['target_type'] ?? '')!==$targetType || (int)($v['value'] ?? 0)===0) continue; $id=$v['target_id']; $val=(int)$v['value']; if(!isset($out[$id])) $out[$id]=['up'=>0,'down'=>0,'voters'=>[]]; if($val>0)$out[$id]['up']++; if($val<0)$out[$id]['down']++; $out[$id]['voters'][$v['voter_account_id']]=$val; } return $out; }
+function fb_snapshot(array $db): array {
+ $assets=[]; foreach($db['assets'] as $a) if(empty($a['deleted_at'])) $assets[]=$a['json'];
+ usort($assets, fn($a,$b)=>(int)($b['updatedAt'] ?? 0) <=> (int)($a['updatedAt'] ?? 0));
+ $placed=[]; $dynamic=[]; foreach($db['objects'] as $o){ if(!empty($o['deleted_at']) || ($o['moderation_status'] ?? 'active')!=='active') continue; if(($o['kind'] ?? '')==='dynamic') $dynamic[]=$o['json']; else $placed[]=$o['json']; }
+ $assetT=[]; foreach($db['assets'] as $a) if(!empty($a['deleted_at'])) $assetT[$a['id']]=['id'=>$a['id'],'deletedAt'=>(int)$a['deleted_at'],'deletedBy'=>$a['deleted_by'] ?? '', 'version'=>(int)($a['version'] ?? 1)];
+ $objectT=[]; foreach($db['objects'] as $o) if(!empty($o['deleted_at'])) $objectT[$o['id']]=['id'=>$o['id'],'assetId'=>$o['asset_id'],'deletedAt'=>(int)$o['deleted_at'],'deletedBy'=>$o['deleted_by'] ?? '', 'version'=>(int)($o['version'] ?? 1)];
+ $reports=[]; foreach($db['reports'] as $r) if(($r['status'] ?? 'open')==='open') $reports[]=['id'=>$r['id'],'objectId'=>$r['object_id'],'assetId'=>$r['asset_id'],'objectKind'=>$r['object_kind'],'reporter'=>$r['reporter_account_id'],'reason'=>$r['reason'],'createdAt'=>(int)$r['created_at']];
+ $last=0; foreach($db['events'] as $e) $last=max($last,(int)($e['id'] ?? 0));
+ return ['ok'=>true,'schema'=>1,'fileDb'=>true,'serverNow'=>fb_now(),'lastEventId'=>$last,'authority'=>['publish'=>'server','objectMove'=>'server','dayNight'=>'local','dynamicMotion'=>'local'],'assets'=>array_values($assets),'placed'=>array_values($placed),'dynamicSummons'=>array_values($dynamic),'assetVotes'=>fb_votes($db,'asset'),'objectVotes'=>fb_votes($db,'object'),'moderationReports'=>$reports,'tombstones'=>['assets'=>$assetT,'objects'=>$objectT]];
+}
+
+$action = $_GET['action'] ?? '';
+if ($action === 'health' || $action === '') fb_respond(['ok'=>true,'service'=>'pixel-island-api','serverNow'=>fb_now(),'sqlite'=>false,'fileDb'=>true,'note'=>'pdo_sqlite is unavailable; using locked JSON fallback']);
+fb_with_db($config, function(array &$db) use ($action, $config) {
+ if ($action === 'snapshot') return fb_snapshot($db);
+ if ($action === 'account') { $data=fb_request_json((int)$config['max_json_bytes']); $account=fb_auth($db, is_array($data['account'] ?? null)?$data['account']:$data, true); return ['ok'=>true,'account'=>['id'=>$account['id'],'name'=>$account['name'],'createdAt'=>(int)$account['created_at']]]; }
+ if ($action === 'commands') { $data=fb_request_json((int)$config['max_json_bytes']); $actor=fb_auth($db, is_array($data['account'] ?? null)?$data['account']:[], true); $commands=is_array($data['commands'] ?? null)?$data['commands']:[]; if(count($commands)>100) fb_fail('too_many_commands',413); $applied=[]; $rejected=[]; foreach($commands as $command){ if(!is_array($command)) continue; try{ $result=fb_process($db,$command,$actor,$config); if($result['ok'] ?? false) $applied[]=$result['id']; else $rejected[]=$result; } catch(Throwable $e){ $rejected[]=['ok'=>false,'id'=>fb_clean_id($command['id'] ?? ''),'error'=>$e->getMessage()]; } } return ['ok'=>true,'appliedCommandIds'=>$applied,'rejectedCommands'=>$rejected,'snapshot'=>fb_snapshot($db)]; }
+ return ['ok'=>false,'error'=>'unknown_action'];
+});
diff --git a/api/index.php b/api/index.php
new file mode 100644
index 0000000..2c32c9c
--- /dev/null
+++ b/api/index.php
@@ -0,0 +1,455 @@
+ false, 'error' => $message] + $extra, $status);
+}
+
+function request_json(int $maxBytes): array {
+ $raw = file_get_contents('php://input') ?: '';
+ if ($raw === '') return [];
+ if (strlen($raw) > $maxBytes) fail('request_too_large', 413);
+ $data = json_decode($raw, true);
+ if (!is_array($data)) fail('invalid_json', 400);
+ return $data;
+}
+
+function now_ms(): int {
+ return (int) floor(microtime(true) * 1000);
+}
+
+function clean_text($value, string $fallback = '', int $max = 80): string {
+ $text = trim((string) ($value ?? ''));
+ $text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text) ?? '';
+ $text = preg_replace('/\s+/u', ' ', $text) ?? '';
+ if ($text === '') $text = $fallback;
+ if (function_exists('mb_substr')) return mb_substr($text, 0, $max, 'UTF-8');
+ return substr($text, 0, $max);
+}
+
+function clean_id($value, string $fallback = ''): string {
+ $id = trim((string) ($value ?? ''));
+ if ($id === '') return $fallback;
+ $id = preg_replace('/[^A-Za-z0-9_:\-.]/', '', $id) ?? '';
+ return substr($id, 0, 96);
+}
+
+function db(array $config): PDO {
+ $path = $config['db_path'];
+ $dir = dirname($path);
+ if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) fail('db_directory_unavailable', 500);
+ $pdo = new PDO('sqlite:' . $path, null, null, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+ $pdo->exec('PRAGMA foreign_keys = ON');
+ $pdo->exec('PRAGMA busy_timeout = 5000');
+ $pdo->exec('PRAGMA journal_mode = WAL');
+ migrate($pdo);
+ return $pdo;
+}
+
+function migrate(PDO $db): void {
+ $db->exec(<<prepare('SELECT * FROM accounts WHERE id = ?');
+ $stmt->execute([$id]);
+ $row = $stmt->fetch();
+ $now = now_ms();
+ if (!$row) {
+ if (!$createIfMissing) fail('account_not_found', 401);
+ $hash = password_hash($password, PASSWORD_DEFAULT);
+ $db->prepare('INSERT INTO accounts(id, name, password_hash, created_at, updated_at) VALUES(?,?,?,?,?)')
+ ->execute([$id, $name, $hash, $now, $now]);
+ return ['id' => $id, 'name' => $name, 'created_at' => $now, 'updated_at' => $now];
+ }
+ if (!empty($row['disabled_at'])) fail('account_disabled', 403);
+ if (!password_verify($password, $row['password_hash'])) fail('invalid_account_password', 401);
+ if ($name !== '' && $name !== $row['name']) {
+ $db->prepare('UPDATE accounts SET name = ?, updated_at = ? WHERE id = ?')->execute([$name, $now, $id]);
+ $row['name'] = $name;
+ $row['updated_at'] = $now;
+ }
+ return $row;
+}
+
+function record_event(PDO $db, string $type, array $event): int {
+ $now = now_ms();
+ $db->prepare('INSERT INTO events(type, json, created_at) VALUES(?,?,?)')
+ ->execute([$type, json_encode($event, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), $now]);
+ return (int) $db->lastInsertId();
+}
+
+function decode_json_row(?string $json): array {
+ $decoded = json_decode((string) $json, true);
+ return is_array($decoded) ? $decoded : [];
+}
+
+function normalize_asset(array $asset, array $actor, array $config): array {
+ $id = clean_id($asset['id'] ?? '');
+ if ($id === '') fail('asset_id_required');
+ $category = (($asset['category'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
+ $width = max(1, min(64, (int) ($asset['width'] ?? $asset['w'] ?? $asset['size'] ?? 16)));
+ $height = max(1, min(64, (int) ($asset['height'] ?? $asset['ht'] ?? $asset['size'] ?? $width)));
+ $asset['id'] = $id;
+ $asset['name'] = clean_text($asset['name'] ?? 'Untitled', 'Untitled', 32);
+ $asset['category'] = $category;
+ $asset['subtype'] = clean_text($asset['subtype'] ?? ($category === 'dynamic' ? 'human' : 'other'), 'other', 24);
+ $asset['size'] = max($width, $height);
+ $asset['width'] = $width;
+ $asset['height'] = $height;
+ $asset['author'] = clean_text($actor['name'] ?? $actor['id'], $actor['id'], 32);
+ $asset['ownerAccountId'] = $actor['id'];
+ $asset['version'] = max(1, (int) ($asset['version'] ?? 1));
+ $asset['createdAt'] = (int) ($asset['createdAt'] ?? now_ms());
+ $asset['updatedAt'] = now_ms();
+ $asset['parentAssetId'] = isset($asset['parentAssetId']) ? clean_id($asset['parentAssetId'], '') : null;
+ $asset['originalAssetId'] = isset($asset['originalAssetId']) ? clean_id($asset['originalAssetId'], '') : null;
+ if (!isset($asset['pixels']) && !isset($asset['faces'])) fail('asset_pixels_required');
+ $json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ if ($json === false || strlen($json) > 250000) fail('asset_too_large', 413);
+ return $asset;
+}
+
+function normalize_object(array $object, string $kind, array $actor, array $config): array {
+ $id = clean_id($object['id'] ?? '');
+ $assetId = clean_id($object['assetId'] ?? '');
+ if ($id === '' || $assetId === '') fail('object_id_or_asset_required');
+ $object['id'] = $id;
+ $object['assetId'] = $assetId;
+ $object['ownerAccountId'] = $actor['id'];
+ $object['version'] = max(1, (int) ($object['version'] ?? 1));
+ $object['status'] = 'active';
+ $object['publishedAt'] = (int) ($object['publishedAt'] ?? now_ms());
+ if ($kind === 'dynamic') {
+ $object['homeX'] = max(0, min((int) $config['world_width'] - 1, (int) round((float) ($object['homeX'] ?? $object['x'] ?? 0))));
+ $object['homeY'] = max(0, min((int) $config['world_height'] - 1, (int) round((float) ($object['homeY'] ?? $object['y'] ?? 0))));
+ $object['createdAt'] = (int) ($object['createdAt'] ?? now_ms());
+ } else {
+ $object['x'] = max(0, min((int) $config['world_width'] - 1, (int) round((float) ($object['x'] ?? 0))));
+ $object['y'] = max(0, min((int) $config['world_height'] - 1, (int) round((float) ($object['y'] ?? 0))));
+ $object['placedAt'] = (int) ($object['placedAt'] ?? now_ms());
+ }
+ $json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ if ($json === false || strlen($json) > 50000) fail('object_too_large', 413);
+ return $object;
+}
+
+function ensure_asset_owner(PDO $db, string $assetId, string $actorId): array {
+ $stmt = $db->prepare('SELECT * FROM assets WHERE id = ? AND deleted_at IS NULL');
+ $stmt->execute([$assetId]);
+ $asset = $stmt->fetch();
+ if (!$asset) fail('asset_not_found', 404);
+ if ($asset['owner_account_id'] !== $actorId) fail('asset_owner_required', 403);
+ return $asset;
+}
+
+function publish_quota_available(PDO $db, array $actor, array $config): bool {
+ $now = now_ms();
+ $created = (int) ($actor['created_at'] ?? $now);
+ $limit = ($now - $created) < (int) $config['trusted_after_ms'] ? (int) $config['publish_limit_first_day'] : (int) $config['publish_limit_trusted'];
+ $since = $now - 60 * 60 * 1000;
+ $stmt = $db->prepare('SELECT COUNT(*) AS c FROM objects WHERE owner_account_id = ? AND published_at >= ? AND deleted_at IS NULL');
+ $stmt->execute([$actor['id'], $since]);
+ return ((int) ($stmt->fetch()['c'] ?? 0)) < $limit;
+}
+
+function process_command(PDO $db, array $command, array $actor, array $config): array {
+ $cmdId = clean_id($command['id'] ?? '');
+ $type = (string) ($command['type'] ?? '');
+ if ($cmdId === '' || $type === '') return ['ok' => false, 'id' => $cmdId, 'error' => 'invalid_command'];
+
+ $stmt = $db->prepare('SELECT id FROM commands WHERE id = ?');
+ $stmt->execute([$cmdId]);
+ if ($stmt->fetch()) return ['ok' => true, 'id' => $cmdId, 'duplicate' => true];
+
+ $now = now_ms();
+ if ($type === 'asset.create') {
+ $asset = normalize_asset(is_array($command['asset'] ?? null) ? $command['asset'] : [], $actor, $config);
+ $existing = $db->prepare('SELECT owner_account_id, deleted_at FROM assets WHERE id = ?');
+ $existing->execute([$asset['id']]);
+ $row = $existing->fetch();
+ if ($row && $row['owner_account_id'] !== $actor['id']) fail('asset_id_already_owned', 403);
+ $json = json_encode($asset, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ $db->prepare('INSERT INTO assets(id, owner_account_id, author_name, json, version, created_at, updated_at, deleted_at, deleted_by, content_hash)
+ VALUES(?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(id) DO UPDATE SET author_name=excluded.author_name, json=excluded.json, version=excluded.version, updated_at=excluded.updated_at, deleted_at=NULL, deleted_by=NULL, content_hash=excluded.content_hash')
+ ->execute([$asset['id'], $actor['id'], $asset['author'], $json, $asset['version'], $asset['createdAt'], $asset['updatedAt'], null, null, $asset['contentHash'] ?? null]);
+ record_event($db, 'asset.upsert', ['type' => 'asset.upsert', 'assetId' => $asset['id'], 'actorAccountId' => $actor['id']]);
+ } elseif ($type === 'asset.delete') {
+ $assetId = clean_id($command['assetId'] ?? '');
+ ensure_asset_owner($db, $assetId, $actor['id']);
+ $db->prepare('UPDATE assets SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ?')
+ ->execute([$now, $actor['id'], $now, $assetId]);
+ $db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE asset_id = ? AND deleted_at IS NULL')
+ ->execute([$now, $actor['id'], $now, $assetId]);
+ record_event($db, 'asset.delete', ['type' => 'asset.delete', 'assetId' => $assetId, 'actorAccountId' => $actor['id']]);
+ } elseif ($type === 'object.publish' || $type === 'object.move') {
+ $kind = (($command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
+ $object = normalize_object(is_array($command['object'] ?? null) ? $command['object'] : [], $kind, $actor, $config);
+ ensure_asset_owner($db, $object['assetId'], $actor['id']);
+ if ($type === 'object.publish' && !publish_quota_available($db, $actor, $config)) fail('publish_limit_reached', 429);
+ $activeCount = (int) $db->query("SELECT COUNT(*) AS c FROM objects WHERE deleted_at IS NULL AND moderation_status = 'active'")->fetch()['c'];
+ if ($type === 'object.publish' && $activeCount >= (int) $config['max_world_objects']) fail('world_object_limit_reached', 409);
+ $json = json_encode($object, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ $publishedAt = (int) ($object['publishedAt'] ?? $now);
+ $db->prepare('INSERT INTO objects(id, kind, asset_id, owner_account_id, json, version, published_at, updated_at, deleted_at, deleted_by, moderation_status)
+ VALUES(?,?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(id) DO UPDATE SET json=excluded.json, version=excluded.version, published_at=excluded.published_at, updated_at=excluded.updated_at, deleted_at=NULL, deleted_by=NULL, moderation_status=\'active\'')
+ ->execute([$object['id'], $kind, $object['assetId'], $actor['id'], $json, $object['version'], $publishedAt, $now, null, null, 'active']);
+ record_event($db, 'object.upsert', ['type' => 'object.upsert', 'kind' => $kind, 'objectId' => $object['id'], 'actorAccountId' => $actor['id']]);
+ } elseif ($type === 'object.delete') {
+ $kind = (($command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
+ $objectId = clean_id($command['objectId'] ?? '');
+ $stmt = $db->prepare('SELECT * FROM objects WHERE id = ? AND kind = ? AND deleted_at IS NULL');
+ $stmt->execute([$objectId, $kind]);
+ $obj = $stmt->fetch();
+ if (!$obj) fail('object_not_found', 404);
+ if ($obj['owner_account_id'] !== $actor['id']) fail('object_owner_required', 403);
+ $db->prepare('UPDATE objects SET deleted_at = ?, deleted_by = ?, updated_at = ? WHERE id = ?')
+ ->execute([$now, $actor['id'], $now, $objectId]);
+ record_event($db, 'object.delete', ['type' => 'object.delete', 'kind' => $kind, 'objectId' => $objectId, 'actorAccountId' => $actor['id']]);
+ } elseif ($type === 'vote.asset' || $type === 'vote.object') {
+ $targetType = $type === 'vote.asset' ? 'asset' : 'object';
+ $targetId = clean_id($command[$targetType . 'Id'] ?? $command['targetId'] ?? '');
+ $value = max(-1, min(1, (int) ($command['value'] ?? $command['delta'] ?? 0)));
+ if ($targetId === '') fail('vote_target_required');
+ if ($value === 0) {
+ $db->prepare('DELETE FROM votes WHERE target_type = ? AND target_id = ? AND voter_account_id = ?')->execute([$targetType, $targetId, $actor['id']]);
+ } else {
+ $db->prepare('INSERT INTO votes(target_type, target_id, voter_account_id, value, updated_at) VALUES(?,?,?,?,?)
+ ON CONFLICT(target_type, target_id, voter_account_id) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at')
+ ->execute([$targetType, $targetId, $actor['id'], $value, $now]);
+ }
+ record_event($db, 'vote.' . $targetType, ['type' => 'vote.' . $targetType, 'targetId' => $targetId, 'actorAccountId' => $actor['id'], 'value' => $value]);
+ } elseif ($type === 'report.object') {
+ $objectId = clean_id($command['objectId'] ?? '');
+ $assetId = clean_id($command['assetId'] ?? '');
+ $kind = (($command['objectKind'] ?? $command['kind'] ?? '') === 'dynamic') ? 'dynamic' : 'static';
+ $reason = clean_text($command['reason'] ?? 'other', 'other', 48);
+ if ($objectId === '' || $assetId === '') fail('report_target_required');
+ $reportId = clean_id($command['reportId'] ?? $command['id'] ?? ('report_' . bin2hex(random_bytes(6))));
+ $db->prepare('INSERT OR IGNORE INTO reports(id, object_id, asset_id, object_kind, reporter_account_id, reason, created_at, status) VALUES(?,?,?,?,?,?,?,?)')
+ ->execute([$reportId, $objectId, $assetId, $kind, $actor['id'], $reason, $now, 'open']);
+ record_event($db, 'report.object', ['type' => 'report.object', 'objectId' => $objectId, 'assetId' => $assetId, 'actorAccountId' => $actor['id'], 'reason' => $reason]);
+ } else {
+ return ['ok' => false, 'id' => $cmdId, 'error' => 'unsupported_command'];
+ }
+
+ $db->prepare('INSERT INTO commands(id, actor_account_id, created_at, applied_at) VALUES(?,?,?,?)')
+ ->execute([$cmdId, $actor['id'], (int) ($command['createdAt'] ?? $now), $now]);
+ return ['ok' => true, 'id' => $cmdId];
+}
+
+function vote_snapshot(PDO $db, string $targetType): array {
+ $stmt = $db->prepare('SELECT target_id, voter_account_id, value FROM votes WHERE target_type = ? AND value != 0');
+ $stmt->execute([$targetType]);
+ $out = [];
+ while ($row = $stmt->fetch()) {
+ $id = $row['target_id'];
+ $value = (int) $row['value'];
+ if (!isset($out[$id])) $out[$id] = ['up' => 0, 'down' => 0, 'voters' => []];
+ if ($value > 0) $out[$id]['up']++;
+ if ($value < 0) $out[$id]['down']++;
+ $out[$id]['voters'][$row['voter_account_id']] = $value;
+ }
+ return $out;
+}
+
+function build_snapshot(PDO $db): array {
+ $assets = [];
+ foreach ($db->query('SELECT json FROM assets WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT 2000') as $row) {
+ $asset = decode_json_row($row['json']);
+ if ($asset) $assets[] = $asset;
+ }
+ $placed = [];
+ $dynamic = [];
+ foreach ($db->query("SELECT kind, json FROM objects WHERE deleted_at IS NULL AND moderation_status = 'active' ORDER BY published_at DESC LIMIT 1000") as $row) {
+ $obj = decode_json_row($row['json']);
+ if (!$obj) continue;
+ if ($row['kind'] === 'dynamic') $dynamic[] = $obj;
+ else $placed[] = $obj;
+ }
+ $assetTombstones = [];
+ foreach ($db->query('SELECT id, deleted_at, deleted_by, version FROM assets WHERE deleted_at IS NOT NULL') as $row) {
+ $assetTombstones[$row['id']] = ['id' => $row['id'], 'deletedAt' => (int) $row['deleted_at'], 'deletedBy' => $row['deleted_by'], 'version' => (int) $row['version']];
+ }
+ $objectTombstones = [];
+ foreach ($db->query('SELECT id, asset_id, deleted_at, deleted_by, version FROM objects WHERE deleted_at IS NOT NULL') as $row) {
+ $objectTombstones[$row['id']] = ['id' => $row['id'], 'assetId' => $row['asset_id'], 'deletedAt' => (int) $row['deleted_at'], 'deletedBy' => $row['deleted_by'], 'version' => (int) $row['version']];
+ }
+ $reports = [];
+ foreach ($db->query("SELECT * FROM reports WHERE status = 'open' ORDER BY created_at DESC LIMIT 500") as $row) {
+ $reports[] = [
+ 'id' => $row['id'],
+ 'objectId' => $row['object_id'],
+ 'assetId' => $row['asset_id'],
+ 'objectKind' => $row['object_kind'],
+ 'reporter' => $row['reporter_account_id'],
+ 'reason' => $row['reason'],
+ 'createdAt' => (int) $row['created_at'],
+ ];
+ }
+ $last = $db->query('SELECT COALESCE(MAX(id), 0) AS id FROM events')->fetch();
+ return [
+ 'ok' => true,
+ 'schema' => 1,
+ 'serverNow' => now_ms(),
+ 'lastEventId' => (int) ($last['id'] ?? 0),
+ 'authority' => ['publish' => 'server', 'objectMove' => 'server', 'dayNight' => 'local', 'dynamicMotion' => 'local'],
+ 'assets' => $assets,
+ 'placed' => $placed,
+ 'dynamicSummons' => $dynamic,
+ 'assetVotes' => vote_snapshot($db, 'asset'),
+ 'objectVotes' => vote_snapshot($db, 'object'),
+ 'moderationReports' => $reports,
+ 'tombstones' => ['assets' => $assetTombstones, 'objects' => $objectTombstones],
+ ];
+}
+
+try {
+ $action = $_GET['action'] ?? '';
+ if ($action === '') {
+ $path = trim((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH), '/');
+ $action = basename($path) === 'index.php' ? '' : basename($path);
+ }
+ $database = db($config);
+
+ if ($action === 'health' || $action === '') {
+ respond(['ok' => true, 'service' => 'pixel-island-api', 'serverNow' => now_ms(), 'sqlite' => true]);
+ }
+
+ if ($action === 'snapshot') {
+ respond(build_snapshot($database));
+ }
+
+ if ($action === 'account') {
+ $data = request_json((int) $config['max_json_bytes']);
+ $account = authenticate($database, is_array($data['account'] ?? null) ? $data['account'] : $data, true);
+ respond(['ok' => true, 'account' => ['id' => $account['id'], 'name' => $account['name'], 'createdAt' => (int) $account['created_at']]]);
+ }
+
+ if ($action === 'commands') {
+ $data = request_json((int) $config['max_json_bytes']);
+ $actor = authenticate($database, is_array($data['account'] ?? null) ? $data['account'] : [], true);
+ $commands = is_array($data['commands'] ?? null) ? $data['commands'] : [];
+ if (count($commands) > 100) fail('too_many_commands', 413);
+ $applied = [];
+ $rejected = [];
+ foreach ($commands as $command) {
+ if (!is_array($command)) continue;
+ try {
+ $database->beginTransaction();
+ $result = process_command($database, $command, $actor, $config);
+ if ($result['ok'] ?? false) {
+ $database->commit();
+ $applied[] = $result['id'];
+ } else {
+ $database->rollBack();
+ $rejected[] = $result;
+ }
+ } catch (Throwable $e) {
+ if ($database->inTransaction()) $database->rollBack();
+ $rejected[] = ['ok' => false, 'id' => clean_id($command['id'] ?? ''), 'error' => $e->getMessage()];
+ }
+ }
+ respond(['ok' => true, 'appliedCommandIds' => $applied, 'rejectedCommands' => $rejected, 'snapshot' => build_snapshot($database)]);
+ }
+
+ fail('unknown_action', 404);
+} catch (Throwable $e) {
+ fail($e->getMessage(), 500);
+}
diff --git a/app.js b/app.js
index 6416fec..c3c0b90 100644
--- a/app.js
+++ b/app.js
@@ -1,12 +1,14 @@
(() => {
'use strict';
- console.info('Pixel Island Summoner loaded');
+ console.info('Pixel Island Summoner shared build loaded');
const STORAGE_KEY = 'pixel-island-summoner:phase6b';
const LEGACY_STORAGE_KEYS = ['pixel-island-summoner:phase6a', 'pixel-island-summoner:phase6b:schema27'];
const SAVE_SCHEMA = 32;
const LOCAL_MANIFEST_FORMAT = 'pixel-island-local-manifest-v1';
+ const SHARED_API_ENDPOINT = './api/index.php';
+ const SHARED_SYNC_INTERVAL_MS = 5000;
const WORLD_W = 144;
const WORLD_H = 112;
const TILE_W = 32;
@@ -27,7 +29,7 @@
const DEPTH_LOCAL_LIGHT_RESPONSE_MULTIPLIER = 5;
const CURSOR_LIGHT_COLOR = '#dff6ff';
const CURSOR_LIGHT_WORLD_RADIUS = 86;
- const CURSOR_LIGHT_INTENSITY = 0.625;
+ const CURSOR_LIGHT_INTENSITY = 0.15625;
const CURSOR_DEPTH_REFLECTION_MULTIPLIER = 1.85;
const BASE_COLOR_CODES = '0123456789abcdefghij';
const ADVANCED_COLOR_CODES = 'klmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + "!#$%&'()*+,-/:;<=>?@[]^_`{|}~";
@@ -45,16 +47,7 @@
const EDITOR_HISTORY_LIMIT = 80;
const MAX_EDITOR_DIMENSION = 64;
const PHASE5_GUARDRAILS = { maxAssets: 220, maxWorldObjects: 1000, maxReports: 200, defaultDisplayLimit: 250, newArrivalSlots: 150, revivalSlots: 100, publishLimitFirstDay: 5, publishLimitTrusted: 10, upvoteDelaySlots: 20, downvoteAdvanceSlots: 25, upvoteRankCap: 50, maxParticles: 200, particleMinZoom: 0.72 };
- const REMOVED_LOW_QUALITY_SEED_ASSET_NAMES = new Set([
- 'Shell Rock',
- 'Azure Minnow',
- 'Firefly Swirl',
- 'Leaf Sparrow',
- 'Silver Trout',
- 'Butterfly Fish'
- ]);
-
- const DEFAULT_GALLERY_KEEP_ASSET_NAMES = new Set([
+ const DEFAULT_GALLERY_DISPLAY_ASSET_NAMES = new Set([
'Pine Cluster',
'Lantern Walker',
'Coral Skiff',
@@ -67,7 +60,79 @@
'Tideglass Lighthouse',
'Rosetta Stela',
'Rain Bell Tower',
- 'Puddle Toad'
+ 'Puddle Toad',
+ 'Aurora Bathhouse',
+ 'Copper Windmill',
+ 'Moon Gate Bridge',
+ 'Starlit Bookshop',
+ 'Crystal Bakery',
+ 'Kelp Observatory',
+ 'Ember Shrine',
+ 'Honeycomb Greenhouse',
+ 'Celadon Pagoda',
+ 'Tide Pearl Arch',
+ 'Lavender Market',
+ 'Skyseed Tower',
+ 'Orchard Terrace',
+ 'Dreamcatcher Tree',
+ 'Nebula Fountain',
+ 'Coral Lighthouse',
+ 'Origami Wind Shrine',
+ 'Rainmaker Drum',
+ 'Velvet Mushroom Grove',
+ 'Star Map Monument',
+ 'Lantern Reef Gate',
+ 'Ember Salamander',
+ 'Moon Snail',
+ 'Ribbon Dragonfly',
+ 'Teacup Automaton',
+ 'Sapphire Crab',
+ 'Cloud Alpaca',
+ 'Starling Bard',
+ 'Glass Jellyfish',
+ 'Copper Heron',
+ 'Paper Fox Kite',
+ 'Prism Seahorse',
+ 'Orchard Sprite',
+ 'Map Turtle',
+ 'Tea Sparrow',
+ 'Bubble Diver',
+ 'Mist Eel',
+ 'Ruby Beetle',
+ 'Snowy Tanuki',
+ 'Lighthouse Keeper',
+ 'Midnight Ramen Cart',
+ 'Quartz Clocktower',
+ 'Lotus Tea Pavilion',
+ 'Meteorite Garden',
+ 'Azure Glasshouse',
+ 'Foxfire Torii',
+ 'Pearl Cloud Altar',
+ 'Clockwork Conservatory',
+ 'Inkcap Mouse',
+ 'Comet Finch',
+ 'Lotus Kappa',
+ 'Lantern Manta',
+ 'Pocket Astronaut',
+ 'Mossy Capybara',
+ 'Opal Octopus',
+ 'Tiny Oracle',
+ 'Amber Apiary',
+ 'Glass Moon Dock',
+ 'Bluebell Chapel',
+ 'Stardust Orchard',
+ 'Prism Ferris Wheel',
+ 'Moss Lantern Gate',
+ 'Shell Music Stage',
+ 'Terracotta Bathhouse',
+ 'Copper Hedgehog',
+ 'Lily Crane',
+ 'Pearl Angler',
+ 'Neon Guppy',
+ 'Cloud Jelly Pup',
+ 'Ember Firefly',
+ 'Teal Seahare',
+ 'Honeybee Courier'
]);
const editorActions = () => MODULES.EditorActions || null;
@@ -329,11 +394,14 @@
let pendingReport = null;
let libraryFilter = 'all';
let librarySearch = '';
- let libraryViewTab = 'mine';
- let libraryScrollTop = 0;
- let libraryScrollLockTop = null;
- let suppressLibraryScrollCapture = 0;
- let defaultGalleryAssetNames = null;
+ const uiState = {
+ library: { viewTab: 'mine', search: '', filter: 'all', scroll: { bodyTop: 0, gridTop: 0 }, restoreToken: 0 },
+ selectedAssetId: null
+ };
+ let libraryViewTab = uiState.library.viewTab;
+ let libraryScrollTop = uiState.library.scroll.bodyTop;
+ let libraryGridScrollTop = uiState.library.scroll.gridTop;
+ let libraryScrollFrameToken = uiState.library.restoreToken;
let renderPhase = null;
let editorView = { zoom: 1, x: 0, y: 0 };
let editorPointer = { panning: false, pointerId: null, lastX: 0, lastY: 0 };
@@ -348,11 +416,15 @@
let shapeGesture = null;
let shapePreview = null;
let placementPreview = null;
+ let sharedSyncInFlight = false;
+ let sharedSyncTimer = 0;
+ let sharedSyncEnabled = true;
async function bootstrap() {
resizeCanvas();
resetView(false);
await restoreStateFromIndexedDb();
+ await initializeSharedWorld();
rebuildWorldIndex();
hydrateRuntime();
coastalFoamTextures = buildCoastalFoamTextures();
@@ -363,10 +435,11 @@
refreshCategoryUI();
setupEditor(8, blankPixels(8), null);
clearEditorHistory();
- renderLibrary();
+ renderLibrary({ preserveScroll: false });
updateSelectedLabel();
hydrateVisualSettingsUI();
updateRotationStats();
+ startSharedSyncLoop();
scheduleFrame();
}
@@ -375,6 +448,11 @@
resizeCanvas();
render();
});
+ drawerScroller()?.addEventListener('scroll', () => {
+ const body = drawerScroller();
+ libraryScrollTop = body?.scrollTop || 0;
+ uiState.library.scroll.bodyTop = libraryScrollTop;
+ }, { passive: true });
els.openEditor?.addEventListener('click', () => { clearWorldSelection(false); setDrawerOpen(true); });
els.openCreate?.addEventListener('click', () => { setTab('draw'); clearWorldSelection(false); setDrawerOpen(true); });
@@ -386,7 +464,7 @@
});
drawerScroller()?.addEventListener('scroll', () => {
- if (suppressLibraryScrollCapture > 0 || activeTabName !== 'library') return;
+ if (activeTabName !== 'library') return;
const top = drawerScroller()?.scrollTop;
if (Number.isFinite(top)) libraryScrollTop = top;
}, { passive: true });
@@ -625,46 +703,47 @@
return els.drawer?.querySelector('.drawerBody') || null;
}
- function captureLibraryScroll() {
- const top = drawerScroller()?.scrollTop;
- if (Number.isFinite(top) && suppressLibraryScrollCapture <= 0) libraryScrollTop = top;
- return Number.isFinite(libraryScrollTop) ? libraryScrollTop : 0;
+ function syncLibraryStateFromLegacy() {
+ uiState.library.viewTab = libraryViewTab;
+ uiState.library.search = librarySearch;
+ uiState.library.filter = libraryFilter;
+ uiState.library.scroll.bodyTop = Number.isFinite(libraryScrollTop) ? libraryScrollTop : 0;
+ uiState.library.scroll.gridTop = Number.isFinite(libraryGridScrollTop) ? libraryGridScrollTop : 0;
+ uiState.selectedAssetId = selectedAssetId || null;
}
- function preferredLibraryScrollTop(fallback = null) {
- if (Number.isFinite(libraryScrollLockTop)) return libraryScrollLockTop;
- if (Number.isFinite(fallback)) return fallback;
- return Number.isFinite(libraryScrollTop) ? libraryScrollTop : 0;
+ function syncLegacyFromLibraryState() {
+ libraryViewTab = uiState.library.viewTab || 'mine';
+ librarySearch = uiState.library.search || '';
+ libraryFilter = uiState.library.filter || 'all';
+ libraryScrollTop = Number(uiState.library.scroll?.bodyTop) || 0;
+ libraryGridScrollTop = Number(uiState.library.scroll?.gridTop) || 0;
+ libraryScrollFrameToken = Number(uiState.library.restoreToken) || 0;
+ selectedAssetId = uiState.selectedAssetId || selectedAssetId || null;
}
- function holdLibraryScroll(scrollTop, work) {
- const top = Number.isFinite(scrollTop) ? scrollTop : captureLibraryScroll();
- libraryScrollTop = top;
- libraryScrollLockTop = top;
- suppressLibraryScrollCapture++;
- try {
- return work?.();
- } finally {
- restoreDrawerScroll(top);
- requestAnimationFrame(() => restoreDrawerScroll(top));
- setTimeout(() => restoreDrawerScroll(top), 0);
- setTimeout(() => {
- restoreDrawerScroll(top);
- suppressLibraryScrollCapture = Math.max(0, suppressLibraryScrollCapture - 1);
- if (suppressLibraryScrollCapture === 0) libraryScrollLockTop = null;
- }, 180);
- }
+ function captureLibraryScrollState() {
+ const body = drawerScroller();
+ const grid = els.assetList?.querySelector('.assetSectionGrid');
+ const bodyTop = Number.isFinite(body?.scrollTop) ? body.scrollTop : libraryScrollTop;
+ const gridTop = Number.isFinite(grid?.scrollTop) ? grid.scrollTop : libraryGridScrollTop;
+ uiState.library.scroll = {
+ bodyTop: Math.max(0, Number(bodyTop) || 0),
+ gridTop: Math.max(0, Number(gridTop) || 0)
+ };
+ syncLegacyFromLibraryState();
+ return { ...uiState.library.scroll };
}
function setTab(name, options = {}) {
const keepScroll = Boolean(options.preserveScroll);
- const scrollTop = keepScroll ? preferredLibraryScrollTop(drawerScroller()?.scrollTop) : null;
+ const scrollState = keepScroll ? captureLibraryScrollState() : null;
activeTabName = name;
els.tabs.forEach((button) => button.classList.toggle('active', button.dataset.tab === name));
els.panels.forEach((panel) => panel.classList.toggle('active', panel.id === `tab-${name}`));
if (name !== 'draw' && advancedDraw) setAdvancedDraw(false, { redraw: false });
else els.drawer?.classList.toggle('advancedTools', name === 'draw' && advancedDraw);
- if (keepScroll && Number.isFinite(scrollTop)) restoreDrawerScroll(scrollTop);
+ if (keepScroll) restoreLibraryScrollState(scrollState);
}
function setAdvancedDraw(enabled, options = {}) {
@@ -683,7 +762,7 @@
toggleHidden(els.particleDirectionWrap, !advancedDraw || paintTool !== 'particle');
toggleHidden(els.advancedHint, !advancedDraw);
els.toggleAdvanced?.classList.toggle('active', advancedDraw);
- if (els.toggleAdvanced) els.toggleAdvanced.textContent = advancedDraw ? '竏・Advanced' : '・・Advanced';
+ if (els.toggleAdvanced) els.toggleAdvanced.textContent = advancedDraw ? '- Advanced' : '+ Advanced';
els.drawer?.classList.toggle('advancedTools', activeTabName === 'draw' && advancedDraw);
if (!advancedDraw && (paintTool === 'depth' || paintTool === 'light' || paintTool === 'particle')) setPaintTool('brush');
updateParticleUI();
@@ -743,14 +822,14 @@
if (!els.roleHint) return;
const role = currentRole();
const textMap = {
- human: 'Humans move by target direction. Draw as 笆カ Right, or press 笳 Left if your canvas is left-facing so Save mirrors it.',
- animal: 'Animals move by target direction. Draw as 笆カ Right, or press 笳 Left if your canvas is left-facing so Save mirrors it.',
- bird: 'Birds fly over terrain and seek nature. Draw as 笆カ Right, or press 笳 Left if your canvas is left-facing so Save mirrors it.',
- nature: 'Nature attracts animals and birds. Static sprites are drawn at 2テ・scale.',
+ human: 'Humans move by target direction. Draw as Right, or press Left if your canvas is left-facing so Save mirrors it.',
+ animal: 'Animals move by target direction. Draw as Right, or press Left if your canvas is left-facing so Save mirrors it.',
+ bird: 'Birds fly over terrain and seek nature. Draw as Right, or press Left if your canvas is left-facing so Save mirrors it.',
+ nature: 'Nature attracts animals and birds. Static sprites are drawn at 2x scale.',
fish: 'Fish swim on water tiles. Draw as right-facing, or use Left if your canvas is left-facing so Save mirrors it.',
building: 'Buildings attract humans. Use Door to mark the entrance.',
ship: 'Ships are static water objects. They float and emit ring ripples.',
- other: 'Other objects are neutral scenery and render at 2テ・scale.'
+ other: 'Other objects are neutral scenery and render at 2x scale.'
};
els.roleHint.textContent = textMap[role] || textMap.other;
}
@@ -764,11 +843,11 @@
const lightText = lightCount ? `${lightCount}/${maxLights} lamp cell${lightCount === 1 ? '' : 's'}` : `no light (${maxLights} max)`;
const particleText = particlePixels.length ? `particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : 'no particles';
const doorText = role === 'building' ? ` / door ${Math.round(doorPixel.x)},${Math.round(doorPixel.y)}` : '';
- els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}テ・{editorHeight} canvas / 2テ・static pixels / ${lightText} / ${particleText}${doorText}.`;
+ els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}x${editorHeight} canvas / 2x static pixels / ${lightText} / ${particleText}${doorText}.`;
} else {
const particleText = particlePixels.length ? ` / particles ${particlePixels.length} cell${particlePixels.length === 1 ? '' : 's'}` : '';
- const sideText = editingSide === 'left' ? 'canvas marked 笳 Left; Save mirrors it into canonical 笆カ Right' : 'canvas marked canonical 笆カ Right';
- els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}テ・{editorHeight} canvas / ${sideText}${particleText}.`;
+ const sideText = editingSide === 'left' ? 'canvas marked Left; Save mirrors it into canonical Right' : 'canvas marked canonical Right';
+ els.settingsSummary.textContent = `${cap(role)} / ${editorWidth}x${editorHeight} canvas / ${sideText}${particleText}.`;
}
}
@@ -1115,7 +1194,7 @@
const limit = getLocalDisplayLimit();
const publish = getPublishQuotaStatus();
const quotaText = publish.accountRequired ? 'account required to publish' : `publish quota ${publish.used}/${publish.limit} this hour`;
- els.rotationStats.textContent = `Island exhibition ${visible}/${entries.length} objects ツキ newest ${buckets.newest.length}/150 ツキ revival ${buckets.revival.length}/100 ツキ local cap ${limit} ツキ rotated out ${hiddenByRotation} ツキ ${quotaText}.`;
+ els.rotationStats.textContent = `Island exhibition ${visible}/${entries.length} objects / newest ${buckets.newest.length}/150 / revival ${buckets.revival.length}/100 / local cap ${limit} / rotated out ${hiddenByRotation} / ${quotaText}.`;
updateAccountUI();
}
@@ -1274,7 +1353,6 @@
function isCurrentUserAsset(asset) {
if (!asset) return false;
- if (isSampleGalleryAsset(asset)) return false;
if (!isSharedWorld()) {
const owner = normalizeOwnerAccountId(asset.ownerAccountId);
if (owner) return owner === currentAccountId();
@@ -1323,9 +1401,164 @@
};
}
saveState();
+ triggerSharedSyncSoon();
if (options.toast !== false) toast('Change queued for server validation.');
}
+ function makeSharedAssetCommandForPublish(asset, createdAt = Date.now()) {
+ if (!asset || !asset.id) return null;
+ const command = makeSharedCommand('asset.create', { asset: { ...asset } });
+ command.createdAt = createdAt;
+ return command;
+ }
+
+ function queueSharedAssetBeforeObject(asset, objectCommand) {
+ const createdAt = Math.max(0, Number(objectCommand?.createdAt || Date.now()) - 1);
+ const assetCommand = makeSharedAssetCommandForPublish(asset, createdAt);
+ if (assetCommand) queueSharedCommand(assetCommand, { toast: false });
+ }
+
+ function sharedApiUrl(action) {
+ return `${SHARED_API_ENDPOINT}?action=${encodeURIComponent(action)}`;
+ }
+
+ async function sharedApiGet(action) {
+ const response = await fetch(sharedApiUrl(action), {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' },
+ credentials: 'same-origin',
+ cache: 'no-store'
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || data?.ok === false) throw new Error(data?.error || `HTTP ${response.status}`);
+ return data;
+ }
+
+ async function sharedApiPost(action, payload) {
+ const response = await fetch(sharedApiUrl(action), {
+ method: 'POST',
+ headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ cache: 'no-store',
+ body: JSON.stringify(payload || {})
+ });
+ const data = await response.json().catch(() => ({}));
+ if (!response.ok || data?.ok === false) throw new Error(data?.error || `HTTP ${response.status}`);
+ return data;
+ }
+
+ async function initializeSharedWorld() {
+ ensureWorldProtectionState();
+ state.worldMode = 'shared';
+ try {
+ await syncSharedWorld({ initial: true, quiet: true });
+ } catch (error) {
+ sharedSyncEnabled = false;
+ console.warn('Shared API is not available; using local-only fallback.', error);
+ state.worldMode = 'local';
+ toast('Shared API unavailable. Running in local-only mode.');
+ }
+ }
+
+ function startSharedSyncLoop() {
+ if (sharedSyncTimer || !sharedSyncEnabled) return;
+ sharedSyncTimer = window.setInterval(() => {
+ syncSharedWorld({ quiet: true }).catch((error) => console.warn('Shared sync failed.', error));
+ }, SHARED_SYNC_INTERVAL_MS);
+ }
+
+ function triggerSharedSyncSoon() {
+ if (!sharedSyncEnabled || !isSharedWorld()) return;
+ window.setTimeout(() => {
+ syncSharedWorld({ quiet: true }).catch((error) => console.warn('Shared sync failed.', error));
+ }, 80);
+ }
+
+ async function registerSharedAccount() {
+ if (!sharedSyncEnabled || !state.account?.createdAt) return null;
+ return sharedApiPost('account', { account: state.account });
+ }
+
+ async function flushSharedOutbox() {
+ if (!Phase2Sync?.readOutboxCommands || !Phase2Sync?.deleteOutboxCommands || !state.account?.createdAt) return null;
+ const commands = await Phase2Sync.readOutboxCommands(100);
+ if (!commands.length) return null;
+ const data = await sharedApiPost('commands', { account: state.account, commands });
+ const applied = Array.isArray(data.appliedCommandIds) ? data.appliedCommandIds : [];
+ if (applied.length) await Phase2Sync.deleteOutboxCommands(applied);
+ const rejected = Array.isArray(data.rejectedCommands) ? data.rejectedCommands : [];
+ if (rejected.length) {
+ console.warn('Some shared commands were rejected.', rejected);
+ toast(`Server rejected ${rejected.length} queued change(s).`);
+ }
+ return data.snapshot || null;
+ }
+
+ async function syncSharedWorld(options = {}) {
+ if (!sharedSyncEnabled || sharedSyncInFlight) return false;
+ sharedSyncInFlight = true;
+ try {
+ ensureWorldProtectionState();
+ state.worldMode = 'shared';
+ if (state.account?.createdAt) await registerSharedAccount();
+ let snapshot = await flushSharedOutbox();
+ if (!snapshot) snapshot = await sharedApiGet('snapshot');
+ applySharedSnapshot(snapshot, options);
+ return true;
+ } finally {
+ sharedSyncInFlight = false;
+ }
+ }
+
+ function applySharedSnapshot(snapshot, options = {}) {
+ if (!snapshot || snapshot.ok === false) return false;
+ ensureWorldProtectionState();
+ state.worldMode = 'shared';
+ state.serverSync.lastServerEventId = snapshot.lastEventId || state.serverSync.lastServerEventId || null;
+ state.serverSync.authority = { ...DEFAULT_SERVER_AUTHORITY, ...(snapshot.authority || {}) };
+ state.serverSync.clock = {
+ ...(state.serverSync.clock || {}),
+ worldTimeMs: Number(snapshot.serverNow) || Date.now(),
+ syncedAt: Date.now(),
+ dayMs: DAY_MS
+ };
+
+ const serverAssetIds = new Set((snapshot.assets || []).map((asset) => String(asset?.id || '')).filter(Boolean));
+ const tombstonedAssets = snapshot.tombstones?.assets || {};
+ const localOnlyAssets = (state.assets || []).filter((asset) => {
+ const id = String(asset?.id || '');
+ if (!id || serverAssetIds.has(id) || tombstonedAssets[id]) return false;
+ return true;
+ });
+ const serverAssets = (snapshot.assets || []).map(normalizeAsset);
+ state.assets = dedupeNormalizedAssets([...serverAssets, ...localOnlyAssets]);
+ state.placed = normalizePlacements(snapshot.placed || []);
+ state.dynamicSummons = normalizeDynamicSummons(snapshot.dynamicSummons || []);
+ state.assetVotes = snapshot.assetVotes || {};
+ state.objectVotes = snapshot.objectVotes || {};
+ state.moderationReports = normalizeModerationReports(snapshot.moderationReports || []);
+ state.tombstones = snapshot.tombstones || { assets: {}, objects: {} };
+ rebuildWorldIndex();
+ hydrateRuntime();
+ cachePhase2Snapshot();
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(buildLocalManifest(state)));
+ if (selectedAssetId && !findAsset(selectedAssetId)) selectedAssetId = state.assets[0]?.id ?? null;
+ renderLibrary({ preserveScroll: activeTabName === 'library' });
+ updateSelectionBubble(performance.now());
+ updateRotationStats();
+ render();
+ if (!options.quiet && !options.initial) toast('Shared island synced.');
+ return true;
+ }
+
+ function queueSharedVote(targetType, targetId, value) {
+ if (!isSharedWorld() || !targetId) return;
+ ensureLocalAccount('vote');
+ const payload = targetType === 'asset'
+ ? { assetId: targetId, value }
+ : { objectId: targetId, value };
+ queueSharedCommand(makeSharedCommand(`vote.${targetType}`, payload), { toast: false });
+ }
function shouldHideUnderPlacementPreview(assetId, x, y) {
@@ -1447,7 +1680,7 @@
localY = worldY - info.drawY;
}
if (localX < 0 || localY < 0 || localX >= info.sprite.width || localY >= info.sprite.height) return false;
- // Small works at 16テ・6 or below are hard to click. Use the whole sprite box
+ // Small works at 16x6 or below are hard to click. Use the whole sprite box
// for them, including transparent cells, while larger works still use opaque pixels.
const aw = assetWidth(asset);
const ah = assetHeight(asset);
@@ -1485,7 +1718,7 @@
}
const asset = findAsset(selectedAssetId);
if (els.selectedAssetName) {
- els.selectedAssetName.textContent = asset ? `${asset.name || 'Untitled'} ツキ ${cap(subtypeToRole(asset))}` : 'No collection work selected';
+ els.selectedAssetName.textContent = asset ? `${cleanWorkText(asset.name || 'Untitled', 'Untitled')} / ${cap(subtypeToRole(asset))}` : 'No collection work selected';
}
}
@@ -1505,13 +1738,13 @@
if (els.tileInfo) els.tileInfo.textContent = lines.join('\n');
}
- function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now()) {
+ function selectWorldObject(kind, objectId, assetId, selectedAt = performance.now(), options = {}) {
selectedObject = { kind, id: objectId, assetId, selectedAt };
- cameraFollowSelected = true;
+ cameraFollowSelected = options.follow !== false;
if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true;
selectedAssetId = assetId;
updateSelectedLabel();
- renderLibrary({ preserveScroll: true });
+ if (options.renderLibrary !== false) renderLibrary({ preserveScroll: true });
render(selectedAt);
updateSelectionBubble(selectedAt);
}
@@ -1613,7 +1846,9 @@
status: 'active',
version: Number(existingObject?.version || 0) + 1
};
- queueSharedCommand(makeSharedCommand(existingObject ? 'object.move' : 'object.publish', { kind, object: nextObject }));
+ const objectCommand = makeSharedCommand(existingObject ? 'object.move' : 'object.publish', { kind, object: nextObject });
+ queueSharedAssetBeforeObject(asset, objectCommand);
+ queueSharedCommand(objectCommand);
return;
}
@@ -2903,7 +3138,7 @@
else {
const maxLights = lightBudgetForArea(editorWidth, editorHeight);
if (lightPixels.length >= maxLights) {
- toast(`Light limit: ${maxLights} for ${editorWidth}テ・{editorHeight} cells.`);
+ toast(`Light limit: ${maxLights} for ${editorWidth}x${editorHeight} cells.`);
return;
}
lightPixels.push(point);
@@ -3135,7 +3370,7 @@
const pixelBlob = buildPixelBlob(encodedRight, savedWidth, savedHeight);
const asset = {
id: existing?.id || uid(),
- name,
+ name: cleanWorkText(name, 'Untitled'),
category,
subtype,
size: savedSize,
@@ -3204,7 +3439,7 @@
saveState();
spriteCache.clear();
hydrateRuntime();
- renderLibrary();
+ renderLibrary({ preserveScroll: activeTabName === 'library' });
updateSelectedLabel();
els.lineageNote.textContent = 'Saved as a permanent collection work. Island placement is a separate temporary exhibition object.';
return asset;
@@ -3352,6 +3587,7 @@
state.authorName = state.account.name;
}
saveState();
+ registerSharedAccount().catch((error) => console.warn('Shared account registration failed.', error));
updateAccountUI();
renderLibrary();
if (!silent) toast('Local account generated. Change the password after creation.');
@@ -3401,7 +3637,7 @@
});
}
- function focusAssetInWorld(asset) {
+ function focusAssetInWorld(asset, options = {}) {
const placed = state.placed.find((p) => p.assetId === asset.id);
const dyn = state.dynamicSummons.find((p) => p.assetId === asset.id);
const target = placed ? { x: placed.x + .5, y: placed.y + .5 } : dyn ? { x: dyn.homeX + .5, y: dyn.homeY + .5 } : null;
@@ -3415,18 +3651,15 @@
pos.y -= getLiftAtCoord(target.x, target.y);
view.x = cw / 2 - pos.x * view.zoom;
view.y = ch / 2 - pos.y * view.zoom;
- if (placed) selectWorldObject('static', placed.id, asset.id, performance.now());
- else if (dyn) selectWorldObject('dynamic', dyn.id, asset.id, performance.now());
+ if (placed) selectWorldObject('static', placed.id, asset.id, performance.now(), { renderLibrary: options.renderLibrary !== false });
+ else if (dyn) selectWorldObject('dynamic', dyn.id, asset.id, performance.now(), { renderLibrary: options.renderLibrary !== false });
setMode('inspect');
}
function renderLibrary(options = {}) {
+ syncLibraryStateFromLegacy();
const preserveScroll = options.preserveScroll ?? Boolean(els.drawer?.classList.contains('open'));
- const preservedScrollTop = preserveScroll ? preferredLibraryScrollTop(drawerScroller()?.scrollTop) : null;
- const finishStableScroll = preserveScroll && Number.isFinite(preservedScrollTop)
- ? stabilizeLibraryScrollSpace(preservedScrollTop)
- : () => {};
- if (preserveScroll && Number.isFinite(preservedScrollTop)) libraryScrollTop = preservedScrollTop;
+ const scrollState = preserveScroll ? (options.scrollState || captureLibraryScrollState()) : null;
els.assetList.replaceChildren();
if (els.likedCodex) {
els.likedCodex.replaceChildren();
@@ -3438,7 +3671,7 @@
renderCollectionTabs({ mine: [], others: [], favorite: [] });
els.assetList.append(makeLibraryEmptyNote('No visible assets.'));
renderHiddenAssets();
- finishStableScroll();
+ if (preserveScroll) restoreLibraryScrollState(scrollState);
return;
}
const matchesFilter = (asset) => libraryFilter === 'all' || subtypeToRole(asset) === libraryFilter;
@@ -3453,13 +3686,15 @@
const favorite = getLikedAssets().filter((asset) => !isModeratedAssetHidden(asset) && !state.hiddenAssets?.[asset.id] && matchesFilter(asset) && matchesSearch(asset));
const groups = { mine, others, favorite };
if (!groups[libraryViewTab]) libraryViewTab = 'mine';
+ uiState.library.viewTab = libraryViewTab;
renderCollectionTabs(groups);
if (libraryViewTab === 'mine') addLibrarySection('My works', mine, 'No assets in this tab.');
else if (libraryViewTab === 'others') addLibrarySection('Others', others, 'No assets in this tab.');
else addLibrarySection('Favorite', favorite, 'Works you upvote appear here.');
renderHiddenAssets();
- finishStableScroll();
+ if (preserveScroll) restoreLibraryScrollState(scrollState);
+ syncLibraryStateFromLegacy();
}
function renderLibraryToolbar(visibleAssets) {
@@ -3479,10 +3714,12 @@
search.value = librarySearch;
search.addEventListener('input', () => {
librarySearch = search.value || '';
+ uiState.library.search = librarySearch;
const caret = search.selectionStart || librarySearch.length;
- renderLibrary({ preserveScroll: true });
+ const scrollState = captureLibraryScrollState();
+ renderLibrary({ preserveScroll: true, scrollState });
requestAnimationFrame(() => {
- restoreDrawerScroll(preferredLibraryScrollTop());
+ restoreLibraryScrollState(scrollState);
const nextSearch = els.assetList?.querySelector('.collectionSearch');
if (!nextSearch) return;
nextSearch.focus?.({ preventScroll: true });
@@ -3500,11 +3737,12 @@
button.classList.toggle('active', libraryFilter === filter);
button.textContent = filter === 'all' ? 'All' : cap(filter);
button.addEventListener('click', () => {
- const scrollTop = captureLibraryScroll();
- holdLibraryScroll(scrollTop, () => {
- libraryFilter = filter;
- renderLibrary({ preserveScroll: true });
- });
+ const scrollState = captureLibraryScrollState();
+ libraryFilter = filter;
+ uiState.library.filter = libraryFilter;
+ libraryGridScrollTop = 0;
+ scrollState.gridTop = 0;
+ renderLibrary({ preserveScroll: true, scrollState });
});
chips.append(button);
}
@@ -3527,11 +3765,12 @@
button.classList.toggle('active', libraryViewTab === key);
button.textContent = `${label} ${groups[key]?.length ?? 0}`;
button.addEventListener('click', () => {
- const scrollTop = captureLibraryScroll();
- holdLibraryScroll(scrollTop, () => {
- libraryViewTab = key;
- renderLibrary({ preserveScroll: true });
- });
+ const scrollState = captureLibraryScrollState();
+ libraryViewTab = key;
+ uiState.library.viewTab = libraryViewTab;
+ libraryGridScrollTop = 0;
+ scrollState.gridTop = 0;
+ renderLibrary({ preserveScroll: true, scrollState });
});
tabs.append(button);
}
@@ -3557,40 +3796,38 @@
}
const grid = document.createElement('div');
grid.className = 'assetSectionGrid';
+ grid.addEventListener('scroll', () => { libraryGridScrollTop = grid.scrollTop; uiState.library.scroll.gridTop = grid.scrollTop; }, { passive: true });
for (const asset of assets) grid.append(makeAssetCard(asset));
els.assetList.append(grid);
}
- function restoreDrawerScroll(scrollTop) {
- const scroller = drawerScroller();
- if (!scroller || !Number.isFinite(scrollTop)) return;
- const top = Math.max(0, scrollTop);
+ function restoreLibraryScrollState(snapshot) {
+ const next = snapshot || uiState.library.scroll || { bodyTop: 0, gridTop: 0 };
+ const bodyTop = Math.max(0, Number(next.bodyTop) || 0);
+ const gridTop = Math.max(0, Number(next.gridTop) || 0);
+ uiState.library.scroll = { bodyTop, gridTop };
+ syncLegacyFromLibraryState();
+ const token = ++uiState.library.restoreToken;
+ libraryScrollFrameToken = token;
const apply = () => {
- if (!drawerScroller()) return;
- drawerScroller().scrollTop = top;
+ if (token !== uiState.library.restoreToken) return;
+ const body = drawerScroller();
+ const grid = els.assetList?.querySelector('.assetSectionGrid');
+ if (body && Math.abs(body.scrollTop - bodyTop) > 1) body.scrollTop = bodyTop;
+ if (grid && Math.abs(grid.scrollTop - gridTop) > 1) grid.scrollTop = gridTop;
};
apply();
requestAnimationFrame(apply);
requestAnimationFrame(() => requestAnimationFrame(apply));
+ setTimeout(apply, 0);
+ setTimeout(apply, 60);
}
- function stabilizeLibraryScrollSpace(scrollTop) {
- const scroller = drawerScroller();
- const list = els.assetList;
- if (!scroller || !list || !Number.isFinite(scrollTop)) return () => {};
- const minimumListHeight = Math.max(list.offsetHeight || 0, scroller.scrollHeight || 0, scroller.clientHeight + scrollTop + 24);
- const previousMinHeight = list.style.minHeight;
- list.style.minHeight = `${Math.ceil(minimumListHeight)}px`;
- return () => {
- restoreDrawerScroll(scrollTop);
- requestAnimationFrame(() => {
- restoreDrawerScroll(scrollTop);
- requestAnimationFrame(() => {
- restoreDrawerScroll(scrollTop);
- list.style.minHeight = previousMinHeight;
- });
- });
- };
+ function withLibraryScrollPreserved(mutator) {
+ const snapshot = captureLibraryScrollState();
+ mutator?.(snapshot);
+ restoreLibraryScrollState(snapshot);
+ return snapshot;
}
function makeAssetCard(asset) {
@@ -3621,30 +3858,31 @@
statusRow.append(roleTag, placeTag);
meta.append(statusRow);
- card.addEventListener('click', () => {
- const scrollTop = captureLibraryScroll();
- holdLibraryScroll(scrollTop, () => {
- selectedAssetId = expanded ? null : asset.id;
- updateSelectedLabel();
- renderLibrary({ preserveScroll: true });
- if (selectedAssetId) focusAssetInWorld(asset);
- });
+ card.addEventListener('click', (event) => {
+ event.preventDefault();
+ const scrollState = captureLibraryScrollState();
+ selectedAssetId = expanded ? null : asset.id;
+ uiState.selectedAssetId = selectedAssetId || null;
+ updateSelectedLabel();
+ renderLibrary({ preserveScroll: true, scrollState });
+ if (selectedAssetId) focusAssetInWorld(asset, { renderLibrary: false });
+ restoreLibraryScrollState(scrollState);
});
if (expanded) {
const lineage = asset.parentAssetId ? ' / derivative' : '';
const span1 = document.createElement('span');
- span1.textContent = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${assetWidth(asset)}テ・{assetHeight(asset)}${lineage}`;
+ span1.textContent = `${displayCategory(asset)} / ${cap(asset.subtype)} / ${assetWidth(asset)}x${assetHeight(asset)}${lineage}`;
const span2 = document.createElement('span');
- span2.textContent = `Author: ${displayAssetAuthor(asset)} ツキ Remixed: ${getRemixCount(asset.id)}`;
+ span2.textContent = `Author: ${displayAssetAuthor(asset)} / Remixed: ${getRemixCount(asset.id)}`;
meta.append(span1, span2);
const actions = document.createElement('div');
actions.className = 'assetActions';
const assetVotes = getAssetVoteCounts(asset.id);
const assetPreviousVote = assetVotes.voters?.[currentVoterKey()] || 0;
- const up = makeButton(`笆イ ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
- const down = makeButton(`笆シ ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
+ const up = makeButton(`Up ${assetVotes.up}`, (event) => { event.stopPropagation(); voteAsset(asset.id, 1); });
+ const down = makeButton(`Down ${assetVotes.down}`, (event) => { event.stopPropagation(); voteAsset(asset.id, -1); });
up.classList.toggle('activeVote', assetPreviousVote > 0);
down.classList.toggle('activeVote', assetPreviousVote < 0);
up.classList.toggle('mutedVote', assetPreviousVote < 0);
@@ -3656,10 +3894,10 @@
const edit = isMine ? makeButton('Edit', (event) => { event?.stopPropagation?.(); editOriginalAsset(asset); }) : null;
const move = isMine ? makeButton('Place/Move', (event) => {
event?.stopPropagation?.();
- const scrollTop = captureLibraryScroll();
+ const scrollState = captureLibraryScrollState();
selectedAssetId = asset.id;
updateSelectedLabel();
- holdLibraryScroll(scrollTop, () => renderLibrary({ preserveScroll: true }));
+ renderLibrary({ preserveScroll: true, scrollState });
setMode('place');
setDrawerOpen(false);
toast('Click a valid tile to place or move it.');
@@ -3673,7 +3911,7 @@
meta.append(actions);
} else {
const mini = document.createElement('span');
- mini.textContent = `${cap(asset.subtype)} ツキ ${assetWidth(asset)}テ・{assetHeight(asset)}`;
+ mini.textContent = `${cap(asset.subtype)} / ${assetWidth(asset)}x${assetHeight(asset)}`;
meta.append(mini);
if (isCurrentUserAsset(asset)) {
const quickActions = document.createElement('div');
@@ -3905,6 +4143,7 @@
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
+ button.addEventListener('pointerdown', (event) => event.preventDefault());
button.addEventListener('click', onClick);
return button;
}
@@ -3932,7 +4171,7 @@
setupEditor(aw, right, null, depthPixels, ah);
clearEditorHistory();
refreshCategoryUI();
- els.lineageNote.textContent = editExisting ? `Editing 窶・{asset.name}窶・ Save updates this collection work.` : `Remixing 窶・{asset.name}窶・ Save creates a separate new collection work.`;
+ els.lineageNote.textContent = editExisting ? `Editing "${cleanWorkText(asset.name, 'Untitled')}". Save updates this collection work.` : `Remixing "${cleanWorkText(asset.name, 'Untitled')}". Save creates a separate new collection work.`;
}
function editOriginalAsset(asset) {
@@ -3959,17 +4198,6 @@
return false;
}
- function getDefaultGalleryAssetNames() {
- if (!defaultGalleryAssetNames) {
- defaultGalleryAssetNames = new Set(buildDefaultGalleryPack(Date.now()).assets.map((asset) => asset.name).filter(Boolean));
- }
- return defaultGalleryAssetNames;
- }
-
- function isSampleGalleryAsset(asset) {
- if (!asset) return false;
- return asset.meta?.galleryOrigin === 'default-gallery' || getDefaultGalleryAssetNames().has(asset.name || '');
- }
async function deleteAsset(asset) {
if (!asset) return;
@@ -3978,6 +4206,7 @@
}
async function deleteAssetIds(deletedAssetIds, label = 'selected works', permissionAsset = null) {
+ const scrollState = activeTabName === 'library' ? captureLibraryScrollState() : null;
deletedAssetIds = new Set([...deletedAssetIds].filter(Boolean));
if (!deletedAssetIds.size) return;
const assetsToDelete = (state.assets || []).filter((candidate) => deletedAssetIds.has(candidate.id));
@@ -4022,6 +4251,9 @@
deletedBy: currentAccountId() || 'local',
ownerAccountId: normalizeOwnerAccountId(deleted.ownerAccountId),
author: deleted.author || '',
+ contentHash: deleted.contentHash || computeAssetContentHash(deleted),
+ category: deleted.category || '',
+ subtype: deleted.subtype || '',
version: Number(deleted.version) || 1
};
const deletedName = deleted.name || '';
@@ -4330,7 +4562,7 @@
if (els.analogClock) {
els.analogClock.style.setProperty('--clock-rotate', `${degrees.toFixed(2)}deg`);
els.analogClock.setAttribute('aria-label', `${phase.label} island clock`);
- els.analogClock.title = `${phase.label} ツキ 10 min = 1 island day`;
+ els.analogClock.title = `${phase.label} - 10 min = 1 island day`;
}
const second = Math.floor(getAuthoritativeNow() / 1000);
if (second !== lastClockSecond) {
@@ -4661,9 +4893,9 @@
ctx.save();
ctx.globalCompositeOperation = 'screen';
const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, radius);
- glow.addColorStop(0, 'rgba(223, 246, 255, .15)');
- glow.addColorStop(0.2, 'rgba(190, 236, 255, .085)');
- glow.addColorStop(0.62, 'rgba(150, 220, 255, .032)');
+ glow.addColorStop(0, 'rgba(223, 246, 255, .0375)');
+ glow.addColorStop(0.2, 'rgba(190, 236, 255, .02125)');
+ glow.addColorStop(0.62, 'rgba(150, 220, 255, .008)');
glow.addColorStop(1, 'rgba(150, 220, 255, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
@@ -4777,18 +5009,19 @@
if (includeSelectBounce && selectedObject?.id === item.source?.id) {
const selectionElapsed = time - (selectedObject.selectedAt || 0);
- const selectedT = clamp(selectionElapsed / 560, 0, 1);
- if (selectionElapsed < 700) {
- const bounce = selectedT < 1 ? Math.sin(selectedT * Math.PI) : 0;
- bob += -bounce * (asset.category === 'dynamic' ? 9 : 8);
+ if (selectionElapsed < 920) {
+ const jumpT = clamp(selectionElapsed / 520, 0, 1);
+ const bounce = Math.sin(jumpT * Math.PI);
+ bob += -bounce * (asset.category === 'dynamic' ? 10 : 8.5);
const tiltSeed = parseInt(fnv1a(`${item.source?.id || asset.id}:select`).slice(0, 6), 16) || 1;
const tiltRand = pseudoNoise(tiltSeed * 0.013) - 0.5;
- angle += tiltRand * 0.32 * bounce;
- // Squash/stretch lands later than the vertical bounce peak.
- const landT = clamp((selectionElapsed - 300) / 560, 0, 1);
- const land = Math.exp(-Math.pow((landT - 0.88) / 0.085, 2));
- stretchX += land * 0.30;
- stretchY -= land * 0.21;
+ angle += tiltRand * 0.34 * bounce;
+ const anticipation = Math.exp(-Math.pow((selectionElapsed - 45) / 70, 2));
+ const landing = Math.exp(-Math.pow((selectionElapsed - 540) / 105, 2));
+ const rebound = Math.exp(-Math.pow((selectionElapsed - 720) / 110, 2));
+ stretchX += anticipation * 0.16 + landing * 0.34 - rebound * 0.08;
+ stretchY -= anticipation * 0.11 + landing * 0.24;
+ stretchY += rebound * 0.07;
}
}
@@ -4886,7 +5119,7 @@
externalLight: !options.cursor,
ownerId: source.ownerId || null,
intensity: options.cursor
- ? clamp((Number(source.intensity ?? 1) || 1) * (0.95 + proximity * 1.45), 1.05, 2.45)
+ ? clamp((Number(source.intensity ?? 1) || 1) * (0.95 + proximity * 1.45), 0.52, 1.23)
: clamp((Number(source.intensity ?? 1) || 1) * (0.44 + proximity * 0.78), 0.22, 1.45),
radiusCells
});
@@ -5187,6 +5420,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
}
function voteAsset(assetId, delta) {
+ const scrollState = activeTabName === 'library' ? captureLibraryScrollState() : null;
state.assetVotes ||= {};
const votes = state.assetVotes[assetId] || { up: 0, down: 0, voters: {} };
votes.voters ||= {};
@@ -5201,17 +5435,19 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
votes.voters[voter] = delta;
}
state.assetVotes[assetId] = votes;
+ if (isSharedWorld()) queueSharedVote('asset', assetId, votes.voters[voter] || 0);
saveState();
- renderLibrary();
+ renderLibrary({ preserveScroll: Boolean(scrollState), scrollState });
}
function hideAsset(assetId) {
+ const scrollState = activeTabName === 'library' ? captureLibraryScrollState() : null;
state.hiddenAssets ||= {};
state.hiddenAssets[assetId] = true;
if (selectedAssetId === assetId) selectedAssetId = state.assets.find((a) => !state.hiddenAssets?.[a.id])?.id || null;
if (selectedObject?.assetId === assetId) selectedObject = null;
saveState();
- renderLibrary();
+ renderLibrary({ preserveScroll: Boolean(scrollState), scrollState });
updateSelectionBubble(performance.now());
toast('Asset hidden locally.');
}
@@ -5317,10 +5553,10 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
const { asset, objectId } = anchor;
const rawSx = anchor.x * view.zoom + view.x;
const rawSy = anchor.y * view.zoom + view.y;
- const fixedFollowBubble = cameraFollowSelected && selectedObject;
- const sx = fixedFollowBubble ? cw * 0.5 : rawSx;
- const sy = fixedFollowBubble ? Math.max(92, ch * 0.32) : rawSy;
- if (!fixedFollowBubble && (sx < -120 || sy < -160 || sx > cw + 120 || sy > ch + 120)) {
+ const fixedFollowBubble = false;
+ const sx = rawSx;
+ const sy = rawSy;
+ if (sx < -120 || sy < -160 || sx > cw + 120 || sy > ch + 120) {
els.selectionBubble.hidden = true;
if (els.bubbleMenuActions) els.bubbleMenuActions.hidden = true;
return;
@@ -5398,6 +5634,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
votes.voters[voter] = delta;
}
state.objectVotes[key] = votes;
+ if (isSharedWorld()) queueSharedVote('object', key, votes.voters[voter] || 0);
saveState();
updateSelectionBubble(performance.now());
renderLibrary();
@@ -5519,6 +5756,16 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
createdAt: Date.now()
};
state.moderationReports.push(report);
+ if (isSharedWorld()) {
+ ensureLocalAccount('report');
+ queueSharedCommand(makeSharedCommand('report.object', {
+ reportId: report.id,
+ objectId: report.objectId,
+ assetId: report.assetId,
+ objectKind: report.objectKind,
+ reason: report.reason
+ }), { toast: false });
+ }
state.moderationReports = state.moderationReports.slice(-PHASE5_GUARDRAILS.maxReports);
state.hiddenObjects ||= {};
state.hiddenObjects[target.id] = true;
@@ -6233,7 +6480,8 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
const ownerByAssetId = new Map(normalized.assets.map((asset) => [asset.id, normalizeOwnerAccountId(asset.ownerAccountId)]));
normalized.placed.forEach((item) => { item.ownerAccountId = normalizeOwnerAccountId(item.ownerAccountId, ownerByAssetId.get(item.assetId) || ''); });
normalized.dynamicSummons.forEach((item) => { item.ownerAccountId = normalizeOwnerAccountId(item.ownerAccountId, ownerByAssetId.get(item.assetId) || ''); });
- normalized.assets = normalized.assets.filter((asset) => !normalized.tombstones.assets?.[asset.id]);
+ const deletedAssetKeys = new Set(Object.values(normalized.tombstones.assets || {}).flatMap((t) => [t?.id && `id:${t.id}`, t?.contentHash && `hash:${t.contentHash}`, t?.name && `name:${t.name}`].filter(Boolean)));
+ normalized.assets = normalized.assets.filter((asset) => !deletedAssetKeys.has(`id:${asset.id}`) && !deletedAssetKeys.has(`hash:${asset.contentHash || computeAssetContentHash(asset)}`) && !deletedAssetKeys.has(`name:${asset.name}`));
normalized.placed = normalized.placed.filter((item) => !normalized.tombstones.objects?.[item.id]);
normalized.dynamicSummons = normalized.dynamicSummons.filter((item) => !normalized.tombstones.objects?.[item.id]);
normalized.placed = repairStaticPlacementTerrain(normalized.placed, normalized.assets);
@@ -6249,6 +6497,15 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
return target;
}
+ function cleanWorkText(value, fallback = '') {
+ const text = String(value ?? '')
+ .replace(/[\uFFFD\u0080-\u009F]/g, '')
+ .replace(/[。-゚]+/g, '')
+ .replace(/\s{2,}/g, ' ')
+ .trim();
+ return text || fallback;
+ }
+
function normalizeAsset(asset) {
const width = assetWidth(asset);
const height = assetHeight(asset);
@@ -6258,7 +6515,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
const subtype = asset.subtype || (category === 'dynamic' ? 'human' : 'other');
const normalized = {
id: asset.id || uid(),
- name: asset.name || 'Untitled',
+ name: cleanWorkText(asset.name || 'Untitled', 'Untitled'),
category,
subtype,
size,
@@ -6271,7 +6528,7 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
originalAssetId: asset.originalAssetId || null,
createdAt: asset.createdAt || Date.now(),
updatedAt: asset.updatedAt || asset.createdAt || Date.now(),
- author: asset.author || 'Local Artist',
+ author: cleanWorkText(asset.author || 'Local Artist', 'Local Artist'),
ownerAccountId: normalizeOwnerAccountId(asset.ownerAccountId, asset.accountId || asset.authorId || ''),
version: Number(asset.version) || 1,
meta: normalizeAssetMeta(asset.meta || {}, category, subtype, width, height, right)
@@ -6791,12 +7048,371 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
depthPixels: filledDepthFromPixelsSized(drawBloomSpriteRight(), 12, 12),
lightPixels: [{ x: 5, y: 3 }],
lightColor: '#ffe7a8'
- })
+ }),
+ makeAssetSized('Aurora Bathhouse', 'static', 'building', drawAuroraBathhouse(), 28, 28, {
+ depthPixels: filledDepthFromPixelsSized(drawAuroraBathhouse(), 28, 28),
+ lightPixels: [{ x: 10, y: 17 }, { x: 17, y: 17 }, { x: 14, y: 22 }],
+ lightColor: '#baf6ff',
+ door: { x: 14, y: 24 }
+ }),
+ makeAssetSized('Copper Windmill', 'static', 'building', drawCopperWindmill(), 24, 32, {
+ depthPixels: filledDepthFromPixelsSized(drawCopperWindmill(), 24, 32),
+ lightPixels: [{ x: 11, y: 21 }],
+ lightColor: '#ffd48a',
+ door: { x: 11, y: 28 }
+ }),
+ makeAssetSized('Moon Gate Bridge', 'static', 'nature', drawMoonGateBridge(), 32, 20, {
+ depthPixels: filledDepthFromPixelsSized(drawMoonGateBridge(), 32, 20),
+ lightPixels: [{ x: 9, y: 9 }, { x: 22, y: 9 }],
+ lightColor: '#e5f6ff'
+ }),
+ makeAssetSized('Starlit Bookshop', 'static', 'building', drawStarlitBookshop(), 24, 28, {
+ depthPixels: filledDepthFromPixelsSized(drawStarlitBookshop(), 24, 28),
+ lightPixels: [{ x: 8, y: 14 }, { x: 15, y: 14 }, { x: 12, y: 7 }],
+ lightColor: '#ffe99a',
+ door: { x: 12, y: 24 }
+ }),
+ makeAssetSized('Crystal Bakery', 'static', 'building', drawCrystalBakery(), 24, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawCrystalBakery(), 24, 24),
+ lightPixels: [{ x: 7, y: 14 }, { x: 16, y: 14 }],
+ lightColor: '#ffd8ef',
+ door: { x: 12, y: 21 }
+ }),
+ makeAssetSized('Kelp Observatory', 'static', 'building', drawKelpObservatory(), 28, 32, {
+ depthPixels: filledDepthFromPixelsSized(drawKelpObservatory(), 28, 32),
+ lightPixels: [{ x: 13, y: 9 }, { x: 14, y: 9 }, { x: 9, y: 19 }, { x: 18, y: 19 }],
+ lightColor: '#9ff4ff',
+ door: { x: 14, y: 28 }
+ }),
+ makeAssetSized('Ember Shrine', 'static', 'building', drawEmberShrine(), 20, 28, {
+ depthPixels: filledDepthFromPixelsSized(drawEmberShrine(), 20, 28),
+ lightPixels: [{ x: 10, y: 13 }, { x: 6, y: 20 }, { x: 14, y: 20 }],
+ lightColor: '#ffb36b',
+ door: { x: 10, y: 24 }
+ }),
+ makeAssetSized('Honeycomb Greenhouse', 'static', 'building', drawHoneycombGreenhouse(), 28, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawHoneycombGreenhouse(), 28, 24),
+ lightPixels: [{ x: 8, y: 13 }, { x: 14, y: 10 }, { x: 20, y: 13 }],
+ lightColor: '#fff08a',
+ door: { x: 14, y: 21 }
+ }),
+ makeAssetSized('Celadon Pagoda', 'static', 'building', drawCeladonPagoda(), 24, 36, {
+ depthPixels: filledDepthFromPixelsSized(drawCeladonPagoda(), 24, 36),
+ lightPixels: [{ x: 11, y: 15 }, { x: 12, y: 15 }, { x: 11, y: 25 }, { x: 12, y: 25 }],
+ lightColor: '#e7ffb0',
+ door: { x: 12, y: 32 }
+ }),
+ makeAssetSized('Tide Pearl Arch', 'static', 'nature', drawTidePearlArch(), 24, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawTidePearlArch(), 24, 24),
+ lightPixels: [{ x: 12, y: 8 }],
+ lightColor: '#e9fdff'
+ }),
+ makeAssetSized('Lavender Market', 'static', 'building', drawLavenderMarket(), 32, 20, {
+ depthPixels: filledDepthFromPixelsSized(drawLavenderMarket(), 32, 20),
+ lightPixels: [{ x: 8, y: 12 }, { x: 16, y: 12 }, { x: 24, y: 12 }],
+ lightColor: '#ffe0ff',
+ door: { x: 16, y: 17 }
+ }),
+ makeAssetSized('Skyseed Tower', 'static', 'building', drawSkyseedTower(), 24, 36, {
+ depthPixels: filledDepthFromPixelsSized(drawSkyseedTower(), 24, 36),
+ lightPixels: [{ x: 12, y: 6 }, { x: 11, y: 22 }, { x: 12, y: 22 }],
+ lightColor: '#cfffd7',
+ door: { x: 12, y: 32 }
+ }),
+ makeAssetSized('Orchard Terrace', 'static', 'nature', drawOrchardTerrace(), 32, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawOrchardTerrace(), 32, 24),
+ particlePixels: [{ x: 9, y: 8, c: nearestPaletteCode('#ffd66e'), dir: 'up' }, { x: 23, y: 10, c: nearestPaletteCode('#ff7f6e'), dir: 'up' }]
+ }),
+ makeAssetSized('Dreamcatcher Tree', 'static', 'nature', drawDreamcatcherTree(), 28, 32, {
+ depthPixels: filledDepthFromPixelsSized(drawDreamcatcherTree(), 28, 32),
+ particlePixels: [{ x: 12, y: 9, c: nearestPaletteCode('#ead1ff'), dir: 'up' }, { x: 17, y: 11, c: nearestPaletteCode('#fff2b0'), dir: 'up' }]
+ }),
+ makeAssetSized('Nebula Fountain', 'static', 'nature', drawNebulaFountain(), 24, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawNebulaFountain(), 24, 24),
+ lightPixels: [{ x: 11, y: 9 }, { x: 12, y: 9 }],
+ lightColor: '#b8e7ff',
+ particlePixels: [{ x: 9, y: 11, c: nearestPaletteCode('#dffbff'), dir: 'up' }, { x: 15, y: 11, c: nearestPaletteCode('#ead1ff'), dir: 'up' }]
+ }),
+ makeAssetSized('Coral Lighthouse', 'static', 'building', drawCoralLighthouse(), 24, 36, {
+ depthPixels: filledDepthFromPixelsSized(drawCoralLighthouse(), 24, 36),
+ lightPixels: [{ x: 11, y: 6 }, { x: 12, y: 6 }, { x: 13, y: 6 }],
+ lightColor: '#fff1a8',
+ door: { x: 12, y: 32 }
+ }),
+ makeAssetSized('Origami Wind Shrine', 'static', 'nature', drawOrigamiWindShrine(), 20, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawOrigamiWindShrine(), 20, 24),
+ particlePixels: [{ x: 9, y: 8, c: nearestPaletteCode('#eef4ff'), dir: 'up' }, { x: 12, y: 6, c: nearestPaletteCode('#cfeaff'), dir: 'up' }]
+ }),
+ makeAssetSized('Rainmaker Drum', 'static', 'nature', drawRainmakerDrum(), 20, 20, {
+ depthPixels: filledDepthFromPixelsSized(drawRainmakerDrum(), 20, 20),
+ particlePixels: [{ x: 7, y: 5, c: nearestPaletteCode('#bfefff'), dir: 'down' }, { x: 12, y: 5, c: nearestPaletteCode('#dffbff'), dir: 'down' }]
+ }),
+ makeAssetSized('Velvet Mushroom Grove', 'static', 'nature', drawVelvetMushroomGrove(), 24, 22, {
+ depthPixels: filledDepthFromPixelsSized(drawVelvetMushroomGrove(), 24, 22),
+ lightPixels: [{ x: 7, y: 12 }, { x: 16, y: 10 }],
+ lightColor: '#ffb8f2'
+ }),
+ makeAssetSized('Star Map Monument', 'static', 'nature', drawStarMapMonument(), 20, 20, {
+ depthPixels: filledDepthFromPixelsSized(drawStarMapMonument(), 20, 20),
+ lightPixels: [{ x: 10, y: 5 }, { x: 6, y: 11 }, { x: 14, y: 12 }],
+ lightColor: '#dce8ff'
+ }),
+ makeAssetSized('Lantern Reef Gate', 'static', 'ship', drawLanternReefGate(), 32, 20, {
+ depthPixels: filledDepthFromPixelsSized(drawLanternReefGate(), 32, 20),
+ lightPixels: [{ x: 10, y: 9 }, { x: 21, y: 9 }],
+ lightColor: '#ffd98a'
+ }),
+ makeAssetSized('Ember Salamander', 'dynamic', 'animal', drawEmberSalamanderRight(), 14, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawEmberSalamanderRight(), 14, 10),
+ particlePixels: [{ x: 2, y: 5, c: nearestPaletteCode('#ffb36b'), dir: 'up' }]
+ }),
+ makeAssetSized('Moon Snail', 'dynamic', 'animal', drawMoonSnailRight(), 12, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawMoonSnailRight(), 12, 10),
+ lightPixels: [{ x: 5, y: 4 }],
+ lightColor: '#eef4ff'
+ }),
+ makeAssetSized('Ribbon Dragonfly', 'dynamic', 'animal', drawRibbonDragonflyRight(), 14, 8, {
+ depthPixels: filledDepthFromPixelsSized(drawRibbonDragonflyRight(), 14, 8),
+ particlePixels: [{ x: 4, y: 3, c: nearestPaletteCode('#ead1ff'), dir: 'up' }]
+ }),
+ makeAssetSized('Teacup Automaton', 'dynamic', 'human', drawTeacupAutomatonRight(), 12, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawTeacupAutomatonRight(), 12, 16),
+ lightPixels: [{ x: 8, y: 7 }],
+ lightColor: '#ffe99a'
+ }),
+ makeAssetSized('Sapphire Crab', 'dynamic', 'fish', drawSapphireCrabRight(), 12, 8, {
+ depthPixels: filledDepthFromPixelsSized(drawSapphireCrabRight(), 12, 8),
+ particlePixels: [{ x: 2, y: 5, c: nearestPaletteCode('#c8f4ff'), dir: 'up' }]
+ }),
+ makeAssetSized('Cloud Alpaca', 'dynamic', 'animal', drawCloudAlpacaRight(), 14, 12, {
+ depthPixels: filledDepthFromPixelsSized(drawCloudAlpacaRight(), 14, 12)
+ }),
+ makeAssetSized('Starling Bard', 'dynamic', 'human', drawStarlingBardRight(), 12, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawStarlingBardRight(), 12, 16),
+ lightPixels: [{ x: 5, y: 5 }],
+ lightColor: '#ffe99a'
+ }),
+ makeAssetSized('Glass Jellyfish', 'dynamic', 'fish', drawGlassJellyfishRight(), 12, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawGlassJellyfishRight(), 12, 16),
+ lightPixels: [{ x: 6, y: 4 }],
+ lightColor: '#aef8ff',
+ particlePixels: [{ x: 5, y: 11, c: nearestPaletteCode('#c8f4ff'), dir: 'up' }]
+ }),
+ makeAssetSized('Copper Heron', 'dynamic', 'bird', drawCopperHeronRight(), 12, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawCopperHeronRight(), 12, 16)
+ }),
+ makeAssetSized('Paper Fox Kite', 'dynamic', 'animal', drawPaperFoxKiteRight(), 14, 12, {
+ depthPixels: filledDepthFromPixelsSized(drawPaperFoxKiteRight(), 14, 12),
+ particlePixels: [{ x: 4, y: 3, c: nearestPaletteCode('#eef4ff'), dir: 'up' }]
+ }),
+ makeAssetSized('Prism Seahorse', 'dynamic', 'fish', drawPrismSeahorseRight(), 10, 14, {
+ depthPixels: filledDepthFromPixelsSized(drawPrismSeahorseRight(), 10, 14),
+ lightPixels: [{ x: 5, y: 5 }],
+ lightColor: '#d7c6ff'
+ }),
+ makeAssetSized('Orchard Sprite', 'dynamic', 'animal', drawOrchardSpriteRight(), 10, 12, {
+ depthPixels: filledDepthFromPixelsSized(drawOrchardSpriteRight(), 10, 12),
+ particlePixels: [{ x: 5, y: 3, c: nearestPaletteCode('#ffd66e'), dir: 'up' }]
+ }),
+ makeAssetSized('Map Turtle', 'dynamic', 'animal', drawMapTurtleRight(), 12, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawMapTurtleRight(), 12, 10)
+ }),
+ makeAssetSized('Tea Sparrow', 'dynamic', 'bird', drawTeaSparrowRight(), 8, 8, {
+ depthPixels: filledDepthFromPixelsSized(drawTeaSparrowRight(), 8, 8)
+ }),
+ makeAssetSized('Bubble Diver', 'dynamic', 'human', drawBubbleDiverRight(), 12, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawBubbleDiverRight(), 12, 16),
+ lightPixels: [{ x: 6, y: 3 }],
+ lightColor: '#dffbff'
+ }),
+ makeAssetSized('Mist Eel', 'dynamic', 'fish', drawMistEelRight(), 14, 6, {
+ depthPixels: filledDepthFromPixelsSized(drawMistEelRight(), 14, 6),
+ particlePixels: [{ x: 2, y: 3, c: nearestPaletteCode('#dffbff'), dir: 'up' }]
+ }),
+ makeAssetSized('Ruby Beetle', 'dynamic', 'animal', drawRubyBeetleRight(), 10, 8, {
+ depthPixels: filledDepthFromPixelsSized(drawRubyBeetleRight(), 10, 8)
+ }),
+ makeAssetSized('Snowy Tanuki', 'dynamic', 'animal', drawSnowyTanukiRight(), 12, 12, {
+ depthPixels: filledDepthFromPixelsSized(drawSnowyTanukiRight(), 12, 12)
+ }),
+ makeAssetSized('Lighthouse Keeper', 'dynamic', 'human', drawLighthouseKeeperRight(), 12, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawLighthouseKeeperRight(), 12, 16),
+ lightPixels: [{ x: 8, y: 7 }],
+ lightColor: '#fff1a8'
+ }),
+ makeAssetSized('Midnight Ramen Cart', 'static', 'building', drawMidnightRamenCart(), 28, 20, {
+ depthPixels: filledDepthFromPixelsSized(drawMidnightRamenCart(), 28, 20),
+ lightPixels: [{ x: 8, y: 10 }, { x: 18, y: 10 }, { x: 13, y: 15 }],
+ lightColor: '#ffd58a',
+ door: { x: 13, y: 17 }
+ }),
+ makeAssetSized('Quartz Clocktower', 'static', 'building', drawQuartzClocktower(), 22, 34, {
+ depthPixels: filledDepthFromPixelsSized(drawQuartzClocktower(), 22, 34),
+ lightPixels: [{ x: 10, y: 8 }, { x: 11, y: 8 }, { x: 10, y: 22 }, { x: 11, y: 22 }],
+ lightColor: '#e6fbff',
+ door: { x: 11, y: 31 }
+ }),
+ makeAssetSized('Lotus Tea Pavilion', 'static', 'building', drawLotusTeaPavilion(), 30, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawLotusTeaPavilion(), 30, 24),
+ lightPixels: [{ x: 9, y: 14 }, { x: 20, y: 14 }, { x: 15, y: 18 }],
+ lightColor: '#ffe0f1',
+ door: { x: 15, y: 21 }
+ }),
+ makeAssetSized('Meteorite Garden', 'static', 'nature', drawMeteoriteGarden(), 26, 20, {
+ depthPixels: filledDepthFromPixelsSized(drawMeteoriteGarden(), 26, 20),
+ lightPixels: [{ x: 13, y: 8 }, { x: 7, y: 13 }, { x: 19, y: 14 }],
+ lightColor: '#b7e9ff',
+ particlePixels: [{ x: 13, y: 7, c: nearestPaletteCode('#dffbff'), dir: 'up' }, { x: 18, y: 9, c: nearestPaletteCode('#b7e9ff'), dir: 'up' }]
+ }),
+ makeAssetSized('Azure Glasshouse', 'static', 'building', drawAzureGlasshouse(), 28, 26, {
+ depthPixels: filledDepthFromPixelsSized(drawAzureGlasshouse(), 28, 26),
+ lightPixels: [{ x: 8, y: 15 }, { x: 14, y: 12 }, { x: 20, y: 15 }],
+ lightColor: '#b9f5ff',
+ door: { x: 14, y: 23 }
+ }),
+ makeAssetSized('Foxfire Torii', 'static', 'nature', drawFoxfireTorii(), 24, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawFoxfireTorii(), 24, 24),
+ lightPixels: [{ x: 6, y: 14 }, { x: 17, y: 14 }, { x: 12, y: 8 }],
+ lightColor: '#ffb36b',
+ particlePixels: [{ x: 6, y: 13, c: nearestPaletteCode('#ff9e66'), dir: 'up' }, { x: 17, y: 13, c: nearestPaletteCode('#ffd58a'), dir: 'up' }]
+ }),
+ makeAssetSized('Pearl Cloud Altar', 'static', 'nature', drawPearlCloudAltar(), 24, 22, {
+ depthPixels: filledDepthFromPixelsSized(drawPearlCloudAltar(), 24, 22),
+ lightPixels: [{ x: 12, y: 9 }, { x: 8, y: 15 }, { x: 16, y: 15 }],
+ lightColor: '#f3fbff',
+ particlePixels: [{ x: 12, y: 8, c: nearestPaletteCode('#f3fbff'), dir: 'up' }]
+ }),
+ makeAssetSized('Clockwork Conservatory', 'static', 'building', drawClockworkConservatory(), 28, 28, {
+ depthPixels: filledDepthFromPixelsSized(drawClockworkConservatory(), 28, 28),
+ lightPixels: [{ x: 8, y: 16 }, { x: 14, y: 11 }, { x: 20, y: 16 }],
+ lightColor: '#ffe9a8',
+ door: { x: 14, y: 25 }
+ }),
+ makeAssetSized('Inkcap Mouse', 'dynamic', 'animal', drawInkcapMouseRight(), 12, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawInkcapMouseRight(), 12, 10),
+ particlePixels: [{ x: 4, y: 2, c: nearestPaletteCode('#cbb8ff'), dir: 'up' }]
+ }),
+ makeAssetSized('Comet Finch', 'dynamic', 'bird', drawCometFinchRight(), 12, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawCometFinchRight(), 12, 10),
+ lightPixels: [{ x: 8, y: 4 }],
+ lightColor: '#fff0a8'
+ }),
+ makeAssetSized('Lotus Kappa', 'dynamic', 'animal', drawLotusKappaRight(), 12, 14, {
+ depthPixels: filledDepthFromPixelsSized(drawLotusKappaRight(), 12, 14),
+ lightPixels: [{ x: 6, y: 3 }],
+ lightColor: '#d9ffd6'
+ }),
+ makeAssetSized('Lantern Manta', 'dynamic', 'fish', drawLanternMantaRight(), 14, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawLanternMantaRight(), 14, 10),
+ lightPixels: [{ x: 7, y: 4 }, { x: 10, y: 6 }],
+ lightColor: '#aef8ff',
+ particlePixels: [{ x: 2, y: 5, c: nearestPaletteCode('#c8f4ff'), dir: 'up' }]
+ }),
+ makeAssetSized('Pocket Astronaut', 'dynamic', 'human', drawPocketAstronautRight(), 12, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawPocketAstronautRight(), 12, 16),
+ lightPixels: [{ x: 6, y: 4 }],
+ lightColor: '#e8fbff'
+ }),
+ makeAssetSized('Mossy Capybara', 'dynamic', 'animal', drawMossyCapybaraRight(), 14, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawMossyCapybaraRight(), 14, 10),
+ particlePixels: [{ x: 6, y: 2, c: nearestPaletteCode('#a6d66b'), dir: 'up' }]
+ }),
+ makeAssetSized('Opal Octopus', 'dynamic', 'fish', drawOpalOctopusRight(), 12, 12, {
+ depthPixels: filledDepthFromPixelsSized(drawOpalOctopusRight(), 12, 12),
+ lightPixels: [{ x: 6, y: 4 }],
+ lightColor: '#ffcaf0',
+ particlePixels: [{ x: 5, y: 10, c: nearestPaletteCode('#f3fbff'), dir: 'up' }]
+ }),
+ makeAssetSized('Tiny Oracle', 'dynamic', 'human', drawTinyOracleRight(), 10, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawTinyOracleRight(), 10, 16),
+ lightPixels: [{ x: 5, y: 5 }],
+ lightColor: '#e6d6ff'
+ }),
+ makeAssetSized('Amber Apiary', 'static', 'building', drawAmberApiary(), 24, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawAmberApiary(), 24, 24),
+ lightPixels: [{ x: 12, y: 10 }, { x: 7, y: 15 }, { x: 17, y: 15 }],
+ lightColor: '#ffd66e',
+ particlePixels: [{ x: 12, y: 6, c: nearestPaletteCode('#ffe99a'), dir: 'up' }]
+ }),
+ makeAssetSized('Glass Moon Dock', 'static', 'ship', drawGlassMoonDock(), 32, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawGlassMoonDock(), 32, 16),
+ lightPixels: [{ x: 8, y: 5 }, { x: 24, y: 5 }],
+ lightColor: '#bdf7ff'
+ }),
+ makeAssetSized('Bluebell Chapel', 'static', 'building', drawBluebellChapel(), 22, 30, {
+ depthPixels: filledDepthFromPixelsSized(drawBluebellChapel(), 22, 30),
+ lightPixels: [{ x: 11, y: 12 }, { x: 10, y: 24 }],
+ lightColor: '#d8d2ff'
+ }),
+ makeAssetSized('Stardust Orchard', 'static', 'nature', drawStardustOrchard(), 28, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawStardustOrchard(), 28, 24),
+ lightPixels: [{ x: 8, y: 7 }, { x: 17, y: 6 }, { x: 22, y: 12 }],
+ lightColor: '#fff4a8',
+ particlePixels: [{ x: 17, y: 6, c: nearestPaletteCode('#fff4a8'), dir: 'up' }]
+ }),
+ makeAssetSized('Prism Ferris Wheel', 'static', 'building', drawPrismFerrisWheel(), 32, 32, {
+ depthPixels: filledDepthFromPixelsSized(drawPrismFerrisWheel(), 32, 32),
+ lightPixels: [{ x: 16, y: 9 }, { x: 9, y: 15 }, { x: 23, y: 15 }],
+ lightColor: '#c7f0ff'
+ }),
+ makeAssetSized('Moss Lantern Gate', 'static', 'nature', drawMossLanternGate(), 26, 24, {
+ depthPixels: filledDepthFromPixelsSized(drawMossLanternGate(), 26, 24),
+ lightPixels: [{ x: 8, y: 11 }, { x: 18, y: 11 }],
+ lightColor: '#b8ff9a'
+ }),
+ makeAssetSized('Shell Music Stage', 'static', 'building', drawShellMusicStage(), 30, 22, {
+ depthPixels: filledDepthFromPixelsSized(drawShellMusicStage(), 30, 22),
+ lightPixels: [{ x: 9, y: 10 }, { x: 20, y: 10 }],
+ lightColor: '#ffd0f0'
+ }),
+ makeAssetSized('Terracotta Bathhouse', 'static', 'building', drawTerracottaBathhouse(), 28, 26, {
+ depthPixels: filledDepthFromPixelsSized(drawTerracottaBathhouse(), 28, 26),
+ lightPixels: [{ x: 14, y: 15 }, { x: 10, y: 21 }, { x: 18, y: 21 }],
+ lightColor: '#ffbd7a'
+ }),
+ makeAssetSized('Copper Hedgehog', 'dynamic', 'animal', drawCopperHedgehogRight(), 12, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawCopperHedgehogRight(), 12, 10)
+ }),
+ makeAssetSized('Lily Crane', 'dynamic', 'bird', drawLilyCraneRight(), 12, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawLilyCraneRight(), 12, 16),
+ lightPixels: [{ x: 5, y: 4 }],
+ lightColor: '#f4f8ff'
+ }),
+ makeAssetSized('Pearl Angler', 'dynamic', 'human', drawPearlAnglerRight(), 12, 16, {
+ depthPixels: filledDepthFromPixelsSized(drawPearlAnglerRight(), 12, 16),
+ lightPixels: [{ x: 10, y: 6 }],
+ lightColor: '#dffbff'
+ }),
+ makeAssetSized('Neon Guppy', 'dynamic', 'fish', drawNeonGuppyRight(), 12, 8, {
+ depthPixels: filledDepthFromPixelsSized(drawNeonGuppyRight(), 12, 8),
+ lightPixels: [{ x: 7, y: 3 }],
+ lightColor: '#5ff0ff'
+ }),
+ makeAssetSized('Cloud Jelly Pup', 'dynamic', 'animal', drawCloudJellyPupRight(), 12, 12, {
+ depthPixels: filledDepthFromPixelsSized(drawCloudJellyPupRight(), 12, 12),
+ lightPixels: [{ x: 5, y: 4 }],
+ lightColor: '#e4f8ff'
+ }),
+ makeAssetSized('Ember Firefly', 'dynamic', 'animal', drawEmberFireflyRight(), 10, 8, {
+ depthPixels: filledDepthFromPixelsSized(drawEmberFireflyRight(), 10, 8),
+ lightPixels: [{ x: 7, y: 3 }],
+ lightColor: '#ffb15a',
+ particlePixels: [{ x: 7, y: 3, c: nearestPaletteCode('#ffb15a'), dir: 'up' }]
+ }),
+ makeAssetSized('Teal Seahare', 'dynamic', 'fish', drawTealSeahareRight(), 12, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawTealSeahareRight(), 12, 10),
+ lightPixels: [{ x: 8, y: 4 }],
+ lightColor: '#8ff6d1'
+ }),
+ makeAssetSized('Honeybee Courier', 'dynamic', 'bird', drawHoneybeeCourierRight(), 12, 10, {
+ depthPixels: filledDepthFromPixelsSized(drawHoneybeeCourierRight(), 12, 10),
+ lightPixels: [{ x: 8, y: 4 }],
+ lightColor: '#ffe66d'
+ }),
];
const idByName = Object.fromEntries(assets.map((a) => [a.name, a.id]));
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
- const removedLowQualitySeedAssetIds = new Set([...REMOVED_LOW_QUALITY_SEED_ASSET_NAMES].map((name) => idByName[name]).filter(Boolean));
const normalizeSeedPlacementTerrain = (object) => {
const asset = assetById.get(object.assetId);
if (!asset || asset.category !== 'static') return object;
@@ -6807,19 +7423,25 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
const target = wantsWater ? findNearestTerrain('water', object.x, object.y) : findNearestLandTerrain(object.x, object.y);
return { ...object, x: target.x, y: target.y };
};
- const applySeedQualityFilter = (gallery) => ({
- ...gallery,
- assets: gallery.assets
- .filter((asset) => DEFAULT_GALLERY_KEEP_ASSET_NAMES.has(asset.name) && !removedLowQualitySeedAssetIds.has(asset.id))
- .map((asset) => ({ ...asset, author: 'Local Artist', ownerAccountId: '' })),
- placed: gallery.placed
- .filter((object) => DEFAULT_GALLERY_KEEP_ASSET_NAMES.has(assetById.get(object.assetId)?.name || '') && !removedLowQualitySeedAssetIds.has(object.assetId))
+ const keepOnlyDisplayedSeedAssets = (gallery) => {
+ const isAllowedSeedAssetId = (assetId) => DEFAULT_GALLERY_DISPLAY_ASSET_NAMES.has(assetById.get(assetId)?.name || '');
+ const placed = (gallery.placed || [])
+ .filter((object) => isAllowedSeedAssetId(object.assetId))
.map(normalizeSeedPlacementTerrain)
- .map((object) => ({ ...object, ownerAccountId: '' })),
- dynamicSummons: gallery.dynamicSummons
- .filter((object) => DEFAULT_GALLERY_KEEP_ASSET_NAMES.has(assetById.get(object.assetId)?.name || '') && !removedLowQualitySeedAssetIds.has(object.assetId))
- .map((object) => ({ ...object, ownerAccountId: '' }))
- });
+ .map((object) => ({ ...object, ownerAccountId: '' }));
+ const dynamicSummons = (gallery.dynamicSummons || [])
+ .filter((object) => isAllowedSeedAssetId(object.assetId))
+ .map((object) => ({ ...object, ownerAccountId: '' }));
+ const displayedAssetIds = new Set([...placed, ...dynamicSummons].map((object) => object.assetId).filter(Boolean));
+ return {
+ ...gallery,
+ assets: gallery.assets
+ .filter((asset) => displayedAssetIds.has(asset.id) && DEFAULT_GALLERY_DISPLAY_ASSET_NAMES.has(asset.name))
+ .map((asset) => ({ ...asset, author: 'Local Artist', ownerAccountId: '' })),
+ placed,
+ dynamicSummons
+ };
+ };
const whalePos = findNearestTerrain('water', 62, 58);
const koiPos = findNearestTerrain('water', 66, 60);
const skiffPos = findNearestTerrain('water', 56, 58);
@@ -6829,7 +7451,15 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
const troutPos = findNearestTerrain('water', 75, 56);
const butterflyFishPos = findNearestTerrain('water', 68, 63);
const starreefPos = findNearestTerrain('water', 118, 84);
- return applySeedQualityFilter({
+ const lanternReefGatePos = findNearestTerrain('water', 118, 96);
+ const sapphireCrabPos = findNearestTerrain('water', 123, 76);
+ const glassJellyfishPos = findNearestTerrain('water', 124, 88);
+ const prismSeahorsePos = findNearestTerrain('water', 116, 92);
+ const mistEelPos = findNearestTerrain('water', 131, 96);
+ const glassMoonDockPos = findNearestTerrain('water', 104, 92);
+ const neonGuppyPos = findNearestTerrain('water', 110, 88);
+ const tealSeaharePos = findNearestTerrain('water', 132, 88);
+ return keepOnlyDisplayedSeedAssets({
assets,
placed: [
{ id: uid(), assetId: idByName['Crescent Tea House'], ownerAccountId: 'island-team', x: 36, y: 36, placedAt: now, publishedAt: now, version: 1 },
@@ -6884,7 +7514,44 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
{ id: uid(), assetId: idByName['Meteor Forge'], ownerAccountId: 'island-team', x: 47, y: 66, placedAt: now, publishedAt: now, version: 1 },
{ id: uid(), assetId: idByName['Ink Garden Screen'], ownerAccountId: 'island-team', x: 86, y: 77, placedAt: now, publishedAt: now, version: 1 },
{ id: uid(), assetId: idByName['Meadow Totem'], ownerAccountId: 'island-team', x: 22, y: 76, placedAt: now, publishedAt: now, version: 1 },
- { id: uid(), assetId: idByName['Twilight Caravan'], ownerAccountId: 'island-team', x: 18, y: 90, placedAt: now, publishedAt: now, version: 1 }
+ { id: uid(), assetId: idByName['Twilight Caravan'], ownerAccountId: 'island-team', x: 18, y: 90, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Aurora Bathhouse'], ownerAccountId: 'island-team', x: 78, y: 30, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Copper Windmill'], ownerAccountId: 'island-team', x: 20, y: 49, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Moon Gate Bridge'], ownerAccountId: 'island-team', x: 64, y: 77, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Starlit Bookshop'], ownerAccountId: 'island-team', x: 93, y: 40, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Crystal Bakery'], ownerAccountId: 'island-team', x: 102, y: 51, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Kelp Observatory'], ownerAccountId: 'island-team', x: 116, y: 54, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Ember Shrine'], ownerAccountId: 'island-team', x: 30, y: 63, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Honeycomb Greenhouse'], ownerAccountId: 'island-team', x: 48, y: 51, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Celadon Pagoda'], ownerAccountId: 'island-team', x: 38, y: 66, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Tide Pearl Arch'], ownerAccountId: 'island-team', x: 125, y: 70, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Lavender Market'], ownerAccountId: 'island-team', x: 83, y: 70, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Skyseed Tower'], ownerAccountId: 'island-team', x: 15, y: 80, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Orchard Terrace'], ownerAccountId: 'island-team', x: 56, y: 84, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Dreamcatcher Tree'], ownerAccountId: 'island-team', x: 30, y: 84, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Nebula Fountain'], ownerAccountId: 'island-team', x: 78, y: 50, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Coral Lighthouse'], ownerAccountId: 'island-team', x: 128, y: 60, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Origami Wind Shrine'], ownerAccountId: 'island-team', x: 66, y: 36, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Rainmaker Drum'], ownerAccountId: 'island-team', x: 99, y: 82, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Velvet Mushroom Grove'], ownerAccountId: 'island-team', x: 45, y: 76, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Star Map Monument'], ownerAccountId: 'island-team', x: 111, y: 31, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Lantern Reef Gate'], ownerAccountId: 'island-team', ...lanternReefGatePos, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Midnight Ramen Cart'], ownerAccountId: 'island-team', x: 90, y: 63, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Quartz Clocktower'], ownerAccountId: 'island-team', x: 58, y: 42, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Lotus Tea Pavilion'], ownerAccountId: 'island-team', x: 69, y: 91, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Meteorite Garden'], ownerAccountId: 'island-team', x: 105, y: 35, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Azure Glasshouse'], ownerAccountId: 'island-team', x: 51, y: 57, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Foxfire Torii'], ownerAccountId: 'island-team', x: 27, y: 72, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Pearl Cloud Altar'], ownerAccountId: 'island-team', x: 115, y: 77, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Clockwork Conservatory'], ownerAccountId: 'island-team', x: 42, y: 44, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Amber Apiary'], ownerAccountId: 'island-team', x: 61, y: 69, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Glass Moon Dock'], ownerAccountId: 'island-team', ...glassMoonDockPos, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Bluebell Chapel'], ownerAccountId: 'island-team', x: 81, y: 83, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Stardust Orchard'], ownerAccountId: 'island-team', x: 35, y: 91, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Prism Ferris Wheel'], ownerAccountId: 'island-team', x: 106, y: 73, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Moss Lantern Gate'], ownerAccountId: 'island-team', x: 50, y: 88, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Shell Music Stage'], ownerAccountId: 'island-team', x: 88, y: 86, placedAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Terracotta Bathhouse'], ownerAccountId: 'island-team', x: 116, y: 66, placedAt: now, publishedAt: now, version: 1 }
],
dynamicSummons: [
{ id: uid(), assetId: idByName['Lantern Cat'], ownerAccountId: 'island-team', homeX: 38, homeY: 39, createdAt: now, publishedAt: now, version: 1 },
@@ -6923,7 +7590,42 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
{ id: uid(), assetId: idByName['Pepper Fox'], ownerAccountId: 'island-team', homeX: 28, homeY: 71, createdAt: now, publishedAt: now, version: 1 },
{ id: uid(), assetId: idByName['Glass Manta'], ownerAccountId: 'island-team', homeX: 122, homeY: 86, createdAt: now, publishedAt: now, version: 1 },
{ id: uid(), assetId: idByName['Festival Drummer'], ownerAccountId: 'island-team', homeX: 52, homeY: 67, createdAt: now, publishedAt: now, version: 1 },
- { id: uid(), assetId: idByName['Bloom Sprite'], ownerAccountId: 'island-team', homeX: 92, homeY: 79, createdAt: now, publishedAt: now, version: 1 }
+ { id: uid(), assetId: idByName['Bloom Sprite'], ownerAccountId: 'island-team', homeX: 92, homeY: 79, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Ember Salamander'], ownerAccountId: 'island-team', homeX: 50, homeY: 70, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Moon Snail'], ownerAccountId: 'island-team', homeX: 31, homeY: 90, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Ribbon Dragonfly'], ownerAccountId: 'island-team', homeX: 42, homeY: 54, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Teacup Automaton'], ownerAccountId: 'island-team', homeX: 105, homeY: 49, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Sapphire Crab'], ownerAccountId: 'island-team', ...homeFromPos(sapphireCrabPos), createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Cloud Alpaca'], ownerAccountId: 'island-team', homeX: 21, homeY: 60, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Starling Bard'], ownerAccountId: 'island-team', homeX: 88, homeY: 72, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Glass Jellyfish'], ownerAccountId: 'island-team', ...homeFromPos(glassJellyfishPos), createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Copper Heron'], ownerAccountId: 'island-team', homeX: 64, homeY: 72, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Paper Fox Kite'], ownerAccountId: 'island-team', homeX: 73, homeY: 36, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Prism Seahorse'], ownerAccountId: 'island-team', ...homeFromPos(prismSeahorsePos), createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Orchard Sprite'], ownerAccountId: 'island-team', homeX: 55, homeY: 82, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Map Turtle'], ownerAccountId: 'island-team', homeX: 75, homeY: 79, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Tea Sparrow'], ownerAccountId: 'island-team', homeX: 38, homeY: 32, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Bubble Diver'], ownerAccountId: 'island-team', homeX: 120, homeY: 69, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Mist Eel'], ownerAccountId: 'island-team', ...homeFromPos(mistEelPos), createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Ruby Beetle'], ownerAccountId: 'island-team', homeX: 28, homeY: 49, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Snowy Tanuki'], ownerAccountId: 'island-team', homeX: 16, homeY: 88, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Lighthouse Keeper'], ownerAccountId: 'island-team', homeX: 124, homeY: 43, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Inkcap Mouse'], ownerAccountId: 'island-team', homeX: 44, homeY: 76, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Comet Finch'], ownerAccountId: 'island-team', homeX: 76, homeY: 33, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Lotus Kappa'], ownerAccountId: 'island-team', homeX: 69, homeY: 88, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Lantern Manta'], ownerAccountId: 'island-team', homeX: 127, homeY: 82, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Pocket Astronaut'], ownerAccountId: 'island-team', homeX: 108, homeY: 37, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Mossy Capybara'], ownerAccountId: 'island-team', homeX: 60, homeY: 79, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Opal Octopus'], ownerAccountId: 'island-team', homeX: 119, homeY: 90, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Tiny Oracle'], ownerAccountId: 'island-team', homeX: 95, homeY: 74, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Copper Hedgehog'], ownerAccountId: 'island-team', homeX: 65, homeY: 72, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Lily Crane'], ownerAccountId: 'island-team', homeX: 80, homeY: 82, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Pearl Angler'], ownerAccountId: 'island-team', homeX: 106, homeY: 91, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Neon Guppy'], ownerAccountId: 'island-team', ...homeFromPos(neonGuppyPos), createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Cloud Jelly Pup'], ownerAccountId: 'island-team', homeX: 39, homeY: 88, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Ember Firefly'], ownerAccountId: 'island-team', homeX: 57, homeY: 72, createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Teal Seahare'], ownerAccountId: 'island-team', ...homeFromPos(tealSeaharePos), createdAt: now, publishedAt: now, version: 1 },
+ { id: uid(), assetId: idByName['Honeybee Courier'], ownerAccountId: 'island-team', homeX: 63, homeY: 66, createdAt: now, publishedAt: now, version: 1 }
]
});
}
@@ -6935,26 +7637,37 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
baseState.placed = Array.isArray(baseState.placed) ? baseState.placed : [];
baseState.dynamicSummons = Array.isArray(baseState.dynamicSummons) ? baseState.dynamicSummons : [];
baseState.deletedSeedAssetNames = Array.isArray(baseState.deletedSeedAssetNames) ? baseState.deletedSeedAssetNames : [];
- const removedExistingSeedAssetIds = new Set(baseState.assets
- .filter((asset) => REMOVED_LOW_QUALITY_SEED_ASSET_NAMES.has(asset.name) && (asset.ownerAccountId === 'island-team' || asset.author === 'Island Team'))
+ const resurrectedDefaultAssetIds = new Set(baseState.assets
+ .filter((asset) => asset?.meta?.galleryOrigin === 'default-gallery' && !DEFAULT_GALLERY_DISPLAY_ASSET_NAMES.has(asset.name))
.map((asset) => asset.id));
- if (removedExistingSeedAssetIds.size) {
- baseState.assets = baseState.assets.filter((asset) => !removedExistingSeedAssetIds.has(asset.id));
- baseState.placed = baseState.placed.filter((object) => !removedExistingSeedAssetIds.has(object.assetId));
- baseState.dynamicSummons = baseState.dynamicSummons.filter((object) => !removedExistingSeedAssetIds.has(object.assetId));
- for (const assetId of removedExistingSeedAssetIds) {
+ if (resurrectedDefaultAssetIds.size) {
+ const resurrectedDefaultObjectIds = new Set([
+ ...(baseState.placed || []),
+ ...(baseState.dynamicSummons || [])
+ ].filter((object) => resurrectedDefaultAssetIds.has(object.assetId)).map((object) => object.id));
+ baseState.assets = baseState.assets.filter((asset) => !resurrectedDefaultAssetIds.has(asset.id));
+ baseState.placed = baseState.placed.filter((object) => !resurrectedDefaultAssetIds.has(object.assetId));
+ baseState.dynamicSummons = baseState.dynamicSummons.filter((object) => !resurrectedDefaultAssetIds.has(object.assetId));
+ for (const assetId of resurrectedDefaultAssetIds) {
delete baseState.hiddenAssets?.[assetId];
delete baseState.assetVotes?.[assetId];
}
+ for (const objectId of resurrectedDefaultObjectIds) {
+ delete baseState.hiddenObjects?.[objectId];
+ delete baseState.objectVotes?.[objectId];
+ }
}
const assetByName = new Map(baseState.assets.map((asset) => [asset.name, asset]));
const assetIdByName = new Map(baseState.assets.map((asset) => [asset.name, asset.id]));
- const tombstonedDefaultNames = new Set(Object.values(baseState.tombstones?.assets || {})
- .filter((tombstone) => tombstone?.name)
- .map((tombstone) => tombstone.name));
- for (const name of baseState.deletedSeedAssetNames) tombstonedDefaultNames.add(name);
+ const deletedAssetKeys = new Set();
+ for (const tombstone of Object.values(baseState.tombstones?.assets || {})) {
+ if (tombstone?.contentHash) deletedAssetKeys.add(`hash:${tombstone.contentHash}`);
+ if (tombstone?.name) deletedAssetKeys.add(`name:${tombstone.name}`);
+ }
+ for (const name of baseState.deletedSeedAssetNames) deletedAssetKeys.add(`name:${name}`);
+ const isDeletedSeedAsset = (asset) => deletedAssetKeys.has(`hash:${asset.contentHash || computeAssetContentHash(asset)}`) || deletedAssetKeys.has(`name:${asset.name}`);
for (const seededAsset of seeded.assets) {
- if (tombstonedDefaultNames.has(seededAsset.name)) continue;
+ if (isDeletedSeedAsset(seededAsset)) continue;
const existing = assetByName.get(seededAsset.name);
if (!existing) {
baseState.assets.push(seededAsset);
@@ -7912,6 +8625,40 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
}
}
+ function pxWH(pixels, width, height, x, y, color) {
+ const xx = Math.round(x);
+ const yy = Math.round(y);
+ if (xx >= 0 && xx < width && yy >= 0 && yy < height) pixels[yy * width + xx] = color;
+ }
+
+ function rectWH(pixels, width, height, x, y, w, h, color) {
+ for (let yy = y; yy < y + h; yy++) for (let xx = x; xx < x + w; xx++) pxWH(pixels, width, height, xx, yy, color);
+ }
+
+ function ellipseWH(pixels, width, height, cx, cy, rx, ry, color) {
+ for (let y = Math.floor(cy - ry); y <= Math.ceil(cy + ry); y++) {
+ for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx); x++) {
+ if (((x - cx) ** 2) / Math.max(1, rx ** 2) + ((y - cy) ** 2) / Math.max(1, ry ** 2) <= 1) pxWH(pixels, width, height, x, y, color);
+ }
+ }
+ }
+
+ function lineWH(pixels, width, height, x0, y0, x1, y1, color) {
+ x0 = Math.round(x0); y0 = Math.round(y0); x1 = Math.round(x1); y1 = Math.round(y1);
+ const dx = Math.abs(x1 - x0);
+ const sx = x0 < x1 ? 1 : -1;
+ const dy = -Math.abs(y1 - y0);
+ const sy = y0 < y1 ? 1 : -1;
+ let err = dx + dy;
+ while (true) {
+ pxWH(pixels, width, height, x0, y0, color);
+ if (x0 === x1 && y0 === y1) break;
+ const e2 = 2 * err;
+ if (e2 >= dy) { err += dy; x0 += sx; }
+ if (e2 <= dx) { err += dx; y0 += sy; }
+ }
+ }
+
function artRows(rows, legend) {
return artRowsSized(rows, legend, 16, 16);
}
@@ -10635,6 +11382,814 @@ function drawShipRipples(pos, time, seedValue = '', asset = null) {
], {N:'#252836',Y:'#ffd3a8',G:'#ff8fc0',W:'#7bc56a'}, 12, 12);
}
+
+
+ function drawMidnightRamenCart() {
+ const w = 28, h = 20, p = blankPixels(w, h);
+ const N = '#252836', R = '#b83b4a', Y = '#ffd58a', W = '#fff7d2', D = '#65433b', B = '#75d7e8', S = '#f7eee6';
+ rectWH(p, w, h, 4, 8, 20, 8, N); rectWH(p, w, h, 5, 9, 18, 6, D);
+ rectWH(p, w, h, 3, 6, 22, 3, N); rectWH(p, w, h, 4, 6, 20, 2, R);
+ for (let x = 5; x < 24; x += 4) rectWH(p, w, h, x, 6, 2, 2, Y);
+ rectWH(p, w, h, 7, 10, 4, 3, W); rectWH(p, w, h, 15, 10, 5, 3, B); rectWH(p, w, h, 11, 13, 4, 3, N); rectWH(p, w, h, 12, 13, 2, 3, S);
+ rectWH(p, w, h, 4, 16, 20, 2, N); ellipseWH(p, w, h, 8, 18, 2, 2, N); ellipseWH(p, w, h, 20, 18, 2, 2, N); pxWH(p, w, h, 8, 18, B); pxWH(p, w, h, 20, 18, B);
+ rectWH(p, w, h, 24, 10, 2, 7, N); pxWH(p, w, h, 25, 9, Y); pxWH(p, w, h, 25, 8, R);
+ return p;
+ }
+
+ function drawQuartzClocktower() {
+ const w = 22, h = 34, p = blankPixels(w, h);
+ const N = '#252836', Q = '#cbeef5', C = '#edfaff', B = '#7894ad', G = '#4e6680', Y = '#ffe6a4';
+ rectWH(p, w, h, 7, 12, 8, 19, N); rectWH(p, w, h, 8, 13, 6, 17, Q);
+ rectWH(p, w, h, 6, 30, 10, 2, G); rectWH(p, w, h, 9, 25, 4, 6, N); rectWH(p, w, h, 10, 25, 2, 6, B);
+ for (let y = 4; y <= 12; y++) { const inset = Math.abs(12 - y) / 2 | 0; rectWH(p, w, h, 5 + inset, y, 12 - inset * 2, 1, N); rectWH(p, w, h, 6 + inset, y, 10 - inset * 2, 1, C); }
+ ellipseWH(p, w, h, 11, 9, 4, 4, N); ellipseWH(p, w, h, 11, 9, 3, 3, C); lineWH(p, w, h, 11, 9, 11, 6, G); lineWH(p, w, h, 11, 9, 14, 10, G);
+ rectWH(p, w, h, 9, 16, 4, 3, Y); rectWH(p, w, h, 9, 21, 4, 3, Y); pxWH(p, w, h, 11, 2, C); pxWH(p, w, h, 10, 3, B); pxWH(p, w, h, 12, 3, B);
+ return p;
+ }
+
+ function drawLotusTeaPavilion() {
+ const w = 30, h = 24, p = blankPixels(w, h);
+ const N = '#252836', P = '#ef86b8', D = '#7b5b6e', W = '#fff0cf', G = '#8dc985', B = '#8fd9e8', Y = '#ffe7a4';
+ ellipseWH(p, w, h, 15, 20, 13, 3, B); rectWH(p, w, h, 5, 14, 20, 7, N); rectWH(p, w, h, 6, 15, 18, 5, W);
+ rectWH(p, w, h, 4, 11, 22, 3, N); rectWH(p, w, h, 5, 11, 20, 2, P); rectWH(p, w, h, 8, 9, 14, 2, N); rectWH(p, w, h, 9, 9, 12, 1, D);
+ rectWH(p, w, h, 9, 15, 4, 3, Y); rectWH(p, w, h, 17, 15, 4, 3, Y); rectWH(p, w, h, 13, 17, 4, 4, D);
+ for (let i = 0; i < 5; i++) { ellipseWH(p, w, h, 8 + i * 3, 7 + (i % 2), 2, 1, G); pxWH(p, w, h, 8 + i * 3, 7 + (i % 2), P); }
+ return p;
+ }
+
+ function drawMeteoriteGarden() {
+ const w = 26, h = 20, p = blankPixels(w, h);
+ const N = '#252836', M = '#646c86', L = '#b7e9ff', V = '#8b7bd6', G = '#86b66d', D = '#3d4c5a';
+ rectWH(p, w, h, 2, 17, 22, 2, G); ellipseWH(p, w, h, 13, 10, 6, 5, N); ellipseWH(p, w, h, 13, 10, 5, 4, M); ellipseWH(p, w, h, 14, 9, 2, 2, L);
+ pxWH(p, w, h, 9, 11, D); pxWH(p, w, h, 17, 12, D); pxWH(p, w, h, 11, 7, V);
+ for (let i = 0; i < 5; i++) { lineWH(p, w, h, 4 + i * 4, 15, 5 + i * 4, 12 - (i % 2), D); pxWH(p, w, h, 5 + i * 4, 12 - (i % 2), L); }
+ rectWH(p, w, h, 6, 16, 14, 1, N);
+ return p;
+ }
+
+ function drawAzureGlasshouse() {
+ const w = 28, h = 26, p = blankPixels(w, h);
+ const N = '#252836', A = '#7ed8ea', C = '#dffbff', G = '#7aba6b', D = '#43556f', Y = '#fff0a6';
+ rectWH(p, w, h, 5, 12, 18, 11, N); rectWH(p, w, h, 6, 13, 16, 9, A);
+ for (let y = 5; y <= 12; y++) { const inset = Math.abs(12 - y); rectWH(p, w, h, 5 + inset, y, 18 - inset * 2, 1, N); rectWH(p, w, h, 6 + inset, y, 16 - inset * 2, 1, C); }
+ for (let x = 8; x < 22; x += 4) lineWH(p, w, h, x, 7, x, 22, D);
+ rectWH(p, w, h, 12, 18, 4, 5, N); rectWH(p, w, h, 13, 18, 2, 5, Y); rectWH(p, w, h, 7, 17, 3, 3, G); rectWH(p, w, h, 18, 16, 3, 4, G);
+ return p;
+ }
+
+ function drawFoxfireTorii() {
+ const w = 24, h = 24, p = blankPixels(w, h);
+ const N = '#252836', R = '#d34b42', D = '#7d2f3a', F = '#ffb36b', Y = '#ffe58e';
+ rectWH(p, w, h, 4, 5, 16, 3, N); rectWH(p, w, h, 5, 5, 14, 2, R); rectWH(p, w, h, 2, 8, 20, 2, N); rectWH(p, w, h, 3, 8, 18, 1, D);
+ rectWH(p, w, h, 6, 10, 3, 11, N); rectWH(p, w, h, 7, 10, 1, 11, R); rectWH(p, w, h, 15, 10, 3, 11, N); rectWH(p, w, h, 16, 10, 1, 11, R);
+ rectWH(p, w, h, 5, 20, 5, 2, D); rectWH(p, w, h, 14, 20, 5, 2, D);
+ ellipseWH(p, w, h, 6, 15, 2, 3, F); pxWH(p, w, h, 6, 14, Y); ellipseWH(p, w, h, 17, 15, 2, 3, F); pxWH(p, w, h, 17, 14, Y);
+ return p;
+ }
+
+ function drawPearlCloudAltar() {
+ const w = 24, h = 22, p = blankPixels(w, h);
+ const N = '#252836', C = '#eaf8ff', B = '#b9d7ea', P = '#fff5d8', S = '#9b8cd7';
+ ellipseWH(p, w, h, 8, 14, 5, 3, C); ellipseWH(p, w, h, 15, 13, 6, 4, C); ellipseWH(p, w, h, 12, 15, 8, 4, B);
+ rectWH(p, w, h, 5, 17, 14, 3, N); rectWH(p, w, h, 6, 17, 12, 2, B); rectWH(p, w, h, 8, 15, 8, 2, N); rectWH(p, w, h, 9, 15, 6, 1, C);
+ ellipseWH(p, w, h, 12, 9, 4, 4, N); ellipseWH(p, w, h, 12, 9, 3, 3, P); pxWH(p, w, h, 11, 8, C); pxWH(p, w, h, 14, 7, S);
+ return p;
+ }
+
+ function drawClockworkConservatory() {
+ const w = 28, h = 28, p = blankPixels(w, h);
+ const N = '#252836', G = '#78c88e', C = '#c8f4ff', B = '#536886', M = '#c88b45', Y = '#ffe9a8';
+ rectWH(p, w, h, 5, 13, 18, 12, N); rectWH(p, w, h, 6, 14, 16, 10, G);
+ ellipseWH(p, w, h, 14, 13, 9, 8, N); ellipseWH(p, w, h, 14, 13, 7, 6, C); rectWH(p, w, h, 6, 13, 16, 6, C);
+ lineWH(p, w, h, 14, 6, 14, 24, B); lineWH(p, w, h, 7, 14, 21, 14, B); lineWH(p, w, h, 9, 9, 19, 19, B); lineWH(p, w, h, 19, 9, 9, 19, B);
+ ellipseWH(p, w, h, 14, 13, 3, 3, N); ellipseWH(p, w, h, 14, 13, 2, 2, M); pxWH(p, w, h, 14, 13, Y); rectWH(p, w, h, 12, 21, 4, 4, N); rectWH(p, w, h, 13, 21, 2, 4, M);
+ return p;
+ }
+
+ function drawInkcapMouseRight() {
+ const w = 12, h = 10, p = blankPixels(w, h);
+ const N = '#252836', M = '#7b7184', P = '#d7c0ff', E = '#ffe0ed';
+ ellipseWH(p, w, h, 5, 6, 4, 2, N); ellipseWH(p, w, h, 5, 6, 3, 2, M); ellipseWH(p, w, h, 8, 5, 2, 2, N); pxWH(p, w, h, 9, 5, E);
+ ellipseWH(p, w, h, 4, 3, 3, 2, P); rectWH(p, w, h, 2, 3, 5, 2, N); pxWH(p, w, h, 9, 4, N); lineWH(p, w, h, 2, 7, 0, 6, M); pxWH(p, w, h, 4, 8, N); pxWH(p, w, h, 7, 8, N);
+ return p;
+ }
+
+ function drawCometFinchRight() {
+ const w = 12, h = 10, p = blankPixels(w, h);
+ const N = '#252836', B = '#6fb7e8', Y = '#fff0a8', O = '#ff9f67', W = '#fff8d8';
+ ellipseWH(p, w, h, 5, 5, 3, 2, N); ellipseWH(p, w, h, 5, 5, 2, 1, B); ellipseWH(p, w, h, 8, 4, 2, 2, N); pxWH(p, w, h, 9, 4, W); pxWH(p, w, h, 10, 4, O);
+ lineWH(p, w, h, 2, 5, 0, 3, Y); lineWH(p, w, h, 2, 6, 0, 7, O); lineWH(p, w, h, 4, 7, 4, 9, N); lineWH(p, w, h, 7, 6, 7, 8, N); pxWH(p, w, h, 8, 4, N);
+ return p;
+ }
+
+ function drawLotusKappaRight() {
+ const w = 12, h = 14, p = blankPixels(w, h);
+ const N = '#252836', G = '#78b96d', D = '#3d8a63', P = '#f6a8cf', B = '#d8f8e0', Y = '#ffe68a';
+ ellipseWH(p, w, h, 6, 5, 4, 4, N); ellipseWH(p, w, h, 6, 5, 3, 3, G); ellipseWH(p, w, h, 6, 2, 3, 1, P); pxWH(p, w, h, 8, 5, N); pxWH(p, w, h, 9, 6, Y);
+ rectWH(p, w, h, 4, 8, 5, 4, N); rectWH(p, w, h, 5, 8, 3, 4, D); pxWH(p, w, h, 4, 12, N); pxWH(p, w, h, 8, 12, N); pxWH(p, w, h, 3, 9, B); pxWH(p, w, h, 9, 9, B);
+ return p;
+ }
+
+ function drawLanternMantaRight() {
+ const w = 14, h = 10, p = blankPixels(w, h);
+ const N = '#252836', A = '#5fc7d9', C = '#bff8ff', Y = '#ffe58e', D = '#3d8298';
+ ellipseWH(p, w, h, 6, 5, 5, 3, N); ellipseWH(p, w, h, 6, 5, 4, 2, A); lineWH(p, w, h, 2, 5, 0, 3, D); lineWH(p, w, h, 2, 6, 0, 8, D); lineWH(p, w, h, 9, 5, 13, 4, D); lineWH(p, w, h, 9, 6, 13, 8, D);
+ pxWH(p, w, h, 8, 4, C); pxWH(p, w, h, 11, 6, Y); lineWH(p, w, h, 5, 8, 4, 9, C); lineWH(p, w, h, 7, 8, 7, 9, C);
+ return p;
+ }
+
+ function drawPocketAstronautRight() {
+ const w = 12, h = 16, p = blankPixels(w, h);
+ const N = '#252836', W = '#f0f5ff', B = '#9be8ff', R = '#e85b72', G = '#77859e', Y = '#ffe58e';
+ ellipseWH(p, w, h, 6, 4, 4, 4, N); ellipseWH(p, w, h, 6, 4, 3, 3, W); rectWH(p, w, h, 5, 3, 4, 2, B); pxWH(p, w, h, 8, 4, Y);
+ rectWH(p, w, h, 3, 8, 6, 5, N); rectWH(p, w, h, 4, 8, 4, 5, W); pxWH(p, w, h, 5, 9, R); pxWH(p, w, h, 7, 9, B); lineWH(p, w, h, 3, 9, 1, 11, G); lineWH(p, w, h, 9, 9, 11, 10, G); rectWH(p, w, h, 4, 13, 2, 2, N); rectWH(p, w, h, 7, 13, 2, 2, N);
+ return p;
+ }
+
+ function drawMossyCapybaraRight() {
+ const w = 14, h = 10, p = blankPixels(w, h);
+ const N = '#252836', B = '#9b6b4e', D = '#684536', G = '#91c76b', W = '#fff1d8';
+ ellipseWH(p, w, h, 6, 6, 5, 3, N); ellipseWH(p, w, h, 6, 6, 4, 2, B); ellipseWH(p, w, h, 10, 5, 3, 2, N); ellipseWH(p, w, h, 10, 5, 2, 1, B); pxWH(p, w, h, 11, 5, N); pxWH(p, w, h, 12, 6, W);
+ rectWH(p, w, h, 3, 3, 6, 2, G); pxWH(p, w, h, 4, 8, N); pxWH(p, w, h, 9, 8, N); lineWH(p, w, h, 1, 6, 0, 7, D);
+ return p;
+ }
+
+ function drawOpalOctopusRight() {
+ const w = 12, h = 12, p = blankPixels(w, h);
+ const N = '#252836', P = '#e58ac8', O = '#ffcaec', B = '#aef8ff', D = '#9d61a8';
+ ellipseWH(p, w, h, 6, 4, 4, 4, N); ellipseWH(p, w, h, 6, 4, 3, 3, P); pxWH(p, w, h, 8, 4, N); pxWH(p, w, h, 9, 5, O); pxWH(p, w, h, 5, 3, B);
+ for (let i = 0; i < 4; i++) lineWH(p, w, h, 3 + i * 2, 7, 2 + i * 2, 10, D); lineWH(p, w, h, 8, 7, 10, 10, D);
+ return p;
+ }
+
+ function drawTinyOracleRight() {
+ const w = 10, h = 16, p = blankPixels(w, h);
+ const N = '#252836', V = '#806bd6', P = '#e6d6ff', S = '#f3d0b6', Y = '#fff0a8';
+ ellipseWH(p, w, h, 5, 4, 3, 3, N); ellipseWH(p, w, h, 5, 4, 2, 2, S); pxWH(p, w, h, 6, 4, N);
+ rectWH(p, w, h, 3, 7, 5, 7, N); rectWH(p, w, h, 4, 7, 3, 7, V); rectWH(p, w, h, 4, 6, 3, 1, P); pxWH(p, w, h, 5, 9, Y); lineWH(p, w, h, 2, 9, 0, 8, P); lineWH(p, w, h, 8, 9, 9, 7, P); pxWH(p, w, h, 4, 14, N); pxWH(p, w, h, 6, 14, N);
+ return p;
+ }
+
+
+ function drawAmberApiary() {
+ const w = 24, h = 24, p = blankPixels(w, h);
+ const N = '#252836', H = '#d99937', Y = '#ffe07a', D = '#744b2d', G = '#6ea85d', C = '#fff0b4';
+ rectWH(p, w, h, 6, 9, 12, 12, N); rectWH(p, w, h, 7, 10, 10, 10, H);
+ for (let y = 11; y <= 18; y += 3) rectWH(p, w, h, 7, y, 10, 1, Y);
+ rectWH(p, w, h, 9, 14, 6, 5, D); rectWH(p, w, h, 10, 15, 4, 3, N);
+ rectWH(p, w, h, 5, 7, 14, 3, N); rectWH(p, w, h, 6, 7, 12, 2, C);
+ for (let i = 0; i < 5; i++) { pxWH(p, w, h, 4 + i * 4, 4 + (i % 2), Y); pxWH(p, w, h, 5 + i * 4, 4 + (i % 2), N); pxWH(p, w, h, 6 + i * 4, 3 + (i % 2), C); }
+ rectWH(p, w, h, 3, 20, 18, 2, G);
+ return p;
+ }
+
+ function drawGlassMoonDock() {
+ const w = 32, h = 16, p = blankPixels(w, h);
+ const N = '#252836', W = '#8b6a4a', L = '#c8a77a', B = '#8ae7ff', C = '#dffbff', Y = '#fff1a8';
+ rectWH(p, w, h, 4, 11, 24, 3, N); rectWH(p, w, h, 5, 11, 22, 2, W);
+ for (let x = 7; x < 27; x += 4) rectWH(p, w, h, x, 8, 2, 5, L);
+ lineWH(p, w, h, 6, 7, 26, 7, N); lineWH(p, w, h, 8, 6, 24, 6, B);
+ ellipseWH(p, w, h, 16, 7, 6, 3, N); ellipseWH(p, w, h, 16, 7, 5, 2, C); ellipseWH(p, w, h, 18, 6, 3, 2, B);
+ pxWH(p, w, h, 8, 5, Y); pxWH(p, w, h, 24, 5, Y); pxWH(p, w, h, 16, 3, C);
+ return p;
+ }
+
+ function drawBluebellChapel() {
+ const w = 22, h = 30, p = blankPixels(w, h);
+ const N = '#252836', B = '#7084d9', L = '#b9c9ff', W = '#fff4df', D = '#4a3d68', Y = '#fff1a8';
+ rectWH(p, w, h, 4, 13, 14, 13, N); rectWH(p, w, h, 5, 14, 12, 11, W);
+ for (let y = 6; y <= 13; y++) { const inset = Math.abs(13 - y); rectWH(p, w, h, 5 + inset, y, 12 - inset * 2, 1, N); rectWH(p, w, h, 6 + inset, y, 10 - inset * 2, 1, B); }
+ rectWH(p, w, h, 9, 16, 4, 5, L); rectWH(p, w, h, 9, 23, 4, 3, D); pxWH(p, w, h, 11, 10, Y);
+ lineWH(p, w, h, 11, 2, 11, 7, N); lineWH(p, w, h, 9, 4, 13, 4, N); pxWH(p, w, h, 11, 3, Y);
+ rectWH(p, w, h, 4, 26, 14, 2, N); return p;
+ }
+
+ function drawStardustOrchard() {
+ const w = 28, h = 24, p = blankPixels(w, h);
+ const N = '#252836', T = '#7a4d34', G = '#5eaa65', B = '#96db7d', P = '#d7b8ff', Y = '#fff1a8';
+ for (const [cx, cy] of [[8, 12], [17, 10], [22, 15]]) {
+ rectWH(p, w, h, cx - 1, cy + 3, 3, 7, T); ellipseWH(p, w, h, cx, cy, 6, 5, N); ellipseWH(p, w, h, cx, cy, 5, 4, G); ellipseWH(p, w, h, cx - 2, cy - 1, 3, 2, B);
+ pxWH(p, w, h, cx + 2, cy - 2, Y); pxWH(p, w, h, cx - 3, cy + 1, P);
+ }
+ rectWH(p, w, h, 2, 21, 24, 2, '#78ad62');
+ return p;
+ }
+
+ function drawPrismFerrisWheel() {
+ const w = 32, h = 32, p = blankPixels(w, h);
+ const N = '#252836', C = '#bdf5ff', R = '#ff8db6', Y = '#ffe78e', G = '#8ee68e', B = '#89a8ff', D = '#6b5a7e';
+ ellipseWH(p, w, h, 16, 12, 10, 10, N); ellipseWH(p, w, h, 16, 12, 8, 8, null);
+ lineWH(p, w, h, 16, 12, 16, 2, C); lineWH(p, w, h, 16, 12, 26, 12, C); lineWH(p, w, h, 16, 12, 16, 22, C); lineWH(p, w, h, 16, 12, 6, 12, C);
+ lineWH(p, w, h, 16, 12, 23, 5, C); lineWH(p, w, h, 16, 12, 23, 19, C); lineWH(p, w, h, 16, 12, 9, 19, C); lineWH(p, w, h, 16, 12, 9, 5, C);
+ const cars = [[16,2,R],[26,12,Y],[16,22,G],[6,12,B],[23,5,R],[23,19,G],[9,19,Y],[9,5,B]];
+ for (const [x,y,col] of cars) { rectWH(p, w, h, x-2, y, 4, 3, N); rectWH(p, w, h, x-1, y+1, 2, 1, col); }
+ lineWH(p, w, h, 16, 14, 10, 29, D); lineWH(p, w, h, 16, 14, 22, 29, D); rectWH(p, w, h, 8, 29, 16, 2, N);
+ pxWH(p, w, h, 16, 12, Y); return p;
+ }
+
+ function drawMossLanternGate() {
+ const w = 26, h = 24, p = blankPixels(w, h);
+ const N = '#252836', G = '#5f8f52', M = '#93c56b', W = '#8a6c4d', Y = '#ffe28a', C = '#d8ffca';
+ rectWH(p, w, h, 4, 6, 18, 3, N); rectWH(p, w, h, 5, 6, 16, 2, G);
+ rectWH(p, w, h, 6, 8, 3, 13, N); rectWH(p, w, h, 17, 8, 3, 13, N); rectWH(p, w, h, 7, 9, 1, 11, W); rectWH(p, w, h, 18, 9, 1, 11, W);
+ lineWH(p, w, h, 5, 5, 13, 2, M); lineWH(p, w, h, 13, 2, 21, 5, M);
+ rectWH(p, w, h, 7, 11, 3, 4, N); rectWH(p, w, h, 8, 12, 1, 2, Y); rectWH(p, w, h, 17, 11, 3, 4, N); rectWH(p, w, h, 18, 12, 1, 2, Y);
+ rectWH(p, w, h, 4, 21, 18, 2, C); return p;
+ }
+
+ function drawShellMusicStage() {
+ const w = 30, h = 22, p = blankPixels(w, h);
+ const N = '#252836', S = '#ffd3df', P = '#f29bc1', B = '#82d6ff', D = '#6e5062', Y = '#fff1a8';
+ ellipseWH(p, w, h, 15, 10, 12, 8, N); ellipseWH(p, w, h, 15, 10, 10, 6, S);
+ for (let x = 8; x <= 22; x += 4) lineWH(p, w, h, 15, 4, x, 16, P);
+ rectWH(p, w, h, 4, 16, 22, 4, N); rectWH(p, w, h, 5, 16, 20, 3, D);
+ rectWH(p, w, h, 9, 11, 2, 6, N); rectWH(p, w, h, 20, 11, 2, 6, N); pxWH(p, w, h, 9, 10, Y); pxWH(p, w, h, 21, 10, Y);
+ lineWH(p, w, h, 13, 14, 17, 12, B); lineWH(p, w, h, 13, 15, 17, 15, B); return p;
+ }
+
+ function drawTerracottaBathhouse() {
+ const w = 28, h = 26, p = blankPixels(w, h);
+ const N = '#252836', R = '#b85d44', O = '#e07d52', W = '#f4d2b1', D = '#614036', C = '#bdf5ff', Y = '#ffd66e';
+ rectWH(p, w, h, 5, 10, 18, 12, N); rectWH(p, w, h, 6, 11, 16, 10, W);
+ rectWH(p, w, h, 4, 7, 20, 4, N); rectWH(p, w, h, 5, 7, 18, 3, R); rectWH(p, w, h, 7, 5, 14, 3, N); rectWH(p, w, h, 8, 5, 12, 2, O);
+ rectWH(p, w, h, 8, 14, 4, 4, C); rectWH(p, w, h, 16, 14, 4, 4, C); rectWH(p, w, h, 12, 17, 4, 5, D); pxWH(p, w, h, 14, 18, Y);
+ for (let i = 0; i < 5; i++) { pxWH(p, w, h, 9 + i * 3, 3 - (i % 2), C); pxWH(p, w, h, 10 + i * 3, 2 - (i % 2), '#ffffff'); }
+ rectWH(p, w, h, 4, 22, 20, 2, N); return p;
+ }
+
+ function drawCopperHedgehogRight() {
+ const w = 12, h = 10, p = blankPixels(w, h);
+ const N = '#252836', C = '#b76a38', D = '#684536', S = '#f0c08a', Y = '#ffe99a';
+ ellipseWH(p, w, h, 5, 6, 5, 3, N); ellipseWH(p, w, h, 5, 6, 4, 2, C); ellipseWH(p, w, h, 9, 5, 3, 2, N); ellipseWH(p, w, h, 9, 5, 2, 1, S);
+ for (let x = 2; x <= 7; x += 2) lineWH(p, w, h, x, 3, x + 1, 1, D); pxWH(p, w, h, 10, 5, N); pxWH(p, w, h, 11, 6, Y); pxWH(p, w, h, 3, 8, N); pxWH(p, w, h, 8, 8, N);
+ return p;
+ }
+
+ function drawLilyCraneRight() {
+ const w = 12, h = 16, p = blankPixels(w, h);
+ const N = '#252836', W = '#f3f8ff', B = '#9ecaff', G = '#7ac776', Y = '#ffe07a';
+ ellipseWH(p, w, h, 5, 8, 4, 3, N); ellipseWH(p, w, h, 5, 8, 3, 2, W); lineWH(p, w, h, 7, 7, 10, 4, N); lineWH(p, w, h, 8, 6, 10, 4, W); pxWH(p, w, h, 10, 4, Y);
+ lineWH(p, w, h, 4, 10, 4, 15, N); lineWH(p, w, h, 7, 10, 8, 15, N); rectWH(p, w, h, 3, 14, 3, 1, N); rectWH(p, w, h, 7, 14, 3, 1, N);
+ ellipseWH(p, w, h, 2, 9, 3, 2, B); pxWH(p, w, h, 3, 4, G); pxWH(p, w, h, 4, 3, '#ffd2ef'); return p;
+ }
+
+ function drawPearlAnglerRight() {
+ const w = 12, h = 16, p = blankPixels(w, h);
+ const N = '#252836', S = '#f0c6a8', C = '#74d4e8', B = '#3c5f7e', W = '#f7fbff', Y = '#fff1a8';
+ ellipseWH(p, w, h, 5, 4, 3, 3, N); ellipseWH(p, w, h, 5, 4, 2, 2, S); pxWH(p, w, h, 6, 4, N);
+ rectWH(p, w, h, 3, 7, 5, 7, N); rectWH(p, w, h, 4, 7, 3, 7, C); rectWH(p, w, h, 4, 6, 3, 1, W);
+ lineWH(p, w, h, 8, 8, 11, 5, B); pxWH(p, w, h, 11, 5, Y); lineWH(p, w, h, 2, 9, 0, 11, B); pxWH(p, w, h, 4, 14, N); pxWH(p, w, h, 7, 14, N); return p;
+ }
+
+ function drawNeonGuppyRight() {
+ const w = 12, h = 8, p = blankPixels(w, h);
+ const N = '#252836', B = '#16c7ff', G = '#61ffb8', Y = '#f5ff79', P = '#ff7bd1';
+ ellipseWH(p, w, h, 6, 4, 4, 2, N); ellipseWH(p, w, h, 6, 4, 3, 1, B); lineWH(p, w, h, 2, 4, 0, 2, P); lineWH(p, w, h, 2, 4, 0, 6, P);
+ pxWH(p, w, h, 8, 3, N); pxWH(p, w, h, 9, 4, Y); lineWH(p, w, h, 5, 2, 7, 1, G); lineWH(p, w, h, 5, 6, 7, 7, G); return p;
+ }
+
+ function drawCloudJellyPupRight() {
+ const w = 12, h = 12, p = blankPixels(w, h);
+ const N = '#252836', C = '#dff6ff', B = '#9ce8ff', P = '#ffc6e5', D = '#6f88a0';
+ ellipseWH(p, w, h, 5, 6, 5, 3, N); ellipseWH(p, w, h, 5, 6, 4, 2, C); ellipseWH(p, w, h, 9, 5, 3, 2, N); ellipseWH(p, w, h, 9, 5, 2, 1, B);
+ pxWH(p, w, h, 10, 5, N); pxWH(p, w, h, 11, 6, P); lineWH(p, w, h, 2, 8, 1, 11, D); lineWH(p, w, h, 5, 8, 4, 11, D); lineWH(p, w, h, 8, 8, 9, 11, D); pxWH(p, w, h, 3, 3, C); return p;
+ }
+
+ function drawEmberFireflyRight() {
+ const w = 10, h = 8, p = blankPixels(w, h);
+ const N = '#252836', O = '#ff9a3d', Y = '#ffe66d', B = '#bdf5ff', D = '#7a4430';
+ ellipseWH(p, w, h, 5, 4, 3, 2, N); ellipseWH(p, w, h, 5, 4, 2, 1, D); ellipseWH(p, w, h, 7, 4, 2, 2, O); pxWH(p, w, h, 8, 4, Y);
+ lineWH(p, w, h, 4, 3, 2, 1, B); lineWH(p, w, h, 5, 3, 7, 1, B); pxWH(p, w, h, 7, 4, Y); pxWH(p, w, h, 3, 4, N); return p;
+ }
+
+ function drawTealSeahareRight() {
+ const w = 12, h = 10, p = blankPixels(w, h);
+ const N = '#252836', T = '#36c7a7', L = '#a7ffe0', B = '#5d89a6', Y = '#fff1a8';
+ ellipseWH(p, w, h, 6, 6, 4, 2, N); ellipseWH(p, w, h, 6, 6, 3, 1, T); ellipseWH(p, w, h, 9, 5, 3, 2, N); ellipseWH(p, w, h, 9, 5, 2, 1, L);
+ lineWH(p, w, h, 9, 3, 8, 1, B); lineWH(p, w, h, 10, 3, 11, 1, B); pxWH(p, w, h, 10, 5, N); pxWH(p, w, h, 11, 6, Y); lineWH(p, w, h, 3, 6, 1, 4, B); return p;
+ }
+
+ function drawHoneybeeCourierRight() {
+ const w = 12, h = 10, p = blankPixels(w, h);
+ const N = '#252836', Y = '#ffd94c', D = '#5a3b2c', B = '#bdf5ff', W = '#fff8d7';
+ ellipseWH(p, w, h, 6, 5, 4, 2, N); ellipseWH(p, w, h, 6, 5, 3, 1, Y); rectWH(p, w, h, 5, 4, 1, 3, D); rectWH(p, w, h, 7, 4, 1, 3, D);
+ ellipseWH(p, w, h, 4, 3, 3, 2, B); ellipseWH(p, w, h, 8, 3, 3, 2, B); pxWH(p, w, h, 9, 5, N); pxWH(p, w, h, 10, 6, W); rectWH(p, w, h, 1, 5, 2, 1, D); pxWH(p, w, h, 3, 7, Y); return p;
+ }
+
+ function drawAuroraBathhouse() {
+ const w = 28, h = 28, p = blankPixels(w, h);
+ const N = '#252836', W = '#8ed9e6', C = '#dffbff', R = '#7b5ed6', D = '#3b3f62', Y = '#ffe99a', S = '#bff7ff';
+ for (let y = 5; y <= 11; y++) { const inset = Math.abs(y - 11); rectWH(p, w, h, 5 + inset, y, 18 - inset * 2, 1, N); rectWH(p, w, h, 6 + inset, y, 16 - inset * 2, 1, R); }
+ rectWH(p, w, h, 5, 12, 18, 2, N); rectWH(p, w, h, 6, 12, 16, 1, D);
+ rectWH(p, w, h, 6, 14, 16, 10, N); rectWH(p, w, h, 7, 15, 14, 8, W);
+ rectWH(p, w, h, 9, 17, 3, 3, C); rectWH(p, w, h, 16, 17, 3, 3, C); rectWH(p, w, h, 12, 20, 4, 4, D); rectWH(p, w, h, 13, 20, 2, 4, Y);
+ rectWH(p, w, h, 4, 23, 20, 2, N); rectWH(p, w, h, 6, 24, 16, 2, D);
+ for (let i = 0; i < 5; i++) { pxWH(p, w, h, 7 + i * 3, 3 - (i % 2), S); pxWH(p, w, h, 8 + i * 3, 2 - (i % 2), C); }
+ return p;
+ }
+
+ function drawCopperWindmill() {
+ const w = 24, h = 32, p = blankPixels(w, h);
+ const N = '#252836', C = '#c97844', B = '#f0c48a', W = '#fff4d6', D = '#5b3c35', G = '#86bf6b';
+ for (let y = 12; y < 30; y++) { const inset = Math.floor((y - 12) / 6); rectWH(p, w, h, 8 - inset, y, 8 + inset * 2, 1, N); rectWH(p, w, h, 9 - inset, y, 6 + inset * 2, 1, B); }
+ rectWH(p, w, h, 7, 28, 10, 2, D); rectWH(p, w, h, 10, 22, 4, 7, N); rectWH(p, w, h, 11, 22, 2, 7, D);
+ rectWH(p, w, h, 9, 15, 2, 3, W); rectWH(p, w, h, 13, 15, 2, 3, W);
+ lineWH(p, w, h, 12, 11, 12, 2, N); lineWH(p, w, h, 12, 11, 4, 8, N); lineWH(p, w, h, 12, 11, 20, 8, N); lineWH(p, w, h, 12, 11, 6, 18, N); lineWH(p, w, h, 12, 11, 18, 18, N);
+ lineWH(p, w, h, 12, 3, 12, 7, C); lineWH(p, w, h, 5, 8, 9, 10, C); lineWH(p, w, h, 19, 8, 15, 10, C); lineWH(p, w, h, 7, 18, 10, 14, C); lineWH(p, w, h, 17, 18, 14, 14, C); ellipseWH(p, w, h, 12, 11, 2, 2, N); pxWH(p, w, h, 12, 11, W);
+ rectWH(p, w, h, 4, 30, 16, 1, G);
+ return p;
+ }
+
+ function drawMoonGateBridge() {
+ const w = 32, h = 20, p = blankPixels(w, h);
+ const N = '#252836', S = '#9a8a7a', L = '#d8c6a9', C = '#dffbff', B = '#79c8de', Y = '#fff1a8';
+ rectWH(p, w, h, 2, 14, 28, 3, N); rectWH(p, w, h, 3, 14, 26, 2, S);
+ for (let x = 4; x < 29; x += 3) rectWH(p, w, h, x, 12, 1, 4, L);
+ ellipseWH(p, w, h, 16, 11, 9, 9, N); ellipseWH(p, w, h, 16, 11, 7, 7, L); ellipseWH(p, w, h, 16, 13, 5, 6, null);
+ for (let y = 6; y < 17; y++) for (let x = 9; x < 24; x++) if (!p[y * w + x] && ((x - 16) ** 2) / 25 + ((y - 13) ** 2) / 36 <= 1) p[y * w + x] = B;
+ pxWH(p, w, h, 9, 9, Y); pxWH(p, w, h, 22, 9, Y); pxWH(p, w, h, 16, 5, C);
+ return p;
+ }
+
+ function drawStarlitBookshop() {
+ const w = 24, h = 28, p = blankPixels(w, h);
+ const N = '#252836', W = '#7d4f6f', B = '#c18bb5', D = '#3c3348', Y = '#ffe99a', C = '#9ee7ff';
+ rectWH(p, w, h, 5, 9, 14, 16, N); rectWH(p, w, h, 6, 10, 12, 14, W);
+ rectWH(p, w, h, 4, 7, 16, 3, N); rectWH(p, w, h, 5, 7, 14, 2, D); rectWH(p, w, h, 8, 4, 8, 4, N); rectWH(p, w, h, 9, 5, 6, 2, B);
+ rectWH(p, w, h, 7, 13, 4, 5, C); rectWH(p, w, h, 13, 13, 4, 5, C); rectWH(p, w, h, 10, 20, 4, 5, D); rectWH(p, w, h, 11, 20, 2, 5, Y);
+ lineWH(p, w, h, 12, 2, 12, 6, Y); lineWH(p, w, h, 10, 4, 14, 4, Y); pxWH(p, w, h, 12, 4, '#fff8d7');
+ return p;
+ }
+
+ function drawCrystalBakery() {
+ const w = 24, h = 24, p = blankPixels(w, h);
+ const N = '#252836', P = '#f3a4cf', W = '#ffe5ef', C = '#98e7ff', B = '#615586', D = '#4b3548', Y = '#fff1a8';
+ rectWH(p, w, h, 4, 9, 16, 12, N); rectWH(p, w, h, 5, 10, 14, 10, W);
+ for (let x = 4; x < 20; x += 4) { rectWH(p, w, h, x, 7, 2, 3, N); rectWH(p, w, h, x + 2, 7, 2, 3, P); }
+ rectWH(p, w, h, 6, 13, 5, 4, C); rectWH(p, w, h, 13, 13, 5, 4, C); rectWH(p, w, h, 10, 17, 4, 4, D);
+ lineWH(p, w, h, 8, 5, 10, 2, C); lineWH(p, w, h, 10, 2, 12, 5, C); lineWH(p, w, h, 15, 5, 17, 2, P); lineWH(p, w, h, 17, 2, 19, 5, P);
+ pxWH(p, w, h, 7, 15, Y); pxWH(p, w, h, 16, 15, Y); rectWH(p, w, h, 4, 21, 16, 1, B);
+ return p;
+ }
+
+ function drawKelpObservatory() {
+ const w = 28, h = 32, p = blankPixels(w, h);
+ const N = '#252836', B = '#4f7896', C = '#92e4f2', D = '#2d4057', G = '#5dbb7a', Y = '#ffe99a';
+ ellipseWH(p, w, h, 14, 9, 9, 7, N); ellipseWH(p, w, h, 14, 9, 7, 5, C); rectWH(p, w, h, 5, 13, 18, 3, N); rectWH(p, w, h, 7, 14, 14, 2, B);
+ rectWH(p, w, h, 7, 16, 14, 13, N); rectWH(p, w, h, 8, 17, 12, 11, D); rectWH(p, w, h, 10, 19, 3, 4, C); rectWH(p, w, h, 16, 19, 3, 4, C); rectWH(p, w, h, 12, 25, 4, 4, B);
+ for (let i = 0; i < 5; i++) lineWH(p, w, h, 3 + i * 5, 30, 5 + i * 4, 20 + i % 3, G);
+ pxWH(p, w, h, 14, 9, Y); pxWH(p, w, h, 13, 8, '#ffffff');
+ return p;
+ }
+
+ function drawEmberShrine() {
+ const w = 20, h = 28, p = blankPixels(w, h);
+ const N = '#252836', R = '#b94a42', O = '#ff9f45', D = '#5b3030', Y = '#ffd66e', W = '#f7e7d0';
+ rectWH(p, w, h, 3, 6, 14, 2, N); rectWH(p, w, h, 4, 6, 12, 1, R); rectWH(p, w, h, 5, 8, 2, 14, N); rectWH(p, w, h, 13, 8, 2, 14, N);
+ rectWH(p, w, h, 4, 18, 12, 7, N); rectWH(p, w, h, 5, 19, 10, 5, D); rectWH(p, w, h, 8, 20, 4, 5, W);
+ ellipseWH(p, w, h, 10, 14, 4, 5, O); ellipseWH(p, w, h, 10, 15, 2, 3, Y); rectWH(p, w, h, 6, 24, 8, 2, N);
+ return p;
+ }
+
+ function drawHoneycombGreenhouse() {
+ const w = 28, h = 24, p = blankPixels(w, h);
+ const N = '#252836', G = '#6fba6a', C = '#d9ffe8', Y = '#ffe99a', D = '#6b8e5a';
+ ellipseWH(p, w, h, 14, 13, 12, 9, N); ellipseWH(p, w, h, 14, 13, 10, 7, C); rectWH(p, w, h, 3, 13, 22, 8, N); rectWH(p, w, h, 4, 14, 20, 6, C);
+ for (let y = 10; y < 20; y += 4) for (let x = 6; x < 23; x += 5) { lineWH(p, w, h, x, y, x + 2, y + 2, D); lineWH(p, w, h, x + 2, y + 2, x, y + 4, D); lineWH(p, w, h, x + 2, y + 2, x + 4, y, D); }
+ rectWH(p, w, h, 11, 18, 6, 3, G); pxWH(p, w, h, 14, 10, Y);
+ return p;
+ }
+
+ function drawCeladonPagoda() {
+ const w = 24, h = 36, p = blankPixels(w, h);
+ const N = '#252836', G = '#74b57a', W = '#e5d9b8', D = '#4c4a3f', Y = '#fff1a8';
+ for (const [y, wide] of [[6, 16], [14, 20], [22, 18]]) { rectWH(p, w, h, Math.floor((w - wide) / 2), y, wide, 2, N); rectWH(p, w, h, Math.floor((w - wide) / 2) + 1, y, wide - 2, 1, G); rectWH(p, w, h, 8, y + 2, 8, 6, N); rectWH(p, w, h, 9, y + 3, 6, 4, W); rectWH(p, w, h, 11, y + 4, 2, 2, Y); }
+ rectWH(p, w, h, 7, 30, 10, 4, N); rectWH(p, w, h, 8, 30, 8, 3, D); rectWH(p, w, h, 11, 31, 2, 3, Y); lineWH(p, w, h, 12, 2, 12, 6, N); pxWH(p, w, h, 12, 2, Y);
+ return p;
+ }
+
+ function drawTidePearlArch() {
+ const w = 24, h = 24, p = blankPixels(w, h);
+ const N = '#252836', S = '#b0a18f', C = '#dffbff', B = '#7fdaf2', P = '#fffaf0';
+ ellipseWH(p, w, h, 12, 14, 9, 10, N); ellipseWH(p, w, h, 12, 14, 7, 8, S); ellipseWH(p, w, h, 12, 16, 5, 6, null);
+ rectWH(p, w, h, 4, 19, 5, 3, N); rectWH(p, w, h, 15, 19, 5, 3, N); ellipseWH(p, w, h, 12, 8, 3, 3, P); pxWH(p, w, h, 11, 7, C); lineWH(p, w, h, 6, 17, 18, 17, B);
+ return p;
+ }
+
+ function drawLavenderMarket() {
+ const w = 32, h = 20, p = blankPixels(w, h);
+ const N = '#252836', L = '#b084d6', W = '#fff0c8', D = '#5b496b', Y = '#ffe99a', G = '#77bd76';
+ rectWH(p, w, h, 3, 8, 26, 10, N); rectWH(p, w, h, 4, 9, 24, 8, W);
+ for (let x = 3; x < 29; x += 4) { rectWH(p, w, h, x, 6, 2, 3, L); rectWH(p, w, h, x + 2, 6, 2, 3, D); }
+ rectWH(p, w, h, 6, 12, 5, 4, G); rectWH(p, w, h, 14, 12, 4, 5, D); rectWH(p, w, h, 21, 12, 5, 4, G); pxWH(p, w, h, 8, 12, Y); pxWH(p, w, h, 23, 12, Y);
+ return p;
+ }
+
+ function drawSkyseedTower() {
+ const w = 24, h = 36, p = blankPixels(w, h);
+ const N = '#252836', G = '#73bf83', C = '#d9ffe8', D = '#456b5d', Y = '#fff1a8';
+ ellipseWH(p, w, h, 12, 6, 5, 5, N); ellipseWH(p, w, h, 12, 6, 3, 3, C); pxWH(p, w, h, 12, 5, Y);
+ for (let y = 11; y < 34; y++) { const inset = Math.floor((y - 11) / 9); rectWH(p, w, h, 8 - inset, y, 8 + inset * 2, 1, N); rectWH(p, w, h, 9 - inset, y, 6 + inset * 2, 1, D); }
+ for (let y = 15; y < 30; y += 5) { rectWH(p, w, h, 10, y, 4, 2, C); }
+ lineWH(p, w, h, 7, 12, 3, 22, G); lineWH(p, w, h, 17, 12, 21, 22, G); rectWH(p, w, h, 9, 31, 6, 3, N);
+ return p;
+ }
+
+ function drawOrchardTerrace() {
+ const w = 32, h = 24, p = blankPixels(w, h);
+ const N = '#252836', G = '#6fbf65', D = '#496d3b', B = '#8a6748', R = '#ff7f6e', Y = '#ffd66e';
+ for (let y = 10; y < 22; y += 3) { rectWH(p, w, h, 3, y, 26, 2, N); rectWH(p, w, h, 4, y, 24, 1, G); }
+ for (const x of [8, 15, 23]) { rectWH(p, w, h, x, 8, 2, 8, B); ellipseWH(p, w, h, x + 1, 7, 5, 4, N); ellipseWH(p, w, h, x + 1, 7, 4, 3, G); pxWH(p, w, h, x - 1, 7, R); pxWH(p, w, h, x + 3, 8, Y); }
+ return p;
+ }
+
+ function drawDreamcatcherTree() {
+ const w = 28, h = 32, p = blankPixels(w, h);
+ const N = '#252836', B = '#7b4f36', G = '#548b5f', L = '#8fd17e', P = '#ead1ff', Y = '#fff1a8';
+ rectWH(p, w, h, 12, 16, 4, 13, N); rectWH(p, w, h, 13, 16, 2, 13, B); ellipseWH(p, w, h, 14, 11, 11, 9, N); ellipseWH(p, w, h, 14, 11, 9, 7, G); ellipseWH(p, w, h, 10, 9, 4, 3, L); ellipseWH(p, w, h, 18, 12, 4, 3, L);
+ ellipseWH(p, w, h, 14, 10, 5, 5, N); ellipseWH(p, w, h, 14, 10, 4, 4, null); lineWH(p, w, h, 10, 10, 18, 10, P); lineWH(p, w, h, 14, 6, 14, 14, P); pxWH(p, w, h, 14, 10, Y);
+ return p;
+ }
+
+ function drawNebulaFountain() {
+ const w = 24, h = 24, p = blankPixels(w, h);
+ const N = '#252836', S = '#7e7f96', C = '#8ee8ff', P = '#d7c6ff', Y = '#fff1a8';
+ rectWH(p, w, h, 5, 17, 14, 3, N); rectWH(p, w, h, 6, 17, 12, 2, S); ellipseWH(p, w, h, 12, 16, 7, 3, N); ellipseWH(p, w, h, 12, 16, 5, 2, C);
+ rectWH(p, w, h, 10, 9, 4, 8, N); rectWH(p, w, h, 11, 9, 2, 8, S); ellipseWH(p, w, h, 12, 8, 4, 3, P); pxWH(p, w, h, 12, 7, Y); lineWH(p, w, h, 8, 12, 5, 9, C); lineWH(p, w, h, 16, 12, 19, 9, C);
+ return p;
+ }
+
+ function drawCoralLighthouse() {
+ const w = 24, h = 36, p = blankPixels(w, h);
+ const N = '#252836', C = '#ff7f6e', W = '#ffe7d5', B = '#5b668d', Y = '#fff1a8';
+ rectWH(p, w, h, 8, 8, 8, 24, N); for (let y = 9; y < 32; y++) rectWH(p, w, h, 9, y, 6, 1, y % 6 < 3 ? W : C);
+ rectWH(p, w, h, 6, 5, 12, 4, N); rectWH(p, w, h, 7, 6, 10, 2, B); rectWH(p, w, h, 9, 2, 6, 4, N); rectWH(p, w, h, 10, 3, 4, 2, Y);
+ rectWH(p, w, h, 10, 17, 4, 3, B); rectWH(p, w, h, 10, 31, 4, 3, B); rectWH(p, w, h, 6, 33, 12, 2, N);
+ return p;
+ }
+
+ function drawOrigamiWindShrine() {
+ const w = 20, h = 24, p = blankPixels(w, h);
+ const N = '#252836', W = '#f4f7ff', C = '#bfe8ff', D = '#6e6a88', Y = '#fff1a8';
+ rectWH(p, w, h, 8, 12, 4, 9, N); rectWH(p, w, h, 9, 13, 2, 8, D); rectWH(p, w, h, 5, 20, 10, 2, N);
+ lineWH(p, w, h, 10, 11, 4, 4, N); lineWH(p, w, h, 10, 11, 16, 4, N); lineWH(p, w, h, 10, 11, 4, 16, N); lineWH(p, w, h, 10, 11, 16, 16, N);
+ lineWH(p, w, h, 5, 4, 10, 7, W); lineWH(p, w, h, 15, 4, 10, 7, C); lineWH(p, w, h, 5, 16, 10, 13, C); lineWH(p, w, h, 15, 16, 10, 13, W); pxWH(p, w, h, 10, 11, Y);
+ return p;
+ }
+
+ function drawRainmakerDrum() {
+ const w = 20, h = 20, p = blankPixels(w, h);
+ const N = '#252836', B = '#8a6748', D = '#5b3c35', C = '#9ee7ff', Y = '#fff1a8';
+ ellipseWH(p, w, h, 10, 12, 8, 5, N); ellipseWH(p, w, h, 10, 12, 6, 3, B); rectWH(p, w, h, 4, 12, 12, 4, D); lineWH(p, w, h, 4, 9, 16, 15, Y); lineWH(p, w, h, 16, 9, 4, 15, Y);
+ for (let x = 5; x <= 15; x += 5) { pxWH(p, w, h, x, 4, C); pxWH(p, w, h, x + 1, 5, C); }
+ return p;
+ }
+
+ function drawVelvetMushroomGrove() {
+ const w = 24, h = 22, p = blankPixels(w, h);
+ const N = '#252836', M = '#a04f91', P = '#ffb8f2', S = '#e9d6b8', G = '#6caf68';
+ for (const [x, y, r] of [[7, 10, 5], [15, 8, 6], [18, 13, 4]]) { rectWH(p, w, h, x - 1, y, 2, 8, N); rectWH(p, w, h, x, y + 1, 1, 7, S); ellipseWH(p, w, h, x, y, r, 4, N); ellipseWH(p, w, h, x, y, r - 1, 3, M); pxWH(p, w, h, x - 1, y - 1, P); pxWH(p, w, h, x + 2, y, P); }
+ rectWH(p, w, h, 3, 20, 18, 1, G);
+ return p;
+ }
+
+ function drawStarMapMonument() {
+ const w = 20, h = 20, p = blankPixels(w, h);
+ const N = '#252836', S = '#6c7188', D = '#3f455a', Y = '#fff1a8', C = '#dce8ff';
+ rectWH(p, w, h, 6, 5, 8, 12, N); rectWH(p, w, h, 7, 6, 6, 10, S); rectWH(p, w, h, 8, 8, 4, 6, D); rectWH(p, w, h, 5, 17, 10, 2, N);
+ const pts = [[10, 5], [7, 10], [13, 11], [9, 14], [12, 8]]; for (const [x, y] of pts) pxWH(p, w, h, x, y, Y); lineWH(p, w, h, 10, 5, 7, 10, C); lineWH(p, w, h, 7, 10, 13, 11, C); lineWH(p, w, h, 13, 11, 9, 14, C);
+ return p;
+ }
+
+ function drawLanternReefGate() {
+ const w = 32, h = 20, p = blankPixels(w, h);
+ const N = '#252836', C = '#ff8a7a', B = '#6dd8f0', G = '#5fbf79', Y = '#ffd98a', D = '#80605a';
+ rectWH(p, w, h, 5, 14, 22, 3, N); rectWH(p, w, h, 6, 14, 20, 2, D); rectWH(p, w, h, 7, 8, 3, 8, N); rectWH(p, w, h, 22, 8, 3, 8, N); rectWH(p, w, h, 7, 8, 18, 2, N); rectWH(p, w, h, 8, 8, 16, 1, C);
+ ellipseWH(p, w, h, 6, 15, 4, 3, G); ellipseWH(p, w, h, 25, 15, 4, 3, B); rectWH(p, w, h, 10, 9, 2, 4, Y); rectWH(p, w, h, 20, 9, 2, 4, Y);
+ return p;
+ }
+
+ function drawEmberSalamanderRight() {
+ return artRowsSized([
+ '..............',
+ '....NNN.......',
+ '..NNORNN......',
+ '.NORRRRONN....',
+ 'NORRYYRRRON...',
+ '.NNORRRRNNN...',
+ '...NNRRN..NN..',
+ '..NN..NN......',
+ '..............',
+ '..............'
+ ], {N:'#252836', O:'#ff9f45', R:'#d4505f', Y:'#ffd66e'}, 14, 10);
+ }
+
+ function drawMoonSnailRight() {
+ return artRowsSized([
+ '............',
+ '....NNNN....',
+ '...NCCYYN...',
+ '..NCCYYYYN..',
+ '.NCCYNNYYN..',
+ '.NSSSNNNNN..',
+ '..NSSSSSSN..',
+ '...NN..NN...',
+ '............',
+ '............'
+ ], {N:'#252836', C:'#dfe9ff', Y:'#fff1a8', S:'#b8a7d8'}, 12, 10);
+ }
+
+ function drawRibbonDragonflyRight() {
+ return artRowsSized([
+ '..............',
+ '..NN....NN....',
+ '.NPPN..NCCN...',
+ '..NPPNNCCNYY..',
+ '....NNNNNYYN..',
+ '..NCCNNPPN....',
+ '.NCCN..NPPN...',
+ '..............'
+ ], {N:'#252836', P:'#ead1ff', C:'#bff7ff', Y:'#ffd66e'}, 14, 8);
+ }
+
+ function drawTeacupAutomatonRight() {
+ return artRowsSized([
+ '....NNNN....',
+ '...NYYYYN...',
+ '..NYYYYYYN..',
+ '..NYNNYNNN..',
+ '...NYYYYN...',
+ '..NNCCCCNN..',
+ '.NCCNNNNCCN.',
+ '.NCCYCCYCCN.',
+ '..NCCCCCCN..',
+ '...NCCCCN...',
+ '..NNNCCNNN..',
+ '.NN..CC..NN.',
+ '.....NN.....',
+ '....N..N....',
+ '...NN..NN...',
+ '............'
+ ], {N:'#252836', Y:'#ffe2bd', C:'#8ac6d1'}, 12, 16);
+ }
+
+ function drawSapphireCrabRight() {
+ return artRowsSized([
+ '............',
+ '..NN....NN..',
+ '.NCCN..NCCN.',
+ '..NCCCCCCN..',
+ '.NCCYCCYCCN.',
+ 'NCCNNNNCCCN.',
+ '.NN....NN...',
+ '............'
+ ], {N:'#252836', C:'#357bd1', Y:'#dffbff'}, 12, 8);
+ }
+
+ function drawCloudAlpacaRight() {
+ return artRowsSized([
+ '..............',
+ '....NNNNN.....',
+ '..NNWWWWWNN...',
+ '.NWWWWWWWWWN..',
+ '.NWWNWWWNWWN..',
+ '.NWWWWWWWWN...',
+ '..NNWWWWNN....',
+ '...NWWNWN.....',
+ '...N..N.N.....',
+ '..NN..NNN.....',
+ '..............',
+ '..............'
+ ], {N:'#252836', W:'#f4f7ff'}, 14, 12);
+ }
+
+ function drawStarlingBardRight() {
+ return artRowsSized([
+ '....NNNN....',
+ '...NYYYYN...',
+ '...NYYYYN...',
+ '....NNNN....',
+ '...NNPPNN...',
+ '..NPPPPPPN..',
+ '..NPPYPPPN..',
+ '...NPPPPN...',
+ '....NDDN....',
+ '...NNDDNN...',
+ '..NN.DD.NN..',
+ '.....DD.....',
+ '....N..N....',
+ '...NN..NN...',
+ '............',
+ '............'
+ ], {N:'#252836', Y:'#e0b18c', P:'#5a67b8', D:'#6b4b38'}, 12, 16);
+ }
+
+ function drawGlassJellyfishRight() {
+ return artRowsSized([
+ '............',
+ '....NNNN....',
+ '...NCCCCN...',
+ '..NCCCCCCN..',
+ '.NCCYYYYCCN.',
+ '.NCCCCCCCCN.',
+ '..NNCCCCNN..',
+ '...N.CC.N...',
+ '..N..CC..N..',
+ '.....CC.....',
+ '....N..N....',
+ '...N....N...',
+ '............',
+ '............',
+ '............',
+ '............'
+ ], {N:'#252836', C:'#9ef4ff', Y:'#fff1a8'}, 12, 16);
+ }
+
+ function drawCopperHeronRight() {
+ return artRowsSized([
+ '.....NN.....',
+ '....NYYN....',
+ '.....NNN....',
+ '......NN....',
+ '......NN....',
+ '....NNCCN...',
+ '...NCCCCCNN.',
+ '..NCCCCCCCN.',
+ '...NNCCCCN..',
+ '.....NNNN...',
+ '......N.....',
+ '......N.....',
+ '.....N.N....',
+ '....NN.NN...',
+ '............',
+ '............'
+ ], {N:'#252836', C:'#c97844', Y:'#fff1a8'}, 12, 16);
+ }
+
+ function drawPaperFoxKiteRight() {
+ return artRowsSized([
+ '..............',
+ '.....NNN......',
+ '....NWWYN.....',
+ '...NWWYYYN....',
+ '..NWWYYYYN....',
+ '.NWWYYNNYN....',
+ '..NNYYYYYN....',
+ '....NYYYNN....',
+ '...NN..NN.....',
+ '..N....N......',
+ '..............',
+ '..............'
+ ], {N:'#252836', W:'#f4f7ff', Y:'#ffd66e'}, 14, 12);
+ }
+
+ function drawPrismSeahorseRight() {
+ return artRowsSized([
+ '..........',
+ '....NN....',
+ '...NCCN...',
+ '..NCPCCN..',
+ '..NCCCCN..',
+ '...NCCN...',
+ '....NCN...',
+ '....NCN...',
+ '...NCCN...',
+ '..NCCN....',
+ '..NCCNN...',
+ '...NNCCN..',
+ '.....NN...',
+ '..........'
+ ], {N:'#252836', C:'#7fdaf2', P:'#d7c6ff'}, 10, 14);
+ }
+
+ function drawOrchardSpriteRight() {
+ return artRowsSized([
+ '..........',
+ '...NNNN...',
+ '..NYYYYN..',
+ '.NYYRYYYN.',
+ '.NYYYYYYN.',
+ '..NNGGNN..',
+ '...NGGN...',
+ '..NNGGNN..',
+ '..N....N..',
+ '.NN....NN.',
+ '..........',
+ '..........'
+ ], {N:'#252836', Y:'#ffd1a8', R:'#ff7f6e', G:'#7bc56a'}, 10, 12);
+ }
+
+ function drawMapTurtleRight() {
+ return artRowsSized([
+ '............',
+ '....NNNN....',
+ '..NNGGGGNN..',
+ '.NGGYGGYGN..',
+ '.NGGGGGGGN..',
+ '..NNGGGGNNN.',
+ '...NSSSSYN..',
+ '..NN....NN..',
+ '............',
+ '............'
+ ], {N:'#252836', G:'#6fbf65', Y:'#ffd66e', S:'#8a6748'}, 12, 10);
+ }
+
+ function drawTeaSparrowRight() {
+ return artRowsSized([
+ '........',
+ '..NNN...',
+ '.NYYNN..',
+ 'NYYYYYN.',
+ '.NDDYN..',
+ '..NNN...',
+ '..N.N...',
+ '........'
+ ], {N:'#252836', Y:'#c89a6b', D:'#6b4b38'}, 8, 8);
+ }
+
+ function drawBubbleDiverRight() {
+ return artRowsSized([
+ '....NNNN....',
+ '...NCCCCN...',
+ '..NCYYYYCN..',
+ '..NCYYYYCN..',
+ '...NCCCCN...',
+ '..NNBBBBNN..',
+ '.NBBBBBBBBN.',
+ '.NBBYBBYBBN.',
+ '..NBBBBBBN..',
+ '...NBBBBN...',
+ '..NN.BB.NN..',
+ '.....BB.....',
+ '....N..N....',
+ '...NN..NN...',
+ '............',
+ '............'
+ ], {N:'#252836', C:'#dffbff', Y:'#e0b18c', B:'#357bd1'}, 12, 16);
+ }
+
+ function drawMistEelRight() {
+ return artRowsSized([
+ '..............',
+ '...NNN........',
+ '.NNCCCNNNNN...',
+ 'NCCCCCCCCCCNN.',
+ '.NNCCNNNNNN...',
+ '..............'
+ ], {N:'#252836', C:'#bfefff'}, 14, 6);
+ }
+
+ function drawRubyBeetleRight() {
+ return artRowsSized([
+ '..........',
+ '..NNNN....',
+ '.NRRRRN...',
+ 'NRYRRYRN..',
+ '.NRRRRN...',
+ '..NNNN....',
+ '.N....N...',
+ '..........'
+ ], {N:'#252836', R:'#d4505f', Y:'#ffd66e'}, 10, 8);
+ }
+
+ function drawSnowyTanukiRight() {
+ return artRowsSized([
+ '............',
+ '....NNNN....',
+ '..NNWWWWNN..',
+ '.NWWDWWDWN..',
+ '.NWWWWWWWN..',
+ '..NNWWWWN...',
+ '...NDDDN....',
+ '..NNWWWNN...',
+ '..N.N.N.N...',
+ '.NN.N.NN....',
+ '............',
+ '............'
+ ], {N:'#252836', W:'#f4f7ff', D:'#6b5d67'}, 12, 12);
+ }
+
+ function drawLighthouseKeeperRight() {
+ return artRowsSized([
+ '....NNNN....',
+ '...NYYYYN...',
+ '..NYNNYYYN..',
+ '..NYYYYYYN..',
+ '...NNNNNN...',
+ '..NNCCCCNN..',
+ '.NCCCCCCCCN.',
+ '.NCCYCCYCCN.',
+ '..NCCCCCCN..',
+ '...NCCCCN...',
+ '..NN.DD.NN..',
+ '.....DD.....',
+ '....N..N....',
+ '...NN..NN...',
+ '............',
+ '............'
+ ], {N:'#252836', Y:'#e0b18c', C:'#c97844', D:'#3c3348'}, 12, 16);
+ }
+
window.PixelIslandDebug = { ...(window.PixelIslandDebug || {}), applySyncEvent };
bootstrap();
})();
diff --git a/index.html b/index.html
index 5afd5ff..95cbffb 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,7 @@
- Pixel Island Summoner - Local Prototype
+ Pixel Island Summoner - Shared Test
@@ -19,7 +19,7 @@
diff --git a/styles.css b/styles.css
index 9409c6f..83e344d 100644
--- a/styles.css
+++ b/styles.css
@@ -324,6 +324,8 @@ button.danger.active, .iconButton.danger.active { background: var(--danger); col
.drawerBody {
overflow-y: auto;
+ overflow-anchor: none;
+ scroll-behavior: auto;
padding: 8px;
background:
linear-gradient(45deg, rgba(255,255,255,.4) 25%, transparent 25%) 0 0/18px 18px,
@@ -472,7 +474,13 @@ button:disabled, .tool:disabled { opacity: .45; cursor: not-allowed; transform:
.actionRow button { flex: 1; }
.lineageNote { min-height: 18px; color: var(--muted); font-size: 12px; }
-.assetList { display: grid; gap: 10px; }
+.assetList {
+ display: grid;
+ gap: 10px;
+ align-content: start;
+ grid-auto-rows: max-content;
+ overflow-anchor: none;
+}
.collectionToolbar {
display: grid;
gap: 8px;
@@ -909,8 +917,10 @@ body, button, input, select, textarea {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
+ align-content: start;
max-height: 360px;
overflow-y: auto;
+ overflow-anchor: none;
padding-right: 4px;
}
.libraryEmptyNote {
@@ -2603,6 +2613,7 @@ body, button, input, select, textarea { font-size: 15px; }
.collectionViewTabs.tabs {
grid-template-columns: repeat(3, minmax(0, 1fr));
+ align-items: start;
border: 2px solid var(--line);
box-shadow: 3px 3px 0 rgba(36,48,68,.12);
margin: 2px 0 0;
@@ -2611,7 +2622,9 @@ body, button, input, select, textarea { font-size: 15px; }
}
.collectionViewTab.tab {
+ align-self: start;
min-width: 0;
+ min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -2839,8 +2852,46 @@ body, button, input, select, textarea { font-size: 15px; }
.studioDrawer { z-index: var(--drawer-z) !important; }
}
-/* Keep Collection list redraws from letting scroll anchoring snap the drawer to the top. */
-.drawerBody,
-.assetList {
- overflow-anchor: none;
+
+/* Follow-up layout: let the palette consume the unused horizontal area below the editor and tool column. */
+@media (min-width: 761px) {
+ #tab-draw .paintLayout {
+ grid-template-columns: minmax(0, 1fr) clamp(220px, 18vw, 270px) !important;
+ grid-template-rows: auto auto !important;
+ align-items: start !important;
+ }
+ #tab-draw .paintMainColumn {
+ display: contents !important;
+ }
+ #tab-draw .paintMainColumn #paintCanvas {
+ grid-column: 1 !important;
+ grid-row: 1 !important;
+ width: 100% !important;
+ max-width: none !important;
+ justify-self: stretch !important;
+ }
+ #tab-draw .drawControlPanel {
+ grid-column: 2 !important;
+ grid-row: 1 !important;
+ }
+ #tab-draw .paintMainColumn .palettePanel {
+ grid-column: 1 / -1 !important;
+ grid-row: 2 !important;
+ width: 100% !important;
+ max-width: none !important;
+ justify-self: stretch !important;
+ padding: 6px !important;
+ }
+ #tab-draw .paintMainColumn .paletteGrid,
+ .studioDrawer.advancedTools #tab-draw .paintMainColumn .paletteGrid {
+ grid-template-columns: repeat(auto-fill, minmax(28px, 1fr)) !important;
+ gap: 4px !important;
+ max-height: none !important;
+ overflow: visible !important;
+ }
+ #tab-draw .paintMainColumn .paletteGrid button,
+ #tab-draw .paintMainColumn .paletteSwatch {
+ min-height: 28px !important;
+ }
}
+