@idosgames/mcp 0.1.34 → 0.1.36

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.
Files changed (37) hide show
  1. package/dist/cli.js +1 -1
  2. package/package.json +1 -1
  3. package/registry/host.json +1 -1
  4. package/registry/index.json +126 -122
  5. package/registry/modules/ai.json +3 -3
  6. package/registry/modules/board-game.json +96 -20
  7. package/registry/modules/boosts.json +3 -3
  8. package/registry/modules/character.json +24 -16
  9. package/registry/modules/chat.json +3 -3
  10. package/registry/modules/collections.json +3 -3
  11. package/registry/modules/coop-events.json +3 -3
  12. package/registry/modules/craft.json +3 -3
  13. package/registry/modules/deals.json +3 -3
  14. package/registry/modules/events.json +8 -8
  15. package/registry/modules/friends.json +3 -3
  16. package/registry/modules/idle-rpg.json +3 -3
  17. package/registry/modules/inventory.json +3 -3
  18. package/registry/modules/invite.json +3 -3
  19. package/registry/modules/leaderboard.json +12 -12
  20. package/registry/modules/lootboxes.json +8 -8
  21. package/registry/modules/mailbox.json +3 -3
  22. package/registry/modules/marketplace.json +3 -3
  23. package/registry/modules/mini-game.json +7 -7
  24. package/registry/modules/multiplayer.json +28 -16
  25. package/registry/modules/premium.json +3 -3
  26. package/registry/modules/profile.json +3 -3
  27. package/registry/modules/pvp.json +3 -3
  28. package/registry/modules/quests.json +10 -10
  29. package/registry/modules/rewards.json +3 -3
  30. package/registry/modules/season.json +3 -3
  31. package/registry/modules/store.json +3 -3
  32. package/registry/modules/tutorials.json +3 -3
  33. package/registry/modules/voxelcraft.json +20 -20
  34. package/registry/modules/workshop.json +3 -3
  35. package/registry/skills/idosgames-module-contract.json +1 -1
  36. package/registry/skills/leaderboard-system.json +2 -2
  37. package/registry/skills/multiplayer-realtime.json +1 -1
@@ -81,16 +81,16 @@
81
81
  "name": "iDos Games",
82
82
  "url": "https://idosgames.com"
83
83
  },
84
- "version": "0.6.0"
84
+ "version": "0.7.0"
85
85
  },
86
86
  "dependencies": {
87
- "@idosgames/core": "0.21.6",
88
- "@idosgames/module-sdk": "0.11.0",
89
- "@idosgames/react": "0.5.0",
87
+ "@idosgames/core": "0.22.0",
88
+ "@idosgames/module-sdk": "0.12.0",
89
+ "@idosgames/react": "0.5.2",
90
90
  "react": "19.2.7",
91
91
  "three": "0.185.1"
92
92
  },
93
- "contentHash": "bfd8ce2406bd36ea05e1bb4f26d83752e8a3b295ad27690eb59c368bebad9051",
93
+ "contentHash": "4564e2471bfe58f9cc0b6deda1935f42f11298bc8d841f1994a9930203ea14ad",
94
94
  "files": [
95
95
  {
96
96
  "path": "agent.ts",
@@ -246,7 +246,7 @@
246
246
  },
247
247
  {
248
248
  "path": "core/GameLoop.ts",
249
- "content": "// Игровой цикл: rAF-рендер + аккумулятор фиксированного тика (20 TPS).\n// Симуляция (мир, мобы, вода, интенты) — на тике; всё, что касается глаза и руки\n// (камера, физика игрока, интерполяция, рендер) — на кадре.\nimport { CONFIG } from \"../config\";\n\nexport class GameLoop {\n onTick: (dt: number, n: number) => void;\n onFrame: (dt: number, alpha: number) => void;\n tickDt: number;\n acc: number;\n tickN: number;\n last: number;\n running: boolean;\n fps: number;\n /**\n * Сколько кадров отработано. Агент ждёт по нему, что нажатие увидел хотя бы один кадр: при\n * страховочном таймере (200 мс) короткий «клик» иначе проскакивал между кадрами.\n */\n frameN = 0;\n private _frames: number;\n private _fpsTime: number;\n /** id запланированного кадра: stop() обязан его отменить, см. там. */\n private _raf = 0;\n /**\n * Таймер шага для скрытой страницы. В фоне (свёрнутое превью AI-кодера, фоновая вкладка) браузер\n * не зовёт requestAnimationFrame вовсе — симуляция вставала, и действия агента (команды, ходьба)\n * висели до возврата на вкладку. Скрытая страница шагает таймером; браузер его душит до ~1 Гц,\n * но тики (maxCatchupTicks за шаг) идут.\n */\n private _timer: ReturnType<typeof setTimeout> | undefined;\n /**\n * Сетевая игра: мир общий, и скрытая вкладка хоста или владельца измерения не может считать его\n * раз в секунду (браузер душит таймеры фоновой страницы до ~1 Гц, а тиков за шаг не больше\n * maxCatchupTicks — мир у ВСЕХ шёл бы вчетверо медленнее). Таймеры выделенного воркера так не\n * душатся: в фоне шаг задаёт его сообщение раз в 50 мс. Подключает Game, пока идёт сетевая игра.\n */\n backgroundTicker: (() => boolean) | null = null;\n private _worker: Worker | null = null;\n private _workerUrl: string | null = null;\n private _workerStep: (() => void) | null = null;\n\n /**\n * @param onTick — фиксированный шаг, dt = 1/TPS\n * @param onFrame — alpha = доля тика для интерполяции\n */\n constructor(\n onTick: (dt: number, n: number) => void,\n onFrame: (dt: number, alpha: number) => void,\n ) {\n this.onTick = onTick;\n this.onFrame = onFrame;\n this.tickDt = 1 / CONFIG.tps;\n this.acc = 0;\n this.tickN = 0;\n this.last = 0;\n this.running = false;\n // FPS: скользящее окно за секунду\n this.fps = 0;\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n start() {\n this.running = true;\n this.last = performance.now();\n const step = (now: number) => {\n if (!this.running) return;\n let dt = (now - this.last) / 1000;\n this.last = now;\n if (dt > 0.25) dt = 0.25; // возврат из фона — не наращивать гигантский долг\n\n this.acc += dt;\n let ticks = 0;\n while (this.acc >= this.tickDt && ticks < CONFIG.maxCatchupTicks) {\n this.onTick(this.tickDt, this.tickN++);\n this.acc -= this.tickDt;\n ticks++;\n }\n if (ticks === CONFIG.maxCatchupTicks) this.acc = 0; // сбросить долг\n\n this._frames++;\n this.frameN++;\n this._fpsTime += dt;\n if (this._fpsTime >= 0.5) {\n this.fps = Math.round(this._frames / this._fpsTime);\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n this.onFrame(Math.min(dt, 0.05), this.acc / this.tickDt);\n schedule();\n };\n const schedule = () => {\n if (typeof document !== \"undefined\" && document.hidden) {\n if (\n this.backgroundTicker?.() &&\n this._startWorker(() => step(performance.now()))\n )\n return;\n this._timer = setTimeout(() => step(performance.now()), 50);\n return;\n }\n this._workerStep = null;\n // Видимая, но неотрисовываемая страница (панель превью за другой вкладкой, iframe вне\n // экрана) не зовёт rAF и при document.hidden === false. Страховочный таймер шагает сам,\n // если кадр не пришёл за 200 мс; кто сработал первым — шагает, второй отменяется.\n this._raf = requestAnimationFrame((now) => {\n clearTimeout(this._timer);\n step(now);\n });\n this._timer = setTimeout(() => {\n cancelAnimationFrame(this._raf);\n step(performance.now());\n }, 200);\n };\n schedule();\n }\n\n stop() {\n this.running = false;\n // Уже запланированный кадр отменяем явно: если start() придёт раньше, чем он сработает\n // (suspend+activate в одном кадре), он увидел бы running=true и продолжил бы СВОЮ цепочку\n // рядом с новой — симуляция пошла бы вдвое быстрее.\n cancelAnimationFrame(this._raf);\n clearTimeout(this._timer);\n this._workerStep = null;\n this._worker?.terminate();\n this._worker = null;\n if (this._workerUrl) URL.revokeObjectURL(this._workerUrl);\n this._workerUrl = null;\n }\n\n /**\n * Следующий шаг скрытой страницы — по сообщению воркера. Воркер тикает всегда, пока жив; шаг\n * берёт только один ожидающий (`_workerStep`), поэтому двойной цепочки не бывает. false —\n * воркер не завести (CSP без blob:, нет Worker) — остаётся обычный таймер.\n */\n private _startWorker(next: () => void): boolean {\n try {\n if (!this._worker) {\n const src = \"setInterval(function(){postMessage(0)},50)\";\n this._workerUrl = URL.createObjectURL(\n new Blob([src], { type: \"text/javascript\" }),\n );\n this._worker = new Worker(this._workerUrl);\n this._worker.onmessage = () => {\n const fn = this._workerStep;\n this._workerStep = null;\n fn?.();\n };\n }\n this._workerStep = next;\n return true;\n } catch {\n return false;\n }\n }\n}\n"
249
+ "content": "// Игровой цикл: rAF-рендер + аккумулятор фиксированного тика (20 TPS).\n// Симуляция (мир, мобы, вода, интенты) — на тике; всё, что касается глаза и руки\n// (камера, физика игрока, интерполяция, рендер) — на кадре.\nimport { CONFIG } from \"../config\";\n\nexport class GameLoop {\n onTick: (dt: number, n: number) => void;\n onFrame: (dt: number, alpha: number) => void;\n tickDt: number;\n acc: number;\n tickN: number;\n last: number;\n running: boolean;\n fps: number;\n /**\n * Сколько кадров отработано. Агент ждёт по нему, что нажатие увидел хотя бы один кадр: при\n * страховочном таймере (200 мс) короткий «клик» иначе проскакивал между кадрами.\n */\n frameN = 0;\n private _frames: number;\n private _fpsTime: number;\n /** id запланированного кадра: stop() обязан его отменить, см. там. */\n private _raf = 0;\n /**\n * Таймер шага для скрытой страницы. В фоне (свёрнутое превью AI-кодера, фоновая вкладка) браузер\n * не зовёт requestAnimationFrame вовсе — симуляция вставала, и действия агента (команды, ходьба)\n * висели до возврата на вкладку. Скрытая страница шагает таймером; браузер его душит до ~1 Гц,\n * но тики (maxCatchupTicks за шаг) идут.\n */\n private _timer: ReturnType<typeof setTimeout> | undefined;\n /**\n * Сетевая игра: мир общий, и скрытая вкладка хоста или владельца измерения не может считать его\n * раз в секунду (браузер душит таймеры фоновой страницы до ~1 Гц, а тиков за шаг не больше\n * maxCatchupTicks — мир у ВСЕХ шёл бы вчетверо медленнее). Таймеры выделенного воркера так не\n * душатся: в фоне шаг задаёт его сообщение раз в 50 мс. Подключает Game, пока идёт сетевая игра.\n */\n backgroundTicker: (() => boolean) | null = null;\n private _worker: Worker | null = null;\n private _workerUrl: string | null = null;\n private _workerStep: (() => void) | null = null;\n\n /**\n * @param onTick — фиксированный шаг, dt = 1/TPS\n * @param onFrame — alpha = доля тика для интерполяции\n */\n constructor(\n onTick: (dt: number, n: number) => void,\n onFrame: (dt: number, alpha: number) => void,\n ) {\n this.onTick = onTick;\n this.onFrame = onFrame;\n this.tickDt = 1 / CONFIG.tps;\n this.acc = 0;\n this.tickN = 0;\n this.last = 0;\n this.running = false;\n // FPS: скользящее окно за секунду\n this.fps = 0;\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n start() {\n this.running = true;\n this.last = performance.now();\n const step = (now: number) => {\n if (!this.running) return;\n let dt = (now - this.last) / 1000;\n this.last = now;\n if (dt > 0.25) dt = 0.25; // возврат из фона — не наращивать гигантский долг\n\n this.acc += dt;\n let ticks = 0;\n while (this.acc >= this.tickDt && ticks < CONFIG.maxCatchupTicks) {\n try {\n this.onTick(this.tickDt, this.tickN++);\n } catch (e) {\n this._report(e);\n }\n this.acc -= this.tickDt;\n ticks++;\n }\n if (ticks === CONFIG.maxCatchupTicks) this.acc = 0; // сбросить долг\n\n this._frames++;\n this.frameN++;\n this._fpsTime += dt;\n if (this._fpsTime >= 0.5) {\n this.fps = Math.round(this._frames / this._fpsTime);\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n try {\n this.onFrame(Math.min(dt, 0.05), this.acc / this.tickDt);\n } catch (e) {\n this._report(e);\n }\n schedule();\n };\n const schedule = () => {\n if (typeof document !== \"undefined\" && document.hidden) {\n if (\n this.backgroundTicker?.() &&\n this._startWorker(() => step(performance.now()))\n )\n return;\n this._timer = setTimeout(() => step(performance.now()), 50);\n return;\n }\n this._workerStep = null;\n // Видимая, но неотрисовываемая страница (панель превью за другой вкладкой, iframe вне\n // экрана) не зовёт rAF и при document.hidden === false. Страховочный таймер шагает сам,\n // если кадр не пришёл за 200 мс; кто сработал первым — шагает, второй отменяется.\n this._raf = requestAnimationFrame((now) => {\n clearTimeout(this._timer);\n step(now);\n });\n this._timer = setTimeout(() => {\n cancelAnimationFrame(this._raf);\n step(performance.now());\n }, 200);\n };\n schedule();\n }\n\n /**\n * Ошибка тика или кадра. Раньше она вылетала из step до schedule() — следующего кадра не было\n * НИКОГДА: игра застывала намертво (в сетевой игре — у хоста, и мир вставал у всех). Теперь цикл\n * идёт дальше; одна и та же ошибка пишется в консоль один раз, а не 20 раз в секунду.\n */\n private _report(e: unknown): void {\n this.errors++;\n const key = e instanceof Error ? (e.stack ?? e.message) : String(e);\n if (this._reported.has(key)) return;\n this._reported.add(key);\n console.error(\"[voxelcraft] game loop error\", e);\n }\n\n /** Сколько тиков/кадров упало с ошибкой (для отладки и тестов). */\n errors = 0;\n private readonly _reported = new Set<string>();\n\n stop() {\n this.running = false;\n // Уже запланированный кадр отменяем явно: если start() придёт раньше, чем он сработает\n // (suspend+activate в одном кадре), он увидел бы running=true и продолжил бы СВОЮ цепочку\n // рядом с новой — симуляция пошла бы вдвое быстрее.\n cancelAnimationFrame(this._raf);\n clearTimeout(this._timer);\n this._workerStep = null;\n this._worker?.terminate();\n this._worker = null;\n if (this._workerUrl) URL.revokeObjectURL(this._workerUrl);\n this._workerUrl = null;\n }\n\n /**\n * Следующий шаг скрытой страницы — по сообщению воркера. Воркер тикает всегда, пока жив; шаг\n * берёт только один ожидающий (`_workerStep`), поэтому двойной цепочки не бывает. false —\n * воркер не завести (CSP без blob:, нет Worker) — остаётся обычный таймер.\n */\n private _startWorker(next: () => void): boolean {\n try {\n if (!this._worker) {\n const src = \"setInterval(function(){postMessage(0)},50)\";\n this._workerUrl = URL.createObjectURL(\n new Blob([src], { type: \"text/javascript\" }),\n );\n this._worker = new Worker(this._workerUrl);\n this._worker.onmessage = () => {\n const fn = this._workerStep;\n this._workerStep = null;\n fn?.();\n };\n }\n this._workerStep = next;\n return true;\n } catch {\n return false;\n }\n }\n}\n"
250
250
  },
251
251
  {
252
252
  "path": "core/Intents.ts",
@@ -290,7 +290,7 @@
290
290
  },
291
291
  {
292
292
  "path": "entities/EntityManager.ts",
293
- "content": "// Менеджер сущностей: мобы, дроп-предметы, сферы опыта, стрелы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Перепись (census) держит население мобов вокруг игрока\n// по правилам Minecraft: мирные — на траве при свете, враждебные — в темноте (ночью и в пещерах).\n// Игрок хранится отдельно (Player), но интерфейс сущностей совместим — в этапе 2 удалённые игроки\n// станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { frogFor } from \"../registry/mobs/frog\";\nimport { rayBox } from \"../physics/rayBox\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport { Arrow } from \"./Arrow\";\nimport { PrimedTnt } from \"./PrimedTnt\";\nimport { Fireball, type FireballKind } from \"./Fireball\";\nimport { RimEye } from \"./RimEye\";\nimport { Minecart, type CartVariant } from \"./Minecart\";\nimport { smokedHive } from \"../world/Hive\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { Boat } from \"./Boat\";\nimport { FishingHook } from \"./FishingHook\";\nimport { REP_CURE, REP_TRADE } from \"../systems/Reputation\";\nimport type { MobCtx } from \"./MobBrain\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { ItemStack, MobDef } from \"../types\";\nimport {\n netSnapshot,\n createFromSnap,\n netSpawnFromRemote,\n netApplySpawn,\n rebase,\n netApplyPose,\n netPose,\n netRemove,\n replicaTick,\n netLocalChanges,\n netPatch,\n netKill,\n netRide,\n netDrive,\n netReleaseDriver,\n release,\n} from \"./manager/net\";\nimport { serialize, deserialize, loadOne } from \"./manager/persistence\";\nimport { census, pickType, trySpawn, canSpawn } from \"./manager/census\";\nimport {\n villagerJobs,\n villagerGolem,\n tickCuring,\n convertToZombie,\n reprofess,\n villageAnger,\n addRep,\n} from \"./manager/villagers\";\nimport { hurt, headHit, explode, mobDied, angerGroup } from \"./manager/combat\";\nimport {\n markRaiders,\n summonAtAltar,\n buildIronGolem,\n buildSnowGolem,\n spawnFangs,\n spawnWanderingTrader,\n spawnPatrol,\n layEggs,\n spawnBee,\n spawnMinecart,\n spawnBoat,\n castHook,\n playerHook,\n spawnDrop,\n takeDrop,\n ejectItem,\n shootArrow,\n spawnMob,\n spawnXp,\n spawnArrow,\n spawnFireball,\n spawnRimEye,\n spawnTnt,\n} from \"./manager/spawners\";\nimport { ctx } from \"./manager/mobContext\";\nimport type {\n Entity,\n EntitySave,\n Actor,\n NetEntitySnap,\n NetEntityAction,\n} from \"./manager/types\";\nimport { INFESTED_IDS, randInt } from \"./manager/shared\";\nimport { cartBox, cartItems, cartWork, raycastRide } from \"./manager/vehicles\";\nimport { updateVisuals, beam } from \"./manager/visuals\";\n\nexport type {\n Actor,\n Entity,\n NetEntityAction,\n NetEntitySnap,\n} from \"./manager/types\";\n\nexport class EntityManager {\n /**\n * Сетевая игра: другие игроки в этом измерении (у владельца измерения). Мобы выбирают ближайшего,\n * предмет подбирает ближайший, взрыв бьёт всех — у удалённого игрока урон уходит ему по сети.\n */\n others: () => Actor[] = () => [];\n /**\n * Реплика (net/sim): сущности живут у владельца измерения, здесь только их образ — без тика,\n * без спавна по событиям. То, что создаёт ДЕЙСТВИЕ этого игрока (бросил предмет, выстрелил),\n * уходит владельцу через `onLocalSpawn`, а удар и взрыв — через `onRemoteAction`.\n */\n replica = false;\n onLocalSpawn: ((snap: NetEntitySnap) => void) | null = null;\n onRemoteAction: ((a: NetEntityAction) => void) | null = null;\n /** Реплика: куда сущность должна прийти по последнему снимку владельца (сглаживание). */\n readonly _netTargets = new Map<\n number,\n { x: number; y: number; z: number; yaw: number }\n >();\n /** Идёт применение сетевого снимка: спавн регистрируется здесь, а не уходит владельцу. */\n _netApplying = false;\n /** Сетевая игра: слот своего игрока (поводок держит конкретный игрок); null — одиночная игра. */\n localSlot: number | null = null;\n /** Реплика: состояние взаимодействия образов по последнему слову владельца (см. interactState). */\n readonly _netBase = new Map<number, string>();\n /** Реплика: сущность, в которой сидит свой игрок, — её ведёт он сам, позы владельца не слушает. */\n drivenId: number | null = null;\n /** Владелец: сущности, которые ведут игроки-реплики (не тикают здесь): id → слот и время позы. */\n readonly _netDriven = new Map<number, { slot: number; t: number }>();\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Entity>;\n _nextId: number;\n _spawnTimer: number;\n /** На сколько уровней ночь гасит небесный свет и день ли сейчас — задаёт Game из DayNight. */\n skyDarken = 0;\n isDay = true;\n /** Идёт дождь (Game из systems/Weather). */\n raining = false;\n /** Игрок не спал три ночи — прилетают фантомы (Game.daysAwake). */\n insomnia = false;\n /** Секунд до следующего патруля мародёров. */\n _patrolT = 300 + Math.random() * 300;\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();\n this._nextId = 1;\n this._spawnTimer = 0;\n // TNT, подожжённый огнём (поведение огня не знает про сущности — просит событием)\n events.on(\n \"tntPrime\",\n (d: { x: number; y: number; z: number; fuse?: number }) =>\n this.spawnTnt(d.x + 0.5, d.y, d.z + 0.5, d.fuse ?? 4),\n );\n // вагонетку поставили на рельс (предмет minecart — world/behaviours/rails)\n events.on(\n \"spawnMinecart\",\n (d: { x: number; y: number; z: number; variant?: CartVariant }) =>\n this.spawnMinecart(d.x + 0.5, d.y, d.z + 0.5, d.variant),\n );\n events.on(\n \"spawnBoat\",\n (d: { x: number; y: number; z: number; wood: string }) =>\n this.spawnBoat(d.x + 0.5, d.y, d.z + 0.5, d.wood),\n );\n // из заражённого блока вылезает чешуйница (registry/blocks/infested)\n events.on(\n \"blockBreak\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (INFESTED_IDS.has(d.id) && MOBS.silverfish)\n this.spawnMob(\"silverfish\", d.x + 0.5, d.y, d.z + 0.5);\n },\n );\n // мёд собрали — пчёлы в 16 блоках от улья злятся\n events.on(\"hiveHarvest\", (d: { x: number; y: number; z: number }) => {\n if (smokedHive(this.world, d.x, d.y, d.z)) return; // дым костра под ульем их успокаивает\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e.def.bee &&\n !e.dead &&\n e.pos.distanceTo(new THREE.Vector3(d.x, d.y, d.z)) < 16\n )\n e.aggro = true;\n });\n // черепашье яйцо вылупилось (world/behaviours/ocean); выросший черепашонок роняет щиток\n events.on(\"turtleHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.turtle) this.spawnMob(\"turtle\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n events.on(\"mobGrew\", ({ entity }: { entity: Mob }) => {\n const item = entity.def.growDrop;\n if (item && ITEMS[item])\n this.spawnDrop(entity.pos.x, entity.pos.y + 0.3, entity.pos.z, item, 1);\n });\n // яйцо нюхача треснуло — детёныш нюхача\n events.on(\"snifferHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.sniffer)\n this.spawnMob(\"sniffer\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n // лягушачья икра — 2–6 головастиков в воду под ней; головастик вырос — лягушка по биому\n events.on(\"frogspawnHatch\", (d: { x: number; y: number; z: number }) => {\n if (!MOBS.tadpole) return;\n for (let i = randInt(2, 6); i > 0; i--)\n this.spawnMob(\n \"tadpole\",\n d.x + 0.3 + Math.random() * 0.4,\n d.y - 0.8,\n d.z + 0.3 + Math.random() * 0.4,\n );\n });\n events.on(\"mobMature\", ({ entity }: { entity: Mob }) => {\n const into =\n entity.def.growsInto === \"frog\"\n ? frogFor(this.world.generator.biomeOf?.(entity.pos.x, entity.pos.z))\n : entity.def.growsInto;\n if (!into || !MOBS[into] || entity.dead) return;\n this.spawnMob(into, entity.pos.x, entity.pos.y, entity.pos.z);\n entity.dead = true; // hp > 0 — без дропа: превратился\n });\n // третий пепельный череп на Т из песка душ — Пепельный владыка (entities/AshLord)\n events.on(\n \"blockPlace\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (d.id === B.ash_skull) this.summonAtAltar(d.x, d.y, d.z);\n if (d.id === B.pumpkin)\n // тыква достраивает снеговика или железного голема (systems/Golem)\n if (!this.buildSnowGolem(d.x, d.y, d.z))\n this.buildIronGolem(d.x, d.y, d.z);\n },\n );\n // ручные волки: на того, кого бьёт игрок, и на того, кто бьёт игрока\n events.on(\"playerHitMob\", ({ entity }: { entity: Mob }) => {\n this.guard(entity);\n this.villageAnger(entity);\n });\n // деревня помнит добро: торговля и вылеченный зомби-житель поднимают репутацию\n events.on(\"trade\", () => this._addRep(REP_TRADE));\n events.on(\"villagerCured\", () => this._addRep(REP_CURE));\n // колокол: налётчики вокруг светятся, как в Minecraft\n events.on(\"bellRing\", (p: { x: number; y: number; z: number }) =>\n this.markRaiders(p.x, p.y, p.z),\n );\n events.on(\"mobAttack\", ({ entity }: { entity: Mob }) => this.guard(entity));\n }\n\n /**\n * Единственная точка регистрации новой сущности. У реплики (net/sim) сущность, созданная\n * действием игрока, здесь НЕ живёт: после того как вызвавший её донастроит (скорость брошенного\n * предмета, эффект стрелы — это делается сразу после spawn*), её снимок уходит владельцу\n * измерения, а тот разошлёт настоящую. Исключения живут у реплики сами: поплавок (рыбалка —\n * своя) и жемчуг Предела (переносит бросившего, а не владельца измерения).\n */\n _register<T extends Entity>(e: T, ...objects: THREE.Object3D[]): T {\n const ownLocal =\n e.kind === \"hook\" || (e.kind === \"fireball\" && e.ballKind === \"pearl\");\n if (this.replica && !this._netApplying && !ownLocal) {\n queueMicrotask(() => {\n const snap = this.netSnapshot(e);\n if (snap) this.onLocalSpawn?.(snap);\n });\n return e;\n }\n this.entities.set(e.id, e);\n if (objects.length) this.scene.add(...objects);\n return e;\n }\n\n /** Убрать всех враждебных мобов (переход на мирную сложность — systems/Difficulty). */\n clearHostiles(): void {\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.category === \"hostile\" && !e.def.boss)\n e.dead = true; // hp > 0 — исчезают без дропа, как при удалении игрока\n }\n\n /** Сдвинуть репутацию игрока в деревне и сказать об этом HUD. */\n _addRep(by: number): void {\n addRep(this, by);\n }\n\n /**\n * Ударили жителя: репутация падает, а железные големы рядом бросаются на обидчика — как в\n * Minecraft, где деревня помнит зло (systems/Reputation).\n */\n villageAnger(target: Mob): void {\n villageAnger(this, target);\n }\n\n /** Звон колокола: налётчики в 32 блоках светятся 3 секунды (видно сквозь стены). */\n markRaiders(x: number, y: number, z: number): void {\n markRaiders(this, x, y, z);\n }\n\n /** Череп в (x,y,z) достроил алтарь — блоки исчезают, встаёт владыка и набирает силу. */\n summonAtAltar(x: number, y: number, z: number): Mob | null {\n return summonAtAltar(this, x, y, z);\n }\n\n /** Железный голем: тыква на фигуре из четырёх железных блоков — блоки исчезают, встаёт голем. */\n buildIronGolem(x: number, y: number, z: number): Mob | null {\n return buildIronGolem(this, x, y, z);\n }\n\n /** Снеговик: тыква на двух блоках снега — блоки исчезают, на их месте снеговик. */\n buildSnowGolem(x: number, y: number, z: number): Mob | null {\n return buildSnowGolem(this, x, y, z);\n }\n\n /** Клыки ворожея: 16 штук по прямой от from к to через 1.25 блока, каждый позже предыдущего. */\n spawnFangs(from: THREE.Vector3, to: THREE.Vector3, ownerId: number): void {\n spawnFangs(this, from, to, ownerId);\n }\n\n /** До следующей попытки прихода странствующего торговца, сек. */\n _traderT = 1200;\n /** Перезарядка вызова железного голема деревней (systems/Golem). */\n _golemT = 0;\n\n /** Странствующий торговец с двумя ламами на поводке — на суше в 16–32 блоках от игрока. */\n spawnWanderingTrader(): Mob | null {\n return spawnWanderingTrader(this);\n }\n\n /** Патруль мародёров: 2–5 во главе с капитаном на суше в 24–48 блоках от игрока. */\n spawnPatrol(): number {\n return spawnPatrol(this);\n }\n\n /** Черепаха после размножения откладывает 1–4 яйца на песок рядом. */\n layEggs(m: Mob): number {\n return layEggs(this, m);\n }\n\n /** Вода в квадрате r вокруг клетки (на её высоте и выше на блок) — пляж, а не пустыня. */\n /** Есть ли блок id в кубе радиуса r (аксолотль — мох пышных пещер рядом с озером). */\n _blockNear(\n x: number,\n y: number,\n z: number,\n id: number | undefined,\n r: number,\n ): boolean {\n if (id === undefined) return false;\n for (let dy = -r; dy <= r; dy++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === id) return true;\n return false;\n }\n\n _waterNear(x: number, y: number, z: number, r: number): boolean {\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n for (let dy = 0; dy <= 1; dy++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === B.water)\n return true;\n return false;\n }\n\n /** Выпустить пчелу из улья (x,y,z) в свободную соседнюю клетку; false — места нет. */\n spawnBee(x: number, y: number, z: number): boolean {\n return spawnBee(this, x, y, z);\n }\n\n /** Ручные волки рядом с игроком (24 блока) бросаются на `target`. */\n guard(target: Mob): void {\n if (target.tamed || target.dead) return;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e !== target &&\n e.tamed &&\n e.def.guards &&\n !e.sitting &&\n !e.dead &&\n e.pos.distanceTo(this.player.pos) < 24\n )\n e.targetId = target.id;\n }\n\n /** Груз вагонетки как контейнер (воронка под рельсом и в вагонетку, экран сундука). */\n /** Груз вагонетки или вьюк моба — как контейнер для экрана сундука. */\n cartBox(c: Minecart | Mob): BlockEntity {\n return cartBox(this, c);\n }\n\n /** Вагонетка с грузом в клетке (x, y, z) — как контейнер; null — нет такой. */\n cartItems(x: number, y: number, z: number): BlockEntity | null {\n return cartItems(this, x, y, z);\n }\n\n /**\n * Работа вагонетки за тик: динамит взрывается, запитанные активирующие рельсы высаживают седока и\n * запирают воронку; вагонетка с воронкой берёт из контейнера над собой или лежащие рядом предметы.\n */\n _cartWork(c: Minecart, dt: number): void {\n cartWork(this, c, dt);\n }\n\n spawnMinecart(\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n ): Minecart {\n return spawnMinecart(this, x, y, z, variant);\n }\n\n spawnBoat(x: number, y: number, z: number, wood: string): Boat {\n return spawnBoat(this, x, y, z, wood);\n }\n\n /** Забросить поплавок игрока (прежний, если был, исчезает). */\n castHook(pos: THREE.Vector3, vel: THREE.Vector3): FishingHook {\n return castHook(this, pos, vel);\n }\n\n /** Поплавок игрока в мире или null. */\n playerHook(): FishingHook | null {\n return playerHook(this);\n }\n\n /** Луч по транспорту (вагонетки, лодки): ближайший и расстояние до него. */\n raycastRide(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): { cart: Minecart | Boat; t: number } | null {\n return raycastRide(this, origin, dir, maxDist);\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n dmg?: number,\n tag?: Record<string, unknown>,\n ): ItemDrop {\n return spawnDrop(this, x, y, z, item, count, dmg, tag);\n }\n\n /** Воронка: забрать из лежащего в коробке дропа один предмет, если accept его взял. */\n takeDrop(\n x0: number,\n y0: number,\n z0: number,\n x1: number,\n y1: number,\n z1: number,\n accept: (s: ItemStack) => boolean,\n ): boolean {\n return takeDrop(this, x0, y0, z0, x1, y1, z1, accept);\n }\n\n /** Выбрасыватель: стак со скоростью, поднять можно не сразу. */\n ejectItem(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n s: ItemStack,\n ): void {\n ejectItem(this, x, y, z, vx, vy, vz, s);\n }\n\n /** Раздатчик: стрела без стрелка; отравленная несёт свой эффект (systems/TippedArrow). */\n shootArrow(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n item = \"arrow\",\n ): void {\n shootArrow(this, x, y, z, vx, vy, vz, item);\n }\n\n spawnMob(type: string, x: number, y: number, z: number, baby = false): Mob {\n return spawnMob(this, type, x, y, z, baby);\n }\n\n /** Опыт сферами (крупные номиналы, как в Minecraft), не больше 10 сфер. */\n spawnXp(x: number, y: number, z: number, amount: number): void {\n spawnXp(this, x, y, z, amount);\n }\n\n spawnArrow(\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n fromPlayer: boolean,\n shooterId: number,\n ): Arrow {\n return spawnArrow(this, pos, vel, damage, fromPlayer, shooterId);\n }\n\n /** Огненный шар моба Пекла. */\n spawnFireball(\n kind: FireballKind,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n shooterId: number,\n ): Fireball {\n return spawnFireball(this, kind, pos, vel, damage, shooterId);\n }\n\n /** Брошенное Око Предела — летит к цели (цитадели). */\n spawnRimEye(from: THREE.Vector3, target: { x: number; z: number }): RimEye {\n return spawnRimEye(this, from, target);\n }\n\n /** Ударили моба со стайной злостью — злятся все такие же в 32 блоках. */\n _angerGroup(m: Mob): void {\n angerGroup(this, m);\n }\n\n /** Взведённая взрывчатка (огниво, огонь, соседний взрыв). */\n spawnTnt(x: number, y: number, z: number, fuse: number): PrimedTnt {\n return spawnTnt(this, x, y, z, fuse);\n }\n\n /**\n * Удар игрока по мобу. `attacker` — кто бил (сетевая игра: удалённый игрок; по умолчанию свой).\n * У реплики (net/sim) удар уходит владельцу измерения — моб живёт там.\n */\n hurt(\n entityId: number,\n damage: number,\n fromPos: THREE.Vector3,\n attacker: Player = this.player,\n ): void {\n hurt(this, entityId, damage, fromPos, attacker);\n }\n\n /** Луч взгляда игрока проходит у головы дракона (перед центром, на половине ширины). */\n _headHit(m: Mob, who: Player = this.player): boolean {\n return headHit(this, m, who);\n }\n\n /** Взрыв силы power: блоки (дроп с шансом 1/сила), урон и отброс игроку, мобам и предметам. */\n explode(x: number, y: number, z: number, power: number): void {\n explode(this, x, y, z, power);\n }\n\n _ctx(): MobCtx {\n return ctx(this);\n }\n\n /** Все игроки этого измерения: свой и удалённые (сетевая игра). */\n actors(): Actor[] {\n const own: Actor = { player: this.player, inventory: this.inventory };\n const rest = this.others();\n return rest.length ? [own, ...rest] : [own];\n }\n\n /**\n * Ближайший к точке игрок: мобы целятся в него, предмет летит к нему. Живые впереди мёртвых —\n * иначе моб «застревал» бы на трупе, пока рядом бегает живой.\n */\n nearestActor(pos: { x: number; y: number; z: number }): Actor {\n const list = this.actors();\n if (list.length === 1) return list[0]!;\n let best = list[0]!,\n bd = Infinity;\n for (const a of list) {\n const p = a.player.pos;\n const d =\n (p.x - pos.x) ** 2 +\n (p.y - pos.y) ** 2 +\n (p.z - pos.z) ** 2 +\n (a.player.dead ? 1e12 : 0);\n if (d < bd) {\n bd = d;\n best = a;\n }\n }\n return best;\n }\n\n /**\n * Игрок для сущности: моба на поводке ведёт ТОТ, кто держит поводок (у двух игроков рядом моб\n * иначе шёл бы к ближайшему); остальным — ближайший.\n */\n private _actorFor(e: Entity): Actor {\n if (\n e.kind === \"mob\" &&\n e.leashed &&\n !e.leashPost &&\n e.leashMobId === null &&\n e.leashSlot !== null\n ) {\n if (e.leashSlot === this.localSlot)\n return { player: this.player, inventory: this.inventory };\n const held = this.others().find(\n (o) => (o.player as Player & { slot?: number }).slot === e.leashSlot,\n );\n if (held) return held;\n }\n return this.nearestActor(e.pos);\n }\n\n tick(dt: number): void {\n if (this.replica) {\n this._replicaTick(dt);\n return;\n }\n const ctx = this._ctx();\n const ownHeld = ctx.heldItem;\n const multi = this.others().length > 0;\n const mobs = [...this.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\",\n );\n const now = performance.now();\n for (const e of this.entities.values()) {\n // её ведёт игрок-реплика: позу шлёт он (netDrive); замолчал 3 с — снова наша\n const drv = this._netDriven.get(e.id);\n if (drv && !e.dead) {\n if (now - drv.t < 3000) continue;\n this._release(e.id);\n }\n const a = multi\n ? this._actorFor(e)\n : { player: this.player, inventory: this.inventory };\n if (e.kind === \"drop\") e.tick(dt, this.world, a.player, a.inventory);\n else if (e.kind === \"mob\") {\n if (multi) {\n ctx.player = a.player;\n ctx.heldItem =\n a.player === this.player\n ? ownHeld\n : (a.inventory.heldItem()?.item ?? null);\n }\n e.tick(dt, ctx);\n if (e.angerCall) this._angerGroup(e);\n } else if (e.kind === \"xp\") e.tick(dt, this.world, a.player);\n else if (e.kind === \"tnt\")\n e.tick(dt, this.world, (x, y, z, p) => this.explode(x, y, z, p));\n else if (e.kind === \"fireball\")\n e.tick(\n dt,\n this.world,\n a.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n mobs,\n );\n else if (e.kind === \"minecart\" || e.kind === \"boat\") {\n e.tick(dt, this.world);\n if (e.kind === \"minecart\") this._cartWork(e, dt);\n } else if (e.kind === \"hook\")\n e.tick(\n dt,\n this.world,\n this.player.pos,\n this.raining,\n this.inventory.heldItem()?.item === \"fishing_rod\",\n );\n else if (e.kind === \"rimeye\") {\n // долетело: в 80% падает предметом, иначе разбивается\n if (e.tick(dt)) {\n if (Math.random() < 0.8)\n this.spawnDrop(e.pos.x, e.pos.y, e.pos.z, \"rim_eye\", 1);\n else\n events.emit(\"eyeShatter\", { x: e.pos.x, y: e.pos.y, z: e.pos.z });\n }\n } else e.tick(dt, this.world, a.player, mobs, a.inventory);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0) this._mobDied(e);\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Смерть моба: дроп по таблице (у детёнышей нет) и опыт, если бил игрок. */\n _mobDied(m: Mob): void {\n mobDied(this, m);\n }\n\n /**\n * Профессия от рабочего блока (systems/Jobs), раз в секунду: безработный житель занимает свободный\n * блок рядом и становится мастером; у кого рабочий блок сломали — теряет профессию, если ни разу\n * не торговал. Житель из деревни рабочего места не имеет и профессию не теряет, как в Minecraft.\n */\n _villagerJobs(): void {\n villagerJobs(this);\n }\n\n /**\n * Деревня зовёт голема (systems/Golem): рядом с жителями враждебные (ночная осада) — раз в\n * GOLEM_COOLDOWN секунд встаёт один железный голем. Так деревня отвечает на осаду сама.\n */\n _villagerGolem(): void {\n villagerGolem(this);\n }\n\n /** Лечение зомби-жителя (systems/Zombify): раз в секунду, на нуле — обратно в свою профессию. */\n _tickCuring(): void {\n tickCuring(this);\n }\n\n /**\n * Житель, убитый зомби, встаёт зомби-жителем и помнит профессию (systems/Zombify).\n * Зовёт мозг зомби через MobCtx.convert.\n */\n convertToZombie(v: Mob): Mob {\n return convertToZombie(this, v);\n }\n\n /** Тот же житель на том же месте, но другим типом моба: типы жителей — по профессии. */\n _reprofess(v: Mob, type: string, site: [number, number, number] | null): Mob {\n return reprofess(this, v, type, site);\n }\n\n /** Перепись: враждебные вдали исчезают, население добирается попытками спавна. */\n _census(dt: number): void {\n census(this, dt);\n }\n\n _pickType(light: \"bright\" | \"dark\"): MobDef | null {\n return pickType(this, light);\n }\n\n _trySpawn(\n light: \"bright\" | \"dark\",\n p: THREE.Vector3 = this.player.pos,\n ): number {\n return trySpawn(this, light, p);\n }\n\n /** Место для спавна: пол непрозрачный, над ним воздух на рост, свет по правилу вида. */\n _canSpawn(def: MobDef, x: number, y: number, z: number): boolean {\n return canSpawn(this, def, x, y, z);\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 || e.rider) continue; // лошадь под игроком лучу не мешает\n const w = e.width / 2;\n const t = rayBox(\n origin,\n dir,\n bestT,\n { x: e.pos.x - w, y: e.pos.y, z: e.pos.z - w },\n { x: e.pos.x + w, y: e.pos.y + e.height, z: e.pos.z + w },\n );\n if (t !== null && t < bestT) {\n bestT = t;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n updateVisuals(this, alpha, time);\n }\n\n /** Луч от лечащего Кристалла Предела к дракону. */\n beamLine: THREE.Line | null = null;\n /** Движущихся сущностей в области теней на прошлом кадре (Game решает, перерисовывать ли тени). */\n shadowCasters = 0;\n\n _beam(): void {\n beam(this);\n }\n\n clear(): void {\n this._netBase.clear();\n this._netDriven.clear();\n this.drivenId = null;\n // игрок сидел в вагонетке/лодке этого мира — ссаживаем без телепорта: иначе Player.update\n // каждый кадр ставил бы его к выброшенной сущности (после портала — в координаты прошлого\n // измерения), а «присесть» высаживало бы туда же\n const ride = this.player?.riding;\n if (ride) {\n ride.rider = false;\n this.player.riding = null;\n }\n // выброшенные сущности — мёртвые для всех, кто держит на них ссылку (экран торговли с жителем,\n // груз вагонетки, поводок): иначе после портала экран жил бы с призраком из прошлого измерения\n for (const e of this.entities.values()) {\n e.dead = true;\n e.dispose(this.scene);\n }\n this.entities.clear();\n if (this.beamLine) this.beamLine.visible = false;\n }\n\n serialize() {\n return serialize(this);\n }\n\n deserialize(list?: EntitySave[]): void {\n deserialize(this, list);\n }\n\n /** Одна сохранённая сущность → живая (сейв, Мастерская, сетевой снимок). null — запись негодна. */\n _loadOne(d: EntitySave): Entity | null {\n return loadOne(this, d);\n }\n\n /**\n * Сущность → снимок для другого игрока. null — вид в сети не живёт (поплавок и клыки — локальные\n * и короткие, Око Предела — чистая анимация у бросившего).\n */\n netSnapshot(e: Entity): NetEntitySnap | null {\n return netSnapshot(this, e);\n }\n\n /** Снимок → живая сущность этого менеджера (id — свой). null — снимок негоден. */\n _createFromSnap(s: NetEntitySnap): Entity | null {\n return createFromSnap(this, s);\n }\n\n /**\n * Владелец измерения: реплика попросила создать сущность (бросила предмет, выстрелила). Создаётся\n * по обычным правилам — с id этого менеджера, и разойдётся всем обычным снимком.\n */\n netSpawnFromRemote(s: NetEntitySnap): Entity | null {\n return netSpawnFromRemote(this, s);\n }\n\n /** Реплика: сущность владельца с ЕГО id (повтор того же id — замена: сменился вид). */\n netApplySpawn(id: number, s: NetEntitySnap): void {\n netApplySpawn(this, id, s);\n }\n\n /** Реплика: запомнить состояние образа как «слово владельца» (изменения от него — не наши). */\n _rebase(e: Entity): void {\n rebase(this, e);\n }\n\n /**\n * Реплика: поза по снимку владельца. Сущность идёт к ней сглаженно (_replicaTick), а флаги вида\n * применяются сразу. Незнакомый id — пропуск: снимок появления придёт надёжным каналом.\n */\n netApplyPose(\n id: number,\n x: number,\n y: number,\n z: number,\n yaw: number,\n flags: number,\n extra: number,\n ): void {\n netApplyPose(this, id, x, y, z, yaw, flags, extra);\n }\n\n /** Поза сущности для снимка: флаги вида (см. netApplyPose) и «лишний» байт (фитиль/раскрытие). */\n netPose(e: Entity): { yaw: number; flags: number; extra: number } {\n return netPose(this, e);\n }\n\n /** Реплика: сущность ушла у владельца (умерла, подобрана, вышла из зоны видимости). */\n netRemove(id: number): void {\n netRemove(this, id);\n }\n\n /**\n * Тик реплики: никакой симуляции — только ход к позе из последнего снимка (снимки приходят 10 раз\n * в секунду, тик — 20: без сглаживания мобы двигались бы рывками). Свои локальные сущности\n * (поплавок, жемчуг) тикают по-настоящему.\n */\n _replicaTick(dt: number): void {\n replicaTick(this, dt);\n }\n\n /**\n * Реплика: что игрок поменял в сущностях своими действиями с прошлого тика — только изменившиеся\n * поля. Зовётся в конце тика (NetGame.afterTick): между изменением и сравнением снимки владельца\n * прийти не могут, так что всё отличие от «слова владельца» — наше.\n */\n netLocalChanges(): NetEntityAction[] {\n return netLocalChanges(this);\n }\n\n /** Владелец: правка сущности игроком-реплики (см. interactState). `slot` — кто правил. */\n netPatch(id: number, f: Record<string, unknown>, slot?: number): void {\n netPatch(this, id, f, slot);\n }\n\n /** Владелец: образ сущности погиб у реплики — с дропом (убили) или без (исчезла, превратилась). */\n netKill(id: number, drops: boolean): void {\n netKill(this, id, drops);\n }\n\n /**\n * Владелец: игрок-реплика сел (on) или слез. Пока сидит, сущность здесь не тикает — её ведёт он.\n * @returns false — место занято (сел кто-то другой, или сидит свой игрок)\n */\n netRide(id: number, slot: number, on: boolean): boolean {\n return netRide(this, id, slot, on);\n }\n\n /** Владелец: поза сущности, которую ведёт реплика (чужая поза той же сущности — мимо). */\n netDrive(\n id: number,\n slot: number,\n pos: readonly number[],\n vel: readonly number[],\n yaw: number,\n ): void {\n netDrive(this, id, slot, pos, vel, yaw);\n }\n\n /** Владелец: игрок ушёл (из измерения, из игры) — всё, что он вёл, снова симулируется здесь. */\n netReleaseDriver(slot: number): void {\n netReleaseDriver(this, slot);\n }\n\n _release(id: number): void {\n release(this, id);\n }\n}\n"
293
+ "content": "// Менеджер сущностей: мобы, дроп-предметы, сферы опыта, стрелы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Перепись (census) держит население мобов вокруг игрока\n// по правилам Minecraft: мирные — на траве при свете, враждебные — в темноте (ночью и в пещерах).\n// Игрок хранится отдельно (Player), но интерфейс сущностей совместим — в этапе 2 удалённые игроки\n// станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { frogFor } from \"../registry/mobs/frog\";\nimport { rayBox } from \"../physics/rayBox\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport { Arrow } from \"./Arrow\";\nimport { PrimedTnt } from \"./PrimedTnt\";\nimport { Fireball, type FireballKind } from \"./Fireball\";\nimport { RimEye } from \"./RimEye\";\nimport { Minecart, type CartVariant } from \"./Minecart\";\nimport { smokedHive } from \"../world/Hive\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { Boat } from \"./Boat\";\nimport { FishingHook } from \"./FishingHook\";\nimport { REP_CURE, REP_TRADE } from \"../systems/Reputation\";\nimport type { MobCtx } from \"./MobBrain\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { ItemStack, MobDef } from \"../types\";\nimport {\n netSnapshot,\n createFromSnap,\n netSpawnFromRemote,\n netApplySpawn,\n rebase,\n netApplyPose,\n netPose,\n netRemove,\n replicaTick,\n netLocalChanges,\n netPatch,\n netKill,\n netRide,\n netDrive,\n netReleaseDriver,\n release,\n} from \"./manager/net\";\nimport { serialize, deserialize, loadOne } from \"./manager/persistence\";\nimport { census, pickType, trySpawn, canSpawn } from \"./manager/census\";\nimport {\n villagerJobs,\n villagerGolem,\n tickCuring,\n convertToZombie,\n reprofess,\n villageAnger,\n addRep,\n} from \"./manager/villagers\";\nimport { hurt, headHit, explode, mobDied, angerGroup } from \"./manager/combat\";\nimport {\n markRaiders,\n summonAtAltar,\n buildIronGolem,\n buildSnowGolem,\n spawnFangs,\n spawnWanderingTrader,\n spawnPatrol,\n layEggs,\n spawnBee,\n spawnMinecart,\n spawnBoat,\n castHook,\n playerHook,\n spawnDrop,\n takeDrop,\n ejectItem,\n shootArrow,\n spawnMob,\n spawnXp,\n spawnArrow,\n spawnFireball,\n spawnRimEye,\n spawnTnt,\n} from \"./manager/spawners\";\nimport { ctx } from \"./manager/mobContext\";\nimport type {\n Entity,\n EntitySave,\n Actor,\n NetEntitySnap,\n NetEntityAction,\n} from \"./manager/types\";\nimport { INFESTED_IDS, randInt } from \"./manager/shared\";\nimport { cartBox, cartItems, cartWork, raycastRide } from \"./manager/vehicles\";\nimport { updateVisuals, beam } from \"./manager/visuals\";\n\n/** Ошибки тика сущностей — в консоль по одному разу на вид и текст, а не 20 раз в секунду. */\nconst reportedEntityErrors = new Set<string>();\nfunction reportEntityError(kind: string, err: unknown): void {\n const key = kind + \":\" + (err instanceof Error ? err.message : String(err));\n if (reportedEntityErrors.has(key)) return;\n reportedEntityErrors.add(key);\n console.error(`[voxelcraft] entity tick error (${kind})`, err);\n}\n\nexport type {\n Actor,\n Entity,\n NetEntityAction,\n NetEntitySnap,\n} from \"./manager/types\";\n\nexport class EntityManager {\n /**\n * Сетевая игра: другие игроки в этом измерении (у владельца измерения). Мобы выбирают ближайшего,\n * предмет подбирает ближайший, взрыв бьёт всех — у удалённого игрока урон уходит ему по сети.\n */\n others: () => Actor[] = () => [];\n /**\n * Реплика (net/sim): сущности живут у владельца измерения, здесь только их образ — без тика,\n * без спавна по событиям. То, что создаёт ДЕЙСТВИЕ этого игрока (бросил предмет, выстрелил),\n * уходит владельцу через `onLocalSpawn`, а удар и взрыв — через `onRemoteAction`.\n */\n replica = false;\n onLocalSpawn: ((snap: NetEntitySnap) => void) | null = null;\n onRemoteAction: ((a: NetEntityAction) => void) | null = null;\n /** Реплика: куда сущность должна прийти по последнему снимку владельца (сглаживание). */\n readonly _netTargets = new Map<\n number,\n { x: number; y: number; z: number; yaw: number }\n >();\n /** Идёт применение сетевого снимка: спавн регистрируется здесь, а не уходит владельцу. */\n _netApplying = false;\n /** Сетевая игра: слот своего игрока (поводок держит конкретный игрок); null — одиночная игра. */\n localSlot: number | null = null;\n /** Реплика: состояние взаимодействия образов по последнему слову владельца (см. interactState). */\n readonly _netBase = new Map<number, string>();\n /** Реплика: сущность, в которой сидит свой игрок, — её ведёт он сам, позы владельца не слушает. */\n drivenId: number | null = null;\n /** Владелец: сущности, которые ведут игроки-реплики (не тикают здесь): id → слот и время позы. */\n readonly _netDriven = new Map<number, { slot: number; t: number }>();\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Entity>;\n _nextId: number;\n _spawnTimer: number;\n /** На сколько уровней ночь гасит небесный свет и день ли сейчас — задаёт Game из DayNight. */\n skyDarken = 0;\n isDay = true;\n /** Идёт дождь (Game из systems/Weather). */\n raining = false;\n /** Игрок не спал три ночи — прилетают фантомы (Game.daysAwake). */\n insomnia = false;\n /** Секунд до следующего патруля мародёров. */\n _patrolT = 300 + Math.random() * 300;\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();\n this._nextId = 1;\n this._spawnTimer = 0;\n // TNT, подожжённый огнём (поведение огня не знает про сущности — просит событием)\n events.on(\n \"tntPrime\",\n (d: { x: number; y: number; z: number; fuse?: number }) =>\n this.spawnTnt(d.x + 0.5, d.y, d.z + 0.5, d.fuse ?? 4),\n );\n // вагонетку поставили на рельс (предмет minecart — world/behaviours/rails)\n events.on(\n \"spawnMinecart\",\n (d: { x: number; y: number; z: number; variant?: CartVariant }) =>\n this.spawnMinecart(d.x + 0.5, d.y, d.z + 0.5, d.variant),\n );\n events.on(\n \"spawnBoat\",\n (d: { x: number; y: number; z: number; wood: string }) =>\n this.spawnBoat(d.x + 0.5, d.y, d.z + 0.5, d.wood),\n );\n // из заражённого блока вылезает чешуйница (registry/blocks/infested)\n events.on(\n \"blockBreak\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (INFESTED_IDS.has(d.id) && MOBS.silverfish)\n this.spawnMob(\"silverfish\", d.x + 0.5, d.y, d.z + 0.5);\n },\n );\n // мёд собрали — пчёлы в 16 блоках от улья злятся\n events.on(\"hiveHarvest\", (d: { x: number; y: number; z: number }) => {\n if (smokedHive(this.world, d.x, d.y, d.z)) return; // дым костра под ульем их успокаивает\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e.def.bee &&\n !e.dead &&\n e.pos.distanceTo(new THREE.Vector3(d.x, d.y, d.z)) < 16\n )\n e.aggro = true;\n });\n // черепашье яйцо вылупилось (world/behaviours/ocean); выросший черепашонок роняет щиток\n events.on(\"turtleHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.turtle) this.spawnMob(\"turtle\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n events.on(\"mobGrew\", ({ entity }: { entity: Mob }) => {\n const item = entity.def.growDrop;\n if (item && ITEMS[item])\n this.spawnDrop(entity.pos.x, entity.pos.y + 0.3, entity.pos.z, item, 1);\n });\n // яйцо нюхача треснуло — детёныш нюхача\n events.on(\"snifferHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.sniffer)\n this.spawnMob(\"sniffer\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n // лягушачья икра — 2–6 головастиков в воду под ней; головастик вырос — лягушка по биому\n events.on(\"frogspawnHatch\", (d: { x: number; y: number; z: number }) => {\n if (!MOBS.tadpole) return;\n for (let i = randInt(2, 6); i > 0; i--)\n this.spawnMob(\n \"tadpole\",\n d.x + 0.3 + Math.random() * 0.4,\n d.y - 0.8,\n d.z + 0.3 + Math.random() * 0.4,\n );\n });\n events.on(\"mobMature\", ({ entity }: { entity: Mob }) => {\n const into =\n entity.def.growsInto === \"frog\"\n ? frogFor(this.world.generator.biomeOf?.(entity.pos.x, entity.pos.z))\n : entity.def.growsInto;\n if (!into || !MOBS[into] || entity.dead) return;\n this.spawnMob(into, entity.pos.x, entity.pos.y, entity.pos.z);\n entity.dead = true; // hp > 0 — без дропа: превратился\n });\n // третий пепельный череп на Т из песка душ — Пепельный владыка (entities/AshLord)\n events.on(\n \"blockPlace\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (d.id === B.ash_skull) this.summonAtAltar(d.x, d.y, d.z);\n if (d.id === B.pumpkin)\n // тыква достраивает снеговика или железного голема (systems/Golem)\n if (!this.buildSnowGolem(d.x, d.y, d.z))\n this.buildIronGolem(d.x, d.y, d.z);\n },\n );\n // ручные волки: на того, кого бьёт игрок, и на того, кто бьёт игрока\n events.on(\"playerHitMob\", ({ entity }: { entity: Mob }) => {\n this.guard(entity);\n this.villageAnger(entity);\n });\n // деревня помнит добро: торговля и вылеченный зомби-житель поднимают репутацию\n events.on(\"trade\", () => this._addRep(REP_TRADE));\n events.on(\"villagerCured\", () => this._addRep(REP_CURE));\n // колокол: налётчики вокруг светятся, как в Minecraft\n events.on(\"bellRing\", (p: { x: number; y: number; z: number }) =>\n this.markRaiders(p.x, p.y, p.z),\n );\n events.on(\"mobAttack\", ({ entity }: { entity: Mob }) => this.guard(entity));\n }\n\n /**\n * Единственная точка регистрации новой сущности. У реплики (net/sim) сущность, созданная\n * действием игрока, здесь НЕ живёт: после того как вызвавший её донастроит (скорость брошенного\n * предмета, эффект стрелы — это делается сразу после spawn*), её снимок уходит владельцу\n * измерения, а тот разошлёт настоящую. Исключения живут у реплики сами: поплавок (рыбалка —\n * своя) и жемчуг Предела (переносит бросившего, а не владельца измерения).\n */\n _register<T extends Entity>(e: T, ...objects: THREE.Object3D[]): T {\n const ownLocal =\n e.kind === \"hook\" || (e.kind === \"fireball\" && e.ballKind === \"pearl\");\n if (this.replica && !this._netApplying && !ownLocal) {\n queueMicrotask(() => {\n const snap = this.netSnapshot(e);\n if (snap) this.onLocalSpawn?.(snap);\n });\n return e;\n }\n this.entities.set(e.id, e);\n if (objects.length) this.scene.add(...objects);\n return e;\n }\n\n /** Убрать всех враждебных мобов (переход на мирную сложность — systems/Difficulty). */\n clearHostiles(): void {\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.category === \"hostile\" && !e.def.boss)\n e.dead = true; // hp > 0 — исчезают без дропа, как при удалении игрока\n }\n\n /** Сдвинуть репутацию игрока в деревне и сказать об этом HUD. */\n _addRep(by: number): void {\n addRep(this, by);\n }\n\n /**\n * Ударили жителя: репутация падает, а железные големы рядом бросаются на обидчика — как в\n * Minecraft, где деревня помнит зло (systems/Reputation).\n */\n villageAnger(target: Mob): void {\n villageAnger(this, target);\n }\n\n /** Звон колокола: налётчики в 32 блоках светятся 3 секунды (видно сквозь стены). */\n markRaiders(x: number, y: number, z: number): void {\n markRaiders(this, x, y, z);\n }\n\n /** Череп в (x,y,z) достроил алтарь — блоки исчезают, встаёт владыка и набирает силу. */\n summonAtAltar(x: number, y: number, z: number): Mob | null {\n return summonAtAltar(this, x, y, z);\n }\n\n /** Железный голем: тыква на фигуре из четырёх железных блоков — блоки исчезают, встаёт голем. */\n buildIronGolem(x: number, y: number, z: number): Mob | null {\n return buildIronGolem(this, x, y, z);\n }\n\n /** Снеговик: тыква на двух блоках снега — блоки исчезают, на их месте снеговик. */\n buildSnowGolem(x: number, y: number, z: number): Mob | null {\n return buildSnowGolem(this, x, y, z);\n }\n\n /** Клыки ворожея: 16 штук по прямой от from к to через 1.25 блока, каждый позже предыдущего. */\n spawnFangs(from: THREE.Vector3, to: THREE.Vector3, ownerId: number): void {\n spawnFangs(this, from, to, ownerId);\n }\n\n /** До следующей попытки прихода странствующего торговца, сек. */\n _traderT = 1200;\n /** Перезарядка вызова железного голема деревней (systems/Golem). */\n _golemT = 0;\n\n /** Странствующий торговец с двумя ламами на поводке — на суше в 16–32 блоках от игрока. */\n spawnWanderingTrader(): Mob | null {\n return spawnWanderingTrader(this);\n }\n\n /** Патруль мародёров: 2–5 во главе с капитаном на суше в 24–48 блоках от игрока. */\n spawnPatrol(): number {\n return spawnPatrol(this);\n }\n\n /** Черепаха после размножения откладывает 1–4 яйца на песок рядом. */\n layEggs(m: Mob): number {\n return layEggs(this, m);\n }\n\n /** Вода в квадрате r вокруг клетки (на её высоте и выше на блок) — пляж, а не пустыня. */\n /** Есть ли блок id в кубе радиуса r (аксолотль — мох пышных пещер рядом с озером). */\n _blockNear(\n x: number,\n y: number,\n z: number,\n id: number | undefined,\n r: number,\n ): boolean {\n if (id === undefined) return false;\n for (let dy = -r; dy <= r; dy++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === id) return true;\n return false;\n }\n\n _waterNear(x: number, y: number, z: number, r: number): boolean {\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n for (let dy = 0; dy <= 1; dy++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === B.water)\n return true;\n return false;\n }\n\n /** Выпустить пчелу из улья (x,y,z) в свободную соседнюю клетку; false — места нет. */\n spawnBee(x: number, y: number, z: number): boolean {\n return spawnBee(this, x, y, z);\n }\n\n /** Ручные волки рядом с игроком (24 блока) бросаются на `target`. */\n guard(target: Mob): void {\n if (target.tamed || target.dead) return;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e !== target &&\n e.tamed &&\n e.def.guards &&\n !e.sitting &&\n !e.dead &&\n e.pos.distanceTo(this.player.pos) < 24\n )\n e.targetId = target.id;\n }\n\n /** Груз вагонетки как контейнер (воронка под рельсом и в вагонетку, экран сундука). */\n /** Груз вагонетки или вьюк моба — как контейнер для экрана сундука. */\n cartBox(c: Minecart | Mob): BlockEntity {\n return cartBox(this, c);\n }\n\n /** Вагонетка с грузом в клетке (x, y, z) — как контейнер; null — нет такой. */\n cartItems(x: number, y: number, z: number): BlockEntity | null {\n return cartItems(this, x, y, z);\n }\n\n /**\n * Работа вагонетки за тик: динамит взрывается, запитанные активирующие рельсы высаживают седока и\n * запирают воронку; вагонетка с воронкой берёт из контейнера над собой или лежащие рядом предметы.\n */\n _cartWork(c: Minecart, dt: number): void {\n cartWork(this, c, dt);\n }\n\n spawnMinecart(\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n ): Minecart {\n return spawnMinecart(this, x, y, z, variant);\n }\n\n spawnBoat(x: number, y: number, z: number, wood: string): Boat {\n return spawnBoat(this, x, y, z, wood);\n }\n\n /** Забросить поплавок игрока (прежний, если был, исчезает). */\n castHook(pos: THREE.Vector3, vel: THREE.Vector3): FishingHook {\n return castHook(this, pos, vel);\n }\n\n /** Поплавок игрока в мире или null. */\n playerHook(): FishingHook | null {\n return playerHook(this);\n }\n\n /** Луч по транспорту (вагонетки, лодки): ближайший и расстояние до него. */\n raycastRide(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): { cart: Minecart | Boat; t: number } | null {\n return raycastRide(this, origin, dir, maxDist);\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n dmg?: number,\n tag?: Record<string, unknown>,\n ): ItemDrop {\n return spawnDrop(this, x, y, z, item, count, dmg, tag);\n }\n\n /** Воронка: забрать из лежащего в коробке дропа один предмет, если accept его взял. */\n takeDrop(\n x0: number,\n y0: number,\n z0: number,\n x1: number,\n y1: number,\n z1: number,\n accept: (s: ItemStack) => boolean,\n ): boolean {\n return takeDrop(this, x0, y0, z0, x1, y1, z1, accept);\n }\n\n /** Выбрасыватель: стак со скоростью, поднять можно не сразу. */\n ejectItem(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n s: ItemStack,\n ): void {\n ejectItem(this, x, y, z, vx, vy, vz, s);\n }\n\n /** Раздатчик: стрела без стрелка; отравленная несёт свой эффект (systems/TippedArrow). */\n shootArrow(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n item = \"arrow\",\n ): void {\n shootArrow(this, x, y, z, vx, vy, vz, item);\n }\n\n spawnMob(type: string, x: number, y: number, z: number, baby = false): Mob {\n return spawnMob(this, type, x, y, z, baby);\n }\n\n /** Опыт сферами (крупные номиналы, как в Minecraft), не больше 10 сфер. */\n spawnXp(x: number, y: number, z: number, amount: number): void {\n spawnXp(this, x, y, z, amount);\n }\n\n spawnArrow(\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n fromPlayer: boolean,\n shooterId: number,\n ): Arrow {\n return spawnArrow(this, pos, vel, damage, fromPlayer, shooterId);\n }\n\n /** Огненный шар моба Пекла. */\n spawnFireball(\n kind: FireballKind,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n shooterId: number,\n ): Fireball {\n return spawnFireball(this, kind, pos, vel, damage, shooterId);\n }\n\n /** Брошенное Око Предела — летит к цели (цитадели). */\n spawnRimEye(from: THREE.Vector3, target: { x: number; z: number }): RimEye {\n return spawnRimEye(this, from, target);\n }\n\n /** Ударили моба со стайной злостью — злятся все такие же в 32 блоках. */\n _angerGroup(m: Mob): void {\n angerGroup(this, m);\n }\n\n /** Взведённая взрывчатка (огниво, огонь, соседний взрыв). */\n spawnTnt(x: number, y: number, z: number, fuse: number): PrimedTnt {\n return spawnTnt(this, x, y, z, fuse);\n }\n\n /**\n * Удар игрока по мобу. `attacker` — кто бил (сетевая игра: удалённый игрок; по умолчанию свой).\n * У реплики (net/sim) удар уходит владельцу измерения — моб живёт там.\n */\n hurt(\n entityId: number,\n damage: number,\n fromPos: THREE.Vector3,\n attacker: Player = this.player,\n ): void {\n hurt(this, entityId, damage, fromPos, attacker);\n }\n\n /** Луч взгляда игрока проходит у головы дракона (перед центром, на половине ширины). */\n _headHit(m: Mob, who: Player = this.player): boolean {\n return headHit(this, m, who);\n }\n\n /** Взрыв силы power: блоки (дроп с шансом 1/сила), урон и отброс игроку, мобам и предметам. */\n explode(x: number, y: number, z: number, power: number): void {\n explode(this, x, y, z, power);\n }\n\n _ctx(): MobCtx {\n return ctx(this);\n }\n\n /** Все игроки этого измерения: свой и удалённые (сетевая игра). */\n actors(): Actor[] {\n const own: Actor = { player: this.player, inventory: this.inventory };\n const rest = this.others();\n return rest.length ? [own, ...rest] : [own];\n }\n\n /**\n * Ближайший к точке игрок: мобы целятся в него, предмет летит к нему. Живые впереди мёртвых —\n * иначе моб «застревал» бы на трупе, пока рядом бегает живой.\n */\n nearestActor(pos: { x: number; y: number; z: number }): Actor {\n const list = this.actors();\n if (list.length === 1) return list[0]!;\n let best = list[0]!,\n bd = Infinity;\n for (const a of list) {\n const p = a.player.pos;\n const d =\n (p.x - pos.x) ** 2 +\n (p.y - pos.y) ** 2 +\n (p.z - pos.z) ** 2 +\n (a.player.dead ? 1e12 : 0);\n if (d < bd) {\n bd = d;\n best = a;\n }\n }\n return best;\n }\n\n /**\n * Игрок для сущности: моба на поводке ведёт ТОТ, кто держит поводок (у двух игроков рядом моб\n * иначе шёл бы к ближайшему); остальным — ближайший.\n */\n private _actorFor(e: Entity): Actor {\n if (\n e.kind === \"mob\" &&\n e.leashed &&\n !e.leashPost &&\n e.leashMobId === null &&\n e.leashSlot !== null\n ) {\n if (e.leashSlot === this.localSlot)\n return { player: this.player, inventory: this.inventory };\n const held = this.others().find(\n (o) => (o.player as Player & { slot?: number }).slot === e.leashSlot,\n );\n if (held) return held;\n }\n return this.nearestActor(e.pos);\n }\n\n tick(dt: number): void {\n if (this.replica) {\n this._replicaTick(dt);\n return;\n }\n const ctx = this._ctx();\n const ownHeld = ctx.heldItem;\n const multi = this.others().length > 0;\n const mobs = [...this.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\",\n );\n const now = performance.now();\n for (const e of this.entities.values()) {\n // её ведёт игрок-реплика: позу шлёт он (netDrive); замолчал 3 с — снова наша\n const drv = this._netDriven.get(e.id);\n if (drv && !e.dead) {\n if (now - drv.t < 3000) continue;\n this._release(e.id);\n }\n // одна сломанная сущность не останавливает остальные (раньше все после неё в Map стояли)\n try {\n this._tickOne(e, dt, ctx, ownHeld, multi, mobs);\n } catch (err) {\n reportEntityError(e.kind, err);\n }\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0) this._mobDied(e);\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n private _tickOne(\n e: Entity,\n dt: number,\n ctx: MobCtx,\n ownHeld: MobCtx[\"heldItem\"],\n multi: boolean,\n mobs: Mob[],\n ): void {\n const a = multi\n ? this._actorFor(e)\n : { player: this.player, inventory: this.inventory };\n if (e.kind === \"drop\") e.tick(dt, this.world, a.player, a.inventory);\n else if (e.kind === \"mob\") {\n if (multi) {\n ctx.player = a.player;\n ctx.heldItem =\n a.player === this.player\n ? ownHeld\n : (a.inventory.heldItem()?.item ?? null);\n }\n e.tick(dt, ctx);\n if (e.angerCall) this._angerGroup(e);\n } else if (e.kind === \"xp\") e.tick(dt, this.world, a.player);\n else if (e.kind === \"tnt\")\n e.tick(dt, this.world, (x, y, z, p) => this.explode(x, y, z, p));\n else if (e.kind === \"fireball\")\n e.tick(\n dt,\n this.world,\n a.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n mobs,\n );\n else if (e.kind === \"minecart\" || e.kind === \"boat\") {\n e.tick(dt, this.world);\n if (e.kind === \"minecart\") this._cartWork(e, dt);\n } else if (e.kind === \"hook\")\n e.tick(\n dt,\n this.world,\n this.player.pos,\n this.raining,\n this.inventory.heldItem()?.item === \"fishing_rod\",\n );\n else if (e.kind === \"rimeye\") {\n // долетело: в 80% падает предметом, иначе разбивается\n if (e.tick(dt)) {\n if (Math.random() < 0.8)\n this.spawnDrop(e.pos.x, e.pos.y, e.pos.z, \"rim_eye\", 1);\n else events.emit(\"eyeShatter\", { x: e.pos.x, y: e.pos.y, z: e.pos.z });\n }\n } else e.tick(dt, this.world, a.player, mobs, a.inventory);\n }\n\n /** Смерть моба: дроп по таблице (у детёнышей нет) и опыт, если бил игрок. */\n _mobDied(m: Mob): void {\n mobDied(this, m);\n }\n\n /**\n * Профессия от рабочего блока (systems/Jobs), раз в секунду: безработный житель занимает свободный\n * блок рядом и становится мастером; у кого рабочий блок сломали — теряет профессию, если ни разу\n * не торговал. Житель из деревни рабочего места не имеет и профессию не теряет, как в Minecraft.\n */\n _villagerJobs(): void {\n villagerJobs(this);\n }\n\n /**\n * Деревня зовёт голема (systems/Golem): рядом с жителями враждебные (ночная осада) — раз в\n * GOLEM_COOLDOWN секунд встаёт один железный голем. Так деревня отвечает на осаду сама.\n */\n _villagerGolem(): void {\n villagerGolem(this);\n }\n\n /** Лечение зомби-жителя (systems/Zombify): раз в секунду, на нуле — обратно в свою профессию. */\n _tickCuring(): void {\n tickCuring(this);\n }\n\n /**\n * Житель, убитый зомби, встаёт зомби-жителем и помнит профессию (systems/Zombify).\n * Зовёт мозг зомби через MobCtx.convert.\n */\n convertToZombie(v: Mob): Mob {\n return convertToZombie(this, v);\n }\n\n /** Тот же житель на том же месте, но другим типом моба: типы жителей — по профессии. */\n _reprofess(v: Mob, type: string, site: [number, number, number] | null): Mob {\n return reprofess(this, v, type, site);\n }\n\n /** Перепись: враждебные вдали исчезают, население добирается попытками спавна. */\n _census(dt: number): void {\n census(this, dt);\n }\n\n _pickType(light: \"bright\" | \"dark\"): MobDef | null {\n return pickType(this, light);\n }\n\n _trySpawn(\n light: \"bright\" | \"dark\",\n p: THREE.Vector3 = this.player.pos,\n ): number {\n return trySpawn(this, light, p);\n }\n\n /** Место для спавна: пол непрозрачный, над ним воздух на рост, свет по правилу вида. */\n _canSpawn(def: MobDef, x: number, y: number, z: number): boolean {\n return canSpawn(this, def, x, y, z);\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 || e.rider) continue; // лошадь под игроком лучу не мешает\n const w = e.width / 2;\n const t = rayBox(\n origin,\n dir,\n bestT,\n { x: e.pos.x - w, y: e.pos.y, z: e.pos.z - w },\n { x: e.pos.x + w, y: e.pos.y + e.height, z: e.pos.z + w },\n );\n if (t !== null && t < bestT) {\n bestT = t;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n updateVisuals(this, alpha, time);\n }\n\n /** Луч от лечащего Кристалла Предела к дракону. */\n beamLine: THREE.Line | null = null;\n /** Движущихся сущностей в области теней на прошлом кадре (Game решает, перерисовывать ли тени). */\n shadowCasters = 0;\n\n _beam(): void {\n beam(this);\n }\n\n clear(): void {\n this._netBase.clear();\n this._netDriven.clear();\n this.drivenId = null;\n // игрок сидел в вагонетке/лодке этого мира — ссаживаем без телепорта: иначе Player.update\n // каждый кадр ставил бы его к выброшенной сущности (после портала — в координаты прошлого\n // измерения), а «присесть» высаживало бы туда же\n const ride = this.player?.riding;\n if (ride) {\n ride.rider = false;\n this.player.riding = null;\n }\n // выброшенные сущности — мёртвые для всех, кто держит на них ссылку (экран торговли с жителем,\n // груз вагонетки, поводок): иначе после портала экран жил бы с призраком из прошлого измерения\n for (const e of this.entities.values()) {\n e.dead = true;\n e.dispose(this.scene);\n }\n this.entities.clear();\n if (this.beamLine) this.beamLine.visible = false;\n }\n\n serialize() {\n return serialize(this);\n }\n\n deserialize(list?: EntitySave[]): void {\n deserialize(this, list);\n }\n\n /** Одна сохранённая сущность → живая (сейв, Мастерская, сетевой снимок). null — запись негодна. */\n _loadOne(d: EntitySave): Entity | null {\n return loadOne(this, d);\n }\n\n /**\n * Сущность → снимок для другого игрока. null — вид в сети не живёт (поплавок и клыки — локальные\n * и короткие, Око Предела — чистая анимация у бросившего).\n */\n netSnapshot(e: Entity): NetEntitySnap | null {\n return netSnapshot(this, e);\n }\n\n /** Снимок → живая сущность этого менеджера (id — свой). null — снимок негоден. */\n _createFromSnap(s: NetEntitySnap): Entity | null {\n return createFromSnap(this, s);\n }\n\n /**\n * Владелец измерения: реплика попросила создать сущность (бросила предмет, выстрелила). Создаётся\n * по обычным правилам — с id этого менеджера, и разойдётся всем обычным снимком.\n */\n netSpawnFromRemote(s: NetEntitySnap): Entity | null {\n return netSpawnFromRemote(this, s);\n }\n\n /** Реплика: сущность владельца с ЕГО id (повтор того же id — замена: сменился вид). */\n netApplySpawn(id: number, s: NetEntitySnap): void {\n netApplySpawn(this, id, s);\n }\n\n /** Реплика: запомнить состояние образа как «слово владельца» (изменения от него — не наши). */\n _rebase(e: Entity): void {\n rebase(this, e);\n }\n\n /**\n * Реплика: поза по снимку владельца. Сущность идёт к ней сглаженно (_replicaTick), а флаги вида\n * применяются сразу. Незнакомый id — пропуск: снимок появления придёт надёжным каналом.\n */\n netApplyPose(\n id: number,\n x: number,\n y: number,\n z: number,\n yaw: number,\n flags: number,\n extra: number,\n ): void {\n netApplyPose(this, id, x, y, z, yaw, flags, extra);\n }\n\n /** Поза сущности для снимка: флаги вида (см. netApplyPose) и «лишний» байт (фитиль/раскрытие). */\n netPose(e: Entity): { yaw: number; flags: number; extra: number } {\n return netPose(this, e);\n }\n\n /** Реплика: сущность ушла у владельца (умерла, подобрана, вышла из зоны видимости). */\n netRemove(id: number): void {\n netRemove(this, id);\n }\n\n /**\n * Тик реплики: никакой симуляции — только ход к позе из последнего снимка (снимки приходят 10 раз\n * в секунду, тик — 20: без сглаживания мобы двигались бы рывками). Свои локальные сущности\n * (поплавок, жемчуг) тикают по-настоящему.\n */\n _replicaTick(dt: number): void {\n replicaTick(this, dt);\n }\n\n /**\n * Реплика: что игрок поменял в сущностях своими действиями с прошлого тика — только изменившиеся\n * поля. Зовётся в конце тика (NetGame.afterTick): между изменением и сравнением снимки владельца\n * прийти не могут, так что всё отличие от «слова владельца» — наше.\n */\n netLocalChanges(): NetEntityAction[] {\n return netLocalChanges(this);\n }\n\n /** Владелец: правка сущности игроком-реплики (см. interactState). `slot` — кто правил. */\n netPatch(id: number, f: Record<string, unknown>, slot?: number): void {\n netPatch(this, id, f, slot);\n }\n\n /** Владелец: образ сущности погиб у реплики — с дропом (убили) или без (исчезла, превратилась). */\n netKill(id: number, drops: boolean): void {\n netKill(this, id, drops);\n }\n\n /**\n * Владелец: игрок-реплика сел (on) или слез. Пока сидит, сущность здесь не тикает — её ведёт он.\n * @returns false — место занято (сел кто-то другой, или сидит свой игрок)\n */\n netRide(id: number, slot: number, on: boolean): boolean {\n return netRide(this, id, slot, on);\n }\n\n /** Владелец: поза сущности, которую ведёт реплика (чужая поза той же сущности — мимо). */\n netDrive(\n id: number,\n slot: number,\n pos: readonly number[],\n vel: readonly number[],\n yaw: number,\n ): void {\n netDrive(this, id, slot, pos, vel, yaw);\n }\n\n /** Владелец: игрок ушёл (из измерения, из игры) — всё, что он вёл, снова симулируется здесь. */\n netReleaseDriver(slot: number): void {\n netReleaseDriver(this, slot);\n }\n\n _release(id: number): void {\n release(this, id);\n }\n}\n"
294
294
  },
295
295
  {
296
296
  "path": "entities/Fang.ts",
@@ -458,11 +458,11 @@
458
458
  },
459
459
  {
460
460
  "path": "game/types.ts",
461
- "content": "import type { VoxelHub } from \"../hub\";\n\nexport interface GameOptions {\n /**\n * Общее с лобби SDK: атлас, облик игрока, ИИ, мир, выбранный в лобби (hub.ts). Нет — игра создаёт\n * свой (стенд без хоста, тесты).\n */\n hub?: VoxelHub;\n /** Лобби SDK (ctx.lobby): «В лобби» в паузе. null/нет — кнопки нет. */\n lobby?: { available(): boolean; open(): boolean } | null;\n /** Имя игрока в сетевой игре (профиль); нет — «Игрок». */\n playerName?: () => string;\n}\n"
461
+ "content": "import type { VoxelHub } from \"../hub\";\n\nexport interface GameOptions {\n /**\n * Общее с лобби SDK: атлас, облик игрока, ИИ, мир, выбранный в лобби (hub.ts). Нет — игра создаёт\n * свой (стенд без хоста, тесты).\n */\n hub?: VoxelHub;\n /** Лобби SDK (ctx.lobby): «В лобби» в паузе. null/нет — кнопки нет. */\n lobby?: { available(): boolean; open(): boolean } | null;\n /** Имя игрока в сетевой игре (профиль); нет — «Игрок». */\n playerName?: () => string;\n /**\n * Яркий момент для роликов платформы (`ctx.clips.mark`): победа над драконом, достижение, смерть…\n * Нет — игра без записи (стенд, тесты).\n */\n highlight?: (name: string, weight: number) => void;\n}\n"
462
462
  },
463
463
  {
464
464
  "path": "game/wire.ts",
465
- "content": "import { tr } from \"../i18n\";\nimport { wireCharacter } from \"./characterWire\";\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { B } from \"../registry/Blocks\";\nimport { hitTarget } from \"../world/behaviours/extras\";\nimport { dripstoneFallDamage } from \"../world/behaviours/dripstone\";\nimport { type TrialState } from \"../systems/TrialSpawner\";\nimport { type Mob } from \"../entities/Mob\";\nimport {\n beaconEffects,\n inBeaconRange,\n type BeaconPulse,\n} from \"../world/Beacon\";\nimport type { Game } from \"../main\";\n\nexport function wireEvents(game: Game): void {\n wireCharacter(game);\n // опыт из печи (и других источников, что не кидают сферы)\n events.on(\"xpGain\", ({ amount }: { amount: number }) =>\n game.player.addXp(amount),\n );\n events.on(\"playerDied\", () => game._onDeath());\n events.on(\"portalLit\", (d: { x: number; y: number; z: number }) =>\n game._rememberPortal(game.dimension, d.x, d.y, d.z),\n );\n events.on(\"throwEye\", (d: { x: number; y: number; z: number }) =>\n game._throwEye(d.x, d.y, d.z),\n );\n events.on(\"dragonDefeated\", () => game._dragonDefeated());\n events.on(\"ui:useBed\", (d: { x: number; y: number; z: number }) =>\n game._useBed(d.x, d.y, d.z),\n );\n events.on(\"lightning\", (d: { x: number; y: number; z: number }) =>\n game._lightning(d.x, d.y, d.z),\n );\n // упавший сталактит бьёт того, кто под ним, и рассыпается острым капельником\n events.on(\n \"dripstoneFall\",\n (p: { x: number; y: number; z: number; landY: number }) => {\n const pl = game.player;\n if (\n !pl.dead &&\n Math.floor(pl.pos.x) === p.x &&\n Math.floor(pl.pos.z) === p.z &&\n pl.pos.y <= p.y &&\n pl.pos.y >= p.landY - 0.5\n )\n pl.damage(dripstoneFallDamage(p.y - pl.pos.y), \"falling_stalactite\");\n game.entities.spawnDrop(\n p.x + 0.5,\n p.landY + 0.2,\n p.z + 0.5,\n \"pointed_dripstone\",\n 1,\n );\n },\n );\n // находка из-под кисти — над расчищенным блоком\n events.on(\n \"brushLoot\",\n (p: { x: number; y: number; z: number; item: string; count: number }) =>\n game.entities.spawnDrop(p.x + 0.5, p.y + 1.1, p.z + 0.5, p.item, p.count),\n );\n // дроп, который роняет сам моб (рог козы)\n events.on(\"mobDrop\", (p: { x: number; y: number; z: number; item: string }) =>\n game.entities.spawnDrop(p.x, p.y, p.z, p.item, 1),\n );\n // глубокая тьма: сломанные и поставленные игроком блоки и взрывы — вибрации; смерть рядом с\n // катализатором — скалк; место смерти игрока — для компаса возвращения\n const overworld = () => game.dimension === \"overworld\";\n // осколки: блок рассыпается (рядом с игроком — дальше их не разглядеть)\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; id: number }) => {\n const pp = game.player.pos;\n const dx = p.x + 0.5 - pp.x,\n dy = p.y + 0.5 - pp.y,\n dz = p.z + 0.5 - pp.z;\n if (dx * dx + dy * dy + dz * dz < 48 * 48)\n game.particles.burst(p.x, p.y, p.z, p.id);\n },\n );\n // каждый замах по ломаемому блоку выбивает несколько осколков из точки удара\n events.on(\"armSwing\", () => {\n const it = game.interaction,\n b = it.breaking,\n t = it.target;\n if (!b || !t || t.x !== b.x || t.y !== b.y || t.z !== b.z) return;\n game.particles.hit(\n t.x,\n t.y,\n t.z,\n t.id,\n t.meta,\n t.hx,\n t.hy,\n t.hz,\n t.nx,\n t.ny,\n t.nz,\n );\n });\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; byPlayer?: boolean }) => {\n if (overworld())\n game.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n !!p.byPlayer && game.player.mode !== \"creative\",\n );\n },\n );\n // Жемчуг Предела упал: игрока переносит туда, и он расшибается, как в Minecraft; изредка из\n // точки вылезает клещ Предела (registry/mobs/beyond)\n events.on(\"pearlLand\", (p: { x: number; y: number; z: number }) => {\n const pl = game.player;\n if (pl.dead) return;\n game.warmup(p.x, p.z);\n let y = Math.floor(p.y);\n while (\n y > 1 &&\n !game.world.isSolid(Math.floor(p.x), y - 1, Math.floor(p.z)) &&\n y > Math.floor(p.y) - 4\n )\n y--;\n pl.pos.set(Math.floor(p.x) + 0.5, y, Math.floor(p.z) + 0.5);\n pl.vel.set(0, 0, 0);\n pl._fallDist = 0;\n if (pl.mode !== \"creative\") pl.damage(5, \"teleport\");\n events.emit(\"pearlTeleport\", { x: pl.pos.x, y: pl.pos.y, z: pl.pos.z });\n if (Math.random() < 0.05)\n game.entities.spawnMob(\"rim_mite\", pl.pos.x, pl.pos.y, pl.pos.z);\n });\n // фейерверк с земли: ракета взлетает и рассыпается искрами (gfx/Fireworks)\n events.on(\"fireworkLaunch\", (p: { x: number; y: number; z: number }) =>\n game.fireworks.launch(p.x, p.y, p.z),\n );\n // компас по путевому камню: стрелка держится за него, пока камень цел (ui/HUD)\n events.on(\"lodestoneBind\", (p: { x: number; y: number; z: number }) => {\n game.player.lodestone = { x: p.x, z: p.z, dim: game.dimension };\n game.hud.message(\n tr({\n en: \"The compass is linked to the lodestone\",\n ru: \"Компас привязан к путевому камню\",\n }),\n 2000,\n );\n });\n // якорь возрождения: в Пекле — точка возрождения, в верхнем мире взрывается, как в Minecraft\n events.on(\n \"anchorUse\",\n (p: { x: number; y: number; z: number; charges: number }) => {\n if (!DIMENSIONS[game.dimension].bedsExplode) {\n game.world.setBlock(p.x, p.y, p.z, B.air!);\n game.entities.explode(p.x + 0.5, p.y + 0.5, p.z + 0.5, 5);\n return;\n }\n if (p.charges <= 0) {\n game.hud.message(\n tr({\n en: \"The anchor is not charged — it needs glowstone\",\n ru: \"Якорь не заряжен — нужен светокамень\",\n }),\n 2200,\n );\n return;\n }\n game.player.anchorPos = [p.x, p.y, p.z];\n game.player.anchorDim = game.dimension;\n game.hud.message(\n tr({\n en: \"Your respawn point is at the anchor\",\n ru: \"Точка возрождения — у якоря\",\n }),\n 2200,\n );\n },\n );\n // стрела в мишень — красный сигнал по близости к центру (world/behaviours/extras)\n events.on(\n \"arrowHit\",\n (p: {\n x: number;\n y: number;\n z: number;\n hx?: number;\n hy?: number;\n hz?: number;\n }) => {\n if (p.hx === undefined || p.hy === undefined || p.hz === undefined)\n return;\n hitTarget(game.world, p.x, p.y, p.z, p.hx, p.hy, p.hz);\n },\n );\n events.on(\"blockPlace\", (p: { x: number; y: number; z: number }) => {\n if (overworld())\n game.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n game.player.mode !== \"creative\",\n );\n });\n events.on(\"explosion\", (p: { x: number; y: number; z: number }) => {\n if (overworld()) game.sculk.vibrate(p.x, p.y, p.z, false);\n });\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const e = p.entity;\n if (overworld() && e.hitByPlayerT > 0 && !e.def.warden)\n game.sculk.onDeath(e.pos.x, e.pos.y, e.pos.z, e.def.xp?.[1] ?? 1);\n });\n // моб волны спавнера испытаний убит — засчитать спавнеру\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const f = p.entity.trialFrom;\n const be = f ? game.blockEntities.get(f[0], f[1], f[2]) : undefined;\n if (be?.type === \"trial_spawner\") (be.state as TrialState).killed++;\n });\n events.on(\"playerDied\", () => {\n const pp = game.player.pos;\n game.lastDeath = {\n x: pp.x,\n y: pp.y,\n z: pp.z,\n dim: game.dimension,\n guide: true,\n };\n });\n // проводник: игрок в воде в его радиусе получает силу моря (дыхание, ночное зрение, спешка)\n events.on(\n \"conduitPulse\",\n (p: { x: number; y: number; z: number; range: number }) => {\n const pl = game.player;\n if (\n pl.dead ||\n !pl.inWater ||\n pl.pos.distanceTo(new THREE.Vector3(p.x + 0.5, p.y + 0.5, p.z + 0.5)) >\n p.range\n )\n return;\n for (const id of [\"water_breathing\", \"night_vision\", \"haste\"] as const)\n pl.addEffect(id, 13);\n events.emit(\"conduitPower\");\n },\n );\n // маяк: игрок в зоне получает выбранные силы\n events.on(\"beaconPulse\", (p: BeaconPulse) => {\n const pl = game.player;\n if (pl.dead || !inBeaconRange(p, pl.pos.x, pl.pos.y, pl.pos.z)) return;\n for (const e of beaconEffects(p)) pl.addEffect(e.id, e.seconds, e.level);\n });\n\n // попытка поесть сытым: экран объясняет, почему не ест (не чаще раза в 3 с)\n let fullT = 0;\n events.on(\"eatFull\", () => {\n const now = performance.now();\n if (now - fullT < 3000) return;\n fullT = now;\n game.hud.message(\n tr({\n en: \"You are full — you can eat when the drumsticks are not full\",\n ru: \"Ты сыт — есть можно, когда ножки под сердечками не полные\",\n }),\n 2500,\n );\n });\n events.on(\"ui:pause\", () => {\n game.playing = false;\n if (game.invUI.open) game.invUI.close();\n game.hud.showOverlay(true);\n void game.persist();\n });\n events.on(\"ui:resume\", () => {\n game.playing = true;\n game.hud.hideOverlay();\n });\n}\n"
465
+ "content": "import { tr } from \"../i18n\";\nimport { wireCharacter } from \"./characterWire\";\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { B } from \"../registry/Blocks\";\nimport { hitTarget } from \"../world/behaviours/extras\";\nimport { dripstoneFallDamage } from \"../world/behaviours/dripstone\";\nimport { type TrialState } from \"../systems/TrialSpawner\";\nimport { type Mob } from \"../entities/Mob\";\nimport {\n beaconEffects,\n inBeaconRange,\n type BeaconPulse,\n} from \"../world/Beacon\";\nimport type { Game } from \"../main\";\n\nexport function wireEvents(game: Game): void {\n wireCharacter(game);\n // Яркие моменты для роликов платформы: самые весомые станут «Лучшими моментами» игрока. Подписки\n // здесь, а не в module.ts: шина модуля очищается целиком в Game.destroy().\n const mark = (name: string, weight: number) => game.highlight?.(name, weight);\n events.on(\"dragonDefeated\", () => mark(\"dragon_defeated\", 10));\n events.on(\"achievement\", () => mark(\"achievement\", 5));\n events.on(\"levelUp\", () => mark(\"level_up\", 3));\n events.on(\"playerDied\", () => mark(\"player_died\", 4));\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const e = p.entity;\n if (e.hitByPlayerT > 0)\n mark(e.def.warden ? \"warden_down\" : \"mob_killed\", e.def.warden ? 9 : 2);\n });\n // опыт из печи (и других источников, что не кидают сферы)\n events.on(\"xpGain\", ({ amount }: { amount: number }) =>\n game.player.addXp(amount),\n );\n events.on(\"playerDied\", () => game._onDeath());\n events.on(\"portalLit\", (d: { x: number; y: number; z: number }) =>\n game._rememberPortal(game.dimension, d.x, d.y, d.z),\n );\n events.on(\"throwEye\", (d: { x: number; y: number; z: number }) =>\n game._throwEye(d.x, d.y, d.z),\n );\n events.on(\"dragonDefeated\", () => game._dragonDefeated());\n events.on(\"ui:useBed\", (d: { x: number; y: number; z: number }) =>\n game._useBed(d.x, d.y, d.z),\n );\n events.on(\"lightning\", (d: { x: number; y: number; z: number }) =>\n game._lightning(d.x, d.y, d.z),\n );\n // упавший сталактит бьёт того, кто под ним, и рассыпается острым капельником\n events.on(\n \"dripstoneFall\",\n (p: { x: number; y: number; z: number; landY: number }) => {\n const pl = game.player;\n if (\n !pl.dead &&\n Math.floor(pl.pos.x) === p.x &&\n Math.floor(pl.pos.z) === p.z &&\n pl.pos.y <= p.y &&\n pl.pos.y >= p.landY - 0.5\n )\n pl.damage(dripstoneFallDamage(p.y - pl.pos.y), \"falling_stalactite\");\n game.entities.spawnDrop(\n p.x + 0.5,\n p.landY + 0.2,\n p.z + 0.5,\n \"pointed_dripstone\",\n 1,\n );\n },\n );\n // находка из-под кисти — над расчищенным блоком\n events.on(\n \"brushLoot\",\n (p: { x: number; y: number; z: number; item: string; count: number }) =>\n game.entities.spawnDrop(p.x + 0.5, p.y + 1.1, p.z + 0.5, p.item, p.count),\n );\n // дроп, который роняет сам моб (рог козы)\n events.on(\"mobDrop\", (p: { x: number; y: number; z: number; item: string }) =>\n game.entities.spawnDrop(p.x, p.y, p.z, p.item, 1),\n );\n // глубокая тьма: сломанные и поставленные игроком блоки и взрывы — вибрации; смерть рядом с\n // катализатором — скалк; место смерти игрока — для компаса возвращения\n const overworld = () => game.dimension === \"overworld\";\n // осколки: блок рассыпается (рядом с игроком — дальше их не разглядеть)\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; id: number }) => {\n const pp = game.player.pos;\n const dx = p.x + 0.5 - pp.x,\n dy = p.y + 0.5 - pp.y,\n dz = p.z + 0.5 - pp.z;\n if (dx * dx + dy * dy + dz * dz < 48 * 48)\n game.particles.burst(p.x, p.y, p.z, p.id);\n },\n );\n // каждый замах по ломаемому блоку выбивает несколько осколков из точки удара\n events.on(\"armSwing\", () => {\n const it = game.interaction,\n b = it.breaking,\n t = it.target;\n if (!b || !t || t.x !== b.x || t.y !== b.y || t.z !== b.z) return;\n game.particles.hit(\n t.x,\n t.y,\n t.z,\n t.id,\n t.meta,\n t.hx,\n t.hy,\n t.hz,\n t.nx,\n t.ny,\n t.nz,\n );\n });\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; byPlayer?: boolean }) => {\n if (overworld())\n game.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n !!p.byPlayer && game.player.mode !== \"creative\",\n );\n },\n );\n // Жемчуг Предела упал: игрока переносит туда, и он расшибается, как в Minecraft; изредка из\n // точки вылезает клещ Предела (registry/mobs/beyond)\n events.on(\"pearlLand\", (p: { x: number; y: number; z: number }) => {\n const pl = game.player;\n if (pl.dead) return;\n game.warmup(p.x, p.z);\n let y = Math.floor(p.y);\n while (\n y > 1 &&\n !game.world.isSolid(Math.floor(p.x), y - 1, Math.floor(p.z)) &&\n y > Math.floor(p.y) - 4\n )\n y--;\n pl.pos.set(Math.floor(p.x) + 0.5, y, Math.floor(p.z) + 0.5);\n pl.vel.set(0, 0, 0);\n pl._fallDist = 0;\n if (pl.mode !== \"creative\") pl.damage(5, \"teleport\");\n events.emit(\"pearlTeleport\", { x: pl.pos.x, y: pl.pos.y, z: pl.pos.z });\n if (Math.random() < 0.05)\n game.entities.spawnMob(\"rim_mite\", pl.pos.x, pl.pos.y, pl.pos.z);\n });\n // фейерверк с земли: ракета взлетает и рассыпается искрами (gfx/Fireworks)\n events.on(\"fireworkLaunch\", (p: { x: number; y: number; z: number }) =>\n game.fireworks.launch(p.x, p.y, p.z),\n );\n // компас по путевому камню: стрелка держится за него, пока камень цел (ui/HUD)\n events.on(\"lodestoneBind\", (p: { x: number; y: number; z: number }) => {\n game.player.lodestone = { x: p.x, z: p.z, dim: game.dimension };\n game.hud.message(\n tr({\n en: \"The compass is linked to the lodestone\",\n ru: \"Компас привязан к путевому камню\",\n }),\n 2000,\n );\n });\n // якорь возрождения: в Пекле — точка возрождения, в верхнем мире взрывается, как в Minecraft\n events.on(\n \"anchorUse\",\n (p: { x: number; y: number; z: number; charges: number }) => {\n if (!DIMENSIONS[game.dimension].bedsExplode) {\n game.world.setBlock(p.x, p.y, p.z, B.air!);\n game.entities.explode(p.x + 0.5, p.y + 0.5, p.z + 0.5, 5);\n return;\n }\n if (p.charges <= 0) {\n game.hud.message(\n tr({\n en: \"The anchor is not charged — it needs glowstone\",\n ru: \"Якорь не заряжен — нужен светокамень\",\n }),\n 2200,\n );\n return;\n }\n game.player.anchorPos = [p.x, p.y, p.z];\n game.player.anchorDim = game.dimension;\n game.hud.message(\n tr({\n en: \"Your respawn point is at the anchor\",\n ru: \"Точка возрождения — у якоря\",\n }),\n 2200,\n );\n },\n );\n // стрела в мишень — красный сигнал по близости к центру (world/behaviours/extras)\n events.on(\n \"arrowHit\",\n (p: {\n x: number;\n y: number;\n z: number;\n hx?: number;\n hy?: number;\n hz?: number;\n }) => {\n if (p.hx === undefined || p.hy === undefined || p.hz === undefined)\n return;\n hitTarget(game.world, p.x, p.y, p.z, p.hx, p.hy, p.hz);\n },\n );\n events.on(\"blockPlace\", (p: { x: number; y: number; z: number }) => {\n if (overworld())\n game.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n game.player.mode !== \"creative\",\n );\n });\n events.on(\"explosion\", (p: { x: number; y: number; z: number }) => {\n if (overworld()) game.sculk.vibrate(p.x, p.y, p.z, false);\n });\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const e = p.entity;\n if (overworld() && e.hitByPlayerT > 0 && !e.def.warden)\n game.sculk.onDeath(e.pos.x, e.pos.y, e.pos.z, e.def.xp?.[1] ?? 1);\n });\n // моб волны спавнера испытаний убит — засчитать спавнеру\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const f = p.entity.trialFrom;\n const be = f ? game.blockEntities.get(f[0], f[1], f[2]) : undefined;\n if (be?.type === \"trial_spawner\") (be.state as TrialState).killed++;\n });\n events.on(\"playerDied\", () => {\n const pp = game.player.pos;\n game.lastDeath = {\n x: pp.x,\n y: pp.y,\n z: pp.z,\n dim: game.dimension,\n guide: true,\n };\n });\n // проводник: игрок в воде в его радиусе получает силу моря (дыхание, ночное зрение, спешка)\n events.on(\n \"conduitPulse\",\n (p: { x: number; y: number; z: number; range: number }) => {\n const pl = game.player;\n if (\n pl.dead ||\n !pl.inWater ||\n pl.pos.distanceTo(new THREE.Vector3(p.x + 0.5, p.y + 0.5, p.z + 0.5)) >\n p.range\n )\n return;\n for (const id of [\"water_breathing\", \"night_vision\", \"haste\"] as const)\n pl.addEffect(id, 13);\n events.emit(\"conduitPower\");\n },\n );\n // маяк: игрок в зоне получает выбранные силы\n events.on(\"beaconPulse\", (p: BeaconPulse) => {\n const pl = game.player;\n if (pl.dead || !inBeaconRange(p, pl.pos.x, pl.pos.y, pl.pos.z)) return;\n for (const e of beaconEffects(p)) pl.addEffect(e.id, e.seconds, e.level);\n });\n\n // попытка поесть сытым: экран объясняет, почему не ест (не чаще раза в 3 с)\n let fullT = 0;\n events.on(\"eatFull\", () => {\n const now = performance.now();\n if (now - fullT < 3000) return;\n fullT = now;\n game.hud.message(\n tr({\n en: \"You are full — you can eat when the drumsticks are not full\",\n ru: \"Ты сыт — есть можно, когда ножки под сердечками не полные\",\n }),\n 2500,\n );\n });\n events.on(\"ui:pause\", () => {\n game.playing = false;\n if (game.invUI.open) game.invUI.close();\n game.hud.showOverlay(true);\n void game.persist();\n });\n events.on(\"ui:resume\", () => {\n game.playing = true;\n game.hud.hideOverlay();\n });\n}\n"
466
466
  },
467
467
  {
468
468
  "path": "game/worlds.ts",
@@ -842,11 +842,11 @@
842
842
  },
843
843
  {
844
844
  "path": "main.ts",
845
- "content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport { tr } from \"./i18n\";\nimport { tickNpcs, type PendingNpc } from \"./game/npcs\";\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 { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView, ARM_LAYER } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { BlockUpdates } from \"./systems/BlockUpdates\";\nimport { BlockEntities } from \"./world/BlockEntities\";\nimport { DIMENSIONS, type DimensionId } from \"./world/Dimensions\";\nimport { portalsOf } from \"./world/Portal\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { type MapData } from \"./systems/Maps\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { Freezing } from \"./systems/Freezing\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { NetUI } from \"./ui/NetUI\";\nimport { ChatConsole } from \"./ui/ChatConsole\";\nimport { SignEditor } from \"./ui/SignEditor\";\nimport { BookScreen } from \"./ui/BookScreen\";\nimport { NameEditor } from \"./ui/NameEditor\";\nimport { TouchControls, touchWanted } from \"./ui/TouchControls\";\nimport { Commands } from \"./systems/Commands\";\nimport { BeaconBeams } from \"./gfx/BeaconBeams\";\nimport { DisplayItems } from \"./gfx/DisplayItems\";\nimport { SignText } from \"./gfx/SignText\";\nimport { Banners } from \"./gfx/Banners\";\nimport { Achievements } from \"./systems/Achievements\";\nimport { Raid } from \"./systems/Raid\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { WorldStore } from \"./systems/WorldStore\";\nimport { startSource } from \"./worlds/startWorld\";\nimport type { DimensionSave, EffectId, SaveData, WorldSource } from \"./types\";\nimport { sim } from \"./net/sim\";\nimport { NetGame, type NetRoom } from \"./net/NetGame\";\nimport type { DiffStore } from \"./net/DiffStore\";\nimport type {\n RoomSettings,\n Welcome,\n WorldMeta,\n WorldState,\n} from \"./net/protocol\";\nimport {\n netWorldMeta,\n netWorldState,\n applyNetWorldState,\n mergeBeyond,\n netPlayerState,\n loadNetWorld,\n adoptNetWorldAsOwn,\n acceptRoom,\n exitNet,\n} from \"./game/netSession\";\nimport {\n persist,\n openStoredWorld,\n openSharedWorld,\n adoptNewWorld,\n warmup,\n warmupAtSpawn,\n rebuildWorld,\n newWorld,\n changeDimension,\n} from \"./game/worlds\";\nimport {\n onDeath,\n respawnPlayer,\n goHome,\n anchorHome,\n useBed,\n skipNight,\n} from \"./game/respawn\";\nimport {\n tickPortal,\n rememberPortal,\n addPortal,\n forgetPortal,\n applyNetPortal,\n nearestPortal,\n} from \"./game/portals\";\nimport {\n tickPlates,\n beyondTravel,\n initBeyond,\n arrivalPlatform,\n placeGateway,\n openGateway,\n tickGateway,\n dragonDefeated,\n throwEye,\n} from \"./game/beyond\";\nimport { tickRaid, siege, settleVillages } from \"./game/raid\";\nimport {\n tickTrials,\n trialSpawn,\n tickFreeze,\n tickSculk,\n lightning,\n} from \"./game/environment\";\nimport { goatHorn, spyglass, surveyHeldMap, createMap } from \"./game/items\";\nimport type { GameOptions } from \"./game/types\";\nimport { AUTOSAVE_EVERY } from \"./game/shared\";\nimport { wireEvents } from \"./game/wire\";\nimport { AdaptiveQuality, applyGraphicsQuality } from \"./gfx/quality\";\nimport { BlockParticles } from \"./gfx/BlockParticles\";\nimport { PlayerCharacter } from \"./game/playerCharacter\";\nimport { EmoteWheel } from \"./ui/EmoteWheel\";\nimport { QuestBook } from \"./quests/QuestBook\";\nimport { survivalSource } from \"./quests/survival\";\nimport { QuestTracker } from \"./ui/quests/QuestTracker\";\nimport { AimHint } from \"./ui/AimHint\";\nimport { RecipeToast } from \"./ui/RecipeToast\";\nimport { QuestBookUI } from \"./ui/quests/QuestBookUI\";\nimport { WorldAtlas } from \"./systems/WorldAtlas\";\nimport { onLangChange } from \"./i18n\";\nimport { NavHud } from \"./ui/map/NavHud\";\nimport { installItemTooltip } from \"./ui/itemTooltip\";\nimport { WorldMapUI } from \"./ui/map/WorldMapUI\";\nimport { CatalogUI } from \"./ui/catalog/CatalogUI\";\nimport { VoxelHub, type WorldLaunch } from \"./hub\";\nimport type { CharacterSpec } from \"./character/CharacterSpec\";\nimport {\n characterMobType,\n ensureCharacterMob,\n type CharBehavior,\n} from \"./entities/characterMobs\";\nimport type { ShadowStamp } from \"./systems/DayNight\";\nimport { ChunkWorkerPool } from \"./world/worker/ChunkWorkerPool\";\n\nexport type { GameOptions } from \"./game/types\";\n\n/** Карта теней источника сдвинулась (клетка привязки) или солнце повернулось больше angle радиан. */\nfunction stampMoved(\n light: THREE.DirectionalLight,\n seen: ShadowStamp,\n angle: number,\n): boolean {\n const st = light.userData.shadowStamp as ShadowStamp | undefined;\n if (!st) return true;\n return (\n st.iu !== seen.iu ||\n st.iv !== seen.iv ||\n st.dir.dot(seen.dir) < Math.cos(angle)\n );\n}\n\nfunction seeStamp(light: THREE.DirectionalLight, seen: ShadowStamp): void {\n const st = light.userData.shadowStamp as ShadowStamp | undefined;\n if (!st) return;\n seen.iu = st.iu;\n seen.iv = st.iv;\n seen.dir.copy(st.dir);\n}\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n /** Адаптивное качество: долго низкий FPS — рендер в 1× пикселей. */\n private readonly quality: AdaptiveQuality;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n /** Снимает разом все DOM-подписки игры (document/window/канвас) в destroy(). */\n private readonly _abort = new AbortController();\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n /** Блоки без опоры (факел, растение, дверь) ломаются вслед за соседом. */\n blockUpdates: BlockUpdates;\n /** Состояние клеток текущего измерения: сундуки, печи… */\n blockEntities: BlockEntities;\n /** Лучи светящих маяков. */\n beams: BeaconBeams;\n /** Предметы, выставленные в рамках. */\n displays: DisplayItems;\n /** Надписи на табличках. */\n signs: SignText;\n banners: Banners;\n armorStands: ArmorStand;\n fireworks: Fireworks;\n /** Осколки блоков: при ударе по блоку и при разрушении (gfx/BlockParticles). */\n particles: BlockParticles;\n /** Достижения мира (systems/Achievements) и музыка (systems/Music). */\n achievements: Achievements;\n music: Music;\n /** Измерение, в котором игрок, и снимки остальных (дифы, сущности) до возвращения в них. */\n dimension: DimensionId = \"overworld\";\n readonly dims = new Map<DimensionId, DimensionSave>();\n interaction: Interaction;\n /** Вода и лава. */\n fluids: FluidSim;\n dayNight: DayNight;\n /** Дождь и гроза (только в измерениях с небом). */\n weather: Weather;\n /** Секунды в портале; lock — не переносить снова, пока игрок не вышел из портала прибытия. */\n _portalT = 0;\n _portalLock = false;\n /** Известные порталы: обратный путь выводит к своему порталу (в верхнем мире — в 128 блоках). */\n portals = portalsOf(null);\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны (сейв v3, `beyond`). */\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны, где Врата (после победы). */\n beyondState: {\n initialized: boolean;\n dragonKilled: boolean;\n creditsShown: boolean;\n gateway: [number, number, number] | null;\n } = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n audio: Audio;\n input: Input;\n armView: ArmView;\n /** Персонаж игрока: тело от третьего лица, камера F5, рецепт облика (game/playerCharacter). */\n character: PlayerCharacter;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n /** Экран «Сетевая игра» и список игроков по Tab. */\n netUI: NetUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Жесты и эмоции персонажа (G). */\n emoteWheel: EmoteWheel;\n /** Книга заданий (quests/QuestBook): линия «Выживание» и будущие источники заданий. */\n quests: QuestBook;\n /** Трекер заданий на экране и сама книга (J). */\n questTracker: QuestTracker;\n aimHint: AimHint;\n recipeToast: RecipeToast;\n questBookUI: QuestBookUI;\n /** Своя карта игрока (systems/WorldAtlas): разведанная местность и метки; сейв `atlas`. */\n worldMap = new WorldAtlas();\n /** Компас, миникарта и метка цели в мире (ui/map/NavHud). */\n navHud: NavHud;\n /** Большая карта (M). */\n mapUI: WorldMapUI;\n /** Справочник (R): как получить предмет, из чего он делается, для чего нужен. */\n catalogUI: CatalogUI;\n /** Студия персонажа и предметов (кнопка «Персонаж» в инвентаре). */\n /**\n * Общее с лобби SDK (hub.ts): атлас, облик игрока, мир, выбранный в лобби. Без лобби (стенд,\n * тесты) — свой.\n */\n readonly hub: VoxelHub;\n /** Лобби SDK (ctx.lobby): «В лобби» в паузе. null — лобби нет, кнопки нет. */\n readonly lobbyApi: { available(): boolean; open(): boolean } | null;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n _time: number;\n loop: GameLoop;\n /** Текущий мир в «Моих мирах». Классический стартовый мир — \"default\". */\n worldId = \"default\";\n worldName = \"VoxelCraft\";\n /** Хранилище миров; null — браузер не даёт IndexedDB (игра работает без сохранения). */\n store: WorldStore | null = null;\n private _autosaveT = 0;\n /** Был ли день на прошлом тике — рассвет пополняет товар жителей. */\n private _wasDay = true;\n /** Деревни, в которые уже поселены жители (id региона; сейв v3, `villages`). */\n villagesSettled = new Set<string>();\n private _villageT = 0;\n /** Ночей без сна (сейв v3 `daysAwake`); _slept — спал этой ночью. */\n daysAwake = 0;\n _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n _worldTouched = false;\n _destroyed = false;\n // ---- сетевая игра (net/NetGame) ----\n /** Идёт сетевая игра (хост или гость); null — одиночная. */\n net: NetGame | null = null;\n /** Последний мир восстановлен из «Моих миров» (или его нет) — хосту открывать сеть можно. */\n readonly restored: Promise<void>;\n private _restored!: () => void;\n /** Имя этого игрока для других (профиль SDK). */\n readonly playerName: () => string;\n /**\n * Хост: сохранённые игроки сетевой игры по userId (позиция, инвентарь, опыт…). Лежат в сейве мира\n * хоста (`players`): вернувшийся игрок продолжает с тем же инвентарём и там же.\n */\n netPlayers = new Map<string, unknown>();\n /** Лёг спать в сетевой игре: ночь пропускается, когда спят все (как в Minecraft). */\n netSleeping = false;\n /** Мир, в котором игрок был до входа в чужую игру: вернуться в него при выходе. */\n _soloWorldBeforeNet: string | null = null;\n\n /** Этот процесс считает своё измерение (одиночная игра, хост, владелец измерения) — net/sim. */\n get simulating(): boolean {\n return sim.authority;\n }\n\n constructor(opts: GameOptions = {}) {\n this.hub = opts.hub ?? new VoxelHub();\n this.lobbyApi = opts.lobby ?? null;\n this.restored = new Promise<void>((r) => (this._restored = r));\n this.playerName =\n opts.playerName ?? (() => tr({ en: \"Player\", ru: \"Игрок\" }));\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n applyGraphicsQuality(); // пресет по устройству — до рендера и мира: они читают CONFIG\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(\n Math.min(window.devicePixelRatio, CONFIG.graphics.maxPixelRatio),\n );\n this.quality = new AdaptiveQuality(() => {\n if (this.renderer.getPixelRatio() <= 1) return;\n this.renderer.setPixelRatio(1);\n this._resize();\n });\n // Казуальная картинка: Neutral-тонмаппинг держит насыщенность ярких цветов (ACES их смывает).\n this.renderer.toneMapping = THREE.NeutralToneMapping;\n this.renderer.toneMappingExposure = CONFIG.graphics.exposure;\n this.renderer.shadowMap.enabled = CONFIG.graphics.shadows;\n this.renderer.shadowMap.type = THREE.PCFShadowMap; // мягкость — shadow.radius (PCFSoft в r185 устарел)\n // Карту теней обновляем сами, раз за кадр (см. frame): теневой проход фильтрует объекты по\n // слоям камеры, и второй проход (рука) перерисовал бы её заново — из одной руки.\n this.renderer.shadowMap.autoUpdate = false;\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 = this.hub.atlas();\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, startSource());\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.blockUpdates = new BlockUpdates(() => this.world, this.entities);\n // броня из инвентаря гасит урон игрока и изнашивается от ударов\n this.player.armorStats = () => this.inventory.armorStats();\n this.player.onArmorHit = (dmg) => this.inventory.damageArmor(dmg);\n this.player.canGlide = () => this.inventory.canGlide();\n this.player.tryTotem = () => {\n if (this.inventory.heldItem()?.item !== \"life_totem\") return false;\n this.inventory.consumeHeld(1);\n return true;\n };\n this.player.onGlideWear = () => this.inventory.wearWings();\n this.blockEntities = new BlockEntities(() => this.world, this.entities);\n this.beams = new BeaconBeams(this.scene);\n this.displays = new DisplayItems(this.scene, this.atlas);\n this.signs = new SignText(this.scene);\n this.banners = new Banners(this.scene);\n this.armorStands = new ArmorStand(this.scene, this.atlas);\n this.fireworks = new Fireworks(this.scene);\n this.particles = new BlockParticles(this.scene, this.atlas, this.world);\n this.achievements = new Achievements({\n has: (pred) => this.inventory.slots.some((s) => !!s && pred(s.item)),\n wearing: (pred) => this.inventory.armor.some((s) => !!s && pred(s.item)),\n effect: (id) => this.player.effects.has(id as EffectId),\n });\n this.quests = new QuestBook({\n active: () => this.playing && this.player.mode === \"survival\",\n items: () => this.inventory.slots,\n armor: () => this.inventory.armor,\n y: () => this.player.pos.y,\n light: () => {\n const e = this.player.eyePos;\n const l = this.world.getLight(\n Math.floor(e.x),\n Math.floor(e.y),\n Math.floor(e.z),\n );\n return { sky: l >> 4, block: l & 15 };\n },\n phase: () => this.dayNight.phase,\n isDay: () => this.dayNight.isDay,\n food: () => this.player.food,\n health: () => this.player.health,\n dead: () => this.player.dead,\n dimension: () => this.dimension,\n near: (mobs, r) => {\n const p = this.player.pos;\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n mobs.includes((e as Mob).def.name) &&\n e.pos.distanceToSquared(p) <= r * r\n )\n return true;\n return false;\n },\n give: (item, count) => {\n const left = this.inventory.add(item, count);\n if (left > 0) {\n const p = this.player.pos;\n this.entities.spawnDrop(p.x, p.y + 0.5, p.z, item, left);\n }\n },\n touch: () => document.body.classList.contains(\"touch\"),\n });\n this.quests.addSource(survivalSource);\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.fluids = new FluidSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.weather = new Weather(this.scene);\n const start = this.world.source;\n if (start.kind === \"spec\") {\n this.dayNight.setTimeOfDay(start.spec.ambience.timeOfDay);\n this.worldName = start.spec.name;\n }\n this.audio = new Audio();\n this.music = new Music(this.audio);\n // пластинка в проигрывателе играет свою мелодию (сид и лад — systems/Disc), вынули — тишина\n events.on(\"discPlay\", ({ mood, seed }: { mood: Mood; seed: number }) =>\n this.music.play(mood, seed),\n );\n events.on(\"discEject\", () => this.music.stop());\n this.input = new Input(canvas, this.intents, this._abort.signal);\n this.armView = new ArmView(this.camera, this.atlas);\n this.character = new PlayerCharacter(this, this.hub.looks);\n // второй проход (рука) видит только свой слой — свет мира включаем и на нём\n this.dayNight.sun.layers.enable(ARM_LAYER);\n this.dayNight.hemi.layers.enable(ARM_LAYER);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n new AchievementsView(this.hud.heartsEl.parentElement!, this.achievements);\n this.invUI = new InventoryUI(this, this._abort.signal);\n this.netUI = new NetUI(this, this._abort.signal);\n this.commands = new Commands(this);\n this.chat = new ChatConsole(this, this._abort.signal);\n this.signEditor = new SignEditor(this, this._abort.signal);\n this.bookScreen = new BookScreen(this, this._abort.signal);\n this.nameEditor = new NameEditor(this, this._abort.signal);\n this.emoteWheel = new EmoteWheel(this, this._abort.signal);\n this.questTracker = new QuestTracker(this);\n this.aimHint = new AimHint(this);\n this.recipeToast = new RecipeToast(this);\n this.questBookUI = new QuestBookUI(this, this._abort.signal);\n this.navHud = new NavHud(this, this._abort.signal);\n // имя предмета при наведении на любой слот (инвентарь, сундук, печь, хотбар, палитра)\n installItemTooltip(\n document.getElementById(\"game\")!.parentElement!,\n this._abort.signal,\n );\n this.mapUI = new WorldMapUI(this, this._abort.signal);\n this.catalogUI = new CatalogUI(this, this._abort.signal);\n // язык сменили (пауза или лобби SDK) — открытое перерисовать сейчас, остальное — при показе\n const offLang = onLangChange(() => {\n this.hud.langChanged();\n this.hud._renderHotbar(); // подсказки слотов\n if (this.invUI.open) this.invUI._render();\n events.emit(\"questsChanged\"); // трекер и книга заданий\n this.catalogUI.refresh();\n });\n this._abort.signal.addEventListener(\"abort\", offLang);\n this.touch = touchWanted()\n ? new TouchControls(this, this._abort.signal)\n : null;\n this.interaction.onCommand = (text) => this.commands.run(text);\n this.interaction.mayToggleMode = () => !this.net || this.net.canCheat;\n this.interaction.onUseMap = () => this.createMap();\n this.playing = false;\n this._time = 0;\n wireEvents(this);\n const { signal } = this._abort;\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n },\n { signal },\n );\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\n \"click\",\n () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n },\n { signal },\n );\n\n // --- старт мира ---\n this.warmupAtSpawn();\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n // сетевая игра: скрытая вкладка продолжает считать мир в полном темпе (core/GameLoop)\n this.loop.backgroundTicker = () => this.net !== null;\n this.hud.showOverlay(false); // захват мыши — только по клику игрока\n this.loop.start();\n this.hub.attach(this);\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open()\n .then(async (store) => {\n if (store && this._destroyed) return store.close();\n if (this._destroyed) return;\n this.store = store;\n // мир, выбранный в лобби («Играть в этом мире», «Мои миры», файл) — вместо последнего\n const launch = this.hub.takeLaunch();\n if (launch) {\n await this.applyLaunch(launch);\n return;\n }\n if (!store) return;\n const last = await store.lastPlayed().catch(() => undefined);\n if (!last || this._worldTouched || this.playing || this._destroyed)\n return;\n try {\n this.save.apply(last.save);\n this.worldId = last.id;\n this.worldName = last.name;\n this.hud.message(\n tr(\n { en: \"Continuing world «{n}»\", ru: \"Продолжаем мир «{n}»\" },\n { n: last.name },\n ),\n 2200,\n );\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n })\n .finally(() => this._restored());\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). Гость чужой мир не сохраняет. */\n persist(): Promise<void> {\n return persist(this);\n }\n\n /** Открыть мир из «Моих миров». */\n openStoredWorld(id: string): Promise<boolean> {\n return openStoredWorld(this, id);\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n return openSharedWorld(this, save, name);\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n return adoptNewWorld(this, name);\n }\n\n /** Гость в чужом мире: мир не наш — не сохраняем его в «Мои миры» (до передачи хоста). */\n _netGuest = false;\n\n /** Мир для входящего гостя: источник, палитра, время, погода, сложность, Предел, порталы. */\n netWorldMeta(): WorldMeta {\n return netWorldMeta(this);\n }\n\n /** Общее состояние мира (хост раз в 5 с): время, погода, сложность, Предел. */\n netWorldState(): WorldState {\n return netWorldState(this);\n }\n\n applyNetWorldState(w: WorldState): void {\n applyNetWorldState(this, w);\n }\n\n /** Предел: победа над драконом и Врата — однажды случившись, не отменяются (слияние «или»). */\n mergeBeyond(b: unknown): void {\n mergeBeyond(this, b);\n }\n\n /** Сейв этого игрока для мира хоста: позиция, измерение, инвентарь, опыт, эффекты. */\n netPlayerState(): Record<string, unknown> {\n return netPlayerState(this);\n }\n\n /** Ночь пропущена: спали все (сетевая игра) или один (одиночная). */\n skipNight(): void {\n skipNight(this);\n }\n\n /**\n * Гость: мир хоста вместо своего. Свой уходит в «Мои миры» и вернётся при выходе. Все измерения\n * строятся из присланных дифов — порталы работают без ожидания.\n */\n loadNetWorld(w: Welcome, diffs: DiffStore, settings: RoomSettings): void {\n loadNetWorld(this, w, diffs, settings);\n }\n\n /** Стал хостом после ухода прежнего: этот мир теперь наш — новой записью в «Моих мирах». */\n adoptNetWorldAsOwn(): void {\n adoptNetWorldAsOwn(this);\n }\n\n _netWorldName = \"\";\n\n /**\n * Комната пришла из лобби (хост нажал «Играть», или игрок вошёл в уже идущую игру). Хост открывает\n * свой мир — последний, «Моих миров»; гость получает мир хоста. Ошибка — строка для игрока, и\n * комната закрывается: полуначатая сетевая игра хуже одиночной.\n */\n acceptRoom(room: NetRoom): Promise<void> {\n return acceptRoom(this, room);\n }\n\n /**\n * Сетевая игра закончилась. Гость возвращается в свой мир; хост (и ставший хостом) продолжает\n * в этом мире один. `reason` — показать почему (исключили, хост закрыл игру).\n */\n exitNet(reason: string | null): void {\n exitNet(this, reason);\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 return warmup(this, x, z);\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n return warmupAtSpawn(this);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n return rebuildWorld(this, source, dimension);\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n return newWorld(this, source);\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n return changeDimension(this, target, x, y, z);\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n return onDeath(this);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n return respawnPlayer(this);\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n return goHome(this);\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n return tickPortal(this, dt);\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n return tickPlates(this, dt);\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n return beyondTravel(this);\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n return initBeyond(this);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n return arrivalPlatform(this);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n return placeGateway(this, x, y, z);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n return openGateway(this);\n }\n\n /** Идущий набег на деревню (systems/Raid) или null. Не сохраняется: перезагрузка его прекращает. */\n raid: Raid | null = null;\n\n /**\n * Набег: игрок с Дурным знамением у заселённой деревни — знамение сгорает, начинается набег. Волны\n * приходят с одного края деревни (в 28–34 блоках от центра) и идут к центру; победа — «Герой\n * деревни» уровня знамения, поражение — все жители в 64 блоках мертвы или вышло время.\n */\n _tickRaid(dt: number) {\n return tickRaid(this, dt);\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n return tickGateway(this);\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n return dragonDefeated(this);\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n return throwEye(this, x, y, z);\n }\n\n /** Сетевая игра: реестр порталов изменился здесь — разослать (net/NetGame). */\n onPortalChange:\n | ((dim: DimensionId, p: [number, number, number], add: boolean) => void)\n | null = null;\n\n /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\n return rememberPortal(this, dim, x, y, z);\n }\n\n _addPortal(dim: DimensionId, x: number, y: number, z: number): boolean {\n return addPortal(this, dim, x, y, z);\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n return forgetPortal(this, dim, p);\n }\n\n /** Сетевая игра: портал зажгли или сломали у другого игрока. */\n applyNetPortal(dim: DimensionId, p: readonly number[], add: boolean): void {\n applyNetPortal(this, dim, p, add);\n }\n\n /** Ближайший известный портал измерения в квадрате radius вокруг (x, z). */\n _nearestPortal(\n dim: DimensionId,\n x: number,\n z: number,\n radius: number,\n ): [number, number, number] | null {\n return nearestPortal(this, dim, x, z, radius);\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n anchorHome(): THREE.Vector3 | null {\n return anchorHome(this);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n return useBed(this, x, y, z);\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n createMap(this);\n }\n\n _trialScanT = 0;\n\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n tickTrials(this, dt);\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n return trialSpawn(this, x, y, z, mob);\n }\n\n freezing = new Freezing();\n\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n tickFreeze(this, dt);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n goatHorn(this, dt);\n }\n\n /** Скалк, вибрации и Страж (systems/Sculk.ts) — в верхнем мире. */\n sculk = new Sculk({\n world: () => this.world,\n player: () => this.player,\n wardens: () =>\n [...this.entities.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\" && !e.dead && !!e.def.warden,\n ),\n summon: (x, y, z) => {\n const m = this.entities.spawnMob(\"warden\", x, y, z);\n m.emergeT = EMERGE_TIME;\n m.invulnerable = true;\n return m;\n },\n });\n /** Пройдено шагов с последней вибрации (вибрация — каждые 2 блока ходьбы без приседа). */\n _stepDist = 0;\n /**\n * Где игрок умер последний раз — туда указывает компас возвращения (в сейв не пишется). `guide` —\n * компас и стрелка ведут туда за вещами, пока игрок не дойдёт (ui/map/NavHud).\n */\n lastDeath: {\n x: number;\n y: number;\n z: number;\n dim: string;\n guide?: boolean;\n } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n tickSculk(this, dt);\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n spyglass(this, dt);\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n surveyHeldMap(this);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n return siege(this);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n return settleVillages(this);\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n return lightning(this, x, y, z);\n }\n\n /**\n * Призвать существо с обликом персонажа в двух блоках перед игроком: NPC (с именем, гуляет рядом)\n * или моба с пресетом поведения (entities/characterMobs). Питомец появляется ручным.\n */\n summonCharacter(spec: CharacterSpec, behavior: CharBehavior): Mob | null {\n const p = this.player;\n const d = p.lookDir();\n const len = Math.hypot(d.x, d.z) || 1;\n return this.summonCharacterAt(\n spec,\n behavior,\n p.pos.x + (d.x / len) * 2,\n p.pos.y + 0.1,\n p.pos.z + (d.z / len) * 2,\n );\n }\n\n /** Существо с обликом персонажа в точке (NPC из рецепта мира, агент). */\n summonCharacterAt(\n spec: CharacterSpec,\n behavior: CharBehavior,\n x: number,\n y: number,\n z: number,\n ): Mob | null {\n const type = characterMobType(spec, behavior);\n if (!ensureCharacterMob(type, spec)) return null;\n const m = this.entities.spawnMob(type, x, y, z);\n if (behavior === \"npc\") m.setName(spec.name);\n if (behavior === \"pet\") m.tamed = true;\n return m;\n }\n\n /** NPC рецепта мира, которые ещё не встали в мир (game/npcs; сейв pendingNpcs). */\n pendingNpcs: PendingNpc[] = [];\n\n /** Из паузы — в лобби SDK: мир сохраняется, «Играть» в лобби вернёт в него. */\n toLobby() {\n this.playing = false;\n void this.persist().finally(() => this.hub.changed());\n this.lobbyApi?.open();\n }\n\n /** Мир, выбранный в лобби: новый (рецепт, сид), из «Моих миров» или из файла. */\n async applyLaunch(l: WorldLaunch): Promise<void> {\n if (l.kind === \"new\") this.newWorld(l.source);\n else if (l.kind === \"stored\") {\n if (l.id !== this.worldId && !(await this.openStoredWorld(l.id)))\n this.hud.message(\n tr({ en: \"Could not open the world\", ru: \"Не удалось открыть мир\" }),\n 2200,\n );\n } else\n try {\n this.save.importText(l.text);\n } catch (e) {\n this.hud.message(\n tr({ en: \"Could not load: \", ru: \"Не загрузилось: \" }) +\n (e as Error).message,\n 2600,\n );\n }\n this._worldTouched = true;\n this.hub.changed();\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 // сетевая игра не встаёт на паузе: мир общий, и в меню игрок остаётся в нём (как в Minecraft)\n if (!this.playing && !this.net) return;\n // Кто что считает (net/sim): своё — игрок, его действия, голод, опасности — считает каждый;\n // мир измерения (мобы, жидкости, блоки, машины) — только владелец измерения; погоду и\n // время суток — хост (остальным их присылают, время между присылками идёт само).\n const own = this.simulating;\n const host = !this.net || this.net.isHost;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n if (host)\n this.weather.tick(\n dt,\n sky && own ? this.world : null,\n this.player.pos.x,\n this.player.pos.z,\n );\n this.world.raining = sky && this.weather.raining;\n this.world.containerAt = this._containerAt;\n this.entities.raining = this.world.raining;\n this.entities.skyDarken = Math.max(\n this.dayNight.skyDarken,\n sky ? this.weather.darken : 0,\n );\n this.entities.isDay = this.dayNight.isDay;\n this.entities.tick(dt); // у реплики — только сглаживание к снимкам владельца\n if (own) this.fluids.tick();\n this.player.tickHazards(dt);\n // черепаший панцирь: над водой копит 10 с Подводного дыхания — под водой их хватает на нырок\n const wb = this.player.effects.get(\"water_breathing\");\n if (\n this.inventory.armor[0]?.item === \"turtle_helmet\" &&\n !this.player.headInWater &&\n (!wb || wb.time < 9.5)\n )\n this.player.addEffect(\"water_breathing\", 10);\n this._tickPortal(dt);\n this._tickGateway();\n if (own) this._tickRaid(dt);\n this._spyglass(dt);\n if (own) this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n if (own) {\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n }\n this.dayNight.tick(dt); // у гостя — между поправками хоста (net WORLD)\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n if (own)\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n if (host) this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n this.netSleeping = false;\n } else if (!day && this._wasDay && own) this._siege(); // стемнело — может начаться осада деревни\n this.entities.insomnia = this.daysAwake >= 3;\n this._wasDay = day;\n this._villageT += dt;\n if (this._villageT >= 1) {\n this._villageT = 0;\n if (own) this._settleVillages();\n if (own) tickNpcs(this); // NPC рецепта мира — как только прогрузилось их место\n }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\n // своя карта: чанки вокруг, которые прогрузились или изменились\n const p = this.player.pos;\n this.worldMap.survey(this.world, this.dimension, p.x, p.z, 96);\n }\n events.emit(\"tick\", { n });\n this.net?.afterTick();\n this._autosaveT += dt;\n if (this._autosaveT >= AUTOSAVE_EVERY) {\n this._autosaveT = 0;\n void this.persist();\n }\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.quality.frame(dt, this.loop.fps);\n this.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n this.quests.tick(dt);\n }\n\n // дальность обзора: с дальним кольцом LOD — дальше полных чанков (туман и дальняя плоскость камеры)\n const view = this.world.viewChunks;\n if (this.dayNight.viewChunks !== view) {\n this.dayNight.viewChunks = view;\n this.camera.far = view * 16 + 60;\n this.camera.updateProjectionMatrix();\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 this.character.frame(dt); // тело игрока; от третьего лица — камера отъезжает\n this.questTracker.frame(dt);\n this.aimHint.frame(dt);\n this.recipeToast.frame(dt);\n this.navHud.frame();\n this.mapUI.frame();\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.net?.frame(dt);\n this.beams.update(this.blockEntities, this._time);\n this.displays.update(this.blockEntities, this.world);\n this.signs.update(this.blockEntities, this.world);\n this.banners.update(this.blockEntities, this.world);\n this.armorStands.update(this.blockEntities);\n this.fireworks.update(dt);\n this.particles.skyDarken = DIMENSIONS[this.dimension].hasSky\n ? this.dayNight.skyDarken\n : 0;\n this.particles.update(dt);\n this.dayNight.frame(\n this.player.pos,\n this.player.headInLava\n ? \"lava\"\n : this.player.headInWater\n ? \"water\"\n : this.player.headInPowderSnow\n ? \"powder_snow\"\n : \"air\",\n this.dimension,\n this.player.effects.has(\"night_vision\"),\n DIMENSIONS[this.dimension].hasSky ? this.weather : undefined,\n );\n // Тьма пульсирует: то наползает, то чуть отступает\n if (this.player.effects.has(\"darkness\"))\n this.dayNight.darkness(\n 0.75 + 0.25 * Math.abs(Math.sin(this._time * 1.3)),\n );\n this.weather.frame(\n dt,\n eye,\n DIMENSIONS[this.dimension].hasSky ? this.world : null,\n );\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.invUI.frame();\n this.renderPasses();\n }\n\n /** Draw call'ы и треугольники мирового прохода прошлого кадра (отладочная строка, агент). */\n readonly renderStats = { calls: 0, triangles: 0 };\n private readonly _nearSeen: ShadowStamp = {\n iu: NaN,\n iv: 0,\n dir: new THREE.Vector3(),\n };\n private _shadowAge = 0;\n private readonly _farSeen: ShadowStamp = {\n iu: NaN,\n iv: 0,\n dir: new THREE.Vector3(),\n };\n private _farAge = 0;\n /** Меши мира менялись с прошлой перерисовки дальнего каскада. */\n private _farMeshes = false;\n\n /**\n * Ближний каскад теней перерисовываем, когда тени могли сдвинуться: сдвинулась карта или солнце,\n * подменился меш чанка, рядом движутся сущности (мобы, дроп, стрелы), идёт сетевая игра (чужие\n * игроки), или прошло полсекунды. Картинка та же, что при перерисовке каждый кадр.\n * Дальний — как LOD: только при сдвиге своей карты или солнца, при изменившихся мешах — не чаще\n * раза в полсекунды, и раз в 10 секунд на всякий случай.\n */\n private _shadowsChanged(): { near: boolean; far: boolean } {\n if (!CONFIG.graphics.shadows) return { near: false, far: false };\n this._shadowAge++;\n this._farAge++;\n const meshes =\n this.world.mesher.swapped > 0 || (this.world.lod?.rebuilt ?? 0) > 0;\n if (meshes) this._farMeshes = true;\n const near =\n stampMoved(this.dayNight.sun, this._nearSeen, 0.00125) ||\n this._shadowAge >= 30 ||\n this.world.mesher.swapped > 0 ||\n this.entities.shadowCasters > 0 ||\n !!this.net;\n if (near) {\n seeStamp(this.dayNight.sun, this._nearSeen);\n this._shadowAge = 0;\n }\n const farLight = this.dayNight.sunFar;\n let far = false;\n if (farLight) {\n far =\n stampMoved(farLight, this._farSeen, 0.004) ||\n (this._farMeshes && this._farAge >= 30) ||\n this._farAge >= 600;\n if (far) {\n seeStamp(farLight, this._farSeen);\n this._farAge = 0;\n this._farMeshes = false;\n }\n farLight.shadow.needsUpdate = far;\n }\n this.dayNight.sun.shadow.needsUpdate = near;\n return { near, far };\n }\n\n /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n const shadows = this._shadowsChanged(); // тени — только в мировом проходе\n this.renderer.shadowMap.needsUpdate = shadows.near || shadows.far;\n this.renderer.render(this.scene, this.camera);\n const info = this.renderer.info.render;\n this.renderStats.calls = info.calls;\n this.renderStats.triangles = info.triangles;\n this.renderer.autoClear = false;\n this.renderer.clearDepth();\n this.camera.layers.set(ARM_LAYER);\n this.renderer.render(this.scene, this.camera);\n this.camera.layers.set(0);\n this.renderer.autoClear = true;\n }\n\n /**\n * Снимок экрана для кнопки «поделиться» на странице игры.\n *\n * ⚠ Рисует и читает В ОДИН приём. Рендерер создан без `preserveDrawingBuffer` (и так и надо —\n * флаг стоит памяти и копии каждый кадр), поэтому к моменту, когда до канвы доберётся кто-то\n * ещё, буфер уже очищен: `toBlob` вернёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись.\n */\n async capture(): Promise<Blob | null> {\n if (this._destroyed) return null;\n this.renderPasses();\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\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 // сетевая игра: уйти из комнаты (хост передаёт мир преемнику) — без возврата в свой мир, игра разбирается\n const net = this.net;\n if (net) {\n net.prepareExit();\n net.dispose();\n void net.leaveRoom();\n }\n // последний снимок уходит до разбора мира; дописывается транзакция уже без нас\n void this.persist().finally(() => this.store?.close());\n this.net = null;\n this._destroyed = true;\n this.loop.stop();\n this.input.releaseLock();\n // Шина `events` и DOM-подписки живут дольше игры: хост пересоздаёт Game после перелогина, и\n // без этого старый экземпляр продолжал бы слушать — `E` эмитился дважды (новый инвентарь\n // открывался и тут же закрывался), звуки двоились, весь старый мир держался в памяти.\n this._abort.abort();\n events.clear();\n this.hud.dispose();\n this.netUI.dispose();\n this.chat.dispose();\n this.signEditor.dispose();\n this.bookScreen.dispose();\n this.nameEditor.dispose();\n this.emoteWheel.dispose();\n this.quests.dispose();\n this.questTracker.dispose();\n this.aimHint.dispose();\n this.recipeToast.dispose();\n this.questBookUI.dispose();\n this.navHud.dispose();\n this.mapUI.dispose();\n this.catalogUI.dispose();\n this.hub.attach(null);\n this.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.particles.dispose();\n this.character.dispose();\n ChunkWorkerPool.shutdown(); // воркеры чанков держат свой экземпляр мира — освободить\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 this.input.enabled = true;\n if (!this.loop.running) this.loop.start();\n // вернулись из лобби: мир, выбранный там, и «Клик — играть» (захват мыши — по клику)\n const launch = this.hub.takeLaunch();\n if (launch) void this.applyLaunch(launch);\n if (!this.playing && !this.player.dead) this.hud.showOverlay(false);\n } else {\n // сетевая игра: мир общий — у хоста и владельцев измерений он не может встать оттого, что\n // игрок открыл другой режим; останавливается только ввод\n if (!this.net) this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
845
+ "content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport { tr } from \"./i18n\";\nimport { tickNpcs, type PendingNpc } from \"./game/npcs\";\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 { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView, ARM_LAYER } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { BlockUpdates } from \"./systems/BlockUpdates\";\nimport { BlockEntities } from \"./world/BlockEntities\";\nimport { DIMENSIONS, type DimensionId } from \"./world/Dimensions\";\nimport { portalsOf } from \"./world/Portal\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { type MapData } from \"./systems/Maps\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { Freezing } from \"./systems/Freezing\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { NetUI } from \"./ui/NetUI\";\nimport { ChatConsole } from \"./ui/ChatConsole\";\nimport { SignEditor } from \"./ui/SignEditor\";\nimport { BookScreen } from \"./ui/BookScreen\";\nimport { NameEditor } from \"./ui/NameEditor\";\nimport { TouchControls, touchWanted } from \"./ui/TouchControls\";\nimport { Commands } from \"./systems/Commands\";\nimport { BeaconBeams } from \"./gfx/BeaconBeams\";\nimport { DisplayItems } from \"./gfx/DisplayItems\";\nimport { SignText } from \"./gfx/SignText\";\nimport { Banners } from \"./gfx/Banners\";\nimport { Achievements } from \"./systems/Achievements\";\nimport { Raid } from \"./systems/Raid\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { WorldStore } from \"./systems/WorldStore\";\nimport { startSource } from \"./worlds/startWorld\";\nimport type { DimensionSave, EffectId, SaveData, WorldSource } from \"./types\";\nimport { sim } from \"./net/sim\";\nimport { NetGame, type NetRoom } from \"./net/NetGame\";\nimport type { DiffStore } from \"./net/DiffStore\";\nimport type {\n RoomSettings,\n Welcome,\n WorldMeta,\n WorldState,\n} from \"./net/protocol\";\nimport {\n netWorldMeta,\n netWorldState,\n applyNetWorldState,\n mergeBeyond,\n netPlayerState,\n loadNetWorld,\n adoptNetWorldAsOwn,\n acceptRoom,\n exitNet,\n} from \"./game/netSession\";\nimport {\n persist,\n openStoredWorld,\n openSharedWorld,\n adoptNewWorld,\n warmup,\n warmupAtSpawn,\n rebuildWorld,\n newWorld,\n changeDimension,\n} from \"./game/worlds\";\nimport {\n onDeath,\n respawnPlayer,\n goHome,\n anchorHome,\n useBed,\n skipNight,\n} from \"./game/respawn\";\nimport {\n tickPortal,\n rememberPortal,\n addPortal,\n forgetPortal,\n applyNetPortal,\n nearestPortal,\n} from \"./game/portals\";\nimport {\n tickPlates,\n beyondTravel,\n initBeyond,\n arrivalPlatform,\n placeGateway,\n openGateway,\n tickGateway,\n dragonDefeated,\n throwEye,\n} from \"./game/beyond\";\nimport { tickRaid, siege, settleVillages } from \"./game/raid\";\nimport {\n tickTrials,\n trialSpawn,\n tickFreeze,\n tickSculk,\n lightning,\n} from \"./game/environment\";\nimport { goatHorn, spyglass, surveyHeldMap, createMap } from \"./game/items\";\nimport type { GameOptions } from \"./game/types\";\nimport { AUTOSAVE_EVERY } from \"./game/shared\";\nimport { wireEvents } from \"./game/wire\";\nimport { AdaptiveQuality, applyGraphicsQuality } from \"./gfx/quality\";\nimport { BlockParticles } from \"./gfx/BlockParticles\";\nimport { PlayerCharacter } from \"./game/playerCharacter\";\nimport { EmoteWheel } from \"./ui/EmoteWheel\";\nimport { QuestBook } from \"./quests/QuestBook\";\nimport { survivalSource } from \"./quests/survival\";\nimport { QuestTracker } from \"./ui/quests/QuestTracker\";\nimport { AimHint } from \"./ui/AimHint\";\nimport { RecipeToast } from \"./ui/RecipeToast\";\nimport { QuestBookUI } from \"./ui/quests/QuestBookUI\";\nimport { WorldAtlas } from \"./systems/WorldAtlas\";\nimport { onLangChange } from \"./i18n\";\nimport { NavHud } from \"./ui/map/NavHud\";\nimport { installItemTooltip } from \"./ui/itemTooltip\";\nimport { WorldMapUI } from \"./ui/map/WorldMapUI\";\nimport { CatalogUI } from \"./ui/catalog/CatalogUI\";\nimport { VoxelHub, type WorldLaunch } from \"./hub\";\nimport type { CharacterSpec } from \"./character/CharacterSpec\";\nimport {\n characterMobType,\n ensureCharacterMob,\n type CharBehavior,\n} from \"./entities/characterMobs\";\nimport type { ShadowStamp } from \"./systems/DayNight\";\nimport { ChunkWorkerPool } from \"./world/worker/ChunkWorkerPool\";\n\nexport type { GameOptions } from \"./game/types\";\n\n/** Карта теней источника сдвинулась (клетка привязки) или солнце повернулось больше angle радиан. */\nfunction stampMoved(\n light: THREE.DirectionalLight,\n seen: ShadowStamp,\n angle: number,\n): boolean {\n const st = light.userData.shadowStamp as ShadowStamp | undefined;\n if (!st) return true;\n return (\n st.iu !== seen.iu ||\n st.iv !== seen.iv ||\n st.dir.dot(seen.dir) < Math.cos(angle)\n );\n}\n\nfunction seeStamp(light: THREE.DirectionalLight, seen: ShadowStamp): void {\n const st = light.userData.shadowStamp as ShadowStamp | undefined;\n if (!st) return;\n seen.iu = st.iu;\n seen.iv = st.iv;\n seen.dir.copy(st.dir);\n}\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n /** Адаптивное качество: долго низкий FPS — рендер в 1× пикселей. */\n private readonly quality: AdaptiveQuality;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n /** Снимает разом все DOM-подписки игры (document/window/канвас) в destroy(). */\n private readonly _abort = new AbortController();\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n /** Блоки без опоры (факел, растение, дверь) ломаются вслед за соседом. */\n blockUpdates: BlockUpdates;\n /** Состояние клеток текущего измерения: сундуки, печи… */\n blockEntities: BlockEntities;\n /** Лучи светящих маяков. */\n beams: BeaconBeams;\n /** Предметы, выставленные в рамках. */\n displays: DisplayItems;\n /** Надписи на табличках. */\n signs: SignText;\n banners: Banners;\n armorStands: ArmorStand;\n fireworks: Fireworks;\n /** Осколки блоков: при ударе по блоку и при разрушении (gfx/BlockParticles). */\n particles: BlockParticles;\n /** Достижения мира (systems/Achievements) и музыка (systems/Music). */\n achievements: Achievements;\n music: Music;\n /** Измерение, в котором игрок, и снимки остальных (дифы, сущности) до возвращения в них. */\n dimension: DimensionId = \"overworld\";\n readonly dims = new Map<DimensionId, DimensionSave>();\n interaction: Interaction;\n /** Вода и лава. */\n fluids: FluidSim;\n dayNight: DayNight;\n /** Дождь и гроза (только в измерениях с небом). */\n weather: Weather;\n /** Секунды в портале; lock — не переносить снова, пока игрок не вышел из портала прибытия. */\n _portalT = 0;\n _portalLock = false;\n /** Известные порталы: обратный путь выводит к своему порталу (в верхнем мире — в 128 блоках). */\n portals = portalsOf(null);\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны (сейв v3, `beyond`). */\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны, где Врата (после победы). */\n beyondState: {\n initialized: boolean;\n dragonKilled: boolean;\n creditsShown: boolean;\n gateway: [number, number, number] | null;\n } = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n audio: Audio;\n /** Яркие моменты для роликов платформы (GameOptions.highlight); null — записи нет. */\n highlight: ((name: string, weight: number) => void) | null = null;\n input: Input;\n armView: ArmView;\n /** Персонаж игрока: тело от третьего лица, камера F5, рецепт облика (game/playerCharacter). */\n character: PlayerCharacter;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n /** Экран «Сетевая игра» и список игроков по Tab. */\n netUI: NetUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Жесты и эмоции персонажа (G). */\n emoteWheel: EmoteWheel;\n /** Книга заданий (quests/QuestBook): линия «Выживание» и будущие источники заданий. */\n quests: QuestBook;\n /** Трекер заданий на экране и сама книга (J). */\n questTracker: QuestTracker;\n aimHint: AimHint;\n recipeToast: RecipeToast;\n questBookUI: QuestBookUI;\n /** Своя карта игрока (systems/WorldAtlas): разведанная местность и метки; сейв `atlas`. */\n worldMap = new WorldAtlas();\n /** Компас, миникарта и метка цели в мире (ui/map/NavHud). */\n navHud: NavHud;\n /** Большая карта (M). */\n mapUI: WorldMapUI;\n /** Справочник (R): как получить предмет, из чего он делается, для чего нужен. */\n catalogUI: CatalogUI;\n /** Студия персонажа и предметов (кнопка «Персонаж» в инвентаре). */\n /**\n * Общее с лобби SDK (hub.ts): атлас, облик игрока, мир, выбранный в лобби. Без лобби (стенд,\n * тесты) — свой.\n */\n readonly hub: VoxelHub;\n /** Лобби SDK (ctx.lobby): «В лобби» в паузе. null — лобби нет, кнопки нет. */\n readonly lobbyApi: { available(): boolean; open(): boolean } | null;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n _time: number;\n loop: GameLoop;\n /** Текущий мир в «Моих мирах». Классический стартовый мир — \"default\". */\n worldId = \"default\";\n worldName = \"VoxelCraft\";\n /** Хранилище миров; null — браузер не даёт IndexedDB (игра работает без сохранения). */\n store: WorldStore | null = null;\n private _autosaveT = 0;\n /** Был ли день на прошлом тике — рассвет пополняет товар жителей. */\n private _wasDay = true;\n /** Деревни, в которые уже поселены жители (id региона; сейв v3, `villages`). */\n villagesSettled = new Set<string>();\n private _villageT = 0;\n /** Ночей без сна (сейв v3 `daysAwake`); _slept — спал этой ночью. */\n daysAwake = 0;\n _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n _worldTouched = false;\n _destroyed = false;\n // ---- сетевая игра (net/NetGame) ----\n /** Идёт сетевая игра (хост или гость); null — одиночная. */\n net: NetGame | null = null;\n /** Последний мир восстановлен из «Моих миров» (или его нет) — хосту открывать сеть можно. */\n readonly restored: Promise<void>;\n private _restored!: () => void;\n /** Имя этого игрока для других (профиль SDK). */\n readonly playerName: () => string;\n /**\n * Хост: сохранённые игроки сетевой игры по userId (позиция, инвентарь, опыт…). Лежат в сейве мира\n * хоста (`players`): вернувшийся игрок продолжает с тем же инвентарём и там же.\n */\n netPlayers = new Map<string, unknown>();\n /** Лёг спать в сетевой игре: ночь пропускается, когда спят все (как в Minecraft). */\n netSleeping = false;\n /** Мир, в котором игрок был до входа в чужую игру: вернуться в него при выходе. */\n _soloWorldBeforeNet: string | null = null;\n\n /** Этот процесс считает своё измерение (одиночная игра, хост, владелец измерения) — net/sim. */\n get simulating(): boolean {\n return sim.authority;\n }\n\n constructor(opts: GameOptions = {}) {\n this.hub = opts.hub ?? new VoxelHub();\n this.lobbyApi = opts.lobby ?? null;\n this.restored = new Promise<void>((r) => (this._restored = r));\n this.playerName =\n opts.playerName ?? (() => tr({ en: \"Player\", ru: \"Игрок\" }));\n this.highlight = opts.highlight ?? null;\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n applyGraphicsQuality(); // пресет по устройству — до рендера и мира: они читают CONFIG\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(\n Math.min(window.devicePixelRatio, CONFIG.graphics.maxPixelRatio),\n );\n this.quality = new AdaptiveQuality(() => {\n if (this.renderer.getPixelRatio() <= 1) return;\n this.renderer.setPixelRatio(1);\n this._resize();\n });\n // Казуальная картинка: Neutral-тонмаппинг держит насыщенность ярких цветов (ACES их смывает).\n this.renderer.toneMapping = THREE.NeutralToneMapping;\n this.renderer.toneMappingExposure = CONFIG.graphics.exposure;\n this.renderer.shadowMap.enabled = CONFIG.graphics.shadows;\n this.renderer.shadowMap.type = THREE.PCFShadowMap; // мягкость — shadow.radius (PCFSoft в r185 устарел)\n // Карту теней обновляем сами, раз за кадр (см. frame): теневой проход фильтрует объекты по\n // слоям камеры, и второй проход (рука) перерисовал бы её заново — из одной руки.\n this.renderer.shadowMap.autoUpdate = false;\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 = this.hub.atlas();\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, startSource());\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.blockUpdates = new BlockUpdates(() => this.world, this.entities);\n // броня из инвентаря гасит урон игрока и изнашивается от ударов\n this.player.armorStats = () => this.inventory.armorStats();\n this.player.onArmorHit = (dmg) => this.inventory.damageArmor(dmg);\n this.player.canGlide = () => this.inventory.canGlide();\n this.player.tryTotem = () => {\n if (this.inventory.heldItem()?.item !== \"life_totem\") return false;\n this.inventory.consumeHeld(1);\n return true;\n };\n this.player.onGlideWear = () => this.inventory.wearWings();\n this.blockEntities = new BlockEntities(() => this.world, this.entities);\n this.beams = new BeaconBeams(this.scene);\n this.displays = new DisplayItems(this.scene, this.atlas);\n this.signs = new SignText(this.scene);\n this.banners = new Banners(this.scene);\n this.armorStands = new ArmorStand(this.scene, this.atlas);\n this.fireworks = new Fireworks(this.scene);\n this.particles = new BlockParticles(this.scene, this.atlas, this.world);\n this.achievements = new Achievements({\n has: (pred) => this.inventory.slots.some((s) => !!s && pred(s.item)),\n wearing: (pred) => this.inventory.armor.some((s) => !!s && pred(s.item)),\n effect: (id) => this.player.effects.has(id as EffectId),\n });\n this.quests = new QuestBook({\n active: () => this.playing && this.player.mode === \"survival\",\n items: () => this.inventory.slots,\n armor: () => this.inventory.armor,\n y: () => this.player.pos.y,\n light: () => {\n const e = this.player.eyePos;\n const l = this.world.getLight(\n Math.floor(e.x),\n Math.floor(e.y),\n Math.floor(e.z),\n );\n return { sky: l >> 4, block: l & 15 };\n },\n phase: () => this.dayNight.phase,\n isDay: () => this.dayNight.isDay,\n food: () => this.player.food,\n health: () => this.player.health,\n dead: () => this.player.dead,\n dimension: () => this.dimension,\n near: (mobs, r) => {\n const p = this.player.pos;\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n mobs.includes((e as Mob).def.name) &&\n e.pos.distanceToSquared(p) <= r * r\n )\n return true;\n return false;\n },\n give: (item, count) => {\n const left = this.inventory.add(item, count);\n if (left > 0) {\n const p = this.player.pos;\n this.entities.spawnDrop(p.x, p.y + 0.5, p.z, item, left);\n }\n },\n touch: () => document.body.classList.contains(\"touch\"),\n });\n this.quests.addSource(survivalSource);\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.fluids = new FluidSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.weather = new Weather(this.scene);\n const start = this.world.source;\n if (start.kind === \"spec\") {\n this.dayNight.setTimeOfDay(start.spec.ambience.timeOfDay);\n this.worldName = start.spec.name;\n }\n this.audio = new Audio();\n this.music = new Music(this.audio);\n // пластинка в проигрывателе играет свою мелодию (сид и лад — systems/Disc), вынули — тишина\n events.on(\"discPlay\", ({ mood, seed }: { mood: Mood; seed: number }) =>\n this.music.play(mood, seed),\n );\n events.on(\"discEject\", () => this.music.stop());\n this.input = new Input(canvas, this.intents, this._abort.signal);\n this.armView = new ArmView(this.camera, this.atlas);\n this.character = new PlayerCharacter(this, this.hub.looks);\n // второй проход (рука) видит только свой слой — свет мира включаем и на нём\n this.dayNight.sun.layers.enable(ARM_LAYER);\n this.dayNight.hemi.layers.enable(ARM_LAYER);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n new AchievementsView(this.hud.heartsEl.parentElement!, this.achievements);\n this.invUI = new InventoryUI(this, this._abort.signal);\n this.netUI = new NetUI(this, this._abort.signal);\n this.commands = new Commands(this);\n this.chat = new ChatConsole(this, this._abort.signal);\n this.signEditor = new SignEditor(this, this._abort.signal);\n this.bookScreen = new BookScreen(this, this._abort.signal);\n this.nameEditor = new NameEditor(this, this._abort.signal);\n this.emoteWheel = new EmoteWheel(this, this._abort.signal);\n this.questTracker = new QuestTracker(this);\n this.aimHint = new AimHint(this);\n this.recipeToast = new RecipeToast(this);\n this.questBookUI = new QuestBookUI(this, this._abort.signal);\n this.navHud = new NavHud(this, this._abort.signal);\n // имя предмета при наведении на любой слот (инвентарь, сундук, печь, хотбар, палитра)\n installItemTooltip(\n document.getElementById(\"game\")!.parentElement!,\n this._abort.signal,\n );\n this.mapUI = new WorldMapUI(this, this._abort.signal);\n this.catalogUI = new CatalogUI(this, this._abort.signal);\n // язык сменили (пауза или лобби SDK) — открытое перерисовать сейчас, остальное — при показе\n const offLang = onLangChange(() => {\n this.hud.langChanged();\n this.hud._renderHotbar(); // подсказки слотов\n if (this.invUI.open) this.invUI._render();\n events.emit(\"questsChanged\"); // трекер и книга заданий\n this.catalogUI.refresh();\n });\n this._abort.signal.addEventListener(\"abort\", offLang);\n this.touch = touchWanted()\n ? new TouchControls(this, this._abort.signal)\n : null;\n this.interaction.onCommand = (text) => this.commands.run(text);\n this.interaction.mayToggleMode = () => !this.net || this.net.canCheat;\n this.interaction.onUseMap = () => this.createMap();\n this.playing = false;\n this._time = 0;\n wireEvents(this);\n const { signal } = this._abort;\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n },\n { signal },\n );\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\n \"click\",\n () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n },\n { signal },\n );\n\n // --- старт мира ---\n this.warmupAtSpawn();\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n // сетевая игра: скрытая вкладка продолжает считать мир в полном темпе (core/GameLoop)\n this.loop.backgroundTicker = () => this.net !== null;\n this.hud.showOverlay(false); // захват мыши — только по клику игрока\n this.loop.start();\n this.hub.attach(this);\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open()\n .then(async (store) => {\n if (store && this._destroyed) return store.close();\n if (this._destroyed) return;\n this.store = store;\n // мир, выбранный в лобби («Играть в этом мире», «Мои миры», файл) — вместо последнего\n const launch = this.hub.takeLaunch();\n if (launch) {\n await this.applyLaunch(launch);\n return;\n }\n if (!store) return;\n const last = await store.lastPlayed().catch(() => undefined);\n if (!last || this._worldTouched || this.playing || this._destroyed)\n return;\n try {\n this.save.apply(last.save);\n this.worldId = last.id;\n this.worldName = last.name;\n this.hud.message(\n tr(\n { en: \"Continuing world «{n}»\", ru: \"Продолжаем мир «{n}»\" },\n { n: last.name },\n ),\n 2200,\n );\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n })\n .finally(() => this._restored());\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). Гость чужой мир не сохраняет. */\n persist(): Promise<void> {\n return persist(this);\n }\n\n /** Открыть мир из «Моих миров». */\n openStoredWorld(id: string): Promise<boolean> {\n return openStoredWorld(this, id);\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n return openSharedWorld(this, save, name);\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n return adoptNewWorld(this, name);\n }\n\n /** Гость в чужом мире: мир не наш — не сохраняем его в «Мои миры» (до передачи хоста). */\n _netGuest = false;\n\n /** Мир для входящего гостя: источник, палитра, время, погода, сложность, Предел, порталы. */\n netWorldMeta(): WorldMeta {\n return netWorldMeta(this);\n }\n\n /** Общее состояние мира (хост раз в 5 с): время, погода, сложность, Предел. */\n netWorldState(): WorldState {\n return netWorldState(this);\n }\n\n applyNetWorldState(w: WorldState): void {\n applyNetWorldState(this, w);\n }\n\n /** Предел: победа над драконом и Врата — однажды случившись, не отменяются (слияние «или»). */\n mergeBeyond(b: unknown): void {\n mergeBeyond(this, b);\n }\n\n /** Сейв этого игрока для мира хоста: позиция, измерение, инвентарь, опыт, эффекты. */\n netPlayerState(): Record<string, unknown> {\n return netPlayerState(this);\n }\n\n /** Ночь пропущена: спали все (сетевая игра) или один (одиночная). */\n skipNight(): void {\n skipNight(this);\n }\n\n /**\n * Гость: мир хоста вместо своего. Свой уходит в «Мои миры» и вернётся при выходе. Все измерения\n * строятся из присланных дифов — порталы работают без ожидания.\n */\n loadNetWorld(w: Welcome, diffs: DiffStore, settings: RoomSettings): void {\n loadNetWorld(this, w, diffs, settings);\n }\n\n /** Стал хостом после ухода прежнего: этот мир теперь наш — новой записью в «Моих мирах». */\n adoptNetWorldAsOwn(): void {\n adoptNetWorldAsOwn(this);\n }\n\n _netWorldName = \"\";\n\n /**\n * Комната пришла из лобби (хост нажал «Играть», или игрок вошёл в уже идущую игру). Хост открывает\n * свой мир — последний, «Моих миров»; гость получает мир хоста. Ошибка — строка для игрока, и\n * комната закрывается: полуначатая сетевая игра хуже одиночной.\n */\n acceptRoom(room: NetRoom): Promise<void> {\n return acceptRoom(this, room);\n }\n\n /**\n * Сетевая игра закончилась. Гость возвращается в свой мир; хост (и ставший хостом) продолжает\n * в этом мире один. `reason` — показать почему (исключили, хост закрыл игру).\n */\n exitNet(reason: string | null): void {\n exitNet(this, reason);\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 return warmup(this, x, z);\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n return warmupAtSpawn(this);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n return rebuildWorld(this, source, dimension);\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n return newWorld(this, source);\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n return changeDimension(this, target, x, y, z);\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n return onDeath(this);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n return respawnPlayer(this);\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n return goHome(this);\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n return tickPortal(this, dt);\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n return tickPlates(this, dt);\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n return beyondTravel(this);\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n return initBeyond(this);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n return arrivalPlatform(this);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n return placeGateway(this, x, y, z);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n return openGateway(this);\n }\n\n /** Идущий набег на деревню (systems/Raid) или null. Не сохраняется: перезагрузка его прекращает. */\n raid: Raid | null = null;\n\n /**\n * Набег: игрок с Дурным знамением у заселённой деревни — знамение сгорает, начинается набег. Волны\n * приходят с одного края деревни (в 28–34 блоках от центра) и идут к центру; победа — «Герой\n * деревни» уровня знамения, поражение — все жители в 64 блоках мертвы или вышло время.\n */\n _tickRaid(dt: number) {\n return tickRaid(this, dt);\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n return tickGateway(this);\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n return dragonDefeated(this);\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n return throwEye(this, x, y, z);\n }\n\n /** Сетевая игра: реестр порталов изменился здесь — разослать (net/NetGame). */\n onPortalChange:\n | ((dim: DimensionId, p: [number, number, number], add: boolean) => void)\n | null = null;\n\n /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\n return rememberPortal(this, dim, x, y, z);\n }\n\n _addPortal(dim: DimensionId, x: number, y: number, z: number): boolean {\n return addPortal(this, dim, x, y, z);\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n return forgetPortal(this, dim, p);\n }\n\n /** Сетевая игра: портал зажгли или сломали у другого игрока. */\n applyNetPortal(dim: DimensionId, p: readonly number[], add: boolean): void {\n applyNetPortal(this, dim, p, add);\n }\n\n /** Ближайший известный портал измерения в квадрате radius вокруг (x, z). */\n _nearestPortal(\n dim: DimensionId,\n x: number,\n z: number,\n radius: number,\n ): [number, number, number] | null {\n return nearestPortal(this, dim, x, z, radius);\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n anchorHome(): THREE.Vector3 | null {\n return anchorHome(this);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n return useBed(this, x, y, z);\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n createMap(this);\n }\n\n _trialScanT = 0;\n\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n tickTrials(this, dt);\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n return trialSpawn(this, x, y, z, mob);\n }\n\n freezing = new Freezing();\n\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n tickFreeze(this, dt);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n goatHorn(this, dt);\n }\n\n /** Скалк, вибрации и Страж (systems/Sculk.ts) — в верхнем мире. */\n sculk = new Sculk({\n world: () => this.world,\n player: () => this.player,\n wardens: () =>\n [...this.entities.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\" && !e.dead && !!e.def.warden,\n ),\n summon: (x, y, z) => {\n const m = this.entities.spawnMob(\"warden\", x, y, z);\n m.emergeT = EMERGE_TIME;\n m.invulnerable = true;\n return m;\n },\n });\n /** Пройдено шагов с последней вибрации (вибрация — каждые 2 блока ходьбы без приседа). */\n _stepDist = 0;\n /**\n * Где игрок умер последний раз — туда указывает компас возвращения (в сейв не пишется). `guide` —\n * компас и стрелка ведут туда за вещами, пока игрок не дойдёт (ui/map/NavHud).\n */\n lastDeath: {\n x: number;\n y: number;\n z: number;\n dim: string;\n guide?: boolean;\n } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n tickSculk(this, dt);\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n spyglass(this, dt);\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n surveyHeldMap(this);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n return siege(this);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n return settleVillages(this);\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n return lightning(this, x, y, z);\n }\n\n /**\n * Призвать существо с обликом персонажа в двух блоках перед игроком: NPC (с именем, гуляет рядом)\n * или моба с пресетом поведения (entities/characterMobs). Питомец появляется ручным.\n */\n summonCharacter(spec: CharacterSpec, behavior: CharBehavior): Mob | null {\n const p = this.player;\n const d = p.lookDir();\n const len = Math.hypot(d.x, d.z) || 1;\n return this.summonCharacterAt(\n spec,\n behavior,\n p.pos.x + (d.x / len) * 2,\n p.pos.y + 0.1,\n p.pos.z + (d.z / len) * 2,\n );\n }\n\n /** Существо с обликом персонажа в точке (NPC из рецепта мира, агент). */\n summonCharacterAt(\n spec: CharacterSpec,\n behavior: CharBehavior,\n x: number,\n y: number,\n z: number,\n ): Mob | null {\n const type = characterMobType(spec, behavior);\n if (!ensureCharacterMob(type, spec)) return null;\n const m = this.entities.spawnMob(type, x, y, z);\n if (behavior === \"npc\") m.setName(spec.name);\n if (behavior === \"pet\") m.tamed = true;\n return m;\n }\n\n /** NPC рецепта мира, которые ещё не встали в мир (game/npcs; сейв pendingNpcs). */\n pendingNpcs: PendingNpc[] = [];\n\n /** Из паузы — в лобби SDK: мир сохраняется, «Играть» в лобби вернёт в него. */\n toLobby() {\n this.playing = false;\n void this.persist().finally(() => this.hub.changed());\n this.lobbyApi?.open();\n }\n\n /** Мир, выбранный в лобби: новый (рецепт, сид), из «Моих миров» или из файла. */\n async applyLaunch(l: WorldLaunch): Promise<void> {\n if (l.kind === \"new\") this.newWorld(l.source);\n else if (l.kind === \"stored\") {\n if (l.id !== this.worldId && !(await this.openStoredWorld(l.id)))\n this.hud.message(\n tr({ en: \"Could not open the world\", ru: \"Не удалось открыть мир\" }),\n 2200,\n );\n } else\n try {\n this.save.importText(l.text);\n } catch (e) {\n this.hud.message(\n tr({ en: \"Could not load: \", ru: \"Не загрузилось: \" }) +\n (e as Error).message,\n 2600,\n );\n }\n this._worldTouched = true;\n this.hub.changed();\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 // сетевая игра не встаёт на паузе: мир общий, и в меню игрок остаётся в нём (как в Minecraft)\n if (!this.playing && !this.net) return;\n // Кто что считает (net/sim): своё — игрок, его действия, голод, опасности — считает каждый;\n // мир измерения (мобы, жидкости, блоки, машины) — только владелец измерения; погоду и\n // время суток — хост (остальным их присылают, время между присылками идёт само).\n const own = this.simulating;\n const host = !this.net || this.net.isHost;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n if (host)\n this.weather.tick(\n dt,\n sky && own ? this.world : null,\n this.player.pos.x,\n this.player.pos.z,\n );\n this.world.raining = sky && this.weather.raining;\n this.world.containerAt = this._containerAt;\n this.entities.raining = this.world.raining;\n this.entities.skyDarken = Math.max(\n this.dayNight.skyDarken,\n sky ? this.weather.darken : 0,\n );\n this.entities.isDay = this.dayNight.isDay;\n this.entities.tick(dt); // у реплики — только сглаживание к снимкам владельца\n if (own) this.fluids.tick();\n this.player.tickHazards(dt);\n // черепаший панцирь: над водой копит 10 с Подводного дыхания — под водой их хватает на нырок\n const wb = this.player.effects.get(\"water_breathing\");\n if (\n this.inventory.armor[0]?.item === \"turtle_helmet\" &&\n !this.player.headInWater &&\n (!wb || wb.time < 9.5)\n )\n this.player.addEffect(\"water_breathing\", 10);\n this._tickPortal(dt);\n this._tickGateway();\n if (own) this._tickRaid(dt);\n this._spyglass(dt);\n if (own) this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n if (own) {\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n }\n this.dayNight.tick(dt); // у гостя — между поправками хоста (net WORLD)\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n if (own)\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n if (host) this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n this.netSleeping = false;\n } else if (!day && this._wasDay && own) this._siege(); // стемнело — может начаться осада деревни\n this.entities.insomnia = this.daysAwake >= 3;\n this._wasDay = day;\n this._villageT += dt;\n if (this._villageT >= 1) {\n this._villageT = 0;\n if (own) this._settleVillages();\n if (own) tickNpcs(this); // NPC рецепта мира — как только прогрузилось их место\n }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\n // своя карта: чанки вокруг, которые прогрузились или изменились\n const p = this.player.pos;\n this.worldMap.survey(this.world, this.dimension, p.x, p.z, 96);\n }\n events.emit(\"tick\", { n });\n this.net?.afterTick();\n this._autosaveT += dt;\n if (this._autosaveT >= AUTOSAVE_EVERY) {\n this._autosaveT = 0;\n void this.persist();\n }\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.quality.frame(dt, this.loop.fps);\n this.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n this.quests.tick(dt);\n }\n\n // дальность обзора: с дальним кольцом LOD — дальше полных чанков (туман и дальняя плоскость камеры)\n const view = this.world.viewChunks;\n if (this.dayNight.viewChunks !== view) {\n this.dayNight.viewChunks = view;\n this.camera.far = view * 16 + 60;\n this.camera.updateProjectionMatrix();\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 this.character.frame(dt); // тело игрока; от третьего лица — камера отъезжает\n this.questTracker.frame(dt);\n this.aimHint.frame(dt);\n this.recipeToast.frame(dt);\n this.navHud.frame();\n this.mapUI.frame();\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.net?.frame(dt);\n this.beams.update(this.blockEntities, this._time);\n this.displays.update(this.blockEntities, this.world);\n this.signs.update(this.blockEntities, this.world);\n this.banners.update(this.blockEntities, this.world);\n this.armorStands.update(this.blockEntities);\n this.fireworks.update(dt);\n this.particles.skyDarken = DIMENSIONS[this.dimension].hasSky\n ? this.dayNight.skyDarken\n : 0;\n this.particles.update(dt);\n this.dayNight.frame(\n this.player.pos,\n this.player.headInLava\n ? \"lava\"\n : this.player.headInWater\n ? \"water\"\n : this.player.headInPowderSnow\n ? \"powder_snow\"\n : \"air\",\n this.dimension,\n this.player.effects.has(\"night_vision\"),\n DIMENSIONS[this.dimension].hasSky ? this.weather : undefined,\n );\n // Тьма пульсирует: то наползает, то чуть отступает\n if (this.player.effects.has(\"darkness\"))\n this.dayNight.darkness(\n 0.75 + 0.25 * Math.abs(Math.sin(this._time * 1.3)),\n );\n this.weather.frame(\n dt,\n eye,\n DIMENSIONS[this.dimension].hasSky ? this.world : null,\n );\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.invUI.frame();\n this.renderPasses();\n }\n\n /** Draw call'ы и треугольники мирового прохода прошлого кадра (отладочная строка, агент). */\n readonly renderStats = { calls: 0, triangles: 0 };\n private readonly _nearSeen: ShadowStamp = {\n iu: NaN,\n iv: 0,\n dir: new THREE.Vector3(),\n };\n private _shadowAge = 0;\n private readonly _farSeen: ShadowStamp = {\n iu: NaN,\n iv: 0,\n dir: new THREE.Vector3(),\n };\n private _farAge = 0;\n /** Меши мира менялись с прошлой перерисовки дальнего каскада. */\n private _farMeshes = false;\n\n /**\n * Ближний каскад теней перерисовываем, когда тени могли сдвинуться: сдвинулась карта или солнце,\n * подменился меш чанка, рядом движутся сущности (мобы, дроп, стрелы), идёт сетевая игра (чужие\n * игроки), или прошло полсекунды. Картинка та же, что при перерисовке каждый кадр.\n * Дальний — как LOD: только при сдвиге своей карты или солнца, при изменившихся мешах — не чаще\n * раза в полсекунды, и раз в 10 секунд на всякий случай.\n */\n private _shadowsChanged(): { near: boolean; far: boolean } {\n if (!CONFIG.graphics.shadows) return { near: false, far: false };\n this._shadowAge++;\n this._farAge++;\n const meshes =\n this.world.mesher.swapped > 0 || (this.world.lod?.rebuilt ?? 0) > 0;\n if (meshes) this._farMeshes = true;\n const near =\n stampMoved(this.dayNight.sun, this._nearSeen, 0.00125) ||\n this._shadowAge >= 30 ||\n this.world.mesher.swapped > 0 ||\n this.entities.shadowCasters > 0 ||\n !!this.net;\n if (near) {\n seeStamp(this.dayNight.sun, this._nearSeen);\n this._shadowAge = 0;\n }\n const farLight = this.dayNight.sunFar;\n let far = false;\n if (farLight) {\n far =\n stampMoved(farLight, this._farSeen, 0.004) ||\n (this._farMeshes && this._farAge >= 30) ||\n this._farAge >= 600;\n if (far) {\n seeStamp(farLight, this._farSeen);\n this._farAge = 0;\n this._farMeshes = false;\n }\n farLight.shadow.needsUpdate = far;\n }\n this.dayNight.sun.shadow.needsUpdate = near;\n return { near, far };\n }\n\n /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n const shadows = this._shadowsChanged(); // тени — только в мировом проходе\n this.renderer.shadowMap.needsUpdate = shadows.near || shadows.far;\n this.renderer.render(this.scene, this.camera);\n const info = this.renderer.info.render;\n this.renderStats.calls = info.calls;\n this.renderStats.triangles = info.triangles;\n this.renderer.autoClear = false;\n this.renderer.clearDepth();\n this.camera.layers.set(ARM_LAYER);\n this.renderer.render(this.scene, this.camera);\n this.camera.layers.set(0);\n this.renderer.autoClear = true;\n }\n\n /**\n * Снимок экрана для кнопки «поделиться» на странице игры.\n *\n * ⚠ Рисует и читает В ОДИН приём. Рендерер создан без `preserveDrawingBuffer` (и так и надо —\n * флаг стоит памяти и копии каждый кадр), поэтому к моменту, когда до канвы доберётся кто-то\n * ещё, буфер уже очищен: `toBlob` вернёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись.\n */\n async capture(): Promise<Blob | null> {\n if (this._destroyed) return null;\n this.renderPasses();\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\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 // сетевая игра: уйти из комнаты (хост передаёт мир преемнику) — без возврата в свой мир, игра разбирается\n const net = this.net;\n if (net) {\n net.prepareExit();\n net.dispose();\n void net.leaveRoom();\n }\n // последний снимок уходит до разбора мира; дописывается транзакция уже без нас\n void this.persist().finally(() => this.store?.close());\n this.net = null;\n this._destroyed = true;\n this.loop.stop();\n this.input.releaseLock();\n // Шина `events` и DOM-подписки живут дольше игры: хост пересоздаёт Game после перелогина, и\n // без этого старый экземпляр продолжал бы слушать — `E` эмитился дважды (новый инвентарь\n // открывался и тут же закрывался), звуки двоились, весь старый мир держался в памяти.\n this._abort.abort();\n events.clear();\n this.hud.dispose();\n this.netUI.dispose();\n this.chat.dispose();\n this.signEditor.dispose();\n this.bookScreen.dispose();\n this.nameEditor.dispose();\n this.emoteWheel.dispose();\n this.quests.dispose();\n this.questTracker.dispose();\n this.aimHint.dispose();\n this.recipeToast.dispose();\n this.questBookUI.dispose();\n this.navHud.dispose();\n this.mapUI.dispose();\n this.catalogUI.dispose();\n this.hub.attach(null);\n this.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.particles.dispose();\n this.character.dispose();\n ChunkWorkerPool.shutdown(); // воркеры чанков держат свой экземпляр мира — освободить\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 this.input.enabled = true;\n if (!this.loop.running) this.loop.start();\n // вернулись из лобби: мир, выбранный там, и «Клик — играть» (захват мыши — по клику)\n const launch = this.hub.takeLaunch();\n if (launch) void this.applyLaunch(launch);\n if (!this.playing && !this.player.dead) this.hud.showOverlay(false);\n } else {\n // сетевая игра: мир общий — у хоста и владельцев измерений он не может встать оттого, что\n // игрок открыл другой режим; останавливается только ввод\n if (!this.net) this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
846
846
  },
847
847
  {
848
848
  "path": "module.ts",
849
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelcraft } from \"./scene\";\nimport { VoxelHub } from \"./hub\";\nimport { registerLobby } from \"./lobby/register\";\nimport { MODE } from \"./lobby/ids\";\nimport { createClientWorldAI } from \"./ai/worldAi\";\nimport { createClientCharacterAI, createClientItemAI } from \"./ai/characterAi\";\nimport { tr } from \"./i18n\";\n\n// The VoxelCraft game module. The game itself is a vanilla-Three engine scene with its own DOM HUD.\n// Around it, it lives INSIDE the SDK's lobby (sign-in, balances, profile and every system come from\n// the base): its showcase — the player's character on a pedestal — on the lobby's home\n// (ctx.lobby.setHero), and its sections as lobby tabs on the UI kit (ctx.features): worlds,\n// character, items, locker. \"Play\" there opens the scene; \"To the lobby\" in its pause returns.\n//\n// The SDK is used for client.ai — worlds, characters and item looks from a picture + description\n// (features \"voxel-world\" / \"voxel-character\" / \"voxel-item\" of the title; without them the AI-free\n// path) — and for network play through the lobby's rooms system (ctx.multiplayer): VoxelCraft only\n// declares how it plays online and takes the room when the host presses \"Play\".\n\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 // setup() runs after login, so the client already has a session for client.ai.\n // Общее у лобби и игры: вкладки и подиум работают ещё до первого «Играть» (сцены нет).\n const hub = new VoxelHub({\n ai: {\n world: createClientWorldAI(ctx.client),\n // персонаж и облик предмета по картинкам: свои фичи тайтла\n character: createClientCharacterAI(ctx.client),\n item: createClientItemAI(ctx.client),\n },\n play: () => ctx.navigate(MODE),\n open: (id, args) => ctx.features.open(id, args),\n });\n const { scene, agent, content, characterContent, itemContent, whenGame } =\n createVoxelcraft({\n hub,\n // «В лобби» в паузе — в лобби SDK\n lobby: {\n available: () => ctx.lobby.available(),\n open: () => ctx.lobby.open(),\n },\n });\n ctx.registerScene(scene);\n // Лобби SDK: персонаж на подиуме главной, вкладки «Миры», «Персонаж», «Предметы», «Шкафчик».\n registerLobby(ctx, hub);\n // своя дорога в лобби — пауза → «В лобби» (на тач-экране кнопка ⏸): круглой «назад» база не рисует\n ctx.registerRoute({ id: MODE, label: \"Voxel\", icon: \"🧊\", ownExit: true });\n // Сетевая игра: звезда вокруг хоста, свой чат в игре, до 8 игроков, опоздавших пускаем — у мира\n // есть вход в идущую игру (Hello → Welcome с миром хоста). Настройки комнаты — режим гостей и\n // команды для всех (их читает NetGame.roomSettingsOf).\n ctx.multiplayer.registerGame({\n mode: MODE,\n label: \"VoxelCraft\",\n topology: \"Host\",\n chatMode: \"P2P\",\n maxPlayers: 8,\n joinInProgress: true,\n settings: [\n {\n id: \"guestMode\",\n label: tr({ en: \"Guest mode\", ru: \"Режим гостей\" }),\n kind: \"choice\",\n options: [\n {\n value: \"survival\",\n label: tr({ en: \"Survival\", ru: \"Выживание\" }),\n },\n {\n value: \"creative\",\n label: tr({ en: \"Creative\", ru: \"Творчество\" }),\n },\n ],\n default: \"survival\",\n },\n {\n id: \"cheats\",\n label: tr({ en: \"Commands for everyone\", ru: \"Команды всем\" }),\n kind: \"toggle\",\n default: false,\n },\n ],\n start: async (room) => (await whenGame()).acceptRoom(room),\n });\n // Миры в Мастерской: модуль-браузер показывает, публикует и открывает их через этот обработчик,\n // не зная ничего про сейвы. Без модуля workshop в проекте регистрация просто ни на что не влияет.\n ctx.content.registerType(content);\n // персонажи и облики предметов из студии — тоже публикуются и скачиваются через Мастерскую\n ctx.content.registerType(characterContent);\n ctx.content.registerType(itemContent);\n // Глаза и руки агента: у canvas-игры DOM пустой, без этого она для него невидима.\n ctx.exposeToAgent(agent);\n },\n});\n"
849
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelcraft } from \"./scene\";\nimport { VoxelHub } from \"./hub\";\nimport { registerLobby } from \"./lobby/register\";\nimport { MODE } from \"./lobby/ids\";\nimport { createClientWorldAI } from \"./ai/worldAi\";\nimport { createClientCharacterAI, createClientItemAI } from \"./ai/characterAi\";\nimport { tr } from \"./i18n\";\n\n// The VoxelCraft game module. The game itself is a vanilla-Three engine scene with its own DOM HUD.\n// Around it, it lives INSIDE the SDK's lobby (sign-in, balances, profile and every system come from\n// the base): its showcase — the player's character on a pedestal — on the lobby's home\n// (ctx.lobby.setHero), and its sections as lobby tabs on the UI kit (ctx.features): worlds,\n// character, items, locker. \"Play\" there opens the scene; \"To the lobby\" in its pause returns.\n//\n// The SDK is used for client.ai — worlds, characters and item looks from a picture + description\n// (features \"voxel-world\" / \"voxel-character\" / \"voxel-item\" of the title; without them the AI-free\n// path) — and for network play through the lobby's rooms system (ctx.multiplayer): VoxelCraft only\n// declares how it plays online and takes the room when the host presses \"Play\".\n\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 // setup() runs after login, so the client already has a session for client.ai.\n // Общее у лобби и игры: вкладки и подиум работают ещё до первого «Играть» (сцены нет).\n const hub = new VoxelHub({\n ai: {\n world: createClientWorldAI(ctx.client),\n // персонаж и облик предмета по картинкам: свои фичи тайтла\n character: createClientCharacterAI(ctx.client),\n item: createClientItemAI(ctx.client),\n },\n play: () => ctx.navigate(MODE),\n open: (id, args) => ctx.features.open(id, args),\n });\n const { scene, agent, content, characterContent, itemContent, whenGame } =\n createVoxelcraft({\n hub,\n // «В лобби» в паузе — в лобби SDK\n lobby: {\n available: () => ctx.lobby.available(),\n open: () => ctx.lobby.open(),\n },\n // Яркие моменты → «Лучшие моменты» роликов игрока на платформе.\n highlight: (name, weight) => ctx.clips.mark(name, weight),\n });\n ctx.registerScene(scene);\n // Лобби SDK: персонаж на подиуме главной, вкладки «Миры», «Персонаж», «Предметы», «Шкафчик».\n registerLobby(ctx, hub);\n // своя дорога в лобби — пауза → «В лобби» (на тач-экране кнопка ⏸): круглой «назад» база не рисует\n ctx.registerRoute({ id: MODE, label: \"Voxel\", icon: \"🧊\", ownExit: true });\n // Сетевая игра: звезда вокруг хоста, свой чат в игре, до 8 игроков, опоздавших пускаем — у мира\n // есть вход в идущую игру (Hello → Welcome с миром хоста). Настройки комнаты — режим гостей и\n // команды для всех (их читает NetGame.roomSettingsOf).\n ctx.multiplayer.registerGame({\n mode: MODE,\n label: \"VoxelCraft\",\n topology: \"Host\",\n chatMode: \"P2P\",\n maxPlayers: 8,\n joinInProgress: true,\n settings: [\n {\n id: \"guestMode\",\n label: tr({ en: \"Guest mode\", ru: \"Режим гостей\" }),\n kind: \"choice\",\n options: [\n {\n value: \"survival\",\n label: tr({ en: \"Survival\", ru: \"Выживание\" }),\n },\n {\n value: \"creative\",\n label: tr({ en: \"Creative\", ru: \"Творчество\" }),\n },\n ],\n default: \"survival\",\n },\n {\n id: \"cheats\",\n label: tr({ en: \"Commands for everyone\", ru: \"Команды всем\" }),\n kind: \"toggle\",\n default: false,\n },\n ],\n start: async (room) => (await whenGame()).acceptRoom(room),\n });\n // Миры в Мастерской: модуль-браузер показывает, публикует и открывает их через этот обработчик,\n // не зная ничего про сейвы. Без модуля workshop в проекте регистрация просто ни на что не влияет.\n ctx.content.registerType(content);\n // персонажи и облики предметов из студии — тоже публикуются и скачиваются через Мастерскую\n ctx.content.registerType(characterContent);\n ctx.content.registerType(itemContent);\n // Глаза и руки агента: у canvas-игры DOM пустой, без этого она для него невидима.\n ctx.exposeToAgent(agent);\n },\n});\n"
850
850
  },
851
851
  {
852
852
  "path": "net/codec.ts",
@@ -866,7 +866,7 @@
866
866
  },
867
867
  {
868
868
  "path": "net/game/handlers.ts",
869
- "content": "import { parseCharacterSpec } from \"../../character/CharacterSpec\";\nimport { characterOf, registerCharacter } from \"../../character/characters\";\nimport {\n parseItemSkin,\n type WearSlot,\n} from \"../../character/items/ItemSkinSpec\";\nimport { registerSkin } from \"../../character/items/skins\";\nimport { isEmote } from \"../../character/anim/emotes\";\nimport { emotionFromText, isEmotion } from \"../../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { rules } from \"../../systems/Difficulty\";\nimport { cleanPin } from \"../../systems/WorldAtlas\";\nimport type { NetEntityAction } from \"../../entities/EntityManager\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n DIMS,\n M,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type PlayerMeta,\n type EmoteMsg,\n type LookMsg,\n type PinMsg,\n type PortalMsg,\n type Pose,\n type Roster,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"../protocol\";\nimport type { Peer } from \"./types\";\nimport { HELLO_RETRY_MS } from \"./shared\";\nimport type { NetGame } from \"../NetGame\";\nimport { tr } from \"../../i18n\";\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nexport async function onMessage(\n ng: NetGame,\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): Promise<void> {\n if (ng.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!ng.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") ng.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return onFast(ng, from, type, data);\n switch (type) {\n case M.HELLO:\n if (ng.isHost) await onHello(ng, from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && ng.welcomeWait) ng.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n ng.welcomeWait?.(\n r?.reason ??\n tr({ en: \"The host refused entry\", ru: \"Хост отказал во входе\" }),\n );\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !ng.isHost) ng.applyRoster(r);\n return;\n }\n case M.PMETA:\n return onMeta(ng, from, data);\n case M.BLOCKS:\n case M.EDITS:\n return onBlocks(ng, from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return onEntities(ng, from, type, data);\n case M.ENT_ACTION:\n return onEntAction(ng, from, data);\n case M.BE:\n return onBe(ng, from, data);\n case M.LOCK:\n return ng.onLock(from, data);\n case M.REPLY:\n return ng.onReply(from, data);\n case M.PORTAL:\n return onPortal(ng, from, data);\n case M.PIN:\n return onPin(ng, from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === ng.game.dimension && !sim.authority)\n for (const b of m.list) applyBe(ng, b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !ng.isHost) {\n ng.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n ng.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return onChat(ng, from, data);\n case M.EMOTE:\n return onEmote(ng, from, data);\n case M.LOOK:\n return onLook(ng, from, data);\n case M.HIT:\n case M.GIVE:\n return onToPlayer(ng, from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = ng.records.get(from);\n if (s && r && ng.isHost) {\n r.state = s.state;\n ng.game.mergeBeyond((s.state as { beyond?: unknown } | null)?.beyond);\n }\n return;\n }\n case M.DIM_ENTER:\n if (ng.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) ng.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === ng.game.dimension) ng.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) ng.bc.reset();\n return;\n case M.SHADOW:\n ng.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && ng.isHost && ng.mayCheat(from)) ng.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n}\n\nexport function onFast(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n if (type === M.POSE) {\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n ng.toAll(\"fast\", stamped, from);\n applyPose(ng, pose);\n return;\n }\n if (!ng.isHost) applyPose(ng, decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"fast\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const p of decodeEntPoses(data).list)\n ng.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n}\n\nexport function sendHello(ng: NetGame): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: ng.name };\n ng.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n}\n\nexport async function onHello(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await ng.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: tr({\n en: \"Game versions differ — reload the page\",\n ru: \"Версии игры отличаются — обновите страницу\",\n }),\n }),\n );\n return;\n }\n const g = ng.game;\n let r = ng.records.get(from);\n // Гость повторяет Hello, пока снимок не дошёл (HELLO_RETRY_MS): только что отправленный снимок\n // ещё в пути — второй такой же не нужен.\n if (r?.welcomedAt && Date.now() - r.welcomedAt < HELLO_RETRY_MS * 2) return;\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: ng.nextSlot++,\n name: (h.name || tr({ en: \"Player\", ru: \"Игрок\" })).slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: ng.ops.has(from),\n state: saved,\n };\n ng.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: ng.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: ng.roster(),\n };\n r.welcomedAt = Date.now();\n await ng.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n ng.broadcastRoster();\n ng.enterDim(from, r.dim);\n}\n\nexport function applyPose(ng: NetGame, p: Pose): void {\n if (p.slot === ng.slot) return;\n const peer = ng.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === ng.game.dimension && p.dim !== peer.dim && sim.authority)\n ng.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === ng.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name, ng.game.atlas);\n ng.game.scene.add(peer.avatar.group);\n applyPeerLook(peer);\n }\n peer.avatar.held = peer.held;\n peer.avatar.sleeping = peer.sleeping;\n peer.avatar.heldSkin = peer.heldSkin ?? null;\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(ng.game.world, p.slot, peer.userId, ng);\n peer.remote.world = ng.game.world;\n peer.remote.held = peer.held;\n peer.remote.applyPose(p);\n }\n } else dropPeerView(ng, peer);\n}\n\nexport function dropPeerView(ng: NetGame, peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(ng.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n}\n\nexport async function onMeta(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n ng.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.look = typeof m.look === \"string\" ? m.look : null;\n peer.outfit = m.outfit && typeof m.outfit === \"object\" ? m.outfit : {};\n peer.heldSkin = typeof m.heldSkin === \"string\" ? m.heldSkin : null;\n applyPeerLook(peer);\n if (peer.avatar) peer.avatar.riding = !!m.riding;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n}\n\nexport function onBlocks(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n const { dim, list } = decodeBlocks(data);\n if (ng.isHost && from !== ng.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (ng.owners.get(dim) !== from) return;\n ng.diffs.applyAll(dim, list);\n ng.toAll(\"rel\", data, from);\n } else {\n const owner = ng.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n ng.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n ng.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (ng.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = ng.owners.get(dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== ng.game.dimension) return;\n applyBlocks(ng, list, true);\n return;\n }\n if (from === ng.selfId) return;\n if (!ng.isHost) ng.diffs.applyAll(dim, list);\n if (dim === ng.game.dimension && !sim.authority) applyBlocks(ng, list, false);\n}\n\nexport function applyBlocks(\n ng: NetGame,\n list: readonly BlockDelta[],\n asOwner: boolean,\n): void {\n const w = ng.game.world;\n ng.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n ng.applyingRemote = false;\n }\n}\n\nexport async function onEntities(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) ng.toDim(m.dim, \"rel\", data, from);\n if (m.dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const [id, snap] of m.list) ng.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"rel\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const id of ids) ng.game.entities.netRemove(id);\n}\n\nexport async function onEntAction(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (ng.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === ng.selfId ? ng.slot : ng.records.get(from)?.slot;\n const owner = ng.owners.get(m.dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== ng.game.dimension) return;\n const em = ng.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker = m.from !== undefined ? ng.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n ng.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n}\n\nexport function entAction(ng: NetGame, action: NetEntityAction): void {\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: ng.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n}\n\nexport async function onPortal(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (ng.isHost) ng.toAll(\"rel\", data, from);\n ng.game.applyNetPortal(m.dim, m.p, !!m.add);\n}\n\n/**\n * Метка карты. Хост проверяет право (менять и удалять — автор или хост), ставит автора новой метке\n * и рассылает остальным; гость верит хосту.\n */\nexport async function onPin(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PinMsg>(data);\n if (!m) return;\n const map = ng.game.worldMap;\n if (!ng.isHost) {\n map.applyRemote(\n m.op === \"remove\"\n ? { op: \"remove\", id: Number(m.id) }\n : { op: \"set\", pin: cleanPin(m.pin)! },\n );\n return;\n }\n const id = m.op === \"remove\" ? Number(m.id) : cleanPin(m.pin)?.id;\n if (id === undefined || !Number.isInteger(id)) return;\n const old = map.pins.find((p) => p.id === id);\n if (old && old.by !== from) return; // чужая метка (или хоста)\n if (m.op === \"remove\") {\n if (map.applyRemote({ op: \"remove\", id }))\n ng.toAll(\"rel\", encodeJsonSync(M.PIN, m), from);\n return;\n }\n const pin = cleanPin(m.pin);\n if (!pin) return;\n pin.by = from;\n pin.byName = ng.records.get(from)?.name ?? pin.byName;\n if (map.applyRemote({ op: \"set\", pin }))\n ng.toAll(\n \"rel\",\n encodeJsonSync(M.PIN, { op: \"set\", pin } satisfies PinMsg),\n from,\n );\n}\n\nexport async function onBe(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = ng.owners.get(m.dim);\n if (ng.isHost && from !== ng.selfId) {\n if (owner === from) {\n storeBe(ng, m);\n ng.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n } else if (ng.isHost && owner && owner !== ng.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void ng.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== ng.game.dimension || from === ng.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else applyBe(ng, m);\n}\n\nexport function applyBe(ng: NetGame, m: BeMsg): void {\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n ng.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n}\n\nexport function storeBe(ng: NetGame, m: BeMsg): void {\n let s = ng.beStore.get(m.dim);\n if (!s) ng.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n}\n\nexport async function onToPlayer(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (ng.isHost && m.to !== ng.slot) {\n const to = ng.userOfSlot(m.to);\n if (to && to !== from) void ng.send(to, \"rel\", data);\n return;\n }\n if (m.to !== ng.slot) return;\n const g = ng.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n}\n\nexport async function onChat(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.from = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who = m.from !== undefined ? (ng.peers.get(m.from)?.name ?? \"?\") : \"?\";\n ng.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n // смайлик в сообщении — эмоция на лице его персонажа\n const feel = emotionFromText(String(m.text));\n if (feel && m.from !== undefined)\n ng.peers.get(m.from)?.avatar?.actor.setEmotion(feel, 4);\n}\n\n/** Облик другого игрока на его аватаре: персонаж (если рецепт уже дошёл) и надетое. */\nexport function applyPeerLook(peer: Peer): void {\n const a = peer.avatar;\n if (!a) return;\n const spec = characterOf(peer.look);\n if (spec) a.setSpec(spec);\n a.actor.setOutfit((peer.outfit ?? {}) as Partial<Record<WearSlot, string>>);\n}\n\n/** Рецепты облика другого игрока: запомнить (персонажа и предметы) и надеть на его аватар. */\nexport async function onLook(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<LookMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", await encodeJson(M.LOOK, m), from);\n }\n const c = parseCharacterSpec(m.character);\n if (c.ok) registerCharacter(c.spec);\n if (m.skins && typeof m.skins === \"object\")\n for (const raw of Object.values(m.skins).slice(0, 8)) {\n const s = parseItemSkin(raw);\n if (s.ok) registerSkin(s.spec);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (peer) applyPeerLook(peer);\n}\n\n/** Жест или эмоция другого игрока: хост ставит слот отправителя и рассылает. */\nexport async function onEmote(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EmoteMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.EMOTE, m), from);\n }\n const actor =\n m.slot !== undefined ? ng.peers.get(m.slot)?.avatar?.actor : undefined;\n if (!actor) return;\n if (isEmote(m.emote)) actor.playEmote(m.emote);\n if (isEmotion(m.emotion))\n actor.setEmotion(\n m.emotion,\n Math.min(30, Math.max(0, Number(m.seconds) || 4)),\n );\n}\n"
869
+ "content": "import { parseCharacterSpec } from \"../../character/CharacterSpec\";\nimport { characterOf, registerCharacter } from \"../../character/characters\";\nimport {\n parseItemSkin,\n type WearSlot,\n} from \"../../character/items/ItemSkinSpec\";\nimport { registerSkin } from \"../../character/items/skins\";\nimport { isEmote } from \"../../character/anim/emotes\";\nimport { emotionFromText, isEmotion } from \"../../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { rules } from \"../../systems/Difficulty\";\nimport { cleanPin } from \"../../systems/WorldAtlas\";\nimport type { NetEntityAction } from \"../../entities/EntityManager\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n DIMS,\n M,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type PlayerMeta,\n type EmoteMsg,\n type LookMsg,\n type PinMsg,\n type PortalMsg,\n type Pose,\n type Roster,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"../protocol\";\nimport type { Peer } from \"./types\";\nimport { HELLO_RETRY_MS } from \"./shared\";\nimport type { NetGame } from \"../NetGame\";\nimport { tr } from \"../../i18n\";\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nexport async function onMessage(\n ng: NetGame,\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): Promise<void> {\n if (ng.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!ng.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") ng.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return onFast(ng, from, type, data);\n switch (type) {\n case M.HELLO:\n if (ng.isHost) await onHello(ng, from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && ng.welcomeWait) ng.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n ng.welcomeWait?.(\n r?.reason ??\n tr({ en: \"The host refused entry\", ru: \"Хост отказал во входе\" }),\n );\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !ng.isHost) ng.applyRoster(r);\n return;\n }\n case M.PMETA:\n return onMeta(ng, from, data);\n case M.BLOCKS:\n case M.EDITS:\n return onBlocks(ng, from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return onEntities(ng, from, type, data);\n case M.ENT_ACTION:\n return onEntAction(ng, from, data);\n case M.BE:\n return onBe(ng, from, data);\n case M.LOCK:\n return ng.onLock(from, data);\n case M.REPLY:\n return ng.onReply(from, data);\n case M.PORTAL:\n return onPortal(ng, from, data);\n case M.PIN:\n return onPin(ng, from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === ng.game.dimension && !sim.authority)\n for (const b of m.list) applyBe(ng, b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !ng.isHost) {\n ng.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n ng.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return onChat(ng, from, data);\n case M.EMOTE:\n return onEmote(ng, from, data);\n case M.LOOK:\n return onLook(ng, from, data);\n case M.HIT:\n case M.GIVE:\n return onToPlayer(ng, from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = ng.records.get(from);\n if (s && r && ng.isHost) {\n r.state = s.state;\n ng.game.mergeBeyond((s.state as { beyond?: unknown } | null)?.beyond);\n }\n return;\n }\n case M.DIM_ENTER:\n if (ng.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) ng.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === ng.game.dimension) ng.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) ng.bc.reset();\n return;\n case M.SHADOW:\n ng.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && ng.isHost && ng.mayCheat(from)) ng.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n}\n\nexport function onFast(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n if (type === M.POSE) {\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n ng.toAll(\"fast\", stamped, from);\n applyPose(ng, pose);\n return;\n }\n if (!ng.isHost) applyPose(ng, decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"fast\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const p of decodeEntPoses(data).list)\n ng.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n}\n\nexport function sendHello(ng: NetGame): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: ng.name };\n ng.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n}\n\nexport async function onHello(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await ng.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: tr({\n en: \"Game versions differ — reload the page\",\n ru: \"Версии игры отличаются — обновите страницу\",\n }),\n }),\n );\n return;\n }\n const g = ng.game;\n let r = ng.records.get(from);\n // Гость повторяет Hello, пока снимок не дошёл (HELLO_RETRY_MS): только что отправленный снимок\n // ещё в пути — второй такой же не нужен.\n if (r?.welcomedAt && Date.now() - r.welcomedAt < HELLO_RETRY_MS * 2) return;\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: ng.nextSlot++,\n name: (h.name || tr({ en: \"Player\", ru: \"Игрок\" })).slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: ng.ops.has(from),\n state: saved,\n };\n ng.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: ng.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: ng.roster(),\n };\n r.welcomedAt = Date.now();\n await ng.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n ng.broadcastRoster();\n ng.enterDim(from, r.dim);\n}\n\nexport function applyPose(ng: NetGame, p: Pose): void {\n if (p.slot === ng.slot) return;\n const peer = ng.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === ng.game.dimension && p.dim !== peer.dim && sim.authority)\n ng.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === ng.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name, ng.game.atlas);\n ng.game.scene.add(peer.avatar.group);\n applyPeerLook(peer);\n }\n peer.avatar.held = peer.held;\n peer.avatar.sleeping = peer.sleeping;\n peer.avatar.heldSkin = peer.heldSkin ?? null;\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(ng.game.world, p.slot, peer.userId, ng);\n peer.remote.world = ng.game.world;\n peer.remote.held = peer.held;\n peer.remote.gold = !!peer.gold;\n peer.remote.applyPose(p);\n }\n } else dropPeerView(ng, peer);\n}\n\nexport function dropPeerView(ng: NetGame, peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(ng.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n}\n\nexport async function onMeta(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n ng.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.look = typeof m.look === \"string\" ? m.look : null;\n peer.outfit = m.outfit && typeof m.outfit === \"object\" ? m.outfit : {};\n peer.heldSkin = typeof m.heldSkin === \"string\" ? m.heldSkin : null;\n peer.gold = m.gold === true;\n if (peer.remote) peer.remote.gold = peer.gold;\n applyPeerLook(peer);\n if (peer.avatar) peer.avatar.riding = !!m.riding;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n}\n\nexport function onBlocks(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n const { dim, list } = decodeBlocks(data);\n if (ng.isHost && from !== ng.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (ng.owners.get(dim) !== from) return;\n ng.diffs.applyAll(dim, list);\n ng.toAll(\"rel\", data, from);\n } else {\n const owner = ng.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n ng.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n ng.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (ng.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = ng.owners.get(dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== ng.game.dimension) return;\n applyBlocks(ng, list, true);\n return;\n }\n if (from === ng.selfId) return;\n if (!ng.isHost) ng.diffs.applyAll(dim, list);\n if (dim === ng.game.dimension && !sim.authority) applyBlocks(ng, list, false);\n}\n\nexport function applyBlocks(\n ng: NetGame,\n list: readonly BlockDelta[],\n asOwner: boolean,\n): void {\n const w = ng.game.world;\n ng.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n ng.applyingRemote = false;\n }\n}\n\nexport async function onEntities(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) ng.toDim(m.dim, \"rel\", data, from);\n if (m.dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const [id, snap] of m.list) ng.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"rel\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const id of ids) ng.game.entities.netRemove(id);\n}\n\nexport async function onEntAction(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (ng.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === ng.selfId ? ng.slot : ng.records.get(from)?.slot;\n const owner = ng.owners.get(m.dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== ng.game.dimension) return;\n const em = ng.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker = m.from !== undefined ? ng.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n ng.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n}\n\nexport function entAction(ng: NetGame, action: NetEntityAction): void {\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: ng.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n}\n\nexport async function onPortal(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (ng.isHost) ng.toAll(\"rel\", data, from);\n ng.game.applyNetPortal(m.dim, m.p, !!m.add);\n}\n\n/**\n * Метка карты. Хост проверяет право (менять и удалять — автор или хост), ставит автора новой метке\n * и рассылает остальным; гость верит хосту.\n */\nexport async function onPin(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PinMsg>(data);\n if (!m) return;\n const map = ng.game.worldMap;\n if (!ng.isHost) {\n map.applyRemote(\n m.op === \"remove\"\n ? { op: \"remove\", id: Number(m.id) }\n : { op: \"set\", pin: cleanPin(m.pin)! },\n );\n return;\n }\n const id = m.op === \"remove\" ? Number(m.id) : cleanPin(m.pin)?.id;\n if (id === undefined || !Number.isInteger(id)) return;\n const old = map.pins.find((p) => p.id === id);\n if (old && old.by !== from) return; // чужая метка (или хоста)\n if (m.op === \"remove\") {\n if (map.applyRemote({ op: \"remove\", id }))\n ng.toAll(\"rel\", encodeJsonSync(M.PIN, m), from);\n return;\n }\n const pin = cleanPin(m.pin);\n if (!pin) return;\n pin.by = from;\n pin.byName = ng.records.get(from)?.name ?? pin.byName;\n if (map.applyRemote({ op: \"set\", pin }))\n ng.toAll(\n \"rel\",\n encodeJsonSync(M.PIN, { op: \"set\", pin } satisfies PinMsg),\n from,\n );\n}\n\nexport async function onBe(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = ng.owners.get(m.dim);\n if (ng.isHost && from !== ng.selfId) {\n if (owner === from) {\n storeBe(ng, m);\n ng.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n } else if (ng.isHost && owner && owner !== ng.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void ng.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== ng.game.dimension || from === ng.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else applyBe(ng, m);\n}\n\nexport function applyBe(ng: NetGame, m: BeMsg): void {\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n ng.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n}\n\nexport function storeBe(ng: NetGame, m: BeMsg): void {\n let s = ng.beStore.get(m.dim);\n if (!s) ng.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n}\n\nexport async function onToPlayer(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (ng.isHost && m.to !== ng.slot) {\n const to = ng.userOfSlot(m.to);\n if (to && to !== from) void ng.send(to, \"rel\", data);\n return;\n }\n if (m.to !== ng.slot) return;\n const g = ng.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n}\n\nexport async function onChat(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.from = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who = m.from !== undefined ? (ng.peers.get(m.from)?.name ?? \"?\") : \"?\";\n ng.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n // смайлик в сообщении — эмоция на лице его персонажа\n const feel = emotionFromText(String(m.text));\n if (feel && m.from !== undefined)\n ng.peers.get(m.from)?.avatar?.actor.setEmotion(feel, 4);\n}\n\n/** Облик другого игрока на его аватаре: персонаж (если рецепт уже дошёл) и надетое. */\nexport function applyPeerLook(peer: Peer): void {\n const a = peer.avatar;\n if (!a) return;\n const spec = characterOf(peer.look);\n if (spec) a.setSpec(spec);\n a.actor.setOutfit((peer.outfit ?? {}) as Partial<Record<WearSlot, string>>);\n}\n\n/** Рецепты облика другого игрока: запомнить (персонажа и предметы) и надеть на его аватар. */\nexport async function onLook(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<LookMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", await encodeJson(M.LOOK, m), from);\n }\n const c = parseCharacterSpec(m.character);\n if (c.ok) registerCharacter(c.spec);\n if (m.skins && typeof m.skins === \"object\")\n for (const raw of Object.values(m.skins).slice(0, 8)) {\n const s = parseItemSkin(raw);\n if (s.ok) registerSkin(s.spec);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (peer) applyPeerLook(peer);\n}\n\n/** Жест или эмоция другого игрока: хост ставит слот отправителя и рассылает. */\nexport async function onEmote(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EmoteMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.EMOTE, m), from);\n }\n const actor =\n m.slot !== undefined ? ng.peers.get(m.slot)?.avatar?.actor : undefined;\n if (!actor) return;\n if (isEmote(m.emote)) actor.playEmote(m.emote);\n if (isEmotion(m.emotion))\n actor.setEmotion(\n m.emotion,\n Math.min(30, Math.max(0, Number(m.seconds) || 4)),\n );\n}\n"
870
870
  },
871
871
  {
872
872
  "path": "net/game/hostMigration.ts",
@@ -886,11 +886,11 @@
886
886
  },
887
887
  {
888
888
  "path": "net/game/tick.ts",
889
- "content": "import { specHash } from \"../../character/CharacterSpec\";\nimport { skinOf, skinSpecs, stackSkinHash } from \"../../character/items/skins\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n POSE_SPRINT,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n type BeMsg,\n type EntSpawnMsg,\n type PlayerMeta,\n type LookMsg,\n} from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function afterTick(ng: NetGame): void {\n if (ng.closed) return;\n // Сессию закрыли в обход событий (страховка к \"left\"): дальше играть не на чем.\n if (ng.session.closed) return ng.end(null);\n const g = ng.game;\n ng.tickN++;\n const dim = g.dimension;\n\n if (ng.pending.length) {\n const list = ng.pending;\n ng.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (ng.isHost && type === M.BLOCKS) ng.toAll(\"rel\", msg);\n else ng.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) ng.entAction(a);\n syncRide(ng);\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of ng.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) ng.hit(peer.slot, 0, \"knock\", imp);\n }\n if (ng.tickN % 2 === 0 && hasOthersIn(ng, dim)) {\n const d = ng.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n ownerSend(\n ng,\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) ownerSend(ng, \"fast\", b);\n if (d.removes.length)\n ownerSend(ng, \"rel\", encodeEntRemove(dim, d.removes));\n }\n if (ng.tickN % 10 === 0)\n for (const { be, key } of ng.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (ng.isHost) ng.storeBe(msg);\n if (hasOthersIn(ng, dim))\n ownerSend(ng, \"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (ng.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of ng.replicaBe.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [number, number, number];\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n sendPose(ng);\n sendMetaIfChanged(ng);\n void sendLookIfChanged(ng);\n if (ng.tickN % 200 === 0 && !ng.isHost) ng.sendPlayerState();\n\n if (ng.isHost) {\n if (ng.tickN % 100 === 0)\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (ng.tickN % 200 === 0) ng.pickSuccessorAndShadow();\n } else ng.watchHost();\n}\n\nexport function syncRide(ng: NetGame): void {\n const g = ng.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== ng.driving) {\n if (ng.driving !== null)\n ng.entAction({ a: \"ride\", id: ng.driving, on: false });\n if (id !== null) ng.entAction({ a: \"ride\", id, on: true });\n ng.driving = id;\n em.drivenId = id;\n }\n if (id === null || ng.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n ng.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n}\n\nexport function ownerSend(\n ng: NetGame,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): void {\n if (ng.isHost) ng.toDim(ng.game.dimension, channel, data, ng.selfId);\n else ng.toHost(channel, data);\n}\n\nexport function hasOthersIn(ng: NetGame, dim: DimensionId): boolean {\n for (const p of ng.peers.values()) if (p.dim === dim) return true;\n return false;\n}\n\nexport function sendPose(ng: NetGame): void {\n const g = ng.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0) |\n (p._sprinting ? POSE_SPRINT : 0);\n const msg = encodePose({\n slot: ng.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (ng.isHost) ng.toAll(\"fast\", msg);\n else ng.toHost(\"fast\", msg);\n}\n\nexport function sendMetaIfChanged(ng: NetGame): void {\n const g = ng.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: ng.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: ng.name,\n sleeping: g.netSleeping,\n riding: !!g.player.riding,\n look: specHash(g.character.spec),\n outfit: { ...g.character.outfit },\n heldSkin: stackSkinHash(g.inventory.heldItem()),\n };\n const j = JSON.stringify(meta);\n if (j === ng.lastMeta) return;\n ng.lastMeta = j;\n if (ng.isHost) {\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n hostSleepCheck(ng, ng.selfId, meta);\n } else ng.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n}\n\n/** Облик сменился (или вошёл новый игрок) — разослать рецепт персонажа и облики предметов. */\nexport async function sendLookIfChanged(ng: NetGame): Promise<void> {\n const g = ng.game;\n const hashes = [\n ...Object.values(g.character.outfit),\n stackSkinHash(g.inventory.heldItem()),\n ].filter((h): h is string => !!h && !!skinOf(h));\n const key = specHash(g.character.spec) + \"|\" + hashes.sort().join(\",\");\n if (key === ng.lastLook) return;\n ng.lastLook = key;\n const msg: LookMsg = {\n slot: ng.slot,\n character: g.character.spec,\n skins: skinSpecs(hashes),\n };\n const data = await encodeJson(M.LOOK, msg);\n if (ng.isHost) ng.toAll(\"rel\", data);\n else ng.toHost(\"rel\", data);\n}\n\nexport function hostSleepCheck(\n ng: NetGame,\n _who: string,\n _m: PlayerMeta,\n): void {\n const g = ng.game;\n const inOverworld = [...ng.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === ng.selfId\n ? g.netSleeping\n : ([...ng.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n}\n"
889
+ "content": "import { specHash } from \"../../character/CharacterSpec\";\nimport { skinOf, skinSpecs, stackSkinHash } from \"../../character/items/skins\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n POSE_SPRINT,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n type BeMsg,\n type EntSpawnMsg,\n type PlayerMeta,\n type LookMsg,\n} from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function afterTick(ng: NetGame): void {\n if (ng.closed) return;\n // Сессию закрыли в обход событий (страховка к \"left\"): дальше играть не на чем.\n if (ng.session.closed) return ng.end(null);\n const g = ng.game;\n ng.tickN++;\n const dim = g.dimension;\n\n if (ng.pending.length) {\n const list = ng.pending;\n ng.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (ng.isHost && type === M.BLOCKS) ng.toAll(\"rel\", msg);\n else ng.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) ng.entAction(a);\n syncRide(ng);\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of ng.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) ng.hit(peer.slot, 0, \"knock\", imp);\n }\n if (ng.tickN % 2 === 0 && hasOthersIn(ng, dim)) {\n const d = ng.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n ownerSend(\n ng,\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) ownerSend(ng, \"fast\", b);\n if (d.removes.length)\n ownerSend(ng, \"rel\", encodeEntRemove(dim, d.removes));\n }\n if (ng.tickN % 10 === 0)\n for (const { be, key } of ng.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (ng.isHost) ng.storeBe(msg);\n if (hasOthersIn(ng, dim))\n ownerSend(ng, \"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (ng.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of ng.replicaBe.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [number, number, number];\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n sendPose(ng);\n sendMetaIfChanged(ng);\n void sendLookIfChanged(ng);\n if (ng.tickN % 200 === 0 && !ng.isHost) ng.sendPlayerState();\n\n if (ng.isHost) {\n if (ng.tickN % 100 === 0)\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (ng.tickN % 200 === 0) ng.pickSuccessorAndShadow();\n } else ng.watchHost();\n}\n\nexport function syncRide(ng: NetGame): void {\n const g = ng.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== ng.driving) {\n if (ng.driving !== null)\n ng.entAction({ a: \"ride\", id: ng.driving, on: false });\n if (id !== null) ng.entAction({ a: \"ride\", id, on: true });\n ng.driving = id;\n em.drivenId = id;\n }\n if (id === null || ng.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n ng.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n}\n\nexport function ownerSend(\n ng: NetGame,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): void {\n if (ng.isHost) ng.toDim(ng.game.dimension, channel, data, ng.selfId);\n else ng.toHost(channel, data);\n}\n\nexport function hasOthersIn(ng: NetGame, dim: DimensionId): boolean {\n for (const p of ng.peers.values()) if (p.dim === dim) return true;\n return false;\n}\n\nexport function sendPose(ng: NetGame): void {\n const g = ng.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0) |\n (p._sprinting ? POSE_SPRINT : 0);\n const msg = encodePose({\n slot: ng.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (ng.isHost) ng.toAll(\"fast\", msg);\n else ng.toHost(\"fast\", msg);\n}\n\nexport function sendMetaIfChanged(ng: NetGame): void {\n const g = ng.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: ng.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: ng.name,\n sleeping: g.netSleeping,\n riding: !!g.player.riding,\n look: specHash(g.character.spec),\n outfit: { ...g.character.outfit },\n heldSkin: stackSkinHash(g.inventory.heldItem()),\n gold: !!g.player.goldArmor?.(),\n };\n const j = JSON.stringify(meta);\n if (j === ng.lastMeta) return;\n ng.lastMeta = j;\n if (ng.isHost) {\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n hostSleepCheck(ng, ng.selfId, meta);\n } else ng.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n}\n\n/** Облик сменился (или вошёл новый игрок) — разослать рецепт персонажа и облики предметов. */\nexport async function sendLookIfChanged(ng: NetGame): Promise<void> {\n const g = ng.game;\n const hashes = [\n ...Object.values(g.character.outfit),\n stackSkinHash(g.inventory.heldItem()),\n ].filter((h): h is string => !!h && !!skinOf(h));\n const key = specHash(g.character.spec) + \"|\" + hashes.sort().join(\",\");\n if (key === ng.lastLook) return;\n ng.lastLook = key;\n const msg: LookMsg = {\n slot: ng.slot,\n character: g.character.spec,\n skins: skinSpecs(hashes),\n };\n const data = await encodeJson(M.LOOK, msg);\n if (ng.isHost) ng.toAll(\"rel\", data);\n else ng.toHost(\"rel\", data);\n}\n\nexport function hostSleepCheck(\n ng: NetGame,\n _who: string,\n _m: PlayerMeta,\n): void {\n const g = ng.game;\n const inOverworld = [...ng.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === ng.selfId\n ? g.netSleeping\n : ([...ng.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n}\n"
890
890
  },
891
891
  {
892
892
  "path": "net/game/types.ts",
893
- "content": "import type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport type { Pose } from \"../protocol\";\n\n/**\n * Комната, которую отдаёт лобби (`MultiplayerRoom` контракта модулей — здесь только нужные поля, чтобы\n * сетевой код игры не зависел от контракта).\n */\nexport interface NetRoom {\n readonly session: Session;\n readonly isHost: boolean;\n readonly lateJoin: boolean;\n readonly settings: Readonly<Record<string, string | boolean>>;\n leave(): Promise<void>;\n}\n\n/** Всё, что знаем о другом игроке. */\nexport interface Peer {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n pose: Pose | null;\n held: string | null;\n mode: \"survival\" | \"creative\";\n sleeping: boolean;\n avatar: Avatar | null;\n remote: RemotePlayer | null;\n /** Облик (PlayerMeta): хэш персонажа, надетое, облик предмета в руке. */\n look?: string | null;\n outfit?: Record<string, string>;\n heldSkin?: string | null;\n}\n\n/** Хост: сейв игрока (его инвентарь авторитетен у него — здесь копия для мира хоста). */\nexport interface HostRecord {\n slot: number;\n name: string;\n dim: DimensionId;\n op: boolean;\n state: unknown | null;\n /** Когда хост последний раз отправил этому игроку снимок мира (повторный Hello — см. onHello). */\n welcomedAt?: number;\n}\n"
893
+ "content": "import type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport type { Pose } from \"../protocol\";\n\n/**\n * Комната, которую отдаёт лобби (`MultiplayerRoom` контракта модулей — здесь только нужные поля, чтобы\n * сетевой код игры не зависел от контракта).\n */\nexport interface NetRoom {\n readonly session: Session;\n readonly isHost: boolean;\n readonly lateJoin: boolean;\n readonly settings: Readonly<Record<string, string | boolean>>;\n leave(): Promise<void>;\n}\n\n/** Всё, что знаем о другом игроке. */\nexport interface Peer {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n pose: Pose | null;\n held: string | null;\n mode: \"survival\" | \"creative\";\n sleeping: boolean;\n avatar: Avatar | null;\n remote: RemotePlayer | null;\n /** Облик (PlayerMeta): хэш персонажа, надетое, облик предмета в руке. */\n look?: string | null;\n outfit?: Record<string, string>;\n heldSkin?: string | null;\n /** Надета золотая вещь (PlayerMeta.gold). */\n gold?: boolean;\n}\n\n/** Хост: сейв игрока (его инвентарь авторитетен у него — здесь копия для мира хоста). */\nexport interface HostRecord {\n slot: number;\n name: string;\n dim: DimensionId;\n op: boolean;\n state: unknown | null;\n /** Когда хост последний раз отправил этому игроку снимок мира (повторный Hello — см. onHello). */\n welcomedAt?: number;\n}\n"
894
894
  },
895
895
  {
896
896
  "path": "net/NetGame.ts",
@@ -902,11 +902,11 @@
902
902
  },
903
903
  {
904
904
  "path": "net/protocol.ts",
905
- "content": "// Протокол мультиплеера VoxelCraft поверх `@idosgames/core/realtime` (звезда: всё идёт через хоста).\n//\n// Роли:\n// • ХОСТ — координатор: принимает игроков, ведёт список (roster), назначает владельцев измерений,\n// хранит замороженные измерения и состояния игроков (для сейва и передачи хоста), пересылает.\n// • ВЛАДЕЛЕЦ ИЗМЕРЕНИЯ — считает мир своего измерения (net/sim): хост, если он там, иначе первый\n// вошедший игрок. Рассылает изменения блоков, сущности, блочные сущности.\n// • РЕПЛИКА — любой другой игрок в измерении: шлёт свои правки мира и действия с сущностями.\n//\n// Игрок и инвентарь — у КАЖДОГО свои (решение владельца 27.09.2026, вариант А): игрок сам считает\n// себя (физику, голод, смерть, инвентарь), хост и владельцы измерений его только видят и «выдают»\n// ему подобранное, урон и отброс.\n\nimport { ByteReader, ByteWriter } from \"./codec\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack, WorldSource } from \"../types\";\nimport type { NetEntityAction, NetEntitySnap } from \"../entities/EntityManager\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\n\n/** Версия протокола: разные версии не играют вместе (Hello → «обновите страницу»). */\nexport const PROTOCOL = 2;\n\nexport const M = {\n // ---- надёжный канал (rel)\n HELLO: 1, // гость → хост: JSON Hello\n WELCOME: 2, // хост → гость: JSON Welcome (сжатый)\n REJECT: 3, // хост → гость: JSON { reason }\n ROSTER: 4, // хост → все: JSON Roster\n PMETA: 6, // игрок → хост → все: JSON PlayerMeta (предмет в руке, режим, имя)\n BLOCKS: 7, // владелец → хост → все: двоичные изменения блоков\n EDITS: 8, // реплика → хост → владелец: двоичные правки\n ENT_SPAWN: 9, // владелец → хост → игроки измерения: JSON { dim, list: [id, snap][] }\n ENT_REMOVE: 11, // владелец → …: двоичный список id\n ENT_ACTION: 12, // реплика → хост → владелец: JSON { dim, from, action }\n BE: 13, // владелец/реплика ↔ хост ↔ все: JSON { dim, x, y, z, be }\n WORLD: 15, // хост → все: JSON WorldState (время, погода, сложность, Предел)\n CHAT: 16, // любой → хост → все: JSON { from?, text }\n HIT: 17, // владелец → хост → игрок: JSON Hit (урон, отброс, эффект)\n GIVE: 18, // владелец → хост → игрок: JSON Give (предмет, опыт)\n PSTATE: 19, // игрок → хост: JSON { state } — сейв игрока для мира хоста и передачи хоста\n DIM_ENTER: 20, // игрок → хост: JSON { dim, handoff? } — сменил измерение\n DIM_OWN: 21, // хост → игрок: JSON { dim, save } — теперь ты владелец измерения\n DIM_RESYNC: 22, // хост → владелец: JSON { dim } — пришли всем полный снимок сущностей\n SHADOW: 24, // хост → преемник: JSON Shadow (сжатый) — теневая копия мира\n COMMAND: 25, // игрок → хост: JSON { text } — команда мира (для операторов)\n BE_LIST: 26, // хост → игрок: JSON { dim, list } — блочные сущности измерения при входе\n LOCK: 27, // игрок → хост → владелец: JSON LockMsg — занять / отпустить контейнер\n REPLY: 28, // владелец → хост → игрок: JSON ReplyMsg — ответ на LOCK, отказ сесть\n PORTAL: 29, // игрок → хост → все: JSON PortalMsg — реестр порталов\n EMOTE: 30, // игрок → хост → все: JSON EmoteMsg — жест и/или эмоция персонажа\n LOOK: 31, // игрок → хост → все: JSON LookMsg (сжатый) — рецепт персонажа и облики предметов\n PIN: 32, // игрок → хост → все: JSON PinMsg — метка карты (поставить, изменить, удалить)\n // ---- быстрый канал (fast)\n POSE: 5, // игрок → хост → все: двоичная поза\n ENT_POSE: 10, // владелец → …: двоичные позы сущностей\n} as const;\n\n// ============================================================ JSON-сообщения\n\nexport interface Hello {\n v: number;\n /** Хэш набора блоков и предметов: разные сборки игры не совместимы. */\n build: string;\n name: string;\n}\n\nexport interface WorldMeta {\n source: WorldSource;\n palette: string[];\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n portals: unknown;\n /** Метки карты мира (systems/WorldAtlas MapPin[]). Нет у старого хоста. */\n pins?: unknown;\n daysAwake: number;\n /** Имя мира у хоста — переходит к новому хосту вместе с миром. Нет у старого хоста. */\n name?: string;\n}\n\nexport interface RosterEntry {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n}\n\nexport interface Roster {\n hostId: string;\n players: RosterEntry[];\n /** Владелец каждого «живого» измерения. */\n owners: Partial<Record<DimensionId, string>>;\n /** Настройки комнаты («Открыть для сети»). */\n settings: RoomSettings;\n}\n\nexport interface RoomSettings {\n /** Режим, в котором входят гости. */\n guestMode: \"survival\" | \"creative\";\n /** Команды всем, а не только операторам. */\n cheatsForAll: boolean;\n}\n\nexport interface Welcome {\n v: number;\n you: number;\n world: WorldMeta;\n /** Изменённые блоки ВСЕХ измерений: у гостя полная копия — на ней держится и передача хоста. */\n diffs: Partial<Record<DimensionId, DimensionSave[\"chunks\"]>>;\n /** Где появиться и в каком измерении (у вернувшегося — там, где был). */\n spawn: { dim: DimensionId; pos: [number, number, number] };\n /** Сохранённое состояние этого игрока в мире хоста (вернулся в тот же мир). */\n state: unknown | null;\n roster: Roster;\n}\n\nexport interface PlayerMeta {\n slot?: number;\n held: string | null;\n mode: \"survival\" | \"creative\";\n name: string;\n /** Едет (лошадь, вагонетка, лодка) — поза седока у других. */\n riding?: boolean;\n /** Хэш рецепта персонажа; сам рецепт — сообщением LOOK. */\n look?: string;\n /** Надетое: слот → хэш облика. */\n outfit?: Record<string, string>;\n /** Облик предмета в руке (tag.skin). */\n heldSkin?: string | null;\n}\n\n/**\n * Облик игрока целиком: рецепт персонажа и облики надетого и того, что в руке. Шлётся при смене и\n * заново, когда в игру вошёл новый игрок (у него этих рецептов ещё нет).\n */\nexport interface LookMsg {\n slot?: number;\n character: unknown;\n skins: Record<string, unknown>;\n}\n\n/** Жест и/или эмоция персонажа (character/anim): слот ставит хост. */\nexport interface EmoteMsg {\n slot?: number;\n emote?: string;\n emotion?: string;\n seconds?: number;\n}\n\nexport interface WorldState {\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n daysAwake: number;\n}\n\nexport interface Hit {\n to: number;\n amount: number;\n cause: string;\n /** Толчок (изменение скорости), который насчитал владелец измерения. */\n knock?: [number, number, number];\n effect?: { id: string; seconds: number; level: number };\n}\n\nexport interface Give {\n to: number;\n stack?: ItemStack;\n xp?: number;\n}\n\nexport interface EntSpawnMsg {\n dim: DimensionId;\n list: [number, NetEntitySnap][];\n}\n\nexport interface EntActionMsg {\n dim: DimensionId;\n from?: number;\n action: NetEntityAction;\n}\n\n/**\n * Контейнер (сундук, печь, вьюк, вагонетка с грузом, житель при торговле) открыт — занят этим\n * игроком, пока он не закроет экран: двое, перекладывающие одно и то же, размножали бы предметы.\n * key: \"b:x,y,z\" — блочная сущность, \"e:id\" — сущность.\n */\nexport interface LockMsg {\n dim: DimensionId;\n from?: number;\n key: string;\n open: boolean;\n}\n\nexport interface ReplyMsg {\n to: number;\n kind: \"lock\" | \"ride\";\n key?: string;\n id?: number;\n ok: boolean;\n /** Кто занял (имя) — для подсказки. */\n by?: string;\n /** Блочная сущность от владельца — открыть её, а не свою (добыча постройки катится у него). */\n be?: BlockEntity | null;\n}\n\n/** Метка карты: автора (`pin.by`) ставит хост — менять и удалять может автор или хост. */\nexport type PinMsg = { op: \"set\"; pin: unknown } | { op: \"remove\"; id: number };\n\nexport interface PortalMsg {\n dim: DimensionId;\n p: [number, number, number];\n add: boolean;\n}\n\nexport interface BeMsg {\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n be: BlockEntity | null;\n}\n\nexport interface DimEnter {\n dim: DimensionId;\n /** Уходящий владелец отдаёт своё измерение: сущности и блочные сущности. */\n handoff?: { dim: DimensionId; save: DimensionSave };\n}\n\nexport interface DimOwn {\n dim: DimensionId;\n save: DimensionSave;\n}\n\nexport interface Shadow {\n world: WorldMeta;\n /** Замороженные и живые измерения хоста — с сущностями. */\n dims: Partial<Record<DimensionId, DimensionSave>>;\n players: Record<string, unknown>;\n settings: RoomSettings;\n ops: string[];\n}\n\n// ============================================================ двоичные сообщения\n\nexport const DIMS: readonly DimensionId[] = [\"overworld\", \"inferno\", \"beyond\"];\nexport const dimCode = (d: DimensionId): number => Math.max(0, DIMS.indexOf(d));\nexport const dimOf = (n: number): DimensionId => DIMS[n] ?? \"overworld\";\n\n/** Поза игрока. Флаги — вид аватара. */\nexport interface Pose {\n slot: number;\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n yaw: number;\n pitch: number;\n flags: number;\n health: number;\n}\n\nexport const POSE_SNEAK = 1,\n POSE_DEAD = 2,\n POSE_FLY = 4,\n POSE_GLIDE = 8,\n POSE_SWIM = 16,\n POSE_SWING = 32,\n POSE_CREATIVE = 64,\n /** Бежит (анимация бега у других). */\n POSE_SPRINT = 128;\n\nexport function encodePose(p: Pose): Uint8Array {\n return new ByteWriter(24)\n .u8(M.POSE)\n .u8(p.slot)\n .u8(dimCode(p.dim))\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .angle(p.pitch)\n .u8(p.flags)\n .u8(Math.max(0, Math.min(255, Math.round(p.health * 10))))\n .finish();\n}\n\nexport function decodePose(msg: Uint8Array): Pose {\n const r = new ByteReader(msg.subarray(1));\n return {\n slot: r.u8(),\n dim: dimOf(r.u8()),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n pitch: r.angle(),\n flags: r.u8(),\n health: r.u8() / 10,\n };\n}\n\n/** Переписать слот позы (хост пересылает позу гостя остальным под его слотом). */\nexport function withPoseSlot(msg: Uint8Array, slot: number): Uint8Array {\n const out = msg.slice();\n out[1] = slot;\n return out;\n}\n\n/** Изменение блока: мировые координаты, id и мета. */\nexport interface BlockDelta {\n x: number;\n y: number;\n z: number;\n id: number;\n meta: number;\n}\n\n/** BLOCKS / EDITS: [тип][dim][n u16] n × [x i32][y u16][z i32][id u16][meta u8] (13 байт). */\nexport function encodeBlocks(\n type: number,\n dim: DimensionId,\n list: readonly BlockDelta[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // одно сообщение ≤ ~16 КБ, но и без того держим пачки небольшими: 1000 правок = 13 КБ\n for (let i = 0; i < list.length; i += 1000) {\n const part = list.slice(i, i + 1000);\n const w = new ByteWriter(4 + part.length * 13)\n .u8(type)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const d of part) w.i32(d.x).u16(d.y).i32(d.z).u16(d.id).u8(d.meta);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeBlocks(msg: Uint8Array): {\n dim: DimensionId;\n list: BlockDelta[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: BlockDelta[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n x: r.i32(),\n y: r.u16(),\n z: r.i32(),\n id: r.u16(),\n meta: r.u8(),\n });\n return { dim, list };\n}\n\n/** Поза сущности (владелец → игроки измерения). */\nexport interface EntPose {\n id: number;\n x: number;\n y: number;\n z: number;\n yaw: number;\n flags: number;\n extra: number;\n}\n\n/** ENT_POSE: [тип][dim][n u16] n × [id u32][x f32][y f32][z f32][yaw i16][flags u8][extra u8] (20 байт). */\nexport function encodeEntPoses(\n dim: DimensionId,\n list: readonly EntPose[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // быстрый канал не режет сообщения — держим каждое заметно меньше 16 КБ\n for (let i = 0; i < list.length; i += 600) {\n const part = list.slice(i, i + 600);\n const w = new ByteWriter(4 + part.length * 20)\n .u8(M.ENT_POSE)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const p of part)\n w.u32(p.id)\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .u8(p.flags)\n .u8(p.extra);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeEntPoses(msg: Uint8Array): {\n dim: DimensionId;\n list: EntPose[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: EntPose[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n id: r.u32(),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n flags: r.u8(),\n extra: r.u8(),\n });\n return { dim, list };\n}\n\n/** ENT_REMOVE: [тип][dim][n u16] n × [id u32]. */\nexport function encodeEntRemove(\n dim: DimensionId,\n ids: readonly number[],\n): Uint8Array {\n const w = new ByteWriter(4 + ids.length * 4)\n .u8(M.ENT_REMOVE)\n .u8(dimCode(dim))\n .u16(ids.length);\n for (const id of ids) w.u32(id);\n return w.finish();\n}\n\nexport function decodeEntRemove(msg: Uint8Array): {\n dim: DimensionId;\n ids: number[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const ids: number[] = [];\n for (let i = 0; i < n; i++) ids.push(r.u32());\n return { dim, ids };\n}\n\n/**\n * Сборка игры: хэш имён блоков и предметов по порядку. У двух игроков с разными версиями id блоков\n * разошлись бы — один увидел бы на месте сундука печь.\n */\nexport function buildHash(\n blockNames: readonly string[],\n itemNames: readonly string[],\n): string {\n let h = 0x811c9dc5;\n for (const s of [...blockNames, \"|\", ...itemNames])\n for (let i = 0; i < s.length; i++) {\n h ^= s.charCodeAt(i);\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return h.toString(16);\n}\n"
905
+ "content": "// Протокол мультиплеера VoxelCraft поверх `@idosgames/core/realtime` (звезда: всё идёт через хоста).\n//\n// Роли:\n// • ХОСТ — координатор: принимает игроков, ведёт список (roster), назначает владельцев измерений,\n// хранит замороженные измерения и состояния игроков (для сейва и передачи хоста), пересылает.\n// • ВЛАДЕЛЕЦ ИЗМЕРЕНИЯ — считает мир своего измерения (net/sim): хост, если он там, иначе первый\n// вошедший игрок. Рассылает изменения блоков, сущности, блочные сущности.\n// • РЕПЛИКА — любой другой игрок в измерении: шлёт свои правки мира и действия с сущностями.\n//\n// Игрок и инвентарь — у КАЖДОГО свои (решение владельца 27.09.2026, вариант А): игрок сам считает\n// себя (физику, голод, смерть, инвентарь), хост и владельцы измерений его только видят и «выдают»\n// ему подобранное, урон и отброс.\n\nimport { ByteReader, ByteWriter } from \"./codec\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack, WorldSource } from \"../types\";\nimport type { NetEntityAction, NetEntitySnap } from \"../entities/EntityManager\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\n\n/** Версия протокола: разные версии не играют вместе (Hello → «обновите страницу»). */\nexport const PROTOCOL = 2;\n\nexport const M = {\n // ---- надёжный канал (rel)\n HELLO: 1, // гость → хост: JSON Hello\n WELCOME: 2, // хост → гость: JSON Welcome (сжатый)\n REJECT: 3, // хост → гость: JSON { reason }\n ROSTER: 4, // хост → все: JSON Roster\n PMETA: 6, // игрок → хост → все: JSON PlayerMeta (предмет в руке, режим, имя)\n BLOCKS: 7, // владелец → хост → все: двоичные изменения блоков\n EDITS: 8, // реплика → хост → владелец: двоичные правки\n ENT_SPAWN: 9, // владелец → хост → игроки измерения: JSON { dim, list: [id, snap][] }\n ENT_REMOVE: 11, // владелец → …: двоичный список id\n ENT_ACTION: 12, // реплика → хост → владелец: JSON { dim, from, action }\n BE: 13, // владелец/реплика ↔ хост ↔ все: JSON { dim, x, y, z, be }\n WORLD: 15, // хост → все: JSON WorldState (время, погода, сложность, Предел)\n CHAT: 16, // любой → хост → все: JSON { from?, text }\n HIT: 17, // владелец → хост → игрок: JSON Hit (урон, отброс, эффект)\n GIVE: 18, // владелец → хост → игрок: JSON Give (предмет, опыт)\n PSTATE: 19, // игрок → хост: JSON { state } — сейв игрока для мира хоста и передачи хоста\n DIM_ENTER: 20, // игрок → хост: JSON { dim, handoff? } — сменил измерение\n DIM_OWN: 21, // хост → игрок: JSON { dim, save } — теперь ты владелец измерения\n DIM_RESYNC: 22, // хост → владелец: JSON { dim } — пришли всем полный снимок сущностей\n SHADOW: 24, // хост → преемник: JSON Shadow (сжатый) — теневая копия мира\n COMMAND: 25, // игрок → хост: JSON { text } — команда мира (для операторов)\n BE_LIST: 26, // хост → игрок: JSON { dim, list } — блочные сущности измерения при входе\n LOCK: 27, // игрок → хост → владелец: JSON LockMsg — занять / отпустить контейнер\n REPLY: 28, // владелец → хост → игрок: JSON ReplyMsg — ответ на LOCK, отказ сесть\n PORTAL: 29, // игрок → хост → все: JSON PortalMsg — реестр порталов\n EMOTE: 30, // игрок → хост → все: JSON EmoteMsg — жест и/или эмоция персонажа\n LOOK: 31, // игрок → хост → все: JSON LookMsg (сжатый) — рецепт персонажа и облики предметов\n PIN: 32, // игрок → хост → все: JSON PinMsg — метка карты (поставить, изменить, удалить)\n // ---- быстрый канал (fast)\n POSE: 5, // игрок → хост → все: двоичная поза\n ENT_POSE: 10, // владелец → …: двоичные позы сущностей\n} as const;\n\n// ============================================================ JSON-сообщения\n\nexport interface Hello {\n v: number;\n /** Хэш набора блоков и предметов: разные сборки игры не совместимы. */\n build: string;\n name: string;\n}\n\nexport interface WorldMeta {\n source: WorldSource;\n palette: string[];\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n portals: unknown;\n /** Метки карты мира (systems/WorldAtlas MapPin[]). Нет у старого хоста. */\n pins?: unknown;\n daysAwake: number;\n /** Имя мира у хоста — переходит к новому хосту вместе с миром. Нет у старого хоста. */\n name?: string;\n}\n\nexport interface RosterEntry {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n}\n\nexport interface Roster {\n hostId: string;\n players: RosterEntry[];\n /** Владелец каждого «живого» измерения. */\n owners: Partial<Record<DimensionId, string>>;\n /** Настройки комнаты («Открыть для сети»). */\n settings: RoomSettings;\n}\n\nexport interface RoomSettings {\n /** Режим, в котором входят гости. */\n guestMode: \"survival\" | \"creative\";\n /** Команды всем, а не только операторам. */\n cheatsForAll: boolean;\n}\n\nexport interface Welcome {\n v: number;\n you: number;\n world: WorldMeta;\n /** Изменённые блоки ВСЕХ измерений: у гостя полная копия — на ней держится и передача хоста. */\n diffs: Partial<Record<DimensionId, DimensionSave[\"chunks\"]>>;\n /** Где появиться и в каком измерении (у вернувшегося — там, где был). */\n spawn: { dim: DimensionId; pos: [number, number, number] };\n /** Сохранённое состояние этого игрока в мире хоста (вернулся в тот же мир). */\n state: unknown | null;\n roster: Roster;\n}\n\nexport interface PlayerMeta {\n slot?: number;\n held: string | null;\n mode: \"survival\" | \"creative\";\n name: string;\n /** Едет (лошадь, вагонетка, лодка) — поза седока у других. */\n riding?: boolean;\n /** Хэш рецепта персонажа; сам рецепт — сообщением LOOK. */\n look?: string;\n /** Надетое: слот → хэш облика. */\n outfit?: Record<string, string>;\n /** Облик предмета в руке (tag.skin). */\n heldSkin?: string | null;\n /** Надета золотая вещь: мобы считаются у владельца измерения, свинорыл смотрит на это. */\n gold?: boolean;\n}\n\n/**\n * Облик игрока целиком: рецепт персонажа и облики надетого и того, что в руке. Шлётся при смене и\n * заново, когда в игру вошёл новый игрок (у него этих рецептов ещё нет).\n */\nexport interface LookMsg {\n slot?: number;\n character: unknown;\n skins: Record<string, unknown>;\n}\n\n/** Жест и/или эмоция персонажа (character/anim): слот ставит хост. */\nexport interface EmoteMsg {\n slot?: number;\n emote?: string;\n emotion?: string;\n seconds?: number;\n}\n\nexport interface WorldState {\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n daysAwake: number;\n}\n\nexport interface Hit {\n to: number;\n amount: number;\n cause: string;\n /** Толчок (изменение скорости), который насчитал владелец измерения. */\n knock?: [number, number, number];\n effect?: { id: string; seconds: number; level: number };\n}\n\nexport interface Give {\n to: number;\n stack?: ItemStack;\n xp?: number;\n}\n\nexport interface EntSpawnMsg {\n dim: DimensionId;\n list: [number, NetEntitySnap][];\n}\n\nexport interface EntActionMsg {\n dim: DimensionId;\n from?: number;\n action: NetEntityAction;\n}\n\n/**\n * Контейнер (сундук, печь, вьюк, вагонетка с грузом, житель при торговле) открыт — занят этим\n * игроком, пока он не закроет экран: двое, перекладывающие одно и то же, размножали бы предметы.\n * key: \"b:x,y,z\" — блочная сущность, \"e:id\" — сущность.\n */\nexport interface LockMsg {\n dim: DimensionId;\n from?: number;\n key: string;\n open: boolean;\n}\n\nexport interface ReplyMsg {\n to: number;\n kind: \"lock\" | \"ride\";\n key?: string;\n id?: number;\n ok: boolean;\n /** Кто занял (имя) — для подсказки. */\n by?: string;\n /** Блочная сущность от владельца — открыть её, а не свою (добыча постройки катится у него). */\n be?: BlockEntity | null;\n}\n\n/** Метка карты: автора (`pin.by`) ставит хост — менять и удалять может автор или хост. */\nexport type PinMsg = { op: \"set\"; pin: unknown } | { op: \"remove\"; id: number };\n\nexport interface PortalMsg {\n dim: DimensionId;\n p: [number, number, number];\n add: boolean;\n}\n\nexport interface BeMsg {\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n be: BlockEntity | null;\n}\n\nexport interface DimEnter {\n dim: DimensionId;\n /** Уходящий владелец отдаёт своё измерение: сущности и блочные сущности. */\n handoff?: { dim: DimensionId; save: DimensionSave };\n}\n\nexport interface DimOwn {\n dim: DimensionId;\n save: DimensionSave;\n}\n\nexport interface Shadow {\n world: WorldMeta;\n /** Замороженные и живые измерения хоста — с сущностями. */\n dims: Partial<Record<DimensionId, DimensionSave>>;\n players: Record<string, unknown>;\n settings: RoomSettings;\n ops: string[];\n}\n\n// ============================================================ двоичные сообщения\n\nexport const DIMS: readonly DimensionId[] = [\"overworld\", \"inferno\", \"beyond\"];\nexport const dimCode = (d: DimensionId): number => Math.max(0, DIMS.indexOf(d));\nexport const dimOf = (n: number): DimensionId => DIMS[n] ?? \"overworld\";\n\n/** Поза игрока. Флаги — вид аватара. */\nexport interface Pose {\n slot: number;\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n yaw: number;\n pitch: number;\n flags: number;\n health: number;\n}\n\nexport const POSE_SNEAK = 1,\n POSE_DEAD = 2,\n POSE_FLY = 4,\n POSE_GLIDE = 8,\n POSE_SWIM = 16,\n POSE_SWING = 32,\n POSE_CREATIVE = 64,\n /** Бежит (анимация бега у других). */\n POSE_SPRINT = 128;\n\nexport function encodePose(p: Pose): Uint8Array {\n return new ByteWriter(24)\n .u8(M.POSE)\n .u8(p.slot)\n .u8(dimCode(p.dim))\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .angle(p.pitch)\n .u8(p.flags)\n .u8(Math.max(0, Math.min(255, Math.round(p.health * 10))))\n .finish();\n}\n\nexport function decodePose(msg: Uint8Array): Pose {\n const r = new ByteReader(msg.subarray(1));\n return {\n slot: r.u8(),\n dim: dimOf(r.u8()),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n pitch: r.angle(),\n flags: r.u8(),\n health: r.u8() / 10,\n };\n}\n\n/** Переписать слот позы (хост пересылает позу гостя остальным под его слотом). */\nexport function withPoseSlot(msg: Uint8Array, slot: number): Uint8Array {\n const out = msg.slice();\n out[1] = slot;\n return out;\n}\n\n/** Изменение блока: мировые координаты, id и мета. */\nexport interface BlockDelta {\n x: number;\n y: number;\n z: number;\n id: number;\n meta: number;\n}\n\n/** BLOCKS / EDITS: [тип][dim][n u16] n × [x i32][y u16][z i32][id u16][meta u8] (13 байт). */\nexport function encodeBlocks(\n type: number,\n dim: DimensionId,\n list: readonly BlockDelta[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // одно сообщение ≤ ~16 КБ, но и без того держим пачки небольшими: 1000 правок = 13 КБ\n for (let i = 0; i < list.length; i += 1000) {\n const part = list.slice(i, i + 1000);\n const w = new ByteWriter(4 + part.length * 13)\n .u8(type)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const d of part) w.i32(d.x).u16(d.y).i32(d.z).u16(d.id).u8(d.meta);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeBlocks(msg: Uint8Array): {\n dim: DimensionId;\n list: BlockDelta[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: BlockDelta[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n x: r.i32(),\n y: r.u16(),\n z: r.i32(),\n id: r.u16(),\n meta: r.u8(),\n });\n return { dim, list };\n}\n\n/** Поза сущности (владелец → игроки измерения). */\nexport interface EntPose {\n id: number;\n x: number;\n y: number;\n z: number;\n yaw: number;\n flags: number;\n extra: number;\n}\n\n/** ENT_POSE: [тип][dim][n u16] n × [id u32][x f32][y f32][z f32][yaw i16][flags u8][extra u8] (20 байт). */\nexport function encodeEntPoses(\n dim: DimensionId,\n list: readonly EntPose[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // быстрый канал не режет сообщения — держим каждое заметно меньше 16 КБ\n for (let i = 0; i < list.length; i += 600) {\n const part = list.slice(i, i + 600);\n const w = new ByteWriter(4 + part.length * 20)\n .u8(M.ENT_POSE)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const p of part)\n w.u32(p.id)\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .u8(p.flags)\n .u8(p.extra);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeEntPoses(msg: Uint8Array): {\n dim: DimensionId;\n list: EntPose[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: EntPose[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n id: r.u32(),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n flags: r.u8(),\n extra: r.u8(),\n });\n return { dim, list };\n}\n\n/** ENT_REMOVE: [тип][dim][n u16] n × [id u32]. */\nexport function encodeEntRemove(\n dim: DimensionId,\n ids: readonly number[],\n): Uint8Array {\n const w = new ByteWriter(4 + ids.length * 4)\n .u8(M.ENT_REMOVE)\n .u8(dimCode(dim))\n .u16(ids.length);\n for (const id of ids) w.u32(id);\n return w.finish();\n}\n\nexport function decodeEntRemove(msg: Uint8Array): {\n dim: DimensionId;\n ids: number[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const ids: number[] = [];\n for (let i = 0; i < n; i++) ids.push(r.u32());\n return { dim, ids };\n}\n\n/**\n * Сборка игры: хэш имён блоков и предметов по порядку. У двух игроков с разными версиями id блоков\n * разошлись бы — один увидел бы на месте сундука печь.\n */\nexport function buildHash(\n blockNames: readonly string[],\n itemNames: readonly string[],\n): string {\n let h = 0x811c9dc5;\n for (const s of [...blockNames, \"|\", ...itemNames])\n for (let i = 0; i < s.length; i++) {\n h ^= s.charCodeAt(i);\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return h.toString(16);\n}\n"
906
906
  },
907
907
  {
908
908
  "path": "net/RemotePlayer.ts",
909
- "content": "// Другой игрок сетевой игры — у ВЛАДЕЛЬЦА измерения. Мобы, стрелы, взрывы и подбор работают с ним\n// как с обычным Player (EntityManager.others), но состояние игрока — у самого игрока (вариант А):\n// урон, отброс, эффекты и опыт не применяются здесь, а уходят ему по сети; подобранный предмет\n// «выдаётся» ему, а не кладётся в чужой инвентарь.\nimport { EMOTION_EVENTS, type Emotion } from \"../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { Player } from \"../player/Player\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EffectId, ItemStack } from \"../types\";\nimport {\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SPRINT,\n POSE_SWIM,\n POSE_SWING,\n type Pose,\n} from \"./protocol\";\nimport { CharacterActor } from \"../character/CharacterActor\";\nimport type { CharacterSpec } from \"../character/CharacterSpec\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n/** Куда уходит то, что с удалённым игроком случилось у владельца измерения. */\nexport interface RemoteSink {\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: { id: string; seconds: number; level: number },\n ): void;\n give(slot: number, stack: ItemStack | null, xp?: number): void;\n}\n\nexport class RemotePlayer extends Player {\n readonly slot: number;\n readonly userId: string;\n /** Предмет в руке (PlayerMeta) — для мобов, которые идут за едой в руке. */\n held: string | null = null;\n /** Скорость из последней позы: всё, что к ней добавили за тик (толчки), уходит игроку. */\n private readonly velBase = new THREE.Vector3();\n\n constructor(\n world: ChunkManager,\n slot: number,\n userId: string,\n private readonly sink: RemoteSink,\n ) {\n super(world);\n this.slot = slot;\n this.userId = userId;\n }\n\n applyPose(p: Pose): void {\n this.pos.set(p.x, p.y, p.z);\n this.yaw = p.yaw;\n this.pitch = p.pitch;\n this.dead = (p.flags & POSE_DEAD) !== 0;\n this._sneaking = (p.flags & POSE_SNEAK) !== 0;\n this.mode = p.flags & POSE_CREATIVE ? \"creative\" : \"survival\";\n this.health = p.health;\n this.vel.set(0, 0, 0);\n this.velBase.set(0, 0, 0);\n }\n\n /** Урон — игроку: он сам решит про щит, броню, тотем и смерть. */\n override damage(amount: number, cause: string): void {\n if (this.dead || this.mode === \"creative\" || amount <= 0) return;\n this.sink.hit(this.slot, amount, cause);\n }\n\n override knock(dx: number, dz: number, strength: number): void {\n const len = Math.hypot(dx, dz) || 1;\n this.vel.x += (dx / len) * strength;\n this.vel.z += (dz / len) * strength;\n this.vel.y = Math.max(this.vel.y, 4);\n }\n\n override addEffect(id: EffectId, seconds: number, level = 1): void {\n this.sink.hit(this.slot, 0, \"effect\", undefined, { id, seconds, level });\n }\n\n override addXp(amount: number): void {\n if (amount > 0) this.sink.give(this.slot, null, amount);\n }\n\n /** Щит решает сам игрок (у него инвентарь): здесь не блокирует ничего. */\n override shieldBlocks(): boolean {\n return false;\n }\n\n /**\n * Толчок, накопленный за тик (knock, взрыв добавили к vel): отдать игроку и обнулить. Зовёт\n * NetGame после тика сущностей.\n */\n takeImpulse(): [number, number, number] | null {\n const dx = this.vel.x - this.velBase.x,\n dy = this.vel.y - this.velBase.y,\n dz = this.vel.z - this.velBase.z;\n this.vel.copy(this.velBase);\n if (dx * dx + dy * dy + dz * dz < 0.01) return null;\n return [dx, dy, dz];\n }\n}\n\n/**\n * Инвентарь удалённого игрока у владельца измерения: подобранное уходит игроку. Считается, что\n * место есть — если нет, игрок сам выбросит лишнее у себя (его инвентарь авторитетен).\n */\nexport function remoteInventory(p: RemotePlayer, sink: RemoteSink): Inventory {\n const inv = {\n add(item: string, count: number): number {\n sink.give(p.slot, { item, count });\n return 0;\n },\n addStack(s: ItemStack): number {\n sink.give(p.slot, { ...s });\n return 0;\n },\n heldItem(): ItemStack | null {\n return p.held ? { item: p.held, count: 1 } : null;\n },\n slots: [] as (ItemStack | null)[],\n armor: [] as (ItemStack | null)[],\n };\n return inv as unknown as Inventory;\n}\n\n// ============================================================ аватар\n\n/**\n * Другой игрок на экране: его персонаж (рецепт по сети, пока не пришёл — «Путник»), анимации по\n * позам — шаг, бег, присед, плавание, полёт, планер, замах, смерть, — жесты и эмоции из сети,\n * предмет в руке. Между позами (15 в секунду) — плавное движение к новой.\n */\nexport class Avatar {\n readonly actor: CharacterActor;\n private readonly from = new THREE.Vector3();\n private readonly to = new THREE.Vector3();\n private readonly pos = new THREE.Vector3();\n private yawFrom = 0;\n private yawTo = 0;\n private pitch = 0;\n private t = 1;\n private flags = 0;\n private speed = 0;\n private vy = 0;\n private placed = false;\n private mood: Emotion = \"neutral\";\n private health = 20;\n /** Предмет в руке и сон/езда — из PlayerMeta (NetGame ставит). */\n held: string | null = null;\n heldSkin: string | null = null;\n sleeping = false;\n riding = false;\n\n constructor(\n name: string,\n atlas: TextureAtlas | null = null,\n spec?: CharacterSpec,\n ) {\n this.actor = new CharacterActor(spec, { name, atlas });\n }\n\n get group(): THREE.Group {\n return this.actor.group;\n }\n\n setName(name: string): void {\n this.actor.view.setName(name);\n }\n\n setSpec(spec: CharacterSpec): void {\n this.actor.setSpec(spec);\n }\n\n /** Новая поза из сети: от текущего положения к новому за ~один интервал поз. */\n target(p: Pose): void {\n this.from.copy(this.pos);\n this.to.set(p.x, p.y, p.z);\n this.yawFrom = this.yawTo;\n this.yawTo = p.yaw;\n this.pitch = p.pitch;\n // замах — по фронту флага (флаг держится, пока у игрока идёт анимация замаха)\n if (p.flags & POSE_SWING && !(this.flags & POSE_SWING))\n this.actor.trigger(\"swing\");\n this.flags = p.flags;\n // настроение по здоровью — как у своего персонажа (game/playerCharacter)\n const mood: Emotion =\n p.flags & (POSE_DEAD | POSE_CREATIVE)\n ? \"neutral\"\n : p.health <= 4\n ? EMOTION_EVENTS.nearDeath.emotion\n : p.health <= 8\n ? EMOTION_EVENTS.lowHealth.emotion\n : \"neutral\";\n if (mood !== this.mood) {\n this.mood = mood;\n this.actor.setEmotion(mood);\n }\n if (p.health < this.health) this.actor.trigger(\"hurt\");\n this.health = p.health;\n // первая поза или телепорт — без плавания через полкарты\n if (!this.placed || this.from.distanceToSquared(this.to) > 64) {\n this.from.copy(this.to);\n this.pos.copy(this.to);\n this.yawFrom = p.yaw;\n this.placed = true;\n }\n this.t = 0;\n }\n\n frame(dt: number, bright?: number): void {\n const prevX = this.pos.x,\n prevY = this.pos.y,\n prevZ = this.pos.z;\n this.t = Math.min(1, this.t + dt / 0.07);\n this.pos.lerpVectors(this.from, this.to, this.t);\n let dy = this.yawTo - this.yawFrom;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n const yaw = this.yawFrom + dy * this.t;\n if (dt > 0) {\n // скорость по пройденному — сглаженно, иначе шаг дёргается между позами\n const v = Math.hypot(this.pos.x - prevX, this.pos.z - prevZ) / dt;\n this.speed += (v - this.speed) * Math.min(1, dt * 12);\n this.vy += ((this.pos.y - prevY) / dt - this.vy) * Math.min(1, dt * 12);\n }\n const f = this.flags;\n const flying = (f & POSE_FLY) !== 0;\n this.actor.update(dt, {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n yaw,\n pitch: this.pitch,\n speed: this.speed,\n vy: this.vy,\n onGround: !flying && Math.abs(this.vy) < 0.5,\n inWater: (f & POSE_SWIM) !== 0,\n flying,\n gliding: (f & POSE_GLIDE) !== 0,\n sneaking: (f & POSE_SNEAK) !== 0,\n sprinting: (f & POSE_SPRINT) !== 0,\n dead: (f & POSE_DEAD) !== 0,\n sleeping: this.sleeping,\n riding: this.riding,\n held: this.held,\n heldSkin: this.heldSkin,\n ...(bright !== undefined ? { bright } : {}),\n });\n }\n\n dispose(scene: THREE.Object3D): void {\n scene.remove(this.actor.group);\n this.actor.dispose();\n }\n}\n"
909
+ "content": "// Другой игрок сетевой игры — у ВЛАДЕЛЬЦА измерения. Мобы, стрелы, взрывы и подбор работают с ним\n// как с обычным Player (EntityManager.others), но состояние игрока — у самого игрока (вариант А):\n// урон, отброс, эффекты и опыт не применяются здесь, а уходят ему по сети; подобранный предмет\n// «выдаётся» ему, а не кладётся в чужой инвентарь.\nimport { EMOTION_EVENTS, type Emotion } from \"../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { Player } from \"../player/Player\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EffectId, ItemStack } from \"../types\";\nimport {\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SPRINT,\n POSE_SWIM,\n POSE_SWING,\n type Pose,\n} from \"./protocol\";\nimport { CharacterActor } from \"../character/CharacterActor\";\nimport type { CharacterSpec } from \"../character/CharacterSpec\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n/** Куда уходит то, что с удалённым игроком случилось у владельца измерения. */\nexport interface RemoteSink {\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: { id: string; seconds: number; level: number },\n ): void;\n give(slot: number, stack: ItemStack | null, xp?: number): void;\n}\n\nexport class RemotePlayer extends Player {\n readonly slot: number;\n readonly userId: string;\n /** Предмет в руке (PlayerMeta) — для мобов, которые идут за едой в руке. */\n held: string | null = null;\n /** Надета золотая вещь (PlayerMeta.gold) — свинорыл не нападает, как на своего игрока. */\n gold = false;\n override goldArmor = (): boolean => this.gold;\n /** Скорость из последней позы: всё, что к ней добавили за тик (толчки), уходит игроку. */\n private readonly velBase = new THREE.Vector3();\n\n constructor(\n world: ChunkManager,\n slot: number,\n userId: string,\n private readonly sink: RemoteSink,\n ) {\n super(world);\n this.slot = slot;\n this.userId = userId;\n }\n\n applyPose(p: Pose): void {\n this.pos.set(p.x, p.y, p.z);\n this.yaw = p.yaw;\n this.pitch = p.pitch;\n this.dead = (p.flags & POSE_DEAD) !== 0;\n this._sneaking = (p.flags & POSE_SNEAK) !== 0;\n this.mode = p.flags & POSE_CREATIVE ? \"creative\" : \"survival\";\n this.health = p.health;\n this.vel.set(0, 0, 0);\n this.velBase.set(0, 0, 0);\n }\n\n /** Урон — игроку: он сам решит про щит, броню, тотем и смерть. */\n override damage(amount: number, cause: string): void {\n if (this.dead || this.mode === \"creative\" || amount <= 0) return;\n this.sink.hit(this.slot, amount, cause);\n }\n\n override knock(dx: number, dz: number, strength: number): void {\n const len = Math.hypot(dx, dz) || 1;\n this.vel.x += (dx / len) * strength;\n this.vel.z += (dz / len) * strength;\n this.vel.y = Math.max(this.vel.y, 4);\n }\n\n override addEffect(id: EffectId, seconds: number, level = 1): void {\n this.sink.hit(this.slot, 0, \"effect\", undefined, { id, seconds, level });\n }\n\n override addXp(amount: number): void {\n if (amount > 0) this.sink.give(this.slot, null, amount);\n }\n\n /** Щит решает сам игрок (у него инвентарь): здесь не блокирует ничего. */\n override shieldBlocks(): boolean {\n return false;\n }\n\n /**\n * Толчок, накопленный за тик (knock, взрыв добавили к vel): отдать игроку и обнулить. Зовёт\n * NetGame после тика сущностей.\n */\n takeImpulse(): [number, number, number] | null {\n const dx = this.vel.x - this.velBase.x,\n dy = this.vel.y - this.velBase.y,\n dz = this.vel.z - this.velBase.z;\n this.vel.copy(this.velBase);\n if (dx * dx + dy * dy + dz * dz < 0.01) return null;\n return [dx, dy, dz];\n }\n}\n\n/**\n * Инвентарь удалённого игрока у владельца измерения: подобранное уходит игроку. Считается, что\n * место есть — если нет, игрок сам выбросит лишнее у себя (его инвентарь авторитетен).\n */\nexport function remoteInventory(p: RemotePlayer, sink: RemoteSink): Inventory {\n const inv = {\n add(item: string, count: number): number {\n sink.give(p.slot, { item, count });\n return 0;\n },\n addStack(s: ItemStack): number {\n sink.give(p.slot, { ...s });\n return 0;\n },\n heldItem(): ItemStack | null {\n return p.held ? { item: p.held, count: 1 } : null;\n },\n // магнит предмета (ItemDrop) спрашивает место: без метода цикл сущностей хозяина падал целиком\n hasRoom(): boolean {\n return true;\n },\n slots: [] as (ItemStack | null)[],\n armor: [] as (ItemStack | null)[],\n };\n return inv as unknown as Inventory;\n}\n\n// ============================================================ аватар\n\n/**\n * Другой игрок на экране: его персонаж (рецепт по сети, пока не пришёл — «Путник»), анимации по\n * позам — шаг, бег, присед, плавание, полёт, планер, замах, смерть, — жесты и эмоции из сети,\n * предмет в руке. Между позами (15 в секунду) — плавное движение к новой.\n */\nexport class Avatar {\n readonly actor: CharacterActor;\n private readonly from = new THREE.Vector3();\n private readonly to = new THREE.Vector3();\n private readonly pos = new THREE.Vector3();\n private yawFrom = 0;\n private yawTo = 0;\n private pitch = 0;\n private t = 1;\n private flags = 0;\n private speed = 0;\n private vy = 0;\n private placed = false;\n private mood: Emotion = \"neutral\";\n private health = 20;\n /** Предмет в руке и сон/езда — из PlayerMeta (NetGame ставит). */\n held: string | null = null;\n heldSkin: string | null = null;\n sleeping = false;\n riding = false;\n\n constructor(\n name: string,\n atlas: TextureAtlas | null = null,\n spec?: CharacterSpec,\n ) {\n this.actor = new CharacterActor(spec, { name, atlas });\n }\n\n get group(): THREE.Group {\n return this.actor.group;\n }\n\n setName(name: string): void {\n this.actor.view.setName(name);\n }\n\n setSpec(spec: CharacterSpec): void {\n this.actor.setSpec(spec);\n }\n\n /** Новая поза из сети: от текущего положения к новому за ~один интервал поз. */\n target(p: Pose): void {\n this.from.copy(this.pos);\n this.to.set(p.x, p.y, p.z);\n this.yawFrom = this.yawTo;\n this.yawTo = p.yaw;\n this.pitch = p.pitch;\n // замах — по фронту флага (флаг держится, пока у игрока идёт анимация замаха)\n if (p.flags & POSE_SWING && !(this.flags & POSE_SWING))\n this.actor.trigger(\"swing\");\n this.flags = p.flags;\n // настроение по здоровью — как у своего персонажа (game/playerCharacter)\n const mood: Emotion =\n p.flags & (POSE_DEAD | POSE_CREATIVE)\n ? \"neutral\"\n : p.health <= 4\n ? EMOTION_EVENTS.nearDeath.emotion\n : p.health <= 8\n ? EMOTION_EVENTS.lowHealth.emotion\n : \"neutral\";\n if (mood !== this.mood) {\n this.mood = mood;\n this.actor.setEmotion(mood);\n }\n if (p.health < this.health) this.actor.trigger(\"hurt\");\n this.health = p.health;\n // первая поза или телепорт — без плавания через полкарты\n if (!this.placed || this.from.distanceToSquared(this.to) > 64) {\n this.from.copy(this.to);\n this.pos.copy(this.to);\n this.yawFrom = p.yaw;\n this.placed = true;\n }\n this.t = 0;\n }\n\n frame(dt: number, bright?: number): void {\n const prevX = this.pos.x,\n prevY = this.pos.y,\n prevZ = this.pos.z;\n this.t = Math.min(1, this.t + dt / 0.07);\n this.pos.lerpVectors(this.from, this.to, this.t);\n let dy = this.yawTo - this.yawFrom;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n const yaw = this.yawFrom + dy * this.t;\n if (dt > 0) {\n // скорость по пройденному — сглаженно, иначе шаг дёргается между позами\n const v = Math.hypot(this.pos.x - prevX, this.pos.z - prevZ) / dt;\n this.speed += (v - this.speed) * Math.min(1, dt * 12);\n this.vy += ((this.pos.y - prevY) / dt - this.vy) * Math.min(1, dt * 12);\n }\n const f = this.flags;\n const flying = (f & POSE_FLY) !== 0;\n this.actor.update(dt, {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n yaw,\n pitch: this.pitch,\n speed: this.speed,\n vy: this.vy,\n onGround: !flying && Math.abs(this.vy) < 0.5,\n inWater: (f & POSE_SWIM) !== 0,\n flying,\n gliding: (f & POSE_GLIDE) !== 0,\n sneaking: (f & POSE_SNEAK) !== 0,\n sprinting: (f & POSE_SPRINT) !== 0,\n dead: (f & POSE_DEAD) !== 0,\n sleeping: this.sleeping,\n riding: this.riding,\n held: this.held,\n heldSkin: this.heldSkin,\n ...(bright !== undefined ? { bright } : {}),\n });\n }\n\n dispose(scene: THREE.Object3D): void {\n scene.remove(this.actor.group);\n this.actor.dispose();\n }\n}\n"
910
910
  },
911
911
  {
912
912
  "path": "net/sim.ts",
@@ -1582,7 +1582,7 @@
1582
1582
  },
1583
1583
  {
1584
1584
  "path": "scene.ts",
1585
- "content": "import {\n createCharacterContentType,\n createItemContentType,\n} from \"./workshopCharacters\";\nimport type {\n ContentTypeHandler,\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { createVoxelWorldContentType } from \"./workshop\";\nimport { Game, type GameOptions } from \"./main\";\nimport { VoxelHub } from \"./hub\";\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\" id=\"overlayControls\"></div>\n <div class=\"langRow\" id=\"overlayLang\"></div>\n <div class=\"buttons\">\n <button id=\"btnResume\" class=\"accent idos-press\">Continue</button>\n <button id=\"btnBook\" class=\"idos-press\">Handbook</button>\n <button id=\"btnLobby\" class=\"idos-press\">To the lobby</button>\n </div>\n </div>\n </div>\n`;\n\n/**\n * Сцена + debug-поверхность для агента + тип контента для Мастерской: все смотрят на ОДИН экземпляр\n * игры, который создаётся в `mount()` и обнуляется в `destroy()`. Поэтому они получают не объект, а\n * доступ к текущему `game` — иначе после пересоздания сцены держали бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(opts: GameOptions = {}): {\n /** Общее с лобби SDK (вкладки, подиум): атлас, облик игрока, ИИ, выбранный мир. */\n hub: VoxelHub;\n scene: EngineScene;\n agent: ModuleAgentApi;\n content: ContentTypeHandler;\n /** Персонажи и облики предметов в Мастерской (workshopCharacters.ts). */\n characterContent: ContentTypeHandler;\n itemContent: ContentTypeHandler;\n /** The game once the scene is mounted (the room from the lobby may arrive before the first frame). */\n whenGame(): Promise<Game>;\n} {\n let game: Game | null = null;\n const hub = opts.hub ?? new VoxelHub();\n const waiting: Array<(g: Game) => void> = [];\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n if (next) for (const resolve of waiting.splice(0)) resolve(next);\n },\n { ...opts, hub },\n );\n return {\n hub,\n scene,\n agent: createVoxelcraftAgentApi(() => game),\n content: createVoxelWorldContentType(() => game),\n characterContent: createCharacterContentType(() => game),\n itemContent: createItemContentType(() => game),\n whenGame: () =>\n game ? Promise.resolve(game) : new Promise<Game>((r) => waiting.push(r)),\n };\n}\n\nfunction buildScene(\n getGame: () => Game | null,\n setGame: (game: Game | null) => void,\n opts: GameOptions,\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 // Правая кнопка в игре — «поставить / использовать»: меню браузера не нужно нигде над игрой\n // (холст, HUD, слоты, окна), кроме полей ввода — там оно даёт «Вставить».\n ctx.host.oncontextmenu = (e) => {\n const t = e.target;\n if (\n t instanceof HTMLInputElement ||\n t instanceof HTMLTextAreaElement ||\n (t instanceof HTMLElement && t.isContentEditable)\n )\n return;\n e.preventDefault();\n };\n setGame(new Game(opts));\n },\n // The host keeps its loading screen up until the last played world is restored from the\n // player's storage — otherwise the start world flashes first and is swapped a moment later.\n ready(): Promise<void> {\n return getGame()?.restored ?? Promise.resolve();\n },\n activate(): void {\n getGame()?.setRunning(true);\n },\n suspend(): void {\n getGame()?.setRunning(false);\n },\n capture(): Promise<Blob | null> {\n return getGame()?.capture() ?? Promise.resolve(null);\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"
1585
+ "content": "import {\n createCharacterContentType,\n createItemContentType,\n} from \"./workshopCharacters\";\nimport type {\n ContentTypeHandler,\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { createVoxelWorldContentType } from \"./workshop\";\nimport { Game, type GameOptions } from \"./main\";\nimport { VoxelHub } from \"./hub\";\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\" id=\"overlayControls\"></div>\n <div class=\"langRow\" id=\"overlayLang\"></div>\n <div class=\"buttons\">\n <button id=\"btnResume\" class=\"accent idos-press\">Continue</button>\n <button id=\"btnBook\" class=\"idos-press\">Handbook</button>\n <button id=\"btnLobby\" class=\"idos-press\">To the lobby</button>\n </div>\n </div>\n </div>\n`;\n\n/**\n * Сцена + debug-поверхность для агента + тип контента для Мастерской: все смотрят на ОДИН экземпляр\n * игры, который создаётся в `mount()` и обнуляется в `destroy()`. Поэтому они получают не объект, а\n * доступ к текущему `game` — иначе после пересоздания сцены держали бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(opts: GameOptions = {}): {\n /** Общее с лобби SDK (вкладки, подиум): атлас, облик игрока, ИИ, выбранный мир. */\n hub: VoxelHub;\n scene: EngineScene;\n agent: ModuleAgentApi;\n content: ContentTypeHandler;\n /** Персонажи и облики предметов в Мастерской (workshopCharacters.ts). */\n characterContent: ContentTypeHandler;\n itemContent: ContentTypeHandler;\n /** The game once the scene is mounted (the room from the lobby may arrive before the first frame). */\n whenGame(): Promise<Game>;\n} {\n let game: Game | null = null;\n const hub = opts.hub ?? new VoxelHub();\n const waiting: Array<(g: Game) => void> = [];\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n if (next) for (const resolve of waiting.splice(0)) resolve(next);\n },\n { ...opts, hub },\n );\n return {\n hub,\n scene,\n agent: createVoxelcraftAgentApi(() => game),\n content: createVoxelWorldContentType(() => game),\n characterContent: createCharacterContentType(() => game),\n itemContent: createItemContentType(() => game),\n whenGame: () =>\n game ? Promise.resolve(game) : new Promise<Game>((r) => waiting.push(r)),\n };\n}\n\nfunction buildScene(\n getGame: () => Game | null,\n setGame: (game: Game | null) => void,\n opts: GameOptions,\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 // Правая кнопка в игре — «поставить / использовать»: меню браузера не нужно нигде над игрой\n // (холст, HUD, слоты, окна), кроме полей ввода — там оно даёт «Вставить».\n ctx.host.oncontextmenu = (e) => {\n const t = e.target;\n if (\n t instanceof HTMLInputElement ||\n t instanceof HTMLTextAreaElement ||\n (t instanceof HTMLElement && t.isContentEditable)\n )\n return;\n e.preventDefault();\n };\n setGame(new Game(opts));\n },\n // The host keeps its loading screen up until the last played world is restored from the\n // player's storage — otherwise the start world flashes first and is swapped a moment later.\n ready(): Promise<void> {\n return getGame()?.restored ?? Promise.resolve();\n },\n activate(): void {\n getGame()?.setRunning(true);\n },\n suspend(): void {\n getGame()?.setRunning(false);\n },\n capture(): Promise<Blob | null> {\n return getGame()?.capture() ?? Promise.resolve(null);\n },\n // Ролики платформы: звук игры. Фокус не нужен — вид от первого лица, главное всегда в центре.\n audio(): MediaStream | null {\n return getGame()?.audio.recordStream() ?? null;\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"
1586
1586
  },
1587
1587
  {
1588
1588
  "path": "shims.d.ts",
@@ -1602,7 +1602,7 @@
1602
1602
  },
1603
1603
  {
1604
1604
  "path": "systems/Audio.ts",
1605
- "content": "// Звук: синтез через Web Audio, без файлов. Подписан на события шины —\n// добавление нового звука = ещё одна подписка, игровая логика не знает об аудио.\nimport { events } from \"../core/EventBus\";\nimport { INSTRUMENTS, noteFrequency } from \"./NoteBlock\";\n\nexport class Audio {\n ctx: AudioContext | null;\n\n constructor() {\n this.ctx = null; // создаётся после первого user gesture (autoplay policy)\n\n events.on(\"blockBreak\", () => this.noise(0.12, 900, 0.25));\n events.on(\"blockPlace\", () => this.noise(0.06, 1600, 0.2));\n events.on(\"blockToggle\", () => this.noise(0.09, 450, 0.2));\n events.on(\"explosion\", () => {\n this.noise(0.6, 300, 0.5);\n this.blip(80, 0.5, \"sawtooth\", 0.25, 30);\n });\n events.on(\"mobAttack\", () => this.noise(0.08, 400, 0.18));\n events.on(\"bowShoot\", () => this.blip(420, 0.12, \"triangle\", 0.12, 180));\n events.on(\"arrowHit\", () => this.noise(0.05, 1200, 0.1));\n events.on(\"xpPickup\", () => this.blip(1200, 0.06, \"sine\", 0.08, 1800));\n events.on(\"levelUp\", () => {\n this.blip(660, 0.15, \"triangle\", 0.12, 880);\n this.blip(990, 0.2, \"triangle\", 0.1, 1320);\n });\n events.on(\"mobFed\", () => this.blip(520, 0.1, \"sine\", 0.1, 700));\n events.on(\"bucketFill\", () => this.noise(0.15, 700, 0.15));\n events.on(\"bucketEmpty\", () => this.noise(0.18, 600, 0.15));\n // пещеры и горы: козий рог, таран, звуковой удар Стража, крик крикуна, упавший сталактит\n events.on(\"goatHorn\", () => this.blip(260, 1.4, \"sawtooth\", 0.14, 200));\n events.on(\"goatRam\", () => this.noise(0.12, 300, 0.25));\n events.on(\"sonicBoom\", () => this.blip(80, 0.7, \"sawtooth\", 0.3, 40));\n events.on(\"sculkShriek\", () => this.blip(900, 0.9, \"square\", 0.06, 1600));\n events.on(\"dripstoneFall\", () => this.noise(0.1, 1400, 0.2));\n events.on(\"goatScream\", () => this.blip(1100, 0.5, \"sawtooth\", 0.08, 700));\n events.on(\"brushStroke\", () => this.noise(0.15, 2200, 0.12));\n events.on(\"frogEat\", () => this.blip(300, 0.12, \"sine\", 0.12, 900));\n events.on(\"snifferSniff\", () => this.noise(0.4, 500, 0.15));\n events.on(\"snifferDig\", () => this.noise(0.3, 250, 0.2));\n events.on(\"windBurst\", () => this.noise(0.25, 1800, 0.2));\n events.on(\"maceSmash\", () => this.blip(90, 0.35, \"square\", 0.25, 45));\n events.on(\"vaultOpen\", () => this.blip(660, 0.3, \"sine\", 0.12, 990));\n events.on(\"trialReward\", () => this.blip(520, 0.4, \"triangle\", 0.12, 1040));\n events.on(\"copperWax\", () => this.noise(0.2, 3000, 0.1));\n events.on(\"copperScrape\", () => this.noise(0.2, 1600, 0.15));\n events.on(\"bulbToggle\", () => this.blip(740, 0.08, \"square\", 0.08, 740));\n events.on(\"armorTrimmed\", () => this.blip(440, 0.3, \"triangle\", 0.12, 880));\n events.on(\"bundleInsert\", () => this.noise(0.08, 900, 0.1));\n events.on(\"bundleRemove\", () => this.noise(0.1, 700, 0.12));\n events.on(\"candleLit\", () => this.noise(0.1, 2600, 0.08));\n events.on(\"snowballThrow\", () => this.noise(0.06, 2000, 0.08));\n events.on(\"snowballHit\", () => this.noise(0.08, 1200, 0.1));\n events.on(\"snowGolemBuilt\", () => this.blip(520, 0.2, \"sine\", 0.1, 780));\n events.on(\"barter\", () => this.blip(740, 0.15, \"triangle\", 0.1, 560));\n events.on(\"crossbowLoad\", () => this.noise(0.12, 900, 0.18));\n events.on(\"crossbowShoot\", () =>\n this.blip(520, 0.1, \"triangle\", 0.14, 200),\n );\n events.on(\"tridentThrow\", () => this.noise(0.2, 1400, 0.16));\n events.on(\"shieldBlock\", () => this.noise(0.08, 500, 0.22));\n events.on(\"brushLoot\", () => this.blip(880, 0.15, \"sine\", 0.1, 1320));\n events.on(\"fireLit\", () => this.noise(0.12, 2400, 0.15));\n events.on(\"tntFuse\", () => this.noise(0.4, 3000, 0.12));\n events.on(\"itemThrow\", () => this.noise(0.05, 1800, 0.08));\n events.on(\"playerDied\", () => this.blip(220, 0.6, \"sawtooth\", 0.2, 60));\n events.on(\"questDone\", () => this.blip(660, 0.25, \"sine\", 0.12, 1320));\n events.on(\"till\", () => this.noise(0.1, 500, 0.16));\n events.on(\"boneMeal\", () => this.blip(900, 0.1, \"sine\", 0.08, 1400));\n events.on(\"leverClick\", () => this.blip(1400, 0.05, \"square\", 0.08, 900));\n events.on(\"buttonClick\", () => this.blip(1600, 0.04, \"square\", 0.07, 1100));\n events.on(\"pistonMove\", () => this.noise(0.14, 700, 0.2));\n events.on(\"dispense\", () => this.noise(0.08, 1300, 0.15));\n events.on(\"fishCast\", () => this.noise(0.2, 1600, 0.1));\n // нотный блок: высота ноты — мета блока, тембр — блок под ним (systems/NoteBlock)\n events.on(\n \"noteBlock\",\n ({ pitch, instrument }: { pitch: number; instrument?: string }) => {\n const inst = INSTRUMENTS[instrument ?? \"harp\"] ?? INSTRUMENTS.harp!;\n const f = noteFrequency(pitch);\n if (inst.noise) this.noise(inst.seconds, f * 2, 0.16);\n else this.blip(f, inst.seconds, inst.wave, 0.12, f);\n },\n );\n events.on(\"discEject\", () => this.noise(0.12, 800, 0.12));\n // колокол: долгий звон с обертоном; картина — шорох холста\n events.on(\"bellRing\", () => {\n this.blip(880, 1.6, \"sine\", 0.16, 870);\n this.blip(1320, 1.2, \"sine\", 0.08, 1310);\n });\n events.on(\"paintingTurn\", () => this.noise(0.1, 1500, 0.1));\n events.on(\"fishSplash\", () => this.noise(0.25, 900, 0.12));\n events.on(\"fishBite\", () => {\n this.noise(0.2, 700, 0.25);\n this.blip(400, 0.1, \"sine\", 0.1, 250);\n });\n events.on(\"fishCaught\", () => this.blip(880, 0.12, \"triangle\", 0.12, 1320));\n events.on(\"mobTamed\", () => {\n this.blip(660, 0.12, \"sine\", 0.12, 990);\n this.blip(990, 0.16, \"sine\", 0.1, 1320);\n });\n events.on(\"tameFail\", () => this.noise(0.12, 600, 0.12));\n events.on(\"potionSplash\", () => {\n this.noise(0.18, 2600, 0.18);\n this.blip(1200, 0.1, \"sine\", 0.06, 600);\n });\n events.on(\"mobSit\", () => this.blip(520, 0.06, \"sine\", 0.08, 400));\n events.on(\"dispenseFail\", () => this.blip(900, 0.05, \"square\", 0.06, 700));\n events.on(\"trade\", () => {\n this.blip(700, 0.1, \"triangle\", 0.12, 1050);\n this.blip(1050, 0.12, \"triangle\", 0.1, 1400);\n });\n events.on(\"rainPatter\", ({ level }: { level: number }) =>\n this.noise(0.35, 2200, 0.025 * level),\n );\n events.on(\"lightning\", () => {\n this.noise(1.4, 260, 0.5);\n this.blip(55, 1.2, \"sawtooth\", 0.2, 30);\n });\n events.on(\"mobTeleport\", () => this.blip(300, 0.25, \"sine\", 0.12, 1200));\n events.on(\"toolBreak\", () => {\n this.noise(0.18, 2600, 0.25);\n this.blip(900, 0.15, \"square\", 0.1, 300);\n });\n events.on(\"itemPickup\", () => this.blip(880, 0.08, \"triangle\", 0.15, 1320));\n events.on(\"playerHurt\", () => this.blip(160, 0.2, \"sawtooth\", 0.2, 110));\n events.on(\"mobHurt\", () => this.blip(520, 0.1, \"square\", 0.12, 380));\n events.on(\"playerStep\", () => this.noise(0.045, 500, 0.06));\n events.on(\"craft\", () => this.blip(660, 0.1, \"triangle\", 0.12, 990));\n events.on(\"armSwing\", () => this.noise(0.04, 2400, 0.04));\n events.on(\"playerAte\", () => {\n this.noise(0.09, 700, 0.14);\n this.blip(300, 0.12, \"sine\", 0.1, 420);\n });\n }\n\n /** Вызвать из обработчика клика (оверлей «Играть»). */\n unlock() {\n if (!this.ctx)\n this.ctx = new (\n window.AudioContext ||\n (window as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext!\n )();\n if (this.ctx.state === \"suspended\") this.ctx.resume();\n }\n\n /** Teardown: у браузера ограниченное число AudioContext — старый нужно закрыть. */\n dispose() {\n void this.ctx?.close();\n this.ctx = null;\n }\n\n /** Тональный писк с глиссандо. */\n blip(\n freq: number,\n dur: number,\n type: OscillatorType = \"square\",\n vol = 0.15,\n glideTo: number | null = null,\n ) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const osc = this.ctx.createOscillator();\n const gain = this.ctx.createGain();\n osc.type = type;\n osc.frequency.setValueAtTime(freq, t);\n if (glideTo) osc.frequency.exponentialRampToValueAtTime(glideTo, t + dur);\n gain.gain.setValueAtTime(vol, t);\n gain.gain.exponentialRampToValueAtTime(0.001, t + dur);\n osc.connect(gain).connect(this.ctx.destination);\n osc.start(t);\n osc.stop(t + dur + 0.02);\n }\n\n /** Шумовой щелчок через фильтр (ломание/шаги). */\n noise(dur: number, filterFreq: number, vol = 0.2) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const len = Math.max(1, (dur * this.ctx.sampleRate) | 0);\n const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);\n const data = buf.getChannelData(0);\n for (let i = 0; i < len; i++)\n data[i] = (Math.random() * 2 - 1) * (1 - i / len);\n const src = this.ctx.createBufferSource();\n src.buffer = buf;\n const filter = this.ctx.createBiquadFilter();\n filter.type = \"lowpass\";\n filter.frequency.value = filterFreq;\n const gain = this.ctx.createGain();\n gain.gain.value = vol;\n src.connect(filter).connect(gain).connect(this.ctx.destination);\n src.start(t);\n }\n}\n"
1605
+ "content": "// Звук: синтез через Web Audio, без файлов. Подписан на события шины —\n// добавление нового звука = ещё одна подписка, игровая логика не знает об аудио.\nimport { events } from \"../core/EventBus\";\nimport { INSTRUMENTS, noteFrequency } from \"./NoteBlock\";\n\nexport class Audio {\n ctx: AudioContext | null;\n /** Общий выход всего звука игры: в динамики и (по запросу) в запись роликов платформы. */\n private out: GainNode | null = null;\n private recordDest: MediaStreamAudioDestinationNode | null = null;\n\n constructor() {\n this.ctx = null; // создаётся после первого user gesture (autoplay policy)\n\n events.on(\"blockBreak\", () => this.noise(0.12, 900, 0.25));\n events.on(\"blockPlace\", () => this.noise(0.06, 1600, 0.2));\n events.on(\"blockToggle\", () => this.noise(0.09, 450, 0.2));\n events.on(\"explosion\", () => {\n this.noise(0.6, 300, 0.5);\n this.blip(80, 0.5, \"sawtooth\", 0.25, 30);\n });\n events.on(\"mobAttack\", () => this.noise(0.08, 400, 0.18));\n events.on(\"bowShoot\", () => this.blip(420, 0.12, \"triangle\", 0.12, 180));\n events.on(\"arrowHit\", () => this.noise(0.05, 1200, 0.1));\n events.on(\"xpPickup\", () => this.blip(1200, 0.06, \"sine\", 0.08, 1800));\n events.on(\"levelUp\", () => {\n this.blip(660, 0.15, \"triangle\", 0.12, 880);\n this.blip(990, 0.2, \"triangle\", 0.1, 1320);\n });\n events.on(\"mobFed\", () => this.blip(520, 0.1, \"sine\", 0.1, 700));\n events.on(\"bucketFill\", () => this.noise(0.15, 700, 0.15));\n events.on(\"bucketEmpty\", () => this.noise(0.18, 600, 0.15));\n // пещеры и горы: козий рог, таран, звуковой удар Стража, крик крикуна, упавший сталактит\n events.on(\"goatHorn\", () => this.blip(260, 1.4, \"sawtooth\", 0.14, 200));\n events.on(\"goatRam\", () => this.noise(0.12, 300, 0.25));\n events.on(\"sonicBoom\", () => this.blip(80, 0.7, \"sawtooth\", 0.3, 40));\n events.on(\"sculkShriek\", () => this.blip(900, 0.9, \"square\", 0.06, 1600));\n events.on(\"dripstoneFall\", () => this.noise(0.1, 1400, 0.2));\n events.on(\"goatScream\", () => this.blip(1100, 0.5, \"sawtooth\", 0.08, 700));\n events.on(\"brushStroke\", () => this.noise(0.15, 2200, 0.12));\n events.on(\"frogEat\", () => this.blip(300, 0.12, \"sine\", 0.12, 900));\n events.on(\"snifferSniff\", () => this.noise(0.4, 500, 0.15));\n events.on(\"snifferDig\", () => this.noise(0.3, 250, 0.2));\n events.on(\"windBurst\", () => this.noise(0.25, 1800, 0.2));\n events.on(\"maceSmash\", () => this.blip(90, 0.35, \"square\", 0.25, 45));\n events.on(\"vaultOpen\", () => this.blip(660, 0.3, \"sine\", 0.12, 990));\n events.on(\"trialReward\", () => this.blip(520, 0.4, \"triangle\", 0.12, 1040));\n events.on(\"copperWax\", () => this.noise(0.2, 3000, 0.1));\n events.on(\"copperScrape\", () => this.noise(0.2, 1600, 0.15));\n events.on(\"bulbToggle\", () => this.blip(740, 0.08, \"square\", 0.08, 740));\n events.on(\"armorTrimmed\", () => this.blip(440, 0.3, \"triangle\", 0.12, 880));\n events.on(\"bundleInsert\", () => this.noise(0.08, 900, 0.1));\n events.on(\"bundleRemove\", () => this.noise(0.1, 700, 0.12));\n events.on(\"candleLit\", () => this.noise(0.1, 2600, 0.08));\n events.on(\"snowballThrow\", () => this.noise(0.06, 2000, 0.08));\n events.on(\"snowballHit\", () => this.noise(0.08, 1200, 0.1));\n events.on(\"snowGolemBuilt\", () => this.blip(520, 0.2, \"sine\", 0.1, 780));\n events.on(\"barter\", () => this.blip(740, 0.15, \"triangle\", 0.1, 560));\n events.on(\"crossbowLoad\", () => this.noise(0.12, 900, 0.18));\n events.on(\"crossbowShoot\", () =>\n this.blip(520, 0.1, \"triangle\", 0.14, 200),\n );\n events.on(\"tridentThrow\", () => this.noise(0.2, 1400, 0.16));\n events.on(\"shieldBlock\", () => this.noise(0.08, 500, 0.22));\n events.on(\"brushLoot\", () => this.blip(880, 0.15, \"sine\", 0.1, 1320));\n events.on(\"fireLit\", () => this.noise(0.12, 2400, 0.15));\n events.on(\"tntFuse\", () => this.noise(0.4, 3000, 0.12));\n events.on(\"itemThrow\", () => this.noise(0.05, 1800, 0.08));\n events.on(\"playerDied\", () => this.blip(220, 0.6, \"sawtooth\", 0.2, 60));\n events.on(\"questDone\", () => this.blip(660, 0.25, \"sine\", 0.12, 1320));\n events.on(\"till\", () => this.noise(0.1, 500, 0.16));\n events.on(\"boneMeal\", () => this.blip(900, 0.1, \"sine\", 0.08, 1400));\n events.on(\"leverClick\", () => this.blip(1400, 0.05, \"square\", 0.08, 900));\n events.on(\"buttonClick\", () => this.blip(1600, 0.04, \"square\", 0.07, 1100));\n events.on(\"pistonMove\", () => this.noise(0.14, 700, 0.2));\n events.on(\"dispense\", () => this.noise(0.08, 1300, 0.15));\n events.on(\"fishCast\", () => this.noise(0.2, 1600, 0.1));\n // нотный блок: высота ноты — мета блока, тембр — блок под ним (systems/NoteBlock)\n events.on(\n \"noteBlock\",\n ({ pitch, instrument }: { pitch: number; instrument?: string }) => {\n const inst = INSTRUMENTS[instrument ?? \"harp\"] ?? INSTRUMENTS.harp!;\n const f = noteFrequency(pitch);\n if (inst.noise) this.noise(inst.seconds, f * 2, 0.16);\n else this.blip(f, inst.seconds, inst.wave, 0.12, f);\n },\n );\n events.on(\"discEject\", () => this.noise(0.12, 800, 0.12));\n // колокол: долгий звон с обертоном; картина — шорох холста\n events.on(\"bellRing\", () => {\n this.blip(880, 1.6, \"sine\", 0.16, 870);\n this.blip(1320, 1.2, \"sine\", 0.08, 1310);\n });\n events.on(\"paintingTurn\", () => this.noise(0.1, 1500, 0.1));\n events.on(\"fishSplash\", () => this.noise(0.25, 900, 0.12));\n events.on(\"fishBite\", () => {\n this.noise(0.2, 700, 0.25);\n this.blip(400, 0.1, \"sine\", 0.1, 250);\n });\n events.on(\"fishCaught\", () => this.blip(880, 0.12, \"triangle\", 0.12, 1320));\n events.on(\"mobTamed\", () => {\n this.blip(660, 0.12, \"sine\", 0.12, 990);\n this.blip(990, 0.16, \"sine\", 0.1, 1320);\n });\n events.on(\"tameFail\", () => this.noise(0.12, 600, 0.12));\n events.on(\"potionSplash\", () => {\n this.noise(0.18, 2600, 0.18);\n this.blip(1200, 0.1, \"sine\", 0.06, 600);\n });\n events.on(\"mobSit\", () => this.blip(520, 0.06, \"sine\", 0.08, 400));\n events.on(\"dispenseFail\", () => this.blip(900, 0.05, \"square\", 0.06, 700));\n events.on(\"trade\", () => {\n this.blip(700, 0.1, \"triangle\", 0.12, 1050);\n this.blip(1050, 0.12, \"triangle\", 0.1, 1400);\n });\n events.on(\"rainPatter\", ({ level }: { level: number }) =>\n this.noise(0.35, 2200, 0.025 * level),\n );\n events.on(\"lightning\", () => {\n this.noise(1.4, 260, 0.5);\n this.blip(55, 1.2, \"sawtooth\", 0.2, 30);\n });\n events.on(\"mobTeleport\", () => this.blip(300, 0.25, \"sine\", 0.12, 1200));\n events.on(\"toolBreak\", () => {\n this.noise(0.18, 2600, 0.25);\n this.blip(900, 0.15, \"square\", 0.1, 300);\n });\n events.on(\"itemPickup\", () => this.blip(880, 0.08, \"triangle\", 0.15, 1320));\n events.on(\"playerHurt\", () => this.blip(160, 0.2, \"sawtooth\", 0.2, 110));\n events.on(\"mobHurt\", () => this.blip(520, 0.1, \"square\", 0.12, 380));\n events.on(\"playerStep\", () => this.noise(0.045, 500, 0.06));\n events.on(\"craft\", () => this.blip(660, 0.1, \"triangle\", 0.12, 990));\n events.on(\"armSwing\", () => this.noise(0.04, 2400, 0.04));\n events.on(\"playerAte\", () => {\n this.noise(0.09, 700, 0.14);\n this.blip(300, 0.12, \"sine\", 0.1, 420);\n });\n }\n\n /** Вызвать из обработчика клика (оверлей «Играть»). */\n unlock() {\n if (!this.ctx)\n this.ctx = new (\n window.AudioContext ||\n (window as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext!\n )();\n if (this.ctx.state === \"suspended\") this.ctx.resume();\n }\n\n /** Куда подключать звуки: общий выход (он идёт в динамики). */\n output(): AudioNode | null {\n if (!this.ctx) return null;\n if (!this.out) {\n this.out = this.ctx.createGain();\n this.out.connect(this.ctx.destination);\n }\n return this.out;\n }\n\n /**\n * Звук игры потоком — для записи роликов платформы (EngineScene.audio). null, пока звук не\n * разблокирован первым нажатием.\n */\n recordStream(): MediaStream | null {\n const out = this.output();\n if (!out || !this.ctx) return null;\n if (!this.recordDest) {\n this.recordDest = this.ctx.createMediaStreamDestination();\n out.connect(this.recordDest);\n }\n return this.recordDest.stream;\n }\n\n /** Teardown: у браузера ограниченное число AudioContext — старый нужно закрыть. */\n dispose() {\n void this.ctx?.close();\n this.ctx = null;\n this.out = null;\n this.recordDest = null;\n }\n\n /** Тональный писк с глиссандо. */\n blip(\n freq: number,\n dur: number,\n type: OscillatorType = \"square\",\n vol = 0.15,\n glideTo: number | null = null,\n ) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const osc = this.ctx.createOscillator();\n const gain = this.ctx.createGain();\n osc.type = type;\n osc.frequency.setValueAtTime(freq, t);\n if (glideTo) osc.frequency.exponentialRampToValueAtTime(glideTo, t + dur);\n gain.gain.setValueAtTime(vol, t);\n gain.gain.exponentialRampToValueAtTime(0.001, t + dur);\n osc.connect(gain).connect(this.output()!);\n osc.start(t);\n osc.stop(t + dur + 0.02);\n }\n\n /** Шумовой щелчок через фильтр (ломание/шаги). */\n noise(dur: number, filterFreq: number, vol = 0.2) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const len = Math.max(1, (dur * this.ctx.sampleRate) | 0);\n const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);\n const data = buf.getChannelData(0);\n for (let i = 0; i < len; i++)\n data[i] = (Math.random() * 2 - 1) * (1 - i / len);\n const src = this.ctx.createBufferSource();\n src.buffer = buf;\n const filter = this.ctx.createBiquadFilter();\n filter.type = \"lowpass\";\n filter.frequency.value = filterFreq;\n const gain = this.ctx.createGain();\n gain.gain.value = vol;\n src.connect(filter).connect(gain).connect(this.output()!);\n src.start(t);\n }\n}\n"
1606
1606
  },
1607
1607
  {
1608
1608
  "path": "systems/Banner.ts",
@@ -1698,7 +1698,7 @@
1698
1698
  },
1699
1699
  {
1700
1700
  "path": "systems/Music.ts",
1701
- "content": "// Музыка, как в Minecraft: время от времени (раз в несколько минут) звучит спокойная пьеса и снова\n// тишина. Без файлов — пьеса сочиняется из сида (composePiece, чистая функция) и играется синтезом\n// Web Audio. У каждого измерения свой лад: верхний мир — мажорная пентатоника, Пекло — низкий\n// фригийский с гулом, Предел — целотонный, редкими высокими нотами. Смена измерения — пьеса затихает.\nimport { mulberry32 } from \"../core/Noise\";\nimport type { Audio } from \"./Audio\";\n\nexport type Mood = \"overworld\" | \"inferno\" | \"beyond\";\n\nexport interface Note {\n /** Начало, сек от старта пьесы. */\n t: number;\n freq: number;\n dur: number;\n /** Громкость 0..1 (общая громкость — у Music). */\n vol: number;\n wave: OscillatorType;\n}\n\ninterface Style {\n root: number;\n /** Ступени лада в полутонах от тоники. */\n steps: readonly number[];\n wave: OscillatorType;\n /** Длина доли, сек. */\n beat: number;\n /** Сколько октав вверх гуляет мелодия. */\n range: number;\n /** Гул тоники октавой ниже под всей пьесой. */\n drone: boolean;\n}\n\nconst STYLES: Record<Mood, Style> = {\n overworld: {\n root: 261.63,\n steps: [0, 2, 4, 7, 9],\n wave: \"triangle\",\n beat: 0.55,\n range: 2,\n drone: false,\n },\n inferno: {\n root: 110,\n steps: [0, 1, 3, 5, 7, 8, 10],\n wave: \"sawtooth\",\n beat: 0.8,\n range: 1,\n drone: true,\n },\n beyond: {\n root: 196,\n steps: [0, 2, 4, 6, 8, 10],\n wave: \"sine\",\n beat: 1.1,\n range: 2,\n drone: true,\n },\n};\n\n/** Пьеса: фразы по 4–8 нот, блуждающие по ступеням лада, паузы между ними, иногда бас. */\nexport function composePiece(mood: Mood, seed: number, length = 60): Note[] {\n const s = STYLES[mood];\n const rng = mulberry32(seed);\n const n = s.steps.length;\n const freqOf = (deg: number): number => {\n const oct = Math.floor(deg / n);\n return s.root * 2 ** (oct + s.steps[((deg % n) + n) % n]! / 12);\n };\n const notes: Note[] = [];\n let deg = Math.floor(rng() * n),\n t = 1.5;\n while (t < length) {\n const phrase = 4 + Math.floor(rng() * 5);\n for (let i = 0; i < phrase && t < length; i++) {\n deg = Math.max(0, Math.min(n * s.range, deg + Math.floor(rng() * 5) - 2));\n const dur = s.beat * (rng() < 0.25 ? 2 : 1);\n notes.push({\n t,\n freq: freqOf(deg),\n dur: dur * 1.8,\n vol: 0.5 + rng() * 0.3,\n wave: s.wave,\n });\n if (rng() < 0.3)\n notes.push({\n t,\n freq: freqOf(0) / 2,\n dur: dur * 3,\n vol: 0.3,\n wave: \"sine\",\n });\n t += dur;\n }\n t += s.beat * (2 + Math.floor(rng() * 4));\n }\n if (s.drone)\n notes.push({\n t: 0,\n freq: s.root / 2,\n dur: length,\n vol: 0.22,\n wave: \"sine\",\n });\n return notes;\n}\n\nconst VOLUME = 0.06;\n\nexport class Music {\n enabled = true;\n /** Секунд тишины до следующей пьесы. */\n private wait = 30 + Math.random() * 40;\n private playing: { until: number; mood: Mood; master: GainNode } | null =\n null;\n\n constructor(private readonly audio: Audio) {}\n\n get status(): \"off\" | \"playing\" | \"waiting\" {\n return !this.enabled ? \"off\" : this.playing ? \"playing\" : \"waiting\";\n }\n\n /** Кадр игры: считать тишину, начать пьесу, заглушить при смене измерения. */\n tick(dt: number, mood: Mood): void {\n const ctx = this.audio.ctx;\n if (!ctx || ctx.state !== \"running\" || !this.enabled) return;\n if (this.playing) {\n if (this.playing.mood !== mood) this.stop();\n else if (ctx.currentTime >= this.playing.until) {\n this.playing = null;\n this.wait = 180 + Math.random() * 240; // как в Minecraft: минуты тишины между пьесами\n }\n return;\n }\n this.wait -= dt;\n if (this.wait <= 0) this.play(mood);\n }\n\n /** Сыграть новую пьесу сейчас. */\n play(mood: Mood, seed = (Math.random() * 0xffffffff) >>> 0): void {\n const ctx = this.audio.ctx;\n if (!ctx || ctx.state !== \"running\") return;\n const t0 = ctx.currentTime + 0.1;\n const master = ctx.createGain();\n master.gain.value = VOLUME;\n master.connect(ctx.destination);\n // простое эхо — «зал» для одиноких нот\n const delay = ctx.createDelay(1);\n delay.delayTime.value = 0.35;\n const fb = ctx.createGain();\n fb.gain.value = 0.35;\n delay.connect(fb).connect(delay);\n delay.connect(master);\n // сид задан у пластинки (systems/Disc) — её мелодия всегда одна и та же\n const notes = composePiece(mood, seed);\n let end = 0;\n for (const n of notes) {\n const osc = ctx.createOscillator();\n osc.type = n.wave;\n osc.frequency.value = n.freq;\n const env = ctx.createGain();\n const s = t0 + n.t;\n env.gain.setValueAtTime(0.0001, s);\n env.gain.exponentialRampToValueAtTime(n.vol, s + 0.03);\n env.gain.exponentialRampToValueAtTime(0.0001, s + n.dur);\n let out: AudioNode = env;\n if (n.wave === \"sawtooth\") {\n const lp = ctx.createBiquadFilter(); // пильный — мягче, как гул Пекла\n lp.type = \"lowpass\";\n lp.frequency.value = 700;\n env.connect(lp);\n out = lp;\n }\n osc.connect(env);\n out.connect(master);\n out.connect(delay);\n osc.start(s);\n osc.stop(s + n.dur + 0.05);\n end = Math.max(end, n.t + n.dur);\n }\n this.playing = { until: t0 + end + 2, mood, master };\n }\n\n /** Плавно заглушить текущую пьесу (смена измерения, /music off). */\n stop(): void {\n const ctx = this.audio.ctx;\n const p = this.playing;\n this.playing = null;\n this.wait = 60 + Math.random() * 60;\n if (!ctx || !p) return;\n p.master.gain.setTargetAtTime(0, ctx.currentTime, 0.6);\n }\n\n /** /music next: следующая пьеса — прямо сейчас. */\n next(): void {\n this.stop();\n this.wait = 0;\n }\n}\n"
1701
+ "content": "// Музыка, как в Minecraft: время от времени (раз в несколько минут) звучит спокойная пьеса и снова\n// тишина. Без файлов — пьеса сочиняется из сида (composePiece, чистая функция) и играется синтезом\n// Web Audio. У каждого измерения свой лад: верхний мир — мажорная пентатоника, Пекло — низкий\n// фригийский с гулом, Предел — целотонный, редкими высокими нотами. Смена измерения — пьеса затихает.\nimport { mulberry32 } from \"../core/Noise\";\nimport type { Audio } from \"./Audio\";\n\nexport type Mood = \"overworld\" | \"inferno\" | \"beyond\";\n\nexport interface Note {\n /** Начало, сек от старта пьесы. */\n t: number;\n freq: number;\n dur: number;\n /** Громкость 0..1 (общая громкость — у Music). */\n vol: number;\n wave: OscillatorType;\n}\n\ninterface Style {\n root: number;\n /** Ступени лада в полутонах от тоники. */\n steps: readonly number[];\n wave: OscillatorType;\n /** Длина доли, сек. */\n beat: number;\n /** Сколько октав вверх гуляет мелодия. */\n range: number;\n /** Гул тоники октавой ниже под всей пьесой. */\n drone: boolean;\n}\n\nconst STYLES: Record<Mood, Style> = {\n overworld: {\n root: 261.63,\n steps: [0, 2, 4, 7, 9],\n wave: \"triangle\",\n beat: 0.55,\n range: 2,\n drone: false,\n },\n inferno: {\n root: 110,\n steps: [0, 1, 3, 5, 7, 8, 10],\n wave: \"sawtooth\",\n beat: 0.8,\n range: 1,\n drone: true,\n },\n beyond: {\n root: 196,\n steps: [0, 2, 4, 6, 8, 10],\n wave: \"sine\",\n beat: 1.1,\n range: 2,\n drone: true,\n },\n};\n\n/** Пьеса: фразы по 4–8 нот, блуждающие по ступеням лада, паузы между ними, иногда бас. */\nexport function composePiece(mood: Mood, seed: number, length = 60): Note[] {\n const s = STYLES[mood];\n const rng = mulberry32(seed);\n const n = s.steps.length;\n const freqOf = (deg: number): number => {\n const oct = Math.floor(deg / n);\n return s.root * 2 ** (oct + s.steps[((deg % n) + n) % n]! / 12);\n };\n const notes: Note[] = [];\n let deg = Math.floor(rng() * n),\n t = 1.5;\n while (t < length) {\n const phrase = 4 + Math.floor(rng() * 5);\n for (let i = 0; i < phrase && t < length; i++) {\n deg = Math.max(0, Math.min(n * s.range, deg + Math.floor(rng() * 5) - 2));\n const dur = s.beat * (rng() < 0.25 ? 2 : 1);\n notes.push({\n t,\n freq: freqOf(deg),\n dur: dur * 1.8,\n vol: 0.5 + rng() * 0.3,\n wave: s.wave,\n });\n if (rng() < 0.3)\n notes.push({\n t,\n freq: freqOf(0) / 2,\n dur: dur * 3,\n vol: 0.3,\n wave: \"sine\",\n });\n t += dur;\n }\n t += s.beat * (2 + Math.floor(rng() * 4));\n }\n if (s.drone)\n notes.push({\n t: 0,\n freq: s.root / 2,\n dur: length,\n vol: 0.22,\n wave: \"sine\",\n });\n return notes;\n}\n\nconst VOLUME = 0.06;\n\nexport class Music {\n enabled = true;\n /** Секунд тишины до следующей пьесы. */\n private wait = 30 + Math.random() * 40;\n private playing: { until: number; mood: Mood; master: GainNode } | null =\n null;\n\n constructor(private readonly audio: Audio) {}\n\n get status(): \"off\" | \"playing\" | \"waiting\" {\n return !this.enabled ? \"off\" : this.playing ? \"playing\" : \"waiting\";\n }\n\n /** Кадр игры: считать тишину, начать пьесу, заглушить при смене измерения. */\n tick(dt: number, mood: Mood): void {\n const ctx = this.audio.ctx;\n if (!ctx || ctx.state !== \"running\" || !this.enabled) return;\n if (this.playing) {\n if (this.playing.mood !== mood) this.stop();\n else if (ctx.currentTime >= this.playing.until) {\n this.playing = null;\n this.wait = 180 + Math.random() * 240; // как в Minecraft: минуты тишины между пьесами\n }\n return;\n }\n this.wait -= dt;\n if (this.wait <= 0) this.play(mood);\n }\n\n /** Сыграть новую пьесу сейчас. */\n play(mood: Mood, seed = (Math.random() * 0xffffffff) >>> 0): void {\n const ctx = this.audio.ctx;\n if (!ctx || ctx.state !== \"running\") return;\n const t0 = ctx.currentTime + 0.1;\n const master = ctx.createGain();\n master.gain.value = VOLUME;\n master.connect(this.audio.output() ?? ctx.destination);\n // простое эхо — «зал» для одиноких нот\n const delay = ctx.createDelay(1);\n delay.delayTime.value = 0.35;\n const fb = ctx.createGain();\n fb.gain.value = 0.35;\n delay.connect(fb).connect(delay);\n delay.connect(master);\n // сид задан у пластинки (systems/Disc) — её мелодия всегда одна и та же\n const notes = composePiece(mood, seed);\n let end = 0;\n for (const n of notes) {\n const osc = ctx.createOscillator();\n osc.type = n.wave;\n osc.frequency.value = n.freq;\n const env = ctx.createGain();\n const s = t0 + n.t;\n env.gain.setValueAtTime(0.0001, s);\n env.gain.exponentialRampToValueAtTime(n.vol, s + 0.03);\n env.gain.exponentialRampToValueAtTime(0.0001, s + n.dur);\n let out: AudioNode = env;\n if (n.wave === \"sawtooth\") {\n const lp = ctx.createBiquadFilter(); // пильный — мягче, как гул Пекла\n lp.type = \"lowpass\";\n lp.frequency.value = 700;\n env.connect(lp);\n out = lp;\n }\n osc.connect(env);\n out.connect(master);\n out.connect(delay);\n osc.start(s);\n osc.stop(s + n.dur + 0.05);\n end = Math.max(end, n.t + n.dur);\n }\n this.playing = { until: t0 + end + 2, mood, master };\n }\n\n /** Плавно заглушить текущую пьесу (смена измерения, /music off). */\n stop(): void {\n const ctx = this.audio.ctx;\n const p = this.playing;\n this.playing = null;\n this.wait = 60 + Math.random() * 60;\n if (!ctx || !p) return;\n p.master.gain.setTargetAtTime(0, ctx.currentTime, 0.6);\n }\n\n /** /music next: следующая пьеса — прямо сейчас. */\n next(): void {\n this.stop();\n this.wait = 0;\n }\n}\n"
1702
1702
  },
1703
1703
  {
1704
1704
  "path": "systems/NoteBlock.ts",
@@ -2410,7 +2410,7 @@
2410
2410
  },
2411
2411
  {
2412
2412
  "path": "module.meta.json",
2413
- "content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The lobby's 'Worlds' tab turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby's home shows 'With friends' next to the game's 'Play' and a 'Multiplayer' tab, and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Characters: the player's own blocky character (third-person view on F5, the first-person arm and an inventory doll from the same recipe) is a CharacterSpec recipe — skeleton template (humanoid, quadruped, winged, blob), boxes on bones, palette and pixel-art faces with expressions — animated by a layered animator (locomotion state graph, actions, emotes, emotions with faces and posture, secondary motion of tails and capes). The lobby's 'Character' and 'Items' tabs turn reference pictures (front, side, back) and a description into a character or an item look — with InApp AI (title features \\\"voxel-character\\\" and \\\"voxel-item\\\") or without it (colours from the picture); item looks re-skin an existing item (stats stay) or are cosmetics worn on the body (hats, capes, wings). Characters also become NPCs and custom mobs with preset behaviours; looks, emotes and emotions sync over the network; characters and items publish to the Workshop. Not included yet: AI dialogue for NPCs and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"player character from a recipe: third-person view (F5), first-person arm and inventory doll\",\n \"character animation: state graph (walk, run, sneak, swim, fly, glide, ride, sleep, death), actions, emotes, emotions with faces\",\n \"AI character from pictures (front/side/back) + description via client.ai (feature \\\"voxel-character\\\"), or colours from a picture without AI\",\n \"AI item looks via client.ai (feature \\\"voxel-item\\\"): new look of an existing item, or cosmetics (hats, capes, wings)\",\n \"NPCs and custom mobs from a character with preset behaviours (npc, passive, neutral, hostile, pet)\",\n \"emote wheel (G), /emote, /feel, chat smileys; synced over the network\",\n \"characters and item looks in the Workshop (voxelcraft.character, voxelcraft.item)\",\n \"quest book (J): a survival line that doubles as the tutorial — chapters, quests with parents, hints that get more precise when the player is stuck, an arrow to the nearest target, recipes as pictures, situational tips; English and Russian\",\n \"pluggable quest sources (QuestSource) — the base for server quests; new task kinds as registry entries\",\n \"lives inside the SDK lobby, Fortnite-style: the player's character on a pedestal in a block diorama on the lobby's home (ctx.lobby hero) with Edit / Emote, the world card and Play\",\n \"lobby tabs on the UI kit next to every system: Worlds (new world from pictures + description, My worlds), Character (studio), Items (looks and wearables), Locker\",\n \"\\\"To the lobby\\\" in the pause menu returns to the SDK lobby; the world chosen there opens on Play\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"character\",\n \"avatar\",\n \"animation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.6.0\"\n}\n"
2413
+ "content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The lobby's 'Worlds' tab turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby's home shows 'With friends' next to the game's 'Play' and a 'Multiplayer' tab, and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Characters: the player's own blocky character (third-person view on F5, the first-person arm and an inventory doll from the same recipe) is a CharacterSpec recipe — skeleton template (humanoid, quadruped, winged, blob), boxes on bones, palette and pixel-art faces with expressions — animated by a layered animator (locomotion state graph, actions, emotes, emotions with faces and posture, secondary motion of tails and capes). The lobby's 'Character' and 'Items' tabs turn reference pictures (front, side, back) and a description into a character or an item look — with InApp AI (title features \\\"voxel-character\\\" and \\\"voxel-item\\\") or without it (colours from the picture); item looks re-skin an existing item (stats stay) or are cosmetics worn on the body (hats, capes, wings). Characters also become NPCs and custom mobs with preset behaviours; looks, emotes and emotions sync over the network; characters and items publish to the Workshop. Not included yet: AI dialogue for NPCs and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"player character from a recipe: third-person view (F5), first-person arm and inventory doll\",\n \"character animation: state graph (walk, run, sneak, swim, fly, glide, ride, sleep, death), actions, emotes, emotions with faces\",\n \"AI character from pictures (front/side/back) + description via client.ai (feature \\\"voxel-character\\\"), or colours from a picture without AI\",\n \"AI item looks via client.ai (feature \\\"voxel-item\\\"): new look of an existing item, or cosmetics (hats, capes, wings)\",\n \"NPCs and custom mobs from a character with preset behaviours (npc, passive, neutral, hostile, pet)\",\n \"emote wheel (G), /emote, /feel, chat smileys; synced over the network\",\n \"characters and item looks in the Workshop (voxelcraft.character, voxelcraft.item)\",\n \"quest book (J): a survival line that doubles as the tutorial — chapters, quests with parents, hints that get more precise when the player is stuck, an arrow to the nearest target, recipes as pictures, situational tips; English and Russian\",\n \"pluggable quest sources (QuestSource) — the base for server quests; new task kinds as registry entries\",\n \"lives inside the SDK lobby, Fortnite-style: the player's character on a pedestal in a block diorama on the lobby's home (ctx.lobby hero) with Edit / Emote, the world card and Play\",\n \"lobby tabs on the UI kit next to every system: Worlds (new world from pictures + description, My worlds), Character (studio), Items (looks and wearables), Locker\",\n \"\\\"To the lobby\\\" in the pause menu returns to the SDK lobby; the world chosen there opens on Play\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"character\",\n \"avatar\",\n \"animation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.7.0\"\n}\n"
2414
2414
  }
2415
2415
  ]
2416
2416
  }