@idosgames/mcp 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -120,13 +120,16 @@ var SERVER_INSTRUCTIONS = [
120
120
  "It does NOT read or change any live Title's data.",
121
121
  "To configure a live Title's settings (TitlePublicConfiguration) or generate assets",
122
122
  "(image / audio / 3D / video / text), that is a SEPARATE server \u2014 the iDosGames Title-configuration MCP:",
123
- "HTTP JSON-RPC at POST {backend}/v2/mcp, authenticated with an X-MCP-API-Key header, tools get_<field> /",
124
- "save_<field> and generate_*.",
123
+ "HTTP JSON-RPC at POST https://site.idosgames.com/api/v2/mcp, authenticated with an X-MCP-API-Key",
124
+ "header (the publisher issues the key per Title on platform.idosgames.com), tools get_<field> /",
125
+ "save_<field> and generate_*. Connect it as an HTTP MCP server; keep the key out of committed config",
126
+ 'via env expansion \u2014 .mcp.json: {"idosgames-title": {"type": "http", "url":',
127
+ '"https://site.idosgames.com/api/v2/mcp", "headers": {"X-MCP-API-Key": "${IDOS_MCP_API_KEY}"}}}.',
125
128
  "Rule of thumb: game CODE \u2192 this server; a Title's live config DATA and generated ASSETS \u2192 the backend v2/mcp server."
126
129
  ].join(" ");
