@idosgames/mcp 0.1.19 → 0.1.20

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 (42) hide show
  1. package/dist/cli.js +2 -2
  2. package/package.json +1 -1
  3. package/registry/host.json +7 -7
  4. package/registry/index.json +437 -110
  5. package/registry/modules/ai.json +65 -0
  6. package/registry/modules/board-game.json +3 -3
  7. package/registry/modules/boosts.json +66 -0
  8. package/registry/modules/character.json +4 -5
  9. package/registry/modules/chat.json +4 -5
  10. package/registry/modules/collections.json +69 -0
  11. package/registry/modules/coop-events.json +67 -0
  12. package/registry/modules/craft.json +4 -5
  13. package/registry/modules/deals.json +4 -5
  14. package/registry/modules/events.json +8 -13
  15. package/registry/modules/friends.json +5 -5
  16. package/registry/modules/idle-rpg.json +3 -3
  17. package/registry/modules/inventory.json +15 -18
  18. package/registry/modules/invite.json +7 -7
  19. package/registry/modules/leaderboard.json +4 -5
  20. package/registry/modules/lootboxes.json +67 -0
  21. package/registry/modules/mailbox.json +71 -0
  22. package/registry/modules/marketplace.json +4 -5
  23. package/registry/modules/mini-game.json +2 -2
  24. package/registry/modules/multiplayer.json +74 -0
  25. package/registry/modules/premium.json +67 -0
  26. package/registry/modules/profile.json +9 -10
  27. package/registry/modules/pvp.json +4 -5
  28. package/registry/modules/quests.json +69 -0
  29. package/registry/modules/rewards.json +15 -21
  30. package/registry/modules/season.json +8 -13
  31. package/registry/modules/store.json +4 -5
  32. package/registry/modules/tutorials.json +4 -5
  33. package/registry/modules/voxelcraft.json +15 -11
  34. package/registry/modules/workshop.json +4 -5
  35. package/registry/skills/blockchain-system.json +1 -1
  36. package/registry/skills/idosgames-compose-modules.json +2 -2
  37. package/registry/skills/idosgames-getting-started.json +1 -1
  38. package/registry/skills/idosgames-module-contract.json +2 -2
  39. package/registry/skills/idosgames-ui-kit.json +2 -2
  40. package/registry/skills/mailbox-system.json +6 -0
  41. package/registry/skills/multiplayer-realtime.json +2 -2
  42. package/registry/skills/workshop-system.json +1 -1
@@ -8,8 +8,12 @@
8
8
  },
