diff --git a/bun.lock b/bun.lock index ada532e06..c84ffa3ae 100644 --- a/bun.lock +++ b/bun.lock @@ -391,7 +391,7 @@ "@ai-sdk/openai": "^3.0.30", "@elizaos/core": "alpha", "@elizaos/plugin-anthropic": "alpha", - "@elizaos/plugin-elizacloud": "^1.8.0", + "@elizaos/plugin-elizacloud": "alpha", "@elizaos/plugin-groq": "alpha", "@elizaos/plugin-openai": "alpha", "@elizaos/plugin-sql": "alpha", @@ -1243,7 +1243,7 @@ "@elizaos/plugin-anthropic": ["@elizaos/plugin-anthropic@2.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/anthropic": "^3.0.9", "@elizaos/core": "alpha", "ai": "^6.0.23", "jsonrepair": "^3.12.0", "undici": "^7.16.0" } }, "sha512-BqTU9DgLdu+HNAxI5hrvBI4t3Ci2oD9EZ2tuC+IUie3ipB1LL/4A6OhvqKOtjMgJI5pgOXHY71zrjOARVTYniw=="], - "@elizaos/plugin-elizacloud": ["@elizaos/plugin-elizacloud@1.8.0", "", { "dependencies": { "@ai-sdk/openai": "^2.0.88", "@elizaos/core": "^1.7.0", "ai": "^5.0.116", "js-tiktoken": "^1.0.21", "undici": "^7.16.0" }, "peerDependencies": { "zod": "^4.2.1" } }, "sha512-E37eyrZkC34JPkdo72ZlVwcjKQMuQeIlL2xTBY+SWWIDpaq6acxzv/jJrRuxm8E9s8CxWUbLEqaGV09gj+99Bg=="], + "@elizaos/plugin-elizacloud": ["@elizaos/plugin-elizacloud@2.0.0-alpha.7", "", { "dependencies": { "@ai-sdk/openai": "^3.0.9", "@elizaos/core": "2.0.0-alpha.3", "ai": "^6.0.30", "js-tiktoken": "^1.0.21", "undici": "^7.16.0" }, "peerDependencies": { "zod": "^4.3.6" } }, "sha512-9q/QNqnKKidmVq70yIch8uxRfartvdT0dgrcsYfXYJTImuLyEcKG2PRt8kkoRHO6+8jTwkUuSESMuTilfV0Pfw=="], "@elizaos/plugin-groq": ["@elizaos/plugin-groq@2.0.0-alpha.8", "", { "dependencies": { "@ai-sdk/groq": "^3.0.4", "@elizaos/core": "alpha", "ai": "^6.0.0" } }, "sha512-BTpT8Q1YaapmcrfZ8gc9jhd+10r5ZbRvT5uaz+0TtmD5+tL3hstalJsyoEH7zyjI9fIVgP2Z6bO5wKXLzhUblg=="], @@ -7111,8 +7111,6 @@ "@elizaos/core/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], - "@elizaos/plugin-elizacloud/@ai-sdk/openai": ["@ai-sdk/openai@2.0.99", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.22" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wwa1/DuO9XThaA+sAi0d3+xfkbEx9nRhZ1USV6kktndmEs8aQRR0DJK/Iec+mwNu06IhfDGd5vMscR1U1q155g=="], - "@elizaos/plugin-sql/dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], "@elizaos/plugin-sql/drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="], @@ -8623,10 +8621,6 @@ "@donmccurdy/caporal/table/string-width": ["string-width@3.1.0", "", { "dependencies": { "emoji-regex": "^7.0.1", "is-fullwidth-code-point": "^2.0.0", "strip-ansi": "^5.1.0" } }, "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w=="], - "@elizaos/plugin-elizacloud/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], - - "@elizaos/plugin-elizacloud/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fFT1KfUUKktfAFm5mClJhS1oux9tP2qgzmEZVl5UdwltQ1LO/s8hd7znVrgKzivwv1s1FIPza0s9OpJaNB/vHw=="], - "@fastify/static/glob/jackspeak": ["jackspeak@4.2.3", "", { "dependencies": { "@isaacs/cliui": "^9.0.0" } }, "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg=="], "@gemini-wallet/core/@metamask/rpc-errors/@metamask/utils": ["@metamask/utils@11.10.0", "", { "dependencies": { "@ethereumjs/tx": "^4.2.0", "@metamask/superstruct": "^3.1.0", "@noble/hashes": "^1.3.1", "@scure/base": "^1.1.3", "@types/debug": "^4.1.7", "@types/lodash": "^4.17.20", "debug": "^4.3.4", "lodash": "^4.17.21", "pony-cause": "^2.1.10", "semver": "^7.5.4", "uuid": "^9.0.1" } }, "sha512-+bWmTOANx1MbBW6RFM8Se4ZoigFYGXiuIrkhjj4XnG5Aez8uWaTSZ76yn9srKKClv+PoEVoAuVtcUOogFEMUNA=="], diff --git a/packages/client/src/lib/api-config.ts b/packages/client/src/lib/api-config.ts index ebae963b6..747d75529 100644 --- a/packages/client/src/lib/api-config.ts +++ b/packages/client/src/lib/api-config.ts @@ -26,15 +26,15 @@ type PublicRuntimeEnv = { type WindowWithRuntimeEnv = Window & { env?: PublicRuntimeEnv }; function isLoopbackHost(hostname: string): boolean { - return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" + ); } function normalizeBaseUrlString(url: URL): string { const normalized = url.toString(); const isRootBase = - url.pathname === "/" && - url.search.length === 0 && - url.hash.length === 0; + url.pathname === "/" && url.search.length === 0 && url.hash.length === 0; if (!isRootBase) { return normalized; } @@ -69,9 +69,7 @@ export function normalizeBrowserLoopbackUrl( } } -function getRuntimeEnvValue( - key: keyof PublicRuntimeEnv, -): string | undefined { +function getRuntimeEnvValue(key: keyof PublicRuntimeEnv): string | undefined { if (typeof window === "undefined") return undefined; return normalizeBrowserLoopbackUrl( (window as WindowWithRuntimeEnv).env?.[key], diff --git a/packages/client/src/screens/GameClient.tsx b/packages/client/src/screens/GameClient.tsx index 5ebbed611..7811c3a13 100644 --- a/packages/client/src/screens/GameClient.tsx +++ b/packages/client/src/screens/GameClient.tsx @@ -327,14 +327,13 @@ export function GameClient({ event.preventDefault?.(); console.error( "[GameClient] GPU context lost - WebGPU device may have been lost. " + - "This indicates GPU resource exhaustion or driver issues.", + "This indicates GPU resource exhaustion or driver issues.", ); }; // Listen for WebGPU context lost events (if supported in future/custom) // canvas.addEventListener("webgpucontextlost", handleContextLost); - return () => { - }; + return () => {}; }, [world]); useEffect(() => { diff --git a/packages/client/src/screens/StreamingMode.tsx b/packages/client/src/screens/StreamingMode.tsx index 8646b805d..c0bf978a0 100644 --- a/packages/client/src/screens/StreamingMode.tsx +++ b/packages/client/src/screens/StreamingMode.tsx @@ -238,7 +238,7 @@ export function StreamingMode() { c.agent1?.damageDealtThisFight === p.agent1?.damageDealtThisFight && c.agent2?.damageDealtThisFight === p.agent2?.damageDealtThisFight && Math.floor(c.timeRemaining / 1000) === - Math.floor(p.timeRemaining / 1000) && + Math.floor(p.timeRemaining / 1000) && state.leaderboard.length === prev.leaderboard.length ) { return prev; // Same reference = no re-render @@ -634,7 +634,7 @@ export function StreamingMode() { if (!recorder || recorder.state !== "recording") return; try { recorder.requestData(); - } catch { } + } catch {} }, 250); healthTimer = setInterval(() => { if (!recorder || recorder.state !== "recording") return; @@ -646,7 +646,7 @@ export function StreamingMode() { ); try { recorder.requestData(); - } catch { } + } catch {} } }, 2000); const videoTrack = stream?.getVideoTracks?.()[0] as // eslint-disable-next-line no-undef @@ -657,7 +657,7 @@ export function StreamingMode() { forceFrameTimer = setInterval(() => { try { videoTrack.requestFrame?.(); - } catch { } + } catch {} }, frameIntervalMs); } if (captureDebug) { @@ -676,7 +676,7 @@ export function StreamingMode() { if (recorder && recorder.state !== "inactive") { try { recorder.stop(); - } catch { } + } catch {} } if (recorder) { recorder.ondataavailable = null; @@ -690,7 +690,7 @@ export function StreamingMode() { if (oscillator) { try { oscillator.stop(); - } catch { } + } catch {} oscillator.disconnect(); oscillator = null; } diff --git a/packages/client/src/stream.tsx b/packages/client/src/stream.tsx index 308757fd6..688046fc8 100644 --- a/packages/client/src/stream.tsx +++ b/packages/client/src/stream.tsx @@ -71,7 +71,9 @@ if (typeof window !== "undefined") { if (envCdn && typeof envCdn === "string" && envCdn !== "undefined") { let resolvedCdn = envCdn; if (resolvedCdn.includes("127.0.0.1") || resolvedCdn.includes("0.0.0.0")) { - resolvedCdn = resolvedCdn.replace("127.0.0.1", "localhost").replace("0.0.0.0", "localhost"); + resolvedCdn = resolvedCdn + .replace("127.0.0.1", "localhost") + .replace("0.0.0.0", "localhost"); } windowWithEnv.__CDN_URL = resolvedCdn; } @@ -105,7 +107,9 @@ async function resetLocalStreamingCaches(): Promise { } const searchParams = new URLSearchParams(window.location.search); - const isInternalCapture = isTruthyUrlFlag(searchParams.get("internalCapture")); + const isInternalCapture = isTruthyUrlFlag( + searchParams.get("internalCapture"), + ); const isLoopbackHost = ["localhost", "127.0.0.1", "0.0.0.0", "::1"].includes( window.location.hostname, ); @@ -125,7 +129,10 @@ async function resetLocalStreamingCaches(): Promise { ); } } catch (error) { - console.warn("[StreamEntry] Failed to unregister service workers:", error); + console.warn( + "[StreamEntry] Failed to unregister service workers:", + error, + ); } } diff --git a/packages/client/tests/e2e/utils/testWorld.ts b/packages/client/tests/e2e/utils/testWorld.ts index 6c193d424..0ff613132 100644 --- a/packages/client/tests/e2e/utils/testWorld.ts +++ b/packages/client/tests/e2e/utils/testWorld.ts @@ -36,7 +36,7 @@ export async function waitForGameLoad( .catch(() => null); if (loadedHandle) { - await loadedHandle.dispose().catch(() => { }); + await loadedHandle.dispose().catch(() => {}); return; } @@ -96,7 +96,7 @@ export async function waitForLoadingScreenHidden( .catch(() => null); if (hiddenHandle) { - await hiddenHandle.dispose().catch(() => { }); + await hiddenHandle.dispose().catch(() => {}); return; } @@ -201,7 +201,7 @@ export async function waitForPlayerSpawn( .catch(() => null); if (spawnedHandle) { - await spawnedHandle.dispose().catch(() => { }); + await spawnedHandle.dispose().catch(() => {}); return; } @@ -340,7 +340,7 @@ export async function takeGameScreenshot( const maxRetries = options.maxRetries ?? 5; const retryDelay = options.retryDelay ?? 500; - await mkdir("screenshots", { recursive: true }).catch(() => { }); + await mkdir("screenshots", { recursive: true }).catch(() => {}); for (let attempt = 0; attempt < maxRetries; attempt++) { // Wait for canvas to be ready @@ -356,7 +356,7 @@ export async function takeGameScreenshot( if (!navigator.gpu) return false; // Try to get a basic 2D context to verify canvas has dimensions and is ready - // (Note: WebGPU contexts can't readPixels directly without a staging buffer, + // (Note: WebGPU contexts can't readPixels directly without a staging buffer, // so we just rely on dimensions and Context availability here) return canvas.width > 0 && canvas.height > 0; }) @@ -395,7 +395,7 @@ export async function takeGameScreenshot( // Check if screenshot is large enough (actual rendered content) if (pngBuffer.length >= minSize) { - await writeFile(screenshotPath, pngBuffer).catch(() => { }); + await writeFile(screenshotPath, pngBuffer).catch(() => {}); console.log( `[Screenshot] Captured "${name}": ${pngBuffer.length} bytes (attempt ${attempt + 1})`, ); @@ -412,7 +412,7 @@ export async function takeGameScreenshot( } // Last attempt, accept whatever we got - await writeFile(screenshotPath, pngBuffer).catch(() => { }); + await writeFile(screenshotPath, pngBuffer).catch(() => {}); console.log( `[Screenshot] Captured "${name}": ${pngBuffer.length} bytes (fallback)`, ); @@ -513,16 +513,16 @@ export function assertScreenshotsDifferent( if (comparison.identical) { throw new Error( `Screenshots "${name1}" and "${name2}" are pixel-identical! ` + - `This indicates the game may not be rendering correctly or the scene isn't changing.`, + `This indicates the game may not be rendering correctly or the scene isn't changing.`, ); } if (comparison.diffPercentage < minDiffPercentage) { throw new Error( `Screenshots "${name1}" and "${name2}" are nearly identical ` + - `(only ${comparison.diffPercentage.toFixed(4)}% different, ${comparison.diffPixels} bytes). ` + - `Expected at least ${minDiffPercentage}% difference. ` + - `This may indicate the game isn't responding to input.`, + `(only ${comparison.diffPercentage.toFixed(4)}% different, ${comparison.diffPixels} bytes). ` + + `Expected at least ${minDiffPercentage}% difference. ` + + `This may indicate the game isn't responding to input.`, ); } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 2646cea07..3f973ab9c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -13,7 +13,7 @@ "dev": "mud dev-contracts", "lint": "bunx solhint --disc 'src/**/*.sol'", "tablegen": "mud tablegen", - "test": "if [ -n \"$(find test -name '*.t.sol' -print -quit 2>/dev/null)\" ]; then export PATH=\"$PWD/node_modules/.bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"; export PRIVATE_KEY=${PRIVATE_KEY:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}; /opt/homebrew/Cellar/node/24.5.0/bin/node ./node_modules/.bin/mud test --port 4252; else echo 'No contract tests defined (test/**/*.t.sol not found); skipping mud test'; fi", + "test": "if [ -n \"$(find test -name '*.t.sol' -print -quit 2>/dev/null)\" ]; then export PATH=\"$PWD/node_modules/.bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"; export PRIVATE_KEY=${PRIVATE_KEY:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}; mud test --port 4252; else echo 'No contract tests defined (test/**/*.t.sol not found); skipping mud test'; fi", "worldgen": "mud worldgen" }, "devDependencies": { diff --git a/packages/duel-oracle-solana/src/generated/fightOracleIdl.ts b/packages/duel-oracle-solana/src/generated/fightOracleIdl.ts index 743209d94..ad016a21d 100644 --- a/packages/duel-oracle-solana/src/generated/fightOracleIdl.ts +++ b/packages/duel-oracle-solana/src/generated/fightOracleIdl.ts @@ -1,851 +1,635 @@ export const FIGHT_ORACLE_IDL = { - "address": "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV", - "metadata": { - "name": "fight_oracle", - "version": "0.1.0", - "spec": "0.1.0" + address: "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV", + metadata: { + name: "fight_oracle", + version: "0.1.0", + spec: "0.1.0", }, - "instructions": [ + instructions: [ { - "name": "cancel_duel", - "discriminator": [ - 83, - 124, - 224, - 237, - 235, - 44, - 38, - 57 - ], - "accounts": [ + name: "cancel_duel", + discriminator: [83, 124, 224, 237, 235, 44, 38, 57], + accounts: [ { - "name": "reporter", - "writable": true, - "signer": true + name: "reporter", + writable: true, + signer: true, }, { - "name": "oracle_config", - "pda": { - "seeds": [ + name: "oracle_config", + pda: { + seeds: [ { - "kind": "const", - "value": [ - 111, - 114, - 97, - 99, - 108, - 101, - 95, - 99, - 111, - 110, - 102, - 105, - 103 - ] - } - ] - } - }, - { - "name": "duel_state", - "writable": true, - "pda": { - "seeds": [ + kind: "const", + value: [ + 111, 114, 97, 99, 108, 101, 95, 99, 111, 110, 102, 105, 103, + ], + }, + ], + }, + }, + { + name: "duel_state", + writable: true, + pda: { + seeds: [ { - "kind": "const", - "value": [ - 100, - 117, - 101, - 108 - ] + kind: "const", + value: [100, 117, 101, 108], }, { - "kind": "arg", - "path": "duel_key" - } - ] - } - } + kind: "arg", + path: "duel_key", + }, + ], + }, + }, ], - "args": [ + args: [ { - "name": "_duel_key", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "_duel_key", + type: { + array: ["u8", 32], + }, }, { - "name": "metadata_uri", - "type": "string" - } - ] + name: "metadata_uri", + type: "string", + }, + ], }, { - "name": "initialize_oracle", - "discriminator": [ - 144, - 223, - 131, - 120, - 196, - 253, - 181, - 99 - ], - "accounts": [ + name: "initialize_oracle", + discriminator: [144, 223, 131, 120, 196, 253, 181, 99], + accounts: [ { - "name": "authority", - "writable": true, - "signer": true + name: "authority", + writable: true, + signer: true, }, { - "name": "oracle_config", - "writable": true, - "pda": { - "seeds": [ + name: "oracle_config", + writable: true, + pda: { + seeds: [ { - "kind": "const", - "value": [ - 111, - 114, - 97, - 99, - 108, - 101, - 95, - 99, - 111, - 110, - 102, - 105, - 103 - ] - } - ] - } - }, - { - "name": "program", - "address": "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV" - }, - { - "name": "program_data" - }, - { - "name": "system_program", - "address": "11111111111111111111111111111111" - } + kind: "const", + value: [ + 111, 114, 97, 99, 108, 101, 95, 99, 111, 110, 102, 105, 103, + ], + }, + ], + }, + }, + { + name: "program", + address: "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV", + }, + { + name: "program_data", + }, + { + name: "system_program", + address: "11111111111111111111111111111111", + }, ], - "args": [ + args: [ { - "name": "reporter", - "type": "pubkey" - } - ] + name: "reporter", + type: "pubkey", + }, + ], }, { - "name": "report_result", - "discriminator": [ - 195, - 187, - 161, - 107, - 75, - 154, - 102, - 183 - ], - "accounts": [ + name: "report_result", + discriminator: [195, 187, 161, 107, 75, 154, 102, 183], + accounts: [ { - "name": "reporter", - "writable": true, - "signer": true + name: "reporter", + writable: true, + signer: true, }, { - "name": "oracle_config", - "pda": { - "seeds": [ + name: "oracle_config", + pda: { + seeds: [ { - "kind": "const", - "value": [ - 111, - 114, - 97, - 99, - 108, - 101, - 95, - 99, - 111, - 110, - 102, - 105, - 103 - ] - } - ] - } - }, - { - "name": "duel_state", - "writable": true, - "pda": { - "seeds": [ + kind: "const", + value: [ + 111, 114, 97, 99, 108, 101, 95, 99, 111, 110, 102, 105, 103, + ], + }, + ], + }, + }, + { + name: "duel_state", + writable: true, + pda: { + seeds: [ { - "kind": "const", - "value": [ - 100, - 117, - 101, - 108 - ] + kind: "const", + value: [100, 117, 101, 108], }, { - "kind": "arg", - "path": "duel_key" - } - ] - } - } + kind: "arg", + path: "duel_key", + }, + ], + }, + }, ], - "args": [ + args: [ { - "name": "_duel_key", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "_duel_key", + type: { + array: ["u8", 32], + }, }, { - "name": "winner", - "type": { - "defined": { - "name": "MarketSide" - } - } + name: "winner", + type: { + defined: { + name: "MarketSide", + }, + }, }, { - "name": "seed", - "type": "u64" + name: "seed", + type: "u64", }, { - "name": "replay_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "replay_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "result_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "result_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "duel_end_ts", - "type": "i64" + name: "duel_end_ts", + type: "i64", }, { - "name": "metadata_uri", - "type": "string" - } - ] + name: "metadata_uri", + type: "string", + }, + ], }, { - "name": "update_oracle_config", - "discriminator": [ - 83, - 16, - 11, - 254, - 57, - 99, - 156, - 58 - ], - "accounts": [ + name: "update_oracle_config", + discriminator: [83, 16, 11, 254, 57, 99, 156, 58], + accounts: [ { - "name": "authority", - "signer": true + name: "authority", + signer: true, }, { - "name": "oracle_config", - "writable": true, - "pda": { - "seeds": [ + name: "oracle_config", + writable: true, + pda: { + seeds: [ { - "kind": "const", - "value": [ - 111, - 114, - 97, - 99, - 108, - 101, - 95, - 99, - 111, - 110, - 102, - 105, - 103 - ] - } - ] - } - } + kind: "const", + value: [ + 111, 114, 97, 99, 108, 101, 95, 99, 111, 110, 102, 105, 103, + ], + }, + ], + }, + }, ], - "args": [ + args: [ { - "name": "authority", - "type": "pubkey" + name: "authority", + type: "pubkey", }, { - "name": "reporter", - "type": "pubkey" - } - ] + name: "reporter", + type: "pubkey", + }, + ], }, { - "name": "upsert_duel", - "discriminator": [ - 174, - 7, - 139, - 223, - 70, - 128, - 251, - 128 - ], - "accounts": [ + name: "upsert_duel", + discriminator: [174, 7, 139, 223, 70, 128, 251, 128], + accounts: [ { - "name": "reporter", - "writable": true, - "signer": true + name: "reporter", + writable: true, + signer: true, }, { - "name": "oracle_config", - "pda": { - "seeds": [ + name: "oracle_config", + pda: { + seeds: [ { - "kind": "const", - "value": [ - 111, - 114, - 97, - 99, - 108, - 101, - 95, - 99, - 111, - 110, - 102, - 105, - 103 - ] - } - ] - } - }, - { - "name": "duel_state", - "writable": true, - "pda": { - "seeds": [ + kind: "const", + value: [ + 111, 114, 97, 99, 108, 101, 95, 99, 111, 110, 102, 105, 103, + ], + }, + ], + }, + }, + { + name: "duel_state", + writable: true, + pda: { + seeds: [ { - "kind": "const", - "value": [ - 100, - 117, - 101, - 108 - ] + kind: "const", + value: [100, 117, 101, 108], }, { - "kind": "arg", - "path": "duel_key" - } - ] - } + kind: "arg", + path: "duel_key", + }, + ], + }, }, { - "name": "system_program", - "address": "11111111111111111111111111111111" - } + name: "system_program", + address: "11111111111111111111111111111111", + }, ], - "args": [ + args: [ { - "name": "duel_key", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duel_key", + type: { + array: ["u8", 32], + }, }, { - "name": "participant_a_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participant_a_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "participant_b_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participant_b_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "bet_open_ts", - "type": "i64" + name: "bet_open_ts", + type: "i64", }, { - "name": "bet_close_ts", - "type": "i64" + name: "bet_close_ts", + type: "i64", }, { - "name": "duel_start_ts", - "type": "i64" + name: "duel_start_ts", + type: "i64", }, { - "name": "metadata_uri", - "type": "string" + name: "metadata_uri", + type: "string", }, { - "name": "status", - "type": { - "defined": { - "name": "DuelStatus" - } - } - } - ] - } + name: "status", + type: { + defined: { + name: "DuelStatus", + }, + }, + }, + ], + }, ], - "accounts": [ + accounts: [ { - "name": "DuelState", - "discriminator": [ - 149, - 213, - 59, - 165, - 124, - 116, - 145, - 120 - ] + name: "DuelState", + discriminator: [149, 213, 59, 165, 124, 116, 145, 120], }, { - "name": "OracleConfig", - "discriminator": [ - 133, - 196, - 152, - 50, - 27, - 21, - 145, - 254 - ] - } + name: "OracleConfig", + discriminator: [133, 196, 152, 50, 27, 21, 145, 254], + }, ], - "events": [ + events: [ { - "name": "DuelCancelled", - "discriminator": [ - 138, - 79, - 20, - 163, - 207, - 11, - 111, - 213 - ] + name: "DuelCancelled", + discriminator: [138, 79, 20, 163, 207, 11, 111, 213], }, { - "name": "DuelResolved", - "discriminator": [ - 224, - 245, - 214, - 212, - 111, - 151, - 50, - 5 - ] + name: "DuelResolved", + discriminator: [224, 245, 214, 212, 111, 151, 50, 5], }, { - "name": "DuelUpserted", - "discriminator": [ - 37, - 241, - 232, - 195, - 196, - 76, - 240, - 120 - ] - } + name: "DuelUpserted", + discriminator: [37, 241, 232, 195, 196, 76, 240, 120], + }, ], - "errors": [ + errors: [ { - "code": 6000, - "name": "Unauthorized", - "msg": "Unauthorized oracle action" + code: 6000, + name: "Unauthorized", + msg: "Unauthorized oracle action", }, { - "code": 6001, - "name": "UnauthorizedInitializer", - "msg": "Only the current upgrade authority can initialize the oracle" + code: 6001, + name: "UnauthorizedInitializer", + msg: "Only the current upgrade authority can initialize the oracle", }, { - "code": 6002, - "name": "InvalidReporter", - "msg": "Reporter pubkey cannot be the default address" + code: 6002, + name: "InvalidReporter", + msg: "Reporter pubkey cannot be the default address", }, { - "code": 6003, - "name": "InvalidAuthority", - "msg": "Authority pubkey cannot be the default address" + code: 6003, + name: "InvalidAuthority", + msg: "Authority pubkey cannot be the default address", }, { - "code": 6004, - "name": "InvalidBetWindow", - "msg": "Betting window is invalid" + code: 6004, + name: "InvalidBetWindow", + msg: "Betting window is invalid", }, { - "code": 6005, - "name": "InvalidParticipants", - "msg": "Participants must be present and distinct" + code: 6005, + name: "InvalidParticipants", + msg: "Participants must be present and distinct", }, { - "code": 6006, - "name": "InvalidLifecycleTransition", - "msg": "Duel lifecycle transition is invalid" + code: 6006, + name: "InvalidLifecycleTransition", + msg: "Duel lifecycle transition is invalid", }, { - "code": 6007, - "name": "DuelKeyMismatch", - "msg": "The provided duel key does not match the stored duel" + code: 6007, + name: "DuelKeyMismatch", + msg: "The provided duel key does not match the stored duel", }, { - "code": 6008, - "name": "DuelAlreadyFinalized", - "msg": "The duel is already finalized" + code: 6008, + name: "DuelAlreadyFinalized", + msg: "The duel is already finalized", }, { - "code": 6009, - "name": "DuelAlreadyCancelled", - "msg": "The duel was cancelled and cannot be resolved" + code: 6009, + name: "DuelAlreadyCancelled", + msg: "The duel was cancelled and cannot be resolved", }, { - "code": 6010, - "name": "InvalidWinner", - "msg": "Winner must be side A or side B" - } + code: 6010, + name: "InvalidWinner", + msg: "Winner must be side A or side B", + }, ], - "types": [ + types: [ { - "name": "DuelCancelled", - "type": { - "kind": "struct", - "fields": [ - { - "name": "duel_key", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "metadata_uri", - "type": "string" - } - ] - } + name: "DuelCancelled", + type: { + kind: "struct", + fields: [ + { + name: "duel_key", + type: { + array: ["u8", 32], + }, + }, + { + name: "metadata_uri", + type: "string", + }, + ], + }, }, { - "name": "DuelResolved", - "type": { - "kind": "struct", - "fields": [ + name: "DuelResolved", + type: { + kind: "struct", + fields: [ { - "name": "duel_key", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duel_key", + type: { + array: ["u8", 32], + }, }, { - "name": "winner", - "type": { - "defined": { - "name": "MarketSide" - } - } + name: "winner", + type: { + defined: { + name: "MarketSide", + }, + }, }, { - "name": "seed", - "type": "u64" + name: "seed", + type: "u64", }, { - "name": "duel_end_ts", - "type": "i64" + name: "duel_end_ts", + type: "i64", }, { - "name": "result_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "result_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "replay_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "replay_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "metadata_uri", - "type": "string" - } - ] - } + name: "metadata_uri", + type: "string", + }, + ], + }, }, { - "name": "DuelState", - "type": { - "kind": "struct", - "fields": [ + name: "DuelState", + type: { + kind: "struct", + fields: [ { - "name": "duel_key", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duel_key", + type: { + array: ["u8", 32], + }, }, { - "name": "participant_a_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participant_a_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "participant_b_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participant_b_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "status", - "type": { - "defined": { - "name": "DuelStatus" - } - } + name: "status", + type: { + defined: { + name: "DuelStatus", + }, + }, }, { - "name": "winner", - "type": { - "defined": { - "name": "MarketSide" - } - } + name: "winner", + type: { + defined: { + name: "MarketSide", + }, + }, }, { - "name": "bet_open_ts", - "type": "i64" + name: "bet_open_ts", + type: "i64", }, { - "name": "bet_close_ts", - "type": "i64" + name: "bet_close_ts", + type: "i64", }, { - "name": "duel_start_ts", - "type": "i64" + name: "duel_start_ts", + type: "i64", }, { - "name": "duel_end_ts", - "type": "i64" + name: "duel_end_ts", + type: "i64", }, { - "name": "seed", - "type": "u64" + name: "seed", + type: "u64", }, { - "name": "result_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "result_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "replay_hash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "replay_hash", + type: { + array: ["u8", 32], + }, }, { - "name": "metadata_uri", - "type": "string" + name: "metadata_uri", + type: "string", }, { - "name": "bump", - "type": "u8" - } - ] - } + name: "bump", + type: "u8", + }, + ], + }, }, { - "name": "DuelStatus", - "type": { - "kind": "enum", - "variants": [ + name: "DuelStatus", + type: { + kind: "enum", + variants: [ { - "name": "Scheduled" + name: "Scheduled", }, { - "name": "BettingOpen" + name: "BettingOpen", }, { - "name": "Locked" + name: "Locked", }, { - "name": "Resolved" + name: "Resolved", }, { - "name": "Cancelled" - } - ] - } + name: "Cancelled", + }, + ], + }, }, { - "name": "DuelUpserted", - "type": { - "kind": "struct", - "fields": [ + name: "DuelUpserted", + type: { + kind: "struct", + fields: [ { - "name": "duel_key", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duel_key", + type: { + array: ["u8", 32], + }, }, { - "name": "status", - "type": { - "defined": { - "name": "DuelStatus" - } - } + name: "status", + type: { + defined: { + name: "DuelStatus", + }, + }, }, { - "name": "bet_open_ts", - "type": "i64" + name: "bet_open_ts", + type: "i64", }, { - "name": "bet_close_ts", - "type": "i64" + name: "bet_close_ts", + type: "i64", }, { - "name": "duel_start_ts", - "type": "i64" + name: "duel_start_ts", + type: "i64", }, { - "name": "metadata_uri", - "type": "string" - } - ] - } + name: "metadata_uri", + type: "string", + }, + ], + }, }, { - "name": "MarketSide", - "type": { - "kind": "enum", - "variants": [ + name: "MarketSide", + type: { + kind: "enum", + variants: [ { - "name": "None" + name: "None", }, { - "name": "A" + name: "A", }, { - "name": "B" - } - ] - } + name: "B", + }, + ], + }, }, { - "name": "OracleConfig", - "type": { - "kind": "struct", - "fields": [ + name: "OracleConfig", + type: { + kind: "struct", + fields: [ { - "name": "authority", - "type": "pubkey" + name: "authority", + type: "pubkey", }, { - "name": "reporter", - "type": "pubkey" + name: "reporter", + type: "pubkey", }, { - "name": "bump", - "type": "u8" - } - ] - } - } - ] + name: "bump", + type: "u8", + }, + ], + }, + }, + ], } as const; export default FIGHT_ORACLE_IDL; diff --git a/packages/duel-oracle-solana/src/generated/fightOracleTypes.ts b/packages/duel-oracle-solana/src/generated/fightOracleTypes.ts index 0a34ce085..84f9b546f 100644 --- a/packages/duel-oracle-solana/src/generated/fightOracleTypes.ts +++ b/packages/duel-oracle-solana/src/generated/fightOracleTypes.ts @@ -5,38 +5,29 @@ * IDL can be found at `target/idl/fight_oracle.json`. */ export type FightOracle = { - "address": "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV", - "metadata": { - "name": "fightOracle", - "version": "0.1.0", - "spec": "0.1.0" - }, - "instructions": [ + address: "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV"; + metadata: { + name: "fightOracle"; + version: "0.1.0"; + spec: "0.1.0"; + }; + instructions: [ { - "name": "cancelDuel", - "discriminator": [ - 83, - 124, - 224, - 237, - 235, - 44, - 38, - 57 - ], - "accounts": [ - { - "name": "reporter", - "writable": true, - "signer": true - }, - { - "name": "oracleConfig", - "pda": { - "seeds": [ + name: "cancelDuel"; + discriminator: [83, 124, 224, 237, 235, 44, 38, 57]; + accounts: [ + { + name: "reporter"; + writable: true; + signer: true; + }, + { + name: "oracleConfig"; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -49,76 +40,59 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } + 103, + ]; + }, + ]; + }; }, { - "name": "duelState", - "writable": true, - "pda": { - "seeds": [ + name: "duelState"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ - 100, - 117, - 101, - 108 - ] + kind: "const"; + value: [100, 117, 101, 108]; }, { - "kind": "arg", - "path": "duelKey" - } - ] - } - } - ], - "args": [ - { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "metadataUri", - "type": "string" - } - ] + kind: "arg"; + path: "duelKey"; + }, + ]; + }; + }, + ]; + args: [ + { + name: "duelKey"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "metadataUri"; + type: "string"; + }, + ]; }, { - "name": "initializeOracle", - "discriminator": [ - 144, - 223, - 131, - 120, - 196, - 253, - 181, - 99 - ], - "accounts": [ - { - "name": "authority", - "writable": true, - "signer": true - }, - { - "name": "oracleConfig", - "writable": true, - "pda": { - "seeds": [ + name: "initializeOracle"; + discriminator: [144, 223, 131, 120, 196, 253, 181, 99]; + accounts: [ + { + name: "authority"; + writable: true; + signer: true; + }, + { + name: "oracleConfig"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -131,56 +105,47 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } + 103, + ]; + }, + ]; + }; }, { - "name": "program", - "address": "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV" + name: "program"; + address: "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV"; }, { - "name": "programData" + name: "programData"; }, { - "name": "systemProgram", - "address": "11111111111111111111111111111111" - } - ], - "args": [ + name: "systemProgram"; + address: "11111111111111111111111111111111"; + }, + ]; + args: [ { - "name": "reporter", - "type": "pubkey" - } - ] + name: "reporter"; + type: "pubkey"; + }, + ]; }, { - "name": "reportResult", - "discriminator": [ - 195, - 187, - 161, - 107, - 75, - 154, - 102, - 183 - ], - "accounts": [ - { - "name": "reporter", - "writable": true, - "signer": true - }, - { - "name": "oracleConfig", - "pda": { - "seeds": [ + name: "reportResult"; + discriminator: [195, 187, 161, 107, 75, 154, 102, 183]; + accounts: [ + { + name: "reporter"; + writable: true; + signer: true; + }, + { + name: "oracleConfig"; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -193,109 +158,86 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } + 103, + ]; + }, + ]; + }; }, { - "name": "duelState", - "writable": true, - "pda": { - "seeds": [ + name: "duelState"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ - 100, - 117, - 101, - 108 - ] + kind: "const"; + value: [100, 117, 101, 108]; }, { - "kind": "arg", - "path": "duelKey" - } - ] - } - } - ], - "args": [ - { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "winner", - "type": { - "defined": { - "name": "marketSide" - } - } - }, - { - "name": "seed", - "type": "u64" - }, - { - "name": "replayHash", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "resultHash", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "duelEndTs", - "type": "i64" - }, - { - "name": "metadataUri", - "type": "string" - } - ] + kind: "arg"; + path: "duelKey"; + }, + ]; + }; + }, + ]; + args: [ + { + name: "duelKey"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "winner"; + type: { + defined: { + name: "marketSide"; + }; + }; + }, + { + name: "seed"; + type: "u64"; + }, + { + name: "replayHash"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "resultHash"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "duelEndTs"; + type: "i64"; + }, + { + name: "metadataUri"; + type: "string"; + }, + ]; }, { - "name": "updateOracleConfig", - "discriminator": [ - 83, - 16, - 11, - 254, - 57, - 99, - 156, - 58 - ], - "accounts": [ - { - "name": "authority", - "signer": true - }, - { - "name": "oracleConfig", - "writable": true, - "pda": { - "seeds": [ + name: "updateOracleConfig"; + discriminator: [83, 16, 11, 254, 57, 99, 156, 58]; + accounts: [ + { + name: "authority"; + signer: true; + }, + { + name: "oracleConfig"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -308,49 +250,40 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } - } - ], - "args": [ - { - "name": "authority", - "type": "pubkey" - }, - { - "name": "reporter", - "type": "pubkey" - } - ] + 103, + ]; + }, + ]; + }; + }, + ]; + args: [ + { + name: "authority"; + type: "pubkey"; + }, + { + name: "reporter"; + type: "pubkey"; + }, + ]; }, { - "name": "upsertDuel", - "discriminator": [ - 174, - 7, - 139, - 223, - 70, - 128, - 251, - 128 - ], - "accounts": [ - { - "name": "reporter", - "writable": true, - "signer": true - }, - { - "name": "oracleConfig", - "pda": { - "seeds": [ + name: "upsertDuel"; + discriminator: [174, 7, 139, 223, 70, 128, 251, 128]; + accounts: [ + { + name: "reporter"; + writable: true; + signer: true; + }, + { + name: "oracleConfig"; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -363,493 +296,404 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } + 103, + ]; + }, + ]; + }; }, { - "name": "duelState", - "writable": true, - "pda": { - "seeds": [ + name: "duelState"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ - 100, - 117, - 101, - 108 - ] + kind: "const"; + value: [100, 117, 101, 108]; }, { - "kind": "arg", - "path": "duelKey" - } - ] - } + kind: "arg"; + path: "duelKey"; + }, + ]; + }; }, { - "name": "systemProgram", - "address": "11111111111111111111111111111111" - } - ], - "args": [ + name: "systemProgram"; + address: "11111111111111111111111111111111"; + }, + ]; + args: [ { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duelKey"; + type: { + array: ["u8", 32]; + }; }, { - "name": "participantAHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participantAHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "participantBHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participantBHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "betOpenTs", - "type": "i64" + name: "betOpenTs"; + type: "i64"; }, { - "name": "betCloseTs", - "type": "i64" + name: "betCloseTs"; + type: "i64"; }, { - "name": "duelStartTs", - "type": "i64" + name: "duelStartTs"; + type: "i64"; }, { - "name": "metadataUri", - "type": "string" + name: "metadataUri"; + type: "string"; }, { - "name": "status", - "type": { - "defined": { - "name": "duelStatus" - } - } - } - ] - } - ], - "accounts": [ + name: "status"; + type: { + defined: { + name: "duelStatus"; + }; + }; + }, + ]; + }, + ]; + accounts: [ { - "name": "duelState", - "discriminator": [ - 149, - 213, - 59, - 165, - 124, - 116, - 145, - 120 - ] + name: "duelState"; + discriminator: [149, 213, 59, 165, 124, 116, 145, 120]; }, { - "name": "oracleConfig", - "discriminator": [ - 133, - 196, - 152, - 50, - 27, - 21, - 145, - 254 - ] - } - ], - "events": [ + name: "oracleConfig"; + discriminator: [133, 196, 152, 50, 27, 21, 145, 254]; + }, + ]; + events: [ { - "name": "duelCancelled", - "discriminator": [ - 138, - 79, - 20, - 163, - 207, - 11, - 111, - 213 - ] + name: "duelCancelled"; + discriminator: [138, 79, 20, 163, 207, 11, 111, 213]; }, { - "name": "duelResolved", - "discriminator": [ - 224, - 245, - 214, - 212, - 111, - 151, - 50, - 5 - ] + name: "duelResolved"; + discriminator: [224, 245, 214, 212, 111, 151, 50, 5]; }, { - "name": "duelUpserted", - "discriminator": [ - 37, - 241, - 232, - 195, - 196, - 76, - 240, - 120 - ] - } - ], - "errors": [ + name: "duelUpserted"; + discriminator: [37, 241, 232, 195, 196, 76, 240, 120]; + }, + ]; + errors: [ { - "code": 6000, - "name": "unauthorized", - "msg": "Unauthorized oracle action" + code: 6000; + name: "unauthorized"; + msg: "Unauthorized oracle action"; }, { - "code": 6001, - "name": "unauthorizedInitializer", - "msg": "Only the current upgrade authority can initialize the oracle" + code: 6001; + name: "unauthorizedInitializer"; + msg: "Only the current upgrade authority can initialize the oracle"; }, { - "code": 6002, - "name": "invalidReporter", - "msg": "Reporter pubkey cannot be the default address" + code: 6002; + name: "invalidReporter"; + msg: "Reporter pubkey cannot be the default address"; }, { - "code": 6003, - "name": "invalidAuthority", - "msg": "Authority pubkey cannot be the default address" + code: 6003; + name: "invalidAuthority"; + msg: "Authority pubkey cannot be the default address"; }, { - "code": 6004, - "name": "invalidBetWindow", - "msg": "Betting window is invalid" + code: 6004; + name: "invalidBetWindow"; + msg: "Betting window is invalid"; }, { - "code": 6005, - "name": "invalidParticipants", - "msg": "Participants must be present and distinct" + code: 6005; + name: "invalidParticipants"; + msg: "Participants must be present and distinct"; }, { - "code": 6006, - "name": "invalidLifecycleTransition", - "msg": "Duel lifecycle transition is invalid" + code: 6006; + name: "invalidLifecycleTransition"; + msg: "Duel lifecycle transition is invalid"; }, { - "code": 6007, - "name": "duelKeyMismatch", - "msg": "The provided duel key does not match the stored duel" + code: 6007; + name: "duelKeyMismatch"; + msg: "The provided duel key does not match the stored duel"; }, { - "code": 6008, - "name": "duelAlreadyFinalized", - "msg": "The duel is already finalized" + code: 6008; + name: "duelAlreadyFinalized"; + msg: "The duel is already finalized"; }, { - "code": 6009, - "name": "duelAlreadyCancelled", - "msg": "The duel was cancelled and cannot be resolved" + code: 6009; + name: "duelAlreadyCancelled"; + msg: "The duel was cancelled and cannot be resolved"; }, { - "code": 6010, - "name": "invalidWinner", - "msg": "Winner must be side A or side B" - } - ], - "types": [ + code: 6010; + name: "invalidWinner"; + msg: "Winner must be side A or side B"; + }, + ]; + types: [ { - "name": "duelCancelled", - "type": { - "kind": "struct", - "fields": [ - { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "metadataUri", - "type": "string" - } - ] - } + name: "duelCancelled"; + type: { + kind: "struct"; + fields: [ + { + name: "duelKey"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "metadataUri"; + type: "string"; + }, + ]; + }; }, { - "name": "duelResolved", - "type": { - "kind": "struct", - "fields": [ + name: "duelResolved"; + type: { + kind: "struct"; + fields: [ { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duelKey"; + type: { + array: ["u8", 32]; + }; }, { - "name": "winner", - "type": { - "defined": { - "name": "marketSide" - } - } + name: "winner"; + type: { + defined: { + name: "marketSide"; + }; + }; }, { - "name": "seed", - "type": "u64" + name: "seed"; + type: "u64"; }, { - "name": "duelEndTs", - "type": "i64" + name: "duelEndTs"; + type: "i64"; }, { - "name": "resultHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "resultHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "replayHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "replayHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "metadataUri", - "type": "string" - } - ] - } + name: "metadataUri"; + type: "string"; + }, + ]; + }; }, { - "name": "duelState", - "type": { - "kind": "struct", - "fields": [ + name: "duelState"; + type: { + kind: "struct"; + fields: [ { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duelKey"; + type: { + array: ["u8", 32]; + }; }, { - "name": "participantAHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participantAHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "participantBHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participantBHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "status", - "type": { - "defined": { - "name": "duelStatus" - } - } + name: "status"; + type: { + defined: { + name: "duelStatus"; + }; + }; }, { - "name": "winner", - "type": { - "defined": { - "name": "marketSide" - } - } + name: "winner"; + type: { + defined: { + name: "marketSide"; + }; + }; }, { - "name": "betOpenTs", - "type": "i64" + name: "betOpenTs"; + type: "i64"; }, { - "name": "betCloseTs", - "type": "i64" + name: "betCloseTs"; + type: "i64"; }, { - "name": "duelStartTs", - "type": "i64" + name: "duelStartTs"; + type: "i64"; }, { - "name": "duelEndTs", - "type": "i64" + name: "duelEndTs"; + type: "i64"; }, { - "name": "seed", - "type": "u64" + name: "seed"; + type: "u64"; }, { - "name": "resultHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "resultHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "replayHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "replayHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "metadataUri", - "type": "string" + name: "metadataUri"; + type: "string"; }, { - "name": "bump", - "type": "u8" - } - ] - } + name: "bump"; + type: "u8"; + }, + ]; + }; }, { - "name": "duelStatus", - "type": { - "kind": "enum", - "variants": [ + name: "duelStatus"; + type: { + kind: "enum"; + variants: [ { - "name": "scheduled" + name: "scheduled"; }, { - "name": "bettingOpen" + name: "bettingOpen"; }, { - "name": "locked" + name: "locked"; }, { - "name": "resolved" + name: "resolved"; }, { - "name": "cancelled" - } - ] - } + name: "cancelled"; + }, + ]; + }; }, { - "name": "duelUpserted", - "type": { - "kind": "struct", - "fields": [ + name: "duelUpserted"; + type: { + kind: "struct"; + fields: [ { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duelKey"; + type: { + array: ["u8", 32]; + }; }, { - "name": "status", - "type": { - "defined": { - "name": "duelStatus" - } - } + name: "status"; + type: { + defined: { + name: "duelStatus"; + }; + }; }, { - "name": "betOpenTs", - "type": "i64" + name: "betOpenTs"; + type: "i64"; }, { - "name": "betCloseTs", - "type": "i64" + name: "betCloseTs"; + type: "i64"; }, { - "name": "duelStartTs", - "type": "i64" + name: "duelStartTs"; + type: "i64"; }, { - "name": "metadataUri", - "type": "string" - } - ] - } + name: "metadataUri"; + type: "string"; + }, + ]; + }; }, { - "name": "marketSide", - "type": { - "kind": "enum", - "variants": [ + name: "marketSide"; + type: { + kind: "enum"; + variants: [ { - "name": "none" + name: "none"; }, { - "name": "a" + name: "a"; }, { - "name": "b" - } - ] - } + name: "b"; + }, + ]; + }; }, { - "name": "oracleConfig", - "type": { - "kind": "struct", - "fields": [ + name: "oracleConfig"; + type: { + kind: "struct"; + fields: [ { - "name": "authority", - "type": "pubkey" + name: "authority"; + type: "pubkey"; }, { - "name": "reporter", - "type": "pubkey" + name: "reporter"; + type: "pubkey"; }, { - "name": "bump", - "type": "u8" - } - ] - } - } - ] + name: "bump"; + type: "u8"; + }, + ]; + }; + }, + ]; }; diff --git a/packages/duel-oracle-solana/src/generated/fight_oracle.ts b/packages/duel-oracle-solana/src/generated/fight_oracle.ts index 0a34ce085..84f9b546f 100644 --- a/packages/duel-oracle-solana/src/generated/fight_oracle.ts +++ b/packages/duel-oracle-solana/src/generated/fight_oracle.ts @@ -5,38 +5,29 @@ * IDL can be found at `target/idl/fight_oracle.json`. */ export type FightOracle = { - "address": "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV", - "metadata": { - "name": "fightOracle", - "version": "0.1.0", - "spec": "0.1.0" - }, - "instructions": [ + address: "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV"; + metadata: { + name: "fightOracle"; + version: "0.1.0"; + spec: "0.1.0"; + }; + instructions: [ { - "name": "cancelDuel", - "discriminator": [ - 83, - 124, - 224, - 237, - 235, - 44, - 38, - 57 - ], - "accounts": [ - { - "name": "reporter", - "writable": true, - "signer": true - }, - { - "name": "oracleConfig", - "pda": { - "seeds": [ + name: "cancelDuel"; + discriminator: [83, 124, 224, 237, 235, 44, 38, 57]; + accounts: [ + { + name: "reporter"; + writable: true; + signer: true; + }, + { + name: "oracleConfig"; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -49,76 +40,59 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } + 103, + ]; + }, + ]; + }; }, { - "name": "duelState", - "writable": true, - "pda": { - "seeds": [ + name: "duelState"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ - 100, - 117, - 101, - 108 - ] + kind: "const"; + value: [100, 117, 101, 108]; }, { - "kind": "arg", - "path": "duelKey" - } - ] - } - } - ], - "args": [ - { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "metadataUri", - "type": "string" - } - ] + kind: "arg"; + path: "duelKey"; + }, + ]; + }; + }, + ]; + args: [ + { + name: "duelKey"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "metadataUri"; + type: "string"; + }, + ]; }, { - "name": "initializeOracle", - "discriminator": [ - 144, - 223, - 131, - 120, - 196, - 253, - 181, - 99 - ], - "accounts": [ - { - "name": "authority", - "writable": true, - "signer": true - }, - { - "name": "oracleConfig", - "writable": true, - "pda": { - "seeds": [ + name: "initializeOracle"; + discriminator: [144, 223, 131, 120, 196, 253, 181, 99]; + accounts: [ + { + name: "authority"; + writable: true; + signer: true; + }, + { + name: "oracleConfig"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -131,56 +105,47 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } + 103, + ]; + }, + ]; + }; }, { - "name": "program", - "address": "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV" + name: "program"; + address: "6Tx7s2UG4maFWakRFVi4GeecXJYyBXQF8f2vJdQShSpV"; }, { - "name": "programData" + name: "programData"; }, { - "name": "systemProgram", - "address": "11111111111111111111111111111111" - } - ], - "args": [ + name: "systemProgram"; + address: "11111111111111111111111111111111"; + }, + ]; + args: [ { - "name": "reporter", - "type": "pubkey" - } - ] + name: "reporter"; + type: "pubkey"; + }, + ]; }, { - "name": "reportResult", - "discriminator": [ - 195, - 187, - 161, - 107, - 75, - 154, - 102, - 183 - ], - "accounts": [ - { - "name": "reporter", - "writable": true, - "signer": true - }, - { - "name": "oracleConfig", - "pda": { - "seeds": [ + name: "reportResult"; + discriminator: [195, 187, 161, 107, 75, 154, 102, 183]; + accounts: [ + { + name: "reporter"; + writable: true; + signer: true; + }, + { + name: "oracleConfig"; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -193,109 +158,86 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } + 103, + ]; + }, + ]; + }; }, { - "name": "duelState", - "writable": true, - "pda": { - "seeds": [ + name: "duelState"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ - 100, - 117, - 101, - 108 - ] + kind: "const"; + value: [100, 117, 101, 108]; }, { - "kind": "arg", - "path": "duelKey" - } - ] - } - } - ], - "args": [ - { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "winner", - "type": { - "defined": { - "name": "marketSide" - } - } - }, - { - "name": "seed", - "type": "u64" - }, - { - "name": "replayHash", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "resultHash", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "duelEndTs", - "type": "i64" - }, - { - "name": "metadataUri", - "type": "string" - } - ] + kind: "arg"; + path: "duelKey"; + }, + ]; + }; + }, + ]; + args: [ + { + name: "duelKey"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "winner"; + type: { + defined: { + name: "marketSide"; + }; + }; + }, + { + name: "seed"; + type: "u64"; + }, + { + name: "replayHash"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "resultHash"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "duelEndTs"; + type: "i64"; + }, + { + name: "metadataUri"; + type: "string"; + }, + ]; }, { - "name": "updateOracleConfig", - "discriminator": [ - 83, - 16, - 11, - 254, - 57, - 99, - 156, - 58 - ], - "accounts": [ - { - "name": "authority", - "signer": true - }, - { - "name": "oracleConfig", - "writable": true, - "pda": { - "seeds": [ + name: "updateOracleConfig"; + discriminator: [83, 16, 11, 254, 57, 99, 156, 58]; + accounts: [ + { + name: "authority"; + signer: true; + }, + { + name: "oracleConfig"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -308,49 +250,40 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } - } - ], - "args": [ - { - "name": "authority", - "type": "pubkey" - }, - { - "name": "reporter", - "type": "pubkey" - } - ] + 103, + ]; + }, + ]; + }; + }, + ]; + args: [ + { + name: "authority"; + type: "pubkey"; + }, + { + name: "reporter"; + type: "pubkey"; + }, + ]; }, { - "name": "upsertDuel", - "discriminator": [ - 174, - 7, - 139, - 223, - 70, - 128, - 251, - 128 - ], - "accounts": [ - { - "name": "reporter", - "writable": true, - "signer": true - }, - { - "name": "oracleConfig", - "pda": { - "seeds": [ + name: "upsertDuel"; + discriminator: [174, 7, 139, 223, 70, 128, 251, 128]; + accounts: [ + { + name: "reporter"; + writable: true; + signer: true; + }, + { + name: "oracleConfig"; + pda: { + seeds: [ { - "kind": "const", - "value": [ + kind: "const"; + value: [ 111, 114, 97, @@ -363,493 +296,404 @@ export type FightOracle = { 110, 102, 105, - 103 - ] - } - ] - } + 103, + ]; + }, + ]; + }; }, { - "name": "duelState", - "writable": true, - "pda": { - "seeds": [ + name: "duelState"; + writable: true; + pda: { + seeds: [ { - "kind": "const", - "value": [ - 100, - 117, - 101, - 108 - ] + kind: "const"; + value: [100, 117, 101, 108]; }, { - "kind": "arg", - "path": "duelKey" - } - ] - } + kind: "arg"; + path: "duelKey"; + }, + ]; + }; }, { - "name": "systemProgram", - "address": "11111111111111111111111111111111" - } - ], - "args": [ + name: "systemProgram"; + address: "11111111111111111111111111111111"; + }, + ]; + args: [ { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duelKey"; + type: { + array: ["u8", 32]; + }; }, { - "name": "participantAHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participantAHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "participantBHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participantBHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "betOpenTs", - "type": "i64" + name: "betOpenTs"; + type: "i64"; }, { - "name": "betCloseTs", - "type": "i64" + name: "betCloseTs"; + type: "i64"; }, { - "name": "duelStartTs", - "type": "i64" + name: "duelStartTs"; + type: "i64"; }, { - "name": "metadataUri", - "type": "string" + name: "metadataUri"; + type: "string"; }, { - "name": "status", - "type": { - "defined": { - "name": "duelStatus" - } - } - } - ] - } - ], - "accounts": [ + name: "status"; + type: { + defined: { + name: "duelStatus"; + }; + }; + }, + ]; + }, + ]; + accounts: [ { - "name": "duelState", - "discriminator": [ - 149, - 213, - 59, - 165, - 124, - 116, - 145, - 120 - ] + name: "duelState"; + discriminator: [149, 213, 59, 165, 124, 116, 145, 120]; }, { - "name": "oracleConfig", - "discriminator": [ - 133, - 196, - 152, - 50, - 27, - 21, - 145, - 254 - ] - } - ], - "events": [ + name: "oracleConfig"; + discriminator: [133, 196, 152, 50, 27, 21, 145, 254]; + }, + ]; + events: [ { - "name": "duelCancelled", - "discriminator": [ - 138, - 79, - 20, - 163, - 207, - 11, - 111, - 213 - ] + name: "duelCancelled"; + discriminator: [138, 79, 20, 163, 207, 11, 111, 213]; }, { - "name": "duelResolved", - "discriminator": [ - 224, - 245, - 214, - 212, - 111, - 151, - 50, - 5 - ] + name: "duelResolved"; + discriminator: [224, 245, 214, 212, 111, 151, 50, 5]; }, { - "name": "duelUpserted", - "discriminator": [ - 37, - 241, - 232, - 195, - 196, - 76, - 240, - 120 - ] - } - ], - "errors": [ + name: "duelUpserted"; + discriminator: [37, 241, 232, 195, 196, 76, 240, 120]; + }, + ]; + errors: [ { - "code": 6000, - "name": "unauthorized", - "msg": "Unauthorized oracle action" + code: 6000; + name: "unauthorized"; + msg: "Unauthorized oracle action"; }, { - "code": 6001, - "name": "unauthorizedInitializer", - "msg": "Only the current upgrade authority can initialize the oracle" + code: 6001; + name: "unauthorizedInitializer"; + msg: "Only the current upgrade authority can initialize the oracle"; }, { - "code": 6002, - "name": "invalidReporter", - "msg": "Reporter pubkey cannot be the default address" + code: 6002; + name: "invalidReporter"; + msg: "Reporter pubkey cannot be the default address"; }, { - "code": 6003, - "name": "invalidAuthority", - "msg": "Authority pubkey cannot be the default address" + code: 6003; + name: "invalidAuthority"; + msg: "Authority pubkey cannot be the default address"; }, { - "code": 6004, - "name": "invalidBetWindow", - "msg": "Betting window is invalid" + code: 6004; + name: "invalidBetWindow"; + msg: "Betting window is invalid"; }, { - "code": 6005, - "name": "invalidParticipants", - "msg": "Participants must be present and distinct" + code: 6005; + name: "invalidParticipants"; + msg: "Participants must be present and distinct"; }, { - "code": 6006, - "name": "invalidLifecycleTransition", - "msg": "Duel lifecycle transition is invalid" + code: 6006; + name: "invalidLifecycleTransition"; + msg: "Duel lifecycle transition is invalid"; }, { - "code": 6007, - "name": "duelKeyMismatch", - "msg": "The provided duel key does not match the stored duel" + code: 6007; + name: "duelKeyMismatch"; + msg: "The provided duel key does not match the stored duel"; }, { - "code": 6008, - "name": "duelAlreadyFinalized", - "msg": "The duel is already finalized" + code: 6008; + name: "duelAlreadyFinalized"; + msg: "The duel is already finalized"; }, { - "code": 6009, - "name": "duelAlreadyCancelled", - "msg": "The duel was cancelled and cannot be resolved" + code: 6009; + name: "duelAlreadyCancelled"; + msg: "The duel was cancelled and cannot be resolved"; }, { - "code": 6010, - "name": "invalidWinner", - "msg": "Winner must be side A or side B" - } - ], - "types": [ + code: 6010; + name: "invalidWinner"; + msg: "Winner must be side A or side B"; + }, + ]; + types: [ { - "name": "duelCancelled", - "type": { - "kind": "struct", - "fields": [ - { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } - }, - { - "name": "metadataUri", - "type": "string" - } - ] - } + name: "duelCancelled"; + type: { + kind: "struct"; + fields: [ + { + name: "duelKey"; + type: { + array: ["u8", 32]; + }; + }, + { + name: "metadataUri"; + type: "string"; + }, + ]; + }; }, { - "name": "duelResolved", - "type": { - "kind": "struct", - "fields": [ + name: "duelResolved"; + type: { + kind: "struct"; + fields: [ { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duelKey"; + type: { + array: ["u8", 32]; + }; }, { - "name": "winner", - "type": { - "defined": { - "name": "marketSide" - } - } + name: "winner"; + type: { + defined: { + name: "marketSide"; + }; + }; }, { - "name": "seed", - "type": "u64" + name: "seed"; + type: "u64"; }, { - "name": "duelEndTs", - "type": "i64" + name: "duelEndTs"; + type: "i64"; }, { - "name": "resultHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "resultHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "replayHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "replayHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "metadataUri", - "type": "string" - } - ] - } + name: "metadataUri"; + type: "string"; + }, + ]; + }; }, { - "name": "duelState", - "type": { - "kind": "struct", - "fields": [ + name: "duelState"; + type: { + kind: "struct"; + fields: [ { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duelKey"; + type: { + array: ["u8", 32]; + }; }, { - "name": "participantAHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participantAHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "participantBHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "participantBHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "status", - "type": { - "defined": { - "name": "duelStatus" - } - } + name: "status"; + type: { + defined: { + name: "duelStatus"; + }; + }; }, { - "name": "winner", - "type": { - "defined": { - "name": "marketSide" - } - } + name: "winner"; + type: { + defined: { + name: "marketSide"; + }; + }; }, { - "name": "betOpenTs", - "type": "i64" + name: "betOpenTs"; + type: "i64"; }, { - "name": "betCloseTs", - "type": "i64" + name: "betCloseTs"; + type: "i64"; }, { - "name": "duelStartTs", - "type": "i64" + name: "duelStartTs"; + type: "i64"; }, { - "name": "duelEndTs", - "type": "i64" + name: "duelEndTs"; + type: "i64"; }, { - "name": "seed", - "type": "u64" + name: "seed"; + type: "u64"; }, { - "name": "resultHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "resultHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "replayHash", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "replayHash"; + type: { + array: ["u8", 32]; + }; }, { - "name": "metadataUri", - "type": "string" + name: "metadataUri"; + type: "string"; }, { - "name": "bump", - "type": "u8" - } - ] - } + name: "bump"; + type: "u8"; + }, + ]; + }; }, { - "name": "duelStatus", - "type": { - "kind": "enum", - "variants": [ + name: "duelStatus"; + type: { + kind: "enum"; + variants: [ { - "name": "scheduled" + name: "scheduled"; }, { - "name": "bettingOpen" + name: "bettingOpen"; }, { - "name": "locked" + name: "locked"; }, { - "name": "resolved" + name: "resolved"; }, { - "name": "cancelled" - } - ] - } + name: "cancelled"; + }, + ]; + }; }, { - "name": "duelUpserted", - "type": { - "kind": "struct", - "fields": [ + name: "duelUpserted"; + type: { + kind: "struct"; + fields: [ { - "name": "duelKey", - "type": { - "array": [ - "u8", - 32 - ] - } + name: "duelKey"; + type: { + array: ["u8", 32]; + }; }, { - "name": "status", - "type": { - "defined": { - "name": "duelStatus" - } - } + name: "status"; + type: { + defined: { + name: "duelStatus"; + }; + }; }, { - "name": "betOpenTs", - "type": "i64" + name: "betOpenTs"; + type: "i64"; }, { - "name": "betCloseTs", - "type": "i64" + name: "betCloseTs"; + type: "i64"; }, { - "name": "duelStartTs", - "type": "i64" + name: "duelStartTs"; + type: "i64"; }, { - "name": "metadataUri", - "type": "string" - } - ] - } + name: "metadataUri"; + type: "string"; + }, + ]; + }; }, { - "name": "marketSide", - "type": { - "kind": "enum", - "variants": [ + name: "marketSide"; + type: { + kind: "enum"; + variants: [ { - "name": "none" + name: "none"; }, { - "name": "a" + name: "a"; }, { - "name": "b" - } - ] - } + name: "b"; + }, + ]; + }; }, { - "name": "oracleConfig", - "type": { - "kind": "struct", - "fields": [ + name: "oracleConfig"; + type: { + kind: "struct"; + fields: [ { - "name": "authority", - "type": "pubkey" + name: "authority"; + type: "pubkey"; }, { - "name": "reporter", - "type": "pubkey" + name: "reporter"; + type: "pubkey"; }, { - "name": "bump", - "type": "u8" - } - ] - } - } - ] + name: "bump"; + type: "u8"; + }, + ]; + }; + }, + ]; }; diff --git a/packages/plugin-hyperscape/src/managers/autonomous-behavior-manager.ts b/packages/plugin-hyperscape/src/managers/autonomous-behavior-manager.ts index 8c6daef4a..632d41870 100644 --- a/packages/plugin-hyperscape/src/managers/autonomous-behavior-manager.ts +++ b/packages/plugin-hyperscape/src/managers/autonomous-behavior-manager.ts @@ -1076,14 +1076,6 @@ export class AutonomousBehaviorManager { } } - if ( - this.dedicatedDuelBot && - !this.duelPrepPhase && - this.duelPhase === null - ) { - return; - } - // Periodic state refresh — catch any missed push events (dropped packet, race condition) if ( Date.now() - this.lastStateRefreshTime > @@ -5442,17 +5434,6 @@ export class AutonomousBehaviorManager { return false; } - if ( - (player as PlayerEntity & { inStreamingDuel?: boolean }).inStreamingDuel - ) { - if (this.debug) { - logger.debug( - "[AutonomousBehavior] Player is in streaming duel, skipping autonomous tick", - ); - } - return false; - } - // Only skip if explicitly dead - undefined means alive if (player.alive === false) { if (this.debug) @@ -7183,13 +7164,6 @@ export class AutonomousBehaviorManager { this.duelId = null; this.resetDuelCombatState(); - if (this.dedicatedDuelBot) { - this.currentGoal = null; - this.goalStack.length = 0; - this.nextTickFast = true; - return; - } - // Generate post-duel assessment and adjust strategy const assessment = this.assessDuelOutcome(won, opponentName, player); logger.info( @@ -7244,13 +7218,6 @@ export class AutonomousBehaviorManager { player.inCombat = false; } - if (this.dedicatedDuelBot) { - this.currentGoal = null; - this.goalStack.length = 0; - this.nextTickFast = true; - return; - } - // Restore saved goal const restored = this.popGoal(); if (restored) { diff --git a/packages/plugin-hyperscape/src/services/HyperscapeService.ts b/packages/plugin-hyperscape/src/services/HyperscapeService.ts index 12473fddc..0478f7102 100644 --- a/packages/plugin-hyperscape/src/services/HyperscapeService.ts +++ b/packages/plugin-hyperscape/src/services/HyperscapeService.ts @@ -468,23 +468,9 @@ export class HyperscapeService } private shouldRunOpenWorldAutonomy(): boolean { - if (!this.isDuelBotRuntime()) { - return true; - } - - if ( - getRuntimeSettingBoolean( - this.runtime, - "HYPERSCAPE_ENABLE_DUEL_BOT_AUTONOMY", - ) - ) { - return true; - } - - const envOverride = - process.env.HYPERSCAPE_ENABLE_DUEL_BOT_AUTONOMY || - process.env.DUEL_BOT_AUTONOMY_ENABLED; - return envOverride ? /^(1|true|yes|on)$/i.test(envOverride.trim()) : false; + // Duel bots should perform autonomous activities (mining, chopping, fishing) + // between duels to make the world feel alive + return true; } private isPlayerInStreamingDuel(): boolean { @@ -2382,8 +2368,9 @@ Respond with ONLY the action name, nothing else.`; // Server needs socket.player to be set (which happens during enterWorld) // so this is the earliest safe point to request quests. this.requestQuestList(); - logger.debug( - `[HyperscapeService] 📜 Requested quest list after player spawn`, + this.requestBankState(); + logger.info( + `[HyperscapeService] 📜 Requested quest list + bank state after player spawn`, ); } else if (data && data.id) { // Debug: Log mob entity additions with position info diff --git a/packages/procgen/package.json b/packages/procgen/package.json index 3a251c71a..fa60670de 100644 --- a/packages/procgen/package.json +++ b/packages/procgen/package.json @@ -121,4 +121,4 @@ "engines": { "node": ">=18.0.0" } -} +} \ No newline at end of file diff --git a/packages/procgen/src/building/viewer/BuildingViewer.tsx b/packages/procgen/src/building/viewer/BuildingViewer.tsx index 8f2b16ce6..a9ec9695c 100644 --- a/packages/procgen/src/building/viewer/BuildingViewer.tsx +++ b/packages/procgen/src/building/viewer/BuildingViewer.tsx @@ -40,7 +40,7 @@ import { NavigationVisualizer, type NavigationVisualizerOptions, } from "./NavigationVisualizer"; -import type { TileCoord } from "@hyperscape/shared"; +import type { TileCoord } from "./index"; /** Available material styles for the viewer */ const MATERIAL_OPTIONS: { diff --git a/packages/procgen/src/building/viewer/TownViewer.tsx b/packages/procgen/src/building/viewer/TownViewer.tsx index c29a0fd09..db3877f14 100644 --- a/packages/procgen/src/building/viewer/TownViewer.tsx +++ b/packages/procgen/src/building/viewer/TownViewer.tsx @@ -36,7 +36,7 @@ import { NavigationVisualizer, type NavigationVisualizerOptions, } from "./NavigationVisualizer"; -import type { TileCoord } from "@hyperscape/shared"; +import type { TileCoord } from "./index"; /** Path info for navigation display */ export interface PathInfo { diff --git a/packages/procgen/src/building/viewer/index.ts b/packages/procgen/src/building/viewer/index.ts index 680186e0c..a13f49eff 100644 --- a/packages/procgen/src/building/viewer/index.ts +++ b/packages/procgen/src/building/viewer/index.ts @@ -15,6 +15,11 @@ export { type TownViewerProps, type TownViewerHandle, } from "./TownViewer"; + +export interface TileCoord { + x: number; + z: number; +} export { NavigationVisualizer, type NavigationVisualizerOptions, diff --git a/packages/procgen/src/terrain/BiomeSystem.ts b/packages/procgen/src/terrain/BiomeSystem.ts index 3fcb97121..179498c48 100644 --- a/packages/procgen/src/terrain/BiomeSystem.ts +++ b/packages/procgen/src/terrain/BiomeSystem.ts @@ -14,95 +14,6 @@ import type { BiomeDefinition, } from "./types"; -/** Numeric biome IDs for shader use */ -export const BIOME_IDS: Record = { - plains: 0, - forest: 1, - valley: 2, - mountains: 3, - tundra: 4, - desert: 5, - lakes: 6, - swamp: 7, -}; - -/** Default biome definitions (can be overridden) */ -export const DEFAULT_BIOMES: Record = { - plains: { - id: "plains", - name: "Plains", - color: 0x7cba5f, - terrainMultiplier: 1.0, - difficultyLevel: 0, - heightRange: [0.1, 0.5], - resourceDensity: 1.0, - }, - forest: { - id: "forest", - name: "Forest", - color: 0x2f7d32, - terrainMultiplier: 1.1, - difficultyLevel: 1, - heightRange: [0.2, 0.6], - resourceDensity: 1.5, - }, - valley: { - id: "valley", - name: "Valley", - color: 0x6b8e23, - terrainMultiplier: 0.8, - difficultyLevel: 1, - heightRange: [0.05, 0.3], - resourceDensity: 1.2, - }, - mountains: { - id: "mountains", - name: "Mountains", - color: 0x808080, - terrainMultiplier: 1.5, - difficultyLevel: 3, - heightRange: [0.5, 1.0], - maxSlope: 0.9, - resourceDensity: 0.7, - }, - desert: { - id: "desert", - name: "Desert", - color: 0xdaa520, - terrainMultiplier: 0.9, - difficultyLevel: 2, - heightRange: [0.1, 0.4], - resourceDensity: 0.3, - }, - swamp: { - id: "swamp", - name: "Swamp", - color: 0x556b2f, - terrainMultiplier: 0.7, - difficultyLevel: 2, - heightRange: [0.0, 0.25], - resourceDensity: 0.8, - }, - tundra: { - id: "tundra", - name: "Tundra", - color: 0xb0c4de, - terrainMultiplier: 1.0, - difficultyLevel: 3, - heightRange: [0.3, 0.8], - resourceDensity: 0.4, - }, - lakes: { - id: "lakes", - name: "Lakes", - color: 0x4682b4, - terrainMultiplier: 0.5, - difficultyLevel: 1, - heightRange: [0.0, 0.15], - resourceDensity: 0.5, - }, -}; - /** * Default biome configuration */ @@ -114,31 +25,8 @@ export const DEFAULT_BIOME_CONFIG: BiomeConfig = { gaussianCoeff: 0.15, boundaryNoiseScale: 0.003, boundaryNoiseAmount: 0.15, - mountainHeightThreshold: 0.4, - mountainWeightBoost: 2.0, - valleyHeightThreshold: 0.4, - valleyWeightBoost: 1.5, - mountainHeightBoost: 0.5, }; -/** - * Weighted biome types for random selection - * Plains is dominant with variety from other biomes - */ -const BIOME_TYPE_WEIGHTS = [ - "plains", - "plains", - "plains", - "forest", - "forest", - "valley", - "mountains", - "mountains", - "desert", - "swamp", - "tundra", -]; - /** * BiomeSystem handles biome placement and influence calculations */ @@ -153,7 +41,7 @@ export class BiomeSystem { seed: number, worldSizeMeters: number, config: Partial = {}, - biomeDefinitions: Record = DEFAULT_BIOMES, + biomeDefinitions: Record = {}, ) { this.config = { ...DEFAULT_BIOME_CONFIG, ...config }; this.biomeDefinitions = biomeDefinitions; @@ -161,18 +49,55 @@ export class BiomeSystem { this.worldSize = worldSizeMeters; this.initializeBiomeCenters(seed); + + for (const id of Object.keys(this.biomeDefinitions)) { + this.biomeIds[id] = this.nextBiomeId++; + } + } + + /** + * Compute biome centers arranged in a regular polygon. + * For 3 types = equilateral triangle, 4 = square, etc. + * Generalizes the island-style biome placement for any N. + */ + static computePolygonCenters( + biomeTypes: string[], + radius: number, + influence: number, + ): BiomeCenter[] { + const centers: BiomeCenter[] = []; + for (let i = 0; i < biomeTypes.length; i++) { + const angle = (i / biomeTypes.length) * Math.PI * 2 - Math.PI / 2; + centers.push({ + x: Math.cos(angle) * radius, + z: Math.sin(angle) * radius, + type: biomeTypes[i], + influence, + }); + } + return centers; } /** * Initialize biome centers using deterministic grid-jitter placement */ private initializeBiomeCenters(seed: number): void { + if (this.config.explicitCenters) { + this.biomeCenters = [...this.config.explicitCenters]; + return; + } + const { gridSize, jitter, minInfluence, maxInfluence } = this.config; const cellSize = this.worldSize / gridSize; // Use deterministic PRNG for reproducible biome placement const random = createSeededRNG(seed); + const biomeTypes = Object.keys(this.biomeDefinitions); + if (biomeTypes.length === 0) { + return; + } + this.biomeCenters = []; // Grid-jitter placement for even distribution @@ -189,15 +114,15 @@ export class BiomeSystem { const x = baseX + jitterX; const z = baseZ + jitterZ; - // Random biome type and influence - const typeIndex = Math.floor(random() * BIOME_TYPE_WEIGHTS.length); + // Random biome type from provided definitions and influence + const typeIndex = Math.floor(random() * biomeTypes.length); const influenceRange = maxInfluence - minInfluence; const influence = minInfluence + random() * influenceRange; this.biomeCenters.push({ x, z, - type: BIOME_TYPE_WEIGHTS[typeIndex], + type: biomeTypes[typeIndex], influence, }); } @@ -215,14 +140,20 @@ export class BiomeSystem { * Get biome definition by ID */ getBiomeDefinition(biomeId: string): BiomeDefinition { - return this.biomeDefinitions[biomeId] ?? this.biomeDefinitions["plains"]; - } - - /** - * Get all biome definitions - */ - getAllBiomeDefinitions(): Record { - return this.biomeDefinitions; + const def = this.biomeDefinitions[biomeId]; + if (def) return def; + const keys = Object.keys(this.biomeDefinitions); + return keys.length > 0 + ? this.biomeDefinitions[keys[0]] + : { + id: biomeId, + name: biomeId, + color: 0x808080, + terrainMultiplier: 1, + difficultyLevel: 0, + heightRange: [0, 1], + resourceDensity: 1, + }; } /** @@ -231,22 +162,15 @@ export class BiomeSystem { * * @param worldX - World X coordinate * @param worldZ - World Z coordinate - * @param baseHeight - Normalized base height (0-1) for height-biome coupling + * @param _baseHeight - Reserved for future height-biome coupling (currently unused) */ getBiomeInfluencesAtPosition( worldX: number, worldZ: number, - baseHeight: number, + _baseHeight: number, ): BiomeInfluence[] { - const { - gaussianCoeff, - boundaryNoiseScale, - boundaryNoiseAmount, - mountainHeightThreshold, - mountainWeightBoost, - valleyHeightThreshold, - valleyWeightBoost, - } = this.config; + const { gaussianCoeff, boundaryNoiseScale, boundaryNoiseAmount } = + this.config; // Add boundary noise for organic edges const boundaryNoise = this.noise.simplex2D( @@ -270,24 +194,10 @@ export class BiomeSystem { // Pure Gaussian falloff - NO hard distance cutoff // The gaussian naturally approaches 0 at large distances const normalizedDistance = noisyDistance / center.influence; - let weight = Math.exp( + const weight = Math.exp( -normalizedDistance * normalizedDistance * gaussianCoeff, ); - // Height-based weight adjustments - if (center.type === "mountains" && baseHeight > mountainHeightThreshold) { - const heightFactor = baseHeight - mountainHeightThreshold; - weight *= 1.0 + heightFactor * mountainWeightBoost; - } - - if ( - (center.type === "valley" || center.type === "plains") && - baseHeight < valleyHeightThreshold - ) { - const heightFactor = valleyHeightThreshold - baseHeight; - weight *= 1.0 + heightFactor * valleyWeightBoost; - } - // Merge same-type biomes const existing = biomeWeightMap.get(center.type) ?? 0; biomeWeightMap.set(center.type, existing + weight); @@ -306,8 +216,8 @@ export class BiomeSystem { influence.weight /= totalWeight; } } else { - // Fallback to plains if no biome centers are nearby - biomeInfluences.push({ type: "plains", weight: 1.0 }); + const fallback = Object.keys(this.biomeDefinitions)[0] ?? "unknown"; + biomeInfluences.push({ type: fallback, weight: 1.0 }); } // Sort by weight descending @@ -325,65 +235,30 @@ export class BiomeSystem { worldZ, baseHeight, ); - return influences.length > 0 ? influences[0].type : "plains"; + if (influences.length > 0) return influences[0].type; + const keys = Object.keys(this.biomeDefinitions); + return keys.length > 0 ? keys[0] : "unknown"; } /** * Get the dominant biome for a terrain tile (at tile center) */ getBiomeForTile(tileX: number, tileZ: number, tileSize: number): string { - // Get world coordinates for center of tile - const worldX = tileX * tileSize + tileSize / 2; - const worldZ = tileZ * tileSize + tileSize / 2; - - // Use mid-range height for tile-level biome query - return this.getDominantBiome(worldX, worldZ, 0.5); - } - - /** - * Apply mountain height boost based on biome influence - * Call this after getting base height to add mountain elevation - */ - applyMountainHeightBoost( - worldX: number, - worldZ: number, - baseHeightNormalized: number, - ): number { - const { mountainHeightBoost } = this.config; - - let maxBoost = 0; - - for (const center of this.biomeCenters) { - if (center.type === "mountains") { - const dx = worldX - center.x; - const dz = worldZ - center.z; - const distance = Math.sqrt(dx * dx + dz * dz); - const normalizedDist = distance / center.influence; - - if (normalizedDist < 2.5) { - // Smooth boost that peaks at center and fades out - const boost = Math.exp(-normalizedDist * normalizedDist * 0.3); - maxBoost = Math.max(maxBoost, boost); - } - } - } - - // Apply mountain height boost - const boostedHeight = - baseHeightNormalized * (1 + maxBoost * mountainHeightBoost); - return Math.min(1, boostedHeight); + // Tile geometry is centered at (tileX * tileSize, tileZ * tileSize) + const worldX = tileX * tileSize; + const worldZ = tileZ * tileSize; + return this.getDominantBiome(worldX, worldZ, 0); } - /** @deprecated Use exported BIOME_IDS constant instead */ - static readonly BIOME_IDS = BIOME_IDS; + private biomeIds: Record = {}; + private nextBiomeId = 0; /** Get numeric biome ID for shader use */ getBiomeId(biomeName: string): number { - const id = BIOME_IDS[biomeName]; + const id = this.biomeIds[biomeName]; if (id === undefined) { - throw new Error( - `[BiomeSystem] Unknown biome name: ${biomeName}. Add to BIOME_IDS.`, - ); + this.biomeIds[biomeName] = this.nextBiomeId++; + return this.biomeIds[biomeName]; } return id; } diff --git a/packages/procgen/src/terrain/TerrainGenerator.test.ts b/packages/procgen/src/terrain/TerrainGenerator.test.ts index 7321484f1..7c5c93bef 100644 --- a/packages/procgen/src/terrain/TerrainGenerator.test.ts +++ b/packages/procgen/src/terrain/TerrainGenerator.test.ts @@ -7,7 +7,38 @@ import { describe, it, expect, beforeEach } from "vitest"; import { TerrainGenerator, DEFAULT_TERRAIN_CONFIG } from "./TerrainGenerator"; -import { BiomeSystem, DEFAULT_BIOMES } from "./BiomeSystem"; +import { BiomeSystem } from "./BiomeSystem"; +import type { BiomeDefinition } from "./types"; + +const TEST_BIOMES: Record = { + forest: { + id: "forest", + name: "Forest", + color: 0x2f7d32, + terrainMultiplier: 1.1, + difficultyLevel: 1, + heightRange: [0.2, 0.6], + resourceDensity: 1.5, + }, + tundra: { + id: "tundra", + name: "Tundra", + color: 0xb0c4de, + terrainMultiplier: 1.0, + difficultyLevel: 3, + heightRange: [0.3, 0.8], + resourceDensity: 0.4, + }, + canyon: { + id: "canyon", + name: "Canyon", + color: 0xdaa520, + terrainMultiplier: 0.9, + difficultyLevel: 2, + heightRange: [0.1, 0.4], + resourceDensity: 0.3, + }, +}; import { IslandMask } from "./IslandMask"; import { NoiseGenerator, @@ -20,7 +51,7 @@ describe("TerrainGenerator", () => { let generator: TerrainGenerator; beforeEach(() => { - generator = new TerrainGenerator({ seed: 12345 }); + generator = new TerrainGenerator({ seed: 12345 }, TEST_BIOMES); }); describe("initialization", () => { @@ -174,7 +205,7 @@ describe("TerrainGenerator", () => { it("should return valid biome names", () => { const point = generator.queryPoint(150, 150); - const validBiomes = Object.keys(DEFAULT_BIOMES); + const validBiomes = Object.keys(TEST_BIOMES); expect(validBiomes).toContain(point.biome); }); @@ -247,7 +278,7 @@ describe("BiomeSystem", () => { let biomeSystem: BiomeSystem; beforeEach(() => { - biomeSystem = new BiomeSystem(12345, 10000); // 10km world + biomeSystem = new BiomeSystem(12345, 10000, {}, TEST_BIOMES); }); it("should create biome centers", () => { @@ -262,7 +293,7 @@ describe("BiomeSystem", () => { }); it("should return valid biome types", () => { - const validTypes = Object.keys(DEFAULT_BIOMES); + const validTypes = Object.keys(TEST_BIOMES); const influences = biomeSystem.getBiomeInfluencesAtPosition(500, 500, 0.3); for (const influence of influences) { @@ -272,8 +303,8 @@ describe("BiomeSystem", () => { it("should blend colors correctly", () => { const influences = [ - { type: "plains", weight: 0.5 }, { type: "forest", weight: 0.5 }, + { type: "tundra", weight: 0.5 }, ]; const color = biomeSystem.blendBiomeColors(influences); diff --git a/packages/procgen/src/terrain/TerrainGenerator.ts b/packages/procgen/src/terrain/TerrainGenerator.ts index a10456997..7c5d72634 100644 --- a/packages/procgen/src/terrain/TerrainGenerator.ts +++ b/packages/procgen/src/terrain/TerrainGenerator.ts @@ -9,7 +9,7 @@ */ import { NoiseGenerator, createTileRNG } from "./NoiseGenerator"; -import { BiomeSystem, DEFAULT_BIOMES } from "./BiomeSystem"; +import { BiomeSystem } from "./BiomeSystem"; import { IslandMask, DEFAULT_ISLAND_CONFIG } from "./IslandMask"; import type { TerrainConfig, @@ -88,11 +88,6 @@ export const DEFAULT_TERRAIN_CONFIG: TerrainConfig = { gaussianCoeff: 0.15, boundaryNoiseScale: 0.003, boundaryNoiseAmount: 0.15, - mountainHeightThreshold: 0.4, - mountainWeightBoost: 2.0, - valleyHeightThreshold: 0.4, - valleyWeightBoost: 1.5, - mountainHeightBoost: 0.5, }, island: DEFAULT_ISLAND_CONFIG, shoreline: DEFAULT_SHORELINE_CONFIG, @@ -114,7 +109,7 @@ export class TerrainGenerator { constructor( config: Partial = {}, - biomeDefinitions: Record = DEFAULT_BIOMES, + biomeDefinitions: Record = {}, ) { // Merge with defaults this.config = { @@ -252,25 +247,6 @@ export class TerrainGenerator { return height * maxHeight; } - /** - * Get height with mountain biome boost applied - * @returns Height in meters - */ - getHeightWithBiomeBoost(worldX: number, worldZ: number): number { - const { maxHeight } = this.config; - const baseHeight = this.getBaseHeightAt(worldX, worldZ); - const normalizedBase = baseHeight / maxHeight; - - // Apply mountain biome height boost - const boostedNormalized = this.biomeSystem.applyMountainHeightBoost( - worldX, - worldZ, - normalizedBase, - ); - - return boostedNormalized * maxHeight; - } - /** * Calculate terrain slope at a position */ @@ -281,19 +257,19 @@ export class TerrainGenerator { ): number { const { slopeSampleDistance } = this.config.shoreline; - const northHeight = this.getHeightWithBiomeBoost( + const northHeight = this.getBaseHeightAt( worldX, worldZ + slopeSampleDistance, ); - const southHeight = this.getHeightWithBiomeBoost( + const southHeight = this.getBaseHeightAt( worldX, worldZ - slopeSampleDistance, ); - const eastHeight = this.getHeightWithBiomeBoost( + const eastHeight = this.getBaseHeightAt( worldX + slopeSampleDistance, worldZ, ); - const westHeight = this.getHeightWithBiomeBoost( + const westHeight = this.getBaseHeightAt( worldX - slopeSampleDistance, worldZ, ); @@ -359,7 +335,7 @@ export class TerrainGenerator { const { waterThreshold } = this.config; const { landBand, underwaterBand } = this.config.shoreline; - const baseHeight = this.getHeightWithBiomeBoost(worldX, worldZ); + const baseHeight = this.getBaseHeightAt(worldX, worldZ); // Skip shoreline adjustment if far from water if ( @@ -464,7 +440,7 @@ export class TerrainGenerator { } // Determine dominant biome for the tile - let tileDominantBiome = "plains"; + let tileDominantBiome = "unknown"; let maxWeight = 0; for (const [biome, weight] of biomeWeights) { if (weight > maxWeight) { diff --git a/packages/procgen/src/terrain/index.ts b/packages/procgen/src/terrain/index.ts index a1fd3995a..4c0591302 100644 --- a/packages/procgen/src/terrain/index.ts +++ b/packages/procgen/src/terrain/index.ts @@ -38,12 +38,7 @@ export { } from "./NoiseGenerator"; // Biome system -export { - BiomeSystem, - BIOME_IDS, - DEFAULT_BIOMES, - DEFAULT_BIOME_CONFIG, -} from "./BiomeSystem"; +export { BiomeSystem, DEFAULT_BIOME_CONFIG } from "./BiomeSystem"; // Island mask export { diff --git a/packages/procgen/src/terrain/presets.ts b/packages/procgen/src/terrain/presets.ts index d794cce2b..b8ffa0899 100644 --- a/packages/procgen/src/terrain/presets.ts +++ b/packages/procgen/src/terrain/presets.ts @@ -37,11 +37,6 @@ export const SMALL_ISLAND_PRESET: TerrainPreset = { gaussianCoeff: 0.2, boundaryNoiseScale: 0.005, boundaryNoiseAmount: 0.1, - mountainHeightThreshold: 0.5, - mountainWeightBoost: 1.5, - valleyHeightThreshold: 0.3, - valleyWeightBoost: 1.2, - mountainHeightBoost: 0.3, }, }, }; @@ -145,11 +140,6 @@ export const CONTINENT_PRESET: TerrainPreset = { gaussianCoeff: 0.12, boundaryNoiseScale: 0.002, boundaryNoiseAmount: 0.2, - mountainHeightThreshold: 0.35, - mountainWeightBoost: 2.5, - valleyHeightThreshold: 0.4, - valleyWeightBoost: 1.8, - mountainHeightBoost: 0.6, }, noise: { continent: { @@ -207,11 +197,6 @@ export const MOUNTAIN_RANGE_PRESET: TerrainPreset = { gaussianCoeff: 0.1, boundaryNoiseScale: 0.003, boundaryNoiseAmount: 0.15, - mountainHeightThreshold: 0.3, - mountainWeightBoost: 3.0, // Strong mountain boost - valleyHeightThreshold: 0.5, - valleyWeightBoost: 2.0, - mountainHeightBoost: 0.8, // Very high mountain boost }, noise: { continent: { @@ -269,11 +254,6 @@ export const FLAT_PLAINS_PRESET: TerrainPreset = { gaussianCoeff: 0.18, boundaryNoiseScale: 0.002, boundaryNoiseAmount: 0.1, - mountainHeightThreshold: 0.7, // Mountains rare - mountainWeightBoost: 0.5, - valleyHeightThreshold: 0.5, - valleyWeightBoost: 2.0, // Plains dominant - mountainHeightBoost: 0.2, }, noise: { continent: { diff --git a/packages/procgen/src/terrain/types.ts b/packages/procgen/src/terrain/types.ts index 3491877ab..c146b5865 100644 --- a/packages/procgen/src/terrain/types.ts +++ b/packages/procgen/src/terrain/types.ts @@ -100,13 +100,13 @@ export interface BiomeInfluence { * Configuration for biome generation */ export interface BiomeConfig { - /** Grid size for biome center placement (e.g., 3 = 3x3 grid) */ + /** Grid size for biome center placement (e.g., 3 = 3x3 grid). Ignored when explicitCenters is set. */ gridSize: number; - /** Jitter amount for randomizing positions within grid cells (0-0.5) */ + /** Jitter amount for randomizing positions within grid cells (0-0.5). Ignored when explicitCenters is set. */ jitter: number; - /** Minimum influence radius in meters */ + /** Minimum influence radius in meters. Ignored when explicitCenters is set. */ minInfluence: number; - /** Maximum influence radius in meters */ + /** Maximum influence radius in meters. Ignored when explicitCenters is set. */ maxInfluence: number; /** Gaussian falloff coefficient for influence calculation */ gaussianCoeff: number; @@ -114,16 +114,8 @@ export interface BiomeConfig { boundaryNoiseScale: number; /** Noise amount for boundary variation */ boundaryNoiseAmount: number; - /** Height threshold above which mountains get boosted weight */ - mountainHeightThreshold: number; - /** Weight multiplier for mountains at high elevation */ - mountainWeightBoost: number; - /** Height threshold below which valleys/plains get boosted weight */ - valleyHeightThreshold: number; - /** Weight multiplier for valleys at low elevation */ - valleyWeightBoost: number; - /** Height boost factor for mountain biomes */ - mountainHeightBoost: number; + /** Pre-computed biome centers. When provided, grid-jitter placement is skipped. */ + explicitCenters?: BiomeCenter[]; } // ============== ISLAND CONFIGURATION ============== diff --git a/packages/procgen/tsconfig.json b/packages/procgen/tsconfig.json index 1a68b086c..a1df2f67d 100644 --- a/packages/procgen/tsconfig.json +++ b/packages/procgen/tsconfig.json @@ -47,7 +47,6 @@ "dist", "tests", "viewer", - "src/building/viewer", "**/*.test.ts" ] } \ No newline at end of file diff --git a/packages/server/package.json b/packages/server/package.json index a3f0ee829..94304cc79 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -60,7 +60,7 @@ "@ai-sdk/openai": "^3.0.30", "@elizaos/core": "alpha", "@elizaos/plugin-anthropic": "alpha", - "@elizaos/plugin-elizacloud": "^1.8.0", + "@elizaos/plugin-elizacloud": "alpha", "@elizaos/plugin-groq": "alpha", "@elizaos/plugin-openai": "alpha", "@elizaos/plugin-sql": "alpha", diff --git a/packages/server/scripts/stream-to-rtmp.ts b/packages/server/scripts/stream-to-rtmp.ts index e2d274ab0..5969c8e3d 100644 --- a/packages/server/scripts/stream-to-rtmp.ts +++ b/packages/server/scripts/stream-to-rtmp.ts @@ -26,7 +26,7 @@ * STREAM_CAPTURE_MODE - 'cdp' (default) or 'mediarecorder' (legacy) * STREAM_CAPTURE_HEADLESS - 'true' for headless (default: false for better GPU rendering) * STREAM_CAPTURE_CHANNEL - Browser channel ('chrome', 'msedge', etc.) - * STREAM_CAPTURE_ANGLE - Optional ANGLE backend override ('metal', 'gl', etc.); use 'default' or unset to let Chrome choose + * STREAM_CAPTURE_ANGLE - ANGLE backend (default: metal on macOS, vulkan elsewhere) * STREAM_CDP_QUALITY - JPEG quality for CDP screencast (1-100, default: 80) * STREAM_FPS - Target frames per second (default: 30) * TWITCH_STREAM_KEY / TWITCH_RTMP_STREAM_KEY - Twitch stream key @@ -46,18 +46,13 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { chromium, type Browser, type CDPSession, type Page } from "playwright"; +import { chromium, type Browser, type Page, type CDPSession } from "playwright"; import { getRTMPBridge, startRTMPBridge, generateCaptureScript, generateWebCodecsCaptureScript, } from "../src/streaming/index.js"; -import { - isStreamDestinationEnabled, - resolveEnabledStreamDestinations, -} from "../src/streaming/stream-destinations.js"; -import { resolveStreamingViewerAccessToken } from "../src/streaming/stream-viewer-access-token.js"; import { errMsg } from "../src/shared/errMsg.ts"; import { getStreamLeakDiagnostics } from "../src/streaming/stream-leak-diagnostics.js"; @@ -67,31 +62,27 @@ getStreamLeakDiagnostics(); // ── Configuration ────────────────────────────────────────────────────────── -const GAME_URL = process.env.GAME_URL || "http://localhost:3333/stream.html"; +const GAME_URL = process.env.GAME_URL || "http://localhost:3333/?page=stream"; const GAME_FALLBACK_URLS = ( process.env.GAME_FALLBACK_URLS || - "http://localhost:3333/?page=stream,http://localhost:3333/?embedded=true&mode=spectator,http://localhost:3333/" + "http://localhost:3333/?embedded=true&mode=spectator,http://localhost:3333/" ) .split(",") .map((value) => value.trim()) .filter(Boolean); -const STREAMING_VIEWER_ACCESS_TOKEN = resolveStreamingViewerAccessToken(); +const STREAMING_VIEWER_ACCESS_TOKEN = ( + process.env.STREAMING_VIEWER_ACCESS_TOKEN || "" +).trim(); function withViewerAccessToken(rawUrl: string): string { + if (!STREAMING_VIEWER_ACCESS_TOKEN) return rawUrl; try { const url = new URL(rawUrl); - url.searchParams.set("internalCapture", "1"); - if (STREAMING_VIEWER_ACCESS_TOKEN) { - url.searchParams.set("streamToken", STREAMING_VIEWER_ACCESS_TOKEN); - } + url.searchParams.set("streamToken", STREAMING_VIEWER_ACCESS_TOKEN); return url.toString(); } catch { - if (!STREAMING_VIEWER_ACCESS_TOKEN) { - const separator = rawUrl.includes("?") ? "&" : "?"; - return `${rawUrl}${separator}internalCapture=1`; - } const separator = rawUrl.includes("?") ? "&" : "?"; - return `${rawUrl}${separator}internalCapture=1&streamToken=${encodeURIComponent(STREAMING_VIEWER_ACCESS_TOKEN)}`; + return `${rawUrl}${separator}streamToken=${encodeURIComponent(STREAMING_VIEWER_ACCESS_TOKEN)}`; } } @@ -100,34 +91,34 @@ const GAME_URL_CANDIDATES = Array.from( ); const BRIDGE_PORT = parseInt(process.env.RTMP_BRIDGE_PORT || "8765", 10); -const BRIDGE_URL = `ws://127.0.0.1:${BRIDGE_PORT}`; +const BRIDGE_URL = `ws://localhost:${BRIDGE_PORT}`; const SPECTATOR_PORT = parseInt(process.env.SPECTATOR_PORT || "4180", 10); const EXTERNAL_STATUS_FILE = (process.env.RTMP_STATUS_FILE || "").trim(); -const ENABLED_STREAM_DESTINATIONS = resolveEnabledStreamDestinations( - process.env.STREAM_ENABLED_DESTINATIONS || - process.env.DUEL_STREAM_DESTINATIONS, -); let externalStatusWriteErrored = false; /** Capture mode: 'cdp' (fast) or 'mediarecorder' (legacy) or 'webcodecs' (holy grail) */ +const CAPTURE_MODE = (process.env.STREAM_CAPTURE_MODE?.trim() || "cdp") as + | "cdp" + | "mediarecorder" + | "webcodecs"; const STREAM_CAPTURE_HEADLESS = process.env.STREAM_CAPTURE_HEADLESS === "true"; -const DEFAULT_CAPTURE_MODE = "cdp"; -const CAPTURE_MODE = (process.env.STREAM_CAPTURE_MODE?.trim() || - DEFAULT_CAPTURE_MODE) as "cdp" | "mediarecorder" | "webcodecs"; const requestedCaptureChannel = process.env.STREAM_CAPTURE_CHANNEL?.trim() || ""; const STREAM_CAPTURE_CHANNEL = process.platform === "darwin" && requestedCaptureChannel === "chromium" ? "chrome" : requestedCaptureChannel; -const requestedAngleBackend = process.env.STREAM_CAPTURE_ANGLE?.trim() || ""; const ANGLE_BACKEND = - requestedAngleBackend && requestedAngleBackend.toLowerCase() !== "default" - ? requestedAngleBackend - : process.platform === "darwin" - ? "metal" - : "vulkan"; - + process.env.STREAM_CAPTURE_ANGLE?.trim() || + (process.platform === "darwin" ? "metal" : "vulkan"); +const STREAM_CAPTURE_DISABLE_WEBGPU = /^(1|true|yes|on)$/i.test( + process.env.STREAM_CAPTURE_DISABLE_WEBGPU || "", +); +if (STREAM_CAPTURE_DISABLE_WEBGPU) { + throw new Error( + "STREAM_CAPTURE_DISABLE_WEBGPU is not supported. Hyperscape capture is WebGPU-only.", + ); +} const CDP_QUALITY = Math.min( 100, Math.max(1, parseInt(process.env.STREAM_CDP_QUALITY || "80", 10)), @@ -147,7 +138,7 @@ const STREAM_CAPTURE_POST_NAV_DELAY_MS = Math.max( 0, Number.parseInt( process.env.STREAM_CAPTURE_POST_NAV_DELAY_MS || - (USE_TIMED_STREAM_WARMUP ? "250" : "5000"), + (USE_TIMED_STREAM_WARMUP ? "250" : "5000"), 10, ) || 0, ); @@ -188,6 +179,13 @@ const CAPTURE_RECOVERY_MAX_FAILURES = Math.max( 10, ) || 2, ); +const CDP_STARTUP_TIMEOUT_MS = Math.max( + 5_000, + Number.parseInt( + process.env.STREAM_CAPTURE_START_TIMEOUT_MS || "15_000", + 10, + ) || 15_000, +); // ── CDP Frame Rate Tracking ──────────────────────────────────────────────── @@ -196,10 +194,6 @@ let cdpFps = 0; let cdpFpsIntervalId: ReturnType | null = null; let cdpDroppedFrames = 0; -// Frame pacing: enforce minimum inter-frame interval to match TARGET_FPS -const FRAME_INTERVAL_MS = 1000 / TARGET_FPS; // 33.3ms at 30fps -let lastFrameTime = 0; - function startFpsTracking() { if (cdpFpsIntervalId) clearInterval(cdpFpsIntervalId); cdpFrameCount = 0; @@ -306,39 +300,20 @@ function withTimeout( } function hasConfiguredOutput(): boolean { - const hasTwitchKey = - isStreamDestinationEnabled(ENABLED_STREAM_DESTINATIONS, "twitch") && - Boolean( - process.env.TWITCH_STREAM_KEY || process.env.TWITCH_RTMP_STREAM_KEY, - ); - const hasYoutubeKey = - isStreamDestinationEnabled(ENABLED_STREAM_DESTINATIONS, "youtube") && - Boolean( - process.env.YOUTUBE_STREAM_KEY || process.env.YOUTUBE_RTMP_STREAM_KEY, - ); - const hasKickKey = - isStreamDestinationEnabled(ENABLED_STREAM_DESTINATIONS, "kick") && - Boolean(process.env.KICK_STREAM_KEY); - const hasPumpfunUrl = - isStreamDestinationEnabled(ENABLED_STREAM_DESTINATIONS, "pumpfun") && - Boolean(process.env.PUMPFUN_RTMP_URL); - const hasXUrl = - isStreamDestinationEnabled(ENABLED_STREAM_DESTINATIONS, "x") && - Boolean(process.env.X_RTMP_URL); - const hasMultiplexerUrl = - isStreamDestinationEnabled(ENABLED_STREAM_DESTINATIONS, "multiplexer") && - Boolean(process.env.RTMP_MULTIPLEXER_URL); - const hasCustomDestinations = - isStreamDestinationEnabled(ENABLED_STREAM_DESTINATIONS, "custom") && - Boolean(process.env.RTMP_DESTINATIONS_JSON); + const hasTwitchKey = Boolean( + process.env.TWITCH_STREAM_KEY || process.env.TWITCH_RTMP_STREAM_KEY, + ); + const hasYoutubeKey = Boolean( + process.env.YOUTUBE_STREAM_KEY || process.env.YOUTUBE_RTMP_STREAM_KEY, + ); return Boolean( - hasMultiplexerUrl || + process.env.RTMP_MULTIPLEXER_URL || hasTwitchKey || hasYoutubeKey || - hasKickKey || - hasPumpfunUrl || - hasXUrl || - hasCustomDestinations, + process.env.KICK_STREAM_KEY || + process.env.PUMPFUN_RTMP_URL || + process.env.X_RTMP_URL || + process.env.RTMP_DESTINATIONS_JSON, ); } @@ -348,7 +323,6 @@ async function waitForStreamReadiness( ): Promise { const startedAt = Date.now(); const deadline = startedAt + timeoutMs; - const hardFallbackMs = Math.max(10_000, Math.min(timeoutMs - 5_000, 30_000)); while (Date.now() < deadline) { try { @@ -375,22 +349,14 @@ async function waitForStreamReadiness( return true; } - // Streaming is better than waiting forever for a "perfect" ready state. - // Once a canvas exists, allow a short boot window and then start capture - // even if the loading UI is still present. - if ( - probe.hasCanvas && - (!probe.hasStreamingBootUi || Date.now() - startedAt >= 5_000) - ) { + // Allow capture once we have a canvas and the stream boot/loading UI has + // cleared (the old gate accepted any canvas, which could lock us at 3%). + if (probe.hasCanvas && !probe.hasStreamingBootUi) { return true; } - // Final fallback in case the page never exposes a canvas but the boot UI - // is clearly alive. - if ( - probe.hasStreamingBootUi && - Date.now() - startedAt >= hardFallbackMs - ) { + // Hard fallback after sustained boot-screen presence to avoid deadlock. + if (probe.hasStreamingBootUi && Date.now() - startedAt >= 180_000) { return true; } } catch (err) { @@ -408,20 +374,11 @@ async function waitForStreamReadiness( // ── Browser Launch ───────────────────────────────────────────────────────── async function launchCaptureBrowser() { - // On Linux dual-GPU laptops (Intel iGPU + NVIDIA dGPU), ensure Chrome uses - // the discrete NVIDIA GPU for WebGPU. - if (process.platform === "linux") { - process.env.__NV_PRIME_RENDER_OFFLOAD = "1"; - process.env.__NV_PRIME_RENDER_OFFLOAD_PROVIDER = "NVIDIA-G0"; - process.env.__GLX_VENDOR_LIBRARY_NAME = "nvidia"; - process.env.__VK_LAYER_NV_optimus = "NVIDIA_only"; - process.env.DRI_PRIME = "1"; - } - const featureFlags = "--enable-features=Vulkan,UseSkiaRenderer,WebGPU"; const launchConfig = { headless: STREAM_CAPTURE_HEADLESS, args: [ + // GPU / WebGPU essentials "--use-gl=angle", `--use-angle=${ANGLE_BACKEND}`, "--enable-webgl", @@ -440,7 +397,6 @@ async function launchCaptureBrowser() { "--disable-renderer-backgrounding", "--disable-hang-monitor", ], - ignoreDefaultArgs: ["--enable-unsafe-swiftshader", "--hide-scrollbars"], }; if (STREAM_CAPTURE_CHANNEL) { @@ -491,20 +447,14 @@ async function launchCaptureBrowser() { async function setupBrowser() { if (browser) await cleanup(); - const streamReadyTimeoutMs = Math.max( - 10_000, - Number.parseInt(process.env.STREAM_READY_TIMEOUT_MS || "30000", 10) || - 30_000, - ); - console.log( `[Main] Launching browser (headless=${STREAM_CAPTURE_HEADLESS}, angle=${ANGLE_BACKEND}${STREAM_CAPTURE_CHANNEL ? `, channel=${STREAM_CAPTURE_CHANNEL}` : ""}, mode=${CAPTURE_MODE})...`, ); browser = await launchCaptureBrowser(); + const context = await browser.newContext({ viewport: VIEWPORT, deviceScaleFactor: 1, - serviceWorkers: "block", }); page = await context.newPage(); @@ -562,9 +512,6 @@ async function setupBrowser() { console.log("[Browser]", text); } }); - page.on("pageerror", (err) => { - console.error("[Browser][PageError]", errMsg(err)); - }); if (!selectedGameUrl) { for (const candidateUrl of GAME_URL_CANDIDATES) { @@ -589,7 +536,7 @@ async function setupBrowser() { } console.log(`[Main] Waiting for stream readiness on ${candidateUrl}...`); - const isReady = await waitForStreamReadiness(page, streamReadyTimeoutMs); + const isReady = await waitForStreamReadiness(page, 90_000); if (isReady) { selectedGameUrl = candidateUrl; break; @@ -651,84 +598,40 @@ async function startCdpCapture(bridge: ReturnType) { startFpsTracking(); - // Handle incoming frames from CDP with frame pacing - cdpSession.on("Page.screencastFrame", (params) => { - void (async () => { - try { - const { sessionId, data: base64Data } = params; - - // Acknowledge the frame immediately to request the next one - try { - await cdpSession?.send("Page.screencastFrameAck", { sessionId }); - } catch { - // Session may have been destroyed during page navigation - } + // Handle incoming frames from CDP + cdpSession.on("Page.screencastFrame", async (params) => { + const { sessionId, data: base64Data } = params; - // Frame pacing: skip frames that arrive faster than the target interval. - // This prevents flooding FFmpeg when the compositor runs above TARGET_FPS. - const now = performance.now(); - if (now - lastFrameTime < FRAME_INTERVAL_MS * 0.85) { - // Too soon — drop this frame to maintain cadence - cdpDroppedFrames++; - return; - } - lastFrameTime = now; + // Acknowledge the frame immediately to request the next one + try { + await cdpSession?.send("Page.screencastFrameAck", { sessionId }); + } catch { + // Session may have been destroyed during page navigation + } - // Decode base64 JPEG and feed to FFmpeg - const jpegBuffer = Buffer.from(base64Data, "base64"); - const written = bridge.feedFrame(jpegBuffer); + // Decode base64 JPEG and feed to FFmpeg + const jpegBuffer = Buffer.from(base64Data, "base64"); + const written = bridge.feedFrame(jpegBuffer); - if (written) { - cdpFrameCount++; - } else { - cdpDroppedFrames++; - } - } catch (err) { - if (!isTransientPageEvalError(err)) { - console.warn("[CDP] Frame handling error:", errMsg(err)); - } - } - })(); + if (written) { + cdpFrameCount++; + } else { + cdpDroppedFrames++; + } }); - // Start the screencast — capture every compositor frame. - // Under Xvfb the compositor runs at the game's native FPS (~30fps), so - // everyNthFrame: 1 delivers ~30fps directly. The frame pacing guard above - // handles edge cases where the compositor exceeds TARGET_FPS. - await withTimeout( - cdpSession.send("Page.startScreencast", { - format: "jpeg", - quality: CDP_QUALITY, - maxWidth: VIEWPORT.width, - maxHeight: VIEWPORT.height, - everyNthFrame: 1, // Capture every frame; pacing guard handles excess - }), - 10_000, - "Page.startScreencast", - ); + // Start the screencast + await cdpSession.send("Page.startScreencast", { + format: "jpeg", + quality: CDP_QUALITY, + maxWidth: VIEWPORT.width, + maxHeight: VIEWPORT.height, + everyNthFrame: 1, // Capture every frame + }); console.log("[CDP] ✅ Screencast capture started — frames piping to FFmpeg"); } -async function startCdpCaptureWithRecovery( - bridge: ReturnType, -): Promise { - try { - await startCdpCapture(bridge); - } catch (err) { - if (!isTransientPageEvalError(err)) { - throw err; - } - - console.warn( - `[CDP] Initial screencast setup failed (${errMsg(err)}); retrying with a fresh browser session...`, - ); - await stopCdpCapture().catch(() => undefined); - await setupBrowser(); - await startCdpCaptureWithRecovery(bridge); - } -} - async function stopCdpCapture() { stopFpsTracking(); @@ -751,15 +654,12 @@ async function startLegacyCapture(bridge: ReturnType) { // Start WebSocket bridge for MediaRecorder chunks bridge.start(BRIDGE_PORT); - if ( - !REQUIRE_IN_PAGE_READY_PROBE && - selectedGameUrl?.includes("?page=stream") && - !selectedGameUrl.includes("disableBridgeCapture=1") - ) { + const streamPageMayAlreadyCapture = + !REQUIRE_IN_PAGE_READY_PROBE && selectedGameUrl?.includes("?page=stream"); + if (streamPageMayAlreadyCapture) { console.log( - "[Main] Relying on built-in stream-page bridge capture; skipping Playwright MediaRecorder injection.", + "[Main] Stream page capture bridge may already be active; will inject MediaRecorder only if the in-page bridge is inactive.", ); - return null; } const captureScript = generateCaptureScript({ @@ -1004,106 +904,75 @@ async function main() { // Get bridge instance const bridge = getRTMPBridge(); - let activeCaptureMode: ActiveCaptureMode = CAPTURE_MODE; - let statusSnapshotInterval: ReturnType | null = null; // Start Spectator Server for zero-latency WebSockets stream bridge.startSpectatorServer(SPECTATOR_PORT); - writeExternalStatusSnapshot(bridge, activeCaptureMode); - statusSnapshotInterval = setInterval(() => { - writeExternalStatusSnapshot(bridge, activeCaptureMode); - }, 2000); // Setup browser await setupBrowser(); let captureWatchdog: ReturnType | null = null; + let activeCaptureMode: "cdp" | "webcodecs" | "mediarecorder" = CAPTURE_MODE; let cdpStalledIntervals = 0; let lastCdpBytesReceived = 0; let cdpRecoveryInFlight = false; let cdpRecoveryFailures = 0; - let browserCaptureStalledIntervals = 0; - let lastBrowserBytesReceived = 0; - let browserCaptureRecoveryInFlight = false; - - const fallbackBrowserCaptureToCdp = async ( - reason: string, - ): Promise => { - if (browserCaptureRecoveryInFlight) { - console.warn( - "[Main] Browser capture fallback already in progress; skipping duplicate request.", - ); - return false; - } - - browserCaptureRecoveryInFlight = true; - console.warn(`[Main] ${reason} Falling back to CDP capture.`); - - try { - if (captureWatchdog) { - clearInterval(captureWatchdog); - captureWatchdog = null; - } - await withTimeout( - stopInPageCaptureControl(), - 5_000, - "Stop browser capture control", - ).catch(() => undefined); - bridge.stop(); - bridge.startSpectatorServer(SPECTATOR_PORT); - await withTimeout( - (async () => { - await setupBrowser(); - await startCdpCaptureWithRecovery(bridge); - })(), - CAPTURE_RECOVERY_TIMEOUT_MS, - "Browser capture fallback to CDP", - ); - activeCaptureMode = "cdp"; - cdpStalledIntervals = 0; - cdpRecoveryFailures = 0; - browserCaptureStalledIntervals = 0; - lastBrowserBytesReceived = 0; - lastCdpBytesReceived = bridge.getStats().bytesReceived; - console.log("[Main] Browser capture fallback to CDP complete"); - return true; - } catch (err) { - console.error( - "[Main] Browser capture fallback to CDP failed:", - errMsg(err), - ); - return false; - } finally { - browserCaptureRecoveryInFlight = false; - } - }; if (CAPTURE_MODE === "cdp") { // ── CDP Mode: Direct screencast frame piping ── try { - await startCdpCaptureWithRecovery(bridge); + await withTimeout( + startCdpCapture(bridge), + CDP_STARTUP_TIMEOUT_MS, + "CDP screencast startup", + ); } catch (err) { - const canFallbackToLegacy = - process.platform === "linux" && !STREAM_CAPTURE_HEADLESS; - if (!canFallbackToLegacy) { - throw err; - } console.warn( - `[Main] CDP capture unavailable (${errMsg(err)}). Falling back to MediaRecorder bridge capture.`, + `[Main] CDP startup failed; falling back to MediaRecorder injection: ${errMsg(err)}`, ); - await stopCdpCapture().catch(() => undefined); - bridge.stopProcessing(); + await withTimeout( + stopCdpCapture(), + 5_000, + "Stop failed CDP capture", + ).catch(() => undefined); + bridge.stop(); + bridge.startSpectatorServer(SPECTATOR_PORT); captureWatchdog = (await startLegacyCapture(bridge)) ?? null; activeCaptureMode = "mediarecorder"; + + const healthy = await waitForCaptureTraffic(bridge, 20_000); + if (!healthy) { + console.warn( + "[Main] MediaRecorder fallback produced no media within 20s; trying WebCodecs capture.", + ); + if (captureWatchdog) { + clearInterval(captureWatchdog); + captureWatchdog = null; + } + await stopInPageCaptureControl(); + bridge.stop(); + bridge.startSpectatorServer(SPECTATOR_PORT); + captureWatchdog = (await startWebCodecsCapture(bridge)) ?? null; + activeCaptureMode = "webcodecs"; + } } } else if (CAPTURE_MODE === "webcodecs") { // ── WebCodecs Mode: Native VideoEncoder API to FFmpeg -c:v copy ── captureWatchdog = (await startWebCodecsCapture(bridge)) ?? null; const healthy = await waitForCaptureTraffic(bridge, 20000); if (!healthy) { - await fallbackBrowserCaptureToCdp( - "WebCodecs capture produced no media within 20s.", + console.warn( + "[Main] WebCodecs capture produced no media within 20s; falling back to CDP screencast capture.", ); + if (captureWatchdog) { + clearInterval(captureWatchdog); + captureWatchdog = null; + } + await stopInPageCaptureControl(); + bridge.stop(); + bridge.startSpectatorServer(SPECTATOR_PORT); + await startCdpCapture(bridge); + activeCaptureMode = "cdp"; } } else { // ── Legacy Mode: MediaRecorder + WebSocket ── @@ -1117,6 +986,11 @@ async function main() { console.log("=".repeat(60)); console.log(""); + writeExternalStatusSnapshot(bridge, activeCaptureMode); + const statusSnapshotInterval = setInterval(() => { + writeExternalStatusSnapshot(bridge, activeCaptureMode); + }, 2000); + // Status updates every 30 seconds const statusInterval = setInterval(async () => { const bridgeStatus = bridge.getStatus(); @@ -1182,7 +1056,7 @@ async function main() { (async () => { await stopCdpCapture(); await setupBrowser(); - await startCdpCaptureWithRecovery(bridge); + await startCdpCapture(bridge); })(), CAPTURE_RECOVERY_TIMEOUT_MS, "CDP restart", @@ -1237,9 +1111,6 @@ async function main() { } else { try { const captureStatus = await getBrowserCaptureStatus(); - const bytesDelta = stats.bytesReceived - lastBrowserBytesReceived; - lastBrowserBytesReceived = stats.bytesReceived; - const hasMeaningfulTraffic = bytesDelta > 16 * 1024; if (captureStatus) { console.log("[Status] Capture:", captureStatus); if (typeof captureStatus.captureFps === "number") { @@ -1255,26 +1126,6 @@ async function main() { console.log( `[Stream Health] BridgeDrops: ${stats.droppedFrames} | Backpressure: ${stats.backpressured ? "ON" : "off"}`, ); - - if (activeCaptureMode === "webcodecs") { - const captureLooksConnected = - captureStatus?.recording === true || - captureStatus?.wsConnected === true; - const captureLooksHealthy = - bridgeStatus.ffmpegRunning && hasMeaningfulTraffic; - if (captureLooksHealthy || !captureLooksConnected) { - browserCaptureStalledIntervals = 0; - } else { - browserCaptureStalledIntervals += 1; - } - - if (browserCaptureStalledIntervals >= 2) { - browserCaptureStalledIntervals = 0; - await fallbackBrowserCaptureToCdp( - "WebCodecs capture stalled after startup.", - ); - } - } } catch { console.log("[Status] Capture: unavailable"); } @@ -1304,7 +1155,7 @@ async function main() { } await setupBrowser(); if (activeCaptureMode === "cdp") { - await startCdpCaptureWithRecovery(bridge); + await startCdpCapture(bridge); } else if (activeCaptureMode === "mediarecorder") { captureWatchdog = (await startLegacyCapture(bridge)) ?? null; } @@ -1319,7 +1170,7 @@ async function main() { const shutdown = async () => { console.log("\n[Main] Shutting down..."); if (captureWatchdog) clearInterval(captureWatchdog); - if (statusSnapshotInterval) clearInterval(statusSnapshotInterval); + clearInterval(statusSnapshotInterval); clearInterval(statusInterval); await stopCdpCapture(); getRTMPBridge().stop(); @@ -1346,7 +1197,7 @@ async function main() { process.on("SIGTERM", shutdown); // Keep process alive - await new Promise(() => { }); + await new Promise(() => {}); } async function cleanup() { @@ -1367,12 +1218,6 @@ async function cleanup() { const bridge = getRTMPBridge(); bridge.stopProcessing(); - // Force kill any remaining FFmpeg strings so they don't become zombies - // preventing the next RTMP connection stream from working. - try { - spawnSync("pkill", ["-9", "ffmpeg"]); - } catch { } - if (browser) { await browser.close(); browser = null; diff --git a/packages/server/src/eliza/AgentManager.ts b/packages/server/src/eliza/AgentManager.ts index 80e6a19e0..5ed44e912 100644 --- a/packages/server/src/eliza/AgentManager.ts +++ b/packages/server/src/eliza/AgentManager.ts @@ -185,11 +185,11 @@ export interface HyperscapeService { getNearbyEntities(): Array<{ id: string; harvestSkill?: - | "woodcutting" - | "fishing" - | "mining" - | "firemaking" - | "cooking"; + | "woodcutting" + | "fishing" + | "mining" + | "firemaking" + | "cooking"; resourceType?: string; }>; @@ -586,21 +586,21 @@ export class AgentManager { async loadAgentsFromDatabase(): Promise { const databaseSystem = this.world.getSystem("database") as | { - db: { - select: () => { - from: (table: unknown) => { - where: (condition: unknown) => Promise< - Array<{ - id: string; - accountId: string; - name: string; - isAgent: boolean; - }> - >; + db: { + select: () => { + from: (table: unknown) => { + where: (condition: unknown) => Promise< + Array<{ + id: string; + accountId: string; + name: string; + isAgent: boolean; + }> + >; + }; }; }; - }; - } + } | undefined; if (!databaseSystem?.db) { diff --git a/packages/server/src/eliza/ElizaDuelBot.ts b/packages/server/src/eliza/ElizaDuelBot.ts index e5a4d3564..b78c474d9 100644 --- a/packages/server/src/eliza/ElizaDuelBot.ts +++ b/packages/server/src/eliza/ElizaDuelBot.ts @@ -273,9 +273,11 @@ export class ElizaDuelBot extends EventEmitter { await this.waitForPlayerSpawnReady(this.config.connectTimeoutMs); + // Start autonomous behavior so agents mine/chop/fish between duels const service = this.getHyperscapeService(); - service?.setAutonomousBehaviorEnabled?.(false); - service?.stopAutonomousBehavior?.(); + if (service?.startAutonomousBehavior) { + service.startAutonomousBehavior(); + } this._id = characterId; this._connected = true; diff --git a/packages/server/src/eliza/ModelAgentSpawner.ts b/packages/server/src/eliza/ModelAgentSpawner.ts index 509431ce0..bc52b359c 100644 --- a/packages/server/src/eliza/ModelAgentSpawner.ts +++ b/packages/server/src/eliza/ModelAgentSpawner.ts @@ -47,7 +47,13 @@ function getBunRuntime(): BunRuntime | undefined { */ export interface ModelProviderConfig { /** Provider name (openai, anthropic, groq, xai, elizacloud) */ - provider: "openai" | "anthropic" | "groq" | "xai" | "openrouter" | "elizacloud"; + provider: + | "openai" + | "anthropic" + | "groq" + | "xai" + | "openrouter" + | "elizacloud"; /** Specific model to use */ model: string; /** Display name for the agent */ @@ -64,111 +70,86 @@ export interface ModelProviderConfig { * AI model configurations for agents */ export const MODEL_AGENTS: ModelProviderConfig[] = [ - // ── Frontier American Models ─────────────────────────────────────────── + // Interleaved so slice(0, N) always gets a mix of providers { - provider: "elizacloud", - model: "openai/gpt-5", - displayName: "GPT-5", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", - }, - { - provider: "elizacloud", - model: "anthropic/claude-sonnet-4.6", + provider: "anthropic", + model: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", - }, - { - provider: "elizacloud", - model: "anthropic/claude-opus-4.6", - displayName: "Claude Opus 4.6", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "google/gemini-3.1-pro-preview", - displayName: "Gemini 3.1 Pro", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "groq", + model: "meta-llama/llama-4-scout-17b-16e-instruct", + displayName: "Llama 4 Scout", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, { - provider: "elizacloud", - model: "xai/grok-4", - displayName: "Grok 4", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "anthropic", + model: "claude-opus-4-6", + displayName: "Claude Opus 4.6", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "meta/llama-4-maverick", + provider: "groq", + model: "meta-llama/llama-4-maverick-17b-128e-instruct", displayName: "Llama 4 Maverick", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", - }, - { - provider: "elizacloud", - model: "mistral/magistral-medium", - displayName: "Magistral Medium", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, - // ── Frontier Chinese Models ──────────────────────────────────────────── { - provider: "elizacloud", - model: "deepseek/deepseek-v3.2", - displayName: "DeepSeek V3.2", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "anthropic", + model: "claude-haiku-4-5-20251001", + displayName: "Claude Haiku 4.5", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "alibaba/qwen3-max", - displayName: "Qwen 3 Max", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "groq", + model: "llama-3.3-70b-versatile", + displayName: "Llama 3.3 70B", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, { - provider: "elizacloud", - model: "minimax/minimax-m2.5", - displayName: "Minimax M2.5", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "anthropic", + model: "claude-opus-4-20250514", + displayName: "Claude Opus 4", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "zai/glm-5", - displayName: "GLM-5", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "groq", + model: "moonshotai/kimi-k2-instruct", + displayName: "Kimi K2", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, { - provider: "elizacloud", - model: "moonshotai/kimi-k2.5", - displayName: "Kimi K2.5", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + displayName: "Claude Sonnet 4", + apiKeyEnv: "ANTHROPIC_API_KEY", + pluginModule: "@elizaos/plugin-anthropic", + pluginExport: "anthropicPlugin", }, { - provider: "elizacloud", - model: "bytedance/seed-1.8", - displayName: "Seed 1.8", - apiKeyEnv: "ELIZAOS_CLOUD_API_KEY", - pluginModule: "@elizaos/plugin-elizacloud", - pluginExport: "elizaOSCloudPlugin", + provider: "groq", + model: "qwen/qwen3-32b", + displayName: "Qwen 3 30B", + apiKeyEnv: "GROQ_API_KEY", + pluginModule: "@elizaos/plugin-groq", + pluginExport: "groqPlugin", }, ]; @@ -233,7 +214,9 @@ export async function spawnModelAgents( /** Maximum number of agents to spawn */ maxAgents?: number; /** Specific providers to spawn (if empty, spawns all available) */ - providers?: Array<"openai" | "anthropic" | "groq" | "xai" | "openrouter" | "elizacloud">; + providers?: Array< + "openai" | "anthropic" | "groq" | "xai" | "openrouter" | "elizacloud" + >; } = {}, ): Promise { const { maxAgents = 10, providers = [] } = options; @@ -462,7 +445,7 @@ export async function spawnModelAgents( while (trackedMemoryIds.length >= MAX_MEMORIES) { const oldId = trackedMemoryIds.shift(); if (oldId) { - runtimeInstance.deleteMemory(oldId as UUID).catch(() => { }); + runtimeInstance.deleteMemory(oldId as UUID).catch(() => {}); } } const id = await originalCreateMemory(memory, tableName, unique); @@ -519,8 +502,8 @@ export async function spawnModelAgents( await Promise.race([initPromise, timeoutPromise]); } catch (err) { if (timedOut) { - initPromise.catch(() => { }); - ri.stop().catch(() => { }); + initPromise.catch(() => {}); + ri.stop().catch(() => {}); } throw err; } @@ -543,7 +526,7 @@ export async function spawnModelAgents( if (adapterBeforeInit !== adapterAfterInit) { console.warn( `[ModelAgentSpawner] ${tag} ⚠️ Adapter was SWAPPED during initialize! ` + - `${adapterNameBefore} → ${adapterNameAfter}. Re-asserting InMemoryDatabaseAdapter.`, + `${adapterNameBefore} → ${adapterNameAfter}. Re-asserting InMemoryDatabaseAdapter.`, ); // Re-register our safe adapter (force override) ( @@ -1029,7 +1012,7 @@ async function getOrCreatePlan( agentPlans.set(planKey, plan); return plan; } - } catch { } + } catch {} return null; } @@ -1437,7 +1420,7 @@ async function executeBehaviorTick( try { await executeQueuedAction(service, nextAction, gameState, world); - } catch { } + } catch {} // If plan is exhausted, clear it so next tick re-plans if (plan.actions.length === 0) { diff --git a/packages/server/src/eliza/agentHelpers.ts b/packages/server/src/eliza/agentHelpers.ts index 40654f43e..454468db4 100644 --- a/packages/server/src/eliza/agentHelpers.ts +++ b/packages/server/src/eliza/agentHelpers.ts @@ -174,9 +174,9 @@ export function buildModelSecrets( // Provider-specific overrides ...(keys ? { - [keys.small]: small, - [keys.large]: config.model, - } + [keys.small]: small, + [keys.large]: config.model, + } : {}), }; } diff --git a/packages/server/src/eliza/index.ts b/packages/server/src/eliza/index.ts index 21c3bd88a..4eb6c6918 100644 --- a/packages/server/src/eliza/index.ts +++ b/packages/server/src/eliza/index.ts @@ -85,7 +85,9 @@ interface ServerConfig { /** Maximum number of model agents to spawn */ maxModelAgents?: number; /** Specific providers to spawn (openai, anthropic, groq, xai, elizacloud) */ - modelProviders?: Array<"openai" | "anthropic" | "groq" | "xai" | "openrouter" | "elizacloud">; + modelProviders?: Array< + "openai" | "anthropic" | "groq" | "xai" | "openrouter" | "elizacloud" + >; } /** diff --git a/packages/server/src/eliza/managers/AgentBehaviorTicker.ts b/packages/server/src/eliza/managers/AgentBehaviorTicker.ts index 13f8f2e30..67d5cbcc5 100644 --- a/packages/server/src/eliza/managers/AgentBehaviorTicker.ts +++ b/packages/server/src/eliza/managers/AgentBehaviorTicker.ts @@ -137,7 +137,7 @@ export class AgentBehaviorTicker { characterId: string, ) => AgentInstance | undefined, private readonly getAllAgentIds: () => string[], - ) { } + ) {} // ─── BEHAVIOR LOOP ─────────────────────────────────────────────────── @@ -207,7 +207,7 @@ export class AgentBehaviorTicker { } // Best-effort stop so paused/stopped agents don't keep pathing or attacking. - void instance.service.executeStop().catch(() => { }); + void instance.service.executeStop().catch(() => {}); } /** @@ -598,8 +598,8 @@ export class AgentBehaviorTicker { const itemData = getItem(slot.itemId); const healAmount = itemData ? ((itemData as unknown as Record).healAmount as - | number - | undefined) + | number + | undefined) : undefined; const isFood = healAmount && healAmount > 0; const isWeapon = diff --git a/packages/server/src/routes/streaming.ts b/packages/server/src/routes/streaming.ts index d4efd9894..62d689201 100644 --- a/packages/server/src/routes/streaming.ts +++ b/packages/server/src/routes/streaming.ts @@ -413,7 +413,10 @@ export function registerStreamingRoutes( >["cameraTarget"]; } | null => { if (STREAMING_PUBLIC_DELAY_MS <= 0) { - return scheduler.getStreamingState(); + return { + type: "STREAMING_STATE_UPDATE" as const, + ...scheduler.getStreamingState(), + }; } // Keep delayed replay frames fresh for REST polling consumers @@ -430,6 +433,7 @@ export function registerStreamingRoutes( if (!delayed) return null; return { + type: "STREAMING_STATE_UPDATE" as const, cycle: delayed.cycle as ReturnType< typeof scheduler.getStreamingState >["cycle"], diff --git a/packages/server/src/startup/config.ts b/packages/server/src/startup/config.ts index 1711bb759..8c2c0a769 100644 --- a/packages/server/src/startup/config.ts +++ b/packages/server/src/startup/config.ts @@ -362,7 +362,7 @@ async function fetchManifestsFromCDN( throw new Error( `Missing required manifests in ${manifestsDir}: ${missingRequiredAfter.join(", ")}. ` + - `Ensure your CDN has /manifests populated (PUBLIC_CDN_URL=${cdnUrl}) or run 'bun install' to download assets for local development.`, + `Ensure your CDN has /manifests populated (PUBLIC_CDN_URL=${cdnUrl}) or run 'bun install' to download assets for local development.`, ); } } diff --git a/packages/server/src/startup/http-server.ts b/packages/server/src/startup/http-server.ts index 3302b04d0..46676d663 100644 --- a/packages/server/src/startup/http-server.ts +++ b/packages/server/src/startup/http-server.ts @@ -520,7 +520,9 @@ async function registerStaticFiles( // Always serve manifests from the dedicated cache directory for compatibility. // This guarantees /game-assets/manifests/* works even when game-assets root points // at a directory that doesn't contain the full manifest set. - console.error(`[HTTP DEBUG] Registering /game-assets/manifests/ with root: ${config.manifestsDir}`); + console.error( + `[HTTP DEBUG] Registering /game-assets/manifests/ with root: ${config.manifestsDir}`, + ); await fastify.register(statics, { root: config.manifestsDir, prefix: "/game-assets/manifests/", diff --git a/packages/server/src/startup/routes/admin-routes.ts b/packages/server/src/startup/routes/admin-routes.ts index 044203e7f..e8b2ad41f 100644 --- a/packages/server/src/startup/routes/admin-routes.ts +++ b/packages/server/src/startup/routes/admin-routes.ts @@ -1508,6 +1508,55 @@ export function registerAdminRoutes( } } + // 3. External agents (e.g. duel bots) — connected via WebSocket but + // not tracked by AgentManager or ModelAgentSpawner. Detect them + // from world entities with the isAgent flag or agent- ID prefix. + const detectExternalAgent = ( + entityId: string, + entity: unknown, + requirePlayerType: boolean, + ) => { + if (agentPromises.has(entityId)) return; + const typed = entity as { + type?: string; + name?: string; + data?: { isAgent?: boolean | number; name?: string }; + }; + if ( + requirePlayerType && + typed.type !== "player" && + typed.type !== "Player" + ) + return; + const isAgentEntity = + entityId.startsWith("agent-") || + typed.data?.isAgent === true || + typed.data?.isAgent === 1; + if (!isAgentEntity) return; + agentPromises.set( + entityId, + buildAgentData( + entityId, + typed.name || typed.data?.name || entityId, + "running", + Date.now(), + Date.now(), + ), + ); + }; + // Check players map first (primary) + if (world.entities?.players) { + for (const [entityId, entity] of world.entities.players) { + detectExternalAgent(entityId, entity, false); + } + } + // Fallback: check items map for player-type entities + if (world.entities?.items) { + for (const [entityId, entity] of world.entities.items) { + detectExternalAgent(entityId, entity, true); + } + } + const agents = await Promise.all(agentPromises.values()); return reply.send({ diff --git a/packages/server/src/systems/ServerNetwork/connection-handler.ts b/packages/server/src/systems/ServerNetwork/connection-handler.ts index e0556f298..4040f35c3 100644 --- a/packages/server/src/systems/ServerNetwork/connection-handler.ts +++ b/packages/server/src/systems/ServerNetwork/connection-handler.ts @@ -124,7 +124,7 @@ export class ConnectionHandler { private sockets: Map, private broadcast: BroadcastManager, private db: SystemDatabase, - ) { } + ) {} private isLoopbackWs(ws: NodeWebSocket): boolean { const rawAddress = @@ -713,15 +713,15 @@ export class ConnectionHandler { // Get towns from TownSystem const townSystem = this.world.getSystem("towns") as | { - getTowns?: () => Array<{ - id: string; - name: string; - position: { x: number; y: number; z: number }; - size: string; - biome: string; - buildings: Array<{ type: string }>; - }>; - } + getTowns?: () => Array<{ + id: string; + name: string; + position: { x: number; y: number; z: number }; + size: string; + biome: string; + buildings: Array<{ type: string }>; + }>; + } | undefined; if (townSystem?.getTowns) { @@ -739,14 +739,14 @@ export class ConnectionHandler { // Get POIs from POISystem const poiSystem = this.world.getSystem("pois") as | { - getPOIs?: () => Array<{ - id: string; - name: string; - category: string; - position: { x: number; y: number; z: number }; - biome: string; - }>; - } + getPOIs?: () => Array<{ + id: string; + name: string; + category: string; + position: { x: number; y: number; z: number }; + biome: string; + }>; + } | undefined; if (poiSystem?.getPOIs) { @@ -978,19 +978,19 @@ export class ConnectionHandler { entity as { data?: { position?: - | [number, number, number] - | { x?: number; y?: number; z?: number }; + | [number, number, number] + | { x?: number; y?: number; z?: number }; }; position?: - | [number, number, number] - | { x?: number; y?: number; z?: number }; + | [number, number, number] + | { x?: number; y?: number; z?: number }; } ).data?.position ?? ( entity as { position?: - | [number, number, number] - | { x?: number; y?: number; z?: number }; + | [number, number, number] + | { x?: number; y?: number; z?: number }; } ).position; @@ -1135,10 +1135,10 @@ export class ConnectionHandler { const state = scheduler?.getStreamingState(); const cycle = state?.cycle as | { - phase?: string; - agent1?: { id?: string } | null; - agent2?: { id?: string } | null; - } + phase?: string; + agent1?: { id?: string } | null; + agent2?: { id?: string } | null; + } | undefined; const contestants = [ @@ -1458,13 +1458,13 @@ export class ConnectionHandler { try { const invSystem = this.world.getSystem?.("inventory") as | { - getInventoryData?: (id: string) => { - items: unknown[]; - coins: number; - maxSlots: number; - }; - isInventoryReady?: (id: string) => boolean; - } + getInventoryData?: (id: string) => { + items: unknown[]; + coins: number; + maxSlots: number; + }; + isInventoryReady?: (id: string) => boolean; + } | undefined; // Wait a bit for inventory to be ready if loading diff --git a/packages/server/src/systems/ServerNetwork/index.ts b/packages/server/src/systems/ServerNetwork/index.ts index 1e60a8959..e92701857 100644 --- a/packages/server/src/systems/ServerNetwork/index.ts +++ b/packages/server/src/systems/ServerNetwork/index.ts @@ -117,6 +117,7 @@ import { ActionQueue } from "./action-queue"; import { TickSystem, TickPriority } from "../TickSystem"; import { SocketManager } from "./socket-management"; import { BroadcastManager } from "./broadcast"; +import { PacketPriority } from "./BandwidthBudget"; import { SpatialIndex } from "./SpatialIndex"; import { SaveManager } from "./save-manager"; import { PositionValidator } from "./position-validator"; @@ -413,11 +414,11 @@ export class ServerNetwork extends System implements NetworkWithSocket { urgency: "critical" | "warning" | "safe"; }; decisionPath?: - | "short-circuit" - | "llm" - | "scripted" - | "planner" - | "curiosity"; + | "short-circuit" + | "llm" + | "scripted" + | "planner" + | "curiosity"; providers?: string[]; }> > = new Map(); @@ -801,7 +802,7 @@ export class ServerNetwork extends System implements NetworkWithSocket { const spawnTerrainHeight = terrain.getHeightAt(spawnX, spawnZ); const safeY = typeof spawnTerrainHeight === "number" && - Number.isFinite(spawnTerrainHeight) + Number.isFinite(spawnTerrainHeight) ? spawnTerrainHeight + 0.1 : baseY; @@ -2805,11 +2806,11 @@ export class ServerNetwork extends System implements NetworkWithSocket { const relevantEntities = entity && "position" in entity ? collectInitialSyncEntities( - this.world, - entity.position.x, - entity.position.z, - reconnectedPlayerId, - ) + this.world, + entity.position.x, + entity.position.z, + reconnectedPlayerId, + ) : []; const relevantEntityIds = new Set( relevantEntities.map((entry) => entry.id), @@ -2835,10 +2836,10 @@ export class ServerNetwork extends System implements NetworkWithSocket { // (initial join flow sends this but packets may be lost during socket reconnect) const equipSys = this.world.getSystem?.("equipment") as | { - getPlayerEquipment?: ( - id: string, - ) => Record | undefined; - } + getPlayerEquipment?: ( + id: string, + ) => Record | undefined; + } | undefined; if (equipSys?.getPlayerEquipment && this.world.entities?.items) { for (const [entityId, ent] of this.world.entities.items.entries()) { @@ -3012,6 +3013,24 @@ export class ServerNetwork extends System implements NetworkWithSocket { this.broadcastManager.sendToAll(name, data, ignoreSocketId); } + /** + * Broadcast message with HIGH priority (bypasses bandwidth throttling for + * NORMAL-priority traffic). Use for batched entity spawns that must not be + * silently dropped by the per-connection bandwidth budget. + */ + sendHighPriority( + name: string, + data: T, + ignoreSocketId?: string, + ): void { + this.broadcastManager.sendToAll( + name, + data, + ignoreSocketId, + PacketPriority.HIGH, + ); + } + /** * Broadcast message to spectator sockets only. * @@ -3369,16 +3388,16 @@ export class ServerNetwork extends System implements NetworkWithSocket { const equipmentSystem = this.world.getSystem("equipment") as | { - getPlayerEquipment?: (id: string) => { - weapon?: { - item?: { - attackRange?: number; - attackType?: string; - id?: string; + getPlayerEquipment?: (id: string) => { + weapon?: { + item?: { + attackRange?: number; + attackType?: string; + id?: string; + }; }; - }; - } | null; - } + } | null; + } | undefined; if (equipmentSystem?.getPlayerEquipment) { @@ -3394,7 +3413,7 @@ export class ServerNetwork extends System implements NetworkWithSocket { String(weaponItem.attackType || "").toLowerCase() === "magic" || (weaponItem.id && String(getItem(weaponItem.id)?.attackType || "").toLowerCase() === - "magic"); + "magic"); if (!isMagicWeapon) { // Non-magic weapons use their attackRange (e.g., bows) @@ -3437,15 +3456,15 @@ export class ServerNetwork extends System implements NetworkWithSocket { const equipmentSystem = this.world.getSystem("equipment") as | { - getPlayerEquipment?: (id: string) => { - weapon?: { - item?: { - attackType?: AttackType; - weaponType?: WeaponType; + getPlayerEquipment?: (id: string) => { + weapon?: { + item?: { + attackType?: AttackType; + weaponType?: WeaponType; + }; }; - }; - } | null; - } + } | null; + } | undefined; if (equipmentSystem?.getPlayerEquipment) { diff --git a/packages/server/src/systems/StreamingDuelScheduler/index.ts b/packages/server/src/systems/StreamingDuelScheduler/index.ts index 2b3b7101a..3ad7c8e58 100644 --- a/packages/server/src/systems/StreamingDuelScheduler/index.ts +++ b/packages/server/src/systems/StreamingDuelScheduler/index.ts @@ -2049,7 +2049,11 @@ export class StreamingDuelScheduler { switch (phase) { case "ANNOUNCEMENT": - return phaseStartTime + STREAMING_TIMING.ANNOUNCEMENT_DURATION; + // Use MIN_ANNOUNCEMENT_DURATION for the timer since the phase + // early-exits as soon as both agents are alive (which is almost + // always immediate). ANNOUNCEMENT_DURATION is only a maximum + // fallback and would make the timer misleadingly long. + return phaseStartTime + STREAMING_TIMING.MIN_ANNOUNCEMENT_DURATION; case "COUNTDOWN": return ( this.currentCycle.fightStartTime ?? diff --git a/packages/server/src/systems/StreamingDuelScheduler/managers/CameraDirector.ts b/packages/server/src/systems/StreamingDuelScheduler/managers/CameraDirector.ts index 1c9dd45fd..dc1f4552a 100644 --- a/packages/server/src/systems/StreamingDuelScheduler/managers/CameraDirector.ts +++ b/packages/server/src/systems/StreamingDuelScheduler/managers/CameraDirector.ts @@ -83,9 +83,9 @@ const CAMERA_DIRECTOR = { }, COUNTDOWN: { minHoldMs: 6_000, maxHoldMs: 24_000, idleThresholdMs: 6_000 }, FIGHTING: { - minHoldMs: 28_000, - maxHoldMs: 130_000, - idleThresholdMs: 18_000, + minHoldMs: 12_000, + maxHoldMs: 45_000, + idleThresholdMs: 12_000, }, RESOLUTION: { minHoldMs: 8_000, maxHoldMs: 45_000, idleThresholdMs: 8_000 }, } as Record, diff --git a/packages/server/src/systems/StreamingDuelScheduler/managers/DuelOrchestrator.ts b/packages/server/src/systems/StreamingDuelScheduler/managers/DuelOrchestrator.ts index 8f5c89b4f..9859c9f03 100644 --- a/packages/server/src/systems/StreamingDuelScheduler/managers/DuelOrchestrator.ts +++ b/packages/server/src/systems/StreamingDuelScheduler/managers/DuelOrchestrator.ts @@ -1807,11 +1807,10 @@ export class DuelOrchestrator { return fallback; } - // Keep post-duel restores near the duel lobby area to avoid origin/out-of-map - // drift from stale respawn state or invalid legacy coordinates. - const lobby = getDuelArenaConfig().lobbySpawnPoint; - const distanceFromLobby = Math.hypot(x - lobby.x, z - lobby.z); - if (distanceFromLobby > 120) { + // Only reject positions that are clearly out-of-world (very far from origin). + // Agents should be free to roam the world between duels. + const distFromOrigin = Math.hypot(x, z); + if (distFromOrigin > 2000) { return fallback; } diff --git a/packages/server/tests/unit/oracle/config.test.ts b/packages/server/tests/unit/oracle/config.test.ts index 4072dcd73..e02e764be 100644 --- a/packages/server/tests/unit/oracle/config.test.ts +++ b/packages/server/tests/unit/oracle/config.test.ts @@ -1,23 +1,22 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getDuelArenaOracleConfig } from "../../../src/oracle/config.js"; const ORIGINAL_ENV = { ...process.env }; -/** Strip ALL oracle-related env vars so tests start from a clean slate. */ -function clearOracleEnvVars(): void { - for (const key of Object.keys(process.env)) { +function clearOracleEnv() { + for (const key in process.env) { if (key.startsWith("DUEL_ARENA_ORACLE_")) { - delete process.env[key]; + vi.stubEnv(key, ""); } } } -beforeEach(() => { - clearOracleEnvVars(); +afterEach(() => { + vi.unstubAllEnvs(); }); -afterEach(() => { - process.env = { ...ORIGINAL_ENV }; +beforeEach(() => { + clearOracleEnv(); }); describe("getDuelArenaOracleConfig", () => { diff --git a/packages/shared/src/constants/GameConstants.ts b/packages/shared/src/constants/GameConstants.ts index 43d429ae0..96d7e87b0 100644 --- a/packages/shared/src/constants/GameConstants.ts +++ b/packages/shared/src/constants/GameConstants.ts @@ -7,6 +7,12 @@ // Import COMBAT_CONSTANTS from dedicated file import { COMBAT_CONSTANTS } from "./CombatConstants"; +// Re-export biome enum as the single source of truth +export { + BiomeType, + DEFAULT_BIOME, + BIOME_LIST, +} from "../systems/shared/world/TerrainBiomeTypes"; // Import banking constants - single source of truth for MAX_BANK_SLOTS import { BANKING_CONSTANTS } from "./BankingConstants"; @@ -56,14 +62,10 @@ export const XP_CONSTANTS = { }, } as const; -// === WORLD AND TERRAIN === +// === WORLD AND SPATIAL PARTITIONING === export const WORLD_CONSTANTS = { + /** Spatial partition chunk size for entity registry (meters). */ CHUNK_SIZE: 64, - WORLD_SIZE: 512, - TERRAIN_HEIGHT_SCALE: 20, - SEA_LEVEL: 0, - BIOME_TRANSITION_SMOOTHNESS: 0.1, - RESOURCE_SPAWN_DENSITY: 0.1, } as const; // === TERRAIN CONSTANTS === @@ -79,9 +81,9 @@ export const TERRAIN_CONSTANTS = { /** * Water threshold in world Y units. * Terrain below this height is underwater and impassable. - * Used by: TerrainSystem, VegetationSystem, GPUVegetation, RoadNetworkSystem, ResourceSystem + * Used by: TerrainSystem, VegetationSystem, DissolveMaterial, RoadNetworkSystem, ResourceSystem */ - WATER_THRESHOLD: 9.0, + WATER_THRESHOLD: 8.0, /** * Buffer distance above water where vegetation shouldn't spawn. @@ -92,14 +94,16 @@ export const TERRAIN_CONSTANTS = { /** * Maximum slope for walkable terrain (tan of angle). * Slopes steeper than this block movement. - * 0.7 ≈ 35 degree angle + * 1.5 ≈ 56 degree angle. */ - MAX_WALKABLE_SLOPE: 0.7, + MAX_WALKABLE_SLOPE: 1.5, /** * Distance to sample for slope calculation (in meters). + * Larger values average slope over a wider area, preventing + * terraced cliffs and landscape features from blocking movement. */ - SLOPE_CHECK_DISTANCE: 1.0, + SLOPE_CHECK_DISTANCE: 4.0, /** * Tile size in meters (1 tile = 1 meter for movement grid). @@ -385,15 +389,13 @@ export const ITEM_ID_TO_KEY: Record = { export const MOB_TYPES = {} as const; // === BIOME TYPES === +// Deprecated: use BiomeType enum (re-exported above) instead. +// Kept for backward compat; maps to the same string values. +import { BiomeType as _BT } from "../systems/shared/world/TerrainBiomeTypes"; export const BIOME_TYPES = { - PLAINS: "plains", - FOREST: "forest", - VALLEY: "valley", - MOUNTAINS: "mountains", - TUNDRA: "tundra", - DESERT: "desert", - LAKES: "lakes", - SWAMP: "swamp", + TUNDRA: _BT.Tundra, + FOREST: _BT.Forest, + CANYON: _BT.Canyon, } as const; // === SKILL NAMES === diff --git a/packages/shared/src/constants/TreeTypes.ts b/packages/shared/src/constants/TreeTypes.ts new file mode 100644 index 000000000..33c21e927 --- /dev/null +++ b/packages/shared/src/constants/TreeTypes.ts @@ -0,0 +1,111 @@ +/** + * Tree Types — Single Source of Truth + * + * All tree type definitions live here. Every other file that needs tree type + * information imports from this module instead of defining its own copy. + * + * To add a new tree type: add an entry to TREE_TYPES and TreeId below. + * To rename a tree type: change the key/enum and update the manifest ID. + * To remove a tree type: delete the entry from both. + * + * The manifest (woodcutting.json) must have a matching "id" entry that equals + * the TreeId enum value for each tree type listed here. + * + * Per-biome tree configs (distribution, placement, density) live in + * TerrainBiomeTypes.ts alongside the BiomeType enum. + */ + +/** + * Enum of all tree IDs — values match the manifest resource IDs. + * Use this instead of hardcoded "tree_xxx" strings everywhere. + */ +export enum TreeId { + Fir = "tree_fir", + Pine = "tree_pine", + Oak = "tree_oak", + Birch = "tree_birch", + Bamboo = "tree_bamboo", + ChinaPine = "tree_chinaPine", + Maple = "tree_maple", + Coconut = "tree_coconut", + Palm = "tree_palm", + Dead = "tree_dead", + Cactus = "tree_cactus", + Knotwood = "tree_knotwood", + WindPine = "tree_windPine", +} + +/** Extract the subtype key from a TreeId (e.g. TreeId.Oak → "oak") */ +export function treeIdToSubType(id: string): string { + return id.replace("tree_", ""); +} + +/** Landscape placement rules for a tree type within a biome. */ +export interface TreePlacementRules { + /** + * How strongly this tree prefers water-adjacent placement (0–1). + * 0 = no preference, 1 = only spawns near water. + * At intermediate values, spawn probability scales with water proximity. + */ + waterAffinity?: number; + /** If waterAffinity > 0, the max height above water to consider "near water" */ + waterProximityHeight?: number; + /** Reject placement if position is below this height above water threshold */ + avoidsWaterBelow?: number; + /** Minimum terrain height for spawning (world units) */ + minHeight?: number; + /** Maximum terrain height for spawning (world units) */ + maxHeight?: number; +} + +/** Spawn weight + placement rules combined — used in per-biome tree configs. */ +export interface TreeSpawnConfig extends TreePlacementRules { + /** Relative spawn weight (higher = more likely). */ + weight: number; +} + +export interface TreeTypeDefinition { + /** Display name shown in UI (e.g., "Oak Tree") */ + name: string; + /** Woodcutting level required to chop */ + levelRequired: number; +} + +/** + * Master tree type registry. + * + * Keys are subtypes (e.g., "oak"). Placement rules live in per-biome configs + * in TerrainBiomeTypes.ts. + */ +export const TREE_TYPES = { + fir: { name: "Fir Tree", levelRequired: 1 }, + pine: { name: "Pine Tree", levelRequired: 1 }, + oak: { name: "Oak Tree", levelRequired: 15 }, + birch: { name: "Birch Tree", levelRequired: 1 }, + bamboo: { name: "Bamboo Tree", levelRequired: 1 }, + chinaPine: { name: "China Pine", levelRequired: 1 }, + maple: { name: "Maple Tree", levelRequired: 45 }, + coconut: { name: "Coconut Palm", levelRequired: 1 }, + palm: { name: "Desert Palm", levelRequired: 1 }, + dead: { name: "Dead Tree", levelRequired: 1 }, + cactus: { name: "Cactus", levelRequired: 1 }, + knotwood: { name: "Knotwood Tree", levelRequired: 1 }, + windPine: { name: "Wind Pine", levelRequired: 1 }, +} as const satisfies Record; + +/** All valid tree subtype keys (e.g., "oak", "willow") */ +export type TreeSubType = keyof typeof TREE_TYPES; + +/** All valid tree subtype keys as a runtime array */ +export const TREE_SUBTYPE_KEYS = Object.keys(TREE_TYPES) as TreeSubType[]; + +/** + * Get the level requirement for a tree subtype. + * Returns 1 for unknown types. + */ +export function getTreeLevelRequired(subType: string): number { + return ( + (TREE_TYPES as Record)[subType] + ?.levelRequired ?? 1 + ); +} diff --git a/packages/shared/src/core/World.ts b/packages/shared/src/core/World.ts index 26b885025..8a8ab7fb3 100644 --- a/packages/shared/src/core/World.ts +++ b/packages/shared/src/core/World.ts @@ -622,10 +622,10 @@ export class World extends EventEmitter { /** Action registry for context-based player actions */ actionRegistry?: { - getAll(): Array<{ name: string;[key: string]: unknown }>; + getAll(): Array<{ name: string; [key: string]: unknown }>; getAvailable( context: Record, - ): Array<{ name: string;[key: string]: unknown }>; + ): Array<{ name: string; [key: string]: unknown }>; execute( name: string, context: Record, @@ -634,7 +634,7 @@ export class World extends EventEmitter { }; /** Reference to all registered RPG systems (PlayerSystem, CombatSystem, etc.) */ - rpgSystems?: Record; + rpgSystems?: Record; /** All available RPG actions that can be executed */ rpgActions?: Record< @@ -656,7 +656,7 @@ export class World extends EventEmitter { /** Get RPG player data by ID */ getRPGPlayer?( playerId: string, - ): { id: string;[key: string]: unknown } | undefined; + ): { id: string; [key: string]: unknown } | undefined; /** Save player data to database */ savePlayer?(playerId: string, data: Record): unknown; @@ -721,12 +721,12 @@ export class World extends EventEmitter { /** Get player's inventory contents */ getInventory?( playerId: string, - ): Array<{ itemId: string; quantity: number;[key: string]: unknown }>; + ): Array<{ itemId: string; quantity: number; [key: string]: unknown }>; /** Get player's equipped items */ getEquipment?( playerId: string, - ): Record; + ): Record; /** Check if player has item in inventory */ hasItem?( diff --git a/packages/shared/src/data/DataManager.ts b/packages/shared/src/data/DataManager.ts index bae7486d7..f53c67955 100644 --- a/packages/shared/src/data/DataManager.ts +++ b/packages/shared/src/data/DataManager.ts @@ -176,6 +176,8 @@ export interface ExternalResourceData { * If specified, runtime procedural generation will be used instead of GLB model. */ procgenPreset?: string; + /** Multiple GLB model paths for visual variation (hash-picked per instance) */ + modelVariants?: string[]; scale: number; depletedScale: number; harvestSkill: string; @@ -641,8 +643,8 @@ export class DataManager { if (!foundManifestsDir) { console.warn( `[DataManager] Could not verify manifests path from cwd=${cwd}. ` + - `Tried: ${candidateManifestsDirs.join(", ")}. ` + - `Proceeding with ${manifestsDir}. Set ASSETS_DIR to override.`, + `Tried: ${candidateManifestsDirs.join(", ")}. ` + + `Proceeding with ${manifestsDir}. Set ASSETS_DIR to override.`, ); } @@ -1533,16 +1535,42 @@ export class DataManager { const gatheringDir = path.join(manifestsDir, "gathering"); - // Load woodcutting (trees) + // Local manifests directory (in main codebase, takes priority over git repo) + const localManifestsDir = path.join( + manifestsDir, + "..", + "..", + "..", + "manifests", + "gathering", + ); + + // Load woodcutting (trees) — check local manifest first, then fall back to git repo try { - const woodcuttingPath = path.join(gatheringDir, "woodcutting.json"); - const woodcuttingData = await fs.readFile(woodcuttingPath, "utf-8"); + let woodcuttingData: string | null = null; + let source = ""; + + // Try local manifest first (packages/server/manifests/gathering/woodcutting.json) + try { + const localPath = path.join(localManifestsDir, "woodcutting.json"); + woodcuttingData = await fs.readFile(localPath, "utf-8"); + source = localPath; + } catch { + // Local not found, fall back to git repo manifest + const repoPath = path.join(gatheringDir, "woodcutting.json"); + woodcuttingData = await fs.readFile(repoPath, "utf-8"); + source = repoPath; + } + const woodcuttingManifest = JSON.parse( woodcuttingData, ) as WoodcuttingManifest; for (const tree of woodcuttingManifest.trees) { resourcesMap.set(tree.id, tree); } + console.log( + `[DataManager] ✅ Loaded woodcutting manifest (${woodcuttingManifest.trees.length} trees) from: ${source}`, + ); } catch { console.warn( "[DataManager] gathering/woodcutting.json not found, trying legacy resources.json", diff --git a/packages/shared/src/entities/Entity.ts b/packages/shared/src/entities/Entity.ts index 5bdf32c23..24ea2a10b 100644 --- a/packages/shared/src/entities/Entity.ts +++ b/packages/shared/src/entities/Entity.ts @@ -135,7 +135,8 @@ import { import { getLODConfig, type LODDistancesWithSq, -} from "../systems/shared/world/GPUVegetation"; +} from "../systems/shared/world/LODConfig"; +import { applyRimHighlight } from "../systems/shared/world/GPUMaterials"; // Re-export types for external use export type { EntityConfig }; @@ -218,6 +219,8 @@ export class Entity implements IEntity { protected config: EntityConfig; public mesh: THREE.Mesh | THREE.Group | THREE.Object3D | null = null; + /** Cached per-material highlight uniforms (lazy-initialized on first hover) */ + private _highlightUniforms: Array<{ value: number }> | null = null; public nodes: Map = new Map(); // Child nodes by ID public worldNodes: Set = new Set(); // Nodes added to world public listeners: Record> = {}; // Event listeners @@ -1215,6 +1218,45 @@ export class Entity implements IEntity { return distance <= (this.config.interactionDistance || 5); } + // ============================================================================ + // SHADER HIGHLIGHT (uniform-based, for non-instanced entities) + // ============================================================================ + + /** + * Toggle shader-based Fresnel rim highlight on this entity's mesh. + * On first call the highlight shader is lazily injected into every + * compatible material found under `this.mesh`. Subsequent calls just + * flip the uniform value. + * + * @returns true if highlight was handled, false if no compatible materials + */ + public setShaderHighlight(on: boolean): boolean { + const root = this.mesh ?? this.node; + if (!root) return false; + + if (!this._highlightUniforms) { + this._highlightUniforms = []; + root.traverse((child: THREE.Object3D) => { + if (!(child instanceof THREE.Mesh)) return; + const materials = Array.isArray(child.material) + ? child.material + : [child.material]; + for (const mat of materials) { + const u = applyRimHighlight(mat); + if (u) this._highlightUniforms!.push(u); + } + }); + } + + if (this._highlightUniforms.length === 0) return false; + + const val = on ? 1.0 : 0.0; + for (const u of this._highlightUniforms) { + u.value = val; + } + return true; + } + /** * Check if we're running in a client environment with full browser APIs * This is more robust than just checking world.isClient as it verifies actual Canvas API availability diff --git a/packages/shared/src/entities/npc/MobEntity.ts b/packages/shared/src/entities/npc/MobEntity.ts index b38edaadb..d8dab1efe 100644 --- a/packages/shared/src/entities/npc/MobEntity.ts +++ b/packages/shared/src/entities/npc/MobEntity.ts @@ -926,7 +926,7 @@ export class MobEntity extends CombatantEntity { // PERFORMANCE: Disable raycasting on VRM meshes - use _raycastProxy instead // SkinnedMesh raycast is extremely slow (~700-1800ms) because THREE.js // must transform every vertex by bone weights. The capsule proxy is instant. - child.raycast = () => { }; + child.raycast = () => {}; }); // Apply manifest scale on top of VRM's height normalization @@ -1053,8 +1053,8 @@ export class MobEntity extends CombatantEntity { if (!initialClip) { throw new Error( `[MobEntity] NO CLIPS: ${this.config.mobType}\n` + - ` Dir: ${modelDir}/animations/\n` + - ` Result: idle=${!!animationClips.idle}, walk=${!!animationClips.walk}, run=${!!animationClips.run}`, + ` Dir: ${modelDir}/animations/\n` + + ` Result: idle=${!!animationClips.idle}, walk=${!!animationClips.walk}, run=${!!animationClips.run}`, ); } @@ -1203,7 +1203,7 @@ export class MobEntity extends CombatantEntity { // PERFORMANCE: Set all children to layer 1 (minimap only sees layer 0) child.layers.set(1); // PERFORMANCE: Disable raycasting on GLB meshes - use _raycastProxy instead - child.raycast = () => { }; + child.raycast = () => {}; if (child instanceof THREE.SkinnedMesh && child.skeleton) { // Ensure mesh matrix is updated @@ -1970,18 +1970,18 @@ export class MobEntity extends CombatantEntity { const cameraPos = getCameraPosition(this.world); const animLODResult = cameraPos ? this._animationLOD.updateFromPosition( - this.node.position.x, - this.node.position.z, - cameraPos.x, - cameraPos.z, - deltaTime, - ) + this.node.position.x, + this.node.position.z, + cameraPos.x, + cameraPos.z, + deltaTime, + ) : { - shouldUpdate: true, - effectiveDelta: deltaTime, - lodLevel: 0, - distanceSq: 0, - }; + shouldUpdate: true, + effectiveDelta: deltaTime, + lodLevel: 0, + distanceSq: 0, + }; const isAnimatedImpostor = this.animatedHLODState?.isImpostor === true; // Update health bar position (HealthBars system uses atlas + instanced mesh) @@ -2306,11 +2306,11 @@ export class MobEntity extends CombatantEntity { const mixer = (this as { mixer?: THREE.AnimationMixer }).mixer; throw new Error( `[MobEntity] BONES NOT MOVING: ${this.config.mobType}\n` + - ` Start: [${this.initialBonePosition.toArray().map((v) => v.toFixed(4))}]\n` + - ` Now: [${hipsBone.position.toArray().map((v) => v.toFixed(4))}]\n` + - ` Distance: ${distance.toFixed(6)} (need > 0.001)\n` + - ` Mixer time: ${mixer?.time.toFixed(2) ?? "N/A"}s\n` + - ` Animation runs but doesn't affect bones!`, + ` Start: [${this.initialBonePosition.toArray().map((v) => v.toFixed(4))}]\n` + + ` Now: [${hipsBone.position.toArray().map((v) => v.toFixed(4))}]\n` + + ` Distance: ${distance.toFixed(6)} (need > 0.001)\n` + + ` Mixer time: ${mixer?.time.toFixed(2) ?? "N/A"}s\n` + + ` Animation runs but doesn't affect bones!`, ); } } diff --git a/packages/shared/src/entities/world/AltarEntity.ts b/packages/shared/src/entities/world/AltarEntity.ts index 7952eb71b..f1e938eb0 100644 --- a/packages/shared/src/entities/world/AltarEntity.ts +++ b/packages/shared/src/entities/world/AltarEntity.ts @@ -200,7 +200,7 @@ export class AltarEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -250,7 +250,7 @@ export class AltarEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `Altar_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Offset mesh up so it sits on the ground (BoxGeometry is centered at origin) mesh.position.y = boxHeight / 2; // Set layer for raycasting (required for interaction detection) diff --git a/packages/shared/src/entities/world/AnvilEntity.ts b/packages/shared/src/entities/world/AnvilEntity.ts index 3533f5d7e..1b65abe51 100644 --- a/packages/shared/src/entities/world/AnvilEntity.ts +++ b/packages/shared/src/entities/world/AnvilEntity.ts @@ -202,7 +202,7 @@ export class AnvilEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -249,7 +249,7 @@ export class AnvilEntity extends InteractableEntity { mesh.name = `Anvil_${this.id}`; mesh.position.y = 0.4; // Raise so bottom is at ground level mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Set up userData for interaction detection mesh.userData = { diff --git a/packages/shared/src/entities/world/BankEntity.ts b/packages/shared/src/entities/world/BankEntity.ts index cf6838d47..6959ec261 100644 --- a/packages/shared/src/entities/world/BankEntity.ts +++ b/packages/shared/src/entities/world/BankEntity.ts @@ -188,7 +188,7 @@ export class BankEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -238,7 +238,7 @@ export class BankEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `Bank_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Offset mesh up so it sits on the ground (BoxGeometry is centered at origin) mesh.position.y = boxHeight / 2; this.mesh = mesh; diff --git a/packages/shared/src/entities/world/FurnaceEntity.ts b/packages/shared/src/entities/world/FurnaceEntity.ts index a455e9f1b..12ed931e1 100644 --- a/packages/shared/src/entities/world/FurnaceEntity.ts +++ b/packages/shared/src/entities/world/FurnaceEntity.ts @@ -198,7 +198,7 @@ export class FurnaceEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -245,7 +245,7 @@ export class FurnaceEntity extends InteractableEntity { mesh.name = `Furnace_${this.id}`; mesh.position.y = 0.7; // Raise so bottom is at ground level mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Set up userData for interaction detection mesh.userData = { diff --git a/packages/shared/src/entities/world/HeadstoneEntity.ts b/packages/shared/src/entities/world/HeadstoneEntity.ts index ab6c91587..dd81c36f8 100644 --- a/packages/shared/src/entities/world/HeadstoneEntity.ts +++ b/packages/shared/src/entities/world/HeadstoneEntity.ts @@ -119,7 +119,7 @@ export class HeadstoneEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); } catch (error) { @@ -167,7 +167,7 @@ export class HeadstoneEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `Corpse_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.layers.set(1); this.mesh = mesh; } diff --git a/packages/shared/src/entities/world/RangeEntity.ts b/packages/shared/src/entities/world/RangeEntity.ts index 03dca47ca..119761030 100644 --- a/packages/shared/src/entities/world/RangeEntity.ts +++ b/packages/shared/src/entities/world/RangeEntity.ts @@ -204,7 +204,7 @@ export class RangeEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -253,7 +253,7 @@ export class RangeEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `Range_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; // Offset mesh up so it sits on the ground (BoxGeometry is centered at origin) mesh.position.y = boxHeight / 2; // Set layer for raycasting (required for interaction detection) diff --git a/packages/shared/src/entities/world/ResourceEntity.ts b/packages/shared/src/entities/world/ResourceEntity.ts index ad4c50852..c7444d18d 100644 --- a/packages/shared/src/entities/world/ResourceEntity.ts +++ b/packages/shared/src/entities/world/ResourceEntity.ts @@ -123,15 +123,15 @@ export class ResourceEntity extends InteractableEntity { } /** - * Returns a temporary highlight mesh positioned at this entity's instanced - * location. Used by EntityHighlightService for outline rendering when the - * entity is instanced (no individual scene-graph mesh to outline). + * Shader-based highlight toggle. Returns true if the strategy handled it, + * false if EntityHighlightService should fall back to the outline pass. */ - public getHighlightRoot(): THREE.Object3D | null { - if (typeof this.visual.getHighlightMesh === "function") { - return this.visual.getHighlightMesh(this.getVisualCtx()); + public setShaderHighlight(on: boolean): boolean { + if (typeof this.visual.setShaderHighlight === "function") { + this.visual.setShaderHighlight(this.getVisualCtx(), on); + return true; } - return null; + return super.setShaderHighlight(on); } // =========================================================================== @@ -357,6 +357,7 @@ export class ResourceEntity extends InteractableEntity { depletedModelScale: this.config.depletedModelScale, depletedModelPath: this.config.depletedModelPath, procgenPreset: this.config.procgenPreset, + modelVariants: this.config.modelVariants, } as EntityData; } @@ -376,6 +377,7 @@ export class ResourceEntity extends InteractableEntity { buf.depletedModelScale = this.config.depletedModelScale; buf.depletedModelPath = this.config.depletedModelPath; buf.procgenPreset = this.config.procgenPreset; + buf.modelVariants = this.config.modelVariants; return buf; } diff --git a/packages/shared/src/entities/world/RunecraftingAltarEntity.ts b/packages/shared/src/entities/world/RunecraftingAltarEntity.ts index 966756292..2e2576b9e 100644 --- a/packages/shared/src/entities/world/RunecraftingAltarEntity.ts +++ b/packages/shared/src/entities/world/RunecraftingAltarEntity.ts @@ -199,7 +199,7 @@ export class RunecraftingAltarEntity extends InteractableEntity { child.layers.set(1); if (child instanceof THREE.Mesh) { child.castShadow = true; - child.receiveShadow = true; + child.receiveShadow = false; } }); @@ -242,7 +242,7 @@ export class RunecraftingAltarEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `RunecraftingAltar_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.position.y = boxHeight / 2; mesh.layers.set(1); this.mesh = mesh; diff --git a/packages/shared/src/entities/world/StarterChestEntity.ts b/packages/shared/src/entities/world/StarterChestEntity.ts index e55596fb2..b1861c49c 100644 --- a/packages/shared/src/entities/world/StarterChestEntity.ts +++ b/packages/shared/src/entities/world/StarterChestEntity.ts @@ -224,7 +224,7 @@ export class StarterChestEntity extends InteractableEntity { const mesh = new THREE.Mesh(geometry, material); mesh.name = `StarterChest_${this.id}`; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.position.y = boxHeight / 2; // Add golden trim on top (lid edge) diff --git a/packages/shared/src/entities/world/visuals/InstancedModelVisualStrategy.ts b/packages/shared/src/entities/world/visuals/InstancedModelVisualStrategy.ts index 41983a1b3..cf8c7fdef 100644 --- a/packages/shared/src/entities/world/visuals/InstancedModelVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/InstancedModelVisualStrategy.ts @@ -16,7 +16,7 @@ import { removeInstance as removeResourceInstance, setDepleted as setResourceDepleted, hasDepleted as hasResourceDepleted, - getHighlightMesh as getResourceHighlightMesh, + setHighlight as setResourceHighlight, updateGLBResourceInstancer, } from "../../../systems/shared/world/GLBResourceInstancer"; import type { @@ -111,9 +111,9 @@ export class InstancedModelVisualStrategy implements ResourceVisualStrategy { return hasResourceDepleted(ctx.id); } - getHighlightMesh(ctx: ResourceVisualContext): THREE.Object3D | null { - if (this.fallback) return null; - return getResourceHighlightMesh(ctx.id); + setShaderHighlight(ctx: ResourceVisualContext, on: boolean): void { + if (this.fallback) return; + setResourceHighlight(ctx.id, on); } async onRespawn(ctx: ResourceVisualContext): Promise { diff --git a/packages/shared/src/entities/world/visuals/PlaceholderVisualStrategy.ts b/packages/shared/src/entities/world/visuals/PlaceholderVisualStrategy.ts index ad2ef4608..981cc7991 100644 --- a/packages/shared/src/entities/world/visuals/PlaceholderVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/PlaceholderVisualStrategy.ts @@ -13,6 +13,7 @@ import { addPlaceholderInstance, removePlaceholderInstance, setPlaceholderVisible, + setPlaceholderHighlight, } from "../../../systems/shared/world/PlaceholderInstancer"; import type { ResourceVisualContext, @@ -93,6 +94,12 @@ export class PlaceholderVisualStrategy implements ResourceVisualStrategy { } } + setShaderHighlight(ctx: ResourceVisualContext, on: boolean): void { + if (this.instanced) { + setPlaceholderHighlight(ctx.id, on); + } + } + update(): void {} destroy(ctx: ResourceVisualContext): void { diff --git a/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts b/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts index 5920f7826..58636e66c 100644 --- a/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/ResourceVisualStrategy.ts @@ -55,6 +55,6 @@ export interface ResourceVisualStrategy { update(ctx: ResourceVisualContext, deltaTime: number): void; destroy(ctx: ResourceVisualContext): void; - /** Return a temporary mesh positioned at this instance for the outline pass. */ - getHighlightMesh?(ctx: ResourceVisualContext): THREE.Object3D | null; + /** Shader-based rim highlight: set on/off per instance (no extra meshes). */ + setShaderHighlight?(ctx: ResourceVisualContext, on: boolean): void; } diff --git a/packages/shared/src/entities/world/visuals/StandardModelVisualStrategy.ts b/packages/shared/src/entities/world/visuals/StandardModelVisualStrategy.ts index fd9a8019f..afa230a2e 100644 --- a/packages/shared/src/entities/world/visuals/StandardModelVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/StandardModelVisualStrategy.ts @@ -11,10 +11,10 @@ import { modelCache } from "../../../utils/rendering/ModelCache"; import { createDissolveMaterial, isDissolveMaterial, - getLODDistances, GPU_VEG_CONFIG, type DissolveMaterial, -} from "../../../systems/shared/world/GPUVegetation"; +} from "../../../systems/shared/world/GPUMaterials"; +import { getLODDistances } from "../../../systems/shared/world/LODConfig"; import { getCameraPosition } from "../../../utils/rendering/AnimationLOD"; import type { ResourceVisualContext, @@ -28,8 +28,6 @@ import type { const DEFAULT_RESOURCE_LOD = getLODDistances("resource"); const SKIP_LOD_TYPES = new Set(["herb", "fishing_spot"]); - - // --------------------------------------------------------------------------- // Shared LOD caches (one entry per model path, shared across all entities) // --------------------------------------------------------------------------- diff --git a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts index 8267d7959..0eebd3410 100644 --- a/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/TreeGLBVisualStrategy.ts @@ -1,28 +1,52 @@ /** - * TreeGLBVisualStrategy — GLBTreeInstancer integration for woodcutting trees. + * TreeGLBVisualStrategy — Unified tree visual strategy. * - * Thin wrapper: the instancer owns InstancedMeshes and LOD switching. - * This strategy just calls addInstance / removeInstance / setDepleted. + * Delegates to one of two instancers based on the manifest config: + * - **BatchedMesh** (GLBTreeBatchedInstancer) for trees with `modelVariants` + * — fewer draw calls when many variants share the same material. + * - **InstancedMesh** (GLBTreeInstancer) for trees with a single `model` path. + * + * All other lifecycle methods (depleted, highlight, respawn, destroy) + * dispatch to whichever instancer owns the entity. */ import THREE from "../../../extras/three/three"; import { MeshBasicNodeMaterial } from "three/webgpu"; import { - addInstance as addGLBTreeInstance, - removeInstance as removeGLBTreeInstance, - setDepleted as setGLBTreeDepleted, - hasDepleted as hasGLBTreeDepleted, - getHighlightMesh as getGLBTreeHighlightMesh, + addInstance as addInstancedTree, + removeInstance as removeInstancedTree, + setDepleted as setInstancedDepleted, + hasDepleted as hasInstancedDepleted, + setHighlight as setInstancedHighlight, + getModelDimensions as getInstancedDimensions, + hasInstance as isInInstancedPool, updateGLBTreeInstancer, } from "../../../systems/shared/world/GLBTreeInstancer"; +import { + addInstance as addBatchedTree, + removeInstance as removeBatchedTree, + setDepleted as setBatchedDepleted, + hasDepleted as hasBatchedDepleted, + setHighlight as setBatchedHighlight, + getModelDimensions as getBatchedDimensions, + hasInstance as isInBatchedPool, + updateGLBTreeBatchedInstancer, +} from "../../../systems/shared/world/GLBTreeBatchedInstancer"; import type { ResourceVisualContext, ResourceVisualStrategy, } from "./ResourceVisualStrategy"; -function createCollisionProxy(ctx: ResourceVisualContext, scale: number): void { - const height = 8 * scale; - const radius = 1 * scale; +function createCollisionProxy( + ctx: ResourceVisualContext, + scale: number, + batched: boolean, +): void { + const dims = batched + ? getBatchedDimensions(ctx.id) + : getInstancedDimensions(ctx.id); + const height = (dims?.height ?? 8) * scale; + const radius = (dims?.radius ?? 1) * scale; const geometry = new THREE.CylinderGeometry(radius, radius, height, 6); const material = new MeshBasicNodeMaterial(); material.visible = false; @@ -43,10 +67,13 @@ function createCollisionProxy(ctx: ResourceVisualContext, scale: number): void { ctx.setMesh(proxy); } +function isBatched(entityId: string): boolean { + return isInBatchedPool(entityId); +} + export class TreeGLBVisualStrategy implements ResourceVisualStrategy { async createVisual(ctx: ResourceVisualContext): Promise { const { config, id, position } = ctx; - if (!config.model) return; const baseScale = config.modelScale ?? 3.0; const worldPos = new THREE.Vector3(); @@ -57,39 +84,73 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { ); const rotation = ((rotHash % 1000) / 1000) * Math.PI * 2; - const success = await addGLBTreeInstance( - config.model, - id, - worldPos, - rotation, - baseScale, - config.depletedModelPath ?? null, - config.depletedModelScale ?? 0.3, - config.lod1Model ?? null, - config.lod2Model ?? null, - ); + let success = false; + + if (config.modelVariants?.length) { + const treeType = config.resourceId.replace(/^tree_/, ""); + const hash = ctx.hashString(id) >>> 0; + const variantIndex = hash % config.modelVariants.length; + + success = await addBatchedTree( + treeType, + config.modelVariants, + variantIndex, + id, + worldPos, + rotation, + baseScale, + config.depletedModelPath ?? null, + config.depletedModelScale ?? 0.3, + ); + } else { + let modelPath = config.model; + if (!modelPath) return; + + success = await addInstancedTree( + modelPath, + id, + worldPos, + rotation, + baseScale, + config.depletedModelPath ?? null, + config.depletedModelScale ?? 0.3, + ); + } if (success) { - createCollisionProxy(ctx, baseScale); + createCollisionProxy(ctx, baseScale, !!config.modelVariants?.length); } } async onDepleted(ctx: ResourceVisualContext): Promise { - setGLBTreeDepleted(ctx.id, true); + const b = isBatched(ctx.id); + if (b) { + setBatchedDepleted(ctx.id, true); + } else { + setInstancedDepleted(ctx.id, true); + } const proxy = ctx.getMesh(); if (proxy) { proxy.userData.depleted = true; proxy.userData.interactable = false; } - return hasGLBTreeDepleted(ctx.id); + return b ? hasBatchedDepleted(ctx.id) : hasInstancedDepleted(ctx.id); } - getHighlightMesh(ctx: ResourceVisualContext): THREE.Object3D | null { - return getGLBTreeHighlightMesh(ctx.id); + setShaderHighlight(ctx: ResourceVisualContext, on: boolean): void { + if (isBatched(ctx.id)) { + setBatchedHighlight(ctx.id, on); + } else { + setInstancedHighlight(ctx.id, on); + } } async onRespawn(ctx: ResourceVisualContext): Promise { - setGLBTreeDepleted(ctx.id, false); + if (isBatched(ctx.id)) { + setBatchedDepleted(ctx.id, false); + } else { + setInstancedDepleted(ctx.id, false); + } const proxy = ctx.getMesh(); if (proxy) { proxy.userData.depleted = false; @@ -99,9 +160,14 @@ export class TreeGLBVisualStrategy implements ResourceVisualStrategy { update(): void { updateGLBTreeInstancer(); + updateGLBTreeBatchedInstancer(); } destroy(ctx: ResourceVisualContext): void { - removeGLBTreeInstance(ctx.id); + if (isBatched(ctx.id)) { + removeBatchedTree(ctx.id); + } else { + removeInstancedTree(ctx.id); + } } } diff --git a/packages/shared/src/entities/world/visuals/createVisualStrategy.ts b/packages/shared/src/entities/world/visuals/createVisualStrategy.ts index 4e10dc2f1..445c70380 100644 --- a/packages/shared/src/entities/world/visuals/createVisualStrategy.ts +++ b/packages/shared/src/entities/world/visuals/createVisualStrategy.ts @@ -25,7 +25,10 @@ export function createVisualStrategy( if (config.resourceType === "tree" && config.procgenPreset) return new TreeProcgenVisualStrategy(); - if (config.resourceType === "tree" && hasModel(config)) + if ( + config.resourceType === "tree" && + (config.modelVariants?.length || hasModel(config)) + ) return new TreeGLBVisualStrategy(); // if (hasModel(config)) return new StandardModelVisualStrategy(); diff --git a/packages/shared/src/extras/three/createVRMFactory.ts b/packages/shared/src/extras/three/createVRMFactory.ts index 260e28e9e..9769174ff 100644 --- a/packages/shared/src/extras/three/createVRMFactory.ts +++ b/packages/shared/src/extras/three/createVRMFactory.ts @@ -138,7 +138,7 @@ export function createVRMFactory( glb.scene.traverse((obj) => { if (isMeshLike(obj)) { obj.castShadow = true; - obj.receiveShadow = true; + obj.receiveShadow = false; // Convert materials to MeshStandardMaterial for proper sun/moon/environment lighting const convertMaterial = ( diff --git a/packages/shared/src/nodes/Collider.ts b/packages/shared/src/nodes/Collider.ts index 92b1dded4..94afc294f 100644 --- a/packages/shared/src/nodes/Collider.ts +++ b/packages/shared/src/nodes/Collider.ts @@ -167,6 +167,11 @@ export class Collider extends Node { return; } + // Guard: physics system may be removed in stream/spectator viewports + if (!this.ctx?.physics) { + return; + } + let geometry; let pmesh; if (this._type === "box") { diff --git a/packages/shared/src/nodes/RigidBody.ts b/packages/shared/src/nodes/RigidBody.ts index 830822785..d8ddbb927 100644 --- a/packages/shared/src/nodes/RigidBody.ts +++ b/packages/shared/src/nodes/RigidBody.ts @@ -169,6 +169,11 @@ export class RigidBody extends Node { ) as PxTransform; } + // Guard: physics system may be removed in stream/spectator viewports + if (!this.ctx?.physics) { + return; + } + // Force decompose using temporary plain vectors const plainPos = new THREE.Vector3(); const plainQuat = new THREE.Quaternion(); diff --git a/packages/shared/src/platform/shared/Socket.ts b/packages/shared/src/platform/shared/Socket.ts index 540e96bd3..43a03a12d 100644 --- a/packages/shared/src/platform/shared/Socket.ts +++ b/packages/shared/src/platform/shared/Socket.ts @@ -72,11 +72,11 @@ export class Socket { // If ws is unexpectedly undefined, install a minimal no-op stub to prevent hard crashes if (!this.ws) { this.ws = { - on: () => { }, - ping: () => { }, - terminate: () => { }, - send: () => { }, - close: () => { }, + on: () => {}, + ping: () => {}, + terminate: () => {}, + send: () => {}, + close: () => {}, } as unknown as NodeWebSocket; } diff --git a/packages/shared/src/platform/shared/packets.ts b/packages/shared/src/platform/shared/packets.ts index f07deb2d4..58e317254 100644 --- a/packages/shared/src/platform/shared/packets.ts +++ b/packages/shared/src/platform/shared/packets.ts @@ -418,6 +418,8 @@ const names = [ 'combatEnded', // Server -> Client: PvE combat ended (target dead or disengaged) // Application-level keepalive (prevents Cloudflare/proxy WebSocket idle timeout) 'keepalive', // Client -> Server: periodic heartbeat to keep proxy connection alive + // Batched entity spawn (reduces per-entity bandwidth overhead for tile generation) + 'entitiesBatchAdded', // Server -> Client: batch of newly spawned entities for a tile ] const byName: Record = {}; diff --git a/packages/shared/src/runtime/clientViewportMode.ts b/packages/shared/src/runtime/clientViewportMode.ts index 618f5f700..7ff4d97df 100644 --- a/packages/shared/src/runtime/clientViewportMode.ts +++ b/packages/shared/src/runtime/clientViewportMode.ts @@ -16,9 +16,7 @@ function parseTruthy(value: string | null | undefined): boolean { ); } -function getWindowRef( - win?: Window, -): HyperscapeViewportWindow | undefined { +function getWindowRef(win?: Window): HyperscapeViewportWindow | undefined { if (typeof window === "undefined") return undefined; return (win ?? window) as HyperscapeViewportWindow; } @@ -59,8 +57,7 @@ export function isEmbeddedSpectatorViewport(win?: Window): boolean { windowRef.__HYPERSCAPE_CONFIG__?.mode === "spectator"; return ( - (embeddedFromQuery && modeFromQuery === "spectator") || - embeddedFromConfig + (embeddedFromQuery && modeFromQuery === "spectator") || embeddedFromConfig ); } diff --git a/packages/shared/src/runtime/createClientWorld.ts b/packages/shared/src/runtime/createClientWorld.ts index 1b42d1903..b2727e0dd 100644 --- a/packages/shared/src/runtime/createClientWorld.ts +++ b/packages/shared/src/runtime/createClientWorld.ts @@ -88,6 +88,10 @@ import { initGLBTreeInstancer, destroyGLBTreeInstancer, } from "../systems/shared/world/GLBTreeInstancer"; +import { + initGLBTreeBatchedInstancer, + destroyGLBTreeBatchedInstancer, +} from "../systems/shared/world/GLBTreeBatchedInstancer"; import { initPlaceholderInstancer, destroyPlaceholderInstancer, @@ -200,6 +204,7 @@ export function createClientWorld() { // Clean up any previous instancer state from prior world destroyGLBTreeInstancer(); + destroyGLBTreeBatchedInstancer(); destroyPlaceholderInstancer(); destroyGLBResourceInstancer(); @@ -257,6 +262,12 @@ export function createClientWorld() { // browser. RigidBody/Collider nodes already guard against missing PHYSX. if (!isStreamingLikeViewport()) { replaceSystem(world, "physics", Physics); + } else { + // The World constructor registers a default PhysicsSystem whose init() + // calls waitForPhysX() with a 120s timeout. If we leave it registered + // the entire init chain stalls waiting for PhysX WASM that will never + // arrive. Remove it so the init pipeline proceeds immediately. + removeSystem(world, "physics"); } // Interaction system - handles clicks, raycasting, context menus @@ -301,7 +312,7 @@ export function createClientWorld() { // ============================================================================ // Procedural building mesh rendering for towns // Must be registered after towns system as it depends on town data - world.register("building-rendering", BuildingRenderingSystem); + // world.register("building-rendering", BuildingRenderingSystem); // ============================================================================ // TOWN LANDMARKS SYSTEM @@ -363,6 +374,10 @@ export function createClientWorld() { if (stageSystem && stageSystem.scene) { stageSystem.THREE = THREE as unknown as StageSystem["THREE"]; initGLBTreeInstancer(stageSystem.scene as unknown as THREE.Scene, world); + initGLBTreeBatchedInstancer( + stageSystem.scene as unknown as THREE.Scene, + world, + ); initPlaceholderInstancer(stageSystem.scene as unknown as THREE.Scene); initGLBResourceInstancer( stageSystem.scene as unknown as THREE.Scene, @@ -427,6 +442,15 @@ export function createClientWorld() { console.log( "[createClientWorld] Skipping tree cache pre-warm and PhysX for stream/spectator viewport", ); + // CRITICAL: We still need to load PhysX even if we skip tree pre-warming! + // In stream mode, there is no local player, so PlayerLocal won't trigger the load either. + // We trigger it here in the background so colliders and static actors can initialize. + waitForPhysX("StreamInitialization", 120000).catch((err) => { + console.warn( + "[createClientWorld] Background PhysX load failed:", + err, + ); + }); } // Pre-warm mob/NPC animated impostors AFTER renderer is ready diff --git a/packages/shared/src/systems/client/ClientCameraSystem.ts b/packages/shared/src/systems/client/ClientCameraSystem.ts index e914dc951..6f1484cdb 100644 --- a/packages/shared/src/systems/client/ClientCameraSystem.ts +++ b/packages/shared/src/systems/client/ClientCameraSystem.ts @@ -29,8 +29,16 @@ interface StreamingCameraStateUpdate { cameraTarget?: string | null; cycle?: { phase?: "IDLE" | "ANNOUNCEMENT" | "COUNTDOWN" | "FIGHTING" | "RESOLUTION"; - agent1?: { id?: string | null } | null; - agent2?: { id?: string | null } | null; + agent1?: { + id?: string | null; + hp?: number | null; + maxHp?: number | null; + } | null; + agent2?: { + id?: string | null; + hp?: number | null; + maxHp?: number | null; + } | null; winnerId?: string | null; }; } @@ -52,6 +60,8 @@ const _cinematicTransitionDir = new THREE.Vector3(); const _cinematicOrientationMatrix = new THREE.Matrix4(); const _cinematicOrientationQuat = new THREE.Quaternion(); const _cinematicOrientationUp = new THREE.Vector3(0, 1, 0); +const _cinematicShakeOffset = new THREE.Vector3(); +const _cinematicLeadOffset = new THREE.Vector3(); // Pre-allocated arrays for getCameraInfo to avoid allocations const _cameraInfoOffset: number[] = [0, 0, 0]; const _cameraInfoPosition: number[] = [0, 0, 0]; @@ -124,14 +134,63 @@ export class ClientCameraSystem extends SystemBase { private cinematicLookSlerpReady = false; private cinematicLastActorSample = new THREE.Vector3(); private cinematicLastOpponentSample = new THREE.Vector3(); + // Combat-reactive camera state + private cinematicPunchIn = 0; + private cinematicDramaticLow = 0; + private cinematicLastActorHP: number | null = null; + private cinematicLastOpponentHP: number | null = null; + // Last known good target position (fallback when entity position unavailable) + private lastKnownTargetPosition = new THREE.Vector3(); + private hasLastKnownPosition = false; + // Cached terrain system reference + private terrainSystemRef: TerrainSystem | null | undefined = undefined; + // Phase-aware camera state + private cinematicPhase: + | "IDLE" + | "ANNOUNCEMENT" + | "COUNTDOWN" + | "FIGHTING" + | "RESOLUTION" = "IDLE"; + private cinematicPhaseChangedAt = 0; + // Smart camera cuts + private cinematicHardCutPending = false; + private cinematicFastSnapRemaining = 0; + // Camera shake + private cinematicShakeIntensity = 0; + private cinematicShakeTime = 0; + // Dynamic FOV + private cinematicTargetFov = 55; + // Movement lead (velocity tracking) + private cinematicPrevActorPos = new THREE.Vector3(); + private cinematicHasPrevActorPos = false; + private cinematicVelocity = new THREE.Vector3(); + // Smoothed entity Y to filter out frame-to-frame jitter from interpolation/terrain + private cinematicSmoothedActorY = 0; + private cinematicSmoothedOpponentY = 0; + private cinematicHasSmoothedY = false; + // Locked Y positions during duel combat — entities are in a flat arena so Y should be constant. + // Without this lock, TileInterpolator terrain sampling, InterpolationEngine snapshots, + // and ClientNetwork direct writes all compete for entity.position.y, causing frame-to-frame noise. + private cinematicLockedActorY: number | null = null; + private cinematicLockedOpponentY: number | null = null; + // Locked separation during combat — prevents radius and phi from oscillating + // as agents reposition. Without this, every 1.2s agent movement causes ~2+ units + // of camera height swing via separation-dependent radius and phi calculations. + private cinematicLockedSeparation: number | null = null; + // Smoothed phase bias to prevent instant Y jumps when duel phase changes + private cinematicSmoothedBias = 0.5; + private cinematicSmoothedBiasValid = false; + // Reverse angle cuts during FIGHTING + private cinematicLastReverseAt = 0; + private cinematicNextReverseCooldown = 14000; private readonly cinematicTuning = { - thetaRefreshRate: 1.35, - thetaIdleDriftRate: 0.6, + thetaRefreshRate: 0.8, + thetaIdleDriftRate: 0.25, thetaTargetRate: 0.85, thetaAppliedRate: 0.8, - phiTargetRate: 0.65, - phiAppliedRate: 0.6, - maxDriftStep: 0.028, + phiTargetRate: 0.35, + phiAppliedRate: 0.3, + maxDriftStep: 0.012, flipPenaltyStartRad: 1.45, focusBaseSpeed: 8.5, focusDistanceSpeedGain: 0.75, @@ -139,7 +198,7 @@ export class ClientCameraSystem extends SystemBase { lookBaseSpeed: 9, lookDistanceSpeedGain: 0.85, lookMaxSpeed: 220, - lookSlerpRate: 4.8, + lookSlerpRate: 2.5, } as const; // Mouse state @@ -245,7 +304,15 @@ export class ClientCameraSystem extends SystemBase { (state) => { this.latestStreamingState = state; this.lastStreamingStateAt = Date.now(); + // Detect phase changes for camera style transitions + const newPhase = state.cycle?.phase ?? "IDLE"; + if (newPhase !== this.cinematicPhase) { + this.onCinematicPhaseChange(this.cinematicPhase, newPhase); + this.cinematicPhase = newPhase; + this.cinematicPhaseChangedAt = Date.now(); + } this.tryRetargetFromStreamingState(); + this.onStreamingStateHP(state); }, ); @@ -880,6 +947,22 @@ export class ClientCameraSystem extends SystemBase { const previousTarget = this.target; this.target = event.target; this.resetCinematicSamplingState(); + + // Smart cut: measure distance to decide transition style + if ( + previousTarget && + this.isCinematicCameraActive() && + this.hasLastKnownPosition && + this.getTargetWorldPosition(_v3_1) + ) { + const dist = this.lastKnownTargetPosition.distanceTo(_v3_1); + if (dist > 20) { + this.cinematicHardCutPending = true; + } else if (dist > 8) { + this.cinematicFastSnapRemaining = 0.3; + } + } + if (this.getTargetWorldPosition(_v3_1)) { this.logger.info("Target set", { x: _v3_1.x, @@ -993,7 +1076,7 @@ export class ClientCameraSystem extends SystemBase { } const hasFreshStreamingSignal = - Date.now() - this.lastStreamingStateAt <= 20_000; + Date.now() - this.lastStreamingStateAt <= 120_000; return hasFreshStreamingSignal; } @@ -1050,6 +1133,11 @@ export class ClientCameraSystem extends SystemBase { const entity = this.resolveEntityById(targetId); if (!entity) { + if (!this.target) { + console.warn( + `[ClientCameraSystem] Streaming target "${targetId}" not found in entity store`, + ); + } return false; } @@ -1409,6 +1497,121 @@ export class ClientCameraSystem extends SystemBase { this.cinematicLastHasOpponent = false; this.cinematicFacingTheta = this.spherical.theta; this.cinematicFacingThetaValid = false; + this.cinematicPunchIn = 0; + this.cinematicDramaticLow = 0; + this.cinematicLastActorHP = null; + this.cinematicLastOpponentHP = null; + this.cinematicShakeIntensity = 0; + this.cinematicHasPrevActorPos = false; + this.cinematicHasSmoothedY = false; + this.cinematicLockedActorY = null; + this.cinematicLockedOpponentY = null; + this.cinematicLockedSeparation = null; + this.cinematicSmoothedBiasValid = false; + this.cinematicVelocity.set(0, 0, 0); + } + + private onCinematicPhaseChange(_oldPhase: string, newPhase: string): void { + // Phase transitions trigger hard cuts for dramatic effect + if ( + newPhase === "ANNOUNCEMENT" || + newPhase === "FIGHTING" || + newPhase === "RESOLUTION" + ) { + this.cinematicHardCutPending = true; + } + this.cinematicLastReverseAt = Date.now(); + this.cinematicPunchIn = 0; + this.cinematicDramaticLow = 0; + this.cinematicShakeIntensity = 0; + this.cinematicFacingThetaValid = false; + // Reset Y and separation locks so they re-capture at the new phase's positions + this.cinematicLockedActorY = null; + this.cinematicLockedOpponentY = null; + this.cinematicLockedSeparation = null; + } + + private getCinematicPhaseParams(): { + radiusMin: number; + radiusMax: number; + basePhi: number; + driftSpeed: number; + targetFov: number; + orbitAmplitude: number; + focusBias: number; + } { + switch (this.cinematicPhase) { + case "ANNOUNCEMENT": + return { + radiusMin: 10, + radiusMax: 14, + basePhi: Math.PI * 0.26, + driftSpeed: 0.02, + targetFov: 48, + orbitAmplitude: 0.08, + focusBias: 0.35, + }; + case "COUNTDOWN": + return { + radiusMin: 4, + radiusMax: 6, + basePhi: Math.PI * 0.4, + driftSpeed: 0.0, + targetFov: 42, + orbitAmplitude: 0.02, + focusBias: 0.85, + }; + case "FIGHTING": + return { + radiusMin: 5, + radiusMax: 9, + basePhi: Math.PI * 0.31, + driftSpeed: 0.035, + targetFov: 55, + orbitAmplitude: 0.15, + focusBias: 0.58, + }; + case "RESOLUTION": + return { + radiusMin: 5.5, + radiusMax: 8, + basePhi: Math.PI * 0.44, + driftSpeed: 0.025, + targetFov: 45, + orbitAmplitude: 0.06, + focusBias: 0.5, + }; + default: + return { + radiusMin: 8, + radiusMax: 12, + basePhi: Math.PI * 0.28, + driftSpeed: 0.015, + targetFov: 52, + orbitAmplitude: 0.1, + focusBias: 0.5, + }; + } + } + + private computeCameraShake(dt: number): THREE.Vector3 { + if (this.cinematicShakeIntensity < 0.001) { + _cinematicShakeOffset.set(0, 0, 0); + return _cinematicShakeOffset; + } + this.cinematicShakeTime += dt; + this.cinematicShakeIntensity *= Math.exp(-6.0 * dt); + if (this.cinematicShakeIntensity < 0.001) { + this.cinematicShakeIntensity = 0; + } + const t = this.cinematicShakeTime * 60; + const i = this.cinematicShakeIntensity; + _cinematicShakeOffset.set( + Math.sin(t * 1.1) * Math.sin(t * 0.47) * i * 0.12, + Math.sin(t * 1.37) * Math.sin(t * 0.63) * i * 0.04, + Math.sin(t * 0.93) * Math.sin(t * 0.37) * i * 0.1, + ); + return _cinematicShakeOffset; } private moveAngleToward( @@ -1497,7 +1700,7 @@ export class ClientCameraSystem extends SystemBase { return true; } - if (now - this.cinematicLastLosRefreshAt >= 120) { + if (now - this.cinematicLastLosRefreshAt >= 500) { return true; } @@ -1514,15 +1717,14 @@ export class ClientCameraSystem extends SystemBase { return true; } - if (actorPosition.distanceToSquared(this.cinematicLastActorSample) > 0.18) { + if (actorPosition.distanceToSquared(this.cinematicLastActorSample) > 0.6) { return true; } if ( hasOpponent && opponentPosition && - opponentPosition.distanceToSquared(this.cinematicLastOpponentSample) > - 0.28 + opponentPosition.distanceToSquared(this.cinematicLastOpponentSample) > 0.8 ) { return true; } @@ -1540,6 +1742,40 @@ export class ClientCameraSystem extends SystemBase { actorPosition: THREE.Vector3, opponentPosition: THREE.Vector3 | null, ): { theta: number; phi: number } { + // During active duel combat (COUNTDOWN/FIGHTING/RESOLUTION), skip the + // periodic LOS grid search entirely. The duel arena is a controlled + // environment with clear sightlines. The grid search's 500ms refresh + // cycle causes oscillation when it alternates between rating adjacent + // angles as "clear" vs "blocked" — the root cause of vertical jitter. + // Instead, use smooth continuous exponential damping toward the desired + // angles, giving a heavy, deliberate, film-quality camera feel. + if ( + this.cinematicPhase === "COUNTDOWN" || + this.cinematicPhase === "FIGHTING" || + this.cinematicPhase === "RESOLUTION" + ) { + if (!this.cinematicThetaCacheValid || !this.cinematicPhiCacheValid) { + this.cinematicThetaCache = baseTheta; + this.cinematicPhiCache = phi; + this.cinematicThetaCacheValid = true; + this.cinematicPhiCacheValid = true; + } else { + // Exponential damping: fast when far from target, slow when close. + // This is the same approach used by AAA cinematic cameras — no linear + // rate caps that create mechanical start/stop movement. + const damp = 1 - Math.exp(-1.8 * deltaSeconds); + const thetaDelta = this.shortestAngleDelta( + this.cinematicThetaCache, + baseTheta, + ); + this.cinematicThetaCache += thetaDelta * damp; + this.cinematicPhiCache += (phi - this.cinematicPhiCache) * damp; + } + return { theta: this.cinematicThetaCache, phi: this.cinematicPhiCache }; + } + + // IDLE / ANNOUNCEMENT: full LOS grid search (agents roaming the world + // where buildings and trees can obstruct the view). const shouldRefresh = this.shouldRefreshCinematicView( now, baseTheta, @@ -1573,7 +1809,7 @@ export class ClientCameraSystem extends SystemBase { ? this.moveAngleToward( this.cinematicPhiCache, selectedView.phi, - this.cinematicTuning.phiTargetRate, + this.cinematicTuning.phiTargetRate * 0.6, refreshDeltaSeconds, ) : selectedView.phi; @@ -1593,7 +1829,8 @@ export class ClientCameraSystem extends SystemBase { }; } - // Keep subtle motion even between LOS refreshes. + // Between LOS refreshes (IDLE/ANNOUNCEMENT only): subtle theta drift, + // phi stays locked to prevent down-blocked-up oscillation. const drift = this.shortestAngleDelta(this.cinematicThetaCache, baseTheta); this.cinematicThetaCache = this.moveAngleToward( this.cinematicThetaCache, @@ -1606,12 +1843,6 @@ export class ClientCameraSystem extends SystemBase { -this.cinematicTuning.maxDriftStep, this.cinematicTuning.maxDriftStep, ); - this.cinematicPhiCache = this.moveAngleToward( - this.cinematicPhiCache, - phi, - this.cinematicTuning.phiTargetRate * 0.55, - deltaSeconds, - ); return { theta: this.cinematicThetaCache, phi: this.cinematicPhiCache, @@ -1752,6 +1983,17 @@ export class ClientCameraSystem extends SystemBase { } score -= Math.abs(candidatePhi - anchorPhi) * 1.05; + // Hysteresis: bias toward the current cached angle to prevent oscillation. + // The camera only switches when a new angle is substantially better. + if (this.cinematicThetaCacheValid) { + const distFromCurrent = + Math.abs(this.shortestAngleDelta(this.cinematicThetaCache, theta)) + + Math.abs(this.cinematicPhiCache - candidatePhi); + if (distFromCurrent < 0.15) { + score += 2.0; + } + } + const probeDirection = _cinematicProbeDir .copy(_cinematicProbePos) .sub(focus); @@ -1875,8 +2117,26 @@ export class ClientCameraSystem extends SystemBase { this.copyEntityPosition(actorEntity, _cinematicActorPos) || this.copyEntityPosition(this.target, _cinematicActorPos); if (!hasActorPos) { - return null; + if (this.hasLastKnownPosition) { + _cinematicActorPos.copy(this.lastKnownTargetPosition); + } else { + return null; + } + } else { + this.lastKnownTargetPosition.copy(_cinematicActorPos); + this.hasLastKnownPosition = true; + } + + // Track velocity for movement lead + if (this.cinematicHasPrevActorPos) { + this.cinematicVelocity.set( + _cinematicActorPos.x - this.cinematicPrevActorPos.x, + 0, + _cinematicActorPos.z - this.cinematicPrevActorPos.z, + ); } + this.cinematicPrevActorPos.copy(_cinematicActorPos); + this.cinematicHasPrevActorPos = true; const actorId = this.resolveEntityId(actorEntity); const opponentEntity = this.resolveOpponentEntity(actorEntity, actorId); @@ -1886,20 +2146,120 @@ export class ClientCameraSystem extends SystemBase { const now = Date.now(); const dt = Math.max(0.001, deltaTime || 0.016); - this.cinematicClock += Math.max(0.001, deltaTime || 0.016); + this.cinematicClock += dt; + + // Lock entity Y during duel combat phases. Entities fight in a flat arena + // so their Y should be constant. Without locking, TileInterpolator terrain + // sampling, InterpolationEngine snapshots, and ClientNetwork direct writes + // all compete for entity.position.y causing frame-to-frame noise that no + // amount of smoothing can fully eliminate. + const inDuelCombat = + this.cinematicPhase === "COUNTDOWN" || + this.cinematicPhase === "FIGHTING" || + this.cinematicPhase === "RESOLUTION"; + + if (inDuelCombat) { + // Lock Y on first frame of duel combat, or if not yet locked + if (this.cinematicLockedActorY === null) { + this.cinematicLockedActorY = _cinematicActorPos.y; + } + if (hasOpponent && this.cinematicLockedOpponentY === null) { + this.cinematicLockedOpponentY = _cinematicOpponentPos.y; + } + // Use locked Y — completely ignores noisy terrain/interpolation updates + _cinematicActorPos.y = this.cinematicLockedActorY; + if (hasOpponent) { + _cinematicOpponentPos.y = this.cinematicLockedOpponentY!; + } + } else { + // Outside duel combat, clear locks and use gentle smoothing for idle following + this.cinematicLockedActorY = null; + this.cinematicLockedOpponentY = null; + this.cinematicLockedSeparation = null; + + if (!this.cinematicHasSmoothedY) { + this.cinematicSmoothedActorY = _cinematicActorPos.y; + this.cinematicSmoothedOpponentY = hasOpponent + ? _cinematicOpponentPos.y + : _cinematicActorPos.y; + this.cinematicHasSmoothedY = true; + } else { + const ySmooth = 1 - Math.exp(-0.5 * dt); + this.cinematicSmoothedActorY += + (_cinematicActorPos.y - this.cinematicSmoothedActorY) * ySmooth; + if (hasOpponent) { + this.cinematicSmoothedOpponentY += + (_cinematicOpponentPos.y - this.cinematicSmoothedOpponentY) * + ySmooth; + } + } + _cinematicActorPos.y = this.cinematicSmoothedActorY; + if (hasOpponent) { + _cinematicOpponentPos.y = this.cinematicSmoothedOpponentY; + } + } + + // Phase-aware camera parameters + const pp = this.getCinematicPhaseParams(); + this.cinematicTargetFov = pp.targetFov; + + // Movement lead offset (camera anticipates movement direction) + const leadScale = this.cinematicPhase === "IDLE" ? 1.5 : 0.8; + const speed = Math.sqrt( + this.cinematicVelocity.x * this.cinematicVelocity.x + + this.cinematicVelocity.z * this.cinematicVelocity.z, + ); + _cinematicLeadOffset.set(0, 0, 0); + if (speed > 0.02) { + _cinematicLeadOffset.set( + this.cinematicVelocity.x * leadScale, + 0, + this.cinematicVelocity.z * leadScale, + ); + } if (hasOpponent) { - const separation = _cinematicActorPos.distanceTo(_cinematicOpponentPos); + const rawSeparation = _cinematicActorPos.distanceTo( + _cinematicOpponentPos, + ); + // Lock separation during duel combat so radius and phi don't oscillate + // when agents reposition. Without this, every 1.2s movement tick causes + // separation to fluctuate (e.g., 2→3 units), which swings both the radius + // (via separation*1.1) and phi (via closeCombatBlend), producing ~2+ units + // of camera height change — the root cause of persistent Y-axis jitter. + if (inDuelCombat) { + if (this.cinematicLockedSeparation === null) { + this.cinematicLockedSeparation = rawSeparation; + } + } else { + this.cinematicLockedSeparation = null; + } + const separation = this.cinematicLockedSeparation ?? rawSeparation; + + // Focus point: phase-aware bias between actor and opponent. + // Smooth the bias transition to prevent Y jumps when phase changes + // (e.g., ANNOUNCEMENT bias=0.35 → FIGHTING bias=0.58 would cause + // an instant focus-point jump if actor and opponent have different Y). + if (!this.cinematicSmoothedBiasValid) { + this.cinematicSmoothedBias = pp.focusBias; + this.cinematicSmoothedBiasValid = true; + } else { + const biasSmooth = 1 - Math.exp(-2.0 * dt); + this.cinematicSmoothedBias += + (pp.focusBias - this.cinematicSmoothedBias) * biasSmooth; + } + const bias = this.cinematicSmoothedBias; _cinematicFocusPos .copy(_cinematicActorPos) - .multiplyScalar(0.58) + .multiplyScalar(bias) .add( _cinematicProbeTarget .copy(_cinematicOpponentPos) - .multiplyScalar(0.42), + .multiplyScalar(1 - bias), ); _cinematicFocusPos.y += 1.05; + _cinematicFocusPos.add(_cinematicLeadOffset); _cinematicLookAtPos .copy(_cinematicActorPos) @@ -1907,6 +2267,7 @@ export class ClientCameraSystem extends SystemBase { .multiplyScalar(0.5); _cinematicLookAtPos.y += 1.12; + // Facing theta (smoothed toward opponent direction) let facingTheta = this.cinematicFacingTheta; if (separation > 0.25) { const rawFacingTheta = Math.atan2( @@ -1931,28 +2292,83 @@ export class ClientCameraSystem extends SystemBase { facingTheta = this.cinematicFacingTheta; } + const t = this.cinematicClock; + // Orbit drift with phase-controlled amplitude + const amp = pp.orbitAmplitude; const orbitDrift = - this.cinematicClock * 0.05 + - Math.sin(this.cinematicClock * 0.21) * 0.12 + - Math.sin(this.cinematicClock * 0.08 + 1.7) * 0.08; - const baseTheta = facingTheta + Math.PI * 0.56 + orbitDrift; + t * pp.driftSpeed + + Math.sin(t * 0.17) * amp + + Math.sin(t * 0.089 + 2.1) * amp * 0.73 + + Math.sin(t * 0.31 + 0.7) * amp * 0.4; + const bigSwing = Math.sin(t * 0.048) * Math.sin(t * 0.032) * amp * 2.3; + + // Reverse angle cuts during FIGHTING for visual variety + let reverseAngleBoost = 0; + if (this.cinematicPhase === "FIGHTING") { + const timeSinceReverse = now - this.cinematicLastReverseAt; + if (timeSinceReverse > this.cinematicNextReverseCooldown) { + reverseAngleBoost = Math.PI * 0.7; + this.cinematicLastReverseAt = now; + this.cinematicNextReverseCooldown = 12000 + Math.random() * 6000; + // Reset theta cache so LOS scorer accepts the new angle + this.cinematicThetaCacheValid = false; + this.cinematicLastLosRefreshAt = 0; + } + } + + const baseTheta = + facingTheta + + Math.PI * 0.56 + + orbitDrift + + bigSwing + + reverseAngleBoost; + + // Phase-aware radius — use smoothstep blend instead of hard threshold + // to prevent discrete jumps when agents hover near 3 units apart + const closeCombatBlend = clamp((3.5 - separation) / 1.5, 0, 1); + const combatTightening = closeCombatBlend * 1.2; const baseRadius = clamp( - 6.2 + separation * 1.45, - this.settings.minDistance + 2, - this.settings.maxDistance, + pp.radiusMin + separation * 1.1 - combatTightening, + pp.radiusMin, + pp.radiusMax, ); const radius = clamp( - baseRadius + Math.sin(this.cinematicClock * 0.32) * 0.2, - this.settings.minDistance + 2, - this.settings.maxDistance, - ); - const phi = clamp( - Math.PI * 0.285 + - Math.sin(this.cinematicClock * 0.18 + separation * 0.2) * 0.035, - this.settings.minPolarAngle + 0.03, - this.settings.maxPolarAngle - 0.03, + baseRadius + Math.sin(t * 0.32) * 0.2, + pp.radiusMin, + pp.radiusMax, ); + // Phase-aware phi (pitch angle) — smooth blend for close combat + let phi: number; + if (this.cinematicPhase === "RESOLUTION") { + // Low heroic angle for winner + phi = clamp( + pp.basePhi + Math.sin(t * 0.09) * 0.03, + this.settings.minPolarAngle + 0.03, + this.settings.maxPolarAngle - 0.01, + ); + } else if (this.cinematicPhase === "COUNTDOWN") { + // Tight hero shot, slight variation + phi = clamp( + pp.basePhi + Math.sin(t * 0.11) * 0.02, + this.settings.minPolarAngle + 0.03, + this.settings.maxPolarAngle - 0.03, + ); + } else { + // FIGHTING / ANNOUNCEMENT / IDLE — blend between base and close-combat phi. + // Use only time-based variation (no separation dependency) to prevent + // discontinuous phi changes when agents move during combat. + const closeCombatPhi = + pp.basePhi + closeCombatBlend * (Math.PI * 0.38 - pp.basePhi); + const phiVariation = + Math.sin(t * 0.13) * 0.015 + Math.sin(t * 0.07) * 0.01; + phi = clamp( + closeCombatPhi + phiVariation, + this.settings.minPolarAngle + 0.03, + this.settings.maxPolarAngle - 0.03, + ); + } + const cinematicView = this.resolveCinematicView( now, dt, @@ -1973,22 +2389,32 @@ export class ClientCameraSystem extends SystemBase { }; } + // Solo (no opponent) — phase-aware this.cinematicFacingThetaValid = false; _cinematicFocusPos.copy(_cinematicActorPos); _cinematicFocusPos.y += 1; + _cinematicFocusPos.add(_cinematicLeadOffset); _cinematicLookAtPos.copy(_cinematicActorPos); _cinematicLookAtPos.y += 1.12; + const tSolo = this.cinematicClock; + const soloAmp = pp.orbitAmplitude; const thetaDrift = - Math.sin(this.cinematicClock * 0.15) * 0.03 + this.cinematicClock * 0.012; + Math.sin(tSolo * 0.15) * soloAmp * 0.5 + + Math.sin(tSolo * 0.067 + 1.3) * soloAmp * 0.4 + + tSolo * pp.driftSpeed; const baseTheta = this.spherical.theta + thetaDrift; const radius = clamp( - this.targetSpherical.radius + Math.sin(this.cinematicClock * 0.34) * 0.12, - this.settings.minDistance + 1.5, - this.settings.maxDistance - 0.5, + (pp.radiusMin + pp.radiusMax) * 0.5 + + Math.sin(tSolo * 0.34) * 0.3 + + Math.sin(tSolo * 0.12 + 0.8) * 0.2, + pp.radiusMin, + pp.radiusMax, ); const phi = clamp( - Math.PI * 0.3 + Math.sin(this.cinematicClock * 0.17 + 0.6) * 0.028, + pp.basePhi + + Math.sin(tSolo * 0.13 + 0.6) * 0.05 + + Math.sin(tSolo * 0.07) * 0.03, this.settings.minPolarAngle + 0.02, this.settings.maxPolarAngle - 0.02, ); @@ -2049,39 +2475,60 @@ export class ClientCameraSystem extends SystemBase { const cinematicFrame = this.buildCinematicFrame(deltaTime); if (cinematicFrame) { this.targetPosition.copy(cinematicFrame.focus); - this.moveVectorToward( - this.smoothedTarget, - this.targetPosition, - frameDt, - this.cinematicTuning.focusBaseSpeed, - this.cinematicTuning.focusDistanceSpeedGain, - this.cinematicTuning.focusMaxSpeed, - ); - this.targetSpherical.radius += clamp( - cinematicFrame.radius - this.targetSpherical.radius, - -2.2 * frameDt, - 2.2 * frameDt, - ); - this.targetSpherical.phi = this.moveAngleToward( - this.targetSpherical.phi, - cinematicFrame.phi, - this.cinematicTuning.phiTargetRate, - frameDt, - ); - this.targetSpherical.theta = this.moveAngleToward( - this.targetSpherical.theta, - cinematicFrame.theta, - this.cinematicTuning.thetaTargetRate, - frameDt, - ); - this.moveVectorToward( - this.lookAtTarget, - cinematicFrame.lookAt, - frameDt, - this.cinematicTuning.lookBaseSpeed, - this.cinematicTuning.lookDistanceSpeedGain, - this.cinematicTuning.lookMaxSpeed, - ); + + // Handle hard cuts and fast snaps + if (this.cinematicHardCutPending) { + // Hard cut: snap everything instantly + this.smoothedTarget.copy(this.targetPosition); + this.targetSpherical.radius = cinematicFrame.radius; + this.targetSpherical.phi = cinematicFrame.phi; + this.targetSpherical.theta = cinematicFrame.theta; + this.spherical.radius = cinematicFrame.radius; + this.spherical.phi = cinematicFrame.phi; + this.spherical.theta = cinematicFrame.theta; + this.effectiveRadius = cinematicFrame.radius; + this.lookAtTarget.copy(cinematicFrame.lookAt); + this.cinematicLookSlerpReady = false; + this.cinematicHardCutPending = false; + this.cinematicFastSnapRemaining = 0; + } else { + // Single-layer exponential damping for all cinematic smoothing. + // Exponential decay (fast when far, slow when close) produces the + // heavy, deliberate camera motion of AAA cinematic cameras like RDR2. + // This replaces the previous 3-layer pipeline (cinematicCache → + // targetSpherical → spherical) which had conflicting linear rate + // caps that created mechanical start/stop motion and oscillation. + // + // Rate 3.5 → half-life ~0.2s, reaches 95% in ~0.6s. + // Rate 5.0 for position → half-life ~0.14s, reaches 95% in ~0.4s. + const snapM = this.cinematicFastSnapRemaining > 0 ? 3.0 : 1.0; + if (this.cinematicFastSnapRemaining > 0) { + this.cinematicFastSnapRemaining = Math.max( + 0, + this.cinematicFastSnapRemaining - frameDt, + ); + } + + // Position: exponential damping (unified X/Y/Z — no separate Y rate limit). + // Y is locked during duel combat so there's no terrain noise to filter. + const posDamp = 1 - Math.exp(-5.0 * snapM * frameDt); + this.smoothedTarget.lerp(this.targetPosition, posDamp); + this.lookAtTarget.lerp(cinematicFrame.lookAt, posDamp); + + // Angles: single-layer exponential damping directly to cinematicFrame + // values. No intermediate targetSpherical — that extra layer added + // latency and created phase conflicts between smoothers. + const angleDamp = 1 - Math.exp(-3.5 * snapM * frameDt); + const thetaDelta = this.shortestAngleDelta( + this.targetSpherical.theta, + cinematicFrame.theta, + ); + this.targetSpherical.theta += thetaDelta * angleDamp; + this.targetSpherical.phi += + (cinematicFrame.phi - this.targetSpherical.phi) * angleDamp; + this.targetSpherical.radius += + (cinematicFrame.radius - this.targetSpherical.radius) * angleDamp; + } } else { let hasTargetPosition = this.getTargetWorldPosition(_v3_1); if (!hasTargetPosition) { @@ -2092,10 +2539,18 @@ export class ClientCameraSystem extends SystemBase { hasTargetPosition = this.getTargetWorldPosition(_v3_1); } if (!hasTargetPosition) { - return; + if (this.hasLastKnownPosition) { + _v3_1.copy(this.lastKnownTargetPosition); + } else { + return; + } } } + // Save last known good position + this.lastKnownTargetPosition.copy(_v3_1); + this.hasLastKnownPosition = true; + // For server-authoritative movement, follow target directly without smoothing. this.targetPosition.copy(_v3_1); this.targetPosition.add(this.cameraOffset); @@ -2113,18 +2568,12 @@ export class ClientCameraSystem extends SystemBase { const shouldSmoothSpherical = isOrbiting || Boolean(cinematicFrame); if (shouldSmoothSpherical) { if (cinematicFrame) { - this.spherical.phi = this.moveAngleToward( - this.spherical.phi, - this.targetSpherical.phi, - this.cinematicTuning.phiAppliedRate, - frameDt, - ); - this.spherical.theta = this.moveAngleToward( - this.spherical.theta, - this.targetSpherical.theta, - this.cinematicTuning.thetaAppliedRate, - frameDt, - ); + // In cinematic mode, targetSpherical already contains the smoothed + // values (single-layer exponential damping applied above). Copy + // directly — adding a second smoothing layer creates sluggishness + // and can cause oscillation when the two layers have different rates. + this.spherical.phi = this.targetSpherical.phi; + this.spherical.theta = this.targetSpherical.theta; } else { const phiDelta = this.targetSpherical.phi - this.spherical.phi; const thetaDelta = this.shortestAngleDelta( @@ -2147,6 +2596,13 @@ export class ClientCameraSystem extends SystemBase { this.spherical.theta = this.targetSpherical.theta; } + // In cinematic mode, propagate targetSpherical.radius → spherical.radius + // (the cinematic frame sets targetSpherical.radius but the smoothing block + // above only handles phi/theta) + if (cinematicFrame) { + this.spherical.radius = this.targetSpherical.radius; + } + // Hard clamp after smoothing to enforce strict RS3-like limits this.spherical.radius = clamp( this.spherical.radius, @@ -2154,18 +2610,24 @@ export class ClientCameraSystem extends SystemBase { this.settings.maxDistance, ); - // Collision-aware effective radius with smoothing to avoid snap on MMB press - const desiredDistance = this.spherical.radius; - const collidedDistance = - this.computeCollisionAdjustedDistance(desiredDistance); - const targetEffective = Math.min(desiredDistance, collidedDistance); - if (this.zoomDirty || this.orbitingActive) { - // When zoom just changed, honor immediate response - this.effectiveRadius = targetEffective; + // Collision-aware effective radius — skip in cinematic mode because the + // LOS scorer already avoids obstructed camera angles. Running the collision + // raycast here causes jitter as the ray alternates between hitting and + // missing terrain at different orbit angles each frame. + if (cinematicFrame) { + this.effectiveRadius = this.spherical.radius; } else { - const radiusDamping = this.settings.radiusDampingFactor ?? 0.18; - this.effectiveRadius += - (targetEffective - this.effectiveRadius) * radiusDamping; + const desiredDistance = this.spherical.radius; + const collidedDistance = + this.computeCollisionAdjustedDistance(desiredDistance); + const targetEffective = Math.min(desiredDistance, collidedDistance); + if (this.zoomDirty || this.orbitingActive) { + this.effectiveRadius = targetEffective; + } else { + const radiusDamping = this.settings.radiusDampingFactor ?? 0.18; + this.effectiveRadius += + (targetEffective - this.effectiveRadius) * radiusDamping; + } } // Calculate camera position from spherical coordinates using effective radius @@ -2177,6 +2639,13 @@ export class ClientCameraSystem extends SystemBase { this.cameraPosition.setFromSpherical(tempSpherical); this.cameraPosition.add(this.smoothedTarget); + // Prevent camera from going underground — skip in cinematic mode where + // the LOS scorer handles obstruction avoidance. The hard Y snap fights + // with the smooth spherical orbit and causes vertical jitter. + if (!cinematicFrame) { + this.clampAboveTerrain(this.cameraPosition, 1.5); + } + if (!cinematicFrame) { // Calculate look-at target - look at player's chest/torso height this.lookAtTarget.copy(this.smoothedTarget); @@ -2219,10 +2688,17 @@ export class ClientCameraSystem extends SystemBase { this.cinematicLookSlerpReady = false; } + // Dynamic FOV for cinematic mode + if (cinematicFrame && this.camera) { + const fovDelta = this.cinematicTargetFov - this.camera.fov; + if (Math.abs(fovDelta) > 0.1) { + this.camera.fov += clamp(fovDelta, -15 * frameDt, 15 * frameDt); + this.camera.updateProjectionMatrix(); + } + } + // Update camera matrices since it has no parent transform to inherit from this.camera.updateMatrixWorld(true); - - // Do not clamp camera height to terrain; effective radius collision handles occlusion } private computeCollisionAdjustedDistance(desiredDistance: number): number { @@ -2260,65 +2736,34 @@ export class ClientCameraSystem extends SystemBase { return desiredDistance; } - private shortestAngleDelta(a: number, b: number): number { - let delta = (b - a) % (Math.PI * 2); - if (delta > Math.PI) delta -= Math.PI * 2; - if (delta < -Math.PI) delta += Math.PI * 2; - return delta; - } - - private validatePlayerOnTerrain( - playerPos: THREE.Vector3 | { x: number; y: number; z: number }, - ): void { - // Get terrain system - const terrainSystem = this.world.getSystem("terrain"); - if (!terrainSystem) { - // No terrain system available - return; - } - - // Get player coordinates - const px = "x" in playerPos ? playerPos.x : (playerPos as THREE.Vector3).x; - const py = "y" in playerPos ? playerPos.y : (playerPos as THREE.Vector3).y; - const pz = "z" in playerPos ? playerPos.z : (playerPos as THREE.Vector3).z; - - // Get terrain height at player position - const terrainHeight = terrainSystem.getHeightAt(px, pz); - - // Check if terrain height is valid - if (!isFinite(terrainHeight) || isNaN(terrainHeight)) { - const errorMsg = `[CRITICAL] Invalid terrain height at player position: x=${px}, z=${pz}, terrainHeight=${terrainHeight}`; - console.error(errorMsg); - throw new Error(errorMsg); - } - - // Check if player is properly positioned on terrain - // Allow some tolerance for player height above terrain (0.0 to 5.0 units) - const heightDifference = py - terrainHeight; - - if (heightDifference < -0.5) { - const errorMsg = `[CRITICAL] Player is BELOW terrain! Player Y: ${py}, Terrain Height: ${terrainHeight}, Difference: ${heightDifference}`; - console.error(errorMsg); - throw new Error(errorMsg); + private getTerrainSystem(): TerrainSystem | null { + if (this.terrainSystemRef === undefined) { + this.terrainSystemRef = + (this.world.getSystem("terrain") as TerrainSystem | null) ?? null; } + return this.terrainSystemRef; + } - if (heightDifference > 10.0) { - const errorMsg = `[CRITICAL] Player is FLOATING above terrain! Player Y: ${py}, Terrain Height: ${terrainHeight}, Difference: ${heightDifference}`; - console.error(errorMsg); - throw new Error(errorMsg); + private clampAboveTerrain(pos: THREE.Vector3, minClearance: number): void { + const terrain = this.getTerrainSystem(); + if (!terrain) return; + const height = terrain.getHeightAt(pos.x, pos.z); + if (Number.isFinite(height) && pos.y < height + minClearance) { + pos.y = height + minClearance; } + } - // Additional check: if player Y is exactly 0 or very close to 0, might indicate spawn issue - if (Math.abs(py) < 0.01 && Math.abs(terrainHeight) > 1.0) { - const errorMsg = `[CRITICAL] Player Y position is near zero (${py}) but terrain height is ${terrainHeight} - likely spawn failure!`; - console.error(errorMsg); - throw new Error(errorMsg); - } + private onStreamingStateHP(_state: StreamingCameraStateUpdate): void { + // Intentionally empty — all combat-reactive camera effects (punch-in, + // shake, dramatic low angle) have been removed for a smooth cinematic + // experience. HP changes no longer affect the camera. + } - // Log successful validation periodically (every 60 frames) - if (Math.random() < 0.0167) { - // ~1/60 chance - } + private shortestAngleDelta(a: number, b: number): number { + let delta = (b - a) % (Math.PI * 2); + if (delta > Math.PI) delta -= Math.PI * 2; + if (delta < -Math.PI) delta += Math.PI * 2; + return delta; } // Public API methods for testing and external access @@ -2440,6 +2885,8 @@ export class ClientCameraSystem extends SystemBase { this.cinematicLosMask = null; this.cinematicCollisionMask = null; this.cinematicLookSlerpReady = false; + this.terrainSystemRef = undefined; + this.hasLastKnownPosition = false; this.resetCinematicSamplingState(); } diff --git a/packages/shared/src/systems/client/ClientLoader.ts b/packages/shared/src/systems/client/ClientLoader.ts index f1c31e5d7..0ac5e227a 100644 --- a/packages/shared/src/systems/client/ClientLoader.ts +++ b/packages/shared/src/systems/client/ClientLoader.ts @@ -51,7 +51,7 @@ type HlsInstance = { }; type HlsConstructor = { - new(): HlsInstance; + new (): HlsInstance; isSupported: () => boolean; }; @@ -604,7 +604,9 @@ export class ClientLoader extends SystemBase { }); if (!resp.ok) { - console.error(`[ClientLoader] Fetch failed for ${url} with status: ${resp.status}`); + console.error( + `[ClientLoader] Fetch failed for ${url} with status: ${resp.status}`, + ); throw new Error(`HTTP error! status: ${resp.status}`); } diff --git a/packages/shared/src/systems/client/ClientNetwork.ts b/packages/shared/src/systems/client/ClientNetwork.ts index 6dac4ee38..1f3c047ff 100644 --- a/packages/shared/src/systems/client/ClientNetwork.ts +++ b/packages/shared/src/systems/client/ClientNetwork.ts @@ -325,7 +325,7 @@ export class ClientNetwork extends SystemBase { constructor(world: World) { super(world, { name: "client-network", - dependencies: { required: [], optional: [] }, + dependencies: { required: ["physics"], optional: [] }, autoCleanup: true, }); this.ids = -1; @@ -1423,6 +1423,16 @@ export class ClientNetwork extends SystemBase { } }; + onEntitiesBatchAdded = (batch: EntityData[]) => { + if (!Array.isArray(batch)) return; + for (const data of batch) { + const newEntity = this.world.entities.add(data); + if (newEntity) { + this.applyPendingModifications(newEntity.id); + } + } + }; + onEntityModified = ( data: { id: string; changes?: Record } & Record< string, @@ -1675,10 +1685,10 @@ export class ClientNetwork extends SystemBase { pArr && pArr.length === 3 ? { x: pArr[0], y: pArr[1], z: pArr[2] } : { - x: entity.position.x, - y: entity.position.y, - z: entity.position.z, - }; + x: entity.position.x, + y: entity.position.y, + z: entity.position.z, + }; this.tileInterpolator.setCombatRotation( id, changesObj.q as number[], @@ -3428,11 +3438,11 @@ export class ClientNetwork extends SystemBase { */ onPrivateMessageFailed = (data: { reason: - | "offline" - | "ignored" - | "not_friends" - | "player_not_found" - | "rate_limited"; + | "offline" + | "ignored" + | "not_friends" + | "player_not_found" + | "rate_limited"; targetName: string; }) => { const reasonMessages: Record = { @@ -3714,7 +3724,7 @@ export class ClientNetwork extends SystemBase { // Uses bounding box which includes door approach areas collisionService ? (x: number, z: number) => - collisionService.isNearBuildingForElevation(x, z) + collisionService.isNearBuildingForElevation(x, z) : undefined, // Pass step height function for smooth entrance stair walking collisionService @@ -3722,7 +3732,7 @@ export class ClientNetwork extends SystemBase { : undefined, this.world.frameBudget ? (minimumMs: number = 1) => - this.world.frameBudget!.hasTimeRemaining(minimumMs) + this.world.frameBudget!.hasTimeRemaining(minimumMs) : undefined, ); } diff --git a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts index ee730c87e..a5ddb9658 100644 --- a/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts +++ b/packages/shared/src/systems/client/DuelArenaVisualsSystem.ts @@ -711,7 +711,7 @@ export class DuelArenaVisualsSystem extends System { TOTAL_FENCE_POSTS, ); postsIM.castShadow = true; - postsIM.receiveShadow = true; + postsIM.receiveShadow = false; postsIM.layers.set(1); postsIM.userData = { type: "arena-fence", walkable: false }; @@ -784,7 +784,7 @@ export class DuelArenaVisualsSystem extends System { TOTAL_X_RAILS, ); railsXIM.castShadow = true; - railsXIM.receiveShadow = true; + railsXIM.receiveShadow = false; railsXIM.layers.set(1); let railXIdx = 0; @@ -823,7 +823,7 @@ export class DuelArenaVisualsSystem extends System { TOTAL_Z_RAILS, ); railsZIM.castShadow = true; - railsZIM.receiveShadow = true; + railsZIM.receiveShadow = false; railsZIM.layers.set(1); let railZIdx = 0; @@ -889,7 +889,7 @@ export class DuelArenaVisualsSystem extends System { for (const im of [basesIM, shaftsIM, capitalsIM]) { im.castShadow = true; - im.receiveShadow = true; + im.receiveShadow = false; im.layers.set(1); } @@ -1253,7 +1253,7 @@ export class DuelArenaVisualsSystem extends System { const pillar = new THREE.Mesh(geom, this.forfeitPillarMat!); pillar.position.set(x, terrainY + FORFEIT_PILLAR_HEIGHT / 2, z); pillar.castShadow = true; - pillar.receiveShadow = true; + pillar.receiveShadow = false; pillar.name = entityId; pillar.userData = { entityId, diff --git a/packages/shared/src/systems/client/ZoneVisualsSystem.ts b/packages/shared/src/systems/client/ZoneVisualsSystem.ts index ddfb3720c..5d3f4b1ed 100644 --- a/packages/shared/src/systems/client/ZoneVisualsSystem.ts +++ b/packages/shared/src/systems/client/ZoneVisualsSystem.ts @@ -128,8 +128,7 @@ export class ZoneVisualsSystem extends SystemBase { return cachedTexture; } - const emoji = - type === "skull" ? "💀" : type === "home" ? "🏠" : "⚔️"; + const emoji = type === "skull" ? "💀" : type === "home" ? "🏠" : "⚔️"; const texture = this.createEmojiTexture(emoji, type); this.emojiTextures.set(type, texture); return texture; diff --git a/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts b/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts index c3cd7cfa4..d2dcc014f 100644 --- a/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts +++ b/packages/shared/src/systems/client/interaction/services/EntityHighlightService.ts @@ -54,8 +54,8 @@ const _meshBuffer: THREE.Object3D[] = []; export class EntityHighlightService { private currentTargetId: string | null = null; private composer: PostProcessingComposer | null = null; - /** Temporary highlight mesh added to the scene for instanced entities */ - private activeHighlightMesh: THREE.Object3D | null = null; + /** Entity using shader-based rim highlight (needs clearing on un-hover) */ + private shaderHighlightEntity: Record | null = null; constructor(private world: World) {} @@ -81,34 +81,30 @@ export class EntityHighlightService { const newId = target?.entityId ?? null; if (newId === this.currentTargetId) return; - this.removeActiveHighlightMesh(); + this.clearShaderHighlight(); this.currentTargetId = newId; - if (!this.composer) return; - if (!target || !target.entity) { - this.composer.setOutlineObjects([]); + if (this.composer) this.composer.setOutlineObjects([]); return; } - // Try instanced highlight path first + // Try shader-based rim highlight first (no extra meshes / draw calls) const entity = target.entity as unknown as Record; - if (typeof entity.getHighlightRoot === "function") { - const hlRoot = (entity.getHighlightRoot as () => THREE.Object3D | null)(); - if (hlRoot) { - this.world.stage?.scene?.add?.(hlRoot); - this.activeHighlightMesh = hlRoot; - const meshes = this.collectMeshes(hlRoot); - if (meshes.length > 0) { - const color = this.getHighlightColor(target.entityType); - this.composer.setOutlineColor(color); - this.composer.setOutlineObjects(meshes); - return; - } + if (typeof entity.setShaderHighlight === "function") { + const handled = (entity.setShaderHighlight as (on: boolean) => boolean)( + true, + ); + if (handled) { + this.shaderHighlightEntity = entity; + if (this.composer) this.composer.setOutlineObjects([]); + return; } } - // Fallback: use entity's own scene-graph mesh + if (!this.composer) return; + + // Fallback: use entity's own scene-graph mesh (non-instanced entities) const mesh = target.entity.mesh; const node = target.entity.node; const root = mesh ?? node; @@ -134,16 +130,19 @@ export class EntityHighlightService { clearHover(): void { if (this.currentTargetId === null) return; this.currentTargetId = null; - this.removeActiveHighlightMesh(); + this.clearShaderHighlight(); if (this.composer) { this.composer.setOutlineObjects([]); } } - private removeActiveHighlightMesh(): void { - if (this.activeHighlightMesh) { - this.world.stage?.scene?.remove?.(this.activeHighlightMesh); - this.activeHighlightMesh = null; + private clearShaderHighlight(): void { + if (this.shaderHighlightEntity) { + const entity = this.shaderHighlightEntity as { + setShaderHighlight?: (on: boolean) => boolean; + }; + entity.setShaderHighlight?.(false); + this.shaderHighlightEntity = null; } } diff --git a/packages/shared/src/systems/shared/entities/Entities.ts b/packages/shared/src/systems/shared/entities/Entities.ts index 747ff8faf..79c5cc384 100644 --- a/packages/shared/src/systems/shared/entities/Entities.ts +++ b/packages/shared/src/systems/shared/entities/Entities.ts @@ -187,7 +187,7 @@ export class Entities extends SystemBase implements IEntities { constructor(world: World) { super(world, { name: "entities", - dependencies: { required: [], optional: [] }, + dependencies: { required: ["physics"], optional: [] }, autoCleanup: true, }); this.items = new Map(); @@ -724,6 +724,7 @@ export class Entities extends SystemBase implements IEntities { depletedModelPath: (data as { depletedModelPath?: string }) .depletedModelPath, procgenPreset: (data as { procgenPreset?: string }).procgenPreset, + modelVariants: (data as { modelVariants?: string[] }).modelVariants, }; const ResourceEntityClass = getEntityType("resource")!; diff --git a/packages/shared/src/systems/shared/entities/EntityManager.ts b/packages/shared/src/systems/shared/entities/EntityManager.ts index 064011e95..2dbc263ed 100644 --- a/packages/shared/src/systems/shared/entities/EntityManager.ts +++ b/packages/shared/src/systems/shared/entities/EntityManager.ts @@ -628,7 +628,10 @@ export class EntityManager extends SystemBase { } } - async spawnEntity(config: EntityConfig): Promise { + async spawnEntity( + config: EntityConfig, + options?: { suppressBroadcast?: boolean }, + ): Promise { // CRITICAL: Only server should spawn entities // Clients receive entities via snapshot/network sync if (!this.world.isServer) { @@ -766,8 +769,10 @@ export class EntityManager extends SystemBase { } // Broadcast entityAdded to all clients (server-only) - // Single broadcast point to prevent duplicate packets - if (this.world.isServer) { + // Single broadcast point to prevent duplicate packets. + // When suppressBroadcast is true, the caller is responsible for batching + // and sending entities (e.g. ResourceSystem batches per-tile). + if (this.world.isServer && !options?.suppressBroadcast) { const network = this.world.network; if (network && typeof network.send === "function") { try { diff --git a/packages/shared/src/systems/shared/entities/ResourceSystem.ts b/packages/shared/src/systems/shared/entities/ResourceSystem.ts index 5e829d362..d054ae05e 100644 --- a/packages/shared/src/systems/shared/entities/ResourceSystem.ts +++ b/packages/shared/src/systems/shared/entities/ResourceSystem.ts @@ -169,6 +169,13 @@ export class ResourceSystem extends SystemBase { // Terrain system reference for height lookups private terrainSystem: TerrainSystem | null = null; + /** + * When true, batched entity spawn packets use HIGH priority to bypass + * per-connection bandwidth throttling. Set to false to revert to NORMAL + * priority (may cause sparse entities during rapid tile generation). + */ + useHighPriorityBatch = true; + // ===== FORESTRY-STYLE RESOURCE TIMERS (OSRS-accurate) ===== /** * Per-resource depletion timer for Forestry-style tree mechanics. @@ -837,7 +844,10 @@ export class ResourceSystem extends SystemBase { // Get EntityManager for spawning const entityManager = this.world.getSystem("entity-manager") as { - spawnEntity?: (config: unknown) => Promise; + spawnEntity?: ( + config: unknown, + options?: { suppressBroadcast?: boolean }, + ) => Promise<{ id?: string; serialize?: () => unknown } | null>; } | null; if (!entityManager?.spawnEntity) { console.error( @@ -847,6 +857,7 @@ export class ResourceSystem extends SystemBase { } let spawned = 0; + const batchedEntityData: unknown[] = []; for (const spawnPoint of spawnPoints) { try { @@ -869,14 +880,11 @@ export class ResourceSystem extends SystemBase { `[ResourceSystem] Stored resource in map: id="${resource.id}", rid="${rid}", map size=${this.resources.size}${isManifest ? " (manifest)" : ""}`, ); } - // Track variant/subtype for tuning (e.g., 'tree_oak') - if (resource.type === "tree") { - // Build full key: if subType is "normal", key is "tree_normal" - const variant = spawnPoint.subType - ? `tree_${spawnPoint.subType}` - : "tree_normal"; - this.resourceVariants.set(rid, variant); - } + // Track variant/subtype for tuning (e.g., 'tree_oak', 'ore_copper') + const variant = spawnPoint.subType + ? `${resource.type}_${spawnPoint.subType}` + : `${resource.type}_normal`; + this.resourceVariants.set(rid, variant); // OSRS-ACCURACY: Initialize fishing spot movement timer if ( @@ -977,17 +985,26 @@ export class ResourceSystem extends SystemBase { resource.type, spawnPoint.subType, ), + // Model variants for visual variation (hash-picked per instance) + modelVariants: this.getModelVariantsForResource( + resource.type, + spawnPoint.subType, + ), // OSRS-ACCURACY: Tile-based positioning for face direction and interaction footprint, anchorTile, occupiedTiles, }; - const spawnedEntity = (await entityManager.spawnEntity( - resourceConfig, - )) as { id?: string } | null; + // Suppress individual broadcasts; we batch them below + const spawnedEntity = await entityManager.spawnEntity(resourceConfig, { + suppressBroadcast: true, + }); if (spawnedEntity) { spawned++; + if (typeof spawnedEntity.serialize === "function") { + batchedEntityData.push(spawnedEntity.serialize()); + } } } catch (err) { console.error( @@ -997,9 +1014,29 @@ export class ResourceSystem extends SystemBase { } } + // Send all entities for this tile as a single batch packet to avoid + // per-entity bandwidth-budget drops during rapid tile generation. + // useHighPriorityBatch controls whether HIGH or NORMAL priority is used. + if (batchedEntityData.length > 0 && this.world.isServer) { + const network = this.world.network as { + sendHighPriority?: (name: string, data: unknown) => void; + send?: (name: string, data: unknown) => void; + } | null; + if (network) { + if ( + this.useHighPriorityBatch && + typeof network.sendHighPriority === "function" + ) { + network.sendHighPriority("entitiesBatchAdded", batchedEntityData); + } else if (typeof network.send === "function") { + network.send("entitiesBatchAdded", batchedEntityData); + } + } + } + if (spawned > 0) { console.log( - `[ResourceSystem] Spawned ${spawned}/${spawnPoints.length} resource entities${isManifest ? " (manifest)" : ""}`, + `[ResourceSystem] Spawned ${spawned}/${spawnPoints.length} resource entities (batch packet: ${batchedEntityData.length})${isManifest ? " (manifest)" : ""}`, ); } } @@ -1122,6 +1159,20 @@ export class ResourceSystem extends SystemBase { return manifestData.procgenPreset; } + /** + * Get model variants for resource type from manifest. + * Returns undefined if not specified (single model or procgen). + */ + private getModelVariantsForResource( + type: string, + subType?: string, + ): string[] | undefined { + const variantKey = subType ? `${type}_${subType}` : `${type}_normal`; + const manifestData = getExternalResource(variantKey); + if (!manifestData?.modelVariants?.length) return undefined; + return manifestData.modelVariants; + } + /** * Get drops for resource type from manifest * Fails fast if manifest data not found @@ -1224,13 +1275,19 @@ export class ResourceSystem extends SystemBase { } /** - * Handle terrain tile unloading - remove resources from unloaded tiles - * Note: Manifest resources (from world-areas.json) are protected and never deleted + * Handle terrain tile unloading - remove resources from unloaded tiles. + * Destroys the backing EntityManager entities so they don't leak and can be + * re-broadcast when the tile is loaded again later. + * Note: Manifest resources (from world-areas.json) are protected and never deleted. */ private onTerrainTileUnloaded(data: { tileId: string }): void { // Extract tileX and tileZ from tileId (format: "x,z") const [tileX, tileZ] = data.tileId.split(",").map(Number); + const entityManager = this.world.getSystem("entity-manager") as { + destroyEntity?: (id: string) => boolean; + } | null; + // Remove resources that belong to this tile (but not manifest resources) for (const [resourceId, resource] of this.resources) { // Skip manifest resources - they are permanent and shouldn't be deleted on tile unload @@ -1243,6 +1300,14 @@ export class ResourceSystem extends SystemBase { const resourceTileZ = Math.floor(resource.position.z / 100); if (resourceTileX === tileX && resourceTileZ === tileZ) { + // Destroy the entity in EntityManager so it's removed from the + // entities map and broadcast as entityRemoved to clients. Without + // this, the entity lingers and the duplicate-ID check in + // spawnEntity prevents it from being re-created on tile revisit. + if (entityManager?.destroyEntity) { + entityManager.destroyEntity(resource.id); + } + this.resources.delete(resourceId); // Clean up any active gathering on this resource @@ -1605,8 +1670,14 @@ export class ResourceSystem extends SystemBase { const currentTick = this.world.currentTick || 0; // Compute tick-based cycle interval - const variant = - this.resourceVariants.get(sessionResourceId) || "tree_normal"; + const variant = this.resourceVariants.get(sessionResourceId); + if (!variant) { + console.error( + `[ResourceSystem] No variant tracked for resource '${resource.id}' (type: ${resource.type}). ` + + `Was the resource registered via spawnResources()?`, + ); + return; + } const tuned = this.getVariantTuning(variant); // Get best tool tier using unified tool system @@ -2931,7 +3002,8 @@ export class ResourceSystem extends SystemBase { */ private getResourceDespawnTicks(resourceId: ResourceID): number { // Get the variant key (e.g., "tree_oak", "tree_willow") - const variantKey = this.resourceVariants.get(resourceId) || "tree_normal"; + const variantKey = this.resourceVariants.get(resourceId); + if (!variantKey) return 0; // Extract tree type from variant key (e.g., "tree_oak" -> "oak") const parts = variantKey.split("_"); diff --git a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts index 129f5fe7f..bc719c7c3 100644 --- a/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts +++ b/packages/shared/src/systems/shared/world/BiomeResourceGenerator.ts @@ -22,6 +22,12 @@ import type { ResourceSubType, } from "../../../types/world/terrain"; import type { VegetationInstance } from "../../../types/world/world-types"; +import { + getTreeLevelRequired, + treeIdToSubType, +} from "../../../constants/TreeTypes"; +import type { TreePlacementRules } from "../../../constants/TreeTypes"; +import { getTreeConfigForBiome } from "./TerrainBiomeTypes"; /** * Context provided by TerrainSystem for resource generation. @@ -41,24 +47,20 @@ export interface ResourceGenerationContext { getHeightAt: (worldX: number, worldZ: number) => number; /** Check if position is on a road */ isOnRoad?: (worldX: number, worldZ: number) => boolean; + /** Get the dominant biome at a world position (for per-tree biome selection) */ + getDominantBiome?: (worldX: number, worldZ: number) => string; /** Deterministic RNG seeded for this tile */ createRng: (salt: string) => () => number; } /** - * Level requirements for tree types (OSRS woodcutting levels). - * Single source of truth - used by both generation and tests. + * @deprecated Use getTreeLevelRequired() from TreeTypes.ts instead. + * Kept for backward compatibility — delegates to the single source of truth. */ -export const TREE_LEVEL_REQUIREMENTS: Record = { - normal: 1, - oak: 15, - willow: 30, - teak: 35, - maple: 45, - mahogany: 50, - yew: 60, - magic: 75, -}; +export const TREE_LEVEL_REQUIREMENTS: Record = new Proxy( + {} as Record, + { get: (_target, prop: string) => getTreeLevelRequired(prop) }, +); /** * Mapping from game tree subtypes to @hyperscape/procgen presets. @@ -109,9 +111,10 @@ export const ORE_LEVEL_REQUIREMENTS: Record = { /** * Get the level requirement for a tree type. + * @deprecated Use getTreeLevelRequired() from TreeTypes.ts directly. */ export function getTreeLevelRequirement(subType: string): number { - return TREE_LEVEL_REQUIREMENTS[subType] ?? 1; + return getTreeLevelRequired(subType); } /** @@ -146,21 +149,22 @@ export function generateTrees( return []; } + const maxSlope = treeConfig.maxSlope ?? Infinity; + // Use deterministic RNG for reproducible placement const rng = ctx.createRng("trees"); - // Get distribution weights - const distribution = treeConfig.distribution; - const treeTypes = Object.keys(distribution); - if (treeTypes.length === 0) { + // Pre-compute tile-level distribution from the merged trees map + const tileTreeMap = treeConfig.trees; + const tileTreeTypes = Object.keys(tileTreeMap); + if (tileTreeTypes.length === 0) { return []; } - - const totalWeight = Object.values(distribution).reduce( - (sum, w) => sum + w, + const tileTotalWeight = Object.values(tileTreeMap).reduce( + (sum, cfg) => sum + cfg.weight, 0, ); - if (totalWeight === 0) { + if (tileTotalWeight === 0) { return []; } @@ -240,18 +244,75 @@ export function generateTrees( continue; } + // Reject steep slopes — sample 4 neighbors to estimate gradient magnitude + if (maxSlope < Infinity) { + const sd = 1.0; + const dhdx = + (ctx.getHeightAt(worldX + sd, worldZ) - + ctx.getHeightAt(worldX - sd, worldZ)) / + (2 * sd); + const dhdz = + (ctx.getHeightAt(worldX, worldZ + sd) - + ctx.getHeightAt(worldX, worldZ - sd)) / + (2 * sd); + if (dhdx * dhdx + dhdz * dhdz > maxSlope * maxSlope) continue; + } + + // Resolve the tree map for THIS position. If we have a per-position + // biome callback, use it to get the actual biome here instead of + // relying on the single tile-center biome. + let activeTreeMap = tileTreeMap; + let treeTypes = tileTreeTypes; + let totalWeight = tileTotalWeight; + + if (ctx.getDominantBiome) { + const positionBiome = ctx.getDominantBiome(worldX, worldZ); + const posConfig = getTreeConfigForBiome(positionBiome); + if (posConfig && posConfig.trees !== tileTreeMap) { + activeTreeMap = posConfig.trees; + treeTypes = Object.keys(activeTreeMap); + totalWeight = Object.values(activeTreeMap).reduce( + (sum, cfg) => sum + cfg.weight, + 0, + ); + if (totalWeight === 0 || treeTypes.length === 0) continue; + } + } + // Select tree type based on weighted distribution - let selectedType = "normal"; + let selectedTreeId = treeTypes[0]; const roll = rng() * totalWeight; let cumulative = 0; for (const treeType of treeTypes) { - cumulative += distribution[treeType]; + cumulative += activeTreeMap[treeType].weight; if (roll < cumulative) { - // Extract subtype: "tree_oak" -> "oak", "tree_normal" -> "normal" - selectedType = treeType.replace("tree_", ""); + selectedTreeId = treeType; break; } } + const selectedType = treeIdToSubType(selectedTreeId); + + // Apply per-tree placement rules from the merged config + const rules: TreePlacementRules | undefined = activeTreeMap[selectedTreeId]; + if (rules) { + const heightAboveWater = height - ctx.waterThreshold; + + if (rules.minHeight !== undefined && height < rules.minHeight) continue; + if (rules.maxHeight !== undefined && height > rules.maxHeight) continue; + + if ( + rules.avoidsWaterBelow !== undefined && + heightAboveWater < rules.avoidsWaterBelow + ) + continue; + + if (rules.waterAffinity && rules.waterAffinity > 0) { + const proximityLimit = rules.waterProximityHeight ?? 10; + if (heightAboveWater > proximityLimit) { + if (rng() < rules.waterAffinity) continue; + } + } + } // Generate random scale within variation range const [minScale, maxScale] = treeConfig.scaleVariation ?? [0.8, 1.2]; @@ -473,46 +534,18 @@ export const ROCK_BIOME_DEFAULTS: Record< string, { presets: string[]; distribution: Record } > = { + tundra: { + presets: ["granite", "basalt", "boulder"], + distribution: { granite: 2, basalt: 2, boulder: 2 }, + }, forest: { presets: ["boulder", "granite", "limestone"], distribution: { boulder: 3, granite: 2, limestone: 1 }, }, - plains: { - presets: ["boulder", "pebble", "sandstone"], - distribution: { boulder: 2, pebble: 3, sandstone: 1 }, - }, - desert: { + canyon: { presets: ["sandstone", "limestone", "pebble"], distribution: { sandstone: 4, limestone: 2, pebble: 1 }, }, - mountains: { - presets: ["granite", "basalt", "cliff"], - distribution: { granite: 3, basalt: 2, cliff: 2 }, - }, - mountain: { - presets: ["granite", "basalt", "cliff"], - distribution: { granite: 3, basalt: 2, cliff: 2 }, - }, - swamp: { - presets: ["limestone", "slate", "pebble"], - distribution: { limestone: 2, slate: 2, pebble: 3 }, - }, - frozen: { - presets: ["granite", "basalt", "boulder"], - distribution: { granite: 2, basalt: 2, boulder: 2 }, - }, - wastes: { - presets: ["basalt", "slate", "asteroid"], - distribution: { basalt: 3, slate: 2, asteroid: 1 }, - }, - corrupted: { - presets: ["obsidian", "basalt", "crystal"], - distribution: { obsidian: 3, basalt: 2, crystal: 2 }, - }, - lake: { - presets: ["pebble", "limestone", "boulder"], - distribution: { pebble: 4, limestone: 2, boulder: 1 }, - }, }; /** @@ -523,7 +556,7 @@ export function getRockPresetsForBiome(biomeType: string): { distribution: Record; } { return ( - ROCK_BIOME_DEFAULTS[biomeType.toLowerCase()] ?? ROCK_BIOME_DEFAULTS.plains + ROCK_BIOME_DEFAULTS[biomeType.toLowerCase()] ?? ROCK_BIOME_DEFAULTS.forest ); } @@ -706,6 +739,10 @@ export const PLANT_BIOME_DEFAULTS: Record< string, { presets: string[]; distribution: Record } > = { + tundra: { + presets: ["bergenia", "pulmonaria"], + distribution: { bergenia: 2, pulmonaria: 2 }, + }, forest: { presets: ["monstera", "philodendron", "calathea", "ficus", "hosta"], distribution: { @@ -716,54 +753,10 @@ export const PLANT_BIOME_DEFAULTS: Record< hosta: 3, }, }, - plains: { - presets: ["hosta", "heuchera", "bergenia", "maranta"], - distribution: { hosta: 3, heuchera: 2, bergenia: 2, maranta: 1 }, - }, - desert: { + canyon: { presets: ["zamioculcas", "aglaonema", "syngonium"], distribution: { zamioculcas: 3, aglaonema: 2, syngonium: 1 }, }, - mountains: { - presets: ["bergenia", "pulmonaria", "heuchera"], - distribution: { bergenia: 2, pulmonaria: 2, heuchera: 2 }, - }, - mountain: { - presets: ["bergenia", "pulmonaria", "heuchera"], - distribution: { bergenia: 2, pulmonaria: 2, heuchera: 2 }, - }, - swamp: { - presets: [ - "monstera", - "colocasia", - "xanthosoma", - "alocasia", - "spathiphyllum", - ], - distribution: { - monstera: 2, - colocasia: 3, - xanthosoma: 2, - alocasia: 2, - spathiphyllum: 2, - }, - }, - frozen: { - presets: ["bergenia", "pulmonaria"], - distribution: { bergenia: 2, pulmonaria: 2 }, - }, - wastes: { - presets: ["zamioculcas", "aglaonema"], - distribution: { zamioculcas: 2, aglaonema: 2 }, - }, - corrupted: { - presets: ["alocasia", "caladium", "anthurium"], - distribution: { alocasia: 2, caladium: 2, anthurium: 2 }, - }, - lake: { - presets: ["colocasia", "calla", "arum", "spathiphyllum"], - distribution: { colocasia: 2, calla: 2, arum: 2, spathiphyllum: 2 }, - }, }; /** diff --git a/packages/shared/src/systems/shared/world/BuildingRenderingSystem.ts b/packages/shared/src/systems/shared/world/BuildingRenderingSystem.ts index 4f3dcdd34..887bca61e 100644 --- a/packages/shared/src/systems/shared/world/BuildingRenderingSystem.ts +++ b/packages/shared/src/systems/shared/world/BuildingRenderingSystem.ts @@ -42,7 +42,7 @@ * - Depends on TownSystem for building placement data * - Depends on TerrainSystem for height queries * - Uses BuildingGenerator from @hyperscape/procgen for mesh generation - * - Uses unified LOD configuration from GPUVegetation + * - Uses unified LOD configuration from LODConfig * - Uses ImpostorManager for octahedral impostor baking * * **Runs on:** Client only (buildings are purely visual on client) @@ -322,7 +322,7 @@ import { snapToBuildingGrid, computeTangentsForNonIndexed, } from "@hyperscape/procgen/building"; -import { getLODDistances, type LODDistancesWithSq } from "./GPUVegetation"; +import { getLODDistances, type LODDistancesWithSq } from "./LODConfig"; import { ImpostorManager, BakePriority, diff --git a/packages/shared/src/systems/shared/world/Environment.ts b/packages/shared/src/systems/shared/world/Environment.ts index e08333633..173116cb6 100644 --- a/packages/shared/src/systems/shared/world/Environment.ts +++ b/packages/shared/src/systems/shared/world/Environment.ts @@ -36,6 +36,26 @@ const _sunDirection = new THREE.Vector3(0, -1, 0); // - More cascades = better near/far resolution but more draw calls // - CSMShadowNode handles texel snapping internally - don't add extra snapping // +// Set ENABLE_CSM=true to use cascaded shadow maps (heavy GPU cost). +// Default: false — uses a single 2048 shadow map centered on the player. +export function isCsmEnabled(): boolean { + try { + if ( + typeof import.meta !== "undefined" && + (import.meta as any).env?.ENABLE_CSM + ) + return (import.meta as any).env.ENABLE_CSM === "true"; + if (typeof process !== "undefined" && process.env?.ENABLE_CSM) + return process.env.ENABLE_CSM === "true"; + } catch { + /* ignore */ + } + return false; +} + +const SINGLE_SHADOW_MAP_SIZE = 2048; +const SINGLE_SHADOW_FRUSTUM = 80; + // IMPORTANT: Vegetation fade distances should be <= maxFar so trees don't appear unshadowed export const csmLevels = { none: { @@ -929,10 +949,9 @@ export class Environment extends System { } /** - * Build directional light (sun/moon) with CSMShadowNode for cascaded shadows - * CSMShadowNode handles cascade splitting internally - we just configure it - * - * Note: WebGPU is required. CSM shadows only work with WebGPU's TSL pipeline. + * Build directional light (sun/moon) with optional CSMShadowNode. + * When ENABLE_CSM=true: uses cascaded shadow maps (multiple passes, heavy). + * When ENABLE_CSM=false (default): uses a single shadow map centered on the player. */ buildSunLight(): void { if (!this.isClientWithGraphics) return; @@ -941,6 +960,7 @@ export class Environment extends System { const shadowsLevel = this.world.prefs?.shadows || "med"; const csmConfig = csmLevels[shadowsLevel as keyof typeof csmLevels] || csmLevels.med; + const useCSM = isCsmEnabled() && csmConfig.enabled; if (!this.world.stage?.scene) { console.warn( @@ -972,74 +992,90 @@ export class Environment extends System { return; } - // Create directional light for CSM + // Create directional light this.sunLight = new THREE.DirectionalLight(0xffffff, 1.8); - this.sunLight.name = useWebGPU ? "SunLight_CSM" : "SunLight_WebGL"; this.sunLight.castShadow = true; - // Shadow map settings (CSMShadowNode will use this as base resolution per cascade) - this.sunLight.shadow.mapSize.width = csmConfig.shadowMapSize; - this.sunLight.shadow.mapSize.height = csmConfig.shadowMapSize; - this.sunLight.shadow.bias = csmConfig.shadowBias; - this.sunLight.shadow.normalBias = csmConfig.shadowNormalBias; - - // Shadow camera settings - CSMShadowNode overrides these per-cascade - const shadowCam = this.sunLight.shadow.camera; - shadowCam.near = 0.5; - shadowCam.far = this.LIGHT_DISTANCE + 200; // Light distance + scene depth - // Base frustum - CSMShadowNode will manage actual cascade frustums - const baseFrustumSize = 100; - shadowCam.left = -baseFrustumSize; - shadowCam.right = baseFrustumSize; - shadowCam.top = baseFrustumSize; - shadowCam.bottom = -baseFrustumSize; - shadowCam.updateProjectionMatrix(); - - // Initial position - this.sunLight.position.set(100, 200, 100); - this.sunLight.target.position.set(0, 0, 0); - - // Create CSMShadowNode for cascaded shadows - // Light direction is derived from sunLight.position and sunLight.target.position - // - // Custom split callback biases toward logarithmic for sharper near shadows: - // - Higher lambda (0.7-0.9) = more logarithmic = smaller near cascade = sharper near shadows - // - Lower lambda (0.3-0.5) = more uniform = larger near cascade = blurrier near shadows - const customSplitCallback = ( - cascades: number, - near: number, - far: number, - breaks: number[], - ) => { - const lambda = 0.8; // Bias toward logarithmic for better near-cascade resolution - for (let i = 1; i < cascades; i++) { - const log = (near * Math.pow(far / near, i / cascades)) / far; - const uniform = (near + ((far - near) * i) / cascades) / far; - breaks.push(lambda * log + (1 - lambda) * uniform); - } - breaks.push(1); - }; + if (useCSM) { + // ---- CSM PATH ---- + this.sunLight.name = useWebGPU ? "SunLight_CSM" : "SunLight_WebGL"; + this.sunLight.shadow.mapSize.width = csmConfig.shadowMapSize; + this.sunLight.shadow.mapSize.height = csmConfig.shadowMapSize; + this.sunLight.shadow.bias = csmConfig.shadowBias; + this.sunLight.shadow.normalBias = csmConfig.shadowNormalBias; + + const shadowCam = this.sunLight.shadow.camera; + shadowCam.near = 0.5; + shadowCam.far = this.LIGHT_DISTANCE + 200; + const baseFrustumSize = 100; + shadowCam.left = -baseFrustumSize; + shadowCam.right = baseFrustumSize; + shadowCam.top = baseFrustumSize; + shadowCam.bottom = -baseFrustumSize; + shadowCam.updateProjectionMatrix(); + + this.sunLight.position.set(100, 200, 100); + this.sunLight.target.position.set(0, 0, 0); + + const customSplitCallback = ( + cascades: number, + near: number, + far: number, + breaks: number[], + ) => { + const lambda = 0.8; + for (let i = 1; i < cascades; i++) { + const log = (near * Math.pow(far / near, i / cascades)) / far; + const uniform = (near + ((far - near) * i) / cascades) / far; + breaks.push(lambda * log + (1 - lambda) * uniform); + } + breaks.push(1); + }; - this.csmShadowNode = new CSMShadowNode(this.sunLight, { - cascades: csmConfig.cascades, - maxFar: csmConfig.maxFar, - mode: "custom", - customSplitsCallback: customSplitCallback, - lightMargin: csmConfig.lightMargin, // Prevents shadow "swimming" artifacts - }); - // Avoid pre-assigning camera so CSMShadowNode can initialize internally. + this.csmShadowNode = new CSMShadowNode(this.sunLight, { + cascades: csmConfig.cascades, + maxFar: csmConfig.maxFar, + mode: "custom", + customSplitsCallback: customSplitCallback, + lightMargin: csmConfig.lightMargin, + }); + this.csmShadowNode.fade = true; + + const shadow = this.sunLight.shadow as THREE.DirectionalLightShadow & { + shadowNode?: InstanceType; + }; + shadow.shadowNode = this.csmShadowNode; + this.needsFrustumUpdate = true; - // Enable smooth cascade transitions (prevents hard seams between cascades) - this.csmShadowNode.fade = true; + console.log( + `[Environment] CSM shadows enabled (${csmConfig.cascades} cascades, ${csmConfig.shadowMapSize}px)`, + ); + } else { + // ---- SINGLE SHADOW MAP PATH (default) ---- + this.sunLight.name = "SunLight_Single"; + this.sunLight.shadow.mapSize.width = SINGLE_SHADOW_MAP_SIZE; + this.sunLight.shadow.mapSize.height = SINGLE_SHADOW_MAP_SIZE; + this.sunLight.shadow.bias = 0.0002; + this.sunLight.shadow.normalBias = 0.01; + + const shadowCam = this.sunLight.shadow.camera; + shadowCam.near = 0.5; + shadowCam.far = this.LIGHT_DISTANCE + 200; + shadowCam.left = -SINGLE_SHADOW_FRUSTUM; + shadowCam.right = SINGLE_SHADOW_FRUSTUM; + shadowCam.top = SINGLE_SHADOW_FRUSTUM; + shadowCam.bottom = -SINGLE_SHADOW_FRUSTUM; + shadowCam.updateProjectionMatrix(); + + this.sunLight.position.set(100, 200, 100); + this.sunLight.target.position.set(0, 0, 0); + this.csmShadowNode = null; - const shadow = this.sunLight.shadow as THREE.DirectionalLightShadow & { - shadowNode?: InstanceType; - }; - shadow.shadowNode = this.csmShadowNode; + console.log( + `[Environment] Single shadow map (${SINGLE_SHADOW_MAP_SIZE}px, ${SINGLE_SHADOW_FRUSTUM}m frustum)`, + ); + } - // Defer frustum initialization until after the first render when the camera is ready. - this.needsFrustumUpdate = true; - // Add light to scene scene.add(this.sunLight); scene.add(this.sunLight.target); } diff --git a/packages/shared/src/systems/shared/world/FogConfig.ts b/packages/shared/src/systems/shared/world/FogConfig.ts index 7cb5529dc..496b176ff 100644 --- a/packages/shared/src/systems/shared/world/FogConfig.ts +++ b/packages/shared/src/systems/shared/world/FogConfig.ts @@ -3,7 +3,7 @@ * * Single source of truth for all fog-related constants. * Imported by SkySystem (fog render target), TerrainShader, WaterSystem, - * GPUVegetation, and any other system that needs fog parameters. + * DissolveMaterial, and any other system that needs fog parameters. * * FOG TECHNIQUE: * The sky dome is rendered to a low-res offscreen texture each frame. diff --git a/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts b/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts index 4d291602d..5418e5122 100644 --- a/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBResourceInstancer.ts @@ -19,10 +19,10 @@ import type { World } from "../../../core/World"; import { modelCache } from "../../../utils/rendering/ModelCache"; import { createDissolveMaterial, - getLODDistances, GPU_VEG_CONFIG, type DissolveMaterial, -} from "./GPUVegetation"; +} from "./GPUMaterials"; +import { getLODDistances, inferLOD1Path, inferLOD2Path } from "./LODConfig"; const MAX_INSTANCES = 512; @@ -49,6 +49,7 @@ interface LODPool { slots: Map; activeCount: number; dirty: boolean; + highlightData: Float32Array; } interface ModelPool { @@ -60,8 +61,6 @@ interface ModelPool { instances: Map; yOffset: number; depletedYOffset: number; - highlightMesh: THREE.Mesh | null; - depletedHighlightMesh: THREE.Mesh | null; } const resourceLOD = getLODDistances("resource"); @@ -71,8 +70,6 @@ let world: World | null = null; const pools = new Map(); const entityToModel = new Map(); - - function extractGeometryAndMaterial( root: THREE.Object3D, ): { geometry: THREE.BufferGeometry; material: THREE.Material } | null { @@ -129,15 +126,27 @@ function createLODPool( material: DissolveMaterial, ): LODPool { const geo = createSharedGeometry(geometry); + const hlData = new Float32Array(MAX_INSTANCES); + const hlAttr = new THREE.InstancedBufferAttribute(hlData, 1); + hlAttr.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute("instanceHighlight", hlAttr); + const mesh = new THREE.InstancedMesh(geo, material, MAX_INSTANCES); mesh.count = 0; mesh.frustumCulled = false; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.layers.set(1); scene!.add(mesh); - return { mesh, material, slots: new Map(), activeCount: 0, dirty: false }; + return { + mesh, + material, + slots: new Map(), + activeCount: 0, + dirty: false, + highlightData: hlData, + }; } async function loadLODModel(path: string): Promise<{ @@ -158,20 +167,6 @@ async function loadLODModel(path: string): Promise<{ const pendingEnsure = new Map>(); -function createHighlightMesh( - geometry: THREE.BufferGeometry, - material: THREE.Material, -): THREE.Mesh { - const geo = createSharedGeometry(geometry); - const mesh = new THREE.Mesh(geo, material); - mesh.frustumCulled = false; - mesh.castShadow = false; - mesh.receiveShadow = false; - mesh.layers.set(1); - mesh.visible = true; - return mesh; -} - async function ensureModelPool( modelPath: string, depletedModelPath?: string | null, @@ -196,50 +191,67 @@ async function ensureModelPool( const yOffset = computeYOffset(lod0Scene, 1); - const lod0Material = createDissolveMaterial(lod0Data.material, { + const dissolveOpts = { fadeStart: GPU_VEG_CONFIG.FADE_START, fadeEnd: GPU_VEG_CONFIG.FADE_END, enableNearFade: false, enableWaterCulling: false, enableOcclusionDissolve: false, - }); - world!.setupMaterial(lod0Material); - const lod0Pool = createLODPool(lod0Data.geometry, lod0Material); + enableRimHighlight: true, + }; - const highlightMesh = createHighlightMesh( - lod0Data.geometry, + const lod0Material = createDissolveMaterial( lod0Data.material, + dissolveOpts, ); + world!.setupMaterial(lod0Material); + const lod0Pool = createLODPool(lod0Data.geometry, lod0Material); + // LOD1 — explicit path first, fall back to inferred naming convention let lod1Pool: LODPool | null = null; - if (lod1ModelPath) { - const lod1Data = await loadLODModel(lod1ModelPath); - if (lod1Data) { - const lod1Material = createDissolveMaterial(lod1Data.material, { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - }); + const lod1Data = await loadLODModel( + lod1ModelPath ?? inferLOD1Path(modelPath), + ); + if (lod1Data) { + const lod1Material = createDissolveMaterial( + lod1Data.material, + dissolveOpts, + ); + world!.setupMaterial(lod1Material); + lod1Pool = createLODPool(lod1Data.geometry, lod1Material); + } else if (lod1ModelPath) { + const lod1Inferred = await loadLODModel(inferLOD1Path(modelPath)); + if (lod1Inferred) { + const lod1Material = createDissolveMaterial( + lod1Inferred.material, + dissolveOpts, + ); world!.setupMaterial(lod1Material); - lod1Pool = createLODPool(lod1Data.geometry, lod1Material); + lod1Pool = createLODPool(lod1Inferred.geometry, lod1Material); } } + // LOD2 — explicit path first, fall back to inferred naming convention let lod2Pool: LODPool | null = null; - if (lod2ModelPath) { - const lod2Data = await loadLODModel(lod2ModelPath); - if (lod2Data) { - const lod2Material = createDissolveMaterial(lod2Data.material, { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - }); + const lod2Data = await loadLODModel( + lod2ModelPath ?? inferLOD2Path(modelPath), + ); + if (lod2Data) { + const lod2Material = createDissolveMaterial( + lod2Data.material, + dissolveOpts, + ); + world!.setupMaterial(lod2Material); + lod2Pool = createLODPool(lod2Data.geometry, lod2Material); + } else if (lod2ModelPath) { + const lod2Inferred = await loadLODModel(inferLOD2Path(modelPath)); + if (lod2Inferred) { + const lod2Material = createDissolveMaterial( + lod2Inferred.material, + dissolveOpts, + ); world!.setupMaterial(lod2Material); - lod2Pool = createLODPool(lod2Data.geometry, lod2Material); + lod2Pool = createLODPool(lod2Inferred.geometry, lod2Material); } } @@ -252,8 +264,6 @@ async function ensureModelPool( instances: new Map(), yOffset, depletedYOffset: 0, - highlightMesh, - depletedHighlightMesh: null, }; pools.set(modelPath, pool); @@ -297,14 +307,11 @@ async function loadDepletedPool( enableNearFade: false, enableWaterCulling: false, enableOcclusionDissolve: false, + enableRimHighlight: true, }); world!.setupMaterial(depletedMaterial); pool.depleted = createLODPool(depletedData.geometry, depletedMaterial); pool.depletedYOffset = depletedYOffset; - pool.depletedHighlightMesh = createHighlightMesh( - depletedData.geometry, - depletedData.material, - ); } // --------------------------------------------------------------------------- @@ -340,6 +347,7 @@ function removeFromPool(pool: LODPool, entityId: string): void { if (idx !== lastIdx) { pool.mesh.getMatrixAt(lastIdx, _swapMatrix); pool.mesh.setMatrixAt(idx, _swapMatrix); + pool.highlightData[idx] = pool.highlightData[lastIdx]; for (const [eid, eidIdx] of pool.slots) { if (eidIdx === lastIdx) { @@ -348,6 +356,7 @@ function removeFromPool(pool: LODPool, entityId: string): void { } } } + pool.highlightData[lastIdx] = 0; pool.slots.delete(entityId); pool.activeCount--; @@ -372,9 +381,6 @@ export function destroyGLBResourceInstancer(): void { lodPool.mesh.geometry.dispose(); lodPool.material.dispose(); } - if (pool.highlightMesh) pool.highlightMesh.geometry.dispose(); - if (pool.depletedHighlightMesh) - pool.depletedHighlightMesh.geometry.dispose(); } pools.clear(); entityToModel.clear(); @@ -397,7 +403,12 @@ export async function addInstance( if (!scene || !world) return false; try { - const pool = await ensureModelPool(modelPath, depletedModelPath, lod1ModelPath, lod2ModelPath); + const pool = await ensureModelPool( + modelPath, + depletedModelPath, + lod1ModelPath, + lod2ModelPath, + ); if (pool.lod0 && pool.lod0.activeCount >= MAX_INSTANCES) { console.warn( @@ -465,13 +476,6 @@ export function setDepleted(entityId: string, depleted: boolean): void { const slot = pool.instances.get(entityId); if (!slot || slot.depleted === depleted) return; - const oldHlMesh = slot.depleted - ? pool.depletedHighlightMesh - : pool.highlightMesh; - if (oldHlMesh?.parent) { - oldHlMesh.parent.remove(oldHlMesh); - } - slot.depleted = depleted; if (depleted) { @@ -524,31 +528,45 @@ export function hasDepleted(entityId: string): boolean { return !!pool?.depleted; } -export function getHighlightMesh(entityId: string): THREE.Object3D | null { +let highlightedEntityId: string | null = null; + +export function setHighlight(entityId: string, on: boolean): void { + if (on && highlightedEntityId && highlightedEntityId !== entityId) { + setHighlight(highlightedEntityId, false); + } + const modelPath = entityToModel.get(entityId); - if (!modelPath) return null; + if (!modelPath) return; const pool = pools.get(modelPath); - if (!pool) return null; + if (!pool) return; const slot = pool.instances.get(entityId); - if (!slot) return null; - - const mesh = slot.depleted ? pool.depletedHighlightMesh : pool.highlightMesh; - if (!mesh) return null; - - const s = slot.depleted ? slot.depletedScale : slot.scale; - const yOff = slot.depleted ? pool.depletedYOffset : pool.yOffset; - mesh.position.set( - slot.position.x, - slot.position.y + yOff * s, - slot.position.z, - ); - mesh.rotation.set(0, slot.rotation, 0); - mesh.scale.set(s, s, s); - mesh.updateMatrixWorld(true); - - return mesh; + if (!slot) return; + + const lodPool = slot.depleted + ? pool.depleted + : slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; + if (!lodPool) return; + + const idx = lodPool.slots.get(entityId); + if (idx === undefined) return; + + lodPool.highlightData[idx] = on ? 1.0 : 0.0; + const attr = lodPool.mesh.geometry.getAttribute("instanceHighlight"); + if (attr) (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + + highlightedEntityId = on ? entityId : null; +} + +export function clearHighlight(): void { + if (highlightedEntityId) { + setHighlight(highlightedEntityId, false); + } } let lastUpdateFrame = -1; @@ -602,6 +620,10 @@ export function updateGLBResourceInstancer(): void { const newPool = targetLOD === 0 ? pool.lod0 : targetLOD === 1 ? pool.lod1 : pool.lod2; + const wasHighlighted = + oldPool && oldPool.slots.has(slot.entityId) + ? oldPool.highlightData[oldPool.slots.get(slot.entityId)!] + : 0; if (oldPool) removeFromPool(oldPool, slot.entityId); if (newPool) { const mat = composeInstanceMatrix( @@ -611,6 +633,16 @@ export function updateGLBResourceInstancer(): void { slot.yOffset, ); addToPool(newPool, slot.entityId, mat); + if (wasHighlighted > 0) { + const newIdx = newPool.slots.get(slot.entityId); + if (newIdx !== undefined) { + newPool.highlightData[newIdx] = wasHighlighted; + const attr = + newPool.mesh.geometry.getAttribute("instanceHighlight"); + if (attr) + (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + } } slot.currentLOD = targetLOD; } diff --git a/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts new file mode 100644 index 000000000..8b4e3620f --- /dev/null +++ b/packages/shared/src/systems/shared/world/GLBTreeBatchedInstancer.ts @@ -0,0 +1,933 @@ +/** + * GLBTreeBatchedInstancer — BatchedMesh-based rendering for multi-variant trees. + * + * Loads all model variants for each tree type once, registers their + * geometries in a shared BatchedMesh per material slot per LOD level. + * Each tree instance picks a variant via addInstance(geometryId). + * + * One BatchedMesh per material slot (bark, leaves) per LOD = minimal + * draw calls regardless of how many variants a tree type has. + * + * Used when a tree type has `modelVariants` in its manifest. + * For single-model resources, use GLBTreeInstancer (InstancedMesh-based). + * + * @module GLBTreeBatchedInstancer + */ + +import THREE from "../../../extras/three/three"; +import type { World } from "../../../core/World"; +import { modelCache } from "../../../utils/rendering/ModelCache"; +import { + createTreeDissolveMaterial, + GPU_VEG_CONFIG, + type DissolveMaterial, + type TreeDissolveMaterial, + type TreeMaterialOptions, +} from "./GPUMaterials"; +import type { Wind } from "./Wind"; +import { getLODDistances, inferLOD1Path, inferLOD2Path } from "./LODConfig"; + +const MAX_INSTANCES = 512; + +const _matrix = new THREE.Matrix4(); +const _position = new THREE.Vector3(); +const _quaternion = new THREE.Quaternion(); +const _scale = new THREE.Vector3(); + +const _defaultColor = new THREE.Color(1, 1, 1); +const _hlColor = new THREE.Color(1.15, 1.15, 1.15); + +interface TreeSlot { + entityId: string; + position: THREE.Vector3; + rotation: number; + scale: number; + depletedScale: number; + yOffset: number; + currentLOD: 0 | 1 | 2; + depleted: boolean; + variantIndex: number; +} + +interface BatchedLODPool { + /** One BatchedMesh per material slot (e.g. [bark, leaves]) */ + batches: THREE.BatchedMesh[]; + materials: DissolveMaterial[]; + /** + * geometryIds[materialSlot][variantIndex] = geometryId returned by + * BatchedMesh.addGeometry() for that variant's geometry. + */ + geometryIds: number[][]; + /** entityId → array of instanceIds (one per BatchedMesh/material slot) */ + instanceIds: Map; +} + +interface TreeTypePool { + treeType: string; + variantPaths: string[]; + lod0: BatchedLODPool | null; + lod1: BatchedLODPool | null; + lod2: BatchedLODPool | null; + depleted: BatchedLODPool | null; + instances: Map; + yOffset: number; + depletedYOffset: number; + modelHeight: number; + modelRadius: number; +} + +const resourceLOD = getLODDistances("resource"); + +// ---- Module state ---- +let scene: THREE.Scene | null = null; +let world: World | null = null; +const pools = new Map(); +const entityToTreeType = new Map(); + +// ---- Geometry extraction ---- + +interface MeshPart { + geometry: THREE.BufferGeometry; + material: THREE.Material; +} + +function extractAllMeshParts(root: THREE.Object3D): MeshPart[] { + const parts: MeshPart[] = []; + root.traverse((child) => { + if (child instanceof THREE.Mesh && child.geometry) { + if (Array.isArray(child.material)) { + for (const mat of child.material) { + parts.push({ geometry: child.geometry, material: mat }); + } + } else { + parts.push({ geometry: child.geometry, material: child.material }); + } + } + }); + return parts; +} + +let _fingerprintId = 0; + +/** + * Returns a string key that identifies a material's diffuse texture. + * Used to match the same material slot across different model variants. + */ +function getTextureFingerprint(mat: THREE.Material): string { + const std = mat as THREE.MeshStandardMaterial; + if (std.map?.image) { + const img = std.map.image as { + width?: number; + height?: number; + src?: string; + uuid?: string; + }; + return `tex:${img.width}x${img.height}:${img.src ?? img.uuid ?? ""}`; + } + if (std.name) return `name:${std.name}`; + // Deterministic fallback — monotonic counter avoids random fingerprints + // that would silently prevent variant matching. + return `idx:${_fingerprintId++}`; +} + +/** + * Reorder `parts` so that each part's texture fingerprint matches + * the corresponding `refFingerprints[slotIdx]`. + * Returns reordered array, or null if matching fails. + */ +function matchPartsToReference( + refFingerprints: string[], + parts: MeshPart[], +): MeshPart[] | null { + if (parts.length !== refFingerprints.length) return null; + const partFingerprints = parts.map((p) => getTextureFingerprint(p.material)); + const used = new Set(); + const reordered: MeshPart[] = []; + + for (let slot = 0; slot < refFingerprints.length; slot++) { + let matched = -1; + for (let pi = 0; pi < partFingerprints.length; pi++) { + if (!used.has(pi) && partFingerprints[pi] === refFingerprints[slot]) { + matched = pi; + break; + } + } + if (matched === -1) { + // Fallback: try matching by material name + const refName = refFingerprints[slot].startsWith("name:") + ? refFingerprints[slot].slice(5) + : ""; + for (let pi = 0; pi < parts.length; pi++) { + if ( + !used.has(pi) && + (parts[pi].material as THREE.MeshStandardMaterial).name === refName + ) { + matched = pi; + break; + } + } + } + if (matched === -1) return null; + used.add(matched); + reordered.push(parts[matched]); + } + return reordered; +} + +function computeModelBounds( + root: THREE.Object3D, + scale: number, +): { + yOffset: number; + height: number; + radius: number; +} { + const saved = root.scale.clone(); + root.scale.set(scale, scale, scale); + const bbox = new THREE.Box3().setFromObject(root); + root.scale.copy(saved); + const height = bbox.max.y - bbox.min.y; + const dx = Math.max(Math.abs(bbox.min.x), Math.abs(bbox.max.x)); + const dz = Math.max(Math.abs(bbox.min.z), Math.abs(bbox.max.z)); + return { yOffset: -bbox.min.y, height, radius: Math.max(dx, dz) }; +} + +async function loadLODParts(path: string): Promise { + try { + const { scene: lodScene } = await modelCache.loadModel(path, world!); + const parts = extractAllMeshParts(lodScene); + return parts.length > 0 ? parts : null; + } catch { + return null; + } +} + +// ---- BatchedLODPool creation ---- + +function countGeometry(geo: THREE.BufferGeometry): { + vertexCount: number; + indexCount: number; +} { + const vertexCount = geo.getAttribute("position")?.count ?? 0; + const indexCount = geo.index?.count ?? 0; + return { vertexCount, indexCount }; +} + +/** + * Build a BatchedLODPool from multiple variants' parts. + * variantParts[variant][materialSlot] = { geometry, material } + * All variants must have the same number of material slots. + */ +function createBatchedLODPool( + variantParts: { + geometry: THREE.BufferGeometry; + material: DissolveMaterial; + }[][], +): BatchedLODPool { + const numSlots = variantParts[0].length; + const numVariants = variantParts.length; + + const batches: THREE.BatchedMesh[] = []; + const materials: DissolveMaterial[] = []; + const geometryIds: number[][] = []; + + for (let slot = 0; slot < numSlots; slot++) { + const mat = variantParts[0][slot].material; + + let totalVerts = 0; + let totalIndices = 0; + for (let v = 0; v < numVariants; v++) { + const counts = countGeometry(variantParts[v][slot].geometry); + totalVerts += counts.vertexCount; + totalIndices += counts.indexCount; + } + + const bm = new THREE.BatchedMesh( + MAX_INSTANCES, + totalVerts, + totalIndices > 0 ? totalIndices : undefined, + mat, + ); + bm.frustumCulled = false; + bm.perObjectFrustumCulled = false; + bm.sortObjects = false; + bm.castShadow = true; + bm.receiveShadow = false; + bm.layers.set(1); + + const slotGeoIds: number[] = []; + for (let v = 0; v < numVariants; v++) { + const geoId = bm.addGeometry(variantParts[v][slot].geometry); + slotGeoIds.push(geoId); + } + + // Force-init colors texture so BatchNode sets up vBatchColor varying + // before the first shader compilation. + const initId = bm.addInstance(slotGeoIds[0]); + bm.setColorAt(initId, _defaultColor); + bm.deleteInstance(initId); + + scene!.add(bm); + batches.push(bm); + materials.push(mat); + geometryIds.push(slotGeoIds); + } + + return { + batches, + materials, + geometryIds, + instanceIds: new Map(), + }; +} + +// ---- Texture helpers ---- + +function enableTextureRepeat(mat: DissolveMaterial): void { + const texProps = [ + "map", + "normalMap", + "roughnessMap", + "metalnessMap", + "aoMap", + "emissiveMap", + "alphaMap", + ] as const; + for (const key of texProps) { + const tex = (mat as unknown as Record)[key] as + | THREE.Texture + | undefined; + if (tex) { + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + tex.needsUpdate = true; + } + } +} + +// ---- Tree type pool lifecycle ---- + +const pendingEnsure = new Map>(); + +async function ensureTreeTypePool( + treeType: string, + variantPaths: string[], + depletedModelPath?: string | null, +): Promise { + const existing = pools.get(treeType); + if (existing) { + if (depletedModelPath && !existing.depleted) { + await loadDepletedPool(existing, depletedModelPath); + } + return existing; + } + + const pending = pendingEnsure.get(treeType); + if (pending) return pending; + + const promise = (async (): Promise => { + const dissolveOpts = { + fadeStart: GPU_VEG_CONFIG.FADE_START, + fadeEnd: GPU_VEG_CONFIG.FADE_END, + enableNearFade: false, + enableWaterCulling: false, + enableOcclusionDissolve: false, + enableRimHighlight: true, + }; + + function buildMaterialForPart(p: MeshPart): DissolveMaterial { + const isLeaf = + !!(p.material as any).transparent || + (p.material as any).side === THREE.DoubleSide; + const dm = createTreeDissolveMaterial(p.material, { + ...dissolveOpts, + batched: true, + isLeafMaterial: isLeaf, + } as TreeMaterialOptions); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return dm; + } + + // Load all variant LOD0s in parallel + const lod0Scenes = await Promise.all( + variantPaths.map(async (vp) => { + const { scene: s } = await modelCache.loadModel(vp, world!); + return s; + }), + ); + + // Extract parts per variant + const allLod0Parts = lod0Scenes.map((s) => extractAllMeshParts(s)); + if (allLod0Parts[0].length === 0) + throw new Error(`No mesh found in ${variantPaths[0]}`); + + const numSlots = allLod0Parts[0].length; + + // Build a texture fingerprint for each part in variant 0 to define slot identity + const refFingerprints = allLod0Parts[0].map((p) => + getTextureFingerprint(p.material), + ); + + // Reorder each subsequent variant's parts to match variant 0's slot order + for (let v = 1; v < allLod0Parts.length; v++) { + const parts = allLod0Parts[v]; + if (parts.length !== numSlots) { + console.warn( + `[GLBTreeBatchedInstancer] Variant ${variantPaths[v]} has ${parts.length} parts, expected ${numSlots}!`, + ); + continue; + } + const reordered = matchPartsToReference(refFingerprints, parts); + if (reordered) { + allLod0Parts[v] = reordered; + } else { + console.warn( + `[GLBTreeBatchedInstancer] Could not match parts for ${variantPaths[v]} — using original order`, + ); + } + } + + // Build materials from first variant (shared for all variants) + const sharedMaterials = allLod0Parts[0].map((p) => buildMaterialForPart(p)); + + // Build variant parts for LOD0 + const lod0VariantParts = allLod0Parts.map((parts) => + parts.map((p, slotIdx) => ({ + geometry: p.geometry, + material: sharedMaterials[slotIdx % sharedMaterials.length], + })), + ); + + const lod0Pool = createBatchedLODPool(lod0VariantParts); + + // Compute bounds from first variant + const bounds = computeModelBounds(lod0Scenes[0], 1); + + // Load LOD1 variants in parallel + let lod1Pool: BatchedLODPool | null = null; + const lod1Results = await Promise.all( + variantPaths.map((vp) => loadLODParts(inferLOD1Path(vp))), + ); + const validLod1 = lod1Results.filter( + (r): r is MeshPart[] => r !== null && r.length === numSlots, + ); + if (validLod1.length > 0) { + const lod1Ref = validLod1[0].map((p) => + getTextureFingerprint(p.material), + ); + for (let v = 1; v < validLod1.length; v++) { + const matched = matchPartsToReference(lod1Ref, validLod1[v]); + if (matched) validLod1[v] = matched; + } + const lod1Materials = validLod1[0].map((p) => buildMaterialForPart(p)); + const lod1VariantParts = validLod1.map((parts) => + parts.map((p, slotIdx) => ({ + geometry: p.geometry, + material: lod1Materials[slotIdx], + })), + ); + lod1Pool = createBatchedLODPool(lod1VariantParts); + } + + // Load LOD2 variants in parallel + let lod2Pool: BatchedLODPool | null = null; + const lod2Results = await Promise.all( + variantPaths.map((vp) => loadLODParts(inferLOD2Path(vp))), + ); + const validLod2 = lod2Results.filter( + (r): r is MeshPart[] => r !== null && r.length === numSlots, + ); + if (validLod2.length > 0) { + const lod2Ref = validLod2[0].map((p) => + getTextureFingerprint(p.material), + ); + for (let v = 1; v < validLod2.length; v++) { + const matched = matchPartsToReference(lod2Ref, validLod2[v]); + if (matched) validLod2[v] = matched; + } + const lod2Materials = validLod2[0].map((p) => buildMaterialForPart(p)); + const lod2VariantParts = validLod2.map((parts) => + parts.map((p, slotIdx) => ({ + geometry: p.geometry, + material: lod2Materials[slotIdx], + })), + ); + lod2Pool = createBatchedLODPool(lod2VariantParts); + } + + const pool: TreeTypePool = { + treeType, + variantPaths, + lod0: lod0Pool, + lod1: lod1Pool, + lod2: lod2Pool, + depleted: null, + instances: new Map(), + yOffset: bounds.yOffset, + depletedYOffset: 0, + modelHeight: bounds.height, + modelRadius: bounds.radius, + }; + pools.set(treeType, pool); + + if (depletedModelPath) { + await loadDepletedPool(pool, depletedModelPath); + } + + return pool; + })(); + + pendingEnsure.set(treeType, promise); + try { + return await promise; + } finally { + pendingEnsure.delete(treeType); + } +} + +async function loadDepletedPool( + pool: TreeTypePool, + depletedModelPath: string, +): Promise { + if (pool.depleted) return; + const depletedParts = await loadLODParts(depletedModelPath); + if (!depletedParts) return; + + let depletedYOffset = 0; + try { + const { scene: depScene } = await modelCache.loadModel( + depletedModelPath, + world!, + ); + depletedYOffset = computeModelBounds(depScene, 1).yOffset; + } catch { + /* use 0 */ + } + + const dissolveOpts = { + fadeStart: GPU_VEG_CONFIG.FADE_START, + fadeEnd: GPU_VEG_CONFIG.FADE_END, + enableNearFade: false, + enableWaterCulling: false, + enableOcclusionDissolve: false, + enableRimHighlight: true, + }; + + const depletedDissolveParts = depletedParts.map((p) => { + const dm = createTreeDissolveMaterial(p.material, { + ...dissolveOpts, + batched: true, + }); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return [{ geometry: p.geometry, material: dm }]; + }); + + // Depleted has 1 "variant" (the stump) + // Transpose: depletedDissolveParts is [slot][1 variant] but we need [1 variant][slot] + const numSlots = depletedParts.length; + const singleVariant: { + geometry: THREE.BufferGeometry; + material: DissolveMaterial; + }[] = []; + for (let s = 0; s < numSlots; s++) { + singleVariant.push(depletedDissolveParts[s][0]); + } + pool.depleted = createBatchedLODPool([singleVariant]); + pool.depletedYOffset = depletedYOffset; +} + +// ---- Instance matrix helper ---- + +function composeInstanceMatrix( + position: THREE.Vector3, + rotation: number, + scale: number, + yOffset: number, +): THREE.Matrix4 { + _position.set(position.x, position.y + yOffset * scale, position.z); + _quaternion.setFromAxisAngle(THREE.Object3D.DEFAULT_UP, rotation); + _scale.set(scale, scale, scale); + return _matrix.compose(_position, _quaternion, _scale); +} + +// ---- Pool add/remove ---- + +function addToPool( + pool: BatchedLODPool, + entityId: string, + mat: THREE.Matrix4, + variantIndex: number, +): void { + const ids: number[] = []; + for (let i = 0; i < pool.batches.length; i++) { + const numVariants = pool.geometryIds[i].length; + const clampedIdx = variantIndex % numVariants; + const geoId = pool.geometryIds[i][clampedIdx]; + if (geoId === undefined) { + console.warn( + `[GLBTreeBatchedInstancer] geoId undefined: slot=${i} variant=${clampedIdx} available=${numVariants}`, + ); + continue; + } + const instId = pool.batches[i].addInstance(geoId); + pool.batches[i].setMatrixAt(instId, mat); + pool.batches[i].setColorAt(instId, _defaultColor); + ids.push(instId); + } + pool.instanceIds.set(entityId, ids); +} + +function removeFromPool(pool: BatchedLODPool, entityId: string): void { + const ids = pool.instanceIds.get(entityId); + if (!ids) return; + for (let i = 0; i < pool.batches.length; i++) { + pool.batches[i].deleteInstance(ids[i]); + } + pool.instanceIds.delete(entityId); +} + +const _tmpColor = new THREE.Color(); + +function isHighlighted(pool: BatchedLODPool, entityId: string): boolean { + const ids = pool.instanceIds.get(entityId); + if (!ids || ids.length === 0) return false; + pool.batches[0].getColorAt(ids[0], _tmpColor); + return _tmpColor.r > 1.01; +} + +function applyHighlightColor( + pool: BatchedLODPool, + entityId: string, + on: boolean, +): void { + const ids = pool.instanceIds.get(entityId); + if (!ids) return; + const color = on ? _hlColor : _defaultColor; + for (let i = 0; i < pool.batches.length; i++) { + pool.batches[i].setColorAt(ids[i], color); + } +} + +// ---- Public API ---- + +export function initGLBTreeBatchedInstancer(s: THREE.Scene, w: World): void { + scene = s; + world = w; +} + +export function destroyGLBTreeBatchedInstancer(): void { + for (const pool of pools.values()) { + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { + if (!lodPool) continue; + for (const bm of lodPool.batches) { + scene?.remove(bm); + bm.dispose(); + } + for (const mat of lodPool.materials) mat.dispose(); + } + } + pools.clear(); + entityToTreeType.clear(); + pendingEnsure.clear(); + scene = null; + world = null; +} + +export async function addInstance( + treeType: string, + variantPaths: string[], + variantIndex: number, + entityId: string, + position: THREE.Vector3, + rotation: number, + scale: number, + depletedModelPath?: string | null, + depletedScale?: number, +): Promise { + if (!scene || !world) return false; + + try { + const pool = await ensureTreeTypePool( + treeType, + variantPaths, + depletedModelPath, + ); + + const slot: TreeSlot = { + entityId, + position: position.clone(), + rotation, + scale, + depletedScale: depletedScale ?? scale, + yOffset: pool.yOffset, + currentLOD: 0, + depleted: false, + variantIndex, + }; + + pool.instances.set(entityId, slot); + entityToTreeType.set(entityId, treeType); + + const mat = composeInstanceMatrix(position, rotation, scale, pool.yOffset); + if (pool.lod0) addToPool(pool.lod0, entityId, mat, variantIndex); + + return true; + } catch (error) { + console.warn( + `[GLBTreeBatchedInstancer] Failed to add instance ${entityId}:`, + error, + ); + return false; + } +} + +export function removeInstance(entityId: string): void { + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; + + const pool = pools.get(treeType); + if (!pool) return; + + const slot = pool.instances.get(entityId); + if (!slot) return; + + const lodPool = getLodPool(pool, slot); + if (lodPool) removeFromPool(lodPool, entityId); + + pool.instances.delete(entityId); + entityToTreeType.delete(entityId); +} + +function getLodPool(pool: TreeTypePool, slot: TreeSlot): BatchedLODPool | null { + if (slot.depleted) return pool.depleted; + return slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; +} + +export function setDepleted(entityId: string, depleted: boolean): void { + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; + + const pool = pools.get(treeType); + if (!pool) return; + + const slot = pool.instances.get(entityId); + if (!slot || slot.depleted === depleted) return; + + slot.depleted = depleted; + + if (depleted) { + const lodPool = getLodPool(pool, slot); + if (lodPool) removeFromPool(lodPool, entityId); + + if (pool.depleted) { + const mat = composeInstanceMatrix( + slot.position, + slot.rotation, + slot.depletedScale, + pool.depletedYOffset, + ); + addToPool(pool.depleted, entityId, mat, 0); + } + } else { + if (pool.depleted) { + removeFromPool(pool.depleted, entityId); + } + + const mat = composeInstanceMatrix( + slot.position, + slot.rotation, + slot.scale, + slot.yOffset, + ); + const lodPool = + slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; + if (lodPool) addToPool(lodPool, entityId, mat, slot.variantIndex); + } +} + +export function hasInstance(entityId: string): boolean { + return entityToTreeType.has(entityId); +} + +export function getModelDimensions( + entityId: string, +): { height: number; radius: number } | null { + const treeType = entityToTreeType.get(entityId); + if (!treeType) return null; + const pool = pools.get(treeType); + if (!pool) return null; + return { height: pool.modelHeight, radius: pool.modelRadius }; +} + +export function hasDepleted(entityId: string): boolean { + const treeType = entityToTreeType.get(entityId); + if (!treeType) return false; + const pool = pools.get(treeType); + return !!pool?.depleted; +} + +let highlightedEntityId: string | null = null; + +export function setHighlight(entityId: string, on: boolean): void { + if (on && highlightedEntityId && highlightedEntityId !== entityId) { + setHighlight(highlightedEntityId, false); + } + + const treeType = entityToTreeType.get(entityId); + if (!treeType) return; + + const pool = pools.get(treeType); + if (!pool) return; + + const slot = pool.instances.get(entityId); + if (!slot) return; + + const lodPool = getLodPool(pool, slot); + if (!lodPool) return; + + applyHighlightColor(lodPool, entityId, on); + highlightedEntityId = on ? entityId : null; +} + +export function clearHighlight(): void { + if (highlightedEntityId) { + setHighlight(highlightedEntityId, false); + } +} + +let lastUpdateFrame = -1; + +export function updateGLBTreeBatchedInstancer(): void { + if (!world) return; + if (world.frame === lastUpdateFrame) return; + lastUpdateFrame = world.frame; + + const camera = world.camera; + if (!camera) return; + + const camPos = camera.position; + const lod1DistSq = resourceLOD.lod1DistanceSq; + const lod2DistSq = resourceLOD.lod2DistanceSq; + const hysteresisSq = 0.81; + + for (const pool of pools.values()) { + for (const slot of pool.instances.values()) { + if (slot.depleted) continue; + + const dx = camPos.x - slot.position.x; + const dz = camPos.z - slot.position.z; + const distSq = dx * dx + dz * dz; + + let targetLOD: 0 | 1 | 2; + if (distSq < lod1DistSq * hysteresisSq) { + targetLOD = 0; + } else if (distSq < lod1DistSq) { + targetLOD = slot.currentLOD === 0 ? 0 : pool.lod1 ? 1 : 0; + } else if (distSq < lod2DistSq * hysteresisSq) { + targetLOD = pool.lod1 ? 1 : 0; + } else if (distSq < lod2DistSq) { + if (slot.currentLOD <= 1) { + targetLOD = pool.lod1 ? 1 : 0; + } else { + targetLOD = pool.lod2 ? 2 : pool.lod1 ? 1 : 0; + } + } else { + targetLOD = pool.lod2 ? 2 : pool.lod1 ? 1 : 0; + } + + if (targetLOD === slot.currentLOD) continue; + + const oldPool = getLodPool(pool, slot); + const wasHl = oldPool ? isHighlighted(oldPool, slot.entityId) : false; + if (oldPool) removeFromPool(oldPool, slot.entityId); + + slot.currentLOD = targetLOD; + + const newPool = getLodPool(pool, slot); + if (newPool) { + const mat = composeInstanceMatrix( + slot.position, + slot.rotation, + slot.scale, + slot.yOffset, + ); + addToPool(newPool, slot.entityId, mat, slot.variantIndex); + if (wasHl) applyHighlightColor(newPool, slot.entityId, true); + } + } + } + + // Update dissolve uniforms + const camY = camPos.y; + const players = world.getPlayers(); + const localPlayer = players && players.length > 0 ? players[0] : null; + const playerPos = localPlayer?.node?.position ?? camPos; + + const env = world.getSystem("environment") as { + sunLight?: { intensity: number }; + lightDirection?: THREE.Vector3; + hemisphereLight?: { color: THREE.Color }; + } | null; + + const wind = world.getSystem("wind") as Wind | null; + + for (const pool of pools.values()) { + for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { + if (!lodPool) continue; + + for (const mat of lodPool.materials) { + mat.dissolveUniforms.cameraPos.value.set(camPos.x, camY, camPos.z); + mat.dissolveUniforms.playerPos.value.set( + playerPos.x, + playerPos.y, + playerPos.z, + ); + + const treeMat = mat as TreeDissolveMaterial; + if (treeMat.treeUniforms) { + if (env?.lightDirection) { + treeMat.treeUniforms.sunDirection.value + .copy(env.lightDirection) + .negate(); + } + if (env?.sunLight) { + treeMat.treeUniforms.sunIntensity.value = Math.min( + env.sunLight.intensity, + 2.0, + ); + } + if (env?.hemisphereLight) { + const c = env.hemisphereLight.color; + const avg = (c.r + c.g + c.b) / 3; + if (avg > 0.01) { + treeMat.treeUniforms.shadeColor.value.setRGB( + c.r / avg, + c.g / avg, + c.b / avg, + ); + } + } + if (wind) { + treeMat.treeUniforms.windTime.value = wind.uniforms.time.value; + treeMat.treeUniforms.windStrength.value = + wind.uniforms.windStrength.value; + const wd = wind.uniforms.windDirection.value; + treeMat.treeUniforms.windDirection.value.set(wd.x, wd.z); + } + } + } + } + } +} diff --git a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts index 692f2d6e7..83c72bc20 100644 --- a/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts +++ b/packages/shared/src/systems/shared/world/GLBTreeInstancer.ts @@ -21,11 +21,14 @@ import THREE from "../../../extras/three/three"; import type { World } from "../../../core/World"; import { modelCache } from "../../../utils/rendering/ModelCache"; import { - createDissolveMaterial, - getLODDistances, + createTreeDissolveMaterial, GPU_VEG_CONFIG, type DissolveMaterial, -} from "./GPUVegetation"; + type TreeDissolveMaterial, + type TreeMaterialOptions, +} from "./GPUMaterials"; +import type { Wind } from "./Wind"; +import { getLODDistances, inferLOD1Path, inferLOD2Path } from "./LODConfig"; const MAX_INSTANCES = 512; @@ -47,12 +50,15 @@ interface TreeSlot { } interface LODPool { - mesh: THREE.InstancedMesh; - material: DissolveMaterial; - /** entityId → slot index in this pool's InstancedMesh */ + /** One InstancedMesh per sub-mesh/primitive in the GLB */ + meshes: THREE.InstancedMesh[]; + materials: DissolveMaterial[]; + /** entityId → slot index (same across all meshes) */ slots: Map; activeCount: number; dirty: boolean; + /** Shared backing array for per-instance highlight intensity (0 or 1) */ + highlightData: Float32Array; } interface ModelPool { @@ -64,8 +70,10 @@ interface ModelPool { instances: Map; yOffset: number; depletedYOffset: number; - highlightMesh: THREE.Mesh | null; - depletedHighlightMesh: THREE.Mesh | null; + /** Unscaled model height from bounding box */ + modelHeight: number; + /** Unscaled model horizontal radius from bounding box */ + modelRadius: number; } const resourceLOD = getLODDistances("resource"); @@ -76,29 +84,27 @@ let world: World | null = null; const pools = new Map(); const entityToModel = new Map(); - - // ---- Geometry extraction (portfolio pattern: reference, not clone) ---- -function extractGeometryAndMaterial( - root: THREE.Object3D, -): { geometry: THREE.BufferGeometry; material: THREE.Material } | null { - let result: { - geometry: THREE.BufferGeometry; - material: THREE.Material; - } | null = null; +interface MeshPart { + geometry: THREE.BufferGeometry; + material: THREE.Material; +} + +function extractAllMeshParts(root: THREE.Object3D): MeshPart[] { + const parts: MeshPart[] = []; root.traverse((child) => { - if (result) return; if (child instanceof THREE.Mesh && child.geometry) { - result = { - geometry: child.geometry, - material: Array.isArray(child.material) - ? child.material[0] - : child.material, - }; + if (Array.isArray(child.material)) { + for (const mat of child.material) { + parts.push({ geometry: child.geometry, material: mat }); + } + } else { + parts.push({ geometry: child.geometry, material: child.material }); + } } }); - return result; + return parts; } function createSharedGeometry( @@ -114,44 +120,74 @@ function createSharedGeometry( geo.morphAttributes[name] = source.morphAttributes[name]; } } + if (source.groups.length > 0) { + for (const group of source.groups) { + geo.addGroup(group.start, group.count, group.materialIndex); + } + } if (source.boundingBox) geo.boundingBox = source.boundingBox.clone(); if (source.boundingSphere) geo.boundingSphere = source.boundingSphere.clone(); return geo; } -function computeYOffset(root: THREE.Object3D, scale: number): number { +function computeModelBounds( + root: THREE.Object3D, + scale: number, +): { + yOffset: number; + height: number; + radius: number; +} { const saved = root.scale.clone(); root.scale.set(scale, scale, scale); const bbox = new THREE.Box3().setFromObject(root); root.scale.copy(saved); - return -bbox.min.y; + const height = bbox.max.y - bbox.min.y; + const dx = Math.max(Math.abs(bbox.min.x), Math.abs(bbox.max.x)); + const dz = Math.max(Math.abs(bbox.min.z), Math.abs(bbox.max.z)); + return { yOffset: -bbox.min.y, height, radius: Math.max(dx, dz) }; } // ---- LODPool creation ---- function createLODPool( - geometry: THREE.BufferGeometry, - material: DissolveMaterial, + parts: { geometry: THREE.BufferGeometry; material: DissolveMaterial }[], ): LODPool { - const geo = createSharedGeometry(geometry); - const mesh = new THREE.InstancedMesh(geo, material, MAX_INSTANCES); - mesh.count = 0; - mesh.frustumCulled = false; - mesh.castShadow = true; - mesh.receiveShadow = true; - mesh.layers.set(1); - scene!.add(mesh); - - return { mesh, material, slots: new Map(), activeCount: 0, dirty: false }; + const meshes: THREE.InstancedMesh[] = []; + const materials: DissolveMaterial[] = []; + const hlData = new Float32Array(MAX_INSTANCES); + for (const part of parts) { + const geo = createSharedGeometry(part.geometry); + + const hlAttr = new THREE.InstancedBufferAttribute(hlData, 1); + hlAttr.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute("instanceHighlight", hlAttr); + + const im = new THREE.InstancedMesh(geo, part.material, MAX_INSTANCES); + im.count = 0; + im.frustumCulled = false; + im.castShadow = true; + im.receiveShadow = false; + im.layers.set(1); + scene!.add(im); + meshes.push(im); + materials.push(part.material); + } + return { + meshes, + materials, + slots: new Map(), + activeCount: 0, + dirty: false, + highlightData: hlData, + }; } -async function loadLODModel(path: string): Promise<{ - geometry: THREE.BufferGeometry; - material: THREE.Material; -} | null> { +async function loadLODParts(path: string): Promise { try { const { scene: lodScene } = await modelCache.loadModel(path, world!); - return extractGeometryAndMaterial(lodScene); + const parts = extractAllMeshParts(lodScene); + return parts.length > 0 ? parts : null; } catch { return null; } @@ -161,18 +197,26 @@ async function loadLODModel(path: string): Promise<{ const pendingEnsure = new Map>(); -function createHighlightMesh( - geometry: THREE.BufferGeometry, - material: THREE.Material, -): THREE.Mesh { - const geo = createSharedGeometry(geometry); - const mesh = new THREE.Mesh(geo, material); - mesh.frustumCulled = false; - mesh.castShadow = false; - mesh.receiveShadow = false; - mesh.layers.set(1); - mesh.visible = true; - return mesh; +function enableTextureRepeat(mat: DissolveMaterial): void { + const texProps = [ + "map", + "normalMap", + "roughnessMap", + "metalnessMap", + "aoMap", + "emissiveMap", + "alphaMap", + ] as const; + for (const key of texProps) { + const tex = (mat as unknown as Record)[key] as + | THREE.Texture + | undefined; + if (tex) { + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + tex.needsUpdate = true; + } + } } async function ensureModelPool( @@ -193,60 +237,70 @@ async function ensureModelPool( if (pending) return pending; const promise = (async (): Promise => { - // LOD0 - const { scene: lod0Scene } = await modelCache.loadModel(modelPath, world!); - const lod0Data = extractGeometryAndMaterial(lod0Scene); - if (!lod0Data) throw new Error(`No mesh found in ${modelPath}`); - - const yOffset = computeYOffset(lod0Scene, 1); - - const lod0Material = createDissolveMaterial(lod0Data.material, { + const dissolveOpts = { fadeStart: GPU_VEG_CONFIG.FADE_START, fadeEnd: GPU_VEG_CONFIG.FADE_END, enableNearFade: false, enableWaterCulling: false, enableOcclusionDissolve: false, - }); - world!.setupMaterial(lod0Material); - const lod0Pool = createLODPool(lod0Data.geometry, lod0Material); - - // Preload highlight mesh from LOD0 geometry + original material - const highlightMesh = createHighlightMesh( - lod0Data.geometry, - lod0Data.material, - ); + enableRimHighlight: true, + }; + + function buildTreeParts( + parts: MeshPart[], + ): { geometry: THREE.BufferGeometry; material: DissolveMaterial }[] { + return parts.map((p) => { + const isLeaf = + !!(p.material as any).transparent || + (p.material as any).side === THREE.DoubleSide; + const dm = createTreeDissolveMaterial(p.material, { + ...dissolveOpts, + isLeafMaterial: isLeaf, + } as TreeMaterialOptions); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return { geometry: p.geometry, material: dm }; + }); + } + + // LOD0 + const { scene: lod0Scene } = await modelCache.loadModel(modelPath, world!); + const lod0Parts = extractAllMeshParts(lod0Scene); + if (lod0Parts.length === 0) + throw new Error(`No mesh found in ${modelPath}`); + + const bounds = computeModelBounds(lod0Scene, 1); - // LOD1 + const lod0Pool = createLODPool(buildTreeParts(lod0Parts)); + + // LOD1 — explicit path first, fall back to inferred naming convention let lod1Pool: LODPool | null = null; if (lod1ModelPath) { - const lod1Data = await loadLODModel(lod1ModelPath); - if (lod1Data) { - const lod1Material = createDissolveMaterial(lod1Data.material, { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - }); - world!.setupMaterial(lod1Material); - lod1Pool = createLODPool(lod1Data.geometry, lod1Material); + const lod1Parts = await loadLODParts(lod1ModelPath); + if (lod1Parts) { + lod1Pool = createLODPool(buildTreeParts(lod1Parts)); + } + } + if (!lod1Pool) { + const lod1Parts = await loadLODParts(inferLOD1Path(modelPath)); + if (lod1Parts) { + lod1Pool = createLODPool(buildTreeParts(lod1Parts)); } } - // LOD2 + // LOD2 — explicit path first, fall back to inferred naming convention let lod2Pool: LODPool | null = null; if (lod2ModelPath) { - const lod2Data = await loadLODModel(lod2ModelPath); - if (lod2Data) { - const lod2Material = createDissolveMaterial(lod2Data.material, { - fadeStart: GPU_VEG_CONFIG.FADE_START, - fadeEnd: GPU_VEG_CONFIG.FADE_END, - enableNearFade: false, - enableWaterCulling: false, - enableOcclusionDissolve: false, - }); - world!.setupMaterial(lod2Material); - lod2Pool = createLODPool(lod2Data.geometry, lod2Material); + const lod2Parts = await loadLODParts(lod2ModelPath); + if (lod2Parts) { + lod2Pool = createLODPool(buildTreeParts(lod2Parts)); + } + } + if (!lod2Pool) { + const lod2Parts = await loadLODParts(inferLOD2Path(modelPath)); + if (lod2Parts) { + lod2Pool = createLODPool(buildTreeParts(lod2Parts)); } } @@ -257,10 +311,10 @@ async function ensureModelPool( lod2: lod2Pool, depleted: null, instances: new Map(), - yOffset, + yOffset: bounds.yOffset, depletedYOffset: 0, - highlightMesh, - depletedHighlightMesh: null, + modelHeight: bounds.height, + modelRadius: bounds.radius, }; pools.set(modelPath, pool); @@ -284,8 +338,8 @@ async function loadDepletedPool( depletedModelPath: string, ): Promise { if (pool.depleted) return; - const depletedData = await loadLODModel(depletedModelPath); - if (!depletedData) return; + const depletedParts = await loadLODParts(depletedModelPath); + if (!depletedParts) return; let depletedYOffset = 0; try { @@ -293,25 +347,28 @@ async function loadDepletedPool( depletedModelPath, world!, ); - depletedYOffset = computeYOffset(depScene, 1); + depletedYOffset = computeModelBounds(depScene, 1).yOffset; } catch { /* use 0 */ } - const depletedMaterial = createDissolveMaterial(depletedData.material, { + const dissolveOpts = { fadeStart: GPU_VEG_CONFIG.FADE_START, fadeEnd: GPU_VEG_CONFIG.FADE_END, enableNearFade: false, enableWaterCulling: false, enableOcclusionDissolve: false, + enableRimHighlight: true, + }; + const depletedDissolveParts = depletedParts.map((p) => { + const dm = createTreeDissolveMaterial(p.material, dissolveOpts); + dm.side = THREE.DoubleSide; + enableTextureRepeat(dm); + world!.setupMaterial(dm); + return { geometry: p.geometry, material: dm }; }); - world!.setupMaterial(depletedMaterial); - pool.depleted = createLODPool(depletedData.geometry, depletedMaterial); + pool.depleted = createLODPool(depletedDissolveParts); pool.depletedYOffset = depletedYOffset; - pool.depletedHighlightMesh = createHighlightMesh( - depletedData.geometry, - depletedData.material, - ); } // ---- Instance matrix helper ---- @@ -330,10 +387,12 @@ function composeInstanceMatrix( function addToPool(pool: LODPool, entityId: string, mat: THREE.Matrix4): void { const idx = pool.activeCount; - pool.mesh.setMatrixAt(idx, mat); + for (const im of pool.meshes) { + im.setMatrixAt(idx, mat); + im.count = idx + 1; + } pool.slots.set(entityId, idx); pool.activeCount++; - pool.mesh.count = pool.activeCount; pool.dirty = true; } @@ -343,11 +402,12 @@ function removeFromPool(pool: LODPool, entityId: string): void { const lastIdx = pool.activeCount - 1; if (idx !== lastIdx) { - // Swap last instance into the removed slot - pool.mesh.getMatrixAt(lastIdx, _swapMatrix); - pool.mesh.setMatrixAt(idx, _swapMatrix); + for (const im of pool.meshes) { + im.getMatrixAt(lastIdx, _swapMatrix); + im.setMatrixAt(idx, _swapMatrix); + } + pool.highlightData[idx] = pool.highlightData[lastIdx]; - // Find the entity that owned the last slot and update its index for (const [eid, eidIdx] of pool.slots) { if (eidIdx === lastIdx) { pool.slots.set(eid, idx); @@ -355,10 +415,13 @@ function removeFromPool(pool: LODPool, entityId: string): void { } } } + pool.highlightData[lastIdx] = 0; pool.slots.delete(entityId); pool.activeCount--; - pool.mesh.count = pool.activeCount; + for (const im of pool.meshes) { + im.count = pool.activeCount; + } pool.dirty = true; } @@ -373,15 +436,11 @@ export function destroyGLBTreeInstancer(): void { for (const pool of pools.values()) { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; - scene?.remove(lodPool.mesh); - lodPool.mesh.geometry.dispose(); - lodPool.material.dispose(); - } - if (pool.highlightMesh) { - pool.highlightMesh.geometry.dispose(); - } - if (pool.depletedHighlightMesh) { - pool.depletedHighlightMesh.geometry.dispose(); + for (const im of lodPool.meshes) { + scene?.remove(im); + im.geometry.dispose(); + } + for (const mat of lodPool.materials) mat.dispose(); } } pools.clear(); @@ -405,7 +464,12 @@ export async function addInstance( if (!scene || !world) return false; try { - const pool = await ensureModelPool(modelPath, depletedModelPath, lod1ModelPath, lod2ModelPath); + const pool = await ensureModelPool( + modelPath, + depletedModelPath, + lod1ModelPath, + lod2ModelPath, + ); if (pool.lod0 && pool.lod0.activeCount >= MAX_INSTANCES) { console.warn( @@ -473,15 +537,6 @@ export function setDepleted(entityId: string, depleted: boolean): void { const slot = pool.instances.get(entityId); if (!slot || slot.depleted === depleted) return; - // If the previous highlight mesh is in the scene (entity is hovered), remove it - // so the stale model doesn't linger during the state transition. - const oldHlMesh = slot.depleted - ? pool.depletedHighlightMesh - : pool.highlightMesh; - if (oldHlMesh?.parent) { - oldHlMesh.parent.remove(oldHlMesh); - } - slot.depleted = depleted; if (depleted) { @@ -531,6 +586,20 @@ export function hasInstance(entityId: string): boolean { return entityToModel.has(entityId); } +/** + * Returns the unscaled model dimensions for an instanced entity. + * Used to size collision proxies to match the actual model. + */ +export function getModelDimensions( + entityId: string, +): { height: number; radius: number } | null { + const modelPath = entityToModel.get(entityId); + if (!modelPath) return null; + const pool = pools.get(modelPath); + if (!pool) return null; + return { height: pool.modelHeight, radius: pool.modelRadius }; +} + /** * Returns true if the instancer has a depleted pool for this entity's model. * When true, ResourceEntity can skip loading an individual depleted model. @@ -542,35 +611,58 @@ export function hasDepleted(entityId: string): boolean { return !!pool?.depleted; } +/** Track which entity is currently highlighted so we can clear it */ +let highlightedEntityId: string | null = null; + /** - * Returns a positioned highlight mesh for outlining an instanced entity. - * The mesh is temporarily added to the scene by EntityHighlightService. + * Set or clear shader-based rim highlight for an instanced tree entity. + * Sets the per-instance `instanceHighlight` attribute to 1 or 0. */ -export function getHighlightMesh(entityId: string): THREE.Object3D | null { +export function setHighlight(entityId: string, on: boolean): void { + if (on && highlightedEntityId && highlightedEntityId !== entityId) { + setHighlight(highlightedEntityId, false); + } + const modelPath = entityToModel.get(entityId); - if (!modelPath) return null; + if (!modelPath) return; const pool = pools.get(modelPath); - if (!pool) return null; + if (!pool) return; const slot = pool.instances.get(entityId); - if (!slot) return null; - - const mesh = slot.depleted ? pool.depletedHighlightMesh : pool.highlightMesh; - if (!mesh) return null; - - const s = slot.depleted ? slot.depletedScale : slot.scale; - const yOff = slot.depleted ? pool.depletedYOffset : pool.yOffset; - mesh.position.set( - slot.position.x, - slot.position.y + yOff * s, - slot.position.z, - ); - mesh.rotation.set(0, slot.rotation, 0); - mesh.scale.set(s, s, s); - mesh.updateMatrixWorld(true); - - return mesh; + if (!slot) return; + + const lodPool = slot.depleted + ? pool.depleted + : slot.currentLOD === 0 + ? pool.lod0 + : slot.currentLOD === 1 + ? pool.lod1 + : pool.lod2; + if (!lodPool) return; + + const idx = lodPool.slots.get(entityId); + if (idx === undefined) return; + + const value = on ? 1.0 : 0.0; + lodPool.highlightData[idx] = value; + for (const im of lodPool.meshes) { + const attr = im.geometry.getAttribute("instanceHighlight"); + if (attr) { + (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + } + + highlightedEntityId = on ? entityId : null; +} + +/** + * Clear any active shader highlight (e.g. when hover leaves all entities). + */ +export function clearHighlight(): void { + if (highlightedEntityId) { + setHighlight(highlightedEntityId, false); + } } let lastUpdateFrame = -1; @@ -625,6 +717,10 @@ export function updateGLBTreeInstancer(): void { const newPool = targetLOD === 0 ? pool.lod0 : targetLOD === 1 ? pool.lod1 : pool.lod2; + const wasHighlighted = + oldPool && oldPool.slots.has(slot.entityId) + ? oldPool.highlightData[oldPool.slots.get(slot.entityId)!] + : 0; if (oldPool) removeFromPool(oldPool, slot.entityId); if (newPool) { const mat = composeInstanceMatrix( @@ -634,6 +730,17 @@ export function updateGLBTreeInstancer(): void { slot.yOffset, ); addToPool(newPool, slot.entityId, mat); + if (wasHighlighted > 0) { + const newIdx = newPool.slots.get(slot.entityId); + if (newIdx !== undefined) { + newPool.highlightData[newIdx] = wasHighlighted; + for (const im of newPool.meshes) { + const attr = im.geometry.getAttribute("instanceHighlight"); + if (attr) + (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + } + } + } } slot.currentLOD = targetLOD; } @@ -645,25 +752,67 @@ export function updateGLBTreeInstancer(): void { const localPlayer = players && players.length > 0 ? players[0] : null; const playerPos = localPlayer?.node?.position ?? camPos; + // Get sun direction from Environment system + const env = world.getSystem("environment") as { + sunLight?: { intensity: number }; + lightDirection?: THREE.Vector3; + hemisphereLight?: { color: THREE.Color }; + } | null; + + const wind = world.getSystem("wind") as Wind | null; + for (const pool of pools.values()) { for (const lodPool of [pool.lod0, pool.lod1, pool.lod2, pool.depleted]) { if (!lodPool) continue; if (lodPool.dirty) { - lodPool.mesh.instanceMatrix.needsUpdate = true; + for (const im of lodPool.meshes) { + im.instanceMatrix.needsUpdate = true; + } lodPool.dirty = false; } - lodPool.material.dissolveUniforms.cameraPos.value.set( - camPos.x, - camY, - camPos.z, - ); - lodPool.material.dissolveUniforms.playerPos.value.set( - playerPos.x, - playerPos.y, - playerPos.z, - ); + for (const mat of lodPool.materials) { + mat.dissolveUniforms.cameraPos.value.set(camPos.x, camY, camPos.z); + mat.dissolveUniforms.playerPos.value.set( + playerPos.x, + playerPos.y, + playerPos.z, + ); + + const treeMat = mat as TreeDissolveMaterial; + if (treeMat.treeUniforms) { + if (env?.lightDirection) { + treeMat.treeUniforms.sunDirection.value + .copy(env.lightDirection) + .negate(); + } + if (env?.sunLight) { + treeMat.treeUniforms.sunIntensity.value = Math.min( + env.sunLight.intensity, + 2.0, + ); + } + if (env?.hemisphereLight) { + const c = env.hemisphereLight.color; + const avg = (c.r + c.g + c.b) / 3; + if (avg > 0.01) { + treeMat.treeUniforms.shadeColor.value.setRGB( + c.r / avg, + c.g / avg, + c.b / avg, + ); + } + } + if (wind) { + treeMat.treeUniforms.windTime.value = wind.uniforms.time.value; + treeMat.treeUniforms.windStrength.value = + wind.uniforms.windStrength.value; + const wd = wind.uniforms.windDirection.value; + treeMat.treeUniforms.windDirection.value.set(wd.x, wd.z); + } + } + } } } } diff --git a/packages/shared/src/systems/shared/world/GPUVegetation.ts b/packages/shared/src/systems/shared/world/GPUMaterials.ts similarity index 57% rename from packages/shared/src/systems/shared/world/GPUVegetation.ts rename to packages/shared/src/systems/shared/world/GPUMaterials.ts index 4dcd98b46..2e8a4974d 100644 --- a/packages/shared/src/systems/shared/world/GPUVegetation.ts +++ b/packages/shared/src/systems/shared/world/GPUMaterials.ts @@ -1,28 +1,21 @@ /** - * GPUVegetation.ts - GPU-Driven Dissolve Rendering + * GPUMaterials.ts - GPU-Driven Material Factories * - * Provides GPU-accelerated dissolve rendering using WebGPU/Three.js TSL. - * This module provides SHARED dissolve functionality for vegetation, mobs, and resources. + * Provides GPU-accelerated materials using WebGPU/Three.js TSL. + * Shared across vegetation, mobs, resources, and imposters. * - * ## Features - * - Screen-space dithered dissolve (smooth per-fragment fade in/out) - * - Both NEAR and FAR camera dissolve support - * - Water level culling - * - Vertex color support (no texture sampling) - * - Cutout rendering (alphaTest, not transparent) for opaque pipeline performance + * ## Material Factories + * - `createGPUVegetationMaterial()` - Vegetation (far dissolve + water culling + fog) + * - `createDissolveMaterial()` - Generic dissolve for instanced models (resources, mobs) + * - `createImposterMaterial()` - Billboard imposter with dithered dissolve * - * ## Dissolve Effect - * Uses world-position-based dithering so entities smoothly dissolve in/out - * as the player approaches or moves away. Each fragment has a pseudo-random threshold - * compared against the distance-based fade factor - more fragments pass - * based on distance, creating an organic dissolve effect. + * ## Shared Shader Features + * - Screen-space dithered dissolve (Bayer 4x4) + * - Camera-to-player occlusion cone (RuneScape-style) + * - Near-camera depth fade + * - Per-instance Fresnel rim highlight * - * ## Shared System - * - `createGPUVegetationMaterial()` - For vegetation (far dissolve + water culling) - * - `createDissolveMaterial()` - Generic dissolve for any material (mobs, resources) - * - Both use identical shader logic for consistent visual appearance - * - * @module GPUVegetation + * @module GPUMaterials */ import * as THREE from "../../../extras/three/three"; @@ -36,9 +29,11 @@ import { MeshStandardNodeMaterial, float, dot, + vec2, vec3, vec4, smoothstep, + positionLocal, positionWorld, screenUV, cameraPosition, @@ -53,8 +48,16 @@ import { mod, floor, abs, + sin, viewportCoordinate, + normalView, + normalWorld, + normalize, + pow, + attribute, + positionView, } from "../../../extras/three/three"; +import { varyingProperty } from "three/tsl"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; @@ -63,8 +66,8 @@ import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; // ============================================================================ /** - * GPU dissolve rendering configuration. - * Central configuration for all dissolve effects (vegetation, resources, mobs). + * GPU rendering configuration shared by all material factories. + * Controls dissolve distances, occlusion, near-camera fade, and water culling. */ export const GPU_VEG_CONFIG = { /** Distance where far fade begins (fully opaque inside) - default for generic dissolve */ @@ -124,408 +127,34 @@ export const GPU_VEG_CONFIG = { } as const; // ============================================================================ -// UNIFIED LOD CONFIGURATION +// SHARED TERRAIN LIGHTING (used by both TerrainShader and tree terrain blend) // ============================================================================ -/** - * LOD distances for a category. - * All distances in meters from camera. - * - * LOD Pipeline: - * - LOD0 (0 to lod1Distance): Full detail mesh - * - LOD1 (lod1Distance to lod2Distance): Low-poly mesh (~10% verts) - * - LOD2 (lod2Distance to imposterDistance): Very low-poly mesh (~3% verts) - * - Impostor (imposterDistance to fadeDistance): Billboard - * - Culled (> fadeDistance): Hidden - */ -export interface LODDistances { - /** Distance to switch from LOD0 (full detail) to LOD1 (low poly ~10%) */ - lod1Distance: number; - /** Distance to switch from LOD1 to LOD2 (very low poly ~3%) */ - lod2Distance: number; - /** Distance to switch from 3D mesh to billboard imposter */ - imposterDistance: number; - /** Distance at which to completely fade out/cull */ - fadeDistance: number; -} - -/** - * LOD distances with pre-computed squared values for performance. - * Use squared distances to avoid Math.sqrt in hot paths. - */ -export interface LODDistancesWithSq extends LODDistances { - lod1DistanceSq: number; - lod2DistanceSq: number; - imposterDistanceSq: number; - fadeDistanceSq: number; -} - -/** - * UNIFIED LOD CONFIGURATION - Single source of truth for all LOD distances. - * - * This is the canonical configuration used by: - * - VegetationSystem (trees, bushes, grass, etc.) - * - ResourceEntity (harvestable resources) - * - Any other LOD systems - * - * Categories can be customized based on object size and visual importance. - */ -export const LOD_DISTANCES: Record = { - // Large vegetation - aggressive LOD for performance - tree: { - lod1Distance: 30, - lod2Distance: 60, - imposterDistance: 100, - fadeDistance: 180, - }, - - // Medium vegetation - bush: { - lod1Distance: 40, - lod2Distance: 80, - imposterDistance: 120, - fadeDistance: 200, - }, - fern: { - lod1Distance: 30, - lod2Distance: 55, - imposterDistance: 80, - fadeDistance: 120, - }, - rock: { - lod1Distance: 50, - lod2Distance: 100, - imposterDistance: 150, - fadeDistance: 250, - }, - fallen_tree: { - lod1Distance: 45, - lod2Distance: 90, - imposterDistance: 130, - fadeDistance: 200, - }, - - // Small vegetation (aggressive culling - skip LOD2, go straight to impostor) - flower: { - lod1Distance: 25, - lod2Distance: 45, // Same as imposter - skip LOD2 - imposterDistance: 60, - fadeDistance: 100, - }, - mushroom: { - lod1Distance: 15, - lod2Distance: 30, // Same as imposter - skip LOD2 - imposterDistance: 40, - fadeDistance: 80, - }, - grass: { - lod1Distance: 10, - lod2Distance: 20, // Same as imposter - skip LOD2 - imposterDistance: 30, - fadeDistance: 60, - }, - - // Resources (harvestable objects) - resource: { - lod1Distance: 45, - lod2Distance: 85, - imposterDistance: 120, - fadeDistance: 200, - }, - tree_resource: { - lod1Distance: 25, // Switch to LOD1 very early - lod2Distance: 50, - imposterDistance: 80, // Switch to impostor ASAP - fadeDistance: 150, // Fade out earlier - }, - rock_resource: { - lod1Distance: 50, - lod2Distance: 100, - imposterDistance: 150, - fadeDistance: 250, - }, - - // Buildings - simple geometry, skip intermediate LODs and go directly to impostor - // Buildings use batched static meshes, so LOD stages are unnecessary - // LOD0 (0-80m): Full detail batched mesh with shadows - // Impostor (80-200m): Octahedral billboard - // Culled (>200m): Hidden - building: { - lod1Distance: 80, // Same as impostor - skip intermediate LOD - lod2Distance: 80, // Same as impostor - skip intermediate LOD - imposterDistance: 80, // Switch to impostor at 80m - fadeDistance: 200, // Cull at 200m - }, - station: { - lod1Distance: 60, - lod2Distance: 140, - imposterDistance: 200, - fadeDistance: 350, - }, - - // Mobs and NPCs (skip LOD2 - use LOD1 to impostor) - mob: { - lod1Distance: 40, - lod2Distance: 70, - imposterDistance: 100, - fadeDistance: 150, - }, - npc: { - lod1Distance: 50, - lod2Distance: 90, - imposterDistance: 120, - fadeDistance: 180, - }, - player: { - lod1Distance: 50, - lod2Distance: 90, - imposterDistance: 120, - fadeDistance: 200, - }, - - // Items (skip LOD2) - item: { - lod1Distance: 25, - lod2Distance: 45, - imposterDistance: 60, - fadeDistance: 100, - }, -}; - -/** Default LOD distances for unknown categories */ -export const DEFAULT_LOD_DISTANCES: LODDistances = { - lod1Distance: 45, - lod2Distance: 85, - imposterDistance: 120, - fadeDistance: 200, -}; - -/** - * Reference size (in meters) for LOD distance scaling. - * Objects larger than this get proportionally extended draw distances. - * Objects smaller than this get proportionally reduced draw distances. - * - * Based on typical "medium" object size (a small tree or large bush). - */ -export const LOD_REFERENCE_SIZE = 5.0; - -/** - * Minimum scale factor (prevents tiny objects from having 0 draw distance) - */ -export const LOD_MIN_SCALE = 0.3; - -/** - * Maximum scale factor (prevents huge objects from having infinite draw distance) - */ -export const LOD_MAX_SCALE = 10.0; - -/** - * Calculate size-based LOD distance scale factor. - * - * Formula: scale = clamp(boundingSize / referenceSize, minScale, maxScale) - * - * Examples: - * - 5m object (reference): scale = 1.0x (normal distances) - * - 10m object: scale = 2.0x (double distances) - * - 50m object: scale = 10.0x (max, capped) - * - 2m object: scale = 0.4x (40% of normal) - * - 0.5m object: scale = 0.3x (min, capped) - * - * @param boundingSize - Bounding box diagonal or sphere diameter in meters - * @returns Scale factor to multiply with base LOD distances - */ -export function calculateLODScaleFactor(boundingSize: number): number { - if (boundingSize <= 0) return 1.0; - const rawScale = boundingSize / LOD_REFERENCE_SIZE; - return Math.max(LOD_MIN_SCALE, Math.min(LOD_MAX_SCALE, rawScale)); -} - -/** Cache for LOD configs with pre-computed squared distances */ -const lodDistanceCache = new Map(); - -/** Cache for size-scaled LOD configs */ -const lodDistanceSizeCache = new Map(); - -/** - * Get LOD distances for a category with pre-computed squared values. - * Caches results for performance. - * - * @param category - Category name (e.g., "tree", "bush", "resource") - * @returns LOD distances with squared values for distance comparisons - */ -export function getLODDistances(category: string): LODDistancesWithSq { - // Check cache first - const cached = lodDistanceCache.get(category); - if (cached) return cached; - - // Get base config or use default - const base = LOD_DISTANCES[category] ?? DEFAULT_LOD_DISTANCES; - - // Pre-compute squared distances - const withSq: LODDistancesWithSq = { - ...base, - lod1DistanceSq: base.lod1Distance * base.lod1Distance, - lod2DistanceSq: base.lod2Distance * base.lod2Distance, - imposterDistanceSq: base.imposterDistance * base.imposterDistance, - fadeDistanceSq: base.fadeDistance * base.fadeDistance, - }; - - // Cache and return - lodDistanceCache.set(category, withSq); - return withSq; -} +/** Sun shade strength — identical in terrain shader and tree shader */ +export const SHADE_STRENGTH = 0.3; /** - * Get LOD distances scaled by object size. - * - * Larger objects (bigger bounding box) get proportionally extended draw distances, - * allowing them to be visible from farther away as imposters. - * - * This enables: - * - Giant trees visible as imposters from 1km+ away - * - Small mushrooms culled at 50m - * - Medium bushes at default distances + * Compute sun shade (shadow-side sky tint) on a pre-lit color. + * Used by both the terrain shader's outputNode and the tree shader's + * terrain color blend so both produce the exact same result. * - * @param category - Category name (e.g., "tree", "bush", "resource") - * @param boundingSize - Bounding box diagonal or sphere diameter in meters - * @returns LOD distances scaled by object size with pre-computed squared values + * @param color - Already-lit color (e.g. PBR output or manually-lit albedo) + * @param normal - Surface normal (world space) + * @param sunDir - Sun direction uniform (normalised inside) + * @param shadeColor - Normalised hemisphere sky color for shadow tint */ -export function getLODDistancesScaled( - category: string, - boundingSize: number, -): LODDistancesWithSq { - // Create cache key combining category and size (rounded to avoid cache bloat) - const roundedSize = Math.round(boundingSize * 10) / 10; // Round to 0.1m precision - const cacheKey = `${category}_${roundedSize}`; - - // Check cache first - const cached = lodDistanceSizeCache.get(cacheKey); - if (cached) return cached; - - // Get base config - const base = LOD_DISTANCES[category] ?? DEFAULT_LOD_DISTANCES; - - // Calculate scale factor - const scale = calculateLODScaleFactor(boundingSize); - - // Apply scaling to all distances - const scaled: LODDistancesWithSq = { - lod1Distance: base.lod1Distance * scale, - lod2Distance: base.lod2Distance * scale, - imposterDistance: base.imposterDistance * scale, - fadeDistance: base.fadeDistance * scale, - lod1DistanceSq: (base.lod1Distance * scale) ** 2, - lod2DistanceSq: (base.lod2Distance * scale) ** 2, - imposterDistanceSq: (base.imposterDistance * scale) ** 2, - fadeDistanceSq: (base.fadeDistance * scale) ** 2, - }; - - // Cache and return - lodDistanceSizeCache.set(cacheKey, scaled); - return scaled; -} - -/** - * Get LOD configuration for an entity or object, with size-based scaling. - * - * Convenience function that extracts bounding size from common object types. - * - * @param category - Category name - * @param object - THREE.Object3D, bounding box, or bounding sphere - * @returns Scaled LOD distances - */ -export function getLODConfig( - category: string, - object?: - | THREE.Object3D - | THREE.Box3 - | THREE.Sphere - | { boundingSize: number } - | number, -): LODDistancesWithSq { - // If no object, return base distances - if (object === undefined || object === null) { - return getLODDistances(category); - } - - // Extract bounding size based on object type - let boundingSize: number; - - if (typeof object === "number") { - // Direct size value - boundingSize = object; - } else if ("boundingSize" in object) { - // Object with explicit boundingSize property - boundingSize = object.boundingSize; - } else if (object instanceof THREE.Box3) { - // Bounding box - use diagonal - const size = new THREE.Vector3(); - object.getSize(size); - boundingSize = size.length(); - } else if (object instanceof THREE.Sphere) { - // Bounding sphere - use diameter - boundingSize = object.radius * 2; - } else if (object instanceof THREE.Object3D) { - // THREE.Object3D - compute bounding box - const box = new THREE.Box3().setFromObject(object); - const size = new THREE.Vector3(); - box.getSize(size); - boundingSize = size.length(); - } else { - // Unknown type, use base distances - return getLODDistances(category); - } - - return getLODDistancesScaled(category, boundingSize); -} - -/** - * Clear the LOD distance cache. - * Call this if LOD_DISTANCES is modified at runtime. - */ -export function clearLODDistanceCache(): void { - lodDistanceCache.clear(); - lodDistanceSizeCache.clear(); -} - -/** - * Apply LOD settings from a manifest or external configuration. - * Merges with existing configuration and clears the cache. - * - * @param settings - LOD settings with distanceThresholds - */ -export function applyLODSettings(settings: { - distanceThresholds?: Record< - string, - { lod1?: number; lod2?: number; imposter: number; fadeOut: number } - >; -}): void { - if (!settings.distanceThresholds) return; - - for (const [category, thresholds] of Object.entries( - settings.distanceThresholds, - )) { - // Map category names (e.g., "fallen" -> "fallen_tree") - const configKey = category === "fallen" ? "fallen_tree" : category; - - // Calculate lod2Distance: if not provided, use midpoint between lod1 and imposter - const lod1 = thresholds.lod1 ?? 0; - const lod2 = thresholds.lod2 ?? (lod1 + thresholds.imposter) / 2; - - LOD_DISTANCES[configKey] = { - lod1Distance: lod1, - lod2Distance: lod2, - imposterDistance: thresholds.imposter, - fadeDistance: thresholds.fadeOut, - }; - } - - // Clear cache to force recalculation with new values - clearLODDistanceCache(); - - console.log( - `[GPUVegetation] Applied LOD settings for ${Object.keys(settings.distanceThresholds).length} categories`, - ); +export function applyTerrainSunShade( + color: any, + normal: any, + sunDir: any, + shadeColor: any, +) { + const L = normalize(sunDir); + const N = normalize(normal); + const NdotL = dot(N, L); + const shade = sub(float(0.5), mul(NdotL, float(0.5))); + const tinted = mul(color, shadeColor); + return mix(color, tinted, mul(shade, float(SHADE_STRENGTH))); } // ============================================================================ @@ -581,6 +210,10 @@ export type DissolveMaterialOptions = { enableWaterCulling?: boolean; /** Enable camera-to-player occlusion dissolve (default: true) */ enableOcclusionDissolve?: boolean; + /** Enable per-instance rim highlight driven by an instanced attribute */ + enableRimHighlight?: boolean; + /** Use BatchedMesh highlight (vBatchColor varying) instead of InstancedMesh attribute */ + batched?: boolean; }; /** @@ -595,6 +228,8 @@ export type DissolveMaterial = THREE.MeshStandardNodeMaterial & { nearFadeStart: { value: number }; nearFadeEnd: { value: number }; }; + /** Present when enableRimHighlight was true at creation */ + highlightColor?: { value: THREE.Color }; }; // ============================================================================ @@ -633,7 +268,7 @@ export function createGPUVegetationMaterial( const fadeEndSq = mul(uFadeEnd, uFadeEnd); // Occlusion dissolve constants (RuneScape-style cone) - const enableOcclusion = options.enableOcclusionDissolve !== false; // Default: enabled + const enableOcclusion = options.enableOcclusionDissolve !== false; const occlusionCameraRadius = float(GPU_VEG_CONFIG.OCCLUSION_CAMERA_RADIUS); const occlusionPlayerRadius = float(GPU_VEG_CONFIG.OCCLUSION_PLAYER_RADIUS); const occlusionDistanceScale = float(GPU_VEG_CONFIG.OCCLUSION_DISTANCE_SCALE); @@ -647,15 +282,11 @@ export function createGPUVegetationMaterial( const nearCameraFadeEnd = float(GPU_VEG_CONFIG.NEAR_CAMERA_FADE_END); // ========== ALPHA TEST (DITHERED DISSOLVE + OCCLUSION + NEAR-CAMERA FADE) ========== - // alphaTestNode controls fragment discard directly - // Fragment is discarded when alpha < alphaTestNode value - // We compute a per-fragment threshold, so higher threshold = more likely to discard material.alphaTestNode = Fn(() => { - // Use positionWorld which includes instance transform const worldPos = positionWorld; // 1. Water check: if below water, use threshold of 2.0 to always discard - const belowWater = step(worldPos.y, waterCutoff); // 1 if below, 0 if above + const belowWater = step(worldPos.y, waterCutoff); // 2. Distance calculation from WORLD position to player (horizontal only, squared) const toPlayer = sub(worldPos, uPlayerPos); @@ -665,30 +296,23 @@ export function createGPUVegetationMaterial( ); // 3. Distance factor: 0.0 when close (keep fragment), 1.0 when far (discard fragment) - // smoothstep(edge0, edge1, x): returns 0 when x<=edge0, 1 when x>=edge1 const distanceFade = smoothstep(fadeStartSq, fadeEndSq, distSq); // 4. CAMERA-TO-PLAYER OCCLUSION DISSOLVE (RuneScape-style) - // Dissolve objects between camera and player using a CONE shape - // The cone is narrow at the camera and wide around the player - // This creates a natural "bubble" of visibility around the player const occlusionFade = enableOcclusion ? (() => { - // Camera to player vector (CT) const camToPlayer = vec3( sub(uPlayerPos.x, uCameraPos.x), sub(uPlayerPos.y, uCameraPos.y), sub(uPlayerPos.z, uCameraPos.z), ); - // Camera to fragment vector (CP) const camToFrag = vec3( sub(worldPos.x, uCameraPos.x), sub(worldPos.y, uCameraPos.y), sub(worldPos.z, uCameraPos.z), ); - // Length of camera-to-player vector const ctLengthSq = add( add( mul(camToPlayer.x, camToPlayer.x), @@ -698,29 +322,23 @@ export function createGPUVegetationMaterial( ); const ctLength = sqrt(ctLengthSq); - // Normalized camera-to-player direction const ctDirX = div(camToPlayer.x, ctLength); const ctDirY = div(camToPlayer.y, ctLength); const ctDirZ = div(camToPlayer.z, ctLength); - // Project fragment onto camera-player line (dot product of CP and normalized CT) - // projDist = how far along the camera->player line this fragment projects const projDist = add( add(mul(camToFrag.x, ctDirX), mul(camToFrag.y, ctDirY)), mul(camToFrag.z, ctDirZ), ); - // Check if fragment is between camera and player (with small margins) const inRangeNear = step(occlusionNearMargin, projDist); const inRangeFar = step(projDist, sub(ctLength, occlusionFarMargin)); const inRange = mul(inRangeNear, inRangeFar); - // Calculate perpendicular distance from fragment to the camera-player line const projX = add(uCameraPos.x, mul(projDist, ctDirX)); const projY = add(uCameraPos.y, mul(projDist, ctDirY)); const projZ = add(uCameraPos.z, mul(projDist, ctDirZ)); - // Perpendicular distance (how far from the center line) const perpX = sub(worldPos.x, projX); const perpY = sub(worldPos.y, projY); const perpZ = sub(worldPos.z, projZ); @@ -730,12 +348,8 @@ export function createGPUVegetationMaterial( ); const perpDist = sqrt(perpDistSq); - // CONE RADIUS CALCULATION (RuneScape-style) - // t = normalized position along camera->player (0 at camera, 1 at player) const t = clamp(div(projDist, ctLength), float(0.0), float(1.0)); - // Cone radius: linearly interpolates from camera radius to player radius - // Plus extra radius based on total camera distance (for zoom-out support) const coneRadius = add( add( occlusionCameraRadius, @@ -744,8 +358,6 @@ export function createGPUVegetationMaterial( mul(ctLength, occlusionDistanceScale), ); - // SHARP EDGE FALLOFF (RuneScape-style binary disappearance) - // Uses a narrow smoothstep band for sharper cutoff const edgeStart = mul( coneRadius, sub(float(1.0), occlusionEdgeSharpness), @@ -755,13 +367,11 @@ export function createGPUVegetationMaterial( smoothstep(edgeStart, coneRadius, perpDist), ); - // Apply occlusion strength and range check return mul(mul(rawOcclusionFade, occlusionStrength), inRange); })() : float(0.0); // 5. NEAR-CAMERA DISSOLVE (RuneScape-style depth fade) - // Prevents hard geometry clipping when camera clips through vegetation const camToFrag = sub(worldPos, uCameraPos); const camDistSq = dot(camToFrag, camToFrag); const camDist = sqrt(camDistSq); @@ -770,11 +380,10 @@ export function createGPUVegetationMaterial( smoothstep(nearCameraFadeEnd, nearCameraFadeStart, camDist), ); - // 6. Combine all fade factors (max ensures all effects work independently) + // 6. Combine all fade factors const combinedFade = max(max(distanceFade, occlusionFade), nearCameraFade); // 7. SCREEN-SPACE 4x4 BAYER DITHERING (RuneScape 3 style) - // 4x4 Bayer matrix: [ 0, 8, 2,10; 12, 4,14, 6; 3,11, 1, 9; 15, 7,13, 5]/16 const ix = mod(floor(viewportCoordinate.x), float(4.0)); const iy = mod(floor(viewportCoordinate.y), float(4.0)); @@ -795,8 +404,6 @@ export function createGPUVegetationMaterial( const ditherValue = mul(bayerInt, float(0.0625)); // 8. RS3-style threshold: discard when fade >= dither - // step returns 0 or 1, multiply by 2 so threshold > 1.0 causes discard - // IMPORTANT: Only apply dithering when combinedFade > 0, otherwise step(0,0)=1 causes holes const hasAnyFade = step(float(0.001), combinedFade); const ditherThreshold = mul( mul(step(ditherValue, combinedFade), hasAnyFade), @@ -807,7 +414,7 @@ export function createGPUVegetationMaterial( return threshold; })(); - // ========== SKY-COLOR FOG (smoothstep + NEAR_SQ, applied in outputNode) ========== + // ========== SKY-COLOR FOG ========== const fogTexNode = texture(fogRenderTarget.texture, screenUV); const toCam = sub(cameraPosition, positionWorld); @@ -818,8 +425,6 @@ export function createGPUVegetationMaterial( fogDistSq, ); - // colorNode reads vertex colors manually; vertexColors must be FALSE to - // prevent the PBR pipeline from multiplying them a second time. material.colorNode = options.vertexColors ? Fn(() => vertexColor().rgb)() : undefined; @@ -829,26 +434,20 @@ export function createGPUVegetationMaterial( } material.fog = false; - // Apply fog AFTER PBR lighting via outputNode so fog color isn't darkened material.outputNode = Fn(() => { const litColor = output; return vec4(mix(litColor.rgb, fogTexNode.rgb, fogFactor), litColor.a); })(); - // CUTOUT rendering with dynamic alphaTestNode - // alphaTestNode returns per-fragment threshold, fragment discarded when alpha < threshold - // This is more efficient than transparent blending and provides proper depth sorting - material.transparent = false; // Opaque rendering for performance - material.opacity = 1.0; // Full opacity - alphaTestNode controls visibility - material.alphaTest = 0.5; // Fallback (alphaTestNode overrides this) + material.transparent = false; + material.opacity = 1.0; + material.alphaTest = 0.5; material.side = THREE.DoubleSide; material.depthWrite = true; - // Matte vegetation material.roughness = 0.95; material.metalness = 0.0; - // Attach uniforms const gpuMaterial = material as unknown as GPUVegetationMaterial; gpuMaterial.gpuUniforms = { playerPos: uPlayerPos, @@ -874,23 +473,6 @@ export function createGPUVegetationMaterial( * @param source - Source material to clone properties from * @param options - Dissolve configuration options * @returns Material with dissolve shader attached - * - * @example - * ```ts - * // Create dissolve material from existing material - * const dissolveMat = createDissolveMaterial(originalMaterial, { - * fadeStart: 270, - * fadeEnd: 300, - * enableNearFade: true, - * nearFadeStart: 1, - * nearFadeEnd: 3, - * enableOcclusionDissolve: true, - * }); - * - * // Update positions each frame - * dissolveMat.dissolveUniforms.playerPos.value.set(px, py, pz); - * dissolveMat.dissolveUniforms.cameraPos.value.set(cx, cy, cz); - * ``` */ export function createDissolveMaterial( source: THREE.MeshStandardMaterial | THREE.Material, @@ -953,7 +535,7 @@ export function createDissolveMaterial( const nearFadeEndSq = mul(uNearFadeEnd, uNearFadeEnd); const enableNearFade = options.enableNearFade ?? true; const enableWaterCulling = options.enableWaterCulling ?? false; - const enableOcclusion = options.enableOcclusionDissolve !== false; // Default: enabled + const enableOcclusion = options.enableOcclusionDissolve !== false; const waterCutoff = float( GPU_VEG_CONFIG.WATER_LEVEL + GPU_VEG_CONFIG.WATER_BUFFER, ); @@ -975,25 +557,23 @@ export function createDissolveMaterial( material.alphaTestNode = Fn(() => { const worldPos = positionWorld; - // Distance calculation from world position to player (horizontal only, squared) const toPlayer = sub(worldPos, uPlayerPos); const distSq = add( mul(toPlayer.x, toPlayer.x), mul(toPlayer.z, toPlayer.z), ); - // FAR fade: 0.0 when close (keep), 1.0 when far (discard) + // FAR fade const farFade = smoothstep(fadeStartSq, fadeEndSq, distSq); - // NEAR fade: 0.0 when outside near zone (keep), 1.0 when too close (discard) + // NEAR fade const nearFade = enableNearFade ? sub(float(1.0), smoothstep(nearFadeStartSq, nearFadeEndSq, distSq)) : float(0.0); - // Combined distance fade (max of near and far fade) const distanceFadeBase = max(farFade, nearFade); - // NEAR-CAMERA DISSOLVE (prevents hard clipping when camera clips through objects) + // NEAR-CAMERA DISSOLVE const camToFrag = sub(worldPos, uCameraPos); const camDistSq = dot(camToFrag, camToFrag); const camDist = sqrt(camDistSq); @@ -1003,24 +583,20 @@ export function createDissolveMaterial( ); // CAMERA-TO-PLAYER OCCLUSION DISSOLVE (RuneScape-style) - // Uses a CONE shape that expands from camera toward player const occlusionFade = enableOcclusion ? (() => { - // Camera to player vector (CT) const camToPlayer = vec3( sub(uPlayerPos.x, uCameraPos.x), sub(uPlayerPos.y, uCameraPos.y), sub(uPlayerPos.z, uCameraPos.z), ); - // Camera to fragment vector (CP) const camToFrag = vec3( sub(worldPos.x, uCameraPos.x), sub(worldPos.y, uCameraPos.y), sub(worldPos.z, uCameraPos.z), ); - // Length of camera-to-player vector const ctLengthSq = add( add( mul(camToPlayer.x, camToPlayer.x), @@ -1030,23 +606,19 @@ export function createDissolveMaterial( ); const ctLength = sqrt(ctLengthSq); - // Normalized camera-to-player direction const ctDirX = div(camToPlayer.x, ctLength); const ctDirY = div(camToPlayer.y, ctLength); const ctDirZ = div(camToPlayer.z, ctLength); - // Project fragment onto camera-player line const projDist = add( add(mul(camToFrag.x, ctDirX), mul(camToFrag.y, ctDirY)), mul(camToFrag.z, ctDirZ), ); - // Check if fragment is between camera and player (with margins) const inRangeNear = step(occlusionNearMargin, projDist); const inRangeFar = step(projDist, sub(ctLength, occlusionFarMargin)); const inRange = mul(inRangeNear, inRangeFar); - // Calculate perpendicular distance from fragment to the camera-player line const projX = add(uCameraPos.x, mul(projDist, ctDirX)); const projY = add(uCameraPos.y, mul(projDist, ctDirY)); const projZ = add(uCameraPos.z, mul(projDist, ctDirZ)); @@ -1060,7 +632,6 @@ export function createDissolveMaterial( ); const perpDist = sqrt(perpDistSq); - // CONE RADIUS CALCULATION (RuneScape-style) const t = clamp(div(projDist, ctLength), float(0.0), float(1.0)); const coneRadius = add( add( @@ -1070,7 +641,6 @@ export function createDissolveMaterial( mul(ctLength, occlusionDistanceScale), ); - // SHARP EDGE FALLOFF (RuneScape-style binary disappearance) const edgeStart = mul( coneRadius, sub(float(1.0), occlusionEdgeSharpness), @@ -1080,12 +650,10 @@ export function createDissolveMaterial( smoothstep(edgeStart, coneRadius, perpDist), ); - // Apply occlusion strength and range check return mul(mul(rawOcclusionFade, occlusionStrength), inRange); })() : float(0.0); - // Combine distance fade, occlusion fade, and near-camera fade const distanceFade = max( max(distanceFadeBase, occlusionFade), nearCameraFade, @@ -1111,16 +679,12 @@ export function createDissolveMaterial( ); const ditherValue = mul(bayerInt, float(0.0625)); - // RS3-style: discard when fade >= dither - // step returns 0 or 1, multiply by 2 so threshold > 1.0 causes discard - // IMPORTANT: Only apply dithering when distanceFade > 0, otherwise step(0,0)=1 causes holes const hasAnyFade = step(float(0.001), distanceFade); const ditherThreshold = mul( mul(step(ditherValue, distanceFade), hasAnyFade), float(2.0), ); - // Water culling (optional) const waterCullValue = enableWaterCulling ? mul(step(worldPos.y, waterCutoff), float(2.0)) : float(0.0); @@ -1129,11 +693,9 @@ export function createDissolveMaterial( return threshold; })(); - // Material settings for cutout rendering - material.alphaTest = 0.5; // Fallback + material.alphaTest = 0.5; material.forceSinglePass = true; - // Attach dissolve uniforms for per-frame updates const dissolveMat = material as DissolveMaterial; dissolveMat.dissolveUniforms = { playerPos: uPlayerPos, @@ -1144,6 +706,39 @@ export function createDissolveMaterial( nearFadeEnd: uNearFadeEnd, }; + // Per-instance glow highlight (driven by instanceHighlight attribute) + if (options.enableRimHighlight) { + const uHighlightColor = uniform(new THREE.Color(0x00ffff)); + dissolveMat.highlightColor = uHighlightColor; + + const BRIGHTEN = 0.08; + const RIM_POWER = 2.5; + const RIM_STRENGTH = 0.4; + + material.outputNode = Fn(() => { + const litColor = output; + const hlIntensity = attribute("instanceHighlight", "float"); + + const N = normalize(normalView); + const V = normalize(sub(vec3(0, 0, 0), positionView.xyz)); + const NdotV = clamp(dot(N, V), float(0.0), float(1.0)); + + // Fresnel rim — glow at silhouette edges only + const rim = mul( + pow(sub(float(1.0), NdotV), float(RIM_POWER)), + float(RIM_STRENGTH), + ); + + // Gentle brighten + rim-only highlight color + const brightened = add(litColor.rgb, float(BRIGHTEN)); + const rimGlow = mul(vec3(uHighlightColor), rim); + const highlighted = add(brightened, rimGlow); + + const finalRgb = mix(litColor.rgb, highlighted, hlIntensity); + return vec4(finalRgb, litColor.a); + })(); + } + material.needsUpdate = true; return dissolveMat; } @@ -1158,7 +753,7 @@ export function isDissolveMaterial( } // ============================================================================ -// IMPOSTER BILLBOARD MATERIAL (FOR VEGETATION/RESOURCE IMPOSTERS) +// IMPOSTER BILLBOARD MATERIAL // ============================================================================ /** @@ -1195,69 +790,38 @@ export type ImposterMaterial = THREE.MeshStandardNodeMaterial & { * - Adds distance-based dithered dissolve (matches 3D vegetation) * - Uses same lighting properties as 3D vegetation (roughness, metalness) * - * The dissolve is achieved by dynamically adjusting the alpha test threshold: - * - Close range: threshold = alphaTest (normal texture cutout) - * - Far range: threshold increases, discarding more fragments via dither - * * @param options - Imposter material configuration * @returns Material with dissolve shader and uniforms - * - * @example - * ```ts - * const material = createImposterMaterial({ - * texture: preRenderedTexture, - * fadeStart: 300, // Start dissolve at 300m - * fadeEnd: 350, // Fully dissolved at 350m - * }); - * - * // Update player position each frame - * material.imposterUniforms.playerPos.value.set(px, 0, pz); - * ``` */ export function createImposterMaterial( options: ImposterMaterialOptions, ): ImposterMaterial { const material = new MeshStandardNodeMaterial(); - // Set the pre-rendered texture material.map = options.texture; // ========== UNIFORMS ========== const uPlayerPos = uniform(new THREE.Vector3(0, 0, 0)); - // Store fade distances for runtime access (if needed) const fadeStartVal = options.fadeStart ?? 300; const fadeEndVal = options.fadeEnd ?? 350; const uFadeStart = uniform(fadeStartVal); const uFadeEnd = uniform(fadeEndVal); // ========== CONSTANTS (PRE-COMPUTED ON CPU) ========== - // OPTIMIZATION: Pre-compute squared distances on CPU rather than per-fragment - // These don't change at runtime, so avoid redundant GPU multiplication const fadeStartSq = float(fadeStartVal * fadeStartVal); const fadeEndSq = float(fadeEndVal * fadeEndVal); const baseAlphaThreshold = float(options.alphaTest ?? 0.5); // ========== ALPHA TEST (TEXTURE CUTOUT + DITHERED DISSOLVE) ========== - // This combines two alpha test behaviors: - // 1. Texture alpha cutout (for tree silhouette) - // 2. Distance-based dithered dissolve (for LOD fade) - // - // The texture has alpha=0 for background, alpha=1 for tree. - // alphaTestNode returns threshold: fragment discarded when texture.alpha < threshold - // - // - Close: threshold = baseAlphaThreshold (0.5) → normal cutout - // - Far: threshold = baseAlphaThreshold + dissolve → even opaque pixels discard material.alphaTestNode = Fn(() => { const worldPos = positionWorld; - // Distance calculation from world position to player (horizontal only, squared) const toPlayer = sub(worldPos, uPlayerPos); const distSq = add( mul(toPlayer.x, toPlayer.x), mul(toPlayer.z, toPlayer.z), ); - // FAR fade: 0.0 when close (keep), 1.0 when far (discard) const farFade = smoothstep(fadeStartSq, fadeEndSq, distSq); // SCREEN-SPACE 4x4 BAYER DITHERING (RuneScape 3 style) @@ -1280,9 +844,6 @@ export function createImposterMaterial( ); const ditherValue = mul(bayerInt, float(0.0625)); - // RS3-style: combine texture cutout with dithered distance fade - // step returns 0 or 1, multiply by 2 so threshold > 1.0 causes discard - // IMPORTANT: Only apply dithering when farFade > 0, otherwise step(0,0)=1 causes holes const hasAnyFade = step(float(0.001), farFade); const ditherDiscard = mul( mul(step(ditherValue, farFade), hasAnyFade), @@ -1294,15 +855,13 @@ export function createImposterMaterial( })(); // ========== MATERIAL SETTINGS ========== - // Match 3D vegetation material for consistent lighting material.roughness = 0.95; material.metalness = 0.0; material.side = THREE.DoubleSide; - // Cutout rendering (not transparent blend) for performance material.transparent = false; material.opacity = 1.0; - material.alphaTest = options.alphaTest ?? 0.5; // Fallback + material.alphaTest = options.alphaTest ?? 0.5; material.depthWrite = true; // ========== ATTACH UNIFORMS ========== @@ -1325,3 +884,295 @@ export function isImposterMaterial( ): material is ImposterMaterial { return material != null && "imposterUniforms" in material; } + +// ============================================================================ +// UNIFORM-BASED RIM HIGHLIGHT (FOR NON-INSTANCED ENTITIES) +// ============================================================================ + +/** + * Apply a Fresnel rim highlight to an individual (non-instanced) node material. + * Uses a uniform toggle instead of an instanced attribute. + * + * Call this once per material; returns the uniform whose `.value` you set to + * `1.0` (highlighted) or `0.0` (normal) at runtime. + * + * @param material - A MeshStandardNodeMaterial (duck-typed via `outputNode` check) + * @param color - Highlight rim color (default: cyan 0x00ffff) + * @returns The highlight uniform, or null if the material is incompatible + */ +export function applyRimHighlight( + material: THREE.Material, + color: THREE.Color = new THREE.Color(0x00ffff), +): { value: number } | null { + const mat = material as THREE.MeshStandardNodeMaterial; + if (!mat || !("outputNode" in mat)) return null; + + const uHighlight = uniform(0.0); + const uHighlightColor = uniform(color); + + const BRIGHTEN = 0.08; + const RIM_POWER = 2.5; + const RIM_STRENGTH = 0.4; + + const prevOutput = mat.outputNode; + + mat.outputNode = Fn(() => { + const litColor = prevOutput ? prevOutput : output; + const hlIntensity = uHighlight; + + const N = normalize(normalView); + const V = normalize(sub(vec3(0, 0, 0), positionView.xyz)); + const NdotV = clamp(dot(N, V), float(0.0), float(1.0)); + + const rim = mul( + pow(sub(float(1.0), NdotV), float(RIM_POWER)), + float(RIM_STRENGTH), + ); + + const brightened = add(litColor.rgb, float(BRIGHTEN)); + const rimGlow = mul(vec3(uHighlightColor), rim); + const highlighted = add(brightened, rimGlow); + + const finalRgb = mix(litColor.rgb, highlighted, hlIntensity); + return vec4(finalRgb, litColor.a); + })(); + + mat.needsUpdate = true; + return uHighlight as unknown as { value: number }; +} + +// ============================================================================ +// TREE DISSOLVE MATERIAL (FORTNITE-STYLE FOLIAGE SHADING) +// ============================================================================ + +/** + * Options for creating tree dissolve materials. + */ +export type TreeMaterialOptions = DissolveMaterialOptions & { + /** Whether this material covers leaf geometry (enables wind + SSS) */ + isLeafMaterial?: boolean; +}; + +/** + * Tree-specific dissolve material with soft stylized shading. + * Extends DissolveMaterial with: + * - Soft clamped lighting (Lambert compressed to [0.7, 1.0], no harsh shadows) + * - Fresnel edge brightening on leaves (morpho-style rim glow) + * - Back-SSS translucency for leaves (warm glow when backlit by sun) + * - Wind vertex animation for leaves + * - Vertex-color AO (G channel darkens crevices) + * - Per-instance rim highlight + */ +export type TreeDissolveMaterial = DissolveMaterial & { + treeUniforms: { + sunDirection: { value: THREE.Vector3 }; + sunIntensity: { value: number }; + shadeColor: { value: THREE.Color }; + windTime: { value: number }; + windStrength: { value: number }; + windDirection: { value: THREE.Vector2 }; + }; +}; + +/** + * Creates a tree dissolve material with soft clamped lighting, SSS, and wind. + * + * 1. **Soft lighting** — Lambert + smoothstep clamped to [0.7, 1.0] (no harsh shadows). + * 2. **AO** — Vertex color G channel as ambient occlusion. + * 3. **SSS** — Back-scatter translucency on leaf materials (warm glow when backlit). + * 4. **Wind** — Sine-wave vertex displacement on leaf materials. + * 5. **Edge brightening** — Fresnel-based rim glow on leaves (morpho effect). + * 6. **Saturation** — Subtle boost keeps colors rich. + * 7. **Rim highlight** — Per-instance Fresnel glow for hover feedback. + * + * @param source - Source material to clone PBR properties from + * @param options - Dissolve + tree configuration (fade distances, isLeafMaterial, etc.) + */ +export function createTreeDissolveMaterial( + source: THREE.MeshStandardMaterial | THREE.Material, + options: TreeMaterialOptions = {}, +): TreeDissolveMaterial { + const baseDm = createDissolveMaterial(source, { + ...options, + enableRimHighlight: false, + }); + + const material = baseDm as unknown as THREE.MeshStandardNodeMaterial; + const isLeaf = options.isLeafMaterial ?? false; + + const hasVertexColors = !!(source as any).vertexColors; + material.vertexColors = false; + + const srcStd = source as THREE.MeshStandardMaterial; + if (srcStd.normalMap) { + material.normalScale = new THREE.Vector2(2, 2); + } + + // --- Uniforms --- + const uSunDir = uniform(new THREE.Vector3(0.5, 0.8, 0.3)); + const uSunIntensity = uniform(1.0); + const uShadeColor = uniform(new THREE.Color(0.7, 1.08, 1.22)); + const uHighlightColor = uniform(new THREE.Color(0x00ffff)); + const uWindTime = uniform(0.0); + const uWindStrength = uniform(0.3); + const uWindDir = uniform(new THREE.Vector2(1, 0)); + + // --- Tuning --- + const AO_POWER = 1.8; + const AO_DARK = 0.35; + const SAT_BOOST = 1.15; + const HL_BRIGHTEN = 0.08; + const HL_RIM_POWER = 2.5; + const HL_RIM_STRENGTH = 0.4; + const LIGHT_AMBIENT = 0.3; + const LIGHT_DIFFUSE_STR = 2.0; + const LIGHT_CLAMP_LO = 0.7; + const LIGHT_CLAMP_HI = 1.0; + const LIGHT_AMBIENT_BOOST = 0.15; + const EDGE_BRIGHT = 1.25; + + // --- Wind vertex displacement (leaf materials only) --- + // Displacement is proportional to local Y so it auto-scales to any model + // coordinate system (bamboo Y~15 at scale 0.8 vs fir Y~1900 at scale 0.008). + if (isLeaf) { + material.positionNode = Fn(() => { + const pos = positionLocal; + const phase = add(mul(pos.x, float(0.013)), mul(pos.z, float(0.017))); + const wave1 = sin(add(mul(uWindTime, float(1.8)), phase)); + const wave2 = sin( + add(mul(uWindTime, float(3.2)), mul(phase, float(0.6))), + ); + const combined = add(mul(wave1, float(0.65)), mul(wave2, float(0.35))); + const amplitude = mul(abs(pos.y), float(0.006)); + const disp = mul(combined, mul(uWindStrength, amplitude)); + return vec3( + add(pos.x, mul(disp, uWindDir.x)), + pos.y, + add(pos.z, mul(disp, uWindDir.y)), + ); + })(); + } + + // --- Sky-color fog (same as terrain/vegetation) --- + const treeFogTex = texture(fogRenderTarget.texture, screenUV); + const treeToCam = sub(cameraPosition, positionWorld); + const treeFogDistSq = dot(treeToCam, treeToCam); + const treeFogFactor = smoothstep( + float(FOG_NEAR_SQ), + float(FOG_FAR_SQ), + treeFogDistSq, + ); + material.fog = false; + + // --- Output: soft clamped lighting (bypass PBR, compute Lambert from scratch) --- + const albedoMap = material.map; + const matColor = vec3(material.color.r, material.color.g, material.color.b); + + material.outputNode = Fn(() => { + const pbrOut = output; + + // ---- Albedo (sample texture directly, bypass PBR lighting) ---- + const texCoord = attribute("uv", "vec2"); + const albedoSample = albedoMap + ? texture(albedoMap, texCoord) + : vec4(1, 1, 1, 1); + let baseAlbedo: any = mul(albedoSample.rgb, matColor); + + // ---- Vertex-color AO ---- + if (hasVertexColors) { + const aoRaw = attribute("color", "vec3").y; + const aoFactor = pow(aoRaw, float(AO_POWER)); + const aoMul = mix(float(AO_DARK), float(1.0), aoFactor); + baseAlbedo = mul(baseAlbedo, aoMul); + } + + // ---- Custom Lambert lighting (soft clamped, scaled by sun intensity) ---- + const N = normalize(normalWorld); + const L = normalize(vec3(uSunDir)); + const NdotL = dot(N, L); + const sunI = clamp(uSunIntensity, float(0.0), float(2.0)); + const dayFactor = div(sunI, float(2.0)); + const diffuse = mul( + mul(max(NdotL, float(0.0)), float(LIGHT_DIFFUSE_STR)), + sunI, + ); + const ambient = mul(float(LIGHT_AMBIENT), dayFactor); + const totalLight = add(ambient, diffuse); + const clampLo = max(float(0.08), mul(float(LIGHT_CLAMP_LO), dayFactor)); + const softLight = clamp( + smoothstep(float(0.9), float(1.1), totalLight), + clampLo, + float(LIGHT_CLAMP_HI), + ); + const ambientBoost = mul(float(LIGHT_AMBIENT_BOOST), dayFactor); + let result: any = mul(baseAlbedo, add(softLight, ambientBoost)); + + // ---- Sun shade (shadow-side sky tint, matches terrain) ---- + result = applyTerrainSunShade(result, N, L, vec3(uShadeColor)); + + // ---- SSS + Fresnel edge brightening (leaf only) ---- + if (isLeaf) { + const V = normalize(sub(cameraPosition, positionWorld)); + + // Back-scatter SSS + const backL = normalize(sub(vec3(0, 0, 0), L)); + const backSSS = clamp(dot(V, backL), float(0), float(1)); + const sssFactor = mul(pow(backSSS, float(3.0)), float(0.12)); + result = add(result, mul(vec3(0.95, 1.0, 0.7), sssFactor)); + + // Edge brightening (morpho effect) + const EDotN = clamp(dot(V, N), float(0.0), float(1.0)); + result = mix(mul(result, float(EDGE_BRIGHT)), result, EDotN); + } + + // ---- Saturation boost ---- + const luma = dot(result, vec3(0.299, 0.587, 0.114)); + const boosted = add( + mul(sub(result, vec3(luma, luma, luma)), float(SAT_BOOST)), + vec3(luma, luma, luma), + ); + + // ---- Instance rim highlight (hover) ---- + let hlIntensity; + if (options.batched) { + const batchColor = varyingProperty("vec3", "vBatchColor"); + hlIntensity = step( + float(1.01), + max(batchColor.x, max(batchColor.y, batchColor.z)), + ); + } else { + hlIntensity = attribute("instanceHighlight", "float"); + } + const NV = normalize(normalView); + const Vv = normalize(sub(vec3(0, 0, 0), positionView.xyz)); + const NdotV = clamp(dot(NV, Vv), float(0.0), float(1.0)); + const rim = mul( + pow(sub(float(1.0), NdotV), float(HL_RIM_POWER)), + float(HL_RIM_STRENGTH), + ); + const brightened = add(boosted, float(HL_BRIGHTEN)); + const rimGlow = mul(vec3(uHighlightColor), rim); + const highlighted = add(brightened, rimGlow); + const finalRgb = mix(boosted, highlighted, hlIntensity); + + // ---- Sky-color fog ---- + const fogged = mix(finalRgb, treeFogTex.rgb, treeFogFactor); + + return vec4(fogged, pbrOut.a); + })(); + + material.needsUpdate = true; + + const treeMat = baseDm as TreeDissolveMaterial; + treeMat.highlightColor = uHighlightColor; + treeMat.treeUniforms = { + sunDirection: uSunDir as unknown as { value: THREE.Vector3 }, + sunIntensity: uSunIntensity as unknown as { value: number }, + shadeColor: uShadeColor as unknown as { value: THREE.Color }, + windTime: uWindTime as unknown as { value: number }, + windStrength: uWindStrength as unknown as { value: number }, + windDirection: uWindDir as unknown as { value: THREE.Vector2 }, + }; + + return treeMat; +} diff --git a/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts b/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts index e9830da6a..3505bb6eb 100644 --- a/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts +++ b/packages/shared/src/systems/shared/world/GrassExclusionGrid.ts @@ -25,6 +25,7 @@ import { texture, uniform, Fn, float } from "three/tsl"; import type { World } from "../../../core/World"; import type { CollisionMatrix } from "../movement/CollisionMatrix"; import { CollisionFlag, CollisionMask } from "../movement/CollisionFlags"; +import { BiomeType } from "./TerrainBiomeTypes"; import { BIOMES } from "../../../data/world-structure"; import { setGridExclusionTexture } from "./ProceduralGrass"; import type { TownSystem } from "./TownSystem"; @@ -44,9 +45,9 @@ const GRID_CONFIG = { /** Distance player must move before re-centering texture */ RECENTER_THRESHOLD: 64, // Re-center when player moves 64m from texture center /** Biomes where grass never grows - matched from biomes.json terrain types */ - NON_GRASSY_TERRAINS: new Set([ - "desert", - "tundra", + NON_GRASSY_TERRAINS: new Set([ + BiomeType.Canyon, + BiomeType.Tundra, "mountains", "lake", "frozen", @@ -63,7 +64,7 @@ const GRID_CONFIG = { */ type ExclusionReason = | "collision" // CollisionMatrix blocked (rock, tree, building, etc.) - | "biome" // Non-grassy biome (desert, tundra, etc.) + | "biome" // Non-grassy biome (canyon, tundra, etc.) | "water" // Below water level | "slope"; // Too steep diff --git a/packages/shared/src/systems/shared/world/LODConfig.ts b/packages/shared/src/systems/shared/world/LODConfig.ts new file mode 100644 index 000000000..f1f17c342 --- /dev/null +++ b/packages/shared/src/systems/shared/world/LODConfig.ts @@ -0,0 +1,421 @@ +/** + * LODConfig.ts - Unified LOD Distance Configuration + * + * Single source of truth for all Level-of-Detail distances across the engine. + * Pure data + utility math — no shader code, no Three.js material dependencies. + * + * Used by: VegetationSystem, ResourceEntity, BuildingRenderingSystem, etc. + * + * @module LODConfig + */ + +import * as THREE from "../../../extras/three/three"; + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * LOD distances for a category. + * All distances in meters from camera. + * + * LOD Pipeline: + * - LOD0 (0 to lod1Distance): Full detail mesh + * - LOD1 (lod1Distance to lod2Distance): Low-poly mesh (~10% verts) + * - LOD2 (lod2Distance to imposterDistance): Very low-poly mesh (~3% verts) + * - Impostor (imposterDistance to fadeDistance): Billboard + * - Culled (> fadeDistance): Hidden + */ +export interface LODDistances { + /** Distance to switch from LOD0 (full detail) to LOD1 (low poly ~10%) */ + lod1Distance: number; + /** Distance to switch from LOD1 to LOD2 (very low poly ~3%) */ + lod2Distance: number; + /** Distance to switch from 3D mesh to billboard imposter */ + imposterDistance: number; + /** Distance at which to completely fade out/cull */ + fadeDistance: number; +} + +/** + * LOD distances with pre-computed squared values for performance. + * Use squared distances to avoid Math.sqrt in hot paths. + */ +export interface LODDistancesWithSq extends LODDistances { + lod1DistanceSq: number; + lod2DistanceSq: number; + imposterDistanceSq: number; + fadeDistanceSq: number; +} + +// ============================================================================ +// DISTANCE TABLE +// ============================================================================ + +/** + * UNIFIED LOD CONFIGURATION - Single source of truth for all LOD distances. + * + * This is the canonical configuration used by: + * - VegetationSystem (trees, bushes, grass, etc.) + * - ResourceEntity (harvestable resources) + * - Any other LOD systems + * + * Categories can be customized based on object size and visual importance. + */ +export const LOD_DISTANCES: Record = { + // Large vegetation - aggressive LOD for performance + tree: { + lod1Distance: 30, + lod2Distance: 60, + imposterDistance: 100, + fadeDistance: 180, + }, + + // Medium vegetation + bush: { + lod1Distance: 40, + lod2Distance: 80, + imposterDistance: 120, + fadeDistance: 200, + }, + fern: { + lod1Distance: 30, + lod2Distance: 55, + imposterDistance: 80, + fadeDistance: 120, + }, + rock: { + lod1Distance: 50, + lod2Distance: 100, + imposterDistance: 150, + fadeDistance: 250, + }, + fallen_tree: { + lod1Distance: 45, + lod2Distance: 90, + imposterDistance: 130, + fadeDistance: 200, + }, + + // Small vegetation (aggressive culling - skip LOD2, go straight to impostor) + flower: { + lod1Distance: 25, + lod2Distance: 45, // Same as imposter - skip LOD2 + imposterDistance: 60, + fadeDistance: 100, + }, + mushroom: { + lod1Distance: 15, + lod2Distance: 30, // Same as imposter - skip LOD2 + imposterDistance: 40, + fadeDistance: 80, + }, + grass: { + lod1Distance: 10, + lod2Distance: 20, // Same as imposter - skip LOD2 + imposterDistance: 30, + fadeDistance: 60, + }, + + // Resources (harvestable objects) + resource: { + lod1Distance: 45, + lod2Distance: 85, + imposterDistance: 120, + fadeDistance: 200, + }, + tree_resource: { + lod1Distance: 25, // Switch to LOD1 very early + lod2Distance: 50, + imposterDistance: 80, // Switch to impostor ASAP + fadeDistance: 150, // Fade out earlier + }, + rock_resource: { + lod1Distance: 50, + lod2Distance: 100, + imposterDistance: 150, + fadeDistance: 250, + }, + + // Buildings - simple geometry, skip intermediate LODs and go directly to impostor + // LOD0 (0-80m): Full detail batched mesh with shadows + // Impostor (80-200m): Octahedral billboard + // Culled (>200m): Hidden + building: { + lod1Distance: 80, // Same as impostor - skip intermediate LOD + lod2Distance: 80, // Same as impostor - skip intermediate LOD + imposterDistance: 80, // Switch to impostor at 80m + fadeDistance: 200, // Cull at 200m + }, + station: { + lod1Distance: 60, + lod2Distance: 140, + imposterDistance: 200, + fadeDistance: 350, + }, + + // Mobs and NPCs (skip LOD2 - use LOD1 to impostor) + mob: { + lod1Distance: 40, + lod2Distance: 70, + imposterDistance: 100, + fadeDistance: 150, + }, + npc: { + lod1Distance: 50, + lod2Distance: 90, + imposterDistance: 120, + fadeDistance: 180, + }, + player: { + lod1Distance: 50, + lod2Distance: 90, + imposterDistance: 120, + fadeDistance: 200, + }, + + // Items (skip LOD2) + item: { + lod1Distance: 25, + lod2Distance: 45, + imposterDistance: 60, + fadeDistance: 100, + }, +}; + +/** Default LOD distances for unknown categories */ +export const DEFAULT_LOD_DISTANCES: LODDistances = { + lod1Distance: 45, + lod2Distance: 85, + imposterDistance: 120, + fadeDistance: 200, +}; + +// ============================================================================ +// SIZE-BASED SCALING +// ============================================================================ + +/** + * Reference size (in meters) for LOD distance scaling. + * Objects larger than this get proportionally extended draw distances. + * Objects smaller than this get proportionally reduced draw distances. + * + * Based on typical "medium" object size (a small tree or large bush). + */ +export const LOD_REFERENCE_SIZE = 5.0; + +/** + * Minimum scale factor (prevents tiny objects from having 0 draw distance) + */ +export const LOD_MIN_SCALE = 0.3; + +/** + * Maximum scale factor (prevents huge objects from having infinite draw distance) + */ +export const LOD_MAX_SCALE = 10.0; + +/** + * Calculate size-based LOD distance scale factor. + * + * Formula: scale = clamp(boundingSize / referenceSize, minScale, maxScale) + * + * Examples: + * - 5m object (reference): scale = 1.0x (normal distances) + * - 10m object: scale = 2.0x (double distances) + * - 50m object: scale = 10.0x (max, capped) + * - 2m object: scale = 0.4x (40% of normal) + * - 0.5m object: scale = 0.3x (min, capped) + * + * @param boundingSize - Bounding box diagonal or sphere diameter in meters + * @returns Scale factor to multiply with base LOD distances + */ +export function calculateLODScaleFactor(boundingSize: number): number { + if (boundingSize <= 0) return 1.0; + const rawScale = boundingSize / LOD_REFERENCE_SIZE; + return Math.max(LOD_MIN_SCALE, Math.min(LOD_MAX_SCALE, rawScale)); +} + +// ============================================================================ +// CACHED LOOKUPS +// ============================================================================ + +/** Cache for LOD configs with pre-computed squared distances */ +const lodDistanceCache = new Map(); + +/** Cache for size-scaled LOD configs */ +const lodDistanceSizeCache = new Map(); + +/** + * Get LOD distances for a category with pre-computed squared values. + * Caches results for performance. + * + * @param category - Category name (e.g., "tree", "bush", "resource") + * @returns LOD distances with squared values for distance comparisons + */ +export function getLODDistances(category: string): LODDistancesWithSq { + const cached = lodDistanceCache.get(category); + if (cached) return cached; + + const base = LOD_DISTANCES[category] ?? DEFAULT_LOD_DISTANCES; + + const withSq: LODDistancesWithSq = { + ...base, + lod1DistanceSq: base.lod1Distance * base.lod1Distance, + lod2DistanceSq: base.lod2Distance * base.lod2Distance, + imposterDistanceSq: base.imposterDistance * base.imposterDistance, + fadeDistanceSq: base.fadeDistance * base.fadeDistance, + }; + + lodDistanceCache.set(category, withSq); + return withSq; +} + +/** + * Get LOD distances scaled by object size. + * + * Larger objects (bigger bounding box) get proportionally extended draw distances, + * allowing them to be visible from farther away as imposters. + * + * @param category - Category name (e.g., "tree", "bush", "resource") + * @param boundingSize - Bounding box diagonal or sphere diameter in meters + * @returns LOD distances scaled by object size with pre-computed squared values + */ +export function getLODDistancesScaled( + category: string, + boundingSize: number, +): LODDistancesWithSq { + const roundedSize = Math.round(boundingSize * 10) / 10; + const cacheKey = `${category}_${roundedSize}`; + + const cached = lodDistanceSizeCache.get(cacheKey); + if (cached) return cached; + + const base = LOD_DISTANCES[category] ?? DEFAULT_LOD_DISTANCES; + const scale = calculateLODScaleFactor(boundingSize); + + const scaled: LODDistancesWithSq = { + lod1Distance: base.lod1Distance * scale, + lod2Distance: base.lod2Distance * scale, + imposterDistance: base.imposterDistance * scale, + fadeDistance: base.fadeDistance * scale, + lod1DistanceSq: (base.lod1Distance * scale) ** 2, + lod2DistanceSq: (base.lod2Distance * scale) ** 2, + imposterDistanceSq: (base.imposterDistance * scale) ** 2, + fadeDistanceSq: (base.fadeDistance * scale) ** 2, + }; + + lodDistanceSizeCache.set(cacheKey, scaled); + return scaled; +} + +/** + * Get LOD configuration for an entity or object, with size-based scaling. + * + * Convenience function that extracts bounding size from common object types. + * + * @param category - Category name + * @param object - THREE.Object3D, bounding box, or bounding sphere + * @returns Scaled LOD distances + */ +export function getLODConfig( + category: string, + object?: + | THREE.Object3D + | THREE.Box3 + | THREE.Sphere + | { boundingSize: number } + | number, +): LODDistancesWithSq { + if (object === undefined || object === null) { + return getLODDistances(category); + } + + let boundingSize: number; + + if (typeof object === "number") { + boundingSize = object; + } else if ("boundingSize" in object) { + boundingSize = object.boundingSize; + } else if (object instanceof THREE.Box3) { + const size = new THREE.Vector3(); + object.getSize(size); + boundingSize = size.length(); + } else if (object instanceof THREE.Sphere) { + boundingSize = object.radius * 2; + } else if (object instanceof THREE.Object3D) { + const box = new THREE.Box3().setFromObject(object); + const size = new THREE.Vector3(); + box.getSize(size); + boundingSize = size.length(); + } else { + return getLODDistances(category); + } + + return getLODDistancesScaled(category, boundingSize); +} + +/** + * Clear the LOD distance cache. + * Call this if LOD_DISTANCES is modified at runtime. + */ +export function clearLODDistanceCache(): void { + lodDistanceCache.clear(); + lodDistanceSizeCache.clear(); +} + +/** + * Apply LOD settings from a manifest or external configuration. + * Merges with existing configuration and clears the cache. + * + * @param settings - LOD settings with distanceThresholds + */ +export function applyLODSettings(settings: { + distanceThresholds?: Record< + string, + { lod1?: number; lod2?: number; imposter: number; fadeOut: number } + >; +}): void { + if (!settings.distanceThresholds) return; + + for (const [category, thresholds] of Object.entries( + settings.distanceThresholds, + )) { + const configKey = category === "fallen" ? "fallen_tree" : category; + + const lod1 = thresholds.lod1 ?? 0; + const lod2 = thresholds.lod2 ?? (lod1 + thresholds.imposter) / 2; + + LOD_DISTANCES[configKey] = { + lod1Distance: lod1, + lod2Distance: lod2, + imposterDistance: thresholds.imposter, + fadeDistance: thresholds.fadeOut, + }; + } + + clearLODDistanceCache(); + + console.log( + `[LODConfig] Applied LOD settings for ${Object.keys(settings.distanceThresholds).length} categories`, + ); +} + +// ============================================================================ +// LOD PATH INFERENCE +// ============================================================================ + +/** + * Infer LOD1 model path from LOD0 path by appending `_lod1` before `.glb`. + * e.g. `trees/oak.glb` → `trees/oak_lod1.glb` + */ +export function inferLOD1Path(lod0Path: string): string { + return lod0Path.replace(/\.glb$/i, "_lod1.glb"); +} + +/** + * Infer LOD2 model path from LOD0 path by appending `_lod2` before `.glb`. + * e.g. `trees/oak.glb` → `trees/oak_lod2.glb` + */ +export function inferLOD2Path(lod0Path: string): string { + return lod0Path.replace(/\.glb$/i, "_lod2.glb"); +} diff --git a/packages/shared/src/systems/shared/world/POISystem.ts b/packages/shared/src/systems/shared/world/POISystem.ts index a0d898ddc..1144c1326 100644 --- a/packages/shared/src/systems/shared/world/POISystem.ts +++ b/packages/shared/src/systems/shared/world/POISystem.ts @@ -18,6 +18,7 @@ import type { } from "../../../types/world/world-types"; import { NoiseGenerator } from "../../../utils/NoiseGenerator"; import { Logger } from "../../../utils/Logger"; +import { BiomeType, DEFAULT_BIOME } from "./TerrainBiomeTypes"; import { DataManager } from "../../../data/DataManager"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; import { dist2D } from "../../../utils/MathUtils"; @@ -50,47 +51,47 @@ const CATEGORY_PROPERTIES: Record< dungeon: { radius: 30, baseImportance: 0.9, - preferredBiomes: ["mountains", "forest", "swamp"], + preferredBiomes: [BiomeType.Canyon, BiomeType.Tundra], }, shrine: { radius: 10, baseImportance: 0.6, - preferredBiomes: ["forest", "plains", "valley"], + preferredBiomes: [BiomeType.Forest, BiomeType.Tundra], }, landmark: { radius: 20, baseImportance: 0.5, - preferredBiomes: ["mountains", "plains", "desert"], + preferredBiomes: [BiomeType.Canyon, BiomeType.Tundra], }, resource_area: { radius: 25, baseImportance: 0.7, - preferredBiomes: ["forest", "mountains", "plains"], + preferredBiomes: [BiomeType.Forest, BiomeType.Canyon], }, ruin: { radius: 35, baseImportance: 0.8, - preferredBiomes: ["desert", "forest", "swamp"], + preferredBiomes: [BiomeType.Canyon, BiomeType.Tundra], }, camp: { radius: 20, baseImportance: 0.4, - preferredBiomes: ["forest", "plains", "mountains"], + preferredBiomes: [BiomeType.Forest, BiomeType.Tundra], }, crossing: { radius: 15, baseImportance: 0.85, - preferredBiomes: ["mountains", "swamp", "valley"], + preferredBiomes: [BiomeType.Canyon, BiomeType.Tundra], }, waystation: { radius: 12, baseImportance: 0.3, - preferredBiomes: ["plains", "valley", "forest"], + preferredBiomes: [BiomeType.Forest, BiomeType.Tundra], }, fishing_spot: { radius: 15, - baseImportance: 0.75, // High importance to ensure road connections - preferredBiomes: ["plains", "forest", "valley"], // Near lakes in temperate areas + baseImportance: 0.75, + preferredBiomes: [BiomeType.Forest, BiomeType.Tundra], }, }; @@ -310,7 +311,7 @@ export class POISystem extends System { // Get terrain info const y = this.terrainSystem!.getHeightAt(x, z); const biome = - this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "plains"; + this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? DEFAULT_BIOME; // Skip underwater const waterThreshold = 5.4; @@ -438,7 +439,7 @@ export class POISystem extends System { // Get terrain info at the water edge (on land side) const y = this.terrainSystem!.getHeightAt(x, z); const biome = - this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "plains"; + this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? DEFAULT_BIOME; // Calculate importance - fishing spots are important destinations let importance = properties.baseImportance; diff --git a/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts b/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts index 489116d7d..ca17d57e0 100644 --- a/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts +++ b/packages/shared/src/systems/shared/world/PlaceholderInstancer.ts @@ -18,7 +18,7 @@ import { createDissolveMaterial, GPU_VEG_CONFIG, type DissolveMaterial, -} from "./GPUVegetation"; +} from "./GPUMaterials"; const INITIAL_CAPACITY = 64; @@ -38,6 +38,7 @@ interface Pool { instances: InstanceSlot[]; activeCount: number; capacity: number; + highlightData: Float32Array; } // ---- Module state ---- @@ -61,6 +62,15 @@ function createBaseMaterial(resourceType: string): MeshStandardNodeMaterial { // ---- Pool lifecycle ---- +function attachHighlightAttr( + geo: THREE.BufferGeometry, + hlData: Float32Array, +): void { + const attr = new THREE.InstancedBufferAttribute(hlData, 1); + attr.setUsage(THREE.DynamicDrawUsage); + geo.setAttribute("instanceHighlight", attr); +} + function createPool(resourceType: string): Pool { const geometry = createGeometry(resourceType); const baseMat = createBaseMaterial(resourceType); @@ -70,13 +80,17 @@ function createPool(resourceType: string): Pool { enableNearFade: true, enableWaterCulling: true, enableOcclusionDissolve: true, + enableRimHighlight: true, }); + const hlData = new Float32Array(INITIAL_CAPACITY); + attachHighlightAttr(geometry, hlData); + const mesh = new THREE.InstancedMesh(geometry, material, INITIAL_CAPACITY); mesh.count = 0; mesh.frustumCulled = false; mesh.castShadow = true; - mesh.receiveShadow = true; + mesh.receiveShadow = false; mesh.layers.set(1); mesh.name = `PlaceholderPool_${resourceType}`; _scene!.add(mesh); @@ -88,6 +102,7 @@ function createPool(resourceType: string): Pool { instances: [], activeCount: 0, capacity: INITIAL_CAPACITY, + highlightData: hlData, }; } @@ -95,11 +110,14 @@ function growPool(pool: Pool): void { const newCapacity = pool.capacity * 2; const { mesh } = pool; - const newMesh = new THREE.InstancedMesh( - mesh.geometry, - mesh.material, - newCapacity, - ); + const newHlData = new Float32Array(newCapacity); + newHlData.set(pool.highlightData); + pool.highlightData = newHlData; + + const newGeo = mesh.geometry.clone(); + attachHighlightAttr(newGeo, newHlData); + + const newMesh = new THREE.InstancedMesh(newGeo, mesh.material, newCapacity); for (let i = 0; i < pool.activeCount; i++) { newMesh.setMatrixAt( i, @@ -110,7 +128,7 @@ function growPool(pool: Pool): void { newMesh.instanceMatrix.needsUpdate = true; newMesh.frustumCulled = false; newMesh.castShadow = true; - newMesh.receiveShadow = true; + newMesh.receiveShadow = false; newMesh.layers.set(1); newMesh.name = mesh.name; @@ -202,9 +220,11 @@ export function removePlaceholderInstance(entityId: string): void { index, lastSlot.visible ? lastSlot.matrix : _zeroScale, ); + pool.highlightData[index] = pool.highlightData[lastIndex]; pool.instances[index] = lastSlot; pool.slots.set(lastSlot.entityId, index); } + pool.highlightData[lastIndex] = 0; pool.activeCount--; pool.mesh.count = pool.activeCount; @@ -234,3 +254,34 @@ export function setPlaceholderVisible( pool.mesh.setMatrixAt(index, visible ? slot.matrix : _zeroScale); pool.mesh.instanceMatrix.needsUpdate = true; } + +// ---- Shader-based highlight ---- + +let _highlightedEntityId: string | null = null; + +export function setPlaceholderHighlight(entityId: string, on: boolean): void { + if (on && _highlightedEntityId && _highlightedEntityId !== entityId) { + setPlaceholderHighlight(_highlightedEntityId, false); + } + + const resourceType = _entityToType.get(entityId); + if (!resourceType) return; + + const pool = _pools.get(resourceType); + if (!pool) return; + + const index = pool.slots.get(entityId); + if (index === undefined) return; + + pool.highlightData[index] = on ? 1.0 : 0.0; + const attr = pool.mesh.geometry.getAttribute("instanceHighlight"); + if (attr) (attr as THREE.InstancedBufferAttribute).needsUpdate = true; + + _highlightedEntityId = on ? entityId : null; +} + +export function clearPlaceholderHighlight(): void { + if (_highlightedEntityId) { + setPlaceholderHighlight(_highlightedEntityId, false); + } +} diff --git a/packages/shared/src/systems/shared/world/ProcgenPlantCache.ts b/packages/shared/src/systems/shared/world/ProcgenPlantCache.ts index 21a2c4c91..05a62cf39 100644 --- a/packages/shared/src/systems/shared/world/ProcgenPlantCache.ts +++ b/packages/shared/src/systems/shared/world/ProcgenPlantCache.ts @@ -510,17 +510,9 @@ export function clearPlantCache(): void { * Default plant presets per biome type. */ export const BIOME_PLANT_PRESETS: Record = { + tundra: ["bergenia", "pulmonaria"], forest: ["monstera", "philodendron", "calathea", "ficus", "hosta"], - plains: ["hosta", "heuchera", "bergenia", "maranta"], - desert: ["zamioculcas", "aglaonema", "syngonium"], - mountains: ["bergenia", "pulmonaria", "heuchera"], - mountain: ["bergenia", "pulmonaria", "heuchera"], - swamp: ["monstera", "colocasia", "xanthosoma", "alocasia", "spathiphyllum"], - frozen: ["bergenia", "pulmonaria"], - wastes: ["zamioculcas", "aglaonema"], - corrupted: ["alocasia", "caladium", "anthurium"], - lake: ["colocasia", "calla", "arum", "spathiphyllum"], - tropical: ["monstera", "philodendron", "alocasia", "colocasia", "calathea"], + canyon: ["zamioculcas", "aglaonema", "syngonium"], }; /** diff --git a/packages/shared/src/systems/shared/world/ProcgenRockCache.ts b/packages/shared/src/systems/shared/world/ProcgenRockCache.ts index 2f863c848..77e9955bb 100644 --- a/packages/shared/src/systems/shared/world/ProcgenRockCache.ts +++ b/packages/shared/src/systems/shared/world/ProcgenRockCache.ts @@ -727,16 +727,9 @@ export async function clearRockCacheAll(): Promise { * Used when no specific biome rock config is provided. */ export const BIOME_ROCK_PRESETS: Record = { + tundra: ["granite", "basalt", "boulder"], forest: ["boulder", "granite", "limestone"], - plains: ["boulder", "pebble", "sandstone"], - desert: ["sandstone", "limestone", "pebble"], - mountains: ["granite", "basalt", "cliff"], - mountain: ["granite", "basalt", "cliff"], - swamp: ["limestone", "slate", "pebble"], - frozen: ["granite", "basalt", "boulder"], - wastes: ["basalt", "slate", "asteroid"], - corrupted: ["obsidian", "basalt", "crystal"], - lake: ["pebble", "limestone", "boulder"], + canyon: ["sandstone", "limestone", "pebble"], }; /** diff --git a/packages/shared/src/systems/shared/world/RoadNetworkSystem.ts b/packages/shared/src/systems/shared/world/RoadNetworkSystem.ts index 98b7cc04b..efcdb1c8d 100644 --- a/packages/shared/src/systems/shared/world/RoadNetworkSystem.ts +++ b/packages/shared/src/systems/shared/world/RoadNetworkSystem.ts @@ -63,14 +63,9 @@ const DEFAULTS = { } as const; const DEFAULT_BIOME_COSTS: Record = { - plains: 1.0, - valley: 1.0, - forest: 1.3, + forest: 1.0, tundra: 1.5, - desert: 2.0, - swamp: 2.5, - mountains: 3.0, - lakes: 100, + canyon: 2.0, }; // Road mask generation settings (shared across terrain/grass/flowers) diff --git a/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts new file mode 100644 index 000000000..7d1c523bd --- /dev/null +++ b/packages/shared/src/systems/shared/world/TerrainBiomeTypes.ts @@ -0,0 +1,110 @@ +/** + * Single source of truth for biome type identifiers and per-biome configs. + * + * All terrain files (TerrainSystem, TerrainHeightParams, TerrainShader, + * QuadChunkWorker, TerrainWorker, TerrainQuadChunkGenerator) MUST import + * from here instead of using string literals. + */ + +import type { BiomeTreeConfig } from "../../../types/world/world-types"; +import { TreeId } from "../../../constants/TreeTypes"; + +export enum BiomeType { + Tundra = "tundra", + Forest = "forest", + Canyon = "canyon", +} + +export const DEFAULT_BIOME = BiomeType.Forest; +export const BIOME_LIST: BiomeType[] = Object.values(BiomeType); + +/** + * Worker-injectable JS that defines BiomeType constants. + * Injected once at the top of inline worker code so the worker + * can reference BT_TUNDRA, BT_FOREST, BT_CANYON without magic strings. + */ +export function buildBiomeConstantsJS(): string { + return ` + var BT_TUNDRA = "${BiomeType.Tundra}"; + var BT_FOREST = "${BiomeType.Forest}"; + var BT_CANYON = "${BiomeType.Canyon}"; + var BT_DEFAULT = BT_FOREST; + `; +} + +// --------------------------------------------------------------------------- +// Per-biome tree configs +// --------------------------------------------------------------------------- + +const FOREST_TREE_CONFIG: BiomeTreeConfig = { + enabled: true, + trees: { + [TreeId.Knotwood]: { weight: 40, maxHeight: 25 }, + [TreeId.Oak]: { weight: 20, maxHeight: 25 }, + [TreeId.Birch]: { weight: 20, maxHeight: 25 }, + [TreeId.Maple]: { weight: 40, maxHeight: 25 }, + [TreeId.Fir]: { weight: 15, maxHeight: 25 }, + [TreeId.Pine]: { weight: 15, maxHeight: 25 }, + [TreeId.ChinaPine]: { weight: 15, minHeight: 30, maxHeight: 60 }, + [TreeId.Bamboo]: { weight: 15, minHeight: 35 }, + }, + density: 15, + minSpacing: 8, + clustering: false, + scaleVariation: [0.8, 1.2], + maxSlope: 1.5, +}; + +const CANYON_TREE_CONFIG: BiomeTreeConfig = { + enabled: true, + trees: { + [TreeId.Cactus]: { weight: 20, avoidsWaterBelow: 3 }, + [TreeId.Dead]: { weight: 20, minHeight: 20 }, + [TreeId.Palm]: { + weight: 20, + waterAffinity: 0.3, + waterProximityHeight: 9, + maxHeight: 15, + }, + [TreeId.Coconut]: { + weight: 10, + waterAffinity: 0.6, + waterProximityHeight: 9, + maxHeight: 15, + }, + }, + density: 15, + minSpacing: 18, + clustering: false, + scaleVariation: [0.7, 1.3], + maxSlope: 2.0, +}; + +const TUNDRA_TREE_CONFIG: BiomeTreeConfig = { + enabled: true, + trees: { + [TreeId.WindPine]: { weight: 40, minHeight: 15 }, + [TreeId.Fir]: { weight: 30, minHeight: 10 }, + [TreeId.Pine]: { weight: 25, minHeight: 8 }, + [TreeId.Birch]: { weight: 10 }, + }, + density: 10, + minSpacing: 12, + clustering: false, + scaleVariation: [0.6, 1.0], + maxSlope: 1.5, +}; + +const BIOME_TREE_CONFIGS: Record = { + [BiomeType.Forest]: FOREST_TREE_CONFIG, + [BiomeType.Canyon]: CANYON_TREE_CONFIG, + [BiomeType.Tundra]: TUNDRA_TREE_CONFIG, +}; + +/** + * Get the tree config for a specific biome. + * Falls back to the forest config for unknown biomes. + */ +export function getTreeConfigForBiome(biomeId: string): BiomeTreeConfig { + return BIOME_TREE_CONFIGS[biomeId as BiomeType] ?? FOREST_TREE_CONFIG; +} diff --git a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts index d406849c0..965d8998f 100644 --- a/packages/shared/src/systems/shared/world/TerrainHeightParams.ts +++ b/packages/shared/src/systems/shared/world/TerrainHeightParams.ts @@ -9,6 +9,37 @@ * them directly as TypeScript constants. */ +import { BiomeType, DEFAULT_BIOME } from "./TerrainBiomeTypes"; +import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; + +// --------------------------------------------------------------------------- +// Core terrain generation constants +// --------------------------------------------------------------------------- + +export const MAX_HEIGHT = 50; + +/** + * Derived from WATER_THRESHOLD / MAX_HEIGHT so there's one source of truth. + * Terrain generation works in 0-1 normalized space; this is the cutoff. + */ +export const WATER_LEVEL_NORMALIZED = + TERRAIN_CONSTANTS.WATER_THRESHOLD / MAX_HEIGHT; + +// --------------------------------------------------------------------------- +// Shoreline constants — shape terrain near the water edge +// --------------------------------------------------------------------------- + +export const SHORELINE_CONFIG = { + THRESHOLD: 0.25, + STRENGTH: 0.6, + MIN_SLOPE: 0.06, + SLOPE_SAMPLE_DISTANCE: 1.0, + LAND_BAND: 3.0, + LAND_MAX_MULTIPLIER: 1.6, + UNDERWATER_BAND: 3.0, + UNDERWATER_DEPTH_MULTIPLIER: 1.8, +} as const; + // --------------------------------------------------------------------------- // Noise layer definitions — drive getBaseHeightAt() // --------------------------------------------------------------------------- @@ -24,7 +55,7 @@ export interface NoiseLayerDef { } export const CONTINENT_LAYER: NoiseLayerDef = { - scale: 0.0008, + scale: 0.0004, octaves: 5, persistence: 0.7, lacunarity: 2.0, @@ -32,12 +63,12 @@ export const CONTINENT_LAYER: NoiseLayerDef = { }; export const RIDGE_LAYER: NoiseLayerDef = { - scale: 0.003, + scale: 0.0015, weight: 0.15, }; export const HILL_LAYER: NoiseLayerDef = { - scale: 0.02, + scale: 0.008, octaves: 4, persistence: 0.6, lacunarity: 2.2, @@ -45,13 +76,13 @@ export const HILL_LAYER: NoiseLayerDef = { }; export const EROSION_LAYER: NoiseLayerDef = { - scale: 0.005, + scale: 0.0025, iterations: 3, weight: 0.1, }; export const DETAIL_LAYER: NoiseLayerDef = { - scale: 0.04, + scale: 0.02, octaves: 2, persistence: 0.3, lacunarity: 2.5, @@ -61,26 +92,187 @@ export const DETAIL_LAYER: NoiseLayerDef = { /** Power curve applied after blending noise layers and normalizing to [0,1] */ export const HEIGHT_POWER_CURVE = 1.1; +// --------------------------------------------------------------------------- +// Biome noise profiles — per-biome noise weight blending (Option A) +// --------------------------------------------------------------------------- + +/** Global terrace step count — shared across all biomes to prevent boundary artifacts. */ +export const TERRACE_STEPS = 10; + +export interface BiomeNoiseProfile { + continentWeight: number; + ridgeWeight: number; + hillWeight: number; + erosionWeight: number; + detailWeight: number; + powerCurve: number; + /** 0–1 blend from smooth to terraced (0 = disabled, higher = more visible) */ + terraceStrength: number; + /** 0–1 flat zone per step (0.8 = 80% flat shelf, 20% cliff transition) */ + terraceSharpness: number; + /** + * Stretches shelf positions around the midpoint to make cliffs taller. + * 1 = normal (5m cliffs at MAX_HEIGHT=50, TERRACE_STEPS=10) + * 3 = 3x taller cliffs (~15m). Vary per biome for visual diversity. + */ + terraceHeightScale: number; + /** + * 0–1 how much natural terrain slope is preserved on each shelf. + * 0 = perfectly flat shelves, 0.3 = 30% of natural slope, 1 = no flattening. + */ + terraceSlope: number; +} + +export const TUNDRA_PROFILE: BiomeNoiseProfile = { + continentWeight: 0.32, + ridgeWeight: 0.15, + hillWeight: 0.28, + erosionWeight: 0.1, + detailWeight: 0.1, + powerCurve: 1.1, + terraceStrength: 0.4, + terraceSharpness: 0.7, + terraceHeightScale: 2.5, + terraceSlope: 0.25, +}; + +export const FOREST_PROFILE: BiomeNoiseProfile = { + continentWeight: 0.15, + ridgeWeight: 0.08, + hillWeight: 0.1, + erosionWeight: 0.05, + detailWeight: 0.05, + powerCurve: 1, + terraceStrength: 0, + terraceSharpness: 0, + terraceHeightScale: 1, + terraceSlope: 0, +}; + +export const CANYON_PROFILE: BiomeNoiseProfile = { + continentWeight: 0.32, + ridgeWeight: 0.25, + hillWeight: 0.18, + erosionWeight: 0.2, + detailWeight: 0.05, + powerCurve: 1.45, + terraceStrength: 0.6, + terraceSharpness: 0.8, + terraceHeightScale: 7, + terraceSlope: 0.35, +}; + +export const BIOME_PROFILES: Record = { + [BiomeType.Tundra]: TUNDRA_PROFILE, + [BiomeType.Forest]: FOREST_PROFILE, + [BiomeType.Canyon]: CANYON_PROFILE, +}; + // --------------------------------------------------------------------------- // Island configuration // --------------------------------------------------------------------------- -export const ISLAND_RADIUS = 350; -export const ISLAND_FALLOFF = 100; -export const ISLAND_DEEP_OCEAN_BUFFER = 50; +export const ISLAND_RADIUS = 788; +export const ISLAND_FALLOFF = 450; +export const ISLAND_DEEP_OCEAN_BUFFER = 113; export const BASE_ELEVATION = 0.42; export const OCEAN_FLOOR_HEIGHT = 0.05; /** height = terrain * HEIGHT_TERRAIN_MIX + BASE_ELEVATION * islandMask */ export const HEIGHT_TERRAIN_MIX = 0.2; +export const BEACH_PROFILE_POWER = 3.0; // --------------------------------------------------------------------------- -// Pond configuration +// Biome configuration — single source of truth for biome placement & blending // --------------------------------------------------------------------------- -export const POND_RADIUS = 50; -export const POND_DEPTH = 0.55; -export const POND_CENTER_X = -80; -export const POND_CENTER_Z = 60; +export const BIOME_CONFIG = { + gaussianCoeff: 12.0, + boundaryNoiseScale: 0.003, + boundaryNoiseAmount: 0.15, + placementRadius: ISLAND_RADIUS * 0.45, + influenceRadius: ISLAND_RADIUS * 0.6, +} as const; + +// --------------------------------------------------------------------------- +// Landscape features — mountains & ponds, independent of biomes +// --------------------------------------------------------------------------- + +export enum LandscapeType { + Mountain = "mountain", + Pond = "pond", +} + +export interface LandscapeFeatureDef { + type: LandscapeType; + x: number; + z: number; + radius: number; + strength: number; + layers: number; + shapePower: number; + edgeSharpness: number; + layerSlope: number; + noiseScale: number; + noiseAmount: number; +} + +/** + * Predefined landscape features — add/remove entries here to control + * exactly where mountains, ponds, and plateaus appear on the island. + * + * Algorithm: radial envelope × domain-warped noise → terrace quantization. + * The envelope defines the feature's footprint; noise drives internal terrain. + * Terracing follows noise contours, producing organic (non-circular) layers. + * + * Parameter guide: + * layers - number of terrace levels (1 = single plateau, 6+ = tiered mountain) + * shapePower - envelope falloff (0.3 = dome, 1 = cone, 4+ = flat-topped mesa) + * edgeSharpness - terrace cliff sharpness (0 = smooth ramp, 1 = hard cliff) + * layerSlope - incline within each shelf (0 = flat, 0.5 = gentle slope, 1 = full slope) + * noiseScale - frequency of internal terrain (0.02 = broad ridges, 0.05 = fine detail) + * noiseAmount - noise vs envelope blend (0 = smooth dome, 0.6 = organic ridges) + */ +export const LANDSCAPE_FEATURES: LandscapeFeatureDef[] = [ + { + type: LandscapeType.Mountain, + x: -168.5, + z: -352.5, + radius: 250, + strength: 5.5, + layers: 5, + shapePower: 2.0, + edgeSharpness: 0.2, + layerSlope: 0.9, + noiseScale: 0.025, + noiseAmount: 0.6, + }, + { + type: LandscapeType.Mountain, + x: 265.5, + z: 322.5, + radius: 130, + strength: 2.5, + layers: 5, + shapePower: 1.3, + edgeSharpness: 0.3, + layerSlope: 0.55, + noiseScale: 0.025, + noiseAmount: 0.55, + }, + { + type: LandscapeType.Pond, + x: -28.5, + z: 327.5, + radius: 90, + strength: 1.5, + layers: 1, + shapePower: 3.5, + edgeSharpness: 0.1, + layerSlope: 0.8, + noiseScale: 0.015, + noiseAmount: 0.06, + }, +]; // --------------------------------------------------------------------------- // Coastline noise — varies the island radius for irregular shoreline @@ -109,39 +301,444 @@ export const COAST_SMALL = { }; // --------------------------------------------------------------------------- -// Mountain boost +// Legacy exports — kept for backward compatibility with TerrainWorker.ts // --------------------------------------------------------------------------- -export const MOUNTAIN_BOOST_MAX_NORM_DIST = 2.5; -export const MOUNTAIN_BOOST_GAUSSIAN_COEFF = 0.3; +/** @deprecated Landscape features replace hardcoded pond */ +export const POND_RADIUS = 50; +/** @deprecated Landscape features replace hardcoded pond */ +export const POND_DEPTH = 0.55; +/** @deprecated Landscape features replace hardcoded pond */ +export const POND_CENTER_X = -80; +/** @deprecated Landscape features replace hardcoded pond */ +export const POND_CENTER_Z = 60; -// --------------------------------------------------------------------------- -// Worker code generation helper -// --------------------------------------------------------------------------- +// ═══════════════════════════════════════════════════════════════════════════ +// SINGLE SOURCE OF TRUTH — pure TypeScript functions +// +// computeBaseHeight() → called directly by TerrainSystem (main thread) +// applyLandscapeFeaturesPure() → called by computeBaseHeight +// +// The buildXxxJS() functions below are worker-embeddable MIRRORS of the +// same logic. They MUST stay in sync — edit both together. +// ═══════════════════════════════════════════════════════════════════════════ + +export interface TerrainNoiseAdapter { + fractal2D( + x: number, + z: number, + octaves: number, + persistence: number, + lacunarity: number, + ): number; + ridgeNoise2D(x: number, z: number): number; + erosionNoise2D(x: number, z: number, iterations: number): number; + simplex2D(x: number, z: number): number; +} + +export function applyLandscapeFeaturesPure( + height: number, + worldX: number, + worldZ: number, + features: ReadonlyArray, + noise: TerrainNoiseAdapter, +): number { + for (let i = 0; i < features.length; i++) { + const feat = features[i]; + const dx = worldX - feat.x; + const dz = worldZ - feat.z; + const dist = Math.sqrt(dx * dx + dz * dz); + if (dist >= feat.radius) continue; + + const t = Math.max(0, 1 - dist / feat.radius); + const envelope = Math.pow(t, feat.shapePower); + + const warpScale = feat.noiseScale * 0.4; + const warpStr = feat.radius * feat.noiseAmount * 0.3; + const warpX = + noise.simplex2D(worldX * warpScale, worldZ * warpScale) * warpStr; + const warpZ = + noise.simplex2D(worldX * warpScale + 31.7, worldZ * warpScale + 47.3) * + warpStr; + + const sx = (worldX + warpX) * feat.noiseScale; + const sz = (worldZ + warpZ) * feat.noiseScale; + + const ridgeN = noise.ridgeNoise2D(sx, sz); + const detailN = noise.fractal2D(sx * 2.3, sz * 2.3, 3, 0.5, 2.0); + const mNoise = (ridgeN * 0.6 + detailN * 0.4 + 1) * 0.5; + + let rawH = envelope * (1 - feat.noiseAmount + feat.noiseAmount * mNoise); + rawH = Math.max(0, Math.min(1, rawH)); + + let influence: number; + if (feat.layers >= 1) { + const stepped = Math.floor(rawH * feat.layers) / feat.layers; + const nextStep = Math.min(1, stepped + 1 / feat.layers); + const frac = (rawH - stepped) * feat.layers; + const blendStart = 1 - feat.edgeSharpness; + const edgeBlend = + frac <= blendStart ? 0 : (frac - blendStart) / (1 - blendStart); + const flatStep = stepped + edgeBlend * (nextStep - stepped); + const slopedStep = stepped + frac * (nextStep - stepped); + influence = flatStep + feat.layerSlope * (slopedStep - flatStep); + } else { + influence = rawH; + } + + if (feat.type === LandscapeType.Pond) { + height -= influence * feat.strength; + } else { + height += influence * feat.strength; + } + } + return height; +} + +/** + * THE height generation algorithm. Main thread calls this directly. + * Workers use the JS-string mirror (buildGetBaseHeightAtJS) which + * must produce identical results — keep them in sync. + */ +export function computeBaseHeight( + worldX: number, + worldZ: number, + noise: TerrainNoiseAdapter, + biomeWeights: Record, + features: ReadonlyArray, + maxHeight: number, +): number { + // ── 1. Sample noise layers ────────────────────────────────────────── + const cN = noise.fractal2D( + worldX * CONTINENT_LAYER.scale, + worldZ * CONTINENT_LAYER.scale, + CONTINENT_LAYER.octaves!, + CONTINENT_LAYER.persistence!, + CONTINENT_LAYER.lacunarity!, + ); + const rN = noise.ridgeNoise2D( + worldX * RIDGE_LAYER.scale, + worldZ * RIDGE_LAYER.scale, + ); + const hN = noise.fractal2D( + worldX * HILL_LAYER.scale, + worldZ * HILL_LAYER.scale, + HILL_LAYER.octaves!, + HILL_LAYER.persistence!, + HILL_LAYER.lacunarity!, + ); + const eN = noise.erosionNoise2D( + worldX * EROSION_LAYER.scale, + worldZ * EROSION_LAYER.scale, + EROSION_LAYER.iterations!, + ); + const dN = noise.fractal2D( + worldX * DETAIL_LAYER.scale, + worldZ * DETAIL_LAYER.scale, + DETAIL_LAYER.octaves!, + DETAIL_LAYER.persistence!, + DETAIL_LAYER.lacunarity!, + ); + + // ── 2. Blend noise using biome-weighted profiles ──────────────────── + let cW = 0, + rW = 0, + hW = 0, + eW = 0, + dW = 0, + pC = 0, + tS = 0, + tSh = 0, + tHS = 0, + tSl = 0; + for (const key of Object.keys(biomeWeights)) { + const w = biomeWeights[key]; + const p = BIOME_PROFILES[key] ?? BIOME_PROFILES[DEFAULT_BIOME]; + cW += p.continentWeight * w; + rW += p.ridgeWeight * w; + hW += p.hillWeight * w; + eW += p.erosionWeight * w; + dW += p.detailWeight * w; + pC += p.powerCurve * w; + tS += p.terraceStrength * w; + tSh += p.terraceSharpness * w; + tHS += p.terraceHeightScale * w; + tSl += p.terraceSlope * w; + } + + // ── 3. Combine, normalize, power curve ────────────────────────────── + let height = cN * cW + rN * rW + hN * hW + eN * eW + dN * dW; + height = (height + 1) * 0.5; + height = Math.max(0, Math.min(1, height)); + height = Math.pow(height, pC); + + // ── 4. Terracing — floor-quantize into flat shelves with cliff edges ─ + // tHS stretches shelf positions around 0.5 so cliffs are taller per biome. + // tSl blends natural slope back onto shelves (0 = flat, 1 = full natural slope). + const steps = TERRACE_STEPS; + const ths = Math.max(1, tHS); + if (tS > 0.01 && steps >= 2) { + const stepped = Math.floor(height * steps) / steps; + const nextStep = Math.min(1, stepped + 1 / steps); + const frac = (height - stepped) * steps; + const edgeBlend = frac < tSh ? 0 : (frac - tSh) / (1 - tSh + 0.001); + const flatStep = stepped + edgeBlend * (nextStep - stepped); + const slopedStep = stepped + frac * (nextStep - stepped); + const terraced = flatStep + tSl * (slopedStep - flatStep); + const scaled = Math.max(0, Math.min(1, 0.5 + (terraced - 0.5) * ths)); + height = height + (scaled - height) * tS; + } + + // ── 5. Coastline noise → island mask ──────────────────────────────── + const distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ); + const angle = Math.atan2(worldZ, worldX); + const cnx = Math.cos(angle) * COASTLINE_CIRCLE_SAMPLE_RADIUS; + const cnz = Math.sin(angle) * COASTLINE_CIRCLE_SAMPLE_RADIUS; + + const cst1 = noise.fractal2D( + cnx, + cnz, + COAST_LARGE.octaves, + COAST_LARGE.persistence, + COAST_LARGE.lacunarity, + ); + const cst2 = noise.fractal2D( + cnx * COAST_MEDIUM.freqMultiplier, + cnz * COAST_MEDIUM.freqMultiplier, + COAST_MEDIUM.octaves, + COAST_MEDIUM.persistence, + COAST_MEDIUM.lacunarity, + ); + const cst3 = noise.simplex2D( + cnx * COAST_SMALL.freqMultiplier, + cnz * COAST_SMALL.freqMultiplier, + ); + const coastVar = + cst1 * COAST_LARGE.weight + + cst2 * COAST_MEDIUM.weight + + cst3 * COAST_SMALL.weight; + const effectiveRadius = ISLAND_RADIUS * (1 + coastVar); + + let islandMask = 1.0; + if (distFromCenter > effectiveRadius - ISLAND_FALLOFF) { + const edgeDist = distFromCenter - (effectiveRadius - ISLAND_FALLOFF); + const t = Math.min(1.0, edgeDist / ISLAND_FALLOFF); + islandMask = 1.0 - Math.pow(t, BEACH_PROFILE_POWER); + } + if (distFromCenter > effectiveRadius + ISLAND_DEEP_OCEAN_BUFFER) { + islandMask = 0; + } + + // ── 6. Island mask + landscape features ───────────────────────────── + height = height * islandMask; + height = applyLandscapeFeaturesPure(height, worldX, worldZ, features, noise); + + if (islandMask === 0) { + height = OCEAN_FLOOR_HEIGHT; + } + + return height * maxHeight; +} + +export interface ShorelineConfig { + waterThreshold: number; + shorelineLandBand: number; + shorelineUnderwaterBand: number; + shorelineMinSlope: number; + shorelineLandMaxMultiplier: number; + underwaterDepthMultiplier: number; +} /** - * Generate the JS source for `getBaseHeightAt()` to be embedded in the - * inline worker string. All numeric constants are baked in from the exports - * above, ensuring the worker always matches the main thread. + * Adjust height near shoreline to prevent flat beach zones. + * Both main thread and workers use the same algorithm. + */ +export function adjustShorelineHeight( + baseHeight: number, + slope: number, + config: ShorelineConfig, +): number { + if (baseHeight === config.waterThreshold) return baseHeight; + + const isLand = baseHeight > config.waterThreshold; + const band = isLand + ? config.shorelineLandBand + : config.shorelineUnderwaterBand; + if (band <= 0) return baseHeight; + + const delta = Math.abs(baseHeight - config.waterThreshold); + if (delta >= band) return baseHeight; + + if (config.shorelineMinSlope <= 0) return baseHeight; + + const maxMul = isLand + ? config.shorelineLandMaxMultiplier + : config.underwaterDepthMultiplier; + if (maxMul <= 1) return baseHeight; + + const slopeSafe = Math.max(0.0001, slope); + const targetMul = Math.min( + maxMul, + Math.max(1, config.shorelineMinSlope / slopeSafe), + ); + const falloff = 1 - delta / band; + const mul = 1 + (targetMul - 1) * falloff; + const adjustedDelta = delta * mul; + + return isLand + ? config.waterThreshold + adjustedDelta + : config.waterThreshold - adjustedDelta; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Worker JS string mirrors — MUST match the pure functions above. +// Workers can't import TS modules, so we generate equivalent JS strings +// with constants baked in. Keep these in lock-step with the TS functions. +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * JS source for position-only biome weight computation. + * Worker mirror — depends on: noise, biomeCenters, + * BIOME_BOUNDARY_NOISE_SCALE, BIOME_BOUNDARY_NOISE_AMOUNT, BIOME_GAUSSIAN_COEFF. + */ +export function buildComputeBiomeWeightsJS(): string { + return ` + function computeBiomeWeightsByPosition(worldX, worldZ) { + var boundaryNoise = noise.simplex2D( + worldX * BIOME_BOUNDARY_NOISE_SCALE, + worldZ * BIOME_BOUNDARY_NOISE_SCALE + ); + var weights = {}; + var totalWeight = 0; + for (var i = 0; i < biomeCenters.length; i++) { + var center = biomeCenters[i]; + var dx = worldX - center.x; + var dz = worldZ - center.z; + var dist = Math.sqrt(dx * dx + dz * dz); + var noisyDist = dist * (1 + boundaryNoise * BIOME_BOUNDARY_NOISE_AMOUNT); + var normDist = noisyDist / center.influence; + var w = Math.exp(-normDist * normDist * BIOME_GAUSSIAN_COEFF); + var type = center.type; + weights[type] = (weights[type] || 0) + w; + totalWeight += w; + } + if (totalWeight > 0) { + for (var key in weights) { weights[key] /= totalWeight; } + } else { + weights[BT_DEFAULT] = 1.0; + } + return weights; + }`; +} + +/** + * JS source — worker mirror of applyLandscapeFeaturesPure(). + * Depends on: landscapeFeatures array injected into worker scope, noise object. + */ +export function buildApplyLandscapeFeaturesJS(): string { + return ` + function applyLandscapeFeatures(height, worldX, worldZ) { + for (var i = 0; i < landscapeFeatures.length; i++) { + var feat = landscapeFeatures[i]; + var dx = worldX - feat.x; + var dz = worldZ - feat.z; + var dist = Math.sqrt(dx * dx + dz * dz); + if (dist >= feat.radius) continue; + + var t = Math.max(0, 1 - dist / feat.radius); + var envelope = Math.pow(t, feat.shapePower); + + var warpScale = feat.noiseScale * 0.4; + var warpStr = feat.radius * feat.noiseAmount * 0.3; + var warpX = noise.simplex2D(worldX * warpScale, worldZ * warpScale) * warpStr; + var warpZ = noise.simplex2D(worldX * warpScale + 31.7, worldZ * warpScale + 47.3) * warpStr; + + var sx = (worldX + warpX) * feat.noiseScale; + var sz = (worldZ + warpZ) * feat.noiseScale; + + var ridgeN = noise.ridgeNoise2D(sx, sz); + var detailN = noise.fractal2D(sx * 2.3, sz * 2.3, 3, 0.5, 2.0); + var mNoise = (ridgeN * 0.6 + detailN * 0.4 + 1) * 0.5; + + var rawH = envelope * (1 - feat.noiseAmount + feat.noiseAmount * mNoise); + rawH = Math.max(0, Math.min(1, rawH)); + + var influence; + if (feat.layers >= 1) { + var stepped = Math.floor(rawH * feat.layers) / feat.layers; + var nextStep = Math.min(1, stepped + 1 / feat.layers); + var frac = (rawH - stepped) * feat.layers; + var blendStart = 1 - feat.edgeSharpness; + var edgeBlend = frac <= blendStart ? 0 : (frac - blendStart) / (1 - blendStart); + var flatStep = stepped + edgeBlend * (nextStep - stepped); + var slopedStep = stepped + frac * (nextStep - stepped); + influence = flatStep + feat.layerSlope * (slopedStep - flatStep); + } else { + influence = rawH; + } + + if (feat.type === '${LandscapeType.Pond}') { + height -= influence * feat.strength; + } else { + height += influence * feat.strength; + } + } + return height; + }`; +} + +// Biome profile constants baked into JS for workers +const PROFILES_JS = ` + var BIOME_PROFILES = {}; + BIOME_PROFILES[BT_TUNDRA] = { cW: ${TUNDRA_PROFILE.continentWeight}, rW: ${TUNDRA_PROFILE.ridgeWeight}, hW: ${TUNDRA_PROFILE.hillWeight}, eW: ${TUNDRA_PROFILE.erosionWeight}, dW: ${TUNDRA_PROFILE.detailWeight}, pC: ${TUNDRA_PROFILE.powerCurve}, tS: ${TUNDRA_PROFILE.terraceStrength}, tSh: ${TUNDRA_PROFILE.terraceSharpness}, tHS: ${TUNDRA_PROFILE.terraceHeightScale}, tSl: ${TUNDRA_PROFILE.terraceSlope} }; + BIOME_PROFILES[BT_FOREST] = { cW: ${FOREST_PROFILE.continentWeight}, rW: ${FOREST_PROFILE.ridgeWeight}, hW: ${FOREST_PROFILE.hillWeight}, eW: ${FOREST_PROFILE.erosionWeight}, dW: ${FOREST_PROFILE.detailWeight}, pC: ${FOREST_PROFILE.powerCurve}, tS: ${FOREST_PROFILE.terraceStrength}, tSh: ${FOREST_PROFILE.terraceSharpness}, tHS: ${FOREST_PROFILE.terraceHeightScale}, tSl: ${FOREST_PROFILE.terraceSlope} }; + BIOME_PROFILES[BT_CANYON] = { cW: ${CANYON_PROFILE.continentWeight}, rW: ${CANYON_PROFILE.ridgeWeight}, hW: ${CANYON_PROFILE.hillWeight}, eW: ${CANYON_PROFILE.erosionWeight}, dW: ${CANYON_PROFILE.detailWeight}, pC: ${CANYON_PROFILE.powerCurve}, tS: ${CANYON_PROFILE.terraceStrength}, tSh: ${CANYON_PROFILE.terraceSharpness}, tHS: ${CANYON_PROFILE.terraceHeightScale}, tSl: ${CANYON_PROFILE.terraceSlope} }; +`; + +/** + * JS source — worker mirror of computeBaseHeight(). + * Accepts biome weights and blends noise per-biome. + * MUST stay in sync with computeBaseHeight() above. */ export function buildGetBaseHeightAtJS(): string { return ` - function getBaseHeightAt(worldX, worldZ) { + ${PROFILES_JS} + + function getBaseHeightAt(worldX, worldZ, biomeWeights) { + var bw = biomeWeights || computeBiomeWeightsByPosition(worldX, worldZ); + var cN = noise.fractal2D(worldX * ${CONTINENT_LAYER.scale}, worldZ * ${CONTINENT_LAYER.scale}, ${CONTINENT_LAYER.octaves}, ${CONTINENT_LAYER.persistence}, ${CONTINENT_LAYER.lacunarity}); var rN = noise.ridgeNoise2D(worldX * ${RIDGE_LAYER.scale}, worldZ * ${RIDGE_LAYER.scale}); var hN = noise.fractal2D(worldX * ${HILL_LAYER.scale}, worldZ * ${HILL_LAYER.scale}, ${HILL_LAYER.octaves}, ${HILL_LAYER.persistence}, ${HILL_LAYER.lacunarity}); var eN = noise.erosionNoise2D(worldX * ${EROSION_LAYER.scale}, worldZ * ${EROSION_LAYER.scale}, ${EROSION_LAYER.iterations}); var dN = noise.fractal2D(worldX * ${DETAIL_LAYER.scale}, worldZ * ${DETAIL_LAYER.scale}, ${DETAIL_LAYER.octaves}, ${DETAIL_LAYER.persistence}, ${DETAIL_LAYER.lacunarity}); - var height = 0; - height += cN * ${CONTINENT_LAYER.weight}; - height += rN * ${RIDGE_LAYER.weight}; - height += hN * ${HILL_LAYER.weight}; - height += eN * ${EROSION_LAYER.weight}; - height += dN * ${DETAIL_LAYER.weight}; + var cW = 0, rW = 0, hW = 0, eW = 0, dW = 0, pC = 0, tS = 0, tSh = 0, tHS = 0, tSl = 0; + for (var key in bw) { + var w = bw[key]; + var p = BIOME_PROFILES[key] || BIOME_PROFILES[BT_DEFAULT]; + cW += p.cW * w; rW += p.rW * w; hW += p.hW * w; eW += p.eW * w; dW += p.dW * w; + pC += p.pC * w; tS += p.tS * w; tSh += p.tSh * w; tHS += p.tHS * w; tSl += p.tSl * w; + } + + var height = cN * cW + rN * rW + hN * hW + eN * eW + dN * dW; height = (height + 1) * 0.5; height = Math.max(0, Math.min(1, height)); - height = Math.pow(height, ${HEIGHT_POWER_CURVE}); + height = Math.pow(height, pC); + + var gSteps = ${TERRACE_STEPS}; + var ths = Math.max(1, tHS); + if (tS > 0.01 && gSteps >= 2) { + var gStepped = Math.floor(height * gSteps) / gSteps; + var gNextStep = Math.min(1, gStepped + 1 / gSteps); + var gFrac = (height - gStepped) * gSteps; + var gEdgeBlend = gFrac < tSh ? 0 : (gFrac - tSh) / (1 - tSh + 0.001); + var gFlatStep = gStepped + gEdgeBlend * (gNextStep - gStepped); + var gSlopedStep = gStepped + gFrac * (gNextStep - gStepped); + var gTerraced = gFlatStep + tSl * (gSlopedStep - gFlatStep); + var gScaled = Math.max(0, Math.min(1, 0.5 + (gTerraced - 0.5) * ths)); + height = height + (gScaled - height) * tS; + } var distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ); var angle = Math.atan2(worldZ, worldX); @@ -157,26 +754,15 @@ export function buildGetBaseHeightAtJS(): string { if (distFromCenter > effectiveRadius - ${ISLAND_FALLOFF}) { var edgeDist = distFromCenter - (effectiveRadius - ${ISLAND_FALLOFF}); var t = Math.min(1.0, edgeDist / ${ISLAND_FALLOFF}); - var smoothstep = t * t * (3 - 2 * t); - islandMask = 1.0 - smoothstep; + islandMask = 1.0 - Math.pow(t, ${BEACH_PROFILE_POWER}); } if (distFromCenter > effectiveRadius + ${ISLAND_DEEP_OCEAN_BUFFER}) { islandMask = 0; } - var distFromPond = Math.sqrt( - (worldX - (${POND_CENTER_X})) * (worldX - (${POND_CENTER_X})) + - (worldZ - ${POND_CENTER_Z}) * (worldZ - ${POND_CENTER_Z}) - ); - var pondDepression = 0; - if (distFromPond < ${POND_RADIUS} * 2) { - var pondFactor = 1.0 - distFromPond / (${POND_RADIUS} * 2); - pondDepression = pondFactor * pondFactor * ${POND_DEPTH}; - } - height = height * islandMask; - height = height * ${HEIGHT_TERRAIN_MIX} + ${BASE_ELEVATION} * islandMask; - height -= pondDepression; + height = applyLandscapeFeatures(height, worldX, worldZ); + if (islandMask === 0) { height = ${OCEAN_FLOOR_HEIGHT}; } return height * MAX_HEIGHT; }`; diff --git a/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts new file mode 100644 index 000000000..3942a7978 --- /dev/null +++ b/packages/shared/src/systems/shared/world/TerrainQuadChunkGenerator.ts @@ -0,0 +1,499 @@ +/** + * TerrainQuadChunkGenerator — Assembles terrain geometry for quad-tree chunks + * from pre-computed worker output. + * + * The heavy lifting (noise, heights, normals, biome blending) is done by + * QuadChunkWorker on a background thread. This module handles: + * - Road influence sampling (needs live road network) + * - Flat-zone height overrides via getFlatZoneHeight (needs live building data) + * - Skirt geometry generation + * - Index buffer generation + * - THREE.BufferGeometry assembly + * + * A synchronous data generator (generateQuadChunkDataSync) is also provided + * as a fallback when workers are unavailable. It produces the same + * QuadChunkWorkerOutput format and feeds it through assembleQuadChunkGeometry, + * keeping all assembly logic in one place. + * + * CLIENT-ONLY: Server terrain uses the flat tile grid. + */ + +import THREE from "../../../extras/three/three"; +import type { QuadChunkWorkerOutput } from "../../../utils/workers/QuadChunkWorker"; +import { BiomeType, DEFAULT_BIOME } from "./TerrainBiomeTypes"; + +/** + * Main-thread callbacks for game-state queries that can't run in a worker. + * Used by assembleQuadChunkGeometry for road influence and flat-zone overrides. + */ +export interface ChunkTerrainProvider { + calculateRoadInfluenceAtVertex( + worldX: number, + worldZ: number, + tileX: number, + tileZ: number, + ): number; + /** + * Returns the final terrain height at this position if a flat zone applies, + * including smooth blend transitions. Returns null if no flat zone affects + * this point. This delegates to TerrainSystem.getFlatZoneHeight() which + * handles core/blend classification and smoothstep interpolation. + */ + getFlatZoneHeight(worldX: number, worldZ: number): number | null; + getHeightAtComputed(worldX: number, worldZ: number): number; + readonly TILE_SIZE: number; +} + +/** + * Extended provider that includes sync fallback methods for the data generator. + * Used by generateQuadChunkDataSync (main-thread fallback) and by the + * TerrainVisualManager for worker dispatch. + */ +export interface FullTerrainProvider extends ChunkTerrainProvider { + computeBiomeWeightsAtPosition( + worldX: number, + worldZ: number, + ): { biomeWeightMap: Map; totalWeight: number }; + computeBiomeWeightsByPosition( + worldX: number, + worldZ: number, + ): Record; + getBiomeId(biomeName: string): number; + getBiomeColor(biomeName: string): { r: number; g: number; b: number }; + readonly WATER_LEVEL_NORMALIZED: number; + readonly SHORELINE_THRESHOLD: number; + readonly SHORELINE_STRENGTH: number; + readonly MAX_HEIGHT: number; +} + +export interface ChunkGeometryResult { + geometry: THREE.BufferGeometry; + heightData: Float32Array; +} + +/** + * Assemble a THREE.BufferGeometry from worker-computed height/normal/color/biome + * data, adding flat-zone overrides, road influence, and skirts on the main thread. + */ +export function assembleQuadChunkGeometry( + workerData: QuadChunkWorkerOutput, + provider: ChunkTerrainProvider, + skirtDrop: number, +): ChunkGeometryResult { + const { + centerX, + centerZ, + size, + resolution, + heightData, + normalData, + colorData, + biomeData, + biomeForestWeight, + biomeCanyonWeight, + } = workerData; + const segments = resolution; + const halfSize = size * 0.5; + const gridStep = size / (segments - 1); + + const skirtCount = segments * 4; + const totalVertices = segments * segments + skirtCount; + const positions = new Float32Array(totalVertices * 3); + const normals = new Float32Array(totalVertices * 3); + const colors = new Float32Array(totalVertices * 3); + const biomeIds = new Float32Array(totalVertices); + const forestWeights = new Float32Array(totalVertices); + const canyonWeights = new Float32Array(totalVertices); + const roadInfluences = new Float32Array(totalVertices); + + let flatZoneModified = false; + + for (let iz = 0; iz < segments; iz++) { + const localZ = -halfSize + iz * gridStep; + const worldZ = centerZ + localZ; + + for (let ix = 0; ix < segments; ix++) { + const localX = -halfSize + ix * gridStep; + const worldX = centerX + localX; + const idx = iz * segments + ix; + const i3 = idx * 3; + + let height = heightData[idx]; + + const flatHeight = provider.getFlatZoneHeight(worldX, worldZ); + if (flatHeight !== null) { + height = flatHeight; + flatZoneModified = true; + } + + positions[i3] = localX; + positions[i3 + 1] = height; + positions[i3 + 2] = localZ; + + normals[i3] = normalData[i3]; + normals[i3 + 1] = normalData[i3 + 1]; + normals[i3 + 2] = normalData[i3 + 2]; + + colors[i3] = colorData[i3]; + colors[i3 + 1] = colorData[i3 + 1]; + colors[i3 + 2] = colorData[i3 + 2]; + + biomeIds[idx] = biomeData[idx]; + forestWeights[idx] = biomeForestWeight[idx]; + canyonWeights[idx] = biomeCanyonWeight[idx]; + + const roadTileX = Math.floor(worldX / provider.TILE_SIZE); + const roadTileZ = Math.floor(worldZ / provider.TILE_SIZE); + roadInfluences[idx] = provider.calculateRoadInfluenceAtVertex( + worldX, + worldZ, + roadTileX, + roadTileZ, + ); + } + } + + if (flatZoneModified) { + recomputeNormals(positions, normals, segments, gridStep); + } + + // ========================================================================= + // Skirt geometry + // ========================================================================= + let skirtIdx = segments * segments; + + const copyEdgeVertex = (mainIdx: number) => { + const si3 = skirtIdx * 3; + const mi3 = mainIdx * 3; + positions[si3] = positions[mi3]; + positions[si3 + 1] = positions[mi3 + 1] - skirtDrop; + positions[si3 + 2] = positions[mi3 + 2]; + normals[si3] = normals[mi3]; + normals[si3 + 1] = normals[mi3 + 1]; + normals[si3 + 2] = normals[mi3 + 2]; + colors[si3] = colors[mi3]; + colors[si3 + 1] = colors[mi3 + 1]; + colors[si3 + 2] = colors[mi3 + 2]; + biomeIds[skirtIdx] = biomeIds[mainIdx]; + forestWeights[skirtIdx] = forestWeights[mainIdx]; + canyonWeights[skirtIdx] = canyonWeights[mainIdx]; + roadInfluences[skirtIdx] = roadInfluences[mainIdx]; + skirtIdx++; + }; + + for (let ix = 0; ix < segments; ix++) copyEdgeVertex(ix); + for (let ix = 0; ix < segments; ix++) + copyEdgeVertex((segments - 1) * segments + ix); + for (let iz = 0; iz < segments; iz++) copyEdgeVertex(iz * segments); + for (let iz = 0; iz < segments; iz++) + copyEdgeVertex(iz * segments + (segments - 1)); + + // ========================================================================= + // Indices + // ========================================================================= + const subs = segments - 1; + const mainFaceCount = subs * subs; + const skirtFaceCount = subs * 4; + const totalIndices = (mainFaceCount + skirtFaceCount) * 6; + const indices = new Uint32Array(totalIndices); + let ii = 0; + + for (let iz = 0; iz < subs; iz++) { + for (let ix = 0; ix < subs; ix++) { + const a = iz * segments + ix; + const b = a + 1; + const c = a + segments; + const d = c + 1; + indices[ii++] = a; + indices[ii++] = c; + indices[ii++] = b; + indices[ii++] = b; + indices[ii++] = c; + indices[ii++] = d; + } + } + + const skirtBase = segments * segments; + const northSkirtBase = skirtBase; + const southSkirtBase = skirtBase + segments; + const westSkirtBase = skirtBase + segments * 2; + const eastSkirtBase = skirtBase + segments * 3; + + for (let ix = 0; ix < subs; ix++) { + const mainA = ix; + const mainB = ix + 1; + const skirtA = northSkirtBase + ix; + const skirtB = northSkirtBase + ix + 1; + indices[ii++] = skirtA; + indices[ii++] = mainA; + indices[ii++] = skirtB; + indices[ii++] = skirtB; + indices[ii++] = mainA; + indices[ii++] = mainB; + } + + for (let ix = 0; ix < subs; ix++) { + const mainA = (segments - 1) * segments + ix; + const mainB = mainA + 1; + const skirtA = southSkirtBase + ix; + const skirtB = southSkirtBase + ix + 1; + indices[ii++] = mainA; + indices[ii++] = skirtA; + indices[ii++] = mainB; + indices[ii++] = mainB; + indices[ii++] = skirtA; + indices[ii++] = skirtB; + } + + for (let iz = 0; iz < subs; iz++) { + const mainA = iz * segments; + const mainB = (iz + 1) * segments; + const skirtA = westSkirtBase + iz; + const skirtB = westSkirtBase + iz + 1; + indices[ii++] = mainA; + indices[ii++] = skirtA; + indices[ii++] = mainB; + indices[ii++] = mainB; + indices[ii++] = skirtA; + indices[ii++] = skirtB; + } + + for (let iz = 0; iz < subs; iz++) { + const mainA = iz * segments + (segments - 1); + const mainB = (iz + 1) * segments + (segments - 1); + const skirtA = eastSkirtBase + iz; + const skirtB = eastSkirtBase + iz + 1; + indices[ii++] = skirtA; + indices[ii++] = mainA; + indices[ii++] = skirtB; + indices[ii++] = skirtB; + indices[ii++] = mainA; + indices[ii++] = mainB; + } + + // ========================================================================= + // Build BufferGeometry + // ========================================================================= + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3)); + geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); + geometry.setAttribute("biomeId", new THREE.BufferAttribute(biomeIds, 1)); + geometry.setAttribute( + "biomeForestWeight", + new THREE.BufferAttribute(forestWeights, 1), + ); + geometry.setAttribute( + "biomeCanyonWeight", + new THREE.BufferAttribute(canyonWeights, 1), + ); + geometry.setAttribute( + "roadInfluence", + new THREE.BufferAttribute(roadInfluences, 1), + ); + geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + return { geometry, heightData }; +} + +/** + * Recompute normals for the main grid after flat-zone height overrides. + * Uses simple centered finite differences from the position buffer. + */ +function recomputeNormals( + positions: Float32Array, + normals: Float32Array, + segments: number, + gridStep: number, +): void { + const invTwoStep = 1 / (2 * gridStep); + + for (let iz = 0; iz < segments; iz++) { + for (let ix = 0; ix < segments; ix++) { + const idx = iz * segments + ix; + const i3 = idx * 3; + + const izN = Math.max(0, iz - 1); + const izS = Math.min(segments - 1, iz + 1); + const ixW = Math.max(0, ix - 1); + const ixE = Math.min(segments - 1, ix + 1); + + const hL = positions[(iz * segments + ixW) * 3 + 1]; + const hR = positions[(iz * segments + ixE) * 3 + 1]; + const hD = positions[(izN * segments + ix) * 3 + 1]; + const hU = positions[(izS * segments + ix) * 3 + 1]; + + const dhdx = (hR - hL) * invTwoStep; + const dhdz = (hU - hD) * invTwoStep; + const nx = -dhdx; + const ny = 1; + const nz = -dhdz; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz); + + normals[i3] = nx / len; + normals[i3 + 1] = ny / len; + normals[i3 + 2] = nz / len; + } + } +} + +/** + * Synchronous data generator — produces QuadChunkWorkerOutput on the main thread. + * Used as fallback when workers are unavailable. The output feeds directly into + * assembleQuadChunkGeometry, keeping all assembly logic in one place. + */ +export function generateQuadChunkDataSync( + centerX: number, + centerZ: number, + size: number, + resolution: number, + provider: FullTerrainProvider, +): QuadChunkWorkerOutput { + const segments = resolution; + const halfSize = size * 0.5; + const gridStep = size / (segments - 1); + const vertexCount = segments * segments; + + // Overflow grid for normals: (segments+2)^2 + const gRes = segments + 2; + const overflowGrid = new Float32Array(gRes * gRes); + + for (let gz = 0; gz < gRes; gz++) { + const localZ = -halfSize + (gz - 1) * gridStep; + const worldZ = centerZ + localZ; + for (let gx = 0; gx < gRes; gx++) { + const localX = -halfSize + (gx - 1) * gridStep; + const worldX = centerX + localX; + overflowGrid[gz * gRes + gx] = provider.getHeightAtComputed( + worldX, + worldZ, + ); + } + } + + const heightData = new Float32Array(vertexCount); + for (let iz = 0; iz < segments; iz++) { + const srcRow = (iz + 1) * gRes + 1; + const dstRow = iz * segments; + for (let ix = 0; ix < segments; ix++) { + heightData[dstRow + ix] = overflowGrid[srcRow + ix]; + } + } + + // Normals via centered finite differences on the overflow grid + const normalData = new Float32Array(vertexCount * 3); + const invTwoStep = 1 / (2 * gridStep); + for (let iz = 0; iz < segments; iz++) { + const gz = iz + 1; + for (let ix = 0; ix < segments; ix++) { + const gx = ix + 1; + const hL = overflowGrid[gz * gRes + (gx - 1)]; + const hR = overflowGrid[gz * gRes + (gx + 1)]; + const hD = overflowGrid[(gz - 1) * gRes + gx]; + const hU = overflowGrid[(gz + 1) * gRes + gx]; + const dhdx = (hR - hL) * invTwoStep; + const dhdz = (hU - hD) * invTwoStep; + const nx = -dhdx; + const ny = 1; + const nz = -dhdz; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz); + const i3 = (iz * segments + ix) * 3; + normalData[i3] = nx / len; + normalData[i3 + 1] = ny / len; + normalData[i3 + 2] = nz / len; + } + } + + // Colors and biome IDs + const colorData = new Float32Array(vertexCount * 3); + const biomeData = new Uint8Array(vertexCount); + const biomeForestWeight = new Float32Array(vertexCount); + const biomeCanyonWeight = new Float32Array(vertexCount); + + for (let iz = 0; iz < segments; iz++) { + for (let ix = 0; ix < segments; ix++) { + const idx = iz * segments + ix; + const height = heightData[idx]; + const normalizedHeight = height / provider.MAX_HEIGHT; + + const localX = -halfSize + ix * gridStep; + const localZ = -halfSize + iz * gridStep; + const worldX = centerX + localX; + const worldZ = centerZ + localZ; + + const { biomeWeightMap, totalWeight } = + provider.computeBiomeWeightsAtPosition(worldX, worldZ); + + let dominantBiome: string = DEFAULT_BIOME; + let dominantWeight = -Infinity; + let cr = 0, + cg = 0, + cb = 0; + + if (totalWeight > 0) { + const invTotal = 1 / totalWeight; + for (const [type, rawWeight] of biomeWeightMap) { + const weight = rawWeight * invTotal; + if (weight > dominantWeight) { + dominantWeight = weight; + dominantBiome = type; + } + const bc = provider.getBiomeColor(type); + cr += bc.r * weight; + cg += bc.g * weight; + cb += bc.b * weight; + } + } else { + const bc = provider.getBiomeColor(DEFAULT_BIOME); + cr = bc.r; + cg = bc.g; + cb = bc.b; + } + + biomeData[idx] = provider.getBiomeId(dominantBiome); + + const fwNorm = + totalWeight > 0 + ? (biomeWeightMap.get(BiomeType.Forest) || 0) / totalWeight + : 0; + const dwNorm = + totalWeight > 0 + ? (biomeWeightMap.get(BiomeType.Canyon) || 0) / totalWeight + : 0; + biomeForestWeight[idx] = fwNorm; + biomeCanyonWeight[idx] = dwNorm; + + const waterLevel = provider.WATER_LEVEL_NORMALIZED; + const shoreThreshold = provider.SHORELINE_THRESHOLD; + if (normalizedHeight > waterLevel && normalizedHeight < shoreThreshold) { + const shoreFactor = + (1.0 - + (normalizedHeight - waterLevel) / (shoreThreshold - waterLevel)) * + provider.SHORELINE_STRENGTH; + cr += (0.545 - cr) * shoreFactor; + cg += (0.451 - cg) * shoreFactor; + cb += (0.333 - cb) * shoreFactor; + } + + colorData[idx * 3] = cr; + colorData[idx * 3 + 1] = cg; + colorData[idx * 3 + 2] = cb; + } + } + + return { + type: "quadChunkResult", + centerX, + centerZ, + size, + resolution, + heightData, + normalData, + colorData, + biomeData, + biomeForestWeight, + biomeCanyonWeight, + }; +} diff --git a/packages/shared/src/systems/shared/world/TerrainQuadTree.ts b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts new file mode 100644 index 000000000..52b1e7f60 --- /dev/null +++ b/packages/shared/src/systems/shared/world/TerrainQuadTree.ts @@ -0,0 +1,652 @@ +/** + * TerrainQuadTree — Quad-tree LOD system for terrain visual chunks. + * + * Manages a hierarchical quad-tree of terrain chunks that split/unsplit + * based on distance to the player. Near chunks are small and high-resolution, + * far chunks are large and low-resolution. + * + * Inspired by infinite-world-master's Chunk/Chunks system, adapted for + * Hyperscape's terrain pipeline. + * + * This is a CLIENT-ONLY visual system. Server and gameplay logic still use + * the flat 100m tile grid (TerrainTile). getHeightAt() is unaffected. + */ + +export interface QuadTreeConfig { + /** Smallest chunk size in meters (leaf nodes). Should match TILE_SIZE for grid alignment. */ + minSize: number; + /** Maximum depth of quad-tree subdivision */ + maxDepth: number; + /** Split when distance < size * splitRatio */ + splitRatio: number; + /** Multiplier on splitRatio for unsplit threshold (prevents thrashing at boundary). Must be > 1. */ + unsplitMultiplier: number; + /** Uniform vertex resolution (segments per axis) for ALL depth levels */ + resolution: number; + /** Skirt drop distance in meters to hide LOD seams */ + skirtDrop: number; +} + +export const DEFAULT_QUAD_TREE_CONFIG: QuadTreeConfig = { + minSize: 100, + maxDepth: 4, + splitRatio: 1.5, + unsplitMultiplier: 1.2, + resolution: 32, + skirtDrop: 15, +}; + +export type QuadPosition = "ne" | "nw" | "sw" | "se"; +type CardinalDirection = "n" | "e" | "s" | "w"; + +/** + * A single node in the terrain quad-tree. + * Each node represents a square region of terrain that may be subdivided. + */ +export class TerrainQuadNode { + readonly id: number; + readonly tree: TerrainQuadTree; + readonly parent: TerrainQuadNode | null; + readonly quadPosition: QuadPosition | null; + readonly size: number; + readonly halfSize: number; + readonly quarterSize: number; + readonly centerX: number; + readonly centerZ: number; + readonly depth: number; + + /** Normalized LOD precision: 0 at root → 1 at max depth */ + readonly precision: number; + /** True when at maximum subdivision depth */ + readonly isMaxDepth: boolean; + + children = new Map(); + neighbours = new Map(); + splitted = false; + splitting = false; + unsplitting = false; + ready = false; + needsCheck = true; + + /** True when this is a leaf node that should have terrain geometry */ + isFinal = false; + terrainNeedsUpdate = true; + + /** Assigned by TerrainVisualManager when geometry is created */ + visualChunkKey: string | null = null; + + readonly boundingBox: { + xMin: number; + xMax: number; + zMin: number; + zMax: number; + }; + + constructor( + tree: TerrainQuadTree, + parent: TerrainQuadNode | null, + quadPosition: QuadPosition | null, + size: number, + centerX: number, + centerZ: number, + depth: number, + id: number, + ) { + this.id = id; + this.tree = tree; + this.parent = parent; + this.quadPosition = quadPosition; + this.size = size; + this.halfSize = size * 0.5; + this.quarterSize = this.halfSize * 0.5; + this.centerX = centerX; + this.centerZ = centerZ; + this.depth = depth; + this.precision = depth / tree.config.maxDepth; + this.isMaxDepth = depth === tree.config.maxDepth; + + this.boundingBox = { + xMin: centerX - this.halfSize, + xMax: centerX + this.halfSize, + zMin: centerZ - this.halfSize, + zMax: centerZ + this.halfSize, + }; + + this.check(); + + if (!this.splitted) { + this.createFinal(); + } + + this.testReady(); + } + + /** Resolution (segments per axis) — uniform for all depth levels */ + get resolution(): number { + return this.tree.config.resolution; + } + + check(): void { + if (!this.needsCheck) return; + this.needsCheck = false; + + const underSplit = this.tree.isUnderSplitDistance( + this.size, + this.centerX, + this.centerZ, + ); + + if (underSplit) { + if (!this.isMaxDepth && !this.splitted) { + this.split(); + } + } else if (this.splitted) { + if ( + this.tree.isOverUnsplitDistance(this.size, this.centerX, this.centerZ) + ) { + this.unsplit(); + } + } + + for (const child of this.children.values()) { + child.check(); + } + } + + update(): void { + if (this.isFinal && this.terrainNeedsUpdate && this.neighbours.size === 4) { + this.tree.requestTerrainGeneration(this); + this.terrainNeedsUpdate = false; + } + + for (const child of this.children.values()) { + child.update(); + } + } + + setNeighbours( + n: TerrainQuadNode | null, + e: TerrainQuadNode | null, + s: TerrainQuadNode | null, + w: TerrainQuadNode | null, + ): void { + this.neighbours.set("n", n); + this.neighbours.set("e", e); + this.neighbours.set("s", s); + this.neighbours.set("w", w); + } + + testReady(): void { + if (this.splitted) { + let readyCount = 0; + for (const child of this.children.values()) { + if (child.ready) readyCount++; + } + if (readyCount === 4) { + this.setReady(); + } + } else { + if (this.visualChunkKey !== null) { + this.setReady(); + } + } + } + + setReady(): void { + if (this.ready) return; + this.ready = true; + + if (this.splitting) { + this.splitting = false; + this.destroyFinal(); + } + + if (this.unsplitting) { + this.unsplitting = false; + for (const child of this.children.values()) { + child.destroy(); + } + this.children.clear(); + } + + if (this.parent) { + this.parent.testReady(); + } + } + + unsetReady(): void { + if (!this.ready) return; + this.ready = false; + } + + split(): void { + this.splitting = true; + this.splitted = true; + this.unsetReady(); + + const qSize = this.halfSize; + const q = this.quarterSize; + const d = this.depth + 1; + + const ne = this.tree.createNode( + this, + "ne", + qSize, + this.centerX + q, + this.centerZ - q, + d, + ); + this.children.set("ne", ne); + + const nw = this.tree.createNode( + this, + "nw", + qSize, + this.centerX - q, + this.centerZ - q, + d, + ); + this.children.set("nw", nw); + + const sw = this.tree.createNode( + this, + "sw", + qSize, + this.centerX - q, + this.centerZ + q, + d, + ); + this.children.set("sw", sw); + + const se = this.tree.createNode( + this, + "se", + qSize, + this.centerX + q, + this.centerZ + q, + d, + ); + this.children.set("se", se); + } + + unsplit(): void { + if (!this.splitted) return; + this.splitted = false; + this.unsplitting = true; + this.unsetReady(); + this.createFinal(); + } + + createFinal(): void { + if (this.isFinal) return; + this.isFinal = true; + this.terrainNeedsUpdate = true; + } + + destroyFinal(): void { + if (!this.isFinal) return; + this.isFinal = false; + this.terrainNeedsUpdate = false; + this.tree.requestTerrainDestruction(this); + } + + destroy(): void { + for (const child of this.children.values()) { + child.destroy(); + } + this.children.clear(); + this.splitted = false; + this.splitting = false; + this.unsplitting = false; + + this.destroyFinal(); + this.tree.removeNode(this); + } + + isInside(x: number, z: number): boolean { + return ( + x > this.boundingBox.xMin && + x < this.boundingBox.xMax && + z > this.boundingBox.zMin && + z < this.boundingBox.zMax + ); + } + + getDeepestNodeAt(x: number, z: number): TerrainQuadNode | null { + if (!this.splitted) return this; + + for (const child of this.children.values()) { + if (child.isInside(x, z)) { + return child.getDeepestNodeAt(x, z); + } + } + + return null; + } +} + +/** + * Callback interface for the visual manager to receive quad-tree events. + */ +export interface QuadTreeListener { + onNodeNeedsGeometry(node: TerrainQuadNode): void; + onNodeDestroyGeometry(node: TerrainQuadNode): void; +} + +/** + * Manages the top-level quad-tree: root chunk grid, split/unsplit, + * neighbor resolution, and player tracking. + */ +export class TerrainQuadTree { + readonly config: QuadTreeConfig; + readonly maxSize: number; + + private mainChunks = new Map(); + private allNodes = new Map(); + private playerX = 0; + private playerZ = 0; + private lastGridX = NaN; + private lastGridZ = NaN; + private listener: QuadTreeListener | null = null; + private nextNodeId = 0; + /** Set true whenever the tree structure changes (split/unsplit). Cleared after neighbour resolution. */ + private structureDirty = false; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_QUAD_TREE_CONFIG, ...config }; + this.maxSize = this.config.minSize * Math.pow(2, this.config.maxDepth); + } + + setListener(listener: QuadTreeListener): void { + this.listener = listener; + } + + createNode( + parent: TerrainQuadNode | null, + quadPosition: QuadPosition | null, + size: number, + centerX: number, + centerZ: number, + depth: number, + ): TerrainQuadNode { + const id = this.nextNodeId++; + const node = new TerrainQuadNode( + this, + parent, + quadPosition, + size, + centerX, + centerZ, + depth, + id, + ); + this.allNodes.set(node.id, node); + this.structureDirty = true; + return node; + } + + removeNode(node: TerrainQuadNode): void { + this.allNodes.delete(node.id); + this.structureDirty = true; + } + + requestTerrainGeneration(node: TerrainQuadNode): void { + this.listener?.onNodeNeedsGeometry(node); + } + + requestTerrainDestruction(node: TerrainQuadNode): void { + this.listener?.onNodeDestroyGeometry(node); + if (node.visualChunkKey !== null) { + node.visualChunkKey = null; + } + // DO NOT removeNode here — the node may still be alive as a structural + // parent after splitting. Removing it from allNodes breaks neighbor + // resolution for all descendants. Nodes are only removed from allNodes + // when truly destroyed via destroy() → explicit removal. + } + + isUnderSplitDistance(size: number, chunkX: number, chunkZ: number): boolean { + const dx = this.playerX - chunkX; + const dz = this.playerZ - chunkZ; + const distance = Math.sqrt(dx * dx + dz * dz); + return distance < size * this.config.splitRatio; + } + + isOverUnsplitDistance(size: number, chunkX: number, chunkZ: number): boolean { + const dx = this.playerX - chunkX; + const dz = this.playerZ - chunkZ; + const distance = Math.sqrt(dx * dx + dz * dz); + return ( + distance > size * this.config.splitRatio * this.config.unsplitMultiplier + ); + } + + /** + * Main update — call every frame or when player moves significantly. + * Returns true if the tree structure changed. + */ + update(playerX: number, playerZ: number): boolean { + this.playerX = playerX; + this.playerZ = playerZ; + + // Use numeric grid coordinates instead of string comparison to avoid + // per-frame string allocation / GC pressure. + const gridX = Math.round((playerX / this.config.minSize) * 2 + 0.5); + const gridZ = Math.round((playerZ / this.config.minSize) * 2 + 0.5); + if (gridX === this.lastGridX && gridZ === this.lastGridZ) { + if (this.structureDirty) { + this.updateAllNeighbours(); + this.structureDirty = false; + } + for (const chunk of this.mainChunks.values()) { + chunk.update(); + } + return false; + } + this.lastGridX = gridX; + this.lastGridZ = gridZ; + + // Mark all nodes for re-check + for (const node of this.allNodes.values()) { + node.needsCheck = true; + } + + // Get main chunk coordinates (3x3 grid around player) + const mainCoords = this.getMainChunkCoordinates(); + + // Destroy main chunks no longer in proximity + for (const [key, chunk] of this.mainChunks) { + if (!mainCoords.find((c) => c.key === key)) { + chunk.destroy(); + this.mainChunks.delete(key); + } + } + + // Create new main chunks + for (const coord of mainCoords) { + if (!this.mainChunks.has(coord.key)) { + const chunk = this.createNode( + null, + null, + this.maxSize, + coord.worldX, + coord.worldZ, + 0, + ); + this.mainChunks.set(coord.key, chunk); + } + } + + // Check all main chunks (propagates to children) + for (const chunk of this.mainChunks.values()) { + chunk.check(); + } + + // Update neighbor relationships + this.updateAllNeighbours(); + this.structureDirty = false; + + // Run update on all main chunks (triggers terrain generation requests) + for (const chunk of this.mainChunks.values()) { + chunk.update(); + } + + return true; + } + + /** Destroy all chunks and reset state */ + dispose(): void { + for (const chunk of this.mainChunks.values()) { + chunk.destroy(); + } + this.mainChunks.clear(); + this.allNodes.clear(); + this.lastGridX = NaN; + this.lastGridZ = NaN; + } + + /** Get all leaf nodes that currently have (or need) visual geometry */ + getFinalNodes(): TerrainQuadNode[] { + const result: TerrainQuadNode[] = []; + for (const node of this.allNodes.values()) { + if (node.isFinal) result.push(node); + } + return result; + } + + /** Get total node count (for debug stats) */ + get totalNodeCount(): number { + return this.allNodes.size; + } + + /** Get active visual chunk count (for debug stats) */ + get visualChunkCount(): number { + let count = 0; + for (const node of this.allNodes.values()) { + if (node.visualChunkKey !== null) count++; + } + return count; + } + + // ========================================================================= + // Private methods + // ========================================================================= + + private getMainChunkCoordinates(): Array<{ + key: string; + gridX: number; + gridZ: number; + worldX: number; + worldZ: number; + }> { + const gx = Math.round(this.playerX / this.maxSize); + const gz = Math.round(this.playerZ / this.maxSize); + + const coords: Array<{ + key: string; + gridX: number; + gridZ: number; + worldX: number; + worldZ: number; + }> = []; + + for (let dx = -1; dx <= 1; dx++) { + for (let dz = -1; dz <= 1; dz++) { + const cx = gx + dx; + const cz = gz + dz; + coords.push({ + key: `${cx},${cz}`, + gridX: cx, + gridZ: cz, + worldX: cx * this.maxSize, + worldZ: cz * this.maxSize, + }); + } + } + + return coords; + } + + private updateAllNeighbours(): void { + // Main chunk neighbours (from the 3x3 grid) + for (const [key, chunk] of this.mainChunks) { + const [xStr, zStr] = key.split(","); + const x = parseFloat(xStr); + const z = parseFloat(zStr); + + chunk.setNeighbours( + this.mainChunks.get(`${x},${z - 1}`) ?? null, + this.mainChunks.get(`${x + 1},${z}`) ?? null, + this.mainChunks.get(`${x},${z + 1}`) ?? null, + this.mainChunks.get(`${x - 1},${z}`) ?? null, + ); + } + + // Child chunk neighbours (depth-sorted for correct propagation) + const childNodes = [...this.allNodes.values()] + .filter((n) => n.depth > 0) + .sort((a, b) => a.depth - b.depth); + + for (const node of childNodes) { + if (!node.parent) continue; + + let n: TerrainQuadNode | null = null; + let e: TerrainQuadNode | null = null; + let s: TerrainQuadNode | null = null; + let w: TerrainQuadNode | null = null; + + const qp = node.quadPosition!; + const parent = node.parent; + + // North + if (qp === "sw") n = parent.children.get("nw") ?? null; + else if (qp === "se") n = parent.children.get("ne") ?? null; + else { + const pn = parent.neighbours.get("n"); + if (pn) { + if (pn.splitted) + n = pn.children.get(qp === "nw" ? "sw" : "se") ?? null; + else n = pn; + } + } + + // East + if (qp === "nw") e = parent.children.get("ne") ?? null; + else if (qp === "sw") e = parent.children.get("se") ?? null; + else { + const pe = parent.neighbours.get("e"); + if (pe) { + if (pe.splitted) + e = pe.children.get(qp === "ne" ? "nw" : "sw") ?? null; + else e = pe; + } + } + + // South + if (qp === "nw") s = parent.children.get("sw") ?? null; + else if (qp === "ne") s = parent.children.get("se") ?? null; + else { + const ps = parent.neighbours.get("s"); + if (ps) { + if (ps.splitted) + s = ps.children.get(qp === "sw" ? "nw" : "ne") ?? null; + else s = ps; + } + } + + // West + if (qp === "ne") w = parent.children.get("nw") ?? null; + else if (qp === "se") w = parent.children.get("sw") ?? null; + else { + const pw = parent.neighbours.get("w"); + if (pw) { + if (pw.splitted) + w = pw.children.get(qp === "nw" ? "ne" : "se") ?? null; + else w = pw; + } + } + + node.setNeighbours(n, e, s, w); + } + } +} diff --git a/packages/shared/src/systems/shared/world/TerrainShader.ts b/packages/shared/src/systems/shared/world/TerrainShader.ts index 38e65d698..14175c1ee 100644 --- a/packages/shared/src/systems/shared/world/TerrainShader.ts +++ b/packages/shared/src/systems/shared/world/TerrainShader.ts @@ -1,6 +1,6 @@ /** - * TerrainShader - TSL Node Material for OSRS-style vertex color terrain - * Flat shaded, no textures - pure vertex colors based on height/slope/noise + * TerrainShader - TSL Node Material for stylized terrain + * Biome textures via triplanar/top-down mapping, blended by height/slope/noise * * **SHARED CODE:** * The core terrain material is also available in @hyperscape/procgen TerrainGen module. @@ -29,6 +29,7 @@ import { add, sub, mul, + div, dot, mix, smoothstep, @@ -43,8 +44,9 @@ import { import { getRoadInfluenceTextureState } from "./RoadInfluenceMask"; import { getLamppostLightTextureState } from "./LamppostLightMask"; import { FOG_NEAR_SQ, FOG_FAR_SQ, fogRenderTarget } from "./FogConfig"; +import { applyTerrainSunShade } from "./GPUMaterials"; -export const TERRAIN_CONSTANTS = { +export const TERRAIN_SHADER_CONSTANTS = { TRIPLANAR_SCALE: 0.5, SNOW_HEIGHT: 50.0, NOISE_SCALE: 0.0008, @@ -54,6 +56,246 @@ export const TERRAIN_CONSTANTS = { WATER_LEVEL: 5.0, }; +const TERRAIN_TEX_TILE = 0.08; +const TERRAIN_TEX_DIR = "textures/terrain-biomes"; + +const TERRAIN_BIOME_TEXTURES = { + grass: { + file: "grass.png", + fallback: [0.3, 0.58, 0.15] as [number, number, number], + }, + dirt: { + file: "dirt.png", + fallback: [0.35, 0.24, 0.12] as [number, number, number], + }, + cliff: { + file: "cliff.png", + fallback: [0.4, 0.38, 0.32] as [number, number, number], + }, + desertGrass: { + file: "desertGrass.png", + fallback: [0.82, 0.52, 0.28] as [number, number, number], + }, + desertDirt: { + file: "desertDirt.png", + fallback: [0.62, 0.28, 0.15] as [number, number, number], + }, + desertCliff: { + file: "desertCliff.png", + fallback: [0.72, 0.38, 0.18] as [number, number, number], + }, + snowGrass: { + file: "snowgrass.png", + fallback: [0.78, 0.82, 0.85] as [number, number, number], + }, + snowDirt: { + file: "snowdirt.png", + fallback: [0.55, 0.55, 0.58] as [number, number, number], + }, + snowCliff: { + file: "snowdirt.png", + fallback: [0.5, 0.52, 0.56] as [number, number, number], + }, +}; + +function getCdnUrl(): string { + if (typeof window !== "undefined") { + const w = window as Window & { __CDN_URL?: string }; + if (w.__CDN_URL) return w.__CDN_URL; + if ( + typeof import.meta !== "undefined" && + (import.meta as any).env?.PUBLIC_CDN_URL + ) + return (import.meta as any).env.PUBLIC_CDN_URL; + } + return "http://localhost:5555/game-assets"; +} + +function createTerrainBiomeTex( + url: string, + pr: number, + pg: number, + pb: number, +): THREE.Texture { + let tex: THREE.Texture; + if (typeof document !== "undefined") { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 2; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = `rgb(${Math.round(pr * 255)},${Math.round(pg * 255)},${Math.round(pb * 255)})`; + ctx.fillRect(0, 0, 2, 2); + tex = new THREE.Texture(canvas); + } else { + const data = new Uint8Array([ + Math.round(pr * 255), + Math.round(pg * 255), + Math.round(pb * 255), + 255, + ]); + tex = new THREE.DataTexture(data, 1, 1, THREE.RGBAFormat); + } + tex.wrapS = tex.wrapT = THREE.RepeatWrapping; + tex.magFilter = THREE.LinearFilter; + tex.minFilter = THREE.LinearMipmapLinearFilter; + tex.colorSpace = THREE.SRGBColorSpace; + tex.generateMipmaps = true; + tex.needsUpdate = true; + if (typeof window !== "undefined") { + new THREE.TextureLoader().load( + url, + (loaded) => { + tex.image = loaded.image; + tex.colorSpace = THREE.SRGBColorSpace; + tex.needsUpdate = true; + }, + undefined, + (err) => + console.warn( + `[TerrainShader] Failed to load ${url.split("/").pop()}:`, + err, + ), + ); + } + return tex; +} + +// ============================================================================ +// SHARED TERRAIN BASE COLOR (used by terrain shader AND tree ground-blend) +// ============================================================================ + +// --- Tundra palette: snowy white-blue with frozen grey stone --- +const TUNDRA_GRASS = vec3(0.78, 0.82, 0.85); +const TUNDRA_GRASS_DARK = vec3(0.65, 0.7, 0.75); +const TUNDRA_DIRT = vec3(0.55, 0.55, 0.58); +const TUNDRA_DIRT_DARK = vec3(0.42, 0.42, 0.45); +const TUNDRA_CLIFF = vec3(0.5, 0.52, 0.56); +const TUNDRA_CLIFF_DARK = vec3(0.38, 0.4, 0.44); + +// --- Forest palette: vibrant energetic greens with warm brown earth --- +const FOREST_GRASS = vec3(0.3, 0.58, 0.15); +const FOREST_GRASS_DARK = vec3(0.18, 0.42, 0.08); +const FOREST_DIRT = vec3(0.35, 0.24, 0.12); +const FOREST_DIRT_DARK = vec3(0.22, 0.15, 0.08); +const FOREST_CLIFF = vec3(0.4, 0.38, 0.32); +const FOREST_CLIFF_DARK = vec3(0.28, 0.26, 0.22); + +// --- Canyon palette: red-orange sand with deep crimson rock --- +const CANYON_SAND = vec3(0.82, 0.52, 0.28); +const CANYON_SAND_DARK = vec3(0.72, 0.42, 0.2); +const CANYON_ROCK = vec3(0.62, 0.28, 0.15); +const CANYON_ROCK_DARK = vec3(0.48, 0.2, 0.1); +const CANYON_CLIFF = vec3(0.72, 0.38, 0.18); +const CANYON_CLIFF_DARK = vec3(0.55, 0.25, 0.12); + +// Legacy aliases used by road overlay and other shader sections (default = forest) +const GRASS_GREEN = FOREST_GRASS; +const GRASS_DARK = FOREST_GRASS_DARK; +const DIRT_BROWN = FOREST_DIRT; +const DIRT_DARK = FOREST_DIRT_DARK; +const ROCK_GRAY = vec3(0.45, 0.42, 0.38); +const ROCK_DARK = vec3(0.3, 0.28, 0.25); +const SAND_YELLOW = vec3(0.7, 0.6, 0.38); +const SNOW_WHITE = vec3(0.92, 0.94, 0.96); +const MUD_BROWN = vec3(0.18, 0.12, 0.08); +const WATER_EDGE = vec3(0.08, 0.06, 0.04); + +/** + * Compute the procedural terrain base color at a world position. + * This is the exact same logic the terrain shader uses (height + slope + noise), + * extracted so the tree shader can call it for ground-blending. + * + * @param height - positionWorld.y + * @param slope - 1 - abs(normalWorld.y) (0 = flat, 1 = vertical) + * @param noiseVal - primary Perlin noise sample (noiseTex @ worldXZ * NOISE_SCALE) + * @param noiseVal2 - derived noise: sin(noiseVal * 6.28) * 0.3 + 0.5 + * @param forestWeight - biome weight for forest [0..1] + * @param canyonWeight - biome weight for canyon [0..1] + */ +export function computeTerrainBaseColor( + height: any, + slope: any, + noiseVal: any, + noiseVal2: any, + forestWeight?: any, + canyonWeight?: any, +) { + const fW = forestWeight ?? float(0.0); + const dW = canyonWeight ?? float(0.0); + const tW = sub(float(1.0), add(fW, dW)); + + // Biome-blended grass + const grassVariation = smoothstep(float(0.4), float(0.6), noiseVal2); + const tundraGrass = mix(TUNDRA_GRASS, TUNDRA_GRASS_DARK, grassVariation); + const forestGrass = mix(FOREST_GRASS, FOREST_GRASS_DARK, grassVariation); + const canyonGrass = mix(CANYON_SAND, CANYON_SAND_DARK, grassVariation); + let c: any = add( + add(mul(tundraGrass, tW), mul(forestGrass, fW)), + mul(canyonGrass, dW), + ); + + // Biome-blended dirt + const dirtPatchFactor = smoothstep( + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15), + noiseVal, + ); + const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); + const dirtVariation = smoothstep(float(0.3), float(0.7), noiseVal2); + const tundraDirt = mix(TUNDRA_DIRT, TUNDRA_DIRT_DARK, dirtVariation); + const forestDirt = mix(FOREST_DIRT, FOREST_DIRT_DARK, dirtVariation); + const canyonDirt = mix(CANYON_ROCK, CANYON_ROCK_DARK, dirtVariation); + const dirtColor = add( + add(mul(tundraDirt, tW), mul(forestDirt, fW)), + mul(canyonDirt, dW), + ); + c = mix(c, dirtColor, mul(dirtPatchFactor, flatnessFactor)); + + // Slope-based dirt — fades out at steep slopes where cliff color takes over + const dirtSlopeFactor = mul( + smoothstep(float(0.15), float(0.4), slope), + smoothstep(float(0.6), float(0.3), slope), + ); + c = mix(c, dirtColor, mul(dirtSlopeFactor, float(0.6))); + + // Per-biome cliff color on steep slopes (terrace sides, rock faces) + const cliffVariation = smoothstep(float(0.3), float(0.7), noiseVal); + const tundraCliff = mix(TUNDRA_CLIFF, TUNDRA_CLIFF_DARK, cliffVariation); + const forestCliff = mix(FOREST_CLIFF, FOREST_CLIFF_DARK, cliffVariation); + const canyonCliff = mix(CANYON_CLIFF, CANYON_CLIFF_DARK, cliffVariation); + const cliffColor = add( + add(mul(tundraCliff, tW), mul(forestCliff, fW)), + mul(canyonCliff, dW), + ); + c = mix(c, cliffColor, smoothstep(float(0.3), float(0.55), slope)); + + // Sand near water (flat areas, stronger in canyon) + const sandBlend = mul( + smoothstep(float(10.0), float(6.0), height), + smoothstep(float(0.25), float(0.0), slope), + ); + const sandStrength = mix(float(0.6), float(0.9), dW); + c = mix(c, SAND_YELLOW, mul(sandBlend, sandStrength)); + + // Shoreline transitions + c = mix( + c, + DIRT_DARK, + mul(smoothstep(float(14.0), float(8.0), height), float(0.4)), + ); + c = mix( + c, + MUD_BROWN, + mul(smoothstep(float(9.0), float(6.0), height), float(0.7)), + ); + c = mix( + c, + WATER_EDGE, + mul(smoothstep(float(6.5), float(5.0), height), float(0.9)), + ); + + return c; +} + // ============================================================================ // PERLIN NOISE TEXTURE GENERATION // ============================================================================ @@ -265,8 +507,8 @@ export function sampleNoiseAtPosition( } // Calculate UV the same way the shader does - const u = worldX * TERRAIN_CONSTANTS.NOISE_SCALE; - const v = worldZ * TERRAIN_CONSTANTS.NOISE_SCALE; + const u = worldX * TERRAIN_SHADER_CONSTANTS.NOISE_SCALE; + const v = worldZ * TERRAIN_SHADER_CONSTANTS.NOISE_SCALE; // The texture tiles, so wrap to 0-1 const wrappedU = u - Math.floor(u); @@ -327,17 +569,6 @@ export function getGrassiness( grassiness -= rockFactor; } - // === SNOW AT HIGH ELEVATION === - // Snow line at ~50m, full snow by 55m - if (height > TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0) { - const snowFactor = smoothstepCPU( - TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0, - TERRAIN_CONSTANTS.SNOW_HEIGHT + 5.0, - height, - ); - grassiness -= snowFactor; - } - // Clamp to 0-1 return Math.max(0, Math.min(1, grassiness)); } @@ -399,6 +630,8 @@ export const MAX_VERTEX_LIGHTS = 8; export type TerrainUniforms = { sunPosition: { value: THREE.Vector3 }; + sunDirection: { value: THREE.Vector3 }; + shadeColor: { value: THREE.Color }; time: { value: number }; fogEnabled: { value: number }; // 1.0 = fog enabled, 0.0 = fog disabled (for minimap) // Vertex lighting uniforms (lampposts, etc.) @@ -445,8 +678,8 @@ export function updateTerrainVertexLights( } /** - * OSRS-style vertex color terrain material - * No textures - pure flat shaded colors based on height, slope, and noise + * Stylized terrain material with biome texture sampling + * Grass/dirt textures use top-down projection; cliff textures use triplanar mapping */ export function createTerrainMaterial(): THREE.Material & { terrainUniforms: TerrainUniforms; @@ -455,8 +688,10 @@ export function createTerrainMaterial(): THREE.Material & { const noiseTex = generateNoiseTexture(); const sunPositionUniform = uniform(vec3(100, 100, 100)); + const sunDirectionUniform = uniform(vec3(0.5, 0.8, 0.3)); + const shadeColorUniform = uniform(vec3(0.7, 1.08, 1.22)); const timeUniform = uniform(float(0)); - const noiseScale = uniform(float(TERRAIN_CONSTANTS.NOISE_SCALE)); + const noiseScale = uniform(float(TERRAIN_SHADER_CONSTANTS.NOISE_SCALE)); // Sky-color fog: uses the shared render target texture (updated in-place by SkySystem) const fogTexNode = texture(fogRenderTarget.texture, screenUV); @@ -482,122 +717,177 @@ export function createTerrainMaterial(): THREE.Material & { const slope = sub(float(1.0), abs(worldNormal.y)); // ============================================================================ - // OSRS-STYLE VERTEX COLORS - // Flat, distinct colors - no gradients, no textures + // TERRAIN BASE COLOR (shared function + anti-dithering noise) // ============================================================================ - // Core terrain colors (OSRS palette) - const grassGreen = vec3(0.3, 0.55, 0.15); // Rich green grass - const grassDark = vec3(0.22, 0.42, 0.1); // Darker grass variation - const dirtBrown = vec3(0.45, 0.32, 0.18); // Light brown dirt - const dirtDark = vec3(0.32, 0.22, 0.12); // Dark brown dirt - const rockGray = vec3(0.45, 0.42, 0.38); // Gray rock - const rockDark = vec3(0.3, 0.28, 0.25); // Dark rock - const sandYellow = vec3(0.7, 0.6, 0.38); // Sandy beach - const snowWhite = vec3(0.92, 0.94, 0.96); // Snow caps - const mudBrown = vec3(0.18, 0.12, 0.08); // Wet mud near water - const waterEdge = vec3(0.08, 0.06, 0.04); // Dark water's edge - - // ============================================================================ - // DISTANCE-BASED LOD FOR NOISE SAMPLING - // PERFORMANCE: Reduced from 4 to 2 texture samples (compute derived values instead) - // ============================================================================ const toCamera = sub(worldPos, cameraPosition); const distSq = dot(toCamera, toCamera); - // LOD threshold: 100m^2 = 10000 - closer threshold for faster falloff - const _lodDetailFactor = smoothstep(float(15000.0), float(8000.0), distSq); - // Sample Perlin noise - ONLY 1 base sample always needed + // Sample Perlin noise const noiseUV = mul(vec2(worldPos.x, worldPos.z), noiseScale); const noiseValue = texture(noiseTex, noiseUV).r; - - // PERFORMANCE: Derive secondary noise mathematically instead of texture sample - // Uses sin transform of base noise for variation (no extra texture fetch) const noiseValue2 = add( mul(sin(mul(noiseValue, float(6.28))), float(0.3)), float(0.5), ); - // CONDITIONAL fine detail sample - only when close - // Uses step function to completely skip sample at distance (cheaper than smoothstep mix) + // Fine detail noise (LOD-gated) const closeEnough = smoothstep(float(12000.0), float(8000.0), distSq); const noiseUV3 = mul(vec2(worldPos.x, worldPos.z), float(0.12)); const fineNoiseSample = texture(noiseTex, noiseUV3).r; const fineNoise = mix(float(0.5), fineNoiseSample, closeEnough); - - // PERFORMANCE: Derive micro noise from base noise (no 4th texture sample) const microNoise = add( mul(cos(mul(noiseValue, float(12.56))), float(0.2)), float(0.5), ); - // === BASE: GRASS with light/dark variation === - const grassVariation = smoothstep(float(0.4), float(0.6), noiseValue2); - let baseColor = mix(grassGreen, grassDark, grassVariation); + // Biome weight attributes (computed per-vertex by QuadChunkWorker) + const biomeForestW = attribute("biomeForestWeight", "float"); + const biomeCanyonW = attribute("biomeCanyonWeight", "float"); + const fW = biomeForestW; + const dW = biomeCanyonW; + const tW = sub(float(1.0), add(fW, dW)); + + // --- TERRAIN BIOME TEXTURES --- + const texBase = `${getCdnUrl()}/${TERRAIN_TEX_DIR}`; + const loadBiomeTex = (key: keyof typeof TERRAIN_BIOME_TEXTURES) => { + const cfg = TERRAIN_BIOME_TEXTURES[key]; + return createTerrainBiomeTex(`${texBase}/${cfg.file}`, ...cfg.fallback); + }; + + const tGrass = loadBiomeTex("grass"); + const tDirt = loadBiomeTex("dirt"); + const tCliff = loadBiomeTex("cliff"); + const tDesertGrass = loadBiomeTex("desertGrass"); + const tDesertDirt = loadBiomeTex("desertDirt"); + const tDesertCliff = loadBiomeTex("desertCliff"); + const tSnowGrass = loadBiomeTex("snowGrass"); + const tSnowDirt = loadBiomeTex("snowDirt"); + const tSnowCliff = loadBiomeTex("snowCliff"); + + // UV projections (top-down for grass/dirt, triplanar for cliffs) + const tileScale = float(TERRAIN_TEX_TILE); + const uvFlat = mul(vec2(worldPos.x, worldPos.z), tileScale); + const uvFront = mul(vec2(worldPos.x, worldPos.y), tileScale); + const uvSide = mul(vec2(worldPos.z, worldPos.y), tileScale); + + // Triplanar blend weights for cliff textures (^4 sharpening) + const tnx = abs(worldNormal.x); + const tny = abs(worldNormal.y); + const tnz = abs(worldNormal.z); + const tw4x = mul(mul(tnx, tnx), mul(tnx, tnx)); + const tw4y = mul(mul(tny, tny), mul(tny, tny)); + const tw4z = mul(mul(tnz, tnz), mul(tnz, tnz)); + const twSum = add(add(tw4x, tw4y), tw4z); + const twX = div(tw4x, twSum); + const twY = div(tw4y, twSum); + const twZ = div(tw4z, twSum); + + // Flat textures sampled top-down (grass/dirt on mostly-flat surfaces) + const sGrass = texture(tGrass, uvFlat).rgb; + const sDirt = texture(tDirt, uvFlat).rgb; + const sDesertGrass = texture(tDesertGrass, uvFlat).rgb; + const sDesertDirt = texture(tDesertDirt, uvFlat).rgb; + const sSnowGrass = texture(tSnowGrass, uvFlat).rgb; + const sSnowDirt = texture(tSnowDirt, uvFlat).rgb; + + // Cliff textures sampled triplanarly (avoids stretching on steep faces) + const triCliff = (t: THREE.Texture) => + add( + add(mul(texture(t, uvFlat).rgb, twY), mul(texture(t, uvSide).rgb, twX)), + mul(texture(t, uvFront).rgb, twZ), + ); + const sCliff = triCliff(tCliff); + const sDesertCliff = triCliff(tDesertCliff); + const sSnowCliff = triCliff(tSnowCliff); + + const TEX_DARKEN = float(0.65); + + // Biome-blended grass (textured) + const grassVar = smoothstep(float(0.4), float(0.6), noiseValue2); + const tundraGrassC = mix(sSnowGrass, mul(sSnowGrass, TEX_DARKEN), grassVar); + const forestGrassC = mix(sGrass, mul(sGrass, TEX_DARKEN), grassVar); + const canyonGrassC = mix( + sDesertGrass, + mul(sDesertGrass, TEX_DARKEN), + grassVar, + ); + let baseColor: any = add( + add(mul(tundraGrassC, tW), mul(forestGrassC, fW)), + mul(canyonGrassC, dW), + ); - // === DIRT PATCHES (noise-based, flat ground only) === - // Wider transition for smoother blending + // Biome-blended dirt patches const dirtPatchFactor = smoothstep( - float(TERRAIN_CONSTANTS.DIRT_THRESHOLD - 0.05), - float(TERRAIN_CONSTANTS.DIRT_THRESHOLD + 0.15), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD - 0.05), + float(TERRAIN_SHADER_CONSTANTS.DIRT_THRESHOLD + 0.15), noiseValue, ); const flatnessFactor = smoothstep(float(0.3), float(0.05), slope); - const dirtVariation = smoothstep(float(0.3), float(0.7), noiseValue2); - const dirtColor = mix(dirtBrown, dirtDark, dirtVariation); + const dirtVar = smoothstep(float(0.3), float(0.7), noiseValue2); + const tundraDirtC = mix(sSnowDirt, mul(sSnowDirt, TEX_DARKEN), dirtVar); + const forestDirtC = mix(sDirt, mul(sDirt, TEX_DARKEN), dirtVar); + const canyonDirtC = mix(sDesertDirt, mul(sDesertDirt, TEX_DARKEN), dirtVar); + const dirtColor = add( + add(mul(tundraDirtC, tW), mul(forestDirtC, fW)), + mul(canyonDirtC, dW), + ); baseColor = mix(baseColor, dirtColor, mul(dirtPatchFactor, flatnessFactor)); - // === SLOPE-BASED DIRT (steeper = more dirt) === - // Gradual transition - baseColor = mix( - baseColor, - dirtColor, - mul(smoothstep(float(0.15), float(0.5), slope), float(0.6)), + // Slope-based dirt + const dirtSlopeFactor = mul( + smoothstep(float(0.15), float(0.4), slope), + smoothstep(float(0.6), float(0.3), slope), ); - - // === ROCK ON STEEP SLOPES === - const rockVariation = smoothstep(float(0.3), float(0.7), noiseValue); - const rockColorFinal = mix(rockGray, rockDark, rockVariation); - baseColor = mix( - baseColor, - rockColorFinal, - smoothstep(float(0.45), float(0.75), slope), + baseColor = mix(baseColor, dirtColor, mul(dirtSlopeFactor, float(0.6))); + + // Per-biome cliff on steep slopes (triplanar textured) + const cliffVar = smoothstep(float(0.3), float(0.7), noiseValue); + const tundraCliffC = mix(sSnowCliff, mul(sSnowCliff, TEX_DARKEN), cliffVar); + const forestCliffC = mix(sCliff, mul(sCliff, TEX_DARKEN), cliffVar); + const canyonCliffC = mix( + sDesertCliff, + mul(sDesertCliff, TEX_DARKEN), + cliffVar, + ); + const cliffColor = add( + add(mul(tundraCliffC, tW), mul(forestCliffC, fW)), + mul(canyonCliffC, dW), ); - - // === SNOW AT HIGH ELEVATION === - // Very gradual snow transition baseColor = mix( baseColor, - snowWhite, - smoothstep(float(TERRAIN_CONSTANTS.SNOW_HEIGHT - 5.0), float(60.0), height), + cliffColor, + smoothstep(float(0.3), float(0.55), slope), ); - // === SAND NEAR WATER (flat areas only) === + // Sand near water (keep flat color - no sand texture) const sandBlend = mul( smoothstep(float(10.0), float(6.0), height), smoothstep(float(0.25), float(0.0), slope), ); - baseColor = mix(baseColor, sandYellow, mul(sandBlend, float(0.6))); - - // === SHORELINE TRANSITIONS (gradual) === - // Zone 1: Wet dirt (8-14m) - wider zone - const wetDirtZone = smoothstep(float(14.0), float(8.0), height); - baseColor = mix(baseColor, dirtDark, mul(wetDirtZone, float(0.4))); + const sandStrength = mix(float(0.6), float(0.9), dW); + baseColor = mix(baseColor, SAND_YELLOW, mul(sandBlend, sandStrength)); - // Zone 2: Mud (6-9m) - const mudZone = smoothstep(float(9.0), float(6.0), height); - baseColor = mix(baseColor, mudBrown, mul(mudZone, float(0.7))); - - // Zone 3: Water's edge (5-6.5m) - const edgeZone = smoothstep(float(6.5), float(5.0), height); - baseColor = mix(baseColor, waterEdge, mul(edgeZone, float(0.9))); - - // === ANTI-DITHERING: Add fine noise variation to break up banding === - // Subtle brightness variation based on high-frequency noise - const brightnessVar = mul(sub(fineNoise, float(0.5)), float(0.08)); // ±4% brightness - const colorVar = mul(sub(microNoise, float(0.5)), float(0.04)); // ±2% color shift + // Shoreline transitions (keep flat colors) + baseColor = mix( + baseColor, + DIRT_DARK, + mul(smoothstep(float(14.0), float(8.0), height), float(0.4)), + ); + baseColor = mix( + baseColor, + MUD_BROWN, + mul(smoothstep(float(9.0), float(6.0), height), float(0.7)), + ); + baseColor = mix( + baseColor, + WATER_EDGE, + mul(smoothstep(float(6.5), float(5.0), height), float(0.9)), + ); - // Apply variations to break up vertex interpolation artifacts + // Anti-dithering noise variation (±4% brightness, ±2% color shift) + const brightnessVar = mul(sub(fineNoise, float(0.5)), float(0.08)); + const colorVar = mul(sub(microNoise, float(0.5)), float(0.04)); const variedColor = add( baseColor, vec3( @@ -636,12 +926,12 @@ export function createTerrainMaterial(): THREE.Material & { // Reuse existing dirt colors with natural noise variation const roadNoiseVar = mul(noiseValue2, float(0.5)); // Natural dirt variation - const roadBaseColor = mix(dirtBrown, dirtDark, roadNoiseVar); + const roadBaseColor = mix(DIRT_BROWN, DIRT_DARK, roadNoiseVar); // Gravel/Cobblestone effect: High frequency noise for texture // Use fineNoise (highest freq) to create small stones const stoneNoise = smoothstep(float(0.4), float(0.7), fineNoise); - const stoneColor = mix(rockGray, rockDark, float(0.5)); + const stoneColor = mix(ROCK_GRAY, ROCK_DARK, float(0.5)); // Mix stones into dirt base - more stones in center of road const roadDetailColor = mix( @@ -812,14 +1102,25 @@ export function createTerrainMaterial(): THREE.Material & { material.side = THREE.FrontSide; material.fog = false; - // Apply fog AFTER PBR lighting via outputNode so fog color isn't darkened + // Apply sun shade + fog AFTER PBR lighting via outputNode material.outputNode = Fn(() => { const litColor = output; - return vec4(mix(litColor.rgb, fogColor, fogFactor), litColor.a); + + // Sun shade: shared function (identical to tree shader terrain blend) + const shaded = applyTerrainSunShade( + litColor.rgb, + normalWorld, + vec3(sunDirectionUniform), + vec3(shadeColorUniform), + ); + + return vec4(mix(shaded, fogColor, fogFactor), litColor.a); })(); const terrainUniforms: TerrainUniforms = { sunPosition: sunPositionUniform, + sunDirection: sunDirectionUniform as unknown as { value: THREE.Vector3 }, + shadeColor: shadeColorUniform as unknown as { value: THREE.Color }, time: timeUniform, fogEnabled: fogEnabledUniform, // Vertex lighting arrays diff --git a/packages/shared/src/systems/shared/world/TerrainSystem.ts b/packages/shared/src/systems/shared/world/TerrainSystem.ts index 165da16c1..891e0baac 100644 --- a/packages/shared/src/systems/shared/world/TerrainSystem.ts +++ b/packages/shared/src/systems/shared/world/TerrainSystem.ts @@ -18,33 +18,31 @@ import { type TerrainWorkerOutput, } from "../../../utils/workers"; import { - CONTINENT_LAYER, - RIDGE_LAYER, - HILL_LAYER, - EROSION_LAYER, - DETAIL_LAYER, - HEIGHT_POWER_CURVE, - ISLAND_RADIUS, - ISLAND_FALLOFF, - ISLAND_DEEP_OCEAN_BUFFER, - BASE_ELEVATION, - OCEAN_FLOOR_HEIGHT, - HEIGHT_TERRAIN_MIX, POND_RADIUS, POND_DEPTH, POND_CENTER_X, POND_CENTER_Z, - COASTLINE_CIRCLE_SAMPLE_RADIUS, - COAST_LARGE, - COAST_MEDIUM, - COAST_SMALL, - MOUNTAIN_BOOST_MAX_NORM_DIST, - MOUNTAIN_BOOST_GAUSSIAN_COEFF, + LANDSCAPE_FEATURES, + ISLAND_RADIUS, + computeBaseHeight, + adjustShorelineHeight, + buildComputeBiomeWeightsJS, + buildApplyLandscapeFeaturesJS, + MAX_HEIGHT, + WATER_LEVEL_NORMALIZED, + SHORELINE_CONFIG, + BIOME_CONFIG, +} from "./TerrainHeightParams"; +import type { + LandscapeFeatureDef, + ShorelineConfig, } from "./TerrainHeightParams"; +import { BiomeType, DEFAULT_BIOME, BIOME_LIST } from "./TerrainBiomeTypes"; // Import terrain generator from procgen package import { TerrainGenerator, + BiomeSystem, type TerrainConfig, type BiomeDefinition, } from "@hyperscape/procgen/terrain"; @@ -62,7 +60,6 @@ import { */ import type { BiomeData } from "../../../types/core/core"; -import type { BiomeTreeConfig } from "../../../types/world/world-types"; import type { ResourceNode, TerrainTile, @@ -72,7 +69,6 @@ import type { RoadTileSegment } from "../../../types/world/world-types"; import { PhysicsHandle } from "../../../types/systems/physics"; import { getPhysX } from "../../../physics/PhysXManager"; import { Layers } from "../../../physics/Layers"; -import { isStreamingLikeViewport } from "../../../runtime/clientViewportMode"; import { BIOMES } from "../../../data/world-structure"; import { ALL_WORLD_AREAS } from "../../../data/world-areas"; import { getDuelArenaConfig } from "../../../data/duel-manifest"; @@ -86,6 +82,7 @@ import { // generatePlants, // DISABLED - plants not working/looking good yet type ResourceGenerationContext, } from "./BiomeResourceGenerator"; +import { getTreeConfigForBiome } from "./TerrainBiomeTypes"; import { setProcgenRockWorld, addRockInstance, @@ -111,6 +108,7 @@ import { TerrainUniforms, } from "./TerrainShader"; import { isLamppostLightTextureReady } from "./LamppostLightMask"; +import { isCsmEnabled } from "./Environment"; import type { RoadNetworkSystem } from "./RoadNetworkSystem"; import type { TownSystem } from "./TownSystem"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; @@ -119,6 +117,12 @@ import { isTerrainComputeAvailable, type GPURoadSegment, } from "../../../utils/compute"; +import { + TerrainVisualManager, + type VisualManagerTerrainProvider, +} from "./TerrainVisualManager"; +import type { QuadChunkWorkerConfig } from "../../../utils/workers/QuadChunkWorker"; +import { terminateQuadChunkWorkerPool } from "../../../utils/workers/QuadChunkWorker"; // Road influence blending - used for shader's roadInfluence attribute const ROAD_BLEND_WIDTH = 0.5; // Extra blend distance beyond road width (meters) @@ -181,8 +185,9 @@ export class TerrainSystem extends System { private updateTimer = 0; private terrainTime = 0; // For animated caustics private noise!: NoiseGenerator; - private biomeCenters: BiomeCenter[] = []; - private biomeWeightScratch = new Map(); + private landscapeFeatures: LandscapeFeatureDef[] = []; + private _loggedWorkerTileBiome = 0; + private _loggedSyncTileBiome = 0; private databaseSystem!: { saveWorldChunk(chunkData: WorldChunkData): void; }; // DatabaseSystem reference @@ -250,6 +255,9 @@ export class TerrainSystem extends System { private terrainComputeContext: TerrainComputeContext | null = null; private gpuComputeAvailable = false; + // Quad-tree LOD visual manager (client-only, when CONFIG.USE_QUADTREE_LOD is true) + private quadTreeVisualManager: TerrainVisualManager | null = null; + // Unified terrain generator from @hyperscape/procgen // Provides deterministic height/biome calculation independent of rendering private terrainGenerator!: TerrainGenerator; @@ -749,31 +757,35 @@ export class TerrainSystem extends System { const workerConfig: TerrainWorkerConfig = { TILE_SIZE: this.CONFIG.TILE_SIZE, TILE_RESOLUTION: this.CONFIG.TILE_RESOLUTION, - MAX_HEIGHT: this.CONFIG.MAX_HEIGHT, + MAX_HEIGHT: MAX_HEIGHT, // Biome calculation - MUST match getBiomeInfluencesAtPosition() - BIOME_GAUSSIAN_COEFF: this.CONFIG.BIOME_GAUSSIAN_COEFF, - BIOME_BOUNDARY_NOISE_SCALE: this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, - BIOME_BOUNDARY_NOISE_AMOUNT: this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT, - MOUNTAIN_HEIGHT_THRESHOLD: this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD, - MOUNTAIN_WEIGHT_BOOST: this.CONFIG.MOUNTAIN_WEIGHT_BOOST, - VALLEY_HEIGHT_THRESHOLD: this.CONFIG.VALLEY_HEIGHT_THRESHOLD, - VALLEY_WEIGHT_BOOST: this.CONFIG.VALLEY_WEIGHT_BOOST, - // Mountain height boost - MUST match getHeightAtWithoutShore() - MOUNTAIN_HEIGHT_BOOST: this.CONFIG.MOUNTAIN_HEIGHT_BOOST, - // Shoreline config - MUST match getHeightAt() and createTileGeometry() + BIOME_GAUSSIAN_COEFF: BIOME_CONFIG.gaussianCoeff, + BIOME_BOUNDARY_NOISE_SCALE: BIOME_CONFIG.boundaryNoiseScale, + BIOME_BOUNDARY_NOISE_AMOUNT: BIOME_CONFIG.boundaryNoiseAmount, WATER_THRESHOLD: this.CONFIG.WATER_THRESHOLD, - WATER_LEVEL_NORMALIZED: this.CONFIG.WATER_LEVEL_NORMALIZED, - SHORELINE_THRESHOLD: this.CONFIG.SHORELINE_THRESHOLD, - SHORELINE_STRENGTH: this.CONFIG.SHORELINE_STRENGTH, - // Shoreline slope adjustment - MUST match adjustHeightForShoreline() - SHORELINE_MIN_SLOPE: this.CONFIG.SHORELINE_MIN_SLOPE, - SHORELINE_SLOPE_SAMPLE_DISTANCE: - this.CONFIG.SHORELINE_SLOPE_SAMPLE_DISTANCE, - SHORELINE_LAND_BAND: this.CONFIG.SHORELINE_LAND_BAND, - SHORELINE_LAND_MAX_MULTIPLIER: - this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, - SHORELINE_UNDERWATER_BAND: this.CONFIG.SHORELINE_UNDERWATER_BAND, - UNDERWATER_DEPTH_MULTIPLIER: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + WATER_LEVEL_NORMALIZED: WATER_LEVEL_NORMALIZED, + SHORELINE_THRESHOLD: SHORELINE_CONFIG.THRESHOLD, + SHORELINE_STRENGTH: SHORELINE_CONFIG.STRENGTH, + SHORELINE_MIN_SLOPE: SHORELINE_CONFIG.MIN_SLOPE, + SHORELINE_SLOPE_SAMPLE_DISTANCE: SHORELINE_CONFIG.SLOPE_SAMPLE_DISTANCE, + SHORELINE_LAND_BAND: SHORELINE_CONFIG.LAND_BAND, + SHORELINE_LAND_MAX_MULTIPLIER: SHORELINE_CONFIG.LAND_MAX_MULTIPLIER, + SHORELINE_UNDERWATER_BAND: SHORELINE_CONFIG.UNDERWATER_BAND, + UNDERWATER_DEPTH_MULTIPLIER: + SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + landscapeFeatures: this.landscapeFeatures.map((f) => ({ + type: f.type, + x: f.x, + z: f.z, + radius: f.radius, + strength: f.strength, + layers: f.layers, + shapePower: f.shapePower, + edgeSharpness: f.edgeSharpness, + layerSlope: f.layerSlope, + noiseScale: f.noiseScale, + noiseAmount: f.noiseAmount, + })), }; // Build simplified biome data for worker @@ -795,7 +807,9 @@ export class TerrainSystem extends System { tilesToProcess, workerConfig, this.computeSeedFromWorldId(), - this.biomeCenters, + this.terrainGenerator + .getBiomeSystem() + .getBiomeCenters() as BiomeCenter[], biomeData, ); @@ -845,6 +859,8 @@ export class TerrainSystem extends System { const positions = geometry.attributes.position; const roadInfluences = new Float32Array(positions.count); + const forestWeights = new Float32Array(positions.count); + const canyonWeights = new Float32Array(positions.count); const tileKey = `${tileX}_${tileZ}`; const hasFlatZones = (this.flatZonesByTile.get(tileKey)?.length ?? 0) > 0; @@ -874,7 +890,7 @@ export class TerrainSystem extends System { } } - // Road influence per vertex (cheap — no noise, only tile lookups) + // Road influence and biome weights per vertex for (let i = 0; i < positions.count; i++) { const localX = positions.getX(i); const localZ = positions.getZ(i); @@ -886,6 +902,14 @@ export class TerrainSystem extends System { tileX, tileZ, ); + + const { biomeWeightMap, totalWeight } = + this.computeBiomeWeightsAtPosition(worldX, worldZ); + if (totalWeight > 0) { + const invW = 1 / totalWeight; + forestWeights[i] = (biomeWeightMap.get(BiomeType.Forest) || 0) * invW; + canyonWeights[i] = (biomeWeightMap.get(BiomeType.Canyon) || 0) * invW; + } } // Set attributes @@ -898,6 +922,34 @@ export class TerrainSystem extends System { "roadInfluence", new THREE.BufferAttribute(roadInfluences, 1), ); + geometry.setAttribute( + "biomeForestWeight", + new THREE.BufferAttribute(forestWeights, 1), + ); + geometry.setAttribute( + "biomeCanyonWeight", + new THREE.BufferAttribute(canyonWeights, 1), + ); + + // DEBUG: log biome weights for first 5 tiles + if (this._loggedWorkerTileBiome < 5) { + this._loggedWorkerTileBiome++; + let maxF = 0, + maxD = 0, + sumF = 0, + sumD = 0; + for (let _d = 0; _d < forestWeights.length; _d++) { + sumF += forestWeights[_d]; + sumD += canyonWeights[_d]; + maxF = Math.max(maxF, forestWeights[_d]); + maxD = Math.max(maxD, canyonWeights[_d]); + } + const n = forestWeights.length; + const avgP = (1 - sumF / n - sumD / n).toFixed(3); + console.log( + `[BiomeDebug/WorkerTile] Tile(${tileX},${tileZ}) plains=${avgP} forest=${(sumF / n).toFixed(3)} canyon=${(sumD / n).toFixed(3)}`, + ); + } if (TERRAIN_ROAD_INFLUENCE_DEBUG) { let nonZeroCount = 0; @@ -956,8 +1008,8 @@ export class TerrainSystem extends System { tileZ * this.CONFIG.TILE_SIZE, ); mesh.name = `Terrain_${key}`; - mesh.receiveShadow = true; - mesh.castShadow = false; + mesh.receiveShadow = this.CONFIG.TERRAIN_RECEIVE_SHADOW && !isCsmEnabled(); + mesh.castShadow = this.CONFIG.TERRAIN_CAST_SHADOW; mesh.frustumCulled = true; mesh.userData = { type: "terrain", @@ -1067,8 +1119,8 @@ export class TerrainSystem extends System { tile.collider = physics.addActor(actor, handle); } - // Add to scene - if (this.terrainContainer) { + // Add to scene (skip when quad-tree LOD handles visual rendering) + if (this.terrainContainer && !this.CONFIG.USE_QUADTREE_LOD) { this.terrainContainer.add(mesh); } @@ -1106,7 +1158,7 @@ export class TerrainSystem extends System { z: originZ + r.position.z, }, })); - const genericBiome = this.mapBiomeToGeneric(tile.biome as string); + const genericBiome = (tile.biome as BiomeType) || DEFAULT_BIOME; this.world.emit(EventType.TERRAIN_TILE_GENERATED, { tileId: `${tileX},${tileZ}`, position: { x: originX, z: originZ }, @@ -1167,68 +1219,23 @@ export class TerrainSystem extends System { return tile; } - private initializeBiomeCenters(): void { - const worldSize = this.getActiveWorldSizeMeters(); - const gridSize = this.CONFIG.BIOME_GRID_SIZE; // 5x5 grid - const cellSize = worldSize / gridSize; - - // Use deterministic PRNG for reproducible biome placement - const baseSeed = this.computeSeedFromWorldId(); - let randomState = baseSeed; - - const nextRandom = () => { - // Linear congruential generator for deterministic random - randomState = (randomState * 1664525 + 1013904223) >>> 0; - return randomState / 0xffffffff; - }; - - // Weighted biome types - plains dominant, with variety - const biomeTypes = [ - "plains", - "plains", - "plains", - "forest", - "forest", - "valley", - "mountains", - "mountains", - "desert", - "swamp", - "tundra", // Added back - ]; - - // Clear any existing centers - this.biomeCenters = []; - - // Grid-jitter placement for even distribution - for (let gx = 0; gx < gridSize; gx++) { - for (let gz = 0; gz < gridSize; gz++) { - // Base position at grid cell center - const baseX = (gx + 0.5) * cellSize - worldSize / 2; - const baseZ = (gz + 0.5) * cellSize - worldSize / 2; - - // Jitter within cell (controlled randomness) - const jitter = this.CONFIG.BIOME_JITTER; - const jitterX = (nextRandom() - 0.5) * 2 * jitter * cellSize; - const jitterZ = (nextRandom() - 0.5) * 2 * jitter * cellSize; - - const x = baseX + jitterX; - const z = baseZ + jitterZ; - - const typeIndex = Math.floor(nextRandom() * biomeTypes.length); - const influenceRange = - this.CONFIG.BIOME_MAX_INFLUENCE - this.CONFIG.BIOME_MIN_INFLUENCE; - const influence = - this.CONFIG.BIOME_MIN_INFLUENCE + nextRandom() * influenceRange; - - this.biomeCenters.push({ - x, - z, - type: biomeTypes[typeIndex], - influence, - }); + private initializeLandscapeFeatures(): void { + this.landscapeFeatures = [...LANDSCAPE_FEATURES]; + console.log( + "[TerrainSystem] Landscape features:", + this.landscapeFeatures.length, + JSON.stringify(this.landscapeFeatures), + ); + // Spot-check: test height at feature locations after terrain is ready + setTimeout(() => { + for (const f of this.landscapeFeatures) { + const h = this.getHeightAt(f.x, f.z); + const hNearby = this.getHeightAt(f.x + 200, f.z + 200); + console.log( + `[LandscapeDebug] ${f.type} at (${f.x}, ${f.z}): height=${h.toFixed(2)}, nearby height=${hNearby.toFixed(2)}, diff=${(h - hNearby).toFixed(2)}`, + ); } - } + }, 5000); } /** @@ -1260,7 +1267,7 @@ export class TerrainSystem extends System { tileSize: this.CONFIG.TILE_SIZE, worldSize: this.CONFIG.WORLD_SIZE, tileResolution: this.CONFIG.TILE_RESOLUTION, - maxHeight: this.CONFIG.MAX_HEIGHT, + maxHeight: MAX_HEIGHT, waterThreshold: this.CONFIG.WATER_THRESHOLD, noise: { continent: { @@ -1288,18 +1295,18 @@ export class TerrainSystem extends System { }, }, biomes: { - gridSize: this.CONFIG.BIOME_GRID_SIZE, - jitter: this.CONFIG.BIOME_JITTER, - minInfluence: this.CONFIG.BIOME_MIN_INFLUENCE, - maxInfluence: this.CONFIG.BIOME_MAX_INFLUENCE, - gaussianCoeff: this.CONFIG.BIOME_GAUSSIAN_COEFF, - boundaryNoiseScale: this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, - boundaryNoiseAmount: this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT, - mountainHeightThreshold: this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD, - mountainWeightBoost: this.CONFIG.MOUNTAIN_WEIGHT_BOOST, - valleyHeightThreshold: this.CONFIG.VALLEY_HEIGHT_THRESHOLD, - valleyWeightBoost: this.CONFIG.VALLEY_WEIGHT_BOOST, - mountainHeightBoost: this.CONFIG.MOUNTAIN_HEIGHT_BOOST, + gridSize: 3, + jitter: 0, + minInfluence: BIOME_CONFIG.influenceRadius, + maxInfluence: BIOME_CONFIG.influenceRadius, + gaussianCoeff: BIOME_CONFIG.gaussianCoeff, + boundaryNoiseScale: BIOME_CONFIG.boundaryNoiseScale, + boundaryNoiseAmount: BIOME_CONFIG.boundaryNoiseAmount, + explicitCenters: BiomeSystem.computePolygonCenters( + BIOME_LIST as string[], + BIOME_CONFIG.placementRadius, + BIOME_CONFIG.influenceRadius, + ), }, island: { enabled: this.CONFIG.ISLAND_MASK_ENABLED, @@ -1309,15 +1316,15 @@ export class TerrainSystem extends System { edgeNoiseStrength: this.CONFIG.ISLAND_EDGE_NOISE_STRENGTH, }, shoreline: { - waterLevelNormalized: this.CONFIG.WATER_LEVEL_NORMALIZED, - threshold: this.CONFIG.SHORELINE_THRESHOLD, - colorStrength: this.CONFIG.SHORELINE_STRENGTH, - minSlope: this.CONFIG.SHORELINE_MIN_SLOPE, - slopeSampleDistance: this.CONFIG.SHORELINE_SLOPE_SAMPLE_DISTANCE, - landBand: this.CONFIG.SHORELINE_LAND_BAND, - landMaxMultiplier: this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER, - underwaterBand: this.CONFIG.SHORELINE_UNDERWATER_BAND, - underwaterDepthMultiplier: this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + waterLevelNormalized: WATER_LEVEL_NORMALIZED, + threshold: SHORELINE_CONFIG.THRESHOLD, + colorStrength: SHORELINE_CONFIG.STRENGTH, + minSlope: SHORELINE_CONFIG.MIN_SLOPE, + slopeSampleDistance: SHORELINE_CONFIG.SLOPE_SAMPLE_DISTANCE, + landBand: SHORELINE_CONFIG.LAND_BAND, + landMaxMultiplier: SHORELINE_CONFIG.LAND_MAX_MULTIPLIER, + underwaterBand: SHORELINE_CONFIG.UNDERWATER_BAND, + underwaterDepthMultiplier: SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER, }, }; @@ -1344,16 +1351,10 @@ export class TerrainSystem extends System { // OSRS-STYLE: Rolling terrain with visible hills private readonly CONFIG = { // Core World Specs - TILE_SIZE: 100, // 100m x 100m tiles + TILE_SIZE: TERRAIN_CONSTANTS.TERRAIN_TILE_SIZE, WORLD_SIZE: 100, // 100x100 grid = 10km x 10km world TILE_RESOLUTION: 64, // 64x64 vertices per tile for smooth terrain - MAX_HEIGHT: 50, // 50m max height variation (bumpy terrain to show flat zones) - WATER_THRESHOLD: TERRAIN_CONSTANTS.WATER_THRESHOLD, // Water appears below this level - - // Performance: Reduced draw distance - CAMERA_FAR: 400, // Match fog far + buffer - FOG_NEAR: 150, - FOG_FAR: 350, + WATER_THRESHOLD: TERRAIN_CONSTANTS.WATER_THRESHOLD, // LOD (Level of Detail) - Resolution tiers based on distance LOD_DISTANCES: [100, 200, 350], // Distance thresholds @@ -1389,33 +1390,7 @@ export class TerrainSystem extends System { BOSS_HOTSPOT_RADIUS: 120, BOSS_MIN_LEVEL: 800, - // Biome Generation - BIOME_GRID_SIZE: 3, // 3x3 grid = 9 very large biomes - BIOME_JITTER: 0.35, // How much to randomize position within grid cell (0-0.5) - BIOME_MIN_INFLUENCE: 2000, // Biome influence radius in meters - BIOME_MAX_INFLUENCE: 3500, // Biome influence radius in meters - BIOME_GAUSSIAN_COEFF: 0.15, // Gaussian falloff (smooth natural decay) - BIOME_RANGE_MULT: 10, // Not used - no hard cutoff - BIOME_BOUNDARY_NOISE_SCALE: 0.003, // Larger scale noise for organic boundaries - BIOME_BOUNDARY_NOISE_AMOUNT: 0.15, // Subtle noise - - // Height-Biome Coupling - MOUNTAIN_HEIGHT_BOOST: 0.5, // How much mountains raise terrain (0.5 = 50%) - MOUNTAIN_HEIGHT_THRESHOLD: 0.4, // Height above which mountains get weight boost - MOUNTAIN_WEIGHT_BOOST: 2.0, // Max weight multiplier for mountains at high elevation - VALLEY_HEIGHT_THRESHOLD: 0.4, // Height below which valleys/plains get weight boost - VALLEY_WEIGHT_BOOST: 1.5, // Max weight multiplier for valleys at low elevation - - // Shoreline - WATER_LEVEL_NORMALIZED: 0.15, // Normalized height where water starts - SHORELINE_THRESHOLD: 0.25, // Normalized height where shoreline effect ends - SHORELINE_STRENGTH: 0.6, // How strong the brown tint is (0-1) - SHORELINE_MIN_SLOPE: 0.06, // Minimum slope to enforce near shorelines - SHORELINE_SLOPE_SAMPLE_DISTANCE: 1.0, // Sample distance for shoreline slope checks - SHORELINE_LAND_BAND: 3.0, // Meters above water to shape shoreline - SHORELINE_LAND_MAX_MULTIPLIER: 1.6, // Max land steepening multiplier - SHORELINE_UNDERWATER_BAND: 3.0, // Meters below water to deepen shoreline - UNDERWATER_DEPTH_MULTIPLIER: 1.8, // Max depth multiplier near shoreline + // Shoreline — delegated to TerrainHeightParams.SHORELINE_CONFIG (single source of truth) // Island Mask (default for main world) ISLAND_MASK_ENABLED: true, @@ -1427,6 +1402,28 @@ export class TerrainSystem extends System { // Web Worker settings for parallel terrain generation USE_WORKERS: true, // Enable web workers for heightmap generation (client-side only) WORKER_POOL_SIZE: 0, // Number of workers (0 = auto, based on CPU cores) + + // Quad-tree LOD system (client-only visual terrain) + // When enabled, terrain rendering uses a quad-tree with uniform resolution + // chunks instead of the flat 100m grid. Gameplay logic (resources, physics, + // biomes, server sync) still uses the flat grid. getHeightAt() is unaffected. + USE_QUADTREE_LOD: true, + QUADTREE_DEBUG_WIREFRAME: false, + QUADTREE_MIN_SIZE: 100, + QUADTREE_MAX_DEPTH: 4, + QUADTREE_SPLIT_RATIO: 1.5, + QUADTREE_SKIRT_DROP: 15, + QUADTREE_UNSPLIT_MULTIPLIER: 1.2, + QUADTREE_RESOLUTION: 64, + QUADTREE_MAX_SYNC_PER_FRAME: 4, + QUADTREE_MAX_ASSEMBLIES_PER_FRAME: 6, + + // Shadow settings for terrain meshes (applies to both flat-grid and quad-tree) + // NOTE: receiveShadow only works with single shadow map (ENABLE_CSM=false). + // CSM adds too many shadow textures per cascade, exceeding WebGPU's 16-texture + // limit (terrain already uses 13 textures for biomes + noise + fog + masks). + TERRAIN_RECEIVE_SHADOW: true, + TERRAIN_CAST_SHADOW: false, }; // Pre-computed terrain data from workers (awaiting geometry creation) @@ -1451,8 +1448,7 @@ export class TerrainSystem extends System { // Initialize deterministic noise from world id this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - // Initialize biome centers using deterministic random placement - this.initializeBiomeCenters(); + this.initializeLandscapeFeatures(); // Initialize the unified terrain generator from @hyperscape/procgen // This provides a standalone, testable height generation system @@ -1562,7 +1558,7 @@ export class TerrainSystem extends System { // Initialize noise generator if not already initialized (failsafe) if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - this.initializeBiomeCenters(); + this.initializeLandscapeFeatures(); } // CRITICAL: Wait for DataManager to initialize BIOMES data before generating terrain @@ -1720,12 +1716,189 @@ export class TerrainSystem extends System { this.instancedMeshManager = new InstancedMeshManager(scene, this.world); this.registerInstancedMeshes(); + // Initialize quad-tree LOD visual manager if enabled + if (this.CONFIG.USE_QUADTREE_LOD) { + this.initQuadTreeVisualManager(); + } + // Setup initial camera only if no client camera system controls it // Leave control to ClientCameraSystem for third-person follow // Initial tiles will be loaded in start() method } + /** + * Build the VisualManagerTerrainProvider adapter that exposes TerrainSystem's + * height/biome/road/flat-zone queries to the quad-tree chunk generator. + */ + private buildChunkTerrainProvider(): VisualManagerTerrainProvider { + const biomeColorCache = new Map< + string, + { r: number; g: number; b: number } + >(); + + return { + getHeightAtComputed: (worldX: number, worldZ: number) => + this.getHeightAtComputed(worldX, worldZ), + + computeBiomeWeightsAtPosition: (worldX: number, worldZ: number) => + this.computeBiomeWeightsAtPosition(worldX, worldZ), + + computeBiomeWeightsByPosition: (worldX: number, worldZ: number) => + this.computeBiomeWeightsByPosition(worldX, worldZ), + + calculateRoadInfluenceAtVertex: ( + worldX: number, + worldZ: number, + tileX: number, + tileZ: number, + ) => this.calculateRoadInfluenceAtVertex(worldX, worldZ, tileX, tileZ), + + getBiomeId: (biomeName: string) => this.getBiomeId(biomeName), + + getBiomeColor: (biomeName: string) => { + let cached = biomeColorCache.get(biomeName); + if (!cached) { + const biomeData = BIOMES[biomeName]; + if (biomeData) { + const c = new THREE.Color(biomeData.color); + cached = { r: c.r, g: c.g, b: c.b }; + } else { + cached = { r: 0.3, g: 0.55, b: 0.15 }; + } + biomeColorCache.set(biomeName, cached); + } + return cached; + }, + + getFlatZoneHeight: (worldX: number, worldZ: number) => + this.getFlatZoneHeight(worldX, worldZ), + + WATER_LEVEL_NORMALIZED: WATER_LEVEL_NORMALIZED, + SHORELINE_THRESHOLD: SHORELINE_CONFIG.THRESHOLD, + SHORELINE_STRENGTH: SHORELINE_CONFIG.STRENGTH, + MAX_HEIGHT: MAX_HEIGHT, + TILE_SIZE: this.CONFIG.TILE_SIZE, + }; + } + + /** + * Initialize the quad-tree LOD visual manager. + * Creates a separate THREE.Group for quad-tree terrain meshes. + */ + private initQuadTreeVisualManager(): void { + if (!this.terrainMaterial) { + console.warn( + "[TerrainSystem] Cannot init quad-tree: terrain material not ready", + ); + return; + } + + const qtContainer = new THREE.Group(); + qtContainer.name = "QuadTreeTerrainContainer"; + const containerParent = this.terrainContainer?.parent; + if (!containerParent) { + console.warn( + "[TerrainSystem] Cannot init quad-tree: terrainContainer has no parent", + ); + return; + } + containerParent.add(qtContainer); + + const provider = this.buildChunkTerrainProvider(); + + const workerConfig: QuadChunkWorkerConfig = { + MAX_HEIGHT: MAX_HEIGHT, + BIOME_GAUSSIAN_COEFF: BIOME_CONFIG.gaussianCoeff, + BIOME_BOUNDARY_NOISE_SCALE: BIOME_CONFIG.boundaryNoiseScale, + BIOME_BOUNDARY_NOISE_AMOUNT: BIOME_CONFIG.boundaryNoiseAmount, + WATER_THRESHOLD: this.CONFIG.WATER_THRESHOLD, + WATER_LEVEL_NORMALIZED: WATER_LEVEL_NORMALIZED, + SHORELINE_THRESHOLD: SHORELINE_CONFIG.THRESHOLD, + SHORELINE_STRENGTH: SHORELINE_CONFIG.STRENGTH, + SHORELINE_MIN_SLOPE: SHORELINE_CONFIG.MIN_SLOPE, + SHORELINE_SLOPE_SAMPLE_DISTANCE: SHORELINE_CONFIG.SLOPE_SAMPLE_DISTANCE, + SHORELINE_LAND_BAND: SHORELINE_CONFIG.LAND_BAND, + SHORELINE_LAND_MAX_MULTIPLIER: SHORELINE_CONFIG.LAND_MAX_MULTIPLIER, + SHORELINE_UNDERWATER_BAND: SHORELINE_CONFIG.UNDERWATER_BAND, + UNDERWATER_DEPTH_MULTIPLIER: SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + landscapeFeatures: this.landscapeFeatures, + }; + + const biomeCenters = [ + ...this.terrainGenerator.getBiomeSystem().getBiomeCenters(), + ].map((c) => ({ + x: c.x, + z: c.z, + type: c.type, + influence: c.influence, + })); + + // DEBUG: log what we're sending to the worker + const workerTypeCounts: Record = {}; + for (const c of biomeCenters) { + workerTypeCounts[c.type ?? "UNDEFINED"] = + (workerTypeCounts[c.type ?? "UNDEFINED"] || 0) + 1; + } + console.log( + "[TerrainSystem] Worker biomeCenters:", + biomeCenters.length, + "types:", + JSON.stringify(workerTypeCounts), + ); + console.log( + "[TerrainSystem] First 5 centers:", + JSON.stringify(biomeCenters.slice(0, 5)), + ); + + const biomeColorCache = new Map< + string, + { r: number; g: number; b: number } + >(); + const workerBiomes: Record< + string, + { color: { r: number; g: number; b: number } } + > = {}; + for (const [name, biomeData] of Object.entries(BIOMES)) { + let cached = biomeColorCache.get(name); + if (!cached) { + const c = new THREE.Color(biomeData.color); + cached = { r: c.r, g: c.g, b: c.b }; + biomeColorCache.set(name, cached); + } + workerBiomes[name] = { color: cached }; + } + + this.quadTreeVisualManager = new TerrainVisualManager( + { + minSize: this.CONFIG.QUADTREE_MIN_SIZE, + maxDepth: this.CONFIG.QUADTREE_MAX_DEPTH, + splitRatio: this.CONFIG.QUADTREE_SPLIT_RATIO, + unsplitMultiplier: this.CONFIG.QUADTREE_UNSPLIT_MULTIPLIER, + resolution: this.CONFIG.QUADTREE_RESOLUTION, + skirtDrop: this.CONFIG.QUADTREE_SKIRT_DROP, + }, + provider, + qtContainer, + this.terrainMaterial, + workerConfig, + this.computeSeedFromWorldId(), + biomeCenters, + workerBiomes, + this.CONFIG.QUADTREE_DEBUG_WIREFRAME, + this.CONFIG.TERRAIN_RECEIVE_SHADOW && !isCsmEnabled(), + this.CONFIG.TERRAIN_CAST_SHADOW, + this.CONFIG.QUADTREE_MAX_SYNC_PER_FRAME, + this.CONFIG.QUADTREE_MAX_ASSEMBLIES_PER_FRAME, + ); + + console.log( + "[TerrainSystem] Quad-tree LOD visual manager initialized " + + `(minSize=${this.CONFIG.QUADTREE_MIN_SIZE}, maxDepth=${this.CONFIG.QUADTREE_MAX_DEPTH}, ` + + `resolution=${this.CONFIG.QUADTREE_RESOLUTION}, splitRatio=${this.CONFIG.QUADTREE_SPLIT_RATIO})`, + ); + } + private registerInstancedMeshes(): void { // Register tree mesh - now with automatic pooling (1000 visible max) const treeSize = { x: 1.2, y: 3.0, z: 1.2 }; @@ -1953,20 +2126,10 @@ export class TerrainSystem extends System { const players = this.world.getPlayers() || []; const centers: Array<{ id: string; position: THREE.Vector3 }> = []; for (const player of players) { - // Support both node.position (client) and direct position property (server) - const pos = player?.node?.position ?? player?.position; - if (!pos) { - // MEMORY DEBUG: Log players without position - if (Math.random() < 0.01) { - console.warn( - `[TerrainSystem] Player ${player?.id} missing position (node: ${!!player?.node}, position: ${!!player?.position})`, - ); - } - continue; - } + if (!player?.node?.position) continue; centers.push({ id: player.id || "player", - position: pos, + position: player.node.position, }); } @@ -2078,9 +2241,8 @@ export class TerrainSystem extends System { ); mesh.name = `Terrain_${key}`; - // Enable shadow receiving for CSM - mesh.receiveShadow = true; - mesh.castShadow = false; // Terrain doesn't cast shadows on itself + mesh.receiveShadow = this.CONFIG.TERRAIN_RECEIVE_SHADOW && !isCsmEnabled(); + mesh.castShadow = this.CONFIG.TERRAIN_CAST_SHADOW; // Enable frustum culling (Three.js built-in) mesh.frustumCulled = true; @@ -2223,8 +2385,8 @@ export class TerrainSystem extends System { tile.collider = physics.addActor(actor, handle); } - // Add to scene if client-side - if (this.terrainContainer) { + // Add to scene if client-side (skip when quad-tree LOD handles visual rendering) + if (this.terrainContainer && !this.CONFIG.USE_QUADTREE_LOD) { this.terrainContainer.add(mesh); } @@ -2268,7 +2430,7 @@ export class TerrainSystem extends System { }; return { id: r.id, type: r.type, position: pos }; }); - const genericBiome = this.mapBiomeToGeneric(tile.biome as string); + const genericBiome = (tile.biome as BiomeType) || DEFAULT_BIOME; this.world.emit(EventType.TERRAIN_TILE_GENERATED, { tileId: `${tileX},${tileZ}`, position: { x: originX, z: originZ }, @@ -2370,6 +2532,8 @@ export class TerrainSystem extends System { const heightData: number[] = []; const biomeIds = new Float32Array(positions.count); const roadInfluences = new Float32Array(positions.count); + const forestWeights = new Float32Array(positions.count); + const canyonWeights = new Float32Array(positions.count); // Verify biome data is loaded - error if not if (Object.keys(BIOMES).length === 0) { @@ -2377,9 +2541,11 @@ export class TerrainSystem extends System { "[TerrainSystem] BIOMES data not loaded! DataManager must initialize before terrain generation.", ); } - const defaultBiomeData = BIOMES["plains"]; + const defaultBiomeData = BIOMES[DEFAULT_BIOME]; if (!defaultBiomeData) { - throw new Error("[TerrainSystem] Plains biome not found in BIOMES data!"); + throw new Error( + "[TerrainSystem] Default biome not found in BIOMES data!", + ); } // Generate heightmap and vertex colors @@ -2408,10 +2574,10 @@ export class TerrainSystem extends System { // Get biome influences for smooth color blending const { biomeWeightMap, totalWeight } = this.computeBiomeWeightsAtPosition(x, z); - const normalizedHeight = height / this.CONFIG.MAX_HEIGHT; + const normalizedHeight = height / MAX_HEIGHT; // Store dominant biome ID for shader - let dominantBiome = "plains"; + let dominantBiome = DEFAULT_BIOME as string; let dominantWeight = -Infinity; // PERFORMANCE: Reuse _tempColor to avoid GC pressure (no new THREE.Color per vertex) @@ -2435,22 +2601,19 @@ export class TerrainSystem extends System { `[TerrainSystem] Biome "${type}" not found in BIOMES data!`, ); } - // Use _tempColor to parse hex once, then extract RGB (no allocation) this._tempColor.set(biomeData.color); - // Accumulate weighted colors directly to numbers colorR += this._tempColor.r * weight; colorG += this._tempColor.g * weight; colorB += this._tempColor.b * weight; } } else { - const biomeData = BIOMES["plains"]; + const biomeData = BIOMES[DEFAULT_BIOME]; if (!biomeData) { throw new Error( - `[TerrainSystem] Biome "plains" not found in BIOMES data!`, + `[TerrainSystem] Default biome not found in BIOMES data!`, ); } - // Use _tempColor to parse hex once, then extract RGB (no allocation) this._tempColor.set(biomeData.color); colorR = this._tempColor.r; colorG = this._tempColor.g; @@ -2458,19 +2621,24 @@ export class TerrainSystem extends System { } biomeIds[i] = this.getBiomeId(dominantBiome); + if (totalWeight > 0) { + const invW = 1 / totalWeight; + forestWeights[i] = (biomeWeightMap.get(BiomeType.Forest) || 0) * invW; + canyonWeights[i] = (biomeWeightMap.get(BiomeType.Canyon) || 0) * invW; + } + // Apply brownish shoreline tint near water level - const waterLevel = this.CONFIG.WATER_LEVEL_NORMALIZED; - const shorelineThreshold = this.CONFIG.SHORELINE_THRESHOLD; + const waterLevel = WATER_LEVEL_NORMALIZED; + const shorelineThreshold = SHORELINE_CONFIG.THRESHOLD; if ( normalizedHeight > waterLevel && normalizedHeight < shorelineThreshold ) { - // Sandy brown (0x8b7355) pre-computed: r=0.545, g=0.451, b=0.333 const shoreFactor = (1.0 - (normalizedHeight - waterLevel) / (shorelineThreshold - waterLevel)) * - this.CONFIG.SHORELINE_STRENGTH; + SHORELINE_CONFIG.STRENGTH; colorR = colorR + (0.545 - colorR) * shoreFactor; colorG = colorG + (0.451 - colorG) * shoreFactor; colorB = colorB + (0.333 - colorB) * shoreFactor; @@ -2497,6 +2665,30 @@ export class TerrainSystem extends System { "roadInfluence", new THREE.BufferAttribute(roadInfluences, 1), ); + geometry.setAttribute( + "biomeForestWeight", + new THREE.BufferAttribute(forestWeights, 1), + ); + geometry.setAttribute( + "biomeCanyonWeight", + new THREE.BufferAttribute(canyonWeights, 1), + ); + + // DEBUG: log biome weights for first 5 sync tiles + if (this._loggedSyncTileBiome < 5) { + this._loggedSyncTileBiome++; + let sumF = 0, + sumD = 0; + for (let _d = 0; _d < forestWeights.length; _d++) { + sumF += forestWeights[_d]; + sumD += canyonWeights[_d]; + } + const n = forestWeights.length; + const avgP = (1 - sumF / n - sumD / n).toFixed(3); + console.log( + `[BiomeDebug/SyncTile] Tile(${tileX},${tileZ}) plains=${avgP} forest=${(sumF / n).toFixed(3)} canyon=${(sumD / n).toFixed(3)}`, + ); + } if (TERRAIN_ROAD_INFLUENCE_DEBUG) { let nonZeroCount = 0; @@ -2956,16 +3148,67 @@ export class TerrainSystem extends System { } console.log( - `[TerrainSystem] Road influence refresh complete: ${tilesUpdated}/${tiles.length} tiles updated, ${totalVerticesWithRoads} vertices with road influence`, + `[TerrainSystem] Road influence refresh (tiles): ${tilesUpdated}/${tiles.length} tiles updated, ${totalVerticesWithRoads} vertices with road influence`, ); - // Log which tiles were skipped (no road segments) - if (tilesUpdated === 0 && tiles.length > 0) { - console.warn( - `[TerrainSystem] WARNING: No tiles had road segments! Check if roads are in the visible area.`, + + // Also refresh quad-tree LOD chunks if enabled + if (this.quadTreeVisualManager) { + let quadChunksUpdated = 0; + let quadVerticesWithRoads = 0; + const chunks = this.quadTreeVisualManager.getChunks(); + const chunkEntries = Array.from(chunks.values()); + const CHUNKS_PER_BATCH = 4; + + for (let ci = 0; ci < chunkEntries.length; ci += CHUNKS_PER_BATCH) { + const batchEnd = Math.min(ci + CHUNKS_PER_BATCH, chunkEntries.length); + + for (let c = ci; c < batchEnd; c++) { + const chunk = chunkEntries[c]; + const geometry = chunk.mesh.geometry as THREE.BufferGeometry; + if (!geometry) continue; + + const positions = geometry.attributes.position; + const roadInfluenceAttr = geometry.getAttribute("roadInfluence"); + if ( + !positions || + !(roadInfluenceAttr instanceof THREE.BufferAttribute) + ) + continue; + + const posArray = positions.array as Float32Array; + const roadArray = roadInfluenceAttr.array as Float32Array; + const cx = chunk.mesh.position.x; + const cz = chunk.mesh.position.z; + let vertsWithRoad = 0; + + for (let i = 0; i < positions.count; i++) { + const worldX = posArray[i * 3] + cx; + const worldZ = posArray[i * 3 + 2] + cz; + const roadTileX = Math.floor(worldX / this.CONFIG.TILE_SIZE); + const roadTileZ = Math.floor(worldZ / this.CONFIG.TILE_SIZE); + const influence = this.calculateRoadInfluenceAtVertex( + worldX, + worldZ, + roadTileX, + roadTileZ, + ); + roadArray[i] = influence; + if (influence > 0) vertsWithRoad++; + } + + roadInfluenceAttr.needsUpdate = true; + quadChunksUpdated++; + quadVerticesWithRoads += vertsWithRoad; + } + + if (batchEnd < chunkEntries.length) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + console.log( + `[TerrainSystem] Road influence refresh (quadtree): ${quadChunksUpdated}/${chunkEntries.length} chunks updated, ${quadVerticesWithRoads} vertices with road influence`, ); - // Log tile keys for debugging - const tileKeys = Array.from(this.terrainTiles.keys()).join(", "); - console.log(`[TerrainSystem] Existing tile keys: ${tileKeys}`); } } @@ -2974,16 +3217,11 @@ export class TerrainSystem extends System { */ private getBiomeId(biomeName: string): number { const biomeIds: Record = { - plains: 0, + tundra: 0, forest: 1, - valley: 2, - mountains: 3, - tundra: 4, - desert: 5, - lakes: 6, - swamp: 7, + canyon: 2, }; - return biomeIds[biomeName] || 0; + return biomeIds[biomeName] ?? 0; } private getIslandMask(worldX: number, worldZ: number): number { @@ -3020,184 +3258,49 @@ export class TerrainSystem extends System { /** * Get base terrain height WITHOUT mountain biome boost. - * Used for biome influence calculation to avoid feedback loops. + * Delegates to the single source of truth in TerrainHeightParams. */ - private getBaseHeightAt(worldX: number, worldZ: number): number { + private getBaseHeightAt( + worldX: number, + worldZ: number, + biomeWeights?: Record, + ): number { if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - if (!this.biomeCenters || this.biomeCenters.length === 0) { - this.initializeBiomeCenters(); - } - } - - // Multi-layered noise — constants from TerrainHeightParams (single source of truth) - const continentNoise = this.noise.fractal2D( - worldX * CONTINENT_LAYER.scale, - worldZ * CONTINENT_LAYER.scale, - CONTINENT_LAYER.octaves!, - CONTINENT_LAYER.persistence!, - CONTINENT_LAYER.lacunarity!, - ); - const ridgeNoise = this.noise.ridgeNoise2D( - worldX * RIDGE_LAYER.scale, - worldZ * RIDGE_LAYER.scale, - ); - const hillNoise = this.noise.fractal2D( - worldX * HILL_LAYER.scale, - worldZ * HILL_LAYER.scale, - HILL_LAYER.octaves!, - HILL_LAYER.persistence!, - HILL_LAYER.lacunarity!, - ); - const erosionNoise = this.noise.erosionNoise2D( - worldX * EROSION_LAYER.scale, - worldZ * EROSION_LAYER.scale, - EROSION_LAYER.iterations!, - ); - const detailNoise = this.noise.fractal2D( - worldX * DETAIL_LAYER.scale, - worldZ * DETAIL_LAYER.scale, - DETAIL_LAYER.octaves!, - DETAIL_LAYER.persistence!, - DETAIL_LAYER.lacunarity!, - ); - - let height = 0; - height += continentNoise * CONTINENT_LAYER.weight; - height += ridgeNoise * RIDGE_LAYER.weight; - height += hillNoise * HILL_LAYER.weight; - height += erosionNoise * EROSION_LAYER.weight; - height += detailNoise * DETAIL_LAYER.weight; - - height = (height + 1) * 0.5; - height = Math.max(0, Math.min(1, height)); - height = Math.pow(height, HEIGHT_POWER_CURVE); - - // Natural coastline — noise varies island radius for irregular shoreline - const distFromCenter = Math.sqrt(worldX * worldX + worldZ * worldZ); - const angle = Math.atan2(worldZ, worldX); - const cnx = Math.cos(angle) * COASTLINE_CIRCLE_SAMPLE_RADIUS; - const cnz = Math.sin(angle) * COASTLINE_CIRCLE_SAMPLE_RADIUS; - - const coastNoise1 = this.noise.fractal2D( - cnx, - cnz, - COAST_LARGE.octaves, - COAST_LARGE.persistence, - COAST_LARGE.lacunarity, - ); - const coastNoise2 = this.noise.fractal2D( - cnx * COAST_MEDIUM.freqMultiplier, - cnz * COAST_MEDIUM.freqMultiplier, - COAST_MEDIUM.octaves, - COAST_MEDIUM.persistence, - COAST_MEDIUM.lacunarity, - ); - const coastNoise3 = this.noise.simplex2D( - cnx * COAST_SMALL.freqMultiplier, - cnz * COAST_SMALL.freqMultiplier, - ); - - const coastlineVariation = - coastNoise1 * COAST_LARGE.weight + - coastNoise2 * COAST_MEDIUM.weight + - coastNoise3 * COAST_SMALL.weight; - const effectiveRadius = ISLAND_RADIUS * (1 + coastlineVariation); - - // Island mask — smooth falloff at edges - let islandMask = 1.0; - if (distFromCenter > effectiveRadius - ISLAND_FALLOFF) { - const edgeDist = distFromCenter - (effectiveRadius - ISLAND_FALLOFF); - const t = Math.min(1.0, edgeDist / ISLAND_FALLOFF); - const smoothstep = t * t * (3 - 2 * t); - islandMask = 1.0 - smoothstep; - } - if (distFromCenter > effectiveRadius + ISLAND_DEEP_OCEAN_BUFFER) { - islandMask = 0; } - // Pond — offset from center near spawn - const distFromPond = Math.sqrt( - (worldX - POND_CENTER_X) * (worldX - POND_CENTER_X) + - (worldZ - POND_CENTER_Z) * (worldZ - POND_CENTER_Z), + const weights = + biomeWeights ?? this.computeBiomeWeightsByPosition(worldX, worldZ); + return computeBaseHeight( + worldX, + worldZ, + this.noise, + weights, + this.landscapeFeatures, + MAX_HEIGHT, ); - let pondDepression = 0; - if (distFromPond < POND_RADIUS * 2) { - const pondFactor = 1.0 - distFromPond / (POND_RADIUS * 2); - pondDepression = pondFactor * pondFactor * POND_DEPTH; - } - - height = height * islandMask; - height = height * HEIGHT_TERRAIN_MIX + BASE_ELEVATION * islandMask; - height -= pondDepression; - - if (islandMask === 0) { - height = OCEAN_FLOOR_HEIGHT; - } - - return height * this.CONFIG.MAX_HEIGHT; } private getHeightAtWithoutShore(worldX: number, worldZ: number): number { - // Ensure biome centers are initialized - if (!this.biomeCenters || this.biomeCenters.length === 0) { - if (!this.noise) { - this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - } - this.initializeBiomeCenters(); + if (!this.noise) { + this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); } - // Check flat zones first (for stations, etc.) const flatHeight = this.getFlatZoneHeight(worldX, worldZ); if (flatHeight !== null) { return flatHeight; } - // Get procedural height with mountain boost - return this.getProceduralHeightWithBoost(worldX, worldZ); + return this.getBaseHeightAt(worldX, worldZ); } /** - * Get procedural terrain height with mountain biome boost applied. + * Get procedural terrain height. * This bypasses flat zones and returns the raw procedural height. * Useful for positioning objects that should sit above the terrain mesh. */ getProceduralHeightAt(worldX: number, worldZ: number): number { - return this.getProceduralHeightWithBoost(worldX, worldZ); - } - - /** - * Get procedural terrain height with mountain biome boost applied. - * Extracted for reuse in flat zone blending. - */ - private getProceduralHeightWithBoost(worldX: number, worldZ: number): number { - // Get base height (without mountain boost) - const baseHeight = this.getBaseHeightAt(worldX, worldZ); - let height = baseHeight / this.CONFIG.MAX_HEIGHT; // Normalize for boost calc - - // Apply mountain biome height boost - let mountainBoost = 0; - for (const center of this.biomeCenters) { - if (center.type === "mountains") { - const dx = worldX - center.x; - const dz = worldZ - center.z; - const distance = Math.sqrt(dx * dx + dz * dz); - const normalizedDist = distance / center.influence; - - if (normalizedDist < MOUNTAIN_BOOST_MAX_NORM_DIST) { - const boost = Math.exp( - -normalizedDist * normalizedDist * MOUNTAIN_BOOST_GAUSSIAN_COEFF, - ); - mountainBoost = Math.max(mountainBoost, boost); - } - } - } - - // Apply mountain height boost from CONFIG - height = height * (1 + mountainBoost * this.CONFIG.MOUNTAIN_HEIGHT_BOOST); - height = Math.min(1, height); // Cap at max - - return height * this.CONFIG.MAX_HEIGHT; + return this.getBaseHeightAt(worldX, worldZ); } private calculateBaseSlopeAt( @@ -3205,7 +3308,7 @@ export class TerrainSystem extends System { worldZ: number, centerHeight: number, ): number { - const checkDistance = this.CONFIG.SHORELINE_SLOPE_SAMPLE_DISTANCE; + const checkDistance = SHORELINE_CONFIG.SLOPE_SAMPLE_DISTANCE; const northHeight = this.getHeightAtWithoutShore( worldX, worldZ + checkDistance, @@ -3233,39 +3336,24 @@ export class TerrainSystem extends System { return Math.max(...slopes); } - private adjustHeightForShoreline(baseHeight: number, slope: number): number { - const waterThreshold = this.CONFIG.WATER_THRESHOLD; - if (baseHeight === waterThreshold) return baseHeight; + private _shorelineConfig: ShorelineConfig | null = null; - const isLand = baseHeight > waterThreshold; - const band = isLand - ? this.CONFIG.SHORELINE_LAND_BAND - : this.CONFIG.SHORELINE_UNDERWATER_BAND; - if (band <= 0) return baseHeight; - - const delta = Math.abs(baseHeight - waterThreshold); - if (delta >= band) return baseHeight; - - const minSlope = this.CONFIG.SHORELINE_MIN_SLOPE; - if (minSlope <= 0) return baseHeight; - - const maxMultiplier = isLand - ? this.CONFIG.SHORELINE_LAND_MAX_MULTIPLIER - : this.CONFIG.UNDERWATER_DEPTH_MULTIPLIER; - if (maxMultiplier <= 1) return baseHeight; - - const slopeSafe = Math.max(0.0001, slope); - const targetMultiplier = Math.min( - maxMultiplier, - Math.max(1, minSlope / slopeSafe), - ); - const falloff = 1 - delta / band; - const multiplier = 1 + (targetMultiplier - 1) * falloff; - const adjustedDelta = delta * multiplier; + private getShorelineConfig(): ShorelineConfig { + if (!this._shorelineConfig) { + this._shorelineConfig = { + waterThreshold: this.CONFIG.WATER_THRESHOLD, + shorelineLandBand: SHORELINE_CONFIG.LAND_BAND, + shorelineUnderwaterBand: SHORELINE_CONFIG.UNDERWATER_BAND, + shorelineMinSlope: SHORELINE_CONFIG.MIN_SLOPE, + shorelineLandMaxMultiplier: SHORELINE_CONFIG.LAND_MAX_MULTIPLIER, + underwaterDepthMultiplier: SHORELINE_CONFIG.UNDERWATER_DEPTH_MULTIPLIER, + }; + } + return this._shorelineConfig; + } - return isLand - ? waterThreshold + adjustedDelta - : waterThreshold - adjustedDelta; + private adjustHeightForShoreline(baseHeight: number, slope: number): number { + return adjustShorelineHeight(baseHeight, slope, this.getShorelineConfig()); } // ============================================================================ @@ -3376,8 +3464,8 @@ export class TerrainSystem extends System { ): number { const baseHeight = this.getHeightAtWithoutShore(worldX, worldZ); const waterThreshold = this.CONFIG.WATER_THRESHOLD; - const landBand = this.CONFIG.SHORELINE_LAND_BAND; - const underwaterBand = this.CONFIG.SHORELINE_UNDERWATER_BAND; + const landBand = SHORELINE_CONFIG.LAND_BAND; + const underwaterBand = SHORELINE_CONFIG.UNDERWATER_BAND; if ( baseHeight >= waterThreshold + landBand || @@ -3630,10 +3718,7 @@ export class TerrainSystem extends System { // If in a blend area, smoothly interpolate if (bestBlendZone) { - const proceduralHeight = this.getProceduralHeightWithBoost( - worldX, - worldZ, - ); + const proceduralHeight = this.getProceduralHeightAt(worldX, worldZ); // Smoothstep: t² × (3 - 2t) for C1 continuous transition const t = bestBlendFactor * bestBlendFactor * (3 - 2 * bestBlendFactor); @@ -4062,7 +4147,7 @@ export class TerrainSystem extends System { const depth = size.z * MOVEMENT_TILE_SIZE + padding * 2; // Get procedural height at station center (what terrain would be without flattening) - const flatHeight = this.getProceduralHeightWithBoost( + const flatHeight = this.getProceduralHeightAt( station.position.x, station.position.z, ); @@ -4086,7 +4171,7 @@ export class TerrainSystem extends System { if (areaConfig.flatZones) { for (const zoneConfig of areaConfig.flatZones) { // Calculate base height from procedural terrain - const proceduralHeight = this.getProceduralHeightWithBoost( + const proceduralHeight = this.getProceduralHeightAt( zoneConfig.centerX, zoneConfig.centerZ, ); @@ -4393,166 +4478,57 @@ export class TerrainSystem extends System { } private getBiomeAt(tileX: number, tileZ: number): string { - // Get world coordinates for center of tile - const worldX = tileX * this.CONFIG.TILE_SIZE + this.CONFIG.TILE_SIZE / 2; - const worldZ = tileZ * this.CONFIG.TILE_SIZE + this.CONFIG.TILE_SIZE / 2; - - // Check if near starter towns first (safe zones) - const towns = [ - { x: 0, z: 0, name: "Brookhaven" }, - { x: 10, z: 0, name: "Eastport" }, - { x: -10, z: 0, name: "Westfall" }, - { x: 0, z: 10, name: "Northridge" }, - { x: 0, z: -10, name: "Southmere" }, - ]; - - for (const town of towns) { - const distance = Math.sqrt((tileX - town.x) ** 2 + (tileZ - town.z) ** 2); - if (distance < 3) return "plains"; - } - - return this.getBiomeAtWorldPosition(worldX, worldZ); + return this.terrainGenerator + .getBiomeSystem() + .getBiomeForTile(tileX, tileZ, this.CONFIG.TILE_SIZE); } private getBiomeInfluencesAtPosition( worldX: number, worldZ: number, ): Array<{ type: string; weight: number }> { - const { biomeWeightMap, totalWeight } = this.computeBiomeWeightsAtPosition( - worldX, - worldZ, - ); - - const biomeInfluences: Array<{ type: string; weight: number }> = []; - if (totalWeight > 0) { - const invTotal = 1 / totalWeight; - for (const [type, weight] of biomeWeightMap) { - biomeInfluences.push({ type, weight: weight * invTotal }); - } - } else { - // Fallback to plains if no biome centers are nearby - biomeInfluences.push({ type: "plains", weight: 1.0 }); - } - - return biomeInfluences; + return this.terrainGenerator + .getBiomeSystem() + .getBiomeInfluencesAtPosition(worldX, worldZ, 0); } private computeBiomeWeightsAtPosition( worldX: number, worldZ: number, ): { biomeWeightMap: Map; totalWeight: number } { - // Get BASE height (without mountain boost) to avoid feedback loop - const baseHeight = this.getBaseHeightAt(worldX, worldZ); - const normalizedHeight = baseHeight / this.CONFIG.MAX_HEIGHT; - - // Add boundary noise for organic edges - const boundaryNoise = this.noise.simplex2D( - worldX * this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, - worldZ * this.CONFIG.BIOME_BOUNDARY_NOISE_SCALE, - ); - - // Map to collect and merge same-type biomes. - // Reusing one map avoids per-vertex map allocations during tile generation. - const biomeWeightMap = this.biomeWeightScratch; - biomeWeightMap.clear(); - - // Calculate influence from ALL biome centers (no hard cutoff!) - for (const center of this.biomeCenters) { - const dx = worldX - center.x; - const dz = worldZ - center.z; - const distance = Math.sqrt(dx * dx + dz * dz); - - // Add subtle noise to distance for organic boundaries - const noisyDistance = - distance * - (1 + boundaryNoise * this.CONFIG.BIOME_BOUNDARY_NOISE_AMOUNT); - - // Pure gaussian falloff - NO hard distance cutoff - // The gaussian naturally approaches 0 at large distances - const normalizedDistance = noisyDistance / center.influence; - let weight = Math.exp( - -normalizedDistance * - normalizedDistance * - this.CONFIG.BIOME_GAUSSIAN_COEFF, - ); - - // Height-based weight adjustments (using BASE height, not boosted) - if ( - center.type === "mountains" && - normalizedHeight > this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD - ) { - const heightFactor = - normalizedHeight - this.CONFIG.MOUNTAIN_HEIGHT_THRESHOLD; - weight *= 1.0 + heightFactor * this.CONFIG.MOUNTAIN_WEIGHT_BOOST; - } - - if ( - (center.type === "valley" || center.type === "plains") && - normalizedHeight < this.CONFIG.VALLEY_HEIGHT_THRESHOLD - ) { - const heightFactor = - this.CONFIG.VALLEY_HEIGHT_THRESHOLD - normalizedHeight; - weight *= 1.0 + heightFactor * this.CONFIG.VALLEY_WEIGHT_BOOST; - } - - // Merge same-type biomes (no threshold - let gaussian handle falloff) - const existing = biomeWeightMap.get(center.type) || 0; - biomeWeightMap.set(center.type, existing + weight); - } - + const influences = this.terrainGenerator + .getBiomeSystem() + .getBiomeInfluencesAtPosition(worldX, worldZ, 0); + const biomeWeightMap = new Map(); let totalWeight = 0; - for (const [type, weight] of biomeWeightMap) { - totalWeight += weight; + for (const inf of influences) { + biomeWeightMap.set(inf.type, inf.weight); + totalWeight += inf.weight; } - return { biomeWeightMap, totalWeight }; } - private getBiomeAtWorldPosition(worldX: number, worldZ: number): string { - const influences = this.getBiomeInfluencesAtPosition(worldX, worldZ); - return influences.length > 0 ? influences[0].type : "plains"; - } - - private getBiomeNoise(x: number, z: number): number { - // Simple noise function for biome determination - return ( - Math.sin(x * 2.1 + z * 1.7) * Math.cos(x * 1.3 - z * 2.4) * 0.5 + - Math.sin(x * 4.2 + z * 3.8) * Math.cos(x * 2.7 - z * 4.1) * 0.3 + - Math.sin(x * 8.1 - z * 6.2) * Math.cos(x * 5.9 + z * 7.3) * 0.2 - ); + computeBiomeWeightsByPosition( + worldX: number, + worldZ: number, + ): Record { + const influences = this.terrainGenerator + .getBiomeSystem() + .getBiomeInfluencesAtPosition(worldX, worldZ, 0); + const result: Record = {}; + for (const inf of influences) { + result[inf.type] = inf.weight; + } + if (influences.length === 0) { + result[DEFAULT_BIOME] = 1.0; + } + return result; } - // Map internal biome keys to generic TerrainTileData biome set - private mapBiomeToGeneric( - internal: string, - ): - | "forest" - | "plains" - | "desert" - | "mountains" - | "swamp" - | "tundra" - | "jungle" { - switch (internal) { - case "forest": - return "forest"; - case "plains": - return "plains"; - case "valley": - return "plains"; - case "mountains": - return "mountains"; - case "tundra": - return "tundra"; - case "desert": - return "desert"; - case "lakes": - return "swamp"; - case "swamp": - return "swamp"; - default: - return "plains"; - } + private getBiomeAtWorldPosition(worldX: number, worldZ: number): string { + return this.terrainGenerator + .getBiomeSystem() + .getDominantBiome(worldX, worldZ, 0); } private generateTileResources(tile: TerrainTile): void { @@ -4570,12 +4546,12 @@ export class TerrainSystem extends System { this.generateTreesForTile(tile, biomeData); // Re-enabled for ore LOD testing - this.generateOtherResourcesForTile(tile, biomeData); + // this.generateOtherResourcesForTile(tile, biomeData); // Generate decorative rocks (client only) const isClient = this.runtimeIsClient; if (isClient) { - this.generateDecorativeRocksForTile(tile, biomeData); + // this.generateDecorativeRocksForTile(tile, biomeData); // NOTE: Plants disabled - not working/looking good yet // this.generateDecorativePlantsForTile(tile, biomeData); } @@ -4680,41 +4656,22 @@ export class TerrainSystem extends System { // } // } - /** - * Default tree configuration for biomes without explicit tree config. - * Targets ~1 tree per 20m spacing (density 25 = ~10 trees per 64m tile). - * Reduced from 100 to prevent exceeding MAX_GLOBAL_LEAVES capacity (100k leaves, - * ~2000 leaves per tree = ~50 trees max visible at full detail). - */ - private static readonly DEFAULT_TREE_CONFIG: BiomeTreeConfig = { - enabled: true, - distribution: { - tree_normal: 40, // Quaking Aspen - tree_oak: 30, // Black Oak - tree_maple: 15, // Acer (Japanese Maple) - tree_willow: 10, // Weeping Willow - tree_yew: 5, // European Larch - }, - density: 20, // ~8 trees per 64m tile - minSpacing: 18, // Minimum 18m between trees for natural spacing - clustering: true, - clusterSize: 3, - scaleVariation: [0.8, 1.2], - }; - /** * Generate harvestable trees for a tile based on biome configuration. * Uses the extracted BiomeResourceGenerator for the actual algorithm. - * Falls back to DEFAULT_TREE_CONFIG if biome has no tree config. + * getTreeConfigForBiome always returns a config (falls back to forest). */ private generateTreesForTile(tile: TerrainTile, biomeData: BiomeData): void { - // Use biome tree config or fall back to defaults - const treeConfig = biomeData.trees ?? TerrainSystem.DEFAULT_TREE_CONFIG; + const treeConfig = biomeData.trees ?? getTreeConfigForBiome(tile.biome); if (!treeConfig.enabled) { return; } - // Create context for resource generation + // Create context for resource generation. + // getDominantBiome lets generateTrees() resolve the actual biome at each + // tree position instead of using the single tile-center biome, which + // prevents wrong tree types appearing near biome boundaries. + const biomeSystem = this.terrainGenerator.getBiomeSystem(); const ctx: ResourceGenerationContext = { tileX: tile.x, tileZ: tile.z, @@ -4726,6 +4683,8 @@ export class TerrainSystem extends System { ? (worldX, worldZ) => this.roadNetworkSystem!.isOnRoad(worldX, worldZ) : undefined, createRng: (salt) => this.createTileRng(tile.x, tile.z, salt), + getDominantBiome: (worldX, worldZ) => + biomeSystem.getDominantBiome(worldX, worldZ, 0), }; // Generate trees using the extracted algorithm @@ -4747,11 +4706,8 @@ export class TerrainSystem extends System { // Generate other resources (herbs, fishing spots) from legacy config const otherResources = biomeData.resources.filter( (r) => - r !== "tree" && - r !== "trees" && - r !== "ore" && - r !== "ores" && - r !== "rock", + // r !== "tree" && + r !== "trees" && r !== "ore" && r !== "ores" && r !== "rock", ); for (const resourceType of otherResources) { @@ -5041,6 +4997,15 @@ export class TerrainSystem extends System { this.processResourceInstanceQueue(); } + // Update quad-tree LOD visual manager (client only) + if (this.runtimeIsClient && this.quadTreeVisualManager) { + const centers = this.getTerrainCenters(); + if (centers.length > 0) { + const pos = centers[0].position; + this.quadTreeVisualManager.update(pos.x, pos.z); + } + } + // Update instance visibility on client based on player position if (this.runtimeIsClient && this.instancedMeshManager) { this.instancedMeshManager.updateAllInstanceVisibility(); @@ -5076,6 +5041,28 @@ export class TerrainSystem extends System { if (materialWithUniforms) { materialWithUniforms.terrainUniforms.time.value = this.terrainTime; + // Sync sun direction and shade color from Environment system + const env = this.world.getSystem("environment") as { + lightDirection?: THREE.Vector3; + hemisphereLight?: { color: THREE.Color }; + } | null; + if (env?.lightDirection) { + materialWithUniforms.terrainUniforms.sunDirection.value + .copy(env.lightDirection) + .negate(); + } + if (env?.hemisphereLight) { + const c = env.hemisphereLight.color; + const avg = (c.r + c.g + c.b) / 3; + if (avg > 0.01) { + (materialWithUniforms.terrainUniforms.shadeColor.value as any).set( + c.r / avg, + c.g / avg, + c.b / avg, + ); + } + } + // Fog texture is the shared fogRenderTarget from FogConfig — no sync needed // Update lamppost vertex lights (night-only) @@ -5595,7 +5582,16 @@ export class TerrainSystem extends System { // waterMesh is null if no underwater areas exist if (waterMesh && tile.mesh) { - tile.mesh.add(waterMesh); + if (this.CONFIG.USE_QUADTREE_LOD && this.terrainContainer) { + // Quad-tree LOD doesn't add tile.mesh to the scene, so parent the + // water mesh directly to terrainContainer with the tile's world pos. + // Preserve Y (water level) already set by WaterSystem.generateWaterMesh. + waterMesh.position.x = tile.x * this.CONFIG.TILE_SIZE; + waterMesh.position.z = tile.z * this.CONFIG.TILE_SIZE; + this.terrainContainer.add(waterMesh); + } else { + tile.mesh.add(waterMesh); + } tile.waterMeshes.push(waterMesh); } } @@ -5883,12 +5879,8 @@ export class TerrainSystem extends System { worldZ: number, overrideDifficultyLevel?: number, ): DifficultySample { - // Ensure noise and biome centers are initialized if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - if (this.biomeCenters.length === 0) { - this.initializeBiomeCenters(); - } } const biome = this.getBiomeAtWorldPosition(worldX, worldZ); @@ -6053,9 +6045,6 @@ export class TerrainSystem extends System { if (!this.noise) { this.noise = new NoiseGenerator(this.computeSeedFromWorldId()); - if (this.biomeCenters.length === 0) { - this.initializeBiomeCenters(); - } } const worldSizeMeters = this.getActiveWorldSizeMeters(); @@ -6167,7 +6156,7 @@ export class TerrainSystem extends System { tilesData.push({ tileX: tile.x, tileZ: tile.z, - biome: this.mapBiomeToGeneric(tile.biome), + biome: (tile.biome as BiomeType) || DEFAULT_BIOME, }); } @@ -6224,8 +6213,15 @@ export class TerrainSystem extends System { } destroy(): void { - // Terminate terrain worker pool to free resources + // Dispose quad-tree visual manager + if (this.quadTreeVisualManager) { + this.quadTreeVisualManager.dispose(); + this.quadTreeVisualManager = null; + } + + // Terminate worker pools to free resources terminateTerrainWorkerPool(); + terminateQuadChunkWorkerPool(); // Clear pending worker results this.pendingWorkerResults.clear(); @@ -6412,13 +6408,23 @@ export class TerrainSystem extends System { * Initialize chunk loading system with 9 core + ring strategy */ private initializeChunkLoadingSystem(): void { - const isStreamingViewport = isStreamingLikeViewport(); + const isEmbeddedSpectator = (() => { + if (typeof window === "undefined") return false; + const win = window as Window & { + __HYPERSCAPE_EMBEDDED__?: boolean; + __HYPERSCAPE_CONFIG__?: { mode?: string }; + }; + return ( + win.__HYPERSCAPE_EMBEDDED__ === true && + win.__HYPERSCAPE_CONFIG__?.mode === "spectator" + ); + })(); // world.isServer can be false during early bootstrap (before network mode // is finalized). Resolve runtime role explicitly so server startup always // uses tight headless chunk ranges. const { isServer: isServerRuntime } = this.resolveRuntimeRole(); - // Stream and spectator viewers prioritize first-frame time over long-range preload. + // Embedded spectator prioritizes first-frame time over long-range preload. if (isServerRuntime) { // Server does not render horizon terrain, so keep chunk windows tight to // avoid runaway memory when many autonomous agents are active. @@ -6427,10 +6433,10 @@ export class TerrainSystem extends System { this.terrainOnlyChunkRange = 0; // Never load render-only distant tiles this.maxTilesPerFrame = 2; this.generationBudgetMsPerFrame = 4; - } else if (isStreamingViewport) { + } else if (isEmbeddedSpectator) { this.coreChunkRange = 1; // 3x3 core grid this.ringChunkRange = 2; // Preload ring up to 5x5 - this.terrainOnlyChunkRange = 2; // Avoid far-horizon churn in stream/spectator view + this.terrainOnlyChunkRange = 2; // Avoid far-horizon churn in embedded stream view this.maxTilesPerFrame = 4; // Catch up faster after camera retargets this.generationBudgetMsPerFrame = 10; } else { @@ -6613,7 +6619,6 @@ export class TerrainSystem extends System { // Remove tiles that are no longer needed, with hysteresis padding // Approximate each player's center from their core chunk set - let unloadedCount = 0; for (const [tileKey, tile] of this.terrainTiles) { if (!neededTiles.has(tileKey)) { let minChebyshev = Infinity; @@ -6623,21 +6628,17 @@ export class TerrainSystem extends System { } if (minChebyshev > this.terrainOnlyChunkRange + this.unloadPadding) { this.unloadTile(tile); - unloadedCount++; } } } - // Log simulation status every 10 updates (~10 seconds) + // Log simulation status every 10 updates if (Math.random() < 0.1) { const _totalPlayers = centers.length; const _simulatedChunkCount = this.simulatedChunks.size; const _loadedChunkCount = this.terrainTiles.size; - // MEMORY DEBUG: Log terrain tile stats - console.log( - `[TerrainSystem] Tiles: ${_loadedChunkCount} loaded, ${unloadedCount} unloaded this cycle | Players: ${_totalPlayers} | PlayerCenters: ${playerCenters.length} | Needed: ${neededTiles.size}`, - ); + // Simulation status tracked for debugging // Log shared world status const sharedChunks = Array.from(this.chunkPlayerCounts.entries()) @@ -6821,6 +6822,13 @@ export class TerrainSystem extends System { lastSerializationTime: number; nextSerializationIn: number; worldStateVersion: number; + quadTreeLOD?: { + totalNodes: number; + visualChunks: number; + pendingWorkers: number; + settledQueue: number; + syncQueue: number; + }; } { return { totalChunks: this.terrainTiles.size, @@ -6831,6 +6839,7 @@ export class TerrainSystem extends System { nextSerializationIn: this.serializationInterval - (Date.now() - this.lastSerializationTime), worldStateVersion: this.worldStateVersion, + quadTreeLOD: this.quadTreeVisualManager?.getStats(), }; } diff --git a/packages/shared/src/systems/shared/world/TerrainVisualManager.ts b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts new file mode 100644 index 000000000..9f4391f3d --- /dev/null +++ b/packages/shared/src/systems/shared/world/TerrainVisualManager.ts @@ -0,0 +1,411 @@ +/** + * TerrainVisualManager — Manages the lifecycle of quad-tree terrain visual chunks. + * + * Listens to TerrainQuadTree events, dispatches heavy computation to + * QuadChunkWorker, assembles geometry on the main thread via + * TerrainQuadChunkGenerator, and adds/removes meshes from the scene. + * + * CLIENT-ONLY: Server terrain uses the flat tile grid. + */ + +import THREE from "../../../extras/three/three"; +import { + TerrainQuadTree, + type TerrainQuadNode, + type QuadTreeConfig, + type QuadTreeListener, +} from "./TerrainQuadTree"; +import { + assembleQuadChunkGeometry, + generateQuadChunkDataSync, + type FullTerrainProvider, +} from "./TerrainQuadChunkGenerator"; +import { + generateQuadChunkAsync, + isQuadChunkWorkerAvailable, + type QuadChunkWorkerConfig, + type QuadChunkWorkerInput, + type QuadChunkWorkerOutput, +} from "../../../utils/workers/QuadChunkWorker"; + +export type VisualManagerTerrainProvider = FullTerrainProvider; + +export interface TerrainVisualChunk { + key: string; + node: TerrainQuadNode; + mesh: THREE.Mesh; + heightData: Float32Array; +} + +interface SettledWorkerResult { + nodeId: number; + node: TerrainQuadNode; + result: QuadChunkWorkerOutput | null; + error: unknown; +} + +/** + * Orchestrates quad-tree LOD terrain rendering with async worker dispatch. + */ +export class TerrainVisualManager implements QuadTreeListener { + private quadTree: TerrainQuadTree; + private provider: VisualManagerTerrainProvider; + private container: THREE.Group; + private material: THREE.Material; + private chunks = new Map(); + private playerX = 0; + private playerZ = 0; + private debugWireframe: boolean; + private receiveShadow: boolean; + private castShadow: boolean; + private workerConfig: QuadChunkWorkerConfig; + private workerSeed: number; + private workerBiomeCenters: QuadChunkWorkerInput["biomeCenters"]; + private workerBiomes: QuadChunkWorkerInput["biomes"]; + private useWorkers: boolean; + + /** Node IDs with in-flight worker promises */ + private pendingNodeIds = new Set(); + /** Settled worker results ready for main-thread assembly */ + private settledResults: SettledWorkerResult[] = []; + /** Sync fallback queue (used when workers unavailable) */ + private syncQueue: TerrainQuadNode[] = []; + /** Destroyed node IDs that should be ignored when results come back */ + private cancelledNodeIds = new Set(); + /** Tracks generation failure count per node ID for bounded retry */ + private failedAttempts = new Map(); + + private maxSyncChunksPerFrame: number; + private maxAssembliesPerFrame: number; + + private static MAX_GENERATION_RETRIES = 3; + + constructor( + config: Partial, + provider: VisualManagerTerrainProvider, + container: THREE.Group, + material: THREE.Material, + workerConfig: QuadChunkWorkerConfig, + workerSeed: number, + workerBiomeCenters: QuadChunkWorkerInput["biomeCenters"], + workerBiomes: QuadChunkWorkerInput["biomes"], + debugWireframe = false, + receiveShadow = false, + castShadow = false, + maxSyncChunksPerFrame = 4, + maxAssembliesPerFrame = 6, + ) { + this.provider = provider; + this.container = container; + this.material = material; + this.debugWireframe = debugWireframe; + this.receiveShadow = receiveShadow; + this.castShadow = castShadow; + this.maxSyncChunksPerFrame = maxSyncChunksPerFrame; + this.maxAssembliesPerFrame = maxAssembliesPerFrame; + this.workerConfig = workerConfig; + this.workerSeed = workerSeed; + this.workerBiomeCenters = workerBiomeCenters; + this.workerBiomes = workerBiomes; + this.useWorkers = isQuadChunkWorkerAvailable(); + + this.quadTree = new TerrainQuadTree(config); + this.quadTree.setListener(this); + } + + update(playerX: number, playerZ: number): void { + this.playerX = playerX; + this.playerZ = playerZ; + this.quadTree.update(playerX, playerZ); + this.processSettledResults(); + this.processSyncQueue(); + } + + dispose(): void { + this.quadTree.dispose(); + for (const chunk of this.chunks.values()) { + this.removeMeshFromScene(chunk); + } + this.chunks.clear(); + this.pendingNodeIds.clear(); + this.settledResults = []; + this.syncQueue = []; + this.cancelledNodeIds.clear(); + this.failedAttempts.clear(); + + if (this.container.parent) { + this.container.parent.remove(this.container); + } + } + + getQuadTree(): TerrainQuadTree { + return this.quadTree; + } + + getChunks(): ReadonlyMap { + return this.chunks; + } + + getStats(): { + totalNodes: number; + visualChunks: number; + pendingWorkers: number; + settledQueue: number; + syncQueue: number; + } { + return { + totalNodes: this.quadTree.totalNodeCount, + visualChunks: this.chunks.size, + pendingWorkers: this.pendingNodeIds.size, + settledQueue: this.settledResults.length, + syncQueue: this.syncQueue.length, + }; + } + + updateBiomeData( + biomeCenters: QuadChunkWorkerInput["biomeCenters"], + biomes: QuadChunkWorkerInput["biomes"], + ): void { + this.workerBiomeCenters = biomeCenters; + this.workerBiomes = biomes; + } + + // ========================================================================= + // QuadTreeListener implementation + // ========================================================================= + + onNodeNeedsGeometry(node: TerrainQuadNode): void { + if (this.pendingNodeIds.has(node.id)) return; + + if (this.useWorkers) { + this.dispatchWorker(node); + } else { + if (!this.syncQueue.includes(node)) { + this.syncQueue.push(node); + } + } + } + + onNodeDestroyGeometry(node: TerrainQuadNode): void { + const key = node.visualChunkKey; + if (key !== null) { + const chunk = this.chunks.get(key); + if (chunk) { + this.removeMeshFromScene(chunk); + this.chunks.delete(key); + } + node.visualChunkKey = null; + } + if (this.pendingNodeIds.has(node.id)) { + this.cancelledNodeIds.add(node.id); + this.pendingNodeIds.delete(node.id); + } + const sqIdx = this.syncQueue.indexOf(node); + if (sqIdx !== -1) this.syncQueue.splice(sqIdx, 1); + this.failedAttempts.delete(node.id); + } + + // ========================================================================= + // Worker dispatch — fire-and-forget, results arrive in settledResults + // ========================================================================= + + private dispatchWorker(node: TerrainQuadNode): void { + const input: QuadChunkWorkerInput = { + type: "generateQuadChunk", + centerX: node.centerX, + centerZ: node.centerZ, + size: node.size, + resolution: node.resolution, + config: this.workerConfig, + seed: this.workerSeed, + biomeCenters: this.workerBiomeCenters, + biomes: this.workerBiomes, + }; + + this.pendingNodeIds.add(node.id); + const nodeId = node.id; + + generateQuadChunkAsync(input).then( + (result) => { + this.settledResults.push({ nodeId, node, result, error: null }); + this.pendingNodeIds.delete(nodeId); + }, + (error) => { + this.settledResults.push({ nodeId, node, result: null, error }); + this.pendingNodeIds.delete(nodeId); + }, + ); + } + + private processSettledResults(): void { + if (this.settledResults.length === 0) return; + + const batch = this.settledResults.splice(0, this.maxAssembliesPerFrame); + + for (const entry of batch) { + if (this.cancelledNodeIds.has(entry.nodeId)) { + this.cancelledNodeIds.delete(entry.nodeId); + continue; + } + + if (!entry.node.isFinal || entry.node.visualChunkKey !== null) continue; + + if (entry.error || !entry.result) { + if (entry.error) { + console.error("[TerrainVisualManager] Worker error:", entry.error); + } + this.generateChunkSync(entry.node); + continue; + } + + this.assembleAndAddChunk(entry.node, entry.result); + } + } + + private processSyncQueue(): void { + if (this.syncQueue.length === 0) return; + + let generated = 0; + while ( + this.syncQueue.length > 0 && + generated < this.maxSyncChunksPerFrame + ) { + const node = this.syncQueue.shift()!; + if (!node.isFinal || node.visualChunkKey !== null) continue; + this.generateChunkSync(node); + generated++; + } + } + + // ========================================================================= + // Chunk assembly + // ========================================================================= + + private assembleAndAddChunk( + node: TerrainQuadNode, + workerData: QuadChunkWorkerOutput, + ): void { + const key = this.makeChunkKey(node); + + let result; + try { + result = assembleQuadChunkGeometry( + workerData, + this.provider, + this.quadTree.config.skirtDrop, + ); + } catch (err) { + console.error(`[TerrainVisualManager] Assembly failed for ${key}:`, err); + this.handleGenerationFailure(node); + return; + } + + this.addMeshToScene(node, key, result); + } + + private generateChunkSync(node: TerrainQuadNode): void { + const key = this.makeChunkKey(node); + + let workerData: QuadChunkWorkerOutput; + try { + workerData = generateQuadChunkDataSync( + node.centerX, + node.centerZ, + node.size, + node.resolution, + this.provider, + ); + } catch (err) { + console.error( + `[TerrainVisualManager] Sync data generation failed for ${key}:`, + err, + ); + this.handleGenerationFailure(node); + return; + } + + this.assembleAndAddChunk(node, workerData); + } + + private handleGenerationFailure(node: TerrainQuadNode): void { + const attempts = (this.failedAttempts.get(node.id) ?? 0) + 1; + if (attempts < TerrainVisualManager.MAX_GENERATION_RETRIES) { + this.failedAttempts.set(node.id, attempts); + node.terrainNeedsUpdate = true; + } else { + console.error( + `[TerrainVisualManager] Giving up on node ${node.id} after ${attempts} attempts`, + ); + this.failedAttempts.delete(node.id); + } + } + + private makeChunkKey(node: TerrainQuadNode): string { + return `quad_${node.id}_d${node.depth}_${node.centerX.toFixed(0)}_${node.centerZ.toFixed(0)}`; + } + + private static DEBUG_DEPTH_COLORS = [ + 0xff0000, 0xff8800, 0xffff00, 0x00ccff, 0x00ff44, + ]; + + private addMeshToScene( + node: TerrainQuadNode, + key: string, + result: { geometry: THREE.BufferGeometry; heightData: Float32Array }, + ): void { + let meshMaterial: THREE.Material; + if (this.debugWireframe) { + const depthColor = + TerrainVisualManager.DEBUG_DEPTH_COLORS[ + Math.min( + node.depth, + TerrainVisualManager.DEBUG_DEPTH_COLORS.length - 1, + ) + ]; + meshMaterial = new THREE.MeshBasicMaterial({ + color: depthColor, + wireframe: true, + }); + } else { + meshMaterial = this.material; + } + + const mesh = new THREE.Mesh(result.geometry, meshMaterial); + mesh.position.set(node.centerX, 0, node.centerZ); + mesh.name = `QuadTerrain_${key}`; + mesh.receiveShadow = this.receiveShadow; + mesh.castShadow = this.castShadow; + mesh.frustumCulled = true; + + mesh.userData = { + type: "terrain", + walkable: true, + clickable: true, + quadNodeId: node.id, + depth: node.depth, + size: node.size, + resolution: node.resolution, + }; + + this.container.add(mesh); + + const chunk: TerrainVisualChunk = { + key, + node, + mesh, + heightData: result.heightData, + }; + + this.chunks.set(key, chunk); + node.visualChunkKey = key; + this.failedAttempts.delete(node.id); + node.testReady(); + } + + private removeMeshFromScene(chunk: TerrainVisualChunk): void { + if (chunk.mesh.parent) { + this.container.remove(chunk.mesh); + } + chunk.mesh.geometry.dispose(); + } +} diff --git a/packages/shared/src/systems/shared/world/TownSystem.ts b/packages/shared/src/systems/shared/world/TownSystem.ts index 1e5938103..e68452906 100644 --- a/packages/shared/src/systems/shared/world/TownSystem.ts +++ b/packages/shared/src/systems/shared/world/TownSystem.ts @@ -69,6 +69,7 @@ import { } from "../../../types/world/building-collision-types"; import { BFSPathfinder } from "../movement/BFSPathfinder"; import { TERRAIN_CONSTANTS } from "../../../constants/GameConstants"; +import { DEFAULT_BIOME } from "./TerrainBiomeTypes"; // Default configuration values // IMPORTANT: waterThreshold must match TERRAIN_CONSTANTS.WATER_THRESHOLD (9.0) @@ -95,14 +96,9 @@ const DEFAULT_TOWN_SIZES: Record = { }; const DEFAULT_BIOME_SUITABILITY: Record = { - plains: 1.0, - valley: 0.95, - forest: 0.7, + forest: 0.8, tundra: 0.4, - desert: 0.3, - swamp: 0.2, - mountains: 0.15, - lakes: 0.0, + canyon: 0.3, }; function isTruthyEnv(rawValue: string | undefined): boolean { @@ -320,7 +316,9 @@ export class TownSystem extends System { return this.terrainSystem?.getHeightAt(x, z) ?? 10; }, getBiomeAt: (x: number, z: number): string => { - return this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? "plains"; + return ( + this.terrainSystem?.getBiomeAtWorldPosition?.(x, z) ?? DEFAULT_BIOME + ); }, getWaterThreshold: (): number => { return this.config.waterThreshold; @@ -494,7 +492,7 @@ export class TownSystem extends System { this.terrainSystem?.getBiomeAtWorldPosition?.( manifest.position.x, manifest.position.z, - ) ?? "plains"; + ) ?? DEFAULT_BIOME; // Convert buildings - positions are relative in manifest, convert to world coords // Also calculate entrance positions for each building diff --git a/packages/shared/src/systems/shared/world/VegetationSystem.ts b/packages/shared/src/systems/shared/world/VegetationSystem.ts index de4f925d1..10fa136d3 100644 --- a/packages/shared/src/systems/shared/world/VegetationSystem.ts +++ b/packages/shared/src/systems/shared/world/VegetationSystem.ts @@ -48,12 +48,14 @@ import { } from "../../../utils/workers/VegetationWorker"; import { createGPUVegetationMaterial, + type GPUVegetationMaterial, +} from "./GPUMaterials"; +import { getLODDistances, getLODDistancesScaled, applyLODSettings, - type GPUVegetationMaterial, type LODDistancesWithSq, -} from "./GPUVegetation"; +} from "./LODConfig"; import { csmLevels } from "./Environment"; import type { RoadNetworkSystem } from "./RoadNetworkSystem"; import { updateTreeInstances } from "./ProcgenTreeCache"; @@ -120,7 +122,7 @@ let FADE_START = 180; // Shader fade begins (fully opaque inside) let FADE_END = 200; // Shader fully culls (invisible beyond) let CHUNK_RENDER_DISTANCE = 300; // CPU hides chunks (buffer zone for loading) -// NOTE: Per-category LOD distances are defined in GPUVegetation.ts LOD_DISTANCES +// NOTE: Per-category LOD distances are defined in LODConfig.ts LOD_DISTANCES // Access via getLODDistances(category) for actual LOD decisions. // The module-level FADE_START/FADE_END above are used for shared material creation. @@ -236,7 +238,7 @@ const DEFAULT_CONFIG: VegetationConfig = { }; /** - * LOD CONFIGURATION - Now uses unified system from GPUVegetation.ts + * LOD CONFIGURATION - Now uses unified system from LODConfig.ts * * The 3-tier LOD system: * - LOD0 (full detail): 0 to lod1Distance @@ -244,7 +246,7 @@ const DEFAULT_CONFIG: VegetationConfig = { * - Imposter (billboard): imposterDistance to fadeDistance * - Culled: beyond fadeDistance * - * Configuration is defined in GPUVegetation.ts LOD_DISTANCES and accessed via getLODDistances() + * Configuration is defined in LODConfig.ts LOD_DISTANCES and accessed via getLODDistances() */ /** @@ -778,7 +780,7 @@ export class VegetationSystem extends System { // Imposter distances - switch to billboard before dissolve zone // LOD transition: 3D mesh -> Billboard imposter -> Dissolve -> Cull - // Per-category LOD distances are defined in GPUVegetation.ts LOD_DISTANCES + // Per-category LOD distances are defined in LODConfig.ts LOD_DISTANCES // The shared material uses FADE_START/FADE_END for dissolve shader console.log( `[VegetationSystem] Synced with shadows "${shadowsLevel}": fade ${FADE_START.toFixed(0)}-${FADE_END.toFixed(0)}m, chunks ${CHUNK_RENDER_DISTANCE.toFixed(0)}m (per-category LOD via getLODDistances)`, @@ -1092,9 +1094,9 @@ export class VegetationSystem extends System { ) { const next = this.pendingTileRegenerations.entries().next().value as | [ - string, - { tileX: number; tileZ: number; biome: string; reason: string }, - ] + string, + { tileX: number; tileZ: number; biome: string; reason: string }, + ] | undefined; if (!next) break; @@ -2541,9 +2543,9 @@ export class VegetationSystem extends System { radius, ); if (!indexed) { - console.warn( - `[VegetationSystem] Chunk ${chunkKey} at (${centerX.toFixed(0)}, ${centerZ.toFixed(0)}) outside quadtree bounds - using fallback linear iteration`, - ); + // console.warn( + // `[VegetationSystem] Chunk ${chunkKey} at (${centerX.toFixed(0)}, ${centerZ.toFixed(0)}) outside quadtree bounds - using fallback linear iteration`, + // ); } } } @@ -3289,9 +3291,9 @@ export class VegetationSystem extends System { const createMesh = lodLevel === 1 ? (x: number, z: number) => - this.getOrCreateLOD1ChunkedMesh(x, z, assetId, assetDataRef) + this.getOrCreateLOD1ChunkedMesh(x, z, assetId, assetDataRef) : (x: number, z: number) => - this.getOrCreateLOD2ChunkedMesh(x, z, assetId, assetDataRef); + this.getOrCreateLOD2ChunkedMesh(x, z, assetId, assetDataRef); // Process up to MAX_LOD_CHUNKS_PER_FRAME chunks const endIndex = Math.min( @@ -3432,24 +3434,24 @@ export class VegetationSystem extends System { const cameraSystem = (this.world.getSystem("client-camera-system") as | { - getCameraInfo?: () => { - target?: { position?: THREE.Vector3 } | null; - }; - } + getCameraInfo?: () => { + target?: { position?: THREE.Vector3 } | null; + }; + } | undefined) ?? (this.world.getSystem("client-camera") as | { - getCameraInfo?: () => { - target?: { position?: THREE.Vector3 } | null; - }; - } + getCameraInfo?: () => { + target?: { position?: THREE.Vector3 } | null; + }; + } | undefined) ?? (this.world.getSystem("camera") as | { - getCameraInfo?: () => { - target?: { position?: THREE.Vector3 } | null; - }; - } + getCameraInfo?: () => { + target?: { position?: THREE.Vector3 } | null; + }; + } | undefined); const targetPos = cameraSystem?.getCameraInfo?.().target?.position; if (targetPos) { diff --git a/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts b/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts index e9108e39e..3c98d2a09 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BiomeConfigLoading.test.ts @@ -12,6 +12,7 @@ import fs from "fs"; import path from "path"; import { BIOMES } from "../../../../data/world-structure"; import type { BiomeData } from "../../../../types/core/core"; +import { BiomeType } from "../TerrainBiomeTypes"; /** * Get path to local biomes manifest for tests @@ -43,10 +44,10 @@ describe("Biome Configuration Loading", () => { ); const mockBiomes: Array = [ { - id: "plains", - name: "Plains", + id: "tundra", + name: "Tundra", difficultyLevel: 0, - terrain: { type: "plains" } as any, + terrain: BiomeType.Tundra as any, vegetation: { enabled: true, layers: [ @@ -61,7 +62,7 @@ describe("Biome Configuration Loading", () => { id: "forest", name: "Forest", difficultyLevel: 1, - terrain: { type: "forest" } as any, + terrain: BiomeType.Forest as any, vegetation: { enabled: true, layers: [ @@ -73,10 +74,10 @@ describe("Biome Configuration Loading", () => { colorScheme: {} as any, }, { - id: "mountains", - name: "Mountains", + id: "canyon", + name: "Canyon", difficultyLevel: 2, - terrain: { type: "mountains" } as any, + terrain: BiomeType.Canyon as any, colorScheme: {} as any, }, ]; diff --git a/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts b/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts index 469d7d8fa..b9d67d735 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BiomeResourceSpawning.test.ts @@ -92,10 +92,10 @@ describe("BiomeResourceGenerator", () => { describe("generateTrees", () => { const forestTreeConfig: BiomeTreeConfig = { enabled: true, - distribution: { - tree_normal: 0.5, - tree_oak: 0.35, - tree_willow: 0.15, + trees: { + tree_normal: { weight: 0.5 }, + tree_oak: { weight: 0.35 }, + tree_willow: { weight: 0.15 }, }, density: 8, minSpacing: 8, @@ -140,29 +140,29 @@ describe("BiomeResourceGenerator", () => { it("assigns correct level requirements from shared constants", () => { const ctx = createTestContext(0, 0); - const yewConfig: BiomeTreeConfig = { + const mapleConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_yew: 1.0 }, + trees: { tree_maple: { weight: 1.0 } }, density: 5, minSpacing: 5, clustering: false, }; - const trees = generateTrees(ctx, yewConfig); + const trees = generateTrees(ctx, mapleConfig); expect(trees.length).toBeGreaterThan(0); // Level should come from TREE_LEVEL_REQUIREMENTS constant expect( - trees.every((r) => r.requiredLevel === TREE_LEVEL_REQUIREMENTS.yew), + trees.every((r) => r.requiredLevel === TREE_LEVEL_REQUIREMENTS.maple), ).toBe(true); - expect(trees.every((r) => r.requiredLevel === 60)).toBe(true); + expect(trees.every((r) => r.requiredLevel === 45)).toBe(true); }); it("respects minimum spacing between trees", () => { const ctx = createTestContext(0, 0); const spacedConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 50, minSpacing: 20, clustering: false, @@ -394,19 +394,19 @@ describe("BiomeResourceGenerator", () => { }); describe("Level Requirements (Single Source of Truth)", () => { - it("tree level requirements match OSRS progression", () => { - // These should match the exported constants - expect(getTreeLevelRequirement("normal")).toBe(1); + it("tree level requirements match progression", () => { + // These should match the actual TREE_TYPES definitions + // Unknown types default to level 1 + expect(getTreeLevelRequirement("fir")).toBe(1); + expect(getTreeLevelRequirement("pine")).toBe(1); expect(getTreeLevelRequirement("oak")).toBe(15); - expect(getTreeLevelRequirement("willow")).toBe(30); - expect(getTreeLevelRequirement("teak")).toBe(35); + expect(getTreeLevelRequirement("birch")).toBe(1); expect(getTreeLevelRequirement("maple")).toBe(45); - expect(getTreeLevelRequirement("mahogany")).toBe(50); - expect(getTreeLevelRequirement("yew")).toBe(60); - expect(getTreeLevelRequirement("magic")).toBe(75); // Verify against exported constant - expect(getTreeLevelRequirement("yew")).toBe(TREE_LEVEL_REQUIREMENTS.yew); + expect(getTreeLevelRequirement("maple")).toBe( + TREE_LEVEL_REQUIREMENTS.maple, + ); }); it("ore level requirements match OSRS progression", () => { @@ -537,7 +537,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const emptyConfig: BiomeTreeConfig = { enabled: true, - distribution: {}, + trees: {}, density: 10, minSpacing: 5, clustering: false, @@ -551,7 +551,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const zeroWeightConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 0, tree_oak: 0 }, + trees: { tree_normal: { weight: 0 }, tree_oak: { weight: 0 } }, density: 10, minSpacing: 5, clustering: false, @@ -565,7 +565,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const zeroDensityConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 0, minSpacing: 5, clustering: false, @@ -579,7 +579,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(-5, -10); const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 5, minSpacing: 5, clustering: false, @@ -597,7 +597,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const noClusterSizeConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 5, minSpacing: 5, clustering: true, @@ -613,7 +613,7 @@ describe("BiomeResourceGenerator", () => { const ctx = createTestContext(0, 0); const highDensityConfig: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 100, // Very high minSpacing: 50, // Large spacing limits actual placement clustering: false, @@ -632,7 +632,7 @@ describe("BiomeResourceGenerator", () => { }); const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 10, minSpacing: 5, clustering: false, @@ -648,7 +648,7 @@ describe("BiomeResourceGenerator", () => { }); const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 10, minSpacing: 5, clustering: false, @@ -668,7 +668,7 @@ describe("BiomeResourceGenerator", () => { const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 10, minSpacing: 5, clustering: false, @@ -692,7 +692,7 @@ describe("BiomeResourceGenerator", () => { const config: BiomeTreeConfig = { enabled: true, - distribution: { tree_normal: 1.0 }, + trees: { tree_normal: { weight: 1.0 } }, density: 20, minSpacing: 5, clustering: false, diff --git a/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts b/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts index d13ebda31..38685d6fb 100644 --- a/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/BuildingTerrainInteraction.test.ts @@ -925,14 +925,14 @@ describe("TERRAIN_CONSTANTS Centralization", () => { it("should have consistent WATER_THRESHOLD value", async () => { const { TERRAIN_CONSTANTS } = await import("@hyperscape/shared"); - // WATER_THRESHOLD should be 9.0 (as per TerrainSystem) - expect(TERRAIN_CONSTANTS.WATER_THRESHOLD).toBe(9.0); + // WATER_THRESHOLD should be 8.0 (as per TerrainSystem) + expect(TERRAIN_CONSTANTS.WATER_THRESHOLD).toBe(8.0); }); it("should have consistent MAX_WALKABLE_SLOPE value", async () => { const { TERRAIN_CONSTANTS } = await import("@hyperscape/shared"); - // MAX_WALKABLE_SLOPE should be 0.7 (tan of ~35 degrees) - expect(TERRAIN_CONSTANTS.MAX_WALKABLE_SLOPE).toBe(0.7); + // MAX_WALKABLE_SLOPE should be 1.5 + expect(TERRAIN_CONSTANTS.MAX_WALKABLE_SLOPE).toBe(1.5); }); }); diff --git a/packages/shared/src/systems/shared/world/__tests__/GPUVegetation.test.ts b/packages/shared/src/systems/shared/world/__tests__/GPUMaterials.test.ts similarity index 99% rename from packages/shared/src/systems/shared/world/__tests__/GPUVegetation.test.ts rename to packages/shared/src/systems/shared/world/__tests__/GPUMaterials.test.ts index a1899e604..1987ec69a 100644 --- a/packages/shared/src/systems/shared/world/__tests__/GPUVegetation.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/GPUMaterials.test.ts @@ -1,5 +1,5 @@ /** - * GPUVegetation Unit Tests + * GPUMaterials & LODConfig Unit Tests * * Tests for GPU-driven dissolve rendering and unified LOD configuration: * - LOD distance configuration (getLODDistances, applyLODSettings) @@ -7,14 +7,11 @@ * - Imposter material creation and type guards * - Boundary conditions and edge cases * - Configuration caching and cache invalidation - * - * Based on packages/shared/src/systems/shared/world/GPUVegetation.ts */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import THREE from "../../../../extras/three/three"; import { - GPU_VEG_CONFIG, LOD_DISTANCES, DEFAULT_LOD_DISTANCES, LOD_REFERENCE_SIZE, @@ -26,13 +23,16 @@ import { calculateLODScaleFactor, clearLODDistanceCache, applyLODSettings, + type LODDistances, +} from "../LODConfig"; +import { + GPU_VEG_CONFIG, isDissolveMaterial, isImposterMaterial, createDissolveMaterial, createGPUVegetationMaterial, createImposterMaterial, - type LODDistances, -} from "../GPUVegetation"; +} from "../GPUMaterials"; /** * Helper to save and restore LOD_DISTANCES global state. @@ -114,7 +114,7 @@ describe("GPUVegetation", () => { [15, 7, 13, 5], ]; - // The shader formula (must match what's in GPUVegetation.ts) + // The shader formula (must match what's in GPUMaterials.ts) function bayerFormula(x: number, y: number): number { const bit0_x = x % 2; const bit1_x = Math.floor(x / 2); diff --git a/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts b/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts index 84c7ec8b0..c5503aaae 100644 --- a/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/ProcgenRocksPlants.test.ts @@ -20,6 +20,7 @@ import type { BiomeRockConfig, BiomePlantConfig, } from "../../../../types/world/world-types"; +import { BiomeType, BIOME_LIST } from "../TerrainBiomeTypes"; /** * Deterministic PRNG - creates seeded random for reproducible tests @@ -69,17 +70,7 @@ function createTestContext( describe("Rock Generation Algorithms", () => { describe("ROCK_BIOME_DEFAULTS", () => { it("has presets defined for all major biome types", () => { - const expectedBiomes = [ - "forest", - "plains", - "desert", - "mountains", - "swamp", - "frozen", - "wastes", - "corrupted", - "lake", - ]; + const expectedBiomes = BIOME_LIST; for (const biome of expectedBiomes) { expect(ROCK_BIOME_DEFAULTS[biome]).toBeDefined(); @@ -96,25 +87,25 @@ describe("Rock Generation Algorithms", () => { expect(forestRocks.presets).toContain("granite"); }); - it("desert biome has appropriate rock types", () => { - const desertRocks = ROCK_BIOME_DEFAULTS.desert; - expect(desertRocks.presets).toContain("sandstone"); + it("canyon biome has appropriate rock types", () => { + const canyonRocks = ROCK_BIOME_DEFAULTS.canyon; + expect(canyonRocks.presets).toContain("sandstone"); }); - it("corrupted biome has unique rock types", () => { - const corruptedRocks = ROCK_BIOME_DEFAULTS.corrupted; - expect(corruptedRocks.presets).toContain("obsidian"); - expect(corruptedRocks.presets).toContain("crystal"); + it("tundra biome has appropriate rock types", () => { + const tundraRocks = ROCK_BIOME_DEFAULTS.tundra; + expect(tundraRocks.presets).toContain("granite"); + expect(tundraRocks.presets).toContain("basalt"); }); }); describe("getRockPresetsForBiome", () => { it("returns correct presets for known biomes", () => { - const forestPresets = getRockPresetsForBiome("forest"); + const forestPresets = getRockPresetsForBiome(BiomeType.Forest); expect(forestPresets.presets).toContain("boulder"); - const desertPresets = getRockPresetsForBiome("desert"); - expect(desertPresets.presets).toContain("sandstone"); + const canyonPresets = getRockPresetsForBiome(BiomeType.Canyon); + expect(canyonPresets.presets).toContain("sandstone"); }); it("returns default presets for unknown biome", () => { @@ -123,7 +114,7 @@ describe("Rock Generation Algorithms", () => { }); it("is case-insensitive", () => { - const lower = getRockPresetsForBiome("forest"); + const lower = getRockPresetsForBiome(BiomeType.Forest); const upper = getRockPresetsForBiome("FOREST"); expect(lower.presets).toEqual(upper.presets); }); @@ -146,29 +137,29 @@ describe("Rock Generation Algorithms", () => { }); it("generates rocks when enabled", () => { - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); }); it("returns empty array when disabled", () => { rockConfig.enabled = false; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); it("respects density setting", () => { rockConfig.density = 5; - const rocksLow = generateRocks(ctx, rockConfig, "forest"); + const rocksLow = generateRocks(ctx, rockConfig, BiomeType.Forest); rockConfig.density = 20; - const rocksHigh = generateRocks(ctx, rockConfig, "forest"); + const rocksHigh = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocksHigh.length).toBeGreaterThan(rocksLow.length); }); it("generates deterministic results for same tile", () => { - const rocks1 = generateRocks(ctx, rockConfig, "forest"); - const rocks2 = generateRocks(ctx, rockConfig, "forest"); + const rocks1 = generateRocks(ctx, rockConfig, BiomeType.Forest); + const rocks2 = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks1.length).toBe(rocks2.length); @@ -182,8 +173,8 @@ describe("Rock Generation Algorithms", () => { it("generates different results for different tiles", () => { const ctx2 = createTestContext(1, 0); - const rocks1 = generateRocks(ctx, rockConfig, "forest"); - const rocks2 = generateRocks(ctx2, rockConfig, "forest"); + const rocks1 = generateRocks(ctx, rockConfig, BiomeType.Forest); + const rocks2 = generateRocks(ctx2, rockConfig, BiomeType.Forest); // Positions should differ if (rocks1.length > 0 && rocks2.length > 0) { @@ -193,7 +184,7 @@ describe("Rock Generation Algorithms", () => { it("uses presets from config", () => { rockConfig.presets = ["granite"]; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.assetId).toBe("granite"); @@ -202,7 +193,7 @@ describe("Rock Generation Algorithms", () => { it("falls back to biome presets when config presets is empty", () => { rockConfig.presets = []; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // Should use forest biome presets for (const rock of rocks) { @@ -212,7 +203,7 @@ describe("Rock Generation Algorithms", () => { it("applies scale within range", () => { rockConfig.scaleRange = [0.2, 0.8]; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.scale).toBeGreaterThanOrEqual(0.2); @@ -221,7 +212,7 @@ describe("Rock Generation Algorithms", () => { }); it("sets category to rock", () => { - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.category).toBe("rock"); @@ -229,7 +220,7 @@ describe("Rock Generation Algorithms", () => { }); it("sets tileKey correctly", () => { - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.tileKey).toBe("0_0"); @@ -242,7 +233,7 @@ describe("Rock Generation Algorithms", () => { getHeightAt: (x, _z) => (x < 50 ? 5 : 15), // Left half underwater }); - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // All rocks should be on the right side (above water) for (const rock of rocks) { @@ -255,7 +246,7 @@ describe("Rock Generation Algorithms", () => { isOnRoad: (x, _z) => x > 40 && x < 60, // Road in middle }); - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // No rocks should be on the road for (const rock of rocks) { @@ -267,7 +258,7 @@ describe("Rock Generation Algorithms", () => { it("respects minimum spacing", () => { rockConfig.minSpacing = 10; rockConfig.density = 50; // High density to test spacing - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // Check spacing between all pairs for (let i = 0; i < rocks.length; i++) { @@ -285,15 +276,7 @@ describe("Rock Generation Algorithms", () => { describe("Plant Generation Algorithms", () => { describe("PLANT_BIOME_DEFAULTS", () => { it("has presets defined for all major biome types", () => { - const expectedBiomes = [ - "forest", - "plains", - "desert", - "mountains", - "swamp", - "frozen", - "lake", - ]; + const expectedBiomes = BIOME_LIST; for (const biome of expectedBiomes) { expect(PLANT_BIOME_DEFAULTS[biome]).toBeDefined(); @@ -307,20 +290,20 @@ describe("Plant Generation Algorithms", () => { expect(forestPlants.presets).toContain("philodendron"); }); - it("swamp biome has water-loving plants", () => { - const swampPlants = PLANT_BIOME_DEFAULTS.swamp; - expect(swampPlants.presets).toContain("colocasia"); - expect(swampPlants.presets).toContain("spathiphyllum"); + it("tundra biome has hardy plants", () => { + const tundraPlants = PLANT_BIOME_DEFAULTS.tundra; + expect(tundraPlants.presets).toContain("bergenia"); + expect(tundraPlants.presets).toContain("pulmonaria"); }); }); describe("getPlantPresetsForBiome", () => { it("returns correct presets for known biomes", () => { - const forestPresets = getPlantPresetsForBiome("forest"); + const forestPresets = getPlantPresetsForBiome(BiomeType.Forest); expect(forestPresets.presets).toContain("monstera"); - const swampPresets = getPlantPresetsForBiome("swamp"); - expect(swampPresets.presets).toContain("colocasia"); + const canyonPresets = getPlantPresetsForBiome(BiomeType.Canyon); + expect(canyonPresets.presets).toContain("zamioculcas"); }); it("returns default presets for unknown biome", () => { @@ -347,29 +330,29 @@ describe("Plant Generation Algorithms", () => { }); it("generates plants when enabled", () => { - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); }); it("returns empty array when disabled", () => { plantConfig.enabled = false; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBe(0); }); it("respects density setting", () => { plantConfig.density = 5; - const plantsLow = generatePlants(ctx, plantConfig, "forest"); + const plantsLow = generatePlants(ctx, plantConfig, BiomeType.Forest); plantConfig.density = 30; - const plantsHigh = generatePlants(ctx, plantConfig, "forest"); + const plantsHigh = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plantsHigh.length).toBeGreaterThan(plantsLow.length); }); it("generates deterministic results for same tile", () => { - const plants1 = generatePlants(ctx, plantConfig, "forest"); - const plants2 = generatePlants(ctx, plantConfig, "forest"); + const plants1 = generatePlants(ctx, plantConfig, BiomeType.Forest); + const plants2 = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants1.length).toBe(plants2.length); @@ -381,7 +364,7 @@ describe("Plant Generation Algorithms", () => { it("uses presets from config", () => { plantConfig.presets = ["calathea"]; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (const plant of plants) { expect(plant.assetId).toBe("calathea"); @@ -390,7 +373,7 @@ describe("Plant Generation Algorithms", () => { it("applies scale within range", () => { plantConfig.scaleRange = [0.3, 0.6]; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (const plant of plants) { expect(plant.scale).toBeGreaterThanOrEqual(0.3); @@ -399,7 +382,7 @@ describe("Plant Generation Algorithms", () => { }); it("sets category to plant", () => { - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (const plant of plants) { expect(plant.category).toBe("plant"); @@ -412,7 +395,7 @@ describe("Plant Generation Algorithms", () => { getHeightAt: (x, _z) => (x < 50 ? 5 : 15), }); - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (const plant of plants) { expect(plant.position.x).toBeGreaterThanOrEqual(50); @@ -423,7 +406,7 @@ describe("Plant Generation Algorithms", () => { plantConfig.minSpacing = 5; plantConfig.density = 50; plantConfig.clustering = false; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); for (let i = 0; i < plants.length; i++) { for (let j = i + 1; j < plants.length; j++) { @@ -458,8 +441,8 @@ describe("Biome Integration", () => { minSpacing: 1.5, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); - const plants = generatePlants(ctx, plantConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); expect(plants.length).toBeGreaterThan(0); @@ -472,7 +455,7 @@ describe("Biome Integration", () => { it("different biomes produce different vegetation", () => { const forestCtx = createTestContext(0, 0); - const desertCtx = createTestContext(10, 10); + const canyonCtx = createTestContext(10, 10); const rockConfig: BiomeRockConfig = { enabled: true, @@ -483,15 +466,15 @@ describe("Biome Integration", () => { minSpacing: 2, }; - const forestRocks = generateRocks(forestCtx, rockConfig, "forest"); - const desertRocks = generateRocks(desertCtx, rockConfig, "desert"); + const forestRocks = generateRocks(forestCtx, rockConfig, BiomeType.Forest); + const canyonRocks = generateRocks(canyonCtx, rockConfig, BiomeType.Canyon); // Should use different rock types const forestTypes = new Set(forestRocks.map((r) => r.assetId)); - const desertTypes = new Set(desertRocks.map((r) => r.assetId)); + const canyonTypes = new Set(canyonRocks.map((r) => r.assetId)); - // Desert should have sandstone, forest should not - expect(desertTypes.has("sandstone")).toBe(true); + // Canyon should have sandstone, forest should not + expect(canyonTypes.has("sandstone")).toBe(true); expect(forestTypes.has("sandstone")).toBe(false); }); }); @@ -509,7 +492,7 @@ describe("ID Generation", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); const ids = rocks.map((r) => r.id); const uniqueIds = new Set(ids); @@ -528,7 +511,7 @@ describe("ID Generation", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { expect(rock.id).toContain("3_7"); @@ -553,7 +536,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); @@ -568,7 +551,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 10, // Large spacing will limit actual count }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // Spacing should limit the count significantly // 50x50 tile with 10m spacing can fit ~25 rocks maximum expect(rocks.length).toBeLessThan(30); @@ -586,7 +569,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); }); @@ -603,7 +586,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // Position should be in negative world space @@ -625,7 +608,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // Positions should be in far positive world space @@ -648,7 +631,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -667,7 +650,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -687,7 +670,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -709,7 +692,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 0, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // With zero spacing, rocks can be placed anywhere }); @@ -725,7 +708,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 100, // Larger than tile }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); // Should only be able to place 1 rock max expect(rocks.length).toBeLessThanOrEqual(1); }); @@ -743,7 +726,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // Rocks should be more evenly distributed }); @@ -760,7 +743,7 @@ describe("Boundary Conditions - Rocks", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); }); }); @@ -778,7 +761,7 @@ describe("Boundary Conditions - Plants", () => { minSpacing: 1.5, }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBe(0); }); }); @@ -795,7 +778,7 @@ describe("Boundary Conditions - Plants", () => { clustering: false, }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); }); @@ -811,7 +794,7 @@ describe("Boundary Conditions - Plants", () => { clusterSize: [5, 10], }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); }); }); @@ -834,12 +817,12 @@ describe("Error Handling - Invalid Inputs", () => { minSpacing: 2, }; - // Unknown biome should use plains fallback, which has presets + // Unknown biome should use forest fallback, which has presets const rocks = generateRocks(ctx, rockConfig, "unknown_xyz_biome"); expect(rocks.length).toBeGreaterThan(0); - // Should use plains defaults (boulder, pebble, sandstone) - const validPresets = ["boulder", "pebble", "sandstone"]; + // Should use forest defaults (boulder, granite, limestone) + const validPresets = ["boulder", "granite", "limestone"]; for (const rock of rocks) { expect(validPresets).toContain(rock.assetId); } @@ -856,7 +839,7 @@ describe("Error Handling - Invalid Inputs", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -878,7 +861,7 @@ describe("Error Handling - Invalid Inputs", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); // All presets should appear (granite/limestone get weight 1) @@ -908,7 +891,7 @@ describe("Error Handling - Invalid Inputs", () => { minSpacing: 1, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(10); // Count each type @@ -943,7 +926,7 @@ describe("Data Verification", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -965,7 +948,7 @@ describe("Data Verification", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); const minX = 5 * tileSize; const maxX = 6 * tileSize; @@ -993,7 +976,7 @@ describe("Data Verification", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -1015,7 +998,7 @@ describe("Data Verification", () => { minSpacing: 1.5, }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); for (const plant of plants) { @@ -1040,7 +1023,7 @@ describe("Data Verification", () => { minSpacing: 1, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(20); const boulderCount = rocks.filter((r) => r.assetId === "boulder").length; @@ -1075,7 +1058,7 @@ describe("Terrain Constraints", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); }); @@ -1095,7 +1078,7 @@ describe("Terrain Constraints", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBe(0); }); }); @@ -1128,7 +1111,7 @@ describe("Terrain Constraints", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); for (const rock of rocks) { const localX = rock.position.x % 100; @@ -1157,7 +1140,7 @@ describe("Terrain Constraints", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); }); }); @@ -1182,9 +1165,9 @@ describe("Concurrent Generation", () => { minSpacing: 2, }; - const rocks1 = generateRocks(ctx1, rockConfig, "forest"); - const rocks2 = generateRocks(ctx2, rockConfig, "forest"); - const rocks3 = generateRocks(ctx3, rockConfig, "forest"); + const rocks1 = generateRocks(ctx1, rockConfig, BiomeType.Forest); + const rocks2 = generateRocks(ctx2, rockConfig, BiomeType.Forest); + const rocks3 = generateRocks(ctx3, rockConfig, BiomeType.Forest); expect(rocks1.length).toBe(rocks2.length); expect(rocks2.length).toBe(rocks3.length); @@ -1216,7 +1199,7 @@ describe("Concurrent Generation", () => { }; const results = tiles.map((ctx) => - generateRocks(ctx, rockConfig, "forest"), + generateRocks(ctx, rockConfig, BiomeType.Forest), ); // Each tile should have different positions @@ -1245,7 +1228,7 @@ describe("VegetationInstance Structure", () => { minSpacing: 2, }; - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); expect(rocks.length).toBeGreaterThan(0); for (const rock of rocks) { @@ -1285,7 +1268,7 @@ describe("VegetationInstance Structure", () => { minSpacing: 1.5, }; - const plants = generatePlants(ctx, plantConfig, "forest"); + const plants = generatePlants(ctx, plantConfig, BiomeType.Forest); expect(plants.length).toBeGreaterThan(0); for (const plant of plants) { @@ -1328,10 +1311,10 @@ describe("Biome Defaults Completeness", () => { } }); - it("mountain and mountains aliases have same presets", () => { - const mountainPresets = getRockPresetsForBiome("mountain"); - const mountainsPresets = getRockPresetsForBiome("mountains"); - expect(mountainPresets.presets).toEqual(mountainsPresets.presets); + it("unknown biomes fall back to forest presets", () => { + const unknownPresets = getRockPresetsForBiome("nonexistent"); + const forestPresets = getRockPresetsForBiome(BiomeType.Forest); + expect(unknownPresets.presets).toEqual(forestPresets.presets); }); }); @@ -1428,7 +1411,7 @@ describe("ProcgenRockCache Exports", () => { describe("getCacheRockPresets", () => { it("returns array for known biomes", () => { - const presets = getCacheRockPresets("forest"); + const presets = getCacheRockPresets(BiomeType.Forest); expect(Array.isArray(presets)).toBe(true); expect(presets.length).toBeGreaterThan(0); }); @@ -1479,9 +1462,9 @@ describe("ProcgenPlantCache Exports", () => { } }); - it("includes tropical biome", () => { - expect(BIOME_PLANT_PRESETS.tropical).toBeDefined(); - expect(BIOME_PLANT_PRESETS.tropical.length).toBeGreaterThan(0); + it("includes canyon biome", () => { + expect(BIOME_PLANT_PRESETS.canyon).toBeDefined(); + expect(BIOME_PLANT_PRESETS.canyon.length).toBeGreaterThan(0); }); }); @@ -1501,7 +1484,7 @@ describe("ProcgenPlantCache Exports", () => { describe("getCachePlantPresets", () => { it("returns array for known biomes", () => { - const presets = getCachePlantPresets("swamp"); + const presets = getCachePlantPresets(BiomeType.Canyon); expect(Array.isArray(presets)).toBe(true); expect(presets.length).toBeGreaterThan(0); }); @@ -1531,7 +1514,7 @@ describe("Performance Characteristics", () => { }; const start = performance.now(); - const rocks = generateRocks(ctx, rockConfig, "forest"); + const rocks = generateRocks(ctx, rockConfig, BiomeType.Forest); const elapsed = performance.now() - start; expect(rocks.length).toBeGreaterThan(0); @@ -1552,7 +1535,7 @@ describe("Performance Characteristics", () => { const start = performance.now(); for (let i = 0; i < 10; i++) { const ctx = createTestContext(i, i); - generateRocks(ctx, rockConfig, "forest"); + generateRocks(ctx, rockConfig, BiomeType.Forest); } const elapsed = performance.now() - start; diff --git a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts index 4b96ba9fe..49d2beb96 100644 --- a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystem.test.ts @@ -4,6 +4,7 @@ */ import { describe, it, expect } from "vitest"; +import { BiomeType, BIOME_LIST } from "../TerrainBiomeTypes"; // ============== Types for testing ============== type TileEdge = "north" | "south" | "east" | "west"; @@ -27,14 +28,9 @@ const COST_BASE = 1.0; const COST_SLOPE_MULTIPLIER = 5.0; const COST_WATER_PENALTY = 1000; const COST_BIOME_MULTIPLIER: Record = { - plains: 1.0, - valley: 1.0, - forest: 1.3, + forest: 1.0, tundra: 1.5, - desert: 2.0, - swamp: 2.5, - mountains: 3.0, - lakes: 100, + canyon: 2.0, }; const SMOOTHING_ITERATIONS = 2; @@ -474,33 +470,33 @@ describe("RoadNetworkSystem Algorithms", () => { it("biome affects cost", () => { const flatHeight = () => 10; - const plainsCost = calculateMovementCost( + const forestCost = calculateMovementCost( 0, 0, 10, 0, flatHeight, - () => "plains", + () => BiomeType.Forest, ); - const swampCost = calculateMovementCost( + const tundraCost = calculateMovementCost( 0, 0, 10, 0, flatHeight, - () => "swamp", + () => BiomeType.Tundra, ); - const mountainsCost = calculateMovementCost( + const canyonCost = calculateMovementCost( 0, 0, 10, 0, flatHeight, - () => "mountains", + () => BiomeType.Canyon, ); - expect(swampCost).toBeGreaterThan(plainsCost); - expect(mountainsCost).toBeGreaterThan(swampCost); + expect(tundraCost).toBeGreaterThan(forestCost); + expect(canyonCost).toBeGreaterThan(tundraCost); }); it("diagonal movement costs more than cardinal", () => { @@ -1873,7 +1869,7 @@ describe("RoadNetworkSystem Algorithms", () => { const biome = (x: number, z: number) => { const tx = Math.floor(x / 50); const tz = Math.floor(z / 50); - const biomes = ["plains", "forest", "desert", "mountains"]; + const biomes = BIOME_LIST; return biomes[(tx + tz) % biomes.length]; }; diff --git a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystemConfigLoading.test.ts b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystemConfigLoading.test.ts index d0f45bd9b..e75810afa 100644 --- a/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystemConfigLoading.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/RoadNetworkSystemConfigLoading.test.ts @@ -22,14 +22,9 @@ const DEFAULTS = { } as const; const DEFAULT_BIOME_COSTS: Record = { - plains: 1.0, - valley: 1.0, - forest: 1.3, + forest: 1.0, tundra: 1.5, - desert: 2.0, - swamp: 2.5, - mountains: 3.0, - lakes: 100, + canyon: 2.0, }; function makeConfig( @@ -112,8 +107,8 @@ describe("RoadNetworkSystem Config Loading", () => { expect(config.roadWidth).toBe(DEFAULTS.roadWidth); expect(config.pathStepSize).toBe(DEFAULTS.pathStepSize); expect(config.costWaterPenalty).toBe(DEFAULTS.costWaterPenalty); - expect(config.biomeCosts.plains).toBe(DEFAULT_BIOME_COSTS.plains); - expect(config.biomeCosts.lakes).toBe(DEFAULT_BIOME_COSTS.lakes); + expect(config.biomeCosts.forest).toBe(DEFAULT_BIOME_COSTS.forest); + expect(config.biomeCosts.canyon).toBe(DEFAULT_BIOME_COSTS.canyon); }); }); @@ -128,7 +123,7 @@ describe("RoadNetworkSystem Config Loading", () => { extraConnectionsRatio: 0.35, costBase: 1.5, costWaterPenalty: 1500, - costBiomeMultipliers: { plains: 0.8, desert: 3.0, swamp: 5.0 }, + costBiomeMultipliers: { forest: 0.8, canyon: 3.0, tundra: 5.0 }, }, }), ); @@ -136,9 +131,9 @@ describe("RoadNetworkSystem Config Loading", () => { expect(config.roadWidth).toBe(6); expect(config.pathStepSize).toBe(25); - expect(config.biomeCosts.plains).toBe(0.8); - expect(config.biomeCosts.desert).toBe(3.0); - expect(config.biomeCosts.valley).toBe(DEFAULT_BIOME_COSTS.valley); + expect(config.biomeCosts.forest).toBe(0.8); + expect(config.biomeCosts.canyon).toBe(3.0); + expect(config.biomeCosts.tundra).toBe(5.0); }); }); @@ -160,7 +155,7 @@ describe("RoadNetworkSystem Config Loading", () => { expect(config.roadWidth).toBe(8); expect(config.pathStepSize).toBe(DEFAULTS.pathStepSize); - expect(config.biomeCosts.plains).toBe(DEFAULT_BIOME_COSTS.plains); + expect(config.biomeCosts.forest).toBe(DEFAULT_BIOME_COSTS.forest); }); }); diff --git a/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts b/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts index 2172a8d41..5f43a4d64 100644 --- a/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/TownRoadIntegration.test.ts @@ -11,6 +11,7 @@ */ import { describe, it, expect, beforeEach } from "vitest"; +import { BiomeType } from "../TerrainBiomeTypes"; // ============== Constants ============== const TOWN_COUNT = 25; @@ -596,10 +597,9 @@ class MockTerrainSystem { getBiome(x: number, z: number): string { const noise = Math.sin(x * 0.005 + z * 0.003 + this.seed); - if (noise > 0.3) return "plains"; - if (noise > 0) return "forest"; - if (noise > -0.3) return "valley"; - return "mountains"; + if (noise > 0.3) return BiomeType.Forest; + if (noise > 0) return BiomeType.Tundra; + return BiomeType.Canyon; } } diff --git a/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts b/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts index 64ab6c451..ed6bbf976 100644 --- a/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/TownSystem.test.ts @@ -4,6 +4,7 @@ */ import { describe, it, expect } from "vitest"; +import { BiomeType, BIOME_LIST } from "../TerrainBiomeTypes"; // ============== Constants (must match TownSystem.ts) ============== const TOWN_COUNT = 25; @@ -11,8 +12,8 @@ const WORLD_SIZE = 10000; const MIN_TOWN_SPACING = 800; const FLATNESS_SAMPLE_RADIUS = 40; const FLATNESS_SAMPLE_COUNT = 16; -// IMPORTANT: This must match TERRAIN_CONSTANTS.WATER_THRESHOLD (9.0) -const WATER_THRESHOLD = 9.0; +// IMPORTANT: This must match TERRAIN_CONSTANTS.WATER_THRESHOLD (8.0) +const WATER_THRESHOLD = 8.0; const OPTIMAL_WATER_DISTANCE_MIN = 30; const OPTIMAL_WATER_DISTANCE_MAX = 150; @@ -48,14 +49,9 @@ const BUILDING_CONFIG: Record< }; const BIOME_SUITABILITY: Record = { - plains: 1.0, - valley: 0.95, - forest: 0.7, + forest: 0.8, tundra: 0.4, - desert: 0.3, - swamp: 0.2, - mountains: 0.15, - lakes: 0.0, + canyon: 0.3, }; const NAME_PREFIXES = [ @@ -533,25 +529,12 @@ describe("TownSystem Algorithms", () => { }); describe("Biome Suitability", () => { - it("plains is most suitable", () => { - expect(BIOME_SUITABILITY.plains).toBe(1.0); - }); - - it("water is completely unsuitable", () => { - expect(BIOME_SUITABILITY.lakes).toBe(0.0); + it("forest is most suitable", () => { + expect(BIOME_SUITABILITY.forest).toBe(0.8); }); it("all biomes have defined suitability", () => { - const biomes = [ - "plains", - "valley", - "forest", - "tundra", - "desert", - "swamp", - "mountains", - "lakes", - ]; + const biomes = BIOME_LIST; for (const biome of biomes) { expect(BIOME_SUITABILITY[biome]).toBeDefined(); expect(BIOME_SUITABILITY[biome]).toBeGreaterThanOrEqual(0); @@ -560,14 +543,13 @@ describe("TownSystem Algorithms", () => { }); it("suitability reflects logical preferences", () => { - // Plains > forest > mountains makes sense for town building - expect(BIOME_SUITABILITY.plains).toBeGreaterThan( - BIOME_SUITABILITY.forest, - ); + // Forest > tundra > canyon makes sense for town building expect(BIOME_SUITABILITY.forest).toBeGreaterThan( - BIOME_SUITABILITY.mountains, + BIOME_SUITABILITY.tundra, + ); + expect(BIOME_SUITABILITY.tundra).toBeGreaterThan( + BIOME_SUITABILITY.canyon, ); - expect(BIOME_SUITABILITY.valley).toBeGreaterThan(BIOME_SUITABILITY.swamp); }); }); diff --git a/packages/shared/src/systems/shared/world/__tests__/TownSystemConfigLoading.test.ts b/packages/shared/src/systems/shared/world/__tests__/TownSystemConfigLoading.test.ts index 1e5df4cbf..ab72fc16b 100644 --- a/packages/shared/src/systems/shared/world/__tests__/TownSystemConfigLoading.test.ts +++ b/packages/shared/src/systems/shared/world/__tests__/TownSystemConfigLoading.test.ts @@ -29,14 +29,9 @@ const DEFAULT_TOWN_SIZES = { }; const DEFAULT_BIOME_SUITABILITY: Record = { - plains: 1.0, - valley: 0.95, - forest: 0.7, + forest: 0.8, tundra: 0.4, - desert: 0.3, - swamp: 0.2, - mountains: 0.15, - lakes: 0.0, + canyon: 0.3, }; // Factory for creating test configs with minimal boilerplate @@ -125,8 +120,8 @@ describe("TownSystem Config Loading", () => { expect(config.townSizes.hamlet.buildingCount.min).toBe( DEFAULT_TOWN_SIZES.hamlet.buildingCount.min, ); - expect(config.biomeSuitability.plains).toBe( - DEFAULT_BIOME_SUITABILITY.plains, + expect(config.biomeSuitability.forest).toBe( + DEFAULT_BIOME_SUITABILITY.forest, ); expect(config.landmarks).toEqual(DEFAULT_LANDMARK_CONFIG); }); @@ -164,7 +159,7 @@ describe("TownSystem Config Loading", () => { safeZoneRadius: 90, }, }, - biomeSuitability: { plains: 0.9, desert: 0.5, swamp: 0.1 }, + biomeSuitability: { forest: 0.9, canyon: 0.5, tundra: 0.1 }, landmarks: { fencesEnabled: false, fenceDensity: 0.25, @@ -183,10 +178,8 @@ describe("TownSystem Config Loading", () => { expect(config.minTownSpacing).toBe(1000); expect(config.townSizes.hamlet.buildingCount.min).toBe(4); expect(config.townSizes.town.safeZoneRadius).toBe(90); - expect(config.biomeSuitability.plains).toBe(0.9); - expect(config.biomeSuitability.valley).toBe( - DEFAULT_BIOME_SUITABILITY.valley, - ); + expect(config.biomeSuitability.forest).toBe(0.9); + expect(config.biomeSuitability.tundra).toBe(0.1); expect(config.landmarks.fencesEnabled).toBe(false); expect(config.landmarks.lamppostSpacing).toBe(20); }); diff --git a/packages/shared/src/types/entities/entities.ts b/packages/shared/src/types/entities/entities.ts index b2e39f6b9..257d5ecb6 100644 --- a/packages/shared/src/types/entities/entities.ts +++ b/packages/shared/src/types/entities/entities.ts @@ -309,6 +309,12 @@ export interface ResourceEntityConfig extends EntityConfig in woodcutting.json manifest. + * Derived from the single source of truth in constants/TreeTypes.ts. */ -export type TreeSubType = - | "normal" - | "oak" - | "willow" - | "teak" - | "maple" - | "mahogany" - | "yew" - | "magic"; +export type TreeSubType = _TreeSubType; /** * Ore subtypes that can spawn in the world. @@ -57,14 +51,7 @@ export interface TerrainResourceSpawnPoint { export interface TerrainTileData { tileId: string; position: { x: number; z: number }; - biome: - | "forest" - | "plains" - | "desert" - | "mountains" - | "swamp" - | "tundra" - | "jungle"; + biome: BiomeType; tileX: number; tileZ: number; resources: TerrainResource[]; @@ -83,14 +70,7 @@ export interface TerrainTile { z: number; mesh: THREE.Mesh; collision: PMeshHandle | null; - biome: - | "forest" - | "plains" - | "desert" - | "mountains" - | "swamp" - | "tundra" - | "jungle"; + biome: BiomeType; resources: ResourceNode[]; roads: RoadSegment[]; waterMeshes: THREE.Mesh[]; @@ -219,3 +199,28 @@ export interface FlatZone { * Format: "tileX_tileZ" where tiles are terrain tiles (100m each). */ export type FlatZoneKey = `${number}_${number}`; + +// ============================================================================ +// QUAD-TREE LOD VISUAL CHUNKS +// ============================================================================ + +/** + * A visual terrain chunk generated by the quad-tree LOD system. + * These are client-only rendering primitives — gameplay logic uses TerrainTile. + */ +export interface TerrainVisualChunkData { + /** Unique key (e.g., "quad_42_3_100_200") */ + key: string; + /** Center X in world coordinates */ + centerX: number; + /** Center Z in world coordinates */ + centerZ: number; + /** World-space size of this chunk (varies by LOD depth) */ + size: number; + /** Vertex resolution per axis (uniform across all depths) */ + resolution: number; + /** Quad-tree depth (0 = root, maxDepth = leaf) */ + depth: number; + /** Height data for the main grid (resolution × resolution) */ + heightData: Float32Array; +} diff --git a/packages/shared/src/types/world/world-types.ts b/packages/shared/src/types/world/world-types.ts index 2c0257095..70b807b65 100644 --- a/packages/shared/src/types/world/world-types.ts +++ b/packages/shared/src/types/world/world-types.ts @@ -5,6 +5,7 @@ import * as THREE from "../../extras/three/three"; import type { Position3D } from "../core/base-types"; +import type { BiomeType } from "../../systems/shared/world/TerrainBiomeTypes"; // Temporary imports from core.ts - will be updated when those modules are created import type { MobData } from "../core/core"; @@ -252,8 +253,8 @@ export interface ResourceDistribution { export interface BiomeTreeConfig { /** Whether harvestable trees are enabled for this biome */ enabled: boolean; - /** Distribution weights for tree types (IDs from woodcutting.json) */ - distribution: ResourceDistribution; + /** Per-tree spawn weight + placement rules, keyed by TreeId */ + trees: Record; /** Trees per 64m tile (base density, modified by resourceDensity) */ density: number; /** Minimum spacing between trees in meters */ @@ -264,6 +265,8 @@ export interface BiomeTreeConfig { clusterSize?: number; /** Scale variation range [min, max] multiplier (default: [0.8, 1.2]) */ scaleVariation?: [number, number]; + /** Maximum terrain slope for tree placement (gradient magnitude, default: 1.5) */ + maxSlope?: number; } /** @@ -358,17 +361,7 @@ export interface BiomeData { name: string; description: string; difficultyLevel: 0 | 1 | 2 | 3; // 0 = safe zones, 1-3 = mob levels - terrain: - | "forest" - | "wastes" - | "plains" - | "frozen" - | "corrupted" - | "lake" - | "mountain" - | "mountains" - | "desert" - | "swamp"; + terrain: BiomeType; resources: string[]; // Available resource types mobs: string[]; // Mob types that spawn here fogIntensity: number; // 0-1 for visual atmosphere diff --git a/packages/shared/src/utils/compute/shaders/heightmap.wgsl.ts b/packages/shared/src/utils/compute/shaders/heightmap.wgsl.ts index a2dfb765f..05d216c4a 100644 --- a/packages/shared/src/utils/compute/shaders/heightmap.wgsl.ts +++ b/packages/shared/src/utils/compute/shaders/heightmap.wgsl.ts @@ -52,7 +52,7 @@ struct BiomeColors { plains: vec4, forest: vec4, mountains: vec4, - desert: vec4, + canyon: vec4, swamp: vec4, shore: vec4, water: vec4, diff --git a/packages/shared/src/utils/rendering/DistanceFade.ts b/packages/shared/src/utils/rendering/DistanceFade.ts index 19c8c24b0..03b6bd575 100644 --- a/packages/shared/src/utils/rendering/DistanceFade.ts +++ b/packages/shared/src/utils/rendering/DistanceFade.ts @@ -34,7 +34,7 @@ import THREE, { dot, } from "../../extras/three/three"; import { DISTANCE_CONSTANTS } from "../../constants/GameConstants"; -import { GPU_VEG_CONFIG } from "../../systems/shared/world/GPUVegetation"; +import { GPU_VEG_CONFIG } from "../../systems/shared/world/GPUMaterials"; export interface DistanceFadeConfig { fadeStart: number; diff --git a/packages/shared/src/utils/rendering/__tests__/LODQuality.test.ts b/packages/shared/src/utils/rendering/__tests__/LODQuality.test.ts index 7102a08ae..2081afdb7 100644 --- a/packages/shared/src/utils/rendering/__tests__/LODQuality.test.ts +++ b/packages/shared/src/utils/rendering/__tests__/LODQuality.test.ts @@ -19,7 +19,7 @@ import { LOD_REFERENCE_SIZE, LOD_MIN_SCALE, LOD_MAX_SCALE, -} from "../../../systems/shared/world/GPUVegetation"; +} from "../../../systems/shared/world/LODConfig"; import { DISTANCE_CONSTANTS } from "../../../constants/GameConstants"; // ============================================================================ diff --git a/packages/shared/src/utils/rendering/__tests__/ModelCacheGeometry.test.ts b/packages/shared/src/utils/rendering/__tests__/ModelCacheGeometry.test.ts index b6913a6fb..d85fa2627 100644 --- a/packages/shared/src/utils/rendering/__tests__/ModelCacheGeometry.test.ts +++ b/packages/shared/src/utils/rendering/__tests__/ModelCacheGeometry.test.ts @@ -11,25 +11,11 @@ describe("ModelCache geometry setup", () => { const geometry = new THREE.BufferGeometry(); geometry.setAttribute( "position", - new THREE.Float32BufferAttribute( - [ - 0, 0, 0, - 1, 0, 0, - 0, 1, 0, - ], - 3, - ), + new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0], 3), ); geometry.setAttribute( "color", - new THREE.Float32BufferAttribute( - [ - 1, 0, 0, - 0, 1, 0, - 0, 0, 1, - ], - 3, - ), + new THREE.Float32BufferAttribute([1, 0, 0, 0, 1, 0, 0, 0, 1], 3), ); const mesh = new THREE.Mesh( diff --git a/packages/shared/src/utils/workers/QuadChunkWorker.ts b/packages/shared/src/utils/workers/QuadChunkWorker.ts new file mode 100644 index 000000000..e41c9d970 --- /dev/null +++ b/packages/shared/src/utils/workers/QuadChunkWorker.ts @@ -0,0 +1,318 @@ +/** + * QuadChunkWorker — Offloads quad-tree terrain chunk computation to Web Worker. + * + * Computes heights (with overflow grid for normals), per-vertex normals, + * biome-blended colors, and biome IDs for an arbitrary world-space region + * defined by (centerX, centerZ, size, resolution). + * + * Road influence and flat-zone application stay on the main thread because + * they depend on runtime game state (road network, building footprints). + * + * The inline worker code duplicates the same noise / height / biome logic + * used by TerrainWorker.ts, keeping both in sync with TerrainHeightParams.ts. + */ + +import { WorkerPool } from "./WorkerPool"; +import { + buildGetBaseHeightAtJS, + buildComputeBiomeWeightsJS, + buildApplyLandscapeFeaturesJS, +} from "../../systems/shared/world/TerrainHeightParams"; +import type { LandscapeFeatureDef } from "../../systems/shared/world/TerrainHeightParams"; +import { buildBiomeConstantsJS } from "../../systems/shared/world/TerrainBiomeTypes"; +import { + buildNoiseGeneratorJS, + buildHeightHelpersJS, + buildBiomeInfluencesJS, +} from "./TerrainWorkerShared"; + +export interface QuadChunkWorkerConfig { + MAX_HEIGHT: number; + BIOME_GAUSSIAN_COEFF: number; + BIOME_BOUNDARY_NOISE_SCALE: number; + BIOME_BOUNDARY_NOISE_AMOUNT: number; + WATER_THRESHOLD: number; + WATER_LEVEL_NORMALIZED: number; + SHORELINE_THRESHOLD: number; + SHORELINE_STRENGTH: number; + SHORELINE_MIN_SLOPE: number; + SHORELINE_SLOPE_SAMPLE_DISTANCE: number; + SHORELINE_LAND_BAND: number; + SHORELINE_LAND_MAX_MULTIPLIER: number; + SHORELINE_UNDERWATER_BAND: number; + UNDERWATER_DEPTH_MULTIPLIER: number; + landscapeFeatures?: LandscapeFeatureDef[]; +} + +export interface QuadChunkWorkerInput { + type: "generateQuadChunk"; + centerX: number; + centerZ: number; + size: number; + resolution: number; + config: QuadChunkWorkerConfig; + seed: number; + biomeCenters: Array<{ + x: number; + z: number; + type: string; + influence: number; + }>; + biomes: Record; +} + +export interface QuadChunkWorkerOutput { + type: "quadChunkResult"; + centerX: number; + centerZ: number; + size: number; + resolution: number; + heightData: Float32Array; + normalData: Float32Array; + colorData: Float32Array; + biomeData: Uint8Array; + biomeForestWeight: Float32Array; + biomeCanyonWeight: Float32Array; +} + +const QUAD_CHUNK_WORKER_CODE = ` +${buildNoiseGeneratorJS()} +${buildBiomeConstantsJS()} + +var BIOME_IDS = {}; +BIOME_IDS[BT_TUNDRA] = 0; +BIOME_IDS[BT_FOREST] = 1; +BIOME_IDS[BT_CANYON] = 2; + +function generateQuadChunk(input) { + const { centerX, centerZ, size, resolution, config, seed, biomeCenters, biomes } = input; + const { + MAX_HEIGHT, + BIOME_GAUSSIAN_COEFF, + BIOME_BOUNDARY_NOISE_SCALE, + BIOME_BOUNDARY_NOISE_AMOUNT, + WATER_THRESHOLD, + WATER_LEVEL_NORMALIZED, + SHORELINE_THRESHOLD, + SHORELINE_STRENGTH, + SHORELINE_MIN_SLOPE, + SHORELINE_SLOPE_SAMPLE_DISTANCE, + SHORELINE_LAND_BAND, + SHORELINE_LAND_MAX_MULTIPLIER, + SHORELINE_UNDERWATER_BAND, + UNDERWATER_DEPTH_MULTIPLIER + } = config; + + const noise = new NoiseGenerator(seed); + const segments = resolution; + const vertexCount = segments * segments; + const halfSize = size * 0.5; + const gridStep = size / (segments - 1); + + var landscapeFeatures = config.landscapeFeatures || []; + ${buildComputeBiomeWeightsJS()} + ${buildApplyLandscapeFeaturesJS()} + ${buildGetBaseHeightAtJS()} + ${buildHeightHelpersJS()} + ${buildBiomeInfluencesJS()} + + // Overflow grid for normals: (segments+2)^2 + const gRes = segments + 2; + const overflowGrid = new Float32Array(gRes * gRes); + + for (let gz = 0; gz < gRes; gz++) { + const localZ = -halfSize + (gz - 1) * gridStep; + const worldZ = centerZ + localZ; + for (let gx = 0; gx < gRes; gx++) { + const localX = -halfSize + (gx - 1) * gridStep; + const worldX = centerX + localX; + overflowGrid[gz * gRes + gx] = getHeightComputed(worldX, worldZ); + } + } + + const heightData = new Float32Array(vertexCount); + for (let iz = 0; iz < segments; iz++) { + const srcRow = (iz + 1) * gRes + 1; + const dstRow = iz * segments; + for (let ix = 0; ix < segments; ix++) { + heightData[dstRow + ix] = overflowGrid[srcRow + ix]; + } + } + + // Normals via centered finite differences + const normalData = new Float32Array(vertexCount * 3); + const invTwoStep = 1 / (2 * gridStep); + for (let iz = 0; iz < segments; iz++) { + const gz = iz + 1; + for (let ix = 0; ix < segments; ix++) { + const gx = ix + 1; + const hL = overflowGrid[gz * gRes + (gx - 1)]; + const hR = overflowGrid[gz * gRes + (gx + 1)]; + const hD = overflowGrid[(gz - 1) * gRes + gx]; + const hU = overflowGrid[(gz + 1) * gRes + gx]; + const dhdx = (hR - hL) * invTwoStep; + const dhdz = (hU - hD) * invTwoStep; + const nx = -dhdx; + const ny = 1; + const nz = -dhdz; + const len = Math.sqrt(nx * nx + ny * ny + nz * nz); + const i3 = (iz * segments + ix) * 3; + normalData[i3] = nx / len; + normalData[i3 + 1] = ny / len; + normalData[i3 + 2] = nz / len; + } + } + + // Colors and biome IDs + const colorData = new Float32Array(vertexCount * 3); + const biomeData = new Uint8Array(vertexCount); + const biomeForestWeight = new Float32Array(vertexCount); + const biomeCanyonWeight = new Float32Array(vertexCount); + + for (let iz = 0; iz < segments; iz++) { + for (let ix = 0; ix < segments; ix++) { + const idx = iz * segments + ix; + const height = heightData[idx]; + const normalizedHeight = height / MAX_HEIGHT; + + const localX = -halfSize + ix * gridStep; + const localZ = -halfSize + iz * gridStep; + const worldX = centerX + localX; + const worldZ = centerZ + localZ; + + var bw = computeBiomeWeightsByPosition(worldX, worldZ); + var forestW = bw[BT_FOREST] || 0; + var canyonW = bw[BT_CANYON] || 0; + biomeForestWeight[idx] = forestW; + biomeCanyonWeight[idx] = canyonW; + + var dominantBiome = BT_DEFAULT; + var dominantWeight = -1; + for (var bk in bw) { + if (bw[bk] > dominantWeight) { dominantWeight = bw[bk]; dominantBiome = bk; } + } + biomeData[idx] = BIOME_IDS[dominantBiome] || 0; + + let colorR = 0, colorG = 0, colorB = 0; + for (var bk2 in bw) { + var bwt = bw[bk2]; + var biomeConfig = biomes[bk2] || { color: { r: 0.4, g: 0.6, b: 0.3 } }; + var color = biomeConfig.color || { r: 0.4, g: 0.6, b: 0.3 }; + colorR += color.r * bwt; + colorG += color.g * bwt; + colorB += color.b * bwt; + } + + if (normalizedHeight > WATER_LEVEL_NORMALIZED && normalizedHeight < SHORELINE_THRESHOLD) { + const shoreFactor = (1.0 - (normalizedHeight - WATER_LEVEL_NORMALIZED) / + (SHORELINE_THRESHOLD - WATER_LEVEL_NORMALIZED)) * SHORELINE_STRENGTH; + colorR = colorR + (0.545 - colorR) * shoreFactor; + colorG = colorG + (0.451 - colorG) * shoreFactor; + colorB = colorB + (0.333 - colorB) * shoreFactor; + } + + colorData[idx * 3] = colorR; + colorData[idx * 3 + 1] = colorG; + colorData[idx * 3 + 2] = colorB; + } + } + + return { + type: 'quadChunkResult', + centerX, + centerZ, + size, + resolution, + heightData, + normalData, + colorData, + biomeData, + biomeForestWeight, + biomeCanyonWeight + }; +} + +self.onmessage = function(e) { + const input = e.data; + if (input.type === 'generateQuadChunk') { + try { + const result = generateQuadChunk(input); + self.postMessage({ result }, [ + result.heightData.buffer, + result.normalData.buffer, + result.colorData.buffer, + result.biomeData.buffer, + result.biomeForestWeight.buffer, + result.biomeCanyonWeight.buffer + ]); + } catch (error) { + self.postMessage({ error: error.message || 'Unknown error' }); + } + } +}; +`; + +let quadChunkWorkerPool: WorkerPool< + QuadChunkWorkerInput, + QuadChunkWorkerOutput +> | null = null; + +let workersChecked = false; +let workersAvailable = false; + +export function isQuadChunkWorkerAvailable(): boolean { + if (!workersChecked) { + workersChecked = true; + if (typeof Worker === "undefined" || typeof Blob === "undefined") { + workersAvailable = false; + return workersAvailable; + } + if ( + typeof process !== "undefined" && + process.versions && + "bun" in process.versions + ) { + workersAvailable = false; + return workersAvailable; + } + if (typeof window === "undefined") { + workersAvailable = false; + return workersAvailable; + } + workersAvailable = true; + } + return workersAvailable; +} + +export function getQuadChunkWorkerPool( + poolSize?: number, +): WorkerPool | null { + if (!isQuadChunkWorkerAvailable()) { + return null; + } + + if (!quadChunkWorkerPool) { + quadChunkWorkerPool = new WorkerPool< + QuadChunkWorkerInput, + QuadChunkWorkerOutput + >(QUAD_CHUNK_WORKER_CODE, poolSize); + } + return quadChunkWorkerPool; +} + +export async function generateQuadChunkAsync( + input: QuadChunkWorkerInput, +): Promise { + const pool = getQuadChunkWorkerPool(); + if (!pool) { + return null; + } + return pool.execute(input); +} + +export function terminateQuadChunkWorkerPool(): void { + if (quadChunkWorkerPool) { + quadChunkWorkerPool.terminate(); + quadChunkWorkerPool = null; + } +} diff --git a/packages/shared/src/utils/workers/TerrainWorker.ts b/packages/shared/src/utils/workers/TerrainWorker.ts index 129220bb6..0dc59aba0 100644 --- a/packages/shared/src/utils/workers/TerrainWorker.ts +++ b/packages/shared/src/utils/workers/TerrainWorker.ts @@ -12,9 +12,15 @@ import { WorkerPool } from "./WorkerPool"; import { buildGetBaseHeightAtJS, - MOUNTAIN_BOOST_MAX_NORM_DIST, - MOUNTAIN_BOOST_GAUSSIAN_COEFF, + buildComputeBiomeWeightsJS, + buildApplyLandscapeFeaturesJS, } from "../../systems/shared/world/TerrainHeightParams"; +import { buildBiomeConstantsJS } from "../../systems/shared/world/TerrainBiomeTypes"; +import { + buildNoiseGeneratorJS, + buildHeightHelpersJS, + buildBiomeInfluencesJS, +} from "./TerrainWorkerShared"; // Types for terrain generation // MUST match TerrainSystem.CONFIG exactly for height and biome calculation @@ -26,12 +32,6 @@ export interface TerrainWorkerConfig { BIOME_GAUSSIAN_COEFF: number; BIOME_BOUNDARY_NOISE_SCALE: number; BIOME_BOUNDARY_NOISE_AMOUNT: number; - MOUNTAIN_HEIGHT_THRESHOLD: number; - MOUNTAIN_WEIGHT_BOOST: number; - VALLEY_HEIGHT_THRESHOLD: number; - VALLEY_WEIGHT_BOOST: number; - // Mountain height boost - MUST match TerrainSystem.getHeightAtWithoutShore() - MOUNTAIN_HEIGHT_BOOST: number; // Shoreline config - MUST match TerrainSystem.getHeightAt() and createTileGeometry() WATER_THRESHOLD: number; WATER_LEVEL_NORMALIZED: number; @@ -44,6 +44,19 @@ export interface TerrainWorkerConfig { SHORELINE_LAND_MAX_MULTIPLIER: number; SHORELINE_UNDERWATER_BAND: number; UNDERWATER_DEPTH_MULTIPLIER: number; + landscapeFeatures?: Array<{ + type: string; + x: number; + z: number; + radius: number; + strength: number; + layers: number; + shapePower: number; + edgeSharpness: number; + layerSlope: number; + noiseScale: number; + noiseAmount: number; + }>; } export interface TerrainWorkerInput { @@ -93,133 +106,13 @@ export interface TerrainWorkerOutput { * Synced with: packages/shared/src/systems/shared/world/TerrainSystem.ts */ const TERRAIN_WORKER_CODE = ` -// NoiseGenerator - exact copy from packages/shared/src/utils/NoiseGenerator.ts -class NoiseGenerator { - constructor(seed = 12345) { - this.permutation = []; - this.p = []; - this.initializePermutation(seed); - } - - initializePermutation(seed) { - const perm = Array.from({ length: 256 }, (_, i) => i); - let random = seed; - for (let i = perm.length - 1; i > 0; i--) { - random = (random * 1664525 + 1013904223) % 4294967296; - const j = Math.floor((random / 4294967296) * (i + 1)); - [perm[i], perm[j]] = [perm[j], perm[i]]; - } - this.permutation = perm; - this.p = [...perm, ...perm]; - } - - fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } - lerp(t, a, b) { return a + t * (b - a); } - grad2D(hash, x, y) { - const h = hash & 3; - const u = h < 2 ? x : y; - const v = h < 2 ? y : x; - return (h & 1 ? -u : u) + (h & 2 ? -v : v); - } - - perlin2D(x, y) { - const X = Math.floor(x) & 255; - const Y = Math.floor(y) & 255; - x -= Math.floor(x); - y -= Math.floor(y); - const u = this.fade(x); - const v = this.fade(y); - const A = this.p[X] + Y; - const AA = this.p[A]; - const AB = this.p[A + 1]; - const B = this.p[X + 1] + Y; - const BA = this.p[B]; - const BB = this.p[B + 1]; - const result = this.lerp(v, - this.lerp(u, this.grad2D(this.p[AA], x, y), this.grad2D(this.p[BA], x - 1, y)), - this.lerp(u, this.grad2D(this.p[AB], x, y - 1), this.grad2D(this.p[BB], x - 1, y - 1)) - ); - return Math.max(-1, Math.min(1, result)); - } - - gradSimplex2D(hash, x, y) { - const grad3 = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]]; - return grad3[hash % 12][0] * x + grad3[hash % 12][1] * y; - } - - simplex2D(x, y) { - const F2 = 0.5 * (Math.sqrt(3.0) - 1.0); - const G2 = (3.0 - Math.sqrt(3.0)) / 6.0; - const s = (x + y) * F2; - const i = Math.floor(x + s); - const j = Math.floor(y + s); - const t = (i + j) * G2; - const X0 = i - t; - const Y0 = j - t; - const x0 = x - X0; - const y0 = y - Y0; - let i1, j1; - if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; } - const x1 = x0 - i1 + G2; - const y1 = y0 - j1 + G2; - const x2 = x0 - 1.0 + 2.0 * G2; - const y2 = y0 - 1.0 + 2.0 * G2; - const ii = i & 255; - const jj = j & 255; - const gi0 = this.p[ii + this.p[jj]] % 12; - const gi1 = this.p[ii + i1 + this.p[jj + j1]] % 12; - const gi2 = this.p[ii + 1 + this.p[jj + 1]] % 12; - let n0, n1, n2; - let t0 = 0.5 - x0 * x0 - y0 * y0; - if (t0 < 0) n0 = 0.0; - else { t0 *= t0; n0 = t0 * t0 * this.gradSimplex2D(gi0, x0, y0); } - let t1 = 0.5 - x1 * x1 - y1 * y1; - if (t1 < 0) n1 = 0.0; - else { t1 *= t1; n1 = t1 * t1 * this.gradSimplex2D(gi1, x1, y1); } - let t2 = 0.5 - x2 * x2 - y2 * y2; - if (t2 < 0) n2 = 0.0; - else { t2 *= t2; n2 = t2 * t2 * this.gradSimplex2D(gi2, x2, y2); } - return 70.0 * (n0 + n1 + n2); - } - - ridgeNoise2D(x, y) { - const perlinValue = this.perlin2D(x, y); - return 1.0 - Math.abs(Math.max(-1, Math.min(1, perlinValue))); - } +${buildNoiseGeneratorJS()} +${buildBiomeConstantsJS()} - fractal2D(x, y, octaves = 4, persistence = 0.5, lacunarity = 2.0) { - let value = 0; - let amplitude = 1; - let frequency = 1; - let maxValue = 0; - for (let i = 0; i < octaves; i++) { - value += this.perlin2D(x * frequency, y * frequency) * amplitude; - maxValue += amplitude; - amplitude *= persistence; - frequency *= lacunarity; - } - return value / maxValue; - } - - erosionNoise2D(x, y, iterations = 3) { - let height = this.fractal2D(x, y, 6); - for (let i = 0; i < iterations; i++) { - const delta = 0.01; - const hC = this.perlin2D(x, y); - const hX = this.perlin2D(x + delta, y); - const hY = this.perlin2D(x, y + delta); - const gradX = (hX - hC) / delta; - const gradY = (hY - hC) / delta; - const magnitude = Math.sqrt(gradX * gradX + gradY * gradY); - const erosionFactor = Math.min(1.0, magnitude * 2.0); - height *= 1.0 - erosionFactor * 0.1; - } - return height; - } -} - -// Biome ID mapping -const BIOME_IDS = { plains: 0, forest: 1, valley: 2, mountains: 3, tundra: 4, desert: 5, lakes: 6, swamp: 7 }; +var BIOME_IDS = {}; +BIOME_IDS[BT_TUNDRA] = 0; +BIOME_IDS[BT_FOREST] = 1; +BIOME_IDS[BT_CANYON] = 2; function generateHeightmap(input) { const { tileX, tileZ, config, seed, biomeCenters, biomes } = input; @@ -230,11 +123,6 @@ function generateHeightmap(input) { BIOME_GAUSSIAN_COEFF, BIOME_BOUNDARY_NOISE_SCALE, BIOME_BOUNDARY_NOISE_AMOUNT, - MOUNTAIN_HEIGHT_THRESHOLD, - MOUNTAIN_WEIGHT_BOOST, - VALLEY_HEIGHT_THRESHOLD, - VALLEY_WEIGHT_BOOST, - MOUNTAIN_HEIGHT_BOOST, WATER_THRESHOLD, WATER_LEVEL_NORMALIZED, SHORELINE_THRESHOLD, @@ -255,112 +143,14 @@ function generateHeightmap(input) { // HEIGHT FUNCTIONS — generated from TerrainHeightParams.ts (single source of truth) // ============================================ - ${buildGetBaseHeightAtJS()} + var landscapeFeatures = (config.landscapeFeatures || []); - function getHeightAtWithoutShore(worldX, worldZ) { - var baseHeight = getBaseHeightAt(worldX, worldZ); - var height = baseHeight / MAX_HEIGHT; - var mountainBoost = 0; - for (var _i = 0; _i < biomeCenters.length; _i++) { - var center = biomeCenters[_i]; - if (center.type === 'mountains') { - var dx = worldX - center.x; - var dz = worldZ - center.z; - var distance = Math.sqrt(dx * dx + dz * dz); - var normalizedDist = distance / center.influence; - if (normalizedDist < ${MOUNTAIN_BOOST_MAX_NORM_DIST}) { - var boost = Math.exp(-normalizedDist * normalizedDist * ${MOUNTAIN_BOOST_GAUSSIAN_COEFF}); - mountainBoost = Math.max(mountainBoost, boost); - } - } - } - height = height * (1 + mountainBoost * MOUNTAIN_HEIGHT_BOOST); - height = Math.min(1, height); - return height * MAX_HEIGHT; - } - - function calculateBaseSlopeAt(worldX, worldZ, centerHeight) { - const d = SHORELINE_SLOPE_SAMPLE_DISTANCE; - const hN = getHeightAtWithoutShore(worldX, worldZ + d); - const hS = getHeightAtWithoutShore(worldX, worldZ - d); - const hE = getHeightAtWithoutShore(worldX + d, worldZ); - const hW = getHeightAtWithoutShore(worldX - d, worldZ); - return Math.max( - Math.abs(hN - centerHeight) / d, - Math.abs(hS - centerHeight) / d, - Math.abs(hE - centerHeight) / d, - Math.abs(hW - centerHeight) / d - ); - } - - function adjustHeightForShoreline(baseHeight, slope) { - if (baseHeight === WATER_THRESHOLD) return baseHeight; - const isLand = baseHeight > WATER_THRESHOLD; - const band = isLand ? SHORELINE_LAND_BAND : SHORELINE_UNDERWATER_BAND; - if (band <= 0) return baseHeight; - const delta = Math.abs(baseHeight - WATER_THRESHOLD); - if (delta >= band) return baseHeight; - if (SHORELINE_MIN_SLOPE <= 0) return baseHeight; - const maxMul = isLand ? SHORELINE_LAND_MAX_MULTIPLIER : UNDERWATER_DEPTH_MULTIPLIER; - if (maxMul <= 1) return baseHeight; - const slopeSafe = Math.max(0.0001, slope); - const targetMul = Math.min(maxMul, Math.max(1, SHORELINE_MIN_SLOPE / slopeSafe)); - const falloff = 1 - delta / band; - const mul = 1 + (targetMul - 1) * falloff; - const adjustedDelta = delta * mul; - return isLand ? WATER_THRESHOLD + adjustedDelta : WATER_THRESHOLD - adjustedDelta; - } - - function getHeightComputed(worldX, worldZ) { - const h = getHeightAtWithoutShore(worldX, worldZ); - if (h >= WATER_THRESHOLD + SHORELINE_LAND_BAND || h <= WATER_THRESHOLD - SHORELINE_UNDERWATER_BAND) { - return h; - } - const slope = calculateBaseSlopeAt(worldX, worldZ, h); - return adjustHeightForShoreline(h, slope); - } + ${buildComputeBiomeWeightsJS()} + ${buildApplyLandscapeFeaturesJS()} - // ============================================ - // BIOME INFLUENCES — synced with TerrainSystem.getBiomeInfluencesAtPosition() - // ============================================ - - function getBiomeInfluences(worldX, worldZ, normalizedHeight) { - if (!biomeCenters || biomeCenters.length === 0) { - return [{ type: 'plains', weight: 1.0 }]; - } - const boundaryNoise = noise.simplex2D( - worldX * BIOME_BOUNDARY_NOISE_SCALE, - worldZ * BIOME_BOUNDARY_NOISE_SCALE - ); - const biomeWeightMap = {}; - for (const center of biomeCenters) { - const dx = worldX - center.x; - const dz = worldZ - center.z; - const distance = Math.sqrt(dx * dx + dz * dz); - const noisyDistance = distance * (1 + boundaryNoise * BIOME_BOUNDARY_NOISE_AMOUNT); - const normalizedDistance = noisyDistance / center.influence; - let weight = Math.exp(-normalizedDistance * normalizedDistance * BIOME_GAUSSIAN_COEFF); - if (center.type === 'mountains' && normalizedHeight > MOUNTAIN_HEIGHT_THRESHOLD) { - weight *= 1.0 + (normalizedHeight - MOUNTAIN_HEIGHT_THRESHOLD) * MOUNTAIN_WEIGHT_BOOST; - } - if ((center.type === 'valley' || center.type === 'plains') && normalizedHeight < VALLEY_HEIGHT_THRESHOLD) { - weight *= 1.0 + (VALLEY_HEIGHT_THRESHOLD - normalizedHeight) * VALLEY_WEIGHT_BOOST; - } - biomeWeightMap[center.type] = (biomeWeightMap[center.type] || 0) + weight; - } - const biomeInfluences = []; - for (const type in biomeWeightMap) { - biomeInfluences.push({ type, weight: biomeWeightMap[type] }); - } - const totalWeight = biomeInfluences.reduce((sum, b) => sum + b.weight, 0); - if (totalWeight > 0) { - for (const inf of biomeInfluences) { inf.weight /= totalWeight; } - } else { - biomeInfluences.push({ type: 'plains', weight: 1.0 }); - } - biomeInfluences.sort((a, b) => b.weight - a.weight); - return biomeInfluences.slice(0, 3); - } + ${buildGetBaseHeightAtJS()} + ${buildHeightHelpersJS()} + ${buildBiomeInfluencesJS()} // ============================================ // OVERFLOW GRID — (resolution+2)^2 height grid for centered-difference normals diff --git a/packages/shared/src/utils/workers/TerrainWorkerShared.ts b/packages/shared/src/utils/workers/TerrainWorkerShared.ts new file mode 100644 index 000000000..40c984c81 --- /dev/null +++ b/packages/shared/src/utils/workers/TerrainWorkerShared.ts @@ -0,0 +1,246 @@ +/** + * TerrainWorkerShared — Single source of truth for inline worker JS code + * shared between QuadChunkWorker and TerrainWorker. + * + * Workers can't import TS modules, so we build JS strings that get injected + * into the inline worker code. Both workers call these builders to get + * identical copies of the shared logic. + * + * If you need to change noise generation, height helpers, biome influence + * calculation, or shoreline adjustment — change it HERE once. + */ + +/** + * NoiseGenerator class — worker-side noise implementation. + * Mirrors packages/shared/src/utils/NoiseGenerator.ts. + */ +export function buildNoiseGeneratorJS(): string { + return ` +class NoiseGenerator { + constructor(seed = 12345) { + this.permutation = []; + this.p = []; + this.initializePermutation(seed); + } + + initializePermutation(seed) { + const perm = Array.from({ length: 256 }, (_, i) => i); + let random = seed; + for (let i = perm.length - 1; i > 0; i--) { + random = (random * 1664525 + 1013904223) % 4294967296; + const j = Math.floor((random / 4294967296) * (i + 1)); + [perm[i], perm[j]] = [perm[j], perm[i]]; + } + this.permutation = perm; + this.p = [...perm, ...perm]; + } + + fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); } + lerp(t, a, b) { return a + t * (b - a); } + grad2D(hash, x, y) { + const h = hash & 3; + const u = h < 2 ? x : y; + const v = h < 2 ? y : x; + return (h & 1 ? -u : u) + (h & 2 ? -v : v); + } + + perlin2D(x, y) { + const X = Math.floor(x) & 255; + const Y = Math.floor(y) & 255; + x -= Math.floor(x); + y -= Math.floor(y); + const u = this.fade(x); + const v = this.fade(y); + const A = this.p[X] + Y; + const AA = this.p[A]; + const AB = this.p[A + 1]; + const B = this.p[X + 1] + Y; + const BA = this.p[B]; + const BB = this.p[B + 1]; + const result = this.lerp(v, + this.lerp(u, this.grad2D(this.p[AA], x, y), this.grad2D(this.p[BA], x - 1, y)), + this.lerp(u, this.grad2D(this.p[AB], x, y - 1), this.grad2D(this.p[BB], x - 1, y - 1)) + ); + return Math.max(-1, Math.min(1, result)); + } + + gradSimplex2D(hash, x, y) { + const grad3 = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]]; + return grad3[hash % 12][0] * x + grad3[hash % 12][1] * y; + } + + simplex2D(x, y) { + const F2 = 0.5 * (Math.sqrt(3.0) - 1.0); + const G2 = (3.0 - Math.sqrt(3.0)) / 6.0; + const s = (x + y) * F2; + const i = Math.floor(x + s); + const j = Math.floor(y + s); + const t = (i + j) * G2; + const X0 = i - t; + const Y0 = j - t; + const x0 = x - X0; + const y0 = y - Y0; + let i1, j1; + if (x0 > y0) { i1 = 1; j1 = 0; } else { i1 = 0; j1 = 1; } + const x1 = x0 - i1 + G2; + const y1 = y0 - j1 + G2; + const x2 = x0 - 1.0 + 2.0 * G2; + const y2 = y0 - 1.0 + 2.0 * G2; + const ii = i & 255; + const jj = j & 255; + const gi0 = this.p[ii + this.p[jj]] % 12; + const gi1 = this.p[ii + i1 + this.p[jj + j1]] % 12; + const gi2 = this.p[ii + 1 + this.p[jj + 1]] % 12; + let n0, n1, n2; + let t0 = 0.5 - x0 * x0 - y0 * y0; + if (t0 < 0) n0 = 0.0; + else { t0 *= t0; n0 = t0 * t0 * this.gradSimplex2D(gi0, x0, y0); } + let t1 = 0.5 - x1 * x1 - y1 * y1; + if (t1 < 0) n1 = 0.0; + else { t1 *= t1; n1 = t1 * t1 * this.gradSimplex2D(gi1, x1, y1); } + let t2 = 0.5 - x2 * x2 - y2 * y2; + if (t2 < 0) n2 = 0.0; + else { t2 *= t2; n2 = t2 * t2 * this.gradSimplex2D(gi2, x2, y2); } + return 70.0 * (n0 + n1 + n2); + } + + ridgeNoise2D(x, y) { + const perlinValue = this.perlin2D(x, y); + return 1.0 - Math.abs(Math.max(-1, Math.min(1, perlinValue))); + } + + fractal2D(x, y, octaves = 4, persistence = 0.5, lacunarity = 2.0) { + let value = 0; + let amplitude = 1; + let frequency = 1; + let maxValue = 0; + for (let i = 0; i < octaves; i++) { + value += this.perlin2D(x * frequency, y * frequency) * amplitude; + maxValue += amplitude; + amplitude *= persistence; + frequency *= lacunarity; + } + return value / maxValue; + } + + erosionNoise2D(x, y, iterations = 3) { + let height = this.fractal2D(x, y, 6); + for (let i = 0; i < iterations; i++) { + const delta = 0.01; + const hC = this.perlin2D(x, y); + const hX = this.perlin2D(x + delta, y); + const hY = this.perlin2D(x, y + delta); + const gradX = (hX - hC) / delta; + const gradY = (hY - hC) / delta; + const magnitude = Math.sqrt(gradX * gradX + gradY * gradY); + const erosionFactor = Math.min(1.0, magnitude * 2.0); + height *= 1.0 - erosionFactor * 0.1; + } + return height; + } +}`; +} + +/** + * Height helper functions used by both workers. + * + * Expects these variables to already be in scope: + * - noise: NoiseGenerator + * - biomeCenters: array of biome center objects + * - MAX_HEIGHT: from config + * - WATER_THRESHOLD, SHORELINE_*: from config + * - getBaseHeightAt(): from buildGetBaseHeightAtJS() + * - computeBiomeWeightsByPosition(): from buildComputeBiomeWeightsJS() + */ +export function buildHeightHelpersJS(): string { + return ` + function getHeightAtWithoutShore(worldX, worldZ) { + var bw = computeBiomeWeightsByPosition(worldX, worldZ); + return getBaseHeightAt(worldX, worldZ, bw); + } + + function calculateBaseSlopeAt(worldX, worldZ, centerHeight) { + const d = SHORELINE_SLOPE_SAMPLE_DISTANCE; + const hN = getHeightAtWithoutShore(worldX, worldZ + d); + const hS = getHeightAtWithoutShore(worldX, worldZ - d); + const hE = getHeightAtWithoutShore(worldX + d, worldZ); + const hW = getHeightAtWithoutShore(worldX - d, worldZ); + return Math.max( + Math.abs(hN - centerHeight) / d, + Math.abs(hS - centerHeight) / d, + Math.abs(hE - centerHeight) / d, + Math.abs(hW - centerHeight) / d + ); + } + + function adjustHeightForShoreline(baseHeight, slope) { + if (baseHeight === WATER_THRESHOLD) return baseHeight; + const isLand = baseHeight > WATER_THRESHOLD; + const band = isLand ? SHORELINE_LAND_BAND : SHORELINE_UNDERWATER_BAND; + if (band <= 0) return baseHeight; + const delta = Math.abs(baseHeight - WATER_THRESHOLD); + if (delta >= band) return baseHeight; + if (SHORELINE_MIN_SLOPE <= 0) return baseHeight; + const maxMul = isLand ? SHORELINE_LAND_MAX_MULTIPLIER : UNDERWATER_DEPTH_MULTIPLIER; + if (maxMul <= 1) return baseHeight; + const slopeSafe = Math.max(0.0001, slope); + const targetMul = Math.min(maxMul, Math.max(1, SHORELINE_MIN_SLOPE / slopeSafe)); + const falloff = 1 - delta / band; + const mul = 1 + (targetMul - 1) * falloff; + const adjustedDelta = delta * mul; + return isLand ? WATER_THRESHOLD + adjustedDelta : WATER_THRESHOLD - adjustedDelta; + } + + function getHeightComputed(worldX, worldZ) { + const h = getHeightAtWithoutShore(worldX, worldZ); + if (h >= WATER_THRESHOLD + SHORELINE_LAND_BAND || h <= WATER_THRESHOLD - SHORELINE_UNDERWATER_BAND) { + return h; + } + const slope = calculateBaseSlopeAt(worldX, worldZ, h); + return adjustHeightForShoreline(h, slope); + }`; +} + +/** + * Biome influence function used by both workers. + * + * Expects these variables to already be in scope: + * - noise: NoiseGenerator + * - biomeCenters: array of biome center objects + * - BIOME_GAUSSIAN_COEFF, BIOME_BOUNDARY_NOISE_*: from config + * - BT_DEFAULT: from buildBiomeConstantsJS() + */ +export function buildBiomeInfluencesJS(): string { + return ` + function getBiomeInfluences(worldX, worldZ, normalizedHeight) { + if (!biomeCenters || biomeCenters.length === 0) { + return [{ type: BT_DEFAULT, weight: 1.0 }]; + } + const boundaryNoise = noise.simplex2D( + worldX * BIOME_BOUNDARY_NOISE_SCALE, + worldZ * BIOME_BOUNDARY_NOISE_SCALE + ); + const biomeWeightMap = {}; + for (const center of biomeCenters) { + const dx = worldX - center.x; + const dz = worldZ - center.z; + const distance = Math.sqrt(dx * dx + dz * dz); + const noisyDistance = distance * (1 + boundaryNoise * BIOME_BOUNDARY_NOISE_AMOUNT); + const normalizedDistance = noisyDistance / center.influence; + const weight = Math.exp(-normalizedDistance * normalizedDistance * BIOME_GAUSSIAN_COEFF); + biomeWeightMap[center.type] = (biomeWeightMap[center.type] || 0) + weight; + } + const biomeInfluences = []; + for (const type in biomeWeightMap) { + biomeInfluences.push({ type, weight: biomeWeightMap[type] }); + } + const totalWeight = biomeInfluences.reduce((sum, b) => sum + b.weight, 0); + if (totalWeight > 0) { + for (const inf of biomeInfluences) { inf.weight /= totalWeight; } + } else { + biomeInfluences.push({ type: BT_DEFAULT, weight: 1.0 }); + } + biomeInfluences.sort((a, b) => b.weight - a.weight); + return biomeInfluences.slice(0, 3); + }`; +} diff --git a/packages/shared/src/utils/workers/index.ts b/packages/shared/src/utils/workers/index.ts index 37bc893f2..81bb647f5 100644 --- a/packages/shared/src/utils/workers/index.ts +++ b/packages/shared/src/utils/workers/index.ts @@ -110,6 +110,17 @@ export { type PhysicsWorkerOutput, } from "./PhysicsWorker"; +// QuadChunk Worker - Offloads quad-tree terrain chunk computation to web worker +export { + getQuadChunkWorkerPool, + generateQuadChunkAsync, + terminateQuadChunkWorkerPool, + isQuadChunkWorkerAvailable, + type QuadChunkWorkerConfig, + type QuadChunkWorkerInput, + type QuadChunkWorkerOutput, +} from "./QuadChunkWorker"; + // Minimap Worker - 2D canvas-based minimap rendering in web worker export { MinimapWorkerManager, diff --git a/packages/vast-keeper/src/offer-utils.ts b/packages/vast-keeper/src/offer-utils.ts index b63b97e39..ad0d248c7 100644 --- a/packages/vast-keeper/src/offer-utils.ts +++ b/packages/vast-keeper/src/offer-utils.ts @@ -56,7 +56,9 @@ function getReliabilityScore(offer: VastOfferLike): number { function getWestRegionScore(offer: VastOfferLike): number { const location = (offer.geolocation || "").trim().toLowerCase(); if (!location) return 1; - return WEST_REGION_MARKERS.some((marker) => location.includes(marker)) ? 0 : 1; + return WEST_REGION_MARKERS.some((marker) => location.includes(marker)) + ? 0 + : 1; } function getGpuPreferenceScore(offer: VastOfferLike): number { @@ -68,7 +70,9 @@ function getGpuPreferenceScore(offer: VastOfferLike): number { return preferredIndex === -1 ? PREFERRED_GPU_ORDER.length : preferredIndex; } -export function filterOffersForStream(offers: T[]): T[] { +export function filterOffersForStream( + offers: T[], +): T[] { return offers.filter((offer) => { return ( typeof offer.reliability === "number" && diff --git a/scripts/deploy-vast.sh b/scripts/deploy-vast.sh index bedd7d83e..5dc521cb7 100755 --- a/scripts/deploy-vast.sh +++ b/scripts/deploy-vast.sh @@ -181,9 +181,15 @@ pkill -f "xvfb-run.*packages/server.*stream:rtmp" || true pkill -f "turbo.*dev" || true pkill -f "chromium" || true pkill -f "chrome" || true -# Kill node processes that might hold DB connections (not bun itself) +# Kill node processes that might hold DB connections pkill -f "node.*packages/server" || true pkill -f "drizzle" || true +# Kill ORPHANED bun child processes that pm2 kill failed to terminate +# (Specifically avoids killing the bash deployment script's own bun instances) +pkill -f "bun.*packages/server.*dist/index.js" || true +pkill -f "bun.*packages/server.*start" || true +pkill -f "bun.*dev-duel.mjs" || true +pkill -f "bun.*preview.*3333" || true rm -f /root/hyperscape/.runtime-locks/rtmp-status.json || true DB_DRAIN_WAIT_SECONDS=5 diff --git a/turbo.json b/turbo.json index 72b229272..7fbc4297b 100644 --- a/turbo.json +++ b/turbo.json @@ -1,12 +1,21 @@ { "$schema": "https://turbo.build/schema.json", - "globalDependencies": ["**/.env"], + "globalDependencies": [ + "**/.env" + ], "ui": "tui", "concurrency": "10", "tasks": { "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**", "build/**", ".next/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**", + "build/**", + ".next/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "extract-bounds": { @@ -27,72 +36,126 @@ "cache": true }, "shared#build": { - "dependsOn": ["^build", "physx-js-webidl#build"], - "outputs": ["dist/**", "build/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "physx-js-webidl#build" + ], + "outputs": [ + "dist/**", + "build/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "procgen#build": { - "dependsOn": ["^build", "shared#build"], - "outputs": ["dist/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build" + ], + "outputs": [ + "dist/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "client#build": { - "dependsOn": ["^build", "physx-js-webidl#build", "shared#build"], - "outputs": ["dist/**", "build/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "physx-js-webidl#build", + "shared#build" + ], + "outputs": [ + "dist/**", + "build/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "asset-forge#build": { - "dependsOn": ["^build", "procgen#build"], - "outputs": ["dist/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "procgen#build" + ], + "outputs": [ + "dist/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "plugin-hyperscape#build": { - "dependsOn": ["^build", "shared#build"], - "outputs": ["dist/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "shared#build" + ], + "outputs": [ + "dist/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "server#build": { - "dependsOn": ["^build", "shared#build"], - "outputs": ["dist/**", "tsconfig.tsbuildinfo"], + "dependsOn": [ + "^build", + "shared#build" + ], + "outputs": [ + "dist/**", + "tsconfig.tsbuildinfo" + ], "cache": true }, "@hyperscape/app#build": { - "dependsOn": ["@hyperscape/client#build"], + "dependsOn": [ + "@hyperscape/client#build" + ], "outputs": [], "cache": false }, "@hyperscape/contracts#build": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "outputs": [], "cache": true }, "@hyperscape/solana-prediction-market#build": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "outputs": [], "cache": true }, "dev": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "cache": false, "persistent": true }, "app#dev": { - "dependsOn": ["client#build"], + "dependsOn": [ + "client#build" + ], "cache": false, "persistent": true }, "test": { - "dependsOn": ["build"], + "dependsOn": [ + "build" + ], "outputs": [], "cache": true, - "env": ["NODE_ENV"] + "env": [ + "NODE_ENV" + ] }, "lint": { "outputs": [], "cache": true }, "typecheck": { - "dependsOn": ["^build"], + "dependsOn": [ + "^build" + ], "outputs": [], "cache": true }, @@ -100,4 +163,4 @@ "cache": false } } -} +} \ No newline at end of file