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 disabled

Admin disabled

Set admin_token in api/config.php to enable this page.

'; + exit; +} +if (!hash_equals($token, $given)) { + http_response_code(403); + echo 'Forbidden

Forbidden

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: / Open reports: . Hiding an object removes it from public snapshots; restore makes it visible again.

+

Admin error:

+ +

No DB has been initialized yet. Open ../api/index.php?action=health and use the app once, then return here.

+ + +
+

+

Reason: / Reporter:

+

Object: / Asset: / Status:

+
+ + + + + +
+
+ +

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 @@
-
A rotating island exhibition for tiny pixel works.
+
A shared rotating island exhibition for tiny pixel works.
@@ -34,7 +34,7 @@
- Publish creates a local account for island publishing. + Publish creates an account for shared island publishing.
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; + } } +