@idosgames/mcp 0.1.1 → 0.1.3

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.
@@ -37,10 +37,14 @@
37
37
  "version": "0.1.0"
38
38
  },
39
39
  "dependencies": {
40
- "@idosgames/module-sdk": "0.1.1",
40
+ "@idosgames/module-sdk": "0.1.3",
41
41
  "three": "0.185.1"
42
42
  },
43
43
  "files": [
44
+ {
45
+ "path": "agent.ts",
46
+ "content": "import type { ModuleAgentApi } from \"@idosgames/module-sdk\";\nimport { Intent } from \"./core/Intents\";\nimport type { Game } from \"./main\";\n\n// Debug-поверхность VoxelCraft для агента AI-кодера (контракт `ctx.exposeToAgent`).\n//\n// Зачем она нужна именно здесь: агент осматривает работающее приложение, читая DOM, а вся игра —\n// один `<canvas>`. Без этого файла для агента экран пуст, и «персонаж провалился сквозь пол» он\n// не увидит никак.\n//\n// ПРАВИЛО, которое нельзя нарушать: действия идут тем же путём, что и живой ввод — через клавиши\n// `Input` и очередь интентов. Второй реализации ходьбы здесь нет и быть не должно, иначе агент\n// проверял бы не ту игру, в которую играет человек.\n\n/** Потолок удержания клавиши: агент не должен уметь «зажать W» на минуту. */\nconst MAX_HOLD_MS = 5000;\n\n/** Сколько времени на действие, если агент не сказал. */\nconst DEFAULT_HOLD_MS = 400;\n\nconst DIR_KEYS: Record<string, string> = {\n forward: \"KeyW\",\n back: \"KeyS\",\n left: \"KeyA\",\n right: \"KeyD\",\n};\n\nfunction clampMs(value: unknown): number {\n const ms = typeof value === \"number\" && value > 0 ? value : DEFAULT_HOLD_MS;\n return Math.min(ms, MAX_HOLD_MS);\n}\n\nfunction round(value: number, digits = 2): number {\n const k = 10 ** digits;\n return Math.round(value * k) / k;\n}\n\nconst toDegrees = (rad: number): number => round((rad * 180) / Math.PI, 1);\n\n/**\n * @param getGame доступ к текущему экземпляру игры. Именно функция, а не объект: сцена создаёт\n * `Game` в `mount()` и обнуляет в `destroy()`, а поверхность регистрируется один раз на `setup()`.\n */\nexport function createVoxelcraftAgentApi(\n getGame: () => Game | null,\n): ModuleAgentApi {\n /** Игра, готовая принимать команды: существует и не на паузе после действий агента. */\n function live(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n // Действие в игре, которая стоит на паузе, молча не сделало бы ничего — сначала берём\n // управление (это же снимает оверлей паузы, как клик игрока).\n game.input.takeControl();\n return game;\n }\n\n /** Удержать клавишу как живой игрок: нажали, подождали, отпустили. */\n function hold(code: string, ms: number): Promise<void> {\n const game = live();\n game.input.keys.add(code);\n return new Promise((resolve) => {\n setTimeout(() => {\n game.input.keys.delete(code);\n resolve();\n }, ms);\n });\n }\n\n /** Удержать кнопку мыши (ломать/ставить обрабатываются по удержанию). */\n function holdButton(button: \"left\" | \"right\", ms: number): Promise<void> {\n const game = live();\n if (button === \"left\") game.input.leftDown = true;\n else game.input.rightDown = true;\n return new Promise((resolve) => {\n setTimeout(() => {\n if (button === \"left\") game.input.leftDown = false;\n else game.input.rightDown = false;\n resolve();\n }, ms);\n });\n }\n\n return {\n state() {\n const game = getGame();\n if (!game) return { mounted: false };\n\n const p = game.player;\n const held = game.inventory.heldItem();\n\n return {\n mounted: true,\n // Идёт ли симуляция. false = игра на паузе (оверлей «Click to play»): в этом состоянии\n // персонаж не двигается вообще, и это НЕ баг движения.\n playing: game.playing,\n inputActive: game.input.active,\n player: {\n pos: { x: round(p.pos.x), y: round(p.pos.y), z: round(p.pos.z) },\n velocity: {\n x: round(p.vel.x),\n y: round(p.vel.y),\n z: round(p.vel.z),\n },\n yawDeg: toDegrees(p.yaw),\n pitchDeg: toDegrees(p.pitch),\n health: p.health,\n food: p.food,\n mode: p.mode,\n flying: p.flying,\n onGround: p.onGround,\n inWater: p.inWater,\n dead: p.dead,\n },\n world: {\n loadedChunks: game.world.chunks.size,\n // Блок под ногами: по нему видно и «стоит на траве», и «провалился в пустоту».\n blockBelow: game.world.getBlock(\n Math.floor(p.pos.x),\n Math.floor(p.pos.y - 0.1),\n Math.floor(p.pos.z),\n ),\n },\n inventory: {\n hotbarIndex: game.inventory.hotbarIndex,\n inHand: held ? { item: held.item, count: held.count } : null,\n },\n };\n },\n\n actions: {\n move: async (args) => {\n const dir = String(args?.dir ?? \"forward\");\n const code = DIR_KEYS[dir];\n if (!code)\n throw new Error(\n `unknown dir '${dir}' — use forward | back | left | right`,\n );\n await hold(code, clampMs(args?.ms));\n },\n\n jump: async (args) => {\n await hold(\"Space\", clampMs(args?.ms ?? 120));\n },\n\n look: (args) => {\n const game = live();\n const dYaw = Number(args?.yawDeg ?? 0) * (Math.PI / 180);\n const dPitch = Number(args?.pitchDeg ?? 0) * (Math.PI / 180);\n // Через штатный метод игрока — те же ограничения по pitch, что и у мыши.\n game.player.look(dYaw, dPitch);\n },\n\n breakBlock: async (args) => {\n // Ломание идёт по удержанию ЛКМ: короткий «клик» не сломает ничего, кроме мгновенных блоков.\n await holdButton(\"left\", clampMs(args?.ms ?? 1200));\n },\n\n placeBlock: async () => {\n await holdButton(\"right\", 120);\n },\n\n selectSlot: (args) => {\n const n = Number(args?.slot ?? 1);\n if (!Number.isInteger(n) || n < 1 || n > 9)\n throw new Error(\"slot must be 1..9\");\n live().intents.push(Intent.SelectSlot, { slot: n - 1 });\n },\n\n toggleMode: () => {\n live().intents.push(Intent.ToggleMode, {});\n },\n },\n\n describeActions: {\n move: \"Walk: { dir: forward|back|left|right, ms?: number } — holds the movement key for ms (default 400, max 5000).\",\n jump: \"Jump: { ms?: number } — taps Space.\",\n look: \"Turn the camera by a DELTA in degrees: { yawDeg?: number, pitchDeg?: number }. Positive pitch looks up.\",\n breakBlock:\n \"Hold left mouse to break the block under the crosshair: { ms?: number } (default 1200 — breaking takes time).\",\n placeBlock:\n \"Tap right mouse to place the block from the selected hotbar slot.\",\n selectSlot: \"Select a hotbar slot: { slot: 1..9 }.\",\n toggleMode: \"Switch between survival and creative.\",\n },\n };\n}\n"
47
+ },
44
48
  {
45
49
  "path": "config.ts",
46
50
  "content": "// Единый конфиг игры. Все тюнинги — здесь, логика магических чисел не содержит.\nexport const CONFIG = {\n chunk: { sx: 16, sy: 64, sz: 16 }, // размер чанка в блоках\n renderDistance: 5, // радиус в чанках\n unloadDistance: 7, // дальше этого — выгрузка\n genBudgetPerFrame: 2, // генераций данных чанков за кадр\n meshBudgetPerFrame: 2, // пересборок мешей за кадр\n\n tps: 20, // тиков симуляции в секунду\n maxCatchupTicks: 4, // защита от «спирали смерти» после сворачивания вкладки\n\n world: {\n seed: 1337,\n waterLevel: 25, // уровень моря (y)\n baseHeight: 31,\n hillAmp: 14, // амплитуда холмов\n },\n\n player: {\n width: 0.6,\n height: 1.8,\n eye: 1.62,\n walkSpeed: 4.3,\n runSpeed: 6.5,\n flySpeed: 11,\n jumpSpeed: 8.2,\n gravity: 24,\n swimSpeed: 2.4,\n swimUp: 4.0,\n waterDrag: 0.6,\n reach: 5, // дистанция взаимодействия с блоками\n breakHealDelay: 1.2, // сек простоя, прежде чем урон блока начнёт заживать\n breakHealRate: 0.6, // сек прогресса, восстанавливаемого за секунду простоя\n maxHealth: 20, // 10 сердечек\n fallSafe: 3.5, // блоков падения без урона\n attackDamage: 1, // урон кулаком\n attackCooldown: 0.35, // сек\n\n // Голод (модель Minecraft: еда + сатурация + накопитель «истощения»)\n maxFood: 20, // 10 «окорочков»\n sprintFood: 6, // ниже — нельзя бежать\n exhaustionPerFood: 4.0, // истощения на -1 очко еды/сатурации\n exhaustIdle: 0.02, // пассивное истощение в секунду (стоя)\n exhaustWalk: 0.04, // истощение на блок ходьбы\n exhaustSprint: 0.08, // истощение на блок бега\n exhaustJump: 0.2, // за прыжок\n exhaustBreak: 0.06, // за сломанный блок\n exhaustAttack: 0.1, // за удар\n exhaustRegen: 6.0, // за восстановленное сердце (регенерация «стоит» еды)\n regenFoodThreshold: 18, // еда >= этого → лечит HP\n regenInterval: 3.5, // сек на +1 HP\n starveInterval: 4.0, // сек на -1 HP при нулевой еде\n eatTime: 1.4, // сек удержания ПКМ, чтобы съесть\n },\n\n drops: {\n magnetRadius: 1.9,\n pickupRadius: 0.6,\n magnetPull: 14,\n gravity: 18,\n bounce: 0.3,\n friction: 0.6,\n maxCount: 60,\n mergeRadius: 0.6,\n scale: 0.25,\n despawnTime: 300, // сек\n },\n\n mobs: {\n targetPopulation: 7, // куриц вокруг игрока\n spawnRadius: [10, 24], // мин/макс дистанция спавна\n despawnRadius: 48,\n },\n\n water: {\n tickEvery: 5, // каждый N-й симтик (20/5 = 4 Гц)\n cellBudget: 400, // макс клеток за водный тик\n maxLevel: 8,\n minFlowLevel: 2, // уровень 2 останавливает растекание (~7 блоков)\n },\n\n dayNight: {\n dayLength: 30,\n nightLength: 30, // секунд\n },\n\n // Задел под этап 2 (мультиплеер «звезда с хостом»). В MVP не используется.\n net: {\n maxPlayers: 8,\n snapshotRateHz: 15, // рассылка позиций unreliable-каналом\n interpBufferMs: 120,\n timeoutMs: 8000,\n protocolVersion: 1,\n },\n\n saveVersion: 1,\n};\n"
@@ -59,15 +63,15 @@
59
63
  },
60
64
  {
61
65
  "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"
66
+ "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
67
  },
64
68
  {
65
69
  "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"
70
+ "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
71
  },
68
72
  {
69
73
  "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"
74
+ "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
75
  },
72
76
  {
73
77
  "path": "entities/Mob.ts",
@@ -75,7 +79,7 @@
75
79
  },
76
80
  {
77
81
  "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"
82
+ "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
83
  },
80
84
  {
81
85
  "path": "index.ts",
@@ -83,27 +87,27 @@
83
87
  },
84
88
  {
85
89
  "path": "main.ts",
86
- "content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config\";\nimport { events } from \"./core/EventBus\";\nimport { GameLoop } from \"./core/GameLoop\";\nimport { IntentQueue } from \"./core/Intents\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas\";\nimport { buildIcons } from \"./registry/Items\";\nimport { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { WaterSim } from \"./systems/WaterSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n interaction: Interaction;\n waterSim: WaterSim;\n dayNight: DayNight;\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n playing: boolean;\n private _time: number;\n loop: GameLoop;\n\n constructor() {\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.scene = new THREE.Scene();\n // Пропорции выставит первый же замер ResizeObserver'а — до него кадров не будет.\n this.camera = new THREE.PerspectiveCamera(\n 75,\n 1,\n 0.1,\n CONFIG.renderDistance * 16 + 60,\n );\n this.camera.rotation.order = \"YXZ\";\n this.scene.add(this.camera); // рука прикреплена к камере\n\n // Размер берём из раскладки самого канваса (CSS растягивает его на вьюпорт), а не из\n // window.innerWidth, и через ResizeObserver, а не событие resize: игра может стартовать\n // в ещё не разложенном контейнере — фоновая вкладка или iframe превью, — где на момент\n // конструктора innerWidth равен нулю. Событие resize там уже не придёт, и канвас навсегда\n // остался бы 0x0: симуляция живая, экран чёрный. ResizeObserver сам отдаёт первый замер,\n // как только раскладка появляется.\n this._resizeObserver = new ResizeObserver(() => this._resize());\n this._resizeObserver.observe(canvas);\n this._resize();\n\n // --- контент: атлас и иконки из одних и тех же процедурных текстур ---\n this.atlas = new TextureAtlas();\n buildIcons(this.atlas);\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, CONFIG.world.seed);\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.waterSim = new WaterSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.audio = new Audio();\n this.input = new Input(canvas, this.intents);\n this.armView = new ArmView(this.camera, this.atlas);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n this.invUI = new InventoryUI(this);\n\n this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\n document.addEventListener(\"keydown\", (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n });\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\"click\", () => {\n if (this.playing && !this.input.locked && !this.input.uiOpen)\n this.input.requestLock();\n });\n\n // --- старт мира ---\n this.warmup(8, 8);\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n this.hud.showOverlay(false);\n this.loop.start();\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n this.world.update(x, z);\n const busy =\n this.world.genQueue.length > 0 ||\n [...this.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n }\n\n /** Пересоздание мира под новый сид (регенерация/импорт сейва). */\n rebuildWorld(seed: number) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, seed);\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.waterSim.world = this.world;\n this.waterSim.queue.clear();\n this.interaction.breaking = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. */\n newWorld(seed: number) {\n this.rebuildWorld(seed);\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n this.dayNight.time = this.dayNight.cycleLength * 0.15;\n this.warmup(8, 8);\n this.player.spawn();\n this.hud.message(\"Новый мир, сид \" + seed, 1800);\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n if (!this.playing) return;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n this.entities.tick(dt);\n this.waterSim.tick();\n this.world.tick(); // onTick-блоки (задел автоматизации)\n this.dayNight.tick(dt);\n events.emit(\"tick\", { n });\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.dayNight.frame(this.player.pos, this.player.headInWater);\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.renderer.render(this.scene, this.camera);\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n this.loop.stop();\n this.input.releaseLock();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n if (!this.loop.running) this.loop.start();\n } else {\n this.loop.stop();\n this.input.releaseLock();\n this.playing = false;\n }\n }\n}\n"
90
+ "content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config\";\nimport { events } from \"./core/EventBus\";\nimport { GameLoop } from \"./core/GameLoop\";\nimport { IntentQueue } from \"./core/Intents\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas\";\nimport { buildIcons } from \"./registry/Items\";\nimport { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { WaterSim } from \"./systems/WaterSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n interaction: Interaction;\n waterSim: WaterSim;\n dayNight: DayNight;\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n playing: boolean;\n private _time: number;\n loop: GameLoop;\n\n constructor() {\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.scene = new THREE.Scene();\n // Пропорции выставит первый же замер ResizeObserver'а — до него кадров не будет.\n this.camera = new THREE.PerspectiveCamera(\n 75,\n 1,\n 0.1,\n CONFIG.renderDistance * 16 + 60,\n );\n this.camera.rotation.order = \"YXZ\";\n this.scene.add(this.camera); // рука прикреплена к камере\n\n // Размер берём из раскладки самого канваса (CSS растягивает его на вьюпорт), а не из\n // window.innerWidth, и через ResizeObserver, а не событие resize: игра может стартовать\n // в ещё не разложенном контейнере — фоновая вкладка или iframe превью, — где на момент\n // конструктора innerWidth равен нулю. Событие resize там уже не придёт, и канвас навсегда\n // остался бы 0x0: симуляция живая, экран чёрный. ResizeObserver сам отдаёт первый замер,\n // как только раскладка появляется.\n this._resizeObserver = new ResizeObserver(() => this._resize());\n this._resizeObserver.observe(canvas);\n this._resize();\n\n // --- контент: атлас и иконки из одних и тех же процедурных текстур ---\n this.atlas = new TextureAtlas();\n buildIcons(this.atlas);\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, CONFIG.world.seed);\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.waterSim = new WaterSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.audio = new Audio();\n this.input = new Input(canvas, this.intents);\n this.armView = new ArmView(this.camera, this.atlas);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n this.invUI = new InventoryUI(this);\n\n this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\n document.addEventListener(\"keydown\", (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n });\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\"click\", () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n });\n\n // --- старт мира ---\n this.warmup(8, 8);\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n this.hud.showOverlay(false);\n this.loop.start();\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n this.world.update(x, z);\n const busy =\n this.world.genQueue.length > 0 ||\n [...this.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n }\n\n /** Пересоздание мира под новый сид (регенерация/импорт сейва). */\n rebuildWorld(seed: number) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, seed);\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.waterSim.world = this.world;\n this.waterSim.queue.clear();\n this.interaction.breaking = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. */\n newWorld(seed: number) {\n this.rebuildWorld(seed);\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n this.dayNight.time = this.dayNight.cycleLength * 0.15;\n this.warmup(8, 8);\n this.player.spawn();\n this.hud.message(\"Новый мир, сид \" + seed, 1800);\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n if (!this.playing) return;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n this.entities.tick(dt);\n this.waterSim.tick();\n this.world.tick(); // onTick-блоки (задел автоматизации)\n this.dayNight.tick(dt);\n events.emit(\"tick\", { n });\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.dayNight.frame(this.player.pos, this.player.headInWater);\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.renderer.render(this.scene, this.camera);\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n this.loop.stop();\n this.input.releaseLock();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n if (!this.loop.running) this.loop.start();\n } else {\n this.loop.stop();\n this.input.releaseLock();\n this.playing = false;\n }\n }\n}\n"
87
91
  },
88
92
  {
89
93
  "path": "module.ts",
90
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelScene } from \"./scene\";\n\n// The VoxelCraft feature module. It contributes ONLY an engine scene and a nav entry — no React UI\n// panels (the game draws its own DOM HUD). This exercises the scene-only branch of the contract and\n// proves a module need not use React or the SDK at all.\nexport const voxelcraftModule: Module = defineModule({\n id: \"voxelcraft\",\n meta: {\n name: \"VoxelCraft\",\n type: \"game\",\n genre: \"sandbox\",\n engine: \"three\",\n },\n setup(ctx) {\n ctx.registerScene(createVoxelScene());\n ctx.registerRoute({ id: \"voxelcraft\", label: \"Voxel\", icon: \"🧊\" });\n },\n});\n"
94
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelcraft } from \"./scene\";\n\n// The VoxelCraft feature module. It contributes ONLY an engine scene and a nav entry — no React UI\n// panels (the game draws its own DOM HUD). This exercises the scene-only branch of the contract and\n// proves a module need not use React or the SDK at all.\nexport const voxelcraftModule: Module = defineModule({\n id: \"voxelcraft\",\n meta: {\n name: \"VoxelCraft\",\n type: \"game\",\n genre: \"sandbox\",\n engine: \"three\",\n },\n setup(ctx) {\n const { scene, agent } = createVoxelcraft();\n ctx.registerScene(scene);\n ctx.registerRoute({ id: \"voxelcraft\", label: \"Voxel\", icon: \"🧊\" });\n // Глаза и руки агента: у canvas-игры DOM пустой, без этого она для него невидима.\n ctx.exposeToAgent(agent);\n },\n});\n"
91
95
  },