9
9
  "catalog": {
10
10
  "type": "template",
11
+ "uses": [
12
+ "multiplayer",
13
+ "workshop"
14
+ ],
11
15
  "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).",
12
- "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 'Create world' screen 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, like Minecraft's 'Open to LAN': a player opens their world to the network (by invite code or in the public list, guest game mode, 'commands for everyone' switch), others join from the 'Network game' screen; 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). Not included yet: AI-driven villagers and a dedicated server.",
16
+ "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 'Create world' screen 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 shows 'With friends' next to the game's 'Play', 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). Not included yet: AI-driven villagers and a dedicated server.",
13
17
  "provides": [
14
18
  "first-person voxel world: mine, place and break blocks",
15
19
  "survival and creative modes (health, hunger, air, XP, death and respawn)",
@@ -29,7 +33,7 @@
29
33
  "AI world generation via client.ai (feature \"voxel-world\")",
30
34
  "AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)",
31
35
  "AI recreates landscapes from pictures (terrain sketch, rivers, biomes)",
32
- "co-op multiplayer up to 8 players (P2P over WebRTC, @idosgames/core/realtime)",
36
+ "co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included",
33
37
  "open the world to the network: invite code or public list, guest game mode, operators, kick and ban",
34
38
  "players in different dimensions at once; host migration without stopping the world",
35
39
  "autosave and 'My worlds' (IndexedDB)",
@@ -65,11 +69,11 @@
65
69
  "version": "0.4.0"
66
70
  },
67
71
  "dependencies": {
68
- "@idosgames/core": "0.19.0",
69
- "@idosgames/module-sdk": "0.6.0",
72
+ "@idosgames/core": "0.19.1",
73
+ "@idosgames/module-sdk": "0.7.0",
70
74
  "three": "0.185.1"
71
75
  },
72
- "contentHash": "1ce732b5d2b0e71a095b690b744fd20721ddbba38085a131418994e8152f303c",
76
+ "contentHash": "94cb46dbc2c7142b5170ea524d450e401d19ac2531fe0fc2501326e8c1a34864",
73
77
  "files": [
74
78
  {
75
79
  "path": "agent.ts",
@@ -493,11 +497,11 @@
493
497
  },
494
498
  {
495
499
  "path": "main.ts",
496
- "content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config\";\nimport { events } from \"./core/EventBus\";\nimport { GameLoop } from \"./core/GameLoop\";\nimport { IntentQueue } from \"./core/Intents\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas\";\nimport { buildIcons } from \"./registry/Items\";\nimport { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView, 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 {\n DIMENSIONS,\n isDimensionId,\n type DimensionId,\n} from \"./world/Dimensions\";\nimport { BLOCKS, B } from \"./registry/Blocks\";\nimport { findSafeSpot, isStandable } from \"./world/safeSpot\";\nimport { MOBS } from \"./registry/Mobs\";\nimport { buildPortal, findPortalNear, portalsOf } from \"./world/Portal\";\nimport { LAVA_SEA } from \"./worldgen/inferno/InfernoGenerator\";\nimport {\n BEYOND_ARRIVAL,\n BeyondGenerator,\n ISLAND_Y,\n OUTER_LANDING,\n beyondPillars,\n exitPortalCells,\n gatewaySpot,\n} from \"./worldgen/beyond/BeyondGenerator\";\nimport { OUTER_START } from \"./worldgen/beyond/outer\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { MAP_SIZE, newMap, surveyMap, type MapData } from \"./systems/Maps\";\nimport { EXPLORER_MAPS } from \"./registry/items/mansion\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { hitTarget } from \"./world/behaviours/extras\";\nimport { anchorCharges } from \"./world/behaviours/stand\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { dripstoneFallDamage } from \"./world/behaviours/dripstone\";\nimport { Freezing } from \"./systems/Freezing\";\nimport {\n TRIAL_RANGE,\n trialStep,\n type TrialState,\n} from \"./systems/TrialSpawner\";\nimport { TRIAL_MOBS } from \"./registry/blocks/trial\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem, worldSourceOf } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { CreateWorldUI } from \"./ui/CreateWorldUI\";\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_WAVES, Raid } from \"./systems/Raid\";\nimport { siegeCount, siegeSpot, siegeTonight } from \"./systems/Siege\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { beaconEffects, inBeaconRange, type BeaconPulse } from \"./world/Beacon\";\nimport { WorldStore, newWorldId } from \"./systems/WorldStore\";\nimport type { WorldAI } from \"./ai/types\";\nimport { startSource } from \"./worlds/startWorld\";\nimport { CURRENT_GEN } from \"./worldgen/WorldGenerator\";\nimport type { DimensionSave, EffectId, SaveData, WorldSource } from \"./types\";\nimport type { RealtimeClient } from \"@idosgames/core/realtime\";\nimport { sim } from \"./net/sim\";\nimport type { NetGame } 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 difficulty,\n parseDifficulty,\n setDifficulty,\n} from \"./systems/Difficulty\";\n\n/** Автосейв текущего мира в «Мои миры», секунд игры. */\nconst AUTOSAVE_EVERY = 30;\n/** Восемь клеток вокруг (сначала стороны, потом углы) — где встать у якоря возрождения. */\nconst AROUND: readonly (readonly [number, number])[] = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n [1, 1],\n [1, -1],\n [-1, 1],\n [-1, -1],\n];\n\nexport interface GameOptions {\n /** ИИ-генерация миров (client.ai). null/нет — только путь без ИИ. */\n ai?: WorldAI | null;\n /** Сетевая игра: SDK-клиент (комнаты и сигналинг движка). null/нет — только одиночная. */\n netClient?: RealtimeClient | null;\n /** Имя игрока в сетевой игре (профиль); нет — «Игрок». */\n playerName?: () => string;\n}\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\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 /** Достижения мира (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 save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n worldUI: CreateWorldUI;\n /** Экран «Сетевая игра» и список игроков по Tab. */\n netUI: NetUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n private _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 private _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n private _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n private _worldTouched = false;\n private _destroyed = false;\n\n // ---- сетевая игра (net/NetGame) ----\n /** Идёт сетевая игра (хост или гость); null — одиночная. */\n net: NetGame | null = null;\n /** SDK-клиент для сетевой игры (комнаты движка); нет — мультиплеер недоступен. */\n readonly netClient: RealtimeClient | null;\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 private _soloWorldBeforeNet: string | null = null;\n\n /** Этот процесс считает своё измерение (одиночная игра, хост, владелец измерения) — net/sim. */\n get simulating(): boolean {\n return sim.authority;\n }\n\n constructor(opts: GameOptions = {}) {\n this.netClient = opts.netClient ?? null;\n this.playerName = opts.playerName ?? (() => \"Игрок\");\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n // Казуальная картинка: 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 = new TextureAtlas();\n buildIcons(this.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.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.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 // второй проход (рука) видит только свой слой — свет мира включаем и на нём\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.worldUI = new CreateWorldUI(this, this._abort.signal, opts.ai ?? null);\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.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 // опыт из печи (и других источников, что не кидают сферы)\n events.on(\"xpGain\", ({ amount }: { amount: number }) =>\n this.player.addXp(amount),\n );\n events.on(\"playerDied\", () => this._onDeath());\n events.on(\"portalLit\", (d: { x: number; y: number; z: number }) =>\n this._rememberPortal(this.dimension, d.x, d.y, d.z),\n );\n events.on(\"throwEye\", (d: { x: number; y: number; z: number }) =>\n this._throwEye(d.x, d.y, d.z),\n );\n events.on(\"dragonDefeated\", () => this._dragonDefeated());\n events.on(\"ui:useBed\", (d: { x: number; y: number; z: number }) =>\n this._useBed(d.x, d.y, d.z),\n );\n events.on(\"lightning\", (d: { x: number; y: number; z: number }) =>\n this._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 = this.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 this.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 this.entities.spawnDrop(\n p.x + 0.5,\n p.y + 1.1,\n p.z + 0.5,\n p.item,\n p.count,\n ),\n );\n // дроп, который роняет сам моб (рог козы)\n events.on(\n \"mobDrop\",\n (p: { x: number; y: number; z: number; item: string }) =>\n this.entities.spawnDrop(p.x, p.y, p.z, p.item, 1),\n );\n // глубокая тьма: сломанные и поставленные игроком блоки и взрывы — вибрации; смерть рядом с\n // катализатором — скалк; место смерти игрока — для компаса возвращения\n const overworld = () => this.dimension === \"overworld\";\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; byPlayer?: boolean }) => {\n if (overworld())\n this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n !!p.byPlayer && this.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 = this.player;\n if (pl.dead) return;\n this.warmup(p.x, p.z);\n let y = Math.floor(p.y);\n while (\n y > 1 &&\n !this.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 this.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 this.fireworks.launch(p.x, p.y, p.z),\n );\n // компас по путевому камню: стрелка держится за него, пока камень цел (ui/HUD)\n events.on(\"lodestoneBind\", (p: { x: number; y: number; z: number }) => {\n this.player.lodestone = { x: p.x, z: p.z, dim: this.dimension };\n this.hud.message(\"Компас привязан к путевому камню\", 2000);\n });\n // якорь возрождения: в Пекле — точка возрождения, в верхнем мире взрывается, как в Minecraft\n events.on(\n \"anchorUse\",\n (p: { x: number; y: number; z: number; charges: number }) => {\n if (!DIMENSIONS[this.dimension].bedsExplode) {\n this.world.setBlock(p.x, p.y, p.z, B.air!);\n this.entities.explode(p.x + 0.5, p.y + 0.5, p.z + 0.5, 5);\n return;\n }\n if (p.charges <= 0) {\n this.hud.message(\"Якорь не заряжен — нужен светокамень\", 2200);\n return;\n }\n this.player.anchorPos = [p.x, p.y, p.z];\n this.player.anchorDim = this.dimension;\n this.hud.message(\"Точка возрождения — у якоря\", 2200);\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(this.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 this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n this.player.mode !== \"creative\",\n );\n });\n events.on(\"explosion\", (p: { x: number; y: number; z: number }) => {\n if (overworld()) this.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 this.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 ? this.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 = this.player.pos;\n this.lastDeath = { x: pp.x, y: pp.y, z: pp.z, dim: this.dimension };\n });\n // проводник: игрок в воде в его радиусе получает силу моря (дыхание, ночное зрение, спешка)\n events.on(\n \"conduitPulse\",\n (p: { x: number; y: number; z: number; range: number }) => {\n const pl = this.player;\n if (\n pl.dead ||\n !pl.inWater ||\n pl.pos.distanceTo(\n 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 = this.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 this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n void this.persist();\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\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\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open().then(async (store) => {\n if (!store) return;\n if (this._destroyed) return store.close();\n this.store = store;\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(\"Продолжаем мир «\" + last.name + \"»\", 2200);\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n });\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). Гость чужой мир не сохраняет. */\n async persist(): Promise<void> {\n if (!this.store || this._netGuest) return;\n this.net?.collectPlayerStates(this.netPlayers);\n try {\n await this.store.put({\n id: this.worldId,\n name: this.worldName,\n updatedAt: Date.now(),\n save: this.save.collect(),\n });\n } catch {\n // квота/запрет хранилища: игра продолжается без автосейва\n }\n }\n\n /** Открыть мир из «Моих миров». */\n async openStoredWorld(id: string): Promise<boolean> {\n if (!this.store) return false;\n const w = await this.store.get(id).catch(() => undefined);\n if (!w) return false;\n if (id !== this.worldId) await this.persist();\n try {\n this.save.apply(w.save);\n } catch {\n return false;\n }\n this._worldTouched = true;\n this.worldId = w.id;\n this.worldName = w.name;\n return true;\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n // persist() снимает сейв синхронно при вызове — до apply ниже, поэтому уходит именно старый мир.\n void this.persist();\n try {\n this.save.apply(save);\n } catch {\n return false;\n }\n this.adoptNewWorld(name);\n this.hud.message(\"Мир «\" + name + \"» из Мастерской\", 2200);\n return true;\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n this._worldTouched = true;\n this.worldId = newWorldId();\n this.worldName = name;\n void this.persist();\n }\n\n // ---- сетевая игра (net/NetGame) ---------------------------------------------------------------\n\n /** Гость в чужом мире: мир не наш — не сохраняем его в «Мои миры» (до передачи хоста). */\n private _netGuest = false;\n\n /** Мир для входящего гостя: источник, палитра, время, погода, сложность, Предел, порталы. */\n netWorldMeta(): WorldMeta {\n return {\n source: this.world.source,\n palette: BLOCKS.map((b) => b.name),\n time: this.dayNight.serialize(),\n weather: this.weather.serialize(),\n difficulty: difficulty(),\n beyond: this.beyondState,\n portals: this.portals,\n daysAwake: this.daysAwake,\n name: this.worldName,\n };\n }\n\n /** Общее состояние мира (хост раз в 5 с): время, погода, сложность, Предел. */\n netWorldState(): WorldState {\n return {\n time: this.dayNight.serialize(),\n weather: this.weather.serialize(),\n difficulty: difficulty(),\n beyond: this.beyondState,\n daysAwake: this.daysAwake,\n };\n }\n\n applyNetWorldState(w: WorldState): void {\n this.dayNight.deserialize(w.time as SaveData[\"time\"]);\n this.weather.deserialize(w.weather as SaveData[\"weather\"]);\n setDifficulty(parseDifficulty(w.difficulty) ?? \"normal\");\n this.mergeBeyond(w.beyond);\n if (typeof w.daysAwake === \"number\" && w.daysAwake >= 0)\n this.daysAwake = w.daysAwake;\n }\n\n /** Предел: победа над драконом и Врата — однажды случившись, не отменяются (слияние «или»). */\n mergeBeyond(b: unknown): void {\n if (!b || typeof b !== \"object\") return;\n const s = b as Partial<Game[\"beyondState\"]>;\n const st = this.beyondState;\n st.initialized ||= s.initialized === true;\n st.dragonKilled ||= s.dragonKilled === true;\n st.creditsShown ||= s.creditsShown === true;\n if (\n !st.gateway &&\n Array.isArray(s.gateway) &&\n s.gateway.length === 3 &&\n s.gateway.every(Number.isFinite)\n )\n st.gateway = [s.gateway[0]!, s.gateway[1]!, s.gateway[2]!];\n }\n\n /** Сейв этого игрока для мира хоста: позиция, измерение, инвентарь, опыт, эффекты. */\n netPlayerState(): Record<string, unknown> {\n const p = this.player.pos;\n return {\n ...this.player.serialize(),\n inventory: this.inventory.serialize(),\n dim: this.dimension,\n pos: [p.x, p.y, p.z],\n beyond: this.beyondState,\n };\n }\n\n /** Ночь пропущена: спали все (сетевая игра) или один (одиночная). */\n skipNight(): void {\n this.dayNight.resetToMorning();\n this.weather.set(\"clear\"); // сон пережидает и непогоду, как в Minecraft\n this._slept = true;\n this.daysAwake = 0;\n this.netSleeping = false;\n this.hud.message(\"Доброе утро!\", 1800);\n events.emit(\"slept\");\n }\n\n /**\n * Гость: мир хоста вместо своего. Свой уходит в «Мои миры» и вернётся при выходе. Все измерения\n * строятся из присланных дифов — порталы работают без ожидания.\n */\n loadNetWorld(w: Welcome, diffs: DiffStore, settings: RoomSettings): void {\n void this.persist();\n this._soloWorldBeforeNet = this.worldId;\n this._netGuest = true;\n // сейвы чужих игроков — от СВОЕГО прежнего мира: стань мы хостом этого, они бы переехали в него\n this.netPlayers = new Map();\n this._worldTouched = true;\n if (this.invUI.open) this.invUI.close();\n const dim = isDimensionId(w.spawn.dim) ? w.spawn.dim : \"overworld\";\n const src = worldSourceOf({\n world: w.world.source,\n seed: 0,\n } as unknown as SaveData);\n this.dims.clear();\n for (const d of [\"overworld\", \"inferno\", \"beyond\"] as const)\n if (d !== dim)\n this.dims.set(d, {\n chunks: diffs.toChunkDiffMap(d),\n entities: [],\n blockEntities: [],\n });\n this.rebuildWorld(src, dim);\n this.world.loadDiffs(diffs.toChunkDiffMap(dim));\n this.entities.clear();\n this.blockEntities.clear();\n this.applyNetWorldState({ ...w.world });\n this.portals = portalsOf(w.world.portals);\n this.villagesSettled = new Set();\n this.maps = new Map();\n this.nextMapId = 1;\n this.achievements.load([]);\n const state = w.state as\n (Record<string, unknown> & { inventory?: unknown }) | null;\n if (state && Array.isArray((state as { pos?: unknown }).pos)) {\n this.player.deserialize(\n state as unknown as Parameters<Player[\"deserialize\"]>[0],\n );\n this.inventory.deserialize(\n state.inventory as Parameters<Inventory[\"deserialize\"]>[0],\n );\n } else {\n this.player.resetForNewWorld();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n this.player.mode = settings.guestMode;\n }\n const [x, y, z] = w.spawn.pos;\n this.warmup(x, z);\n this.player.pos.set(x, y, z);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n if (!state) this.player.spawnPoint.set(x, y, z);\n this.worldName = \"Сетевая игра\";\n this._netWorldName =\n typeof w.world.name === \"string\" && w.world.name.trim()\n ? w.world.name.trim().slice(0, 60)\n : \"\";\n this.hud.message(\"Вы в сетевой игре\", 2200);\n events.emit(\"dimensionChanged\", { dimension: dim });\n }\n\n /** Стал хостом после ухода прежнего: этот мир теперь наш — новой записью в «Моих мирах». */\n adoptNetWorldAsOwn(): void {\n this._netGuest = false;\n // имя мира хоста, а не безликое «Сетевой мир»: при переносе мир остаётся тем же миром\n this.adoptNewWorld(this._netWorldName || \"Сетевой мир\");\n }\n private _netWorldName = \"\";\n\n /**\n * Сетевая игра закончилась. Гость возвращается в свой мир; хост (и ставший хостом) продолжает\n * в этом мире один. `reason` — показать почему (исключили, хост закрыл игру).\n */\n exitNet(reason: string | null): void {\n const guest = this._netGuest;\n this.net = null;\n this._netGuest = false;\n this.netSleeping = false;\n sim.authority = true;\n this.entities.replica = false;\n this.entities.others = () => [];\n this.entities.onLocalSpawn = null;\n this.entities.onRemoteAction = null;\n if (guest) {\n const back = this._soloWorldBeforeNet;\n this._soloWorldBeforeNet = null;\n if (back)\n void this.openStoredWorld(back).then(\n (ok) => ok || this.newWorld((Math.random() * 1e9) >>> 0),\n );\n else this.newWorld((Math.random() * 1e9) >>> 0);\n }\n if (reason) this.hud.message(reason, 3500);\n events.emit(\"net:roster\");\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n this.world.update(x, z);\n const busy =\n this.world.genQueue.length > 0 ||\n [...this.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n const s = this.world.generator.spawnHint();\n this.warmup(s.x, s.z);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, source, dimension);\n this.dimension = dimension;\n this.blockEntities.clear();\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.fluids.world = this.world;\n this.fluids.clear();\n this.interaction.breaking = null;\n this.interaction.damage.clear(); // трещины старого мира не должны лечь на блоки нового\n this.interaction.eating = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n const src: WorldSource =\n typeof source === \"number\"\n ? { kind: \"noise\", seed: source, gen: CURRENT_GEN }\n : // новый мир — текущего поколения (у мира из рецепта: цитадель под островом)\n { ...source, gen: source.gen ?? CURRENT_GEN };\n void this.persist(); // старый мир остаётся в «Моих мирах» таким, каким его оставили\n this.dims.clear();\n this.portals = portalsOf(null);\n this.beyondState = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n this.villagesSettled = new Set();\n this.achievements.load([]); // достижения — у каждого мира свои, как в Minecraft\n this.maps = new Map();\n this.nextMapId = 1;\n this.daysAwake = 0;\n this.rebuildWorld(src, \"overworld\");\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n if (src.kind === \"spec\")\n this.dayNight.setTimeOfDay(src.spec.ambience.timeOfDay);\n else this.dayNight.resetToMorning();\n this.warmupAtSpawn();\n this.player.resetForNewWorld(); // кровать, якорь, эффекты, опыт старого мира — не переносятся\n this.freezing.t = 0;\n this.player.spawn();\n const name = src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed;\n this.adoptNewWorld(name);\n this.hud.message(\n src.kind === \"spec\"\n ? \"Мир «\" + src.spec.name + \"»\"\n : \"Новый мир, сид \" + src.seed,\n 1800,\n );\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n if (target === this.dimension) return;\n const prev = this.dimension;\n // сетевая игра: целевое измерение — из общей копии дифов (net/NetGame.beforeDimension)\n this.net?.beforeDimension(target);\n this.dims.set(this.dimension, {\n chunks: this.world.serializeDiffs(),\n entities: this.entities.serialize(),\n blockEntities: this.blockEntities.serialize(),\n });\n // прежнее снято (с сущностями — для следующего владельца); теперь роль в новом измерении\n this.net?.prepareDimension();\n const saved = this.dims.get(target);\n this.dims.delete(target);\n this.rebuildWorld(this.world.source, target);\n this.entities.clear();\n if (saved) {\n this.world.loadDiffs(saved.chunks);\n this.entities.deserialize(\n (saved.entities ?? []) as Parameters<EntityManager[\"deserialize\"]>[0],\n );\n this.blockEntities.deserialize(saved.blockEntities);\n }\n this.warmup(x, z);\n this.player.pos.set(x, y, z);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n // сетевая игра: отдать прежнее измерение (если владел) и заявить новое — до любых правок в нём\n this.net?.afterDimension(prev, target);\n events.emit(\"dimensionChanged\", { dimension: target });\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n if (this.player.mode !== \"creative\") {\n const p = this.player.pos;\n const inv = this.inventory;\n for (const s of [...inv.slots, ...inv.armor])\n if (s)\n this.entities.spawnDrop(\n p.x + (Math.random() - 0.5),\n p.y + 0.5,\n p.z + (Math.random() - 0.5),\n s.item,\n s.count,\n s.dmg,\n );\n inv.clearCarried(); // сундук Предела не трогаем — раньше смерть стирала и его\n const xp = Math.min(100, this.player.xpLevel * 7);\n if (xp > 0) this.entities.spawnXp(p.x, p.y + 0.5, p.z, xp);\n this.player.xpLevel = this.player.xpProgress = this.player.xpTotal = 0;\n }\n if (this.invUI.open) this.invUI.close();\n // чат, табличка, книга, бирка поверх экрана смерти: их закрытие вернуло бы захват мыши\n this.chat.hide();\n this.signEditor.hide();\n this.bookScreen.hide();\n this.nameEditor.hide();\n this.input.uiOpen = true; // до releaseLock: без паузы — вместо неё экран смерти\n this.input.releaseLock();\n this.hud.showDeath(this.player.lastCause);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n if (!this.player.dead) return;\n this.player.respawn(this.goHome());\n this.hud.hideDeath();\n this.input.uiOpen = false;\n this.input.requestLock();\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n // якорь возрождения сильнее кровати и возвращает в СВОЁ измерение (обычно в Пекло)\n const anchor = this.anchorHome();\n if (anchor) return anchor;\n const s = this.player.spawnPoint;\n let at = new THREE.Vector3(s.x, s.y, s.z);\n let atBed = false;\n const bed = this.player.bedPos;\n if (this.dimension !== \"overworld\")\n this.changeDimension(\"overworld\", at.x, at.y, at.z);\n if (bed) {\n this.warmup(bed[0], bed[2]);\n if (\n BLOCKS[this.world.getBlock(bed[0], bed[1], bed[2])]?.shape === \"bed\"\n ) {\n at = new THREE.Vector3(bed[0] + 0.5, bed[1] + 0.6, bed[2] + 0.5);\n atBed = true;\n } else {\n this.player.bedPos = null;\n this.hud.message(\"Ваша кровать отсутствует или заблокирована\", 2500);\n }\n }\n this.warmup(at.x, at.z);\n // точку могли застроить — поднимаемся до свободного места на рост\n const bx = Math.floor(at.x),\n bz = Math.floor(at.z);\n let y = Math.floor(at.y);\n while (\n y < 126 &&\n (this.world.isSolid(bx, y, bz) || this.world.isSolid(bx, y + 1, bz))\n )\n y++;\n // …и опускаемся до пола: точка возрождения в воздухе — это смерть от падения сразу после неё\n while (y > 1 && this.world.getBlock(bx, y - 1, bz) === B.air) y--;\n at.y = y;\n // …и не в яме снега-пороха, не в лаве: иначе возрождение сразу ведёт к новой смерти, и так по кругу\n // (точка мира из старых сейвов могла быть записана прямо на дне такой ямы)\n if (!atBed && !isStandable(this.world, bx, y, bz)) {\n const safe = findSafeSpot(this.world, bx, bz, 16);\n if (safe) at = new THREE.Vector3(safe.x + 0.5, safe.y, safe.z + 0.5);\n }\n return at;\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n const p = this.player.pos;\n const cell = (dy: number) =>\n this.world.getBlock(\n Math.floor(p.x),\n Math.floor(p.y + dy),\n Math.floor(p.z),\n );\n const inBeyond = cell(0) === B.beyond_portal;\n const inside =\n inBeyond || [0, 1].some((dy) => cell(dy) === B.inferno_portal);\n if (!inside || this.player.dead) {\n this._portalT = 0;\n this._portalLock = false;\n return;\n }\n if (this._portalLock) return;\n // портал Предела переносит сразу, как в Minecraft\n if (inBeyond) {\n this._portalLock = true;\n this._beyondTravel();\n return;\n }\n this._portalT += dt;\n if (this._portalT < (this.player.mode === \"creative\" ? 0.25 : 4)) return;\n this._portalT = 0;\n this._portalLock = true;\n const target: DimensionId =\n this.dimension === \"inferno\" ? \"overworld\" : \"inferno\";\n const k =\n target === \"inferno\"\n ? 1 / DIMENSIONS.inferno.scale\n : DIMENSIONS.inferno.scale;\n const tx = Math.floor(p.x * k),\n tz = Math.floor(p.z * k);\n // портал, через который уходим, — тоже известный (порталы из сейвов до реестра)\n this._rememberPortal(\n this.dimension,\n Math.floor(p.x),\n Math.floor(p.y),\n Math.floor(p.z),\n );\n // свой портал на той стороне: в верхнем мире ищем в 128 блоках, в Пекле — в 16, как в Minecraft\n const known = this._nearestPortal(\n target,\n tx,\n tz,\n target === \"overworld\" ? 128 : 16,\n );\n this.changeDimension(\n target,\n (known?.[0] ?? tx) + 0.5,\n 70,\n (known?.[2] ?? tz) + 0.5,\n );\n let at = known ? findPortalNear(this.world, known[0], known[2], 2) : null;\n if (known && !at) this._forgetPortal(target, known); // сломали — забываем\n if (!at) {\n if (known) this.warmup(tx, tz);\n at =\n findPortalNear(this.world, tx, tz, 16) ??\n (target === \"inferno\"\n ? buildPortal(this.world, tx, tz, 64, LAVA_SEA + 2, 110)\n : buildPortal(this.world, tx, tz, 70, 4, 120));\n this._rememberPortal(target, at.x, at.y, at.z);\n }\n this.player.pos.set(at.x + 0.5, at.y, at.z + 0.5);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: target });\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n private readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n const plate = B.stone_pressure_plate;\n if (plate === undefined) return;\n const on = new Set<string>();\n // плиту жмут все, нажимные рельсы — только вагонетки\n const mark = (x: number, y: number, z: number, cart = false) => {\n const bx = Math.floor(x),\n by = Math.floor(y + 0.05),\n bz = Math.floor(z);\n const id = this.world.getBlock(bx, by, bz);\n if (id === plate || (cart && id === B.detector_rail))\n on.add(bx + \",\" + by + \",\" + bz);\n };\n if (!this.player.dead)\n mark(this.player.pos.x, this.player.pos.y, this.player.pos.z);\n // сетевая игра: плиту жмут и другие игроки этого измерения\n for (const a of this.entities.others())\n if (!a.player.dead) mark(a.player.pos.x, a.player.pos.y, a.player.pos.z);\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead) mark(e.pos.x, e.pos.y, e.pos.z);\n else if (e.kind === \"minecart\" && !e.dead)\n mark(e.pos.x, e.pos.y, e.pos.z, true);\n const at = (k: string) =>\n k.split(\",\").map(Number) as [number, number, number];\n for (const k of on) {\n this._plates.set(k, 1);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z),\n m = this.world.getMeta(x, y, z);\n if (id === plate && m === 0) this.world.setBlock(x, y, z, plate, 1);\n else if (id === B.detector_rail && !(m & 0x8))\n this.world.setBlock(x, y, z, id, m | 0x8);\n }\n for (const [k, left] of this._plates) {\n if (on.has(k)) continue;\n if (left - dt > 0) {\n this._plates.set(k, left - dt);\n continue;\n }\n this._plates.delete(k);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z);\n if (id === plate) this.world.setBlock(x, y, z, plate, 0);\n else if (id === B.detector_rail)\n this.world.setBlock(x, y, z, id, this.world.getMeta(x, y, z) & ~0x8);\n }\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n if (this.dimension === \"beyond\") {\n const s = this.player.spawnPoint;\n this.changeDimension(\"overworld\", s.x, s.y, s.z);\n this.player.pos.copy(this.goHome());\n // первый выход после победы — титры\n if (this.beyondState.dragonKilled && !this.beyondState.creditsShown) {\n this.beyondState.creditsShown = true;\n this.hud.showCredits();\n }\n } else {\n const { x, y, z } = BEYOND_ARRIVAL;\n this.changeDimension(\"beyond\", x + 0.5, y, z + 0.5);\n this._arrivalPlatform();\n if (!this.beyondState.initialized) this._initBeyond();\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: this.dimension });\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n this.beyondState.initialized = true;\n for (const p of beyondPillars(this.world.seed))\n this.entities.spawnMob(\"beyond_crystal\", p.x + 0.5, p.top + 2, p.z + 0.5);\n this.entities.spawnMob(\"void_dragon\", 0, 90, 0);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n const { x, y, z } = BEYOND_ARRIVAL;\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++) {\n this.world.setBlock(x + dx, y - 1, z + dz, B.obsidian!);\n for (let dy = 0; dy < 3; dy++)\n this.world.setBlock(x + dx, y + dy, z + dz, B.air!);\n }\n this.player.pos.set(x + 0.5, y, z + 0.5);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n this.world.setBlock(x, y - 1, z, B.bedrock!);\n this.world.setBlock(x, y, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 1, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 2, z, B.bedrock!);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n const [x, y, z] = gatewaySpot(this.world.seed);\n this.warmup(x, z); // запись в незагруженный чанк пропала бы: победа бывает далеко от врат\n this._placeGateway(x, y, z);\n this.beyondState.gateway = [x, y, z];\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 if (this.dimension !== \"overworld\") return;\n const pl = this.player;\n if (!this.raid) {\n const omen = pl.effects.get(\"bad_omen\");\n if (!omen || pl.dead) return;\n const v = (\n this.world.generator.villagesNear?.(pl.pos.x, pl.pos.z, 48) ?? []\n ).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v) return;\n pl.effects.delete(\"bad_omen\");\n events.emit(\"effectsChanged\", { id: \"bad_omen\" });\n this.raid = new Raid({ x: v.x + 0.5, y: v.y, z: v.z + 0.5 }, omen.level);\n events.emit(\"raidStart\", { x: v.x, z: v.z });\n this.hud.message(\"Набег! Защитите деревню\", 3500);\n return;\n }\n const r = this.raid;\n const center = new THREE.Vector3(r.center.x, r.center.y, r.center.z);\n let villagers = 0;\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.trades &&\n e.pos.distanceTo(center) < 64\n )\n villagers++;\n const res = r.tick(\n dt,\n (id) => {\n const e = this.entities.entities.get(id);\n return e?.kind === \"mob\" && !e.dead;\n },\n villagers,\n );\n if (res.spawn) {\n const a = Math.random() * Math.PI * 2;\n for (const type of res.spawn) {\n if (!MOBS[type]) continue;\n const d = 28 + Math.random() * 6,\n aa = a + (Math.random() - 0.5) * 0.4;\n const x = Math.floor(center.x + Math.cos(aa) * d),\n z = Math.floor(center.z + Math.sin(aa) * d);\n this.warmup(x, z);\n const m = this.entities.spawnMob(\n type,\n x + 0.5,\n this.world.surfaceY(x, z) + 1.01,\n z + 0.5,\n );\n m.raider = true;\n m.goal = center.clone();\n r.add(m.id, m.def.hp);\n }\n this.hud.message(`Набег: волна ${r.wave} из ${RAID_WAVES}`, 2500);\n events.emit(\"raidWave\", { wave: r.wave });\n }\n if (res.end === \"won\") {\n pl.addEffect(\"hero\", 2400, r.level);\n events.emit(\"raidWon\", { level: r.level });\n this.hud.message(\n \"Набег отбит! Вы — герой деревни: жители уступают в цене\",\n 4500,\n );\n this.raid = null;\n } else if (res.end === \"lost\") {\n for (const id of r.raiders) {\n const e = this.entities.entities.get(id);\n if (e?.kind === \"mob\") {\n e.raider = false;\n e.goal = null;\n }\n }\n events.emit(\"raidLost\");\n this.hud.message(\"Набег: деревня пала\", 3500);\n this.raid = null;\n }\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n private _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n if (this.dimension !== \"beyond\" || this.player.dead) return;\n const g = this.beyondState.gateway;\n if (this.beyondState.dragonKilled && !g) this._openGateway();\n // врата не ломаются: записаны, чанк загружен, а их нет — значит, не встали (сейв до починки)\n else if (g && this.world.getBlockOrUnloaded(g[0], g[1], g[2]) === B.air)\n this._placeGateway(g[0], g[1], g[2]);\n const p = this.player.pos;\n let touching = false;\n for (let x = Math.floor(p.x - 0.5); x <= Math.floor(p.x + 0.5); x++)\n for (let y = Math.floor(p.y); y <= Math.floor(p.y + 1.9); y++)\n for (let z = Math.floor(p.z - 0.5); z <= Math.floor(p.z + 0.5); z++)\n if (this.world.getBlock(x, y, z) === B.beyond_gateway)\n touching = true;\n if (!touching) {\n this._gateLock = false;\n return;\n }\n if (this._gateLock) return;\n this._gateLock = true;\n const toOuter = Math.hypot(p.x, p.z) < OUTER_START / 2;\n if (!toOuter) {\n this.warmup(BEYOND_ARRIVAL.x, BEYOND_ARRIVAL.z);\n this._arrivalPlatform();\n } else {\n const a = Math.atan2(p.z, p.x);\n const tx = Math.round(Math.cos(a) * OUTER_LANDING),\n tz = Math.round(Math.sin(a) * OUTER_LANDING);\n this.warmup(tx, tz);\n const gen = this.world.generator;\n const land =\n gen instanceof BeyondGenerator ? gen.landingNear(tx, tz, 96) : null;\n const x = land?.x ?? tx,\n z = land?.z ?? tz;\n let y = land?.y ?? ISLAND_Y;\n if (land) {\n this.warmup(x, z);\n while (\n y < 120 &&\n (this.world.isSolid(x, y, z) || this.world.isSolid(x, y + 1, z))\n )\n y++;\n } else\n // островов рядом нет — площадка из камня Предела, как в Minecraft\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++)\n this.world.setBlock(x + dx, y - 1, z + dz, B.beyond_stone!);\n this._placeGateway(x + 3, y, z); // обратные врата у места прибытия\n this.player.pos.set(x + 0.5, y, z + 0.5);\n this.hud.message(\"Внешние острова Предела\", 2500);\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"gatewayTravel\", { outer: toOuter });\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n this.beyondState.dragonKilled = true;\n for (const [x, y, z] of exitPortalCells())\n this.world.setBlock(x, y, z, B.beyond_portal!);\n this.world.setBlock(0, ISLAND_Y + 4, 0, B.dragon_egg!);\n this._openGateway();\n this.hud.message(\n \"Дракон Предела повержен! Портал домой — в фонтане, Врата на внешние острова — за краем\",\n 4500,\n );\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n const target = this.world.generator.locate?.(\"citadel\", x, z);\n if (!target) {\n this.hud.message(\"Око не чувствует цитадели в этом мире\", 2200);\n if (this.player.mode !== \"creative\") this.inventory.add(\"rim_eye\", 1);\n return;\n }\n this.entities.spawnRimEye(new THREE.Vector3(x, y, z), target);\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 if (this._addPortal(dim, x, y, z))\n this.onPortalChange?.(dim, [x, y, z], true);\n }\n\n private _addPortal(\n dim: DimensionId,\n x: number,\n y: number,\n z: number,\n ): boolean {\n const list = this.portals[dim];\n const near = list.some(\n ([px, py, pz]) =>\n Math.abs(px - x) <= 3 && Math.abs(py - y) <= 4 && Math.abs(pz - z) <= 3,\n );\n if (!near) list.push([x, y, z]);\n return !near;\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n this.portals[dim] = this.portals[dim].filter((q) => q !== p);\n this.onPortalChange?.(dim, [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0], false);\n }\n\n /** Сетевая игра: портал зажгли или сломали у другого игрока. */\n applyNetPortal(dim: DimensionId, p: readonly number[], add: boolean): void {\n const [x = 0, y = 0, z = 0] = p;\n if (add) this._addPortal(dim, x, y, z);\n else\n this.portals[dim] = this.portals[dim].filter(\n (q) => q[0] !== x || q[1] !== y || q[2] !== z,\n );\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 let best: [number, number, number] | null = null;\n let bestD = Infinity;\n for (const q of this.portals[dim]) {\n const dx = Math.abs(q[0] - x),\n dz = Math.abs(q[2] - z);\n if (dx > radius || dz > radius) continue;\n const d = dx * dx + dz * dz;\n if (d < bestD) {\n best = q;\n bestD = d;\n }\n }\n return best;\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n private anchorHome(): THREE.Vector3 | null {\n const a = this.player.anchorPos;\n const dim = this.player.anchorDim;\n if (!a || !dim || !isDimensionId(dim)) return null;\n const [x, y, z] = a;\n if (this.dimension !== dim)\n this.changeDimension(dim, x + 0.5, y + 1, z + 0.5);\n this.warmup(x, z);\n const charges =\n this.world.getBlock(x, y, z) === B.respawn_anchor\n ? anchorCharges(this.world.getMeta(x, y, z))\n : 0;\n if (charges <= 0) {\n this.player.anchorPos = null;\n this.player.anchorDim = null;\n this.hud.message(\"Якорь возрождения разряжен или сломан\", 2500);\n return null;\n }\n this.world.setBlock(x, y, z, B.respawn_anchor!, charges - 1);\n // рядом с якорем, а не в нём: клетка с полом и местом на рост — не лава и не обрыв (в Пекле\n // «не твёрдая» соседняя клетка часто оказывалась озером лавы или пустотой над ним)\n for (const dy of [0, 1, -1])\n for (const [dx, dz] of AROUND) {\n const nx = x + dx,\n ny = y + dy,\n nz = z + dz;\n if (isStandable(this.world, nx, ny, nz))\n return new THREE.Vector3(nx + 0.5, ny, nz + 0.5);\n }\n return new THREE.Vector3(x + 0.5, y + 1, z + 0.5);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n if (DIMENSIONS[this.dimension].bedsExplode) {\n // в Пекле и Пределе кровать взрывается, как в Minecraft\n this.world.setBlock(x, y, z, B.air!);\n this.entities.explode(x + 0.5, y + 0.5, z + 0.5, 5);\n return;\n }\n this.player.bedPos = [x, y, z];\n if (this.dayNight.isDay && this.weather.kind !== \"thunder\") {\n this.hud.message(\n \"Спать можно только ночью или в грозу. Точка возрождения — здесь\",\n 2400,\n );\n return;\n }\n const monsters = [...this.entities.entities.values()].some(\n (e) =>\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.category === \"hostile\" &&\n e.pos.distanceTo(this.player.pos) < 8,\n );\n if (monsters) {\n this.hud.message(\"Нельзя спать — рядом монстры\", 2000);\n return;\n }\n // сетевая игра: ночь пропускает хост, когда спят ВСЕ игроки Верхнего мира (net/NetGame)\n if (this.net) {\n this.netSleeping = true;\n this.hud.message(\"Вы спите. Ночь пройдёт, когда уснут все игроки\", 2600);\n return;\n }\n this.skipNight();\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n if (this.inventory.heldItem()?.item !== \"map\") return;\n const p = this.player.pos;\n const m = newMap(this.nextMapId++, p.x, p.z, this.dimension);\n this.maps.set(m.id, m);\n for (let ph = 0; ph < 4; ph++) surveyMap(this.world, m, p.x, p.z, 48, ph);\n const filled = { item: \"filled_map\", count: 1, tag: { map: m.id } };\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n const i = this.inventory.hotbarIndex;\n if (!this.inventory.slots[i]) this.inventory.setSlot(i, filled);\n else if (this.inventory.addStack(filled) > 0)\n this.entities.spawnDrop(p.x, p.y + 1, p.z, \"filled_map\", 1);\n events.emit(\"mapCreated\", { id: m.id });\n }\n\n _trialScanT = 0;\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n const p = this.player;\n if ((this._trialScanT -= dt) <= 0) {\n this._trialScanT = 1;\n for (const [x, y, z] of this.world.generator.trialSpawnersNear?.(\n p.pos.x,\n p.pos.z,\n 48,\n ) ?? [])\n if (this.world.getBlockOrUnloaded(x, y, z) === B.trial_spawner)\n this.blockEntities.ensure(x, y, z);\n }\n const hunter = !p.dead && p.mode !== \"creative\";\n for (const be of this.blockEntities.map.values()) {\n if (be.type !== \"trial_spawner\") continue;\n const mob =\n TRIAL_MOBS[this.world.getMeta(be.x, be.y, be.z) % TRIAL_MOBS.length]!;\n trialStep(be.state as TrialState, {\n dt,\n near:\n hunter &&\n Math.hypot(\n p.pos.x - be.x - 0.5,\n p.pos.y - be.y,\n p.pos.z - be.z - 0.5,\n ) <= TRIAL_RANGE,\n rng: Math.random,\n spawn: () => this._trialSpawn(be.x, be.y, be.z, mob),\n reward: (items) => {\n for (const s of items)\n this.entities.spawnDrop(\n be.x + 0.5,\n be.y + 1.2,\n be.z + 0.5,\n s.item,\n s.count,\n );\n events.emit(\"trialReward\", { x: be.x, y: be.y, z: be.z });\n },\n });\n }\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n for (let i = 0; i < 10; i++) {\n const sx = x + Math.floor(Math.random() * 9) - 4,\n sz = z + Math.floor(Math.random() * 9) - 4;\n for (let sy = y - 1; sy <= y + 2; sy++) {\n if (!BLOCKS[this.world.getBlock(sx, sy - 1, sz)]?.solid) continue;\n if (\n this.world.getBlock(sx, sy, sz) !== 0 ||\n this.world.getBlock(sx, sy + 1, sz) !== 0\n )\n continue;\n const m = this.entities.spawnMob(mob, sx + 0.5, sy, sz + 0.5);\n m.trialFrom = [x, y, z];\n events.emit(\"trialSpawn\", { x, y, z, mob });\n return true;\n }\n }\n return false;\n }\n\n freezing = new Freezing();\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n const p = this.player;\n const armor = this.inventory.armor;\n p.leatherBoots = () => armor.some((s) => s?.item === \"leather_boots\");\n p.goldArmor = () => armor.some((s) => !!s?.item.startsWith(\"golden_\"));\n // щит принял удар — теряет прочность по урону (как в Minecraft), не меньше 1\n p.onShieldBlock = (n) => {\n if (this.inventory.heldItem()?.item === \"shield\")\n this.inventory.damageHeld(Math.max(1, Math.floor(n)));\n };\n const leather = armor.some((s) => !!s?.item.startsWith(\"leather_\"));\n const dmg = this.freezing.tick(\n dt,\n p.inPowderSnow && !p.dead,\n leather || p.mode === \"creative\",\n p.fireT > 0,\n );\n if (dmg) p.damage(dmg, \"freeze\");\n this.hud.frost(this.freezing.level);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n this._hornCd = Math.max(0, this._hornCd - dt);\n const press =\n this.input.rightDown &&\n !this.player.dead &&\n this.inventory.heldItem()?.item === \"goat_horn\";\n if (press && !this._hornHeld && this._hornCd <= 0) {\n this._hornCd = 7;\n events.emit(\"goatHorn\");\n const p = this.player.pos;\n if (this.dimension === \"overworld\")\n this.sculk.vibrate(p.x, p.y, p.z, this.player.mode !== \"creative\");\n }\n this._hornHeld = press;\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 lastDeath: { x: number; y: number; z: number; dim: string } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n this.sculk.tick(dt);\n const p = this.player;\n if (p.dead || p.flying || !p.onGround || p._sneaking) return;\n this._stepDist += Math.hypot(p.vel.x, p.vel.z) * dt;\n if (this._stepDist < 2) return;\n this._stepDist = 0;\n const x = Math.floor(p.pos.x),\n y = Math.floor(p.pos.y),\n z = Math.floor(p.pos.z);\n if (this.world.getBlock(x, y - 1, z) === B.sculk_shrieker)\n this.sculk.shriek(x, y - 1, z);\n this.sculk.vibrate(p.pos.x, p.pos.y, p.pos.z, p.mode !== \"creative\");\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n const on =\n !this.player.dead &&\n this.input.rightDown &&\n this.inventory.heldItem()?.item === \"spyglass\";\n const want = on ? 10 : 75;\n if (this.camera.fov === want) return;\n this.camera.fov += (want - this.camera.fov) * Math.min(1, dt * 12);\n if (Math.abs(this.camera.fov - want) < 0.1) this.camera.fov = want;\n this.camera.updateProjectionMatrix();\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n const h = this.inventory.heldItem();\n // карта сокровищ или исследователя в руке становится картой участка с крестиком над целью:\n // из тега {x, z}, а без него (карта картографа) — ближайшая к игроку цель такого вида\n const kind = h ? EXPLORER_MAPS[h.item] : undefined;\n if (h && kind) {\n let tx = Number(h.tag?.x),\n tz = Number(h.tag?.z);\n if (!Number.isFinite(tx) || !Number.isFinite(tz)) {\n if (this.dimension !== \"overworld\") return;\n const f = this.world.generator.locate?.(\n kind,\n this.player.pos.x,\n this.player.pos.z,\n );\n if (!f) return;\n tx = f.x;\n tz = f.z;\n }\n const m = newMap(this.nextMapId++, tx, tz, \"overworld\");\n // карта с целью — по центру цели, а не по сетке карт: иначе ✖ бывает у самого края\n m.x0 = Math.floor(tx) - MAP_SIZE / 2;\n m.z0 = Math.floor(tz) - MAP_SIZE / 2;\n m.target = [tx, tz];\n this.maps.set(m.id, m);\n this.inventory.setSlot(this.inventory.hotbarIndex, {\n item: \"filled_map\",\n count: 1,\n tag: { map: m.id },\n });\n events.emit(\"mapCreated\", { id: m.id, treasure: true });\n return;\n }\n if (h?.item !== \"filled_map\") return;\n const m = this.maps.get(Number(h.tag?.map));\n if (!m || m.dim !== this.dimension) return;\n const p = this.player.pos;\n surveyMap(this.world, m, p.x, p.z, 48, this._mapPhase++ & 3);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n if (this.dimension !== \"overworld\" || this.raid) return;\n const p = this.player.pos;\n const v = (this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v || !siegeTonight()) return;\n const angle = Math.random() * Math.PI * 2;\n let n = 0;\n for (let i = siegeCount(); i > 0; i--) {\n const { x, z } = siegeSpot(v.x + 0.5, v.z + 0.5, angle);\n this.warmup(x, z);\n const y = this.world.surfaceY(x, z);\n if (y <= 0) continue;\n const m = this.entities.spawnMob(\"zombie\", x + 0.5, y + 1.01, z + 0.5);\n // цель — центр деревни: без неё отряд разбредается, не дойдя до жителей (MobBrain, huntsLand)\n m.goal = new THREE.Vector3(v.x + 0.5, v.y, v.z + 0.5);\n n++;\n }\n if (n === 0) return;\n events.emit(\"siegeStart\", { x: v.x, z: v.z, count: n });\n this.hud.message(\"Ночная осада! Зомби идут к деревне\", 3500);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n // деревни верхнего мира и города Предела (панцирники) — один путь: villagesNear генератора\n const p = this.player.pos;\n for (const v of this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []) {\n if (this.villagesSettled.has(v.id)) continue;\n const loaded = v.residents.every(\n (r) =>\n this.world.getBlockOrUnloaded(\n Math.floor(r.x),\n Math.floor(r.y),\n Math.floor(r.z),\n ) !== -1,\n );\n if (!loaded) continue;\n for (const r of v.residents)\n if (MOBS[r.type]) {\n const m = this.entities.spawnMob(r.type, r.x, r.y + 0.01, r.z);\n if (r.captain) m.captain = true;\n }\n this.villagesSettled.add(v.id);\n }\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n const near = (p: THREE.Vector3) =>\n Math.hypot(p.x - x, p.z - z) < 3 && Math.abs(p.y - y) < 4;\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && near(e.pos)) e.damage(5);\n if (near(this.player.pos)) this.player.damage(5, \"lightning\");\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 }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\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.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.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.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 /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n this.renderer.shadowMap.needsUpdate = true; // тени — только в мировом проходе\n this.renderer.render(this.scene, this.camera);\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.session.leave();\n }\n // последний снимок уходит до разбора мира; дописывается транзакция уже без нас\n void this.persist().finally(() => this.store?.close());\n this.net = null;\n this._destroyed = true;\n this.worldUI.hide();\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.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n this.input.enabled = true;\n if (!this.loop.running) this.loop.start();\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"
500
+ "content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config\";\nimport { events } from \"./core/EventBus\";\nimport { GameLoop } from \"./core/GameLoop\";\nimport { IntentQueue } from \"./core/Intents\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas\";\nimport { buildIcons } from \"./registry/Items\";\nimport { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView, 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 {\n DIMENSIONS,\n isDimensionId,\n type DimensionId,\n} from \"./world/Dimensions\";\nimport { BLOCKS, B } from \"./registry/Blocks\";\nimport { findSafeSpot, isStandable } from \"./world/safeSpot\";\nimport { MOBS } from \"./registry/Mobs\";\nimport { buildPortal, findPortalNear, portalsOf } from \"./world/Portal\";\nimport { LAVA_SEA } from \"./worldgen/inferno/InfernoGenerator\";\nimport {\n BEYOND_ARRIVAL,\n BeyondGenerator,\n ISLAND_Y,\n OUTER_LANDING,\n beyondPillars,\n exitPortalCells,\n gatewaySpot,\n} from \"./worldgen/beyond/BeyondGenerator\";\nimport { OUTER_START } from \"./worldgen/beyond/outer\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { MAP_SIZE, newMap, surveyMap, type MapData } from \"./systems/Maps\";\nimport { EXPLORER_MAPS } from \"./registry/items/mansion\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { hitTarget } from \"./world/behaviours/extras\";\nimport { anchorCharges } from \"./world/behaviours/stand\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { dripstoneFallDamage } from \"./world/behaviours/dripstone\";\nimport { Freezing } from \"./systems/Freezing\";\nimport {\n TRIAL_RANGE,\n trialStep,\n type TrialState,\n} from \"./systems/TrialSpawner\";\nimport { TRIAL_MOBS } from \"./registry/blocks/trial\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem, worldSourceOf } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { CreateWorldUI } from \"./ui/CreateWorldUI\";\nimport { NetUI, friendlyNetError } 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_WAVES, Raid } from \"./systems/Raid\";\nimport { siegeCount, siegeSpot, siegeTonight } from \"./systems/Siege\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { beaconEffects, inBeaconRange, type BeaconPulse } from \"./world/Beacon\";\nimport { WorldStore, newWorldId } from \"./systems/WorldStore\";\nimport type { WorldAI } from \"./ai/types\";\nimport { startSource } from \"./worlds/startWorld\";\nimport { CURRENT_GEN } from \"./worldgen/WorldGenerator\";\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 difficulty,\n parseDifficulty,\n setDifficulty,\n} from \"./systems/Difficulty\";\n\n/** Автосейв текущего мира в «Мои миры», секунд игры. */\nconst AUTOSAVE_EVERY = 30;\n/** Восемь клеток вокруг (сначала стороны, потом углы) — где встать у якоря возрождения. */\nconst AROUND: readonly (readonly [number, number])[] = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n [1, 1],\n [1, -1],\n [-1, 1],\n [-1, -1],\n];\n\nexport interface GameOptions {\n /** ИИ-генерация миров (client.ai). null/нет — только путь без ИИ. */\n ai?: WorldAI | null;\n /**\n * Сетевая игра — через систему мультиплеера лобби (ctx.multiplayer). `openForFriends` открывает в\n * лобби «Создать комнату» с этой игрой. null/нет — системы нет, игра только одиночная.\n */\n online?: { available(): boolean; openForFriends(): void } | null;\n /** Имя игрока в сетевой игре (профиль); нет — «Игрок». */\n playerName?: () => string;\n}\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\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 /** Достижения мира (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 save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n worldUI: CreateWorldUI;\n /** Экран «Сетевая игра» и список игроков по Tab. */\n netUI: NetUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n private _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 private _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n private _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n private _worldTouched = false;\n private _destroyed = false;\n\n // ---- сетевая игра (net/NetGame) ----\n /** Идёт сетевая игра (хост или гость); null — одиночная. */\n net: NetGame | null = null;\n /** Система мультиплеера лобби; нет — сетевой игры нет, кнопки для неё не рисуются. */\n readonly online: { available(): boolean; openForFriends(): void } | 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 private _soloWorldBeforeNet: string | null = null;\n\n /** Этот процесс считает своё измерение (одиночная игра, хост, владелец измерения) — net/sim. */\n get simulating(): boolean {\n return sim.authority;\n }\n\n constructor(opts: GameOptions = {}) {\n this.online = opts.online ?? null;\n this.restored = new Promise<void>((r) => (this._restored = r));\n this.playerName = opts.playerName ?? (() => \"Игрок\");\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n // Казуальная картинка: 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 = new TextureAtlas();\n buildIcons(this.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.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.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 // второй проход (рука) видит только свой слой — свет мира включаем и на нём\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.worldUI = new CreateWorldUI(this, this._abort.signal, opts.ai ?? null);\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.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 // опыт из печи (и других источников, что не кидают сферы)\n events.on(\"xpGain\", ({ amount }: { amount: number }) =>\n this.player.addXp(amount),\n );\n events.on(\"playerDied\", () => this._onDeath());\n events.on(\"portalLit\", (d: { x: number; y: number; z: number }) =>\n this._rememberPortal(this.dimension, d.x, d.y, d.z),\n );\n events.on(\"throwEye\", (d: { x: number; y: number; z: number }) =>\n this._throwEye(d.x, d.y, d.z),\n );\n events.on(\"dragonDefeated\", () => this._dragonDefeated());\n events.on(\"ui:useBed\", (d: { x: number; y: number; z: number }) =>\n this._useBed(d.x, d.y, d.z),\n );\n events.on(\"lightning\", (d: { x: number; y: number; z: number }) =>\n this._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 = this.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 this.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 this.entities.spawnDrop(\n p.x + 0.5,\n p.y + 1.1,\n p.z + 0.5,\n p.item,\n p.count,\n ),\n );\n // дроп, который роняет сам моб (рог козы)\n events.on(\n \"mobDrop\",\n (p: { x: number; y: number; z: number; item: string }) =>\n this.entities.spawnDrop(p.x, p.y, p.z, p.item, 1),\n );\n // глубокая тьма: сломанные и поставленные игроком блоки и взрывы — вибрации; смерть рядом с\n // катализатором — скалк; место смерти игрока — для компаса возвращения\n const overworld = () => this.dimension === \"overworld\";\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; byPlayer?: boolean }) => {\n if (overworld())\n this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n !!p.byPlayer && this.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 = this.player;\n if (pl.dead) return;\n this.warmup(p.x, p.z);\n let y = Math.floor(p.y);\n while (\n y > 1 &&\n !this.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 this.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 this.fireworks.launch(p.x, p.y, p.z),\n );\n // компас по путевому камню: стрелка держится за него, пока камень цел (ui/HUD)\n events.on(\"lodestoneBind\", (p: { x: number; y: number; z: number }) => {\n this.player.lodestone = { x: p.x, z: p.z, dim: this.dimension };\n this.hud.message(\"Компас привязан к путевому камню\", 2000);\n });\n // якорь возрождения: в Пекле — точка возрождения, в верхнем мире взрывается, как в Minecraft\n events.on(\n \"anchorUse\",\n (p: { x: number; y: number; z: number; charges: number }) => {\n if (!DIMENSIONS[this.dimension].bedsExplode) {\n this.world.setBlock(p.x, p.y, p.z, B.air!);\n this.entities.explode(p.x + 0.5, p.y + 0.5, p.z + 0.5, 5);\n return;\n }\n if (p.charges <= 0) {\n this.hud.message(\"Якорь не заряжен — нужен светокамень\", 2200);\n return;\n }\n this.player.anchorPos = [p.x, p.y, p.z];\n this.player.anchorDim = this.dimension;\n this.hud.message(\"Точка возрождения — у якоря\", 2200);\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(this.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 this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n this.player.mode !== \"creative\",\n );\n });\n events.on(\"explosion\", (p: { x: number; y: number; z: number }) => {\n if (overworld()) this.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 this.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 ? this.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 = this.player.pos;\n this.lastDeath = { x: pp.x, y: pp.y, z: pp.z, dim: this.dimension };\n });\n // проводник: игрок в воде в его радиусе получает силу моря (дыхание, ночное зрение, спешка)\n events.on(\n \"conduitPulse\",\n (p: { x: number; y: number; z: number; range: number }) => {\n const pl = this.player;\n if (\n pl.dead ||\n !pl.inWater ||\n pl.pos.distanceTo(\n 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 = this.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 this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n void this.persist();\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\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\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open()\n .then(async (store) => {\n if (!store) return;\n if (this._destroyed) return store.close();\n this.store = store;\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(\"Продолжаем мир «\" + last.name + \"»\", 2200);\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n })\n .finally(() => this._restored());\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). Гость чужой мир не сохраняет. */\n async persist(): Promise<void> {\n if (!this.store || this._netGuest) return;\n this.net?.collectPlayerStates(this.netPlayers);\n try {\n await this.store.put({\n id: this.worldId,\n name: this.worldName,\n updatedAt: Date.now(),\n save: this.save.collect(),\n });\n } catch {\n // квота/запрет хранилища: игра продолжается без автосейва\n }\n }\n\n /** Открыть мир из «Моих миров». */\n async openStoredWorld(id: string): Promise<boolean> {\n if (!this.store) return false;\n const w = await this.store.get(id).catch(() => undefined);\n if (!w) return false;\n if (id !== this.worldId) await this.persist();\n try {\n this.save.apply(w.save);\n } catch {\n return false;\n }\n this._worldTouched = true;\n this.worldId = w.id;\n this.worldName = w.name;\n return true;\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n // persist() снимает сейв синхронно при вызове — до apply ниже, поэтому уходит именно старый мир.\n void this.persist();\n try {\n this.save.apply(save);\n } catch {\n return false;\n }\n this.adoptNewWorld(name);\n this.hud.message(\"Мир «\" + name + \"» из Мастерской\", 2200);\n return true;\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n this._worldTouched = true;\n this.worldId = newWorldId();\n this.worldName = name;\n void this.persist();\n }\n\n // ---- сетевая игра (net/NetGame) ---------------------------------------------------------------\n\n /** Гость в чужом мире: мир не наш — не сохраняем его в «Мои миры» (до передачи хоста). */\n private _netGuest = false;\n\n /** Мир для входящего гостя: источник, палитра, время, погода, сложность, Предел, порталы. */\n netWorldMeta(): WorldMeta {\n return {\n source: this.world.source,\n palette: BLOCKS.map((b) => b.name),\n time: this.dayNight.serialize(),\n weather: this.weather.serialize(),\n difficulty: difficulty(),\n beyond: this.beyondState,\n portals: this.portals,\n daysAwake: this.daysAwake,\n name: this.worldName,\n };\n }\n\n /** Общее состояние мира (хост раз в 5 с): время, погода, сложность, Предел. */\n netWorldState(): WorldState {\n return {\n time: this.dayNight.serialize(),\n weather: this.weather.serialize(),\n difficulty: difficulty(),\n beyond: this.beyondState,\n daysAwake: this.daysAwake,\n };\n }\n\n applyNetWorldState(w: WorldState): void {\n this.dayNight.deserialize(w.time as SaveData[\"time\"]);\n this.weather.deserialize(w.weather as SaveData[\"weather\"]);\n setDifficulty(parseDifficulty(w.difficulty) ?? \"normal\");\n this.mergeBeyond(w.beyond);\n if (typeof w.daysAwake === \"number\" && w.daysAwake >= 0)\n this.daysAwake = w.daysAwake;\n }\n\n /** Предел: победа над драконом и Врата — однажды случившись, не отменяются (слияние «или»). */\n mergeBeyond(b: unknown): void {\n if (!b || typeof b !== \"object\") return;\n const s = b as Partial<Game[\"beyondState\"]>;\n const st = this.beyondState;\n st.initialized ||= s.initialized === true;\n st.dragonKilled ||= s.dragonKilled === true;\n st.creditsShown ||= s.creditsShown === true;\n if (\n !st.gateway &&\n Array.isArray(s.gateway) &&\n s.gateway.length === 3 &&\n s.gateway.every(Number.isFinite)\n )\n st.gateway = [s.gateway[0]!, s.gateway[1]!, s.gateway[2]!];\n }\n\n /** Сейв этого игрока для мира хоста: позиция, измерение, инвентарь, опыт, эффекты. */\n netPlayerState(): Record<string, unknown> {\n const p = this.player.pos;\n return {\n ...this.player.serialize(),\n inventory: this.inventory.serialize(),\n dim: this.dimension,\n pos: [p.x, p.y, p.z],\n beyond: this.beyondState,\n };\n }\n\n /** Ночь пропущена: спали все (сетевая игра) или один (одиночная). */\n skipNight(): void {\n this.dayNight.resetToMorning();\n this.weather.set(\"clear\"); // сон пережидает и непогоду, как в Minecraft\n this._slept = true;\n this.daysAwake = 0;\n this.netSleeping = false;\n this.hud.message(\"Доброе утро!\", 1800);\n events.emit(\"slept\");\n }\n\n /**\n * Гость: мир хоста вместо своего. Свой уходит в «Мои миры» и вернётся при выходе. Все измерения\n * строятся из присланных дифов — порталы работают без ожидания.\n */\n loadNetWorld(w: Welcome, diffs: DiffStore, settings: RoomSettings): void {\n void this.persist();\n this._soloWorldBeforeNet = this.worldId;\n this._netGuest = true;\n // сейвы чужих игроков — от СВОЕГО прежнего мира: стань мы хостом этого, они бы переехали в него\n this.netPlayers = new Map();\n this._worldTouched = true;\n if (this.invUI.open) this.invUI.close();\n const dim = isDimensionId(w.spawn.dim) ? w.spawn.dim : \"overworld\";\n const src = worldSourceOf({\n world: w.world.source,\n seed: 0,\n } as unknown as SaveData);\n this.dims.clear();\n for (const d of [\"overworld\", \"inferno\", \"beyond\"] as const)\n if (d !== dim)\n this.dims.set(d, {\n chunks: diffs.toChunkDiffMap(d),\n entities: [],\n blockEntities: [],\n });\n this.rebuildWorld(src, dim);\n this.world.loadDiffs(diffs.toChunkDiffMap(dim));\n this.entities.clear();\n this.blockEntities.clear();\n this.applyNetWorldState({ ...w.world });\n this.portals = portalsOf(w.world.portals);\n this.villagesSettled = new Set();\n this.maps = new Map();\n this.nextMapId = 1;\n this.achievements.load([]);\n const state = w.state as\n (Record<string, unknown> & { inventory?: unknown }) | null;\n if (state && Array.isArray((state as { pos?: unknown }).pos)) {\n this.player.deserialize(\n state as unknown as Parameters<Player[\"deserialize\"]>[0],\n );\n this.inventory.deserialize(\n state.inventory as Parameters<Inventory[\"deserialize\"]>[0],\n );\n } else {\n this.player.resetForNewWorld();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n this.player.mode = settings.guestMode;\n }\n const [x, y, z] = w.spawn.pos;\n this.warmup(x, z);\n this.player.pos.set(x, y, z);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n if (!state) this.player.spawnPoint.set(x, y, z);\n this.worldName = \"Сетевая игра\";\n this._netWorldName =\n typeof w.world.name === \"string\" && w.world.name.trim()\n ? w.world.name.trim().slice(0, 60)\n : \"\";\n this.hud.message(\"Вы в сетевой игре\", 2200);\n events.emit(\"dimensionChanged\", { dimension: dim });\n }\n\n /** Стал хостом после ухода прежнего: этот мир теперь наш — новой записью в «Моих мирах». */\n adoptNetWorldAsOwn(): void {\n this._netGuest = false;\n // имя мира хоста, а не безликое «Сетевой мир»: при переносе мир остаётся тем же миром\n this.adoptNewWorld(this._netWorldName || \"Сетевой мир\");\n }\n private _netWorldName = \"\";\n\n /**\n * Комната пришла из лобби (хост нажал «Играть», или игрок вошёл в уже идущую игру). Хост открывает\n * свой мир — последний, «Моих миров»; гость получает мир хоста. Ошибка — строка для игрока, и\n * комната закрывается: полуначатая сетевая игра хуже одиночной.\n */\n async acceptRoom(room: NetRoom): Promise<void> {\n if (this.net || this._destroyed) return;\n await this.restored;\n const res = await NetGame.fromRoom(this, room, this.playerName());\n if (typeof res === \"string\") {\n this.hud.message(friendlyNetError(res), 3200);\n await room.leave();\n return;\n }\n this.net = res;\n events.emit(\"net:roster\");\n if (room.isHost && !room.lateJoin)\n this.hud.message(\"Мир открыт для друзей\", 2200);\n this.startPlay();\n }\n\n /**\n * Сетевая игра закончилась. Гость возвращается в свой мир; хост (и ставший хостом) продолжает\n * в этом мире один. `reason` — показать почему (исключили, хост закрыл игру).\n */\n exitNet(reason: string | null): void {\n const guest = this._netGuest;\n this.net = null;\n this._netGuest = false;\n this.netSleeping = false;\n sim.authority = true;\n this.entities.replica = false;\n this.entities.others = () => [];\n this.entities.onLocalSpawn = null;\n this.entities.onRemoteAction = null;\n if (guest) {\n const back = this._soloWorldBeforeNet;\n this._soloWorldBeforeNet = null;\n if (back)\n void this.openStoredWorld(back).then(\n (ok) => ok || this.newWorld((Math.random() * 1e9) >>> 0),\n );\n else this.newWorld((Math.random() * 1e9) >>> 0);\n }\n if (reason) this.hud.message(reason, 3500);\n events.emit(\"net:roster\");\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n this.world.update(x, z);\n const busy =\n this.world.genQueue.length > 0 ||\n [...this.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n const s = this.world.generator.spawnHint();\n this.warmup(s.x, s.z);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, source, dimension);\n this.dimension = dimension;\n this.blockEntities.clear();\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.fluids.world = this.world;\n this.fluids.clear();\n this.interaction.breaking = null;\n this.interaction.damage.clear(); // трещины старого мира не должны лечь на блоки нового\n this.interaction.eating = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n const src: WorldSource =\n typeof source === \"number\"\n ? { kind: \"noise\", seed: source, gen: CURRENT_GEN }\n : // новый мир — текущего поколения (у мира из рецепта: цитадель под островом)\n { ...source, gen: source.gen ?? CURRENT_GEN };\n void this.persist(); // старый мир остаётся в «Моих мирах» таким, каким его оставили\n this.dims.clear();\n this.portals = portalsOf(null);\n this.beyondState = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n this.villagesSettled = new Set();\n this.achievements.load([]); // достижения — у каждого мира свои, как в Minecraft\n this.maps = new Map();\n this.nextMapId = 1;\n this.daysAwake = 0;\n this.rebuildWorld(src, \"overworld\");\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n if (src.kind === \"spec\")\n this.dayNight.setTimeOfDay(src.spec.ambience.timeOfDay);\n else this.dayNight.resetToMorning();\n this.warmupAtSpawn();\n this.player.resetForNewWorld(); // кровать, якорь, эффекты, опыт старого мира — не переносятся\n this.freezing.t = 0;\n this.player.spawn();\n const name = src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed;\n this.adoptNewWorld(name);\n this.hud.message(\n src.kind === \"spec\"\n ? \"Мир «\" + src.spec.name + \"»\"\n : \"Новый мир, сид \" + src.seed,\n 1800,\n );\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n if (target === this.dimension) return;\n const prev = this.dimension;\n // сетевая игра: целевое измерение — из общей копии дифов (net/NetGame.beforeDimension)\n this.net?.beforeDimension(target);\n this.dims.set(this.dimension, {\n chunks: this.world.serializeDiffs(),\n entities: this.entities.serialize(),\n blockEntities: this.blockEntities.serialize(),\n });\n // прежнее снято (с сущностями — для следующего владельца); теперь роль в новом измерении\n this.net?.prepareDimension();\n const saved = this.dims.get(target);\n this.dims.delete(target);\n this.rebuildWorld(this.world.source, target);\n this.entities.clear();\n if (saved) {\n this.world.loadDiffs(saved.chunks);\n this.entities.deserialize(\n (saved.entities ?? []) as Parameters<EntityManager[\"deserialize\"]>[0],\n );\n this.blockEntities.deserialize(saved.blockEntities);\n }\n this.warmup(x, z);\n this.player.pos.set(x, y, z);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n // сетевая игра: отдать прежнее измерение (если владел) и заявить новое — до любых правок в нём\n this.net?.afterDimension(prev, target);\n events.emit(\"dimensionChanged\", { dimension: target });\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n if (this.player.mode !== \"creative\") {\n const p = this.player.pos;\n const inv = this.inventory;\n for (const s of [...inv.slots, ...inv.armor])\n if (s)\n this.entities.spawnDrop(\n p.x + (Math.random() - 0.5),\n p.y + 0.5,\n p.z + (Math.random() - 0.5),\n s.item,\n s.count,\n s.dmg,\n );\n inv.clearCarried(); // сундук Предела не трогаем — раньше смерть стирала и его\n const xp = Math.min(100, this.player.xpLevel * 7);\n if (xp > 0) this.entities.spawnXp(p.x, p.y + 0.5, p.z, xp);\n this.player.xpLevel = this.player.xpProgress = this.player.xpTotal = 0;\n }\n if (this.invUI.open) this.invUI.close();\n // чат, табличка, книга, бирка поверх экрана смерти: их закрытие вернуло бы захват мыши\n this.chat.hide();\n this.signEditor.hide();\n this.bookScreen.hide();\n this.nameEditor.hide();\n this.input.uiOpen = true; // до releaseLock: без паузы — вместо неё экран смерти\n this.input.releaseLock();\n this.hud.showDeath(this.player.lastCause);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n if (!this.player.dead) return;\n this.player.respawn(this.goHome());\n this.hud.hideDeath();\n this.input.uiOpen = false;\n this.input.requestLock();\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n // якорь возрождения сильнее кровати и возвращает в СВОЁ измерение (обычно в Пекло)\n const anchor = this.anchorHome();\n if (anchor) return anchor;\n const s = this.player.spawnPoint;\n let at = new THREE.Vector3(s.x, s.y, s.z);\n let atBed = false;\n const bed = this.player.bedPos;\n if (this.dimension !== \"overworld\")\n this.changeDimension(\"overworld\", at.x, at.y, at.z);\n if (bed) {\n this.warmup(bed[0], bed[2]);\n if (\n BLOCKS[this.world.getBlock(bed[0], bed[1], bed[2])]?.shape === \"bed\"\n ) {\n at = new THREE.Vector3(bed[0] + 0.5, bed[1] + 0.6, bed[2] + 0.5);\n atBed = true;\n } else {\n this.player.bedPos = null;\n this.hud.message(\"Ваша кровать отсутствует или заблокирована\", 2500);\n }\n }\n this.warmup(at.x, at.z);\n // точку могли застроить — поднимаемся до свободного места на рост\n const bx = Math.floor(at.x),\n bz = Math.floor(at.z);\n let y = Math.floor(at.y);\n while (\n y < 126 &&\n (this.world.isSolid(bx, y, bz) || this.world.isSolid(bx, y + 1, bz))\n )\n y++;\n // …и опускаемся до пола: точка возрождения в воздухе — это смерть от падения сразу после неё\n while (y > 1 && this.world.getBlock(bx, y - 1, bz) === B.air) y--;\n at.y = y;\n // …и не в яме снега-пороха, не в лаве: иначе возрождение сразу ведёт к новой смерти, и так по кругу\n // (точка мира из старых сейвов могла быть записана прямо на дне такой ямы)\n if (!atBed && !isStandable(this.world, bx, y, bz)) {\n const safe = findSafeSpot(this.world, bx, bz, 16);\n if (safe) at = new THREE.Vector3(safe.x + 0.5, safe.y, safe.z + 0.5);\n }\n return at;\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n const p = this.player.pos;\n const cell = (dy: number) =>\n this.world.getBlock(\n Math.floor(p.x),\n Math.floor(p.y + dy),\n Math.floor(p.z),\n );\n const inBeyond = cell(0) === B.beyond_portal;\n const inside =\n inBeyond || [0, 1].some((dy) => cell(dy) === B.inferno_portal);\n if (!inside || this.player.dead) {\n this._portalT = 0;\n this._portalLock = false;\n return;\n }\n if (this._portalLock) return;\n // портал Предела переносит сразу, как в Minecraft\n if (inBeyond) {\n this._portalLock = true;\n this._beyondTravel();\n return;\n }\n this._portalT += dt;\n if (this._portalT < (this.player.mode === \"creative\" ? 0.25 : 4)) return;\n this._portalT = 0;\n this._portalLock = true;\n const target: DimensionId =\n this.dimension === \"inferno\" ? \"overworld\" : \"inferno\";\n const k =\n target === \"inferno\"\n ? 1 / DIMENSIONS.inferno.scale\n : DIMENSIONS.inferno.scale;\n const tx = Math.floor(p.x * k),\n tz = Math.floor(p.z * k);\n // портал, через который уходим, — тоже известный (порталы из сейвов до реестра)\n this._rememberPortal(\n this.dimension,\n Math.floor(p.x),\n Math.floor(p.y),\n Math.floor(p.z),\n );\n // свой портал на той стороне: в верхнем мире ищем в 128 блоках, в Пекле — в 16, как в Minecraft\n const known = this._nearestPortal(\n target,\n tx,\n tz,\n target === \"overworld\" ? 128 : 16,\n );\n this.changeDimension(\n target,\n (known?.[0] ?? tx) + 0.5,\n 70,\n (known?.[2] ?? tz) + 0.5,\n );\n let at = known ? findPortalNear(this.world, known[0], known[2], 2) : null;\n if (known && !at) this._forgetPortal(target, known); // сломали — забываем\n if (!at) {\n if (known) this.warmup(tx, tz);\n at =\n findPortalNear(this.world, tx, tz, 16) ??\n (target === \"inferno\"\n ? buildPortal(this.world, tx, tz, 64, LAVA_SEA + 2, 110)\n : buildPortal(this.world, tx, tz, 70, 4, 120));\n this._rememberPortal(target, at.x, at.y, at.z);\n }\n this.player.pos.set(at.x + 0.5, at.y, at.z + 0.5);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: target });\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n private readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n const plate = B.stone_pressure_plate;\n if (plate === undefined) return;\n const on = new Set<string>();\n // плиту жмут все, нажимные рельсы — только вагонетки\n const mark = (x: number, y: number, z: number, cart = false) => {\n const bx = Math.floor(x),\n by = Math.floor(y + 0.05),\n bz = Math.floor(z);\n const id = this.world.getBlock(bx, by, bz);\n if (id === plate || (cart && id === B.detector_rail))\n on.add(bx + \",\" + by + \",\" + bz);\n };\n if (!this.player.dead)\n mark(this.player.pos.x, this.player.pos.y, this.player.pos.z);\n // сетевая игра: плиту жмут и другие игроки этого измерения\n for (const a of this.entities.others())\n if (!a.player.dead) mark(a.player.pos.x, a.player.pos.y, a.player.pos.z);\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead) mark(e.pos.x, e.pos.y, e.pos.z);\n else if (e.kind === \"minecart\" && !e.dead)\n mark(e.pos.x, e.pos.y, e.pos.z, true);\n const at = (k: string) =>\n k.split(\",\").map(Number) as [number, number, number];\n for (const k of on) {\n this._plates.set(k, 1);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z),\n m = this.world.getMeta(x, y, z);\n if (id === plate && m === 0) this.world.setBlock(x, y, z, plate, 1);\n else if (id === B.detector_rail && !(m & 0x8))\n this.world.setBlock(x, y, z, id, m | 0x8);\n }\n for (const [k, left] of this._plates) {\n if (on.has(k)) continue;\n if (left - dt > 0) {\n this._plates.set(k, left - dt);\n continue;\n }\n this._plates.delete(k);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z);\n if (id === plate) this.world.setBlock(x, y, z, plate, 0);\n else if (id === B.detector_rail)\n this.world.setBlock(x, y, z, id, this.world.getMeta(x, y, z) & ~0x8);\n }\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n if (this.dimension === \"beyond\") {\n const s = this.player.spawnPoint;\n this.changeDimension(\"overworld\", s.x, s.y, s.z);\n this.player.pos.copy(this.goHome());\n // первый выход после победы — титры\n if (this.beyondState.dragonKilled && !this.beyondState.creditsShown) {\n this.beyondState.creditsShown = true;\n this.hud.showCredits();\n }\n } else {\n const { x, y, z } = BEYOND_ARRIVAL;\n this.changeDimension(\"beyond\", x + 0.5, y, z + 0.5);\n this._arrivalPlatform();\n if (!this.beyondState.initialized) this._initBeyond();\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: this.dimension });\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n this.beyondState.initialized = true;\n for (const p of beyondPillars(this.world.seed))\n this.entities.spawnMob(\"beyond_crystal\", p.x + 0.5, p.top + 2, p.z + 0.5);\n this.entities.spawnMob(\"void_dragon\", 0, 90, 0);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n const { x, y, z } = BEYOND_ARRIVAL;\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++) {\n this.world.setBlock(x + dx, y - 1, z + dz, B.obsidian!);\n for (let dy = 0; dy < 3; dy++)\n this.world.setBlock(x + dx, y + dy, z + dz, B.air!);\n }\n this.player.pos.set(x + 0.5, y, z + 0.5);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n this.world.setBlock(x, y - 1, z, B.bedrock!);\n this.world.setBlock(x, y, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 1, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 2, z, B.bedrock!);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n const [x, y, z] = gatewaySpot(this.world.seed);\n this.warmup(x, z); // запись в незагруженный чанк пропала бы: победа бывает далеко от врат\n this._placeGateway(x, y, z);\n this.beyondState.gateway = [x, y, z];\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 if (this.dimension !== \"overworld\") return;\n const pl = this.player;\n if (!this.raid) {\n const omen = pl.effects.get(\"bad_omen\");\n if (!omen || pl.dead) return;\n const v = (\n this.world.generator.villagesNear?.(pl.pos.x, pl.pos.z, 48) ?? []\n ).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v) return;\n pl.effects.delete(\"bad_omen\");\n events.emit(\"effectsChanged\", { id: \"bad_omen\" });\n this.raid = new Raid({ x: v.x + 0.5, y: v.y, z: v.z + 0.5 }, omen.level);\n events.emit(\"raidStart\", { x: v.x, z: v.z });\n this.hud.message(\"Набег! Защитите деревню\", 3500);\n return;\n }\n const r = this.raid;\n const center = new THREE.Vector3(r.center.x, r.center.y, r.center.z);\n let villagers = 0;\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.trades &&\n e.pos.distanceTo(center) < 64\n )\n villagers++;\n const res = r.tick(\n dt,\n (id) => {\n const e = this.entities.entities.get(id);\n return e?.kind === \"mob\" && !e.dead;\n },\n villagers,\n );\n if (res.spawn) {\n const a = Math.random() * Math.PI * 2;\n for (const type of res.spawn) {\n if (!MOBS[type]) continue;\n const d = 28 + Math.random() * 6,\n aa = a + (Math.random() - 0.5) * 0.4;\n const x = Math.floor(center.x + Math.cos(aa) * d),\n z = Math.floor(center.z + Math.sin(aa) * d);\n this.warmup(x, z);\n const m = this.entities.spawnMob(\n type,\n x + 0.5,\n this.world.surfaceY(x, z) + 1.01,\n z + 0.5,\n );\n m.raider = true;\n m.goal = center.clone();\n r.add(m.id, m.def.hp);\n }\n this.hud.message(`Набег: волна ${r.wave} из ${RAID_WAVES}`, 2500);\n events.emit(\"raidWave\", { wave: r.wave });\n }\n if (res.end === \"won\") {\n pl.addEffect(\"hero\", 2400, r.level);\n events.emit(\"raidWon\", { level: r.level });\n this.hud.message(\n \"Набег отбит! Вы — герой деревни: жители уступают в цене\",\n 4500,\n );\n this.raid = null;\n } else if (res.end === \"lost\") {\n for (const id of r.raiders) {\n const e = this.entities.entities.get(id);\n if (e?.kind === \"mob\") {\n e.raider = false;\n e.goal = null;\n }\n }\n events.emit(\"raidLost\");\n this.hud.message(\"Набег: деревня пала\", 3500);\n this.raid = null;\n }\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n private _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n if (this.dimension !== \"beyond\" || this.player.dead) return;\n const g = this.beyondState.gateway;\n if (this.beyondState.dragonKilled && !g) this._openGateway();\n // врата не ломаются: записаны, чанк загружен, а их нет — значит, не встали (сейв до починки)\n else if (g && this.world.getBlockOrUnloaded(g[0], g[1], g[2]) === B.air)\n this._placeGateway(g[0], g[1], g[2]);\n const p = this.player.pos;\n let touching = false;\n for (let x = Math.floor(p.x - 0.5); x <= Math.floor(p.x + 0.5); x++)\n for (let y = Math.floor(p.y); y <= Math.floor(p.y + 1.9); y++)\n for (let z = Math.floor(p.z - 0.5); z <= Math.floor(p.z + 0.5); z++)\n if (this.world.getBlock(x, y, z) === B.beyond_gateway)\n touching = true;\n if (!touching) {\n this._gateLock = false;\n return;\n }\n if (this._gateLock) return;\n this._gateLock = true;\n const toOuter = Math.hypot(p.x, p.z) < OUTER_START / 2;\n if (!toOuter) {\n this.warmup(BEYOND_ARRIVAL.x, BEYOND_ARRIVAL.z);\n this._arrivalPlatform();\n } else {\n const a = Math.atan2(p.z, p.x);\n const tx = Math.round(Math.cos(a) * OUTER_LANDING),\n tz = Math.round(Math.sin(a) * OUTER_LANDING);\n this.warmup(tx, tz);\n const gen = this.world.generator;\n const land =\n gen instanceof BeyondGenerator ? gen.landingNear(tx, tz, 96) : null;\n const x = land?.x ?? tx,\n z = land?.z ?? tz;\n let y = land?.y ?? ISLAND_Y;\n if (land) {\n this.warmup(x, z);\n while (\n y < 120 &&\n (this.world.isSolid(x, y, z) || this.world.isSolid(x, y + 1, z))\n )\n y++;\n } else\n // островов рядом нет — площадка из камня Предела, как в Minecraft\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++)\n this.world.setBlock(x + dx, y - 1, z + dz, B.beyond_stone!);\n this._placeGateway(x + 3, y, z); // обратные врата у места прибытия\n this.player.pos.set(x + 0.5, y, z + 0.5);\n this.hud.message(\"Внешние острова Предела\", 2500);\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"gatewayTravel\", { outer: toOuter });\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n this.beyondState.dragonKilled = true;\n for (const [x, y, z] of exitPortalCells())\n this.world.setBlock(x, y, z, B.beyond_portal!);\n this.world.setBlock(0, ISLAND_Y + 4, 0, B.dragon_egg!);\n this._openGateway();\n this.hud.message(\n \"Дракон Предела повержен! Портал домой — в фонтане, Врата на внешние острова — за краем\",\n 4500,\n );\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n const target = this.world.generator.locate?.(\"citadel\", x, z);\n if (!target) {\n this.hud.message(\"Око не чувствует цитадели в этом мире\", 2200);\n if (this.player.mode !== \"creative\") this.inventory.add(\"rim_eye\", 1);\n return;\n }\n this.entities.spawnRimEye(new THREE.Vector3(x, y, z), target);\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 if (this._addPortal(dim, x, y, z))\n this.onPortalChange?.(dim, [x, y, z], true);\n }\n\n private _addPortal(\n dim: DimensionId,\n x: number,\n y: number,\n z: number,\n ): boolean {\n const list = this.portals[dim];\n const near = list.some(\n ([px, py, pz]) =>\n Math.abs(px - x) <= 3 && Math.abs(py - y) <= 4 && Math.abs(pz - z) <= 3,\n );\n if (!near) list.push([x, y, z]);\n return !near;\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n this.portals[dim] = this.portals[dim].filter((q) => q !== p);\n this.onPortalChange?.(dim, [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0], false);\n }\n\n /** Сетевая игра: портал зажгли или сломали у другого игрока. */\n applyNetPortal(dim: DimensionId, p: readonly number[], add: boolean): void {\n const [x = 0, y = 0, z = 0] = p;\n if (add) this._addPortal(dim, x, y, z);\n else\n this.portals[dim] = this.portals[dim].filter(\n (q) => q[0] !== x || q[1] !== y || q[2] !== z,\n );\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 let best: [number, number, number] | null = null;\n let bestD = Infinity;\n for (const q of this.portals[dim]) {\n const dx = Math.abs(q[0] - x),\n dz = Math.abs(q[2] - z);\n if (dx > radius || dz > radius) continue;\n const d = dx * dx + dz * dz;\n if (d < bestD) {\n best = q;\n bestD = d;\n }\n }\n return best;\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n private anchorHome(): THREE.Vector3 | null {\n const a = this.player.anchorPos;\n const dim = this.player.anchorDim;\n if (!a || !dim || !isDimensionId(dim)) return null;\n const [x, y, z] = a;\n if (this.dimension !== dim)\n this.changeDimension(dim, x + 0.5, y + 1, z + 0.5);\n this.warmup(x, z);\n const charges =\n this.world.getBlock(x, y, z) === B.respawn_anchor\n ? anchorCharges(this.world.getMeta(x, y, z))\n : 0;\n if (charges <= 0) {\n this.player.anchorPos = null;\n this.player.anchorDim = null;\n this.hud.message(\"Якорь возрождения разряжен или сломан\", 2500);\n return null;\n }\n this.world.setBlock(x, y, z, B.respawn_anchor!, charges - 1);\n // рядом с якорем, а не в нём: клетка с полом и местом на рост — не лава и не обрыв (в Пекле\n // «не твёрдая» соседняя клетка часто оказывалась озером лавы или пустотой над ним)\n for (const dy of [0, 1, -1])\n for (const [dx, dz] of AROUND) {\n const nx = x + dx,\n ny = y + dy,\n nz = z + dz;\n if (isStandable(this.world, nx, ny, nz))\n return new THREE.Vector3(nx + 0.5, ny, nz + 0.5);\n }\n return new THREE.Vector3(x + 0.5, y + 1, z + 0.5);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n if (DIMENSIONS[this.dimension].bedsExplode) {\n // в Пекле и Пределе кровать взрывается, как в Minecraft\n this.world.setBlock(x, y, z, B.air!);\n this.entities.explode(x + 0.5, y + 0.5, z + 0.5, 5);\n return;\n }\n this.player.bedPos = [x, y, z];\n if (this.dayNight.isDay && this.weather.kind !== \"thunder\") {\n this.hud.message(\n \"Спать можно только ночью или в грозу. Точка возрождения — здесь\",\n 2400,\n );\n return;\n }\n const monsters = [...this.entities.entities.values()].some(\n (e) =>\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.category === \"hostile\" &&\n e.pos.distanceTo(this.player.pos) < 8,\n );\n if (monsters) {\n this.hud.message(\"Нельзя спать — рядом монстры\", 2000);\n return;\n }\n // сетевая игра: ночь пропускает хост, когда спят ВСЕ игроки Верхнего мира (net/NetGame)\n if (this.net) {\n this.netSleeping = true;\n this.hud.message(\"Вы спите. Ночь пройдёт, когда уснут все игроки\", 2600);\n return;\n }\n this.skipNight();\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n if (this.inventory.heldItem()?.item !== \"map\") return;\n const p = this.player.pos;\n const m = newMap(this.nextMapId++, p.x, p.z, this.dimension);\n this.maps.set(m.id, m);\n for (let ph = 0; ph < 4; ph++) surveyMap(this.world, m, p.x, p.z, 48, ph);\n const filled = { item: \"filled_map\", count: 1, tag: { map: m.id } };\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n const i = this.inventory.hotbarIndex;\n if (!this.inventory.slots[i]) this.inventory.setSlot(i, filled);\n else if (this.inventory.addStack(filled) > 0)\n this.entities.spawnDrop(p.x, p.y + 1, p.z, \"filled_map\", 1);\n events.emit(\"mapCreated\", { id: m.id });\n }\n\n _trialScanT = 0;\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n const p = this.player;\n if ((this._trialScanT -= dt) <= 0) {\n this._trialScanT = 1;\n for (const [x, y, z] of this.world.generator.trialSpawnersNear?.(\n p.pos.x,\n p.pos.z,\n 48,\n ) ?? [])\n if (this.world.getBlockOrUnloaded(x, y, z) === B.trial_spawner)\n this.blockEntities.ensure(x, y, z);\n }\n const hunter = !p.dead && p.mode !== \"creative\";\n for (const be of this.blockEntities.map.values()) {\n if (be.type !== \"trial_spawner\") continue;\n const mob =\n TRIAL_MOBS[this.world.getMeta(be.x, be.y, be.z) % TRIAL_MOBS.length]!;\n trialStep(be.state as TrialState, {\n dt,\n near:\n hunter &&\n Math.hypot(\n p.pos.x - be.x - 0.5,\n p.pos.y - be.y,\n p.pos.z - be.z - 0.5,\n ) <= TRIAL_RANGE,\n rng: Math.random,\n spawn: () => this._trialSpawn(be.x, be.y, be.z, mob),\n reward: (items) => {\n for (const s of items)\n this.entities.spawnDrop(\n be.x + 0.5,\n be.y + 1.2,\n be.z + 0.5,\n s.item,\n s.count,\n );\n events.emit(\"trialReward\", { x: be.x, y: be.y, z: be.z });\n },\n });\n }\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n for (let i = 0; i < 10; i++) {\n const sx = x + Math.floor(Math.random() * 9) - 4,\n sz = z + Math.floor(Math.random() * 9) - 4;\n for (let sy = y - 1; sy <= y + 2; sy++) {\n if (!BLOCKS[this.world.getBlock(sx, sy - 1, sz)]?.solid) continue;\n if (\n this.world.getBlock(sx, sy, sz) !== 0 ||\n this.world.getBlock(sx, sy + 1, sz) !== 0\n )\n continue;\n const m = this.entities.spawnMob(mob, sx + 0.5, sy, sz + 0.5);\n m.trialFrom = [x, y, z];\n events.emit(\"trialSpawn\", { x, y, z, mob });\n return true;\n }\n }\n return false;\n }\n\n freezing = new Freezing();\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n const p = this.player;\n const armor = this.inventory.armor;\n p.leatherBoots = () => armor.some((s) => s?.item === \"leather_boots\");\n p.goldArmor = () => armor.some((s) => !!s?.item.startsWith(\"golden_\"));\n // щит принял удар — теряет прочность по урону (как в Minecraft), не меньше 1\n p.onShieldBlock = (n) => {\n if (this.inventory.heldItem()?.item === \"shield\")\n this.inventory.damageHeld(Math.max(1, Math.floor(n)));\n };\n const leather = armor.some((s) => !!s?.item.startsWith(\"leather_\"));\n const dmg = this.freezing.tick(\n dt,\n p.inPowderSnow && !p.dead,\n leather || p.mode === \"creative\",\n p.fireT > 0,\n );\n if (dmg) p.damage(dmg, \"freeze\");\n this.hud.frost(this.freezing.level);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n this._hornCd = Math.max(0, this._hornCd - dt);\n const press =\n this.input.rightDown &&\n !this.player.dead &&\n this.inventory.heldItem()?.item === \"goat_horn\";\n if (press && !this._hornHeld && this._hornCd <= 0) {\n this._hornCd = 7;\n events.emit(\"goatHorn\");\n const p = this.player.pos;\n if (this.dimension === \"overworld\")\n this.sculk.vibrate(p.x, p.y, p.z, this.player.mode !== \"creative\");\n }\n this._hornHeld = press;\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 lastDeath: { x: number; y: number; z: number; dim: string } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n this.sculk.tick(dt);\n const p = this.player;\n if (p.dead || p.flying || !p.onGround || p._sneaking) return;\n this._stepDist += Math.hypot(p.vel.x, p.vel.z) * dt;\n if (this._stepDist < 2) return;\n this._stepDist = 0;\n const x = Math.floor(p.pos.x),\n y = Math.floor(p.pos.y),\n z = Math.floor(p.pos.z);\n if (this.world.getBlock(x, y - 1, z) === B.sculk_shrieker)\n this.sculk.shriek(x, y - 1, z);\n this.sculk.vibrate(p.pos.x, p.pos.y, p.pos.z, p.mode !== \"creative\");\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n const on =\n !this.player.dead &&\n this.input.rightDown &&\n this.inventory.heldItem()?.item === \"spyglass\";\n const want = on ? 10 : 75;\n if (this.camera.fov === want) return;\n this.camera.fov += (want - this.camera.fov) * Math.min(1, dt * 12);\n if (Math.abs(this.camera.fov - want) < 0.1) this.camera.fov = want;\n this.camera.updateProjectionMatrix();\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n const h = this.inventory.heldItem();\n // карта сокровищ или исследователя в руке становится картой участка с крестиком над целью:\n // из тега {x, z}, а без него (карта картографа) — ближайшая к игроку цель такого вида\n const kind = h ? EXPLORER_MAPS[h.item] : undefined;\n if (h && kind) {\n let tx = Number(h.tag?.x),\n tz = Number(h.tag?.z);\n if (!Number.isFinite(tx) || !Number.isFinite(tz)) {\n if (this.dimension !== \"overworld\") return;\n const f = this.world.generator.locate?.(\n kind,\n this.player.pos.x,\n this.player.pos.z,\n );\n if (!f) return;\n tx = f.x;\n tz = f.z;\n }\n const m = newMap(this.nextMapId++, tx, tz, \"overworld\");\n // карта с целью — по центру цели, а не по сетке карт: иначе ✖ бывает у самого края\n m.x0 = Math.floor(tx) - MAP_SIZE / 2;\n m.z0 = Math.floor(tz) - MAP_SIZE / 2;\n m.target = [tx, tz];\n this.maps.set(m.id, m);\n this.inventory.setSlot(this.inventory.hotbarIndex, {\n item: \"filled_map\",\n count: 1,\n tag: { map: m.id },\n });\n events.emit(\"mapCreated\", { id: m.id, treasure: true });\n return;\n }\n if (h?.item !== \"filled_map\") return;\n const m = this.maps.get(Number(h.tag?.map));\n if (!m || m.dim !== this.dimension) return;\n const p = this.player.pos;\n surveyMap(this.world, m, p.x, p.z, 48, this._mapPhase++ & 3);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n if (this.dimension !== \"overworld\" || this.raid) return;\n const p = this.player.pos;\n const v = (this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v || !siegeTonight()) return;\n const angle = Math.random() * Math.PI * 2;\n let n = 0;\n for (let i = siegeCount(); i > 0; i--) {\n const { x, z } = siegeSpot(v.x + 0.5, v.z + 0.5, angle);\n this.warmup(x, z);\n const y = this.world.surfaceY(x, z);\n if (y <= 0) continue;\n const m = this.entities.spawnMob(\"zombie\", x + 0.5, y + 1.01, z + 0.5);\n // цель — центр деревни: без неё отряд разбредается, не дойдя до жителей (MobBrain, huntsLand)\n m.goal = new THREE.Vector3(v.x + 0.5, v.y, v.z + 0.5);\n n++;\n }\n if (n === 0) return;\n events.emit(\"siegeStart\", { x: v.x, z: v.z, count: n });\n this.hud.message(\"Ночная осада! Зомби идут к деревне\", 3500);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n // деревни верхнего мира и города Предела (панцирники) — один путь: villagesNear генератора\n const p = this.player.pos;\n for (const v of this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []) {\n if (this.villagesSettled.has(v.id)) continue;\n const loaded = v.residents.every(\n (r) =>\n this.world.getBlockOrUnloaded(\n Math.floor(r.x),\n Math.floor(r.y),\n Math.floor(r.z),\n ) !== -1,\n );\n if (!loaded) continue;\n for (const r of v.residents)\n if (MOBS[r.type]) {\n const m = this.entities.spawnMob(r.type, r.x, r.y + 0.01, r.z);\n if (r.captain) m.captain = true;\n }\n this.villagesSettled.add(v.id);\n }\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n const near = (p: THREE.Vector3) =>\n Math.hypot(p.x - x, p.z - z) < 3 && Math.abs(p.y - y) < 4;\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && near(e.pos)) e.damage(5);\n if (near(this.player.pos)) this.player.damage(5, \"lightning\");\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 }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\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.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.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.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 /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n this.renderer.shadowMap.needsUpdate = true; // тени — только в мировом проходе\n this.renderer.render(this.scene, this.camera);\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.worldUI.hide();\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.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n this.input.enabled = true;\n if (!this.loop.running) this.loop.start();\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"
497
501
  },
498
502
  {
499
503
  "path": "module.ts",
500
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelcraft } from \"./scene\";\nimport { createClientWorldAI } from \"./ai/worldAi\";\n\n// The VoxelCraft feature module. It contributes ONLY an engine scene and a nav entry — no React UI\n// panels (the game draws its own DOM HUD). This exercises the scene-only branch of the contract and\n// proves a module need not use React.\n//\n// The SDK is used for one thing only: client.ai — building a world from a picture + description\n// (feature \"voxel-world\" of the title). Without that feature the \"Create world\" screen simply\n// offers the local, AI-free path.\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 const { scene, agent, content } = createVoxelcraft({\n ai: createClientWorldAI(ctx.client),\n // сетевая игра: комнаты и WebRTC-сигналинг движка (client.multiplayer); выключена у тайтла —\n // экран «Сетевая игра» так и скажет\n netClient: ctx.client,\n });\n ctx.registerScene(scene);\n ctx.registerRoute({ id: \"voxelcraft\", label: \"Voxel\", icon: \"🧊\" });\n // Миры в Мастерской: модуль-браузер показывает, публикует и открывает их через этот обработчик,\n // не зная ничего про сейвы. Без модуля workshop в проекте регистрация просто ни на что не влияет.\n ctx.content.registerType(content);\n // Глаза и руки агента: у canvas-игры DOM пустой, без этого она для него невидима.\n ctx.exposeToAgent(agent);\n },\n});\n"
504
+ "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelcraft } from \"./scene\";\nimport { createClientWorldAI } from \"./ai/worldAi\";\n\n// The VoxelCraft feature module. It contributes ONLY an engine scene and a nav entry — no React UI\n// panels (the game draws its own DOM HUD). This exercises the scene-only branch of the contract and\n// proves a module need not use React.\n//\n// The SDK is used for client.ai — building a world from a picture + description (feature\n// \"voxel-world\" of the title; without it \"Create world\" offers the local, AI-free path) — and for\n// network play through the lobby's rooms system (ctx.multiplayer): VoxelCraft only declares how it\n// plays online and takes the room when the host presses \"Play\"; it has no rooms UI of its own.\nconst MODE = \"voxelcraft\";\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 const { scene, agent, content, whenGame } = createVoxelcraft({\n ai: createClientWorldAI(ctx.client),\n // «Играть с друзьями» в паузе — «Создать комнату» лобби с этой игрой. Нет системы мультиплеера\n // (или она выключена у тайтла) — кнопки нет, игра одиночная.\n online: {\n available: () => ctx.features.get(\"multiplayer\")?.available === true,\n openForFriends: () =>\n ctx.features.open(\"multiplayer\", { mode: MODE, create: true }),\n },\n });\n ctx.registerScene(scene);\n ctx.registerRoute({ id: MODE, label: \"Voxel\", icon: \"🧊\" });\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: \"Режим гостей\",\n kind: \"choice\",\n options: [\n { value: \"survival\", label: \"Выживание\" },\n { value: \"creative\", label: \"Творчество\" },\n ],\n default: \"survival\",\n },\n { id: \"cheats\", label: \"Команды всем\", kind: \"toggle\", default: false },\n ],\n start: async (room) => (await whenGame()).acceptRoom(room),\n });\n // Миры в Мастерской: модуль-браузер показывает, публикует и открывает их через этот обработчик,\n // не зная ничего про сейвы. Без модуля workshop в проекте регистрация просто ни на что не влияет.\n ctx.content.registerType(content);\n // Глаза и руки агента: у canvas-игры DOM пустой, без этого она для него невидима.\n ctx.exposeToAgent(agent);\n },\n});\n"
501
505
  },
502
506
  {
503
507
  "path": "net/codec.ts",
@@ -509,7 +513,7 @@
509
513
  },
510
514
  {
511
515
  "path": "net/NetGame.ts",
512
- "content": "// Сетевая игра VoxelCraft: связывает игру (Game) с `RealtimeSession` (@idosgames/core/realtime).\n//\n// Топология — звезда (комнаты `Host`): каждый гость связан только с хостом, хост пересылает. Роли и\n// протокол — net/protocol.ts. Кто считает мир — net/sim.ts: у каждого «живого» измерения один\n// владелец (первый вошедший; хост не главнее других), остальные в нём — реплики.\n//\n// Игрок и инвентарь — у каждого свои (вариант А, 27.09.2026): здесь по сети ходят только мир,\n// сущности, блочные сущности и то, что мир делает с игроком (урон, отброс, подобранное).\nimport * as THREE from \"three\";\nimport type {\n RealtimeClient,\n RealtimeSession as Session,\n} from \"@idosgames/core/realtime\";\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\nimport { events } from \"../core/EventBus\";\nimport { rules } from \"../systems/Difficulty\";\nimport type { NetEntityAction } from \"../entities/EntityManager\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\nimport { sim } from \"./sim\";\nimport { DiffStore } from \"./DiffStore\";\nimport { OwnerBroadcast } from \"./OwnerBroadcast\";\nimport {\n Avatar,\n RemotePlayer,\n remoteInventory,\n type RemoteSink,\n} from \"./RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"./codec\";\nimport {\n DIMS,\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type LockMsg,\n type PlayerMeta,\n type PortalMsg,\n type ReplyMsg,\n type Pose,\n type Roster,\n type RoomSettings,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"./protocol\";\n\nexport interface NetHostOptions {\n name: string;\n roomName: string;\n visibility: \"Public\" | \"Private\";\n joinCode?: string;\n settings: RoomSettings;\n}\n\n/** Всё, что знаем о другом игроке. */\ninterface Peer {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n pose: Pose | null;\n held: string | null;\n mode: \"survival\" | \"creative\";\n sleeping: boolean;\n avatar: Avatar | null;\n remote: RemotePlayer | null;\n}\n\n/** Хост: сейв игрока (его инвентарь авторитетен у него — здесь копия для мира хоста). */\ninterface HostRecord {\n slot: number;\n name: string;\n dim: DimensionId;\n op: boolean;\n state: unknown | null;\n}\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nconst EMPTY_DIM = (): DimensionSave => ({\n chunks: {},\n entities: [],\n blockEntities: [],\n});\n\nexport class NetGame implements RemoteSink {\n readonly session: Session;\n readonly selfId: string;\n slot = 0;\n settings: RoomSettings;\n /** Операторы (хост — всегда). */\n readonly ops = new Set<string>();\n readonly diffs = new DiffStore();\n /** Другие игроки по слоту. */\n readonly peers = new Map<number, Peer>();\n /** Владелец каждого живого измерения (userId). */\n readonly owners = new Map<DimensionId, string>();\n /** Хост ушёл — кто подхватит (объявляет хост в списке игроков). */\n successor: string | null = null;\n\n // ---- только у хоста\n private readonly records = new Map<string, HostRecord>();\n private nextSlot = 1;\n /** Измерения без владельца: сущности и блочные сущности (дифы — в `diffs`). */\n private readonly frozen = new Map<DimensionId, DimensionSave>();\n /** Блочные сущности всех измерений (для вошедшего в измерение гостя). */\n private readonly beStore = new Map<DimensionId, Map<string, BeMsg[\"be\"]>>();\n\n // ---- владелец/реплика своего измерения\n private readonly bc = new OwnerBroadcast();\n /** Реплика: что последнее прислал владелец по блочным сущностям — своё отличие уходит ему. */\n private readonly replicaBe = new OwnerBroadcast();\n private pending: BlockDelta[] = [];\n private applyingRemote = false;\n private tickN = 0;\n private lastMeta = \"\";\n private shadow: Shadow | null = null;\n private hostSilentSince = 0;\n private readonly offs: (() => void)[] = [];\n private closed = false;\n /** Гость: ждём снимок мира. */\n private welcomeWait: ((w: Welcome | string) => void) | null = null;\n /** Очередь обработки надёжного канала (см. конструктор). */\n private inbox: Promise<void> = Promise.resolve();\n /** Гость получил снимок мира (хост — всегда). До него сообщения копятся в `stash`. */\n private welcomed = true;\n private readonly stash: [string, Uint8Array][] = [];\n /** Владелец измерения: занятые контейнеры — ключ (LockMsg.key) → слот игрока. */\n private readonly locks = new Map<string, number>();\n /** Свои запросы контейнера, ждущие ответа владельца. */\n private readonly lockWait = new Map<\n string,\n { open: () => void; dim: DimensionId; timer: ReturnType<typeof setTimeout> }\n >();\n /** Реплика: id сущности, в которой сидит свой игрок (её ведёт он). */\n private driving: number | null = null;\n\n private constructor(\n readonly game: Game,\n session: Session,\n selfId: string,\n settings: RoomSettings,\n readonly name: string,\n ) {\n this.session = session;\n this.selfId = selfId;\n this.settings = settings;\n this.offs.push(\n session.on(\"message\", ({ from, channel, data }) => {\n // Надёжный канал — строго по порядку: сжатое сообщение распаковывается асинхронно, и без\n // очереди следующее («удалить сущность») обогнало бы предыдущее («создать»). Быстрый канал\n // (позы) порядка не требует и ждать не должен.\n if (channel === \"fast\") void this.onMessage(from, channel, data);\n else\n this.inbox = this.inbox.then(() =>\n this.onMessage(from, channel, data),\n );\n }),\n session.on(\"peerOpen\", ({ peerId }) => this.onPeerOpen(peerId)),\n session.on(\"peerClose\", ({ peerId }) => this.onPeerClose(peerId)),\n session.on(\"memberLeft\", ({ userId }) => this.onMemberLeft(userId)),\n session.on(\"hostChanged\", ({ hostId, previousHostId }) =>\n this.onHostChanged(hostId, previousHostId),\n ),\n session.on(\"chat\", ({ text }) => game.chat.print(text)),\n // исключили: сервер отвечает «не участник» (после уведомления хоста) или прислал PlayerLeft kicked\n session.on(\"removed\", ({ reason }) =>\n this.end(\n /kicked|not a member|removed/i.test(reason)\n ? \"Вас исключили из игры\"\n : \"Связь с игрой потеряна\",\n ),\n ),\n session.on(\"roomClosed\", () => this.end(\"Игра закрыта\")),\n );\n // Реестр порталов общий: зажёг портал любой — знают все (выход на той стороне ищется по нему).\n game.onPortalChange = (dim, pos, add) => {\n const msg = encodeJsonSync(M.PORTAL, {\n dim,\n p: pos,\n add,\n } satisfies PortalMsg);\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n };\n // Правки мира: владелец рассылает все (свои, каскады, пришедшие от реплик), реплика — только свои.\n this.offs.push(\n events.on(\n \"blockChanged\",\n (d: { x: number; y: number; z: number; id: number; meta: number }) => {\n if (this.applyingRemote && !sim.authority) return;\n this.diffs.apply(this.game.dimension, d.x, d.y, d.z, d.id, d.meta);\n this.pending.push({ x: d.x, y: d.y, z: d.z, id: d.id, meta: d.meta });\n },\n ),\n );\n }\n\n get isHost(): boolean {\n return this.session.hostId === this.selfId;\n }\n\n get hostId(): string {\n return this.session.hostId;\n }\n\n /** Может ли этот игрок командами менять мир (хост и операторы, или «команды всем»). */\n get canCheat(): boolean {\n return (\n this.isHost || this.ops.has(this.selfId) || this.settings.cheatsForAll\n );\n }\n\n // =========================================================================== старт\n\n /** Открыть мир для сети: этот игрок — хост, его мир — общий. */\n static async host(\n game: Game,\n client: RealtimeClient,\n o: NetHostOptions,\n ): Promise<NetGame | string> {\n const res = await RealtimeSession.create(client, o.roomName, {\n topology: \"Host\",\n chatMode: \"P2P\",\n maxPlayers: 8,\n visibility: o.visibility,\n joinCode: o.joinCode || undefined,\n });\n if (!res.ok) return res.error;\n // имя — публичный профиль игрока в этом тайтле (его кладёт в снимок комнаты сервер)\n const name = res.data.members.get(res.data.selfId)?.Username || o.name;\n const net = new NetGame(game, res.data, res.data.selfId, o.settings, name);\n net.ops.add(net.selfId);\n net.startAsHost();\n return net;\n }\n\n /** Войти в чужую игру. Мир этого игрока уходит в «Мои миры» и заменяется миром хоста. */\n static async join(\n game: Game,\n client: RealtimeClient,\n roomId: string,\n joinCode: string | undefined,\n name: string,\n ): Promise<NetGame | string> {\n const res = await RealtimeSession.join(client, roomId, joinCode);\n if (!res.ok) return res.error;\n const session = res.data;\n const profileName = session.members.get(session.selfId)?.Username || name;\n const net = new NetGame(\n game,\n session,\n session.selfId,\n { guestMode: \"survival\", cheatsForAll: false },\n profileName,\n );\n net.welcomed = false;\n const w = await new Promise<Welcome | string>((resolve) => {\n net.welcomeWait = resolve;\n setTimeout(() => resolve(\"Хост не ответил\"), 30_000);\n if (session.isOpen(session.hostId)) net.sendHello();\n });\n net.welcomeWait = null;\n if (typeof w === \"string\") {\n net.dispose();\n await session.leave();\n return w;\n }\n net.applyWelcome(w);\n return net;\n }\n\n private startAsHost(): void {\n const g = this.game;\n this.slot = 0;\n this.records.set(this.selfId, {\n slot: 0,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n // Мир хоста целиком в хранилище дифов: живое измерение — с мира, остальные — из сейва измерений.\n this.diffs.load(g.dimension, g.world.serializeDiffs());\n for (const [d, save] of g.dims) {\n this.diffs.load(d, save.chunks);\n this.frozen.set(d, {\n chunks: {},\n entities: save.entities ?? [],\n blockEntities: save.blockEntities ?? [],\n });\n }\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(null);\n }\n\n // =========================================================================== отправка\n\n private async send(\n to: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (to === this.selfId) {\n await this.onMessage(this.selfId, channel, data);\n return;\n }\n this.session.send(to, channel, data);\n }\n\n /** Хосту (у хоста — себе). */\n private toHost(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n void this.send(this.hostId, channel, data);\n }\n\n /** Хост: всем, кроме `except`. */\n private toAll(\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n this.session.broadcast(channel, data, except);\n }\n\n /** Хост: всем игрокам измерения, кроме `except` (себе — тоже, если хост там же). */\n private toDim(\n dim: DimensionId,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n for (const [uid, r] of this.records)\n if (r.dim === dim && uid !== except) void this.send(uid, channel, data);\n }\n\n private userOfSlot(slot: number): string | null {\n if (slot === this.slot) return this.selfId;\n return this.peers.get(slot)?.userId ?? null;\n }\n\n // =========================================================================== приём\n\n private async onMessage(\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (this.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!this.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") this.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return this.onFast(from, type, data);\n switch (type) {\n case M.HELLO:\n if (this.isHost) await this.onHello(from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && this.welcomeWait) this.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n this.welcomeWait?.(r?.reason ?? \"Хост отказал во входе\");\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !this.isHost) this.applyRoster(r);\n return;\n }\n case M.PMETA:\n return this.onMeta(from, data);\n case M.BLOCKS:\n case M.EDITS:\n return this.onBlocks(from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return this.onEntities(from, type, data);\n case M.ENT_ACTION:\n return this.onEntAction(from, data);\n case M.BE:\n return this.onBe(from, data);\n case M.LOCK:\n return this.onLock(from, data);\n case M.REPLY:\n return this.onReply(from, data);\n case M.PORTAL:\n return this.onPortal(from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === this.game.dimension && !sim.authority)\n for (const b of m.list) this.applyBe(b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !this.isHost) {\n this.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n this.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return this.onChat(from, data);\n case M.HIT:\n case M.GIVE:\n return this.onToPlayer(from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = this.records.get(from);\n if (s && r && this.isHost) {\n r.state = s.state;\n this.game.mergeBeyond(\n (s.state as { beyond?: unknown } | null)?.beyond,\n );\n }\n return;\n }\n case M.DIM_ENTER:\n if (this.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) this.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === this.game.dimension) this.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) this.bc.reset();\n return;\n case M.SHADOW:\n this.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && this.isHost && this.mayCheat(from))\n this.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n }\n\n private onFast(from: string, type: number, data: Uint8Array): void {\n if (type === M.POSE) {\n if (this.isHost && from !== this.selfId) {\n const r = this.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n this.toAll(\"fast\", stamped, from);\n this.applyPose(pose);\n return;\n }\n if (!this.isHost) this.applyPose(decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (this.isHost && from !== this.selfId)\n this.toDim(dim, \"fast\", data, from);\n if (dim === this.game.dimension && !sim.authority && from !== this.selfId)\n for (const p of decodeEntPoses(data).list)\n this.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n }\n\n // ---- вход гостя (хост)\n\n private sendHello(): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: this.name };\n this.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n }\n\n private async onHello(from: string, data: Uint8Array): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await this.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: \"Версии игры отличаются — обновите страницу\",\n }),\n );\n return;\n }\n const g = this.game;\n let r = this.records.get(from);\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: this.nextSlot++,\n name: (h.name || \"Игрок\").slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: this.ops.has(from),\n state: saved,\n };\n this.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)\n ?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: this.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: this.roster(),\n };\n await this.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n this.broadcastRoster();\n this.enterDim(from, r.dim);\n }\n\n // ---- поза и метаданные\n\n private applyPose(p: Pose): void {\n if (p.slot === this.slot) return;\n const peer = this.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === this.game.dimension && p.dim !== peer.dim && sim.authority)\n this.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === this.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name);\n this.game.scene.add(peer.avatar.group);\n }\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(\n this.game.world,\n p.slot,\n peer.userId,\n this,\n );\n peer.remote.world = this.game.world;\n peer.remote.held = peer.held;\n peer.remote.applyPose(p);\n }\n } else this.dropPeerView(peer);\n }\n\n private dropPeerView(peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(this.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n }\n\n private async onMeta(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (this.isHost && from !== this.selfId) {\n const r = this.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n this.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n this.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? this.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n }\n\n // ---- блоки\n\n private onBlocks(from: string, type: number, data: Uint8Array): void {\n const { dim, list } = decodeBlocks(data);\n if (this.isHost && from !== this.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (this.owners.get(dim) !== from) return;\n this.diffs.applyAll(dim, list);\n this.toAll(\"rel\", data, from);\n } else {\n const owner = this.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n this.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n this.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (this.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = this.owners.get(dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== this.game.dimension) return;\n this.applyBlocks(list, true);\n return;\n }\n if (from === this.selfId) return;\n if (!this.isHost) this.diffs.applyAll(dim, list);\n if (dim === this.game.dimension && !sim.authority)\n this.applyBlocks(list, false);\n }\n\n private applyBlocks(list: readonly BlockDelta[], asOwner: boolean): void {\n const w = this.game.world;\n this.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n this.applyingRemote = false;\n }\n }\n\n // ---- сущности\n\n private async onEntities(\n from: string,\n type: number,\n data: Uint8Array,\n ): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (this.isHost && from !== this.selfId)\n this.toDim(m.dim, \"rel\", data, from);\n if (\n m.dim === this.game.dimension &&\n !sim.authority &&\n from !== this.selfId\n )\n for (const [id, snap] of m.list)\n this.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (this.isHost && from !== this.selfId) this.toDim(dim, \"rel\", data, from);\n if (dim === this.game.dimension && !sim.authority && from !== this.selfId)\n for (const id of ids) this.game.entities.netRemove(id);\n }\n\n private async onEntAction(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (this.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === this.selfId ? this.slot : this.records.get(from)?.slot;\n const owner = this.owners.get(m.dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== this.game.dimension) return;\n const em = this.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker =\n m.from !== undefined ? this.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n this.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n }\n\n /** Реплика: действие над сущностью — владельцу измерения (через хоста). */\n private entAction(action: NetEntityAction): void {\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: this.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n }\n\n // ---- контейнеры: один игрок — один открытый контейнер\n\n /**\n * Открыть контейнер (сундук, печь, вьюк, груз вагонетки, торговлю с жителем). Владелец измерения\n * решает сам, реплика спрашивает его и открывает по ответу — с его содержимым. Занят — подсказка.\n */\n openContainer(key: string, open: () => void): void {\n const g = this.game;\n if (sim.authority) {\n const by = this.lockBy(key, this.slot);\n if (by) return this.busyHint(by);\n this.locks.set(key, this.slot);\n open();\n return;\n }\n if (this.lockWait.has(key)) return;\n const timer = setTimeout(() => {\n if (this.lockWait.delete(key))\n g.hud.message(\"Нет ответа от игрока, у которого это измерение\", 2200);\n }, 4000);\n this.lockWait.set(key, { open, dim: g.dimension, timer });\n this.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: g.dimension,\n key,\n open: true,\n } satisfies LockMsg),\n );\n }\n\n /** Экран контейнера закрыт — отпустить. */\n releaseContainer(key: string): void {\n if (sim.authority) {\n if (this.locks.get(key) === this.slot) this.locks.delete(key);\n return;\n }\n this.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: this.game.dimension,\n key,\n open: false,\n } satisfies LockMsg),\n );\n }\n\n private busyHint(by: string): void {\n this.game.hud.message(`Сейчас открыто у игрока ${by}`, 2000);\n }\n\n /** Владелец: имя того, кто держит контейнер (если не `slot` и он всё ещё в этом измерении). */\n private lockBy(key: string, slot: number): string | null {\n const cur = this.locks.get(key);\n if (cur === undefined || cur === slot) return null;\n if (cur === this.slot) return this.name;\n const peer = this.peers.get(cur);\n if (peer && peer.dim === this.game.dimension) return peer.name;\n this.locks.delete(key); // держатель ушёл (из измерения или из игры)\n return null;\n }\n\n private async onLock(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<LockMsg>(data);\n if (!m || typeof m.key !== \"string\") return;\n if (this.isHost) {\n m.from = from === this.selfId ? this.slot : this.records.get(from)?.slot;\n const owner = this.owners.get(m.dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", encodeJsonSync(M.LOCK, m));\n return;\n }\n }\n const slot = m.from;\n if (!sim.authority || m.dim !== this.game.dimension || slot === undefined)\n return;\n if (!m.open) {\n if (this.locks.get(m.key) === slot) this.locks.delete(m.key);\n return;\n }\n const by = this.lockBy(m.key, slot);\n const reply: ReplyMsg = {\n to: slot,\n kind: \"lock\",\n key: m.key,\n ok: !by,\n ...(by ? { by } : {}),\n };\n if (!by) {\n this.locks.set(m.key, slot);\n // блочная сущность — отсюда: сундук постройки получает добычу здесь, у реплики катилась бы своя\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b) {\n const be = this.game.blockEntities.ensure(+b[1]!, +b[2]!, +b[3]!);\n reply.be = be\n ? {\n type: be.type,\n x: be.x,\n y: be.y,\n z: be.z,\n state: JSON.parse(JSON.stringify(be.state)) as typeof be.state,\n }\n : null;\n }\n }\n this.replyTo(reply);\n }\n\n /** Ответ игроку `to`: себе — сразу, иначе через хоста (или хост — прямо ему). */\n private replyTo(r: ReplyMsg): void {\n if (r.to === this.slot) {\n this.handleReply(r);\n return;\n }\n const msg = encodeJsonSync(M.REPLY, r);\n if (this.isHost) {\n const to = this.userOfSlot(r.to);\n if (to) void this.send(to, \"rel\", msg);\n } else this.toHost(\"rel\", msg);\n }\n\n private async onReply(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<ReplyMsg>(data);\n if (!m) return;\n if (this.isHost && m.to !== this.slot) {\n const to = this.userOfSlot(m.to);\n if (to && to !== from) void this.send(to, \"rel\", data);\n return;\n }\n if (m.to === this.slot) this.handleReply(m);\n }\n\n private handleReply(m: ReplyMsg): void {\n const g = this.game;\n if (m.kind === \"ride\") {\n const r = g.player.riding as unknown as { id?: number } | null;\n if (!m.ok && r && r.id === m.id) {\n g.player.dismount();\n g.hud.message(\"Место уже занято\", 1600);\n }\n return;\n }\n const w = m.key ? this.lockWait.get(m.key) : undefined;\n if (!w || !m.key) return;\n clearTimeout(w.timer);\n this.lockWait.delete(m.key);\n if (!m.ok) return this.busyHint(m.by ?? \"?\");\n if (w.dim !== g.dimension) {\n this.releaseContainer(m.key);\n return;\n }\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b && m.be !== undefined)\n this.applyBe({\n dim: g.dimension,\n x: +b[1]!,\n y: +b[2]!,\n z: +b[3]!,\n be: m.be,\n });\n w.open();\n }\n\n // ---- порталы\n\n private async onPortal(from: string, data: Uint8Array): Promise<void> {\n if (from === this.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (this.isHost) this.toAll(\"rel\", data, from);\n this.game.applyNetPortal(m.dim, m.p, !!m.add);\n }\n\n // ---- блочные сущности\n\n private async onBe(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = this.owners.get(m.dim);\n if (this.isHost && from !== this.selfId) {\n if (owner === from) {\n this.storeBe(m);\n this.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n } else if (this.isHost && owner && owner !== this.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void this.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== this.game.dimension || from === this.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n this.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else this.applyBe(m);\n }\n\n private applyBe(m: BeMsg): void {\n this.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n this.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n }\n\n private storeBe(m: BeMsg): void {\n let s = this.beStore.get(m.dim);\n if (!s) this.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n }\n\n // ---- игроку: урон, предметы\n\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: Hit[\"effect\"],\n ): void {\n const h: Hit = {\n to: slot,\n amount,\n cause,\n ...(knock ? { knock } : {}),\n ...(effect ? { effect } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.HIT, h));\n }\n\n give(slot: number, stack: ItemStack | null, xp?: number): void {\n const g: Give = {\n to: slot,\n ...(stack ? { stack } : {}),\n ...(xp ? { xp } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.GIVE, g));\n }\n\n private async onToPlayer(\n from: string,\n type: number,\n data: Uint8Array,\n ): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (this.isHost && m.to !== this.slot) {\n const to = this.userOfSlot(m.to);\n if (to && to !== from) void this.send(to, \"rel\", data);\n return;\n }\n if (m.to !== this.slot) return;\n const g = this.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n }\n\n // ---- чат\n\n sendChat(text: string): void {\n const t = text.slice(0, 256);\n this.game.chat.print(`<${this.name}> ${t}`);\n this.toHost(\"rel\", encodeJsonSync(M.CHAT, { from: this.slot, text: t }));\n }\n\n private async onChat(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === this.selfId) return;\n if (this.isHost) {\n m.from = this.records.get(from)?.slot;\n this.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who =\n m.from !== undefined ? (this.peers.get(m.from)?.name ?? \"?\") : \"?\";\n this.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n }\n\n // =========================================================================== владение измерением\n\n /** Стать владельцем своего измерения: сущности и блочные сущности — из переданного сохранения. */\n private becomeOwner(save: DimensionSave | null): void {\n const g = this.game;\n sim.authority = true;\n g.entities.replica = false;\n g.entities.localSlot = this.slot;\n this.driving = null;\n g.entities.drivenId = null;\n this.locks.clear();\n if (save) {\n g.entities.deserialize(\n (save.entities ?? []) as Parameters<typeof g.entities.deserialize>[0],\n );\n g.blockEntities.deserialize(save.blockEntities ?? []);\n }\n this.bc.reset();\n for (const peer of this.peers.values())\n if (peer.pose && peer.dim === g.dimension && !peer.remote) {\n peer.remote = new RemotePlayer(g.world, peer.slot, peer.userId, this);\n peer.remote.applyPose(peer.pose);\n }\n g.entities.others = () => this.remoteActors();\n }\n\n /** Стать репликой: сущности — образы от владельца, локальные id — в отдельном диапазоне. */\n private becomeReplica(): void {\n const g = this.game;\n sim.authority = false;\n g.entities.replica = true;\n g.entities.localSlot = this.slot;\n this.driving = null;\n this.locks.clear();\n g.entities._nextId = Math.max(g.entities._nextId, 1_000_000_000);\n g.entities.clear();\n g.entities.others = () => [];\n g.entities.onLocalSpawn = (snap) =>\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action: { a: \"spawn\", snap },\n } satisfies EntActionMsg),\n );\n g.entities.onRemoteAction = (action) =>\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action,\n } satisfies EntActionMsg),\n );\n for (const peer of this.peers.values()) peer.remote = null;\n }\n\n private remoteActors() {\n const out = [];\n for (const peer of this.peers.values())\n if (peer.remote && peer.dim === this.game.dimension && peer.pose)\n out.push({\n player: peer.remote as RemotePlayer,\n inventory: remoteInventory(peer.remote, this),\n });\n return out;\n }\n\n /**\n * Перед сменой измерения (начало Game.changeDimension): у всех есть дифы любого измерения, так\n * что целевое строится без ожидания. Сущности поднимаем, только если станем владельцем сразу\n * (хост, измерение ничьё); иначе их пришлёт владелец или хост отдаст владение.\n */\n beforeDimension(target: DimensionId): void {\n const g = this.game;\n const iOwnTarget = this.isHost && !this.owners.get(target);\n const frozen = this.frozen.get(target);\n g.dims.set(target, {\n chunks: this.diffs.toChunkDiffMap(target),\n entities: iOwnTarget ? (frozen?.entities ?? []) : [],\n blockEntities: iOwnTarget\n ? (frozen?.blockEntities ?? [])\n : [...(this.beStore.get(target)?.values() ?? [])].filter(Boolean),\n });\n if (iOwnTarget) this.frozen.delete(target);\n this.pendingOwnTarget = iOwnTarget;\n }\n private pendingOwnTarget = false;\n\n /**\n * Середина Game.changeDimension: прежнее измерение уже снято в `dims` (с сущностями — для\n * передачи следующему владельцу), новое ещё не загружено. Здесь переключается роль: реплике нельзя\n * поднимать сущности из сохранения — её spawn ушёл бы владельцу, а сущности там и так есть.\n */\n prepareDimension(): void {\n if (this.pendingOwnTarget) {\n sim.authority = true;\n this.game.entities.replica = false;\n } else this.becomeReplica();\n }\n\n /** После смены измерения: отдать прежнее (если владел) и заявить новое. */\n afterDimension(prev: DimensionId, target: DimensionId): void {\n const g = this.game;\n const wasOwner = this.owners.get(prev) === this.selfId;\n const handoff = wasOwner ? g.dims.get(prev) : undefined;\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n if (this.isHost) {\n const r = this.records.get(this.selfId)!;\n r.dim = target;\n if (handoff)\n this.frozen.set(prev, {\n chunks: {},\n entities: handoff.entities ?? [],\n blockEntities: handoff.blockEntities ?? [],\n });\n if (wasOwner) this.owners.delete(prev);\n this.assignOwner(prev);\n if (!this.owners.get(target)) {\n this.owners.set(target, this.selfId);\n this.becomeOwner(null); // сущности уже подняты changeDimension из g.dims\n } else this.enterDim(this.selfId, target);\n this.broadcastRoster();\n return;\n }\n // Синхронно и без сжатия: следом пойдут правки нового измерения (портал на той стороне), и по\n // упорядоченному каналу хост обязан узнать о входе РАНЬШЕ — иначе правки ничейного измерения\n // он бы выбросил.\n const msg: DimEnter = {\n dim: target,\n ...(handoff ? { handoff: { dim: prev, save: handoff } } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.DIM_ENTER, msg));\n // сейв игрока у хоста — сразу, а не через 10 с: уйди хост раньше, гость вернулся бы в прежнее\n // измерение на прежнее место\n this.sendPlayerState();\n }\n\n /** Хост: игрок вошёл в измерение — владельцем или репликой. */\n private onDimEnter(from: string, m: DimEnter): void {\n const r = this.records.get(from);\n if (!r) return;\n const prev = r.dim;\n r.dim = m.dim;\n if (m.handoff && this.owners.get(m.handoff.dim) === from) {\n this.frozen.set(m.handoff.dim, {\n chunks: {},\n entities: m.handoff.save.entities ?? [],\n blockEntities: m.handoff.save.blockEntities ?? [],\n });\n for (const b of m.handoff.save.blockEntities ?? []) {\n const be = b as BeMsg[\"be\"];\n if (be)\n this.storeBe({ dim: m.handoff.dim, x: be.x, y: be.y, z: be.z, be });\n }\n this.owners.delete(m.handoff.dim);\n }\n this.assignOwner(prev);\n this.enterDim(from, m.dim);\n this.broadcastRoster();\n }\n\n /** Хост: `who` в измерении `dim` — дать владение (если ничьё) или полный снимок от владельца. */\n private enterDim(who: string, dim: DimensionId): void {\n const owner = this.owners.get(dim);\n if (!owner) {\n this.owners.set(dim, who);\n const save = this.frozen.get(dim) ?? EMPTY_DIM();\n this.frozen.delete(dim);\n if (who === this.selfId) this.becomeOwner(save);\n // синхронно: по упорядоченному каналу владение обязано прийти раньше пересланных следом правок\n else\n void this.send(\n who,\n \"rel\",\n encodeJsonSync(M.DIM_OWN, { dim, save } satisfies DimOwn),\n );\n return;\n }\n if (owner === who) return;\n void this.send(owner, \"rel\", encodeJsonSync(M.DIM_RESYNC, { dim }));\n const list = [...(this.beStore.get(dim) ?? new Map())].map(([k, be]) => {\n const [x, y, z] = k.split(\",\").map(Number) as [number, number, number];\n return { dim, x, y, z, be } satisfies BeMsg;\n });\n // синхронно: следом могут пойти свежие изменения тех же сундуков, старый список не должен их затереть\n if (who !== this.selfId && list.length)\n void this.send(who, \"rel\", encodeJsonSync(M.BE_LIST, { dim, list }));\n }\n\n /** Хост: у измерения нет владельца или он ушёл — отдать следующему игроку в нём, иначе заморозить. */\n private assignOwner(dim: DimensionId): void {\n const owner = this.owners.get(dim);\n if (owner && this.records.get(owner)?.dim === dim) return;\n this.owners.delete(dim);\n const next = [...this.records.entries()]\n .filter(([, r]) => r.dim === dim)\n .sort((a, b) => a[1].slot - b[1].slot)[0];\n if (next) this.enterDim(next[0], dim);\n }\n\n // =========================================================================== список игроков\n\n private roster(): Roster {\n return {\n hostId: this.selfId,\n players: [...this.records.entries()].map(([userId, r]) => ({\n slot: r.slot,\n userId,\n name: r.name,\n dim: r.dim,\n op: this.ops.has(userId),\n })),\n owners: Object.fromEntries(this.owners),\n settings: this.settings,\n ...(this.successor ? { successor: this.successor } : {}),\n } as Roster;\n }\n\n private broadcastRoster(): void {\n if (!this.isHost) return;\n const r = this.roster();\n this.applyRoster(r);\n this.toAll(\"rel\", encodeJsonSync(M.ROSTER, r));\n }\n\n private applyRoster(r: Roster): void {\n this.settings = r.settings;\n this.ops.clear();\n const seen = new Set<number>();\n for (const p of r.players) {\n if (p.op) this.ops.add(p.userId);\n if (p.userId === this.selfId) {\n this.slot = p.slot;\n continue;\n }\n seen.add(p.slot);\n const cur = this.peers.get(p.slot);\n if (cur) {\n cur.name = p.name;\n cur.op = p.op;\n cur.dim = p.dim;\n } else\n this.peers.set(p.slot, {\n slot: p.slot,\n userId: p.userId,\n name: p.name,\n dim: p.dim,\n op: p.op,\n pose: null,\n held: null,\n mode: \"survival\",\n sleeping: false,\n avatar: null,\n remote: null,\n });\n }\n for (const [slot, peer] of [...this.peers])\n if (!seen.has(slot)) {\n this.dropPeerView(peer);\n this.peers.delete(slot);\n }\n // владелец моего измерения сменился — старые образы сущностей больше не обновятся\n const ownerHere = r.owners[this.game.dimension];\n const prevOwner = this.owners.get(this.game.dimension);\n this.owners.clear();\n for (const [d, u] of Object.entries(r.owners))\n if (u) this.owners.set(d as DimensionId, u);\n if (\n !this.isHost &&\n ownerHere !== prevOwner &&\n ownerHere !== this.selfId &&\n !sim.authority\n )\n this.game.entities.clear();\n const succ = (r as Roster & { successor?: string }).successor ?? null;\n if (succ !== this.successor) {\n this.successor = succ;\n // запасная связь с преемником: передача хоста не ждёт сигналинга\n if (succ === this.selfId)\n this.session.setExtraPeers(r.players.map((p) => p.userId));\n else this.session.setExtraPeers(succ ? [succ] : []);\n }\n events.emit(\"net:roster\");\n }\n\n /** Игроки для списка по Tab. */\n playerList(): {\n name: string;\n slot: number;\n userId: string;\n host: boolean;\n op: boolean;\n dim: DimensionId;\n rtt: number;\n }[] {\n const out = [\n {\n name: this.name,\n slot: this.slot,\n userId: this.selfId,\n host: this.isHost,\n op: this.ops.has(this.selfId),\n dim: this.game.dimension,\n rtt: 0,\n },\n ];\n for (const p of this.peers.values())\n out.push({\n name: p.name,\n slot: p.slot,\n userId: p.userId,\n host: p.userId === this.hostId,\n op: p.op,\n dim: p.dim,\n rtt: this.session.linkStats(p.userId)?.rttMs ?? 0,\n });\n return out.sort((a, b) => a.slot - b.slot);\n }\n\n // =========================================================================== тик и кадр\n\n /** Конец симтика Game: разослать накопленное. */\n afterTick(): void {\n if (this.closed) return;\n const g = this.game;\n this.tickN++;\n const dim = g.dimension;\n\n if (this.pending.length) {\n const list = this.pending;\n this.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (this.isHost && type === M.BLOCKS) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) this.entAction(a);\n this.syncRide();\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of this.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) this.hit(peer.slot, 0, \"knock\", imp);\n }\n if (this.tickN % 2 === 0 && this.hasOthersIn(dim)) {\n const d = this.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n this.ownerSend(\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) this.ownerSend(\"fast\", b);\n if (d.removes.length)\n this.ownerSend(\"rel\", encodeEntRemove(dim, d.removes));\n }\n if (this.tickN % 10 === 0)\n for (const { be, key } of this.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (this.isHost) this.storeBe(msg);\n if (this.hasOthersIn(dim))\n this.ownerSend(\"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (this.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of this.replicaBe.diffBlockEntities(\n g.blockEntities,\n )) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n this.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n this.sendPose();\n this.sendMetaIfChanged();\n if (this.tickN % 200 === 0 && !this.isHost) this.sendPlayerState();\n\n if (this.isHost) {\n if (this.tickN % 100 === 0)\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (this.tickN % 200 === 0) this.pickSuccessorAndShadow();\n } else this.watchHost();\n }\n\n /**\n * Реплика в транспорте или верхом: сущность ведёт этот игрок (EntityManager.drivenId), владелец\n * её не тикает и получает позу 10 раз в секунду. Сел/слез — сказать владельцу.\n */\n private syncRide(): void {\n const g = this.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== this.driving) {\n if (this.driving !== null)\n this.entAction({ a: \"ride\", id: this.driving, on: false });\n if (id !== null) this.entAction({ a: \"ride\", id, on: true });\n this.driving = id;\n em.drivenId = id;\n }\n if (id === null || this.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n this.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n }\n\n /** Владелец рассылает игрокам измерения: хост — сам, гость — через хоста. */\n private ownerSend(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n if (this.isHost)\n this.toDim(this.game.dimension, channel, data, this.selfId);\n else this.toHost(channel, data);\n }\n\n private hasOthersIn(dim: DimensionId): boolean {\n for (const p of this.peers.values()) if (p.dim === dim) return true;\n return false;\n }\n\n private sendPose(): void {\n const g = this.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0);\n const msg = encodePose({\n slot: this.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (this.isHost) this.toAll(\"fast\", msg);\n else this.toHost(\"fast\", msg);\n }\n\n private sendMetaIfChanged(): void {\n const g = this.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: this.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: this.name,\n sleeping: g.netSleeping,\n };\n const j = JSON.stringify(meta);\n if (j === this.lastMeta) return;\n this.lastMeta = j;\n if (this.isHost) {\n this.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n this.hostSleepCheck(this.selfId, meta);\n } else this.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n }\n\n /** Сон как в Minecraft: ночь пропускается, когда спят ВСЕ игроки Верхнего мира. */\n private hostSleepCheck(_who: string, _m: PlayerMeta): void {\n const g = this.game;\n const inOverworld = [...this.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === this.selfId\n ? g.netSleeping\n : ([...this.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n }\n\n frame(dt: number): void {\n for (const peer of this.peers.values()) peer.avatar?.frame(dt);\n }\n\n // =========================================================================== команды\n\n private mayCheat(userId: string): boolean {\n return (\n userId === this.selfId ||\n this.ops.has(userId) ||\n this.settings.cheatsForAll\n );\n }\n\n /**\n * Команда в сетевой игре: «про себя» — здесь, «про мир» — у хоста, без прав — отказ.\n * Возвращает null (исполнять локально) или текст ответа.\n */\n routeCommand(name: string, text: string): string | null {\n const harmless = [\"help\", \"seed\", \"locate\", \"achievements\", \"music\"];\n if (harmless.includes(name)) return null;\n if (name === \"newworld\")\n return \"В сетевой игре новый мир не создать — сначала выйдите из неё\";\n if (!this.canCheat) return \"Команды в этой игре — только у операторов\";\n if ([\"time\", \"weather\", \"difficulty\"].includes(name) && !this.isHost) {\n this.toHost(\"rel\", encodeJsonSync(M.COMMAND, { text }));\n return \"Команда отправлена хосту\";\n }\n return null;\n }\n\n /** Хост: выдать или снять права оператора. */\n setOp(userId: string, op: boolean): void {\n if (!this.isHost || userId === this.selfId) return;\n if (op) this.ops.add(userId);\n else this.ops.delete(userId);\n const r = this.records.get(userId);\n if (r) r.op = op;\n this.broadcastRoster();\n }\n\n async kick(userId: string, ban: boolean): Promise<string | null> {\n const res = await this.session.kick(userId, ban);\n return res.ok ? null : res.error;\n }\n\n // =========================================================================== хост ушёл\n\n /** Хост: выбрать преемника (лучшая связь) и отправить ему теневую копию мира. */\n private pickSuccessorAndShadow(): void {\n let best: string | null = null,\n bestRtt = Infinity;\n for (const peer of this.peers.values()) {\n const s = this.session.linkStats(peer.userId);\n if (!s || !this.session.isOpen(peer.userId)) continue;\n const rtt = s.rttMs || 999;\n if (rtt < bestRtt) {\n bestRtt = rtt;\n best = peer.userId;\n }\n }\n if (best !== this.successor) {\n this.successor = best;\n this.session.setSuccessor(best);\n this.broadcastRoster();\n }\n if (!best) return;\n const g = this.game;\n const dims: Partial<Record<DimensionId, DimensionSave>> = {};\n for (const [d, s] of this.frozen) dims[d] = s;\n if (this.owners.get(g.dimension) === this.selfId)\n dims[g.dimension] = {\n chunks: {},\n entities: g.entities.serialize(),\n blockEntities: g.blockEntities.serialize(),\n };\n const players: Record<string, unknown> = {};\n for (const [uid, r] of this.records)\n players[uid] = uid === this.selfId ? g.netPlayerState() : r.state;\n const shadow: Shadow = {\n world: g.netWorldMeta(),\n dims,\n players,\n settings: this.settings,\n ops: [...this.ops],\n };\n void encodeJson(M.SHADOW, shadow).then((b) => this.send(best!, \"rel\", b));\n }\n\n private onPeerOpen(peerId: string): void {\n if (!this.isHost && peerId === this.hostId) {\n if (this.welcomeWait) this.sendHello();\n else {\n // новый хост после передачи: представиться заново\n this.lastMeta = \"\";\n this.sendPlayerState();\n }\n }\n }\n\n /** Гость: свой сейв хосту (он хранит его в своём мире). */\n private sendPlayerState(): void {\n if (this.isHost) return;\n void encodeJson(M.PSTATE, { state: this.game.netPlayerState() }).then((b) =>\n this.toHost(\"rel\", b),\n );\n }\n\n private onPeerClose(peerId: string): void {\n if (!this.isHost && peerId === this.hostId)\n this.hostSilentSince = Date.now();\n }\n\n private onMemberLeft(userId: string): void {\n for (const peer of this.peers.values())\n if (peer.userId === userId && sim.authority)\n this.game.entities.netReleaseDriver(peer.slot);\n const r = this.records.get(userId);\n if (this.isHost && r) {\n this.records.delete(userId);\n if (this.owners.get(r.dim) === userId) {\n this.owners.delete(r.dim);\n this.assignOwner(r.dim);\n }\n this.broadcastRoster();\n }\n for (const [slot, peer] of [...this.peers])\n if (peer.userId === userId) {\n this.dropPeerView(peer);\n this.peers.delete(slot);\n }\n events.emit(\"net:roster\");\n }\n\n /** Гость: связь с хостом пропала. Преемник через 5 с забирает роль (сервер проверит, что хост молчит). */\n private watchHost(): void {\n if (!this.hostSilentSince || this.successor !== this.selfId) return;\n if (this.session.isOpen(this.hostId)) {\n this.hostSilentSince = 0;\n return;\n }\n if (Date.now() - this.hostSilentSince < 5000 || this.tickN % 40 !== 0)\n return;\n void this.session.claimHost();\n }\n\n private onHostChanged(hostId: string, previousHostId: string | null): void {\n this.hostSilentSince = 0;\n this.hostIdBefore = previousHostId ?? \"\";\n if (hostId !== this.selfId) {\n // у нового хоста наш сейв из теневой копии (до 10 с давности); по запасной связи, уже открытой,\n // onPeerOpen не сработает — шлём сразу\n if (this.session.isOpen(hostId)) this.sendPlayerState();\n return;\n }\n this.promote();\n }\n\n /** Стал хостом: мир из теневой копии и своей копии дифов, прежние владельцы измерений — как были. */\n private promote(): void {\n const g = this.game;\n const sh = this.shadow;\n this.records.clear();\n this.records.set(this.selfId, {\n slot: this.slot,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n for (const peer of this.peers.values())\n if (this.session.members.has(peer.userId))\n this.records.set(peer.userId, {\n slot: peer.slot,\n name: peer.name,\n dim: peer.dim,\n op: peer.op,\n state: sh?.players[peer.userId] ?? null,\n });\n this.nextSlot = Math.max(this.slot, ...[...this.peers.keys()]) + 1;\n if (sh) {\n this.settings = sh.settings;\n for (const o of sh.ops) this.ops.add(o);\n for (const [d, s] of Object.entries(sh.dims) as [\n DimensionId,\n DimensionSave,\n ][])\n if (!this.owners.has(d) || this.owners.get(d) === this.hostIdBefore)\n this.frozen.set(d, s);\n g.applyNetWorldState({\n ...sh.world,\n difficulty: sh.world.difficulty,\n daysAwake: sh.world.daysAwake,\n } as WorldState);\n }\n this.ops.add(this.selfId);\n // владения прежнего хоста: его измерение — следующему там (может, мне), остальное — как было\n for (const [d, owner] of [...this.owners])\n if (!this.records.has(owner)) {\n this.owners.delete(d);\n this.assignOwner(d);\n }\n if (!this.owners.get(g.dimension)) {\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(this.frozen.get(g.dimension) ?? null);\n this.frozen.delete(g.dimension);\n }\n g.adoptNetWorldAsOwn();\n this.broadcastRoster();\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n g.hud.message(\"Хост ушёл — теперь мир у вас\", 3000);\n }\n private hostIdBefore = \"\";\n\n // =========================================================================== гость: вход\n\n private applyWelcome(w: Welcome): void {\n this.slot = w.you;\n this.applyRoster(w.roster);\n this.hostIdBefore = w.roster.hostId;\n for (const d of DIMS) this.diffs.load(d, w.diffs[d]);\n this.becomeReplica();\n this.game.loadNetWorld(w, this.diffs, this.settings);\n // правки, накопленные, пока шёл вход, — из СВОЕГО одиночного мира: хосту их слать нельзя\n this.pending = [];\n this.welcomed = true;\n // отложенное — по порядку прихода; правки из снимка применятся повторно, это безвредно\n const stash = this.stash.splice(0);\n for (const [from, data] of stash)\n this.inbox = this.inbox.then(() => this.onMessage(from, \"rel\", data));\n }\n\n // =========================================================================== выход\n\n /** Хост уходит: свежая теневая копия преемнику, и сервер отдаст роль именно ему. */\n prepareExit(): void {\n if (this.closed || !this.isHost) return;\n this.pickSuccessorAndShadow();\n this.session.setSuccessor(this.successor);\n }\n\n /** Хост: сейвы других игроков — в мир хоста (Game.persist). */\n collectPlayerStates(into: Map<string, unknown>): void {\n if (!this.isHost) return;\n for (const [uid, r] of this.records)\n if (uid !== this.selfId && r.state) into.set(uid, r.state);\n }\n\n /** Уйти из сетевой игры (кнопка). Хост передаёт мир преемнику. */\n async leave(): Promise<void> {\n if (this.closed) return;\n this.prepareExit();\n this.dispose();\n await this.session.leave();\n this.game.exitNet(null);\n }\n\n private end(reason: string): void {\n if (this.closed) return;\n this.dispose();\n this.game.exitNet(reason);\n }\n\n dispose(): void {\n if (this.closed) return;\n this.closed = true;\n for (const off of this.offs.splice(0)) off();\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n this.peers.clear();\n const g = this.game;\n g.entities.replica = false;\n g.entities.others = () => [];\n g.entities.onLocalSpawn = null;\n g.entities.onRemoteAction = null;\n g.entities.localSlot = null;\n g.entities.drivenId = null;\n g.onPortalChange = null;\n for (const w of this.lockWait.values()) clearTimeout(w.timer);\n this.lockWait.clear();\n this.locks.clear();\n sim.authority = true;\n }\n}\n"
516
+ "content": "// Сетевая игра VoxelCraft: связывает игру (Game) с `RealtimeSession` (@idosgames/core/realtime).\n// Комнату создаёт и в неё входит ЛОББИ (система мультиплеера, ctx.multiplayer) — сюда приходит уже\n// открытая сессия (`NetGame.fromRoom`); своего списка комнат, создания и входа по коду у игры нет.\n//\n// Топология — звезда (комнаты `Host`): каждый гость связан только с хостом, хост пересылает. Роли и\n// протокол — net/protocol.ts. Кто считает мир — net/sim.ts: у каждого «живого» измерения один\n// владелец (первый вошедший; хост не главнее других), остальные в нём — реплики.\n//\n// Игрок и инвентарь — у каждого свои (вариант А, 27.09.2026): здесь по сети ходят только мир,\n// сущности, блочные сущности и то, что мир делает с игроком (урон, отброс, подобранное).\nimport * as THREE from \"three\";\nimport type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport { events } from \"../core/EventBus\";\nimport { rules } from \"../systems/Difficulty\";\nimport type { NetEntityAction } from \"../entities/EntityManager\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\nimport { sim } from \"./sim\";\nimport { DiffStore } from \"./DiffStore\";\nimport { OwnerBroadcast } from \"./OwnerBroadcast\";\nimport {\n Avatar,\n RemotePlayer,\n remoteInventory,\n type RemoteSink,\n} from \"./RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"./codec\";\nimport {\n DIMS,\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type LockMsg,\n type PlayerMeta,\n type PortalMsg,\n type ReplyMsg,\n type Pose,\n type Roster,\n type RoomSettings,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"./protocol\";\n\n/**\n * Комната, которую отдаёт лобби (`MultiplayerRoom` контракта модулей — здесь только нужные поля, чтобы\n * сетевой код игры не зависел от контракта).\n */\nexport interface NetRoom {\n readonly session: Session;\n readonly isHost: boolean;\n readonly lateJoin: boolean;\n readonly settings: Readonly<Record<string, string | boolean>>;\n leave(): Promise<void>;\n}\n\n/** Настройки комнаты, как их объявляет модуль (`registerGame.settings`): режим гостей и команды. */\nexport function roomSettingsOf(\n settings: Readonly<Record<string, string | boolean>>,\n): RoomSettings {\n return {\n guestMode: settings.guestMode === \"creative\" ? \"creative\" : \"survival\",\n cheatsForAll: settings.cheats === true,\n };\n}\n\n/** Всё, что знаем о другом игроке. */\ninterface Peer {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n pose: Pose | null;\n held: string | null;\n mode: \"survival\" | \"creative\";\n sleeping: boolean;\n avatar: Avatar | null;\n remote: RemotePlayer | null;\n}\n\n/** Хост: сейв игрока (его инвентарь авторитетен у него — здесь копия для мира хоста). */\ninterface HostRecord {\n slot: number;\n name: string;\n dim: DimensionId;\n op: boolean;\n state: unknown | null;\n}\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nconst EMPTY_DIM = (): DimensionSave => ({\n chunks: {},\n entities: [],\n blockEntities: [],\n});\n\nexport class NetGame implements RemoteSink {\n readonly session: Session;\n readonly selfId: string;\n slot = 0;\n settings: RoomSettings;\n /** Операторы (хост — всегда). */\n readonly ops = new Set<string>();\n readonly diffs = new DiffStore();\n /** Другие игроки по слоту. */\n readonly peers = new Map<number, Peer>();\n /** Владелец каждого живого измерения (userId). */\n readonly owners = new Map<DimensionId, string>();\n /** Хост ушёл — кто подхватит (объявляет хост в списке игроков). */\n successor: string | null = null;\n\n // ---- только у хоста\n private readonly records = new Map<string, HostRecord>();\n private nextSlot = 1;\n /** Измерения без владельца: сущности и блочные сущности (дифы — в `diffs`). */\n private readonly frozen = new Map<DimensionId, DimensionSave>();\n /** Блочные сущности всех измерений (для вошедшего в измерение гостя). */\n private readonly beStore = new Map<DimensionId, Map<string, BeMsg[\"be\"]>>();\n\n // ---- владелец/реплика своего измерения\n private readonly bc = new OwnerBroadcast();\n /** Реплика: что последнее прислал владелец по блочным сущностям — своё отличие уходит ему. */\n private readonly replicaBe = new OwnerBroadcast();\n private pending: BlockDelta[] = [];\n private applyingRemote = false;\n private tickN = 0;\n private lastMeta = \"\";\n private shadow: Shadow | null = null;\n private hostSilentSince = 0;\n private readonly offs: (() => void)[] = [];\n private closed = false;\n /** Гость: ждём снимок мира. */\n private welcomeWait: ((w: Welcome | string) => void) | null = null;\n /** Очередь обработки надёжного канала (см. конструктор). */\n private inbox: Promise<void> = Promise.resolve();\n /** Гость получил снимок мира (хост — всегда). До него сообщения копятся в `stash`. */\n private welcomed = true;\n private readonly stash: [string, Uint8Array][] = [];\n /** Владелец измерения: занятые контейнеры — ключ (LockMsg.key) → слот игрока. */\n private readonly locks = new Map<string, number>();\n /** Свои запросы контейнера, ждущие ответа владельца. */\n private readonly lockWait = new Map<\n string,\n { open: () => void; dim: DimensionId; timer: ReturnType<typeof setTimeout> }\n >();\n /** Реплика: id сущности, в которой сидит свой игрок (её ведёт он). */\n private driving: number | null = null;\n\n private constructor(\n readonly game: Game,\n session: Session,\n selfId: string,\n settings: RoomSettings,\n readonly name: string,\n /** Уйти из комнаты — через лобби: оно владеет комнатой и забывает её вместе с сессией. */\n readonly leaveRoom: () => Promise<void>,\n ) {\n this.session = session;\n this.selfId = selfId;\n this.settings = settings;\n this.offs.push(\n session.on(\"message\", ({ from, channel, data }) => {\n // Надёжный канал — строго по порядку: сжатое сообщение распаковывается асинхронно, и без\n // очереди следующее («удалить сущность») обогнало бы предыдущее («создать»). Быстрый канал\n // (позы) порядка не требует и ждать не должен.\n if (channel === \"fast\") void this.onMessage(from, channel, data);\n else\n this.inbox = this.inbox.then(() =>\n this.onMessage(from, channel, data),\n );\n }),\n session.on(\"peerOpen\", ({ peerId }) => this.onPeerOpen(peerId)),\n session.on(\"peerClose\", ({ peerId }) => this.onPeerClose(peerId)),\n session.on(\"memberLeft\", ({ userId }) => this.onMemberLeft(userId)),\n session.on(\"hostChanged\", ({ hostId, previousHostId }) =>\n this.onHostChanged(hostId, previousHostId),\n ),\n session.on(\"chat\", ({ text }) => game.chat.print(text)),\n // исключили: сервер отвечает «не участник» (после уведомления хоста) или прислал PlayerLeft kicked\n session.on(\"removed\", ({ reason }) =>\n this.end(\n /kicked|not a member|removed/i.test(reason)\n ? \"Вас исключили из игры\"\n : \"Связь с игрой потеряна\",\n ),\n ),\n session.on(\"roomClosed\", () => this.end(\"Игра закрыта\")),\n );\n // Реестр порталов общий: зажёг портал любой — знают все (выход на той стороне ищется по нему).\n game.onPortalChange = (dim, pos, add) => {\n const msg = encodeJsonSync(M.PORTAL, {\n dim,\n p: pos,\n add,\n } satisfies PortalMsg);\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n };\n // Правки мира: владелец рассылает все (свои, каскады, пришедшие от реплик), реплика — только свои.\n this.offs.push(\n events.on(\n \"blockChanged\",\n (d: { x: number; y: number; z: number; id: number; meta: number }) => {\n if (this.applyingRemote && !sim.authority) return;\n this.diffs.apply(this.game.dimension, d.x, d.y, d.z, d.id, d.meta);\n this.pending.push({ x: d.x, y: d.y, z: d.z, id: d.id, meta: d.meta });\n },\n ),\n );\n }\n\n get isHost(): boolean {\n return this.session.hostId === this.selfId;\n }\n\n get hostId(): string {\n return this.session.hostId;\n }\n\n /** Может ли этот игрок командами менять мир (хост и операторы, или «команды всем»). */\n get canCheat(): boolean {\n return (\n this.isHost || this.ops.has(this.selfId) || this.settings.cheatsForAll\n );\n }\n\n // =========================================================================== старт\n\n /**\n * Комната пришла из лобби (система мультиплеера, `ctx.multiplayer`): её создали и в неё вошли там,\n * соединения уже открыты — здесь только начинается игра поверх готовой сессии. Хост, запустивший\n * игру, открывает свой мир для остальных; гость (и опоздавший) получает мир хоста.\n */\n static async fromRoom(\n game: Game,\n room: NetRoom,\n fallbackName: string,\n ): Promise<NetGame | string> {\n const session = room.session;\n // имя — публичный профиль игрока в этом тайтле (его кладёт в снимок комнаты сервер)\n const name = session.members.get(session.selfId)?.Username || fallbackName;\n if (room.isHost && !room.lateJoin) {\n const net = new NetGame(\n game,\n session,\n session.selfId,\n roomSettingsOf(room.settings),\n name,\n room.leave,\n );\n net.ops.add(net.selfId);\n net.startAsHost();\n return net;\n }\n const net = new NetGame(\n game,\n session,\n session.selfId,\n { guestMode: \"survival\", cheatsForAll: false },\n name,\n room.leave,\n );\n net.welcomed = false;\n const w = await new Promise<Welcome | string>((resolve) => {\n net.welcomeWait = resolve;\n setTimeout(() => resolve(\"Хост не ответил\"), 30_000);\n if (session.isOpen(session.hostId)) net.sendHello();\n });\n net.welcomeWait = null;\n if (typeof w === \"string\") {\n net.dispose();\n await room.leave();\n return w;\n }\n net.applyWelcome(w);\n return net;\n }\n\n private startAsHost(): void {\n const g = this.game;\n this.slot = 0;\n this.records.set(this.selfId, {\n slot: 0,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n // Мир хоста целиком в хранилище дифов: живое измерение — с мира, остальные — из сейва измерений.\n this.diffs.load(g.dimension, g.world.serializeDiffs());\n for (const [d, save] of g.dims) {\n this.diffs.load(d, save.chunks);\n this.frozen.set(d, {\n chunks: {},\n entities: save.entities ?? [],\n blockEntities: save.blockEntities ?? [],\n });\n }\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(null);\n }\n\n // =========================================================================== отправка\n\n private async send(\n to: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (to === this.selfId) {\n await this.onMessage(this.selfId, channel, data);\n return;\n }\n this.session.send(to, channel, data);\n }\n\n /** Хосту (у хоста — себе). */\n private toHost(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n void this.send(this.hostId, channel, data);\n }\n\n /** Хост: всем, кроме `except`. */\n private toAll(\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n this.session.broadcast(channel, data, except);\n }\n\n /** Хост: всем игрокам измерения, кроме `except` (себе — тоже, если хост там же). */\n private toDim(\n dim: DimensionId,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n for (const [uid, r] of this.records)\n if (r.dim === dim && uid !== except) void this.send(uid, channel, data);\n }\n\n private userOfSlot(slot: number): string | null {\n if (slot === this.slot) return this.selfId;\n return this.peers.get(slot)?.userId ?? null;\n }\n\n // =========================================================================== приём\n\n private async onMessage(\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (this.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!this.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") this.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return this.onFast(from, type, data);\n switch (type) {\n case M.HELLO:\n if (this.isHost) await this.onHello(from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && this.welcomeWait) this.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n this.welcomeWait?.(r?.reason ?? \"Хост отказал во входе\");\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !this.isHost) this.applyRoster(r);\n return;\n }\n case M.PMETA:\n return this.onMeta(from, data);\n case M.BLOCKS:\n case M.EDITS:\n return this.onBlocks(from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return this.onEntities(from, type, data);\n case M.ENT_ACTION:\n return this.onEntAction(from, data);\n case M.BE:\n return this.onBe(from, data);\n case M.LOCK:\n return this.onLock(from, data);\n case M.REPLY:\n return this.onReply(from, data);\n case M.PORTAL:\n return this.onPortal(from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === this.game.dimension && !sim.authority)\n for (const b of m.list) this.applyBe(b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !this.isHost) {\n this.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n this.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return this.onChat(from, data);\n case M.HIT:\n case M.GIVE:\n return this.onToPlayer(from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = this.records.get(from);\n if (s && r && this.isHost) {\n r.state = s.state;\n this.game.mergeBeyond(\n (s.state as { beyond?: unknown } | null)?.beyond,\n );\n }\n return;\n }\n case M.DIM_ENTER:\n if (this.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) this.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === this.game.dimension) this.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) this.bc.reset();\n return;\n case M.SHADOW:\n this.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && this.isHost && this.mayCheat(from))\n this.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n }\n\n private onFast(from: string, type: number, data: Uint8Array): void {\n if (type === M.POSE) {\n if (this.isHost && from !== this.selfId) {\n const r = this.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n this.toAll(\"fast\", stamped, from);\n this.applyPose(pose);\n return;\n }\n if (!this.isHost) this.applyPose(decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (this.isHost && from !== this.selfId)\n this.toDim(dim, \"fast\", data, from);\n if (dim === this.game.dimension && !sim.authority && from !== this.selfId)\n for (const p of decodeEntPoses(data).list)\n this.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n }\n\n // ---- вход гостя (хост)\n\n private sendHello(): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: this.name };\n this.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n }\n\n private async onHello(from: string, data: Uint8Array): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await this.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: \"Версии игры отличаются — обновите страницу\",\n }),\n );\n return;\n }\n const g = this.game;\n let r = this.records.get(from);\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: this.nextSlot++,\n name: (h.name || \"Игрок\").slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: this.ops.has(from),\n state: saved,\n };\n this.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)\n ?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: this.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: this.roster(),\n };\n await this.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n this.broadcastRoster();\n this.enterDim(from, r.dim);\n }\n\n // ---- поза и метаданные\n\n private applyPose(p: Pose): void {\n if (p.slot === this.slot) return;\n const peer = this.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === this.game.dimension && p.dim !== peer.dim && sim.authority)\n this.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === this.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name);\n this.game.scene.add(peer.avatar.group);\n }\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(\n this.game.world,\n p.slot,\n peer.userId,\n this,\n );\n peer.remote.world = this.game.world;\n peer.remote.held = peer.held;\n peer.remote.applyPose(p);\n }\n } else this.dropPeerView(peer);\n }\n\n private dropPeerView(peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(this.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n }\n\n private async onMeta(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (this.isHost && from !== this.selfId) {\n const r = this.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n this.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n this.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? this.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n }\n\n // ---- блоки\n\n private onBlocks(from: string, type: number, data: Uint8Array): void {\n const { dim, list } = decodeBlocks(data);\n if (this.isHost && from !== this.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (this.owners.get(dim) !== from) return;\n this.diffs.applyAll(dim, list);\n this.toAll(\"rel\", data, from);\n } else {\n const owner = this.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n this.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n this.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (this.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = this.owners.get(dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== this.game.dimension) return;\n this.applyBlocks(list, true);\n return;\n }\n if (from === this.selfId) return;\n if (!this.isHost) this.diffs.applyAll(dim, list);\n if (dim === this.game.dimension && !sim.authority)\n this.applyBlocks(list, false);\n }\n\n private applyBlocks(list: readonly BlockDelta[], asOwner: boolean): void {\n const w = this.game.world;\n this.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n this.applyingRemote = false;\n }\n }\n\n // ---- сущности\n\n private async onEntities(\n from: string,\n type: number,\n data: Uint8Array,\n ): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (this.isHost && from !== this.selfId)\n this.toDim(m.dim, \"rel\", data, from);\n if (\n m.dim === this.game.dimension &&\n !sim.authority &&\n from !== this.selfId\n )\n for (const [id, snap] of m.list)\n this.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (this.isHost && from !== this.selfId) this.toDim(dim, \"rel\", data, from);\n if (dim === this.game.dimension && !sim.authority && from !== this.selfId)\n for (const id of ids) this.game.entities.netRemove(id);\n }\n\n private async onEntAction(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (this.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === this.selfId ? this.slot : this.records.get(from)?.slot;\n const owner = this.owners.get(m.dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== this.game.dimension) return;\n const em = this.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker =\n m.from !== undefined ? this.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n this.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n }\n\n /** Реплика: действие над сущностью — владельцу измерения (через хоста). */\n private entAction(action: NetEntityAction): void {\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: this.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n }\n\n // ---- контейнеры: один игрок — один открытый контейнер\n\n /**\n * Открыть контейнер (сундук, печь, вьюк, груз вагонетки, торговлю с жителем). Владелец измерения\n * решает сам, реплика спрашивает его и открывает по ответу — с его содержимым. Занят — подсказка.\n */\n openContainer(key: string, open: () => void): void {\n const g = this.game;\n if (sim.authority) {\n const by = this.lockBy(key, this.slot);\n if (by) return this.busyHint(by);\n this.locks.set(key, this.slot);\n open();\n return;\n }\n if (this.lockWait.has(key)) return;\n const timer = setTimeout(() => {\n if (this.lockWait.delete(key))\n g.hud.message(\"Нет ответа от игрока, у которого это измерение\", 2200);\n }, 4000);\n this.lockWait.set(key, { open, dim: g.dimension, timer });\n this.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: g.dimension,\n key,\n open: true,\n } satisfies LockMsg),\n );\n }\n\n /** Экран контейнера закрыт — отпустить. */\n releaseContainer(key: string): void {\n if (sim.authority) {\n if (this.locks.get(key) === this.slot) this.locks.delete(key);\n return;\n }\n this.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: this.game.dimension,\n key,\n open: false,\n } satisfies LockMsg),\n );\n }\n\n private busyHint(by: string): void {\n this.game.hud.message(`Сейчас открыто у игрока ${by}`, 2000);\n }\n\n /** Владелец: имя того, кто держит контейнер (если не `slot` и он всё ещё в этом измерении). */\n private lockBy(key: string, slot: number): string | null {\n const cur = this.locks.get(key);\n if (cur === undefined || cur === slot) return null;\n if (cur === this.slot) return this.name;\n const peer = this.peers.get(cur);\n if (peer && peer.dim === this.game.dimension) return peer.name;\n this.locks.delete(key); // держатель ушёл (из измерения или из игры)\n return null;\n }\n\n private async onLock(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<LockMsg>(data);\n if (!m || typeof m.key !== \"string\") return;\n if (this.isHost) {\n m.from = from === this.selfId ? this.slot : this.records.get(from)?.slot;\n const owner = this.owners.get(m.dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", encodeJsonSync(M.LOCK, m));\n return;\n }\n }\n const slot = m.from;\n if (!sim.authority || m.dim !== this.game.dimension || slot === undefined)\n return;\n if (!m.open) {\n if (this.locks.get(m.key) === slot) this.locks.delete(m.key);\n return;\n }\n const by = this.lockBy(m.key, slot);\n const reply: ReplyMsg = {\n to: slot,\n kind: \"lock\",\n key: m.key,\n ok: !by,\n ...(by ? { by } : {}),\n };\n if (!by) {\n this.locks.set(m.key, slot);\n // блочная сущность — отсюда: сундук постройки получает добычу здесь, у реплики катилась бы своя\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b) {\n const be = this.game.blockEntities.ensure(+b[1]!, +b[2]!, +b[3]!);\n reply.be = be\n ? {\n type: be.type,\n x: be.x,\n y: be.y,\n z: be.z,\n state: JSON.parse(JSON.stringify(be.state)) as typeof be.state,\n }\n : null;\n }\n }\n this.replyTo(reply);\n }\n\n /** Ответ игроку `to`: себе — сразу, иначе через хоста (или хост — прямо ему). */\n private replyTo(r: ReplyMsg): void {\n if (r.to === this.slot) {\n this.handleReply(r);\n return;\n }\n const msg = encodeJsonSync(M.REPLY, r);\n if (this.isHost) {\n const to = this.userOfSlot(r.to);\n if (to) void this.send(to, \"rel\", msg);\n } else this.toHost(\"rel\", msg);\n }\n\n private async onReply(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<ReplyMsg>(data);\n if (!m) return;\n if (this.isHost && m.to !== this.slot) {\n const to = this.userOfSlot(m.to);\n if (to && to !== from) void this.send(to, \"rel\", data);\n return;\n }\n if (m.to === this.slot) this.handleReply(m);\n }\n\n private handleReply(m: ReplyMsg): void {\n const g = this.game;\n if (m.kind === \"ride\") {\n const r = g.player.riding as unknown as { id?: number } | null;\n if (!m.ok && r && r.id === m.id) {\n g.player.dismount();\n g.hud.message(\"Место уже занято\", 1600);\n }\n return;\n }\n const w = m.key ? this.lockWait.get(m.key) : undefined;\n if (!w || !m.key) return;\n clearTimeout(w.timer);\n this.lockWait.delete(m.key);\n if (!m.ok) return this.busyHint(m.by ?? \"?\");\n if (w.dim !== g.dimension) {\n this.releaseContainer(m.key);\n return;\n }\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b && m.be !== undefined)\n this.applyBe({\n dim: g.dimension,\n x: +b[1]!,\n y: +b[2]!,\n z: +b[3]!,\n be: m.be,\n });\n w.open();\n }\n\n // ---- порталы\n\n private async onPortal(from: string, data: Uint8Array): Promise<void> {\n if (from === this.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (this.isHost) this.toAll(\"rel\", data, from);\n this.game.applyNetPortal(m.dim, m.p, !!m.add);\n }\n\n // ---- блочные сущности\n\n private async onBe(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = this.owners.get(m.dim);\n if (this.isHost && from !== this.selfId) {\n if (owner === from) {\n this.storeBe(m);\n this.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n } else if (this.isHost && owner && owner !== this.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void this.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== this.game.dimension || from === this.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n this.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else this.applyBe(m);\n }\n\n private applyBe(m: BeMsg): void {\n this.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n this.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n }\n\n private storeBe(m: BeMsg): void {\n let s = this.beStore.get(m.dim);\n if (!s) this.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n }\n\n // ---- игроку: урон, предметы\n\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: Hit[\"effect\"],\n ): void {\n const h: Hit = {\n to: slot,\n amount,\n cause,\n ...(knock ? { knock } : {}),\n ...(effect ? { effect } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.HIT, h));\n }\n\n give(slot: number, stack: ItemStack | null, xp?: number): void {\n const g: Give = {\n to: slot,\n ...(stack ? { stack } : {}),\n ...(xp ? { xp } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.GIVE, g));\n }\n\n private async onToPlayer(\n from: string,\n type: number,\n data: Uint8Array,\n ): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (this.isHost && m.to !== this.slot) {\n const to = this.userOfSlot(m.to);\n if (to && to !== from) void this.send(to, \"rel\", data);\n return;\n }\n if (m.to !== this.slot) return;\n const g = this.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n }\n\n // ---- чат\n\n sendChat(text: string): void {\n const t = text.slice(0, 256);\n this.game.chat.print(`<${this.name}> ${t}`);\n this.toHost(\"rel\", encodeJsonSync(M.CHAT, { from: this.slot, text: t }));\n }\n\n private async onChat(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === this.selfId) return;\n if (this.isHost) {\n m.from = this.records.get(from)?.slot;\n this.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who =\n m.from !== undefined ? (this.peers.get(m.from)?.name ?? \"?\") : \"?\";\n this.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n }\n\n // =========================================================================== владение измерением\n\n /** Стать владельцем своего измерения: сущности и блочные сущности — из переданного сохранения. */\n private becomeOwner(save: DimensionSave | null): void {\n const g = this.game;\n sim.authority = true;\n g.entities.replica = false;\n g.entities.localSlot = this.slot;\n this.driving = null;\n g.entities.drivenId = null;\n this.locks.clear();\n if (save) {\n g.entities.deserialize(\n (save.entities ?? []) as Parameters<typeof g.entities.deserialize>[0],\n );\n g.blockEntities.deserialize(save.blockEntities ?? []);\n }\n this.bc.reset();\n for (const peer of this.peers.values())\n if (peer.pose && peer.dim === g.dimension && !peer.remote) {\n peer.remote = new RemotePlayer(g.world, peer.slot, peer.userId, this);\n peer.remote.applyPose(peer.pose);\n }\n g.entities.others = () => this.remoteActors();\n }\n\n /** Стать репликой: сущности — образы от владельца, локальные id — в отдельном диапазоне. */\n private becomeReplica(): void {\n const g = this.game;\n sim.authority = false;\n g.entities.replica = true;\n g.entities.localSlot = this.slot;\n this.driving = null;\n this.locks.clear();\n g.entities._nextId = Math.max(g.entities._nextId, 1_000_000_000);\n g.entities.clear();\n g.entities.others = () => [];\n g.entities.onLocalSpawn = (snap) =>\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action: { a: \"spawn\", snap },\n } satisfies EntActionMsg),\n );\n g.entities.onRemoteAction = (action) =>\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action,\n } satisfies EntActionMsg),\n );\n for (const peer of this.peers.values()) peer.remote = null;\n }\n\n private remoteActors() {\n const out = [];\n for (const peer of this.peers.values())\n if (peer.remote && peer.dim === this.game.dimension && peer.pose)\n out.push({\n player: peer.remote as RemotePlayer,\n inventory: remoteInventory(peer.remote, this),\n });\n return out;\n }\n\n /**\n * Перед сменой измерения (начало Game.changeDimension): у всех есть дифы любого измерения, так\n * что целевое строится без ожидания. Сущности поднимаем, только если станем владельцем сразу\n * (хост, измерение ничьё); иначе их пришлёт владелец или хост отдаст владение.\n */\n beforeDimension(target: DimensionId): void {\n const g = this.game;\n const iOwnTarget = this.isHost && !this.owners.get(target);\n const frozen = this.frozen.get(target);\n g.dims.set(target, {\n chunks: this.diffs.toChunkDiffMap(target),\n entities: iOwnTarget ? (frozen?.entities ?? []) : [],\n blockEntities: iOwnTarget\n ? (frozen?.blockEntities ?? [])\n : [...(this.beStore.get(target)?.values() ?? [])].filter(Boolean),\n });\n if (iOwnTarget) this.frozen.delete(target);\n this.pendingOwnTarget = iOwnTarget;\n }\n private pendingOwnTarget = false;\n\n /**\n * Середина Game.changeDimension: прежнее измерение уже снято в `dims` (с сущностями — для\n * передачи следующему владельцу), новое ещё не загружено. Здесь переключается роль: реплике нельзя\n * поднимать сущности из сохранения — её spawn ушёл бы владельцу, а сущности там и так есть.\n */\n prepareDimension(): void {\n if (this.pendingOwnTarget) {\n sim.authority = true;\n this.game.entities.replica = false;\n } else this.becomeReplica();\n }\n\n /** После смены измерения: отдать прежнее (если владел) и заявить новое. */\n afterDimension(prev: DimensionId, target: DimensionId): void {\n const g = this.game;\n const wasOwner = this.owners.get(prev) === this.selfId;\n const handoff = wasOwner ? g.dims.get(prev) : undefined;\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n if (this.isHost) {\n const r = this.records.get(this.selfId)!;\n r.dim = target;\n if (handoff)\n this.frozen.set(prev, {\n chunks: {},\n entities: handoff.entities ?? [],\n blockEntities: handoff.blockEntities ?? [],\n });\n if (wasOwner) this.owners.delete(prev);\n this.assignOwner(prev);\n if (!this.owners.get(target)) {\n this.owners.set(target, this.selfId);\n this.becomeOwner(null); // сущности уже подняты changeDimension из g.dims\n } else this.enterDim(this.selfId, target);\n this.broadcastRoster();\n return;\n }\n // Синхронно и без сжатия: следом пойдут правки нового измерения (портал на той стороне), и по\n // упорядоченному каналу хост обязан узнать о входе РАНЬШЕ — иначе правки ничейного измерения\n // он бы выбросил.\n const msg: DimEnter = {\n dim: target,\n ...(handoff ? { handoff: { dim: prev, save: handoff } } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.DIM_ENTER, msg));\n // сейв игрока у хоста — сразу, а не через 10 с: уйди хост раньше, гость вернулся бы в прежнее\n // измерение на прежнее место\n this.sendPlayerState();\n }\n\n /** Хост: игрок вошёл в измерение — владельцем или репликой. */\n private onDimEnter(from: string, m: DimEnter): void {\n const r = this.records.get(from);\n if (!r) return;\n const prev = r.dim;\n r.dim = m.dim;\n if (m.handoff && this.owners.get(m.handoff.dim) === from) {\n this.frozen.set(m.handoff.dim, {\n chunks: {},\n entities: m.handoff.save.entities ?? [],\n blockEntities: m.handoff.save.blockEntities ?? [],\n });\n for (const b of m.handoff.save.blockEntities ?? []) {\n const be = b as BeMsg[\"be\"];\n if (be)\n this.storeBe({ dim: m.handoff.dim, x: be.x, y: be.y, z: be.z, be });\n }\n this.owners.delete(m.handoff.dim);\n }\n this.assignOwner(prev);\n this.enterDim(from, m.dim);\n this.broadcastRoster();\n }\n\n /** Хост: `who` в измерении `dim` — дать владение (если ничьё) или полный снимок от владельца. */\n private enterDim(who: string, dim: DimensionId): void {\n const owner = this.owners.get(dim);\n if (!owner) {\n this.owners.set(dim, who);\n const save = this.frozen.get(dim) ?? EMPTY_DIM();\n this.frozen.delete(dim);\n if (who === this.selfId) this.becomeOwner(save);\n // синхронно: по упорядоченному каналу владение обязано прийти раньше пересланных следом правок\n else\n void this.send(\n who,\n \"rel\",\n encodeJsonSync(M.DIM_OWN, { dim, save } satisfies DimOwn),\n );\n return;\n }\n if (owner === who) return;\n void this.send(owner, \"rel\", encodeJsonSync(M.DIM_RESYNC, { dim }));\n const list = [...(this.beStore.get(dim) ?? new Map())].map(([k, be]) => {\n const [x, y, z] = k.split(\",\").map(Number) as [number, number, number];\n return { dim, x, y, z, be } satisfies BeMsg;\n });\n // синхронно: следом могут пойти свежие изменения тех же сундуков, старый список не должен их затереть\n if (who !== this.selfId && list.length)\n void this.send(who, \"rel\", encodeJsonSync(M.BE_LIST, { dim, list }));\n }\n\n /** Хост: у измерения нет владельца или он ушёл — отдать следующему игроку в нём, иначе заморозить. */\n private assignOwner(dim: DimensionId): void {\n const owner = this.owners.get(dim);\n if (owner && this.records.get(owner)?.dim === dim) return;\n this.owners.delete(dim);\n const next = [...this.records.entries()]\n .filter(([, r]) => r.dim === dim)\n .sort((a, b) => a[1].slot - b[1].slot)[0];\n if (next) this.enterDim(next[0], dim);\n }\n\n // =========================================================================== список игроков\n\n private roster(): Roster {\n return {\n hostId: this.selfId,\n players: [...this.records.entries()].map(([userId, r]) => ({\n slot: r.slot,\n userId,\n name: r.name,\n dim: r.dim,\n op: this.ops.has(userId),\n })),\n owners: Object.fromEntries(this.owners),\n settings: this.settings,\n ...(this.successor ? { successor: this.successor } : {}),\n } as Roster;\n }\n\n private broadcastRoster(): void {\n if (!this.isHost) return;\n const r = this.roster();\n this.applyRoster(r);\n this.toAll(\"rel\", encodeJsonSync(M.ROSTER, r));\n }\n\n private applyRoster(r: Roster): void {\n this.settings = r.settings;\n this.ops.clear();\n const seen = new Set<number>();\n for (const p of r.players) {\n if (p.op) this.ops.add(p.userId);\n if (p.userId === this.selfId) {\n this.slot = p.slot;\n continue;\n }\n seen.add(p.slot);\n const cur = this.peers.get(p.slot);\n if (cur) {\n cur.name = p.name;\n cur.op = p.op;\n cur.dim = p.dim;\n } else\n this.peers.set(p.slot, {\n slot: p.slot,\n userId: p.userId,\n name: p.name,\n dim: p.dim,\n op: p.op,\n pose: null,\n held: null,\n mode: \"survival\",\n sleeping: false,\n avatar: null,\n remote: null,\n });\n }\n for (const [slot, peer] of [...this.peers])\n if (!seen.has(slot)) {\n this.dropPeerView(peer);\n this.peers.delete(slot);\n }\n // владелец моего измерения сменился — старые образы сущностей больше не обновятся\n const ownerHere = r.owners[this.game.dimension];\n const prevOwner = this.owners.get(this.game.dimension);\n this.owners.clear();\n for (const [d, u] of Object.entries(r.owners))\n if (u) this.owners.set(d as DimensionId, u);\n if (\n !this.isHost &&\n ownerHere !== prevOwner &&\n ownerHere !== this.selfId &&\n !sim.authority\n )\n this.game.entities.clear();\n const succ = (r as Roster & { successor?: string }).successor ?? null;\n if (succ !== this.successor) {\n this.successor = succ;\n // запасная связь с преемником: передача хоста не ждёт сигналинга\n if (succ === this.selfId)\n this.session.setExtraPeers(r.players.map((p) => p.userId));\n else this.session.setExtraPeers(succ ? [succ] : []);\n }\n events.emit(\"net:roster\");\n }\n\n /** Игроки для списка по Tab. */\n playerList(): {\n name: string;\n slot: number;\n userId: string;\n host: boolean;\n op: boolean;\n dim: DimensionId;\n rtt: number;\n }[] {\n const out = [\n {\n name: this.name,\n slot: this.slot,\n userId: this.selfId,\n host: this.isHost,\n op: this.ops.has(this.selfId),\n dim: this.game.dimension,\n rtt: 0,\n },\n ];\n for (const p of this.peers.values())\n out.push({\n name: p.name,\n slot: p.slot,\n userId: p.userId,\n host: p.userId === this.hostId,\n op: p.op,\n dim: p.dim,\n rtt: this.session.linkStats(p.userId)?.rttMs ?? 0,\n });\n return out.sort((a, b) => a.slot - b.slot);\n }\n\n // =========================================================================== тик и кадр\n\n /** Конец симтика Game: разослать накопленное. */\n afterTick(): void {\n if (this.closed) return;\n const g = this.game;\n this.tickN++;\n const dim = g.dimension;\n\n if (this.pending.length) {\n const list = this.pending;\n this.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (this.isHost && type === M.BLOCKS) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) this.entAction(a);\n this.syncRide();\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of this.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) this.hit(peer.slot, 0, \"knock\", imp);\n }\n if (this.tickN % 2 === 0 && this.hasOthersIn(dim)) {\n const d = this.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n this.ownerSend(\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) this.ownerSend(\"fast\", b);\n if (d.removes.length)\n this.ownerSend(\"rel\", encodeEntRemove(dim, d.removes));\n }\n if (this.tickN % 10 === 0)\n for (const { be, key } of this.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (this.isHost) this.storeBe(msg);\n if (this.hasOthersIn(dim))\n this.ownerSend(\"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (this.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of this.replicaBe.diffBlockEntities(\n g.blockEntities,\n )) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n this.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n this.sendPose();\n this.sendMetaIfChanged();\n if (this.tickN % 200 === 0 && !this.isHost) this.sendPlayerState();\n\n if (this.isHost) {\n if (this.tickN % 100 === 0)\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (this.tickN % 200 === 0) this.pickSuccessorAndShadow();\n } else this.watchHost();\n }\n\n /**\n * Реплика в транспорте или верхом: сущность ведёт этот игрок (EntityManager.drivenId), владелец\n * её не тикает и получает позу 10 раз в секунду. Сел/слез — сказать владельцу.\n */\n private syncRide(): void {\n const g = this.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== this.driving) {\n if (this.driving !== null)\n this.entAction({ a: \"ride\", id: this.driving, on: false });\n if (id !== null) this.entAction({ a: \"ride\", id, on: true });\n this.driving = id;\n em.drivenId = id;\n }\n if (id === null || this.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n this.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n }\n\n /** Владелец рассылает игрокам измерения: хост — сам, гость — через хоста. */\n private ownerSend(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n if (this.isHost)\n this.toDim(this.game.dimension, channel, data, this.selfId);\n else this.toHost(channel, data);\n }\n\n private hasOthersIn(dim: DimensionId): boolean {\n for (const p of this.peers.values()) if (p.dim === dim) return true;\n return false;\n }\n\n private sendPose(): void {\n const g = this.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0);\n const msg = encodePose({\n slot: this.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (this.isHost) this.toAll(\"fast\", msg);\n else this.toHost(\"fast\", msg);\n }\n\n private sendMetaIfChanged(): void {\n const g = this.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: this.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: this.name,\n sleeping: g.netSleeping,\n };\n const j = JSON.stringify(meta);\n if (j === this.lastMeta) return;\n this.lastMeta = j;\n if (this.isHost) {\n this.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n this.hostSleepCheck(this.selfId, meta);\n } else this.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n }\n\n /** Сон как в Minecraft: ночь пропускается, когда спят ВСЕ игроки Верхнего мира. */\n private hostSleepCheck(_who: string, _m: PlayerMeta): void {\n const g = this.game;\n const inOverworld = [...this.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === this.selfId\n ? g.netSleeping\n : ([...this.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n }\n\n frame(dt: number): void {\n for (const peer of this.peers.values()) peer.avatar?.frame(dt);\n }\n\n // =========================================================================== команды\n\n private mayCheat(userId: string): boolean {\n return (\n userId === this.selfId ||\n this.ops.has(userId) ||\n this.settings.cheatsForAll\n );\n }\n\n /**\n * Команда в сетевой игре: «про себя» — здесь, «про мир» — у хоста, без прав — отказ.\n * Возвращает null (исполнять локально) или текст ответа.\n */\n routeCommand(name: string, text: string): string | null {\n const harmless = [\"help\", \"seed\", \"locate\", \"achievements\", \"music\"];\n if (harmless.includes(name)) return null;\n if (name === \"newworld\")\n return \"В сетевой игре новый мир не создать — сначала выйдите из неё\";\n if (!this.canCheat) return \"Команды в этой игре — только у операторов\";\n if ([\"time\", \"weather\", \"difficulty\"].includes(name) && !this.isHost) {\n this.toHost(\"rel\", encodeJsonSync(M.COMMAND, { text }));\n return \"Команда отправлена хосту\";\n }\n return null;\n }\n\n /** Хост: выдать или снять права оператора. */\n setOp(userId: string, op: boolean): void {\n if (!this.isHost || userId === this.selfId) return;\n if (op) this.ops.add(userId);\n else this.ops.delete(userId);\n const r = this.records.get(userId);\n if (r) r.op = op;\n this.broadcastRoster();\n }\n\n async kick(userId: string, ban: boolean): Promise<string | null> {\n const res = await this.session.kick(userId, ban);\n return res.ok ? null : res.error;\n }\n\n // =========================================================================== хост ушёл\n\n /** Хост: выбрать преемника (лучшая связь) и отправить ему теневую копию мира. */\n private pickSuccessorAndShadow(): void {\n let best: string | null = null,\n bestRtt = Infinity;\n for (const peer of this.peers.values()) {\n const s = this.session.linkStats(peer.userId);\n if (!s || !this.session.isOpen(peer.userId)) continue;\n const rtt = s.rttMs || 999;\n if (rtt < bestRtt) {\n bestRtt = rtt;\n best = peer.userId;\n }\n }\n if (best !== this.successor) {\n this.successor = best;\n this.session.setSuccessor(best);\n this.broadcastRoster();\n }\n if (!best) return;\n const g = this.game;\n const dims: Partial<Record<DimensionId, DimensionSave>> = {};\n for (const [d, s] of this.frozen) dims[d] = s;\n if (this.owners.get(g.dimension) === this.selfId)\n dims[g.dimension] = {\n chunks: {},\n entities: g.entities.serialize(),\n blockEntities: g.blockEntities.serialize(),\n };\n const players: Record<string, unknown> = {};\n for (const [uid, r] of this.records)\n players[uid] = uid === this.selfId ? g.netPlayerState() : r.state;\n const shadow: Shadow = {\n world: g.netWorldMeta(),\n dims,\n players,\n settings: this.settings,\n ops: [...this.ops],\n };\n void encodeJson(M.SHADOW, shadow).then((b) => this.send(best!, \"rel\", b));\n }\n\n private onPeerOpen(peerId: string): void {\n if (!this.isHost && peerId === this.hostId) {\n if (this.welcomeWait) this.sendHello();\n else {\n // новый хост после передачи: представиться заново\n this.lastMeta = \"\";\n this.sendPlayerState();\n }\n }\n }\n\n /** Гость: свой сейв хосту (он хранит его в своём мире). */\n private sendPlayerState(): void {\n if (this.isHost) return;\n void encodeJson(M.PSTATE, { state: this.game.netPlayerState() }).then((b) =>\n this.toHost(\"rel\", b),\n );\n }\n\n private onPeerClose(peerId: string): void {\n if (!this.isHost && peerId === this.hostId)\n this.hostSilentSince = Date.now();\n }\n\n private onMemberLeft(userId: string): void {\n for (const peer of this.peers.values())\n if (peer.userId === userId && sim.authority)\n this.game.entities.netReleaseDriver(peer.slot);\n const r = this.records.get(userId);\n if (this.isHost && r) {\n this.records.delete(userId);\n if (this.owners.get(r.dim) === userId) {\n this.owners.delete(r.dim);\n this.assignOwner(r.dim);\n }\n this.broadcastRoster();\n }\n for (const [slot, peer] of [...this.peers])\n if (peer.userId === userId) {\n this.dropPeerView(peer);\n this.peers.delete(slot);\n }\n events.emit(\"net:roster\");\n }\n\n /** Гость: связь с хостом пропала. Преемник через 5 с забирает роль (сервер проверит, что хост молчит). */\n private watchHost(): void {\n if (!this.hostSilentSince || this.successor !== this.selfId) return;\n if (this.session.isOpen(this.hostId)) {\n this.hostSilentSince = 0;\n return;\n }\n if (Date.now() - this.hostSilentSince < 5000 || this.tickN % 40 !== 0)\n return;\n void this.session.claimHost();\n }\n\n private onHostChanged(hostId: string, previousHostId: string | null): void {\n this.hostSilentSince = 0;\n this.hostIdBefore = previousHostId ?? \"\";\n if (hostId !== this.selfId) {\n // у нового хоста наш сейв из теневой копии (до 10 с давности); по запасной связи, уже открытой,\n // onPeerOpen не сработает — шлём сразу\n if (this.session.isOpen(hostId)) this.sendPlayerState();\n return;\n }\n this.promote();\n }\n\n /** Стал хостом: мир из теневой копии и своей копии дифов, прежние владельцы измерений — как были. */\n private promote(): void {\n const g = this.game;\n const sh = this.shadow;\n this.records.clear();\n this.records.set(this.selfId, {\n slot: this.slot,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n for (const peer of this.peers.values())\n if (this.session.members.has(peer.userId))\n this.records.set(peer.userId, {\n slot: peer.slot,\n name: peer.name,\n dim: peer.dim,\n op: peer.op,\n state: sh?.players[peer.userId] ?? null,\n });\n this.nextSlot = Math.max(this.slot, ...[...this.peers.keys()]) + 1;\n if (sh) {\n this.settings = sh.settings;\n for (const o of sh.ops) this.ops.add(o);\n for (const [d, s] of Object.entries(sh.dims) as [\n DimensionId,\n DimensionSave,\n ][])\n if (!this.owners.has(d) || this.owners.get(d) === this.hostIdBefore)\n this.frozen.set(d, s);\n g.applyNetWorldState({\n ...sh.world,\n difficulty: sh.world.difficulty,\n daysAwake: sh.world.daysAwake,\n } as WorldState);\n }\n this.ops.add(this.selfId);\n // владения прежнего хоста: его измерение — следующему там (может, мне), остальное — как было\n for (const [d, owner] of [...this.owners])\n if (!this.records.has(owner)) {\n this.owners.delete(d);\n this.assignOwner(d);\n }\n if (!this.owners.get(g.dimension)) {\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(this.frozen.get(g.dimension) ?? null);\n this.frozen.delete(g.dimension);\n }\n g.adoptNetWorldAsOwn();\n this.broadcastRoster();\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n g.hud.message(\"Хост ушёл — теперь мир у вас\", 3000);\n }\n private hostIdBefore = \"\";\n\n // =========================================================================== гость: вход\n\n private applyWelcome(w: Welcome): void {\n this.slot = w.you;\n this.applyRoster(w.roster);\n this.hostIdBefore = w.roster.hostId;\n for (const d of DIMS) this.diffs.load(d, w.diffs[d]);\n this.becomeReplica();\n this.game.loadNetWorld(w, this.diffs, this.settings);\n // правки, накопленные, пока шёл вход, — из СВОЕГО одиночного мира: хосту их слать нельзя\n this.pending = [];\n this.welcomed = true;\n // отложенное — по порядку прихода; правки из снимка применятся повторно, это безвредно\n const stash = this.stash.splice(0);\n for (const [from, data] of stash)\n this.inbox = this.inbox.then(() => this.onMessage(from, \"rel\", data));\n }\n\n // =========================================================================== выход\n\n /** Хост уходит: свежая теневая копия преемнику, и сервер отдаст роль именно ему. */\n prepareExit(): void {\n if (this.closed || !this.isHost) return;\n this.pickSuccessorAndShadow();\n this.session.setSuccessor(this.successor);\n }\n\n /** Хост: сейвы других игроков — в мир хоста (Game.persist). */\n collectPlayerStates(into: Map<string, unknown>): void {\n if (!this.isHost) return;\n for (const [uid, r] of this.records)\n if (uid !== this.selfId && r.state) into.set(uid, r.state);\n }\n\n /** Уйти из сетевой игры (кнопка). Хост передаёт мир преемнику. */\n async leave(): Promise<void> {\n if (this.closed) return;\n this.prepareExit();\n this.dispose();\n await this.leaveRoom();\n this.game.exitNet(null);\n }\n\n private end(reason: string): void {\n if (this.closed) return;\n this.dispose();\n this.game.exitNet(reason);\n }\n\n dispose(): void {\n if (this.closed) return;\n this.closed = true;\n for (const off of this.offs.splice(0)) off();\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n this.peers.clear();\n const g = this.game;\n g.entities.replica = false;\n g.entities.others = () => [];\n g.entities.onLocalSpawn = null;\n g.entities.onRemoteAction = null;\n g.entities.localSlot = null;\n g.entities.drivenId = null;\n g.onPortalChange = null;\n for (const w of this.lockWait.values()) clearTimeout(w.timer);\n this.lockWait.clear();\n this.locks.clear();\n sim.authority = true;\n }\n}\n"
513
517
  },
