diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9ed44b0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{js,json,css,md}] +indent_style = space +indent_size = 2 +max_line_length = 180 + +[puzzle-patterns.js] +max_line_length = off + +[store-catalog.generated.js] +max_line_length = off diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..318fad8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: BEND FIELD CI + +on: + push: + pull_request: + +concurrency: + group: bend-field-${{ github.ref }} + cancel-in-progress: true + +jobs: + release: + runs-on: windows-latest + strategy: + max-parallel: 1 + env: + BEND_FIELD_BROWSER_PATH: C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe + BEND_FIELD_EDGE_PATH: C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe + BEND_FIELD_BENCHMARK_PROFILE: small + BEND_FIELD_BROWSER_CONCURRENCY: "1" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm install --ignore-scripts + - run: npm run test:ci + + production-bridge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm install --ignore-scripts + - name: Install production bridge runtimes + run: sudo apt-get update && sudo apt-get install -y php-cli apache2 libapache2-mod-php + - run: npm run test:bridge + - name: Exercise real Apache rewrite and proxy + shell: bash + run: | + set -euo pipefail + sudo a2enmod rewrite proxy proxy_http proxy_wstunnel headers + sudo tee /etc/apache2/conf-available/linkfield-ci.conf >/dev/null <<'EOF' + ServerTokens Prod + ServerSignature Off + + AllowOverride All + Require all granted + + EOF + sudo a2enconf linkfield-ci + sudo rm -rf /var/www/html/link-field + sudo mkdir -p /var/www/html/link-field + sudo chown -R "$USER":"$USER" /var/www/html/link-field + LINK_FIELD_PUBLIC_DIR=/var/www/html/link-field node scripts/service-control.js deploy + test_root="$(mktemp -d)" + LINK_FIELD_PUBLIC_DIR=/var/www/html/link-field LINK_FIELD_TEST_DATA_ROOT="$test_root" LINK_FIELD_APACHE_BRIDGE=1 HOST=127.0.0.1 PORT=43129 node server.js >"$test_root/server.log" 2>&1 & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true; rm -rf "$test_root"' EXIT + sudo service apache2 restart + for attempt in $(seq 1 100); do + if curl --fail --silent http://127.0.0.1/link-field/api/cloud/status | grep -q '"available":true'; then break; fi + sleep 0.1 + done + curl --fail --show-error http://127.0.0.1/link-field/ + curl --fail --show-error http://127.0.0.1/link-field/api/cloud/status | grep -q '"sharedWorld":true' + test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1/link-field/server.js)" = 403 + test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1/link-field/package.json)" = 403 + test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1/link-field/scripts/service-control.js)" = 403 + test "$(curl --silent --output /dev/null --write-out '%{http_code}' http://127.0.0.1/link-field/.linkfield-deployment.json)" = 403 + node scripts/public-smoke-test.js http://127.0.0.1/link-field/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..924ad55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Runtime state belongs outside the repository. +cloud-data/ +node_modules/ + +# Browser-test artifacts and owned temporary profiles. +.tmp-edge-profile/ +*.browser-profile/ +*-smoke.png +*-probe.png + +# Local logs and platform metadata. +*.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..195b7a9 --- /dev/null +++ b/.htaccess @@ -0,0 +1,24 @@ +# BEGIN LINKFIELD MANAGED PROXY +ServerSignature Off + + Header always unset X-Powered-By + + + RewriteEngine On + + # Safe bootstrap: use the bounded PHP bridge until the Node server + # writes a verified current proxy port after it begins listening. + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^api/(.*)$ api-bridge.php?path=/api/$1 [QSA,L] + + # The service source may share this directory with Apache. Expose only + # the curated browser bundle and deny every other direct file request. + RewriteRule ^$ - [L] + RewriteRule ^(?:index\.html|style\.css|favicon\.(?:svg|ico)|build-meta\.js|runtime-config\.js|shared-contracts\.js|store-catalog\.(?:generated\.js|json)|puzzle-patterns\.js|puzzle-core\.js|app-logic\.js|archive-codec\.js|field-persistence(?:-worker)?\.js|puzzle-worker\.js|app\.js|api-bridge\.php)$ - [L] + RewriteRule ^(?:assets|client)(?:/|$) - [L] + RewriteRule ^ - [F,L] + + + Require all denied + +# END LINKFIELD MANAGED PROXY diff --git a/README.md b/README.md index e69de29..bcb7e66 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,205 @@ +# LinkField v48.0 + +LinkFieldは、サーバー上の**単一共有ワールド専用**です。ローカル盤面、同期コード、複数ワールド切替はありません。 + +## 起動 + +ZIPを展開したプロジェクト直下で実行します。 + +```bash +npm start +``` + +`npm start` は次を自動実行し、完了後すぐにコマンド入力へ戻ります。 + +1. 公開ファイルを一時ディレクトリで完成させ、ディレクトリ単位で切り替え +2. 稼働中の管理済みLinkFieldサーバーと旧版サーバーを安全に停止 +3. 古いポート情報を削除 +4. 自動再起動・ハートビート付きの監視プロセスをバックグラウンド起動 +5. ローカルAPIと、設定済みの場合は公開URLの疎通を確認 +6. 実際の使用ポートを `.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 +``` + +公開URLも指定すると、起動完了前にページとAPIの外部疎通を検査します。ユーザー名が `333` のホストではREADME記載のURLが自動使用されます。 + +```bash +LINK_FIELD_PUBLIC_DIR="$HOME/www/link-field" \ +LINK_FIELD_PUBLIC_URL="https://example.com/link-field/" \ +npm start +``` + +## 接続確認 + +```bash +curl 'https://2012r2.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} +``` + +### Windows Apache/PHP host hardening + +The public `Server` header must not expose exact Apache or PHP versions. Run the helper once without `-Apply` to preview the change, then run it again with `-Apply`. It creates timestamped backups and runs `httpd.exe -t`; if Apache rejects the configuration, both files are restored automatically. + +```powershell +powershell -ExecutionPolicy Bypass -File scripts\harden-windows-host.ps1 ` + -ApacheConfig C:\Apache24\conf\httpd.conf ` + -PhpIni C:\php\php.ini + +powershell -ExecutionPolicy Bypass -File scripts\harden-windows-host.ps1 ` + -ApacheConfig C:\Apache24\conf\httpd.conf ` + -PhpIni C:\php\php.ini ` + -Apply +``` + +Use the actual configuration paths if Apache or PHP is installed elsewhere. Restart Apache after a successful apply, then verify the public deployment: + +```bash +npm run smoke:public -- 'https://2012r2.nishi.boats/~333/link-field/' +``` + +If the project source is installed directly inside `public_html/link-field`, deploy the current hidden `.htaccess` and `server/apache-bridge.js` before restarting Node. LinkField then maintains a strict Apache allowlist automatically. A private source check must return HTTP 403 (404 is also acceptable when the source and web roots are separated): + +```bash +curl -I 'https://2012r2.nishi.boats/~333/link-field/server.js' +curl -I 'https://2012r2.nishi.boats/~333/link-field/package.json' +``` + +If startup reports `Another LinkField server is already running` or asks for legacy-lock recovery, run the verified one-time recovery command and then start again: + +```bash +npm run recover +npm start +npm run status +``` + +Recovery reads only LinkField's configured world/service lock files and refuses to stop a lock-holder unless it is a running Node process. Normal restarts do not require this command. + +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 # 停止 +npm run backup # 停止中の手動バックアップ +npm run backups # バックアップ一覧とチェックサム検証 +npm run restore -- 2026-08-01T00-00-00-000Z # 停止中に復元 +``` + +ログ確認: + +```bash +tail -f "$HOME/.local/share/LinkField/service/server.log" +``` + +ログは10 MiBで自動ローテーションし、5世代を保持します。監視プロセスは5分間に最大10回までクラッシュしたサーバーを再起動します。 + +前面起動で直接ログを見る場合: + +```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 +``` + +root直下を利用できない環境では、明示した1か所へ変更できます。全リリースで同じ値を設定してください。 + +```bash +LINK_FIELD_WORLD_DIR="$HOME/.local/share/LinkField/world" npm start +``` + +自動バックアップは既定で `/link-field/backups`(変更時はワールドディレクトリの隣の `backups`)へ1日1回作成され、チェックサム付きで7世代保持されます。保存先は `LINK_FIELD_BACKUP_DIR` で変更できます。 + +展開フォルダやブラウザごとに別ワールドは作成されません。通常ウィンドウとシークレットウィンドウも同じサーバー正本を取得します。 + +## 盤面共有 + +未クリア盤面の線・特殊マス進行もサーバーへ保存されます。盤面を操作する際は専用の共有APIで占有権を即時取得し、利用できない場合だけリアルタイム経路へフォールバックします。占有応答前に指を離しても、ドラッグ軌跡は承認後に適用されます。接続状態は左下のFPS表示直上にある固定サイズの小型インジケーターで確認できます。 +## 更新時の起動 + +`npm start` は、新しい公開ツリーを完成・検証してからアトミックに切り替え、旧LinkFieldサーバーを停止して監視プロセス配下で起動し直します。配置に失敗した場合は旧公開ツリーが維持されます。 + +```bash +npm start +``` + +起動エラー画面の「再試行」は共有サーバーへの再接続を行います。ブラウザ内バックアップの復元は実行しません。 + +## Apache・PHPの要件 + +- 推奨: `mod_rewrite`、`mod_proxy`、`mod_proxy_http`、`mod_proxy_wstunnel`、`mod_headers` +- HTTPプロキシが無い場合はPHPブリッジへ自動フォールバックします。 +- `ServerTokens Prod` と `ServerSignature Off` をホスト設定で有効にし、Apache・PHP・OpenSSLをサポート中の版へ更新してください。 +- `.htaccess` を有効にする公開ディレクトリには `AllowOverride All`(または必要なRewrite/Header権限)が必要です。 + +リリース後の公開確認: + +```bash +LINK_FIELD_PUBLIC_URL="https://example.com/link-field/" npm run smoke:public +``` + +PHP実装を含むブリッジ統合テスト: + +```bash +npm run test:bridge +``` + + +## v48.0の共有確定ルール + +- 盤面の「プレイ中」は、ゲートまたは線端のつまみを押して操作を開始した時だけ発生します。カーソルを重ねただけでは発生しません。 +- 占有に関する画面下部の通知は表示しません。 +- 盤面がサーバーでクリア確定すると「プレイ中」は即時解除されます。ページ離脱・接続切断時も解除されます。 +- クリア表示とジェム加算は、共有サーバーが解答を検証して受理した後に確定します。送信できなかった場合、端末だけがクリア済みになることはありません。 +- 新規盤面を生成できるのは、その盤面をクリアしたプレイヤーだけです。生成後はサーバーへ保存され、他プレイヤーと再アクセス後の両方へ同じ盤面が返ります。 +- 初回ラインカラーは、プレミアム商品を除いた通常ラインカラーから選ばれます。 diff --git a/api-bridge.php b/api-bridge.php new file mode 100644 index 0000000..7ad3f71 --- /dev/null +++ b/api-bridge.php @@ -0,0 +1,168 @@ + $message, 'serverTime' => (int) round(microtime(true) * 1000)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +function write_all($socket, string $value): void { + $written = 0; + $length = strlen($value); + while ($written < $length) { + $result = fwrite($socket, substr($value, $written, 64 * 1024)); + if ($result === false || $result === 0) { + fclose($socket); + fail_json(502, 'Failed to send the request to the LinkField server.'); + } + $written += $result; + } +} + +function request_body(int $maximum): array { + $input = fopen('php://input', 'rb'); + if (!is_resource($input)) fail_json(400, 'Could not read the request body.'); + $declared = trim((string) ($_SERVER['CONTENT_LENGTH'] ?? '')); + if ($declared !== '') { + if (!preg_match('/^[0-9]+$/D', $declared)) { fclose($input); fail_json(400, 'Invalid request content length.'); } + $length = (int) $declared; + if ($length > $maximum) { fclose($input); fail_json(413, 'Request body is too large.'); } + return [$input, $length]; + } + $temporary = fopen('php://temp/maxmemory:1048576', 'w+b'); + if (!is_resource($temporary)) { fclose($input); fail_json(500, 'Could not stage the request body.'); } + $length = 0; + while (!feof($input)) { + $chunk = fread($input, 64 * 1024); + if ($chunk === false) { fclose($input); fclose($temporary); fail_json(400, 'Could not read the request body.'); } + $length += strlen($chunk); + if ($length > $maximum) { fclose($input); fclose($temporary); fail_json(413, 'Request body is too large.'); } + if ($chunk !== '' && fwrite($temporary, $chunk) !== strlen($chunk)) { fclose($input); fclose($temporary); fail_json(500, 'Could not stage the request body.'); } + } + fclose($input); + rewind($temporary); + return [$temporary, $length]; +} + +function write_body($socket, $body, int $length): void { + $written = 0; + while ($written < $length) { + $chunk = fread($body, min(64 * 1024, $length - $written)); + if ($chunk === false || $chunk === '') { fclose($body); fclose($socket); fail_json(400, 'Request body ended before its declared length.'); } + write_all($socket, $chunk); + $written += strlen($chunk); + } + fclose($body); +} + +$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)(?:/[A-Za-z0-9_-]+)*$#D', $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, $bodyLength] = request_body(8 * 1024 * 1024); + +$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, 30); + +$requestHeaders = [ + $method . ' ' . $targetPath . " HTTP/1.1", + 'Host: 127.0.0.1:' . $port, + 'Connection: close', + 'Accept: application/json', + 'Content-Type: application/json', + 'Content-Length: ' . $bodyLength, +]; +if ($authorization !== '') $requestHeaders[] = 'Authorization: ' . str_replace(["\r", "\n"], '', $authorization); +$remoteAddress = (string) ($_SERVER['REMOTE_ADDR'] ?? ''); +if (preg_match('/^[0-9A-Fa-f:.]{3,64}$/', $remoteAddress)) $requestHeaders[] = 'X-Forwarded-For: ' . $remoteAddress; +write_all($socket, implode("\r\n", $requestHeaders) . "\r\n\r\n"); +write_body($socket, $body, $bodyLength); + +$response = ''; +$separator = false; +while (!feof($socket) && $separator === false && strlen($response) <= 64 * 1024) { + $chunk = fread($socket, 8192); + if ($chunk === false) break; + $response .= $chunk; + $separator = strpos($response, "\r\n\r\n"); +} +$meta = stream_get_meta_data($socket); +if ($response === '' || $separator === false || !empty($meta['timed_out'])) { + fclose($socket); + fail_json(504, 'LinkField server did not respond in time.'); +} +$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', 'retry-after'], true)) { + header($name . ': ' . $value, true); + } +} +header('X-LinkField-Bridge: php', true); +header_remove('X-Powered-By'); +if ($method !== 'HEAD') { + echo $responseBody; + while (!feof($socket)) { + $chunk = fread($socket, 64 * 1024); + if ($chunk === false) break; + echo $chunk; + if (function_exists('flush')) flush(); + } +} +fclose($socket); diff --git a/app-logic.js b/app-logic.js index 673cbd1..9bb1fc9 100644 --- a/app-logic.js +++ b/app-logic.js @@ -8,6 +8,7 @@ const key2=shared.key2; function sectionCountRange(level){ const max=Math.max(1,Math.min(10,Math.round(level)||1)); + if(max<=3)return{min:1,max:1}; return{min:Math.max(1,max-3),max}; } function normalizeGeneratedShape(chunks){ @@ -90,7 +91,7 @@ function specialSchedule(level,validCellCount,seed,encounteredTypes=[],recentTypes=[]){ const boundedLevel=Math.max(1,Math.min(10,Math.floor(Number(level)||1))),cells=Math.max(1,Math.floor(Number(validCellCount)||1)); if(boundedLevel<5)return{types:[],setCount:0,maxCells:0}; - const types=['warp','lock','crossing'],encountered=new Set(encounteredTypes||[]),recent=(recentTypes||[]).filter(type=>types.includes(type)).slice(-3), + const types=['warp','lock','crossing','internalGate'],encountered=new Set(encounteredTypes||[]),recent=(recentTypes||[]).filter(type=>types.includes(type)).slice(-3), unseen=types.filter(type=>!encountered.has(type)); if(unseen.length){ const introduced=[...unseen].sort((a,b)=>stableHash([seed,'introduction',a])-stableHash([seed,'introduction',b])||a.localeCompare(b))[0]; @@ -105,7 +106,7 @@ function interactionBurden(puzzle){ const paths=puzzle?.solution||[],totalPathLength=paths.reduce((sum,path)=>sum+(path.cells?.length||0),0),pickups=paths.length, gateTravel=(puzzle?.g||[]).reduce((sum,gate,index,gates)=>index?sum+Math.abs(gate[0]-gates[index-1][0])+Math.abs(gate[1]-gates[index-1][1]):sum,0), - specialSwitches=(puzzle?.specialCells?.crossings?.length||0)+(puzzle?.specialCells?.warps?.length||0)+(puzzle?.specialCells?.locks?.length||0), + specialSwitches=(puzzle?.specialCells?.crossings?.length||0)+(puzzle?.specialCells?.warps?.length||0)+(puzzle?.specialCells?.locks?.length||0)+(puzzle?.specialCells?.internalGates?.length||0), occupancy=new Map();let ambiguousAdjacency=0; paths.forEach((path,pathIndex)=>(path.cells||[]).forEach(cell=>occupancy.set(key2(cell[0],cell[1]),pathIndex))); for(const[key,pathIndex]of occupancy){const[row,column]=key.split(',').map(Number);for(const[dr,dc]of[[1,0],[0,1]]){const neighbor=occupancy.get(key2(row+dr,column+dc));if(neighbor!=null&&neighbor!==pathIndex)ambiguousAdjacency++}} @@ -141,9 +142,9 @@ return balancedShapeCandidates(candidates,hash32((seed>>>0)^0x3c6ef372),{hash32,rngFrom,shuffle}); } - function analyzePathTurns(path,gates,warpPairs,includeEnd=true){ + function analyzePathTurns(path,gates,warpPairs,includeEnd=true,internalGateIndexes=[]){ if(!path?.cells?.length||includeEnd&&path.endGate==null)return{count:0,cells:[]}; - const sideDelta={N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},same=(a,b)=>a&&b&&a[0]===b[0]&&a[1]===b[1],warpMap=new Map(); + const sideDelta={N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},same=(a,b)=>a&&b&&a[0]===b[0]&&a[1]===b[1],warpMap=new Map(),internal=new Set(internalGateIndexes||[]); for(const pair of Array.isArray(warpPairs)?warpPairs:[]){if(pair?.a&&pair?.b){warpMap.set(pair.a.join(','),pair.b);warpMap.set(pair.b.join(','),pair.a)}} const isWarp=(a,b)=>same(warpMap.get(a?.join(',')),b),outside=gate=>{const delta=sideDelta[gate?.[2]]||[0,0];return[(gate?.[0]||0)+delta[0],(gate?.[1]||0)+delta[1]]}; const segments=[];let start=0; @@ -151,9 +152,9 @@ segments.push(path.cells.slice(start)); const cells=[]; segments.forEach((segment,segmentIndex)=>{ - if(!segment.length)return;const first=segmentIndex===0,last=segmentIndex===segments.length-1,nodes=[]; - if(first)nodes.push(outside(gates?.[path.startGate]));nodes.push(...segment);if(last&&includeEnd)nodes.push(outside(gates?.[path.endGate])); - const offset=first?1:0; + if(!segment.length)return;const first=segmentIndex===0,last=segmentIndex===segments.length-1,nodes=[],hasStartOutside=first&&!internal.has(path.startGate),hasEndOutside=last&&includeEnd&&!internal.has(path.endGate); + if(hasStartOutside)nodes.push(outside(gates?.[path.startGate]));nodes.push(...segment);if(hasEndOutside)nodes.push(outside(gates?.[path.endGate])); + const offset=hasStartOutside?1:0; for(let index=1;index=0&&sourceIndex{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'; @@ -17,16 +41,17 @@ 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,FLAG_CURSOR_BASE_PRICE=10000,OECD_FLAG_CURSOR_BASE_PRICE=20000,STORE_CHANCE=1/30,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=3.6,UNIQUE_SOLUTION_MIN_LEVEL=6; -const SPECIAL_CELL_MIN_LEVEL=5,SPECIAL_CELL_DEBUG_ALL_LEVELS=false; +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; +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}), @@ -34,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:'ジェムレンズ',cost:200000,scoreLens:true,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 @@ -88,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 @@ -119,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 @@ -139,37 +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))), - cost=MIN_CURSOR_PRICE+((index*61+37)%100)*MIN_CURSOR_PRICE; + emoji=String.fromCodePoint(...codes.split(' ').map(code=>Number.parseInt(code,16))); return Object.freeze({ - id:`cursor-face-${codeKey}`,name:titleEmojiName(name),cost, - cursorStyle:`emoji-${codeKey}`,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(' '); -const OECD_FLAG_CODES=new Set('AU AT BE CA CL CO CR CZ DK EE FI FR DE GR HU IS IE IL IT JP KR LV LT LU MX NL NZ NO PL PT SK SI ES SE CH TR GB US'.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),cost:OECD_FLAG_CODES.has(code)?OECD_FLAG_CURSOR_BASE_PRICE:FLAG_CURSOR_BASE_PRICE})), - ...[['gbeng','England'],['gbsct','Scotland'],['gbwls','Wales']].map(([key,name])=>({key,name,emoji:subdivisionFlagEmoji(key),cost:FLAG_CURSOR_BASE_PRICE})) + ...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,cost:source.cost, - cursorStyle:`flag-${source.key}`,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 CURSOR_ITEMS=Object.freeze([...YELLOW_FACE_CURSOR_ITEMS,...FLAG_CURSOR_ITEMS]); -const STORE_ITEMS=Object.freeze([...STORE_ITEM_BASE,...CURSOR_ITEMS]); +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('ショップ表示データがカタログと一致しません'); +const STORE_ITEMS=Object.freeze(CanonicalStoreCatalog.map(contract=>{ + const presentation=STORE_PRESENTATION_CATALOG.get(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 CURSOR_ITEM_BY_STYLE=new Map(CURSOR_ITEMS.map(item=>[item.cursorStyle,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'}; @@ -180,30 +228,49 @@ 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; + CHAIN_WINDOW=120000,SAVE_DELAY=90; const mirrorChunkPrefix=`${storageKey}:chunk:`; const MIRROR_IDLE_DELAY=5000,MIRROR_IDLE_TIMEOUT=15000,MIRROR_CHUNK_BYTES=64*1024,MIRROR_CHUNK_FORMAT='bend-field-chunked-v1',MINIMAP_CACHE_OVERSCAN_CHUNKS=4,LOD_CHANGES_PER_PASS=1,BOARD_RENDERS_PER_FRAME=6,HYDRATE_CONCURRENCY=4; const RETIRED_WORLD_STORES=Object.freeze([ Object.freeze({schema:31,generation:'v47-field-reset-20260728-bugfix'}), Object.freeze({schema:30,generation:'v47-field-reset-20260727'}) ]); -const world=document.querySelector('#world'),viewport=document.querySelector('#viewport'),topbar=document.querySelector('#topbar'),overviewCanvas=document.querySelector('#overviewCanvas'),noiseCanvas=document.querySelector('#noiseCanvas'), - solvedCountEl=document.querySelector('#solvedCount'),scoreCountEl=document.querySelector('#scoreCount'),selectedInfo=document.querySelector('#selectedInfo'), +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'), worldCountEl=document.querySelector('#worldCount'),fpsCounter=document.querySelector('#fpsCounter'),playerNameBtn=document.querySelector('#playerNameBtn'),settingsBtn=document.querySelector('#settingsBtn'), - minimapCanvas=document.querySelector('#minimapCanvas'),minimapStatus=document.querySelector('#minimapStatus'),clearFeed=document.querySelector('#clearFeed'),presenceCanvas=document.querySelector('#presenceCanvas'),reactionCanvas=document.querySelector('#reactionCanvas'),reactionRadial=document.querySelector('#reactionRadial'), + 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=document.querySelector('meta[name="bend-field-cloud-api"]')?.content==='on'; +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[`${cloudAppBaseUrl}api/`,cloudApiBridgeUrl,(()=>{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/',cloudApiPhpBridgeActive=false; +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 cloudApiPhpBridgeActive||Boolean(cloudApiBridgeUrl&&cloudApiBaseUrl===cloudApiBridgeUrl)} const sessionId=globalThis.crypto?.randomUUID?.()||`session-${Date.now()}-${Math.random().toString(36).slice(2)}`; const sessionRecoveryJournalKey=recoveryJournalPrefix+sessionId; const loadNotices=[]; let volatileRecovery=null,storageAccessError=null,serverClockOffset=null; const sessionWallClock=Date.now(),sessionMonotonic=globalThis.performance?.now?.()||0; function deepClone(value){if(globalThis.structuredClone)try{return globalThis.structuredClone(value)}catch(_){}return JSON.parse(JSON.stringify(value))} +function sameDataValue(a,b){ + if(Object.is(a,b))return true; + if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)!==Array.isArray(b))return false; + if(Array.isArray(a)){if(a.length!==b.length)return false;for(let index=0;index0)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={}; @@ -283,16 +350,9 @@ function normalizeCloudPending(raw){ return{metaIds:ids(raw?.metaIds),stateIds:ids(raw?.stateIds),deleted:ids(raw?.deleted),globalChanged:raw?.globalChanged===true}; } function normalizePlayerPurchases(raw){ - const purchases=[],seen=new Set(); - for(const source of Array.isArray(raw)?raw:[]){ - const purchaseId=typeof source?.purchaseId==='string'?source.purchaseId.slice(0,64):'',boardId=typeof source?.boardId==='string'?source.boardId.slice(0,32):'',item=storeItem(source?.itemId); - if(!purchaseId||!boardId||!item||seen.has(purchaseId))continue; - const boughtAt=Number(source.boughtAt); - seen.add(purchaseId);purchases.push({purchaseId,boardId,itemId:item.id,buyer:typeof source.buyer==='string'&&source.buyer.trim()?source.buyer.trim().slice(0,32):DEFAULT_PLAYER_NAME,boughtAt:Number.isFinite(boughtAt)&&boughtAt>0?boughtAt:0,paidCost:Number.isSafeInteger(source.paidCost)&&source.paidCost>0?Math.min(source.paidCost,MAX_SCORE):item.cost}); - } - return purchases; + 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(); @@ -316,7 +376,7 @@ function normalizePath(raw){ const color=value=>Number.isInteger(value)&&value>=0&&value{ if(!Array.isArray(cell)||cell.length!==2||!Number.isInteger(cell[0])||!Number.isInteger(cell[1]))return null; const key=ckey(cell[0],cell[1]); @@ -352,37 +412,29 @@ function normalizeSpecialCells(raw,validSet){ const kk=ckey(...keyCell),dk=ckey(...door);if(reserved.has(kk)||reserved.has(dk))continue; reserved.add(kk);reserved.add(dk);result.locks.push({key:keyCell,door}); } + const usedGateIndexes=new Set(); + for(const rawPair of Array.isArray(raw?.internalGates)?raw.internalGates:[]){ + const a=Number.isInteger(rawPair?.a)?rawPair.a:Number.isInteger(rawPair?.[0])?rawPair[0]:-1,b=Number.isInteger(rawPair?.b)?rawPair.b:Number.isInteger(rawPair?.[1])?rawPair[1]:-1; + const ga=gates[a],gb=gates[b];if(a<0||b<0||a===b||!ga||!gb||usedGateIndexes.has(a)||usedGateIndexes.has(b))continue; + const ac=[ga[0],ga[1]],bc=[gb[0],gb[1]],ak=ckey(...ac),bk=ckey(...bc);if(manhattan(ac,bc)!==1||reserved.has(ak)||reserved.has(bk))continue; + const[dr,dc]=SIDE_D[ga[2]]||[],[br,bcDelta]=SIDE_D[gb[2]]||[];if(ac[0]+dr!==bc[0]||ac[1]+dc!==bc[1]||bc[0]+br!==ac[0]||bc[1]+bcDelta!==ac[1])continue; + usedGateIndexes.add(a);usedGateIndexes.add(b);reserved.add(ak);reserved.add(bk);result.internalGates.push({a,b}); + } return result; } function normalizeStore(raw,paths){ - if(!isPlainObject(raw)||!Number.isInteger(raw.pathIndex)||raw.pathIndex<0||raw.pathIndex>=paths.length)return null; - const path=paths[raw.pathIndex]; - if(!path?.cells?.length)return null; + if(!isPlainObject(raw))return null; + const directCell=Array.isArray(raw.cell)&&raw.cell.length===2&&Number.isInteger(raw.cell[0])&&Number.isInteger(raw.cell[1])?[raw.cell[0],raw.cell[1]]:null, + legacyPath=Number.isInteger(raw.pathIndex)&&raw.pathIndex>=0&&raw.pathIndex0?purchase.boughtAt:0, - paidCost:Number.isSafeInteger(purchase?.paidCost)&&purchase.paidCost>0?Math.min(purchase.paidCost,MAX_SCORE):item.cost - }); + if(!STORE_ITEM_IDS.has(id)||seen.has(id))continue;const item=storeItem(id); + 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; - const itemIds=normalizeStoreItemIds(raw.itemIds); - return{ - owner:typeof raw.owner==='string'&&raw.owner.trim()?raw.owner.trim().slice(0,32):DEFAULT_PLAYER_NAME, - pathIndex:raw.pathIndex, - cellIndex:Math.max(0,Math.min(path.cells.length-1,Number.isInteger(raw.cellIndex)?raw.cellIndex:Math.floor(path.cells.length/2))), - 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 - }; + 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}; } function normalizeState(raw){ const state={paths:[],specialProgress:{crossings:[]},solved:false,expanded:false,expansionRetryRound:0,solvedBy:null,solvedById:null,solvedAt:null,scoreAwarded:0,scoreVersion:SCORE_VERSION,rewardIdentity:null,rewardCoefficient:null,store:null,rev:0,revAuthor:''}; @@ -410,12 +462,12 @@ function repairWarpNumberClues(puzzle,sourceNumbers){ const reserved=new Set();for(const cell of puzzle.specialCells.crossings||[])reserved.add(ckey(...cell));for(const pair of warps){reserved.add(ckey(...pair.a));reserved.add(ckey(...pair.b))}for(const lock of puzzle.specialCells.locks||[]){reserved.add(ckey(...lock.key));reserved.add(ckey(...lock.door))} const used=new Set(),numbers=[];let maxTurns=0,totalTurns=0; for(const path of puzzle.solution){ - const analysis=AppLogic.analyzePathTurns(path,puzzle.g,warps,true);if(!analysis.count||!analysis.cells.length)return null; + const analysis=AppLogic.analyzePathTurns(path,puzzle.g,warps,true,(puzzle.specialCells?.internalGates||[]).flatMap(pair=>[pair?.a,pair?.b]).filter(Number.isInteger));if(!analysis.count||!analysis.cells.length)return null; const bendKeys=new Set(analysis.cells.map(cell=>ckey(...cell))),existing=(sourceNumbers||[]).find(number=>path.cells.some(cell=>sameCell(cell,number))&&bendKeys.has(ckey(number[0],number[1]))&&!used.has(ckey(number[0],number[1]))), pool=analysis.cells.filter(cell=>!reserved.has(ckey(...cell))&&!used.has(ckey(...cell))),fallback=analysis.cells.filter(cell=>!used.has(ckey(...cell))),cell=existing?[existing[0],existing[1]]:(pool[0]||fallback[0]); if(!cell)return null;used.add(ckey(...cell));numbers.push([cell[0],cell[1],analysis.count]);maxTurns=Math.max(maxTurns,analysis.count);totalTurns+=analysis.count; } - const repaired=JSON.stringify(numbers)!==JSON.stringify(sourceNumbers||[])||maxTurns!==(puzzle.maxTurns||0)||totalTurns!==(puzzle.totalTurns||0); + const repaired=!sameDataValue(numbers,sourceNumbers||[])||maxTurns!==(puzzle.maxTurns||0)||totalTurns!==(puzzle.totalTurns||0); return{numbers,repaired,maxTurns,totalTurns}; } function normalizeStoredPuzzle(raw,chunks,targetLevel){ @@ -430,7 +482,7 @@ function normalizeStoredPuzzle(raw,chunks,targetLevel){ if(!gates.length||gates.length%2!==0)return null; const numbers=[]; for(const number of raw.n){if(!Array.isArray(number)||number.length!==3||!Number.isInteger(number[0])||!Number.isInteger(number[1])||!Number.isInteger(number[2])||number[2]<0||number[2]>512||!validSet.has(ckey(number[0],number[1])))return null;numbers.push([number[0],number[1],number[2]])} - const specialCells=normalizeSpecialCells(raw.specialCells,validSet),warpPairs=new Map(); + const specialCells=normalizeSpecialCells(raw.specialCells,validSet,gates),warpPairs=new Map(); for(const pair of specialCells.warps){warpPairs.set(ckey(...pair.a),ckey(...pair.b));warpPairs.set(ckey(...pair.b),ckey(...pair.a))} const solution=[]; for(const path of raw.solution){ @@ -457,7 +509,7 @@ function normalizeStoredPuzzle(raw,chunks,targetLevel){ }catch(_){return null} } function puzzleForStorage(puzzle){ - return puzzle?{g:puzzle.g,n:puzzle.n,valid:puzzle.valid,obstacles:Array.isArray(puzzle.obstacles)?puzzle.obstacles:[],specialCells:puzzle.specialCells||{crossings:[],warps:[],locks:[]},bounds:puzzle.bounds,axis:puzzle.axis,solution:puzzle.solution,level:puzzle.level,difficulty:puzzle.difficulty,maxTurns:puzzle.maxTurns,totalTurns:puzzle.totalTurns,style:puzzle.style,complexity:puzzle.complexity,regionalTarget:puzzle.regionalTarget,regionalFallback:puzzle.regionalFallback===true,regionalOutlier:puzzle.regionalOutlier===true,uniqueness:puzzle.uniqueness||null,solutionQuality:puzzle.solutionQuality||null,interactionBurden:puzzle.interactionBurden||null}:null; + return puzzle?{g:puzzle.g,n:puzzle.n,valid:puzzle.valid,obstacles:Array.isArray(puzzle.obstacles)?puzzle.obstacles:[],specialCells:puzzle.specialCells||{crossings:[],warps:[],locks:[],internalGates:[]},bounds:puzzle.bounds,axis:puzzle.axis,solution:puzzle.solution,level:puzzle.level,difficulty:puzzle.difficulty,maxTurns:puzzle.maxTurns,totalTurns:puzzle.totalTurns,style:puzzle.style,complexity:puzzle.complexity,regionalTarget:puzzle.regionalTarget,regionalFallback:puzzle.regionalFallback===true,regionalOutlier:puzzle.regionalOutlier===true,uniqueness:puzzle.uniqueness||null,solutionQuality:puzzle.solutionQuality||null,interactionBurden:puzzle.interactionBurden||null}:null; } function normalizeMeta(id,raw){ if(!/^B(?:0|[1-9]\d*)$/.test(id)||!isPlainObject(raw))return null; @@ -472,7 +524,7 @@ function normalizeMeta(id,raw){ return{id,x,y,chunks:chunks.map(q=>[q[0],q[1]]),level:puzzle.difficulty,targetLevel:target,seed:raw.seed>>>0,axis:puzzle.axis,entrySide:['N','S','W','E'].includes(raw.entrySide)?raw.entrySide:null,sealedSides,puzzle,generatorVersion:Number.isInteger(raw.generatorVersion)?raw.generatorVersion:GENERATOR_VERSION,rev:Number.isFinite(raw.rev)&&raw.rev>=0?raw.rev:0,revAuthor:typeof raw.revAuthor==='string'?raw.revAuthor.slice(0,128):''}; } function mechanicTypesForPuzzle(puzzle){ - const types=[];if(puzzle?.specialCells?.warps?.length)types.push('warp');if(puzzle?.specialCells?.locks?.length)types.push('lock');if(puzzle?.specialCells?.crossings?.length)types.push('crossing');return types; + const types=[];if(puzzle?.specialCells?.warps?.length)types.push('warp');if(puzzle?.specialCells?.locks?.length)types.push('lock');if(puzzle?.specialCells?.crossings?.length)types.push('crossing');if(puzzle?.specialCells?.internalGates?.length)types.push('internalGate');return types; } function normalizeSnapshot(raw,{quiet=false}={}){ const clean=defaultData(); @@ -503,10 +555,10 @@ 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; - const seen=new Set(Array.isArray(raw.specialMechanicsSeen)?raw.specialMechanicsSeen.filter(type=>['warp','lock','crossing'].includes(type)):[]); + 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(); clean.lastTimeAttack=normalizeTimeAttackResult(raw.lastTimeAttack); @@ -518,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; @@ -751,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)} @@ -824,7 +880,7 @@ async function clearDatabaseWorld(newEpoch=createWorldEpoch()){ }catch(error){await deleteV2Epoch(newEpoch).catch(()=>{});throw error} } let data=defaultData(); -const rendered=new Map(),staticRendered=new Map(); +const rendered=new Map(); const boardIndexSummaries=new Map(),hydratedBoardLru=new Map(); let hydratedBoardBytes=0; let occupancy=new Map(),closedVoidKeys=new Set(),activeBoard=null,hudBoardId=null,toastTimer=null,saveTimer=null,lastRevision=Date.now()*1000; @@ -832,12 +888,12 @@ 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; +let overviewFrame=0,overviewDirty=true,overviewLastDraw=0,overviewDelayTimer=0,overviewAllowInteractionBuild=false,overviewInteractionLastBuild=0; const minimapBase=document.createElement('canvas'),overviewBase=document.createElement('canvas'); let overviewCache=null; -const OVERVIEW_CACHE_OVERSCAN_PX=192; +const OVERVIEW_CACHE_OVERSCAN_PX=192,OVERVIEW_INTERACTION_REBUILD_INTERVAL=180; const adjacencyCache=new Map(); let lineGraphRevision=0,lineGraphCacheRevision=-1,lineComponentCache=new Map(),lineWidthCache=new Map(),lineMinimapGeometryCache=new WeakMap(); let minimapWorldRevision=0,worldGeometryRevision=0,minimapCache=null,minimapPointerState=null,fieldBoundsCache=null,orphanPruneDirty=true; @@ -856,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:staticRendered.size,domNodes:document.getElementsByTagName('*').length},elapsedSeconds,capturedAt:new Date().toISOString()}; + 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=globalThis.document?.body?.classList?.contains?.('is-interacting')?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=>{ @@ -880,17 +936,17 @@ 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; + const interacting=interactionActive(),idle=!interacting&&fpsFrameCount<=2&&now-fpsLastFrameAt>180; 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); const reducedMotionQuery=globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')||null; -let noiseTimer=0,noiseTick=0,noisePainted=false; +let noiseTick=0,noisePainted=false; function noiseHash(value){value=Math.imul(value^(value>>>16),0x7feb352d);value=Math.imul(value^(value>>>15),0x846ca68b);return(value^(value>>>16))>>>0} function paintNoiseBackground(staticFrame=false){ if(!noiseCanvas||document.visibilityState==='hidden')return false; @@ -905,36 +961,48 @@ function paintNoiseBackground(staticFrame=false){ } context.putImageData(image,0,0);noisePainted=true;perfCount('noiseFrames');return true; } -function pauseNoiseBackground(){if(noiseTimer){clearTimeout(noiseTimer);noiseTimer=0}} function scheduleNoiseBackground(force=false){ - pauseNoiseBackground();if(document.visibilityState==='hidden')return;if(force||!noisePainted)paintNoiseBackground(true); + if(document.visibilityState==='hidden')return;if(force||!noisePainted)paintNoiseBackground(true); } reducedMotionQuery?.addEventListener?.('change',()=>{noisePainted=false;scheduleNoiseBackground(true)}); const interactionCommitAt=Object.create(null),interactionInputAt=Object.create(null); const interactionIntervals=[]; -let interactionActiveStartedAt=0,interactionLastFrame=0,interactionFrames=0,interactionSlowFrames=0,interactionBestFrameGap=Infinity,interactionFrameOpportunities=0,interactionDroppedFrames=0,interactionQualityDowngradePending=false,wheelInteractionTimer=0,interactionSettleFrame=0; +let interactionActiveStartedAt=0,interactionLastFrame=0,interactionFrames=0,interactionSlowFrames=0,interactionBestFrameGap=Infinity,interactionFrameOpportunities=0,interactionDroppedFrames=0,wheelInteractionTimer=0,interactionSettleFrame=0; +let interactionDrawingBoard=null,interactionPendingClaimBoard=null,interactionStyledBoard=null,interactionLastActiveBoard=null; function recordInteractionCommit(kind,timestamp=perfNow(),inputAt=timestamp,sampleInputAge=true){ const previous=interactionCommitAt[kind]||0,previousInput=interactionInputAt[kind]||0,gap=previous?timestamp-previous:0,inputGap=previousInput&&Number.isFinite(inputAt)?inputAt-previousInput:0; if(gap>0&&gap<=120&&inputGap<=25)perfObserve(`${kind}FrameGap`,gap);interactionCommitAt[kind]=timestamp;interactionInputAt[kind]=inputAt; if(sampleInputAge&&Number.isFinite(inputAt)&&inputAt>0&&inputAt<=timestamp+1000)perfObserve(`${kind}InputAge`,Math.max(0,timestamp-inputAt)); } function refreshInteractionState(force=null){ - const drawingBoard=[...rendered.values()].find(board=>board.drawing?.pointerId!=null)||null,pendingClaimBoard=[...rendered.values()].find(board=>board.pendingClaimPointer)||null, - active=force==null?Boolean(pan||pinch||drawingBoard||pendingClaimBoard):Boolean(force); + const activeCandidate=rendered.get(activeBoard)||null, + drawingBoard=interactionDrawingBoard?.drawing?.pointerId!=null&&interactionDrawingBoard.card?.isConnected?interactionDrawingBoard:activeCandidate?.drawing?.pointerId!=null?activeCandidate:null, + pendingClaimBoard=interactionPendingClaimBoard?.pendingClaimPointer&&interactionPendingClaimBoard.card?.isConnected?interactionPendingClaimBoard:activeCandidate?.pendingClaimPointer?activeCandidate:null; + interactionState.set('camera',pan?.id??(pinch?'pinch':null)); + interactionState.set('drawing',drawingBoard?.drawing?.pointerId??null); + interactionState.set('claim',pendingClaimBoard?.pendingClaimPointer?.pointerId??null); + if(force===true)interactionState.set('wheel','wheel');else if(force===false)interactionState.clear('wheel'); + const active=interactionActive(); + interactionDrawingBoard=drawingBoard;interactionPendingClaimBoard=pendingClaimBoard; const wasActive=document.body.classList.contains('is-interacting'); document.body.classList.toggle('is-interacting',active); document.body.classList.toggle('is-drawing',Boolean(drawingBoard)); document.body.classList.toggle('is-claim-pending',Boolean(pendingClaimBoard)); - topbar?.classList.toggle('drawing-active',Boolean(drawingBoard));world?.classList.toggle('camera-interacting',Boolean(pan||pinch));noiseCanvas?.classList.toggle('interaction-muted',active); + topbar?.classList.toggle('drawing-active',Boolean(drawingBoard));world?.classList.toggle('camera-interacting',Boolean(pan||pinch)); const styledDrawingBoard=drawingBoard&&!usesLightweightDragOverlay(drawingBoard)?drawingBoard:null; - for(const board of rendered.values())board.card.classList.toggle('input-active',board===styledDrawingBoard); + const affectedBoards=new Set([interactionStyledBoard,styledDrawingBoard,interactionLastActiveBoard,activeCandidate,rendered.get(hudBoardId),drawingBoard,pendingClaimBoard]); + for(const board of affectedBoards){ + if(!board?.card?.isConnected)continue; + board.card.classList.toggle('input-active',board===styledDrawingBoard); + setBoardHudVisibility(board,boardPlayHudVisible(board)); + } + interactionStyledBoard=styledDrawingBoard;interactionLastActiveBoard=activeCandidate; if(active&&!wasActive)interactionActiveStartedAt=perfNow(); else if(!active&&wasActive&&interactionActiveStartedAt){interactionIntervals.push([interactionActiveStartedAt,perfNow()]);if(interactionIntervals.length>64)interactionIntervals.splice(0,interactionIntervals.length-64);interactionActiveStartedAt=0} if(!active){ interactionLastFrame=0;interactionFrames=0;interactionSlowFrames=0;interactionBestFrameGap=Infinity;interactionFrameOpportunities=0;interactionDroppedFrames=0; interactionCommitAt.camera=0;interactionCommitAt.pickupLogical=0;interactionCommitAt.pickupVisual=0;interactionCommitAt.pickupPreview=0; interactionInputAt.camera=0;interactionInputAt.pickupLogical=0;interactionInputAt.pickupVisual=0;interactionInputAt.pickupPreview=0; - if(interactionQualityDowngradePending&&!autoReducedEffects){interactionQualityDowngradePending=false;autoReducedEffects=true;applyUiSettings()} if(zoomDetailsDirty)updateZoomPresentation(true); } } @@ -942,7 +1010,7 @@ function scheduleInteractionSettlePresentation(){ if(interactionSettleFrame)return; interactionSettleFrame=requestAnimationFrame(()=>{ interactionSettleFrame=0; - if(document.body.classList.contains('is-interacting')){scheduleInteractionSettlePresentation();return} + if(interactionActive()){scheduleInteractionSettlePresentation();return} setPickupScenePresentation(null,false); updateZoomPresentation(true);scheduleLodPass();scheduleMinimap();scheduleWorldOverview(); }); @@ -956,11 +1024,11 @@ function observeInteractionFrame(timestamp=perfNow(),workDuration=0){ } const observedInterval=Number.isFinite(interactionBestFrameGap)?interactionBestFrameGap:16.67,slowWorkThreshold=Math.max(6,observedInterval*.7); interactionFrames++;if(workDuration>slowWorkThreshold)interactionSlowFrames++; - if(interactionFrames>=12){perfGauge('interactionSlowWorkRatio',interactionSlowFrames/interactionFrames);if(interactionSlowFrames>=6&&!autoReducedEffects)interactionQualityDowngradePending=true;interactionFrames=0;interactionSlowFrames=0} + if(interactionFrames>=12){perfGauge('interactionSlowWorkRatio',interactionSlowFrames/interactionFrames);interactionFrames=0;interactionSlowFrames=0} interactionLastFrame=timestamp; } function pulseWheelInteraction(){ - refreshInteractionState(true);observeInteractionFrame();clearTimeout(wheelInteractionTimer);wheelInteractionTimer=setTimeout(()=>{refreshInteractionState();recordCameraAnchor();ensureBoards();repositionActiveBoardHud();scheduleMinimap(true);redrawOnlineLayersAfterCamera()},140); + gestureCoordinator.claim('wheel','wheel',{replace:true});refreshInteractionState(true);observeInteractionFrame();clearTimeout(wheelInteractionTimer);wheelInteractionTimer=setTimeout(()=>{gestureCoordinator.release('wheel','wheel');refreshInteractionState(false);recordCameraAnchor();ensureBoards();repositionActiveBoardHud();scheduleMinimap(true);redrawOnlineLayersAfterCamera()},140); } function invalidateLineGraphCaches(boardId=null,pathIndex=null){ lineGraphRevision++; @@ -983,27 +1051,43 @@ function invalidateMinimapWorld(){minimapWorldRevision++;minimapDirty=true;minim function invalidateWorldPresentation(){invalidateMinimapWorld();overviewDirty=true;overviewCache=null} function svgEl(tag,attrs={}){const e=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const[k,v]of Object.entries(attrs))e.setAttribute(k,v);return e} let soundContext=null,lastStretchSoundAt=0; +function activeSoundContext(){ + const Audio=window.AudioContext||window.webkitAudioContext;if(!Audio)return null; + soundContext=soundContext&&soundContext.state!=='closed'?soundContext:new Audio();if(soundContext.state==='suspended')void soundContext.resume();return soundContext; +} function soundTone(frequency,duration=.08,{gain=.035,type='sine',slide=1,delay=0}={}){ if(!uiSettings.soundEnabled)return; try{ - const Audio=window.AudioContext||window.webkitAudioContext;if(!Audio)return; - soundContext=soundContext&&soundContext.state!=='closed'?soundContext:new Audio(); - if(soundContext.state==='suspended')void soundContext.resume(); - const now=soundContext.currentTime+delay,osc=soundContext.createOscillator(),amp=soundContext.createGain(); - osc.type=type;osc.frequency.setValueAtTime(frequency,now);osc.frequency.exponentialRampToValueAtTime(Math.max(40,frequency*slide),now+duration); - const outputGain=Math.min(.28,Math.max(.001,gain*SOUND_GAIN_MULTIPLIER)); - amp.gain.setValueAtTime(.0001,now);amp.gain.exponentialRampToValueAtTime(outputGain,now+.008);amp.gain.exponentialRampToValueAtTime(.0001,now+duration); - osc.connect(amp).connect(soundContext.destination);osc.start(now);osc.stop(now+duration+.02); + const context=activeSoundContext();if(!context)return;const now=context.currentTime+delay,osc=context.createOscillator(),amp=context.createGain(); + osc.type=type;osc.frequency.setValueAtTime(Math.max(40,frequency),now);osc.frequency.exponentialRampToValueAtTime(Math.max(40,frequency*slide),now+duration); + const outputGain=Math.min(.42,Math.max(.001,gain*SOUND_GAIN_MULTIPLIER)); + amp.gain.setValueAtTime(.0001,now);amp.gain.exponentialRampToValueAtTime(outputGain,now+.006);amp.gain.exponentialRampToValueAtTime(.0001,now+duration); + osc.connect(amp).connect(context.destination);osc.start(now);osc.stop(now+duration+.025); + }catch(_){} +} +function soundNoise(duration=.045,{gain=.012,delay=0,highpass=500}={}){ + if(!uiSettings.soundEnabled)return; + try{ + const context=activeSoundContext();if(!context)return;const now=context.currentTime+delay,length=Math.max(1,Math.ceil(context.sampleRate*duration)),buffer=context.createBuffer(1,length,context.sampleRate),channel=buffer.getChannelData(0); + for(let index=0;indexsoundTone(frequency,.16,{gain:.04-index*.004,type:index%2?'sine':'triangle',slide:1.35,delay}));soundNoise(.08,{gain:.012,delay:.15,highpass:2200})} + else if(kind==='buy'){soundTone(310,.075,{gain:.037,type:'square',slide:1.22});soundTone(465,.1,{gain:.033,type:'triangle',slide:1.35,delay:.045});soundTone(760,.08,{gain:.022,type:'sine',slide:.96,delay:.105})} + else if(kind==='reset'){soundTone(265,.1,{gain:.035,type:'triangle',slide:.55});soundTone(150,.13,{gain:.028,type:'sine',slide:.68,delay:.04});soundNoise(.065,{gain:.011,delay:.025,highpass:700})} + else if(kind==='remove'){soundTone(330,.075,{gain:.032,type:'sawtooth',slide:.42});soundTone(145,.09,{gain:.024,type:'triangle',slide:.72,delay:.035});soundNoise(.055,{gain:.014,highpass:900})} + else if(kind==='shop'){soundTone(220,.1,{gain:.031,type:'square',slide:1});soundTone(330,.12,{gain:.027,type:'triangle',slide:1.08,delay:.035});soundTone(660,.13,{gain:.021,type:'sine',slide:1.04,delay:.08})} + else if(kind==='warp'){soundTone(115,.16,{gain:.032,type:'sine',slide:3.4});soundTone(540,.12,{gain:.018,type:'triangle',slide:.58,delay:.035});soundNoise(.07,{gain:.009,delay:.02,highpass:1800})} + else if(kind==='key'){soundTone(520,.065,{gain:.027,type:'square',slide:1.16});soundTone(780,.09,{gain:.024,type:'sine',slide:1.12,delay:.04})} + else if(kind==='error'){soundTone(185,.11,{gain:.03,type:'sawtooth',slide:.86});soundTone(155,.12,{gain:.024,type:'square',slide:.9,delay:.055})} + else{soundTone(235,.05,{gain:.022,type:'triangle',slide:1.1});soundTone(355,.038,{gain:.012,type:'sine',slide:.95,delay:.018})} } function toast(message,duration=2200){const e=document.querySelector('#toast');e.textContent=message;e.classList.add('show');clearTimeout(toastTimer);toastTimer=setTimeout(()=>e.classList.remove('show'),duration)} function nextRevision(){lastRevision=Math.max(lastRevision+1,Date.now()*1000);return lastRevision} @@ -1014,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(); @@ -1049,7 +1140,7 @@ function createPuzzleWorker(){ if(message.generatorVersion!==GENERATOR_VERSION){const error=new Error('\u76e4\u9762\u751f\u6210\u6a5f\u80fd\u306e\u30d0\u30fc\u30b8\u30e7\u30f3\u304c\u4e00\u81f4\u3057\u307e\u305b\u3093\u3002');fallbackWorkerJob(message.id,error);restartPuzzleWorker(error);return} workerJobs.delete(message.id);clearTimeout(job.timeout);workerFailureCount=0; const deliver=()=>message.error?job.reject(new Error(message.error)):job.resolve(message.result??message.puzzle); - if(globalThis.document?.body?.classList?.contains?.('is-interacting')){perfCount('workerResultsDeferredDuringInteraction');waitForInteractionSettle().then(deliver)} + if(interactionActive('worker')){perfCount('workerResultsDeferredDuringInteraction');waitForInteractionSettle(null,'worker').then(deliver)} else deliver(); }; const failed=event=>{if(worker===puzzleWorker)restartPuzzleWorker(new Error(event?.message||'\u76e4\u9762\u751f\u6210\u51e6\u7406\u304c\u505c\u6b62\u3057\u307e\u3057\u305f\u3002'))}; @@ -1107,13 +1198,18 @@ function rebuildOccupancy(){ } function invalidateEconomyCaches(){inventoryCache=null;spentScoreCache=null;statsDirty=true} function stateStatSignature(state){return`${state?.solved===true?1:0}:${Math.max(0,Number(state?.scoreAwarded)||0)}`} -function stateEconomySignature(state){return JSON.stringify((state?.store?.purchases||[]).map(purchase=>[purchase.id,purchase.paidCost||0,purchase.boughtAt||0]))} -function economySignatureSpend(signature){ - let rows=[];try{rows=JSON.parse(signature||'[]')}catch(_){} - return rows.reduce((sum,row)=>{const item=storeItem(row?.[0]);return sum+(item?row[1]||item.cost:0)},0); +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`; + spent+=paid;for(let index=0;index>>0; + } + return{count:purchases.length,spent,hash}; } +function sameEconomySignature(a,b){return a===b||Boolean(a&&b&&a.count===b.count&&a.spent===b.spent&&a.hash===b.hash)} +function economySignatureSpend(signature){return signature?.spent||0} function rememberStateSignatures(id,state){ - const previousStat=stateStatSignatures.get(id),previousEconomy=stateEconomySignatures.get(id),stat=stateStatSignature(state),economy=stateEconomySignature(state),statChanged=previousStat!==stat,economyChanged=previousEconomy!==economy; + const previousStat=stateStatSignatures.get(id),previousEconomy=stateEconomySignatures.get(id),stat=stateStatSignature(state),economy=stateEconomySignature(state),statChanged=previousStat!==stat,economyChanged=!sameEconomySignature(previousEconomy,economy); stateStatSignatures.set(id,stat);stateEconomySignatures.set(id,economy); if(statChanged&&!statsDirty){ const[oldSolved,oldAward]=(previousStat||'0:0').split(':').map(Number),[newSolved,newAward]=stat.split(':').map(Number); @@ -1121,7 +1217,7 @@ function rememberStateSignatures(id,state){ score=earned==null?Math.max(0,cachedStats.score+awardDelta):Math.max(0,earned-(spentScoreCache||0)); cachedStats={solved:Math.max(0,cachedStats.solved+newSolved-oldSolved),earned,score}; } - if(economyChanged&&previousEconomy!=null){ + if(economyChanged&&previousEconomy!=null&&!personalEconomyMode()){ const spentDelta=economySignatureSpend(economy)-economySignatureSpend(previousEconomy); if(spentScoreCache!=null)spentScoreCache=Math.max(0,spentScoreCache+spentDelta); if(!statsDirty)cachedStats.score=Math.max(0,cachedStats.score-spentDelta); @@ -1134,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++; @@ -1157,57 +1252,69 @@ function replaceObjectContents(target,source){ for(const key of Object.keys(target))if(!(key in source))delete target[key]; Object.assign(target,source);return target; } -function metaState(id){ +function ensureMetaState(id,{dirty=true}={}){ if(!data.states[id]||typeof data.states[id]!=='object'){ - data.states[id]=normalizeState(null);normalizedStateObjects.add(data.states[id]);markStateDirty(id);return data.states[id]; + data.states[id]=normalizeState(null);normalizedStateObjects.add(data.states[id]);if(dirty)markStateDirty(id);return data.states[id]; } const state=data.states[id]; if(normalizedStateObjects.has(state))return state; - replaceObjectContents(state,normalizeState(state));normalizedStateObjects.add(state);markStateDirty(id);return state; + replaceObjectContents(state,normalizeState(state));normalizedStateObjects.add(state);if(dirty)markStateDirty(id);return state; } +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); return ids.map(storeItem).filter(Boolean); } let playerEconomyLoaded=false; -function onlinePlayerEconomy(){return Boolean(cloudApiEnabled&&cloudAvailable&&data.cloudProfile&&playerEconomyLoaded)} +function personalEconomyMode(){return Boolean(cloudApiEnabled&&data.cloudProfile)} +function onlinePlayerEconomy(){return Boolean(personalEconomyMode()&&cloudAvailable&&playerEconomyLoaded)} function playerPurchaseForStore(boardId,itemId){return normalizePlayerPurchases(data.playerPurchases).find(purchase=>purchase.boardId===boardId&&purchase.itemId===itemId)||null} function inventoryEntries(itemId=null){ if(!inventoryCache){ inventoryCache=[]; - if(onlinePlayerEconomy())for(const source of normalizePlayerPurchases(data.playerPurchases)){const item=storeItem(source.itemId);if(!item)continue;const meta=data.metas[source.boardId]||null,st=meta?metaState(meta.id):null,store=st?.store||null,purchase={...source,id:source.itemId};inventoryCache.push({meta,st,store,purchase,personal:true})} + if(personalEconomyMode())for(const source of normalizePlayerPurchases(data.playerPurchases)){const item=storeItem(source.itemId);if(!item)continue;const meta=data.metas[source.boardId]||null,st=meta?metaState(meta.id):null,store=st?.store||null,purchase={...source,id:source.itemId};inventoryCache.push({meta,st,store,purchase,personal:true})} else for(const meta of Object.values(data.metas)){const st=metaState(meta.id),store=st.store;if(!store)continue;for(const purchase of store.purchases)inventoryCache.push({meta,st,store,purchase,personal:false})} inventoryCache.sort((a,b)=>(a.purchase.boughtAt||0)-(b.purchase.boughtAt||0)); } 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(onlinePlayerEconomy())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(){ if(orphanPruneDirty){for(const id of Object.keys(data.states))if(!data.metas[id]){delete data.states[id];markBoardDeleted(id)}orphanPruneDirty=false} if(statsDirty){ - let solved=0,earned=onlinePlayerEconomy()?Math.max(0,Number(data.playerEarnedScore)||0):bonusEventTotal();data.bonusScore=onlinePlayerEconomy()?0:earned; - for(const id of Object.keys(data.metas)){const st=metaState(id);if(st.solved)solved++;if(!onlinePlayerEconomy())earned+=st.scoreAwarded} + let solved=0,earned=personalEconomyMode()?Math.max(0,Number(data.playerEarnedScore)||0):bonusEventTotal();data.bonusScore=personalEconomyMode()?0:earned; + 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(); @@ -1238,7 +1345,17 @@ function mergeBoardStates(current,incoming){ result.specialProgress=mergeSpecialProgress(current.specialProgress,incoming.specialProgress); result.rev=newer.rev||0;result.revAuthor=newer.revAuthor||'';return result; } -function sameMetaGeometry(a,b){return a&&b&&a.x===b.x&&a.y===b.y&&a.seed===b.seed&&JSON.stringify(a.chunks)===JSON.stringify(b.chunks)&&JSON.stringify(a.sealedSides||[])===JSON.stringify(b.sealedSides||[])} +function preserveSolvedBoardState(solvedSource,baseState){ + const base=solvedSource?._summaryOnly?baseState:solvedSource,preserved=deepClone(base||baseState||solvedSource||normalizeState(null)); + preserved.solved=true;preserved.expanded=Boolean(solvedSource?.expanded||preserved.expanded); + for(const key of['solvedBy','solvedById','solvedAt','rewardIdentity','rewardCoefficient'])if(solvedSource?.[key]!=null)preserved[key]=deepClone(solvedSource[key]); + preserved.scoreAwarded=Math.max(Number(preserved.scoreAwarded)||0,Number(solvedSource?.scoreAwarded)||0);preserved.scoreVersion=SCORE_VERSION; + preserved.specialProgress=mergeSpecialProgress(solvedSource?.specialProgress,preserved.specialProgress); + if(solvedSource?.store){if(preserved.store)preserved.store.purchases=mergePurchases(preserved.store.purchases,solvedSource.store.purchases);else preserved.store=deepClone(solvedSource.store)} + delete preserved._summaryOnly;return preserved; +} + +function sameMetaGeometry(a,b){return Boolean(a&&b&&a.x===b.x&&a.y===b.y&&a.seed===b.seed&&sameDataValue(a.chunks,b.chunks)&&sameDataValue(a.sealedSides||[],b.sealedSides||[]))} function resolveMergedOverlaps(){ const metas=Object.values(data.metas).sort((a,b)=>a.id==='B0'?-1:b.id==='B0'?1:(b.rev||0)-(a.rev||0)||a.id.localeCompare(b.id,undefined,{numeric:true})); const occupied=new Set(),removed=[]; @@ -1259,20 +1376,32 @@ function applyAuthoritativeSharedGlobal(external){ if(Number.isSafeInteger(external.nextId)&&external.nextId>0)data.nextId=external.nextId; if(Number.isSafeInteger(external.solved)&&external.solved>=0)data.solved=external.solved; if(Number.isFinite(external.lastSolveAt)&&external.lastSolveAt>=0)data.lastSolveAt=external.lastSolveAt; - if(Array.isArray(external.specialMechanicsSeen))data.specialMechanicsSeen=[...new Set(external.specialMechanicsSeen)].filter(type=>['warp','lock','crossing'].includes(type)).sort(); + if(Array.isArray(external.specialMechanicsSeen))data.specialMechanicsSeen=normalizeSpecialMechanics(external.specialMechanicsSeen); 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=[]; + const added=[],authoritativeCompatibleStateIds=authoritativeWorld?new Set():null; for(const[id,incoming]of Object.entries(external.metas)){ lastRevision=Math.max(lastRevision,incoming.rev||0);const current=data.metas[id]; if(authoritativeWorld){ + const compatible=!current||sameMetaGeometry(current,incoming);if(compatible)authoritativeCompatibleStateIds.add(id); clearSharedWorldJournalRow('meta',id); if(!current){data.metas[id]=incoming;added.push(id)} - else if(sameMetaGeometry(current,incoming))Object.assign(current,incoming); - else{destroyBoard(rendered.get(id));destroyStaticBoard(staticRendered.get(id));data.metas[id]=incoming;added.push(id)} + else if(compatible)Object.assign(current,incoming); + else{destroyBoard(rendered.get(id));data.metas[id]=incoming;added.push(id)} continue; } if(!current){data.metas[id]=incoming;added.push(id);continue} @@ -1285,12 +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){ - clearSharedWorldJournalRow('state',id); - merged=current?.solved&&!incoming.solved?deepClone(incoming):mergeBoardStates(current,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||JSON.stringify(merged)!==JSON.stringify(current)){ + 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}); } @@ -1307,13 +1439,15 @@ 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, - specialMechanicsSeen:[...new Set(source.specialMechanicsSeen||[])].filter(type=>['warp','lock','crossing'].includes(type)).sort(), + debugAllItems:false, + specialMechanicsSeen:normalizeSpecialMechanics(source.specialMechanicsSeen), cameraAnchor:anchor,selectedBoardId:source===data?(activeBoard||source.selectedBoardId||null):source.selectedBoardId||null,updatedAt,sessionId }; } @@ -1326,30 +1460,28 @@ function mergeGlobalRecords(current,incoming){ merged.clockFloor=Math.max(current.clockFloor||0,incoming.clockFloor||0);merged.cloudRevision=Math.max(current.cloudRevision||0,incoming.cloudRevision||0);merged.updatedAt=Math.max(current.updatedAt||0,incoming.updatedAt||0); merged.bonusEvents={};for(const source of[current.bonusEvents||{},incoming.bonusEvents||{}])for(const[id,value]of Object.entries(source))merged.bonusEvents[id]=Math.max(merged.bonusEvents[id]||0,value||0); merged.bonusScore=bonusEventTotal(merged.bonusEvents); - const personalPurchases=new Map();for(const purchase of[...(current.playerPurchases||[]),...(incoming.playerPurchases||[])]){if(!purchase?.purchaseId)continue;const prior=personalPurchases.get(purchase.purchaseId);if(!prior||(purchase.boughtAt||0)>=(prior.boughtAt||0))personalPurchases.set(purchase.purchaseId,deepClone(purchase))}merged.playerPurchases=[...personalPurchases.values()]; + const personalPurchases=new Map();for(const purchase of[...(current.playerPurchases||[]),...(incoming.playerPurchases||[])]){if(!purchase?.purchaseId)continue;const prior=personalPurchases.get(purchase.purchaseId);if(!prior||(purchase.boughtAt||0)>=(prior.boughtAt||0))personalPurchases.set(purchase.purchaseId,deepClone(purchase))}merged.playerPurchases=[...personalPurchases.values()];merged.playerEarnedScore=Math.max(Number(current.playerEarnedScore)||0,Number(incoming.playerEarnedScore)||0); merged.quarantine={};for(const source of[current.quarantine||{},incoming.quarantine||{}])for(const[id,value]of Object.entries(source)){const prior=merged.quarantine[id];if(!prior||(value?.failedAt||0)>=(prior?.failedAt||0))merged.quarantine[id]=deepClone(value)} if((older.lastSolveAt||0)>(merged.lastSolveAt||0))merged.lastSolveAt=older.lastSolveAt; if((older.timeAttackRev||0)>(merged.timeAttackRev||0)){merged.timeAttack=deepClone(older.timeAttack);merged.timeAttackRev=older.timeAttackRev;merged.timeAttackCooldowns=deepClone(older.timeAttackCooldowns);merged.lastTimeAttack=deepClone(older.lastTimeAttack)} else if((older.lastTimeAttack?.completedAt||0)>(merged.lastTimeAttack?.completedAt||0))merged.lastTimeAttack=deepClone(older.lastTimeAttack); - merged.specialMechanicsSeen=[...new Set([...(current.specialMechanicsSeen||[]),...(incoming.specialMechanicsSeen||[])])].filter(type=>['warp','lock','crossing'].includes(type)).sort(); + merged.specialMechanicsSeen=normalizeSpecialMechanics([...(current.specialMechanicsSeen||[]),...(incoming.specialMechanicsSeen||[])]); for(const retired of['combo','comboUntil','comboExpiresAt','comboTimer','bestCombo','undo','undoStack','redoStack'])delete merged[retired]; merged.globalRev=newer.globalRev||0;merged.globalRevAuthor=newer.globalRevAuthor||'';return merged; } function applyGlobalRecordToData(record){ if(!record)return; - const priorBonuses=JSON.stringify(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); - if(priorBonuses!==JSON.stringify(data.bonusEvents||{}))statsDirty=true; + const priorBonuses=deepClone(data.bonusEvents||{}); + 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} function stateForStorage(state){return AppLogic.stateForStorage(state,{scoreVersion:SCORE_VERSION,normalizeState})} function summarizeBoardV2(meta,state,epoch=data.worldEpoch){ const puzzle=meta?.puzzle,special=puzzle?.specialCells||{},store=state?.store; - let storeCell=null; - if(store&&Number.isInteger(store.pathIndex)&&Number.isInteger(store.cellIndex)){ - const cell=state.paths?.[store.pathIndex]?.cells?.[store.cellIndex];if(cell)storeCell=[cell[0],cell[1]]; - } + let storeCell=Array.isArray(store?.cell)?[store.cell[0],store.cell[1]]:null; + if(!storeCell&&store&&Number.isInteger(store.pathIndex)&&Number.isInteger(store.cellIndex)){const cell=state.paths?.[store.pathIndex]?.cells?.[store.cellIndex];if(cell)storeCell=[cell[0],cell[1]]} return{ epoch,id:meta.id,number:Number(meta.id.slice(1))||0,x:meta.x,y:meta.y,chunks:meta.chunks.map(chunk=>[chunk[0],chunk[1]]),level:meta.level,targetLevel:meta.targetLevel, seed:meta.seed,axis:meta.axis,entrySide:meta.entrySide||null,sealedSides:[...(meta.sealedSides||[])],metaRev:meta.rev||0,stateRev:state?.rev||0,metaRevAuthor:meta.revAuthor||'',stateRevAuthor:state?.revAuthor||'', @@ -1374,7 +1506,7 @@ function summaryStateFromIndex(index){ return{ paths:[],specialProgress:{crossings:[]},solved:index?.solved===true,expanded:index?.expanded===true,expansionRetryRound:0,solvedBy:typeof index?.solvedBy==='string'?index.solvedBy:null,solvedById:typeof index?.solvedById==='string'?index.solvedById:null,solvedAt:Number.isFinite(index?.solvedAt)?index.solvedAt:null, scoreAwarded:Math.max(0,Number(index?.scoreAwarded)||0),scoreVersion:SCORE_VERSION,rewardIdentity:null,rewardCoefficient:null, - store:shop?{owner:'',pathIndex:-1,cellIndex:-1,itemIds:[...(shop.itemIds||[])],purchases:deepClone(shop.purchases||[]),summaryCell:Array.isArray(shop.cell)?[...shop.cell]:null}:null, + store:shop?{owner:'',pathIndex:-1,cellIndex:-1,cell:Array.isArray(shop.cell)?[...shop.cell]:null,itemIds:[...(shop.itemIds||[])],purchases:deepClone(shop.purchases||[]),summaryCell:Array.isArray(shop.cell)?[...shop.cell]:null}:null, rev:index?.stateRev||0,revAuthor:index?.stateRevAuthor||index?.revAuthor||'',_summaryOnly:true }; } @@ -1575,8 +1707,8 @@ function scheduleMirrorCheckpoint(delay=MIRROR_IDLE_DELAY){ } async function persistDirtyToDb(options={}){ const{skipCloud=false}=options; - if(options.lifecycle!==true&&globalThis.document?.body?.classList?.contains?.('is-interacting')){perfCount('savesDeferredDuringInteraction');await waitForInteractionSettle(null)} - if(globalThis.document?.body?.classList?.contains?.('is-interacting'))perfCount('persistenceDuringInteraction'); + if(options.lifecycle!==true&&interactionActive('persistence')){perfCount('savesDeferredDuringInteraction');await waitForInteractionSettle(null,'persistence')} + if(interactionActive('persistence'))perfCount('persistenceDuringInteraction'); const started=perfStart();pruneAndCount(); if(!hasPendingPersistence()){perfEnd('persistDirtyToDb',started);return{updatedAt:data.updatedAt||data.clockFloor||0,count:0,skipped:true}} data.worldEpoch=validWorldEpoch(data.worldEpoch)?data.worldEpoch:storedWorldEpoch()||createWorldEpoch();rememberWorldEpoch(data.worldEpoch); @@ -1680,13 +1812,13 @@ 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}; } async function persistNow(options={}){ if(lifecyclePersistenceSuppressed)return false; - if(options.lifecycle!==true&&globalThis.document?.body?.classList?.contains?.('is-interacting')){perfCount('savesDeferredDuringInteraction');await waitForInteractionSettle(null)} + if(options.lifecycle!==true&&interactionActive('persistence')){perfCount('savesDeferredDuringInteraction');await waitForInteractionSettle(null,'persistence')} clearTimeout(saveTimer);saveTimer=null; const run=async()=>{if(lifecyclePersistenceSuppressed)return false;try{ const result=await(options.lockHeld===true?persistDirtyToDb(options):withWorldMutationLock(()=>persistDirtyToDb(options))); @@ -1701,7 +1833,7 @@ async function persistNow(options={}){ } function flushSave(options={}){return persistNow(options)} function runDeferredSave(){ - if(document.body?.classList?.contains('is-interacting')){clearTimeout(saveTimer);saveTimer=setTimeout(runDeferredSave,SAVE_DELAY);perfCount('savesDeferredDuringInteraction');return} + if(interactionActive('persistence')){clearTimeout(saveTimer);saveTimer=setTimeout(runDeferredSave,SAVE_DELAY);perfCount('savesDeferredDuringInteraction');return} void persistNow(); } function save(immediate=false,options={}){ @@ -1711,26 +1843,39 @@ function save(immediate=false,options={}){ setSaveStatus('saving','\u4fdd\u5b58\u4e2d');clearTimeout(saveTimer);saveTimer=setTimeout(runDeferredSave,SAVE_DELAY);return true; } function updatePlayerNameUi(){if(!playerNameBtn)return;const name=currentPlayerName();playerNameBtn.textContent=name;playerNameBtn.title=`プレイヤー名を変更:${name}`} -function updateHud(){pruneAndCount();worldCountEl.textContent=Object.keys(data.metas).length;updatePlayerNameUi();applyUiSettings();syncCursorAppearance(data.cursorStyle||'default');document.body.dataset.debugItems=data.debugAllItems?'on':'off';updateInventoryUi();updateTimeAttackUi()} +function normalizePlayerNameInput(value){return String(value??'').replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24)} +async function commitPlayerProfileName(value){ + const requested=normalizePlayerNameInput(value);if(!requested)throw Object.assign(new Error('プレイヤー名を入力してください。'),{code:'INVALID_PLAYER_NAME'}); + 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(); + } + data.playerName=committed;markGlobalDirty(false); + if(!await persistNow({skipCloud:true}))throw new Error('プレイヤー名を端末へ保存できませんでした。'); + updatePlayerNameUi();renderAll();schedulePresenceRender(true);return committed; +} +function updateHud(){pruneAndCount();worldCountEl.textContent=Object.keys(data.metas).length;updatePlayerNameUi();updateInventoryUi();updateTimeAttackUi()} function changed(id,pathIndex=null){const st=metaState(id);st.rev=nextRevision();st.revAuthor=sessionId;markStateDirty(id,pathIndex);save()} const SHAPES_BY_SIZE=new Map();for(const s of SHAPES){const n=s.length;if(!SHAPES_BY_SIZE.has(n))SHAPES_BY_SIZE.set(n,[]);SHAPES_BY_SIZE.get(n).push(s)} function shapeCandidatesForLevel(seed,level,attemptsPerSize=12){return AppLogic.shapeCandidatesForLevel(seed,level,attemptsPerSize,{shapesBySize:SHAPES_BY_SIZE,hash32,rngFrom,shuffle})} function gateConnectionAllowed(meta,gateIndex){ if(!meta?.puzzle||!Number.isInteger(gateIndex))return false; - const gate=puzzleOf(meta).g[gateIndex];return Boolean(gate&&!(meta.sealedSides||[]).includes(gate[2])); + const puzzle=puzzleOf(meta),gate=puzzle.g[gateIndex],internal=(puzzle.specialCells?.internalGates||[]).some(pair=>pair?.a===gateIndex||pair?.b===gateIndex);return Boolean(gate&&(internal||!(meta.sealedSides||[]).includes(gate[2]))); } function boardMeta(id){return data.metas[id]} function puzzleOf(meta){ if(!meta?.puzzle)throw new Error(`\u76e4\u9762${meta?.id||'?'}\u306e\u8aad\u307f\u8fbc\u307f\u304c\u5b8c\u4e86\u3057\u3066\u3044\u307e\u305b\u3093\u3002`); - if(meta.puzzle.difficulty==null) - meta.puzzle.difficulty=solverDifficulty(meta.puzzle,meta.targetLevel??meta.level??1); - if(meta.level!==meta.puzzle.difficulty){meta.level=meta.puzzle.difficulty;markMetaDirty(meta.id)} return meta.puzzle } +function repairPuzzleDifficulty(meta,{dirty=true}={}){ + const puzzle=puzzleOf(meta);if(puzzle.difficulty==null)puzzle.difficulty=solverDifficulty(puzzle,meta.targetLevel??meta.level??1); + if(meta.level===puzzle.difficulty)return false;meta.level=puzzle.difficulty;if(dirty)markMetaDirty(meta.id);return true; +} function validateHydratedMeta(meta){ if(!meta)throw new Error('\u76e4\u9762\u60c5\u5831\u304c\u3042\u308a\u307e\u305b\u3093\u3002'); if(!meta.puzzle)throw new Error(`\u76e4\u9762${meta.id}\u306e\u30c7\u30fc\u30bf\u304c\u3042\u308a\u307e\u305b\u3093\u3002`); - puzzleOf(meta);sanitizeStateForPuzzle(meta); + repairPuzzleDifficulty(meta);sanitizeStateForPuzzle(meta); if(data.quarantine)delete data.quarantine[meta.id]; return meta; } @@ -1738,14 +1883,14 @@ function rememberHydratedBoard(id,bytes){ const previous=hydratedBoardLru.get(id);if(previous)hydratedBoardBytes-=previous.bytes; hydratedBoardLru.delete(id);hydratedBoardLru.set(id,{bytes});hydratedBoardBytes+=bytes;evictHydratedBoardDetails(); } -function hydratedBoardPinned(id){return id===activeBoard||dirtyMetaIds.has(id)||dirtyStateIds.has(id)||rendered.has(id)||staticRendered.has(id)||pendingVisibleHydrations?.has?.(id)||openStoreBoardId===id} +function hydratedBoardPinned(id){return id===activeBoard||dirtyMetaIds.has(id)||dirtyStateIds.has(id)||rendered.has(id)||pendingVisibleHydrations?.has?.(id)||openStoreBoardId===id} function evictHydratedBoardDetails(){ let attempts=0; while((hydratedBoardLru.size>32||hydratedBoardBytes>8*1024*1024)&&attemptspair?.a===gateIndex||pair?.b===gateIndex))continue;const g=p.g[gateIndex],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],neighbor=metaAtGlobalCell(gr+dr,gc+dc);if(neighbor&&neighbor.id!==meta.id&&!neighbor.puzzle)ids.add(neighbor.id)} for(const id of ids){try{await hydrateMeta(data.metas[id])}catch(error){console.warn(`BEND FIELD: adjacent hydration deferred for ${id}`,error)}} adjacencyCache.clear(); } -function matchingNeighborGate(meta,gi){const cacheKey=meta.id+':'+gi;if(adjacencyCache.has(cacheKey))return adjacencyCache.get(cacheKey);if(!meta?.puzzle||!gateConnectionAllowed(meta,gi)){adjacencyCache.set(cacheKey,null);return null}const p=puzzleOf(meta),g=p.g[gi],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc,nb=metaAtGlobalCell(nr,nc);if(!nb||nb.id===meta.id||!nb.puzzle){adjacencyCache.set(cacheKey,null);return null}const np=puzzleOf(nb),lr=nr-nb.y*5,lc=nc-nb.x*5;if(!cellSet(np).has(ckey(lr,lc))){adjacencyCache.set(cacheKey,null);return null}const ngi=gateAtCell(np,[lr,lc],null,OPP[g[2]]);const hit=ngi==null||!gateConnectionAllowed(nb,ngi)?null:{meta:nb,gateIndex:ngi};adjacencyCache.set(cacheKey,hit);return hit} +function matchingNeighborGate(meta,gi){const cacheKey=meta.id+':'+gi;if(adjacencyCache.has(cacheKey))return adjacencyCache.get(cacheKey);if(!meta?.puzzle||!gateConnectionAllowed(meta,gi)){adjacencyCache.set(cacheKey,null);return null}const p=puzzleOf(meta);if((p.specialCells?.internalGates||[]).some(pair=>pair?.a===gi||pair?.b===gi)){adjacencyCache.set(cacheKey,null);return null}const g=p.g[gi],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc,nb=metaAtGlobalCell(nr,nc);if(!nb||nb.id===meta.id||!nb.puzzle){adjacencyCache.set(cacheKey,null);return null}const np=puzzleOf(nb),lr=nr-nb.y*5,lc=nc-nb.x*5;if(!cellSet(np).has(ckey(lr,lc))){adjacencyCache.set(cacheKey,null);return null}const ngi=gateAtCell(np,[lr,lc],null,OPP[g[2]]);const hit=ngi==null||!gateConnectionAllowed(nb,ngi)?null:{meta:nb,gateIndex:ngi};adjacencyCache.set(cacheKey,hit);return hit} function pathUsesGate(path,gi){return Boolean(path&&((!path.detachedStart&&path.startGate===gi)||path.endGate===gi||path.openGate===gi))} function gateUsedPath(st,gi){return st.paths.find(path=>path&&Array.isArray(path.cells)&&pathUsesGate(path,gi))||null} function pathColorIndexAtGate(path,gi){ @@ -1868,7 +2015,7 @@ function syncBoundaryConnections({rebuild=true,persist=true,updateProgress=true} return touched.size; } function refreshWorldView(options={}){ - if(globalThis.document?.body?.classList?.contains?.('is-interacting'))return waitForInteractionSettle().then(()=>refreshWorldView(options)); + if(interactionActive('world'))return waitForInteractionSettle(undefined,'world').then(()=>refreshWorldView(options)); const{rebuild=true,syncConnections=true,markStats=false,resumeTimer=false,hide=false,persist=false,immediate=false,lockHeld=false}=options; if(rebuild)rebuildOccupancy(); if(syncConnections)syncBoundaryConnections({rebuild:false,persist:false,updateProgress:false}); @@ -1877,10 +2024,10 @@ function refreshWorldView(options={}){ if(resumeTimer)resumeTimeAttackTimer();if(hide)hideStatus(); return persist?save(immediate,{lockHeld}):true; } -function waitForInteractionSettle(counter='worldRefreshesDeferredDuringInteraction'){ - if(!globalThis.document?.body?.classList?.contains?.('is-interacting'))return Promise.resolve(); +function waitForInteractionSettle(counter='worldRefreshesDeferredDuringInteraction',scope='any'){ + if(!interactionActive(scope))return Promise.resolve(); if(counter)perfCount(counter); - return new Promise(resolve=>{const check=()=>globalThis.document?.body?.classList?.contains?.('is-interacting')?setTimeout(check,40):resolve();check()}); + return new Promise(resolve=>{const unsubscribe=interactionState.subscribe(()=>{if(!interactionActive(scope)){unsubscribe();resolve()}})}); } function fitsMeta(x,y,chunks){ @@ -1951,11 +2098,11 @@ function storeWorldCenter(meta){ } function worldSeedValue(){return data.metas.B0?.seed??STARTER_SEED} function storePriceLocation(meta,store){ - const path=store&&metaState(meta.id).paths[store.pathIndex],cell=path?.cells?.[store.cellIndex]; + const direct=Array.isArray(store?.cell)?store.cell:null,path=store&&metaState(meta.id).paths[store.pathIndex],cell=direct||path?.cells?.[store.cellIndex]; return cell?[meta.x+(cell[1]+.5)/CHUNK,meta.y+(cell[0]+.5)/CHUNK]:storeWorldCenter(meta); } function storeCellForMeta(meta){ - const state=meta&&metaState(meta.id),store=state?.store,path=store&&state.paths[store.pathIndex],cell=path?.cells?.[store.cellIndex]; + const state=meta&&metaState(meta.id),store=state?.store,direct=Array.isArray(store?.cell)?store.cell:null,path=store&&state.paths[store.pathIndex],cell=direct||path?.cells?.[store.cellIndex]; return Array.isArray(cell)?cell:Array.isArray(store?.summaryCell)?store.summaryCell:null; } function storePriceDetails(meta,store){ @@ -2024,7 +2171,7 @@ function repairFacingGateConnections(meta){ if(!meta?.puzzle)return 0; const p=puzzleOf(meta);let changed=0; for(let gateIndex=0;gateIndexpair?.a===gateIndex||pair?.b===gateIndex)||!gateConnectionAllowed(meta,gateIndex))continue; const g=p.g[gateIndex],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc, neighbor=metaAtGlobalCell(nr,nc); if(!neighbor||neighbor.id===meta.id||!neighbor.puzzle)continue; @@ -2040,7 +2187,7 @@ function gateFrontierCandidates(meta){ if(!meta?.puzzle)return[]; const p=puzzleOf(meta),map=new Map(); for(let gateIndex=0;gateIndexpair?.a===gateIndex||pair?.b===gateIndex)||!gateConnectionAllowed(meta,gateIndex)||matchingNeighborGate(meta,gateIndex))continue; const g=p.g[gateIndex],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc, targetUnitX=Math.floor(nc/5),targetUnitY=Math.floor(nr/5),key=key2(targetUnitX,targetUnitY); if(occupancy.has(key))continue; @@ -2073,7 +2220,7 @@ function missingGateConnections(meta){ if(!meta?.puzzle)return[]; const p=puzzleOf(meta),missing=[]; for(let gateIndex=0;gateIndexpair?.a===gateIndex||pair?.b===gateIndex)||!gateConnectionAllowed(meta,gateIndex)||matchingNeighborGate(meta,gateIndex))continue; const g=p.g[gateIndex],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc, blocker=metaAtGlobalCell(nr,nc); missing.push({gateIndex,side:g[2],globalRow:gr,globalCol:gc,neighborRow:nr,neighborCol:nc,blockedBy:blocker?.id||null}); @@ -2088,7 +2235,7 @@ function placementConnectionRequirements(x,y,chunks){ if(!meta?.puzzle)continue; const p=puzzleOf(meta); for(let gateIndex=0;gateIndexpair?.a===gateIndex||pair?.b===gateIndex)||!gateConnectionAllowed(meta,gateIndex)||matchingNeighborGate(meta,gateIndex))continue; const g=p.g[gateIndex],[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]],nr=gr+dr,nc=gc+dc, targetUnitX=Math.floor(nc/5),targetUnitY=Math.floor(nr/5); if(!placed.has(key2(targetUnitX,targetUnitY)))continue; @@ -2154,6 +2301,7 @@ function generatedPuzzleIssue(puzzle){ } for(const pair of special.warps)if(!valid.has(ckey(...pair.a))||!valid.has(ckey(...pair.b)))return'\u30ef\u30fc\u30d7\u30bb\u30eb\u304c\u76e4\u9762\u5916'; for(const lock of special.locks)if(!valid.has(ckey(...lock.key))||!valid.has(ckey(...lock.door)))return'\u9375\u307e\u305f\u306f\u6249\u304c\u76e4\u9762\u5916'; + const internalSeen=new Set();for(const pair of special.internalGates||[]){const a=pair?.a,b=pair?.b,ga=puzzle.g[a],gb=puzzle.g[b];if(!Number.isInteger(a)||!Number.isInteger(b)||a===b||!ga||!gb||internalSeen.has(a)||internalSeen.has(b))return'盤面内ゲートが不正';const da=SIDE_D[ga[2]],db=SIDE_D[gb[2]];if(manhattan(ga,gb)!==1||ga[0]+da[0]!==gb[0]||ga[1]+da[1]!==gb[1]||gb[0]+db[0]!==ga[0]||gb[1]+db[1]!==ga[1])return'盤面内ゲートの向きが不正';internalSeen.add(a);internalSeen.add(b)} return null; } @@ -2204,18 +2352,26 @@ function addObstaclePattern(sourcePuzzle,seed){ return puzzle; } function specialCellSet(p){ - if(!p.specialCells)p.specialCells={crossings:[],warps:[],locks:[]}; + if(!p.specialCells)p.specialCells={crossings:[],warps:[],locks:[],internalGates:[]}; if(!Array.isArray(p.specialCells.crossings))p.specialCells.crossings=[]; if(!Array.isArray(p.specialCells.warps))p.specialCells.warps=[]; if(!Array.isArray(p.specialCells.locks))p.specialCells.locks=[]; + if(!Array.isArray(p.specialCells.internalGates))p.specialCells.internalGates=[]; return p.specialCells; } -function invalidateSpecialCellCaches(p){if(p){delete p._warpMap}} +function internalGateIndexes(p){ + const indexes=[];for(const pair of specialCellSet(p).internalGates||[]){if(Number.isInteger(pair?.a))indexes.push(pair.a);if(Number.isInteger(pair?.b))indexes.push(pair.b)}return indexes; +} +function internalGateIndexSet(p){if(!p)return new Set();return p._internalGateIndexes||(p._internalGateIndexes=new Set(internalGateIndexes(p)))} +function isInternalGateIndex(p,index){return internalGateIndexSet(p).has(index)} +function internalGatePartner(p,index){for(const pair of specialCellSet(p).internalGates||[]){if(pair?.a===index)return pair.b;if(pair?.b===index)return pair.a}return null} +function invalidateSpecialCellCaches(p){if(p){delete p._warpMap;delete p._internalGateIndexes;delete p._gateCells}} function reservedSpecialKeys(p){ const keys=new Set(),special=specialCellSet(p); for(const cell of special.crossings)keys.add(ckey(...cell)); for(const pair of special.warps){keys.add(ckey(...pair.a));keys.add(ckey(...pair.b))} for(const lock of special.locks){keys.add(ckey(...lock.key));keys.add(ckey(...lock.door))} + for(const pair of special.internalGates){const a=p.g?.[pair.a],b=p.g?.[pair.b];if(a)keys.add(ckey(a[0],a[1]));if(b)keys.add(ckey(b[0],b[1]))} return keys; } function rebuildSolutionClues(p,seed){ @@ -2277,7 +2433,7 @@ function buildCrossingTemplate(p,reserved){ const crossing=offsetCell([2,2]),validKeys=new Set();for(const path of p.solution)for(const cell of path.cells)validKeys.add(ckey(...cell)); const nextValid=allCells.filter(cell=>validKeys.has(ckey(...cell))),nextObstacles=allCells.filter(cell=>!validKeys.has(ckey(...cell))); if(nextObstacles.length>obstacleCellLimit(p))return false; - p.valid=nextValid;p.obstacles=nextObstacles;p.n=[];p.specialCells={crossings:[crossing],warps:[],locks:[]};invalidateSpecialCellCaches(p);reserved.add(ckey(...crossing));return true; + p.valid=nextValid;p.obstacles=nextObstacles;p.n=[];p.specialCells={crossings:[crossing],warps:[],locks:[],internalGates:[]};invalidateSpecialCellCaches(p);reserved.add(ckey(...crossing));return true; } function addCrossingSpecial(p,rng,reserved){ const valid=cellSet(p),gateCells=new Set(p.g.map(g=>ckey(g[0],g[1]))),candidates=[]; @@ -2310,6 +2466,31 @@ function addCrossingSpecial(p,rng,reserved){ return Boolean(crossingStateAtCell({paths:p.solution},p,chosen.cell)); } +function addInternalGateSpecial(p,rng,reserved){ + const valid=cellSet(p),gateCells=new Set(p.g.map(g=>ckey(g[0],g[1]))),candidates=[],sideFor=(a,b)=>b[0]a[0]?'S':b[1]{ + for(let cut=1;cut{const keyIndex=path.cells.findIndex(cell=>sameCell(cell,lock.key)),doorIndex=path.cells.findIndex(cell=>sameCell(cell,lock.door));return keyIndex>=0&&doorIndex>=0&&(keyIndex<=cut)!==(doorIndex<=cut)});if(separatesLock)continue; + // Internal gates are rendered at cell centers, so even a cell on the outer row/column remains an in-board gate rather than a world-edge gate. + if(!valid.has(ak)||!valid.has(bk))continue; + candidates.push({pathIndex,cut,a:[...a],b:[...b],balance:Math.min(cut,path.cells.length-cut-2)}); + } + }); + if(!candidates.length)return false; + candidates.sort((a,b)=>b.balance-a.balance||rng()-.5); + const preferred=candidates.splice(Math.floor(rng()*Math.min(10,candidates.length)),1)[0],ordered=[preferred,...candidates]; + for(const chosen of ordered){ + const path=p.solution[chosen.pathIndex],aIndex=p.g.length,bIndex=p.g.length+1,sideA=sideFor(chosen.a,chosen.b),sideB=OPP[sideA], + prefix={...path,endGate:aIndex,cells:path.cells.slice(0,chosen.cut+1)},suffix={...path,startGate:bIndex,cells:path.cells.slice(chosen.cut+1)},special=specialCellSet(p); + p.g.push([chosen.a[0],chosen.a[1],sideA],[chosen.b[0],chosen.b[1],sideB]);p.solution.splice(chosen.pathIndex,1,prefix,suffix);special.internalGates.push({a:aIndex,b:bIndex});invalidateSpecialCellCaches(p); + const clueable=[prefix,suffix].every(segment=>{const analysis=turnAnalysis(segment,p);return analysis.count>0&&analysis.cells.length>0}); + if(clueable){reserved.add(ckey(...chosen.a));reserved.add(ckey(...chosen.b));return true} + special.internalGates.pop();p.solution.splice(chosen.pathIndex,2,path);p.g.length=aIndex;invalidateSpecialCellCaches(p); + } + return false; +} + function recentSpecialMechanicTypes(limit=3){ const result=[],metas=Object.values(data.metas||{}).sort((left,right)=>(Number(right.id?.slice(1))||0)-(Number(left.id?.slice(1))||0)); for(const meta of metas)for(const type of mechanicTypesForPuzzle(meta.puzzle)){result.push(type);if(result.length>=limit)return result.reverse()} @@ -2319,18 +2500,20 @@ function addScheduledSpecial(puzzle,type,rng,reserved){ if(type==='warp')return addWarpSpecial(puzzle,rng,reserved); if(type==='lock')return addLockSpecial(puzzle,rng,reserved); if(type==='crossing')return addCrossingSpecial(puzzle,rng,reserved); + if(type==='internalGate')return addInternalGateSpecial(puzzle,rng,reserved); return false; } function specialCellUsage(puzzle){ const special=specialCellSet(puzzle); - return(special.crossings?.length||0)+(special.warps?.length||0)*2+(special.locks?.length||0)*2; + return(special.crossings?.length||0)+(special.warps?.length||0)*2+(special.locks?.length||0)*2+(special.internalGates?.length||0)*2; } function addSpecialCellPattern(sourcePuzzle,seed,level){ - const effectiveLevel=SPECIAL_CELL_DEBUG_ALL_LEVELS?Math.max(SPECIAL_CELL_MIN_LEVEL,level):level, - schedule=AppLogic.specialSchedule(effectiveLevel,sourcePuzzle.valid?.length||0,seed,data.specialMechanicsSeen||[],recentSpecialMechanicTypes()); - if(!schedule.types.length)return sourcePuzzle; - const puzzle=deepClone(sourcePuzzle),special=specialCellSet(puzzle),reserved=reservedSpecialKeys(puzzle); - special.crossings=[];special.warps=[];special.locks=[];invalidateSpecialCellCaches(puzzle);reserved.clear(); + const scheduled=AppLogic.specialSchedule(level,sourcePuzzle.valid?.length||0,seed,data.specialMechanicsSeen||[],recentSpecialMechanicTypes()), + requiredTypes=[...scheduled.types]; + if(!requiredTypes.length)return sourcePuzzle; + const schedule={...scheduled,types:requiredTypes,setCount:Math.max(scheduled.setCount||0,requiredTypes.length),maxCells:Math.max(scheduled.maxCells||0,requiredTypes.reduce((sum,type)=>sum+(type==='crossing'?1:2),0))}, + puzzle=deepClone(sourcePuzzle),special=specialCellSet(puzzle),reserved=reservedSpecialKeys(puzzle); + special.crossings=[];special.warps=[];special.locks=[];special.internalGates=[];invalidateSpecialCellCaches(puzzle);reserved.clear(); const plan=[...schedule.types];for(let index=plan.length;index=39)issue='\u64cd\u4f5c\u8ca0\u62c5\u304c\u4e0a\u9650\u3092\u8d85\u904e'; @@ -2499,8 +2683,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}); @@ -2570,7 +2750,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++; } @@ -2582,7 +2762,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++} } @@ -2590,8 +2770,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){ @@ -2622,8 +2802,8 @@ function crossingExitDirection(st,p,pathIndex){ if(!crossingKeys(p).includes(key)||pathIndexesAtCell(st,key).length<2)return null; const previous=path.cells[path.cells.length-2];return[cell[0]-previous[0],cell[1]-previous[1]]; } -function gateObj(p,i){const g=p.g[i];return{i,cell:[g[0],g[1]],side:g[2]}} -function outsidePoint(g){const[dR,dC]=SIDE_D[g.side];return[g.cell[0]+dR,g.cell[1]+dC]} +function gateObj(p,i){const g=p.g[i];return{i,cell:[g[0],g[1]],side:g[2],internal:isInternalGateIndex(p,i)}} +function outsidePoint(g){if(g?.internal)return null;const[dR,dC]=SIDE_D[g.side];return[g.cell[0]+dR,g.cell[1]+dC]} function detachedTurnAnalysis(path,p){ const cells=[],segments=[];let start=0; for(let index=1;index[pair?.a,pair?.b]).filter(Number.isInteger);return path?.detachedStart?detachedTurnAnalysis(path,p):AppLogic.analyzePathTurns(path,p.g,special.warps,includeEnd,internal)} function turnAnalysis(path,p){return analyzeTurns(path,p,true)} function partialTurnCount(path,p){return analyzeTurns(path,p,false).count} function numbersForPath(path,p){return p.n.filter(n=>path.cells.some(c=>c[0]===n[0]&&c[1]===n[1])).map(n=>({cell:[n[0],n[1]],value:n[2]}))} @@ -2676,14 +2856,16 @@ function sanitizeStateForPuzzle(meta,{quiet=false}={}){ } if(removed){st.paths.splice(0,st.paths.length,...kept);st.rev=nextRevision();markStateDirty(meta.id);if(!quiet)loadNotices.push(`${meta.id}\u306e\u7834\u640d\u3057\u305f\u7dda\u3092${removed}\u672c\u524a\u9664\u3057\u307e\u3057\u305f\u3002`)} if(st.specialProgress?.crossings?.length){st.specialProgress={crossings:[]};st.rev=nextRevision();markStateDirty(meta.id)} - const solved=isSolved(st,p); + if(st.solved&&st.store){const obstacleCell=storeObstacleCell(meta),current=Array.isArray(st.store.cell)?st.store.cell:null;if(obstacleCell&&(!current||!sameCell(current,obstacleCell))){st.store.cell=obstacleCell;st.store.pathIndex=-1;st.store.cellIndex=-1;delete st.store.summaryCell;st.rev=nextRevision();markStateDirty(meta.id)}} + const solved=st.solved===true||isSolved(st,p); if(st.solved!==solved){st.solved=solved;st.rev=nextRevision();markStateDirty(meta.id)} if(solved&&!st.solvedBy){st.solvedBy=LEGACY_LOCAL_SOLVER;st.rev=nextRevision();markStateDirty(meta.id)} 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) ]} @@ -2710,8 +2892,8 @@ function straightNumberWarningKeys(st,p){ const numberKeys=new Set(p.n.map(number=>ckey(number[0],number[1]))),warnings=new Set(); for(const path of st.paths){ if(!path.cells.length)continue; - const points=[...(path.detachedStart?[]:[outsidePoint(gateObj(p,path.startGate))]),...path.cells],offset=path.detachedStart?0:1; - if(path.endGate!=null)points.push(outsidePoint(gateObj(p,path.endGate))); + const startOutside=path.detachedStart?null:outsidePoint(gateObj(p,path.startGate)),points=[...(startOutside?[startOutside]:[]),...path.cells],offset=startOutside?1:0; + if(path.endGate!=null){const endOutside=outsidePoint(gateObj(p,path.endGate));if(endOutside)points.push(endOutside)} path.cells.forEach((cell,index)=>{ const key=ckey(...cell),pointIndex=index+offset; if(!numberKeys.has(key)||!points[pointIndex-1]||!points[pointIndex+1])return; @@ -2742,7 +2924,7 @@ function minimapGeometryForComponent(component,cache=currentLineGraphCaches().ge const segments=[]; for(const[member,memberIndex]of component.members||[]){ if(!member?.puzzle)continue;const memberPath=metaState(member.id).paths[memberIndex];if(!memberPath?.cells?.length)continue;const memberPuzzle=puzzleOf(member), - gateMap=gateIndex=>{const g=gateObj(memberPuzzle,gateIndex),row=g.cell[0],col=g.cell[1];return[member.x+(col+(g.side==='E'?1:g.side==='W'?0:.5))/CHUNK,member.y+(row+(g.side==='S'?1:g.side==='N'?0:.5))/CHUNK]},turns=[]; + gateMap=gateIndex=>{const g=gateObj(memberPuzzle,gateIndex),row=g.cell[0],col=g.cell[1];return[member.x+(col+(g.internal?.5:g.side==='E'?1:g.side==='W'?0:.5))/CHUNK,member.y+(row+(g.internal?.5:g.side==='S'?1:g.side==='N'?0:.5))/CHUNK]},turns=[]; for(let pointIndex=0;pointIndex{ if(!minimapFrame)return; - if(globalThis.document?.body?.classList?.contains?.('is-interacting')){minimapFrame=0;return} + if(interactionActive('overview')){minimapFrame=0;return} if(timestamp-minimapLastDraw96)return 0;let longSegments=0;const caches=currentLineGraphCaches(),seenComponents=new Set();context.lineCap='square';context.lineJoin='miter'; for(const meta of metas){if(!meta.puzzle)continue;const st=metaState(meta.id);for(let index=0;indexdata.metas[id]).filter(Boolean);drawMapBoardCells(base,visibleMetas,mapX,mapY,scale);const longSegments=drawMapLongLines(base,visibleMetas,visibleIds,mapX,mapY,1); - minimapCache={revision:minimapWorldRevision,width,height,dpr,anchorX:centerX,anchorY:centerY,scale,overscanPixels,baseWidth,baseHeight,longSegments};perfCount('minimapWorldBuilds');perfEnd('rebuildMinimapWorld',started); + const visibleMetas=[...visibleIds].map(id=>data.metas[id]).filter(Boolean);drawMapBoardCells(base,visibleMetas,mapX,mapY,scale);const longSegments=drawMapLongLines(base,visibleMetas,visibleIds,mapX,mapY,1),storeCount=drawMapStores(base,visibleMetas,mapX,mapY,scale); + minimapCache={revision:minimapWorldRevision,width,height,dpr,anchorX:centerX,anchorY:centerY,scale,overscanPixels,baseWidth,baseHeight,longSegments,storeCount};perfCount('minimapWorldBuilds');perfEnd('rebuildMinimapWorld',started); } function drawMinimap(){ - if(globalThis.document?.body?.classList?.contains?.('is-interacting')){minimapDirty=true;perfCount('minimapDrawsDeferredDuringInteraction');return false} + if(interactionActive('overview')){minimapDirty=true;perfCount('minimapDrawsDeferredDuringInteraction');return false} const started=perfStart();perfCount('minimapDraws');const rect=getMinimapRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),dpr=Math.min(2,Math.max(1,window.devicePixelRatio||1)),pixelWidth=Math.round(width*dpr),pixelHeight=Math.round(height*dpr),[centerX,centerY]=cameraCenterInChunks(); if(minimapCanvas.width!==pixelWidth||minimapCanvas.height!==pixelHeight){minimapCanvas.width=pixelWidth;minimapCanvas.height=pixelHeight} const stale=!minimapCache||minimapCache.revision!==minimapWorldRevision||minimapCache.width!==width||minimapCache.height!==height||minimapCache.dpr!==dpr|| @@ -2805,10 +3000,9 @@ function drawMinimap(){ const sourceX=(minimapCache.overscanPixels+(centerX-minimapCache.anchorX)*minimapCache.scale)*dpr,sourceY=(minimapCache.overscanPixels+(centerY-minimapCache.anchorY)*minimapCache.scale)*dpr, context=minimapCanvas.getContext('2d');context.setTransform(1,0,0,1,0,0);context.clearRect(0,0,pixelWidth,pixelHeight);context.drawImage(minimapBase,sourceX,sourceY,pixelWidth,pixelHeight,0,0,pixelWidth,pixelHeight); context.setTransform(dpr,0,0,dpr,0,0);context.strokeStyle='rgba(239,248,255,.82)';context.lineWidth=1;context.beginPath();context.moveTo(width/2-5,height/2);context.lineTo(width/2+5,height/2);context.moveTo(width/2,height/2-5);context.lineTo(width/2,height/2+5);context.stroke(); - let nearbyPlayers=0;for(const player of(typeof remotePlayers==='undefined'?[]:remotePlayers.values())){const worldX=Number.isFinite(player.currentX)?player.currentX:player.targetX,worldY=Number.isFinite(player.currentY)?player.currentY:player.targetY;if(!Number.isFinite(worldX)||!Number.isFinite(worldY))continue;const x=width/2+(worldX-centerX)*minimapCache.scale,y=height/2+(worldY-centerY)*minimapCache.scale;if(x<-4||y<-4||x>width+4||y>height+4)continue;nearbyPlayers++;context.beginPath();context.arc(x,y,2.6,0,Math.PI*2);context.fillStyle='#d9f06f';context.fill();context.strokeStyle='rgba(8,12,15,.9)';context.lineWidth=1;context.stroke()} - const spanX=MINIMAP_VIEW_CHUNKS_X,spanY=Math.max(10,spanX*height/Math.max(1,width)),bounds={minX:Math.floor(centerX-spanX/2)-1,maxX:Math.ceil(centerX+spanX/2)+1,minY:Math.floor(centerY-spanY/2)-1,maxY:Math.ceil(centerY+spanY/2)+1},visibleIds=visibleMetaIdsForBounds(bounds),visibleMetas=[...visibleIds].map(id=>data.metas[id]).filter(Boolean), - solved=visibleMetas.reduce((count,meta)=>count+(metaState(meta.id).solved?1:0),0),status=`${solved} / ${visibleMetas.length} · ${nearbyPlayers+1}人`,label=`\u8996\u70b9\u5468\u8fba\u306e\u76e4\u9762\u3002\u30af\u30ea\u30a2\u6e08\u307f\uff1a${solved}\u3001\u672a\u30af\u30ea\u30a2\uff1a${visibleMetas.length-solved}\u3001\u8fd1\u304f\u306e\u30d7\u30ec\u30a4\u30e4\u30fc\uff1a${nearbyPlayers+1}\u4eba\u3002`; - if(minimapStatus.textContent!==status)minimapStatus.textContent=status;if(minimapCanvas.getAttribute('aria-label')!==label)minimapCanvas.setAttribute('aria-label',label); + for(const player of(typeof remotePlayers==='undefined'?[]:remotePlayers.values())){const worldX=Number.isFinite(player.currentX)?player.currentX:player.targetX,worldY=Number.isFinite(player.currentY)?player.currentY:player.targetY;if(!Number.isFinite(worldX)||!Number.isFinite(worldY))continue;const x=width/2+(worldX-centerX)*minimapCache.scale,y=height/2+(worldY-centerY)*minimapCache.scale;if(x<-4||y<-4||x>width+4||y>height+4)continue;context.beginPath();context.arc(x,y,2.6,0,Math.PI*2);context.fillStyle='#d9f06f';context.fill();context.strokeStyle='rgba(8,12,15,.9)';context.lineWidth=1;context.stroke()} + const label='マップ。店舗マーカーをクリックするとショップを開きます。'; + if(minimapCanvas.getAttribute('aria-label')!==label)minimapCanvas.setAttribute('aria-label',label); perfCount(stale?'minimapCacheMisses':'minimapCacheHits');perfEnd('drawMinimap',started); } function worldNavigationBounds(){ @@ -2827,8 +3021,10 @@ function minimapWorldPoint(event){ return{x:center[0]+(point.x-point.width/2)/scale,y:center[1]+(point.y-point.height/2)/scale,scale}; } function beginMinimapPointer(event){ - if(event.button!==0)return;event.preventDefault();event.stopPropagation();const target=minimapWorldPoint(event); + if(event.button!==0||!gestureCoordinator.claim(event.pointerId,'minimap'))return;event.preventDefault();event.stopPropagation();const target=minimapWorldPoint(event),storeMeta=nearestStoreMetaAtWorldPoint(target.x,target.y,14,target.scale); + if(storeMeta){gestureCoordinator.release(event.pointerId,'minimap');playSound('shop');centerMeta(storeMeta,{preserveScale:true,select:false});openStoreMeta(storeMeta);minimapCanvas.focus({preventScroll:true});return} minimapPointerState={id:event.pointerId,startClientX:event.clientX,startClientY:event.clientY,targetX:target.x,targetY:target.y,scale:target.scale}; + interactionState.set('minimap',event.pointerId); try{minimapCanvas.setPointerCapture(event.pointerId)}catch(_){} centerWorldUnit(target.x,target.y,{persist:false});minimapCanvas.focus({preventScroll:true}); } @@ -2839,7 +3035,7 @@ function moveMinimapPointer(event){ function endMinimapPointer(event){ if(!minimapPointerState||minimapPointerState.id!==event.pointerId)return;minimapPointerState=null; try{if(minimapCanvas.hasPointerCapture?.(event.pointerId))minimapCanvas.releasePointerCapture(event.pointerId)}catch(_){} - recordCameraAnchor(); + gestureCoordinator.release(event.pointerId,'minimap');interactionState.clear('minimap');recordCameraAnchor(); } function keyMinimap(event){ const directions={ArrowUp:[0,-1],ArrowDown:[0,1],ArrowLeft:[-1,0],ArrowRight:[1,0]},direction=directions[event.key]; @@ -2879,20 +3075,18 @@ function projectedScoreForBoard(meta){ projectedState={paths:solution,solved:true}; return AppLogic.deterministicBoardReward(baseScore,{worldSeed:worldSeedValue(),meta,state:projectedState,timeAttackModifier:timeAttackRewardModifier(),scoreLensCount:scoreLensCountForMeta(meta)}).award; } +function storeObstacleCell(meta){const obstacles=puzzleOf(meta).obstacles||[];if(!obstacles.length)return null;return[...obstacles[(hash32((meta.seed>>>0)^0x2fd51a37)>>>0)%obstacles.length]]} function maybeOpenStore(meta,st,solveAward,roll=null){ - const deterministicRoll=roll==null?(hash32(meta.seed^0x7f4a7c15)>>>0)/4294967296:roll; - if(!st.solved||st.store||!st.paths.length||deterministicRoll>=STORE_CHANCE)return 0; - const pathIndex=st.paths.reduce((best,path,index)=>path.cells.length>st.paths[best].cells.length?index:best,0), - path=st.paths[pathIndex], - cellIndex=Math.max(0,Math.min(path.cells.length-1,Math.floor(path.cells.length*.55))), - bonus=0; - st.store={owner:currentPlayerName(),pathIndex,cellIndex,openedAt:trustedNow(),priceVersion:STORE_PRICE_VERSION,priceCoefficient:null,bonus,bonusVersion:SCORE_VERSION,itemIds:seededStoreItemIds(meta.seed),purchases:[]}; - st.store.priceCoefficient=storePriceDetails(meta,st.store).coefficient; - - return bonus; + const deterministicRoll=roll==null?(hash32(meta.seed^0x7f4a7c15)>>>0)/4294967296:roll,cell=storeObstacleCell(meta); + if(!st.solved||st.store||!cell||deterministicRoll>=STORE_CHANCE)return 0; + const bonus=0; + st.store={owner:currentPlayerName(),pathIndex:-1,cellIndex:-1,cell,openedAt:trustedNow(),priceVersion:STORE_PRICE_VERSION,priceCoefficient:null,bonus,bonusVersion:SCORE_VERSION,itemIds:seededStoreItemIds(meta.seed),purchases:[]}; + st.store.priceCoefficient=storePriceDetails(meta,st.store).coefficient;return bonus; } -function gatePoint(g){const r=g.cell[0],c=g.cell[1],x=PAD+(c+.5)*CELL,y=PAD+(r+.5)*CELL;return g.side==='N'?[x,PAD+r*CELL]:g.side==='S'?[x,PAD+(r+1)*CELL]:g.side==='W'?[PAD+c*CELL,y]:[PAD+(c+1)*CELL,y]} -function gateMarkerPathAt(point,side,sealed=false,scale=1){ +function gatePoint(g){const r=g.cell[0],c=g.cell[1],x=PAD+(c+.5)*CELL,y=PAD+(r+.5)*CELL;if(g.internal)return[x,y];return g.side==='N'?[x,PAD+r*CELL]:g.side==='S'?[x,PAD+(r+1)*CELL]:g.side==='W'?[PAD+c*CELL,y]:[PAD+(c+1)*CELL,y]} +function gateMarkerPathAt(point,side,sealed=false,scale=1,internal=false){ + if(internal){const[dr,dc]=SIDE_D[side],tx=-dr,ty=dc,stem=10*scale,wing=5*scale,tip=[point[0]+dc*stem,point[1]+dr*stem],left=[point[0]+tx*wing,point[1]+ty*wing],right=[point[0]-tx*wing,point[1]-ty*wing];return`M ${left[0]} ${left[1]} L ${point[0]} ${point[1]} L ${right[0]} ${right[1]} M ${point[0]} ${point[1]} L ${tip[0]} ${tip[1]}`} + const[dr,dc]=SIDE_D[side],ox=dc,oy=dr,tx=-dr,ty=dc,stem=11*scale,back=4.5*scale,wing=4.5*scale, tip=[point[0]+ox*stem,point[1]+oy*stem]; if(sealed){ @@ -2904,7 +3098,8 @@ function gateMarkerPathAt(point,side,sealed=false,scale=1){ left=[base[0]+tx*wing,base[1]+ty*wing],right=[base[0]-tx*wing,base[1]-ty*wing]; return`M ${point[0]} ${point[1]} L ${tip[0]} ${tip[1]} M ${left[0]} ${left[1]} L ${tip[0]} ${tip[1]} L ${right[0]} ${right[1]}`; } -function gateHitBox(point,side){ +function gateHitBox(point,side,internal=false){ + if(internal){const half=Math.round(CELL*.38);return{x:point[0]-half,y:point[1]-half,width:half*2,height:half*2}} // Keep hit regions inside the owning board. Outward hit boxes from adjacent // boards overlapped and could steal a pointer from the opposite gate. const inward=Math.round(CELL*.42),outward=0,half=Math.round(CELL*.30),depth=inward+outward; @@ -2919,31 +3114,29 @@ function hudPlacementCandidates(meta){ const own=new Set(meta.chunks.map(([dx,dy])=>key2(meta.x+dx,meta.y+dy))),priority={N:0,S:1,W:2,E:3},candidates=[]; for(const[dx,dy]of meta.chunks)for(const side of['N','S','W','E']){ const[dr,dc]=SIDE_D[side],localKey=key2(dx+dc,dy+dr);if(own.has(key2(meta.x+dx+dc,meta.y+dy+dr)))continue; - const neighbor=occupancy.get(key2(meta.x+dx+dc,meta.y+dy+dr));if(neighbor&&neighbor!==meta.id)continue; candidates.push({side,dx,dy,priority:priority[side],distance:Math.abs(dx+.5-(puzzleOf(meta).bounds.w/CHUNK)/2)+Math.abs(dy+.5-(puzzleOf(meta).bounds.h/CHUNK)/2),localKey}); } return candidates.sort((a,b)=>a.priority-b.priority||a.distance-b.distance||a.dy-b.dy||a.dx-b.dx); } function positionBoardLabel(b){ - const candidates=hudPlacementCandidates(b.meta),fallback={side:'N',dx:0,dy:0},viewportRect=viewport.getBoundingClientRect(),boardRect=boardScreenRect(b),scale=Math.max(MIN_CAMERA_SCALE,cam.scale),margin=8, - innerWidth=b.p.bounds.w*CELL,estimatedHeight=Math.max(28,b.label.offsetHeight||32)*scale; - const boundsFor=placement=>{ - const horizontal=placement.side==='N'||placement.side==='S',labelWidth=(horizontal?innerWidth:Math.max(150,b.label.offsetWidth||190))*scale, - edgeX=horizontal?PAD+innerWidth/2:PAD+(placement.dx+(placement.side==='E'?1:0))*UNIT, - edgeY=horizontal?PAD+(placement.side==='S'?b.p.bounds.h*CELL:0):PAD+(placement.dy+.5)*UNIT, - x=boardRect.left+edgeX/b.w*boardRect.width,y=boardRect.top+edgeY/b.h*boardRect.height,gap=(placement.side==='N'?22:8)*scale; - if(placement.side==='N')return{left:x-labelWidth/2,right:x+labelWidth/2,top:y-gap-estimatedHeight,bottom:y-gap}; - if(placement.side==='S')return{left:x-labelWidth/2,right:x+labelWidth/2,top:y+gap,bottom:y+gap+estimatedHeight}; - if(placement.side==='W')return{left:x-gap-labelWidth,right:x-gap,top:y-estimatedHeight/2,bottom:y+estimatedHeight/2}; - return{left:x+gap,right:x+gap+labelWidth,top:y-estimatedHeight/2,bottom:y+estimatedHeight/2}; + if(!b?.label||!boardHudLayer||!boardPlayHudVisible(b))return false; + const candidates=hudPlacementCandidates(b.meta),fallback={side:'N',dx:0,dy:0,priority:0},viewportRect=getViewportRect(true),boardRect=boardScreenRect(b),scale=Math.max(MIN_CAMERA_SCALE,cam.scale),margin=8,gap=10, + innerWidth=b.p.bounds.w*CELL,label=b.label; + label.hidden=false;label.classList.add('hud-visible');label.style.removeProperty('width');label.style.maxWidth=Math.max(120,viewportRect.width-margin*2)+'px'; + const labelWidth=Math.min(viewportRect.width-margin*2,Math.max(1,label.offsetWidth||190)),labelHeight=Math.min(viewportRect.height-margin*2,Math.max(1,label.offsetHeight||34)); + const anchorFor=placement=>{ + const side=placement.side||'N',horizontal=side==='N'||side==='S', + x=horizontal?boardRect.left-viewportRect.left+(PAD+innerWidth/2)*scale:boardRect.left-viewportRect.left+(PAD+(placement.dx+(side==='E'?1:0))*UNIT)*scale, + y=horizontal?boardRect.top-viewportRect.top+(PAD+(side==='S'?b.p.bounds.h*CELL:0))*scale:boardRect.top-viewportRect.top+(PAD+(placement.dy+.5)*UNIT)*scale; + if(side==='N')return{left:x-labelWidth/2,top:y-gap-labelHeight}; + if(side==='S')return{left:x-labelWidth/2,top:y+gap}; + if(side==='W')return{left:x-gap-labelWidth,top:y-labelHeight/2}; + return{left:x+gap,top:y-labelHeight/2}; }; - const overflow=rect=>Math.max(0,viewportRect.left+margin-rect.left)+Math.max(0,rect.right-(viewportRect.right-margin))+Math.max(0,viewportRect.top+margin-rect.top)+Math.max(0,rect.bottom-(viewportRect.bottom-margin)); - const placement=(candidates.length?candidates:[fallback]).map(candidate=>({candidate,overflow:overflow(boundsFor(candidate))})).sort((a,c)=>a.overflow-c.overflow||a.candidate.priority-c.candidate.priority)[0].candidate; - b.label.dataset.side=placement.side;b.label.style.removeProperty('right');b.label.style.removeProperty('bottom');b.label.style.removeProperty('width'); - if(placement.side==='N'){b.label.style.left=PAD+'px';b.label.style.width=innerWidth+'px';b.label.style.top=(PAD-22)+'px';b.label.style.transform='translate(0,-100%)'} - else if(placement.side==='S'){b.label.style.left=PAD+'px';b.label.style.width=innerWidth+'px';b.label.style.top=(PAD+b.p.bounds.h*CELL+8)+'px';b.label.style.transform='translate(0,0)'} - else if(placement.side==='W'){const edgeX=PAD+placement.dx*UNIT,edgeY=PAD+(placement.dy+.5)*UNIT;b.label.style.left=(edgeX-8)+'px';b.label.style.top=edgeY+'px';b.label.style.transform='translate(-100%,-50%)'} - else{const edgeX=PAD+(placement.dx+1)*UNIT,edgeY=PAD+(placement.dy+.5)*UNIT;b.label.style.left=(edgeX+8)+'px';b.label.style.top=edgeY+'px';b.label.style.transform='translate(0,-50%)'} + const overflow=point=>Math.max(0,margin-point.left)+Math.max(0,point.left+labelWidth-(viewportRect.width-margin))+Math.max(0,margin-point.top)+Math.max(0,point.top+labelHeight-(viewportRect.height-margin)); + const ranked=(candidates.length?candidates:[fallback]).map(candidate=>({candidate,point:anchorFor(candidate)}));ranked.sort((a,c)=>overflow(a.point)-overflow(c.point)||(a.candidate.priority||0)-(c.candidate.priority||0)); + const selected=ranked[0],left=Math.max(margin,Math.min(selected.point.left,viewportRect.width-margin-labelWidth)),top=Math.max(margin,Math.min(selected.point.top,viewportRect.height-margin-labelHeight)); + label.dataset.side=selected.candidate.side;label.style.left=left.toFixed(2)+'px';label.style.top=top.toFixed(2)+'px';label.style.transform='none';return true; } const dirtyBoards=new Set(),lineWidthDirtyPathKeys=new Set();let renderFrame=0,lineWidthFrame=0; function applyLineWidth(node,width){const value=width.toFixed(2);if(node.style.getPropertyValue('--line-width')!==value)node.style.setProperty('--line-width',value)} @@ -2977,6 +3170,9 @@ function queueLineWidthRefresh(boardId=null,pathIndex=null){ if(lineWidthFrame)return; lineWidthFrame=requestAnimationFrame(()=>{lineWidthFrame=0;const keys=new Set(lineWidthDirtyPathKeys);lineWidthDirtyPathKeys.clear();refreshRenderedLineWidths(keys)}); } +function commitConnectedLineVisuals(b,pathIndex=null){ + if(!b)return false;invalidateLineGraphCaches(b.id,pathIndex);if(b.card?.isConnected)renderBoard(b);if(Number.isInteger(pathIndex))queueLineWidthRefresh(b.id,pathIndex);else queueLineWidthRefresh(b.id);return true +} function scoreLensVisibleForMeta(meta){return cam.scale<=SCORE_LENS_ZOOM_THRESHOLD&&!metaState(meta.id).solved&&activeScoreLensCount()>0} function updateScoreLensBadge(b){ if(!b?.scoreLensBadge)return;const visible=scoreLensVisibleForMeta(b.meta);b.scoreLensBadge.hidden=!visible; @@ -2993,7 +3189,7 @@ function updateZoomPresentation(force=false){ if(!force&&Math.abs(cam.scale-lastPresentedScale)<1e-6)return inWorldOverview();lastPresentedScale=cam.scale; const overview=inWorldOverview();world.classList.toggle('world-overview',overview);world.classList.toggle('score-lens-zoom',cam.scale<=SCORE_LENS_ZOOM_THRESHOLD);viewport.classList.toggle('canvas-overview',overview); world.style.setProperty('--inverse-camera-scale',String(1/Math.max(MIN_CAMERA_SCALE,cam.scale)));world.style.setProperty('--camera-scale',String(cam.scale)); - if(document.body.classList.contains('is-interacting'))zoomDetailsDirty=true;else{for(const board of rendered.values())updateScoreLensBadge(board);zoomDetailsDirty=false} + if(interactionActive('overview'))zoomDetailsDirty=true;else{for(const board of rendered.values())updateScoreLensBadge(board);zoomDetailsDirty=false} if(overview){overviewDirty=true;scheduleWorldOverview(true)}else if(overviewCanvas){overviewCanvas.hidden=true} return overview; } @@ -3033,6 +3229,7 @@ function specialCellInfoMap(p){ for(const cell of special.crossings)map.set(ckey(...cell),{type:'cross',description:'\u4ea4\u5dee\u30bb\u30eb\uff1a\u30af\u30ea\u30a2\u6642\u306b\u6c34\u5e73\u3068\u5782\u76f4\u306e2\u672c\u3092\u4ea4\u5dee\u72b6\u614b\u306b\u3059\u308b'}); for(const pair of special.warps)for(const cell of[pair.a,pair.b])map.set(ckey(...cell),{type:'warp',description:'\u30ef\u30fc\u30d7\u30bb\u30eb\uff1a\u5165\u308b\u3068\u5bfe\u306b\u306a\u308b\u30bb\u30eb\u3078\u79fb\u52d5'}); for(const lock of special.locks){map.set(ckey(...lock.key),{type:'key',description:'\u9375\u30bb\u30eb\uff1a\u540c\u3058\u7dda\u3067\u89e6\u308c\u308b\u3068\u6249\u3092\u901a\u904e\u53ef\u80fd'});map.set(ckey(...lock.door),{type:'door',description:'\u6249\u30bb\u30eb\uff1a\u9375\u306b\u89e6\u308c\u305f\u7dda\u306e\u307f\u901a\u904e\u53ef\u80fd'})} + for(const pair of special.internalGates||[])for(const index of[pair.a,pair.b]){const gate=p.g?.[index];if(gate)map.set(ckey(gate[0],gate[1]),{type:'internal-gate',description:'盤面内ゲート:つまみから通常ゲートと同様に線を引く'})} return map; } function makeSpecialMarker(info,r,c){ @@ -3040,6 +3237,7 @@ function makeSpecialMarker(info,r,c){ group.append(svgEl('rect',{x:x+4,y:y+4,width:CELL-8,height:CELL-8,rx:3,class:'special-cell-frame'})); if(info.type==='cross')group.append(svgEl('line',{x1:cx-11,y1:cy,x2:cx+11,y2:cy,class:'special-symbol-line'}),svgEl('line',{x1:cx,y1:cy-11,x2:cx,y2:cy+11,class:'special-symbol-line'}),svgEl('circle',{cx,cy,r:4,class:'special-symbol-core'})); else if(info.type==='warp')group.append(svgEl('circle',{cx,cy,r:11,class:'warp-ring'}),svgEl('circle',{cx,cy,r:5,class:'warp-core'})); + else if(info.type==='internal-gate')group.append(svgEl('circle',{cx,cy,r:13,class:'internal-gate-bracket'}),svgEl('circle',{cx,cy,r:8,class:'internal-gate-bracket inner'})); else if(info.type==='key')group.append(svgEl('circle',{cx:cx-5,cy:cy-3,r:5.2,class:'key-ring'}),svgEl('line',{x1:cx-1,y1:cy+1,x2:cx+9,y2:cy+11,class:'key-shaft'}),svgEl('line',{x1:cx+5,y1:cy+7,x2:cx+9,y2:cy+3,class:'key-tooth'}),svgEl('line',{x1:cx+8,y1:cy+10,x2:cx+12,y2:cy+6,class:'key-tooth'})) else{group.append(svgEl('rect',{x:cx-9,y:cy-12,width:18,height:24,rx:2,class:'door-panel'}),svgEl('circle',{cx:cx+4,cy,r:1.8,class:'door-knob'}))} return group; @@ -3106,39 +3304,23 @@ function boardCellsPath(cells){return(cells||[]).map(([r,c])=>{const x=PAD+c*CEL function outerEdgesPath(p){const set=cellSet(p),parts=[];for(const[r,c]of p.valid){const x=PAD+c*CELL,y=PAD+r*CELL;if(!set.has(ckey(r-1,c)))parts.push(`M${x} ${y}h${CELL}`);if(!set.has(ckey(r+1,c)))parts.push(`M${x} ${y+CELL}h${CELL}`);if(!set.has(ckey(r,c-1)))parts.push(`M${x} ${y}v${CELL}`);if(!set.has(ckey(r,c+1)))parts.push(`M${x+CELL} ${y}v${CELL}`)}return parts.join('')} function drawOuterEdges(layer,p){layer.append(svgEl('path',{d:outerEdgesPath(p),class:'outer-edge'}))} function overviewChunkPath(meta){return meta.chunks.map(([dx,dy])=>{const x=PAD+dx*UNIT,y=PAD+dy*UNIT;return`M${x} ${y}h${UNIT}v${UNIT}h-${UNIT}Z`}).join('')} -function staticBoardSignature(meta){const state=metaState(meta.id);return`${meta.rev||0}:${state.rev||0}:${state.solved?1:0}:${state.paths.length}`} -function destroyStaticBoard(board){ - if(!board)return;board.card.remove();staticRendered.delete(board.id);perfCount('staticBoardsDestroyed'); -} function boardTimeout(board,callback,delay){ const timer=setTimeout(()=>{board?.timers?.delete(timer);if(board?.card?.isConnected)callback()},delay);board?.timers?.add(timer);return timer; } -function makeStaticBoard(meta){ - const p=puzzleOf(meta),w=p.bounds.w*CELL+PAD*2,h=p.bounds.h*CELL+PAD*2,card=document.createElement('div'); - card.className=`board-card board-static${metaState(meta.id).solved?' solved':''}`;card.style.left=((meta.x-renderOriginX)*UNIT-PAD)+'px';card.style.top=((meta.y-renderOriginY)*UNIT-PAD)+'px';card.style.width=w+'px';card.style.height=h+'px';card.dataset.id=meta.id;card.tabIndex=-1; - const svg=svgEl('svg',{viewBox:`0 0 ${w} ${h}`,width:w,height:h,class:'board-static-svg','aria-hidden':'true'}),[background]=levelBackground(meta.level), - shape=svgEl('path',{d:overviewChunkPath(meta),class:'static-summary-fill',fill:background}); - svg.append(shape);const state=metaState(meta.id); - state.paths.forEach((path,index)=>{if(!path?.cells?.length)return;const color=LINE_COLORS[(path.startColorIndex??path.colorIndex??index)%LINE_COLORS.length],width=Math.min(15,Math.max(3,renderedConnectedLineWidth(meta,index)));for(const points of pathRenderSegments(path,p)){const node=svgEl('polyline',{points:points.map(point=>point.join(',')).join(' '),class:'static-summary-path',stroke:color});node.style.setProperty('--static-line-width',width.toFixed(2));svg.append(node)}}); - card.append(svg); - const storeCell=storeCellForMeta(meta);if(storeCell){const shop=document.createElement('button'),[x,y]=boardCellCenter(storeCell);shop.type='button';shop.className='static-shop-icon';shop.innerHTML='';shop.style.left=x+'px';shop.style.top=y+'px';shop.setAttribute('aria-label','\u30b7\u30e7\u30c3\u30d7\u3092\u958b\u304f');shop.addEventListener('pointerdown',event=>{if(event.button!==0)return;event.preventDefault();event.stopPropagation();playSound('click');openStoreMeta(meta)});card.append(shop)} - world.append(card);const board={id:meta.id,meta,card,svg,signature:staticBoardSignature(meta)};staticRendered.set(meta.id,board);refreshClaimPresentation(meta.id);perfCount('staticBoardsCreated');return board; -} - function makeBoard(meta){ const makeStarted=perfStart(); const p=puzzleOf(meta),w=p.bounds.w*CELL+PAD*2,h=p.bounds.h*CELL+PAD*2; const card=document.createElement('div');card.className='board-card';card.style.left=((meta.x-renderOriginX)*UNIT-PAD)+'px';card.style.top=((meta.y-renderOriginY)*UNIT-PAD)+'px';card.style.width=w+'px';card.style.height=h+'px';card.dataset.id=meta.id; const region=regionFor(meta.targetLevel??meta.level);card.dataset.region=region.name.toLowerCase();card.dataset.difficulty=String(meta.level);card.style.setProperty('--region',region.accent); - const label=document.createElement('div');label.className='board-label'; + const label=document.createElement('div');label.className='board-label';label.hidden=true;label.dataset.boardId=meta.id;label.style.setProperty('--region',region.accent); const solverBadge=document.createElement('div');solverBadge.className='solver-badge';solverBadge.setAttribute('aria-hidden','true'); const claimBadge=document.createElement('div');claimBadge.className='board-claim-badge';claimBadge.hidden=true;claimBadge.setAttribute('aria-live','polite'); const solverCaption=document.createElement('span');solverCaption.textContent='\u30af\u30ea\u30a2'; const solverName=document.createElement('strong');solverBadge.append(solverCaption,solverName); const boardActions=document.createElement('div');boardActions.className='board-actions'; const boardReset=document.createElement('button');boardReset.type='button';boardReset.className='board-action board-reset';boardReset.textContent='\u21ba \u30ea\u30bb\u30c3\u30c8';boardReset.setAttribute('aria-label','\u3053\u306e\u76e4\u9762\u306e\u7dda\u3092\u3059\u3079\u3066\u6d88\u3059');boardActions.append(boardReset);label.append(boardActions); - const storeButton=document.createElement('button');storeButton.type='button';storeButton.className='line-store';storeButton.hidden=true;storeButton.innerHTML='SHOP'; + 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'}),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'}); @@ -3147,7 +3329,7 @@ function makeBoard(meta){ 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); const cellShape=svgEl('path',{d:validCellPath,class:'cell-shape',fill:`url(#${bgId})`}),unfilledWarningShape=svgEl('path',{d:'',class:'unfilled-warning-cells'}),boardInputSurface=svgEl('path',{d:validCellPath,class:'board-input-surface','aria-hidden':'true'});staticLayer.append(cellShape,unfilledWarningShape,boardInputSurface) - for(const[r,c]of p.obstacles||[]){const x=PAD+c*CELL,y=PAD+r*CELL,group=svgEl('g',{class:'obstacle-mark'});group.append(svgEl('rect',{x,y,width:CELL,height:CELL,class:'obstacle-cell'}),svgEl('line',{x1:x+10,y1:y+10,x2:x+CELL-10,y2:y+CELL-10,class:'obstacle-cross'}),svgEl('line',{x1:x+CELL-10,y1:y+10,x2:x+10,y2:y+CELL-10,class:'obstacle-cross'}));staticLayer.append(group)} + const obstacleNodes=new Map();for(const[r,c]of p.obstacles||[]){const x=PAD+c*CELL,y=PAD+r*CELL,group=svgEl('g',{class:'obstacle-mark'});obstacleNodes.set(ckey(r,c),group);group.append(svgEl('rect',{x,y,width:CELL,height:CELL,class:'obstacle-cell'}),svgEl('line',{x1:x+10,y1:y+10,x2:x+CELL-10,y2:y+CELL-10,class:'obstacle-cross'}),svgEl('line',{x1:x+CELL-10,y1:y+10,x2:x+10,y2:y+CELL-10,class:'obstacle-cross'}));staticLayer.append(group)} drawOuterEdges(staticLayer,p); const specialInfo=specialCellInfoMap(p),specialSets=specialCellSet(p),specialNodes=new Map(); for(const[key,info]of specialInfo){const[r,c]=key.split(',').map(Number),marker=makeSpecialMarker(info,r,c);specialNodes.set(key,marker);specialLayer.append(marker)} @@ -3160,16 +3342,17 @@ function makeBoard(meta){ } const gateDots=[],gateKnobs=[],gateMarkers=[],gateHits=[],gateIndexesByCell=new Map(); p.g.forEach((_,i)=>{ - const g=gateObj(p,i),gp=gatePoint(g),sealed=!gateConnectionAllowed(meta,i), - dot=svgEl('circle',{cx:gp[0],cy:gp[1],r:4.5,class:`gate-dot${sealed?' sealed':''}`,fill:'#7d878e'}), - knob=svgEl('circle',{cx:gp[0],cy:gp[1],r:7,class:'gate-knob','data-gate-knob':i}), - marker=svgEl('path',{d:gateMarkerPathAt(gp,g.side,sealed),class:`gate-marker${sealed?' sealed':''}`}), - hit=svgEl('rect',{...gateHitBox(gp,g.side),rx:3,class:'gate-hit','clip-path':`url(#${inputClipId})`,'data-gate':i,tabindex:0,role:'button','aria-label':`${sealed?'\u7d42\u7aef':'\u30b2\u30fc\u30c8 '+(i+1)}`}); + const g=gateObj(p,i),gp=gatePoint(g),sealed=!gateConnectionAllowed(meta,i),internalClass=g.internal?' internal':'', + dot=svgEl('circle',{cx:gp[0],cy:gp[1],r:g.internal?5.5:4.5,class:`gate-dot${internalClass}${sealed?' sealed':''}`,fill:'#7d878e'}), + knob=svgEl('circle',{cx:gp[0],cy:gp[1],r:g.internal?9:7,class:`gate-knob${internalClass}`,'data-gate-knob':i}), + marker=svgEl('path',{d:gateMarkerPathAt(gp,g.side,sealed,1,g.internal),class:`gate-marker${internalClass}${sealed?' sealed':''}`}), + hit=svgEl('rect',{...gateHitBox(gp,g.side,g.internal),rx:3,class:`gate-hit${internalClass}`,'clip-path':`url(#${inputClipId})`,'data-gate':i,tabindex:0,role:'button','aria-label':g.internal?`盤面内ゲート ${i+1}`:`${sealed?'終端':'ゲート '+(i+1)}`}); 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,staticLayer,specialLayer,connectorLayer,gateLayer,pathLayer,dragLayer,claimPreviewLayer,numberLayer);card.append(label,svg,solverBadge,claimBadge,storeButton,scoreLensBadge);world.append(card); - 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,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()}; + 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); if(meta.justRevealed){card.classList.add('revealing');boardTimeout(b,()=>card.classList.remove('revealing'),900);delete meta.justRevealed} perfCount('boardsCreated');perfEnd('makeBoard',makeStarted);return b; @@ -3179,7 +3362,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.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])}); @@ -3223,10 +3406,7 @@ function updateDrawingPointerState(b,drawing,point){ } function updateDrawingHandlePosition(b,drawing,point){ if(!drawing||!point||!syncDrawingConfirmedState(b,drawing))return false; - // Keep the model snapped to the confirmed cell; only the visual handle follows the pointer. - const center=boardCellCenter(drawing.currentConfirmedCell),dx=point[0]-center[0],dy=point[1]-center[1], - distance=Math.hypot(dx,dy),limit=CELL*.44,scale=distance>limit?limit/distance:1; - drawing.renderedHandlePosition=setPoint(drawing.renderedHandlePosition,center[0]+dx*scale,center[1]+dy*scale); + drawing.renderedHandlePosition=setPoint(drawing.renderedHandlePosition,point[0],point[1]); return true; } function liveEndpointPoint(b,pathIndex,actualTip){ @@ -3261,7 +3441,7 @@ function buildDragCache(b,index,blended,count){ matchOrbit.append(matchOrbitRing,matchOrbitHoles);startHalo.style.display='none';startKnob.style.display='none';tipGroup.append(matchOrbit,halo,knob,cursorImage,cursorEmoji,badge,turns); layer.append(liveTail,startHalo,startKnob,tipGroup);return b.dragCache={index,blended,count,strokes,gradients,logicalRevision:-1,geometryRevision:-1,startHalo,startKnob,liveTail,tipGroup,matchOrbit,matchOrbitRing,matchOrbitHoles,halo,knob,cursorImage,cursorEmoji,badge,turns}; } -function activeCustomCursorItem(){return CURSOR_ITEM_BY_STYLE.get(data.cursorStyle)||null} +function activeCustomCursorItem(){return cursorModel.item(data.cursorStyle)} function updateDragCursorDesign(cache){ const item=activeCustomCursorItem(),showFlag=Boolean(item?.flagAsset),showEmoji=Boolean(item?.cursorEmoji&&!showFlag); if(cache.cursorDesignStyle!==(item?.cursorStyle||null)){ @@ -3312,7 +3492,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)=>{ @@ -3321,8 +3503,8 @@ function renderDragFrame(b){ else if(node.getAttribute('stroke')!==startColor)node.setAttribute('stroke',startColor); });cache.geometryRevision=logicalRevision; } - const actualTip=boardCellCenter(path.cells[path.cells.length-1]),tip=liveEndpointPoint(b,index,actualTip),displayColorIndex=displayedEndpointColorIndex(b.meta,path,'end'),color=LINE_COLORS[displayColorIndex%LINE_COLORS.length],turnCount=drawing.renderTurnCount||'0'; - const previousTip=drawing.lastRenderedTip;if(previousTip&&cache.logicalRevision===logicalRevision&&Math.abs(previousTip[0]-tip[0])<.45&&Math.abs(previousTip[1]-tip[1])<.45){perfEnd('renderDragFrame',started);return true}drawing.lastRenderedTip=setPoint(drawing.lastRenderedTip,tip[0],tip[1]); + const actualTip=boardCellCenter(path.cells[path.cells.length-1]),tip=liveEndpointPoint(b,index,actualTip),color=startColor,turnCount=drawing.renderTurnCount||'0',colorChanged=drawing.lastRenderedColor!==color; + const previousTip=drawing.lastRenderedTip;if(previousTip&&!colorChanged&&cache.logicalRevision===logicalRevision&&Math.abs(previousTip[0]-tip[0])<.45&&Math.abs(previousTip[1]-tip[1])<.45){perfEnd('renderDragFrame',started);return true}drawing.lastRenderedColor=color;drawing.lastRenderedTip=setPoint(drawing.lastRenderedTip,tip[0],tip[1]); const tailNow=perfNow(),tailDue=geometryChanged||!drawing.lastTailRenderAt||tailNow-drawing.lastTailRenderAt>=32; if(tailDue){drawing.lastTailRenderAt=tailNow;setSvgAttr(cache.liveTail,'x1',actualTip[0]);setSvgAttr(cache.liveTail,'y1',actualTip[1]);setSvgAttr(cache.liveTail,'x2',tip[0]);setSvgAttr(cache.liveTail,'y2',tip[1]);setSvgAttr(cache.liveTail,'stroke',startColor);applyLineWidth(cache.liveTail,lineWidth)} if(path.detachedStart){const startTip=boardCellCenter(path.cells[0]);for(const node of[cache.startHalo,cache.startKnob]){node.style.display='';node.setAttribute('cx',startTip[0]);node.setAttribute('cy',startTip[1])}cache.startKnob.setAttribute('fill',startColor)}else for(const node of[cache.startHalo,cache.startKnob])node.style.display='none'; @@ -3343,17 +3525,23 @@ function refreshDragNumberColors(b){ drawing.dragNumberKeys=current; } function boardPlayHudVisible(b,solved=metaState(b.id).solved){return !solved&&hudBoardId===b?.id} +function setBoardHudVisibility(b,visible){if(!b)return false;const next=Boolean(visible);b.card.classList.toggle('hud-current',next);b.label?.classList.toggle('hud-visible',next);if(b.label)b.label.hidden=!next;if(next)positionBoardLabel(b);return next} +function activateBoardHud(b){ + if(!b)return false;const previous=rendered.get(hudBoardId);hudBoardId=b.id; + if(previous&&previous!==b)setBoardHudVisibility(previous,false); + setBoardHudVisibility(b,boardPlayHudVisible(b));return true; +} function renderBoardNow(b){ const renderStarted=perfStart();perfCount('fullBoardRenders'); clearDragRender(b); const{p,meta,card,label,pathLayer,connectorLayer}=b,st=metaState(meta.id); rebuildEndpointIndexes(b,st); const solved=!!st.solved,validation=new Map(),lineLengthCache=currentLineGraphCaches().widths; - connectorLayer.replaceChildren();b.connectorNodes=[];card.classList.toggle('solved',solved);applyClaimPresentationToBoard(b);updateScoreLensBadge(b);const hudVisible=boardPlayHudVisible(b,solved);card.classList.toggle('hud-current',hudVisible);if(hudVisible)positionBoardLabel(b);b.boardActions.hidden=solved; + connectorLayer.replaceChildren();b.connectorNodes=[];card.classList.toggle('solved',solved);applyClaimPresentationToBoard(b);updateScoreLensBadge(b);const hudVisible=boardPlayHudVisible(b,solved);setBoardHudVisibility(b,hudVisible);b.boardActions.hidden=solved; const solver=st.solvedBy&&st.solvedBy!==LEGACY_LOCAL_SOLVER?st.solvedBy:currentPlayerName(); b.solverName.textContent=solver;b.solverBadge.setAttribute('aria-hidden',solved?'false':'true'); - const store=solved?st.store:null,storePath=store?st.paths[store.pathIndex]:null,storeCell=storePath?.cells?.[store.cellIndex]; - b.storeButton.hidden=!storeCell; + const store=solved?st.store:null,storeCell=store?storeCellForMeta(meta):null; + b.storeButton.hidden=!storeCell;for(const[key,node]of b.obstacleNodes||[])node.style.display=storeCell&&key===ckey(...storeCell)?'none':''; if(storeCell){ const[x,y]=boardCellCenter(storeCell);b.storeButton.style.left=x+'px';b.storeButton.style.top=y+'px'; b.storeButton.setAttribute('aria-label',`${store.owner}\u306e\u30b7\u30e7\u30c3\u30d7`); @@ -3377,7 +3565,7 @@ function renderBoardNow(b){ card.classList.toggle('has-unfilled-warning',unfilledWarning); const showUnfilledWarning=unfilledWarning&&!b.unfilledWarningActive&&activeBoard===meta.id; b.unfilledWarningActive=unfilledWarning; - label.replaceChildren(Object.assign(document.createElement('b'),{textContent:`\u30ec\u30d9\u30eb${meta.level}`}),b.boardActions); + label.replaceChildren(Object.assign(document.createElement('b'),{textContent:`\u30ec\u30d9\u30eb${meta.level}`}),b.boardActions);if(hudVisible)positionBoardLabel(b); if(!solved||!b.solvedPathsRendered){ pathLayer.replaceChildren();b.pathStrokeNodes=[];b.pathVisualNodes=new Map(); const defs=svgEl('defs');pathLayer.append(defs); @@ -3393,18 +3581,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)} } @@ -3419,7 +3607,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); @@ -3454,47 +3642,62 @@ function renderBoardNow(b){ const inherited=sharedBoundaryColorIndex(meta,i),path=gateUsedPath(st,i), color=inherited!=null?LINE_COLORS[inherited]:(gateOwnColors.get(i)||'#f4f5f5'), sealed=!gateConnectionAllowed(meta,i),[gr,gc]=globalCell(meta,[g[0],g[1]]),[dr,dc]=SIDE_D[g[2]], - frontier=!sealed&&!matchingNeighborGate(meta,i)&&!unitOccupiedAtGlobalCell(gr+dr,gc+dc,meta.id), + frontier=!isInternalGateIndex(p,i)&&!sealed&&!matchingNeighborGate(meta,i)&&!unitOccupiedAtGlobalCell(gr+dr,gc+dc,meta.id), 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)); - b.gateMarkers[i]?.setAttribute('stroke',sealed?'#7d878e':color); + b.gateMarkers[i]?.setAttribute('d',gateMarkerPathAt(gp,gate.side,sealed,1,gate.internal)); + 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; @@ -3502,31 +3705,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(); @@ -3537,7 +3765,7 @@ function handleExpansionError(error){ const workerDelay=error?.code==='WORKER_UNAVAILABLE'?Math.max(1000,workerRetryAt-Date.now()+50):700;scheduleExpansionRepair(workerDelay); } function checkSolvedWhenInteractionSettles(b){ - if(globalThis.document?.body?.classList?.contains?.('is-interacting')){requestAnimationFrame(()=>checkSolvedWhenInteractionSettles(b));return} + if(interactionActive('persistence')){requestAnimationFrame(()=>checkSolvedWhenInteractionSettles(b));return} checkSolvedAndExpand(b).catch(handleExpansionError); } const pendingBoardCommandSettlements=new Map(); @@ -3550,7 +3778,7 @@ function scheduleBoardCommandSettlement(b,{persist=false,solve=false,paint=true, if(boardCommandSettleFrame)return; boardCommandSettleFrame=requestAnimationFrame(()=>{ boardCommandSettleFrame=0; - if(document.body.classList.contains('is-interacting')){scheduleBoardCommandSettlement(b,{paint:false});return} + if(interactionActive('persistence')){scheduleBoardCommandSettlement(b,{paint:false});return} const settlements=[...pendingBoardCommandSettlements.values()];pendingBoardCommandSettlements.clear(); for(const item of settlements){ const index=item.pathIndexes.size===1?[...item.pathIndexes][0]:null; @@ -3596,19 +3824,23 @@ 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){ finishCompletionVisual(b.id,true);if(data.metas[b.id]===b.meta&&data.states[b.id]===st){data.states[b.id]=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} 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(b.id);if(!published&&metaState(b.id)?.solved===true)await sleep(180*(attempt+1))} if(!published){ - finishCompletionVisual(b.id,true);if(data.metas[b.id]===b.meta&&data.states[b.id]===st){data.states[b.id]=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);markMetaDirty(b.id);markGlobalDirty(false);await persistNow({skipCloud:true})} - await pullCloudWorld(true);renderBoard(rendered.get(b.id));updateHud();toast('共有ワールドで先に更新されたため、最新状態を取得しました。');return false; + 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} @@ -3625,7 +3857,7 @@ function gateConnectEffect(b,gi,color){ } function reconcileDrawingPresentation(){refreshInteractionState();scheduleInteractionSettlePresentation()} function finalizeAtGate(b,gi){ - const st=metaState(b.id),pi=b.drawing?.pathIndex,path=pi!=null?st.paths[pi]:null;if(!path||gi==null||!path.detachedStart&&gi===path.startGate||usedGateSet(st,pi).has(gi))return false; + const pointerId=b?.drawing?.pointerId,st=metaState(b.id),pi=b.drawing?.pathIndex,path=pi!=null?st.paths[pi]:null;if(!path||gi==null||!path.detachedStart&&gi===path.startGate||usedGateSet(st,pi).has(gi))return false; const g=b.p.g[gi],last=path.cells[path.cells.length-1];if(g[0]!==last[0]||g[1]!==last[1])return false; const color=LINE_COLORS[(path.startColorIndex??path.colorIndex??0)%LINE_COLORS.length],done=applyBoardCommand(b,()=>{ if(path.detachedStart){ @@ -3634,7 +3866,7 @@ function finalizeAtGate(b,gi){ }else{path.endGate=gi;path.openGate=null;const inherited=neighborColor(b.meta,gi);path.endColorIndex=inherited??path.startColorIndex??path.colorIndex} b.drawing=null;b.armedGate=null; },{persist:true,solve:true,deferSettlement:true}); - if(done){playSound('gate');if(usesLightweightDragOverlay(b))requestAnimationFrame(()=>gateConnectEffect(b,gi,color));else gateConnectEffect(b,gi,color);queueMicrotask(reconcileDrawingPresentation)}return done; + if(done){commitConnectedLineVisuals(b,pi);cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay();if(pointerId!=null)safeRelease(b.svg,pointerId);playSound('gate');if(usesLightweightDragOverlay(b))requestAnimationFrame(()=>gateConnectEffect(b,gi,color));else gateConnectEffect(b,gi,color);queueMicrotask(reconcileDrawingPresentation)}return done; } function openTipMergePlan(b,ai,oi,otherSide='end',enteredOtherTipCell=false){ const st=metaState(b.id);if(ai===oi||!Number.isInteger(ai)||!Number.isInteger(oi))return null; @@ -3647,10 +3879,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){ @@ -3661,7 +3894,7 @@ function joinTips(b,ai,oi,otherSide='end',enteredOtherTipCell=false){ if(st.paths[ai]!==plan.a||st.paths[oi]!==plan.o)return false; st.paths.splice(plan.hi,1);st.paths.splice(plan.lo,1);st.paths.push(plan.merged);b.drawing=null; },{persist:true,solve:true,deferSettlement:true})}finally{b.joiningPaths=false} - if(done){if(pointerId!=null)safeRelease(b.svg,pointerId);playSound('gate');queueMicrotask(reconcileDrawingPresentation)} + if(done){commitConnectedLineVisuals(b,metaState(b.id).paths.length-1);if(pointerId!=null)safeRelease(b.svg,pointerId);playSound('gate');queueMicrotask(reconcileDrawingPresentation)} return done; } function pathAxisAtCell(path,p,cell){ @@ -3676,7 +3909,9 @@ function activateCrossing(b,cell,owner,last,deferRender=false,segmentOccupancy=n const key=ckey(...cell),st=metaState(b.id),pi=b.drawing?.pathIndex,path=pi==null?null:st.paths[pi];if(!path||!b.crossingKeySet.has(key))return false; const other=st.paths[owner],otherAxis=pathAxisAtCell(other,b.p,cell),approachAxis=last[0]===cell[0]?'H':last[1]===cell[1]?'V':null; if(!otherAxis||!approachAxis||otherAxis===approachAxis||pathIndexesAtCell(st,key).length!==1)return false; + const touchedKey=specialCellSet(b.p).locks.some(candidate=>sameCell(candidate.key,cell)); applyBoardCommand(b,()=>{path.cells.push(cell);path.openGate=null},{paint:!deferRender,persist:!deferRender,invalidate:!deferRender}); + if(touchedKey)playSound('key'); if(segmentOccupancy)segmentOccupancy.set(key,pi);b.drawing?.pathCellIndex?.set(key,path.cells.length-1);playSound('gate');return true; } function extendOne(b,cell,deferRender=false,segmentOccupancy=null,suppressMotion=false){ @@ -3728,7 +3963,7 @@ function extendOne(b,cell,deferRender=false,segmentOccupancy=null,suppressMotion const beforeWarp=path.cells[path.cells.length-1],warpDirection=[cell[0]-beforeWarp[0],cell[1]-beforeWarp[1]]; applyBoardCommand(b,()=>{path.cells.push(cell,[...warpExit]);path.openGate=null},{paint:!deferRender,persist:!deferRender,invalidate:!deferRender}); if(segmentOccupancy){segmentOccupancy.set(key,pi);segmentOccupancy.set(exitKey,pi)}if(b.drawing?.pathCellIndex){b.drawing.pathCellIndex.set(key,path.cells.length-2);b.drawing.pathCellIndex.set(exitKey,path.cells.length-1)} - if(pointerId!=null)b.warpedDuringExtend=true;if(b.drawing)b.drawing.warpDirection=warpDirection + if(pointerId!=null)b.warpedDuringExtend=true;if(b.drawing)b.drawing.warpDirection=warpDirection;playSound('warp') const gi=gateAtCell(b.p,warpExit,path.detachedStart?null:path.startGate);if(gi!=null&&!usedGateSet(st,pi).has(gi))return finalizeAtGate(b,gi); return true; } @@ -3739,15 +3974,6 @@ function extendOne(b,cell,deferRender=false,segmentOccupancy=null,suppressMotion if(gi!=null&&!usedGateSet(st,pi).has(gi))return finalizeAtGate(b,gi); return true; } -function extendToward(b,target){ - const path=activePath(b);if(!path||!target)return false; - const last=path.cells[path.cells.length-1]; - if(sameCell(last,target))return true; - // A direct cell click may only advance to a neighboring cell. Long pointer - // movement is handled by segment traversal below, never by a guessed route. - if(manhattan(last,target)!==1)return false; - return extendOne(b,target); -} function pathTailAxis(path,p){ if(!path?.cells?.length)return null; const last=path.cells[path.cells.length-1],previous=path.cells.length>1?path.cells[path.cells.length-2]:path.detachedStart?null:outsidePoint(gateObj(p,path.startGate));if(!previous)return null; @@ -3796,21 +4022,24 @@ function removeDetachedPathAtOwnEndpoint(b,point,pixels=24){ if((fixed[0]-point[0])**2+(fixed[1]-point[1])**2>radius*radius)return false; const pointerId=drawing.pointerId,index=drawing.pathIndex;cancelBoardDragFrame(b); const done=applyBoardCommand(b,()=>{if(st.paths[index]!==path)return false;st.paths.splice(index,1);b.drawing=null;b.armedGate=null},{persist:true,solve:true,deferSettlement:true}); - if(done){if(pointerId!=null)safeRelease(b.svg,pointerId);playSound('reset');queueMicrotask(reconcileDrawingPresentation)} + if(done){if(typeof renderBoardNow==='function')renderBoardNow(b);if(pointerId!=null)safeRelease(b.svg,pointerId);playSound('remove');queueMicrotask(reconcileDrawingPresentation)} return Boolean(done); } -function extendPointerTo(b,rawPoint,deferFrameRender=false,catchupLimit=DRAG_MAX_CATCHUP_CELLS){ +function extendPointerTo(b,rawPoint,deferFrameRender=false,catchupLimit=DRAG_MAX_LIVE_CATCHUP_CELLS){ + const prepareStarted=perfStart(); const drawing=b.drawing,path=activePath(b);if(!drawing||!path||!rawPoint)return; const pathIndex=drawing.pathIndex;b.specialCrossTriggered=false;b.warpedDuringExtend=false; const mappedPoint=pointerPointForDrawing(drawing,rawPoint);updateDrawingPointerState(b,drawing,mappedPoint); const point=mappedPoint,targetCell=gridCellAt(b,point),candidate=drawing.currentCandidateCell,probeStep=6,probeKey=`${drawing.currentConfirmedCell?.join(',')||''}|${targetCell?.join(',')||''}|${candidate?.join(',')||''}|${Math.round(point[0]/probeStep)},${Math.round(point[1]/probeStep)}`; if(drawing.lastModelProbeKey===probeKey&&!drawing.catchupPending)return;drawing.lastModelProbeKey=probeKey;drawing.catchupPending=false; const from=boardCellCenter(path.cells[path.cells.length-1]),segmentOccupancy=drawing.segmentOccupancy||(drawing.segmentOccupancy=occupiedMap(metaState(b.id)));let changedPath=false,blocked=false,feedbackCell=null,feedbackLength=0; - const emitBatchedFeedback=()=>{if(!feedbackCell)return;const now=perfNow();if(now-(b.lastDragToneAt||0)>=90){b.lastDragToneAt=now;playConfirmTone(feedbackLength)}if(uiSettings.lightweightRendering||autoReducedEffects||now-(b.lastDragFeedbackAt||0)<180)return;b.lastDragFeedbackAt=now;flashConfirmedCell(b,feedbackCell)}; + perfEnd('pickupModelPrepare',prepareStarted);const topologyStarted=perfStart(); + const emitBatchedFeedback=()=>{if(!feedbackCell)return;const now=perfNow();if(now-(b.lastDragToneAt||0)>=90){b.lastDragToneAt=now;playConfirmTone(feedbackLength)}if(uiSettings.lightweightRendering||now-(b.lastDragFeedbackAt||0)<180)return;b.lastDragFeedbackAt=now;flashConfirmedCell(b,feedbackCell)}; updateGateSnapArming(b,drawing); if(removeDetachedPathAtOwnEndpoint(b,point)){queueSelectedProgress(b);return} const overlap=overlappingOpenTipAtPoint(b,point); if(overlap&&joinTips(b,pathIndex,overlap.index,overlap.side)){queueSelectedProgress(b);return} + perfEnd('pickupModelTopology',topologyStarted);const traversalStarted=perfStart(); if(targetCell&&(drawing.pathCellIndex?.has(ckey(...targetCell))||path.cells.some(cell=>sameCell(cell,targetCell))))changedPath=rewindActivePathToCell(b,targetCell,segmentOccupancy)||changedPath; else{ const crossed=cellsCrossedBySegment(from,point,{preferredAxis:pathTailAxis(activePath(b),b.p),validCells:cellSet(b.p)}), @@ -3820,11 +4049,13 @@ function extendPointerTo(b,rawPoint,deferFrameRender=false,catchupLimit=DRAG_MAX const key=ckey(...cell),active=activePath(b);if(!active){catchupHalted=true;break} if(!cellSet(b.p).has(key)){blocked=true;continue} if(blocked){if(active.cells.some(existing=>sameCell(existing,cell)))changedPath=rewindActivePathToCell(b,cell,segmentOccupancy)||changedPath;continue} - if(!extendOne(b,cell,true,segmentOccupancy,true)){catchupHalted=true;break} + const cellStarted=perfStart(),extended=extendOne(b,cell,true,segmentOccupancy,true);perfEnd('pickupModelCell',cellStarted); + if(!extended){catchupHalted=true;break} changedPath=true;const updated=activePath(b)||active,tip=updated?.cells?.[updated.cells.length-1];feedbackCell=tip?[...tip]:[...cell];feedbackLength=updated?.cells?.length||1;if(b.specialCrossTriggered||b.warpedDuringExtend)break; } if(b.drawing&&crossed.length>batch.length&&!catchupHalted&&!blocked&&!b.specialCrossTriggered&&!b.warpedDuringExtend){b.drawing.catchupPending=true;perfCount('pickupCatchupBatches');perfGauge('pickupCatchupCellsRemaining',crossed.length-batch.length)} } + perfEnd('pickupModelTraversal',traversalStarted); if(b.warpedDuringExtend&&b.drawing){ const active=activePath(b),tip=active?.cells?.[active.cells.length-1]; if(tip){const exitPoint=boardCellCenter(tip);b.drawing.pointerOffset=[exitPoint[0]-rawPoint[0],exitPoint[1]-rawPoint[1]];b.drawing.lastPoint=exitPoint;syncDrawingConfirmedState(b,b.drawing)} @@ -3917,14 +4148,14 @@ 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}); if(started&&pointerId==null)renderBoardNow(b); } function safeCapture(el,id){try{el.setPointerCapture(id)}catch(_){}finally{refreshInteractionState()}} -function safeRelease(el,id){try{if(el.hasPointerCapture?.(id))el.releasePointerCapture(id)}catch(_){}finally{refreshInteractionState();scheduleInteractionSettlePresentation()}} +function safeRelease(el,id){try{if(el.hasPointerCapture?.(id))el.releasePointerCapture(id)}catch(_){}finally{gestureCoordinator.release(id,'draw');refreshInteractionState();scheduleInteractionSettlePresentation()}} function eventToSvg(b,e){ const r=boardScreenRect(b),vb=b.svg.viewBox.baseVal; if(!r.width||!r.height)return null; @@ -3934,21 +4165,10 @@ function cellAt(b,pt){if(!pt)return null;const c=Math.floor((pt[0]-PAD)/CELL),r= function setActiveBoard(id){const previous=rendered.get(activeBoard);let endedDrawing=false;activeBoard=id;if(previous&&previous.id!==id&&previous.drawing?.pointerId==null){endedDrawing=Boolean(previous.drawing);previous.drawing=null;previous.armedGate=null;if(endedDrawing)renderBoard(previous)}if(endedDrawing)queueMicrotask(reconcileDrawingPresentation);return previous} function selectBoard(b,{paint=true}={}){ if(!b)return; - const alreadySelected=activeBoard===b.id&&hudBoardId===b.id&&data.selectedBoardId===b.id; - const previousHud=rendered.get(hudBoardId);setActiveBoard(b.id);hudBoardId=b.id;data.selectedBoardId=b.id;markGlobalDirty(false); - if(previousHud&&previousHud!==b){previousHud.card.classList.remove('hud-current');if(paint)renderBoard(previousHud)} - b.card.classList.add('hud-current');if(!alreadySelected&&paint)renderBoard(b);updateSelectedProgress(b) + const alreadySelected=activeBoard===b.id&&data.selectedBoardId===b.id; + setActiveBoard(b.id);data.selectedBoardId=b.id;markGlobalDirty(false); + if(!alreadySelected&&paint)renderBoard(b);updateSelectedProgress(b) } -function metasAreAdjacent(a,b){ - if(!a?.chunks||!b?.chunks||a.id===b.id)return false; - const cells=new Set(b.chunks.map(([dx,dy])=>key2(b.x+dx,b.y+dy))); - for(const[dx,dy]of a.chunks){ - const x=a.x+dx,y=a.y+dy; - if(cells.has(key2(x+1,y))||cells.has(key2(x-1,y))||cells.has(key2(x,y+1))||cells.has(key2(x,y-1)))return true; - } - return false; -} -function shouldTeleportToUnsolvedBoard(_meta){return false} function pointerLocalRadius(_board,pixels=50){return pixels/Math.max(MIN_CAMERA_SCALE,cam.scale)} function pointerFacesGate(b,index,point){ const gate=gateObj(b.p,index),center=boardCellCenter(gate.cell),[dr,dc]=SIDE_D[gate.side]; @@ -3996,7 +4216,7 @@ function rebuildEndpointIndexes(b,st=metaState(b.id)){ } function endpointCandidatesNearPoint(b,point){ const cell=gridCellAt(b,point);if(!cell)return[]; - const index=b.endpointIndexesByCell?.size?b.endpointIndexesByCell:rebuildEndpointIndexes(b),candidates=[]; + const index=b.endpointIndexesByCell||rebuildEndpointIndexes(b),candidates=[]; for(let dr=-1;dr<=1;dr++)for(let dc=-1;dc<=1;dc++)for(const entry of index.get(ckey(cell[0]+dr,cell[1]+dc))||[])candidates.push(entry); return candidates; } @@ -4009,7 +4229,7 @@ function nearestEndpointAtPoint(b,point){ function overlappingOpenTipAtPoint(b,point,pixels=42){ const drawing=b?.drawing,st=drawing&&metaState(b.id),active=st?.paths?.[drawing.pathIndex];if(!point||!active?.cells?.length)return null; const activeTip=active.cells[active.cells.length-1],radius=Math.min(pointerLocalRadius(b,pixels),CELL*.82),limit=radius*radius;let best=null,bestDistance=limit; - const endpointIndex=b.endpointIndexesByCell?.size?b.endpointIndexesByCell:rebuildEndpointIndexes(b,st); + const endpointIndex=b.endpointIndexesByCell||rebuildEndpointIndexes(b,st); for(const entry of endpointIndex.get(ckey(...activeTip))||[]){if(entry.index===drawing.pathIndex)continue;const center=boardCellCenter(entry.cell),distance=(center[0]-point[0])**2+(center[1]-point[1])**2;if(distance<=bestDistance){best={index:entry.index,side:entry.side};bestDistance=distance}} return best; } @@ -4047,15 +4267,23 @@ function edgePanVelocity(clientX,clientY){ return[axis(clientX,rect.left,rect.right)*DRAG_EDGE_MAX_SPEED,axis(clientY,rect.top,rect.bottom)*DRAG_EDGE_MAX_SPEED]; } function pointerInsideBoardScreen(b,move){const rect=boardScreenRect(b);return Boolean(move&&rect.width&&rect.height&&move.clientX>=rect.left&&move.clientX<=rect.right&&move.clientY>=rect.top&&move.clientY<=rect.bottom)} -function hidePickupHandleOverlay(){pickupHandleOverlay?.classList.remove('visible')} +function hidePickupHandleOverlay(){if(!pickupHandleOverlay)return;pickupHandleOverlay.classList.remove('visible');pickupHandleOverlay.style.removeProperty('transform')} +function syncPickupHandleDesign(){ + if(!pickupHandleOverlay)return false; + const item=activeCustomCursorItem(),style=item?.cursorStyle||'default';if(pickupHandleOverlay.dataset.cursorStyle===style)return Boolean(item); + pickupHandleOverlay.dataset.cursorStyle=style;pickupHandleOverlay.classList.toggle('custom-cursor',Boolean(item));pickupHandleOverlay.classList.toggle('flag-cursor',Boolean(item?.flagAsset)); + if(item?.flagAsset){const image=document.createElement('img');image.src=item.flagAsset;image.alt='';image.draggable=false;pickupHandleOverlay.replaceChildren(image)}else pickupHandleOverlay.textContent=item?.cursorEmoji||''; + return Boolean(item); +} +function activeDrawingLineColorIndex(path){return Number.isInteger(path?.startColorIndex)?path.startColorIndex:Number.isInteger(path?.colorIndex)?path.colorIndex:0} function updatePickupHandleOverlay(b,drawing,clientX,clientY){ - if(!pickupHandleOverlay||!drawing||!Number.isFinite(clientX)||!Number.isFinite(clientY))return false; - const path=activePath(b),color=LINE_COLORS[(path?.endColorIndex??path?.startColorIndex??path?.colorIndex??0)%LINE_COLORS.length],transform=`translate3d(${clientX}px,${clientY}px,0) translate(-50%,-50%)`; - if(pickupHandleOverlay.style.transform!==transform)pickupHandleOverlay.style.transform=transform; + if(!pickupHandleOverlay)return false;if(!drawing||!Number.isFinite(clientX)||!Number.isFinite(clientY)){hidePickupHandleOverlay();return false} + const path=activePath(b),color=LINE_COLORS[activeDrawingLineColorIndex(path)%LINE_COLORS.length],transform=`translate3d(${clientX}px,${clientY}px,0) translate(-50%,-50%)`; + syncPickupHandleDesign();if(pickupHandleOverlay.style.transform!==transform)pickupHandleOverlay.style.transform=transform; if(pickupHandleOverlay.style.getPropertyValue('--pickup-color')!==color)pickupHandleOverlay.style.setProperty('--pickup-color',color); pickupHandleOverlay.classList.add('visible');return true; } -function usesLightweightDragOverlay(b){return(b?.p?.valid?.length||0)>=LIGHTWEIGHT_DRAG_BOARD_CELLS} +function usesLightweightDragOverlay(b){return uiSettings.lightweightRendering&&(b?.p?.valid?.length||0)>=LIGHTWEIGHT_DRAG_BOARD_CELLS} function setLightweightDragPresentation(b,active){ if(!b||!usesLightweightDragOverlay(b))return false;const next=Boolean(active);if(b.lightweightDragPresentation===next)return false;b.lightweightDragPresentation=next; for(const layer of[b.connectorLayer,b.pathLayer,b.specialLayer,b.gateLayer,b.numberLayer])if(layer)layer.style.opacity=next?'0':''; @@ -4070,27 +4298,30 @@ function recordPickupStartFrame(b,drawing){ perfGauge('pickupStartFullBoardRenders',Math.max(0,(perfCounters.fullBoardRenders||0)-(drawing.fullBoardRendersAtPointerDown||0))); if(Number.isFinite(drawing.pointerDownAt))perfObserve('pickupStartLatency',Math.max(0,perfNow()-drawing.pointerDownAt));return true; } -function cancelBoardDragFrame(b){if(!b)return;if(b.pointerMoveFrame)cancelAnimationFrame(b.pointerMoveFrame);if(b.pointerMoveDelayTimer)clearTimeout(b.pointerMoveDelayTimer);b.pointerMoveTaskController?.abort();b.pointerMoveFrame=0;b.pointerMoveDelayTimer=0;b.pointerMoveTaskController=null;b.pendingPointerMove=null;b.pointerMoveSamples=[];b.lastProcessedPointerMove=null;b.dragFrameAt=0;b.dragNextFrameAt=0;b.dragVisualActiveUntil=0;b.dragVisualX=NaN;b.dragVisualY=NaN;b.dragVisualLastFrameAt=0;b.dragVisualCommitAt=0;b.dragSvgVisualAt=0;b.dragTrailRevision=-1;b.dragInputRevision=0;b.dragVisualCommittedInputRevision=0;b.dragLogicalCommittedInputRevision=0;hidePickupHandleOverlay()} +function ensureBoardDragScheduler(b){ + if(!b)return null; + if(!b.dragScheduler)b.dragScheduler=DragSchedulerApi.createDragScheduler({ + maxSamples:DRAG_MAX_POINTER_SAMPLES, + trim:trimBoardPointerSamples, + interval:DRAG_FRAME_INTERVAL, + tolerance:INTERACTION_FRAME_TOLERANCE_MS, + watchdogDelay:DRAG_DISPLAY_WATCHDOG_MS, + now:perfNow, + onWatchdog:()=>perfCount('pickupDisplayWatchdogFrames'), + onFrame:(_scheduler,timestamp)=>{ + if(b.releaseDrain)processBoardPointerReleaseDrain(b,b.releaseDrain,timestamp); + else processBoardDragFrame(b,timestamp); + } + }); + return b.dragScheduler; +} +function cancelBoardDragFrame(b){ + if(!b)return; + b.dragScheduler?.cancel();b.releaseDrain=null; + b.lastProcessedPointerMove=null;b.dragFrameAt=0;b.dragNextFrameAt=0;b.dragVisualActiveUntil=0;b.dragVisualX=NaN;b.dragVisualY=NaN;b.dragVisualLastFrameAt=0;b.dragVisualCommitAt=0;b.dragSvgVisualAt=0;b.dragTrailRevision=-1;hidePickupHandleOverlay(); +} function scheduleBoardDragFrame(b){ - if(!b||b.pointerMoveFrame||b.pointerMoveDelayTimer||b.pointerMoveTaskController)return; - let committed=false; - const step=timestamp=>{ - if(committed)return;committed=true; - if(b.dragVisualLastFrameAt&×tamp+INTERACTION_FRAME_TOLERANCE_MS{ - b.pointerMoveDelayTimer=0; - b.pointerMoveFrame=requestAnimationFrame(step); - const watchdog=()=>{perfCount('pickupDisplayWatchdogFrames');step(perfNow())}; - if(globalThis.scheduler?.postTask){const controller=new AbortController();b.pointerMoveTaskController=controller;scheduler.postTask(watchdog,{priority:'user-blocking',delay:DRAG_DISPLAY_WATCHDOG_MS,signal:controller.signal}).catch(()=>{})} - else b.pointerMoveDelayTimer=setTimeout(watchdog,DRAG_DISPLAY_WATCHDOG_MS); - }; - arm(); + return Boolean(ensureBoardDragScheduler(b)?.requestFrame()); } function pointerEventSamples(event){ let sample=event;if(event?.pointerType!=='mouse'&&typeof event?.getCoalescedEvents==='function'){const coalesced=event.getCoalescedEvents();if(coalesced?.length)sample=coalesced[coalesced.length-1]} @@ -4109,65 +4340,79 @@ function trimBoardPointerSamples(samples){ return samples; } function setBoardPointerSample(b,sample){ - if(!b||!sample)return null;const samples=b.pointerMoveSamples||(b.pointerMoveSamples=[]);samples.push(sample);trimBoardPointerSamples(samples); - b.pendingPointerMove=sample;b.dragInputRevision=(b.dragInputRevision||0)+1;b.dragVisualActiveUntil=perfNow()+40;return sample; + if(!b||!sample)return null; + const scheduler=ensureBoardDragScheduler(b); + if(!scheduler.push(sample))return null; + b.dragVisualActiveUntil=perfNow()+40;return sample; } function appendBoardPointerSamples(b,event){return setBoardPointerSample(b,pointerEventSamples(event))} -function commitBoardDragFromInputDeadline(b){ - const now=perfNow();if(!b?.drawing||b.dragVisualLastFrameAt&&now+INTERACTION_FRAME_TOLERANCE_MS=b.dragNextFrameAt, - freshVisualInput=b.dragVisualCommittedInputRevision!==b.dragInputRevision, - freshLogicalInput=b.dragLogicalCommittedInputRevision!==b.dragInputRevision; - if(freshVisualInput){recordInteractionCommit('pickupVisual',timestamp,move.inputAt,true);b.dragVisualCommitAt=timestamp;b.dragVisualCommittedInputRevision=b.dragInputRevision} + freshVisualInput=dragState.freshVisual, + freshLogicalInput=dragState.freshLogical; + if(freshVisualInput){recordInteractionCommit('pickupVisual',timestamp,move.inputAt,true);b.dragVisualCommitAt=timestamp;scheduler.markVisual()} else if(timestamp<=b.dragVisualActiveUntil)recordInteractionCommit('pickupVisual',timestamp,move.inputAt,false); b.lastProcessedPointerMove=move; if(logicalSlotDue){let nextFrame=b.dragNextFrameAt||timestamp;do nextFrame+=DRAG_FRAME_INTERVAL;while(nextFrame<=timestamp+2);b.dragNextFrameAt=nextFrame} - const visualElapsed=b.dragVisualLastFrameAt?Math.min(50,Math.max(1,timestamp-b.dragVisualLastFrameAt)):16.67,visualBlend=1-Math.exp(-visualElapsed/8); - if(!Number.isFinite(b.dragVisualX)){b.dragVisualX=move.clientX;b.dragVisualY=move.clientY} - else{b.dragVisualX+=(move.clientX-b.dragVisualX)*visualBlend;b.dragVisualY+=(move.clientY-b.dragVisualY)*visualBlend} - b.dragVisualLastFrameAt=timestamp; + b.dragVisualX=move.clientX;b.dragVisualY=move.clientY;b.dragVisualLastFrameAt=timestamp; touchBoardClaim(b.id);const velocity=edgePanVelocity(move.clientX,move.clientY),dt=Math.min(32,Math.max(8,timestamp-(b.dragFrameAt||timestamp-16)));b.dragFrameAt=timestamp; if(velocity[0]||velocity[1]){cam.x-=velocity[0]*dt;cam.y-=velocity[1]*dt;applyCamera(true)} - const logicalDue=logicalSlotDue&&(freshLogicalInput||velocity[0]||velocity[1]||b.drawing?.catchupPending||b.pointerMoveSamples?.length); + const logicalDue=logicalSlotDue&&(freshLogicalInput||velocity[0]||velocity[1]||b.drawing?.catchupPending||scheduler.hasLogical()); if(logicalDue){ const modelStarted=perfStart();let processed=0,logicalMove=move; do{ - logicalMove=freshLogicalInput&&b.pointerMoveSamples?.length?b.pointerMoveSamples.shift():move;processed++; + logicalMove=freshLogicalInput&&scheduler.hasLogical()?scheduler.takeLogical(1)[0]:move;processed++; const point=pointerInsideBoardScreen(b,logicalMove)?eventToSvg(b,logicalMove):null,traceCell=point?cellAt(b,point):null; if(traceCell){const trace=b.logicalPointerCellTrace||(b.logicalPointerCellTrace=[]);trace.push(traceCell);if(trace.length>48)trace.shift()} if(point&&b.drawing?.pointerId===logicalMove.pointerId)extendPointerTo(b,point,true); - }while(freshLogicalInput&&b.pointerMoveSamples?.length&&processed48)trace.shift()}if(point&&b.drawing?.pointerId===move.pointerId)extendPointerTo(b,point,true,index===moves.length-1?Infinity:DRAG_MAX_CATCHUP_CELLS)} +function scheduleBoardPointerReleaseDrain(b){ + const drain=b?.releaseDrain;if(!drain)return false; + ensureBoardDragScheduler(b).requestFrame();return true; +} +function processBoardPointerReleaseDrain(b,drain,timestamp){ + if(b.releaseDrain!==drain)return; + const started=perfStart();let processed=0; + while(drain.index48)trace.shift()}} + if(point&&b.drawing?.pointerId===move.pointerId)extendPointerTo(b,point,true,DRAG_MAX_RELEASE_CATCHUP_CELLS); + processed++; + if(b.releaseDrain!==drain){queueMicrotask(drain.onComplete);perfEnd('pickupReleaseDrainWork',started);return} + if(!b.drawing||b.drawing.pointerId!==drain.pointerId||!b.drawing.catchupPending)drain.index++; + } if(b.drawing&&!usesLightweightDragOverlay(b))renderDragFrame(b); + recordInteractionCommit('pickupReleaseDrain',timestamp,drain.inputAt,true);perfCount('pickupReleaseDrainFrames');perfEnd('pickupReleaseDrainWork',started); + if(drain.index{}){ + const scheduler=ensureBoardDragScheduler(b),sample=event?pointerEventSamples(event):null; + scheduler.beginDrain(sample);const moves=scheduler.drainLogical(); + b.lastProcessedPointerMove=null;b.dragFrameAt=0;b.dragNextFrameAt=0;b.dragVisualActiveUntil=0;b.dragVisualX=NaN;b.dragVisualY=NaN;b.dragVisualLastFrameAt=0;b.dragVisualCommitAt=0;b.dragSvgVisualAt=0;b.dragTrailRevision=-1;hidePickupHandleOverlay(); + b.lastFlushPointerCells=[]; + if(!moves.length||!b.drawing){queueMicrotask(onComplete);return false} + b.releaseDrain={pointerId:b.drawing.pointerId,moves,index:0,inputAt:moves[moves.length-1]?.inputAt||perfNow(),lastFrameAt:0,tracedIndexes:new Set(),onComplete}; + scheduleBoardPointerReleaseDrain(b);return true; } function reconnectOpenGateOnRelease(b){ const drawing=b?.drawing,path=activePath(b),gateIndex=path?.openGate; @@ -4183,10 +4428,13 @@ function discardUnmovedCreatedPath(b,drawing=b?.drawing){ } function schedulePendingClaimPreview(b){ if(!b?.pendingClaimPointer||b.pendingClaimFrame)return; - b.pendingClaimFrame=requestAnimationFrame(timestamp=>{ - b.pendingClaimFrame=0;const pending=b.pendingClaimPointer;if(!pending)return;const point=eventToSvg(b,pending);if(!point)return; - pending.preview.setAttribute('transform',`translate(${point[0]} ${point[1]})`);recordInteractionCommit('pickupPreview',timestamp,pending.inputAt);markVisualFrame(timestamp);perfCount('pickupPreviewFrames'); - }); + const step=timestamp=>{ + const pending=b.pendingClaimPointer;if(!pending){b.pendingClaimFrame=0;return} + if(pending.lastFrameAt&×tamp+INTERACTION_FRAME_TOLERANCE_MS{if(b.card?.isConnected&&b.drawing?.pointerId===pointerId&&realtimeHeldPointers.has(pointerId))safeCapture(b.svg,pointerId)});return true; +} function activateBoardPointerDrag(b,pending,point){ const started=perfStart(); const drawing=b?.drawing;if(!drawing||!pending||!point){perfEnd('pickupPointerDownCommit',started);return false} cancelBoardDragFrame(b); b.logicalPointerCellTrace=[];b.lastFlushPointerCells=[]; drawing.lastPoint=point;updateDrawingPointerState(b,drawing,point);drawing.pointerDownAt=pending.startedAt;drawing.fullBoardRendersAtPointerDown=pending.fullBoardRendersAtDown; - setPickupScenePresentation(b,true); + activateBoardHud(b);setPickupScenePresentation(b,true);refreshInteractionState(); safeCapture(b.svg,pending.pointerId); const samples=pending.samples?.length?pending.samples:[{pointerId:pending.pointerId,clientX:pending.clientX,clientY:pending.clientY,inputAt:pending.inputAt}]; for(const sample of samples)setBoardPointerSample(b,sample); + const latest=samples[samples.length-1];if(latest){b.dragVisualX=latest.clientX;b.dragVisualY=latest.clientY;updatePickupHandleOverlay(b,drawing,latest.clientX,latest.clientY)} scheduleBoardDragFrame(b);perfEnd('pickupPointerDownCommit',started);return true; } function bindBoard(b){ b.card.addEventListener('focus',()=>selectBoard(b)); b.svg.addEventListener('pointerdown',async e=>{ - if(e.button!==0||b.joiningPaths)return; + const endpointTarget=e.target.closest?.('.endpoint-hit'),gateTarget=e.target.closest?.('.gate-hit'),boardTarget=e.target.closest?.('.board-input-surface'); + if(e.button!==0||b.joiningPaths||!endpointTarget&&!gateTarget&&!boardTarget||!gestureCoordinator.claim(e.pointerId,'draw'))return; e.preventDefault(); e.stopPropagation(); selectBoard(b,{paint:false}); let st=metaState(b.id); - if(st.solved)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', + if(st.solved){gestureCoordinator.release(e.pointerId,'draw');return} + 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=>{ @@ -4283,23 +4544,26 @@ 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.drawing||b.drawing.pointerId!==e.pointerId)return; - const release=()=>{clearDragRender(b);safeRelease(b.svg,e.pointerId);queueMicrotask(()=>updateCustomCursorFromPointer(e))}; - if(flush)flushBoardPointerMove(b,e);else cancelBoardDragFrame(b); + 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; + const release=(paintCurrent=false)=>{if(paintCurrent&&b.card?.isConnected)renderBoardNow(b);cancelBoardDragFrame(b);clearDragRender(b);hidePickupHandleOverlay();safeRelease(b.svg,e.pointerId);queueMicrotask(()=>updateCustomCursorFromPointer(e))}; + if(flush===true){flushBoardPointerMove(b,e,()=>finishPointer(e,'settled'));return} + if(flush!=='settled')cancelBoardDragFrame(b); if(!b.drawing||b.drawing.pointerId!==e.pointerId){release();return} - if(flush&&reconnectOpenGateOnRelease(b)){release();return} - if(discardUnmovedCreatedPath(b)){release();return} + if(flush==='settled'&&reconnectOpenGateOnRelease(b)){release(true);return} + if(discardUnmovedCreatedPath(b)){release(true);return} const finishedPathIndex=b.drawing.pathIndex;b.drawing=null;b.armedGate=null;refreshInteractionState(); const removedWarpKnobPaths=sanitizeStateForPuzzle(b.meta,{quiet:true}); changed(b.id);if(removedWarpKnobPaths)toast('両端がワープセルにある未接続の線を削除しました。'); - scheduleBoardCommandSettlement(b,{paint:true,invalidate:true,pathIndex:finishedPathIndex}); + renderBoardNow(b);scheduleBoardCommandSettlement(b,{paint:true,invalidate:true,pathIndex:finishedPathIndex}); release(); }finally{perfEnd('pickupPointerFinish',started)} }; 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});else if(b.drawing?.pointerId===e.pointerId)finishPointer(e,false);else 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){if(!recoverLostBoardPointerCapture(b,e))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=>{ @@ -4308,11 +4572,11 @@ function bindBoard(b){ const gateTarget=e.target.closest?.('.gate-hit'),endpointTarget=e.target.closest?.('.endpoint-hit'),claimKey=e.key==='Enter'||e.key===' '||e.key==='Backspace'||['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.key); if(claimKey&&!await ensureBoardClaimForInput(b))return;if(claimKey)touchBoardClaim(b.id); if((e.key==='Enter'||e.key===' ')&&gateTarget){ - e.preventDefault();selectBoard(b);startGate(b,Number(gateTarget.dataset.gate),null);b.svg.focus({preventScroll:true});return; + e.preventDefault();selectBoard(b);startGate(b,Number(gateTarget.dataset.gate),null);activateBoardHud(b);b.svg.focus({preventScroll:true});return; } if((e.key==='Enter'||e.key===' ')&&endpointTarget){ e.preventDefault();selectBoard(b);const pi=Number(endpointTarget.dataset.pathIndex),side=endpointTarget.dataset.endpointSide||'end'; - if(st.paths[pi]?.endGate==null){if(st.paths[pi].detachedStart)orientDetachedEndpoint(b,pi,side,null,null);else b.drawing=drawingForPath(b,pi,null,null);b.drawing.keyboardActive=true;renderBoard(b);b.svg.focus({preventScroll:true})}return; + if(st.paths[pi]?.endGate==null){if(st.paths[pi].detachedStart)orientDetachedEndpoint(b,pi,side,null,null);else b.drawing=drawingForPath(b,pi,null,null);b.drawing.keyboardActive=true;activateBoardHud(b);renderBoard(b);b.svg.focus({preventScroll:true})}return; } const moves={ArrowUp:[-1,0],ArrowDown:[1,0],ArrowLeft:[0,-1],ArrowRight:[0,1]}; if(moves[e.key]&&activePath(b)){ @@ -4345,44 +4609,58 @@ function visibleMetaIds(){ const ids=visibleMetaIdsForBounds(viewportChunkBounds(24)); for(const[id,b]of rendered)if(b.drawing?.pointerId!=null)ids.add(id);return ids; } -function scheduleWorldOverview(markDirty=false){ +function scheduleWorldOverview(markDirty=false,{allowDuringInteraction=false}={}){ if(markDirty)overviewDirty=true; - if(document.body.classList.contains('is-interacting'))return; + if(allowDuringInteraction)overviewAllowInteractionBuild=true; + if(interactionActive('overview')&&!overviewAllowInteractionBuild)return; if(!overviewCanvas||!inWorldOverview()||!overviewDirty||overviewFrame||overviewDelayTimer)return; const step=timestamp=>{ if(!overviewFrame)return; + if(overviewAllowInteractionBuild&×tamp-overviewInteractionLastBuild{overviewDelayTimer=0;if(document.body.classList.contains('is-interacting'))return;drawWorldOverview()}; + const rebuild=()=>{ + overviewDelayTimer=0;const allow=overviewAllowInteractionBuild;overviewAllowInteractionBuild=false; + if(interactionActive('overview')&&!allow)return; + drawWorldOverview({allowDuringInteraction:allow});if(allow)overviewInteractionLastBuild=perfNow(); + }; overviewDelayTimer=typeof requestIdleCallback==='function'?requestIdleCallback(rebuild,{timeout:180}):setTimeout(rebuild,0); }; overviewFrame=requestAnimationFrame(step); } +function overviewCacheNeedsInteractionRebuild(){ + if(!overviewCache)return true; + const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),[centerX,centerY]=cameraCenterInChunks(),scaleRatio=cam.scale/Math.max(MIN_CAMERA_SCALE,overviewCache.scale); + return overviewCache.revision!==minimapWorldRevision||overviewCache.width!==width||overviewCache.height!==height|| + Math.abs(centerX-overviewCache.anchorX)*overviewCache.unit*Math.max(.01,scaleRatio)>overviewCache.overscan*.82|| + Math.abs(centerY-overviewCache.anchorY)*overviewCache.unit*Math.max(.01,scaleRatio)>overviewCache.overscan*.82; +} function positionCachedWorldOverview(){ if(!overviewCanvas||!overviewCache||!inWorldOverview())return false; - const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),[centerX,centerY]=cameraCenterInChunks(), - left=width/2-overviewCache.baseWidth/2-(centerX-overviewCache.anchorX)*overviewCache.unit, - top=height/2-overviewCache.baseHeight/2-(centerY-overviewCache.anchorY)*overviewCache.unit; - overviewCanvas.hidden=false;overviewCanvas.style.transform=`translate3d(${left.toFixed(2)}px,${top.toFixed(2)}px,0)`; + const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),[centerX,centerY]=cameraCenterInChunks(),ratio=Math.max(.01,cam.scale/Math.max(MIN_CAMERA_SCALE,overviewCache.scale)), + left=width/2-(overviewCache.baseWidth/2+(centerX-overviewCache.anchorX)*overviewCache.unit)*ratio, + top=height/2-(overviewCache.baseHeight/2+(centerY-overviewCache.anchorY)*overviewCache.unit)*ratio; + overviewCanvas.hidden=false;overviewCanvas.style.transform=`translate3d(${left.toFixed(2)}px,${top.toFixed(2)}px,0) scale(${ratio})`; perfCount('overviewTransformUpdates');return true; } function rebuildWorldOverviewCache(width,height,dpr,centerX,centerY){ - if(globalThis.document?.body?.classList?.contains?.('is-interacting'))perfCount('overviewBuildsDuringInteraction'); + if(interactionActive('overview'))perfCount('overviewBuildsDuringInteraction'); const started=perfStart(),unit=UNIT*cam.scale,overscan=Math.max(96,Math.min(OVERVIEW_CACHE_OVERSCAN_PX,Math.max(width,height)*.18)),baseWidth=width+overscan*2,baseHeight=height+overscan*2, pixelWidth=Math.max(1,Math.round(baseWidth*dpr)),pixelHeight=Math.max(1,Math.round(baseHeight*dpr)); if(overviewBase.width!==pixelWidth||overviewBase.height!==pixelHeight){overviewBase.width=pixelWidth;overviewBase.height=pixelHeight} const context=overviewBase.getContext('2d',{alpha:false});context.setTransform(dpr,0,0,dpr,0,0);context.fillStyle='#101315';context.fillRect(0,0,baseWidth,baseHeight); const halfX=baseWidth/(2*unit),halfY=baseHeight/(2*unit),bounds={minX:Math.floor(centerX-halfX)-1,maxX:Math.ceil(centerX+halfX)+1,minY:Math.floor(centerY-halfY)-1,maxY:Math.ceil(centerY+halfY)+1},ids=visibleMetaIdsForBounds(bounds),visibleMetas=[...ids].map(id=>data.metas[id]).filter(Boolean), mapX=value=>baseWidth/2+(value-centerX)*unit,mapY=value=>baseHeight/2+(value-centerY)*unit; - drawMapBoardCells(context,visibleMetas,mapX,mapY,unit,{showLevels:true});const longSegments=drawMapLongLines(context,visibleMetas,ids,mapX,mapY,Math.max(1,cam.scale*1.5)); - overviewCache={revision:minimapWorldRevision,width,height,dpr,scale:cam.scale,anchorX:centerX,anchorY:centerY,unit,overscan,baseWidth,baseHeight,longSegments,boardCount:ids.size}; - perfCount('overviewCacheBuilds');perfGauge('overviewBoards',ids.size);perfGauge('overviewPaths',longSegments);perfEnd('rebuildWorldOverviewCache',started); + drawMapBoardCells(context,visibleMetas,mapX,mapY,unit,{showLevels:true});const longSegments=drawMapLongLines(context,visibleMetas,ids,mapX,mapY,Math.max(1,cam.scale*1.5)),storeCount=drawMapStores(context,visibleMetas,mapX,mapY,unit); + overviewCache={revision:minimapWorldRevision,width,height,dpr,scale:cam.scale,anchorX:centerX,anchorY:centerY,unit,overscan,baseWidth,baseHeight,longSegments,storeCount,boardCount:ids.size}; + perfCount('overviewCacheBuilds');perfGauge('overviewBoards',ids.size);perfGauge('overviewPaths',longSegments);perfGauge('overviewShops',storeCount);perfEnd('rebuildWorldOverviewCache',started); } -function drawWorldOverview(){ +function drawWorldOverview({allowDuringInteraction=false}={}){ const started=perfStart();if(!overviewCanvas||!inWorldOverview())return; - const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),dpr=1,[centerX,centerY]=cameraCenterInChunks(); - const stale=!overviewCache||overviewCache.revision!==minimapWorldRevision||overviewCache.width!==width||overviewCache.height!==height||overviewCache.dpr!==dpr||Math.abs(overviewCache.scale-cam.scale)>1e-6|| - Math.abs(centerX-overviewCache.anchorX)*overviewCache.unit>overviewCache.overscan*.82||Math.abs(centerY-overviewCache.anchorY)*overviewCache.unit>overviewCache.overscan*.82; + const interacting=interactionActive('overview');if(interacting&&!allowDuringInteraction)return; + const rect=getViewportRect(),width=Math.max(1,Math.round(rect.width)),height=Math.max(1,Math.round(rect.height)),dpr=1,[centerX,centerY]=cameraCenterInChunks(),scaleRatio=overviewCache?cam.scale/Math.max(MIN_CAMERA_SCALE,overviewCache.scale):1; + const stale=!overviewCache||overviewCache.revision!==minimapWorldRevision||overviewCache.width!==width||overviewCache.height!==height||overviewCache.dpr!==dpr||(!interacting&&Math.abs(overviewCache.scale-cam.scale)>1e-6)|| + Math.abs(centerX-overviewCache.anchorX)*overviewCache.unit*Math.max(.01,scaleRatio)>overviewCache.overscan*.82||Math.abs(centerY-overviewCache.anchorY)*overviewCache.unit*Math.max(.01,scaleRatio)>overviewCache.overscan*.82; if(stale){ rebuildWorldOverviewCache(width,height,dpr,centerX,centerY); const bitmapWidth=Math.max(1,Math.round(overviewCache.baseWidth*dpr)),bitmapHeight=Math.max(1,Math.round(overviewCache.baseHeight*dpr)); @@ -4390,11 +4668,7 @@ function drawWorldOverview(){ const context=overviewCanvas.getContext('2d',{alpha:false});context.setTransform(1,0,0,1,0,0);context.drawImage(overviewBase,0,0,bitmapWidth,bitmapHeight); overviewCanvas.style.width=`${overviewCache.baseWidth}px`;overviewCanvas.style.height=`${overviewCache.baseHeight}px`;perfCount('overviewBitmapCopies'); } - overviewCanvas.hidden=false;overviewDirty=false; - // Move the oversized cached bitmap as one compositor layer. Previous builds - // copied the entire viewport canvas on every pan frame, which dominated low- - // end devices even though the map geometry itself was cached. - positionCachedWorldOverview(); + overviewCanvas.hidden=false;overviewDirty=false;positionCachedWorldOverview(); perfGauge('overviewBoards',overviewCache.boardCount);perfGauge('overviewPaths',overviewCache.longSegments);perfGauge('overviewShops',0);perfEnd('drawWorldOverview',started); } async function hydrateVisibleMetas(){ @@ -4405,7 +4679,7 @@ async function hydrateVisibleMetas(){ } let lodFrame=0; let lodIdleHandle=0; -function scheduleLodPass(){if(lodFrame||lodIdleHandle)return;const run=()=>{lodFrame=0;lodIdleHandle=0;ensureBoards()},interacting=globalThis.document?.body?.classList?.contains?.('is-interacting')===true;if(interacting&&typeof requestIdleCallback==='function')lodIdleHandle=requestIdleCallback(run,{timeout:500});else if(interacting)lodIdleHandle=setTimeout(run,180);else lodFrame=requestAnimationFrame(run)} +function scheduleLodPass(){if(lodFrame||lodIdleHandle)return;const run=()=>{lodFrame=0;lodIdleHandle=0;ensureBoards()},interacting=interactionActive('world');if(interacting&&typeof requestIdleCallback==='function')lodIdleHandle=requestIdleCallback(run,{timeout:500});else if(interacting)lodIdleHandle=setTimeout(run,180);else lodFrame=requestAnimationFrame(run)} function desiredInteractiveBoardIds(ids){ const desired=new Set(); for(const id of ids)if(data.metas[id]?.puzzle)desired.add(id); @@ -4413,11 +4687,10 @@ function desiredInteractiveBoardIds(ids){ } function ensureBoards(){ const started=perfStart();perfCount('ensureBoardsRuns'); - if(globalThis.document?.body?.classList?.contains?.('is-interacting')===true){perfCount('lodPassesDeferredDuringInteraction');scheduleLodPass();if(inWorldOverview())scheduleWorldOverview(true);perfEnd('ensureBoards',started);return} + if(interactionActive('world')){perfCount('lodPassesDeferredDuringInteraction');scheduleLodPass();if(inWorldOverview())scheduleWorldOverview(true);perfEnd('ensureBoards',started);return} let changes=0,pending=false; if(inWorldOverview()){ for(const[,b]of rendered)if(b.drawing?.pointerId==null){if(changesdata.metas[id]?.puzzle&&!metaState(id).solved).length; perfGauge('visibleBoardIds',ids.size);perfGauge('visibleUnsolvedBoards',visibleUnsolvedBoards);perfGauge('interactiveBoardBudget',desiredInteractive.size);perfEnd('ensureBoards',started); } function renderAll(){for(const b of rendered.values())renderBoard(b)} -let cam={x:0,y:0,scale:1},pan=null,pinch=null,cameraFrame=0,cameraFrameDelayTimer=0,cameraLastDraw=0,cameraInteractionFrame=0,cameraInteractionDelayTimer=0,cameraInteractionLastDraw=0,pendingCameraInteraction=null,visibilityTimer=0,previousViewportSize=null; +let cam={x:0,y:0,scale:1},pan=null,pinch=null,cameraFrame=0,cameraFrameDelayTimer=0,cameraLastDraw=0,pendingCameraInteraction=null,visibilityTimer=0,previousViewportSize=null; function currentCameraAnchor(){const r=getViewportRect(),scale=Math.max(MIN_CAMERA_SCALE,Math.min(1.8,cam.scale||1));return{centerX:renderOriginX+(r.width/2-cam.x)/(UNIT*scale),centerY:renderOriginY+(r.height/2-cam.y)/(UNIT*scale),scale}} function recordCameraAnchor(){ const next=currentCameraAnchor(),previous=data.cameraAnchor; @@ -4447,12 +4719,12 @@ function rebaseWorldOrigin(){ const r=getViewportRect(),worldX=renderOriginX+(r.width/2-cam.x)/(UNIT*cam.scale),worldY=renderOriginY+(r.height/2-cam.y)/(UNIT*cam.scale); if(Math.abs(worldX-renderOriginX)<2048&&Math.abs(worldY-renderOriginY)<2048)return; const nextX=Math.trunc(worldX),nextY=Math.trunc(worldY),dx=nextX-renderOriginX,dy=nextY-renderOriginY;renderOriginX=nextX;renderOriginY=nextY;cam.x+=dx*UNIT*cam.scale;cam.y+=dy*UNIT*cam.scale; - for(const b of[...rendered.values(),...staticRendered.values()]){b.card.style.left=((b.meta.x-renderOriginX)*UNIT-PAD)+'px';b.card.style.top=((b.meta.y-renderOriginY)*UNIT-PAD)+'px'}overviewDirty=true; + for(const b of rendered.values()){b.card.style.left=((b.meta.x-renderOriginX)*UNIT-PAD)+'px';b.card.style.top=((b.meta.y-renderOriginY)*UNIT-PAD)+'px'}overviewDirty=true; } 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||document.body.classList.contains('is-interacting'));if(!cameraGestureActive)rebaseWorldOrigin();const overview=updateZoomPresentation(); - const paint=timestamp=>{cameraFrame=0;cameraFrameDelayTimer=0;cameraLastDraw=timestamp;markVisualFrame(timestamp);if(overview)positionCachedWorldOverview();else world.style.transform=`translate3d(${cam.x}px,${cam.y}px,0) scale(${cam.scale})`}; + const cameraGestureActive=Boolean(pan||pinch||interactionActive('camera'));if(!cameraGestureActive)rebaseWorldOrigin();const overview=updateZoomPresentation(); + 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); @@ -4484,34 +4766,45 @@ async function centerRandomBoard(){ } function cancelPointerGestures(){ for(const b of rendered.values())if(b.drawing?.pointerId!=null){const pointerId=b.drawing.pointerId;cancelBoardDragFrame(b);safeRelease(b.svg,pointerId);if(discardUnmovedCreatedPath(b)){renderBoard(b);continue}b.drawing.pointerId=null;b.drawing.lastPoint=null;b.drawing.pointerOffset=null;clearDrawingPointerState(b.drawing);changed(b.id);renderBoard(b)} + refreshInteractionState(); +} +function cancelCameraGestures(reason='cancelled'){ + if(pendingCameraInteraction)flushCameraInteraction(); + const pointerIds=new Set(touchPoints.keys());if(pan?.id!=null)pointerIds.add(pan.id); + for(const pointerId of pointerIds){gestureCoordinator.release(pointerId,null,reason);try{if(viewport.hasPointerCapture?.(pointerId))viewport.releasePointerCapture(pointerId)}catch(_){}} + touchPoints.clear();pan=null;pinch=null;viewport.classList.remove('panning'); +} +function cancelMinimapGesture(reason='cancelled'){ + const pointerId=minimapPointerState?.id;if(pointerId==null)return; + minimapPointerState=null;gestureCoordinator.release(pointerId,'minimap',reason);interactionState.clear('minimap'); + try{if(minimapCanvas.hasPointerCapture?.(pointerId))minimapCanvas.releasePointerCapture(pointerId)}catch(_){} +} +function cancelAllPointerInteractions(reason='cancelled'){ + cancelReactionGesture();cancelPointerGestures();cancelCameraGestures(reason);cancelMinimapGesture(reason);gestureCoordinator.cancelAll(reason); + interactionState.clear('reaction');interactionState.clear('minimap');interactionState.clear('wheel');refreshInteractionState(false);scheduleInteractionSettlePresentation(); } function touchPair(){ const points=[...touchPoints.values()];if(points.length<2)return null; const[a,b]=points,center=[(a.x+b.x)/2,(a.y+b.y)/2],distance=Math.hypot(a.x-b.x,a.y-b.y); return{center,distance}; } -function commitCameraInteraction(timestamp=perfNow()){ - const started=perfStart();cameraInteractionFrame=0;cameraInteractionDelayTimer=0;cameraInteractionLastDraw=timestamp;const next=pendingCameraInteraction;pendingCameraInteraction=null;if(!next){perfEnd('commitCameraInteraction',started);return false} +function commitCameraInteraction(next,timestamp=perfNow()){ + const started=perfStart();if(pendingCameraInteraction===next)pendingCameraInteraction=null;if(!next){perfEnd('commitCameraInteraction',started);return false} cam.x=next.x;cam.y=next.y;if(Number.isFinite(next.scale))cam.scale=next.scale;applyCamera(true,timestamp);recordInteractionCommit('camera',timestamp,next.inputAt);perfCount('cameraInteractionFrames');const workDuration=perfEnd('commitCameraInteraction',started);observeInteractionFrame(timestamp,workDuration);return true; } +const cameraInteractionScheduler=FrameSchedulerApi.createFrameScheduler({ + interval:DRAG_FRAME_INTERVAL, + tolerance:INTERACTION_FRAME_TOLERANCE_MS, + watchdogDelay:CAMERA_DISPLAY_WATCHDOG_MS, + now:perfNow, + onWatchdog:()=>perfCount('cameraDisplayWatchdogFrames'), + commit:commitCameraInteraction +}); function queueCameraInteraction(next){ - pendingCameraInteraction=next;if(cameraInteractionFrame||cameraInteractionDelayTimer)return; - const step=timestamp=>{ - if(!cameraInteractionFrame&&!cameraInteractionDelayTimer)return; - if(cameraInteractionLastDraw&×tamp+INTERACTION_FRAME_TOLERANCE_MS{ - cameraInteractionDelayTimer=0; - cameraInteractionFrame=requestAnimationFrame(step); - cameraInteractionDelayTimer=setTimeout(()=>{perfCount('cameraDisplayWatchdogFrames');step(perfNow())},CAMERA_DISPLAY_WATCHDOG_MS); - }; - arm(); + pendingCameraInteraction=next;cameraInteractionScheduler.push(next); } function flushCameraInteraction(){ - if(cameraInteractionFrame){cancelAnimationFrame(cameraInteractionFrame);cameraInteractionFrame=0}if(cameraInteractionDelayTimer){clearTimeout(cameraInteractionDelayTimer);cameraInteractionDelayTimer=0} - const committed=commitCameraInteraction();recordCameraAnchor();return committed; + const committed=cameraInteractionScheduler.flush();recordCameraAnchor();return committed; } function leftFieldPanAllowed(event){ if(event.button!==0)return false; @@ -4521,21 +4814,29 @@ function leftFieldPanAllowed(event){ return card.classList?.contains('solved')||Boolean(id&&data.metas[id]&&metaState(id).solved); } function beginPan(e){ + if(e.target===minimapCanvas||e.target?.closest?.('#minimapCanvas')||gestureCoordinator.owner(e.pointerId)&&!gestureCoordinator.owns(e.pointerId,'pan'))return; + if(e.button===0&&inWorldOverview()){ + const rect=getViewportRect(),worldX=renderOriginX+(e.clientX-rect.left-cam.x)/(UNIT*cam.scale),worldY=renderOriginY+(e.clientY-rect.top-cam.y)/(UNIT*cam.scale),storeMeta=nearestStoreMetaAtWorldPoint(worldX,worldY,18,UNIT*cam.scale); + if(storeMeta){e.preventDefault();e.stopPropagation();playSound('shop');openStoreMeta(storeMeta);return} + } if(e.pointerType==='touch'){ touchPoints.set(e.pointerId,{x:e.clientX,y:e.clientY}); if(touchPoints.size===2){ e.preventDefault();e.stopPropagation();cancelPointerGestures(); + for(const pointerId of touchPoints.keys())gestureCoordinator.claim(pointerId,'pinch',{replace:true}); const pair=touchPair(),r=getViewportRect(),mx=pair.center[0]-r.left,my=pair.center[1]-r.top; pinch={startDistance:Math.max(1,pair.distance),startScale:cam.scale,worldX:(mx-cam.x)/cam.scale,worldY:(my-cam.y)/cam.scale,lastCenter:pair.center,lastAt:performance.now()}; pan=null;viewport.classList.add('panning');refreshInteractionState();return; } - if(touchPoints.size===1&&!e.target.closest?.('.board-svg,.board-static,.board-card button')){ + if(touchPoints.size===1&&!e.target.closest?.('.board-svg,button,a,input,select,textarea,[role="button"]')){ + if(!gestureCoordinator.claim(e.pointerId,'pan')){touchPoints.delete(e.pointerId);return} e.preventDefault();e.stopPropagation();pan={id:e.pointerId,x:e.clientX,y:e.clientY,cx:cam.x,cy:cam.y,lastX:e.clientX,lastY:e.clientY,lastAt:performance.now(),touch:true};viewport.classList.add('panning'); try{viewport.setPointerCapture(e.pointerId)}catch(_){}refreshInteractionState(); } return; } if(e.button!==2&&!leftFieldPanAllowed(e))return; + if(!gestureCoordinator.claim(e.pointerId,'pan'))return; e.preventDefault();e.stopPropagation();pan={id:e.pointerId,x:e.clientX,y:e.clientY,cx:cam.x,cy:cam.y,lastX:e.clientX,lastY:e.clientY,lastAt:performance.now(),touch:false,button:e.button};viewport.classList.add('panning'); try{viewport.setPointerCapture(e.pointerId)}catch(_){}refreshInteractionState(); } @@ -4553,8 +4854,8 @@ function movePan(e){ function stopPan(e){ touchPoints.delete(e.pointerId); let ended=false; - if(pinch&&touchPoints.size<2){flushCameraInteraction();pinch=null;ended=true} - if(pan&&pan.id===e.pointerId){flushCameraInteraction();pan=null;ended=true;try{viewport.releasePointerCapture(e.pointerId)}catch(_){}} + if(pinch&&touchPoints.size<2){flushCameraInteraction();pinch=null;ended=true;gestureCoordinator.release(e.pointerId,'pinch');for(const pointerId of touchPoints.keys())gestureCoordinator.release(pointerId,'pinch')} + if(pan&&pan.id===e.pointerId){flushCameraInteraction();pan=null;ended=true;gestureCoordinator.release(e.pointerId,'pan');try{viewport.releasePointerCapture(e.pointerId)}catch(_){}} if(ended){viewport.classList.remove('panning');refreshInteractionState();clearTimeout(visibilityTimer);visibilityTimer=setTimeout(()=>{visibilityTimer=0;rebaseWorldOrigin();ensureBoards();repositionActiveBoardHud();scheduleWorldOverview(true)},60);scheduleMinimap(true);redrawOnlineLayersAfterCamera();scheduleRealtimeViewport(true)} } let tooltipFrame=0,pendingTooltipEvent=null,tooltipSize=null,tooltipText='',tooltipLastProbe=0; @@ -4601,13 +4902,15 @@ viewport.addEventListener('pointerdown',hideSpecialTooltip,true); viewport.addEventListener('pointerdown',beginReactionGesture,true); viewport.addEventListener('pointerdown',beginPan,true); window.addEventListener('pointermove',moveReactionGesture,true); -viewport.addEventListener('pointermove',movePan,true); +window.addEventListener('pointermove',movePan,true); window.addEventListener('pointerup',endReactionGesture,true); -viewport.addEventListener('pointerup',stopPan,true); +window.addEventListener('pointerup',stopPan,true); window.addEventListener('pointercancel',cancelReactionGesture,true); -viewport.addEventListener('pointercancel',stopPan,true); +window.addEventListener('pointercancel',stopPan,true); +window.addEventListener('blur',()=>cancelAllPointerInteractions('blur')); +viewport.addEventListener('lostpointercapture',stopPan,true); viewport.addEventListener('contextmenu',e=>e.preventDefault()); -viewport.addEventListener('wheel',e=>{e.preventDefault();pulseWheelInteraction();zoomAt(e.clientX,e.clientY,cam.scale*Math.exp(-e.deltaY*.0012))},{passive:false}); +viewport.addEventListener('wheel',e=>{e.preventDefault();pulseWheelInteraction();zoomAt(e.clientX,e.clientY,Math.exp(-e.deltaY*.0012),{relative:true,inputAt:Number(e.timeStamp)||perfNow()})},{passive:false}); minimapCanvas.addEventListener('pointerdown',beginMinimapPointer); minimapCanvas.addEventListener('pointermove',moveMinimapPointer); minimapCanvas.addEventListener('pointerup',endMinimapPointer); @@ -4634,7 +4937,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'), @@ -4642,13 +4945,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'), - settingsModal=document.querySelector('#settingsModal'),settingsPanel=settingsModal.querySelector('.settings-panel'),settingsPlayerName=document.querySelector('#settingsPlayerName'),lightweightRenderingToggle=document.querySelector('#lightweightRenderingToggle'),soundEnabledToggle=document.querySelector('#soundEnabledToggle'),saveSettingsBtn=document.querySelector('#saveSettings'),closeSettingsBtn=document.querySelector('#closeSettings'),customEmojiCursor=document.querySelector('#customEmojiCursor'),pickupHandleOverlay=document.querySelector('#pickupHandleOverlay'); + 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); @@ -4670,6 +4972,7 @@ function focusOutsideDialog(root,preferred=null){ if(root.contains(document.activeElement))active.blur?.(); } function openDialogRoot(root){ + cancelAllPointerInteractions('modal');gestureCoordinator.claim(`dialog:${root.id||'root'}`,'dialog',{replace:true});interactionState.set('dialog',root.id||'root'); setDialogInert(root,false); root.setAttribute('aria-hidden','false'); root.classList.add('show'); @@ -4679,99 +4982,182 @@ function closeDialogRoot(root,preferredFocus=null){ root.classList.remove('show'); setDialogInert(root,true); 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';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',()=>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;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){ - const selected=CURSOR_ITEM_BY_STYLE.get(style); - document.body.dataset.cursorStyle=style||'default';document.body.dataset.cursorEmoji=selected?'on':'off'; - let nativeCursor=''; - if(selected?.flagAsset)nativeCursor=`url("${selected.flagAsset}") 16 16, auto`; - else if(selected?.cursorEmoji){ - const svg=`${selected.cursorEmoji}`; - nativeCursor=`url("data:image/svg+xml,${encodeURIComponent(svg)}") 16 16, auto`; - } - let forceDomFollower=false;try{forceDomFollower=localStorage.getItem('bend-field-cursor-renderer')==='dom'}catch(_){} - const nativeSupported=Boolean(selected&&nativeCursor&&!forceDomFollower&&globalThis.CSS?.supports?.('cursor',nativeCursor)); - document.body.dataset.cursorMode=selected?(nativeSupported?'native':'dom'):'default'; - if(nativeSupported)document.body.style.setProperty('--active-native-cursor',nativeCursor);else document.body.style.removeProperty('--active-native-cursor'); + const selected=cursorModel.item(style),presentation=cursorModel.presentation(style); + document.body.dataset.cursorStyle=presentation.style;document.body.dataset.cursorEmoji=selected?'on':'off';document.body.dataset.cursorMode=presentation.mode;document.body.style.removeProperty('--active-native-cursor'); customEmojiCursor.classList.toggle('flag-cursor',Boolean(selected?.flagAsset)); - if(selected?.flagAsset){ - const image=document.createElement('img');image.src=selected.flagAsset;image.alt='';image.draggable=false;customEmojiCursor.replaceChildren(image); - }else customEmojiCursor.textContent=selected?.cursorEmoji||''; - if(!selected||nativeSupported){customEmojiCursor.classList.remove('visible');if(!selected)customEmojiCursor.style.transform=''} + if(selected?.flagAsset){const image=document.createElement('img');image.src=selected.flagAsset;image.alt='';image.draggable=false;customEmojiCursor.replaceChildren(image)}else customEmojiCursor.textContent=selected?.cursorEmoji||''; + if(!selected){customEmojiCursor.classList.remove('visible');customEmojiCursor.style.transform=''} + syncPickupHandleDesign(); } function applyCursorStyle(style){data.cursorStyle=style||'default';syncCursorAppearance(data.cursorStyle);if(realtimePendingCursor){realtimePendingCursor.cursorStyle=data.cursorStyle;realtimeSend({type:'cursor',...realtimePendingCursor})}markGlobalDirty();} -let customCursorFrame=0,customCursorX=0,customCursorY=0,customCursorInputAt=0,customCursorActiveUntil=0,customCursorRenderedX=NaN,customCursorRenderedY=NaN,customCursorLastFrameAt=0,customCursorInputRevision=0,customCursorCommittedRevision=0; -function updateCustomCursorFromPointer(event){ - const customActive=document.body.dataset.cursorMode==='dom',insideDocument=event.target?.isConnected!==false,finePointer=event.pointerType!=='touch'; - if(!customActive||!insideDocument||!finePointer){customCursorRenderedX=customCursorRenderedY=NaN;customCursorLastFrameAt=0;customCursorActiveUntil=0;if(customEmojiCursor.classList.contains('visible'))customEmojiCursor.classList.remove('visible');return} - const wasVisible=customEmojiCursor.classList.contains('visible'); - customCursorX=event.clientX;customCursorY=event.clientY;customCursorInputAt=Number(event.timeStamp)||perfNow();customCursorInputRevision++;customCursorActiveUntil=perfNow()+40; - if(!wasVisible){customCursorRenderedX=customCursorX;customCursorRenderedY=customCursorY;customEmojiCursor.classList.add('visible')} +let customCursorX=0,customCursorY=0,customCursorInputAt=0,customCursorInputRevision=0,customCursorCommittedRevision=0,customCursorFrame=0,customCursorLastDraw=0; +function scheduleCustomCursorFrame(){ if(customCursorFrame)return; - const step=timestamp=>{ - if(!customCursorFrame)return;customCursorFrame=0; - if(document.body.dataset.cursorMode!=='dom'||!customEmojiCursor.classList.contains('visible'))return; - const elapsed=customCursorLastFrameAt?Math.min(50,Math.max(1,timestamp-customCursorLastFrameAt)):16.67,blend=1-Math.exp(-elapsed/8); - if(!Number.isFinite(customCursorRenderedX)){customCursorRenderedX=customCursorX;customCursorRenderedY=customCursorY} - else{customCursorRenderedX+=(customCursorX-customCursorRenderedX)*blend;customCursorRenderedY+=(customCursorY-customCursorRenderedY)*blend} - customCursorLastFrameAt=timestamp; - customEmojiCursor.style.transform=`translate3d(${customCursorRenderedX}px,${customCursorRenderedY}px,0) translate(-50%,-50%)`;markVisualFrame(timestamp); - const freshInput=customCursorCommittedRevision!==customCursorInputRevision;recordInteractionCommit('cursor',timestamp,customCursorInputAt,freshInput);if(freshInput)customCursorCommittedRevision=customCursorInputRevision;perfCount('cursorCommits'); - if(perfNow(){ + if(customCursorLastDraw&×tamp+INTERACTION_FRAME_TOLERANCE_MSsetTimeout(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)}`, + cloudAppBaseUrl + ].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; @@ -4894,21 +5317,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)); @@ -4926,16 +5351,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){ @@ -4958,10 +5388,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(){ @@ -5017,42 +5447,68 @@ function openSettings(){ settingsPlayerName.value=currentPlayerName();lightweightRenderingToggle.checked=uiSettings.lightweightRendering;soundEnabledToggle.checked=uiSettings.soundEnabled; openDialogRoot(settingsModal);settingsBtn?.setAttribute('aria-expanded','true');requestAnimationFrame(()=>{settingsPanel.focus();settingsPlayerName.select()}); } -function closeSettings(restoreFocus=true){closeDialogRoot(settingsModal,restoreFocus?(settingsBtn||playerNameBtn):viewport);settingsBtn?.setAttribute('aria-expanded','false')} -async function saveSettings(){ - const name=settingsPlayerName.value.replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24);if(!name){toast('プレイヤー名を入力してください。');settingsPlayerName.focus();return false} - if(name!==currentPlayerName()){ - if(cloudAvailable&&data.cloudProfile)try{const result=await fetchJson('/api/cloud/profile',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({name})});data.playerName=result.name||name;setCloudStatus('共有中','saved')}catch(error){toast(`名前を変更できませんでした。 ${error.message}`);return false} - else data.playerName=name;markGlobalDirty(false);await persistNow({skipCloud:true});updatePlayerNameUi();renderAll();schedulePresenceRender(true); - } - const previousLightweight=uiSettings.lightweightRendering;uiSettings=normalizeUiSettings({lightweightRendering:lightweightRenderingToggle.checked,soundEnabled:soundEnabledToggle.checked});persistUiSettings();if(previousLightweight!==uiSettings.lightweightRendering){for(const board of rendered.values())board.solvedPathsRendered=false;renderAll();}closeSettings();toast('設定を保存しました。');return true +function closeSettingsDialog(restoreFocus=true){closeDialogRoot(settingsModal,restoreFocus?(settingsBtn||playerNameBtn):viewport);settingsBtn?.setAttribute('aria-expanded','false')} +function resetSettingsForm(){settingsPlayerName.value=createAutomaticPlayerName();lightweightRenderingToggle.checked=false;soundEnabledToggle.checked=true;toast('設定内容を初期値に戻しました。閉じると反映されます。');settingsPlayerName.focus()} +async function saveSettings(restoreFocus=true){ + const name=normalizePlayerNameInput(settingsPlayerName.value);if(!name){toast('プレイヤー名を入力してください。');settingsPlayerName.focus();return false} + const previousName=currentPlayerName(),previousLightweight=uiSettings.lightweightRendering,nameChanged=name!==previousName; + 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('共有プロフィールを更新できませんでした。')} + 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=onlinePlayerEconomy()?Boolean(playerPurchaseForStore(meta.id,item.id)):store.purchases.some(purchase=>purchase.id===item.id),price=storeItemPrice(meta,store,item), + 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; @@ -5060,8 +5516,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} @@ -5077,23 +5534,25 @@ 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} - const already=onlinePlayerEconomy()?playerPurchaseForStore(boardId,itemId):store.purchases.find(purchase=>purchase.id===item.id);if(already){toast('購入済みです。');return false} - const price=storeItemPrice(meta,store,item);if(data.scorepurchase.id===item.id);if(already){toast('購入済みです。');return false} + const price=storeItemPrice(meta,store,item);if(!debug&&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')){ - if(e.key==='Escape'){e.preventDefault();closeSettings();return} - if(e.key==='Enter'&&document.activeElement===settingsPlayerName){e.preventDefault();void saveSettings();return} + if(e.key==='Escape'){e.preventDefault();void closeSettings();return} + if(e.key==='Enter'&&document.activeElement===settingsPlayerName){e.preventDefault();void closeSettings();return} trapDialogFocus(settingsPanel,e);return; } if(inventoryModal.classList.contains('show')){ @@ -5119,13 +5578,12 @@ window.addEventListener('keydown',e=>{ helpBtn.onclick=openHelp; settingsBtn.onclick=openSettings; playerNameBtn.onclick=openSettings; -saveSettingsBtn.onclick=()=>{void saveSettings()}; -closeSettingsBtn.onclick=()=>closeSettings(); -settingsModal.addEventListener('pointerdown',e=>{if(e.target===settingsModal)closeSettings()}); +resetSettingsBtn.onclick=resetSettingsForm; +closeSettingsBtn.onclick=()=>{void closeSettings()}; +settingsModal.addEventListener('pointerdown',e=>{if(e.target===settingsModal)void closeSettings()}); 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;markGlobalDirty();updateHud();void save(true);toast(data.debugAllItems?'デバッグON · 全アイテムを使用できます。':'デバッグOFF · 通常所持数へ戻りました。')}); document.querySelector('#acceptTimeAttackSuggestion').onclick=()=>{dismissTimeAttackSuggestion();openTimeAttack()}; document.querySelector('#dismissTimeAttackSuggestion').onclick=dismissTimeAttackSuggestion; timeAttackBtn.onclick=openTimeAttack; @@ -5155,7 +5613,7 @@ world.addEventListener('click',event=>{ const card=control.closest('.board-card'),board=card?rendered.get(card.dataset.id):null;if(!board)return; event.preventDefault();event.stopPropagation();selectBoard(board); if(control.classList.contains('board-reset')){playSound('reset');void resetSelectedBoard(board)} - else{playSound('click');openStore(board)} + else{playSound('shop');openStore(board)} }); saveStatusEl.onclick=async()=>{if(await flushSave())toast('\u4fdd\u5b58\u3057\u307e\u3057\u305f')}; let activeArchiveController=null,fieldArchiveBusy=false; @@ -5173,7 +5631,7 @@ function portableGlobalForArchive(source=data){ return{ gameplayVersion:GAMEPLAY_DATA_VERSION, bonusEvents:deepClone(source.bonusEvents||{}), - specialMechanicsSeen:[...new Set(source.specialMechanicsSeen||[])].filter(type=>['warp','lock','crossing'].includes(type)).sort(), + specialMechanicsSeen:normalizeSpecialMechanics(source.specialMechanicsSeen), clockFloor:Math.max(0,Number(source.clockFloor)||0), lastSolveAt:Math.max(0,Number(source.lastSolveAt)||0), timeAttack:deepClone(source.timeAttack), @@ -5232,7 +5690,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)); @@ -5245,7 +5703,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})} @@ -5369,7 +5827,7 @@ async function writeStagedBoardBatch(epoch,batch){ function importedGlobalFromPortable(raw,epoch,{nextId,solved,boardScore}){ const result=defaultData();result.worldEpoch=epoch;result.gameplayVersion=GAMEPLAY_DATA_VERSION; result.bonusEvents=normalizeBonusEvents(raw?.bonusEvents);result.bonusScore=bonusEventTotal(result.bonusEvents);result.bonusScoreVersion=SCORE_VERSION; - result.specialMechanicsSeen=[...new Set(raw?.specialMechanicsSeen||[])].filter(type=>['warp','lock','crossing'].includes(type)).sort(); + result.specialMechanicsSeen=normalizeSpecialMechanics(raw?.specialMechanicsSeen); result.clockFloor=Math.max(0,Number(raw?.clockFloor)||0);result.lastSolveAt=Math.max(0,Number(raw?.lastSolveAt)||0); result.timeAttack=normalizeTimeAttackRun(raw?.timeAttack);result.timeAttackCooldowns=normalizeTimeAttackCooldowns(raw?.timeAttackCooldowns);result.lastTimeAttack=normalizeTimeAttackResult(raw?.lastTimeAttack); result.timeAttackSuggestionsDisabled=raw?.timeAttackSuggestionsDisabled===true;result.cursorStyle=typeof raw?.cursorStyle==='string'?raw.cursorStyle.slice(0,32):'default';result.scoreLensEnabled=raw?.scoreLensEnabled===true; @@ -5482,94 +5940,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=[];for(const row of delta.metaRows.filter(Boolean)){const incoming=normalizeMeta(row.id,row);if(!incoming)continue;const current=data.metas[row.id];if(!current||compareRevisionVersions(incoming,current)>0){destroyBoard(rendered.get(row.id));destroyStaticBoard(staticRendered.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];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));destroyStaticBoard(staticRendered.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}} +let cloudAvailable=false,cloudPushTimer=null,cloudPushPending=emptyCloudPending(),cloudSyncing=false,cloudCheckpointRetryTimer=0,worldPollTimer=0,remoteWorldPullTimer=0,remoteWorldRevision=0,lastCloudWorldGlobalSignature=''; +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(),realtimeWebSocketFailures=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,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(0,Number(envelope.sequence));for(const message of Array.isArray(envelope.messages)?envelope.messages:[])handleRealtimeMessage(message);if(envelope.eventGap===true&&realtimeReady)realtimeSend({type:'snapshot-request'});return true} +function scheduleRealtimeHttpPoll(delay=cloudApiUsesPhpBridge()?PHP_REALTIME_POLL_INTERVAL:50){clearTimeout(realtimePollTimer);realtimePollTimer=0;if(realtimeTransport!=='http-poll'||!realtimePresenceId||document.visibilityState==='hidden')return false;realtimePollTimer=setTimeout(()=>{realtimePollTimer=0;void enqueueRealtimeHttp(async()=>{try{const bridge=cloudApiUsesPhpBridge(),query=new URLSearchParams({presenceId:realtimePresenceId,after:String(realtimePollSequence),wait:bridge?'0':'20000'}),result=await fetchJson(`/api/realtime/poll?${query}`,{headers:cloudAuthHeaders()},bridge?5000:25000);applyRealtimeHttpEnvelope(result);scheduleRealtimeHttpPoll(bridge?PHP_REALTIME_POLL_INTERVAL:50)}catch(error){if(error?.status===410){realtimeReady=false;realtimePresenceId=null;realtimeTransport='none'}scheduleRealtimeReconnect(1200)}})},Math.max(25,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} @@ -5577,7 +5986,7 @@ function scheduleRealtimeHeartbeat(){clearTimeout(realtimeHeartbeatTimer);realti function queueRealtimeCursor(event){ if(!realtimeReady||event.pointerType==='touch'||document.body.classList.contains('is-drawing')||!viewport.contains(event.target))return; realtimePendingCursorClient={clientX:event.clientX,clientY:event.clientY,inputAt:Number(event.timeStamp)||perfNow()}; - const wait=REALTIME_CURSOR_INTERVAL-(perfNow()-realtimeLastCursorSentAt);if(realtimeCursorTimer)return; + const transportInterval=realtimeTransport==='http-poll'?(cloudApiUsesPhpBridge()?PHP_REALTIME_CURSOR_INTERVAL:250):REALTIME_CURSOR_INTERVAL,wait=transportInterval-(perfNow()-realtimeLastCursorSentAt);if(realtimeCursorTimer)return; realtimeCursorTimer=setTimeout(()=>{ realtimeCursorTimer=0;const client=realtimePendingCursorClient;if(!client||!realtimeReady)return; const point=worldUnitAtClient(client.clientX,client.clientY),previous=realtimePendingCursor,now=perfNow(),elapsed=Math.max(1,now-(realtimeLastCursorSampleAt||now)), @@ -5596,7 +6005,7 @@ function remoteCursorImage(item){ function roundedCanvasRect(context,x,y,width,height,radius){const r=Math.max(0,Math.min(radius,width/2,height/2));context.beginPath();context.moveTo(x+r,y);context.arcTo(x+width,y,x+width,y+height,r);context.arcTo(x+width,y+height,x,y+height,r);context.arcTo(x,y+height,x,y,r);context.arcTo(x,y,x+width,y,r);context.closePath()} function drawDefaultRemoteCursor(context,x,y){context.save();context.translate(x,y);context.beginPath();context.moveTo(-6,-10);context.lineTo(9,1);context.lineTo(2,3);context.lineTo(6,11);context.lineTo(1,13);context.lineTo(-3,5);context.lineTo(-8,10);context.closePath();context.lineJoin='round';context.lineWidth=4;context.strokeStyle='rgba(8,12,15,.95)';context.stroke();context.lineWidth=2;context.strokeStyle='#fff';context.stroke();context.fillStyle='#d9f06f';context.fill();context.restore()} function drawRemoteCursorGlyph(context,player,x,y){ - const item=CURSOR_ITEM_BY_STYLE.get(player.cursorStyle); + const item=cursorModel.item(player.cursorStyle); if(!item){drawDefaultRemoteCursor(context,x,y);return} if(item.flagAsset){const entry=remoteCursorImageCache.get(item.flagAsset),image=remoteCursorImage(item);if(entry?.ready){context.save();context.translate(x,y);roundedCanvasRect(context,-15,-11,30,22,4);context.clip();context.drawImage(image,-17,-13,34,26);context.restore();context.strokeStyle='rgba(255,255,255,.9)';context.lineWidth=1.5;roundedCanvasRect(context,x-15,y-11,30,22,4);context.stroke()}else drawDefaultRemoteCursor(context,x,y);return} context.save();context.font='25px "Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif';context.textAlign='center';context.textBaseline='middle';context.shadowColor='rgba(0,0,0,.8)';context.shadowBlur=4;context.fillText(item.cursorEmoji||'•',x,y);context.restore(); @@ -5637,85 +6046,379 @@ 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 openReactionRadial(){const gesture=reactionGesture;if(!gesture||gesture.moved||!gestureCoordinator.claim(gesture.pointerId,'reaction'))return;gesture.menuOpen=true;interactionState.set('reaction',gesture.pointerId);gesture.selected=REACTION_EMOJIS.indexOf(data.lastReaction);if(gesture.selected<0)gesture.selected=0;if(pan?.id===gesture.pointerId){pan=null;viewport.classList.remove('panning');refreshInteractionState()}reactionRadial.replaceChildren();REACTION_EMOJIS.forEach((emoji,index)=>{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(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;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)try{if(viewport.hasPointerCapture?.(pointerId))viewport.releasePointerCapture(pointerId)}catch(_){}hideReactionRadial()} +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 boardClaimedByOther(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));board.card.classList.toggle('hud-current',hudVisible);if(hudVisible)positionBoardLabel(board);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]:[...new Set([...rendered.keys(),...staticRendered.keys()])];for(const id of ids){const board=rendered.get(id);if(board)applyClaimPresentationToBoard(board);const staticBoard=staticRendered.get(id),claim=currentBoardClaim(id),own=claim?.playerId===currentPlayerId(),solved=metaState(id).solved;if(staticBoard){staticBoard.card.classList.toggle('claimed-other',Boolean(claim&&!own&&!solved));staticBoard.card.classList.toggle('claimed-own',Boolean(claim&&own&&!solved));staticBoard.card.dataset.claimLabel=claim&&!solved?(own?'占有中':`${claim.playerName||'他のプレイヤー'}がプレイ中`):'';staticBoard.card.title=claim&&!own?`${claim.playerName||'他のプレイヤー'}がプレイ中`:''}} + 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 preserveActiveDrawingClaim(boardId,claim,reason){ + const board=rendered.get(boardId),pointerId=board?.drawing?.pointerId;if(reason==='cleared'||claim?.playerId!==currentPlayerId()||pointerId==null||!realtimeHeldPointers.has(pointerId))return false; + claim.expiresAt=Math.max(Number(claim.expiresAt)||0,trustedNow()+30000);boardClaims.set(boardId,claim);realtimeOwnClaimBoardId=boardId;refreshClaimPresentation(boardId); + void requestBoardClaim(boardId,{force:true}).then(ok=>{if(!ok&&board?.drawing?.pointerId===pointerId)console.warn(`LinkField: board claim recovery is pending for ${boardId}`)});return true; +} +function removeClaim(boardId,reason='released'){const claim=boardClaims.get(boardId);if(preserveActiveDrawingClaim(boardId,claim,reason))return false;boardClaims.delete(boardId);if(realtimeOwnClaimBoardId===boardId)realtimeOwnClaimBoardId=null;refreshClaimPresentation(boardId);return true} 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 applyRealtimeWorldDelta(message){ + const revision=Math.max(0,Number(message?.revision)||0),page=message?.page;if(!revision||revision<=(data.cloudRevision||0)||!page||!isPlainObject(page.metas)||!isPlainObject(page.states))return false; + const external={...(page.global||{}),worldEpoch:data.worldEpoch,metas:{},states:{}}; + for(const[id,raw]of Object.entries(page.metas)){const meta=normalizeMeta(id,raw);if(meta)external.metas[id]=meta} + for(const[id,raw]of Object.entries(page.states)){const board=rendered.get(id),locallyOwned=boardClaimOwnedByMe(id)||board?.drawing?.pointerId!=null||board?.drawing?.keyboardActive;if(!locallyOwned)external.states[id]=normalizeState(raw)} + cloudApplyingRemote=true;try{mergeSnapshotIntoData(external,{finalize:false,authoritativeWorld:false});for(const id of Object.keys(external.metas))dirtyMetaIds.add(id);for(const id of Object.keys(external.states))dirtyStateIds.add(id);resolveMergedOverlaps();data.cloudRevision=revision;remoteWorldRevision=Math.max(remoteWorldRevision,revision);statsDirty=true;markGlobalDirty(false)}finally{cloudApplyingRemote=false} + void refreshWorldView({rebuild:true,syncConnections:true,persist:false});void persistNow({skipCloud:true}).then(ok=>{if(!ok)scheduleCloudCheckpointRetry()});return 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;if(realtimeTransport==='websocket')realtimeWebSocketFailures=0;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();if(realtimeOwnClaimBoardId&&rendered.get(realtimeOwnClaimBoardId)?.drawing?.pointerId!=null)void requestBoardClaim(realtimeOwnClaimBoardId,{force:true});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} if(message.type==='claim')return applyClaim(message.claim); - if(message.type==='claim-release')return removeClaim(message.boardId,message.reason); + if(message.type==='claim-release'){removeClaim(message.boardId,message.reason);if(message.reason!=='moved')void pullCloudWorld();return} if(message.type==='claim-result')return settleRealtimeClaimResult(message); if(message.type==='reaction')return applyRealtimeReaction(message.reaction); if(message.type==='player-profile'){for(const player of remotePlayers.values())if(player.playerId===message.playerId)player.name=String(message.name||player.name).slice(0,24);for(const claim of boardClaims.values())if(claim.playerId===message.playerId)claim.playerName=String(message.name||claim.playerName).slice(0,24);refreshClaimPresentation();schedulePresenceRender(true);return} if(message.type==='board-cleared'&&message.event){const revision=Number(message.event.revision)||0;if(revision>(data.worldFeedRevision||0)){data.worldFeedRevision=revision;markGlobalDirty(false);addClearFeedEvent(message.event)}removeClaim(message.event.id,'cleared');void pullCloudWorld();} + if(message.type==='world-revision'){if(!applyRealtimeWorldDelta(message))scheduleRemoteWorldPull(Number(message.revision)||0);return} } 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'||realtimeWebSocketFailures>=2)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;realtimeWebSocketFailures++;resetRealtimeConnectionState();scheduleRealtimeReconnect(realtimeWebSocketFailures>=2?100:1500)};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,{allowLocalSolved=false,force=false}={}){ + if(!allowLocalSolved&&metaState(boardId)?.solved||!cloudAvailable||!data.cloudProfile)return false; + const existing=currentBoardClaim(boardId),now=trustedNow(); + if(existing?.playerId!==currentPlayerId()&&existing?.expiresAt>now)return false; + if(!force&&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}); -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:[...new Set(data.specialMechanicsSeen||[])].filter(type=>['warp','lock','crossing'].includes(type)).sort(),quarantine:data.quarantine||{}}} +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; } -function takeCloudPendingBatch(source,limit=512){ - const batch=emptyCloudPending(),remainder=emptyCloudPending(),maximum=Math.max(1,Math.floor(limit)||1);let used=0; +function takeCloudPendingBatch(source,limit=16,preferredBoardId=null){ + const batch=emptyCloudPending(),remainder=emptyCloudPending(),maximum=Math.max(1,Math.floor(limit)||1); batch.globalChanged=source.globalChanged===true; - for(const key of['metaIds','stateIds','deleted'])for(const id of source[key]||[])(used0||pending.stateIds.size>0||pending.deleted.size>0} +function cloudMutationId(payload,changeSeq){const signature=JSON.stringify(payload);let value=2166136261;for(let index=0;index>>0).toString(16).padStart(8,'0'),owner=String(sessionId||'client').replace(/[^A-Za-z0-9_-]/g,'').slice(0,40)||'client';return`${owner}-${Math.max(0,Number(changeSeq)||0).toString(36)}-${hash}`} function cloudProfileIdentity(profile=data.cloudProfile){return profile?`${profile.playerId}.${profile.token}`:''} function scheduleCloudCheckpointRetry(){if(cloudCheckpointRetryTimer)return;cloudCheckpointRetryTimer=setTimeout(()=>{cloudCheckpointRetryTimer=0;if(hasPendingPersistence())void persistNow({skipCloud:true}).then(ok=>{if(!ok)scheduleCloudCheckpointRetry()})},1000)} function restoreCloudPushPending(){cloudPushPending=emptyCloudPending();mergeCloudPending(cloudPushPending,currentCloudPending())} @@ -5727,26 +6430,47 @@ 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}`}:{}} -function applyPlayerEconomyEnvelope(raw){const player=raw?.player||raw;if(!player||!Array.isArray(player.purchases))return false;data.playerPurchases=normalizePlayerPurchases(player.purchases);data.playerEarnedScore=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} +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;cloudApiPhpBridgeActive=response.headers.get('x-linkfield-bridge')==='php'||base===cloudApiBridgeUrl;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)} + 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); + if(result?.clearEventsGap===true&&!initial){console.warn('LinkField: shared clear-event history gap; state was resynchronized from the authoritative snapshot');toast('\u5171\u6709\u5c65\u6b74\u306e\u4e00\u90e8\u3092\u518d\u540c\u671f\u3057\u307e\u3057\u305f\u3002',3000)} 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(document.body?.classList?.contains('is-interacting')||[...rendered.values()].some(board=>board.drawing?.keyboardActive))return false;if(hasPendingPersistence()&&!await flushSave())return false; - const profileIdentity=cloudProfileIdentity();cloudSyncing=true;setCloudStatus('共有取得中','syncing');let pulled=false; + if(interactionActive()||[...rendered.values()].some(board=>board.drawing?.keyboardActive))return false;if(hasPendingPersistence()&&!await flushSave())return false; + const profileIdentity=cloudProfileIdentity();cloudSyncing=true;setCloudStatus('共有取得中','syncing');let pulled=false,reopenedAfterPull=0; try{ const previousRevision=sinceOverride??data.cloudRevision??0,remoteIds=new Set();let cursor=0,targetRevision=null,changed=false,fullSnapshot=false,firstPage=true,envelopeChanged=false,authoritativeWorld=authoritative===true; do{ @@ -5763,51 +6487,71 @@ async function pullCloudWorld(force=false,sinceOverride=null,{initial=false,auth cursor=Number.isSafeInteger(result.nextCursor)?result.nextCursor:0; }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));destroyStaticBoard(staticRendered.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)} + 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}reopenedAfterPull=reopenMissingGateExpansions();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();pulled=true;return true; + }catch(error){setCloudStatus();console.warn('BEND FIELD: shared-world pull failed',error);return false} + finally{cloudSyncing=false;setCloudStatus();restoreCloudPushPending();if(reopenedAfterPull)scheduleExpansionRepair(150);if(cloudPendingHasWork())armCloudPush(pulled?250:5000)} } -function scheduleCloudPush(signal={},delay=5000){mergeCloudPending(cloudPushPending,signal);armCloudPush(delay)} -async function pushCloudPending(){ +function scheduleCloudPush(signal={},delay=120){mergeCloudPending(cloudPushPending,signal);armCloudPush(delay)} +async function pushCloudPending(preferredBoardId=null){ 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; + const{batch:pending,remainder}=takeCloudPendingBatch(cloudPushPending,16,preferredBoardId),profileIdentity=cloudProfileIdentity();cloudPushPending=remainder; const globalSignature=sharedWorldGlobalSignature();if(!pending.metaIds.size&&!pending.stateIds.size&&!pending.deleted.size&&pending.globalChanged&&globalSignature===lastCloudWorldGlobalSignature){cloudJournalGlobalChanged=false;cloudOutboxDeleteKeys.add('global');data.cloudPending=currentCloudPending();restoreCloudPushPending();if(!await persistNow({skipCloud:true})){scheduleCloudCheckpointRetry();return false}return true} 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 rows=await cloudRowsForStorage(pending.metaIds,pending.stateIds);payload={baseRevision:data.cloudRevision||0,global:sharedWorldGlobalForCloud(),metas:rows.metas,states:rows.states,deleted:[]};payload.mutationId=cloudMutationId(payload,sentCloudChangeSeq);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();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 boardId=error.body?.boardId||payload.states?.[0]?.id,claimed=boardId?await requestBoardClaim(boardId,{allowLocalSolved:true}):false;if(!claimed){const pulled=await pullCloudWorld(true,0,{authoritative:true});retryDelay=pulled?500:5000}else retryDelay=0;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'?30000:120000){clearTimeout(worldPollTimer);if(!cloudAvailable||!data.cloudProfile)return;worldPollTimer=setTimeout(async()=>{worldPollTimer=0;if(document.visibilityState==='visible')await pullCloudWorld();scheduleWorldPoll()},Math.max(5000,delay))} +function scheduleRemoteWorldPull(revision,delay=60){ + remoteWorldRevision=Math.max(remoteWorldRevision,Math.max(0,Number(revision)||0));if(remoteWorldRevision<=(data.cloudRevision||0))return false; + clearTimeout(remoteWorldPullTimer);remoteWorldPullTimer=setTimeout(async()=>{remoteWorldPullTimer=0;if(remoteWorldRevision<=(data.cloudRevision||0))return;const pulled=await pullCloudWorld();if(!pulled&&remoteWorldRevision>(data.cloudRevision||0))scheduleRemoteWorldPull(remoteWorldRevision,500)},Math.max(0,delay));return true; +} +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; - const name=entered.replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24);if(!name){toast('プレイヤー名を入力してください。');return} - if(cloudAvailable&&data.cloudProfile){ - try{const result=await fetchJson('/api/cloud/profile',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({name})});data.playerName=result.name||name;setCloudStatus('共有中','saved')} - catch(error){toast(`名前を変更できませんでした。 ${error.message}`);return} - }else data.playerName=name; - markGlobalDirty(false);await persistNow({skipCloud:true});updatePlayerNameUi();renderAll();schedulePresenceRender(true);toast(`プレイヤー名を「${data.playerName}」に変更しました。`) + try{const name=await commitPlayerProfileName(entered);toast(`プレイヤー名を「${name}」に変更しました。`)} + 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} - const name=entered.replace(/[\u0000-\u001f\u007f]/g,'').replace(/\s+/g,' ').trim().slice(0,24);if(!name){toast('プレイヤー名を入力してください。');return} - try{const result=await fetchJson('/api/cloud/profile',{method:'POST',headers:cloudAuthHeaders(),body:JSON.stringify({name})});data.playerName=result.name;markGlobalDirty(false);await persistNow({skipCloud:true});setCloudStatus('共有中','saved');toast(`プレイヤー名を「${result.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); @@ -5816,18 +6560,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); @@ -5838,10 +6577,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); @@ -5852,12 +6590,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/archive-codec.js b/archive-codec.js new file mode 100644 index 0000000..8aabab2 --- /dev/null +++ b/archive-codec.js @@ -0,0 +1,19 @@ +'use strict'; +(function attachArchiveCodec(root,factory){ + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api; + if(root)root.BendArchiveCodec=api; +})(typeof globalThis!=='undefined'?globalThis:this,()=>{ + const encoder=new TextEncoder(),crcTable=new Uint32Array(256); + for(let index=0;index<256;index++){let value=index;for(let bit=0;bit<8;bit++)value=value&1?0xedb88320^(value>>>1):value>>>1;crcTable[index]=value>>>0} + function encodeUtf8(value){return encoder.encode(String(value))} + function encodeRecord(record){return encodeUtf8(`${JSON.stringify(record)}\n`)} + function crc32Update(crc,bytes){let value=crc>>>0;for(const byte of bytes)value=crcTable[(value^byte)&255]^(value>>>8);return value>>>0} + function crc32Hex(crc){return((crc^0xffffffff)>>>0).toString(16).padStart(8,'0')} + function parseRecordLine(sourceLine){ + const line=String(sourceLine).replace(/\r$/,'');let record; + try{record=JSON.parse(line)}catch(_){throw new Error('An archive record is not valid JSON.')} + return{line,record,bytes:encodeUtf8(`${line}\n`)}; + } + return Object.freeze({encodeUtf8,encodeRecord,crc32Update,crc32Hex,parseRecordLine}); +}); diff --git a/build-config.json b/build-config.json new file mode 100644 index 0000000..3e47373 --- /dev/null +++ b/build-config.json @@ -0,0 +1,9 @@ +{ + "SAVE_SCHEMA": 31, + "STORAGE_SCHEMA": 30, + "IDB_LAYOUT_VERSION": 8, + "FIELD_STORAGE_FORMAT": 2, + "GAMEPLAY_DATA_VERSION": 3, + "GENERATOR_VERSION": 5, + "WORLD_GENERATION": "linkfield-single-world-20260801" +} diff --git a/build-meta.js b/build-meta.js new file mode 100644 index 0000000..6676cf4 --- /dev/null +++ b/build-meta.js @@ -0,0 +1,17 @@ +'use strict'; +// Generated from package.json and build-config.json. Do not edit. +(function attachBuildMeta(root,factory){ + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api; + if(root)root.BendBuildMeta=api; +})(typeof globalThis!=='undefined'?globalThis:this,()=>Object.freeze({ + "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": "linkfield-single-world-20260801" +})); diff --git a/client/input/drag.js b/client/input/drag.js new file mode 100644 index 0000000..c190b8a --- /dev/null +++ b/client/input/drag.js @@ -0,0 +1,73 @@ +(function(root,factory){ + const api=factory(root.BendFrameScheduler); + if(typeof module==='object'&&module.exports)module.exports=factory(require('./frame-scheduler.js')); + root.BendDragScheduler=api; +})(typeof globalThis!=='undefined'?globalThis:this,function(FrameScheduler){ + 'use strict'; + + const STATES=Object.freeze(['idle','armed','running','draining','settling','cancelled']); + + function createDragScheduler(options={}){ + if(!FrameScheduler?.createFrameScheduler)throw new TypeError('BendFrameScheduler is required'); + if(typeof options.onFrame!=='function')throw new TypeError('Drag onFrame callback is required'); + const maxSamples=Math.max(2,Number(options.maxSamples)||12); + const trim=typeof options.trim==='function'?options.trim:samples=>samples.splice(0,Math.max(0,samples.length-maxSamples)); + let state='idle',pointerId=null,latest=null,logical=[],revision=0,visualRevision=0,logicalRevision=0; + const frame=FrameScheduler.createFrameScheduler({ + interval:options.interval, + tolerance:options.tolerance, + watchdogDelay:options.watchdogDelay, + requestFrame:options.requestFrame, + cancelFrame:options.cancelFrame, + setDelay:options.setDelay, + clearDelay:options.clearDelay, + now:options.now, + onWatchdog:options.onWatchdog, + commit:(_signal,timestamp)=>options.onFrame(api,timestamp) + }); + const assertState=next=>{if(!STATES.includes(next))throw new TypeError(`Unknown drag state: ${next}`);state=next}; + const clearData=()=>{pointerId=null;latest=null;logical.length=0;revision=0;visualRevision=0;logicalRevision=0}; + const arm=id=>{ + if(id==null)throw new TypeError('Drag pointerId is required'); + frame.cancel({resetCadence:true});clearData();pointerId=id;assertState('armed');return api; + }; + const push=(sample,{schedule=true}={})=>{ + if(!sample||sample.pointerId==null)return false; + if(state==='idle'||state==='cancelled'||state==='settling')arm(sample.pointerId); + if(sample.pointerId!==pointerId||state==='draining')return false; + latest=sample;logical.push(sample);trim(logical,maxSamples);revision++; + if(state==='armed')assertState('running'); + if(schedule)frame.push(revision); + return revision; + }; + const requestFrame=()=>{if(latest&&state!=='idle'&&state!=='cancelled')frame.push(revision)}; + const beginDrain=sample=>{ + if(sample)push(sample,{schedule:false}); + if(state==='idle'||state==='cancelled')return false; + frame.cancel();assertState('draining');return true; + }; + const beginSettling=()=>{if(state==='draining'||state==='running'||state==='armed')assertState('settling');frame.cancel();return state==='settling'}; + const settle=()=>{frame.cancel({resetCadence:true});clearData();assertState('idle')}; + const cancel=()=>{frame.cancel({resetCadence:true});clearData();assertState('cancelled')}; + const takeLogical=max=>{ + const count=Math.max(0,Math.min(logical.length,Number(max)||1)); + return logical.splice(0,count); + }; + const drainLogical=()=>logical.splice(0); + const markVisual=()=>{visualRevision=revision}; + const markLogical=()=>{if(!logical.length)logicalRevision=revision}; + const inspect=()=>Object.freeze({ + state,pointerId,latest,logicalCount:logical.length,revision,visualRevision,logicalRevision, + freshVisual:visualRevision!==revision,freshLogical:logicalRevision!==revision + }); + const api=Object.freeze({ + arm,push,requestFrame,beginDrain,beginSettling,settle,cancel,takeLogical,drainLogical, + latest:()=>latest, + hasLogical:()=>logical.length>0, + markVisual,markLogical,inspect + }); + return api; + } + + return Object.freeze({STATES,createDragScheduler}); +}); diff --git a/client/input/frame-scheduler.js b/client/input/frame-scheduler.js new file mode 100644 index 0000000..a61a31b --- /dev/null +++ b/client/input/frame-scheduler.js @@ -0,0 +1,85 @@ +(function(root,factory){ + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api; + root.BendFrameScheduler=api; +})(typeof globalThis!=='undefined'?globalThis:this,function(){ + 'use strict'; + + const DEFAULT_INTERVAL=1000/60; + + function createFrameScheduler(options={}){ + const requestFrame=options.requestFrame||globalThis.requestAnimationFrame?.bind(globalThis); + const cancelFrame=options.cancelFrame||globalThis.cancelAnimationFrame?.bind(globalThis); + const setDelay=options.setDelay||globalThis.setTimeout?.bind(globalThis); + const clearDelay=options.clearDelay||globalThis.clearTimeout?.bind(globalThis); + const now=options.now||(()=>globalThis.performance?.now?.()||Date.now()); + const interval=Math.max(1,Number(options.interval)||DEFAULT_INTERVAL); + const tolerance=Math.max(0,Number(options.tolerance)||0); + const watchdogDelay=Math.max(interval,Number(options.watchdogDelay)||interval+2); + const commit=options.commit; + const validTiming=typeof requestFrame==='function'&&typeof cancelFrame==='function' + &&typeof setDelay==='function'&&typeof clearDelay==='function'; + if(!validTiming)throw new TypeError('Frame scheduler timing functions are required'); + if(typeof commit!=='function')throw new TypeError('Frame scheduler commit callback is required'); + + let frame=0,timer=0,lastCommitAt=0,latest=null,revision=0,committedRevision=0,disposed=false; + const clearArmed=()=>{ + if(frame)cancelFrame(frame); + if(timer)clearDelay(timer); + frame=0;timer=0; + }; + const arm=()=>{ + if(disposed||frame||timer||latest==null)return false; + const step=timestamp=>{ + if(disposed||latest==null){clearArmed();return false} + const at=Number.isFinite(timestamp)?timestamp:now(); + if(lastCommitAt&&at+tolerance{ + timer=0; + if(typeof options.onWatchdog==='function')options.onWatchdog(); + step(now()); + },watchdogDelay); + return true; + }; + const push=value=>{ + if(disposed)return false; + latest=value;revision++;arm();return revision; + }; + const flush=()=>{ + if(disposed||latest==null)return false; + clearArmed(); + const value=latest,currentRevision=revision,at=now(); + latest=null;lastCommitAt=at;committedRevision=currentRevision; + commit(value,at,currentRevision); + if(latest!=null)arm(); + return true; + }; + const cancel=({resetCadence=false}={})=>{ + clearArmed();latest=null; + if(resetCadence)lastCommitAt=0; + }; + const dispose=()=>{cancel({resetCadence:true});disposed=true}; + const inspect=()=>Object.freeze({ + armed:Boolean(frame||timer), + pending:latest!=null, + revision, + committedRevision, + lastCommitAt + }); + return Object.freeze({push,flush,cancel,dispose,inspect}); + } + + return Object.freeze({DEFAULT_INTERVAL,createFrameScheduler}); +}); diff --git a/client/input/gesture-coordinator.js b/client/input/gesture-coordinator.js new file mode 100644 index 0000000..d39e010 --- /dev/null +++ b/client/input/gesture-coordinator.js @@ -0,0 +1,47 @@ +(function(root,factory){ + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api; + root.BendGestureCoordinator=api; +})(typeof globalThis!=='undefined'?globalThis:this,function(){ + 'use strict'; + + const DEFAULT_PRECEDENCE=Object.freeze({ + wheel:10, + pan:20, + pinch:30, + reaction:40, + minimap:50, + draw:60, + dialog:70 + }); + + function createGestureCoordinator(precedence=DEFAULT_PRECEDENCE){ + const owners=new Map(),listeners=new Set();let revision=0; + const priority=owner=>Number(precedence[owner])||0; + const snapshot=()=>Object.freeze({revision,owners:Object.freeze(Object.fromEntries(owners))}); + const notify=(change)=>{revision++;const state=snapshot();for(const listener of listeners)listener(state,change)}; + const claim=(pointerId,owner,{replace=false}={})=>{ + if(pointerId==null||!owner)return false;const key=String(pointerId),next=String(owner),current=owners.get(key); + if(current===next)return true; + if(current&&!replace&&priority(next)<=priority(current))return false; + owners.set(key,next);notify(Object.freeze({type:current?'transfer':'claim',pointerId:key,owner:next,previous:current||null}));return true; + }; + const release=(pointerId,owner=null,reason='complete')=>{ + if(pointerId==null)return false;const key=String(pointerId),current=owners.get(key); + if(!current||owner!=null&¤t!==String(owner))return false; + owners.delete(key);notify(Object.freeze({type:'release',pointerId:key,owner:current,reason}));return true; + }; + const cancelAll=(reason='cancelled')=>{for(const[key,owner]of[...owners]){owners.delete(key);notify(Object.freeze({type:'release',pointerId:key,owner,reason}))}}; + return Object.freeze({ + claim, + release, + cancelAll, + owner:pointerId=>pointerId==null?null:owners.get(String(pointerId))||null, + owns:(pointerId,owner)=>owners.get(String(pointerId))===String(owner), + snapshot, + subscribe(listener){if(typeof listener!=='function')throw new TypeError('Listener must be a function');listeners.add(listener);return()=>listeners.delete(listener)} + }); + } + + return Object.freeze({DEFAULT_PRECEDENCE,createGestureCoordinator}); +}); diff --git a/client/input/interaction-state.js b/client/input/interaction-state.js new file mode 100644 index 0000000..0d4d989 --- /dev/null +++ b/client/input/interaction-state.js @@ -0,0 +1,52 @@ +(function(root,factory){ + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api; + root.BendInteractionState=api; +})(typeof globalThis!=='undefined'?globalThis:this,function(){ + 'use strict'; + + const DEFAULT_SCOPES=Object.freeze({ + any:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap','dialog']), + pointer:Object.freeze(['camera','drawing','claim','reaction','minimap']), + camera:Object.freeze(['camera','wheel']), + persistence:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap']), + world:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap']), + overview:Object.freeze(['camera','drawing','claim','wheel','reaction','minimap']), + worker:Object.freeze(['drawing','claim']) + }); + + function createInteractionState(scopeDefinitions=DEFAULT_SCOPES){ + const owners=new Map(),listeners=new Set(),scopes=new Map( + Object.entries(scopeDefinitions).map(([name,kinds])=>[name,new Set(kinds)]) + ); + let revision=0; + const snapshot=()=>Object.freeze({ + revision, + activeKinds:Object.freeze([...owners.keys()]), + owners:Object.freeze(Object.fromEntries(owners)) + }); + const notify=()=>{const state=snapshot();for(const listener of listeners)listener(state)}; + const set=(kind,owner)=>{ + const key=String(kind||'');if(!key)throw new TypeError('Interaction kind is required'); + const next=owner==null||owner===false?null:String(owner),previous=owners.get(key)||null; + if(previous===next)return false; + if(next==null)owners.delete(key);else owners.set(key,next); + revision++;notify();return true; + }; + const active=(scope='any')=>{ + const kinds=scopes.get(scope);if(!kinds)throw new TypeError(`Unknown interaction scope: ${scope}`); + for(const kind of kinds)if(owners.has(kind))return true; + return false; + }; + return Object.freeze({ + set, + clear:kind=>set(kind,null), + owner:kind=>owners.get(String(kind||''))||null, + active, + snapshot, + subscribe(listener){if(typeof listener!=='function')throw new TypeError('Listener must be a function');listeners.add(listener);return()=>listeners.delete(listener)} + }); + } + + return Object.freeze({DEFAULT_SCOPES,createInteractionState}); +}); diff --git a/client/styles/accessibility.css b/client/styles/accessibility.css new file mode 100644 index 0000000..3959846 --- /dev/null +++ b/client/styles/accessibility.css @@ -0,0 +1,8 @@ +@media(prefers-reduced-motion:reduce){ + *,*::before,*::after{scroll-behavior:auto!important;transition-duration:.001ms!important} + .board-card.revealing,.board-card.completing .path,.completion-burst,.gate-dot.frontier,.tutorial-demo *,.tutorial-demo::after,.num.turn-warning,.number-turn-warning,.pill.time-attack.active>span:first-child,.time-attack-clock.urgent,.completion-flash,.gate-connect-pulse,.number-match-orbit-holes,#viewport::before{animation:none!important} + .completion-burst{opacity:1} + .tutorial-path{stroke-dashoffset:0} + .tutorial-shop-marker,.tutorial-number-pulse{transform:none} + .tutorial-shop-score{opacity:1} +} diff --git a/client/styles/base.css b/client/styles/base.css new file mode 100644 index 0000000..f33b26a --- /dev/null +++ b/client/styles/base.css @@ -0,0 +1,6 @@ +*{box-sizing:border-box;user-select:none;-webkit-user-select:none} +html,body{margin:0;width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--ink);font-family:var(--dot-font);font-variant-ligatures:none;-webkit-font-smoothing:none;text-rendering:geometricPrecision} +body,button,input,select,textarea{font-family:var(--dot-font)} +button,input,select,textarea{font-size:inherit} +button{font:inherit;color:inherit;letter-spacing:.04em} +button:focus-visible,[tabindex]:focus-visible{outline:3px solid #fff;outline-offset:3px} diff --git a/client/styles/tokens.css b/client/styles/tokens.css new file mode 100644 index 0000000..314cd07 --- /dev/null +++ b/client/styles/tokens.css @@ -0,0 +1,17 @@ +@font-face{font-family:"DotGothic16Local";src:url("../../assets/fonts/DotGothic16-Regular.ttf") format("truetype");font-style:normal;font-weight:400;font-display:swap} +:root{ + --bg:#121416; + --panel:#1c1f22; + --ink:#f3f4f5; + --muted:#8b949b; + --grid:rgba(198,210,218,.28); + --accent:#d9f06f; + --bad:#ff6e7a; + --dot-font:"DotGothic16Local","DotGothic16","MS Gothic","MS ゴシック",ui-monospace,monospace; + --emoji-font:"Segoe UI Emoji","Apple Color Emoji","Noto Color Emoji",sans-serif; + --bar-height:54px; + --safe-top:env(safe-area-inset-top,0px); + --safe-right:env(safe-area-inset-right,0px); + --safe-bottom:env(safe-area-inset-bottom,0px); + --safe-left:env(safe-area-inset-left,0px) +} diff --git a/client/ui/cursor.css b/client/ui/cursor.css new file mode 100644 index 0000000..6523eac --- /dev/null +++ b/client/ui/cursor.css @@ -0,0 +1,17 @@ +body[data-cursor-mode="dom"], +body[data-cursor-mode="dom"] *{cursor:none!important} +.emoji-glyph,#customEmojiCursor{font-family:var(--emoji-font)!important;font-variant-emoji:emoji} +.item-flag-image{display:block;width:34px;height:26px;object-fit:contain;pointer-events:none} +#customEmojiCursor{position:fixed;z-index:300;left:0;top:0;display:none;width:32px;height:32px;place-items:center;overflow:visible;border:0;border-radius:0;background:transparent;font-size:26px;line-height:1;opacity:1;pointer-events:none;will-change:transform} +#customEmojiCursor.visible{display:grid} +#customEmojiCursor.flag-cursor{width:32px;height:32px;overflow:hidden;border:2px solid #101214;border-radius:50%;background:#f4d45f;font-size:26px;opacity:1;box-shadow:0 2px 7px rgba(0,0,0,.5)} +#customEmojiCursor.flag-cursor img{position:absolute;left:50%;top:50%;display:block;width:140%;height:110%;margin:0;transform:translate(-50%,-50%);border-radius:0;object-fit:cover;object-position:center;clip-path:none} +body.is-drawing #viewport, +body.is-drawing #viewport *{cursor:none!important} +body.is-drawing #customEmojiCursor{display:none!important} +#pickupHandleOverlay{position:fixed;z-index:299;left:0;top:0;display:none;width:15px;height:15px;border:2px solid #111820;border-radius:50%;background:var(--pickup-color,#f4d45f);box-shadow:0 0 0 2px rgba(255,255,255,.82),0 2px 7px rgba(0,0,0,.48);pointer-events:none;will-change:transform;contain:strict} +#pickupHandleOverlay.visible{display:block} +#pickupHandleOverlay.custom-cursor{width:32px;height:32px;place-items:center;border:0;border-radius:0;background:transparent;box-shadow:none;font:26px/1 var(--emoji-font);overflow:visible} +#pickupHandleOverlay.custom-cursor.visible{display:grid} +#pickupHandleOverlay.custom-cursor.flag-cursor{overflow:hidden;border:2px solid #101214;border-radius:50%;background:var(--pickup-color,#f4d45f);box-shadow:0 2px 7px rgba(0,0,0,.5)} +#pickupHandleOverlay.custom-cursor.flag-cursor img{position:absolute;left:50%;top:50%;display:block;width:140%;height:110%;transform:translate(-50%,-50%);object-fit:cover;object-position:center;pointer-events:none} diff --git a/client/ui/cursor.js b/client/ui/cursor.js new file mode 100644 index 0000000..20aac42 --- /dev/null +++ b/client/ui/cursor.js @@ -0,0 +1,30 @@ +(function(root,factory){ + const api=factory(); + if(typeof module==='object'&&module.exports)module.exports=api; + root.BendCursorModel=api; +})(typeof globalThis!=='undefined'?globalThis:this,function(){ + 'use strict'; + + function createCursorModel(items=[]){ + const byStyle=new Map(); + for(const item of items)if(item?.cursorStyle)byStyle.set(item.cursorStyle,Object.freeze({...item})); + const item=style=>byStyle.get(String(style||''))||null; + const presentation=style=>{ + const selected=item(style); + if(!selected)return Object.freeze({style:'default',mode:'default',asset:null,glyph:'',flag:false,hotspot:Object.freeze([0,0]),pickup:Object.freeze({kind:'default'})}); + const flag=Boolean(selected.flagAsset),asset=selected.flagAsset||null,glyph=flag?'':selected.cursorEmoji||''; + return Object.freeze({ + style:selected.cursorStyle, + mode:'dom', + asset, + glyph, + flag, + hotspot:Object.freeze([.5,.5]), + pickup:Object.freeze({kind:flag?'flag':'glyph',asset,glyph}) + }); + }; + return Object.freeze({item,presentation,styles:()=>Object.freeze([...byStyle.keys()])}); + } + + return Object.freeze({createCursorModel}); +}); diff --git a/docs/bend-field-performance-solutions.docx b/docs/bend-field-performance-solutions.docx deleted file mode 100644 index c7cef55..0000000 Binary files a/docs/bend-field-performance-solutions.docx and /dev/null differ 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/field-save-load-design.md b/docs/field-save-load-design.md deleted file mode 100644 index 6a7c91b..0000000 --- a/docs/field-save-load-design.md +++ /dev/null @@ -1,676 +0,0 @@ -# Bend Field: Efficient Field Save and Load Design - -## Purpose - -This document proposes a scalable persistence system for a complete Bend Field -world. It covers two related but separate problems: - -1. Loading a large local field quickly enough that the player can begin using it - before every puzzle has been read. -2. Exporting and restoring the complete field without creating several - whole-field copies in memory. - -The recommended design preserves the existing incremental autosave behavior, -introduces an index-first local repository, and adds a streamed portable archive. - -## Current system assessment - -IndexedDB is already the durable local source of truth. Normal gameplay saves are -incremental: only dirty board metadata, dirty board states, global data, -tombstones, recovery information, and cloud-outbox changes are committed. This -part should be retained. - -The main scaling problems are elsewhere: - -- Startup calls `getAll()` for all metadata and state rows, constructs a complete - snapshot, and normalizes every puzzle before gameplay begins. -- `hydrateMeta()` does not currently load anything from storage. It only checks - that an already loaded puzzle exists. -- The compact localStorage mirror is useful for small-field recovery but is - intentionally limited to about 4.5 MiB and cannot represent a very large - field. -- JSON export constructs a complete snapshot, pretty-printed JSON string, and - Blob in memory. -- The current export payload can contain cloud credentials, cloud transport - state, session identity, and recovery data. These are local persistence - details and must not be portable. -- JSON import reads and parses the complete file in memory, then clears and - repopulates the active stores. -- The current metadata and state stores are keyed only by board ID. A second - complete field cannot be staged beside the active field, so an atomic pointer - switch is not possible with the present keys. -- The original cloud implementation used a complete snapshot for pulls and one - whole-world JSON file on the server. - -The local repository and portable backup were implemented first. The follow-up -cloud implementation now batches pushes, pages pulls, returns recent revision -deltas, and stores board revisions in separate crash-safe files. - -## Design goals - -- Preserve the existing low-cost dirty-row autosave path. -- Make the origin board or current viewport usable before a full field scan - completes. -- Avoid loading every puzzle definition and path collection during startup. -- Keep archive memory use independent of total field size. -- Make restore strict, cancelable, and all-or-nothing. -- Never place cloud credentials, recovery journals, or session identity in a - portable backup. -- Make reset, import, migration, and rollback use the same safe epoch-switching - primitive. -- Keep old JSON saves importable through explicit compatibility rules. -- Preserve multi-tab protection and reject stale writers after a field switch. - -## Version boundaries - -The following versions serve different purposes and must not share a counter: - -- **Archive version** describes the portable `.bfsave` wire format. -- **Save schema** describes gameplay data and its migrations. -- **IndexedDB layout version** describes object stores and indexes. -- **World generation** identifies compatible generated-world rules. -- **Application version** identifies the build that produced an archive. - -The proposed portable archive is version 2. The proposed IndexedDB layout is -version 6. Upgrading either one must not implicitly change the other. - -The existing physical database name should be reused for the version-6 -migration. Future database names should not be derived from the save-schema -number. - -## Local repository design - -### Object stores - -The version-6 upgrade creates the following stores while retaining the current -version-5 stores for migration: - -- `control`, keyed by a singleton key. -- `worlds`, keyed by `epoch`. -- `boardIndex`, `boardPuzzles`, `boardStates`, and `tombstonesV2`, keyed by - `[epoch, id]`. -- `recoveryV2` and `outboxV2`, keyed by `[epoch, key]`. - -Every epoch-scoped store has an `epoch` index so an abandoned field can be -deleted in bounded batches. - -Conceptual records: - -```ts -interface WorldControl { - key: "active"; - activeFormat: 1 | 2; - activeEpoch: string; - previousEpoch?: string; - activationId: string; - activationVerified: boolean; - switchedAt: number; -} - -interface WorldRecord { - epoch: string; - status: "staging" | "ready" | "active" | "rollback" | "garbage"; - schema: number; - worldGeneration: string; - global: PortableAndLocalGlobalState; - boardCount: number; - solvedCount: number; - score: number; - bounds: { minX: number; minY: number; maxX: number; maxY: number }; - approximateBytes: number; - createdAt: number; - activatedAt?: number; - source?: { - kind: "migration" | "import" | "reset"; - archiveCrc32?: string; - }; - progress?: { - phase: string; - lastKey?: IDBValidKey; - rows: number; - }; -} - -interface BoardIndexRecord { - epoch: string; - id: string; - x: number; - y: number; - chunks: [number, number][]; - level: number; - targetLevel: number; - seed: number; - axis: string; - entrySide: "N" | "S" | "W" | "E" | null; - metaRev: number; - stateRev: number; - solved: boolean; - expanded: boolean; - scoreAwarded: number; - hasProgress: boolean; - specialFlags: { - crossing: boolean; - warp: boolean; - lock: boolean; - }; - shop: null | { - cell: [number, number] | null; - itemIds: string[]; - purchases: CompactPurchaseSummary[]; - }; -} - -interface BoardPuzzleRecord { - epoch: string; - id: string; - metaRev: number; - revAuthor: string; - generatorVersion: number; - puzzle: StoredPuzzle; -} - -interface BoardStateRecord { - epoch: string; - id: string; - stateRev: number; - revAuthor: string; - value: StoredBoardState; -} -``` - -`BoardIndexRecord` is the source for occupancy, distant rendering, solved -totals, shop icons, and inventory summaries. `summarizeBoard(meta, state)` is -the only function that creates it. Any transaction that changes a field used by -the summary must update the index in the same transaction. - -Puzzle records remain separate from state records so drawing a path does not -rewrite the static puzzle definition. - -### Startup and hydration - -Startup proceeds in this order: - -1. Open IndexedDB and read `WorldControl`, the active `WorldRecord`, recovery - coverage, and the cloud outbox header. -2. Read B0 and the saved camera/selected-board area first. If no camera anchor is - available, use B0. -3. Build initial occupancy and presentation from the first index page. -4. Mark the application ready once B0 and the initial viewport details are - hydrated. -5. Continue scanning `boardIndex` in 512-record pages and progressively add - occupancy and overview summaries. - -Puzzle and state stores must never use an unbounded `getAll()` during startup. -Page scans resume from the final compound key of the previous page. - -The current hydration contract is replaced by: - -```ts -async function hydrateBoardDetails( - epoch: string, - id: string, - expected?: { metaRev: number; stateRev: number } -): Promise -``` - -It reads the index, puzzle, and state in one readonly transaction. Missing rows -or revision mismatches are rejected rather than combined. - -Details are hydrated for: - -- Boards entering the interactive viewport and its prefetch margin. -- The selected or keyboard-focused board. -- Boards on both sides of an active gate connection. -- Boards involved in pending expansion or repair work. -- A shop before it opens or an inventory item before it is consumed. - -The detail cache is an LRU limited to 128 boards or 32 MiB, whichever is reached -first. Active pointer boards, dirty boards, time-attack boards, and boards still -referenced by a renderer or editor are pinned. A dirty board must be committed -before it can be evicted. - -Full-field score, inventory, minimap, shop-marker, and distant-overview loops -must use index summaries. They must not hydrate every board as a side effect. - -### Commit and concurrency rules - -All canonical field writes use one repository entry point: - -```ts -async function commitFieldDelta(delta: FieldDelta): Promise -``` - -The transaction: - -1. Reads `WorldControl`. -2. Verifies both the expected active epoch and active format. -3. Applies puzzle, state, summary, global, recovery, tombstone, and outbox - changes. -4. Commits the updated revisions together. - -An epoch or format mismatch aborts with `STALE_WORLD_EPOCH`. - -The existing Web Lock and storage-lease fallback remain the cross-tab mutation -guard. BroadcastChannel and storage events are only wake-up signals; receivers -must re-read authoritative control and revision records from IndexedDB. - -Compression, file writes, fetches, timers, worker replies, and rendering yields -must not be awaited inside an IndexedDB transaction. Browsers may auto-commit a -transaction when it has no pending IndexedDB request. Data should be parsed, -normalized, summarized, and encoded before its bounded transaction is opened. - -### Recovery - -IndexedDB remains authoritative. LocalStorage retains only: - -- The active-epoch hint. -- A bounded dirty-row recovery journal. -- Cross-tab wake-up data. - -It no longer attempts to mirror the entire version-2 field. Recovery records -remain epoch-scoped and cannot be applied after an import, reset, or rollback -switches to another epoch. - -## Portable archive - -### Format - -The default filename is: - -```text -bend-field-save-YYYY-MM-DD.bfsave -``` - -The payload is UTF-8 NDJSON. It is wrapped in one gzip stream when -`CompressionStream("gzip")` is available and remains uncompressed otherwise. -The importer detects the gzip magic bytes and verifies that the decoded -manifest declares the same compression mode. - -Decoded records have this exact order: - -```json -{"type":"manifest","format":"bend-field-save","archiveVersion":2,"saveSchema":31,"gameplayVersion":3,"worldGeneration":"...","appVersion":"...","generatorVersion":1,"exportedAt":"...","boardCount":42,"estimatedRawBytes":123456,"encoding":"ndjson","compression":"gzip"} -{"type":"global","value":{}} -{"type":"board","id":"B0","meta":{},"state":{}} -{"type":"board","id":"B1","meta":{},"state":{}} -{"type":"end","boardCount":42,"rawBytes":123456,"crc32":"12ab34cd"} -``` - -Board records are sorted by numeric board ID. Exactly one global record and one -footer are required. No record is pretty-printed. - -The footer CRC32 covers every decoded UTF-8 byte before the footer, including -record newlines. Gzip validation, CRC32, deterministic ordering, byte counts, -and board counts detect accidental corruption and truncation. They do not -authenticate the archive's author. - -Version 2 intentionally has no random-access index, encryption, signature, or -per-block hash. Import consumes every record in order, so a custom container -would add complexity without improving the current use case. - -### Portable allowlist - -Portable board metadata includes geometry, levels, seed, puzzle definition, -generator version, and connection-related data. Portable board state includes -paths, special progress, solved/expanded status, rewards, and shop purchases. - -Portable global state includes gameplay version, bonus events, -encountered mechanics, last-solve information, time-attack gameplay state, and -the trusted clock floor. - -The importer recomputes `nextId`, solved count, total score, bonus score, field -bounds, board summaries, and storage-size estimates. - -The following data is always excluded: - -- `cloudProfile`, player ID, token, cloud revision, cloud pending data, and - outbox rows. -- Recovery envelopes, journals, coverage markers, and tombstones. -- `worldEpoch`, `sessionId`, revisions' author identities, and transport - revision state. -- Origin URL. -- Quarantine diagnostics, debug flags, performance data, and transient caches. -- Cosmetic preferences that are not part of the field. - -Portable records omit local revisions and authors. Import assigns a fresh epoch -and fresh local revisions. - -### Export pipeline - -The export button obtains its destination synchronously while it still has user -activation. Destination priority is: - -1. `showSaveFilePicker()` and a direct `FileSystemWritableFileStream`. -2. An OPFS temporary file whose resulting `File` is downloaded. -3. An in-memory Blob only when the projected archive is at most 64 MiB. - -All capabilities are feature-detected. Lack of gzip support selects identity -encoding; it does not change the archive record format. - -After a destination is available, export: - -1. Flushes pending field writes and aborts if the flush fails. -2. Acquires the exclusive world lock. -3. Captures the active epoch, global record, board count, and size estimate. -4. Reads board IDs in deterministic pages using short readonly transactions. -5. Encodes records through a worker and writes them with stream backpressure. -6. Writes the footer, closes the destination, and releases the lock. - -Canonical database writes are queued while the lock is held. The archive -therefore represents the committed field at export start. The UI may allow -navigation but must pause puzzle edits, purchases, field placement, reset, and -time-attack mutations until export finishes or is canceled. - -Worker input uses approximately 1 MiB chunks with at most two chunks in flight. -The worker performs JSON serialization, parsing, and CRC work. Native streams -perform compression and decompression. - -## Import and activation - -### Inspection - -`inspectFieldArchive(file, { signal })` reads enough of the file to display: - -- Archive and save-schema versions. -- Export date. -- World generation. -- Board count. -- Estimated raw size. -- Compression mode. - -Inspection does not trust the declared values and does not activate anything. -The complete stream is validated during staging. - -### Limits - -Version-2 import enforces: - -- Compressed file size at most 1 GiB. -- Decoded content at most 2 GiB. -- A single decoded record at most 8 MiB. -- Expansion ratio at most 100:1. -- At most `MAX_BOARDS`, currently 200,000. -- Existing board-coordinate, chunk-shape, puzzle-cell, and path-count limits. -- Write batches of at most 256 boards or 4 MiB. - -`navigator.storage.estimate()` provides an advisory preflight. Import requests -approximately twice the estimated staged size plus 32 MiB of headroom because -the old and new fields coexist during validation. The actual IndexedDB result -remains authoritative: `QuotaExceededError` aborts staging without deleting the -active field. - -Large or version-2 restores require IndexedDB. There is no destructive -localStorage-only fallback. - -### Strict validation - -Each record is treated as untrusted data. Version-2 restore fails closed for: - -- Invalid archive identity, ordering, or unsupported version. -- A schema/world-generation pair without an explicit migration. -- Duplicate or noncanonical board IDs. -- Missing or invalid B0. -- Missing metadata or state. -- Invalid puzzle bounds, gates, cells, paths, stores, or references. -- Overlapping world chunks. -- Board, byte, line, cell, path, or expansion-ratio limits. -- A missing footer or incorrect board count, byte count, or CRC. - -Unlike current tolerant snapshot normalization, a damaged version-2 archive -never silently drops a board. - -Validation maintains occupied chunks and derived totals while records are -staged. After the final record, a database pass verifies staged counts, -revisions, summary/detail agreement, B0 hydration, and global aggregates. - -`importFieldArchive()` stops after producing a completely validated `ready` -epoch. It never changes the active pointer; activation is a separate operation -after the replacement confirmation. - -### Staging and atomic activation - -Import creates a new local epoch with `status: "staging"`. The epoch from the -archive is never reused. Each validated batch is committed independently and -updates resumable progress on its `WorldRecord`. - -After complete validation, the epoch becomes `ready`. Activation then acquires -the world lock and uses one short transaction to: - -1. Re-read the expected active epoch and global revision. -2. Move the current epoch to `rollback`. -3. Mark the ready epoch `active`. -4. Set `previousEpoch`. -5. Flip `WorldControl.activeEpoch`. -6. Mark the activation as unverified. - -Only after this transaction commits may localStorage hints and cross-tab -replacement messages be updated. - -The application reloads into the new epoch. It verifies the header, first index -page, B0 puzzle/state, and occupancy before enabling mutations. Failure at this -point atomically restores `previousEpoch`. - -After the new epoch completes its first durable gameplay checkpoint, -`activationVerified` becomes true and the old epoch becomes garbage. At most -one rollback epoch is retained, and it is not presented as save history. - -Canceled, interrupted, corrupt, quota-failing, or worker-failing imports leave -the active pointer unchanged. Abandoned staging epochs are marked for cleanup -and removed after 24 hours. - -Restored archives start local-only with no cloud profile or outbox. Reconnecting -cloud sync requires an explicit player action and a separately designed paged -full-sync protocol. The current 10,000-change push path must not be used to -silently upload a very large restored world. - -### Legacy JSON - -Existing JSON envelopes and raw snapshots remain importable and retain the -current 100 MiB file limit. They are sanitized before conversion and pass -through the same epoch-staging and activation pipeline. - -Compatibility is handled through explicit -`migrateArchiveRecord(fromSchema, record)` registrations. An unknown schema or -world generation is rejected instead of silently creating a fresh field. - -Legacy normalization may repair or discard malformed data. When that occurs, -the importer displays a lossy-import report with totals and the first affected -board IDs. Activation requires separate confirmation of that report. New -version-2 archives never use lossy repair. - -## Migration from the current database - -The version-change transaction creates stores and indexes only. It must not copy -an entire field inside `onupgradeneeded`. - -While `activeFormat` is 1: - -1. Startup continues using the existing stores. -2. One tab becomes migration leader through the existing world lock. -3. A new version-2 staging epoch is created. -4. Legacy rows are copied in resumable batches. -5. Normal commits dual-write legacy and version-2 records in the same - transaction. -6. Backfill uses revision-conditional writes so it cannot overwrite newer - dual-written data. -7. The last legacy cursor key and copied counts are persisted after every - batch. - -After board, global, tombstone, recovery, and outbox counts and revisions -reconcile, the persistence queue is flushed and `activeFormat` switches to 2 in -one transaction. Dual-writing continues until the first version-2 activation -verification checkpoint. - -Quota failure, a blocked upgrade, or interruption leaves format 1 authoritative -and retryable. The old stores remain for one application release and are -removed only by a later IndexedDB version upgrade. - -## Garbage collection - -Garbage collection: - -- Never deletes the active, previous, or recovery-pinned epoch. -- Deletes no more than 500 rows per idle batch. -- Persists the current store and key after every batch. -- Resumes after reload or interruption. -- Removes abandoned staging epochs older than 24 hours. -- Removes a verified rollback epoch after the new field's first checkpoint. -- Cleans failed OPFS export files. - -## Interfaces - -The repository and archive modules expose these conceptual operations: - -```ts -loadActiveWorldHeader(): Promise<{ - control: WorldControl; - world: WorldRecord; -}>; - -scanBoardIndex( - epoch: string, - afterKey?: IDBValidKey, - limit?: number -): Promise; - -hydrateBoardDetails( - epoch: string, - id: string, - expectedRevisions?: { metaRev: number; stateRev: number } -): Promise; - -commitFieldDelta(delta: FieldDelta): Promise; - -inspectFieldArchive( - file: File, - options?: { signal?: AbortSignal } -): Promise; - -exportFieldArchive(options: { - writable: WritableStream; - signal?: AbortSignal; - onProgress?: (progress: FieldArchiveProgress) => void; -}): Promise; - -importFieldArchive(options: { - file: File; - signal?: AbortSignal; - onProgress?: (progress: FieldArchiveProgress) => void; -}): Promise; - -activateStagedWorld( - stagedEpoch: string, - expectedActive: { epoch: string; globalRev: number } -): Promise; - -rollbackUnverifiedActivation(expectedEpoch: string): Promise; -migrateLegacyWorld(): Promise; -collectWorldGarbage(): Promise; -``` - -Progress has the following stable shape: - -```ts -interface FieldArchiveProgress { - phase: - | "prepare" - | "encode" - | "write" - | "validate" - | "stage" - | "activate" - | "cleanup"; - boardsDone: number; - boardsTotal: number; - bytesRead: number; - bytesWritten: number; -} -``` - -Progress is reported at least every 250 ms while work is advancing. -Cancellation is acknowledged within one worker chunk or one database batch. - -## Performance budgets - -- Initial gameplay readiness requires only the field header, first index page, - and initial viewport details. -- Startup does not load every puzzle or state. -- Archive import/export adds at most 32 MiB of JavaScript heap beyond the - resident field summaries and browser stream buffers. -- At most two approximately 1 MiB worker chunks are in flight. -- No archive or migration task occupies the main thread for 50 ms or longer. -- IndexedDB writes contain at most 256 boards or 4 MiB. -- Index scans use at most 512 records per transaction. -- Import and export UI remains cancelable throughout encode, write, validation, - and staging. - -## Verification - -### Semantic and privacy tests - -- Gzip and identity-encoded round trips preserve the complete portable gameplay - field. -- Derived totals after import equal totals recomputed from the original field. -- Archive text and decoded records contain none of the excluded credential, - recovery, epoch, session, debug, or transport fields. -- Numeric board ordering and CRC output are deterministic for identical - portable input. - -### Invalid archive tests - -- Corrupt gzip data. -- Truncated records or missing footer. -- Incorrect CRC, byte count, or board count. -- Reordered, duplicated, or unknown records. -- Duplicate IDs, missing B0, and overlapping geometry. -- Malformed puzzles, paths, stores, and references. -- Oversized file, decoded stream, line, board count, and expansion ratio. -- Unsupported archive, save schema, or world-generation combination. - -### Failure and concurrency tests - -- Cancellation and injected failure before and after every staging batch. -- Quota failure during preflight and during a write transaction. -- Worker termination and destination-write failure. -- Crash before activation, during pointer activation, and before verification. -- Automatic rollback after failed B0 or first-index validation. -- Stale-tab writes after import, reset, rollback, or migration cutover. -- Cross-tab field replacement and blocked database upgrade. -- Migration resume, dual-write conflict, and conditional backfill. -- Garbage-collection resume and protection of active/rollback epochs. -- Cloud remains disconnected after portable restore. - -### Lazy-load tests - -- B0 becomes interactive before the complete index scan finishes. -- Details hydrate for viewport, gate adjacency, repair, inventory, and shop - access. -- Missing or mismatched revisions fail hydration. -- Dirty, active, and referenced boards cannot be evicted. -- Full-field summaries do not cause detail hydration. - -### Browser and scale tests - -- Direct file picker, OPFS, and bounded Blob output paths. -- Gzip and identity compression paths. -- Web Lock and storage-lease concurrency paths. -- 10,000-board round trip and startup coverage in continuous integration. -- 100,000-board and 200,000-board browser benchmarks as scheduled tests. -- Long-task, heap, progress-frequency, cancellation-latency, transaction-size, - and startup-readiness budgets. - -Existing local `BEND_PERF` instrumentation should record only aggregate -duration, byte, board, cancellation, quota, rollback, and cleanup metrics. No -field contents, board IDs, player identity, or remote telemetry are added. - -## Current implementation requirements - -1. Add the version-6 stores, repository interfaces, epoch checks, and resumable - migration without changing the active read path. -2. Enable summary-first startup, real detail hydration, summary-based - full-field operations, and the bounded LRU. -3. Add the streamed `.bfsave` exporter and all output sinks. -4. Add strict staged import, atomic activation, rollback, and legacy conversion. -5. Route reset through the same epoch activation path. -6. Garbage collection removes inactive epochs and obsolete stores only after verified activation. -7. Cloud synchronization uses paged pulls, bounded push batches, recent-revision deltas, and per-board versioned server storage. diff --git a/docs/interaction-performance.md b/docs/interaction-performance.md new file mode 100644 index 0000000..682a439 --- /dev/null +++ b/docs/interaction-performance.md @@ -0,0 +1,47 @@ +# Bend Field interaction performance and persistence + +## Problems addressed + +- Cursor movement felt uneven. +- Camera panning could appear to stop redrawing during a long held gesture. +- Pickup dragging needed a stable upper frame-rate limit. +- Zoomed-out play could exhaust its cached field image while the camera was still moving. +- A solved puzzle could later return to an unsolved state. +- Puzzle gates still had a dormant debug scheduling switch. + +## Implemented solutions + +### Cursor, camera, and pickup cadence + +- Cursor rendering now keeps only the newest pointer sample and commits it on a display frame. +- Cursor commits use the shared 33.33 ms visual interval, limiting presentation to 30 FPS even on high-refresh displays. +- Camera commits use the same 30 FPS ceiling with a separate missed-frame fallback. +- Pickup dragging remains on the bounded 30 FPS visual scheduler and keeps logical catch-up work separate from visual pointer tracking. + +### Continuous overview panning + +- The zoomed-out field still pans by transforming a cached bitmap, avoiding a full map redraw on every pointer event. +- When a held pan reaches the bitmap's overscan boundary, the game now requests a cache rebuild during the gesture. +- These rebuilds run through an idle callback, are separated by at least 180 ms, and are capped by the benchmark. Camera transforms continue while the new bitmap is prepared. + +### Durable puzzle completion + +- A durable `solved: true` value is now monotonic for the same puzzle. +- Route sanitization may remove damaged path data, but it no longer revokes the clear, score, solver identity, or store state. +- Existing cloud-pull, cross-tab, recovery-journal, and board-hydration merges continue to preserve compatible solved records. + +### Production-only puzzle gates + +- The `SPECIAL_CELL_DEBUG_ALL_LEVELS` switch was removed. +- Internal gates and other special cells now use only the normal production level schedule, beginning at level 5. + +## Verification + +- Focused regression coverage executes the in-gesture cache refresh, durable-clear sanitization, and production-only gate schedule. +- The complete fast test suite passes. +- The real Edge benchmark gates cursor, camera, and pickup presentation at the shared 30 FPS ceiling, checks input age and camera work separately, and rejects uncapped presentation. +- Edge and benchmark server processes are checked after every browser run; the final count is zero. + +## Remaining stress-test observation + +The optional 4× CPU-throttled Edge scenario recorded one 106.70 ms functional-drag outlier. Its steady cadence (8.40 ms p95), model work (2.80 ms p95), visual work (4.90 ms p95), and render work (2.60 ms p95) stayed within their individual budgets. This outlier should remain visible in future profiling rather than being hidden by a relaxed acceptance threshold. diff --git a/docs/internal-system.md b/docs/internal-system.md index 8edd2ea..04e0ae6 100644 --- a/docs/internal-system.md +++ b/docs/internal-system.md @@ -87,6 +87,18 @@ The field uses several levels of detail: Camera updates, board drag frames, the minimap, distant overview, presence cursors, reactions, and static noise are scheduled and instrumented independently. Difficulty-field overlays do not exist in the current runtime. +### Effects and cosmetics + +Reaction rendering keeps the complete `classic`, `giant`, `laser`, `orbit`, `firework`, and `comet` artwork at the existing 30 FPS cap. Accepted local reactions are sent to nearby realtime subscribers; reactions created while the socket is reconnecting are queued until their visual lifetime expires. Each accepted reaction receives one deterministic prepared model containing its immutable geometry and trigonometry. Frames reuse that model, draw every valid visible reaction in original layer order, and derive lifetime from authoritative timestamps. Overload is measured but never changes style, duration, particle count, glow, or compositing. + +The reaction scheduler uses absolute deadlines, one pending timer, and one pending animation-frame callback. It stops while the page is hidden and resumes from current time. The renderer retains full-canvas clearing because cropped clears and cached transformed glyphs did not satisfy the visual-equivalence gate. A bounded emoji cache is used only for transform-neutral draws; rotated or scaled glyphs use direct browser text rendering. A separate bounded LRU `Path2D` cache reuses fixed Orbit and Firework geometry, with direct path construction as the exact fallback. + +Aurora is a shop-only line-color contract. Its curated palette advances once every two seconds and colors newly drawn Aurora paths, connectors, endpoints, and usable gates. Updates are scoped to the world element and run only while tracked Aurora presentation nodes are visible. Completion flashes, completion bursts, and gem particles use bounded reusable node pools with overflow cleanup; gem particles share immutable keyframe and option templates while per-particle values live on the reused nodes. The cosmetic inventory keeps keyed category/item nodes, skips unchanged view signatures, patches only the old and new equipped cards, preserves focus and scroll position, lazy-decodes flag images, and applies render containment to offscreen cards. + +`BEND_PERF` reports reaction preparation and publication, style, compositing, frame cadence, scheduler callbacks, visible/active counts, glyph and path-cache entries/hits/misses/evictions, Aurora ticks and writes, pooled-node setup/activity, and inventory patch/render timings. + +The item UI contains no debug toggle. Opening `/debug-items` enables the debug purchase economy for that URL only: normal shop stock and purchase records are retained, while affordability checks settle at zero cost. + ## Persistence IndexedDB is the durable local source of truth. A compact local mirror supports recovery when IndexedDB startup or writes fail. @@ -109,7 +121,7 @@ The world-generation identifier is separate from the application version. Ordina `test/run-all.js` runs source, gameplay, economy, performance, storage, concurrency, generation, interaction, reset, special-cell, and server regressions. -`test/browser-performance-benchmark.js` verifies real Edge startup and measures pointer work, camera work, minimap conversion, level-of-detail processing, persistence, overview rendering, DOM size, and main-thread responsiveness. +`test/browser-performance-benchmark.js` verifies real Edge startup and measures pointer work, camera work, minimap conversion, level-of-detail processing, persistence, overview rendering, DOM size, main-thread responsiveness, every reaction style, four/eight-effect overlap, deterministic checkpoint visual equivalence, reaction/Gem/completion setup cost, Aurora active/inactive/hidden behavior, localized cosmetic inventory updates with focus and scroll retention, effect-plus-interaction scenarios, and 100-cycle cleanup. It writes the complete report to `test-results/browser-performance-benchmark.json` unless `BEND_FIELD_BENCHMARK_OUTPUT` overrides the path. ## Documentation policy diff --git a/docs/test-policy.md b/docs/test-policy.md new file mode 100644 index 0000000..c4693c9 --- /dev/null +++ b/docs/test-policy.md @@ -0,0 +1,53 @@ +# Test tiers and source-guard inventory + +The release pipeline has four explicit tiers: + +- `npm run test:fast` — deterministic unit, contract, persistence, server, and + source-policy-compatible regression tests; no browser is launched. +- `npm run test:browser` — the required real-browser interaction/performance + matrix plus the store UI flow. Each runner owns one temporary profile and one + process tree, closes it in `finally`, and never targets an unrelated Edge + process. The UI runner uses `playwright-core` only as a driver for the + system-provided Edge binary; it does not download a second browser. +- `npm run test:storage` — opt-in large IndexedDB/archive scale coverage. +- `npm run test:ci` — source policy, fast suite, and required browser release + behavior. CI sets the small bounded browser profile and runs one job at a + time. + +## Effects and cosmetics release gates + +The browser performance runner loads deterministic checkpoints from `test/fixtures/effect-visual-checkpoints.json` and exercises all six reaction styles at normalized lifetimes of 0.10, 0.25, 0.50, 0.75, and 0.95. Direct main-thread rendering with glyph and path caches bypassed is the correctness reference. Cached output may differ only within the anti-aliasing tolerance, with at least 99.5% of pixels inside that tolerance. Structural reductions, style substitution, dropped overlap, shorter lifetimes, or automatic quality tiers are release failures. + +Normal-speed Edge gates enforce the 30 FPS cadence, per-style work limits, the four-effect overlap budget, full rendering of eight simultaneous valid effects, bounded scheduler callback rates, reaction publication p95 at or below 2 ms, Gem and completion setup p95 at or below 3 ms, localized inventory patch p95 at or below 8 ms, exact inventory focus/scroll/node retention, Aurora active-write and inactive/hidden zero-work behavior, effect-plus-pan/zoom/pickup/edge-pan operation, and zero owned state after a 100-cycle cleanup stress. The settled heap delta must remain at or below 2 MB after the bounded caches are cleared. The 4x CPU profile is an overload diagnostic: it enforces bounded callbacks, complete rendering, and cleanup, but it does not permit the test or runtime to reduce visuals merely to meet normal-speed timing. + +`effects-performance-smoke-test.js` is a documented repository-policy guard. Its source assertions protect the no-auto-degradation contract, bounded glyph/path caches, shared animation templates, localized inventory patches, and cleanup/lifecycle mechanisms that do not yet have a smaller public module seam. The browser runner always writes its report to `test-results/browser-performance-benchmark.json` unless `BEND_FIELD_BENCHMARK_OUTPUT` supplies another path, including when a gate fails after measurement. + +## Source-shape guard inventory + +The historical `source-smoke` and versioned `v47xx` files contain temporary +implementation-shape tripwires. They remain only where no stable public seam +exists yet. Their common reason is to prevent a known expensive or unsafe path +from being accidentally restored. Their removal condition is one of: + +1. a pure module has a behavioral unit test; +2. a browser test measures the user-visible DOM, timing, or computed style; +3. a protocol/storage integration test covers the invariant; or +4. a generated artifact equality test covers the contract. + +The following guards have already moved to public seams: + +| Area | Public seam | Replacement coverage | +|---|---|---| +| Pointer ownership | `createGestureCoordinator` | `interaction-ownership-test.js` | +| Interaction scopes | `createInteractionState` | `interaction-ownership-test.js` | +| 60 Hz latest-value scheduling | `createFrameScheduler` | `frame-drag-scheduler-test.js` and browser cadence probes | +| Pickup lifecycle/queue | `createDragScheduler` | `frame-drag-scheduler-test.js` and release-drain behavior | +| Cursor identity/presentation | `createCursorModel` | `architecture-boundaries-test.js` and browser cursor probes | +| HTTP dispatch | `createHttpRouter` | `architecture-boundaries-test.js` and server integration | +| Authentication | `createAuthenticator` | `architecture-boundaries-test.js` and server security integration | +| Atomic JSON storage | `createJsonRepository` | recovery and server integration tests | + +When touching a remaining source assertion, migrate it to the nearest seam and +delete the old assertion in the same change. New tests must not parse function +source unless they enforce a documented repository policy that cannot be +expressed as behavior. diff --git a/field-persistence-worker.js b/field-persistence-worker.js index 83187db..aac535c 100644 --- a/field-persistence-worker.js +++ b/field-persistence-worker.js @@ -1,13 +1,7 @@ 'use strict'; -const encoder=new TextEncoder(),crcTable=new Uint32Array(256),MAX_CHUNK_BYTES=1024*1024; -for(let index=0;index<256;index++){ - let value=index; - for(let bit=0;bit<8;bit++)value=value&1?0xedb88320^(value>>>1):value>>>1; - crcTable[index]=value>>>0; -} +importScripts('archive-codec.js'); +const{encodeRecord,crc32Update,crc32Hex,parseRecordLine}=self.BendArchiveCodec,MAX_CHUNK_BYTES=1024*1024; let crc=0xffffffff,rawBytes=0; -function crc32Update(value,bytes){let next=value>>>0;for(const byte of bytes)next=crcTable[(next^byte)&255]^(next>>>8);return next>>>0} -function crc32Hex(){return((crc^0xffffffff)>>>0).toString(16).padStart(8,'0')} function splitBytes(parts){ const chunks=[];let current=new Uint8Array(MAX_CHUNK_BYTES),offset=0; for(const bytes of parts){let sourceOffset=0;while(sourceOffset{ try{ if(message.op==='encode'){ const parts=[]; - for(const record of message.records||[]){const bytes=encoder.encode(`${JSON.stringify(record)}\n`);if(message.includeInCrc!==false){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength}parts.push(bytes)} - const chunks=splitBytes(parts);self.postMessage({id,chunks,rawBytes,crc32:crc32Hex()},chunks);return; + for(const record of message.records||[]){const bytes=encodeRecord(record);if(message.includeInCrc!==false){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength}parts.push(bytes)} + const chunks=splitBytes(parts);self.postMessage({id,chunks,rawBytes,crc32:crc32Hex(crc)},chunks);return; } if(message.op==='parse'){ const items=[]; - for(const sourceLine of message.lines||[]){const line=sourceLine.replace(/\r$/,'');let record;try{record=JSON.parse(line)}catch(_){throw new Error('An archive record is not valid JSON.')}const bytes=encoder.encode(`${line}\n`);if(record?.type!=='end'){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength}items.push({record,byteLength:bytes.byteLength,rawBytes,crc32:crc32Hex()})} - self.postMessage({id,items,rawBytes,crc32:crc32Hex()});return; + for(const sourceLine of message.lines||[]){const{record,bytes}=parseRecordLine(sourceLine);if(record?.type!=='end'){crc=crc32Update(crc,bytes);rawBytes+=bytes.byteLength}items.push({record,byteLength:bytes.byteLength,rawBytes,crc32:crc32Hex(crc)})} + self.postMessage({id,items,rawBytes,crc32:crc32Hex(crc)});return; } - if(message.op==='snapshot'){self.postMessage({id,rawBytes,crc32:crc32Hex()});return} + if(message.op==='snapshot'){self.postMessage({id,rawBytes,crc32:crc32Hex(crc)});return} throw new Error('Unknown archive worker operation.'); }catch(error){self.postMessage({id,error:error?.message||String(error)})} }; diff --git a/field-persistence.js b/field-persistence.js index 5f706c9..0a051c8 100644 --- a/field-persistence.js +++ b/field-persistence.js @@ -11,22 +11,9 @@ const PROGRESS_INTERVAL=250; const WORKER_TARGET_BYTES=1024*1024; const MAX_WORKER_RECORDS=32; - const encoder=new TextEncoder(); - const crcTable=new Uint32Array(256); - - for(let index=0;index<256;index++){ - let value=index; - for(let bit=0;bit<8;bit++)value=value&1?0xedb88320^(value>>>1):value>>>1; - crcTable[index]=value>>>0; - } - - function crc32Update(crc,bytes){ - let value=crc>>>0; - for(const byte of bytes)value=crcTable[(value^byte)&255]^(value>>>8); - return value>>>0; - } - - function crc32Hex(crc){return((crc^0xffffffff)>>>0).toString(16).padStart(8,'0')} + const ArchiveCodec=global.BendArchiveCodec||(typeof module==='object'&&module.exports?require('./archive-codec'):null); + if(!ArchiveCodec)throw new Error('BendArchiveCodec is not loaded'); + const{encodeUtf8,encodeRecord,crc32Update,crc32Hex,parseRecordLine}=ArchiveCodec; function abortError(){return new DOMException('The operation was canceled.','AbortError')} function throwIfAborted(signal){if(signal?.aborted)throw signal.reason||abortError()} function archiveError(message,code='INVALID_ARCHIVE'){const error=new Error(message);error.code=code;return error} @@ -38,7 +25,6 @@ if(!Number.isSafeInteger(record.estimatedRawBytes)||record.estimatedRawBytes<0||record.estimatedRawBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The archive size declaration is invalid.','ARCHIVE_TOO_LARGE'); return record; } - function encodeRecord(record){return encoder.encode(`${JSON.stringify(record)}\n`)} function boardNumber(id){return/^B(?:0|[1-9]\d*)$/.test(id)?Number(id.slice(1)):-1} function triggerDownload(file,name){ const url=URL.createObjectURL(file),link=document.createElement('a'); @@ -58,7 +44,7 @@ function createArchiveCodec(){ if(typeof Worker!=='function')return null; - let worker;try{worker=new Worker('field-persistence-worker.js?v=47.77')}catch(_){return null} + let worker;try{worker=new Worker('field-persistence-worker.js')}catch(_){return null} let nextId=0;const pending=new Map(); worker.onmessage=event=>{const message=event.data||{},entry=pending.get(message.id);if(!entry)return;pending.delete(message.id);if(message.error)entry.reject(archiveError(message.error,'ARCHIVE_WORKER'));else entry.resolve(message)}; worker.onerror=event=>{const error=archiveError(event?.message||'The archive worker failed.','ARCHIVE_WORKER');for(const entry of pending.values())entry.reject(error);pending.clear()}; @@ -232,10 +218,10 @@ const flushLines=async()=>{ if(!pendingLines.length)return;const lines=pendingLines;pendingLines=[];pendingLineBytes=0; if(codec){const result=await codec.parse(lines);for(const item of result.items){if(item.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');await processRecord(item.record,item)}return} - for(const sourceLine of lines){const line=sourceLine.replace(/\r$/,'');if(!line)throw archiveError('The archive contains an empty record.');const lineBytes=encoder.encode(`${line}\n`);if(lineBytes.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');let record;try{record=JSON.parse(line)}catch(_){throw archiveError('An archive record is not valid JSON.')}if(record?.type!=='end'){crc=crc32Update(crc,lineBytes);rawBytes+=lineBytes.byteLength;crcHex=crc32Hex(crc)}await processRecord(record,{rawBytes,crc32:crcHex})} + for(const sourceLine of lines){let parsed;try{parsed=parseRecordLine(sourceLine)}catch(error){throw archiveError(error.message)}const{line,record,bytes:lineBytes}=parsed;if(!line)throw archiveError('The archive contains an empty record.');if(lineBytes.byteLength>MAX_ARCHIVE_LINE_BYTES)throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');if(record?.type!=='end'){crc=crc32Update(crc,lineBytes);rawBytes+=lineBytes.byteLength;crcHex=crc32Hex(crc)}await processRecord(record,{rawBytes,crc32:crcHex})} }; try{ - while(true){throwIfAborted(signal);const{value,done}=await reader.read();if(done)break;decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The expanded archive exceeded the size limit.','ARCHIVE_TOO_LARGE');if(decoded.compression==='gzip'&&decodedBytes/Math.max(1,file.size)>MAX_ARCHIVE_EXPANSION_RATIO)throw archiveError('The archive expansion ratio is unsafe.','EXPANSION_LIMIT');buffer+=decoder.decode(value,{stream:true});if(encoder.encode(buffer).byteLength>MAX_ARCHIVE_LINE_BYTES&&!buffer.includes('\n'))throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');let newline;while((newline=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,newline);buffer=buffer.slice(newline+1);if(!line)throw archiveError('The archive contains an empty record.');const estimatedBytes=line.length*3+1;if(pendingLines.length&&(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES))await flushLines();pendingLines.push(line);pendingLineBytes+=estimatedBytes;if(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes>=WORKER_TARGET_BYTES)await flushLines()}} + while(true){throwIfAborted(signal);const{value,done}=await reader.read();if(done)break;decodedBytes+=value.byteLength;if(decodedBytes>MAX_ARCHIVE_RAW_BYTES)throw archiveError('The expanded archive exceeded the size limit.','ARCHIVE_TOO_LARGE');if(decoded.compression==='gzip'&&decodedBytes/Math.max(1,file.size)>MAX_ARCHIVE_EXPANSION_RATIO)throw archiveError('The archive expansion ratio is unsafe.','EXPANSION_LIMIT');buffer+=decoder.decode(value,{stream:true});if(encodeUtf8(buffer).byteLength>MAX_ARCHIVE_LINE_BYTES&&!buffer.includes('\n'))throw archiveError('An archive record exceeded the size limit.','LINE_TOO_LARGE');let newline;while((newline=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,newline);buffer=buffer.slice(newline+1);if(!line)throw archiveError('The archive contains an empty record.');const estimatedBytes=line.length*3+1;if(pendingLines.length&&(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES))await flushLines();pendingLines.push(line);pendingLineBytes+=estimatedBytes;if(pendingLines.length>=MAX_WORKER_RECORDS||pendingLineBytes>=WORKER_TARGET_BYTES)await flushLines()}} buffer+=decoder.decode();if(buffer){const estimatedBytes=buffer.length*3+1;if(pendingLines.length&&pendingLineBytes+estimatedBytes>WORKER_TARGET_BYTES)await flushLines();pendingLines.push(buffer);pendingLineBytes+=estimatedBytes}await flushLines();if(phase!=='done'||!footer)throw archiveError('The archive footer is missing or incomplete.');report({phase:'validate',boardsDone,boardsTotal:manifest.boardCount,bytesRead:decodedBytes,bytesWritten:0},true);return{manifest,globalState,footer,boardCount:boardsDone,decodedBytes,compression:decoded.compression,worker:codec!=null}; }finally{try{reader.releaseLock()}catch(_){}codec?.terminate()} } diff --git a/index.html b/index.html index 95e1981..983677d 100644 --- a/index.html +++ b/index.html @@ -3,21 +3,20 @@ - -曲線フィールド v47.77 +LinkField/リンクフィールド - +
-
曲線フィールド v47.77
+ 333の部屋に戻る +
LinkField/リンクフィールド
クリア 0
0
盤面 1
-
FPS 待機
-
レベル1
+
FPS 待機
@@ -25,38 +24,35 @@ -
-
-