92
96
  {
93
97
  "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"
98
+ "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
99
  },
96
100
  {
97
101
  "path": "player/Input.ts",
98
- "content": "// Ввод: Pointer Lock, клавиши, мышь. НИКОГДА не трогает мир напрямую —\n// дискретные действия уходят интентами в очередь, непрерывные состояния\n// (WASD, зажатые кнопки мыши, взгляд) читает Player/Interaction на кадре.\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentQueue } from \"../core/Intents\";\n\nexport class Input {\n canvas: HTMLCanvasElement;\n intents: IntentQueue;\n keys: Set<string>;\n leftDown: boolean;\n rightDown: boolean;\n dx: number;\n dy: number;\n locked: boolean;\n uiOpen: boolean;\n _lastSpace: number;\n\n constructor(canvas: HTMLCanvasElement, intents: IntentQueue) {\n this.canvas = canvas;\n this.intents = intents;\n this.keys = new Set(); // KeyboardEvent.code\n this.leftDown = false;\n this.rightDown = false;\n this.dx = 0;\n this.dy = 0; // накопленное движение мыши за кадр\n this.locked = false;\n this.uiOpen = false; // инвентарь открыт — игровой ввод заморожен\n this._lastSpace = 0; // двойной Space → полёт в креативе\n\n document.addEventListener(\"pointerlockchange\", () => {\n this.locked = document.pointerLockElement === canvas;\n if (!this.locked && !this.uiOpen) events.emit(\"ui:pause\");\n if (this.locked) events.emit(\"ui:resume\");\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n });\n\n canvas.addEventListener(\"mousemove\", (e) => {\n if (!this.locked) return;\n this.dx += e.movementX;\n this.dy += e.movementY;\n });\n\n canvas.addEventListener(\"mousedown\", (e) => {\n if (!this.locked) return;\n if (e.button === 0) this.leftDown = true;\n if (e.button === 2) this.rightDown = true;\n });\n canvas.addEventListener(\"mouseup\", (e) => {\n if (e.button === 0) this.leftDown = false;\n if (e.button === 2) this.rightDown = false;\n });\n canvas.addEventListener(\"contextmenu\", (e) => e.preventDefault());\n\n canvas.addEventListener(\n \"wheel\",\n (e) => {\n if (!this.locked) return;\n this.intents.push(Intent.SelectSlot, { delta: Math.sign(e.deltaY) });\n e.preventDefault();\n },\n { passive: false },\n );\n\n document.addEventListener(\"keydown\", (e) => {\n if (e.repeat) return;\n // E работает и без pointer lock (закрыть инвентарь)\n if (e.code === \"KeyE\") {\n events.emit(\"ui:toggleInventory\");\n return;\n }\n if (!this.locked) return;\n this.keys.add(e.code);\n if (e.code.startsWith(\"Digit\")) {\n const n = +e.code.slice(5);\n if (n >= 1 && n <= 9)\n this.intents.push(Intent.SelectSlot, { slot: n - 1 });\n }\n if (e.code === \"KeyC\") this.intents.push(Intent.ToggleMode);\n if (e.code === \"Space\") {\n const now = performance.now();\n if (now - this._lastSpace < 300) events.emit(\"input:doubleSpace\");\n this._lastSpace = now;\n }\n });\n document.addEventListener(\"keyup\", (e) => this.keys.delete(e.code));\n }\n\n /** Захват мыши — только из user gesture (клик по оверлею). */\n requestLock(): void {\n // Браузер может бросить, если Esc был нажат <1.2 c назад — тихо игнорируем.\n this.canvas.requestPointerLock()?.catch?.(() => {});\n }\n releaseLock(): void {\n if (this.locked) document.exitPointerLock();\n }\n\n /** Забрать накопленную дельту мыши (вызывается раз в кадр). */\n consumeMouse(): { dx: number; dy: number } {\n const d = { dx: this.dx, dy: this.dy };\n this.dx = this.dy = 0;\n return d;\n }\n\n get forward(): boolean {\n return this.keys.has(\"KeyW\");\n }\n get back(): boolean {\n return this.keys.has(\"KeyS\");\n }\n get left(): boolean {\n return this.keys.has(\"KeyA\");\n }\n get right(): boolean {\n return this.keys.has(\"KeyD\");\n }\n get jump(): boolean {\n return this.keys.has(\"Space\");\n }\n get sneakOrDown(): boolean {\n return this.keys.has(\"ShiftLeft\") || this.keys.has(\"ShiftRight\");\n }\n get run(): boolean {\n return this.keys.has(\"ControlLeft\") || this.keys.has(\"ShiftLeft\");\n }\n}\n"
102
+ "content": "// Ввод: Pointer Lock, клавиши, мышь. НИКОГДА не трогает мир напрямую —\n// дискретные действия уходят интентами в очередь, непрерывные состояния\n// (WASD, зажатые кнопки мыши, взгляд) читает Player/Interaction на кадре.\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentQueue } from \"../core/Intents\";\n\nexport class Input {\n canvas: HTMLCanvasElement;\n intents: IntentQueue;\n keys: Set<string>;\n leftDown: boolean;\n rightDown: boolean;\n dx: number;\n dy: number;\n locked: boolean;\n uiOpen: boolean;\n _lastSpace: number;\n\n /**\n * Управление без захвата указателя.\n *\n * Pointer Lock есть не везде: в кросс-доменном iframe (превью AI-кодера, встраивание игры на\n * сайте) браузер его запрещает. Раньше это означало, что играть НЕЛЬЗЯ ВООБЩЕ — весь ввод был\n * закрыт проверкой `locked`, а `locked` без API никогда не становился true: игра шла, а клавиши\n * и мышь молчали.\n *\n * Поэтому здесь тот же режим, но своими силами: клик по канвасу «берёт управление»\n * (`softLocked`), Esc отпускает. Мышь двигает камеру без захвата — курсор при этом виден и\n * может уйти за пределы канваса, зато играть можно.\n */\n softLocked: boolean;\n\n /** Есть ли в этом окружении настоящий Pointer Lock. Если да — мягкий режим не нужен. */\n readonly pointerLockAvailable: boolean;\n\n constructor(canvas: HTMLCanvasElement, intents: IntentQueue) {\n this.canvas = canvas;\n this.intents = intents;\n this.keys = new Set(); // KeyboardEvent.code\n this.leftDown = false;\n this.rightDown = false;\n this.dx = 0;\n this.dy = 0; // накопленное движение мыши за кадр\n this.locked = false;\n this.uiOpen = false; // инвентарь открыт — игровой ввод заморожен\n this._lastSpace = 0; // двойной Space → полёт в креативе\n this.softLocked = false;\n this.pointerLockAvailable =\n typeof canvas.requestPointerLock === \"function\" &&\n typeof document.exitPointerLock === \"function\";\n\n document.addEventListener(\"pointerlockchange\", () => {\n this.locked = document.pointerLockElement === canvas;\n if (!this.locked && !this.uiOpen) events.emit(\"ui:pause\");\n if (this.locked) events.emit(\"ui:resume\");\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n });\n\n canvas.addEventListener(\"mousemove\", (e) => {\n if (!this.active) return;\n // Без захвата курсор реальный: он может уйти с канваса и вернуться, и тогда браузер отдаёт\n // одну гигантскую дельту — камера дёрнулась бы на пол-оборота. Рывки отбрасываем.\n if (\n !this.locked &&\n (Math.abs(e.movementX) > 120 || Math.abs(e.movementY) > 120)\n )\n return;\n this.dx += e.movementX;\n this.dy += e.movementY;\n });\n\n canvas.addEventListener(\"mousedown\", (e) => {\n // Первый клик по канвасу в мягком режиме — это «взять управление», а не удар киркой.\n if (!this.locked && !this.softLocked && !this.uiOpen) {\n this.takeControl();\n return;\n }\n if (!this.active) return;\n if (e.button === 0) this.leftDown = true;\n if (e.button === 2) this.rightDown = true;\n });\n canvas.addEventListener(\"mouseup\", (e) => {\n if (e.button === 0) this.leftDown = false;\n if (e.button === 2) this.rightDown = false;\n });\n canvas.addEventListener(\"contextmenu\", (e) => e.preventDefault());\n\n canvas.addEventListener(\n \"wheel\",\n (e) => {\n if (!this.active) return;\n this.intents.push(Intent.SelectSlot, { delta: Math.sign(e.deltaY) });\n e.preventDefault();\n },\n { passive: false },\n );\n\n document.addEventListener(\"keydown\", (e) => {\n if (e.repeat) return;\n // E работает и без pointer lock (закрыть инвентарь)\n if (e.code === \"KeyE\") {\n events.emit(\"ui:toggleInventory\");\n return;\n }\n // Esc в мягком режиме играет роль выхода из захвата: браузер сам его не отдаст.\n if (e.code === \"Escape\" && this.softLocked) {\n this.releaseControl();\n return;\n }\n if (!this.active) return;\n this.keys.add(e.code);\n if (e.code.startsWith(\"Digit\")) {\n const n = +e.code.slice(5);\n if (n >= 1 && n <= 9)\n this.intents.push(Intent.SelectSlot, { slot: n - 1 });\n }\n if (e.code === \"KeyC\") this.intents.push(Intent.ToggleMode);\n if (e.code === \"Space\") {\n const now = performance.now();\n if (now - this._lastSpace < 300) events.emit(\"input:doubleSpace\");\n this._lastSpace = now;\n }\n });\n document.addEventListener(\"keyup\", (e) => this.keys.delete(e.code));\n }\n\n /** Читает ли игра ввод прямо сейчас: настоящий захват ИЛИ мягкий режим. */\n get active(): boolean {\n return (this.locked || this.softLocked) && !this.uiOpen;\n }\n\n /** Взять управление без Pointer Lock (мягкий режим) и снять паузу. */\n takeControl(): void {\n if (this.softLocked) return;\n this.softLocked = true;\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n this.dx = this.dy = 0;\n events.emit(\"ui:resume\");\n }\n\n /** Отпустить управление в мягком режиме — аналог выхода из захвата по Esc. */\n releaseControl(): void {\n if (!this.softLocked) return;\n this.softLocked = false;\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n if (!this.uiOpen) events.emit(\"ui:pause\");\n }\n\n /** Захват мыши — только из user gesture (клик по оверлею). */\n requestLock(): void {\n // Там, где Pointer Lock запрещён (кросс-доменный iframe: превью AI-кодера, встраивание игры\n // на сайте), играем без него. Раньше здесь был тихий `return` — и игра оставалась\n // неуправляемой навсегда: `locked` без API не становится true никогда.\n if (!this.pointerLockAvailable) {\n this.takeControl();\n return;\n }\n try {\n // Браузер может бросить, если Esc был нажат <1.2 c назад — тихо игнорируем.\n this.canvas.requestPointerLock()?.catch?.(() => {\n // Обещание отклонено (нет user gesture, политика фрейма) — не оставляем игрока без\n // управления, а переходим в мягкий режим.\n this.takeControl();\n });\n } catch {\n this.takeControl();\n }\n }\n releaseLock(): void {\n if (this.locked && typeof document.exitPointerLock === \"function\") {\n document.exitPointerLock();\n }\n this.releaseControl();\n }\n\n /** Забрать накопленную дельту мыши (вызывается раз в кадр). */\n consumeMouse(): { dx: number; dy: number } {\n const d = { dx: this.dx, dy: this.dy };\n this.dx = this.dy = 0;\n return d;\n }\n\n get forward(): boolean {\n return this.keys.has(\"KeyW\");\n }\n get back(): boolean {\n return this.keys.has(\"KeyS\");\n }\n get left(): boolean {\n return this.keys.has(\"KeyA\");\n }\n get right(): boolean {\n return this.keys.has(\"KeyD\");\n }\n get jump(): boolean {\n return this.keys.has(\"Space\");\n }\n get sneakOrDown(): boolean {\n return this.keys.has(\"ShiftLeft\") || this.keys.has(\"ShiftRight\");\n }\n get run(): boolean {\n return this.keys.has(\"ControlLeft\") || this.keys.has(\"ShiftLeft\");\n }\n}\n"
99
103
  },