514
518
  {
515
519
  "path": "net/OwnerBroadcast.ts",
@@ -1113,7 +1117,7 @@
1113
1117
  },
1114
1118
  {
1115
1119
  "path": "scene.ts",
1116
- "content": "import 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 \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">&rarr;</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> / <b>W W</b> sprint, <b>Ctrl</b> sneak</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block / open door</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory &amp; 2&times;2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3&times;3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"btnWorlds\" class=\"accent\">Create world</button>\n <button id=\"btnExport\">Export world</button>\n <button id=\"btnImport\">Import world</button>\n <button id=\"btnNewSeed\">New seed</button>\n </div>\n </div>\n </div>\n <div id=\"worldScreen\" class=\"hidden\">\n <div class=\"wsPanel\">\n <div class=\"wsHead\">\n <button class=\"wsTab active\" data-tab=\"new\">Новый мир</button>\n <button class=\"wsTab\" data-tab=\"mine\">Мои миры</button>\n <button id=\"wsClose\" class=\"wsClose\" title=\"Закрыть (Esc)\">&times;</button>\n </div>\n <div id=\"wsNew\" class=\"wsBody\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Картинки-референсы <span class=\"wsNote\">до 4, можно один объект с разных сторон</span></div>\n <div id=\"wsDrop\" class=\"wsDrop\" title=\"Клик, перетаскивание или Ctrl+V\">\n <div id=\"wsThumbs\" class=\"wsThumbs hidden\"></div>\n <span id=\"wsDropHint\">Перетащите картинки, вставьте Ctrl+V или нажмите, чтобы выбрать</span>\n </div>\n <div class=\"wsRow\">\n <label title=\"ИИ сам поймёт по описанию, что на картинках: объект, который надо построить из блоков, стиль или место\">\n <input type=\"radio\" name=\"wsRead\" value=\"reference\" checked /> Образец для ИИ</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"topdown\" /> Карта сверху</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"mood\" /> Настроение</label>\n <button id=\"wsClearImg\" class=\"wsLink hidden\">убрать картинки</button>\n </div>\n <label class=\"wsLabel\" for=\"wsText\">Описание</label>\n <textarea id=\"wsText\" rows=\"3\" maxlength=\"600\"\n placeholder=\"Например: тропический остров, на взлётной полосе стоит самолёт с картинок\"></textarea>\n <div class=\"wsLabel\">Размер</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsSize\" value=\"island\" checked /> Остров 256</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"large\" /> Большой 512</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"infinite\" /> Бесконечный</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"custom\" /> Свой</label>\n <span id=\"wsCustom\" class=\"hidden\">\n <input id=\"wsW\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> &times;\n <input id=\"wsD\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> чанков\n </span>\n </div>\n <div class=\"wsLabel\">Как строить</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsMode\" value=\"local\" checked /> Без ИИ</label>\n <label id=\"wsAiLabel\"><input type=\"radio\" name=\"wsMode\" value=\"ai\" disabled /> С ИИ</label>\n <span id=\"wsAiNote\" class=\"wsNote\"></span>\n </div>\n <div class=\"wsButtons\">\n <button id=\"wsGenerate\" class=\"wsPrimary\">Сгенерировать</button>\n <button id=\"wsLoadSpec\">Загрузить рецепт</button>\n </div>\n <div id=\"wsStatus\" class=\"wsStatus\"></div>\n </div>\n <div class=\"wsPreview\">\n <canvas id=\"wsMap\" width=\"256\" height=\"256\"></canvas>\n <div id=\"wsInfo\" class=\"wsInfo\">Здесь появится карта мира</div>\n <div class=\"wsButtons\">\n <button id=\"wsPlay\" class=\"wsPrimary\" disabled>Играть в этом мире</button>\n <button id=\"wsSaveSpec\" disabled>Скачать рецепт</button>\n </div>\n </div>\n </div>\n <div id=\"wsMine\" class=\"wsBody hidden\"><div id=\"wsList\" class=\"wsList\"></div></div>\n </div>\n </div>\n`;\n\n/**\n * Сцена + debug-поверхность для агента + тип контента для Мастерской: все смотрят на ОДИН экземпляр\n * игры, который создаётся в `mount()` и обнуляется в `destroy()`. Поэтому они получают не объект, а\n * доступ к текущему `game` — иначе после пересоздания сцены держали бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(opts: GameOptions = {}): {\n scene: EngineScene;\n agent: ModuleAgentApi;\n content: ContentTypeHandler;\n} {\n let game: Game | null = null;\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n },\n opts,\n );\n return {\n scene,\n agent: createVoxelcraftAgentApi(() => game),\n content: createVoxelWorldContentType(() => game),\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 setGame(new Game(opts));\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"
1120
+ "content": "import 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 \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">&rarr;</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> / <b>W W</b> sprint, <b>Ctrl</b> sneak</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block / open door</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory &amp; 2&times;2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3&times;3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"btnWorlds\" class=\"accent\">Create world</button>\n <button id=\"btnExport\">Export world</button>\n <button id=\"btnImport\">Import world</button>\n <button id=\"btnNewSeed\">New seed</button>\n </div>\n </div>\n </div>\n <div id=\"worldScreen\" class=\"hidden\">\n <div class=\"wsPanel\">\n <div class=\"wsHead\">\n <button class=\"wsTab active\" data-tab=\"new\">Новый мир</button>\n <button class=\"wsTab\" data-tab=\"mine\">Мои миры</button>\n <button id=\"wsClose\" class=\"wsClose\" title=\"Закрыть (Esc)\">&times;</button>\n </div>\n <div id=\"wsNew\" class=\"wsBody\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Картинки-референсы <span class=\"wsNote\">до 4, можно один объект с разных сторон</span></div>\n <div id=\"wsDrop\" class=\"wsDrop\" title=\"Клик, перетаскивание или Ctrl+V\">\n <div id=\"wsThumbs\" class=\"wsThumbs hidden\"></div>\n <span id=\"wsDropHint\">Перетащите картинки, вставьте Ctrl+V или нажмите, чтобы выбрать</span>\n </div>\n <div class=\"wsRow\">\n <label title=\"ИИ сам поймёт по описанию, что на картинках: объект, который надо построить из блоков, стиль или место\">\n <input type=\"radio\" name=\"wsRead\" value=\"reference\" checked /> Образец для ИИ</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"topdown\" /> Карта сверху</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"mood\" /> Настроение</label>\n <button id=\"wsClearImg\" class=\"wsLink hidden\">убрать картинки</button>\n </div>\n <label class=\"wsLabel\" for=\"wsText\">Описание</label>\n <textarea id=\"wsText\" rows=\"3\" maxlength=\"600\"\n placeholder=\"Например: тропический остров, на взлётной полосе стоит самолёт с картинок\"></textarea>\n <div class=\"wsLabel\">Размер</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsSize\" value=\"island\" checked /> Остров 256</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"large\" /> Большой 512</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"infinite\" /> Бесконечный</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"custom\" /> Свой</label>\n <span id=\"wsCustom\" class=\"hidden\">\n <input id=\"wsW\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> &times;\n <input id=\"wsD\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> чанков\n </span>\n </div>\n <div class=\"wsLabel\">Как строить</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsMode\" value=\"local\" checked /> Без ИИ</label>\n <label id=\"wsAiLabel\"><input type=\"radio\" name=\"wsMode\" value=\"ai\" disabled /> С ИИ</label>\n <span id=\"wsAiNote\" class=\"wsNote\"></span>\n </div>\n <div class=\"wsButtons\">\n <button id=\"wsGenerate\" class=\"wsPrimary\">Сгенерировать</button>\n <button id=\"wsLoadSpec\">Загрузить рецепт</button>\n </div>\n <div id=\"wsStatus\" class=\"wsStatus\"></div>\n </div>\n <div class=\"wsPreview\">\n <canvas id=\"wsMap\" width=\"256\" height=\"256\"></canvas>\n <div id=\"wsInfo\" class=\"wsInfo\">Здесь появится карта мира</div>\n <div class=\"wsButtons\">\n <button id=\"wsPlay\" class=\"wsPrimary\" disabled>Играть в этом мире</button>\n <button id=\"wsSaveSpec\" disabled>Скачать рецепт</button>\n </div>\n </div>\n </div>\n <div id=\"wsMine\" class=\"wsBody hidden\"><div id=\"wsList\" class=\"wsList\"></div></div>\n </div>\n </div>\n`;\n\n/**\n * Сцена + debug-поверхность для агента + тип контента для Мастерской: все смотрят на ОДИН экземпляр\n * игры, который создаётся в `mount()` и обнуляется в `destroy()`. Поэтому они получают не объект, а\n * доступ к текущему `game` — иначе после пересоздания сцены держали бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(opts: GameOptions = {}): {\n scene: EngineScene;\n agent: ModuleAgentApi;\n content: 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 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,\n );\n return {\n scene,\n agent: createVoxelcraftAgentApi(() => game),\n content: createVoxelWorldContentType(() => 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 setGame(new Game(opts));\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"
1117
1121
  },