127
130
  function createServer() {
128
131
  const server = new Server(
129
- { name: "idosgames", version: "0.1.0" },
132
+ { name: "idosgames", version: "0.1.2" },
130
133
  { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
131
134
  );
132
135
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idosgames/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "MCP server that serves the iDosGames Module & Skills Registry to AI coding agents (Claude Code, Codex, Cursor…): list/pull composable game modules and the host scaffold, and load skills for @idosgames/core, the module contract, and composition.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  {
17
17
  "path": "src/config.ts",
18
- "content": "// Which Title this app talks to. Always targets the real backend (https://api.idosgames.com).\n//\n// IDENTITY and ENVIRONMENT are resolved separately, and that separation is the point:\n//\n// identity — WHICH game. Resolved once, in this order:\n// 1. src/idos.title.ts — baked into the bundle by the platform. Wins over everything.\n// 2. /drive/app/{id}/… — the hosted artifact's own path.\n// 3. ?titleID= — explicit override, for debugging and staged previews.\n// 4. VITE_IDOS_TITLE_ID — local development, via .env.local.\n// There is deliberately NO fallback title. A build that cannot tell which game it is must fail\n// loudly rather than quietly sign in to somebody else's data.\n//\n// environment — WHICH copy of that game, prod or dev. A modifier on the identity, never a\n// different identity: the dev title is always `{id}-DEV`. That is what lets a staged preview run\n// the production artifact against dev data without the baked identity getting in the way.\n//\n// Baking identity into a file instead of reading the URL is what lets the same source ship as a\n// packaged mobile app, where there is no URL to read at all.\n\nimport { IDOS_BUILD_KEY, IDOS_DEFAULT_ENV, IDOS_TITLE_ID } from \"./idos.title\";\nimport { ENV_BUILD_KEY, ENV_TITLE_ID } from \"./env\";\n\n/** Mirrors DevTitleService.DevSuffix on the backend. */\nconst DEV_SUFFIX = \"-DEV\";\n\nconst launchParams =\n typeof window === \"undefined\"\n ? null\n : new URLSearchParams(window.location.search);\n\n/**\n * Hosted AI Coder artifacts live at `/drive/app/{titleId}/…` (live pointer) and\n * `/drive/app/{titleId}/v/{buildId}/…` (a pinned version), so the path already identifies the\n * title. Deliberately narrow: the shared per-template builds under `/bld/{templateId}/` serve many\n * titles from one artifact, and guessing a title from that path would be wrong.\n */\nfunction titleFromPath(): string | null {\n if (typeof window === \"undefined\") return null;\n const match = window.location.pathname.match(\n /\\/drive\\/app\\/([A-Za-z0-9_-]{1,64})\\//,\n );\n return match?.[1] ?? null;\n}\n\n/** Identity is always canonical; the dev suffix is an environment, so strip it if an override carries one. */\nfunction toCanonical(id: string): string {\n return id.endsWith(DEV_SUFFIX) ? id.slice(0, -DEV_SUFFIX.length) : id;\n}\n\nconst overrideTitle = launchParams?.get(\"titleID\") || null;\nconst envParam = launchParams?.get(\"env\") || null;\n\nconst canonicalTitle =\n IDOS_TITLE_ID ||\n titleFromPath() ||\n (overrideTitle ? toCanonical(overrideTitle) : null) ||\n ENV_TITLE_ID;\n\nif (!canonicalTitle) {\n throw new Error(\n \"[idos] No Title id resolved. Expected src/idos.title.ts (generated by the platform), \" +\n \"a /drive/app/{titleID}/ artifact path, ?titleID= on the URL, or VITE_IDOS_TITLE_ID in .env.local.\",\n );\n}\n\n/**\n * Precedence: explicit `?env` → the suffix on an explicit `?titleID` (how staged previews run the\n * prod artifact against dev data) → the baked default, which is the only channel a packaged mobile\n * build has.\n */\nfunction resolveIsDev(): boolean {\n if (envParam !== null) return envParam === \"dev\";\n if (overrideTitle !== null) return overrideTitle.endsWith(DEV_SUFFIX);\n return IDOS_DEFAULT_ENV === \"dev\";\n}\n\nconst isDev = resolveIsDev();\n\nexport const TITLE_ID = isDev\n ? `${canonicalTitle}${DEV_SUFFIX}`\n : canonicalTitle;\n\nexport const BUILD_KEY =\n IDOS_BUILD_KEY || launchParams?.get(\"buildKey\") || ENV_BUILD_KEY;\n"
18
+ "content": "// Which Title this app talks to. Always targets the real backend (https://api.idosgames.com).\n//\n// IDENTITY and ENVIRONMENT are resolved separately, and that separation is the point:\n//\n// identity — WHICH game. Resolved once, in this order:\n// 1. src/idos.title.ts — the centralized identity file (generated by the platform, or filled\n// by hand on a manual scaffold). Wins over everything.\n// 2. /drive/app/{id}/… — the hosted artifact's own path.\n// 3. ?titleID= — explicit override, for debugging and staged previews.\n// 4. VITE_IDOS_TITLE_ID — local development, via .env.local.\n// There is deliberately NO fallback title. A build that cannot tell which game it is must fail\n// loudly rather than quietly sign in to somebody else's data.\n//\n// environment — WHICH copy of that game, prod or dev. A modifier on the identity, never a\n// different identity: the dev title is always `{id}-DEV`. That is what lets a staged preview run\n// the production artifact against dev data without the baked identity getting in the way.\n//\n// Baking identity into a file instead of reading the URL is what lets the same source ship as a\n// packaged mobile app, where there is no URL to read at all.\n\nimport { IDOS_BUILD_KEY, IDOS_DEFAULT_ENV, IDOS_TITLE_ID } from \"./idos.title\";\nimport { ENV_BUILD_KEY, ENV_TITLE_ID } from \"./env\";\n\n/** Mirrors DevTitleService.DevSuffix on the backend. */\nconst DEV_SUFFIX = \"-DEV\";\n\nconst launchParams =\n typeof window === \"undefined\"\n ? null\n : new URLSearchParams(window.location.search);\n\n/**\n * Hosted AI Coder artifacts live at `/drive/app/{titleId}/…` (live pointer) and\n * `/drive/app/{titleId}/v/{buildId}/…` (a pinned version), so the path already identifies the\n * title. Deliberately narrow: the shared per-template builds under `/bld/{templateId}/` serve many\n * titles from one artifact, and guessing a title from that path would be wrong.\n */\nfunction titleFromPath(): string | null {\n if (typeof window === \"undefined\") return null;\n const match = window.location.pathname.match(\n /\\/drive\\/app\\/([A-Za-z0-9_-]{1,64})\\//,\n );\n return match?.[1] ?? null;\n}\n\n/** Identity is always canonical; the dev suffix is an environment, so strip it if an override carries one. */\nfunction toCanonical(id: string): string {\n return id.endsWith(DEV_SUFFIX) ? id.slice(0, -DEV_SUFFIX.length) : id;\n}\n\nconst overrideTitle = launchParams?.get(\"titleID\") || null;\nconst envParam = launchParams?.get(\"env\") || null;\n\nconst canonicalTitle =\n IDOS_TITLE_ID ||\n titleFromPath() ||\n (overrideTitle ? toCanonical(overrideTitle) : null) ||\n ENV_TITLE_ID;\n\nif (!canonicalTitle) {\n throw new Error(\n \"[idos] No Title id resolved. Expected src/idos.title.ts (generated by the platform), \" +\n \"a /drive/app/{titleID}/ artifact path, ?titleID= on the URL, or VITE_IDOS_TITLE_ID in .env.local.\",\n );\n}\n\n/**\n * Precedence: explicit `?env` → the suffix on an explicit `?titleID` (how staged previews run the\n * prod artifact against dev data) → the baked default, which is the only channel a packaged mobile\n * build has.\n */\nfunction resolveIsDev(): boolean {\n if (envParam !== null) return envParam === \"dev\";\n if (overrideTitle !== null) return overrideTitle.endsWith(DEV_SUFFIX);\n return IDOS_DEFAULT_ENV === \"dev\";\n}\n\nconst isDev = resolveIsDev();\n\nexport const TITLE_ID = isDev\n ? `${canonicalTitle}${DEV_SUFFIX}`\n : canonicalTitle;\n\nexport const BUILD_KEY =\n IDOS_BUILD_KEY || launchParams?.get(\"buildKey\") || ENV_BUILD_KEY;\n"
19
19
  },
20
20
  {
21
21
  "path": "src/env.ts",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  {
25
25
  "path": "src/idos.title.ts",
26
- "content": "// GENERATED — the platform writes this file when it creates the project. Do not edit by hand and\n// do not import anything into it: the AI editor is denied write access to this path on purpose.\n//\n// This is the project's IDENTITY, and it is the highest-priority source of the Title id (see\n// config.ts for the full chain). It is baked into the bundle rather than read from the URL because\n// a build has to keep working where there is no URL to read — packaged as a mobile app, embedded in\n// an iframe, or opened from a shared link that dropped its query string.\n//\n// It holds the CANONICAL (production) title. The DEV title is derived from it, never stored here —\n// one project has one identity, and DEV/PROD is an environment on top of that identity.\n\n/** Canonical (production) Title id. Empty only in the raw template, before the platform seeds it. */\nexport const IDOS_TITLE_ID = \"\";\n\n/** Build key for this title, if the title enforces one. */\nexport const IDOS_BUILD_KEY = \"\";\n\n/** Environment this artifact defaults to. Web builds may override it (see config.ts); a packaged\n * mobile build cannot, so a DEV app is produced by generating this file with \"dev\". */\nexport const IDOS_DEFAULT_ENV: \"prod\" | \"dev\" = \"prod\";\n\n/**\n * Whether this title was created as web3. Set by the platform from the same toggle the publisher\n * used at title creation.\n *\n * It is baked here rather than read from the title's blockchain config because the login screen\n * needs it BEFORE there is a session, and `client.title.getTitlePublicConfiguration()` requires one.\n * LoginScreen.tsx uses it only as the default for which providers to offer — that list is ordinary\n * editable code, so a title that goes web3 later just gets the wallet button added there.\n */\nexport const IDOS_WEB3 = false;\n\n/**\n * NetworkID the wallet login challenge is issued for (e.g. \"bsc\", \"base\", \"solana\"). Empty on\n * web2 titles. Baked for the same reason as everything else here: the login screen needs it before\n * there is a session, and the title's blockchain config is only readable once logged in.\n */\nexport const IDOS_WEB3_NETWORK_ID = \"\";\n"
26
+ "content": "// The project's IDENTITY file — the ONE centralized place the Title id lives.\n//\n// On platform-created projects the platform GENERATES this file when it creates the project; there\n// the AI editor is denied write access to this path on purpose — do not edit it by hand. On a\n// manually scaffolded project (get_host_scaffold / copied template) there is no generator: YOU fill\n// IDOS_TITLE_ID here yourself. Either way, do not import anything into this file, and do not bind\n// the title anywhere else (.env.local is a local-dev fallback for the raw template only).\n//\n// It is the highest-priority source of the Title id (see config.ts for the full chain). It is baked\n// into the bundle rather than read from the URL because a build has to keep working where there is\n// no URL to read — packaged as a mobile app, embedded in an iframe, or opened from a shared link\n// that dropped its query string.\n//\n// It holds the CANONICAL (production) title. The DEV title is derived from it, never stored here —\n// one project has one identity, and DEV/PROD is an environment on top of that identity.\n\n/** Canonical (production) Title id. Empty only in the raw template, before the platform seeds it. */\nexport const IDOS_TITLE_ID = \"\";\n\n/** Build key for this title, if the title enforces one. */\nexport const IDOS_BUILD_KEY = \"\";\n\n/** Environment this artifact defaults to. Web builds may override it (see config.ts); a packaged\n * mobile build cannot, so a DEV app is produced by generating this file with \"dev\". */\nexport const IDOS_DEFAULT_ENV: \"prod\" | \"dev\" = \"prod\";\n\n/**\n * Whether this title was created as web3. Set by the platform from the same toggle the publisher\n * used at title creation.\n *\n * It is baked here rather than read from the title's blockchain config because the login screen\n * needs it BEFORE there is a session, and `client.title.getTitlePublicConfiguration()` requires one.\n * LoginScreen.tsx uses it only as the default for which providers to offer — that list is ordinary\n * editable code, so a title that goes web3 later just gets the wallet button added there.\n */\nexport const IDOS_WEB3 = false;\n\n/**\n * NetworkID the wallet login challenge is issued for (e.g. \"bsc\", \"base\", \"solana\"). Empty on\n * web2 titles. Baked for the same reason as everything else here: the login screen needs it before\n * there is a session, and the title's blockchain config is only readable once logged in.\n */\nexport const IDOS_WEB3_NETWORK_ID = \"\";\n"
27
27
  },
28
28
  {
29
29
  "path": "src/LoginScreen.tsx",
@@ -1,7 +1,7 @@
1
1
  {
2
- "generatedFromCommit": "31be6c40d338716a2c08049e1bb279a1d37ab8f7",
2
+ "generatedFromCommit": "0e6d7a0b1db746a991f118ffb6dc42c07ec5ac9d",
3
3
  "runtimePackages": {
4
- "@idosgames/core": "0.1.3",
4
+ "@idosgames/core": "0.1.4",
5
5
  "@idosgames/wallet": "0.1.2",
6
6
  "@idosgames/module-sdk": "0.1.1",
7
7
  "@idosgames/react": "0.1.0",
@@ -207,6 +207,10 @@
207
207
  "name": "idosgames-module-contract",
208
208
  "description": "Write or modify an iDosGames feature module against the module contract in @idosgames/module-sdk: the Module manifest, ModuleContext, EngineScene (Three/Phaser/vanilla), UiPanel (React), route registration, and the shared controller-box bridge between an imperative scene and React panels. Use this whenever a developer authors a NEW module, edits an existing one (board-game, idle-rpg, voxelcraft), or asks about defineModule, registerScene/registerPanel/registerRoute, activate/suspend, SceneMountContext, or the {camelCase(id)}Module export convention."
209
209
  },
210
+ {
211
+ "name": "idosgames-title-bootstrap",
212
+ "description": "Configure a FRESH (empty) iDosGames Title so a game can actually run against it: currencies with starting balances, then the game-loop board config, then verify with a real login. Use this when a newly created Title answers \"Board not found\", \"Board not enabled\", \"Bots config is not configured\", or \"SpecialMode ... must be configured\", when starting balances don't appear, or whenever you scaffold a project for a Title that was just created and has no config yet. All writes go through the Title-configuration MCP (see idosgames-getting-started for how to connect it)."
213
+ },
210
214
  {
211
215
  "name": "item-system",
212
216
  "description": "Work with items on the iDosGames TypeScript SDK (@idosgames/core) via client.item (ItemService) and the shared Item data model: upgrade an item instance's level (single or batch, optionally consuming fodder instances), and understand ItemDefinition / item catalogs / stackable vs unstackable item instances / equipment rules — the vocabulary Character (equipment), Marketplace (listings), Craft (recipes), Lootbox (rewards), and Store (purchase grants) all build on. Use this whenever the user is working in the iDosGames TS SDK or its game templates (board-game, idle-rpg) and wants item upgrade/leveling UIs, inventory screens, item definitions/catalogs, stackable/unstackable item instances, item rarity/tags, NFT-bound items, or otherwise touches client.item, ItemService, ItemDefinition, ItemCatalog, UnstackableItemInstanceState, or InventoryV2 — even if they don't name the module explicitly."
@@ -59,15 +59,15 @@
59
59
  },
60
60
  {
61
61
  "path": "core/Noise.ts",
62
- "content": "// Сидированный PRNG (mulberry32) и 2D-симплекс-шум — самодостаточно, без зависимостей.\n\n/** Детерминированный PRNG: одинаковый сид → одинаковый мир. */\nexport function mulberry32(seed: number): () => number {\n let a = seed >>> 0;\n return function () {\n a |= 0;\n a = (a + 0x6d2b79f5) | 0;\n let t = Math.imul(a ^ (a >>> 15), 1 | a);\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\n/** Хэш строки в 32-битный сид (для под-генераторов: \"trees:3,-2\" и т.п.). */\nexport function hashString(str: string): number {\n let h = 2166136261 >>> 0;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619);\n }\n return h >>> 0;\n}\n\nconst GRAD2 = [\n [1, 1],\n [-1, 1],\n [1, -1],\n [-1, -1],\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n];\nconst F2 = 0.5 * (Math.sqrt(3) - 1);\nconst G2 = (3 - Math.sqrt(3)) / 6;\n\nexport class Simplex2 {\n perm: Uint8Array;\n constructor(seed: number) {\n const rand = mulberry32(seed);\n this.perm = new Uint8Array(512);\n const p = new Uint8Array(256);\n for (let i = 0; i < 256; i++) p[i] = i;\n for (let i = 255; i > 0; i--) {\n const j = (rand() * (i + 1)) | 0;\n [p[i], p[j]] = [p[j], p[i]];\n }\n for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];\n }\n\n /** Значение шума в диапазоне примерно [-1, 1]. */\n noise(xin: number, yin: number): number {\n const perm = this.perm;\n const s = (xin + yin) * F2;\n const i = Math.floor(xin + s),\n j = Math.floor(yin + s);\n const t = (i + j) * G2;\n const x0 = xin - (i - t),\n y0 = yin - (j - t);\n const i1 = x0 > y0 ? 1 : 0,\n j1 = x0 > y0 ? 0 : 1;\n const x1 = x0 - i1 + G2,\n y1 = y0 - j1 + G2;\n const x2 = x0 - 1 + 2 * G2,\n y2 = y0 - 1 + 2 * G2;\n const ii = i & 255,\n jj = j & 255;\n let n = 0;\n let t0 = 0.5 - x0 * x0 - y0 * y0;\n if (t0 > 0) {\n const g = GRAD2[perm[ii + perm[jj]] & 7];\n t0 *= t0;\n n += t0 * t0 * (g[0] * x0 + g[1] * y0);\n }\n let t1 = 0.5 - x1 * x1 - y1 * y1;\n if (t1 > 0) {\n const g = GRAD2[perm[ii + i1 + perm[jj + j1]] & 7];\n t1 *= t1;\n n += t1 * t1 * (g[0] * x1 + g[1] * y1);\n }\n let t2 = 0.5 - x2 * x2 - y2 * y2;\n if (t2 > 0) {\n const g = GRAD2[perm[ii + 1 + perm[jj + 1]] & 7];\n t2 *= t2;\n n += t2 * t2 * (g[0] * x2 + g[1] * y2);\n }\n return 70 * n;\n }\n\n /** Фрактальный шум (несколько октав), диапазон примерно [-1, 1]. */\n fbm(x: number, y: number, octaves = 4, lacunarity = 2, gain = 0.5): number {\n let amp = 1,\n freq = 1,\n sum = 0,\n norm = 0;\n for (let o = 0; o < octaves; o++) {\n sum += amp * this.noise(x * freq, y * freq);\n norm += amp;\n amp *= gain;\n freq *= lacunarity;\n }\n return sum / norm;\n }\n}\n"
62
+ "content": "// Сидированный PRNG (mulberry32) и 2D-симплекс-шум — самодостаточно, без зависимостей.\n\n/** Детерминированный PRNG: одинаковый сид → одинаковый мир. */\nexport function mulberry32(seed: number): () => number {\n let a = seed >>> 0;\n return function () {\n a |= 0;\n a = (a + 0x6d2b79f5) | 0;\n let t = Math.imul(a ^ (a >>> 15), 1 | a);\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\n/** Хэш строки в 32-битный сид (для под-генераторов: \"trees:3,-2\" и т.п.). */\nexport function hashString(str: string): number {\n let h = 2166136261 >>> 0;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619);\n }\n return h >>> 0;\n}\n\nconst GRAD2 = [\n [1, 1],\n [-1, 1],\n [1, -1],\n [-1, -1],\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n];\nconst F2 = 0.5 * (Math.sqrt(3) - 1);\nconst G2 = (3 - Math.sqrt(3)) / 6;\n\nexport class Simplex2 {\n perm: Uint8Array;\n constructor(seed: number) {\n const rand = mulberry32(seed);\n this.perm = new Uint8Array(512);\n const p = new Uint8Array(256);\n for (let i = 0; i < 256; i++) p[i] = i;\n for (let i = 255; i > 0; i--) {\n const j = (rand() * (i + 1)) | 0;\n const pi = p[i]!,\n pj = p[j]!;\n p[i] = pj;\n p[j] = pi;\n }\n for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255]!;\n }\n\n /** Значение шума в диапазоне примерно [-1, 1]. */\n noise(xin: number, yin: number): number {\n const perm = this.perm;\n const s = (xin + yin) * F2;\n const i = Math.floor(xin + s),\n j = Math.floor(yin + s);\n const t = (i + j) * G2;\n const x0 = xin - (i - t),\n y0 = yin - (j - t);\n const i1 = x0 > y0 ? 1 : 0,\n j1 = x0 > y0 ? 0 : 1;\n const x1 = x0 - i1 + G2,\n y1 = y0 - j1 + G2;\n const x2 = x0 - 1 + 2 * G2,\n y2 = y0 - 1 + 2 * G2;\n const ii = i & 255,\n jj = j & 255;\n let n = 0;\n let t0 = 0.5 - x0 * x0 - y0 * y0;\n if (t0 > 0) {\n const g = GRAD2[perm[ii + perm[jj]!]! & 7]!;\n t0 *= t0;\n n += t0 * t0 * (g[0]! * x0 + g[1]! * y0);\n }\n let t1 = 0.5 - x1 * x1 - y1 * y1;\n if (t1 > 0) {\n const g = GRAD2[perm[ii + i1 + perm[jj + j1]!]! & 7]!;\n t1 *= t1;\n n += t1 * t1 * (g[0]! * x1 + g[1]! * y1);\n }\n let t2 = 0.5 - x2 * x2 - y2 * y2;\n if (t2 > 0) {\n const g = GRAD2[perm[ii + 1 + perm[jj + 1]!]! & 7]!;\n t2 *= t2;\n n += t2 * t2 * (g[0]! * x2 + g[1]! * y2);\n }\n return 70 * n;\n }\n\n /** Фрактальный шум (несколько октав), диапазон примерно [-1, 1]. */\n fbm(x: number, y: number, octaves = 4, lacunarity = 2, gain = 0.5): number {\n let amp = 1,\n freq = 1,\n sum = 0,\n norm = 0;\n for (let o = 0; o < octaves; o++) {\n sum += amp * this.noise(x * freq, y * freq);\n norm += amp;\n amp *= gain;\n freq *= lacunarity;\n }\n return sum / norm;\n }\n}\n"
63
63
  },
64
64
  {
65
65
  "path": "entities/EntityManager.ts",
66
- "content": "// Менеджер сущностей: мобы + дроп-предметы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Census поддерживает популяцию\n// куриц вокруг игрока. Игрок в MVP хранится отдельно (Player), но интерфейс\n// сущностей совместим — в этапе 2 удалённые игроки станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\n// Форма сохранённой сущности (выход serialize() у Mob/ItemDrop) — дискриминируется по `kind`.\ntype EntitySave =\n | { kind: \"mob\"; type: string; pos: [number, number, number]; hp: number }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n };\n\nexport class EntityManager {\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Mob | ItemDrop>;\n _nextId: number;\n _spawnTimer: number;\n\n constructor(\n scene: THREE.Scene,\n world: ChunkManager,\n atlas: TextureAtlas,\n player: Player,\n inventory: Inventory,\n ) {\n this.scene = scene;\n this.world = world;\n this.atlas = atlas;\n this.player = player;\n this.inventory = inventory;\n this.entities = new Map(); // id -> Mob|ItemDrop\n this._nextId = 1;\n this._spawnTimer = 0;\n\n events.on(\"mobDied\", ({ entity }) => {\n for (const d of entity.def.drops)\n if (Math.random() <= d.chance)\n this.spawnDrop(\n entity.pos.x,\n entity.pos.y + 0.3,\n entity.pos.z,\n d.item,\n d.n,\n );\n });\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n ): ItemDrop {\n // слияние с ближайшим таким же дропом\n for (const e of this.entities.values()) {\n if (\n e.kind === \"drop\" &&\n e.item === item &&\n !e.dead &&\n e.pos.distanceToSquared({ x, y, z }) < CONFIG.drops.mergeRadius ** 2\n ) {\n e.count += count;\n return e;\n }\n }\n // лимит: убиваем самый старый дроп\n const drops = [...this.entities.values()].filter(\n (e): e is ItemDrop => e.kind === \"drop\",\n );\n if (drops.length >= CONFIG.drops.maxCount) {\n drops.sort((a, b) => b.age - a.age)[0].dead = true;\n }\n const drop = new ItemDrop(this._nextId++, this.atlas, x, y, z, item, count);\n this.entities.set(drop.id, drop);\n this.scene.add(drop.mesh);\n return drop;\n }\n\n spawnMob(type: string, x: number, y: number, z: number): Mob {\n const mob = new Mob(this._nextId++, MOBS[type], x, y, z);\n this.entities.set(mob.id, mob);\n this.scene.add(mob.mesh);\n return mob;\n }\n\n hurt(entityId: number, damage: number, fromPos: THREE.Vector3): void {\n const e = this.entities.get(entityId);\n if (e && e.kind === \"mob\" && !e.dead) e.hurt(damage, fromPos);\n }\n\n tick(dt: number): void {\n for (const e of this.entities.values()) {\n if (e.kind === \"drop\")\n e.tick(dt, this.world, this.player, this.inventory);\n else e.tick(dt, this.world);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0)\n events.emit(\"mobDied\", { entity: e });\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Поддержание популяции куриц вокруг игрока. */\n _census(dt: number): void {\n this._spawnTimer -= dt;\n if (this._spawnTimer > 0) return;\n this._spawnTimer = 1.5;\n\n const p = this.player.pos;\n let count = 0;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\") continue;\n const d = e.pos.distanceTo(p);\n if (d > CONFIG.mobs.despawnRadius) {\n e.dead = true;\n continue;\n }\n count++;\n }\n if (count >= CONFIG.mobs.targetPopulation) return;\n\n // одна попытка за вызов: случайная точка в кольце вокруг игрока\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\n const ang = Math.random() * Math.PI * 2;\n const r = rMin + Math.random() * (rMax - rMin);\n const x = Math.floor(p.x + Math.cos(ang) * r);\n const z = Math.floor(p.z + Math.sin(ang) * r);\n const y = this.world.surfaceY(x, z);\n if (\n this.world.getBlock(x, y, z) === B.grass &&\n this.world.getBlock(x, y + 1, z) === B.air &&\n this.world.getBlock(x, y + 2, z) === B.air\n ) {\n this.spawnMob(\"chicken\", x + 0.5, y + 1.01, z + 0.5);\n }\n }\n\n /** Луч по AABB мобов (slab-тест) — для атаки. Возвращает ближайшего. */\n raycast(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): Mob | null {\n let best: Mob | null = null,\n bestT = maxDist;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead) continue;\n const { w, h } = e.def.box;\n const min = { x: e.pos.x - w / 2, y: e.pos.y, z: e.pos.z - w / 2 };\n const max = { x: e.pos.x + w / 2, y: e.pos.y + h, z: e.pos.z + w / 2 };\n let t0 = 0,\n t1 = bestT,\n ok = true;\n for (const ax of [\"x\", \"y\", \"z\"] as const) {\n const d = dir[ax],\n o = origin[ax];\n if (Math.abs(d) < 1e-9) {\n if (o < min[ax] || o > max[ax]) {\n ok = false;\n break;\n }\n continue;\n }\n let ta = (min[ax] - o) / d,\n tb = (max[ax] - o) / d;\n if (ta > tb) [ta, tb] = [tb, ta];\n t0 = Math.max(t0, ta);\n t1 = Math.min(t1, tb);\n if (t0 > t1) {\n ok = false;\n break;\n }\n }\n if (ok && t0 < bestT) {\n bestT = t0;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n for (const e of this.entities.values()) e.updateVisual(alpha, time);\n }\n\n clear(): void {\n for (const e of this.entities.values()) e.dispose(this.scene);\n this.entities.clear();\n }\n\n serialize() {\n return [...this.entities.values()].map((e) => e.serialize());\n }\n\n deserialize(list?: EntitySave[]): void {\n this.clear();\n for (const d of list ?? []) {\n if (d.kind === \"mob\" && MOBS[d.type]) {\n const m = this.spawnMob(d.type, d.pos[0], d.pos[1], d.pos[2]);\n m.hp = d.hp;\n } else if (d.kind === \"drop\") {\n const drop = this.spawnDrop(\n d.pos[0],\n d.pos[1],\n d.pos[2],\n d.item,\n d.count,\n );\n if (drop) drop.vel.set(0, 0, 0);\n }\n }\n }\n}\n"
66
+ "content": "// Менеджер сущностей: мобы + дроп-предметы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Census поддерживает популяцию\n// куриц вокруг игрока. Игрок в MVP хранится отдельно (Player), но интерфейс\n// сущностей совместим — в этапе 2 удалённые игроки станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\n// Форма сохранённой сущности (выход serialize() у Mob/ItemDrop) — дискриминируется по `kind`.\ntype EntitySave =\n | { kind: \"mob\"; type: string; pos: [number, number, number]; hp: number }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n };\n\nexport class EntityManager {\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Mob | ItemDrop>;\n _nextId: number;\n _spawnTimer: number;\n\n constructor(\n scene: THREE.Scene,\n world: ChunkManager,\n atlas: TextureAtlas,\n player: Player,\n inventory: Inventory,\n ) {\n this.scene = scene;\n this.world = world;\n this.atlas = atlas;\n this.player = player;\n this.inventory = inventory;\n this.entities = new Map(); // id -> Mob|ItemDrop\n this._nextId = 1;\n this._spawnTimer = 0;\n\n events.on(\"mobDied\", ({ entity }) => {\n for (const d of entity.def.drops)\n if (Math.random() <= d.chance)\n this.spawnDrop(\n entity.pos.x,\n entity.pos.y + 0.3,\n entity.pos.z,\n d.item,\n d.n,\n );\n });\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n ): ItemDrop {\n // слияние с ближайшим таким же дропом\n for (const e of this.entities.values()) {\n if (\n e.kind === \"drop\" &&\n e.item === item &&\n !e.dead &&\n e.pos.distanceToSquared({ x, y, z }) < CONFIG.drops.mergeRadius ** 2\n ) {\n e.count += count;\n return e;\n }\n }\n // лимит: убиваем самый старый дроп\n const drops = [...this.entities.values()].filter(\n (e): e is ItemDrop => e.kind === \"drop\",\n );\n if (drops.length >= CONFIG.drops.maxCount) {\n drops.sort((a, b) => b.age - a.age)[0]!.dead = true;\n }\n const drop = new ItemDrop(this._nextId++, this.atlas, x, y, z, item, count);\n this.entities.set(drop.id, drop);\n this.scene.add(drop.mesh);\n return drop;\n }\n\n spawnMob(type: string, x: number, y: number, z: number): Mob {\n const mob = new Mob(this._nextId++, MOBS[type]!, x, y, z);\n this.entities.set(mob.id, mob);\n this.scene.add(mob.mesh);\n return mob;\n }\n\n hurt(entityId: number, damage: number, fromPos: THREE.Vector3): void {\n const e = this.entities.get(entityId);\n if (e && e.kind === \"mob\" && !e.dead) e.hurt(damage, fromPos);\n }\n\n tick(dt: number): void {\n for (const e of this.entities.values()) {\n if (e.kind === \"drop\")\n e.tick(dt, this.world, this.player, this.inventory);\n else e.tick(dt, this.world);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0)\n events.emit(\"mobDied\", { entity: e });\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Поддержание популяции куриц вокруг игрока. */\n _census(dt: number): void {\n this._spawnTimer -= dt;\n if (this._spawnTimer > 0) return;\n this._spawnTimer = 1.5;\n\n const p = this.player.pos;\n let count = 0;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\") continue;\n const d = e.pos.distanceTo(p);\n if (d > CONFIG.mobs.despawnRadius) {\n e.dead = true;\n continue;\n }\n count++;\n }\n if (count >= CONFIG.mobs.targetPopulation) return;\n\n // одна попытка за вызов: случайная точка в кольце вокруг игрока\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\n const ang = Math.random() * Math.PI * 2;\n const r = rMin! + Math.random() * (rMax! - rMin!);\n const x = Math.floor(p.x + Math.cos(ang) * r);\n const z = Math.floor(p.z + Math.sin(ang) * r);\n const y = this.world.surfaceY(x, z);\n if (\n this.world.getBlock(x, y, z) === B.grass &&\n this.world.getBlock(x, y + 1, z) === B.air &&\n this.world.getBlock(x, y + 2, z) === B.air\n ) {\n this.spawnMob(\"chicken\", x + 0.5, y + 1.01, z + 0.5);\n }\n }\n\n /** Луч по AABB мобов (slab-тест) — для атаки. Возвращает ближайшего. */\n raycast(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): Mob | null {\n let best: Mob | null = null,\n bestT = maxDist;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead) continue;\n const { w, h } = e.def.box;\n const min = { x: e.pos.x - w / 2, y: e.pos.y, z: e.pos.z - w / 2 };\n const max = { x: e.pos.x + w / 2, y: e.pos.y + h, z: e.pos.z + w / 2 };\n let t0 = 0,\n t1 = bestT,\n ok = true;\n for (const ax of [\"x\", \"y\", \"z\"] as const) {\n const d = dir[ax],\n o = origin[ax];\n if (Math.abs(d) < 1e-9) {\n if (o < min[ax] || o > max[ax]) {\n ok = false;\n break;\n }\n continue;\n }\n let ta = (min[ax] - o) / d,\n tb = (max[ax] - o) / d;\n if (ta > tb) [ta, tb] = [tb, ta];\n t0 = Math.max(t0, ta);\n t1 = Math.min(t1, tb);\n if (t0 > t1) {\n ok = false;\n break;\n }\n }\n if (ok && t0 < bestT) {\n bestT = t0;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n for (const e of this.entities.values()) e.updateVisual(alpha, time);\n }\n\n clear(): void {\n for (const e of this.entities.values()) e.dispose(this.scene);\n this.entities.clear();\n }\n\n serialize() {\n return [...this.entities.values()].map((e) => e.serialize());\n }\n\n deserialize(list?: EntitySave[]): void {\n this.clear();\n for (const d of list ?? []) {\n if (d.kind === \"mob\" && MOBS[d.type]) {\n const m = this.spawnMob(d.type, d.pos[0], d.pos[1], d.pos[2]);\n m.hp = d.hp;\n } else if (d.kind === \"drop\") {\n const drop = this.spawnDrop(\n d.pos[0],\n d.pos[1],\n d.pos[2],\n d.item,\n d.count,\n );\n if (drop) drop.vel.set(0, 0, 0);\n }\n }\n }\n}\n"
67
67
  },
68
68
  {
69
69
  "path": "entities/ItemDrop.ts",
70
- "content": "// Дроп-предмет: мини-модель блока (или плоская иконка) с физикой —\n// подброс, гравитация, отскок, трение, медленное вращение и «магнит» к игроку.\n// Физика на тике (20 TPS), позиция для рендера интерполируется.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { BlockDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\nconst D = CONFIG.drops;\n// Гетерогенный кэш: геометрия под ключом key, материал под key+':mat' (разные типы значений).\nconst geoCache = new Map<string, THREE.BufferGeometry | THREE.Material>();\n\nfunction blockGeometry(\n atlas: TextureAtlas,\n blockDef: BlockDef,\n): THREE.BoxGeometry {\n // мини-куб с UV граней из атласа (top/side/bottom)\n const g = new THREE.BoxGeometry(D.scale, D.scale, D.scale);\n const uvAttr = g.getAttribute(\"uv\");\n // порядок граней BoxGeometry: +x,-x,+y,-y,+z,-z (по 4 вершины)\n // tex/грани заведомо заданы для размещаемых блоков, приходящих сюда — non-null asserts.\n const tex = blockDef.tex!;\n const tiles = [\n tex.side!,\n tex.side!,\n tex.top!,\n tex.bottom!,\n tex.side!,\n tex.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = atlas.uv(tiles[f]);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // общий материал чанков — vertexColors:true; без белого атрибута цвета\n // вершины считаются чёрными и гасят текстуру\n g.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(g.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n return g;\n}\n\nexport class ItemDrop {\n id: number;\n kind: \"drop\";\n item: string;\n count: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n age: number;\n dead: boolean;\n _pickupAnim: number;\n mesh: THREE.Mesh;\n\n constructor(\n id: number,\n atlas: TextureAtlas,\n x: number,\n y: number,\n z: number,\n itemName: string,\n count: number,\n ) {\n this.id = id;\n this.kind = \"drop\";\n this.item = itemName;\n this.count = count;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3(\n (Math.random() - 0.5) * 2.4,\n 4 + Math.random() * 1.5,\n (Math.random() - 0.5) * 2.4,\n );\n this.age = 0;\n this.dead = false;\n this._pickupAnim = 0; // >0 — летит в игрока\n\n const def = ITEMS[itemName];\n // Значения кэша гетерогенны (геометрия/материал под разными ключами) — сужаем кастами при чтении.\n const key = \"drop:\" + itemName;\n let geo = geoCache.get(key) as THREE.BufferGeometry | undefined;\n if (def.blockId !== null && !BLOCKS[def.blockId].cross) {\n if (!geo) {\n geo = blockGeometry(atlas, BLOCKS[def.blockId]);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, atlas.opaqueMat);\n } else {\n // плоский предмет: квад с иконкой (или тайлом растения)\n let mat = geoCache.get(key + \":mat\") as\n THREE.MeshLambertMaterial | undefined;\n if (!mat) {\n // не-блочные предметы (материалы/инструменты/крестовины) всегда имеют tile — non-null assert.\n const tex = new THREE.CanvasTexture(\n def.icon ?? atlas.tileCanvas(def.tile!),\n );\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshLambertMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n geoCache.set(key + \":mat\", mat);\n }\n if (!geo) {\n geo = new THREE.PlaneGeometry(D.scale * 1.4, D.scale * 1.4);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, mat);\n }\n this.mesh.position.copy(this.pos);\n }\n\n tick(dt: number, world: ChunkManager, player: Player, inventory: Inventory) {\n this.prevPos.copy(this.pos);\n this.age += dt;\n if (this.age > D.despawnTime) {\n this.dead = true;\n return;\n }\n\n const toPlayer = new THREE.Vector3(\n player.pos.x,\n player.pos.y + 0.9,\n player.pos.z,\n ).sub(this.pos);\n const dist = toPlayer.length();\n\n // подбор\n if (dist < D.pickupRadius && this.age > 0.5) {\n const left = inventory.add(this.item, this.count);\n if (left < this.count) {\n events.emit(\"itemPickup\", {\n item: this.item,\n count: this.count - left,\n });\n if (left > 0) this.count = left;\n else {\n this.dead = true;\n return;\n }\n }\n }\n // магнит: тянет к игроку, физика отключается\n if (dist < D.magnetRadius && this.age > 0.5) {\n toPlayer\n .normalize()\n .multiplyScalar(\n D.magnetPull * Math.max(0.3, 1 - dist / D.magnetRadius) + 2,\n );\n this.vel.lerp(toPlayer, 0.5);\n this.pos.addScaledVector(this.vel, dt);\n return;\n }\n\n // обычная физика: гравитация + точечная коллизия с вокселями\n this.vel.y -= D.gravity * dt;\n const nx = this.pos.x + this.vel.x * dt;\n const ny = this.pos.y + this.vel.y * dt;\n const nz = this.pos.z + this.vel.z * dt;\n\n if (\n this.vel.y < 0 &&\n world.isSolid(Math.floor(nx), Math.floor(ny - 0.12), Math.floor(nz))\n ) {\n // отскок от пола\n this.pos.y = Math.floor(ny - 0.12) + 1 + 0.12;\n this.vel.y = Math.abs(this.vel.y) > 1 ? -this.vel.y * D.bounce : 0;\n this.vel.x *= D.friction;\n this.vel.z *= D.friction;\n } else {\n this.pos.y = ny;\n // горизонтальные стенки — просто стоп\n if (\n !world.isSolid(\n Math.floor(nx),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n )\n this.pos.x = nx;\n else this.vel.x = 0;\n if (\n !world.isSolid(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(nz),\n )\n )\n this.pos.z = nz;\n else this.vel.z = 0;\n }\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.mesh.position.set(\n p.x,\n p.y + Math.sin(time * 2 + this.id) * 0.05 + 0.05,\n p.z,\n );\n this.mesh.rotation.y = time * 1.2 + this.id;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.mesh); /* геометрии/материалы в кэше — общие */\n }\n\n serialize() {\n return {\n kind: \"drop\",\n item: this.item,\n count: this.count,\n pos: this.pos.toArray(),\n };\n }\n}\n"
70
+ "content": "// Дроп-предмет: мини-модель блока (или плоская иконка) с физикой —\n// подброс, гравитация, отскок, трение, медленное вращение и «магнит» к игроку.\n// Физика на тике (20 TPS), позиция для рендера интерполируется.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { BlockDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\nconst D = CONFIG.drops;\n// Гетерогенный кэш: геометрия под ключом key, материал под key+':mat' (разные типы значений).\nconst geoCache = new Map<string, THREE.BufferGeometry | THREE.Material>();\n\nfunction blockGeometry(\n atlas: TextureAtlas,\n blockDef: BlockDef,\n): THREE.BoxGeometry {\n // мини-куб с UV граней из атласа (top/side/bottom)\n const g = new THREE.BoxGeometry(D.scale, D.scale, D.scale);\n const uvAttr = g.getAttribute(\"uv\");\n // порядок граней BoxGeometry: +x,-x,+y,-y,+z,-z (по 4 вершины)\n // tex/грани заведомо заданы для размещаемых блоков, приходящих сюда — non-null asserts.\n const tex = blockDef.tex!;\n const tiles = [\n tex.side!,\n tex.side!,\n tex.top!,\n tex.bottom!,\n tex.side!,\n tex.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = atlas.uv(tiles[f]!);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // общий материал чанков — vertexColors:true; без белого атрибута цвета\n // вершины считаются чёрными и гасят текстуру\n g.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(g.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n return g;\n}\n\nexport class ItemDrop {\n id: number;\n kind: \"drop\";\n item: string;\n count: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n age: number;\n dead: boolean;\n _pickupAnim: number;\n mesh: THREE.Mesh;\n\n constructor(\n id: number,\n atlas: TextureAtlas,\n x: number,\n y: number,\n z: number,\n itemName: string,\n count: number,\n ) {\n this.id = id;\n this.kind = \"drop\";\n this.item = itemName;\n this.count = count;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3(\n (Math.random() - 0.5) * 2.4,\n 4 + Math.random() * 1.5,\n (Math.random() - 0.5) * 2.4,\n );\n this.age = 0;\n this.dead = false;\n this._pickupAnim = 0; // >0 — летит в игрока\n\n const def = ITEMS[itemName]!;\n // Значения кэша гетерогенны (геометрия/материал под разными ключами) — сужаем кастами при чтении.\n const key = \"drop:\" + itemName;\n let geo = geoCache.get(key) as THREE.BufferGeometry | undefined;\n if (def.blockId !== null && !BLOCKS[def.blockId]!.cross) {\n if (!geo) {\n geo = blockGeometry(atlas, BLOCKS[def.blockId]!);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, atlas.opaqueMat);\n } else {\n // плоский предмет: квад с иконкой (или тайлом растения)\n let mat = geoCache.get(key + \":mat\") as\n THREE.MeshLambertMaterial | undefined;\n if (!mat) {\n // не-блочные предметы (материалы/инструменты/крестовины) всегда имеют tile — non-null assert.\n const tex = new THREE.CanvasTexture(\n def.icon ?? atlas.tileCanvas(def.tile!),\n );\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshLambertMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n geoCache.set(key + \":mat\", mat);\n }\n if (!geo) {\n geo = new THREE.PlaneGeometry(D.scale * 1.4, D.scale * 1.4);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, mat);\n }\n this.mesh.position.copy(this.pos);\n }\n\n tick(dt: number, world: ChunkManager, player: Player, inventory: Inventory) {\n this.prevPos.copy(this.pos);\n this.age += dt;\n if (this.age > D.despawnTime) {\n this.dead = true;\n return;\n }\n\n const toPlayer = new THREE.Vector3(\n player.pos.x,\n player.pos.y + 0.9,\n player.pos.z,\n ).sub(this.pos);\n const dist = toPlayer.length();\n\n // подбор\n if (dist < D.pickupRadius && this.age > 0.5) {\n const left = inventory.add(this.item, this.count);\n if (left < this.count) {\n events.emit(\"itemPickup\", {\n item: this.item,\n count: this.count - left,\n });\n if (left > 0) this.count = left;\n else {\n this.dead = true;\n return;\n }\n }\n }\n // магнит: тянет к игроку, физика отключается\n if (dist < D.magnetRadius && this.age > 0.5) {\n toPlayer\n .normalize()\n .multiplyScalar(\n D.magnetPull * Math.max(0.3, 1 - dist / D.magnetRadius) + 2,\n );\n this.vel.lerp(toPlayer, 0.5);\n this.pos.addScaledVector(this.vel, dt);\n return;\n }\n\n // обычная физика: гравитация + точечная коллизия с вокселями\n this.vel.y -= D.gravity * dt;\n const nx = this.pos.x + this.vel.x * dt;\n const ny = this.pos.y + this.vel.y * dt;\n const nz = this.pos.z + this.vel.z * dt;\n\n if (\n this.vel.y < 0 &&\n world.isSolid(Math.floor(nx), Math.floor(ny - 0.12), Math.floor(nz))\n ) {\n // отскок от пола\n this.pos.y = Math.floor(ny - 0.12) + 1 + 0.12;\n this.vel.y = Math.abs(this.vel.y) > 1 ? -this.vel.y * D.bounce : 0;\n this.vel.x *= D.friction;\n this.vel.z *= D.friction;\n } else {\n this.pos.y = ny;\n // горизонтальные стенки — просто стоп\n if (\n !world.isSolid(\n Math.floor(nx),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n )\n this.pos.x = nx;\n else this.vel.x = 0;\n if (\n !world.isSolid(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(nz),\n )\n )\n this.pos.z = nz;\n else this.vel.z = 0;\n }\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.mesh.position.set(\n p.x,\n p.y + Math.sin(time * 2 + this.id) * 0.05 + 0.05,\n p.z,\n );\n this.mesh.rotation.y = time * 1.2 + this.id;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.mesh); /* геометрии/материалы в кэше — общие */\n }\n\n serialize() {\n return {\n kind: \"drop\",\n item: this.item,\n count: this.count,\n pos: this.pos.toArray(),\n };\n }\n}\n"
71
71
  },
72
72
  {
73
73
  "path": "entities/Mob.ts",
@@ -75,7 +75,7 @@
75
75
  },
76
76
  {
77
77
  "path": "gfx/TextureAtlas.ts",
78
- "content": "// Процедурный texture atlas: все текстуры рисуются на canvas 256×256 (16×16 тайлов\n// по 16px), никаких внешних файлов. NearestFilter + отключённые мипмапы + полутексельный\n// inset UV — честный пиксель-арт без bleeding.\nimport * as THREE from \"three\";\nimport { mulberry32, hashString } from \"../core/Noise\";\n\nconst TILE = 16; // пикселей в тайле\nconst GRID = 16; // тайлов в ряду\nconst SIZE = TILE * GRID;\n\n/** Закрашивает один пиксель тайла. */\ntype PxFn = (x: number, y: number, color: string) => void;\n/** Сидированный PRNG. */\ntype RngFn = () => number;\n/** painter-функция: рисует один тайл 16×16. */\ntype Painter = (px: PxFn, rng: RngFn) => void;\n\n/** Смешивание hex-цвета с чёрным/белым: amt в [-1..1]. */\nfunction shade(hex: string, amt: number): string {\n const n = parseInt(hex.slice(1), 16);\n let r = (n >> 16) & 255,\n g = (n >> 8) & 255,\n b = n & 255;\n if (amt >= 0) {\n r += (255 - r) * amt;\n g += (255 - g) * amt;\n b += (255 - b) * amt;\n } else {\n r *= 1 + amt;\n g *= 1 + amt;\n b *= 1 + amt;\n }\n return `rgb(${r | 0},${g | 0},${b | 0})`;\n}\n\n/** База: заливка цветом + пиксельный шум яркости. */\nfunction noisy(px: PxFn, base: string, variance: number, rng: RngFn): void {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(x, y, shade(base, (rng() - 0.5) * 2 * variance));\n}\n\n// ---- painter-функции: (px, rng) → рисуют один тайл 16×16 ---------------------\n// px(x, y, cssColor) закрашивает один пиксель тайла.\n\nconst painters: Record<string, Painter> = {\n grass_top(px, rng) {\n noisy(px, \"#5fa63c\", 0.1, rng);\n },\n dirt(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n },\n grass_side(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n for (let x = 0; x < TILE; x++) {\n const depth = 3 + ((rng() * 2) | 0); // рваная кромка дёрна\n for (let y = 0; y < depth; y++)\n px(x, y, shade(\"#5fa63c\", (rng() - 0.5) * 0.2));\n }\n },\n stone(px, rng) {\n noisy(px, \"#7d7d7d\", 0.08, rng);\n for (let i = 0; i < 5; i++) {\n // светлые/тёмные пятна\n const x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0,\n c = shade(\"#7d7d7d\", rng() < 0.5 ? -0.15 : 0.12);\n px(x, y, c);\n px((x + 1) & 15, y, c);\n }\n },\n cobblestone(px, rng) {\n noisy(px, \"#828282\", 0.1, rng);\n // «булыжники»: тёмная сетка неровных швов\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const cell = (x + (y & 4 ? 3 : 0)) % 6 === 0 || y % 6 === 0;\n if (cell && rng() < 0.8)\n px(x, y, shade(\"#4a4a4a\", (rng() - 0.5) * 0.2));\n }\n },\n bedrock(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(rng() < 0.5 ? \"#2b2b2b\" : \"#565656\", (rng() - 0.5) * 0.3),\n );\n },\n sand(px, rng) {\n noisy(px, \"#dcd3a0\", 0.08, rng);\n },\n oak_log_side(px, rng) {\n for (let x = 0; x < TILE; x++) {\n const stripe = x % 4 === 3 ? -0.25 : (rng() - 0.5) * 0.15; // кора полосами\n for (let y = 0; y < TILE; y++)\n px(x, y, shade(\"#6b5030\", stripe + (rng() - 0.5) * 0.08));\n }\n },\n oak_log_top(px, rng) {\n noisy(px, \"#6b5030\", 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++)\n px(x, y, shade(\"#b8945f\", (rng() - 0.5) * 0.15));\n // годовые кольца\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, \"#8a6d42\");\n }\n }\n },\n planks(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#a4824c\", (rng() - 0.5) * 0.12);\n if (y % 4 === 3) c = \"#6e5631\"; // горизонтальные швы досок\n if (\n (y < 4 && x === 11) ||\n (y >= 4 && y < 8 && x === 3) ||\n (y >= 8 && y < 12 && x === 13) ||\n (y >= 12 && x === 6)\n )\n c = \"#6e5631\"; // стыки\n px(x, y, c);\n }\n },\n leaves(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < 0.18) continue; // прозрачные «дырки» (alphaTest-вырезы)\n px(x, y, shade(\"#3e7a25\", (rng() - 0.5) * 0.35));\n }\n },\n water(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#3057d8\", (rng() - 0.5) * 0.12);\n if ((x + y * 2) % 8 === 0 && rng() < 0.5) c = shade(\"#4a74ea\", 0.1); // блики\n px(x, y, c);\n }\n },\n coal_ore(px, rng) {\n painters.stone(px, rng);\n oreBlobs(px, rng, \"#2e2e2e\");\n },\n iron_ore(px, rng) {\n painters.stone(px, rng);\n oreBlobs(px, rng, \"#d8af93\");\n },\n crafting_table_top(px, rng) {\n painters.planks(px, rng);\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#59431f\");\n px(i, 15, \"#59431f\");\n px(0, i, \"#59431f\");\n px(15, i, \"#59431f\");\n }\n for (let y = 3; y < 13; y++)\n for (let x = 3; x < 13; x++)\n if (x === 3 || x === 12 || y === 3 || y === 12) px(x, y, \"#7d6134\");\n },\n crafting_table_side(px, rng) {\n painters.planks(px, rng);\n // «инструменты» на боковине: тёмные силуэты\n for (let y = 2; y < 7; y++)\n for (let x = 3; x < 6; x++) if (rng() < 0.8) px(x, y, \"#4d3a1c\");\n for (let y = 2; y < 7; y++)\n for (let x = 10; x < 13; x++) if (rng() < 0.8) px(x, y, \"#57422a\");\n },\n};\n\nfunction oreBlobs(px: PxFn, rng: RngFn, color: string): void {\n for (let i = 0; i < 5; i++) {\n const cx = (2 + rng() * 12) | 0,\n cy = (2 + rng() * 12) | 0;\n for (let dy = -1; dy <= 1; dy++)\n for (let dx = -1; dx <= 1; dx++)\n if (Math.abs(dx) + Math.abs(dy) < 2 || rng() < 0.4)\n px((cx + dx) & 15, (cy + dy) & 15, shade(color, (rng() - 0.5) * 0.2));\n }\n}\n\n// ---- пиксель-арт по строковым шаблонам (растения, предметы, инструменты) -----\n// Каждая строка — 16 символов; '.' = прозрачно, буквы — цвета из палитры.\n\nfunction patternPainter(\n rows: string[],\n palette: Record<string, string>,\n): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++) {\n const row = rows[y] || \"\";\n for (let x = 0; x < TILE; x++) {\n const ch = row[x];\n if (!ch || ch === \".\") continue;\n px(x, y, shade(palette[ch], (rng() - 0.5) * 0.1));\n }\n }\n };\n}\n\nconst P = patternPainter;\n\npainters.tallgrass = P(\n [\n \"................\",\n \"................\",\n \"....g......g....\",\n \"..g.g...g..g.g..\",\n \"..g.g..gg..g.g..\",\n \"..gg.g.g.g.gg...\",\n \"...g.g.g.g.g....\",\n \"...g.gg.gg.g....\",\n \"....gg.g.gg.....\",\n \"....g.gg.g......\",\n \".....g.g.g......\",\n \".....gggg.......\",\n \"......gg........\",\n \"......gg........\",\n \"................\",\n \"................\",\n ],\n { g: \"#4c8a2f\" },\n);\n\npainters.flower_yellow = P(\n [\n \"................\",\n \"................\",\n \"......yy........\",\n \".....yYYy.......\",\n \".....yYYy.......\",\n \"......yy........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { y: \"#e8c11c\", Y: \"#fbe87a\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.flower_red = P(\n [\n \"................\",\n \"................\",\n \"......rr........\",\n \".....rRRr.......\",\n \".....rRRr.......\",\n \"......rr........\",\n \".......s........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e86a5f\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.stick = P(\n [\n \"................\",\n \"................\",\n \"............w...\",\n \"...........ww...\",\n \"..........ww....\",\n \".........ww.....\",\n \"........ww......\",\n \".......ww.......\",\n \"......ww........\",\n \".....ww.........\",\n \"....ww..........\",\n \"...ww...........\",\n \"...w............\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { w: \"#8a6a3a\" },\n);\n\npainters.feather = P(\n [\n \"................\",\n \"...........ff...\",\n \"..........ffff..\",\n \".........fffff..\",\n \"........ffffff..\",\n \".......ffffff...\",\n \"......ffffff....\",\n \".....ffffff.....\",\n \"....ffffff......\",\n \"....fffff.......\",\n \"...ffff.........\",\n \"...fff..........\",\n \"..qf............\",\n \"..q.............\",\n \".q..............\",\n \"................\",\n ],\n { f: \"#f2f2f2\", q: \"#b9b9b9\" },\n);\n\npainters.raw_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPpppppp....\",\n \"...pPppppppppb..\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#e8a2a2\", P: \"#f6caca\", b: \"#e3ddc4\" },\n);\n\npainters.coal_item = P(\n [\n \"................\",\n \"................\",\n \"................\",\n \".....cccc.......\",\n \"....cccccc......\",\n \"...ccCccccc.....\",\n \"...cCccccccc....\",\n \"...ccccCccccc...\",\n \"....ccccccccc...\",\n \"....cccCccccc...\",\n \".....ccccccc....\",\n \"......ccccc.....\",\n \".......ccc......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { c: \"#2c2c2c\", C: \"#4d4d4d\" },\n);\n\npainters.apple = P(\n [\n \"................\",\n \".......g........\",\n \".......s........\",\n \".....rrsr.......\",\n \"....rRRrrr......\",\n \"...rRRrrrrr.....\",\n \"...rRrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"....rrrrrr......\",\n \"....rrrrrr......\",\n \".....rrrr.......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e8635a\", s: \"#6e4a24\", g: \"#4c8a2f\" },\n);\n\npainters.cooked_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPppppbp....\",\n \"...pPpppppppb...\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#c8843e\", P: \"#e0a35a\", b: \"#8a5a2a\" },\n);\n\nconst TOOL_PALETTE = { w: \"#a4824c\", h: \"#6e5631\", s: \"#8a6a3a\" };\npainters.wooden_pickaxe = P(\n [\n \"................\",\n \"....wwwwwww.....\",\n \"...ww.....ww....\",\n \"..ww.......ww...\",\n \"..w.........w...\",\n \"..w....s....w...\",\n \".......s........\",\n \"......ss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_axe = P(\n [\n \"................\",\n \".....wwww.......\",\n \"....wwwwww......\",\n \"....ww..ww......\",\n \"....ww.ss.......\",\n \"....wwss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"..ss............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_sword = P(\n [\n \"................\",\n \"...........ww...\",\n \"..........www...\",\n \".........www....\",\n \"........www.....\",\n \".......www......\",\n \"......www.......\",\n \".....www........\",\n \"..h.www.........\",\n \"...hwww.........\",\n \"...shh..........\",\n \"..ss.hh.........\",\n \"..ss..hh........\",\n \".ss.............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\n\n// ---- стадии трещин (ряд 14, тайлы 224..233) ----------------------------------\n\nfunction crackPainter(stage: number): Painter {\n return (px, rng) => {\n // от центра расходятся stage+1 ломаных трещин; глубже стадия — гуще\n const cracks = 2 + stage;\n for (let c = 0; c < cracks; c++) {\n let x = 7 + rng() * 2,\n y = 7 + rng() * 2;\n const ang = rng() * Math.PI * 2;\n let dx = Math.cos(ang),\n dy = Math.sin(ang);\n const len = 3 + stage * 1.2;\n for (let i = 0; i < len; i++) {\n px(Math.round(x) & 15, Math.round(y) & 15, \"rgba(20,15,10,0.85)\");\n x += dx;\n y += dy;\n dx += (rng() - 0.5) * 0.8;\n dy += (rng() - 0.5) * 0.8; // излом\n const n = Math.hypot(dx, dy) || 1;\n dx /= n;\n dy /= n;\n }\n }\n // на поздних стадиях — крошка по всему тайлу\n for (let i = 0; i < stage * 4; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"rgba(20,15,10,0.7)\");\n };\n}\nfor (let s = 0; s < 10; s++) painters[\"crack\" + s] = crackPainter(s);\n\n// ---- сборка атласа ------------------------------------------------------------\n\n// Порядок = индекс тайла. Ряд 14 зарезервирован под трещины.\nconst TILE_ORDER = [\n \"grass_top\",\n \"grass_side\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"bedrock\",\n \"sand\",\n \"oak_log_side\",\n \"oak_log_top\",\n \"planks\",\n \"leaves\",\n \"water\",\n \"coal_ore\",\n \"iron_ore\",\n \"crafting_table_top\",\n \"crafting_table_side\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n \"stick\",\n \"feather\",\n \"raw_chicken\",\n \"coal_item\",\n \"apple\",\n \"cooked_chicken\",\n \"wooden_pickaxe\",\n \"wooden_axe\",\n \"wooden_sword\",\n];\nconst CRACK_BASE = 14 * GRID; // индекс тайла crack0\n\nexport class TextureAtlas {\n canvas: HTMLCanvasElement;\n tiles: Record<string, number>;\n texture: THREE.CanvasTexture;\n waterTexture: THREE.CanvasTexture;\n opaqueMat: THREE.MeshLambertMaterial;\n foliageMat: THREE.MeshLambertMaterial;\n waterMat: THREE.MeshLambertMaterial;\n _iconCache: Map<string, HTMLCanvasElement>;\n\n constructor() {\n this.canvas = document.createElement(\"canvas\");\n this.canvas.width = this.canvas.height = SIZE;\n const ctx = this.canvas.getContext(\"2d\")!; // код всегда рассчитывает на валидный 2d-контекст\n this.tiles = {}; // имя → индекс\n\n const paint = (name: string, index: number) => {\n const tx = (index % GRID) * TILE,\n ty = ((index / GRID) | 0) * TILE;\n const rng = mulberry32(hashString(\"tile:\" + name));\n const px: PxFn = (x, y, color) => {\n ctx.fillStyle = color;\n ctx.fillRect(tx + x, ty + y, 1, 1);\n };\n painters[name](px, rng);\n };\n\n TILE_ORDER.forEach((name, i) => {\n this.tiles[name] = i;\n paint(name, i);\n });\n for (let s = 0; s < 10; s++) {\n this.tiles[\"crack\" + s] = CRACK_BASE + s;\n paint(\"crack\" + s, CRACK_BASE + s);\n }\n\n this.texture = new THREE.CanvasTexture(this.canvas);\n this.texture.magFilter = THREE.NearestFilter;\n this.texture.minFilter = THREE.NearestFilter;\n this.texture.generateMipmaps = false;\n this.texture.colorSpace = THREE.SRGBColorSpace;\n\n // Отдельная маленькая текстура воды: у водного материала свой repeat-тайл,\n // чтобы анимировать offset, не сдвигая UV всего атласа.\n const wc = document.createElement(\"canvas\");\n wc.width = wc.height = TILE;\n const wctx = wc.getContext(\"2d\")!; // тот же расчёт на валидный 2d-контекст\n const wrng = mulberry32(hashString(\"tile:water-solo\"));\n painters.water((x, y, c) => {\n wctx.fillStyle = c;\n wctx.fillRect(x, y, 1, 1);\n }, wrng);\n this.waterTexture = new THREE.CanvasTexture(wc);\n this.waterTexture.magFilter = this.waterTexture.minFilter =\n THREE.NearestFilter;\n this.waterTexture.generateMipmaps = false;\n this.waterTexture.wrapS = this.waterTexture.wrapT = THREE.RepeatWrapping;\n this.waterTexture.colorSpace = THREE.SRGBColorSpace;\n\n // Материалы чанков — общие на весь мир (3 draw-call-группы).\n this.opaqueMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n });\n this.foliageMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n alphaTest: 0.5,\n side: THREE.DoubleSide,\n });\n this.waterMat = new THREE.MeshLambertMaterial({\n map: this.waterTexture,\n vertexColors: true,\n transparent: true,\n opacity: 0.72,\n depthWrite: false,\n });\n\n this._iconCache = new Map();\n }\n\n /** UV-прямоугольник тайла с полутексельным inset. Индекс или имя. */\n uv(tile: string | number): {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n } {\n const i = typeof tile === \"string\" ? this.tiles[tile] : tile;\n const col = i % GRID,\n row = (i / GRID) | 0;\n const inset = 0.5 / SIZE;\n return {\n u0: col / GRID + inset,\n u1: (col + 1) / GRID - inset,\n // canvas рисует сверху вниз, UV в three — снизу вверх\n v0: 1 - (row + 1) / GRID + inset,\n v1: 1 - row / GRID - inset,\n };\n }\n\n /** Канвас-фрагмент тайла (для иконок). */\n tileCanvas(name: string, shadeAmt = 0): HTMLCanvasElement {\n const i = this.tiles[name];\n const c = document.createElement(\"canvas\");\n c.width = c.height = TILE;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.drawImage(\n this.canvas,\n (i % GRID) * TILE,\n ((i / GRID) | 0) * TILE,\n TILE,\n TILE,\n 0,\n 0,\n TILE,\n TILE,\n );\n if (shadeAmt !== 0) {\n ctx.globalCompositeOperation = \"source-atop\";\n ctx.fillStyle = `rgba(0,0,0,${shadeAmt})`;\n ctx.fillRect(0, 0, TILE, TILE);\n }\n return c;\n }\n\n /** Изометрическая иконка блока (48×48) из его top/side тайлов. */\n blockIcon(topName: string, sideName: string): HTMLCanvasElement {\n const key = \"b:\" + topName + \"/\" + sideName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n const top = this.tileCanvas(topName, 0);\n const left = this.tileCanvas(sideName, 0.25);\n const right = this.tileCanvas(sideName, 0.42);\n const k = 21 / TILE; // размер грани\n // верхняя грань (ромб), затем левая и правая\n ctx.setTransform(k, k * 0.5, -k, k * 0.5, 24, 3);\n ctx.drawImage(top, 0, 0);\n ctx.setTransform(k, k * 0.5, 0, k, 24 - k * TILE, 3 + k * TILE * 0.5);\n ctx.drawImage(left, 0, 0);\n ctx.setTransform(k, -k * 0.5, 0, k, 24, 3 + k * TILE);\n ctx.drawImage(right, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n this._iconCache.set(key, c);\n return c;\n }\n\n /** Плоская иконка предмета (его тайл, 48×48, nearest). */\n flatIcon(tileName: string): HTMLCanvasElement {\n const key = \"f:\" + tileName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n ctx.drawImage(this.tileCanvas(tileName), 0, 0, 48, 48);\n this._iconCache.set(key, c);\n return c;\n }\n}\n"
78
+ "content": "// Процедурный texture atlas: все текстуры рисуются на canvas 256×256 (16×16 тайлов\n// по 16px), никаких внешних файлов. NearestFilter + отключённые мипмапы + полутексельный\n// inset UV — честный пиксель-арт без bleeding.\nimport * as THREE from \"three\";\nimport { mulberry32, hashString } from \"../core/Noise\";\n\nconst TILE = 16; // пикселей в тайле\nconst GRID = 16; // тайлов в ряду\nconst SIZE = TILE * GRID;\n\n/** Закрашивает один пиксель тайла. */\ntype PxFn = (x: number, y: number, color: string) => void;\n/** Сидированный PRNG. */\ntype RngFn = () => number;\n/** painter-функция: рисует один тайл 16×16. */\ntype Painter = (px: PxFn, rng: RngFn) => void;\n\n/** Смешивание hex-цвета с чёрным/белым: amt в [-1..1]. */\nfunction shade(hex: string, amt: number): string {\n const n = parseInt(hex.slice(1), 16);\n let r = (n >> 16) & 255,\n g = (n >> 8) & 255,\n b = n & 255;\n if (amt >= 0) {\n r += (255 - r) * amt;\n g += (255 - g) * amt;\n b += (255 - b) * amt;\n } else {\n r *= 1 + amt;\n g *= 1 + amt;\n b *= 1 + amt;\n }\n return `rgb(${r | 0},${g | 0},${b | 0})`;\n}\n\n/** База: заливка цветом + пиксельный шум яркости. */\nfunction noisy(px: PxFn, base: string, variance: number, rng: RngFn): void {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(x, y, shade(base, (rng() - 0.5) * 2 * variance));\n}\n\n// ---- painter-функции: (px, rng) → рисуют один тайл 16×16 ---------------------\n// px(x, y, cssColor) закрашивает один пиксель тайла.\n\nconst painters: Record<string, Painter> = {\n grass_top(px, rng) {\n noisy(px, \"#5fa63c\", 0.1, rng);\n },\n dirt(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n },\n grass_side(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n for (let x = 0; x < TILE; x++) {\n const depth = 3 + ((rng() * 2) | 0); // рваная кромка дёрна\n for (let y = 0; y < depth; y++)\n px(x, y, shade(\"#5fa63c\", (rng() - 0.5) * 0.2));\n }\n },\n stone(px, rng) {\n noisy(px, \"#7d7d7d\", 0.08, rng);\n for (let i = 0; i < 5; i++) {\n // светлые/тёмные пятна\n const x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0,\n c = shade(\"#7d7d7d\", rng() < 0.5 ? -0.15 : 0.12);\n px(x, y, c);\n px((x + 1) & 15, y, c);\n }\n },\n cobblestone(px, rng) {\n noisy(px, \"#828282\", 0.1, rng);\n // «булыжники»: тёмная сетка неровных швов\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const cell = (x + (y & 4 ? 3 : 0)) % 6 === 0 || y % 6 === 0;\n if (cell && rng() < 0.8)\n px(x, y, shade(\"#4a4a4a\", (rng() - 0.5) * 0.2));\n }\n },\n bedrock(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(rng() < 0.5 ? \"#2b2b2b\" : \"#565656\", (rng() - 0.5) * 0.3),\n );\n },\n sand(px, rng) {\n noisy(px, \"#dcd3a0\", 0.08, rng);\n },\n oak_log_side(px, rng) {\n for (let x = 0; x < TILE; x++) {\n const stripe = x % 4 === 3 ? -0.25 : (rng() - 0.5) * 0.15; // кора полосами\n for (let y = 0; y < TILE; y++)\n px(x, y, shade(\"#6b5030\", stripe + (rng() - 0.5) * 0.08));\n }\n },\n oak_log_top(px, rng) {\n noisy(px, \"#6b5030\", 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++)\n px(x, y, shade(\"#b8945f\", (rng() - 0.5) * 0.15));\n // годовые кольца\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, \"#8a6d42\");\n }\n }\n },\n planks(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#a4824c\", (rng() - 0.5) * 0.12);\n if (y % 4 === 3) c = \"#6e5631\"; // горизонтальные швы досок\n if (\n (y < 4 && x === 11) ||\n (y >= 4 && y < 8 && x === 3) ||\n (y >= 8 && y < 12 && x === 13) ||\n (y >= 12 && x === 6)\n )\n c = \"#6e5631\"; // стыки\n px(x, y, c);\n }\n },\n leaves(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < 0.18) continue; // прозрачные «дырки» (alphaTest-вырезы)\n px(x, y, shade(\"#3e7a25\", (rng() - 0.5) * 0.35));\n }\n },\n water(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#3057d8\", (rng() - 0.5) * 0.12);\n if ((x + y * 2) % 8 === 0 && rng() < 0.5) c = shade(\"#4a74ea\", 0.1); // блики\n px(x, y, c);\n }\n },\n coal_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#2e2e2e\");\n },\n iron_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#d8af93\");\n },\n crafting_table_top(px, rng) {\n painters.planks!(px, rng);\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#59431f\");\n px(i, 15, \"#59431f\");\n px(0, i, \"#59431f\");\n px(15, i, \"#59431f\");\n }\n for (let y = 3; y < 13; y++)\n for (let x = 3; x < 13; x++)\n if (x === 3 || x === 12 || y === 3 || y === 12) px(x, y, \"#7d6134\");\n },\n crafting_table_side(px, rng) {\n painters.planks!(px, rng);\n // «инструменты» на боковине: тёмные силуэты\n for (let y = 2; y < 7; y++)\n for (let x = 3; x < 6; x++) if (rng() < 0.8) px(x, y, \"#4d3a1c\");\n for (let y = 2; y < 7; y++)\n for (let x = 10; x < 13; x++) if (rng() < 0.8) px(x, y, \"#57422a\");\n },\n};\n\nfunction oreBlobs(px: PxFn, rng: RngFn, color: string): void {\n for (let i = 0; i < 5; i++) {\n const cx = (2 + rng() * 12) | 0,\n cy = (2 + rng() * 12) | 0;\n for (let dy = -1; dy <= 1; dy++)\n for (let dx = -1; dx <= 1; dx++)\n if (Math.abs(dx) + Math.abs(dy) < 2 || rng() < 0.4)\n px((cx + dx) & 15, (cy + dy) & 15, shade(color, (rng() - 0.5) * 0.2));\n }\n}\n\n// ---- пиксель-арт по строковым шаблонам (растения, предметы, инструменты) -----\n// Каждая строка — 16 символов; '.' = прозрачно, буквы — цвета из палитры.\n\nfunction patternPainter(\n rows: string[],\n palette: Record<string, string>,\n): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++) {\n const row = rows[y] || \"\";\n for (let x = 0; x < TILE; x++) {\n const ch = row[x];\n if (!ch || ch === \".\") continue;\n px(x, y, shade(palette[ch]!, (rng() - 0.5) * 0.1));\n }\n }\n };\n}\n\nconst P = patternPainter;\n\npainters.tallgrass = P(\n [\n \"................\",\n \"................\",\n \"....g......g....\",\n \"..g.g...g..g.g..\",\n \"..g.g..gg..g.g..\",\n \"..gg.g.g.g.gg...\",\n \"...g.g.g.g.g....\",\n \"...g.gg.gg.g....\",\n \"....gg.g.gg.....\",\n \"....g.gg.g......\",\n \".....g.g.g......\",\n \".....gggg.......\",\n \"......gg........\",\n \"......gg........\",\n \"................\",\n \"................\",\n ],\n { g: \"#4c8a2f\" },\n);\n\npainters.flower_yellow = P(\n [\n \"................\",\n \"................\",\n \"......yy........\",\n \".....yYYy.......\",\n \".....yYYy.......\",\n \"......yy........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { y: \"#e8c11c\", Y: \"#fbe87a\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.flower_red = P(\n [\n \"................\",\n \"................\",\n \"......rr........\",\n \".....rRRr.......\",\n \".....rRRr.......\",\n \"......rr........\",\n \".......s........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e86a5f\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.stick = P(\n [\n \"................\",\n \"................\",\n \"............w...\",\n \"...........ww...\",\n \"..........ww....\",\n \".........ww.....\",\n \"........ww......\",\n \".......ww.......\",\n \"......ww........\",\n \".....ww.........\",\n \"....ww..........\",\n \"...ww...........\",\n \"...w............\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { w: \"#8a6a3a\" },\n);\n\npainters.feather = P(\n [\n \"................\",\n \"...........ff...\",\n \"..........ffff..\",\n \".........fffff..\",\n \"........ffffff..\",\n \".......ffffff...\",\n \"......ffffff....\",\n \".....ffffff.....\",\n \"....ffffff......\",\n \"....fffff.......\",\n \"...ffff.........\",\n \"...fff..........\",\n \"..qf............\",\n \"..q.............\",\n \".q..............\",\n \"................\",\n ],\n { f: \"#f2f2f2\", q: \"#b9b9b9\" },\n);\n\npainters.raw_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPpppppp....\",\n \"...pPppppppppb..\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#e8a2a2\", P: \"#f6caca\", b: \"#e3ddc4\" },\n);\n\npainters.coal_item = P(\n [\n \"................\",\n \"................\",\n \"................\",\n \".....cccc.......\",\n \"....cccccc......\",\n \"...ccCccccc.....\",\n \"...cCccccccc....\",\n \"...ccccCccccc...\",\n \"....ccccccccc...\",\n \"....cccCccccc...\",\n \".....ccccccc....\",\n \"......ccccc.....\",\n \".......ccc......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { c: \"#2c2c2c\", C: \"#4d4d4d\" },\n);\n\npainters.apple = P(\n [\n \"................\",\n \".......g........\",\n \".......s........\",\n \".....rrsr.......\",\n \"....rRRrrr......\",\n \"...rRRrrrrr.....\",\n \"...rRrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"....rrrrrr......\",\n \"....rrrrrr......\",\n \".....rrrr.......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e8635a\", s: \"#6e4a24\", g: \"#4c8a2f\" },\n);\n\npainters.cooked_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPppppbp....\",\n \"...pPpppppppb...\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#c8843e\", P: \"#e0a35a\", b: \"#8a5a2a\" },\n);\n\nconst TOOL_PALETTE = { w: \"#a4824c\", h: \"#6e5631\", s: \"#8a6a3a\" };\npainters.wooden_pickaxe = P(\n [\n \"................\",\n \"....wwwwwww.....\",\n \"...ww.....ww....\",\n \"..ww.......ww...\",\n \"..w.........w...\",\n \"..w....s....w...\",\n \".......s........\",\n \"......ss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_axe = P(\n [\n \"................\",\n \".....wwww.......\",\n \"....wwwwww......\",\n \"....ww..ww......\",\n \"....ww.ss.......\",\n \"....wwss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"..ss............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_sword = P(\n [\n \"................\",\n \"...........ww...\",\n \"..........www...\",\n \".........www....\",\n \"........www.....\",\n \".......www......\",\n \"......www.......\",\n \".....www........\",\n \"..h.www.........\",\n \"...hwww.........\",\n \"...shh..........\",\n \"..ss.hh.........\",\n \"..ss..hh........\",\n \".ss.............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\n\n// ---- стадии трещин (ряд 14, тайлы 224..233) ----------------------------------\n\nfunction crackPainter(stage: number): Painter {\n return (px, rng) => {\n // от центра расходятся stage+1 ломаных трещин; глубже стадия — гуще\n const cracks = 2 + stage;\n for (let c = 0; c < cracks; c++) {\n let x = 7 + rng() * 2,\n y = 7 + rng() * 2;\n const ang = rng() * Math.PI * 2;\n let dx = Math.cos(ang),\n dy = Math.sin(ang);\n const len = 3 + stage * 1.2;\n for (let i = 0; i < len; i++) {\n px(Math.round(x) & 15, Math.round(y) & 15, \"rgba(20,15,10,0.85)\");\n x += dx;\n y += dy;\n dx += (rng() - 0.5) * 0.8;\n dy += (rng() - 0.5) * 0.8; // излом\n const n = Math.hypot(dx, dy) || 1;\n dx /= n;\n dy /= n;\n }\n }\n // на поздних стадиях — крошка по всему тайлу\n for (let i = 0; i < stage * 4; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"rgba(20,15,10,0.7)\");\n };\n}\nfor (let s = 0; s < 10; s++) painters[\"crack\" + s] = crackPainter(s);\n\n// ---- сборка атласа ------------------------------------------------------------\n\n// Порядок = индекс тайла. Ряд 14 зарезервирован под трещины.\nconst TILE_ORDER = [\n \"grass_top\",\n \"grass_side\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"bedrock\",\n \"sand\",\n \"oak_log_side\",\n \"oak_log_top\",\n \"planks\",\n \"leaves\",\n \"water\",\n \"coal_ore\",\n \"iron_ore\",\n \"crafting_table_top\",\n \"crafting_table_side\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n \"stick\",\n \"feather\",\n \"raw_chicken\",\n \"coal_item\",\n \"apple\",\n \"cooked_chicken\",\n \"wooden_pickaxe\",\n \"wooden_axe\",\n \"wooden_sword\",\n];\nconst CRACK_BASE = 14 * GRID; // индекс тайла crack0\n\nexport class TextureAtlas {\n canvas: HTMLCanvasElement;\n tiles: Record<string, number>;\n texture: THREE.CanvasTexture;\n waterTexture: THREE.CanvasTexture;\n opaqueMat: THREE.MeshLambertMaterial;\n foliageMat: THREE.MeshLambertMaterial;\n waterMat: THREE.MeshLambertMaterial;\n _iconCache: Map<string, HTMLCanvasElement>;\n\n constructor() {\n this.canvas = document.createElement(\"canvas\");\n this.canvas.width = this.canvas.height = SIZE;\n const ctx = this.canvas.getContext(\"2d\")!; // код всегда рассчитывает на валидный 2d-контекст\n this.tiles = {}; // имя → индекс\n\n const paint = (name: string, index: number) => {\n const tx = (index % GRID) * TILE,\n ty = ((index / GRID) | 0) * TILE;\n const rng = mulberry32(hashString(\"tile:\" + name));\n const px: PxFn = (x, y, color) => {\n ctx.fillStyle = color;\n ctx.fillRect(tx + x, ty + y, 1, 1);\n };\n painters[name]!(px, rng);\n };\n\n TILE_ORDER.forEach((name, i) => {\n this.tiles[name] = i;\n paint(name, i);\n });\n for (let s = 0; s < 10; s++) {\n this.tiles[\"crack\" + s] = CRACK_BASE + s;\n paint(\"crack\" + s, CRACK_BASE + s);\n }\n\n this.texture = new THREE.CanvasTexture(this.canvas);\n this.texture.magFilter = THREE.NearestFilter;\n this.texture.minFilter = THREE.NearestFilter;\n this.texture.generateMipmaps = false;\n this.texture.colorSpace = THREE.SRGBColorSpace;\n\n // Отдельная маленькая текстура воды: у водного материала свой repeat-тайл,\n // чтобы анимировать offset, не сдвигая UV всего атласа.\n const wc = document.createElement(\"canvas\");\n wc.width = wc.height = TILE;\n const wctx = wc.getContext(\"2d\")!; // тот же расчёт на валидный 2d-контекст\n const wrng = mulberry32(hashString(\"tile:water-solo\"));\n painters.water!((x, y, c) => {\n wctx.fillStyle = c;\n wctx.fillRect(x, y, 1, 1);\n }, wrng);\n this.waterTexture = new THREE.CanvasTexture(wc);\n this.waterTexture.magFilter = this.waterTexture.minFilter =\n THREE.NearestFilter;\n this.waterTexture.generateMipmaps = false;\n this.waterTexture.wrapS = this.waterTexture.wrapT = THREE.RepeatWrapping;\n this.waterTexture.colorSpace = THREE.SRGBColorSpace;\n\n // Материалы чанков — общие на весь мир (3 draw-call-группы).\n this.opaqueMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n });\n this.foliageMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n alphaTest: 0.5,\n side: THREE.DoubleSide,\n });\n this.waterMat = new THREE.MeshLambertMaterial({\n map: this.waterTexture,\n vertexColors: true,\n transparent: true,\n opacity: 0.72,\n depthWrite: false,\n });\n\n this._iconCache = new Map();\n }\n\n /** UV-прямоугольник тайла с полутексельным inset. Индекс или имя. */\n uv(tile: string | number): {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n } {\n const i = typeof tile === \"string\" ? this.tiles[tile]! : tile;\n const col = i % GRID,\n row = (i / GRID) | 0;\n const inset = 0.5 / SIZE;\n return {\n u0: col / GRID + inset,\n u1: (col + 1) / GRID - inset,\n // canvas рисует сверху вниз, UV в three — снизу вверх\n v0: 1 - (row + 1) / GRID + inset,\n v1: 1 - row / GRID - inset,\n };\n }\n\n /** Канвас-фрагмент тайла (для иконок). */\n tileCanvas(name: string, shadeAmt = 0): HTMLCanvasElement {\n const i = this.tiles[name]!;\n const c = document.createElement(\"canvas\");\n c.width = c.height = TILE;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.drawImage(\n this.canvas,\n (i % GRID) * TILE,\n ((i / GRID) | 0) * TILE,\n TILE,\n TILE,\n 0,\n 0,\n TILE,\n TILE,\n );\n if (shadeAmt !== 0) {\n ctx.globalCompositeOperation = \"source-atop\";\n ctx.fillStyle = `rgba(0,0,0,${shadeAmt})`;\n ctx.fillRect(0, 0, TILE, TILE);\n }\n return c;\n }\n\n /** Изометрическая иконка блока (48×48) из его top/side тайлов. */\n blockIcon(topName: string, sideName: string): HTMLCanvasElement {\n const key = \"b:\" + topName + \"/\" + sideName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n const top = this.tileCanvas(topName, 0);\n const left = this.tileCanvas(sideName, 0.25);\n const right = this.tileCanvas(sideName, 0.42);\n const k = 21 / TILE; // размер грани\n // верхняя грань (ромб), затем левая и правая\n ctx.setTransform(k, k * 0.5, -k, k * 0.5, 24, 3);\n ctx.drawImage(top, 0, 0);\n ctx.setTransform(k, k * 0.5, 0, k, 24 - k * TILE, 3 + k * TILE * 0.5);\n ctx.drawImage(left, 0, 0);\n ctx.setTransform(k, -k * 0.5, 0, k, 24, 3 + k * TILE);\n ctx.drawImage(right, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n this._iconCache.set(key, c);\n return c;\n }\n\n /** Плоская иконка предмета (его тайл, 48×48, nearest). */\n flatIcon(tileName: string): HTMLCanvasElement {\n const key = \"f:\" + tileName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n ctx.drawImage(this.tileCanvas(tileName), 0, 0, 48, 48);\n this._iconCache.set(key, c);\n return c;\n }\n}\n"
79
79
  },
80
80
  {
81
81
  "path": "index.ts",
@@ -91,7 +91,7 @@
91
91
  },
92
92
  {
93
93
  "path": "player/ArmView.ts",
94
- "content": "// Рука от первого лица: блочная рука в стиле Minecraft, прикреплена к камере.\n// Если в руке блок — вместо руки показывается мини-куб блока; плоские предметы —\n// квад с иконкой. Замах — процедурная анимация по событию armSwing.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\n\nexport class ArmView {\n camera: THREE.PerspectiveCamera;\n atlas: TextureAtlas;\n group: THREE.Group;\n armMesh: THREE.Mesh;\n itemMesh: THREE.Mesh | null;\n _heldKey: string | null;\n _swing: number;\n _texCache: Map<string, THREE.CanvasTexture>;\n _dirty: boolean;\n\n constructor(camera: THREE.PerspectiveCamera, atlas: TextureAtlas) {\n this.camera = camera;\n this.atlas = atlas;\n this.group = new THREE.Group();\n camera.add(this.group);\n this.group.position.set(0.42, -0.42, -0.7);\n\n // сама рука: «кожа» с рукавом\n const armMat = new THREE.MeshLambertMaterial({ color: 0xd8a06c });\n this.armMesh = new THREE.Mesh(\n new THREE.BoxGeometry(0.14, 0.14, 0.5),\n armMat,\n );\n this.armMesh.rotation.set(0.35, -0.25, 0);\n this.group.add(this.armMesh);\n\n this.itemMesh = null; // мини-блок/иконка в руке\n this._heldKey = null;\n this._swing = 0; // 0..1 — фаза замаха\n this._texCache = new Map();\n\n events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n events.on(\"invChanged\", () => {\n this._dirty = true;\n });\n this._dirty = true;\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const key = held ? held.item : null;\n if (key === this._heldKey && !this._dirty) return;\n this._heldKey = key;\n this._dirty = false;\n\n if (this.itemMesh) {\n this.group.remove(this.itemMesh);\n this.itemMesh.geometry.dispose();\n this.itemMesh = null;\n }\n this.armMesh.visible = !key;\n if (!key) return;\n\n const item = ITEMS[key];\n if (item.blockId !== null && !BLOCKS[item.blockId].cross) {\n const def = BLOCKS[item.blockId];\n const geo = new THREE.BoxGeometry(0.3, 0.3, 0.3);\n const uvAttr = geo.getAttribute(\"uv\");\n const tiles = [\n def.tex!.side!,\n def.tex!.side!,\n def.tex!.top!,\n def.tex!.bottom!,\n def.tex!.side!,\n def.tex!.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = this.atlas.uv(tiles[f]);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // белые вершинные цвета: общий материал чанков ждёт color-атрибут (vertexColors)\n geo.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(geo.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n this.itemMesh = new THREE.Mesh(geo, this.atlas.opaqueMat);\n this.itemMesh.position.set(0, -0.02, -0.1);\n this.itemMesh.rotation.set(0.1, Math.PI / 5, 0);\n } else {\n let tex = this._texCache.get(key);\n if (!tex) {\n tex = new THREE.CanvasTexture(item.icon);\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n this._texCache.set(key, tex);\n }\n const mat = new THREE.MeshBasicMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n this.itemMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.4, 0.4), mat);\n this.itemMesh.position.set(0, 0, -0.1);\n this.itemMesh.rotation.set(-0.15, Math.PI / 6, 0.5);\n }\n this.group.add(this.itemMesh);\n }\n\n frame(\n dt: number,\n inventory: Inventory,\n walking: boolean,\n time: number,\n ): void {\n this._syncHeld(inventory);\n\n // замах: быстрый удар вперёд-вниз с возвратом\n if (this._swing > 0) this._swing = Math.max(0, this._swing - dt * 3.2);\n const s = this._swing;\n const swingAng = Math.sin(s * Math.PI) * 1.1;\n\n // лёгкое покачивание при ходьбе\n const bobX = walking ? Math.sin(time * 7) * 0.015 : 0;\n const bobY = walking ? Math.abs(Math.cos(time * 7)) * 0.02 : 0;\n\n this.group.position.set(\n 0.42 + bobX,\n -0.42 + bobY - swingAng * 0.25,\n -0.7 - swingAng * 0.1,\n );\n this.group.rotation.set(-swingAng * 0.9, swingAng * 0.35, 0);\n }\n}\n"
94
+ "content": "// Рука от первого лица: блочная рука в стиле Minecraft, прикреплена к камере.\n// Если в руке блок — вместо руки показывается мини-куб блока; плоские предметы —\n// квад с иконкой. Замах — процедурная анимация по событию armSwing.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\n\nexport class ArmView {\n camera: THREE.PerspectiveCamera;\n atlas: TextureAtlas;\n group: THREE.Group;\n armMesh: THREE.Mesh;\n itemMesh: THREE.Mesh | null;\n _heldKey: string | null;\n _swing: number;\n _texCache: Map<string, THREE.CanvasTexture>;\n _dirty: boolean;\n\n constructor(camera: THREE.PerspectiveCamera, atlas: TextureAtlas) {\n this.camera = camera;\n this.atlas = atlas;\n this.group = new THREE.Group();\n camera.add(this.group);\n this.group.position.set(0.42, -0.42, -0.7);\n\n // сама рука: «кожа» с рукавом\n const armMat = new THREE.MeshLambertMaterial({ color: 0xd8a06c });\n this.armMesh = new THREE.Mesh(\n new THREE.BoxGeometry(0.14, 0.14, 0.5),\n armMat,\n );\n this.armMesh.rotation.set(0.35, -0.25, 0);\n this.group.add(this.armMesh);\n\n this.itemMesh = null; // мини-блок/иконка в руке\n this._heldKey = null;\n this._swing = 0; // 0..1 — фаза замаха\n this._texCache = new Map();\n\n events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n events.on(\"invChanged\", () => {\n this._dirty = true;\n });\n this._dirty = true;\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const key = held ? held.item : null;\n if (key === this._heldKey && !this._dirty) return;\n this._heldKey = key;\n this._dirty = false;\n\n if (this.itemMesh) {\n this.group.remove(this.itemMesh);\n this.itemMesh.geometry.dispose();\n this.itemMesh = null;\n }\n this.armMesh.visible = !key;\n if (!key) return;\n\n const item = ITEMS[key]!;\n if (item.blockId !== null && !BLOCKS[item.blockId]!.cross) {\n const def = BLOCKS[item.blockId]!;\n const geo = new THREE.BoxGeometry(0.3, 0.3, 0.3);\n const uvAttr = geo.getAttribute(\"uv\");\n const tiles = [\n def.tex!.side!,\n def.tex!.side!,\n def.tex!.top!,\n def.tex!.bottom!,\n def.tex!.side!,\n def.tex!.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = this.atlas.uv(tiles[f]!);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // белые вершинные цвета: общий материал чанков ждёт color-атрибут (vertexColors)\n geo.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(geo.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n this.itemMesh = new THREE.Mesh(geo, this.atlas.opaqueMat);\n this.itemMesh.position.set(0, -0.02, -0.1);\n this.itemMesh.rotation.set(0.1, Math.PI / 5, 0);\n } else {\n let tex = this._texCache.get(key);\n if (!tex) {\n tex = new THREE.CanvasTexture(item.icon);\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n this._texCache.set(key, tex);\n }\n const mat = new THREE.MeshBasicMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n this.itemMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.4, 0.4), mat);\n this.itemMesh.position.set(0, 0, -0.1);\n this.itemMesh.rotation.set(-0.15, Math.PI / 6, 0.5);\n }\n this.group.add(this.itemMesh);\n }\n\n frame(\n dt: number,\n inventory: Inventory,\n walking: boolean,\n time: number,\n ): void {\n this._syncHeld(inventory);\n\n // замах: быстрый удар вперёд-вниз с возвратом\n if (this._swing > 0) this._swing = Math.max(0, this._swing - dt * 3.2);\n const s = this._swing;\n const swingAng = Math.sin(s * Math.PI) * 1.1;\n\n // лёгкое покачивание при ходьбе\n const bobX = walking ? Math.sin(time * 7) * 0.015 : 0;\n const bobY = walking ? Math.abs(Math.cos(time * 7)) * 0.02 : 0;\n\n this.group.position.set(\n 0.42 + bobX,\n -0.42 + bobY - swingAng * 0.25,\n -0.7 - swingAng * 0.1,\n );\n this.group.rotation.set(-swingAng * 0.9, swingAng * 0.35, 0);\n }\n}\n"
95
95
  },
96
96
  {
97
97
  "path": "player/Input.ts",
@@ -99,11 +99,11 @@
99
99
  },
100
100
  {
101
101
  "path": "player/Interaction.ts",
102
- "content": "// Взаимодействие с миром. Разделено на две половины:\n// frame() — «клиентская»: raycast из камеры, рамка выделения, перевод\n// состояния ввода (зажатая ЛКМ, цель) в интенты;\n// tick() — «серверная»: выгребает очередь интентов, валидирует\n// (дистанция, наличие предмета, кулдаун) и исполняет.\n// В этапе 2 tick()-половина живёт только у хоста; frame()-половина у всех.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentMsg, type IntentQueue } from \"../core/Intents\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { raycastBlocks } from \"../world/Raycast\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"./Player\";\nimport type { Input } from \"./Input\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EntityManager } from \"../entities/EntityManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n/** Блок под прицелом (результат воксельного raycast). */\ntype BlockHit = NonNullable<ReturnType<typeof raycastBlocks>>;\n/** Ближайшая сущность под прицелом. */\ntype EntityHit = ReturnType<EntityManager[\"raycast\"]>;\n/** Состояние ломания блока (тиковая половина). */\ninterface BreakInfo {\n x: number;\n y: number;\n z: number;\n id: number;\n progress: number;\n total: number;\n}\n/** Накопленный урон по блоку: прогресс + таймер заживления. */\ninterface DamageInfo {\n id: number;\n progress: number;\n heal: number;\n}\n\nexport class Interaction {\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n intents: IntentQueue;\n entities: EntityManager;\n target: BlockHit | null;\n targetEntity: EntityHit;\n breaking: BreakInfo | null;\n _breakHeld: boolean;\n _sentBreakKey: string | null;\n _placeCooldown: number;\n _attackCooldown: number;\n eating: { item: string; time: number } | null;\n _sentEat: string | null;\n damage: Map<string, DamageInfo>;\n highlight: THREE.LineSegments;\n crackMat: THREE.MeshBasicMaterial;\n crackMesh: THREE.Mesh;\n atlas: TextureAtlas;\n _crackStage: number;\n _swungThisHold = false;\n\n constructor(\n world: ChunkManager,\n player: Player,\n inventory: Inventory,\n intents: IntentQueue,\n scene: THREE.Scene,\n atlas: TextureAtlas,\n entities: EntityManager,\n ) {\n this.world = world;\n this.player = player;\n this.inventory = inventory;\n this.intents = intents;\n this.entities = entities;\n this.target = null; // текущий блок под прицелом (кадровая половина)\n this.targetEntity = null;\n\n // Состояние ломания (тиковая половина — авторитетно)\n this.breaking = null; // {x,y,z,id, progress, total}\n this._breakHeld = false;\n this._sentBreakKey = null;\n this._placeCooldown = 0;\n this._attackCooldown = 0;\n this.eating = null; // {item, time} — процесс поедания (тиковая половина)\n this._sentEat = null; // защита от повторной отправки EatStart за удержание\n // Накопленный урон по блокам: \"x,y,z\" -> {id, progress, heal}.\n // Сохраняется при отпускании ЛКМ и медленно «заживает» в простое.\n this.damage = new Map();\n // блок сменился (сломан/заменён/затоплен) — забыть его урон\n events.on(\"blockChanged\", ({ x, y, z }) =>\n this.damage.delete(x + \",\" + y + \",\" + z),\n );\n\n // рамка выделения блока\n const boxGeo = new THREE.BoxGeometry(1.002, 1.002, 1.002);\n this.highlight = new THREE.LineSegments(\n new THREE.EdgesGeometry(boxGeo),\n new THREE.LineBasicMaterial({\n color: 0x111111,\n transparent: true,\n opacity: 0.6,\n }),\n );\n this.highlight.visible = false;\n scene.add(this.highlight);\n\n // decal трещин: чуть увеличенный куб с прозрачной текстурой стадии\n this.crackMat = new THREE.MeshBasicMaterial({\n map: atlas.texture.clone(),\n transparent: true,\n depthWrite: false,\n polygonOffset: true,\n polygonOffsetFactor: -1,\n polygonOffsetUnits: -2,\n });\n this.crackMat.map!.magFilter = this.crackMat.map!.minFilter =\n THREE.NearestFilter;\n this.crackMat.map!.generateMipmaps = false;\n this.crackMesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.004, 1.004, 1.004),\n this.crackMat,\n );\n this.crackMesh.visible = false;\n this.crackMesh.renderOrder = 2;\n scene.add(this.crackMesh);\n this.atlas = atlas;\n this._crackStage = -1;\n }\n\n // ---- кадровая половина ------------------------------------------------------\n\n frame(input: Input): void {\n const eye = this.player.eyePos;\n const dir = this.player.lookDir();\n const hit = raycastBlocks(this.world, eye, dir, CONFIG.player.reach);\n // сущность ближе блока?\n const entHit = this.entities.raycast(\n eye,\n dir,\n hit ? hit.dist : CONFIG.player.reach,\n );\n this.targetEntity = entHit;\n this.target = entHit ? null : hit;\n\n if (this.target) {\n this.highlight.visible = true;\n this.highlight.position.set(\n this.target.x + 0.5,\n this.target.y + 0.5,\n this.target.z + 0.5,\n );\n } else {\n this.highlight.visible = false;\n }\n\n // ЛКМ: по сущности — атака (разовая), по блоку — ломание (удержание)\n if (input.leftDown && !input.uiOpen) {\n if (entHit) {\n this.intents.push(Intent.Attack, { entityId: entHit.id });\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n } else if (this.target) {\n const k = this.target.x + \",\" + this.target.y + \",\" + this.target.z;\n if (this._sentBreakKey !== k) {\n this.intents.push(Intent.BreakStart, {\n x: this.target.x,\n y: this.target.y,\n z: this.target.z,\n });\n this._sentBreakKey = k;\n }\n } else if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n if (!this._swungThisHold) {\n events.emit(\"armSwing\");\n this._swungThisHold = true;\n }\n } else {\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n this._swungThisHold = false;\n }\n\n // ПКМ: приоритет — интерактивный блок (верстак) > еда > постановка блока\n const held = this.inventory.heldItem();\n const heldFood = held && ITEMS[held.item]?.food > 0;\n const lookingAtTable =\n this.target && BLOCKS[this.target.id].name === \"crafting_table\";\n let eatingNow = false;\n if (input.rightDown && !input.uiOpen) {\n if (lookingAtTable && this._placeCooldown <= 0) {\n events.emit(\"ui:openCrafting\");\n this._placeCooldown = 0.25;\n } else if (\n heldFood &&\n this.player.food < CONFIG.player.maxFood &&\n this.player.mode !== \"creative\"\n ) {\n eatingNow = true;\n if (this._sentEat !== held.item) {\n this.intents.push(Intent.EatStart, { item: held.item });\n this._sentEat = held.item;\n }\n } else if (this.target && this._placeCooldown <= 0) {\n this.intents.push(Intent.PlaceBlock, {\n x: this.target.x + this.target.nx,\n y: this.target.y + this.target.ny,\n z: this.target.z + this.target.nz,\n });\n events.emit(\"armSwing\");\n this._placeCooldown = 0.25;\n }\n }\n if (!eatingNow && this._sentEat) {\n this.intents.push(Intent.EatStop);\n this._sentEat = null;\n }\n if (!input.rightDown) this._placeCooldown = 0;\n\n // визуализация трещин: активное ломание либо запомненный урон на блоке под прицелом\n let crack: { x: number; y: number; z: number; frac: number } | null = null;\n if (this.breaking && this.breaking.total > 0.06) {\n crack = {\n x: this.breaking.x,\n y: this.breaking.y,\n z: this.breaking.z,\n frac: this.breaking.progress / this.breaking.total,\n };\n } else if (this.target) {\n const d = this.damage.get(\n this.target.x + \",\" + this.target.y + \",\" + this.target.z,\n );\n const total = BLOCKS[this.target.id].hardness;\n if (d && d.id === this.target.id && d.progress > 0 && total > 0.06)\n crack = {\n x: this.target.x,\n y: this.target.y,\n z: this.target.z,\n frac: d.progress / total,\n };\n }\n\n if (crack) {\n const stage = Math.min(9, Math.floor(crack.frac * 10));\n if (stage !== this._crackStage) {\n const uv = this.atlas.uv(\"crack\" + stage);\n // двигаем offset/repeat клона атласа на нужный тайл\n this.crackMat.map!.repeat.set(uv.u1 - uv.u0, uv.v1 - uv.v0);\n this.crackMat.map!.offset.set(uv.u0, uv.v0);\n this._crackStage = stage;\n }\n this.crackMesh.visible = true;\n this.crackMesh.position.set(crack.x + 0.5, crack.y + 0.5, crack.z + 0.5);\n } else {\n this.crackMesh.visible = false;\n this._crackStage = -1;\n }\n }\n\n frameTimers(dt: number): void {\n if (this._placeCooldown > 0) this._placeCooldown -= dt;\n }\n\n // ---- тиковая половина (симуляция) --------------------------------------------\n\n tick(dt: number): void {\n if (this._attackCooldown > 0) this._attackCooldown -= dt;\n\n this.intents.drain((intent: IntentMsg) => {\n switch (intent.type) {\n case Intent.BreakStart:\n this._startBreak(intent);\n break;\n case Intent.BreakStop:\n this.breaking = null;\n break;\n case Intent.PlaceBlock:\n this._place(intent);\n break;\n case Intent.EatStart:\n this._startEat(intent);\n break;\n case Intent.EatStop:\n this.eating = null;\n break;\n case Intent.Attack:\n this._attack(intent);\n break;\n case Intent.SelectSlot:\n if (intent.slot !== undefined) this.inventory.selectSlot(intent.slot);\n else this.inventory.scrollSlot(intent.delta);\n break;\n case Intent.ToggleMode:\n this.player.toggleMode();\n break;\n }\n });\n\n // прогресс ломания\n let activeKey: string | null = null;\n if (this.breaking) {\n const { x, y, z } = this.breaking;\n if (this.world.getBlock(x, y, z) !== this.breaking.id) {\n this.damage.delete(x + \",\" + y + \",\" + z);\n this.breaking = null;\n } else {\n this.breaking.progress += dt * this._breakSpeed(this.breaking.id);\n activeKey = x + \",\" + y + \",\" + z;\n // запоминаем урон (heal:0 — таймер заживления сбрасывается, пока бьём)\n this.damage.set(activeKey, {\n id: this.breaking.id,\n progress: this.breaking.progress,\n heal: 0,\n });\n if (this.breaking.progress >= this.breaking.total) {\n this._finishBreak(x, y, z, this.breaking.id);\n this.damage.delete(activeKey);\n this.breaking = null;\n }\n }\n }\n\n // заживление урона по блокам, которые сейчас не ломают\n for (const [k, d] of this.damage) {\n if (k === activeKey) continue;\n d.heal += dt;\n if (d.heal < CONFIG.player.breakHealDelay) continue;\n d.progress -= dt * CONFIG.player.breakHealRate;\n if (d.progress <= 0) this.damage.delete(k);\n }\n\n // процесс поедания\n if (this.eating) {\n const held = this.inventory.heldItem();\n if (\n !held ||\n held.item !== this.eating.item ||\n this.player.food >= CONFIG.player.maxFood\n ) {\n this.eating = null;\n } else {\n this.eating.time += dt;\n events.emit(\"armSwing\"); // лёгкое подёргивание еды в руке\n if (this.eating.time >= CONFIG.player.eatTime) {\n if (this.player.eat(this.eating.item)) this.inventory.consumeHeld(1);\n this.eating = null;\n }\n }\n }\n }\n\n _startEat({ item }: IntentMsg): void {\n const held = this.inventory.heldItem();\n if (!held || held.item !== item) return;\n if (!ITEMS[item]?.food || this.player.food >= CONFIG.player.maxFood) return;\n this.eating = { item, time: 0 };\n }\n\n _validDistance(x: number, y: number, z: number): boolean {\n const e = this.player.eyePos;\n const dx = x + 0.5 - e.x,\n dy = y + 0.5 - e.y,\n dz = z + 0.5 - e.z;\n return dx * dx + dy * dy + dz * dz <= (CONFIG.player.reach + 1) ** 2;\n }\n\n /** Множитель скорости от инструмента в руке. */\n _breakSpeed(blockId: number): number {\n const def = BLOCKS[blockId];\n const held = this.inventory.heldItem();\n if (held && def.tool && ITEMS[held.item]?.toolType === def.tool)\n return ITEMS[held.item].speed;\n return 1;\n }\n\n _startBreak({ x, y, z }: IntentMsg): void {\n if (!this._validDistance(x, y, z)) return;\n const id = this.world.getBlock(x, y, z);\n const def = BLOCKS[id];\n if (id === B.air || def.liquid || def.hardness === Infinity) return;\n const total = this.player.mode === \"creative\" ? 0 : def.hardness;\n // возобновляем с запомненного урона (если это тот же блок), а не с нуля\n const stored = this.damage.get(x + \",\" + y + \",\" + z);\n const progress = stored && stored.id === id ? stored.progress : 0;\n this.breaking = { x, y, z, id, progress, total };\n if (total <= 0.05) {\n // мгновенное ломание\n this._finishBreak(x, y, z, id);\n this.damage.delete(x + \",\" + y + \",\" + z);\n this.breaking = null;\n }\n }\n\n _finishBreak(x: number, y: number, z: number, id: number): void {\n const def = BLOCKS[id];\n this.world.setBlock(x, y, z, B.air);\n events.emit(\"blockBreak\", { x, y, z, id, byPlayer: true });\n this.player.addExhaustion(CONFIG.player.exhaustBreak);\n // дроп: null = сам блок; [] = ничего; chance — вероятность (по умолчанию 1); в креативе дропов нет\n if (this.player.mode !== \"creative\") {\n const drops = def.drops === null ? [{ item: def.name, n: 1 }] : def.drops;\n for (const d of drops)\n if (\n ITEMS[d.item] &&\n (d.chance === undefined || Math.random() <= d.chance)\n )\n this.entities.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n }\n // если на блоке стояло растение — оно падает (ломается)\n const above = this.world.getBlock(x, y + 1, z);\n if (BLOCKS[above]?.cross) {\n this.world.setBlock(x, y + 1, z, B.air);\n events.emit(\"blockBreak\", { x, y: y + 1, z, id: above, byPlayer: false });\n }\n }\n\n _place({ x, y, z }: IntentMsg): void {\n if (!this._validDistance(x, y, z)) return;\n const held = this.inventory.heldItem();\n if (!held) return;\n const item = ITEMS[held.item];\n if (!item || item.blockId === null) return;\n const cur = this.world.getBlock(x, y, z);\n if (cur !== B.air && !BLOCKS[cur].liquid && !BLOCKS[cur].cross) return;\n if (BLOCKS[item.blockId].solid && this.player.intersectsBlock(x, y, z))\n return; // не в себя\n // крестовины — только на твёрдое основание\n if (BLOCKS[item.blockId].cross && !this.world.isSolid(x, y - 1, z)) return;\n\n this.world.setBlock(x, y, z, item.blockId);\n events.emit(\"blockPlace\", { x, y, z, id: item.blockId });\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n }\n\n _attack({ entityId }: IntentMsg): void {\n if (this._attackCooldown > 0) return;\n this._attackCooldown = CONFIG.player.attackCooldown;\n this.player.addExhaustion(CONFIG.player.exhaustAttack);\n const held = this.inventory.heldItem();\n const damage = held\n ? (ITEMS[held.item]?.damage ?? 1)\n : CONFIG.player.attackDamage;\n this.entities.hurt(entityId, damage, this.player.pos);\n }\n}\n"
102
+ "content": "// Взаимодействие с миром. Разделено на две половины:\n// frame() — «клиентская»: raycast из камеры, рамка выделения, перевод\n// состояния ввода (зажатая ЛКМ, цель) в интенты;\n// tick() — «серверная»: выгребает очередь интентов, валидирует\n// (дистанция, наличие предмета, кулдаун) и исполняет.\n// В этапе 2 tick()-половина живёт только у хоста; frame()-половина у всех.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentMsg, type IntentQueue } from \"../core/Intents\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { raycastBlocks } from \"../world/Raycast\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"./Player\";\nimport type { Input } from \"./Input\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EntityManager } from \"../entities/EntityManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n/** Блок под прицелом (результат воксельного raycast). */\ntype BlockHit = NonNullable<ReturnType<typeof raycastBlocks>>;\n/** Ближайшая сущность под прицелом. */\ntype EntityHit = ReturnType<EntityManager[\"raycast\"]>;\n/** Состояние ломания блока (тиковая половина). */\ninterface BreakInfo {\n x: number;\n y: number;\n z: number;\n id: number;\n progress: number;\n total: number;\n}\n/** Накопленный урон по блоку: прогресс + таймер заживления. */\ninterface DamageInfo {\n id: number;\n progress: number;\n heal: number;\n}\n\nexport class Interaction {\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n intents: IntentQueue;\n entities: EntityManager;\n target: BlockHit | null;\n targetEntity: EntityHit;\n breaking: BreakInfo | null;\n _breakHeld: boolean;\n _sentBreakKey: string | null;\n _placeCooldown: number;\n _attackCooldown: number;\n eating: { item: string; time: number } | null;\n _sentEat: string | null;\n damage: Map<string, DamageInfo>;\n highlight: THREE.LineSegments;\n crackMat: THREE.MeshBasicMaterial;\n crackMesh: THREE.Mesh;\n atlas: TextureAtlas;\n _crackStage: number;\n _swungThisHold = false;\n\n constructor(\n world: ChunkManager,\n player: Player,\n inventory: Inventory,\n intents: IntentQueue,\n scene: THREE.Scene,\n atlas: TextureAtlas,\n entities: EntityManager,\n ) {\n this.world = world;\n this.player = player;\n this.inventory = inventory;\n this.intents = intents;\n this.entities = entities;\n this.target = null; // текущий блок под прицелом (кадровая половина)\n this.targetEntity = null;\n\n // Состояние ломания (тиковая половина — авторитетно)\n this.breaking = null; // {x,y,z,id, progress, total}\n this._breakHeld = false;\n this._sentBreakKey = null;\n this._placeCooldown = 0;\n this._attackCooldown = 0;\n this.eating = null; // {item, time} — процесс поедания (тиковая половина)\n this._sentEat = null; // защита от повторной отправки EatStart за удержание\n // Накопленный урон по блокам: \"x,y,z\" -> {id, progress, heal}.\n // Сохраняется при отпускании ЛКМ и медленно «заживает» в простое.\n this.damage = new Map();\n // блок сменился (сломан/заменён/затоплен) — забыть его урон\n events.on(\"blockChanged\", ({ x, y, z }) =>\n this.damage.delete(x + \",\" + y + \",\" + z),\n );\n\n // рамка выделения блока\n const boxGeo = new THREE.BoxGeometry(1.002, 1.002, 1.002);\n this.highlight = new THREE.LineSegments(\n new THREE.EdgesGeometry(boxGeo),\n new THREE.LineBasicMaterial({\n color: 0x111111,\n transparent: true,\n opacity: 0.6,\n }),\n );\n this.highlight.visible = false;\n scene.add(this.highlight);\n\n // decal трещин: чуть увеличенный куб с прозрачной текстурой стадии\n this.crackMat = new THREE.MeshBasicMaterial({\n map: atlas.texture.clone(),\n transparent: true,\n depthWrite: false,\n polygonOffset: true,\n polygonOffsetFactor: -1,\n polygonOffsetUnits: -2,\n });\n this.crackMat.map!.magFilter = this.crackMat.map!.minFilter =\n THREE.NearestFilter;\n this.crackMat.map!.generateMipmaps = false;\n this.crackMesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.004, 1.004, 1.004),\n this.crackMat,\n );\n this.crackMesh.visible = false;\n this.crackMesh.renderOrder = 2;\n scene.add(this.crackMesh);\n this.atlas = atlas;\n this._crackStage = -1;\n }\n\n // ---- кадровая половина ------------------------------------------------------\n\n frame(input: Input): void {\n const eye = this.player.eyePos;\n const dir = this.player.lookDir();\n const hit = raycastBlocks(this.world, eye, dir, CONFIG.player.reach);\n // сущность ближе блока?\n const entHit = this.entities.raycast(\n eye,\n dir,\n hit ? hit.dist : CONFIG.player.reach,\n );\n this.targetEntity = entHit;\n this.target = entHit ? null : hit;\n\n if (this.target) {\n this.highlight.visible = true;\n this.highlight.position.set(\n this.target.x + 0.5,\n this.target.y + 0.5,\n this.target.z + 0.5,\n );\n } else {\n this.highlight.visible = false;\n }\n\n // ЛКМ: по сущности — атака (разовая), по блоку — ломание (удержание)\n if (input.leftDown && !input.uiOpen) {\n if (entHit) {\n this.intents.push(Intent.Attack, { entityId: entHit.id });\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n } else if (this.target) {\n const k = this.target.x + \",\" + this.target.y + \",\" + this.target.z;\n if (this._sentBreakKey !== k) {\n this.intents.push(Intent.BreakStart, {\n x: this.target.x,\n y: this.target.y,\n z: this.target.z,\n });\n this._sentBreakKey = k;\n }\n } else if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n if (!this._swungThisHold) {\n events.emit(\"armSwing\");\n this._swungThisHold = true;\n }\n } else {\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n this._swungThisHold = false;\n }\n\n // ПКМ: приоритет — интерактивный блок (верстак) > еда > постановка блока\n const held = this.inventory.heldItem();\n const heldFood = held && (ITEMS[held.item]?.food ?? 0) > 0;\n const lookingAtTable =\n this.target && BLOCKS[this.target.id]!.name === \"crafting_table\";\n let eatingNow = false;\n if (input.rightDown && !input.uiOpen) {\n if (lookingAtTable && this._placeCooldown <= 0) {\n events.emit(\"ui:openCrafting\");\n this._placeCooldown = 0.25;\n } else if (\n heldFood &&\n this.player.food < CONFIG.player.maxFood &&\n this.player.mode !== \"creative\"\n ) {\n eatingNow = true;\n if (this._sentEat !== held.item) {\n this.intents.push(Intent.EatStart, { item: held.item });\n this._sentEat = held.item;\n }\n } else if (this.target && this._placeCooldown <= 0) {\n this.intents.push(Intent.PlaceBlock, {\n x: this.target.x + this.target.nx,\n y: this.target.y + this.target.ny,\n z: this.target.z + this.target.nz,\n });\n events.emit(\"armSwing\");\n this._placeCooldown = 0.25;\n }\n }\n if (!eatingNow && this._sentEat) {\n this.intents.push(Intent.EatStop);\n this._sentEat = null;\n }\n if (!input.rightDown) this._placeCooldown = 0;\n\n // визуализация трещин: активное ломание либо запомненный урон на блоке под прицелом\n let crack: { x: number; y: number; z: number; frac: number } | null = null;\n if (this.breaking && this.breaking.total > 0.06) {\n crack = {\n x: this.breaking.x,\n y: this.breaking.y,\n z: this.breaking.z,\n frac: this.breaking.progress / this.breaking.total,\n };\n } else if (this.target) {\n const d = this.damage.get(\n this.target.x + \",\" + this.target.y + \",\" + this.target.z,\n );\n const total = BLOCKS[this.target.id]!.hardness;\n if (d && d.id === this.target.id && d.progress > 0 && total > 0.06)\n crack = {\n x: this.target.x,\n y: this.target.y,\n z: this.target.z,\n frac: d.progress / total,\n };\n }\n\n if (crack) {\n const stage = Math.min(9, Math.floor(crack.frac * 10));\n if (stage !== this._crackStage) {\n const uv = this.atlas.uv(\"crack\" + stage);\n // двигаем offset/repeat клона атласа на нужный тайл\n this.crackMat.map!.repeat.set(uv.u1 - uv.u0, uv.v1 - uv.v0);\n this.crackMat.map!.offset.set(uv.u0, uv.v0);\n this._crackStage = stage;\n }\n this.crackMesh.visible = true;\n this.crackMesh.position.set(crack.x + 0.5, crack.y + 0.5, crack.z + 0.5);\n } else {\n this.crackMesh.visible = false;\n this._crackStage = -1;\n }\n }\n\n frameTimers(dt: number): void {\n if (this._placeCooldown > 0) this._placeCooldown -= dt;\n }\n\n // ---- тиковая половина (симуляция) --------------------------------------------\n\n tick(dt: number): void {\n if (this._attackCooldown > 0) this._attackCooldown -= dt;\n\n this.intents.drain((intent: IntentMsg) => {\n switch (intent.type) {\n case Intent.BreakStart:\n this._startBreak(intent);\n break;\n case Intent.BreakStop:\n this.breaking = null;\n break;\n case Intent.PlaceBlock:\n this._place(intent);\n break;\n case Intent.EatStart:\n this._startEat(intent);\n break;\n case Intent.EatStop:\n this.eating = null;\n break;\n case Intent.Attack:\n this._attack(intent);\n break;\n case Intent.SelectSlot:\n if (intent.slot !== undefined) this.inventory.selectSlot(intent.slot);\n else this.inventory.scrollSlot(intent.delta);\n break;\n case Intent.ToggleMode:\n this.player.toggleMode();\n break;\n }\n });\n\n // прогресс ломания\n let activeKey: string | null = null;\n if (this.breaking) {\n const { x, y, z } = this.breaking;\n if (this.world.getBlock(x, y, z) !== this.breaking.id) {\n this.damage.delete(x + \",\" + y + \",\" + z);\n this.breaking = null;\n } else {\n this.breaking.progress += dt * this._breakSpeed(this.breaking.id);\n activeKey = x + \",\" + y + \",\" + z;\n // запоминаем урон (heal:0 — таймер заживления сбрасывается, пока бьём)\n this.damage.set(activeKey, {\n id: this.breaking.id,\n progress: this.breaking.progress,\n heal: 0,\n });\n if (this.breaking.progress >= this.breaking.total) {\n this._finishBreak(x, y, z, this.breaking.id);\n this.damage.delete(activeKey);\n this.breaking = null;\n }\n }\n }\n\n // заживление урона по блокам, которые сейчас не ломают\n for (const [k, d] of this.damage) {\n if (k === activeKey) continue;\n d.heal += dt;\n if (d.heal < CONFIG.player.breakHealDelay) continue;\n d.progress -= dt * CONFIG.player.breakHealRate;\n if (d.progress <= 0) this.damage.delete(k);\n }\n\n // процесс поедания\n if (this.eating) {\n const held = this.inventory.heldItem();\n if (\n !held ||\n held.item !== this.eating.item ||\n this.player.food >= CONFIG.player.maxFood\n ) {\n this.eating = null;\n } else {\n this.eating.time += dt;\n events.emit(\"armSwing\"); // лёгкое подёргивание еды в руке\n if (this.eating.time >= CONFIG.player.eatTime) {\n if (this.player.eat(this.eating.item)) this.inventory.consumeHeld(1);\n this.eating = null;\n }\n }\n }\n }\n\n _startEat({ item }: IntentMsg): void {\n const held = this.inventory.heldItem();\n if (!held || held.item !== item) return;\n if (!ITEMS[item]?.food || this.player.food >= CONFIG.player.maxFood) return;\n this.eating = { item, time: 0 };\n }\n\n _validDistance(x: number, y: number, z: number): boolean {\n const e = this.player.eyePos;\n const dx = x + 0.5 - e.x,\n dy = y + 0.5 - e.y,\n dz = z + 0.5 - e.z;\n return dx * dx + dy * dy + dz * dz <= (CONFIG.player.reach + 1) ** 2;\n }\n\n /** Множитель скорости от инструмента в руке. */\n _breakSpeed(blockId: number): number {\n const def = BLOCKS[blockId]!;\n const held = this.inventory.heldItem();\n if (held && def.tool && ITEMS[held.item]?.toolType === def.tool)\n return ITEMS[held.item]!.speed;\n return 1;\n }\n\n _startBreak({ x, y, z }: IntentMsg): void {\n if (!this._validDistance(x, y, z)) return;\n const id = this.world.getBlock(x, y, z);\n const def = BLOCKS[id]!;\n if (id === B.air || def.liquid || def.hardness === Infinity) return;\n const total = this.player.mode === \"creative\" ? 0 : def.hardness;\n // возобновляем с запомненного урона (если это тот же блок), а не с нуля\n const stored = this.damage.get(x + \",\" + y + \",\" + z);\n const progress = stored && stored.id === id ? stored.progress : 0;\n this.breaking = { x, y, z, id, progress, total };\n if (total <= 0.05) {\n // мгновенное ломание\n this._finishBreak(x, y, z, id);\n this.damage.delete(x + \",\" + y + \",\" + z);\n this.breaking = null;\n }\n }\n\n _finishBreak(x: number, y: number, z: number, id: number): void {\n const def = BLOCKS[id]!;\n this.world.setBlock(x, y, z, B.air!);\n events.emit(\"blockBreak\", { x, y, z, id, byPlayer: true });\n this.player.addExhaustion(CONFIG.player.exhaustBreak);\n // дроп: null = сам блок; [] = ничего; chance — вероятность (по умолчанию 1); в креативе дропов нет\n if (this.player.mode !== \"creative\") {\n const drops = def.drops === null ? [{ item: def.name, n: 1 }] : def.drops;\n for (const d of drops)\n if (\n ITEMS[d.item] &&\n (d.chance === undefined || Math.random() <= d.chance)\n )\n this.entities.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n }\n // если на блоке стояло растение — оно падает (ломается)\n const above = this.world.getBlock(x, y + 1, z);\n if (BLOCKS[above]?.cross) {\n this.world.setBlock(x, y + 1, z, B.air!);\n events.emit(\"blockBreak\", { x, y: y + 1, z, id: above, byPlayer: false });\n }\n }\n\n _place({ x, y, z }: IntentMsg): void {\n if (!this._validDistance(x, y, z)) return;\n const held = this.inventory.heldItem();\n if (!held) return;\n const item = ITEMS[held.item];\n if (!item || item.blockId === null) return;\n const cur = this.world.getBlock(x, y, z);\n if (cur !== B.air && !BLOCKS[cur]!.liquid && !BLOCKS[cur]!.cross) return;\n if (BLOCKS[item.blockId]!.solid && this.player.intersectsBlock(x, y, z))\n return; // не в себя\n // крестовины — только на твёрдое основание\n if (BLOCKS[item.blockId]!.cross && !this.world.isSolid(x, y - 1, z)) return;\n\n this.world.setBlock(x, y, z, item.blockId);\n events.emit(\"blockPlace\", { x, y, z, id: item.blockId });\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n }\n\n _attack({ entityId }: IntentMsg): void {\n if (this._attackCooldown > 0) return;\n this._attackCooldown = CONFIG.player.attackCooldown;\n this.player.addExhaustion(CONFIG.player.exhaustAttack);\n const held = this.inventory.heldItem();\n const damage = held\n ? (ITEMS[held.item]?.damage ?? 1)\n : CONFIG.player.attackDamage;\n this.entities.hurt(entityId, damage, this.player.pos);\n }\n}\n"
103
103
  },
104
104
  {
105
105
  "path": "player/Player.ts",
106
- "content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\n\n/** Форма persist-блоба игрока (см. serialize/deserialize). */\ninterface PlayerSaveData {\n pos: number[];\n yaw: number;\n pitch: number;\n health: number;\n food?: number;\n saturation?: number;\n mode: PlayerMode;\n flying: boolean;\n}\n\nexport class Player {\n world: ChunkManager;\n pos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n pitch: number;\n onGround: boolean;\n inWater: boolean;\n headInWater: boolean;\n mode: PlayerMode;\n flying: boolean;\n health: number;\n food: number;\n saturation: number;\n exhaustion: number;\n dead: boolean;\n spawnPoint: THREE.Vector3;\n _fallDist: number;\n _stepDist: number;\n _regenTimer: number;\n _starveTimer: number;\n _sprinting = false;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.pos = new THREE.Vector3(8.5, 40, 8.5); // ноги; уточняется при спавне\n this.vel = new THREE.Vector3();\n this.yaw = 0;\n this.pitch = 0;\n this.onGround = false;\n this.inWater = false;\n this.headInWater = false;\n this.mode = \"survival\"; // 'survival' | 'creative'\n this.flying = false;\n this.health = P.maxHealth;\n // голод: еда [0..maxFood], сатурация [0..food] (буфер, тратится первой),\n // истощение [0..exhaustionPerFood] — накопитель, при переполнении съедает очко\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n this._stepDist = 0;\n this._regenTimer = 0;\n this._starveTimer = 0;\n this.spawnPoint = new THREE.Vector3();\n\n events.on(\"input:doubleSpace\", () => {\n if (this.mode === \"creative\") this.flying = !this.flying;\n });\n }\n\n spawn(): void {\n // ищем сушу по спирали от (8,8): не спавнимся в океане на произвольном сиде\n let sx = 8,\n sz = 8;\n outer: for (let r = 0; r <= 6; r++) {\n for (let dx = -r; dx <= r; dx += Math.max(1, r)) {\n for (let dz = -r; dz <= r; dz += Math.max(1, r)) {\n const x = 8 + dx * 8,\n z = 8 + dz * 8;\n const y = this.world.surfaceY(x, z);\n if (\n y > CONFIG.world.waterLevel &&\n this.world.getBlock(x, y, z) === B.grass\n ) {\n sx = x;\n sz = z;\n break outer;\n }\n }\n }\n }\n const y = this.world.surfaceY(sx, sz);\n this.pos.set(sx + 0.5, y + 1.01, sz + 0.5);\n this.spawnPoint.copy(this.pos);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n }\n\n get eyePos(): THREE.Vector3 {\n return new THREE.Vector3(this.pos.x, this.pos.y + P.eye, this.pos.z);\n }\n\n lookDir(): THREE.Vector3 {\n return new THREE.Vector3(\n -Math.sin(this.yaw) * Math.cos(this.pitch),\n Math.sin(this.pitch),\n -Math.cos(this.yaw) * Math.cos(this.pitch),\n );\n }\n\n toggleMode(): void {\n this.mode = this.mode === \"survival\" ? \"creative\" : \"survival\";\n if (this.mode === \"survival\") this.flying = false;\n events.emit(\"modeChanged\", { mode: this.mode });\n }\n\n /** Кадровое обновление: взгляд, ускорения, интеграция, коллизии. */\n update(dt: number, input: Input): void {\n if (this.dead) return;\n // взгляд\n const m = input.consumeMouse();\n this.yaw -= m.dx * 0.0024;\n this.pitch = Math.max(\n -Math.PI / 2 + 0.01,\n Math.min(Math.PI / 2 - 0.01, this.pitch - m.dy * 0.0024),\n );\n\n // среда\n const feet = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n const head = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + P.eye),\n Math.floor(this.pos.z),\n );\n this.inWater = BLOCKS[feet]?.liquid || BLOCKS[head]?.liquid;\n this.headInWater = !!BLOCKS[head]?.liquid;\n\n // желаемое горизонтальное движение в локальных осях камеры\n let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);\n let iz = (input.back ? 1 : 0) - (input.forward ? 1 : 0);\n if (input.uiOpen) {\n ix = 0;\n iz = 0;\n }\n const len = Math.hypot(ix, iz) || 1;\n ix /= len;\n iz /= len;\n // перевод ввода в мировые оси: forward = (-sin yaw, -cos yaw), right = (cos yaw, -sin yaw)\n const sin = Math.sin(this.yaw),\n cos = Math.cos(this.yaw);\n const wishX = ix * cos + iz * sin;\n const wishZ = iz * cos - ix * sin;\n\n // бег доступен, пока еды достаточно (как в Minecraft)\n const canSprint = input.run && input.forward && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed = this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed;\n\n if (this.flying) {\n // полёт: прямое управление скоростью, вертикаль на Space/Shift\n this.vel.x = wishX * speed;\n this.vel.z = wishZ * speed;\n this.vel.y = (input.jump ? speed : 0) + (input.sneakOrDown ? -speed : 0);\n if (input.uiOpen) this.vel.y = 0;\n } else if (this.inWater) {\n const accel = 24 * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * 0.3 * dt; // ослабленная гравитация\n if (input.jump && !input.uiOpen) this.vel.y = P.swimUp; // гребок вверх\n this.vel.y *= 1 - P.waterDrag * dt; // сопротивление воды\n this._fallDist = 0;\n } else {\n const accel = (this.onGround ? 18 : 4) * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * dt;\n if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed;\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\n }\n }\n\n // интеграция с сабстепами — защита от туннелирования на лагающем кадре\n const steps = Math.max(1, Math.ceil((this.vel.length() * dt) / 0.4));\n const sdt = dt / steps;\n for (let s = 0; s < steps; s++) this._moveStep(sdt);\n\n // шаги (звук) + истощение от ходьбы/бега\n if (this.onGround && !this.inWater) {\n const dist = Math.hypot(this.vel.x, this.vel.z) * dt;\n this._stepDist += dist;\n if (this.mode === \"survival\")\n this.addExhaustion(\n dist * (this._sprinting ? P.exhaustSprint : P.exhaustWalk),\n );\n if (this._stepDist > 2.2) {\n this._stepDist = 0;\n events.emit(\"playerStep\");\n }\n }\n\n if (this.pos.y < -10) this._die(); // выпал из мира\n }\n\n _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n\n // ось Y\n this.pos.y += this.vel.y * dt;\n const hit = this._resolveAxis(1);\n const landed = hit === -1; // упёрлись вниз\n if (landed) {\n if (\n wasAirborne &&\n this._fallDist > P.fallSafe &&\n this.mode === \"survival\"\n )\n this.damage(Math.round(this._fallDist - P.fallSafe), \"fall\");\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else if (hit === 1) {\n this.vel.y = 0; // потолок\n } else {\n this.onGround = false;\n if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n\n // ось X\n this.pos.x += this.vel.x * dt;\n if (this._resolveAxis(0)) this.vel.x = 0;\n // ось Z\n this.pos.z += this.vel.z * dt;\n if (this._resolveAxis(2)) this.vel.z = 0;\n }\n\n /**\n * Разрешение коллизии по одной оси: если AABB пересекает твёрдый воксель,\n * позиция клампится к его грани. Возвращает -1/1 (сторону) или 0.\n */\n _resolveAxis(axis: number): number {\n const half = P.width / 2;\n const minX = this.pos.x - half,\n maxX = this.pos.x + half;\n const minY = this.pos.y,\n maxY = this.pos.y + P.height;\n const minZ = this.pos.z - half,\n maxZ = this.pos.z + half;\n const eps = 0.001;\n let result = 0;\n\n for (let by = Math.floor(minY); by <= Math.floor(maxY - eps); by++) {\n for (let bz = Math.floor(minZ); bz <= Math.floor(maxZ - eps); bz++) {\n for (let bx = Math.floor(minX); bx <= Math.floor(maxX - eps); bx++) {\n if (!this.world.isSolid(bx, by, bz)) continue;\n if (axis === 1) {\n if (this.vel.y <= 0 && minY < by + 1 && maxY > by + 1) {\n /* невозможно по одной оси */\n }\n if (this.vel.y <= 0) {\n this.pos.y = by + 1;\n result = -1;\n } else {\n this.pos.y = by - P.height - eps;\n result = 1;\n }\n } else if (axis === 0) {\n if (this.vel.x > 0) this.pos.x = bx - half - eps;\n else this.pos.x = bx + 1 + half + eps;\n result = 1;\n } else {\n if (this.vel.z > 0) this.pos.z = bz - half - eps;\n else this.pos.z = bz + 1 + half + eps;\n result = 1;\n }\n return result; // после клампа пересечений по этой оси больше нет\n }\n }\n }\n return result;\n }\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) this._die();\n }\n\n addExhaustion(n: number): void {\n this.exhaustion += n;\n }\n\n /** Тик голода (симуляция, 20 TPS): истощение → еда, регенерация, голодание. */\n tickHunger(dt: number): void {\n if (this.mode === \"creative\" || this.dead) return;\n this.addExhaustion(P.exhaustIdle * dt); // медленная пассивная трата\n while (this.exhaustion >= P.exhaustionPerFood) {\n this.exhaustion -= P.exhaustionPerFood;\n if (this.saturation > 0)\n this.saturation = Math.max(0, this.saturation - 1);\n else this.food = Math.max(0, this.food - 1);\n }\n\n // сытость лечит; лечение «стоит» истощения\n if (this.food >= P.regenFoodThreshold && this.health < P.maxHealth) {\n this._regenTimer += dt;\n if (this._regenTimer >= P.regenInterval) {\n this._regenTimer = 0;\n this.health = Math.min(P.maxHealth, this.health + 1);\n this.addExhaustion(P.exhaustRegen);\n events.emit(\"playerHeal\", { health: this.health });\n }\n } else this._regenTimer = 0;\n\n // голодание: урон при нулевой еде, но не насмерть (минимум 1 HP)\n if (this.food <= 0) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > 1) this.damage(1, \"starve\");\n }\n } else this._starveTimer = 0;\n\n events.emit(\"foodChanged\", { food: this.food });\n }\n\n /** Съесть предмет (если это еда и есть куда). @returns {boolean} успех */\n eat(itemName: string): boolean {\n const it = ITEMS[itemName];\n if (!it?.food || this.food >= P.maxFood) return false;\n this.food = Math.min(P.maxFood, this.food + it.food);\n this.saturation = Math.min(\n this.food,\n this.saturation + (it.saturation ?? it.food * 0.6),\n );\n events.emit(\"playerAte\", { item: itemName });\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n events.emit(\"playerDied\");\n // простой респаун через секунду\n setTimeout(() => {\n this.pos.copy(this.spawnPoint);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n events.emit(\"playerRespawn\");\n }, 1200);\n }\n\n /** Пересекается ли AABB игрока с блоком (запрет постановки «в себя»). */\n intersectsBlock(bx: number, by: number, bz: number): boolean {\n const half = P.width / 2;\n return (\n bx + 1 > this.pos.x - half &&\n bx < this.pos.x + half &&\n by + 1 > this.pos.y &&\n by < this.pos.y + P.height &&\n bz + 1 > this.pos.z - half &&\n bz < this.pos.z + half\n );\n }\n\n serialize() {\n return {\n pos: this.pos.toArray(),\n yaw: this.yaw,\n pitch: this.pitch,\n health: this.health,\n food: this.food,\n saturation: this.saturation,\n mode: this.mode,\n flying: this.flying,\n };\n }\n deserialize(d: PlayerSaveData): void {\n this.pos.fromArray(d.pos);\n this.yaw = d.yaw;\n this.pitch = d.pitch;\n this.health = d.health;\n this.mode = d.mode;\n this.flying = d.flying;\n this.food = d.food ?? P.maxFood;\n this.saturation = d.saturation ?? 5;\n this.exhaustion = 0;\n this.vel.set(0, 0, 0);\n this.spawnPoint.copy(this.pos);\n }\n}\n"
106
+ "content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\n\n/** Форма persist-блоба игрока (см. serialize/deserialize). */\ninterface PlayerSaveData {\n pos: number[];\n yaw: number;\n pitch: number;\n health: number;\n food?: number;\n saturation?: number;\n mode: PlayerMode;\n flying: boolean;\n}\n\nexport class Player {\n world: ChunkManager;\n pos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n pitch: number;\n onGround: boolean;\n inWater: boolean;\n headInWater: boolean;\n mode: PlayerMode;\n flying: boolean;\n health: number;\n food: number;\n saturation: number;\n exhaustion: number;\n dead: boolean;\n spawnPoint: THREE.Vector3;\n _fallDist: number;\n _stepDist: number;\n _regenTimer: number;\n _starveTimer: number;\n _sprinting = false;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.pos = new THREE.Vector3(8.5, 40, 8.5); // ноги; уточняется при спавне\n this.vel = new THREE.Vector3();\n this.yaw = 0;\n this.pitch = 0;\n this.onGround = false;\n this.inWater = false;\n this.headInWater = false;\n this.mode = \"survival\"; // 'survival' | 'creative'\n this.flying = false;\n this.health = P.maxHealth;\n // голод: еда [0..maxFood], сатурация [0..food] (буфер, тратится первой),\n // истощение [0..exhaustionPerFood] — накопитель, при переполнении съедает очко\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n this._stepDist = 0;\n this._regenTimer = 0;\n this._starveTimer = 0;\n this.spawnPoint = new THREE.Vector3();\n\n events.on(\"input:doubleSpace\", () => {\n if (this.mode === \"creative\") this.flying = !this.flying;\n });\n }\n\n spawn(): void {\n // ищем сушу по спирали от (8,8): не спавнимся в океане на произвольном сиде\n let sx = 8,\n sz = 8;\n outer: for (let r = 0; r <= 6; r++) {\n for (let dx = -r; dx <= r; dx += Math.max(1, r)) {\n for (let dz = -r; dz <= r; dz += Math.max(1, r)) {\n const x = 8 + dx * 8,\n z = 8 + dz * 8;\n const y = this.world.surfaceY(x, z);\n if (\n y > CONFIG.world.waterLevel &&\n this.world.getBlock(x, y, z) === B.grass\n ) {\n sx = x;\n sz = z;\n break outer;\n }\n }\n }\n }\n const y = this.world.surfaceY(sx, sz);\n this.pos.set(sx + 0.5, y + 1.01, sz + 0.5);\n this.spawnPoint.copy(this.pos);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n }\n\n get eyePos(): THREE.Vector3 {\n return new THREE.Vector3(this.pos.x, this.pos.y + P.eye, this.pos.z);\n }\n\n lookDir(): THREE.Vector3 {\n return new THREE.Vector3(\n -Math.sin(this.yaw) * Math.cos(this.pitch),\n Math.sin(this.pitch),\n -Math.cos(this.yaw) * Math.cos(this.pitch),\n );\n }\n\n toggleMode(): void {\n this.mode = this.mode === \"survival\" ? \"creative\" : \"survival\";\n if (this.mode === \"survival\") this.flying = false;\n events.emit(\"modeChanged\", { mode: this.mode });\n }\n\n /** Кадровое обновление: взгляд, ускорения, интеграция, коллизии. */\n update(dt: number, input: Input): void {\n if (this.dead) return;\n // взгляд\n const m = input.consumeMouse();\n this.yaw -= m.dx * 0.0024;\n this.pitch = Math.max(\n -Math.PI / 2 + 0.01,\n Math.min(Math.PI / 2 - 0.01, this.pitch - m.dy * 0.0024),\n );\n\n // среда\n const feet = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n const head = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + P.eye),\n Math.floor(this.pos.z),\n );\n this.inWater = !!(BLOCKS[feet]?.liquid || BLOCKS[head]?.liquid);\n this.headInWater = !!BLOCKS[head]?.liquid;\n\n // желаемое горизонтальное движение в локальных осях камеры\n let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);\n let iz = (input.back ? 1 : 0) - (input.forward ? 1 : 0);\n if (input.uiOpen) {\n ix = 0;\n iz = 0;\n }\n const len = Math.hypot(ix, iz) || 1;\n ix /= len;\n iz /= len;\n // перевод ввода в мировые оси: forward = (-sin yaw, -cos yaw), right = (cos yaw, -sin yaw)\n const sin = Math.sin(this.yaw),\n cos = Math.cos(this.yaw);\n const wishX = ix * cos + iz * sin;\n const wishZ = iz * cos - ix * sin;\n\n // бег доступен, пока еды достаточно (как в Minecraft)\n const canSprint = input.run && input.forward && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed = this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed;\n\n if (this.flying) {\n // полёт: прямое управление скоростью, вертикаль на Space/Shift\n this.vel.x = wishX * speed;\n this.vel.z = wishZ * speed;\n this.vel.y = (input.jump ? speed : 0) + (input.sneakOrDown ? -speed : 0);\n if (input.uiOpen) this.vel.y = 0;\n } else if (this.inWater) {\n const accel = 24 * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * 0.3 * dt; // ослабленная гравитация\n if (input.jump && !input.uiOpen) this.vel.y = P.swimUp; // гребок вверх\n this.vel.y *= 1 - P.waterDrag * dt; // сопротивление воды\n this._fallDist = 0;\n } else {\n const accel = (this.onGround ? 18 : 4) * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * dt;\n if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed;\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\n }\n }\n\n // интеграция с сабстепами — защита от туннелирования на лагающем кадре\n const steps = Math.max(1, Math.ceil((this.vel.length() * dt) / 0.4));\n const sdt = dt / steps;\n for (let s = 0; s < steps; s++) this._moveStep(sdt);\n\n // шаги (звук) + истощение от ходьбы/бега\n if (this.onGround && !this.inWater) {\n const dist = Math.hypot(this.vel.x, this.vel.z) * dt;\n this._stepDist += dist;\n if (this.mode === \"survival\")\n this.addExhaustion(\n dist * (this._sprinting ? P.exhaustSprint : P.exhaustWalk),\n );\n if (this._stepDist > 2.2) {\n this._stepDist = 0;\n events.emit(\"playerStep\");\n }\n }\n\n if (this.pos.y < -10) this._die(); // выпал из мира\n }\n\n _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n\n // ось Y\n this.pos.y += this.vel.y * dt;\n const hit = this._resolveAxis(1);\n const landed = hit === -1; // упёрлись вниз\n if (landed) {\n if (\n wasAirborne &&\n this._fallDist > P.fallSafe &&\n this.mode === \"survival\"\n )\n this.damage(Math.round(this._fallDist - P.fallSafe), \"fall\");\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else if (hit === 1) {\n this.vel.y = 0; // потолок\n } else {\n this.onGround = false;\n if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n\n // ось X\n this.pos.x += this.vel.x * dt;\n if (this._resolveAxis(0)) this.vel.x = 0;\n // ось Z\n this.pos.z += this.vel.z * dt;\n if (this._resolveAxis(2)) this.vel.z = 0;\n }\n\n /**\n * Разрешение коллизии по одной оси: если AABB пересекает твёрдый воксель,\n * позиция клампится к его грани. Возвращает -1/1 (сторону) или 0.\n */\n _resolveAxis(axis: number): number {\n const half = P.width / 2;\n const minX = this.pos.x - half,\n maxX = this.pos.x + half;\n const minY = this.pos.y,\n maxY = this.pos.y + P.height;\n const minZ = this.pos.z - half,\n maxZ = this.pos.z + half;\n const eps = 0.001;\n let result = 0;\n\n for (let by = Math.floor(minY); by <= Math.floor(maxY - eps); by++) {\n for (let bz = Math.floor(minZ); bz <= Math.floor(maxZ - eps); bz++) {\n for (let bx = Math.floor(minX); bx <= Math.floor(maxX - eps); bx++) {\n if (!this.world.isSolid(bx, by, bz)) continue;\n if (axis === 1) {\n if (this.vel.y <= 0 && minY < by + 1 && maxY > by + 1) {\n /* невозможно по одной оси */\n }\n if (this.vel.y <= 0) {\n this.pos.y = by + 1;\n result = -1;\n } else {\n this.pos.y = by - P.height - eps;\n result = 1;\n }\n } else if (axis === 0) {\n if (this.vel.x > 0) this.pos.x = bx - half - eps;\n else this.pos.x = bx + 1 + half + eps;\n result = 1;\n } else {\n if (this.vel.z > 0) this.pos.z = bz - half - eps;\n else this.pos.z = bz + 1 + half + eps;\n result = 1;\n }\n return result; // после клампа пересечений по этой оси больше нет\n }\n }\n }\n return result;\n }\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) this._die();\n }\n\n addExhaustion(n: number): void {\n this.exhaustion += n;\n }\n\n /** Тик голода (симуляция, 20 TPS): истощение → еда, регенерация, голодание. */\n tickHunger(dt: number): void {\n if (this.mode === \"creative\" || this.dead) return;\n this.addExhaustion(P.exhaustIdle * dt); // медленная пассивная трата\n while (this.exhaustion >= P.exhaustionPerFood) {\n this.exhaustion -= P.exhaustionPerFood;\n if (this.saturation > 0)\n this.saturation = Math.max(0, this.saturation - 1);\n else this.food = Math.max(0, this.food - 1);\n }\n\n // сытость лечит; лечение «стоит» истощения\n if (this.food >= P.regenFoodThreshold && this.health < P.maxHealth) {\n this._regenTimer += dt;\n if (this._regenTimer >= P.regenInterval) {\n this._regenTimer = 0;\n this.health = Math.min(P.maxHealth, this.health + 1);\n this.addExhaustion(P.exhaustRegen);\n events.emit(\"playerHeal\", { health: this.health });\n }\n } else this._regenTimer = 0;\n\n // голодание: урон при нулевой еде, но не насмерть (минимум 1 HP)\n if (this.food <= 0) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > 1) this.damage(1, \"starve\");\n }\n } else this._starveTimer = 0;\n\n events.emit(\"foodChanged\", { food: this.food });\n }\n\n /** Съесть предмет (если это еда и есть куда). @returns {boolean} успех */\n eat(itemName: string): boolean {\n const it = ITEMS[itemName];\n if (!it?.food || this.food >= P.maxFood) return false;\n this.food = Math.min(P.maxFood, this.food + it.food);\n this.saturation = Math.min(\n this.food,\n this.saturation + (it.saturation ?? it.food * 0.6),\n );\n events.emit(\"playerAte\", { item: itemName });\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n events.emit(\"playerDied\");\n // простой респаун через секунду\n setTimeout(() => {\n this.pos.copy(this.spawnPoint);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n events.emit(\"playerRespawn\");\n }, 1200);\n }\n\n /** Пересекается ли AABB игрока с блоком (запрет постановки «в себя»). */\n intersectsBlock(bx: number, by: number, bz: number): boolean {\n const half = P.width / 2;\n return (\n bx + 1 > this.pos.x - half &&\n bx < this.pos.x + half &&\n by + 1 > this.pos.y &&\n by < this.pos.y + P.height &&\n bz + 1 > this.pos.z - half &&\n bz < this.pos.z + half\n );\n }\n\n serialize() {\n return {\n pos: this.pos.toArray(),\n yaw: this.yaw,\n pitch: this.pitch,\n health: this.health,\n food: this.food,\n saturation: this.saturation,\n mode: this.mode,\n flying: this.flying,\n };\n }\n deserialize(d: PlayerSaveData): void {\n this.pos.fromArray(d.pos);\n this.yaw = d.yaw;\n this.pitch = d.pitch;\n this.health = d.health;\n this.mode = d.mode;\n this.flying = d.flying;\n this.food = d.food ?? P.maxFood;\n this.saturation = d.saturation ?? 5;\n this.exhaustion = 0;\n this.vel.set(0, 0, 0);\n this.spawnPoint.copy(this.pos);\n }\n}\n"
107
107
  },
108
108
  {
109
109
  "path": "registry/Blocks.ts",
@@ -111,7 +111,7 @@
111
111
  },
112
112
  {
113
113
  "path": "registry/Items.ts",
114
- "content": "// Реестр предметов. Каждому размещаемому блоку автоматически создаётся\n// предмет-двойник (blockId), плюс вручную — материалы и инструменты.\n//\n// Поля:\n// maxStack — размер стака (инструменты не стакаются)\n// kind — 'block' | 'material' | 'tool'\n// toolType — 'pickaxe'|'axe'|'sword' (у инструментов)\n// speed — множитель скорости добычи блоков соответствующего tool\n// damage — урон по сущностям (по умолчанию 1 — «кулак»)\n// tile — тайл атласа для плоской иконки (у не-блоков)\nimport { BLOCKS } from \"./Blocks\";\nimport type { ItemDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n// food — очки восстановления голода; saturation — буфер сытости (по умолчанию food*0.6)\nfunction item(def: Partial<ItemDef> & { name: string }): ItemDef {\n return {\n maxStack: 64,\n kind: \"material\",\n toolType: null,\n speed: 1,\n damage: 1,\n blockId: null,\n tile: null,\n food: 0,\n saturation: 0,\n ...def,\n };\n}\n\nexport const ITEMS: Record<string, ItemDef> = {};\n\n// Предметы-блоки: всё, что можно держать и ставить.\nconst PLACEABLE = [\n \"grass\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"sand\",\n \"oak_log\",\n \"leaves\",\n \"planks\",\n \"crafting_table\",\n \"coal_ore\",\n \"iron_ore\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n];\nfor (const name of PLACEABLE) {\n const b = BLOCKS.find((x) => x.name === name)!;\n ITEMS[name] = item({ name, kind: \"block\", blockId: b.id });\n}\n\n// Материалы.\nITEMS.stick = item({ name: \"stick\", tile: \"stick\" });\nITEMS.coal = item({ name: \"coal\", tile: \"coal_item\" });\nITEMS.feather = item({ name: \"feather\", tile: \"feather\" });\n\n// Еда (food = очки голода из 20). Сырая курятина слабая, жареная — сытная\n// (появится при добавлении печи), яблоко падает с листвы.\nITEMS.raw_chicken = item({\n name: \"raw_chicken\",\n tile: \"raw_chicken\",\n food: 2,\n saturation: 1.2,\n});\nITEMS.cooked_chicken = item({\n name: \"cooked_chicken\",\n tile: \"cooked_chicken\",\n food: 6,\n saturation: 7.2,\n});\nITEMS.apple = item({ name: \"apple\", tile: \"apple\", food: 4, saturation: 2.4 });\n\n// Инструменты. speed — во сколько раз быстрее ломаются блоки с matching tool.\nITEMS.wooden_pickaxe = item({\n name: \"wooden_pickaxe\",\n kind: \"tool\",\n toolType: \"pickaxe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_pickaxe\",\n});\nITEMS.wooden_axe = item({\n name: \"wooden_axe\",\n kind: \"tool\",\n toolType: \"axe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_axe\",\n});\nITEMS.wooden_sword = item({\n name: \"wooden_sword\",\n kind: \"tool\",\n toolType: \"sword\",\n speed: 1,\n damage: 4,\n maxStack: 1,\n tile: \"wooden_sword\",\n});\n\n/** Русские названия для тултипов HUD. */\nexport const ITEM_LABELS: Record<string, string> = {\n grass: \"Дёрн\",\n dirt: \"Земля\",\n stone: \"Камень\",\n cobblestone: \"Булыжник\",\n sand: \"Песок\",\n oak_log: \"Бревно\",\n leaves: \"Листва\",\n planks: \"Доски\",\n crafting_table: \"Верстак\",\n coal_ore: \"Угольная руда\",\n iron_ore: \"Железная руда\",\n tallgrass: \"Трава\",\n flower_yellow: \"Одуванчик\",\n flower_red: \"Мак\",\n stick: \"Палка\",\n coal: \"Уголь\",\n feather: \"Перо\",\n raw_chicken: \"Сырая курятина\",\n cooked_chicken: \"Жареная курятина\",\n apple: \"Яблоко\",\n wooden_pickaxe: \"Деревянная кирка\",\n wooden_axe: \"Деревянный топор\",\n wooden_sword: \"Деревянный меч\",\n};\n\n/**\n * Генерация иконок для HUD/инвентаря из тех же текстур атласа.\n * Вызывается один раз из main.js после создания атласа.\n */\nexport function buildIcons(atlas: TextureAtlas): void {\n for (const it of Object.values(ITEMS)) {\n if (it.kind === \"block\") {\n const b = BLOCKS[it.blockId!];\n const tex = b.tex!;\n it.icon = b.cross\n ? atlas.flatIcon(tex.side!)\n : atlas.blockIcon(tex.top!, tex.side!);\n } else {\n it.icon = atlas.flatIcon(it.tile!);\n }\n it.iconURL = it.icon!.toDataURL();\n }\n}\n"
114
+ "content": "// Реестр предметов. Каждому размещаемому блоку автоматически создаётся\n// предмет-двойник (blockId), плюс вручную — материалы и инструменты.\n//\n// Поля:\n// maxStack — размер стака (инструменты не стакаются)\n// kind — 'block' | 'material' | 'tool'\n// toolType — 'pickaxe'|'axe'|'sword' (у инструментов)\n// speed — множитель скорости добычи блоков соответствующего tool\n// damage — урон по сущностям (по умолчанию 1 — «кулак»)\n// tile — тайл атласа для плоской иконки (у не-блоков)\nimport { BLOCKS } from \"./Blocks\";\nimport type { ItemDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n// food — очки восстановления голода; saturation — буфер сытости (по умолчанию food*0.6)\nfunction item(def: Partial<ItemDef> & { name: string }): ItemDef {\n return {\n maxStack: 64,\n kind: \"material\",\n toolType: null,\n speed: 1,\n damage: 1,\n blockId: null,\n tile: null,\n food: 0,\n saturation: 0,\n ...def,\n };\n}\n\nexport const ITEMS: Record<string, ItemDef> = {};\n\n// Предметы-блоки: всё, что можно держать и ставить.\nconst PLACEABLE = [\n \"grass\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"sand\",\n \"oak_log\",\n \"leaves\",\n \"planks\",\n \"crafting_table\",\n \"coal_ore\",\n \"iron_ore\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n];\nfor (const name of PLACEABLE) {\n const b = BLOCKS.find((x) => x.name === name)!;\n ITEMS[name] = item({ name, kind: \"block\", blockId: b.id });\n}\n\n// Материалы.\nITEMS.stick = item({ name: \"stick\", tile: \"stick\" });\nITEMS.coal = item({ name: \"coal\", tile: \"coal_item\" });\nITEMS.feather = item({ name: \"feather\", tile: \"feather\" });\n\n// Еда (food = очки голода из 20). Сырая курятина слабая, жареная — сытная\n// (появится при добавлении печи), яблоко падает с листвы.\nITEMS.raw_chicken = item({\n name: \"raw_chicken\",\n tile: \"raw_chicken\",\n food: 2,\n saturation: 1.2,\n});\nITEMS.cooked_chicken = item({\n name: \"cooked_chicken\",\n tile: \"cooked_chicken\",\n food: 6,\n saturation: 7.2,\n});\nITEMS.apple = item({ name: \"apple\", tile: \"apple\", food: 4, saturation: 2.4 });\n\n// Инструменты. speed — во сколько раз быстрее ломаются блоки с matching tool.\nITEMS.wooden_pickaxe = item({\n name: \"wooden_pickaxe\",\n kind: \"tool\",\n toolType: \"pickaxe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_pickaxe\",\n});\nITEMS.wooden_axe = item({\n name: \"wooden_axe\",\n kind: \"tool\",\n toolType: \"axe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_axe\",\n});\nITEMS.wooden_sword = item({\n name: \"wooden_sword\",\n kind: \"tool\",\n toolType: \"sword\",\n speed: 1,\n damage: 4,\n maxStack: 1,\n tile: \"wooden_sword\",\n});\n\n/** Русские названия для тултипов HUD. */\nexport const ITEM_LABELS: Record<string, string> = {\n grass: \"Дёрн\",\n dirt: \"Земля\",\n stone: \"Камень\",\n cobblestone: \"Булыжник\",\n sand: \"Песок\",\n oak_log: \"Бревно\",\n leaves: \"Листва\",\n planks: \"Доски\",\n crafting_table: \"Верстак\",\n coal_ore: \"Угольная руда\",\n iron_ore: \"Железная руда\",\n tallgrass: \"Трава\",\n flower_yellow: \"Одуванчик\",\n flower_red: \"Мак\",\n stick: \"Палка\",\n coal: \"Уголь\",\n feather: \"Перо\",\n raw_chicken: \"Сырая курятина\",\n cooked_chicken: \"Жареная курятина\",\n apple: \"Яблоко\",\n wooden_pickaxe: \"Деревянная кирка\",\n wooden_axe: \"Деревянный топор\",\n wooden_sword: \"Деревянный меч\",\n};\n\n/**\n * Генерация иконок для HUD/инвентаря из тех же текстур атласа.\n * Вызывается один раз из main.js после создания атласа.\n */\nexport function buildIcons(atlas: TextureAtlas): void {\n for (const it of Object.values(ITEMS)) {\n if (it.kind === \"block\") {\n const b = BLOCKS[it.blockId!]!;\n const tex = b.tex!;\n it.icon = b.cross\n ? atlas.flatIcon(tex.side!)\n : atlas.blockIcon(tex.top!, tex.side!);\n } else {\n it.icon = atlas.flatIcon(it.tile!);\n }\n it.iconURL = it.icon!.toDataURL();\n }\n}\n"
115
115
  },
116
116
  {
117
117
  "path": "registry/Mobs.ts",
@@ -119,7 +119,7 @@
119
119
  },
120
120
  {
121
121
  "path": "registry/Recipes.ts",
122
- "content": "// Реестр рецептов — декларативный. Новый рецепт = одна запись.\n//\n// shapeless: { shapeless: ['oak_log'], result: { item: 'planks', count: 4 } }\n// shaped: { pattern: [['planks'],['planks']], result: {...} }\n// pattern — массив рядов, null = пустая клетка. При загрузке паттерн\n// нормализуется (обрезаются пустые ряды/колонки) и матчится в любом\n// месте сетки 2×2/3×3, включая зеркальное отражение.\n\nimport type { RecipeResult } from \"../types\";\n\ntype Grid = (string | null)[][];\ninterface RawRecipe {\n shapeless?: string[];\n pattern?: Grid;\n result: RecipeResult;\n}\ninterface CompiledRecipe extends RawRecipe {\n sorted?: string[];\n variants?: Grid[];\n}\n\nexport const RECIPES: RawRecipe[] = [\n { shapeless: [\"oak_log\"], result: { item: \"planks\", count: 4 } },\n { pattern: [[\"planks\"], [\"planks\"]], result: { item: \"stick\", count: 4 } },\n {\n pattern: [\n [\"planks\", \"planks\"],\n [\"planks\", \"planks\"],\n ],\n result: { item: \"crafting_table\", count: 1 },\n },\n {\n pattern: [\n [\"planks\", \"planks\", \"planks\"],\n [null, \"stick\", null],\n [null, \"stick\", null],\n ],\n result: { item: \"wooden_pickaxe\", count: 1 },\n },\n {\n pattern: [\n [\"planks\", \"planks\"],\n [\"planks\", \"stick\"],\n [null, \"stick\"],\n ],\n result: { item: \"wooden_axe\", count: 1 },\n },\n {\n pattern: [[\"planks\"], [\"planks\"], [\"stick\"]],\n result: { item: \"wooden_sword\", count: 1 },\n },\n];\n\n/** Обрезает пустые ряды/колонки, возвращает компактную матрицу или null. */\nfunction trimGrid(grid: Grid): Grid | null {\n let minR = Infinity,\n maxR = -1,\n minC = Infinity,\n maxC = -1;\n for (let r = 0; r < grid.length; r++)\n for (let c = 0; c < grid[r].length; c++)\n if (grid[r][c]) {\n minR = Math.min(minR, r);\n maxR = Math.max(maxR, r);\n minC = Math.min(minC, c);\n maxC = Math.max(maxC, c);\n }\n if (maxR < 0) return null;\n const out: Grid = [];\n for (let r = minR; r <= maxR; r++) {\n const row: (string | null)[] = [];\n for (let c = minC; c <= maxC; c++) row.push(grid[r][c] || null);\n out.push(row);\n }\n return out;\n}\n\nconst mirror = (grid: Grid): Grid => grid.map((row) => [...row].reverse());\nconst gridsEqual = (a: Grid, b: Grid): boolean =>\n a.length === b.length &&\n a.every(\n (row, r) =>\n row.length === b[r].length && row.every((v, c) => v === b[r][c]),\n );\n\n// Предвычисляем нормализованные варианты (оригинал + зеркало).\nconst compiled: CompiledRecipe[] = RECIPES.map((r) => {\n if (r.shapeless) return { ...r, sorted: [...r.shapeless].sort() };\n const trimmed = trimGrid(r.pattern!)!;\n const variants: Grid[] = [trimmed];\n const mir = mirror(trimmed);\n if (!gridsEqual(mir, trimmed)) variants.push(mir);\n return { ...r, variants };\n});\n\n/**\n * Матчит содержимое крафт-сетки.\n * @param slots — имена предметов, row-major\n * @param size — 2 или 3\n */\nexport function matchRecipe(\n slots: (string | null)[],\n size: number,\n): RecipeResult | null {\n const grid: Grid = [];\n for (let r = 0; r < size; r++)\n grid.push(slots.slice(r * size, r * size + size));\n const trimmed = trimGrid(grid);\n\n const present = slots.filter(Boolean).sort();\n for (const rec of compiled) {\n if (rec.shapeless) {\n if (\n present.length === rec.sorted!.length &&\n present.every((v, i) => v === rec.sorted![i])\n )\n return rec.result;\n } else if (trimmed) {\n // паттерн должен влезать в сетку (меч 1×3 не собрать в 2×2)\n for (const v of rec.variants!)\n if (v.length <= size && v[0].length <= size && gridsEqual(v, trimmed))\n return rec.result;\n }\n }\n return null;\n}\n"
122
+ "content": "// Реестр рецептов — декларативный. Новый рецепт = одна запись.\n//\n// shapeless: { shapeless: ['oak_log'], result: { item: 'planks', count: 4 } }\n// shaped: { pattern: [['planks'],['planks']], result: {...} }\n// pattern — массив рядов, null = пустая клетка. При загрузке паттерн\n// нормализуется (обрезаются пустые ряды/колонки) и матчится в любом\n// месте сетки 2×2/3×3, включая зеркальное отражение.\n\nimport type { RecipeResult } from \"../types\";\n\ntype Grid = (string | null)[][];\ninterface RawRecipe {\n shapeless?: string[];\n pattern?: Grid;\n result: RecipeResult;\n}\ninterface CompiledRecipe extends RawRecipe {\n sorted?: string[];\n variants?: Grid[];\n}\n\nexport const RECIPES: RawRecipe[] = [\n { shapeless: [\"oak_log\"], result: { item: \"planks\", count: 4 } },\n { pattern: [[\"planks\"], [\"planks\"]], result: { item: \"stick\", count: 4 } },\n {\n pattern: [\n [\"planks\", \"planks\"],\n [\"planks\", \"planks\"],\n ],\n result: { item: \"crafting_table\", count: 1 },\n },\n {\n pattern: [\n [\"planks\", \"planks\", \"planks\"],\n [null, \"stick\", null],\n [null, \"stick\", null],\n ],\n result: { item: \"wooden_pickaxe\", count: 1 },\n },\n {\n pattern: [\n [\"planks\", \"planks\"],\n [\"planks\", \"stick\"],\n [null, \"stick\"],\n ],\n result: { item: \"wooden_axe\", count: 1 },\n },\n {\n pattern: [[\"planks\"], [\"planks\"], [\"stick\"]],\n result: { item: \"wooden_sword\", count: 1 },\n },\n];\n\n/** Обрезает пустые ряды/колонки, возвращает компактную матрицу или null. */\nfunction trimGrid(grid: Grid): Grid | null {\n let minR = Infinity,\n maxR = -1,\n minC = Infinity,\n maxC = -1;\n for (let r = 0; r < grid.length; r++)\n for (let c = 0; c < grid[r]!.length; c++)\n if (grid[r]![c]) {\n minR = Math.min(minR, r);\n maxR = Math.max(maxR, r);\n minC = Math.min(minC, c);\n maxC = Math.max(maxC, c);\n }\n if (maxR < 0) return null;\n const out: Grid = [];\n for (let r = minR; r <= maxR; r++) {\n const row: (string | null)[] = [];\n for (let c = minC; c <= maxC; c++) row.push(grid[r]![c] || null);\n out.push(row);\n }\n return out;\n}\n\nconst mirror = (grid: Grid): Grid => grid.map((row) => [...row].reverse());\nconst gridsEqual = (a: Grid, b: Grid): boolean =>\n a.length === b.length &&\n a.every(\n (row, r) =>\n row.length === b[r]!.length && row.every((v, c) => v === b[r]![c]),\n );\n\n// Предвычисляем нормализованные варианты (оригинал + зеркало).\nconst compiled: CompiledRecipe[] = RECIPES.map((r) => {\n if (r.shapeless) return { ...r, sorted: [...r.shapeless].sort() };\n const trimmed = trimGrid(r.pattern!)!;\n const variants: Grid[] = [trimmed];\n const mir = mirror(trimmed);\n if (!gridsEqual(mir, trimmed)) variants.push(mir);\n return { ...r, variants };\n});\n\n/**\n * Матчит содержимое крафт-сетки.\n * @param slots — имена предметов, row-major\n * @param size — 2 или 3\n */\nexport function matchRecipe(\n slots: (string | null)[],\n size: number,\n): RecipeResult | null {\n const grid: Grid = [];\n for (let r = 0; r < size; r++)\n grid.push(slots.slice(r * size, r * size + size));\n const trimmed = trimGrid(grid);\n\n const present = slots.filter(Boolean).sort();\n for (const rec of compiled) {\n if (rec.shapeless) {\n if (\n present.length === rec.sorted!.length &&\n present.every((v, i) => v === rec.sorted![i])\n )\n return rec.result;\n } else if (trimmed) {\n // паттерн должен влезать в сетку (меч 1×3 не собрать в 2×2)\n for (const v of rec.variants!)\n if (v.length <= size && v[0]!.length <= size && gridsEqual(v, trimmed))\n return rec.result;\n }\n }\n return null;\n}\n"
123
123
  },
124
124
  {
125
125
  "path": "scene.ts",
@@ -143,11 +143,11 @@
143
143
  },
144
144
  {
145
145
  "path": "systems/DayNight.ts",
146
- "content": "// Цикл суток: 30 c день + 30 c ночь. Плавно лерпаются цвет неба, туман,\n// интенсивность/цвет солнца и ambient; солнце и луна — спрайты на орбите камеры.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\n\n// Ключевые точки цикла: t в [0..1), 0 = рассвет.\nconst KEYS = [\n {\n t: 0.0,\n sky: 0xffa958,\n sun: 0xffd9a0,\n sunI: 0.55,\n amb: 0.45,\n fog: 0xffc890,\n }, // рассвет\n { t: 0.1, sky: 0x87ceeb, sun: 0xffffff, sunI: 1.0, amb: 0.65, fog: 0xbfe3f5 }, // день\n { t: 0.4, sky: 0x87ceeb, sun: 0xffffff, sunI: 1.0, amb: 0.65, fog: 0xbfe3f5 },\n { t: 0.5, sky: 0xff7b45, sun: 0xffb070, sunI: 0.45, amb: 0.4, fog: 0xe8956a }, // закат\n {\n t: 0.6,\n sky: 0x0a1030,\n sun: 0x223055,\n sunI: 0.12,\n amb: 0.18,\n fog: 0x0e1638,\n }, // ночь\n {\n t: 0.9,\n sky: 0x0a1030,\n sun: 0x223055,\n sunI: 0.12,\n amb: 0.18,\n fog: 0x0e1638,\n },\n {\n t: 1.0,\n sky: 0xffa958,\n sun: 0xffd9a0,\n sunI: 0.55,\n amb: 0.45,\n fog: 0xffc890,\n },\n];\n\nfunction makeDiscSprite(color: string, glow: string): THREE.Sprite {\n const c = document.createElement(\"canvas\");\n c.width = c.height = 64;\n const ctx = c.getContext(\"2d\")!;\n const g = ctx.createRadialGradient(32, 32, 6, 32, 32, 30);\n g.addColorStop(0, color);\n g.addColorStop(0.5, color);\n g.addColorStop(1, glow);\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 64, 64);\n const tex = new THREE.CanvasTexture(c);\n const mat = new THREE.SpriteMaterial({\n map: tex,\n transparent: true,\n fog: false,\n depthWrite: false,\n });\n const s = new THREE.Sprite(mat);\n s.scale.set(14, 14, 1);\n return s;\n}\n\nexport class DayNight {\n scene: THREE.Scene;\n renderer: THREE.WebGLRenderer;\n cycleLength: number;\n time: number;\n sun: THREE.DirectionalLight;\n ambient: THREE.AmbientLight;\n skyColor: THREE.Color;\n sunSprite: THREE.Sprite;\n moonSprite: THREE.Sprite;\n _colA: THREE.Color;\n _colB: THREE.Color;\n\n constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {\n this.scene = scene;\n this.renderer = renderer;\n this.cycleLength = CONFIG.dayNight.dayLength + CONFIG.dayNight.nightLength;\n this.time = this.cycleLength * 0.15; // старт утром\n this.sun = new THREE.DirectionalLight(0xffffff, 1);\n this.ambient = new THREE.AmbientLight(0xffffff, 0.65);\n scene.add(this.sun, this.ambient);\n scene.fog = new THREE.Fog(0xbfe3f5, 20, CONFIG.renderDistance * 16 - 8);\n this.skyColor = new THREE.Color();\n this.sunSprite = makeDiscSprite(\"#fff4c0\", \"rgba(255,235,150,0)\");\n this.moonSprite = makeDiscSprite(\"#e8ecf5\", \"rgba(190,200,230,0)\");\n this.moonSprite.scale.set(9, 9, 1);\n scene.add(this.sunSprite, this.moonSprite);\n this._colA = new THREE.Color();\n this._colB = new THREE.Color();\n }\n\n tick(dt: number) {\n this.time = (this.time + dt) % this.cycleLength;\n }\n\n /** 0..1 фазы цикла (0 — рассвет). */\n get phase() {\n return this.time / this.cycleLength;\n }\n get isDay() {\n return this.phase < 0.5;\n }\n\n /** Кадровое обновление: интерполяция ключей + позиции светил вокруг игрока. */\n frame(playerPos: THREE.Vector3, underwater: boolean) {\n const t = this.phase;\n let a = KEYS[0],\n b = KEYS[KEYS.length - 1];\n for (let i = 0; i < KEYS.length - 1; i++)\n if (t >= KEYS[i].t && t <= KEYS[i + 1].t) {\n a = KEYS[i];\n b = KEYS[i + 1];\n break;\n }\n const f = (t - a.t) / Math.max(1e-6, b.t - a.t);\n const lerpC = (target: THREE.Color, ca: number, cb: number) =>\n target.copy(this._colA.setHex(ca)).lerp(this._colB.setHex(cb), f);\n\n lerpC(this.skyColor, a.sky, b.sky);\n lerpC(this.sun.color, a.sun, b.sun);\n this.sun.intensity = a.sunI + (b.sunI - a.sunI) * f;\n this.ambient.intensity = a.amb + (b.amb - a.amb) * f;\n\n const fog = this.scene.fog as THREE.Fog;\n lerpC(fog.color, a.fog, b.fog);\n if (underwater) {\n fog.color.setHex(0x1a4a8a);\n fog.near = 2;\n fog.far = 18;\n } else {\n fog.near = 20;\n fog.far = CONFIG.renderDistance * 16 - 8;\n }\n this.renderer.setClearColor(underwater ? 0x1a4a8a : this.skyColor);\n\n // солнце: восход на востоке (+x) в t=0, зенит в t=0.25, закат в t=0.5\n const elev = Math.sin(Math.PI * (0.5 - Math.abs(t - 0.25) * 2)); // 1 в полдень, <0 ночью\n const az = Math.cos((t - 0.25) * Math.PI * 2);\n const sunDir = new THREE.Vector3(az, Math.max(-0.4, elev), 0.3).normalize();\n this.sun.position.copy(playerPos).addScaledVector(sunDir, 60);\n this.sun.target.position.copy(playerPos);\n this.sun.target.updateMatrixWorld();\n\n this.sunSprite.position.copy(playerPos).addScaledVector(sunDir, 70);\n this.moonSprite.position\n .copy(playerPos)\n .addScaledVector(sunDir.clone().negate(), 70);\n this.sunSprite.material.opacity = Math.max(\n 0,\n Math.min(1, sunDir.y * 4 + 0.4),\n );\n this.moonSprite.material.opacity = Math.max(\n 0,\n Math.min(1, -sunDir.y * 4 + 0.4),\n );\n }\n\n serialize() {\n return { time: this.time };\n }\n deserialize(d: { time?: number }) {\n this.time = d.time ?? this.time;\n }\n}\n"
146
+ "content": "// Цикл суток: 30 c день + 30 c ночь. Плавно лерпаются цвет неба, туман,\n// интенсивность/цвет солнца и ambient; солнце и луна — спрайты на орбите камеры.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\n\n// Ключевые точки цикла: t в [0..1), 0 = рассвет.\nconst KEYS = [\n {\n t: 0.0,\n sky: 0xffa958,\n sun: 0xffd9a0,\n sunI: 0.55,\n amb: 0.45,\n fog: 0xffc890,\n }, // рассвет\n { t: 0.1, sky: 0x87ceeb, sun: 0xffffff, sunI: 1.0, amb: 0.65, fog: 0xbfe3f5 }, // день\n { t: 0.4, sky: 0x87ceeb, sun: 0xffffff, sunI: 1.0, amb: 0.65, fog: 0xbfe3f5 },\n { t: 0.5, sky: 0xff7b45, sun: 0xffb070, sunI: 0.45, amb: 0.4, fog: 0xe8956a }, // закат\n {\n t: 0.6,\n sky: 0x0a1030,\n sun: 0x223055,\n sunI: 0.12,\n amb: 0.18,\n fog: 0x0e1638,\n }, // ночь\n {\n t: 0.9,\n sky: 0x0a1030,\n sun: 0x223055,\n sunI: 0.12,\n amb: 0.18,\n fog: 0x0e1638,\n },\n {\n t: 1.0,\n sky: 0xffa958,\n sun: 0xffd9a0,\n sunI: 0.55,\n amb: 0.45,\n fog: 0xffc890,\n },\n];\n\nfunction makeDiscSprite(color: string, glow: string): THREE.Sprite {\n const c = document.createElement(\"canvas\");\n c.width = c.height = 64;\n const ctx = c.getContext(\"2d\")!;\n const g = ctx.createRadialGradient(32, 32, 6, 32, 32, 30);\n g.addColorStop(0, color);\n g.addColorStop(0.5, color);\n g.addColorStop(1, glow);\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 64, 64);\n const tex = new THREE.CanvasTexture(c);\n const mat = new THREE.SpriteMaterial({\n map: tex,\n transparent: true,\n fog: false,\n depthWrite: false,\n });\n const s = new THREE.Sprite(mat);\n s.scale.set(14, 14, 1);\n return s;\n}\n\nexport class DayNight {\n scene: THREE.Scene;\n renderer: THREE.WebGLRenderer;\n cycleLength: number;\n time: number;\n sun: THREE.DirectionalLight;\n ambient: THREE.AmbientLight;\n skyColor: THREE.Color;\n sunSprite: THREE.Sprite;\n moonSprite: THREE.Sprite;\n _colA: THREE.Color;\n _colB: THREE.Color;\n\n constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {\n this.scene = scene;\n this.renderer = renderer;\n this.cycleLength = CONFIG.dayNight.dayLength + CONFIG.dayNight.nightLength;\n this.time = this.cycleLength * 0.15; // старт утром\n this.sun = new THREE.DirectionalLight(0xffffff, 1);\n this.ambient = new THREE.AmbientLight(0xffffff, 0.65);\n scene.add(this.sun, this.ambient);\n scene.fog = new THREE.Fog(0xbfe3f5, 20, CONFIG.renderDistance * 16 - 8);\n this.skyColor = new THREE.Color();\n this.sunSprite = makeDiscSprite(\"#fff4c0\", \"rgba(255,235,150,0)\");\n this.moonSprite = makeDiscSprite(\"#e8ecf5\", \"rgba(190,200,230,0)\");\n this.moonSprite.scale.set(9, 9, 1);\n scene.add(this.sunSprite, this.moonSprite);\n this._colA = new THREE.Color();\n this._colB = new THREE.Color();\n }\n\n tick(dt: number) {\n this.time = (this.time + dt) % this.cycleLength;\n }\n\n /** 0..1 фазы цикла (0 — рассвет). */\n get phase() {\n return this.time / this.cycleLength;\n }\n get isDay() {\n return this.phase < 0.5;\n }\n\n /** Кадровое обновление: интерполяция ключей + позиции светил вокруг игрока. */\n frame(playerPos: THREE.Vector3, underwater: boolean) {\n const t = this.phase;\n let a = KEYS[0]!,\n b = KEYS[KEYS.length - 1]!;\n for (let i = 0; i < KEYS.length - 1; i++)\n if (t >= KEYS[i]!.t && t <= KEYS[i + 1]!.t) {\n a = KEYS[i]!;\n b = KEYS[i + 1]!;\n break;\n }\n const f = (t - a.t) / Math.max(1e-6, b.t - a.t);\n const lerpC = (target: THREE.Color, ca: number, cb: number) =>\n target.copy(this._colA.setHex(ca)).lerp(this._colB.setHex(cb), f);\n\n lerpC(this.skyColor, a.sky, b.sky);\n lerpC(this.sun.color, a.sun, b.sun);\n this.sun.intensity = a.sunI + (b.sunI - a.sunI) * f;\n this.ambient.intensity = a.amb + (b.amb - a.amb) * f;\n\n const fog = this.scene.fog as THREE.Fog;\n lerpC(fog.color, a.fog, b.fog);\n if (underwater) {\n fog.color.setHex(0x1a4a8a);\n fog.near = 2;\n fog.far = 18;\n } else {\n fog.near = 20;\n fog.far = CONFIG.renderDistance * 16 - 8;\n }\n this.renderer.setClearColor(underwater ? 0x1a4a8a : this.skyColor);\n\n // солнце: восход на востоке (+x) в t=0, зенит в t=0.25, закат в t=0.5\n const elev = Math.sin(Math.PI * (0.5 - Math.abs(t - 0.25) * 2)); // 1 в полдень, <0 ночью\n const az = Math.cos((t - 0.25) * Math.PI * 2);\n const sunDir = new THREE.Vector3(az, Math.max(-0.4, elev), 0.3).normalize();\n this.sun.position.copy(playerPos).addScaledVector(sunDir, 60);\n this.sun.target.position.copy(playerPos);\n this.sun.target.updateMatrixWorld();\n\n this.sunSprite.position.copy(playerPos).addScaledVector(sunDir, 70);\n this.moonSprite.position\n .copy(playerPos)\n .addScaledVector(sunDir.clone().negate(), 70);\n this.sunSprite.material.opacity = Math.max(\n 0,\n Math.min(1, sunDir.y * 4 + 0.4),\n );\n this.moonSprite.material.opacity = Math.max(\n 0,\n Math.min(1, -sunDir.y * 4 + 0.4),\n );\n }\n\n serialize() {\n return { time: this.time };\n }\n deserialize(d: { time?: number }) {\n this.time = d.time ?? this.time;\n }\n}\n"
147
147
  },
148
148
  {
149
149
  "path": "systems/Inventory.ts",
150
- "content": "// Инвентарь — чистая модель данных без DOM (UI подписывается на invChanged).\n// 36 слотов: 0..8 — хотбар, 9..35 — рюкзак. Слот: {item: string, count: number} | null.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ItemStack, InventorySave } from \"../types\";\n\nexport class Inventory {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n\n constructor(size = 36) {\n this.slots = new Array(size).fill(null);\n this.hotbarIndex = 0;\n }\n\n _changed() {\n events.emit(\"invChanged\");\n }\n\n heldItem(): ItemStack | null {\n return this.slots[this.hotbarIndex];\n }\n\n selectSlot(i: number) {\n this.hotbarIndex = Math.max(0, Math.min(8, i));\n this._changed();\n }\n scrollSlot(delta: number) {\n this.hotbarIndex = (this.hotbarIndex + delta + 9) % 9;\n this._changed();\n }\n\n /** Добавить предметы (сначала в существующие стаки, потом в пустые слоты).\n * @returns {number} сколько НЕ поместилось */\n add(itemName: string, count: number): number {\n const max = ITEMS[itemName]?.maxStack ?? 64;\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName && s.count < max) {\n const take = Math.min(count, max - s.count);\n s.count += take;\n count -= take;\n }\n }\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n if (!this.slots[i]) {\n const take = Math.min(count, max);\n this.slots[i] = { item: itemName, count: take };\n count -= take;\n }\n }\n this._changed();\n return count;\n }\n\n consumeHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n if (!s) return;\n s.count -= n;\n if (s.count <= 0) this.slots[this.hotbarIndex] = null;\n this._changed();\n }\n\n /** Съесть count предметов itemName из любого места (для крафта). */\n consume(itemName: string, count: number) {\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName) {\n const take = Math.min(count, s.count);\n s.count -= take;\n count -= take;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n this._changed();\n }\n\n setSlot(i: number, stack: ItemStack | null) {\n this.slots[i] = stack;\n this._changed();\n }\n\n serialize(): InventorySave {\n return { slots: this.slots, hotbarIndex: this.hotbarIndex };\n }\n deserialize(d: InventorySave) {\n this.slots = d.slots.map((s) => (s ? { ...s } : null));\n while (this.slots.length < 36) this.slots.push(null);\n this.hotbarIndex = d.hotbarIndex ?? 0;\n this._changed();\n }\n}\n"
150
+ "content": "// Инвентарь — чистая модель данных без DOM (UI подписывается на invChanged).\n// 36 слотов: 0..8 — хотбар, 9..35 — рюкзак. Слот: {item: string, count: number} | null.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ItemStack, InventorySave } from \"../types\";\n\nexport class Inventory {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n\n constructor(size = 36) {\n this.slots = new Array(size).fill(null);\n this.hotbarIndex = 0;\n }\n\n _changed() {\n events.emit(\"invChanged\");\n }\n\n heldItem(): ItemStack | null {\n return this.slots[this.hotbarIndex]!;\n }\n\n selectSlot(i: number) {\n this.hotbarIndex = Math.max(0, Math.min(8, i));\n this._changed();\n }\n scrollSlot(delta: number) {\n this.hotbarIndex = (this.hotbarIndex + delta + 9) % 9;\n this._changed();\n }\n\n /** Добавить предметы (сначала в существующие стаки, потом в пустые слоты).\n * @returns {number} сколько НЕ поместилось */\n add(itemName: string, count: number): number {\n const max = ITEMS[itemName]?.maxStack ?? 64;\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName && s.count < max) {\n const take = Math.min(count, max - s.count);\n s.count += take;\n count -= take;\n }\n }\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n if (!this.slots[i]) {\n const take = Math.min(count, max);\n this.slots[i] = { item: itemName, count: take };\n count -= take;\n }\n }\n this._changed();\n return count;\n }\n\n consumeHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n if (!s) return;\n s.count -= n;\n if (s.count <= 0) this.slots[this.hotbarIndex] = null;\n this._changed();\n }\n\n /** Съесть count предметов itemName из любого места (для крафта). */\n consume(itemName: string, count: number) {\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName) {\n const take = Math.min(count, s.count);\n s.count -= take;\n count -= take;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n this._changed();\n }\n\n setSlot(i: number, stack: ItemStack | null) {\n this.slots[i] = stack;\n this._changed();\n }\n\n serialize(): InventorySave {\n return { slots: this.slots, hotbarIndex: this.hotbarIndex };\n }\n deserialize(d: InventorySave) {\n this.slots = d.slots.map((s) => (s ? { ...s } : null));\n while (this.slots.length < 36) this.slots.push(null);\n this.hotbarIndex = d.hotbarIndex ?? 0;\n this._changed();\n }\n}\n"
151
151
  },
152
152
  {
153
153
  "path": "systems/Save.ts",
@@ -155,7 +155,7 @@
155
155
  },
156
156
  {
157
157
  "path": "systems/WaterSim.ts",
158
- "content": "// Симуляция воды — клеточный автомат на dirty-set: мир никогда не сканируется,\n// обрабатываются только клетки из очереди. Тикает на каждом 5-м симтике (4 Гц),\n// бюджет клеток за тик жёстко ограничен — остаток «дотекает» в следующие тики.\n//\n// Правила:\n// • source (бит меты) никогда не гаснет;\n// • под водой воздух → вода падает вниз полным уровнем;\n// • иначе растекается горизонтально с уровнем max(соседей)-1, минимум minFlowLevel;\n// • flowing-вода без опоры (нет соседа с большим уровнем и не под водой) убывает.\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { B, packWater, waterLevel, isWaterSource } from \"../registry/Blocks\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\n\ninterface Cell {\n x: number;\n y: number;\n z: number;\n}\n\nconst W = CONFIG.water;\nconst key = (x: number, y: number, z: number) => x + \",\" + y + \",\" + z;\n\nexport class WaterSim {\n world: ChunkManager;\n queue: Map<string, Cell>;\n _tickCounter: number;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.queue = new Map(); // key -> {x,y,z}\n this._tickCounter = 0;\n\n // любое изменение мира будит воду вокруг — так «прорывается дамба»\n events.on(\"blockChanged\", ({ x, y, z }) => {\n this.wake(x, y, z);\n this.wakeNeighbors(x, y, z);\n });\n }\n\n wake(x: number, y: number, z: number) {\n if (this.world.getBlock(x, y, z) === B.water || this._canFlowInto(x, y, z))\n this.queue.set(key(x, y, z), { x, y, z });\n }\n\n wakeNeighbors(x: number, y: number, z: number) {\n this.wake(x + 1, y, z);\n this.wake(x - 1, y, z);\n this.wake(x, y + 1, z);\n this.wake(x, y - 1, z);\n this.wake(x, y, z + 1);\n this.wake(x, y, z - 1);\n }\n\n _canFlowInto(x: number, y: number, z: number) {\n return this.world.getBlock(x, y, z) === B.air; // растения вода не смывает (MVP)\n }\n\n get queueSize() {\n return this.queue.size;\n }\n\n tick() {\n if (++this._tickCounter % W.tickEvery !== 0) return;\n if (this.queue.size === 0) return;\n\n const batch = [];\n for (const cell of this.queue.values()) {\n batch.push(cell);\n if (batch.length >= W.cellBudget) break;\n }\n for (const c of batch) this.queue.delete(key(c.x, c.y, c.z));\n\n for (const { x, y, z } of batch) this._updateCell(x, y, z);\n }\n\n _set(x: number, y: number, z: number, id: number, meta: number) {\n this.world.setBlock(x, y, z, id, meta, { waterOnly: true });\n this.wakeNeighbors(x, y, z);\n this.queue.set(key(x, y, z), { x, y, z });\n }\n\n _updateCell(x: number, y: number, z: number) {\n const id = this.world.getBlock(x, y, z);\n\n if (id === B.air) {\n // может ли вода прийти сюда? сверху — или сбоку с уровнем > minFlowLevel\n const above = this.world.getBlock(x, y + 1, z);\n if (above === B.water) {\n // ВАЖНО: при падении уровень наследуется (не сбрасывается в максимум) —\n // иначе каскад по склону заливает конус до океана (как в MC, но\n // неподъёмно для геймплея «сломал дамбу — лужа растеклась и встала»).\n this._set(\n x,\n y,\n z,\n B.water,\n packWater(waterLevel(this.world.getMeta(x, y + 1, z)), false),\n );\n return;\n }\n let best = 0;\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ]) {\n if (this.world.getBlock(x + dx, y, z + dz) !== B.water) continue;\n // сосед отдаёт воду вбок только если сам стоит на опоре\n const nUnder = this.world.getBlock(x + dx, y - 1, z + dz);\n if (nUnder === B.air) continue;\n best = Math.max(\n best,\n waterLevel(this.world.getMeta(x + dx, y, z + dz)),\n );\n }\n if (best - 1 >= W.minFlowLevel)\n this._set(x, y, z, B.water, packWater(best - 1, false));\n return;\n }\n\n if (id !== B.water) return;\n const meta = this.world.getMeta(x, y, z);\n if (isWaterSource(meta)) {\n this._spread(x, y, z, W.maxLevel);\n return;\n }\n\n // пересчёт уровня flowing-воды: питание сверху или от соседей\n const fedFromAbove = this.world.getBlock(x, y + 1, z) === B.water;\n let want = 0;\n if (fedFromAbove) want = waterLevel(this.world.getMeta(x, y + 1, z));\n else {\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ])\n if (this.world.getBlock(x + dx, y, z + dz) === B.water)\n want = Math.max(\n want,\n waterLevel(this.world.getMeta(x + dx, y, z + dz)) - 1,\n );\n }\n\n const cur = waterLevel(meta);\n if (want < W.minFlowLevel) {\n this._set(x, y, z, B.air, 0);\n return;\n } // высохла\n if (want !== cur) {\n this._set(x, y, z, B.water, packWater(want, false));\n return;\n }\n this._spread(x, y, z, cur);\n }\n\n /** Растекание из клетки: сначала вниз (уровень наследуется), потом в стороны. */\n _spread(x: number, y: number, z: number, level: number) {\n const below = this.world.getBlock(x, y - 1, z);\n if (below === B.air) {\n this._set(x, y - 1, z, B.water, packWater(level, false));\n return; // вода уходит вниз — вбок не льём\n }\n if (level - 1 < W.minFlowLevel) return;\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ]) {\n const nid = this.world.getBlock(x + dx, y, z + dz);\n if (nid === B.air)\n this._set(x + dx, y, z + dz, B.water, packWater(level - 1, false));\n else if (\n nid === B.water &&\n waterLevel(this.world.getMeta(x + dx, y, z + dz)) < level - 1\n )\n this._set(x + dx, y, z + dz, B.water, packWater(level - 1, false));\n }\n }\n}\n"
158
+ "content": "// Симуляция воды — клеточный автомат на dirty-set: мир никогда не сканируется,\n// обрабатываются только клетки из очереди. Тикает на каждом 5-м симтике (4 Гц),\n// бюджет клеток за тик жёстко ограничен — остаток «дотекает» в следующие тики.\n//\n// Правила:\n// • source (бит меты) никогда не гаснет;\n// • под водой воздух → вода падает вниз полным уровнем;\n// • иначе растекается горизонтально с уровнем max(соседей)-1, минимум minFlowLevel;\n// • flowing-вода без опоры (нет соседа с большим уровнем и не под водой) убывает.\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { B, packWater, waterLevel, isWaterSource } from \"../registry/Blocks\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\n\ninterface Cell {\n x: number;\n y: number;\n z: number;\n}\n\nconst W = CONFIG.water;\nconst key = (x: number, y: number, z: number) => x + \",\" + y + \",\" + z;\n\nexport class WaterSim {\n world: ChunkManager;\n queue: Map<string, Cell>;\n _tickCounter: number;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.queue = new Map(); // key -> {x,y,z}\n this._tickCounter = 0;\n\n // любое изменение мира будит воду вокруг — так «прорывается дамба»\n events.on(\"blockChanged\", ({ x, y, z }) => {\n this.wake(x, y, z);\n this.wakeNeighbors(x, y, z);\n });\n }\n\n wake(x: number, y: number, z: number) {\n if (this.world.getBlock(x, y, z) === B.water || this._canFlowInto(x, y, z))\n this.queue.set(key(x, y, z), { x, y, z });\n }\n\n wakeNeighbors(x: number, y: number, z: number) {\n this.wake(x + 1, y, z);\n this.wake(x - 1, y, z);\n this.wake(x, y + 1, z);\n this.wake(x, y - 1, z);\n this.wake(x, y, z + 1);\n this.wake(x, y, z - 1);\n }\n\n _canFlowInto(x: number, y: number, z: number) {\n return this.world.getBlock(x, y, z) === B.air; // растения вода не смывает (MVP)\n }\n\n get queueSize() {\n return this.queue.size;\n }\n\n tick() {\n if (++this._tickCounter % W.tickEvery !== 0) return;\n if (this.queue.size === 0) return;\n\n const batch = [];\n for (const cell of this.queue.values()) {\n batch.push(cell);\n if (batch.length >= W.cellBudget) break;\n }\n for (const c of batch) this.queue.delete(key(c.x, c.y, c.z));\n\n for (const { x, y, z } of batch) this._updateCell(x, y, z);\n }\n\n _set(x: number, y: number, z: number, id: number, meta: number) {\n this.world.setBlock(x, y, z, id, meta, { waterOnly: true });\n this.wakeNeighbors(x, y, z);\n this.queue.set(key(x, y, z), { x, y, z });\n }\n\n _updateCell(x: number, y: number, z: number) {\n const id = this.world.getBlock(x, y, z);\n\n if (id === B.air) {\n // может ли вода прийти сюда? сверху — или сбоку с уровнем > minFlowLevel\n const above = this.world.getBlock(x, y + 1, z);\n if (above === B.water) {\n // ВАЖНО: при падении уровень наследуется (не сбрасывается в максимум) —\n // иначе каскад по склону заливает конус до океана (как в MC, но\n // неподъёмно для геймплея «сломал дамбу — лужа растеклась и встала»).\n this._set(\n x,\n y,\n z,\n B.water,\n packWater(waterLevel(this.world.getMeta(x, y + 1, z)), false),\n );\n return;\n }\n let best = 0;\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ]) {\n if (this.world.getBlock(x + dx!, y, z + dz!) !== B.water) continue;\n // сосед отдаёт воду вбок только если сам стоит на опоре\n const nUnder = this.world.getBlock(x + dx!, y - 1, z + dz!);\n if (nUnder === B.air) continue;\n best = Math.max(\n best,\n waterLevel(this.world.getMeta(x + dx!, y, z + dz!)),\n );\n }\n if (best - 1 >= W.minFlowLevel)\n this._set(x, y, z, B.water!, packWater(best - 1, false));\n return;\n }\n\n if (id !== B.water) return;\n const meta = this.world.getMeta(x, y, z);\n if (isWaterSource(meta)) {\n this._spread(x, y, z, W.maxLevel);\n return;\n }\n\n // пересчёт уровня flowing-воды: питание сверху или от соседей\n const fedFromAbove = this.world.getBlock(x, y + 1, z) === B.water;\n let want = 0;\n if (fedFromAbove) want = waterLevel(this.world.getMeta(x, y + 1, z));\n else {\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ])\n if (this.world.getBlock(x + dx!, y, z + dz!) === B.water)\n want = Math.max(\n want,\n waterLevel(this.world.getMeta(x + dx!, y, z + dz!)) - 1,\n );\n }\n\n const cur = waterLevel(meta);\n if (want < W.minFlowLevel) {\n this._set(x, y, z, B.air!, 0);\n return;\n } // высохла\n if (want !== cur) {\n this._set(x, y, z, B.water, packWater(want, false));\n return;\n }\n this._spread(x, y, z, cur);\n }\n\n /** Растекание из клетки: сначала вниз (уровень наследуется), потом в стороны. */\n _spread(x: number, y: number, z: number, level: number) {\n const below = this.world.getBlock(x, y - 1, z);\n if (below === B.air) {\n this._set(x, y - 1, z, B.water!, packWater(level, false));\n return; // вода уходит вниз — вбок не льём\n }\n if (level - 1 < W.minFlowLevel) return;\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ]) {\n const nid = this.world.getBlock(x + dx!, y, z + dz!);\n if (nid === B.air)\n this._set(x + dx!, y, z + dz!, B.water!, packWater(level - 1, false));\n else if (\n nid === B.water &&\n waterLevel(this.world.getMeta(x + dx!, y, z + dz!)) < level - 1\n )\n this._set(x + dx!, y, z + dz!, B.water, packWater(level - 1, false));\n }\n }\n}\n"
159
159
  },
160
160
  {
161
161
  "path": "types.ts",
@@ -163,23 +163,23 @@
163
163
  },
164
164
  {
165
165
  "path": "ui/HUD.ts",
166
- "content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items\";\nimport type { Game } from \"../main\";\n\nexport class HUD {\n game: Game;\n $: (id: string) => HTMLElement;\n hotbarEl: HTMLElement;\n heartsEl: HTMLElement;\n fpsEl: HTMLElement;\n debugEl: HTMLElement;\n timeEl: HTMLElement;\n overlayEl: HTMLElement;\n hurtEl: HTMLElement;\n msgEl: HTMLElement;\n hungerEl: HTMLElement;\n _invDirty: boolean;\n _lastHealth: number;\n _lastFood?: number;\n _modeWas?: string;\n _foodModeWas?: string;\n _msgT?: ReturnType<typeof setTimeout>;\n _slots: HTMLDivElement[];\n _hearts: HTMLSpanElement[];\n _drumsticks: HTMLSpanElement[];\n\n constructor(game: Game) {\n this.game = game;\n // Все элементы гарантированно есть в разметке — helper утверждает non-null.\n this.$ = (id: string): HTMLElement => document.getElementById(id)!;\n this.hotbarEl = this.$(\"hotbar\");\n this.heartsEl = this.$(\"hearts\");\n this.fpsEl = this.$(\"fps\");\n this.debugEl = this.$(\"debug\");\n this.timeEl = this.$(\"timeIndicator\");\n this.overlayEl = this.$(\"overlay\");\n this.hurtEl = this.$(\"hurtFlash\");\n this.msgEl = this.$(\"message\");\n\n this._invDirty = true;\n this._lastHealth = -1;\n events.on(\"invChanged\", () => {\n this._invDirty = true;\n });\n events.on(\"playerHurt\", () => this._flashHurt());\n events.on(\"playerDied\", () => this.message(\"Вы погибли…\", 1200));\n events.on(\"modeChanged\", ({ mode }) =>\n this.message(\n mode === \"creative\"\n ? \"Режим: КРЕАТИВ (двойной Space — полёт)\"\n : \"Режим: выживание\",\n 1600,\n ),\n );\n\n // слоты хотбара\n this._slots = [];\n for (let i = 0; i < 9; i++) {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n this.hotbarEl.appendChild(d);\n this._slots.push(d);\n }\n // сердечки\n this._hearts = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"heart\";\n s.textContent = \"❤\";\n this.heartsEl.appendChild(s);\n this._hearts.push(s);\n }\n // окорочка (голод)\n this.hungerEl = this.$(\"hunger\");\n this._drumsticks = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"drumstick\";\n s.textContent = \"🍗\";\n this.hungerEl.appendChild(s);\n this._drumsticks.push(s);\n }\n\n // кнопки паузы\n this.$(\"btnExport\").onclick = () => game.save.exportFile();\n this.$(\"btnImport\").onclick = () =>\n game.save.importFile((err: unknown) => {\n this.message(\n err ? \"Ошибка импорта: \" + (err as Error).message : \"Мир загружен\",\n 1800,\n );\n });\n this.$(\"btnNewSeed\").onclick = () => {\n const s = prompt(\n \"Сид нового мира (число):\",\n String((Math.random() * 1e9) | 0),\n );\n if (s !== null && s.trim() !== \"\") game.newWorld(Number(s) || 0);\n };\n this.overlayEl.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).closest(\"button\")) return; // кнопки не запускают игру\n game.startPlay();\n });\n }\n\n showOverlay(paused: boolean) {\n this.overlayEl.classList.remove(\"hidden\");\n this.$(\"overlayTitle\").textContent = paused ? \"Пауза\" : \"VoxelCraft\";\n this.$(\"overlayHint\").textContent = paused\n ? \"Клик — продолжить\"\n : \"Клик — играть\";\n }\n hideOverlay() {\n this.overlayEl.classList.add(\"hidden\");\n }\n\n message(text: string, ms = 1500) {\n this.msgEl.textContent = text;\n this.msgEl.classList.add(\"show\");\n clearTimeout(this._msgT);\n this._msgT = setTimeout(() => this.msgEl.classList.remove(\"show\"), ms);\n }\n\n _flashHurt() {\n this.hurtEl.classList.remove(\"show\");\n void this.hurtEl.offsetWidth; // перезапуск CSS-анимации\n this.hurtEl.classList.add(\"show\");\n }\n\n frame() {\n const g = this.game;\n if (this._invDirty) {\n this._renderHotbar();\n this._invDirty = false;\n }\n if (\n g.player.health !== this._lastHealth ||\n this._modeWas !== g.player.mode\n ) {\n this._renderHearts();\n this._lastHealth = g.player.health;\n this._modeWas = g.player.mode;\n }\n if (\n g.player.food !== this._lastFood ||\n this._foodModeWas !== g.player.mode\n ) {\n this._renderHunger();\n this._lastFood = g.player.food;\n this._foodModeWas = g.player.mode;\n }\n\n this.fpsEl.textContent = g.loop.fps + \" FPS\";\n // дебаг-строка\n const p = g.player.pos;\n this.debugEl.textContent =\n `xyz: ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)} | ` +\n `чанки: ${g.world.chunks.size} | вода: ${g.waterSim.queueSize} | сид: ${g.world.seed}`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n this.timeEl.textContent =\n (g.dayNight.isDay ? \"☀\" : \"☾\") + \" \" + (ph < 0.5 ? \"день\" : \"ночь\");\n }\n\n _renderHotbar() {\n const inv = this.game.inventory;\n for (let i = 0; i < 9; i++) {\n const el = this._slots[i];\n const s = inv.slots[i];\n el.classList.toggle(\"selected\", i === inv.hotbarIndex);\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (s) {\n img.src = ITEMS[s.item].iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = ITEM_LABELS[s.item] ?? s.item;\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (s.count > 1 ? s.count : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n }\n }\n }\n\n _renderHearts() {\n const g = this.game;\n this.heartsEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const hp = g.player.health;\n for (let i = 0; i < 10; i++) {\n const v = hp - i * 2;\n this._hearts[i].className =\n \"heart\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n _renderHunger() {\n const g = this.game;\n this.hungerEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const food = g.player.food;\n for (let i = 0; i < 10; i++) {\n const v = food - i * 2;\n this._drumsticks[i].className =\n \"drumstick\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n}\n"
166
+ "content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items\";\nimport type { Game } from \"../main\";\n\nexport class HUD {\n game: Game;\n $: (id: string) => HTMLElement;\n hotbarEl: HTMLElement;\n heartsEl: HTMLElement;\n fpsEl: HTMLElement;\n debugEl: HTMLElement;\n timeEl: HTMLElement;\n overlayEl: HTMLElement;\n hurtEl: HTMLElement;\n msgEl: HTMLElement;\n hungerEl: HTMLElement;\n _invDirty: boolean;\n _lastHealth: number;\n _lastFood?: number;\n _modeWas?: string;\n _foodModeWas?: string;\n _msgT?: ReturnType<typeof setTimeout>;\n _slots: HTMLDivElement[];\n _hearts: HTMLSpanElement[];\n _drumsticks: HTMLSpanElement[];\n\n constructor(game: Game) {\n this.game = game;\n // Все элементы гарантированно есть в разметке — helper утверждает non-null.\n this.$ = (id: string): HTMLElement => document.getElementById(id)!;\n this.hotbarEl = this.$(\"hotbar\");\n this.heartsEl = this.$(\"hearts\");\n this.fpsEl = this.$(\"fps\");\n this.debugEl = this.$(\"debug\");\n this.timeEl = this.$(\"timeIndicator\");\n this.overlayEl = this.$(\"overlay\");\n this.hurtEl = this.$(\"hurtFlash\");\n this.msgEl = this.$(\"message\");\n\n this._invDirty = true;\n this._lastHealth = -1;\n events.on(\"invChanged\", () => {\n this._invDirty = true;\n });\n events.on(\"playerHurt\", () => this._flashHurt());\n events.on(\"playerDied\", () => this.message(\"Вы погибли…\", 1200));\n events.on(\"modeChanged\", ({ mode }) =>\n this.message(\n mode === \"creative\"\n ? \"Режим: КРЕАТИВ (двойной Space — полёт)\"\n : \"Режим: выживание\",\n 1600,\n ),\n );\n\n // слоты хотбара\n this._slots = [];\n for (let i = 0; i < 9; i++) {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n this.hotbarEl.appendChild(d);\n this._slots.push(d);\n }\n // сердечки\n this._hearts = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"heart\";\n s.textContent = \"❤\";\n this.heartsEl.appendChild(s);\n this._hearts.push(s);\n }\n // окорочка (голод)\n this.hungerEl = this.$(\"hunger\");\n this._drumsticks = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"drumstick\";\n s.textContent = \"🍗\";\n this.hungerEl.appendChild(s);\n this._drumsticks.push(s);\n }\n\n // кнопки паузы\n this.$(\"btnExport\").onclick = () => game.save.exportFile();\n this.$(\"btnImport\").onclick = () =>\n game.save.importFile((err: unknown) => {\n this.message(\n err ? \"Ошибка импорта: \" + (err as Error).message : \"Мир загружен\",\n 1800,\n );\n });\n this.$(\"btnNewSeed\").onclick = () => {\n const s = prompt(\n \"Сид нового мира (число):\",\n String((Math.random() * 1e9) | 0),\n );\n if (s !== null && s.trim() !== \"\") game.newWorld(Number(s) || 0);\n };\n this.overlayEl.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).closest(\"button\")) return; // кнопки не запускают игру\n game.startPlay();\n });\n }\n\n showOverlay(paused: boolean) {\n this.overlayEl.classList.remove(\"hidden\");\n this.$(\"overlayTitle\").textContent = paused ? \"Пауза\" : \"VoxelCraft\";\n this.$(\"overlayHint\").textContent = paused\n ? \"Клик — продолжить\"\n : \"Клик — играть\";\n }\n hideOverlay() {\n this.overlayEl.classList.add(\"hidden\");\n }\n\n message(text: string, ms = 1500) {\n this.msgEl.textContent = text;\n this.msgEl.classList.add(\"show\");\n clearTimeout(this._msgT);\n this._msgT = setTimeout(() => this.msgEl.classList.remove(\"show\"), ms);\n }\n\n _flashHurt() {\n this.hurtEl.classList.remove(\"show\");\n void this.hurtEl.offsetWidth; // перезапуск CSS-анимации\n this.hurtEl.classList.add(\"show\");\n }\n\n frame() {\n const g = this.game;\n if (this._invDirty) {\n this._renderHotbar();\n this._invDirty = false;\n }\n if (\n g.player.health !== this._lastHealth ||\n this._modeWas !== g.player.mode\n ) {\n this._renderHearts();\n this._lastHealth = g.player.health;\n this._modeWas = g.player.mode;\n }\n if (\n g.player.food !== this._lastFood ||\n this._foodModeWas !== g.player.mode\n ) {\n this._renderHunger();\n this._lastFood = g.player.food;\n this._foodModeWas = g.player.mode;\n }\n\n this.fpsEl.textContent = g.loop.fps + \" FPS\";\n // дебаг-строка\n const p = g.player.pos;\n this.debugEl.textContent =\n `xyz: ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)} | ` +\n `чанки: ${g.world.chunks.size} | вода: ${g.waterSim.queueSize} | сид: ${g.world.seed}`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n this.timeEl.textContent =\n (g.dayNight.isDay ? \"☀\" : \"☾\") + \" \" + (ph < 0.5 ? \"день\" : \"ночь\");\n }\n\n _renderHotbar() {\n const inv = this.game.inventory;\n for (let i = 0; i < 9; i++) {\n const el = this._slots[i]!;\n const s = inv.slots[i];\n el.classList.toggle(\"selected\", i === inv.hotbarIndex);\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (s) {\n img.src = ITEMS[s.item]!.iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = ITEM_LABELS[s.item] ?? s.item;\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (s.count > 1 ? s.count : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n }\n }\n }\n\n _renderHearts() {\n const g = this.game;\n this.heartsEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const hp = g.player.health;\n for (let i = 0; i < 10; i++) {\n const v = hp - i * 2;\n this._hearts[i]!.className =\n \"heart\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n _renderHunger() {\n const g = this.game;\n this.hungerEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const food = g.player.food;\n for (let i = 0; i < 10; i++) {\n const v = food - i * 2;\n this._drumsticks[i]!.className =\n \"drumstick\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n}\n"
167
167
  },
168
168
  {
169
169
  "path": "ui/InventoryUI.ts",
170
- "content": "// Экран инвентаря и крафта: сетка слотов с drag-and-drop «курсорным стаком»\n// (клик — взять/положить, ПКМ — половина/по одному), крафт 2×2 или 3×3 (верстак).\n// UI мутирует ТОЛЬКО модели Inventory/CraftingGrid и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\n\ntype Section = \"inv\" | \"craft\";\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n titleEl: HTMLElement;\n _craftSlots!: HTMLDivElement[]; // строятся в _build() до первого _render()\n _resultSlot!: HTMLElement;\n _invSlots!: HTMLDivElement[];\n\n constructor(game: Game) {\n this.game = game;\n this.inventory = game.inventory;\n this.grid = new CraftingGrid(2);\n this.cursor = null; // стак «в руке» у курсора\n this.open = false;\n\n // Элементы гарантированно есть в разметке — non-null утверждается локально.\n this.rootEl = document.getElementById(\"invScreen\")!;\n this.cursorEl = document.getElementById(\"cursorStack\")!;\n this.titleEl = document.getElementById(\"invTitle\")!;\n\n events.on(\"ui:toggleInventory\", () => this.toggle(2));\n events.on(\"ui:openCrafting\", () => this.toggle(3, true));\n\n document.addEventListener(\"mousemove\", (e) => {\n if (!this.open) return;\n this.cursorEl.style.left = e.clientX + \"px\";\n this.cursorEl.style.top = e.clientY + \"px\";\n });\n }\n\n toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n if (this.open && forceOpen) return; // уже открыт\n this.open = true;\n this.grid.setSize(craftSize);\n this.titleEl.textContent = craftSize === 3 ? \"Верстак\" : \"Инвентарь\";\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.rootEl.classList.remove(\"hidden\");\n this._build();\n this._render();\n }\n\n close() {\n if (!this.open) return;\n this.open = false;\n // вернуть всё из крафт-сетки и курсора\n this.grid.dumpInto(this.inventory);\n if (this.cursor) {\n const left = this.inventory.add(this.cursor.item, this.cursor.count);\n if (left > 0)\n // некуда класть — выбрасываем под ноги, ничего не теряем\n this.game.entities.spawnDrop(\n this.game.player.pos.x,\n this.game.player.pos.y + 1,\n this.game.player.pos.z,\n this.cursor.item,\n left,\n );\n this.cursor = null;\n }\n this.rootEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Построение DOM под текущий размер крафт-сетки. */\n _build() {\n const n = this.grid.size;\n const craftEl = document.getElementById(\"craftGrid\")!;\n craftEl.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n craftEl.innerHTML = \"\";\n this._craftSlots = [];\n for (let i = 0; i < n * n; i++)\n this._craftSlots.push(this._slot(craftEl, \"craft\", i));\n\n this._resultSlot = document.getElementById(\"craftResult\")!;\n this._resultSlot.innerHTML =\n '<img draggable=\"false\"><span class=\"count\"></span>';\n this._resultSlot.onmousedown = (e) => {\n this._clickResult(e);\n e.preventDefault();\n };\n\n const invEl = document.getElementById(\"invGrid\")!;\n invEl.innerHTML = \"\";\n this._invSlots = [];\n for (let i = 9; i < 36; i++)\n this._invSlots[i] = this._slot(invEl, \"inv\", i);\n const hbEl = document.getElementById(\"invHotbar\")!;\n hbEl.innerHTML = \"\";\n for (let i = 0; i < 9; i++) this._invSlots[i] = this._slot(hbEl, \"inv\", i);\n }\n\n _slot(parent: HTMLElement, section: Section, index: number): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n d.onmousedown = (e) => {\n this._clickSlot(section, index, e.button === 2);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n parent.appendChild(d);\n return d;\n }\n\n _getStack(section: Section, i: number): ItemStack | null {\n return section === \"inv\" ? this.inventory.slots[i] : this.grid.slots[i];\n }\n _setStack(section: Section, i: number, stack: ItemStack | null) {\n if (section === \"inv\") this.inventory.setSlot(i, stack);\n else this.grid.slots[i] = stack;\n }\n\n _clickSlot(section: Section, i: number, rightClick: boolean) {\n const cur = this.cursor;\n const slot = this._getStack(section, i);\n\n if (!cur) {\n if (!slot) return;\n if (rightClick) {\n // взять половину\n const take = Math.ceil(slot.count / 2);\n this.cursor = { item: slot.item, count: take };\n slot.count -= take;\n this._setStack(section, i, slot.count > 0 ? slot : null);\n } else {\n this.cursor = slot;\n this._setStack(section, i, null);\n }\n } else {\n const max = ITEMS[cur.item].maxStack;\n if (!slot) {\n if (rightClick) {\n this._setStack(section, i, { item: cur.item, count: 1 });\n cur.count -= 1;\n if (cur.count <= 0) this.cursor = null;\n } else {\n this._setStack(section, i, cur);\n this.cursor = null;\n }\n } else if (slot.item === cur.item) {\n const move = rightClick ? 1 : cur.count;\n const take = Math.min(move, max - slot.count);\n slot.count += take;\n cur.count -= take;\n this._setStack(section, i, slot);\n if (cur.count <= 0) this.cursor = null;\n } else {\n // обмен\n this._setStack(section, i, cur);\n this.cursor = slot;\n }\n }\n this._render();\n }\n\n _clickResult(_e?: MouseEvent) {\n const res = this.grid.result();\n if (!res) return;\n if (\n this.cursor &&\n (this.cursor.item !== res.item ||\n this.cursor.count + res.count > ITEMS[res.item].maxStack)\n )\n return;\n const taken = this.grid.takeResult()!; // result() уже проверен выше — сетка не менялась, результат есть\n if (this.cursor) this.cursor.count += taken.count;\n else this.cursor = { item: taken.item, count: taken.count };\n this._render();\n }\n\n _render() {\n const draw = (el: HTMLElement, stack: ItemStack | null) => {\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (stack) {\n img.src = ITEMS[stack.item].iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = ITEM_LABELS[stack.item] ?? stack.item;\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (stack.count > 1\n ? stack.count\n : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n }\n };\n for (let i = 0; i < 36; i++)\n draw(this._invSlots[i], this.inventory.slots[i]);\n this._craftSlots.forEach((el, i) => draw(el, this.grid.slots[i]));\n draw(\n this._resultSlot,\n this.grid.result() ? { ...this.grid.result()! } : null,\n );\n\n // курсорный стак\n if (this.cursor) {\n this.cursorEl.classList.remove(\"hidden\");\n this.cursorEl.querySelector(\"img\")!.src =\n ITEMS[this.cursor.item].iconURL!;\n this.cursorEl.querySelector(\".count\")!.textContent = (this.cursor.count >\n 1\n ? this.cursor.count\n : \"\") as unknown as string;\n } else {\n this.cursorEl.classList.add(\"hidden\");\n }\n events.emit(\"invChanged\"); // HUD-хотбар обновится\n }\n}\n"
170
+ "content": "// Экран инвентаря и крафта: сетка слотов с drag-and-drop «курсорным стаком»\n// (клик — взять/положить, ПКМ — половина/по одному), крафт 2×2 или 3×3 (верстак).\n// UI мутирует ТОЛЬКО модели Inventory/CraftingGrid и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\n\ntype Section = \"inv\" | \"craft\";\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n titleEl: HTMLElement;\n _craftSlots!: HTMLDivElement[]; // строятся в _build() до первого _render()\n _resultSlot!: HTMLElement;\n _invSlots!: HTMLDivElement[];\n\n constructor(game: Game) {\n this.game = game;\n this.inventory = game.inventory;\n this.grid = new CraftingGrid(2);\n this.cursor = null; // стак «в руке» у курсора\n this.open = false;\n\n // Элементы гарантированно есть в разметке — non-null утверждается локально.\n this.rootEl = document.getElementById(\"invScreen\")!;\n this.cursorEl = document.getElementById(\"cursorStack\")!;\n this.titleEl = document.getElementById(\"invTitle\")!;\n\n events.on(\"ui:toggleInventory\", () => this.toggle(2));\n events.on(\"ui:openCrafting\", () => this.toggle(3, true));\n\n document.addEventListener(\"mousemove\", (e) => {\n if (!this.open) return;\n this.cursorEl.style.left = e.clientX + \"px\";\n this.cursorEl.style.top = e.clientY + \"px\";\n });\n }\n\n toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n if (this.open && forceOpen) return; // уже открыт\n this.open = true;\n this.grid.setSize(craftSize);\n this.titleEl.textContent = craftSize === 3 ? \"Верстак\" : \"Инвентарь\";\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.rootEl.classList.remove(\"hidden\");\n this._build();\n this._render();\n }\n\n close() {\n if (!this.open) return;\n this.open = false;\n // вернуть всё из крафт-сетки и курсора\n this.grid.dumpInto(this.inventory);\n if (this.cursor) {\n const left = this.inventory.add(this.cursor.item, this.cursor.count);\n if (left > 0)\n // некуда класть — выбрасываем под ноги, ничего не теряем\n this.game.entities.spawnDrop(\n this.game.player.pos.x,\n this.game.player.pos.y + 1,\n this.game.player.pos.z,\n this.cursor.item,\n left,\n );\n this.cursor = null;\n }\n this.rootEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Построение DOM под текущий размер крафт-сетки. */\n _build() {\n const n = this.grid.size;\n const craftEl = document.getElementById(\"craftGrid\")!;\n craftEl.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n craftEl.innerHTML = \"\";\n this._craftSlots = [];\n for (let i = 0; i < n * n; i++)\n this._craftSlots.push(this._slot(craftEl, \"craft\", i));\n\n this._resultSlot = document.getElementById(\"craftResult\")!;\n this._resultSlot.innerHTML =\n '<img draggable=\"false\"><span class=\"count\"></span>';\n this._resultSlot.onmousedown = (e) => {\n this._clickResult(e);\n e.preventDefault();\n };\n\n const invEl = document.getElementById(\"invGrid\")!;\n invEl.innerHTML = \"\";\n this._invSlots = [];\n for (let i = 9; i < 36; i++)\n this._invSlots[i] = this._slot(invEl, \"inv\", i);\n const hbEl = document.getElementById(\"invHotbar\")!;\n hbEl.innerHTML = \"\";\n for (let i = 0; i < 9; i++) this._invSlots[i] = this._slot(hbEl, \"inv\", i);\n }\n\n _slot(parent: HTMLElement, section: Section, index: number): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n d.onmousedown = (e) => {\n this._clickSlot(section, index, e.button === 2);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n parent.appendChild(d);\n return d;\n }\n\n _getStack(section: Section, i: number): ItemStack | null {\n return section === \"inv\" ? this.inventory.slots[i]! : this.grid.slots[i]!;\n }\n _setStack(section: Section, i: number, stack: ItemStack | null) {\n if (section === \"inv\") this.inventory.setSlot(i, stack);\n else this.grid.slots[i] = stack;\n }\n\n _clickSlot(section: Section, i: number, rightClick: boolean) {\n const cur = this.cursor;\n const slot = this._getStack(section, i);\n\n if (!cur) {\n if (!slot) return;\n if (rightClick) {\n // взять половину\n const take = Math.ceil(slot.count / 2);\n this.cursor = { item: slot.item, count: take };\n slot.count -= take;\n this._setStack(section, i, slot.count > 0 ? slot : null);\n } else {\n this.cursor = slot;\n this._setStack(section, i, null);\n }\n } else {\n const max = ITEMS[cur.item]!.maxStack;\n if (!slot) {\n if (rightClick) {\n this._setStack(section, i, { item: cur.item, count: 1 });\n cur.count -= 1;\n if (cur.count <= 0) this.cursor = null;\n } else {\n this._setStack(section, i, cur);\n this.cursor = null;\n }\n } else if (slot.item === cur.item) {\n const move = rightClick ? 1 : cur.count;\n const take = Math.min(move, max - slot.count);\n slot.count += take;\n cur.count -= take;\n this._setStack(section, i, slot);\n if (cur.count <= 0) this.cursor = null;\n } else {\n // обмен\n this._setStack(section, i, cur);\n this.cursor = slot;\n }\n }\n this._render();\n }\n\n _clickResult(_e?: MouseEvent) {\n const res = this.grid.result();\n if (!res) return;\n if (\n this.cursor &&\n (this.cursor.item !== res.item ||\n this.cursor.count + res.count > ITEMS[res.item]!.maxStack)\n )\n return;\n const taken = this.grid.takeResult()!; // result() уже проверен выше — сетка не менялась, результат есть\n if (this.cursor) this.cursor.count += taken.count;\n else this.cursor = { item: taken.item, count: taken.count };\n this._render();\n }\n\n _render() {\n const draw = (el: HTMLElement, stack: ItemStack | null) => {\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (stack) {\n img.src = ITEMS[stack.item]!.iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = ITEM_LABELS[stack.item] ?? stack.item;\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (stack.count > 1\n ? stack.count\n : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n }\n };\n for (let i = 0; i < 36; i++)\n draw(this._invSlots[i]!, this.inventory.slots[i]!);\n this._craftSlots.forEach((el, i) => draw(el, this.grid.slots[i]!));\n draw(\n this._resultSlot,\n this.grid.result() ? { ...this.grid.result()! } : null,\n );\n\n // курсорный стак\n if (this.cursor) {\n this.cursorEl.classList.remove(\"hidden\");\n this.cursorEl.querySelector(\"img\")!.src =\n ITEMS[this.cursor.item]!.iconURL!;\n this.cursorEl.querySelector(\".count\")!.textContent = (this.cursor.count >\n 1\n ? this.cursor.count\n : \"\") as unknown as string;\n } else {\n this.cursorEl.classList.add(\"hidden\");\n }\n events.emit(\"invChanged\"); // HUD-хотбар обновится\n }\n}\n"
171
171
  },
172
172
  {
173
173
  "path": "world/Chunk.ts",
174
- "content": "// Чанк: плоские типизированные массивы блоков и меты.\n// Индекс: x | (z<<4) | (y<<8) — горизонтальные срезы непрерывны (мешинг/вода\n// читают их кэш-дружелюбно).\nimport type * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\n\nconst { sx: SX, sy: SY, sz: SZ } = CONFIG.chunk;\nexport const idx = (x: number, y: number, z: number): number =>\n x | (z << 4) | (y << 8);\n\nexport class Chunk {\n cx: number;\n cz: number;\n blocks: Uint8Array;\n meta: Uint8Array;\n modified: Map<number, [number, number]>;\n dirty: boolean;\n waterDirty: boolean;\n generated: boolean;\n solidMesh: THREE.Mesh | null;\n foliageMesh: THREE.Mesh | null;\n waterMesh: THREE.Mesh | null;\n\n constructor(cx: number, cz: number) {\n this.cx = cx;\n this.cz = cz;\n this.blocks = new Uint8Array(SX * SY * SZ);\n this.meta = new Uint8Array(SX * SY * SZ);\n // Диф от генерации: каждый рантайм-setBlock пишется сюда.\n // Это одновременно формат сейва и полного снапшота этапа 2.\n this.modified = new Map(); // index -> [id, meta]\n this.dirty = false; // нужна пересборка твёрдой/растительной геометрии\n this.waterDirty = false; // нужна пересборка только водной геометрии\n this.generated = false;\n // THREE.Mesh-ы (создаются/пересоздаются мешером)\n this.solidMesh = null;\n this.foliageMesh = null;\n this.waterMesh = null;\n }\n\n get(x: number, y: number, z: number): number {\n return this.blocks[idx(x, y, z)];\n }\n getMeta(x: number, y: number, z: number): number {\n return this.meta[idx(x, y, z)];\n }\n\n /** Прямая запись без записи в diff — используется генератором. */\n setRaw(x: number, y: number, z: number, id: number, meta = 0): void {\n const i = idx(x, y, z);\n this.blocks[i] = id;\n this.meta[i] = meta;\n }\n\n /** Рантайм-запись: фиксируется в modified (сейв/снапшот). */\n set(x: number, y: number, z: number, id: number, meta = 0): void {\n const i = idx(x, y, z);\n this.blocks[i] = id;\n this.meta[i] = meta;\n this.modified.set(i, [id, meta]);\n }\n\n /** Применить сейв-диф после генерации. */\n applyDiff(entries: Iterable<[number, [number, number]]>): void {\n for (const [i, [id, meta]] of entries) {\n this.blocks[i] = id;\n this.meta[i] = meta;\n this.modified.set(i, [id, meta]);\n }\n }\n\n serializeDiff(): [number, number, number][] {\n return [...this.modified.entries()].map(\n ([i, [id, meta]]): [number, number, number] => [i, id, meta],\n );\n }\n}\n\nexport const CHUNK_SIZE = { SX, SY, SZ };\n"
174
+ "content": "// Чанк: плоские типизированные массивы блоков и меты.\n// Индекс: x | (z<<4) | (y<<8) — горизонтальные срезы непрерывны (мешинг/вода\n// читают их кэш-дружелюбно).\nimport type * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\n\nconst { sx: SX, sy: SY, sz: SZ } = CONFIG.chunk;\nexport const idx = (x: number, y: number, z: number): number =>\n x | (z << 4) | (y << 8);\n\nexport class Chunk {\n cx: number;\n cz: number;\n blocks: Uint8Array;\n meta: Uint8Array;\n modified: Map<number, [number, number]>;\n dirty: boolean;\n waterDirty: boolean;\n generated: boolean;\n solidMesh: THREE.Mesh | null;\n foliageMesh: THREE.Mesh | null;\n waterMesh: THREE.Mesh | null;\n\n constructor(cx: number, cz: number) {\n this.cx = cx;\n this.cz = cz;\n this.blocks = new Uint8Array(SX * SY * SZ);\n this.meta = new Uint8Array(SX * SY * SZ);\n // Диф от генерации: каждый рантайм-setBlock пишется сюда.\n // Это одновременно формат сейва и полного снапшота этапа 2.\n this.modified = new Map(); // index -> [id, meta]\n this.dirty = false; // нужна пересборка твёрдой/растительной геометрии\n this.waterDirty = false; // нужна пересборка только водной геометрии\n this.generated = false;\n // THREE.Mesh-ы (создаются/пересоздаются мешером)\n this.solidMesh = null;\n this.foliageMesh = null;\n this.waterMesh = null;\n }\n\n get(x: number, y: number, z: number): number {\n return this.blocks[idx(x, y, z)]!;\n }\n getMeta(x: number, y: number, z: number): number {\n return this.meta[idx(x, y, z)]!;\n }\n\n /** Прямая запись без записи в diff — используется генератором. */\n setRaw(x: number, y: number, z: number, id: number, meta = 0): void {\n const i = idx(x, y, z);\n this.blocks[i] = id;\n this.meta[i] = meta;\n }\n\n /** Рантайм-запись: фиксируется в modified (сейв/снапшот). */\n set(x: number, y: number, z: number, id: number, meta = 0): void {\n const i = idx(x, y, z);\n this.blocks[i] = id;\n this.meta[i] = meta;\n this.modified.set(i, [id, meta]);\n }\n\n /** Применить сейв-диф после генерации. */\n applyDiff(entries: Iterable<[number, [number, number]]>): void {\n for (const [i, [id, meta]] of entries) {\n this.blocks[i] = id;\n this.meta[i] = meta;\n this.modified.set(i, [id, meta]);\n }\n }\n\n serializeDiff(): [number, number, number][] {\n return [...this.modified.entries()].map(\n ([i, [id, meta]]): [number, number, number] => [i, id, meta],\n );\n }\n}\n\nexport const CHUNK_SIZE = { SX, SY, SZ };\n"
175
175
  },
176
176
  {
177
177
  "path": "world/ChunkManager.ts",
178
- "content": "// Менеджер чанков: стриминг вокруг игрока, мировые getBlock/setBlock,\n// очередь пересборки мешей с бюджетом на кадр, планировщик onTick-блоков\n// (шов будущей автоматизации: печи/конвейеры регистрируются сами).\nimport type * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { Chunk, CHUNK_SIZE, idx } from \"./Chunk\";\nimport { TerrainGenerator } from \"./TerrainGenerator\";\nimport { ChunkMesher } from \"./ChunkMesher\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkDiffMap } from \"../types\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\nconst key = (cx: number, cz: number): string => cx + \",\" + cz;\n\nexport class ChunkManager {\n scene: THREE.Scene;\n seed: number;\n generator: TerrainGenerator;\n mesher: ChunkMesher;\n chunks: Map<string, Chunk>;\n genQueue: Chunk[];\n pendingDiffs: Map<string, Map<number, [number, number]>>;\n tickingBlocks: Map<string, { x: number; y: number; z: number }>;\n\n constructor(scene: THREE.Scene, atlas: TextureAtlas, seed: number) {\n this.scene = scene;\n this.seed = seed;\n this.generator = new TerrainGenerator(seed);\n this.mesher = new ChunkMesher(atlas, this);\n this.chunks = new Map(); // \"cx,cz\" -> Chunk\n this.genQueue = []; // чанки, ожидающие генерации данных\n this.pendingDiffs = new Map(); // сейв-дифы для ещё не загруженных чанков\n this.tickingBlocks = new Map(); // \"x,y,z\" -> {x,y,z} — блоки с onTick\n }\n\n // ---- доступ к блокам в мировых координатах --------------------------------\n\n chunkAt(wx: number, wz: number): Chunk | undefined {\n return this.chunks.get(key(Math.floor(wx / SX), Math.floor(wz / SZ)));\n }\n\n getBlock(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return B.air;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return B.air;\n return c.blocks[idx(wx & 15, wy, wz & 15)];\n }\n\n /** Как getBlock, но -1 для незагруженного чанка (мешер трактует как «непрозрачно»). */\n getBlockOrUnloaded(wx: number, wy: number, wz: number): number {\n if (wy < 0) return B.bedrock;\n if (wy >= SY) return B.air;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return -1;\n return c.blocks[idx(wx & 15, wy, wz & 15)];\n }\n\n getMeta(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return 0;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return 0;\n return c.meta[idx(wx & 15, wy, wz & 15)];\n }\n\n isSolid(wx: number, wy: number, wz: number): boolean {\n return BLOCKS[this.getBlock(wx, wy, wz)]?.solid ?? false;\n }\n\n /**\n * Единственная точка мутации мира. Пишет диф, дертит чанк и соседей\n * на границе, ведёт реестр onTick-блоков, эмитит blockChanged.\n */\n setBlock(\n wx: number,\n wy: number,\n wz: number,\n id: number,\n meta = 0,\n opts: { waterOnly?: boolean } = {},\n ): boolean {\n if (wy < 0 || wy >= SY) return false;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return false;\n const lx = wx & 15,\n lz = wz & 15;\n const prevId = c.blocks[idx(lx, wy, lz)];\n c.set(lx, wy, lz, id, meta);\n\n // только вода → дешёвая пересборка одного водного меша\n if (\n opts.waterOnly &&\n (id === B.water || prevId === B.water) &&\n !(id !== B.water && prevId !== B.water)\n ) {\n c.waterDirty = true;\n } else {\n c.dirty = true;\n if (lx === 0) this._dirtyNeighbor(wx - 1, wz);\n if (lx === 15) this._dirtyNeighbor(wx + 1, wz);\n if (lz === 0) this._dirtyNeighbor(wx, wz - 1);\n if (lz === 15) this._dirtyNeighbor(wx, wz + 1);\n }\n\n const tickKey = wx + \",\" + wy + \",\" + wz;\n if (BLOCKS[id]?.onTick)\n this.tickingBlocks.set(tickKey, { x: wx, y: wy, z: wz });\n else this.tickingBlocks.delete(tickKey);\n\n events.emit(\"blockChanged\", { x: wx, y: wy, z: wz, id, meta, prevId });\n return true;\n }\n\n _dirtyNeighbor(wx: number, wz: number): void {\n const c = this.chunkAt(wx, wz);\n if (c && c.generated) c.dirty = true;\n }\n\n /** Высота поверхности (первый твёрдый блок сверху) — для спавна. */\n surfaceY(wx: number, wz: number): number {\n for (let y = SY - 1; y >= 0; y--)\n if (BLOCKS[this.getBlock(wx, y, wz)].solid) return y;\n return CONFIG.world.baseHeight;\n }\n\n // ---- стриминг ---------------------------------------------------------------\n\n /** Вызывается каждый кадр: догружает кольцо чанков, выгружает дальние. */\n update(px: number, pz: number): void {\n const pcx = Math.floor(px / SX),\n pcz = Math.floor(pz / SZ);\n const R = CONFIG.renderDistance;\n\n // заказать недостающие чанки (по спирали от игрока — ближние раньше)\n for (let r = 0; r <= R; r++) {\n for (let dx = -r; dx <= r; dx++) {\n for (let dz = -r; dz <= r; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;\n const k = key(pcx + dx, pcz + dz);\n if (!this.chunks.has(k)) {\n const chunk = new Chunk(pcx + dx, pcz + dz);\n this.chunks.set(k, chunk);\n this.genQueue.push(chunk);\n }\n }\n }\n }\n\n // генерация данных с бюджетом\n let gen = 0;\n while (this.genQueue.length && gen < CONFIG.genBudgetPerFrame) {\n const chunk = this.genQueue.shift()!;\n if (!this.chunks.has(key(chunk.cx, chunk.cz))) continue; // успел выгрузиться\n this.generator.generate(chunk);\n const diff = this.pendingDiffs.get(key(chunk.cx, chunk.cz));\n if (diff) {\n chunk.applyDiff(diff);\n this.pendingDiffs.delete(key(chunk.cx, chunk.cz));\n // восстановить onTick-реестр из дифа\n for (const [i, [id]] of chunk.modified)\n if (BLOCKS[id]?.onTick) {\n const x = chunk.cx * SX + (i & 15),\n y = i >> 8,\n z = chunk.cz * SZ + ((i >> 4) & 15);\n this.tickingBlocks.set(x + \",\" + y + \",\" + z, { x, y, z });\n }\n }\n chunk.dirty = true;\n // сосед уже отрисован с «глухой» границей — пере-дертить\n this._dirtyNeighbor((chunk.cx - 1) * SX, chunk.cz * SZ);\n this._dirtyNeighbor((chunk.cx + 1) * SX + 1, chunk.cz * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz - 1) * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz + 1) * SZ + 1);\n gen++;\n }\n\n // выгрузка дальних\n for (const [k, chunk] of this.chunks) {\n if (\n Math.max(Math.abs(chunk.cx - pcx), Math.abs(chunk.cz - pcz)) >\n CONFIG.unloadDistance\n ) {\n this._disposeChunk(chunk);\n this.chunks.delete(k);\n if (chunk.modified.size) this.pendingDiffs.set(k, chunk.modified);\n }\n }\n\n // пересборка мешей: ближние к игроку — приоритетнее\n const dirty: Chunk[] = [];\n for (const chunk of this.chunks.values()) {\n if (!chunk.generated) continue;\n if (chunk.dirty || chunk.waterDirty) dirty.push(chunk);\n }\n dirty.sort(\n (a, b) =>\n Math.abs(a.cx - pcx) +\n Math.abs(a.cz - pcz) -\n (Math.abs(b.cx - pcx) + Math.abs(b.cz - pcz)),\n );\n for (\n let i = 0;\n i < Math.min(dirty.length, CONFIG.meshBudgetPerFrame);\n i++\n ) {\n const chunk = dirty[i];\n this.mesher.rebuild(chunk, this.scene, !chunk.dirty && chunk.waterDirty);\n }\n }\n\n _disposeChunk(chunk: Chunk): void {\n for (const m of [chunk.solidMesh, chunk.foliageMesh, chunk.waterMesh]) {\n if (m) {\n this.scene.remove(m);\n m.geometry.dispose();\n }\n }\n chunk.solidMesh = chunk.foliageMesh = chunk.waterMesh = null;\n }\n\n /** Тик блоков с поведением (в MVP реестр пуст — шов автоматизации). */\n tick(): void {\n for (const { x, y, z } of this.tickingBlocks.values()) {\n const def = BLOCKS[this.getBlock(x, y, z)];\n def?.onTick?.(this, x, y, z, this.getMeta(x, y, z));\n }\n }\n\n /** Полная очистка (регенерация по новому сиду / импорт сейва). */\n clear(): void {\n for (const chunk of this.chunks.values()) this._disposeChunk(chunk);\n this.chunks.clear();\n this.genQueue.length = 0;\n this.pendingDiffs.clear();\n this.tickingBlocks.clear();\n }\n\n /** Сейв: дифы всех чанков (загруженных и отложенных). */\n serializeDiffs(): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n for (const [k, diff] of this.pendingDiffs)\n out[k] = [...diff.entries()].map(\n ([i, [id, meta]]): [number, number, number] => [i, id, meta],\n );\n for (const [k, chunk] of this.chunks)\n if (chunk.modified.size) out[k] = chunk.serializeDiff();\n return out;\n }\n\n /** Импорт сейва: дифы применятся к чанкам по мере генерации. */\n loadDiffs(obj: ChunkDiffMap): void {\n for (const [k, arr] of Object.entries(obj))\n this.pendingDiffs.set(\n k,\n new Map(\n arr.map(([i, id, meta]): [number, [number, number]] => [\n i,\n [id, meta],\n ]),\n ),\n );\n }\n}\n"
178
+ "content": "// Менеджер чанков: стриминг вокруг игрока, мировые getBlock/setBlock,\n// очередь пересборки мешей с бюджетом на кадр, планировщик onTick-блоков\n// (шов будущей автоматизации: печи/конвейеры регистрируются сами).\nimport type * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { Chunk, CHUNK_SIZE, idx } from \"./Chunk\";\nimport { TerrainGenerator } from \"./TerrainGenerator\";\nimport { ChunkMesher } from \"./ChunkMesher\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkDiffMap } from \"../types\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\nconst key = (cx: number, cz: number): string => cx + \",\" + cz;\n\nexport class ChunkManager {\n scene: THREE.Scene;\n seed: number;\n generator: TerrainGenerator;\n mesher: ChunkMesher;\n chunks: Map<string, Chunk>;\n genQueue: Chunk[];\n pendingDiffs: Map<string, Map<number, [number, number]>>;\n tickingBlocks: Map<string, { x: number; y: number; z: number }>;\n\n constructor(scene: THREE.Scene, atlas: TextureAtlas, seed: number) {\n this.scene = scene;\n this.seed = seed;\n this.generator = new TerrainGenerator(seed);\n this.mesher = new ChunkMesher(atlas, this);\n this.chunks = new Map(); // \"cx,cz\" -> Chunk\n this.genQueue = []; // чанки, ожидающие генерации данных\n this.pendingDiffs = new Map(); // сейв-дифы для ещё не загруженных чанков\n this.tickingBlocks = new Map(); // \"x,y,z\" -> {x,y,z} — блоки с onTick\n }\n\n // ---- доступ к блокам в мировых координатах --------------------------------\n\n chunkAt(wx: number, wz: number): Chunk | undefined {\n return this.chunks.get(key(Math.floor(wx / SX), Math.floor(wz / SZ)));\n }\n\n getBlock(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return B.air!;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return B.air!;\n return c.blocks[idx(wx & 15, wy, wz & 15)]!;\n }\n\n /** Как getBlock, но -1 для незагруженного чанка (мешер трактует как «непрозрачно»). */\n getBlockOrUnloaded(wx: number, wy: number, wz: number): number {\n if (wy < 0) return B.bedrock!;\n if (wy >= SY) return B.air!;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return -1;\n return c.blocks[idx(wx & 15, wy, wz & 15)]!;\n }\n\n getMeta(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return 0;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return 0;\n return c.meta[idx(wx & 15, wy, wz & 15)]!;\n }\n\n isSolid(wx: number, wy: number, wz: number): boolean {\n return BLOCKS[this.getBlock(wx, wy, wz)]?.solid ?? false;\n }\n\n /**\n * Единственная точка мутации мира. Пишет диф, дертит чанк и соседей\n * на границе, ведёт реестр onTick-блоков, эмитит blockChanged.\n */\n setBlock(\n wx: number,\n wy: number,\n wz: number,\n id: number,\n meta = 0,\n opts: { waterOnly?: boolean } = {},\n ): boolean {\n if (wy < 0 || wy >= SY) return false;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return false;\n const lx = wx & 15,\n lz = wz & 15;\n const prevId = c.blocks[idx(lx, wy, lz)]!;\n c.set(lx, wy, lz, id, meta);\n\n // только вода → дешёвая пересборка одного водного меша\n if (\n opts.waterOnly &&\n (id === B.water || prevId === B.water) &&\n !(id !== B.water && prevId !== B.water)\n ) {\n c.waterDirty = true;\n } else {\n c.dirty = true;\n if (lx === 0) this._dirtyNeighbor(wx - 1, wz);\n if (lx === 15) this._dirtyNeighbor(wx + 1, wz);\n if (lz === 0) this._dirtyNeighbor(wx, wz - 1);\n if (lz === 15) this._dirtyNeighbor(wx, wz + 1);\n }\n\n const tickKey = wx + \",\" + wy + \",\" + wz;\n if (BLOCKS[id]?.onTick)\n this.tickingBlocks.set(tickKey, { x: wx, y: wy, z: wz });\n else this.tickingBlocks.delete(tickKey);\n\n events.emit(\"blockChanged\", { x: wx, y: wy, z: wz, id, meta, prevId });\n return true;\n }\n\n _dirtyNeighbor(wx: number, wz: number): void {\n const c = this.chunkAt(wx, wz);\n if (c && c.generated) c.dirty = true;\n }\n\n /** Высота поверхности (первый твёрдый блок сверху) — для спавна. */\n surfaceY(wx: number, wz: number): number {\n for (let y = SY - 1; y >= 0; y--)\n if (BLOCKS[this.getBlock(wx, y, wz)]!.solid) return y;\n return CONFIG.world.baseHeight;\n }\n\n // ---- стриминг ---------------------------------------------------------------\n\n /** Вызывается каждый кадр: догружает кольцо чанков, выгружает дальние. */\n update(px: number, pz: number): void {\n const pcx = Math.floor(px / SX),\n pcz = Math.floor(pz / SZ);\n const R = CONFIG.renderDistance;\n\n // заказать недостающие чанки (по спирали от игрока — ближние раньше)\n for (let r = 0; r <= R; r++) {\n for (let dx = -r; dx <= r; dx++) {\n for (let dz = -r; dz <= r; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;\n const k = key(pcx + dx, pcz + dz);\n if (!this.chunks.has(k)) {\n const chunk = new Chunk(pcx + dx, pcz + dz);\n this.chunks.set(k, chunk);\n this.genQueue.push(chunk);\n }\n }\n }\n }\n\n // генерация данных с бюджетом\n let gen = 0;\n while (this.genQueue.length && gen < CONFIG.genBudgetPerFrame) {\n const chunk = this.genQueue.shift()!;\n if (!this.chunks.has(key(chunk.cx, chunk.cz))) continue; // успел выгрузиться\n this.generator.generate(chunk);\n const diff = this.pendingDiffs.get(key(chunk.cx, chunk.cz));\n if (diff) {\n chunk.applyDiff(diff);\n this.pendingDiffs.delete(key(chunk.cx, chunk.cz));\n // восстановить onTick-реестр из дифа\n for (const [i, [id]] of chunk.modified)\n if (BLOCKS[id]?.onTick) {\n const x = chunk.cx * SX + (i & 15),\n y = i >> 8,\n z = chunk.cz * SZ + ((i >> 4) & 15);\n this.tickingBlocks.set(x + \",\" + y + \",\" + z, { x, y, z });\n }\n }\n chunk.dirty = true;\n // сосед уже отрисован с «глухой» границей — пере-дертить\n this._dirtyNeighbor((chunk.cx - 1) * SX, chunk.cz * SZ);\n this._dirtyNeighbor((chunk.cx + 1) * SX + 1, chunk.cz * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz - 1) * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz + 1) * SZ + 1);\n gen++;\n }\n\n // выгрузка дальних\n for (const [k, chunk] of this.chunks) {\n if (\n Math.max(Math.abs(chunk.cx - pcx), Math.abs(chunk.cz - pcz)) >\n CONFIG.unloadDistance\n ) {\n this._disposeChunk(chunk);\n this.chunks.delete(k);\n if (chunk.modified.size) this.pendingDiffs.set(k, chunk.modified);\n }\n }\n\n // пересборка мешей: ближние к игроку — приоритетнее\n const dirty: Chunk[] = [];\n for (const chunk of this.chunks.values()) {\n if (!chunk.generated) continue;\n if (chunk.dirty || chunk.waterDirty) dirty.push(chunk);\n }\n dirty.sort(\n (a, b) =>\n Math.abs(a.cx - pcx) +\n Math.abs(a.cz - pcz) -\n (Math.abs(b.cx - pcx) + Math.abs(b.cz - pcz)),\n );\n for (\n let i = 0;\n i < Math.min(dirty.length, CONFIG.meshBudgetPerFrame);\n i++\n ) {\n const chunk = dirty[i]!;\n this.mesher.rebuild(chunk, this.scene, !chunk.dirty && chunk.waterDirty);\n }\n }\n\n _disposeChunk(chunk: Chunk): void {\n for (const m of [chunk.solidMesh, chunk.foliageMesh, chunk.waterMesh]) {\n if (m) {\n this.scene.remove(m);\n m.geometry.dispose();\n }\n }\n chunk.solidMesh = chunk.foliageMesh = chunk.waterMesh = null;\n }\n\n /** Тик блоков с поведением (в MVP реестр пуст — шов автоматизации). */\n tick(): void {\n for (const { x, y, z } of this.tickingBlocks.values()) {\n const def = BLOCKS[this.getBlock(x, y, z)];\n def?.onTick?.(this, x, y, z, this.getMeta(x, y, z));\n }\n }\n\n /** Полная очистка (регенерация по новому сиду / импорт сейва). */\n clear(): void {\n for (const chunk of this.chunks.values()) this._disposeChunk(chunk);\n this.chunks.clear();\n this.genQueue.length = 0;\n this.pendingDiffs.clear();\n this.tickingBlocks.clear();\n }\n\n /** Сейв: дифы всех чанков (загруженных и отложенных). */\n serializeDiffs(): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n for (const [k, diff] of this.pendingDiffs)\n out[k] = [...diff.entries()].map(\n ([i, [id, meta]]): [number, number, number] => [i, id, meta],\n );\n for (const [k, chunk] of this.chunks)\n if (chunk.modified.size) out[k] = chunk.serializeDiff();\n return out;\n }\n\n /** Импорт сейва: дифы применятся к чанкам по мере генерации. */\n loadDiffs(obj: ChunkDiffMap): void {\n for (const [k, arr] of Object.entries(obj))\n this.pendingDiffs.set(\n k,\n new Map(\n arr.map(([i, id, meta]): [number, [number, number]] => [\n i,\n [id, meta],\n ]),\n ),\n );\n }\n}\n"
179
179
  },
180
180
  {
181
181
  "path": "world/ChunkMesher.ts",
182
- "content": "// Мешер чанка: одна пересборка = три геометрии (твёрдая, растительность-крестовины,\n// вода). Рендерятся только грани, видимые наружу: сосед-непрозрачный блок гасит грань,\n// в том числе через границу чанков (незагруженный сосед считается непрозрачным —\n// фронтир прячет туман, при загрузке соседа чанк пере-дертится).\nimport * as THREE from \"three\";\nimport { BLOCKS, B, waterLevel } from \"../registry/Blocks\";\nimport { CHUNK_SIZE, idx, type Chunk } from \"./Chunk\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"./ChunkManager\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\ninterface UVRect {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n}\n\n// Таблица граней куба: направление, 4 угла (позиция + UV), базовая яркость.\n// Порядок индексов: i, i+1, i+2, i+2, i+1, i+3 (CCW снаружи).\nconst FACES = [\n {\n dir: [-1, 0, 0],\n shade: 0.8,\n corners: [\n [0, 1, 0, 0, 1],\n [0, 0, 0, 0, 0],\n [0, 1, 1, 1, 1],\n [0, 0, 1, 1, 0],\n ],\n },\n {\n dir: [1, 0, 0],\n shade: 0.8,\n corners: [\n [1, 1, 1, 0, 1],\n [1, 0, 1, 0, 0],\n [1, 1, 0, 1, 1],\n [1, 0, 0, 1, 0],\n ],\n },\n {\n dir: [0, -1, 0],\n shade: 0.5,\n corners: [\n [1, 0, 1, 1, 0],\n [0, 0, 1, 0, 0],\n [1, 0, 0, 1, 1],\n [0, 0, 0, 0, 1],\n ],\n },\n {\n dir: [0, 1, 0],\n shade: 1.0,\n corners: [\n [0, 1, 1, 1, 1],\n [1, 1, 1, 0, 1],\n [0, 1, 0, 1, 0],\n [1, 1, 0, 0, 0],\n ],\n },\n {\n dir: [0, 0, -1],\n shade: 0.6,\n corners: [\n [1, 0, 0, 0, 0],\n [0, 0, 0, 1, 0],\n [1, 1, 0, 0, 1],\n [0, 1, 0, 1, 1],\n ],\n },\n {\n dir: [0, 0, 1],\n shade: 0.6,\n corners: [\n [0, 0, 1, 0, 0],\n [1, 0, 1, 1, 0],\n [0, 1, 1, 0, 1],\n [1, 1, 1, 1, 1],\n ],\n },\n];\n\nclass GeoBuilder {\n pos: number[];\n norm: number[];\n uv: number[];\n col: number[];\n index: number[];\n constructor() {\n this.pos = [];\n this.norm = [];\n this.uv = [];\n this.col = [];\n this.index = [];\n }\n get empty(): boolean {\n return this.index.length === 0;\n }\n quad(\n corners: number[][],\n ox: number,\n oy: number,\n oz: number,\n nx: number,\n ny: number,\n nz: number,\n uvRect: UVRect | null,\n shade: number,\n ): void {\n const base = this.pos.length / 3;\n for (const [cx, cy, cz, cu, cv] of corners) {\n this.pos.push(ox + cx, oy + cy, oz + cz);\n this.norm.push(nx, ny, nz);\n this.uv.push(\n uvRect ? uvRect.u0 + (uvRect.u1 - uvRect.u0) * cu : cu,\n uvRect ? uvRect.v0 + (uvRect.v1 - uvRect.v0) * cv : cv,\n );\n this.col.push(shade, shade, shade);\n }\n this.index.push(base, base + 1, base + 2, base + 2, base + 1, base + 3);\n }\n build(): THREE.BufferGeometry {\n const g = new THREE.BufferGeometry();\n g.setAttribute(\"position\", new THREE.Float32BufferAttribute(this.pos, 3));\n g.setAttribute(\"normal\", new THREE.Float32BufferAttribute(this.norm, 3));\n g.setAttribute(\"uv\", new THREE.Float32BufferAttribute(this.uv, 2));\n g.setAttribute(\"color\", new THREE.Float32BufferAttribute(this.col, 3));\n g.setIndex(this.index);\n return g;\n }\n}\n\nexport class ChunkMesher {\n atlas: TextureAtlas;\n world: ChunkManager;\n _uvCache: Map<string, UVRect>;\n\n /** @param {import('../gfx/TextureAtlas').TextureAtlas} atlas */\n constructor(atlas: TextureAtlas, world: ChunkManager) {\n this.atlas = atlas;\n this.world = world; // ChunkManager: кросс-чанковые чтения на границах\n this._uvCache = new Map(); // tileName -> uvRect\n }\n\n _uv(tileName: string): UVRect {\n let r = this._uvCache.get(tileName);\n if (!r) {\n r = this.atlas.uv(tileName);\n this._uvCache.set(tileName, r);\n }\n return r;\n }\n\n /** Пересобирает и подменяет меши чанка. Старые геометрии всегда dispose(). */\n rebuild(chunk: Chunk, scene: THREE.Scene, waterOnly = false): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const solid = waterOnly ? null : new GeoBuilder();\n const foliage = waterOnly ? null : new GeoBuilder();\n const water = new GeoBuilder();\n\n // Чтение блока с фолбэком в мир на границах. -1 = чанк не загружен.\n const blockAt = (x: number, y: number, z: number): number => {\n if (y < 0) return B.bedrock; // ниже мира — «непрозрачно»\n if (y >= SY) return B.air;\n if (x >= 0 && x < SX && z >= 0 && z < SZ)\n return chunk.blocks[idx(x, y, z)];\n return this.world.getBlockOrUnloaded(wx0 + x, y, wz0 + z);\n };\n const metaAt = (x: number, y: number, z: number): number => {\n if (x >= 0 && x < SX && z >= 0 && z < SZ && y >= 0 && y < SY)\n return chunk.meta[idx(x, y, z)];\n return this.world.getMeta(wx0 + x, y, wz0 + z);\n };\n const opaqueAt = (x: number, y: number, z: number): boolean => {\n const id = blockAt(x, y, z);\n return id === -1 || BLOCKS[id]?.opaque;\n };\n\n for (let y = 0; y < SY; y++) {\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const id = chunk.blocks[idx(x, y, z)];\n if (id === B.air) continue;\n const def = BLOCKS[id];\n\n if (def.liquid) {\n this._waterCell(water, chunk, x, y, z, wx0, wz0, blockAt, metaAt);\n continue;\n }\n if (waterOnly) continue;\n\n if (def.cross) {\n // растение: два скрещённых квада по диагоналям\n const uv = this._uv(def.tex!.side!);\n const b = foliage!;\n const q = (c: number[][]): void =>\n b.quad(c, x, y, z, 0, 1, 0, uv, 1.0);\n q([\n [0.15, 1, 0.15, 0, 1],\n [0.15, 0, 0.15, 0, 0],\n [0.85, 1, 0.85, 1, 1],\n [0.85, 0, 0.85, 1, 0],\n ]);\n q([\n [0.85, 1, 0.15, 0, 1],\n [0.85, 0, 0.15, 0, 0],\n [0.15, 1, 0.85, 1, 1],\n [0.15, 0, 0.85, 1, 0],\n ]);\n continue;\n }\n\n const target = def.opaque ? solid! : foliage!; // листва — в cutout-проход\n for (const f of FACES) {\n const nx = x + f.dir[0],\n ny = y + f.dir[1],\n nz = z + f.dir[2];\n if (opaqueAt(nx, ny, nz)) continue;\n if (!def.opaque && blockAt(nx, ny, nz) === id) continue; // листва к листве\n const tile =\n f.dir[1] > 0\n ? def.tex!.top!\n : f.dir[1] < 0\n ? def.tex!.bottom!\n : def.tex!.side!;\n target.quad(\n f.corners,\n x,\n y,\n z,\n f.dir[0],\n f.dir[1],\n f.dir[2],\n this._uv(tile),\n f.shade,\n );\n }\n }\n }\n }\n\n if (!waterOnly) {\n chunk.solidMesh = this._swap(\n chunk.solidMesh,\n solid!,\n this.atlas.opaqueMat,\n scene,\n wx0,\n wz0,\n 0,\n );\n chunk.foliageMesh = this._swap(\n chunk.foliageMesh,\n foliage!,\n this.atlas.foliageMat,\n scene,\n wx0,\n wz0,\n 0,\n );\n chunk.dirty = false;\n }\n chunk.waterMesh = this._swap(\n chunk.waterMesh,\n water,\n this.atlas.waterMat,\n scene,\n wx0,\n wz0,\n 1,\n );\n chunk.waterDirty = false;\n }\n\n _waterCell(\n b: GeoBuilder,\n chunk: Chunk,\n x: number,\n y: number,\n z: number,\n wx0: number,\n wz0: number,\n blockAt: (x: number, y: number, z: number) => number,\n _metaAt: (x: number, y: number, z: number) => number,\n ): void {\n const isWater = (id: number): boolean => id === B.water;\n const level = waterLevel(chunk.meta[idx(x, y, z)]);\n const above = blockAt(x, y + 1, z);\n // высота столба: полный, если сверху тоже вода\n const h = isWater(above) ? 1 : 0.125 + (level / 8) * 0.75;\n const wx = wx0 + x,\n wz = wz0 + z; // мировые UV: repeat-текстура тайлится сама\n\n // верх: виден, если сверху не вода\n if (!isWater(above)) {\n b.quad(\n [\n [0, h, 1, 0, 1],\n [1, h, 1, 1, 1],\n [0, h, 0, 0, 0],\n [1, h, 0, 1, 0],\n ].map(([cx, cy, cz]) => [cx, cy, cz, wx + cx, wz + cz]),\n x,\n y,\n z,\n 0,\n 1,\n 0,\n null,\n 1.0,\n );\n }\n // низ\n if (\n blockAt(x, y - 1, z) !== -1 &&\n !BLOCKS[blockAt(x, y - 1, z)]?.opaque &&\n !isWater(blockAt(x, y - 1, z))\n ) {\n b.quad(\n [\n [1, 0, 1, wx + 1, wz + 1],\n [0, 0, 1, wx, wz + 1],\n [1, 0, 0, wx + 1, wz],\n [0, 0, 0, wx, wz],\n ],\n x,\n y,\n z,\n 0,\n -1,\n 0,\n null,\n 0.6,\n );\n }\n // бока: против воздуха/прозрачного, но не против воды и не против непрозрачного\n const sides = [\n {\n d: [-1, 0, 0],\n c: [\n [0, 1, 0],\n [0, 0, 0],\n [0, 1, 1],\n [0, 0, 1],\n ],\n },\n {\n d: [1, 0, 0],\n c: [\n [1, 1, 1],\n [1, 0, 1],\n [1, 1, 0],\n [1, 0, 0],\n ],\n },\n {\n d: [0, 0, -1],\n c: [\n [1, 1, 0],\n [1, 0, 0],\n [0, 1, 0],\n [0, 0, 0],\n ],\n },\n {\n d: [0, 0, 1],\n c: [\n [0, 1, 1],\n [0, 0, 1],\n [1, 1, 1],\n [1, 0, 1],\n ],\n },\n ];\n for (const s of sides) {\n const nid = blockAt(x + s.d[0], y, z + s.d[2]);\n if (nid === -1 || isWater(nid) || BLOCKS[nid]?.opaque) continue;\n const corners = s.c.map(([cx, cy, cz]) => {\n const uAxis = s.d[0] !== 0 ? wz + cz : wx + cx; // горизонтальная UV-ось\n return [cx, cy === 1 ? h : 0, cz, uAxis, y + (cy === 1 ? h : 0)];\n });\n b.quad(corners, x, y, z, s.d[0], 0, s.d[2], null, 0.8);\n }\n }\n\n /** Подмена меша: dispose старой геометрии, пустые геометрии не создают меш. */\n _swap(\n oldMesh: THREE.Mesh | null,\n builder: GeoBuilder,\n material: THREE.Material,\n scene: THREE.Scene,\n wx0: number,\n wz0: number,\n renderOrder: number,\n ): THREE.Mesh | null {\n if (oldMesh) {\n scene.remove(oldMesh);\n oldMesh.geometry.dispose();\n }\n if (builder.empty) return null;\n const mesh = new THREE.Mesh(builder.build(), material);\n mesh.position.set(wx0, 0, wz0);\n mesh.renderOrder = renderOrder;\n mesh.matrixAutoUpdate = false;\n mesh.updateMatrix();\n scene.add(mesh);\n return mesh;\n }\n}\n"
182
+ "content": "// Мешер чанка: одна пересборка = три геометрии (твёрдая, растительность-крестовины,\n// вода). Рендерятся только грани, видимые наружу: сосед-непрозрачный блок гасит грань,\n// в том числе через границу чанков (незагруженный сосед считается непрозрачным —\n// фронтир прячет туман, при загрузке соседа чанк пере-дертится).\nimport * as THREE from \"three\";\nimport { BLOCKS, B, waterLevel } from \"../registry/Blocks\";\nimport { CHUNK_SIZE, idx, type Chunk } from \"./Chunk\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"./ChunkManager\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\ninterface UVRect {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n}\n\n// Таблица граней куба: направление, 4 угла (позиция + UV), базовая яркость.\n// Порядок индексов: i, i+1, i+2, i+2, i+1, i+3 (CCW снаружи).\nconst FACES = [\n {\n dir: [-1, 0, 0],\n shade: 0.8,\n corners: [\n [0, 1, 0, 0, 1],\n [0, 0, 0, 0, 0],\n [0, 1, 1, 1, 1],\n [0, 0, 1, 1, 0],\n ],\n },\n {\n dir: [1, 0, 0],\n shade: 0.8,\n corners: [\n [1, 1, 1, 0, 1],\n [1, 0, 1, 0, 0],\n [1, 1, 0, 1, 1],\n [1, 0, 0, 1, 0],\n ],\n },\n {\n dir: [0, -1, 0],\n shade: 0.5,\n corners: [\n [1, 0, 1, 1, 0],\n [0, 0, 1, 0, 0],\n [1, 0, 0, 1, 1],\n [0, 0, 0, 0, 1],\n ],\n },\n {\n dir: [0, 1, 0],\n shade: 1.0,\n corners: [\n [0, 1, 1, 1, 1],\n [1, 1, 1, 0, 1],\n [0, 1, 0, 1, 0],\n [1, 1, 0, 0, 0],\n ],\n },\n {\n dir: [0, 0, -1],\n shade: 0.6,\n corners: [\n [1, 0, 0, 0, 0],\n [0, 0, 0, 1, 0],\n [1, 1, 0, 0, 1],\n [0, 1, 0, 1, 1],\n ],\n },\n {\n dir: [0, 0, 1],\n shade: 0.6,\n corners: [\n [0, 0, 1, 0, 0],\n [1, 0, 1, 1, 0],\n [0, 1, 1, 0, 1],\n [1, 1, 1, 1, 1],\n ],\n },\n];\n\nclass GeoBuilder {\n pos: number[];\n norm: number[];\n uv: number[];\n col: number[];\n index: number[];\n constructor() {\n this.pos = [];\n this.norm = [];\n this.uv = [];\n this.col = [];\n this.index = [];\n }\n get empty(): boolean {\n return this.index.length === 0;\n }\n quad(\n corners: number[][],\n ox: number,\n oy: number,\n oz: number,\n nx: number,\n ny: number,\n nz: number,\n uvRect: UVRect | null,\n shade: number,\n ): void {\n const base = this.pos.length / 3;\n for (const [cx, cy, cz, cu, cv] of corners) {\n this.pos.push(ox + cx!, oy + cy!, oz + cz!);\n this.norm.push(nx, ny, nz);\n this.uv.push(\n uvRect ? uvRect.u0 + (uvRect.u1 - uvRect.u0) * cu! : cu!,\n uvRect ? uvRect.v0 + (uvRect.v1 - uvRect.v0) * cv! : cv!,\n );\n this.col.push(shade, shade, shade);\n }\n this.index.push(base, base + 1, base + 2, base + 2, base + 1, base + 3);\n }\n build(): THREE.BufferGeometry {\n const g = new THREE.BufferGeometry();\n g.setAttribute(\"position\", new THREE.Float32BufferAttribute(this.pos, 3));\n g.setAttribute(\"normal\", new THREE.Float32BufferAttribute(this.norm, 3));\n g.setAttribute(\"uv\", new THREE.Float32BufferAttribute(this.uv, 2));\n g.setAttribute(\"color\", new THREE.Float32BufferAttribute(this.col, 3));\n g.setIndex(this.index);\n return g;\n }\n}\n\nexport class ChunkMesher {\n atlas: TextureAtlas;\n world: ChunkManager;\n _uvCache: Map<string, UVRect>;\n\n /** @param {import('../gfx/TextureAtlas').TextureAtlas} atlas */\n constructor(atlas: TextureAtlas, world: ChunkManager) {\n this.atlas = atlas;\n this.world = world; // ChunkManager: кросс-чанковые чтения на границах\n this._uvCache = new Map(); // tileName -> uvRect\n }\n\n _uv(tileName: string): UVRect {\n let r = this._uvCache.get(tileName);\n if (!r) {\n r = this.atlas.uv(tileName);\n this._uvCache.set(tileName, r);\n }\n return r;\n }\n\n /** Пересобирает и подменяет меши чанка. Старые геометрии всегда dispose(). */\n rebuild(chunk: Chunk, scene: THREE.Scene, waterOnly = false): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const solid = waterOnly ? null : new GeoBuilder();\n const foliage = waterOnly ? null : new GeoBuilder();\n const water = new GeoBuilder();\n\n // Чтение блока с фолбэком в мир на границах. -1 = чанк не загружен.\n const blockAt = (x: number, y: number, z: number): number => {\n if (y < 0) return B.bedrock!; // ниже мира — «непрозрачно»\n if (y >= SY) return B.air!;\n if (x >= 0 && x < SX && z >= 0 && z < SZ)\n return chunk.blocks[idx(x, y, z)]!;\n return this.world.getBlockOrUnloaded(wx0 + x, y, wz0 + z);\n };\n const metaAt = (x: number, y: number, z: number): number => {\n if (x >= 0 && x < SX && z >= 0 && z < SZ && y >= 0 && y < SY)\n return chunk.meta[idx(x, y, z)]!;\n return this.world.getMeta(wx0 + x, y, wz0 + z);\n };\n const opaqueAt = (x: number, y: number, z: number): boolean => {\n const id = blockAt(x, y, z);\n return id === -1 || (BLOCKS[id]?.opaque ?? false);\n };\n\n for (let y = 0; y < SY; y++) {\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const id = chunk.blocks[idx(x, y, z)]!;\n if (id === B.air) continue;\n const def = BLOCKS[id]!;\n\n if (def.liquid) {\n this._waterCell(water, chunk, x, y, z, wx0, wz0, blockAt, metaAt);\n continue;\n }\n if (waterOnly) continue;\n\n if (def.cross) {\n // растение: два скрещённых квада по диагоналям\n const uv = this._uv(def.tex!.side!);\n const b = foliage!;\n const q = (c: number[][]): void =>\n b.quad(c, x, y, z, 0, 1, 0, uv, 1.0);\n q([\n [0.15, 1, 0.15, 0, 1],\n [0.15, 0, 0.15, 0, 0],\n [0.85, 1, 0.85, 1, 1],\n [0.85, 0, 0.85, 1, 0],\n ]);\n q([\n [0.85, 1, 0.15, 0, 1],\n [0.85, 0, 0.15, 0, 0],\n [0.15, 1, 0.85, 1, 1],\n [0.15, 0, 0.85, 1, 0],\n ]);\n continue;\n }\n\n const target = def.opaque ? solid! : foliage!; // листва — в cutout-проход\n for (const f of FACES) {\n const nx = x + f.dir[0]!,\n ny = y + f.dir[1]!,\n nz = z + f.dir[2]!;\n if (opaqueAt(nx, ny, nz)) continue;\n if (!def.opaque && blockAt(nx, ny, nz) === id) continue; // листва к листве\n const tile =\n f.dir[1]! > 0\n ? def.tex!.top!\n : f.dir[1]! < 0\n ? def.tex!.bottom!\n : def.tex!.side!;\n target.quad(\n f.corners,\n x,\n y,\n z,\n f.dir[0]!,\n f.dir[1]!,\n f.dir[2]!,\n this._uv(tile),\n f.shade,\n );\n }\n }\n }\n }\n\n if (!waterOnly) {\n chunk.solidMesh = this._swap(\n chunk.solidMesh,\n solid!,\n this.atlas.opaqueMat,\n scene,\n wx0,\n wz0,\n 0,\n );\n chunk.foliageMesh = this._swap(\n chunk.foliageMesh,\n foliage!,\n this.atlas.foliageMat,\n scene,\n wx0,\n wz0,\n 0,\n );\n chunk.dirty = false;\n }\n chunk.waterMesh = this._swap(\n chunk.waterMesh,\n water,\n this.atlas.waterMat,\n scene,\n wx0,\n wz0,\n 1,\n );\n chunk.waterDirty = false;\n }\n\n _waterCell(\n b: GeoBuilder,\n chunk: Chunk,\n x: number,\n y: number,\n z: number,\n wx0: number,\n wz0: number,\n blockAt: (x: number, y: number, z: number) => number,\n _metaAt: (x: number, y: number, z: number) => number,\n ): void {\n const isWater = (id: number): boolean => id === B.water;\n const level = waterLevel(chunk.meta[idx(x, y, z)]!);\n const above = blockAt(x, y + 1, z);\n // высота столба: полный, если сверху тоже вода\n const h = isWater(above) ? 1 : 0.125 + (level / 8) * 0.75;\n const wx = wx0 + x,\n wz = wz0 + z; // мировые UV: repeat-текстура тайлится сама\n\n // верх: виден, если сверху не вода\n if (!isWater(above)) {\n b.quad(\n [\n [0, h, 1, 0, 1],\n [1, h, 1, 1, 1],\n [0, h, 0, 0, 0],\n [1, h, 0, 1, 0],\n ].map(([cx, cy, cz]) => [cx!, cy!, cz!, wx + cx!, wz + cz!]),\n x,\n y,\n z,\n 0,\n 1,\n 0,\n null,\n 1.0,\n );\n }\n // низ\n if (\n blockAt(x, y - 1, z) !== -1 &&\n !BLOCKS[blockAt(x, y - 1, z)]?.opaque &&\n !isWater(blockAt(x, y - 1, z))\n ) {\n b.quad(\n [\n [1, 0, 1, wx + 1, wz + 1],\n [0, 0, 1, wx, wz + 1],\n [1, 0, 0, wx + 1, wz],\n [0, 0, 0, wx, wz],\n ],\n x,\n y,\n z,\n 0,\n -1,\n 0,\n null,\n 0.6,\n );\n }\n // бока: против воздуха/прозрачного, но не против воды и не против непрозрачного\n const sides = [\n {\n d: [-1, 0, 0],\n c: [\n [0, 1, 0],\n [0, 0, 0],\n [0, 1, 1],\n [0, 0, 1],\n ],\n },\n {\n d: [1, 0, 0],\n c: [\n [1, 1, 1],\n [1, 0, 1],\n [1, 1, 0],\n [1, 0, 0],\n ],\n },\n {\n d: [0, 0, -1],\n c: [\n [1, 1, 0],\n [1, 0, 0],\n [0, 1, 0],\n [0, 0, 0],\n ],\n },\n {\n d: [0, 0, 1],\n c: [\n [0, 1, 1],\n [0, 0, 1],\n [1, 1, 1],\n [1, 0, 1],\n ],\n },\n ];\n for (const s of sides) {\n const nid = blockAt(x + s.d[0]!, y, z + s.d[2]!);\n if (nid === -1 || isWater(nid) || BLOCKS[nid]?.opaque) continue;\n const corners = s.c.map(([cx, cy, cz]) => {\n const uAxis = s.d[0] !== 0 ? wz + cz! : wx + cx!; // горизонтальная UV-ось\n return [cx!, cy === 1 ? h : 0, cz!, uAxis, y + (cy === 1 ? h : 0)];\n });\n b.quad(corners, x, y, z, s.d[0]!, 0, s.d[2]!, null, 0.8);\n }\n }\n\n /** Подмена меша: dispose старой геометрии, пустые геометрии не создают меш. */\n _swap(\n oldMesh: THREE.Mesh | null,\n builder: GeoBuilder,\n material: THREE.Material,\n scene: THREE.Scene,\n wx0: number,\n wz0: number,\n renderOrder: number,\n ): THREE.Mesh | null {\n if (oldMesh) {\n scene.remove(oldMesh);\n oldMesh.geometry.dispose();\n }\n if (builder.empty) return null;\n const mesh = new THREE.Mesh(builder.build(), material);\n mesh.position.set(wx0, 0, wz0);\n mesh.renderOrder = renderOrder;\n mesh.matrixAutoUpdate = false;\n mesh.updateMatrix();\n scene.add(mesh);\n return mesh;\n }\n}\n"
183
183
  },
184
184
  {
185
185
  "path": "world/Raycast.ts",
@@ -187,7 +187,7 @@
187
187
  },
188
188
  {
189
189
  "path": "world/TerrainGenerator.ts",
190
- "content": "// Генератор террейна: высотная карта на симплекс-fbm, слои пород, руды,\n// озёра, деревья и растения. Полностью детерминирован сидом:\n// один и тот же сид → бит-в-бит одинаковый мир (важно для сейва и этапа 2,\n// где клиент генерирует мир по сиду и накатывает диф).\nimport { CONFIG } from \"../config\";\nimport { Simplex2, mulberry32, hashString } from \"../core/Noise\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { CHUNK_SIZE, type Chunk } from \"./Chunk\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\nexport class TerrainGenerator {\n seed: number;\n height: Simplex2;\n detail: Simplex2;\n\n constructor(seed: number) {\n this.seed = seed;\n this.height = new Simplex2(hashString(\"height:\" + seed));\n this.detail = new Simplex2(hashString(\"detail:\" + seed));\n }\n\n /** Высота поверхности в мировых координатах. */\n surfaceHeight(wx: number, wz: number): number {\n const w = CONFIG.world;\n const h =\n this.height.fbm(wx * 0.008, wz * 0.008, 4) * w.hillAmp +\n this.detail.noise(wx * 0.05, wz * 0.05) * 2;\n return Math.max(2, Math.min(SY - 8, Math.round(w.baseHeight + h)));\n }\n\n /** Заполняет данные чанка. Деревья сажаются с отступом от края (крона радиуса 2\n * не пересекает границу чанка — генерация чанков остаётся независимой). */\n generate(chunk: Chunk): void {\n const water = CONFIG.world.waterLevel;\n const rng = mulberry32(\n hashString(`chunk:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const heights = new Int16Array(SX * SZ);\n\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const wx = chunk.cx * SX + x,\n wz = chunk.cz * SZ + z;\n const h = this.surfaceHeight(wx, wz);\n heights[x + z * SX] = h;\n const beach = h <= water + 1; // у воды — песок вместо дёрна\n\n for (let y = 0; y <= h; y++) {\n let id: number;\n if (y === 0) id = B.bedrock;\n else if (y === 1 && rng() < 0.5)\n id = B.bedrock; // рваный второй слой\n else if (y >= h - (beach ? 2 : 0) && beach) id = B.sand;\n else if (y === h) id = B.grass;\n else if (y >= h - 3) id = B.dirt;\n else id = B.stone;\n chunk.setRaw(x, y, z, id);\n }\n // озёра: всё, что ниже уровня моря — source-вода\n for (let y = h + 1; y <= water; y++)\n chunk.setRaw(x, y, z, B.water, packWater(8, true));\n }\n }\n\n this._ores(chunk, rng);\n this._trees(chunk, rng, heights, water);\n this._plants(chunk, rng, heights, water);\n chunk.generated = true;\n }\n\n _ores(chunk: Chunk, rng: () => number): void {\n // жилы: случайный «пьяный» шаг из точки, 4-8 блоков\n const vein = (id: number, count: number, maxY: number): void => {\n for (let v = 0; v < count; v++) {\n let x = (rng() * SX) | 0,\n y = (3 + rng() * (maxY - 3)) | 0,\n z = (rng() * SZ) | 0;\n const len = (4 + rng() * 5) | 0;\n for (let i = 0; i < len; i++) {\n if (\n x >= 0 &&\n x < SX &&\n y >= 2 &&\n y < SY &&\n z >= 0 &&\n z < SZ &&\n chunk.get(x, y, z) === B.stone\n )\n chunk.setRaw(x, y, z, id);\n x += ((rng() * 3) | 0) - 1;\n y += ((rng() * 3) | 0) - 1;\n z += ((rng() * 3) | 0) - 1;\n }\n }\n };\n vein(B.coal_ore, 7, 40);\n vein(B.iron_ore, 5, 28);\n }\n\n _trees(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n _water: number,\n ): void {\n const attempts = 3;\n for (let t = 0; t < attempts; t++) {\n // отступ 2 от края — крона не выходит за чанк\n const x = 2 + ((rng() * (SX - 4)) | 0),\n z = 2 + ((rng() * (SZ - 4)) | 0);\n const h = heights[x + z * SX];\n if (rng() > 0.6) continue;\n if (chunk.get(x, h, z) !== B.grass || h + 8 >= SY) continue;\n const trunk = 4 + ((rng() * 2) | 0);\n for (let y = 1; y <= trunk; y++) chunk.setRaw(x, h + y, z, B.oak_log);\n // крона: два слоя 5×5 + два слоя 3×3 сверху, углы прореживаются\n for (let dy = trunk - 1; dy <= trunk + 2; dy++) {\n const r = dy >= trunk + 1 ? 1 : 2;\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (dx === 0 && dz === 0 && dy <= trunk) continue;\n if (Math.abs(dx) === r && Math.abs(dz) === r && rng() < 0.5)\n continue;\n const y = h + dy + 1;\n if (y < SY && chunk.get(x + dx, y, z + dz) === B.air)\n chunk.setRaw(x + dx, y, z + dz, B.leaves);\n }\n }\n }\n }\n\n _plants(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n water: number,\n ): void {\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const h = heights[x + z * SX];\n if (h <= water || h + 1 >= SY) continue;\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const r = rng();\n if (r < 0.08) chunk.setRaw(x, h + 1, z, B.tallgrass);\n else if (r < 0.095)\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.flower_yellow : B.flower_red,\n );\n }\n }\n}\n"
190
+ "content": "// Генератор террейна: высотная карта на симплекс-fbm, слои пород, руды,\n// озёра, деревья и растения. Полностью детерминирован сидом:\n// один и тот же сид → бит-в-бит одинаковый мир (важно для сейва и этапа 2,\n// где клиент генерирует мир по сиду и накатывает диф).\nimport { CONFIG } from \"../config\";\nimport { Simplex2, mulberry32, hashString } from \"../core/Noise\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { CHUNK_SIZE, type Chunk } from \"./Chunk\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\nexport class TerrainGenerator {\n seed: number;\n height: Simplex2;\n detail: Simplex2;\n\n constructor(seed: number) {\n this.seed = seed;\n this.height = new Simplex2(hashString(\"height:\" + seed));\n this.detail = new Simplex2(hashString(\"detail:\" + seed));\n }\n\n /** Высота поверхности в мировых координатах. */\n surfaceHeight(wx: number, wz: number): number {\n const w = CONFIG.world;\n const h =\n this.height.fbm(wx * 0.008, wz * 0.008, 4) * w.hillAmp +\n this.detail.noise(wx * 0.05, wz * 0.05) * 2;\n return Math.max(2, Math.min(SY - 8, Math.round(w.baseHeight + h)));\n }\n\n /** Заполняет данные чанка. Деревья сажаются с отступом от края (крона радиуса 2\n * не пересекает границу чанка — генерация чанков остаётся независимой). */\n generate(chunk: Chunk): void {\n const water = CONFIG.world.waterLevel;\n const rng = mulberry32(\n hashString(`chunk:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const heights = new Int16Array(SX * SZ);\n\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const wx = chunk.cx * SX + x,\n wz = chunk.cz * SZ + z;\n const h = this.surfaceHeight(wx, wz);\n heights[x + z * SX] = h;\n const beach = h <= water + 1; // у воды — песок вместо дёрна\n\n for (let y = 0; y <= h; y++) {\n let id: number;\n if (y === 0) id = B.bedrock!;\n else if (y === 1 && rng() < 0.5)\n id = B.bedrock!; // рваный второй слой\n else if (y >= h - (beach ? 2 : 0) && beach) id = B.sand!;\n else if (y === h) id = B.grass!;\n else if (y >= h - 3) id = B.dirt!;\n else id = B.stone!;\n chunk.setRaw(x, y, z, id);\n }\n // озёра: всё, что ниже уровня моря — source-вода\n for (let y = h + 1; y <= water; y++)\n chunk.setRaw(x, y, z, B.water!, packWater(8, true));\n }\n }\n\n this._ores(chunk, rng);\n this._trees(chunk, rng, heights, water);\n this._plants(chunk, rng, heights, water);\n chunk.generated = true;\n }\n\n _ores(chunk: Chunk, rng: () => number): void {\n // жилы: случайный «пьяный» шаг из точки, 4-8 блоков\n const vein = (id: number, count: number, maxY: number): void => {\n for (let v = 0; v < count; v++) {\n let x = (rng() * SX) | 0,\n y = (3 + rng() * (maxY - 3)) | 0,\n z = (rng() * SZ) | 0;\n const len = (4 + rng() * 5) | 0;\n for (let i = 0; i < len; i++) {\n if (\n x >= 0 &&\n x < SX &&\n y >= 2 &&\n y < SY &&\n z >= 0 &&\n z < SZ &&\n chunk.get(x, y, z) === B.stone\n )\n chunk.setRaw(x, y, z, id);\n x += ((rng() * 3) | 0) - 1;\n y += ((rng() * 3) | 0) - 1;\n z += ((rng() * 3) | 0) - 1;\n }\n }\n };\n vein(B.coal_ore!, 7, 40);\n vein(B.iron_ore!, 5, 28);\n }\n\n _trees(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n _water: number,\n ): void {\n const attempts = 3;\n for (let t = 0; t < attempts; t++) {\n // отступ 2 от края — крона не выходит за чанк\n const x = 2 + ((rng() * (SX - 4)) | 0),\n z = 2 + ((rng() * (SZ - 4)) | 0);\n const h = heights[x + z * SX]!;\n if (rng() > 0.6) continue;\n if (chunk.get(x, h, z) !== B.grass || h + 8 >= SY) continue;\n const trunk = 4 + ((rng() * 2) | 0);\n for (let y = 1; y <= trunk; y++) chunk.setRaw(x, h + y, z, B.oak_log!);\n // крона: два слоя 5×5 + два слоя 3×3 сверху, углы прореживаются\n for (let dy = trunk - 1; dy <= trunk + 2; dy++) {\n const r = dy >= trunk + 1 ? 1 : 2;\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (dx === 0 && dz === 0 && dy <= trunk) continue;\n if (Math.abs(dx) === r && Math.abs(dz) === r && rng() < 0.5)\n continue;\n const y = h + dy + 1;\n if (y < SY && chunk.get(x + dx, y, z + dz) === B.air)\n chunk.setRaw(x + dx, y, z + dz, B.leaves!);\n }\n }\n }\n }\n\n _plants(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n water: number,\n ): void {\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const h = heights[x + z * SX]!;\n if (h <= water || h + 1 >= SY) continue;\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const r = rng();\n if (r < 0.08) chunk.setRaw(x, h + 1, z, B.tallgrass!);\n else if (r < 0.095)\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.flower_yellow! : B.flower_red!,\n );\n }\n }\n}\n"
191
191
  }
192
192
  ]
193
193
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idosgames-getting-started",
3
3
  "description": "Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp tools get_host_scaffold / get_module / get_manifest.",
4
- "content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ]\nsrc/modules/{id}/ # each module's source (from get_module)\n```\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n3. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`).\n4. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n5. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them; a shared HUD panel (`activeOnly:false`) stays visible across all modes.\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at `POST {backend}/v2/mcp`,\n authenticated with an `X-MCP-API-Key` header; every tool call takes a `title_id` argument.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
4
+ "content": "---\nname: idosgames-getting-started\ndescription: >-\n Start a new game or app on the iDosGames composable-module architecture: scaffold the host shell\n and plug in feature modules (board-game, idle-rpg, voxelcraft, …). Use this whenever\n a developer wants to CREATE an iDosGames project from scratch, add an iDosGames game module to a\n project, or asks how @idosgames/app-shell, @idosgames/module-sdk, the host shell, mountHost, or\n src/modules.ts fit together. Pairs with idosgames-module-contract (writing a module) and\n idosgames-compose-modules (merging several). If pulling modules over MCP, use the @idosgames/mcp\n tools get_host_scaffold / get_module / get_manifest.\n---\n\n# Getting started (iDosGames composable modules)\n\nAn iDosGames project is **one host shell + N feature modules**. The host owns everything that exists\nonce — the SDK client, login, the React root, the screen, a module registry, and a Mode Router that\nswitches between modules. A module is a library (a game or app) that plugs into the host; it never\ncreates the client, logs in, or owns the root.\n\n## Runtime packages (npm)\n\n- `@idosgames/core` — the SDK client (`createIDosGamesClient`): auth, currency, store, characters,\n blockchain, ~28 services. See the per-service skills (currency-system, store-system, …).\n- `@idosgames/module-sdk` — the module contract types (`Module`, `ModuleContext`, `EngineScene`,\n `UiPanel`). See idosgames-module-contract.\n- `@idosgames/react` — shared React glue (`IDosGamesProvider`, `useIDosGamesClient`, `useUserState`,\n `StatusProvider`, `createControllerContext`).\n- `@idosgames/app-shell` — the host runtime (`mountHost`, the Mode Router, the module registry).\n- `@idosgames/wallet` — optional wallet bridge (EVM/Solana) for on-chain deposits/withdrawals.\n\nInstall the exact versions from `get_manifest` (MCP) or the registry `index.json` `runtimePackages`.\n\n## Project shape\n\n```\nindex.html # mounts #app\nsrc/main.tsx # creates ONE client and calls mountHost({container, client, modules})\nsrc/modules.ts # the registry: export const modules: Module[] = [ … ]\nsrc/modules/{id}/ # each module's source (from get_module)\n```\n\n`src/main.tsx` (host-owned) is the only place that creates the client — but it does **not** sign in.\n`mountHost` owns sign-in: it replays a previous session (`autoLogin`) and renders the login screen\nwhen there is nothing to replay. Calling a `login*` method here skips that screen for good, taking\n\"switch account\" and wallet sign-in with it:\n\n```ts\nconst client = createIDosGamesClient({ titleID, buildKey, throttleMs: 0 });\n// Do NOT log in here — the host does it.\nmountHost({\n container: app,\n client,\n modules, // from ./modules\n renderLogin, // optional: your own screen; omitted = a plain guest-only default\n});\n```\n\nWhether a returning player is signed back in silently is the login screen's business, not the\nhost's: it calls `client.auth.setRememberSession(remember)` before a `login*` method. See the\nauthentication skill.\n\n`src/modules.ts` registers what the project composes:\n\n```ts\nimport type { Module } from \"@idosgames/module-sdk\";\nimport { boardGameModule } from \"./modules/board-game\";\nexport const modules: Module[] = [boardGameModule];\n```\n\n## Steps to scaffold\n\n1. **Host** — write the host scaffold to the project root (`get_host_scaffold`, or copy\n `templates/host-starter`). Its `src/modules.ts` starts empty → the host shows a \"no modules\" state.\n2. **Bind the Title** — open `src/idos.title.ts` and set `IDOS_TITLE_ID` to the game's canonical\n Title id (and `IDOS_BUILD_KEY` if the title enforces one). This file is the project's single\n centralized identity — it is the highest-priority source and the only channel a packaged mobile\n build, iframe embed, or shared link has. On platform-created projects the platform generates it;\n on a manually scaffolded project **you fill it yourself**. Do NOT bind the title via `.env.local`\n (`VITE_IDOS_TITLE_ID`) — that is a local-dev fallback for the raw template only, and it does not\n travel with the code.\n3. **Pick modules** — `list_modules` / `search`, then `get_module {id}` for each. Write its files\n into `src/modules/{id}/`.\n4. **Register** — for each module add `import { {camelCase(id)}Module } from \"./modules/{id}\"` and\n push it into the `modules` array (e.g. `board-game` → `boardGameModule`).\n5. **Install deps** — the union of `runtimePackages` + each module's `dependencies`. Modules bring\n their own engine (three, phaser); the host brings react/react-dom/app-shell.\n6. **Run** — the host renders a nav-bar when ≥2 modules register a route, and mode-switches between\n them; a shared HUD panel (`activeOnly:false`) stays visible across all modes.\n\nTo combine multiple genres into one game, see **idosgames-compose-modules**. To write or edit a\nmodule, see **idosgames-module-contract**.\n\n## Two MCP surfaces: game CODE vs. a Title's live DATA\n\nThe platform exposes **two independent MCP servers** — don't confuse them:\n\n- **`@idosgames/mcp`** (this one) serves the **CODE registry**. Use it to WRITE a game's source:\n `get_host_scaffold`, `list_modules` / `search` / `get_module`, `get_manifest`, `list_skills` /\n `get_skill`. Transport: stdio (`npx -y @idosgames/mcp`). Its registry is bundled offline; to use the\n hosted copy set `IDOSGAMES_REGISTRY_URL=https://cloud.idosgames.com/drive/registry/latest` — a **base**\n the loader appends `/index.json`, `/modules/{id}.json`, etc. to (the base itself is not fetchable on\n R2; open `.../latest/index.json` to browse the catalog). Read-only, no auth. It never reads or changes\n a live Title's data.\n- **The Title-configuration MCP** (a separate backend server) owns a **live Title's DATA**. Use it to\n read/write the Title's `TitlePublicConfiguration` (`get_<field>` / `save_<field>`, or the whole\n model) and to generate assets (`generate_image` / `generate_audio` / `generate_text` /\n `generate_three_d` / `generate_video`). Transport: HTTP JSON-RPC at\n `POST https://site.idosgames.com/api/v2/mcp`, authenticated with an `X-MCP-API-Key` header (the\n publisher issues the key per Title on platform.idosgames.com); every tool call takes a `title_id`\n argument. Connect it as an HTTP MCP server — and keep the key out of committed config via env\n expansion:\n\n ```json\n {\n \"mcpServers\": {\n \"idosgames-title\": {\n \"type\": \"http\",\n \"url\": \"https://site.idosgames.com/api/v2/mcp\",\n \"headers\": { \"X-MCP-API-Key\": \"${IDOS_MCP_API_KEY}\" }\n }\n }\n }\n ```\n\n Configuring a **fresh (empty) Title** so a game can actually run against it (currencies → game\n loop → bots) is its own checklist: see **idosgames-title-bootstrap**.\n\nRule of thumb: **game CODE → `@idosgames/mcp`; a Title's live config DATA and generated ASSETS → the\nbackend `v2/mcp` server.** Scaffolding a project and configuring/populating the Title it runs as are\ntwo different jobs on two different servers — connect the one that matches the task (or both).\n",
5
5
  "references": []
6
6
  }
@@ -0,0 +1,6 @@
1
+ {
2
+ "name": "idosgames-title-bootstrap",
3
+ "description": "Configure a FRESH (empty) iDosGames Title so a game can actually run against it: currencies with starting balances, then the game-loop board config, then verify with a real login. Use this when a newly created Title answers \"Board not found\", \"Board not enabled\", \"Bots config is not configured\", or \"SpecialMode ... must be configured\", when starting balances don't appear, or whenever you scaffold a project for a Title that was just created and has no config yet. All writes go through the Title-configuration MCP (see idosgames-getting-started for how to connect it).",
4
+ "content": "---\nname: idosgames-title-bootstrap\ndescription: >-\n Configure a FRESH (empty) iDosGames Title so a game can actually run against it: currencies with\n starting balances, then the game-loop board config, then verify with a real login. Use this when a\n newly created Title answers \"Board not found\", \"Board not enabled\", \"Bots config is not\n configured\", or \"SpecialMode ... must be configured\", when starting balances don't appear, or\n whenever you scaffold a project for a Title that was just created and has no config yet. All\n writes go through the Title-configuration MCP (see idosgames-getting-started for how to connect\n it).\n---\n\n# Bootstrap an empty Title\n\nA freshly created Title has an **empty `TitlePublicConfiguration`** — the game client will log in\nfine, but every feature that reads config fails until its section exists. Configure it over the\nTitle-configuration MCP (`POST https://site.idosgames.com/api/v2/mcp`, `X-MCP-API-Key` header,\nevery tool takes `title_id`). Tools are `get_<section>` / `save_<section>` — snake_case of the\nconfig model's property names (`Currency` → `save_currency`, `GameLoop` → `save_game_loop`).\n\n**Always `get_` a section before `save_` — save replaces the whole section**, so build on what is\nthere rather than authoring blind.\n\n## Error → missing config\n\n| Server error | What's missing |\n| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |\n| `Board not found` / `Board not enabled` | `GameLoop.Board` — the whole board definition |\n| `Stage not found` / `BoardTemplate not found for stage` / `StageTemplate not found for stage` | `StagesByLevel[\"1\"]` or the template it references by id |\n| `Bots config is not configured (Bots.RankMultiplierMin/Max ...)` | `Board.Bots` — required as soon as any tile can trigger Attack/Raid |\n| `SpecialMode '<id>' OfferExpireSeconds must be configured (> 0)` (same for `ClaimExpireSeconds`) | that mode in `Board.SpecialModesByID` |\n| Player starts with zero of everything | `Currency` entries' `InitialDeposit` |\n\n## Order of operations\n\n### 1. Currencies (`save_currency`)\n\nDefine every currency the game references **before** the game loop that spends them. For the\nboard-game module that is three roles: a roll currency (dice), a shield currency, and a soft\ncurrency (building costs / rewards). Give each an `InitialDeposit` for the starting balance.\n\n`InitialDeposit` applies when a **user is created** — an account that logged in before the deposit\nwas configured stays at 0. When verifying, log in as a **fresh guest**, don't reuse the session.\n\n### 2. Game loop (`save_game_loop`)\n\nThe `Board` object wires everything together. Minimum viable shape:\n\n- `RollCurrencyID` / `ShieldCurrencyID` / `SoftCurrencyID` — ids from step 1.\n- `BoardTemplatesByID` — at least one template with the tile ring (`Reward`, `Chance`, `Attack`,\n `Raid`, `Special`, `Shield`, `Empty`, `RandomAction`).\n- `StageTemplatesByID` — at least one economy template (`StageOperations`: `OnBuild`,\n `OnTileLanding`, `OnStageComplete`, `SpecialModesByID`, …).\n- `StagesByLevel` — `{\"1\": {...}}` referencing a `BoardTemplateID` + `StageTemplateID` that exist\n in the two maps above (dangling ids are a runtime error, not a save error).\n- `AllowedRollMultipliers`, `Dice`.\n- `Bots` — **required** if any tile can resolve to Attack or Raid: `RankMultiplierMin`/`Max` with\n `Max >= Min > 0`.\n- `RaidMode` — `Sequential` (server reveals cell by cell) or `Fast` (client reveals locally from\n the pre-dealt layout, submits once). Pick one; the client adapts.\n- Any `SpecialModesByID` mode needs `OfferExpireSeconds > 0` and `ClaimExpireSeconds > 0`.\n\n### 3. Verify against the live backend\n\n1. Fresh guest login → starting balances match the `InitialDeposit`s.\n2. `client.gameLoop.getUserBoardState()` → no `Board not enabled`.\n3. Roll until each tile type triggers once — Reward, Chance, Attack, Raid, Special — and confirm\n the granted/spent currencies match the configured economy.\n\n## Scope\n\nThis checklist covers the board-game loop because it is the config-heaviest module. Other config\nsections (store, quests, lootboxes, …) follow the same pattern — `get_<section>`, fill, `save_`,\nverify with the matching `@idosgames/core` service — and each service's own skill documents the\nshape it reads.\n",
5
+ "references": []
6
+ }