100
104
  {
101
105
  "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"
106
+ "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
107
  },
104
108
  {
105
109
  "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"
110
+ "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 * Поворот взгляда в радианах. Отдельным методом, чтобы у поворота был ОДИН источник правды:\n * им пользуется и кадровое обновление по мыши, и агентский API (`agent.ts`) — иначе у агента\n * появилась бы своя копия правил поворота, которая разъехалась бы с настоящей.\n */\n look(dYaw: number, dPitch: number): void {\n this.yaw += dYaw;\n this.pitch = Math.max(\n -Math.PI / 2 + 0.01,\n Math.min(Math.PI / 2 - 0.01, this.pitch + dPitch),\n );\n }\n\n /** Кадровое обновление: взгляд, ускорения, интеграция, коллизии. */\n update(dt: number, input: Input): void {\n if (this.dead) return;\n // взгляд\n const m = input.consumeMouse();\n this.look(-m.dx * 0.0024, -m.dy * 0.0024);\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
111
  },
108
112
  {
109
113
  "path": "registry/Blocks.ts",
@@ -111,7 +115,7 @@
111
115
  },
112
116
  {
113
117
  "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"
118
+ "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
119
  },
116
120
  {
117
121
  "path": "registry/Mobs.ts",
@@ -119,11 +123,11 @@
119
123
  },
