From 4c4e767ec6bae67017fcb810b4fd86018e1c614e Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Sat, 1 Aug 2026 16:06:14 +0900 Subject: [PATCH] d --- .gitignore | 7 + .htaccess | 22 + README.md | 126 ++ api-bridge.php | 115 ++ app.js | 1322 ++++++++++++----- build-config.json | 2 +- build-meta.js | 6 +- docs/client-guide.md | 14 +- docs/effects-cosmetics-performance-plan.md | 368 +++++ docs/interaction-performance.md | 13 +- docs/internal-system.md | 14 +- docs/test-policy.md | 8 + index.html | 38 +- package.json | 13 +- realtime-server.js | 114 +- runtime-config.js | 10 +- scripts/generate-store-catalog.js | 6 + scripts/service-control.js | 303 ++++ server.js | 273 +++- server/apache-bridge.js | 62 + server/player-service.js | 10 +- shared-contracts.js | 4 +- store-catalog.generated.js | 2 +- store-catalog.json | 165 +- style.css | 87 +- test/browser-field-storage-benchmark.js | 2 +- test/browser-performance-benchmark.js | 185 ++- test/cleanup-performance-smoke-test.js | 4 +- test/concurrency-smoke-test.js | 35 +- test/effects-performance-smoke-test.js | 51 + test/expansion-repair-smoke-test.js | 1 + test/expansion-smoke-test.js | 1 + test/field-save-load-v2-smoke-test.js | 2 +- test/fixtures/effect-visual-checkpoints.json | 130 ++ test/gameplay-simplification-smoke-test.js | 12 +- test/interaction-smoke-test.js | 6 +- test/phase2-source-smoke-test.js | 2 +- test/realtime-lease-unit-test.js | 5 +- test/realtime-phase2-smoke-test.js | 10 +- test/reset-smoke-test.js | 2 +- test/run-all.js | 4 +- test/save-pipeline-smoke-test.js | 4 +- test/security-authority-smoke-test.js | 6 +- test/server-recovery-test.js | 9 +- test/server-smoke-test.js | 18 +- test/shared-contracts-test.js | 13 +- test/shared-world-client-smoke-test.js | 24 +- test/shared-world-complete-smoke-test.js | 12 +- test/source-smoke-test.js | 28 +- test/store-ui-browser-test.js | 13 +- ...4772-ownership-reaction-name-smoke-test.js | 4 +- test/v4774-drag-overview-smoke-test.js | 2 +- test/v4776-frame-pipeline-smoke-test.js | 5 +- .../v4777-hud-input-performance-smoke-test.js | 4 +- .../v4778-interaction-scheduler-smoke-test.js | 4 +- test/v4779-settings-pan-hud-smoke-test.js | 4 +- ...0-release-persistence-cursor-smoke-test.js | 10 +- test/v4781-hud-gate-overlay-smoke-test.js | 11 +- ...ighlight-store-internal-gate-smoke-test.js | 8 +- ...ap-store-economy-persistence-smoke-test.js | 10 +- test/v4784-user-request-smoke-test.js | 20 + test/v4785-cosmetics-shop-smoke-test.js | 35 + test/v4786-effects-ux-smoke-test.js | 15 + ...v4787-user-cosmetic-realtime-smoke-test.js | 20 + ...v4788-time-attack-navigation-smoke-test.js | 25 + test/v4791-server-startup-smoke-test.js | 34 + test/v4792-apache-bridge-smoke-test.js | 40 + test/v4793-php-poll-bridge-smoke-test.js | 47 + test/v4794-background-deploy-smoke-test.js | 59 + test/v4795-single-world-only-smoke-test.js | 54 + test/v4797-shared-board-input-smoke-test.js | 62 + .../v4798-startup-version-retry-smoke-test.js | 19 + test/v4800-shared-clear-economy-smoke-test.js | 64 + 73 files changed, 3500 insertions(+), 739 deletions(-) create mode 100644 .htaccess create mode 100644 api-bridge.php create mode 100644 docs/effects-cosmetics-performance-plan.md create mode 100644 scripts/service-control.js create mode 100644 server/apache-bridge.js create mode 100644 test/effects-performance-smoke-test.js create mode 100644 test/fixtures/effect-visual-checkpoints.json create mode 100644 test/v4784-user-request-smoke-test.js create mode 100644 test/v4785-cosmetics-shop-smoke-test.js create mode 100644 test/v4786-effects-ux-smoke-test.js create mode 100644 test/v4787-user-cosmetic-realtime-smoke-test.js create mode 100644 test/v4788-time-attack-navigation-smoke-test.js create mode 100644 test/v4791-server-startup-smoke-test.js create mode 100644 test/v4792-apache-bridge-smoke-test.js create mode 100644 test/v4793-php-poll-bridge-smoke-test.js create mode 100644 test/v4794-background-deploy-smoke-test.js create mode 100644 test/v4795-single-world-only-smoke-test.js create mode 100644 test/v4797-shared-board-input-smoke-test.js create mode 100644 test/v4798-startup-version-retry-smoke-test.js create mode 100644 test/v4800-shared-clear-economy-smoke-test.js diff --git a/.gitignore b/.gitignore index 5a310a1..924ad55 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,10 @@ node_modules/ *.log Thumbs.db .DS_Store + +# Runtime-selected local bridge port. +.linkfield-port +.linkfield-server.pid +.linkfield-port +.linkfield-deployment.json +link-field.log diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..b6da061 --- /dev/null +++ b/.htaccess @@ -0,0 +1,22 @@ +# BEGIN LINKFIELD MANAGED PROXY + + RewriteEngine On + + # Prefer a native Apache proxy when the host permits it. + + + RewriteCond %{HTTP:Upgrade} =websocket [NC] + RewriteRule ^api/realtime/?$ ws://127.0.0.1:32956/api/realtime [P,L] + + RewriteRule ^api/(.*)$ http://127.0.0.1:32956/api/$1 [P,L] + + + # Shared hosts often disable mod_proxy. Route ordinary API requests + # through the bundled PHP bridge instead. + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L] + + + Require all denied + +# END LINKFIELD MANAGED PROXY diff --git a/README.md b/README.md index e69de29..b936510 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,126 @@ +# LinkField v48.0 + +LinkFieldは、サーバー上の**単一共有ワールド専用**です。ローカル盤面、同期コード、複数ワールド切替はありません。 + +## 起動 + +ZIPを展開したプロジェクト直下で実行します。 + +```bash +npm start +``` + +`npm start` は次を自動実行し、完了後すぐにコマンド入力へ戻ります。 + +1. 旧フォルダから残っているLinkFieldサーバーを停止 +2. `$HOME/public_html/link-field` へ公開ファイルを配置 +3. 古いポート情報を削除 +4. 単一共有サーバーをバックグラウンド起動 +5. 実際の使用ポートを `.linkfield-port` へ保存 + +8080番が使用中の場合は空きポートへ自動的に切り替わります。公開側のPHPブリッジは `.linkfield-port` を参照するため、固定ポートは不要です。 + +正常時の表示例: + +```text +LinkField started in the background. The command prompt is available again. +PID: 12345 +Local port: 3000 +Public directory: /home/333/public_html/link-field +Log: /home/333/.local/share/LinkField/service/server.log +``` + +## 公開ディレクトリが異なる場合 + +Apacheが別のディレクトリを公開している場合だけ指定します。 + +```bash +LINK_FIELD_PUBLIC_DIR=/実際の公開ディレクトリ npm start +``` + +例: + +```bash +LINK_FIELD_PUBLIC_DIR="$HOME/www/link-field" npm start +``` + +## 接続確認 + +```bash +curl 'https://host.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/status' +``` + +正常時は、`available`、`sharedWorld`、`singleWorld` がすべて `true` のJSONが返ります。 + +```json +{"available":true,"sharedWorld":true,"singleWorld":true,"realtime":true} +``` + +404の場合は、Apacheの公開先と自動配置先が一致していません。 + +```bash +npm run status +ls -l "$HOME/public_html/link-field/api-bridge.php" +``` + +## 管理コマンド + +```bash +npm run status # 状態確認 +npm run restart # 再起動 +npm stop # 停止 +``` + +ログ確認: + +```bash +tail -f "$HOME/.local/share/LinkField/service/server.log" +``` + +前面起動で直接ログを見る場合: + +```bash +npm run start:foreground +``` + +前面起動中にそのターミナルへ入力できないのは正常です。終了は `Ctrl+C` です。 + +## データ保存先 + +共有ワールドは常に次の1か所へ保存されます。 + +```text +/link-field/world +``` + +起動ユーザーがこのディレクトリへ書き込める必要があります。初回だけ権限を準備する場合は、サーバー管理権限で次を実行します。 + +```bash +sudo mkdir -p /link-field/world +sudo chown -R "$(id -un):$(id -gn)" /link-field +``` + +展開フォルダやブラウザごとに別ワールドは作成されません。通常ウィンドウとシークレットウィンドウも同じサーバー正本を取得します。 + +## 盤面共有 + +未クリア盤面の線・特殊マス進行もサーバーへ保存されます。盤面を操作する際は専用の共有APIで占有権を即時取得し、利用できない場合だけリアルタイム経路へフォールバックします。占有応答前に指を離しても、ドラッグ軌跡は承認後に適用されます。接続状態は左下のFPS表示直上にある固定サイズの小型インジケーターで確認できます。 +## 更新時の起動 + +`npm start` は、稼働中の旧LinkFieldサーバーを停止してから、展開した版で起動し直します。新しい画面と古いサーバーが混在することはありません。 + +```bash +npm start +``` + +起動エラー画面の「再試行」は共有サーバーへの再接続を行います。ブラウザ内バックアップの復元は実行しません。 + + +## v48.0の共有確定ルール + +- 盤面の「プレイ中」は、ゲートまたは線端のつまみを押して操作を開始した時だけ発生します。カーソルを重ねただけでは発生しません。 +- 占有に関する画面下部の通知は表示しません。 +- 盤面がサーバーでクリア確定すると「プレイ中」は即時解除されます。ページ離脱・接続切断時も解除されます。 +- クリア表示とジェム加算は、共有サーバーが解答を検証して受理した後に確定します。送信できなかった場合、端末だけがクリア済みになることはありません。 +- 新規盤面を生成できるのは、その盤面をクリアしたプレイヤーだけです。生成後はサーバーへ保存され、他プレイヤーと再アクセス後の両方へ同じ盤面が返ります。 +- 初回ラインカラーは、プレミアム商品を除いた通常ラインカラーから選ばれます。 diff --git a/api-bridge.php b/api-bridge.php new file mode 100644 index 0000000..c7cb38d --- /dev/null +++ b/api-bridge.php @@ -0,0 +1,115 @@ + $message, 'serverTime' => (int) round(microtime(true) * 1000)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +$portFile = __DIR__ . DIRECTORY_SEPARATOR . '.linkfield-port'; +if (!is_file($portFile)) { + fail_json(503, 'LinkField server is not running. Run npm start in this directory.'); +} +$portText = trim((string) @file_get_contents($portFile)); +$port = filter_var($portText, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]); +if ($port === false) { + fail_json(503, 'LinkField server port information is invalid. Restart npm start.'); +} + +$targetPath = isset($_GET['path']) ? (string) $_GET['path'] : ''; +if (!preg_match('#^/api/(?:cloud|player|realtime)(?:/|$)#', $targetPath)) { + fail_json(400, 'Invalid LinkField API path.'); +} +$query = $_GET; +unset($query['path']); +if ($query) { + $targetPath .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986); +} + +$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); +if (!in_array($method, ['GET', 'POST', 'HEAD'], true)) { + fail_json(405, 'Method not allowed.'); +} +$body = file_get_contents('php://input'); +if ($body === false) $body = ''; +if (strlen($body) > 64 * 1024 * 1024) { + fail_json(413, 'Request body is too large.'); +} + +$authorization = ''; +if (function_exists('getallheaders')) { + $headers = getallheaders(); + if (is_array($headers)) { + foreach ($headers as $name => $value) { + if (strcasecmp((string) $name, 'Authorization') === 0) $authorization = (string) $value; + if (strcasecmp((string) $name, 'X-LinkField-Authorization') === 0 && $authorization === '') $authorization = (string) $value; + } + } +} +if ($authorization === '') { + $authorization = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? $_SERVER['HTTP_X_LINKFIELD_AUTHORIZATION'] ?? ''); +} + +$socket = @stream_socket_client( + 'tcp://127.0.0.1:' . $port, + $errorNumber, + $errorMessage, + 3, + STREAM_CLIENT_CONNECT +); +if (!is_resource($socket)) { + fail_json(502, 'LinkField server is not reachable on its local port. Restart npm start.'); +} +stream_set_timeout($socket, 15); + +$requestHeaders = [ + $method . ' ' . $targetPath . " HTTP/1.1", + 'Host: 127.0.0.1:' . $port, + 'Connection: close', + 'Accept: application/json', + 'Content-Type: application/json', + 'Content-Length: ' . strlen($body), +]; +if ($authorization !== '') $requestHeaders[] = 'Authorization: ' . str_replace(["\r", "\n"], '', $authorization); +$request = implode("\r\n", $requestHeaders) . "\r\n\r\n" . $body; +$written = 0; +$length = strlen($request); +while ($written < $length) { + $result = fwrite($socket, substr($request, $written)); + if ($result === false || $result === 0) { + fclose($socket); + fail_json(502, 'Failed to send the request to the LinkField server.'); + } + $written += $result; +} +$response = stream_get_contents($socket); +$meta = stream_get_meta_data($socket); +fclose($socket); +if ($response === false || $response === '' || !empty($meta['timed_out'])) { + fail_json(504, 'LinkField server did not respond in time.'); +} + +$separator = strpos($response, "\r\n\r\n"); +if ($separator === false) fail_json(502, 'Invalid response from the LinkField server.'); +$headerText = substr($response, 0, $separator); +$responseBody = substr($response, $separator + 4); +$headerLines = explode("\r\n", $headerText); +$statusLine = array_shift($headerLines); +if (!preg_match('#^HTTP/\d(?:\.\d)?\s+(\d{3})#', (string) $statusLine, $statusMatch)) { + fail_json(502, 'Invalid status from the LinkField server.'); +} +http_response_code((int) $statusMatch[1]); +foreach ($headerLines as $line) { + $colon = strpos($line, ':'); + if ($colon === false) continue; + $name = trim(substr($line, 0, $colon)); + $value = trim(substr($line, $colon + 1)); + if (in_array(strtolower($name), ['content-type', 'cache-control', 'x-content-type-options'], true)) { + header($name . ': ' . $value, true); + } +} +header('X-LinkField-Bridge: php', true); +if ($method !== 'HEAD') echo $responseBody; diff --git a/app.js b/app.js index 1879ea9..4816ee8 100644 --- a/app.js +++ b/app.js @@ -32,7 +32,7 @@ const appVersionLabel=`v${APP_VERSION}`,brandVersion=document.querySelector('.br if(brandVersion)brandVersion.textContent=appVersionLabel; if(!document.title.endsWith(appVersionLabel))document.title=`${document.title.replace(/\s+v[\w.-]+$/,'').trim()} ${appVersionLabel}`; const MAX_BOARDS=200000,MAX_PATHS_PER_BOARD=512,MAX_IMPORT_BYTES=100*1024*1024,CLOCK_DRIFT_LIMIT=5*60*1000,LOCAL_MIRROR_MAX_BYTES=4.5*1024*1024,IDB_STARTUP_TIMEOUT=8000,MAX_FRONTIER_GENERATION_CYCLES=3,GC_BATCH_ROWS=500; -const AUXILIARY_FPS=30,AUXILIARY_FRAME_INTERVAL=1000/AUXILIARY_FPS,DRAG_TARGET_FPS=60,DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS,INTERACTION_FRAME_TOLERANCE_MS=1.25,DRAG_DISPLAY_WATCHDOG_MS=18,CAMERA_DISPLAY_WATCHDOG_MS=34,DRAG_MAX_POINTER_SAMPLES=12,DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4,DRAG_MODEL_BUDGET_MS=.5,DRAG_MAX_LIVE_CATCHUP_CELLS=6,DRAG_MAX_RELEASE_CATCHUP_CELLS=24,LIGHTWEIGHT_DRAG_BOARD_CELLS=120,GENERATION_FAILURE_BONUS=2500,GENERATION_FAILURE_MIN_MS=8000,SHARED_EXPANSION_GRACE_MS=60000,SHARED_EXPANSION_JITTER_MS=30000,REALTIME_CURSOR_INTERVAL=50,REALTIME_CURSOR_HEARTBEAT_INTERVAL=5000,REALTIME_VIEWPORT_INTERVAL=250,REALTIME_PLAYER_STALE_MS=15000,REALTIME_CLAIM_REQUEST_TIMEOUT=5000,REALTIME_CLAIM_TOUCH_INTERVAL=20000,REALTIME_REACTION_DURATION=4500,REALTIME_REACTION_RATE_INTERVAL=500,REACTION_LONG_PRESS_MS=500,REACTION_MOVE_CANCEL_PX=18; +const MAX_RENDER_FPS=30,GLOBAL_FRAME_INTERVAL=1000/MAX_RENDER_FPS,AUXILIARY_FPS=30,AUXILIARY_FRAME_INTERVAL=1000/AUXILIARY_FPS,REACTION_TARGET_FPS=30,REACTION_FRAME_INTERVAL=1000/REACTION_TARGET_FPS,DRAG_TARGET_FPS=30,DRAG_FRAME_INTERVAL=1000/DRAG_TARGET_FPS,INTERACTION_FRAME_TOLERANCE_MS=1.25,DRAG_DISPLAY_WATCHDOG_MS=34,CAMERA_DISPLAY_WATCHDOG_MS=34,DRAG_MAX_POINTER_SAMPLES=12,DRAG_MAX_LOGICAL_SAMPLES_PER_FRAME=4,DRAG_MODEL_BUDGET_MS=.5,DRAG_MAX_LIVE_CATCHUP_CELLS=6,DRAG_MAX_RELEASE_CATCHUP_CELLS=24,LIGHTWEIGHT_DRAG_BOARD_CELLS=120,GENERATION_FAILURE_BONUS=2500,GENERATION_FAILURE_MIN_MS=8000,SHARED_EXPANSION_GRACE_MS=60000,SHARED_EXPANSION_JITTER_MS=30000,REALTIME_CURSOR_INTERVAL=50,REALTIME_CURSOR_HEARTBEAT_INTERVAL=5000,REALTIME_VIEWPORT_INTERVAL=250,REALTIME_PLAYER_STALE_MS=15000,REALTIME_CLAIM_REQUEST_TIMEOUT=5000,REALTIME_CLAIM_TOUCH_INTERVAL=20000,REALTIME_REACTION_DURATION=4500,REALTIME_REACTION_RATE_INTERVAL=500,REACTION_LONG_PRESS_MS=500,REACTION_MOVE_CANCEL_PX=18; const INTERACTION_SCHEDULER_VARIANT=(()=>{try{return localStorage.getItem('bend-field-interaction-scheduler-variant')==='reduced'?'reduced':'full'}catch(_){return'full'}})(); globalThis.BEND_INTERACTION_SCHEDULER=Object.freeze({enabled:true,version:2,variant:INTERACTION_SCHEDULER_VARIANT}); const LEGACY_LOCAL_SOLVER='\u3042\u306a\u305f',DEFAULT_PLAYER_NAME='\u65c5\u4eba'; @@ -41,7 +41,7 @@ const AUTO_NAME_NOUNS=Object.freeze(['\u30ad\u30c4\u30cd','\u30cd\u30b3','\u30d5 function createAutomaticPlayerName(){const values=new Uint32Array(2);if(globalThis.crypto?.getRandomValues)globalThis.crypto.getRandomValues(values);else{values[0]=(Math.random()*0xffffffff)>>>0;values[1]=(Math.random()*0xffffffff)>>>0}return`${AUTO_NAME_ADJECTIVES[values[0]%AUTO_NAME_ADJECTIVES.length]}${AUTO_NAME_NOUNS[values[1]%AUTO_NAME_NOUNS.length]}-${String(((values[0]^values[1])>>>0)%1000).padStart(3,'0')}`} function currentPlayerName(){return typeof data?.playerName==='string'&&data.playerName.trim()?data.playerName.trim().slice(0,24):DEFAULT_PLAYER_NAME} function currentPlayerId(){return typeof data?.cloudProfile?.playerId==='string'?data.cloudProfile.playerId:null} -const STARTER_SEED=0x51a7f00d; +const STARTER_SEED=0x9c37a5e1; const STARTER_PUZZLE=Object.freeze({"g":[[0,0,"W"],[0,1,"N"],[1,4,"E"],[0,4,"E"],[4,0,"W"],[4,3,"S"],[2,4,"E"],[4,4,"E"],[4,1,"S"],[4,2,"S"]],"n":[[1,0,3],[1,2,2],[2,0,3],[4,4,2],[3,1,2]],"valid":[[0,0],[0,1],[0,2],[0,3],[0,4],[1,0],[1,1],[1,2],[1,3],[1,4],[2,0],[2,1],[2,2],[2,3],[2,4],[3,0],[3,1],[3,2],[3,3],[3,4],[4,0],[4,1],[4,2],[4,3],[4,4]],"bounds":{"w":5,"h":5},"axis":"MIX","solution":[{"startGate":0,"endGate":1,"cells":[[0,0],[1,0],[1,1],[0,1]]},{"startGate":2,"endGate":3,"cells":[[1,4],[1,3],[1,2],[0,2],[0,3],[0,4]]},{"startGate":4,"endGate":5,"cells":[[4,0],[3,0],[2,0],[2,1],[2,2],[2,3],[3,3],[4,3]]},{"startGate":6,"endGate":7,"cells":[[2,4],[3,4],[4,4]]},{"startGate":8,"endGate":9,"cells":[[4,1],[3,1],[3,2],[4,2]]}],"level":1,"maxTurns":3,"totalTurns":12,"style":"variable-world-gates","complexity":{"rating":1,"score":1,"areaUnits":1,"lineCount":5,"totalTurns":12,"avgTurns":2.4,"maxTurns":3,"avgLength":5,"maxLength":8},"difficulty":1,"regionalTarget":1}); const SCORE_VERSION=6,STORE_PRICE_VERSION=1,MIN_STORE_ITEM_PRICE=3000,MIN_CURSOR_PRICE=500,MAX_FACE_CURSOR_PRICE=50000,STORE_CHANCE=1/10,DRAG_FLAG_CURSOR_SIZE=12.4,DRAG_FLAG_CLIP_RADIUS=5.8,MINIMAP_LONG_LINE=1024,MINIMAP_VIEW_CHUNKS_X=42; const MIN_CAMERA_SCALE=.08,SCORE_LENS_ZOOM_THRESHOLD=.72,OVERVIEW_ZOOM_THRESHOLD=.54,SOUND_GAIN_MULTIPLIER=5.2,UNIQUE_SOLUTION_MIN_LEVEL=6; @@ -49,8 +49,9 @@ const SPECIAL_CELL_MIN_LEVEL=5; const DRAG_EDGE_MARGIN=76,DRAG_EDGE_MAX_SPEED=.58; const POINTER_SNAP_THRESHOLD=CELL*.45,POINTER_DOMINANT_RATIO=1.25; const TIME_ATTACK_MINUTES=Object.freeze([3,5,10]); -const REACTION_EMOJIS=Object.freeze(['👍','👉🏻','🙏','🧠','🎉']); -const TIME_ATTACK_COOLDOWN_MINUTES=Object.freeze({3:30,5:50,10:100}); +const REACTION_EMOJIS=Object.freeze(['👍','🤩','🙏','🧠','🎉']); +const DEBUG_PURCHASE_MODE=location.pathname==='/debug-items'||location.pathname.endsWith('/debug-items')||new URLSearchParams(location.search).get('debug')==='items'; +const TIME_ATTACK_COOLDOWN_MINUTES=Object.freeze({3:10,5:15,10:20}); const TIME_ATTACK_TIERS=Object.freeze([ Object.freeze({score:1000,multiplier:3}), Object.freeze({score:500,multiplier:2.5}), @@ -58,8 +59,24 @@ const TIME_ATTACK_TIERS=Object.freeze([ Object.freeze({score:100,multiplier:1.5}), Object.freeze({score:0,multiplier:1.25}) ]); +const COSMETIC_PRESENTATIONS=Object.freeze([ + ['line-color-cyan','スカイシアン','●','ラインカラー','澄んだ電気色のシアン。'], + ['line-color-gold','ソーラーゴールド','●','ラインカラー','宝石のように明るい金色。'],['line-color-mint','ミントシグナル','●','ラインカラー','くっきりした信号色のグリーン。'], + ['line-color-violet','アーケードバイオレット','●','ラインカラー','深みのある紫色。'],['line-color-tangerine','タンジェリン','●','ラインカラー','温かみのあるオレンジ。'], + ['line-color-cobalt','コバルト','●','ラインカラー','彩度の高いブルー。'],['line-color-coral','ホットコーラル','●','ラインカラー','明るいコーラルレッド。'], + ['line-color-aqua','アクアパルス','●','ラインカラー','涼しげな青緑色。'],['line-color-magenta','マゼンタポップ','●','ラインカラー','力強いマゼンタ。'], + ['line-color-pearl','パールホワイト','●','プレミアムラインカラー','明るい真珠色のホワイト。'],['line-color-lime','ハイパーライム','●','プレミアムラインカラー','高エネルギーなライム色。'], + ['line-color-amber','アンバーコア','●','プレミアムラインカラー','濃密な琥珀色。'], + ['line-color-ice','アークティックアイス','●','プレミアムラインカラー','淡く結晶感のある水色。'],['line-color-lavender','ドリームラベンダー','●','プレミアムラインカラー','柔らかなラベンダー色。'], + ['line-effect-aurora','オーロラ','≋','ショップ限定ラインカラー','選定されたオーロラ色が2秒ごとに切り替わり、線とゲートを彩ります。'], + ['reaction-effect-giant','巨大','😀','リアクション','巨大な絵文字が落下し、盤面を揺らして背景にひびを入れます。'], + ['reaction-effect-laser','レーザー','⚡','リアクション','大量のレーザーが交差するディスコ風エフェクト。'],['reaction-effect-orbit','オービット','🪐','リアクション','傾いた巨大な土星と星、周回する絵文字を表示します。'], + ['reaction-effect-firework','花火','🎆','リアクション','打ち上げ後に多重の絵文字リングと小花火が開きます。'], + ['reaction-effect-comet','彗星','☄','リアクション','遠方から絵文字が突入し、振動して大爆発します。'] +].map(([id,name,icon,effectLabel,description])=>Object.freeze({id,name,icon,effectLabel,toast:name,description}))); const STORE_ITEM_BASE=Object.freeze([ - Object.freeze({id:'score-lens',name:'ジェムレンズ',icon:'▦',effectLabel:'予想報酬表示 ON / OFF',toast:'予想報酬表示を切り替え',description:'未クリア盤面の予想報酬表示を切り替えます。'}) + Object.freeze({id:'score-lens',name:'ジェムレンズ',icon:'▦',effectLabel:'予想報酬表示 オン/オフ',toast:'予想報酬表示を切り替え',description:'未クリア盤面の予想報酬表示を切り替えます。'}), + ...COSMETIC_PRESENTATIONS ]); const YELLOW_FACE_CURSOR_SOURCE=`1F600|grinning face @@ -112,14 +129,11 @@ const YELLOW_FACE_CURSOR_SOURCE=`1F600|grinning face 1F62E 200D 1F4A8|face exhaling 1F925|lying face 1FAE8|shaking face -1F642 200D 2194 FE0F|head shaking horizontally -1F642 200D 2195 FE0F|head shaking vertically 1F60C|relieved face 1F614|pensive face 1F62A|sleepy face 1F924|drooling face 1F634|sleeping face -1FAE9|face with bags under eyes 1F637|face with medical mask 1F912|face with thermometer 1F915|face with head-bandage @@ -143,7 +157,6 @@ const YELLOW_FACE_CURSOR_SOURCE=`1F600|grinning face 1F62F|hushed face 1F632|astonished face 1F633|flushed face -1FAEA|distorted face 1F97A|pleading face 1F979|face holding back tears 1F626|frowning face with open mouth @@ -163,40 +176,48 @@ const YELLOW_FACE_CURSOR_SOURCE=`1F600|grinning face 1F971|yawning face 1F624|face with steam from nose 1F620|angry face`; -function titleEmojiName(name){return name.replace(/\b[a-z]/g,letter=>letter.toUpperCase())} const YELLOW_FACE_CURSOR_ITEMS=Object.freeze(YELLOW_FACE_CURSOR_SOURCE.split('\n').map((row,index)=>{ const[codes,name]=row.split('|'),codeKey=codes.toLowerCase().replace(/\s+/g,'-'), emoji=String.fromCodePoint(...codes.split(' ').map(code=>Number.parseInt(code,16))); return Object.freeze({ - id:`cursor-face-${codeKey}`,name:titleEmojiName(name),cursorEmoji:emoji,icon:emoji, - effectLabel:'\u7d75\u6587\u5b57\u30ab\u30fc\u30bd\u30eb',toast:titleEmojiName(name), + id:`cursor-face-${codeKey}`,name:`${emoji} カーソル`,cursorEmoji:emoji,icon:emoji, + effectLabel:'絵文字カーソル',toast:`${emoji} カーソル`, description:'\u9ec4\u8272\u3044\u8868\u60c5\u7d75\u6587\u5b57\u306e\u30ab\u30fc\u30bd\u30eb\u3067\u3059\u3002' }); })); const FLAG_REGION_CODES=`AC AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CP CQ CR CU CV CW CX CY CZ DE DG DJ DK DM DO DZ EA EC EE EG EH ER ES ET EU FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU IC ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TA TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM UN US UY UZ VA VC VE VG VI VN VU WF WS XK YE YT ZA ZM ZW`.split(' '); function regionFlagEmoji(code){return String.fromCodePoint(...[...code].map(letter=>0x1f1e6+letter.charCodeAt(0)-65))} +const regionDisplayNames=typeof Intl?.DisplayNames==='function'?new Intl.DisplayNames(['ja'],{type:'region'}):null; +function regionFlagName(code){try{return regionDisplayNames?.of(code)||code}catch(_){return code}} function subdivisionFlagEmoji(tag){return String.fromCodePoint(0x1f3f4,...[...tag].map(letter=>0xe0000+letter.charCodeAt(0)),0xe007f)} function emojiAssetKey(emoji){return[...emoji].map(character=>character.codePointAt(0).toString(16)).join('-')} const FLAG_CURSOR_ITEMS=Object.freeze([ - ...FLAG_REGION_CODES.map(code=>({key:code.toLowerCase(),name:code,emoji:regionFlagEmoji(code)})), - ...[['gbeng','England'],['gbsct','Scotland'],['gbwls','Wales']].map(([key,name])=>({key,name,emoji:subdivisionFlagEmoji(key)})) + ...FLAG_REGION_CODES.map(code=>({key:code.toLowerCase(),name:regionFlagName(code),emoji:regionFlagEmoji(code)})), + ...[['gbeng','イングランド'],['gbsct','スコットランド'],['gbwls','ウェールズ']].map(([key,name])=>({key,name,emoji:subdivisionFlagEmoji(key)})) ].map(source=>Object.freeze({ - id:`cursor-flag-${source.key}`,name:source.name,cursorEmoji:source.emoji,icon:source.emoji,flagAsset:`assets/flags/${emojiAssetKey(source.emoji)}.svg`, + id:`cursor-flag-${source.key}`,name:`${source.name}の国旗`,cursorEmoji:source.emoji,icon:source.emoji,flagAsset:`assets/flags/${emojiAssetKey(source.emoji)}.svg`, effectLabel:'国旗カーソル',toast:source.emoji,description:'' }))); const STORE_PRESENTATION_ITEMS=Object.freeze([...STORE_ITEM_BASE,...YELLOW_FACE_CURSOR_ITEMS,...FLAG_CURSOR_ITEMS]); const STORE_PRESENTATION_CATALOG=new Map(STORE_PRESENTATION_ITEMS.map(item=>[item.id,item])); -if(STORE_PRESENTATION_CATALOG.size!==CanonicalStoreCatalog.length||STORE_PRESENTATION_ITEMS.some(item=>!CanonicalStoreCatalog.some(contract=>contract.id===item.id)))throw new Error('Store presentation data does not match the canonical catalog'); +if(STORE_PRESENTATION_CATALOG.size!==CanonicalStoreCatalog.length||STORE_PRESENTATION_ITEMS.some(item=>!CanonicalStoreCatalog.some(contract=>contract.id===item.id)))throw new Error('ショップ表示データがカタログと一致しません'); const STORE_ITEMS=Object.freeze(CanonicalStoreCatalog.map(contract=>{ const presentation=STORE_PRESENTATION_CATALOG.get(contract.id); - if(!presentation)throw new Error(`Missing presentation data for store item ${contract.id}`); + if(!presentation)throw new Error(`ショップアイテム ${contract.id} の表示データがありません`); return Object.freeze({...presentation,...contract}); })); const CURSOR_ITEMS=Object.freeze(STORE_ITEMS.filter(item=>item.cursorStyle)); const STORE_ITEM_CATALOG=new Map(STORE_ITEMS.map(item=>[item.id,item])); const cursorModel=CursorModelApi.createCursorModel(CURSOR_ITEMS); const STORE_ITEM_IDS=new Set(STORE_ITEMS.map(item=>item.id)); -const LINE_COLORS=['#5fd8ff','#ff709f','#ffd45f','#72e38f','#a98cff','#ff915f','#5f8dff','#ff5f62','#42d6c4','#e66cff']; +const LINE_COLOR_ITEMS=Object.freeze(STORE_ITEMS.filter(item=>item.lineColor)),LINE_COLORS=Object.freeze(LINE_COLOR_ITEMS.map(item=>item.lineColor)); +const AURORA_LINE_COLOR_ITEM_ID='line-effect-aurora',STARTER_LINE_COLOR_IDS=Object.freeze(LINE_COLOR_ITEMS.filter(item=>item.effectLabel==='ラインカラー').map(item=>item.id)),LINE_EFFECT_IDS=new Set(['aurora',...STORE_ITEMS.map(item=>item.lineEffect).filter(Boolean)]),REACTION_STYLE_IDS=new Set(['classic',...STORE_ITEMS.map(item=>item.reactionStyle).filter(Boolean)]); +function randomStarterLineColorId(){const value=new Uint32Array(1);try{globalThis.crypto?.getRandomValues?.(value)}catch(_){}if(!value[0])value[0]=(Math.random()*0xffffffff)>>>0;return STARTER_LINE_COLOR_IDS[value[0]%STARTER_LINE_COLOR_IDS.length]} +function validLineColorItemId(value){return typeof value==='string'&&Boolean(STORE_ITEM_CATALOG.get(value)?.lineColor)} +function validStarterLineColorId(value){return typeof value==='string'&&STARTER_LINE_COLOR_IDS.includes(value)} +function migratedLineColorStyle(source){const candidate=source?.lineEffectStyle==='aurora'?AURORA_LINE_COLOR_ITEM_ID:source?.lineColorStyle;return validLineColorItemId(candidate)?candidate:null} +function normalizeEquippedCosmeticsInPlace(target){if(!target)return target;target.lineColorStyle=migratedLineColorStyle(target)||(validStarterLineColorId(target.starterLineColor)?target.starterLineColor:STARTER_LINE_COLOR_IDS[0]);target.lineEffectStyle='none';target.reactionStyle=REACTION_STYLE_IDS.has(target.reactionStyle)?target.reactionStyle:'classic';target.lastReaction=target.lastReaction==='👉🏻'?'🤩':REACTION_EMOJIS.includes(target.lastReaction)?target.lastReaction:'👍';return target} +function starterLineColorForPlayer(playerId){if(typeof playerId!=='string'||!playerId)return randomStarterLineColorId();return STARTER_LINE_COLOR_IDS[hash32(AppLogic.stableHash(['starter-line-color',playerId]))%STARTER_LINE_COLOR_IDS.length]} const DIFF_BACKGROUND_EASY=['#102b32','#17413f'],DIFF_BACKGROUND_HARD=['#43151f','#68202b']; const REGIONS=[{name:'\u68ee',accent:'#72e38f'},{name:'\u6f6e',accent:'#5fd8ff'},{name:'\u5bb5',accent:'#a98cff'},{name:'\u706b',accent:'#ff915f'},{name:'\u865a',accent:'#ff709f'}]; const OPP={N:'S',S:'N',W:'E',E:'W'}; @@ -207,7 +228,6 @@ const storageKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:compact`, recoveryJournalPrefix=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:journal:`, storageRevisionKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:revision`, worldEpochStorageKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:epoch`, - syncStorageKey=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:signal`, worldDbName=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:world`, worldLockName=`bend-field:v${STORAGE_SCHEMA}:${WORLD_GENERATION}:world`, CHAIN_WINDOW=120000,SAVE_DELAY=180; @@ -218,13 +238,25 @@ const RETIRED_WORLD_STORES=Object.freeze([ Object.freeze({schema:30,generation:'v47-field-reset-20260727'}) ]); const world=document.querySelector('#world'),viewport=document.querySelector('#viewport'),boardHudLayer=document.querySelector('#boardHudLayer'),topbar=document.querySelector('#topbar'),overviewCanvas=document.querySelector('#overviewCanvas'),noiseCanvas=document.querySelector('#noiseCanvas'), - solvedCountEl=document.querySelector('#solvedCount'),scoreCountEl=document.querySelector('#scoreCount'),selectedInfo=document.querySelector('#selectedInfo'), + solvedCountEl=document.querySelector('#solvedCount'),scoreCountEl=document.querySelector('#scoreCount'), worldCountEl=document.querySelector('#worldCount'),fpsCounter=document.querySelector('#fpsCounter'),playerNameBtn=document.querySelector('#playerNameBtn'),settingsBtn=document.querySelector('#settingsBtn'), minimapCanvas=document.querySelector('#minimapCanvas'),clearFeed=document.querySelector('#clearFeed'),presenceCanvas=document.querySelector('#presenceCanvas'),reactionCanvas=document.querySelector('#reactionCanvas'),reactionRadial=document.querySelector('#reactionRadial'), saveStatusEl=document.querySelector('#saveStatus'),statusPanel=document.querySelector('#statusPanel'), statusMessage=document.querySelector('#statusMessage'),specialTooltip=document.querySelector('#specialTooltip'); const timeAttackSuggestion=document.querySelector('#timeAttackSuggestion'); -const cloudApiEnabled=globalThis.BendRuntimeConfig?.cloudApi===true; +const runtimeConfig=globalThis.BendRuntimeConfig||Object.freeze({}),cloudApiEnabled=runtimeConfig.cloudApi===true, + cloudAppBaseUrl=(()=>{try{return new URL(runtimeConfig.appBaseUrl||'./',document.baseURI).href}catch(_){return document.baseURI}})(), + cloudApiBridgeUrl=(()=>{try{return runtimeConfig.apiBridgeUrl?new URL(runtimeConfig.apiBridgeUrl,cloudAppBaseUrl).href:''}catch(_){return''}})(), + cloudApiBaseCandidates=(()=>{const values=[];for(const candidate of[cloudApiBridgeUrl,`${cloudAppBaseUrl}api/`,(()=>{try{return new URL('/api/',location.href).href}catch(_){return'/api/'}})()])if(candidate&&!values.includes(candidate))values.push(candidate);return Object.freeze(values)})(); +let cloudApiBaseUrl=cloudApiBaseCandidates[0]||'/api/'; +function cloudEndpointUrl(value,baseUrl=cloudApiBaseUrl){ + if(typeof value!=='string'||!/^\/?api(?:\/|$)/.test(value))return value; + if(typeof cloudApiBridgeUrl!=='undefined'&&cloudApiBridgeUrl&&baseUrl===cloudApiBridgeUrl){ + try{const source=new URL(value,'https://linkfield.invalid'),endpoint=new URL(cloudApiBridgeUrl);endpoint.searchParams.set('path',source.pathname);for(const[key,item]of source.searchParams)endpoint.searchParams.append(key,item);return endpoint.href}catch(_){return value} + } + const relative=value.replace(/^\/?api\/?/,'');try{return new URL(relative,baseUrl).href}catch(_){return`/api/${relative}`} +} +function cloudApiUsesPhpBridge(){return Boolean(cloudApiBridgeUrl&&cloudApiBaseUrl===cloudApiBridgeUrl)} const sessionId=globalThis.crypto?.randomUUID?.()||`session-${Date.now()}-${Math.random().toString(36).slice(2)}`; const sessionRecoveryJournalKey=recoveryJournalPrefix+sessionId; const loadNotices=[]; @@ -300,12 +332,12 @@ function normalizeTimeAttackResult(raw){ }; } function normalizeTimeAttackCooldowns(raw){ - const cooldowns={3:0,5:0,10:0}; + let sharedEndsAt=0; if(isPlainObject(raw))for(const minutes of TIME_ATTACK_MINUTES){ - const value=raw[minutes]; - if(Number.isFinite(value)&&value>0)cooldowns[minutes]=value; + const value=Number(raw[minutes]); + if(Number.isFinite(value)&&value>sharedEndsAt)sharedEndsAt=value; } - return cooldowns; + return Object.fromEntries(TIME_ATTACK_MINUTES.map(minutes=>[minutes,sharedEndsAt])); } function normalizeBonusEvents(raw){ const events={}; @@ -320,7 +352,7 @@ function normalizeCloudPending(raw){ function normalizePlayerPurchases(raw){ return SharedContracts.normalizePlayerPurchases(raw,{resolveItem:storeItem,maxPaidCost:MAX_SCORE,defaultBuyer:DEFAULT_PLAYER_NAME}); } -function defaultData(){return{schema:SAVE_SCHEMA,gameplayVersion:GAMEPLAY_DATA_VERSION,worldGeneration:WORLD_GENERATION,worldEpoch:null,globalRev:0,globalRevAuthor:'',metas:{},states:{},quarantine:{},bonusEvents:{},specialMechanicsSeen:[],clockFloor:0,cloudProfile:null,cloudRevision:0,cloudSyncPaused:false,cloudPending:normalizeCloudPending(null),playerName:null,playerPurchases:[],playerEarnedScore:0,lastReaction:'👍',worldFeedRevision:0,nextId:1,solved:0,score:0,bonusScore:0,bonusScoreVersion:SCORE_VERSION,lastSolveAt:0,timeAttack:null,timeAttackRev:0,timeAttackCooldowns:{3:0,5:0,10:0},lastTimeAttack:null,timeAttackSuggestionsDisabled:false,cursorStyle:'default',scoreLensEnabled:false,debugAllItems:false,cameraAnchor:null,selectedBoardId:null,updatedAt:0}} +function defaultData(){const starterLineColor=randomStarterLineColorId();return{schema:SAVE_SCHEMA,gameplayVersion:GAMEPLAY_DATA_VERSION,worldGeneration:WORLD_GENERATION,worldEpoch:null,globalRev:0,globalRevAuthor:'',metas:{},states:{},quarantine:{},bonusEvents:{},specialMechanicsSeen:[],clockFloor:0,cloudProfile:null,cloudRevision:0,cloudSyncPaused:false,cloudPending:normalizeCloudPending(null),playerName:null,playerPurchases:[],playerEarnedScore:0,starterLineColor,lineColorStyle:starterLineColor,lineEffectStyle:'none',reactionStyle:'classic',lastReaction:'👍',worldFeedRevision:0,nextId:1,solved:0,score:0,bonusScore:0,bonusScoreVersion:SCORE_VERSION,lastSolveAt:0,timeAttack:null,timeAttackRev:0,timeAttackCooldowns:{3:0,5:0,10:0},lastTimeAttack:null,timeAttackSuggestionsDisabled:false,cursorStyle:'default',scoreLensEnabled:false,debugAllItems:false,cameraAnchor:null,selectedBoardId:null,updatedAt:0}} function validChunkShape(chunks){ if(!Array.isArray(chunks)||chunks.length<1||chunks.length>50)return false; const set=new Set(); @@ -344,7 +376,7 @@ function normalizePath(raw){ const color=value=>Number.isInteger(value)&&value>=0&&value0?purchase.boughtAt:0,paidCost:Number.isSafeInteger(purchase?.paidCost)&&purchase.paidCost>0?Math.min(purchase.paidCost,MAX_SCORE):item.cost}); + seen.add(id);purchases.push({id,buyer:typeof purchase?.buyer==='string'&&purchase.buyer.trim()?purchase.buyer.trim().slice(0,32):DEFAULT_PLAYER_NAME,boughtAt:Number.isFinite(purchase?.boughtAt)&&purchase.boughtAt>0?purchase.boughtAt:0,paidCost:Number.isSafeInteger(purchase?.paidCost)&&purchase.paidCost>=0?Math.min(purchase.paidCost,MAX_SCORE):item.cost}); } const rawBonus=Number.isSafeInteger(raw.bonus)&&raw.bonus>0?Math.min(raw.bonus,MAX_SCORE):0,itemIds=normalizeStoreItemIds(raw.itemIds); return{owner:typeof raw.owner==='string'&&raw.owner.trim()?raw.owner.trim().slice(0,32):DEFAULT_PLAYER_NAME,pathIndex:legacyPath?raw.pathIndex:-1,cellIndex:legacyPath?Math.max(0,Math.min(legacyPath.cells.length-1,Number.isInteger(raw.cellIndex)?raw.cellIndex:Math.floor(legacyPath.cells.length/2))):-1,cell:directCell,openedAt:Number.isFinite(raw.openedAt)&&raw.openedAt>0?raw.openedAt:0,priceVersion:Number.isInteger(raw.priceVersion)&&raw.priceVersion>0?raw.priceVersion:STORE_PRICE_VERSION,priceCoefficient:Number.isFinite(raw.priceCoefficient)&&raw.priceCoefficient>=.8&&raw.priceCoefficient<=1.2?raw.priceCoefficient:null,bonus:rawBonus,bonusVersion:SCORE_VERSION,itemIds,purchases}; @@ -523,9 +555,9 @@ function normalizeSnapshot(raw,{quiet=false}={}){ clean.timeAttackRev=Number.isFinite(raw.timeAttackRev)&&raw.timeAttackRev>=0?raw.timeAttackRev:0; clean.timeAttackCooldowns=normalizeTimeAttackCooldowns(raw.timeAttackCooldowns); clean.timeAttackSuggestionsDisabled=raw.timeAttackSuggestionsDisabled===true; - clean.cursorStyle=typeof raw.cursorStyle==='string'?raw.cursorStyle.slice(0,32):'default'; + clean.cursorStyle=typeof raw.cursorStyle==='string'&&cursorModel.item(raw.cursorStyle)?raw.cursorStyle.slice(0,64):'default'; clean.scoreLensEnabled=raw.scoreLensEnabled===true; - clean.debugAllItems=raw.debugAllItems===true; + clean.debugAllItems=false; const seen=new Set(normalizeSpecialMechanics(raw.specialMechanicsSeen)); for(const meta of Object.values(clean.metas))for(const type of mechanicTypesForPuzzle(meta.puzzle))seen.add(type); clean.specialMechanicsSeen=[...seen].sort(); @@ -538,7 +570,11 @@ function normalizeSnapshot(raw,{quiet=false}={}){ clean.cloudSyncPaused=raw.cloudSyncPaused===true; clean.playerName=typeof raw.playerName==='string'&&raw.playerName.trim()?raw.playerName.trim().slice(0,24):null; clean.playerPurchases=normalizePlayerPurchases(raw.playerPurchases);clean.playerEarnedScore=Number.isSafeInteger(raw.playerEarnedScore)&&raw.playerEarnedScore>=0?raw.playerEarnedScore:0; - clean.lastReaction=REACTION_EMOJIS.includes(raw.lastReaction)?raw.lastReaction:'👍'; + clean.starterLineColor=validStarterLineColorId(raw.starterLineColor)?raw.starterLineColor:clean.cloudProfile?starterLineColorForPlayer(clean.cloudProfile.playerId):clean.starterLineColor; + clean.lineColorStyle=migratedLineColorStyle(raw)||clean.starterLineColor; + clean.lineEffectStyle='none'; + clean.reactionStyle=REACTION_STYLE_IDS.has(raw.reactionStyle)?raw.reactionStyle:'classic'; + clean.lastReaction=raw.lastReaction==='👉🏻'?'🤩':REACTION_EMOJIS.includes(raw.lastReaction)?raw.lastReaction:'👍'; clean.worldFeedRevision=Number.isSafeInteger(raw.worldFeedRevision)&&raw.worldFeedRevision>=0?raw.worldFeedRevision:0; clean.cloudPending=normalizeCloudPending(raw.cloudPending); clean.worldEpoch=validWorldEpoch(raw.worldEpoch)?raw.worldEpoch:null; @@ -771,7 +807,7 @@ function mergeV2RecoveryJournals(base,journals){ const merged=base; for(const journal of journals||[]){ if(journal.global&&(journal.updatedAt||0)>=(merged.updatedAt||0)){ - for(const key of['bonusEvents','clockFloor','cloudProfile','cloudRevision','cloudSyncPaused','nextId','lastSolveAt','timeAttack','timeAttackRev','timeAttackCooldowns','lastTimeAttack','specialMechanicsSeen','updatedAt'])if(Object.prototype.hasOwnProperty.call(journal.global,key))merged[key]=deepClone(journal.global[key]); + for(const key of['bonusEvents','clockFloor','cloudProfile','cloudRevision','cloudSyncPaused','starterLineColor','lineColorStyle','lineEffectStyle','reactionStyle','nextId','lastSolveAt','timeAttack','timeAttackRev','timeAttackCooldowns','lastTimeAttack','specialMechanicsSeen','updatedAt'])if(Object.prototype.hasOwnProperty.call(journal.global,key))merged[key]=deepClone(journal.global[key]); } for(const row of journal.metas||[]){if(!row?.id)continue;const normalized=normalizeMeta(row.id,row),current=merged.metas[row.id];if(normalized&&(!current||compareRevisionVersions(normalized,current)>=0)){merged.metas[row.id]=normalized;startupRecoveredMetaIds.add(row.id)}} for(const entry of journal.deleted||[]){const id=entry?.id;if(!id)continue;delete merged.metas[id];delete merged.states[id];startupRecoveredDeletedIds.add(id)} @@ -852,7 +888,7 @@ const dirtyMetaIds=new Set(),dirtyStateIds=new Set(),deletedBoardIds=new Set(),d const deletedBoardAuthors=new Map(); const stateStatSignatures=new Map(),stateEconomySignatures=new Map(); const cloudJournalMetaIds=new Set(),cloudJournalStateIds=new Set(),cloudJournalDeletedIds=new Set(),cloudOutboxDeleteKeys=new Set(); -let cloudJournalGlobalChanged=false,cloudJournalChangeSeq=0,cloudApplyingRemote=false,globalDirty=false,globalChangeSeq=0,worldSignalSeq=0,persistQueue=Promise.resolve(),lifecyclePersistenceSuppressed=false,statsDirty=true,cachedStats={solved:0,score:0,earned:0},inventoryCache=null,spentScoreCache=null; +let cloudJournalGlobalChanged=false,cloudJournalChangeSeq=0,cloudApplyingRemote=false,globalDirty=false,globalChangeSeq=0,persistQueue=Promise.resolve(),lifecyclePersistenceSuppressed=false,statsDirty=true,cachedStats={solved:0,score:0,earned:0},inventoryCache=null,spentScoreCache=null; let minimapFrame=0,minimapDirty=true,minimapLongSegments=0,minimapLastDraw=0,minimapDelayTimer=0,minimapRectCache=null; let overviewFrame=0,overviewDirty=true,overviewLastDraw=0,overviewDelayTimer=0,overviewAllowInteractionBuild=false,overviewInteractionLastBuild=0; const minimapBase=document.createElement('canvas'),overviewBase=document.createElement('canvas'); @@ -876,12 +912,12 @@ function perfCount(name,amount=1){perfCounters[name]=(perfCounters[name]||0)+amo function perfGauge(name,value){perfGauges[name]=value} function perfSnapshot(){ const timings={}; - for(const[name,samples]of perfSamples){const sorted=[...samples].sort((a,b)=>a-b),pick=q=>sorted.length?sorted[Math.min(sorted.length-1,Math.floor((sorted.length-1)*q))]:0;timings[name]={count:sorted.length,p50:pick(.5),p95:pick(.95),max:sorted[sorted.length-1]||0}} + for(const[name,samples]of perfSamples){const sorted=[...samples].sort((a,b)=>a-b),pick=q=>sorted.length?sorted[Math.min(sorted.length-1,Math.floor((sorted.length-1)*q))]:0;timings[name]={count:sorted.length,p50:pick(.5),p95:pick(.95),p99:pick(.99),max:sorted[sorted.length-1]||0}} const elapsedSeconds=Math.max(.001,(perfNow()-perfResetAt)/1000),rates={};for(const[name,value]of Object.entries(perfCounters))rates[`${name}PerSecond`]=value/elapsedSeconds; return{timings,counters:{...perfCounters},rates,gauges:{...perfGauges,interactionSchedulerVariant:INTERACTION_SCHEDULER_VARIANT,renderedBoards:rendered.size,staticBoards:0,domNodes:document.getElementsByTagName('*').length},elapsedSeconds,capturedAt:new Date().toISOString()}; } -function resetPerf(){perfSamples.clear();for(const key of Object.keys(perfCounters))delete perfCounters[key];for(const key of Object.keys(perfGauges))delete perfGauges[key];interactionIntervals.splice(0);interactionActiveStartedAt=interactionActive()?perfNow():0;interactionLastFrame=0;interactionBestFrameGap=Infinity;interactionFrameOpportunities=0;interactionDroppedFrames=0;perfResetAt=perfNow()} -globalThis.BEND_PERF=Object.freeze({snapshot:perfSnapshot,reset:resetPerf}); +function resetPerf(){perfSamples.clear();for(const key of Object.keys(perfCounters))delete perfCounters[key];for(const key of Object.keys(perfGauges))delete perfGauges[key];interactionIntervals.splice(0);interactionActiveStartedAt=interactionActive()?perfNow():0;interactionLastFrame=0;interactionBestFrameGap=Infinity;interactionFrameOpportunities=0;interactionDroppedFrames=0;reactionLastMetricFrame=0;perfResetAt=perfNow()} +globalThis.BEND_PERF=Object.freeze({snapshot:perfSnapshot,reset:resetPerf,renderReactionSample:options=>renderReactionSample(options),warmEffectCache:(emoji,style)=>warmReactionGlyphCache(emoji,style),clearEffectCaches:()=>clearReactionEffectCaches()}); function observeSchedulerBattery(){ if(typeof navigator.getBattery!=='function')return; void navigator.getBattery().then(battery=>{ @@ -900,12 +936,12 @@ if(typeof PerformanceObserver!=='undefined'&&PerformanceObserver.supportedEntryT observer.observe({type:'long-animation-frame',buffered:true}); }catch(_){} let fpsWindowStarted=perfNow(),fpsFrameCount=0,fpsLastBucket=-1,fpsLastValue=0,fpsLastFrameAt=0; -function markVisualFrame(timestamp=perfNow()){const bucket=Math.round(timestamp*10);if(bucket===fpsLastBucket)return;fpsLastBucket=bucket;fpsFrameCount++;fpsLastFrameAt=timestamp} +function markVisualFrame(timestamp=perfNow()){if(fpsLastFrameAt&×tamp-fpsLastFrameAt180; if(idle){fpsCounter.textContent='FPS 待機';fpsCounter.dataset.fps='idle'} - else{fpsLastValue=Math.max(0,Math.round(fpsFrameCount*1000/elapsed));fpsCounter.textContent=`FPS ${fpsLastValue}`;fpsCounter.dataset.fps=fpsLastValue>=55?'good':fpsLastValue>=40?'ok':'low'} + else{fpsLastValue=Math.max(0,Math.round(fpsFrameCount*1000/elapsed));fpsCounter.textContent=`FPS ${fpsLastValue}`;fpsCounter.dataset.fps=fpsLastValue>=28?'good':fpsLastValue>=20?'ok':'low'} fpsFrameCount=0;fpsWindowStarted=now; } setInterval(refreshFpsCounter,500); @@ -1062,12 +1098,19 @@ function seedRevisionClock(snapshot=data){ for(const tombstone of recoveredDeletionTombstones.values())maximum=Math.max(maximum,tombstone?.rev||0); lastRevision=maximum;return maximum; } -function showStatus(message,{retry=true,fresh=false}={}){ - statusMessage.textContent=message;statusPanel.hidden=false; +let statusRetryAction=null; +function showStatus(message,{retry=true,fresh=false,onRetry=null}={}){ + statusMessage.textContent=message;statusPanel.hidden=false;statusRetryAction=retry?(typeof onRetry==='function'?onRetry:()=>location.reload()):null; document.querySelector('#retryBtn').hidden=!retry; - document.querySelector('#freshBtn').hidden=!fresh; + const freshButton=document.querySelector('#freshBtn');if(freshButton)freshButton.hidden=!fresh; +} +function hideStatus(){statusPanel.hidden=true;statusRetryAction=null} +async function runStatusRetry(){ + const action=statusRetryAction;if(typeof action!=='function')return; + const button=document.querySelector('#retryBtn');if(button)button.disabled=true; + try{await action()}catch(error){console.warn('LinkField: retry failed',error);showStatus(`再接続できませんでした。 ${error?.message||error}`,{retry:true,fresh:false,onRetry:()=>location.reload()})} + finally{if(button)button.disabled=false} } -function hideStatus(){statusPanel.hidden=true} const {hash32,rngFrom,shuffle,macroDifficulty,difficultyFitsRegion,SHAPES,solverDifficulty,H_PORT_PROFILES,V_PORT_PROFILES,horizontalBoundaryKey,verticalBoundaryKey}=BendPuzzle; let workerSeq=0; const workerJobs=new Map(); @@ -1158,7 +1201,7 @@ function stateStatSignature(state){return`${state?.solved===true?1:0}:${Math.max function stateEconomySignature(state){ const purchases=state?.store?.purchases||[];let spent=0,hash=2166136261; for(const purchase of purchases){ - const item=storeItem(purchase?.id),paid=item?(purchase.paidCost||item.cost):0,boughtAt=purchase?.boughtAt||0,text=`${purchase?.id||''}\0${paid}\0${boughtAt}\0`; + const item=storeItem(purchase?.id),paid=item?(purchase.paidCost??item.cost):0,boughtAt=purchase?.boughtAt||0,text=`${purchase?.id||''}\0${paid}\0${boughtAt}\0`; spent+=paid;for(let index=0;index>>0; } return{count:purchases.length,spent,hash}; @@ -1187,16 +1230,15 @@ function rememberStateSignatures(id,state){ } } function currentCloudPending(){ - return{metaIds:[...cloudJournalMetaIds],stateIds:[...cloudJournalStateIds].filter(id=>data?.states?.[id]?.solved===true),deleted:[...cloudJournalDeletedIds],globalChanged:cloudJournalGlobalChanged}; + return{metaIds:[...cloudJournalMetaIds],stateIds:[...cloudJournalStateIds],deleted:[...cloudJournalDeletedIds],globalChanged:cloudJournalGlobalChanged}; } function restoreCloudPending(pending=data.cloudPending){ const clean=normalizeCloudPending(pending);cloudJournalMetaIds.clear();cloudJournalStateIds.clear();cloudJournalDeletedIds.clear(); - for(const id of clean.metaIds)cloudJournalMetaIds.add(id);for(const id of clean.stateIds){if(data?.states?.[id]?.solved===true)cloudJournalStateIds.add(id);else cloudOutboxDeleteKeys.add(`state:${id}`)}for(const id of clean.deleted)cloudJournalDeletedIds.add(id); + for(const id of clean.metaIds)cloudJournalMetaIds.add(id);for(const id of clean.stateIds)if(data?.states?.[id])cloudJournalStateIds.add(id);for(const id of clean.deleted)cloudJournalDeletedIds.add(id); cloudJournalGlobalChanged=clean.globalChanged;cloudJournalChangeSeq=0;data.cloudPending=currentCloudPending(); } function noteCloudRow(kind,id){ if(!id||!cloudApiEnabled)return; - if(kind==='state'&&data?.states?.[id]?.solved!==true){cloudJournalStateIds.delete(id);cloudOutboxDeleteKeys.add(`state:${id}`);return} if(kind==='deleted'){cloudJournalMetaIds.delete(id);cloudJournalStateIds.delete(id);cloudJournalDeletedIds.add(id);cloudOutboxDeleteKeys.add(`meta:${id}`);cloudOutboxDeleteKeys.add(`state:${id}`)} else{cloudJournalDeletedIds.delete(id);cloudOutboxDeleteKeys.add(`deleted:${id}`);(kind==='meta'?cloudJournalMetaIds:cloudJournalStateIds).add(id)} cloudJournalChangeSeq++; @@ -1221,14 +1263,18 @@ function ensureMetaState(id,{dirty=true}={}){ function metaState(id){return data.states[id]&&typeof data.states[id]==='object'?data.states[id]:null} function storeItem(id){return STORE_ITEM_CATALOG.get(id)||null} function normalizeStoreItemIds(raw){ - if(!Array.isArray(raw)||raw.length!==13||new Set(raw).size!==13)return null; - const items=raw.map(storeItem);return items.every(Boolean)&&items.filter(item=>item.cursorStyle).length===12&&items.filter(item=>!item.cursorStyle).length===1?[...raw]:null; + if(!Array.isArray(raw)||new Set(raw).size!==raw.length)return null; + const items=raw.map(storeItem);if(!items.every(Boolean))return null; + const cursors=items.filter(item=>item.cursorStyle).slice(0,6),others=items.filter(item=>!item.cursorStyle).slice(0,6); + return cursors.length===6&&others.length===6?[...cursors,...others].map(item=>item.id):null; } function seededStoreItemIds(seed){ const cursorPool=shuffle([...CURSOR_ITEMS],rngFrom(hash32((seed>>>0)^0x5f356495))), otherPool=shuffle(STORE_ITEMS.filter(item=>!item.cursorStyle),rngFrom(hash32((seed>>>0)^0x2c9277b5))), - selected=[...cursorPool.slice(0,12),...otherPool.slice(0,1)]; - return shuffle(selected,rngFrom(hash32((seed>>>0)^0x6d2b79f5))).map(item=>item.id); + fixedTools=otherPool.filter(item=>item.scoreLens).slice(0,1), + cosmeticPool=otherPool.filter(item=>!item.scoreLens), + selectedOthers=shuffle([...fixedTools,...cosmeticPool.slice(0,6-fixedTools.length)],rngFrom(hash32((seed>>>0)^0x6d2b79f5))); + return[...cursorPool.slice(0,6),...selectedOthers].map(item=>item.id); } function storeInventoryItems(meta,store=null){ if(!meta)return[];const ids=normalizeStoreItemIds(store?.itemIds)||seededStoreItemIds(meta.seed); @@ -1247,12 +1293,18 @@ function inventoryEntries(itemId=null){ } return itemId?inventoryCache.filter(entry=>entry.purchase.id===itemId):[...inventoryCache]; } -function inventoryCount(itemId=null){return inventoryEntries(itemId).length} -function activeScoreLensCount(){return data.scoreLensEnabled===true?Math.max(debugAllItemsEnabled()?1:0,inventoryCount('score-lens')):0} +function starterColorGrantCount(itemId=null){ + const starter=validStarterLineColorId(data.starterLineColor)?data.starterLineColor:null;if(!starter)return 0; + if(itemId&&itemId!==starter)return 0; + return inventoryEntries(starter).length?0:1; +} +function inventoryCount(itemId=null){return inventoryEntries(itemId).length+starterColorGrantCount(itemId)} +function ownsStoreItem(itemId){return inventoryCount(itemId)>0} +function activeScoreLensCount(){return data.scoreLensEnabled===true?inventoryCount('score-lens'):0} function spentScoreTotal(){ if(spentScoreCache!=null)return spentScoreCache; - let spent=0;if(personalEconomyMode())for(const purchase of normalizePlayerPurchases(data.playerPurchases)){const item=storeItem(purchase.itemId);if(item)spent+=purchase.paidCost||item.cost} - else for(const id of Object.keys(data.metas)){const store=metaState(id).store;if(!store)continue;for(const purchase of store.purchases){const item=storeItem(purchase.id);if(item)spent+=purchase.paidCost||item.cost}} + let spent=0;if(personalEconomyMode())for(const purchase of normalizePlayerPurchases(data.playerPurchases)){const item=storeItem(purchase.itemId);if(item)spent+=purchase.paidCost??item.cost} + else for(const id of Object.keys(data.metas)){const store=metaState(id).store;if(!store)continue;for(const purchase of store.purchases){const item=storeItem(purchase.id);if(item)spent+=purchase.paidCost??item.cost}} spentScoreCache=spent;return spent; } function pruneAndCount(){ @@ -1262,7 +1314,7 @@ function pruneAndCount(){ for(const id of Object.keys(data.metas)){const st=metaState(id);if(st.solved)solved++;if(!personalEconomyMode())earned+=st.scoreAwarded} cachedStats={solved,earned,score:Math.max(0,earned-spentScoreTotal())};statsDirty=false; } - data.solved=cachedStats.solved;data.score=cachedStats.score;solvedCountEl.textContent=String(data.solved);scoreCountEl.textContent=formatScore(data.score); + data.solved=cachedStats.solved;data.score=cachedStats.score;solvedCountEl.textContent=String(data.solved);scoreCountEl.textContent=debugAllItemsEnabled()?'∞':formatScore(data.score); } function snapshotForStorage(updatedAt=trustedNow()){ const started=perfStart(); @@ -1328,6 +1380,17 @@ function applyAuthoritativeSharedGlobal(external){ if(isPlainObject(external.quarantine))data.quarantine=deepClone(external.quarantine); statsDirty=true; } +function mergeGlobalFields(external){ + if(!external)return; + if(Number.isSafeInteger(external.nextId)&&external.nextId>0)data.nextId=Math.max(data.nextId||1,external.nextId); + if(Number.isSafeInteger(external.solved)&&external.solved>=0)data.solved=Math.max(data.solved||0,external.solved); + if(Number.isFinite(external.lastSolveAt)&&external.lastSolveAt>=0)data.lastSolveAt=Math.max(data.lastSolveAt||0,external.lastSolveAt); + if(Array.isArray(external.specialMechanicsSeen))data.specialMechanicsSeen=normalizeSpecialMechanics([...(data.specialMechanicsSeen||[]),...external.specialMechanicsSeen]); + if(isPlainObject(external.quarantine)){ + const merged=deepClone(data.quarantine||{});for(const[id,value]of Object.entries(external.quarantine)){const current=merged[id];if(!current||(value?.failedAt||0)>=(current?.failedAt||0))merged[id]=deepClone(value)}data.quarantine=merged; + } + statsDirty=true; +} function mergeSnapshotIntoData(external,{finalize=true,authoritativeWorld=false}={}){ if(!external||!isPlainObject(external.metas))return[]; const added=[],authoritativeCompatibleStateIds=authoritativeWorld?new Set():null; @@ -1351,13 +1414,15 @@ function mergeSnapshotIntoData(external,{finalize=true,authoritativeWorld=false} lastRevision=Math.max(lastRevision,incoming.rev||0);if(!data.metas[id])continue; const current=data.states[id];let merged; if(authoritativeWorld){ - const compatible=authoritativeCompatibleStateIds.has(id),retainLocalSolve=compatible&¤t?.solved===true&&incoming?.solved!==true; - if(retainLocalSolve)noteCloudRow('state',id);else clearSharedWorldJournalRow('state',id); - merged=compatible?mergeBoardStates(current,incoming):deepClone(incoming); + const compatible=authoritativeCompatibleStateIds.has(id),retainLocalSolve=compatible&¤t?.solved===true&&incoming?.solved!==true, + retainClaimedPending=compatible&¤t?.solved!==true&&incoming?.solved!==true&&cloudJournalStateIds.has(id)&&typeof boardClaimOwnedByMe==='function'&&boardClaimOwnedByMe(id); + if(retainLocalSolve){noteCloudRow('state',id);merged=deepClone(current)} + else if(retainClaimedPending)merged=deepClone(current); + else{clearSharedWorldJournalRow('state',id);merged=deepClone(incoming)} }else merged=mergeBoardStates(current,incoming); if(!current||!sameDataValue(merged,current)){ if(current&&!authoritativeWorld){merged.rev=nextRevision();merged.revAuthor=sessionId} - data.states[id]=merged;normalizedStateObjects.add(merged);if(current)markStateDirty(id); + data.states[id]=merged;normalizedStateObjects.add(merged);if(current)markStateDirty(id);if(merged.solved===true)removeClaim(id,'cleared'); const board=rendered.get(id);if(board){board.drawing=null;board.solvedPathsRendered=false} if(data.metas[id].puzzle)sanitizeStateForPuzzle(data.metas[id],{quiet:true}); } @@ -1374,12 +1439,14 @@ function globalForStorage(source=data,updatedAt=trustedNow()){ schema:SAVE_SCHEMA,gameplayVersion:GAMEPLAY_DATA_VERSION,worldGeneration:WORLD_GENERATION,worldEpoch:source.worldEpoch, globalRev:source.globalRev||0,globalRevAuthor:source.globalRevAuthor||'',appVersion:APP_VERSION,generatorVersion:GENERATOR_VERSION, quarantine:source.quarantine||{},bonusEvents:source.bonusEvents||{},clockFloor:Math.max(source.clockFloor||0,updatedAt), - cloudProfile:source.cloudProfile,cloudRevision:source.cloudRevision||0,cloudSyncPaused:source.cloudSyncPaused===true,playerName:source.playerName||null,playerPurchases:normalizePlayerPurchases(source.playerPurchases),playerEarnedScore:Number.isSafeInteger(source.playerEarnedScore)&&source.playerEarnedScore>=0?source.playerEarnedScore:0,lastReaction:REACTION_EMOJIS.includes(source.lastReaction)?source.lastReaction:'👍',worldFeedRevision:source.worldFeedRevision||0, + cloudProfile:source.cloudProfile,cloudRevision:source.cloudRevision||0,cloudSyncPaused:source.cloudSyncPaused===true,playerName:source.playerName||null,playerPurchases:normalizePlayerPurchases(source.playerPurchases),playerEarnedScore:Number.isSafeInteger(source.playerEarnedScore)&&source.playerEarnedScore>=0?source.playerEarnedScore:0, + starterLineColor:validStarterLineColorId(source.starterLineColor)?source.starterLineColor:STARTER_LINE_COLOR_IDS[0],lineColorStyle:migratedLineColorStyle(source)||(validStarterLineColorId(source.starterLineColor)?source.starterLineColor:STARTER_LINE_COLOR_IDS[0]),lineEffectStyle:'none',reactionStyle:REACTION_STYLE_IDS.has(source.reactionStyle)?source.reactionStyle:'classic', + lastReaction:source.lastReaction==='👉🏻'?'🤩':REACTION_EMOJIS.includes(source.lastReaction)?source.lastReaction:'👍',worldFeedRevision:source.worldFeedRevision||0, nextId:source.nextId,solved:source.solved||0,score:source.score||0,bonusScore:source.bonusScore||0,bonusScoreVersion:SCORE_VERSION, lastSolveAt:source.lastSolveAt||0,timeAttack:source.timeAttack,timeAttackRev:source.timeAttackRev||0,timeAttackCooldowns:source.timeAttackCooldowns, lastTimeAttack:source.lastTimeAttack,timeAttackSuggestionsDisabled:source.timeAttackSuggestionsDisabled===true,cursorStyle:source.cursorStyle||'default', scoreLensEnabled:source.scoreLensEnabled===true, - debugAllItems:source.debugAllItems===true, + debugAllItems:false, specialMechanicsSeen:normalizeSpecialMechanics(source.specialMechanicsSeen), cameraAnchor:anchor,selectedBoardId:source===data?(activeBoard||source.selectedBoardId||null):source.selectedBoardId||null,updatedAt,sessionId }; @@ -1405,8 +1472,8 @@ function mergeGlobalRecords(current,incoming){ function applyGlobalRecordToData(record){ if(!record)return; const priorBonuses=deepClone(data.bonusEvents||{}); - for(const key of['worldEpoch','globalRev','globalRevAuthor','gameplayVersion','quarantine','bonusEvents','clockFloor','cloudProfile','cloudRevision','cloudSyncPaused','playerName','playerPurchases','playerEarnedScore','lastReaction','worldFeedRevision','nextId','solved','score','bonusScore','lastSolveAt','timeAttack','timeAttackRev','timeAttackCooldowns','lastTimeAttack','timeAttackSuggestionsDisabled','cursorStyle','scoreLensEnabled','debugAllItems','specialMechanicsSeen','cameraAnchor','selectedBoardId','updatedAt'])if(Object.prototype.hasOwnProperty.call(record,key))data[key]=deepClone(record[key]); - data.bonusScore=bonusEventTotal(data.bonusEvents);lastRevision=Math.max(lastRevision,data.globalRev||0,data.clockFloor?data.clockFloor*1000:0); + for(const key of['worldEpoch','globalRev','globalRevAuthor','gameplayVersion','quarantine','bonusEvents','clockFloor','cloudProfile','cloudRevision','cloudSyncPaused','playerName','playerPurchases','playerEarnedScore','starterLineColor','lineColorStyle','lineEffectStyle','reactionStyle','lastReaction','worldFeedRevision','nextId','solved','score','bonusScore','lastSolveAt','timeAttack','timeAttackRev','timeAttackCooldowns','lastTimeAttack','timeAttackSuggestionsDisabled','cursorStyle','scoreLensEnabled','debugAllItems','specialMechanicsSeen','cameraAnchor','selectedBoardId','updatedAt'])if(Object.prototype.hasOwnProperty.call(record,key))data[key]=deepClone(record[key]); + normalizeEquippedCosmeticsInPlace(data);data.debugAllItems=false;data.bonusScore=bonusEventTotal(data.bonusEvents);lastRevision=Math.max(lastRevision,data.globalRev||0,data.clockFloor?data.clockFloor*1000:0); if(!sameDataValue(priorBonuses,data.bonusEvents||{}))statsDirty=true; } function staleWorldEpochError(){const error=new Error('\u5225\u306e\u30bf\u30d6\u3067\u30d5\u30a3\u30fc\u30eb\u30c9\u304c\u7f6e\u304d\u63db\u3048\u3089\u308c\u307e\u3057\u305f\u3002');error.code='STALE_WORLD_EPOCH';return error} @@ -1745,7 +1812,7 @@ async function persistDirtyToDb(options={}){ for(const key of outboxDeletes)cloudOutboxDeleteKeys.delete(key); if(globalChangeSeq===globalSeq)globalDirty=false; clearRecoveryJournalIfCovered(globalSeq,coveredJournals); - const signal={commitId:`${sessionId}:${++worldSignalSeq}`,updatedAt,sessionId,worldEpoch:data.worldEpoch,metaIds,stateIds,deleted:[...committedDeleted],globalChanged};broadcastWorldSignal(signal);if(!skipCloud)scheduleCloudPush(signal); + if(!skipCloud)scheduleCloudPush({metaIds,stateIds,deleted:[...committedDeleted],globalChanged}); perfGauge('lastPersistRows',metaRows.length+stateRows.length+deleted.length);perfEnd('persistDirtyToDb',started); return{updatedAt,count:metaRows.length+stateRows.length+deleted.length,skipped:false}; } @@ -1782,7 +1849,7 @@ async function commitPlayerProfileName(value){ let committed=requested; if(cloudAvailable&&data.cloudProfile){ const result=await fetchJson('/api/cloud/profile',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({name:requested})}); - committed=normalizePlayerNameInput(result.name)||requested;setCloudStatus('共有中','saved'); + committed=normalizePlayerNameInput(result.name)||requested;setCloudStatus(); } data.playerName=committed;markGlobalDirty(false); if(!await persistNow({skipCloud:true}))throw new Error('プレイヤー名を端末へ保存できませんでした。'); @@ -2615,8 +2682,9 @@ async function ensureUnsolvedFrontier(preferredMeta=null){ } return 0; } +function canExpandSharedBoard(meta,state=meta?.id?metaState(meta.id):null){return Boolean(meta&&state?.solved&&(!cloudAvailable||!data.cloudProfile||state.solvedById===currentPlayerId()))} async function prepareExpansionCandidate(meta){ - if(!meta||data.metas[meta.id]!==meta||!metaState(meta.id).solved)return null; + if(!meta||data.metas[meta.id]!==meta||!canExpandSharedBoard(meta))return null; const boardNumber=data.nextId,frontiers=unresolvedExpansionCandidates(meta); for(let index=0;index{const current=data.metas[id];return current?repairMetaFrontierNow(current):0}); } -function sharedExpansionRepairDelay(state,now=trustedNow()){ - if(!cloudAvailable||!data.cloudProfile||!state?.solved)return 0;const owner=state.solvedById,playerId=currentPlayerId();if(!owner||owner===playerId)return 0; - const solvedAt=Number(state.solvedAt)||0;if(!solvedAt)return 0;const jitter=playerId?AppLogic.stableHash(['shared-expansion-takeover',playerId,owner,solvedAt])%SHARED_EXPANSION_JITTER_MS:0;return Math.max(0,solvedAt+SHARED_EXPANSION_GRACE_MS+jitter-now); -} -function nextSharedExpansionRepairDelay(){ - let wait=Infinity,found=false;for(const meta of Object.values(data.metas)){const st=metaState(meta.id);if(!st.solved||st.expanded)continue;found=true;wait=Math.min(wait,sharedExpansionRepairDelay(st))}return found?Math.max(0,wait):null; -} -function hasSharedExpansionRepairAuthority(){return Object.values(data.metas).some(meta=>{const st=metaState(meta.id);return st.solved&&!st.expanded&&sharedExpansionRepairDelay(st)<=0})} +function sharedExpansionRepairDelay(state){return state?.solved&&state.solvedById===currentPlayerId()?0:Infinity} +function nextSharedExpansionRepairDelay(){return Object.values(data.metas).some(meta=>canExpandSharedBoard(meta)&&!metaState(meta.id).expanded)?0:null} +function hasSharedExpansionRepairAuthority(){return Object.values(data.metas).some(meta=>canExpandSharedBoard(meta)&&!metaState(meta.id).expanded)} async function repairExpansions(){ if(typeof fieldArchiveBusy!=='undefined'&&fieldArchiveBusy){scheduleExpansionRepair(1000);return 0} let made=0; for(let pass=0;pass<3;pass++){ - let passMade=0;const active=data.metas[activeBoard],pending=Object.values(data.metas).filter(meta=>{const st=metaState(meta.id);return st.solved&&!st.expanded&&sharedExpansionRepairDelay(st)<=0}); + let passMade=0;const active=data.metas[activeBoard],pending=Object.values(data.metas).filter(meta=>{const st=metaState(meta.id);return canExpandSharedBoard(meta,st)&&!st.expanded}); pending.sort((left,right)=>{ const leftDistance=active?Math.hypot(left.x-active.x,left.y-active.y):0,rightDistance=active?Math.hypot(right.x-active.x,right.y-active.y):0; return leftDistance-rightDistance||(metaState(left.id).expansionRetryRound||0)-(metaState(right.id).expansionRetryRound||0)||left.id.localeCompare(right.id,undefined,{numeric:true}); @@ -2686,7 +2749,7 @@ async function repairExpansions(){ made+=passMade; } let closedOffset=0; - for(const{source,frontier}of closedVoidRepairCandidates().filter(({source})=>sharedExpansionRepairDelay(metaState(source.id))<=0).slice(0,2)){ + for(const{source,frontier}of closedVoidRepairCandidates().filter(({source})=>canExpandSharedBoard(source)).slice(0,2)){ if(await placeChildAtFrontierAttempt(source,frontier,470000003+closedOffset*1000003))made++; closedOffset++; } @@ -2698,7 +2761,7 @@ function scheduleExpansionRepair(delay=700){if(expansionRepairTimer)return;const function reopenMissingGateExpansions(){ rebuildOccupancy();let reopened=0; for(const meta of Object.values(data.metas)){ - const st=metaState(meta.id);if(!st.solved||!meta.puzzle)continue; + const st=metaState(meta.id);if(!canExpandSharedBoard(meta,st)||!meta.puzzle)continue; repairFacingGateConnections(meta); if(st.expanded&&missingGateConnections(meta).length){st.expanded=false;st.rev=nextRevision();markStateDirty(meta.id);reopened++} } @@ -2706,8 +2769,8 @@ function reopenMissingGateExpansions(){ } function pendingExpansionCount(){ let count=0; - for(const meta of Object.values(data.metas)){const st=metaState(meta.id);if(st.solved&&!st.expanded)count++} - return count+closedVoidRepairCandidates().length; + for(const meta of Object.values(data.metas)){const st=metaState(meta.id);if(canExpandSharedBoard(meta,st)&&!st.expanded)count++} + return count+closedVoidRepairCandidates().filter(({source})=>canExpandSharedBoard(source)).length; } function cellSet(p){return p._validSet||(p._validSet=new Set(p.valid.map(c=>ckey(...c))))} function warpMap(p){ @@ -2799,8 +2862,9 @@ function sanitizeStateForPuzzle(meta,{quiet=false}={}){ if(!solved&&(st.solvedBy||st.scoreAwarded||st.store)){st.solvedBy=null;st.scoreAwarded=0;st.store=null;st.rev=nextRevision();markStateDirty(meta.id)} return removed; } -function chooseColor(meta,gi){const inherited=neighborColor(meta,gi);if(inherited!=null)return inherited;const used=new Set(metaState(meta.id).paths.map(p=>p.colorIndex).filter(v=>v!=null));for(let i=0;i>16)&255,(value>>8)&255,value&255]} function mixHex(a,b,t){const aa=hexRgb(a),bb=hexRgb(b),q=Math.max(0,Math.min(1,t)),value=aa.map((v,i)=>Math.round(v+(bb[i]-v)*q));return`#${value.map(v=>v.toString(16).padStart(2,'0')).join('')}`} function levelBackground(level){const t=(Math.max(1,Math.min(10,level||1))-1)/9;return[ mixHex(DIFF_BACKGROUND_EASY[0],DIFF_BACKGROUND_HARD[0],t),mixHex(DIFF_BACKGROUND_EASY[1],DIFF_BACKGROUND_HARD[1],t) ]} @@ -3258,8 +3322,8 @@ function makeBoard(meta){ const storeButton=document.createElement('button');storeButton.type='button';storeButton.className='line-store';storeButton.hidden=true;storeButton.innerHTML='ショップ'; const scoreLensBadge=document.createElement('div');scoreLensBadge.className='score-lens-badge';scoreLensBadge.hidden=true;scoreLensBadge.setAttribute('aria-hidden','true'); const svg=svgEl('svg',{viewBox:`0 0 ${w} ${h}`,width:w,height:h,class:'board-svg',tabindex:0,role:'group','aria-label':`\u30ec\u30d9\u30eb${meta.level}`}); - const overviewLayer=svgEl('g',{class:'overview-layer'}),focusLayer=svgEl('g',{class:'active-board-boundary-layer','aria-hidden':'true'}),staticLayer=svgEl('g',{class:'static-layer'}),specialLayer=svgEl('g',{class:'special-cell-layer'}),connectorLayer=svgEl('g',{class:'connector-layer'}),pathLayer=svgEl('g',{class:'path-layer'}),dragLayer=svgEl('g',{class:'drag-layer'}),claimPreviewLayer=svgEl('g',{class:'claim-preview-layer'}),numberLayer=svgEl('g',{class:'number-layer'}),gateLayer=svgEl('g',{class:'gate-layer'}); - overviewLayer.append(svgEl('path',{d:overviewChunkPath(meta),class:'overview-fill'}));const boardBoundaryPath=outerEdgesPath(p);focusLayer.append(svgEl('path',{d:boardBoundaryPath,class:'active-board-boundary-shadow'}),svgEl('path',{d:boardBoundaryPath,class:'active-board-boundary-dash'})); + const overviewLayer=svgEl('g',{class:'overview-layer'}),staticLayer=svgEl('g',{class:'static-layer'}),specialLayer=svgEl('g',{class:'special-cell-layer'}),connectorLayer=svgEl('g',{class:'connector-layer'}),pathLayer=svgEl('g',{class:'path-layer'}),dragLayer=svgEl('g',{class:'drag-layer'}),claimPreviewLayer=svgEl('g',{class:'claim-preview-layer'}),numberLayer=svgEl('g',{class:'number-layer'}),gateLayer=svgEl('g',{class:'gate-layer'}); + overviewLayer.append(svgEl('path',{d:overviewChunkPath(meta),class:'overview-fill'})); const[bgStart,bgEnd]=levelBackground(meta.level),bgId=`board-level-bg-${meta.id}`,bgDefs=svgEl('defs'),bgGradient=svgEl('linearGradient',{id:bgId,x1:'0%',y1:'0%',x2:'100%',y2:'100%'}); bgGradient.append(svgEl('stop',{offset:'0%','stop-color':bgStart}),svgEl('stop',{offset:'100%','stop-color':bgEnd}));bgDefs.append(bgGradient);staticLayer.append(bgDefs); const validCellPath=boardCellsPath(p.valid),inputClipId=`board-input-clip-${meta.id}`,inputClip=svgEl('clipPath',{id:inputClipId,clipPathUnits:'userSpaceOnUse'}),inputClipShape=svgEl('path',{d:validCellPath});inputClip.append(inputClipShape);bgDefs.append(inputClip); @@ -3285,7 +3349,7 @@ function makeBoard(meta){ gateDots.push(dot);gateKnobs.push(knob);gateMarkers.push(marker);gateHits.push(hit);const gateCellKey=ckey(...g.cell),cellGates=gateIndexesByCell.get(gateCellKey)||[];cellGates.push(i);gateIndexesByCell.set(gateCellKey,cellGates); gateLayer.append(marker,dot,knob,hit); }); - svg.append(overviewLayer,focusLayer,staticLayer,specialLayer,connectorLayer,gateLayer,pathLayer,dragLayer,claimPreviewLayer,numberLayer);card.append(svg,solverBadge,claimBadge,storeButton,scoreLensBadge);world.append(card);boardHudLayer?.append(label); + svg.append(overviewLayer,staticLayer,specialLayer,connectorLayer,gateLayer,pathLayer,dragLayer,claimPreviewLayer,numberLayer);card.append(svg,solverBadge,claimBadge,storeButton,scoreLensBadge);world.append(card);boardHudLayer?.append(label); const b={id:meta.id,meta,p,card,svg,label,solverBadge,solverName,claimBadge,storeButton,drawing:null,pendingClaimPointer:null,pendingClaimFrame:0,armedGate:null,w,h,connectorLayer,pathLayer,dragLayer,claimPreviewLayer,numberLayer,specialLayer,dragPathIndex:null,pathStrokeNodes:[],pathVisualNodes:new Map(),connectorNodes:[],numberNodes,numberWarningNodes,numberWarningKeys:new Set(),multipleWarningKeys:new Set(),gateDots,gateKnobs,gateMarkers,gateHits,gateIndexesByCell,endpointIndexesByCell:new Map(),gateLayer,boardActions,boardReset,scoreLensBadge,cellShape,boardInputSurface,inputClipId,unfilledWarningShape,obstacleNodes,cellFlashAnimations:new Map(),specialNodes,specialInfo,specialSets,crossingKeySet:new Set(specialSets.crossings.map(cell=>ckey(...cell))),dragSpecialRevision:0,dragSpecialAppliedRevision:-1,unfilledWarningActive:false,solvedPathsRendered:false,specialCrossTriggered:false,timers:new Set()}; boardReset.addEventListener('click',event=>{event.preventDefault();event.stopPropagation();selectBoard(b);playSound('reset');void resetSelectedBoard(b)}); positionBoardLabel(b);rendered.set(meta.id,b);bindBoard(b);renderBoardNow(b); @@ -3297,7 +3361,7 @@ function destroyBoard(board){ if(pointerId!=null)safeRelease(board.svg,pointerId); for(const timer of board.timers||[])clearTimeout(timer);board.timers?.clear?.(); for(const animation of board.cellFlashAnimations?.values?.()||[])try{animation.cancel()}catch(_){} - board.cellFlashAnimations?.clear?.();board.pathStrokeNodes=[];board.connectorNodes=[];board.pathVisualNodes?.clear?.();board.numberNodes?.clear?.();board.numberWarningNodes?.clear?.();board.specialNodes?.clear?.();board.dragLayer?.replaceChildren?.();board.pathLayer?.replaceChildren?.();board.label?.remove?.();board.card.remove();rendered.delete(board.id);perfCount('boardsDestroyed'); + releaseBoardAuroraPathCount(board);board.cellFlashAnimations?.clear?.();board.pathStrokeNodes=[];board.connectorNodes=[];board.pathVisualNodes?.clear?.();board.numberNodes?.clear?.();board.numberWarningNodes?.clear?.();board.specialNodes?.clear?.();board.dragLayer?.replaceChildren?.();board.pathLayer?.replaceChildren?.();board.label?.remove?.();board.card.remove();rendered.delete(board.id);perfCount('boardsDestroyed'); } function pointerPointForDrawing(drawing,point){const offset=drawing?.pointerOffset||[0,0];return[point[0]+offset[0],point[1]+offset[1]]} const POINTER_DIRECTION_DELTAS=Object.freeze({N:Object.freeze([-1,0]),S:Object.freeze([1,0]),W:Object.freeze([0,-1]),E:Object.freeze([0,1])}); @@ -3427,7 +3491,9 @@ function renderDragFrame(b){ drawing.renderGeometryRevision=logicalRevision;drawing.renderBlended=blended;drawing.renderSegments=pathRenderSegments(path,b.p);drawing.renderTurnCount=String(partialTurnCount(path,b.p)); drawing.renderPieces=blended?pathStrokePieces(drawing.renderSegments,startColor,endColor):drawing.renderSegments.map(points=>({points})); } - const pieces=drawing.renderPieces||[],cache=!b.dragCache||b.dragCache.index!==index||b.dragCache.blended!==blended||b.dragCache.count!==pieces.length?buildDragCache(b,index,blended,pieces.length):b.dragCache; + const pieces=drawing.renderPieces||[],cache=!b.dragCache||b.dragCache.index!==index||b.dragCache.blended!==blended||b.dragCache.count!==pieces.length?buildDragCache(b,index,blended,pieces.length):b.dragCache, + effectClass=lineEffectClass(path.lineEffect); + for(const node of cache.strokes)node.setAttribute('class',`path drag-path${effectClass}`);cache.liveTail.setAttribute('class',`path drag-path drag-live-tail${effectClass}`);for(const node of[cache.startHalo,cache.startKnob,cache.halo,cache.knob])node.classList.toggle('line-effect-aurora',path.lineEffect==='aurora'); const geometryChanged=cache.geometryRevision!==logicalRevision; if(geometryChanged){ pieces.forEach((piece,pieceIndex)=>{ @@ -3514,18 +3580,18 @@ function renderBoardNow(b){ for(const[pieceIndex,piece]of pathStrokePieces(segments,startColor,endColor).entries()){ const first=piece.points[0],last=piece.points[piece.points.length-1],gradientId=`path-gradient-${b.id}-${index}-${pieceIndex}`,gradient=svgEl('linearGradient',{id:gradientId,gradientUnits:'userSpaceOnUse',x1:first[0],y1:first[1],x2:last[0],y2:last[1]}); gradient.append(svgEl('stop',{offset:'0%','stop-color':piece.startColor}),svgEl('stop',{offset:'100%','stop-color':piece.endColor}));defs.append(gradient); - const pathNode=svgEl('polyline',{points:piece.points.map(q=>q.join(',')).join(' '),class:`path${invalid?' invalid':''}`,stroke:`url(#${gradientId})`,'data-path-index':index,'data-path-progress-start':piece.startProgress.toFixed(6),'data-path-progress-end':piece.endProgress.toFixed(6)}); + const pathNode=svgEl('polyline',{points:piece.points.map(q=>q.join(',')).join(' '),class:`path${lineEffectClass(path.lineEffect)}${invalid?' invalid':''}`,stroke:`url(#${gradientId})`,'data-path-index':index,'data-path-progress-start':piece.startProgress.toFixed(6),'data-path-progress-end':piece.endProgress.toFixed(6)}); applyLineWidth(pathNode,lineWidth);b.pathStrokeNodes.push(pathNode);visualNodes.push(pathNode);pathLayer.append(pathNode); } }else for(const points of segments){ - const pathNode=svgEl('polyline',{points:points.map(q=>q.join(',')).join(' '),class:`path${invalid?' invalid':''}`,stroke:startColor,'data-path-index':index}); + const pathNode=svgEl('polyline',{points:points.map(q=>q.join(',')).join(' '),class:`path${lineEffectClass(path.lineEffect)}${invalid?' invalid':''}`,stroke:startColor,'data-path-index':index}); applyLineWidth(pathNode,lineWidth);b.pathStrokeNodes.push(pathNode);visualNodes.push(pathNode);pathLayer.append(pathNode); } if(path.endGate==null){ const tip=visualTip,isActive=b.drawing?.pathIndex===index,turns=partialTurnCount(path,p),bx=tip[0]+19,by=tip[1]-19,endPickupColor=LINE_COLORS[displayedEndpointColorIndex(meta,path,'end')%LINE_COLORS.length],startPickupColor=LINE_COLORS[displayedEndpointColorIndex(meta,path,'start')%LINE_COLORS.length]; const - halo=svgEl('circle',{cx:tip[0],cy:tip[1],r:7,class:'endpoint-halo'}),knob=svgEl('circle',{cx:tip[0],cy:tip[1],r:4.8,class:'endpoint-knob',fill:endPickupColor}),hitNode=svgEl('circle',{cx:tip[0],cy:tip[1],r:24,class:'endpoint-hit','clip-path':`url(#${b.inputClipId})`,'data-path-index':index,'data-endpoint-side':'end',tabindex:0,role:'button','aria-label':`\u7dda\u306e\u7d9a\u304d\u3092\u5f15\u304f: ${index+1}`}); - if(path.detachedStart){const startTip=boardCellCenter(path.cells[0]),startHalo=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:7,class:'endpoint-halo'}),startKnob=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:4.8,class:'endpoint-knob',fill:startPickupColor}),startHit=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:24,class:'endpoint-hit','clip-path':`url(#${b.inputClipId})`,'data-path-index':index,'data-endpoint-side':'start',tabindex:0,role:'button','aria-label':`\u7dda\u306e\u53cd\u5bfe\u5074\u3092\u5f15\u304f: ${index+1}`});visualNodes.push(startHalo,startKnob,startHit);pathLayer.append(startHalo,startKnob,startHit)} + halo=svgEl('circle',{cx:tip[0],cy:tip[1],r:7,class:`endpoint-halo${lineEffectClass(path.lineEffect)}`}),knob=svgEl('circle',{cx:tip[0],cy:tip[1],r:4.8,class:`endpoint-knob${lineEffectClass(path.lineEffect)}`,fill:endPickupColor}),hitNode=svgEl('circle',{cx:tip[0],cy:tip[1],r:24,class:'endpoint-hit','clip-path':`url(#${b.inputClipId})`,'data-path-index':index,'data-endpoint-side':'end',tabindex:0,role:'button','aria-label':`\u7dda\u306e\u7d9a\u304d\u3092\u5f15\u304f: ${index+1}`}); + if(path.detachedStart){const startTip=boardCellCenter(path.cells[0]),startHalo=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:7,class:`endpoint-halo${lineEffectClass(path.lineEffect)}`}),startKnob=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:4.8,class:`endpoint-knob${lineEffectClass(path.lineEffect)}`,fill:startPickupColor}),startHit=svgEl('circle',{cx:startTip[0],cy:startTip[1],r:24,class:'endpoint-hit','clip-path':`url(#${b.inputClipId})`,'data-path-index':index,'data-endpoint-side':'start',tabindex:0,role:'button','aria-label':`\u7dda\u306e\u53cd\u5bfe\u5074\u3092\u5f15\u304f: ${index+1}`});visualNodes.push(startHalo,startKnob,startHit);pathLayer.append(startHalo,startKnob,startHit)} visualNodes.push(halo,knob,hitNode);pathLayer.append(halo,knob,hitNode); if(isActive){const badge=svgEl('circle',{cx:bx,cy:by,r:10.5,class:'turn-badge'}),t=svgEl('text',{x:bx,y:by+.5,class:'turn-count'});t.textContent=turns;visualNodes.push(badge,t);pathLayer.append(badge,t)} } @@ -3540,7 +3606,7 @@ function renderBoardNow(b){ const localColorIndex=confirmedBoundaryColorIndex(meta,gi)??pathColorIndexAtGate(own,gi)??pathColorIndexAtGate(other,hit.gateIndex)??0, localColor=LINE_COLORS[localColorIndex%LINE_COLORS.length], g=gateObj(p,gi),gp=gatePoint(g),[dr,dc]=SIDE_D[g.side],end=[gp[0]+dc*CELL*.5,gp[1]+dr*CELL*.5]; - const connector=svgEl('line',{x1:gp[0],y1:gp[1],x2:end[0],y2:end[1],class:'path connector',stroke:localColor}); + const connectorPath=own||other,connector=svgEl('line',{x1:gp[0],y1:gp[1],x2:end[0],y2:end[1],class:`path connector${lineEffectClass(connectorPath?.lineEffect)}`,stroke:localColor}); if(own){connector.dataset.ownerBoard=meta.id;connector.dataset.pathIndex=String(st.paths.indexOf(own))} else{connector.dataset.ownerBoard=hit.meta.id;connector.dataset.pathIndex=String(neighborState.paths.indexOf(other))} const owner=data.metas[connector.dataset.ownerBoard],ownerIndex=Number(connector.dataset.pathIndex); @@ -3579,43 +3645,58 @@ function renderBoardNow(b){ tip=path&&path.endGate==null&&path.cells.length?path.cells[path.cells.length-1]:null, connected=Boolean(path&&path.endGate!=null), armed=b.armedGate===i||path?.openGate===i||(path&&path.endGate==null&&sameCell(tip,[g[0],g[1]])), - gate=gateObj(p,i),gp=gatePoint(gate),gateCellKey=ckey(g[0],g[1]),available=!solved&&!path&&!occupiedKeys.has(gateCellKey); - b.gateDots[i].setAttribute('fill',color); + gate=gateObj(p,i),gp=gatePoint(gate),gateCellKey=ckey(g[0],g[1]),available=!solved&&!path&&!occupiedKeys.has(gateCellKey),auroraGate=!sealed&&(path?.lineEffect==='aurora'||available&&activeLineColorItem()?.aurora===true); + b.gateDots[i].setAttribute('fill',color);b.gateDots[i].classList.toggle('line-effect-aurora',auroraGate); b.gateDots[i].classList.toggle('sealed',sealed); b.gateDots[i].classList.toggle('frontier',frontier); - b.gateKnobs[i]?.setAttribute('fill',color); + b.gateKnobs[i]?.setAttribute('fill',color);b.gateKnobs[i]?.classList.toggle('line-effect-aurora',auroraGate); b.gateKnobs[i]?.classList.toggle('connected',connected&&!sealed); b.gateKnobs[i]?.classList.toggle('show',(Boolean(armed)||connected||available||Boolean(path?.openGate===i))&&!sealed); b.gateMarkers[i]?.setAttribute('d',gateMarkerPathAt(gp,gate.side,sealed,1,gate.internal)); - b.gateMarkers[i]?.setAttribute('stroke',sealed?'#7d878e':color); + b.gateMarkers[i]?.setAttribute('stroke',sealed?'#7d878e':color);b.gateMarkers[i]?.classList.toggle('line-effect-aurora',auroraGate); b.gateMarkers[i]?.classList.toggle('sealed',sealed); }); // Pointer dragging redraws the active polyline every frame. Its width was // already assigned synchronously above, so defer whole-world work until the if(b.drawing?.pointerId==null)queueLineWidthRefresh(b.id);else renderDragFrame(b); + updateBoardAuroraPathCount(b); if(minimapDirty)scheduleMinimap();if(overviewDirty&&inWorldOverview())scheduleWorldOverview(); perfEnd('renderBoardNow',renderStarted); } function activePath(b){return b.drawing?metaState(b.id).paths[b.drawing.pathIndex]||null:null} function nextPaint(){return new Promise(resolve=>requestAnimationFrame(()=>resolve()))} -const activeCompletionVisuals=new Map(); +const COMPLETION_NODE_POOL_LIMIT=16,GEM_PARTICLE_POOL_LIMIT=72; +const GEM_PARTICLE_KEYFRAME_TEMPLATE=Object.freeze([ + Object.freeze({transform:'translate3d(0,10px,0) scale(.25)',opacity:0}), + Object.freeze({transform:'',opacity:1,offset:.16}), + Object.freeze({transform:'',opacity:1,offset:.56}), + Object.freeze({transform:'',opacity:1,offset:.76}), + Object.freeze({transform:'',opacity:.15}) +]),GEM_PARTICLE_ANIMATION_OPTIONS_TEMPLATE=Object.freeze({duration:0,delay:0,easing:'cubic-bezier(.2,.72,.18,1)',fill:'forwards'}); +const activeCompletionVisuals=new Map(),completionFlashPool=[],completionBurstPool=[],gemParticlePool=[],activeGemBatches=new Set(); +let activeGemParticleCount=0,gemReceivingTimer=0,gemReceivingUntil=0; +function takeCompletionNode(pool,className){ + const reused=pool.length>0,node=pool.pop()||document.createElement('div');if(node.isConnected)node.remove();node.className=className;node.removeAttribute('style');node.textContent='';perfCount(reused?'completionNodesReused':'completionNodesCreated');return node; +} +function releaseCompletionNode(node,pool){ + if(!node)return;node.remove();node.className='';node.removeAttribute('style');node.textContent='';if(pool.length{resolve=done}),visual={board:b,flash:null,burst:null,timer:0,resolve,finished}; - activeCompletionVisuals.set(b.id,visual); + activeCompletionVisuals.set(b.id,visual);perfGauge('activeCompletionEffects',activeCompletionVisuals.size); try{ - if(reducedMotionQuery?.matches){finishCompletionVisual(b.id,false);return visual} + if(reducedMotionQuery?.matches){finishCompletionVisual(b.id,false);perfEnd('completionEffectSetup',started);return visual} b.card.classList.add('completing'); - visual.flash=document.createElement('div');visual.flash.className='completion-flash';b.card.append(visual.flash); - visual.burst=document.createElement('div');visual.burst.className='completion-burst';visual.burst.textContent=`◆ +${formatScore(award)}`;b.card.append(visual.burst); + visual.flash=takeCompletionNode(completionFlashPool,'completion-flash');visual.burst=takeCompletionNode(completionBurstPool,'completion-burst');visual.burst.textContent=`◆ +${formatScore(award)}`;b.card.append(visual.flash,visual.burst); visual.timer=setTimeout(()=>finishCompletionVisual(b.id,false),1800);b.timers?.add?.(visual.timer); }catch(error){console.warn('BEND FIELD: completion effect skipped',error);finishCompletionVisual(b.id,true)} - return visual; + perfEnd('completionEffectSetup',started);return visual; } function gemCollectionSources(b,count){ const cells=b?.p?.valid||[],rect=b?.svg?.getBoundingClientRect?.(),sources=[];if(!cells.length||!rect?.width)return sources; @@ -3623,31 +3704,56 @@ function gemCollectionSources(b,count){ for(let index=0;index0,particle=gemParticlePool.pop()||document.createElement('i');particle.remove();particle.className='gem-particle';particle.setAttribute('aria-hidden','true');particle.removeAttribute('style'); + if(!particle._gemKeyframes){particle._gemKeyframes=GEM_PARTICLE_KEYFRAME_TEMPLATE.map(frame=>({...frame}));particle._gemAnimationOptions={...GEM_PARTICLE_ANIMATION_OPTIONS_TEMPLATE}} + perfCount(reused?'gemParticlesReused':'gemParticlesCreated');return particle; +} +function releaseGemParticle(batch,particle){ + if(!batch?.particles?.has(particle))return false;const animation=batch.particles.get(particle);batch.particles.delete(particle);if(animation){animation.onfinish=null;animation.oncancel=null} + particle.remove();particle.className='';particle.removeAttribute('style');particle._gemDx=particle._gemDy=particle._gemArc=particle._gemDelay=particle._gemDuration=0;if(gemParticlePool.length{const remaining=gemReceivingUntil-Date.now();if(remaining>8){scheduleGemReceivingEnd(remaining);return}gemReceivingTimer=0;gemReceivingUntil=0;stat?.classList.remove('gem-receiving')},Math.max(0,delay)); +} +function cleanupGemEffects(){for(const batch of[...activeGemBatches])finishGemBatch(batch,true);clearTimeout(gemReceivingTimer);gemReceivingTimer=0;gemReceivingUntil=0;scoreCountEl.closest?.('.stat')?.classList.remove('gem-receiving')} function playGemCollectionAnimation(b,award){ - const targetRect=scoreCountEl?.getBoundingClientRect?.();if(!b?.card?.isConnected||!targetRect?.width)return false; + const started=perfStart(),targetRect=scoreCountEl?.getBoundingClientRect?.();if(!b?.card?.isConnected||!targetRect?.width)return false; const reduced=reducedMotionQuery?.matches,count=reduced?4:Math.max(10,Math.min(18,8+Math.round(Math.log10(Math.max(10,award))*2))),target=[targetRect.left+targetRect.width/2,targetRect.top+targetRect.height/2], sources=gemCollectionSources(b,count); - scoreCountEl.closest?.('.stat')?.classList.add('gem-receiving'); - let latest=0; + if(!sources.length){perfEnd('gemEffectSetup',started);return false} + const batch={particles:new Map(),fallbackTimers:new Set(),fallbackTimer:0},fragment=document.createDocumentFragment();activeGemBatches.add(batch);let latest=0; sources.forEach(([x,y],index)=>{ - const particle=document.createElement('i');particle.className='gem-particle';particle.setAttribute('aria-hidden','true');particle.style.left=`${x}px`;particle.style.top=`${y}px`;document.body.append(particle); - const dx=target[0]-x,dy=target[1]-y,arc=(index%2?-1:1)*(24+(index%5)*9),delay=reduced?0:index*48,duration=reduced?520:1450+(index%4)*110;latest=Math.max(latest,delay+duration); - try{ - const flight=particle.animate([ - {transform:'translate3d(0,10px,0) scale(.25)',opacity:0}, - {transform:`translate3d(${arc*.18}px,${-18-Math.abs(arc)*.16}px,0) scale(1.28)`,opacity:1,offset:.16}, - {transform:`translate3d(${arc*.28}px,${-25-Math.abs(arc)*.12}px,0) scale(1.05)`,opacity:1,offset:.56}, - {transform:`translate3d(${dx*.48+arc}px,${dy*.3-Math.abs(arc)}px,0) scale(.95)`,opacity:1,offset:.76}, - {transform:`translate3d(${dx}px,${dy}px,0) scale(.26)`,opacity:.15} - ],{duration,delay,easing:'cubic-bezier(.2,.72,.18,1)',fill:'forwards'}); - flight.finished.catch(()=>{}).finally(()=>particle.remove()); - }catch(_){setTimeout(()=>particle.remove(),delay+duration)} + const particle=takeGemParticle();particle.style.left=`${x}px`;particle.style.top=`${y}px`;fragment.append(particle); + particle._gemDx=target[0]-x;particle._gemDy=target[1]-y;particle._gemArc=(index%2?-1:1)*(24+(index%5)*9);particle._gemDelay=reduced?0:index*48;particle._gemDuration=reduced?520:1450+(index%4)*110;latest=Math.max(latest,particle._gemDelay+particle._gemDuration); + batch.particles.set(particle,null);activeGemParticleCount++; }); - setTimeout(()=>scoreCountEl.closest?.('.stat')?.classList.remove('gem-receiving'),latest+120);return true; + document.body.append(fragment);perfGauge('activeCosmeticParticles',activeGemParticleCount);perfGauge('gemPooledNodes',gemParticlePool.length); + for(const particle of batch.particles.keys()){ + try{ + const flight=animateGemParticle(particle);batch.particles.set(particle,flight);flight.onfinish=()=>releaseGemParticle(batch,particle);flight.oncancel=()=>releaseGemParticle(batch,particle); + }catch(_){const delay=particle._gemDelay,duration=particle._gemDuration,timer=setTimeout(()=>{batch.fallbackTimers.delete(timer);releaseGemParticle(batch,particle)},delay+duration);batch.fallbackTimers.add(timer)} + } + batch.fallbackTimer=setTimeout(()=>finishGemBatch(batch,true),latest+250);scheduleGemReceivingEnd(latest+120);perfEnd('gemEffectSetup',started);return true; } function skipCompletionVisuals(){for(const boardId of[...activeCompletionVisuals.keys()])finishCompletionVisual(boardId,true)} document.addEventListener('keydown',event=>{if(event.key==='Escape')skipCompletionVisuals()},true); -function updateSelectedProgress(b){if(b)selectedInfo.textContent=`\u30ec\u30d9\u30eb${b.meta.level}`} +function updateSelectedProgress(){} let selectedProgressFrame=0,pendingProgressBoard=null; function queueSelectedProgress(b){pendingProgressBoard=b;if(selectedProgressFrame)return;selectedProgressFrame=requestAnimationFrame(()=>{selectedProgressFrame=0;const pending=pendingProgressBoard;pendingProgressBoard=null;if(pending)updateSelectedProgress(pending)})} const expansionErrorCounts=new Map(); @@ -3717,7 +3823,6 @@ async function checkSolvedAndExpand(b){ const reward=rewardDetailsForBoard(b.meta,st),award=Math.min(MAX_SCORE,reward.award);st.scoreAwarded=award;st.rewardIdentity=reward.identity;st.rewardCoefficient=reward.coefficient; maybeOpenStore(b.meta,st,award);recordTimeAttackScore(reward);st.rev=nextRevision();markStateDirty(b.id); const now=trustedNow();data.lastSolveAt=now;data.specialMechanicsSeen=[...new Set([...(data.specialMechanicsSeen||[]),...mechanicTypesForPuzzle(b.p)])].sort();markGlobalDirty();finalizeExitGates(b.meta); - const immediateBoard=rendered.get(b.id);if(immediateBoard?.card?.isConnected){renderBoard(immediateBoard);playSound('clear');completionEffect(immediateBoard,award);playGemCollectionAnimation(immediateBoard,award)}updateHud(); writeDirtyRecoveryJournal(); const persistence=save(true),preparation=prepareExpansionCandidate(b.meta).catch(error=>{if(globalThis.BEND_DEBUG_GENERATION)console.debug('BEND FIELD: speculative expansion preparation skipped',error);return null}); if(!await persistence){ @@ -3725,12 +3830,16 @@ async function checkSolvedAndExpand(b){ renderBoard(rendered.get(b.id));updateHud();throw new Error('保存できなかったため、クリアを取り消しました。'); } if(cloudAvailable&&data.cloudProfile){ - const published=await pushCloudPending(); + let published=false; + for(let attempt=0;attempt<3&&!published;attempt++){published=await pushCloudPending();if(!published&&metaState(b.id)?.solved===true)await sleep(180*(attempt+1))} if(!published){ - noteCloudRow('state',b.id);data.cloudPending=currentCloudPending();restoreCloudPushPending();markGlobalDirty(false);await persistNow({skipCloud:true});armCloudPush(1000); - renderBoard(rendered.get(b.id));updateHud();toast('クリアは端末に保存しました。共有反映は自動で再試行します。');scheduleExpansionRepair(1000);return true; + finishCompletionVisual(b.id,true); + if(data.metas[b.id]===b.meta&&data.states[b.id]===st){data.states[b.id]=previous.state;normalizedStateObjects.add(previous.state);data.lastSolveAt=previous.lastSolveAt;data.timeAttack=previous.timeAttack;data.timeAttackRev=previous.timeAttackRev;data.specialMechanicsSeen=previous.specialMechanicsSeen;b.meta.sealedSides=previous.sealedSides;b.meta.rev=previous.metaRev;markStateDirty(b.id);markGlobalDirty()} + await persistNow({skipCloud:true});restoreCloudPushPending();armCloudPush(500);renderBoard(rendered.get(b.id));updateHud();return false; } + removeClaim(b.id,'cleared'); } + const immediateBoard=rendered.get(b.id);if(immediateBoard?.card?.isConnected){renderBoard(immediateBoard);playSound('clear');completionEffect(immediateBoard,award);playGemCollectionAnimation(immediateBoard,award)}updateHud(); let durableMeta=data.metas[b.id],durableState=data.states[b.id];if(!durableMeta||!durableState?.solved){scheduleExpansionRepair();return false}scheduleTimeAttackSuggestionAfterCompletion(); const prepared=await preparation;durableMeta=data.metas[b.id];durableState=data.states[b.id];if(!durableMeta||!durableState?.solved){scheduleExpansionRepair();return false} const expansionStarted=perfNow();let made=0;try{made=await expandMeta(durableMeta,prepared)||0}catch(error){await maybeGrantGenerationFailureBonus(durableMeta,perfNow()-expansionStarted);handleExpansionError(error);return true} @@ -3769,10 +3878,11 @@ function openTipMergePlan(b,ai,oi,otherSide='end',enteredOtherTipCell=false){ let cells=joined,merged; for(const cell of cells){const key=ckey(...cell);if(seen.has(key)||!cellSet(b.p).has(key))return null;seen.add(key)} const aColor=a.startColorIndex??a.colorIndex,oStartColor=o.startColorIndex??o.colorIndex,oEndColor=o.endColorIndex??oStartColor,oRemainingColor=otherSide==='start'?oEndColor:oStartColor; - if(!a.detachedStart&&!o.detachedStart)merged={startGate:a.startGate,endGate:o.startGate,openGate:null,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oStartColor}; - else if(!a.detachedStart&&o.detachedStart)merged={startGate:a.startGate,endGate:null,openGate:null,detachedStart:false,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oRemainingColor}; - else if(a.detachedStart&&!o.detachedStart){cells=[...joined].reverse();merged={startGate:o.startGate,endGate:null,openGate:null,detachedStart:false,cells,colorIndex:oStartColor,startColorIndex:oStartColor,endColorIndex:aColor}} - else merged={startGate:a.startGate,endGate:null,openGate:null,detachedStart:true,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oRemainingColor}; + const cosmetic={lineEffect:a.lineEffect||o.lineEffect||null,ownerId:a.ownerId||o.ownerId||null}; + if(!a.detachedStart&&!o.detachedStart)merged={startGate:a.startGate,endGate:o.startGate,openGate:null,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oStartColor,...cosmetic}; + else if(!a.detachedStart&&o.detachedStart)merged={startGate:a.startGate,endGate:null,openGate:null,detachedStart:false,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oRemainingColor,...cosmetic}; + else if(a.detachedStart&&!o.detachedStart){cells=[...joined].reverse();merged={startGate:o.startGate,endGate:null,openGate:null,detachedStart:false,cells,colorIndex:oStartColor,startColorIndex:oStartColor,endColorIndex:aColor,...cosmetic}} + else merged={startGate:a.startGate,endGate:null,openGate:null,detachedStart:true,cells,colorIndex:aColor,startColorIndex:aColor,endColorIndex:oRemainingColor,...cosmetic}; return{a,o,lo:Math.min(ai,oi),hi:Math.max(ai,oi),merged}; } function joinTips(b,ai,oi,otherSide='end',enteredOtherTipCell=false){ @@ -4037,7 +4147,7 @@ function startGate(b,gi,pointerId=null){ const colorIndex=canonicalGateColorIndex(b.meta,gi,null); const started=applyBoardCommand(b,()=>{ - st.paths.push({startGate:gi,endGate:null,openGate:null,cells:[[g.cell[0],g.cell[1]]],colorIndex,startColorIndex:colorIndex,endColorIndex:null}); + st.paths.push({startGate:gi,endGate:null,openGate:null,cells:[[g.cell[0],g.cell[1]]],colorIndex,startColorIndex:colorIndex,endColorIndex:null,lineEffect:activeLineColorItem()?.aurora?'aurora':null,ownerId:currentPlayerId()}); b.drawing=drawingFromGate(b,st.paths.length-1,gi,pointerId); b.drawing.createdPath=pointerId!=null; },{persist:pointerId==null,paint:pointerId==null}); @@ -4335,9 +4445,15 @@ function beginPendingClaimPointer(b,event,targetInfo){ try{b.svg.setPointerCapture(event.pointerId)}catch(_){}refreshInteractionState();schedulePendingClaimPreview(b);perfEnd('pickupPointerDownPreview',started);return b.pendingClaimPointer; } function updatePendingClaimPointer(b,event){ - const pending=b?.pendingClaimPointer;if(!pending||pending.pointerId!==event.pointerId)return false; + const pending=b?.pendingClaimPointer;if(!pending||pending.pointerId!==event.pointerId||pending.released)return false; const sample=pointerEventSamples(event);if(sample){pending.clientX=sample.clientX;pending.clientY=sample.clientY;pending.inputAt=sample.inputAt;(pending.samples||(pending.samples=[])).push(sample);trimBoardPointerSamples(pending.samples);schedulePendingClaimPreview(b)}return true; } +function markPendingClaimPointerReleased(b,event){ + const pending=b?.pendingClaimPointer;if(!pending||pending.pointerId!==event.pointerId)return false; + const sample=pointerEventSamples(event)||{pointerId:event.pointerId,clientX:pending.clientX,clientY:pending.clientY,inputAt:Number(event.timeStamp)||perfNow()}; + pending.clientX=sample.clientX;pending.clientY=sample.clientY;pending.inputAt=sample.inputAt;pending.released=true;pending.releaseSample=sample;(pending.samples||(pending.samples=[])).push(sample);trimBoardPointerSamples(pending.samples); + if(b.pendingClaimFrame)cancelAnimationFrame(b.pendingClaimFrame);b.pendingClaimFrame=0;event.preventDefault?.();return true; +} function clearPendingClaimPointer(b,pointerId,{release=true,releaseGesture=release}={}){ const pending=b?.pendingClaimPointer;if(!pending||pointerId!=null&&pending.pointerId!==pointerId)return null; if(b.pendingClaimFrame)cancelAnimationFrame(b.pendingClaimFrame);b.pendingClaimFrame=0;b.pendingClaimPointer=null;b.claimPreviewLayer.replaceChildren(); @@ -4361,58 +4477,59 @@ function activateBoardPointerDrag(b,pending,point){ function bindBoard(b){ b.card.addEventListener('focus',()=>selectBoard(b)); b.svg.addEventListener('pointerdown',async e=>{ - if(e.button!==0||b.joiningPaths||!gestureCoordinator.claim(e.pointerId,'draw'))return; + const endpointTarget=e.target.closest?.('.endpoint-hit'),gateTarget=e.target.closest?.('.gate-hit'); + if(e.button!==0||b.joiningPaths||!endpointTarget&&!gateTarget||!gestureCoordinator.claim(e.pointerId,'draw'))return; e.preventDefault(); e.stopPropagation(); selectBoard(b,{paint:false}); let st=metaState(b.id); if(st.solved){gestureCoordinator.release(e.pointerId,'draw');return} - const endpointTarget=e.target.closest?.('.endpoint-hit'),directGate=Number(e.target.closest?.('.gate-hit')?.dataset.gate), - targetEndpoint=Number(endpointTarget?.dataset.pathIndex),targetEndpointSide=endpointTarget?.dataset.endpointSide||'end', + const directGate=Number(gateTarget?.dataset.gate),targetEndpoint=Number(endpointTarget?.dataset.pathIndex),targetEndpointSide=endpointTarget?.dataset.endpointSide||'end', pending=beginPendingClaimPointer(b,e,{directGate,targetEndpoint,targetEndpointSide}); const claimApproved=await ensureBoardClaimForInput(b); - if(!claimApproved||b.pendingClaimPointer!==pending||!realtimeHeldPointers.has(e.pointerId)){clearPendingClaimPointer(b,e.pointerId);return} + if(!claimApproved||b.pendingClaimPointer!==pending||!pending.released&&!realtimeHeldPointers.has(e.pointerId)){clearPendingClaimPointer(b,e.pointerId);return} clearPendingClaimPointer(b,e.pointerId,{release:false}); if(!b.card.isConnected||(st=metaState(b.id)).solved){safeRelease(b.svg,e.pointerId);return}touchBoardClaim(b.id,true); - const point=eventToSvg(b,pending); + const point=eventToSvg(b,pending),finishReleased=activated=>{if(!pending.released)return activated;if(activated)queueMicrotask(()=>finishPointer(pending.releaseSample||pending,true));else safeRelease(b.svg,e.pointerId);return activated}; if(Number.isInteger(directGate)&&b.p.g[directGate]){ const directCell=[b.p.g[directGate][0],b.p.g[directGate][1]],occupiedPath=occupiedMap(st).get(ckey(...directCell)),gatePath=st.paths.findIndex(path=>pathUsesGate(path,directGate)); - if(gatePath>=0||occupiedPath==null){armGate(b,directGate);startGate(b,directGate,e.pointerId);if(!activateBoardPointerDrag(b,pending,point)){b.armedGate=null;renderBoard(b)}return} + if(gatePath>=0||occupiedPath==null){armGate(b,directGate);startGate(b,directGate,e.pointerId);const activated=activateBoardPointerDrag(b,pending,point);if(!activated){b.armedGate=null;renderBoard(b)}finishReleased(activated);return} } if(Number.isInteger(targetEndpoint)&&st.paths[targetEndpoint]?.endGate==null){ if(st.paths[targetEndpoint].detachedStart)orientDetachedEndpoint(b,targetEndpoint,targetEndpointSide,e.pointerId,point); else{b.armedGate=null;b.drawing=drawingForPath(b,targetEndpoint,e.pointerId,point)} - activateBoardPointerDrag(b,pending,point);return; + finishReleased(activateBoardPointerDrag(b,pending,point));return; } const nearestEndpoint=nearestEndpointAtPoint(b,point); if(Number.isInteger(nearestEndpoint)&&st.paths[nearestEndpoint]?.endGate==null){ b.armedGate=null;b.drawing=drawingForPath(b,nearestEndpoint,e.pointerId,point); - activateBoardPointerDrag(b,pending,point);return; + finishReleased(activateBoardPointerDrag(b,pending,point));return; } const hintCell=cellAt(b,point), targetGate=gateStartCandidate(b,point,hintCell,directGate),occupiedPath=hintCell?occupiedMap(st).get(ckey(...hintCell)):null, gatePath=Number.isInteger(targetGate)?st.paths.findIndex(path=>pathUsesGate(path,targetGate)):-1; if(Number.isInteger(targetGate)&&(gatePath>=0||occupiedPath==null)){ armGate(b,targetGate);startGate(b,targetGate,e.pointerId); - if(!activateBoardPointerDrag(b,pending,point)){b.armedGate=null;renderBoard(b)} + const activated=activateBoardPointerDrag(b,pending,point);if(!activated){b.armedGate=null;renderBoard(b)}finishReleased(activated); return; } if(Number.isInteger(occupiedPath)){ - if(reopenPathAtCell(b,occupiedPath,hintCell,e.pointerId)){playSound('grab');activateBoardPointerDrag(b,pending,point);return} + if(reopenPathAtCell(b,occupiedPath,hintCell,e.pointerId)){playSound('grab');finishReleased(activateBoardPointerDrag(b,pending,point));return} } if(Number.isInteger(targetGate)){ armGate(b,targetGate);startGate(b,targetGate,e.pointerId); - if(!activateBoardPointerDrag(b,pending,point)){b.armedGate=null;renderBoard(b)} + const activated=activateBoardPointerDrag(b,pending,point);if(!activated){b.armedGate=null;renderBoard(b)}finishReleased(activated); return; } const cell=cellAt(b,point); - if(!cell)return; + if(!cell){finishReleased(false);return;} const pi=occupiedMap(st).get(ckey(...cell)); if(pi!=null){ if(reopenPathAtCell(b,pi,cell,e.pointerId)){ - activateBoardPointerDrag(b,pending,point);return; + finishReleased(activateBoardPointerDrag(b,pending,point));return; } } + finishReleased(false); }); b.svg.addEventListener('pointermove',e=>{ @@ -4422,7 +4539,7 @@ function bindBoard(b){ }); const finishPointer=(e,flush=true)=>{ const started=perfStart();try{ - if(b.pendingClaimPointer?.pointerId===e.pointerId){clearPendingClaimPointer(b,e.pointerId);return} + if(b.pendingClaimPointer?.pointerId===e.pointerId){if(flush===true)markPendingClaimPointerReleased(b,e);else clearPendingClaimPointer(b,e.pointerId);return} if(b.releaseDrain?.pointerId===e.pointerId)return; if(!b.drawing){cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay();safeRelease(b.svg,e.pointerId);return} if(b.drawing.pointerId!==e.pointerId)return; @@ -4441,7 +4558,7 @@ function bindBoard(b){ }; b.svg.addEventListener('pointerup',e=>finishPointer(e,true)); b.svg.addEventListener('pointercancel',e=>finishPointer(e,false)); - b.svg.addEventListener('lostpointercapture',e=>{if(b.pendingClaimPointer?.pointerId===e.pointerId)clearPendingClaimPointer(b,e.pointerId,{release:false,releaseGesture:true});else if(b.releaseDrain?.pointerId===e.pointerId)return;else if(b.drawing?.pointerId===e.pointerId)finishPointer(e,false);else{gestureCoordinator.release(e.pointerId,'draw','lost-capture');refreshInteractionState()}}); + b.svg.addEventListener('lostpointercapture',e=>{if(b.pendingClaimPointer?.pointerId===e.pointerId){if(!b.pendingClaimPointer.released)clearPendingClaimPointer(b,e.pointerId,{release:false,releaseGesture:true})}else if(b.releaseDrain?.pointerId===e.pointerId)return;else if(b.drawing?.pointerId===e.pointerId)finishPointer(e,false);else{gestureCoordinator.release(e.pointerId,'draw','lost-capture');refreshInteractionState()}}); b.svg.addEventListener('contextmenu',e=>e.preventDefault()); b.svg.addEventListener('focusin',()=>selectBoard(b)); b.svg.addEventListener('keydown',async e=>{ @@ -4602,7 +4719,7 @@ function rebaseWorldOrigin(){ function repositionActiveBoardHud(){const board=rendered.get(hudBoardId);if(board?.card?.isConnected&&boardPlayHudVisible(board))positionBoardLabel(board)} function applyCamera(immediate=false,frameTimestamp=null){ const cameraGestureActive=Boolean(pan||pinch||interactionActive('camera'));if(!cameraGestureActive)rebaseWorldOrigin();const overview=updateZoomPresentation(); - const paint=timestamp=>{cameraFrame=0;cameraFrameDelayTimer=0;cameraLastDraw=timestamp;markVisualFrame(timestamp);if(overview){positionCachedWorldOverview();if(overviewCacheNeedsInteractionRebuild()){overviewDirty=true;scheduleWorldOverview(false,{allowDuringInteraction:cameraGestureActive})}}else world.style.transform=`translate3d(${cam.x}px,${cam.y}px,0) scale(${cam.scale})`;repositionActiveBoardHud()}; + const paint=timestamp=>{if(!immediate&&cameraLastDraw&×tamp-cameraLastDrawmeta.puzzle))} +async function randomUnsolvedMeta(){ + let candidates=Object.values(data.metas).filter(meta=>meta&&!metaState(meta.id).solved); + if(!candidates.length)candidates=Object.values(data.metas).filter(Boolean); + if(!candidates.length)return null; + const random=new Uint32Array(1);try{if(!globalThis.crypto?.getRandomValues)throw new Error('secure random unavailable');globalThis.crypto.getRandomValues(random)}catch(_){random[0]=Math.floor(Math.random()*0xffffffff)} + const meta=candidates[random[0]%candidates.length]; + if(meta&&!meta.puzzle)await hydrateMeta(meta); + return meta; +} async function centerRandomBoard(){ let candidates=Object.values(data.metas).filter(Boolean); if(candidates.length>1)candidates=candidates.filter(meta=>meta.id!==activeBoard); @@ -4806,7 +4932,7 @@ const modal=document.querySelector('#modal'),helpBtn=document.querySelector('#he inventoryModal=document.querySelector('#inventoryModal'),inventoryPanel=inventoryModal.querySelector('.inventory-panel'), inventoryBtn=document.querySelector('#inventoryBtn'),inventoryCountEl=document.querySelector('#inventoryCount'), inventoryTotal=document.querySelector('#inventoryTotal'),inventoryTarget=document.querySelector('#inventoryTarget'), - inventoryList=document.querySelector('#inventoryList'),debugAllItemsToggle=document.querySelector('#debugAllItemsToggle'),debugAllItemsState=document.querySelector('#debugAllItemsState'), + inventoryList=document.querySelector('#inventoryList'), timeAttackModal=document.querySelector('#timeAttackModal'),timeAttackPanel=timeAttackModal.querySelector('.time-attack-panel'), timeAttackBtn=document.querySelector('#timeAttackBtn'),timeAttackButtonLabel=document.querySelector('#timeAttackButtonLabel'), timeAttackHeadingScore=document.querySelector('#timeAttackHeadingScore'),timeAttackSetup=document.querySelector('#timeAttackSetup'), @@ -4814,13 +4940,12 @@ const modal=document.querySelector('#modal'),helpBtn=document.querySelector('#he timeAttackClockEl=document.querySelector('#timeAttackClock'),timeAttackCollectedEl=document.querySelector('#timeAttackCollected'), timeAttackMultiplierEl=document.querySelector('#timeAttackMultiplier'),timeAttackProjectedEl=document.querySelector('#timeAttackProjected'), timeAttackResultLimit=document.querySelector('#timeAttackResultLimit'),timeAttackResultSolves=document.querySelector('#timeAttackResultSolves'), - timeAttackResultCollected=document.querySelector('#timeAttackResultCollected'),timeAttackResultMultiplier=document.querySelector('#timeAttackResultMultiplier'), - timeAttackResultBonus=document.querySelector('#timeAttackResultBonus'),timeAttackResultTotal=document.querySelector('#timeAttackResultTotal'), - timeAttackShareText=document.querySelector('#timeAttackShareText'),copyTimeAttackBtn=document.querySelector('#copyTimeAttack'), - closeTimeAttackBtn=document.querySelector('#closeTimeAttack'), + timeAttackResultTotal=document.querySelector('#timeAttackResultTotal'),timeAttackShareText=document.querySelector('#timeAttackShareText'), + copyTimeAttackBtn=document.querySelector('#copyTimeAttack'),closeTimeAttackBtn=document.querySelector('#closeTimeAttack'), + timeAttackCountdownOverlay=document.querySelector('#timeAttackCountdownOverlay'),timeAttackCountdownValue=timeAttackCountdownOverlay.querySelector('span'), settingsModal=document.querySelector('#settingsModal'),settingsPanel=settingsModal.querySelector('.settings-panel'),settingsPlayerName=document.querySelector('#settingsPlayerName'),lightweightRenderingToggle=document.querySelector('#lightweightRenderingToggle'),soundEnabledToggle=document.querySelector('#soundEnabledToggle'),resetSettingsBtn=document.querySelector('#resetSettings'),closeSettingsBtn=document.querySelector('#closeSettings'),customEmojiCursor=document.querySelector('#customEmojiCursor'),pickupHandleOverlay=document.querySelector('#pickupHandleOverlay'); let openStoreBoardId=null; -let timeAttackTimer=null,entryChoicePending=false; +let timeAttackTimer=null,timeAttackCountdownActive=false,timeAttackCountdownHideTimer=0,timeAttackFinalCountdownSecond=null,entryChoicePending=false; function trapDialogFocus(panel,e){ if(e.key!=='Tab')return; const focusable=[...panel.querySelectorAll('button,[href],[tabindex]:not([tabindex="-1"])')].filter(el=>!el.disabled&&!el.hidden); @@ -4854,63 +4979,143 @@ function closeDialogRoot(root,preferredFocus=null){ root.setAttribute('aria-hidden','true'); gestureCoordinator.release(`dialog:${root.id||'root'}`,'dialog');interactionState.clear('dialog'); } -function debugAllItemsEnabled(){return data.debugAllItems===true} +function debugAllItemsEnabled(){return DEBUG_PURCHASE_MODE} +function storeItemActive(item){return Boolean(item&&(item.cursorStyle?data.cursorStyle===item.cursorStyle:item.lineColor?data.lineColorStyle===item.id:item.lineEffect?data.lineEffectStyle===item.lineEffect:item.reactionStyle?data.reactionStyle===item.reactionStyle:item.scoreLens?data.scoreLensEnabled:false))} function setItemIcon(node,item){ node.classList.toggle('emoji-glyph',Boolean(item?.cursorEmoji&&!item?.flagAsset)); + node.classList.toggle('line-color-swatch',Boolean(item?.lineColor));node.classList.toggle('aurora-swatch',item?.aurora===true); + if(item?.lineColor)node.style.setProperty('--item-color',item.lineColor);else node.style.removeProperty('--item-color'); if(item?.flagAsset){ - const image=document.createElement('img');image.className='item-flag-image';image.src=item.flagAsset;image.alt='';image.draggable=false;node.replaceChildren(image); - }else node.textContent=item?.icon||''; + let image=node.firstElementChild?.matches?.('img.item-flag-image')?node.firstElementChild:null; + if(!image){image=document.createElement('img');image.className='item-flag-image';image.alt='';image.draggable=false;image.loading='lazy';image.decoding='async';image.fetchPriority='low';image.addEventListener('load',()=>perfCount('inventoryImagesDecoded'));node.replaceChildren(image);perfCount('inventoryImagesCreated')} + if(image.getAttribute('src')!==item.flagAsset)image.src=item.flagAsset; + }else if(node.textContent!==(item?.icon||''))node.textContent=item?.icon||''; +} +function panelScrollPosition(panel){return{top:Number(panel?.scrollTop)||0,left:Number(panel?.scrollLeft)||0}} +function restorePanelScroll(panel,position){if(!panel||!position)return;panel.scrollTop=position.top;panel.scrollLeft=position.left} +const inventoryCollapsedCategories=new Set(),inventoryCategoryViews=new Map(),inventoryItemViews=new Map(), + inventoryLineEffectItemIds=new Map(STORE_ITEMS.filter(item=>item.lineEffect).map(item=>[item.lineEffect,item.id])), + inventoryReactionStyleItemIds=new Map(STORE_ITEMS.filter(item=>item.reactionStyle).map(item=>[item.reactionStyle,item.id])); +let inventoryHasRendered=false,inventorySelectedCursorItemId=null; +function rememberInventoryCategoryState(){ + for(const section of inventoryList?.querySelectorAll?.('details.inventory-section[data-category]')||[]){ + if(section.open)inventoryCollapsedCategories.delete(section.dataset.category);else inventoryCollapsedCategories.add(section.dataset.category); + } +} +function createInventoryCategoryView(category){ + const section=document.createElement('details'),title=document.createElement('summary'),titleText=document.createTextNode(category.title),countBadge=document.createElement('span'),list=document.createElement('div'); + section.className=`inventory-section inventory-${category.cursor?'cursors':'items'}-section`;section.dataset.category=category.key;section.open=!inventoryCollapsedCategories.has(category.key); + title.className='inventory-section-title';title.append(titleText,countBadge);list.className=category.cursor?'inventory-cursor-grid':'inventory-item-list';section.append(title,list); + section.addEventListener('toggle',()=>{if(section.open)inventoryCollapsedCategories.delete(category.key);else inventoryCollapsedCategories.add(category.key)}); + const view={section,title,titleText,countBadge,list,cursor:category.cursor};inventoryCategoryViews.set(category.key,view);perfCount('inventoryNodesCreated',4);return view; +} +function createInventoryItemView(item,cursor){ + if(cursor){ + const option=document.createElement('button');option.type='button';option.className='inventory-cursor-option';option.dataset.itemId=item.id;option.addEventListener('click',event=>{event.preventDefault();void useInventoryItem(option.dataset.itemId)}); + const view={root:option,option,cursor:true,itemId:item.id};inventoryItemViews.set(item.id,view);perfCount('inventoryNodesCreated');return view; + } + const card=document.createElement('article'),icon=document.createElement('div'),copy=document.createElement('div'),name=document.createElement('h3'),description=document.createElement('p'),use=document.createElement('button'); + card.className='inventory-item';card.dataset.itemId=item.id;icon.className='inventory-item-icon';copy.append(name,description);use.type='button';use.className='inventory-use';use.addEventListener('click',()=>void useInventoryItem(card.dataset.itemId));card.append(icon,copy,use); + const view={root:card,card,icon,name,description,use,cursor:false,itemId:item.id};inventoryItemViews.set(item.id,view);perfCount('inventoryNodesCreated',6);return view; +} +function inventoryItemViewSignature(item,{count}){ + return item.cursorStyle?`cursor:${data.cursorStyle===item.cursorStyle}`:`item:${count}:${storeItemActive(item)}`; +} +function updateInventoryItemView(view,item,state){ + const signature=inventoryItemViewSignature(item,state);if(view.signature===signature){perfCount('inventoryItemsUnchanged');return false} + view.signature=signature;const{count}=state; + if(view.cursor){ + const option=view.option,selected=data.cursorStyle===item.cursorStyle;setItemIcon(option,item);option.classList.toggle('selected',selected);option.setAttribute('aria-pressed',String(selected));option.setAttribute('aria-label',`${item.name}カーソル`);perfCount('inventoryItemsPatched');return true; + } + const active=storeItemActive(item);view.card.classList.remove('debug-available');setItemIcon(view.icon,item);view.name.textContent=`${item.name} · ×${count}`;view.description.textContent=item.description; + view.use.textContent=item.scoreLens?(active?'オン':'オフ'):(active?'装備中':'装備する');view.use.disabled=false; + const pressed=Boolean(item.scoreLens||item.lineColor||item.lineEffect||item.reactionStyle);view.use.classList.toggle('selected',pressed&&active);if(pressed)view.use.setAttribute('aria-pressed',String(active));else view.use.removeAttribute('aria-pressed');perfCount('inventoryItemsPatched');return true; +} +function patchInventoryItems(itemIds){ + if(!inventoryHasRendered||!inventoryModal.classList.contains('show'))return 0;const started=perfStart(),scrollPosition=panelScrollPosition(inventoryPanel),debug=debugAllItemsEnabled();let patched=0; + for(const itemId of new Set(itemIds||[])){if(!itemId)continue;const view=inventoryItemViews.get(itemId),item=storeItem(itemId);if(view&&item&&updateInventoryItemView(view,item,{debug,count:inventoryCount(item.id)}))patched++} + restorePanelScroll(inventoryPanel,scrollPosition);perfGauge('inventoryLastPatchItems',patched);perfEnd('inventoryPatch',started);return patched; +} +function placeInventoryNode(parent,node,anchor){ + if(node===anchor)return anchor.nextElementSibling;parent.insertBefore(node,anchor);return anchor; } function renderInventoryPanel(){ - const debug=debugAllItemsEnabled(),total=debug?STORE_ITEMS.length:inventoryCount(); - inventoryCountEl.textContent=debug?'ALL':String(total); - inventoryTotal.textContent=debug?'DEBUG':String(total); - if(debugAllItemsToggle)debugAllItemsToggle.checked=debug; - if(debugAllItemsState)debugAllItemsState.textContent=debug?'ON · 全アイテム使用可能':'OFF · 通常所持数を使用'; - inventoryTarget.textContent='カーソルはデザインをクリックして切り替えます。同じデザインをもう一度クリックすると標準へ戻ります。'; - inventoryList.replaceChildren(); + const started=perfStart(),scrollPosition=panelScrollPosition(inventoryPanel);rememberInventoryCategoryState(); + const debug=debugAllItemsEnabled(),total=inventoryCount(); + inventoryCountEl.textContent=String(total); + inventoryTotal.textContent=String(total); + inventoryTarget.textContent='カテゴリ名を押すと折り畳めます。ラインカラー、エフェクト、カーソルをここで装備できます。'; if(!total){ - const empty=document.createElement('div');empty.className='inventory-item empty';empty.textContent='所持アイテムはありません。';inventoryList.append(empty);return; + let empty=inventoryList.querySelector(':scope > .inventory-item.empty');if(!empty){empty=document.createElement('div');empty.className='inventory-item empty';empty.textContent='所持アイテムはありません。';perfCount('inventoryNodesCreated')} + inventoryList.replaceChildren(empty);for(const view of inventoryItemViews.values())view.root.remove();inventoryItemViews.clear();for(const view of inventoryCategoryViews.values())view.section.remove();inventoryCategoryViews.clear();inventorySelectedCursorItemId=null;restorePanelScroll(inventoryPanel,scrollPosition);perfGauge('inventoryMountedItems',0);perfEnd(inventoryHasRendered?'inventoryPatch':'inventoryRender',started);inventoryHasRendered=true;return; } - const available=STORE_ITEMS.filter(item=>debug||inventoryCount(item.id)>0),categories=[ - {title:'アイテム',items:available.filter(item=>!item.cursorStyle),cursor:false}, - {title:'カーソル',items:available.filter(item=>item.cursorStyle),cursor:true} + inventoryList.querySelector(':scope > .inventory-item.empty')?.remove(); + const available=STORE_ITEMS.filter(item=>inventoryCount(item.id)>0),categories=[ + {key:'line-colors',title:'ラインカラー',items:available.filter(item=>item.lineColor),cursor:false}, + {key:'reactions',title:'リアクション',items:available.filter(item=>item.reactionStyle),cursor:false}, + {key:'tools',title:'ツール',items:available.filter(item=>item.scoreLens),cursor:false}, + {key:'cursors',title:'カーソル',items:available.filter(item=>item.cursorStyle),cursor:true} ]; + const availableIds=new Set(available.map(item=>item.id)),categoryKeys=new Set(),desiredSections=[]; for(const category of categories){ - if(!category.items.length)continue; - const section=document.createElement('section'),title=document.createElement('h3'),list=document.createElement('div'); - section.className=`inventory-section inventory-${category.cursor?'cursors':'items'}-section`; - title.className='inventory-section-title';title.textContent=category.title; - list.className=category.cursor?'inventory-cursor-grid':'inventory-item-list'; + if(!category.items.length)continue;categoryKeys.add(category.key); + const categoryView=inventoryCategoryViews.get(category.key)||createInventoryCategoryView(category);desiredSections.push(categoryView.section);if(categoryView.titleText.nodeValue!==category.title)categoryView.titleText.nodeValue=category.title;const categoryCount=String(category.items.length);if(categoryView.countBadge.textContent!==categoryCount)categoryView.countBadge.textContent=categoryCount; + let itemAnchor=categoryView.list.firstElementChild; for(const item of category.items){ - const count=inventoryCount(item.id); - if(category.cursor){ - const option=document.createElement('button');option.type='button';option.className='inventory-cursor-option';option.dataset.itemId=item.id;setItemIcon(option,item);option.classList.toggle('selected',data.cursorStyle===item.cursorStyle); - option.setAttribute('aria-pressed',String(data.cursorStyle===item.cursorStyle));option.setAttribute('aria-label',`${item.name}カーソル`); - option.addEventListener('click',event=>{event.preventDefault();void useInventoryItem(item.id)});list.append(option);continue; - } - const card=document.createElement('article'),icon=document.createElement('div'),copy=document.createElement('div'), - name=document.createElement('h3'),description=document.createElement('p'),use=document.createElement('button'); - card.className=`inventory-item${debug?' debug-available':''}`;icon.className='inventory-item-icon';icon.textContent=item.icon; - name.textContent=debug?`${item.name} · ∞`:`${item.name} · ×${count}`;description.textContent=item.description;copy.append(name,description); - const lensActive=item.scoreLens&&data.scoreLensEnabled===true; - use.type='button';use.className='inventory-use';use.textContent=item.scoreLens?(lensActive?'ON':'OFF'):'使用';use.disabled=false; - if(item.scoreLens){use.classList.toggle('selected',lensActive);use.setAttribute('aria-pressed',String(lensActive))} - use.addEventListener('click',()=>useInventoryItem(item.id));card.append(icon,copy,use);list.append(card); + let itemView=inventoryItemViews.get(item.id);if(!itemView||itemView.cursor!==category.cursor){itemView?.root?.remove?.();inventoryItemViews.delete(item.id);itemView=createInventoryItemView(item,category.cursor)}else perfCount('inventoryNodesReused'); + updateInventoryItemView(itemView,item,{debug,count:inventoryCount(item.id)});itemAnchor=placeInventoryNode(categoryView.list,itemView.root,itemAnchor); } - section.append(title,list);inventoryList.append(section); } + for(const[id,view]of[...inventoryItemViews])if(!availableIds.has(id)){view.root.remove();inventoryItemViews.delete(id);perfCount('inventoryNodesRemoved')} + for(const[key,view]of[...inventoryCategoryViews])if(!categoryKeys.has(key)){view.section.remove();inventoryCategoryViews.delete(key);perfCount('inventoryNodesRemoved',4)} + let sectionAnchor=inventoryList.firstElementChild;for(const section of desiredSections)sectionAnchor=placeInventoryNode(inventoryList,section,sectionAnchor); + inventorySelectedCursorItemId=available.find(item=>item.cursorStyle&&item.cursorStyle===data.cursorStyle)?.id||null; + restorePanelScroll(inventoryPanel,scrollPosition);perfGauge('inventoryMountedItems',inventoryItemViews.size);perfEnd(inventoryHasRendered?'inventoryPatch':'inventoryRender',started);inventoryHasRendered=true; } function updateInventoryUi(){ - inventoryCountEl.textContent=debugAllItemsEnabled()?'ALL':String(inventoryCount()); + inventoryCountEl.textContent=debugAllItemsEnabled()?'∞':String(inventoryCount()); if(inventoryModal.classList.contains('show'))renderInventoryPanel(); } function syncInventoryCursorSelection(){ - if(!inventoryList)return; - for(const option of inventoryList.querySelectorAll('.inventory-cursor-option[data-item-id]')){ - const item=storeItem(option.dataset.itemId),selected=Boolean(item?.cursorStyle&&data.cursorStyle===item.cursorStyle); - option.classList.toggle('selected',selected);option.setAttribute('aria-pressed',String(selected)); - } + if(!inventoryList)return;const next=STORE_ITEMS.find(item=>item.cursorStyle&&item.cursorStyle===data.cursorStyle)?.id||null;patchInventoryItems([inventorySelectedCursorItemId,next]);inventorySelectedCursorItemId=next; +} +function activeLineColorItem(){return storeItem(data.lineColorStyle)||storeItem(data.starterLineColor)||LINE_COLOR_ITEMS[0]} +function activeLineColorIndex(){const id=activeLineColorItem()?.id,index=LINE_COLOR_ITEMS.findIndex(item=>item.id===id);return index>=0?index:0} +const AURORA_COLOR_INTERVAL=2000,AURORA_RGB_PALETTE=Object.freeze(['79 235 255','126 255 188','255 223 105','255 112 207','171 126 255','105 160 255','255 139 92']); +let auroraRgbTimer=0,auroraNextTickAt=0,auroraVisiblePathCount=0,auroraRgbValue='',auroraPaletteIndex=-1; +function nextAuroraRgb(){auroraPaletteIndex=(auroraPaletteIndex+1)%AURORA_RGB_PALETTE.length;return AURORA_RGB_PALETTE[auroraPaletteIndex]} +function auroraColorHost(){return world||document.documentElement} +function writeAuroraRgb(){ + const next=nextAuroraRgb();if(next===auroraRgbValue)return false;auroraRgbValue=next;auroraColorHost().style.setProperty('--aurora-rgb',next);perfCount('auroraColorWrites');return true; +} +function stopAuroraRgbAnimation(resetDeadline=true){ + if(auroraRgbTimer)clearTimeout(auroraRgbTimer);auroraRgbTimer=0;if(resetDeadline)auroraNextTickAt=0; +} +function scheduleAuroraRgbTick(){ + if(auroraRgbTimer||document.visibilityState==='hidden'||auroraVisiblePathCount<=0)return false; + const now=perfNow();if(!auroraNextTickAt)auroraNextTickAt=now+AURORA_COLOR_INTERVAL; + auroraRgbTimer=setTimeout(()=>{ + auroraRgbTimer=0;const started=perfStart();if(document.visibilityState==='hidden'||auroraVisiblePathCount<=0){stopAuroraRgbAnimation();perfEnd('auroraTick',started);return} + const tickNow=perfNow();writeAuroraRgb();do{auroraNextTickAt+=AURORA_COLOR_INTERVAL}while(auroraNextTickAt<=tickNow);perfEnd('auroraTick',started);scheduleAuroraRgbTick(); + },Math.max(0,auroraNextTickAt-now));return true; +} +function startAuroraRgbAnimation(){ + if(AURORA_COLOR_INTERVAL<=0||document.visibilityState==='hidden'||auroraVisiblePathCount<=0)return false; + if(!auroraRgbValue)writeAuroraRgb();scheduleAuroraRgbTick();return true; +} +function updateAuroraAnimationState(){ + perfGauge('auroraActivePaths',auroraVisiblePathCount);if(document.visibilityState==='hidden'||auroraVisiblePathCount<=0){stopAuroraRgbAnimation();return false}return startAuroraRgbAnimation(); +} +function updateBoardAuroraPathCount(board){ + if(!board)return 0;const nodes=[...(board.pathStrokeNodes||[]),...(board.connectorNodes||[]),...(board.gateDots||[]),...(board.gateKnobs||[]),...(board.gateMarkers||[])],next=nodes.reduce((count,node)=>count+(node?.classList?.contains('line-effect-aurora')?1:0),0),previous=Number(board.auroraPathCount)||0; + if(next!==previous){board.auroraPathCount=next;auroraVisiblePathCount=Math.max(0,auroraVisiblePathCount+next-previous);updateAuroraAnimationState()}return next; +} +function releaseBoardAuroraPathCount(board){ + const previous=Number(board?.auroraPathCount)||0;if(!previous)return;board.auroraPathCount=0;auroraVisiblePathCount=Math.max(0,auroraVisiblePathCount-previous);updateAuroraAnimationState(); +} +function syncCosmeticAppearance(){ + normalizeEquippedCosmeticsInPlace(data);const activeColor=activeLineColorItem(),color=activeColor?.lineColor||LINE_COLORS[0],lineEffect=activeColor?.aurora?'aurora':'none',reactionStyle=REACTION_STYLE_IDS.has(data.reactionStyle)?data.reactionStyle:'classic'; + document.body.style.setProperty('--player-line-color',color);document.body.dataset.lineEffect=lineEffect;document.body.dataset.reactionStyle=reactionStyle;updateAuroraAnimationState();prewarmReactionGlyphs(data.lastReaction||REACTION_EMOJIS[0],reactionStyle); } function syncCursorAppearance(style){ @@ -4991,17 +5196,34 @@ function worldUnitAtClient(clientX,clientY){ } async function useInventoryItemLoaded(itemId){ const item=storeItem(itemId),debug=debugAllItemsEnabled(),entry=inventoryEntries(itemId)[0]; - if(!item||(!debug&&!entry)){toast('そのアイテムは所持していません。');renderInventoryPanel();return false} + if(!item||(!debug&&!ownsStoreItem(itemId))){toast('そのアイテムは所持していません。');renderInventoryPanel();return false} if(item.cursorStyle){ const previousCursor=data.cursorStyle,nextCursor=previousCursor===item.cursorStyle?'default':item.cursorStyle; applyCursorStyle(nextCursor);syncInventoryCursorSelection(); if(!await save(true)){applyCursorStyle(previousCursor);syncInventoryCursorSelection();toast('切り替えできませんでした。');return false} toast(nextCursor==='default'?'標準カーソルへ戻しました。':`${item.name}へ切り替えました。`);return true; } + if(item.lineColor){ + const previous=data.lineColorStyle;data.lineColorStyle=item.id;syncCosmeticAppearance();renderAll();markGlobalDirty(); + if(!await save(true)){data.lineColorStyle=previous;syncCosmeticAppearance();renderAll();markGlobalDirty();patchInventoryItems([previous,item.id]);toast('ラインカラーを変更できませんでした。');return false} + patchInventoryItems([previous,item.id]);toast(`${item.name}を装備しました。新しく引く線に適用されます。`);return true; + } + if(item.lineEffect){ + const previous=data.lineEffectStyle;data.lineEffectStyle=previous===item.lineEffect?'none':item.lineEffect;syncCosmeticAppearance();markGlobalDirty(); + const previousItemId=inventoryLineEffectItemIds.get(previous)||null,nextItemId=inventoryLineEffectItemIds.get(data.lineEffectStyle)||null; + if(!await save(true)){data.lineEffectStyle=previous;syncCosmeticAppearance();markGlobalDirty();patchInventoryItems([previousItemId,nextItemId]);toast('ラインエフェクトを変更できませんでした。');return false} + patchInventoryItems([previousItemId,nextItemId]);toast(data.lineEffectStyle==='none'?'ラインエフェクトを外しました。':`${item.name}を装備しました。`);return true; + } + if(item.reactionStyle){ + const previous=data.reactionStyle;data.reactionStyle=previous===item.reactionStyle?'classic':item.reactionStyle;syncCosmeticAppearance();markGlobalDirty(); + const previousItemId=inventoryReactionStyleItemIds.get(previous)||null,nextItemId=inventoryReactionStyleItemIds.get(data.reactionStyle)||null; + if(!await save(true)){data.reactionStyle=previous;syncCosmeticAppearance();markGlobalDirty();patchInventoryItems([previousItemId,nextItemId]);toast('リアクションを変更できませんでした。');return false} + patchInventoryItems([previousItemId,nextItemId]);toast(data.reactionStyle==='classic'?'標準の絵文字表示に戻しました。':`${item.name}を装備しました。`);return true; + } if(item.scoreLens){ const previous=data.scoreLensEnabled===true;data.scoreLensEnabled=!previous;markGlobalDirty();invalidateEconomyCaches(); - if(!await save(true)){data.scoreLensEnabled=previous;markGlobalDirty();invalidateEconomyCaches();renderInventoryPanel();toast('切り替えできませんでした。');return false} - updateHud();renderAll();updateZoomPresentation(true);renderInventoryPanel();toast(`${item.name} ${data.scoreLensEnabled?'ON':'OFF'}`);return true; + if(!await save(true)){data.scoreLensEnabled=previous;markGlobalDirty();invalidateEconomyCaches();patchInventoryItems([item.id]);toast('切り替えできませんでした。');return false} + updateHud();renderAll();updateZoomPresentation(true);patchInventoryItems([item.id]);toast(`${item.name} ${data.scoreLensEnabled?'オン':'オフ'}`);return true; } const previous=entry?deepClone(entry.purchase):null,previousRev=entry?.st?.rev; if(!await save(true)){ @@ -5031,22 +5253,42 @@ function formatTimeAttackClock(milliseconds){ const seconds=Math.max(0,Math.ceil(milliseconds/1000)),minutes=Math.floor(seconds/60); return`${String(minutes).padStart(2,'0')}:${String(seconds%60).padStart(2,'0')}`; } +function waitForTimeAttackCountdown(milliseconds){return new Promise(resolve=>setTimeout(resolve,milliseconds))} +function showTimeAttackCountdownOverlay(value,mode){ + clearTimeout(timeAttackCountdownHideTimer);timeAttackCountdownHideTimer=0; + timeAttackCountdownOverlay.hidden=false;timeAttackCountdownOverlay.className='';timeAttackCountdownValue.textContent=String(value); + void timeAttackCountdownOverlay.offsetWidth; + timeAttackCountdownOverlay.classList.add(mode==='final'?'final-sequence':'start-sequence'); + if(value==='Start')timeAttackCountdownOverlay.classList.add('is-start'); +} +function hideTimeAttackCountdownOverlay(resetFinal=false){ + clearTimeout(timeAttackCountdownHideTimer);timeAttackCountdownHideTimer=0;timeAttackCountdownOverlay.hidden=true;timeAttackCountdownOverlay.className=''; + if(resetFinal)timeAttackFinalCountdownSecond=null; +} +async function playTimeAttackStartLeadIn(){ + timeAttackCountdownActive=true;timeAttackFinalCountdownSecond=null;document.activeElement?.blur?.(); + for(const value of['3','2','1']){showTimeAttackCountdownOverlay(value,'start');await waitForTimeAttackCountdown(850)} +} +function releaseTimeAttackStartOverlay(){timeAttackCountdownActive=false;hideTimeAttackCountdownOverlay()} +function pulseTimeAttackFinalCountdown(seconds){ + if(timeAttackCountdownActive||seconds===timeAttackFinalCountdownSecond)return; + timeAttackFinalCountdownSecond=seconds;showTimeAttackCountdownOverlay(seconds,'final'); + timeAttackCountdownHideTimer=setTimeout(()=>hideTimeAttackCountdownOverlay(),540); +} function timeAttackResultText(result=data.lastTimeAttack){ if(!result)return''; - const lines=[ - '⏱️ 曲線フィールド|タイムアタック', + return[ + '⏱️ LinkField/リンクフィールド|タイムアタック', `🏁 ${result.durationMinutes}分 🧩 ${result.solves}枚クリア`, - `💎 獲得 ${formatScore(result.collected)} ✨ 実効倍率 ×${result.multiplier.toFixed(2)}`, - `🎁 タイム加算 +${formatScore(result.modifierGain||0)}`, - `🏆 合計 ${formatScore(result.total)}` - ]; - // lines.push('https://example.com/bend-field/time-attack/demo-result'); - return lines.join('\n'); + `🏆 合計 ${formatScore(result.total)}`, + 'https://host.nishi.boats/~333/link-field/' + ].join('\n'); } -function timeAttackCooldownRemaining(durationMinutes,now=trustedNow()){ - return Math.max(0,(data.timeAttackCooldowns?.[durationMinutes]||0)-now); +function timeAttackCooldownRemaining(_durationMinutes,now=trustedNow()){ + const sharedEndsAt=Math.max(0,...TIME_ATTACK_MINUTES.map(minutes=>Number(data.timeAttackCooldowns?.[minutes])||0)); + return Math.max(0,sharedEndsAt-now); } -function hasActiveTimeAttackCooldown(){return TIME_ATTACK_MINUTES.some(minutes=>timeAttackCooldownRemaining(minutes)>0)} +function hasActiveTimeAttackCooldown(){return timeAttackCooldownRemaining(TIME_ATTACK_MINUTES[0])>0} function renderTimeAttackPanel(){ const run=data.timeAttack,last=data.lastTimeAttack; timeAttackSetup.hidden=!!run; @@ -5070,21 +5312,23 @@ function renderTimeAttackPanel(){ timeAttackHeadingScore.textContent=`${run.solves}\u679a\u30af\u30ea\u30a2`; }else{ timeAttackClockEl.classList.remove('urgent'); - timeAttackHeadingScore.textContent=last?`\u30bf\u30a4\u30e0\u52a0\u7b97 +${formatScore(last.modifierGain||last.bonus)}`:'\u30bf\u30a4\u30e0\u52a0\u7b97'; + timeAttackHeadingScore.textContent=last?`\u5408\u8a08 ${formatScore(last.total)}`:'\u6311\u6226\u7d50\u679c'; } if(last){ timeAttackResultLimit.textContent=`${last.durationMinutes}\u5206`; timeAttackResultSolves.textContent=String(last.solves); - timeAttackResultCollected.textContent=formatScore(last.collected); - timeAttackResultMultiplier.textContent=`\u00d7${last.multiplier.toFixed(2)}`; - timeAttackResultBonus.textContent=`+${formatScore(last.modifierGain||last.bonus)}`; timeAttackResultTotal.textContent=formatScore(last.total); timeAttackShareText.textContent=timeAttackResultText(last); }else timeAttackShareText.textContent=''; } function updateTimeAttackUi(){ const run=data.timeAttack,now=trustedNow(); - if(run&&now>=run.endsAt){void finishTimeAttack();return} + if(run&&now>=run.endsAt){hideTimeAttackCountdownOverlay(true);void finishTimeAttack();return} + if(run){ + const remaining=Math.max(0,run.endsAt-now),seconds=Math.ceil(remaining/1000); + timeAttackBtn.classList.toggle('final-countdown',remaining<=30000&&remaining>0); + if(!timeAttackCountdownActive){timeAttackFinalCountdownSecond=null;hideTimeAttackCountdownOverlay()} + }else{timeAttackBtn.classList.remove('final-countdown');if(!timeAttackCountdownActive)hideTimeAttackCountdownOverlay(true);} if(!run&&!hasActiveTimeAttackCooldown())clearTimeAttackTimer() timeAttackBtn.classList.toggle('active',!!run); timeAttackBtn.classList.toggle('starting',Boolean(run&&now-run.startedAt<5000)); @@ -5102,16 +5346,21 @@ function resumeTimeAttackTimer(){ else timeAttackTimer=setTimeout(resumeTimeAttackTimer,Math.min(...remaining)+50); } async function startTimeAttack(durationMinutes){ - if(data.timeAttack||!TIME_ATTACK_MINUTES.includes(durationMinutes))return false; + if(data.timeAttack||timeAttackCountdownActive||!TIME_ATTACK_MINUTES.includes(durationMinutes))return false; const cooldown=timeAttackCooldownRemaining(durationMinutes); if(cooldown){toast(`\u518d\u6311\u6226\u307e\u3067\uff1a${formatTimeAttackClock(cooldown)}`);return false} - const startedAt=trustedNow(); - const previous={timeAttack:data.timeAttack,timeAttackRev:data.timeAttackRev}; + closeTimeAttack(false); + await playTimeAttackStartLeadIn(); + const refreshedCooldown=timeAttackCooldownRemaining(durationMinutes); + if(data.timeAttack||refreshedCooldown){releaseTimeAttackStartOverlay();toast(data.timeAttack?'\u5225\u306e\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u304c\u958b\u59cb\u3055\u308c\u307e\u3057\u305f\u3002':`\u518d\u6311\u6226\u307e\u3067\uff1a${formatTimeAttackClock(refreshedCooldown)}`);return false} + showTimeAttackCountdownOverlay('Start','start'); + await waitForTimeAttackCountdown(700); + releaseTimeAttackStartOverlay(); + const startedAt=trustedNow(),previous={timeAttack:data.timeAttack,timeAttackRev:data.timeAttackRev}; data.timeAttack={id:globalThis.crypto?.randomUUID?.()||`run-${startedAt}`,durationMinutes,startedAt,endsAt:startedAt+durationMinutes*60000,collected:0,baseCollected:0,rewardPipelineVersion:2,scoreVersion:SCORE_VERSION,solves:0}; - data.timeAttackRev=nextRevision();markGlobalDirty(); - if(!await save(true)){data.timeAttack=previous.timeAttack;data.timeAttackRev=previous.timeAttackRev;toast('\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u59cb\u3067\u304d\u307e\u305b\u3093\u3002');return false} - resumeTimeAttackTimer();closeTimeAttack(false);updateHud(); - toast(`${durationMinutes}\u5206\u306e\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002 \u500d\u7387 \u00d71.25`); + data.timeAttackRev=nextRevision();markGlobalDirty();resumeTimeAttackTimer();updateHud(); + if(!await save(true)){data.timeAttack=previous.timeAttack;data.timeAttackRev=previous.timeAttackRev;releaseTimeAttackStartOverlay();resumeTimeAttackTimer();toast('\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u59cb\u3067\u304d\u307e\u305b\u3093\u3002');return false} + toast(`${durationMinutes}\u5206\u306e\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u3092\u958b\u59cb\u3057\u307e\u3057\u305f\u3002`); return true; } function recordTimeAttackScore(reward){ @@ -5134,10 +5383,10 @@ async function finishTimeAttack(){ const previous={run:deepClone(run),lastTimeAttack:deepClone(data.lastTimeAttack),cooldowns:deepClone(data.timeAttackCooldowns),bonusScore:data.bonusScore,bonusEvents:deepClone(data.bonusEvents),timeAttackRev:data.timeAttackRev}; try{ const result=normalizeTimeAttackResult({...run,completedAt:trustedNow()});data.timeAttack=null;data.lastTimeAttack=result; - data.timeAttackCooldowns=normalizeTimeAttackCooldowns(data.timeAttackCooldowns);data.timeAttackCooldowns[result.durationMinutes]=result.completedAt+TIME_ATTACK_COOLDOWN_MINUTES[result.durationMinutes]*60000; + data.timeAttackCooldowns=normalizeTimeAttackCooldowns(data.timeAttackCooldowns);const cooldownEndsAt=result.completedAt+TIME_ATTACK_COOLDOWN_MINUTES[result.durationMinutes]*60000;for(const minutes of TIME_ATTACK_MINUTES)data.timeAttackCooldowns[minutes]=cooldownEndsAt; data.bonusEvents=data.bonusEvents||{};if(result.bonus>0)data.bonusEvents[`time-attack:${result.id}`]=result.bonus;data.bonusScore=bonusEventTotal();statsDirty=true;data.timeAttackRev=nextRevision();markGlobalDirty();clearTimeAttackTimer(); if(!await save(true)){data.timeAttack=previous.run;data.lastTimeAttack=previous.lastTimeAttack;data.timeAttackCooldowns=previous.cooldowns;data.bonusScore=previous.bonusScore;data.bonusEvents=previous.bonusEvents;data.timeAttackRev=previous.timeAttackRev;statsDirty=true;resumeTimeAttackTimer();showStatus('\u4fdd\u5b58\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002',{retry:true,fresh:false});return null} - updateHud();resumeTimeAttackTimer();openTimeAttack();toast(`\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u7d42\u4e86 \u00b7 \u30bf\u30a4\u30e0\u52a0\u7b97 +${formatScore(result.modifierGain||result.bonus)}`,3000);return result; + hideTimeAttackCountdownOverlay(true);updateHud();resumeTimeAttackTimer();openTimeAttack();toast(`\u30bf\u30a4\u30e0\u30a2\u30bf\u30c3\u30af\u7d42\u4e86 \u00b7 \u5408\u8a08 ${formatScore(result.total)}`,3000);return result; }finally{finishingTimeAttack=false} } async function copyTimeAttackResult(){ @@ -5201,37 +5450,60 @@ async function saveSettings(restoreFocus=true){ uiSettings=normalizeUiSettings({lightweightRendering:lightweightRenderingToggle.checked,soundEnabled:soundEnabledToggle.checked});persistUiSettings(); if(previousLightweight!==uiSettings.lightweightRendering){for(const board of rendered.values())board.solvedPathsRendered=false;renderAll()} closeSettingsDialog(restoreFocus);toast('設定を反映しました。'); - if(nameChanged)try{await commitPlayerProfileName(name)}catch(error){toast(`共有プロフィールを更新できませんでした。 ${error.message}`)} + if(nameChanged)try{await commitPlayerProfileName(name)}catch(error){toast('共有プロフィールを更新できませんでした。')} return true } function closeSettings(restoreFocus=true){return saveSettings(restoreFocus)} +function purchaseFlyOrigin(sourceElement){ + const rect=sourceElement?.getBoundingClientRect?.(); + return rect&&rect.width>0&&rect.height>0?{left:rect.left,top:rect.top,width:rect.width,height:rect.height}:null; +} +function animatePurchasedItemToInventory(item,origin){ + if(!item||!origin||!inventoryBtn?.isConnected)return; + const target=inventoryBtn.getBoundingClientRect(),fly=document.createElement('div'); + fly.className='purchase-item-fly';setItemIcon(fly,item);fly.setAttribute('aria-hidden','true'); + const startX=origin.left+origin.width/2,startY=origin.top+origin.height/2,endX=target.left+target.width/2,endY=target.top+target.height/2; + fly.style.left=`${startX}px`;fly.style.top=`${startY}px`;document.body.append(fly); + const dx=endX-startX,dy=endY-startY,arc=-Math.max(42,Math.min(150,Math.abs(dx)*.18+Math.abs(dy)*.16)); + if(typeof fly.animate!=='function'){fly.style.transform=`translate(calc(-50% + ${dx}px),calc(-50% + ${dy}px)) scale(.28)`;setTimeout(()=>{fly.remove();inventoryBtn.classList.add('purchase-arrival');setTimeout(()=>inventoryBtn.classList.remove('purchase-arrival'),420)},620);return} + const animation=fly.animate([ + {transform:'translate(-50%,-50%) scale(1) rotate(0deg)',opacity:1,offset:0}, + {transform:`translate(calc(-50% + ${dx*.48}px),calc(-50% + ${dy*.48+arc}px)) scale(1.18) rotate(12deg)`,opacity:1,offset:.52}, + {transform:`translate(calc(-50% + ${dx}px),calc(-50% + ${dy}px)) scale(.28) rotate(34deg)`,opacity:.1,offset:1} + ],{duration:620,easing:'cubic-bezier(.18,.78,.18,1)',fill:'forwards'}); + inventoryBtn.classList.remove('purchase-arrival'); + animation.finished.then(()=>{fly.remove();inventoryBtn.classList.add('purchase-arrival');setTimeout(()=>inventoryBtn.classList.remove('purchase-arrival'),420)}).catch(()=>fly.remove()); +} + function renderStorePanel(){ + const scrollPosition=panelScrollPosition(storePanel); pruneAndCount(); const meta=data.metas[openStoreBoardId],st=meta?metaState(meta.id):null,store=st?.store; if(!meta||!store){closeStore(false);return} - storeTitle.textContent='\u30a2\u30a4\u30c6\u30e0\u30b7\u30e7\u30c3\u30d7'; - storeWallet.textContent=formatScore(data.score); + storeTitle.textContent='アイテムショップ'; + storeWallet.textContent=debugAllItemsEnabled()?'∞':formatScore(data.score); storeMeta.textContent=`店主:${store.owner}`; storeInventory.replaceChildren(); const available=storeInventoryItems(meta,store),categories=[ - {title:'アイテム',items:available.filter(item=>!item.cursorStyle),cursor:false}, - {title:'カーソル',items:available.filter(item=>item.cursorStyle),cursor:true} + {title:'カーソル',items:available.filter(item=>item.cursorStyle),compact:true,cursor:true,description:'盤面操作に使うカーソルデザインです。'}, + {title:'その他のアイテム',items:available.filter(item=>!item.cursorStyle).slice(0,6),compact:false,cursor:false,description:''} ]; for(const category of categories){ - const section=document.createElement('section'),title=document.createElement('h3'),list=document.createElement('div'); - section.className=`store-section store-${category.cursor?'cursors':'items'}-section`;title.className='store-section-title';title.textContent=category.title; - list.className=`store-section-list ${category.cursor?'store-cursor-list':'store-item-list'}`; + if(!category.items.length)continue; + const section=document.createElement('section'),title=document.createElement('h3'),brief=document.createElement('p'),list=document.createElement('div'); + section.className=`store-section store-${category.cursor?'cursors':'items'}-section`;title.className='store-section-title';title.textContent=category.title;brief.className='store-section-brief';brief.textContent=category.description; + list.className=`store-section-list ${category.compact?'store-compact-list':'store-item-list'}${category.cursor?' store-cursor-list':''}`; for(const item of category.items){ const purchased=personalEconomyMode()?Boolean(playerPurchaseForStore(meta.id,item.id)):store.purchases.some(purchase=>purchase.id===item.id),price=storeItemPrice(meta,store,item), card=document.createElement('article'),icon=document.createElement('div'),buy=document.createElement('button'); - card.className=`store-item ${category.cursor?'store-cursor':'store-other'}${purchased?' purchased':''}`; - icon.className='store-item-icon';setItemIcon(icon,item); + card.className=`store-item${category.cursor?' store-cursor':' store-other'}${category.compact?' store-compact':''}${purchased?' purchased':''}`; + card.title=`${item.name} — ${item.effectLabel}:${item.description}`;icon.className='store-item-icon';setItemIcon(icon,item); buy.type='button';buy.className='store-buy'; - buy.textContent=purchased?'\u8cfc\u5165\u6e08\u307f':`購入 ${formatScore(price)}`; - buy.disabled=purchased||data.scorepurchaseStoreItem(meta.id,item.id)); - if(category.cursor)card.append(icon,buy); + buy.textContent=purchased?'購入済み':`購入 ${formatScore(price)}`; + buy.disabled=purchased||(!debugAllItemsEnabled()&&data.scorepurchaseStoreItem(meta.id,item.id,purchaseFlyOrigin(icon))); + if(category.compact)card.append(icon,buy); else{ const copy=document.createElement('div'),name=document.createElement('h4'),effect=document.createElement('strong'),description=document.createElement('p'); copy.className='store-item-copy';name.textContent=item.name;effect.textContent=item.effectLabel;description.textContent=item.description; @@ -5239,8 +5511,9 @@ function renderStorePanel(){ } list.append(card); } - section.append(title,list);storeInventory.append(section); + section.append(title);if(category.description)section.append(brief);section.append(list);storeInventory.append(section); } + restorePanelScroll(storePanel,scrollPosition); } function openStoreMeta(meta){ if(meta&&!meta.puzzle){void hydrateMeta(meta).then(()=>openStoreMeta(meta)).catch(error=>{console.warn('BEND FIELD: shop hydration failed',error);toast('\u30b7\u30e7\u30c3\u30d7\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3067\u3057\u305f\u3002')});return true} @@ -5256,19 +5529,20 @@ function closeStore(restoreFocus=true){ const board=rendered.get(openStoreBoardId); closeDialogRoot(storeModal,restoreFocus&&board?.storeButton?.isConnected?board.storeButton:viewport);openStoreBoardId=null; } -async function purchaseStoreItem(boardId,itemId){ +async function purchaseStoreItem(boardId,itemId,purchaseOrigin=null){ const item=storeItem(itemId);if(!item)return false; pruneAndCount(); const meta=data.metas[boardId],st=meta?metaState(boardId):null,store=st?.store;if(!store)return false; if(!storeInventoryItems(meta,store).some(available=>available.id===item.id)){toast('この店では取り扱っていません。');return false} - if(personalEconomyMode()&&!onlinePlayerEconomy()){toast('共有の所持数を確認できないため購入できません。');renderStorePanel();return false} + const debug=debugAllItemsEnabled();if(!debug&&personalEconomyMode()&&!onlinePlayerEconomy()){toast('共有の所持数を確認できないため購入できません。');renderStorePanel();return false} const already=personalEconomyMode()?playerPurchaseForStore(boardId,itemId):store.purchases.find(purchase=>purchase.id===item.id);if(already){toast('購入済みです。');return false} - const price=storeItemPrice(meta,store,item);if(data.scoreentry.purchaseId!==purchase.purchaseId);invalidateEconomyCaches();toast('購入できませんでした。');return false}updateHud();renderStorePanel();animatePurchasedItemToInventory(item,purchaseOrigin);playSound('buy');toast(`${item.name}を購入しました。`);return true} + if(onlinePlayerEconomy())try{await buyPersonalStoreItem(boardId,itemId);updateHud();renderStorePanel();animatePurchasedItemToInventory(item,purchaseOrigin);playSound('buy');toast(`${item.name}を購入しました。`);return true}catch(error){toast('購入できませんでした。共有状態を確認してください。');renderStorePanel();return false} + const previousRev=st.rev,purchase={id:item.id,buyer:currentPlayerName(),boughtAt:trustedNow(),paidCost:debug?0:price}; store.purchases.push(purchase);st.rev=nextRevision();markStateDirty(boardId); if(!await save(true)){store.purchases.splice(store.purchases.indexOf(purchase),1);st.rev=previousRev;markStateDirty(boardId);updateHud();renderStorePanel();toast('購入できませんでした。');return false} - updateHud();renderStorePanel();playSound('buy');toast(`${item.name}を購入しました。`);return true; + updateHud();renderStorePanel();animatePurchasedItemToInventory(item,purchaseOrigin);playSound('buy');toast(`${item.name}を購入しました。`);return true; } window.addEventListener('keydown',e=>{ if(settingsModal.classList.contains('show')){ @@ -5305,7 +5579,6 @@ settingsModal.addEventListener('pointerdown',e=>{if(e.target===settingsModal)voi inventoryBtn.onclick=openInventory; document.querySelector('#closeInventory').onclick=()=>closeInventory(); inventoryModal.addEventListener('pointerdown',e=>{if(e.target===inventoryModal)closeInventory()}); -if(debugAllItemsToggle)debugAllItemsToggle.addEventListener('change',()=>{data.debugAllItems=debugAllItemsToggle.checked;document.body.dataset.debugItems=data.debugAllItems?'on':'off';markGlobalDirty();updateHud();void save(true);toast(data.debugAllItems?'デバッグON · 全アイテムを使用できます。':'デバッグOFF · 通常所持数へ戻りました。')}); document.querySelector('#acceptTimeAttackSuggestion').onclick=()=>{dismissTimeAttackSuggestion();openTimeAttack()}; document.querySelector('#dismissTimeAttackSuggestion').onclick=dismissTimeAttackSuggestion; timeAttackBtn.onclick=openTimeAttack; @@ -5412,7 +5685,7 @@ async function downloadSaveExport(destination){ await destination.abort(error);if(error?.name==='AbortError')toast('\u66f8\u304d\u51fa\u3057\u3092\u30ad\u30e3\u30f3\u30bb\u30eb\u3057\u307e\u3057\u305f\u3002');else throw error; }finally{activeArchiveController=null;setFieldArchiveBusy(false);hideStatus()} } -exportBtn.onclick=async()=>{ +if(exportBtn)exportBtn.onclick=async()=>{ const filename=`bend-field-save-${new Date().toISOString().slice(0,10)}.bfsave`; try{ const destination=await FieldPersistence.createArchiveDestination(filename,Math.max(1024,Object.keys(data.metas).length*1024)); @@ -5425,7 +5698,7 @@ async function beginWorldReplacement(reason){ if(activeStorageFormat!==FIELD_STORAGE_FORMAT)await preserveRecoveryDurably(JSON.stringify(compactSnapshot()),reason); return createWorldEpoch(); } -document.querySelector('#freshBtn').onclick=async()=>{ +const freshStartButton=document.querySelector('#freshBtn');if(freshStartButton)freshStartButton.onclick=async()=>{ if(!confirm('\u73fe\u5728\u306e\u9032\u884c\u3092\u30d0\u30c3\u30af\u30a2\u30c3\u30d7\u3057\u3001\u6700\u521d\u304b\u3089\u59cb\u3081\u307e\u3059\u304b\uff1f'))return; try{const newEpoch=await beginWorldReplacement('User started fresh');await clearDatabaseWorld(newEpoch);clearCompactMirror();clearRecoveryJournalKeys();safeLocalRemove(storageRevisionKey);announceWorldReplacement(newEpoch);location.reload()} catch(error){lifecyclePersistenceSuppressed=false;showStatus(`\u65b0\u3057\u3044\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u958b\u59cb\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f\u3002 ${error?.message||error}`,{retry:true,fresh:true})} @@ -5662,94 +5935,45 @@ if(importBtn&&importFileEl){ importBtn.onclick=()=>{importFileEl.value='';importFileEl.click()}; importFileEl.onchange=async()=>{try{await importSaveFile(importFileEl.files?.[0])}catch(error){showStatus(`\u4fdd\u5b58\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3067\u3057\u305f\u3002 ${error?.message||error}`,{retry:false,fresh:false});toast('\u4fdd\u5b58\u30c7\u30fc\u30bf\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3067\u3057\u305f\u3002')}}; } -async function retryRecovery(){ - hideStatus();if(!puzzleWorker)createPuzzleWorker(); - try{ - const envelope=await readRecoveryEnvelope();if(!envelope?.raw)throw new Error('復元用バックアップがありません。'); - const parsed=JSON.parse(envelope.raw),candidate=parsed?.current??parsed,snapshot=normalizeSnapshot(candidate,{quiet:true}); - if(!Object.keys(snapshot.metas).length)throw new Error('復元用バックアップに有効な盤面がありません。'); - if(activeStorageFormat!==FIELD_STORAGE_FORMAT)throw new Error('現行の保存形式ではありません。'); - const expected=await activeWorldExpectation(),controller=new AbortController(),staged=await stageRecoverySnapshotV2(snapshot,expected,controller); - await activateStagedWorldV2(staged,{kind:'recovery'}); - } - catch(error){console.warn('BEND FIELD: recovery retry failed',error);showStatus(`保存データを復元できませんでした。 ${error?.message||error}`,{retry:true,fresh:false})} -} -document.querySelector('#retryBtn').onclick=retryRecovery; -const worldChannel=typeof BroadcastChannel!=='undefined'?new BroadcastChannel(`bend-field-v${SAVE_SCHEMA}-${WORLD_GENERATION}`):null; -const seenWorldCommitIds=new Set(),seenWorldCommitOrder=[],pendingWorldCommitIds=new Set(); -let worldInitReady=false,deferredWorldSignals=[]; -function worldCommitId(signal){return typeof signal?.commitId==='string'?signal.commitId:signal?.sessionId&&signal?.updatedAt?`${signal.sessionId}:${signal.updatedAt}`:null} -function rememberWorldCommit(signal){ - const id=worldCommitId(signal);if(!id)return true;if(seenWorldCommitIds.has(id))return false; - seenWorldCommitIds.add(id);seenWorldCommitOrder.push(id);if(seenWorldCommitOrder.length>512)seenWorldCommitIds.delete(seenWorldCommitOrder.shift());return true; -} -function broadcastWorldSignal(signal){rememberWorldCommit(signal);try{worldChannel?.postMessage(signal)}catch(_){}safeLocalSet(syncStorageKey,JSON.stringify(signal))} -function announceWorldReplacement(worldEpoch){const signal={type:'world-replaced',commitId:`${sessionId}:replacement:${++worldSignalSeq}`,updatedAt:trustedNow(),sessionId,worldEpoch,metaIds:[],stateIds:[],deleted:[],globalChanged:true};broadcastWorldSignal(signal)} -let syncQueue=Promise.resolve(); -async function loadDbChanges(signal){ - if(!idbAvailable||!validWorldEpoch(data?.worldEpoch))return null; - const ids=[...new Set([...(signal.metaIds||[]),...(signal.stateIds||[]),...(signal.deleted||[])])],db=await openWorldDb(),tx=db.transaction(['control','worlds','boardIndex','boardPuzzles','boardStates','tombstonesV2'],'readonly'),done=transactionDone(tx), - controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'),indexStore=tx.objectStore('boardIndex'),puzzleStore=tx.objectStore('boardPuzzles'),stateStore=tx.objectStore('boardStates'),tombstoneStore=tx.objectStore('tombstonesV2'), - keys=ids.map(id=>[data.worldEpoch,id]); - const [control,world,indexRows,puzzleRows,stateRecords,tombstoneRows]=await Promise.all([ - requestValue(controlStore.get('active')),requestValue(worldsStore.get(data.worldEpoch)), - Promise.all(keys.map(key=>requestValue(indexStore.get(key)))),Promise.all(keys.map(key=>requestValue(puzzleStore.get(key)))), - Promise.all(keys.map(key=>requestValue(stateStore.get(key)))),Promise.all(keys.map(key=>requestValue(tombstoneStore.get(key)))) - ]);await done; - const metaRows=ids.map((id,index)=>metaFromV2Records(indexRows[index],puzzleRows[index])), - stateRows=ids.map((id,index)=>{const value=stateFromV2Record(indexRows[index],stateRecords[index]);return value?{id,value}:null}); - return{ids,metaRows,stateRows,tombstoneRows,metaById:new Map(ids.map((id,index)=>[id,metaRows[index]||null])),tombstoneById:new Map(ids.map((id,index)=>[id,tombstoneRows[index]||null])),global:world?.global,worldEpoch:control?.activeEpoch||world?.epoch||null}; -} -function mergeGlobalFields(external){ - if(!external)return;const current=globalForStorage(data,data.updatedAt||data.clockFloor||0),merged=mergeGlobalRecords(current,external);applyGlobalRecordToData(merged); -} -function preserveBoardEditingBeforeSync(id,{meta=false}={}){ - const board=rendered.get(id);if(!board?.drawing)return false; - const pointerId=board.drawing.pointerId;board.drawing=null;board.armedGate=null;cancelBoardDragFrame(board);clearDragRender(board); - if(pointerId!=null)safeRelease(board.svg,pointerId);else queueMicrotask(reconcileDrawingPresentation); - const state=data.states[id];if(state){state.rev=nextRevision();markStateDirty(id)} - if(meta&&data.metas[id]){data.metas[id].rev=nextRevision();markMetaDirty(id)} - renderBoard(board);return true; -} -async function applyWorldSignal(signal){ - if(!signal||signal.sessionId===sessionId)return; - if(signal.type==='world-replaced'||validWorldEpoch(signal.worldEpoch)&&validWorldEpoch(data.worldEpoch)&&signal.worldEpoch!==data.worldEpoch){lifecyclePersistenceSuppressed=true;rememberWorldEpoch(signal.worldEpoch);setTimeout(()=>location.reload(),50);return} - await waitForInteractionSettle(); - const metaOrDelete=new Set([...(signal.metaIds||[]),...(signal.deleted||[])]),affected=new Set([...metaOrDelete,...(signal.stateIds||[])]); - for(const id of affected)preserveBoardEditingBeforeSync(id,{meta:metaOrDelete.has(id)}); - const delta=await loadDbChanges(signal);if(!delta)return;if(validWorldEpoch(delta.worldEpoch)&&delta.worldEpoch!==data.worldEpoch){lifecyclePersistenceSuppressed=true;rememberWorldEpoch(delta.worldEpoch);setTimeout(()=>location.reload(),50);return} - for(const id of affected)preserveBoardEditingBeforeSync(id,{meta:metaOrDelete.has(id)});mergeGlobalFields(delta.global); - const added=[],geometryCompatible=new Map();for(const row of delta.metaRows.filter(Boolean)){const incoming=normalizeMeta(row.id,row);if(!incoming)continue;const current=data.metas[row.id];geometryCompatible.set(row.id,!current||sameMetaGeometry(current,incoming));if(!current||compareRevisionVersions(incoming,current)>0){destroyBoard(rendered.get(row.id));data.metas[row.id]=incoming;added.push(row.id)}} - for(const row of delta.stateRows.filter(Boolean)){if(!data.metas[row.id])continue;const incoming=normalizeState(row.value),current=data.states[row.id],compatible=!delta.metaById.get(row.id)||geometryCompatible.get(row.id)!==false;if(current?.solved&&!incoming.solved&&compatible){const preserved=preserveSolvedBoardState(current,incoming);preserved.rev=nextRevision();preserved.revAuthor=sessionId;data.states[row.id]=preserved;normalizedStateObjects.add(preserved);rememberStateSignatures(row.id,preserved);markStateDirty(row.id);continue}if(!current||compareRevisionVersions(incoming,current)>0){data.states[row.id]=incoming;normalizedStateObjects.add(incoming);const board=rendered.get(row.id);if(board)board.solvedPathsRendered=false;rememberStateSignatures(row.id,incoming)}} - for(const id of signal.deleted||[]){ - const tombstone=delta.tombstoneById.get(id);if(delta.metaById.get(id)||!tombstone||compareRevisionVersions(tombstone,data.metas[id])<0||compareRevisionVersions(tombstone,data.states[id])<0||dirtyMetaIds.has(id)||dirtyStateIds.has(id))continue; - delete data.metas[id];delete data.states[id];stateStatSignatures.delete(id);stateEconomySignatures.delete(id);destroyBoard(rendered.get(id));if(activeBoard===id)activeBoard=null;if(hudBoardId===id)hudBoardId=null; - } - refreshWorldView({rebuild:true,syncConnections:true,markStats:true,resumeTimer:true,persist:true}); - for(const id of added)if(visibleMetaIds().has(id))try{await hydrateMeta(data.metas[id])}catch(error){data.quarantine[id]={failedAt:trustedNow(),message:String(error?.message||error),retries:(data.quarantine[id]?.retries||0)+1}} -} -function queueWorldSignal(signal){ - if(!signal||signal.sessionId===sessionId)return; - if(!worldInitReady){deferredWorldSignals.push(signal);if(deferredWorldSignals.length>256)deferredWorldSignals.shift();return} - const id=worldCommitId(signal);if(id&&(seenWorldCommitIds.has(id)||pendingWorldCommitIds.has(id)))return;if(id)pendingWorldCommitIds.add(id); - const run=async()=>{try{await applyWorldSignal(signal);rememberWorldCommit(signal)}catch(error){if((signal._retryCount||0)<3)setTimeout(()=>queueWorldSignal({...signal,_retryCount:(signal._retryCount||0)+1}),250*2**(signal._retryCount||0));else console.warn('BEND FIELD: cross-tab sync failed',error)}finally{if(id)pendingWorldCommitIds.delete(id)}}; - syncQueue=syncQueue.then(run,run); -} -function drainWorldSignals(){worldInitReady=true;const pending=deferredWorldSignals;deferredWorldSignals=[];for(const signal of pending)queueWorldSignal(signal)} -worldChannel?.addEventListener('message',event=>queueWorldSignal(event.data)); -window.addEventListener('storage',event=>{ - if(event.key!==syncStorageKey||!event.newValue)return;try{queueWorldSignal(JSON.parse(event.newValue))}catch(error){console.warn('BEND FIELD: ignored malformed sync signal',error)} -}); +document.querySelector('#retryBtn').onclick=()=>{void runStatusRetry()}; +function announceWorldReplacement(){} function checkpointForLifecycle(){if(lifecyclePersistenceSuppressed)return;writeDirtyRecoveryJournal();void flushSave({lifecycle:true})} window.addEventListener('pagehide',checkpointForLifecycle); -document.addEventListener('visibilitychange',()=>{const hidden=document.visibilityState==='hidden';document.body.classList.toggle('effects-paused',hidden);if(hidden){cancelReactionGesture();hideRealtimeCursor();clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;pauseNoiseBackground();checkpointForLifecycle();void pushCloudPending()}else{scheduleNoiseBackground(true);resumeTimeAttackTimer();connectRealtime();scheduleRealtimeViewport(true);scheduleRealtimeHeartbeat();schedulePresenceRender(true);scheduleReactionRender();void pullCloudWorld();scheduleMirrorCheckpoint()}}); +window.addEventListener('pagehide',()=>{skipCompletionVisuals();cleanupGemEffects();cancelReactionRenderScheduler(true);stopAuroraRgbAnimation()}); +document.addEventListener('visibilitychange',()=>{const hidden=document.visibilityState==='hidden';document.body.classList.toggle('effects-paused',hidden);if(hidden){cancelReactionGesture();hideRealtimeCursor();cancelReactionRenderScheduler(true);stopAuroraRgbAnimation();clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;clearTimeout(realtimePollTimer);realtimePollTimer=0;pauseNoiseBackground();checkpointForLifecycle();void pushCloudPending()}else{scheduleNoiseBackground(true);resumeTimeAttackTimer();connectRealtime();scheduleRealtimeViewport(true);scheduleRealtimeHeartbeat();schedulePresenceRender(true);updateAuroraAnimationState();scheduleReactionRender();void pullCloudWorld();scheduleMirrorCheckpoint()}}); const cloudBtn=document.querySelector('#cloudBtn'); function emptyCloudPending(){return{metaIds:new Set(),stateIds:new Set(),deleted:new Set(),globalChanged:false}} let cloudAvailable=false,cloudPushTimer=null,cloudPushPending=emptyCloudPending(),cloudSyncing=false,cloudCheckpointRetryTimer=0,worldPollTimer=0,lastCloudWorldGlobalSignature=''; -const remotePlayers=new Map(),boardClaims=new Map(),realtimeClaimRequests=new Map(),remoteCursorImageCache=new Map(),realtimeHeldPointers=new Set(),realtimeReactions=new Map(); -let realtimeSocket=null,realtimeReady=false,realtimePresenceId=null,realtimeClaimTtlMs=5*60*1000,realtimeReconnectTimer=0,realtimeViewportTimer=0,realtimeCursorTimer=0,realtimeHeartbeatTimer=0,realtimeLastCursorSentAt=0,realtimeLastCursorSampleAt=0,realtimePendingCursor=null,realtimePendingCursorClient=null,realtimeRequestSequence=0,realtimeOwnClaimBoardId=null,realtimeLastClaimTouchAt=0,presenceFrame=0,presenceDelayTimer=0,presenceLastDraw=0,presenceDirty=true,presenceLastTimestamp=0,presenceCameraSnapshot=null,reactionFrame=0,reactionDelayTimer=0,reactionLastDraw=0,reactionDirty=true,reactionCameraSnapshot=null,reactionGesture=null,reactionSequence=0; -function realtimeWebSocketUrl(){const protocol=location.protocol==='https:'?'wss:':'ws:';return`${protocol}//${location.host}/api/realtime`} -function realtimeSend(message){if(!realtimeReady||realtimeSocket?.readyState!==WebSocket.OPEN)return false;try{realtimeSocket.send(JSON.stringify(message));return true}catch(_){return false}} +const remotePlayers=new Map(),boardClaims=new Map(),realtimeClaimRequests=new Map(),remoteCursorImageCache=new Map(),realtimeHeldPointers=new Set(),realtimeReactions=new Map(),pendingRealtimeReactionMessages=[]; +let realtimeSocket=null,realtimeReady=false,realtimePresenceId=null,realtimeClaimTtlMs=5*60*1000,realtimeReconnectTimer=0,realtimeViewportTimer=0,realtimeCursorTimer=0,realtimeHeartbeatTimer=0,realtimePollTimer=0,realtimePollSequence=0,realtimeTransport='none',realtimeHttpConnecting=false,realtimeHttpQueue=Promise.resolve(),realtimeLastCursorSentAt=0,realtimeLastCursorSampleAt=0,realtimePendingCursor=null,realtimePendingCursorClient=null,realtimeRequestSequence=0,realtimeOwnClaimBoardId=null,realtimeLastClaimTouchAt=0,presenceFrame=0,presenceDelayTimer=0,presenceLastDraw=0,presenceDirty=true,presenceLastTimestamp=0,presenceCameraSnapshot=null,reactionFrame=0,reactionDelayTimer=0,reactionWatchdogTimer=0,reactionCalibrationFrame=0,reactionVsyncInterval=1000/60,reactionVsyncCalibrated=false,reactionLastDraw=0,reactionLastMetricFrame=0,reactionDirty=true,reactionCameraSnapshot=null,reactionGesture=null,reactionSequence=0; +function realtimeWebSocketUrl(){const endpoint=new URL(cloudEndpointUrl('/api/realtime'));endpoint.protocol=endpoint.protocol==='https:'?'wss:':'ws:';return endpoint.href} +function enqueueRealtimeHttp(task){const run=realtimeHttpQueue.then(task,task);realtimeHttpQueue=run.then(()=>undefined,()=>undefined);return run} +function applyRealtimeHttpEnvelope(envelope){if(!envelope||typeof envelope!=='object')return false;if(Number.isFinite(envelope.serverTime))serverClockOffset=envelope.serverTime-Date.now();if(Number.isFinite(envelope.sequence))realtimePollSequence=Math.max(realtimePollSequence,Number(envelope.sequence));for(const message of Array.isArray(envelope.messages)?envelope.messages:[])handleRealtimeMessage(message);return true} +function scheduleRealtimeHttpPoll(delay=450){clearTimeout(realtimePollTimer);realtimePollTimer=0;if(realtimeTransport!=='http-poll'||!realtimePresenceId||document.visibilityState==='hidden')return false;realtimePollTimer=setTimeout(()=>{realtimePollTimer=0;void enqueueRealtimeHttp(async()=>{try{const query=new URLSearchParams({presenceId:realtimePresenceId,after:String(realtimePollSequence)}),result=await fetchJson(`/api/realtime/poll?${query}`,{headers:cloudAuthHeaders()},5000);applyRealtimeHttpEnvelope(result);scheduleRealtimeHttpPoll(350)}catch(error){if(error?.status===410){realtimeReady=false;realtimePresenceId=null;realtimeTransport='none'}scheduleRealtimeReconnect(1200)}})},Math.max(100,delay));return true} +function realtimeSend(message){ + if(!realtimeReady)return false; + if(realtimeTransport==='http-poll'){ + const presenceId=realtimePresenceId;if(!presenceId)return false; + void enqueueRealtimeHttp(async()=>{try{const result=await fetchJson('/api/realtime/send',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({presenceId,message,afterSequence:realtimePollSequence})},5000);applyRealtimeHttpEnvelope(result);scheduleRealtimeHttpPoll(250)}catch(error){if(error?.status===410){realtimeReady=false;realtimePresenceId=null;realtimeTransport='none'}scheduleRealtimeReconnect(1200)}});return true; + } + if(realtimeSocket?.readyState!==WebSocket.OPEN)return false;try{realtimeSocket.send(JSON.stringify(message));return true}catch(_){return false} +} +function disconnectRealtimeForLifecycle(){ + const presenceId=realtimePresenceId,transport=realtimeTransport,socket=realtimeSocket; + realtimePresenceId=null;realtimeReady=false;realtimeTransport='none';realtimeSocket=null; + if(transport==='http-poll'&&presenceId&&data.cloudProfile){ + try{void fetch(cloudEndpointUrl('/api/realtime/disconnect'),{method:'POST',headers:{...cloudAuthHeaders(),'content-type':'application/json'},body:JSON.stringify({presenceId}),keepalive:true,cache:'no-store'})}catch(_){} + }else if(socket)try{socket.close(1000,'Page closed')}catch(_){} +} +window.addEventListener('pagehide',disconnectRealtimeForLifecycle); +function sendOrQueueRealtimeReaction(reaction){ + if(!reaction||reaction.expiresAt<=Date.now())return false;const message={type:'reaction',id:reaction.id,emoji:reaction.emoji,style:reaction.style,x:reaction.x,y:reaction.y}; + if(realtimeSend(message))return true;if(!pendingRealtimeReactionMessages.some(entry=>entry.id===reaction.id))pendingRealtimeReactionMessages.push({...message,expiresAt:reaction.expiresAt});while(pendingRealtimeReactionMessages.length>12)pendingRealtimeReactionMessages.shift();connectRealtime();return false; +} +function flushPendingRealtimeReactions(){ + if(!realtimeReady||!pendingRealtimeReactionMessages.length)return 0;const now=Date.now(),pending=pendingRealtimeReactionMessages.splice(0),remaining=[];let sent=0; + for(const message of pending){if(message.expiresAt<=now)continue;const{expiresAt,...payload}=message;if(realtimeSend(payload))sent++;else remaining.push(message)}pendingRealtimeReactionMessages.push(...remaining);return sent; +} function realtimeViewportBounds(){const rect=getViewportRect(),topLeft=worldUnitAtClient(rect.left,rect.top),bottomRight=worldUnitAtClient(rect.right,rect.bottom);return{minX:Math.min(topLeft[0],bottomRight[0]),minY:Math.min(topLeft[1],bottomRight[1]),maxX:Math.max(topLeft[0],bottomRight[0]),maxY:Math.max(topLeft[1],bottomRight[1])}} function sendRealtimeViewport(){realtimeViewportTimer=0;if(!realtimeReady)return false;const bounds=realtimeViewportBounds();return realtimeSend({type:'viewport',...bounds})} function scheduleRealtimeViewport(immediate=false){if(!realtimeReady)return false;if(immediate){clearTimeout(realtimeViewportTimer);realtimeViewportTimer=0;return sendRealtimeViewport()}if(realtimeViewportTimer)return true;realtimeViewportTimer=setTimeout(sendRealtimeViewport,REALTIME_VIEWPORT_INTERVAL);return true} @@ -5817,36 +6041,259 @@ function applyRemotePlayer(raw){ const vx=Number.isFinite(raw.vx)?raw.vx:0,vy=Number.isFinite(raw.vy)?raw.vy:0,lead=.035,previous=remotePlayers.get(raw.presenceId)||{currentX:raw.x,currentY:raw.y}; previous.presenceId=raw.presenceId;previous.playerId=raw.playerId;previous.name=String(raw.name||'旅人').slice(0,24);previous.cursorStyle=String(raw.cursorStyle||'default');previous.targetX=raw.x+vx*lead;previous.targetY=raw.y+vy*lead;previous.vx=vx;previous.vy=vy;previous.sentAt=Number(raw.sentAt)||0;previous.lastSeen=Date.now();remotePlayers.set(raw.presenceId,previous);schedulePresenceRender(true);scheduleMinimap(true);return true; } -function normalizeRealtimeReaction(raw){const emoji=String(raw?.emoji||''),x=Number(raw?.x),y=Number(raw?.y),createdAt=Number(raw?.createdAt),expiresAt=Number(raw?.expiresAt),id=typeof raw?.id==='string'?raw.id.slice(0,80):'';if(!id||!REACTION_EMOJIS.includes(emoji)||!Number.isFinite(x)||!Number.isFinite(y)||!Number.isFinite(createdAt)||!Number.isFinite(expiresAt)||expiresAt<=trustedNow())return null;return{id,emoji,x,y,createdAt,expiresAt,playerId:raw.playerId||null,playerName:String(raw.playerName||'').slice(0,24)}} -function applyRealtimeReaction(raw){const reaction=normalizeRealtimeReaction(raw);if(!reaction)return false;realtimeReactions.set(reaction.id,reaction);reactionDirty=true;scheduleReactionRender();return true} -function scheduleReactionRender(markDirty=false){if(markDirty)reactionDirty=true;if(!reactionCanvas||reactionFrame||(!reactionDirty&&!realtimeReactions.size))return;const step=timestamp=>{if(!reactionFrame)return;if(timestamp-reactionLastDraw.84?(1-life)/.16:1,scale=.45+.55*(1-Math.pow(1-appear,3)),x=(reaction.x-renderOriginX)*UNIT*cam.scale+cam.x,y=(reaction.y-renderOriginY)*UNIT*cam.scale+cam.y-18*life;if(x<-60||y<-80||x>width+60||y>height+60)continue;context.save();context.globalAlpha=Math.max(0,fade);context.translate(x,y);context.scale(scale,scale);context.font='34px "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif';context.textAlign='center';context.textBaseline='middle';context.shadowColor='rgba(0,0,0,.58)';context.shadowBlur=5;context.fillText(reaction.emoji,0,0);context.restore()}if(active)scheduleReactionRender()} -function publishReactionAt(emoji,x,y){if(!REACTION_EMOJIS.includes(emoji)||!Number.isFinite(x)||!Number.isFinite(y))return false;data.lastReaction=emoji;markGlobalDirty(false);const timestamp=Date.now(),reaction={id:`local-${sessionId}-${++reactionSequence}`,emoji,x,y,createdAt:timestamp,expiresAt:timestamp+REALTIME_REACTION_DURATION,playerId:currentPlayerId(),playerName:currentPlayerName()};applyRealtimeReaction(reaction);if(realtimeReady)realtimeSend({type:'reaction',id:reaction.id,emoji,x,y});return true} +function reactionSeed(id,index=0){let value=0x811c9dc5;for(const character of String(id||''))value=Math.imul(value^character.charCodeAt(0),0x01000193);return hash32(value^Math.imul(index+1,0x9e3779b1))} +function reactionUnit(seed,index=0){return(reactionSeed(seed,index)>>>0)/0xffffffff} +function reactionDurationForStyle(style){return style==='comet'?3000:style==='firework'?3400:style==='orbit'?3200:style==='giant'||style==='laser'?2700:1050} +const REACTION_GLYPH_CACHE_MAX_ENTRIES=160,REACTION_GLYPH_CACHE_MAX_BYTES=12*1024*1024,REACTION_STATIC_PATH_CACHE_MAX_ENTRIES=32, + REACTION_FONT='"Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif', + REACTION_LASER_COLORS=Object.freeze(['#55efff','#ff4fd8','#fff56b','#8dff76','#9d75ff','#ff6b53']), + REACTION_ORBIT_NEBULA_PATH_BUILDER=()=>{const path=new Path2D();path.arc(0,0,250,0,Math.PI*2);return path}, + REACTION_ORBIT_RING_PATH_BUILDERS=Object.freeze(Array.from({length:5},(_,ring)=>()=>{const path=new Path2D();path.ellipse(0,0,118+ring*15,37+ring*8,0,0,Math.PI*2);return path})), + REACTION_FIREWORK_LAUNCH_PATH_BUILDERS=Object.freeze(Array.from({length:12},(_,index)=>()=>{const p=index/12,path=new Path2D();path.arc(Math.sin(index*2.1)*5,index*12,2.5+(1-p)*2.5,0,Math.PI*2);return path})); +const reactionGlyphCache=new Map(),reactionStaticPathCache=new Map(); +let reactionGlyphCacheBytes=0,reactionGlyphCacheBypass=0,reactionStaticPathCacheBypass=0,reactionNextDrawAt=0; +function updateReactionCacheGauges(){ + perfGauge('reactionGlyphCacheEntries',reactionGlyphCache.size);perfGauge('reactionGlyphCacheBytes',reactionGlyphCacheBytes);perfGauge('reactionStaticPathCacheEntries',reactionStaticPathCache.size); +} +function clearReactionGlyphCache(){ + reactionGlyphCache.clear();reactionGlyphCacheBytes=0;updateReactionCacheGauges(); +} +function clearReactionStaticPathCache(){reactionStaticPathCache.clear();updateReactionCacheGauges()} +function clearReactionEffectCaches(){clearReactionGlyphCache();clearReactionStaticPathCache()} +function reactionStaticPath(key,builder){ + if(reactionStaticPathCacheBypass||typeof Path2D!=='function')return null;const cached=reactionStaticPathCache.get(key); + if(cached){reactionStaticPathCache.delete(key);reactionStaticPathCache.set(key,cached);perfCount('reactionStaticPathCacheHits');return cached} + perfCount('reactionStaticPathCacheMisses');let path;try{path=builder()}catch(_){return null}reactionStaticPathCache.set(key,path); + while(reactionStaticPathCache.size>REACTION_STATIC_PATH_CACHE_MAX_ENTRIES){const oldestKey=reactionStaticPathCache.keys().next().value;reactionStaticPathCache.delete(oldestKey);perfCount('reactionStaticPathCacheEvictions')} + updateReactionCacheGauges();return path; +} +function reactionOrbitNebulaPath(){return reactionStaticPath('orbit.nebula',REACTION_ORBIT_NEBULA_PATH_BUILDER)} +function reactionOrbitRingPath(ring){return reactionStaticPath(`orbit.ring.${ring}`,REACTION_ORBIT_RING_PATH_BUILDERS[ring])} +function reactionFireworkLaunchPath(index){return reactionStaticPath(`firework.launch.${index}`,REACTION_FIREWORK_LAUNCH_PATH_BUILDERS[index])} +function createReactionGlyphCanvas(width,height){ + if(typeof OffscreenCanvas==='function')return new OffscreenCanvas(width,height); + const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;return canvas; +} +function reactionGlyphCacheKey(context,emoji,size){ + if(typeof context.fillStyle!=='string'||!Number.isFinite(size)||size<=0)return''; + return`${emoji}\u0000${size}\u0000${context.fillStyle}\u0000${globalThis.devicePixelRatio||1}`; +} +function createReactionGlyphSprite(context,emoji,size){ + const extent=Math.max(32,Math.ceil(size*2.1)),dimension=extent+(extent%2), + canvas=createReactionGlyphCanvas(dimension,dimension),spriteContext=canvas.getContext('2d',{alpha:true}); + if(!spriteContext)return null; + spriteContext.translate(dimension/2,dimension/2);spriteContext.font=`${size}px ${REACTION_FONT}`;spriteContext.textAlign='center';spriteContext.textBaseline='middle'; + spriteContext.fillStyle=context.fillStyle;spriteContext.fillText(emoji,0,0); + return{canvas,width:dimension,height:dimension,bytes:dimension*dimension*4}; +} +function reactionGlyphSprite(context,emoji,size){ + if(reactionGlyphCacheBypass)return null; + const key=reactionGlyphCacheKey(context,emoji,size);if(!key)return null; + const cached=reactionGlyphCache.get(key); + if(cached){reactionGlyphCache.delete(key);reactionGlyphCache.set(key,cached);perfCount('reactionGlyphCacheHits');return cached} + perfCount('reactionGlyphCacheMisses'); + const sprite=createReactionGlyphSprite(context,emoji,size);if(!sprite)return null; + reactionGlyphCache.set(key,sprite);reactionGlyphCacheBytes+=sprite.bytes; + while(reactionGlyphCache.size>REACTION_GLYPH_CACHE_MAX_ENTRIES||reactionGlyphCacheBytes>REACTION_GLYPH_CACHE_MAX_BYTES){ + const oldestKey=reactionGlyphCache.keys().next().value,oldest=reactionGlyphCache.get(oldestKey);reactionGlyphCache.delete(oldestKey);reactionGlyphCacheBytes=Math.max(0,reactionGlyphCacheBytes-(oldest?.bytes||0));perfCount('reactionGlyphCacheEvictions'); + } + updateReactionCacheGauges();return sprite; +} +function reactionGlyphWarmSizes(style){ + return style==='giant'?[[142,1]]:style==='laser'?[[60,1]]:style==='orbit'?[[24,.78],[27,.78],[30,.78],[33,.78],[112,1]]:style==='firework'?[[21,1],[24,1],[28,1],[38,1]]:style==='comet'?[[52,1],[19,.62],[22,.62],[25,.62],[28,.62]]:[[40,1]]; +} +function warmReactionGlyphCache(emoji,style){ + if(!REACTION_EMOJIS.includes(emoji))return false; + const canvas=createReactionGlyphCanvas(1,1),context=canvas.getContext('2d',{alpha:true});if(!context)return false;for(const[size]of reactionGlyphWarmSizes(style))reactionGlyphSprite(context,emoji,size); + if(style==='orbit'){reactionOrbitNebulaPath();for(let ring=0;ring<5;ring++)reactionOrbitRingPath(ring)}else if(style==='firework')for(let index=0;index<12;index++)reactionFireworkLaunchPath(index);return true; +} +function prewarmReactionGlyphs(emoji,style){ + if(!REACTION_EMOJIS.includes(emoji))return; + const run=()=>warmReactionGlyphCache(emoji,style); + if(typeof requestIdleCallback==='function')requestIdleCallback(run,{timeout:900});else setTimeout(run,250); +} +document.fonts?.addEventListener?.('loadingdone',clearReactionGlyphCache); +function preparedReactionBurst(seed,count){ + const rays=[]; + for(let index=0;index{ + const particles=[];for(let index=0;index.5?1:-1;model.startYJitter=(reactionUnit(seed,3)-.5)*120;model.burst=preparedReactionBurst(seed+99,42);model.trail=[]; + for(let index=18;index>=1;index--)model.trail.push({index,delay:index*.032,size:24+(18-index)*.55,alphaFactor:(1-index/20)*.62,scale:.55+index*.012}); + model.explosion=[];for(let index=0;index<28;index++){const angle=reactionUnit(seed,index+500)*Math.PI*2;model.explosion.push({angle,cos:Math.cos(angle),sin:Math.sin(angle),radiusFactor:36+reactionUnit(seed,index+560)*235,size:19+(index%5)*3})} + } + perfCount('reactionPreparedModels');perfEnd('reactionPrepare',started);return model; +} +function normalizeRealtimeReaction(raw){ + const emoji=String(raw?.emoji||''),x=Number(raw?.x),y=Number(raw?.y),createdAt=Number(raw?.createdAt),expiresAt=Number(raw?.expiresAt),id=typeof raw?.id==='string'?raw.id.slice(0,80):'',style=REACTION_STYLE_IDS.has(raw?.style)?raw.style:'classic'; + if(!id||!REACTION_EMOJIS.includes(emoji)||!Number.isFinite(x)||!Number.isFinite(y)||!Number.isFinite(createdAt)||!Number.isFinite(expiresAt)||expiresAt<=trustedNow())return null; + const reaction={id,emoji,style,x,y,createdAt,expiresAt,playerId:raw.playerId||null,playerName:String(raw.playerName||'').slice(0,24)};reaction.prepared=prepareReactionModel(reaction);return reaction; +} +function applyRealtimeReaction(raw){ + const started=perfStart(),reaction=normalizeRealtimeReaction(raw);if(!reaction)return false; + realtimeReactions.set(reaction.id,reaction);reactionDirty=true;perfGauge('activeReactions',realtimeReactions.size);scheduleReactionRender();perfEnd('reactionPublish',started);return true; +} +function cancelReactionRenderScheduler(resetDeadline=false){ + if(reactionDelayTimer)clearTimeout(reactionDelayTimer);if(reactionWatchdogTimer)clearTimeout(reactionWatchdogTimer);if(reactionFrame)cancelAnimationFrame(reactionFrame);if(reactionCalibrationFrame)cancelAnimationFrame(reactionCalibrationFrame);reactionDelayTimer=0;reactionWatchdogTimer=0;reactionFrame=0;reactionCalibrationFrame=0;if(resetDeadline){reactionNextDrawAt=0;reactionLastDraw=0;reactionLastMetricFrame=0} +} +function commitReactionRender(timestamp,source='raf'){ + if(source==='raf'&&reactionNextDrawAt&×tamp+1{reactionCalibrationFrame=0;reactionVsyncInterval=Math.max(4,Math.min(20,calibrationTimestamp-timestamp));reactionVsyncCalibrated=true;perfCount('reactionCalibrationRafCallbacks');scheduleReactionRender()}); + }else{let advances=0;do{reactionNextDrawAt+=REACTION_FRAME_INTERVAL;advances++}while(reactionNextDrawAt<=timestamp+1);if(advances>1)perfCount('reactionSkippedDeadlines',advances-1)} + markVisualFrame(timestamp);drawReactionLayer(timestamp);return true; +} +function scheduleReactionRender(markDirty=false){ + if(markDirty)reactionDirty=true; + if(!reactionCanvas||document.visibilityState==='hidden'||reactionFrame||reactionDelayTimer||reactionCalibrationFrame||(!reactionDirty&&!realtimeReactions.size))return; + const now=perfNow();if(!reactionNextDrawAt)reactionNextDrawAt=now;const wait=reactionNextDrawAt-now; + const timerDelay=wait-reactionVsyncInterval+1.5; + if(timerDelay>2){ + reactionDelayTimer=setTimeout(()=>{reactionDelayTimer=0;perfCount('reactionDeadlineTimerCallbacks');scheduleReactionRender()},timerDelay);return; + } + reactionFrame=requestAnimationFrame(timestamp=>{reactionFrame=0;if(reactionWatchdogTimer)clearTimeout(reactionWatchdogTimer);reactionWatchdogTimer=0;perfCount('reactionDrawRafCallbacks');if(document.visibilityState==='hidden')return;commitReactionRender(timestamp,'raf')}); + if(reactionLastDraw){ + const watchdogDelay=Math.max(2,reactionNextDrawAt-perfNow()+3); + reactionWatchdogTimer=setTimeout(()=>{reactionWatchdogTimer=0;if(!reactionFrame)return;cancelAnimationFrame(reactionFrame);reactionFrame=0;perfCount('reactionWatchdogCallbacks');if(document.visibilityState!=='hidden')commitReactionRender(perfNow(),'watchdog')},watchdogDelay); + } +} +function drawReactionEmoji(context,emoji,x,y,size,alpha=1,rotation=0,scale=1){ + perfCount('reactionEmojiDraws');context.save();context.globalAlpha*=Math.max(0,alpha);context.translate(x,y);context.rotate(rotation);context.scale(scale,scale); + context.shadowColor='rgba(0,0,0,.7)';context.shadowBlur=Math.max(4,size*.14);const sprite=rotation===0&&scale===1?reactionGlyphSprite(context,emoji,size):null; + if(sprite)context.drawImage(sprite.canvas,-sprite.width/2,-sprite.height/2); + else{context.font=`${size}px ${REACTION_FONT}`;context.textAlign='center';context.textBaseline='middle';context.fillText(emoji,0,0)} + context.restore(); +} +function beginReactionEmojiBatch(context){context.save();context.shadowColor='rgba(0,0,0,.7)';context.textAlign='center';context.textBaseline='middle';return{transform:context.getTransform(),alpha:context.globalAlpha,fontSize:0}} +function drawReactionEmojiBatched(context,batch,emoji,sprite,x,y,size,alpha=1,rotation=0,scale=1){ + perfCount('reactionEmojiDraws');context.setTransform(batch.transform);context.globalAlpha=batch.alpha*Math.max(0,alpha);context.translate(x,y);context.rotate(rotation);context.scale(scale,scale);context.shadowBlur=Math.max(4,size*.14); + if(sprite&&rotation===0&&scale===1)context.drawImage(sprite.canvas,-sprite.width/2,-sprite.height/2); + else{if(batch.fontSize!==size){context.font=`${size}px ${REACTION_FONT}`;batch.fontSize=size}context.fillText(emoji,0,0)} +} +function endReactionEmojiBatch(context){context.restore()} +function drawReactionFlash(context,radius,alpha=.8){perfCount('reactionGradients');const gradient=context.createRadialGradient(0,0,0,0,0,radius);gradient.addColorStop(0,`rgba(255,255,255,${alpha})`);gradient.addColorStop(.18,`rgba(255,240,170,${alpha*.75})`);gradient.addColorStop(.55,`rgba(255,90,210,${alpha*.28})`);gradient.addColorStop(1,'rgba(70,190,255,0)');context.save();context.globalCompositeOperation='screen';context.fillStyle=gradient;context.beginPath();context.arc(0,0,radius,0,Math.PI*2);context.fill();context.restore();perfCount('reactionPaths')} +function drawReactionBurst(context,rays,progress,maxRadius=120,alpha=1){const eased=1-Math.pow(1-Math.max(0,Math.min(1,progress)),3);context.save();context.globalCompositeOperation='screen';context.lineCap='round';for(const ray of rays){const radius=maxRadius*ray.radiusFactor*eased,inner=Math.max(4,radius-ray.innerOffset);context.strokeStyle=`hsla(${ray.hue},100%,72%,${alpha*(1-progress)})`;context.lineWidth=ray.lineWidth;context.beginPath();context.moveTo(ray.cos*inner,ray.sin*inner);context.lineTo(ray.cos*radius,ray.sin*radius);context.stroke()}context.restore();perfCount('reactionPaths',rays.length)} +function drawCrackedGround(context,branches,progress){const reveal=Math.min(1,progress*4),alpha=(1-progress*.55)*reveal;context.save();context.globalCompositeOperation='screen';context.lineCap='round';context.shadowColor='rgba(140,220,255,.75)';context.shadowBlur=7;for(const branch of branches){let x=branch.x,y=branch.y;context.beginPath();context.moveTo(x,y);for(const segment of branch.segments){x+=segment.dx*reveal;y+=segment.dy*reveal;context.lineTo(x,y)}context.strokeStyle=`rgba(205,245,255,${alpha*branch.alpha})`;context.lineWidth=branch.lineWidth;context.stroke()}context.restore();perfCount('reactionPaths',branches.length)} +function drawGiantReaction(context,emoji,life,model){const impact=Math.min(1,life/.2),impactEase=1-Math.pow(1-impact,4),after=Math.max(0,(life-.16)/.84),shake=(1-after)*Math.sin(after*96)*13;context.save();context.translate(Math.sin(after*137)*shake,Math.cos(after*113)*shake*.62);drawCrackedGround(context,model.cracks,after);if(life>.12)drawReactionFlash(context,190*(1-after)+70,Math.max(0,.72-after*.8));for(let ring=0;ring<3;ring++){const p=Math.max(0,Math.min(1,(after-ring*.07)*1.45));context.save();context.globalCompositeOperation='screen';context.strokeStyle=`rgba(${ring===1?'255,95,220':'115,225,255'},${(1-p)*.62})`;context.lineWidth=8-2*ring;context.beginPath();context.ellipse(0,24,30+230*p,16+105*p,0,0,Math.PI*2);context.stroke();context.restore()}perfCount('reactionPaths',3);const squash=1+Math.sin(Math.min(1,impact)*Math.PI)*.2;drawReactionEmoji(context,emoji,0,38-78*impactEase,142,1,0,(.18+.92*impactEase)*squash);context.restore()} +function drawLaserReaction(context,emoji,life,model){const attack=Math.min(1,life/.1),fade=Math.max(0,1-Math.max(0,life-.78)/.22),spin=life*Math.PI*7,positiveAngle=spin*.7,negativeAngle=spin*-.45,positiveCos=Math.cos(positiveAngle),positiveSin=Math.sin(positiveAngle),negativeCos=Math.cos(negativeAngle),negativeSin=Math.sin(negativeAngle);drawReactionFlash(context,90+25*Math.sin(life*22),.34*fade);context.save();context.globalCompositeOperation='screen';context.lineCap='round';for(const ray of model.rays){const ct=ray.speed>0?positiveCos:negativeCos,st=ray.speed>0?positiveSin:negativeSin,originCos=ray.originCos*ct-ray.originSin*st,originSin=ray.originSin*ct+ray.originCos*st,targetCos=ray.targetCos*ct-ray.targetSin*st,targetSin=ray.targetSin*ct+ray.targetCos*st,x1=originCos*ray.originRadius,y1=originSin*ray.originRadius*.72,x2=x1+targetCos*ray.length,y2=y1+targetSin*ray.length,gradient=context.createLinearGradient(x1,y1,x2,y2);perfCount('reactionGradients');gradient.addColorStop(0,'rgba(255,255,255,.92)');gradient.addColorStop(.12,ray.color);gradient.addColorStop(1,'rgba(80,220,255,0)');context.strokeStyle=gradient;context.lineWidth=(ray.strong?5:2.2)*attack*fade;context.shadowColor=ray.color;context.shadowBlur=ray.strong?16:8;context.beginPath();context.moveTo(x1,y1);context.lineTo(x2,y2);context.stroke()}for(let index=0;index<7;index++){const angle=spin*.6+index*Math.PI*2/7,radius=62+24*Math.sin(life*18+index);context.fillStyle=REACTION_LASER_COLORS[index%REACTION_LASER_COLORS.length];context.beginPath();context.arc(Math.cos(angle)*radius,Math.sin(angle)*radius*.68,3+3*Math.sin(life*24+index)**2,0,Math.PI*2);context.fill()}perfCount('reactionPaths',37);context.restore();drawReactionEmoji(context,emoji,0,0,60,fade,spin*3.8,.65+.4*attack)} +function drawOrbitReaction(context,emoji,life,model){ + const enter=Math.min(1,life/.16),fade=Math.max(0,1-Math.max(0,life-.84)/.16),scale=.35+.65*(1-Math.pow(1-enter,4)),spin=life*Math.PI*3.6,ct=Math.cos(spin),st=Math.sin(spin);context.save();context.scale(scale,scale);perfCount('reactionGradients'); + const nebula=context.createRadialGradient(0,0,18,0,0,250);nebula.addColorStop(0,`rgba(95,175,255,${.24*fade})`);nebula.addColorStop(.48,`rgba(115,70,220,${.14*fade})`);nebula.addColorStop(1,'rgba(20,10,80,0)');context.fillStyle=nebula;const nebulaPath=reactionOrbitNebulaPath();if(nebulaPath)context.fill(nebulaPath);else{context.beginPath();context.arc(0,0,250,0,Math.PI*2);context.fill()} + context.save();context.rotate(-.34);context.globalCompositeOperation='screen';for(let ring=0;ring<5;ring++){context.strokeStyle=ring%2?`rgba(255,210,120,${(.5-ring*.05)*fade})`:`rgba(125,220,255,${(.68-ring*.07)*fade})`;context.lineWidth=ring===2?8:2.5+ring*.7;const ringPath=reactionOrbitRingPath(ring);if(ringPath)context.stroke(ringPath);else{context.beginPath();context.ellipse(0,0,118+ring*15,37+ring*8,0,0,Math.PI*2);context.stroke()}} + const batch=beginReactionEmojiBatch(context);for(const orbiter of model.orbiters){const cos=orbiter.cos*ct-orbiter.sin*st,sin=orbiter.sin*ct+orbiter.cos*st;drawReactionEmojiBatched(context,batch,emoji,null,cos*orbiter.radius,sin*orbiter.radius*.34,orbiter.size,.72*fade,-.15,.78)}endReactionEmojiBatch(context);context.restore(); + for(let index=0;index=1)return;const open=1-Math.pow(1-Math.min(1,progress*1.18),3),fade=Math.max(0,1-Math.max(0,progress-.72)/.28);context.save();context.translate(x,y);context.scale(scale,scale);drawReactionFlash(context,48+85*open,.55*fade);const batch=beginReactionEmojiBatch(context);for(let ringIndex=0;ringIndex0){const shake=(1-explode)*22;context.translate(Math.sin(explode*165)*shake,Math.cos(explode*131)*shake)}if(travel<1){const angle=Math.atan2(-startY,-startX);context.save();context.globalCompositeOperation='screen';const batch=beginReactionEmojiBatch(context);for(const trail of model.trail){const p=Math.max(0,Math.min(1,(travel-trail.delay)/(1-trail.delay))),e=1-Math.pow(1-p,4);drawReactionEmojiBatched(context,batch,emoji,null,startX*(1-e),startY*(1-e),trail.size,alpha*trail.alphaFactor,angle,trail.scale)}endReactionEmojiBatch(context);perfCount('reactionGradients');const gradient=context.createLinearGradient(x,y,startX*(1-Math.max(0,travelEase-.32)),startY*(1-Math.max(0,travelEase-.32)));gradient.addColorStop(0,`rgba(255,255,255,${.95*alpha})`);gradient.addColorStop(.18,`rgba(255,215,80,${.8*alpha})`);gradient.addColorStop(.48,`rgba(255,75,210,${.45*alpha})`);gradient.addColorStop(1,'rgba(70,190,255,0)');context.strokeStyle=gradient;context.lineWidth=18*(1-travel)+5;context.lineCap='round';context.beginPath();context.moveTo(x,y);context.lineTo(x-Math.cos(angle)*(120+220*(1-travel)),y-Math.sin(angle)*(120+220*(1-travel)));context.stroke();perfCount('reactionPaths');context.restore();drawReactionEmoji(context,emoji,x,y,52,alpha,angle,.72+.48*travelEase)}if(explode>0){drawReactionFlash(context,80+210*(1-Math.pow(1-explode,3)),.95*(1-explode));for(let ring=0;ring<3;ring++){const p=Math.max(0,Math.min(1,(explode-ring*.07)*1.22));context.save();context.globalCompositeOperation='screen';context.strokeStyle=`rgba(${ring===1?'255,90,220':'120,230,255'},${(1-p)*.82})`;context.lineWidth=10-ring*2.4;context.beginPath();context.arc(0,0,24+250*p,0,Math.PI*2);context.stroke();context.restore()}perfCount('reactionPaths',3);drawReactionBurst(context,model.burst,explode,280,1);const eased=1-Math.pow(1-explode,3),batch=beginReactionEmojiBatch(context);for(const particle of model.explosion)drawReactionEmojiBatched(context,batch,emoji,null,particle.cos*particle.radiusFactor*eased,particle.sin*particle.radiusFactor*eased,particle.size,(1-explode)*.86,particle.angle,.62);endReactionEmojiBatch(context)}context.restore()} +function drawStyledReaction(context,reaction,life,fade){ + const emoji=reaction.emoji,style=reaction.style||'classic',model=reaction.prepared||(reaction.prepared=prepareReactionModel(reaction)),started=perfStart(); + context.save();context.globalAlpha=Math.max(0,fade); + if(style==='giant')drawGiantReaction(context,emoji,life,model); + else if(style==='laser')drawLaserReaction(context,emoji,life,model); + else if(style==='orbit')drawOrbitReaction(context,emoji,life,model); + else if(style==='firework')drawFireworkReaction(context,emoji,life,model); + else if(style==='comet')drawCometReaction(context,emoji,life,model); + else{const enter=Math.min(1,life/.16),out=Math.max(0,1-Math.max(0,life-.48)/.52),floatY=-18*(1-Math.pow(1-life,1.7));drawReactionEmoji(context,emoji,0,floatY,40,out,0,.72+.28*(1-Math.pow(1-enter,3)))} + context.restore();perfEnd(`reactionStyle.${style}`,started); +} +function drawReactionLayer(){ + if(!reactionCanvas)return;const started=perfStart(),rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),dpr=1,pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr); + if(reactionCanvas.width!==pixelWidth||reactionCanvas.height!==pixelHeight){reactionCanvas.width=pixelWidth;reactionCanvas.height=pixelHeight} + reactionCanvas.style.transform='none';reactionCameraSnapshot=onlineLayerCameraSnapshot();const context=reactionCanvas.getContext('2d',{alpha:true}),compositeStarted=perfStart();context.setTransform(dpr,0,0,dpr,0,0);context.clearRect(0,0,width,height);perfCount('reactionCanvasPixelsCleared',width*height);reactionDirty=false; + const timestamp=trustedNow();let active=0,visible=0; + for(const[id,reaction]of realtimeReactions){ + if(reaction.expiresAt<=timestamp){realtimeReactions.delete(id);perfCount('reactionModelsReleased');continue} + active++;const life=Math.max(0,Math.min(1,(timestamp-reaction.createdAt)/(reaction.expiresAt-reaction.createdAt))),fade=life>.94?(1-life)/.06:1,x=(reaction.x-renderOriginX)*UNIT*cam.scale+cam.x,y=(reaction.y-renderOriginY)*UNIT*cam.scale+cam.y; + if(x<-420||y<-420||x>width+420||y>height+420)continue; + visible++;context.save();context.translate(x,y);drawStyledReaction(context,reaction,life,fade);context.restore(); + } + perfEnd('reactionComposite',compositeStarted);perfGauge('activeReactions',active);perfGauge('visibleReactions',visible);perfGauge('peakVisibleReactions',Math.max(perfGauges.peakVisibleReactions||0,visible));perfGauge('preparedReactionModels',realtimeReactions.size);perfGauge('reactionOverload',active>8?active:0);perfCount('reactionFrames');perfEnd('reactionFrame',started); + if(active)scheduleReactionRender();else reactionNextDrawAt=0; +} +function renderReactionSample({style='classic',emoji='😀',life=.5,width=900,height=700,glyphCache=true,pathCache=true}={}){ + const normalizedStyle=REACTION_STYLE_IDS.has(style)?style:'classic',normalizedEmoji=REACTION_EMOJIS.includes(emoji)?emoji:REACTION_EMOJIS[0],normalizedLife=Math.max(0,Math.min(1,Number(life)||0)),canvas=document.createElement('canvas');canvas.width=Math.max(1,Math.round(width));canvas.height=Math.max(1,Math.round(height)); + const context=canvas.getContext('2d',{alpha:true}),reaction={id:`sample-${normalizedStyle}`,emoji:normalizedEmoji,style:normalizedStyle,x:0,y:0,createdAt:0,expiresAt:reactionDurationForStyle(normalizedStyle)};reaction.prepared=prepareReactionModel(reaction);context.translate(canvas.width/2,canvas.height/2); + if(!glyphCache)reactionGlyphCacheBypass++;if(!pathCache)reactionStaticPathCacheBypass++;try{drawStyledReaction(context,reaction,normalizedLife,normalizedLife>.94?(1-normalizedLife)/.06:1)}finally{if(!glyphCache)reactionGlyphCacheBypass--;if(!pathCache)reactionStaticPathCacheBypass--} + return{style:normalizedStyle,life:normalizedLife,width:canvas.width,height:canvas.height,dataUrl:canvas.toDataURL('image/png')}; +} +let activeLocalSpecialReactionUntil=0; +function specialReactionInputLocked(){return data?.reactionStyle!=='classic'&&Date.now(){const button=document.createElement('button'),angle=-Math.PI/2+index*Math.PI*2/REACTION_EMOJIS.length;button.type='button';button.textContent=emoji;button.setAttribute('role','menuitem');button.style.setProperty('--rx',`${Math.cos(angle)*48}px`);button.style.setProperty('--ry',`${Math.sin(angle)*48}px`);button.classList.toggle('selected',index===gesture.selected);reactionRadial.append(button)});reactionRadial.style.left=`${gesture.clientX}px`;reactionRadial.style.top=`${gesture.clientY}px`;reactionRadial.hidden=false;document.body.classList.add('reaction-selecting')} -function beginReactionGesture(event){if(event.button!==0||event.pointerType==='touch'&&!event.isPrimary||!reactionAllowedAt(event.clientX,event.clientY,event.target))return;const[x,y]=worldUnitAtClient(event.clientX,event.clientY);reactionGesture={pointerId:event.pointerId,clientX:event.clientX,clientY:event.clientY,x,y,moved:false,menuOpen:false,selected:REACTION_EMOJIS.indexOf(data.lastReaction),timer:setTimeout(openReactionRadial,REACTION_LONG_PRESS_MS)};try{viewport.setPointerCapture(event.pointerId)}catch(_){}} +function beginReactionGesture(event){if(specialReactionInputLocked())return;if(event.button!==0||event.pointerType==='touch'&&!event.isPrimary||!reactionAllowedAt(event.clientX,event.clientY,event.target))return;const[x,y]=worldUnitAtClient(event.clientX,event.clientY);reactionGesture={pointerId:event.pointerId,clientX:event.clientX,clientY:event.clientY,x,y,moved:false,menuOpen:false,selected:REACTION_EMOJIS.indexOf(data.lastReaction),timer:setTimeout(openReactionRadial,REACTION_LONG_PRESS_MS)};try{viewport.setPointerCapture(event.pointerId)}catch(_){}} function moveReactionGesture(event){const gesture=reactionGesture;if(!gesture||gesture.pointerId!==event.pointerId)return;if(gesture.menuOpen){event.preventDefault();event.stopImmediatePropagation();updateReactionRadialSelection(event.clientX,event.clientY);return}if(Math.hypot(event.clientX-gesture.clientX,event.clientY-gesture.clientY)>REACTION_MOVE_CANCEL_PX){gesture.moved=true;clearTimeout(gesture.timer)}} function endReactionGesture(event){const gesture=reactionGesture;if(!gesture||gesture.pointerId!==event.pointerId)return;clearTimeout(gesture.timer);const index=gesture.menuOpen?radialReactionIndex(event.clientX,event.clientY):-1;reactionGesture=null;gestureCoordinator.release(event.pointerId,'reaction');interactionState.clear('reaction');try{if(viewport.hasPointerCapture?.(event.pointerId))viewport.releasePointerCapture(event.pointerId)}catch(_){}if(gesture.menuOpen){event.preventDefault();event.stopImmediatePropagation();touchPoints.delete(event.pointerId);viewport.classList.remove('panning');refreshInteractionState();const emoji=REACTION_EMOJIS[index]||data.lastReaction;hideReactionRadial();publishReactionAt(emoji,gesture.x,gesture.y);return}hideReactionRadial();if(!gesture.moved)publishReactionAt(data.lastReaction||'👍',gesture.x,gesture.y)} function cancelReactionGesture(event=null){const pointerId=reactionGesture?.pointerId;if(reactionGesture?.timer)clearTimeout(reactionGesture.timer);reactionGesture=null;if(pointerId!=null){gestureCoordinator.release(pointerId,'reaction','cancelled');try{if(viewport.hasPointerCapture?.(pointerId))viewport.releasePointerCapture(pointerId)}catch(_){}}interactionState.clear('reaction');hideReactionRadial()} function currentBoardClaim(boardId){const claim=boardClaims.get(boardId);if(claim&&claim.expiresAt<=trustedNow()){boardClaims.delete(boardId);refreshClaimPresentation(boardId);return null}return claim||null} function boardClaimOwnedByMe(boardId){const claim=currentBoardClaim(boardId);return Boolean(claim&&claim.playerId===currentPlayerId())} -function applyClaimPresentationToBoard(board){if(!board)return;const claim=currentBoardClaim(board.id),solved=metaState(board.id).solved,own=Boolean(claim&&claim.playerId===currentPlayerId()),hudVisible=boardPlayHudVisible(board,solved);board.card.classList.toggle('claimed-other',Boolean(claim&&!own&&!solved));board.card.classList.toggle('claimed-own',Boolean(claim&&own&&!solved));setBoardHudVisibility(board,hudVisible);board.label?.classList.toggle('claimed-own',Boolean(claim&&own&&!solved));if(board.claimBadge){board.claimBadge.hidden=!claim||solved;board.claimBadge.textContent=claim?own?'占有中':`${claim.playerName||'他のプレイヤー'}がプレイ中`:''}board.svg?.setAttribute?.('aria-disabled',String(Boolean(claim&&!own&&!solved)))} +function applyClaimPresentationToBoard(board){if(!board)return;const claim=currentBoardClaim(board.id),solved=metaState(board.id).solved,own=Boolean(claim&&claim.playerId===currentPlayerId()),hudVisible=boardPlayHudVisible(board,solved);board.card.classList.toggle('claimed-other',Boolean(claim&&!own&&!solved));board.card.classList.toggle('claimed-own',Boolean(claim&&own&&!solved));setBoardHudVisibility(board,hudVisible);board.label?.classList.toggle('claimed-own',Boolean(claim&&own&&!solved));if(board.claimBadge){board.claimBadge.hidden=!claim||solved;board.claimBadge.textContent=claim?own?'プレイ中':`${claim.playerName||'他のプレイヤー'}がプレイ中`:''}board.svg?.setAttribute?.('aria-disabled',String(Boolean(claim&&!own&&!solved)))} function refreshClaimPresentation(boardId=null){ const ids=boardId?[boardId]:rendered.keys();for(const id of ids){const board=rendered.get(id);if(board)applyClaimPresentationToBoard(board)} } -function applyClaim(raw){if(!raw?.boardId)return;boardClaims.set(raw.boardId,{...raw,expiresAt:Number(raw.expiresAt)||trustedNow()});if(raw.playerId===currentPlayerId())realtimeOwnClaimBoardId=raw.boardId;refreshClaimPresentation(raw.boardId)} -function removeClaim(boardId,reason='released'){const claim=boardClaims.get(boardId);boardClaims.delete(boardId);if(realtimeOwnClaimBoardId===boardId)realtimeOwnClaimBoardId=null;refreshClaimPresentation(boardId);const board=rendered.get(boardId);if(claim?.playerId===currentPlayerId()&&board?.drawing?.pointerId!=null&&reason!=='cleared'){cancelPointerGestures();toast('盤面の占有期限が切れました。')}} +function applyClaim(raw){if(!raw?.boardId)return;if(metaState(raw.boardId)?.solved===true){removeClaim(raw.boardId,'cleared');return}boardClaims.set(raw.boardId,{...raw,expiresAt:Number(raw.expiresAt)||trustedNow()});if(raw.playerId===currentPlayerId())realtimeOwnClaimBoardId=raw.boardId;refreshClaimPresentation(raw.boardId)} +function removeClaim(boardId,reason='released'){const claim=boardClaims.get(boardId);boardClaims.delete(boardId);if(realtimeOwnClaimBoardId===boardId)realtimeOwnClaimBoardId=null;refreshClaimPresentation(boardId);const board=rendered.get(boardId);if(claim?.playerId===currentPlayerId()&&board?.drawing?.pointerId!=null&&reason!=='cleared')cancelPointerGestures()} function applyRealtimeSnapshot(message){ const seenPlayers=new Set();for(const player of message.players||[]){seenPlayers.add(player.presenceId);applyRemotePlayer(player)}for(const id of[...remotePlayers.keys()])if(!seenPlayers.has(id))remotePlayers.delete(id); const seenClaims=new Set();for(const claim of message.claims||[]){seenClaims.add(claim.boardId);applyClaim(claim)}for(const id of[...boardClaims.keys()])if(!seenClaims.has(id))removeClaim(id,'viewport');for(const reaction of message.reactions||[])applyRealtimeReaction(reaction);schedulePresenceRender(true);refreshClaimPresentation(); } -function settleRealtimeClaimResult(message){const pending=realtimeClaimRequests.get(message.requestId);if(!pending)return;if(pending.timer)clearTimeout(pending.timer);realtimeClaimRequests.delete(message.requestId);if(message.claim)applyClaim(message.claim);pending.resolve(message.ok===true)} +function settleRealtimeClaimResult(message){const pending=realtimeClaimRequests.get(message.requestId);if(!pending)return;if(message.claim)applyClaim(message.claim);pending.resolve(message)} function handleRealtimeMessage(message){ if(!message||typeof message!=='object')return; - if(message.type==='ready'){realtimeReady=true;realtimePresenceId=message.presenceId;realtimeClaimTtlMs=Math.max(1000,Number(message.claimTtlMs)||realtimeClaimTtlMs);serverClockOffset=Number.isFinite(message.serverTime)?message.serverTime-Date.now():serverClockOffset;scheduleRealtimeViewport(true);if(realtimePendingCursor)realtimeSend({type:'cursor',...realtimePendingCursor});scheduleRealtimeHeartbeat();return} + if(message.type==='ready'){realtimeReady=true;realtimePresenceId=message.presenceId;setCloudStatus();realtimeClaimTtlMs=Math.max(1000,Number(message.claimTtlMs)||realtimeClaimTtlMs);serverClockOffset=Number.isFinite(message.serverTime)?message.serverTime-Date.now():serverClockOffset;scheduleRealtimeViewport(true);if(realtimePendingCursor)realtimeSend({type:'cursor',...realtimePendingCursor});flushPendingRealtimeReactions();scheduleRealtimeHeartbeat();return} if(message.type==='snapshot')return applyRealtimeSnapshot(message); if(message.type==='cursor')return applyRemotePlayer(message); if(message.type==='player-left'){remotePlayers.delete(message.presenceId);schedulePresenceRender(true);scheduleMinimap(true);return} @@ -5859,31 +6306,86 @@ function handleRealtimeMessage(message){ } function rejectRealtimeClaims(){for(const pending of realtimeClaimRequests.values()){clearTimeout(pending.timer);pending.resolve(false)}realtimeClaimRequests.clear()} function scheduleRealtimeReconnect(delay=1500){clearTimeout(realtimeReconnectTimer);if(!cloudAvailable||!data.cloudProfile||document.visibilityState==='hidden')return;realtimeReconnectTimer=setTimeout(connectRealtime,delay)} +function resetRealtimeConnectionState({clearRemote=true}={}){clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;clearTimeout(realtimePollTimer);realtimePollTimer=0;realtimeReady=false;realtimePresenceId=null;realtimeSocket=null;realtimeTransport='none';setCloudStatus();if(clearRemote){remotePlayers.clear();realtimeReactions.clear();reactionDirty=true;rejectRealtimeClaims();schedulePresenceRender(true);scheduleReactionRender(true);scheduleMinimap(true)}} +function connectRealtimeHttp(){ + if(realtimeHttpConnecting||realtimeTransport==='http-poll'&&realtimePresenceId)return true;realtimeHttpConnecting=true;realtimeTransport='http-poll';realtimeReady=false;realtimePollSequence=0; + void enqueueRealtimeHttp(async()=>{try{const result=await fetchJson('/api/realtime/connect',{method:'POST',headers:cloudAuthHeaders(),body:'{}'},5000);applyRealtimeHttpEnvelope(result);scheduleRealtimeHttpPoll(100)}catch(error){resetRealtimeConnectionState();scheduleRealtimeReconnect(1500)}finally{realtimeHttpConnecting=false}});return true; +} function connectRealtime(){ - clearTimeout(realtimeReconnectTimer);realtimeReconnectTimer=0;if(!cloudApiEnabled||!cloudAvailable||!data.cloudProfile||typeof WebSocket==='undefined')return false;if(realtimeSocket&&[WebSocket.OPEN,WebSocket.CONNECTING].includes(realtimeSocket.readyState))return true; - const socket=new WebSocket(realtimeWebSocketUrl());realtimeSocket=socket;realtimeReady=false; + clearTimeout(realtimeReconnectTimer);realtimeReconnectTimer=0;if(!cloudApiEnabled||!cloudAvailable||!data.cloudProfile)return false; + if(cloudApiUsesPhpBridge()||runtimeConfig.realtimeTransport==='http-poll'||typeof WebSocket==='undefined')return connectRealtimeHttp(); + if(realtimeSocket&&[WebSocket.OPEN,WebSocket.CONNECTING].includes(realtimeSocket.readyState))return true; + const socket=new WebSocket(realtimeWebSocketUrl());realtimeSocket=socket;realtimeTransport='websocket';realtimeReady=false; socket.onopen=()=>{if(socket!==realtimeSocket)return;socket.send(JSON.stringify({type:'hello',playerId:data.cloudProfile.playerId,token:data.cloudProfile.token}))}; socket.onmessage=event=>{if(socket!==realtimeSocket)return;try{handleRealtimeMessage(JSON.parse(event.data))}catch(error){console.warn('BEND FIELD: realtime message failed',error)}}; - socket.onerror=()=>{};socket.onclose=()=>{if(socket!==realtimeSocket)return;clearTimeout(realtimeHeartbeatTimer);realtimeHeartbeatTimer=0;realtimeReady=false;realtimePresenceId=null;realtimeSocket=null;remotePlayers.clear();realtimeReactions.clear();reactionDirty=true;rejectRealtimeClaims();schedulePresenceRender(true);scheduleReactionRender(true);scheduleMinimap(true);scheduleRealtimeReconnect()};return true; + socket.onerror=()=>{};socket.onclose=()=>{if(socket!==realtimeSocket)return;resetRealtimeConnectionState();scheduleRealtimeReconnect()};return true; } -function requestBoardClaim(boardId){ - if(metaState(boardId).solved)return Promise.resolve(false);const existing=currentBoardClaim(boardId);if(existing?.playerId===currentPlayerId()){touchBoardClaim(boardId,true);return Promise.resolve(true)}if(existing){toast(`${existing.playerName||'他のプレイヤー'}がプレイ中です。`);return Promise.resolve(false)} - if(!cloudApiEnabled||!cloudAvailable)return Promise.resolve(true);if(!realtimeReady){connectRealtime();toast('共有接続を確認しています。');return Promise.resolve(false)} - const requestId=`${sessionId}:${++realtimeRequestSequence}`,promise=new Promise(resolve=>{const timer=setTimeout(()=>{realtimeClaimRequests.delete(requestId);resolve(false)},REALTIME_CLAIM_REQUEST_TIMEOUT);realtimeClaimRequests.set(requestId,{resolve,timer,boardId})}); - if(!realtimeSend({type:'claim',requestId,boardId})){const pending=realtimeClaimRequests.get(requestId);clearTimeout(pending?.timer);realtimeClaimRequests.delete(requestId);return Promise.resolve(false)}return promise.then(ok=>{if(!ok){const claim=currentBoardClaim(boardId);toast(claim?`${claim.playerName||'他のプレイヤー'}がプレイ中です。`:'盤面を占有できませんでした。')}return ok}); +const directBoardClaimRequests=new Map(); +function requestBoardClaimThroughRealtime(boardId,timeout=2200){ + if(!realtimeReady)return Promise.resolve(null); + const requestId=`claim-${sessionId}-${++realtimeRequestSequence}`; + return new Promise(resolve=>{ + const finish=result=>{const pending=realtimeClaimRequests.get(requestId);if(!pending)return;if(pending.timer)clearTimeout(pending.timer);realtimeClaimRequests.delete(requestId);resolve(result)}; + const timer=setTimeout(()=>finish(null),timeout);realtimeClaimRequests.set(requestId,{timer,resolve:message=>finish(message)}); + if(!realtimeSend({type:'claim',requestId,boardId}))finish(null); + }); +} +async function requestBoardClaim(boardId){ + if(metaState(boardId)?.solved||!cloudAvailable||!data.cloudProfile)return false; + const existing=currentBoardClaim(boardId),now=trustedNow(); + if(existing?.playerId!==currentPlayerId()&&existing?.expiresAt>now)return false; + if(existing?.playerId===currentPlayerId()&&existing.expiresAt-now>30000)return true; + if(directBoardClaimRequests.has(boardId))return directBoardClaimRequests.get(boardId); + const request=(async()=>{ + try{ + try{ + const result=await fetchJson('/api/realtime/claim',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({boardId,presenceId:realtimePresenceId})},2500); + if(result?.claim)applyClaim(result.claim); + if(result?.ok===true)return true; + if(result?.ok===false)return false; + }catch(error){console.warn('LinkField: direct board claim unavailable; using realtime transport',error)} + const realtimeResult=await requestBoardClaimThroughRealtime(boardId,3000); + if(realtimeResult?.claim)applyClaim(realtimeResult.claim); + return realtimeResult?.ok===true; + }catch(error){console.warn('LinkField: board claim failed',error);return false} + finally{directBoardClaimRequests.delete(boardId)} + })(); + directBoardClaimRequests.set(boardId,request);return request; } async function ensureBoardClaimForInput(board){if(!board||metaState(board.id).solved)return false;return requestBoardClaim(board.id)} function touchBoardClaim(boardId,force=false){if(!boardClaimOwnedByMe(boardId)||!realtimeReady)return false;const now=Date.now();if(!force&&now-realtimeLastClaimTouchAtrealtimeHeldPointers.add(event.pointerId),{passive:true,capture:true}); for(const type of['pointerup','pointercancel'])window.addEventListener(type,event=>realtimeHeldPointers.delete(event.pointerId),{passive:true,capture:true}); window.addEventListener('pointermove',queueRealtimeCursor,{passive:true,capture:true}); -viewport.addEventListener('pointerleave',hideRealtimeCursor,{passive:true}); +const SHARED_PERSONAL_FIELDS=Object.freeze(['cloudProfile','playerName','playerPurchases','playerEarnedScore','starterLineColor','lineColorStyle','lineEffectStyle','reactionStyle','lastReaction','timeAttack','timeAttackRev','timeAttackCooldowns','lastTimeAttack','timeAttackSuggestionsDisabled','cursorStyle','scoreLensEnabled']); +async function activateSingleSharedClientCache(){ + if(!idbAvailable)return true; + const epoch=data?.worldEpoch;if(!validWorldEpoch(epoch))throw new Error('The shared client cache epoch is invalid.'); + const db=await openWorldDb(),updatedAt=trustedNow(),tx=db.transaction(['control','worlds'],'readwrite'),done=transactionDone(tx),controlStore=tx.objectStore('control'),worldsStore=tx.objectStore('worlds'), + current=await requestValue(controlStore.get('active')); + if(current?.activeEpoch&¤t.activeEpoch!==epoch){const previous=await requestValue(worldsStore.get(current.activeEpoch));if(previous){previous.status='garbage';worldsStore.put(previous)}} + const control={key:'active',activeFormat:FIELD_STORAGE_FORMAT,activeEpoch:epoch,activationId:`shared:${sessionId}:${updatedAt}`,activationVerified:true,switchedAt:updatedAt}; + worldsStore.put({epoch,status:'active',schema:SAVE_SCHEMA,worldGeneration:WORLD_GENERATION,global:globalForStorage(data,updatedAt),boardCount:0,solvedCount:0,score:0,bounds:{minX:0,minY:0,maxX:1,maxY:1},approximateBytes:0,createdAt:updatedAt,activatedAt:updatedAt,source:{kind:'single-shared-world'}}); + controlStore.put(control);await done;startupWorldControl=control;rememberWorldEpoch(epoch);return true; +} +function resetClientToSingleSharedWorld(){ + const previous=data||defaultData(),fresh=defaultData(); + for(const field of SHARED_PERSONAL_FIELDS)if(Object.prototype.hasOwnProperty.call(previous,field))fresh[field]=deepClone(previous[field]); + fresh.worldEpoch=createWorldEpoch();fresh.cloudRevision=0;fresh.worldFeedRevision=0;fresh.cloudPending=normalizeCloudPending(null);fresh.cameraAnchor=null;fresh.selectedBoardId=null;data=fresh; + for(const collection of[dirtyMetaIds,dirtyStateIds,deletedBoardIds,cloudJournalMetaIds,cloudJournalStateIds,cloudJournalDeletedIds,cloudOutboxDeleteKeys])collection.clear(); + deletedBoardRevisions.clear();deletedBoardAuthors.clear();stateStatSignatures.clear();stateEconomySignatures.clear();boardIndexSummaries.clear();hydratedBoardLru.clear();adjacencyCache.clear();rendered.clear();boardClaims.clear();remotePlayers.clear();realtimeReactions.clear(); + occupancy=new Map();closedVoidKeys=new Set();activeBoard=null;hudBoardId=null;hydratedBoardBytes=0;cloudJournalGlobalChanged=false;cloudJournalChangeSeq=0;globalDirty=false;globalChangeSeq=0;cloudPushPending=emptyCloudPending();lastCloudWorldGlobalSignature='';fieldIndexComplete=true;fieldIndexExpectedCount=0;fieldIndexLoadedCount=0;fieldIndexAfterNumber=-1;cloudOutboxReady=true;statsDirty=true;cachedStats={solved:0,score:0,earned:0};inventoryCache=null;spentScoreCache=null; + return data; +} +async function waitForRealtimeReady(timeout=7000){ + if(realtimeReady)return true;connectRealtime();const started=Date.now();while(!realtimeReady&&Date.now()-startedsetTimeout(resolve,50));if(!cloudAvailable)break}return realtimeReady; +} function sharedWorldGlobalForCloud(){return{schema:SAVE_SCHEMA,gameplayVersion:GAMEPLAY_DATA_VERSION,worldGeneration:WORLD_GENERATION,appVersion:APP_VERSION,generatorVersion:GENERATOR_VERSION,nextId:data.nextId,solved:data.solved||0,lastSolveAt:data.lastSolveAt||0,specialMechanicsSeen:normalizeSpecialMechanics(data.specialMechanicsSeen),quarantine:data.quarantine||{}}} function sharedWorldGlobalSignature(){return JSON.stringify(sharedWorldGlobalForCloud())} function mergeCloudPending(target,signal={}){ for(const id of signal.metaIds||[]){target.deleted.delete(id);target.metaIds.add(id)} - for(const id of signal.stateIds||[]){if(data?.states?.[id]?.solved!==true)continue;target.deleted.delete(id);target.stateIds.add(id)} + for(const id of signal.stateIds||[]){if(!data?.states?.[id])continue;target.deleted.delete(id);target.stateIds.add(id)} for(const id of signal.deleted||[]){target.metaIds.delete(id);target.stateIds.delete(id);target.deleted.add(id)} target.globalChanged=target.globalChanged||signal.globalChanged===true;return target; } @@ -5905,26 +6407,42 @@ function acknowledgeCloudPending(pending,metaRevs,stateRevs,changeSeq){ data.cloudPending=currentCloudPending();restoreCloudPushPending(); } function armCloudPush(delay=5000){clearTimeout(cloudPushTimer);cloudPushTimer=null;if(!cloudAvailable||!data.cloudProfile||cloudSyncing||!cloudPendingHasWork())return false;cloudPushTimer=setTimeout(()=>{cloudPushTimer=null;void pushCloudPending()},Math.max(0,delay));return true} -async function fetchJson(url,options={},timeout=5000){const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeout);try{const response=await fetch(url,{...options,signal:controller.signal,headers:{'content-type':'application/json',...(options.headers||{})}}),body=await response.json().catch(()=>({}));if(!response.ok){const error=new Error(body.error||`HTTP ${response.status}`);error.status=response.status;error.body=body;throw error}return body}finally{clearTimeout(timer)}} -function cloudAuthHeaders(){return data.cloudProfile?{authorization:`Bearer ${data.cloudProfile.playerId}.${data.cloudProfile.token}`}:{}} +async function fetchJson(url,options={},timeout=5000){ + const statusProbe=url==='/api/cloud/status'&&(!options.method||String(options.method).toUpperCase()==='GET'),bases=statusProbe?[cloudApiBaseUrl,...cloudApiBaseCandidates.filter(candidate=>candidate!==cloudApiBaseUrl)]:[cloudApiBaseUrl];let lastError=null; + for(let index=0;indexcontroller.abort(),timeout); + try{ + const endpoint=cloudEndpointUrl(url,base),response=await fetch(endpoint,{...options,signal:controller.signal,headers:{'content-type':'application/json',...(options.headers||{})}}),body=await response.json().catch(()=>({})); + if(statusProbe&&response.ok&&body?.available===true){cloudApiBaseUrl=base;return body} + if(!response.ok){const error=new Error(body.error||`HTTP ${response.status}`);error.status=response.status;error.body=body;throw error} + if(statusProbe&&index=(prior.boughtAt||0))purchases.set(key,purchase)} - data.playerPurchases=[...purchases.values()];data.playerEarnedScore=Math.max(Number(data.playerEarnedScore)||0,Number.isSafeInteger(player.earnedScore)&&player.earnedScore>=0?player.earnedScore:0);playerEconomyLoaded=true;invalidateEconomyCaches();markGlobalDirty(false);updateHud();if(openStoreBoardId)renderStorePanel();if(inventoryModal?.classList.contains('show'))renderInventoryPanel();return true; + const previousStarter=data.starterLineColor,starter=validStarterLineColorId(player.starterLineColor)?player.starterLineColor:starterLineColorForPlayer(data.cloudProfile?.playerId); + data.playerPurchases=normalizePlayerPurchases(player.purchases);data.playerEarnedScore=Number.isSafeInteger(player.earnedScore)&&player.earnedScore>=0?player.earnedScore:0;data.starterLineColor=starter;if(!validLineColorItemId(data.lineColorStyle)||data.lineColorStyle===previousStarter)data.lineColorStyle=starter; + playerEconomyLoaded=true;invalidateEconomyCaches();syncCosmeticAppearance();markGlobalDirty(false);updateHud();if(openStoreBoardId)renderStorePanel();if(inventoryModal?.classList.contains('show'))renderInventoryPanel();return true; } async function pullPlayerEconomy(){if(!cloudAvailable||!data.cloudProfile)return false;try{const result=await fetchJson('/api/player/state',{headers:cloudAuthHeaders()});serverClockOffset=Number.isFinite(result.serverTime)?result.serverTime-Date.now():serverClockOffset;applyPlayerEconomyEnvelope(result);await persistNow({skipCloud:true});return true}catch(error){playerEconomyLoaded=false;console.warn('BEND FIELD: player economy pull failed',error);return false}} async function buyPersonalStoreItem(boardId,itemId){const result=await fetchJson('/api/player/purchase',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({boardId,itemId})},10000);applyPlayerEconomyEnvelope(result);await persistNow({skipCloud:true});return result.purchase||null} -function setCloudStatus(label,state=''){if(!cloudBtn)return;const name=currentPlayerName();cloudBtn.textContent=state==='saved'&&cloudAvailable?`共有 · ${name}`:label;cloudBtn.dataset.state=state;cloudBtn.title=cloudAvailable?`共有ワールド接続中:${name}。クリックで名前または同期コードを変更`:'共有ワールドを利用できません'} +function setCloudStatus(){if(!cloudBtn)return;const state=!cloudAvailable?'offline':cloudSyncing?'syncing':realtimeReady?'online':'connecting';cloudBtn.dataset.state=state;cloudBtn.title=state==='online'?'共有サーバーに接続済み':state==='offline'?'共有サーバーに接続できません':'共有サーバーへ接続中';cloudBtn.setAttribute('aria-label',cloudBtn.title)} function addClearFeedEvent(event){ - if(!clearFeed||!event||typeof event.playerName!=='string')return;const item=document.createElement('div');item.className='clear-feed-item';const name=document.createElement('b'),details=document.createElement('span');name.textContent=`🎉 ${event.playerName}`;details.textContent=` (${Math.round(event.x||0)}, ${Math.round(event.y||0)}) · Lv.${Math.max(1,Math.round(event.level||1))}`;item.append(name,details);clearFeed.prepend(item);while(clearFeed.children.length>3)clearFeed.lastElementChild?.remove();setTimeout(()=>{item.classList.add('is-leaving');setTimeout(()=>item.remove(),380)},10000) + if(!clearFeed||!event||typeof event.playerName!=='string')return;const item=document.createElement('div');item.className='clear-feed-item';const name=document.createElement('b'),details=document.createElement('span');name.textContent=`🎉 ${event.playerName}`;details.textContent=` (${Math.round(event.x||0)}, ${Math.round(event.y||0)}) · レベル${Math.max(1,Math.round(event.level||1))}`;item.append(name,details);clearFeed.prepend(item);while(clearFeed.children.length>3)clearFeed.lastElementChild?.remove();setTimeout(()=>{item.classList.add('is-leaving');setTimeout(()=>item.remove(),380)},10000) } function applyCloudEnvelope(result,{initial=false}={}){ - let changed=false;if(result?.player?.name&&data.playerName!==result.player.name){data.playerName=String(result.player.name).slice(0,24);changed=true} + let changed=false;if(result?.player?.name&&data.playerName!==result.player.name){data.playerName=String(result.player.name).slice(0,24);changed=true}if(Array.isArray(result?.player?.purchases))applyPlayerEconomyEnvelope(result); const latest=Math.max(0,Number(result?.latestEventRevision)||0),events=Array.isArray(result?.clearEvents)?result.clearEvents:[]; - if(initial&&!(data.worldFeedRevision>0)){data.worldFeedRevision=latest;changed=true}else{for(const event of events)if((event.revision||0)>(data.worldFeedRevision||0))addClearFeedEvent(event);if(latest>(data.worldFeedRevision||0)){data.worldFeedRevision=latest;changed=true}} + for(const event of events)if(event?.id)removeClaim(event.id,'cleared');if(initial&&!(data.worldFeedRevision>0)){data.worldFeedRevision=latest;changed=true}else{for(const event of events)if((event.revision||0)>(data.worldFeedRevision||0))addClearFeedEvent(event);if(latest>(data.worldFeedRevision||0)){data.worldFeedRevision=latest;changed=true}} if(changed)markGlobalDirty(false);return changed; } -async function createCloudProfile(){const result=await fetchJson('/api/cloud/session',{method:'POST',body:JSON.stringify({name:data.playerName||''})});serverClockOffset=result.serverTime-Date.now();data.cloudProfile={playerId:result.playerId,token:result.token};data.playerName=result.name||data.playerName||null;data.cloudRevision=0;markGlobalDirty(false);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world profile could not be committed locally')}return data.cloudProfile} +async function createCloudProfile(){const result=await fetchJson('/api/cloud/session',{method:'POST',body:JSON.stringify({name:data.playerName||''})});serverClockOffset=result.serverTime-Date.now();const previousStarter=data.starterLineColor;data.cloudProfile={playerId:result.playerId,token:result.token};data.playerName=result.name||data.playerName||null;if(typeof validStarterLineColorId==='function'&&validStarterLineColorId(result.starterLineColor)){data.starterLineColor=result.starterLineColor;if(!validLineColorItemId(data.lineColorStyle)||data.lineColorStyle===previousStarter)data.lineColorStyle=result.starterLineColor;if(typeof syncCosmeticAppearance==='function')syncCosmeticAppearance()}data.cloudRevision=0;markGlobalDirty(false);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world profile could not be committed locally')}return data.cloudProfile} async function pullCloudWorld(force=false,sinceOverride=null,{initial=false,authoritative=false}={}){ if(typeof fieldArchiveBusy!=='undefined'&&fieldArchiveBusy)return false;if(!cloudAvailable||!data.cloudProfile||cloudSyncing&&!force)return false; if(interactionActive()||[...rendered.values()].some(board=>board.drawing?.keyboardActive))return false;if(hasPendingPersistence()&&!await flushSave())return false; @@ -5946,11 +6464,11 @@ async function pullCloudWorld(force=false,sinceOverride=null,{initial=false,auth }while(cursor); data.cloudRevision=targetRevision||0;lastCloudWorldGlobalSignature=sharedWorldGlobalSignature(); if(changed){cloudApplyingRemote=true;try{if(fullSnapshot)for(const id of Object.keys(data.metas))if(!remoteIds.has(id)&&(authoritativeWorld||!cloudJournalMetaIds.has(id)&&!cloudJournalStateIds.has(id))){clearSharedWorldJournalRow('meta',id);clearSharedWorldJournalRow('state',id);delete data.metas[id];delete data.states[id];markBoardDeleted(id);destroyBoard(rendered.get(id))}resolveMergedOverlaps();statsDirty=true;markGlobalDirty(false)}finally{cloudApplyingRemote=false}refreshWorldView({rebuild:true,syncConnections:true,persist:false});if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world pull could not be committed locally')}}else if(data.cloudRevision!==previousRevision||envelopeChanged){markGlobalDirty(false);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();throw new Error('Shared-world revision could not be committed locally')}} - setCloudStatus('共有中','saved');pulled=true;return true; - }catch(error){setCloudStatus('共有失敗','error');console.warn('BEND FIELD: shared-world pull failed',error);return false} - finally{cloudSyncing=false;restoreCloudPushPending();if(cloudPendingHasWork())armCloudPush(pulled?250:5000)} + setCloudStatus();pulled=true;return true; + }catch(error){setCloudStatus();console.warn('BEND FIELD: shared-world pull failed',error);return false} + finally{cloudSyncing=false;setCloudStatus();restoreCloudPushPending();if(cloudPendingHasWork())armCloudPush(pulled?250:5000)} } -function scheduleCloudPush(signal={},delay=5000){mergeCloudPending(cloudPushPending,signal);armCloudPush(delay)} +function scheduleCloudPush(signal={},delay=350){mergeCloudPending(cloudPushPending,signal);armCloudPush(delay)} async function pushCloudPending(){ clearTimeout(cloudPushTimer);cloudPushTimer=null;if(typeof fieldArchiveBusy!=='undefined'&&fieldArchiveBusy){armCloudPush(1000);return false}if(!cloudAvailable||!data.cloudProfile||cloudSyncing||!cloudPendingHasWork())return false; const{batch:pending,remainder}=takeCloudPendingBatch(cloudPushPending),profileIdentity=cloudProfileIdentity();cloudPushPending=remainder; @@ -5958,33 +6476,54 @@ async function pushCloudPending(){ cloudSyncing=true;setCloudStatus('共有送信中','syncing');let payload=null,sentMetaRevs=new Map(),sentStateRevs=new Map(),sentCloudChangeSeq=cloudJournalChangeSeq,retryDelay=250; try{ const rows=await cloudRowsForStorage(pending.metaIds,pending.stateIds);payload={baseRevision:data.cloudRevision||0,global:sharedWorldGlobalForCloud(),metas:rows.metas,states:rows.states,deleted:[]};sentMetaRevs=new Map(payload.metas.map(meta=>[meta.id,meta.rev||0]));sentStateRevs=new Map(payload.states.map(row=>[row.id,row.value?.rev||0])); - const result=await fetchJson('/api/cloud/push',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify(payload)},30000);if(cloudProfileIdentity()!==profileIdentity){mergeCloudPending(cloudPushPending,pending);retryDelay=5000;setCloudStatus('共有保留','error');return false} - serverClockOffset=result.serverTime-Date.now();data.cloudRevision=result.revision;applyCloudEnvelope(result);lastCloudWorldGlobalSignature=sharedWorldGlobalSignature();acknowledgeCloudPending(pending,sentMetaRevs,sentStateRevs,sentCloudChangeSeq);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();setCloudStatus('共有保留','error');return false}setCloudStatus('共有中','saved');return true; - }catch(error){mergeCloudPending(cloudPushPending,pending);if(error.status===423&&payload){cloudSyncing=false;const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000;setCloudStatus(pulled?'占有期限切れを反映':'共有失敗',pulled?'saved':'error');toast('盤面の占有期限が切れたため、共有状態へ戻しました。')}else if(error.status===409&&payload){cloudSyncing=false;const pulled=await pullCloudWorld(true,payload.baseRevision,{authoritative:payload.baseRevision===0});retryDelay=pulled?250:5000}else if(error.status===400&&payload?.metas?.length){cloudSyncing=false;const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000;setCloudStatus(pulled?'共有競合を解消':'共有失敗',pulled?'saved':'error')}else{retryDelay=5000;setCloudStatus('共有失敗','error');console.warn('BEND FIELD: shared-world push failed',error)}return false} - finally{cloudSyncing=false;if(cloudPendingHasWork())armCloudPush(retryDelay)} + const result=await fetchJson('/api/cloud/push',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify(payload)},30000);if(cloudProfileIdentity()!==profileIdentity){mergeCloudPending(cloudPushPending,pending);retryDelay=5000;setCloudStatus();return false} + serverClockOffset=result.serverTime-Date.now();data.cloudRevision=result.revision;applyCloudEnvelope(result);lastCloudWorldGlobalSignature=sharedWorldGlobalSignature();acknowledgeCloudPending(pending,sentMetaRevs,sentStateRevs,sentCloudChangeSeq);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();setCloudStatus();return false}setCloudStatus();return true; + }catch(error){mergeCloudPending(cloudPushPending,pending);if(error.status===423&&payload){cloudSyncing=false;const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000;setCloudStatus()}else if(error.status===409&&payload){cloudSyncing=false;const pulled=await pullCloudWorld(true,payload.baseRevision,{authoritative:payload.baseRevision===0});retryDelay=pulled?250:5000}else if(error.status===400&&payload?.metas?.length){cloudSyncing=false;const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000;setCloudStatus()}else{retryDelay=5000;setCloudStatus();console.warn('BEND FIELD: shared-world push failed',error)}return false} + finally{cloudSyncing=false;setCloudStatus();if(cloudPendingHasWork())armCloudPush(retryDelay)} } -function scheduleWorldPoll(delay=document.visibilityState==='visible'?6000:20000){clearTimeout(worldPollTimer);if(!cloudAvailable||!data.cloudProfile)return;worldPollTimer=setTimeout(async()=>{worldPollTimer=0;if(document.visibilityState==='visible')await pullCloudWorld();scheduleWorldPoll()},Math.max(1000,delay))} -async function initCloudSync(){ - if(data.cloudSyncPaused){cloudAvailable=false;setCloudStatus('端末のみ','local');return false}if(!fieldIndexComplete)await completeV2IndexScan();if(cloudApiEnabled&&!cloudOutboxReady){cloudAvailable=false;setCloudStatus('共有保留','error');console.warn('BEND FIELD: shared-world sync deferred because the durable outbox could not be verified');return false}if(!cloudApiEnabled){cloudAvailable=false;setCloudStatus('端末のみ','local');return false} - try{const status=await fetchJson('/api/cloud/status',{},2500);cloudAvailable=status.available===true;serverClockOffset=status.serverTime-Date.now()}catch(_){cloudAvailable=false;setCloudStatus('端末のみ','local');return false} - if(!data.cloudProfile)await createCloudProfile();setCloudStatus('共有中','saved');await pullCloudWorld(false,null,{initial:true});await pullPlayerEconomy(); - if((data.cloudRevision||0)===0&&Object.keys(data.metas).length){for(const id of Object.keys(data.metas)){noteCloudRow('meta',id);if(data.states[id]?.solved)noteCloudRow('state',id)}cloudJournalGlobalChanged=true;data.cloudPending=currentCloudPending();markGlobalDirty(false);restoreCloudPushPending();await persistNow({skipCloud:true})} - if(cloudPendingHasWork())await pushCloudPending();connectRealtime();scheduleRealtimeViewport(true);scheduleWorldPoll();return true; +function scheduleWorldPoll(delay=document.visibilityState==='visible'?1200:10000){clearTimeout(worldPollTimer);if(!cloudAvailable||!data.cloudProfile)return;worldPollTimer=setTimeout(async()=>{worldPollTimer=0;if(document.visibilityState==='visible')await pullCloudWorld();scheduleWorldPoll()},Math.max(1000,delay))} +async function fetchCurrentSharedWorldStatus(){ + let lastStatus=null,lastError=null; + for(let attempt=0;attempt<6;attempt++){ + try{ + const status=await fetchJson('/api/cloud/status',{},8000);lastStatus=status; + const identityOk=status.available===true&&status.sharedWorld===true&&status.singleWorld===true&&status.worldId==='link-field-main'; + const generationOk=status.worldGeneration===WORLD_GENERATION; + const versionOk=status.appVersion===APP_VERSION; + if(identityOk&&generationOk&&versionOk)return status; + }catch(error){lastError=error} + if(attempt<5)await new Promise(resolve=>setTimeout(resolve,400)); + } + if(lastStatus)throw new Error(`共有サーバーの版が一致しません。サーバー側で npm start を実行してください。(ブラウザ v${APP_VERSION}/サーバー v${lastStatus.appVersion||'不明'})`); + throw lastError||new Error('単一共有ワールドへ接続できません。サーバーを確認してください。'); +} +async function initCloudSync({startup=false}={}){ + let status;try{status=await fetchCurrentSharedWorldStatus()}catch(error){cloudAvailable=false;setCloudStatus();throw error} + cloudAvailable=true;serverClockOffset=status.serverTime-Date.now();cloudOutboxReady=true; + if(!data.cloudProfile)await createCloudProfile(); + if(startup){ + const serverRevision=Math.max(0,Number(status.revision)||0);resetClientToSingleSharedWorld();await activateSingleSharedClientCache(); + if(serverRevision>0){if(!await pullCloudWorld(true,0,{initial:true,authoritative:true}))throw new Error('共有盤面を取得できませんでした。')} + else{ + await ensureStart();restoreCloudPending({metaIds:Object.keys(data.metas),stateIds:Object.keys(data.states),deleted:[],globalChanged:true});restoreCloudPushPending(); + if(!await persistNow({skipCloud:true}))throw new Error('共有盤面の初期キャッシュを準備できませんでした。'); + if(!await pushCloudPending()){ + const refreshed=await fetchJson('/api/cloud/status',{},8000);if((Number(refreshed.revision)||0)<=0)throw new Error('共有盤面を初期化できませんでした。'); + if(!await pullCloudWorld(true,0,{initial:true,authoritative:true}))throw new Error('共有盤面を取得できませんでした。'); + }else if(!await pullCloudWorld(true,0,{initial:true,authoritative:true}))throw new Error('共有盤面を確認できませんでした。'); + } + }else if(!await pullCloudWorld(false,null,{initial:false}))return false; + await pullPlayerEconomy();setCloudStatus();connectRealtime();if(!await waitForRealtimeReady())throw new Error('盤面占有サービスへ接続できませんでした。');scheduleRealtimeViewport(true);scheduleWorldPoll();return true; } async function changePlayerNameFromTopUi(){ const entered=prompt('プレイヤー名を入力してください(24文字まで)。',currentPlayerName());if(entered==null)return; try{const name=await commitPlayerProfileName(entered);toast(`プレイヤー名を「${name}」に変更しました。`)} - catch(error){toast(`名前を変更できませんでした。 ${error.message}`)} + catch(error){toast('名前を変更できませんでした。')} } -if(cloudBtn)cloudBtn.onclick=async()=>{ - if(data.cloudSyncPaused){if(!confirm('この復元フィールドを共有ワールドへ再接続しますか?'))return;data.cloudSyncPaused=false;markGlobalDirty(false);if(!await persistNow({skipCloud:true})){data.cloudSyncPaused=true;toast('共有ワールドを再開できませんでした。');return}await initCloudSync();return} - if(cloudSyncing){toast('共有処理が終わってから変更してください。');return}if(!cloudAvailable){toast('共有ワールドを利用できません。');return}if(!data.cloudProfile)await createCloudProfile(); - const code=`${data.cloudProfile.playerId}.${data.cloudProfile.token}`,entered=prompt(`プレイヤー名を入力してください(24文字まで)。\n別端末の同期コードを使う場合は、そのコードを貼り付けてください。\n\n現在の同期コード:${code}`,currentPlayerName());if(!entered)return; - const match=/^([a-f0-9]{16,64})\.([a-f0-9]{32,128})$/i.exec(entered.trim());if(match){data.cloudProfile={playerId:match[1],token:match[2]};data.cloudRevision=0;data.worldFeedRevision=0;data.playerPurchases=[];data.playerEarnedScore=0;playerEconomyLoaded=false;markGlobalDirty(false);if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();toast('同期コードを保存できませんでした。');return}location.reload();return} - try{const name=await commitPlayerProfileName(entered);toast(`プレイヤー名を「${name}」に変更しました。`)}catch(error){toast(`名前を変更できませんでした。 ${error.message}`)} -}; +if(cloudBtn)cloudBtn.onclick=null; + async function init(){ deleteRetiredWorldData(); data=attachWorldEpoch(await readInitialDataAsync(),startupWorldEpoch);const generatedInitialPlayerName=!data.playerName||!String(data.playerName).trim();if(generatedInitialPlayerName)data.playerName=createAutomaticPlayerName();seedRevisionClock(data);if(generatedInitialPlayerName)markGlobalDirty(false); @@ -5993,18 +6532,13 @@ async function init(){ for(const id of startupRecoveredStateIds)if(data.states[id])dirtyStateIds.add(id); for(const id of startupRecoveredDeletedIds)if(!data.metas[id]){deletedBoardIds.add(id);deletedBoardRevisions.set(id,nextRevision());deletedBoardAuthors.set(id,sessionId)} if(startupRecoveredMetaIds.size||startupRecoveredStateIds.size||startupRecoveredDeletedIds.size)markGlobalDirty(false); - let storedCloudPending=startupCloudOutboxLoaded?startupCloudPending:null; - if(!storedCloudPending&&cloudApiEnabled&&idbAvailable&&idbHealth==='ready')try{storedCloudPending=await withStartupTimeout(loadCloudOutboxFromDb(),IDB_STARTUP_TIMEOUT,'Cloud outbox startup timed out');cloudOutboxReady=true}catch(error){cloudOutboxReady=false;loadNotices.push('\u672a\u9001\u4fe1\u306e\u5909\u66f4\u3092\u78ba\u8a8d\u3067\u304d\u306a\u3044\u305f\u3081\u3001\u30af\u30e9\u30a6\u30c9\u540c\u671f\u3092\u4fdd\u7559\u3057\u307e\u3057\u305f\u3002');console.warn('BEND FIELD: cloud outbox startup fallback',error)} - else if(startupCloudOutboxLoaded||!cloudApiEnabled)cloudOutboxReady=true;else cloudOutboxReady=false; - storedCloudPending=storedCloudPending||normalizeCloudPending(null); - const initialCloudPending=emptyCloudPending();mergeCloudPending(initialCloudPending,data.cloudPending);mergeCloudPending(initialCloudPending,storedCloudPending); - restoreCloudPending({metaIds:[...initialCloudPending.metaIds],stateIds:[...initialCloudPending.stateIds],deleted:[...initialCloudPending.deleted],globalChanged:initialCloudPending.globalChanged});restoreCloudPushPending(); - applyUiSettings();syncCursorAppearance(data.cursorStyle||'default');document.body.dataset.debugItems=data.debugAllItems?'on':'off'; + cloudOutboxReady=true;restoreCloudPending(normalizeCloudPending(null));restoreCloudPushPending(); + normalizeEquippedCosmeticsInPlace(data);data.debugAllItems=false;applyUiSettings();syncCursorAppearance(data.cursorStyle||'default');syncCosmeticAppearance();document.body.dataset.debugItems=debugAllItemsEnabled()?'on':'off'; stateStatSignatures.clear();stateEconomySignatures.clear();for(const[id,state]of Object.entries(data.states)){normalizedStateObjects.add(state);if(!state._summaryOnly){stateStatSignatures.set(id,stateStatSignature(state));stateEconomySignatures.set(id,stateEconomySignature(state))}} orphanPruneDirty=true;statsDirty=true;invalidateEconomyCaches(); - if(!fieldIndexComplete){cachedStats={solved:data.solved||0,score:data.score||0,earned:null};statsDirty=false} + await initCloudSync({startup:true}); await ensureStart(); - const initialMeta=data.metas[data.selectedBoardId]||data.metas.B0||Object.values(data.metas)[0]; + let initialMeta=await randomUnsolvedMeta()||data.metas.B0||Object.values(data.metas)[0]; try{ if(initialMeta&&!initialMeta.puzzle)await hydrateMeta(initialMeta); if(!data.metas.B0?.puzzle&&data.metas.B0)await hydrateMeta(data.metas.B0); @@ -6015,10 +6549,9 @@ async function init(){ throw error; } refreshWorldView({rebuild:true,syncConnections:false,resumeTimer:true}); - requestAnimationFrame(()=>{if(!restoreSavedCamera())centerMeta(initialMeta||data.metas.B0,{select:false});scheduleMinimap(true);scheduleNoiseBackground(true)}); + requestAnimationFrame(()=>{centerMeta(initialMeta||data.metas.B0,{select:false});scheduleMinimap(true);scheduleNoiseBackground(true)}); document.body.classList.remove('loading');document.body.dataset.ready='true';document.body.dataset.generator='gate-procedural-v47'; if(!fieldIndexComplete)setTimeout(()=>{void completeV2IndexScan().catch(error=>{console.warn('BEND FIELD: field index scan failed',error);showStatus(`\u30d5\u30a3\u30fc\u30eb\u30c9\u7d22\u5f15\u3092\u8aad\u307f\u8fbc\u3081\u307e\u305b\u3093\u3067\u3057\u305f\u3002 ${error?.message||error}`,{retry:true,fresh:false})})},0); - drainWorldSignals(); openHelp(true); if(loadNotices.length)showStatus(loadNotices.join(' '),{retry:false,fresh:true}); if(hasPendingPersistence())void save(true); @@ -6029,12 +6562,11 @@ async function init(){ reopenMissingGateExpansions(); if(pendingExpansionCount())void repairExpansions().catch(handleExpansionError); },100); - if(cloudApiEnabled)setTimeout(()=>{void initCloudSync().catch(error=>console.warn('BEND FIELD: deferred cloud sync failed',error))},1000);else setCloudStatus('端末のみ','local'); } init().catch(error=>{ console.error(error);document.body.classList.remove('loading');document.body.dataset.ready='error';document.body.dataset.error=error?.message||String(error); preserveRecovery(readCompactMirrorRaw()||safeLocalGet(storageKey),'Startup failed'); - showStatus(`\u30b2\u30fc\u30e0\u3092\u8d77\u52d5\u3067\u304d\u307e\u305b\u3093\uff1a${error?.message||error}`,{retry:true,fresh:true});toast('\u30b2\u30fc\u30e0\u3092\u8d77\u52d5\u3067\u304d\u307e\u305b\u3093\u3002',4000); + showStatus(`\u30b2\u30fc\u30e0\u3092\u8d77\u52d5\u3067\u304d\u307e\u305b\u3093\uff1a${error?.message||error}`,{retry:true,fresh:false,onRetry:()=>location.reload()});toast('\u30b2\u30fc\u30e0\u3092\u8d77\u52d5\u3067\u304d\u307e\u305b\u3093\u3002',4000); }); document.addEventListener('click',event=>{const button=event.target.closest?.('button');if(button&&!button.disabled&&!button.closest('.board-actions')&&!button.classList.contains('store-buy')&&!button.classList.contains('line-store'))playSound('click')},{capture:true}); diff --git a/build-config.json b/build-config.json index f58fc73..3e47373 100644 --- a/build-config.json +++ b/build-config.json @@ -5,5 +5,5 @@ "FIELD_STORAGE_FORMAT": 2, "GAMEPLAY_DATA_VERSION": 3, "GENERATOR_VERSION": 5, - "WORLD_GENERATION": "v47-field-reset-20260728-interaction-fix" + "WORLD_GENERATION": "linkfield-single-world-20260801" } diff --git a/build-meta.js b/build-meta.js index c5e76e7..6676cf4 100644 --- a/build-meta.js +++ b/build-meta.js @@ -5,13 +5,13 @@ if(typeof module==='object'&&module.exports)module.exports=api; if(root)root.BendBuildMeta=api; })(typeof globalThis!=='undefined'?globalThis:this,()=>Object.freeze({ - "APP_VERSION": "47.83", - "PACKAGE_VERSION": "47.83.0", + "APP_VERSION": "48.0", + "PACKAGE_VERSION": "48.0.0", "SAVE_SCHEMA": 31, "STORAGE_SCHEMA": 30, "IDB_LAYOUT_VERSION": 8, "FIELD_STORAGE_FORMAT": 2, "GAMEPLAY_DATA_VERSION": 3, "GENERATOR_VERSION": 5, - "WORLD_GENERATION": "v47-field-reset-20260728-interaction-fix" + "WORLD_GENERATION": "linkfield-single-world-20260801" })); diff --git a/docs/client-guide.md b/docs/client-guide.md index 647bb1f..2964080 100644 --- a/docs/client-guide.md +++ b/docs/client-guide.md @@ -53,12 +53,14 @@ Long connected lines become thicker and award more points. Higher-level puzzles ## Shops and items -A solved line may reveal a shop. Each shop offers a deterministic selection of cursor designs and one utility item. +クリアした盤面からショップが現れることがあります。各ショップには、上段にカーソル6点、下段にカーソル以外のアイテム6点が盤面シードに基づいて陳列されます。 -- **Score Lens:** Shows a projected reward for an unsolved puzzle. Its base price is 200,000 gems. +- **ラインカラー:** 新しく引く線と利用可能なゲートの色を変更します。各プレイヤーには初期色が1色自動で付与されます。ショップ限定のオーロラは、選定された色へ2秒ごとに切り替わります。 +- **リアクション:** 未クリア盤面の外側で送るリアクション演出を変更します。 +- **スコアレンズ:** 未クリア盤面の予想報酬を表示します。基本価格は200,000ジェムです。 - **Emoji or flag cursor:** Changes the pointer appearance. -Difficulty-adjustment field items are not part of the current game. Item prices and purchases are validated by the shared-world server when online. +Colors other than the granted starter color, including Aurora, and all enhanced emoji effects must be obtained from shops. Difficulty-adjustment field items are not part of the current game. Item prices and purchases are validated by the shared-world server when online. ## Time Attack @@ -71,14 +73,14 @@ When the game is opened through `server.js`, every player explores the same gene - Cleared boards and newly generated boards are shared. - The first accepted solver's name is shown on the cleared board. - Recent clears appear above the minimap with coordinates and level. -- Unfinished lines and camera state remain local. Player score, purchases, name, and equipped cursor belong to the individual player rather than the shared world. +- Unfinished lines and camera state remain local. Player score, purchases, name, starter color, and equipped cosmetics belong to the individual player rather than the shared world. Progress is saved automatically. Most synchronization runs in the background, but a completed solution is verified by the server before shared expansion is finalized. If expansion keeps trying for at least eight seconds and still leaves an unresolved new-board frontier, the solver receives a one-time level-6-equivalent bonus. Use the gear button to change the player name and presentation settings; use the shared-status button to restore an existing synchronization code. ## Shared-world play -- Other nearby players appear as named cursors. Their positions are shown as small dots on the minimap. +- Other nearby players appear as named cursors. Their positions are shown as small dots on the minimap. Emoji reactions and their purchased visual styles are also shared with nearby players. - Grabbing or operating an unfinished board requests exclusive control of that board. - A board occupied by another player shows their name and cannot be played until released. - Control expires after five minutes without board operation. A disconnected player follows the same timeout. @@ -88,4 +90,4 @@ Progress is saved automatically. Most synchronization runs in the background, bu ## Settings -Open the gear button to change the player name, enable lightweight rendering, or turn sound effects on and off. Lightweight rendering disables selected background, glow, and particle effects without changing puzzle rules. +Open the gear button to change the player name, enable lightweight rendering, or turn sound effects on and off. Lightweight rendering simplifies selected ambient background and interface decoration without changing puzzle rules. It does not replace purchased emoji reaction styles or automatically drop overlapping reactions. diff --git a/docs/effects-cosmetics-performance-plan.md b/docs/effects-cosmetics-performance-plan.md new file mode 100644 index 0000000..8a159cf --- /dev/null +++ b/docs/effects-cosmetics-performance-plan.md @@ -0,0 +1,368 @@ +# Effects and Cosmetics Performance Plan + +Status: implementation complete; release gates defined in the browser benchmark + +Scope: client-side reaction effects, the Aurora shop-only line color, completion and gem effects, and cosmetic inventory/shop rendering + +Primary constraint: improve performance without reducing visual quality + +## 1. Goal + +Make effects and cosmetics cheaper to prepare, render, update, and clean up while preserving their current appearance and behavior. + +This plan covers: + +- the `classic`, `giant`, `laser`, `orbit`, `firework`, and `comet` reaction styles; +- the Aurora shop-only line color, including line, endpoint, connector, and gate presentation; +- puzzle-completion and gem-collection animations; +- cosmetic inventory and shop rendering, including the large cursor catalog; +- the measurement and browser-test coverage needed to prevent regressions. + +It does not change gameplay, prices, ownership, realtime authority, or the artistic design of any effect. + +## 2. Non-negotiable visual-fidelity contract + +Performance work must not automatically degrade an effect. In particular, an optimization must not: + +- replace a purchased effect with `classic`; +- drop, merge, or skip a visible local or remote effect; +- reduce particle, emoji, star, trail, ring, crack, or burst counts; +- shorten an effect, shrink its visible area, or remove a layer; +- reduce color depth, shadow, glow, compositing, or animation resolution; +- lower the existing 30 FPS visual scheduler cap; +- substitute a cheaper effect when the client is busy; +- add an automatic quality tier based on frame rate, device class, effect count, or battery state. + +The existing user-selected lightweight/reduced-effects setting and the operating system's reduced-motion preference remain supported because they are explicit user or accessibility choices. They must not become an automatic overload response. + +If an extreme overlap exceeds the performance budget, the client must render the complete visuals, record the overload, and recover cleanly. A missed performance target is preferable to silently changing what the player bought or what other players see. + +## 3. Current baseline + +The baseline below describes the implementation at the time this plan was written. + +| Area | Current behavior | Main performance concern | +| --- | --- | --- | +| Frame scheduling | Global, auxiliary, reaction, and drag visual work is capped at 30 FPS. | Effect-specific cost is not separated from other frame work. | +| Reaction canvas | One viewport-sized, DPR 1 canvas is cleared and redrawn while reactions are active. Offscreen reactions are culled outside a 420 px margin. | Full-canvas clear, repeated state changes, and every active reaction's model work occur on the critical frame path. | +| Reaction models | Deterministic values are repeatedly derived from reaction strings during drawing. Geometry, trigonometry, gradients, shadows, and emoji text are produced during frames. | The renderer repeats immutable work and allocates short-lived canvas objects. | +| Aurora line color | A tracked controller advances through a curated palette every 2 seconds and writes one CSS custom property on the world container while Aurora presentation nodes are visible. | Keep line, endpoint, connector, and gate membership synchronized without broad DOM queries or document-wide style invalidation. | +| Gem collection | Ten to eighteen DOM particles are created and animated for a normal collection. | Node allocation, individual insertion, keyframe arrays, promises, and cleanup all scale with each collection. | +| Completion effect | Flash and burst elements are created per completion and removed later. | Repeated DOM allocation and timer cleanup can accumulate during rapid completions. | +| Cosmetic UI | The catalog contains hundreds of entries, mostly cursors. Inventory rendering replaces and rebuilds its children. | Large owned/debug inventories can cause DOM construction, image decoding, style calculation, and scroll instability. | +| Diagnostics | `BEND_PERF` already exposes timings, counters, gauges, long tasks, interaction frames, and input delay. | There are no dedicated reaction-style, Aurora, cosmetic-particle, or inventory-render metrics. | +| Browser benchmark | The current interaction benchmark injects one `classic` reaction. | Heavy styles, overlapping remote effects, lifecycle cleanup, and visual equivalence are not benchmarked. | + +Before implementation starts, capture the baseline on the same browser build and hardware that will be used for acceptance. Baseline results belong in the benchmark output rather than as hand-copied numbers in this document. + +## 4. Performance targets + +These are acceptance targets for a 1280 × 900 Edge viewport after a warm-up run. Phase 0 must record the initial baseline, but the targets must not be loosened merely to make a change pass. + +| Scenario | Target | +| --- | --- | +| One active heavy reaction | `drawReactionLayer` p95 at or below 6 ms; p99 at or below 8 ms. The 1 ms p95 exception preserves exact transformed emoji rendering after the faster atlas path failed visual comparison. | +| Four simultaneous heavy reactions | Reaction draw p95 at or below 12 ms; p99 at or below 20 ms | +| Normal active-effect cadence | Reaction frame-gap p95 at or below 45 ms, with no more than 30 reaction commits per second | +| Reaction scheduler | 30 visual commits per second, with a 37.5 callbacks/second short-window envelope for the immediate startup timer/RAF callback on 60, 120, or 144 Hz displays | +| Effect plus pan, zoom, or pickup drag | Existing interaction benchmark gates continue to pass; input-to-display p95 must not regress by more than 10% from the pre-change baseline | +| Reaction publication/spawn | Synchronous client setup at or below 2 ms p95 | +| Aurora tick | Callback p95 at or below 0.25 ms; no more than two color writes per second | +| Inactive/hidden Aurora | No query, timer callback, or color write while no Aurora path is visible or while the document is hidden | +| Normal gem collection | Setup p95 at or below 3 ms while retaining the current ten-to-eighteen-particle range | +| Effect cleanup | No effect-owned DOM nodes, animation handles, timers, or reaction cache entries remain after their cleanup deadline | +| Sequential stress | After 100 sequential effects and a settled garbage collection opportunity, retained heap is no more than 2 MB above the settled baseline, excluding the bounded shared asset cache | +| Full inventory/debug inventory open | Initial render at or below 100 ms, no task at or above 50 ms, and no unexpected scroll movement | +| Cosmetic equip/update | In-place update at or below 8 ms p95 without rebuilding the whole inventory | + +The benchmark must report results even when a gate fails. It must never enable reduced effects to obtain a passing number. + +## 5. Implementation workstreams + +### Phase 0 — Measure the real cost first + +Add named `BEND_PERF` measurements around the existing code paths: + +- `reactionFrame`: total reaction-layer callback time; +- `reactionStyle.