@idosgames/mcp 0.1.35 → 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 +124 -120
  5. package/registry/modules/ai.json +3 -3
  6. package/registry/modules/board-game.json +37 -37
  7. package/registry/modules/boosts.json +3 -3
  8. package/registry/modules/character.json +15 -15
  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 +13 -13
  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.1"
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.1",
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": "d1116beca55d0f654ff1c0592bdd55b063bfff6ebae41ba7419fa606cd5890de",
93
+ "contentHash": "4564e2471bfe58f9cc0b6deda1935f42f11298bc8d841f1994a9930203ea14ad",
94
94
  "files": [
95
95
  {
96
96
  "path": "agent.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",
@@ -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.1\"\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
  }