120
124
  {
121
125
  "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"
126
+ "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
127
  },
124
128
  {
125
129
  "path": "scene.ts",
126
- "content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { Game } from \"./main\";\nimport \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">&rarr;</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> run</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory &amp; 2&times;2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3&times;3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"btnExport\">Export world</button>\n <button id=\"btnImport\">Import world</button>\n <button id=\"btnNewSeed\">New seed</button>\n </div>\n </div>\n </div>\n`;\n\nexport function createVoxelScene(): EngineScene {\n let game: Game | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n // The game reads its elements by id from the document; provide them inside the host surface.\n ctx.host.innerHTML = VOXEL_DOM;\n game = new Game();\n },\n activate(): void {\n game?.setRunning(true);\n },\n suspend(): void {\n game?.setRunning(false);\n },\n destroy(): void {\n game?.destroy();\n game = null;\n },\n };\n}\n"
130
+ "content": "import type {\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { Game } from \"./main\";\nimport \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">&rarr;</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> run</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory &amp; 2&times;2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3&times;3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"btnExport\">Export world</button>\n <button id=\"btnImport\">Import world</button>\n <button id=\"btnNewSeed\">New seed</button>\n </div>\n </div>\n </div>\n`;\n\n/**\n * Сцена + debug-поверхность для агента одной парой: обе смотрят на ОДИН экземпляр игры, который\n * создаётся в `mount()` и обнуляется в `destroy()`. Поэтому агент получает не объект, а доступ к\n * текущему `game` — иначе после пересоздания сцены он держал бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(): {\n scene: EngineScene;\n agent: ModuleAgentApi;\n} {\n let game: Game | null = null;\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n },\n );\n return { scene, agent: createVoxelcraftAgentApi(() => game) };\n}\n\nfunction buildScene(\n getGame: () => Game | null,\n setGame: (game: Game | null) => void,\n): EngineScene {\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n // The game reads its elements by id from the document; provide them inside the host surface.\n ctx.host.innerHTML = VOXEL_DOM;\n setGame(new Game());\n },\n activate(): void {\n getGame()?.setRunning(true);\n },\n suspend(): void {\n getGame()?.setRunning(false);\n },\n destroy(): void {\n getGame()?.destroy();\n setGame(null);\n },\n };\n}\n\n/** @deprecated Используйте `createVoxelcraft()` — он отдаёт ещё и поверхность для агента. */\nexport function createVoxelScene(): EngineScene {\n return createVoxelcraft().scene;\n}\n"
127
131
  },