1118
1122
  {
1119
1123
  "path": "shims.d.ts",
@@ -1329,7 +1333,7 @@
1329
1333
  },
1330
1334
  {
1331
1335
  "path": "ui/NetUI.ts",
1332
- "content": "// Экран «Сетевая игра»: открыть свой мир для других (как «Открыть для сети» в Minecraft), войти в\n// чужой (список открытых игр или код приглашения), игроки с правами оператора и исключением. Плюс\n// список игроков по Tab во время игры. Чистый DOM, как остальной HUD.\nimport { events } from \"../core/EventBus\";\nimport { NetGame } from \"../net/NetGame\";\nimport type { Game } from \"../main\";\nimport type { RoomListItem } from \"@idosgames/core\";\n\ntype Tab = \"host\" | \"join\" | \"players\";\n\n/** Тексты движка → понятные игроку (текст сервера дословно не показываем). */\nfunction friendly(err: string): string {\n const e = err || \"\";\n if (/disabled/i.test(e))\n return \"Сетевая игра выключена в настройках этой игры (LiveOps → Мультиплеер)\";\n if (/full/i.test(e)) return \"В этой игре нет мест\";\n if (/join code/i.test(e)) return \"Неверный код приглашения\";\n if (/removed from this room/i.test(e)) return \"Вас исключили из этой игры\";\n if (/not found|no longer exists/i.test(e))\n return \"Игра не найдена или уже закрыта\";\n if (/already in room/i.test(e)) return \"Вы уже в другой сетевой игре\";\n if (/closed/i.test(e)) return \"Игра закрыта\";\n if (/Версии|обновите/i.test(e)) return e;\n if (/хост/i.test(e)) return e;\n return \"Не получилось. Попробуйте ещё раз\";\n}\n\nfunction code6(): string {\n const abc = \"ABCDEFGHJKLMNPQRSTUVWXYZ23456789\";\n let s = \"\";\n const r = crypto.getRandomValues(new Uint8Array(6));\n for (const b of r) s += abc[b % abc.length];\n return s;\n}\n\nconst DIM_NAMES: Record<string, string> = {\n overworld: \"Верхний мир\",\n inferno: \"Пекло\",\n beyond: \"Предел\",\n};\n\nexport class NetUI {\n open = false;\n private readonly root: HTMLDivElement;\n private readonly tabList: HTMLDivElement;\n private busy = false;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"netScreen\";\n this.root.className = \"hidden\";\n this.root.innerHTML = `\n <div class=\"wsPanel\">\n <div class=\"wsHead\">\n <button class=\"wsTab\" data-tab=\"host\">Открыть мой мир</button>\n <button class=\"wsTab\" data-tab=\"join\">Присоединиться</button>\n <button class=\"wsTab\" data-tab=\"players\">Игроки</button>\n <button class=\"wsClose\" data-act=\"close\" title=\"Закрыть (Esc)\">&times;</button>\n </div>\n <div class=\"wsBody\" data-body=\"host\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Название</div>\n <input class=\"netInput\" data-f=\"room\" maxlength=\"40\" />\n <div class=\"wsLabel\">Кто может войти</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"netVis\" value=\"Private\" checked /> По коду приглашения</label>\n <label><input type=\"radio\" name=\"netVis\" value=\"Public\" /> Все (в списке открытых игр)</label>\n </div>\n <div class=\"wsLabel\">Режим гостей</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"netMode\" value=\"survival\" checked /> Выживание</label>\n <label><input type=\"radio\" name=\"netMode\" value=\"creative\" /> Творчество</label>\n </div>\n <div class=\"wsRow\">\n <label><input type=\"checkbox\" data-f=\"cheats\" /> Команды всем <span class=\"wsNote\">(иначе — только вам и операторам)</span></label>\n </div>\n <div class=\"wsButtons\"><button class=\"wsPrimary\" data-act=\"host\">Открыть для сети</button></div>\n <div class=\"wsStatus\" data-f=\"hostStatus\"></div>\n </div>\n </div>\n <div class=\"wsBody hidden\" data-body=\"join\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Код приглашения</div>\n <div class=\"wsRow\">\n <input class=\"netInput\" data-f=\"invite\" placeholder=\"Например: 66f1c0…:K7P2QX\" />\n <button class=\"wsPrimary\" data-act=\"joinCode\">Войти</button>\n </div>\n <div class=\"wsLabel\">Открытые игры <button class=\"wsLink\" data-act=\"refresh\">обновить</button></div>\n <div class=\"wsList\" data-f=\"rooms\"></div>\n <div class=\"wsStatus\" data-f=\"joinStatus\"></div>\n <div class=\"wsNote\">Ваш мир сохранится в «Моих мирах» и вернётся, когда вы выйдете из чужой игры.</div>\n </div>\n </div>\n <div class=\"wsBody hidden\" data-body=\"players\">\n <div class=\"wsForm\">\n <div class=\"wsList\" data-f=\"players\"></div>\n <div class=\"wsButtons\"><button data-act=\"leave\">Выйти из сетевой игры</button></div>\n <div class=\"wsStatus\" data-f=\"playersStatus\"></div>\n </div>\n </div>\n </div>`;\n this.tabList = document.createElement(\"div\");\n this.tabList.id = \"netTabList\";\n this.tabList.className = \"hidden\";\n const host = document.getElementById(\"game\")!.parentElement!;\n host.append(this.root, this.tabList);\n\n // кнопка в меню паузы — только если у игры есть SDK-клиент\n const buttons = document.querySelector(\"#overlay .buttons\");\n if (buttons && game.netClient) {\n const b = document.createElement(\"button\");\n b.id = \"btnNet\";\n b.textContent = \"Сетевая игра\";\n b.addEventListener(\n \"click\",\n () => this.show(game.net ? \"players\" : \"host\"),\n { signal },\n );\n buttons.prepend(b);\n }\n\n this.root.addEventListener(\n \"click\",\n (e) => {\n const t = e.target as HTMLElement;\n const tab = t.closest<HTMLElement>(\".wsTab\")?.dataset.tab as\n Tab | undefined;\n if (tab) return this.selectTab(tab);\n const act = t.closest<HTMLElement>(\"[data-act]\")?.dataset.act;\n if (act) void this.action(act, t.closest<HTMLElement>(\"[data-act]\")!);\n },\n { signal },\n );\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (this.open && e.code === \"Escape\") this.hide();\n if (e.code === \"Tab\" && game.net && game.playing && !this.open) {\n e.preventDefault();\n this.renderTabList();\n this.tabList.classList.remove(\"hidden\");\n }\n },\n { signal },\n );\n document.addEventListener(\n \"keyup\",\n (e) => {\n if (e.code === \"Tab\") this.tabList.classList.add(\"hidden\");\n },\n { signal },\n );\n events.on(\"net:roster\", () => {\n if (this.open) this.renderPlayers();\n if (!this.tabList.classList.contains(\"hidden\")) this.renderTabList();\n });\n }\n\n private $(f: string): HTMLElement {\n return this.root.querySelector(`[data-f=\"${f}\"]`)!;\n }\n\n private radio(name: string): string {\n return (\n this.root.querySelector<HTMLInputElement>(`input[name=\"${name}\"]:checked`)\n ?.value ?? \"\"\n );\n }\n\n show(tab: Tab): void {\n if (!this.game.netClient) return;\n this.open = true;\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.root.classList.remove(\"hidden\");\n (this.$(\"room\") as HTMLInputElement).value ||= this.game.worldName;\n // вне сетевой игры строки «Мир открыт. Код…» и «Игрок исключён» — от прошлой игры\n if (!this.game.net)\n for (const f of [\"hostStatus\", \"joinStatus\", \"playersStatus\"])\n this.status(f, \"\");\n this.selectTab(tab);\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.game.input.uiOpen = false;\n this.root.classList.add(\"hidden\");\n }\n\n private selectTab(tab: Tab): void {\n // в чужой игре открыть свой мир нельзя, а вне игры список игроков пуст\n if (tab === \"host\" && this.game.net) tab = \"players\";\n for (const t of this.root.querySelectorAll<HTMLElement>(\".wsTab\")) {\n t.classList.toggle(\"active\", t.dataset.tab === tab);\n t.classList.toggle(\n \"hidden\",\n (t.dataset.tab === \"players\") !== !!this.game.net &&\n t.dataset.tab !== \"join\",\n );\n }\n for (const b of this.root.querySelectorAll<HTMLElement>(\"[data-body]\"))\n b.classList.toggle(\"hidden\", b.dataset.body !== tab);\n if (tab === \"join\") void this.refreshRooms();\n if (tab === \"players\") this.renderPlayers();\n }\n\n private status(f: string, text: string, error = false): void {\n const el = this.$(f);\n el.textContent = text;\n el.classList.toggle(\"error\", error);\n }\n\n private async action(act: string, el: HTMLElement): Promise<void> {\n const g = this.game;\n if (act === \"close\") return this.hide();\n if (act === \"refresh\") return void this.refreshRooms();\n if (this.busy) return;\n this.busy = true;\n try {\n if (act === \"host\") await this.openWorld();\n else if (act === \"joinCode\") {\n const raw = (this.$(\"invite\") as HTMLInputElement).value.trim();\n const [roomId, code] = raw.split(\":\");\n if (!roomId)\n this.status(\"joinStatus\", \"Вставьте код приглашения\", true);\n else await this.join(roomId, code);\n } else if (act === \"join\")\n await this.join(el.dataset.room ?? \"\", undefined);\n else if (act === \"leave\") {\n await g.net?.leave();\n this.hide();\n } else if (act === \"op\" || act === \"deop\")\n g.net?.setOp(el.dataset.user ?? \"\", act === \"op\");\n else if (act === \"kick\" || act === \"ban\") {\n const err = await g.net?.kick(el.dataset.user ?? \"\", act === \"ban\");\n this.status(\n \"playersStatus\",\n err\n ? friendly(err)\n : act === \"ban\"\n ? \"Игрок исключён навсегда\"\n : \"Игрок исключён\",\n !!err,\n );\n }\n } finally {\n this.busy = false;\n }\n }\n\n private async openWorld(): Promise<void> {\n const g = this.game;\n if (!g.netClient || g.net) return;\n const visibility = this.radio(\"netVis\") === \"Public\" ? \"Public\" : \"Private\";\n const joinCode = visibility === \"Private\" ? code6() : undefined;\n this.status(\"hostStatus\", \"Открываем…\");\n const res = await NetGame.host(g, g.netClient, {\n name: g.playerName(),\n roomName:\n (this.$(\"room\") as HTMLInputElement).value.trim() || g.worldName,\n visibility,\n joinCode,\n settings: {\n guestMode:\n this.radio(\"netMode\") === \"creative\" ? \"creative\" : \"survival\",\n cheatsForAll: (this.$(\"cheats\") as HTMLInputElement).checked,\n },\n });\n if (typeof res === \"string\")\n return this.status(\"hostStatus\", friendly(res), true);\n g.net = res;\n const invite = joinCode\n ? `${res.session.roomId}:${joinCode}`\n : res.session.roomId;\n this.status(\"hostStatus\", `Мир открыт. Код приглашения: ${invite}`);\n void navigator.clipboard?.writeText(invite).catch(() => undefined);\n g.hud.message(\"Мир открыт для сети — код скопирован\", 2600);\n events.emit(\"net:roster\");\n }\n\n private async join(roomId: string, code: string | undefined): Promise<void> {\n const g = this.game;\n if (!g.netClient || g.net || !roomId) return;\n this.status(\"joinStatus\", \"Входим…\");\n const res = await NetGame.join(\n g,\n g.netClient,\n roomId,\n code,\n g.playerName(),\n );\n if (typeof res === \"string\")\n return this.status(\"joinStatus\", friendly(res), true);\n g.net = res;\n this.status(\"joinStatus\", \"\");\n this.hide();\n g.startPlay();\n }\n\n private async refreshRooms(): Promise<void> {\n const list = this.$(\"rooms\");\n const client = this.game.netClient as unknown as {\n multiplayer: {\n listRooms(\n p?: number,\n s?: number,\n ): Promise<{\n ok: boolean;\n data?: { Rooms?: RoomListItem[] | null };\n error?: string;\n }>;\n };\n } | null;\n if (!client) return;\n list.textContent = \"Загрузка…\";\n const res = await client.multiplayer.listRooms(0, 30);\n list.textContent = \"\";\n if (!res.ok) {\n this.status(\"joinStatus\", friendly(res.error ?? \"\"), true);\n return;\n }\n const rooms = res.data?.Rooms ?? [];\n if (!rooms.length) {\n list.textContent = \"Открытых игр пока нет\";\n return;\n }\n for (const r of rooms) {\n const item = document.createElement(\"div\");\n item.className = \"wsItem\";\n const info = document.createElement(\"div\");\n const name = document.createElement(\"div\");\n name.className = \"wsItemName\";\n name.textContent = r.Name || \"Игра\";\n const meta = document.createElement(\"div\");\n meta.className = \"wsItemMeta\";\n meta.textContent = `Игроков: ${r.MemberCount ?? 0} из ${r.MaxPlayers ?? 8}`;\n info.append(name, meta);\n const btn = document.createElement(\"button\");\n btn.className = \"wsPrimary\";\n btn.dataset.act = \"join\";\n btn.dataset.room = r.RoomID ?? \"\";\n btn.textContent = \"Войти\";\n item.append(info, btn);\n list.appendChild(item);\n }\n }\n\n private renderPlayers(): void {\n const net = this.game.net;\n const list = this.$(\"players\");\n list.textContent = \"\";\n if (!net) {\n list.textContent = \"Вы не в сетевой игре\";\n return;\n }\n for (const p of net.playerList()) {\n const item = document.createElement(\"div\");\n item.className = \"wsItem\";\n const info = document.createElement(\"div\");\n const name = document.createElement(\"div\");\n name.className = \"wsItemName\";\n name.textContent =\n p.name +\n (p.userId === net.selfId ? \" (вы)\" : \"\") +\n (p.host ? \" — хост\" : p.op ? \" — оператор\" : \"\");\n const meta = document.createElement(\"div\");\n meta.className = \"wsItemMeta\";\n meta.textContent = DIM_NAMES[p.dim] ?? p.dim;\n info.append(name, meta);\n item.appendChild(info);\n if (net.isHost && p.userId !== net.selfId) {\n const mk = (act: string, text: string) => {\n const b = document.createElement(\"button\");\n b.dataset.act = act;\n b.dataset.user = p.userId;\n b.textContent = text;\n return b;\n };\n item.append(\n p.op ? mk(\"deop\", \"Снять оператора\") : mk(\"op\", \"Сделать оператором\"),\n mk(\"kick\", \"Исключить\"),\n mk(\"ban\", \"Бан\"),\n );\n }\n list.appendChild(item);\n }\n }\n\n private renderTabList(): void {\n const net = this.game.net;\n this.tabList.textContent = \"\";\n if (!net) return;\n for (const p of net.playerList()) {\n const row = document.createElement(\"div\");\n row.className = \"netTabRow\";\n const n = document.createElement(\"span\");\n n.textContent = (p.host ? \"★ \" : p.op ? \"☆ \" : \"\") + p.name;\n const r = document.createElement(\"span\");\n r.className = \"netTabPing\";\n r.textContent =\n p.userId === net.selfId ? \"\" : p.rtt ? `${p.rtt} мс` : \"…\";\n row.append(n, r);\n this.tabList.appendChild(row);\n }\n }\n\n dispose(): void {\n this.root.remove();\n this.tabList.remove();\n document.getElementById(\"btnNet\")?.remove();\n }\n}\n"
1336
+ "content": "// Сетевая игра внутри VoxelCraft: игроки мира (права оператора, исключение, бан, выход) и список по\n// Tab во время игры. Комнаты — НЕ здесь: их список, создание и вход по приглашению живут в лобби\n// (система мультиплеера, ctx.multiplayer), одинаковые для всех игр. Кнопка паузы «Играть с друзьями»\n// открывает в лобби «Создать комнату» с этой игрой. Чистый DOM, как остальной HUD.\nimport { events } from \"../core/EventBus\";\nimport type { Game } from \"../main\";\n\n/** Тексты движка и сетевого кода → понятные игроку (текст сервера дословно не показываем). */\nexport function friendlyNetError(err: string): string {\n const e = err || \"\";\n if (/removed from this room/i.test(e)) return \"Вас исключили из этой игры\";\n if (/not found|no longer exists/i.test(e))\n return \"Игра не найдена или уже закрыта\";\n if (/closed/i.test(e)) return \"Игра закрыта\";\n if (/Версии|обновите/i.test(e)) return e;\n if (/хост/i.test(e)) return e;\n return \"Не получилось. Попробуйте ещё раз\";\n}\n\nconst DIM_NAMES: Record<string, string> = {\n overworld: \"Верхний мир\",\n inferno: \"Пекло\",\n beyond: \"Предел\",\n};\n\nexport class NetUI {\n open = false;\n private readonly root: HTMLDivElement;\n private readonly tabList: HTMLDivElement;\n private busy = false;\n private readonly button: HTMLButtonElement | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"netScreen\";\n this.root.className = \"hidden\";\n this.root.innerHTML = `\n <div class=\"wsPanel\">\n <div class=\"wsHead\">\n <button class=\"wsTab active\">Игроки</button>\n <button class=\"wsClose\" data-act=\"close\" title=\"Закрыть (Esc)\">&times;</button>\n </div>\n <div class=\"wsBody\">\n <div class=\"wsForm\">\n <div class=\"wsList\" data-f=\"players\"></div>\n <div class=\"wsButtons\"><button data-act=\"leave\">Выйти из сетевой игры</button></div>\n <div class=\"wsStatus\" data-f=\"playersStatus\"></div>\n </div>\n </div>\n </div>`;\n this.tabList = document.createElement(\"div\");\n this.tabList.id = \"netTabList\";\n this.tabList.className = \"hidden\";\n const host = document.getElementById(\"game\")!.parentElement!;\n host.append(this.root, this.tabList);\n\n // Кнопка в меню паузы — только если в приложении есть система мультиплеера.\n const buttons = document.querySelector(\"#overlay .buttons\");\n if (buttons && game.online?.available()) {\n const b = document.createElement(\"button\");\n b.id = \"btnNet\";\n b.addEventListener(\n \"click\",\n () => (game.net ? this.show() : game.online?.openForFriends()),\n { signal },\n );\n buttons.prepend(b);\n this.button = b;\n this.syncButton();\n }\n\n this.root.addEventListener(\n \"click\",\n (e) => {\n const el = (e.target as HTMLElement).closest<HTMLElement>(\"[data-act]\");\n if (el) void this.action(el.dataset.act ?? \"\", el);\n },\n { signal },\n );\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (this.open && e.code === \"Escape\") this.hide();\n if (e.code === \"Tab\" && game.net && game.playing && !this.open) {\n e.preventDefault();\n this.renderTabList();\n this.tabList.classList.remove(\"hidden\");\n }\n },\n { signal },\n );\n document.addEventListener(\n \"keyup\",\n (e) => {\n if (e.code === \"Tab\") this.tabList.classList.add(\"hidden\");\n },\n { signal },\n );\n events.on(\"net:roster\", () => {\n this.syncButton();\n if (this.open) this.renderPlayers();\n if (!this.tabList.classList.contains(\"hidden\")) this.renderTabList();\n });\n }\n\n /** «Играть с друзьями» вне сетевой игры, «Игроки» — в ней. */\n private syncButton(): void {\n if (this.button)\n this.button.textContent = this.game.net ? \"Игроки\" : \"Играть с друзьями\";\n }\n\n private $(f: string): HTMLElement {\n return this.root.querySelector(`[data-f=\"${f}\"]`)!;\n }\n\n show(): void {\n if (!this.game.net) return;\n this.open = true;\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.root.classList.remove(\"hidden\");\n this.status(\"\");\n this.renderPlayers();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.game.input.uiOpen = false;\n this.root.classList.add(\"hidden\");\n }\n\n private status(text: string, error = false): void {\n const el = this.$(\"playersStatus\");\n el.textContent = text;\n el.classList.toggle(\"error\", error);\n }\n\n private async action(act: string, el: HTMLElement): Promise<void> {\n const g = this.game;\n if (act === \"close\") return this.hide();\n if (this.busy) return;\n this.busy = true;\n try {\n if (act === \"leave\") {\n await g.net?.leave();\n this.hide();\n } else if (act === \"op\" || act === \"deop\")\n g.net?.setOp(el.dataset.user ?? \"\", act === \"op\");\n else if (act === \"kick\" || act === \"ban\") {\n const err = await g.net?.kick(el.dataset.user ?? \"\", act === \"ban\");\n this.status(\n err\n ? friendlyNetError(err)\n : act === \"ban\"\n ? \"Игрок исключён навсегда\"\n : \"Игрок исключён\",\n !!err,\n );\n }\n } finally {\n this.busy = false;\n }\n }\n\n private renderPlayers(): void {\n const net = this.game.net;\n const list = this.$(\"players\");\n list.textContent = \"\";\n if (!net) {\n list.textContent = \"Вы не в сетевой игре\";\n return;\n }\n for (const p of net.playerList()) {\n const item = document.createElement(\"div\");\n item.className = \"wsItem\";\n const info = document.createElement(\"div\");\n const name = document.createElement(\"div\");\n name.className = \"wsItemName\";\n name.textContent =\n p.name +\n (p.userId === net.selfId ? \" (вы)\" : \"\") +\n (p.host ? \" — хост\" : p.op ? \" — оператор\" : \"\");\n const meta = document.createElement(\"div\");\n meta.className = \"wsItemMeta\";\n meta.textContent = DIM_NAMES[p.dim] ?? p.dim;\n info.append(name, meta);\n item.appendChild(info);\n if (net.isHost && p.userId !== net.selfId) {\n const mk = (act: string, text: string) => {\n const b = document.createElement(\"button\");\n b.dataset.act = act;\n b.dataset.user = p.userId;\n b.textContent = text;\n return b;\n };\n item.append(\n p.op ? mk(\"deop\", \"Снять оператора\") : mk(\"op\", \"Сделать оператором\"),\n mk(\"kick\", \"Исключить\"),\n mk(\"ban\", \"Бан\"),\n );\n }\n list.appendChild(item);\n }\n }\n\n private renderTabList(): void {\n const net = this.game.net;\n this.tabList.textContent = \"\";\n if (!net) return;\n for (const p of net.playerList()) {\n const row = document.createElement(\"div\");\n row.className = \"netTabRow\";\n const n = document.createElement(\"span\");\n n.textContent = (p.host ? \"★ \" : p.op ? \"☆ \" : \"\") + p.name;\n const r = document.createElement(\"span\");\n r.className = \"netTabPing\";\n r.textContent =\n p.userId === net.selfId ? \"\" : p.rtt ? `${p.rtt} мс` : \"…\";\n row.append(n, r);\n this.tabList.appendChild(row);\n }\n }\n\n dispose(): void {\n this.root.remove();\n this.tabList.remove();\n document.getElementById(\"btnNet\")?.remove();\n }\n}\n"
1333
1337
  },
1334
1338
  {
1335
1339
  "path": "ui/SignEditor.ts",
@@ -1773,7 +1777,7 @@
1773
1777
  },
1774
1778
  {
1775
1779
  "path": "module.meta.json",
1776
- "content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\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 'Create world' screen 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, like Minecraft's 'Open to LAN': a player opens their world to the network (by invite code or in the public list, guest game mode, 'commands for everyone' switch), others join from the 'Network game' screen; 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). Not included yet: AI-driven villagers 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, @idosgames/core/realtime)\",\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 \"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 \"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.4.0\"\n}\n"
1780
+ "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 'Create world' screen 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 shows 'With friends' next to the game's 'Play', 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). Not included yet: AI-driven villagers 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 \"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 \"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.4.0\"\n}\n"
1777
1781
  }
1778
1782
  ]
1779
1783
  }