@idosgames/mcp 0.1.16 → 0.1.17

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.
@@ -60,11 +60,11 @@
60
60
  "version": "0.3.0"
61
61
  },
62
62
  "dependencies": {
63
- "@idosgames/core": "0.17.0",
64
- "@idosgames/module-sdk": "0.4.0",
63
+ "@idosgames/core": "0.18.0",
64
+ "@idosgames/module-sdk": "0.4.1",
65
65
  "three": "0.185.1"
66
66
  },
67
- "contentHash": "0de6a76e323311ee027133892e4fa66c72540ced75d21cb9e441025c06feea6c",
67
+ "contentHash": "df89efc0e7a785db0c4620f3ec4f652bd2174b61883d1d2ff39bf1db57d2c57d",
68
68
  "files": [
69
69
  {
70
70
  "path": "agent.ts",
@@ -188,7 +188,7 @@
188
188
  },
189
189
  {
190
190
  "path": "entities/EntityManager.ts",
191
- "content": "// Менеджер сущностей: мобы, дроп-предметы, сферы опыта, стрелы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Перепись (census) держит население мобов вокруг игрока\n// по правилам Minecraft: мирные — на траве при свете, враждебные — в темноте (ночью и в пещерах).\n// Игрок хранится отдельно (Player), но интерфейс сущностей совместим — в этапе 2 удалённые игроки\n// станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { frogFor } from \"../registry/mobs/frog\";\nimport { dropsOf } from \"../systems/Mining\";\nimport { explodeBlocks, explosionDamage } from \"../systems/Explosion\";\nimport { rayBox } from \"../physics/rayBox\";\nimport { raycastBlocks } from \"../world/Raycast\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport { XpOrb } from \"./XpOrb\";\nimport { Arrow } from \"./Arrow\";\nimport { tippedEffect } from \"../systems/TippedArrow\";\nimport { PrimedTnt } from \"./PrimedTnt\";\nimport { Fireball, type FireballKind } from \"./Fireball\";\nimport { RimEye } from \"./RimEye\";\nimport { CART_VARIANTS, Minecart, type CartVariant } from \"./Minecart\";\nimport { Fang } from \"./Fang\";\nimport { extractOne, insertOne } from \"../world/Containers\";\nimport { HOPPER_COOLDOWN } from \"../world/blockentities/hopper\";\nimport { smokedHive } from \"../world/Hive\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { Boat } from \"./Boat\";\nimport { FishingHook } from \"./FishingHook\";\nimport { INFESTED_NAMES } from \"../registry/blocks/infested\";\nimport { findAltar } from \"./AshLord\";\nimport { startCharge } from \"./AshLordBrain\";\nimport { isSnowGolem } from \"./SnowGolemBrain\";\nimport { findJob, jobAt } from \"../systems/Jobs\";\nimport { ZOMBIE_VILLAGER, curedTypeOf } from \"../systems/Zombify\";\nimport {\n GOLEM_COOLDOWN,\n GOLEM_RADIUS,\n isIronGolem,\n shouldSummonGolem,\n} from \"../systems/Golem\";\nimport {\n REP_CURE,\n REP_HIT_VILLAGER,\n REP_TRADE,\n clampRep,\n} from \"../systems/Reputation\";\n\n/** Секунд между пополнениями товара за работой у своего блока. */\nconst VILLAGER_RESTOCK = 120;\n\n/** id заражённых блоков — из них вылезает чешуйница. */\nconst INFESTED_IDS = new Set(\n INFESTED_NAMES.map((n) => B[n]).filter(\n (id): id is number => id !== undefined,\n ),\n);\nimport type { MobCtx } from \"./MobBrain\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { ItemStack, MobDef } from \"../types\";\n\ntype Entity =\n | Mob\n | ItemDrop\n | XpOrb\n | Arrow\n | PrimedTnt\n | Fireball\n | RimEye\n | Minecart\n | Boat\n | FishingHook\n | Fang;\n\n// Форма сохранённой сущности (выход serialize()) — дискриминируется по `kind`. Стрелы не сохраняются.\ntype EntitySave =\n | {\n kind: \"mob\";\n type: string;\n pos: [number, number, number];\n hp: number;\n baby?: boolean;\n sheared?: boolean;\n /** Житель: использованные сделки до пополнения. */\n uses?: number[];\n /** Житель: занятый рабочий блок и «уже торговал» (systems/Jobs). */\n jobSite?: [number, number, number];\n traded?: boolean;\n /** Житель: опыт профессии (уровни сделок). */\n villagerXp?: number;\n /** Зомби-житель: кем он был и сколько осталось лечиться (systems/Zombify). */\n curedType?: string;\n curing?: number;\n tamed?: boolean;\n /** Имя с бирки (registry/items/nametag). */\n customName?: string;\n sitting?: boolean;\n saddled?: boolean;\n /** Овца: цвет шерсти (нет поля — белая). */\n woolColor?: string;\n /** Волк: запас брони из щитков. */\n wolfArmor?: number;\n /** Дух-помощник: образец и собранное. */\n carry?: string;\n bag?: number;\n /** Вьюк осла, мула, ламы; на поводке. */\n items?: (ItemStack | null)[];\n leashed?: boolean;\n leashPost?: [number, number, number];\n /** Секунд до ухода странствующего торговца и его лам. */\n leaveT?: number;\n /** Пчела: свой улей и пыльца. */\n home?: [number, number, number];\n pollen?: boolean;\n /** Капитан мародёров. */\n captain?: boolean;\n }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n dmg?: number;\n /** Зачарования, вещи панцирного сундука. */\n tag?: Record<string, unknown>;\n }\n | { kind: \"xp\"; pos: [number, number, number]; value: number }\n | {\n kind: \"minecart\";\n pos: [number, number, number];\n vel?: [number, number, number];\n /** Вид (нет — обычная) и груз вагонетки с сундуком или воронкой. */\n variant?: CartVariant;\n items?: (ItemStack | null)[];\n }\n | { kind: \"boat\"; pos: [number, number, number]; wood?: string };\n\nconst randInt = (a: number, b: number): number =>\n a + Math.floor(Math.random() * (b - a + 1));\n\nexport class EntityManager {\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Entity>;\n _nextId: number;\n _spawnTimer: number;\n /** На сколько уровней ночь гасит небесный свет и день ли сейчас — задаёт Game из DayNight. */\n skyDarken = 0;\n isDay = true;\n /** Идёт дождь (Game из systems/Weather). */\n raining = false;\n /** Игрок не спал три ночи — прилетают фантомы (Game.daysAwake). */\n insomnia = false;\n /** Секунд до следующего патруля мародёров. */\n private _patrolT = 300 + Math.random() * 300;\n\n constructor(\n scene: THREE.Scene,\n world: ChunkManager,\n atlas: TextureAtlas,\n player: Player,\n inventory: Inventory,\n ) {\n this.scene = scene;\n this.world = world;\n this.atlas = atlas;\n this.player = player;\n this.inventory = inventory;\n this.entities = new Map();\n this._nextId = 1;\n this._spawnTimer = 0;\n // TNT, подожжённый огнём (поведение огня не знает про сущности — просит событием)\n events.on(\n \"tntPrime\",\n (d: { x: number; y: number; z: number; fuse?: number }) =>\n this.spawnTnt(d.x + 0.5, d.y, d.z + 0.5, d.fuse ?? 4),\n );\n // вагонетку поставили на рельс (предмет minecart — world/behaviours/rails)\n events.on(\n \"spawnMinecart\",\n (d: { x: number; y: number; z: number; variant?: CartVariant }) =>\n this.spawnMinecart(d.x + 0.5, d.y, d.z + 0.5, d.variant),\n );\n events.on(\n \"spawnBoat\",\n (d: { x: number; y: number; z: number; wood: string }) =>\n this.spawnBoat(d.x + 0.5, d.y, d.z + 0.5, d.wood),\n );\n // из заражённого блока вылезает чешуйница (registry/blocks/infested)\n events.on(\n \"blockBreak\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (INFESTED_IDS.has(d.id) && MOBS.silverfish)\n this.spawnMob(\"silverfish\", d.x + 0.5, d.y, d.z + 0.5);\n },\n );\n // мёд собрали — пчёлы в 16 блоках от улья злятся\n events.on(\"hiveHarvest\", (d: { x: number; y: number; z: number }) => {\n if (smokedHive(this.world, d.x, d.y, d.z)) return; // дым костра под ульем их успокаивает\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e.def.bee &&\n !e.dead &&\n e.pos.distanceTo(new THREE.Vector3(d.x, d.y, d.z)) < 16\n )\n e.aggro = true;\n });\n // черепашье яйцо вылупилось (world/behaviours/ocean); выросший черепашонок роняет щиток\n events.on(\"turtleHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.turtle) this.spawnMob(\"turtle\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n events.on(\"mobGrew\", ({ entity }: { entity: Mob }) => {\n const item = entity.def.growDrop;\n if (item && ITEMS[item])\n this.spawnDrop(entity.pos.x, entity.pos.y + 0.3, entity.pos.z, item, 1);\n });\n // яйцо нюхача треснуло — детёныш нюхача\n events.on(\"snifferHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.sniffer)\n this.spawnMob(\"sniffer\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n // лягушачья икра — 2–6 головастиков в воду под ней; головастик вырос — лягушка по биому\n events.on(\"frogspawnHatch\", (d: { x: number; y: number; z: number }) => {\n if (!MOBS.tadpole) return;\n for (let i = randInt(2, 6); i > 0; i--)\n this.spawnMob(\n \"tadpole\",\n d.x + 0.3 + Math.random() * 0.4,\n d.y - 0.8,\n d.z + 0.3 + Math.random() * 0.4,\n );\n });\n events.on(\"mobMature\", ({ entity }: { entity: Mob }) => {\n const into =\n entity.def.growsInto === \"frog\"\n ? frogFor(this.world.generator.biomeOf?.(entity.pos.x, entity.pos.z))\n : entity.def.growsInto;\n if (!into || !MOBS[into] || entity.dead) return;\n this.spawnMob(into, entity.pos.x, entity.pos.y, entity.pos.z);\n entity.dead = true; // hp > 0 — без дропа: превратился\n });\n // третий пепельный череп на Т из песка душ — Пепельный владыка (entities/AshLord)\n events.on(\n \"blockPlace\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (d.id === B.ash_skull) this.summonAtAltar(d.x, d.y, d.z);\n if (d.id === B.pumpkin)\n // тыква достраивает снеговика или железного голема (systems/Golem)\n if (!this.buildSnowGolem(d.x, d.y, d.z))\n this.buildIronGolem(d.x, d.y, d.z);\n },\n );\n // ручные волки: на того, кого бьёт игрок, и на того, кто бьёт игрока\n events.on(\"playerHitMob\", ({ entity }: { entity: Mob }) => {\n this.guard(entity);\n this.villageAnger(entity);\n });\n // деревня помнит добро: торговля и вылеченный зомби-житель поднимают репутацию\n events.on(\"trade\", () => this._addRep(REP_TRADE));\n events.on(\"villagerCured\", () => this._addRep(REP_CURE));\n // колокол: налётчики вокруг светятся, как в Minecraft\n events.on(\"bellRing\", (p: { x: number; y: number; z: number }) =>\n this.markRaiders(p.x, p.y, p.z),\n );\n events.on(\"mobAttack\", ({ entity }: { entity: Mob }) => this.guard(entity));\n }\n\n /** Убрать всех враждебных мобов (переход на мирную сложность — systems/Difficulty). */\n clearHostiles(): void {\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.category === \"hostile\" && !e.def.boss)\n e.dead = true; // hp > 0 — исчезают без дропа, как при удалении игрока\n }\n\n /** Сдвинуть репутацию игрока в деревне и сказать об этом HUD. */\n private _addRep(by: number): void {\n this.player.villageRep = clampRep(this.player.villageRep + by);\n events.emit(\"villageRep\", { rep: this.player.villageRep });\n }\n\n /**\n * Ударили жителя: репутация падает, а железные големы рядом бросаются на обидчика — как в\n * Minecraft, где деревня помнит зло (systems/Reputation).\n */\n villageAnger(target: Mob): void {\n if (!target.def.trades || target.dead) return;\n this._addRep(REP_HIT_VILLAGER);\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === \"iron_golem\" &&\n e.pos.distanceTo(target.pos) < GOLEM_RADIUS\n )\n e.aggro = true;\n }\n\n /** Звон колокола: налётчики в 32 блоках светятся 3 секунды (видно сквозь стены). */\n markRaiders(x: number, y: number, z: number): void {\n const at = new THREE.Vector3(x + 0.5, y + 0.5, z + 0.5);\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.raider && e.pos.distanceTo(at) < 32)\n e.glowT = 3;\n }\n\n /** Череп в (x,y,z) достроил алтарь — блоки исчезают, встаёт владыка и набирает силу. */\n summonAtAltar(x: number, y: number, z: number): Mob | null {\n const a = findAltar(\n (bx, by, bz) => this.world.getBlock(bx, by, bz),\n x,\n y,\n z,\n );\n if (!a || !MOBS.ash_lord) return null;\n for (const [cx, cy, cz] of a.cells) this.world.setBlock(cx, cy, cz, B.air!);\n const m = this.spawnMob(\"ash_lord\", a.x, a.y, a.z);\n startCharge(m);\n events.emit(\"ashLordSummoned\", { x: a.x, y: a.y, z: a.z });\n return m;\n }\n\n /** Железный голем: тыква на фигуре из четырёх железных блоков — блоки исчезают, встаёт голем. */\n buildIronGolem(x: number, y: number, z: number): Mob | null {\n const get = (bx: number, by: number, bz: number) =>\n this.world.getBlock(bx, by, bz);\n if (!MOBS.iron_golem || !isIronGolem(get, x, y, z)) return null;\n const armsX = get(x - 1, y - 1, z) === B.iron_block;\n for (const [cx, cy, cz] of [\n [x, y, z],\n [x, y - 1, z],\n [x, y - 2, z],\n armsX ? [x - 1, y - 1, z] : [x, y - 1, z - 1],\n armsX ? [x + 1, y - 1, z] : [x, y - 1, z + 1],\n ] as [number, number, number][])\n this.world.setBlock(cx, cy, cz, B.air!);\n const m = this.spawnMob(\"iron_golem\", x + 0.5, y - 2, z + 0.5);\n events.emit(\"ironGolemBuilt\", { x, y, z });\n return m;\n }\n\n /** Снеговик: тыква на двух блоках снега — блоки исчезают, на их месте снеговик. */\n buildSnowGolem(x: number, y: number, z: number): Mob | null {\n if (\n !MOBS.snow_golem ||\n !isSnowGolem((bx, by, bz) => this.world.getBlock(bx, by, bz), x, y, z)\n )\n return null;\n for (let k = 0; k < 3; k++) this.world.setBlock(x, y - k, z, B.air!);\n const m = this.spawnMob(\"snow_golem\", x + 0.5, y - 2, z + 0.5);\n events.emit(\"snowGolemBuilt\", { x, y, z });\n return m;\n }\n\n /** Клыки ворожея: 16 штук по прямой от from к to через 1.25 блока, каждый позже предыдущего. */\n spawnFangs(from: THREE.Vector3, to: THREE.Vector3, ownerId: number): void {\n const dir = to.clone().sub(from).setY(0);\n const len = dir.length();\n if (len < 1e-3) return;\n dir.divideScalar(len);\n for (let i = 1; i <= 16; i++) {\n const x = from.x + dir.x * i * 1.25,\n z = from.z + dir.z * i * 1.25;\n // пол у линии: твёрдый блок в нескольких блоках от высоты ворожея\n let y = Math.floor(from.y) + 2;\n for (\n let k = 0;\n k < 6 &&\n !BLOCKS[this.world.getBlock(Math.floor(x), y - 1, Math.floor(z))]\n ?.solid;\n k++\n )\n y--;\n const f = new Fang(this._nextId++, x, y, z, i * 0.05, ownerId);\n this.entities.set(f.id, f);\n this.scene.add(f.mesh);\n }\n }\n\n /** До следующей попытки прихода странствующего торговца, сек. */\n private _traderT = 1200;\n /** Перезарядка вызова железного голема деревней (systems/Golem). */\n private _golemT = 0;\n\n /** Странствующий торговец с двумя ламами на поводке — на суше в 16–32 блоках от игрока. */\n spawnWanderingTrader(): Mob | null {\n if (this.world.dimension !== \"overworld\" || !MOBS.wandering_trader)\n return null;\n const p = this.player.pos;\n for (let k = 0; k < 8; k++) {\n const a = Math.random() * Math.PI * 2,\n r = 16 + Math.random() * 16;\n const x = Math.floor(p.x + Math.cos(a) * r),\n z = Math.floor(p.z + Math.sin(a) * r);\n const top = this.world.surfaceY(x, z);\n const g = BLOCKS[this.world.getBlock(x, top, z)];\n if (\n !g?.solid ||\n g.liquid ||\n this.world.getBlock(x, top + 1, z) !== 0 ||\n this.world.getBlock(x, top + 2, z) !== 0\n )\n continue;\n const t = this.spawnMob(\"wandering_trader\", x + 0.5, top + 1, z + 0.5);\n for (const dx of [-1.5, 1.5]) {\n const l = this.spawnMob(\"trader_llama\", x + 0.5 + dx, top + 1, z + 0.5);\n l.leashed = true;\n l.leashMobId = t.id;\n }\n events.emit(\"traderArrived\", { x, y: top + 1, z });\n return t;\n }\n return null;\n }\n\n /** Патруль мародёров: 2–5 во главе с капитаном на суше в 24–48 блоках от игрока. */\n spawnPatrol(): number {\n if (this.world.dimension !== \"overworld\" || !MOBS.marauder) return 0;\n const p = this.player.pos;\n const a = Math.random() * Math.PI * 2,\n r = 24 + Math.random() * 24;\n const x = Math.floor(p.x + Math.cos(a) * r),\n z = Math.floor(p.z + Math.sin(a) * r);\n const top = this.world.surfaceY(x, z);\n if (\n !BLOCKS[this.world.getBlock(x, top, z)]?.solid ||\n this.world.getBlock(x, top + 1, z) !== 0\n )\n return 0;\n const n = randInt(2, 5);\n for (let i = 0; i < n; i++) {\n const sx = x + (i ? randInt(-2, 2) : 0),\n sz = z + (i ? randInt(-2, 2) : 0);\n const m = this.spawnMob(\n \"marauder\",\n sx + 0.5,\n this.world.surfaceY(sx, sz) + 1.01,\n sz + 0.5,\n );\n if (i === 0) m.captain = true;\n }\n events.emit(\"patrolSpawned\", { x, z, n });\n return n;\n }\n\n /** Черепаха после размножения откладывает 1–4 яйца на песок рядом. */\n layEggs(m: Mob): number {\n const egg = B[m.def.layEggs ?? \"\"];\n if (egg === undefined) return 0;\n // лягушка — одну икру на воду, нюхач — одно яйцо на любой твёрдый блок, черепаха — 1–4 яйца на песок\n const spawn = m.def.layEggs === \"frogspawn\",\n sniffer = m.def.layEggs === \"sniffer_egg\";\n const baseOk = (id: number): boolean =>\n spawn\n ? id === B.water\n : sniffer\n ? !!BLOCKS[id]?.solid && BLOCKS[id]?.shape === \"cube\"\n : id === B.sand;\n let left = spawn || sniffer ? 1 : randInt(1, 4);\n const x0 = Math.floor(m.pos.x),\n y0 = Math.floor(m.pos.y),\n z0 = Math.floor(m.pos.z);\n for (let dx = -2; dx <= 2 && left > 0; dx++)\n for (let dz = -2; dz <= 2 && left > 0; dz++)\n for (let dy = -1; dy <= 1 && left > 0; dy++) {\n const x = x0 + dx,\n y = y0 + dy,\n z = z0 + dz;\n if (\n !baseOk(this.world.getBlock(x, y - 1, z)) ||\n this.world.getBlock(x, y, z) !== B.air\n )\n continue;\n this.world.setBlock(x, y, z, egg, 0);\n left--;\n }\n events.emit(\"eggsLaid\", { entity: m });\n return left;\n }\n\n /** Вода в квадрате r вокруг клетки (на её высоте и выше на блок) — пляж, а не пустыня. */\n /** Есть ли блок id в кубе радиуса r (аксолотль — мох пышных пещер рядом с озером). */\n private _blockNear(\n x: number,\n y: number,\n z: number,\n id: number | undefined,\n r: number,\n ): boolean {\n if (id === undefined) return false;\n for (let dy = -r; dy <= r; dy++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === id) return true;\n return false;\n }\n\n private _waterNear(x: number, y: number, z: number, r: number): boolean {\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n for (let dy = 0; dy <= 1; dy++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === B.water)\n return true;\n return false;\n }\n\n /** Выпустить пчелу из улья (x,y,z) в свободную соседнюю клетку; false — места нет. */\n spawnBee(x: number, y: number, z: number): boolean {\n for (const [dx, dy, dz] of [\n [0, 0, -1],\n [1, 0, 0],\n [0, 0, 1],\n [-1, 0, 0],\n [0, 1, 0],\n [0, -1, 0],\n ] as const) {\n if (this.world.getBlock(x + dx, y + dy, z + dz) !== 0) continue;\n const b = this.spawnMob(\"bee\", x + dx + 0.5, y + dy + 0.1, z + dz + 0.5);\n b.home = [x, y, z];\n return true;\n }\n return false;\n }\n\n /** Ручные волки рядом с игроком (24 блока) бросаются на `target`. */\n guard(target: Mob): void {\n if (target.tamed || target.dead) return;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e !== target &&\n e.tamed &&\n e.def.guards &&\n !e.sitting &&\n !e.dead &&\n e.pos.distanceTo(this.player.pos) < 24\n )\n e.targetId = target.id;\n }\n\n /** Груз вагонетки как контейнер (воронка под рельсом и в вагонетку, экран сундука). */\n /** Груз вагонетки или вьюк моба — как контейнер для экрана сундука. */\n cartBox(c: Minecart | Mob): BlockEntity {\n return {\n type:\n c.kind === \"minecart\" && c.variant === \"hopper\" ? \"hopper\" : \"chest\",\n x: Math.floor(c.pos.x),\n y: Math.floor(c.pos.y),\n z: Math.floor(c.pos.z),\n state: { items: c.items },\n };\n }\n\n /** Вагонетка с грузом в клетке (x, y, z) — как контейнер; null — нет такой. */\n cartItems(x: number, y: number, z: number): BlockEntity | null {\n for (const e of this.entities.values())\n if (\n e.kind === \"minecart\" &&\n !e.dead &&\n e.items.length &&\n Math.floor(e.pos.x) === x &&\n Math.floor(e.pos.y) === y &&\n Math.floor(e.pos.z) === z\n )\n return this.cartBox(e);\n return null;\n }\n\n /**\n * Работа вагонетки за тик: динамит взрывается, запитанные активирующие рельсы высаживают седока и\n * запирают воронку; вагонетка с воронкой берёт из контейнера над собой или лежащие рядом предметы.\n */\n private _cartWork(c: Minecart, dt: number): void {\n if (c.boom) {\n c.dead = true;\n this.explode(c.pos.x, c.pos.y + 0.5, c.pos.z, 4);\n return;\n }\n if (c.onActivator && c.rider && this.player.riding === c)\n this.player.dismount();\n if (c.variant !== \"hopper\" || c.onActivator) return;\n c.hopCd -= dt;\n if (c.hopCd > 0) return;\n c.hopCd = HOPPER_COOLDOWN;\n const box = this.cartBox(c);\n const into = (s: ItemStack) => insertOne(box, s, \"top\");\n const above = this.world.containerAt?.(box.x, box.y + 1, box.z);\n if (above) extractOne(above, into);\n else\n this.takeDrop(\n c.pos.x - 0.7,\n c.pos.y - 0.2,\n c.pos.z - 0.7,\n c.pos.x + 0.7,\n c.pos.y + 1.2,\n c.pos.z + 0.7,\n into,\n );\n }\n\n spawnMinecart(\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n ): Minecart {\n const c = new Minecart(this._nextId++, x, y, z, variant);\n this.entities.set(c.id, c);\n this.scene.add(c.mesh);\n return c;\n }\n\n spawnBoat(x: number, y: number, z: number, wood: string): Boat {\n const b = new Boat(this._nextId++, x, y, z, wood);\n this.entities.set(b.id, b);\n this.scene.add(b.mesh);\n return b;\n }\n\n /** Забросить поплавок игрока (прежний, если был, исчезает). */\n castHook(pos: THREE.Vector3, vel: THREE.Vector3): FishingHook {\n const old = this.playerHook();\n if (old) old.dead = true;\n const h = new FishingHook(this._nextId++, pos, vel);\n this.entities.set(h.id, h);\n this.scene.add(h.mesh, h.line);\n return h;\n }\n\n /** Поплавок игрока в мире или null. */\n playerHook(): FishingHook | null {\n for (const e of this.entities.values())\n if (e.kind === \"hook\" && !e.dead) return e;\n return null;\n }\n\n /** Луч по транспорту (вагонетки, лодки): ближайший и расстояние до него. */\n raycastRide(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): { cart: Minecart | Boat; t: number } | null {\n let best: { cart: Minecart | Boat; t: number } | null = null;\n for (const e of this.entities.values()) {\n // своя вагонетка или лодка (в ней сидит игрок) лучу не мешает\n if ((e.kind !== \"minecart\" && e.kind !== \"boat\") || e.dead || e.rider)\n continue;\n const hw = e.kind === \"boat\" ? 0.7 : 0.49;\n const t = rayBox(\n origin,\n dir,\n best ? best.t : maxDist,\n { x: e.pos.x - hw, y: e.pos.y, z: e.pos.z - hw },\n {\n x: e.pos.x + hw,\n y: e.pos.y + (e.kind === \"boat\" ? 0.6 : 0.7),\n z: e.pos.z + hw,\n },\n );\n if (t !== null) best = { cart: e, t };\n }\n return best;\n }\n\n // ---- появление ------------------------------------------------------------------------\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n dmg?: number,\n tag?: Record<string, unknown>,\n ): ItemDrop {\n // слияние с ближайшим таким же дропом (инструменты с износом и вещи с tag не сливаются)\n const mergeable = !dmg && !tag && (ITEMS[item]?.maxStack ?? 64) > 1;\n for (const e of mergeable ? this.entities.values() : []) {\n if (\n e.kind === \"drop\" &&\n e.item === item &&\n !e.dead &&\n !e.dmg &&\n e.pos.distanceToSquared({ x, y, z }) < CONFIG.drops.mergeRadius ** 2\n ) {\n e.count += count;\n return e;\n }\n }\n // лимит: убиваем самый старый дроп (уже помеченные на удаление не считаем)\n const drops = [...this.entities.values()].filter(\n (e): e is ItemDrop => e.kind === \"drop\" && !e.dead,\n );\n if (drops.length >= CONFIG.drops.maxCount)\n drops.sort((a, b) => b.age - a.age)[0]!.dead = true;\n const drop = new ItemDrop(this._nextId++, this.atlas, x, y, z, item, count);\n if (dmg) drop.dmg = dmg;\n if (tag) drop.tag = tag;\n this.entities.set(drop.id, drop);\n this.scene.add(drop.mesh);\n return drop;\n }\n\n /** Воронка: забрать из лежащего в коробке дропа один предмет, если accept его взял. */\n takeDrop(\n x0: number,\n y0: number,\n z0: number,\n x1: number,\n y1: number,\n z1: number,\n accept: (s: ItemStack) => boolean,\n ): boolean {\n for (const e of this.entities.values()) {\n if (e.kind !== \"drop\" || e.dead) continue;\n const p = e.pos;\n if (p.x < x0 || p.x > x1 || p.y < y0 || p.y > y1 || p.z < z0 || p.z > z1)\n continue;\n if (!accept({ item: e.item, count: 1, ...(e.dmg ? { dmg: e.dmg } : {}) }))\n continue;\n e.count -= 1;\n if (e.count <= 0) e.dead = true;\n return true;\n }\n return false;\n }\n\n /** Выбрасыватель: стак со скоростью, поднять можно не сразу. */\n ejectItem(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n s: ItemStack,\n ): void {\n const d = this.spawnDrop(x, y, z, s.item, s.count, s.dmg, s.tag);\n d.vel.set(vx, vy, vz);\n d.pickupDelay = 1;\n }\n\n /** Раздатчик: стрела без стрелка; отравленная несёт свой эффект (systems/TippedArrow). */\n shootArrow(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n item = \"arrow\",\n ): void {\n const a = this.spawnArrow(\n new THREE.Vector3(x, y, z),\n new THREE.Vector3(vx, vy, vz),\n 3,\n false,\n -1,\n );\n if (item !== \"arrow\") {\n a.effect = tippedEffect(item);\n a.pick = { item, count: 1 };\n }\n }\n\n spawnMob(type: string, x: number, y: number, z: number, baby = false): Mob {\n const mob = new Mob(this._nextId++, MOBS[type]!, x, y, z, baby);\n this.entities.set(mob.id, mob);\n this.scene.add(mob.mesh);\n return mob;\n }\n\n /** Опыт сферами (крупные номиналы, как в Minecraft), не больше 10 сфер. */\n spawnXp(x: number, y: number, z: number, amount: number): void {\n let left = Math.round(amount);\n for (let n = 0; left > 0 && n < 10; n++) {\n const v =\n n === 9 ? left : left >= 17 ? 17 : left >= 7 ? 7 : left >= 3 ? 3 : 1;\n const orb = new XpOrb(this._nextId++, x, y, z, v);\n this.entities.set(orb.id, orb);\n this.scene.add(orb.mesh);\n left -= v;\n }\n }\n\n spawnArrow(\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n fromPlayer: boolean,\n shooterId: number,\n ): Arrow {\n const a = new Arrow(\n this._nextId++,\n pos,\n vel,\n damage,\n fromPlayer,\n shooterId,\n );\n this.entities.set(a.id, a);\n this.scene.add(a.mesh);\n return a;\n }\n\n /** Огненный шар моба Пекла. */\n spawnFireball(\n kind: FireballKind,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n shooterId: number,\n ): Fireball {\n const f = new Fireball(this._nextId++, kind, pos, vel, damage, shooterId);\n this.entities.set(f.id, f);\n this.scene.add(f.mesh);\n return f;\n }\n\n /** Брошенное Око Предела — летит к цели (цитадели). */\n spawnRimEye(from: THREE.Vector3, target: { x: number; z: number }): RimEye {\n const e = new RimEye(this._nextId++, from, target);\n this.entities.set(e.id, e);\n this.scene.add(e.mesh);\n events.emit(\"eyeThrown\", { x: from.x, y: from.y, z: from.z });\n return e;\n }\n\n /** Ударили моба со стайной злостью — злятся все такие же в 32 блоках. */\n private _angerGroup(m: Mob): void {\n m.angerCall = false;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === m.def.name &&\n e.pos.distanceTo(m.pos) < 32\n )\n e.aggro = true;\n }\n\n /** Взведённая взрывчатка (огниво, огонь, соседний взрыв). */\n spawnTnt(x: number, y: number, z: number, fuse: number): PrimedTnt {\n const t = new PrimedTnt(this._nextId++, this.atlas, x, y, z, fuse);\n this.entities.set(t.id, t);\n this.scene.add(t.mesh);\n events.emit(\"tntFuse\", { x, y, z });\n return t;\n }\n\n /** Удар игрока по мобу. */\n hurt(entityId: number, damage: number, fromPos: THREE.Vector3): void {\n const e = this.entities.get(entityId);\n if (!e || e.kind !== \"mob\" || e.dead) return;\n // дракон: по голове — полный урон, по телу — четверть (как в Minecraft)\n if (e.def.boss === \"dragon\" && !this._headHit(e))\n damage = Math.max(1, damage / 4);\n e.hurt(damage, fromPos, true);\n // шипы глубинника: бьёшь, пока он не заряжает луч, — укол в ответ\n if (e.def.thorns && e.beamT <= 0)\n this.player.damage(e.def.thorns, \"thorns\");\n }\n\n /** Луч взгляда игрока проходит у головы дракона (перед центром, на половине ширины). */\n private _headHit(m: Mob): boolean {\n const fwd = new THREE.Vector3(-Math.sin(m.yaw), 0, -Math.cos(m.yaw));\n const head = m.pos\n .clone()\n .addScaledVector(fwd, m.width * 0.5)\n .setY(m.pos.y + m.height * 0.6);\n const eye = this.player.eyePos,\n dir = this.player.lookDir();\n const t = Math.max(0, head.clone().sub(eye).dot(dir));\n return eye.clone().addScaledVector(dir, t).distanceTo(head) < 2.5;\n }\n\n // ---- взрыв -------------------------------------------------------------------------------\n\n /** Взрыв силы power: блоки (дроп с шансом 1/сила), урон и отброс игроку, мобам и предметам. */\n explode(x: number, y: number, z: number, power: number): void {\n const destroyed = explodeBlocks(this.world, x, y, z, power);\n for (const b of destroyed)\n // соседняя взрывчатка взводится с коротким фитилём — цепная реакция\n if (b.id === B.tnt)\n this.spawnTnt(b.x + 0.5, b.y, b.z + 0.5, 0.5 + Math.random());\n else if (Math.random() < 1 / power)\n for (const d of dropsOf(BLOCKS[b.id]!, b.meta))\n if (ITEMS[d.item])\n this.spawnDrop(b.x + 0.5, b.y + 0.5, b.z + 0.5, d.item, d.n);\n\n const center = new THREE.Vector3(x, y, z);\n const R = power * 2;\n const blast = (\n pos: THREE.Vector3,\n h: number,\n apply: (dmg: number, dir: THREE.Vector3, impact: number) => void,\n ) => {\n const c = new THREE.Vector3(pos.x, pos.y + h / 2, pos.z);\n const dist = c.distanceTo(center);\n if (dist >= R) return;\n const dir = c.clone().sub(center);\n const len = dir.length();\n if (len > 1e-4) dir.divideScalar(len);\n else dir.set(0, 1, 0);\n const exposure =\n len > 1e-4 && raycastBlocks(this.world, center, dir, len) ? 0.3 : 1;\n const impact = (1 - dist / R) * exposure;\n apply(explosionDamage(impact, power), dir, impact);\n };\n blast(this.player.pos, 1.8, (dmg, dir, impact) => {\n this.player.damage(dmg, \"explosion\");\n this.player.vel.addScaledVector(dir, impact * 12);\n });\n for (const e of this.entities.values()) {\n if (e.kind === \"mob\" && !e.dead)\n blast(e.pos, e.height, (dmg, dir, impact) => {\n e.hurt(dmg, center, false);\n e.vel.addScaledVector(dir, impact * 12);\n });\n else if (e.kind === \"drop\" || e.kind === \"tnt\")\n blast(e.pos, 0.5, (_d, dir, impact) =>\n e.vel.addScaledVector(dir, impact * 10),\n );\n }\n events.emit(\"explosion\", { x, y, z, power });\n }\n\n // ---- симуляция ----------------------------------------------------------------------------\n\n private _ctx(): MobCtx {\n const held = this.inventory.heldItem();\n return {\n world: this.world,\n player: this.player,\n isDay: this.isDay,\n skyDarken: this.skyDarken,\n raining: this.raining,\n heldItem: held ? held.item : null,\n shoot: (from, to, speed, damage, shooter, kind = \"arrow\") => {\n if (kind !== \"arrow\" && kind !== \"potion\" && kind !== \"snowball\") {\n // огненный шар летит по прямой — без поправки на падение\n const dir = to.clone().sub(from).normalize();\n this.spawnFireball(\n kind,\n from.clone().addScaledVector(dir, 0.8),\n dir.multiplyScalar(speed),\n damage,\n shooter.id,\n );\n events.emit(\"mobShoot\", { entity: shooter });\n return;\n }\n const d = to.clone().sub(from);\n const dist = d.length();\n const t = dist / speed;\n d.y += 0.5 * 20 * t * t; // поправка на падение стрелы\n d.x += (Math.random() - 0.5) * dist * 0.08;\n d.z += (Math.random() - 0.5) * dist * 0.08;\n d.normalize();\n if (kind === \"potion\" || kind === \"snowball\")\n // зелье ведьмы и снежок снеговика — дугой, как стрела (та же поправка на падение)\n this.spawnFireball(\n kind,\n from.clone().addScaledVector(d, 0.6),\n d.clone().multiplyScalar(speed),\n damage,\n shooter.id,\n );\n else\n this.spawnArrow(\n from.clone().addScaledVector(d, 0.6),\n d.clone().multiplyScalar(speed),\n damage,\n false,\n shooter.id,\n ).effect = shooter.def.ranged?.effect ?? null; // стрела зимогора замедляет\n events.emit(\"mobShoot\", { entity: shooter });\n },\n convert: (villager) => this.convertToZombie(villager),\n crystals: () => {\n const out: Mob[] = [];\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.def.name === \"beyond_crystal\")\n out.push(e);\n return out;\n },\n explode: (x, y, z, p) => this.explode(x, y, z, p),\n summon: (type, x, y, z) =>\n MOBS[type] ? this.spawnMob(type, x, y, z) : null,\n fangs: (from, to, owner) => this.spawnFangs(from, to, owner.id),\n // владыка ломает блоки вокруг себя — с дропом; неломаемые (бедрок, порталы) и жидкости стоят\n breakBlock: (x, y, z) => {\n const id = this.world.getBlock(x, y, z);\n const def = BLOCKS[id];\n if (!id || !def || def.liquid || !Number.isFinite(def.hardness))\n return false;\n const meta = this.world.getMeta(x, y, z);\n this.world.setBlock(x, y, z, B.air!);\n for (const d of dropsOf(def, meta))\n if (ITEMS[d.item])\n this.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n return true;\n },\n mob: (id) => {\n const e = this.entities.get(id);\n return e && e.kind === \"mob\" && !e.dead ? e : null;\n },\n near: (pos, r, pred) => {\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.pos.distanceTo(pos) < r &&\n pred(e)\n )\n return e;\n return null;\n },\n nearDrop: (pos, r, item) => {\n let best: ItemDrop | null = null,\n bd = r * r;\n for (const e of this.entities.values()) {\n if (\n e.kind !== \"drop\" ||\n e.dead ||\n e.item !== item ||\n e.age < e.pickupDelay\n )\n continue;\n const d2 = e.pos.distanceToSquared(pos);\n if (d2 < bd) {\n bd = d2;\n best = e;\n }\n }\n return best ? { id: best.id, pos: best.pos } : null;\n },\n takeDrop: (id, max) => {\n const e = this.entities.get(id);\n if (!e || e.kind !== \"drop\" || e.dead) return 0;\n const n = Math.min(max, e.count);\n e.count -= n;\n if (e.count <= 0) e.dead = true;\n return n;\n },\n giveDrop: (x, y, z, item, count) => {\n this.spawnDrop(x, y, z, item, count);\n },\n findMate: (m) => {\n let best: Mob | null = null,\n bd = 64;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e === m || e.dead || e.baby) continue;\n // своя порода или указанная в mates (лошадь и осёл)\n if ((e.def !== m.def && !m.def.mates?.[e.def.name]) || e.loveT <= 0)\n continue;\n const d2 = e.pos.distanceToSquared(m.pos);\n if (d2 < bd) {\n bd = d2;\n best = e;\n }\n }\n return best;\n },\n breed: (a, b) => {\n a.loveT = b.loveT = 0;\n a.breedCd = b.breedCd = 300; // 5 минут, как в Minecraft\n if (a.def.layEggs)\n this.layEggs(a); // черепаха — яйцами на песок\n else\n this.spawnMob(\n // разные породы — детёныш из таблицы mates (лошадь + осёл → мул)\n a.def === b.def\n ? a.def.name\n : (a.def.mates?.[b.def.name] ?? a.def.name),\n (a.pos.x + b.pos.x) / 2,\n a.pos.y,\n (a.pos.z + b.pos.z) / 2,\n true,\n );\n this.spawnXp(a.pos.x, a.pos.y + 0.5, a.pos.z, randInt(1, 7));\n events.emit(\"mobBred\", { type: a.def.name });\n },\n };\n }\n\n tick(dt: number): void {\n const ctx = this._ctx();\n const mobs = [...this.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\",\n );\n for (const e of this.entities.values()) {\n if (e.kind === \"drop\")\n e.tick(dt, this.world, this.player, this.inventory);\n else if (e.kind === \"mob\") {\n e.tick(dt, ctx);\n if (e.angerCall) this._angerGroup(e);\n } else if (e.kind === \"xp\") e.tick(dt, this.world, this.player);\n else if (e.kind === \"tnt\")\n e.tick(dt, this.world, (x, y, z, p) => this.explode(x, y, z, p));\n else if (e.kind === \"fireball\")\n e.tick(\n dt,\n this.world,\n this.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n mobs,\n );\n else if (e.kind === \"minecart\" || e.kind === \"boat\") {\n e.tick(dt, this.world);\n if (e.kind === \"minecart\") this._cartWork(e, dt);\n } else if (e.kind === \"hook\")\n e.tick(\n dt,\n this.world,\n this.player.pos,\n this.raining,\n this.inventory.heldItem()?.item === \"fishing_rod\",\n );\n else if (e.kind === \"rimeye\") {\n // долетело: в 80% падает предметом, иначе разбивается\n if (e.tick(dt)) {\n if (Math.random() < 0.8)\n this.spawnDrop(e.pos.x, e.pos.y, e.pos.z, \"rim_eye\", 1);\n else\n events.emit(\"eyeShatter\", { x: e.pos.x, y: e.pos.y, z: e.pos.z });\n }\n } else e.tick(dt, this.world, this.player, mobs, this.inventory);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0) this._mobDied(e);\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Смерть моба: дроп по таблице (у детёнышей нет) и опыт, если бил игрок. */\n private _mobDied(m: Mob): void {\n events.emit(\"mobDied\", { entity: m });\n // капитан мародёров от руки игрока — Дурное знамение, уровень растёт до 5 (как в Minecraft)\n if (m.captain && m.hitByPlayerT > 0) {\n const level = Math.min(\n 5,\n (this.player.effects.get(\"bad_omen\")?.level ?? 0) + 1,\n );\n this.player.addEffect(\"bad_omen\", 6000, level);\n events.emit(\"badOmen\", { level });\n }\n if (m.def.deathExplode) {\n // лечивший дракона кристалл, взорвавшись, ранит его на 10 (как в Minecraft)\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.boss && e.healFrom === m.id) {\n e.healFrom = null;\n e.damage(10);\n }\n this.explode(m.pos.x, m.pos.y + 1, m.pos.z, m.def.deathExplode);\n }\n // слизень распадается на слизней поменьше\n const split = m.def.splitsInto;\n if (split)\n for (let i = randInt(split.n[0], split.n[1]); i > 0; i--)\n this.spawnMob(\n split.mob,\n m.pos.x + (Math.random() - 0.5) * m.width,\n m.pos.y + 0.2,\n m.pos.z + (Math.random() - 0.5) * m.width,\n );\n const drop = (item: string, n: number) => {\n if (n > 0 && ITEMS[item])\n this.spawnDrop(m.pos.x, m.pos.y + 0.3, m.pos.z, item, n);\n };\n if (!m.baby) {\n for (const d of m.def.drops) {\n if (d.chance !== undefined && Math.random() > d.chance) continue;\n drop(d.item, d.max ? randInt(d.n, d.max) : d.n);\n }\n const s = m.def.shearDrop;\n if (s && !m.sheared) drop(m.woolItem(), s.n);\n if (m.carry) drop(m.carry.item, 1 + m.bag); // дух-помощник роняет образец и собранное\n // вьюк: вещи и сам сундук; поводок\n if (m.items.length) {\n drop(\"chest\", 1);\n for (const it of m.items) if (it) drop(it.item, it.count);\n }\n if (m.leashed) drop(\"lead\", 1);\n if (m.hitByPlayerT > 0 && m.def.xp)\n this.spawnXp(\n m.pos.x,\n m.pos.y + 0.5,\n m.pos.z,\n randInt(m.def.xp[0], m.def.xp[1]),\n );\n }\n }\n\n /**\n * Профессия от рабочего блока (systems/Jobs), раз в секунду: безработный житель занимает свободный\n * блок рядом и становится мастером; у кого рабочий блок сломали — теряет профессию, если ни разу\n * не торговал. Житель из деревни рабочего места не имеет и профессию не теряет, как в Minecraft.\n */\n private _villagerJobs(): void {\n const villagers: Mob[] = [];\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n (e.def.name === \"villager\" || e.def.name.startsWith(\"villager_\"))\n )\n villagers.push(e);\n if (!villagers.length) return;\n const claimed = villagers.flatMap((v) => (v.jobSite ? [v.jobSite] : []));\n const blockAt = (x: number, y: number, z: number): string =>\n BLOCKS[this.world.getBlock(x, y, z)]?.name ?? \"air\";\n for (const v of villagers) {\n const site = v.jobSite;\n if (v.def.trades) {\n // работа у своего блока пополняет товар (в Minecraft — дважды за день, здесь — по таймеру)\n if (site && v.uses.length) {\n v.workT -= 1;\n if (\n v.workT <= 0 &&\n Math.abs(v.pos.x - (site[0] + 0.5)) < 2.5 &&\n Math.abs(v.pos.z - (site[2] + 0.5)) < 2.5 &&\n Math.abs(v.pos.y - site[1]) < 3\n ) {\n v.uses = [];\n v.workT = VILLAGER_RESTOCK;\n events.emit(\"villagerRestock\", { mob: v.def.name });\n }\n }\n // рабочий блок сломали или подменили — обратно в безработные (торговавший остаётся мастером)\n if (\n site &&\n !v.traded &&\n jobAt(blockAt(site[0], site[1], site[2])) !== v.def.trades\n )\n this._reprofess(v, \"villager\", null);\n continue;\n }\n const found = findJob(\n blockAt,\n Math.floor(v.pos.x),\n Math.floor(v.pos.y),\n Math.floor(v.pos.z),\n (x, y, z) =>\n claimed.some((c) => c[0] === x && c[1] === y && c[2] === z),\n );\n if (!found) continue;\n claimed.push(found.site);\n this._reprofess(v, \"villager_\" + found.job, found.site);\n }\n }\n\n /**\n * Деревня зовёт голема (systems/Golem): рядом с жителями враждебные (ночная осада) — раз в\n * GOLEM_COOLDOWN секунд встаёт один железный голем. Так деревня отвечает на осаду сама.\n */\n private _villagerGolem(): void {\n if (this._golemT > 0) {\n this._golemT -= 1;\n return;\n }\n // ⚠ кандидатов перебираем всех: в мире живёт не одна кучка жителей, и у соседней деревни свой\n // голем уже есть — по первому встречному жителю вызов не сработал бы никогда.\n const mobs: Mob[] = [];\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && !e.dead) mobs.push(e);\n for (const host of mobs) {\n if (!host.def.trades || host.baby) continue;\n let villagers = 0,\n golems = 0,\n danger = false;\n for (const e of mobs) {\n if (e.pos.distanceTo(host.pos) > GOLEM_RADIUS) continue;\n if (e.def.trades) villagers++;\n else if (e.def.name === \"iron_golem\") golems++;\n else if (e.def.category === \"hostile\") danger = true;\n }\n if (!shouldSummonGolem({ villagers, golems, danger, cooldown: 0 }))\n continue;\n const x = Math.floor(host.pos.x) + (Math.random() < 0.5 ? 2 : -2),\n z = Math.floor(host.pos.z) + (Math.random() < 0.5 ? 2 : -2);\n const y = this.world.surfaceY(x, z);\n if (y <= 0) continue;\n this.spawnMob(\"iron_golem\", x + 0.5, y + 1.01, z + 0.5);\n this._golemT = GOLEM_COOLDOWN;\n events.emit(\"golemSummoned\", { x, y, z });\n return;\n }\n }\n\n /** Лечение зомби-жителя (systems/Zombify): раз в секунду, на нуле — обратно в свою профессию. */\n private _tickCuring(): void {\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead || e.curing <= 0) continue;\n e.curing -= 1;\n if (e.curing > 0) continue;\n const back = this._reprofess(e, curedTypeOf(e.curedType), e.jobSite);\n events.emit(\"villagerCured\", { mob: back.def.name });\n }\n }\n\n /**\n * Житель, убитый зомби, встаёт зомби-жителем и помнит профессию (systems/Zombify).\n * Зовёт мозг зомби через MobCtx.convert.\n */\n convertToZombie(v: Mob): Mob {\n const z = this._reprofess(v, ZOMBIE_VILLAGER, v.jobSite);\n z.hp = z.def.hp;\n z.curedType = v.def.name;\n events.emit(\"villagerZombified\", { mob: v.def.name });\n return z;\n }\n\n /** Тот же житель на том же месте, но другим типом моба: типы жителей — по профессии. */\n private _reprofess(\n v: Mob,\n type: string,\n site: [number, number, number] | null,\n ): Mob {\n const n = this.spawnMob(type, v.pos.x, v.pos.y, v.pos.z, v.baby);\n n.hp = v.hp;\n n.yaw = v.yaw;\n n.jobSite = site;\n n.traded = v.traded;\n n.villagerXp = v.villagerXp; // опыт профессии переживает и смену профессии, и лечение\n v.dead = true; // hp > 0 — исчезает без дропа\n events.emit(\"villagerJob\", { mob: type });\n return n;\n }\n\n /** Перепись: враждебные вдали исчезают, население добирается попытками спавна. */\n _census(dt: number): void {\n this._spawnTimer -= dt;\n if (this._spawnTimer > 0) return;\n this._spawnTimer = 1;\n // патрули мародёров — раз в 5–10 минут, как в Minecraft\n this._patrolT -= 1;\n if (this._patrolT <= 0) {\n this._patrolT = 300 + Math.random() * 300;\n this.spawnPatrol();\n }\n // странствующий торговец — раз в 20 минут с шансом 25%, если его ещё нет (как в Minecraft)\n this._traderT -= 1;\n if (this._traderT <= 0) {\n this._traderT = 1200;\n const here = [...this.entities.values()].some(\n (e) => e.kind === \"mob\" && !e.dead && e.def.name === \"wandering_trader\",\n );\n if (!here && Math.random() < 0.25) this.spawnWanderingTrader();\n }\n this._villagerJobs();\n this._tickCuring();\n this._villagerGolem();\n const M = CONFIG.mobs;\n const p = this.player.pos;\n let passive = 0,\n hostile = 0;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead) continue;\n const d = e.pos.distanceTo(p);\n if (e.def.category === \"passive\") {\n if (d < M.countRadius) passive++;\n continue;\n }\n // голем деревни, ручные, названные биркой, набег и волна испытаний не исчезают\n if (\n e.def.persistent ||\n e.tamed ||\n e.customName ||\n e.raider ||\n e.trialFrom\n )\n continue;\n if (d > M.despawnRadius && !e.def.boss && !e.def.stationary) {\n e.dead = true; // hp > 0 — без дропа\n continue;\n }\n if (d < M.countRadius) hostile++;\n }\n for (let a = 0; a < M.spawnAttempts; a++) {\n const wantHostile =\n hostile < M.hostileCap &&\n (passive >= M.passiveCap || Math.random() < 0.75);\n if (!wantHostile && passive >= M.passiveCap) return;\n const n = this._trySpawn(wantHostile ? \"dark\" : \"bright\");\n if (wantHostile) hostile += n;\n else passive += n;\n }\n }\n\n private _pickType(light: \"bright\" | \"dark\"): MobDef | null {\n const dim = this.world.dimension;\n const list = Object.values(MOBS).filter(\n (d) =>\n d.spawn &&\n (d.spawn.light === light ||\n (light === \"dark\" && d.spawn.light === \"any\")) &&\n (d.spawn.dimension ?? \"overworld\") === dim,\n );\n let total = 0;\n for (const d of list) total += d.spawn!.weight;\n let r = Math.random() * total;\n for (const d of list) {\n r -= d.spawn!.weight;\n if (r <= 0) return d;\n }\n return list[0] ?? null;\n }\n\n private _trySpawn(light: \"bright\" | \"dark\"): number {\n const def = this._pickType(light);\n if (!def) return 0;\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\n const p = this.player.pos;\n const ang = Math.random() * Math.PI * 2;\n // жители построек (крепость, храм) — ближе: постройка может быть меньше обычного кольца спавна\n const r = def.spawn!.structure\n ? 6 + Math.random() * 26\n : rMin! + Math.random() * (rMax! - rMin!);\n const x = Math.floor(p.x + Math.cos(ang) * r),\n z = Math.floor(p.z + Math.sin(ang) * r);\n let y: number;\n if (this.world.dimension === \"inferno\") {\n // пещерный мир: «поверхность» — свод из бедрока; берём высоту около игрока и спускаемся до пола\n y = Math.max(8, Math.min(120, Math.floor(p.y) + randInt(-16, 16)));\n for (\n let k = 0;\n k < 24 && y > 1 && !BLOCKS[this.world.getBlock(x, y - 1, z)]?.solid;\n k++\n )\n y--;\n } else {\n const top = this.world.surfaceY(x, z);\n // тьма — на поверхности (ночью) или случайно в толще (пещеры)\n y =\n light === \"bright\" || Math.random() < 0.5\n ? top + 1\n : 5 + Math.floor(Math.random() * Math.max(1, top - 8));\n }\n // не на полу: спрут и утопленник — в толще воды, фантом — высоко в воздухе\n const med = def.spawn!.medium;\n if (med === \"water\" && def.spawn!.underground) {\n // пещерное озеро: случайная высота под поверхностью и вниз до воды\n let wy = 12 + randInt(0, Math.max(0, this.world.surfaceY(x, z) - 24));\n for (\n let k = 0;\n k < 24 && wy > 1 && this.world.getBlock(x, wy, z) !== B.water;\n k++\n )\n wy--;\n y = wy;\n } else if (med === \"water\") {\n let wy = this.world.surfaceY(x, z) + 1;\n for (\n let k = 0;\n k < 12 && wy > 1 && this.world.getBlock(x, wy, z) !== B.water;\n k++\n )\n wy--;\n // в толще у поверхности; житель подводной постройки — на любой глубине\n y = def.spawn!.structure\n ? randInt(8, Math.max(8, wy))\n : wy - randInt(0, 2);\n } else if (med === \"lava\")\n y = this.world.generator.waterLevel + 1; // страйдер — на лавовом море\n else if (med === \"air\")\n // летучая мышь — в воздухе пещер, фантом — высоко над поверхностью\n y = def.spawn!.underground\n ? 5 + randInt(0, Math.max(0, this.world.surfaceY(x, z) - 12))\n : this.world.surfaceY(x, z) + randInt(12, 20);\n if (!this._canSpawn(def, x, y, z)) return 0;\n const [gMin, gMax] = def.spawn!.group;\n const n = randInt(gMin, gMax);\n let spawned = 0;\n for (let i = 0; i < n; i++) {\n const sx = x + (i ? randInt(-2, 2) : 0),\n sz = z + (i ? randInt(-2, 2) : 0);\n if (i && !this._canSpawn(def, sx, y, sz)) continue;\n this.spawnMob(def.name, sx + 0.5, y + 0.01, sz + 0.5);\n spawned++;\n }\n return spawned;\n }\n\n /** Место для спавна: пол непрозрачный, над ним воздух на рост, свет по правилу вида. */\n private _canSpawn(def: MobDef, x: number, y: number, z: number): boolean {\n const w = this.world;\n if (y < 1) return false;\n const sp = def.spawn!;\n // на мирной сложности монстров нет вовсе (systems/Difficulty)\n if (def.category === \"hostile\" && !rules().hostiles) return false;\n if (sp.insomnia && !this.insomnia) return false;\n // по биомам: тропическая рыба — в тёплом океане (у мира без биомов фильтра нет)\n // в грибных полях монстры не появляются (и в пещерах под ними), кроме жителей построек\n if (\n def.category === \"hostile\" &&\n !sp.structure &&\n w.generator.biomeOf?.(x, z) === \"mushroom_fields\"\n )\n return false;\n const bio = sp.biomes ? w.generator.biomeOf?.(x, z) : undefined;\n if (sp.biomes && bio !== undefined && !sp.biomes.includes(bio))\n return false;\n // на лаве: под ногами лава, над ней воздух на рост\n if (sp.medium === \"lava\") {\n if (w.getBlock(x, y - 1, z) !== B.lava) return false;\n for (let dy = 0; dy < Math.ceil(def.box.h); dy++)\n if (w.getBlock(x, y + dy, z) !== 0) return false;\n return true;\n }\n if (sp.medium) {\n // вода или воздух на весь рост, свет — по правилу вида\n const want = sp.medium === \"water\" ? B.water : 0;\n for (let dy = 0; dy < Math.max(1, Math.ceil(def.box.h)); dy++) {\n const id = w.getBlock(x, y + dy, z);\n if (id !== want && !(want === B.water && BLOCKS[id]?.waterlogged))\n return false;\n }\n if (sp.structure && w.generator.structureAt?.(x, y, z) !== sp.structure)\n return false;\n if (sp.underground && y >= w.surfaceY(x, z) - 4) return false;\n if (sp.nearBlock && !this._blockNear(x, y, z, B[sp.nearBlock], 4))\n return false;\n const l = w.getLight(x, y, z);\n const sky = (l >> 4) - this.skyDarken,\n blk = l & 15;\n if (sp.light === \"any\") return true;\n if (sp.light === \"bright\") return Math.max(sky, blk) >= 9;\n return blk === 0 && sky <= 7;\n }\n const bd = BLOCKS[w.getBlock(x, y - 1, z)];\n if (!bd || !bd.solid || !bd.opaque) return false;\n for (let dy = 0; dy < Math.ceil(def.box.h); dy++)\n if (w.getBlock(x, y + dy, z) !== 0) return false;\n const l = w.getLight(x, y, z);\n const sky = (l >> 4) - this.skyDarken,\n blk = l & 15;\n const s = def.spawn!;\n if (s.maxY !== undefined && y > s.maxY) return false;\n if (\n s.structure &&\n this.world.generator.structureAt?.(x, y, z) !== s.structure\n )\n return false;\n if (s.nearWater && !this._waterNear(x, y - 1, z, 4)) return false;\n if (s.light === \"any\") return !s.ground || s.ground.includes(bd.name);\n if (s.light === \"bright\") {\n if (s.ground && !s.ground.includes(bd.name)) return false;\n return Math.max(sky, blk) >= 9;\n }\n return blk === 0 && sky <= 7;\n }\n\n /** Луч по AABB мобов (slab-тест) — для атаки и кормления. Возвращает ближайшего. */\n raycast(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): Mob | null {\n let best: Mob | null = null,\n bestT = maxDist;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead || e.rider) continue; // лошадь под игроком лучу не мешает\n const w = e.width / 2;\n const t = rayBox(\n origin,\n dir,\n bestT,\n { x: e.pos.x - w, y: e.pos.y, z: e.pos.z - w },\n { x: e.pos.x + w, y: e.pos.y + e.height, z: e.pos.z + w },\n );\n if (t !== null && t < bestT) {\n bestT = t;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n for (const e of this.entities.values()) {\n if (e.kind === \"mob\") {\n // свет места: в пещере моб темнее (ночь приглушает свет сцены и так)\n const l = this.world.getLight(\n Math.floor(e.pos.x),\n Math.floor(e.pos.y + e.height * 0.5),\n Math.floor(e.pos.z),\n );\n const lvl = Math.max(l >> 4, l & 15);\n // без неба (Пекло, Предел) свет блоков почти всюду 0 — там свой фоновый порог\n const floor = this.world.dimension === \"overworld\" ? 0.05 : 0.6;\n e.updateVisual(alpha, time, Math.max(floor, Math.pow(0.8, 15 - lvl)));\n } else if (\n e.kind === \"arrow\" ||\n e.kind === \"fireball\" ||\n e.kind === \"minecart\" ||\n e.kind === \"boat\"\n )\n e.updateVisual(alpha);\n else if (e.kind === \"hook\") {\n // леска — от кончика удочки: чуть впереди, правее и ниже глаз\n const eye = this.player.eyePos,\n yaw = this.player.yaw;\n const tip = eye\n .clone()\n .addScaledVector(this.player.lookDir(), 0.7)\n .add(\n new THREE.Vector3(Math.cos(yaw) * 0.3, -0.3, -Math.sin(yaw) * 0.3),\n );\n e.updateVisual(alpha, tip);\n } else e.updateVisual(alpha, time);\n }\n this._beam();\n }\n\n /** Луч от лечащего Кристалла Предела к дракону. */\n private beamLine: THREE.Line | null = null;\n private _beam(): void {\n let pts: THREE.Vector3[] | null = null;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || !e.def.boss || e.healFrom === null) continue;\n const k = this.entities.get(e.healFrom);\n if (k && k.kind === \"mob\" && !k.dead)\n pts = [\n k.group.position.clone().setY(k.group.position.y + 1),\n e.group.position.clone().setY(e.group.position.y + e.height * 0.5),\n ];\n }\n if (!pts) {\n if (this.beamLine) this.beamLine.visible = false;\n return;\n }\n if (!this.beamLine) {\n this.beamLine = new THREE.Line(\n new THREE.BufferGeometry(),\n new THREE.LineBasicMaterial({ color: 0xff80ff }),\n );\n this.scene.add(this.beamLine);\n }\n this.beamLine.geometry.setFromPoints(pts);\n this.beamLine.visible = true;\n }\n\n clear(): void {\n for (const e of this.entities.values()) e.dispose(this.scene);\n this.entities.clear();\n if (this.beamLine) this.beamLine.visible = false;\n }\n\n serialize() {\n const out: unknown[] = [];\n for (const e of this.entities.values())\n if (\n e.kind !== \"arrow\" &&\n e.kind !== \"tnt\" &&\n e.kind !== \"fireball\" &&\n e.kind !== \"rimeye\" &&\n e.kind !== \"hook\" &&\n e.kind !== \"fang\"\n )\n out.push(e.serialize());\n return out;\n }\n\n deserialize(list?: EntitySave[]): void {\n this.clear();\n for (const d of list ?? []) {\n if (d.kind === \"mob\" && MOBS[d.type]) {\n const m = this.spawnMob(d.type, d.pos[0], d.pos[1], d.pos[2], !!d.baby);\n m.hp = d.hp;\n m.sheared = !!d.sheared;\n if (Array.isArray(d.uses))\n m.uses = d.uses.filter((n) => typeof n === \"number\");\n if (Array.isArray(d.jobSite) && d.jobSite.length === 3)\n m.jobSite = [d.jobSite[0], d.jobSite[1], d.jobSite[2]];\n m.traded = !!d.traded;\n if (typeof d.villagerXp === \"number\") m.villagerXp = d.villagerXp;\n if (typeof d.curedType === \"string\") m.curedType = d.curedType;\n if (typeof d.curing === \"number\") m.curing = d.curing;\n m.tamed = !!d.tamed;\n if (typeof d.customName === \"string\") m.setName(d.customName);\n m.sitting = !!d.sitting;\n m.saddled = !!d.saddled;\n // нет поля — белая (случайный цвет новорождённой овцы здесь не нужен)\n m.woolColor = typeof d.woolColor === \"string\" ? d.woolColor : null;\n if (typeof d.wolfArmor === \"number\") m.wolfArmor = d.wolfArmor;\n if (typeof d.carry === \"string\") {\n m.carry = { item: d.carry, count: 1 };\n m.bag = typeof d.bag === \"number\" ? d.bag : 0;\n }\n if (Array.isArray(d.items) && m.def.chestSlots)\n m.items = d.items.map((s) =>\n s && typeof s.item === \"string\" ? { ...s } : null,\n );\n m.leashed = !!d.leashed;\n if (typeof d.leaveT === \"number\") m.leaveT = d.leaveT;\n if (Array.isArray(d.leashPost) && d.leashPost.length === 3)\n m.leashPost = [d.leashPost[0], d.leashPost[1], d.leashPost[2]];\n if (Array.isArray(d.home) && d.home.length === 3)\n m.home = [d.home[0], d.home[1], d.home[2]];\n m.pollen = !!d.pollen;\n m.captain = !!d.captain;\n } else if (d.kind === \"drop\") {\n const drop = this.spawnDrop(\n d.pos[0],\n d.pos[1],\n d.pos[2],\n d.item,\n d.count,\n d.dmg,\n d.tag,\n );\n if (drop) drop.vel.set(0, 0, 0);\n } else if (d.kind === \"xp\")\n this.spawnXp(d.pos[0], d.pos[1], d.pos[2], d.value);\n else if (d.kind === \"minecart\") {\n const variant =\n d.variant && CART_VARIANTS.includes(d.variant) ? d.variant : \"plain\";\n const c = this.spawnMinecart(d.pos[0], d.pos[1], d.pos[2], variant);\n if (Array.isArray(d.vel)) c.vel.fromArray(d.vel);\n if (Array.isArray(d.items))\n d.items.forEach((s, i) => {\n if (i < c.items.length)\n c.items[i] =\n s && typeof s.item === \"string\" && s.count > 0\n ? { ...s }\n : null;\n });\n } else if (d.kind === \"boat\")\n this.spawnBoat(d.pos[0], d.pos[1], d.pos[2], d.wood ?? \"oak\");\n }\n }\n}\n"
191
+ "content": "// Менеджер сущностей: мобы, дроп-предметы, сферы опыта, стрелы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Перепись (census) держит население мобов вокруг игрока\n// по правилам Minecraft: мирные — на траве при свете, враждебные — в темноте (ночью и в пещерах).\n// Игрок хранится отдельно (Player), но интерфейс сущностей совместим — в этапе 2 удалённые игроки\n// станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { frogFor } from \"../registry/mobs/frog\";\nimport { dropsOf } from \"../systems/Mining\";\nimport { explodeBlocks, explosionDamage } from \"../systems/Explosion\";\nimport { rayBox } from \"../physics/rayBox\";\nimport { raycastBlocks } from \"../world/Raycast\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport { XpOrb } from \"./XpOrb\";\nimport { Arrow } from \"./Arrow\";\nimport { tippedEffect } from \"../systems/TippedArrow\";\nimport { PrimedTnt } from \"./PrimedTnt\";\nimport { Fireball, type FireballKind } from \"./Fireball\";\nimport { RimEye } from \"./RimEye\";\nimport { CART_VARIANTS, Minecart, type CartVariant } from \"./Minecart\";\nimport { Fang } from \"./Fang\";\nimport { extractOne, insertOne } from \"../world/Containers\";\nimport { HOPPER_COOLDOWN } from \"../world/blockentities/hopper\";\nimport { smokedHive } from \"../world/Hive\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { Boat } from \"./Boat\";\nimport { FishingHook } from \"./FishingHook\";\nimport { INFESTED_NAMES } from \"../registry/blocks/infested\";\nimport { findAltar } from \"./AshLord\";\nimport { startCharge } from \"./AshLordBrain\";\nimport { isSnowGolem } from \"./SnowGolemBrain\";\nimport { findJob, jobAt } from \"../systems/Jobs\";\nimport { ZOMBIE_VILLAGER, curedTypeOf } from \"../systems/Zombify\";\nimport {\n GOLEM_COOLDOWN,\n GOLEM_RADIUS,\n isIronGolem,\n shouldSummonGolem,\n} from \"../systems/Golem\";\nimport {\n REP_CURE,\n REP_HIT_VILLAGER,\n REP_TRADE,\n clampRep,\n} from \"../systems/Reputation\";\n\n/** Секунд между пополнениями товара за работой у своего блока. */\nconst VILLAGER_RESTOCK = 120;\n\n/** id заражённых блоков — из них вылезает чешуйница. */\nconst INFESTED_IDS = new Set(\n INFESTED_NAMES.map((n) => B[n]).filter(\n (id): id is number => id !== undefined,\n ),\n);\nimport type { MobCtx } from \"./MobBrain\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport { sanitizeStack, type Inventory } from \"../systems/Inventory\";\nimport type { ItemStack, MobDef } from \"../types\";\n\ntype Entity =\n | Mob\n | ItemDrop\n | XpOrb\n | Arrow\n | PrimedTnt\n | Fireball\n | RimEye\n | Minecart\n | Boat\n | FishingHook\n | Fang;\n\n// Форма сохранённой сущности (выход serialize()) — дискриминируется по `kind`. Стрелы не сохраняются.\ntype EntitySave =\n | {\n kind: \"mob\";\n type: string;\n pos: [number, number, number];\n hp: number;\n baby?: boolean;\n sheared?: boolean;\n /** Житель: использованные сделки до пополнения. */\n uses?: number[];\n /** Житель: занятый рабочий блок и «уже торговал» (systems/Jobs). */\n jobSite?: [number, number, number];\n traded?: boolean;\n /** Житель: опыт профессии (уровни сделок). */\n villagerXp?: number;\n /** Зомби-житель: кем он был и сколько осталось лечиться (systems/Zombify). */\n curedType?: string;\n curing?: number;\n tamed?: boolean;\n /** Имя с бирки (registry/items/nametag). */\n customName?: string;\n sitting?: boolean;\n saddled?: boolean;\n /** Овца: цвет шерсти (нет поля — белая). */\n woolColor?: string;\n /** Волк: запас брони из щитков. */\n wolfArmor?: number;\n /** Дух-помощник: образец и собранное. */\n carry?: string;\n bag?: number;\n /** Вьюк осла, мула, ламы; на поводке. */\n items?: (ItemStack | null)[];\n leashed?: boolean;\n leashPost?: [number, number, number];\n /** Секунд до ухода странствующего торговца и его лам. */\n leaveT?: number;\n /** Пчела: свой улей и пыльца. */\n home?: [number, number, number];\n pollen?: boolean;\n /** Капитан мародёров. */\n captain?: boolean;\n }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n dmg?: number;\n /** Зачарования, вещи панцирного сундука. */\n tag?: Record<string, unknown>;\n }\n | { kind: \"xp\"; pos: [number, number, number]; value: number }\n | {\n kind: \"minecart\";\n pos: [number, number, number];\n vel?: [number, number, number];\n /** Вид (нет — обычная) и груз вагонетки с сундуком или воронкой. */\n variant?: CartVariant;\n items?: (ItemStack | null)[];\n }\n | { kind: \"boat\"; pos: [number, number, number]; wood?: string };\n\nconst randInt = (a: number, b: number): number =>\n a + Math.floor(Math.random() * (b - a + 1));\n\nexport class EntityManager {\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Entity>;\n _nextId: number;\n _spawnTimer: number;\n /** На сколько уровней ночь гасит небесный свет и день ли сейчас — задаёт Game из DayNight. */\n skyDarken = 0;\n isDay = true;\n /** Идёт дождь (Game из systems/Weather). */\n raining = false;\n /** Игрок не спал три ночи — прилетают фантомы (Game.daysAwake). */\n insomnia = false;\n /** Секунд до следующего патруля мародёров. */\n private _patrolT = 300 + Math.random() * 300;\n\n constructor(\n scene: THREE.Scene,\n world: ChunkManager,\n atlas: TextureAtlas,\n player: Player,\n inventory: Inventory,\n ) {\n this.scene = scene;\n this.world = world;\n this.atlas = atlas;\n this.player = player;\n this.inventory = inventory;\n this.entities = new Map();\n this._nextId = 1;\n this._spawnTimer = 0;\n // TNT, подожжённый огнём (поведение огня не знает про сущности — просит событием)\n events.on(\n \"tntPrime\",\n (d: { x: number; y: number; z: number; fuse?: number }) =>\n this.spawnTnt(d.x + 0.5, d.y, d.z + 0.5, d.fuse ?? 4),\n );\n // вагонетку поставили на рельс (предмет minecart — world/behaviours/rails)\n events.on(\n \"spawnMinecart\",\n (d: { x: number; y: number; z: number; variant?: CartVariant }) =>\n this.spawnMinecart(d.x + 0.5, d.y, d.z + 0.5, d.variant),\n );\n events.on(\n \"spawnBoat\",\n (d: { x: number; y: number; z: number; wood: string }) =>\n this.spawnBoat(d.x + 0.5, d.y, d.z + 0.5, d.wood),\n );\n // из заражённого блока вылезает чешуйница (registry/blocks/infested)\n events.on(\n \"blockBreak\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (INFESTED_IDS.has(d.id) && MOBS.silverfish)\n this.spawnMob(\"silverfish\", d.x + 0.5, d.y, d.z + 0.5);\n },\n );\n // мёд собрали — пчёлы в 16 блоках от улья злятся\n events.on(\"hiveHarvest\", (d: { x: number; y: number; z: number }) => {\n if (smokedHive(this.world, d.x, d.y, d.z)) return; // дым костра под ульем их успокаивает\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e.def.bee &&\n !e.dead &&\n e.pos.distanceTo(new THREE.Vector3(d.x, d.y, d.z)) < 16\n )\n e.aggro = true;\n });\n // черепашье яйцо вылупилось (world/behaviours/ocean); выросший черепашонок роняет щиток\n events.on(\"turtleHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.turtle) this.spawnMob(\"turtle\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n events.on(\"mobGrew\", ({ entity }: { entity: Mob }) => {\n const item = entity.def.growDrop;\n if (item && ITEMS[item])\n this.spawnDrop(entity.pos.x, entity.pos.y + 0.3, entity.pos.z, item, 1);\n });\n // яйцо нюхача треснуло — детёныш нюхача\n events.on(\"snifferHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.sniffer)\n this.spawnMob(\"sniffer\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n // лягушачья икра — 2–6 головастиков в воду под ней; головастик вырос — лягушка по биому\n events.on(\"frogspawnHatch\", (d: { x: number; y: number; z: number }) => {\n if (!MOBS.tadpole) return;\n for (let i = randInt(2, 6); i > 0; i--)\n this.spawnMob(\n \"tadpole\",\n d.x + 0.3 + Math.random() * 0.4,\n d.y - 0.8,\n d.z + 0.3 + Math.random() * 0.4,\n );\n });\n events.on(\"mobMature\", ({ entity }: { entity: Mob }) => {\n const into =\n entity.def.growsInto === \"frog\"\n ? frogFor(this.world.generator.biomeOf?.(entity.pos.x, entity.pos.z))\n : entity.def.growsInto;\n if (!into || !MOBS[into] || entity.dead) return;\n this.spawnMob(into, entity.pos.x, entity.pos.y, entity.pos.z);\n entity.dead = true; // hp > 0 — без дропа: превратился\n });\n // третий пепельный череп на Т из песка душ — Пепельный владыка (entities/AshLord)\n events.on(\n \"blockPlace\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (d.id === B.ash_skull) this.summonAtAltar(d.x, d.y, d.z);\n if (d.id === B.pumpkin)\n // тыква достраивает снеговика или железного голема (systems/Golem)\n if (!this.buildSnowGolem(d.x, d.y, d.z))\n this.buildIronGolem(d.x, d.y, d.z);\n },\n );\n // ручные волки: на того, кого бьёт игрок, и на того, кто бьёт игрока\n events.on(\"playerHitMob\", ({ entity }: { entity: Mob }) => {\n this.guard(entity);\n this.villageAnger(entity);\n });\n // деревня помнит добро: торговля и вылеченный зомби-житель поднимают репутацию\n events.on(\"trade\", () => this._addRep(REP_TRADE));\n events.on(\"villagerCured\", () => this._addRep(REP_CURE));\n // колокол: налётчики вокруг светятся, как в Minecraft\n events.on(\"bellRing\", (p: { x: number; y: number; z: number }) =>\n this.markRaiders(p.x, p.y, p.z),\n );\n events.on(\"mobAttack\", ({ entity }: { entity: Mob }) => this.guard(entity));\n }\n\n /** Убрать всех враждебных мобов (переход на мирную сложность — systems/Difficulty). */\n clearHostiles(): void {\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.category === \"hostile\" && !e.def.boss)\n e.dead = true; // hp > 0 — исчезают без дропа, как при удалении игрока\n }\n\n /** Сдвинуть репутацию игрока в деревне и сказать об этом HUD. */\n private _addRep(by: number): void {\n this.player.villageRep = clampRep(this.player.villageRep + by);\n events.emit(\"villageRep\", { rep: this.player.villageRep });\n }\n\n /**\n * Ударили жителя: репутация падает, а железные големы рядом бросаются на обидчика — как в\n * Minecraft, где деревня помнит зло (systems/Reputation).\n */\n villageAnger(target: Mob): void {\n if (!target.def.trades || target.dead) return;\n this._addRep(REP_HIT_VILLAGER);\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === \"iron_golem\" &&\n e.pos.distanceTo(target.pos) < GOLEM_RADIUS\n )\n e.aggro = true;\n }\n\n /** Звон колокола: налётчики в 32 блоках светятся 3 секунды (видно сквозь стены). */\n markRaiders(x: number, y: number, z: number): void {\n const at = new THREE.Vector3(x + 0.5, y + 0.5, z + 0.5);\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.raider && e.pos.distanceTo(at) < 32)\n e.glowT = 3;\n }\n\n /** Череп в (x,y,z) достроил алтарь — блоки исчезают, встаёт владыка и набирает силу. */\n summonAtAltar(x: number, y: number, z: number): Mob | null {\n const a = findAltar(\n (bx, by, bz) => this.world.getBlock(bx, by, bz),\n x,\n y,\n z,\n );\n if (!a || !MOBS.ash_lord) return null;\n for (const [cx, cy, cz] of a.cells) this.world.setBlock(cx, cy, cz, B.air!);\n const m = this.spawnMob(\"ash_lord\", a.x, a.y, a.z);\n startCharge(m);\n events.emit(\"ashLordSummoned\", { x: a.x, y: a.y, z: a.z });\n return m;\n }\n\n /** Железный голем: тыква на фигуре из четырёх железных блоков — блоки исчезают, встаёт голем. */\n buildIronGolem(x: number, y: number, z: number): Mob | null {\n const get = (bx: number, by: number, bz: number) =>\n this.world.getBlock(bx, by, bz);\n if (!MOBS.iron_golem || !isIronGolem(get, x, y, z)) return null;\n const armsX = get(x - 1, y - 1, z) === B.iron_block;\n for (const [cx, cy, cz] of [\n [x, y, z],\n [x, y - 1, z],\n [x, y - 2, z],\n armsX ? [x - 1, y - 1, z] : [x, y - 1, z - 1],\n armsX ? [x + 1, y - 1, z] : [x, y - 1, z + 1],\n ] as [number, number, number][])\n this.world.setBlock(cx, cy, cz, B.air!);\n const m = this.spawnMob(\"iron_golem\", x + 0.5, y - 2, z + 0.5);\n events.emit(\"ironGolemBuilt\", { x, y, z });\n return m;\n }\n\n /** Снеговик: тыква на двух блоках снега — блоки исчезают, на их месте снеговик. */\n buildSnowGolem(x: number, y: number, z: number): Mob | null {\n if (\n !MOBS.snow_golem ||\n !isSnowGolem((bx, by, bz) => this.world.getBlock(bx, by, bz), x, y, z)\n )\n return null;\n for (let k = 0; k < 3; k++) this.world.setBlock(x, y - k, z, B.air!);\n const m = this.spawnMob(\"snow_golem\", x + 0.5, y - 2, z + 0.5);\n events.emit(\"snowGolemBuilt\", { x, y, z });\n return m;\n }\n\n /** Клыки ворожея: 16 штук по прямой от from к to через 1.25 блока, каждый позже предыдущего. */\n spawnFangs(from: THREE.Vector3, to: THREE.Vector3, ownerId: number): void {\n const dir = to.clone().sub(from).setY(0);\n const len = dir.length();\n if (len < 1e-3) return;\n dir.divideScalar(len);\n for (let i = 1; i <= 16; i++) {\n const x = from.x + dir.x * i * 1.25,\n z = from.z + dir.z * i * 1.25;\n // пол у линии: твёрдый блок в нескольких блоках от высоты ворожея\n let y = Math.floor(from.y) + 2;\n for (\n let k = 0;\n k < 6 &&\n !BLOCKS[this.world.getBlock(Math.floor(x), y - 1, Math.floor(z))]\n ?.solid;\n k++\n )\n y--;\n const f = new Fang(this._nextId++, x, y, z, i * 0.05, ownerId);\n this.entities.set(f.id, f);\n this.scene.add(f.mesh);\n }\n }\n\n /** До следующей попытки прихода странствующего торговца, сек. */\n private _traderT = 1200;\n /** Перезарядка вызова железного голема деревней (systems/Golem). */\n private _golemT = 0;\n\n /** Странствующий торговец с двумя ламами на поводке — на суше в 16–32 блоках от игрока. */\n spawnWanderingTrader(): Mob | null {\n if (this.world.dimension !== \"overworld\" || !MOBS.wandering_trader)\n return null;\n const p = this.player.pos;\n for (let k = 0; k < 8; k++) {\n const a = Math.random() * Math.PI * 2,\n r = 16 + Math.random() * 16;\n const x = Math.floor(p.x + Math.cos(a) * r),\n z = Math.floor(p.z + Math.sin(a) * r);\n const top = this.world.surfaceY(x, z);\n const g = BLOCKS[this.world.getBlock(x, top, z)];\n if (\n !g?.solid ||\n g.liquid ||\n this.world.getBlock(x, top + 1, z) !== 0 ||\n this.world.getBlock(x, top + 2, z) !== 0\n )\n continue;\n const t = this.spawnMob(\"wandering_trader\", x + 0.5, top + 1, z + 0.5);\n for (const dx of [-1.5, 1.5]) {\n const l = this.spawnMob(\"trader_llama\", x + 0.5 + dx, top + 1, z + 0.5);\n l.leashed = true;\n l.leashMobId = t.id;\n }\n events.emit(\"traderArrived\", { x, y: top + 1, z });\n return t;\n }\n return null;\n }\n\n /** Патруль мародёров: 2–5 во главе с капитаном на суше в 24–48 блоках от игрока. */\n spawnPatrol(): number {\n if (this.world.dimension !== \"overworld\" || !MOBS.marauder) return 0;\n const p = this.player.pos;\n const a = Math.random() * Math.PI * 2,\n r = 24 + Math.random() * 24;\n const x = Math.floor(p.x + Math.cos(a) * r),\n z = Math.floor(p.z + Math.sin(a) * r);\n const top = this.world.surfaceY(x, z);\n if (\n !BLOCKS[this.world.getBlock(x, top, z)]?.solid ||\n this.world.getBlock(x, top + 1, z) !== 0\n )\n return 0;\n const n = randInt(2, 5);\n for (let i = 0; i < n; i++) {\n const sx = x + (i ? randInt(-2, 2) : 0),\n sz = z + (i ? randInt(-2, 2) : 0);\n const m = this.spawnMob(\n \"marauder\",\n sx + 0.5,\n this.world.surfaceY(sx, sz) + 1.01,\n sz + 0.5,\n );\n if (i === 0) m.captain = true;\n }\n events.emit(\"patrolSpawned\", { x, z, n });\n return n;\n }\n\n /** Черепаха после размножения откладывает 1–4 яйца на песок рядом. */\n layEggs(m: Mob): number {\n const egg = B[m.def.layEggs ?? \"\"];\n if (egg === undefined) return 0;\n // лягушка — одну икру на воду, нюхач — одно яйцо на любой твёрдый блок, черепаха — 1–4 яйца на песок\n const spawn = m.def.layEggs === \"frogspawn\",\n sniffer = m.def.layEggs === \"sniffer_egg\";\n const baseOk = (id: number): boolean =>\n spawn\n ? id === B.water\n : sniffer\n ? !!BLOCKS[id]?.solid && BLOCKS[id]?.shape === \"cube\"\n : id === B.sand;\n let left = spawn || sniffer ? 1 : randInt(1, 4);\n const x0 = Math.floor(m.pos.x),\n y0 = Math.floor(m.pos.y),\n z0 = Math.floor(m.pos.z);\n for (let dx = -2; dx <= 2 && left > 0; dx++)\n for (let dz = -2; dz <= 2 && left > 0; dz++)\n for (let dy = -1; dy <= 1 && left > 0; dy++) {\n const x = x0 + dx,\n y = y0 + dy,\n z = z0 + dz;\n if (\n !baseOk(this.world.getBlock(x, y - 1, z)) ||\n this.world.getBlock(x, y, z) !== B.air\n )\n continue;\n this.world.setBlock(x, y, z, egg, 0);\n left--;\n }\n events.emit(\"eggsLaid\", { entity: m });\n return left;\n }\n\n /** Вода в квадрате r вокруг клетки (на её высоте и выше на блок) — пляж, а не пустыня. */\n /** Есть ли блок id в кубе радиуса r (аксолотль — мох пышных пещер рядом с озером). */\n private _blockNear(\n x: number,\n y: number,\n z: number,\n id: number | undefined,\n r: number,\n ): boolean {\n if (id === undefined) return false;\n for (let dy = -r; dy <= r; dy++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === id) return true;\n return false;\n }\n\n private _waterNear(x: number, y: number, z: number, r: number): boolean {\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n for (let dy = 0; dy <= 1; dy++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === B.water)\n return true;\n return false;\n }\n\n /** Выпустить пчелу из улья (x,y,z) в свободную соседнюю клетку; false — места нет. */\n spawnBee(x: number, y: number, z: number): boolean {\n for (const [dx, dy, dz] of [\n [0, 0, -1],\n [1, 0, 0],\n [0, 0, 1],\n [-1, 0, 0],\n [0, 1, 0],\n [0, -1, 0],\n ] as const) {\n if (this.world.getBlock(x + dx, y + dy, z + dz) !== 0) continue;\n const b = this.spawnMob(\"bee\", x + dx + 0.5, y + dy + 0.1, z + dz + 0.5);\n b.home = [x, y, z];\n return true;\n }\n return false;\n }\n\n /** Ручные волки рядом с игроком (24 блока) бросаются на `target`. */\n guard(target: Mob): void {\n if (target.tamed || target.dead) return;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e !== target &&\n e.tamed &&\n e.def.guards &&\n !e.sitting &&\n !e.dead &&\n e.pos.distanceTo(this.player.pos) < 24\n )\n e.targetId = target.id;\n }\n\n /** Груз вагонетки как контейнер (воронка под рельсом и в вагонетку, экран сундука). */\n /** Груз вагонетки или вьюк моба — как контейнер для экрана сундука. */\n cartBox(c: Minecart | Mob): BlockEntity {\n return {\n type:\n c.kind === \"minecart\" && c.variant === \"hopper\" ? \"hopper\" : \"chest\",\n x: Math.floor(c.pos.x),\n y: Math.floor(c.pos.y),\n z: Math.floor(c.pos.z),\n state: { items: c.items },\n };\n }\n\n /** Вагонетка с грузом в клетке (x, y, z) — как контейнер; null — нет такой. */\n cartItems(x: number, y: number, z: number): BlockEntity | null {\n for (const e of this.entities.values())\n if (\n e.kind === \"minecart\" &&\n !e.dead &&\n e.items.length &&\n Math.floor(e.pos.x) === x &&\n Math.floor(e.pos.y) === y &&\n Math.floor(e.pos.z) === z\n )\n return this.cartBox(e);\n return null;\n }\n\n /**\n * Работа вагонетки за тик: динамит взрывается, запитанные активирующие рельсы высаживают седока и\n * запирают воронку; вагонетка с воронкой берёт из контейнера над собой или лежащие рядом предметы.\n */\n private _cartWork(c: Minecart, dt: number): void {\n if (c.boom) {\n c.dead = true;\n this.explode(c.pos.x, c.pos.y + 0.5, c.pos.z, 4);\n return;\n }\n if (c.onActivator && c.rider && this.player.riding === c)\n this.player.dismount();\n if (c.variant !== \"hopper\" || c.onActivator) return;\n c.hopCd -= dt;\n if (c.hopCd > 0) return;\n c.hopCd = HOPPER_COOLDOWN;\n const box = this.cartBox(c);\n const into = (s: ItemStack) => insertOne(box, s, \"top\");\n const above = this.world.containerAt?.(box.x, box.y + 1, box.z);\n if (above) extractOne(above, into);\n else\n this.takeDrop(\n c.pos.x - 0.7,\n c.pos.y - 0.2,\n c.pos.z - 0.7,\n c.pos.x + 0.7,\n c.pos.y + 1.2,\n c.pos.z + 0.7,\n into,\n );\n }\n\n spawnMinecart(\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n ): Minecart {\n const c = new Minecart(this._nextId++, x, y, z, variant);\n this.entities.set(c.id, c);\n this.scene.add(c.mesh);\n return c;\n }\n\n spawnBoat(x: number, y: number, z: number, wood: string): Boat {\n const b = new Boat(this._nextId++, x, y, z, wood);\n this.entities.set(b.id, b);\n this.scene.add(b.mesh);\n return b;\n }\n\n /** Забросить поплавок игрока (прежний, если был, исчезает). */\n castHook(pos: THREE.Vector3, vel: THREE.Vector3): FishingHook {\n const old = this.playerHook();\n if (old) old.dead = true;\n const h = new FishingHook(this._nextId++, pos, vel);\n this.entities.set(h.id, h);\n this.scene.add(h.mesh, h.line);\n return h;\n }\n\n /** Поплавок игрока в мире или null. */\n playerHook(): FishingHook | null {\n for (const e of this.entities.values())\n if (e.kind === \"hook\" && !e.dead) return e;\n return null;\n }\n\n /** Луч по транспорту (вагонетки, лодки): ближайший и расстояние до него. */\n raycastRide(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): { cart: Minecart | Boat; t: number } | null {\n let best: { cart: Minecart | Boat; t: number } | null = null;\n for (const e of this.entities.values()) {\n // своя вагонетка или лодка (в ней сидит игрок) лучу не мешает\n if ((e.kind !== \"minecart\" && e.kind !== \"boat\") || e.dead || e.rider)\n continue;\n const hw = e.kind === \"boat\" ? 0.7 : 0.49;\n const t = rayBox(\n origin,\n dir,\n best ? best.t : maxDist,\n { x: e.pos.x - hw, y: e.pos.y, z: e.pos.z - hw },\n {\n x: e.pos.x + hw,\n y: e.pos.y + (e.kind === \"boat\" ? 0.6 : 0.7),\n z: e.pos.z + hw,\n },\n );\n if (t !== null) best = { cart: e, t };\n }\n return best;\n }\n\n // ---- появление ------------------------------------------------------------------------\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n dmg?: number,\n tag?: Record<string, unknown>,\n ): ItemDrop {\n // слияние с ближайшим таким же дропом (инструменты с износом и вещи с tag не сливаются)\n const mergeable = !dmg && !tag && (ITEMS[item]?.maxStack ?? 64) > 1;\n for (const e of mergeable ? this.entities.values() : []) {\n if (\n e.kind === \"drop\" &&\n e.item === item &&\n !e.dead &&\n !e.dmg &&\n e.pos.distanceToSquared({ x, y, z }) < CONFIG.drops.mergeRadius ** 2\n ) {\n e.count += count;\n return e;\n }\n }\n // лимит: убиваем самый старый дроп (уже помеченные на удаление не считаем)\n const drops = [...this.entities.values()].filter(\n (e): e is ItemDrop => e.kind === \"drop\" && !e.dead,\n );\n if (drops.length >= CONFIG.drops.maxCount)\n drops.sort((a, b) => b.age - a.age)[0]!.dead = true;\n const drop = new ItemDrop(this._nextId++, this.atlas, x, y, z, item, count);\n if (dmg) drop.dmg = dmg;\n if (tag) drop.tag = tag;\n this.entities.set(drop.id, drop);\n this.scene.add(drop.mesh);\n return drop;\n }\n\n /** Воронка: забрать из лежащего в коробке дропа один предмет, если accept его взял. */\n takeDrop(\n x0: number,\n y0: number,\n z0: number,\n x1: number,\n y1: number,\n z1: number,\n accept: (s: ItemStack) => boolean,\n ): boolean {\n for (const e of this.entities.values()) {\n if (e.kind !== \"drop\" || e.dead) continue;\n const p = e.pos;\n if (p.x < x0 || p.x > x1 || p.y < y0 || p.y > y1 || p.z < z0 || p.z > z1)\n continue;\n if (!accept({ item: e.item, count: 1, ...(e.dmg ? { dmg: e.dmg } : {}) }))\n continue;\n e.count -= 1;\n if (e.count <= 0) e.dead = true;\n return true;\n }\n return false;\n }\n\n /** Выбрасыватель: стак со скоростью, поднять можно не сразу. */\n ejectItem(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n s: ItemStack,\n ): void {\n const d = this.spawnDrop(x, y, z, s.item, s.count, s.dmg, s.tag);\n d.vel.set(vx, vy, vz);\n d.pickupDelay = 1;\n }\n\n /** Раздатчик: стрела без стрелка; отравленная несёт свой эффект (systems/TippedArrow). */\n shootArrow(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n item = \"arrow\",\n ): void {\n const a = this.spawnArrow(\n new THREE.Vector3(x, y, z),\n new THREE.Vector3(vx, vy, vz),\n 3,\n false,\n -1,\n );\n if (item !== \"arrow\") {\n a.effect = tippedEffect(item);\n a.pick = { item, count: 1 };\n }\n }\n\n spawnMob(type: string, x: number, y: number, z: number, baby = false): Mob {\n const mob = new Mob(this._nextId++, MOBS[type]!, x, y, z, baby);\n this.entities.set(mob.id, mob);\n this.scene.add(mob.mesh);\n return mob;\n }\n\n /** Опыт сферами (крупные номиналы, как в Minecraft), не больше 10 сфер. */\n spawnXp(x: number, y: number, z: number, amount: number): void {\n let left = Math.round(amount);\n for (let n = 0; left > 0 && n < 10; n++) {\n const v =\n n === 9 ? left : left >= 17 ? 17 : left >= 7 ? 7 : left >= 3 ? 3 : 1;\n const orb = new XpOrb(this._nextId++, x, y, z, v);\n this.entities.set(orb.id, orb);\n this.scene.add(orb.mesh);\n left -= v;\n }\n }\n\n spawnArrow(\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n fromPlayer: boolean,\n shooterId: number,\n ): Arrow {\n const a = new Arrow(\n this._nextId++,\n pos,\n vel,\n damage,\n fromPlayer,\n shooterId,\n );\n this.entities.set(a.id, a);\n this.scene.add(a.mesh);\n return a;\n }\n\n /** Огненный шар моба Пекла. */\n spawnFireball(\n kind: FireballKind,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n shooterId: number,\n ): Fireball {\n const f = new Fireball(this._nextId++, kind, pos, vel, damage, shooterId);\n this.entities.set(f.id, f);\n this.scene.add(f.mesh);\n return f;\n }\n\n /** Брошенное Око Предела — летит к цели (цитадели). */\n spawnRimEye(from: THREE.Vector3, target: { x: number; z: number }): RimEye {\n const e = new RimEye(this._nextId++, from, target);\n this.entities.set(e.id, e);\n this.scene.add(e.mesh);\n events.emit(\"eyeThrown\", { x: from.x, y: from.y, z: from.z });\n return e;\n }\n\n /** Ударили моба со стайной злостью — злятся все такие же в 32 блоках. */\n private _angerGroup(m: Mob): void {\n m.angerCall = false;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === m.def.name &&\n e.pos.distanceTo(m.pos) < 32\n )\n e.aggro = true;\n }\n\n /** Взведённая взрывчатка (огниво, огонь, соседний взрыв). */\n spawnTnt(x: number, y: number, z: number, fuse: number): PrimedTnt {\n const t = new PrimedTnt(this._nextId++, this.atlas, x, y, z, fuse);\n this.entities.set(t.id, t);\n this.scene.add(t.mesh);\n events.emit(\"tntFuse\", { x, y, z });\n return t;\n }\n\n /** Удар игрока по мобу. */\n hurt(entityId: number, damage: number, fromPos: THREE.Vector3): void {\n const e = this.entities.get(entityId);\n if (!e || e.kind !== \"mob\" || e.dead) return;\n // дракон: по голове — полный урон, по телу — четверть (как в Minecraft)\n if (e.def.boss === \"dragon\" && !this._headHit(e))\n damage = Math.max(1, damage / 4);\n e.hurt(damage, fromPos, true);\n // шипы глубинника: бьёшь, пока он не заряжает луч, — укол в ответ\n if (e.def.thorns && e.beamT <= 0)\n this.player.damage(e.def.thorns, \"thorns\");\n }\n\n /** Луч взгляда игрока проходит у головы дракона (перед центром, на половине ширины). */\n private _headHit(m: Mob): boolean {\n const fwd = new THREE.Vector3(-Math.sin(m.yaw), 0, -Math.cos(m.yaw));\n const head = m.pos\n .clone()\n .addScaledVector(fwd, m.width * 0.5)\n .setY(m.pos.y + m.height * 0.6);\n const eye = this.player.eyePos,\n dir = this.player.lookDir();\n const t = Math.max(0, head.clone().sub(eye).dot(dir));\n return eye.clone().addScaledVector(dir, t).distanceTo(head) < 2.5;\n }\n\n // ---- взрыв -------------------------------------------------------------------------------\n\n /** Взрыв силы power: блоки (дроп с шансом 1/сила), урон и отброс игроку, мобам и предметам. */\n explode(x: number, y: number, z: number, power: number): void {\n const destroyed = explodeBlocks(this.world, x, y, z, power);\n for (const b of destroyed)\n // соседняя взрывчатка взводится с коротким фитилём — цепная реакция\n if (b.id === B.tnt)\n this.spawnTnt(b.x + 0.5, b.y, b.z + 0.5, 0.5 + Math.random());\n else if (Math.random() < 1 / power)\n for (const d of dropsOf(BLOCKS[b.id]!, b.meta))\n if (ITEMS[d.item])\n this.spawnDrop(b.x + 0.5, b.y + 0.5, b.z + 0.5, d.item, d.n);\n\n const center = new THREE.Vector3(x, y, z);\n const R = power * 2;\n const blast = (\n pos: THREE.Vector3,\n h: number,\n apply: (dmg: number, dir: THREE.Vector3, impact: number) => void,\n ) => {\n const c = new THREE.Vector3(pos.x, pos.y + h / 2, pos.z);\n const dist = c.distanceTo(center);\n if (dist >= R) return;\n const dir = c.clone().sub(center);\n const len = dir.length();\n if (len > 1e-4) dir.divideScalar(len);\n else dir.set(0, 1, 0);\n const exposure =\n len > 1e-4 && raycastBlocks(this.world, center, dir, len) ? 0.3 : 1;\n const impact = (1 - dist / R) * exposure;\n apply(explosionDamage(impact, power), dir, impact);\n };\n blast(this.player.pos, 1.8, (dmg, dir, impact) => {\n this.player.damage(dmg, \"explosion\");\n this.player.vel.addScaledVector(dir, impact * 12);\n });\n for (const e of this.entities.values()) {\n if (e.kind === \"mob\" && !e.dead)\n blast(e.pos, e.height, (dmg, dir, impact) => {\n e.hurt(dmg, center, false);\n e.vel.addScaledVector(dir, impact * 12);\n });\n else if (e.kind === \"drop\" || e.kind === \"tnt\")\n blast(e.pos, 0.5, (_d, dir, impact) =>\n e.vel.addScaledVector(dir, impact * 10),\n );\n }\n events.emit(\"explosion\", { x, y, z, power });\n }\n\n // ---- симуляция ----------------------------------------------------------------------------\n\n private _ctx(): MobCtx {\n const held = this.inventory.heldItem();\n return {\n world: this.world,\n player: this.player,\n isDay: this.isDay,\n skyDarken: this.skyDarken,\n raining: this.raining,\n heldItem: held ? held.item : null,\n shoot: (from, to, speed, damage, shooter, kind = \"arrow\") => {\n if (kind !== \"arrow\" && kind !== \"potion\" && kind !== \"snowball\") {\n // огненный шар летит по прямой — без поправки на падение\n const dir = to.clone().sub(from).normalize();\n this.spawnFireball(\n kind,\n from.clone().addScaledVector(dir, 0.8),\n dir.multiplyScalar(speed),\n damage,\n shooter.id,\n );\n events.emit(\"mobShoot\", { entity: shooter });\n return;\n }\n const d = to.clone().sub(from);\n const dist = d.length();\n const t = dist / speed;\n d.y += 0.5 * 20 * t * t; // поправка на падение стрелы\n d.x += (Math.random() - 0.5) * dist * 0.08;\n d.z += (Math.random() - 0.5) * dist * 0.08;\n d.normalize();\n if (kind === \"potion\" || kind === \"snowball\")\n // зелье ведьмы и снежок снеговика — дугой, как стрела (та же поправка на падение)\n this.spawnFireball(\n kind,\n from.clone().addScaledVector(d, 0.6),\n d.clone().multiplyScalar(speed),\n damage,\n shooter.id,\n );\n else\n this.spawnArrow(\n from.clone().addScaledVector(d, 0.6),\n d.clone().multiplyScalar(speed),\n damage,\n false,\n shooter.id,\n ).effect = shooter.def.ranged?.effect ?? null; // стрела зимогора замедляет\n events.emit(\"mobShoot\", { entity: shooter });\n },\n convert: (villager) => this.convertToZombie(villager),\n crystals: () => {\n const out: Mob[] = [];\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.def.name === \"beyond_crystal\")\n out.push(e);\n return out;\n },\n explode: (x, y, z, p) => this.explode(x, y, z, p),\n summon: (type, x, y, z) =>\n MOBS[type] ? this.spawnMob(type, x, y, z) : null,\n fangs: (from, to, owner) => this.spawnFangs(from, to, owner.id),\n // владыка ломает блоки вокруг себя — с дропом; неломаемые (бедрок, порталы) и жидкости стоят\n breakBlock: (x, y, z) => {\n const id = this.world.getBlock(x, y, z);\n const def = BLOCKS[id];\n if (!id || !def || def.liquid || !Number.isFinite(def.hardness))\n return false;\n const meta = this.world.getMeta(x, y, z);\n this.world.setBlock(x, y, z, B.air!);\n for (const d of dropsOf(def, meta))\n if (ITEMS[d.item])\n this.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n return true;\n },\n mob: (id) => {\n const e = this.entities.get(id);\n return e && e.kind === \"mob\" && !e.dead ? e : null;\n },\n near: (pos, r, pred) => {\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.pos.distanceTo(pos) < r &&\n pred(e)\n )\n return e;\n return null;\n },\n nearDrop: (pos, r, item) => {\n let best: ItemDrop | null = null,\n bd = r * r;\n for (const e of this.entities.values()) {\n if (\n e.kind !== \"drop\" ||\n e.dead ||\n e.item !== item ||\n e.age < e.pickupDelay\n )\n continue;\n const d2 = e.pos.distanceToSquared(pos);\n if (d2 < bd) {\n bd = d2;\n best = e;\n }\n }\n return best ? { id: best.id, pos: best.pos } : null;\n },\n takeDrop: (id, max) => {\n const e = this.entities.get(id);\n if (!e || e.kind !== \"drop\" || e.dead) return 0;\n const n = Math.min(max, e.count);\n e.count -= n;\n if (e.count <= 0) e.dead = true;\n return n;\n },\n giveDrop: (x, y, z, item, count) => {\n this.spawnDrop(x, y, z, item, count);\n },\n findMate: (m) => {\n let best: Mob | null = null,\n bd = 64;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e === m || e.dead || e.baby) continue;\n // своя порода или указанная в mates (лошадь и осёл)\n if ((e.def !== m.def && !m.def.mates?.[e.def.name]) || e.loveT <= 0)\n continue;\n const d2 = e.pos.distanceToSquared(m.pos);\n if (d2 < bd) {\n bd = d2;\n best = e;\n }\n }\n return best;\n },\n breed: (a, b) => {\n a.loveT = b.loveT = 0;\n a.breedCd = b.breedCd = 300; // 5 минут, как в Minecraft\n if (a.def.layEggs)\n this.layEggs(a); // черепаха — яйцами на песок\n else\n this.spawnMob(\n // разные породы — детёныш из таблицы mates (лошадь + осёл → мул)\n a.def === b.def\n ? a.def.name\n : (a.def.mates?.[b.def.name] ?? a.def.name),\n (a.pos.x + b.pos.x) / 2,\n a.pos.y,\n (a.pos.z + b.pos.z) / 2,\n true,\n );\n this.spawnXp(a.pos.x, a.pos.y + 0.5, a.pos.z, randInt(1, 7));\n events.emit(\"mobBred\", { type: a.def.name });\n },\n };\n }\n\n tick(dt: number): void {\n const ctx = this._ctx();\n const mobs = [...this.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\",\n );\n for (const e of this.entities.values()) {\n if (e.kind === \"drop\")\n e.tick(dt, this.world, this.player, this.inventory);\n else if (e.kind === \"mob\") {\n e.tick(dt, ctx);\n if (e.angerCall) this._angerGroup(e);\n } else if (e.kind === \"xp\") e.tick(dt, this.world, this.player);\n else if (e.kind === \"tnt\")\n e.tick(dt, this.world, (x, y, z, p) => this.explode(x, y, z, p));\n else if (e.kind === \"fireball\")\n e.tick(\n dt,\n this.world,\n this.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n mobs,\n );\n else if (e.kind === \"minecart\" || e.kind === \"boat\") {\n e.tick(dt, this.world);\n if (e.kind === \"minecart\") this._cartWork(e, dt);\n } else if (e.kind === \"hook\")\n e.tick(\n dt,\n this.world,\n this.player.pos,\n this.raining,\n this.inventory.heldItem()?.item === \"fishing_rod\",\n );\n else if (e.kind === \"rimeye\") {\n // долетело: в 80% падает предметом, иначе разбивается\n if (e.tick(dt)) {\n if (Math.random() < 0.8)\n this.spawnDrop(e.pos.x, e.pos.y, e.pos.z, \"rim_eye\", 1);\n else\n events.emit(\"eyeShatter\", { x: e.pos.x, y: e.pos.y, z: e.pos.z });\n }\n } else e.tick(dt, this.world, this.player, mobs, this.inventory);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0) this._mobDied(e);\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Смерть моба: дроп по таблице (у детёнышей нет) и опыт, если бил игрок. */\n private _mobDied(m: Mob): void {\n events.emit(\"mobDied\", { entity: m });\n // капитан мародёров от руки игрока — Дурное знамение, уровень растёт до 5 (как в Minecraft)\n if (m.captain && m.hitByPlayerT > 0) {\n const level = Math.min(\n 5,\n (this.player.effects.get(\"bad_omen\")?.level ?? 0) + 1,\n );\n this.player.addEffect(\"bad_omen\", 6000, level);\n events.emit(\"badOmen\", { level });\n }\n if (m.def.deathExplode) {\n // лечивший дракона кристалл, взорвавшись, ранит его на 10 (как в Minecraft)\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.boss && e.healFrom === m.id) {\n e.healFrom = null;\n e.damage(10);\n }\n this.explode(m.pos.x, m.pos.y + 1, m.pos.z, m.def.deathExplode);\n }\n // слизень распадается на слизней поменьше\n const split = m.def.splitsInto;\n if (split)\n for (let i = randInt(split.n[0], split.n[1]); i > 0; i--)\n this.spawnMob(\n split.mob,\n m.pos.x + (Math.random() - 0.5) * m.width,\n m.pos.y + 0.2,\n m.pos.z + (Math.random() - 0.5) * m.width,\n );\n const drop = (item: string, n: number) => {\n if (n > 0 && ITEMS[item])\n this.spawnDrop(m.pos.x, m.pos.y + 0.3, m.pos.z, item, n);\n };\n if (!m.baby) {\n for (const d of m.def.drops) {\n if (d.chance !== undefined && Math.random() > d.chance) continue;\n drop(d.item, d.max ? randInt(d.n, d.max) : d.n);\n }\n const s = m.def.shearDrop;\n if (s && !m.sheared) drop(m.woolItem(), s.n);\n if (m.carry) drop(m.carry.item, 1 + m.bag); // дух-помощник роняет образец и собранное\n // вьюк: вещи и сам сундук; поводок\n if (m.items.length) {\n drop(\"chest\", 1);\n // с износом и tag: зачарованная броня из вьюка не должна выпадать «чистой»\n for (const it of m.items)\n if (it && ITEMS[it.item])\n this.spawnDrop(\n m.pos.x,\n m.pos.y + 0.3,\n m.pos.z,\n it.item,\n it.count,\n it.dmg,\n it.tag,\n );\n }\n if (m.saddled) drop(\"saddle\", 1); // седло с убитой лошади/свиньи — как в Minecraft\n if (m.leashed) drop(\"lead\", 1);\n if (m.hitByPlayerT > 0 && m.def.xp)\n this.spawnXp(\n m.pos.x,\n m.pos.y + 0.5,\n m.pos.z,\n randInt(m.def.xp[0], m.def.xp[1]),\n );\n }\n }\n\n /**\n * Профессия от рабочего блока (systems/Jobs), раз в секунду: безработный житель занимает свободный\n * блок рядом и становится мастером; у кого рабочий блок сломали — теряет профессию, если ни разу\n * не торговал. Житель из деревни рабочего места не имеет и профессию не теряет, как в Minecraft.\n */\n private _villagerJobs(): void {\n const villagers: Mob[] = [];\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n (e.def.name === \"villager\" || e.def.name.startsWith(\"villager_\"))\n )\n villagers.push(e);\n if (!villagers.length) return;\n const claimed = villagers.flatMap((v) => (v.jobSite ? [v.jobSite] : []));\n const blockAt = (x: number, y: number, z: number): string =>\n BLOCKS[this.world.getBlock(x, y, z)]?.name ?? \"air\";\n for (const v of villagers) {\n const site = v.jobSite;\n if (v.def.trades) {\n // работа у своего блока пополняет товар (в Minecraft — дважды за день, здесь — по таймеру)\n if (site && v.uses.length) {\n v.workT -= 1;\n if (\n v.workT <= 0 &&\n Math.abs(v.pos.x - (site[0] + 0.5)) < 2.5 &&\n Math.abs(v.pos.z - (site[2] + 0.5)) < 2.5 &&\n Math.abs(v.pos.y - site[1]) < 3\n ) {\n v.uses = [];\n v.workT = VILLAGER_RESTOCK;\n events.emit(\"villagerRestock\", { mob: v.def.name });\n }\n }\n // рабочий блок сломали или подменили — обратно в безработные (торговавший остаётся мастером)\n if (\n site &&\n !v.traded &&\n jobAt(blockAt(site[0], site[1], site[2])) !== v.def.trades\n )\n this._reprofess(v, \"villager\", null);\n continue;\n }\n const found = findJob(\n blockAt,\n Math.floor(v.pos.x),\n Math.floor(v.pos.y),\n Math.floor(v.pos.z),\n (x, y, z) =>\n claimed.some((c) => c[0] === x && c[1] === y && c[2] === z),\n );\n if (!found) continue;\n claimed.push(found.site);\n this._reprofess(v, \"villager_\" + found.job, found.site);\n }\n }\n\n /**\n * Деревня зовёт голема (systems/Golem): рядом с жителями враждебные (ночная осада) — раз в\n * GOLEM_COOLDOWN секунд встаёт один железный голем. Так деревня отвечает на осаду сама.\n */\n private _villagerGolem(): void {\n if (this._golemT > 0) {\n this._golemT -= 1;\n return;\n }\n // ⚠ кандидатов перебираем всех: в мире живёт не одна кучка жителей, и у соседней деревни свой\n // голем уже есть — по первому встречному жителю вызов не сработал бы никогда.\n const mobs: Mob[] = [];\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && !e.dead) mobs.push(e);\n for (const host of mobs) {\n if (!host.def.trades || host.baby) continue;\n let villagers = 0,\n golems = 0,\n danger = false;\n for (const e of mobs) {\n if (e.pos.distanceTo(host.pos) > GOLEM_RADIUS) continue;\n if (e.def.trades) villagers++;\n else if (e.def.name === \"iron_golem\") golems++;\n else if (e.def.category === \"hostile\") danger = true;\n }\n if (!shouldSummonGolem({ villagers, golems, danger, cooldown: 0 }))\n continue;\n const x = Math.floor(host.pos.x) + (Math.random() < 0.5 ? 2 : -2),\n z = Math.floor(host.pos.z) + (Math.random() < 0.5 ? 2 : -2);\n const y = this.world.surfaceY(x, z);\n if (y <= 0) continue;\n this.spawnMob(\"iron_golem\", x + 0.5, y + 1.01, z + 0.5);\n this._golemT = GOLEM_COOLDOWN;\n events.emit(\"golemSummoned\", { x, y, z });\n return;\n }\n }\n\n /** Лечение зомби-жителя (systems/Zombify): раз в секунду, на нуле — обратно в свою профессию. */\n private _tickCuring(): void {\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead || e.curing <= 0) continue;\n e.curing -= 1;\n if (e.curing > 0) continue;\n const back = this._reprofess(e, curedTypeOf(e.curedType), e.jobSite);\n events.emit(\"villagerCured\", { mob: back.def.name });\n }\n }\n\n /**\n * Житель, убитый зомби, встаёт зомби-жителем и помнит профессию (systems/Zombify).\n * Зовёт мозг зомби через MobCtx.convert.\n */\n convertToZombie(v: Mob): Mob {\n const z = this._reprofess(v, ZOMBIE_VILLAGER, v.jobSite);\n z.hp = z.def.hp;\n z.curedType = v.def.name;\n events.emit(\"villagerZombified\", { mob: v.def.name });\n return z;\n }\n\n /** Тот же житель на том же месте, но другим типом моба: типы жителей — по профессии. */\n private _reprofess(\n v: Mob,\n type: string,\n site: [number, number, number] | null,\n ): Mob {\n const n = this.spawnMob(type, v.pos.x, v.pos.y, v.pos.z, v.baby);\n n.hp = v.hp;\n n.yaw = v.yaw;\n n.jobSite = site;\n n.traded = v.traded;\n n.villagerXp = v.villagerXp; // опыт профессии переживает и смену профессии, и лечение\n v.dead = true; // hp > 0 — исчезает без дропа\n events.emit(\"villagerJob\", { mob: type });\n return n;\n }\n\n /** Перепись: враждебные вдали исчезают, население добирается попытками спавна. */\n _census(dt: number): void {\n this._spawnTimer -= dt;\n if (this._spawnTimer > 0) return;\n this._spawnTimer = 1;\n // патрули мародёров — раз в 5–10 минут, как в Minecraft\n this._patrolT -= 1;\n if (this._patrolT <= 0) {\n this._patrolT = 300 + Math.random() * 300;\n this.spawnPatrol();\n }\n // странствующий торговец — раз в 20 минут с шансом 25%, если его ещё нет (как в Minecraft)\n this._traderT -= 1;\n if (this._traderT <= 0) {\n this._traderT = 1200;\n const here = [...this.entities.values()].some(\n (e) => e.kind === \"mob\" && !e.dead && e.def.name === \"wandering_trader\",\n );\n if (!here && Math.random() < 0.25) this.spawnWanderingTrader();\n }\n this._villagerJobs();\n this._tickCuring();\n this._villagerGolem();\n const M = CONFIG.mobs;\n const p = this.player.pos;\n let passive = 0,\n hostile = 0;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead) continue;\n const d = e.pos.distanceTo(p);\n if (e.def.category === \"passive\") {\n if (d < M.countRadius) passive++;\n continue;\n }\n // голем деревни, ручные, названные биркой, набег и волна испытаний не исчезают\n if (\n e.def.persistent ||\n e.tamed ||\n e.customName ||\n e.raider ||\n e.trialFrom\n )\n continue;\n if (d > M.despawnRadius && !e.def.boss && !e.def.stationary) {\n e.dead = true; // hp > 0 — без дропа\n continue;\n }\n if (d < M.countRadius) hostile++;\n }\n for (let a = 0; a < M.spawnAttempts; a++) {\n const wantHostile =\n hostile < M.hostileCap &&\n (passive >= M.passiveCap || Math.random() < 0.75);\n if (!wantHostile && passive >= M.passiveCap) return;\n const n = this._trySpawn(wantHostile ? \"dark\" : \"bright\");\n if (wantHostile) hostile += n;\n else passive += n;\n }\n }\n\n private _pickType(light: \"bright\" | \"dark\"): MobDef | null {\n const dim = this.world.dimension;\n const list = Object.values(MOBS).filter(\n (d) =>\n d.spawn &&\n (d.spawn.light === light ||\n (light === \"dark\" && d.spawn.light === \"any\")) &&\n (d.spawn.dimension ?? \"overworld\") === dim,\n );\n let total = 0;\n for (const d of list) total += d.spawn!.weight;\n let r = Math.random() * total;\n for (const d of list) {\n r -= d.spawn!.weight;\n if (r <= 0) return d;\n }\n return list[0] ?? null;\n }\n\n private _trySpawn(light: \"bright\" | \"dark\"): number {\n const def = this._pickType(light);\n if (!def) return 0;\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\n const p = this.player.pos;\n const ang = Math.random() * Math.PI * 2;\n // жители построек (крепость, храм) — ближе: постройка может быть меньше обычного кольца спавна\n const r = def.spawn!.structure\n ? 6 + Math.random() * 26\n : rMin! + Math.random() * (rMax! - rMin!);\n const x = Math.floor(p.x + Math.cos(ang) * r),\n z = Math.floor(p.z + Math.sin(ang) * r);\n let y: number;\n if (this.world.dimension === \"inferno\") {\n // пещерный мир: «поверхность» — свод из бедрока; берём высоту около игрока и спускаемся до пола\n y = Math.max(8, Math.min(120, Math.floor(p.y) + randInt(-16, 16)));\n for (\n let k = 0;\n k < 24 && y > 1 && !BLOCKS[this.world.getBlock(x, y - 1, z)]?.solid;\n k++\n )\n y--;\n } else {\n const top = this.world.surfaceY(x, z);\n // тьма — на поверхности (ночью) или случайно в толще (пещеры)\n y =\n light === \"bright\" || Math.random() < 0.5\n ? top + 1\n : 5 + Math.floor(Math.random() * Math.max(1, top - 8));\n }\n // не на полу: спрут и утопленник — в толще воды, фантом — высоко в воздухе\n const med = def.spawn!.medium;\n if (med === \"water\" && def.spawn!.underground) {\n // пещерное озеро: случайная высота под поверхностью и вниз до воды\n let wy = 12 + randInt(0, Math.max(0, this.world.surfaceY(x, z) - 24));\n for (\n let k = 0;\n k < 24 && wy > 1 && this.world.getBlock(x, wy, z) !== B.water;\n k++\n )\n wy--;\n y = wy;\n } else if (med === \"water\") {\n let wy = this.world.surfaceY(x, z) + 1;\n for (\n let k = 0;\n k < 12 && wy > 1 && this.world.getBlock(x, wy, z) !== B.water;\n k++\n )\n wy--;\n // в толще у поверхности; житель подводной постройки — на любой глубине\n y = def.spawn!.structure\n ? randInt(8, Math.max(8, wy))\n : wy - randInt(0, 2);\n } else if (med === \"lava\")\n y = this.world.generator.waterLevel + 1; // страйдер — на лавовом море\n else if (med === \"air\")\n // летучая мышь — в воздухе пещер, фантом — высоко над поверхностью\n y = def.spawn!.underground\n ? 5 + randInt(0, Math.max(0, this.world.surfaceY(x, z) - 12))\n : this.world.surfaceY(x, z) + randInt(12, 20);\n if (!this._canSpawn(def, x, y, z)) return 0;\n const [gMin, gMax] = def.spawn!.group;\n const n = randInt(gMin, gMax);\n let spawned = 0;\n for (let i = 0; i < n; i++) {\n const sx = x + (i ? randInt(-2, 2) : 0),\n sz = z + (i ? randInt(-2, 2) : 0);\n if (i && !this._canSpawn(def, sx, y, sz)) continue;\n this.spawnMob(def.name, sx + 0.5, y + 0.01, sz + 0.5);\n spawned++;\n }\n return spawned;\n }\n\n /** Место для спавна: пол непрозрачный, над ним воздух на рост, свет по правилу вида. */\n private _canSpawn(def: MobDef, x: number, y: number, z: number): boolean {\n const w = this.world;\n if (y < 1) return false;\n const sp = def.spawn!;\n // на мирной сложности монстров нет вовсе (systems/Difficulty)\n if (def.category === \"hostile\" && !rules().hostiles) return false;\n if (sp.insomnia && !this.insomnia) return false;\n // по биомам: тропическая рыба — в тёплом океане (у мира без биомов фильтра нет)\n // в грибных полях монстры не появляются (и в пещерах под ними), кроме жителей построек\n if (\n def.category === \"hostile\" &&\n !sp.structure &&\n w.generator.biomeOf?.(x, z) === \"mushroom_fields\"\n )\n return false;\n const bio = sp.biomes ? w.generator.biomeOf?.(x, z) : undefined;\n if (sp.biomes && bio !== undefined && !sp.biomes.includes(bio))\n return false;\n // на лаве: под ногами лава, над ней воздух на рост\n if (sp.medium === \"lava\") {\n if (w.getBlock(x, y - 1, z) !== B.lava) return false;\n for (let dy = 0; dy < Math.ceil(def.box.h); dy++)\n if (w.getBlock(x, y + dy, z) !== 0) return false;\n return true;\n }\n if (sp.medium) {\n // вода или воздух на весь рост, свет — по правилу вида\n const want = sp.medium === \"water\" ? B.water : 0;\n for (let dy = 0; dy < Math.max(1, Math.ceil(def.box.h)); dy++) {\n const id = w.getBlock(x, y + dy, z);\n if (id !== want && !(want === B.water && BLOCKS[id]?.waterlogged))\n return false;\n }\n if (sp.structure && w.generator.structureAt?.(x, y, z) !== sp.structure)\n return false;\n if (sp.underground && y >= w.surfaceY(x, z) - 4) return false;\n if (sp.nearBlock && !this._blockNear(x, y, z, B[sp.nearBlock], 4))\n return false;\n const l = w.getLight(x, y, z);\n const sky = (l >> 4) - this.skyDarken,\n blk = l & 15;\n if (sp.light === \"any\") return true;\n if (sp.light === \"bright\") return Math.max(sky, blk) >= 9;\n return blk === 0 && sky <= 7;\n }\n const bd = BLOCKS[w.getBlock(x, y - 1, z)];\n if (!bd || !bd.solid || !bd.opaque) return false;\n for (let dy = 0; dy < Math.ceil(def.box.h); dy++)\n if (w.getBlock(x, y + dy, z) !== 0) return false;\n const l = w.getLight(x, y, z);\n const sky = (l >> 4) - this.skyDarken,\n blk = l & 15;\n const s = def.spawn!;\n if (s.maxY !== undefined && y > s.maxY) return false;\n if (\n s.structure &&\n this.world.generator.structureAt?.(x, y, z) !== s.structure\n )\n return false;\n if (s.nearWater && !this._waterNear(x, y - 1, z, 4)) return false;\n if (s.light === \"any\") return !s.ground || s.ground.includes(bd.name);\n if (s.light === \"bright\") {\n if (s.ground && !s.ground.includes(bd.name)) return false;\n return Math.max(sky, blk) >= 9;\n }\n return blk === 0 && sky <= 7;\n }\n\n /** Луч по AABB мобов (slab-тест) — для атаки и кормления. Возвращает ближайшего. */\n raycast(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): Mob | null {\n let best: Mob | null = null,\n bestT = maxDist;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead || e.rider) continue; // лошадь под игроком лучу не мешает\n const w = e.width / 2;\n const t = rayBox(\n origin,\n dir,\n bestT,\n { x: e.pos.x - w, y: e.pos.y, z: e.pos.z - w },\n { x: e.pos.x + w, y: e.pos.y + e.height, z: e.pos.z + w },\n );\n if (t !== null && t < bestT) {\n bestT = t;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n for (const e of this.entities.values()) {\n if (e.kind === \"mob\") {\n // свет места: в пещере моб темнее (ночь приглушает свет сцены и так)\n const l = this.world.getLight(\n Math.floor(e.pos.x),\n Math.floor(e.pos.y + e.height * 0.5),\n Math.floor(e.pos.z),\n );\n const lvl = Math.max(l >> 4, l & 15);\n // без неба (Пекло, Предел) свет блоков почти всюду 0 — там свой фоновый порог\n const floor = this.world.dimension === \"overworld\" ? 0.05 : 0.6;\n e.updateVisual(alpha, time, Math.max(floor, Math.pow(0.8, 15 - lvl)));\n } else if (\n e.kind === \"arrow\" ||\n e.kind === \"fireball\" ||\n e.kind === \"minecart\" ||\n e.kind === \"boat\"\n )\n e.updateVisual(alpha);\n else if (e.kind === \"hook\") {\n // леска — от кончика удочки: чуть впереди, правее и ниже глаз\n const eye = this.player.eyePos,\n yaw = this.player.yaw;\n const tip = eye\n .clone()\n .addScaledVector(this.player.lookDir(), 0.7)\n .add(\n new THREE.Vector3(Math.cos(yaw) * 0.3, -0.3, -Math.sin(yaw) * 0.3),\n );\n e.updateVisual(alpha, tip);\n } else e.updateVisual(alpha, time);\n }\n this._beam();\n }\n\n /** Луч от лечащего Кристалла Предела к дракону. */\n private beamLine: THREE.Line | null = null;\n private _beam(): void {\n let pts: THREE.Vector3[] | null = null;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || !e.def.boss || e.healFrom === null) continue;\n const k = this.entities.get(e.healFrom);\n if (k && k.kind === \"mob\" && !k.dead)\n pts = [\n k.group.position.clone().setY(k.group.position.y + 1),\n e.group.position.clone().setY(e.group.position.y + e.height * 0.5),\n ];\n }\n if (!pts) {\n if (this.beamLine) this.beamLine.visible = false;\n return;\n }\n if (!this.beamLine) {\n this.beamLine = new THREE.Line(\n new THREE.BufferGeometry(),\n new THREE.LineBasicMaterial({ color: 0xff80ff }),\n );\n this.scene.add(this.beamLine);\n }\n this.beamLine.geometry.setFromPoints(pts);\n this.beamLine.visible = true;\n }\n\n clear(): void {\n // игрок сидел в вагонетке/лодке этого мира — ссаживаем без телепорта: иначе Player.update\n // каждый кадр ставил бы его к выброшенной сущности (после портала — в координаты прошлого\n // измерения), а «присесть» высаживало бы туда же\n const ride = this.player?.riding;\n if (ride) {\n ride.rider = false;\n this.player.riding = null;\n }\n // выброшенные сущности — мёртвые для всех, кто держит на них ссылку (экран торговли с жителем,\n // груз вагонетки, поводок): иначе после портала экран жил бы с призраком из прошлого измерения\n for (const e of this.entities.values()) {\n e.dead = true;\n e.dispose(this.scene);\n }\n this.entities.clear();\n if (this.beamLine) this.beamLine.visible = false;\n }\n\n serialize() {\n const out: unknown[] = [];\n for (const e of this.entities.values())\n if (\n e.kind !== \"arrow\" &&\n e.kind !== \"tnt\" &&\n e.kind !== \"fireball\" &&\n e.kind !== \"rimeye\" &&\n e.kind !== \"hook\" &&\n e.kind !== \"fang\"\n )\n out.push(e.serialize());\n return out;\n }\n\n deserialize(list?: EntitySave[]): void {\n this.clear();\n for (const d of Array.isArray(list) ? list : []) {\n // запись из чужого файла (Мастерская, другая версия): null или без координат ронял загрузку\n // посреди замены мира — мир уже новый, а сущности и блоки ещё старые\n const pos: unknown = (d as { pos?: unknown } | null)?.pos;\n if (\n !d ||\n typeof d !== \"object\" ||\n !Array.isArray(pos) ||\n pos.length < 3 ||\n !pos.slice(0, 3).every((n) => Number.isFinite(n))\n )\n continue;\n if (d.kind === \"mob\" && MOBS[d.type]) {\n const m = this.spawnMob(d.type, d.pos[0], d.pos[1], d.pos[2], !!d.baby);\n // NaN в здоровье делал моба бессмертным: hp − урон = NaN, а NaN ≤ 0 — ложь\n if (Number.isFinite(d.hp) && d.hp > 0) m.hp = d.hp;\n m.sheared = !!d.sheared;\n if (Array.isArray(d.uses))\n m.uses = d.uses.filter((n) => typeof n === \"number\");\n if (Array.isArray(d.jobSite) && d.jobSite.length === 3)\n m.jobSite = [d.jobSite[0], d.jobSite[1], d.jobSite[2]];\n m.traded = !!d.traded;\n if (typeof d.villagerXp === \"number\") m.villagerXp = d.villagerXp;\n if (typeof d.curedType === \"string\") m.curedType = d.curedType;\n if (typeof d.curing === \"number\") m.curing = d.curing;\n m.tamed = !!d.tamed;\n if (typeof d.customName === \"string\") m.setName(d.customName);\n m.sitting = !!d.sitting;\n m.saddled = !!d.saddled;\n // нет поля — белая (случайный цвет новорождённой овцы здесь не нужен)\n m.woolColor = typeof d.woolColor === \"string\" ? d.woolColor : null;\n if (typeof d.wolfArmor === \"number\") m.wolfArmor = d.wolfArmor;\n if (typeof d.carry === \"string\") {\n m.carry = { item: d.carry, count: 1 };\n m.bag = typeof d.bag === \"number\" ? d.bag : 0;\n }\n if (Array.isArray(d.items) && m.def.chestSlots)\n m.items = d.items.map((s) => sanitizeStack(s));\n m.leashed = !!d.leashed;\n if (typeof d.leaveT === \"number\") m.leaveT = d.leaveT;\n if (Array.isArray(d.leashPost) && d.leashPost.length === 3)\n m.leashPost = [d.leashPost[0], d.leashPost[1], d.leashPost[2]];\n if (Array.isArray(d.home) && d.home.length === 3)\n m.home = [d.home[0], d.home[1], d.home[2]];\n m.pollen = !!d.pollen;\n m.captain = !!d.captain;\n } else if (d.kind === \"drop\") {\n // предмет, которого нет в игре (чужая версия сейва), рисовать нечем — ItemMesh упал бы\n if (!ITEMS[d.item] || !(d.count > 0)) continue;\n const drop = this.spawnDrop(\n d.pos[0],\n d.pos[1],\n d.pos[2],\n d.item,\n d.count,\n d.dmg,\n d.tag,\n );\n if (drop) drop.vel.set(0, 0, 0);\n } else if (d.kind === \"xp\")\n this.spawnXp(d.pos[0], d.pos[1], d.pos[2], d.value);\n else if (d.kind === \"minecart\") {\n const variant =\n d.variant && CART_VARIANTS.includes(d.variant) ? d.variant : \"plain\";\n const c = this.spawnMinecart(d.pos[0], d.pos[1], d.pos[2], variant);\n if (Array.isArray(d.vel)) c.vel.fromArray(d.vel);\n if (Array.isArray(d.items))\n d.items.forEach((s, i) => {\n if (i < c.items.length) c.items[i] = sanitizeStack(s);\n });\n } else if (d.kind === \"boat\")\n this.spawnBoat(d.pos[0], d.pos[1], d.pos[2], d.wood ?? \"oak\");\n }\n }\n}\n"
192
192
  },
193
193
  {
194
194
  "path": "entities/Fang.ts",
@@ -228,7 +228,7 @@
228
228
  },
229
229
  {
230
230
  "path": "entities/ItemDrop.ts",
231
- "content": "// Дроп-предмет: мини-модель блока (или плоская иконка) с физикой —\n// подброс, гравитация, отскок, трение, медленное вращение и «магнит» к игроку.\n// Физика на тике (20 TPS), позиция для рендера интерполируется.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { BlockDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\nconst D = CONFIG.drops;\n// Гетерогенный кэш: геометрия под ключом key, материал под key+':mat' (разные типы значений).\nconst geoCache = new Map<string, THREE.BufferGeometry | THREE.Material>();\n\nfunction blockGeometry(\n atlas: TextureAtlas,\n blockDef: BlockDef,\n): THREE.BoxGeometry {\n // мини-куб с UV граней из атласа (top/side/bottom)\n const g = new THREE.BoxGeometry(D.scale, D.scale, D.scale);\n const uvAttr = g.getAttribute(\"uv\");\n // порядок граней BoxGeometry: +x,-x,+y,-y,+z,-z (по 4 вершины)\n // tex/грани заведомо заданы для размещаемых блоков, приходящих сюда — non-null asserts.\n const tex = blockDef.tex!;\n const tiles = [\n tex.side!,\n tex.side!,\n tex.top!,\n tex.bottom!,\n tex.side!,\n tex.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = atlas.uv(tiles[f]!);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // общий материал чанков — vertexColors:true; без белого атрибута цвета\n // вершины считаются чёрными и гасят текстуру\n g.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(g.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n return g;\n}\n\nexport class ItemDrop {\n id: number;\n kind: \"drop\";\n item: string;\n count: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n age: number;\n dead: boolean;\n _pickupAnim: number;\n /** Износ инструмента/брони — переживает выбрасывание и подбор. */\n dmg?: number;\n /** Доп. данные стака (зачарования, вещи панцирного сундука) — тоже переживают выброс и подбор. */\n tag?: Record<string, unknown>;\n /** Сколько секунд после появления нельзя подобрать (выброшенное игроком — 2 с, как в Minecraft). */\n pickupDelay = 0.5;\n mesh: THREE.Mesh;\n\n constructor(\n id: number,\n atlas: TextureAtlas,\n x: number,\n y: number,\n z: number,\n itemName: string,\n count: number,\n ) {\n this.id = id;\n this.kind = \"drop\";\n this.item = itemName;\n this.count = count;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3(\n (Math.random() - 0.5) * 2.4,\n 4 + Math.random() * 1.5,\n (Math.random() - 0.5) * 2.4,\n );\n this.age = 0;\n this.dead = false;\n this._pickupAnim = 0; // >0 — летит в игрока\n\n const def = ITEMS[itemName]!;\n // Значения кэша гетерогенны (геометрия/материал под разными ключами) — сужаем кастами при чтении.\n const key = \"drop:\" + itemName;\n let geo = geoCache.get(key) as THREE.BufferGeometry | undefined;\n if (def.blockId !== null && BLOCKS[def.blockId]!.shape === \"cube\") {\n if (!geo) {\n geo = blockGeometry(atlas, BLOCKS[def.blockId]!);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, atlas.opaqueMat);\n } else {\n // плоский предмет: квад с иконкой (или тайлом растения)\n let mat = geoCache.get(key + \":mat\") as\n THREE.MeshLambertMaterial | undefined;\n if (!mat) {\n // не-блочные предметы (материалы/инструменты/крестовины) всегда имеют tile — non-null assert.\n const tex = new THREE.CanvasTexture(\n def.icon ?? atlas.tileCanvas(def.tile!),\n );\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshLambertMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n geoCache.set(key + \":mat\", mat);\n }\n if (!geo) {\n geo = new THREE.PlaneGeometry(D.scale * 1.4, D.scale * 1.4);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, mat);\n }\n this.mesh.castShadow = true;\n this.mesh.position.copy(this.pos);\n }\n\n tick(dt: number, world: ChunkManager, player: Player, inventory: Inventory) {\n this.prevPos.copy(this.pos);\n this.age += dt;\n if (this.age > D.despawnTime) {\n this.dead = true;\n return;\n }\n // в лаве и в огне предметы сгорают\n const here = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n );\n // пеклит и древние обломки в лаве не горят, как в Minecraft\n const fireproof =\n this.item.startsWith(\"infernite\") || this.item === \"ancient_debris\";\n if (\n (here === B.lava || here === B.fire || here === B.soul_fire) &&\n !fireproof\n ) {\n this.dead = true;\n return;\n }\n\n const toPlayer = new THREE.Vector3(\n player.pos.x,\n player.pos.y + 0.9,\n player.pos.z,\n ).sub(this.pos);\n const dist = toPlayer.length();\n\n // подбор\n const canTake = !player.dead && this.age > this.pickupDelay; // мёртвый не подбирает\n if (dist < D.pickupRadius && canTake) {\n const left =\n this.dmg || this.tag\n ? inventory.addStack({\n item: this.item,\n count: this.count,\n ...(this.dmg ? { dmg: this.dmg } : {}),\n ...(this.tag ? { tag: this.tag } : {}),\n })\n : inventory.add(this.item, this.count);\n if (left < this.count) {\n events.emit(\"itemPickup\", {\n item: this.item,\n count: this.count - left,\n });\n if (left > 0) this.count = left;\n else {\n this.dead = true;\n return;\n }\n }\n }\n // магнит: тянет к игроку, физика отключается\n if (dist < D.magnetRadius && canTake) {\n toPlayer\n .normalize()\n .multiplyScalar(\n D.magnetPull * Math.max(0.3, 1 - dist / D.magnetRadius) + 2,\n );\n this.vel.lerp(toPlayer, 0.5);\n this.pos.addScaledVector(this.vel, dt);\n return;\n }\n\n // обычная физика: гравитация + точечная коллизия с вокселями\n this.vel.y -= D.gravity * dt;\n const nx = this.pos.x + this.vel.x * dt;\n const ny = this.pos.y + this.vel.y * dt;\n const nz = this.pos.z + this.vel.z * dt;\n\n if (\n this.vel.y < 0 &&\n world.isSolid(Math.floor(nx), Math.floor(ny - 0.12), Math.floor(nz))\n ) {\n // отскок от пола\n this.pos.y = Math.floor(ny - 0.12) + 1 + 0.12;\n this.vel.y = Math.abs(this.vel.y) > 1 ? -this.vel.y * D.bounce : 0;\n this.vel.x *= D.friction;\n this.vel.z *= D.friction;\n } else {\n this.pos.y = ny;\n // горизонтальные стенки — просто стоп\n if (\n !world.isSolid(\n Math.floor(nx),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n )\n this.pos.x = nx;\n else this.vel.x = 0;\n if (\n !world.isSolid(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(nz),\n )\n )\n this.pos.z = nz;\n else this.vel.z = 0;\n }\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.mesh.position.set(\n p.x,\n p.y + Math.sin(time * 2 + this.id) * 0.05 + 0.05,\n p.z,\n );\n this.mesh.rotation.y = time * 1.2 + this.id;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.mesh); /* геометрии/материалы в кэше — общие */\n }\n\n serialize() {\n return {\n kind: \"drop\",\n item: this.item,\n count: this.count,\n pos: this.pos.toArray(),\n ...(this.dmg ? { dmg: this.dmg } : {}),\n ...(this.tag ? { tag: this.tag } : {}),\n };\n }\n}\n"
231
+ "content": "// Дроп-предмет: мини-модель блока (или плоская иконка) с физикой —\n// подброс, гравитация, отскок, трение, медленное вращение и «магнит» к игроку.\n// Физика на тике (20 TPS), позиция для рендера интерполируется.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { BlockDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\nconst D = CONFIG.drops;\n// Гетерогенный кэш: геометрия под ключом key, материал под key+':mat' (разные типы значений).\nconst geoCache = new Map<string, THREE.BufferGeometry | THREE.Material>();\n\nfunction blockGeometry(\n atlas: TextureAtlas,\n blockDef: BlockDef,\n): THREE.BoxGeometry {\n // мини-куб с UV граней из атласа (top/side/bottom)\n const g = new THREE.BoxGeometry(D.scale, D.scale, D.scale);\n const uvAttr = g.getAttribute(\"uv\");\n // порядок граней BoxGeometry: +x,-x,+y,-y,+z,-z (по 4 вершины)\n // tex/грани заведомо заданы для размещаемых блоков, приходящих сюда — non-null asserts.\n const tex = blockDef.tex!;\n const tiles = [\n tex.side!,\n tex.side!,\n tex.top!,\n tex.bottom!,\n tex.side!,\n tex.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = atlas.uv(tiles[f]!);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // общий материал чанков — vertexColors:true; без белого атрибута цвета\n // вершины считаются чёрными и гасят текстуру\n g.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(g.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n return g;\n}\n\nexport class ItemDrop {\n id: number;\n kind: \"drop\";\n item: string;\n count: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n age: number;\n dead: boolean;\n _pickupAnim: number;\n /** Износ инструмента/брони — переживает выбрасывание и подбор. */\n dmg?: number;\n /** Доп. данные стака (зачарования, вещи панцирного сундука) — тоже переживают выброс и подбор. */\n tag?: Record<string, unknown>;\n /** Сколько секунд после появления нельзя подобрать (выброшенное игроком — 2 с, как в Minecraft). */\n pickupDelay = 0.5;\n mesh: THREE.Mesh;\n\n constructor(\n id: number,\n atlas: TextureAtlas,\n x: number,\n y: number,\n z: number,\n itemName: string,\n count: number,\n ) {\n this.id = id;\n this.kind = \"drop\";\n this.item = itemName;\n this.count = count;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3(\n (Math.random() - 0.5) * 2.4,\n 4 + Math.random() * 1.5,\n (Math.random() - 0.5) * 2.4,\n );\n this.age = 0;\n this.dead = false;\n this._pickupAnim = 0; // >0 — летит в игрока\n\n // предмет, которого нет в игре (сундук из чужого сейва), рисуем камнем: падать из-за него весь\n // кадр не должен\n const def = ITEMS[itemName] ?? ITEMS.stone!;\n // Значения кэша гетерогенны (геометрия/материал под разными ключами) — сужаем кастами при чтении.\n const key = \"drop:\" + itemName;\n let geo = geoCache.get(key) as THREE.BufferGeometry | undefined;\n if (def.blockId !== null && BLOCKS[def.blockId]!.shape === \"cube\") {\n if (!geo) {\n geo = blockGeometry(atlas, BLOCKS[def.blockId]!);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, atlas.opaqueMat);\n } else {\n // плоский предмет: квад с иконкой (или тайлом растения)\n let mat = geoCache.get(key + \":mat\") as\n THREE.MeshLambertMaterial | undefined;\n if (!mat) {\n // не-блочные предметы (материалы/инструменты/крестовины) всегда имеют tile — non-null assert.\n const tex = new THREE.CanvasTexture(\n def.icon ?? atlas.tileCanvas(def.tile!),\n );\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshLambertMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n geoCache.set(key + \":mat\", mat);\n }\n if (!geo) {\n geo = new THREE.PlaneGeometry(D.scale * 1.4, D.scale * 1.4);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, mat);\n }\n this.mesh.castShadow = true;\n this.mesh.position.copy(this.pos);\n }\n\n tick(dt: number, world: ChunkManager, player: Player, inventory: Inventory) {\n this.prevPos.copy(this.pos);\n this.age += dt;\n if (this.age > D.despawnTime) {\n this.dead = true;\n return;\n }\n // в лаве и в огне предметы сгорают\n const here = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n );\n // пеклит и древние обломки в лаве не горят, как в Minecraft\n const fireproof =\n this.item.startsWith(\"infernite\") || this.item === \"ancient_debris\";\n if (\n (here === B.lava || here === B.fire || here === B.soul_fire) &&\n !fireproof\n ) {\n this.dead = true;\n return;\n }\n\n const toPlayer = new THREE.Vector3(\n player.pos.x,\n player.pos.y + 0.9,\n player.pos.z,\n ).sub(this.pos);\n const dist = toPlayer.length();\n\n // подбор\n const canTake = !player.dead && this.age > this.pickupDelay; // мёртвый не подбирает\n if (dist < D.pickupRadius && canTake) {\n const left =\n this.dmg || this.tag\n ? inventory.addStack({\n item: this.item,\n count: this.count,\n ...(this.dmg ? { dmg: this.dmg } : {}),\n ...(this.tag ? { tag: this.tag } : {}),\n })\n : inventory.add(this.item, this.count);\n if (left < this.count) {\n events.emit(\"itemPickup\", {\n item: this.item,\n count: this.count - left,\n });\n if (left > 0) this.count = left;\n else {\n this.dead = true;\n return;\n }\n }\n }\n // магнит: тянет к игроку, физика отключается\n if (dist < D.magnetRadius && canTake) {\n toPlayer\n .normalize()\n .multiplyScalar(\n D.magnetPull * Math.max(0.3, 1 - dist / D.magnetRadius) + 2,\n );\n this.vel.lerp(toPlayer, 0.5);\n this.pos.addScaledVector(this.vel, dt);\n return;\n }\n\n // обычная физика: гравитация + точечная коллизия с вокселями\n this.vel.y -= D.gravity * dt;\n const nx = this.pos.x + this.vel.x * dt;\n const ny = this.pos.y + this.vel.y * dt;\n const nz = this.pos.z + this.vel.z * dt;\n\n if (\n this.vel.y < 0 &&\n world.isSolid(Math.floor(nx), Math.floor(ny - 0.12), Math.floor(nz))\n ) {\n // отскок от пола\n this.pos.y = Math.floor(ny - 0.12) + 1 + 0.12;\n this.vel.y = Math.abs(this.vel.y) > 1 ? -this.vel.y * D.bounce : 0;\n this.vel.x *= D.friction;\n this.vel.z *= D.friction;\n } else {\n this.pos.y = ny;\n // горизонтальные стенки — просто стоп\n if (\n !world.isSolid(\n Math.floor(nx),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n )\n this.pos.x = nx;\n else this.vel.x = 0;\n if (\n !world.isSolid(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(nz),\n )\n )\n this.pos.z = nz;\n else this.vel.z = 0;\n }\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.mesh.position.set(\n p.x,\n p.y + Math.sin(time * 2 + this.id) * 0.05 + 0.05,\n p.z,\n );\n this.mesh.rotation.y = time * 1.2 + this.id;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.mesh); /* геометрии/материалы в кэше — общие */\n }\n\n serialize() {\n return {\n kind: \"drop\",\n item: this.item,\n count: this.count,\n pos: this.pos.toArray(),\n ...(this.dmg ? { dmg: this.dmg } : {}),\n ...(this.tag ? { tag: this.tag } : {}),\n };\n }\n}\n"
232
232
  },
233
233
  {
234
234
  "path": "entities/Jungle.test.ts",
@@ -248,7 +248,7 @@
248
248
  },
249
249
  {
250
250
  "path": "entities/Mob.ts",
251
- "content": "// Универсальный моб: риг собирается из parts-описания реестра (BoxGeometry), физика — по коробкам\n// блоков (общая с игроком), решения — entities/MobBrain.ts, анимации процедурные.\n// Добавление моба = запись в registry/mobs/*.ts, этот код не меняется.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { DYE_COLORS } from \"../registry/blocks/colors\";\nimport type { ItemStack as Stack } from \"../types\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { moveBody } from \"../physics/Collision\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { EffectId, MobDef } from \"../types\";\nimport { think, type MobCtx } from \"./MobBrain\";\n\nconst PX = 1 / 16; // части описаны в пикселях: 16 px = 1 блок\n/** Детёныш вырастает за 20 минут, как в Minecraft. */\nexport const GROW_TIME = 1200;\n/** Для страйдера лава — полный куб (physics/Collision, extraSolid). */\nconst isLava = (id: number): boolean => id === B.lava;\n\n/** follow — ручной идёт за хозяином (MobBrain.petThink). */\nexport type MobState =\n \"idle\" | \"wander\" | \"flee\" | \"chase\" | \"tempt\" | \"love\" | \"follow\";\n\ninterface AnimPart {\n g: THREE.Group;\n base: THREE.Euler;\n}\n\n/**\n * Сколько секунд зомби ломится в дверь, прежде чем она рухнет. Счётчик копится, пока моб\n * упирается в дверь, и МЕДЛЕННО стекает, когда он отскочил: иначе каждый отскок сбрасывал бы\n * долбёжку в ноль и дверь стояла бы вечно (проверено на живом зомби).\n */\nconst DOOR_TIME = 3;\n\nexport class Mob {\n id: number;\n kind = \"mob\" as const;\n def: MobDef;\n hp: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n targetYaw: number;\n onGround = false;\n dead = false;\n state: MobState = \"idle\";\n stateTime: number;\n /** Желаемая скорость этого тика (мозг); отрицательная — пятиться. */\n speed = 0;\n /**\n * Эффекты на мобе: id → сколько ещё секунд. Понимает два — яд (1 урона раз в 2 с, но не\n * добивает) и замедление (вдвое медленнее). Их вешают отравленные стрелы (systems/TippedArrow).\n */\n effects = new Map<EffectId, number>();\n /** Имя с бирки: висит над мобом и держит его в мире (не исчезает вдали от игрока). */\n customName: string | null = null;\n private nameSprite: THREE.Sprite | null = null;\n private _poisonT = 0;\n /** Желаемая вертикальная скорость летуна (мозг). */\n climbWish = 0;\n /** Куда идти, если не туда, куда смотрит (владыка летит по кругу, глядя на игрока); null — по yaw. */\n moveYaw: number | null = null;\n /** Пауза до следующего прыжка (слизень). */\n hopT = 0;\n /** Часы пике фантома: из каждых 6 с — 2 с вниз на цель. */\n swoopT = 0;\n /** Пчела: свой улей, есть ли пыльца, цветок-цель, таймеры поиска и сбора. */\n home: [number, number, number] | null = null;\n pollen = false;\n beeTarget: [number, number, number] | null = null;\n beeT = 0;\n beeWork = 0;\n /** Ударил игрок моба со стайной злостью — EntityManager разбудит стаю на тике. */\n angerCall = false;\n /** Дракон: состояние мозга босса (entities/DragonBrain). Не сохраняется — после загрузки кружит. */\n bossState: unknown = null;\n /** Кристалл, что сейчас лечит дракона (id): луч и урон дракону от взрыва этого кристалла. */\n healFrom: number | null = null;\n /** Не получает урона (Пепельный владыка набирает силу). */\n invulnerable = false;\n /** Панцирник: насколько открыта крышка (0 — закрыт, 1 — открыт); иглобрюх — насколько раздут. */\n peek = 0;\n /** Рыба: сколько секунд на суше. */\n dryT = 0;\n /** Страж (entities/WardenBrain): злость, место последней вибрации, заряд и остывание звукового удара. */\n anger = 0;\n suspect: [number, number, number] | null = null;\n boomT = 0;\n boomCd = 0;\n /** Страж: сколько ещё вылезает из земли; сколько простаивает без тревог; до следующей Тьмы. */\n emergeT = 0;\n idleT = 0;\n darkT = 0;\n /** Коза (entities/GoatBrain): до следующего тарана, пригибается, разгон, сколько рогов осталось. */\n ramCd = 0;\n ramT = 0;\n dashT = 0;\n horns = 2;\n /** Кричащая коза (2%, как в Minecraft): таранит в разы чаще и кричит. */\n screaming = Math.random() < 0.02;\n /** Лягушка (entities/FrogBrain): до следующего броска языком. */\n tongueCd = 0;\n /** Нюхач (entities/SnifferBrain): до следующего принюхивания; сколько ещё роет. */\n sniffCd = 0;\n digT = 0;\n /** Моб волны спавнера испытаний: координаты спавнера (Game засчитывает убитого; вдали не исчезает). */\n trialFrom: [number, number, number] | null = null;\n /** Капитан мародёров: знамя на спине, за его смерть — Дурное знамение. */\n captain = false;\n /** Участник набега: идёт к goal (центру деревни) и не исчезает вдали. */\n raider = false;\n /** Секунд подсветки от колокола (systems: EntityManager.markRaiders). */\n glowT = 0;\n goal: THREE.Vector3 | null = null;\n /** Ворожей: до следующего заклинания и призванные бесята. */\n castT = 2;\n minions: Mob[] = [];\n /** Бесёнок: сколько ещё жить (сек). */\n lifeT = Infinity;\n private readonly bannerParts: THREE.Object3D[] = [];\n /** Глубинник: сколько заряжается луч (сек), куда он смотрит (null — не заряжает), до следующей Усталости. */\n beamT = 0;\n beamTo: THREE.Vector3 | null = null;\n fatigueT = 3;\n private beamLine: THREE.Line | null = null;\n inWater = false;\n inLava = false;\n hitWall = false;\n baby: boolean;\n growT = 0;\n /** Секунд «любви» после кормления (ищет пару). */\n loveT = 0;\n breedCd = 0;\n fuse = 0;\n attackCd = 0;\n shootCd = 0;\n burnT = 0;\n burning = false;\n /** Снеговик: накопитель секунд в жаре или воде (урон 1 за секунду). */\n snowT = 0;\n /** Попугай на плече игрока: 0 — левое, 1 — правое, null — нет; shoulderCd — пауза после спрыгивания. */\n onShoulder: 0 | 1 | null = null;\n shoulderCd = 0;\n /** Овца: цвет шерсти (id красителя), null — белая; woolShown — какой цвет уже нарисован. */\n woolColor: string | null = null;\n private woolShown: string | null = null;\n /** Броненосец: секунд до разворота (0 — не свёрнут); до следующего сброшенного щитка. */\n rolledT = 0;\n scuteT = 300 + Math.random() * 300;\n /** Волк: запас брони из щитков (0 — без брони). */\n wolfArmor = 0;\n /** Дух-помощник: что велено собирать (образец) и сколько уже собрано. */\n carry: { item: string; count: number } | null = null;\n bag = 0;\n /** Вьюк (осёл, мул, лама): слоты сундука; пусто — не навьючен. */\n items: (Stack | null)[] = [];\n /** На поводке у игрока; leashTo — куда тянется верёвка (рука игрока), задаёт мозг. */\n leashed = false;\n leashTo: THREE.Vector3 | null = null;\n /** Поводок привязан к забору в этой клетке (null — держит игрок). */\n leashPost: [number, number, number] | null = null;\n /** Караван лам: за кем идёт (id моба), не сохраняется. */\n followId: number | null = null;\n /** Поводок держит другой моб (ламы странствующего торговца), не сохраняется. */\n leashMobId: number | null = null;\n /** Секунд до ухода (MobDef.leavesAfter); Infinity — не уходит. */\n leaveT = Infinity;\n /** С ним торгуют (открыт экран торговли) — стоит и смотрит на игрока. */\n trading = false;\n /** Невидим (странствующий торговец ночью — зелье невидимости). */\n invisible = false;\n private leashLine: THREE.Line | null = null;\n private readonly packParts: THREE.Object3D[] = [];\n private readonly armorParts: THREE.Object3D[] = [];\n private readonly outParts: THREE.Object3D[] = [];\n aggro = false;\n justHurt = false;\n /** Недавно бил игрок — за смерть дадут опыт. */\n hitByPlayerT = 0;\n /** Шипун взорвался сам — дропа нет, как в Minecraft. */\n exploded = false;\n sheared = false;\n /** Житель: сколько раз совершена каждая сделка (по индексу в списке профессии) до пополнения. */\n uses: number[] = [];\n /** Житель: занятый рабочий блок (systems/Jobs), null — безработный. */\n jobSite: [number, number, number] | null = null;\n /** Житель уже торговал — профессию не теряет, даже если рабочий блок сломали. */\n traded = false;\n /** Житель: опыт профессии (systems/Trading, levelOf) — открывает следующие сделки. */\n villagerXp = 0;\n /** Зомби-житель: секунд до выздоровления (0 — не лечится) и кем он был (systems/Zombify). */\n curing = 0;\n curedType: string | null = null;\n /** Секунд до следующего пополнения товара за работой у своего блока (не сохраняется). */\n workT = 0;\n /** Ручной (волк, кошка, лошадь), сидит по команде, осёдлан (лошадь). */\n tamed = false;\n sitting = false;\n saddled = false;\n /** Ручной волк: кого атакует (id моба). */\n targetId: number | null = null;\n /** На лошади сидит игрок (Player.riding): мозг молчит, ведёт седок. */\n rider = false;\n private readonly riderWish = { x: 0, z: 0, t: 0 };\n /** Где нарисован (интерполяция) — за это держится седок. */\n readonly renderPos = new THREE.Vector3();\n private readonly tameParts: THREE.Object3D[] = [];\n private readonly saddleParts: THREE.Object3D[] = [];\n /** Замах руки (атака, выстрел). */\n swing = 0;\n animTime = 0;\n flash = 0;\n private lavaT = 0;\n group: THREE.Group;\n mesh: THREE.Object3D;\n private readonly anims = new Map<string, AnimPart[]>();\n private readonly mats: {\n m: THREE.MeshLambertMaterial | THREE.MeshBasicMaterial;\n base: THREE.Color;\n glow: boolean;\n }[] = [];\n private readonly wool: THREE.Object3D[] = [];\n\n constructor(\n id: number,\n def: MobDef,\n x: number,\n y: number,\n z: number,\n baby = false,\n ) {\n this.id = id;\n this.def = def;\n this.hp = def.hp;\n this.baby = baby;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3();\n this.yaw = Math.random() * Math.PI * 2;\n this.targetYaw = this.yaw;\n this.stateTime = 1 + Math.random() * 2;\n\n // риг: каждая часть — Group в точке pivot, бокс смещён относительно pivot\n this.group = new THREE.Group();\n for (const p of def.parts) {\n const color = new THREE.Color(p.color);\n const mat = p.glow\n ? new THREE.MeshBasicMaterial({ color })\n : new THREE.MeshLambertMaterial({ color });\n this.mats.push({ m: mat, base: color.clone(), glow: !!p.glow });\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(p.size[0] * PX, p.size[1] * PX, p.size[2] * PX),\n mat,\n );\n mesh.castShadow = mesh.receiveShadow = true;\n const pivot = new THREE.Group();\n const pv = p.pivot ?? p.pos;\n pivot.position.set(pv[0] * PX, pv[1] * PX, pv[2] * PX);\n mesh.position.set(\n (p.pos[0] - pv[0]) * PX,\n (p.pos[1] - pv[1]) * PX,\n (p.pos[2] - pv[2]) * PX,\n );\n if (p.pose) pivot.rotation.set(p.pose[0], p.pose[1], p.pose[2]);\n pivot.add(mesh);\n this.group.add(pivot);\n if (p.anim) {\n const list = this.anims.get(p.anim) ?? [];\n list.push({ g: pivot, base: pivot.rotation.clone() });\n this.anims.set(p.anim, list);\n }\n if (p.name.startsWith(\"wool\")) this.wool.push(pivot);\n if (p.name.startsWith(\"tame\")) this.tameParts.push(pivot);\n if (p.name.startsWith(\"saddle\")) this.saddleParts.push(pivot);\n if (p.name.startsWith(\"armor\")) this.armorParts.push(pivot);\n if (p.name.startsWith(\"out\")) this.outParts.push(pivot);\n if (p.name.startsWith(\"pack\")) this.packParts.push(pivot);\n if (p.name.startsWith(\"banner\")) this.bannerParts.push(pivot);\n }\n if (def.lifespan)\n this.lifeT =\n def.lifespan[0] + Math.random() * (def.lifespan[1] - def.lifespan[0]);\n if (def.leavesAfter)\n this.leaveT =\n def.leavesAfter[0] +\n Math.random() * (def.leavesAfter[1] - def.leavesAfter[0]);\n // овцы в Minecraft изредка бывают чёрные, серые, светло-серые, коричневые и совсем редко розовые\n if (def.dyeable) {\n const r = Math.random();\n this.woolColor =\n r < 0.05\n ? \"black\"\n : r < 0.1\n ? \"gray\"\n : r < 0.15\n ? \"light_gray\"\n : r < 0.18\n ? \"brown\"\n : r < 0.1816\n ? \"pink\"\n : null;\n }\n this.mesh = this.group; // единообразие с ItemDrop\n }\n\n /** Что даёт стрижка: шерсть своего цвета (овца), иначе shearDrop. */\n woolItem(): string {\n return this.woolColor\n ? `${this.woolColor}_wool`\n : (this.def.shearDrop?.item ?? \"white_wool\");\n }\n\n /** Высота седока над мобом (лошадь). */\n get seatY(): number {\n return this.def.rideable?.seat ?? 1;\n }\n\n /** Седок жмёт «вперёд»: куда вести лошадь (держится 0.15 с, седок повторяет каждый кадр). */\n push(dx: number, dz: number, _dt: number): void {\n this.riderWish.x = dx;\n this.riderWish.z = dz;\n this.riderWish.t = 0.15;\n }\n\n get width(): number {\n return this.def.box.w * (this.baby ? 0.5 : 1);\n }\n get height(): number {\n return this.def.box.h * (this.baby ? 0.5 : 1);\n }\n\n tick(dt: number, c: MobCtx) {\n this.prevPos.copy(this.pos);\n const dec = (v: number) => Math.max(0, v - dt);\n this.flash = dec(this.flash);\n this.attackCd = dec(this.attackCd);\n this.shootCd = dec(this.shootCd);\n this.loveT = dec(this.loveT);\n this.breedCd = dec(this.breedCd);\n this.hitByPlayerT = dec(this.hitByPlayerT);\n this.swing = dec(this.swing);\n // попугай на плече: едет с игроком (мозг и физика молчат); игрок присел или умер — слетает\n if (this.onShoulder !== null) {\n const p = c.player;\n if (p.dead || p._sneaking || p.shoulders[this.onShoulder] !== this.id) {\n if (p.shoulders[this.onShoulder] === this.id)\n p.shoulders[this.onShoulder] = null;\n this.onShoulder = null;\n this.shoulderCd = 3; // не запрыгивать сразу обратно\n } else {\n const side = this.onShoulder === 1 ? 1 : -1;\n this.pos.set(\n p.pos.x + Math.cos(p.yaw) * 0.4 * side,\n p.pos.y + 1.45,\n p.pos.z - Math.sin(p.yaw) * 0.4 * side,\n );\n this.vel.set(0, 0, 0);\n this.yaw = this.targetYaw = p.yaw;\n this.speed = 0;\n this.animTime += dt;\n return;\n }\n }\n this._tickEffects(dt);\n this.glowT = Math.max(0, this.glowT - dt);\n this.shoulderCd = dec(this.shoulderCd);\n this.rolledT = dec(this.rolledT);\n // странствующий торговец и его ламы: срок вышел — уходят (исчезают без дропа)\n if (this.leaveT !== Infinity) {\n this.leaveT -= dt;\n if (this.leaveT <= 0 && !this.dead) {\n this.dead = true; // hp > 0 — без дропа\n events.emit(\"mobLeft\", { entity: this });\n }\n }\n // броненосец сам сбрасывает щиток раз в 5–10 минут\n if (this.def.rollsUp && !this.baby) {\n this.scuteT -= dt;\n if (this.scuteT <= 0) {\n this.scuteT = 300 + Math.random() * 300;\n events.emit(\"mobDrop\", {\n x: this.pos.x,\n y: this.pos.y + 0.3,\n z: this.pos.z,\n item: \"armadillo_scute\",\n });\n }\n }\n // бесёнок: срок вышел — теряет по 1 здоровью в секунду\n if (this.def.lifespan) {\n this.lifeT -= dt;\n if (\n this.lifeT <= 0 &&\n Math.floor(this.lifeT) !== Math.floor(this.lifeT + dt)\n )\n this.damage(1);\n }\n if (this.baby) {\n this.growT += dt;\n if (this.growT >= GROW_TIME) {\n this.baby = false;\n if (this.def.growDrop) events.emit(\"mobGrew\", { entity: this }); // черепашонок — щиток\n }\n }\n // головастик: вырастает в другой вид (лягушку по биому — EntityManager, событие mobMature)\n // житель Пекла вне Пекла превращается (свинорыл → мёртвый свинорыл, хоглин → зоглин)\n if (\n this.def.growsInto &&\n !this.dead &&\n !(this.def.zombifies && c.world.dimension === \"inferno\")\n ) {\n this.growT += dt;\n if (this.growT >= (this.def.growTime ?? GROW_TIME)) {\n this.growT = -Infinity;\n events.emit(\"mobMature\", { entity: this });\n }\n }\n // Кристалл Предела: ни мозга, ни физики — висит над колонной\n if (this.def.stationary) {\n this.animTime += dt;\n return;\n }\n // панцирник сидит на блоке: мозг решает, открыться ли и стрелять; ни шагов, ни гравитации\n if (this.def.anchored) {\n think(this, c, dt);\n this.animTime += dt;\n return;\n }\n\n const world = c.world;\n const feet = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n this.inWater = feet === B.water || !!BLOCKS[feet]?.waterlogged;\n this.inLava = feet === B.lava;\n // рыба на суше: 5 с — и по 1 урону в секунду; бьётся, подпрыгивая\n if (this.def.needsWater) {\n if (this.inWater) this.dryT = 0;\n else {\n this.dryT += dt;\n // рыба задыхается через 5 с и бьётся на месте; аксолотль ходит по суше dryTime секунд\n const limit = this.def.dryTime ?? 5;\n if (\n this.dryT > limit &&\n Math.floor(this.dryT) !== Math.floor(this.dryT - dt)\n )\n this.damage(1);\n if (!this.def.dryTime && this.onGround && Math.random() < dt * 3)\n this.vel.y = 3;\n }\n }\n\n if (this.rider) {\n // под седоком: направление и галоп — от игрока; ступеньки перепрыгивает сам (hitWall ниже)\n const w = this.riderWish;\n w.t -= dt;\n if (w.t > 0 && (w.x || w.z)) {\n this.targetYaw = this.yaw = Math.atan2(-w.x, -w.z);\n this.speed = this.def.rideable?.speed ?? this.def.walkSpeed;\n } else this.speed = 0;\n } else think(this, c, dt);\n\n // плавный поворот к целевому направлению\n let dy = this.targetYaw - this.yaw;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n this.yaw += dy * Math.min(1, 6 * dt);\n\n const moveYaw = this.moveYaw ?? this.yaw;\n const slow = this.effects.has(\"slowness\") ? 0.5 : 1;\n const wishX = -Math.sin(moveYaw) * this.speed * slow;\n const wishZ = -Math.cos(moveYaw) * this.speed * slow;\n const accel = Math.min(1, 10 * dt);\n this.vel.x += (wishX - this.vel.x) * accel;\n this.vel.z += (wishZ - this.vel.z) * accel;\n if (this.def.hops) {\n // прыжками: на земле стоит и выжидает, в воздухе летит туда, куда смотрит\n if (this.onGround) {\n this.vel.x = 0;\n this.vel.z = 0;\n this.hopT -= dt;\n if (this.speed !== 0 && this.hopT <= 0) {\n this.vel.y = 7;\n this.hopT = 0.5 + Math.random() * 1.5;\n }\n } else {\n this.vel.x = wishX * 1.5;\n this.vel.z = wishZ * 1.5;\n }\n }\n // летун и пловец в воде: без гравитации, высоту задаёт мозг (climbWish)\n if (this.def.flies || (this.def.swims && this.inWater))\n this.vel.y += (this.climbWish - this.vel.y) * accel;\n else {\n this.vel.y -= 22 * dt;\n if (this.inWater || this.inLava) this.vel.y = Math.max(this.vel.y, 1.5); // всплывает\n }\n\n if (this.def.noClip)\n this.pos.addScaledVector(this.vel, dt); // дракон: сквозь блоки\n else this._move(dt, world);\n\n // зомби осады ломает дверь, в которую упёрся (как в Minecraft на высокой сложности)\n // ⚠ у обычного враждебного моба aggro остаётся false: злость — это поле для НЕЙТРАЛЬНЫХ\n // (свинорыл, голем), а зомби враждебен по категории. Дверь ломает и тот, и другой.\n if (\n this.def.breaksDoors &&\n rules().doorBreaking && // только на высокой сложности (systems/Difficulty)\n (this.aggro || this.def.category === \"hostile\") &&\n this.speed !== 0\n )\n this._breakDoor(dt, world);\n else this.doorT = Math.max(0, this.doorT - dt); // короткий отскок не сбрасывает долбёжку\n\n // упёрся в стену на ходу — подпрыгнуть (на 1 блок); паук лезет по стене\n if (this.speed !== 0 && this.hitWall && !this.def.hops) {\n if (this.def.climbs) this.vel.y = 3;\n else if (this.onGround) this.vel.y = 7;\n }\n\n // лава и огонь жгут: 4 и 1 урона каждые полсекунды\n if (\n (this.inLava ||\n feet === B.fire ||\n feet === B.soul_fire ||\n feet === B.campfire ||\n feet === B.soul_campfire) &&\n !this.def.fireImmune\n ) {\n this.lavaT -= dt;\n if (this.lavaT <= 0) {\n this.lavaT = 0.5;\n this.damage(this.inLava ? 4 : 1);\n }\n }\n\n this.animTime +=\n dt * (this.speed !== 0 ? Math.hypot(this.vel.x, this.vel.z) * 3 : 0);\n }\n\n /** Шаг с коллизиями по коробкам форм — тот же код, что у игрока (physics/Collision). */\n _move(dt: number, world: ChunkManager) {\n const r = moveBody(\n world,\n {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n hw: this.width / 2,\n h: this.height,\n },\n this.vel.x * dt,\n this.vel.y * dt,\n this.vel.z * dt,\n // страйдер стоит на лаве, как на полу\n {\n step: this.onGround ? 0.6 : 0,\n onGround: this.onGround,\n ...(this.def.walksOnLava ? { extraSolid: isLava } : {}),\n },\n );\n this.pos.x += r.dx;\n this.pos.y += r.dy;\n this.pos.z += r.dz;\n if (r.hitDown) {\n this.onGround = true;\n this.vel.y = 0;\n } else {\n this.onGround = false;\n if (r.hitUp) this.vel.y = 0;\n }\n if (r.hitX) this.vel.x = 0;\n if (r.hitZ) this.vel.z = 0;\n this.hitWall = r.hitX || r.hitZ;\n }\n\n /** Стрелы отскакивают: Пепельный владыка на половине здоровья, закрытый панцирник. */\n shielded(): boolean {\n return (\n (this.def.boss === \"ash_lord\" && this.hp <= this.def.hp / 2) ||\n (!!this.def.shell && this.peek < 0.3)\n );\n }\n\n /** Урон без отброса (солнце, лава, падение). */\n damage(amount: number) {\n if (this.dead || this.invulnerable) return;\n this.hp -= amount;\n this.flash = 0.4;\n events.emit(\"mobHurt\", { entity: this, amount });\n if (this.hp <= 0) {\n if (this.def.boss === \"dragon\")\n this.hp = 0; // дракон умирает сам — после анимации (DragonBrain)\n else this.dead = true;\n }\n }\n\n /** Удар: урон, отброс от атакующего, вспышка; мирный убегает, остальные злятся. */\n /** Дать имя с бирки: табличка над головой. Пустое имя — снять. */\n setName(name: string | null): void {\n const clean = (name ?? \"\").trim().slice(0, 24);\n this.customName = clean || null;\n if (this.nameSprite) {\n this.group.remove(this.nameSprite);\n this.nameSprite.material.map?.dispose();\n this.nameSprite.material.dispose();\n this.nameSprite = null;\n }\n if (!this.customName) return;\n const c = document.createElement(\"canvas\");\n c.width = 256;\n c.height = 64;\n const g = c.getContext(\"2d\")!;\n g.font = \"bold 34px monospace\";\n g.textAlign = \"center\";\n g.textBaseline = \"middle\";\n g.fillStyle = \"rgba(0,0,0,0.45)\";\n g.fillRect(0, 12, 256, 40);\n g.fillStyle = \"#ffffff\";\n g.fillText(this.customName, 128, 32, 248);\n const tex = new THREE.CanvasTexture(c);\n tex.colorSpace = THREE.SRGBColorSpace;\n const s = new THREE.Sprite(\n new THREE.SpriteMaterial({\n map: tex,\n depthTest: false,\n transparent: true,\n }),\n );\n s.scale.set(1.6, 0.4, 1);\n s.position.set(0, this.height + 0.45, 0);\n this.group.add(s);\n this.nameSprite = s;\n }\n\n /** Повесить эффект (дольше уже висящего — продлить). */\n applyEffect(id: EffectId, seconds: number): void {\n this.effects.set(id, Math.max(this.effects.get(id) ?? 0, seconds));\n }\n\n /** Сроки эффектов и урон от яда — зовётся из tick. */\n private _tickEffects(dt: number): void {\n if (!this.effects.size) return;\n for (const [id, t] of this.effects)\n if (t - dt <= 0) this.effects.delete(id);\n else this.effects.set(id, t - dt);\n if (!this.effects.has(\"poison\")) {\n this._poisonT = 0;\n return;\n }\n this._poisonT += dt;\n if (this._poisonT >= 2) {\n this._poisonT = 0;\n if (this.hp > 1) this.damage(1); // яд не добивает, как в Minecraft\n }\n }\n\n /** Секунд, что моб уже ломится в дверь перед собой. */\n private doorT = 0;\n\n /** Долбить дверь перед собой: через DOOR_TIME секунд она ломается совсем. */\n private _breakDoor(dt: number, world: ChunkManager): void {\n const ahead = new THREE.Vector3(\n -Math.sin(this.yaw),\n 0,\n -Math.cos(this.yaw),\n );\n const x = Math.floor(this.pos.x + ahead.x * 0.7),\n z = Math.floor(this.pos.z + ahead.z * 0.7),\n y = Math.floor(this.pos.y);\n const id = world.getBlock(x, y, z);\n if (BLOCKS[id]?.shape !== \"door\") {\n this.doorT = Math.max(0, this.doorT - dt);\n return;\n }\n this.doorT += dt;\n if (this.doorT < DOOR_TIME) return;\n this.doorT = 0;\n // дверь из двух половин: сносим обе, обломков не остаётся\n world.setBlock(x, y, z, B.air!);\n if (BLOCKS[world.getBlock(x, y + 1, z)]?.shape === \"door\")\n world.setBlock(x, y + 1, z, B.air!);\n if (BLOCKS[world.getBlock(x, y - 1, z)]?.shape === \"door\")\n world.setBlock(x, y - 1, z, B.air!);\n events.emit(\"doorBroken\", { x, y, z });\n }\n\n hurt(damage: number, fromPos: THREE.Vector3, byPlayer = true) {\n if (this.dead || this.invulnerable) return;\n this.justHurt = true;\n // броня волка берёт урон на себя, пока не износится\n if (this.wolfArmor > 0) {\n const a = Math.min(this.wolfArmor, damage);\n this.wolfArmor -= a;\n damage -= a;\n if (this.wolfArmor <= 0) events.emit(\"wolfArmorBroke\", { entity: this });\n }\n // броненосец: свёрнутый держит удар (−3), любой удар сворачивает на 6 с\n if (this.def.rollsUp) {\n if (this.rolledT > 0) damage = Math.max(0, damage - 3);\n this.rolledT = 6;\n }\n if (byPlayer) {\n this.hitByPlayerT = 5;\n events.emit(\"playerHitMob\", { entity: this }); // ручные волки бросаются на обидчика\n }\n const away = this.pos.clone().sub(fromPos).setY(0);\n if (away.lengthSq() < 1e-6)\n away.set(Math.random() - 0.5, 0, Math.random() - 0.5);\n away.normalize();\n if (!this.def.boss) this.vel.add(away.clone().multiplyScalar(6)).setY(5); // дракона не отбросить\n if (this.def.category === \"passive\") {\n this.state = \"flee\";\n // Ход моба — (-sin yaw, -cos yaw), и этот yaw даёт ход вдоль `away`, т.е. прочь от атакующего.\n this.targetYaw = Math.atan2(-away.x, -away.z);\n this.stateTime = 3;\n } else if (!this.tamed) this.aggro = true; // ручной на хозяина не злится\n if (byPlayer && this.def.groupAnger) this.angerCall = true;\n this.damage(damage);\n }\n\n updateVisual(alpha: number, time: number, bright = 1) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.group.position.copy(p);\n this.renderPos.copy(p);\n this._drawBeam(p);\n this._drawLeash();\n this.group.visible = !this.invisible;\n this.group.rotation.y = this.yaw;\n // Шипун раздувается перед взрывом\n // иглобрюх раздувается шаром\n const swell = this.def.explode\n ? 1 + (this.fuse / this.def.explode.fuse) * 0.18\n : this.def.puffs\n ? 1 + this.peek * 0.9\n : 1;\n this.group.scale.setScalar((this.baby ? 0.5 : 1) * swell);\n\n // анимации: шаг ногами, руки в такт (или замах), покачивание головы, крылья при падении\n const walk = Math.sin(this.animTime * 6) * 0.7;\n const swingArm =\n this.swing > 0 ? Math.sin((this.swing / 0.3) * Math.PI) * 1.2 : 0;\n const set = (name: string, fn: (a: AnimPart) => void) =>\n this.anims.get(name)?.forEach(fn);\n set(\"legL\", (a) => (a.g.rotation.x = a.base.x + walk));\n set(\"legR\", (a) => (a.g.rotation.x = a.base.x - walk));\n set(\"armL\", (a) => (a.g.rotation.x = a.base.x - walk * 0.5 - swingArm));\n set(\"armR\", (a) => (a.g.rotation.x = a.base.x + walk * 0.5 - swingArm));\n set(\n \"head\",\n (a) => (a.g.rotation.x = a.base.x + Math.sin(time * 1.7) * 0.08),\n );\n set(\"lid\", (a) => (a.g.rotation.x = a.base.x + this.peek * 0.9));\n const falling = !this.onGround && this.vel.y < -1;\n // курица хлопает, только падая; летуны машут всегда (дракон — медленно и широко)\n const wing = this.def.boss\n ? Math.sin(time * 3) * 0.6\n : falling || this.def.flies\n ? Math.sin(time * 30) * 0.9\n : 0.05;\n set(\"wingL\", (a) => (a.g.rotation.z = wing));\n set(\"wingR\", (a) => (a.g.rotation.z = -wing));\n for (const w of this.wool) w.visible = !this.sheared;\n // окрашенная овца: шерсти — свой материал цвета красителя (общие материалы не трогаем)\n if (this.woolShown !== this.woolColor) {\n this.woolShown = this.woolColor;\n const hex = DYE_COLORS.find(\n (d) => d.id === (this.woolColor ?? \"white\"),\n )?.hex;\n if (hex)\n for (const w of this.wool)\n w.traverse((o) => {\n if (!(o instanceof THREE.Mesh)) return;\n const mat = (o.material as THREE.MeshLambertMaterial).clone();\n mat.color.set(hex);\n o.material = mat;\n });\n }\n for (const t of this.tameParts) t.visible = this.tamed;\n for (const s of this.saddleParts) s.visible = this.saddled;\n for (const a of this.armorParts) a.visible = this.wolfArmor > 0;\n for (const o of this.outParts) o.visible = this.rolledT <= 0; // свёрнутый броненосец — один панцирь\n for (const k of this.packParts) k.visible = this.items.length > 0;\n for (const b of this.bannerParts) b.visible = this.captain;\n\n // свет места (пещера темнее), вспышка урона, горение на солнце, мигание фитиля\n const fuseBlink =\n this.fuse > 0 && Math.sin(time * (8 + this.fuse * 12)) > 0;\n // владыка: набирая силу, мерцает синим; в броне — синеватый отлив\n const emissive =\n this.flash > 0\n ? 0xff3333\n : this.burning && Math.sin(time * 20) > 0\n ? 0xff7a1a\n : fuseBlink\n ? 0xffffff\n : this.invulnerable\n ? Math.sin(time * 10) > 0\n ? 0x3050ff\n : 0x000000\n : this.shielded()\n ? 0x1a2a60\n : 0x000000;\n for (const { m, base, glow } of this.mats) {\n if (glow) continue;\n m.color.copy(base).multiplyScalar(this.glowT > 0 ? 2.4 : bright);\n const lm = m as THREE.MeshLambertMaterial;\n if (lm.emissive.getHex() !== emissive) lm.emissive.setHex(emissive);\n }\n }\n\n /** Луч глубинника: линия от глаза к цели, от лилового к жёлтому по мере зарядки. */\n /** Верёвка поводка — дочерняя линия группы (уходит вместе с мобом): от шеи до руки игрока. */\n private _drawLeash(): void {\n const to = this.leashTo; // к держателю поводка или к ламе впереди в караване (задаёт мозг)\n if (!to) {\n if (this.leashLine) this.leashLine.visible = false;\n return;\n }\n if (!this.leashLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.leashLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x8a6a3a }),\n );\n this.group.add(this.leashLine);\n }\n this.leashLine.visible = true;\n this.group.updateMatrixWorld();\n const s = this.group.scale.x || 1;\n const local = this.group.worldToLocal(to.clone());\n const a = this.leashLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, 0, (this.height * 0.8) / s, 0);\n a.setXYZ(1, local.x, local.y, local.z);\n a.needsUpdate = true;\n }\n\n private _drawBeam(p: THREE.Vector3): void {\n const to = this.beamTo;\n if (!to || !this.group.parent) {\n if (this.beamLine) this.beamLine.visible = false;\n return;\n }\n if (!this.beamLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.beamLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x9a60ff }),\n );\n this.group.parent.add(this.beamLine);\n }\n const a = this.beamLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, p.x, p.y + this.height / 2, p.z);\n a.setXYZ(1, to.x, to.y, to.z);\n a.needsUpdate = true;\n const k = Math.min(1, this.beamT / 3);\n (this.beamLine.material as THREE.LineBasicMaterial).color.setRGB(\n 0.6 + 0.4 * k,\n 0.4 + 0.5 * k,\n 1 - 0.8 * k,\n );\n this.beamLine.visible = true;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.group);\n if (this.beamLine) {\n scene.remove(this.beamLine);\n this.beamLine.geometry.dispose();\n (this.beamLine.material as THREE.Material).dispose();\n }\n // traverse даёт Object3D; узкий каст к Mesh для доступа к geometry (проверка isMesh — рантайм).\n this.group.traverse((o) => {\n if ((o as THREE.Mesh).isMesh) (o as THREE.Mesh).geometry.dispose();\n });\n for (const { m } of this.mats) m.dispose();\n }\n\n serialize() {\n return {\n kind: \"mob\",\n type: this.def.name,\n pos: this.pos.toArray(),\n hp: this.hp,\n baby: this.baby,\n sheared: this.sheared,\n ...(this.uses.length ? { uses: [...this.uses] } : {}),\n ...(this.jobSite ? { jobSite: [...this.jobSite] } : {}),\n ...(this.traded ? { traded: true } : {}),\n ...(this.villagerXp > 0 ? { villagerXp: this.villagerXp } : {}),\n ...(this.curedType ? { curedType: this.curedType } : {}),\n ...(this.curing > 0 ? { curing: Math.round(this.curing) } : {}),\n ...(this.tamed ? { tamed: true } : {}),\n ...(this.customName ? { customName: this.customName } : {}),\n ...(this.sitting ? { sitting: true } : {}),\n ...(this.saddled ? { saddled: true } : {}),\n ...(this.woolColor ? { woolColor: this.woolColor } : {}),\n ...(this.wolfArmor > 0 ? { wolfArmor: this.wolfArmor } : {}),\n ...(this.carry ? { carry: this.carry.item, bag: this.bag } : {}),\n ...(this.items.length\n ? { items: this.items.map((s) => (s ? { ...s } : null)) }\n : {}),\n ...(this.leashed ? { leashed: true } : {}),\n ...(this.leashPost ? { leashPost: [...this.leashPost] } : {}),\n ...(this.leaveT !== Infinity ? { leaveT: Math.round(this.leaveT) } : {}),\n ...(this.home ? { home: [...this.home] } : {}),\n ...(this.pollen ? { pollen: true } : {}),\n ...(this.captain ? { captain: true } : {}),\n };\n }\n}\n"
251
+ "content": "// Универсальный моб: риг собирается из parts-описания реестра (BoxGeometry), физика — по коробкам\n// блоков (общая с игроком), решения — entities/MobBrain.ts, анимации процедурные.\n// Добавление моба = запись в registry/mobs/*.ts, этот код не меняется.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { DYE_COLORS } from \"../registry/blocks/colors\";\nimport type { ItemStack as Stack } from \"../types\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { moveBody } from \"../physics/Collision\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { EffectId, MobDef } from \"../types\";\nimport { think, type MobCtx } from \"./MobBrain\";\n\nconst PX = 1 / 16; // части описаны в пикселях: 16 px = 1 блок\n/** Детёныш вырастает за 20 минут, как в Minecraft. */\nexport const GROW_TIME = 1200;\n/** Для страйдера лава — полный куб (physics/Collision, extraSolid). */\nconst isLava = (id: number): boolean => id === B.lava;\n\n/** follow — ручной идёт за хозяином (MobBrain.petThink). */\nexport type MobState =\n \"idle\" | \"wander\" | \"flee\" | \"chase\" | \"tempt\" | \"love\" | \"follow\";\n\ninterface AnimPart {\n g: THREE.Group;\n base: THREE.Euler;\n}\n\n/**\n * Сколько секунд зомби ломится в дверь, прежде чем она рухнет. Счётчик копится, пока моб\n * упирается в дверь, и МЕДЛЕННО стекает, когда он отскочил: иначе каждый отскок сбрасывал бы\n * долбёжку в ноль и дверь стояла бы вечно (проверено на живом зомби).\n */\nconst DOOR_TIME = 3;\n\nexport class Mob {\n id: number;\n kind = \"mob\" as const;\n def: MobDef;\n hp: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n targetYaw: number;\n onGround = false;\n dead = false;\n state: MobState = \"idle\";\n stateTime: number;\n /** Желаемая скорость этого тика (мозг); отрицательная — пятиться. */\n speed = 0;\n /**\n * Эффекты на мобе: id → сколько ещё секунд. Понимает два — яд (1 урона раз в 2 с, но не\n * добивает) и замедление (вдвое медленнее). Их вешают отравленные стрелы (systems/TippedArrow).\n */\n effects = new Map<EffectId, number>();\n /** Имя с бирки: висит над мобом и держит его в мире (не исчезает вдали от игрока). */\n customName: string | null = null;\n private nameSprite: THREE.Sprite | null = null;\n private _poisonT = 0;\n /** Желаемая вертикальная скорость летуна (мозг). */\n climbWish = 0;\n /** Куда идти, если не туда, куда смотрит (владыка летит по кругу, глядя на игрока); null — по yaw. */\n moveYaw: number | null = null;\n /** Пауза до следующего прыжка (слизень). */\n hopT = 0;\n /** Часы пике фантома: из каждых 6 с — 2 с вниз на цель. */\n swoopT = 0;\n /** Пчела: свой улей, есть ли пыльца, цветок-цель, таймеры поиска и сбора. */\n home: [number, number, number] | null = null;\n pollen = false;\n beeTarget: [number, number, number] | null = null;\n beeT = 0;\n beeWork = 0;\n /** Ударил игрок моба со стайной злостью — EntityManager разбудит стаю на тике. */\n angerCall = false;\n /** Дракон: состояние мозга босса (entities/DragonBrain). Не сохраняется — после загрузки кружит. */\n bossState: unknown = null;\n /** Кристалл, что сейчас лечит дракона (id): луч и урон дракону от взрыва этого кристалла. */\n healFrom: number | null = null;\n /** Не получает урона (Пепельный владыка набирает силу). */\n invulnerable = false;\n /** Панцирник: насколько открыта крышка (0 — закрыт, 1 — открыт); иглобрюх — насколько раздут. */\n peek = 0;\n /** Рыба: сколько секунд на суше. */\n dryT = 0;\n /** Страж (entities/WardenBrain): злость, место последней вибрации, заряд и остывание звукового удара. */\n anger = 0;\n suspect: [number, number, number] | null = null;\n boomT = 0;\n boomCd = 0;\n /** Страж: сколько ещё вылезает из земли; сколько простаивает без тревог; до следующей Тьмы. */\n emergeT = 0;\n idleT = 0;\n darkT = 0;\n /** Коза (entities/GoatBrain): до следующего тарана, пригибается, разгон, сколько рогов осталось. */\n ramCd = 0;\n ramT = 0;\n dashT = 0;\n horns = 2;\n /** Кричащая коза (2%, как в Minecraft): таранит в разы чаще и кричит. */\n screaming = Math.random() < 0.02;\n /** Лягушка (entities/FrogBrain): до следующего броска языком. */\n tongueCd = 0;\n /** Нюхач (entities/SnifferBrain): до следующего принюхивания; сколько ещё роет. */\n sniffCd = 0;\n digT = 0;\n /** Моб волны спавнера испытаний: координаты спавнера (Game засчитывает убитого; вдали не исчезает). */\n trialFrom: [number, number, number] | null = null;\n /** Капитан мародёров: знамя на спине, за его смерть — Дурное знамение. */\n captain = false;\n /** Участник набега: идёт к goal (центру деревни) и не исчезает вдали. */\n raider = false;\n /** Секунд подсветки от колокола (systems: EntityManager.markRaiders). */\n glowT = 0;\n goal: THREE.Vector3 | null = null;\n /** Ворожей: до следующего заклинания и призванные бесята. */\n castT = 2;\n minions: Mob[] = [];\n /** Бесёнок: сколько ещё жить (сек). */\n lifeT = Infinity;\n private readonly bannerParts: THREE.Object3D[] = [];\n /** Глубинник: сколько заряжается луч (сек), куда он смотрит (null — не заряжает), до следующей Усталости. */\n beamT = 0;\n beamTo: THREE.Vector3 | null = null;\n fatigueT = 3;\n private beamLine: THREE.Line | null = null;\n inWater = false;\n inLava = false;\n hitWall = false;\n baby: boolean;\n growT = 0;\n /** Секунд «любви» после кормления (ищет пару). */\n loveT = 0;\n breedCd = 0;\n fuse = 0;\n attackCd = 0;\n shootCd = 0;\n burnT = 0;\n burning = false;\n /** Снеговик: накопитель секунд в жаре или воде (урон 1 за секунду). */\n snowT = 0;\n /** Попугай на плече игрока: 0 — левое, 1 — правое, null — нет; shoulderCd — пауза после спрыгивания. */\n onShoulder: 0 | 1 | null = null;\n shoulderCd = 0;\n /** Овца: цвет шерсти (id красителя), null — белая; woolShown — какой цвет уже нарисован. */\n woolColor: string | null = null;\n private woolShown: string | null = null;\n /** Броненосец: секунд до разворота (0 — не свёрнут); до следующего сброшенного щитка. */\n rolledT = 0;\n scuteT = 300 + Math.random() * 300;\n /** Волк: запас брони из щитков (0 — без брони). */\n wolfArmor = 0;\n /** Дух-помощник: что велено собирать (образец) и сколько уже собрано. */\n carry: { item: string; count: number } | null = null;\n bag = 0;\n /** Вьюк (осёл, мул, лама): слоты сундука; пусто — не навьючен. */\n items: (Stack | null)[] = [];\n /** На поводке у игрока; leashTo — куда тянется верёвка (рука игрока), задаёт мозг. */\n leashed = false;\n leashTo: THREE.Vector3 | null = null;\n /** Поводок привязан к забору в этой клетке (null — держит игрок). */\n leashPost: [number, number, number] | null = null;\n /** Караван лам: за кем идёт (id моба), не сохраняется. */\n followId: number | null = null;\n /** Поводок держит другой моб (ламы странствующего торговца), не сохраняется. */\n leashMobId: number | null = null;\n /** Секунд до ухода (MobDef.leavesAfter); Infinity — не уходит. */\n leaveT = Infinity;\n /** С ним торгуют (открыт экран торговли) — стоит и смотрит на игрока. */\n trading = false;\n /** Невидим (странствующий торговец ночью — зелье невидимости). */\n invisible = false;\n private leashLine: THREE.Line | null = null;\n private readonly packParts: THREE.Object3D[] = [];\n private readonly armorParts: THREE.Object3D[] = [];\n private readonly outParts: THREE.Object3D[] = [];\n aggro = false;\n justHurt = false;\n /** Недавно бил игрок — за смерть дадут опыт. */\n hitByPlayerT = 0;\n /** Шипун взорвался сам — дропа нет, как в Minecraft. */\n exploded = false;\n sheared = false;\n /** Житель: сколько раз совершена каждая сделка (по индексу в списке профессии) до пополнения. */\n uses: number[] = [];\n /** Житель: занятый рабочий блок (systems/Jobs), null — безработный. */\n jobSite: [number, number, number] | null = null;\n /** Житель уже торговал — профессию не теряет, даже если рабочий блок сломали. */\n traded = false;\n /** Житель: опыт профессии (systems/Trading, levelOf) — открывает следующие сделки. */\n villagerXp = 0;\n /** Зомби-житель: секунд до выздоровления (0 — не лечится) и кем он был (systems/Zombify). */\n curing = 0;\n curedType: string | null = null;\n /** Секунд до следующего пополнения товара за работой у своего блока (не сохраняется). */\n workT = 0;\n /** Ручной (волк, кошка, лошадь), сидит по команде, осёдлан (лошадь). */\n tamed = false;\n sitting = false;\n saddled = false;\n /** Ручной волк: кого атакует (id моба). */\n targetId: number | null = null;\n /** На лошади сидит игрок (Player.riding): мозг молчит, ведёт седок. */\n rider = false;\n private readonly riderWish = { x: 0, z: 0, t: 0 };\n /** Где нарисован (интерполяция) — за это держится седок. */\n readonly renderPos = new THREE.Vector3();\n private readonly tameParts: THREE.Object3D[] = [];\n private readonly saddleParts: THREE.Object3D[] = [];\n /** Замах руки (атака, выстрел). */\n swing = 0;\n animTime = 0;\n flash = 0;\n private lavaT = 0;\n group: THREE.Group;\n mesh: THREE.Object3D;\n private readonly anims = new Map<string, AnimPart[]>();\n private readonly mats: {\n m: THREE.MeshLambertMaterial | THREE.MeshBasicMaterial;\n base: THREE.Color;\n glow: boolean;\n }[] = [];\n private readonly wool: THREE.Object3D[] = [];\n\n constructor(\n id: number,\n def: MobDef,\n x: number,\n y: number,\n z: number,\n baby = false,\n ) {\n this.id = id;\n this.def = def;\n this.hp = def.hp;\n this.baby = baby;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3();\n this.yaw = Math.random() * Math.PI * 2;\n this.targetYaw = this.yaw;\n this.stateTime = 1 + Math.random() * 2;\n\n // риг: каждая часть — Group в точке pivot, бокс смещён относительно pivot\n this.group = new THREE.Group();\n for (const p of def.parts) {\n const color = new THREE.Color(p.color);\n const mat = p.glow\n ? new THREE.MeshBasicMaterial({ color })\n : new THREE.MeshLambertMaterial({ color });\n this.mats.push({ m: mat, base: color.clone(), glow: !!p.glow });\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(p.size[0] * PX, p.size[1] * PX, p.size[2] * PX),\n mat,\n );\n mesh.castShadow = mesh.receiveShadow = true;\n const pivot = new THREE.Group();\n const pv = p.pivot ?? p.pos;\n pivot.position.set(pv[0] * PX, pv[1] * PX, pv[2] * PX);\n mesh.position.set(\n (p.pos[0] - pv[0]) * PX,\n (p.pos[1] - pv[1]) * PX,\n (p.pos[2] - pv[2]) * PX,\n );\n if (p.pose) pivot.rotation.set(p.pose[0], p.pose[1], p.pose[2]);\n pivot.add(mesh);\n this.group.add(pivot);\n if (p.anim) {\n const list = this.anims.get(p.anim) ?? [];\n list.push({ g: pivot, base: pivot.rotation.clone() });\n this.anims.set(p.anim, list);\n }\n if (p.name.startsWith(\"wool\")) this.wool.push(pivot);\n if (p.name.startsWith(\"tame\")) this.tameParts.push(pivot);\n if (p.name.startsWith(\"saddle\")) this.saddleParts.push(pivot);\n if (p.name.startsWith(\"armor\")) this.armorParts.push(pivot);\n if (p.name.startsWith(\"out\")) this.outParts.push(pivot);\n if (p.name.startsWith(\"pack\")) this.packParts.push(pivot);\n if (p.name.startsWith(\"banner\")) this.bannerParts.push(pivot);\n }\n if (def.lifespan)\n this.lifeT =\n def.lifespan[0] + Math.random() * (def.lifespan[1] - def.lifespan[0]);\n if (def.leavesAfter)\n this.leaveT =\n def.leavesAfter[0] +\n Math.random() * (def.leavesAfter[1] - def.leavesAfter[0]);\n // овцы в Minecraft изредка бывают чёрные, серые, светло-серые, коричневые и совсем редко розовые\n if (def.dyeable) {\n const r = Math.random();\n this.woolColor =\n r < 0.05\n ? \"black\"\n : r < 0.1\n ? \"gray\"\n : r < 0.15\n ? \"light_gray\"\n : r < 0.18\n ? \"brown\"\n : r < 0.1816\n ? \"pink\"\n : null;\n }\n this.mesh = this.group; // единообразие с ItemDrop\n }\n\n /** Что даёт стрижка: шерсть своего цвета (овца), иначе shearDrop. */\n woolItem(): string {\n return this.woolColor\n ? `${this.woolColor}_wool`\n : (this.def.shearDrop?.item ?? \"white_wool\");\n }\n\n /** Высота седока над мобом (лошадь). */\n get seatY(): number {\n return this.def.rideable?.seat ?? 1;\n }\n\n /** Седок жмёт «вперёд»: куда вести лошадь (держится 0.15 с, седок повторяет каждый кадр). */\n push(dx: number, dz: number, _dt: number): void {\n this.riderWish.x = dx;\n this.riderWish.z = dz;\n this.riderWish.t = 0.15;\n }\n\n get width(): number {\n return this.def.box.w * (this.baby ? 0.5 : 1);\n }\n get height(): number {\n return this.def.box.h * (this.baby ? 0.5 : 1);\n }\n\n tick(dt: number, c: MobCtx) {\n this.prevPos.copy(this.pos);\n const dec = (v: number) => Math.max(0, v - dt);\n this.flash = dec(this.flash);\n this.attackCd = dec(this.attackCd);\n this.shootCd = dec(this.shootCd);\n this.loveT = dec(this.loveT);\n this.breedCd = dec(this.breedCd);\n this.hitByPlayerT = dec(this.hitByPlayerT);\n this.swing = dec(this.swing);\n // попугай на плече: едет с игроком (мозг и физика молчат); игрок присел или умер — слетает\n if (this.onShoulder !== null) {\n const p = c.player;\n if (p.dead || p._sneaking || p.shoulders[this.onShoulder] !== this.id) {\n if (p.shoulders[this.onShoulder] === this.id)\n p.shoulders[this.onShoulder] = null;\n this.onShoulder = null;\n this.shoulderCd = 3; // не запрыгивать сразу обратно\n } else {\n const side = this.onShoulder === 1 ? 1 : -1;\n this.pos.set(\n p.pos.x + Math.cos(p.yaw) * 0.4 * side,\n p.pos.y + 1.45,\n p.pos.z - Math.sin(p.yaw) * 0.4 * side,\n );\n this.vel.set(0, 0, 0);\n this.yaw = this.targetYaw = p.yaw;\n this.speed = 0;\n this.animTime += dt;\n return;\n }\n }\n this._tickEffects(dt);\n this.glowT = Math.max(0, this.glowT - dt);\n this.shoulderCd = dec(this.shoulderCd);\n this.rolledT = dec(this.rolledT);\n // странствующий торговец и его ламы: срок вышел — уходят (исчезают без дропа)\n if (this.leaveT !== Infinity) {\n this.leaveT -= dt;\n if (this.leaveT <= 0 && !this.dead) {\n this.dead = true; // hp > 0 — без дропа\n events.emit(\"mobLeft\", { entity: this });\n }\n }\n // броненосец сам сбрасывает щиток раз в 5–10 минут\n if (this.def.rollsUp && !this.baby) {\n this.scuteT -= dt;\n if (this.scuteT <= 0) {\n this.scuteT = 300 + Math.random() * 300;\n events.emit(\"mobDrop\", {\n x: this.pos.x,\n y: this.pos.y + 0.3,\n z: this.pos.z,\n item: \"armadillo_scute\",\n });\n }\n }\n // бесёнок: срок вышел — теряет по 1 здоровью в секунду\n if (this.def.lifespan) {\n this.lifeT -= dt;\n if (\n this.lifeT <= 0 &&\n Math.floor(this.lifeT) !== Math.floor(this.lifeT + dt)\n )\n this.damage(1);\n }\n if (this.baby) {\n this.growT += dt;\n if (this.growT >= GROW_TIME) {\n this.baby = false;\n if (this.def.growDrop) events.emit(\"mobGrew\", { entity: this }); // черепашонок — щиток\n }\n }\n // головастик: вырастает в другой вид (лягушку по биому — EntityManager, событие mobMature)\n // житель Пекла вне Пекла превращается (свинорыл → мёртвый свинорыл, хоглин → зоглин)\n if (\n this.def.growsInto &&\n !this.dead &&\n !(this.def.zombifies && c.world.dimension === \"inferno\")\n ) {\n this.growT += dt;\n if (this.growT >= (this.def.growTime ?? GROW_TIME)) {\n this.growT = -Infinity;\n events.emit(\"mobMature\", { entity: this });\n }\n }\n // Кристалл Предела: ни мозга, ни физики — висит над колонной\n if (this.def.stationary) {\n this.animTime += dt;\n return;\n }\n // панцирник сидит на блоке: мозг решает, открыться ли и стрелять; ни шагов, ни гравитации\n if (this.def.anchored) {\n think(this, c, dt);\n this.animTime += dt;\n return;\n }\n\n const world = c.world;\n const feet = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n this.inWater = feet === B.water || !!BLOCKS[feet]?.waterlogged;\n this.inLava = feet === B.lava;\n // рыба на суше: 5 с — и по 1 урону в секунду; бьётся, подпрыгивая\n if (this.def.needsWater) {\n if (this.inWater) this.dryT = 0;\n else {\n this.dryT += dt;\n // рыба задыхается через 5 с и бьётся на месте; аксолотль ходит по суше dryTime секунд\n const limit = this.def.dryTime ?? 5;\n if (\n this.dryT > limit &&\n Math.floor(this.dryT) !== Math.floor(this.dryT - dt)\n )\n this.damage(1);\n if (!this.def.dryTime && this.onGround && Math.random() < dt * 3)\n this.vel.y = 3;\n }\n }\n\n if (this.rider) {\n // под седоком: направление и галоп — от игрока; ступеньки перепрыгивает сам (hitWall ниже)\n const w = this.riderWish;\n w.t -= dt;\n if (w.t > 0 && (w.x || w.z)) {\n this.targetYaw = this.yaw = Math.atan2(-w.x, -w.z);\n this.speed = this.def.rideable?.speed ?? this.def.walkSpeed;\n } else this.speed = 0;\n } else think(this, c, dt);\n\n // плавный поворот к целевому направлению\n let dy = this.targetYaw - this.yaw;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n this.yaw += dy * Math.min(1, 6 * dt);\n\n const moveYaw = this.moveYaw ?? this.yaw;\n const slow = this.effects.has(\"slowness\") ? 0.5 : 1;\n const wishX = -Math.sin(moveYaw) * this.speed * slow;\n const wishZ = -Math.cos(moveYaw) * this.speed * slow;\n const accel = Math.min(1, 10 * dt);\n this.vel.x += (wishX - this.vel.x) * accel;\n this.vel.z += (wishZ - this.vel.z) * accel;\n if (this.def.hops) {\n // прыжками: на земле стоит и выжидает, в воздухе летит туда, куда смотрит\n if (this.onGround) {\n this.vel.x = 0;\n this.vel.z = 0;\n this.hopT -= dt;\n if (this.speed !== 0 && this.hopT <= 0) {\n this.vel.y = 7;\n this.hopT = 0.5 + Math.random() * 1.5;\n }\n } else {\n this.vel.x = wishX * 1.5;\n this.vel.z = wishZ * 1.5;\n }\n }\n // летун и пловец в воде: без гравитации, высоту задаёт мозг (climbWish)\n if (this.def.flies || (this.def.swims && this.inWater))\n this.vel.y += (this.climbWish - this.vel.y) * accel;\n else {\n this.vel.y -= 22 * dt;\n if (this.inWater || this.inLava) this.vel.y = Math.max(this.vel.y, 1.5); // всплывает\n }\n\n if (this.def.noClip)\n this.pos.addScaledVector(this.vel, dt); // дракон: сквозь блоки\n else this._move(dt, world);\n // пустота под миром (край острова Предела): падал бы вечно — ручной волк или названная бирка\n // не исчезают по дальности и висели бы в списке сущностей навсегда. Пустота убивает, как в Minecraft\n if (this.pos.y < -64 && !this.def.boss && !this.dead) {\n this.hp = 0;\n this.dead = true;\n }\n\n // зомби осады ломает дверь, в которую упёрся (как в Minecraft на высокой сложности)\n // ⚠ у обычного враждебного моба aggro остаётся false: злость — это поле для НЕЙТРАЛЬНЫХ\n // (свинорыл, голем), а зомби враждебен по категории. Дверь ломает и тот, и другой.\n if (\n this.def.breaksDoors &&\n rules().doorBreaking && // только на высокой сложности (systems/Difficulty)\n (this.aggro || this.def.category === \"hostile\") &&\n this.speed !== 0\n )\n this._breakDoor(dt, world);\n else this.doorT = Math.max(0, this.doorT - dt); // короткий отскок не сбрасывает долбёжку\n\n // упёрся в стену на ходу — подпрыгнуть (на 1 блок); паук лезет по стене\n if (this.speed !== 0 && this.hitWall && !this.def.hops) {\n if (this.def.climbs) this.vel.y = 3;\n else if (this.onGround) this.vel.y = 7;\n }\n\n // лава и огонь жгут: 4 и 1 урона каждые полсекунды\n if (\n (this.inLava ||\n feet === B.fire ||\n feet === B.soul_fire ||\n feet === B.campfire ||\n feet === B.soul_campfire) &&\n !this.def.fireImmune\n ) {\n this.lavaT -= dt;\n if (this.lavaT <= 0) {\n this.lavaT = 0.5;\n this.damage(this.inLava ? 4 : 1);\n }\n }\n\n this.animTime +=\n dt * (this.speed !== 0 ? Math.hypot(this.vel.x, this.vel.z) * 3 : 0);\n }\n\n /** Шаг с коллизиями по коробкам форм — тот же код, что у игрока (physics/Collision). */\n _move(dt: number, world: ChunkManager) {\n const r = moveBody(\n world,\n {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n hw: this.width / 2,\n h: this.height,\n },\n this.vel.x * dt,\n this.vel.y * dt,\n this.vel.z * dt,\n // страйдер стоит на лаве, как на полу\n {\n step: this.onGround ? 0.6 : 0,\n onGround: this.onGround,\n ...(this.def.walksOnLava ? { extraSolid: isLava } : {}),\n },\n );\n this.pos.x += r.dx;\n this.pos.y += r.dy;\n this.pos.z += r.dz;\n if (r.hitDown) {\n this.onGround = true;\n this.vel.y = 0;\n } else {\n this.onGround = false;\n if (r.hitUp) this.vel.y = 0;\n }\n if (r.hitX) this.vel.x = 0;\n if (r.hitZ) this.vel.z = 0;\n this.hitWall = r.hitX || r.hitZ;\n }\n\n /** Стрелы отскакивают: Пепельный владыка на половине здоровья, закрытый панцирник. */\n shielded(): boolean {\n return (\n (this.def.boss === \"ash_lord\" && this.hp <= this.def.hp / 2) ||\n (!!this.def.shell && this.peek < 0.3)\n );\n }\n\n /** Урон без отброса (солнце, лава, падение). */\n damage(amount: number) {\n if (this.dead || this.invulnerable) return;\n this.hp -= amount;\n this.flash = 0.4;\n events.emit(\"mobHurt\", { entity: this, amount });\n if (this.hp <= 0) {\n if (this.def.boss === \"dragon\")\n this.hp = 0; // дракон умирает сам — после анимации (DragonBrain)\n else this.dead = true;\n }\n }\n\n /** Удар: урон, отброс от атакующего, вспышка; мирный убегает, остальные злятся. */\n /** Дать имя с бирки: табличка над головой. Пустое имя — снять. */\n setName(name: string | null): void {\n const clean = (name ?? \"\").trim().slice(0, 24);\n this.customName = clean || null;\n if (this.nameSprite) {\n this.group.remove(this.nameSprite);\n this.nameSprite.material.map?.dispose();\n this.nameSprite.material.dispose();\n this.nameSprite = null;\n }\n if (!this.customName) return;\n const c = document.createElement(\"canvas\");\n c.width = 256;\n c.height = 64;\n const g = c.getContext(\"2d\")!;\n g.font = \"bold 34px monospace\";\n g.textAlign = \"center\";\n g.textBaseline = \"middle\";\n g.fillStyle = \"rgba(0,0,0,0.45)\";\n g.fillRect(0, 12, 256, 40);\n g.fillStyle = \"#ffffff\";\n g.fillText(this.customName, 128, 32, 248);\n const tex = new THREE.CanvasTexture(c);\n tex.colorSpace = THREE.SRGBColorSpace;\n const s = new THREE.Sprite(\n new THREE.SpriteMaterial({\n map: tex,\n depthTest: false,\n transparent: true,\n }),\n );\n s.scale.set(1.6, 0.4, 1);\n s.position.set(0, this.height + 0.45, 0);\n this.group.add(s);\n this.nameSprite = s;\n }\n\n /** Повесить эффект (дольше уже висящего — продлить). */\n applyEffect(id: EffectId, seconds: number): void {\n this.effects.set(id, Math.max(this.effects.get(id) ?? 0, seconds));\n }\n\n /** Сроки эффектов и урон от яда — зовётся из tick. */\n private _tickEffects(dt: number): void {\n if (!this.effects.size) return;\n for (const [id, t] of this.effects)\n if (t - dt <= 0) this.effects.delete(id);\n else this.effects.set(id, t - dt);\n if (!this.effects.has(\"poison\")) {\n this._poisonT = 0;\n return;\n }\n this._poisonT += dt;\n if (this._poisonT >= 2) {\n this._poisonT = 0;\n if (this.hp > 1) this.damage(1); // яд не добивает, как в Minecraft\n }\n }\n\n /** Секунд, что моб уже ломится в дверь перед собой. */\n private doorT = 0;\n\n /** Долбить дверь перед собой: через DOOR_TIME секунд она ломается совсем. */\n private _breakDoor(dt: number, world: ChunkManager): void {\n const ahead = new THREE.Vector3(\n -Math.sin(this.yaw),\n 0,\n -Math.cos(this.yaw),\n );\n const x = Math.floor(this.pos.x + ahead.x * 0.7),\n z = Math.floor(this.pos.z + ahead.z * 0.7),\n y = Math.floor(this.pos.y);\n const id = world.getBlock(x, y, z);\n if (BLOCKS[id]?.shape !== \"door\") {\n this.doorT = Math.max(0, this.doorT - dt);\n return;\n }\n this.doorT += dt;\n if (this.doorT < DOOR_TIME) return;\n this.doorT = 0;\n // дверь из двух половин: сносим обе, обломков не остаётся\n world.setBlock(x, y, z, B.air!);\n if (BLOCKS[world.getBlock(x, y + 1, z)]?.shape === \"door\")\n world.setBlock(x, y + 1, z, B.air!);\n if (BLOCKS[world.getBlock(x, y - 1, z)]?.shape === \"door\")\n world.setBlock(x, y - 1, z, B.air!);\n events.emit(\"doorBroken\", { x, y, z });\n }\n\n hurt(damage: number, fromPos: THREE.Vector3, byPlayer = true) {\n if (this.dead || this.invulnerable) return;\n this.justHurt = true;\n // броня волка берёт урон на себя, пока не износится\n if (this.wolfArmor > 0) {\n const a = Math.min(this.wolfArmor, damage);\n this.wolfArmor -= a;\n damage -= a;\n if (this.wolfArmor <= 0) events.emit(\"wolfArmorBroke\", { entity: this });\n }\n // броненосец: свёрнутый держит удар (−3), любой удар сворачивает на 6 с\n if (this.def.rollsUp) {\n if (this.rolledT > 0) damage = Math.max(0, damage - 3);\n this.rolledT = 6;\n }\n if (byPlayer) {\n this.hitByPlayerT = 5;\n events.emit(\"playerHitMob\", { entity: this }); // ручные волки бросаются на обидчика\n }\n const away = this.pos.clone().sub(fromPos).setY(0);\n if (away.lengthSq() < 1e-6)\n away.set(Math.random() - 0.5, 0, Math.random() - 0.5);\n away.normalize();\n if (!this.def.boss) this.vel.add(away.clone().multiplyScalar(6)).setY(5); // дракона не отбросить\n if (this.def.category === \"passive\") {\n this.state = \"flee\";\n // Ход моба — (-sin yaw, -cos yaw), и этот yaw даёт ход вдоль `away`, т.е. прочь от атакующего.\n this.targetYaw = Math.atan2(-away.x, -away.z);\n this.stateTime = 3;\n } else if (!this.tamed) this.aggro = true; // ручной на хозяина не злится\n if (byPlayer && this.def.groupAnger) this.angerCall = true;\n this.damage(damage);\n }\n\n updateVisual(alpha: number, time: number, bright = 1) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.group.position.copy(p);\n this.renderPos.copy(p);\n this._drawBeam(p);\n this._drawLeash();\n this.group.visible = !this.invisible;\n this.group.rotation.y = this.yaw;\n // Шипун раздувается перед взрывом\n // иглобрюх раздувается шаром\n const swell = this.def.explode\n ? 1 + (this.fuse / this.def.explode.fuse) * 0.18\n : this.def.puffs\n ? 1 + this.peek * 0.9\n : 1;\n this.group.scale.setScalar((this.baby ? 0.5 : 1) * swell);\n\n // анимации: шаг ногами, руки в такт (или замах), покачивание головы, крылья при падении\n const walk = Math.sin(this.animTime * 6) * 0.7;\n const swingArm =\n this.swing > 0 ? Math.sin((this.swing / 0.3) * Math.PI) * 1.2 : 0;\n const set = (name: string, fn: (a: AnimPart) => void) =>\n this.anims.get(name)?.forEach(fn);\n set(\"legL\", (a) => (a.g.rotation.x = a.base.x + walk));\n set(\"legR\", (a) => (a.g.rotation.x = a.base.x - walk));\n set(\"armL\", (a) => (a.g.rotation.x = a.base.x - walk * 0.5 - swingArm));\n set(\"armR\", (a) => (a.g.rotation.x = a.base.x + walk * 0.5 - swingArm));\n set(\n \"head\",\n (a) => (a.g.rotation.x = a.base.x + Math.sin(time * 1.7) * 0.08),\n );\n set(\"lid\", (a) => (a.g.rotation.x = a.base.x + this.peek * 0.9));\n const falling = !this.onGround && this.vel.y < -1;\n // курица хлопает, только падая; летуны машут всегда (дракон — медленно и широко)\n const wing = this.def.boss\n ? Math.sin(time * 3) * 0.6\n : falling || this.def.flies\n ? Math.sin(time * 30) * 0.9\n : 0.05;\n set(\"wingL\", (a) => (a.g.rotation.z = wing));\n set(\"wingR\", (a) => (a.g.rotation.z = -wing));\n for (const w of this.wool) w.visible = !this.sheared;\n // окрашенная овца: шерсти — свой материал цвета красителя (общие материалы не трогаем)\n if (this.woolShown !== this.woolColor) {\n this.woolShown = this.woolColor;\n const hex = DYE_COLORS.find(\n (d) => d.id === (this.woolColor ?? \"white\"),\n )?.hex;\n if (hex)\n for (const w of this.wool)\n w.traverse((o) => {\n if (!(o instanceof THREE.Mesh)) return;\n const mat = (o.material as THREE.MeshLambertMaterial).clone();\n mat.color.set(hex);\n o.material = mat;\n });\n }\n for (const t of this.tameParts) t.visible = this.tamed;\n for (const s of this.saddleParts) s.visible = this.saddled;\n for (const a of this.armorParts) a.visible = this.wolfArmor > 0;\n for (const o of this.outParts) o.visible = this.rolledT <= 0; // свёрнутый броненосец — один панцирь\n for (const k of this.packParts) k.visible = this.items.length > 0;\n for (const b of this.bannerParts) b.visible = this.captain;\n\n // свет места (пещера темнее), вспышка урона, горение на солнце, мигание фитиля\n const fuseBlink =\n this.fuse > 0 && Math.sin(time * (8 + this.fuse * 12)) > 0;\n // владыка: набирая силу, мерцает синим; в броне — синеватый отлив\n const emissive =\n this.flash > 0\n ? 0xff3333\n : this.burning && Math.sin(time * 20) > 0\n ? 0xff7a1a\n : fuseBlink\n ? 0xffffff\n : this.invulnerable\n ? Math.sin(time * 10) > 0\n ? 0x3050ff\n : 0x000000\n : this.shielded()\n ? 0x1a2a60\n : 0x000000;\n for (const { m, base, glow } of this.mats) {\n if (glow) continue;\n m.color.copy(base).multiplyScalar(this.glowT > 0 ? 2.4 : bright);\n const lm = m as THREE.MeshLambertMaterial;\n if (lm.emissive.getHex() !== emissive) lm.emissive.setHex(emissive);\n }\n }\n\n /** Луч глубинника: линия от глаза к цели, от лилового к жёлтому по мере зарядки. */\n /** Верёвка поводка — дочерняя линия группы (уходит вместе с мобом): от шеи до руки игрока. */\n private _drawLeash(): void {\n const to = this.leashTo; // к держателю поводка или к ламе впереди в караване (задаёт мозг)\n if (!to) {\n if (this.leashLine) this.leashLine.visible = false;\n return;\n }\n if (!this.leashLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.leashLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x8a6a3a }),\n );\n this.group.add(this.leashLine);\n }\n this.leashLine.visible = true;\n this.group.updateMatrixWorld();\n const s = this.group.scale.x || 1;\n const local = this.group.worldToLocal(to.clone());\n const a = this.leashLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, 0, (this.height * 0.8) / s, 0);\n a.setXYZ(1, local.x, local.y, local.z);\n a.needsUpdate = true;\n }\n\n private _drawBeam(p: THREE.Vector3): void {\n const to = this.beamTo;\n if (!to || !this.group.parent) {\n if (this.beamLine) this.beamLine.visible = false;\n return;\n }\n if (!this.beamLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.beamLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x9a60ff }),\n );\n this.group.parent.add(this.beamLine);\n }\n const a = this.beamLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, p.x, p.y + this.height / 2, p.z);\n a.setXYZ(1, to.x, to.y, to.z);\n a.needsUpdate = true;\n const k = Math.min(1, this.beamT / 3);\n (this.beamLine.material as THREE.LineBasicMaterial).color.setRGB(\n 0.6 + 0.4 * k,\n 0.4 + 0.5 * k,\n 1 - 0.8 * k,\n );\n this.beamLine.visible = true;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.group);\n if (this.beamLine) {\n scene.remove(this.beamLine);\n this.beamLine.geometry.dispose();\n (this.beamLine.material as THREE.Material).dispose();\n }\n // traverse даёт Object3D; узкий каст к Mesh для доступа к geometry (проверка isMesh — рантайм).\n this.group.traverse((o) => {\n if ((o as THREE.Mesh).isMesh) (o as THREE.Mesh).geometry.dispose();\n });\n for (const { m } of this.mats) m.dispose();\n }\n\n serialize() {\n return {\n kind: \"mob\",\n type: this.def.name,\n pos: this.pos.toArray(),\n hp: this.hp,\n baby: this.baby,\n sheared: this.sheared,\n ...(this.uses.length ? { uses: [...this.uses] } : {}),\n ...(this.jobSite ? { jobSite: [...this.jobSite] } : {}),\n ...(this.traded ? { traded: true } : {}),\n ...(this.villagerXp > 0 ? { villagerXp: this.villagerXp } : {}),\n ...(this.curedType ? { curedType: this.curedType } : {}),\n ...(this.curing > 0 ? { curing: Math.round(this.curing) } : {}),\n ...(this.tamed ? { tamed: true } : {}),\n ...(this.customName ? { customName: this.customName } : {}),\n ...(this.sitting ? { sitting: true } : {}),\n ...(this.saddled ? { saddled: true } : {}),\n ...(this.woolColor ? { woolColor: this.woolColor } : {}),\n ...(this.wolfArmor > 0 ? { wolfArmor: this.wolfArmor } : {}),\n ...(this.carry ? { carry: this.carry.item, bag: this.bag } : {}),\n ...(this.items.length\n ? { items: this.items.map((s) => (s ? { ...s } : null)) }\n : {}),\n ...(this.leashed ? { leashed: true } : {}),\n ...(this.leashPost ? { leashPost: [...this.leashPost] } : {}),\n ...(this.leaveT !== Infinity ? { leaveT: Math.round(this.leaveT) } : {}),\n ...(this.home ? { home: [...this.home] } : {}),\n ...(this.pollen ? { pollen: true } : {}),\n ...(this.captain ? { captain: true } : {}),\n };\n }\n}\n"
252
252
  },
253
253
  {
254
254
  "path": "entities/MobBrain.ts",
@@ -636,7 +636,7 @@
636
636
  },
637
637
  {
638
638
  "path": "main.ts",
639
- "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 { FACING_DIRS } from \"./registry/meta\";\nimport { BLOCKS, B } from \"./registry/Blocks\";\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 } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { CreateWorldUI } from \"./ui/CreateWorldUI\";\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\";\n\n/** Автосейв текущего мира в «Мои миры», секунд игры. */\nconst AUTOSAVE_EVERY = 30;\n\nexport interface GameOptions {\n /** ИИ-генерация миров (client.ai). null/нет — только путь без ИИ. */\n ai?: WorldAI | null;\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 /** Команды консоли и чат (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 constructor(opts: GameOptions = {}) {\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.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.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 this.hud.showOverlay(false);\n this.loop.start();\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open().then(async (store) => {\n if (!store) return;\n if (this._destroyed) return store.close();\n this.store = store;\n const last = await store.lastPlayed().catch(() => undefined);\n if (!last || this._worldTouched || this.playing || this._destroyed)\n return;\n try {\n this.save.apply(last.save);\n this.worldId = last.id;\n this.worldName = last.name;\n this.hud.message(\"Продолжаем мир «\" + last.name + \"»\", 2200);\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n });\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). */\n async persist(): Promise<void> {\n if (!this.store) return;\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 /** Подгонка рендера под текущую раскладку канваса. Вызывается 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.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 this.dims.set(this.dimension, {\n chunks: this.world.serializeDiffs(),\n entities: this.entities.serialize(),\n blockEntities: this.blockEntities.serialize(),\n });\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 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.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: inv.hotbarIndex,\n });\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 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 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 (BLOCKS[this.world.getBlock(bed[0], bed[1], bed[2])]?.shape === \"bed\")\n at = new THREE.Vector3(bed[0] + 0.5, bed[1] + 0.6, bed[2] + 0.5);\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 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 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 /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\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 }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n this.portals[dim] = this.portals[dim].filter((q) => q !== p);\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 for (const [dx, dz] of FACING_DIRS) {\n const nx = x + dx,\n nz = z + dz;\n if (!this.world.isSolid(nx, y, nz) && !this.world.isSolid(nx, y + 1, nz))\n return new THREE.Vector3(nx + 0.5, y, 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 this.dayNight.resetToMorning();\n this.weather.set(\"clear\"); // сон пережидает и непогоду, как в Minecraft\n this._slept = true;\n this.daysAwake = 0;\n this.hud.message(\"Доброе утро!\", 1800);\n events.emit(\"slept\");\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 if (!this.playing) return;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n this.weather.tick(\n dt,\n sky ? 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 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 this._tickRaid(dt);\n this._spyglass(dt);\n this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n this.dayNight.tick(dt);\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n } else if (!day && this._wasDay) 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 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._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.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 : \"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 void this.persist().finally(() => this.store?.close());\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.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 this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
639
+ "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 } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { CreateWorldUI } from \"./ui/CreateWorldUI\";\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\";\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\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 /** Команды консоли и чат (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 constructor(opts: GameOptions = {}) {\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.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.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 this.hud.showOverlay(false);\n this.loop.start();\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open().then(async (store) => {\n if (!store) return;\n if (this._destroyed) return store.close();\n this.store = store;\n const last = await store.lastPlayed().catch(() => undefined);\n if (!last || this._worldTouched || this.playing || this._destroyed)\n return;\n try {\n this.save.apply(last.save);\n this.worldId = last.id;\n this.worldName = last.name;\n this.hud.message(\"Продолжаем мир «\" + last.name + \"»\", 2200);\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n });\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). */\n async persist(): Promise<void> {\n if (!this.store) return;\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 /** Подгонка рендера под текущую раскладку канваса. Вызывается 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 this.dims.set(this.dimension, {\n chunks: this.world.serializeDiffs(),\n entities: this.entities.serialize(),\n blockEntities: this.blockEntities.serialize(),\n });\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 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 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 /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\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 }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n this.portals[dim] = this.portals[dim].filter((q) => q !== p);\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 this.dayNight.resetToMorning();\n this.weather.set(\"clear\"); // сон пережидает и непогоду, как в Minecraft\n this._slept = true;\n this.daysAwake = 0;\n this.hud.message(\"Доброе утро!\", 1800);\n events.emit(\"slept\");\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 if (!this.playing) return;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n this.weather.tick(\n dt,\n sky ? 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 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 this._tickRaid(dt);\n this._spyglass(dt);\n this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n this.dayNight.tick(dt);\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n } else if (!day && this._wasDay) 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 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._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.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 void this.persist().finally(() => this.store?.close());\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.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 this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
640
640
  },
641
641
  {
642
642
  "path": "module.ts",
@@ -656,7 +656,7 @@
656
656
  },
657
657
  {
658
658
  "path": "player/ArmView.ts",
659
- "content": "// Рука от первого лица: блочная рука в стиле Minecraft, прикреплена к камере.\n// Если в руке блок — вместо руки показывается мини-куб блока; плоские предметы —\n// квад с иконкой. Замах — процедурная анимация по событию armSwing.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\n\n/**\n * Слой рендера руки. Рука рисуется ВТОРЫМ проходом поверх мира с очищенным depth (см. Game.frame):\n * в общем depth-буфере она висит в 0.7 м перед глазом и, стоя вплотную к блоку, целиком тонула\n * в нём — замах при добыче было просто не видно.\n */\nexport const ARM_LAYER = 1;\n\nexport class ArmView {\n camera: THREE.PerspectiveCamera;\n atlas: TextureAtlas;\n group: THREE.Group;\n armMesh: THREE.Mesh;\n itemMesh: THREE.Mesh | null;\n _heldKey: string | null;\n _swing: number;\n _matCache: Map<string, THREE.MeshBasicMaterial>;\n\n constructor(camera: THREE.PerspectiveCamera, atlas: TextureAtlas) {\n this.camera = camera;\n this.atlas = atlas;\n this.group = new THREE.Group();\n camera.add(this.group);\n this.group.position.set(0.42, -0.42, -0.7);\n\n // сама рука: «кожа» с рукавом\n const armMat = new THREE.MeshLambertMaterial({ color: 0xd8a06c });\n this.armMesh = new THREE.Mesh(\n new THREE.BoxGeometry(0.14, 0.14, 0.5),\n armMat,\n );\n this.armMesh.rotation.set(0.35, -0.25, 0);\n this.armMesh.layers.set(ARM_LAYER);\n this.group.add(this.armMesh);\n\n this.itemMesh = null; // мини-блок/иконка в руке\n this._heldKey = null;\n this._swing = 0; // 0..1 — фаза замаха\n // Материал плоского предмета — один на предмет: пересоздавать его на каждую смену слота\n // значило бы копить неосвобождённые материалы.\n this._matCache = new Map();\n\n events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const key = held ? held.item : null;\n // Меш зависит только от того, КАКОЙ предмет в руке: смена количества (подбор, постановка\n // блока) пересборки не требует.\n if (key === this._heldKey) return;\n this._heldKey = key;\n\n if (this.itemMesh) {\n this.group.remove(this.itemMesh);\n this.itemMesh.geometry.dispose();\n this.itemMesh = null;\n }\n this.armMesh.visible = !key;\n if (!key) return;\n\n const item = ITEMS[key]!;\n if (item.blockId !== null && BLOCKS[item.blockId]!.shape === \"cube\") {\n const def = BLOCKS[item.blockId]!;\n const geo = new THREE.BoxGeometry(0.3, 0.3, 0.3);\n const uvAttr = geo.getAttribute(\"uv\");\n const tiles = [\n def.tex!.side!,\n def.tex!.side!,\n def.tex!.top!,\n def.tex!.bottom!,\n def.tex!.side!,\n def.tex!.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = this.atlas.uv(tiles[f]!);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // белые вершинные цвета: общий материал чанков ждёт color-атрибут (vertexColors)\n geo.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(geo.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n this.itemMesh = new THREE.Mesh(geo, this.atlas.opaqueMat);\n this.itemMesh.position.set(0, -0.02, -0.1);\n this.itemMesh.rotation.set(0.1, Math.PI / 5, 0);\n } else {\n let mat = this._matCache.get(key);\n if (!mat) {\n const tex = new THREE.CanvasTexture(item.icon);\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshBasicMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n this._matCache.set(key, mat);\n }\n this.itemMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.4, 0.4), mat);\n this.itemMesh.position.set(0, 0, -0.1);\n this.itemMesh.rotation.set(-0.15, Math.PI / 6, 0.5);\n }\n this.itemMesh.layers.set(ARM_LAYER);\n this.group.add(this.itemMesh);\n }\n\n frame(\n dt: number,\n inventory: Inventory,\n walking: boolean,\n time: number,\n ): void {\n this._syncHeld(inventory);\n\n // замах: быстрый удар вперёд-вниз с возвратом\n if (this._swing > 0)\n this._swing = Math.max(0, this._swing - dt / CONFIG.player.swingTime);\n const s = this._swing;\n const swingAng = Math.sin(s * Math.PI) * 1.1;\n\n // лёгкое покачивание при ходьбе\n const bobX = walking ? Math.sin(time * 7) * 0.015 : 0;\n const bobY = walking ? Math.abs(Math.cos(time * 7)) * 0.02 : 0;\n\n this.group.position.set(\n 0.42 + bobX,\n -0.42 + bobY - swingAng * 0.25,\n -0.7 - swingAng * 0.1,\n );\n this.group.rotation.set(-swingAng * 0.9, swingAng * 0.35, 0);\n }\n}\n"
659
+ "content": "// Рука от первого лица: блочная рука в стиле Minecraft, прикреплена к камере.\n// Если в руке блок — вместо руки показывается мини-куб блока; плоские предметы —\n// квад с иконкой. Замах — процедурная анимация по событию armSwing.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\n\n/**\n * Слой рендера руки. Рука рисуется ВТОРЫМ проходом поверх мира с очищенным depth (см. Game.frame):\n * в общем depth-буфере она висит в 0.7 м перед глазом и, стоя вплотную к блоку, целиком тонула\n * в нём — замах при добыче было просто не видно.\n */\nexport const ARM_LAYER = 1;\n\nexport class ArmView {\n camera: THREE.PerspectiveCamera;\n atlas: TextureAtlas;\n group: THREE.Group;\n armMesh: THREE.Mesh;\n itemMesh: THREE.Mesh | null;\n _heldKey: string | null;\n _swing: number;\n _matCache: Map<string, THREE.MeshBasicMaterial>;\n\n constructor(camera: THREE.PerspectiveCamera, atlas: TextureAtlas) {\n this.camera = camera;\n this.atlas = atlas;\n this.group = new THREE.Group();\n camera.add(this.group);\n this.group.position.set(0.42, -0.42, -0.7);\n\n // сама рука: «кожа» с рукавом\n const armMat = new THREE.MeshLambertMaterial({ color: 0xd8a06c });\n this.armMesh = new THREE.Mesh(\n new THREE.BoxGeometry(0.14, 0.14, 0.5),\n armMat,\n );\n this.armMesh.rotation.set(0.35, -0.25, 0);\n this.armMesh.layers.set(ARM_LAYER);\n this.group.add(this.armMesh);\n\n this.itemMesh = null; // мини-блок/иконка в руке\n this._heldKey = null;\n this._swing = 0; // 0..1 — фаза замаха\n // Материал плоского предмета — один на предмет: пересоздавать его на каждую смену слота\n // значило бы копить неосвобождённые материалы.\n this._matCache = new Map();\n\n events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const key = held ? held.item : null;\n // Меш зависит только от того, КАКОЙ предмет в руке: смена количества (подбор, постановка\n // блока) пересборки не требует.\n if (key === this._heldKey) return;\n this._heldKey = key;\n\n if (this.itemMesh) {\n this.group.remove(this.itemMesh);\n this.itemMesh.geometry.dispose();\n this.itemMesh = null;\n }\n // неизвестный предмет (из сундука чужого сейва) — просто пустая рука, а не падение кадра\n const item = key ? ITEMS[key] : undefined;\n this.armMesh.visible = !item;\n if (!key || !item) return;\n\n if (item.blockId !== null && BLOCKS[item.blockId]!.shape === \"cube\") {\n const def = BLOCKS[item.blockId]!;\n const geo = new THREE.BoxGeometry(0.3, 0.3, 0.3);\n const uvAttr = geo.getAttribute(\"uv\");\n const tiles = [\n def.tex!.side!,\n def.tex!.side!,\n def.tex!.top!,\n def.tex!.bottom!,\n def.tex!.side!,\n def.tex!.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = this.atlas.uv(tiles[f]!);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // белые вершинные цвета: общий материал чанков ждёт color-атрибут (vertexColors)\n geo.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(geo.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n this.itemMesh = new THREE.Mesh(geo, this.atlas.opaqueMat);\n this.itemMesh.position.set(0, -0.02, -0.1);\n this.itemMesh.rotation.set(0.1, Math.PI / 5, 0);\n } else {\n let mat = this._matCache.get(key);\n if (!mat) {\n const tex = new THREE.CanvasTexture(item.icon);\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshBasicMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n this._matCache.set(key, mat);\n }\n this.itemMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.4, 0.4), mat);\n this.itemMesh.position.set(0, 0, -0.1);\n this.itemMesh.rotation.set(-0.15, Math.PI / 6, 0.5);\n }\n this.itemMesh.layers.set(ARM_LAYER);\n this.group.add(this.itemMesh);\n }\n\n frame(\n dt: number,\n inventory: Inventory,\n walking: boolean,\n time: number,\n ): void {\n this._syncHeld(inventory);\n\n // замах: быстрый удар вперёд-вниз с возвратом\n if (this._swing > 0)\n this._swing = Math.max(0, this._swing - dt / CONFIG.player.swingTime);\n const s = this._swing;\n const swingAng = Math.sin(s * Math.PI) * 1.1;\n\n // лёгкое покачивание при ходьбе\n const bobX = walking ? Math.sin(time * 7) * 0.015 : 0;\n const bobY = walking ? Math.abs(Math.cos(time * 7)) * 0.02 : 0;\n\n this.group.position.set(\n 0.42 + bobX,\n -0.42 + bobY - swingAng * 0.25,\n -0.7 - swingAng * 0.1,\n );\n this.group.rotation.set(-swingAng * 0.9, swingAng * 0.35, 0);\n }\n}\n"
660
660
  },
661
661
  {
662
662
  "path": "player/Glide.ts",
@@ -680,7 +680,11 @@
680
680
  },
681
681
  {
682
682
  "path": "player/Player.ts",
683
- "content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { clampRep } from \"../systems/Reputation\";\nimport type { DrinkDef, EffectId } from \"../types\";\n\nconst EFFECT_IDS: readonly EffectId[] = [\n \"strength\",\n \"regeneration\",\n \"night_vision\",\n \"speed\",\n \"fire_resistance\",\n \"haste\",\n \"resistance\",\n \"jump_boost\",\n \"decay\",\n \"levitation\",\n \"water_breathing\",\n \"mining_fatigue\",\n \"poison\",\n \"bad_omen\",\n \"hero\",\n \"darkness\",\n \"slowness\",\n \"hunger\",\n];\n\n/** Запас воздуха под водой, сек (300 тиков Minecraft). */\nexport const MAX_AIR = 15;\n/** Для коллизий в кожаных ботинках: снег-порох — полный куб. */\nconst isPowderSnow = (id: number): boolean => id === B.powder_snow;\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { moveBody } from \"../physics/Collision\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\nimport type { Rideable } from \"../entities/Rideable\";\nimport { GLIDE_TICK, glideTick } from \"./Glide\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\n\n/** Причины урона, которые гасит броня. */\nconst ARMORED_CAUSES: ReadonlySet<string> = new Set([\n \"attack\",\n \"projectile\",\n \"explosion\",\n \"lava\",\n \"fire\",\n]);\n\n/** Урон после брони — формула Minecraft (очки защиты и твёрдость). */\nexport function armorReduce(\n damage: number,\n points: number,\n toughness: number,\n): number {\n const eff = Math.min(\n 20,\n Math.max(points / 5, points - damage / (2 + toughness / 4)),\n );\n return damage * (1 - eff / 25);\n}\n\n/** Форма persist-блоба игрока (см. serialize/deserialize). */\ninterface PlayerSaveData {\n pos: number[];\n yaw: number;\n pitch: number;\n health: number;\n food?: number;\n saturation?: number;\n mode: PlayerMode;\n flying: boolean;\n /** v3: точка возрождения (кровать). Нет — там, где загрузились. */\n spawn?: number[];\n /** v3: кровать, у которой возрождаться (нет — у точки мира). */\n bedPos?: number[] | null;\n /** Якорь возрождения и путевой камень компаса (нет полей — их и не было). */\n anchorPos?: number[] | null;\n villageRep?: number;\n anchorDim?: string | null;\n lodestone?: { x: number; z: number; dim: string } | null;\n /** v3: действующие эффекты зелий. */\n effects?: { id: string; level: number; time: number }[];\n /** v3: зерно вариантов стола зачарований. */\n enchSeed?: number;\n /** v3: опыт — уровень, доля до следующего, всего набрано. */\n xpLevel?: number;\n xpProgress?: number;\n xpTotal?: number;\n}\n\nexport class Player {\n world: ChunkManager;\n pos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n pitch: number;\n onGround: boolean;\n inWater: boolean;\n headInWater: boolean;\n mode: PlayerMode;\n flying: boolean;\n health: number;\n food: number;\n saturation: number;\n exhaustion: number;\n dead: boolean;\n spawnPoint: THREE.Vector3;\n _fallDist: number;\n _stepDist: number;\n _regenTimer: number;\n _starveTimer: number;\n _sprinting = false;\n /** Присел (Shift): медленнее, глаз ниже, с края не сходит. */\n _sneaking = false;\n /** Кто сидит на плечах (id мобов: левое, правое) — попугаи (entities/Mob). */\n shoulders: [number | null, number | null] = [null, null];\n /** Касается лестницы (ladder) — можно лезть. */\n onLadder = false;\n /** Последний шаг упёрся в стену — «вперёд» у лестницы значит «вверх». */\n _hitWall = false;\n /** В лаве (урон, медленно), голова в лаве (оранжевый туман). */\n inLava = false;\n headInLava = false;\n /** До следующего удара лавой, сек (первый — сразу при касании). */\n _lavaT = 0;\n /** До следующего удара пустоты, сек. */\n _voidT = 0;\n /** Эффекты зелий: уровень и сколько ещё секунд. */\n effects = new Map<EffectId, { level: number; time: number }>();\n _effectRegenT = 0;\n _decayT = 0;\n _poisonT = 0;\n _cactusT = 0;\n _campT = 0;\n /** Тотем жизни в руке: потратить и спастись (подключает Game из инвентаря). */\n tryTotem: (() => boolean) | null = null;\n /** Воздух под водой, сек; кончился — 2 урона в секунду (утопление). */\n air = MAX_AIR;\n _drownT = 0;\n /** Летит на Крыльях Предела (player/Glide). */\n gliding = false;\n /** Надеты ли годные крылья и износ за секунду полёта — подключает Game из инвентаря. */\n canGlide: (() => boolean) | null = null;\n onGlideWear: (() => void) | null = null;\n _jumpHeld = false;\n _glideAcc = 0;\n _wearT = 0;\n /** Сколько ещё тянет ракета, сек. */\n _boostT = 0;\n /** Зерно вариантов стола зачарований — меняется после каждого зачарования (как в Minecraft). */\n enchSeed = (Math.random() * 0xffffffff) >>> 0;\n\n /** Касается ли тело кактуса (коробка игрока, раздутая на 0.1 по бокам). */\n _touchesCactus(): boolean {\n if (B.cactus === undefined || typeof this.world?.getBlock !== \"function\")\n return false;\n const r = 0.3 + 0.1;\n for (\n let x = Math.floor(this.pos.x - r);\n x <= Math.floor(this.pos.x + r);\n x++\n )\n for (\n let y = Math.floor(this.pos.y);\n y <= Math.floor(this.pos.y + 1.7);\n y++\n )\n for (\n let z = Math.floor(this.pos.z - r);\n z <= Math.floor(this.pos.z + r);\n z++\n )\n if (this.world.getBlock(x, y, z) === B.cactus) return true;\n return false;\n }\n /** Стоит в огне; сколько ещё гореть (сек) и до следующего урона от горения. */\n inFire = false;\n fireT = 0;\n _burnT = 0;\n /** Кровать — точка возрождения (мировые координаты клетки). */\n bedPos: [number, number, number] | null = null;\n /** Якорь возрождения (Пекло): клетка и измерение — возрождение там, а не у кровати. */\n anchorPos: [number, number, number] | null = null;\n anchorDim: string | null = null;\n /** Репутация в деревне: торговля поднимает, удар по жителю роняет (systems/Reputation). */\n villageRep = 0;\n /** Путевой камень, к которому привязан компас (клетка и измерение). */\n lodestone: { x: number; z: number; dim: string } | null = null;\n /** Последняя причина урона (экран смерти). */\n lastCause = \"\";\n /** Опыт: уровень, доля до следующего уровня (0..1), всего набрано. */\n xpLevel = 0;\n xpProgress = 0;\n xpTotal = 0;\n private _xpFrac = 0;\n\n /** Опыта до следующего уровня — формула Minecraft. */\n static xpToNext(level: number): number {\n return level >= 31\n ? 9 * level - 158\n : level >= 16\n ? 5 * level - 38\n : 2 * level + 7;\n }\n\n /** Добавить опыт (дробный копится: печь даёт по 0.1–1 за предмет). */\n addXp(amount: number): void {\n this._xpFrac += amount;\n const n = Math.floor(this._xpFrac);\n this._xpFrac -= n;\n if (n <= 0) return;\n this.xpTotal += n;\n this.xpProgress += n / Player.xpToNext(this.xpLevel);\n while (this.xpProgress >= 1) {\n this.xpProgress = (this.xpProgress - 1) * Player.xpToNext(this.xpLevel);\n this.xpLevel++;\n this.xpProgress /= Player.xpToNext(this.xpLevel);\n events.emit(\"levelUp\", { level: this.xpLevel });\n }\n events.emit(\"xpChanged\");\n }\n\n /** Отбросить игрока от удара (моб, стрела, взрыв). */\n knock(dx: number, dz: number, strength: number): void {\n const len = Math.hypot(dx, dz) || 1;\n this.vel.x += (dx / len) * strength;\n this.vel.z += (dz / len) * strength;\n this.vel.y = Math.max(this.vel.y, 4);\n this.onGround = false;\n }\n _respawnT?: ReturnType<typeof setTimeout>;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.pos = new THREE.Vector3(8.5, 40, 8.5); // ноги; уточняется при спавне\n this.vel = new THREE.Vector3();\n this.yaw = 0;\n this.pitch = 0;\n this.onGround = false;\n this.inWater = false;\n this.headInWater = false;\n this.mode = \"survival\"; // 'survival' | 'creative'\n this.flying = false;\n this.health = P.maxHealth;\n // голод: еда [0..maxFood], сатурация [0..food] (буфер, тратится первой),\n // истощение [0..exhaustionPerFood] — накопитель, при переполнении съедает очко\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n this._stepDist = 0;\n this._regenTimer = 0;\n this._starveTimer = 0;\n this.spawnPoint = new THREE.Vector3();\n\n events.on(\"input:doubleSpace\", () => {\n if (this.mode === \"creative\") this.flying = !this.flying;\n });\n }\n\n spawn(): void {\n // Ищем сушу по спирали от подсказки генератора (классический мир — (8,8)): не спавнимся в\n // океане на произвольном сиде. Сначала трава; мир из рецепта может быть пустыней или снегом —\n // тогда вторым проходом годится любая сухая твёрдая поверхность.\n const hint = this.world.generator.spawnHint();\n const water = this.world.waterLevel;\n let sx = hint.x,\n sz = hint.z;\n outer: for (const needGrass of [true, false]) {\n for (let r = 0; r <= 6; r++) {\n for (let dx = -r; dx <= r; dx += Math.max(1, r)) {\n for (let dz = -r; dz <= r; dz += Math.max(1, r)) {\n const x = hint.x + dx * 8,\n z = hint.z + dz * 8;\n const y = this.world.surfaceY(x, z);\n const top = this.world.getBlock(x, y, z);\n if (\n y > water &&\n (needGrass ? top === B.grass : BLOCKS[top]?.opaque)\n ) {\n sx = x;\n sz = z;\n break outer;\n }\n }\n }\n }\n }\n const y = this.world.surfaceY(sx, sz);\n this.pos.set(sx + 0.5, y + 1.01, sz + 0.5);\n this.spawnPoint.copy(this.pos);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n }\n\n get eyePos(): THREE.Vector3 {\n return new THREE.Vector3(\n this.pos.x,\n this.pos.y + (this._sneaking ? P.sneakEye : P.eye),\n this.pos.z,\n );\n }\n\n lookDir(): THREE.Vector3 {\n return new THREE.Vector3(\n -Math.sin(this.yaw) * Math.cos(this.pitch),\n Math.sin(this.pitch),\n -Math.cos(this.yaw) * Math.cos(this.pitch),\n );\n }\n\n toggleMode(): void {\n this.mode = this.mode === \"survival\" ? \"creative\" : \"survival\";\n if (this.mode === \"survival\") this.flying = false;\n this.exhaustion = 0; // накопленное в другом режиме не списываем разом\n events.emit(\"modeChanged\", { mode: this.mode });\n }\n\n /**\n * Поворот взгляда в радианах. Отдельным методом, чтобы у поворота был ОДИН источник правды:\n * им пользуется и кадровое обновление по мыши, и агентский API (`agent.ts`) — иначе у агента\n * появилась бы своя копия правил поворота, которая разъехалась бы с настоящей.\n */\n look(dYaw: number, dPitch: number): void {\n this.yaw += dYaw;\n this.pitch = Math.max(\n -Math.PI / 2 + 0.01,\n Math.min(Math.PI / 2 - 0.01, this.pitch + dPitch),\n );\n }\n\n /** Кадровое обновление: взгляд, ускорения, интеграция, коллизии. */\n update(dt: number, input: Input): void {\n // дельту забираем и у мёртвого: иначе она копится и после респауна камеру дёргает\n const m = input.consumeMouse();\n if (this.dead) return;\n // взгляд\n this.look(-m.dx * 0.0024, -m.dy * 0.0024);\n\n // в вагонетке: едем с ней, «вперёд» толкает, присесть — выйти\n if (this.riding) {\n const cart = this.riding;\n if (cart.dead || (input.sneak && !input.uiOpen)) this.dismount();\n else {\n this.pos.set(\n cart.renderPos.x,\n cart.renderPos.y + cart.seatY,\n cart.renderPos.z,\n );\n this.vel.set(0, 0, 0);\n this.onGround = true;\n this._fallDist = 0;\n if (input.forward && !input.uiOpen)\n cart.push(-Math.sin(this.yaw), -Math.cos(this.yaw), dt);\n return;\n }\n }\n\n // среда\n const feet = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n const head = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + P.eye),\n Math.floor(this.pos.z),\n );\n const wet = (id: number): boolean =>\n !!(BLOCKS[id]?.liquid || BLOCKS[id]?.waterlogged);\n this.inWater = wet(feet) || wet(head);\n this.headInWater = wet(head);\n this.inLava = feet === B.lava || head === B.lava;\n this.headInLava = head === B.lava;\n this.inFire =\n feet === B.fire ||\n head === B.fire ||\n feet === B.soul_fire ||\n head === B.soul_fire;\n this.onLadder = this._touchesLadder();\n this.inPowderSnow = feet === B.powder_snow || head === B.powder_snow;\n\n // желаемое горизонтальное движение в локальных осях камеры\n let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);\n let iz = (input.back ? 1 : 0) - (input.forward ? 1 : 0);\n if (input.uiOpen) {\n ix = 0;\n iz = 0;\n }\n const len = Math.hypot(ix, iz) || 1;\n ix /= len;\n iz /= len;\n // перевод ввода в мировые оси: forward = (-sin yaw, -cos yaw), right = (cos yaw, -sin yaw)\n const sin = Math.sin(this.yaw),\n cos = Math.cos(this.yaw);\n const wishX = ix * cos + iz * sin;\n const wishZ = iz * cos - ix * sin;\n\n // присесть — Shift (в полёте Shift — вниз); бег доступен, пока еды достаточно (как в Minecraft)\n this._sneaking = input.sneak && !this.flying && !input.uiOpen;\n const canSprint =\n input.run && input.forward && !this._sneaking && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed =\n this.speedFactor *\n (this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed * (this.inLava ? 0.5 : 1)\n : this._sneaking\n ? P.sneakSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed);\n\n // Крылья Предела: прыжок в воздухе раскрывает их; земля, вода, лестница, полёт, износ — складывают\n const jumpEdge = input.jump && !this._jumpHeld && !input.uiOpen;\n this._jumpHeld = input.jump;\n if (\n this.gliding &&\n (this.onGround ||\n this.inWater ||\n this.flying ||\n this.onLadder ||\n !this.canGlide?.())\n )\n this.gliding = false;\n else if (\n !this.gliding &&\n jumpEdge &&\n !this.onGround &&\n !this.inWater &&\n !this.flying &&\n this.vel.y < 1 &&\n this.canGlide?.()\n ) {\n this.gliding = true;\n this._glideAcc = 0;\n events.emit(\"glideStart\");\n }\n\n if (this.gliding) {\n // физика Minecraft по тикам; ракета толкает вдоль взгляда\n this._fallDist = 0;\n this._glideAcc += dt;\n while (this._glideAcc >= GLIDE_TICK) {\n this._glideAcc -= GLIDE_TICK;\n glideTick(this.vel, this.pitch, this.yaw, this._boostT > 0);\n this._boostT = Math.max(0, this._boostT - GLIDE_TICK);\n }\n this._wearT += dt;\n if (this._wearT >= 1) {\n this._wearT -= 1;\n if (this.mode !== \"creative\") this.onGlideWear?.();\n }\n } else if (this.flying) {\n // полёт: прямое управление скоростью, вертикаль на Space/Shift\n this.vel.x = wishX * speed;\n this.vel.z = wishZ * speed;\n this.vel.y = (input.jump ? speed : 0) + (input.sneakOrDown ? -speed : 0);\n if (input.uiOpen) this.vel.y = 0;\n } else if (this.inPowderSnow) {\n // снег-порох: вязнем — медленно; выбраться прыжками можно только в кожаных ботинках\n this.vel.x = wishX * speed * 0.35;\n this.vel.z = wishZ * speed * 0.35;\n this.vel.y =\n input.jump && !input.uiOpen && this.leatherBoots?.()\n ? 2.5\n : Math.max(-1.2, this.vel.y - P.gravity * dt);\n this._fallDist = 0;\n } else if (this.inWater) {\n const accel = 24 * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * 0.3 * dt; // ослабленная гравитация\n if (input.jump && !input.uiOpen) this.vel.y = P.swimUp; // гребок вверх\n this.vel.y *= 1 - P.waterDrag * dt; // сопротивление воды\n this._fallDist = 0;\n } else {\n const accel = (this.onGround ? 18 : 4) * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * dt;\n // Левитация (пуля панцирника): тянет вверх ~1 блок/с за уровень, падения не копит\n const lev = this.effects.get(\"levitation\");\n if (lev) {\n this.vel.y += (lev.level - this.vel.y) * Math.min(1, 5 * dt);\n this._fallDist = 0;\n }\n if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed + 1.6 * this.jumpBoost; // Прыгучесть — выше на ~полблока за уровень\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\n }\n // лестница: вверх — прыжок или «вперёд» в стену, присев — висим, иначе медленно сползаем\n if (this.onLadder) {\n this._fallDist = 0;\n const climb =\n !input.uiOpen && (input.jump || (input.forward && this._hitWall));\n if (climb) this.vel.y = P.climbSpeed;\n else if (this._sneaking) this.vel.y = Math.max(this.vel.y, 0);\n else this.vel.y = Math.max(this.vel.y, -P.climbSpeed);\n }\n }\n\n // интеграция с сабстепами — защита от туннелирования на лагающем кадре\n const steps = Math.max(1, Math.ceil((this.vel.length() * dt) / 0.4));\n const sdt = dt / steps;\n for (let s = 0; s < steps; s++) this._moveStep(sdt);\n\n // шаги (звук) + истощение от ходьбы/бега\n if (this.onGround && !this.inWater) {\n const dist = Math.hypot(this.vel.x, this.vel.z) * dt;\n this._stepDist += dist;\n if (this.mode === \"survival\")\n this.addExhaustion(\n dist * (this._sprinting ? P.exhaustSprint : P.exhaustWalk),\n );\n if (this._stepDist > 2.2) {\n this._stepDist = 0;\n events.emit(\"playerStep\");\n }\n }\n\n if (this.pos.y < -10) this._die(); // выпал из мира\n }\n\n /** Транспорт, в котором сидит игрок (вагонетка, лодка — entities/Rideable). */\n riding: Rideable | null = null;\n /** Игрок в снегу-порохе (вязнет; мёрзнет — systems/Freezing через Game). */\n inPowderSnow = false;\n /** Хук Game: надеты ли кожаные ботинки — снег-порох держит, из него можно выпрыгнуть. */\n leatherBoots?: () => boolean;\n /** Надета ли хоть одна золотая вещь — свинорылы не нападают (задаёт Game). */\n goldArmor?: () => boolean;\n /** Прикрывается щитом (держит ПКМ со щитом — player/Interaction). */\n blocking = false;\n /** Щит принял удар: изнашивает щит в руке (задаёт Game). */\n onShieldBlock?: (amount: number) => void;\n\n /** Сесть в вагонетку или лодку. */\n mount(cart: Rideable): void {\n if (this.riding) this.dismount();\n this.riding = cart;\n cart.rider = true;\n }\n\n /** Выйти из вагонетки — встать над ней. */\n dismount(): void {\n const cart = this.riding;\n if (!cart) return;\n cart.rider = false;\n this.riding = null;\n this.pos.set(cart.pos.x, cart.pos.y + 1, cart.pos.z);\n this.vel.set(0, 0, 0);\n }\n\n /** Шаг интеграции: коллизии по коробкам форм (physics/Collision), шаг на ступень, край при приседании. */\n _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n const hv0 = Math.hypot(this.vel.x, this.vel.z);\n const r = moveBody(\n this.world,\n {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n hw: P.width / 2,\n h: P.height,\n },\n this.vel.x * dt,\n this.vel.y * dt,\n this.vel.z * dt,\n {\n step: this.onGround && !this.flying ? P.stepHeight : 0,\n sneak: this._sneaking,\n onGround: this.onGround,\n // в кожаных ботинках снег-порох держит (присев — проваливаешься, как в Minecraft)\n extraSolid:\n !this.inPowderSnow && !this._sneaking && this.leatherBoots?.()\n ? isPowderSnow\n : undefined,\n },\n );\n this.pos.x += r.dx;\n this.pos.y += r.dy;\n this.pos.z += r.dz;\n\n if (r.hitDown) {\n // на острый сталагмит больнее: урон вдвое и уже с 2 блоков (как в Minecraft)\n const fx = Math.floor(this.pos.x),\n fy = Math.floor(this.pos.y + 0.01),\n fz = Math.floor(this.pos.z);\n const spike =\n this.world.getBlock(fx, fy, fz) === B.pointed_dripstone ||\n this.world.getBlock(fx, fy - 1, fz) === B.pointed_dripstone;\n const safe = spike ? 2 : P.fallSafe + this.jumpBoost;\n if (wasAirborne && this._fallDist > safe && this.mode === \"survival\")\n this.damage(\n Math.round(this._fallDist - safe) * (spike ? 2 : 1),\n spike ? \"stalagmite\" : \"fall\",\n );\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else {\n this.onGround = false;\n if (r.hitUp) this.vel.y = 0; // потолок\n // снижение в полёте — не падение: выключив полёт в воздухе, получили бы урон за весь спуск\n if (this.flying) this._fallDist = 0;\n else if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n if (r.hitX) this.vel.x = 0;\n if (r.hitZ) this.vel.z = 0;\n this._hitWall = r.hitX || r.hitZ;\n // на крыльях врезался в стену: урон по потерянной скорости, как в Minecraft (блоки за тик)\n if (this.gliding && this._hitWall && this.mode === \"survival\") {\n const dmg = ((hv0 - Math.hypot(this.vel.x, this.vel.z)) / 20) * 10 - 3;\n if (dmg > 0) this.damage(dmg, \"wall\");\n }\n }\n\n /** Касается ли тело лестницы (клетки, которые пересекает AABB, чуть расширенный вбок). */\n _touchesLadder(): boolean {\n const half = P.width / 2 + 0.05;\n for (let y = Math.floor(this.pos.y); y <= Math.floor(this.pos.y + 1); y++)\n for (\n let z = Math.floor(this.pos.z - half);\n z <= Math.floor(this.pos.z + half);\n z++\n )\n for (\n let x = Math.floor(this.pos.x - half);\n x <= Math.floor(this.pos.x + half);\n x++\n ) {\n // лезут по лестнице и по строительным лесам (systems/Scaffold)\n const sh = BLOCKS[this.world.getBlock(x, y, z)]?.shape;\n if (sh === \"ladder\" || sh === \"scaffold\") return true;\n }\n return false;\n }\n\n /** Надетая броня (подключает Game из инвентаря): очки защиты и твёрдость. */\n armorStats:\n (() => { points: number; toughness: number; protection?: number }) | null =\n null;\n /** Удар по броне — износ (подключает Game). */\n onArmorHit: ((damage: number) => void) | null = null;\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n // Огнестойкость: огонь и лава не ранят\n if (\n (cause === \"fire\" || cause === \"lava\") &&\n this.effects.has(\"fire_resistance\")\n )\n return;\n this.lastCause = cause;\n // броня гасит удары, огонь, лаву и взрывы — формула Minecraft; падение и голод — нет\n if (ARMORED_CAUSES.has(cause) && this.armorStats) {\n const { points, toughness } = this.armorStats();\n if (points > 0) {\n this.onArmorHit?.(amount);\n amount = armorReduce(amount, points, toughness);\n }\n }\n // Защита: −4% за уровень по всей броне, не больше 80% (пустоту и голод не гасит)\n const prot = this.armorStats?.().protection ?? 0;\n if (prot > 0 && cause !== \"void\" && cause !== \"starve\")\n amount *= 1 - Math.min(20, prot) * 0.04;\n // Сопротивление (маяк): −20% за уровень (пустоту не гасит)\n const res = this.effects.get(\"resistance\")?.level ?? 0;\n if (res > 0 && cause !== \"void\") amount *= Math.max(0, 1 - 0.2 * res);\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) {\n // тотем жизни в руке: 1 здоровья, Регенерация II и Огнестойкость вместо смерти (пустоту не спасает)\n if (cause !== \"void\" && this.tryTotem?.()) {\n this.health = 1;\n this.clearEffects();\n this.addEffect(\"regeneration\", 45, 2);\n this.addEffect(\"fire_resistance\", 40);\n events.emit(\"totemUsed\");\n return;\n }\n this._die();\n }\n }\n\n addExhaustion(n: number): void {\n // В креативе голода нет и tickHunger истощение не списывает — оно копилось бы и при возврате\n // в выживание съедало еду пачкой за один тик.\n if (this.mode === \"creative\") return;\n this.exhaustion += n;\n }\n\n /**\n * Тик опасностей среды (симуляция), как в Minecraft: лава — 4 урона каждые полсекунды и поджигает\n * на 15 с, огонь — поджигает на 8 с; горящий теряет 1 в секунду, вода тушит.\n */\n tickHazards(dt: number): void {\n if (this.dead) return;\n this._tickEffects(dt);\n // воздух: голова под водой тратит запас (Подводное дыхание — нет), кончился — тонем\n if (\n this.headInWater &&\n !this.headInLava &&\n this.mode === \"survival\" &&\n !this.effects.has(\"water_breathing\")\n ) {\n this.air = Math.max(0, this.air - dt);\n if (this.air <= 0) {\n this._drownT -= dt;\n if (this._drownT <= 0) {\n this._drownT = 1;\n this.damage(2, \"drown\");\n }\n }\n } else {\n this.air = Math.min(MAX_AIR, this.air + dt * 5);\n this._drownT = 0;\n }\n // горящий костёр жжёт стоящего на нём: 1 урон в секунду (костёр душ — 2), но не поджигает\n this._campT = Math.max(0, this._campT - dt);\n const camp =\n B.campfire !== undefined && typeof this.world?.getBlock === \"function\"\n ? this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n : -1;\n const soulCamp = camp === B.soul_campfire;\n if (\n this._campT <= 0 &&\n (camp === B.campfire || soulCamp) &&\n !this.effects.has(\"fire_resistance\")\n ) {\n this._campT = 1;\n this.damage(soulCamp ? 2 : 1, \"fire\");\n }\n // кактус колет: 1 урон каждые полсекунды, пока касаешься\n this._cactusT = Math.max(0, this._cactusT - dt);\n if (this._cactusT <= 0 && this._touchesCactus()) {\n this._cactusT = 0.5;\n this.damage(1, \"cactus\");\n }\n // пустота под Пределом (и под миром): 4 урона каждые полсекунды, даже в креативе — как в Minecraft\n if (this.pos.y < -48) {\n this._voidT -= dt;\n if (this._voidT <= 0) {\n this._voidT = 0.5;\n const was = this.mode;\n this.mode = \"survival\";\n this.damage(4, \"void\");\n this.mode = was;\n }\n return;\n }\n if (this.inLava) {\n this._lavaT -= dt;\n if (this._lavaT <= 0) {\n this._lavaT = 0.5;\n this.damage(4, \"lava\");\n }\n this.fireT = 15;\n } else {\n this._lavaT = 0;\n if (this.inFire) this.fireT = Math.max(this.fireT, 8);\n else if (this.inWater) this.fireT = 0;\n }\n if (this.fireT > 0 && this.mode !== \"creative\") {\n this.fireT = Math.max(0, this.fireT - dt);\n this._burnT -= dt;\n if (this._burnT <= 0) {\n this._burnT = 1;\n this.damage(1, \"fire\");\n }\n } else {\n this.fireT = 0;\n this._burnT = 0;\n }\n }\n\n /** Тик голода (симуляция, 20 TPS): истощение → еда, регенерация, голодание. */\n tickHunger(dt: number): void {\n if (this.mode === \"creative\" || this.dead) return;\n this.addExhaustion(P.exhaustIdle * dt); // медленная пассивная трата\n while (this.exhaustion >= P.exhaustionPerFood) {\n this.exhaustion -= P.exhaustionPerFood;\n if (this.saturation > 0)\n this.saturation = Math.max(0, this.saturation - 1);\n else this.food = Math.max(0, this.food - 1);\n }\n\n // сытость лечит; лечение «стоит» истощения\n if (this.food >= P.regenFoodThreshold && this.health < P.maxHealth) {\n this._regenTimer += dt;\n if (this._regenTimer >= P.regenInterval) {\n this._regenTimer = 0;\n this.health = Math.min(P.maxHealth, this.health + 1);\n this.addExhaustion(P.exhaustRegen);\n events.emit(\"playerHeal\", { health: this.health });\n }\n } else this._regenTimer = 0;\n\n // голодание: до какого здоровья добивает — по сложности (systems/Difficulty);\n // на мирной голода нет вовсе, на высокой голод убивает\n const floor = rules().starveFloor;\n if (this.food <= 0 && floor !== null) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > floor) this.damage(1, \"starve\");\n }\n } else this._starveTimer = 0;\n\n events.emit(\"foodChanged\", { food: this.food });\n }\n\n /** Съесть предмет (если это еда и есть куда). @returns {boolean} успех */\n eat(itemName: string): boolean {\n const it = ITEMS[itemName];\n // плод эхоцвета едят и сытым — ради переноса\n if (!it?.food || (this.food >= P.maxFood && itemName !== \"echo_fruit\"))\n return false;\n this.food = Math.min(P.maxFood, this.food + it.food);\n this.saturation = Math.min(\n this.food,\n this.saturation + (it.saturation ?? it.food * 0.6),\n );\n for (const e of it.eatEffects ?? [])\n this.addEffect(e.id, e.seconds, e.level ?? 1);\n events.emit(\"playerAte\", { item: itemName });\n if (itemName === \"echo_fruit\") this.echoHop();\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n /** Множитель скорости ходьбы: +20% за уровень Скорости и −15% за уровень Замедления, как в Minecraft. */\n get speedFactor(): number {\n const slow = this.effects.get(\"slowness\")?.level ?? 0;\n return (\n (1 + 0.2 * (this.effects.get(\"speed\")?.level ?? 0)) *\n Math.max(0, 1 - 0.15 * slow) *\n (this.blocking ? 0.3 : 1) // прикрываясь щитом, идёшь медленно\n );\n }\n\n /**\n * Щит, как в Minecraft: прикрылся, а удар или снаряд пришёл спереди (из полусферы взгляда) — урона нет,\n * щит изнашивается. @returns true — принял щит (урон и эффекты удара не наносить)\n */\n shieldBlocks(from: { x: number; z: number }, amount: number): boolean {\n if (!this.blocking || this.dead || this.mode === \"creative\") return false;\n const look = this.lookDir();\n if (look.x * (from.x - this.pos.x) + look.z * (from.z - this.pos.z) <= 0)\n return false;\n this.onShieldBlock?.(amount);\n events.emit(\"shieldBlock\", { amount });\n return true;\n }\n\n /** Ракета в полёте на крыльях: рывок на 1.2 с. false — не летим (ракета не тратится). */\n boost(): boolean {\n if (!this.gliding) return false;\n this._boostT = 1.2;\n return true;\n }\n\n /** Плод эхоцвета: прыжок в случайную точку до 8 блоков, где есть пол и место на рост. */\n echoHop(): boolean {\n for (let i = 0; i < 16; i++) {\n const x = Math.floor(this.pos.x + (Math.random() - 0.5) * 16),\n z = Math.floor(this.pos.z + (Math.random() - 0.5) * 16),\n y0 = Math.floor(this.pos.y + (Math.random() - 0.5) * 16);\n for (let y = Math.min(y0, 126); y > y0 - 16 && y > 1; y--) {\n if (!BLOCKS[this.world.getBlock(x, y - 1, z)]?.solid) continue;\n if (\n this.world.getBlock(x, y, z) !== 0 ||\n this.world.getBlock(x, y + 1, z) !== 0\n )\n break;\n this.pos.set(x + 0.5, y, z + 0.5);\n this.vel.set(0, 0, 0);\n this._fallDist = 0;\n events.emit(\"echoHop\", { x, y, z });\n return true;\n }\n }\n return false;\n }\n\n /** Уровень Прыгучести: прыжок выше, безопасное падение длиннее на столько блоков. */\n get jumpBoost(): number {\n return this.effects.get(\"jump_boost\")?.level ?? 0;\n }\n\n /** Наложить эффект: остаётся более сильный уровень и более долгий срок. */\n addEffect(id: EffectId, seconds: number, level = 1): void {\n const cur = this.effects.get(id);\n this.effects.set(id, {\n level: Math.max(level, cur?.level ?? 0),\n time: Math.max(seconds, cur?.time ?? 0),\n });\n events.emit(\"effectsChanged\", { id });\n }\n\n clearEffects(): void {\n this.effects.clear();\n events.emit(\"effectsChanged\", {});\n }\n\n /** Выпить: зелье накладывает эффект, молоко снимает все. */\n drink(d: DrinkDef): void {\n if (d.clearsEffects) this.clearEffects();\n if (d.heal) this.health = Math.min(P.maxHealth, this.health + d.heal);\n if (d.damage) this.damage(d.damage, \"magic\");\n if (d.food) this.food = Math.min(P.maxFood, this.food + d.food);\n if (d.effect) this.addEffect(d.effect, d.seconds ?? 60, d.level ?? 1);\n events.emit(\"playerDrank\", { effect: d.effect ?? null });\n }\n\n /** Сроки эффектов; Регенерация лечит 1 здоровье за 2.5 с (вдвое чаще за уровень). */\n _tickEffects(dt: number): void {\n for (const [id, e] of this.effects) {\n e.time -= dt;\n if (e.time <= 0) {\n this.effects.delete(id);\n events.emit(\"effectsChanged\", { id });\n }\n }\n // Тление: 1 урона раз в 2 с (вдвое чаще за уровень), убивает — в отличие от голода\n const decay = this.effects.get(\"decay\");\n if (decay) {\n this._decayT += dt;\n if (this._decayT >= 2 / decay.level) {\n this._decayT = 0;\n this.damage(1, \"decay\");\n }\n } else this._decayT = 0;\n // Отравление: 1 урона раз в 1.25 с (вдвое чаще за уровень), но не убивает — оставляет 1 здоровья\n const poison = this.effects.get(\"poison\");\n if (poison) {\n this._poisonT += dt;\n if (this._poisonT >= 1.25 / poison.level) {\n this._poisonT = 0;\n if (this.health > 1)\n this.damage(Math.min(1, this.health - 1), \"poison\");\n }\n } else this._poisonT = 0;\n // Голод (удар кадавра): истощение 0.1 в секунду за уровень — сытость тает быстрее\n const hunger = this.effects.get(\"hunger\");\n if (hunger) this.addExhaustion(0.1 * hunger.level * dt);\n const regen = this.effects.get(\"regeneration\");\n if (!regen) return;\n this._effectRegenT += dt;\n const every = 2.5 / 2 ** (regen.level - 1);\n if (this._effectRegenT >= every) {\n this._effectRegenT = 0;\n if (this.health < P.maxHealth)\n this.health = Math.min(P.maxHealth, this.health + 1);\n }\n }\n\n /** Смерть: экран «Вы погибли» и выпадение вещей решает Game (подписан на playerDied). */\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n this.fireT = 0;\n this.air = MAX_AIR;\n this.vel.set(0, 0, 0);\n events.emit(\"playerDied\", { cause: this.lastCause });\n }\n\n /** Возродиться в точке (кровать или точка мира) с полным здоровьем и сытостью. */\n respawn(at: THREE.Vector3): void {\n clearTimeout(this._respawnT);\n this.pos.copy(at);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.fireT = 0;\n this._fallDist = 0;\n this.dead = false;\n events.emit(\"playerRespawn\");\n }\n\n /** Пересекается ли AABB игрока с блоком (запрет постановки «в себя»). */\n intersectsBlock(bx: number, by: number, bz: number): boolean {\n const half = P.width / 2;\n return (\n bx + 1 > this.pos.x - half &&\n bx < this.pos.x + half &&\n by + 1 > this.pos.y &&\n by < this.pos.y + P.height &&\n bz + 1 > this.pos.z - half &&\n bz < this.pos.z + half\n );\n }\n\n /** Teardown: отложенный респаун не должен сработать после уничтожения игры. */\n dispose(): void {\n clearTimeout(this._respawnT);\n }\n\n serialize() {\n return {\n pos: this.pos.toArray(),\n yaw: this.yaw,\n pitch: this.pitch,\n health: this.health,\n food: this.food,\n saturation: this.saturation,\n mode: this.mode,\n flying: this.flying,\n spawn: this.spawnPoint.toArray(),\n xpLevel: this.xpLevel,\n xpProgress: this.xpProgress,\n xpTotal: this.xpTotal,\n bedPos: this.bedPos,\n anchorPos: this.anchorPos,\n villageRep: this.villageRep,\n anchorDim: this.anchorDim,\n lodestone: this.lodestone,\n effects: [...this.effects].map(([id, e]) => ({\n id,\n level: e.level,\n time: e.time,\n })),\n enchSeed: this.enchSeed,\n };\n }\n deserialize(d: PlayerSaveData): void {\n this.pos.fromArray(d.pos);\n this.yaw = d.yaw;\n this.pitch = d.pitch;\n // сейв, записанный на экране смерти: здоровье 0 при dead=false убило бы первым же уроном\n this.health = d.health > 0 ? d.health : P.maxHealth;\n this.mode = d.mode;\n this.flying = d.flying;\n this.food = d.food ?? P.maxFood;\n this.saturation = d.saturation ?? 5;\n this.exhaustion = 0;\n this.vel.set(0, 0, 0);\n if (Array.isArray(d.spawn) && d.spawn.length === 3)\n this.spawnPoint.fromArray(d.spawn);\n else this.spawnPoint.copy(this.pos);\n this.xpLevel = d.xpLevel ?? 0;\n this.xpProgress = d.xpProgress ?? 0;\n this.xpTotal = d.xpTotal ?? 0;\n const cell = (v: unknown): [number, number, number] | null =>\n Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n))\n ? [Number(v[0]), Number(v[1]), Number(v[2])]\n : null;\n this.anchorPos = cell(d.anchorPos);\n this.villageRep = clampRep(Number(d.villageRep) || 0);\n this.anchorDim = typeof d.anchorDim === \"string\" ? d.anchorDim : null;\n this.lodestone =\n d.lodestone && typeof d.lodestone === \"object\"\n ? { ...d.lodestone }\n : null;\n this.bedPos =\n Array.isArray(d.bedPos) && d.bedPos.length === 3\n ? [d.bedPos[0]!, d.bedPos[1]!, d.bedPos[2]!]\n : null;\n this.dead = false;\n if (typeof d.enchSeed === \"number\") this.enchSeed = d.enchSeed;\n this.effects.clear();\n for (const e of Array.isArray(d.effects) ? d.effects : [])\n if (EFFECT_IDS.includes(e.id as EffectId) && e.time > 0)\n this.effects.set(e.id as EffectId, {\n level: e.level || 1,\n time: e.time,\n });\n }\n}\n"
683
+ "content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { clampRep } from \"../systems/Reputation\";\nimport type { DrinkDef, EffectId } from \"../types\";\n\nconst EFFECT_IDS: readonly EffectId[] = [\n \"strength\",\n \"regeneration\",\n \"night_vision\",\n \"speed\",\n \"fire_resistance\",\n \"haste\",\n \"resistance\",\n \"jump_boost\",\n \"decay\",\n \"levitation\",\n \"water_breathing\",\n \"mining_fatigue\",\n \"poison\",\n \"bad_omen\",\n \"hero\",\n \"darkness\",\n \"slowness\",\n \"hunger\",\n];\n\n/** Запас воздуха под водой, сек (300 тиков Minecraft). */\nexport const MAX_AIR = 15;\n/** Для коллизий в кожаных ботинках: снег-порох — полный куб. */\nconst isPowderSnow = (id: number): boolean => id === B.powder_snow;\n/** Подъём в снегу-порохе с зажатым прыжком, блоков/с: без кожаных ботинок и в них. */\nconst POWDER_CLIMB = 1.5;\nconst POWDER_CLIMB_BOOTS = 2.5;\n/**\n * Подскок на выходе из снега-пороха: ноги покинули клетку, а тело ещё на 0.2 ниже кромки — без него\n * упор в край не срабатывает (коробка пересекает тело) и игрок сползает обратно. ~0.5 блока.\n */\nconst POWDER_HOP = 5;\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { moveBody } from \"../physics/Collision\";\nimport { findSafeSpot, isStandable } from \"../world/safeSpot\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\nimport type { Rideable } from \"../entities/Rideable\";\nimport { GLIDE_TICK, glideTick } from \"./Glide\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\n\n/** Причины урона, которые гасит броня. */\nconst ARMORED_CAUSES: ReadonlySet<string> = new Set([\n \"attack\",\n \"projectile\",\n \"explosion\",\n \"lava\",\n \"fire\",\n]);\n\n/** Урон после брони — формула Minecraft (очки защиты и твёрдость). */\nexport function armorReduce(\n damage: number,\n points: number,\n toughness: number,\n): number {\n const eff = Math.min(\n 20,\n Math.max(points / 5, points - damage / (2 + toughness / 4)),\n );\n return damage * (1 - eff / 25);\n}\n\n/** Форма persist-блоба игрока (см. serialize/deserialize). */\ninterface PlayerSaveData {\n pos: number[];\n yaw: number;\n pitch: number;\n health: number;\n food?: number;\n saturation?: number;\n mode: PlayerMode;\n flying: boolean;\n /** v3: точка возрождения (кровать). Нет — там, где загрузились. */\n spawn?: number[];\n /** v3: кровать, у которой возрождаться (нет — у точки мира). */\n bedPos?: number[] | null;\n /** Якорь возрождения и путевой камень компаса (нет полей — их и не было). */\n anchorPos?: number[] | null;\n villageRep?: number;\n anchorDim?: string | null;\n lodestone?: { x: number; z: number; dim: string } | null;\n /** v3: действующие эффекты зелий. */\n effects?: { id: string; level: number; time: number }[];\n /** v3: зерно вариантов стола зачарований. */\n enchSeed?: number;\n /** v3: опыт — уровень, доля до следующего, всего набрано. */\n xpLevel?: number;\n xpProgress?: number;\n xpTotal?: number;\n}\n\nexport class Player {\n world: ChunkManager;\n pos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n pitch: number;\n onGround: boolean;\n inWater: boolean;\n headInWater: boolean;\n mode: PlayerMode;\n flying: boolean;\n health: number;\n food: number;\n saturation: number;\n exhaustion: number;\n dead: boolean;\n spawnPoint: THREE.Vector3;\n _fallDist: number;\n _stepDist: number;\n _regenTimer: number;\n _starveTimer: number;\n _sprinting = false;\n /** Присел (Shift): медленнее, глаз ниже, с края не сходит. */\n _sneaking = false;\n /** Кто сидит на плечах (id мобов: левое, правое) — попугаи (entities/Mob). */\n shoulders: [number | null, number | null] = [null, null];\n /** Касается лестницы (ladder) — можно лезть. */\n onLadder = false;\n /** Последний шаг упёрся в стену — «вперёд» у лестницы значит «вверх». */\n _hitWall = false;\n /** В лаве (урон, медленно), голова в лаве (оранжевый туман). */\n inLava = false;\n headInLava = false;\n /** До следующего удара лавой, сек (первый — сразу при касании). */\n _lavaT = 0;\n /** До следующего удара пустоты, сек. */\n _voidT = 0;\n /** Эффекты зелий: уровень и сколько ещё секунд. */\n effects = new Map<EffectId, { level: number; time: number }>();\n _effectRegenT = 0;\n _decayT = 0;\n _poisonT = 0;\n _cactusT = 0;\n _campT = 0;\n /** Тотем жизни в руке: потратить и спастись (подключает Game из инвентаря). */\n tryTotem: (() => boolean) | null = null;\n /** Воздух под водой, сек; кончился — 2 урона в секунду (утопление). */\n air = MAX_AIR;\n _drownT = 0;\n /** Летит на Крыльях Предела (player/Glide). */\n gliding = false;\n /** Надеты ли годные крылья и износ за секунду полёта — подключает Game из инвентаря. */\n canGlide: (() => boolean) | null = null;\n onGlideWear: (() => void) | null = null;\n _jumpHeld = false;\n _glideAcc = 0;\n _wearT = 0;\n /** Сколько ещё тянет ракета, сек. */\n _boostT = 0;\n /** Зерно вариантов стола зачарований — меняется после каждого зачарования (как в Minecraft). */\n enchSeed = (Math.random() * 0xffffffff) >>> 0;\n\n /** Касается ли тело кактуса (коробка игрока, раздутая на 0.1 по бокам). */\n _touchesCactus(): boolean {\n if (B.cactus === undefined || typeof this.world?.getBlock !== \"function\")\n return false;\n const r = 0.3 + 0.1;\n for (\n let x = Math.floor(this.pos.x - r);\n x <= Math.floor(this.pos.x + r);\n x++\n )\n for (\n let y = Math.floor(this.pos.y);\n y <= Math.floor(this.pos.y + 1.7);\n y++\n )\n for (\n let z = Math.floor(this.pos.z - r);\n z <= Math.floor(this.pos.z + r);\n z++\n )\n if (this.world.getBlock(x, y, z) === B.cactus) return true;\n return false;\n }\n /** Стоит в огне; сколько ещё гореть (сек) и до следующего урона от горения. */\n inFire = false;\n fireT = 0;\n _burnT = 0;\n /** Кровать — точка возрождения (мировые координаты клетки). */\n bedPos: [number, number, number] | null = null;\n /** Якорь возрождения (Пекло): клетка и измерение — возрождение там, а не у кровати. */\n anchorPos: [number, number, number] | null = null;\n anchorDim: string | null = null;\n /** Репутация в деревне: торговля поднимает, удар по жителю роняет (systems/Reputation). */\n villageRep = 0;\n /** Путевой камень, к которому привязан компас (клетка и измерение). */\n lodestone: { x: number; z: number; dim: string } | null = null;\n /** Последняя причина урона (экран смерти). */\n lastCause = \"\";\n /** Опыт: уровень, доля до следующего уровня (0..1), всего набрано. */\n xpLevel = 0;\n xpProgress = 0;\n xpTotal = 0;\n private _xpFrac = 0;\n\n /** Опыта до следующего уровня — формула Minecraft. */\n static xpToNext(level: number): number {\n return level >= 31\n ? 9 * level - 158\n : level >= 16\n ? 5 * level - 38\n : 2 * level + 7;\n }\n\n /** Добавить опыт (дробный копится: печь даёт по 0.1–1 за предмет). */\n addXp(amount: number): void {\n this._xpFrac += amount;\n const n = Math.floor(this._xpFrac);\n this._xpFrac -= n;\n if (n <= 0) return;\n this.xpTotal += n;\n this.xpProgress += n / Player.xpToNext(this.xpLevel);\n while (this.xpProgress >= 1) {\n this.xpProgress = (this.xpProgress - 1) * Player.xpToNext(this.xpLevel);\n this.xpLevel++;\n this.xpProgress /= Player.xpToNext(this.xpLevel);\n events.emit(\"levelUp\", { level: this.xpLevel });\n }\n events.emit(\"xpChanged\");\n }\n\n /** Отбросить игрока от удара (моб, стрела, взрыв). */\n knock(dx: number, dz: number, strength: number): void {\n const len = Math.hypot(dx, dz) || 1;\n this.vel.x += (dx / len) * strength;\n this.vel.z += (dz / len) * strength;\n this.vel.y = Math.max(this.vel.y, 4);\n this.onGround = false;\n }\n _respawnT?: ReturnType<typeof setTimeout>;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.pos = new THREE.Vector3(8.5, 40, 8.5); // ноги; уточняется при спавне\n this.vel = new THREE.Vector3();\n this.yaw = 0;\n this.pitch = 0;\n this.onGround = false;\n this.inWater = false;\n this.headInWater = false;\n this.mode = \"survival\"; // 'survival' | 'creative'\n this.flying = false;\n this.health = P.maxHealth;\n // голод: еда [0..maxFood], сатурация [0..food] (буфер, тратится первой),\n // истощение [0..exhaustionPerFood] — накопитель, при переполнении съедает очко\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n this._stepDist = 0;\n this._regenTimer = 0;\n this._starveTimer = 0;\n this.spawnPoint = new THREE.Vector3();\n\n events.on(\"input:doubleSpace\", () => {\n if (this.mode === \"creative\") this.flying = !this.flying;\n });\n }\n\n spawn(): void {\n // Ищем сушу по спирали от подсказки генератора (классический мир — (8,8)): не спавнимся в\n // океане на произвольном сиде. Сначала трава; мир из рецепта может быть пустыней или снегом —\n // тогда вторым проходом годится любая сухая твёрдая поверхность. Над ней — место на рост:\n // верх колонки по surfaceY лежит ПОД снегом-порохом (он не твёрдый), и без проверки игрок\n // появлялся на дне его ямы.\n const hint = this.world.generator.spawnHint();\n const water = this.world.waterLevel;\n let sx = hint.x,\n sz = hint.z;\n outer: for (const needGrass of [true, false]) {\n for (let r = 0; r <= 6; r++) {\n for (let dx = -r; dx <= r; dx += Math.max(1, r)) {\n for (let dz = -r; dz <= r; dz += Math.max(1, r)) {\n const x = hint.x + dx * 8,\n z = hint.z + dz * 8;\n const y = this.world.surfaceY(x, z);\n const top = this.world.getBlock(x, y, z);\n if (\n y > water &&\n (needGrass ? top === B.grass : BLOCKS[top]?.opaque) &&\n isStandable(this.world, x, y + 1, z)\n ) {\n sx = x;\n sz = z;\n break outer;\n }\n }\n }\n }\n }\n let y = this.world.surfaceY(sx, sz);\n if (!isStandable(this.world, sx, y + 1, sz)) {\n const near = findSafeSpot(this.world, sx, sz, 16, water);\n if (near) {\n sx = near.x;\n sz = near.z;\n y = near.y - 1;\n }\n }\n this.pos.set(sx + 0.5, y + 1.01, sz + 0.5);\n this.spawnPoint.copy(this.pos);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n }\n\n get eyePos(): THREE.Vector3 {\n return new THREE.Vector3(\n this.pos.x,\n this.pos.y + (this._sneaking ? P.sneakEye : P.eye),\n this.pos.z,\n );\n }\n\n lookDir(): THREE.Vector3 {\n return new THREE.Vector3(\n -Math.sin(this.yaw) * Math.cos(this.pitch),\n Math.sin(this.pitch),\n -Math.cos(this.yaw) * Math.cos(this.pitch),\n );\n }\n\n toggleMode(): void {\n this.mode = this.mode === \"survival\" ? \"creative\" : \"survival\";\n if (this.mode === \"survival\") this.flying = false;\n this.exhaustion = 0; // накопленное в другом режиме не списываем разом\n events.emit(\"modeChanged\", { mode: this.mode });\n }\n\n /**\n * Поворот взгляда в радианах. Отдельным методом, чтобы у поворота был ОДИН источник правды:\n * им пользуется и кадровое обновление по мыши, и агентский API (`agent.ts`) — иначе у агента\n * появилась бы своя копия правил поворота, которая разъехалась бы с настоящей.\n */\n look(dYaw: number, dPitch: number): void {\n this.yaw += dYaw;\n this.pitch = Math.max(\n -Math.PI / 2 + 0.01,\n Math.min(Math.PI / 2 - 0.01, this.pitch + dPitch),\n );\n }\n\n /** Кадровое обновление: взгляд, ускорения, интеграция, коллизии. */\n update(dt: number, input: Input): void {\n // дельту забираем и у мёртвого: иначе она копится и после респауна камеру дёргает\n const m = input.consumeMouse();\n if (this.dead) return;\n // взгляд\n this.look(-m.dx * 0.0024, -m.dy * 0.0024);\n\n // в вагонетке: едем с ней, «вперёд» толкает, присесть — выйти\n if (this.riding) {\n const cart = this.riding;\n if (cart.dead || (input.sneak && !input.uiOpen)) this.dismount();\n else {\n this.pos.set(\n cart.renderPos.x,\n cart.renderPos.y + cart.seatY,\n cart.renderPos.z,\n );\n this.vel.set(0, 0, 0);\n this.onGround = true;\n this._fallDist = 0;\n if (input.forward && !input.uiOpen)\n cart.push(-Math.sin(this.yaw), -Math.cos(this.yaw), dt);\n return;\n }\n }\n\n // среда\n const feet = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n const head = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + P.eye),\n Math.floor(this.pos.z),\n );\n const wet = (id: number): boolean =>\n !!(BLOCKS[id]?.liquid || BLOCKS[id]?.waterlogged);\n this.inWater = wet(feet) || wet(head);\n this.headInWater = wet(head);\n this.inLava = feet === B.lava || head === B.lava;\n this.headInLava = head === B.lava;\n this.inFire =\n feet === B.fire ||\n head === B.fire ||\n feet === B.soul_fire ||\n head === B.soul_fire;\n this.onLadder = this._touchesLadder();\n this.inPowderSnow = feet === B.powder_snow || head === B.powder_snow;\n this.headInPowderSnow = head === B.powder_snow;\n // вылезли из снега-пороха вверх с зажатым прыжком — подскок через кромку\n if (\n this._wasInPowder &&\n !this.inPowderSnow &&\n input.jump &&\n !input.uiOpen &&\n this.vel.y > 0 &&\n !this.flying\n )\n this.vel.y = Math.max(this.vel.y, POWDER_HOP);\n this._wasInPowder = this.inPowderSnow;\n\n // желаемое горизонтальное движение в локальных осях камеры\n let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);\n let iz = (input.back ? 1 : 0) - (input.forward ? 1 : 0);\n if (input.uiOpen) {\n ix = 0;\n iz = 0;\n }\n const len = Math.hypot(ix, iz) || 1;\n ix /= len;\n iz /= len;\n // перевод ввода в мировые оси: forward = (-sin yaw, -cos yaw), right = (cos yaw, -sin yaw)\n const sin = Math.sin(this.yaw),\n cos = Math.cos(this.yaw);\n const wishX = ix * cos + iz * sin;\n const wishZ = iz * cos - ix * sin;\n\n // присесть — Shift (в полёте Shift — вниз); бег доступен, пока еды достаточно (как в Minecraft)\n this._sneaking = input.sneak && !this.flying && !input.uiOpen;\n const canSprint =\n input.run && input.forward && !this._sneaking && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed =\n this.speedFactor *\n (this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed * (this.inLava ? 0.5 : 1)\n : this._sneaking\n ? P.sneakSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed);\n\n // Крылья Предела: прыжок в воздухе раскрывает их; земля, вода, лестница, полёт, износ — складывают\n const jumpEdge = input.jump && !this._jumpHeld && !input.uiOpen;\n this._jumpHeld = input.jump;\n if (\n this.gliding &&\n (this.onGround ||\n this.inWater ||\n this.flying ||\n this.onLadder ||\n !this.canGlide?.())\n )\n this.gliding = false;\n else if (\n !this.gliding &&\n jumpEdge &&\n !this.onGround &&\n !this.inWater &&\n !this.flying &&\n this.vel.y < 1 &&\n this.canGlide?.()\n ) {\n this.gliding = true;\n this._glideAcc = 0;\n events.emit(\"glideStart\");\n }\n\n if (this.gliding) {\n // физика Minecraft по тикам; ракета толкает вдоль взгляда\n this._fallDist = 0;\n this._glideAcc += dt;\n while (this._glideAcc >= GLIDE_TICK) {\n this._glideAcc -= GLIDE_TICK;\n glideTick(this.vel, this.pitch, this.yaw, this._boostT > 0);\n this._boostT = Math.max(0, this._boostT - GLIDE_TICK);\n }\n this._wearT += dt;\n if (this._wearT >= 1) {\n this._wearT -= 1;\n if (this.mode !== \"creative\") this.onGlideWear?.();\n }\n } else if (this.flying) {\n // полёт: прямое управление скоростью, вертикаль на Space/Shift\n this.vel.x = wishX * speed;\n this.vel.z = wishZ * speed;\n this.vel.y = (input.jump ? speed : 0) + (input.sneakOrDown ? -speed : 0);\n if (input.uiOpen) this.vel.y = 0;\n } else if (this.inPowderSnow) {\n // снег-порох: вязнем — медленно; зажав прыжок, карабкаемся вверх, как по лестнице (в кожаных\n // ботинках быстрее). Без подъёма яма в 2 блока была ловушкой: холод добивал игрока насмерть.\n this.vel.x = wishX * speed * 0.35;\n this.vel.z = wishZ * speed * 0.35;\n this.vel.y =\n input.jump && !input.uiOpen\n ? this.leatherBoots?.()\n ? POWDER_CLIMB_BOOTS\n : POWDER_CLIMB\n : Math.max(-1.2, this.vel.y - P.gravity * dt);\n this._fallDist = 0;\n } else if (this.inWater) {\n const accel = 24 * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * 0.3 * dt; // ослабленная гравитация\n if (input.jump && !input.uiOpen) this.vel.y = P.swimUp; // гребок вверх\n this.vel.y *= 1 - P.waterDrag * dt; // сопротивление воды\n this._fallDist = 0;\n } else {\n const accel = (this.onGround ? 18 : 4) * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * dt;\n // Левитация (пуля панцирника): тянет вверх ~1 блок/с за уровень, падения не копит\n const lev = this.effects.get(\"levitation\");\n if (lev) {\n this.vel.y += (lev.level - this.vel.y) * Math.min(1, 5 * dt);\n this._fallDist = 0;\n }\n if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed + 1.6 * this.jumpBoost; // Прыгучесть — выше на ~полблока за уровень\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\n }\n // лестница: вверх — прыжок или «вперёд» в стену, присев — висим, иначе медленно сползаем\n if (this.onLadder) {\n this._fallDist = 0;\n const climb =\n !input.uiOpen && (input.jump || (input.forward && this._hitWall));\n if (climb) this.vel.y = P.climbSpeed;\n else if (this._sneaking) this.vel.y = Math.max(this.vel.y, 0);\n else this.vel.y = Math.max(this.vel.y, -P.climbSpeed);\n }\n }\n\n // интеграция с сабстепами — защита от туннелирования на лагающем кадре\n const steps = Math.max(1, Math.ceil((this.vel.length() * dt) / 0.4));\n const sdt = dt / steps;\n for (let s = 0; s < steps; s++) this._moveStep(sdt);\n\n // шаги (звук) + истощение от ходьбы/бега\n if (this.onGround && !this.inWater) {\n const dist = Math.hypot(this.vel.x, this.vel.z) * dt;\n this._stepDist += dist;\n if (this.mode === \"survival\")\n this.addExhaustion(\n dist * (this._sprinting ? P.exhaustSprint : P.exhaustWalk),\n );\n if (this._stepDist > 2.2) {\n this._stepDist = 0;\n events.emit(\"playerStep\");\n }\n }\n\n if (this.pos.y < -10) this._die(); // выпал из мира\n }\n\n /** Транспорт, в котором сидит игрок (вагонетка, лодка — entities/Rideable). */\n riding: Rideable | null = null;\n /** Игрок в снегу-порохе (вязнет; мёрзнет — systems/Freezing через Game). */\n inPowderSnow = false;\n /** Голова в снегу-порохе — белый туман вплотную (DayNight, среда \"powder_snow\"). */\n headInPowderSnow = false;\n /** Был ли в снегу-порохе прошлым кадром — для подскока на выходе (POWDER_HOP). */\n _wasInPowder = false;\n /** Хук Game: надеты ли кожаные ботинки — снег-порох держит, из него можно выпрыгнуть. */\n leatherBoots?: () => boolean;\n /** Надета ли хоть одна золотая вещь — свинорылы не нападают (задаёт Game). */\n goldArmor?: () => boolean;\n /** Прикрывается щитом (держит ПКМ со щитом — player/Interaction). */\n blocking = false;\n /** Щит принял удар: изнашивает щит в руке (задаёт Game). */\n onShieldBlock?: (amount: number) => void;\n\n /** Сесть в вагонетку или лодку. */\n mount(cart: Rideable): void {\n if (this.riding) this.dismount();\n this.riding = cart;\n cart.rider = true;\n }\n\n /** Выйти из вагонетки — встать над ней. */\n dismount(): void {\n const cart = this.riding;\n if (!cart) return;\n cart.rider = false;\n this.riding = null;\n this.pos.set(cart.pos.x, cart.pos.y + 1, cart.pos.z);\n this.vel.set(0, 0, 0);\n }\n\n /** Шаг интеграции: коллизии по коробкам форм (physics/Collision), шаг на ступень, край при приседании. */\n _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n const hv0 = Math.hypot(this.vel.x, this.vel.z);\n const r = moveBody(\n this.world,\n {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n hw: P.width / 2,\n h: P.height,\n },\n this.vel.x * dt,\n this.vel.y * dt,\n this.vel.z * dt,\n {\n step: this.onGround && !this.flying ? P.stepHeight : 0,\n sneak: this._sneaking,\n onGround: this.onGround,\n // в кожаных ботинках снег-порох держит (присев — проваливаешься, как в Minecraft)\n extraSolid:\n !this.inPowderSnow && !this._sneaking && this.leatherBoots?.()\n ? isPowderSnow\n : undefined,\n },\n );\n this.pos.x += r.dx;\n this.pos.y += r.dy;\n this.pos.z += r.dz;\n\n if (r.hitDown) {\n // на острый сталагмит больнее: урон вдвое и уже с 2 блоков (как в Minecraft)\n const fx = Math.floor(this.pos.x),\n fy = Math.floor(this.pos.y + 0.01),\n fz = Math.floor(this.pos.z);\n const spike =\n this.world.getBlock(fx, fy, fz) === B.pointed_dripstone ||\n this.world.getBlock(fx, fy - 1, fz) === B.pointed_dripstone;\n const safe = spike ? 2 : P.fallSafe + this.jumpBoost;\n if (wasAirborne && this._fallDist > safe && this.mode === \"survival\")\n this.damage(\n Math.round(this._fallDist - safe) * (spike ? 2 : 1),\n spike ? \"stalagmite\" : \"fall\",\n );\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else {\n this.onGround = false;\n if (r.hitUp) this.vel.y = 0; // потолок\n // снижение в полёте — не падение: выключив полёт в воздухе, получили бы урон за весь спуск\n if (this.flying) this._fallDist = 0;\n else if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n if (r.hitX) this.vel.x = 0;\n if (r.hitZ) this.vel.z = 0;\n this._hitWall = r.hitX || r.hitZ;\n // на крыльях врезался в стену: урон по потерянной скорости, как в Minecraft (блоки за тик)\n if (this.gliding && this._hitWall && this.mode === \"survival\") {\n const dmg = ((hv0 - Math.hypot(this.vel.x, this.vel.z)) / 20) * 10 - 3;\n if (dmg > 0) this.damage(dmg, \"wall\");\n }\n }\n\n /** Касается ли тело лестницы (клетки, которые пересекает AABB, чуть расширенный вбок). */\n _touchesLadder(): boolean {\n const half = P.width / 2 + 0.05;\n for (let y = Math.floor(this.pos.y); y <= Math.floor(this.pos.y + 1); y++)\n for (\n let z = Math.floor(this.pos.z - half);\n z <= Math.floor(this.pos.z + half);\n z++\n )\n for (\n let x = Math.floor(this.pos.x - half);\n x <= Math.floor(this.pos.x + half);\n x++\n ) {\n // лезут по лестнице и по строительным лесам (systems/Scaffold)\n const sh = BLOCKS[this.world.getBlock(x, y, z)]?.shape;\n if (sh === \"ladder\" || sh === \"scaffold\") return true;\n }\n return false;\n }\n\n /** Надетая броня (подключает Game из инвентаря): очки защиты и твёрдость. */\n armorStats:\n (() => { points: number; toughness: number; protection?: number }) | null =\n null;\n /** Удар по броне — износ (подключает Game). */\n onArmorHit: ((damage: number) => void) | null = null;\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n // Огнестойкость: огонь и лава не ранят\n if (\n (cause === \"fire\" || cause === \"lava\") &&\n this.effects.has(\"fire_resistance\")\n )\n return;\n this.lastCause = cause;\n // броня гасит удары, огонь, лаву и взрывы — формула Minecraft; падение и голод — нет\n if (ARMORED_CAUSES.has(cause) && this.armorStats) {\n const { points, toughness } = this.armorStats();\n if (points > 0) {\n this.onArmorHit?.(amount);\n amount = armorReduce(amount, points, toughness);\n }\n }\n // Защита: −4% за уровень по всей броне, не больше 80% (пустоту и голод не гасит)\n const prot = this.armorStats?.().protection ?? 0;\n if (prot > 0 && cause !== \"void\" && cause !== \"starve\")\n amount *= 1 - Math.min(20, prot) * 0.04;\n // Сопротивление (маяк): −20% за уровень (пустоту не гасит)\n const res = this.effects.get(\"resistance\")?.level ?? 0;\n if (res > 0 && cause !== \"void\") amount *= Math.max(0, 1 - 0.2 * res);\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) {\n // тотем жизни в руке: 1 здоровья, Регенерация II и Огнестойкость вместо смерти (пустоту не спасает)\n if (cause !== \"void\" && this.tryTotem?.()) {\n this.health = 1;\n this.clearEffects();\n this.addEffect(\"regeneration\", 45, 2);\n this.addEffect(\"fire_resistance\", 40);\n events.emit(\"totemUsed\");\n return;\n }\n this._die();\n }\n }\n\n addExhaustion(n: number): void {\n // В креативе голода нет и tickHunger истощение не списывает — оно копилось бы и при возврате\n // в выживание съедало еду пачкой за один тик.\n if (this.mode === \"creative\") return;\n this.exhaustion += n;\n }\n\n /**\n * Тик опасностей среды (симуляция), как в Minecraft: лава — 4 урона каждые полсекунды и поджигает\n * на 15 с, огонь — поджигает на 8 с; горящий теряет 1 в секунду, вода тушит.\n */\n tickHazards(dt: number): void {\n if (this.dead) return;\n this._tickEffects(dt);\n // воздух: голова под водой тратит запас (Подводное дыхание — нет), кончился — тонем\n if (\n this.headInWater &&\n !this.headInLava &&\n this.mode === \"survival\" &&\n !this.effects.has(\"water_breathing\")\n ) {\n this.air = Math.max(0, this.air - dt);\n if (this.air <= 0) {\n this._drownT -= dt;\n if (this._drownT <= 0) {\n this._drownT = 1;\n this.damage(2, \"drown\");\n }\n }\n } else {\n this.air = Math.min(MAX_AIR, this.air + dt * 5);\n this._drownT = 0;\n }\n // горящий костёр жжёт стоящего на нём: 1 урон в секунду (костёр душ — 2), но не поджигает\n this._campT = Math.max(0, this._campT - dt);\n const camp =\n B.campfire !== undefined && typeof this.world?.getBlock === \"function\"\n ? this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n : -1;\n const soulCamp = camp === B.soul_campfire;\n if (\n this._campT <= 0 &&\n (camp === B.campfire || soulCamp) &&\n !this.effects.has(\"fire_resistance\")\n ) {\n this._campT = 1;\n this.damage(soulCamp ? 2 : 1, \"fire\");\n }\n // кактус колет: 1 урон каждые полсекунды, пока касаешься\n this._cactusT = Math.max(0, this._cactusT - dt);\n if (this._cactusT <= 0 && this._touchesCactus()) {\n this._cactusT = 0.5;\n this.damage(1, \"cactus\");\n }\n // пустота под Пределом (и под миром): 4 урона каждые полсекунды, даже в креативе — как в Minecraft\n if (this.pos.y < -48) {\n this._voidT -= dt;\n if (this._voidT <= 0) {\n this._voidT = 0.5;\n const was = this.mode;\n this.mode = \"survival\";\n this.damage(4, \"void\");\n this.mode = was;\n }\n return;\n }\n if (this.inLava) {\n this._lavaT -= dt;\n if (this._lavaT <= 0) {\n this._lavaT = 0.5;\n this.damage(4, \"lava\");\n }\n this.fireT = 15;\n } else {\n this._lavaT = 0;\n if (this.inFire) this.fireT = Math.max(this.fireT, 8);\n else if (this.inWater) this.fireT = 0;\n }\n if (this.fireT > 0 && this.mode !== \"creative\") {\n this.fireT = Math.max(0, this.fireT - dt);\n this._burnT -= dt;\n if (this._burnT <= 0) {\n this._burnT = 1;\n this.damage(1, \"fire\");\n }\n } else {\n this.fireT = 0;\n this._burnT = 0;\n }\n }\n\n /** Тик голода (симуляция, 20 TPS): истощение → еда, регенерация, голодание. */\n tickHunger(dt: number): void {\n if (this.mode === \"creative\" || this.dead) return;\n this.addExhaustion(P.exhaustIdle * dt); // медленная пассивная трата\n while (this.exhaustion >= P.exhaustionPerFood) {\n this.exhaustion -= P.exhaustionPerFood;\n if (this.saturation > 0)\n this.saturation = Math.max(0, this.saturation - 1);\n else this.food = Math.max(0, this.food - 1);\n }\n\n // сытость лечит; лечение «стоит» истощения\n if (this.food >= P.regenFoodThreshold && this.health < P.maxHealth) {\n this._regenTimer += dt;\n if (this._regenTimer >= P.regenInterval) {\n this._regenTimer = 0;\n this.health = Math.min(P.maxHealth, this.health + 1);\n this.addExhaustion(P.exhaustRegen);\n events.emit(\"playerHeal\", { health: this.health });\n }\n } else this._regenTimer = 0;\n\n // голодание: до какого здоровья добивает — по сложности (systems/Difficulty);\n // на мирной голода нет вовсе, на высокой голод убивает\n const floor = rules().starveFloor;\n if (this.food <= 0 && floor !== null) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > floor) this.damage(1, \"starve\");\n }\n } else this._starveTimer = 0;\n\n events.emit(\"foodChanged\", { food: this.food });\n }\n\n /** Съесть предмет (если это еда и есть куда). @returns {boolean} успех */\n eat(itemName: string): boolean {\n const it = ITEMS[itemName];\n // плод эхоцвета едят и сытым — ради переноса\n if (!it?.food || (this.food >= P.maxFood && itemName !== \"echo_fruit\"))\n return false;\n this.food = Math.min(P.maxFood, this.food + it.food);\n this.saturation = Math.min(\n this.food,\n this.saturation + (it.saturation ?? it.food * 0.6),\n );\n for (const e of it.eatEffects ?? [])\n this.addEffect(e.id, e.seconds, e.level ?? 1);\n events.emit(\"playerAte\", { item: itemName });\n if (itemName === \"echo_fruit\") this.echoHop();\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n /** Множитель скорости ходьбы: +20% за уровень Скорости и −15% за уровень Замедления, как в Minecraft. */\n get speedFactor(): number {\n const slow = this.effects.get(\"slowness\")?.level ?? 0;\n return (\n (1 + 0.2 * (this.effects.get(\"speed\")?.level ?? 0)) *\n Math.max(0, 1 - 0.15 * slow) *\n (this.blocking ? 0.3 : 1) // прикрываясь щитом, идёшь медленно\n );\n }\n\n /**\n * Щит, как в Minecraft: прикрылся, а удар или снаряд пришёл спереди (из полусферы взгляда) — урона нет,\n * щит изнашивается. @returns true — принял щит (урон и эффекты удара не наносить)\n */\n shieldBlocks(from: { x: number; z: number }, amount: number): boolean {\n if (!this.blocking || this.dead || this.mode === \"creative\") return false;\n const look = this.lookDir();\n if (look.x * (from.x - this.pos.x) + look.z * (from.z - this.pos.z) <= 0)\n return false;\n this.onShieldBlock?.(amount);\n events.emit(\"shieldBlock\", { amount });\n return true;\n }\n\n /** Ракета в полёте на крыльях: рывок на 1.2 с. false — не летим (ракета не тратится). */\n boost(): boolean {\n if (!this.gliding) return false;\n this._boostT = 1.2;\n return true;\n }\n\n /** Плод эхоцвета: прыжок в случайную точку до 8 блоков, где есть пол и место на рост. */\n echoHop(): boolean {\n for (let i = 0; i < 16; i++) {\n const x = Math.floor(this.pos.x + (Math.random() - 0.5) * 16),\n z = Math.floor(this.pos.z + (Math.random() - 0.5) * 16),\n y0 = Math.floor(this.pos.y + (Math.random() - 0.5) * 16);\n for (let y = Math.min(y0, 126); y > y0 - 16 && y > 1; y--) {\n if (!BLOCKS[this.world.getBlock(x, y - 1, z)]?.solid) continue;\n if (\n this.world.getBlock(x, y, z) !== 0 ||\n this.world.getBlock(x, y + 1, z) !== 0\n )\n break;\n this.pos.set(x + 0.5, y, z + 0.5);\n this.vel.set(0, 0, 0);\n this._fallDist = 0;\n events.emit(\"echoHop\", { x, y, z });\n return true;\n }\n }\n return false;\n }\n\n /** Уровень Прыгучести: прыжок выше, безопасное падение длиннее на столько блоков. */\n get jumpBoost(): number {\n return this.effects.get(\"jump_boost\")?.level ?? 0;\n }\n\n /** Наложить эффект: остаётся более сильный уровень и более долгий срок. */\n addEffect(id: EffectId, seconds: number, level = 1): void {\n const cur = this.effects.get(id);\n this.effects.set(id, {\n level: Math.max(level, cur?.level ?? 0),\n time: Math.max(seconds, cur?.time ?? 0),\n });\n events.emit(\"effectsChanged\", { id });\n }\n\n clearEffects(): void {\n this.effects.clear();\n events.emit(\"effectsChanged\", {});\n }\n\n /** Выпить: зелье накладывает эффект, молоко снимает все. */\n drink(d: DrinkDef): void {\n if (d.clearsEffects) this.clearEffects();\n if (d.heal) this.health = Math.min(P.maxHealth, this.health + d.heal);\n if (d.damage) this.damage(d.damage, \"magic\");\n if (d.food) this.food = Math.min(P.maxFood, this.food + d.food);\n if (d.effect) this.addEffect(d.effect, d.seconds ?? 60, d.level ?? 1);\n events.emit(\"playerDrank\", { effect: d.effect ?? null });\n }\n\n /** Сроки эффектов; Регенерация лечит 1 здоровье за 2.5 с (вдвое чаще за уровень). */\n _tickEffects(dt: number): void {\n for (const [id, e] of this.effects) {\n e.time -= dt;\n if (e.time <= 0) {\n this.effects.delete(id);\n events.emit(\"effectsChanged\", { id });\n }\n }\n // Тление: 1 урона раз в 2 с (вдвое чаще за уровень), убивает — в отличие от голода\n const decay = this.effects.get(\"decay\");\n if (decay) {\n this._decayT += dt;\n if (this._decayT >= 2 / decay.level) {\n this._decayT = 0;\n this.damage(1, \"decay\");\n }\n } else this._decayT = 0;\n // Отравление: 1 урона раз в 1.25 с (вдвое чаще за уровень), но не убивает — оставляет 1 здоровья\n const poison = this.effects.get(\"poison\");\n if (poison) {\n this._poisonT += dt;\n if (this._poisonT >= 1.25 / poison.level) {\n this._poisonT = 0;\n if (this.health > 1)\n this.damage(Math.min(1, this.health - 1), \"poison\");\n }\n } else this._poisonT = 0;\n // Голод (удар кадавра): истощение 0.1 в секунду за уровень — сытость тает быстрее\n const hunger = this.effects.get(\"hunger\");\n if (hunger) this.addExhaustion(0.1 * hunger.level * dt);\n const regen = this.effects.get(\"regeneration\");\n if (!regen) return;\n this._effectRegenT += dt;\n const every = 2.5 / 2 ** (regen.level - 1);\n if (this._effectRegenT >= every) {\n this._effectRegenT = 0;\n if (this.health < P.maxHealth)\n this.health = Math.min(P.maxHealth, this.health + 1);\n }\n }\n\n /** Смерть: экран «Вы погибли» и выпадение вещей решает Game (подписан на playerDied). */\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n this.fireT = 0;\n this.air = MAX_AIR;\n this.vel.set(0, 0, 0);\n // из транспорта — вон: иначе после возрождения update() ставил игрока обратно к вагонетке/лодке/\n // лошади на месте смерти\n if (this.riding) {\n this.riding.rider = false;\n this.riding = null;\n }\n this.gliding = false;\n this._boostT = 0;\n // эффекты зелий смерть снимает, как в Minecraft (яд и тление не должны пережить возрождение)\n if (this.effects.size) this.clearEffects();\n events.emit(\"playerDied\", { cause: this.lastCause });\n }\n\n /** Возродиться в точке (кровать или точка мира) с полным здоровьем и сытостью. */\n respawn(at: THREE.Vector3): void {\n clearTimeout(this._respawnT);\n this.pos.copy(at);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.fireT = 0;\n this._fallDist = 0;\n this.dead = false;\n events.emit(\"playerRespawn\");\n }\n\n /**\n * Новый мир — новый игрок: всё, что принадлежит старому миру (кровать, якорь, путевой камень,\n * репутация деревни) и телу в нём (эффекты, горение, воздух, опыт, полёт на крыльях), не переносится.\n * Режим игры и взгляд остаются. Место и здоровье ставит следом spawn().\n */\n resetForNewWorld(): void {\n this.riding = null;\n this.gliding = false;\n this._boostT = 0;\n this.effects.clear();\n this.fireT = 0;\n this._burnT = 0;\n this.air = MAX_AIR;\n this._drownT = 0;\n this._fallDist = 0;\n this.bedPos = null;\n this.anchorPos = null;\n this.anchorDim = null;\n this.lodestone = null;\n this.villageRep = 0;\n this.xpLevel = this.xpProgress = this.xpTotal = 0;\n this._xpFrac = 0;\n this.inPowderSnow = this.headInPowderSnow = this._wasInPowder = false;\n events.emit(\"effectsChanged\", {});\n events.emit(\"xpChanged\");\n }\n\n /** Пересекается ли AABB игрока с блоком (запрет постановки «в себя»). */\n intersectsBlock(bx: number, by: number, bz: number): boolean {\n const half = P.width / 2;\n return (\n bx + 1 > this.pos.x - half &&\n bx < this.pos.x + half &&\n by + 1 > this.pos.y &&\n by < this.pos.y + P.height &&\n bz + 1 > this.pos.z - half &&\n bz < this.pos.z + half\n );\n }\n\n /** Teardown: отложенный респаун не должен сработать после уничтожения игры. */\n dispose(): void {\n clearTimeout(this._respawnT);\n }\n\n serialize() {\n return {\n pos: this.pos.toArray(),\n yaw: this.yaw,\n pitch: this.pitch,\n health: this.health,\n food: this.food,\n saturation: this.saturation,\n mode: this.mode,\n flying: this.flying,\n spawn: this.spawnPoint.toArray(),\n xpLevel: this.xpLevel,\n xpProgress: this.xpProgress,\n xpTotal: this.xpTotal,\n bedPos: this.bedPos,\n anchorPos: this.anchorPos,\n villageRep: this.villageRep,\n anchorDim: this.anchorDim,\n lodestone: this.lodestone,\n effects: [...this.effects].map(([id, e]) => ({\n id,\n level: e.level,\n time: e.time,\n })),\n enchSeed: this.enchSeed,\n };\n }\n deserialize(d: PlayerSaveData): void {\n // позиция из чужого файла: NaN в координатах — это чёрный экран и мир, который не грузится\n if (\n Array.isArray(d.pos) &&\n d.pos.length === 3 &&\n d.pos.every((n) => Number.isFinite(n))\n )\n this.pos.fromArray(d.pos);\n this.yaw = Number.isFinite(d.yaw) ? d.yaw : 0;\n this.pitch = Number.isFinite(d.pitch) ? d.pitch : 0;\n // состояние тела прошлого мира не переносится: горение, воздух, крылья, транспорт, падение\n this.riding = null;\n this.gliding = false;\n this._boostT = 0;\n this.fireT = 0;\n this._burnT = 0;\n this.air = MAX_AIR;\n this._drownT = 0;\n this._fallDist = 0;\n this.inPowderSnow = this.headInPowderSnow = this._wasInPowder = false;\n // сейв, записанный на экране смерти: здоровье 0 при dead=false убило бы первым же уроном\n this.health =\n Number.isFinite(d.health) && d.health > 0\n ? Math.min(P.maxHealth, d.health)\n : P.maxHealth;\n this.mode = d.mode === \"creative\" ? \"creative\" : \"survival\";\n this.flying = d.flying;\n this.food = d.food ?? P.maxFood;\n this.saturation = d.saturation ?? 5;\n this.exhaustion = 0;\n this.vel.set(0, 0, 0);\n if (Array.isArray(d.spawn) && d.spawn.length === 3)\n this.spawnPoint.fromArray(d.spawn);\n else this.spawnPoint.copy(this.pos);\n this.xpLevel = d.xpLevel ?? 0;\n this.xpProgress = d.xpProgress ?? 0;\n this.xpTotal = d.xpTotal ?? 0;\n const cell = (v: unknown): [number, number, number] | null =>\n Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n))\n ? [Number(v[0]), Number(v[1]), Number(v[2])]\n : null;\n this.anchorPos = cell(d.anchorPos);\n this.villageRep = clampRep(Number(d.villageRep) || 0);\n this.anchorDim = typeof d.anchorDim === \"string\" ? d.anchorDim : null;\n this.lodestone =\n d.lodestone && typeof d.lodestone === \"object\"\n ? { ...d.lodestone }\n : null;\n this.bedPos =\n Array.isArray(d.bedPos) && d.bedPos.length === 3\n ? [d.bedPos[0]!, d.bedPos[1]!, d.bedPos[2]!]\n : null;\n this.dead = false;\n if (typeof d.enchSeed === \"number\") this.enchSeed = d.enchSeed;\n this.effects.clear();\n for (const e of Array.isArray(d.effects) ? d.effects : [])\n if (EFFECT_IDS.includes(e.id as EffectId) && e.time > 0)\n this.effects.set(e.id as EffectId, {\n level: e.level || 1,\n time: e.time,\n });\n }\n}\n"
684
+ },
685
+ {
686
+ "path": "player/PowderSnow.test.ts",
687
+ "content": "import * as THREE from \"three\";\nimport { describe, expect, it } from \"vitest\";\nimport { B } from \"../registry/Blocks\";\nimport { Chunk } from \"../world/Chunk\";\nimport { ChunkManager } from \"../world/ChunkManager\";\nimport { findSafeSpot, isStandable } from \"../world/safeSpot\";\nimport { EntityManager } from \"../entities/EntityManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { Input } from \"./Input\";\nimport { Player } from \"./Player\";\n\n/** Снежное поле: камень на y=4, снег на y=5..6 (верх — y=7) и яма снега-пороха в 2 блока в (8, 8). */\nfunction world(): ChunkManager {\n const w = new ChunkManager(new THREE.Scene(), {} as TextureAtlas, {\n kind: \"noise\",\n seed: 1,\n });\n const c = new Chunk(0, 0);\n c.generated = true;\n for (let x = 0; x < 16; x++)\n for (let z = 0; z < 16; z++) {\n c.setRaw(x, 4, z, B.stone!);\n c.setRaw(x, 5, z, B.snow!);\n c.setRaw(x, 6, z, B.snow!);\n }\n c.setRaw(8, 5, 8, B.powder_snow!);\n c.setRaw(8, 6, 8, B.powder_snow!);\n w.chunks.set(\"0,0\", c);\n return w;\n}\n\nfunction input(keys: { jump?: boolean; forward?: boolean }): Input {\n return {\n consumeMouse: () => ({ dx: 0, dy: 0 }),\n forward: !!keys.forward,\n back: false,\n left: false,\n right: false,\n jump: !!keys.jump,\n sneak: false,\n sneakOrDown: false,\n run: false,\n uiOpen: false,\n } as unknown as Input;\n}\n\n/** Игрок на дне ямы, взгляд на север (−z): впереди — край из обычного снега. */\nfunction inPit(w: ChunkManager): Player {\n const p = new Player(w);\n p.pos.set(8.5, 5, 8.5);\n p.yaw = 0;\n return p;\n}\n\nfunction run(p: Player, inp: Input, seconds: number): void {\n for (let t = 0; t < seconds; t += 1 / 60) p.update(1 / 60, inp);\n}\n\ndescribe(\"снег-порох: не ловушка\", () => {\n it(\"без прыжка игрок вязнет на дне ямы\", () => {\n const p = inPit(world());\n run(p, input({ forward: true }), 3);\n expect(p.inPowderSnow).toBe(true);\n expect(p.pos.y).toBeLessThan(5.1);\n });\n\n it(\"без кожаных ботинок, зажав прыжок и «вперёд», выбирается на край\", () => {\n const p = inPit(world());\n run(p, input({ jump: true, forward: true }), 5);\n expect(p.inPowderSnow).toBe(false);\n expect(p.pos.z).toBeLessThan(8);\n expect(p.pos.y).toBeGreaterThanOrEqual(7 - 1e-6);\n });\n\n it(\"в кожаных ботинках — тоже (раньше на кромке качало вверх-вниз без выхода)\", () => {\n const p = inPit(world());\n p.leatherBoots = () => true;\n run(p, input({ jump: true, forward: true }), 5);\n expect(p.inPowderSnow).toBe(false);\n expect(p.pos.z).toBeLessThan(8);\n expect(p.pos.y).toBeGreaterThanOrEqual(7 - 1e-6);\n });\n});\n\ndescribe(\"безопасная точка\", () => {\n it(\"дно ямы снега-пороха и её верх — не место для спавна, соседняя клетка — место\", () => {\n const w = world();\n expect(isStandable(w, 8, 5, 8)).toBe(false); // на дне ямы\n expect(isStandable(w, 8, 7, 8)).toBe(false); // «поверх» — это верх сугроба, провалишься\n expect(isStandable(w, 7, 7, 8)).toBe(true);\n const s = findSafeSpot(w, 8, 8);\n expect(s).not.toBeNull();\n expect(s!.y).toBe(7);\n expect(s!.x !== 8 || s!.z !== 8).toBe(true);\n });\n\n it(\"лава — не место\", () => {\n const w = world();\n w.setBlock(3, 7, 3, B.lava!);\n expect(isStandable(w, 3, 7, 3)).toBe(false);\n });\n});\n\ndescribe(\"ездок-призрак\", () => {\n it(\"очистка сущностей (портал, новый мир, загрузка) ссаживает игрока\", () => {\n const w = world();\n const p = new Player(w);\n const inv = { heldItem: () => null } as unknown as Inventory;\n const em = new EntityManager(\n new THREE.Scene(),\n w,\n {} as TextureAtlas,\n p,\n inv,\n );\n const cart = em.spawnMinecart(3.5, 7, 3.5);\n p.mount(cart);\n expect(p.riding).toBe(cart);\n em.clear();\n expect(p.riding).toBeNull();\n expect(cart.rider).toBe(false);\n });\n});\n"
684
688
  },
685
689
  {
686
690
  "path": "player/Shield.test.ts",
@@ -688,7 +692,7 @@
688
692
  },
689
693
  {
690
694
  "path": "registry/aliases.ts",
691
- "content": "// Синонимы имён: Minecraft-имена (их знают игроки и ИИ-модели) → id этой игры. Нужны командам\n// (/give grass_block) и разбору чужих данных. Названия в интерфейсе — отдельно, в names.ts.\nimport { B } from \"./Blocks\";\nimport { ITEMS } from \"./Items\";\n\nexport const ALIASES: Record<string, string> = {\n grass_block: \"grass\",\n short_grass: \"tallgrass\",\n tall_grass: \"tallgrass\",\n dandelion: \"flower_yellow\",\n poppy: \"flower_red\",\n oak_planks: \"planks\",\n oak_leaves: \"leaves\",\n dark_oak_planks: \"dark_planks\",\n snow_block: \"snow\",\n stone_brick: \"stone_bricks\",\n brick_block: \"bricks\",\n wall_torch: \"torch\",\n // Minecraft-имена, которым здесь дано своё название\n netherite_ingot: \"infernite_ingot\",\n netherite_scrap: \"infernite_scrap\",\n netherite_pickaxe: \"infernite_pickaxe\",\n netherite_axe: \"infernite_axe\",\n netherite_shovel: \"infernite_shovel\",\n netherite_hoe: \"infernite_hoe\",\n // Пекло\n netherrack: \"infernal_stone\",\n nether_portal: \"inferno_portal\",\n nether_quartz_ore: \"quartz_ore\",\n nether_bricks: \"inferno_bricks\",\n nether_brick_fence: \"inferno_brick_fence\",\n nether_brick_stairs: \"inferno_brick_stairs\",\n nether_brick: \"inferno_brick\",\n // Предел\n end_portal_frame: \"rim_frame\",\n end_portal: \"beyond_portal\",\n end_stone: \"beyond_stone\",\n ender_eye: \"rim_eye\",\n eye_of_ender: \"rim_eye\",\n blaze_rod: \"flame_rod\",\n blaze_powder: \"flame_powder\",\n ghast_tear: \"wailer_tear\",\n nether_wart: \"inferno_wart\",\n netherite_block: \"infernite_block\",\n reeds: \"sugar_cane\",\n nether_wart_block: \"inferno_wart_block\",\n glistering_melon_slice: \"glistering_melon\",\n netherite_sword: \"infernite_sword\",\n netherite_helmet: \"infernite_helmet\",\n netherite_chestplate: \"infernite_chestplate\",\n netherite_leggings: \"infernite_leggings\",\n netherite_boots: \"infernite_boots\",\n // иссушитель и его добыча\n wither_skeleton_skull: \"ash_skull\",\n nether_star: \"inferno_star\",\n wither_skeleton: \"ash_skeleton\",\n wither: \"ash_lord\",\n // океан\n prismarine: \"sea_stone\",\n prismarine_bricks: \"sea_bricks\",\n dark_prismarine: \"dark_sea_stone\",\n prismarine_shard: \"sea_shard\",\n prismarine_crystals: \"sea_crystals\",\n turtle_scute: \"scute\",\n guardian: \"deepling\",\n elder_guardian: \"elder_deepling\",\n // разбойники\n pillager: \"marauder\",\n vindicator: \"axeman\",\n evoker: \"conjurer\",\n vex: \"wisp\",\n endermite: \"rim_mite\",\n ravager: \"crusher\",\n totem_of_undying: \"life_totem\",\n // сырая крольчатина в Minecraft — просто rabbit (здесь так зовут самого кролика)\n rabbit: \"raw_rabbit\",\n};\n\n/** Имя без префикса `minecraft:` и в нижнем регистре. */\nconst clean = (name: string): string =>\n name\n .trim()\n .toLowerCase()\n .replace(/^minecraft:/, \"\");\n\n/** id предмета по имени или синониму; null — такого нет. */\nexport function resolveItem(name: string): string | null {\n const n = clean(name);\n if (ITEMS[n]) return n;\n const a = ALIASES[n];\n return a && ITEMS[a] ? a : null;\n}\n\n/** id блока по имени или синониму; null — такого нет. */\nexport function resolveBlock(name: string): number | null {\n const n = clean(name);\n return B[n] ?? B[ALIASES[n] ?? \"\"] ?? null;\n}\n"
695
+ "content": "// Синонимы имён: Minecraft-имена (их знают игроки и ИИ-модели) → id этой игры. Нужны командам\n// (/give grass_block) и разбору чужих данных. Названия в интерфейсе — отдельно, в names.ts.\nimport { B } from \"./Blocks\";\nimport { ITEMS } from \"./Items\";\n\nexport const ALIASES: Record<string, string> = {\n grass_block: \"grass\",\n short_grass: \"tallgrass\",\n tall_grass: \"tallgrass\",\n dandelion: \"flower_yellow\",\n poppy: \"flower_red\",\n oak_planks: \"planks\",\n oak_leaves: \"leaves\",\n dark_oak_planks: \"dark_planks\",\n snow_block: \"snow\",\n stone_brick: \"stone_bricks\",\n brick_block: \"bricks\",\n wall_torch: \"torch\",\n // Minecraft-имена, которым здесь дано своё название\n netherite_ingot: \"infernite_ingot\",\n netherite_scrap: \"infernite_scrap\",\n netherite_pickaxe: \"infernite_pickaxe\",\n netherite_axe: \"infernite_axe\",\n netherite_shovel: \"infernite_shovel\",\n netherite_hoe: \"infernite_hoe\",\n // Пекло\n netherrack: \"infernal_stone\",\n nether_portal: \"inferno_portal\",\n nether_quartz_ore: \"quartz_ore\",\n nether_bricks: \"inferno_bricks\",\n nether_brick_fence: \"inferno_brick_fence\",\n nether_brick_stairs: \"inferno_brick_stairs\",\n nether_brick: \"inferno_brick\",\n // Предел\n end_portal_frame: \"rim_frame\",\n end_portal: \"beyond_portal\",\n end_stone: \"beyond_stone\",\n ender_eye: \"rim_eye\",\n eye_of_ender: \"rim_eye\",\n blaze_rod: \"flame_rod\",\n blaze_powder: \"flame_powder\",\n ghast_tear: \"wailer_tear\",\n nether_wart: \"inferno_wart\",\n netherite_block: \"infernite_block\",\n reeds: \"sugar_cane\",\n nether_wart_block: \"inferno_wart_block\",\n glistering_melon_slice: \"glistering_melon\",\n netherite_sword: \"infernite_sword\",\n netherite_helmet: \"infernite_helmet\",\n netherite_chestplate: \"infernite_chestplate\",\n netherite_leggings: \"infernite_leggings\",\n netherite_boots: \"infernite_boots\",\n // иссушитель и его добыча\n wither_skeleton_skull: \"ash_skull\",\n nether_star: \"inferno_star\",\n wither_skeleton: \"ash_skeleton\",\n wither: \"ash_lord\",\n // океан\n prismarine: \"sea_stone\",\n prismarine_bricks: \"sea_bricks\",\n dark_prismarine: \"dark_sea_stone\",\n prismarine_shard: \"sea_shard\",\n prismarine_crystals: \"sea_crystals\",\n turtle_scute: \"scute\",\n guardian: \"deepling\",\n elder_guardian: \"elder_deepling\",\n // разбойники\n pillager: \"marauder\",\n vindicator: \"axeman\",\n evoker: \"conjurer\",\n vex: \"wisp\",\n endermite: \"rim_mite\",\n ravager: \"crusher\",\n totem_of_undying: \"life_totem\",\n // мобы, которым здесь дано своё имя: /summon creeper раньше отвечал «нет такого существа»\n creeper: \"bomber\",\n enderman: \"stalker\",\n ghast: \"wailer\",\n blaze: \"flamer\",\n piglin: \"hogman\",\n piglin_brute: \"hogman_brute\",\n zombified_piglin: \"hogman_dead\",\n ender_dragon: \"void_dragon\",\n end_crystal: \"beyond_crystal\",\n shulker: \"shellkeeper\",\n // сырая крольчатина в Minecraft — просто rabbit (здесь так зовут самого кролика)\n rabbit: \"raw_rabbit\",\n};\n\n/** Имя без префикса `minecraft:` и в нижнем регистре. */\nconst clean = (name: string): string =>\n name\n .trim()\n .toLowerCase()\n .replace(/^minecraft:/, \"\");\n\n/** id предмета по имени или синониму; null — такого нет. */\nexport function resolveItem(name: string): string | null {\n const n = clean(name);\n if (ITEMS[n]) return n;\n const a = ALIASES[n];\n return a && ITEMS[a] ? a : null;\n}\n\n/** id блока по имени или синониму; null — такого нет. */\nexport function resolveBlock(name: string): number | null {\n const n = clean(name);\n return B[n] ?? B[ALIASES[n] ?? \"\"] ?? null;\n}\n"
692
696
  },
693
697
  {
694
698
  "path": "registry/Blocks.ts",
@@ -836,7 +840,7 @@
836
840
  },
837
841
  {
838
842
  "path": "registry/blocks/mountain.ts",
839
- "content": "// Горы и вишнёвые рощи: снег-порох (не твёрдый — в нём вязнешь и мёрзнешь, в кожаных ботинках держит:\n// Player, physics/Collision, systems/Freezing; набирается ведром) и розовые лепестки под вишнями.\nimport type { BlockDef } from \"../../types\";\nimport { addNames } from \"../names\";\nimport { all, block } from \"./define\";\n\nexport const MOUNTAIN_BLOCKS: BlockDef[] = [\n block({\n name: \"powder_snow\",\n solid: false,\n hardness: 0.25,\n tool: \"shovel\",\n drops: [],\n tex: all(\"powder_snow\"),\n mapColor: 0xf4f8fc,\n }),\n block({\n name: \"pink_petals\",\n shape: \"carpet\",\n solid: false,\n opaque: false,\n hardness: 0,\n flammable: true,\n tex: all(\"pink_petals\"),\n mapColor: 0xf0a8c8,\n }),\n];\n\naddNames({\n powder_snow: \"Снег-порох\",\n pink_petals: \"Розовые лепестки\",\n cherry_grove: \"Вишнёвая роща\",\n});\n"
843
+ "content": "// Горы и вишнёвые рощи: снег-порох (не твёрдый — в нём вязнешь и мёрзнешь, в кожаных ботинках держит:\n// Player, physics/Collision, systems/Freezing; набирается ведром) и розовые лепестки под вишнями.\nimport type { BlockDef } from \"../../types\";\nimport { addNames } from \"../names\";\nimport { all, block } from \"./define\";\n\nexport const MOUNTAIN_BLOCKS: BlockDef[] = [\n block({\n name: \"powder_snow\",\n solid: false,\n // не «непрозрачный»: иначе грани соседей к нему отсекаются и изнутри видно сквозь землю;\n // свет гасит, как и раньше\n opaque: false,\n filter: 15,\n hardness: 0.25,\n tool: \"shovel\",\n drops: [],\n tex: all(\"powder_snow\"),\n mapColor: 0xf4f8fc,\n }),\n block({\n name: \"pink_petals\",\n shape: \"carpet\",\n solid: false,\n opaque: false,\n hardness: 0,\n flammable: true,\n tex: all(\"pink_petals\"),\n mapColor: 0xf0a8c8,\n }),\n];\n\naddNames({\n powder_snow: \"Снег-порох\",\n pink_petals: \"Розовые лепестки\",\n cherry_grove: \"Вишнёвая роща\",\n});\n"
840
844
  },
841
845
  {
842
846
  "path": "registry/blocks/mushroom.ts",
@@ -1332,15 +1336,15 @@
1332
1336
  },
1333
1337
  {
1334
1338
  "path": "systems/Commands.ts",
1335
- "content": "// Команды консоли, как в Minecraft (/give, /tp, /time…). Текст приходит интентом Command (из\n// консоли игрока или от агента AI-кодера) и исполняется на тике — тем же путём, что и остальной\n// ввод. Выключаются целиком CONFIG.commands.enabled (издатель может закрыть читы в своей игре).\nimport { CONFIG } from \"../config\";\nimport {\n DIFFICULTY_NAMES,\n parseDifficulty,\n rules,\n setDifficulty,\n} from \"./Difficulty\";\nimport { events } from \"../core/EventBus\";\nimport { ALIASES, resolveBlock, resolveItem } from \"../registry/aliases\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { ITEMS } from \"../registry/Items\";\nimport { ENCHANTS, enchantLine, withEnchants, type EnchantId } from \"./Enchant\";\nimport { EFFECT_NAMES, labelOf } from \"../registry/names\";\nimport { startCharge } from \"../entities/AshLordBrain\";\nimport { ACHIEVEMENTS } from \"./Achievements\";\nimport type { Game } from \"../main\";\nimport type { EffectId } from \"../types\";\n\ntype Handler = (game: Game, args: string[]) => string;\n\n/** Координата: число или `~`/`~N` относительно игрока. */\nfunction coord(arg: string | undefined, base: number): number {\n if (arg === undefined) throw new Error(\"не хватает координат\");\n if (arg.startsWith(\"~\"))\n return base + (arg.length > 1 ? Number(arg.slice(1)) : 0);\n const v = Number(arg);\n if (!Number.isFinite(v)) throw new Error(\"не число: \" + arg);\n return v;\n}\n\n/** Три координаты начиная с args[i] (целые — для блоков). */\nfunction xyz(\n game: Game,\n args: string[],\n i: number,\n whole: boolean,\n): [number, number, number] {\n const p = game.player.pos;\n const r = [\n coord(args[i], p.x),\n coord(args[i + 1], p.y),\n coord(args[i + 2], p.z),\n ] as [number, number, number];\n return whole ? (r.map(Math.floor) as [number, number, number]) : r;\n}\n\nfunction blockArg(name: string | undefined): number {\n const id = name === undefined ? null : resolveBlock(name);\n if (id === null) throw new Error(\"нет такого блока: \" + name);\n return id;\n}\n\n/** Мета по умолчанию: жидкость ставится источником (как в Minecraft), иначе 0 — вода с метой 0 утекает. */\nconst defaultMeta = (id: number): number =>\n id === B.water || id === B.lava ? packWater(8, true) : 0;\n\nconst TIMES: Record<string, (d: number, n: number) => number> = {\n day: (d) => d * 0.3,\n noon: (d) => d * 0.5,\n sunset: (d) => d * 0.94,\n night: (d, n) => d + n * 0.3,\n midnight: (d, n) => d + n * 0.5,\n};\n\nconst HANDLERS: Record<string, Handler> = {\n help: () =>\n \"Команды: /give <предмет> [n], /tp x y z, /time set day|noon|night|midnight|<сек>, /weather clear|rain|thunder [сек], \" +\n \"/difficulty мирная|лёгкая|обычная|высокая, /gamemode survival|creative, /setblock x y z <блок> [мета], /fill x1 y1 z1 x2 y2 z2 <блок>, \" +\n \"/summon <моб> [x y z], /kill [моб], /effect <эффект>|clear [сек] [уровень], /achievements, /music on|off|next, /clear, /seed, /locate fortress|citadel|village|outpost|temple|shipwreck|buried_treasure|mansion|ancient_city|trail_ruins|trial_chambers|city, /enchant <зачарование> [уровень], /newworld [сид]. Координаты можно с ~ (относительно себя).\",\n\n give: (g, [name, n]) => {\n const item = name === undefined ? null : resolveItem(name);\n if (!item) throw new Error(\"нет такого предмета: \" + name);\n const count = Math.max(\n 1,\n Math.min(64 * 36, Math.floor(Number(n ?? 1)) || 1),\n );\n const left = g.inventory.add(item, count);\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(p.x, p.y + 1, p.z, item, left);\n }\n return `Выдано: ${labelOf(item)} ×${count}`;\n },\n\n tp: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, false);\n g.warmup(x, z);\n if (g.player.riding) g.player.dismount(); // телепорт высаживает из транспорта, как в Minecraft\n g.player.pos.set(x, y, z);\n g.player.vel.set(0, 0, 0);\n g.player._fallDist = 0;\n return `Телепорт: ${x.toFixed(1)} ${y.toFixed(1)} ${z.toFixed(1)}`;\n },\n\n time: (g, [op, v]) => {\n const { dayLength, nightLength } = CONFIG.dayNight;\n const cycle = dayLength + nightLength;\n if (op === \"set\" && v !== undefined) {\n const named = TIMES[v];\n const t = named ? named(dayLength, nightLength) : Number(v);\n if (!Number.isFinite(t))\n throw new Error(\"время: day, noon, night, midnight или секунды\");\n g.dayNight.time = ((t % cycle) + cycle) % cycle;\n } else if (op === \"add\" && v !== undefined) {\n g.dayNight.time =\n (((g.dayNight.time + Number(v)) % cycle) + cycle) % cycle;\n } else throw new Error(\"/time set <день|ночь|сек> или /time add <сек>\");\n return \"Время: \" + Math.round(g.dayNight.time) + \" c\";\n },\n\n weather: (g, [k, sec]) => {\n if (k !== \"clear\" && k !== \"rain\" && k !== \"thunder\")\n throw new Error(\"/weather clear|rain|thunder [сек]\");\n const s = sec === undefined ? undefined : Number(sec);\n if (s !== undefined && !(s > 0)) throw new Error(\"не число: \" + sec);\n g.weather.set(k, s);\n return (\n \"Погода: \" + (k === \"clear\" ? \"ясно\" : k === \"rain\" ? \"дождь\" : \"гроза\")\n );\n },\n\n gamemode: (g, [m]) => {\n const mode =\n m === \"creative\" || m === \"1\" || m === \"c\"\n ? \"creative\"\n : m === \"survival\" || m === \"0\" || m === \"s\"\n ? \"survival\"\n : null;\n if (!mode) throw new Error(\"/gamemode survival|creative\");\n if (g.player.mode !== mode) g.player.toggleMode();\n return \"Режим: \" + (mode === \"creative\" ? \"креатив\" : \"выживание\");\n },\n\n difficulty: (g, [d]) => {\n const next = parseDifficulty(d);\n if (!next)\n throw new Error(\"/difficulty мирная|лёгкая|обычная|высокая (0..3)\");\n setDifficulty(next);\n // на мирной монстры исчезают сразу, как в Minecraft\n if (!rules().hostiles) g.entities.clearHostiles();\n events.emit(\"difficultyChanged\", { difficulty: next });\n return \"Сложность: \" + DIFFICULTY_NAMES[next];\n },\n\n setblock: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, true);\n const id = blockArg(args[3]);\n const meta =\n args[4] === undefined ? defaultMeta(id) : Number(args[4]) & 255;\n if (!g.world.setBlock(x, y, z, id, meta))\n throw new Error(\"клетка вне загруженного мира\");\n return `Блок ${labelOf(args[3]!)} в ${x} ${y} ${z}`;\n },\n\n fill: (g, args) => {\n const [x1, y1, z1] = xyz(g, args, 0, true);\n const [x2, y2, z2] = xyz(g, args, 3, true);\n const id = blockArg(args[6]);\n const cells =\n (Math.abs(x2 - x1) + 1) *\n (Math.abs(y2 - y1) + 1) *\n (Math.abs(z2 - z1) + 1);\n if (cells > 65536)\n throw new Error(`слишком много блоков: ${cells} (максимум 65536)`);\n const meta = defaultMeta(id);\n let n = 0;\n for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++)\n for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++)\n for (let z = Math.min(z1, z2); z <= Math.max(z1, z2); z++)\n if (g.world.setBlock(x, y, z, id, meta)) n++;\n return `Заполнено блоков: ${n}`;\n },\n\n achievements: (g) => {\n const got = ACHIEVEMENTS.filter((a) => g.achievements.done.has(a.id)).map(\n (a) => a.title,\n );\n return `Достижения ${got.length}/${ACHIEVEMENTS.length}: ${got.join(\", \") || \"пока нет\"} (L — список)`;\n },\n\n music: (g, [op]) => {\n if (op === \"off\") {\n g.music.enabled = false;\n g.music.stop();\n return \"Музыка выключена\";\n }\n if (op === \"on\" || op === \"next\") {\n g.music.enabled = true;\n if (op === \"next\") g.music.next();\n return op === \"next\" ? \"Сейчас заиграет\" : \"Музыка включена\";\n }\n return (\n \"Музыка: \" +\n { off: \"выключена\", playing: \"играет\", waiting: \"ждёт своего часа\" }[\n g.music.status\n ]\n );\n },\n\n effect: (g, [id, sec, lvl]) => {\n if (id === \"clear\") {\n g.player.clearEffects();\n return \"Эффекты сняты\";\n }\n if (!id || !EFFECT_NAMES[id])\n throw new Error(\n \"нет такого эффекта: \" +\n id +\n \" (\" +\n Object.keys(EFFECT_NAMES).join(\", \") +\n \")\",\n );\n const seconds = sec === undefined ? 30 : Number(sec),\n level = lvl === undefined ? 1 : Number(lvl);\n if (!(seconds > 0) || !(level >= 1))\n throw new Error(\"время и уровень — положительные числа\");\n g.player.addEffect(id as EffectId, seconds, Math.floor(level));\n return `${EFFECT_NAMES[id]} ${Math.floor(level)} на ${seconds} с`;\n },\n\n summon: (g, args) => {\n // Minecraft-имена существ тоже годятся (/summon wither)\n const type =\n args[0] === undefined\n ? undefined\n : MOBS[args[0]]\n ? args[0]\n : ALIASES[args[0].toLowerCase()];\n if (!type || !MOBS[type])\n throw new Error(\"нет такого существа: \" + args[0]);\n const [x, y, z] =\n args.length >= 4\n ? xyz(g, args, 1, false)\n : [g.player.pos.x + 2, g.player.pos.y, g.player.pos.z];\n const m = g.entities.spawnMob(type, x, y, z);\n if (m.def.boss === \"ash_lord\") startCharge(m); // как призванный: сперва набирает силу\n return \"Призвано: \" + labelOf(type);\n },\n\n // без аргумента — игрок; с видом моба — все такие в 64 блоках, обычной смертью (с дропом и распадом)\n kill: (g, [type]) => {\n if (!type) {\n g.player._die();\n return \"Игрок погиб\";\n }\n if (!MOBS[type]) throw new Error(\"нет такого существа: \" + type);\n let n = 0;\n for (const e of g.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === type &&\n e.pos.distanceTo(g.player.pos) < 64\n ) {\n e.damage(e.hp);\n n++;\n }\n return `Убито: ${labelOf(type)} × ${n}`;\n },\n\n clear: (g) => {\n g.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: g.inventory.hotbarIndex,\n });\n return \"Инвентарь очищен\";\n },\n\n seed: (g) => \"Сид мира: \" + g.world.seed,\n\n enchant: (g, [id, lvl]) => {\n const held = g.inventory.heldItem();\n if (!held) throw new Error(\"в руке пусто\");\n if (!id || !(id in ENCHANTS))\n throw new Error(\"нет такого зачарования: \" + (id ?? \"?\"));\n const e = ENCHANTS[id as EnchantId];\n const def = ITEMS[held.item];\n if (!def || !e.fits(def))\n throw new Error(`${e.name} не подходит к этому предмету`);\n const level = Math.max(\n 1,\n Math.min(e.max, Math.floor(Number(lvl ?? 1)) || 1),\n );\n g.inventory.setSlot(\n g.inventory.hotbarIndex,\n withEnchants(held, { [id]: level }),\n );\n return \"Зачаровано: \" + enchantLine(id as EnchantId, level);\n },\n\n locate: (g, [what]) => {\n const kind =\n what === \"fortress\" || what === \"крепость\"\n ? \"fortress\"\n : what === \"деревня\"\n ? \"village\"\n : what === \"город\" || what === \"end_city\"\n ? \"city\"\n : what === \"храм\" ||\n what === \"monument\" ||\n what === \"ocean_monument\"\n ? \"temple\"\n : what === \"корабль\"\n ? \"shipwreck\"\n : what === \"застава\" || what === \"pillager_outpost\"\n ? \"outpost\"\n : what === \"клад\" || what === \"treasure\"\n ? \"buried_treasure\"\n : what === \"особняк\" || what === \"woodland_mansion\"\n ? \"mansion\"\n : what === \"древний\" || what === \"древний_город\"\n ? \"ancient_city\"\n : what === \"руины\" || what === \"ruins\"\n ? \"trail_ruins\"\n : what === \"камеры\" || what === \"trial\"\n ? \"trial_chambers\"\n : what;\n const p = g.player.pos;\n const found = kind ? g.world.generator.locate?.(kind, p.x, p.z) : null;\n if (!found)\n throw new Error(\n kind === \"fortress\" && g.dimension !== \"inferno\"\n ? \"адские крепости есть только в Пекле\"\n : \"не нашлось: \" + (what ?? \"?\"),\n );\n const d = Math.round(Math.hypot(found.x - p.x, found.z - p.z));\n return `Ближайшая: ${found.x} ${found.y} ${found.z} (${d} блоков)`;\n },\n\n newworld: (g, [s]) => {\n const seed =\n s !== undefined && Number.isFinite(Number(s))\n ? Number(s) >>> 0\n : (Math.random() * 1e9) >>> 0;\n g.newWorld(seed); // текущий мир остаётся в «Моих мирах»\n return \"Новый мир, сид \" + seed;\n },\n};\n\nexport class Commands {\n /** Последние строки вывода (консоль игрока и ответ агенту). */\n log: string[] = [];\n\n constructor(private readonly game: Game) {}\n\n /** Исполнить строку команды (с `/` или без). Возвращает вывод. */\n run(text: string): string {\n const parts = text.trim().replace(/^\\//, \"\").split(/\\s+/);\n const name = (parts.shift() ?? \"\").toLowerCase();\n let out: string;\n if (!CONFIG.commands.enabled) out = \"Команды в этой игре выключены\";\n else {\n const h = HANDLERS[name];\n try {\n out = h\n ? h(this.game, parts)\n : `Неизвестная команда: /${name}. /help — список`;\n } catch (e) {\n out = \"Ошибка: \" + (e instanceof Error ? e.message : String(e));\n }\n }\n this.log.push(out);\n if (this.log.length > 50) this.log.shift();\n events.emit(\"chat\", { text: out });\n return out;\n }\n}\n"
1339
+ "content": "// Команды консоли, как в Minecraft (/give, /tp, /time…). Текст приходит интентом Command (из\n// консоли игрока или от агента AI-кодера) и исполняется на тике — тем же путём, что и остальной\n// ввод. Выключаются целиком CONFIG.commands.enabled (издатель может закрыть читы в своей игре).\nimport { CONFIG } from \"../config\";\nimport {\n DIFFICULTY_NAMES,\n parseDifficulty,\n rules,\n setDifficulty,\n} from \"./Difficulty\";\nimport { events } from \"../core/EventBus\";\nimport { ALIASES, resolveBlock, resolveItem } from \"../registry/aliases\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { ITEMS } from \"../registry/Items\";\nimport { ENCHANTS, enchantLine, withEnchants, type EnchantId } from \"./Enchant\";\nimport { EFFECT_NAMES, labelOf } from \"../registry/names\";\nimport { startCharge } from \"../entities/AshLordBrain\";\nimport { ACHIEVEMENTS } from \"./Achievements\";\nimport type { Game } from \"../main\";\nimport type { EffectId } from \"../types\";\n\ntype Handler = (game: Game, args: string[]) => string;\n\nconst WORLD_BORDER = 30_000_000;\n\n/** Координата: число или `~`/`~N` относительно игрока. */\nfunction coord(arg: string | undefined, base: number): number {\n if (arg === undefined) throw new Error(\"не хватает координат\");\n // `~abc` раньше давал NaN: игрок улетал в NaN-координаты, и мир переставал грузиться насовсем\n const v = arg.startsWith(\"~\")\n ? base + (arg.length > 1 ? Number(arg.slice(1)) : 0)\n : Number(arg);\n if (!Number.isFinite(v)) throw new Error(\"не число: \" + arg);\n // граница мира, как в Minecraft: на 1e300 шум генератора и ключи чанков теряют точность\n if (Math.abs(v) > WORLD_BORDER)\n throw new Error(`за границей мира (±${WORLD_BORDER}): ${arg}`);\n return v;\n}\n\n/** Три координаты начиная с args[i] (целые — для блоков). */\nfunction xyz(\n game: Game,\n args: string[],\n i: number,\n whole: boolean,\n): [number, number, number] {\n const p = game.player.pos;\n const r = [\n coord(args[i], p.x),\n coord(args[i + 1], p.y),\n coord(args[i + 2], p.z),\n ] as [number, number, number];\n return whole ? (r.map(Math.floor) as [number, number, number]) : r;\n}\n\nfunction blockArg(name: string | undefined): number {\n const id = name === undefined ? null : resolveBlock(name);\n if (id === null) throw new Error(\"нет такого блока: \" + name);\n return id;\n}\n\n/** Мета по умолчанию: жидкость ставится источником (как в Minecraft), иначе 0 — вода с метой 0 утекает. */\nconst defaultMeta = (id: number): number =>\n id === B.water || id === B.lava ? packWater(8, true) : 0;\n\nconst TIMES: Record<string, (d: number, n: number) => number> = {\n day: (d) => d * 0.3,\n noon: (d) => d * 0.5,\n sunset: (d) => d * 0.94,\n night: (d, n) => d + n * 0.3,\n midnight: (d, n) => d + n * 0.5,\n};\n\nconst HANDLERS: Record<string, Handler> = {\n help: () =>\n \"Команды: /give <предмет> [n], /tp x y z, /time set day|noon|night|midnight|<сек>, /weather clear|rain|thunder [сек], \" +\n \"/difficulty мирная|лёгкая|обычная|высокая, /gamemode survival|creative, /setblock x y z <блок> [мета], /fill x1 y1 z1 x2 y2 z2 <блок>, \" +\n \"/summon <моб> [x y z], /kill [моб], /effect <эффект>|clear [сек] [уровень], /achievements, /music on|off|next, /clear, /seed, /locate fortress|citadel|village|outpost|temple|shipwreck|buried_treasure|mansion|ancient_city|trail_ruins|trial_chambers|city, /enchant <зачарование> [уровень], /newworld [сид]. Координаты можно с ~ (относительно себя).\",\n\n give: (g, [name, n]) => {\n const item = name === undefined ? null : resolveItem(name);\n if (!item) throw new Error(\"нет такого предмета: \" + name);\n const count = Math.max(\n 1,\n Math.min(64 * 36, Math.floor(Number(n ?? 1)) || 1),\n );\n const left = g.inventory.add(item, count);\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(p.x, p.y + 1, p.z, item, left);\n }\n return `Выдано: ${labelOf(item)} ×${count}`;\n },\n\n tp: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, false);\n g.warmup(x, z);\n if (g.player.riding) g.player.dismount(); // телепорт высаживает из транспорта, как в Minecraft\n g.player.pos.set(x, y, z);\n g.player.vel.set(0, 0, 0);\n g.player._fallDist = 0;\n return `Телепорт: ${x.toFixed(1)} ${y.toFixed(1)} ${z.toFixed(1)}`;\n },\n\n time: (g, [op, v]) => {\n const { dayLength, nightLength } = CONFIG.dayNight;\n const cycle = dayLength + nightLength;\n if (op === \"set\" && v !== undefined) {\n const named = TIMES[v];\n const t = named ? named(dayLength, nightLength) : Number(v);\n if (!Number.isFinite(t))\n throw new Error(\"время: day, noon, night, midnight или секунды\");\n g.dayNight.time = ((t % cycle) + cycle) % cycle;\n } else if (op === \"add\" && v !== undefined) {\n const dt = Number(v);\n if (!Number.isFinite(dt)) throw new Error(\"не число: \" + v); // NaN ломал небо и свет\n g.dayNight.time = (((g.dayNight.time + dt) % cycle) + cycle) % cycle;\n } else throw new Error(\"/time set <день|ночь|сек> или /time add <сек>\");\n return \"Время: \" + Math.round(g.dayNight.time) + \" c\";\n },\n\n weather: (g, [k, sec]) => {\n if (k !== \"clear\" && k !== \"rain\" && k !== \"thunder\")\n throw new Error(\"/weather clear|rain|thunder [сек]\");\n const s = sec === undefined ? undefined : Number(sec);\n if (s !== undefined && !(s > 0)) throw new Error(\"не число: \" + sec);\n g.weather.set(k, s);\n return (\n \"Погода: \" + (k === \"clear\" ? \"ясно\" : k === \"rain\" ? \"дождь\" : \"гроза\")\n );\n },\n\n gamemode: (g, [m]) => {\n const mode =\n m === \"creative\" || m === \"1\" || m === \"c\"\n ? \"creative\"\n : m === \"survival\" || m === \"0\" || m === \"s\"\n ? \"survival\"\n : null;\n if (!mode) throw new Error(\"/gamemode survival|creative\");\n if (g.player.mode !== mode) g.player.toggleMode();\n return \"Режим: \" + (mode === \"creative\" ? \"креатив\" : \"выживание\");\n },\n\n difficulty: (g, [d]) => {\n const next = parseDifficulty(d);\n if (!next)\n throw new Error(\"/difficulty мирная|лёгкая|обычная|высокая (0..3)\");\n setDifficulty(next);\n // на мирной монстры исчезают сразу, как в Minecraft\n if (!rules().hostiles) g.entities.clearHostiles();\n events.emit(\"difficultyChanged\", { difficulty: next });\n return \"Сложность: \" + DIFFICULTY_NAMES[next];\n },\n\n setblock: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, true);\n const id = blockArg(args[3]);\n const meta =\n args[4] === undefined ? defaultMeta(id) : Number(args[4]) & 255;\n if (!g.world.setBlock(x, y, z, id, meta))\n throw new Error(\"клетка вне загруженного мира\");\n return `Блок ${labelOf(args[3]!)} в ${x} ${y} ${z}`;\n },\n\n fill: (g, args) => {\n const [x1, y1, z1] = xyz(g, args, 0, true);\n const [x2, y2, z2] = xyz(g, args, 3, true);\n const id = blockArg(args[6]);\n const cells =\n (Math.abs(x2 - x1) + 1) *\n (Math.abs(y2 - y1) + 1) *\n (Math.abs(z2 - z1) + 1);\n if (cells > 65536)\n throw new Error(`слишком много блоков: ${cells} (максимум 65536)`);\n const meta = defaultMeta(id);\n let n = 0;\n for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++)\n for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++)\n for (let z = Math.min(z1, z2); z <= Math.max(z1, z2); z++)\n if (g.world.setBlock(x, y, z, id, meta)) n++;\n return `Заполнено блоков: ${n}`;\n },\n\n achievements: (g) => {\n const got = ACHIEVEMENTS.filter((a) => g.achievements.done.has(a.id)).map(\n (a) => a.title,\n );\n return `Достижения ${got.length}/${ACHIEVEMENTS.length}: ${got.join(\", \") || \"пока нет\"} (L — список)`;\n },\n\n music: (g, [op]) => {\n if (op === \"off\") {\n g.music.enabled = false;\n g.music.stop();\n return \"Музыка выключена\";\n }\n if (op === \"on\" || op === \"next\") {\n g.music.enabled = true;\n if (op === \"next\") g.music.next();\n return op === \"next\" ? \"Сейчас заиграет\" : \"Музыка включена\";\n }\n return (\n \"Музыка: \" +\n { off: \"выключена\", playing: \"играет\", waiting: \"ждёт своего часа\" }[\n g.music.status\n ]\n );\n },\n\n effect: (g, [id, sec, lvl]) => {\n if (id === \"clear\") {\n g.player.clearEffects();\n return \"Эффекты сняты\";\n }\n if (!id || !EFFECT_NAMES[id])\n throw new Error(\n \"нет такого эффекта: \" +\n id +\n \" (\" +\n Object.keys(EFFECT_NAMES).join(\", \") +\n \")\",\n );\n const seconds = sec === undefined ? 30 : Number(sec),\n level = lvl === undefined ? 1 : Number(lvl);\n if (!(seconds > 0) || !(level >= 1))\n throw new Error(\"время и уровень — положительные числа\");\n g.player.addEffect(id as EffectId, seconds, Math.floor(level));\n return `${EFFECT_NAMES[id]} ${Math.floor(level)} на ${seconds} с`;\n },\n\n summon: (g, args) => {\n // Minecraft-имена существ тоже годятся (/summon wither)\n const type =\n args[0] === undefined\n ? undefined\n : MOBS[args[0]]\n ? args[0]\n : ALIASES[args[0].toLowerCase()];\n if (!type || !MOBS[type])\n throw new Error(\"нет такого существа: \" + args[0]);\n const [x, y, z] =\n args.length >= 4\n ? xyz(g, args, 1, false)\n : [g.player.pos.x + 2, g.player.pos.y, g.player.pos.z];\n const m = g.entities.spawnMob(type, x, y, z);\n if (m.def.boss === \"ash_lord\") startCharge(m); // как призванный: сперва набирает силу\n return \"Призвано: \" + labelOf(type);\n },\n\n // без аргумента — игрок; с видом моба — все такие в 64 блоках, обычной смертью (с дропом и распадом)\n kill: (g, [type]) => {\n if (!type) {\n g.player._die();\n return \"Игрок погиб\";\n }\n if (!MOBS[type]) throw new Error(\"нет такого существа: \" + type);\n let n = 0;\n for (const e of g.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === type &&\n e.pos.distanceTo(g.player.pos) < 64\n ) {\n e.damage(e.hp);\n n++;\n }\n return `Убито: ${labelOf(type)} × ${n}`;\n },\n\n clear: (g) => {\n // карманы и броня; сундук Предела — склад, /clear его не трогает (как в Minecraft)\n g.inventory.clearCarried();\n return \"Инвентарь очищен\";\n },\n\n seed: (g) => \"Сид мира: \" + g.world.seed,\n\n enchant: (g, [id, lvl]) => {\n const held = g.inventory.heldItem();\n if (!held) throw new Error(\"в руке пусто\");\n if (!id || !(id in ENCHANTS))\n throw new Error(\"нет такого зачарования: \" + (id ?? \"?\"));\n const e = ENCHANTS[id as EnchantId];\n const def = ITEMS[held.item];\n if (!def || !e.fits(def))\n throw new Error(`${e.name} не подходит к этому предмету`);\n const level = Math.max(\n 1,\n Math.min(e.max, Math.floor(Number(lvl ?? 1)) || 1),\n );\n g.inventory.setSlot(\n g.inventory.hotbarIndex,\n withEnchants(held, { [id]: level }),\n );\n return \"Зачаровано: \" + enchantLine(id as EnchantId, level);\n },\n\n locate: (g, [what]) => {\n const kind =\n what === \"fortress\" || what === \"крепость\"\n ? \"fortress\"\n : what === \"деревня\"\n ? \"village\"\n : what === \"город\" || what === \"end_city\"\n ? \"city\"\n : what === \"храм\" ||\n what === \"monument\" ||\n what === \"ocean_monument\"\n ? \"temple\"\n : what === \"корабль\"\n ? \"shipwreck\"\n : what === \"застава\" || what === \"pillager_outpost\"\n ? \"outpost\"\n : what === \"клад\" || what === \"treasure\"\n ? \"buried_treasure\"\n : what === \"особняк\" || what === \"woodland_mansion\"\n ? \"mansion\"\n : what === \"древний\" || what === \"древний_город\"\n ? \"ancient_city\"\n : what === \"руины\" || what === \"ruins\"\n ? \"trail_ruins\"\n : what === \"камеры\" || what === \"trial\"\n ? \"trial_chambers\"\n : what;\n const p = g.player.pos;\n const found = kind ? g.world.generator.locate?.(kind, p.x, p.z) : null;\n if (!found)\n throw new Error(\n kind === \"fortress\" && g.dimension !== \"inferno\"\n ? \"адские крепости есть только в Пекле\"\n : \"не нашлось: \" + (what ?? \"?\"),\n );\n const d = Math.round(Math.hypot(found.x - p.x, found.z - p.z));\n return `Ближайшая: ${found.x} ${found.y} ${found.z} (${d} блоков)`;\n },\n\n newworld: (g, [s]) => {\n const seed =\n s !== undefined && Number.isFinite(Number(s))\n ? Number(s) >>> 0\n : (Math.random() * 1e9) >>> 0;\n g.newWorld(seed); // текущий мир остаётся в «Моих мирах»\n return \"Новый мир, сид \" + seed;\n },\n};\n\nexport class Commands {\n /** Последние строки вывода (консоль игрока и ответ агенту). */\n log: string[] = [];\n\n constructor(private readonly game: Game) {}\n\n /** Исполнить строку команды (с `/` или без). Возвращает вывод. */\n run(text: string): string {\n const parts = text.trim().replace(/^\\//, \"\").split(/\\s+/);\n const name = (parts.shift() ?? \"\").toLowerCase();\n let out: string;\n if (!CONFIG.commands.enabled) out = \"Команды в этой игре выключены\";\n else {\n const h = HANDLERS[name];\n try {\n out = h\n ? h(this.game, parts)\n : `Неизвестная команда: /${name}. /help — список`;\n } catch (e) {\n out = \"Ошибка: \" + (e instanceof Error ? e.message : String(e));\n }\n }\n this.log.push(out);\n if (this.log.length > 50) this.log.shift();\n events.emit(\"chat\", { text: out });\n return out;\n }\n}\n"
1336
1340
  },
1337
1341
  {
1338
1342
  "path": "systems/Crafting.ts",
1339
- "content": "// Крафт: состояние сетки (2×2 инвентарь / 3×3 верстак) + матчинг рецептов.\n// Слоты сетки — такие же стаки, как в инвентаре; UI двигает их drag-and-drop'ом.\nimport { events } from \"../core/EventBus\";\nimport { matchRecipe } from \"../registry/Recipes\";\nimport type { ItemStack, RecipeResult } from \"../types\";\nimport type { Inventory } from \"./Inventory\";\n\nexport class CraftingGrid {\n size: number;\n slots: (ItemStack | null)[];\n\n constructor(size = 2) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n setSize(size: number) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n /** Текущий результат по рецептам (не изымает ингредиенты). */\n result(): RecipeResult | null {\n return matchRecipe(\n this.slots.map((s) => (s ? s.item : null)),\n this.size,\n );\n }\n\n /** Забрать результат: по 1 предмету из каждой занятой клетки. */\n takeResult(): RecipeResult | null {\n const res = this.result();\n if (!res) return null;\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n s.count -= 1;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n events.emit(\"craft\", { item: res.item, count: res.count });\n return { ...res };\n }\n\n /** Вернуть всё из сетки в инвентарь (закрытие окна). */\n dumpInto(inventory: Inventory) {\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n const left = inventory.add(s.item, s.count);\n this.slots[i] = left > 0 ? { item: s.item, count: left } : null;\n // если инвентарь полон — остаток остаётся в сетке; выбросить его — дело вызывающего\n // (InventoryUI.close), иначе следующий setSize() его обнулит\n }\n }\n }\n}\n"
1343
+ "content": "// Крафт: состояние сетки (2×2 инвентарь / 3×3 верстак) + матчинг рецептов.\n// Слоты сетки — такие же стаки, как в инвентаре; UI двигает их drag-and-drop'ом.\nimport { events } from \"../core/EventBus\";\nimport { matchRecipe } from \"../registry/Recipes\";\nimport type { ItemStack, RecipeResult } from \"../types\";\nimport type { Inventory } from \"./Inventory\";\n\nexport class CraftingGrid {\n size: number;\n slots: (ItemStack | null)[];\n\n constructor(size = 2) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n setSize(size: number) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n /** Текущий результат по рецептам (не изымает ингредиенты). */\n result(): RecipeResult | null {\n return matchRecipe(\n this.slots.map((s) => (s ? s.item : null)),\n this.size,\n );\n }\n\n /** Забрать результат: по 1 предмету из каждой занятой клетки. */\n takeResult(): RecipeResult | null {\n const res = this.result();\n if (!res) return null;\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n s.count -= 1;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n events.emit(\"craft\", { item: res.item, count: res.count });\n return { ...res };\n }\n\n /** Вернуть всё из сетки в инвентарь (закрытие окна). */\n dumpInto(inventory: Inventory) {\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n // с износом или tag — через addStack: add() вернул бы «новую» вещь без чар и износа\n const left =\n s.dmg || s.tag\n ? inventory.addStack(s)\n : inventory.add(s.item, s.count);\n this.slots[i] = left > 0 ? { ...s, count: left } : null;\n // если инвентарь полон — остаток остаётся в сетке; выбросить его — дело вызывающего\n // (InventoryUI.close), иначе следующий setSize() его обнулит\n }\n }\n }\n}\n"
1340
1344
  },
1341
1345
  {
1342
1346
  "path": "systems/DayNight.ts",
1343
- "content": "// Цикл суток: CONFIG.dayNight.dayLength дня + nightLength ночи. Плавно лерпаются цвет неба, туман,\n// свет солнца/луны и небосвода; солнце и луна — спрайты на орбите камеры.\n//\n// Освещение в казуальном стиле: тёплое солнце с мягкими тенями (PCF + radius) и полусферический\n// свет вместо плоского ambient — теневые стороны берут цвет неба сверху и тёплый отражённый от\n// земли снизу, а не проваливаются в серое.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { LIGHT_AMBIENT } from \"../gfx/LightMaterial\";\nimport { DIMENSIONS, type DimensionId } from \"../world/Dimensions\";\n\n/** Ключевая точка цикла; t в [0..1), 0 = рассвет. Цвета — sRGB hex. */\ninterface LightKey {\n t: number;\n sky: number; // цвет неба (clear color)\n fog: number;\n sun: number; // направленный свет: днём солнце, ночью луна\n sunI: number;\n hemiSky: number; // небосвод — подсветка сверху\n hemiGround: number; // отражённый от земли тёплый свет снизу\n hemiI: number;\n}\n\n// Интенсивности подобраны под Lambert three r155+ (освещённость делится на π): сумма на верхней\n// грани в полдень ≈ π — альбедо текстуры выходит в полную яркость. Солнце заметно сильнее\n// небосвода, иначе тень гасит лишь малую долю света и почти не видна; небосвод же не даёт ей\n// провалиться в черноту — тень остаётся мягкой и цветной.\nconst DAWN = {\n sky: 0xffb27a,\n fog: 0xffc9a0,\n sun: 0xffc27f,\n sunI: 1.6,\n hemiSky: 0xffd6c0,\n hemiGround: 0xa08060,\n hemiI: 1.3,\n};\nconst DAY = {\n sky: 0x5ab8f5,\n fog: 0x9fd4f7,\n sun: 0xfff0d6,\n sunI: 2.6,\n hemiSky: 0xd2ecff,\n hemiGround: 0xc9b38a,\n hemiI: 1.4,\n};\nconst SUNSET = {\n sky: 0xff8f5a,\n fog: 0xffac80,\n sun: 0xff9c5c,\n sunI: 1.5,\n hemiSky: 0xffc6a8,\n hemiGround: 0x8a6050,\n hemiI: 1.2,\n};\nconst NIGHT = {\n sky: 0x13204f,\n fog: 0x1a2757,\n sun: 0x9db4ff,\n sunI: 1.0,\n hemiSky: 0x4a62a8,\n hemiGround: 0x2a2f45,\n hemiI: 1.2,\n};\nconst KEYS: LightKey[] = [\n { t: 0.0, ...DAWN },\n { t: 0.1, ...DAY },\n { t: 0.4, ...DAY },\n { t: 0.5, ...SUNSET },\n { t: 0.6, ...NIGHT },\n { t: 0.9, ...NIGHT },\n { t: 1.0, ...DAWN },\n];\n\nconst G = CONFIG.graphics;\nconst NO_WEATHER = { rain: 0, thunder: 0, flash: 0 };\nconst UP = new THREE.Vector3(0, 1, 0);\nconst ORIGIN = new THREE.Vector3();\n\nfunction makeDiscSprite(color: string, glow: string): THREE.Sprite {\n const c = document.createElement(\"canvas\");\n c.width = c.height = 64;\n const ctx = c.getContext(\"2d\")!;\n const g = ctx.createRadialGradient(32, 32, 6, 32, 32, 30);\n g.addColorStop(0, color);\n g.addColorStop(0.5, color);\n g.addColorStop(1, glow);\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 64, 64);\n const tex = new THREE.CanvasTexture(c);\n const mat = new THREE.SpriteMaterial({\n map: tex,\n transparent: true,\n fog: false,\n depthWrite: false,\n });\n const s = new THREE.Sprite(mat);\n s.scale.set(14, 14, 1);\n return s;\n}\n\nexport class DayNight {\n scene: THREE.Scene;\n renderer: THREE.WebGLRenderer;\n cycleLength: number;\n time: number;\n sun: THREE.DirectionalLight;\n hemi: THREE.HemisphereLight;\n skyColor: THREE.Color;\n sunSprite: THREE.Sprite;\n moonSprite: THREE.Sprite;\n _colA: THREE.Color;\n _colB: THREE.Color;\n private readonly _sunDir = new THREE.Vector3();\n private readonly _lightDir = new THREE.Vector3();\n private readonly _center = new THREE.Vector3();\n private readonly _basis = new THREE.Matrix4();\n private readonly _ax = new THREE.Vector3();\n private readonly _ay = new THREE.Vector3();\n\n constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {\n this.scene = scene;\n this.renderer = renderer;\n this.cycleLength = CONFIG.dayNight.dayLength + CONFIG.dayNight.nightLength;\n this.time = 0;\n this.resetToMorning(); // старт утром\n\n this.sun = new THREE.DirectionalLight(0xffffff, 1);\n this.sun.castShadow = G.shadows;\n const sh = this.sun.shadow;\n sh.mapSize.set(G.shadowMapSize, G.shadowMapSize);\n sh.radius = G.shadowSoftness;\n sh.bias = -0.0004;\n sh.normalBias = 0.04;\n const R = G.shadowDistance;\n sh.camera.left = sh.camera.bottom = -R;\n sh.camera.right = sh.camera.top = R;\n sh.camera.near = 1;\n sh.camera.far = R * 6;\n sh.camera.updateProjectionMatrix();\n\n this.hemi = new THREE.HemisphereLight(0xffffff, 0xffffff, 1);\n // target — в сцене, чтобы его матрица обновлялась вместе со сценой\n scene.add(this.sun, this.sun.target, this.hemi);\n scene.fog = new THREE.Fog(0xbfe3f5, 20, CONFIG.renderDistance * 16 - 8);\n this.skyColor = new THREE.Color();\n this.sunSprite = makeDiscSprite(\"#fff4c0\", \"rgba(255,235,150,0)\");\n this.moonSprite = makeDiscSprite(\"#e8ecf5\", \"rgba(190,200,230,0)\");\n this.moonSprite.scale.set(9, 9, 1);\n scene.add(this.sunSprite, this.moonSprite);\n this._colA = new THREE.Color();\n this._colB = new THREE.Color();\n }\n\n tick(dt: number) {\n this.time = (this.time + dt) % this.cycleLength;\n }\n\n /**\n * 0..1 фазы цикла (0 — рассвет, 0.5 — закат). Дневная половина фазы растягивается на dayLength\n * секунд, ночная — на nightLength. Раньше длины только суммировались, и день с ночью всегда\n * делили цикл поровну, какие бы значения ни стояли в конфиге.\n */\n get phase() {\n const { dayLength, nightLength } = CONFIG.dayNight;\n return this.time < dayLength\n ? (0.5 * this.time) / dayLength\n : 0.5 + (0.5 * (this.time - dayLength)) / nightLength;\n }\n\n /** Утро (фаза 0.15 — солнце уже поднялось): старт игры и новый мир. */\n resetToMorning() {\n this.time = CONFIG.dayNight.dayLength * 0.3;\n }\n\n /** Время суток мира из рецепта: рассвет, день, закат или ночь. */\n setTimeOfDay(t: \"dawn\" | \"day\" | \"sunset\" | \"night\") {\n const { dayLength, nightLength } = CONFIG.dayNight;\n // фазы: 0 — рассвет, 0.15 — утро, 0.47 — закат, 0.7 — глубокая ночь\n if (t === \"dawn\") this.time = dayLength * 0.06;\n else if (t === \"sunset\") this.time = dayLength * 0.94;\n else if (t === \"night\") this.time = dayLength + nightLength * 0.4;\n else this.resetToMorning();\n }\n\n get isDay() {\n return this.phase < 0.5;\n }\n\n /**\n * На сколько уровней время суток гасит небесный свет: днём 0, ночью 11, в сумерках плавно — как в\n * Minecraft. По нему спавнятся враждебные мобы (ночью под открытым небом свет 15-11 = 4 ≤ 7).\n */\n get skyDarken(): number {\n const t = this.phase; // 0 рассвет, 0.5 закат\n if (t < 0.05) return 11 * (1 - t / 0.05);\n if (t < 0.45) return 0;\n if (t < 0.55) return 11 * ((t - 0.45) / 0.1);\n if (t < 0.95) return 11;\n return 11 * (1 - (t - 0.95) / 0.05);\n }\n\n /** Кадровое обновление: интерполяция ключей + позиции светил вокруг игрока. */\n frame(\n playerPos: THREE.Vector3,\n medium: \"air\" | \"water\" | \"lava\" = \"air\",\n dimension: DimensionId = \"overworld\",\n nightVision = false,\n weather: { rain: number; thunder: number; flash: number } = NO_WEATHER,\n ) {\n const underwater = medium !== \"air\";\n const t = this.phase;\n let a = KEYS[0]!,\n b = KEYS[KEYS.length - 1]!;\n for (let i = 0; i < KEYS.length - 1; i++)\n if (t >= KEYS[i]!.t && t <= KEYS[i + 1]!.t) {\n a = KEYS[i]!;\n b = KEYS[i + 1]!;\n break;\n }\n const f = (t - a.t) / Math.max(1e-6, b.t - a.t);\n const lerpC = (target: THREE.Color, ca: number, cb: number) =>\n target.copy(this._colA.setHex(ca)).lerp(this._colB.setHex(cb), f);\n const lerpN = (na: number, nb: number) => na + (nb - na) * f;\n\n lerpC(this.skyColor, a.sky, b.sky);\n lerpC(this.sun.color, a.sun, b.sun);\n lerpC(this.hemi.color, a.hemiSky, b.hemiSky);\n lerpC(this.hemi.groundColor, a.hemiGround, b.hemiGround);\n this.hemi.intensity = lerpN(a.hemiI, b.hemiI);\n\n const fog = this.scene.fog as THREE.Fog;\n lerpC(fog.color, a.fog, b.fog);\n if (medium === \"lava\") {\n fog.color.setHex(0xc04a10);\n fog.near = 0.1;\n fog.far = 2.5;\n } else if (underwater) {\n fog.color.setHex(0x1a4a8a);\n fog.near = 2;\n fog.far = 18;\n } else {\n // Туман только прячет край прогрузки: начинается далеко, иначе дымка смывает насыщенность.\n fog.far = CONFIG.renderDistance * 16 - 8;\n fog.near = fog.far * 0.55;\n }\n // дождь: небо и туман серые и темнее, дымка ближе; в грозу ещё темнее\n const { rain, thunder } = weather;\n if (rain > 0) {\n const gray = (c: THREE.Color) => {\n const l = 0.3 * c.r + 0.59 * c.g + 0.11 * c.b;\n c.lerp(this._colA.setRGB(l, l, l), 0.8 * rain).multiplyScalar(\n 1 - 0.35 * rain - 0.3 * thunder,\n );\n };\n gray(this.skyColor);\n if (!underwater) {\n gray(fog.color);\n fog.near = fog.far * (0.55 - 0.4 * rain);\n }\n }\n this.renderer.setClearColor(\n medium === \"lava\" ? 0xc04a10 : underwater ? 0x1a4a8a : this.skyColor,\n );\n\n const dim = DIMENSIONS[dimension];\n // фоновый свет измерения без неба (Пекло, Предел) — из описания измерения\n LIGHT_AMBIENT.value = dim.hasSky ? 0.03 : dim.ambient.level;\n // Ночное зрение: темнота почти не темнит (пещеры и ночь видно)\n if (nightVision) LIGHT_AMBIENT.value = Math.max(LIGHT_AMBIENT.value, 0.85);\n if (!dim.hasSky) {\n // Пекло и Предел: своё постоянное небо и густой туман — без светил и суточного цикла\n this.skyColor.setHex(dim.sky);\n if (!underwater) {\n fog.color.setHex(dim.fog);\n fog.far = CONFIG.renderDistance * 16 * 0.75;\n fog.near = fog.far * 0.2;\n this.renderer.setClearColor(this.skyColor);\n }\n this.sun.intensity = 0;\n this.sun.shadow.intensity = 0;\n // рассеянный свет — свой цвет измерения (цвета тумана и неба почти чёрные: Предел был не виден)\n this.hemi.color.setHex(dim.ambient.color);\n this.hemi.groundColor.setHex(dim.ambient.color).multiplyScalar(0.55);\n this.hemi.intensity = 0.9;\n this.sunSprite.material.opacity = 0;\n this.moonSprite.material.opacity = 0;\n return;\n }\n\n // солнце: восход на востоке (+x) в t=0, зенит в t=0.25, закат в t=0.5, ночью — под горизонтом\n const elev = Math.sin(Math.PI * (0.5 - Math.abs(t - 0.25) * 2)); // 1 в полдень, <0 ночью\n const az = Math.cos((t - 0.25) * Math.PI * 2);\n const sunDir = this._sunDir.set(az, elev, 0.3).normalize();\n\n // Направленный свет всегда сверху: днём от солнца, ночью от луны (она напротив). У горизонта\n // он гаснет и только потом переключается — иначе на закате тени и освещённые грани\n // перескакивали бы на противоположную сторону.\n const lightDir = this._lightDir.copy(sunDir);\n if (lightDir.y < 0) lightDir.negate();\n const horizon = THREE.MathUtils.smoothstep(lightDir.y, 0.02, 0.3);\n this.sun.intensity = lerpN(a.sunI, b.sunI) * horizon;\n this.sun.shadow.intensity = G.shadowStrength * horizon;\n this._placeSun(playerPos, lightDir);\n\n this.sunSprite.position.copy(playerPos).addScaledVector(sunDir, 70);\n this.moonSprite.position.copy(playerPos).addScaledVector(sunDir, -70);\n this.sunSprite.material.opacity = Math.max(\n 0,\n Math.min(1, sunDir.y * 4 + 0.4),\n );\n this.moonSprite.material.opacity = Math.max(\n 0,\n Math.min(1, -sunDir.y * 4 + 0.4),\n );\n // за тучами солнца и луны не видно, тени бледнеют; молния на миг заливает всё светом\n if (rain > 0) {\n this.sun.intensity *= 1 - 0.75 * rain;\n this.sun.shadow.intensity *= 1 - 0.8 * rain;\n this.hemi.intensity *= 1 - 0.25 * rain - 0.2 * thunder;\n this.sunSprite.material.opacity *= 1 - rain;\n this.moonSprite.material.opacity *= 1 - rain;\n }\n if (weather.flash > 0) this.hemi.intensity += 3 * weather.flash;\n }\n\n /**\n * Эффект Тьмы (сила 0..1, main пульсирует ею): поверх кадра frame() туман подходит вплотную, небо,\n * туман и рассеянный свет гаснут. Ночное зрение Тьму не снимает — как в Minecraft.\n */\n darkness(k: number): void {\n if (k <= 0) return;\n const fog = this.scene.fog as THREE.Fog;\n // цвета линейные, на экране — через sRGB: гасить нужно почти до нуля, иначе выходит серая дымка\n const dim = 0.01 + 0.04 * (1 - k);\n fog.color.multiplyScalar(dim);\n fog.far = Math.min(fog.far, 40 - 30 * k);\n fog.near = Math.min(fog.near, 1);\n this.skyColor.multiplyScalar(dim);\n this.renderer.setClearColor(fog.color);\n LIGHT_AMBIENT.value = Math.min(LIGHT_AMBIENT.value, 0.03 * (1 - k));\n this.hemi.intensity *= 1 - 0.8 * k;\n this.sun.intensity *= 1 - 0.8 * k;\n }\n\n /**\n * Карта теней ездит за игроком. Её центр привязан к сетке текселей в пространстве света: без\n * этого при ходьбе край каждой тени пересчитывался бы со сдвигом на долю текселя и «кипел».\n */\n _placeSun(playerPos: THREE.Vector3, lightDir: THREE.Vector3) {\n const R = G.shadowDistance;\n const texel = (2 * R) / G.shadowMapSize;\n // тот же базис, что построит shadow-камера: z смотрит на свет, up — мировой\n this._basis.lookAt(lightDir, ORIGIN, UP);\n this._ax.setFromMatrixColumn(this._basis, 0);\n this._ay.setFromMatrixColumn(this._basis, 1);\n const c = this._center.copy(playerPos);\n const du = c.dot(this._ax);\n const dv = c.dot(this._ay);\n c.addScaledVector(this._ax, Math.round(du / texel) * texel - du);\n c.addScaledVector(this._ay, Math.round(dv / texel) * texel - dv);\n this.sun.position.copy(c).addScaledVector(lightDir, R * 3);\n this.sun.target.position.copy(c);\n }\n\n serialize() {\n return { time: this.time };\n }\n deserialize(d: { time?: number }) {\n this.time = d.time ?? this.time;\n }\n}\n"
1347
+ "content": "// Цикл суток: CONFIG.dayNight.dayLength дня + nightLength ночи. Плавно лерпаются цвет неба, туман,\n// свет солнца/луны и небосвода; солнце и луна — спрайты на орбите камеры.\n//\n// Освещение в казуальном стиле: тёплое солнце с мягкими тенями (PCF + radius) и полусферический\n// свет вместо плоского ambient — теневые стороны берут цвет неба сверху и тёплый отражённый от\n// земли снизу, а не проваливаются в серое.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { LIGHT_AMBIENT } from \"../gfx/LightMaterial\";\nimport { DIMENSIONS, type DimensionId } from \"../world/Dimensions\";\n\n/** Ключевая точка цикла; t в [0..1), 0 = рассвет. Цвета — sRGB hex. */\ninterface LightKey {\n t: number;\n sky: number; // цвет неба (clear color)\n fog: number;\n sun: number; // направленный свет: днём солнце, ночью луна\n sunI: number;\n hemiSky: number; // небосвод — подсветка сверху\n hemiGround: number; // отражённый от земли тёплый свет снизу\n hemiI: number;\n}\n\n// Интенсивности подобраны под Lambert three r155+ (освещённость делится на π): сумма на верхней\n// грани в полдень ≈ π — альбедо текстуры выходит в полную яркость. Солнце заметно сильнее\n// небосвода, иначе тень гасит лишь малую долю света и почти не видна; небосвод же не даёт ей\n// провалиться в черноту — тень остаётся мягкой и цветной.\nconst DAWN = {\n sky: 0xffb27a,\n fog: 0xffc9a0,\n sun: 0xffc27f,\n sunI: 1.6,\n hemiSky: 0xffd6c0,\n hemiGround: 0xa08060,\n hemiI: 1.3,\n};\nconst DAY = {\n sky: 0x5ab8f5,\n fog: 0x9fd4f7,\n sun: 0xfff0d6,\n sunI: 2.6,\n hemiSky: 0xd2ecff,\n hemiGround: 0xc9b38a,\n hemiI: 1.4,\n};\nconst SUNSET = {\n sky: 0xff8f5a,\n fog: 0xffac80,\n sun: 0xff9c5c,\n sunI: 1.5,\n hemiSky: 0xffc6a8,\n hemiGround: 0x8a6050,\n hemiI: 1.2,\n};\nconst NIGHT = {\n sky: 0x13204f,\n fog: 0x1a2757,\n sun: 0x9db4ff,\n sunI: 1.0,\n hemiSky: 0x4a62a8,\n hemiGround: 0x2a2f45,\n hemiI: 1.2,\n};\nconst KEYS: LightKey[] = [\n { t: 0.0, ...DAWN },\n { t: 0.1, ...DAY },\n { t: 0.4, ...DAY },\n { t: 0.5, ...SUNSET },\n { t: 0.6, ...NIGHT },\n { t: 0.9, ...NIGHT },\n { t: 1.0, ...DAWN },\n];\n\nconst G = CONFIG.graphics;\n/** Цвет пелены, когда голова в снегу-порохе. */\nconst POWDER_FOG = 0xdfe9f3;\nconst NO_WEATHER = { rain: 0, thunder: 0, flash: 0 };\nconst UP = new THREE.Vector3(0, 1, 0);\nconst ORIGIN = new THREE.Vector3();\n\nfunction makeDiscSprite(color: string, glow: string): THREE.Sprite {\n const c = document.createElement(\"canvas\");\n c.width = c.height = 64;\n const ctx = c.getContext(\"2d\")!;\n const g = ctx.createRadialGradient(32, 32, 6, 32, 32, 30);\n g.addColorStop(0, color);\n g.addColorStop(0.5, color);\n g.addColorStop(1, glow);\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 64, 64);\n const tex = new THREE.CanvasTexture(c);\n const mat = new THREE.SpriteMaterial({\n map: tex,\n transparent: true,\n fog: false,\n depthWrite: false,\n });\n const s = new THREE.Sprite(mat);\n s.scale.set(14, 14, 1);\n return s;\n}\n\nexport class DayNight {\n scene: THREE.Scene;\n renderer: THREE.WebGLRenderer;\n cycleLength: number;\n time: number;\n sun: THREE.DirectionalLight;\n hemi: THREE.HemisphereLight;\n skyColor: THREE.Color;\n sunSprite: THREE.Sprite;\n moonSprite: THREE.Sprite;\n _colA: THREE.Color;\n _colB: THREE.Color;\n private readonly _sunDir = new THREE.Vector3();\n private readonly _lightDir = new THREE.Vector3();\n private readonly _center = new THREE.Vector3();\n private readonly _basis = new THREE.Matrix4();\n private readonly _ax = new THREE.Vector3();\n private readonly _ay = new THREE.Vector3();\n\n constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {\n this.scene = scene;\n this.renderer = renderer;\n this.cycleLength = CONFIG.dayNight.dayLength + CONFIG.dayNight.nightLength;\n this.time = 0;\n this.resetToMorning(); // старт утром\n\n this.sun = new THREE.DirectionalLight(0xffffff, 1);\n this.sun.castShadow = G.shadows;\n const sh = this.sun.shadow;\n sh.mapSize.set(G.shadowMapSize, G.shadowMapSize);\n sh.radius = G.shadowSoftness;\n sh.bias = -0.0004;\n sh.normalBias = 0.04;\n const R = G.shadowDistance;\n sh.camera.left = sh.camera.bottom = -R;\n sh.camera.right = sh.camera.top = R;\n sh.camera.near = 1;\n sh.camera.far = R * 6;\n sh.camera.updateProjectionMatrix();\n\n this.hemi = new THREE.HemisphereLight(0xffffff, 0xffffff, 1);\n // target — в сцене, чтобы его матрица обновлялась вместе со сценой\n scene.add(this.sun, this.sun.target, this.hemi);\n scene.fog = new THREE.Fog(0xbfe3f5, 20, CONFIG.renderDistance * 16 - 8);\n this.skyColor = new THREE.Color();\n this.sunSprite = makeDiscSprite(\"#fff4c0\", \"rgba(255,235,150,0)\");\n this.moonSprite = makeDiscSprite(\"#e8ecf5\", \"rgba(190,200,230,0)\");\n this.moonSprite.scale.set(9, 9, 1);\n scene.add(this.sunSprite, this.moonSprite);\n this._colA = new THREE.Color();\n this._colB = new THREE.Color();\n }\n\n tick(dt: number) {\n this.time = (this.time + dt) % this.cycleLength;\n }\n\n /**\n * 0..1 фазы цикла (0 — рассвет, 0.5 — закат). Дневная половина фазы растягивается на dayLength\n * секунд, ночная — на nightLength. Раньше длины только суммировались, и день с ночью всегда\n * делили цикл поровну, какие бы значения ни стояли в конфиге.\n */\n get phase() {\n const { dayLength, nightLength } = CONFIG.dayNight;\n return this.time < dayLength\n ? (0.5 * this.time) / dayLength\n : 0.5 + (0.5 * (this.time - dayLength)) / nightLength;\n }\n\n /** Утро (фаза 0.15 — солнце уже поднялось): старт игры и новый мир. */\n resetToMorning() {\n this.time = CONFIG.dayNight.dayLength * 0.3;\n }\n\n /** Время суток мира из рецепта: рассвет, день, закат или ночь. */\n setTimeOfDay(t: \"dawn\" | \"day\" | \"sunset\" | \"night\") {\n const { dayLength, nightLength } = CONFIG.dayNight;\n // фазы: 0 — рассвет, 0.15 — утро, 0.47 — закат, 0.7 — глубокая ночь\n if (t === \"dawn\") this.time = dayLength * 0.06;\n else if (t === \"sunset\") this.time = dayLength * 0.94;\n else if (t === \"night\") this.time = dayLength + nightLength * 0.4;\n else this.resetToMorning();\n }\n\n get isDay() {\n return this.phase < 0.5;\n }\n\n /**\n * На сколько уровней время суток гасит небесный свет: днём 0, ночью 11, в сумерках плавно — как в\n * Minecraft. По нему спавнятся враждебные мобы (ночью под открытым небом свет 15-11 = 4 ≤ 7).\n */\n get skyDarken(): number {\n const t = this.phase; // 0 рассвет, 0.5 закат\n if (t < 0.05) return 11 * (1 - t / 0.05);\n if (t < 0.45) return 0;\n if (t < 0.55) return 11 * ((t - 0.45) / 0.1);\n if (t < 0.95) return 11;\n return 11 * (1 - (t - 0.95) / 0.05);\n }\n\n /** Кадровое обновление: интерполяция ключей + позиции светил вокруг игрока. */\n frame(\n playerPos: THREE.Vector3,\n medium: \"air\" | \"water\" | \"lava\" | \"powder_snow\" = \"air\",\n dimension: DimensionId = \"overworld\",\n nightVision = false,\n weather: { rain: number; thunder: number; flash: number } = NO_WEATHER,\n ) {\n const underwater = medium !== \"air\";\n const t = this.phase;\n let a = KEYS[0]!,\n b = KEYS[KEYS.length - 1]!;\n for (let i = 0; i < KEYS.length - 1; i++)\n if (t >= KEYS[i]!.t && t <= KEYS[i + 1]!.t) {\n a = KEYS[i]!;\n b = KEYS[i + 1]!;\n break;\n }\n const f = (t - a.t) / Math.max(1e-6, b.t - a.t);\n const lerpC = (target: THREE.Color, ca: number, cb: number) =>\n target.copy(this._colA.setHex(ca)).lerp(this._colB.setHex(cb), f);\n const lerpN = (na: number, nb: number) => na + (nb - na) * f;\n\n lerpC(this.skyColor, a.sky, b.sky);\n lerpC(this.sun.color, a.sun, b.sun);\n lerpC(this.hemi.color, a.hemiSky, b.hemiSky);\n lerpC(this.hemi.groundColor, a.hemiGround, b.hemiGround);\n this.hemi.intensity = lerpN(a.hemiI, b.hemiI);\n\n const fog = this.scene.fog as THREE.Fog;\n lerpC(fog.color, a.fog, b.fog);\n if (medium === \"lava\") {\n fog.color.setHex(0xc04a10);\n fog.near = 0.1;\n fog.far = 2.5;\n } else if (medium === \"powder_snow\") {\n // голова в снегу-порохе: белая пелена вплотную, как в Minecraft — а не вид сквозь землю\n fog.color.setHex(POWDER_FOG);\n fog.near = 0;\n fog.far = 1.5;\n } else if (underwater) {\n fog.color.setHex(0x1a4a8a);\n fog.near = 2;\n fog.far = 18;\n } else {\n // Туман только прячет край прогрузки: начинается далеко, иначе дымка смывает насыщенность.\n fog.far = CONFIG.renderDistance * 16 - 8;\n fog.near = fog.far * 0.55;\n }\n // дождь: небо и туман серые и темнее, дымка ближе; в грозу ещё темнее\n const { rain, thunder } = weather;\n if (rain > 0) {\n const gray = (c: THREE.Color) => {\n const l = 0.3 * c.r + 0.59 * c.g + 0.11 * c.b;\n c.lerp(this._colA.setRGB(l, l, l), 0.8 * rain).multiplyScalar(\n 1 - 0.35 * rain - 0.3 * thunder,\n );\n };\n gray(this.skyColor);\n if (!underwater) {\n gray(fog.color);\n fog.near = fog.far * (0.55 - 0.4 * rain);\n }\n }\n this.renderer.setClearColor(\n medium === \"lava\"\n ? 0xc04a10\n : medium === \"powder_snow\"\n ? POWDER_FOG\n : underwater\n ? 0x1a4a8a\n : this.skyColor,\n );\n\n const dim = DIMENSIONS[dimension];\n // фоновый свет измерения без неба (Пекло, Предел) — из описания измерения\n LIGHT_AMBIENT.value = dim.hasSky ? 0.03 : dim.ambient.level;\n // Ночное зрение: темнота почти не темнит (пещеры и ночь видно)\n if (nightVision) LIGHT_AMBIENT.value = Math.max(LIGHT_AMBIENT.value, 0.85);\n if (!dim.hasSky) {\n // Пекло и Предел: своё постоянное небо и густой туман — без светил и суточного цикла\n this.skyColor.setHex(dim.sky);\n if (!underwater) {\n fog.color.setHex(dim.fog);\n fog.far = CONFIG.renderDistance * 16 * 0.75;\n fog.near = fog.far * 0.2;\n this.renderer.setClearColor(this.skyColor);\n }\n this.sun.intensity = 0;\n this.sun.shadow.intensity = 0;\n // рассеянный свет — свой цвет измерения (цвета тумана и неба почти чёрные: Предел был не виден)\n this.hemi.color.setHex(dim.ambient.color);\n this.hemi.groundColor.setHex(dim.ambient.color).multiplyScalar(0.55);\n this.hemi.intensity = 0.9;\n this.sunSprite.material.opacity = 0;\n this.moonSprite.material.opacity = 0;\n return;\n }\n\n // солнце: восход на востоке (+x) в t=0, зенит в t=0.25, закат в t=0.5, ночью — под горизонтом\n const elev = Math.sin(Math.PI * (0.5 - Math.abs(t - 0.25) * 2)); // 1 в полдень, <0 ночью\n const az = Math.cos((t - 0.25) * Math.PI * 2);\n const sunDir = this._sunDir.set(az, elev, 0.3).normalize();\n\n // Направленный свет всегда сверху: днём от солнца, ночью от луны (она напротив). У горизонта\n // он гаснет и только потом переключается — иначе на закате тени и освещённые грани\n // перескакивали бы на противоположную сторону.\n const lightDir = this._lightDir.copy(sunDir);\n if (lightDir.y < 0) lightDir.negate();\n const horizon = THREE.MathUtils.smoothstep(lightDir.y, 0.02, 0.3);\n this.sun.intensity = lerpN(a.sunI, b.sunI) * horizon;\n this.sun.shadow.intensity = G.shadowStrength * horizon;\n this._placeSun(playerPos, lightDir);\n\n this.sunSprite.position.copy(playerPos).addScaledVector(sunDir, 70);\n this.moonSprite.position.copy(playerPos).addScaledVector(sunDir, -70);\n this.sunSprite.material.opacity = Math.max(\n 0,\n Math.min(1, sunDir.y * 4 + 0.4),\n );\n this.moonSprite.material.opacity = Math.max(\n 0,\n Math.min(1, -sunDir.y * 4 + 0.4),\n );\n // за тучами солнца и луны не видно, тени бледнеют; молния на миг заливает всё светом\n if (rain > 0) {\n this.sun.intensity *= 1 - 0.75 * rain;\n this.sun.shadow.intensity *= 1 - 0.8 * rain;\n this.hemi.intensity *= 1 - 0.25 * rain - 0.2 * thunder;\n this.sunSprite.material.opacity *= 1 - rain;\n this.moonSprite.material.opacity *= 1 - rain;\n }\n if (weather.flash > 0) this.hemi.intensity += 3 * weather.flash;\n }\n\n /**\n * Эффект Тьмы (сила 0..1, main пульсирует ею): поверх кадра frame() туман подходит вплотную, небо,\n * туман и рассеянный свет гаснут. Ночное зрение Тьму не снимает — как в Minecraft.\n */\n darkness(k: number): void {\n if (k <= 0) return;\n const fog = this.scene.fog as THREE.Fog;\n // цвета линейные, на экране — через sRGB: гасить нужно почти до нуля, иначе выходит серая дымка\n const dim = 0.01 + 0.04 * (1 - k);\n fog.color.multiplyScalar(dim);\n fog.far = Math.min(fog.far, 40 - 30 * k);\n fog.near = Math.min(fog.near, 1);\n this.skyColor.multiplyScalar(dim);\n this.renderer.setClearColor(fog.color);\n LIGHT_AMBIENT.value = Math.min(LIGHT_AMBIENT.value, 0.03 * (1 - k));\n this.hemi.intensity *= 1 - 0.8 * k;\n this.sun.intensity *= 1 - 0.8 * k;\n }\n\n /**\n * Карта теней ездит за игроком. Её центр привязан к сетке текселей в пространстве света: без\n * этого при ходьбе край каждой тени пересчитывался бы со сдвигом на долю текселя и «кипел».\n */\n _placeSun(playerPos: THREE.Vector3, lightDir: THREE.Vector3) {\n const R = G.shadowDistance;\n const texel = (2 * R) / G.shadowMapSize;\n // тот же базис, что построит shadow-камера: z смотрит на свет, up — мировой\n this._basis.lookAt(lightDir, ORIGIN, UP);\n this._ax.setFromMatrixColumn(this._basis, 0);\n this._ay.setFromMatrixColumn(this._basis, 1);\n const c = this._center.copy(playerPos);\n const du = c.dot(this._ax);\n const dv = c.dot(this._ay);\n c.addScaledVector(this._ax, Math.round(du / texel) * texel - du);\n c.addScaledVector(this._ay, Math.round(dv / texel) * texel - dv);\n this.sun.position.copy(c).addScaledVector(lightDir, R * 3);\n this.sun.target.position.copy(c);\n }\n\n serialize() {\n return { time: this.time };\n }\n deserialize(d: { time?: number } | undefined) {\n // нет поля или не число (чужой файл) — время остаётся: NaN ломал небо, свет и спавн мобов\n const t = Number(d?.time);\n if (Number.isFinite(t) && t >= 0) this.time = t;\n }\n}\n"
1344
1348
  },
1345
1349
  {
1346
1350
  "path": "systems/Difficulty.test.ts",
@@ -1408,7 +1412,11 @@
1408
1412
  },
1409
1413
  {
1410
1414
  "path": "systems/Inventory.ts",
1411
- "content": "// Инвентарь — чистая модель данных без DOM (UI подписывается на invChanged).\n// 36 слотов: 0..8 — хотбар, 9..35 — рюкзак. Слот: {item, count, dmg?} | null.\n// Броня — 4 отдельных слота: шлем, нагрудник, поножи, ботинки.\nimport { events } from \"../core/EventBus\";\nimport { enchLevel } from \"./Enchant\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ArmorSlot, ItemStack, InventorySave } from \"../types\";\n\n/** Порядок слотов брони. */\nexport const ARMOR_SLOTS: readonly ArmorSlot[] = [\n \"head\",\n \"chest\",\n \"legs\",\n \"feet\",\n];\n\n/** Сколько слотов у сундука Предела (общего склада игрока). */\nexport const ENDER_SIZE = 27;\n\nexport class Inventory {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n armor: (ItemStack | null)[] = [null, null, null, null];\n /**\n * Сундук Предела: один склад на все свои копии и все измерения, поэтому он у игрока, а не у\n * клетки (world/behaviours/extras, экран — ui/InventoryUI).\n */\n ender: (ItemStack | null)[] = new Array(ENDER_SIZE).fill(null);\n\n constructor(size = 36) {\n this.slots = new Array(size).fill(null);\n this.hotbarIndex = 0;\n }\n\n _changed() {\n events.emit(\"invChanged\");\n }\n\n heldItem(): ItemStack | null {\n return this.slots[this.hotbarIndex]!;\n }\n\n selectSlot(i: number) {\n this.hotbarIndex = Math.max(0, Math.min(8, i));\n this._changed();\n }\n scrollSlot(delta: number) {\n this.hotbarIndex = (this.hotbarIndex + delta + 9) % 9;\n this._changed();\n }\n\n /** Добавить предметы (сначала в существующие стаки, потом в пустые слоты).\n * @returns {number} сколько НЕ поместилось */\n add(itemName: string, count: number): number {\n const max = ITEMS[itemName]?.maxStack ?? 64;\n const requested = count;\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName && s.count < max && !s.dmg) {\n const take = Math.min(count, max - s.count);\n s.count += take;\n count -= take;\n }\n }\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n if (!this.slots[i]) {\n const take = Math.min(count, max);\n this.slots[i] = { item: itemName, count: take };\n count -= take;\n }\n }\n // Ничего не легло (инвентарь полон) — подписчиков не будим: дроп у ног игрока зовёт add()\n // каждый тик, и HUD с рукой перерисовывались бы 20 раз в секунду впустую.\n if (count !== requested) this._changed();\n return count;\n }\n\n /**\n * Положить стак с износом (инструмент, броня) — в пустой слот целиком, износ сохраняется; обычный\n * стак — как add(). @returns сколько не поместилось\n */\n addStack(s: ItemStack): number {\n if (!s.dmg && !s.tag) return this.add(s.item, s.count);\n const i = this.slots.findIndex((x) => !x);\n if (i < 0) return s.count;\n this.slots[i] = { ...s };\n this._changed();\n return 0;\n }\n\n /**\n * Заменить один предмет в руке другим (ведро → ведро воды). Из стака пустых вёдер берётся одно,\n * полное кладётся в инвентарь. @returns true — не поместилось (надо выбросить под ноги)\n */\n swapHeldOne(item: string): boolean {\n const s = this.slots[this.hotbarIndex];\n if (!s) return false;\n if (s.count <= 1) {\n this.slots[this.hotbarIndex] = { item, count: 1 };\n this._changed();\n return false;\n }\n s.count -= 1;\n const left = this.add(item, 1);\n this._changed();\n return left > 0;\n }\n\n consumeHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n if (!s) return;\n s.count -= n;\n if (s.count <= 0) this.slots[this.hotbarIndex] = null;\n this._changed();\n }\n\n /**\n * Износ предмета в руке (инструмент, оружие): n единиц прочности. Кончилась — предмет ломается,\n * как в Minecraft. У предметов без прочности ничего не происходит.\n */\n damageHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n const max = s ? (ITEMS[s.item]?.durability ?? 0) : 0;\n if (!s || !max) return;\n // Прочность: износ тратится лишь с шансом 1/(уровень+1), как в Minecraft\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = (s.dmg ?? 0) + n;\n if (s.dmg >= max) {\n this.slots[this.hotbarIndex] = null;\n events.emit(\"toolBreak\", { item: s.item });\n }\n this._changed();\n }\n\n /** Сколько предметов itemName во всех слотах (торговля). */\n count(itemName: string): number {\n let n = 0;\n for (const s of this.slots) if (s && s.item === itemName) n += s.count;\n return n;\n }\n\n /** Съесть count предметов itemName из любого места (для крафта). */\n consume(itemName: string, count: number) {\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName) {\n const take = Math.min(count, s.count);\n s.count -= take;\n count -= take;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n this._changed();\n }\n\n setSlot(i: number, stack: ItemStack | null) {\n this.slots[i] = stack;\n this._changed();\n }\n\n // ---- броня ----------------------------------------------------------------------------\n\n setArmor(i: number, stack: ItemStack | null) {\n this.armor[i] = stack;\n this._changed();\n }\n\n /** Сумма очков защиты и твёрдости надетой брони. */\n armorStats(): { points: number; toughness: number; protection: number } {\n let points = 0,\n toughness = 0,\n protection = 0;\n for (const s of this.armor) {\n const d = s ? ITEMS[s.item] : undefined;\n if (d) {\n points += d.armor;\n toughness += d.toughness;\n }\n protection += enchLevel(s, \"protection\");\n }\n return { points, toughness, protection };\n }\n\n /** Износ брони от удара: каждая часть теряет max(1, урон/4), как в Minecraft. */\n damageArmor(dmg: number) {\n const wear = Math.max(1, Math.floor(dmg / 4));\n let changed = false;\n this.armor.forEach((s, i) => {\n const max = s ? (ITEMS[s.item]?.durability ?? 0) : 0;\n if (!s || !max || !ITEMS[s.item]?.armor) return; // крылья от ударов не изнашиваются\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = (s.dmg ?? 0) + wear;\n changed = true;\n if (s.dmg >= max) {\n this.armor[i] = null;\n events.emit(\"toolBreak\", { item: s.item });\n }\n });\n if (changed) this._changed();\n }\n\n /** Надеты годные Крылья Предела: последняя единица прочности не летает (как у элитр). */\n canGlide(): boolean {\n const s = this.armor[1];\n return (\n s?.item === \"wings\" && (s.dmg ?? 0) < (ITEMS.wings?.durability ?? 0) - 1\n );\n }\n\n /** Секунда полёта на крыльях — единица износа (Прочность бережёт), до последней единицы. */\n wearWings(): void {\n const s = this.armor[1];\n const max = ITEMS.wings?.durability ?? 0;\n if (s?.item !== \"wings\" || !max) return;\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = Math.min(max - 1, (s.dmg ?? 0) + 1);\n this._changed();\n }\n\n serialize(): InventorySave {\n return {\n slots: this.slots,\n hotbarIndex: this.hotbarIndex,\n armor: this.armor,\n ender: this.ender,\n };\n }\n deserialize(d: InventorySave) {\n this.slots = d.slots.map((s) => (s ? { ...s } : null));\n while (this.slots.length < 36) this.slots.push(null);\n this.hotbarIndex = d.hotbarIndex ?? 0;\n this.armor = [0, 1, 2, 3].map((i) => {\n const s = d.armor?.[i];\n return s ? { ...s } : null;\n });\n // сейвы до сундука Предела поля не знают — склад просто пуст\n this.ender = new Array(ENDER_SIZE).fill(null).map((_, i) => {\n const s = d.ender?.[i];\n return s ? { ...s } : null;\n });\n this._changed();\n }\n}\n"
1415
+ "content": "// Инвентарь — чистая модель данных без DOM (UI подписывается на invChanged).\n// 36 слотов: 0..8 — хотбар, 9..35 — рюкзак. Слот: {item, count, dmg?} | null.\n// Броня — 4 отдельных слота: шлем, нагрудник, поножи, ботинки.\nimport { events } from \"../core/EventBus\";\nimport { enchLevel } from \"./Enchant\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ArmorSlot, ItemStack, InventorySave } from \"../types\";\n\n/** Порядок слотов брони. */\nexport const ARMOR_SLOTS: readonly ArmorSlot[] = [\n \"head\",\n \"chest\",\n \"legs\",\n \"feet\",\n];\n\n/** Сколько слотов у сундука Предела (общего склада игрока). */\nexport const ENDER_SIZE = 27;\n\n/**\n * Стак из чужих данных (сейв, Мастерская, старая версия игры): предмет должен существовать в игре,\n * иначе HUD и экран инвентаря падают на `ITEMS[item]!` каждый кадр. Негодное — пустой слот.\n */\nexport function sanitizeStack(s: unknown): ItemStack | null {\n if (!s || typeof s !== \"object\") return null;\n const v = s as Partial<ItemStack>;\n if (typeof v.item !== \"string\" || !ITEMS[v.item]) return null;\n const count = Math.floor(Number(v.count));\n if (!Number.isFinite(count) || count <= 0) return null;\n const out: ItemStack = { ...(v as ItemStack), count };\n if (out.dmg !== undefined && !(Number(out.dmg) >= 0)) delete out.dmg;\n return out;\n}\n\nexport class Inventory {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n armor: (ItemStack | null)[] = [null, null, null, null];\n /**\n * Сундук Предела: один склад на все свои копии и все измерения, поэтому он у игрока, а не у\n * клетки (world/behaviours/extras, экран — ui/InventoryUI).\n */\n ender: (ItemStack | null)[] = new Array(ENDER_SIZE).fill(null);\n\n constructor(size = 36) {\n this.slots = new Array(size).fill(null);\n this.hotbarIndex = 0;\n }\n\n _changed() {\n events.emit(\"invChanged\");\n }\n\n heldItem(): ItemStack | null {\n return this.slots[this.hotbarIndex]!;\n }\n\n selectSlot(i: number) {\n this.hotbarIndex = Math.max(0, Math.min(8, i));\n this._changed();\n }\n scrollSlot(delta: number) {\n this.hotbarIndex = (this.hotbarIndex + delta + 9) % 9;\n this._changed();\n }\n\n /** Добавить предметы (сначала в существующие стаки, потом в пустые слоты).\n * @returns {number} сколько НЕ поместилось */\n add(itemName: string, count: number): number {\n const max = ITEMS[itemName]?.maxStack ?? 64;\n const requested = count;\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n // в стак с износом или tag (зачарование, имя, содержимое) обычные предметы не доливаем\n if (s && s.item === itemName && s.count < max && !s.dmg && !s.tag) {\n const take = Math.min(count, max - s.count);\n s.count += take;\n count -= take;\n }\n }\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n if (!this.slots[i]) {\n const take = Math.min(count, max);\n this.slots[i] = { item: itemName, count: take };\n count -= take;\n }\n }\n // Ничего не легло (инвентарь полон) — подписчиков не будим: дроп у ног игрока зовёт add()\n // каждый тик, и HUD с рукой перерисовывались бы 20 раз в секунду впустую.\n if (count !== requested) this._changed();\n return count;\n }\n\n /**\n * Положить стак с износом (инструмент, броня) — в пустой слот целиком, износ сохраняется; обычный\n * стак — как add(). @returns сколько не поместилось\n */\n addStack(s: ItemStack): number {\n if (!s.dmg && !s.tag) return this.add(s.item, s.count);\n const i = this.slots.findIndex((x) => !x);\n if (i < 0) return s.count;\n this.slots[i] = { ...s };\n this._changed();\n return 0;\n }\n\n /**\n * Заменить один предмет в руке другим (ведро → ведро воды). Из стака пустых вёдер берётся одно,\n * полное кладётся в инвентарь. @returns true — не поместилось (надо выбросить под ноги)\n */\n swapHeldOne(item: string): boolean {\n const s = this.slots[this.hotbarIndex];\n if (!s) return false;\n if (s.count <= 1) {\n this.slots[this.hotbarIndex] = { item, count: 1 };\n this._changed();\n return false;\n }\n s.count -= 1;\n const left = this.add(item, 1);\n this._changed();\n return left > 0;\n }\n\n consumeHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n if (!s) return;\n s.count -= n;\n if (s.count <= 0) this.slots[this.hotbarIndex] = null;\n this._changed();\n }\n\n /**\n * Износ предмета в руке (инструмент, оружие): n единиц прочности. Кончилась — предмет ломается,\n * как в Minecraft. У предметов без прочности ничего не происходит.\n */\n damageHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n const max = s ? (ITEMS[s.item]?.durability ?? 0) : 0;\n if (!s || !max) return;\n // Прочность: износ тратится лишь с шансом 1/(уровень+1), как в Minecraft\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = (s.dmg ?? 0) + n;\n if (s.dmg >= max) {\n this.slots[this.hotbarIndex] = null;\n events.emit(\"toolBreak\", { item: s.item });\n }\n this._changed();\n }\n\n /** Сколько предметов itemName во всех слотах (торговля). */\n count(itemName: string): number {\n let n = 0;\n for (const s of this.slots) if (s && s.item === itemName) n += s.count;\n return n;\n }\n\n /** Съесть count предметов itemName из любого места (для крафта). */\n consume(itemName: string, count: number) {\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName) {\n const take = Math.min(count, s.count);\n s.count -= take;\n count -= take;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n this._changed();\n }\n\n setSlot(i: number, stack: ItemStack | null) {\n this.slots[i] = stack;\n this._changed();\n }\n\n // ---- броня ----------------------------------------------------------------------------\n\n setArmor(i: number, stack: ItemStack | null) {\n this.armor[i] = stack;\n this._changed();\n }\n\n /** Сумма очков защиты и твёрдости надетой брони. */\n armorStats(): { points: number; toughness: number; protection: number } {\n let points = 0,\n toughness = 0,\n protection = 0;\n for (const s of this.armor) {\n const d = s ? ITEMS[s.item] : undefined;\n if (d) {\n points += d.armor;\n toughness += d.toughness;\n }\n protection += enchLevel(s, \"protection\");\n }\n return { points, toughness, protection };\n }\n\n /** Износ брони от удара: каждая часть теряет max(1, урон/4), как в Minecraft. */\n damageArmor(dmg: number) {\n const wear = Math.max(1, Math.floor(dmg / 4));\n let changed = false;\n this.armor.forEach((s, i) => {\n const max = s ? (ITEMS[s.item]?.durability ?? 0) : 0;\n if (!s || !max || !ITEMS[s.item]?.armor) return; // крылья от ударов не изнашиваются\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = (s.dmg ?? 0) + wear;\n changed = true;\n if (s.dmg >= max) {\n this.armor[i] = null;\n events.emit(\"toolBreak\", { item: s.item });\n }\n });\n if (changed) this._changed();\n }\n\n /** Надеты годные Крылья Предела: последняя единица прочности не летает (как у элитр). */\n canGlide(): boolean {\n const s = this.armor[1];\n return (\n s?.item === \"wings\" && (s.dmg ?? 0) < (ITEMS.wings?.durability ?? 0) - 1\n );\n }\n\n /** Секунда полёта на крыльях — единица износа (Прочность бережёт), до последней единицы. */\n wearWings(): void {\n const s = this.armor[1];\n const max = ITEMS.wings?.durability ?? 0;\n if (s?.item !== \"wings\" || !max) return;\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = Math.min(max - 1, (s.dmg ?? 0) + 1);\n this._changed();\n }\n\n serialize(): InventorySave {\n return {\n slots: this.slots,\n hotbarIndex: this.hotbarIndex,\n armor: this.armor,\n ender: this.ender,\n };\n }\n deserialize(d: InventorySave) {\n const slots = Array.isArray(d?.slots) ? d.slots : [];\n this.slots = new Array(36)\n .fill(null)\n .map((_, i) => sanitizeStack(slots[i]));\n const hb = Number(d?.hotbarIndex);\n this.hotbarIndex = Number.isInteger(hb) && hb >= 0 && hb <= 8 ? hb : 0;\n this.armor = [0, 1, 2, 3].map((i) => sanitizeStack(d?.armor?.[i]));\n // сейвы до сундука Предела поля не знают — склад просто пуст\n this.ender = new Array(ENDER_SIZE)\n .fill(null)\n .map((_, i) => sanitizeStack(d?.ender?.[i]));\n this._changed();\n }\n\n /**\n * Смерть: рюкзак, хотбар и броня пусты (их вещи Game уже бросил на землю). Сундук Предела —\n * склад, а не карманы: его содержимое смерть не трогает, как в Minecraft.\n */\n clearCarried() {\n this.slots = new Array(36).fill(null);\n this.armor = [null, null, null, null];\n this._changed();\n }\n}\n"
1416
+ },
1417
+ {
1418
+ "path": "systems/InventoryLoad.test.ts",
1419
+ "content": "import { describe, expect, it } from \"vitest\";\nimport \"../registry/Items\";\nimport { Inventory, sanitizeStack } from \"./Inventory\";\n\ndescribe(\"инвентарь: загрузка и смерть\", () => {\n it(\"предмет, которого нет в игре, и битые стаки из чужого сейва становятся пустыми слотами\", () => {\n expect(sanitizeStack({ item: \"no_such_item_xyz\", count: 3 })).toBeNull();\n expect(sanitizeStack({ item: \"dirt\", count: 0 })).toBeNull();\n expect(sanitizeStack({ item: \"dirt\", count: \"abc\" })).toBeNull();\n expect(sanitizeStack(null)).toBeNull();\n expect(sanitizeStack({ item: \"dirt\", count: 5.7 })).toEqual({\n item: \"dirt\",\n count: 5,\n });\n const inv = new Inventory();\n inv.deserialize({\n slots: [\n { item: \"no_such_item_xyz\", count: 1 },\n { item: \"dirt\", count: 2 },\n ],\n hotbarIndex: 42,\n });\n expect(inv.slots[0]).toBeNull();\n expect(inv.slots[1]).toEqual({ item: \"dirt\", count: 2 });\n expect(inv.slots.length).toBe(36);\n expect(inv.hotbarIndex).toBe(0);\n });\n\n it(\"смерть очищает карманы и броню, но не сундук Предела\", () => {\n const inv = new Inventory();\n inv.slots[0] = { item: \"dirt\", count: 5 };\n inv.armor[3] = { item: \"leather_boots\", count: 1 };\n inv.ender[0] = { item: \"diamond\", count: 7 };\n inv.clearCarried();\n expect(inv.slots.every((s) => s === null)).toBe(true);\n expect(inv.armor.every((s) => s === null)).toBe(true);\n expect(inv.ender[0]).toEqual({ item: \"diamond\", count: 7 });\n });\n\n it(\"обычные предметы не доливаются в стак с tag (чары, имя)\", () => {\n const inv = new Inventory();\n inv.slots[0] = { item: \"dirt\", count: 1, tag: { name: \"Особая\" } };\n inv.add(\"dirt\", 3);\n expect(inv.slots[0]).toEqual({\n item: \"dirt\",\n count: 1,\n tag: { name: \"Особая\" },\n });\n expect(inv.slots[1]).toEqual({ item: \"dirt\", count: 3 });\n });\n});\n"
1412
1420
  },
1413
1421
  {
1414
1422
  "path": "systems/Jobs.test.ts",
@@ -1468,7 +1476,7 @@
1468
1476
  },
1469
1477
  {
1470
1478
  "path": "systems/Save.ts",
1471
- "content": "// Сохранение: экспорт/импорт мира в JSON-файл. Формат = полный снапшот\n// этапа 2 (мультиплеер): источник мира (сид или рецепт) + дифы блоков + игрок + инвентарь +\n// сущности + время. Мир восстанавливается повторной генерацией, поверх неё ложатся дифы.\n//\n// v1 — только сид (классический мир). v2 — плюс `world`. v3 — плюс палитра id→имя (дифы\n// переносятся по именам), измерения (Пекло, Предел), блочные сущности и точка возрождения.\n// Верхний мир всегда лежит в корне (`chunks`, `entities`) — его читают и Мастерская, и старый код.\n// Импорт понимает все версии и, заодно, голый рецепт мира (файл «Скачать рецепт»).\nimport { CONFIG } from \"../config\";\nimport { difficulty, parseDifficulty, setDifficulty } from \"./Difficulty\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport {\n DIMENSION_IDS,\n isDimensionId,\n type DimensionId,\n} from \"../world/Dimensions\";\nimport type { Game } from \"../main\";\nimport type {\n ChunkDiffMap,\n DimensionSave,\n SaveData,\n WorldSource,\n} from \"../types\";\nimport { parseWorldSpec } from \"../worldgen/WorldSpec\";\nimport { portalsOf } from \"../world/Portal\";\nimport { deserializeMap, serializeMap } from \"./Maps\";\n\nconst isObj = (v: unknown): v is Record<string, unknown> =>\n typeof v === \"object\" && v !== null && !Array.isArray(v);\n\n/** Клетка Врат Предела из сейва: три числа или null. */\nfunction gatewayOf(v: unknown): [number, number, number] | null {\n if (!Array.isArray(v) || v.length !== 3) return null;\n const [x, y, z] = v.map(Number) as [number, number, number];\n return [x, y, z].every(Number.isFinite) ? [x, y, z] : null;\n}\n\n/** Имя файла без запрещённых символов. */\nexport function safeFileName(name: string): string {\n return (\n name\n .replace(/[\\\\/:*?\"<>|]+/g, \"\")\n .replace(/\\s+/g, \"-\")\n .slice(0, 40) || \"world\"\n );\n}\n\n/** Скачать текст файлом. */\nexport function downloadText(\n fileName: string,\n text: string,\n type = \"application/json\",\n) {\n const blob = new Blob([text], { type });\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(blob);\n a.download = fileName;\n a.click();\n // отзывать сразу нельзя: часть браузеров (Firefox, Safari) ещё не начала скачивание\n const url = a.href;\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\n/** Выбрать файл и прочитать его текстом. */\nexport function pickTextFile(accept: string, onText: (text: string) => void) {\n const input = document.createElement(\"input\");\n input.type = \"file\";\n input.accept = accept;\n input.onchange = () => {\n const file = input.files?.[0];\n if (!file) return;\n const reader = new FileReader();\n reader.onload = () => onText(String(reader.result));\n reader.readAsText(file);\n };\n input.click();\n}\n\n/**\n * Источник мира из сейва. Рецепт из файла — чужие данные: проходит ту же проверку, что и ответ\n * модели, иначе сломанный сейв ронял бы генератор.\n */\nexport function worldSourceOf(data: SaveData): WorldSource {\n const w: unknown = data.world;\n // поколение генератора сохраняется как есть: нет поля — поколение 1 (все сейвы до v3)\n const gen =\n isObj(w) && typeof w.gen === \"number\" && Number.isInteger(w.gen)\n ? { gen: w.gen }\n : {};\n if (isObj(w) && w.kind === \"spec\") {\n const parsed = parseWorldSpec(w.spec);\n if (!parsed.ok)\n throw new Error(\"рецепт мира в сейве повреждён: \" + parsed.error);\n return { kind: \"spec\", spec: parsed.spec, ...gen };\n }\n if (isObj(w) && w.kind === \"noise\" && typeof w.seed === \"number\")\n return { kind: \"noise\", seed: w.seed, ...gen };\n return { kind: \"noise\", seed: data.seed };\n}\n\n/**\n * Перенос id блоков по палитре сейва: старый id → имя → нынешний id. null — переносить нечего\n * (палитры нет, как у v1/v2, или она совпадает с нынешним реестром). Блок, которого больше нет в\n * игре, становится воздухом.\n */\nexport function paletteRemap(\n palette: unknown,\n): ((id: number) => number) | null {\n if (!Array.isArray(palette)) return null;\n const map = palette.map((n) =>\n typeof n === \"string\" ? (B[n] ?? B.air!) : B.air!,\n );\n if (map.every((v, i) => v === i)) return null;\n return (id) => map[id] ?? B.air!;\n}\n\n/** Дифы с перенесёнными id. */\nexport function remapDiffs(\n diffs: ChunkDiffMap,\n remap: ((id: number) => number) | null,\n): ChunkDiffMap {\n if (!remap) return diffs;\n const out: ChunkDiffMap = {};\n for (const [k, arr] of Object.entries(diffs))\n out[k] = arr.map(([i, id, meta]): [number, number, number] => [\n i,\n remap(id),\n meta,\n ]);\n return out;\n}\n\nexport class SaveSystem {\n game: Game;\n\n constructor(game: Game) {\n this.game = game;\n } // game — фасад из main.ts\n\n collect(): SaveData {\n const g = this.game;\n const here = g.dimension;\n const current: DimensionSave = {\n chunks: g.world.serializeDiffs(),\n entities: g.entities.serialize(),\n blockEntities: g.blockEntities.serialize(),\n };\n const dimOf = (d: DimensionId): DimensionSave | undefined =>\n d === here ? current : g.dims.get(d);\n const ow = dimOf(\"overworld\") ?? { chunks: {} };\n const others: Partial<Record<DimensionId, DimensionSave>> = {};\n for (const d of DIMENSION_IDS)\n if (d !== \"overworld\") {\n const s = dimOf(d);\n if (s) others[d] = s;\n }\n return {\n version: CONFIG.saveVersion,\n seed: g.world.seed,\n world: g.world.source,\n time: g.dayNight.serialize(),\n weather: g.weather.serialize(),\n difficulty: difficulty(),\n player: { ...g.player.serialize(), inventory: g.inventory.serialize() },\n palette: BLOCKS.map((b) => b.name),\n chunks: ow.chunks,\n entities: ow.entities ?? [],\n blockEntities: ow.blockEntities ?? [],\n dimension: here,\n dimensions: others,\n portals: g.portals,\n beyond: g.beyondState,\n villages: [...g.villagesSettled],\n achievements: g.achievements.serialize(),\n maps: [...g.maps.values()].map(serializeMap),\n daysAwake: g.daysAwake,\n };\n }\n\n exportFile() {\n const src = this.game.world.source;\n const name =\n src.kind === \"spec\" ? safeFileName(src.spec.name) : String(src.seed);\n downloadText(\n `voxelcraft-save-${name}.json`,\n JSON.stringify(this.collect()),\n );\n }\n\n /** Импорт из выбранного файла: сейв (v1/v2/v3) или голый рецепт мира. */\n importFile(onDone?: (err: unknown) => void) {\n pickTextFile(\".json,application/json\", (text) => {\n try {\n this.importText(text);\n onDone?.(null);\n } catch (e) {\n onDone?.(e);\n }\n });\n }\n\n importText(text: string) {\n const data: unknown = JSON.parse(text);\n if (!isObj(data)) throw new Error(\"не похоже на сейв VoxelCraft\");\n if (\n isObj(data.chunks) &&\n (typeof data.seed === \"number\" || isObj(data.world))\n ) {\n const save = data as unknown as SaveData;\n void this.game.persist(); // снимок текущего мира берётся синхронно, до замены\n this.apply(save);\n const src = this.game.world.source;\n this.game.adoptNewWorld(\n src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed,\n );\n return;\n }\n // не сейв — может быть, рецепт мира\n if (\"terrain\" in data || \"zones\" in data || \"structures\" in data) {\n const parsed = parseWorldSpec(data);\n if (!parsed.ok) throw new Error(parsed.error);\n this.game.newWorld({ kind: \"spec\", spec: parsed.spec });\n return;\n }\n throw new Error(\"не похоже на сейв VoxelCraft\");\n }\n\n apply(data: SaveData) {\n const g = this.game;\n const remap = paletteRemap(data.palette);\n const src = worldSourceOf(data);\n const all: Partial<Record<DimensionId, DimensionSave>> = {\n ...(isObj(data.dimensions) ? data.dimensions : {}),\n overworld: {\n chunks: data.chunks,\n entities: data.entities,\n blockEntities: data.blockEntities,\n },\n };\n const here: DimensionId = isDimensionId(data.dimension)\n ? data.dimension\n : \"overworld\";\n\n g.dims.clear();\n let current: DimensionSave = { chunks: {} };\n for (const d of DIMENSION_IDS) {\n const s = all[d];\n if (!s) continue;\n const fixed: DimensionSave = {\n ...s,\n chunks: remapDiffs(isObj(s.chunks) ? s.chunks : {}, remap),\n };\n if (d === here) current = fixed;\n else g.dims.set(d, fixed);\n }\n\n g.rebuildWorld(src, here); // новый ChunkManager с этим источником и измерением\n g.world.loadDiffs(current.chunks);\n g.player.deserialize(data.player);\n g.inventory.deserialize(data.player.inventory);\n g.dayNight.deserialize(data.time);\n g.weather.deserialize(data.weather);\n // сложность мира: нет поля в старом сейве — обычная (systems/Difficulty)\n setDifficulty(parseDifficulty(data.difficulty) ?? \"normal\");\n g.entities.deserialize(\n current.entities as Parameters<Game[\"entities\"][\"deserialize\"]>[0],\n );\n g.blockEntities.deserialize(current.blockEntities);\n g.portals = portalsOf(data.portals);\n g.maps = new Map();\n for (const d of Array.isArray(data.maps) ? data.maps : []) {\n const m = deserializeMap(d);\n if (m) g.maps.set(m.id, m);\n }\n g.nextMapId = Math.max(0, ...g.maps.keys()) + 1;\n g.daysAwake =\n typeof data.daysAwake === \"number\" && data.daysAwake >= 0\n ? data.daysAwake\n : 0;\n g.villagesSettled = new Set(\n Array.isArray(data.villages)\n ? data.villages.filter((s): s is string => typeof s === \"string\")\n : [],\n );\n g.achievements.load(data.achievements);\n const b = isObj(data.beyond) ? data.beyond : {};\n g.beyondState = {\n initialized: b.initialized === true,\n dragonKilled: b.dragonKilled === true,\n creditsShown: b.creditsShown === true,\n gateway: gatewayOf(b.gateway),\n };\n }\n}\n"
1479
+ "content": "// Сохранение: экспорт/импорт мира в JSON-файл. Формат = полный снапшот\n// этапа 2 (мультиплеер): источник мира (сид или рецепт) + дифы блоков + игрок + инвентарь +\n// сущности + время. Мир восстанавливается повторной генерацией, поверх неё ложатся дифы.\n//\n// v1 — только сид (классический мир). v2 — плюс `world`. v3 — плюс палитра id→имя (дифы\n// переносятся по именам), измерения (Пекло, Предел), блочные сущности и точка возрождения.\n// Верхний мир всегда лежит в корне (`chunks`, `entities`) — его читают и Мастерская, и старый код.\n// Импорт понимает все версии и, заодно, голый рецепт мира (файл «Скачать рецепт»).\nimport { CONFIG } from \"../config\";\nimport { difficulty, parseDifficulty, setDifficulty } from \"./Difficulty\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport {\n DIMENSION_IDS,\n isDimensionId,\n type DimensionId,\n} from \"../world/Dimensions\";\nimport type { Game } from \"../main\";\nimport type {\n ChunkDiffMap,\n DimensionSave,\n SaveData,\n WorldSource,\n} from \"../types\";\nimport { parseWorldSpec } from \"../worldgen/WorldSpec\";\nimport { portalsOf } from \"../world/Portal\";\nimport { deserializeMap, serializeMap } from \"./Maps\";\n\nconst isObj = (v: unknown): v is Record<string, unknown> =>\n typeof v === \"object\" && v !== null && !Array.isArray(v);\n\n/** Клетка Врат Предела из сейва: три числа или null. */\nfunction gatewayOf(v: unknown): [number, number, number] | null {\n if (!Array.isArray(v) || v.length !== 3) return null;\n const [x, y, z] = v.map(Number) as [number, number, number];\n return [x, y, z].every(Number.isFinite) ? [x, y, z] : null;\n}\n\n/** Имя файла без запрещённых символов. */\nexport function safeFileName(name: string): string {\n return (\n name\n .replace(/[\\\\/:*?\"<>|]+/g, \"\")\n .replace(/\\s+/g, \"-\")\n .slice(0, 40) || \"world\"\n );\n}\n\n/** Скачать текст файлом. */\nexport function downloadText(\n fileName: string,\n text: string,\n type = \"application/json\",\n) {\n const blob = new Blob([text], { type });\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(blob);\n a.download = fileName;\n a.click();\n // отзывать сразу нельзя: часть браузеров (Firefox, Safari) ещё не начала скачивание\n const url = a.href;\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\n/** Выбрать файл и прочитать его текстом. */\nexport function pickTextFile(accept: string, onText: (text: string) => void) {\n const input = document.createElement(\"input\");\n input.type = \"file\";\n input.accept = accept;\n input.onchange = () => {\n const file = input.files?.[0];\n if (!file) return;\n const reader = new FileReader();\n reader.onload = () => onText(String(reader.result));\n reader.readAsText(file);\n };\n input.click();\n}\n\n/**\n * Источник мира из сейва. Рецепт из файла — чужие данные: проходит ту же проверку, что и ответ\n * модели, иначе сломанный сейв ронял бы генератор.\n */\nexport function worldSourceOf(data: SaveData): WorldSource {\n const w: unknown = data.world;\n // поколение генератора сохраняется как есть: нет поля — поколение 1 (все сейвы до v3)\n const gen =\n isObj(w) && typeof w.gen === \"number\" && Number.isInteger(w.gen)\n ? { gen: w.gen }\n : {};\n if (isObj(w) && w.kind === \"spec\") {\n const parsed = parseWorldSpec(w.spec);\n if (!parsed.ok)\n throw new Error(\"рецепт мира в сейве повреждён: \" + parsed.error);\n return { kind: \"spec\", spec: parsed.spec, ...gen };\n }\n if (isObj(w) && w.kind === \"noise\" && typeof w.seed === \"number\")\n return { kind: \"noise\", seed: w.seed, ...gen };\n return { kind: \"noise\", seed: data.seed };\n}\n\n/**\n * Перенос id блоков по палитре сейва: старый id → имя → нынешний id. null — переносить нечего\n * (палитры нет, как у v1/v2, или она совпадает с нынешним реестром). Блок, которого больше нет в\n * игре, становится воздухом.\n */\nexport function paletteRemap(\n palette: unknown,\n): ((id: number) => number) | null {\n if (!Array.isArray(palette)) return null;\n const map = palette.map((n) =>\n typeof n === \"string\" ? (B[n] ?? B.air!) : B.air!,\n );\n if (map.every((v, i) => v === i)) return null;\n return (id) => map[id] ?? B.air!;\n}\n\n/** Дифы из чужого файла: только записи вида [индекс, id, мета] из целых чисел. */\nfunction cleanDiffs(v: unknown): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n if (!isObj(v)) return out;\n for (const [k, arr] of Object.entries(v)) {\n if (!Array.isArray(arr)) continue;\n out[k] = (arr as unknown[]).filter(\n (e): e is [number, number, number] =>\n Array.isArray(e) && e.length === 3 && e.every(Number.isInteger),\n );\n }\n return out;\n}\n\n/**\n * id вне нынешнего реестра — воздух (сейв без палитры из другой версии): BLOCKS[id]! роняли бы мешер\n * и физику. Только ПОСЛЕ remapDiffs — до него id сейва с палитрой указывают в его палитру, а не в реестр.\n */\nfunction clampIds(diffs: ChunkDiffMap): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n for (const [k, arr] of Object.entries(diffs))\n out[k] = arr.map(([i, id, meta]): [number, number, number] =>\n id >= 0 && id < BLOCKS.length ? [i, id, meta] : [i, B.air!, 0],\n );\n return out;\n}\n\n/** Дифы с перенесёнными id. */\nexport function remapDiffs(\n diffs: ChunkDiffMap,\n remap: ((id: number) => number) | null,\n): ChunkDiffMap {\n if (!remap) return diffs;\n const out: ChunkDiffMap = {};\n for (const [k, arr] of Object.entries(diffs))\n out[k] = arr.map(([i, id, meta]): [number, number, number] => [\n i,\n remap(id),\n meta,\n ]);\n return out;\n}\n\nexport class SaveSystem {\n game: Game;\n\n constructor(game: Game) {\n this.game = game;\n } // game — фасад из main.ts\n\n collect(): SaveData {\n const g = this.game;\n const here = g.dimension;\n const current: DimensionSave = {\n chunks: g.world.serializeDiffs(),\n entities: g.entities.serialize(),\n blockEntities: g.blockEntities.serialize(),\n };\n const dimOf = (d: DimensionId): DimensionSave | undefined =>\n d === here ? current : g.dims.get(d);\n const ow = dimOf(\"overworld\") ?? { chunks: {} };\n const others: Partial<Record<DimensionId, DimensionSave>> = {};\n for (const d of DIMENSION_IDS)\n if (d !== \"overworld\") {\n const s = dimOf(d);\n if (s) others[d] = s;\n }\n return {\n version: CONFIG.saveVersion,\n seed: g.world.seed,\n world: g.world.source,\n time: g.dayNight.serialize(),\n weather: g.weather.serialize(),\n difficulty: difficulty(),\n player: { ...g.player.serialize(), inventory: g.inventory.serialize() },\n palette: BLOCKS.map((b) => b.name),\n chunks: ow.chunks,\n entities: ow.entities ?? [],\n blockEntities: ow.blockEntities ?? [],\n dimension: here,\n dimensions: others,\n portals: g.portals,\n beyond: g.beyondState,\n villages: [...g.villagesSettled],\n achievements: g.achievements.serialize(),\n maps: [...g.maps.values()].map(serializeMap),\n daysAwake: g.daysAwake,\n };\n }\n\n exportFile() {\n const src = this.game.world.source;\n const name =\n src.kind === \"spec\" ? safeFileName(src.spec.name) : String(src.seed);\n downloadText(\n `voxelcraft-save-${name}.json`,\n JSON.stringify(this.collect()),\n );\n }\n\n /** Импорт из выбранного файла: сейв (v1/v2/v3) или голый рецепт мира. */\n importFile(onDone?: (err: unknown) => void) {\n pickTextFile(\".json,application/json\", (text) => {\n try {\n this.importText(text);\n onDone?.(null);\n } catch (e) {\n onDone?.(e);\n }\n });\n }\n\n importText(text: string) {\n const data: unknown = JSON.parse(text);\n if (!isObj(data)) throw new Error(\"не похоже на сейв VoxelCraft\");\n if (\n isObj(data.chunks) &&\n (typeof data.seed === \"number\" || isObj(data.world))\n ) {\n const save = data as unknown as SaveData;\n void this.game.persist(); // снимок текущего мира берётся синхронно, до замены\n this.apply(save);\n const src = this.game.world.source;\n this.game.adoptNewWorld(\n src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed,\n );\n return;\n }\n // не сейв — может быть, рецепт мира\n if (\"terrain\" in data || \"zones\" in data || \"structures\" in data) {\n const parsed = parseWorldSpec(data);\n if (!parsed.ok) throw new Error(parsed.error);\n this.game.newWorld({ kind: \"spec\", spec: parsed.spec });\n return;\n }\n throw new Error(\"не похоже на сейв VoxelCraft\");\n }\n\n apply(data: SaveData) {\n const g = this.game;\n // всё, что может бросить, проверяем ДО замены мира: иначе мир уже пересоздан, а игрок и\n // инвентарь — от прошлого, и вызывающий (ловящий исключение) считает, что «мир остался прежним»\n const raw: unknown = data;\n const rawPlayer: unknown = isObj(raw) ? raw.player : undefined;\n if (!isObj(rawPlayer) || !Array.isArray(rawPlayer.pos))\n throw new Error(\"в сейве нет игрока\");\n const remap = paletteRemap(data.palette);\n const src = worldSourceOf(data);\n const all: Partial<Record<DimensionId, DimensionSave>> = {\n ...(isObj(data.dimensions) ? data.dimensions : {}),\n overworld: {\n chunks: data.chunks,\n entities: data.entities,\n blockEntities: data.blockEntities,\n },\n };\n const here: DimensionId = isDimensionId(data.dimension)\n ? data.dimension\n : \"overworld\";\n\n // измерения собираем в стороне и только потом заменяем: раньше dims чистились до разбора, и\n // сломанный сейв, упав на кривом чанке, стирал Пекло и Предел ТЕКУЩЕГО мира\n let current: DimensionSave = { chunks: {} };\n const others = new Map<DimensionId, DimensionSave>();\n for (const d of DIMENSION_IDS) {\n const s = all[d];\n if (!isObj(s)) continue;\n const fixed: DimensionSave = {\n ...s,\n chunks: clampIds(remapDiffs(cleanDiffs(s.chunks), remap)),\n };\n if (d === here) current = fixed;\n else others.set(d, fixed);\n }\n g.dims.clear();\n for (const [d, s] of others) g.dims.set(d, s);\n\n g.rebuildWorld(src, here); // новый ChunkManager с этим источником и измерением\n g.world.loadDiffs(current.chunks);\n g.player.deserialize(data.player);\n g.inventory.deserialize(data.player.inventory);\n g.dayNight.deserialize(data.time);\n g.weather.deserialize(data.weather);\n // сложность мира: нет поля в старом сейве — обычная (systems/Difficulty)\n setDifficulty(parseDifficulty(data.difficulty) ?? \"normal\");\n g.entities.deserialize(\n current.entities as Parameters<Game[\"entities\"][\"deserialize\"]>[0],\n );\n g.blockEntities.deserialize(current.blockEntities);\n g.portals = portalsOf(data.portals);\n g.maps = new Map();\n for (const d of Array.isArray(data.maps) ? data.maps : []) {\n const m = deserializeMap(d);\n if (m) g.maps.set(m.id, m);\n }\n g.nextMapId = Math.max(0, ...g.maps.keys()) + 1;\n g.daysAwake =\n typeof data.daysAwake === \"number\" && data.daysAwake >= 0\n ? data.daysAwake\n : 0;\n g.villagesSettled = new Set(\n Array.isArray(data.villages)\n ? data.villages.filter((s): s is string => typeof s === \"string\")\n : [],\n );\n g.achievements.load(data.achievements);\n const b = isObj(data.beyond) ? data.beyond : {};\n g.beyondState = {\n initialized: b.initialized === true,\n dragonKilled: b.dragonKilled === true,\n creditsShown: b.creditsShown === true,\n gateway: gatewayOf(b.gateway),\n };\n }\n}\n"
1472
1480
  },
1473
1481
  {
1474
1482
  "path": "systems/Scaffold.test.ts",
@@ -1568,11 +1576,11 @@
1568
1576
  },
1569
1577
  {
1570
1578
  "path": "ui/BookScreen.ts",
1571
- "content": "// Книга на кафедре: ПКМ кладёт книгу из руки, открывает её на правку («Книга с пером») или на\n// чтение (написанная). Правка — по страницам, «Подписать» превращает книгу в написанную.\n// Чистая часть (страницы, подпись, перенос строк) — systems/Book; хранение — блочная сущность\n// lectern. Экран устроен как редактор таблички: ввод забирает клавиши у игры и отпускает захват.\nimport { events } from \"../core/EventBus\";\nimport {\n MAX_PAGES,\n bookOf,\n isBook,\n isWritable,\n pageLines,\n signBook,\n withPages,\n} from \"../systems/Book\";\nimport type { LecternState } from \"../world/blockentities/lectern\";\nimport type { ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\n\ntype At = { x: number; y: number; z: number };\n\nexport class BookScreen {\n open = false;\n readonly root: HTMLDivElement;\n private readonly titleEl: HTMLDivElement;\n private readonly pageEl: HTMLDivElement;\n private readonly editEl: HTMLTextAreaElement;\n private readonly countEl: HTMLDivElement;\n private readonly signRow: HTMLDivElement;\n private readonly titleInput: HTMLInputElement;\n private at: At | null = null;\n private page = 0;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"bookScreen\";\n this.root.className = \"bookScreen hidden\";\n\n this.titleEl = this._div(this.root, \"bookTitle\");\n this.pageEl = this._div(this.root, \"bookPage\");\n this.editEl = document.createElement(\"textarea\");\n this.editEl.className = \"bookEdit hidden\";\n this.editEl.maxLength = 900;\n this.editEl.spellcheck = false;\n this.root.appendChild(this.editEl);\n\n const nav = this._div(this.root, \"bookNav\");\n this._button(nav, \"◀\", () => this._turn(-1));\n this.countEl = this._div(nav, \"bookCount\");\n this._button(nav, \"▶\", () => this._turn(1));\n\n this.signRow = this._div(this.root, \"bookNav\");\n this.titleInput = document.createElement(\"input\");\n this.titleInput.className = \"bookTitleInput\";\n this.titleInput.placeholder = \"Название\";\n this.titleInput.maxLength = 32;\n this.signRow.appendChild(this.titleInput);\n this._button(this.signRow, \"Подписать\", () => this._sign());\n\n const foot = this._div(this.root, \"bookNav\");\n this._button(foot, \"Забрать\", () => this._take());\n this._button(foot, \"Закрыть\", () => this.hide());\n\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openLectern\", (p: At) => this.show(p));\n for (const el of [this.editEl, this.titleInput] as HTMLElement[])\n el.addEventListener(\n \"keydown\",\n (e: KeyboardEvent) => {\n e.stopPropagation(); // клавиши ввода — не игре\n if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n private _div(parent: HTMLElement, cls: string): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n }\n\n private _button(parent: HTMLElement, text: string, fn: () => void): void {\n const b = document.createElement(\"div\");\n b.className = \"bookBtn\";\n b.textContent = text;\n b.onmousedown = (e) => {\n e.preventDefault();\n fn();\n };\n parent.appendChild(b);\n }\n\n /** Книга, лежащая на кафедре. */\n private _book(): ItemStack | null {\n const at = this.at;\n if (!at) return null;\n const be = this.game.blockEntities.ensure(at.x, at.y, at.z);\n return be?.type === \"lectern\"\n ? ((be.state as LecternState).items[0] ?? null)\n : null;\n }\n\n private _setBook(s: ItemStack | null): void {\n const at = this.at;\n if (!at) return;\n const be = this.game.blockEntities.ensure(at.x, at.y, at.z);\n if (be?.type === \"lectern\") (be.state as LecternState).items[0] = s;\n }\n\n show(p: At): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.ensure(p.x, p.y, p.z);\n if (be?.type !== \"lectern\") return;\n this.at = p;\n // кафедра пуста, а в руке книга — она ложится на кафедру, как в Minecraft\n const st = be.state as LecternState;\n const held = g.inventory.heldItem();\n if (!st.items[0] && held && isBook(held.item)) {\n st.items[0] = { ...held, count: 1 };\n g.inventory.consumeHeld(1);\n events.emit(\"bookPlaced\", { ...p, item: held.item });\n }\n if (!st.items[0]) {\n this.at = null;\n return; // класть нечего и читать нечего\n }\n this.open = true;\n this.page = 0;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.remove(\"hidden\");\n this._render();\n }\n\n hide(): void {\n if (!this.open) return;\n this._save();\n this.open = false;\n this.at = null;\n this.root.classList.add(\"hidden\");\n this.editEl.blur();\n this.titleInput.blur();\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Записать набранную страницу в книгу (правится только «книга с пером»). */\n private _save(): void {\n const book = this._book();\n if (!book || !isWritable(book)) return;\n const pages = bookOf(book).pages.slice();\n while (pages.length <= this.page) pages.push(\"\");\n pages[this.page] = this.editEl.value;\n this._setBook(withPages(book, pages));\n }\n\n private _turn(d: number): void {\n this._save();\n const book = this._book();\n if (!book) return;\n const pages = bookOf(book).pages;\n const last = isWritable(book)\n ? Math.min(pages.length, MAX_PAGES - 1) // у книги с пером можно завести следующую\n : pages.length - 1;\n this.page = Math.max(0, Math.min(last, this.page + d));\n this._render();\n }\n\n /** Подписать: книга с пером становится написанной и больше не правится. */\n private _sign(): void {\n this._save();\n const signed = signBook(this._book(), this.titleInput.value, \"Игрок\");\n if (!signed) return;\n this._setBook(signed);\n this.titleInput.value = \"\";\n this.page = 0;\n events.emit(\"bookSigned\", { title: signed.tag!.title as string });\n this._render();\n }\n\n /** Забрать книгу с кафедры в инвентарь. */\n private _take(): void {\n this._save();\n const book = this._book();\n if (!book) return this.hide();\n this._setBook(null);\n const p = this.game.player.pos;\n if (this.game.inventory.addStack(book) > 0)\n this.game.entities.spawnDrop(\n p.x,\n p.y + 1,\n p.z,\n book.item,\n 1,\n undefined,\n book.tag,\n );\n this.hide();\n }\n\n private _render(): void {\n const book = this._book();\n if (!book) return this.hide();\n const { title, author, pages } = bookOf(book);\n const writable = isWritable(book);\n this.titleEl.textContent = writable\n ? \"Книга с пером\"\n : title + (author ? ` — ${author}` : \"\");\n this.countEl.textContent = `${this.page + 1} / ${Math.max(pages.length, this.page + 1)}`;\n this.signRow.classList.toggle(\"hidden\", !writable);\n this.editEl.classList.toggle(\"hidden\", !writable);\n this.pageEl.classList.toggle(\"hidden\", writable);\n if (writable) {\n this.editEl.value = pages[this.page] ?? \"\";\n this.editEl.focus();\n } else {\n this.pageEl.textContent = \"\";\n for (const line of pageLines(pages[this.page] ?? \"\")) {\n const d = document.createElement(\"div\");\n d.textContent = line;\n this.pageEl.appendChild(d);\n }\n }\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
1579
+ "content": "// Книга на кафедре: ПКМ кладёт книгу из руки, открывает её на правку («Книга с пером») или на\n// чтение (написанная). Правка — по страницам, «Подписать» превращает книгу в написанную.\n// Чистая часть (страницы, подпись, перенос строк) — systems/Book; хранение — блочная сущность\n// lectern. Экран устроен как редактор таблички: ввод забирает клавиши у игры и отпускает захват.\nimport { events } from \"../core/EventBus\";\nimport {\n MAX_PAGES,\n bookOf,\n isBook,\n isWritable,\n pageLines,\n signBook,\n withPages,\n} from \"../systems/Book\";\nimport type { LecternState } from \"../world/blockentities/lectern\";\nimport type { ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\n\ntype At = { x: number; y: number; z: number };\n\nexport class BookScreen {\n open = false;\n readonly root: HTMLDivElement;\n private readonly titleEl: HTMLDivElement;\n private readonly pageEl: HTMLDivElement;\n private readonly editEl: HTMLTextAreaElement;\n private readonly countEl: HTMLDivElement;\n private readonly signRow: HTMLDivElement;\n private readonly titleInput: HTMLInputElement;\n private at: At | null = null;\n private page = 0;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"bookScreen\";\n this.root.className = \"bookScreen hidden\";\n\n this.titleEl = this._div(this.root, \"bookTitle\");\n this.pageEl = this._div(this.root, \"bookPage\");\n this.editEl = document.createElement(\"textarea\");\n this.editEl.className = \"bookEdit hidden\";\n this.editEl.maxLength = 900;\n this.editEl.spellcheck = false;\n this.root.appendChild(this.editEl);\n\n const nav = this._div(this.root, \"bookNav\");\n this._button(nav, \"◀\", () => this._turn(-1));\n this.countEl = this._div(nav, \"bookCount\");\n this._button(nav, \"▶\", () => this._turn(1));\n\n this.signRow = this._div(this.root, \"bookNav\");\n this.titleInput = document.createElement(\"input\");\n this.titleInput.className = \"bookTitleInput\";\n this.titleInput.placeholder = \"Название\";\n this.titleInput.maxLength = 32;\n this.signRow.appendChild(this.titleInput);\n this._button(this.signRow, \"Подписать\", () => this._sign());\n\n const foot = this._div(this.root, \"bookNav\");\n this._button(foot, \"Забрать\", () => this._take());\n this._button(foot, \"Закрыть\", () => this.hide());\n\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openLectern\", (p: At) => this.show(p));\n for (const el of [this.editEl, this.titleInput] as HTMLElement[])\n el.addEventListener(\n \"keydown\",\n (e: KeyboardEvent) => {\n e.stopPropagation(); // клавиши ввода — не игре\n if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n private _div(parent: HTMLElement, cls: string): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n }\n\n private _button(parent: HTMLElement, text: string, fn: () => void): void {\n const b = document.createElement(\"div\");\n b.className = \"bookBtn\";\n b.textContent = text;\n b.onmousedown = (e) => {\n e.preventDefault();\n fn();\n };\n parent.appendChild(b);\n }\n\n /** Книга, лежащая на кафедре. */\n private _book(): ItemStack | null {\n const at = this.at;\n if (!at) return null;\n const be = this.game.blockEntities.ensure(at.x, at.y, at.z);\n return be?.type === \"lectern\"\n ? ((be.state as LecternState).items[0] ?? null)\n : null;\n }\n\n private _setBook(s: ItemStack | null): void {\n const at = this.at;\n if (!at) return;\n const be = this.game.blockEntities.ensure(at.x, at.y, at.z);\n if (be?.type === \"lectern\") (be.state as LecternState).items[0] = s;\n }\n\n show(p: At): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.ensure(p.x, p.y, p.z);\n if (be?.type !== \"lectern\") return;\n this.at = p;\n // кафедра пуста, а в руке книга — она ложится на кафедру, как в Minecraft\n const st = be.state as LecternState;\n const held = g.inventory.heldItem();\n if (!st.items[0] && held && isBook(held.item)) {\n st.items[0] = { ...held, count: 1 };\n g.inventory.consumeHeld(1);\n events.emit(\"bookPlaced\", { ...p, item: held.item });\n }\n if (!st.items[0]) {\n this.at = null;\n return; // класть нечего и читать нечего\n }\n this.open = true;\n this.page = 0;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.remove(\"hidden\");\n this._render();\n }\n\n hide(): void {\n if (!this.open) return;\n this._save();\n this.open = false;\n this.at = null;\n this.root.classList.add(\"hidden\");\n this.editEl.blur();\n this.titleInput.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Записать набранную страницу в книгу (правится только «книга с пером»). */\n private _save(): void {\n const book = this._book();\n if (!book || !isWritable(book)) return;\n const pages = bookOf(book).pages.slice();\n while (pages.length <= this.page) pages.push(\"\");\n pages[this.page] = this.editEl.value;\n this._setBook(withPages(book, pages));\n }\n\n private _turn(d: number): void {\n this._save();\n const book = this._book();\n if (!book) return;\n const pages = bookOf(book).pages;\n const last = isWritable(book)\n ? Math.min(pages.length, MAX_PAGES - 1) // у книги с пером можно завести следующую\n : pages.length - 1;\n this.page = Math.max(0, Math.min(last, this.page + d));\n this._render();\n }\n\n /** Подписать: книга с пером становится написанной и больше не правится. */\n private _sign(): void {\n this._save();\n const signed = signBook(this._book(), this.titleInput.value, \"Игрок\");\n if (!signed) return;\n this._setBook(signed);\n this.titleInput.value = \"\";\n this.page = 0;\n events.emit(\"bookSigned\", { title: signed.tag!.title as string });\n this._render();\n }\n\n /** Забрать книгу с кафедры в инвентарь. */\n private _take(): void {\n this._save();\n const book = this._book();\n if (!book) return this.hide();\n this._setBook(null);\n const p = this.game.player.pos;\n if (this.game.inventory.addStack(book) > 0)\n this.game.entities.spawnDrop(\n p.x,\n p.y + 1,\n p.z,\n book.item,\n 1,\n undefined,\n book.tag,\n );\n this.hide();\n }\n\n private _render(): void {\n const book = this._book();\n if (!book) return this.hide();\n const { title, author, pages } = bookOf(book);\n const writable = isWritable(book);\n this.titleEl.textContent = writable\n ? \"Книга с пером\"\n : title + (author ? ` — ${author}` : \"\");\n this.countEl.textContent = `${this.page + 1} / ${Math.max(pages.length, this.page + 1)}`;\n this.signRow.classList.toggle(\"hidden\", !writable);\n this.editEl.classList.toggle(\"hidden\", !writable);\n this.pageEl.classList.toggle(\"hidden\", writable);\n if (writable) {\n this.editEl.value = pages[this.page] ?? \"\";\n this.editEl.focus();\n } else {\n this.pageEl.textContent = \"\";\n for (const line of pageLines(pages[this.page] ?? \"\")) {\n const d = document.createElement(\"div\");\n d.textContent = line;\n this.pageEl.appendChild(d);\n }\n }\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
1572
1580
  },
1573
1581
  {
1574
1582
  "path": "ui/ChatConsole.ts",
1575
- "content": "// Чат/консоль: T — открыть строку, / — открыть сразу с «/». Строка с «/» уходит командой\n// (интент Command → systems/Commands), остальное — реплика в чат (одиночная игра — только эхо).\n// Лог показывает последние строки и гаснет сам; открытая консоль показывает всю историю.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport type { Game } from \"../main\";\n\nconst MAX_LINES = 30;\n\nexport class ChatConsole {\n open = false;\n readonly root: HTMLDivElement;\n private readonly logEl: HTMLDivElement;\n private readonly input: HTMLInputElement;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"chat\";\n this.logEl = document.createElement(\"div\");\n this.logEl.className = \"chatLog\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 256;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.logEl, this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openConsole\", (d?: { prefix?: string }) =>\n this.show(d?.prefix ?? \"\"),\n );\n events.on(\"chat\", ({ text }: { text: string }) => this.print(text));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n // клавиши строки — не игре (Esc не должен ставить паузу, E — открывать инвентарь)\n e.stopPropagation();\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(prefix = \"\"): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open) return;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = prefix;\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n private submit(): void {\n const text = this.input.value.trim();\n this.hide();\n if (!text) return;\n if (text.startsWith(\"/\")) this.game.intents.push(Intent.Command, { text });\n else this.print(\"<Вы> \" + text);\n }\n\n print(text: string): void {\n const line = document.createElement(\"div\");\n line.className = \"chatLine\";\n line.textContent = text;\n this.logEl.appendChild(line);\n while (this.logEl.children.length > MAX_LINES)\n this.logEl.firstElementChild!.remove();\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
1583
+ "content": "// Чат/консоль: T — открыть строку, / — открыть сразу с «/». Строка с «/» уходит командой\n// (интент Command → systems/Commands), остальное — реплика в чат (одиночная игра — только эхо).\n// Лог показывает последние строки и гаснет сам; открытая консоль показывает всю историю.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport type { Game } from \"../main\";\n\nconst MAX_LINES = 30;\n\nexport class ChatConsole {\n open = false;\n readonly root: HTMLDivElement;\n private readonly logEl: HTMLDivElement;\n private readonly input: HTMLInputElement;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"chat\";\n this.logEl = document.createElement(\"div\");\n this.logEl.className = \"chatLog\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 256;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.logEl, this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openConsole\", (d?: { prefix?: string }) =>\n this.show(d?.prefix ?? \"\"),\n );\n events.on(\"chat\", ({ text }: { text: string }) => this.print(text));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n // клавиши строки — не игре (Esc не должен ставить паузу, E — открывать инвентарь)\n e.stopPropagation();\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(prefix = \"\"): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open) return;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = prefix;\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n private submit(): void {\n const text = this.input.value.trim();\n this.hide();\n if (!text) return;\n if (text.startsWith(\"/\")) this.game.intents.push(Intent.Command, { text });\n else this.print(\"<Вы> \" + text);\n }\n\n print(text: string): void {\n const line = document.createElement(\"div\");\n line.className = \"chatLine\";\n line.textContent = text;\n this.logEl.appendChild(line);\n while (this.logEl.children.length > MAX_LINES)\n this.logEl.firstElementChild!.remove();\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
1576
1584
  },
1577
1585
  {
1578
1586
  "path": "ui/CreateWorldUI.ts",
@@ -1584,19 +1592,19 @@
1584
1592
  },
1585
1593
  {
1586
1594
  "path": "ui/HUD.ts",
1587
- "content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport { ITEMS } from \"../registry/Items\";\nimport { EFFECT_NAMES, labelOf } from \"../registry/names\";\nimport { MAX_AIR } from \"../player/Player\";\nimport { RAID_WAVES } from \"../systems/Raid\";\nimport { drawDurability } from \"./slotView\";\nimport { enchantsOf, enchantSuffix } from \"../systems/Enchant\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { MAP_SIZE, mapRGB, type MapData } from \"../systems/Maps\";\nimport type { Game } from \"../main\";\n\nexport class HUD {\n game: Game;\n $: (id: string) => HTMLElement;\n hotbarEl: HTMLElement;\n heartsEl: HTMLElement;\n fpsEl: HTMLElement;\n debugEl: HTMLElement;\n timeEl: HTMLElement;\n overlayEl: HTMLElement;\n hurtEl: HTMLElement;\n msgEl: HTMLElement;\n hungerEl: HTMLElement;\n _invDirty: boolean;\n _lastHealth: number;\n _lastFood?: number;\n _modeWas?: string;\n _foodModeWas?: string;\n _msgT?: ReturnType<typeof setTimeout>;\n _slots: HTMLDivElement[];\n _hearts: HTMLSpanElement[];\n _drumsticks: HTMLSpanElement[];\n /** Экран смерти и оранжевая кайма горения. */\n deathEl!: HTMLElement;\n fireEl!: HTMLElement;\n /** Полоса здоровья босса и экран титров. */\n bossEl!: HTMLElement;\n creditsEl!: HTMLElement;\n /** Список эффектов зелий с оставшимся временем. */\n effectsEl!: HTMLElement;\n /** Пузырьки воздуха над едой — только под водой. */\n bubblesEl!: HTMLElement;\n private readonly _bubbles: HTMLSpanElement[] = [];\n /** Компас или часы в руке — стрелка или время у хотбара. */\n gadgetEl!: HTMLElement;\n /** Карта в руке. */\n mapEl!: HTMLElement;\n mapCanvas!: HTMLCanvasElement;\n mapMarker!: HTMLElement;\n /** Крестик карты сокровищ. */\n mapTreasure!: HTMLElement;\n private _mapShown: MapData | null = null;\n private _mapVersion = -1;\n /** Полоса опыта над хотбаром и номер уровня. */\n xpFillEl: HTMLElement;\n xpLevelEl: HTMLElement;\n _lastXp = \"\";\n /** Шкала брони над сердцами (10 значков, по 2 очка защиты). */\n armorEl: HTMLElement;\n _armorPips: HTMLSpanElement[] = [];\n _lastArmor = -1;\n\n constructor(game: Game) {\n this.game = game;\n // Все элементы гарантированно есть в разметке — helper утверждает non-null.\n this.$ = (id: string): HTMLElement => document.getElementById(id)!;\n this.hotbarEl = this.$(\"hotbar\");\n this.heartsEl = this.$(\"hearts\");\n this.fpsEl = this.$(\"fps\");\n this.debugEl = this.$(\"debug\");\n this.timeEl = this.$(\"timeIndicator\");\n this.overlayEl = this.$(\"overlay\");\n this.hurtEl = this.$(\"hurtFlash\");\n this.msgEl = this.$(\"message\");\n\n this._invDirty = true;\n this._lastHealth = -1;\n events.on(\"invChanged\", () => {\n this._invDirty = true;\n });\n events.on(\"playerHurt\", () => this._flashHurt());\n // короткие подсказки из систем без доступа к HUD («Нужно седло»)\n events.on(\"ui:hint\", ({ text }: { text: string }) =>\n this.message(text, 1600),\n );\n // экран смерти и огонь на экране\n this.deathEl = document.createElement(\"div\");\n this.deathEl.id = \"deathScreen\";\n this.deathEl.className = \"hidden\";\n this.deathEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Вы погибли</h1><p class=\"deathCause\"></p>' +\n '<button class=\"deathBtn\">Возродиться</button></div>';\n this.deathEl.querySelector(\"button\")!.onclick = () => game.respawnPlayer();\n this.fireEl = document.createElement(\"div\");\n this.fireEl.id = \"fireOverlay\";\n // полоса босса и титры после победы над драконом\n this.bossEl = document.createElement(\"div\");\n this.bossEl.id = \"bossBar\";\n this.bossEl.className = \"hidden\";\n this.bossEl.innerHTML =\n '<div class=\"bossName\"></div><div class=\"bossTrack\"><div class=\"bossFill\"></div></div>';\n this.creditsEl = document.createElement(\"div\");\n this.creditsEl.id = \"creditsScreen\";\n this.creditsEl.className = \"hidden\";\n this.creditsEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Победа</h1><p class=\"credits\">Дракон Предела повержен.<br>' +\n \"Путь пройден: верхний мир — Пекло — Предел.<br>Мир остаётся твоим: строй, исследуй, \" +\n \"возвращайся.<br><br>Спасибо, что играешь в VoxelCraft.</p>\" +\n '<button class=\"deathBtn\">Продолжить</button></div>';\n this.creditsEl.querySelector(\"button\")!.onclick = () => {\n this.creditsEl.classList.add(\"hidden\");\n game.input.uiOpen = false;\n game.input.requestLock();\n };\n this.effectsEl = document.createElement(\"div\");\n this.effectsEl.id = \"effects\";\n // компас и часы в руке\n this.gadgetEl = document.createElement(\"div\");\n this.gadgetEl.id = \"gadget\";\n this.gadgetEl.className = \"hidden\";\n this.heartsEl.parentElement!.appendChild(this.gadgetEl);\n // карта в руке: холст 128×128 (растянут) и метка игрока\n this.mapEl = document.createElement(\"div\");\n this.mapEl.id = \"mapView\";\n this.mapEl.className = \"hidden\";\n this.mapCanvas = document.createElement(\"canvas\");\n this.mapCanvas.width = this.mapCanvas.height = MAP_SIZE;\n this.mapMarker = document.createElement(\"span\");\n this.mapMarker.className = \"mapMarker\";\n this.mapMarker.textContent = \"▲\";\n this.mapTreasure = document.createElement(\"span\");\n this.mapTreasure.className = \"mapTreasure\";\n this.mapTreasure.textContent = \"✖\";\n this.mapEl.append(this.mapCanvas, this.mapTreasure, this.mapMarker);\n this.heartsEl.parentElement!.appendChild(this.mapEl);\n this.heartsEl.parentElement!.append(\n this.deathEl,\n this.fireEl,\n this.bossEl,\n this.creditsEl,\n this.effectsEl,\n );\n events.on(\"modeChanged\", ({ mode }) =>\n this.message(\n mode === \"creative\"\n ? \"Режим: КРЕАТИВ (двойной Space — полёт)\"\n : \"Режим: выживание\",\n 1600,\n ),\n );\n\n // слоты хотбара\n this._slots = [];\n for (let i = 0; i < 9; i++) {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n // тап/клик по слоту — выбрать его (на телефоне колёсика и цифр нет)\n d.addEventListener(\"pointerdown\", () =>\n game.intents.push(Intent.SelectSlot, { slot: i }),\n );\n this.hotbarEl.appendChild(d);\n this._slots.push(d);\n }\n // сердечки\n this._hearts = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"heart\";\n s.textContent = \"❤\";\n this.heartsEl.appendChild(s);\n this._hearts.push(s);\n }\n // опыт\n const xpBar = document.createElement(\"div\");\n xpBar.id = \"xpBar\";\n this.xpFillEl = document.createElement(\"div\");\n this.xpFillEl.className = \"xpFill\";\n xpBar.appendChild(this.xpFillEl);\n this.xpLevelEl = document.createElement(\"div\");\n this.xpLevelEl.id = \"xpLevel\";\n this.heartsEl.parentElement!.append(xpBar, this.xpLevelEl);\n // броня\n this.armorEl = document.createElement(\"div\");\n this.armorEl.id = \"armorBar\";\n this.heartsEl.parentElement!.appendChild(this.armorEl);\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"armorPip\";\n s.textContent = \"⛨\";\n this.armorEl.appendChild(s);\n this._armorPips.push(s);\n }\n // окорочка (голод)\n this.hungerEl = this.$(\"hunger\");\n this._drumsticks = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"drumstick\";\n s.textContent = \"🍗\";\n this.hungerEl.appendChild(s);\n this._drumsticks.push(s);\n }\n this.bubblesEl = document.createElement(\"div\");\n this.bubblesEl.id = \"bubbles\";\n this.bubblesEl.style.display = \"none\";\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"bubble\";\n s.textContent = \"◯\";\n this.bubblesEl.appendChild(s);\n this._bubbles.push(s);\n }\n this.hungerEl.parentElement!.appendChild(this.bubblesEl);\n\n // кнопки паузы\n this.$(\"btnExport\").onclick = () => game.save.exportFile();\n this.$(\"btnImport\").onclick = () =>\n game.save.importFile((err: unknown) => {\n this.message(\n err ? \"Ошибка импорта: \" + (err as Error).message : \"Мир загружен\",\n 1800,\n );\n });\n this.$(\"btnNewSeed\").onclick = () => {\n const s = prompt(\n \"Сид нового мира (число):\",\n String((Math.random() * 1e9) | 0),\n );\n if (s !== null && s.trim() !== \"\") game.newWorld(Number(s) || 0);\n };\n this.overlayEl.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).closest(\"button\")) return; // кнопки не запускают игру\n game.startPlay();\n });\n }\n\n showOverlay(paused: boolean) {\n this.overlayEl.classList.remove(\"hidden\");\n this.$(\"overlayTitle\").textContent = paused ? \"Пауза\" : \"VoxelCraft\";\n this.$(\"overlayHint\").textContent = paused\n ? \"Клик — продолжить\"\n : \"Клик — играть\";\n }\n hideOverlay() {\n this.overlayEl.classList.add(\"hidden\");\n }\n\n /** Экран «Вы погибли» с причиной и кнопкой возрождения. */\n showDeath(cause: string) {\n const CAUSES: Record<string, string> = {\n attack: \"Вас убил монстр\",\n projectile: \"Вас застрелили\",\n explosion: \"Вас взорвали\",\n lava: \"Вы искупались в лаве\",\n fire: \"Вы сгорели\",\n fall: \"Вы упали с высоты\",\n starve: \"Вы умерли от голода\",\n void: \"Вы выпали из мира\",\n cactus: \"Вас искололо кактусом\",\n magic: \"Вас сгубило колдовство\",\n lightning: \"Вас поразила молния\",\n decay: \"Вы истлели\",\n skull: \"Вас сразил Пепельный владыка\",\n bullet: \"Вас подбил панцирник\",\n drown: \"Вы утонули\",\n beam: \"Вас сжёг луч глубинника\",\n thorns: \"Вы укололись о шипы глубинника\",\n wall: \"Вы врезались в стену на лету\",\n };\n this.deathEl.querySelector(\".deathCause\")!.textContent =\n (CAUSES[cause] ?? \"\") + (this.game.player.xpTotal ? \"\" : \"\");\n this.deathEl.classList.remove(\"hidden\");\n }\n hideDeath() {\n this.deathEl.classList.add(\"hidden\");\n }\n\n /** Титры после первой победы над драконом (кнопка «Продолжить» возвращает управление). */\n showCredits() {\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.creditsEl.classList.remove(\"hidden\");\n }\n\n message(text: string, ms = 1500) {\n this.msgEl.textContent = text;\n this.msgEl.classList.add(\"show\");\n clearTimeout(this._msgT);\n this._msgT = setTimeout(() => this.msgEl.classList.remove(\"show\"), ms);\n }\n\n /** Teardown: таймер сообщения не должен пережить игру. */\n dispose() {\n clearTimeout(this._msgT);\n }\n\n _flashHurt() {\n this.hurtEl.classList.remove(\"show\");\n void this.hurtEl.offsetWidth; // перезапуск CSS-анимации\n this.hurtEl.classList.add(\"show\");\n }\n\n frame() {\n const g = this.game;\n if (this._invDirty) {\n this._renderHotbar();\n this._invDirty = false;\n }\n if (\n g.player.health !== this._lastHealth ||\n this._modeWas !== g.player.mode\n ) {\n this._renderHearts();\n this._lastHealth = g.player.health;\n this._modeWas = g.player.mode;\n }\n if (\n g.player.food !== this._lastFood ||\n this._foodModeWas !== g.player.mode\n ) {\n this._renderHunger();\n this._lastFood = g.player.food;\n this._foodModeWas = g.player.mode;\n }\n\n const xpKey =\n g.player.xpLevel +\n \":\" +\n Math.round(g.player.xpProgress * 200) +\n \":\" +\n g.player.mode;\n if (xpKey !== this._lastXp) {\n this._lastXp = xpKey;\n const show = g.player.mode !== \"creative\";\n this.xpFillEl.parentElement!.style.display = show ? \"\" : \"none\";\n this.xpFillEl.style.width = (g.player.xpProgress * 100).toFixed(1) + \"%\";\n this.xpLevelEl.textContent =\n show && g.player.xpLevel > 0 ? String(g.player.xpLevel) : \"\";\n }\n\n const armor = g.inventory.armorStats().points;\n if (armor !== this._lastArmor) {\n this._lastArmor = armor;\n this.armorEl.style.display =\n armor > 0 && g.player.mode !== \"creative\" ? \"\" : \"none\";\n for (let i = 0; i < 10; i++) {\n const v = armor - i * 2;\n this._armorPips[i]!.className =\n \"armorPip\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n this.fireEl.classList.toggle(\"show\", g.player.fireT > 0 && !g.player.dead);\n // эффекты зелий и маяка: «Сила 2:31»; Тление чернит сердца\n this.heartsEl.classList.toggle(\"decay\", g.player.effects.has(\"decay\"));\n // пузырьки: сколько воздуха осталось под водой\n const pl = g.player;\n const showAir =\n pl.mode !== \"creative\" && !pl.dead && pl.air < MAX_AIR - 0.01;\n this.bubblesEl.style.display = showAir ? \"\" : \"none\";\n if (showAir) {\n const n = Math.ceil((pl.air / MAX_AIR) * 10);\n this._bubbles.forEach(\n (b, i) => (b.className = \"bubble\" + (i < n ? \"\" : \" empty\")),\n );\n }\n let fx = \"\";\n for (const [id, e] of g.player.effects) {\n const s = Math.max(0, Math.ceil(e.time));\n fx +=\n `${EFFECT_NAMES[id] ?? id}${e.level > 1 ? \" \" + e.level : \"\"} ` +\n `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}\\n`;\n }\n if (this.effectsEl.textContent !== fx) this.effectsEl.textContent = fx;\n // полоса босса: живой босс в загруженном мире\n let boss: { name: string; hp: number; max: number } | null = null;\n for (const e of g.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n boss = { name: e.def.name, hp: e.hp, max: e.def.hp };\n // нет босса, идёт набег — полоса набега: здоровье живых разбойников волны\n const raid = g.raid;\n if (!boss && raid) {\n let hp = 0;\n for (const id of raid.raiders) {\n const e = g.entities.entities.get(id);\n if (e?.kind === \"mob\" && !e.dead) hp += e.hp;\n }\n boss =\n raid.state === \"fighting\"\n ? {\n name: `Набег · волна ${raid.wave} из ${RAID_WAVES}`,\n hp,\n max: Math.max(1, raid.hpTotal),\n }\n : {\n name: `Набег · волна ${raid.wave + 1} из ${RAID_WAVES} — скоро`,\n hp: 0,\n max: 1,\n };\n }\n this.bossEl.classList.toggle(\"hidden\", !boss);\n if (boss) {\n this.bossEl.querySelector(\".bossName\")!.textContent = labelOf(boss.name);\n (this.bossEl.querySelector(\".bossFill\") as HTMLElement).style.width =\n ((boss.hp / boss.max) * 100).toFixed(1) + \"%\";\n }\n this.fpsEl.textContent = g.loop.fps + \" FPS\";\n // дебаг-строка\n const p = g.player.pos;\n this.debugEl.textContent =\n `xyz: ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)} | ` +\n `чанки: ${g.world.chunks.size} | жидкости: ${g.fluids.queueSize} | сид: ${g.world.seed}`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n // в Пекле и Пределе неба нет — и времени суток на экране тоже\n this.timeEl.textContent = DIMENSIONS[g.dimension].hasSky\n ? (g.dayNight.isDay ? \"☀\" : \"☾\") +\n \" \" +\n (ph < 0.5 ? \"день\" : \"ночь\") +\n (g.weather.kind === \"thunder\"\n ? \" · гроза\"\n : g.weather.kind === \"rain\"\n ? \" · дождь\"\n : \"\")\n : \"\";\n this._gadget();\n this._mapView();\n }\n\n /** Карта в руке: холст перерисовывается, только когда карта изменилась; метка — каждый кадр. */\n private _mapView() {\n const g = this.game;\n const h = g.inventory.heldItem();\n const m =\n h?.item === \"filled_map\" ? g.maps.get(Number(h.tag?.map)) : undefined;\n if (!m) {\n this.mapEl.classList.add(\"hidden\");\n return;\n }\n this.mapEl.classList.remove(\"hidden\");\n if (this._mapShown !== m || this._mapVersion !== m.version) {\n this._mapShown = m;\n this._mapVersion = m.version;\n const ctx = this.mapCanvas.getContext(\"2d\");\n if (ctx) {\n const img = ctx.createImageData(MAP_SIZE, MAP_SIZE);\n for (let i = 0; i < m.px.length; i++) {\n const c = mapRGB(m.px[i]!);\n const o = i * 4;\n img.data[o] = c < 0 ? 214 : (c >> 16) & 255;\n img.data[o + 1] = c < 0 ? 190 : (c >> 8) & 255;\n img.data[o + 2] = c < 0 ? 150 : c & 255;\n img.data[o + 3] = 255;\n }\n ctx.putImageData(img, 0, 0);\n }\n }\n const p = g.player.pos;\n const u = (p.x - m.x0) / MAP_SIZE,\n v = (p.z - m.z0) / MAP_SIZE;\n const inside =\n m.dim === g.dimension && u >= 0 && u <= 1 && v >= 0 && v <= 1;\n this.mapMarker.style.display = inside ? \"\" : \"none\";\n this.mapMarker.style.left = (u * 100).toFixed(2) + \"%\";\n this.mapMarker.style.top = (v * 100).toFixed(2) + \"%\";\n this.mapMarker.style.transform = `translate(-50%, -50%) rotate(${((-g.player.yaw * 180) / Math.PI).toFixed(1)}deg)`;\n // карта сокровищ: красный крестик над кладом\n const t = m.target;\n this.mapTreasure.style.display = t ? \"\" : \"none\";\n if (t) {\n this.mapTreasure.style.left =\n (((t[0] + 0.5 - m.x0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n this.mapTreasure.style.top =\n (((t[1] + 0.5 - m.z0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n }\n }\n\n private frostEl: HTMLElement | null = null;\n /** Иней по краям экрана — насколько игрок промёрз в снегу-порохе (0..1, systems/Freezing). */\n frost(k: number): void {\n if (!this.frostEl) {\n if (k <= 0) return;\n const el = document.createElement(\"div\");\n el.style.cssText =\n \"position:fixed;inset:0;pointer-events:none;opacity:0;\" +\n \"background:radial-gradient(ellipse at center,rgba(255,255,255,0) 40%,rgba(220,240,255,.9) 100%)\";\n this.heartsEl.parentElement!.appendChild(el);\n this.frostEl = el;\n }\n this.frostEl.style.opacity = k.toFixed(2);\n }\n\n /**\n * Компас и часы в руке: у хотбара — стрелка на точку появления мира (в Пекле и Пределе крутится)\n * или время суток (без неба — «??:??»), как у приборов Minecraft.\n */\n private _gadget() {\n const g = this.game;\n const held = g.inventory.heldItem()?.item;\n const el = this.gadgetEl;\n if (held !== \"compass\" && held !== \"clock\" && held !== \"recovery_compass\") {\n el.classList.add(\"hidden\");\n return;\n }\n el.classList.remove(\"hidden\");\n if (held === \"compass\" || held === \"recovery_compass\") {\n // компас — на путевой камень (если привязан здесь) или на точку появления,\n // компас возвращения — на место последней смерти в этом измерении\n const d = g.lastDeath;\n const lode = g.player.lodestone;\n const s =\n held === \"compass\"\n ? lode && lode.dim === g.dimension\n ? { x: lode.x, z: lode.z }\n : g.dimension === \"overworld\"\n ? g.world.generator.spawnHint()\n : null\n : d && d.dim === g.dimension\n ? { x: Math.floor(d.x), z: Math.floor(d.z) }\n : null;\n let deg: number;\n if (!s) deg = (performance.now() / 3) % 360;\n else {\n const p = g.player.pos;\n const target = Math.atan2(-(s.x + 0.5 - p.x), -(s.z + 0.5 - p.z));\n deg = (-(target - g.player.yaw) * 180) / Math.PI;\n }\n el.innerHTML = `<span class=\"needle\" style=\"transform:rotate(${deg.toFixed(1)}deg)\">▲</span>`;\n return;\n }\n if (!DIMENSIONS[g.dimension].hasSky) {\n el.textContent = \"🕒 ??:??\";\n return;\n }\n const h = (6 + g.dayNight.phase * 24) % 24;\n el.textContent = `🕒 ${String(Math.floor(h)).padStart(2, \"0\")}:${String(Math.floor((h % 1) * 60)).padStart(2, \"0\")}`;\n }\n\n _renderHotbar() {\n const inv = this.game.inventory;\n for (let i = 0; i < 9; i++) {\n const el = this._slots[i]!;\n const s = inv.slots[i];\n el.classList.toggle(\"selected\", i === inv.hotbarIndex);\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (s) {\n img.src = ITEMS[s.item]!.iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = labelOf(s.item) + enchantSuffix(s);\n el.classList.toggle(\"ench\", enchantsOf(s).length > 0);\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (s.count > 1 ? s.count : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n el.classList.remove(\"ench\");\n }\n drawDurability(el, s ?? null);\n }\n }\n\n _renderHearts() {\n const g = this.game;\n this.heartsEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const hp = g.player.health;\n for (let i = 0; i < 10; i++) {\n const v = hp - i * 2;\n this._hearts[i]!.className =\n \"heart\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n _renderHunger() {\n const g = this.game;\n this.hungerEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const food = g.player.food;\n for (let i = 0; i < 10; i++) {\n const v = food - i * 2;\n this._drumsticks[i]!.className =\n \"drumstick\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n}\n"
1595
+ "content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport { ITEMS } from \"../registry/Items\";\nimport { EFFECT_NAMES, labelOf } from \"../registry/names\";\nimport { MAX_AIR } from \"../player/Player\";\nimport { RAID_WAVES } from \"../systems/Raid\";\nimport { drawDurability } from \"./slotView\";\nimport { enchantsOf, enchantSuffix } from \"../systems/Enchant\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { MAP_SIZE, mapRGB, type MapData } from \"../systems/Maps\";\nimport type { Game } from \"../main\";\n\nexport class HUD {\n game: Game;\n $: (id: string) => HTMLElement;\n hotbarEl: HTMLElement;\n heartsEl: HTMLElement;\n fpsEl: HTMLElement;\n debugEl: HTMLElement;\n timeEl: HTMLElement;\n overlayEl: HTMLElement;\n hurtEl: HTMLElement;\n msgEl: HTMLElement;\n hungerEl: HTMLElement;\n _invDirty: boolean;\n _lastHealth: number;\n _lastFood?: number;\n _modeWas?: string;\n _foodModeWas?: string;\n _msgT?: ReturnType<typeof setTimeout>;\n _slots: HTMLDivElement[];\n _hearts: HTMLSpanElement[];\n _drumsticks: HTMLSpanElement[];\n /** Экран смерти и оранжевая кайма горения. */\n deathEl!: HTMLElement;\n fireEl!: HTMLElement;\n /** Полоса здоровья босса и экран титров. */\n bossEl!: HTMLElement;\n creditsEl!: HTMLElement;\n /** Список эффектов зелий с оставшимся временем. */\n effectsEl!: HTMLElement;\n /** Пузырьки воздуха над едой — только под водой. */\n bubblesEl!: HTMLElement;\n private readonly _bubbles: HTMLSpanElement[] = [];\n /** Компас или часы в руке — стрелка или время у хотбара. */\n gadgetEl!: HTMLElement;\n /** Карта в руке. */\n mapEl!: HTMLElement;\n mapCanvas!: HTMLCanvasElement;\n mapMarker!: HTMLElement;\n /** Крестик карты сокровищ. */\n mapTreasure!: HTMLElement;\n private _mapShown: MapData | null = null;\n private _mapVersion = -1;\n /** Полоса опыта над хотбаром и номер уровня. */\n xpFillEl: HTMLElement;\n xpLevelEl: HTMLElement;\n _lastXp = \"\";\n /** Шкала брони над сердцами (10 значков, по 2 очка защиты). */\n armorEl: HTMLElement;\n _armorPips: HTMLSpanElement[] = [];\n _lastArmor = -1;\n\n constructor(game: Game) {\n this.game = game;\n // Все элементы гарантированно есть в разметке — helper утверждает non-null.\n this.$ = (id: string): HTMLElement => document.getElementById(id)!;\n this.hotbarEl = this.$(\"hotbar\");\n this.heartsEl = this.$(\"hearts\");\n this.fpsEl = this.$(\"fps\");\n this.debugEl = this.$(\"debug\");\n this.timeEl = this.$(\"timeIndicator\");\n this.overlayEl = this.$(\"overlay\");\n this.hurtEl = this.$(\"hurtFlash\");\n this.msgEl = this.$(\"message\");\n\n this._invDirty = true;\n this._lastHealth = -1;\n events.on(\"invChanged\", () => {\n this._invDirty = true;\n });\n events.on(\"playerHurt\", () => this._flashHurt());\n // короткие подсказки из систем без доступа к HUD («Нужно седло»)\n events.on(\"ui:hint\", ({ text }: { text: string }) =>\n this.message(text, 1600),\n );\n // экран смерти и огонь на экране\n this.deathEl = document.createElement(\"div\");\n this.deathEl.id = \"deathScreen\";\n this.deathEl.className = \"hidden\";\n this.deathEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Вы погибли</h1><p class=\"deathCause\"></p>' +\n '<button class=\"deathBtn\">Возродиться</button></div>';\n this.deathEl.querySelector(\"button\")!.onclick = () => game.respawnPlayer();\n this.fireEl = document.createElement(\"div\");\n this.fireEl.id = \"fireOverlay\";\n // полоса босса и титры после победы над драконом\n this.bossEl = document.createElement(\"div\");\n this.bossEl.id = \"bossBar\";\n this.bossEl.className = \"hidden\";\n this.bossEl.innerHTML =\n '<div class=\"bossName\"></div><div class=\"bossTrack\"><div class=\"bossFill\"></div></div>';\n this.creditsEl = document.createElement(\"div\");\n this.creditsEl.id = \"creditsScreen\";\n this.creditsEl.className = \"hidden\";\n this.creditsEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Победа</h1><p class=\"credits\">Дракон Предела повержен.<br>' +\n \"Путь пройден: верхний мир — Пекло — Предел.<br>Мир остаётся твоим: строй, исследуй, \" +\n \"возвращайся.<br><br>Спасибо, что играешь в VoxelCraft.</p>\" +\n '<button class=\"deathBtn\">Продолжить</button></div>';\n this.creditsEl.querySelector(\"button\")!.onclick = () => {\n this.creditsEl.classList.add(\"hidden\");\n game.input.uiOpen = false;\n game.input.requestLock();\n };\n this.effectsEl = document.createElement(\"div\");\n this.effectsEl.id = \"effects\";\n // компас и часы в руке\n this.gadgetEl = document.createElement(\"div\");\n this.gadgetEl.id = \"gadget\";\n this.gadgetEl.className = \"hidden\";\n this.heartsEl.parentElement!.appendChild(this.gadgetEl);\n // карта в руке: холст 128×128 (растянут) и метка игрока\n this.mapEl = document.createElement(\"div\");\n this.mapEl.id = \"mapView\";\n this.mapEl.className = \"hidden\";\n this.mapCanvas = document.createElement(\"canvas\");\n this.mapCanvas.width = this.mapCanvas.height = MAP_SIZE;\n this.mapMarker = document.createElement(\"span\");\n this.mapMarker.className = \"mapMarker\";\n this.mapMarker.textContent = \"▲\";\n this.mapTreasure = document.createElement(\"span\");\n this.mapTreasure.className = \"mapTreasure\";\n this.mapTreasure.textContent = \"✖\";\n this.mapEl.append(this.mapCanvas, this.mapTreasure, this.mapMarker);\n this.heartsEl.parentElement!.appendChild(this.mapEl);\n this.heartsEl.parentElement!.append(\n this.deathEl,\n this.fireEl,\n this.bossEl,\n this.creditsEl,\n this.effectsEl,\n );\n events.on(\"modeChanged\", ({ mode }) =>\n this.message(\n mode === \"creative\"\n ? \"Режим: КРЕАТИВ (двойной Space — полёт)\"\n : \"Режим: выживание\",\n 1600,\n ),\n );\n\n // слоты хотбара\n this._slots = [];\n for (let i = 0; i < 9; i++) {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n // тап/клик по слоту — выбрать его (на телефоне колёсика и цифр нет)\n d.addEventListener(\"pointerdown\", () =>\n game.intents.push(Intent.SelectSlot, { slot: i }),\n );\n this.hotbarEl.appendChild(d);\n this._slots.push(d);\n }\n // сердечки\n this._hearts = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"heart\";\n s.textContent = \"❤\";\n this.heartsEl.appendChild(s);\n this._hearts.push(s);\n }\n // опыт\n const xpBar = document.createElement(\"div\");\n xpBar.id = \"xpBar\";\n this.xpFillEl = document.createElement(\"div\");\n this.xpFillEl.className = \"xpFill\";\n xpBar.appendChild(this.xpFillEl);\n this.xpLevelEl = document.createElement(\"div\");\n this.xpLevelEl.id = \"xpLevel\";\n this.heartsEl.parentElement!.append(xpBar, this.xpLevelEl);\n // броня\n this.armorEl = document.createElement(\"div\");\n this.armorEl.id = \"armorBar\";\n this.heartsEl.parentElement!.appendChild(this.armorEl);\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"armorPip\";\n s.textContent = \"⛨\";\n this.armorEl.appendChild(s);\n this._armorPips.push(s);\n }\n // окорочка (голод)\n this.hungerEl = this.$(\"hunger\");\n this._drumsticks = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"drumstick\";\n s.textContent = \"🍗\";\n this.hungerEl.appendChild(s);\n this._drumsticks.push(s);\n }\n this.bubblesEl = document.createElement(\"div\");\n this.bubblesEl.id = \"bubbles\";\n this.bubblesEl.style.display = \"none\";\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"bubble\";\n s.textContent = \"◯\";\n this.bubblesEl.appendChild(s);\n this._bubbles.push(s);\n }\n this.hungerEl.parentElement!.appendChild(this.bubblesEl);\n\n // кнопки паузы\n this.$(\"btnExport\").onclick = () => game.save.exportFile();\n this.$(\"btnImport\").onclick = () =>\n game.save.importFile((err: unknown) => {\n this.message(\n err ? \"Ошибка импорта: \" + (err as Error).message : \"Мир загружен\",\n 1800,\n );\n });\n this.$(\"btnNewSeed\").onclick = () => {\n const s = prompt(\n \"Сид нового мира (число):\",\n String((Math.random() * 1e9) | 0),\n );\n if (s !== null && s.trim() !== \"\") game.newWorld(Number(s) || 0);\n };\n this.overlayEl.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).closest(\"button\")) return; // кнопки не запускают игру\n game.startPlay();\n });\n }\n\n showOverlay(paused: boolean) {\n this.overlayEl.classList.remove(\"hidden\");\n this.$(\"overlayTitle\").textContent = paused ? \"Пауза\" : \"VoxelCraft\";\n this.$(\"overlayHint\").textContent = paused\n ? \"Клик — продолжить\"\n : \"Клик — играть\";\n }\n hideOverlay() {\n this.overlayEl.classList.add(\"hidden\");\n }\n\n /** Экран «Вы погибли» с причиной и кнопкой возрождения. */\n showDeath(cause: string) {\n const CAUSES: Record<string, string> = {\n attack: \"Вас убил монстр\",\n projectile: \"Вас застрелили\",\n explosion: \"Вас взорвали\",\n lava: \"Вы искупались в лаве\",\n fire: \"Вы сгорели\",\n fall: \"Вы упали с высоты\",\n starve: \"Вы умерли от голода\",\n void: \"Вы выпали из мира\",\n cactus: \"Вас искололо кактусом\",\n magic: \"Вас сгубило колдовство\",\n lightning: \"Вас поразила молния\",\n decay: \"Вы истлели\",\n skull: \"Вас сразил Пепельный владыка\",\n bullet: \"Вас подбил панцирник\",\n drown: \"Вы утонули\",\n beam: \"Вас сжёг луч глубинника\",\n thorns: \"Вы укололись о шипы глубинника\",\n wall: \"Вы врезались в стену на лету\",\n // причины, для которых экран смерти молчал (замёрзнуть в снегу-порохе — самая частая)\n freeze: \"Вы замёрзли\",\n stalagmite: \"Вас пронзил сталагмит\",\n falling_stalactite: \"На вас упал сталактит\",\n teleport: \"Неудачный бросок жемчуга\",\n sonic_boom: \"Вас оглушил вопль глубинника\",\n wind: \"Вас сдуло ветром\",\n poison: \"Вас отравили\",\n };\n this.deathEl.querySelector(\".deathCause\")!.textContent =\n CAUSES[cause] ?? \"\"; // заголовок панели уже «Вы погибли» — без повтора\n this.deathEl.classList.remove(\"hidden\");\n }\n hideDeath() {\n this.deathEl.classList.add(\"hidden\");\n }\n\n /** Титры после первой победы над драконом (кнопка «Продолжить» возвращает управление). */\n showCredits() {\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.creditsEl.classList.remove(\"hidden\");\n }\n\n message(text: string, ms = 1500) {\n this.msgEl.textContent = text;\n this.msgEl.classList.add(\"show\");\n clearTimeout(this._msgT);\n this._msgT = setTimeout(() => this.msgEl.classList.remove(\"show\"), ms);\n }\n\n /** Teardown: таймер сообщения не должен пережить игру. */\n dispose() {\n clearTimeout(this._msgT);\n }\n\n _flashHurt() {\n this.hurtEl.classList.remove(\"show\");\n void this.hurtEl.offsetWidth; // перезапуск CSS-анимации\n this.hurtEl.classList.add(\"show\");\n }\n\n frame() {\n const g = this.game;\n if (this._invDirty) {\n this._renderHotbar();\n this._invDirty = false;\n }\n if (\n g.player.health !== this._lastHealth ||\n this._modeWas !== g.player.mode\n ) {\n this._renderHearts();\n this._lastHealth = g.player.health;\n this._modeWas = g.player.mode;\n }\n if (\n g.player.food !== this._lastFood ||\n this._foodModeWas !== g.player.mode\n ) {\n this._renderHunger();\n this._lastFood = g.player.food;\n this._foodModeWas = g.player.mode;\n }\n\n const xpKey =\n g.player.xpLevel +\n \":\" +\n Math.round(g.player.xpProgress * 200) +\n \":\" +\n g.player.mode;\n if (xpKey !== this._lastXp) {\n this._lastXp = xpKey;\n const show = g.player.mode !== \"creative\";\n this.xpFillEl.parentElement!.style.display = show ? \"\" : \"none\";\n this.xpFillEl.style.width = (g.player.xpProgress * 100).toFixed(1) + \"%\";\n this.xpLevelEl.textContent =\n show && g.player.xpLevel > 0 ? String(g.player.xpLevel) : \"\";\n }\n\n const armor = g.inventory.armorStats().points;\n if (armor !== this._lastArmor) {\n this._lastArmor = armor;\n this.armorEl.style.display =\n armor > 0 && g.player.mode !== \"creative\" ? \"\" : \"none\";\n for (let i = 0; i < 10; i++) {\n const v = armor - i * 2;\n this._armorPips[i]!.className =\n \"armorPip\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n this.fireEl.classList.toggle(\"show\", g.player.fireT > 0 && !g.player.dead);\n // эффекты зелий и маяка: «Сила 2:31»; Тление чернит сердца\n this.heartsEl.classList.toggle(\"decay\", g.player.effects.has(\"decay\"));\n // пузырьки: сколько воздуха осталось под водой\n const pl = g.player;\n const showAir =\n pl.mode !== \"creative\" && !pl.dead && pl.air < MAX_AIR - 0.01;\n this.bubblesEl.style.display = showAir ? \"\" : \"none\";\n if (showAir) {\n const n = Math.ceil((pl.air / MAX_AIR) * 10);\n this._bubbles.forEach(\n (b, i) => (b.className = \"bubble\" + (i < n ? \"\" : \" empty\")),\n );\n }\n let fx = \"\";\n for (const [id, e] of g.player.effects) {\n const s = Math.max(0, Math.ceil(e.time));\n fx +=\n `${EFFECT_NAMES[id] ?? id}${e.level > 1 ? \" \" + e.level : \"\"} ` +\n `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}\\n`;\n }\n if (this.effectsEl.textContent !== fx) this.effectsEl.textContent = fx;\n // полоса босса: живой босс в загруженном мире\n let boss: { name: string; hp: number; max: number } | null = null;\n for (const e of g.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n boss = { name: e.def.name, hp: e.hp, max: e.def.hp };\n // нет босса, идёт набег — полоса набега: здоровье живых разбойников волны\n const raid = g.raid;\n if (!boss && raid) {\n let hp = 0;\n for (const id of raid.raiders) {\n const e = g.entities.entities.get(id);\n if (e?.kind === \"mob\" && !e.dead) hp += e.hp;\n }\n boss =\n raid.state === \"fighting\"\n ? {\n name: `Набег · волна ${raid.wave} из ${RAID_WAVES}`,\n hp,\n max: Math.max(1, raid.hpTotal),\n }\n : {\n name: `Набег · волна ${raid.wave + 1} из ${RAID_WAVES} — скоро`,\n hp: 0,\n max: 1,\n };\n }\n this.bossEl.classList.toggle(\"hidden\", !boss);\n if (boss) {\n this.bossEl.querySelector(\".bossName\")!.textContent = labelOf(boss.name);\n (this.bossEl.querySelector(\".bossFill\") as HTMLElement).style.width =\n ((boss.hp / boss.max) * 100).toFixed(1) + \"%\";\n }\n this.fpsEl.textContent = g.loop.fps + \" FPS\";\n // дебаг-строка\n const p = g.player.pos;\n this.debugEl.textContent =\n `xyz: ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)} | ` +\n `чанки: ${g.world.chunks.size} | жидкости: ${g.fluids.queueSize} | сид: ${g.world.seed}`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n // в Пекле и Пределе неба нет — и времени суток на экране тоже\n this.timeEl.textContent = DIMENSIONS[g.dimension].hasSky\n ? (g.dayNight.isDay ? \"☀\" : \"☾\") +\n \" \" +\n (ph < 0.5 ? \"день\" : \"ночь\") +\n (g.weather.kind === \"thunder\"\n ? \" · гроза\"\n : g.weather.kind === \"rain\"\n ? \" · дождь\"\n : \"\")\n : \"\";\n this._gadget();\n this._mapView();\n }\n\n /** Карта в руке: холст перерисовывается, только когда карта изменилась; метка — каждый кадр. */\n private _mapView() {\n const g = this.game;\n const h = g.inventory.heldItem();\n const m =\n h?.item === \"filled_map\" ? g.maps.get(Number(h.tag?.map)) : undefined;\n if (!m) {\n this.mapEl.classList.add(\"hidden\");\n return;\n }\n this.mapEl.classList.remove(\"hidden\");\n if (this._mapShown !== m || this._mapVersion !== m.version) {\n this._mapShown = m;\n this._mapVersion = m.version;\n const ctx = this.mapCanvas.getContext(\"2d\");\n if (ctx) {\n const img = ctx.createImageData(MAP_SIZE, MAP_SIZE);\n for (let i = 0; i < m.px.length; i++) {\n const c = mapRGB(m.px[i]!);\n const o = i * 4;\n img.data[o] = c < 0 ? 214 : (c >> 16) & 255;\n img.data[o + 1] = c < 0 ? 190 : (c >> 8) & 255;\n img.data[o + 2] = c < 0 ? 150 : c & 255;\n img.data[o + 3] = 255;\n }\n ctx.putImageData(img, 0, 0);\n }\n }\n const p = g.player.pos;\n const u = (p.x - m.x0) / MAP_SIZE,\n v = (p.z - m.z0) / MAP_SIZE;\n const inside =\n m.dim === g.dimension && u >= 0 && u <= 1 && v >= 0 && v <= 1;\n this.mapMarker.style.display = inside ? \"\" : \"none\";\n this.mapMarker.style.left = (u * 100).toFixed(2) + \"%\";\n this.mapMarker.style.top = (v * 100).toFixed(2) + \"%\";\n this.mapMarker.style.transform = `translate(-50%, -50%) rotate(${((-g.player.yaw * 180) / Math.PI).toFixed(1)}deg)`;\n // карта сокровищ: красный крестик над кладом\n const t = m.target;\n this.mapTreasure.style.display = t ? \"\" : \"none\";\n if (t) {\n this.mapTreasure.style.left =\n (((t[0] + 0.5 - m.x0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n this.mapTreasure.style.top =\n (((t[1] + 0.5 - m.z0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n }\n }\n\n private frostEl: HTMLElement | null = null;\n /** Иней по краям экрана — насколько игрок промёрз в снегу-порохе (0..1, systems/Freezing). */\n frost(k: number): void {\n if (!this.frostEl) {\n if (k <= 0) return;\n const el = document.createElement(\"div\");\n el.style.cssText =\n \"position:fixed;inset:0;pointer-events:none;opacity:0;\" +\n \"background:radial-gradient(ellipse at center,rgba(255,255,255,0) 40%,rgba(220,240,255,.9) 100%)\";\n this.heartsEl.parentElement!.appendChild(el);\n this.frostEl = el;\n }\n this.frostEl.style.opacity = k.toFixed(2);\n }\n\n /**\n * Компас и часы в руке: у хотбара — стрелка на точку появления мира (в Пекле и Пределе крутится)\n * или время суток (без неба — «??:??»), как у приборов Minecraft.\n */\n private _gadget() {\n const g = this.game;\n const held = g.inventory.heldItem()?.item;\n const el = this.gadgetEl;\n if (held !== \"compass\" && held !== \"clock\" && held !== \"recovery_compass\") {\n el.classList.add(\"hidden\");\n return;\n }\n el.classList.remove(\"hidden\");\n if (held === \"compass\" || held === \"recovery_compass\") {\n // компас — на путевой камень (если привязан здесь) или на точку появления,\n // компас возвращения — на место последней смерти в этом измерении\n const d = g.lastDeath;\n const lode = g.player.lodestone;\n const s =\n held === \"compass\"\n ? lode && lode.dim === g.dimension\n ? { x: lode.x, z: lode.z }\n : g.dimension === \"overworld\"\n ? g.world.generator.spawnHint()\n : null\n : d && d.dim === g.dimension\n ? { x: Math.floor(d.x), z: Math.floor(d.z) }\n : null;\n let deg: number;\n if (!s) deg = (performance.now() / 3) % 360;\n else {\n const p = g.player.pos;\n const target = Math.atan2(-(s.x + 0.5 - p.x), -(s.z + 0.5 - p.z));\n deg = (-(target - g.player.yaw) * 180) / Math.PI;\n }\n el.innerHTML = `<span class=\"needle\" style=\"transform:rotate(${deg.toFixed(1)}deg)\">▲</span>`;\n return;\n }\n if (!DIMENSIONS[g.dimension].hasSky) {\n el.textContent = \"🕒 ??:??\";\n return;\n }\n const h = (6 + g.dayNight.phase * 24) % 24;\n el.textContent = `🕒 ${String(Math.floor(h)).padStart(2, \"0\")}:${String(Math.floor((h % 1) * 60)).padStart(2, \"0\")}`;\n }\n\n _renderHotbar() {\n const inv = this.game.inventory;\n for (let i = 0; i < 9; i++) {\n const el = this._slots[i]!;\n const s = inv.slots[i];\n el.classList.toggle(\"selected\", i === inv.hotbarIndex);\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (s) {\n img.src = ITEMS[s.item]?.iconURL ?? \"\"; // iconURL заполняется buildIcons() до первого рендера; неизвестный предмет (чужой сейв) — пустая иконка, а не падение\n img.style.display = \"\";\n img.title = labelOf(s.item) + enchantSuffix(s);\n el.classList.toggle(\"ench\", enchantsOf(s).length > 0);\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (s.count > 1 ? s.count : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n el.classList.remove(\"ench\");\n }\n drawDurability(el, s ?? null);\n }\n }\n\n _renderHearts() {\n const g = this.game;\n this.heartsEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const hp = g.player.health;\n for (let i = 0; i < 10; i++) {\n const v = hp - i * 2;\n this._hearts[i]!.className =\n \"heart\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n _renderHunger() {\n const g = this.game;\n this.hungerEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const food = g.player.food;\n for (let i = 0; i < 10; i++) {\n const v = food - i * 2;\n this._drumsticks[i]!.className =\n \"drumstick\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n}\n"
1588
1596
  },
1589
1597
  {
1590
1598
  "path": "ui/InventoryUI.ts",
1591
- "content": "// Экраны инвентаря, как в Minecraft: инвентарь (броня + крафт 2×2), верстак (3×3), сундук, печь.\n// У всех внизу рюкзак и хотбар игрока. Мышь: ЛКМ — взять/положить стак, ПКМ — половину/по одному,\n// Shift+ЛКМ — перенести в «другую половину» экрана (броню — на тело, руду — в печь).\n// UI мутирует ТОЛЬКО модели (Inventory, CraftingGrid, состояние блочной сущности) и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS } from \"../registry/Items\";\nimport { labelOf } from \"../registry/names\";\nimport { smeltResult } from \"../registry/recipes/smelting\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport { ARMOR_SLOTS } from \"../systems/Inventory\";\nimport { COOK_TIME, type FurnaceState } from \"../world/blockentities/furnace\";\nimport { BREW_TIME, type BrewingState } from \"../world/blockentities/brewing\";\nimport { smithResult } from \"../systems/Smithing\";\nimport { TRIM_MATERIALS } from \"../registry/items/trims\";\nimport { bundleInsert, bundleTakeLast } from \"../systems/Bundle\";\nimport { stonecutOptions } from \"../systems/Stonecutter\";\nimport { TOO_EXPENSIVE, anvilResult } from \"../systems/Anvil\";\nimport { grindResult } from \"../systems/Grindstone\";\nimport {\n BANNER_PATTERNS,\n bannerColor,\n dyeColor,\n layersOf,\n loomResult,\n} from \"../systems/Banner\";\nimport { bannerCanvas } from \"../gfx/BannerArt\";\nimport {\n canEnchant,\n countShelves,\n enchantLine,\n enchantOffers,\n enchantsOf,\n enchantSuffix,\n withEnchants,\n} from \"../systems/Enchant\";\nimport { B, BLOCKS } from \"../registry/Blocks\";\nimport type { ChestState } from \"../world/blockentities/chest\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { drawDurability } from \"./slotView\";\nimport { renderTradeOffers } from \"./tradeView\";\nimport { TIER_NAMES, levelOf } from \"../systems/Trading\";\nimport type { Mob } from \"../entities/Mob\";\nimport type { Minecart } from \"../entities/Minecart\";\nimport { buildCreativePalette } from \"./creativePalette\";\nimport { renderBeaconPowers } from \"./beaconView\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\n\nexport type ScreenKind =\n | \"inventory\"\n | \"crafting\"\n | \"chest\"\n | \"furnace\"\n | \"brewing\"\n | \"smithing\"\n | \"enchanting\"\n | \"anvil\"\n | \"trade\"\n | \"beacon\"\n | \"creative\"\n | \"stonecutter\"\n | \"grindstone\"\n | \"loom\"\n | \"ender\"\n | \"armor_stand\";\n\ntype Group =\n | \"hotbar\"\n | \"main\"\n | \"armor\"\n | \"craft\"\n | \"chest\"\n | \"fIn\"\n | \"fFuel\"\n | \"fOut\"\n | \"bIn\"\n | \"bFuel\"\n | \"bottle\"\n | \"smTpl\"\n | \"smBase\"\n | \"smAdd\"\n | \"enItem\"\n | \"enLapis\"\n | \"anLeft\"\n | \"anRight\"\n | \"scIn\"\n | \"grLeft\"\n | \"grRight\"\n | \"loBanner\"\n | \"loDye\"\n | \"ender\"\n | \"stand\"\n | \"trash\";\n\ninterface SlotRef {\n group: Group;\n get(): ItemStack | null;\n set(s: ItemStack | null): void;\n /** Можно ли положить (броня — только в свой слот, топливо — только топливо). */\n accepts?(s: ItemStack): boolean;\n /** Только забирать (выход печи). */\n takeOnly?: boolean;\n el?: HTMLDivElement;\n}\n\nconst TITLES: Record<ScreenKind, string> = {\n inventory: \"Инвентарь\",\n crafting: \"Верстак\",\n chest: \"Сундук\",\n furnace: \"Печь\",\n creative: \"Креатив\",\n brewing: \"Варочная стойка\",\n smithing: \"Кузнечный стол\",\n enchanting: \"Стол зачарований\",\n anvil: \"Наковальня\",\n trade: \"Торговля\",\n beacon: \"Маяк\",\n stonecutter: \"Камнерез\",\n grindstone: \"Точило\",\n loom: \"Ткацкий станок\",\n ender: \"Сундук Предела\",\n armor_stand: \"Стойка для брони\",\n};\n\nconst maxOf = (item: string): number => ITEMS[item]?.maxStack ?? 64;\n/**\n * Складываются ли два стака: один предмет, без износа и с одинаковыми доп. данными. Сравнение tag\n * обязательно: иначе знамя с узорами, слитое со стопкой чистых, потеряло бы узоры (systems/Banner).\n */\nconst sameTag = (\n a?: Record<string, unknown>,\n b?: Record<string, unknown>,\n): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);\nconst stackable = (a: ItemStack, b: ItemStack): boolean =>\n a.item === b.item && !a.dmg && !b.dmg && sameTag(a.tag, b.tag);\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n kind: ScreenKind = \"inventory\";\n /** Кузнечный стол: основа (алмазная вещь) и добавка (пеклитовый слиток). */\n /** Кузнечный стол: вещь, добавка (пеклит или материал отделки), шаблон отделки. */\n smith: (ItemStack | null)[] = [null, null, null];\n /** Стол зачарований: предмет и лазурит; где стоит стол (полки вокруг); список вариантов. */\n enchSlots: (ItemStack | null)[] = [null, null];\n enchPos: { x: number; y: number; z: number } | null = null;\n private offersEl: HTMLElement | null = null;\n /** Наковальня: левый и правый слот; строка цены. */\n anvil: (ItemStack | null)[] = [null, null];\n /** Вход камнереза (экран \"stonecutter\"). */\n stoneIn: (ItemStack | null)[] = [null];\n /** Точило: два слота (экран \"grindstone\"). */\n grind: (ItemStack | null)[] = [null, null];\n /** Ткацкий станок: знамя и краситель (экран \"loom\"). */\n loom: (ItemStack | null)[] = [null, null];\n private costEl: HTMLElement | null = null;\n /** Житель, с которым торгуем (экран \"trade\"). */\n tradeMob: Mob | null = null;\n /** Вагонетка, чей груз открыт (экран \"chest\" поверх её слотов). */\n cart: Minecart | Mob | null = null;\n /** Блочная сущность открытого сундука/печи. */\n be: BlockEntity | null = null;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n private panelEl: HTMLElement;\n private refs: SlotRef[] = [];\n private resultEl: HTMLDivElement | null = null;\n private flameEl: HTMLDivElement | null = null;\n private arrowEl: HTMLDivElement | null = null;\n\n constructor(game: Game, signal: AbortSignal) {\n this.game = game;\n this.inventory = game.inventory;\n this.grid = new CraftingGrid(2);\n this.cursor = null; // стак «в руке» у курсора\n this.open = false;\n\n // Элементы гарантированно есть в разметке — non-null утверждается локально.\n this.rootEl = document.getElementById(\"invScreen\")!;\n this.cursorEl = document.getElementById(\"cursorStack\")!;\n this.panelEl = this.rootEl.querySelector(\".invPanel\")!;\n\n events.on(\"ui:toggleInventory\", () => {\n if (this.open) this.close();\n // в креативе E открывает палитру всех предметов, как в Minecraft\n else\n this.show(game.player.mode === \"creative\" ? \"creative\" : \"inventory\");\n });\n events.on(\"ui:openCrafting\", () => this.show(\"crafting\"));\n events.on(\"ui:openSmithing\", () => this.show(\"smithing\"));\n events.on(\"ui:openAnvil\", () => this.show(\"anvil\"));\n events.on(\"ui:openStonecutter\", () => this.show(\"stonecutter\"));\n events.on(\"ui:openGrindstone\", () => this.show(\"grindstone\"));\n events.on(\"ui:openLoom\", () => this.show(\"loom\"));\n events.on(\"ui:openEnder\", () => this.show(\"ender\"));\n events.on(\n \"ui:openArmorStand\",\n ({ x, y, z }: { x: number; y: number; z: number }) => {\n const be = game.blockEntities.ensure(x, y, z);\n if (be?.type === \"armor_stand\") this.show(\"armor_stand\", be);\n },\n );\n events.on(\"ui:openEnchanting\", (p: { x: number; y: number; z: number }) => {\n this.enchPos = p;\n this.show(\"enchanting\");\n });\n events.on(\"ui:openTrade\", ({ entityId }: { entityId: number }) => {\n const m = game.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || !m.def.trades) return;\n this.tradeMob = m;\n this.show(\"trade\");\n if (this.open) m.trading = true; // пока торгуем — стоит и смотрит на игрока (MobBrain)\n });\n events.on(\"ui:openCart\", ({ entityId }: { entityId: number }) => {\n const c = game.entities.entities.get(entityId);\n // груз вагонетки или вьюк осла, мула, ламы\n if (\n (c?.kind !== \"minecart\" && c?.kind !== \"mob\") ||\n !c.items.length ||\n this.open\n )\n return;\n this.cart = c;\n this.show(\"chest\", game.entities.cartBox(c));\n if (!this.open) this.cart = null;\n });\n events.on(\n \"ui:openContainer\",\n ({ x, y, z }: { x: number; y: number; z: number }) => {\n // ensure: сундук постройки генератора получает сущность (и добычу) при первом открытии\n const be = game.blockEntities.ensure(x, y, z);\n if (\n be &&\n (be.type === \"chest\" ||\n be.type === \"furnace\" ||\n be.type === \"brewing\")\n )\n this.show(be.type, be);\n // воронка, раздатчик, панцирный сундук — тот же экран, что сундук (слоты items), со своим заголовком\n else if (\n be &&\n (be.type === \"hopper\" ||\n be.type === \"dispenser\" ||\n be.type === \"shell\")\n )\n this.show(\"chest\", be);\n else if (be?.type === \"beacon\") this.show(\"beacon\", be);\n },\n );\n\n document.addEventListener(\n \"mousemove\",\n (e) => {\n if (!this.open) return;\n this.cursorEl.style.left = e.clientX + \"px\";\n this.cursorEl.style.top = e.clientY + \"px\";\n },\n { signal },\n );\n }\n\n /** Открыть экран. Уже открыт — ничего (закрывается E или Esc). */\n show(kind: ScreenKind, be: BlockEntity | null = null) {\n if (this.open) return;\n // На стартовом оверлее и паузе игра стоит: инвентарь открылся бы ПОД оверлеем, а его закрытие\n // (requestLock → takeControl) запускало бы игру в обход оверлея.\n if (!this.game.playing) return;\n this.open = true;\n this.kind = kind;\n this.be = be;\n this.grid.setSize(kind === \"crafting\" ? 3 : 2);\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.rootEl.classList.remove(\"hidden\");\n this._build();\n this._render();\n }\n\n /** Совместимость со старым вызовом: toggle(2) — инвентарь, toggle(3, true) — верстак. */\n toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n this.show(craftSize === 3 ? \"crafting\" : \"inventory\");\n }\n\n close() {\n if (!this.open) return;\n this.open = false;\n // вернуть всё из крафт-сетки и курсора; некуда класть — выбрасываем под ноги, ничего не теряем\n this.grid.dumpInto(this.inventory);\n // Остаток сетки выбрасываем обязательно: следующий show() пересоздаёт её (setSize), и то,\n // что «осталось в сетке», пропадало бы бесследно.\n this.grid.slots.forEach((s, i) => {\n if (!s) return;\n this._dropAtFeet(s.item, s.count);\n this.grid.slots[i] = null;\n });\n // кузнечный стол: всё обратно в инвентарь (с износом), не влезло — под ноги\n for (const slots of [\n this.smith,\n this.enchSlots,\n this.anvil,\n this.stoneIn,\n this.grind,\n ])\n for (let i = 0; i < slots.length; i++) {\n const s = slots[i];\n if (s && this.inventory.addStack(s) > 0)\n this._dropAtFeet(s.item, s.count);\n slots[i] = null;\n }\n if (this.cursor) {\n // addStack — инструмент в курсоре не теряет износ (раньше add() его обнулял)\n const left = this.inventory.addStack(this.cursor);\n if (left > 0) this._dropAtFeet(this.cursor.item, left);\n this.cursor = null;\n }\n this.be = null;\n if (this.tradeMob) this.tradeMob.trading = false;\n this.tradeMob = null;\n this.cart = null;\n this.rootEl.classList.add(\"hidden\");\n this.cursorEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Кадр: у печи бегут шкалы; сломанный сундук/печь закрывают свой экран. */\n frame() {\n if (this.open && this.kind === \"trade\") {\n // житель умер или ушёл — торговля закрывается\n const m = this.tradeMob;\n if (!m || m.dead || m.pos.distanceTo(this.game.player.pos) > 8)\n this.close();\n return;\n }\n if (this.open && this.cart) {\n // вагонетку сломали или она уехала — экран закрывается\n const c = this.cart;\n if (c.dead || c.pos.distanceTo(this.game.player.pos) > 8) this.close();\n return;\n }\n if (!this.open || !this.be) return;\n const be = this.be;\n if (this.game.blockEntities.get(be.x, be.y, be.z) !== be)\n return this.close();\n if (this.kind === \"furnace\" || this.kind === \"brewing\") this._render();\n }\n\n _dropAtFeet(item: string, count: number) {\n const p = this.game.player.pos;\n this.game.entities.spawnDrop(p.x, p.y + 1, p.z, item, count);\n }\n\n // ---- построение ------------------------------------------------------------------------\n\n /** Имя блока под открытой блочной сущностью (для заголовка). */\n private _beBlockName(): string {\n const be = this.be;\n if (!be) return \"\";\n return BLOCKS[this.game.world.getBlock(be.x, be.y, be.z)]?.name ?? be.type;\n }\n\n private _build() {\n const inv = this.inventory;\n this.refs = [];\n this.resultEl = this.flameEl = this.arrowEl = null;\n this.panelEl.innerHTML = \"\";\n const h = document.createElement(\"h3\");\n h.id = \"invTitle\";\n h.textContent = this.cart\n ? labelOf(\n this.cart.kind === \"mob\" ? this.cart.def.name : this.cart.dropItem,\n )\n : this.kind === \"trade\" && this.tradeMob\n ? `${labelOf(this.tradeMob.def.name)} — ${TIER_NAMES[levelOf(this.tradeMob.villagerXp) - 1]}`\n : // воронка, раздатчик, бочка, коптильня, плавильная печь — своим именем (горящие — как погасшие)\n (this.kind === \"chest\" || this.kind === \"furnace\") &&\n this.be &&\n ![\"chest\", \"furnace\", \"lit_furnace\"].includes(this._beBlockName())\n ? labelOf(this._beBlockName().replace(/^lit_/, \"\"))\n : TITLES[this.kind];\n this.panelEl.appendChild(h);\n\n if (this.kind === \"creative\")\n buildCreativePalette(this.panelEl, (item, right, shift) =>\n this._clickPalette(item, right, shift),\n );\n\n const top = this._div(this.panelEl, \"invTop\");\n if (this.kind === \"creative\") {\n // корзина: положенный сюда стак исчезает (ПКМ — по одному)\n this._div(top, \"trashLabel\").textContent = \"Удалить:\";\n this._slot(top, {\n group: \"trash\",\n get: () => null,\n set: () => {},\n }).classList.add(\"trash\");\n }\n if (this.kind === \"inventory\") {\n const armor = this._div(top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n this._slot(armor, {\n group: \"armor\",\n get: () => inv.armor[i] ?? null,\n set: (s) => inv.setArmor(i, s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n }\n if (this.kind === \"inventory\" || this.kind === \"crafting\") {\n const n = this.grid.size;\n const craft = this._div(top, \"craftGrid\");\n craft.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n for (let i = 0; i < n * n; i++)\n this._slot(craft, {\n group: \"craft\",\n get: () => this.grid.slots[i] ?? null,\n set: (s) => (this.grid.slots[i] = s),\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickResult(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n }\n if (this.kind === \"armor_stand\" && this.be) {\n // стойка: те же четыре слота брони, что у игрока, но у клетки (gfx/ArmorStand показывает их)\n const items = (this.be.state as ChestState).items;\n const col = this._div(top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n this._slot(col, {\n group: \"stand\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n }\n if (this.kind === \"ender\") {\n // сундук Предела: слоты не у клетки, а у игрока — один склад на все сундуки и измерения\n const grid = this._div(top, \"chestGrid\");\n for (let i = 0; i < inv.ender.length; i++)\n this._slot(grid, {\n group: \"ender\",\n get: () => inv.ender[i] ?? null,\n set: (s) => (inv.ender[i] = s),\n });\n }\n if (this.kind === \"chest\" && this.be) {\n const items = (this.be.state as ChestState).items;\n const grid = this._div(top, \"chestGrid\");\n if (items.length === 9)\n grid.style.gridTemplateColumns = \"repeat(3, 44px)\"; // раздатчик 3×3\n for (let i = 0; i < items.length; i++)\n this._slot(grid, {\n group: \"chest\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n // панцирный сундук в панцирный не кладётся, как в Minecraft\n accepts: (s) =>\n !(this.be?.type === \"shell\" && s.item === \"shell_chest\"),\n });\n }\n if (this.kind === \"furnace\" && this.be) {\n const st = this.be.state as FurnaceState;\n const col = this._div(top, \"furnaceCol\");\n this._slot(col, {\n group: \"fIn\",\n get: () => st.input,\n set: (s) => (st.input = s),\n });\n this.flameEl = this._div(\n this._div(col, \"flame\"),\n \"flameFill\",\n ) as HTMLDivElement;\n this._slot(col, {\n group: \"fFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => (ITEMS[s.item]?.fuel ?? 0) > 0,\n });\n this.arrowEl = this._div(\n this._div(top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n this._slot(top, {\n group: \"fOut\",\n get: () => st.output,\n set: (s) => (st.output = s),\n takeOnly: true,\n }).classList.add(\"result\");\n }\n\n this.offersEl = null;\n this.costEl = null;\n if (this.kind === \"anvil\") {\n this._slot(top, {\n group: \"anLeft\",\n get: () => this.anvil[0] ?? null,\n set: (s) => (this.anvil[0] = s),\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"anRight\",\n get: () => this.anvil[1] ?? null,\n set: (s) => (this.anvil[1] = s),\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickAnvil(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n this.costEl = this._div(top, \"anvilCost\");\n }\n if (this.kind === \"grindstone\") {\n // точило: два слота и результат без зачарований (опыт возвращается игроку)\n this._slot(top, {\n group: \"grLeft\",\n get: () => this.grind[0] ?? null,\n set: (s) => (this.grind[0] = s),\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"grRight\",\n get: () => this.grind[1] ?? null,\n set: (s) => (this.grind[1] = s),\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickGrind(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n this.costEl = this._div(top, \"anvilCost\");\n }\n if (this.kind === \"enchanting\") {\n const col = this._div(top, \"enchCol\");\n this._slot(col, {\n group: \"enItem\",\n get: () => this.enchSlots[0] ?? null,\n set: (s) => (this.enchSlots[0] = s),\n accepts: (s) => canEnchant(s.item),\n });\n this._slot(col, {\n group: \"enLapis\",\n get: () => this.enchSlots[1] ?? null,\n set: (s) => (this.enchSlots[1] = s),\n accepts: (s) => s.item === \"lapis_lazuli\",\n });\n this.offersEl = this._div(top, \"enchOffers\");\n }\n if (this.kind === \"loom\") {\n // ткацкий станок: знамя, краситель и список узоров — щелчок кладёт слой\n this._slot(top, {\n group: \"loBanner\",\n get: () => this.loom[0] ?? null,\n set: (s) => (this.loom[0] = s),\n accepts: (s) => bannerColor(s.item) !== null,\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"loDye\",\n get: () => this.loom[1] ?? null,\n set: (s) => (this.loom[1] = s),\n accepts: (s) => dyeColor(s.item) !== null,\n });\n this._div(top, \"arrow\").textContent = \"→\";\n this.offersEl = this._div(top, \"enchOffers tradeOffers loomOffers\");\n }\n if (this.kind === \"stonecutter\") {\n // камнерез: вход и список изделий — щелчок режет один блок\n this._slot(top, {\n group: \"scIn\",\n get: () => this.stoneIn[0] ?? null,\n set: (s) => (this.stoneIn[0] = s),\n accepts: (s) => stonecutOptions(s.item).length > 0,\n });\n this._div(top, \"arrow\").textContent = \"→\";\n this.offersEl = this._div(top, \"enchOffers tradeOffers\");\n }\n if (this.kind === \"trade\")\n this.offersEl = this._div(top, \"enchOffers tradeOffers\");\n if (this.kind === \"beacon\")\n this.offersEl = this._div(top, \"enchOffers beaconPowers\");\n if (this.kind === \"smithing\") {\n // шаблон отделки (необязателен: без него — улучшение до пеклита)\n this._slot(top, {\n group: \"smTpl\",\n get: () => this.smith[2] ?? null,\n set: (s) => (this.smith[2] = s),\n accepts: (s) => s.item.endsWith(\"_smithing_template\"),\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"smBase\",\n get: () => this.smith[0] ?? null,\n set: (s) => (this.smith[0] = s),\n accepts: (s) =>\n s.item.startsWith(\"diamond_\") || !!ITEMS[s.item]?.armorSlot,\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"smAdd\",\n get: () => this.smith[1] ?? null,\n set: (s) => (this.smith[1] = s),\n accepts: (s) =>\n s.item === \"infernite_ingot\" || s.item in TRIM_MATERIALS,\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickSmith(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n }\n if (this.kind === \"brewing\" && this.be) {\n const st = this.be.state as BrewingState;\n const col = this._div(top, \"brewCol\");\n this._slot(col, {\n group: \"bIn\",\n get: () => st.ingredient,\n set: (s) => (st.ingredient = s),\n });\n this._slot(col, {\n group: \"bFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => s.item === \"flame_powder\",\n });\n this.arrowEl = this._div(\n this._div(top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n const row = this._div(top, \"bottleRow\");\n for (let i = 0; i < 3; i++)\n this._slot(row, {\n group: \"bottle\",\n get: () => st.bottles[i] ?? null,\n set: (s) => (st.bottles[i] = s),\n accepts: (s) =>\n s.item.startsWith(\"potion_\") || s.item === \"glass_bottle\",\n });\n }\n\n const main = this._div(this.panelEl, \"invGrid\");\n for (let i = 9; i < 36; i++)\n this._slot(main, {\n group: \"main\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n const hot = this._div(this.panelEl, \"invHotbar\");\n for (let i = 0; i < 9; i++)\n this._slot(hot, {\n group: \"hotbar\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n }\n\n private _div(parent: HTMLElement, cls: string): HTMLElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n }\n\n private _slot(parent: HTMLElement, ref: SlotRef): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n d.onmousedown = (e) => {\n this._clickSlot(ref, e.button === 2, e.shiftKey);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n parent.appendChild(d);\n ref.el = d;\n this.refs.push(ref);\n return d;\n }\n\n // ---- клики -------------------------------------------------------------------------------\n\n private _clickSlot(ref: SlotRef, rightClick: boolean, shift: boolean) {\n if (shift) {\n this._quickMove(ref);\n this._render();\n return;\n }\n const cur = this.cursor;\n const slot = ref.get();\n\n if (ref.takeOnly) {\n // выход печи: только забрать — целиком в курсор, если влезает\n if (!slot) return;\n if (!cur) {\n this.cursor = slot;\n ref.set(null);\n } else if (\n stackable(cur, slot) &&\n cur.count + slot.count <= maxOf(cur.item)\n ) {\n cur.count += slot.count;\n ref.set(null);\n } else return;\n this._tookOutput(ref);\n this._render();\n return;\n }\n\n // мешок: ПКМ мешком по предмету или предметом по мешку — положить; ПКМ пустой рукой по мешку — достать\n if (\n rightClick &&\n cur?.item === \"bundle\" &&\n slot &&\n slot.item !== \"bundle\"\n ) {\n const moved = bundleInsert(cur, slot);\n if (moved > 0) {\n slot.count -= moved;\n ref.set(slot.count > 0 ? slot : null);\n events.emit(\"bundleInsert\", { item: slot.item });\n this._render();\n }\n return;\n }\n if (rightClick && slot?.item === \"bundle\" && cur?.item !== \"bundle\") {\n if (!cur) {\n const out = bundleTakeLast(slot);\n if (out) {\n this.cursor = out;\n ref.set(slot);\n events.emit(\"bundleRemove\", { item: out.item });\n this._render();\n return;\n }\n } else {\n const moved = bundleInsert(slot, cur);\n if (moved > 0) {\n cur.count -= moved;\n if (cur.count <= 0) this.cursor = null;\n ref.set(slot);\n events.emit(\"bundleInsert\", { item: cur.item });\n }\n this._render();\n return;\n }\n }\n\n if (!cur) {\n if (!slot) return;\n if (rightClick) {\n // взять половину\n const take = Math.ceil(slot.count / 2);\n this.cursor = { ...slot, count: take };\n slot.count -= take;\n ref.set(slot.count > 0 ? slot : null);\n } else {\n this.cursor = slot;\n ref.set(null);\n }\n } else {\n if (ref.accepts && !ref.accepts(cur)) return;\n const max = maxOf(cur.item);\n if (!slot) {\n if (rightClick) {\n ref.set({ ...cur, count: 1 });\n cur.count -= 1;\n if (cur.count <= 0) this.cursor = null;\n } else {\n ref.set(cur);\n this.cursor = null;\n }\n } else if (stackable(slot, cur)) {\n const move = rightClick ? 1 : cur.count;\n const take = Math.min(move, max - slot.count);\n slot.count += take;\n cur.count -= take;\n ref.set(slot);\n if (cur.count <= 0) this.cursor = null;\n } else {\n // обмен\n ref.set(cur);\n this.cursor = slot;\n }\n }\n this._render();\n }\n\n /**\n * Креатив: клик по палитре — полный стак в курсор (ПКМ — один); с чем-то в курсоре — удалить\n * это, как в Minecraft; Shift — стак сразу в инвентарь.\n */\n private _clickPalette(item: string, rightClick: boolean, shift: boolean) {\n if (shift) this.inventory.add(item, maxOf(item));\n else if (this.cursor) this.cursor = null;\n else this.cursor = { item, count: rightClick ? 1 : maxOf(item) };\n this._render();\n }\n\n /** Книжные полки вокруг открытого стола зачарований. */\n private _shelves(): number {\n const p = this.enchPos;\n if (!p) return 0;\n const w = this.game.world;\n return countShelves(\n (x, y, z) => w.getBlock(x, y, z),\n B.bookshelf!,\n p.x,\n p.y,\n p.z,\n );\n }\n\n /** Варианты стола: текст, доступность (уровень, лазурит), клик — зачаровать. */\n /** Камнерез: блок из входа → изделие в курсор (не лезет — в инвентарь), с Shift — сразу в инвентарь. */\n private _stonecut(out: ItemStack, shift: boolean) {\n const input = this.stoneIn[0];\n if (!input) return;\n input.count -= 1;\n if (input.count <= 0) this.stoneIn[0] = null;\n const made: ItemStack = { item: out.item, count: out.count };\n const cur = this.cursor;\n if (!shift && !cur) this.cursor = made;\n else if (\n !shift &&\n cur &&\n cur.item === made.item &&\n !cur.dmg &&\n cur.count + made.count <= maxOf(made.item)\n )\n cur.count += made.count;\n else {\n const left = this.inventory.addStack(made);\n if (left > 0) this._dropAtFeet(made.item, left);\n }\n events.emit(\"stonecut\", { item: made.item });\n this._render();\n }\n\n private _renderOffers() {\n const el = this.offersEl;\n if (!el) return;\n if (this.kind === \"beacon\") {\n if (this.be)\n renderBeaconPowers(\n el,\n this.inventory,\n this.be,\n this.game.player.mode === \"creative\",\n () => this._render(),\n );\n return;\n }\n if (this.kind === \"trade\") {\n if (this.tradeMob)\n renderTradeOffers(\n el,\n this.inventory,\n this.tradeMob,\n (s) => this._dropAtFeet(s.item, s.count),\n () => this._render(),\n this.game.player.effects.get(\"hero\")?.level ?? 0,\n this.game.player.villageRep,\n );\n return;\n }\n if (this.kind === \"loom\") {\n el.innerHTML = \"\";\n const banner = this.loom[0] ?? null;\n const dye = this.loom[1] ?? null;\n const base = banner ? bannerColor(banner.item) : null;\n for (const p of BANNER_PATTERNS) {\n const out = loomResult(banner, dye, p.id);\n if (!out) continue;\n const b = document.createElement(\"div\");\n b.className = \"enchOffer\";\n const pic = bannerCanvas(base, layersOf(out), 2);\n pic.className = \"loomPic\";\n b.appendChild(pic);\n b.appendChild(document.createTextNode(p.name));\n b.onmousedown = (e) => {\n e.preventDefault();\n this._loom(p.id, e.shiftKey);\n };\n el.appendChild(b);\n }\n return;\n }\n if (this.kind === \"stonecutter\") {\n el.innerHTML = \"\";\n const input = this.stoneIn[0];\n for (const out of input ? stonecutOptions(input.item) : []) {\n const b = document.createElement(\"div\");\n b.className = \"enchOffer\";\n b.textContent = `${labelOf(input!.item)} → ${out.count > 1 ? out.count + \"× \" : \"\"}${labelOf(out.item)}`;\n b.onmousedown = (e) => {\n e.preventDefault();\n this._stonecut(out, e.shiftKey);\n };\n el.appendChild(b);\n }\n return;\n }\n const item = this.enchSlots[0];\n const offers =\n item && enchantsOf(item).length === 0\n ? enchantOffers(item.item, this._shelves(), this.game.player.enchSeed)\n : null;\n el.innerHTML = \"\";\n const pl = this.game.player;\n const lapis = this.enchSlots[1]?.count ?? 0;\n (offers ?? []).forEach((o, i) => {\n const ok =\n pl.mode === \"creative\" || (pl.xpLevel >= o.level && lapis >= o.cost);\n const b = document.createElement(\"div\");\n b.className = \"enchOffer\" + (ok ? \"\" : \" off\");\n b.textContent =\n o.ench.map(([id, l]) => enchantLine(id, l)).join(\", \") +\n ` · ${o.level} ур. · ${o.cost} лаз.`;\n b.onmousedown = (e) => {\n e.preventDefault();\n if (ok) this._enchant(i);\n };\n el.appendChild(b);\n });\n }\n\n /** Зачаровать вариантом i: списать уровни и лазурит (в креативе — нет), сменить зерно. */\n private _enchant(i: number) {\n const item = this.enchSlots[0];\n if (!item) return;\n const o = enchantOffers(\n item.item,\n this._shelves(),\n this.game.player.enchSeed,\n )?.[i];\n if (!o) return;\n const pl = this.game.player;\n if (pl.mode !== \"creative\") {\n const lapis = this.enchSlots[1];\n if (!lapis || lapis.count < o.cost || pl.xpLevel < o.level) return;\n lapis.count -= o.cost;\n if (lapis.count <= 0) this.enchSlots[1] = null;\n pl.xpLevel = Math.max(0, pl.xpLevel - o.cost);\n }\n // книга становится зачарованной книгой (одна; остальные книги — обратно в инвентарь)\n let base = item;\n if (item.item === \"book\") {\n if (item.count > 1 && this.inventory.add(\"book\", item.count - 1) > 0)\n this._dropAtFeet(\"book\", item.count - 1);\n base = { item: \"enchanted_book\", count: 1 };\n }\n this.enchSlots[0] = withEnchants(base, Object.fromEntries(o.ench));\n pl.enchSeed = (Math.random() * 0xffffffff) >>> 0;\n events.emit(\"enchanted\", { item: item.item });\n this._render();\n }\n\n /** Результат наковальни: списать уровни (в креативе — нет), левый слот и нужное из правого. */\n private _clickAnvil(shift: boolean) {\n const r = anvilResult(this.anvil[0] ?? null, this.anvil[1] ?? null);\n if (!r || (!shift && this.cursor)) return;\n const pl = this.game.player;\n if (pl.mode !== \"creative\") {\n if (r.cost >= TOO_EXPENSIVE || pl.xpLevel < r.cost) return;\n pl.xpLevel -= r.cost;\n }\n this.anvil[0] = null;\n const right = this.anvil[1]!;\n right.count -= r.useRight;\n if (right.count <= 0) this.anvil[1] = null;\n if (shift) {\n if (this.inventory.addStack(r.result) > 0)\n this._dropAtFeet(r.result.item, 1);\n } else this.cursor = r.result;\n events.emit(\"anvilUse\", { item: r.result.item });\n this._render();\n }\n\n /** Результат точила: вещь без зачарований, снятый опыт — игроку (как в Minecraft). */\n private _clickGrind(shift: boolean) {\n const r = grindResult(this.grind[0] ?? null, this.grind[1] ?? null);\n if (!r || (!shift && this.cursor)) return;\n this.grind[0] = null;\n if (r.useRight) this.grind[1] = null;\n if (shift) {\n if (this.inventory.addStack(r.result) > 0)\n this._dropAtFeet(r.result.item, 1);\n } else this.cursor = r.result;\n if (r.xp > 0) events.emit(\"xpGain\", { amount: r.xp });\n events.emit(\"grindstoneUse\", { item: r.result.item });\n this._render();\n }\n\n /** Узор на знамя: тратит один краситель, знамя из слота заменяется на новое со слоем. */\n private _loom(pattern: string, shift: boolean) {\n const out = loomResult(this.loom[0] ?? null, this.loom[1] ?? null, pattern);\n if (!out || (!shift && this.cursor)) return;\n const dye = this.loom[1]!;\n dye.count -= 1;\n if (dye.count <= 0) this.loom[1] = null;\n const banner = this.loom[0]!;\n banner.count -= 1;\n if (banner.count <= 0) this.loom[0] = null;\n if (shift) {\n if (this.inventory.addStack(out) > 0) this._dropAtFeet(out.item, 1);\n } else this.cursor = out;\n events.emit(\"loomUse\", { item: out.item });\n this._render();\n }\n\n /** Результат кузнечного стола: в курсор (рука свободна) или Shift — сразу в инвентарь. */\n private _clickSmith(shift: boolean) {\n const tpl = this.smith[2] ?? null;\n const res = smithResult(this.smith[0] ?? null, this.smith[1] ?? null, tpl);\n if (!res || (!shift && this.cursor)) return;\n this.smith[0] = null;\n const add = this.smith[1]!;\n add.count -= 1;\n if (add.count <= 0) this.smith[1] = null;\n if (tpl) {\n tpl.count -= 1; // шаблон тратится, как в Minecraft\n if (tpl.count <= 0) this.smith[2] = null;\n events.emit(\"armorTrimmed\", { item: res.item });\n }\n if (shift) {\n if (this.inventory.addStack(res) > 0) this._dropAtFeet(res.item, 1);\n } else this.cursor = res;\n events.emit(\"craft\", { item: res.item, count: 1 });\n this._render();\n }\n\n /** Забрали выход печи: накопленный опыт — игроку. */\n private _tookOutput(ref: SlotRef) {\n if (ref.group !== \"fOut\" || !this.be) return;\n const st = this.be.state as FurnaceState;\n if (st.xp > 0) events.emit(\"xpGain\", { amount: st.xp });\n st.xp = 0;\n }\n\n /** Куда Shift-клик переносит стак из этой группы, по порядку предпочтения. */\n private _targets(from: Group, s: ItemStack): SlotRef[] {\n const of = (...g: Group[]) => this.refs.filter((r) => g.includes(r.group));\n const player =\n from === \"hotbar\"\n ? of(\"main\")\n : from === \"main\"\n ? of(\"hotbar\")\n : of(\"hotbar\", \"main\");\n if (from === \"hotbar\" || from === \"main\") {\n const def = ITEMS[s.item];\n if (def?.armorSlot) {\n const armor = of(\"armor\").filter((r) => !r.get() && r.accepts?.(s));\n if (armor.length) return armor;\n }\n if (this.kind === \"chest\") return [...of(\"chest\"), ...player];\n if (this.kind === \"ender\") return [...of(\"ender\"), ...player];\n if (this.kind === \"armor_stand\")\n return ITEMS[s.item]?.armorSlot ? [...of(\"stand\"), ...player] : player;\n if (this.kind === \"stonecutter\" && stonecutOptions(s.item).length)\n return [...of(\"scIn\"), ...player];\n if (this.kind === \"anvil\")\n return [...of(\"anLeft\"), ...of(\"anRight\"), ...player];\n if (this.kind === \"grindstone\")\n return [...of(\"grLeft\"), ...of(\"grRight\"), ...player];\n if (this.kind === \"loom\") {\n if (bannerColor(s.item)) return [...of(\"loBanner\"), ...player];\n if (dyeColor(s.item)) return [...of(\"loDye\"), ...player];\n return player;\n }\n if (this.kind === \"enchanting\") {\n if (s.item === \"lapis_lazuli\") return [...of(\"enLapis\"), ...player];\n if (canEnchant(s.item)) return [...of(\"enItem\"), ...player];\n return player;\n }\n if (this.kind === \"smithing\") {\n if (s.item.endsWith(\"_smithing_template\"))\n return [...of(\"smTpl\"), ...player];\n if (s.item.startsWith(\"diamond_\") || ITEMS[s.item]?.armorSlot)\n return [...of(\"smBase\"), ...player];\n if (s.item === \"infernite_ingot\" || s.item in TRIM_MATERIALS)\n return [...of(\"smAdd\"), ...player];\n return player;\n }\n if (this.kind === \"brewing\") {\n if (s.item.startsWith(\"potion_\")) return [...of(\"bottle\"), ...player];\n if (s.item === \"flame_powder\")\n return [...of(\"bFuel\"), ...of(\"bIn\"), ...player];\n return [...of(\"bIn\"), ...player];\n }\n if (this.kind === \"furnace\") {\n if (smeltResult(s.item)) return [...of(\"fIn\"), ...player];\n if ((def?.fuel ?? 0) > 0) return [...of(\"fFuel\"), ...player];\n }\n return player;\n }\n return of(\"hotbar\", \"main\");\n }\n\n private _quickMove(ref: SlotRef) {\n const s = ref.get();\n if (!s) return;\n let left = s.count;\n const targets = this._targets(ref.group, s).filter(\n (t) => t !== ref && !t.takeOnly && (!t.accepts || t.accepts(s)),\n );\n // сначала досыпать в такие же стаки, потом — в пустые\n for (const pass of [0, 1])\n for (const t of targets) {\n if (left <= 0) break;\n const ts = t.get();\n if (pass === 0 && ts && stackable(ts, s)) {\n const take = Math.min(left, maxOf(s.item) - ts.count);\n if (take > 0) {\n ts.count += take;\n left -= take;\n t.set(ts);\n }\n } else if (pass === 1 && !ts) {\n const take = Math.min(left, maxOf(s.item));\n t.set({ ...s, count: take });\n left -= take;\n }\n }\n ref.set(left > 0 ? { ...s, count: left } : null);\n if (left < s.count) this._tookOutput(ref);\n }\n\n /** Результат крафта: клик — в курсор; Shift — скрафтить сколько получится прямо в инвентарь. */\n private _clickResult(shift: boolean) {\n const res = this.grid.result();\n if (!res) return;\n if (shift) {\n for (let n = 0; n < 64; n++) {\n const cur = this.grid.result();\n if (!cur || cur.item !== res.item) break;\n const taken = this.grid.takeResult()!;\n const left = this.inventory.add(taken.item, taken.count);\n if (left > 0) {\n this._dropAtFeet(taken.item, left);\n break;\n }\n }\n this._render();\n return;\n }\n if (\n this.cursor &&\n (this.cursor.item !== res.item ||\n this.cursor.count + res.count > maxOf(res.item))\n )\n return;\n const taken = this.grid.takeResult()!; // result() уже проверен выше — сетка не менялась, результат есть\n if (this.cursor) this.cursor.count += taken.count;\n else this.cursor = { item: taken.item, count: taken.count };\n this._render();\n }\n\n // ---- отрисовка ----------------------------------------------------------------------------\n\n _render() {\n const draw = (el: HTMLElement, stack: ItemStack | null) => {\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (stack) {\n img.src = ITEMS[stack.item]!.iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = labelOf(stack.item) + enchantSuffix(stack);\n count.textContent = stack.count > 1 ? String(stack.count) : \"\";\n el.classList.toggle(\"ench\", enchantsOf(stack).length > 0);\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n el.classList.remove(\"ench\");\n }\n drawDurability(el, stack);\n };\n for (const r of this.refs) if (r.el) draw(r.el, r.get());\n if (this.resultEl) {\n const anvil =\n this.kind === \"anvil\"\n ? anvilResult(this.anvil[0] ?? null, this.anvil[1] ?? null)\n : null;\n const res =\n this.kind === \"smithing\"\n ? smithResult(\n this.smith[0] ?? null,\n this.smith[1] ?? null,\n this.smith[2] ?? null,\n )\n : this.kind === \"anvil\"\n ? (anvil?.result ?? null)\n : this.kind === \"grindstone\"\n ? (grindResult(this.grind[0] ?? null, this.grind[1] ?? null)\n ?.result ?? null)\n : this.grid.result();\n draw(this.resultEl, res ? { ...res } : null);\n if (this.costEl) {\n const pl = this.game.player;\n this.costEl.textContent = !anvil\n ? \"\"\n : anvil.cost >= TOO_EXPENSIVE && pl.mode !== \"creative\"\n ? \"Слишком дорого!\"\n : `Цена: ${anvil.cost} ур.`;\n }\n }\n if (this.be && this.kind === \"brewing\" && this.arrowEl) {\n const st = this.be.state as BrewingState;\n this.arrowEl.style.width = ((st.brew / BREW_TIME) * 100).toFixed(1) + \"%\";\n }\n if (this.be && this.kind === \"furnace\") {\n const st = this.be.state as FurnaceState;\n if (this.flameEl)\n this.flameEl.style.height =\n (st.burnMax > 0 ? (st.burn / st.burnMax) * 100 : 0).toFixed(1) + \"%\";\n if (this.arrowEl)\n this.arrowEl.style.width =\n ((st.cook / COOK_TIME) * 100).toFixed(1) + \"%\";\n }\n\n // курсорный стак\n if (this.cursor) {\n this.cursorEl.classList.remove(\"hidden\");\n this.cursorEl.querySelector(\"img\")!.src =\n ITEMS[this.cursor.item]!.iconURL!;\n this.cursorEl.querySelector(\".count\")!.textContent =\n this.cursor.count > 1 ? String(this.cursor.count) : \"\";\n } else {\n this.cursorEl.classList.add(\"hidden\");\n }\n this._renderOffers();\n events.emit(\"invChanged\"); // HUD-хотбар обновится\n }\n}\n"
1599
+ "content": "// Экраны инвентаря, как в Minecraft: инвентарь (броня + крафт 2×2), верстак (3×3), сундук, печь.\n// У всех внизу рюкзак и хотбар игрока. Мышь: ЛКМ — взять/положить стак, ПКМ — половину/по одному,\n// Shift+ЛКМ — перенести в «другую половину» экрана (броню — на тело, руду — в печь).\n// UI мутирует ТОЛЬКО модели (Inventory, CraftingGrid, состояние блочной сущности) и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS } from \"../registry/Items\";\nimport { labelOf } from \"../registry/names\";\nimport { smeltResult } from \"../registry/recipes/smelting\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport { ARMOR_SLOTS } from \"../systems/Inventory\";\nimport { COOK_TIME, type FurnaceState } from \"../world/blockentities/furnace\";\nimport { BREW_TIME, type BrewingState } from \"../world/blockentities/brewing\";\nimport { smithResult } from \"../systems/Smithing\";\nimport { TRIM_MATERIALS } from \"../registry/items/trims\";\nimport { bundleInsert, bundleTakeLast } from \"../systems/Bundle\";\nimport { stonecutOptions } from \"../systems/Stonecutter\";\nimport { TOO_EXPENSIVE, anvilResult } from \"../systems/Anvil\";\nimport { grindResult } from \"../systems/Grindstone\";\nimport {\n BANNER_PATTERNS,\n bannerColor,\n dyeColor,\n layersOf,\n loomResult,\n} from \"../systems/Banner\";\nimport { bannerCanvas } from \"../gfx/BannerArt\";\nimport {\n canEnchant,\n countShelves,\n enchantLine,\n enchantOffers,\n enchantsOf,\n enchantSuffix,\n withEnchants,\n} from \"../systems/Enchant\";\nimport { B, BLOCKS } from \"../registry/Blocks\";\nimport type { ChestState } from \"../world/blockentities/chest\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { drawDurability } from \"./slotView\";\nimport { renderTradeOffers } from \"./tradeView\";\nimport { TIER_NAMES, levelOf } from \"../systems/Trading\";\nimport type { Mob } from \"../entities/Mob\";\nimport type { Minecart } from \"../entities/Minecart\";\nimport { buildCreativePalette } from \"./creativePalette\";\nimport { renderBeaconPowers } from \"./beaconView\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\n\nexport type ScreenKind =\n | \"inventory\"\n | \"crafting\"\n | \"chest\"\n | \"furnace\"\n | \"brewing\"\n | \"smithing\"\n | \"enchanting\"\n | \"anvil\"\n | \"trade\"\n | \"beacon\"\n | \"creative\"\n | \"stonecutter\"\n | \"grindstone\"\n | \"loom\"\n | \"ender\"\n | \"armor_stand\";\n\ntype Group =\n | \"hotbar\"\n | \"main\"\n | \"armor\"\n | \"craft\"\n | \"chest\"\n | \"fIn\"\n | \"fFuel\"\n | \"fOut\"\n | \"bIn\"\n | \"bFuel\"\n | \"bottle\"\n | \"smTpl\"\n | \"smBase\"\n | \"smAdd\"\n | \"enItem\"\n | \"enLapis\"\n | \"anLeft\"\n | \"anRight\"\n | \"scIn\"\n | \"grLeft\"\n | \"grRight\"\n | \"loBanner\"\n | \"loDye\"\n | \"ender\"\n | \"stand\"\n | \"trash\";\n\ninterface SlotRef {\n group: Group;\n get(): ItemStack | null;\n set(s: ItemStack | null): void;\n /** Можно ли положить (броня — только в свой слот, топливо — только топливо). */\n accepts?(s: ItemStack): boolean;\n /** Только забирать (выход печи). */\n takeOnly?: boolean;\n el?: HTMLDivElement;\n}\n\nconst TITLES: Record<ScreenKind, string> = {\n inventory: \"Инвентарь\",\n crafting: \"Верстак\",\n chest: \"Сундук\",\n furnace: \"Печь\",\n creative: \"Креатив\",\n brewing: \"Варочная стойка\",\n smithing: \"Кузнечный стол\",\n enchanting: \"Стол зачарований\",\n anvil: \"Наковальня\",\n trade: \"Торговля\",\n beacon: \"Маяк\",\n stonecutter: \"Камнерез\",\n grindstone: \"Точило\",\n loom: \"Ткацкий станок\",\n ender: \"Сундук Предела\",\n armor_stand: \"Стойка для брони\",\n};\n\nconst maxOf = (item: string): number => ITEMS[item]?.maxStack ?? 64;\n/**\n * Складываются ли два стака: один предмет, без износа и с одинаковыми доп. данными. Сравнение tag\n * обязательно: иначе знамя с узорами, слитое со стопкой чистых, потеряло бы узоры (systems/Banner).\n */\nconst sameTag = (\n a?: Record<string, unknown>,\n b?: Record<string, unknown>,\n): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);\nconst stackable = (a: ItemStack, b: ItemStack): boolean =>\n a.item === b.item && !a.dmg && !b.dmg && sameTag(a.tag, b.tag);\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n kind: ScreenKind = \"inventory\";\n /** Кузнечный стол: основа (алмазная вещь) и добавка (пеклитовый слиток). */\n /** Кузнечный стол: вещь, добавка (пеклит или материал отделки), шаблон отделки. */\n smith: (ItemStack | null)[] = [null, null, null];\n /** Стол зачарований: предмет и лазурит; где стоит стол (полки вокруг); список вариантов. */\n enchSlots: (ItemStack | null)[] = [null, null];\n enchPos: { x: number; y: number; z: number } | null = null;\n private offersEl: HTMLElement | null = null;\n /** Наковальня: левый и правый слот; строка цены. */\n anvil: (ItemStack | null)[] = [null, null];\n /** Вход камнереза (экран \"stonecutter\"). */\n stoneIn: (ItemStack | null)[] = [null];\n /** Точило: два слота (экран \"grindstone\"). */\n grind: (ItemStack | null)[] = [null, null];\n /** Ткацкий станок: знамя и краситель (экран \"loom\"). */\n loom: (ItemStack | null)[] = [null, null];\n private costEl: HTMLElement | null = null;\n /** Житель, с которым торгуем (экран \"trade\"). */\n tradeMob: Mob | null = null;\n /** Вагонетка, чей груз открыт (экран \"chest\" поверх её слотов). */\n cart: Minecart | Mob | null = null;\n /** Блочная сущность открытого сундука/печи. */\n be: BlockEntity | null = null;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n private panelEl: HTMLElement;\n private refs: SlotRef[] = [];\n private resultEl: HTMLDivElement | null = null;\n private flameEl: HTMLDivElement | null = null;\n private arrowEl: HTMLDivElement | null = null;\n\n constructor(game: Game, signal: AbortSignal) {\n this.game = game;\n this.inventory = game.inventory;\n this.grid = new CraftingGrid(2);\n this.cursor = null; // стак «в руке» у курсора\n this.open = false;\n\n // Элементы гарантированно есть в разметке — non-null утверждается локально.\n this.rootEl = document.getElementById(\"invScreen\")!;\n this.cursorEl = document.getElementById(\"cursorStack\")!;\n this.panelEl = this.rootEl.querySelector(\".invPanel\")!;\n\n events.on(\"ui:toggleInventory\", () => {\n if (this.open) this.close();\n // в креативе E открывает палитру всех предметов, как в Minecraft\n else\n this.show(game.player.mode === \"creative\" ? \"creative\" : \"inventory\");\n });\n events.on(\"ui:openCrafting\", () => this.show(\"crafting\"));\n events.on(\"ui:openSmithing\", () => this.show(\"smithing\"));\n events.on(\"ui:openAnvil\", () => this.show(\"anvil\"));\n events.on(\"ui:openStonecutter\", () => this.show(\"stonecutter\"));\n events.on(\"ui:openGrindstone\", () => this.show(\"grindstone\"));\n events.on(\"ui:openLoom\", () => this.show(\"loom\"));\n events.on(\"ui:openEnder\", () => this.show(\"ender\"));\n events.on(\n \"ui:openArmorStand\",\n ({ x, y, z }: { x: number; y: number; z: number }) => {\n const be = game.blockEntities.ensure(x, y, z);\n if (be?.type === \"armor_stand\") this.show(\"armor_stand\", be);\n },\n );\n events.on(\"ui:openEnchanting\", (p: { x: number; y: number; z: number }) => {\n this.enchPos = p;\n this.show(\"enchanting\");\n });\n events.on(\"ui:openTrade\", ({ entityId }: { entityId: number }) => {\n const m = game.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || !m.def.trades) return;\n this.tradeMob = m;\n this.show(\"trade\");\n if (this.open) m.trading = true; // пока торгуем — стоит и смотрит на игрока (MobBrain)\n });\n events.on(\"ui:openCart\", ({ entityId }: { entityId: number }) => {\n const c = game.entities.entities.get(entityId);\n // груз вагонетки или вьюк осла, мула, ламы\n if (\n (c?.kind !== \"minecart\" && c?.kind !== \"mob\") ||\n !c.items.length ||\n this.open\n )\n return;\n this.cart = c;\n this.show(\"chest\", game.entities.cartBox(c));\n if (!this.open) this.cart = null;\n });\n events.on(\n \"ui:openContainer\",\n ({ x, y, z }: { x: number; y: number; z: number }) => {\n // ensure: сундук постройки генератора получает сущность (и добычу) при первом открытии\n const be = game.blockEntities.ensure(x, y, z);\n if (\n be &&\n (be.type === \"chest\" ||\n be.type === \"furnace\" ||\n be.type === \"brewing\")\n )\n this.show(be.type, be);\n // воронка, раздатчик, панцирный сундук — тот же экран, что сундук (слоты items), со своим заголовком\n else if (\n be &&\n (be.type === \"hopper\" ||\n be.type === \"dispenser\" ||\n be.type === \"shell\")\n )\n this.show(\"chest\", be);\n else if (be?.type === \"beacon\") this.show(\"beacon\", be);\n },\n );\n\n document.addEventListener(\n \"mousemove\",\n (e) => {\n if (!this.open) return;\n this.cursorEl.style.left = e.clientX + \"px\";\n this.cursorEl.style.top = e.clientY + \"px\";\n },\n { signal },\n );\n }\n\n /** Открыть экран. Уже открыт — ничего (закрывается E или Esc). */\n show(kind: ScreenKind, be: BlockEntity | null = null) {\n if (this.open) return;\n // На стартовом оверлее и паузе игра стоит: инвентарь открылся бы ПОД оверлеем, а его закрытие\n // (requestLock → takeControl) запускало бы игру в обход оверлея.\n if (!this.game.playing) return;\n // Поверх другого экрана (смерть, титры, табличка, чат) не открываемся: закрытие инвентаря\n // захватывало мышь, и кнопку «Возродиться» на экране смерти становилось нечем нажать.\n if (this.game.player.dead || this.game.input.uiOpen) return;\n this.open = true;\n this.kind = kind;\n this.be = be;\n this.grid.setSize(kind === \"crafting\" ? 3 : 2);\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.rootEl.classList.remove(\"hidden\");\n this._build();\n this._render();\n }\n\n /** Совместимость со старым вызовом: toggle(2) — инвентарь, toggle(3, true) — верстак. */\n toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n this.show(craftSize === 3 ? \"crafting\" : \"inventory\");\n }\n\n close() {\n if (!this.open) return;\n this.open = false;\n // вернуть всё из крафт-сетки и курсора; некуда класть — выбрасываем под ноги, ничего не теряем\n this.grid.dumpInto(this.inventory);\n // Остаток сетки выбрасываем обязательно: следующий show() пересоздаёт её (setSize), и то,\n // что «осталось в сетке», пропадало бы бесследно.\n this.grid.slots.forEach((s, i) => {\n if (!s) return;\n this._dropAtFeet(s);\n this.grid.slots[i] = null;\n });\n // кузнечный стол: всё обратно в инвентарь (с износом), не влезло — под ноги\n for (const slots of [\n this.smith,\n this.enchSlots,\n this.anvil,\n this.stoneIn,\n this.grind,\n ])\n for (let i = 0; i < slots.length; i++) {\n const s = slots[i];\n if (s && this.inventory.addStack(s) > 0) this._dropAtFeet(s);\n slots[i] = null;\n }\n if (this.cursor) {\n // addStack — инструмент в курсоре не теряет износ (раньше add() его обнулял)\n const left = this.inventory.addStack(this.cursor);\n if (left > 0) this._dropAtFeet(this.cursor, left);\n this.cursor = null;\n }\n this.be = null;\n if (this.tradeMob) this.tradeMob.trading = false;\n this.tradeMob = null;\n this.cart = null;\n this.rootEl.classList.add(\"hidden\");\n this.cursorEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n // закрыт смертью — мышь не захватываем, экран смерти должен остаться кликабельным\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Кадр: у печи бегут шкалы; сломанный сундук/печь закрывают свой экран. */\n frame() {\n if (this.open && this.kind === \"trade\") {\n // житель умер или ушёл — торговля закрывается\n const m = this.tradeMob;\n if (!m || m.dead || m.pos.distanceTo(this.game.player.pos) > 8)\n this.close();\n return;\n }\n if (this.open && this.cart) {\n // вагонетку сломали или она уехала — экран закрывается\n const c = this.cart;\n if (c.dead || c.pos.distanceTo(this.game.player.pos) > 8) this.close();\n return;\n }\n if (!this.open || !this.be) return;\n const be = this.be;\n if (this.game.blockEntities.get(be.x, be.y, be.z) !== be)\n return this.close();\n if (this.kind === \"furnace\" || this.kind === \"brewing\") this._render();\n }\n\n /** Бросить стак под ноги — целиком, с износом и tag (зачарования, имя): иначе вещь «чинилась» и\n * теряла чары, если в инвентаре не нашлось места. */\n _dropAtFeet(s: ItemStack, count = s.count) {\n if (count <= 0) return;\n const p = this.game.player.pos;\n this.game.entities.spawnDrop(\n p.x,\n p.y + 1,\n p.z,\n s.item,\n count,\n s.dmg,\n s.tag,\n );\n }\n\n // ---- построение ------------------------------------------------------------------------\n\n /** Имя блока под открытой блочной сущностью (для заголовка). */\n private _beBlockName(): string {\n const be = this.be;\n if (!be) return \"\";\n return BLOCKS[this.game.world.getBlock(be.x, be.y, be.z)]?.name ?? be.type;\n }\n\n private _build() {\n const inv = this.inventory;\n this.refs = [];\n this.resultEl = this.flameEl = this.arrowEl = null;\n this.panelEl.innerHTML = \"\";\n const h = document.createElement(\"h3\");\n h.id = \"invTitle\";\n h.textContent = this.cart\n ? labelOf(\n this.cart.kind === \"mob\" ? this.cart.def.name : this.cart.dropItem,\n )\n : this.kind === \"trade\" && this.tradeMob\n ? `${labelOf(this.tradeMob.def.name)} — ${TIER_NAMES[levelOf(this.tradeMob.villagerXp) - 1]}`\n : // воронка, раздатчик, бочка, коптильня, плавильная печь — своим именем (горящие — как погасшие)\n (this.kind === \"chest\" || this.kind === \"furnace\") &&\n this.be &&\n ![\"chest\", \"furnace\", \"lit_furnace\"].includes(this._beBlockName())\n ? labelOf(this._beBlockName().replace(/^lit_/, \"\"))\n : TITLES[this.kind];\n this.panelEl.appendChild(h);\n\n if (this.kind === \"creative\")\n buildCreativePalette(this.panelEl, (item, right, shift) =>\n this._clickPalette(item, right, shift),\n );\n\n const top = this._div(this.panelEl, \"invTop\");\n if (this.kind === \"creative\") {\n // корзина: положенный сюда стак исчезает (ПКМ — по одному)\n this._div(top, \"trashLabel\").textContent = \"Удалить:\";\n this._slot(top, {\n group: \"trash\",\n get: () => null,\n set: () => {},\n }).classList.add(\"trash\");\n }\n if (this.kind === \"inventory\") {\n const armor = this._div(top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n this._slot(armor, {\n group: \"armor\",\n get: () => inv.armor[i] ?? null,\n set: (s) => inv.setArmor(i, s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n }\n if (this.kind === \"inventory\" || this.kind === \"crafting\") {\n const n = this.grid.size;\n const craft = this._div(top, \"craftGrid\");\n craft.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n for (let i = 0; i < n * n; i++)\n this._slot(craft, {\n group: \"craft\",\n get: () => this.grid.slots[i] ?? null,\n set: (s) => (this.grid.slots[i] = s),\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickResult(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n }\n if (this.kind === \"armor_stand\" && this.be) {\n // стойка: те же четыре слота брони, что у игрока, но у клетки (gfx/ArmorStand показывает их)\n const items = (this.be.state as ChestState).items;\n const col = this._div(top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n this._slot(col, {\n group: \"stand\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n }\n if (this.kind === \"ender\") {\n // сундук Предела: слоты не у клетки, а у игрока — один склад на все сундуки и измерения\n const grid = this._div(top, \"chestGrid\");\n for (let i = 0; i < inv.ender.length; i++)\n this._slot(grid, {\n group: \"ender\",\n get: () => inv.ender[i] ?? null,\n set: (s) => (inv.ender[i] = s),\n });\n }\n if (this.kind === \"chest\" && this.be) {\n const items = (this.be.state as ChestState).items;\n const grid = this._div(top, \"chestGrid\");\n if (items.length === 9)\n grid.style.gridTemplateColumns = \"repeat(3, 44px)\"; // раздатчик 3×3\n for (let i = 0; i < items.length; i++)\n this._slot(grid, {\n group: \"chest\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n // панцирный сундук в панцирный не кладётся, как в Minecraft\n accepts: (s) =>\n !(this.be?.type === \"shell\" && s.item === \"shell_chest\"),\n });\n }\n if (this.kind === \"furnace\" && this.be) {\n const st = this.be.state as FurnaceState;\n const col = this._div(top, \"furnaceCol\");\n this._slot(col, {\n group: \"fIn\",\n get: () => st.input,\n set: (s) => (st.input = s),\n });\n this.flameEl = this._div(\n this._div(col, \"flame\"),\n \"flameFill\",\n ) as HTMLDivElement;\n this._slot(col, {\n group: \"fFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => (ITEMS[s.item]?.fuel ?? 0) > 0,\n });\n this.arrowEl = this._div(\n this._div(top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n this._slot(top, {\n group: \"fOut\",\n get: () => st.output,\n set: (s) => (st.output = s),\n takeOnly: true,\n }).classList.add(\"result\");\n }\n\n this.offersEl = null;\n this.costEl = null;\n if (this.kind === \"anvil\") {\n this._slot(top, {\n group: \"anLeft\",\n get: () => this.anvil[0] ?? null,\n set: (s) => (this.anvil[0] = s),\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"anRight\",\n get: () => this.anvil[1] ?? null,\n set: (s) => (this.anvil[1] = s),\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickAnvil(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n this.costEl = this._div(top, \"anvilCost\");\n }\n if (this.kind === \"grindstone\") {\n // точило: два слота и результат без зачарований (опыт возвращается игроку)\n this._slot(top, {\n group: \"grLeft\",\n get: () => this.grind[0] ?? null,\n set: (s) => (this.grind[0] = s),\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"grRight\",\n get: () => this.grind[1] ?? null,\n set: (s) => (this.grind[1] = s),\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickGrind(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n this.costEl = this._div(top, \"anvilCost\");\n }\n if (this.kind === \"enchanting\") {\n const col = this._div(top, \"enchCol\");\n this._slot(col, {\n group: \"enItem\",\n get: () => this.enchSlots[0] ?? null,\n set: (s) => (this.enchSlots[0] = s),\n accepts: (s) => canEnchant(s.item),\n });\n this._slot(col, {\n group: \"enLapis\",\n get: () => this.enchSlots[1] ?? null,\n set: (s) => (this.enchSlots[1] = s),\n accepts: (s) => s.item === \"lapis_lazuli\",\n });\n this.offersEl = this._div(top, \"enchOffers\");\n }\n if (this.kind === \"loom\") {\n // ткацкий станок: знамя, краситель и список узоров — щелчок кладёт слой\n this._slot(top, {\n group: \"loBanner\",\n get: () => this.loom[0] ?? null,\n set: (s) => (this.loom[0] = s),\n accepts: (s) => bannerColor(s.item) !== null,\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"loDye\",\n get: () => this.loom[1] ?? null,\n set: (s) => (this.loom[1] = s),\n accepts: (s) => dyeColor(s.item) !== null,\n });\n this._div(top, \"arrow\").textContent = \"→\";\n this.offersEl = this._div(top, \"enchOffers tradeOffers loomOffers\");\n }\n if (this.kind === \"stonecutter\") {\n // камнерез: вход и список изделий — щелчок режет один блок\n this._slot(top, {\n group: \"scIn\",\n get: () => this.stoneIn[0] ?? null,\n set: (s) => (this.stoneIn[0] = s),\n accepts: (s) => stonecutOptions(s.item).length > 0,\n });\n this._div(top, \"arrow\").textContent = \"→\";\n this.offersEl = this._div(top, \"enchOffers tradeOffers\");\n }\n if (this.kind === \"trade\")\n this.offersEl = this._div(top, \"enchOffers tradeOffers\");\n if (this.kind === \"beacon\")\n this.offersEl = this._div(top, \"enchOffers beaconPowers\");\n if (this.kind === \"smithing\") {\n // шаблон отделки (необязателен: без него — улучшение до пеклита)\n this._slot(top, {\n group: \"smTpl\",\n get: () => this.smith[2] ?? null,\n set: (s) => (this.smith[2] = s),\n accepts: (s) => s.item.endsWith(\"_smithing_template\"),\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"smBase\",\n get: () => this.smith[0] ?? null,\n set: (s) => (this.smith[0] = s),\n accepts: (s) =>\n s.item.startsWith(\"diamond_\") || !!ITEMS[s.item]?.armorSlot,\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"smAdd\",\n get: () => this.smith[1] ?? null,\n set: (s) => (this.smith[1] = s),\n accepts: (s) =>\n s.item === \"infernite_ingot\" || s.item in TRIM_MATERIALS,\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickSmith(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n }\n if (this.kind === \"brewing\" && this.be) {\n const st = this.be.state as BrewingState;\n const col = this._div(top, \"brewCol\");\n this._slot(col, {\n group: \"bIn\",\n get: () => st.ingredient,\n set: (s) => (st.ingredient = s),\n });\n this._slot(col, {\n group: \"bFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => s.item === \"flame_powder\",\n });\n this.arrowEl = this._div(\n this._div(top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n const row = this._div(top, \"bottleRow\");\n for (let i = 0; i < 3; i++)\n this._slot(row, {\n group: \"bottle\",\n get: () => st.bottles[i] ?? null,\n set: (s) => (st.bottles[i] = s),\n accepts: (s) =>\n s.item.startsWith(\"potion_\") || s.item === \"glass_bottle\",\n });\n }\n\n const main = this._div(this.panelEl, \"invGrid\");\n for (let i = 9; i < 36; i++)\n this._slot(main, {\n group: \"main\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n const hot = this._div(this.panelEl, \"invHotbar\");\n for (let i = 0; i < 9; i++)\n this._slot(hot, {\n group: \"hotbar\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n }\n\n private _div(parent: HTMLElement, cls: string): HTMLElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n }\n\n private _slot(parent: HTMLElement, ref: SlotRef): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n d.onmousedown = (e) => {\n this._clickSlot(ref, e.button === 2, e.shiftKey);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n parent.appendChild(d);\n ref.el = d;\n this.refs.push(ref);\n return d;\n }\n\n // ---- клики -------------------------------------------------------------------------------\n\n private _clickSlot(ref: SlotRef, rightClick: boolean, shift: boolean) {\n if (shift) {\n this._quickMove(ref);\n this._render();\n return;\n }\n const cur = this.cursor;\n const slot = ref.get();\n\n if (ref.takeOnly) {\n // выход печи: только забрать — целиком в курсор, если влезает\n if (!slot) return;\n if (!cur) {\n this.cursor = slot;\n ref.set(null);\n } else if (\n stackable(cur, slot) &&\n cur.count + slot.count <= maxOf(cur.item)\n ) {\n cur.count += slot.count;\n ref.set(null);\n } else return;\n this._tookOutput(ref);\n this._render();\n return;\n }\n\n // мешок: ПКМ мешком по предмету или предметом по мешку — положить; ПКМ пустой рукой по мешку — достать\n if (\n rightClick &&\n cur?.item === \"bundle\" &&\n slot &&\n slot.item !== \"bundle\"\n ) {\n const moved = bundleInsert(cur, slot);\n if (moved > 0) {\n slot.count -= moved;\n ref.set(slot.count > 0 ? slot : null);\n events.emit(\"bundleInsert\", { item: slot.item });\n this._render();\n }\n return;\n }\n if (rightClick && slot?.item === \"bundle\" && cur?.item !== \"bundle\") {\n if (!cur) {\n const out = bundleTakeLast(slot);\n if (out) {\n this.cursor = out;\n ref.set(slot);\n events.emit(\"bundleRemove\", { item: out.item });\n this._render();\n return;\n }\n } else {\n const moved = bundleInsert(slot, cur);\n if (moved > 0) {\n cur.count -= moved;\n if (cur.count <= 0) this.cursor = null;\n ref.set(slot);\n events.emit(\"bundleInsert\", { item: cur.item });\n }\n this._render();\n return;\n }\n }\n\n if (!cur) {\n if (!slot) return;\n if (rightClick) {\n // взять половину\n const take = Math.ceil(slot.count / 2);\n this.cursor = { ...slot, count: take };\n slot.count -= take;\n ref.set(slot.count > 0 ? slot : null);\n } else {\n this.cursor = slot;\n ref.set(null);\n }\n } else {\n if (ref.accepts && !ref.accepts(cur)) return;\n const max = maxOf(cur.item);\n if (!slot) {\n if (rightClick) {\n ref.set({ ...cur, count: 1 });\n cur.count -= 1;\n if (cur.count <= 0) this.cursor = null;\n } else {\n ref.set(cur);\n this.cursor = null;\n }\n } else if (stackable(slot, cur) && max > 1) {\n // нескладываемые (мечи, вёдра, зелья) — обмен ниже, как в Minecraft: раньше клик мечом по\n // такому же мечу не делал ничего\n const move = rightClick ? 1 : cur.count;\n const take = Math.min(move, max - slot.count);\n slot.count += take;\n cur.count -= take;\n ref.set(slot);\n if (cur.count <= 0) this.cursor = null;\n } else {\n // обмен\n ref.set(cur);\n this.cursor = slot;\n }\n }\n this._render();\n }\n\n /**\n * Креатив: клик по палитре — полный стак в курсор (ПКМ — один); с чем-то в курсоре — удалить\n * это, как в Minecraft; Shift — стак сразу в инвентарь.\n */\n private _clickPalette(item: string, rightClick: boolean, shift: boolean) {\n if (shift) this.inventory.add(item, maxOf(item));\n else if (this.cursor) this.cursor = null;\n else this.cursor = { item, count: rightClick ? 1 : maxOf(item) };\n this._render();\n }\n\n /** Книжные полки вокруг открытого стола зачарований. */\n private _shelves(): number {\n const p = this.enchPos;\n if (!p) return 0;\n const w = this.game.world;\n return countShelves(\n (x, y, z) => w.getBlock(x, y, z),\n B.bookshelf!,\n p.x,\n p.y,\n p.z,\n );\n }\n\n /** Варианты стола: текст, доступность (уровень, лазурит), клик — зачаровать. */\n /** Камнерез: блок из входа → изделие в курсор (не лезет — в инвентарь), с Shift — сразу в инвентарь. */\n private _stonecut(out: ItemStack, shift: boolean) {\n const input = this.stoneIn[0];\n if (!input) return;\n input.count -= 1;\n if (input.count <= 0) this.stoneIn[0] = null;\n const made: ItemStack = { item: out.item, count: out.count };\n const cur = this.cursor;\n if (!shift && !cur) this.cursor = made;\n else if (\n !shift &&\n cur &&\n cur.item === made.item &&\n !cur.dmg &&\n cur.count + made.count <= maxOf(made.item)\n )\n cur.count += made.count;\n else {\n const left = this.inventory.addStack(made);\n if (left > 0) this._dropAtFeet(made, left);\n }\n events.emit(\"stonecut\", { item: made.item });\n this._render();\n }\n\n private _renderOffers() {\n const el = this.offersEl;\n if (!el) return;\n if (this.kind === \"beacon\") {\n if (this.be)\n renderBeaconPowers(\n el,\n this.inventory,\n this.be,\n this.game.player.mode === \"creative\",\n () => this._render(),\n );\n return;\n }\n if (this.kind === \"trade\") {\n if (this.tradeMob)\n renderTradeOffers(\n el,\n this.inventory,\n this.tradeMob,\n (s) => this._dropAtFeet(s),\n () => this._render(),\n this.game.player.effects.get(\"hero\")?.level ?? 0,\n this.game.player.villageRep,\n );\n return;\n }\n if (this.kind === \"loom\") {\n el.innerHTML = \"\";\n const banner = this.loom[0] ?? null;\n const dye = this.loom[1] ?? null;\n const base = banner ? bannerColor(banner.item) : null;\n for (const p of BANNER_PATTERNS) {\n const out = loomResult(banner, dye, p.id);\n if (!out) continue;\n const b = document.createElement(\"div\");\n b.className = \"enchOffer\";\n const pic = bannerCanvas(base, layersOf(out), 2);\n pic.className = \"loomPic\";\n b.appendChild(pic);\n b.appendChild(document.createTextNode(p.name));\n b.onmousedown = (e) => {\n e.preventDefault();\n this._loom(p.id, e.shiftKey);\n };\n el.appendChild(b);\n }\n return;\n }\n if (this.kind === \"stonecutter\") {\n el.innerHTML = \"\";\n const input = this.stoneIn[0];\n for (const out of input ? stonecutOptions(input.item) : []) {\n const b = document.createElement(\"div\");\n b.className = \"enchOffer\";\n b.textContent = `${labelOf(input!.item)} → ${out.count > 1 ? out.count + \"× \" : \"\"}${labelOf(out.item)}`;\n b.onmousedown = (e) => {\n e.preventDefault();\n this._stonecut(out, e.shiftKey);\n };\n el.appendChild(b);\n }\n return;\n }\n const item = this.enchSlots[0];\n const offers =\n item && enchantsOf(item).length === 0\n ? enchantOffers(item.item, this._shelves(), this.game.player.enchSeed)\n : null;\n el.innerHTML = \"\";\n const pl = this.game.player;\n const lapis = this.enchSlots[1]?.count ?? 0;\n (offers ?? []).forEach((o, i) => {\n const ok =\n pl.mode === \"creative\" || (pl.xpLevel >= o.level && lapis >= o.cost);\n const b = document.createElement(\"div\");\n b.className = \"enchOffer\" + (ok ? \"\" : \" off\");\n b.textContent =\n o.ench.map(([id, l]) => enchantLine(id, l)).join(\", \") +\n ` · ${o.level} ур. · ${o.cost} лаз.`;\n b.onmousedown = (e) => {\n e.preventDefault();\n if (ok) this._enchant(i);\n };\n el.appendChild(b);\n });\n }\n\n /** Зачаровать вариантом i: списать уровни и лазурит (в креативе — нет), сменить зерно. */\n private _enchant(i: number) {\n const item = this.enchSlots[0];\n if (!item) return;\n const o = enchantOffers(\n item.item,\n this._shelves(),\n this.game.player.enchSeed,\n )?.[i];\n if (!o) return;\n const pl = this.game.player;\n if (pl.mode !== \"creative\") {\n const lapis = this.enchSlots[1];\n if (!lapis || lapis.count < o.cost || pl.xpLevel < o.level) return;\n lapis.count -= o.cost;\n if (lapis.count <= 0) this.enchSlots[1] = null;\n pl.xpLevel = Math.max(0, pl.xpLevel - o.cost);\n }\n // книга становится зачарованной книгой (одна; остальные книги — обратно в инвентарь)\n let base = item;\n if (item.item === \"book\") {\n // не влезло — под ноги только остаток (раньше выбрасывалась вся стопка сверх одной: дюп книг)\n const left =\n item.count > 1 ? this.inventory.add(\"book\", item.count - 1) : 0;\n if (left > 0) this._dropAtFeet({ item: \"book\", count: left });\n base = { item: \"enchanted_book\", count: 1 };\n }\n this.enchSlots[0] = withEnchants(base, Object.fromEntries(o.ench));\n pl.enchSeed = (Math.random() * 0xffffffff) >>> 0;\n events.emit(\"enchanted\", { item: item.item });\n this._render();\n }\n\n /** Результат наковальни: списать уровни (в креативе — нет), левый слот и нужное из правого. */\n private _clickAnvil(shift: boolean) {\n const r = anvilResult(this.anvil[0] ?? null, this.anvil[1] ?? null);\n if (!r || (!shift && this.cursor)) return;\n const pl = this.game.player;\n if (pl.mode !== \"creative\") {\n if (r.cost >= TOO_EXPENSIVE || pl.xpLevel < r.cost) return;\n pl.xpLevel -= r.cost;\n }\n // из левого слота уходит одна вещь (результат — всегда одна), остаток стопки остаётся на месте\n const left = this.anvil[0]!;\n left.count -= 1;\n if (left.count <= 0) this.anvil[0] = null;\n const right = this.anvil[1]!;\n right.count -= r.useRight;\n if (right.count <= 0) this.anvil[1] = null;\n if (shift) {\n if (this.inventory.addStack(r.result) > 0) this._dropAtFeet(r.result);\n } else this.cursor = r.result;\n events.emit(\"anvilUse\", { item: r.result.item });\n this._render();\n }\n\n /** Результат точила: вещь без зачарований, снятый опыт — игроку (как в Minecraft). */\n private _clickGrind(shift: boolean) {\n const r = grindResult(this.grind[0] ?? null, this.grind[1] ?? null);\n if (!r || (!shift && this.cursor)) return;\n this.grind[0] = null;\n if (r.useRight) this.grind[1] = null;\n if (shift) {\n if (this.inventory.addStack(r.result) > 0) this._dropAtFeet(r.result);\n } else this.cursor = r.result;\n if (r.xp > 0) events.emit(\"xpGain\", { amount: r.xp });\n events.emit(\"grindstoneUse\", { item: r.result.item });\n this._render();\n }\n\n /** Узор на знамя: тратит один краситель, знамя из слота заменяется на новое со слоем. */\n private _loom(pattern: string, shift: boolean) {\n const out = loomResult(this.loom[0] ?? null, this.loom[1] ?? null, pattern);\n if (!out || (!shift && this.cursor)) return;\n const dye = this.loom[1]!;\n dye.count -= 1;\n if (dye.count <= 0) this.loom[1] = null;\n const banner = this.loom[0]!;\n banner.count -= 1;\n if (banner.count <= 0) this.loom[0] = null;\n if (shift) {\n if (this.inventory.addStack(out) > 0) this._dropAtFeet(out);\n } else this.cursor = out;\n events.emit(\"loomUse\", { item: out.item });\n this._render();\n }\n\n /** Результат кузнечного стола: в курсор (рука свободна) или Shift — сразу в инвентарь. */\n private _clickSmith(shift: boolean) {\n const tpl = this.smith[2] ?? null;\n const res = smithResult(this.smith[0] ?? null, this.smith[1] ?? null, tpl);\n if (!res || (!shift && this.cursor)) return;\n this.smith[0] = null;\n const add = this.smith[1]!;\n add.count -= 1;\n if (add.count <= 0) this.smith[1] = null;\n if (tpl) {\n tpl.count -= 1; // шаблон тратится, как в Minecraft\n if (tpl.count <= 0) this.smith[2] = null;\n events.emit(\"armorTrimmed\", { item: res.item });\n }\n if (shift) {\n if (this.inventory.addStack(res) > 0) this._dropAtFeet(res);\n } else this.cursor = res;\n events.emit(\"craft\", { item: res.item, count: 1 });\n this._render();\n }\n\n /** Забрали выход печи: накопленный опыт — игроку. */\n private _tookOutput(ref: SlotRef) {\n if (ref.group !== \"fOut\" || !this.be) return;\n const st = this.be.state as FurnaceState;\n if (st.xp > 0) events.emit(\"xpGain\", { amount: st.xp });\n st.xp = 0;\n }\n\n /** Куда Shift-клик переносит стак из этой группы, по порядку предпочтения. */\n private _targets(from: Group, s: ItemStack): SlotRef[] {\n const of = (...g: Group[]) => this.refs.filter((r) => g.includes(r.group));\n const player =\n from === \"hotbar\"\n ? of(\"main\")\n : from === \"main\"\n ? of(\"hotbar\")\n : of(\"hotbar\", \"main\");\n if (from === \"hotbar\" || from === \"main\") {\n const def = ITEMS[s.item];\n if (def?.armorSlot) {\n const armor = of(\"armor\").filter((r) => !r.get() && r.accepts?.(s));\n if (armor.length) return armor;\n }\n if (this.kind === \"chest\") return [...of(\"chest\"), ...player];\n if (this.kind === \"ender\") return [...of(\"ender\"), ...player];\n if (this.kind === \"armor_stand\")\n return ITEMS[s.item]?.armorSlot ? [...of(\"stand\"), ...player] : player;\n if (this.kind === \"stonecutter\" && stonecutOptions(s.item).length)\n return [...of(\"scIn\"), ...player];\n if (this.kind === \"anvil\")\n return [...of(\"anLeft\"), ...of(\"anRight\"), ...player];\n if (this.kind === \"grindstone\")\n return [...of(\"grLeft\"), ...of(\"grRight\"), ...player];\n if (this.kind === \"loom\") {\n if (bannerColor(s.item)) return [...of(\"loBanner\"), ...player];\n if (dyeColor(s.item)) return [...of(\"loDye\"), ...player];\n return player;\n }\n if (this.kind === \"enchanting\") {\n if (s.item === \"lapis_lazuli\") return [...of(\"enLapis\"), ...player];\n if (canEnchant(s.item)) return [...of(\"enItem\"), ...player];\n return player;\n }\n if (this.kind === \"smithing\") {\n if (s.item.endsWith(\"_smithing_template\"))\n return [...of(\"smTpl\"), ...player];\n if (s.item.startsWith(\"diamond_\") || ITEMS[s.item]?.armorSlot)\n return [...of(\"smBase\"), ...player];\n if (s.item === \"infernite_ingot\" || s.item in TRIM_MATERIALS)\n return [...of(\"smAdd\"), ...player];\n return player;\n }\n if (this.kind === \"brewing\") {\n if (s.item.startsWith(\"potion_\")) return [...of(\"bottle\"), ...player];\n if (s.item === \"flame_powder\")\n return [...of(\"bFuel\"), ...of(\"bIn\"), ...player];\n return [...of(\"bIn\"), ...player];\n }\n if (this.kind === \"furnace\") {\n if (smeltResult(s.item)) return [...of(\"fIn\"), ...player];\n if ((def?.fuel ?? 0) > 0) return [...of(\"fFuel\"), ...player];\n }\n return player;\n }\n return of(\"hotbar\", \"main\");\n }\n\n private _quickMove(ref: SlotRef) {\n const s = ref.get();\n if (!s) return;\n let left = s.count;\n const targets = this._targets(ref.group, s).filter(\n (t) => t !== ref && !t.takeOnly && (!t.accepts || t.accepts(s)),\n );\n // сначала досыпать в такие же стаки, потом — в пустые\n for (const pass of [0, 1])\n for (const t of targets) {\n if (left <= 0) break;\n const ts = t.get();\n if (pass === 0 && ts && stackable(ts, s)) {\n const take = Math.min(left, maxOf(s.item) - ts.count);\n if (take > 0) {\n ts.count += take;\n left -= take;\n t.set(ts);\n }\n } else if (pass === 1 && !ts) {\n const take = Math.min(left, maxOf(s.item));\n t.set({ ...s, count: take });\n left -= take;\n }\n }\n ref.set(left > 0 ? { ...s, count: left } : null);\n if (left < s.count) this._tookOutput(ref);\n }\n\n /** Результат крафта: клик — в курсор; Shift — скрафтить сколько получится прямо в инвентарь. */\n private _clickResult(shift: boolean) {\n const res = this.grid.result();\n if (!res) return;\n if (shift) {\n for (let n = 0; n < 64; n++) {\n const cur = this.grid.result();\n if (!cur || cur.item !== res.item) break;\n const taken = this.grid.takeResult()!;\n const left = this.inventory.addStack(taken);\n if (left > 0) {\n this._dropAtFeet(taken, left);\n break;\n }\n }\n this._render();\n return;\n }\n if (\n this.cursor &&\n (this.cursor.item !== res.item ||\n // в курсоре такая же вещь, но с износом или tag — не сливаем, иначе новая «прилипла» бы к ней\n this.cursor.dmg ||\n this.cursor.tag ||\n this.cursor.count + res.count > maxOf(res.item))\n )\n return;\n const taken = this.grid.takeResult()!; // result() уже проверен выше — сетка не менялась, результат есть\n if (this.cursor) this.cursor.count += taken.count;\n else this.cursor = { item: taken.item, count: taken.count };\n this._render();\n }\n\n // ---- отрисовка ----------------------------------------------------------------------------\n\n _render() {\n const draw = (el: HTMLElement, stack: ItemStack | null) => {\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (stack) {\n img.src = ITEMS[stack.item]?.iconURL ?? \"\"; // iconURL заполняется buildIcons() до первого рендера; неизвестный предмет — пустая иконка\n img.style.display = \"\";\n img.title = labelOf(stack.item) + enchantSuffix(stack);\n count.textContent = stack.count > 1 ? String(stack.count) : \"\";\n el.classList.toggle(\"ench\", enchantsOf(stack).length > 0);\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n el.classList.remove(\"ench\");\n }\n drawDurability(el, stack);\n };\n for (const r of this.refs) if (r.el) draw(r.el, r.get());\n if (this.resultEl) {\n const anvil =\n this.kind === \"anvil\"\n ? anvilResult(this.anvil[0] ?? null, this.anvil[1] ?? null)\n : null;\n const res =\n this.kind === \"smithing\"\n ? smithResult(\n this.smith[0] ?? null,\n this.smith[1] ?? null,\n this.smith[2] ?? null,\n )\n : this.kind === \"anvil\"\n ? (anvil?.result ?? null)\n : this.kind === \"grindstone\"\n ? (grindResult(this.grind[0] ?? null, this.grind[1] ?? null)\n ?.result ?? null)\n : this.grid.result();\n draw(this.resultEl, res ? { ...res } : null);\n if (this.costEl) {\n const pl = this.game.player;\n this.costEl.textContent = !anvil\n ? \"\"\n : anvil.cost >= TOO_EXPENSIVE && pl.mode !== \"creative\"\n ? \"Слишком дорого!\"\n : `Цена: ${anvil.cost} ур.`;\n }\n }\n if (this.be && this.kind === \"brewing\" && this.arrowEl) {\n const st = this.be.state as BrewingState;\n this.arrowEl.style.width = ((st.brew / BREW_TIME) * 100).toFixed(1) + \"%\";\n }\n if (this.be && this.kind === \"furnace\") {\n const st = this.be.state as FurnaceState;\n if (this.flameEl)\n this.flameEl.style.height =\n (st.burnMax > 0 ? (st.burn / st.burnMax) * 100 : 0).toFixed(1) + \"%\";\n if (this.arrowEl)\n this.arrowEl.style.width =\n ((st.cook / COOK_TIME) * 100).toFixed(1) + \"%\";\n }\n\n // курсорный стак\n if (this.cursor) {\n this.cursorEl.classList.remove(\"hidden\");\n this.cursorEl.querySelector(\"img\")!.src =\n ITEMS[this.cursor.item]?.iconURL ?? \"\";\n this.cursorEl.querySelector(\".count\")!.textContent =\n this.cursor.count > 1 ? String(this.cursor.count) : \"\";\n } else {\n this.cursorEl.classList.add(\"hidden\");\n }\n this._renderOffers();\n events.emit(\"invChanged\"); // HUD-хотбар обновится\n }\n}\n"
1592
1600
  },
1593
1601
  {
1594
1602
  "path": "ui/NameEditor.ts",
1595
- "content": "// Ввод имени для бирки: ПКМ биркой по мобу открывает строку (как редактор таблички), Enter даёт\n// имя, Esc отменяет. Имя вешает сам моб (Mob.setName), бирка тратится только при удаче.\nimport { events } from \"../core/EventBus\";\nimport type { Game } from \"../main\";\n\nexport class NameEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private entityId: number | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"nameEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 24;\n this.input.placeholder = \"Имя\";\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:nameMob\", (p: { entityId: number }) => this.show(p.entityId));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(entityId: number): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const m = g.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || m.dead) return;\n this.entityId = entityId;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = m.customName ?? \"\";\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.entityId = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Дать мобу имя и потратить бирку. */\n submit(): void {\n const id = this.entityId;\n const name = this.input.value.trim();\n this.hide();\n if (id === null || !name) return;\n const m = this.game.entities.entities.get(id);\n if (m?.kind !== \"mob\" || m.dead) return;\n m.setName(name);\n if (this.game.player.mode !== \"creative\")\n this.game.inventory.consumeHeld(1);\n events.emit(\"mobNamed\", { entityId: id, name });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
1603
+ "content": "// Ввод имени для бирки: ПКМ биркой по мобу открывает строку (как редактор таблички), Enter даёт\n// имя, Esc отменяет. Имя вешает сам моб (Mob.setName), бирка тратится только при удаче.\nimport { events } from \"../core/EventBus\";\nimport type { Game } from \"../main\";\n\nexport class NameEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private entityId: number | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"nameEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 24;\n this.input.placeholder = \"Имя\";\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:nameMob\", (p: { entityId: number }) => this.show(p.entityId));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(entityId: number): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const m = g.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || m.dead) return;\n this.entityId = entityId;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = m.customName ?? \"\";\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.entityId = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Дать мобу имя и потратить бирку. */\n submit(): void {\n const id = this.entityId;\n const name = this.input.value.trim();\n this.hide();\n if (id === null || !name) return;\n const m = this.game.entities.entities.get(id);\n if (m?.kind !== \"mob\" || m.dead) return;\n m.setName(name);\n if (this.game.player.mode !== \"creative\")\n this.game.inventory.consumeHeld(1);\n events.emit(\"mobNamed\", { entityId: id, name });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
1596
1604
  },
1597
1605
  {
1598
1606
  "path": "ui/SignEditor.ts",
1599
- "content": "// Ввод текста таблички: ПКМ по табличке открывает строку (как консоль чата), Enter сохраняет,\n// Esc отменяет. Разбивку по строкам делает systems/SignText, показ в мире — gfx/SignText.\nimport { events } from \"../core/EventBus\";\nimport { wrapSignText } from \"../systems/SignText\";\nimport type { SignState } from \"../world/blockentities/sign\";\nimport type { Game } from \"../main\";\n\nexport class SignEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private at: { x: number; y: number; z: number } | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"signEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 96;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openSign\", (p: { x: number; y: number; z: number }) =>\n this.show(p),\n );\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(p: { x: number; y: number; z: number }): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.get(p.x, p.y, p.z);\n if (be?.type !== \"sign\") return;\n this.at = p;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = (be.state as SignState).lines.join(\" \");\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.at = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Сохранить набранное в табличку. */\n submit(): void {\n const at = this.at;\n const text = this.input.value;\n this.hide();\n if (!at) return;\n const be = this.game.blockEntities.get(at.x, at.y, at.z);\n if (be?.type !== \"sign\") return;\n (be.state as SignState).lines = wrapSignText(text);\n events.emit(\"signEdited\", { ...at, text });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
1607
+ "content": "// Ввод текста таблички: ПКМ по табличке открывает строку (как консоль чата), Enter сохраняет,\n// Esc отменяет. Разбивку по строкам делает systems/SignText, показ в мире — gfx/SignText.\nimport { events } from \"../core/EventBus\";\nimport { wrapSignText } from \"../systems/SignText\";\nimport type { SignState } from \"../world/blockentities/sign\";\nimport type { Game } from \"../main\";\n\nexport class SignEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private at: { x: number; y: number; z: number } | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"signEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 96;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openSign\", (p: { x: number; y: number; z: number }) =>\n this.show(p),\n );\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(p: { x: number; y: number; z: number }): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.get(p.x, p.y, p.z);\n if (be?.type !== \"sign\") return;\n this.at = p;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = (be.state as SignState).lines.join(\" \");\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.at = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Сохранить набранное в табличку. */\n submit(): void {\n const at = this.at;\n const text = this.input.value;\n this.hide();\n if (!at) return;\n const be = this.game.blockEntities.get(at.x, at.y, at.z);\n if (be?.type !== \"sign\") return;\n (be.state as SignState).lines = wrapSignText(text);\n events.emit(\"signEdited\", { ...at, text });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
1600
1608
  },
1601
1609
  {
1602
1610
  "path": "ui/slotView.ts",
@@ -1966,6 +1974,10 @@
1966
1974
  "path": "world/Redstone.ts",
1967
1975
  "content": "// Красный камень, как в Minecraft (упрощённо): источники — рычаг, кнопка, нажимная плита, красный\n// факел, блок красной пыли — питают соседей; провод несёт сигнал 15 и теряет 1 за блок; твёрдый\n// блок с включённым рычагом или кнопкой на нём (или с горящим факелом под ним) «сильно заряжен» и\n// питает своих соседей. Потребители — лампа, двери и люки, TNT; красный факел — инвертор.\n// Поведения блоков — world/behaviours/redstone.ts.\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { DIR6, FACING_DIRS } from \"../registry/meta\";\nimport { fillLevel } from \"./Containers\";\nimport type { ChunkManager } from \"./ChunkManager\";\n\n/** Бит «включено» у рычага и кнопки. */\nexport const ON = 0x8;\n\nconst N6 = [\n [1, 0, 0],\n [-1, 0, 0],\n [0, 1, 0],\n [0, -1, 0],\n [0, 0, 1],\n [0, 0, -1],\n] as const;\n\nlet readers: Set<number> | null = null;\n\n/**\n * Блок читает сигнал (провод, факел, лампа, TNT, двери и люки). Таких будят и через клетку от\n * изменения: рычаг на камне питает факел по ту сторону камня (systems/BlockUpdates).\n */\nexport function readsSignal(id: number): boolean {\n if (!readers) {\n readers = new Set<number>();\n for (const n of [\n \"redstone_wire\",\n \"redstone_torch\",\n \"redstone_torch_off\",\n \"redstone_lamp\",\n \"redstone_lamp_on\",\n \"repeater\",\n \"repeater_on\",\n \"piston\",\n \"sticky_piston\",\n \"powered_rail\",\n \"activator_rail\",\n \"tnt\",\n \"note_block\",\n ])\n if (B[n] !== undefined) readers.add(B[n]!);\n for (const d of BLOCKS)\n if (d.shape === \"door\" || d.shape === \"trapdoor\") readers.add(d.id);\n }\n return readers.has(id);\n}\n\n/** Клетка, к которой прикреплён рычаг/кнопка/факел (мета: 0 — пол, 1..4 — стена). */\nexport function attachedTo(\n x: number,\n y: number,\n z: number,\n meta: number,\n): [number, number, number] {\n const m = meta & 7;\n if (m === 0) return [x, y - 1, z];\n const [dx, dz] = FACING_DIRS[(m - 1) & 3]!;\n return [x + dx, y, z + dz];\n}\n\n/** Сигнал, который блок в (x,y,z) отдаёт соседу (tx,ty,tz) сам, без провода. */\nfunction directPower(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n tx: number,\n ty: number,\n tz: number,\n): number {\n const id = w.getBlock(x, y, z);\n if (id === 0) return 0;\n const meta = w.getMeta(x, y, z);\n if (id === B.redstone_block) return 15;\n if (id === B.sculk_sensor_on) return 15; // сенсор скалка, пока горит после вибрации\n if ((id === B.lever || id === B.stone_button) && meta & ON) return 15;\n if (id === B.stone_pressure_plate && meta > 0) return 15;\n if (id === B.target && meta > 0) return meta & 15; // мишень: сила по близости попадания к центру\n if (id === B.detector_rail && meta & 0x8) return 15; // на нажимных рельсах вагонетка\n if (id === B.repeater_on) {\n // повторитель питает только клетку перед собой\n const [dx, dz] = FACING_DIRS[meta & 3]!;\n return x + dx === tx && y === ty && z + dz === tz ? 15 : 0;\n }\n if (id === B.comparator) {\n // компаратор — своей силой, только вперёд\n const [dx, dz] = FACING_DIRS[meta & 3]!;\n return x + dx === tx && y === ty && z + dz === tz ? comparatorOut(meta) : 0;\n }\n if (id === B.observer && meta & OBSERVER_ON) {\n // наблюдатель — импульс с тыла (напротив лица)\n const [dx, dy, dz] = DIR6[meta & 7]!;\n return x - dx === tx && y - dy === ty && z - dz === tz ? 15 : 0;\n }\n if (id === B.redstone_torch) {\n const [ax, ay, az] = attachedTo(x, y, z, meta);\n return ax === tx && ay === ty && az === tz ? 0 : 15; // свой блок факел не питает\n }\n return 0;\n}\n\n/** Твёрдый блок сильно заряжен: на нём включённый рычаг/кнопка или под ним горит красный факел. */\nfunction strongly(w: ChunkManager, x: number, y: number, z: number): boolean {\n if (!BLOCKS[w.getBlock(x, y, z)]?.solid) return false;\n for (const [dx, dy, dz] of N6) {\n const nx = x + dx,\n ny = y + dy,\n nz = z + dz;\n const id = w.getBlock(nx, ny, nz);\n if (\n (id === B.lever || id === B.stone_button) &&\n w.getMeta(nx, ny, nz) & ON\n ) {\n const [ax, ay, az] = attachedTo(nx, ny, nz, w.getMeta(nx, ny, nz));\n if (ax === x && ay === y && az === z) return true;\n }\n if (id === B.redstone_torch && dy === -1) return true; // факел снизу\n if (id === B.repeater_on || id === B.comparator) {\n // включённый повторитель или компаратор, смотрящий в этот блок\n const m = w.getMeta(nx, ny, nz);\n const [fx, fz] = FACING_DIRS[m & 3]!;\n if (\n (id === B.repeater_on || comparatorOut(m) > 0) &&\n nx + fx === x &&\n ny === y &&\n nz + fz === z\n )\n return true;\n }\n if (id === B.observer) {\n const m = w.getMeta(nx, ny, nz);\n const [fx, fy, fz] = DIR6[m & 7]!;\n if (m & OBSERVER_ON && nx - fx === x && ny - fy === y && nz - fz === z)\n return true;\n }\n }\n return false;\n}\n\n/** Компаратор: режим «вычитание» (иначе сравнение) и сила выхода в битах 3..6 меты. */\nexport const COMPARATOR_SUB = 0x4;\nexport const comparatorOut = (meta: number): number => (meta >> 3) & 15;\n/** Наблюдатель: бит импульса (мета — направление лица DIR6). */\nexport const OBSERVER_ON = 0x8;\n\n/** Сила сигнала 0..15, которую клетка s отдаёт в t: источник, провод, заряженный блок. */\nfunction signalInto(\n w: ChunkManager,\n sx: number,\n sy: number,\n sz: number,\n tx: number,\n ty: number,\n tz: number,\n): number {\n const d = directPower(w, sx, sy, sz, tx, ty, tz);\n if (d > 0) return d;\n const id = w.getBlock(sx, sy, sz);\n if (id === B.redstone_wire) return w.getMeta(sx, sy, sz);\n return BLOCKS[id]?.solid && isPowered(w, sx, sy, sz) ? 15 : 0;\n}\n\n/**\n * Тик компаратора (onTick — каждый симтик: заполненность сундука не будит соседей). Вход сзади —\n * сигнал или заполненность контейнера, сбоку — боковой сигнал. Сравнение: вход, если он не меньше\n * бокового, иначе 0; вычитание: вход − боковой.\n */\nexport function comparatorTick(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n meta: number,\n): void {\n const [dx, dz] = FACING_DIRS[meta & 3]!;\n const bx = x - dx,\n bz = z - dz;\n const box = w.containerAt?.(bx, y, bz);\n const input = box ? fillLevel(box) : signalInto(w, bx, y, bz, x, y, z);\n const side = Math.max(\n signalInto(w, x - dz, y, z + dx, x, y, z),\n signalInto(w, x + dz, y, z - dx, x, y, z),\n );\n const out =\n meta & COMPARATOR_SUB\n ? Math.max(0, input - side)\n : input >= side\n ? input\n : 0;\n if (out !== comparatorOut(meta))\n w.setBlock(x, y, z, B.comparator!, (meta & 7) | (out << 3));\n}\n\n/** Вход повторителя — только клетка позади него: источник, заряженный провод, заряженный блок. */\nexport function repeaterInput(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n meta: number,\n): boolean {\n const [dx, dz] = FACING_DIRS[meta & 3]!;\n const bx = x - dx,\n bz = z - dz;\n if (directPower(w, bx, y, bz, x, y, z) > 0) return true;\n const back = w.getBlock(bx, y, bz);\n if (back === B.redstone_wire) return w.getMeta(bx, y, bz) > 0;\n return !!BLOCKS[back]?.solid && isPowered(w, bx, y, bz);\n}\n\n/** Получает ли клетка сигнал: источник рядом, заряженный провод сбоку/сверху, сильно заряженный блок. */\nexport function isPowered(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n except?: readonly [number, number, number],\n): boolean {\n for (const [dx, dy, dz] of N6) {\n if (except && dx === except[0] && dy === except[1] && dz === except[2])\n continue; // лицо поршня\n const nx = x + dx,\n ny = y + dy,\n nz = z + dz;\n if (directPower(w, nx, ny, nz, x, y, z) > 0) return true;\n // провод питает блок, на котором лежит, и соседей сбоку — но не блок над собой\n if (\n w.getBlock(nx, ny, nz) === B.redstone_wire &&\n w.getMeta(nx, ny, nz) > 0 &&\n dy !== -1\n )\n return true;\n if (strongly(w, nx, ny, nz)) return true;\n }\n return false;\n}\n\n/** Соседи провода по сети: четыре стороны на своём уровне и ступеньки на блок выше и ниже. */\nfunction wireLinks(\n x: number,\n y: number,\n z: number,\n): [number, number, number][] {\n const out: [number, number, number][] = [];\n for (const [dx, dz] of FACING_DIRS)\n for (const dy of [0, 1, -1]) out.push([x + dx, y + dy, z + dz]);\n return out;\n}\n\n/**\n * Пересчитать сеть проводов, в которую входит (x,y,z) или её соседи: 15 у провода рядом с источником\n * (или сильно заряженным блоком), дальше −1 за блок. Мета провода — его сила.\n */\nexport function updateWires(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n): void {\n const key = (ax: number, ay: number, az: number) => ax + \",\" + ay + \",\" + az;\n const net = new Map<string, [number, number, number]>();\n const stack: [number, number, number][] = [];\n const add = (ax: number, ay: number, az: number) => {\n if (net.size > 2048 || w.getBlock(ax, ay, az) !== B.redstone_wire) return;\n const k = key(ax, ay, az);\n if (net.has(k)) return;\n net.set(k, [ax, ay, az]);\n stack.push([ax, ay, az]);\n };\n add(x, y, z);\n for (const [dx, dy, dz] of N6) add(x + dx, y + dy, z + dz);\n while (stack.length) {\n const [cx, cy, cz] = stack.pop()!;\n for (const [nx, ny, nz] of wireLinks(cx, cy, cz)) add(nx, ny, nz);\n }\n\n const power = new Map<string, number>();\n const queue: [number, number, number, number][] = [];\n // затравка: сила самого сильного источника рядом (компаратор даёт свою, остальные — 15)\n for (const [k, [cx, cy, cz]] of net) {\n let p = 0;\n for (const [dx, dy, dz] of N6) {\n const nx = cx + dx,\n ny = cy + dy,\n nz = cz + dz;\n p = Math.max(\n p,\n directPower(w, nx, ny, nz, cx, cy, cz),\n strongly(w, nx, ny, nz) ? 15 : 0,\n );\n if (p === 15) break;\n }\n if (p > 0) {\n power.set(k, p);\n queue.push([cx, cy, cz, p]);\n }\n }\n for (let i = 0; i < queue.length; i++) {\n const [cx, cy, cz, p] = queue[i]!;\n if (p <= 1) continue;\n for (const [nx, ny, nz] of wireLinks(cx, cy, cz)) {\n const k = key(nx, ny, nz);\n if (!net.has(k) || (power.get(k) ?? 0) >= p - 1) continue;\n power.set(k, p - 1);\n queue.push([nx, ny, nz, p - 1]);\n }\n }\n for (const [k, [cx, cy, cz]] of net) {\n const p = power.get(k) ?? 0;\n if (w.getMeta(cx, cy, cz) !== p)\n w.setBlock(cx, cy, cz, B.redstone_wire!, p);\n }\n}\n"
1968
1976
  },
1977
+ {
1978
+ "path": "world/safeSpot.ts",
1979
+ "content": "// Безопасная точка для игрока: спавн, возрождение, якорь. Верх колонки по `surfaceY` — это верхний\n// ТВЁРДЫЙ блок, а снег-порох, вода и лава не твёрдые: без этой проверки игрок возрождался на дне\n// ямы снега-пороха, замерзал и возрождался там же снова.\nimport { BLOCKS, B } from \"../registry/Blocks\";\n\nexport interface SpotWorld {\n getBlock(x: number, y: number, z: number): number;\n surfaceY(x: number, z: number): number;\n}\n\n/** Клетка, где может быть тело: не твёрдая, не жидкость, не снег-порох, не огонь. */\nfunction roomy(id: number): boolean {\n const def = BLOCKS[id];\n if (!def) return id === 0;\n return (\n !def.solid &&\n !def.liquid &&\n !def.waterlogged &&\n id !== B.powder_snow &&\n id !== B.fire &&\n id !== B.soul_fire\n );\n}\n\n/** Можно ли встать ногами в клетку (x, y, z): твёрдый пол под ней и две клетки на рост. */\nexport function isStandable(\n w: SpotWorld,\n x: number,\n y: number,\n z: number,\n): boolean {\n const floor = BLOCKS[w.getBlock(x, y - 1, z)];\n return (\n !!floor?.solid &&\n floor.id !== B.magma_block &&\n roomy(w.getBlock(x, y, z)) &&\n roomy(w.getBlock(x, y + 1, z))\n );\n}\n\n/**\n * Ближайшая колонка (по кольцам вокруг (x, z), до `radius`), где на верхнем твёрдом блоке можно\n * стоять. Возвращает клетку ног или null — тогда вызывающий оставляет свою точку.\n */\nexport function findSafeSpot(\n w: SpotWorld,\n x: number,\n z: number,\n radius = 8,\n above?: number,\n): { x: number; y: number; z: number } | null {\n for (let r = 0; r <= radius; r++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; // только кольцо\n const cx = x + dx,\n cz = z + dz;\n const y = w.surfaceY(cx, cz) + 1;\n if (above !== undefined && y <= above) continue;\n if (isStandable(w, cx, y, cz)) return { x: cx, y, z: cz };\n }\n return null;\n}\n"
1980
+ },
1969
1981
  {
1970
1982
  "path": "world/Scaffolding.ts",
1971
1983
  "content": "// Расстояние до опоры для клетки строительных лесов, прочитанное из мира: соседей берём из блоков,\n// саму арифметику — из systems/Scaffold (она чистая и покрыта тестами).\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { FACING_DIRS } from \"../registry/meta\";\nimport { NO_SUPPORT, scaffoldDistance } from \"../systems/Scaffold\";\nimport type { ChunkManager } from \"./ChunkManager\";\n\n/** Расстояние до опоры у клетки (сам блок в ней может ещё не стоять — так считает постановка). */\nexport function scaffoldDistanceAt(\n world: ChunkManager,\n x: number,\n y: number,\n z: number,\n): number {\n const scaffold = B.scaffolding;\n const below = world.getBlock(x, y - 1, z);\n const belowSolid = below !== scaffold && (BLOCKS[below]?.solid ?? false);\n const belowScaffold = below === scaffold ? world.getMeta(x, y - 1, z) : null;\n const sides: number[] = [];\n for (const [dx, dz] of FACING_DIRS)\n if (world.getBlock(x + dx, y, z + dz) === scaffold) {\n const d = world.getMeta(x + dx, y, z + dz);\n if (d < NO_SUPPORT) sides.push(d);\n }\n return scaffoldDistance(belowSolid, belowScaffold, sides);\n}\n"