128
132
  {
129
133
  "path": "shims.d.ts",
@@ -131,7 +135,7 @@
131
135
  },
132
136
  {
133
137
  "path": "style.css",
134
- "content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #000;\n}\nbody {\n font-family: \"Segoe UI\", system-ui, sans-serif;\n user-select: none;\n}\n\n#game {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ---------- HUD ---------- */\n#crosshair {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 18px;\n height: 18px;\n transform: translate(-50%, -50%);\n pointer-events: none;\n z-index: 5;\n}\n#crosshair::before,\n#crosshair::after {\n content: \"\";\n position: absolute;\n background: rgba(255, 255, 255, 0.85);\n mix-blend-mode: difference;\n}\n#crosshair::before {\n left: 8px;\n top: 0;\n width: 2px;\n height: 18px;\n}\n#crosshair::after {\n left: 0;\n top: 8px;\n width: 18px;\n height: 2px;\n}\n\n#fps {\n position: fixed;\n top: 8px;\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#debug {\n position: fixed;\n top: 8px;\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: 26px;\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: 8px;\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid {\n display: grid;\n gap: 3px;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(10, 14, 28, 0.82);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n"
138
+ "content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #063d99;\n}\nbody {\n font-family: \"Segoe UI\", system-ui, sans-serif;\n user-select: none;\n}\n\n#game {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ---------- HUD ---------- */\n#crosshair {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 18px;\n height: 18px;\n transform: translate(-50%, -50%);\n pointer-events: none;\n z-index: 5;\n}\n#crosshair::before,\n#crosshair::after {\n content: \"\";\n position: absolute;\n background: rgba(255, 255, 255, 0.85);\n mix-blend-mode: difference;\n}\n#crosshair::before {\n left: 8px;\n top: 0;\n width: 2px;\n height: 18px;\n}\n#crosshair::after {\n left: 0;\n top: 8px;\n width: 18px;\n height: 2px;\n}\n\n#fps {\n position: fixed;\n top: 8px;\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#debug {\n position: fixed;\n top: 8px;\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: 26px;\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: 8px;\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid {\n display: grid;\n gap: 3px;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(4, 32, 90, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n"
135
139
  },
136
140
  {
137
141
  "path": "systems/Audio.ts",
@@ -143,11 +147,11 @@
143
147
  },
144
148
  {
145
149
  "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"
150
+ "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
151
  },
148
152
  {
149
153
  "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"
154
+ "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
155
  },
152
156
  {
153
157
  "path": "systems/Save.ts",
@@ -155,7 +159,7 @@
155
159
  },
156
160
  {
157
161
  "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"
162
+ "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
163
  },
160
164
  {
161
165
  "path": "types.ts",
@@ -163,23 +167,23 @@
163
167
  },
164
168
  {
165
169
  "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"
170
+ "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
171
  },
168
172
  {
169
173
  "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"
174
+ "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
175
  },
172
176
  {
173
177
  "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"
178
+ "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
179
  },
176
180
  {
177
181
  "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"
182
+ "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
183
  },
180
184
  {
181
185
  "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"
186
+ "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
187
  },
184
188
  {
185
189
  "path": "world/Raycast.ts",
@@ -187,7 +191,7 @@
187
191
  },
188
192
  {
189
193
  "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"
194
+ "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
195
  }
192
196
  ]
193
197
  }