@idosgames/mcp 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/registry/host.json +2 -2
- package/registry/index.json +124 -124
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +9 -9
- package/registry/modules/boosts.json +7 -7
- package/registry/modules/character.json +6 -6
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +5 -5
- package/registry/modules/coop-events.json +5 -5
- package/registry/modules/craft.json +5 -5
- package/registry/modules/deals.json +5 -5
- package/registry/modules/events.json +5 -5
- package/registry/modules/friends.json +5 -5
- package/registry/modules/idle-rpg.json +8 -8
- package/registry/modules/inventory.json +5 -5
- package/registry/modules/invite.json +5 -5
- package/registry/modules/leaderboard.json +5 -5
- package/registry/modules/lootboxes.json +6 -6
- package/registry/modules/mailbox.json +5 -5
- package/registry/modules/marketplace.json +6 -6
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +5 -5
- package/registry/modules/profile.json +7 -7
- package/registry/modules/pvp.json +7 -7
- package/registry/modules/quests.json +5 -5
- package/registry/modules/rewards.json +5 -5
- package/registry/modules/season.json +5 -5
- package/registry/modules/store.json +5 -5
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/voxelcraft.json +6 -6
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/community-marketing-system.json +1 -1
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/mailbox-system.json +1 -1
- package/registry/skills/match-system.json +2 -2
- package/registry/skills/multiplayer-realtime.json +2 -2
|
@@ -69,11 +69,11 @@
|
|
|
69
69
|
"version": "0.4.1"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
-
"@idosgames/core": "0.
|
|
73
|
-
"@idosgames/module-sdk": "0.8.
|
|
72
|
+
"@idosgames/core": "0.21.0",
|
|
73
|
+
"@idosgames/module-sdk": "0.8.2",
|
|
74
74
|
"three": "0.185.1"
|
|
75
75
|
},
|
|
76
|
-
"contentHash": "
|
|
76
|
+
"contentHash": "8aa30c0f20465efb1cfc3d03041e01c68d9d622ec121cff83fc6a5562ad94166",
|
|
77
77
|
"files": [
|
|
78
78
|
{
|
|
79
79
|
"path": "agent.ts",
|
|
@@ -497,7 +497,7 @@
|
|
|
497
497
|
},
|
|
498
498
|
{
|
|
499
499
|
"path": "main.ts",
|
|
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"
|
|
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._destroyed) return;\n // Прошлая сетевая игра, чья сессия уже закрыта (из комнаты вышли через лобби, пока игра стояла на\n // паузе), не должна съесть новую комнату: раньше здесь был молчаливый выход, хост не открывал мир,\n // а гости через 30 с получали «Хост не ответил».\n if (this.net?.session.closed) this.net.closeStale();\n if (this.net) 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"
|
|
501
501
|
},
|
|
502
502
|
{
|
|
503
503
|
"path": "module.ts",
|
|
@@ -513,7 +513,7 @@
|
|
|
513
513
|
},
|
|
514
514
|
{
|
|
515
515
|
"path": "net/NetGame.ts",
|
|
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"
|
|
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 /** Когда хост последний раз отправил этому игроку снимок мира (повторный Hello — см. onHello). */\n welcomedAt?: number;\n}\n\n/**\n * Гость повторяет Hello, пока не придёт снимок мира. Хост берёт комнату только после загрузки своего\n * мира, а гость получает «старт» сразу — его первый Hello приходил хосту, у которого сетевой игры\n * ещё не было, и терялся: через 30 с «Хост не ответил» (тот самый «иногда не подключается»).\n */\nconst HELLO_RETRY_MS = 2500;\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 session.on(\"left\", () => this.end(null)),\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 const retry = setInterval(() => {\n if (session.isOpen(session.hostId)) net.sendHello();\n }, HELLO_RETRY_MS);\n const deadline = setTimeout(() => done(\"Хост не ответил\"), 30_000);\n function done(r: Welcome | string): void {\n clearInterval(retry);\n clearTimeout(deadline);\n resolve(r);\n }\n net.welcomeWait = done;\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 // Гость повторяет Hello, пока снимок не дошёл (HELLO_RETRY_MS): только что отправленный снимок\n // ещё в пути — второй такой же не нужен.\n if (r?.welcomedAt && Date.now() - r.welcomedAt < HELLO_RETRY_MS * 2) return;\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: 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 r.welcomedAt = Date.now();\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 // Сессию закрыли в обход событий (страховка к \"left\"): дальше играть не на чем.\n if (this.session.closed) return this.end(null);\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 /** Сессия уже закрыта снаружи — закончить сетевую игру без сообщения (см. Game.acceptRoom). */\n closeStale(): void {\n this.end(null);\n }\n\n /** The network game is over; `reason` — what to tell the player (null — they left themselves). */\n private end(reason: string | null): 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"
|
|
517
517
|
},
|
|
518
518
|
{
|
|
519
519
|
"path": "net/OwnerBroadcast.ts",
|
|
@@ -1125,7 +1125,7 @@
|
|
|
1125
1125
|
},
|
|
1126
1126
|
{
|
|
1127
1127
|
"path": "style.css",
|
|
1128
|
-
"content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #063d99;\n}\nbody {\n font-family: \"Segoe UI\", system-ui, sans-serif;\n user-select: none;\n}\n\n#game {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ---------- HUD ---------- */\n#crosshair {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 18px;\n height: 18px;\n transform: translate(-50%, -50%);\n pointer-events: none;\n z-index: 5;\n}\n#crosshair::before,\n#crosshair::after {\n content: \"\";\n position: absolute;\n background: rgba(255, 255, 255, 0.85);\n mix-blend-mode: difference;\n}\n#crosshair::before {\n left: 8px;\n top: 0;\n width: 2px;\n height: 18px;\n}\n#crosshair::after {\n left: 0;\n top: 8px;\n width: 18px;\n height: 2px;\n}\n\n/* --idos-safe-top: высота общего HUD host'а (0 без него) — служебные строки не уходят под HUD. */\n#fps {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#debug {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 26px);\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* --idos-safe-bottom: высота нижней навигации host'а (0, когда режим один) — хотбар над ней. */\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: calc(var(--idos-safe-bottom, 0px) + 8px);\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n.slot .dur {\n position: absolute;\n left: 4px;\n bottom: 3px;\n height: 3px;\n display: none;\n pointer-events: none;\n box-shadow: 0 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#xpBar {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 60px);\n width: min(400px, 90vw);\n height: 6px;\n background: rgba(20, 20, 20, 0.8);\n border: 1px solid #000;\n z-index: 5;\n pointer-events: none;\n}\n.xpFill {\n height: 100%;\n width: 0;\n background: linear-gradient(#b8ff5a, #5ec21e);\n}\n#xpLevel {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 64px);\n color: #8aff3a;\n font: bold 14px monospace;\n text-shadow:\n 1px 1px 0 #000,\n -1px -1px 0 #000;\n z-index: 6;\n pointer-events: none;\n}\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n/* пузырьки воздуха над едой — под водой */\n#bubbles {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 15px;\n color: #7ad0ff;\n}\n.bubble.empty {\n opacity: 0;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- смерть, горение ---------- */\n#deathScreen {\n position: fixed;\n inset: 0;\n z-index: 35;\n background: rgba(120, 0, 0, 0.55);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.deathPanel {\n text-align: center;\n color: #fff;\n text-shadow: 2px 2px 0 #000;\n}\n.deathPanel h1 {\n font-size: 46px;\n margin-bottom: 8px;\n}\n.deathCause {\n font-size: 16px;\n margin-bottom: 20px;\n}\n.deathBtn {\n cursor: pointer;\n font-size: 16px;\n padding: 10px 28px;\n border-radius: 4px;\n border: 2px solid #aaa;\n background: #3a3f4d;\n color: #eee;\n}\n.deathBtn:hover {\n background: #4a5163;\n}\n#fireOverlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n opacity: 0;\n background: radial-gradient(\n ellipse at 50% 120%,\n rgba(255, 140, 20, 0.75),\n transparent 60%\n );\n transition: opacity 0.2s;\n}\n#fireOverlay.show {\n opacity: 1;\n animation: burn 0.35s infinite alternate;\n}\n@keyframes burn {\n from {\n filter: brightness(0.85);\n }\n to {\n filter: brightness(1.2);\n }\n}\n\n/* ---------- босс и титры ---------- */\n#bossBar {\n position: fixed;\n top: 14px;\n left: 50%;\n transform: translateX(-50%);\n width: min(380px, calc(100% - 32px));\n z-index: 6;\n text-align: center;\n color: #fff;\n text-shadow: 1px 1px 0 #000;\n font-size: 14px;\n pointer-events: none;\n}\n.bossTrack {\n margin-top: 4px;\n height: 8px;\n background: #3a1030;\n border: 1px solid #000;\n}\n.bossFill {\n height: 100%;\n width: 100%;\n background: #c040ff;\n}\n#creditsScreen {\n position: fixed;\n inset: 0;\n z-index: 36;\n background: rgba(20, 10, 40, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.credits {\n font-size: 16px;\n line-height: 1.6;\n margin-bottom: 20px;\n}\n\n/* ---------- чат / консоль ---------- */\n#chat {\n position: fixed;\n left: 10px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n width: min(520px, calc(100% - 20px));\n z-index: 8;\n pointer-events: none;\n font: 13px monospace;\n color: #fff;\n}\n.chatLog {\n display: flex;\n flex-direction: column;\n gap: 2px;\n max-height: 40vh;\n overflow: hidden;\n}\n.chatLine {\n background: rgba(0, 0, 0, 0.45);\n padding: 2px 6px;\n text-shadow: 1px 1px 0 #000;\n animation: chatFade 10s forwards;\n}\n#chat.open .chatLine {\n animation: none;\n opacity: 1;\n}\n@keyframes chatFade {\n 0%,\n 80% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n.chatInput {\n pointer-events: auto;\n width: 100%;\n margin-top: 4px;\n padding: 6px 8px;\n font: 14px monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.7);\n border: 1px solid #888;\n outline: none;\n}\n\n/* ---------- сенсорное управление ---------- */\n.touchUi {\n position: fixed;\n inset: 0;\n z-index: 6; /* над канвасом и HUD, под инвентарём, чатом и оверлеем */\n pointer-events: none;\n touch-action: none;\n}\n.touchUi > * {\n pointer-events: auto;\n touch-action: none;\n user-select: none;\n -webkit-user-select: none;\n}\n.tLook {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 40%;\n}\n.tStick {\n position: absolute;\n left: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 84px);\n width: 132px;\n height: 132px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.12);\n border: 2px solid rgba(255, 255, 255, 0.35);\n}\n.tKnob {\n position: absolute;\n left: 50%;\n top: 50%;\n width: 56px;\n height: 56px;\n margin: -28px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.42);\n pointer-events: none;\n}\n.tBtn {\n position: absolute;\n width: 62px;\n height: 62px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.35);\n border: 2px solid rgba(255, 255, 255, 0.45);\n color: #fff;\n font:\n bold 22px system-ui,\n sans-serif;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.tBtn.on {\n background: rgba(154, 223, 106, 0.5);\n}\n.tJump {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n}\n.tSneak {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 172px);\n}\n.tAttack {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 134px);\n}\n.tUse {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 210px);\n}\n.tInv {\n right: 12px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tDrop {\n right: 124px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tChat {\n right: 68px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n/* на телефоне хотбар над зоной обзора: иначе тап по слоту крутил бы камеру */\nbody.touch #hotbar {\n z-index: 7;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow,\n.invTop {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid,\n.craftGrid {\n display: grid;\n gap: 3px;\n}\n.armorCol {\n display: grid;\n gap: 3px;\n margin-right: 18px;\n}\n.chestGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n.furnaceCol {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n}\n.flame {\n width: 20px;\n height: 20px;\n background: #5a5a5a;\n display: flex;\n align-items: flex-end;\n}\n.flameFill {\n width: 100%;\n height: 0;\n background: linear-gradient(#ffe27a, #f06a14);\n}\n.cookArrow {\n width: 56px;\n height: 14px;\n background: #5a5a5a;\n margin: 0 8px;\n}\n.cookFill {\n height: 100%;\n width: 0;\n background: #f5f5f5;\n}\n.invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n.invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n/* наковальня: цена в уровнях справа от результата */\n.anvilCost {\n min-width: 110px;\n font-size: 12px;\n color: #2a6a2a;\n}\n\n/* стол зачарований: предмет и лазурит, три варианта; зачарованное — с фиолетовым отблеском */\n.enchCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.enchOffers {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 260px;\n}\n.enchOffer {\n padding: 6px 8px;\n font-size: 12px;\n cursor: pointer;\n color: #f0e8ff;\n background: #4a3a6a;\n border: 2px solid #2a1a40;\n}\n.enchOffer:hover {\n background: #5a4a8a;\n}\n/* ткацкий станок: картинка знамени слева от названия узора */\n.loomOffers .enchOffer {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.loomPic {\n width: 24px;\n height: 24px;\n image-rendering: pixelated;\n border: 1px solid #2a1a40;\n}\n/* карта сокровищ: крестик над кладом */\n.mapTreasure {\n position: absolute;\n transform: translate(-50%, -50%);\n color: #c02020;\n font-weight: bold;\n font-size: 14px;\n text-shadow: 0 0 2px #fff;\n pointer-events: none;\n}\n/* достижения: всплывашка справа сверху и список по L */\n#achToast {\n position: absolute;\n top: 12px;\n right: 12px;\n min-width: 220px;\n padding: 8px 12px;\n background: rgba(20, 20, 30, 0.88);\n border: 2px solid #7a6a3a;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 30;\n}\n#achToast .achHead {\n color: #f0d060;\n font-weight: bold;\n margin-bottom: 2px;\n}\n#achList {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: min(560px, 92vw);\n max-height: 80vh;\n overflow-y: auto;\n padding: 10px 14px;\n background: rgba(16, 16, 24, 0.92);\n border: 2px solid #555;\n color: #ccc;\n font-size: 13px;\n z-index: 29;\n}\n#achList h3 {\n margin: 0 0 8px;\n color: #fff;\n}\n#achList .ach {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding: 3px 0;\n border-bottom: 1px solid #2a2a36;\n}\n#achList .ach.done b {\n color: #9ae07a;\n}\n#achList .ach span {\n opacity: 0.7;\n text-align: right;\n}\n.enchOffer.on {\n outline: 2px solid #7ee8e0;\n}\n.beaconInfo {\n font-size: 12px;\n opacity: 0.85;\n margin: 2px 0 4px;\n}\n/* Тление: сердца чернеют */\n#hearts.decay .heart {\n filter: grayscale(1) brightness(0.4);\n}\n.enchOffer.off {\n cursor: default;\n color: #9a90a8;\n background: #5a5560;\n}\n.slot.ench img {\n filter: drop-shadow(0 0 3px #b060ff) saturate(1.3);\n}\n\n/* торговля с жителем: строки сделок с иконками, в тонах дерева */\n.tradeOffers {\n max-height: 260px;\n overflow-y: auto;\n}\n.tradeOffer {\n display: flex;\n align-items: center;\n gap: 4px;\n background: #5a4630;\n border-color: #3a2a18;\n color: #fff4e0;\n}\n.tradeOffer:hover {\n background: #6e583c;\n}\n.tradeOffer img {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n}\n.tradeLeft {\n margin-left: auto;\n padding-left: 8px;\n opacity: 0.75;\n}\n\n/* компас и часы в руке — у правого края хотбара */\n#gadget {\n position: absolute;\n bottom: 58px;\n left: calc(50% + 200px);\n min-width: 40px;\n padding: 4px 8px;\n text-align: center;\n font: 16px/1.2 monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.45);\n border-radius: 6px;\n pointer-events: none;\n}\n#gadget .needle {\n display: inline-block;\n color: #e03030;\n font-size: 22px;\n}\n\n/* карта в руке — справа над хотбаром */\n#mapView {\n position: absolute;\n right: 16px;\n bottom: 70px;\n width: min(256px, 40vw);\n aspect-ratio: 1;\n max-width: 100%;\n border: 4px solid #8a6a40;\n background: #d6be96;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n pointer-events: none;\n}\n#mapView canvas {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#mapView .mapMarker {\n position: absolute;\n color: #fff;\n font-size: 14px;\n text-shadow:\n 0 0 2px #000,\n 0 0 2px #000;\n}\n\n/* варочная стойка: ингредиент и топливо столбиком, три бутылки в ряд */\n.brewCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.bottleRow {\n display: flex;\n gap: 6px;\n}\n/* эффекты зелий — под индикатором времени */\n#effects {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 34px);\n right: 12px;\n z-index: 5;\n color: #fff;\n font-size: 13px;\n text-align: right;\n text-shadow: 1px 1px 0 #000;\n pointer-events: none;\n white-space: pre;\n}\n\n/* креатив: поиск, палитра всех предметов, корзина */\n.creativeSearch {\n width: 100%;\n box-sizing: border-box;\n margin-bottom: 8px;\n padding: 6px 8px;\n font-size: 14px;\n border: 2px solid #555;\n background: #fff;\n}\n.creativeGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n max-height: calc(44px * 5 + 3px * 4);\n overflow-y: auto;\n margin-bottom: 12px;\n padding-right: 4px;\n}\n.creativeGrid .slot {\n cursor: pointer;\n}\n.trashLabel {\n color: #3a3a3a;\n font-size: 13px;\n}\n.slot.trash::before {\n content: \"🗑\";\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 22px;\n opacity: 0.6;\n pointer-events: none;\n}\n#armorBar {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 16px;\n letter-spacing: 3px;\n text-shadow: 1px 1px 0 #000;\n}\n.armorPip {\n color: #cfd6e0;\n}\n.armorPip.half {\n opacity: 0.55;\n}\n.armorPip.empty {\n color: #3a3a3a;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(4, 32, 90, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n#overlay button.accent {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n#overlay button.accent:hover {\n background: #4c8535;\n}\n\n/* ---------- экран «Создать мир» ---------- */\n#worldScreen {\n position: fixed;\n inset: 0;\n z-index: 40; /* над оверлеем паузы */\n background: rgba(4, 20, 50, 0.72);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: calc(var(--idos-safe-top, 0px) + 12px) 12px\n calc(var(--idos-safe-bottom, 0px) + 12px);\n color: #eee;\n font-size: 14px;\n user-select: text;\n}\n.wsPanel {\n background: #22283a;\n border: 2px solid #4a5163;\n border-radius: 8px;\n box-shadow: 0 16px 50px rgba(0, 0, 0, 0.6);\n width: min(880px, 100%);\n max-height: 100%;\n overflow: auto;\n}\n.wsHead {\n display: flex;\n gap: 4px;\n padding: 8px 8px 0;\n border-bottom: 1px solid #3a4157;\n}\n.wsTab,\n.wsClose {\n cursor: pointer;\n background: none;\n border: none;\n color: #aab3c8;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 6px 6px 0 0;\n}\n.wsTab.active {\n color: #9adf6a;\n background: #2c3348;\n}\n.wsClose {\n margin-left: auto;\n font-size: 22px;\n line-height: 1;\n}\n.wsBody {\n display: flex;\n flex-wrap: wrap;\n gap: 18px;\n padding: 16px;\n}\n.wsForm {\n flex: 1 1 360px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n min-width: 0;\n}\n.wsPreview {\n flex: 0 1 280px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n align-items: stretch;\n}\n#wsMap {\n width: 100%;\n aspect-ratio: 1;\n image-rendering: pixelated;\n background: #10141f;\n border: 2px solid #3a4157;\n border-radius: 6px;\n}\n.wsLabel {\n color: #ffd76a;\n font-weight: 600;\n margin-top: 4px;\n}\n.wsNote {\n color: #8f99b0;\n font-weight: normal;\n font-size: 12px;\n}\n.wsRow {\n display: flex;\n flex-wrap: wrap;\n gap: 6px 14px;\n align-items: center;\n}\n.wsRow label {\n cursor: pointer;\n white-space: nowrap;\n}\n.wsRow input[type=\"number\"] {\n width: 56px;\n}\n.wsDrop {\n cursor: pointer;\n min-height: 92px;\n border: 2px dashed #5a6480;\n border-radius: 6px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n padding: 8px;\n color: #8f99b0;\n}\n.wsDrop.over {\n border-color: #9adf6a;\n background: rgba(154, 223, 106, 0.08);\n}\n.wsThumbs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n justify-content: center;\n}\n.wsThumb {\n position: relative;\n}\n.wsThumb img {\n height: 84px;\n max-width: 140px;\n object-fit: cover;\n border-radius: 4px;\n display: block;\n}\n.wsThumbX {\n position: absolute;\n top: -6px;\n right: -6px;\n width: 20px;\n height: 20px;\n line-height: 18px;\n text-align: center;\n border-radius: 50%;\n background: #c0392b;\n color: #fff;\n font-size: 14px;\n cursor: pointer;\n}\n#wsText {\n width: 100%;\n resize: vertical;\n font: inherit;\n padding: 8px;\n border-radius: 6px;\n border: 1px solid #4a5163;\n background: #161b28;\n color: #eee;\n}\n.wsButtons {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 6px;\n}\n#worldScreen button:not(.wsTab):not(.wsClose):not(.wsLink) {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#worldScreen button:disabled {\n opacity: 0.5;\n cursor: default;\n}\n#worldScreen button.wsPrimary {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n.wsLink {\n cursor: pointer;\n background: none;\n border: none;\n color: #8fb8ff;\n text-decoration: underline;\n font-size: 12px;\n}\n.wsStatus {\n min-height: 20px;\n color: #b8e59a;\n}\n.wsStatus.error {\n color: #ff8f80;\n}\n.wsInfo {\n color: #cfd6e6;\n font-size: 13px;\n line-height: 1.4;\n}\n.wsList {\n width: 100%;\n display: flex;\n flex-direction: column;\n gap: 6px;\n color: #aab3c8;\n}\n.wsItem {\n display: flex;\n gap: 8px;\n align-items: center;\n padding: 8px 10px;\n background: #2c3348;\n border-radius: 6px;\n}\n.wsItem > div {\n flex: 1;\n min-width: 0;\n}\n.wsItem.current {\n outline: 1px solid #9adf6a;\n}\n.wsItemName {\n color: #eee;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.wsItemMeta {\n font-size: 12px;\n}\n\n/* Книга на кафедре (ui/BookScreen): страница пергамента поверх игры */\n.bookScreen {\n position: absolute;\n left: 50%;\n top: 50%;\n transform: translate(-50%, -50%);\n width: 320px;\n padding: 14px;\n background: #e8dcb8;\n color: #2a1c0e;\n border: 3px solid #8a6a3a;\n font-size: 13px;\n z-index: 40;\n}\n.bookScreen.hidden {\n display: none;\n}\n.bookTitle {\n font-weight: bold;\n margin-bottom: 8px;\n text-align: center;\n}\n.bookPage {\n min-height: 180px;\n line-height: 15px;\n white-space: pre-wrap;\n}\n.bookPage.hidden,\n.bookNav.hidden {\n display: none;\n}\n.bookEdit {\n width: 100%;\n height: 180px;\n box-sizing: border-box;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n resize: none;\n}\n.bookEdit.hidden {\n display: none;\n}\n.bookNav {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 8px;\n}\n.bookBtn {\n padding: 4px 10px;\n cursor: pointer;\n background: #8a6a3a;\n color: #f4ecd0;\n border: 1px solid #5a4020;\n}\n.bookBtn:hover {\n background: #a07a44;\n}\n.bookCount {\n flex: 1;\n text-align: center;\n}\n.bookTitleInput {\n flex: 1;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n padding: 3px 5px;\n}\n\n/* ---------- сетевая игра (ui/NetUI) ---------- */\n#netScreen {\n position: fixed;\n inset: 0;\n z-index: 40;\n background: rgba(4, 20, 50, 0.72);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: calc(var(--idos-safe-top, 0px) + 12px) 12px\n calc(var(--idos-safe-bottom, 0px) + 12px);\n color: #eee;\n font-size: 14px;\n user-select: text;\n}\n#netScreen.hidden,\n#netTabList.hidden,\n#netScreen .hidden {\n display: none;\n}\n#netScreen .wsPanel {\n width: min(620px, 100%);\n}\n#netScreen .wsForm button {\n cursor: pointer;\n font-size: 13px;\n padding: 6px 12px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#netScreen .wsForm button.wsPrimary {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n#netScreen .wsForm button.wsLink {\n padding: 0 4px;\n border: none;\n background: none;\n color: #8fb8ff;\n}\n.netInput {\n flex: none;\n width: 100%;\n box-sizing: border-box;\n min-width: 0;\n font: inherit;\n padding: 7px 8px;\n border-radius: 6px;\n border: 1px solid #4a5163;\n background: #161b28;\n color: #eee;\n}\n#netTabList {\n position: absolute;\n top: calc(var(--idos-safe-top, 0px) + 40px);\n left: 50%;\n transform: translateX(-50%);\n z-index: 30;\n min-width: 240px;\n max-width: calc(100% - 32px);\n padding: 8px 12px;\n background: rgba(0, 0, 0, 0.6);\n border-radius: 6px;\n color: #fff;\n font-size: 14px;\n pointer-events: none;\n}\n.wsRow .netInput {\n flex: 1 1 200px;\n width: auto;\n}\n.netTabRow {\n display: flex;\n justify-content: space-between;\n gap: 24px;\n padding: 2px 0;\n}\n.netTabPing {\n color: #9adf6a;\n font-size: 12px;\n}\n"
|
|
1128
|
+
"content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #063d99;\n}\nbody {\n font-family: \"Segoe UI\", system-ui, sans-serif;\n user-select: none;\n}\n\n#game {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ---------- HUD ---------- */\n#crosshair {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 18px;\n height: 18px;\n transform: translate(-50%, -50%);\n pointer-events: none;\n z-index: 5;\n}\n#crosshair::before,\n#crosshair::after {\n content: \"\";\n position: absolute;\n background: rgba(255, 255, 255, 0.85);\n mix-blend-mode: difference;\n}\n#crosshair::before {\n left: 8px;\n top: 0;\n width: 2px;\n height: 18px;\n}\n#crosshair::after {\n left: 0;\n top: 8px;\n width: 18px;\n height: 2px;\n}\n\n/* --idos-safe-top: высота общего HUD host'а (0 без него) — служебные строки не уходят под HUD. */\n#fps {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n/* под круглой кнопкой «в лобби» базы (40px в левом верхнем углу): строка не уходит под неё */\n#debug {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 56px);\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 26px);\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* --idos-safe-bottom: высота нижней навигации host'а (0, когда режим один) — хотбар над ней. */\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: calc(var(--idos-safe-bottom, 0px) + 8px);\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n.slot .dur {\n position: absolute;\n left: 4px;\n bottom: 3px;\n height: 3px;\n display: none;\n pointer-events: none;\n box-shadow: 0 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#xpBar {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 60px);\n width: min(400px, 90vw);\n height: 6px;\n background: rgba(20, 20, 20, 0.8);\n border: 1px solid #000;\n z-index: 5;\n pointer-events: none;\n}\n.xpFill {\n height: 100%;\n width: 0;\n background: linear-gradient(#b8ff5a, #5ec21e);\n}\n#xpLevel {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 64px);\n color: #8aff3a;\n font: bold 14px monospace;\n text-shadow:\n 1px 1px 0 #000,\n -1px -1px 0 #000;\n z-index: 6;\n pointer-events: none;\n}\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n/* пузырьки воздуха над едой — под водой */\n#bubbles {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 15px;\n color: #7ad0ff;\n}\n.bubble.empty {\n opacity: 0;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- смерть, горение ---------- */\n#deathScreen {\n position: fixed;\n inset: 0;\n z-index: 35;\n background: rgba(120, 0, 0, 0.55);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.deathPanel {\n text-align: center;\n color: #fff;\n text-shadow: 2px 2px 0 #000;\n}\n.deathPanel h1 {\n font-size: 46px;\n margin-bottom: 8px;\n}\n.deathCause {\n font-size: 16px;\n margin-bottom: 20px;\n}\n.deathBtn {\n cursor: pointer;\n font-size: 16px;\n padding: 10px 28px;\n border-radius: 4px;\n border: 2px solid #aaa;\n background: #3a3f4d;\n color: #eee;\n}\n.deathBtn:hover {\n background: #4a5163;\n}\n#fireOverlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n opacity: 0;\n background: radial-gradient(\n ellipse at 50% 120%,\n rgba(255, 140, 20, 0.75),\n transparent 60%\n );\n transition: opacity 0.2s;\n}\n#fireOverlay.show {\n opacity: 1;\n animation: burn 0.35s infinite alternate;\n}\n@keyframes burn {\n from {\n filter: brightness(0.85);\n }\n to {\n filter: brightness(1.2);\n }\n}\n\n/* ---------- босс и титры ---------- */\n#bossBar {\n position: fixed;\n top: 14px;\n left: 50%;\n transform: translateX(-50%);\n width: min(380px, calc(100% - 32px));\n z-index: 6;\n text-align: center;\n color: #fff;\n text-shadow: 1px 1px 0 #000;\n font-size: 14px;\n pointer-events: none;\n}\n.bossTrack {\n margin-top: 4px;\n height: 8px;\n background: #3a1030;\n border: 1px solid #000;\n}\n.bossFill {\n height: 100%;\n width: 100%;\n background: #c040ff;\n}\n#creditsScreen {\n position: fixed;\n inset: 0;\n z-index: 36;\n background: rgba(20, 10, 40, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.credits {\n font-size: 16px;\n line-height: 1.6;\n margin-bottom: 20px;\n}\n\n/* ---------- чат / консоль ---------- */\n#chat {\n position: fixed;\n left: 10px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n width: min(520px, calc(100% - 20px));\n z-index: 8;\n pointer-events: none;\n font: 13px monospace;\n color: #fff;\n}\n.chatLog {\n display: flex;\n flex-direction: column;\n gap: 2px;\n max-height: 40vh;\n overflow: hidden;\n}\n.chatLine {\n background: rgba(0, 0, 0, 0.45);\n padding: 2px 6px;\n text-shadow: 1px 1px 0 #000;\n animation: chatFade 10s forwards;\n}\n#chat.open .chatLine {\n animation: none;\n opacity: 1;\n}\n@keyframes chatFade {\n 0%,\n 80% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n.chatInput {\n pointer-events: auto;\n width: 100%;\n margin-top: 4px;\n padding: 6px 8px;\n font: 14px monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.7);\n border: 1px solid #888;\n outline: none;\n}\n\n/* ---------- сенсорное управление ---------- */\n.touchUi {\n position: fixed;\n inset: 0;\n z-index: 6; /* над канвасом и HUD, под инвентарём, чатом и оверлеем */\n pointer-events: none;\n touch-action: none;\n}\n.touchUi > * {\n pointer-events: auto;\n touch-action: none;\n user-select: none;\n -webkit-user-select: none;\n}\n.tLook {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 40%;\n}\n.tStick {\n position: absolute;\n left: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 84px);\n width: 132px;\n height: 132px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.12);\n border: 2px solid rgba(255, 255, 255, 0.35);\n}\n.tKnob {\n position: absolute;\n left: 50%;\n top: 50%;\n width: 56px;\n height: 56px;\n margin: -28px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.42);\n pointer-events: none;\n}\n.tBtn {\n position: absolute;\n width: 62px;\n height: 62px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.35);\n border: 2px solid rgba(255, 255, 255, 0.45);\n color: #fff;\n font:\n bold 22px system-ui,\n sans-serif;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.tBtn.on {\n background: rgba(154, 223, 106, 0.5);\n}\n.tJump {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n}\n.tSneak {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 172px);\n}\n.tAttack {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 134px);\n}\n.tUse {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 210px);\n}\n.tInv {\n right: 12px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tDrop {\n right: 124px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tChat {\n right: 68px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n/* на телефоне хотбар над зоной обзора: иначе тап по слоту крутил бы камеру */\nbody.touch #hotbar {\n z-index: 7;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow,\n.invTop {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid,\n.craftGrid {\n display: grid;\n gap: 3px;\n}\n.armorCol {\n display: grid;\n gap: 3px;\n margin-right: 18px;\n}\n.chestGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n.furnaceCol {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n}\n.flame {\n width: 20px;\n height: 20px;\n background: #5a5a5a;\n display: flex;\n align-items: flex-end;\n}\n.flameFill {\n width: 100%;\n height: 0;\n background: linear-gradient(#ffe27a, #f06a14);\n}\n.cookArrow {\n width: 56px;\n height: 14px;\n background: #5a5a5a;\n margin: 0 8px;\n}\n.cookFill {\n height: 100%;\n width: 0;\n background: #f5f5f5;\n}\n.invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n.invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n/* наковальня: цена в уровнях справа от результата */\n.anvilCost {\n min-width: 110px;\n font-size: 12px;\n color: #2a6a2a;\n}\n\n/* стол зачарований: предмет и лазурит, три варианта; зачарованное — с фиолетовым отблеском */\n.enchCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.enchOffers {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 260px;\n}\n.enchOffer {\n padding: 6px 8px;\n font-size: 12px;\n cursor: pointer;\n color: #f0e8ff;\n background: #4a3a6a;\n border: 2px solid #2a1a40;\n}\n.enchOffer:hover {\n background: #5a4a8a;\n}\n/* ткацкий станок: картинка знамени слева от названия узора */\n.loomOffers .enchOffer {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.loomPic {\n width: 24px;\n height: 24px;\n image-rendering: pixelated;\n border: 1px solid #2a1a40;\n}\n/* карта сокровищ: крестик над кладом */\n.mapTreasure {\n position: absolute;\n transform: translate(-50%, -50%);\n color: #c02020;\n font-weight: bold;\n font-size: 14px;\n text-shadow: 0 0 2px #fff;\n pointer-events: none;\n}\n/* достижения: всплывашка справа сверху и список по L */\n#achToast {\n position: absolute;\n top: 12px;\n right: 12px;\n min-width: 220px;\n padding: 8px 12px;\n background: rgba(20, 20, 30, 0.88);\n border: 2px solid #7a6a3a;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 30;\n}\n#achToast .achHead {\n color: #f0d060;\n font-weight: bold;\n margin-bottom: 2px;\n}\n#achList {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: min(560px, 92vw);\n max-height: 80vh;\n overflow-y: auto;\n padding: 10px 14px;\n background: rgba(16, 16, 24, 0.92);\n border: 2px solid #555;\n color: #ccc;\n font-size: 13px;\n z-index: 29;\n}\n#achList h3 {\n margin: 0 0 8px;\n color: #fff;\n}\n#achList .ach {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding: 3px 0;\n border-bottom: 1px solid #2a2a36;\n}\n#achList .ach.done b {\n color: #9ae07a;\n}\n#achList .ach span {\n opacity: 0.7;\n text-align: right;\n}\n.enchOffer.on {\n outline: 2px solid #7ee8e0;\n}\n.beaconInfo {\n font-size: 12px;\n opacity: 0.85;\n margin: 2px 0 4px;\n}\n/* Тление: сердца чернеют */\n#hearts.decay .heart {\n filter: grayscale(1) brightness(0.4);\n}\n.enchOffer.off {\n cursor: default;\n color: #9a90a8;\n background: #5a5560;\n}\n.slot.ench img {\n filter: drop-shadow(0 0 3px #b060ff) saturate(1.3);\n}\n\n/* торговля с жителем: строки сделок с иконками, в тонах дерева */\n.tradeOffers {\n max-height: 260px;\n overflow-y: auto;\n}\n.tradeOffer {\n display: flex;\n align-items: center;\n gap: 4px;\n background: #5a4630;\n border-color: #3a2a18;\n color: #fff4e0;\n}\n.tradeOffer:hover {\n background: #6e583c;\n}\n.tradeOffer img {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n}\n.tradeLeft {\n margin-left: auto;\n padding-left: 8px;\n opacity: 0.75;\n}\n\n/* компас и часы в руке — у правого края хотбара */\n#gadget {\n position: absolute;\n bottom: 58px;\n left: calc(50% + 200px);\n min-width: 40px;\n padding: 4px 8px;\n text-align: center;\n font: 16px/1.2 monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.45);\n border-radius: 6px;\n pointer-events: none;\n}\n#gadget .needle {\n display: inline-block;\n color: #e03030;\n font-size: 22px;\n}\n\n/* карта в руке — справа над хотбаром */\n#mapView {\n position: absolute;\n right: 16px;\n bottom: 70px;\n width: min(256px, 40vw);\n aspect-ratio: 1;\n max-width: 100%;\n border: 4px solid #8a6a40;\n background: #d6be96;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n pointer-events: none;\n}\n#mapView canvas {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#mapView .mapMarker {\n position: absolute;\n color: #fff;\n font-size: 14px;\n text-shadow:\n 0 0 2px #000,\n 0 0 2px #000;\n}\n\n/* варочная стойка: ингредиент и топливо столбиком, три бутылки в ряд */\n.brewCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.bottleRow {\n display: flex;\n gap: 6px;\n}\n/* эффекты зелий — под индикатором времени */\n#effects {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 34px);\n right: 12px;\n z-index: 5;\n color: #fff;\n font-size: 13px;\n text-align: right;\n text-shadow: 1px 1px 0 #000;\n pointer-events: none;\n white-space: pre;\n}\n\n/* креатив: поиск, палитра всех предметов, корзина */\n.creativeSearch {\n width: 100%;\n box-sizing: border-box;\n margin-bottom: 8px;\n padding: 6px 8px;\n font-size: 14px;\n border: 2px solid #555;\n background: #fff;\n}\n.creativeGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n max-height: calc(44px * 5 + 3px * 4);\n overflow-y: auto;\n margin-bottom: 12px;\n padding-right: 4px;\n}\n.creativeGrid .slot {\n cursor: pointer;\n}\n.trashLabel {\n color: #3a3a3a;\n font-size: 13px;\n}\n.slot.trash::before {\n content: \"🗑\";\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 22px;\n opacity: 0.6;\n pointer-events: none;\n}\n#armorBar {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 16px;\n letter-spacing: 3px;\n text-shadow: 1px 1px 0 #000;\n}\n.armorPip {\n color: #cfd6e0;\n}\n.armorPip.half {\n opacity: 0.55;\n}\n.armorPip.empty {\n color: #3a3a3a;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(4, 32, 90, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n#overlay button.accent {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n#overlay button.accent:hover {\n background: #4c8535;\n}\n\n/* ---------- экран «Создать мир» ---------- */\n#worldScreen {\n position: fixed;\n inset: 0;\n z-index: 40; /* над оверлеем паузы */\n background: rgba(4, 20, 50, 0.72);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: calc(var(--idos-safe-top, 0px) + 12px) 12px\n calc(var(--idos-safe-bottom, 0px) + 12px);\n color: #eee;\n font-size: 14px;\n user-select: text;\n}\n.wsPanel {\n background: #22283a;\n border: 2px solid #4a5163;\n border-radius: 8px;\n box-shadow: 0 16px 50px rgba(0, 0, 0, 0.6);\n width: min(880px, 100%);\n max-height: 100%;\n overflow: auto;\n}\n.wsHead {\n display: flex;\n gap: 4px;\n padding: 8px 8px 0;\n border-bottom: 1px solid #3a4157;\n}\n.wsTab,\n.wsClose {\n cursor: pointer;\n background: none;\n border: none;\n color: #aab3c8;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 6px 6px 0 0;\n}\n.wsTab.active {\n color: #9adf6a;\n background: #2c3348;\n}\n.wsClose {\n margin-left: auto;\n font-size: 22px;\n line-height: 1;\n}\n.wsBody {\n display: flex;\n flex-wrap: wrap;\n gap: 18px;\n padding: 16px;\n}\n.wsForm {\n flex: 1 1 360px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n min-width: 0;\n}\n.wsPreview {\n flex: 0 1 280px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n align-items: stretch;\n}\n#wsMap {\n width: 100%;\n aspect-ratio: 1;\n image-rendering: pixelated;\n background: #10141f;\n border: 2px solid #3a4157;\n border-radius: 6px;\n}\n.wsLabel {\n color: #ffd76a;\n font-weight: 600;\n margin-top: 4px;\n}\n.wsNote {\n color: #8f99b0;\n font-weight: normal;\n font-size: 12px;\n}\n.wsRow {\n display: flex;\n flex-wrap: wrap;\n gap: 6px 14px;\n align-items: center;\n}\n.wsRow label {\n cursor: pointer;\n white-space: nowrap;\n}\n.wsRow input[type=\"number\"] {\n width: 56px;\n}\n.wsDrop {\n cursor: pointer;\n min-height: 92px;\n border: 2px dashed #5a6480;\n border-radius: 6px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n padding: 8px;\n color: #8f99b0;\n}\n.wsDrop.over {\n border-color: #9adf6a;\n background: rgba(154, 223, 106, 0.08);\n}\n.wsThumbs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n justify-content: center;\n}\n.wsThumb {\n position: relative;\n}\n.wsThumb img {\n height: 84px;\n max-width: 140px;\n object-fit: cover;\n border-radius: 4px;\n display: block;\n}\n.wsThumbX {\n position: absolute;\n top: -6px;\n right: -6px;\n width: 20px;\n height: 20px;\n line-height: 18px;\n text-align: center;\n border-radius: 50%;\n background: #c0392b;\n color: #fff;\n font-size: 14px;\n cursor: pointer;\n}\n#wsText {\n width: 100%;\n resize: vertical;\n font: inherit;\n padding: 8px;\n border-radius: 6px;\n border: 1px solid #4a5163;\n background: #161b28;\n color: #eee;\n}\n.wsButtons {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 6px;\n}\n#worldScreen button:not(.wsTab):not(.wsClose):not(.wsLink) {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#worldScreen button:disabled {\n opacity: 0.5;\n cursor: default;\n}\n#worldScreen button.wsPrimary {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n.wsLink {\n cursor: pointer;\n background: none;\n border: none;\n color: #8fb8ff;\n text-decoration: underline;\n font-size: 12px;\n}\n.wsStatus {\n min-height: 20px;\n color: #b8e59a;\n}\n.wsStatus.error {\n color: #ff8f80;\n}\n.wsInfo {\n color: #cfd6e6;\n font-size: 13px;\n line-height: 1.4;\n}\n.wsList {\n width: 100%;\n display: flex;\n flex-direction: column;\n gap: 6px;\n color: #aab3c8;\n}\n.wsItem {\n display: flex;\n gap: 8px;\n align-items: center;\n padding: 8px 10px;\n background: #2c3348;\n border-radius: 6px;\n}\n.wsItem > div {\n flex: 1;\n min-width: 0;\n}\n.wsItem.current {\n outline: 1px solid #9adf6a;\n}\n.wsItemName {\n color: #eee;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.wsItemMeta {\n font-size: 12px;\n}\n\n/* Книга на кафедре (ui/BookScreen): страница пергамента поверх игры */\n.bookScreen {\n position: absolute;\n left: 50%;\n top: 50%;\n transform: translate(-50%, -50%);\n width: 320px;\n padding: 14px;\n background: #e8dcb8;\n color: #2a1c0e;\n border: 3px solid #8a6a3a;\n font-size: 13px;\n z-index: 40;\n}\n.bookScreen.hidden {\n display: none;\n}\n.bookTitle {\n font-weight: bold;\n margin-bottom: 8px;\n text-align: center;\n}\n.bookPage {\n min-height: 180px;\n line-height: 15px;\n white-space: pre-wrap;\n}\n.bookPage.hidden,\n.bookNav.hidden {\n display: none;\n}\n.bookEdit {\n width: 100%;\n height: 180px;\n box-sizing: border-box;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n resize: none;\n}\n.bookEdit.hidden {\n display: none;\n}\n.bookNav {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 8px;\n}\n.bookBtn {\n padding: 4px 10px;\n cursor: pointer;\n background: #8a6a3a;\n color: #f4ecd0;\n border: 1px solid #5a4020;\n}\n.bookBtn:hover {\n background: #a07a44;\n}\n.bookCount {\n flex: 1;\n text-align: center;\n}\n.bookTitleInput {\n flex: 1;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n padding: 3px 5px;\n}\n\n/* ---------- сетевая игра (ui/NetUI) ---------- */\n#netScreen {\n position: fixed;\n inset: 0;\n z-index: 40;\n background: rgba(4, 20, 50, 0.72);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: calc(var(--idos-safe-top, 0px) + 12px) 12px\n calc(var(--idos-safe-bottom, 0px) + 12px);\n color: #eee;\n font-size: 14px;\n user-select: text;\n}\n#netScreen.hidden,\n#netTabList.hidden,\n#netScreen .hidden {\n display: none;\n}\n#netScreen .wsPanel {\n width: min(620px, 100%);\n}\n#netScreen .wsForm button {\n cursor: pointer;\n font-size: 13px;\n padding: 6px 12px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#netScreen .wsForm button.wsPrimary {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n#netScreen .wsForm button.wsLink {\n padding: 0 4px;\n border: none;\n background: none;\n color: #8fb8ff;\n}\n.netInput {\n flex: none;\n width: 100%;\n box-sizing: border-box;\n min-width: 0;\n font: inherit;\n padding: 7px 8px;\n border-radius: 6px;\n border: 1px solid #4a5163;\n background: #161b28;\n color: #eee;\n}\n#netTabList {\n position: absolute;\n top: calc(var(--idos-safe-top, 0px) + 40px);\n left: 50%;\n transform: translateX(-50%);\n z-index: 30;\n min-width: 240px;\n max-width: calc(100% - 32px);\n padding: 8px 12px;\n background: rgba(0, 0, 0, 0.6);\n border-radius: 6px;\n color: #fff;\n font-size: 14px;\n pointer-events: none;\n}\n.wsRow .netInput {\n flex: 1 1 200px;\n width: auto;\n}\n.netTabRow {\n display: flex;\n justify-content: space-between;\n gap: 24px;\n padding: 2px 0;\n}\n.netTabPing {\n color: #9adf6a;\n font-size: 12px;\n}\n"
|
|
1129
1129
|
},
|
|
1130
1130
|
{
|
|
1131
1131
|
"path": "systems/Achievements.ts",
|