@idosgames/mcp 0.1.28 → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +11 -71
- package/registry/index.json +140 -140
- package/registry/modules/ai.json +5 -5
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/boosts.json +7 -7
- package/registry/modules/character.json +34 -6
- package/registry/modules/chat.json +26 -6
- package/registry/modules/collections.json +23 -7
- package/registry/modules/coop-events.json +7 -7
- package/registry/modules/craft.json +7 -7
- package/registry/modules/deals.json +3 -3
- package/registry/modules/events.json +6 -6
- package/registry/modules/friends.json +26 -6
- package/registry/modules/idle-rpg.json +3 -3
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +6 -6
- package/registry/modules/leaderboard.json +22 -6
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +34 -6
- package/registry/modules/marketplace.json +35 -7
- package/registry/modules/mini-game.json +3 -3
- package/registry/modules/multiplayer.json +35 -11
- package/registry/modules/premium.json +7 -7
- package/registry/modules/profile.json +5 -5
- package/registry/modules/pvp.json +34 -6
- package/registry/modules/quests.json +6 -6
- package/registry/modules/rewards.json +7 -7
- package/registry/modules/season.json +6 -6
- package/registry/modules/store.json +5 -5
- package/registry/modules/tutorials.json +3 -3
- package/registry/modules/voxelcraft.json +265 -13
- package/registry/modules/workshop.json +50 -6
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/checkout-system.json +1 -1
- package/registry/skills/cloud-code.json +2 -2
- package/registry/skills/currency-system.json +1 -1
- package/registry/skills/idosgames-compose-modules.json +1 -1
- package/registry/skills/idosgames-getting-started.json +1 -1
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +2 -2
- package/registry/skills/marketplace-system.json +1 -1
- package/registry/skills/match-system.json +1 -1
- package/registry/skills/multiplayer-realtime.json +2 -2
- package/registry/skills/voxelcraft-worlds.json +2 -2
- package/registry/skills/workshop-system.json +2 -2
|
@@ -66,14 +66,14 @@
|
|
|
66
66
|
"name": "iDos Games",
|
|
67
67
|
"url": "https://idosgames.com"
|
|
68
68
|
},
|
|
69
|
-
"version": "0.4.
|
|
69
|
+
"version": "0.4.2"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
-
"@idosgames/core": "0.21.
|
|
73
|
-
"@idosgames/module-sdk": "0.10.
|
|
72
|
+
"@idosgames/core": "0.21.3",
|
|
73
|
+
"@idosgames/module-sdk": "0.10.1",
|
|
74
74
|
"three": "0.185.1"
|
|
75
75
|
},
|
|
76
|
-
"contentHash": "
|
|
76
|
+
"contentHash": "19454062d0c4f579ab7f7ce3bd728c79cf34f1f8f68ff8d1cacfb952e450dcc2",
|
|
77
77
|
"files": [
|
|
78
78
|
{
|
|
79
79
|
"path": "agent.ts",
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
},
|
|
142
142
|
{
|
|
143
143
|
"path": "entities/EntityManager.ts",
|
|
144
|
-
"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 { CURE_SECONDS, 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 leashSlot?: 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\n/**\n * Игрок в этом измерении, с которым работают сущности: мобы целятся, предметы подбираются, взрыв\n * бьёт. Локальный — `player`/`inventory` менеджера; удалённые (сетевая игра) — `others()`.\n */\nexport interface Actor {\n player: Player;\n inventory: Inventory;\n}\n\nexport type { Entity };\n\n/** Сущность в сети: вид + всё, что нужно, чтобы построить её у другого игрока. */\nexport type NetEntitySnap =\n | EntitySave\n | {\n kind: \"arrow\";\n pos: [number, number, number];\n vel: [number, number, number];\n damage: number;\n fromPlayer: boolean;\n stuck?: boolean;\n effect?: { id: string; seconds: number } | null;\n pick?: ItemStack;\n }\n | {\n kind: \"fireball\";\n ballKind: FireballKind;\n pos: [number, number, number];\n vel: [number, number, number];\n damage: number;\n shooterId: number;\n }\n | { kind: \"tnt\"; pos: [number, number, number]; fuse: number }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n dmg?: number;\n tag?: Record<string, unknown>;\n vel?: [number, number, number];\n pickupDelay?: number;\n };\n\n/** Действие игрока-реплики над сущностями — исполняет владелец измерения (net/sim). */\nexport type NetEntityAction =\n | { a: \"spawn\"; snap: NetEntitySnap }\n | { a: \"hurt\"; id: number; damage: number; from: [number, number, number] }\n | { a: \"explode\"; x: number; y: number; z: number; power: number }\n /** Игрок-реплика поменял сущность действием (остриг, оседлал, назвал, поторговал): только поля, что изменились. */\n | { a: \"patch\"; id: number; fields: Record<string, unknown> }\n /** Образ погиб у реплики (/kill, рыба в ведро): drops — смерть с дропом, иначе просто исчезла. */\n | { a: \"kill\"; id: number; drops: boolean }\n /** Сел (on) или слез: пока сидит, сущность ведёт он сам. */\n | { a: \"ride\"; id: number; on: boolean }\n /** Поза сущности, которую ведёт реплика. */\n | {\n a: \"drive\";\n id: number;\n pos: [number, number, number];\n vel: [number, number, number];\n yaw: number;\n };\n\n/** Бит «на ней кто-то сидит» во флагах позы сущности (net/protocol ENT_POSE). */\nconst POSE_RIDDEN = 128;\n\n/**\n * Поля сущности, которые меняет ДЕЙСТВИЕ игрока, а не мозг: их реплика отдаёт владельцу измерения.\n * Любовь и лечение — флагами: сами таймеры тикают у владельца, реплика лишь сообщает «накормил».\n */\nfunction interactState(e: Entity): Record<string, unknown> | null {\n if (e.kind === \"minecart\") return e.items.length ? { items: e.items } : null;\n if (e.kind !== \"mob\") return null;\n return {\n sheared: e.sheared,\n tamed: e.tamed,\n sitting: e.sitting,\n saddled: e.saddled,\n woolColor: e.woolColor,\n wolfArmor: e.wolfArmor,\n carry: e.carry ? e.carry.item : null,\n bag: e.bag,\n items: e.items,\n leashed: e.leashed,\n leashPost: e.leashPost,\n leashMob: e.leashMobId !== null,\n love: e.loveT > 0,\n cure: e.curing > 0,\n name: e.customName,\n uses: e.uses,\n traded: e.traded,\n villagerXp: e.villagerXp,\n };\n}\n\nexport class EntityManager {\n /**\n * Сетевая игра: другие игроки в этом измерении (у владельца измерения). Мобы выбирают ближайшего,\n * предмет подбирает ближайший, взрыв бьёт всех — у удалённого игрока урон уходит ему по сети.\n */\n others: () => Actor[] = () => [];\n /**\n * Реплика (net/sim): сущности живут у владельца измерения, здесь только их образ — без тика,\n * без спавна по событиям. То, что создаёт ДЕЙСТВИЕ этого игрока (бросил предмет, выстрелил),\n * уходит владельцу через `onLocalSpawn`, а удар и взрыв — через `onRemoteAction`.\n */\n replica = false;\n onLocalSpawn: ((snap: NetEntitySnap) => void) | null = null;\n onRemoteAction: ((a: NetEntityAction) => void) | null = null;\n /** Реплика: куда сущность должна прийти по последнему снимку владельца (сглаживание). */\n private readonly _netTargets = new Map<\n number,\n { x: number; y: number; z: number; yaw: number }\n >();\n /** Идёт применение сетевого снимка: спавн регистрируется здесь, а не уходит владельцу. */\n private _netApplying = false;\n /** Сетевая игра: слот своего игрока (поводок держит конкретный игрок); null — одиночная игра. */\n localSlot: number | null = null;\n /** Реплика: состояние взаимодействия образов по последнему слову владельца (см. interactState). */\n private readonly _netBase = new Map<number, string>();\n /** Реплика: сущность, в которой сидит свой игрок, — её ведёт он сам, позы владельца не слушает. */\n drivenId: number | null = null;\n /** Владелец: сущности, которые ведут игроки-реплики (не тикают здесь): id → слот и время позы. */\n private readonly _netDriven = new Map<number, { slot: number; t: number }>();\n\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 /**\n * Единственная точка регистрации новой сущности. У реплики (net/sim) сущность, созданная\n * действием игрока, здесь НЕ живёт: после того как вызвавший её донастроит (скорость брошенного\n * предмета, эффект стрелы — это делается сразу после spawn*), её снимок уходит владельцу\n * измерения, а тот разошлёт настоящую. Исключения живут у реплики сами: поплавок (рыбалка —\n * своя) и жемчуг Предела (переносит бросившего, а не владельца измерения).\n */\n private _register<T extends Entity>(e: T, ...objects: THREE.Object3D[]): T {\n const ownLocal =\n e.kind === \"hook\" || (e.kind === \"fireball\" && e.ballKind === \"pearl\");\n if (this.replica && !this._netApplying && !ownLocal) {\n queueMicrotask(() => {\n const snap = this.netSnapshot(e);\n if (snap) this.onLocalSpawn?.(snap);\n });\n return e;\n }\n this.entities.set(e.id, e);\n if (objects.length) this.scene.add(...objects);\n return e;\n }\n\n /** Убрать всех враждебных мобов (переход на мирную сложность — systems/Difficulty). */\n clearHostiles(): void {\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.category === \"hostile\" && !e.def.boss)\n e.dead = true; // hp > 0 — исчезают без дропа, как при удалении игрока\n }\n\n /** Сдвинуть репутацию игрока в деревне и сказать об этом HUD. */\n 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._register(f, 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 return this._register(c, c.mesh);\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 return this._register(b, b.mesh);\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 return this._register(h, h.mesh, h.line);\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 // (net/sim) не сливаем и не вытесняем: её дропы — образы чужих, решает владелец измерения.\n const mergeable =\n !this.replica && !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.replica\n ? []\n : [...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 return this._register(drop, drop.mesh);\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 return this._register(mob, mob.mesh);\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._register(orb, 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 return this._register(a, a.mesh);\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 return this._register(f, f.mesh);\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._register(e, 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._register(t, t.mesh);\n events.emit(\"tntFuse\", { x, y, z });\n return t;\n }\n\n /**\n * Удар игрока по мобу. `attacker` — кто бил (сетевая игра: удалённый игрок; по умолчанию свой).\n * У реплики (net/sim) удар уходит владельцу измерения — моб живёт там.\n */\n hurt(\n entityId: number,\n damage: number,\n fromPos: THREE.Vector3,\n attacker: Player = this.player,\n ): void {\n if (this.replica && !this._netApplying) {\n this.onRemoteAction?.({\n a: \"hurt\",\n id: entityId,\n damage,\n from: [fromPos.x, fromPos.y, fromPos.z],\n });\n return;\n }\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, attacker))\n damage = Math.max(1, damage / 4);\n e.hurt(damage, fromPos, true);\n // шипы глубинника: бьёшь, пока он не заряжает луч, — укол в ответ\n if (e.def.thorns && e.beamT <= 0) attacker.damage(e.def.thorns, \"thorns\");\n }\n\n /** Луч взгляда игрока проходит у головы дракона (перед центром, на половине ширины). */\n private _headHit(m: Mob, who: Player = this.player): 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 = who.eyePos,\n dir = who.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 // реплика (net/sim): взрыв от действия игрока (кровать в Пекле) считает владелец измерения\n if (this.replica && !this._netApplying) {\n this.onRemoteAction?.({ a: \"explode\", x, y, z, power });\n return;\n }\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 // все игроки измерения: у удалённого урон и отброс уйдут ему по сети (net/RemotePlayer)\n for (const { player } of this.actors())\n blast(player.pos, 1.8, (dmg, dir, impact) => {\n player.damage(dmg, \"explosion\");\n 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 /** Все игроки этого измерения: свой и удалённые (сетевая игра). */\n actors(): Actor[] {\n const own: Actor = { player: this.player, inventory: this.inventory };\n const rest = this.others();\n return rest.length ? [own, ...rest] : [own];\n }\n\n /**\n * Ближайший к точке игрок: мобы целятся в него, предмет летит к нему. Живые впереди мёртвых —\n * иначе моб «застревал» бы на трупе, пока рядом бегает живой.\n */\n nearestActor(pos: { x: number; y: number; z: number }): Actor {\n const list = this.actors();\n if (list.length === 1) return list[0]!;\n let best = list[0]!,\n bd = Infinity;\n for (const a of list) {\n const p = a.player.pos;\n const d =\n (p.x - pos.x) ** 2 +\n (p.y - pos.y) ** 2 +\n (p.z - pos.z) ** 2 +\n (a.player.dead ? 1e12 : 0);\n if (d < bd) {\n bd = d;\n best = a;\n }\n }\n return best;\n }\n\n /**\n * Игрок для сущности: моба на поводке ведёт ТОТ, кто держит поводок (у двух игроков рядом моб\n * иначе шёл бы к ближайшему); остальным — ближайший.\n */\n private _actorFor(e: Entity): Actor {\n if (\n e.kind === \"mob\" &&\n e.leashed &&\n !e.leashPost &&\n e.leashMobId === null &&\n e.leashSlot !== null\n ) {\n if (e.leashSlot === this.localSlot)\n return { player: this.player, inventory: this.inventory };\n const held = this.others().find(\n (o) => (o.player as Player & { slot?: number }).slot === e.leashSlot,\n );\n if (held) return held;\n }\n return this.nearestActor(e.pos);\n }\n\n tick(dt: number): void {\n if (this.replica) {\n this._replicaTick(dt);\n return;\n }\n const ctx = this._ctx();\n const ownHeld = ctx.heldItem;\n const multi = this.others().length > 0;\n const mobs = [...this.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\",\n );\n const now = performance.now();\n for (const e of this.entities.values()) {\n // её ведёт игрок-реплика: позу шлёт он (netDrive); замолчал 3 с — снова наша\n const drv = this._netDriven.get(e.id);\n if (drv && !e.dead) {\n if (now - drv.t < 3000) continue;\n this._release(e.id);\n }\n const a = multi\n ? this._actorFor(e)\n : { player: this.player, inventory: this.inventory };\n if (e.kind === \"drop\") e.tick(dt, this.world, a.player, a.inventory);\n else if (e.kind === \"mob\") {\n if (multi) {\n ctx.player = a.player;\n ctx.heldItem =\n a.player === this.player\n ? ownHeld\n : (a.inventory.heldItem()?.item ?? null);\n }\n e.tick(dt, ctx);\n if (e.angerCall) this._angerGroup(e);\n } else if (e.kind === \"xp\") e.tick(dt, this.world, a.player);\n else if (e.kind === \"tnt\")\n e.tick(dt, this.world, (x, y, z, p) => this.explode(x, y, z, p));\n else if (e.kind === \"fireball\")\n e.tick(\n dt,\n this.world,\n a.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n mobs,\n );\n else if (e.kind === \"minecart\" || e.kind === \"boat\") {\n e.tick(dt, this.world);\n if (e.kind === \"minecart\") this._cartWork(e, dt);\n } else if (e.kind === \"hook\")\n e.tick(\n dt,\n this.world,\n this.player.pos,\n this.raining,\n this.inventory.heldItem()?.item === \"fishing_rod\",\n );\n else if (e.kind === \"rimeye\") {\n // долетело: в 80% падает предметом, иначе разбивается\n if (e.tick(dt)) {\n if (Math.random() < 0.8)\n this.spawnDrop(e.pos.x, e.pos.y, e.pos.z, \"rim_eye\", 1);\n else\n events.emit(\"eyeShatter\", { x: e.pos.x, y: e.pos.y, z: e.pos.z });\n }\n } else e.tick(dt, this.world, a.player, mobs, a.inventory);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0) this._mobDied(e);\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Смерть моба: дроп по таблице (у детёнышей нет) и опыт, если бил игрок. */\n 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 // сетевая игра: «рядом с игроком» — рядом с БЛИЖАЙШИМ игроком измерения, и спавн — вокруг\n // случайного из них (как в Minecraft на сервере: население держится вокруг каждого игрока)\n const actors = this.actors();\n const center =\n actors[Math.floor(Math.random() * actors.length)]!.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 let d = Infinity;\n for (const a of actors) d = Math.min(d, e.pos.distanceTo(a.player.pos));\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\", center);\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(\n light: \"bright\" | \"dark\",\n p: THREE.Vector3 = this.player.pos,\n ): number {\n const def = this._pickType(light);\n if (!def) return 0;\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\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 this._netBase.clear();\n this._netDriven.clear();\n this.drivenId = null;\n // игрок сидел в вагонетке/лодке этого мира — ссаживаем без телепорта: иначе Player.update\n // каждый кадр ставил бы его к выброшенной сущности (после портала — в координаты прошлого\n // измерения), а «присесть» высаживало бы туда же\n const ride = this.player?.riding;\n if (ride) {\n ride.rider = false;\n this.player.riding = null;\n }\n // выброшенные сущности — мёртвые для всех, кто держит на них ссылку (экран торговли с жителем,\n // груз вагонетки, поводок): иначе после портала экран жил бы с призраком из прошлого измерения\n for (const e of this.entities.values()) {\n e.dead = true;\n e.dispose(this.scene);\n }\n this.entities.clear();\n if (this.beamLine) this.beamLine.visible = false;\n }\n\n serialize() {\n 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 : []) this._loadOne(d);\n }\n\n /** Координаты записи годны: чужой файл (Мастерская, другая версия, сеть) мог прислать что угодно. */\n private static _validPos(d: unknown): boolean {\n const pos: unknown = (d as { pos?: unknown } | null)?.pos;\n return (\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 }\n\n /** Одна сохранённая сущность → живая (сейв, Мастерская, сетевой снимок). null — запись негодна. */\n private _loadOne(d: EntitySave): Entity | null {\n // запись из чужого файла (Мастерская, другая версия): null или без координат ронял загрузку\n // посреди замены мира — мир уже новый, а сущности и блоки ещё старые\n if (!EntityManager._validPos(d)) return null;\n {\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.leashSlot === \"number\") m.leashSlot = d.leashSlot;\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 return m;\n } else if (d.kind === \"drop\") {\n // предмет, которого нет в игре (чужая версия сейва), рисовать нечем — ItemMesh упал бы\n if (!ITEMS[d.item] || !(d.count > 0)) return null;\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 return drop;\n } else if (d.kind === \"xp\") {\n this.spawnXp(d.pos[0], d.pos[1], d.pos[2], d.value);\n return null;\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 return c;\n } else if (d.kind === \"boat\")\n return this.spawnBoat(d.pos[0], d.pos[1], d.pos[2], d.wood ?? \"oak\");\n }\n return null;\n }\n\n // ---- сеть (net/NetGame) ----------------------------------------------------------------------\n\n /**\n * Сущность → снимок для другого игрока. null — вид в сети не живёт (поплавок и клыки — локальные\n * и короткие, Око Предела — чистая анимация у бросившего).\n */\n netSnapshot(e: Entity): NetEntitySnap | null {\n const v3 = (v: THREE.Vector3): [number, number, number] => [v.x, v.y, v.z];\n switch (e.kind) {\n case \"mob\":\n case \"minecart\":\n case \"boat\":\n return e.serialize() as NetEntitySnap;\n case \"drop\":\n return {\n kind: \"drop\",\n pos: v3(e.pos),\n item: e.item,\n count: e.count,\n ...(e.dmg ? { dmg: e.dmg } : {}),\n ...(e.tag ? { tag: e.tag } : {}),\n vel: v3(e.vel),\n pickupDelay: Math.max(0, e.pickupDelay - e.age),\n };\n case \"xp\":\n return { kind: \"xp\", pos: v3(e.pos), value: e.value };\n case \"arrow\":\n return {\n kind: \"arrow\",\n pos: v3(e.pos),\n vel: v3(e.vel),\n damage: e.damage,\n fromPlayer: e.fromPlayer,\n stuck: e.stuck,\n effect: e.effect,\n pick: e.pick,\n };\n case \"fireball\":\n return {\n kind: \"fireball\",\n ballKind: e.ballKind,\n pos: v3(e.pos),\n vel: v3(e.vel),\n damage: e.damage,\n shooterId: e.shooterId,\n };\n case \"tnt\":\n return { kind: \"tnt\", pos: v3(e.pos), fuse: e.fuse };\n default:\n return null;\n }\n }\n\n /** Снимок → живая сущность этого менеджера (id — свой). null — снимок негоден. */\n private _createFromSnap(s: NetEntitySnap): Entity | null {\n if (!EntityManager._validPos(s)) return null;\n const at = new THREE.Vector3(s.pos[0], s.pos[1], s.pos[2]);\n const vel = (v: unknown) =>\n Array.isArray(v) && v.length === 3 && v.every(Number.isFinite)\n ? new THREE.Vector3(v[0], v[1], v[2])\n : new THREE.Vector3();\n switch (s.kind) {\n case \"drop\": {\n if (!ITEMS[s.item] || !(s.count > 0)) return null;\n const d = this.spawnDrop(\n at.x,\n at.y,\n at.z,\n s.item,\n s.count,\n s.dmg,\n s.tag,\n );\n d.vel.copy(vel((s as { vel?: unknown }).vel));\n const delay = (s as { pickupDelay?: unknown }).pickupDelay;\n if (typeof delay === \"number\" && Number.isFinite(delay))\n d.pickupDelay = delay;\n return d;\n }\n case \"xp\": {\n const orb = new XpOrb(\n this._nextId++,\n at.x,\n at.y,\n at.z,\n Math.max(1, Math.round(s.value) || 1),\n );\n return this._register(orb, orb.mesh);\n }\n case \"arrow\": {\n const a = this.spawnArrow(\n at,\n vel(s.vel),\n Number(s.damage) || 0,\n !!s.fromPlayer,\n -1,\n );\n if (s.stuck) a.stuck = true;\n if (s.effect) a.effect = s.effect as Arrow[\"effect\"];\n if (s.pick && ITEMS[s.pick.item])\n a.pick = sanitizeStack(s.pick) ?? a.pick;\n return a;\n }\n case \"fireball\":\n return this.spawnFireball(\n s.ballKind,\n at,\n vel(s.vel),\n Number(s.damage) || 0,\n s.shooterId,\n );\n case \"tnt\":\n return this.spawnTnt(\n at.x,\n at.y,\n at.z,\n Math.max(0, Number(s.fuse) || 0),\n );\n default:\n return this._loadOne(s);\n }\n }\n\n /**\n * Владелец измерения: реплика попросила создать сущность (бросила предмет, выстрелила). Создаётся\n * по обычным правилам — с id этого менеджера, и разойдётся всем обычным снимком.\n */\n netSpawnFromRemote(s: NetEntitySnap): Entity | null {\n return this._createFromSnap(s);\n }\n\n /** Реплика: сущность владельца с ЕГО id (повтор того же id — замена: сменился вид). */\n netApplySpawn(id: number, s: NetEntitySnap): void {\n // в ней сидит свой игрок: объект не подменяем (Player.riding держит ссылку), ведём сами\n if (id === this.drivenId && this.entities.has(id)) return;\n this.netRemove(id);\n this._netApplying = true;\n let e: Entity | null;\n try {\n e = this._createFromSnap(s);\n } finally {\n this._netApplying = false;\n }\n if (!e) return;\n this.entities.delete(e.id);\n e.id = id;\n this.entities.set(id, e);\n this._netTargets.set(id, {\n x: e.pos.x,\n y: e.pos.y,\n z: e.pos.z,\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n this._rebase(e);\n }\n\n /** Реплика: запомнить состояние образа как «слово владельца» (изменения от него — не наши). */\n private _rebase(e: Entity): void {\n const st = interactState(e);\n if (st) this._netBase.set(e.id, JSON.stringify(st));\n else this._netBase.delete(e.id);\n }\n\n /**\n * Реплика: поза по снимку владельца. Сущность идёт к ней сглаженно (_replicaTick), а флаги вида\n * применяются сразу. Незнакомый id — пропуск: снимок появления придёт надёжным каналом.\n */\n netApplyPose(\n id: number,\n x: number,\n y: number,\n z: number,\n yaw: number,\n flags: number,\n extra: number,\n ): void {\n const e = this.entities.get(id);\n if (!e || id === this.drivenId) return;\n if (e.kind === \"minecart\" || e.kind === \"boat\")\n e.rider = (flags & POSE_RIDDEN) !== 0;\n const t = this._netTargets.get(id);\n if (t) {\n t.x = x;\n t.y = y;\n t.z = z;\n t.yaw = yaw;\n } else this._netTargets.set(id, { x, y, z, yaw });\n if (e.kind === \"mob\") {\n e.onGround = (flags & 1) !== 0;\n if (flags & 2 && e.swing <= 0) e.swing = 0.3;\n e.sheared = (flags & 4) !== 0;\n e.sitting = (flags & 8) !== 0;\n e.tamed = (flags & 16) !== 0;\n e.saddled = (flags & 32) !== 0;\n e.invisible = (flags & 64) !== 0;\n e.rider = (flags & POSE_RIDDEN) !== 0;\n if (e.def.explode) e.fuse = (extra / 255) * e.def.explode.fuse;\n else e.peek = extra / 255;\n // поза несёт только эти поля — и только их она делает «словом владельца»: правка из экрана\n // (торговля, груз, имя), сделанная между тиками, иначе приняла бы вид чужой и не ушла бы\n const base = this._netBase.get(id);\n if (base) {\n const b = JSON.parse(base) as Record<string, unknown>;\n b.sheared = e.sheared;\n b.sitting = e.sitting;\n b.tamed = e.tamed;\n b.saddled = e.saddled;\n this._netBase.set(id, JSON.stringify(b));\n }\n }\n }\n\n /** Поза сущности для снимка: флаги вида (см. netApplyPose) и «лишний» байт (фитиль/раскрытие). */\n netPose(e: Entity): { yaw: number; flags: number; extra: number } {\n if (e.kind === \"minecart\" || e.kind === \"boat\")\n return { yaw: 0, flags: e.rider ? POSE_RIDDEN : 0, extra: 0 };\n if (e.kind !== \"mob\") return { yaw: 0, flags: 0, extra: 0 };\n const flags =\n (e.onGround ? 1 : 0) |\n (e.swing > 0 ? 2 : 0) |\n (e.sheared ? 4 : 0) |\n (e.sitting ? 8 : 0) |\n (e.tamed ? 16 : 0) |\n (e.saddled ? 32 : 0) |\n (e.invisible ? 64 : 0) |\n (e.rider ? POSE_RIDDEN : 0);\n const extra = e.def.explode\n ? Math.round(Math.min(1, Math.max(0, e.fuse / e.def.explode.fuse)) * 255)\n : Math.round(Math.min(1, Math.max(0, e.peek)) * 255);\n return { yaw: e.yaw, flags, extra };\n }\n\n /** Реплика: сущность ушла у владельца (умерла, подобрана, вышла из зоны видимости). */\n netRemove(id: number): void {\n const e = this.entities.get(id);\n this._netTargets.delete(id);\n this._netBase.delete(id);\n if (!e) return;\n e.dead = true;\n e.dispose(this.scene);\n this.entities.delete(id);\n }\n\n /**\n * Тик реплики: никакой симуляции — только ход к позе из последнего снимка (снимки приходят 10 раз\n * в секунду, тик — 20: без сглаживания мобы двигались бы рывками). Свои локальные сущности\n * (поплавок, жемчуг) тикают по-настоящему.\n */\n private _replicaTick(dt: number): void {\n for (const e of this.entities.values()) {\n if (e.id === this.drivenId && !e.dead) {\n // в ней сидит свой игрок: настоящая физика здесь, позу владельцу шлёт NetGame\n if (e.kind === \"mob\") e.tick(dt, this._ctx());\n else if (e.kind === \"minecart\" || e.kind === \"boat\")\n e.tick(dt, this.world);\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 (\n e.kind === \"fireball\" &&\n e.ballKind === \"pearl\" &&\n !this._netTargets.has(e.id)\n ) {\n e.tick(\n dt,\n this.world,\n this.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n [],\n );\n } else {\n const t = this._netTargets.get(e.id);\n if (\"prevPos\" in e) e.prevPos.copy(e.pos);\n if (t) {\n const dx = t.x - e.pos.x,\n dy = t.y - e.pos.y,\n dz = t.z - e.pos.z;\n // далёкий скачок (телепорт, первая поза) — сразу; иначе полпути за тик\n if (dx * dx + dy * dy + dz * dz > 64) e.pos.set(t.x, t.y, t.z);\n else\n e.pos.set(\n e.pos.x + dx * 0.5,\n e.pos.y + dy * 0.5,\n e.pos.z + dz * 0.5,\n );\n if (e.kind === \"mob\") {\n let dyaw = t.yaw - e.yaw;\n while (dyaw > Math.PI) dyaw -= Math.PI * 2;\n while (dyaw < -Math.PI) dyaw += Math.PI * 2;\n e.yaw += dyaw * 0.5;\n if (dx * dx + dz * dz > 1e-4) e.animTime += dt;\n if (e.swing > 0) e.swing = Math.max(0, e.swing - dt);\n }\n }\n // «накормил» гаснет и здесь, иначе второй раз того же зверя не покормить; это не наша правка\n if (e.kind === \"mob\" && e.loveT > 0) {\n e.loveT = Math.max(0, e.loveT - dt);\n if (e.loveT === 0) this._rebase(e);\n }\n }\n if (e.dead) {\n // образ сущности владельца погиб ЗДЕСЬ (/kill, рыба в ведро, превращение) — пусть и там\n if (this._netTargets.has(e.id))\n this.onRemoteAction?.({\n a: \"kill\",\n id: e.id,\n drops: e.kind === \"mob\" && e.hp <= 0,\n });\n e.dispose(this.scene);\n this.entities.delete(e.id);\n this._netTargets.delete(e.id);\n this._netBase.delete(e.id);\n }\n }\n }\n\n /**\n * Реплика: что игрок поменял в сущностях своими действиями с прошлого тика — только изменившиеся\n * поля. Зовётся в конце тика (NetGame.afterTick): между изменением и сравнением снимки владельца\n * прийти не могут, так что всё отличие от «слова владельца» — наше.\n */\n netLocalChanges(): NetEntityAction[] {\n const out: NetEntityAction[] = [];\n for (const [id, base] of this._netBase) {\n const e = this.entities.get(id);\n if (!e || e.dead) continue;\n const st = interactState(e);\n if (!st) continue;\n const cur = JSON.stringify(st);\n if (cur === base) continue;\n const prev = JSON.parse(base) as Record<string, unknown>;\n const fields: Record<string, unknown> = {};\n for (const k of Object.keys(st))\n if (JSON.stringify(st[k]) !== JSON.stringify(prev[k]))\n fields[k] = st[k];\n this._netBase.set(id, cur);\n out.push({ a: \"patch\", id, fields });\n }\n return out;\n }\n\n /** Владелец: правка сущности игроком-реплики (см. interactState). `slot` — кто правил. */\n netPatch(id: number, f: Record<string, unknown>, slot?: number): void {\n const e = this.entities.get(id);\n if (!e || e.dead) return;\n const slots = (v: unknown, n: number): (ItemStack | null)[] | null =>\n Array.isArray(v) && v.length === n\n ? v.map((s) => sanitizeStack(s))\n : null;\n if (e.kind === \"minecart\") {\n const items = slots(f.items, e.items.length);\n if (items) e.items = items;\n return;\n }\n if (e.kind !== \"mob\") return;\n const bool = (k: string): boolean | undefined =>\n typeof f[k] === \"boolean\" ? (f[k] as boolean) : undefined;\n const num = (k: string): number | undefined =>\n typeof f[k] === \"number\" && Number.isFinite(f[k])\n ? Math.max(0, f[k] as number)\n : undefined;\n const v = {\n sheared: bool(\"sheared\"),\n tamed: bool(\"tamed\"),\n sitting: bool(\"sitting\"),\n saddled: bool(\"saddled\"),\n leashed: bool(\"leashed\"),\n traded: bool(\"traded\"),\n };\n if (v.sheared !== undefined) e.sheared = v.sheared;\n if (v.tamed !== undefined) {\n e.tamed = v.tamed;\n if (e.tamed) e.aggro = false;\n }\n if (v.sitting !== undefined) {\n e.sitting = v.sitting;\n e.speed = 0;\n }\n if (v.saddled !== undefined) e.saddled = v.saddled;\n if (v.traded !== undefined) e.traded = v.traded;\n if (\"woolColor\" in f)\n e.woolColor =\n typeof f.woolColor === \"string\" && f.woolColor.length < 32\n ? f.woolColor\n : null;\n const armor = num(\"wolfArmor\");\n if (armor !== undefined) e.wolfArmor = Math.floor(armor);\n if (\"carry\" in f)\n e.carry =\n typeof f.carry === \"string\" && ITEMS[f.carry]\n ? { item: f.carry, count: 1 }\n : null;\n const bag = num(\"bag\");\n if (bag !== undefined) e.bag = Math.floor(bag);\n if (\"items\" in f && e.def.chestSlots) {\n if (Array.isArray(f.items) && f.items.length === 0) e.items = [];\n else {\n const items = slots(f.items, e.def.chestSlots);\n if (items) e.items = items;\n }\n }\n if (v.leashed !== undefined) {\n e.leashed = v.leashed;\n if (!e.leashed) {\n e.leashPost = null;\n e.leashMobId = null;\n e.leashSlot = null;\n } else if (!Array.isArray(f.leashPost) && !e.leashPost)\n e.leashSlot = slot ?? null;\n }\n if (\"leashPost\" in f) {\n const lp = f.leashPost;\n e.leashPost =\n Array.isArray(lp) && lp.length === 3 && lp.every(Number.isFinite)\n ? [lp[0] as number, lp[1] as number, lp[2] as number]\n : null;\n }\n if (f.leashMob === false) e.leashMobId = null;\n // накормил / начал лечить: условия проверяет владелец — его таймеры точнее образа реплики\n if (f.love === true && !e.baby && e.loveT <= 0 && e.breedCd <= 0) {\n e.loveT = 30;\n events.emit(\"mobFed\", { entity: e });\n }\n if (f.cure === true && e.def.curable && e.curing <= 0)\n e.curing = CURE_SECONDS;\n if (\"name\" in f) e.setName(typeof f.name === \"string\" ? f.name : null);\n if (Array.isArray(f.uses))\n e.uses = f.uses.map((n) =>\n typeof n === \"number\" && Number.isFinite(n)\n ? Math.max(0, Math.floor(n))\n : 0,\n );\n const xp = num(\"villagerXp\");\n if (xp !== undefined) e.villagerXp = Math.floor(xp);\n }\n\n /** Владелец: образ сущности погиб у реплики — с дропом (убили) или без (исчезла, превратилась). */\n netKill(id: number, drops: boolean): void {\n const e = this.entities.get(id);\n if (!e || e.dead) return;\n if (drops && e.kind === \"mob\") e.damage(e.hp);\n else e.dead = true;\n }\n\n /**\n * Владелец: игрок-реплика сел (on) или слез. Пока сидит, сущность здесь не тикает — её ведёт он.\n * @returns false — место занято (сел кто-то другой, или сидит свой игрок)\n */\n netRide(id: number, slot: number, on: boolean): boolean {\n const e = this.entities.get(id);\n if (\n !e ||\n e.dead ||\n !(e.kind === \"mob\" || e.kind === \"minecart\" || e.kind === \"boat\")\n )\n return !on;\n const cur = this._netDriven.get(id);\n if (!on) {\n if (cur?.slot === slot) this._release(id);\n return true;\n }\n if ((cur && cur.slot !== slot) || (this.player.riding as unknown) === e)\n return false;\n this._netDriven.set(id, { slot, t: performance.now() });\n e.rider = true;\n return true;\n }\n\n /** Владелец: поза сущности, которую ведёт реплика (чужая поза той же сущности — мимо). */\n netDrive(\n id: number,\n slot: number,\n pos: readonly number[],\n vel: readonly number[],\n yaw: number,\n ): void {\n const cur = this._netDriven.get(id);\n const e = this.entities.get(id);\n if (!cur || cur.slot !== slot || !e || e.dead) return;\n if (pos.length !== 3 || !pos.every(Number.isFinite)) return;\n if (\"prevPos\" in e) e.prevPos.copy(e.pos);\n e.pos.set(pos[0]!, pos[1]!, pos[2]!);\n if (\"vel\" in e && vel.length === 3 && vel.every(Number.isFinite))\n e.vel.set(vel[0]!, vel[1]!, vel[2]!);\n if (e.kind === \"mob\" && Number.isFinite(yaw)) e.yaw = e.targetYaw = yaw;\n cur.t = performance.now();\n }\n\n /** Владелец: игрок ушёл (из измерения, из игры) — всё, что он вёл, снова симулируется здесь. */\n netReleaseDriver(slot: number): void {\n for (const [id, d] of [...this._netDriven])\n if (d.slot === slot) this._release(id);\n }\n\n private _release(id: number): void {\n this._netDriven.delete(id);\n const e = this.entities.get(id);\n if (e && (e.kind === \"mob\" || e.kind === \"minecart\" || e.kind === \"boat\"))\n e.rider = false;\n }\n}\n"
|
|
144
|
+
"content": "// Менеджер сущностей: мобы, дроп-предметы, сферы опыта, стрелы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Перепись (census) держит население мобов вокруг игрока\n// по правилам Minecraft: мирные — на траве при свете, враждебные — в темноте (ночью и в пещерах).\n// Игрок хранится отдельно (Player), но интерфейс сущностей совместим — в этапе 2 удалённые игроки\n// станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { frogFor } from \"../registry/mobs/frog\";\nimport { rayBox } from \"../physics/rayBox\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport { Arrow } from \"./Arrow\";\nimport { PrimedTnt } from \"./PrimedTnt\";\nimport { Fireball, type FireballKind } from \"./Fireball\";\nimport { RimEye } from \"./RimEye\";\nimport { Minecart, type CartVariant } from \"./Minecart\";\nimport { smokedHive } from \"../world/Hive\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { Boat } from \"./Boat\";\nimport { FishingHook } from \"./FishingHook\";\nimport { REP_CURE, REP_TRADE } from \"../systems/Reputation\";\nimport type { MobCtx } from \"./MobBrain\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { ItemStack, MobDef } from \"../types\";\nimport {\n netSnapshot,\n createFromSnap,\n netSpawnFromRemote,\n netApplySpawn,\n rebase,\n netApplyPose,\n netPose,\n netRemove,\n replicaTick,\n netLocalChanges,\n netPatch,\n netKill,\n netRide,\n netDrive,\n netReleaseDriver,\n release,\n} from \"./manager/net\";\nimport { serialize, deserialize, loadOne } from \"./manager/persistence\";\nimport { census, pickType, trySpawn, canSpawn } from \"./manager/census\";\nimport {\n villagerJobs,\n villagerGolem,\n tickCuring,\n convertToZombie,\n reprofess,\n villageAnger,\n addRep,\n} from \"./manager/villagers\";\nimport { hurt, headHit, explode, mobDied, angerGroup } from \"./manager/combat\";\nimport {\n markRaiders,\n summonAtAltar,\n buildIronGolem,\n buildSnowGolem,\n spawnFangs,\n spawnWanderingTrader,\n spawnPatrol,\n layEggs,\n spawnBee,\n spawnMinecart,\n spawnBoat,\n castHook,\n playerHook,\n spawnDrop,\n takeDrop,\n ejectItem,\n shootArrow,\n spawnMob,\n spawnXp,\n spawnArrow,\n spawnFireball,\n spawnRimEye,\n spawnTnt,\n} from \"./manager/spawners\";\nimport { ctx } from \"./manager/mobContext\";\nimport type {\n Entity,\n EntitySave,\n Actor,\n NetEntitySnap,\n NetEntityAction,\n} from \"./manager/types\";\nimport { INFESTED_IDS, randInt } from \"./manager/shared\";\nimport { cartBox, cartItems, cartWork, raycastRide } from \"./manager/vehicles\";\nimport { updateVisuals, beam } from \"./manager/visuals\";\n\nexport type {\n Actor,\n Entity,\n NetEntityAction,\n NetEntitySnap,\n} from \"./manager/types\";\n\nexport class EntityManager {\n /**\n * Сетевая игра: другие игроки в этом измерении (у владельца измерения). Мобы выбирают ближайшего,\n * предмет подбирает ближайший, взрыв бьёт всех — у удалённого игрока урон уходит ему по сети.\n */\n others: () => Actor[] = () => [];\n /**\n * Реплика (net/sim): сущности живут у владельца измерения, здесь только их образ — без тика,\n * без спавна по событиям. То, что создаёт ДЕЙСТВИЕ этого игрока (бросил предмет, выстрелил),\n * уходит владельцу через `onLocalSpawn`, а удар и взрыв — через `onRemoteAction`.\n */\n replica = false;\n onLocalSpawn: ((snap: NetEntitySnap) => void) | null = null;\n onRemoteAction: ((a: NetEntityAction) => void) | null = null;\n /** Реплика: куда сущность должна прийти по последнему снимку владельца (сглаживание). */\n readonly _netTargets = new Map<\n number,\n { x: number; y: number; z: number; yaw: number }\n >();\n /** Идёт применение сетевого снимка: спавн регистрируется здесь, а не уходит владельцу. */\n _netApplying = false;\n /** Сетевая игра: слот своего игрока (поводок держит конкретный игрок); null — одиночная игра. */\n localSlot: number | null = null;\n /** Реплика: состояние взаимодействия образов по последнему слову владельца (см. interactState). */\n readonly _netBase = new Map<number, string>();\n /** Реплика: сущность, в которой сидит свой игрок, — её ведёт он сам, позы владельца не слушает. */\n drivenId: number | null = null;\n /** Владелец: сущности, которые ведут игроки-реплики (не тикают здесь): id → слот и время позы. */\n readonly _netDriven = new Map<number, { slot: number; t: number }>();\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Entity>;\n _nextId: number;\n _spawnTimer: number;\n /** На сколько уровней ночь гасит небесный свет и день ли сейчас — задаёт Game из DayNight. */\n skyDarken = 0;\n isDay = true;\n /** Идёт дождь (Game из systems/Weather). */\n raining = false;\n /** Игрок не спал три ночи — прилетают фантомы (Game.daysAwake). */\n insomnia = false;\n /** Секунд до следующего патруля мародёров. */\n _patrolT = 300 + Math.random() * 300;\n\n constructor(\n scene: THREE.Scene,\n world: ChunkManager,\n atlas: TextureAtlas,\n player: Player,\n inventory: Inventory,\n ) {\n this.scene = scene;\n this.world = world;\n this.atlas = atlas;\n this.player = player;\n this.inventory = inventory;\n this.entities = new Map();\n this._nextId = 1;\n this._spawnTimer = 0;\n // TNT, подожжённый огнём (поведение огня не знает про сущности — просит событием)\n events.on(\n \"tntPrime\",\n (d: { x: number; y: number; z: number; fuse?: number }) =>\n this.spawnTnt(d.x + 0.5, d.y, d.z + 0.5, d.fuse ?? 4),\n );\n // вагонетку поставили на рельс (предмет minecart — world/behaviours/rails)\n events.on(\n \"spawnMinecart\",\n (d: { x: number; y: number; z: number; variant?: CartVariant }) =>\n this.spawnMinecart(d.x + 0.5, d.y, d.z + 0.5, d.variant),\n );\n events.on(\n \"spawnBoat\",\n (d: { x: number; y: number; z: number; wood: string }) =>\n this.spawnBoat(d.x + 0.5, d.y, d.z + 0.5, d.wood),\n );\n // из заражённого блока вылезает чешуйница (registry/blocks/infested)\n events.on(\n \"blockBreak\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (INFESTED_IDS.has(d.id) && MOBS.silverfish)\n this.spawnMob(\"silverfish\", d.x + 0.5, d.y, d.z + 0.5);\n },\n );\n // мёд собрали — пчёлы в 16 блоках от улья злятся\n events.on(\"hiveHarvest\", (d: { x: number; y: number; z: number }) => {\n if (smokedHive(this.world, d.x, d.y, d.z)) return; // дым костра под ульем их успокаивает\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e.def.bee &&\n !e.dead &&\n e.pos.distanceTo(new THREE.Vector3(d.x, d.y, d.z)) < 16\n )\n e.aggro = true;\n });\n // черепашье яйцо вылупилось (world/behaviours/ocean); выросший черепашонок роняет щиток\n events.on(\"turtleHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.turtle) this.spawnMob(\"turtle\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n events.on(\"mobGrew\", ({ entity }: { entity: Mob }) => {\n const item = entity.def.growDrop;\n if (item && ITEMS[item])\n this.spawnDrop(entity.pos.x, entity.pos.y + 0.3, entity.pos.z, item, 1);\n });\n // яйцо нюхача треснуло — детёныш нюхача\n events.on(\"snifferHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.sniffer)\n this.spawnMob(\"sniffer\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n // лягушачья икра — 2–6 головастиков в воду под ней; головастик вырос — лягушка по биому\n events.on(\"frogspawnHatch\", (d: { x: number; y: number; z: number }) => {\n if (!MOBS.tadpole) return;\n for (let i = randInt(2, 6); i > 0; i--)\n this.spawnMob(\n \"tadpole\",\n d.x + 0.3 + Math.random() * 0.4,\n d.y - 0.8,\n d.z + 0.3 + Math.random() * 0.4,\n );\n });\n events.on(\"mobMature\", ({ entity }: { entity: Mob }) => {\n const into =\n entity.def.growsInto === \"frog\"\n ? frogFor(this.world.generator.biomeOf?.(entity.pos.x, entity.pos.z))\n : entity.def.growsInto;\n if (!into || !MOBS[into] || entity.dead) return;\n this.spawnMob(into, entity.pos.x, entity.pos.y, entity.pos.z);\n entity.dead = true; // hp > 0 — без дропа: превратился\n });\n // третий пепельный череп на Т из песка душ — Пепельный владыка (entities/AshLord)\n events.on(\n \"blockPlace\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (d.id === B.ash_skull) this.summonAtAltar(d.x, d.y, d.z);\n if (d.id === B.pumpkin)\n // тыква достраивает снеговика или железного голема (systems/Golem)\n if (!this.buildSnowGolem(d.x, d.y, d.z))\n this.buildIronGolem(d.x, d.y, d.z);\n },\n );\n // ручные волки: на того, кого бьёт игрок, и на того, кто бьёт игрока\n events.on(\"playerHitMob\", ({ entity }: { entity: Mob }) => {\n this.guard(entity);\n this.villageAnger(entity);\n });\n // деревня помнит добро: торговля и вылеченный зомби-житель поднимают репутацию\n events.on(\"trade\", () => this._addRep(REP_TRADE));\n events.on(\"villagerCured\", () => this._addRep(REP_CURE));\n // колокол: налётчики вокруг светятся, как в Minecraft\n events.on(\"bellRing\", (p: { x: number; y: number; z: number }) =>\n this.markRaiders(p.x, p.y, p.z),\n );\n events.on(\"mobAttack\", ({ entity }: { entity: Mob }) => this.guard(entity));\n }\n\n /**\n * Единственная точка регистрации новой сущности. У реплики (net/sim) сущность, созданная\n * действием игрока, здесь НЕ живёт: после того как вызвавший её донастроит (скорость брошенного\n * предмета, эффект стрелы — это делается сразу после spawn*), её снимок уходит владельцу\n * измерения, а тот разошлёт настоящую. Исключения живут у реплики сами: поплавок (рыбалка —\n * своя) и жемчуг Предела (переносит бросившего, а не владельца измерения).\n */\n _register<T extends Entity>(e: T, ...objects: THREE.Object3D[]): T {\n const ownLocal =\n e.kind === \"hook\" || (e.kind === \"fireball\" && e.ballKind === \"pearl\");\n if (this.replica && !this._netApplying && !ownLocal) {\n queueMicrotask(() => {\n const snap = this.netSnapshot(e);\n if (snap) this.onLocalSpawn?.(snap);\n });\n return e;\n }\n this.entities.set(e.id, e);\n if (objects.length) this.scene.add(...objects);\n return e;\n }\n\n /** Убрать всех враждебных мобов (переход на мирную сложность — systems/Difficulty). */\n clearHostiles(): void {\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.category === \"hostile\" && !e.def.boss)\n e.dead = true; // hp > 0 — исчезают без дропа, как при удалении игрока\n }\n\n /** Сдвинуть репутацию игрока в деревне и сказать об этом HUD. */\n _addRep(by: number): void {\n addRep(this, by);\n }\n\n /**\n * Ударили жителя: репутация падает, а железные големы рядом бросаются на обидчика — как в\n * Minecraft, где деревня помнит зло (systems/Reputation).\n */\n villageAnger(target: Mob): void {\n villageAnger(this, target);\n }\n\n /** Звон колокола: налётчики в 32 блоках светятся 3 секунды (видно сквозь стены). */\n markRaiders(x: number, y: number, z: number): void {\n markRaiders(this, x, y, z);\n }\n\n /** Череп в (x,y,z) достроил алтарь — блоки исчезают, встаёт владыка и набирает силу. */\n summonAtAltar(x: number, y: number, z: number): Mob | null {\n return summonAtAltar(this, x, y, z);\n }\n\n /** Железный голем: тыква на фигуре из четырёх железных блоков — блоки исчезают, встаёт голем. */\n buildIronGolem(x: number, y: number, z: number): Mob | null {\n return buildIronGolem(this, x, y, z);\n }\n\n /** Снеговик: тыква на двух блоках снега — блоки исчезают, на их месте снеговик. */\n buildSnowGolem(x: number, y: number, z: number): Mob | null {\n return buildSnowGolem(this, x, y, z);\n }\n\n /** Клыки ворожея: 16 штук по прямой от from к to через 1.25 блока, каждый позже предыдущего. */\n spawnFangs(from: THREE.Vector3, to: THREE.Vector3, ownerId: number): void {\n spawnFangs(this, from, to, ownerId);\n }\n\n /** До следующей попытки прихода странствующего торговца, сек. */\n _traderT = 1200;\n /** Перезарядка вызова железного голема деревней (systems/Golem). */\n _golemT = 0;\n\n /** Странствующий торговец с двумя ламами на поводке — на суше в 16–32 блоках от игрока. */\n spawnWanderingTrader(): Mob | null {\n return spawnWanderingTrader(this);\n }\n\n /** Патруль мародёров: 2–5 во главе с капитаном на суше в 24–48 блоках от игрока. */\n spawnPatrol(): number {\n return spawnPatrol(this);\n }\n\n /** Черепаха после размножения откладывает 1–4 яйца на песок рядом. */\n layEggs(m: Mob): number {\n return layEggs(this, m);\n }\n\n /** Вода в квадрате r вокруг клетки (на её высоте и выше на блок) — пляж, а не пустыня. */\n /** Есть ли блок id в кубе радиуса r (аксолотль — мох пышных пещер рядом с озером). */\n _blockNear(\n x: number,\n y: number,\n z: number,\n id: number | undefined,\n r: number,\n ): boolean {\n if (id === undefined) return false;\n for (let dy = -r; dy <= r; dy++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === id) return true;\n return false;\n }\n\n _waterNear(x: number, y: number, z: number, r: number): boolean {\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n for (let dy = 0; dy <= 1; dy++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === B.water)\n return true;\n return false;\n }\n\n /** Выпустить пчелу из улья (x,y,z) в свободную соседнюю клетку; false — места нет. */\n spawnBee(x: number, y: number, z: number): boolean {\n return spawnBee(this, x, y, z);\n }\n\n /** Ручные волки рядом с игроком (24 блока) бросаются на `target`. */\n guard(target: Mob): void {\n if (target.tamed || target.dead) return;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e !== target &&\n e.tamed &&\n e.def.guards &&\n !e.sitting &&\n !e.dead &&\n e.pos.distanceTo(this.player.pos) < 24\n )\n e.targetId = target.id;\n }\n\n /** Груз вагонетки как контейнер (воронка под рельсом и в вагонетку, экран сундука). */\n /** Груз вагонетки или вьюк моба — как контейнер для экрана сундука. */\n cartBox(c: Minecart | Mob): BlockEntity {\n return cartBox(this, c);\n }\n\n /** Вагонетка с грузом в клетке (x, y, z) — как контейнер; null — нет такой. */\n cartItems(x: number, y: number, z: number): BlockEntity | null {\n return cartItems(this, x, y, z);\n }\n\n /**\n * Работа вагонетки за тик: динамит взрывается, запитанные активирующие рельсы высаживают седока и\n * запирают воронку; вагонетка с воронкой берёт из контейнера над собой или лежащие рядом предметы.\n */\n _cartWork(c: Minecart, dt: number): void {\n cartWork(this, c, dt);\n }\n\n spawnMinecart(\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n ): Minecart {\n return spawnMinecart(this, x, y, z, variant);\n }\n\n spawnBoat(x: number, y: number, z: number, wood: string): Boat {\n return spawnBoat(this, x, y, z, wood);\n }\n\n /** Забросить поплавок игрока (прежний, если был, исчезает). */\n castHook(pos: THREE.Vector3, vel: THREE.Vector3): FishingHook {\n return castHook(this, pos, vel);\n }\n\n /** Поплавок игрока в мире или null. */\n playerHook(): FishingHook | null {\n return playerHook(this);\n }\n\n /** Луч по транспорту (вагонетки, лодки): ближайший и расстояние до него. */\n raycastRide(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): { cart: Minecart | Boat; t: number } | null {\n return raycastRide(this, origin, dir, maxDist);\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n dmg?: number,\n tag?: Record<string, unknown>,\n ): ItemDrop {\n return spawnDrop(this, x, y, z, item, count, dmg, tag);\n }\n\n /** Воронка: забрать из лежащего в коробке дропа один предмет, если accept его взял. */\n takeDrop(\n x0: number,\n y0: number,\n z0: number,\n x1: number,\n y1: number,\n z1: number,\n accept: (s: ItemStack) => boolean,\n ): boolean {\n return takeDrop(this, x0, y0, z0, x1, y1, z1, accept);\n }\n\n /** Выбрасыватель: стак со скоростью, поднять можно не сразу. */\n ejectItem(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n s: ItemStack,\n ): void {\n ejectItem(this, x, y, z, vx, vy, vz, s);\n }\n\n /** Раздатчик: стрела без стрелка; отравленная несёт свой эффект (systems/TippedArrow). */\n shootArrow(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n item = \"arrow\",\n ): void {\n shootArrow(this, x, y, z, vx, vy, vz, item);\n }\n\n spawnMob(type: string, x: number, y: number, z: number, baby = false): Mob {\n return spawnMob(this, type, x, y, z, baby);\n }\n\n /** Опыт сферами (крупные номиналы, как в Minecraft), не больше 10 сфер. */\n spawnXp(x: number, y: number, z: number, amount: number): void {\n spawnXp(this, x, y, z, amount);\n }\n\n spawnArrow(\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n fromPlayer: boolean,\n shooterId: number,\n ): Arrow {\n return spawnArrow(this, pos, vel, damage, fromPlayer, shooterId);\n }\n\n /** Огненный шар моба Пекла. */\n spawnFireball(\n kind: FireballKind,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n shooterId: number,\n ): Fireball {\n return spawnFireball(this, kind, pos, vel, damage, shooterId);\n }\n\n /** Брошенное Око Предела — летит к цели (цитадели). */\n spawnRimEye(from: THREE.Vector3, target: { x: number; z: number }): RimEye {\n return spawnRimEye(this, from, target);\n }\n\n /** Ударили моба со стайной злостью — злятся все такие же в 32 блоках. */\n _angerGroup(m: Mob): void {\n angerGroup(this, m);\n }\n\n /** Взведённая взрывчатка (огниво, огонь, соседний взрыв). */\n spawnTnt(x: number, y: number, z: number, fuse: number): PrimedTnt {\n return spawnTnt(this, x, y, z, fuse);\n }\n\n /**\n * Удар игрока по мобу. `attacker` — кто бил (сетевая игра: удалённый игрок; по умолчанию свой).\n * У реплики (net/sim) удар уходит владельцу измерения — моб живёт там.\n */\n hurt(\n entityId: number,\n damage: number,\n fromPos: THREE.Vector3,\n attacker: Player = this.player,\n ): void {\n hurt(this, entityId, damage, fromPos, attacker);\n }\n\n /** Луч взгляда игрока проходит у головы дракона (перед центром, на половине ширины). */\n _headHit(m: Mob, who: Player = this.player): boolean {\n return headHit(this, m, who);\n }\n\n /** Взрыв силы power: блоки (дроп с шансом 1/сила), урон и отброс игроку, мобам и предметам. */\n explode(x: number, y: number, z: number, power: number): void {\n explode(this, x, y, z, power);\n }\n\n _ctx(): MobCtx {\n return ctx(this);\n }\n\n /** Все игроки этого измерения: свой и удалённые (сетевая игра). */\n actors(): Actor[] {\n const own: Actor = { player: this.player, inventory: this.inventory };\n const rest = this.others();\n return rest.length ? [own, ...rest] : [own];\n }\n\n /**\n * Ближайший к точке игрок: мобы целятся в него, предмет летит к нему. Живые впереди мёртвых —\n * иначе моб «застревал» бы на трупе, пока рядом бегает живой.\n */\n nearestActor(pos: { x: number; y: number; z: number }): Actor {\n const list = this.actors();\n if (list.length === 1) return list[0]!;\n let best = list[0]!,\n bd = Infinity;\n for (const a of list) {\n const p = a.player.pos;\n const d =\n (p.x - pos.x) ** 2 +\n (p.y - pos.y) ** 2 +\n (p.z - pos.z) ** 2 +\n (a.player.dead ? 1e12 : 0);\n if (d < bd) {\n bd = d;\n best = a;\n }\n }\n return best;\n }\n\n /**\n * Игрок для сущности: моба на поводке ведёт ТОТ, кто держит поводок (у двух игроков рядом моб\n * иначе шёл бы к ближайшему); остальным — ближайший.\n */\n private _actorFor(e: Entity): Actor {\n if (\n e.kind === \"mob\" &&\n e.leashed &&\n !e.leashPost &&\n e.leashMobId === null &&\n e.leashSlot !== null\n ) {\n if (e.leashSlot === this.localSlot)\n return { player: this.player, inventory: this.inventory };\n const held = this.others().find(\n (o) => (o.player as Player & { slot?: number }).slot === e.leashSlot,\n );\n if (held) return held;\n }\n return this.nearestActor(e.pos);\n }\n\n tick(dt: number): void {\n if (this.replica) {\n this._replicaTick(dt);\n return;\n }\n const ctx = this._ctx();\n const ownHeld = ctx.heldItem;\n const multi = this.others().length > 0;\n const mobs = [...this.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\",\n );\n const now = performance.now();\n for (const e of this.entities.values()) {\n // её ведёт игрок-реплика: позу шлёт он (netDrive); замолчал 3 с — снова наша\n const drv = this._netDriven.get(e.id);\n if (drv && !e.dead) {\n if (now - drv.t < 3000) continue;\n this._release(e.id);\n }\n const a = multi\n ? this._actorFor(e)\n : { player: this.player, inventory: this.inventory };\n if (e.kind === \"drop\") e.tick(dt, this.world, a.player, a.inventory);\n else if (e.kind === \"mob\") {\n if (multi) {\n ctx.player = a.player;\n ctx.heldItem =\n a.player === this.player\n ? ownHeld\n : (a.inventory.heldItem()?.item ?? null);\n }\n e.tick(dt, ctx);\n if (e.angerCall) this._angerGroup(e);\n } else if (e.kind === \"xp\") e.tick(dt, this.world, a.player);\n else if (e.kind === \"tnt\")\n e.tick(dt, this.world, (x, y, z, p) => this.explode(x, y, z, p));\n else if (e.kind === \"fireball\")\n e.tick(\n dt,\n this.world,\n a.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n mobs,\n );\n else if (e.kind === \"minecart\" || e.kind === \"boat\") {\n e.tick(dt, this.world);\n if (e.kind === \"minecart\") this._cartWork(e, dt);\n } else if (e.kind === \"hook\")\n e.tick(\n dt,\n this.world,\n this.player.pos,\n this.raining,\n this.inventory.heldItem()?.item === \"fishing_rod\",\n );\n else if (e.kind === \"rimeye\") {\n // долетело: в 80% падает предметом, иначе разбивается\n if (e.tick(dt)) {\n if (Math.random() < 0.8)\n this.spawnDrop(e.pos.x, e.pos.y, e.pos.z, \"rim_eye\", 1);\n else\n events.emit(\"eyeShatter\", { x: e.pos.x, y: e.pos.y, z: e.pos.z });\n }\n } else e.tick(dt, this.world, a.player, mobs, a.inventory);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0) this._mobDied(e);\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Смерть моба: дроп по таблице (у детёнышей нет) и опыт, если бил игрок. */\n _mobDied(m: Mob): void {\n mobDied(this, m);\n }\n\n /**\n * Профессия от рабочего блока (systems/Jobs), раз в секунду: безработный житель занимает свободный\n * блок рядом и становится мастером; у кого рабочий блок сломали — теряет профессию, если ни разу\n * не торговал. Житель из деревни рабочего места не имеет и профессию не теряет, как в Minecraft.\n */\n _villagerJobs(): void {\n villagerJobs(this);\n }\n\n /**\n * Деревня зовёт голема (systems/Golem): рядом с жителями враждебные (ночная осада) — раз в\n * GOLEM_COOLDOWN секунд встаёт один железный голем. Так деревня отвечает на осаду сама.\n */\n _villagerGolem(): void {\n villagerGolem(this);\n }\n\n /** Лечение зомби-жителя (systems/Zombify): раз в секунду, на нуле — обратно в свою профессию. */\n _tickCuring(): void {\n tickCuring(this);\n }\n\n /**\n * Житель, убитый зомби, встаёт зомби-жителем и помнит профессию (systems/Zombify).\n * Зовёт мозг зомби через MobCtx.convert.\n */\n convertToZombie(v: Mob): Mob {\n return convertToZombie(this, v);\n }\n\n /** Тот же житель на том же месте, но другим типом моба: типы жителей — по профессии. */\n _reprofess(v: Mob, type: string, site: [number, number, number] | null): Mob {\n return reprofess(this, v, type, site);\n }\n\n /** Перепись: враждебные вдали исчезают, население добирается попытками спавна. */\n _census(dt: number): void {\n census(this, dt);\n }\n\n _pickType(light: \"bright\" | \"dark\"): MobDef | null {\n return pickType(this, light);\n }\n\n _trySpawn(\n light: \"bright\" | \"dark\",\n p: THREE.Vector3 = this.player.pos,\n ): number {\n return trySpawn(this, light, p);\n }\n\n /** Место для спавна: пол непрозрачный, над ним воздух на рост, свет по правилу вида. */\n _canSpawn(def: MobDef, x: number, y: number, z: number): boolean {\n return canSpawn(this, def, x, y, z);\n }\n\n /** Луч по AABB мобов (slab-тест) — для атаки и кормления. Возвращает ближайшего. */\n raycast(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): Mob | null {\n let best: Mob | null = null,\n bestT = maxDist;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead || e.rider) continue; // лошадь под игроком лучу не мешает\n const w = e.width / 2;\n const t = rayBox(\n origin,\n dir,\n bestT,\n { x: e.pos.x - w, y: e.pos.y, z: e.pos.z - w },\n { x: e.pos.x + w, y: e.pos.y + e.height, z: e.pos.z + w },\n );\n if (t !== null && t < bestT) {\n bestT = t;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n updateVisuals(this, alpha, time);\n }\n\n /** Луч от лечащего Кристалла Предела к дракону. */\n beamLine: THREE.Line | null = null;\n\n _beam(): void {\n beam(this);\n }\n\n clear(): void {\n this._netBase.clear();\n this._netDriven.clear();\n this.drivenId = null;\n // игрок сидел в вагонетке/лодке этого мира — ссаживаем без телепорта: иначе Player.update\n // каждый кадр ставил бы его к выброшенной сущности (после портала — в координаты прошлого\n // измерения), а «присесть» высаживало бы туда же\n const ride = this.player?.riding;\n if (ride) {\n ride.rider = false;\n this.player.riding = null;\n }\n // выброшенные сущности — мёртвые для всех, кто держит на них ссылку (экран торговли с жителем,\n // груз вагонетки, поводок): иначе после портала экран жил бы с призраком из прошлого измерения\n for (const e of this.entities.values()) {\n e.dead = true;\n e.dispose(this.scene);\n }\n this.entities.clear();\n if (this.beamLine) this.beamLine.visible = false;\n }\n\n serialize() {\n return serialize(this);\n }\n\n deserialize(list?: EntitySave[]): void {\n deserialize(this, list);\n }\n\n /** Одна сохранённая сущность → живая (сейв, Мастерская, сетевой снимок). null — запись негодна. */\n _loadOne(d: EntitySave): Entity | null {\n return loadOne(this, d);\n }\n\n /**\n * Сущность → снимок для другого игрока. null — вид в сети не живёт (поплавок и клыки — локальные\n * и короткие, Око Предела — чистая анимация у бросившего).\n */\n netSnapshot(e: Entity): NetEntitySnap | null {\n return netSnapshot(this, e);\n }\n\n /** Снимок → живая сущность этого менеджера (id — свой). null — снимок негоден. */\n _createFromSnap(s: NetEntitySnap): Entity | null {\n return createFromSnap(this, s);\n }\n\n /**\n * Владелец измерения: реплика попросила создать сущность (бросила предмет, выстрелила). Создаётся\n * по обычным правилам — с id этого менеджера, и разойдётся всем обычным снимком.\n */\n netSpawnFromRemote(s: NetEntitySnap): Entity | null {\n return netSpawnFromRemote(this, s);\n }\n\n /** Реплика: сущность владельца с ЕГО id (повтор того же id — замена: сменился вид). */\n netApplySpawn(id: number, s: NetEntitySnap): void {\n netApplySpawn(this, id, s);\n }\n\n /** Реплика: запомнить состояние образа как «слово владельца» (изменения от него — не наши). */\n _rebase(e: Entity): void {\n rebase(this, e);\n }\n\n /**\n * Реплика: поза по снимку владельца. Сущность идёт к ней сглаженно (_replicaTick), а флаги вида\n * применяются сразу. Незнакомый id — пропуск: снимок появления придёт надёжным каналом.\n */\n netApplyPose(\n id: number,\n x: number,\n y: number,\n z: number,\n yaw: number,\n flags: number,\n extra: number,\n ): void {\n netApplyPose(this, id, x, y, z, yaw, flags, extra);\n }\n\n /** Поза сущности для снимка: флаги вида (см. netApplyPose) и «лишний» байт (фитиль/раскрытие). */\n netPose(e: Entity): { yaw: number; flags: number; extra: number } {\n return netPose(this, e);\n }\n\n /** Реплика: сущность ушла у владельца (умерла, подобрана, вышла из зоны видимости). */\n netRemove(id: number): void {\n netRemove(this, id);\n }\n\n /**\n * Тик реплики: никакой симуляции — только ход к позе из последнего снимка (снимки приходят 10 раз\n * в секунду, тик — 20: без сглаживания мобы двигались бы рывками). Свои локальные сущности\n * (поплавок, жемчуг) тикают по-настоящему.\n */\n _replicaTick(dt: number): void {\n replicaTick(this, dt);\n }\n\n /**\n * Реплика: что игрок поменял в сущностях своими действиями с прошлого тика — только изменившиеся\n * поля. Зовётся в конце тика (NetGame.afterTick): между изменением и сравнением снимки владельца\n * прийти не могут, так что всё отличие от «слова владельца» — наше.\n */\n netLocalChanges(): NetEntityAction[] {\n return netLocalChanges(this);\n }\n\n /** Владелец: правка сущности игроком-реплики (см. interactState). `slot` — кто правил. */\n netPatch(id: number, f: Record<string, unknown>, slot?: number): void {\n netPatch(this, id, f, slot);\n }\n\n /** Владелец: образ сущности погиб у реплики — с дропом (убили) или без (исчезла, превратилась). */\n netKill(id: number, drops: boolean): void {\n netKill(this, id, drops);\n }\n\n /**\n * Владелец: игрок-реплика сел (on) или слез. Пока сидит, сущность здесь не тикает — её ведёт он.\n * @returns false — место занято (сел кто-то другой, или сидит свой игрок)\n */\n netRide(id: number, slot: number, on: boolean): boolean {\n return netRide(this, id, slot, on);\n }\n\n /** Владелец: поза сущности, которую ведёт реплика (чужая поза той же сущности — мимо). */\n netDrive(\n id: number,\n slot: number,\n pos: readonly number[],\n vel: readonly number[],\n yaw: number,\n ): void {\n netDrive(this, id, slot, pos, vel, yaw);\n }\n\n /** Владелец: игрок ушёл (из измерения, из игры) — всё, что он вёл, снова симулируется здесь. */\n netReleaseDriver(slot: number): void {\n netReleaseDriver(this, slot);\n }\n\n _release(id: number): void {\n release(this, id);\n }\n}\n"
|
|
145
145
|
},
|
|
146
146
|
{
|
|
147
147
|
"path": "entities/Fang.ts",
|
|
@@ -175,6 +175,50 @@
|
|
|
175
175
|
"path": "entities/ItemDrop.ts",
|
|
176
176
|
"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"
|
|
177
177
|
},
|
|
178
|
+
{
|
|
179
|
+
"path": "entities/manager/census.ts",
|
|
180
|
+
"content": "import * as THREE from \"three\";\nimport { rules } from \"../../systems/Difficulty\";\nimport { CONFIG } from \"../../config\";\nimport { BLOCKS, B } from \"../../registry/Blocks\";\nimport { MOBS } from \"../../registry/Mobs\";\nimport type { MobDef } from \"../../types\";\nimport { randInt } from \"./shared\";\nimport type { EntityManager } from \"../EntityManager\";\n\nexport function census(em: EntityManager, dt: number): void {\n em._spawnTimer -= dt;\n if (em._spawnTimer > 0) return;\n em._spawnTimer = 1;\n // патрули мародёров — раз в 5–10 минут, как в Minecraft\n em._patrolT -= 1;\n if (em._patrolT <= 0) {\n em._patrolT = 300 + Math.random() * 300;\n em.spawnPatrol();\n }\n // странствующий торговец — раз в 20 минут с шансом 25%, если его ещё нет (как в Minecraft)\n em._traderT -= 1;\n if (em._traderT <= 0) {\n em._traderT = 1200;\n const here = [...em.entities.values()].some(\n (e) => e.kind === \"mob\" && !e.dead && e.def.name === \"wandering_trader\",\n );\n if (!here && Math.random() < 0.25) em.spawnWanderingTrader();\n }\n em._villagerJobs();\n em._tickCuring();\n em._villagerGolem();\n const M = CONFIG.mobs;\n // сетевая игра: «рядом с игроком» — рядом с БЛИЖАЙШИМ игроком измерения, и спавн — вокруг\n // случайного из них (как в Minecraft на сервере: население держится вокруг каждого игрока)\n const actors = em.actors();\n const center = actors[Math.floor(Math.random() * actors.length)]!.player.pos;\n let passive = 0,\n hostile = 0;\n for (const e of em.entities.values()) {\n if (e.kind !== \"mob\" || e.dead) continue;\n let d = Infinity;\n for (const a of actors) d = Math.min(d, e.pos.distanceTo(a.player.pos));\n if (e.def.category === \"passive\") {\n if (d < M.countRadius) passive++;\n continue;\n }\n // голем деревни, ручные, названные биркой, набег и волна испытаний не исчезают\n if (e.def.persistent || e.tamed || e.customName || e.raider || e.trialFrom)\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 = trySpawn(em, wantHostile ? \"dark\" : \"bright\", center);\n if (wantHostile) hostile += n;\n else passive += n;\n }\n}\n\nexport function pickType(\n em: EntityManager,\n light: \"bright\" | \"dark\",\n): MobDef | null {\n const dim = em.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\nexport function trySpawn(\n em: EntityManager,\n light: \"bright\" | \"dark\",\n p: THREE.Vector3 = em.player.pos,\n): number {\n const def = pickType(em, light);\n if (!def) return 0;\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\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 (em.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[em.world.getBlock(x, y - 1, z)]?.solid;\n k++\n )\n y--;\n } else {\n const top = em.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, em.world.surfaceY(x, z) - 24));\n for (\n let k = 0;\n k < 24 && wy > 1 && em.world.getBlock(x, wy, z) !== B.water;\n k++\n )\n wy--;\n y = wy;\n } else if (med === \"water\") {\n let wy = em.world.surfaceY(x, z) + 1;\n for (\n let k = 0;\n k < 12 && wy > 1 && em.world.getBlock(x, wy, z) !== B.water;\n k++\n )\n wy--;\n // в толще у поверхности; житель подводной постройки — на любой глубине\n y = def.spawn!.structure ? randInt(8, Math.max(8, wy)) : wy - randInt(0, 2);\n } else if (med === \"lava\")\n y = em.world.generator.waterLevel + 1; // страйдер — на лавовом море\n else if (med === \"air\")\n // летучая мышь — в воздухе пещер, фантом — высоко над поверхностью\n y = def.spawn!.underground\n ? 5 + randInt(0, Math.max(0, em.world.surfaceY(x, z) - 12))\n : em.world.surfaceY(x, z) + randInt(12, 20);\n if (!canSpawn(em, 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 && !canSpawn(em, def, sx, y, sz)) continue;\n em.spawnMob(def.name, sx + 0.5, y + 0.01, sz + 0.5);\n spawned++;\n }\n return spawned;\n}\n\nexport function canSpawn(\n em: EntityManager,\n def: MobDef,\n x: number,\n y: number,\n z: number,\n): boolean {\n const w = em.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 && !em.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)) 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 && !em._blockNear(x, y, z, B[sp.nearBlock], 4))\n return false;\n const l = w.getLight(x, y, z);\n const sky = (l >> 4) - em.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) - em.skyDarken,\n blk = l & 15;\n const s = def.spawn!;\n if (s.maxY !== undefined && y > s.maxY) return false;\n if (s.structure && em.world.generator.structureAt?.(x, y, z) !== s.structure)\n return false;\n if (s.nearWater && !em._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"
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
"path": "entities/manager/combat.ts",
|
|
184
|
+
"content": "import * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { BLOCKS, B } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { dropsOf } from \"../../systems/Mining\";\nimport { explodeBlocks, explosionDamage } from \"../../systems/Explosion\";\nimport { raycastBlocks } from \"../../world/Raycast\";\nimport { Mob } from \"../Mob\";\nimport type { Player } from \"../../player/Player\";\nimport { randInt } from \"./shared\";\nimport type { EntityManager } from \"../EntityManager\";\n\nexport function angerGroup(em: EntityManager, m: Mob): void {\n m.angerCall = false;\n for (const e of em.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\nexport function hurt(\n em: EntityManager,\n entityId: number,\n damage: number,\n fromPos: THREE.Vector3,\n attacker: Player = em.player,\n): void {\n if (em.replica && !em._netApplying) {\n em.onRemoteAction?.({\n a: \"hurt\",\n id: entityId,\n damage,\n from: [fromPos.x, fromPos.y, fromPos.z],\n });\n return;\n }\n const e = em.entities.get(entityId);\n if (!e || e.kind !== \"mob\" || e.dead) return;\n // дракон: по голове — полный урон, по телу — четверть (как в Minecraft)\n if (e.def.boss === \"dragon\" && !headHit(em, e, attacker))\n damage = Math.max(1, damage / 4);\n e.hurt(damage, fromPos, true);\n // шипы глубинника: бьёшь, пока он не заряжает луч, — укол в ответ\n if (e.def.thorns && e.beamT <= 0) attacker.damage(e.def.thorns, \"thorns\");\n}\n\nexport function headHit(\n em: EntityManager,\n m: Mob,\n who: Player = em.player,\n): 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 = who.eyePos,\n dir = who.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\nexport function explode(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n power: number,\n): void {\n // реплика (net/sim): взрыв от действия игрока (кровать в Пекле) считает владелец измерения\n if (em.replica && !em._netApplying) {\n em.onRemoteAction?.({ a: \"explode\", x, y, z, power });\n return;\n }\n const destroyed = explodeBlocks(em.world, x, y, z, power);\n for (const b of destroyed)\n // соседняя взрывчатка взводится с коротким фитилём — цепная реакция\n if (b.id === B.tnt)\n em.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 em.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(em.world, center, dir, len) ? 0.3 : 1;\n const impact = (1 - dist / R) * exposure;\n apply(explosionDamage(impact, power), dir, impact);\n };\n // все игроки измерения: у удалённого урон и отброс уйдут ему по сети (net/RemotePlayer)\n for (const { player } of em.actors())\n blast(player.pos, 1.8, (dmg, dir, impact) => {\n player.damage(dmg, \"explosion\");\n player.vel.addScaledVector(dir, impact * 12);\n });\n for (const e of em.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\nexport function mobDied(em: EntityManager, 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 (em.player.effects.get(\"bad_omen\")?.level ?? 0) + 1,\n );\n em.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 em.entities.values())\n if (e.kind === \"mob\" && e.def.boss && e.healFrom === m.id) {\n e.healFrom = null;\n e.damage(10);\n }\n explode(em, 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 em.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 em.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 em.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 em.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"
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"path": "entities/manager/mobContext.ts",
|
|
188
|
+
"content": "import { events } from \"../../core/EventBus\";\nimport { BLOCKS, B } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { MOBS } from \"../../registry/Mobs\";\nimport { dropsOf } from \"../../systems/Mining\";\nimport { Mob } from \"../Mob\";\nimport { ItemDrop } from \"../ItemDrop\";\nimport type { MobCtx } from \"../MobBrain\";\nimport { randInt } from \"./shared\";\nimport type { EntityManager } from \"../EntityManager\";\n\nexport function ctx(em: EntityManager): MobCtx {\n const held = em.inventory.heldItem();\n return {\n world: em.world,\n player: em.player,\n isDay: em.isDay,\n skyDarken: em.skyDarken,\n raining: em.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 em.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 em.spawnFireball(\n kind,\n from.clone().addScaledVector(d, 0.6),\n d.clone().multiplyScalar(speed),\n damage,\n shooter.id,\n );\n else\n em.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) => em.convertToZombie(villager),\n crystals: () => {\n const out: Mob[] = [];\n for (const e of em.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) => em.explode(x, y, z, p),\n summon: (type, x, y, z) => (MOBS[type] ? em.spawnMob(type, x, y, z) : null),\n fangs: (from, to, owner) => em.spawnFangs(from, to, owner.id),\n // владыка ломает блоки вокруг себя — с дропом; неломаемые (бедрок, порталы) и жидкости стоят\n breakBlock: (x, y, z) => {\n const id = em.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 = em.world.getMeta(x, y, z);\n em.world.setBlock(x, y, z, B.air!);\n for (const d of dropsOf(def, meta))\n if (ITEMS[d.item]) em.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n return true;\n },\n mob: (id) => {\n const e = em.entities.get(id);\n return e && e.kind === \"mob\" && !e.dead ? e : null;\n },\n near: (pos, r, pred) => {\n for (const e of em.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.pos.distanceTo(pos) < r && pred(e))\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 em.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 = em.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 em.spawnDrop(x, y, z, item, count);\n },\n findMate: (m) => {\n let best: Mob | null = null,\n bd = 64;\n for (const e of em.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 em.layEggs(a); // черепаха — яйцами на песок\n else\n em.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 em.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"
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
"path": "entities/manager/net.ts",
|
|
192
|
+
"content": "import * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { XpOrb } from \"../XpOrb\";\nimport { Arrow } from \"../Arrow\";\nimport { CURE_SECONDS } from \"../../systems/Zombify\";\nimport { sanitizeStack } from \"../../systems/Inventory\";\nimport type { ItemStack } from \"../../types\";\nimport type { Entity, NetEntitySnap, NetEntityAction } from \"./types\";\nimport { validPos } from \"./shared\";\nimport type { EntityManager } from \"../EntityManager\";\n\n/** Бит «на ней кто-то сидит» во флагах позы сущности (net/protocol ENT_POSE). */\nconst POSE_RIDDEN = 128;\n\n/**\n * Поля сущности, которые меняет ДЕЙСТВИЕ игрока, а не мозг: их реплика отдаёт владельцу измерения.\n * Любовь и лечение — флагами: сами таймеры тикают у владельца, реплика лишь сообщает «накормил».\n */\nfunction interactState(e: Entity): Record<string, unknown> | null {\n if (e.kind === \"minecart\") return e.items.length ? { items: e.items } : null;\n if (e.kind !== \"mob\") return null;\n return {\n sheared: e.sheared,\n tamed: e.tamed,\n sitting: e.sitting,\n saddled: e.saddled,\n woolColor: e.woolColor,\n wolfArmor: e.wolfArmor,\n carry: e.carry ? e.carry.item : null,\n bag: e.bag,\n items: e.items,\n leashed: e.leashed,\n leashPost: e.leashPost,\n leashMob: e.leashMobId !== null,\n love: e.loveT > 0,\n cure: e.curing > 0,\n name: e.customName,\n uses: e.uses,\n traded: e.traded,\n villagerXp: e.villagerXp,\n };\n}\n\nexport function netSnapshot(\n em: EntityManager,\n e: Entity,\n): NetEntitySnap | null {\n const v3 = (v: THREE.Vector3): [number, number, number] => [v.x, v.y, v.z];\n switch (e.kind) {\n case \"mob\":\n case \"minecart\":\n case \"boat\":\n return e.serialize() as NetEntitySnap;\n case \"drop\":\n return {\n kind: \"drop\",\n pos: v3(e.pos),\n item: e.item,\n count: e.count,\n ...(e.dmg ? { dmg: e.dmg } : {}),\n ...(e.tag ? { tag: e.tag } : {}),\n vel: v3(e.vel),\n pickupDelay: Math.max(0, e.pickupDelay - e.age),\n };\n case \"xp\":\n return { kind: \"xp\", pos: v3(e.pos), value: e.value };\n case \"arrow\":\n return {\n kind: \"arrow\",\n pos: v3(e.pos),\n vel: v3(e.vel),\n damage: e.damage,\n fromPlayer: e.fromPlayer,\n stuck: e.stuck,\n effect: e.effect,\n pick: e.pick,\n };\n case \"fireball\":\n return {\n kind: \"fireball\",\n ballKind: e.ballKind,\n pos: v3(e.pos),\n vel: v3(e.vel),\n damage: e.damage,\n shooterId: e.shooterId,\n };\n case \"tnt\":\n return { kind: \"tnt\", pos: v3(e.pos), fuse: e.fuse };\n default:\n return null;\n }\n}\n\nexport function createFromSnap(\n em: EntityManager,\n s: NetEntitySnap,\n): Entity | null {\n if (!validPos(s)) return null;\n const at = new THREE.Vector3(s.pos[0], s.pos[1], s.pos[2]);\n const vel = (v: unknown) =>\n Array.isArray(v) && v.length === 3 && v.every(Number.isFinite)\n ? new THREE.Vector3(v[0], v[1], v[2])\n : new THREE.Vector3();\n switch (s.kind) {\n case \"drop\": {\n if (!ITEMS[s.item] || !(s.count > 0)) return null;\n const d = em.spawnDrop(at.x, at.y, at.z, s.item, s.count, s.dmg, s.tag);\n d.vel.copy(vel((s as { vel?: unknown }).vel));\n const delay = (s as { pickupDelay?: unknown }).pickupDelay;\n if (typeof delay === \"number\" && Number.isFinite(delay))\n d.pickupDelay = delay;\n return d;\n }\n case \"xp\": {\n const orb = new XpOrb(\n em._nextId++,\n at.x,\n at.y,\n at.z,\n Math.max(1, Math.round(s.value) || 1),\n );\n return em._register(orb, orb.mesh);\n }\n case \"arrow\": {\n const a = em.spawnArrow(\n at,\n vel(s.vel),\n Number(s.damage) || 0,\n !!s.fromPlayer,\n -1,\n );\n if (s.stuck) a.stuck = true;\n if (s.effect) a.effect = s.effect as Arrow[\"effect\"];\n if (s.pick && ITEMS[s.pick.item])\n a.pick = sanitizeStack(s.pick) ?? a.pick;\n return a;\n }\n case \"fireball\":\n return em.spawnFireball(\n s.ballKind,\n at,\n vel(s.vel),\n Number(s.damage) || 0,\n s.shooterId,\n );\n case \"tnt\":\n return em.spawnTnt(at.x, at.y, at.z, Math.max(0, Number(s.fuse) || 0));\n default:\n return em._loadOne(s);\n }\n}\n\nexport function netSpawnFromRemote(\n em: EntityManager,\n s: NetEntitySnap,\n): Entity | null {\n return createFromSnap(em, s);\n}\n\nexport function netApplySpawn(\n em: EntityManager,\n id: number,\n s: NetEntitySnap,\n): void {\n // в ней сидит свой игрок: объект не подменяем (Player.riding держит ссылку), ведём сами\n if (id === em.drivenId && em.entities.has(id)) return;\n netRemove(em, id);\n em._netApplying = true;\n let e: Entity | null;\n try {\n e = createFromSnap(em, s);\n } finally {\n em._netApplying = false;\n }\n if (!e) return;\n em.entities.delete(e.id);\n e.id = id;\n em.entities.set(id, e);\n em._netTargets.set(id, {\n x: e.pos.x,\n y: e.pos.y,\n z: e.pos.z,\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n rebase(em, e);\n}\n\nexport function rebase(em: EntityManager, e: Entity): void {\n const st = interactState(e);\n if (st) em._netBase.set(e.id, JSON.stringify(st));\n else em._netBase.delete(e.id);\n}\n\nexport function netApplyPose(\n em: EntityManager,\n id: number,\n x: number,\n y: number,\n z: number,\n yaw: number,\n flags: number,\n extra: number,\n): void {\n const e = em.entities.get(id);\n if (!e || id === em.drivenId) return;\n if (e.kind === \"minecart\" || e.kind === \"boat\")\n e.rider = (flags & POSE_RIDDEN) !== 0;\n const t = em._netTargets.get(id);\n if (t) {\n t.x = x;\n t.y = y;\n t.z = z;\n t.yaw = yaw;\n } else em._netTargets.set(id, { x, y, z, yaw });\n if (e.kind === \"mob\") {\n e.onGround = (flags & 1) !== 0;\n if (flags & 2 && e.swing <= 0) e.swing = 0.3;\n e.sheared = (flags & 4) !== 0;\n e.sitting = (flags & 8) !== 0;\n e.tamed = (flags & 16) !== 0;\n e.saddled = (flags & 32) !== 0;\n e.invisible = (flags & 64) !== 0;\n e.rider = (flags & POSE_RIDDEN) !== 0;\n if (e.def.explode) e.fuse = (extra / 255) * e.def.explode.fuse;\n else e.peek = extra / 255;\n // поза несёт только эти поля — и только их она делает «словом владельца»: правка из экрана\n // (торговля, груз, имя), сделанная между тиками, иначе приняла бы вид чужой и не ушла бы\n const base = em._netBase.get(id);\n if (base) {\n const b = JSON.parse(base) as Record<string, unknown>;\n b.sheared = e.sheared;\n b.sitting = e.sitting;\n b.tamed = e.tamed;\n b.saddled = e.saddled;\n em._netBase.set(id, JSON.stringify(b));\n }\n }\n}\n\nexport function netPose(\n em: EntityManager,\n e: Entity,\n): { yaw: number; flags: number; extra: number } {\n if (e.kind === \"minecart\" || e.kind === \"boat\")\n return { yaw: 0, flags: e.rider ? POSE_RIDDEN : 0, extra: 0 };\n if (e.kind !== \"mob\") return { yaw: 0, flags: 0, extra: 0 };\n const flags =\n (e.onGround ? 1 : 0) |\n (e.swing > 0 ? 2 : 0) |\n (e.sheared ? 4 : 0) |\n (e.sitting ? 8 : 0) |\n (e.tamed ? 16 : 0) |\n (e.saddled ? 32 : 0) |\n (e.invisible ? 64 : 0) |\n (e.rider ? POSE_RIDDEN : 0);\n const extra = e.def.explode\n ? Math.round(Math.min(1, Math.max(0, e.fuse / e.def.explode.fuse)) * 255)\n : Math.round(Math.min(1, Math.max(0, e.peek)) * 255);\n return { yaw: e.yaw, flags, extra };\n}\n\nexport function netRemove(em: EntityManager, id: number): void {\n const e = em.entities.get(id);\n em._netTargets.delete(id);\n em._netBase.delete(id);\n if (!e) return;\n e.dead = true;\n e.dispose(em.scene);\n em.entities.delete(id);\n}\n\nexport function replicaTick(em: EntityManager, dt: number): void {\n for (const e of em.entities.values()) {\n if (e.id === em.drivenId && !e.dead) {\n // в ней сидит свой игрок: настоящая физика здесь, позу владельцу шлёт NetGame\n if (e.kind === \"mob\") e.tick(dt, em._ctx());\n else if (e.kind === \"minecart\" || e.kind === \"boat\") e.tick(dt, em.world);\n } else if (e.kind === \"hook\") {\n e.tick(\n dt,\n em.world,\n em.player.pos,\n em.raining,\n em.inventory.heldItem()?.item === \"fishing_rod\",\n );\n } else if (\n e.kind === \"fireball\" &&\n e.ballKind === \"pearl\" &&\n !em._netTargets.has(e.id)\n ) {\n e.tick(\n dt,\n em.world,\n em.player,\n (x, y, z, p) => em.explode(x, y, z, p),\n [],\n );\n } else {\n const t = em._netTargets.get(e.id);\n if (\"prevPos\" in e) e.prevPos.copy(e.pos);\n if (t) {\n const dx = t.x - e.pos.x,\n dy = t.y - e.pos.y,\n dz = t.z - e.pos.z;\n // далёкий скачок (телепорт, первая поза) — сразу; иначе полпути за тик\n if (dx * dx + dy * dy + dz * dz > 64) e.pos.set(t.x, t.y, t.z);\n else\n e.pos.set(e.pos.x + dx * 0.5, e.pos.y + dy * 0.5, e.pos.z + dz * 0.5);\n if (e.kind === \"mob\") {\n let dyaw = t.yaw - e.yaw;\n while (dyaw > Math.PI) dyaw -= Math.PI * 2;\n while (dyaw < -Math.PI) dyaw += Math.PI * 2;\n e.yaw += dyaw * 0.5;\n if (dx * dx + dz * dz > 1e-4) e.animTime += dt;\n if (e.swing > 0) e.swing = Math.max(0, e.swing - dt);\n }\n }\n // «накормил» гаснет и здесь, иначе второй раз того же зверя не покормить; это не наша правка\n if (e.kind === \"mob\" && e.loveT > 0) {\n e.loveT = Math.max(0, e.loveT - dt);\n if (e.loveT === 0) rebase(em, e);\n }\n }\n if (e.dead) {\n // образ сущности владельца погиб ЗДЕСЬ (/kill, рыба в ведро, превращение) — пусть и там\n if (em._netTargets.has(e.id))\n em.onRemoteAction?.({\n a: \"kill\",\n id: e.id,\n drops: e.kind === \"mob\" && e.hp <= 0,\n });\n e.dispose(em.scene);\n em.entities.delete(e.id);\n em._netTargets.delete(e.id);\n em._netBase.delete(e.id);\n }\n }\n}\n\nexport function netLocalChanges(em: EntityManager): NetEntityAction[] {\n const out: NetEntityAction[] = [];\n for (const [id, base] of em._netBase) {\n const e = em.entities.get(id);\n if (!e || e.dead) continue;\n const st = interactState(e);\n if (!st) continue;\n const cur = JSON.stringify(st);\n if (cur === base) continue;\n const prev = JSON.parse(base) as Record<string, unknown>;\n const fields: Record<string, unknown> = {};\n for (const k of Object.keys(st))\n if (JSON.stringify(st[k]) !== JSON.stringify(prev[k])) fields[k] = st[k];\n em._netBase.set(id, cur);\n out.push({ a: \"patch\", id, fields });\n }\n return out;\n}\n\nexport function netPatch(\n em: EntityManager,\n id: number,\n f: Record<string, unknown>,\n slot?: number,\n): void {\n const e = em.entities.get(id);\n if (!e || e.dead) return;\n const slots = (v: unknown, n: number): (ItemStack | null)[] | null =>\n Array.isArray(v) && v.length === n ? v.map((s) => sanitizeStack(s)) : null;\n if (e.kind === \"minecart\") {\n const items = slots(f.items, e.items.length);\n if (items) e.items = items;\n return;\n }\n if (e.kind !== \"mob\") return;\n const bool = (k: string): boolean | undefined =>\n typeof f[k] === \"boolean\" ? (f[k] as boolean) : undefined;\n const num = (k: string): number | undefined =>\n typeof f[k] === \"number\" && Number.isFinite(f[k])\n ? Math.max(0, f[k] as number)\n : undefined;\n const v = {\n sheared: bool(\"sheared\"),\n tamed: bool(\"tamed\"),\n sitting: bool(\"sitting\"),\n saddled: bool(\"saddled\"),\n leashed: bool(\"leashed\"),\n traded: bool(\"traded\"),\n };\n if (v.sheared !== undefined) e.sheared = v.sheared;\n if (v.tamed !== undefined) {\n e.tamed = v.tamed;\n if (e.tamed) e.aggro = false;\n }\n if (v.sitting !== undefined) {\n e.sitting = v.sitting;\n e.speed = 0;\n }\n if (v.saddled !== undefined) e.saddled = v.saddled;\n if (v.traded !== undefined) e.traded = v.traded;\n if (\"woolColor\" in f)\n e.woolColor =\n typeof f.woolColor === \"string\" && f.woolColor.length < 32\n ? f.woolColor\n : null;\n const armor = num(\"wolfArmor\");\n if (armor !== undefined) e.wolfArmor = Math.floor(armor);\n if (\"carry\" in f)\n e.carry =\n typeof f.carry === \"string\" && ITEMS[f.carry]\n ? { item: f.carry, count: 1 }\n : null;\n const bag = num(\"bag\");\n if (bag !== undefined) e.bag = Math.floor(bag);\n if (\"items\" in f && e.def.chestSlots) {\n if (Array.isArray(f.items) && f.items.length === 0) e.items = [];\n else {\n const items = slots(f.items, e.def.chestSlots);\n if (items) e.items = items;\n }\n }\n if (v.leashed !== undefined) {\n e.leashed = v.leashed;\n if (!e.leashed) {\n e.leashPost = null;\n e.leashMobId = null;\n e.leashSlot = null;\n } else if (!Array.isArray(f.leashPost) && !e.leashPost)\n e.leashSlot = slot ?? null;\n }\n if (\"leashPost\" in f) {\n const lp = f.leashPost;\n e.leashPost =\n Array.isArray(lp) && lp.length === 3 && lp.every(Number.isFinite)\n ? [lp[0] as number, lp[1] as number, lp[2] as number]\n : null;\n }\n if (f.leashMob === false) e.leashMobId = null;\n // накормил / начал лечить: условия проверяет владелец — его таймеры точнее образа реплики\n if (f.love === true && !e.baby && e.loveT <= 0 && e.breedCd <= 0) {\n e.loveT = 30;\n events.emit(\"mobFed\", { entity: e });\n }\n if (f.cure === true && e.def.curable && e.curing <= 0)\n e.curing = CURE_SECONDS;\n if (\"name\" in f) e.setName(typeof f.name === \"string\" ? f.name : null);\n if (Array.isArray(f.uses))\n e.uses = f.uses.map((n) =>\n typeof n === \"number\" && Number.isFinite(n)\n ? Math.max(0, Math.floor(n))\n : 0,\n );\n const xp = num(\"villagerXp\");\n if (xp !== undefined) e.villagerXp = Math.floor(xp);\n}\n\nexport function netKill(em: EntityManager, id: number, drops: boolean): void {\n const e = em.entities.get(id);\n if (!e || e.dead) return;\n if (drops && e.kind === \"mob\") e.damage(e.hp);\n else e.dead = true;\n}\n\nexport function netRide(\n em: EntityManager,\n id: number,\n slot: number,\n on: boolean,\n): boolean {\n const e = em.entities.get(id);\n if (\n !e ||\n e.dead ||\n !(e.kind === \"mob\" || e.kind === \"minecart\" || e.kind === \"boat\")\n )\n return !on;\n const cur = em._netDriven.get(id);\n if (!on) {\n if (cur?.slot === slot) release(em, id);\n return true;\n }\n if ((cur && cur.slot !== slot) || (em.player.riding as unknown) === e)\n return false;\n em._netDriven.set(id, { slot, t: performance.now() });\n e.rider = true;\n return true;\n}\n\nexport function netDrive(\n em: EntityManager,\n id: number,\n slot: number,\n pos: readonly number[],\n vel: readonly number[],\n yaw: number,\n): void {\n const cur = em._netDriven.get(id);\n const e = em.entities.get(id);\n if (!cur || cur.slot !== slot || !e || e.dead) return;\n if (pos.length !== 3 || !pos.every(Number.isFinite)) return;\n if (\"prevPos\" in e) e.prevPos.copy(e.pos);\n e.pos.set(pos[0]!, pos[1]!, pos[2]!);\n if (\"vel\" in e && vel.length === 3 && vel.every(Number.isFinite))\n e.vel.set(vel[0]!, vel[1]!, vel[2]!);\n if (e.kind === \"mob\" && Number.isFinite(yaw)) e.yaw = e.targetYaw = yaw;\n cur.t = performance.now();\n}\n\nexport function netReleaseDriver(em: EntityManager, slot: number): void {\n for (const [id, d] of [...em._netDriven])\n if (d.slot === slot) release(em, id);\n}\n\nexport function release(em: EntityManager, id: number): void {\n em._netDriven.delete(id);\n const e = em.entities.get(id);\n if (e && (e.kind === \"mob\" || e.kind === \"minecart\" || e.kind === \"boat\"))\n e.rider = false;\n}\n"
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
"path": "entities/manager/persistence.ts",
|
|
196
|
+
"content": "import { ITEMS } from \"../../registry/Items\";\nimport { MOBS } from \"../../registry/Mobs\";\nimport { CART_VARIANTS } from \"../Minecart\";\nimport { sanitizeStack } from \"../../systems/Inventory\";\nimport type { Entity, EntitySave } from \"./types\";\nimport { validPos } from \"./shared\";\nimport type { EntityManager } from \"../EntityManager\";\n\nexport function serialize(em: EntityManager) {\n const out: unknown[] = [];\n for (const e of em.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\nexport function deserialize(em: EntityManager, list?: EntitySave[]): void {\n em.clear();\n for (const d of Array.isArray(list) ? list : []) loadOne(em, d);\n}\n\nexport function loadOne(em: EntityManager, d: EntitySave): Entity | null {\n // запись из чужого файла (Мастерская, другая версия): null или без координат ронял загрузку\n // посреди замены мира — мир уже новый, а сущности и блоки ещё старые\n if (!validPos(d)) return null;\n {\n if (d.kind === \"mob\" && MOBS[d.type]) {\n const m = em.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.leashSlot === \"number\") m.leashSlot = d.leashSlot;\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 return m;\n } else if (d.kind === \"drop\") {\n // предмет, которого нет в игре (чужая версия сейва), рисовать нечем — ItemMesh упал бы\n if (!ITEMS[d.item] || !(d.count > 0)) return null;\n const drop = em.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 return drop;\n } else if (d.kind === \"xp\") {\n em.spawnXp(d.pos[0], d.pos[1], d.pos[2], d.value);\n return null;\n } else if (d.kind === \"minecart\") {\n const variant =\n d.variant && CART_VARIANTS.includes(d.variant) ? d.variant : \"plain\";\n const c = em.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 return c;\n } else if (d.kind === \"boat\")\n return em.spawnBoat(d.pos[0], d.pos[1], d.pos[2], d.wood ?? \"oak\");\n }\n return null;\n}\n"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"path": "entities/manager/shared.ts",
|
|
200
|
+
"content": "import { B } from \"../../registry/Blocks\";\nimport { INFESTED_NAMES } from \"../../registry/blocks/infested\";\n\n/** id заражённых блоков — из них вылезает чешуйница. */\nexport const INFESTED_IDS = new Set(\n INFESTED_NAMES.map((n) => B[n]).filter(\n (id): id is number => id !== undefined,\n ),\n);\n\nexport const randInt = (a: number, b: number): number =>\n a + Math.floor(Math.random() * (b - a + 1));\n\n/** Координаты записи годны: чужой файл (Мастерская, другая версия, сеть) мог прислать что угодно. */\nexport function validPos(d: unknown): boolean {\n const pos: unknown = (d as { pos?: unknown } | null)?.pos;\n return (\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}\n"
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
"path": "entities/manager/spawners.ts",
|
|
204
|
+
"content": "import * 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 { MOBS } from \"../../registry/Mobs\";\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 { Minecart, type CartVariant } from \"../Minecart\";\nimport { Fang } from \"../Fang\";\nimport { Boat } from \"../Boat\";\nimport { FishingHook } from \"../FishingHook\";\nimport { findAltar } from \"../AshLord\";\nimport { startCharge } from \"../AshLordBrain\";\nimport { isSnowGolem } from \"../SnowGolemBrain\";\nimport { isIronGolem } from \"../../systems/Golem\";\nimport type { ItemStack } from \"../../types\";\nimport { randInt } from \"./shared\";\nimport type { EntityManager } from \"../EntityManager\";\n\nexport function markRaiders(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n): void {\n const at = new THREE.Vector3(x + 0.5, y + 0.5, z + 0.5);\n for (const e of em.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.raider && e.pos.distanceTo(at) < 32)\n e.glowT = 3;\n}\n\nexport function summonAtAltar(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n): Mob | null {\n const a = findAltar((bx, by, bz) => em.world.getBlock(bx, by, bz), x, y, z);\n if (!a || !MOBS.ash_lord) return null;\n for (const [cx, cy, cz] of a.cells) em.world.setBlock(cx, cy, cz, B.air!);\n const m = spawnMob(em, \"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\nexport function buildIronGolem(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n): Mob | null {\n const get = (bx: number, by: number, bz: number) =>\n em.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 em.world.setBlock(cx, cy, cz, B.air!);\n const m = spawnMob(em, \"iron_golem\", x + 0.5, y - 2, z + 0.5);\n events.emit(\"ironGolemBuilt\", { x, y, z });\n return m;\n}\n\nexport function buildSnowGolem(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n): Mob | null {\n if (\n !MOBS.snow_golem ||\n !isSnowGolem((bx, by, bz) => em.world.getBlock(bx, by, bz), x, y, z)\n )\n return null;\n for (let k = 0; k < 3; k++) em.world.setBlock(x, y - k, z, B.air!);\n const m = spawnMob(em, \"snow_golem\", x + 0.5, y - 2, z + 0.5);\n events.emit(\"snowGolemBuilt\", { x, y, z });\n return m;\n}\n\nexport function spawnFangs(\n em: EntityManager,\n from: THREE.Vector3,\n to: THREE.Vector3,\n ownerId: number,\n): 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[em.world.getBlock(Math.floor(x), y - 1, Math.floor(z))]?.solid;\n k++\n )\n y--;\n const f = new Fang(em._nextId++, x, y, z, i * 0.05, ownerId);\n em._register(f, f.mesh);\n }\n}\n\nexport function spawnWanderingTrader(em: EntityManager): Mob | null {\n if (em.world.dimension !== \"overworld\" || !MOBS.wandering_trader) return null;\n const p = em.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 = em.world.surfaceY(x, z);\n const g = BLOCKS[em.world.getBlock(x, top, z)];\n if (\n !g?.solid ||\n g.liquid ||\n em.world.getBlock(x, top + 1, z) !== 0 ||\n em.world.getBlock(x, top + 2, z) !== 0\n )\n continue;\n const t = spawnMob(em, \"wandering_trader\", x + 0.5, top + 1, z + 0.5);\n for (const dx of [-1.5, 1.5]) {\n const l = spawnMob(em, \"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\nexport function spawnPatrol(em: EntityManager): number {\n if (em.world.dimension !== \"overworld\" || !MOBS.marauder) return 0;\n const p = em.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 = em.world.surfaceY(x, z);\n if (\n !BLOCKS[em.world.getBlock(x, top, z)]?.solid ||\n em.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 = spawnMob(\n em,\n \"marauder\",\n sx + 0.5,\n em.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\nexport function layEggs(em: EntityManager, 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(em.world.getBlock(x, y - 1, z)) ||\n em.world.getBlock(x, y, z) !== B.air\n )\n continue;\n em.world.setBlock(x, y, z, egg, 0);\n left--;\n }\n events.emit(\"eggsLaid\", { entity: m });\n return left;\n}\n\nexport function spawnBee(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n): 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 (em.world.getBlock(x + dx, y + dy, z + dz) !== 0) continue;\n const b = spawnMob(em, \"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\nexport function spawnMinecart(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n): Minecart {\n const c = new Minecart(em._nextId++, x, y, z, variant);\n return em._register(c, c.mesh);\n}\n\nexport function spawnBoat(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n wood: string,\n): Boat {\n const b = new Boat(em._nextId++, x, y, z, wood);\n return em._register(b, b.mesh);\n}\n\nexport function castHook(\n em: EntityManager,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n): FishingHook {\n const old = playerHook(em);\n if (old) old.dead = true;\n const h = new FishingHook(em._nextId++, pos, vel);\n return em._register(h, h.mesh, h.line);\n}\n\nexport function playerHook(em: EntityManager): FishingHook | null {\n for (const e of em.entities.values())\n if (e.kind === \"hook\" && !e.dead) return e;\n return null;\n}\n\nexport function spawnDrop(\n em: EntityManager,\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 // (net/sim) не сливаем и не вытесняем: её дропы — образы чужих, решает владелец измерения.\n const mergeable =\n !em.replica && !dmg && !tag && (ITEMS[item]?.maxStack ?? 64) > 1;\n for (const e of mergeable ? em.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 = em.replica\n ? []\n : [...em.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(em._nextId++, em.atlas, x, y, z, item, count);\n if (dmg) drop.dmg = dmg;\n if (tag) drop.tag = tag;\n return em._register(drop, drop.mesh);\n}\n\nexport function takeDrop(\n em: EntityManager,\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 em.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\nexport function ejectItem(\n em: EntityManager,\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 = spawnDrop(em, x, y, z, s.item, s.count, s.dmg, s.tag);\n d.vel.set(vx, vy, vz);\n d.pickupDelay = 1;\n}\n\nexport function shootArrow(\n em: EntityManager,\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 = spawnArrow(\n em,\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\nexport function spawnMob(\n em: EntityManager,\n type: string,\n x: number,\n y: number,\n z: number,\n baby = false,\n): Mob {\n const mob = new Mob(em._nextId++, MOBS[type]!, x, y, z, baby);\n return em._register(mob, mob.mesh);\n}\n\nexport function spawnXp(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n amount: number,\n): 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(em._nextId++, x, y, z, v);\n em._register(orb, orb.mesh);\n left -= v;\n }\n}\n\nexport function spawnArrow(\n em: EntityManager,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n fromPlayer: boolean,\n shooterId: number,\n): Arrow {\n const a = new Arrow(em._nextId++, pos, vel, damage, fromPlayer, shooterId);\n return em._register(a, a.mesh);\n}\n\nexport function spawnFireball(\n em: EntityManager,\n kind: FireballKind,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n shooterId: number,\n): Fireball {\n const f = new Fireball(em._nextId++, kind, pos, vel, damage, shooterId);\n return em._register(f, f.mesh);\n}\n\nexport function spawnRimEye(\n em: EntityManager,\n from: THREE.Vector3,\n target: { x: number; z: number },\n): RimEye {\n const e = new RimEye(em._nextId++, from, target);\n em._register(e, e.mesh);\n events.emit(\"eyeThrown\", { x: from.x, y: from.y, z: from.z });\n return e;\n}\n\nexport function spawnTnt(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n fuse: number,\n): PrimedTnt {\n const t = new PrimedTnt(em._nextId++, em.atlas, x, y, z, fuse);\n em._register(t, t.mesh);\n events.emit(\"tntFuse\", { x, y, z });\n return t;\n}\n"
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
"path": "entities/manager/types.ts",
|
|
208
|
+
"content": "import { Mob } from \"../Mob\";\nimport { ItemDrop } from \"../ItemDrop\";\nimport { XpOrb } from \"../XpOrb\";\nimport { Arrow } from \"../Arrow\";\nimport { PrimedTnt } from \"../PrimedTnt\";\nimport { Fireball, type FireballKind } from \"../Fireball\";\nimport { RimEye } from \"../RimEye\";\nimport { Minecart, type CartVariant } from \"../Minecart\";\nimport { Fang } from \"../Fang\";\nimport { Boat } from \"../Boat\";\nimport { FishingHook } from \"../FishingHook\";\nimport type { Player } from \"../../player/Player\";\nimport type { Inventory } from \"../../systems/Inventory\";\nimport type { ItemStack } from \"../../types\";\n\nexport type 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`. Стрелы не сохраняются.\nexport type 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 leashSlot?: 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\n/**\n * Игрок в этом измерении, с которым работают сущности: мобы целятся, предметы подбираются, взрыв\n * бьёт. Локальный — `player`/`inventory` менеджера; удалённые (сетевая игра) — `others()`.\n */\nexport interface Actor {\n player: Player;\n inventory: Inventory;\n}\n\n/** Сущность в сети: вид + всё, что нужно, чтобы построить её у другого игрока. */\nexport type NetEntitySnap =\n | EntitySave\n | {\n kind: \"arrow\";\n pos: [number, number, number];\n vel: [number, number, number];\n damage: number;\n fromPlayer: boolean;\n stuck?: boolean;\n effect?: { id: string; seconds: number } | null;\n pick?: ItemStack;\n }\n | {\n kind: \"fireball\";\n ballKind: FireballKind;\n pos: [number, number, number];\n vel: [number, number, number];\n damage: number;\n shooterId: number;\n }\n | { kind: \"tnt\"; pos: [number, number, number]; fuse: number }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n dmg?: number;\n tag?: Record<string, unknown>;\n vel?: [number, number, number];\n pickupDelay?: number;\n };\n\n/** Действие игрока-реплики над сущностями — исполняет владелец измерения (net/sim). */\nexport type NetEntityAction =\n | { a: \"spawn\"; snap: NetEntitySnap }\n | { a: \"hurt\"; id: number; damage: number; from: [number, number, number] }\n | { a: \"explode\"; x: number; y: number; z: number; power: number }\n /** Игрок-реплика поменял сущность действием (остриг, оседлал, назвал, поторговал): только поля, что изменились. */\n | { a: \"patch\"; id: number; fields: Record<string, unknown> }\n /** Образ погиб у реплики (/kill, рыба в ведро): drops — смерть с дропом, иначе просто исчезла. */\n | { a: \"kill\"; id: number; drops: boolean }\n /** Сел (on) или слез: пока сидит, сущность ведёт он сам. */\n | { a: \"ride\"; id: number; on: boolean }\n /** Поза сущности, которую ведёт реплика. */\n | {\n a: \"drive\";\n id: number;\n pos: [number, number, number];\n vel: [number, number, number];\n yaw: number;\n };\n"
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
"path": "entities/manager/vehicles.ts",
|
|
212
|
+
"content": "import * as THREE from \"three\";\nimport { rayBox } from \"../../physics/rayBox\";\nimport { Mob } from \"../Mob\";\nimport { Minecart } from \"../Minecart\";\nimport { extractOne, insertOne } from \"../../world/Containers\";\nimport { HOPPER_COOLDOWN } from \"../../world/blockentities/hopper\";\nimport type { BlockEntity } from \"../../world/BlockEntities\";\nimport { Boat } from \"../Boat\";\nimport type { ItemStack } from \"../../types\";\nimport type { EntityManager } from \"../EntityManager\";\n\nexport function cartBox(em: EntityManager, c: Minecart | Mob): BlockEntity {\n return {\n type: 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\nexport function cartItems(\n em: EntityManager,\n x: number,\n y: number,\n z: number,\n): BlockEntity | null {\n for (const e of em.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 cartBox(em, e);\n return null;\n}\n\nexport function cartWork(em: EntityManager, c: Minecart, dt: number): void {\n if (c.boom) {\n c.dead = true;\n em.explode(c.pos.x, c.pos.y + 0.5, c.pos.z, 4);\n return;\n }\n if (c.onActivator && c.rider && em.player.riding === c) em.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 = cartBox(em, c);\n const into = (s: ItemStack) => insertOne(box, s, \"top\");\n const above = em.world.containerAt?.(box.x, box.y + 1, box.z);\n if (above) extractOne(above, into);\n else\n em.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\nexport function raycastRide(\n em: EntityManager,\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 em.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"
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
"path": "entities/manager/villagers.ts",
|
|
216
|
+
"content": "import { events } from \"../../core/EventBus\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { Mob } from \"../Mob\";\nimport { findJob, jobAt } from \"../../systems/Jobs\";\nimport { ZOMBIE_VILLAGER, curedTypeOf } from \"../../systems/Zombify\";\nimport {\n GOLEM_COOLDOWN,\n GOLEM_RADIUS,\n shouldSummonGolem,\n} from \"../../systems/Golem\";\nimport { REP_HIT_VILLAGER, clampRep } from \"../../systems/Reputation\";\nimport type { EntityManager } from \"../EntityManager\";\n\n/** Секунд между пополнениями товара за работой у своего блока. */\nconst VILLAGER_RESTOCK = 120;\n\nexport function addRep(em: EntityManager, by: number): void {\n em.player.villageRep = clampRep(em.player.villageRep + by);\n events.emit(\"villageRep\", { rep: em.player.villageRep });\n}\n\nexport function villageAnger(em: EntityManager, target: Mob): void {\n if (!target.def.trades || target.dead) return;\n addRep(em, REP_HIT_VILLAGER);\n for (const e of em.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\nexport function villagerJobs(em: EntityManager): void {\n const villagers: Mob[] = [];\n for (const e of em.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[em.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 reprofess(em, 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) => claimed.some((c) => c[0] === x && c[1] === y && c[2] === z),\n );\n if (!found) continue;\n claimed.push(found.site);\n reprofess(em, v, \"villager_\" + found.job, found.site);\n }\n}\n\nexport function villagerGolem(em: EntityManager): void {\n if (em._golemT > 0) {\n em._golemT -= 1;\n return;\n }\n // ⚠ кандидатов перебираем всех: в мире живёт не одна кучка жителей, и у соседней деревни свой\n // голем уже есть — по первому встречному жителю вызов не сработал бы никогда.\n const mobs: Mob[] = [];\n for (const e of em.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 = em.world.surfaceY(x, z);\n if (y <= 0) continue;\n em.spawnMob(\"iron_golem\", x + 0.5, y + 1.01, z + 0.5);\n em._golemT = GOLEM_COOLDOWN;\n events.emit(\"golemSummoned\", { x, y, z });\n return;\n }\n}\n\nexport function tickCuring(em: EntityManager): void {\n for (const e of em.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 = reprofess(em, e, curedTypeOf(e.curedType), e.jobSite);\n events.emit(\"villagerCured\", { mob: back.def.name });\n }\n}\n\nexport function convertToZombie(em: EntityManager, v: Mob): Mob {\n const z = reprofess(em, 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\nexport function reprofess(\n em: EntityManager,\n v: Mob,\n type: string,\n site: [number, number, number] | null,\n): Mob {\n const n = em.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"
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
"path": "entities/manager/visuals.ts",
|
|
220
|
+
"content": "import * as THREE from \"three\";\nimport type { EntityManager } from \"../EntityManager\";\n\nexport function updateVisuals(\n em: EntityManager,\n alpha: number,\n time: number,\n): void {\n for (const e of em.entities.values()) {\n if (e.kind === \"mob\") {\n // свет места: в пещере моб темнее (ночь приглушает свет сцены и так)\n const l = em.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 = em.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 = em.player.eyePos,\n yaw = em.player.yaw;\n const tip = eye\n .clone()\n .addScaledVector(em.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 beam(em);\n}\n\nexport function beam(em: EntityManager): void {\n let pts: THREE.Vector3[] | null = null;\n for (const e of em.entities.values()) {\n if (e.kind !== \"mob\" || !e.def.boss || e.healFrom === null) continue;\n const k = em.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 (em.beamLine) em.beamLine.visible = false;\n return;\n }\n if (!em.beamLine) {\n em.beamLine = new THREE.Line(\n new THREE.BufferGeometry(),\n new THREE.LineBasicMaterial({ color: 0xff80ff }),\n );\n em.scene.add(em.beamLine);\n }\n em.beamLine.geometry.setFromPoints(pts);\n em.beamLine.visible = true;\n}\n"
|
|
221
|
+
},
|
|
178
222
|
{
|
|
179
223
|
"path": "entities/Minecart.ts",
|
|
180
224
|
"content": "// Вагонетка, как в Minecraft: едет по рельсам (world/Rails) — скорость вдоль отрезка между выходами\n// клетки, на поворотах направление меняется без потери скорости, на подъёмах тянет гравитация.\n// Разгонные рельсы под током разгоняют до 8 блоков/с, без тока — тормозят. В тупике встаёт, без\n// рельсов падает. Игрок садится ПКМ (Player.riding), W толкает, Ctrl — выйти; удары ломают.\n// Виды: с сундуком (27 слотов) и с воронкой (5 слотов, сама собирает — EntityManager) открываются ПКМ;\n// с динамитом взводится запитанными активирующими рельсами и через 4 с взрывается.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { RAIL_POWERED, isRail, railPoints, railUnder } from \"../world/Rails\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { ItemStack } from \"../types\";\n\nexport const CART_MAX_SPEED = 8;\nconst GRAVITY = 20;\nconst POWER_ACCEL = 14;\n/** Трение за секунду (с седоком — меньше). */\nconst FRICTION = 0.35;\nconst RIDER_FRICTION = 0.15;\n/** Фитиль вагонетки с динамитом, сек. */\nexport const TNT_CART_FUSE = 4;\n\nexport type CartVariant = \"plain\" | \"chest\" | \"hopper\" | \"tnt\";\nexport const CART_VARIANTS: readonly CartVariant[] = [\n \"plain\",\n \"chest\",\n \"hopper\",\n \"tnt\",\n];\nconst DROP: Record<CartVariant, string> = {\n plain: \"minecart\",\n chest: \"chest_minecart\",\n hopper: \"hopper_minecart\",\n tnt: \"tnt_minecart\",\n};\nconst SLOTS: Record<CartVariant, number> = {\n plain: 0,\n chest: 27,\n hopper: 5,\n tnt: 0,\n};\n\nconst mat = new THREE.MeshLambertMaterial({ color: 0x6e7278 });\nconst dark = new THREE.MeshLambertMaterial({ color: 0x3a3c40 });\nconst CARGO: Partial<Record<CartVariant, THREE.MeshLambertMaterial>> = {\n chest: new THREE.MeshLambertMaterial({ color: 0x9a6a2a }),\n hopper: new THREE.MeshLambertMaterial({ color: 0x4a4a4e }),\n tnt: new THREE.MeshLambertMaterial({ color: 0xc03020 }),\n};\nconst band = new THREE.MeshLambertMaterial({ color: 0xe8e8e0 });\n\nfunction cartMesh(variant: CartVariant): THREE.Group {\n const g = new THREE.Group();\n const box = (\n w: number,\n h: number,\n d: number,\n x: number,\n y: number,\n z: number,\n m = mat,\n ) => {\n const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m);\n mesh.position.set(x, y, z);\n mesh.castShadow = true;\n g.add(mesh);\n };\n box(0.9, 0.08, 1.2, 0, 0.12, 0, dark); // дно\n box(0.9, 0.45, 0.06, 0, 0.36, 0.57);\n box(0.9, 0.45, 0.06, 0, 0.36, -0.57);\n box(0.06, 0.45, 1.2, 0.42, 0.36, 0);\n box(0.06, 0.45, 1.2, -0.42, 0.36, 0);\n for (const [x, z] of [\n [0.4, 0.4],\n [-0.4, 0.4],\n [0.4, -0.4],\n [-0.4, -0.4],\n ] as const)\n box(0.06, 0.16, 0.16, x, 0.08, z, dark); // колёса\n const cargo = CARGO[variant];\n if (cargo) {\n box(\n 0.72,\n variant === \"hopper\" ? 0.4 : 0.6,\n 0.72,\n 0,\n variant === \"hopper\" ? 0.6 : 0.5,\n 0,\n cargo,\n );\n if (variant === \"tnt\") box(0.74, 0.14, 0.74, 0, 0.5, 0, band);\n }\n return g;\n}\n\nexport class Minecart {\n id: number;\n kind = \"minecart\" as const;\n readonly variant: CartVariant;\n /** Груз вагонетки с сундуком или воронкой. */\n items: (ItemStack | null)[];\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel = new THREE.Vector3();\n /** Где нарисована (интерполяция) — за неё держится седок. */\n renderPos: THREE.Vector3;\n dead = false;\n /** В вагонетке сидит игрок. */\n rider = false;\n readonly seatY = 0.35;\n hp = 3;\n shake = 0;\n /** Динамит: секунд до взрыва (-1 — не взведён); boom — пора взрываться (EntityManager). */\n fuse = -1;\n boom = false;\n /** Стоит на запитанных активирующих рельсах. */\n onActivator = false;\n /** Вагонетка с воронкой: пауза между переносами. */\n hopCd = 0;\n private yaw = 0;\n mesh: THREE.Group;\n\n constructor(\n id: number,\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n ) {\n this.id = id;\n this.variant = variant;\n this.items = new Array<ItemStack | null>(SLOTS[variant]).fill(null);\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.renderPos = this.pos.clone();\n this.mesh = cartMesh(variant);\n this.mesh.position.copy(this.pos);\n }\n\n /** Что выпадает, когда ломают. */\n get dropItem(): string {\n return DROP[this.variant];\n }\n\n /** Седок жмёт «вперёд»: толчок в сторону взгляда. */\n push(dx: number, dz: number, dt: number): void {\n this.vel.x += dx * 5 * dt;\n this.vel.z += dz * 5 * dt;\n }\n\n /** Удар: true — сломана. */\n hit(): boolean {\n this.hp -= 1;\n this.shake = 0.3;\n if (this.hp <= 0) this.dead = true;\n return this.dead;\n }\n\n tick(dt: number, world: ChunkManager): void {\n this.prevPos.copy(this.pos);\n this.shake = Math.max(0, this.shake - dt);\n if (this.fuse >= 0) {\n this.fuse -= dt;\n if (this.fuse <= 0) this.boom = true;\n }\n const r = railUnder(world, this.pos.x, this.pos.y, this.pos.z);\n this.onActivator =\n !!r && r.id === B.activator_rail && (r.meta & RAIL_POWERED) !== 0;\n if (this.variant === \"tnt\" && this.onActivator && this.fuse < 0) {\n this.fuse = TNT_CART_FUSE;\n events.emit(\"tntFuse\", { x: this.pos.x, y: this.pos.y, z: this.pos.z });\n }\n if (!r) return this._offRail(dt, world);\n\n const [a, b] = railPoints(r.shape, r.x, r.y, r.z);\n const A = new THREE.Vector3(...a);\n const d = new THREE.Vector3(...b).sub(A);\n const len = d.length();\n d.divideScalar(len);\n\n // скорость вдоль пути: знак — куда ехали, величина сохраняется (поворот её не съедает)\n const dot = this.vel.dot(d);\n let v = Math.abs(dot) < 1e-6 ? 0 : Math.sign(dot) * this.vel.length();\n v -= GRAVITY * d.y * dt;\n if (r.id === B.powered_rail) {\n if (r.meta & RAIL_POWERED) {\n if (v !== 0) v += Math.sign(v) * POWER_ACCEL * dt;\n } else v *= Math.max(0, 1 - 8 * dt);\n }\n v *= Math.max(0, 1 - (this.rider ? RIDER_FRICTION : FRICTION) * dt);\n if (Math.abs(v) < 0.03 && d.y === 0) v = 0;\n v = Math.max(-CART_MAX_SPEED, Math.min(CART_MAX_SPEED, v));\n\n let t = Math.max(0, Math.min(len, this.pos.clone().sub(A).dot(d))) + v * dt;\n if (t < 0 || t > len) {\n // за край клетки: есть ли рельс дальше? нет — тупик, встаём внутри своей клетки\n const ex = t > len ? b : a;\n const s = t > len ? 0.05 : -0.05;\n if (\n !railUnder(world, ex[0] + d.x * s, ex[1] + d.y * s, ex[2] + d.z * s)\n ) {\n t = Math.max(0.02, Math.min(len - 0.02, t));\n v = 0;\n }\n }\n this.pos.copy(A).addScaledVector(d, t);\n this.vel.copy(d).multiplyScalar(v);\n if (Math.abs(d.x) + Math.abs(d.z) > 0.1) this.yaw = Math.atan2(d.x, d.z);\n }\n\n /** Без рельсов: падает до опоры, по земле тормозит. */\n private _offRail(dt: number, world: ChunkManager): void {\n this.vel.y -= GRAVITY * dt;\n const k = Math.max(0, 1 - 3 * dt);\n this.vel.x *= k;\n this.vel.z *= k;\n const x = this.pos.x + this.vel.x * dt,\n z = this.pos.z + this.vel.z * dt;\n const ny = this.pos.y + this.vel.y * dt;\n const below = world.getBlock(Math.floor(x), Math.floor(ny), Math.floor(z));\n if (this.vel.y < 0 && isRail(below)) {\n this.pos.set(x, Math.floor(ny), z);\n this.vel.y = 0;\n } else if (this.vel.y < 0 && BLOCKS[below]?.solid) {\n this.pos.set(x, Math.floor(ny) + 1, z);\n this.vel.y = 0;\n } else this.pos.set(x, ny, z);\n if (this.pos.y < -64) this.dead = true;\n }\n\n updateVisual(alpha: number): void {\n this.renderPos.copy(this.prevPos).lerp(this.pos, alpha);\n this.mesh.position.copy(this.renderPos);\n this.mesh.rotation.set(\n 0,\n this.yaw,\n this.shake > 0 ? Math.sin(this.shake * 40) * 0.12 : 0,\n );\n // взведённый динамит пульсирует\n this.mesh.scale.setScalar(\n this.fuse >= 0 ? 1 + Math.max(0, Math.sin(this.fuse * 12)) * 0.06 : 1,\n );\n }\n\n dispose(scene: THREE.Scene): void {\n scene.remove(this.mesh);\n this.mesh.traverse((o) => {\n if ((o as THREE.Mesh).isMesh) (o as THREE.Mesh).geometry.dispose();\n });\n }\n\n serialize() {\n return {\n kind: \"minecart\",\n pos: this.pos.toArray(),\n vel: this.vel.toArray(),\n ...(this.variant !== \"plain\" ? { variant: this.variant } : {}),\n ...(this.items.length\n ? { items: this.items.map((s) => (s ? { ...s } : null)) }\n : {}),\n };\n }\n}\n"
|
|
@@ -219,10 +263,74 @@
|
|
|
219
263
|
"path": "entities/XpOrb.ts",
|
|
220
264
|
"content": "// Сфера опыта: выпадает из мобов (убитых игроком), летит к игроку в радиусе ~7 блоков.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"../player/Player\";\n\nconst MAGNET = 7.25;\nconst LIFETIME = 300;\nconst geo = new THREE.BoxGeometry(0.16, 0.16, 0.16);\nconst mat = new THREE.MeshBasicMaterial({ color: 0xc8ff3a });\n\nexport class XpOrb {\n id: number;\n kind = \"xp\" as const;\n value: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n age = 0;\n dead = false;\n mesh: THREE.Mesh;\n\n constructor(id: number, x: number, y: number, z: number, value: number) {\n this.id = id;\n this.value = value;\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) * 3,\n 3 + Math.random() * 2,\n (Math.random() - 0.5) * 3,\n );\n this.mesh = new THREE.Mesh(geo, mat);\n this.mesh.position.copy(this.pos);\n }\n\n tick(dt: number, world: ChunkManager, player: Player) {\n this.prevPos.copy(this.pos);\n this.age += dt;\n if (this.age > LIFETIME) {\n this.dead = true;\n return;\n }\n const to = new THREE.Vector3(\n player.pos.x,\n player.pos.y + 0.9,\n player.pos.z,\n ).sub(this.pos);\n const dist = to.length();\n if (!player.dead && this.age > 0.4) {\n if (dist < 0.9) {\n player.addXp(this.value);\n events.emit(\"xpPickup\", { amount: this.value });\n this.dead = true;\n return;\n }\n if (dist < MAGNET) {\n to.normalize().multiplyScalar(10 * (1 - dist / MAGNET) + 2);\n this.vel.lerp(to, 0.25);\n this.pos.addScaledVector(this.vel, dt);\n return;\n }\n }\n this.vel.y -= 16 * dt;\n const nx = this.pos.x + this.vel.x * dt,\n ny = this.pos.y + this.vel.y * dt,\n nz = this.pos.z + this.vel.z * dt;\n if (\n this.vel.y < 0 &&\n world.isSolid(Math.floor(nx), Math.floor(ny - 0.08), Math.floor(nz))\n ) {\n this.pos.y = Math.floor(ny - 0.08) + 1.08;\n this.vel.set(this.vel.x * 0.5, 0, this.vel.z * 0.5);\n } else {\n this.pos.set(nx, ny, nz);\n }\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.mesh.position.set(p.x, p.y + Math.sin(time * 4 + this.id) * 0.05, p.z);\n this.mesh.rotation.y = time * 2;\n this.mesh.scale.setScalar(\n 0.8 + Math.min(1.2, this.value / 10) + Math.sin(time * 6) * 0.1,\n );\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.mesh);\n }\n\n serialize() {\n return { kind: \"xp\", pos: this.pos.toArray(), value: this.value };\n }\n}\n"
|
|
221
265
|
},
|
|
266
|
+
{
|
|
267
|
+
"path": "game/beyond.ts",
|
|
268
|
+
"content": "import * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\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 type { Game } from \"../main\";\n\nexport function tickPlates(game: Game, 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 = game.world.getBlock(bx, by, bz);\n if (id === plate || (cart && id === B.detector_rail))\n on.add(bx + \",\" + by + \",\" + bz);\n };\n if (!game.player.dead)\n mark(game.player.pos.x, game.player.pos.y, game.player.pos.z);\n // сетевая игра: плиту жмут и другие игроки этого измерения\n for (const a of game.entities.others())\n if (!a.player.dead) mark(a.player.pos.x, a.player.pos.y, a.player.pos.z);\n for (const e of game.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 game._plates.set(k, 1);\n const [x, y, z] = at(k);\n const id = game.world.getBlock(x, y, z),\n m = game.world.getMeta(x, y, z);\n if (id === plate && m === 0) game.world.setBlock(x, y, z, plate, 1);\n else if (id === B.detector_rail && !(m & 0x8))\n game.world.setBlock(x, y, z, id, m | 0x8);\n }\n for (const [k, left] of game._plates) {\n if (on.has(k)) continue;\n if (left - dt > 0) {\n game._plates.set(k, left - dt);\n continue;\n }\n game._plates.delete(k);\n const [x, y, z] = at(k);\n const id = game.world.getBlock(x, y, z);\n if (id === plate) game.world.setBlock(x, y, z, plate, 0);\n else if (id === B.detector_rail)\n game.world.setBlock(x, y, z, id, game.world.getMeta(x, y, z) & ~0x8);\n }\n}\n\nexport function beyondTravel(game: Game) {\n if (game.dimension === \"beyond\") {\n const s = game.player.spawnPoint;\n game.changeDimension(\"overworld\", s.x, s.y, s.z);\n game.player.pos.copy(game.goHome());\n // первый выход после победы — титры\n if (game.beyondState.dragonKilled && !game.beyondState.creditsShown) {\n game.beyondState.creditsShown = true;\n game.hud.showCredits();\n }\n } else {\n const { x, y, z } = BEYOND_ARRIVAL;\n game.changeDimension(\"beyond\", x + 0.5, y, z + 0.5);\n arrivalPlatform(game);\n if (!game.beyondState.initialized) initBeyond(game);\n }\n game.player.vel.set(0, 0, 0);\n game.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: game.dimension });\n}\n\nexport function initBeyond(game: Game) {\n game.beyondState.initialized = true;\n for (const p of beyondPillars(game.world.seed))\n game.entities.spawnMob(\"beyond_crystal\", p.x + 0.5, p.top + 2, p.z + 0.5);\n game.entities.spawnMob(\"void_dragon\", 0, 90, 0);\n}\n\nexport function arrivalPlatform(game: Game) {\n const { x, y, z } = BEYOND_ARRIVAL;\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++) {\n game.world.setBlock(x + dx, y - 1, z + dz, B.obsidian!);\n for (let dy = 0; dy < 3; dy++)\n game.world.setBlock(x + dx, y + dy, z + dz, B.air!);\n }\n game.player.pos.set(x + 0.5, y, z + 0.5);\n}\n\nexport function placeGateway(game: Game, x: number, y: number, z: number) {\n game.world.setBlock(x, y - 1, z, B.bedrock!);\n game.world.setBlock(x, y, z, B.beyond_gateway!);\n game.world.setBlock(x, y + 1, z, B.beyond_gateway!);\n game.world.setBlock(x, y + 2, z, B.bedrock!);\n}\n\nexport function openGateway(game: Game) {\n const [x, y, z] = gatewaySpot(game.world.seed);\n game.warmup(x, z); // запись в незагруженный чанк пропала бы: победа бывает далеко от врат\n placeGateway(game, x, y, z);\n game.beyondState.gateway = [x, y, z];\n}\n\nexport function tickGateway(game: Game) {\n if (game.dimension !== \"beyond\" || game.player.dead) return;\n const g = game.beyondState.gateway;\n if (game.beyondState.dragonKilled && !g) openGateway(game);\n // врата не ломаются: записаны, чанк загружен, а их нет — значит, не встали (сейв до починки)\n else if (g && game.world.getBlockOrUnloaded(g[0], g[1], g[2]) === B.air)\n placeGateway(game, g[0], g[1], g[2]);\n const p = game.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 (game.world.getBlock(x, y, z) === B.beyond_gateway) touching = true;\n if (!touching) {\n game._gateLock = false;\n return;\n }\n if (game._gateLock) return;\n game._gateLock = true;\n const toOuter = Math.hypot(p.x, p.z) < OUTER_START / 2;\n if (!toOuter) {\n game.warmup(BEYOND_ARRIVAL.x, BEYOND_ARRIVAL.z);\n arrivalPlatform(game);\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 game.warmup(tx, tz);\n const gen = game.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 game.warmup(x, z);\n while (\n y < 120 &&\n (game.world.isSolid(x, y, z) || game.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 game.world.setBlock(x + dx, y - 1, z + dz, B.beyond_stone!);\n placeGateway(game, x + 3, y, z); // обратные врата у места прибытия\n game.player.pos.set(x + 0.5, y, z + 0.5);\n game.hud.message(\"Внешние острова Предела\", 2500);\n }\n game.player.vel.set(0, 0, 0);\n game.player._fallDist = 0;\n events.emit(\"gatewayTravel\", { outer: toOuter });\n}\n\nexport function dragonDefeated(game: Game) {\n game.beyondState.dragonKilled = true;\n for (const [x, y, z] of exitPortalCells())\n game.world.setBlock(x, y, z, B.beyond_portal!);\n game.world.setBlock(0, ISLAND_Y + 4, 0, B.dragon_egg!);\n openGateway(game);\n game.hud.message(\n \"Дракон Предела повержен! Портал домой — в фонтане, Врата на внешние острова — за краем\",\n 4500,\n );\n}\n\nexport function throwEye(game: Game, x: number, y: number, z: number) {\n const target = game.world.generator.locate?.(\"citadel\", x, z);\n if (!target) {\n game.hud.message(\"Око не чувствует цитадели в этом мире\", 2200);\n if (game.player.mode !== \"creative\") game.inventory.add(\"rim_eye\", 1);\n return;\n }\n game.entities.spawnRimEye(new THREE.Vector3(x, y, z), target);\n}\n"
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"path": "game/environment.ts",
|
|
272
|
+
"content": "import * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport {\n TRIAL_RANGE,\n trialStep,\n type TrialState,\n} from \"../systems/TrialSpawner\";\nimport { TRIAL_MOBS } from \"../registry/blocks/trial\";\nimport type { Game } from \"../main\";\n\nexport function tickTrials(game: Game, dt: number): void {\n if (game.dimension !== \"overworld\") return;\n const p = game.player;\n if ((game._trialScanT -= dt) <= 0) {\n game._trialScanT = 1;\n for (const [x, y, z] of game.world.generator.trialSpawnersNear?.(\n p.pos.x,\n p.pos.z,\n 48,\n ) ?? [])\n if (game.world.getBlockOrUnloaded(x, y, z) === B.trial_spawner)\n game.blockEntities.ensure(x, y, z);\n }\n const hunter = !p.dead && p.mode !== \"creative\";\n for (const be of game.blockEntities.map.values()) {\n if (be.type !== \"trial_spawner\") continue;\n const mob =\n TRIAL_MOBS[game.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: () => trialSpawn(game, be.x, be.y, be.z, mob),\n reward: (items) => {\n for (const s of items)\n game.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\nexport function trialSpawn(\n game: Game,\n x: number,\n y: number,\n z: number,\n mob: string,\n): 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[game.world.getBlock(sx, sy - 1, sz)]?.solid) continue;\n if (\n game.world.getBlock(sx, sy, sz) !== 0 ||\n game.world.getBlock(sx, sy + 1, sz) !== 0\n )\n continue;\n const m = game.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\nexport function tickFreeze(game: Game, dt: number): void {\n const p = game.player;\n const armor = game.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 (game.inventory.heldItem()?.item === \"shield\")\n game.inventory.damageHeld(Math.max(1, Math.floor(n)));\n };\n const leather = armor.some((s) => !!s?.item.startsWith(\"leather_\"));\n const dmg = game.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 game.hud.frost(game.freezing.level);\n}\n\nexport function tickSculk(game: Game, dt: number): void {\n if (game.dimension !== \"overworld\") return;\n game.sculk.tick(dt);\n const p = game.player;\n if (p.dead || p.flying || !p.onGround || p._sneaking) return;\n game._stepDist += Math.hypot(p.vel.x, p.vel.z) * dt;\n if (game._stepDist < 2) return;\n game._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 (game.world.getBlock(x, y - 1, z) === B.sculk_shrieker)\n game.sculk.shriek(x, y - 1, z);\n game.sculk.vibrate(p.pos.x, p.pos.y, p.pos.z, p.mode !== \"creative\");\n}\n\nexport function lightning(game: Game, 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 game.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && near(e.pos)) e.damage(5);\n if (near(game.player.pos)) game.player.damage(5, \"lightning\");\n}\n"
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"path": "game/items.ts",
|
|
276
|
+
"content": "import { events } from \"../core/EventBus\";\nimport { MAP_SIZE, newMap, surveyMap } from \"../systems/Maps\";\nimport { EXPLORER_MAPS } from \"../registry/items/mansion\";\nimport type { Game } from \"../main\";\n\nexport function createMap(game: Game): void {\n if (game.inventory.heldItem()?.item !== \"map\") return;\n const p = game.player.pos;\n const m = newMap(game.nextMapId++, p.x, p.z, game.dimension);\n game.maps.set(m.id, m);\n for (let ph = 0; ph < 4; ph++) surveyMap(game.world, m, p.x, p.z, 48, ph);\n const filled = { item: \"filled_map\", count: 1, tag: { map: m.id } };\n if (game.player.mode !== \"creative\") game.inventory.consumeHeld(1);\n const i = game.inventory.hotbarIndex;\n if (!game.inventory.slots[i]) game.inventory.setSlot(i, filled);\n else if (game.inventory.addStack(filled) > 0)\n game.entities.spawnDrop(p.x, p.y + 1, p.z, \"filled_map\", 1);\n events.emit(\"mapCreated\", { id: m.id });\n}\n\nexport function goatHorn(game: Game, dt: number): void {\n game._hornCd = Math.max(0, game._hornCd - dt);\n const press =\n game.input.rightDown &&\n !game.player.dead &&\n game.inventory.heldItem()?.item === \"goat_horn\";\n if (press && !game._hornHeld && game._hornCd <= 0) {\n game._hornCd = 7;\n events.emit(\"goatHorn\");\n const p = game.player.pos;\n if (game.dimension === \"overworld\")\n game.sculk.vibrate(p.x, p.y, p.z, game.player.mode !== \"creative\");\n }\n game._hornHeld = press;\n}\n\nexport function spyglass(game: Game, dt: number): void {\n const on =\n !game.player.dead &&\n game.input.rightDown &&\n game.inventory.heldItem()?.item === \"spyglass\";\n const want = on ? 10 : 75;\n if (game.camera.fov === want) return;\n game.camera.fov += (want - game.camera.fov) * Math.min(1, dt * 12);\n if (Math.abs(game.camera.fov - want) < 0.1) game.camera.fov = want;\n game.camera.updateProjectionMatrix();\n}\n\nexport function surveyHeldMap(game: Game): void {\n const h = game.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 (game.dimension !== \"overworld\") return;\n const f = game.world.generator.locate?.(\n kind,\n game.player.pos.x,\n game.player.pos.z,\n );\n if (!f) return;\n tx = f.x;\n tz = f.z;\n }\n const m = newMap(game.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 game.maps.set(m.id, m);\n game.inventory.setSlot(game.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 = game.maps.get(Number(h.tag?.map));\n if (!m || m.dim !== game.dimension) return;\n const p = game.player.pos;\n surveyMap(game.world, m, p.x, p.z, 48, game._mapPhase++ & 3);\n}\n"
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
"path": "game/netSession.ts",
|
|
280
|
+
"content": "import { events } from \"../core/EventBus\";\nimport { Player } from \"../player/Player\";\nimport { Inventory } from \"../systems/Inventory\";\nimport { isDimensionId } from \"../world/Dimensions\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { portalsOf } from \"../world/Portal\";\nimport { worldSourceOf } from \"../systems/Save\";\nimport { friendlyNetError } from \"../ui/NetUI\";\nimport type { SaveData } from \"../types\";\nimport { sim } from \"../net/sim\";\nimport { NetGame, type NetRoom } from \"../net/NetGame\";\nimport type { DiffStore } from \"../net/DiffStore\";\nimport type {\n RoomSettings,\n Welcome,\n WorldMeta,\n WorldState,\n} from \"../net/protocol\";\nimport {\n difficulty,\n parseDifficulty,\n setDifficulty,\n} from \"../systems/Difficulty\";\nimport type { Game } from \"../main\";\n\nexport function netWorldMeta(game: Game): WorldMeta {\n return {\n source: game.world.source,\n palette: BLOCKS.map((b) => b.name),\n time: game.dayNight.serialize(),\n weather: game.weather.serialize(),\n difficulty: difficulty(),\n beyond: game.beyondState,\n portals: game.portals,\n daysAwake: game.daysAwake,\n name: game.worldName,\n };\n}\n\nexport function netWorldState(game: Game): WorldState {\n return {\n time: game.dayNight.serialize(),\n weather: game.weather.serialize(),\n difficulty: difficulty(),\n beyond: game.beyondState,\n daysAwake: game.daysAwake,\n };\n}\n\nexport function applyNetWorldState(game: Game, w: WorldState): void {\n game.dayNight.deserialize(w.time as SaveData[\"time\"]);\n game.weather.deserialize(w.weather as SaveData[\"weather\"]);\n setDifficulty(parseDifficulty(w.difficulty) ?? \"normal\");\n mergeBeyond(game, w.beyond);\n if (typeof w.daysAwake === \"number\" && w.daysAwake >= 0)\n game.daysAwake = w.daysAwake;\n}\n\nexport function mergeBeyond(game: Game, b: unknown): void {\n if (!b || typeof b !== \"object\") return;\n const s = b as Partial<Game[\"beyondState\"]>;\n const st = game.beyondState;\n st.initialized ||= s.initialized === true;\n st.dragonKilled ||= s.dragonKilled === true;\n st.creditsShown ||= s.creditsShown === true;\n if (\n !st.gateway &&\n Array.isArray(s.gateway) &&\n s.gateway.length === 3 &&\n s.gateway.every(Number.isFinite)\n )\n st.gateway = [s.gateway[0]!, s.gateway[1]!, s.gateway[2]!];\n}\n\nexport function netPlayerState(game: Game): Record<string, unknown> {\n const p = game.player.pos;\n return {\n ...game.player.serialize(),\n inventory: game.inventory.serialize(),\n dim: game.dimension,\n pos: [p.x, p.y, p.z],\n beyond: game.beyondState,\n };\n}\n\nexport function loadNetWorld(\n game: Game,\n w: Welcome,\n diffs: DiffStore,\n settings: RoomSettings,\n): void {\n void game.persist();\n game._soloWorldBeforeNet = game.worldId;\n game._netGuest = true;\n // сейвы чужих игроков — от СВОЕГО прежнего мира: стань мы хостом этого, они бы переехали в него\n game.netPlayers = new Map();\n game._worldTouched = true;\n if (game.invUI.open) game.invUI.close();\n const dim = isDimensionId(w.spawn.dim) ? w.spawn.dim : \"overworld\";\n const src = worldSourceOf({\n world: w.world.source,\n seed: 0,\n } as unknown as SaveData);\n game.dims.clear();\n for (const d of [\"overworld\", \"inferno\", \"beyond\"] as const)\n if (d !== dim)\n game.dims.set(d, {\n chunks: diffs.toChunkDiffMap(d),\n entities: [],\n blockEntities: [],\n });\n game.rebuildWorld(src, dim);\n game.world.loadDiffs(diffs.toChunkDiffMap(dim));\n game.entities.clear();\n game.blockEntities.clear();\n applyNetWorldState(game, { ...w.world });\n game.portals = portalsOf(w.world.portals);\n game.villagesSettled = new Set();\n game.maps = new Map();\n game.nextMapId = 1;\n game.achievements.load([]);\n const state = w.state as\n (Record<string, unknown> & { inventory?: unknown }) | null;\n if (state && Array.isArray((state as { pos?: unknown }).pos)) {\n game.player.deserialize(\n state as unknown as Parameters<Player[\"deserialize\"]>[0],\n );\n game.inventory.deserialize(\n state.inventory as Parameters<Inventory[\"deserialize\"]>[0],\n );\n } else {\n game.player.resetForNewWorld();\n game.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n game.player.mode = settings.guestMode;\n }\n const [x, y, z] = w.spawn.pos;\n game.warmup(x, z);\n game.player.pos.set(x, y, z);\n game.player.vel.set(0, 0, 0);\n game.player._fallDist = 0;\n if (!state) game.player.spawnPoint.set(x, y, z);\n game.worldName = \"Сетевая игра\";\n game._netWorldName =\n typeof w.world.name === \"string\" && w.world.name.trim()\n ? w.world.name.trim().slice(0, 60)\n : \"\";\n game.hud.message(\"Вы в сетевой игре\", 2200);\n events.emit(\"dimensionChanged\", { dimension: dim });\n}\n\nexport function adoptNetWorldAsOwn(game: Game): void {\n game._netGuest = false;\n // имя мира хоста, а не безликое «Сетевой мир»: при переносе мир остаётся тем же миром\n game.adoptNewWorld(game._netWorldName || \"Сетевой мир\");\n}\n\nexport async function acceptRoom(game: Game, room: NetRoom): Promise<void> {\n if (game._destroyed) return;\n // Прошлая сетевая игра, чья сессия уже закрыта (из комнаты вышли через лобби, пока игра стояла на\n // паузе), не должна съесть новую комнату: раньше здесь был молчаливый выход, хост не открывал мир,\n // а гости через 30 с получали «Хост не ответил».\n if (game.net?.session.closed) game.net.closeStale();\n if (game.net) return;\n await game.restored;\n const res = await NetGame.fromRoom(game, room, game.playerName());\n if (typeof res === \"string\") {\n // После выхода: пока комната принимается, сверху экран загрузки лобби — сообщение под ним\n // гасло раньше, чем его можно было прочесть.\n await room.leave();\n game.hud.message(friendlyNetError(res), 6000);\n return;\n }\n game.net = res;\n events.emit(\"net:roster\");\n if (room.isHost && !room.lateJoin)\n game.hud.message(\"Мир открыт для друзей\", 2200);\n game.startPlay();\n}\n\nexport function exitNet(game: Game, reason: string | null): void {\n const guest = game._netGuest;\n game.net = null;\n game._netGuest = false;\n game.netSleeping = false;\n sim.authority = true;\n game.entities.replica = false;\n game.entities.others = () => [];\n game.entities.onLocalSpawn = null;\n game.entities.onRemoteAction = null;\n if (guest) {\n const back = game._soloWorldBeforeNet;\n game._soloWorldBeforeNet = null;\n if (back)\n void game\n .openStoredWorld(back)\n .then((ok) => ok || game.newWorld((Math.random() * 1e9) >>> 0));\n else game.newWorld((Math.random() * 1e9) >>> 0);\n }\n if (reason) game.hud.message(reason, 3500);\n events.emit(\"net:roster\");\n}\n"
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
"path": "game/portals.ts",
|
|
284
|
+
"content": "import { events } from \"../core/EventBus\";\nimport { DIMENSIONS, type DimensionId } from \"../world/Dimensions\";\nimport { B } from \"../registry/Blocks\";\nimport { buildPortal, findPortalNear } from \"../world/Portal\";\nimport { LAVA_SEA } from \"../worldgen/inferno/InfernoGenerator\";\nimport type { Game } from \"../main\";\n\nexport function tickPortal(game: Game, dt: number) {\n const p = game.player.pos;\n const cell = (dy: number) =>\n game.world.getBlock(Math.floor(p.x), Math.floor(p.y + dy), Math.floor(p.z));\n const inBeyond = cell(0) === B.beyond_portal;\n const inside = inBeyond || [0, 1].some((dy) => cell(dy) === B.inferno_portal);\n if (!inside || game.player.dead) {\n game._portalT = 0;\n game._portalLock = false;\n return;\n }\n if (game._portalLock) return;\n // портал Предела переносит сразу, как в Minecraft\n if (inBeyond) {\n game._portalLock = true;\n game._beyondTravel();\n return;\n }\n game._portalT += dt;\n if (game._portalT < (game.player.mode === \"creative\" ? 0.25 : 4)) return;\n game._portalT = 0;\n game._portalLock = true;\n const target: DimensionId =\n game.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 rememberPortal(\n game,\n game.dimension,\n Math.floor(p.x),\n Math.floor(p.y),\n Math.floor(p.z),\n );\n // свой портал на той стороне: в верхнем мире ищем в 128 блоках, в Пекле — в 16, как в Minecraft\n const known = nearestPortal(\n game,\n target,\n tx,\n tz,\n target === \"overworld\" ? 128 : 16,\n );\n game.changeDimension(\n target,\n (known?.[0] ?? tx) + 0.5,\n 70,\n (known?.[2] ?? tz) + 0.5,\n );\n let at = known ? findPortalNear(game.world, known[0], known[2], 2) : null;\n if (known && !at) forgetPortal(game, target, known); // сломали — забываем\n if (!at) {\n if (known) game.warmup(tx, tz);\n at =\n findPortalNear(game.world, tx, tz, 16) ??\n (target === \"inferno\"\n ? buildPortal(game.world, tx, tz, 64, LAVA_SEA + 2, 110)\n : buildPortal(game.world, tx, tz, 70, 4, 120));\n rememberPortal(game, target, at.x, at.y, at.z);\n }\n game.player.pos.set(at.x + 0.5, at.y, at.z + 0.5);\n game.player.vel.set(0, 0, 0);\n game.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: target });\n}\n\nexport function rememberPortal(\n game: Game,\n dim: DimensionId,\n x: number,\n y: number,\n z: number,\n) {\n if (addPortal(game, dim, x, y, z))\n game.onPortalChange?.(dim, [x, y, z], true);\n}\n\nexport function addPortal(\n game: Game,\n dim: DimensionId,\n x: number,\n y: number,\n z: number,\n): boolean {\n const list = game.portals[dim];\n const near = list.some(\n ([px, py, pz]) =>\n Math.abs(px - x) <= 3 && Math.abs(py - y) <= 4 && Math.abs(pz - z) <= 3,\n );\n if (!near) list.push([x, y, z]);\n return !near;\n}\n\nexport function forgetPortal(\n game: Game,\n dim: DimensionId,\n p: readonly number[],\n) {\n game.portals[dim] = game.portals[dim].filter((q) => q !== p);\n game.onPortalChange?.(dim, [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0], false);\n}\n\nexport function applyNetPortal(\n game: Game,\n dim: DimensionId,\n p: readonly number[],\n add: boolean,\n): void {\n const [x = 0, y = 0, z = 0] = p;\n if (add) addPortal(game, dim, x, y, z);\n else\n game.portals[dim] = game.portals[dim].filter(\n (q) => q[0] !== x || q[1] !== y || q[2] !== z,\n );\n}\n\nexport function nearestPortal(\n game: Game,\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 game.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"
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
"path": "game/raid.ts",
|
|
288
|
+
"content": "import * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { RAID_WAVES, Raid } from \"../systems/Raid\";\nimport { siegeCount, siegeSpot, siegeTonight } from \"../systems/Siege\";\nimport type { Game } from \"../main\";\n\nexport function tickRaid(game: Game, dt: number) {\n if (game.dimension !== \"overworld\") return;\n const pl = game.player;\n if (!game.raid) {\n const omen = pl.effects.get(\"bad_omen\");\n if (!omen || pl.dead) return;\n const v = (\n game.world.generator.villagesNear?.(pl.pos.x, pl.pos.z, 48) ?? []\n ).find(\n (x) =>\n game.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 game.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 game.hud.message(\"Набег! Защитите деревню\", 3500);\n return;\n }\n const r = game.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 game.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 = game.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 game.warmup(x, z);\n const m = game.entities.spawnMob(\n type,\n x + 0.5,\n game.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 game.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 game.hud.message(\n \"Набег отбит! Вы — герой деревни: жители уступают в цене\",\n 4500,\n );\n game.raid = null;\n } else if (res.end === \"lost\") {\n for (const id of r.raiders) {\n const e = game.entities.entities.get(id);\n if (e?.kind === \"mob\") {\n e.raider = false;\n e.goal = null;\n }\n }\n events.emit(\"raidLost\");\n game.hud.message(\"Набег: деревня пала\", 3500);\n game.raid = null;\n }\n}\n\nexport function siege(game: Game) {\n if (game.dimension !== \"overworld\" || game.raid) return;\n const p = game.player.pos;\n const v = (game.world.generator.villagesNear?.(p.x, p.z, 72) ?? []).find(\n (x) =>\n game.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 game.warmup(x, z);\n const y = game.world.surfaceY(x, z);\n if (y <= 0) continue;\n const m = game.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 game.hud.message(\"Ночная осада! Зомби идут к деревне\", 3500);\n}\n\nexport function settleVillages(game: Game) {\n // деревни верхнего мира и города Предела (панцирники) — один путь: villagesNear генератора\n const p = game.player.pos;\n for (const v of game.world.generator.villagesNear?.(p.x, p.z, 72) ?? []) {\n if (game.villagesSettled.has(v.id)) continue;\n const loaded = v.residents.every(\n (r) =>\n game.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 = game.entities.spawnMob(r.type, r.x, r.y + 0.01, r.z);\n if (r.captain) m.captain = true;\n }\n game.villagesSettled.add(v.id);\n }\n}\n"
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"path": "game/respawn.ts",
|
|
292
|
+
"content": "import * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { DIMENSIONS, isDimensionId } from \"../world/Dimensions\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { findSafeSpot, isStandable } from \"../world/safeSpot\";\nimport { anchorCharges } from \"../world/behaviours/stand\";\nimport type { Game } from \"../main\";\n\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 function skipNight(game: Game): void {\n game.dayNight.resetToMorning();\n game.weather.set(\"clear\"); // сон пережидает и непогоду, как в Minecraft\n game._slept = true;\n game.daysAwake = 0;\n game.netSleeping = false;\n game.hud.message(\"Доброе утро!\", 1800);\n events.emit(\"slept\");\n}\n\nexport function onDeath(game: Game) {\n if (game.player.mode !== \"creative\") {\n const p = game.player.pos;\n const inv = game.inventory;\n for (const s of [...inv.slots, ...inv.armor])\n if (s)\n game.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, game.player.xpLevel * 7);\n if (xp > 0) game.entities.spawnXp(p.x, p.y + 0.5, p.z, xp);\n game.player.xpLevel = game.player.xpProgress = game.player.xpTotal = 0;\n }\n if (game.invUI.open) game.invUI.close();\n // чат, табличка, книга, бирка поверх экрана смерти: их закрытие вернуло бы захват мыши\n game.chat.hide();\n game.signEditor.hide();\n game.bookScreen.hide();\n game.nameEditor.hide();\n game.input.uiOpen = true; // до releaseLock: без паузы — вместо неё экран смерти\n game.input.releaseLock();\n game.hud.showDeath(game.player.lastCause);\n}\n\nexport function respawnPlayer(game: Game) {\n if (!game.player.dead) return;\n game.player.respawn(goHome(game));\n game.hud.hideDeath();\n game.input.uiOpen = false;\n game.input.requestLock();\n}\n\nexport function goHome(game: Game): THREE.Vector3 {\n // якорь возрождения сильнее кровати и возвращает в СВОЁ измерение (обычно в Пекло)\n const anchor = anchorHome(game);\n if (anchor) return anchor;\n const s = game.player.spawnPoint;\n let at = new THREE.Vector3(s.x, s.y, s.z);\n let atBed = false;\n const bed = game.player.bedPos;\n if (game.dimension !== \"overworld\")\n game.changeDimension(\"overworld\", at.x, at.y, at.z);\n if (bed) {\n game.warmup(bed[0], bed[2]);\n if (BLOCKS[game.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 atBed = true;\n } else {\n game.player.bedPos = null;\n game.hud.message(\"Ваша кровать отсутствует или заблокирована\", 2500);\n }\n }\n game.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 (game.world.isSolid(bx, y, bz) || game.world.isSolid(bx, y + 1, bz))\n )\n y++;\n // …и опускаемся до пола: точка возрождения в воздухе — это смерть от падения сразу после неё\n while (y > 1 && game.world.getBlock(bx, y - 1, bz) === B.air) y--;\n at.y = y;\n // …и не в яме снега-пороха, не в лаве: иначе возрождение сразу ведёт к новой смерти, и так по кругу\n // (точка мира из старых сейвов могла быть записана прямо на дне такой ямы)\n if (!atBed && !isStandable(game.world, bx, y, bz)) {\n const safe = findSafeSpot(game.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\nexport function anchorHome(game: Game): THREE.Vector3 | null {\n const a = game.player.anchorPos;\n const dim = game.player.anchorDim;\n if (!a || !dim || !isDimensionId(dim)) return null;\n const [x, y, z] = a;\n if (game.dimension !== dim)\n game.changeDimension(dim, x + 0.5, y + 1, z + 0.5);\n game.warmup(x, z);\n const charges =\n game.world.getBlock(x, y, z) === B.respawn_anchor\n ? anchorCharges(game.world.getMeta(x, y, z))\n : 0;\n if (charges <= 0) {\n game.player.anchorPos = null;\n game.player.anchorDim = null;\n game.hud.message(\"Якорь возрождения разряжен или сломан\", 2500);\n return null;\n }\n game.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(game.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\nexport function useBed(game: Game, x: number, y: number, z: number) {\n if (DIMENSIONS[game.dimension].bedsExplode) {\n // в Пекле и Пределе кровать взрывается, как в Minecraft\n game.world.setBlock(x, y, z, B.air!);\n game.entities.explode(x + 0.5, y + 0.5, z + 0.5, 5);\n return;\n }\n game.player.bedPos = [x, y, z];\n if (game.dayNight.isDay && game.weather.kind !== \"thunder\") {\n game.hud.message(\n \"Спать можно только ночью или в грозу. Точка возрождения — здесь\",\n 2400,\n );\n return;\n }\n const monsters = [...game.entities.entities.values()].some(\n (e) =>\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.category === \"hostile\" &&\n e.pos.distanceTo(game.player.pos) < 8,\n );\n if (monsters) {\n game.hud.message(\"Нельзя спать — рядом монстры\", 2000);\n return;\n }\n // сетевая игра: ночь пропускает хост, когда спят ВСЕ игроки Верхнего мира (net/NetGame)\n if (game.net) {\n game.netSleeping = true;\n game.hud.message(\"Вы спите. Ночь пройдёт, когда уснут все игроки\", 2600);\n return;\n }\n skipNight(game);\n}\n"
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"path": "game/shared.ts",
|
|
296
|
+
"content": "/** Автосейв текущего мира в «Мои миры», секунд игры. */\nexport const AUTOSAVE_EVERY = 30;\n"
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"path": "game/types.ts",
|
|
300
|
+
"content": "import type { WorldAI } from \"../ai/types\";\n\nexport interface GameOptions {\n /** ИИ-генерация миров (client.ai). null/нет — только путь без ИИ. */\n ai?: WorldAI | null;\n /**\n * Сетевая игра — через систему мультиплеера лобби (ctx.multiplayer). `openForFriends` открывает в\n * лобби «Создать комнату» с этой игрой. null/нет — системы нет, игра только одиночная.\n */\n online?: { available(): boolean; openForFriends(): void } | null;\n /** Имя игрока в сетевой игре (профиль); нет — «Игрок». */\n playerName?: () => string;\n}\n"
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"path": "game/wire.ts",
|
|
304
|
+
"content": "import * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { B } from \"../registry/Blocks\";\nimport { hitTarget } from \"../world/behaviours/extras\";\nimport { dripstoneFallDamage } from \"../world/behaviours/dripstone\";\nimport { type TrialState } from \"../systems/TrialSpawner\";\nimport { type Mob } from \"../entities/Mob\";\nimport {\n beaconEffects,\n inBeaconRange,\n type BeaconPulse,\n} from \"../world/Beacon\";\nimport type { Game } from \"../main\";\n\nexport function wireEvents(game: Game): void {\n // опыт из печи (и других источников, что не кидают сферы)\n events.on(\"xpGain\", ({ amount }: { amount: number }) =>\n game.player.addXp(amount),\n );\n events.on(\"playerDied\", () => game._onDeath());\n events.on(\"portalLit\", (d: { x: number; y: number; z: number }) =>\n game._rememberPortal(game.dimension, d.x, d.y, d.z),\n );\n events.on(\"throwEye\", (d: { x: number; y: number; z: number }) =>\n game._throwEye(d.x, d.y, d.z),\n );\n events.on(\"dragonDefeated\", () => game._dragonDefeated());\n events.on(\"ui:useBed\", (d: { x: number; y: number; z: number }) =>\n game._useBed(d.x, d.y, d.z),\n );\n events.on(\"lightning\", (d: { x: number; y: number; z: number }) =>\n game._lightning(d.x, d.y, d.z),\n );\n // упавший сталактит бьёт того, кто под ним, и рассыпается острым капельником\n events.on(\n \"dripstoneFall\",\n (p: { x: number; y: number; z: number; landY: number }) => {\n const pl = game.player;\n if (\n !pl.dead &&\n Math.floor(pl.pos.x) === p.x &&\n Math.floor(pl.pos.z) === p.z &&\n pl.pos.y <= p.y &&\n pl.pos.y >= p.landY - 0.5\n )\n pl.damage(dripstoneFallDamage(p.y - pl.pos.y), \"falling_stalactite\");\n game.entities.spawnDrop(\n p.x + 0.5,\n p.landY + 0.2,\n p.z + 0.5,\n \"pointed_dripstone\",\n 1,\n );\n },\n );\n // находка из-под кисти — над расчищенным блоком\n events.on(\n \"brushLoot\",\n (p: { x: number; y: number; z: number; item: string; count: number }) =>\n game.entities.spawnDrop(p.x + 0.5, p.y + 1.1, p.z + 0.5, p.item, p.count),\n );\n // дроп, который роняет сам моб (рог козы)\n events.on(\"mobDrop\", (p: { x: number; y: number; z: number; item: string }) =>\n game.entities.spawnDrop(p.x, p.y, p.z, p.item, 1),\n );\n // глубокая тьма: сломанные и поставленные игроком блоки и взрывы — вибрации; смерть рядом с\n // катализатором — скалк; место смерти игрока — для компаса возвращения\n const overworld = () => game.dimension === \"overworld\";\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; byPlayer?: boolean }) => {\n if (overworld())\n game.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n !!p.byPlayer && game.player.mode !== \"creative\",\n );\n },\n );\n // Жемчуг Предела упал: игрока переносит туда, и он расшибается, как в Minecraft; изредка из\n // точки вылезает клещ Предела (registry/mobs/beyond)\n events.on(\"pearlLand\", (p: { x: number; y: number; z: number }) => {\n const pl = game.player;\n if (pl.dead) return;\n game.warmup(p.x, p.z);\n let y = Math.floor(p.y);\n while (\n y > 1 &&\n !game.world.isSolid(Math.floor(p.x), y - 1, Math.floor(p.z)) &&\n y > Math.floor(p.y) - 4\n )\n y--;\n pl.pos.set(Math.floor(p.x) + 0.5, y, Math.floor(p.z) + 0.5);\n pl.vel.set(0, 0, 0);\n pl._fallDist = 0;\n if (pl.mode !== \"creative\") pl.damage(5, \"teleport\");\n events.emit(\"pearlTeleport\", { x: pl.pos.x, y: pl.pos.y, z: pl.pos.z });\n if (Math.random() < 0.05)\n game.entities.spawnMob(\"rim_mite\", pl.pos.x, pl.pos.y, pl.pos.z);\n });\n // фейерверк с земли: ракета взлетает и рассыпается искрами (gfx/Fireworks)\n events.on(\"fireworkLaunch\", (p: { x: number; y: number; z: number }) =>\n game.fireworks.launch(p.x, p.y, p.z),\n );\n // компас по путевому камню: стрелка держится за него, пока камень цел (ui/HUD)\n events.on(\"lodestoneBind\", (p: { x: number; y: number; z: number }) => {\n game.player.lodestone = { x: p.x, z: p.z, dim: game.dimension };\n game.hud.message(\"Компас привязан к путевому камню\", 2000);\n });\n // якорь возрождения: в Пекле — точка возрождения, в верхнем мире взрывается, как в Minecraft\n events.on(\n \"anchorUse\",\n (p: { x: number; y: number; z: number; charges: number }) => {\n if (!DIMENSIONS[game.dimension].bedsExplode) {\n game.world.setBlock(p.x, p.y, p.z, B.air!);\n game.entities.explode(p.x + 0.5, p.y + 0.5, p.z + 0.5, 5);\n return;\n }\n if (p.charges <= 0) {\n game.hud.message(\"Якорь не заряжен — нужен светокамень\", 2200);\n return;\n }\n game.player.anchorPos = [p.x, p.y, p.z];\n game.player.anchorDim = game.dimension;\n game.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(game.world, p.x, p.y, p.z, p.hx, p.hy, p.hz);\n },\n );\n events.on(\"blockPlace\", (p: { x: number; y: number; z: number }) => {\n if (overworld())\n game.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n game.player.mode !== \"creative\",\n );\n });\n events.on(\"explosion\", (p: { x: number; y: number; z: number }) => {\n if (overworld()) game.sculk.vibrate(p.x, p.y, p.z, false);\n });\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const e = p.entity;\n if (overworld() && e.hitByPlayerT > 0 && !e.def.warden)\n game.sculk.onDeath(e.pos.x, e.pos.y, e.pos.z, e.def.xp?.[1] ?? 1);\n });\n // моб волны спавнера испытаний убит — засчитать спавнеру\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const f = p.entity.trialFrom;\n const be = f ? game.blockEntities.get(f[0], f[1], f[2]) : undefined;\n if (be?.type === \"trial_spawner\") (be.state as TrialState).killed++;\n });\n events.on(\"playerDied\", () => {\n const pp = game.player.pos;\n game.lastDeath = { x: pp.x, y: pp.y, z: pp.z, dim: game.dimension };\n });\n // проводник: игрок в воде в его радиусе получает силу моря (дыхание, ночное зрение, спешка)\n events.on(\n \"conduitPulse\",\n (p: { x: number; y: number; z: number; range: number }) => {\n const pl = game.player;\n if (\n pl.dead ||\n !pl.inWater ||\n pl.pos.distanceTo(new THREE.Vector3(p.x + 0.5, p.y + 0.5, p.z + 0.5)) >\n p.range\n )\n return;\n for (const id of [\"water_breathing\", \"night_vision\", \"haste\"] as const)\n pl.addEffect(id, 13);\n events.emit(\"conduitPower\");\n },\n );\n // маяк: игрок в зоне получает выбранные силы\n events.on(\"beaconPulse\", (p: BeaconPulse) => {\n const pl = game.player;\n if (pl.dead || !inBeaconRange(p, pl.pos.x, pl.pos.y, pl.pos.z)) return;\n for (const e of beaconEffects(p)) pl.addEffect(e.id, e.seconds, e.level);\n });\n\n events.on(\"ui:pause\", () => {\n game.playing = false;\n if (game.invUI.open) game.invUI.close();\n game.hud.showOverlay(true);\n void game.persist();\n });\n events.on(\"ui:resume\", () => {\n game.playing = true;\n game.hud.hideOverlay();\n });\n}\n"
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
"path": "game/worlds.ts",
|
|
308
|
+
"content": "import { events } from \"../core/EventBus\";\nimport { ChunkManager } from \"../world/ChunkManager\";\nimport { EntityManager } from \"../entities/EntityManager\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport { portalsOf } from \"../world/Portal\";\nimport { newWorldId } from \"../systems/WorldStore\";\nimport { CURRENT_GEN } from \"../worldgen/WorldGenerator\";\nimport type { SaveData, WorldSource } from \"../types\";\nimport type { Game } from \"../main\";\n\nexport async function persist(game: Game): Promise<void> {\n if (!game.store || game._netGuest) return;\n game.net?.collectPlayerStates(game.netPlayers);\n try {\n await game.store.put({\n id: game.worldId,\n name: game.worldName,\n updatedAt: Date.now(),\n save: game.save.collect(),\n });\n } catch {\n // квота/запрет хранилища: игра продолжается без автосейва\n }\n}\n\nexport async function openStoredWorld(\n game: Game,\n id: string,\n): Promise<boolean> {\n if (!game.store) return false;\n const w = await game.store.get(id).catch(() => undefined);\n if (!w) return false;\n if (id !== game.worldId) await persist(game);\n try {\n game.save.apply(w.save);\n } catch {\n return false;\n }\n game._worldTouched = true;\n game.worldId = w.id;\n game.worldName = w.name;\n return true;\n}\n\nexport function openSharedWorld(\n game: Game,\n save: SaveData,\n name: string,\n): boolean {\n // persist() снимает сейв синхронно при вызове — до apply ниже, поэтому уходит именно старый мир.\n void persist(game);\n try {\n game.save.apply(save);\n } catch {\n return false;\n }\n adoptNewWorld(game, name);\n game.hud.message(\"Мир «\" + name + \"» из Мастерской\", 2200);\n return true;\n}\n\nexport function adoptNewWorld(game: Game, name: string) {\n game._worldTouched = true;\n game.worldId = newWorldId();\n game.worldName = name;\n void persist(game);\n}\n\nexport function warmup(game: Game, x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n game.world.update(x, z);\n const busy =\n game.world.genQueue.length > 0 ||\n [...game.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n}\n\nexport function warmupAtSpawn(game: Game) {\n const s = game.world.generator.spawnHint();\n warmup(game, s.x, s.z);\n}\n\nexport function rebuildWorld(\n game: Game,\n source: WorldSource,\n dimension: DimensionId = game.dimension,\n) {\n game.world.clear();\n game.world = new ChunkManager(game.scene, game.atlas, source, dimension);\n game.dimension = dimension;\n game.blockEntities.clear();\n game.player.world = game.world;\n game.entities.world = game.world;\n game.interaction.world = game.world;\n game.fluids.world = game.world;\n game.fluids.clear();\n game.interaction.breaking = null;\n game.interaction.damage.clear(); // трещины старого мира не должны лечь на блоки нового\n game.interaction.eating = null;\n game.intents.clear();\n}\n\nexport function newWorld(game: Game, 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 persist(game); // старый мир остаётся в «Моих мирах» таким, каким его оставили\n game.dims.clear();\n game.portals = portalsOf(null);\n game.beyondState = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n game.villagesSettled = new Set();\n game.achievements.load([]); // достижения — у каждого мира свои, как в Minecraft\n game.maps = new Map();\n game.nextMapId = 1;\n game.daysAwake = 0;\n rebuildWorld(game, src, \"overworld\");\n game.entities.clear();\n game.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n if (src.kind === \"spec\")\n game.dayNight.setTimeOfDay(src.spec.ambience.timeOfDay);\n else game.dayNight.resetToMorning();\n warmupAtSpawn(game);\n game.player.resetForNewWorld(); // кровать, якорь, эффекты, опыт старого мира — не переносятся\n game.freezing.t = 0;\n game.player.spawn();\n const name = src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed;\n adoptNewWorld(game, name);\n game.hud.message(\n src.kind === \"spec\"\n ? \"Мир «\" + src.spec.name + \"»\"\n : \"Новый мир, сид \" + src.seed,\n 1800,\n );\n}\n\nexport function changeDimension(\n game: Game,\n target: DimensionId,\n x: number,\n y: number,\n z: number,\n) {\n if (target === game.dimension) return;\n const prev = game.dimension;\n // сетевая игра: целевое измерение — из общей копии дифов (net/NetGame.beforeDimension)\n game.net?.beforeDimension(target);\n game.dims.set(game.dimension, {\n chunks: game.world.serializeDiffs(),\n entities: game.entities.serialize(),\n blockEntities: game.blockEntities.serialize(),\n });\n // прежнее снято (с сущностями — для следующего владельца); теперь роль в новом измерении\n game.net?.prepareDimension();\n const saved = game.dims.get(target);\n game.dims.delete(target);\n rebuildWorld(game, game.world.source, target);\n game.entities.clear();\n if (saved) {\n game.world.loadDiffs(saved.chunks);\n game.entities.deserialize(\n (saved.entities ?? []) as Parameters<EntityManager[\"deserialize\"]>[0],\n );\n game.blockEntities.deserialize(saved.blockEntities);\n }\n warmup(game, x, z);\n game.player.pos.set(x, y, z);\n game.player.vel.set(0, 0, 0);\n game.player._fallDist = 0;\n // сетевая игра: отдать прежнее измерение (если владел) и заявить новое — до любых правок в нём\n game.net?.afterDimension(prev, target);\n events.emit(\"dimensionChanged\", { dimension: target });\n}\n"
|
|
309
|
+
},
|
|
222
310
|
{
|
|
223
311
|
"path": "gfx/ArmorStand.ts",
|
|
224
312
|
"content": "// Броня на стойках: перед каждой занятой стойкой висят её вещи — шлем выше, ботинки ниже.\n// Устроено как предметы в рамках (gfx/DisplayItems): каждый кадр сверяем меши с блочными сущностями.\nimport * as THREE from \"three\";\nimport { itemMesh } from \"./ItemMesh\";\nimport type { TextureAtlas } from \"./TextureAtlas\";\nimport type { BlockEntities } from \"../world/BlockEntities\";\nimport type { ArmorStandState } from \"../world/blockentities/armorStand\";\n\n/** Высота каждой части над низом клетки: шлем, нагрудник, поножи, ботинки. */\nconst HEIGHTS = [1.25, 0.9, 0.55, 0.2];\n\nexport class ArmorStand {\n /** Ключ «клетка:слот» → меш и что на нём показано. */\n private readonly shown = new Map<\n string,\n { mesh: THREE.Mesh; item: string }\n >();\n\n constructor(\n private readonly scene: THREE.Scene,\n private readonly atlas: TextureAtlas,\n ) {}\n\n update(bes: BlockEntities): void {\n const seen = new Set<string>();\n for (const [k, be] of bes.map) {\n if (be.type !== \"armor_stand\") continue;\n const items = (be.state as ArmorStandState).items;\n for (let i = 0; i < HEIGHTS.length; i++) {\n const it = items[i];\n if (!it) continue;\n const key = `${k}:${i}`;\n let cur = this.shown.get(key);\n if (cur && cur.item !== it.item) {\n this.drop(key, cur.mesh);\n cur = undefined;\n }\n if (!cur) {\n const mesh = itemMesh(this.atlas, it.item);\n if (!mesh) continue;\n this.scene.add(mesh);\n cur = { mesh, item: it.item };\n this.shown.set(key, cur);\n }\n seen.add(key);\n cur.mesh.position.set(be.x + 0.5, be.y + HEIGHTS[i]!, be.z + 0.5);\n }\n }\n for (const [key, cur] of this.shown)\n if (!seen.has(key)) this.drop(key, cur.mesh);\n }\n\n private drop(key: string, mesh: THREE.Mesh): void {\n this.scene.remove(mesh);\n this.shown.delete(key);\n }\n}\n"
|
|
225
313
|
},
|
|
314
|
+
{
|
|
315
|
+
"path": "gfx/atlas/door-crack.ts",
|
|
316
|
+
"content": "import { PAINTERS, TILE, noisy, shade, type Painter } from \"../paint\";\nimport { painters } from \"./registry\";\n// The painters are merged into the shared PAINTERS at the end of this file: the wool and tool tiles\n// (and the base set) must be registered first, so this module imports them for that order.\nimport \"./wool\";\nimport \"./tools\";\n\n/** Дверь: вертикальные доски в рамке; у верхней половины — окно (прозрачные пиксели). */\nfunction doorPainter(window: boolean): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const frame = x === 0 || x === 15 || (window ? y === 0 : y === 15);\n const seam = x === 5 || x === 10;\n if (window && !frame && y >= 3 && y <= 8 && x >= 2 && x <= 13 && !seam)\n continue;\n px(\n x,\n y,\n frame || seam ? \"#6e5631\" : shade(\"#a4824c\", (rng() - 0.5) * 0.12),\n );\n }\n };\n}\npainters.oak_door_bottom = doorPainter(false);\npainters.oak_door_top = doorPainter(true);\npainters.oak_door_item = (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 4; x < 12; x++) {\n const frame = x === 4 || x === 11 || y === 0 || y === 15;\n const mullion = x === 7 || x === 8;\n if (!frame && !mullion && y >= 2 && y <= 6) continue; // окно\n px(\n x,\n y,\n frame || mullion ? \"#6e5631\" : shade(\"#a4824c\", (rng() - 0.5) * 0.12),\n );\n }\n};\npainters.oak_trapdoor = (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const hx = x === 3 || x === 4 || x === 11 || x === 12;\n const hy = y === 3 || y === 4 || y === 11 || y === 12;\n if (hx && hy) continue; // окошки\n const frame =\n x === 0 || x === 15 || y === 0 || y === 15 || x === 7 || x === 8;\n px(x, y, frame ? \"#6e5631\" : shade(\"#a4824c\", (rng() - 0.5) * 0.12));\n }\n};\n\n// ---- природные (сейв v3): лава, обсидиан ------------------------------------------\n\npainters.lava = (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const hot = rng() < 0.14;\n px(x, y, hot ? \"#ffe27a\" : shade(\"#e8661c\", (rng() - 0.5) * 0.35));\n }\n};\npainters.obsidian = (px, rng) => {\n noisy(px, \"#1a1024\", 0.14, rng);\n for (let i = 0; i < 7; i++)\n px(\n (rng() * TILE) | 0,\n (rng() * TILE) | 0,\n shade(\"#4b2a6e\", (rng() - 0.5) * 0.3),\n );\n};\n\n// ---- стадии трещин (последние 10 тайлов атласа) ------------------------------\n\nfunction crackPainter(stage: number): Painter {\n return (px, rng) => {\n // от центра расходятся stage+1 ломаных трещин; глубже стадия — гуще\n const cracks = 2 + stage;\n for (let c = 0; c < cracks; c++) {\n let x = 7 + rng() * 2,\n y = 7 + rng() * 2;\n const ang = rng() * Math.PI * 2;\n let dx = Math.cos(ang),\n dy = Math.sin(ang);\n const len = 3 + stage * 1.2;\n for (let i = 0; i < len; i++) {\n px(Math.round(x) & 15, Math.round(y) & 15, \"rgba(20,15,10,0.85)\");\n x += dx;\n y += dy;\n dx += (rng() - 0.5) * 0.8;\n dy += (rng() - 0.5) * 0.8; // излом\n const n = Math.hypot(dx, dy) || 1;\n dx /= n;\n dy /= n;\n }\n }\n // на поздних стадиях — крошка по всему тайлу\n for (let i = 0; i < stage * 4; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"rgba(20,15,10,0.7)\");\n };\n}\nfor (let s = 0; s < 10; s++) painters[\"crack\" + s] = crackPainter(s);\n\n// исходные тайлы — в общий реестр (части из gfx/painters/* уже там: их импорт выполнился раньше)\nObject.assign(PAINTERS, painters);\n\n// ---- сборка атласа ------------------------------------------------------------\n"
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
"path": "gfx/atlas/registry.ts",
|
|
320
|
+
"content": "import { TILE, noisy, oreBlobs, shade, type Painter } from \"../paint\";\n\nexport const GRID = 32; // тайлов в ряду (до 1024 тайлов)\n\nexport const SIZE = TILE * GRID;\n\n// ---- painter-функции: (px, rng) → рисуют один тайл 16×16 ---------------------\n// px(x, y, cssColor) закрашивает один пиксель тайла.\n\nexport const painters: Record<string, Painter> = {\n grass_top(px, rng) {\n noisy(px, \"#5fa63c\", 0.1, rng);\n },\n dirt(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n },\n grass_side(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n for (let x = 0; x < TILE; x++) {\n const depth = 3 + ((rng() * 2) | 0); // рваная кромка дёрна\n for (let y = 0; y < depth; y++)\n px(x, y, shade(\"#5fa63c\", (rng() - 0.5) * 0.2));\n }\n },\n stone(px, rng) {\n noisy(px, \"#7d7d7d\", 0.08, rng);\n for (let i = 0; i < 5; i++) {\n // светлые/тёмные пятна\n const x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0,\n c = shade(\"#7d7d7d\", rng() < 0.5 ? -0.15 : 0.12);\n px(x, y, c);\n px((x + 1) & 15, y, c);\n }\n },\n cobblestone(px, rng) {\n noisy(px, \"#828282\", 0.1, rng);\n // «булыжники»: тёмная сетка неровных швов\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const cell = (x + (y & 4 ? 3 : 0)) % 6 === 0 || y % 6 === 0;\n if (cell && rng() < 0.8)\n px(x, y, shade(\"#4a4a4a\", (rng() - 0.5) * 0.2));\n }\n },\n bedrock(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(rng() < 0.5 ? \"#2b2b2b\" : \"#565656\", (rng() - 0.5) * 0.3),\n );\n },\n sand(px, rng) {\n noisy(px, \"#dcd3a0\", 0.08, rng);\n },\n oak_log_side(px, rng) {\n for (let x = 0; x < TILE; x++) {\n const stripe = x % 4 === 3 ? -0.25 : (rng() - 0.5) * 0.15; // кора полосами\n for (let y = 0; y < TILE; y++)\n px(x, y, shade(\"#6b5030\", stripe + (rng() - 0.5) * 0.08));\n }\n },\n oak_log_top(px, rng) {\n noisy(px, \"#6b5030\", 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++)\n px(x, y, shade(\"#b8945f\", (rng() - 0.5) * 0.15));\n // годовые кольца\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, \"#8a6d42\");\n }\n }\n },\n planks(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#a4824c\", (rng() - 0.5) * 0.12);\n if (y % 4 === 3) c = \"#6e5631\"; // горизонтальные швы досок\n if (\n (y < 4 && x === 11) ||\n (y >= 4 && y < 8 && x === 3) ||\n (y >= 8 && y < 12 && x === 13) ||\n (y >= 12 && x === 6)\n )\n c = \"#6e5631\"; // стыки\n px(x, y, c);\n }\n },\n leaves(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < 0.18) continue; // прозрачные «дырки» (alphaTest-вырезы)\n px(x, y, shade(\"#3e7a25\", (rng() - 0.5) * 0.35));\n }\n },\n water(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#3057d8\", (rng() - 0.5) * 0.12);\n if ((x + y * 2) % 8 === 0 && rng() < 0.5) c = shade(\"#4a74ea\", 0.1); // блики\n px(x, y, c);\n }\n },\n coal_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#2e2e2e\");\n },\n iron_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#d8af93\");\n },\n crafting_table_top(px, rng) {\n painters.planks!(px, rng);\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#59431f\");\n px(i, 15, \"#59431f\");\n px(0, i, \"#59431f\");\n px(15, i, \"#59431f\");\n }\n for (let y = 3; y < 13; y++)\n for (let x = 3; x < 13; x++)\n if (x === 3 || x === 12 || y === 3 || y === 12) px(x, y, \"#7d6134\");\n },\n crafting_table_side(px, rng) {\n painters.planks!(px, rng);\n // «инструменты» на боковине: тёмные силуэты\n for (let y = 2; y < 7; y++)\n for (let x = 3; x < 6; x++) if (rng() < 0.8) px(x, y, \"#4d3a1c\");\n for (let y = 2; y < 7; y++)\n for (let x = 10; x < 13; x++) if (rng() < 0.8) px(x, y, \"#57422a\");\n },\n};\n\n// ---- тайлы блоков генератора миров ---------------------------------------------\n// Отдельные функции, а не правка существующих painter'ов: у каждого тайла свой сид PRNG, и\n// общий код менял бы порядок случайных чисел — старые тайлы перерисовались бы иначе.\n\n/** Кора бревна вертикальными полосами. */\nexport function logSidePainter(bark: string, stripe: string): Painter {\n return (px, rng) => {\n for (let x = 0; x < TILE; x++) {\n const dark = x % 4 === 3;\n for (let y = 0; y < TILE; y++)\n px(x, y, dark ? stripe : shade(bark, (rng() - 0.5) * 0.14));\n }\n };\n}\n\n/** Спил бревна: кора по краю, светлая древесина и годовые кольца. */\nexport function logTopPainter(\n bark: string,\n wood: string,\n ring: string,\n): Painter {\n return (px, rng) => {\n noisy(px, bark, 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++) px(x, y, shade(wood, (rng() - 0.5) * 0.14));\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, ring);\n }\n }\n };\n}\n\n/** Листва с прозрачными «дырками» (alphaTest-вырезы). */\nexport function leavesPainter(color: string, holes: number): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < holes) continue;\n px(x, y, shade(color, (rng() - 0.5) * 0.35));\n }\n };\n}\n"
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
"path": "gfx/atlas/tiles.ts",
|
|
324
|
+
"content": "import { PAINTERS } from \"../paint\";\nimport { MATERIAL_TILES } from \"../painters/materials\";\nimport { FUNCTIONAL_TILES } from \"../painters/functional\";\nimport { MOBDROP_TILES } from \"../painters/mobdrops\";\nimport { UTILITY_TILES } from \"../painters/utility\";\nimport { FARMING_TILES } from \"../painters/farming\";\nimport { INFERNO_TILES } from \"../painters/inferno\";\nimport { BEYOND_TILES } from \"../painters/beyond\";\nimport { BREWING_TILES } from \"../painters/brewing\";\nimport { REDSTONE_TILES } from \"../painters/redstone\";\nimport { LATE_TILES, LATE_WOOD_TILES, WOOD_TILES } from \"../painters/wood\";\nimport { COLOR_TILES } from \"../painters/colors\";\nimport { OUTDOOR_TILES } from \"../painters/outdoor\";\nimport { BOSS_TILES } from \"../painters/boss\";\nimport { CITY_TILES } from \"../painters/city\";\nimport { OCEAN_TILES } from \"../painters/ocean\";\nimport { CAMP_TILES } from \"../painters/camp\";\nimport { SEA_LIFE_TILES } from \"../painters/sealife\";\nimport { ILLAGER_TILES } from \"../painters/illager\";\nimport { TREASURE_TILES } from \"../painters/treasure\";\nimport { MANSION_TILES } from \"../painters/mansion\";\nimport { CAVE_TILES } from \"../painters/caves\";\nimport { SCULK_TILES } from \"../painters/sculk\";\nimport { DRIPSTONE_TILES } from \"../painters/dripstone\";\nimport { MOUNTAIN_TILES } from \"../painters/mountain\";\nimport { ARCHAEOLOGY_TILES } from \"../painters/archaeology\";\nimport { MANGROVE_TILES } from \"../painters/mangrove\";\nimport { SNIFFER_TILES } from \"../painters/sniffer\";\nimport { TRIAL_TILES } from \"../painters/trial\";\nimport { COPPER_TILES } from \"../painters/copper\";\nimport { DECOR_TILES } from \"../painters/decor\";\nimport { FROST_TILES } from \"../painters/frost\";\nimport { MUSHROOM_TILES } from \"../painters/mushroom\";\nimport { JUNGLE_TILES } from \"../painters/jungle\";\nimport { CRIMSON_TILES } from \"../painters/crimson\";\nimport { BASALT_TILES } from \"../painters/basalt\";\nimport { COCOA_TILES } from \"../painters/cocoa\";\nimport { CRAFTED_TILES } from \"../painters/crafted\";\nimport { SOUL_CAMP_TILES } from \"../painters/soulcamp\";\nimport { ARMADILLO_TILES } from \"../painters/armadillo\";\nimport { LEAD_TILES } from \"../painters/lead\";\nimport { SHIELD_TILES } from \"../painters/shield\";\nimport { RANGED_TILES } from \"../painters/ranged\";\nimport { WORKSTATION_TILES } from \"../painters/workstations\";\nimport { STONECUTTER_TILES } from \"../painters/stonecutter\";\nimport { JOB_TILES } from \"../painters/jobblocks\";\nimport { CURE_TILES } from \"../painters/cure\";\nimport { SOUND_TILES } from \"../painters/sound\";\nimport { HALL_TILES } from \"../painters/hall\";\nimport { DISPLAY_TILES } from \"../painters/display\";\nimport { SIGN_TILES } from \"../painters/sign\";\nimport { BANNER_TILES } from \"../painters/banner\";\nimport { BOOK_TILES } from \"../painters/book\";\nimport { EXTRA_TILES } from \"../painters/extras\";\nimport { STAND_TILES } from \"../painters/stand\";\nimport { POTION_TILES } from \"../painters/potions\";\nimport { TIPPED_TILES } from \"../painters/tipped\";\nimport { NAMETAG_TILES } from \"../painters/nametag\";\nimport { GRID } from \"./registry\";\n\n// Порядок = индекс тайла. Новые тайлы — только в конец (хвост атласа занят трещинами).\nexport const TILE_ORDER = [\n \"grass_top\",\n \"grass_side\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"bedrock\",\n \"sand\",\n \"oak_log_side\",\n \"oak_log_top\",\n \"planks\",\n \"leaves\",\n \"water\",\n \"coal_ore\",\n \"iron_ore\",\n \"crafting_table_top\",\n \"crafting_table_side\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n \"stick\",\n \"feather\",\n \"raw_chicken\",\n \"coal_item\",\n \"apple\",\n \"cooked_chicken\",\n \"wooden_pickaxe\",\n \"wooden_axe\",\n \"wooden_sword\",\n // блоки генератора миров (дописываются в конец — индексы старых тайлов не сдвигаются)\n \"snow\",\n \"ice\",\n \"gravel\",\n \"clay\",\n \"sandstone_top\",\n \"sandstone_side\",\n \"red_sand\",\n \"terracotta\",\n \"stone_bricks\",\n \"mossy_cobblestone\",\n \"bricks\",\n \"glass\",\n \"spruce_log_side\",\n \"spruce_log_top\",\n \"spruce_leaves\",\n \"birch_log_side\",\n \"birch_log_top\",\n \"birch_leaves\",\n \"dark_planks\",\n \"dirt_path_top\",\n \"white_wool\",\n \"red_wool\",\n \"orange_wool\",\n \"yellow_wool\",\n \"green_wool\",\n \"blue_wool\",\n \"light_blue_wool\",\n \"purple_wool\",\n \"pink_wool\",\n \"brown_wool\",\n \"gray_wool\",\n \"black_wool\",\n // блоки с формами (сейв v3)\n \"torch\",\n \"ladder\",\n \"oak_door_bottom\",\n \"oak_door_top\",\n \"oak_door_item\",\n \"oak_trapdoor\",\n // природные (сейв v3)\n \"lava\",\n \"obsidian\",\n // выживание: руды, блоки хранения, материалы, инструменты, броня (gfx/painters/materials.ts)\n ...MATERIAL_TILES,\n // печь, сундук (gfx/painters/functional.ts)\n ...FUNCTIONAL_TILES,\n // добыча с мобов и оружие (gfx/painters/mobdrops.ts)\n ...MOBDROP_TILES,\n // вёдра, огниво, огонь (gfx/painters/utility.ts)\n ...UTILITY_TILES,\n // пашня, посевы по стадиям, сено, урожай (gfx/painters/farming.ts)\n ...FARMING_TILES,\n // Пекло: камень, песок душ, светокамень, кварц, магма, кирпич, портал (gfx/painters/inferno.ts)\n ...INFERNO_TILES,\n // рамка и портал Предела, камень Предела, яйцо, Око (gfx/painters/beyond.ts)\n ...BEYOND_TILES,\n // варочная стойка, нарост, пузырьки и зелья (gfx/painters/brewing.ts)\n ...BREWING_TILES,\n // красный камень: провод, факел, лампа, рычаг (gfx/painters/redstone.ts)\n ...REDSTONE_TILES,\n // породы дерева: доски, брёвна, листва, саженцы, двери, люки (gfx/painters/wood.ts)\n ...WOOD_TILES,\n // 16 цветов: новая шерсть и красители (gfx/painters/colors.ts)\n ...COLOR_TILES,\n // рыбалка, компас, часы, лодки (gfx/painters/outdoor.ts)\n ...OUTDOOR_TILES,\n // Пепельный череп, маяк, звезда Пекла (gfx/painters/boss.ts)\n ...BOSS_TILES,\n // внешние острова и города Предела (gfx/painters/city.ts)\n ...CITY_TILES,\n // океан: морская трава, морской камень, губки, черепахи (gfx/painters/ocean.ts)\n ...OCEAN_TILES,\n // активирующие рельсы, костёр, вагонетки с грузом (gfx/painters/camp.ts)\n ...CAMP_TILES,\n // кораллы, ламинария, рыбы (gfx/painters/sealife.ts)\n ...SEA_LIFE_TILES,\n // тотем жизни (gfx/painters/illager.ts)\n ...ILLAGER_TILES,\n // вёдра с рыбой, клад, проводник (gfx/painters/treasure.ts)\n ...TREASURE_TILES,\n // стол картографа и карты-исследователи (gfx/painters/mansion.ts)\n ...MANSION_TILES,\n // аметист, мох, лозы, подзорная труба (gfx/painters/caves.ts)\n ...CAVE_TILES,\n // скалк, сланец древнего города, осколок эха (gfx/painters/sculk.ts)\n ...SCULK_TILES,\n // капельник, котёл, козий рог (gfx/painters/dripstone.ts)\n ...DRIPSTONE_TILES,\n // вишня: доски, бревно, листва, саженец, дверь, люк, лодка (gfx/painters/wood.ts, поздние породы)\n ...LATE_WOOD_TILES,\n // снег-порох, лепестки (gfx/painters/mountain.ts)\n ...MOUNTAIN_TILES,\n // подозрительный песок и гравий, горшок, кисть, черепки (gfx/painters/archaeology.ts)\n ...ARCHAEOLOGY_TILES,\n // мангр — вторая волна поздних пород (gfx/painters/wood.ts)\n ...LATE_TILES[2]!,\n // грязь, корни, икра, лягушачий свет, магмовый крем (gfx/painters/mangrove.ts)\n ...MANGROVE_TILES,\n // нюхач и древние растения (gfx/painters/sniffer.ts)\n ...SNIFFER_TILES,\n // испытательные камеры, булава (gfx/painters/trial.ts)\n ...TRIAL_TILES,\n // окисление меди, медная лампа и решётка, шаблоны отделки (gfx/painters/copper.ts)\n ...COPPER_TILES,\n // фонари, цепь, свеча, мешок (gfx/painters/decor.ts)\n ...DECOR_TILES,\n // снежок (gfx/painters/frost.ts)\n ...FROST_TILES,\n // грибные поля (gfx/painters/mushroom.ts)\n ...MUSHROOM_TILES,\n // бамбук (gfx/painters/jungle.ts)\n ...JUNGLE_TILES,\n // биомы Пекла (gfx/painters/crimson.ts)\n ...CRIMSON_TILES,\n // базальт, чернокамень, кости, огонь душ (gfx/painters/basalt.ts)\n ...BASALT_TILES,\n // какао, печенье, факел душ (gfx/painters/cocoa.ts)\n ...COCOA_TILES,\n // цветное стекло, полированный базальт и чернокамень (gfx/painters/crafted.ts)\n ...CRAFTED_TILES,\n // костёр душ (gfx/painters/soulcamp.ts)\n ...SOUL_CAMP_TILES,\n // щиток броненосца, броня для волка (gfx/painters/armadillo.ts)\n ...ARMADILLO_TILES,\n // поводок (gfx/painters/lead.ts)\n ...LEAD_TILES,\n // щит (gfx/painters/shield.ts)\n ...SHIELD_TILES,\n // арбалет, трезубец (gfx/painters/ranged.ts)\n ...RANGED_TILES,\n // рабочие блоки жителей (gfx/painters/workstations.ts)\n ...WORKSTATION_TILES,\n // камнерез (gfx/painters/stonecutter.ts)\n ...STONECUTTER_TILES,\n // точило, ткацкий станок, кафедра, стол фехтовальщика (gfx/painters/jobblocks.ts)\n ...JOB_TILES,\n // золотое яблоко (gfx/painters/cure.ts)\n ...CURE_TILES,\n // проигрыватель, нотный блок, пластинки (gfx/painters/sound.ts)\n ...SOUND_TILES,\n // колокол и картины (gfx/painters/hall.ts)\n ...HALL_TILES,\n // подставка (gfx/painters/display.ts)\n ...DISPLAY_TILES,\n // табличка (gfx/painters/sign.ts)\n ...SIGN_TILES,\n // знамёна 16 цветов (gfx/painters/banner.ts)\n ...BANNER_TILES,\n // книги (gfx/painters/book.ts)\n ...BOOK_TILES,\n // сундук Предела, леса, мишень (gfx/painters/extras.ts)\n ...EXTRA_TILES,\n // стойка для брони, путевой камень, якорь (gfx/painters/stand.ts)\n ...STAND_TILES,\n // остальные зелья (gfx/painters/potions.ts)\n ...POTION_TILES,\n // отравленные стрелы (gfx/painters/tipped.ts)\n ...TIPPED_TILES,\n // бирка с именем (gfx/painters/nametag.ts)\n ...NAMETAG_TILES,\n];\n\n/** Индекс тайла crack0: трещины — последние 10 тайлов, TILE_ORDER растёт им навстречу. */\nexport const CRACK_BASE = GRID * GRID - 10;\n\n/** Сколько тайлов помещается в TILE_ORDER. */\nexport const TILE_CAPACITY = CRACK_BASE;\n\n/** Есть ли у тайла функция рисования (проверка реестров в тестах). */\nexport function hasPainter(name: string): boolean {\n return name in PAINTERS;\n}\n"
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
"path": "gfx/atlas/tools.ts",
|
|
328
|
+
"content": "import { P, TILE, shade } from \"../paint\";\nimport { painters } from \"./registry\";\n\nconst TOOL_PALETTE = { w: \"#a4824c\", h: \"#6e5631\", s: \"#8a6a3a\" };\npainters.wooden_pickaxe = P(\n [\n \"................\",\n \"....wwwwwww.....\",\n \"...ww.....ww....\",\n \"..ww.......ww...\",\n \"..w.........w...\",\n \"..w....s....w...\",\n \".......s........\",\n \"......ss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_axe = P(\n [\n \"................\",\n \".....wwww.......\",\n \"....wwwwww......\",\n \"....ww..ww......\",\n \"....ww.ss.......\",\n \"....wwss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"..ss............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_sword = P(\n [\n \"................\",\n \"...........ww...\",\n \"..........www...\",\n \".........www....\",\n \"........www.....\",\n \".......www......\",\n \"......www.......\",\n \".....www........\",\n \"..h.www.........\",\n \"...hwww.........\",\n \"...shh..........\",\n \"..ss.hh.........\",\n \"..ss..hh........\",\n \".ss.............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\n\n// ---- блоки с формами (сейв v3): факел, лестница, дверь, люк -----------------------\n\n// Факел: пламя и древко в колонках 7–8 (коробка факела вырезает ровно их), древко — строки 8–15.\npainters.torch = P(\n [\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n \".......FF.......\",\n \".......ff.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n ],\n { F: \"#ffe36a\", f: \"#f0a030\", s: \"#6b4a24\" },\n);\n\npainters.ladder = (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (x === 2 || x === 13) px(x, y, shade(\"#7a5a30\", (rng() - 0.5) * 0.12));\n else if (y % 4 === 2 && x > 2 && x < 13)\n px(x, y, shade(\"#9a7442\", (rng() - 0.5) * 0.12));\n }\n};\n"
|
|
329
|
+
},
|
|
330
|
+
{
|
|
331
|
+
"path": "gfx/atlas/wool.ts",
|
|
332
|
+
"content": "import {\n P,\n TILE,\n noisy,\n shade,\n type Painter,\n type PxFn,\n type RngFn,\n} from \"../paint\";\nimport {\n leavesPainter,\n logSidePainter,\n logTopPainter,\n painters,\n} from \"./registry\";\n\n/** Шерсть: ровный цвет с лёгким «плетением». */\nfunction woolPainter(color: string): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const weave = (x + y) % 4 === 0 ? -0.07 : 0;\n px(x, y, shade(color, weave + (rng() - 0.5) * 0.08));\n }\n };\n}\n\nObject.assign(painters, {\n snow(px: PxFn, rng: RngFn) {\n noisy(px, \"#eef3f8\", 0.04, rng);\n for (let i = 0; i < 6; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"#ffffff\");\n },\n ice(px: PxFn, rng: RngFn) {\n noisy(px, \"#9cc7f0\", 0.05, rng);\n // светлые трещины-блики по диагонали\n for (let i = 0; i < 3; i++) {\n let x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0;\n for (let k = 0; k < 6; k++) {\n px(x & 15, y & 15, \"#d6ecff\");\n x += 1;\n y += rng() < 0.5 ? 1 : 0;\n }\n }\n },\n gravel(px: PxFn, rng: RngFn) {\n const tones = [\"#857f7a\", \"#6e6863\", \"#9d9690\", \"#5b5550\"];\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(tones[(rng() * tones.length) | 0]!, (rng() - 0.5) * 0.1),\n );\n },\n clay(px: PxFn, rng: RngFn) {\n noisy(px, \"#9ea4b0\", 0.06, rng);\n },\n sandstone_top(px: PxFn, rng: RngFn) {\n noisy(px, \"#d9c98f\", 0.05, rng);\n },\n sandstone_side(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const base = y < 3 ? \"#e3d49c\" : y >= 12 ? \"#c4b27a\" : \"#d9c98f\";\n px(x, y, y === 3 ? \"#b9a66f\" : shade(base, (rng() - 0.5) * 0.08));\n }\n },\n red_sand(px: PxFn, rng: RngFn) {\n noisy(px, \"#c2703a\", 0.08, rng);\n },\n terracotta(px: PxFn, rng: RngFn) {\n noisy(px, \"#a05a3c\", 0.05, rng);\n },\n stone_bricks(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const joint = y % 8 === 7 || x === (y < 8 ? 7 : 15);\n px(x, y, joint ? \"#555555\" : shade(\"#7a7a7a\", (rng() - 0.5) * 0.14));\n }\n },\n mossy_cobblestone(px: PxFn, rng: RngFn) {\n painters.cobblestone!(px, rng);\n for (let i = 0; i < 7; i++) {\n const cx = (rng() * TILE) | 0,\n cy = (rng() * TILE) | 0;\n for (let d = 0; d < 4; d++)\n px(\n (cx + ((rng() * 3) | 0) - 1) & 15,\n (cy + ((rng() * 3) | 0) - 1) & 15,\n shade(\"#5a7a3a\", (rng() - 0.5) * 0.2),\n );\n }\n },\n bricks(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const row = y >> 2;\n const offset = row % 2 ? 4 : 0;\n const mortar = y % 4 === 3 || (x + offset) % 8 === 7;\n px(x, y, mortar ? \"#c9bfb3\" : shade(\"#9a4a3a\", (rng() - 0.5) * 0.16));\n }\n },\n glass(px: PxFn, _rng: RngFn) {\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#dff2f7\");\n px(i, 15, \"#dff2f7\");\n px(0, i, \"#dff2f7\");\n px(15, i, \"#dff2f7\");\n }\n for (let x = 3; x < 9; x++) px(x, x - 1, \"#f4fbfd\"); // блик\n for (let x = 4; x < 8; x++) px(x + 1, x - 1, \"#f4fbfd\");\n },\n spruce_log_side: logSidePainter(\"#4a3520\", \"#33241a\"),\n spruce_log_top: logTopPainter(\"#4a3520\", \"#8a6a42\", \"#6e5232\"),\n spruce_leaves: leavesPainter(\"#2d5a3a\", 0.14),\n birch_log_side(px: PxFn, rng: RngFn) {\n noisy(px, \"#d7d2c4\", 0.05, rng);\n for (let i = 0; i < 9; i++) {\n const x = (rng() * 14) | 0,\n y = (rng() * TILE) | 0;\n px(x, y, \"#3a3a3a\");\n px(x + 1, y, \"#3a3a3a\");\n }\n },\n birch_log_top: logTopPainter(\"#d7d2c4\", \"#c9b27a\", \"#a88f5a\"),\n birch_leaves: leavesPainter(\"#5c8f3a\", 0.18),\n dark_planks(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const seam =\n y % 4 === 3 ||\n (y < 4 && x === 5) ||\n (y >= 4 && y < 8 && x === 12) ||\n (y >= 8 && y < 12 && x === 2) ||\n (y >= 12 && x === 9);\n px(x, y, seam ? \"#3a2714\" : shade(\"#5a3d22\", (rng() - 0.5) * 0.12));\n }\n },\n dirt_path_top(px: PxFn, rng: RngFn) {\n noisy(px, \"#9a7a45\", 0.1, rng);\n },\n white_wool: woolPainter(\"#e9ecec\"),\n red_wool: woolPainter(\"#a12722\"),\n orange_wool: woolPainter(\"#f07613\"),\n yellow_wool: woolPainter(\"#f8c527\"),\n green_wool: woolPainter(\"#5e7c16\"),\n blue_wool: woolPainter(\"#35399d\"),\n light_blue_wool: woolPainter(\"#3aafd9\"),\n purple_wool: woolPainter(\"#7a2aa9\"),\n pink_wool: woolPainter(\"#ed8dac\"),\n brown_wool: woolPainter(\"#724728\"),\n gray_wool: woolPainter(\"#3e4447\"),\n black_wool: woolPainter(\"#1d1d21\"),\n} satisfies Record<string, Painter>);\n\n// ---- пиксель-арт по строковым шаблонам (растения, предметы, инструменты) — P из ./paint -----\n\npainters.tallgrass = P(\n [\n \"................\",\n \"................\",\n \"....g......g....\",\n \"..g.g...g..g.g..\",\n \"..g.g..gg..g.g..\",\n \"..gg.g.g.g.gg...\",\n \"...g.g.g.g.g....\",\n \"...g.gg.gg.g....\",\n \"....gg.g.gg.....\",\n \"....g.gg.g......\",\n \".....g.g.g......\",\n \".....gggg.......\",\n \"......gg........\",\n \"......gg........\",\n \"................\",\n \"................\",\n ],\n { g: \"#4c8a2f\" },\n);\n\npainters.flower_yellow = P(\n [\n \"................\",\n \"................\",\n \"......yy........\",\n \".....yYYy.......\",\n \".....yYYy.......\",\n \"......yy........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { y: \"#e8c11c\", Y: \"#fbe87a\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.flower_red = P(\n [\n \"................\",\n \"................\",\n \"......rr........\",\n \".....rRRr.......\",\n \".....rRRr.......\",\n \"......rr........\",\n \".......s........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e86a5f\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.stick = P(\n [\n \"................\",\n \"................\",\n \"............w...\",\n \"...........ww...\",\n \"..........ww....\",\n \".........ww.....\",\n \"........ww......\",\n \".......ww.......\",\n \"......ww........\",\n \".....ww.........\",\n \"....ww..........\",\n \"...ww...........\",\n \"...w............\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { w: \"#8a6a3a\" },\n);\n\npainters.feather = P(\n [\n \"................\",\n \"...........ff...\",\n \"..........ffff..\",\n \".........fffff..\",\n \"........ffffff..\",\n \".......ffffff...\",\n \"......ffffff....\",\n \".....ffffff.....\",\n \"....ffffff......\",\n \"....fffff.......\",\n \"...ffff.........\",\n \"...fff..........\",\n \"..qf............\",\n \"..q.............\",\n \".q..............\",\n \"................\",\n ],\n { f: \"#f2f2f2\", q: \"#b9b9b9\" },\n);\n\npainters.raw_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPpppppp....\",\n \"...pPppppppppb..\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#e8a2a2\", P: \"#f6caca\", b: \"#e3ddc4\" },\n);\n\npainters.coal_item = P(\n [\n \"................\",\n \"................\",\n \"................\",\n \".....cccc.......\",\n \"....cccccc......\",\n \"...ccCccccc.....\",\n \"...cCccccccc....\",\n \"...ccccCccccc...\",\n \"....ccccccccc...\",\n \"....cccCccccc...\",\n \".....ccccccc....\",\n \"......ccccc.....\",\n \".......ccc......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { c: \"#2c2c2c\", C: \"#4d4d4d\" },\n);\n\npainters.apple = P(\n [\n \"................\",\n \".......g........\",\n \".......s........\",\n \".....rrsr.......\",\n \"....rRRrrr......\",\n \"...rRRrrrrr.....\",\n \"...rRrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"....rrrrrr......\",\n \"....rrrrrr......\",\n \".....rrrr.......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e8635a\", s: \"#6e4a24\", g: \"#4c8a2f\" },\n);\n\npainters.cooked_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPppppbp....\",\n \"...pPpppppppb...\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#c8843e\", P: \"#e0a35a\", b: \"#8a5a2a\" },\n);\n"
|
|
333
|
+
},
|
|
226
334
|
{
|
|
227
335
|
"path": "gfx/BannerArt.ts",
|
|
228
336
|
"content": "// Рисование знамён (systems/Banner): узор — маска на сетке 16×16, как тайл атласа. Одна таблица\n// масок обслуживает три места: тайлы полотна (gfx/painters/banner), полотно узора в мире\n// (gfx/Banners) и картинку варианта на ткацком станке (ui/InventoryUI).\nimport { DYE_COLORS } from \"../registry/blocks/colors\";\nimport type { BannerLayer } from \"../systems/Banner\";\nimport { TILE, shade, type PxFn } from \"./paint\";\n\n/** Маска узора: попадает ли пиксель сетки 16×16 в фигуру. */\ntype Mask = (x: number, y: number) => boolean;\n\nexport const PATTERN_MASKS: Record<string, Mask> = {\n top: (_x, y) => y < 8,\n bottom: (_x, y) => y >= 8,\n pale: (x) => x >= 6 && x <= 9,\n fess: (_x, y) => y >= 6 && y <= 9,\n cross: (x, y) => (x >= 6 && x <= 9) || (y >= 6 && y <= 9),\n saltire: (x, y) => Math.abs(x - y) <= 1.5 || Math.abs(x + y - 15) <= 1.5,\n border: (x, y) => x < 2 || x > 13 || y < 2 || y > 13,\n bend: (x, y) => x > y,\n lozenge: (x, y) => Math.abs(x - 7.5) / 7.5 + Math.abs(y - 7.5) / 7.5 <= 1,\n roundel: (x, y) => Math.hypot(x - 7.5, y - 7.5) < 4.5,\n};\n\nconst hexOf = (color: string): string =>\n DYE_COLORS.find((c) => c.id === color)?.hex ?? \"#e9ecec\";\n\n/** Слои поверх полотна: узор за узором, поздний закрывает ранний. */\nexport function paintLayers(px: PxFn, layers: readonly BannerLayer[]): void {\n for (const l of layers) {\n const mask = PATTERN_MASKS[l.pattern];\n if (!mask) continue;\n const hex = hexOf(l.color);\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n if (mask(x, y)) px(x, y, shade(hex, (x + y) % 4 === 0 ? -0.07 : 0));\n }\n}\n\n/** Полотно знамени: складки ткани основного цвета. */\nexport function paintCloth(px: PxFn, hex: string): void {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(\n hex,\n ((x + y) % 4 === 0 ? -0.07 : 0) - (x === 0 || x === 15 ? 0.18 : 0),\n ),\n );\n for (let x = 0; x < TILE; x++) px(x, 0, shade(hex, -0.35)); // перекладина сверху\n}\n\n/** Canvas со знаменем: полотно основного цвета (или прозрачное) плюс узоры. */\nexport function bannerCanvas(\n base: string | null,\n layers: readonly BannerLayer[],\n scale = 2,\n): HTMLCanvasElement {\n const c = document.createElement(\"canvas\");\n c.width = c.height = TILE * scale;\n const g = c.getContext(\"2d\")!;\n const px: PxFn = (x, y, color) => {\n g.fillStyle = color;\n g.fillRect(x * scale, y * scale, scale, scale);\n };\n if (base) paintCloth(px, hexOf(base));\n paintLayers(px, layers);\n return c;\n}\n"
|
|
@@ -489,7 +597,7 @@
|
|
|
489
597
|
},
|
|
490
598
|
{
|
|
491
599
|
"path": "gfx/TextureAtlas.ts",
|
|
492
|
-
"content": "// Процедурный texture atlas: все текстуры рисуются на canvas 512×512 (32×32 тайла\n// по 16px), никаких внешних файлов. NearestFilter + отключённые мипмапы + полутексельный\n// inset UV — честный пиксель-арт без bleeding. Сид PRNG тайла — его имя, а не место в атласе:\n// поэтому рост сетки не перерисовывает старые тайлы.\nimport * as THREE from \"three\";\nimport { mulberry32, hashString } from \"../core/Noise\";\nimport { patchLightMaterial } from \"./LightMaterial\";\nimport {\n P,\n PAINTERS,\n TILE,\n noisy,\n oreBlobs,\n shade,\n type Painter,\n type PxFn,\n type RngFn,\n} from \"./paint\";\nimport { MATERIAL_TILES } from \"./painters/materials\";\nimport { FUNCTIONAL_TILES } from \"./painters/functional\";\nimport { MOBDROP_TILES } from \"./painters/mobdrops\";\nimport { UTILITY_TILES } from \"./painters/utility\";\nimport { FARMING_TILES } from \"./painters/farming\";\nimport { INFERNO_TILES } from \"./painters/inferno\";\nimport { BEYOND_TILES } from \"./painters/beyond\";\nimport { BREWING_TILES } from \"./painters/brewing\";\nimport { REDSTONE_TILES } from \"./painters/redstone\";\nimport { LATE_TILES, LATE_WOOD_TILES, WOOD_TILES } from \"./painters/wood\";\nimport { COLOR_TILES } from \"./painters/colors\";\nimport { OUTDOOR_TILES } from \"./painters/outdoor\";\nimport { BOSS_TILES } from \"./painters/boss\";\nimport { CITY_TILES } from \"./painters/city\";\nimport { OCEAN_TILES } from \"./painters/ocean\";\nimport { CAMP_TILES } from \"./painters/camp\";\nimport { SEA_LIFE_TILES } from \"./painters/sealife\";\nimport { ILLAGER_TILES } from \"./painters/illager\";\nimport { TREASURE_TILES } from \"./painters/treasure\";\nimport { MANSION_TILES } from \"./painters/mansion\";\nimport { CAVE_TILES } from \"./painters/caves\";\nimport { SCULK_TILES } from \"./painters/sculk\";\nimport { DRIPSTONE_TILES } from \"./painters/dripstone\";\nimport { MOUNTAIN_TILES } from \"./painters/mountain\";\nimport { ARCHAEOLOGY_TILES } from \"./painters/archaeology\";\nimport { MANGROVE_TILES } from \"./painters/mangrove\";\nimport { SNIFFER_TILES } from \"./painters/sniffer\";\nimport { TRIAL_TILES } from \"./painters/trial\";\nimport { COPPER_TILES } from \"./painters/copper\";\nimport { DECOR_TILES } from \"./painters/decor\";\nimport { FROST_TILES } from \"./painters/frost\";\nimport { MUSHROOM_TILES } from \"./painters/mushroom\";\nimport { JUNGLE_TILES } from \"./painters/jungle\";\nimport { CRIMSON_TILES } from \"./painters/crimson\";\nimport { BASALT_TILES } from \"./painters/basalt\";\nimport { COCOA_TILES } from \"./painters/cocoa\";\nimport { CRAFTED_TILES } from \"./painters/crafted\";\nimport { SOUL_CAMP_TILES } from \"./painters/soulcamp\";\nimport { ARMADILLO_TILES } from \"./painters/armadillo\";\nimport { LEAD_TILES } from \"./painters/lead\";\nimport { SHIELD_TILES } from \"./painters/shield\";\nimport { RANGED_TILES } from \"./painters/ranged\";\nimport { WORKSTATION_TILES } from \"./painters/workstations\";\nimport { STONECUTTER_TILES } from \"./painters/stonecutter\";\nimport { JOB_TILES } from \"./painters/jobblocks\";\nimport { CURE_TILES } from \"./painters/cure\";\nimport { SOUND_TILES } from \"./painters/sound\";\nimport { HALL_TILES } from \"./painters/hall\";\nimport { DISPLAY_TILES } from \"./painters/display\";\nimport { SIGN_TILES } from \"./painters/sign\";\nimport { BANNER_TILES } from \"./painters/banner\";\nimport { BOOK_TILES } from \"./painters/book\";\nimport { EXTRA_TILES } from \"./painters/extras\";\nimport { STAND_TILES } from \"./painters/stand\";\nimport { POTION_TILES } from \"./painters/potions\";\nimport { TIPPED_TILES } from \"./painters/tipped\";\nimport { NAMETAG_TILES } from \"./painters/nametag\";\n\nconst GRID = 32; // тайлов в ряду (до 1024 тайлов)\nconst SIZE = TILE * GRID;\n\n// ---- painter-функции: (px, rng) → рисуют один тайл 16×16 ---------------------\n// px(x, y, cssColor) закрашивает один пиксель тайла.\n\nconst painters: Record<string, Painter> = {\n grass_top(px, rng) {\n noisy(px, \"#5fa63c\", 0.1, rng);\n },\n dirt(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n },\n grass_side(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n for (let x = 0; x < TILE; x++) {\n const depth = 3 + ((rng() * 2) | 0); // рваная кромка дёрна\n for (let y = 0; y < depth; y++)\n px(x, y, shade(\"#5fa63c\", (rng() - 0.5) * 0.2));\n }\n },\n stone(px, rng) {\n noisy(px, \"#7d7d7d\", 0.08, rng);\n for (let i = 0; i < 5; i++) {\n // светлые/тёмные пятна\n const x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0,\n c = shade(\"#7d7d7d\", rng() < 0.5 ? -0.15 : 0.12);\n px(x, y, c);\n px((x + 1) & 15, y, c);\n }\n },\n cobblestone(px, rng) {\n noisy(px, \"#828282\", 0.1, rng);\n // «булыжники»: тёмная сетка неровных швов\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const cell = (x + (y & 4 ? 3 : 0)) % 6 === 0 || y % 6 === 0;\n if (cell && rng() < 0.8)\n px(x, y, shade(\"#4a4a4a\", (rng() - 0.5) * 0.2));\n }\n },\n bedrock(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(rng() < 0.5 ? \"#2b2b2b\" : \"#565656\", (rng() - 0.5) * 0.3),\n );\n },\n sand(px, rng) {\n noisy(px, \"#dcd3a0\", 0.08, rng);\n },\n oak_log_side(px, rng) {\n for (let x = 0; x < TILE; x++) {\n const stripe = x % 4 === 3 ? -0.25 : (rng() - 0.5) * 0.15; // кора полосами\n for (let y = 0; y < TILE; y++)\n px(x, y, shade(\"#6b5030\", stripe + (rng() - 0.5) * 0.08));\n }\n },\n oak_log_top(px, rng) {\n noisy(px, \"#6b5030\", 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++)\n px(x, y, shade(\"#b8945f\", (rng() - 0.5) * 0.15));\n // годовые кольца\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, \"#8a6d42\");\n }\n }\n },\n planks(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#a4824c\", (rng() - 0.5) * 0.12);\n if (y % 4 === 3) c = \"#6e5631\"; // горизонтальные швы досок\n if (\n (y < 4 && x === 11) ||\n (y >= 4 && y < 8 && x === 3) ||\n (y >= 8 && y < 12 && x === 13) ||\n (y >= 12 && x === 6)\n )\n c = \"#6e5631\"; // стыки\n px(x, y, c);\n }\n },\n leaves(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < 0.18) continue; // прозрачные «дырки» (alphaTest-вырезы)\n px(x, y, shade(\"#3e7a25\", (rng() - 0.5) * 0.35));\n }\n },\n water(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#3057d8\", (rng() - 0.5) * 0.12);\n if ((x + y * 2) % 8 === 0 && rng() < 0.5) c = shade(\"#4a74ea\", 0.1); // блики\n px(x, y, c);\n }\n },\n coal_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#2e2e2e\");\n },\n iron_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#d8af93\");\n },\n crafting_table_top(px, rng) {\n painters.planks!(px, rng);\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#59431f\");\n px(i, 15, \"#59431f\");\n px(0, i, \"#59431f\");\n px(15, i, \"#59431f\");\n }\n for (let y = 3; y < 13; y++)\n for (let x = 3; x < 13; x++)\n if (x === 3 || x === 12 || y === 3 || y === 12) px(x, y, \"#7d6134\");\n },\n crafting_table_side(px, rng) {\n painters.planks!(px, rng);\n // «инструменты» на боковине: тёмные силуэты\n for (let y = 2; y < 7; y++)\n for (let x = 3; x < 6; x++) if (rng() < 0.8) px(x, y, \"#4d3a1c\");\n for (let y = 2; y < 7; y++)\n for (let x = 10; x < 13; x++) if (rng() < 0.8) px(x, y, \"#57422a\");\n },\n};\n\n// ---- тайлы блоков генератора миров ---------------------------------------------\n// Отдельные функции, а не правка существующих painter'ов: у каждого тайла свой сид PRNG, и\n// общий код менял бы порядок случайных чисел — старые тайлы перерисовались бы иначе.\n\n/** Кора бревна вертикальными полосами. */\nfunction logSidePainter(bark: string, stripe: string): Painter {\n return (px, rng) => {\n for (let x = 0; x < TILE; x++) {\n const dark = x % 4 === 3;\n for (let y = 0; y < TILE; y++)\n px(x, y, dark ? stripe : shade(bark, (rng() - 0.5) * 0.14));\n }\n };\n}\n\n/** Спил бревна: кора по краю, светлая древесина и годовые кольца. */\nfunction logTopPainter(bark: string, wood: string, ring: string): Painter {\n return (px, rng) => {\n noisy(px, bark, 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++) px(x, y, shade(wood, (rng() - 0.5) * 0.14));\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, ring);\n }\n }\n };\n}\n\n/** Листва с прозрачными «дырками» (alphaTest-вырезы). */\nfunction leavesPainter(color: string, holes: number): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < holes) continue;\n px(x, y, shade(color, (rng() - 0.5) * 0.35));\n }\n };\n}\n\n/** Шерсть: ровный цвет с лёгким «плетением». */\nfunction woolPainter(color: string): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const weave = (x + y) % 4 === 0 ? -0.07 : 0;\n px(x, y, shade(color, weave + (rng() - 0.5) * 0.08));\n }\n };\n}\n\nObject.assign(painters, {\n snow(px: PxFn, rng: RngFn) {\n noisy(px, \"#eef3f8\", 0.04, rng);\n for (let i = 0; i < 6; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"#ffffff\");\n },\n ice(px: PxFn, rng: RngFn) {\n noisy(px, \"#9cc7f0\", 0.05, rng);\n // светлые трещины-блики по диагонали\n for (let i = 0; i < 3; i++) {\n let x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0;\n for (let k = 0; k < 6; k++) {\n px(x & 15, y & 15, \"#d6ecff\");\n x += 1;\n y += rng() < 0.5 ? 1 : 0;\n }\n }\n },\n gravel(px: PxFn, rng: RngFn) {\n const tones = [\"#857f7a\", \"#6e6863\", \"#9d9690\", \"#5b5550\"];\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(tones[(rng() * tones.length) | 0]!, (rng() - 0.5) * 0.1),\n );\n },\n clay(px: PxFn, rng: RngFn) {\n noisy(px, \"#9ea4b0\", 0.06, rng);\n },\n sandstone_top(px: PxFn, rng: RngFn) {\n noisy(px, \"#d9c98f\", 0.05, rng);\n },\n sandstone_side(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const base = y < 3 ? \"#e3d49c\" : y >= 12 ? \"#c4b27a\" : \"#d9c98f\";\n px(x, y, y === 3 ? \"#b9a66f\" : shade(base, (rng() - 0.5) * 0.08));\n }\n },\n red_sand(px: PxFn, rng: RngFn) {\n noisy(px, \"#c2703a\", 0.08, rng);\n },\n terracotta(px: PxFn, rng: RngFn) {\n noisy(px, \"#a05a3c\", 0.05, rng);\n },\n stone_bricks(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const joint = y % 8 === 7 || x === (y < 8 ? 7 : 15);\n px(x, y, joint ? \"#555555\" : shade(\"#7a7a7a\", (rng() - 0.5) * 0.14));\n }\n },\n mossy_cobblestone(px: PxFn, rng: RngFn) {\n painters.cobblestone!(px, rng);\n for (let i = 0; i < 7; i++) {\n const cx = (rng() * TILE) | 0,\n cy = (rng() * TILE) | 0;\n for (let d = 0; d < 4; d++)\n px(\n (cx + ((rng() * 3) | 0) - 1) & 15,\n (cy + ((rng() * 3) | 0) - 1) & 15,\n shade(\"#5a7a3a\", (rng() - 0.5) * 0.2),\n );\n }\n },\n bricks(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const row = y >> 2;\n const offset = row % 2 ? 4 : 0;\n const mortar = y % 4 === 3 || (x + offset) % 8 === 7;\n px(x, y, mortar ? \"#c9bfb3\" : shade(\"#9a4a3a\", (rng() - 0.5) * 0.16));\n }\n },\n glass(px: PxFn, _rng: RngFn) {\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#dff2f7\");\n px(i, 15, \"#dff2f7\");\n px(0, i, \"#dff2f7\");\n px(15, i, \"#dff2f7\");\n }\n for (let x = 3; x < 9; x++) px(x, x - 1, \"#f4fbfd\"); // блик\n for (let x = 4; x < 8; x++) px(x + 1, x - 1, \"#f4fbfd\");\n },\n spruce_log_side: logSidePainter(\"#4a3520\", \"#33241a\"),\n spruce_log_top: logTopPainter(\"#4a3520\", \"#8a6a42\", \"#6e5232\"),\n spruce_leaves: leavesPainter(\"#2d5a3a\", 0.14),\n birch_log_side(px: PxFn, rng: RngFn) {\n noisy(px, \"#d7d2c4\", 0.05, rng);\n for (let i = 0; i < 9; i++) {\n const x = (rng() * 14) | 0,\n y = (rng() * TILE) | 0;\n px(x, y, \"#3a3a3a\");\n px(x + 1, y, \"#3a3a3a\");\n }\n },\n birch_log_top: logTopPainter(\"#d7d2c4\", \"#c9b27a\", \"#a88f5a\"),\n birch_leaves: leavesPainter(\"#5c8f3a\", 0.18),\n dark_planks(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const seam =\n y % 4 === 3 ||\n (y < 4 && x === 5) ||\n (y >= 4 && y < 8 && x === 12) ||\n (y >= 8 && y < 12 && x === 2) ||\n (y >= 12 && x === 9);\n px(x, y, seam ? \"#3a2714\" : shade(\"#5a3d22\", (rng() - 0.5) * 0.12));\n }\n },\n dirt_path_top(px: PxFn, rng: RngFn) {\n noisy(px, \"#9a7a45\", 0.1, rng);\n },\n white_wool: woolPainter(\"#e9ecec\"),\n red_wool: woolPainter(\"#a12722\"),\n orange_wool: woolPainter(\"#f07613\"),\n yellow_wool: woolPainter(\"#f8c527\"),\n green_wool: woolPainter(\"#5e7c16\"),\n blue_wool: woolPainter(\"#35399d\"),\n light_blue_wool: woolPainter(\"#3aafd9\"),\n purple_wool: woolPainter(\"#7a2aa9\"),\n pink_wool: woolPainter(\"#ed8dac\"),\n brown_wool: woolPainter(\"#724728\"),\n gray_wool: woolPainter(\"#3e4447\"),\n black_wool: woolPainter(\"#1d1d21\"),\n} satisfies Record<string, Painter>);\n\n// ---- пиксель-арт по строковым шаблонам (растения, предметы, инструменты) — P из ./paint -----\n\npainters.tallgrass = P(\n [\n \"................\",\n \"................\",\n \"....g......g....\",\n \"..g.g...g..g.g..\",\n \"..g.g..gg..g.g..\",\n \"..gg.g.g.g.gg...\",\n \"...g.g.g.g.g....\",\n \"...g.gg.gg.g....\",\n \"....gg.g.gg.....\",\n \"....g.gg.g......\",\n \".....g.g.g......\",\n \".....gggg.......\",\n \"......gg........\",\n \"......gg........\",\n \"................\",\n \"................\",\n ],\n { g: \"#4c8a2f\" },\n);\n\npainters.flower_yellow = P(\n [\n \"................\",\n \"................\",\n \"......yy........\",\n \".....yYYy.......\",\n \".....yYYy.......\",\n \"......yy........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { y: \"#e8c11c\", Y: \"#fbe87a\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.flower_red = P(\n [\n \"................\",\n \"................\",\n \"......rr........\",\n \".....rRRr.......\",\n \".....rRRr.......\",\n \"......rr........\",\n \".......s........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e86a5f\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.stick = P(\n [\n \"................\",\n \"................\",\n \"............w...\",\n \"...........ww...\",\n \"..........ww....\",\n \".........ww.....\",\n \"........ww......\",\n \".......ww.......\",\n \"......ww........\",\n \".....ww.........\",\n \"....ww..........\",\n \"...ww...........\",\n \"...w............\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { w: \"#8a6a3a\" },\n);\n\npainters.feather = P(\n [\n \"................\",\n \"...........ff...\",\n \"..........ffff..\",\n \".........fffff..\",\n \"........ffffff..\",\n \".......ffffff...\",\n \"......ffffff....\",\n \".....ffffff.....\",\n \"....ffffff......\",\n \"....fffff.......\",\n \"...ffff.........\",\n \"...fff..........\",\n \"..qf............\",\n \"..q.............\",\n \".q..............\",\n \"................\",\n ],\n { f: \"#f2f2f2\", q: \"#b9b9b9\" },\n);\n\npainters.raw_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPpppppp....\",\n \"...pPppppppppb..\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#e8a2a2\", P: \"#f6caca\", b: \"#e3ddc4\" },\n);\n\npainters.coal_item = P(\n [\n \"................\",\n \"................\",\n \"................\",\n \".....cccc.......\",\n \"....cccccc......\",\n \"...ccCccccc.....\",\n \"...cCccccccc....\",\n \"...ccccCccccc...\",\n \"....ccccccccc...\",\n \"....cccCccccc...\",\n \".....ccccccc....\",\n \"......ccccc.....\",\n \".......ccc......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { c: \"#2c2c2c\", C: \"#4d4d4d\" },\n);\n\npainters.apple = P(\n [\n \"................\",\n \".......g........\",\n \".......s........\",\n \".....rrsr.......\",\n \"....rRRrrr......\",\n \"...rRRrrrrr.....\",\n \"...rRrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"....rrrrrr......\",\n \"....rrrrrr......\",\n \".....rrrr.......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e8635a\", s: \"#6e4a24\", g: \"#4c8a2f\" },\n);\n\npainters.cooked_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPppppbp....\",\n \"...pPpppppppb...\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#c8843e\", P: \"#e0a35a\", b: \"#8a5a2a\" },\n);\n\nconst TOOL_PALETTE = { w: \"#a4824c\", h: \"#6e5631\", s: \"#8a6a3a\" };\npainters.wooden_pickaxe = P(\n [\n \"................\",\n \"....wwwwwww.....\",\n \"...ww.....ww....\",\n \"..ww.......ww...\",\n \"..w.........w...\",\n \"..w....s....w...\",\n \".......s........\",\n \"......ss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_axe = P(\n [\n \"................\",\n \".....wwww.......\",\n \"....wwwwww......\",\n \"....ww..ww......\",\n \"....ww.ss.......\",\n \"....wwss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"..ss............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_sword = P(\n [\n \"................\",\n \"...........ww...\",\n \"..........www...\",\n \".........www....\",\n \"........www.....\",\n \".......www......\",\n \"......www.......\",\n \".....www........\",\n \"..h.www.........\",\n \"...hwww.........\",\n \"...shh..........\",\n \"..ss.hh.........\",\n \"..ss..hh........\",\n \".ss.............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\n\n// ---- блоки с формами (сейв v3): факел, лестница, дверь, люк -----------------------\n\n// Факел: пламя и древко в колонках 7–8 (коробка факела вырезает ровно их), древко — строки 8–15.\npainters.torch = P(\n [\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n \".......FF.......\",\n \".......ff.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n \".......ss.......\",\n ],\n { F: \"#ffe36a\", f: \"#f0a030\", s: \"#6b4a24\" },\n);\n\npainters.ladder = (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (x === 2 || x === 13) px(x, y, shade(\"#7a5a30\", (rng() - 0.5) * 0.12));\n else if (y % 4 === 2 && x > 2 && x < 13)\n px(x, y, shade(\"#9a7442\", (rng() - 0.5) * 0.12));\n }\n};\n\n/** Дверь: вертикальные доски в рамке; у верхней половины — окно (прозрачные пиксели). */\nfunction doorPainter(window: boolean): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const frame = x === 0 || x === 15 || (window ? y === 0 : y === 15);\n const seam = x === 5 || x === 10;\n if (window && !frame && y >= 3 && y <= 8 && x >= 2 && x <= 13 && !seam)\n continue;\n px(\n x,\n y,\n frame || seam ? \"#6e5631\" : shade(\"#a4824c\", (rng() - 0.5) * 0.12),\n );\n }\n };\n}\npainters.oak_door_bottom = doorPainter(false);\npainters.oak_door_top = doorPainter(true);\npainters.oak_door_item = (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 4; x < 12; x++) {\n const frame = x === 4 || x === 11 || y === 0 || y === 15;\n const mullion = x === 7 || x === 8;\n if (!frame && !mullion && y >= 2 && y <= 6) continue; // окно\n px(\n x,\n y,\n frame || mullion ? \"#6e5631\" : shade(\"#a4824c\", (rng() - 0.5) * 0.12),\n );\n }\n};\npainters.oak_trapdoor = (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const hx = x === 3 || x === 4 || x === 11 || x === 12;\n const hy = y === 3 || y === 4 || y === 11 || y === 12;\n if (hx && hy) continue; // окошки\n const frame =\n x === 0 || x === 15 || y === 0 || y === 15 || x === 7 || x === 8;\n px(x, y, frame ? \"#6e5631\" : shade(\"#a4824c\", (rng() - 0.5) * 0.12));\n }\n};\n\n// ---- природные (сейв v3): лава, обсидиан ------------------------------------------\n\npainters.lava = (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const hot = rng() < 0.14;\n px(x, y, hot ? \"#ffe27a\" : shade(\"#e8661c\", (rng() - 0.5) * 0.35));\n }\n};\npainters.obsidian = (px, rng) => {\n noisy(px, \"#1a1024\", 0.14, rng);\n for (let i = 0; i < 7; i++)\n px(\n (rng() * TILE) | 0,\n (rng() * TILE) | 0,\n shade(\"#4b2a6e\", (rng() - 0.5) * 0.3),\n );\n};\n\n// ---- стадии трещин (последние 10 тайлов атласа) ------------------------------\n\nfunction crackPainter(stage: number): Painter {\n return (px, rng) => {\n // от центра расходятся stage+1 ломаных трещин; глубже стадия — гуще\n const cracks = 2 + stage;\n for (let c = 0; c < cracks; c++) {\n let x = 7 + rng() * 2,\n y = 7 + rng() * 2;\n const ang = rng() * Math.PI * 2;\n let dx = Math.cos(ang),\n dy = Math.sin(ang);\n const len = 3 + stage * 1.2;\n for (let i = 0; i < len; i++) {\n px(Math.round(x) & 15, Math.round(y) & 15, \"rgba(20,15,10,0.85)\");\n x += dx;\n y += dy;\n dx += (rng() - 0.5) * 0.8;\n dy += (rng() - 0.5) * 0.8; // излом\n const n = Math.hypot(dx, dy) || 1;\n dx /= n;\n dy /= n;\n }\n }\n // на поздних стадиях — крошка по всему тайлу\n for (let i = 0; i < stage * 4; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"rgba(20,15,10,0.7)\");\n };\n}\nfor (let s = 0; s < 10; s++) painters[\"crack\" + s] = crackPainter(s);\n\n// исходные тайлы — в общий реестр (части из gfx/painters/* уже там: их импорт выполнился раньше)\nObject.assign(PAINTERS, painters);\n\n// ---- сборка атласа ------------------------------------------------------------\n\n// Порядок = индекс тайла. Новые тайлы — только в конец (хвост атласа занят трещинами).\nexport const TILE_ORDER = [\n \"grass_top\",\n \"grass_side\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"bedrock\",\n \"sand\",\n \"oak_log_side\",\n \"oak_log_top\",\n \"planks\",\n \"leaves\",\n \"water\",\n \"coal_ore\",\n \"iron_ore\",\n \"crafting_table_top\",\n \"crafting_table_side\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n \"stick\",\n \"feather\",\n \"raw_chicken\",\n \"coal_item\",\n \"apple\",\n \"cooked_chicken\",\n \"wooden_pickaxe\",\n \"wooden_axe\",\n \"wooden_sword\",\n // блоки генератора миров (дописываются в конец — индексы старых тайлов не сдвигаются)\n \"snow\",\n \"ice\",\n \"gravel\",\n \"clay\",\n \"sandstone_top\",\n \"sandstone_side\",\n \"red_sand\",\n \"terracotta\",\n \"stone_bricks\",\n \"mossy_cobblestone\",\n \"bricks\",\n \"glass\",\n \"spruce_log_side\",\n \"spruce_log_top\",\n \"spruce_leaves\",\n \"birch_log_side\",\n \"birch_log_top\",\n \"birch_leaves\",\n \"dark_planks\",\n \"dirt_path_top\",\n \"white_wool\",\n \"red_wool\",\n \"orange_wool\",\n \"yellow_wool\",\n \"green_wool\",\n \"blue_wool\",\n \"light_blue_wool\",\n \"purple_wool\",\n \"pink_wool\",\n \"brown_wool\",\n \"gray_wool\",\n \"black_wool\",\n // блоки с формами (сейв v3)\n \"torch\",\n \"ladder\",\n \"oak_door_bottom\",\n \"oak_door_top\",\n \"oak_door_item\",\n \"oak_trapdoor\",\n // природные (сейв v3)\n \"lava\",\n \"obsidian\",\n // выживание: руды, блоки хранения, материалы, инструменты, броня (gfx/painters/materials.ts)\n ...MATERIAL_TILES,\n // печь, сундук (gfx/painters/functional.ts)\n ...FUNCTIONAL_TILES,\n // добыча с мобов и оружие (gfx/painters/mobdrops.ts)\n ...MOBDROP_TILES,\n // вёдра, огниво, огонь (gfx/painters/utility.ts)\n ...UTILITY_TILES,\n // пашня, посевы по стадиям, сено, урожай (gfx/painters/farming.ts)\n ...FARMING_TILES,\n // Пекло: камень, песок душ, светокамень, кварц, магма, кирпич, портал (gfx/painters/inferno.ts)\n ...INFERNO_TILES,\n // рамка и портал Предела, камень Предела, яйцо, Око (gfx/painters/beyond.ts)\n ...BEYOND_TILES,\n // варочная стойка, нарост, пузырьки и зелья (gfx/painters/brewing.ts)\n ...BREWING_TILES,\n // красный камень: провод, факел, лампа, рычаг (gfx/painters/redstone.ts)\n ...REDSTONE_TILES,\n // породы дерева: доски, брёвна, листва, саженцы, двери, люки (gfx/painters/wood.ts)\n ...WOOD_TILES,\n // 16 цветов: новая шерсть и красители (gfx/painters/colors.ts)\n ...COLOR_TILES,\n // рыбалка, компас, часы, лодки (gfx/painters/outdoor.ts)\n ...OUTDOOR_TILES,\n // Пепельный череп, маяк, звезда Пекла (gfx/painters/boss.ts)\n ...BOSS_TILES,\n // внешние острова и города Предела (gfx/painters/city.ts)\n ...CITY_TILES,\n // океан: морская трава, морской камень, губки, черепахи (gfx/painters/ocean.ts)\n ...OCEAN_TILES,\n // активирующие рельсы, костёр, вагонетки с грузом (gfx/painters/camp.ts)\n ...CAMP_TILES,\n // кораллы, ламинария, рыбы (gfx/painters/sealife.ts)\n ...SEA_LIFE_TILES,\n // тотем жизни (gfx/painters/illager.ts)\n ...ILLAGER_TILES,\n // вёдра с рыбой, клад, проводник (gfx/painters/treasure.ts)\n ...TREASURE_TILES,\n // стол картографа и карты-исследователи (gfx/painters/mansion.ts)\n ...MANSION_TILES,\n // аметист, мох, лозы, подзорная труба (gfx/painters/caves.ts)\n ...CAVE_TILES,\n // скалк, сланец древнего города, осколок эха (gfx/painters/sculk.ts)\n ...SCULK_TILES,\n // капельник, котёл, козий рог (gfx/painters/dripstone.ts)\n ...DRIPSTONE_TILES,\n // вишня: доски, бревно, листва, саженец, дверь, люк, лодка (gfx/painters/wood.ts, поздние породы)\n ...LATE_WOOD_TILES,\n // снег-порох, лепестки (gfx/painters/mountain.ts)\n ...MOUNTAIN_TILES,\n // подозрительный песок и гравий, горшок, кисть, черепки (gfx/painters/archaeology.ts)\n ...ARCHAEOLOGY_TILES,\n // мангр — вторая волна поздних пород (gfx/painters/wood.ts)\n ...LATE_TILES[2]!,\n // грязь, корни, икра, лягушачий свет, магмовый крем (gfx/painters/mangrove.ts)\n ...MANGROVE_TILES,\n // нюхач и древние растения (gfx/painters/sniffer.ts)\n ...SNIFFER_TILES,\n // испытательные камеры, булава (gfx/painters/trial.ts)\n ...TRIAL_TILES,\n // окисление меди, медная лампа и решётка, шаблоны отделки (gfx/painters/copper.ts)\n ...COPPER_TILES,\n // фонари, цепь, свеча, мешок (gfx/painters/decor.ts)\n ...DECOR_TILES,\n // снежок (gfx/painters/frost.ts)\n ...FROST_TILES,\n // грибные поля (gfx/painters/mushroom.ts)\n ...MUSHROOM_TILES,\n // бамбук (gfx/painters/jungle.ts)\n ...JUNGLE_TILES,\n // биомы Пекла (gfx/painters/crimson.ts)\n ...CRIMSON_TILES,\n // базальт, чернокамень, кости, огонь душ (gfx/painters/basalt.ts)\n ...BASALT_TILES,\n // какао, печенье, факел душ (gfx/painters/cocoa.ts)\n ...COCOA_TILES,\n // цветное стекло, полированный базальт и чернокамень (gfx/painters/crafted.ts)\n ...CRAFTED_TILES,\n // костёр душ (gfx/painters/soulcamp.ts)\n ...SOUL_CAMP_TILES,\n // щиток броненосца, броня для волка (gfx/painters/armadillo.ts)\n ...ARMADILLO_TILES,\n // поводок (gfx/painters/lead.ts)\n ...LEAD_TILES,\n // щит (gfx/painters/shield.ts)\n ...SHIELD_TILES,\n // арбалет, трезубец (gfx/painters/ranged.ts)\n ...RANGED_TILES,\n // рабочие блоки жителей (gfx/painters/workstations.ts)\n ...WORKSTATION_TILES,\n // камнерез (gfx/painters/stonecutter.ts)\n ...STONECUTTER_TILES,\n // точило, ткацкий станок, кафедра, стол фехтовальщика (gfx/painters/jobblocks.ts)\n ...JOB_TILES,\n // золотое яблоко (gfx/painters/cure.ts)\n ...CURE_TILES,\n // проигрыватель, нотный блок, пластинки (gfx/painters/sound.ts)\n ...SOUND_TILES,\n // колокол и картины (gfx/painters/hall.ts)\n ...HALL_TILES,\n // подставка (gfx/painters/display.ts)\n ...DISPLAY_TILES,\n // табличка (gfx/painters/sign.ts)\n ...SIGN_TILES,\n // знамёна 16 цветов (gfx/painters/banner.ts)\n ...BANNER_TILES,\n // книги (gfx/painters/book.ts)\n ...BOOK_TILES,\n // сундук Предела, леса, мишень (gfx/painters/extras.ts)\n ...EXTRA_TILES,\n // стойка для брони, путевой камень, якорь (gfx/painters/stand.ts)\n ...STAND_TILES,\n // остальные зелья (gfx/painters/potions.ts)\n ...POTION_TILES,\n // отравленные стрелы (gfx/painters/tipped.ts)\n ...TIPPED_TILES,\n // бирка с именем (gfx/painters/nametag.ts)\n ...NAMETAG_TILES,\n];\n/** Индекс тайла crack0: трещины — последние 10 тайлов, TILE_ORDER растёт им навстречу. */\nconst CRACK_BASE = GRID * GRID - 10;\n/** Сколько тайлов помещается в TILE_ORDER. */\nexport const TILE_CAPACITY = CRACK_BASE;\n\n/** Есть ли у тайла функция рисования (проверка реестров в тестах). */\nexport function hasPainter(name: string): boolean {\n return name in PAINTERS;\n}\n\nexport class TextureAtlas {\n canvas: HTMLCanvasElement;\n tiles: Record<string, number>;\n texture: THREE.CanvasTexture;\n waterTexture: THREE.CanvasTexture;\n opaqueMat: THREE.MeshLambertMaterial;\n foliageMat: THREE.MeshLambertMaterial;\n waterMat: THREE.MeshLambertMaterial;\n _iconCache: Map<string, HTMLCanvasElement>;\n\n constructor() {\n this.canvas = document.createElement(\"canvas\");\n this.canvas.width = this.canvas.height = SIZE;\n const ctx = this.canvas.getContext(\"2d\")!; // код всегда рассчитывает на валидный 2d-контекст\n this.tiles = {}; // имя → индекс\n\n const paint = (name: string, index: number) => {\n const tx = (index % GRID) * TILE,\n ty = ((index / GRID) | 0) * TILE;\n const rng = mulberry32(hashString(\"tile:\" + name));\n const px: PxFn = (x, y, color) => {\n ctx.fillStyle = color;\n ctx.fillRect(tx + x, ty + y, 1, 1);\n };\n PAINTERS[name]!(px, rng);\n };\n\n TILE_ORDER.forEach((name, i) => {\n this.tiles[name] = i;\n paint(name, i);\n });\n for (let s = 0; s < 10; s++) {\n this.tiles[\"crack\" + s] = CRACK_BASE + s;\n paint(\"crack\" + s, CRACK_BASE + s);\n }\n\n this.texture = new THREE.CanvasTexture(this.canvas);\n this.texture.magFilter = THREE.NearestFilter;\n this.texture.minFilter = THREE.NearestFilter;\n this.texture.generateMipmaps = false;\n this.texture.colorSpace = THREE.SRGBColorSpace;\n\n // Отдельная маленькая текстура воды: у водного материала свой repeat-тайл,\n // чтобы анимировать offset, не сдвигая UV всего атласа.\n const wc = document.createElement(\"canvas\");\n wc.width = wc.height = TILE;\n const wctx = wc.getContext(\"2d\")!; // тот же расчёт на валидный 2d-контекст\n const wrng = mulberry32(hashString(\"tile:water-solo\"));\n painters.water!((x, y, c) => {\n wctx.fillStyle = c;\n wctx.fillRect(x, y, 1, 1);\n }, wrng);\n this.waterTexture = new THREE.CanvasTexture(wc);\n this.waterTexture.magFilter = this.waterTexture.minFilter =\n THREE.NearestFilter;\n this.waterTexture.generateMipmaps = false;\n this.waterTexture.wrapS = this.waterTexture.wrapT = THREE.RepeatWrapping;\n this.waterTexture.colorSpace = THREE.SRGBColorSpace;\n\n // Материалы чанков — общие на весь мир (3 draw-call-группы).\n this.opaqueMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n });\n this.foliageMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n alphaTest: 0.5,\n side: THREE.DoubleSide,\n });\n this.waterMat = new THREE.MeshLambertMaterial({\n map: this.waterTexture,\n vertexColors: true,\n transparent: true,\n opacity: 0.72,\n depthWrite: false,\n });\n // свет клеток (небо/факелы) из атрибута мешера\n for (const m of [this.opaqueMat, this.foliageMat, this.waterMat])\n patchLightMaterial(m);\n\n this._iconCache = new Map();\n }\n\n /** UV-прямоугольник тайла с полутексельным inset. Индекс или имя. */\n uv(tile: string | number): {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n } {\n const i = typeof tile === \"string\" ? this.tiles[tile]! : tile;\n const col = i % GRID,\n row = (i / GRID) | 0;\n const inset = 0.5 / SIZE;\n return {\n u0: col / GRID + inset,\n u1: (col + 1) / GRID - inset,\n // canvas рисует сверху вниз, UV в three — снизу вверх\n v0: 1 - (row + 1) / GRID + inset,\n v1: 1 - row / GRID - inset,\n };\n }\n\n /** Канвас-фрагмент тайла (для иконок). */\n tileCanvas(name: string, shadeAmt = 0): HTMLCanvasElement {\n const i = this.tiles[name]!;\n const c = document.createElement(\"canvas\");\n c.width = c.height = TILE;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.drawImage(\n this.canvas,\n (i % GRID) * TILE,\n ((i / GRID) | 0) * TILE,\n TILE,\n TILE,\n 0,\n 0,\n TILE,\n TILE,\n );\n if (shadeAmt !== 0) {\n ctx.globalCompositeOperation = \"source-atop\";\n ctx.fillStyle = `rgba(0,0,0,${shadeAmt})`;\n ctx.fillRect(0, 0, TILE, TILE);\n }\n return c;\n }\n\n /** Изометрическая иконка блока (48×48) из его top/side тайлов. */\n blockIcon(topName: string, sideName: string): HTMLCanvasElement {\n const key = \"b:\" + topName + \"/\" + sideName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n const top = this.tileCanvas(topName, 0);\n const left = this.tileCanvas(sideName, 0.25);\n const right = this.tileCanvas(sideName, 0.42);\n const k = 21 / TILE; // размер грани\n // верхняя грань (ромб), затем левая и правая\n ctx.setTransform(k, k * 0.5, -k, k * 0.5, 24, 3);\n ctx.drawImage(top, 0, 0);\n ctx.setTransform(k, k * 0.5, 0, k, 24 - k * TILE, 3 + k * TILE * 0.5);\n ctx.drawImage(left, 0, 0);\n ctx.setTransform(k, -k * 0.5, 0, k, 24, 3 + k * TILE);\n ctx.drawImage(right, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n this._iconCache.set(key, c);\n return c;\n }\n\n /** Плоская иконка предмета (его тайл, 48×48, nearest). */\n flatIcon(tileName: string): HTMLCanvasElement {\n const key = \"f:\" + tileName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n ctx.drawImage(this.tileCanvas(tileName), 0, 0, 48, 48);\n this._iconCache.set(key, c);\n return c;\n }\n}\n"
|
|
600
|
+
"content": "// Процедурный texture atlas: все текстуры рисуются на canvas 512×512 (32×32 тайла\n// по 16px), никаких внешних файлов. NearestFilter + отключённые мипмапы + полутексельный\n// inset UV — честный пиксель-арт без bleeding. Сид PRNG тайла — его имя, а не место в атласе:\n// поэтому рост сетки не перерисовывает старые тайлы.\nimport * as THREE from \"three\";\nimport { mulberry32, hashString } from \"../core/Noise\";\nimport { patchLightMaterial } from \"./LightMaterial\";\nimport { PAINTERS, TILE, type PxFn } from \"./paint\";\nimport { GRID, SIZE, painters } from \"./atlas/registry\";\nimport \"./atlas/door-crack\"; // registers every painter (wool, tools, doors, cracks) into PAINTERS\nimport { CRACK_BASE, TILE_ORDER } from \"./atlas/tiles\";\n\nexport { TILE_CAPACITY, TILE_ORDER, hasPainter } from \"./atlas/tiles\";\n\nexport class TextureAtlas {\n canvas: HTMLCanvasElement;\n tiles: Record<string, number>;\n texture: THREE.CanvasTexture;\n waterTexture: THREE.CanvasTexture;\n opaqueMat: THREE.MeshLambertMaterial;\n foliageMat: THREE.MeshLambertMaterial;\n waterMat: THREE.MeshLambertMaterial;\n _iconCache: Map<string, HTMLCanvasElement>;\n\n constructor() {\n this.canvas = document.createElement(\"canvas\");\n this.canvas.width = this.canvas.height = SIZE;\n const ctx = this.canvas.getContext(\"2d\")!; // код всегда рассчитывает на валидный 2d-контекст\n this.tiles = {}; // имя → индекс\n\n const paint = (name: string, index: number) => {\n const tx = (index % GRID) * TILE,\n ty = ((index / GRID) | 0) * TILE;\n const rng = mulberry32(hashString(\"tile:\" + name));\n const px: PxFn = (x, y, color) => {\n ctx.fillStyle = color;\n ctx.fillRect(tx + x, ty + y, 1, 1);\n };\n PAINTERS[name]!(px, rng);\n };\n\n TILE_ORDER.forEach((name, i) => {\n this.tiles[name] = i;\n paint(name, i);\n });\n for (let s = 0; s < 10; s++) {\n this.tiles[\"crack\" + s] = CRACK_BASE + s;\n paint(\"crack\" + s, CRACK_BASE + s);\n }\n\n this.texture = new THREE.CanvasTexture(this.canvas);\n this.texture.magFilter = THREE.NearestFilter;\n this.texture.minFilter = THREE.NearestFilter;\n this.texture.generateMipmaps = false;\n this.texture.colorSpace = THREE.SRGBColorSpace;\n\n // Отдельная маленькая текстура воды: у водного материала свой repeat-тайл,\n // чтобы анимировать offset, не сдвигая UV всего атласа.\n const wc = document.createElement(\"canvas\");\n wc.width = wc.height = TILE;\n const wctx = wc.getContext(\"2d\")!; // тот же расчёт на валидный 2d-контекст\n const wrng = mulberry32(hashString(\"tile:water-solo\"));\n painters.water!((x, y, c) => {\n wctx.fillStyle = c;\n wctx.fillRect(x, y, 1, 1);\n }, wrng);\n this.waterTexture = new THREE.CanvasTexture(wc);\n this.waterTexture.magFilter = this.waterTexture.minFilter =\n THREE.NearestFilter;\n this.waterTexture.generateMipmaps = false;\n this.waterTexture.wrapS = this.waterTexture.wrapT = THREE.RepeatWrapping;\n this.waterTexture.colorSpace = THREE.SRGBColorSpace;\n\n // Материалы чанков — общие на весь мир (3 draw-call-группы).\n this.opaqueMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n });\n this.foliageMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n alphaTest: 0.5,\n side: THREE.DoubleSide,\n });\n this.waterMat = new THREE.MeshLambertMaterial({\n map: this.waterTexture,\n vertexColors: true,\n transparent: true,\n opacity: 0.72,\n depthWrite: false,\n });\n // свет клеток (небо/факелы) из атрибута мешера\n for (const m of [this.opaqueMat, this.foliageMat, this.waterMat])\n patchLightMaterial(m);\n\n this._iconCache = new Map();\n }\n\n /** UV-прямоугольник тайла с полутексельным inset. Индекс или имя. */\n uv(tile: string | number): {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n } {\n const i = typeof tile === \"string\" ? this.tiles[tile]! : tile;\n const col = i % GRID,\n row = (i / GRID) | 0;\n const inset = 0.5 / SIZE;\n return {\n u0: col / GRID + inset,\n u1: (col + 1) / GRID - inset,\n // canvas рисует сверху вниз, UV в three — снизу вверх\n v0: 1 - (row + 1) / GRID + inset,\n v1: 1 - row / GRID - inset,\n };\n }\n\n /** Канвас-фрагмент тайла (для иконок). */\n tileCanvas(name: string, shadeAmt = 0): HTMLCanvasElement {\n const i = this.tiles[name]!;\n const c = document.createElement(\"canvas\");\n c.width = c.height = TILE;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.drawImage(\n this.canvas,\n (i % GRID) * TILE,\n ((i / GRID) | 0) * TILE,\n TILE,\n TILE,\n 0,\n 0,\n TILE,\n TILE,\n );\n if (shadeAmt !== 0) {\n ctx.globalCompositeOperation = \"source-atop\";\n ctx.fillStyle = `rgba(0,0,0,${shadeAmt})`;\n ctx.fillRect(0, 0, TILE, TILE);\n }\n return c;\n }\n\n /** Изометрическая иконка блока (48×48) из его top/side тайлов. */\n blockIcon(topName: string, sideName: string): HTMLCanvasElement {\n const key = \"b:\" + topName + \"/\" + sideName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n const top = this.tileCanvas(topName, 0);\n const left = this.tileCanvas(sideName, 0.25);\n const right = this.tileCanvas(sideName, 0.42);\n const k = 21 / TILE; // размер грани\n // верхняя грань (ромб), затем левая и правая\n ctx.setTransform(k, k * 0.5, -k, k * 0.5, 24, 3);\n ctx.drawImage(top, 0, 0);\n ctx.setTransform(k, k * 0.5, 0, k, 24 - k * TILE, 3 + k * TILE * 0.5);\n ctx.drawImage(left, 0, 0);\n ctx.setTransform(k, -k * 0.5, 0, k, 24, 3 + k * TILE);\n ctx.drawImage(right, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n this._iconCache.set(key, c);\n return c;\n }\n\n /** Плоская иконка предмета (его тайл, 48×48, nearest). */\n flatIcon(tileName: string): HTMLCanvasElement {\n const key = \"f:\" + tileName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n ctx.drawImage(this.tileCanvas(tileName), 0, 0, 48, 48);\n this._iconCache.set(key, c);\n return c;\n }\n}\n"
|
|
493
601
|
},
|
|
494
602
|
{
|
|
495
603
|
"path": "index.ts",
|
|
@@ -497,7 +605,7 @@
|
|
|
497
605
|
},
|
|
498
606
|
{
|
|
499
607
|
"path": "main.ts",
|
|
500
|
-
"content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config\";\nimport { events } from \"./core/EventBus\";\nimport { GameLoop } from \"./core/GameLoop\";\nimport { IntentQueue } from \"./core/Intents\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas\";\nimport { buildIcons } from \"./registry/Items\";\nimport { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView, ARM_LAYER } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { BlockUpdates } from \"./systems/BlockUpdates\";\nimport { BlockEntities } from \"./world/BlockEntities\";\nimport {\n DIMENSIONS,\n isDimensionId,\n type DimensionId,\n} from \"./world/Dimensions\";\nimport { BLOCKS, B } from \"./registry/Blocks\";\nimport { findSafeSpot, isStandable } from \"./world/safeSpot\";\nimport { MOBS } from \"./registry/Mobs\";\nimport { buildPortal, findPortalNear, portalsOf } from \"./world/Portal\";\nimport { LAVA_SEA } from \"./worldgen/inferno/InfernoGenerator\";\nimport {\n BEYOND_ARRIVAL,\n BeyondGenerator,\n ISLAND_Y,\n OUTER_LANDING,\n beyondPillars,\n exitPortalCells,\n gatewaySpot,\n} from \"./worldgen/beyond/BeyondGenerator\";\nimport { OUTER_START } from \"./worldgen/beyond/outer\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { MAP_SIZE, newMap, surveyMap, type MapData } from \"./systems/Maps\";\nimport { EXPLORER_MAPS } from \"./registry/items/mansion\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { hitTarget } from \"./world/behaviours/extras\";\nimport { anchorCharges } from \"./world/behaviours/stand\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { dripstoneFallDamage } from \"./world/behaviours/dripstone\";\nimport { Freezing } from \"./systems/Freezing\";\nimport {\n TRIAL_RANGE,\n trialStep,\n type TrialState,\n} from \"./systems/TrialSpawner\";\nimport { TRIAL_MOBS } from \"./registry/blocks/trial\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem, worldSourceOf } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { CreateWorldUI } from \"./ui/CreateWorldUI\";\nimport { NetUI, friendlyNetError } from \"./ui/NetUI\";\nimport { ChatConsole } from \"./ui/ChatConsole\";\nimport { SignEditor } from \"./ui/SignEditor\";\nimport { BookScreen } from \"./ui/BookScreen\";\nimport { NameEditor } from \"./ui/NameEditor\";\nimport { TouchControls, touchWanted } from \"./ui/TouchControls\";\nimport { Commands } from \"./systems/Commands\";\nimport { BeaconBeams } from \"./gfx/BeaconBeams\";\nimport { DisplayItems } from \"./gfx/DisplayItems\";\nimport { SignText } from \"./gfx/SignText\";\nimport { Banners } from \"./gfx/Banners\";\nimport { Achievements } from \"./systems/Achievements\";\nimport { RAID_WAVES, Raid } from \"./systems/Raid\";\nimport { siegeCount, siegeSpot, siegeTonight } from \"./systems/Siege\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { beaconEffects, inBeaconRange, type BeaconPulse } from \"./world/Beacon\";\nimport { WorldStore, newWorldId } from \"./systems/WorldStore\";\nimport type { WorldAI } from \"./ai/types\";\nimport { startSource } from \"./worlds/startWorld\";\nimport { CURRENT_GEN } from \"./worldgen/WorldGenerator\";\nimport type { DimensionSave, EffectId, SaveData, WorldSource } from \"./types\";\nimport { sim } from \"./net/sim\";\nimport { NetGame, type NetRoom } from \"./net/NetGame\";\nimport type { DiffStore } from \"./net/DiffStore\";\nimport type {\n RoomSettings,\n Welcome,\n WorldMeta,\n WorldState,\n} from \"./net/protocol\";\nimport {\n difficulty,\n parseDifficulty,\n setDifficulty,\n} from \"./systems/Difficulty\";\n\n/** Автосейв текущего мира в «Мои миры», секунд игры. */\nconst AUTOSAVE_EVERY = 30;\n/** Восемь клеток вокруг (сначала стороны, потом углы) — где встать у якоря возрождения. */\nconst AROUND: readonly (readonly [number, number])[] = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n [1, 1],\n [1, -1],\n [-1, 1],\n [-1, -1],\n];\n\nexport interface GameOptions {\n /** ИИ-генерация миров (client.ai). null/нет — только путь без ИИ. */\n ai?: WorldAI | null;\n /**\n * Сетевая игра — через систему мультиплеера лобби (ctx.multiplayer). `openForFriends` открывает в\n * лобби «Создать комнату» с этой игрой. null/нет — системы нет, игра только одиночная.\n */\n online?: { available(): boolean; openForFriends(): void } | null;\n /** Имя игрока в сетевой игре (профиль); нет — «Игрок». */\n playerName?: () => string;\n}\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n /** Снимает разом все DOM-подписки игры (document/window/канвас) в destroy(). */\n private readonly _abort = new AbortController();\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n /** Блоки без опоры (факел, растение, дверь) ломаются вслед за соседом. */\n blockUpdates: BlockUpdates;\n /** Состояние клеток текущего измерения: сундуки, печи… */\n blockEntities: BlockEntities;\n /** Лучи светящих маяков. */\n beams: BeaconBeams;\n /** Предметы, выставленные в рамках. */\n displays: DisplayItems;\n /** Надписи на табличках. */\n signs: SignText;\n banners: Banners;\n armorStands: ArmorStand;\n fireworks: Fireworks;\n /** Достижения мира (systems/Achievements) и музыка (systems/Music). */\n achievements: Achievements;\n music: Music;\n /** Измерение, в котором игрок, и снимки остальных (дифы, сущности) до возвращения в них. */\n dimension: DimensionId = \"overworld\";\n readonly dims = new Map<DimensionId, DimensionSave>();\n interaction: Interaction;\n /** Вода и лава. */\n fluids: FluidSim;\n dayNight: DayNight;\n /** Дождь и гроза (только в измерениях с небом). */\n weather: Weather;\n /** Секунды в портале; lock — не переносить снова, пока игрок не вышел из портала прибытия. */\n _portalT = 0;\n _portalLock = false;\n /** Известные порталы: обратный путь выводит к своему порталу (в верхнем мире — в 128 блоках). */\n portals = portalsOf(null);\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны (сейв v3, `beyond`). */\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны, где Врата (после победы). */\n beyondState: {\n initialized: boolean;\n dragonKilled: boolean;\n creditsShown: boolean;\n gateway: [number, number, number] | null;\n } = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n worldUI: CreateWorldUI;\n /** Экран «Сетевая игра» и список игроков по Tab. */\n netUI: NetUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n private _time: number;\n loop: GameLoop;\n /** Текущий мир в «Моих мирах». Классический стартовый мир — \"default\". */\n worldId = \"default\";\n worldName = \"VoxelCraft\";\n /** Хранилище миров; null — браузер не даёт IndexedDB (игра работает без сохранения). */\n store: WorldStore | null = null;\n private _autosaveT = 0;\n /** Был ли день на прошлом тике — рассвет пополняет товар жителей. */\n private _wasDay = true;\n /** Деревни, в которые уже поселены жители (id региона; сейв v3, `villages`). */\n villagesSettled = new Set<string>();\n private _villageT = 0;\n /** Ночей без сна (сейв v3 `daysAwake`); _slept — спал этой ночью. */\n daysAwake = 0;\n private _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n private _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n private _worldTouched = false;\n private _destroyed = false;\n\n // ---- сетевая игра (net/NetGame) ----\n /** Идёт сетевая игра (хост или гость); null — одиночная. */\n net: NetGame | null = null;\n /** Система мультиплеера лобби; нет — сетевой игры нет, кнопки для неё не рисуются. */\n readonly online: { available(): boolean; openForFriends(): void } | null;\n /** Последний мир восстановлен из «Моих миров» (или его нет) — хосту открывать сеть можно. */\n readonly restored: Promise<void>;\n private _restored!: () => void;\n /** Имя этого игрока для других (профиль SDK). */\n readonly playerName: () => string;\n /**\n * Хост: сохранённые игроки сетевой игры по userId (позиция, инвентарь, опыт…). Лежат в сейве мира\n * хоста (`players`): вернувшийся игрок продолжает с тем же инвентарём и там же.\n */\n netPlayers = new Map<string, unknown>();\n /** Лёг спать в сетевой игре: ночь пропускается, когда спят все (как в Minecraft). */\n netSleeping = false;\n /** Мир, в котором игрок был до входа в чужую игру: вернуться в него при выходе. */\n private _soloWorldBeforeNet: string | null = null;\n\n /** Этот процесс считает своё измерение (одиночная игра, хост, владелец измерения) — net/sim. */\n get simulating(): boolean {\n return sim.authority;\n }\n\n constructor(opts: GameOptions = {}) {\n this.online = opts.online ?? null;\n this.restored = new Promise<void>((r) => (this._restored = r));\n this.playerName = opts.playerName ?? (() => \"Игрок\");\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n // Казуальная картинка: Neutral-тонмаппинг держит насыщенность ярких цветов (ACES их смывает).\n this.renderer.toneMapping = THREE.NeutralToneMapping;\n this.renderer.toneMappingExposure = CONFIG.graphics.exposure;\n this.renderer.shadowMap.enabled = CONFIG.graphics.shadows;\n this.renderer.shadowMap.type = THREE.PCFShadowMap; // мягкость — shadow.radius (PCFSoft в r185 устарел)\n // Карту теней обновляем сами, раз за кадр (см. frame): теневой проход фильтрует объекты по\n // слоям камеры, и второй проход (рука) перерисовал бы её заново — из одной руки.\n this.renderer.shadowMap.autoUpdate = false;\n this.scene = new THREE.Scene();\n // Пропорции выставит первый же замер ResizeObserver'а — до него кадров не будет.\n this.camera = new THREE.PerspectiveCamera(\n 75,\n 1,\n 0.1,\n CONFIG.renderDistance * 16 + 60,\n );\n this.camera.rotation.order = \"YXZ\";\n this.scene.add(this.camera); // рука прикреплена к камере\n\n // Размер берём из раскладки самого канваса (CSS растягивает его на вьюпорт), а не из\n // window.innerWidth, и через ResizeObserver, а не событие resize: игра может стартовать\n // в ещё не разложенном контейнере — фоновая вкладка или iframe превью, — где на момент\n // конструктора innerWidth равен нулю. Событие resize там уже не придёт, и канвас навсегда\n // остался бы 0x0: симуляция живая, экран чёрный. ResizeObserver сам отдаёт первый замер,\n // как только раскладка появляется.\n this._resizeObserver = new ResizeObserver(() => this._resize());\n this._resizeObserver.observe(canvas);\n this._resize();\n\n // --- контент: атлас и иконки из одних и тех же процедурных текстур ---\n this.atlas = new TextureAtlas();\n buildIcons(this.atlas);\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, startSource());\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.blockUpdates = new BlockUpdates(() => this.world, this.entities);\n // броня из инвентаря гасит урон игрока и изнашивается от ударов\n this.player.armorStats = () => this.inventory.armorStats();\n this.player.onArmorHit = (dmg) => this.inventory.damageArmor(dmg);\n this.player.canGlide = () => this.inventory.canGlide();\n this.player.tryTotem = () => {\n if (this.inventory.heldItem()?.item !== \"life_totem\") return false;\n this.inventory.consumeHeld(1);\n return true;\n };\n this.player.onGlideWear = () => this.inventory.wearWings();\n this.blockEntities = new BlockEntities(() => this.world, this.entities);\n this.beams = new BeaconBeams(this.scene);\n this.displays = new DisplayItems(this.scene, this.atlas);\n this.signs = new SignText(this.scene);\n this.banners = new Banners(this.scene);\n this.armorStands = new ArmorStand(this.scene, this.atlas);\n this.fireworks = new Fireworks(this.scene);\n this.achievements = new Achievements({\n has: (pred) => this.inventory.slots.some((s) => !!s && pred(s.item)),\n wearing: (pred) => this.inventory.armor.some((s) => !!s && pred(s.item)),\n effect: (id) => this.player.effects.has(id as EffectId),\n });\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.fluids = new FluidSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.weather = new Weather(this.scene);\n const start = this.world.source;\n if (start.kind === \"spec\") {\n this.dayNight.setTimeOfDay(start.spec.ambience.timeOfDay);\n this.worldName = start.spec.name;\n }\n this.audio = new Audio();\n this.music = new Music(this.audio);\n // пластинка в проигрывателе играет свою мелодию (сид и лад — systems/Disc), вынули — тишина\n events.on(\"discPlay\", ({ mood, seed }: { mood: Mood; seed: number }) =>\n this.music.play(mood, seed),\n );\n events.on(\"discEject\", () => this.music.stop());\n this.input = new Input(canvas, this.intents, this._abort.signal);\n this.armView = new ArmView(this.camera, this.atlas);\n // второй проход (рука) видит только свой слой — свет мира включаем и на нём\n this.dayNight.sun.layers.enable(ARM_LAYER);\n this.dayNight.hemi.layers.enable(ARM_LAYER);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n new AchievementsView(this.hud.heartsEl.parentElement!, this.achievements);\n this.invUI = new InventoryUI(this, this._abort.signal);\n this.worldUI = new CreateWorldUI(this, this._abort.signal, opts.ai ?? null);\n this.netUI = new NetUI(this, this._abort.signal);\n this.commands = new Commands(this);\n this.chat = new ChatConsole(this, this._abort.signal);\n this.signEditor = new SignEditor(this, this._abort.signal);\n this.bookScreen = new BookScreen(this, this._abort.signal);\n this.nameEditor = new NameEditor(this, this._abort.signal);\n this.touch = touchWanted()\n ? new TouchControls(this, this._abort.signal)\n : null;\n this.interaction.onCommand = (text) => this.commands.run(text);\n this.interaction.mayToggleMode = () => !this.net || this.net.canCheat;\n this.interaction.onUseMap = () => this.createMap();\n // опыт из печи (и других источников, что не кидают сферы)\n events.on(\"xpGain\", ({ amount }: { amount: number }) =>\n this.player.addXp(amount),\n );\n events.on(\"playerDied\", () => this._onDeath());\n events.on(\"portalLit\", (d: { x: number; y: number; z: number }) =>\n this._rememberPortal(this.dimension, d.x, d.y, d.z),\n );\n events.on(\"throwEye\", (d: { x: number; y: number; z: number }) =>\n this._throwEye(d.x, d.y, d.z),\n );\n events.on(\"dragonDefeated\", () => this._dragonDefeated());\n events.on(\"ui:useBed\", (d: { x: number; y: number; z: number }) =>\n this._useBed(d.x, d.y, d.z),\n );\n events.on(\"lightning\", (d: { x: number; y: number; z: number }) =>\n this._lightning(d.x, d.y, d.z),\n );\n // упавший сталактит бьёт того, кто под ним, и рассыпается острым капельником\n events.on(\n \"dripstoneFall\",\n (p: { x: number; y: number; z: number; landY: number }) => {\n const pl = this.player;\n if (\n !pl.dead &&\n Math.floor(pl.pos.x) === p.x &&\n Math.floor(pl.pos.z) === p.z &&\n pl.pos.y <= p.y &&\n pl.pos.y >= p.landY - 0.5\n )\n pl.damage(dripstoneFallDamage(p.y - pl.pos.y), \"falling_stalactite\");\n this.entities.spawnDrop(\n p.x + 0.5,\n p.landY + 0.2,\n p.z + 0.5,\n \"pointed_dripstone\",\n 1,\n );\n },\n );\n // находка из-под кисти — над расчищенным блоком\n events.on(\n \"brushLoot\",\n (p: { x: number; y: number; z: number; item: string; count: number }) =>\n this.entities.spawnDrop(\n p.x + 0.5,\n p.y + 1.1,\n p.z + 0.5,\n p.item,\n p.count,\n ),\n );\n // дроп, который роняет сам моб (рог козы)\n events.on(\n \"mobDrop\",\n (p: { x: number; y: number; z: number; item: string }) =>\n this.entities.spawnDrop(p.x, p.y, p.z, p.item, 1),\n );\n // глубокая тьма: сломанные и поставленные игроком блоки и взрывы — вибрации; смерть рядом с\n // катализатором — скалк; место смерти игрока — для компаса возвращения\n const overworld = () => this.dimension === \"overworld\";\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; byPlayer?: boolean }) => {\n if (overworld())\n this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n !!p.byPlayer && this.player.mode !== \"creative\",\n );\n },\n );\n // Жемчуг Предела упал: игрока переносит туда, и он расшибается, как в Minecraft; изредка из\n // точки вылезает клещ Предела (registry/mobs/beyond)\n events.on(\"pearlLand\", (p: { x: number; y: number; z: number }) => {\n const pl = this.player;\n if (pl.dead) return;\n this.warmup(p.x, p.z);\n let y = Math.floor(p.y);\n while (\n y > 1 &&\n !this.world.isSolid(Math.floor(p.x), y - 1, Math.floor(p.z)) &&\n y > Math.floor(p.y) - 4\n )\n y--;\n pl.pos.set(Math.floor(p.x) + 0.5, y, Math.floor(p.z) + 0.5);\n pl.vel.set(0, 0, 0);\n pl._fallDist = 0;\n if (pl.mode !== \"creative\") pl.damage(5, \"teleport\");\n events.emit(\"pearlTeleport\", { x: pl.pos.x, y: pl.pos.y, z: pl.pos.z });\n if (Math.random() < 0.05)\n this.entities.spawnMob(\"rim_mite\", pl.pos.x, pl.pos.y, pl.pos.z);\n });\n // фейерверк с земли: ракета взлетает и рассыпается искрами (gfx/Fireworks)\n events.on(\"fireworkLaunch\", (p: { x: number; y: number; z: number }) =>\n this.fireworks.launch(p.x, p.y, p.z),\n );\n // компас по путевому камню: стрелка держится за него, пока камень цел (ui/HUD)\n events.on(\"lodestoneBind\", (p: { x: number; y: number; z: number }) => {\n this.player.lodestone = { x: p.x, z: p.z, dim: this.dimension };\n this.hud.message(\"Компас привязан к путевому камню\", 2000);\n });\n // якорь возрождения: в Пекле — точка возрождения, в верхнем мире взрывается, как в Minecraft\n events.on(\n \"anchorUse\",\n (p: { x: number; y: number; z: number; charges: number }) => {\n if (!DIMENSIONS[this.dimension].bedsExplode) {\n this.world.setBlock(p.x, p.y, p.z, B.air!);\n this.entities.explode(p.x + 0.5, p.y + 0.5, p.z + 0.5, 5);\n return;\n }\n if (p.charges <= 0) {\n this.hud.message(\"Якорь не заряжен — нужен светокамень\", 2200);\n return;\n }\n this.player.anchorPos = [p.x, p.y, p.z];\n this.player.anchorDim = this.dimension;\n this.hud.message(\"Точка возрождения — у якоря\", 2200);\n },\n );\n // стрела в мишень — красный сигнал по близости к центру (world/behaviours/extras)\n events.on(\n \"arrowHit\",\n (p: {\n x: number;\n y: number;\n z: number;\n hx?: number;\n hy?: number;\n hz?: number;\n }) => {\n if (p.hx === undefined || p.hy === undefined || p.hz === undefined)\n return;\n hitTarget(this.world, p.x, p.y, p.z, p.hx, p.hy, p.hz);\n },\n );\n events.on(\"blockPlace\", (p: { x: number; y: number; z: number }) => {\n if (overworld())\n this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n this.player.mode !== \"creative\",\n );\n });\n events.on(\"explosion\", (p: { x: number; y: number; z: number }) => {\n if (overworld()) this.sculk.vibrate(p.x, p.y, p.z, false);\n });\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const e = p.entity;\n if (overworld() && e.hitByPlayerT > 0 && !e.def.warden)\n this.sculk.onDeath(e.pos.x, e.pos.y, e.pos.z, e.def.xp?.[1] ?? 1);\n });\n // моб волны спавнера испытаний убит — засчитать спавнеру\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const f = p.entity.trialFrom;\n const be = f ? this.blockEntities.get(f[0], f[1], f[2]) : undefined;\n if (be?.type === \"trial_spawner\") (be.state as TrialState).killed++;\n });\n events.on(\"playerDied\", () => {\n const pp = this.player.pos;\n this.lastDeath = { x: pp.x, y: pp.y, z: pp.z, dim: this.dimension };\n });\n // проводник: игрок в воде в его радиусе получает силу моря (дыхание, ночное зрение, спешка)\n events.on(\n \"conduitPulse\",\n (p: { x: number; y: number; z: number; range: number }) => {\n const pl = this.player;\n if (\n pl.dead ||\n !pl.inWater ||\n pl.pos.distanceTo(\n new THREE.Vector3(p.x + 0.5, p.y + 0.5, p.z + 0.5),\n ) > p.range\n )\n return;\n for (const id of [\"water_breathing\", \"night_vision\", \"haste\"] as const)\n pl.addEffect(id, 13);\n events.emit(\"conduitPower\");\n },\n );\n // маяк: игрок в зоне получает выбранные силы\n events.on(\"beaconPulse\", (p: BeaconPulse) => {\n const pl = this.player;\n if (pl.dead || !inBeaconRange(p, pl.pos.x, pl.pos.y, pl.pos.z)) return;\n for (const e of beaconEffects(p)) pl.addEffect(e.id, e.seconds, e.level);\n });\n\n this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n void this.persist();\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\n const { signal } = this._abort;\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n },\n { signal },\n );\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\n \"click\",\n () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n },\n { signal },\n );\n\n // --- старт мира ---\n this.warmupAtSpawn();\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n // сетевая игра: скрытая вкладка продолжает считать мир в полном темпе (core/GameLoop)\n this.loop.backgroundTicker = () => this.net !== null;\n this.hud.showOverlay(false);\n this.loop.start();\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open()\n .then(async (store) => {\n if (!store) return;\n if (this._destroyed) return store.close();\n this.store = store;\n const last = await store.lastPlayed().catch(() => undefined);\n if (!last || this._worldTouched || this.playing || this._destroyed)\n return;\n try {\n this.save.apply(last.save);\n this.worldId = last.id;\n this.worldName = last.name;\n this.hud.message(\"Продолжаем мир «\" + last.name + \"»\", 2200);\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n })\n .finally(() => this._restored());\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). Гость чужой мир не сохраняет. */\n async persist(): Promise<void> {\n if (!this.store || this._netGuest) return;\n this.net?.collectPlayerStates(this.netPlayers);\n try {\n await this.store.put({\n id: this.worldId,\n name: this.worldName,\n updatedAt: Date.now(),\n save: this.save.collect(),\n });\n } catch {\n // квота/запрет хранилища: игра продолжается без автосейва\n }\n }\n\n /** Открыть мир из «Моих миров». */\n async openStoredWorld(id: string): Promise<boolean> {\n if (!this.store) return false;\n const w = await this.store.get(id).catch(() => undefined);\n if (!w) return false;\n if (id !== this.worldId) await this.persist();\n try {\n this.save.apply(w.save);\n } catch {\n return false;\n }\n this._worldTouched = true;\n this.worldId = w.id;\n this.worldName = w.name;\n return true;\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n // persist() снимает сейв синхронно при вызове — до apply ниже, поэтому уходит именно старый мир.\n void this.persist();\n try {\n this.save.apply(save);\n } catch {\n return false;\n }\n this.adoptNewWorld(name);\n this.hud.message(\"Мир «\" + name + \"» из Мастерской\", 2200);\n return true;\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n this._worldTouched = true;\n this.worldId = newWorldId();\n this.worldName = name;\n void this.persist();\n }\n\n // ---- сетевая игра (net/NetGame) ---------------------------------------------------------------\n\n /** Гость в чужом мире: мир не наш — не сохраняем его в «Мои миры» (до передачи хоста). */\n private _netGuest = false;\n\n /** Мир для входящего гостя: источник, палитра, время, погода, сложность, Предел, порталы. */\n netWorldMeta(): WorldMeta {\n return {\n source: this.world.source,\n palette: BLOCKS.map((b) => b.name),\n time: this.dayNight.serialize(),\n weather: this.weather.serialize(),\n difficulty: difficulty(),\n beyond: this.beyondState,\n portals: this.portals,\n daysAwake: this.daysAwake,\n name: this.worldName,\n };\n }\n\n /** Общее состояние мира (хост раз в 5 с): время, погода, сложность, Предел. */\n netWorldState(): WorldState {\n return {\n time: this.dayNight.serialize(),\n weather: this.weather.serialize(),\n difficulty: difficulty(),\n beyond: this.beyondState,\n daysAwake: this.daysAwake,\n };\n }\n\n applyNetWorldState(w: WorldState): void {\n this.dayNight.deserialize(w.time as SaveData[\"time\"]);\n this.weather.deserialize(w.weather as SaveData[\"weather\"]);\n setDifficulty(parseDifficulty(w.difficulty) ?? \"normal\");\n this.mergeBeyond(w.beyond);\n if (typeof w.daysAwake === \"number\" && w.daysAwake >= 0)\n this.daysAwake = w.daysAwake;\n }\n\n /** Предел: победа над драконом и Врата — однажды случившись, не отменяются (слияние «или»). */\n mergeBeyond(b: unknown): void {\n if (!b || typeof b !== \"object\") return;\n const s = b as Partial<Game[\"beyondState\"]>;\n const st = this.beyondState;\n st.initialized ||= s.initialized === true;\n st.dragonKilled ||= s.dragonKilled === true;\n st.creditsShown ||= s.creditsShown === true;\n if (\n !st.gateway &&\n Array.isArray(s.gateway) &&\n s.gateway.length === 3 &&\n s.gateway.every(Number.isFinite)\n )\n st.gateway = [s.gateway[0]!, s.gateway[1]!, s.gateway[2]!];\n }\n\n /** Сейв этого игрока для мира хоста: позиция, измерение, инвентарь, опыт, эффекты. */\n netPlayerState(): Record<string, unknown> {\n const p = this.player.pos;\n return {\n ...this.player.serialize(),\n inventory: this.inventory.serialize(),\n dim: this.dimension,\n pos: [p.x, p.y, p.z],\n beyond: this.beyondState,\n };\n }\n\n /** Ночь пропущена: спали все (сетевая игра) или один (одиночная). */\n skipNight(): void {\n this.dayNight.resetToMorning();\n this.weather.set(\"clear\"); // сон пережидает и непогоду, как в Minecraft\n this._slept = true;\n this.daysAwake = 0;\n this.netSleeping = false;\n this.hud.message(\"Доброе утро!\", 1800);\n events.emit(\"slept\");\n }\n\n /**\n * Гость: мир хоста вместо своего. Свой уходит в «Мои миры» и вернётся при выходе. Все измерения\n * строятся из присланных дифов — порталы работают без ожидания.\n */\n loadNetWorld(w: Welcome, diffs: DiffStore, settings: RoomSettings): void {\n void this.persist();\n this._soloWorldBeforeNet = this.worldId;\n this._netGuest = true;\n // сейвы чужих игроков — от СВОЕГО прежнего мира: стань мы хостом этого, они бы переехали в него\n this.netPlayers = new Map();\n this._worldTouched = true;\n if (this.invUI.open) this.invUI.close();\n const dim = isDimensionId(w.spawn.dim) ? w.spawn.dim : \"overworld\";\n const src = worldSourceOf({\n world: w.world.source,\n seed: 0,\n } as unknown as SaveData);\n this.dims.clear();\n for (const d of [\"overworld\", \"inferno\", \"beyond\"] as const)\n if (d !== dim)\n this.dims.set(d, {\n chunks: diffs.toChunkDiffMap(d),\n entities: [],\n blockEntities: [],\n });\n this.rebuildWorld(src, dim);\n this.world.loadDiffs(diffs.toChunkDiffMap(dim));\n this.entities.clear();\n this.blockEntities.clear();\n this.applyNetWorldState({ ...w.world });\n this.portals = portalsOf(w.world.portals);\n this.villagesSettled = new Set();\n this.maps = new Map();\n this.nextMapId = 1;\n this.achievements.load([]);\n const state = w.state as\n (Record<string, unknown> & { inventory?: unknown }) | null;\n if (state && Array.isArray((state as { pos?: unknown }).pos)) {\n this.player.deserialize(\n state as unknown as Parameters<Player[\"deserialize\"]>[0],\n );\n this.inventory.deserialize(\n state.inventory as Parameters<Inventory[\"deserialize\"]>[0],\n );\n } else {\n this.player.resetForNewWorld();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n this.player.mode = settings.guestMode;\n }\n const [x, y, z] = w.spawn.pos;\n this.warmup(x, z);\n this.player.pos.set(x, y, z);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n if (!state) this.player.spawnPoint.set(x, y, z);\n this.worldName = \"Сетевая игра\";\n this._netWorldName =\n typeof w.world.name === \"string\" && w.world.name.trim()\n ? w.world.name.trim().slice(0, 60)\n : \"\";\n this.hud.message(\"Вы в сетевой игре\", 2200);\n events.emit(\"dimensionChanged\", { dimension: dim });\n }\n\n /** Стал хостом после ухода прежнего: этот мир теперь наш — новой записью в «Моих мирах». */\n adoptNetWorldAsOwn(): void {\n this._netGuest = false;\n // имя мира хоста, а не безликое «Сетевой мир»: при переносе мир остаётся тем же миром\n this.adoptNewWorld(this._netWorldName || \"Сетевой мир\");\n }\n private _netWorldName = \"\";\n\n /**\n * Комната пришла из лобби (хост нажал «Играть», или игрок вошёл в уже идущую игру). Хост открывает\n * свой мир — последний, «Моих миров»; гость получает мир хоста. Ошибка — строка для игрока, и\n * комната закрывается: полуначатая сетевая игра хуже одиночной.\n */\n async acceptRoom(room: NetRoom): Promise<void> {\n if (this._destroyed) return;\n // Прошлая сетевая игра, чья сессия уже закрыта (из комнаты вышли через лобби, пока игра стояла на\n // паузе), не должна съесть новую комнату: раньше здесь был молчаливый выход, хост не открывал мир,\n // а гости через 30 с получали «Хост не ответил».\n if (this.net?.session.closed) this.net.closeStale();\n if (this.net) return;\n await this.restored;\n const res = await NetGame.fromRoom(this, room, this.playerName());\n if (typeof res === \"string\") {\n this.hud.message(friendlyNetError(res), 3200);\n await room.leave();\n return;\n }\n this.net = res;\n events.emit(\"net:roster\");\n if (room.isHost && !room.lateJoin)\n this.hud.message(\"Мир открыт для друзей\", 2200);\n this.startPlay();\n }\n\n /**\n * Сетевая игра закончилась. Гость возвращается в свой мир; хост (и ставший хостом) продолжает\n * в этом мире один. `reason` — показать почему (исключили, хост закрыл игру).\n */\n exitNet(reason: string | null): void {\n const guest = this._netGuest;\n this.net = null;\n this._netGuest = false;\n this.netSleeping = false;\n sim.authority = true;\n this.entities.replica = false;\n this.entities.others = () => [];\n this.entities.onLocalSpawn = null;\n this.entities.onRemoteAction = null;\n if (guest) {\n const back = this._soloWorldBeforeNet;\n this._soloWorldBeforeNet = null;\n if (back)\n void this.openStoredWorld(back).then(\n (ok) => ok || this.newWorld((Math.random() * 1e9) >>> 0),\n );\n else this.newWorld((Math.random() * 1e9) >>> 0);\n }\n if (reason) this.hud.message(reason, 3500);\n events.emit(\"net:roster\");\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n this.world.update(x, z);\n const busy =\n this.world.genQueue.length > 0 ||\n [...this.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n const s = this.world.generator.spawnHint();\n this.warmup(s.x, s.z);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, source, dimension);\n this.dimension = dimension;\n this.blockEntities.clear();\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.fluids.world = this.world;\n this.fluids.clear();\n this.interaction.breaking = null;\n this.interaction.damage.clear(); // трещины старого мира не должны лечь на блоки нового\n this.interaction.eating = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n const src: WorldSource =\n typeof source === \"number\"\n ? { kind: \"noise\", seed: source, gen: CURRENT_GEN }\n : // новый мир — текущего поколения (у мира из рецепта: цитадель под островом)\n { ...source, gen: source.gen ?? CURRENT_GEN };\n void this.persist(); // старый мир остаётся в «Моих мирах» таким, каким его оставили\n this.dims.clear();\n this.portals = portalsOf(null);\n this.beyondState = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n this.villagesSettled = new Set();\n this.achievements.load([]); // достижения — у каждого мира свои, как в Minecraft\n this.maps = new Map();\n this.nextMapId = 1;\n this.daysAwake = 0;\n this.rebuildWorld(src, \"overworld\");\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n if (src.kind === \"spec\")\n this.dayNight.setTimeOfDay(src.spec.ambience.timeOfDay);\n else this.dayNight.resetToMorning();\n this.warmupAtSpawn();\n this.player.resetForNewWorld(); // кровать, якорь, эффекты, опыт старого мира — не переносятся\n this.freezing.t = 0;\n this.player.spawn();\n const name = src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed;\n this.adoptNewWorld(name);\n this.hud.message(\n src.kind === \"spec\"\n ? \"Мир «\" + src.spec.name + \"»\"\n : \"Новый мир, сид \" + src.seed,\n 1800,\n );\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n if (target === this.dimension) return;\n const prev = this.dimension;\n // сетевая игра: целевое измерение — из общей копии дифов (net/NetGame.beforeDimension)\n this.net?.beforeDimension(target);\n this.dims.set(this.dimension, {\n chunks: this.world.serializeDiffs(),\n entities: this.entities.serialize(),\n blockEntities: this.blockEntities.serialize(),\n });\n // прежнее снято (с сущностями — для следующего владельца); теперь роль в новом измерении\n this.net?.prepareDimension();\n const saved = this.dims.get(target);\n this.dims.delete(target);\n this.rebuildWorld(this.world.source, target);\n this.entities.clear();\n if (saved) {\n this.world.loadDiffs(saved.chunks);\n this.entities.deserialize(\n (saved.entities ?? []) as Parameters<EntityManager[\"deserialize\"]>[0],\n );\n this.blockEntities.deserialize(saved.blockEntities);\n }\n this.warmup(x, z);\n this.player.pos.set(x, y, z);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n // сетевая игра: отдать прежнее измерение (если владел) и заявить новое — до любых правок в нём\n this.net?.afterDimension(prev, target);\n events.emit(\"dimensionChanged\", { dimension: target });\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n if (this.player.mode !== \"creative\") {\n const p = this.player.pos;\n const inv = this.inventory;\n for (const s of [...inv.slots, ...inv.armor])\n if (s)\n this.entities.spawnDrop(\n p.x + (Math.random() - 0.5),\n p.y + 0.5,\n p.z + (Math.random() - 0.5),\n s.item,\n s.count,\n s.dmg,\n );\n inv.clearCarried(); // сундук Предела не трогаем — раньше смерть стирала и его\n const xp = Math.min(100, this.player.xpLevel * 7);\n if (xp > 0) this.entities.spawnXp(p.x, p.y + 0.5, p.z, xp);\n this.player.xpLevel = this.player.xpProgress = this.player.xpTotal = 0;\n }\n if (this.invUI.open) this.invUI.close();\n // чат, табличка, книга, бирка поверх экрана смерти: их закрытие вернуло бы захват мыши\n this.chat.hide();\n this.signEditor.hide();\n this.bookScreen.hide();\n this.nameEditor.hide();\n this.input.uiOpen = true; // до releaseLock: без паузы — вместо неё экран смерти\n this.input.releaseLock();\n this.hud.showDeath(this.player.lastCause);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n if (!this.player.dead) return;\n this.player.respawn(this.goHome());\n this.hud.hideDeath();\n this.input.uiOpen = false;\n this.input.requestLock();\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n // якорь возрождения сильнее кровати и возвращает в СВОЁ измерение (обычно в Пекло)\n const anchor = this.anchorHome();\n if (anchor) return anchor;\n const s = this.player.spawnPoint;\n let at = new THREE.Vector3(s.x, s.y, s.z);\n let atBed = false;\n const bed = this.player.bedPos;\n if (this.dimension !== \"overworld\")\n this.changeDimension(\"overworld\", at.x, at.y, at.z);\n if (bed) {\n this.warmup(bed[0], bed[2]);\n if (\n BLOCKS[this.world.getBlock(bed[0], bed[1], bed[2])]?.shape === \"bed\"\n ) {\n at = new THREE.Vector3(bed[0] + 0.5, bed[1] + 0.6, bed[2] + 0.5);\n atBed = true;\n } else {\n this.player.bedPos = null;\n this.hud.message(\"Ваша кровать отсутствует или заблокирована\", 2500);\n }\n }\n this.warmup(at.x, at.z);\n // точку могли застроить — поднимаемся до свободного места на рост\n const bx = Math.floor(at.x),\n bz = Math.floor(at.z);\n let y = Math.floor(at.y);\n while (\n y < 126 &&\n (this.world.isSolid(bx, y, bz) || this.world.isSolid(bx, y + 1, bz))\n )\n y++;\n // …и опускаемся до пола: точка возрождения в воздухе — это смерть от падения сразу после неё\n while (y > 1 && this.world.getBlock(bx, y - 1, bz) === B.air) y--;\n at.y = y;\n // …и не в яме снега-пороха, не в лаве: иначе возрождение сразу ведёт к новой смерти, и так по кругу\n // (точка мира из старых сейвов могла быть записана прямо на дне такой ямы)\n if (!atBed && !isStandable(this.world, bx, y, bz)) {\n const safe = findSafeSpot(this.world, bx, bz, 16);\n if (safe) at = new THREE.Vector3(safe.x + 0.5, safe.y, safe.z + 0.5);\n }\n return at;\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n const p = this.player.pos;\n const cell = (dy: number) =>\n this.world.getBlock(\n Math.floor(p.x),\n Math.floor(p.y + dy),\n Math.floor(p.z),\n );\n const inBeyond = cell(0) === B.beyond_portal;\n const inside =\n inBeyond || [0, 1].some((dy) => cell(dy) === B.inferno_portal);\n if (!inside || this.player.dead) {\n this._portalT = 0;\n this._portalLock = false;\n return;\n }\n if (this._portalLock) return;\n // портал Предела переносит сразу, как в Minecraft\n if (inBeyond) {\n this._portalLock = true;\n this._beyondTravel();\n return;\n }\n this._portalT += dt;\n if (this._portalT < (this.player.mode === \"creative\" ? 0.25 : 4)) return;\n this._portalT = 0;\n this._portalLock = true;\n const target: DimensionId =\n this.dimension === \"inferno\" ? \"overworld\" : \"inferno\";\n const k =\n target === \"inferno\"\n ? 1 / DIMENSIONS.inferno.scale\n : DIMENSIONS.inferno.scale;\n const tx = Math.floor(p.x * k),\n tz = Math.floor(p.z * k);\n // портал, через который уходим, — тоже известный (порталы из сейвов до реестра)\n this._rememberPortal(\n this.dimension,\n Math.floor(p.x),\n Math.floor(p.y),\n Math.floor(p.z),\n );\n // свой портал на той стороне: в верхнем мире ищем в 128 блоках, в Пекле — в 16, как в Minecraft\n const known = this._nearestPortal(\n target,\n tx,\n tz,\n target === \"overworld\" ? 128 : 16,\n );\n this.changeDimension(\n target,\n (known?.[0] ?? tx) + 0.5,\n 70,\n (known?.[2] ?? tz) + 0.5,\n );\n let at = known ? findPortalNear(this.world, known[0], known[2], 2) : null;\n if (known && !at) this._forgetPortal(target, known); // сломали — забываем\n if (!at) {\n if (known) this.warmup(tx, tz);\n at =\n findPortalNear(this.world, tx, tz, 16) ??\n (target === \"inferno\"\n ? buildPortal(this.world, tx, tz, 64, LAVA_SEA + 2, 110)\n : buildPortal(this.world, tx, tz, 70, 4, 120));\n this._rememberPortal(target, at.x, at.y, at.z);\n }\n this.player.pos.set(at.x + 0.5, at.y, at.z + 0.5);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: target });\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n private readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n const plate = B.stone_pressure_plate;\n if (plate === undefined) return;\n const on = new Set<string>();\n // плиту жмут все, нажимные рельсы — только вагонетки\n const mark = (x: number, y: number, z: number, cart = false) => {\n const bx = Math.floor(x),\n by = Math.floor(y + 0.05),\n bz = Math.floor(z);\n const id = this.world.getBlock(bx, by, bz);\n if (id === plate || (cart && id === B.detector_rail))\n on.add(bx + \",\" + by + \",\" + bz);\n };\n if (!this.player.dead)\n mark(this.player.pos.x, this.player.pos.y, this.player.pos.z);\n // сетевая игра: плиту жмут и другие игроки этого измерения\n for (const a of this.entities.others())\n if (!a.player.dead) mark(a.player.pos.x, a.player.pos.y, a.player.pos.z);\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead) mark(e.pos.x, e.pos.y, e.pos.z);\n else if (e.kind === \"minecart\" && !e.dead)\n mark(e.pos.x, e.pos.y, e.pos.z, true);\n const at = (k: string) =>\n k.split(\",\").map(Number) as [number, number, number];\n for (const k of on) {\n this._plates.set(k, 1);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z),\n m = this.world.getMeta(x, y, z);\n if (id === plate && m === 0) this.world.setBlock(x, y, z, plate, 1);\n else if (id === B.detector_rail && !(m & 0x8))\n this.world.setBlock(x, y, z, id, m | 0x8);\n }\n for (const [k, left] of this._plates) {\n if (on.has(k)) continue;\n if (left - dt > 0) {\n this._plates.set(k, left - dt);\n continue;\n }\n this._plates.delete(k);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z);\n if (id === plate) this.world.setBlock(x, y, z, plate, 0);\n else if (id === B.detector_rail)\n this.world.setBlock(x, y, z, id, this.world.getMeta(x, y, z) & ~0x8);\n }\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n if (this.dimension === \"beyond\") {\n const s = this.player.spawnPoint;\n this.changeDimension(\"overworld\", s.x, s.y, s.z);\n this.player.pos.copy(this.goHome());\n // первый выход после победы — титры\n if (this.beyondState.dragonKilled && !this.beyondState.creditsShown) {\n this.beyondState.creditsShown = true;\n this.hud.showCredits();\n }\n } else {\n const { x, y, z } = BEYOND_ARRIVAL;\n this.changeDimension(\"beyond\", x + 0.5, y, z + 0.5);\n this._arrivalPlatform();\n if (!this.beyondState.initialized) this._initBeyond();\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: this.dimension });\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n this.beyondState.initialized = true;\n for (const p of beyondPillars(this.world.seed))\n this.entities.spawnMob(\"beyond_crystal\", p.x + 0.5, p.top + 2, p.z + 0.5);\n this.entities.spawnMob(\"void_dragon\", 0, 90, 0);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n const { x, y, z } = BEYOND_ARRIVAL;\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++) {\n this.world.setBlock(x + dx, y - 1, z + dz, B.obsidian!);\n for (let dy = 0; dy < 3; dy++)\n this.world.setBlock(x + dx, y + dy, z + dz, B.air!);\n }\n this.player.pos.set(x + 0.5, y, z + 0.5);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n this.world.setBlock(x, y - 1, z, B.bedrock!);\n this.world.setBlock(x, y, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 1, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 2, z, B.bedrock!);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n const [x, y, z] = gatewaySpot(this.world.seed);\n this.warmup(x, z); // запись в незагруженный чанк пропала бы: победа бывает далеко от врат\n this._placeGateway(x, y, z);\n this.beyondState.gateway = [x, y, z];\n }\n\n /** Идущий набег на деревню (systems/Raid) или null. Не сохраняется: перезагрузка его прекращает. */\n raid: Raid | null = null;\n\n /**\n * Набег: игрок с Дурным знамением у заселённой деревни — знамение сгорает, начинается набег. Волны\n * приходят с одного края деревни (в 28–34 блоках от центра) и идут к центру; победа — «Герой\n * деревни» уровня знамения, поражение — все жители в 64 блоках мертвы или вышло время.\n */\n _tickRaid(dt: number) {\n if (this.dimension !== \"overworld\") return;\n const pl = this.player;\n if (!this.raid) {\n const omen = pl.effects.get(\"bad_omen\");\n if (!omen || pl.dead) return;\n const v = (\n this.world.generator.villagesNear?.(pl.pos.x, pl.pos.z, 48) ?? []\n ).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v) return;\n pl.effects.delete(\"bad_omen\");\n events.emit(\"effectsChanged\", { id: \"bad_omen\" });\n this.raid = new Raid({ x: v.x + 0.5, y: v.y, z: v.z + 0.5 }, omen.level);\n events.emit(\"raidStart\", { x: v.x, z: v.z });\n this.hud.message(\"Набег! Защитите деревню\", 3500);\n return;\n }\n const r = this.raid;\n const center = new THREE.Vector3(r.center.x, r.center.y, r.center.z);\n let villagers = 0;\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.trades &&\n e.pos.distanceTo(center) < 64\n )\n villagers++;\n const res = r.tick(\n dt,\n (id) => {\n const e = this.entities.entities.get(id);\n return e?.kind === \"mob\" && !e.dead;\n },\n villagers,\n );\n if (res.spawn) {\n const a = Math.random() * Math.PI * 2;\n for (const type of res.spawn) {\n if (!MOBS[type]) continue;\n const d = 28 + Math.random() * 6,\n aa = a + (Math.random() - 0.5) * 0.4;\n const x = Math.floor(center.x + Math.cos(aa) * d),\n z = Math.floor(center.z + Math.sin(aa) * d);\n this.warmup(x, z);\n const m = this.entities.spawnMob(\n type,\n x + 0.5,\n this.world.surfaceY(x, z) + 1.01,\n z + 0.5,\n );\n m.raider = true;\n m.goal = center.clone();\n r.add(m.id, m.def.hp);\n }\n this.hud.message(`Набег: волна ${r.wave} из ${RAID_WAVES}`, 2500);\n events.emit(\"raidWave\", { wave: r.wave });\n }\n if (res.end === \"won\") {\n pl.addEffect(\"hero\", 2400, r.level);\n events.emit(\"raidWon\", { level: r.level });\n this.hud.message(\n \"Набег отбит! Вы — герой деревни: жители уступают в цене\",\n 4500,\n );\n this.raid = null;\n } else if (res.end === \"lost\") {\n for (const id of r.raiders) {\n const e = this.entities.entities.get(id);\n if (e?.kind === \"mob\") {\n e.raider = false;\n e.goal = null;\n }\n }\n events.emit(\"raidLost\");\n this.hud.message(\"Набег: деревня пала\", 3500);\n this.raid = null;\n }\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n private _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n if (this.dimension !== \"beyond\" || this.player.dead) return;\n const g = this.beyondState.gateway;\n if (this.beyondState.dragonKilled && !g) this._openGateway();\n // врата не ломаются: записаны, чанк загружен, а их нет — значит, не встали (сейв до починки)\n else if (g && this.world.getBlockOrUnloaded(g[0], g[1], g[2]) === B.air)\n this._placeGateway(g[0], g[1], g[2]);\n const p = this.player.pos;\n let touching = false;\n for (let x = Math.floor(p.x - 0.5); x <= Math.floor(p.x + 0.5); x++)\n for (let y = Math.floor(p.y); y <= Math.floor(p.y + 1.9); y++)\n for (let z = Math.floor(p.z - 0.5); z <= Math.floor(p.z + 0.5); z++)\n if (this.world.getBlock(x, y, z) === B.beyond_gateway)\n touching = true;\n if (!touching) {\n this._gateLock = false;\n return;\n }\n if (this._gateLock) return;\n this._gateLock = true;\n const toOuter = Math.hypot(p.x, p.z) < OUTER_START / 2;\n if (!toOuter) {\n this.warmup(BEYOND_ARRIVAL.x, BEYOND_ARRIVAL.z);\n this._arrivalPlatform();\n } else {\n const a = Math.atan2(p.z, p.x);\n const tx = Math.round(Math.cos(a) * OUTER_LANDING),\n tz = Math.round(Math.sin(a) * OUTER_LANDING);\n this.warmup(tx, tz);\n const gen = this.world.generator;\n const land =\n gen instanceof BeyondGenerator ? gen.landingNear(tx, tz, 96) : null;\n const x = land?.x ?? tx,\n z = land?.z ?? tz;\n let y = land?.y ?? ISLAND_Y;\n if (land) {\n this.warmup(x, z);\n while (\n y < 120 &&\n (this.world.isSolid(x, y, z) || this.world.isSolid(x, y + 1, z))\n )\n y++;\n } else\n // островов рядом нет — площадка из камня Предела, как в Minecraft\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++)\n this.world.setBlock(x + dx, y - 1, z + dz, B.beyond_stone!);\n this._placeGateway(x + 3, y, z); // обратные врата у места прибытия\n this.player.pos.set(x + 0.5, y, z + 0.5);\n this.hud.message(\"Внешние острова Предела\", 2500);\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"gatewayTravel\", { outer: toOuter });\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n this.beyondState.dragonKilled = true;\n for (const [x, y, z] of exitPortalCells())\n this.world.setBlock(x, y, z, B.beyond_portal!);\n this.world.setBlock(0, ISLAND_Y + 4, 0, B.dragon_egg!);\n this._openGateway();\n this.hud.message(\n \"Дракон Предела повержен! Портал домой — в фонтане, Врата на внешние острова — за краем\",\n 4500,\n );\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n const target = this.world.generator.locate?.(\"citadel\", x, z);\n if (!target) {\n this.hud.message(\"Око не чувствует цитадели в этом мире\", 2200);\n if (this.player.mode !== \"creative\") this.inventory.add(\"rim_eye\", 1);\n return;\n }\n this.entities.spawnRimEye(new THREE.Vector3(x, y, z), target);\n }\n\n /** Сетевая игра: реестр порталов изменился здесь — разослать (net/NetGame). */\n onPortalChange:\n | ((dim: DimensionId, p: [number, number, number], add: boolean) => void)\n | null = null;\n\n /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\n if (this._addPortal(dim, x, y, z))\n this.onPortalChange?.(dim, [x, y, z], true);\n }\n\n private _addPortal(\n dim: DimensionId,\n x: number,\n y: number,\n z: number,\n ): boolean {\n const list = this.portals[dim];\n const near = list.some(\n ([px, py, pz]) =>\n Math.abs(px - x) <= 3 && Math.abs(py - y) <= 4 && Math.abs(pz - z) <= 3,\n );\n if (!near) list.push([x, y, z]);\n return !near;\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n this.portals[dim] = this.portals[dim].filter((q) => q !== p);\n this.onPortalChange?.(dim, [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0], false);\n }\n\n /** Сетевая игра: портал зажгли или сломали у другого игрока. */\n applyNetPortal(dim: DimensionId, p: readonly number[], add: boolean): void {\n const [x = 0, y = 0, z = 0] = p;\n if (add) this._addPortal(dim, x, y, z);\n else\n this.portals[dim] = this.portals[dim].filter(\n (q) => q[0] !== x || q[1] !== y || q[2] !== z,\n );\n }\n\n /** Ближайший известный портал измерения в квадрате radius вокруг (x, z). */\n _nearestPortal(\n dim: DimensionId,\n x: number,\n z: number,\n radius: number,\n ): [number, number, number] | null {\n let best: [number, number, number] | null = null;\n let bestD = Infinity;\n for (const q of this.portals[dim]) {\n const dx = Math.abs(q[0] - x),\n dz = Math.abs(q[2] - z);\n if (dx > radius || dz > radius) continue;\n const d = dx * dx + dz * dz;\n if (d < bestD) {\n best = q;\n bestD = d;\n }\n }\n return best;\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n private anchorHome(): THREE.Vector3 | null {\n const a = this.player.anchorPos;\n const dim = this.player.anchorDim;\n if (!a || !dim || !isDimensionId(dim)) return null;\n const [x, y, z] = a;\n if (this.dimension !== dim)\n this.changeDimension(dim, x + 0.5, y + 1, z + 0.5);\n this.warmup(x, z);\n const charges =\n this.world.getBlock(x, y, z) === B.respawn_anchor\n ? anchorCharges(this.world.getMeta(x, y, z))\n : 0;\n if (charges <= 0) {\n this.player.anchorPos = null;\n this.player.anchorDim = null;\n this.hud.message(\"Якорь возрождения разряжен или сломан\", 2500);\n return null;\n }\n this.world.setBlock(x, y, z, B.respawn_anchor!, charges - 1);\n // рядом с якорем, а не в нём: клетка с полом и местом на рост — не лава и не обрыв (в Пекле\n // «не твёрдая» соседняя клетка часто оказывалась озером лавы или пустотой над ним)\n for (const dy of [0, 1, -1])\n for (const [dx, dz] of AROUND) {\n const nx = x + dx,\n ny = y + dy,\n nz = z + dz;\n if (isStandable(this.world, nx, ny, nz))\n return new THREE.Vector3(nx + 0.5, ny, nz + 0.5);\n }\n return new THREE.Vector3(x + 0.5, y + 1, z + 0.5);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n if (DIMENSIONS[this.dimension].bedsExplode) {\n // в Пекле и Пределе кровать взрывается, как в Minecraft\n this.world.setBlock(x, y, z, B.air!);\n this.entities.explode(x + 0.5, y + 0.5, z + 0.5, 5);\n return;\n }\n this.player.bedPos = [x, y, z];\n if (this.dayNight.isDay && this.weather.kind !== \"thunder\") {\n this.hud.message(\n \"Спать можно только ночью или в грозу. Точка возрождения — здесь\",\n 2400,\n );\n return;\n }\n const monsters = [...this.entities.entities.values()].some(\n (e) =>\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.category === \"hostile\" &&\n e.pos.distanceTo(this.player.pos) < 8,\n );\n if (monsters) {\n this.hud.message(\"Нельзя спать — рядом монстры\", 2000);\n return;\n }\n // сетевая игра: ночь пропускает хост, когда спят ВСЕ игроки Верхнего мира (net/NetGame)\n if (this.net) {\n this.netSleeping = true;\n this.hud.message(\"Вы спите. Ночь пройдёт, когда уснут все игроки\", 2600);\n return;\n }\n this.skipNight();\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n if (this.inventory.heldItem()?.item !== \"map\") return;\n const p = this.player.pos;\n const m = newMap(this.nextMapId++, p.x, p.z, this.dimension);\n this.maps.set(m.id, m);\n for (let ph = 0; ph < 4; ph++) surveyMap(this.world, m, p.x, p.z, 48, ph);\n const filled = { item: \"filled_map\", count: 1, tag: { map: m.id } };\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n const i = this.inventory.hotbarIndex;\n if (!this.inventory.slots[i]) this.inventory.setSlot(i, filled);\n else if (this.inventory.addStack(filled) > 0)\n this.entities.spawnDrop(p.x, p.y + 1, p.z, \"filled_map\", 1);\n events.emit(\"mapCreated\", { id: m.id });\n }\n\n _trialScanT = 0;\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n const p = this.player;\n if ((this._trialScanT -= dt) <= 0) {\n this._trialScanT = 1;\n for (const [x, y, z] of this.world.generator.trialSpawnersNear?.(\n p.pos.x,\n p.pos.z,\n 48,\n ) ?? [])\n if (this.world.getBlockOrUnloaded(x, y, z) === B.trial_spawner)\n this.blockEntities.ensure(x, y, z);\n }\n const hunter = !p.dead && p.mode !== \"creative\";\n for (const be of this.blockEntities.map.values()) {\n if (be.type !== \"trial_spawner\") continue;\n const mob =\n TRIAL_MOBS[this.world.getMeta(be.x, be.y, be.z) % TRIAL_MOBS.length]!;\n trialStep(be.state as TrialState, {\n dt,\n near:\n hunter &&\n Math.hypot(\n p.pos.x - be.x - 0.5,\n p.pos.y - be.y,\n p.pos.z - be.z - 0.5,\n ) <= TRIAL_RANGE,\n rng: Math.random,\n spawn: () => this._trialSpawn(be.x, be.y, be.z, mob),\n reward: (items) => {\n for (const s of items)\n this.entities.spawnDrop(\n be.x + 0.5,\n be.y + 1.2,\n be.z + 0.5,\n s.item,\n s.count,\n );\n events.emit(\"trialReward\", { x: be.x, y: be.y, z: be.z });\n },\n });\n }\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n for (let i = 0; i < 10; i++) {\n const sx = x + Math.floor(Math.random() * 9) - 4,\n sz = z + Math.floor(Math.random() * 9) - 4;\n for (let sy = y - 1; sy <= y + 2; sy++) {\n if (!BLOCKS[this.world.getBlock(sx, sy - 1, sz)]?.solid) continue;\n if (\n this.world.getBlock(sx, sy, sz) !== 0 ||\n this.world.getBlock(sx, sy + 1, sz) !== 0\n )\n continue;\n const m = this.entities.spawnMob(mob, sx + 0.5, sy, sz + 0.5);\n m.trialFrom = [x, y, z];\n events.emit(\"trialSpawn\", { x, y, z, mob });\n return true;\n }\n }\n return false;\n }\n\n freezing = new Freezing();\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n const p = this.player;\n const armor = this.inventory.armor;\n p.leatherBoots = () => armor.some((s) => s?.item === \"leather_boots\");\n p.goldArmor = () => armor.some((s) => !!s?.item.startsWith(\"golden_\"));\n // щит принял удар — теряет прочность по урону (как в Minecraft), не меньше 1\n p.onShieldBlock = (n) => {\n if (this.inventory.heldItem()?.item === \"shield\")\n this.inventory.damageHeld(Math.max(1, Math.floor(n)));\n };\n const leather = armor.some((s) => !!s?.item.startsWith(\"leather_\"));\n const dmg = this.freezing.tick(\n dt,\n p.inPowderSnow && !p.dead,\n leather || p.mode === \"creative\",\n p.fireT > 0,\n );\n if (dmg) p.damage(dmg, \"freeze\");\n this.hud.frost(this.freezing.level);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n this._hornCd = Math.max(0, this._hornCd - dt);\n const press =\n this.input.rightDown &&\n !this.player.dead &&\n this.inventory.heldItem()?.item === \"goat_horn\";\n if (press && !this._hornHeld && this._hornCd <= 0) {\n this._hornCd = 7;\n events.emit(\"goatHorn\");\n const p = this.player.pos;\n if (this.dimension === \"overworld\")\n this.sculk.vibrate(p.x, p.y, p.z, this.player.mode !== \"creative\");\n }\n this._hornHeld = press;\n }\n\n /** Скалк, вибрации и Страж (systems/Sculk.ts) — в верхнем мире. */\n sculk = new Sculk({\n world: () => this.world,\n player: () => this.player,\n wardens: () =>\n [...this.entities.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\" && !e.dead && !!e.def.warden,\n ),\n summon: (x, y, z) => {\n const m = this.entities.spawnMob(\"warden\", x, y, z);\n m.emergeT = EMERGE_TIME;\n m.invulnerable = true;\n return m;\n },\n });\n /** Пройдено шагов с последней вибрации (вибрация — каждые 2 блока ходьбы без приседа). */\n _stepDist = 0;\n /** Где игрок умер последний раз — туда указывает компас возвращения (в сейв не пишется). */\n lastDeath: { x: number; y: number; z: number; dim: string } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n this.sculk.tick(dt);\n const p = this.player;\n if (p.dead || p.flying || !p.onGround || p._sneaking) return;\n this._stepDist += Math.hypot(p.vel.x, p.vel.z) * dt;\n if (this._stepDist < 2) return;\n this._stepDist = 0;\n const x = Math.floor(p.pos.x),\n y = Math.floor(p.pos.y),\n z = Math.floor(p.pos.z);\n if (this.world.getBlock(x, y - 1, z) === B.sculk_shrieker)\n this.sculk.shriek(x, y - 1, z);\n this.sculk.vibrate(p.pos.x, p.pos.y, p.pos.z, p.mode !== \"creative\");\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n const on =\n !this.player.dead &&\n this.input.rightDown &&\n this.inventory.heldItem()?.item === \"spyglass\";\n const want = on ? 10 : 75;\n if (this.camera.fov === want) return;\n this.camera.fov += (want - this.camera.fov) * Math.min(1, dt * 12);\n if (Math.abs(this.camera.fov - want) < 0.1) this.camera.fov = want;\n this.camera.updateProjectionMatrix();\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n const h = this.inventory.heldItem();\n // карта сокровищ или исследователя в руке становится картой участка с крестиком над целью:\n // из тега {x, z}, а без него (карта картографа) — ближайшая к игроку цель такого вида\n const kind = h ? EXPLORER_MAPS[h.item] : undefined;\n if (h && kind) {\n let tx = Number(h.tag?.x),\n tz = Number(h.tag?.z);\n if (!Number.isFinite(tx) || !Number.isFinite(tz)) {\n if (this.dimension !== \"overworld\") return;\n const f = this.world.generator.locate?.(\n kind,\n this.player.pos.x,\n this.player.pos.z,\n );\n if (!f) return;\n tx = f.x;\n tz = f.z;\n }\n const m = newMap(this.nextMapId++, tx, tz, \"overworld\");\n // карта с целью — по центру цели, а не по сетке карт: иначе ✖ бывает у самого края\n m.x0 = Math.floor(tx) - MAP_SIZE / 2;\n m.z0 = Math.floor(tz) - MAP_SIZE / 2;\n m.target = [tx, tz];\n this.maps.set(m.id, m);\n this.inventory.setSlot(this.inventory.hotbarIndex, {\n item: \"filled_map\",\n count: 1,\n tag: { map: m.id },\n });\n events.emit(\"mapCreated\", { id: m.id, treasure: true });\n return;\n }\n if (h?.item !== \"filled_map\") return;\n const m = this.maps.get(Number(h.tag?.map));\n if (!m || m.dim !== this.dimension) return;\n const p = this.player.pos;\n surveyMap(this.world, m, p.x, p.z, 48, this._mapPhase++ & 3);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n if (this.dimension !== \"overworld\" || this.raid) return;\n const p = this.player.pos;\n const v = (this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v || !siegeTonight()) return;\n const angle = Math.random() * Math.PI * 2;\n let n = 0;\n for (let i = siegeCount(); i > 0; i--) {\n const { x, z } = siegeSpot(v.x + 0.5, v.z + 0.5, angle);\n this.warmup(x, z);\n const y = this.world.surfaceY(x, z);\n if (y <= 0) continue;\n const m = this.entities.spawnMob(\"zombie\", x + 0.5, y + 1.01, z + 0.5);\n // цель — центр деревни: без неё отряд разбредается, не дойдя до жителей (MobBrain, huntsLand)\n m.goal = new THREE.Vector3(v.x + 0.5, v.y, v.z + 0.5);\n n++;\n }\n if (n === 0) return;\n events.emit(\"siegeStart\", { x: v.x, z: v.z, count: n });\n this.hud.message(\"Ночная осада! Зомби идут к деревне\", 3500);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n // деревни верхнего мира и города Предела (панцирники) — один путь: villagesNear генератора\n const p = this.player.pos;\n for (const v of this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []) {\n if (this.villagesSettled.has(v.id)) continue;\n const loaded = v.residents.every(\n (r) =>\n this.world.getBlockOrUnloaded(\n Math.floor(r.x),\n Math.floor(r.y),\n Math.floor(r.z),\n ) !== -1,\n );\n if (!loaded) continue;\n for (const r of v.residents)\n if (MOBS[r.type]) {\n const m = this.entities.spawnMob(r.type, r.x, r.y + 0.01, r.z);\n if (r.captain) m.captain = true;\n }\n this.villagesSettled.add(v.id);\n }\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n const near = (p: THREE.Vector3) =>\n Math.hypot(p.x - x, p.z - z) < 3 && Math.abs(p.y - y) < 4;\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && near(e.pos)) e.damage(5);\n if (near(this.player.pos)) this.player.damage(5, \"lightning\");\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n // сетевая игра не встаёт на паузе: мир общий, и в меню игрок остаётся в нём (как в Minecraft)\n if (!this.playing && !this.net) return;\n // Кто что считает (net/sim): своё — игрок, его действия, голод, опасности — считает каждый;\n // мир измерения (мобы, жидкости, блоки, машины) — только владелец измерения; погоду и\n // время суток — хост (остальным их присылают, время между присылками идёт само).\n const own = this.simulating;\n const host = !this.net || this.net.isHost;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n if (host)\n this.weather.tick(\n dt,\n sky && own ? this.world : null,\n this.player.pos.x,\n this.player.pos.z,\n );\n this.world.raining = sky && this.weather.raining;\n this.world.containerAt = this._containerAt;\n this.entities.raining = this.world.raining;\n this.entities.skyDarken = Math.max(\n this.dayNight.skyDarken,\n sky ? this.weather.darken : 0,\n );\n this.entities.isDay = this.dayNight.isDay;\n this.entities.tick(dt); // у реплики — только сглаживание к снимкам владельца\n if (own) this.fluids.tick();\n this.player.tickHazards(dt);\n // черепаший панцирь: над водой копит 10 с Подводного дыхания — под водой их хватает на нырок\n const wb = this.player.effects.get(\"water_breathing\");\n if (\n this.inventory.armor[0]?.item === \"turtle_helmet\" &&\n !this.player.headInWater &&\n (!wb || wb.time < 9.5)\n )\n this.player.addEffect(\"water_breathing\", 10);\n this._tickPortal(dt);\n this._tickGateway();\n if (own) this._tickRaid(dt);\n this._spyglass(dt);\n if (own) this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n if (own) {\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n }\n this.dayNight.tick(dt); // у гостя — между поправками хоста (net WORLD)\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n if (own)\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n if (host) this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n this.netSleeping = false;\n } else if (!day && this._wasDay && own) this._siege(); // стемнело — может начаться осада деревни\n this.entities.insomnia = this.daysAwake >= 3;\n this._wasDay = day;\n this._villageT += dt;\n if (this._villageT >= 1) {\n this._villageT = 0;\n if (own) this._settleVillages();\n }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\n }\n events.emit(\"tick\", { n });\n this.net?.afterTick();\n this._autosaveT += dt;\n if (this._autosaveT >= AUTOSAVE_EVERY) {\n this._autosaveT = 0;\n void this.persist();\n }\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.net?.frame(dt);\n this.beams.update(this.blockEntities, this._time);\n this.displays.update(this.blockEntities, this.world);\n this.signs.update(this.blockEntities, this.world);\n this.banners.update(this.blockEntities, this.world);\n this.armorStands.update(this.blockEntities);\n this.fireworks.update(dt);\n this.dayNight.frame(\n this.player.pos,\n this.player.headInLava\n ? \"lava\"\n : this.player.headInWater\n ? \"water\"\n : this.player.headInPowderSnow\n ? \"powder_snow\"\n : \"air\",\n this.dimension,\n this.player.effects.has(\"night_vision\"),\n DIMENSIONS[this.dimension].hasSky ? this.weather : undefined,\n );\n // Тьма пульсирует: то наползает, то чуть отступает\n if (this.player.effects.has(\"darkness\"))\n this.dayNight.darkness(\n 0.75 + 0.25 * Math.abs(Math.sin(this._time * 1.3)),\n );\n this.weather.frame(\n dt,\n eye,\n DIMENSIONS[this.dimension].hasSky ? this.world : null,\n );\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.invUI.frame();\n this.renderPasses();\n }\n\n /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n this.renderer.shadowMap.needsUpdate = true; // тени — только в мировом проходе\n this.renderer.render(this.scene, this.camera);\n this.renderer.autoClear = false;\n this.renderer.clearDepth();\n this.camera.layers.set(ARM_LAYER);\n this.renderer.render(this.scene, this.camera);\n this.camera.layers.set(0);\n this.renderer.autoClear = true;\n }\n\n /**\n * Снимок экрана для кнопки «поделиться» на странице игры.\n *\n * ⚠ Рисует и читает В ОДИН приём. Рендерер создан без `preserveDrawingBuffer` (и так и надо —\n * флаг стоит памяти и копии каждый кадр), поэтому к моменту, когда до канвы доберётся кто-то\n * ещё, буфер уже очищен: `toBlob` вернёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись.\n */\n async capture(): Promise<Blob | null> {\n if (this._destroyed) return null;\n this.renderPasses();\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n // сетевая игра: уйти из комнаты (хост передаёт мир преемнику) — без возврата в свой мир, игра разбирается\n const net = this.net;\n if (net) {\n net.prepareExit();\n net.dispose();\n void net.leaveRoom();\n }\n // последний снимок уходит до разбора мира; дописывается транзакция уже без нас\n void this.persist().finally(() => this.store?.close());\n this.net = null;\n this._destroyed = true;\n this.worldUI.hide();\n this.loop.stop();\n this.input.releaseLock();\n // Шина `events` и DOM-подписки живут дольше игры: хост пересоздаёт Game после перелогина, и\n // без этого старый экземпляр продолжал бы слушать — `E` эмитился дважды (новый инвентарь\n // открывался и тут же закрывался), звуки двоились, весь старый мир держался в памяти.\n this._abort.abort();\n events.clear();\n this.hud.dispose();\n this.netUI.dispose();\n this.chat.dispose();\n this.signEditor.dispose();\n this.bookScreen.dispose();\n this.nameEditor.dispose();\n this.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n this.input.enabled = true;\n if (!this.loop.running) this.loop.start();\n } else {\n // сетевая игра: мир общий — у хоста и владельцев измерений он не может встать оттого, что\n // игрок открыл другой режим; останавливается только ввод\n if (!this.net) this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
|
|
608
|
+
"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 { DIMENSIONS, type DimensionId } from \"./world/Dimensions\";\nimport { portalsOf } from \"./world/Portal\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { type MapData } from \"./systems/Maps\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { Freezing } from \"./systems/Freezing\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { CreateWorldUI } from \"./ui/CreateWorldUI\";\nimport { NetUI } from \"./ui/NetUI\";\nimport { ChatConsole } from \"./ui/ChatConsole\";\nimport { SignEditor } from \"./ui/SignEditor\";\nimport { BookScreen } from \"./ui/BookScreen\";\nimport { NameEditor } from \"./ui/NameEditor\";\nimport { TouchControls, touchWanted } from \"./ui/TouchControls\";\nimport { Commands } from \"./systems/Commands\";\nimport { BeaconBeams } from \"./gfx/BeaconBeams\";\nimport { DisplayItems } from \"./gfx/DisplayItems\";\nimport { SignText } from \"./gfx/SignText\";\nimport { Banners } from \"./gfx/Banners\";\nimport { Achievements } from \"./systems/Achievements\";\nimport { Raid } from \"./systems/Raid\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { WorldStore } from \"./systems/WorldStore\";\nimport { startSource } from \"./worlds/startWorld\";\nimport type { DimensionSave, EffectId, SaveData, WorldSource } from \"./types\";\nimport { sim } from \"./net/sim\";\nimport { NetGame, type NetRoom } from \"./net/NetGame\";\nimport type { DiffStore } from \"./net/DiffStore\";\nimport type {\n RoomSettings,\n Welcome,\n WorldMeta,\n WorldState,\n} from \"./net/protocol\";\nimport {\n netWorldMeta,\n netWorldState,\n applyNetWorldState,\n mergeBeyond,\n netPlayerState,\n loadNetWorld,\n adoptNetWorldAsOwn,\n acceptRoom,\n exitNet,\n} from \"./game/netSession\";\nimport {\n persist,\n openStoredWorld,\n openSharedWorld,\n adoptNewWorld,\n warmup,\n warmupAtSpawn,\n rebuildWorld,\n newWorld,\n changeDimension,\n} from \"./game/worlds\";\nimport {\n onDeath,\n respawnPlayer,\n goHome,\n anchorHome,\n useBed,\n skipNight,\n} from \"./game/respawn\";\nimport {\n tickPortal,\n rememberPortal,\n addPortal,\n forgetPortal,\n applyNetPortal,\n nearestPortal,\n} from \"./game/portals\";\nimport {\n tickPlates,\n beyondTravel,\n initBeyond,\n arrivalPlatform,\n placeGateway,\n openGateway,\n tickGateway,\n dragonDefeated,\n throwEye,\n} from \"./game/beyond\";\nimport { tickRaid, siege, settleVillages } from \"./game/raid\";\nimport {\n tickTrials,\n trialSpawn,\n tickFreeze,\n tickSculk,\n lightning,\n} from \"./game/environment\";\nimport { goatHorn, spyglass, surveyHeldMap, createMap } from \"./game/items\";\nimport type { GameOptions } from \"./game/types\";\nimport { AUTOSAVE_EVERY } from \"./game/shared\";\nimport { wireEvents } from \"./game/wire\";\n\nexport type { GameOptions } from \"./game/types\";\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n /** Снимает разом все DOM-подписки игры (document/window/канвас) в destroy(). */\n private readonly _abort = new AbortController();\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n /** Блоки без опоры (факел, растение, дверь) ломаются вслед за соседом. */\n blockUpdates: BlockUpdates;\n /** Состояние клеток текущего измерения: сундуки, печи… */\n blockEntities: BlockEntities;\n /** Лучи светящих маяков. */\n beams: BeaconBeams;\n /** Предметы, выставленные в рамках. */\n displays: DisplayItems;\n /** Надписи на табличках. */\n signs: SignText;\n banners: Banners;\n armorStands: ArmorStand;\n fireworks: Fireworks;\n /** Достижения мира (systems/Achievements) и музыка (systems/Music). */\n achievements: Achievements;\n music: Music;\n /** Измерение, в котором игрок, и снимки остальных (дифы, сущности) до возвращения в них. */\n dimension: DimensionId = \"overworld\";\n readonly dims = new Map<DimensionId, DimensionSave>();\n interaction: Interaction;\n /** Вода и лава. */\n fluids: FluidSim;\n dayNight: DayNight;\n /** Дождь и гроза (только в измерениях с небом). */\n weather: Weather;\n /** Секунды в портале; lock — не переносить снова, пока игрок не вышел из портала прибытия. */\n _portalT = 0;\n _portalLock = false;\n /** Известные порталы: обратный путь выводит к своему порталу (в верхнем мире — в 128 блоках). */\n portals = portalsOf(null);\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны (сейв v3, `beyond`). */\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны, где Врата (после победы). */\n beyondState: {\n initialized: boolean;\n dragonKilled: boolean;\n creditsShown: boolean;\n gateway: [number, number, number] | null;\n } = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n worldUI: CreateWorldUI;\n /** Экран «Сетевая игра» и список игроков по Tab. */\n netUI: NetUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n _time: number;\n loop: GameLoop;\n /** Текущий мир в «Моих мирах». Классический стартовый мир — \"default\". */\n worldId = \"default\";\n worldName = \"VoxelCraft\";\n /** Хранилище миров; null — браузер не даёт IndexedDB (игра работает без сохранения). */\n store: WorldStore | null = null;\n private _autosaveT = 0;\n /** Был ли день на прошлом тике — рассвет пополняет товар жителей. */\n private _wasDay = true;\n /** Деревни, в которые уже поселены жители (id региона; сейв v3, `villages`). */\n villagesSettled = new Set<string>();\n private _villageT = 0;\n /** Ночей без сна (сейв v3 `daysAwake`); _slept — спал этой ночью. */\n daysAwake = 0;\n _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n _worldTouched = false;\n _destroyed = false;\n // ---- сетевая игра (net/NetGame) ----\n /** Идёт сетевая игра (хост или гость); null — одиночная. */\n net: NetGame | null = null;\n /** Система мультиплеера лобби; нет — сетевой игры нет, кнопки для неё не рисуются. */\n readonly online: { available(): boolean; openForFriends(): void } | null;\n /** Последний мир восстановлен из «Моих миров» (или его нет) — хосту открывать сеть можно. */\n readonly restored: Promise<void>;\n private _restored!: () => void;\n /** Имя этого игрока для других (профиль SDK). */\n readonly playerName: () => string;\n /**\n * Хост: сохранённые игроки сетевой игры по userId (позиция, инвентарь, опыт…). Лежат в сейве мира\n * хоста (`players`): вернувшийся игрок продолжает с тем же инвентарём и там же.\n */\n netPlayers = new Map<string, unknown>();\n /** Лёг спать в сетевой игре: ночь пропускается, когда спят все (как в Minecraft). */\n netSleeping = false;\n /** Мир, в котором игрок был до входа в чужую игру: вернуться в него при выходе. */\n _soloWorldBeforeNet: string | null = null;\n\n /** Этот процесс считает своё измерение (одиночная игра, хост, владелец измерения) — net/sim. */\n get simulating(): boolean {\n return sim.authority;\n }\n\n constructor(opts: GameOptions = {}) {\n this.online = opts.online ?? null;\n this.restored = new Promise<void>((r) => (this._restored = r));\n this.playerName = opts.playerName ?? (() => \"Игрок\");\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n // Казуальная картинка: Neutral-тонмаппинг держит насыщенность ярких цветов (ACES их смывает).\n this.renderer.toneMapping = THREE.NeutralToneMapping;\n this.renderer.toneMappingExposure = CONFIG.graphics.exposure;\n this.renderer.shadowMap.enabled = CONFIG.graphics.shadows;\n this.renderer.shadowMap.type = THREE.PCFShadowMap; // мягкость — shadow.radius (PCFSoft в r185 устарел)\n // Карту теней обновляем сами, раз за кадр (см. frame): теневой проход фильтрует объекты по\n // слоям камеры, и второй проход (рука) перерисовал бы её заново — из одной руки.\n this.renderer.shadowMap.autoUpdate = false;\n this.scene = new THREE.Scene();\n // Пропорции выставит первый же замер ResizeObserver'а — до него кадров не будет.\n this.camera = new THREE.PerspectiveCamera(\n 75,\n 1,\n 0.1,\n CONFIG.renderDistance * 16 + 60,\n );\n this.camera.rotation.order = \"YXZ\";\n this.scene.add(this.camera); // рука прикреплена к камере\n\n // Размер берём из раскладки самого канваса (CSS растягивает его на вьюпорт), а не из\n // window.innerWidth, и через ResizeObserver, а не событие resize: игра может стартовать\n // в ещё не разложенном контейнере — фоновая вкладка или iframe превью, — где на момент\n // конструктора innerWidth равен нулю. Событие resize там уже не придёт, и канвас навсегда\n // остался бы 0x0: симуляция живая, экран чёрный. ResizeObserver сам отдаёт первый замер,\n // как только раскладка появляется.\n this._resizeObserver = new ResizeObserver(() => this._resize());\n this._resizeObserver.observe(canvas);\n this._resize();\n\n // --- контент: атлас и иконки из одних и тех же процедурных текстур ---\n this.atlas = new TextureAtlas();\n buildIcons(this.atlas);\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, startSource());\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.blockUpdates = new BlockUpdates(() => this.world, this.entities);\n // броня из инвентаря гасит урон игрока и изнашивается от ударов\n this.player.armorStats = () => this.inventory.armorStats();\n this.player.onArmorHit = (dmg) => this.inventory.damageArmor(dmg);\n this.player.canGlide = () => this.inventory.canGlide();\n this.player.tryTotem = () => {\n if (this.inventory.heldItem()?.item !== \"life_totem\") return false;\n this.inventory.consumeHeld(1);\n return true;\n };\n this.player.onGlideWear = () => this.inventory.wearWings();\n this.blockEntities = new BlockEntities(() => this.world, this.entities);\n this.beams = new BeaconBeams(this.scene);\n this.displays = new DisplayItems(this.scene, this.atlas);\n this.signs = new SignText(this.scene);\n this.banners = new Banners(this.scene);\n this.armorStands = new ArmorStand(this.scene, this.atlas);\n this.fireworks = new Fireworks(this.scene);\n this.achievements = new Achievements({\n has: (pred) => this.inventory.slots.some((s) => !!s && pred(s.item)),\n wearing: (pred) => this.inventory.armor.some((s) => !!s && pred(s.item)),\n effect: (id) => this.player.effects.has(id as EffectId),\n });\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.fluids = new FluidSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.weather = new Weather(this.scene);\n const start = this.world.source;\n if (start.kind === \"spec\") {\n this.dayNight.setTimeOfDay(start.spec.ambience.timeOfDay);\n this.worldName = start.spec.name;\n }\n this.audio = new Audio();\n this.music = new Music(this.audio);\n // пластинка в проигрывателе играет свою мелодию (сид и лад — systems/Disc), вынули — тишина\n events.on(\"discPlay\", ({ mood, seed }: { mood: Mood; seed: number }) =>\n this.music.play(mood, seed),\n );\n events.on(\"discEject\", () => this.music.stop());\n this.input = new Input(canvas, this.intents, this._abort.signal);\n this.armView = new ArmView(this.camera, this.atlas);\n // второй проход (рука) видит только свой слой — свет мира включаем и на нём\n this.dayNight.sun.layers.enable(ARM_LAYER);\n this.dayNight.hemi.layers.enable(ARM_LAYER);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n new AchievementsView(this.hud.heartsEl.parentElement!, this.achievements);\n this.invUI = new InventoryUI(this, this._abort.signal);\n this.worldUI = new CreateWorldUI(this, this._abort.signal, opts.ai ?? null);\n this.netUI = new NetUI(this, this._abort.signal);\n this.commands = new Commands(this);\n this.chat = new ChatConsole(this, this._abort.signal);\n this.signEditor = new SignEditor(this, this._abort.signal);\n this.bookScreen = new BookScreen(this, this._abort.signal);\n this.nameEditor = new NameEditor(this, this._abort.signal);\n this.touch = touchWanted()\n ? new TouchControls(this, this._abort.signal)\n : null;\n this.interaction.onCommand = (text) => this.commands.run(text);\n this.interaction.mayToggleMode = () => !this.net || this.net.canCheat;\n this.interaction.onUseMap = () => this.createMap();\n this.playing = false;\n this._time = 0;\n wireEvents(this);\n const { signal } = this._abort;\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n },\n { signal },\n );\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\n \"click\",\n () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n },\n { signal },\n );\n\n // --- старт мира ---\n this.warmupAtSpawn();\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n // сетевая игра: скрытая вкладка продолжает считать мир в полном темпе (core/GameLoop)\n this.loop.backgroundTicker = () => this.net !== null;\n this.hud.showOverlay(false);\n this.loop.start();\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open()\n .then(async (store) => {\n if (!store) return;\n if (this._destroyed) return store.close();\n this.store = store;\n const last = await store.lastPlayed().catch(() => undefined);\n if (!last || this._worldTouched || this.playing || this._destroyed)\n return;\n try {\n this.save.apply(last.save);\n this.worldId = last.id;\n this.worldName = last.name;\n this.hud.message(\"Продолжаем мир «\" + last.name + \"»\", 2200);\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n })\n .finally(() => this._restored());\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). Гость чужой мир не сохраняет. */\n persist(): Promise<void> {\n return persist(this);\n }\n\n /** Открыть мир из «Моих миров». */\n openStoredWorld(id: string): Promise<boolean> {\n return openStoredWorld(this, id);\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n return openSharedWorld(this, save, name);\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n return adoptNewWorld(this, name);\n }\n\n /** Гость в чужом мире: мир не наш — не сохраняем его в «Мои миры» (до передачи хоста). */\n _netGuest = false;\n\n /** Мир для входящего гостя: источник, палитра, время, погода, сложность, Предел, порталы. */\n netWorldMeta(): WorldMeta {\n return netWorldMeta(this);\n }\n\n /** Общее состояние мира (хост раз в 5 с): время, погода, сложность, Предел. */\n netWorldState(): WorldState {\n return netWorldState(this);\n }\n\n applyNetWorldState(w: WorldState): void {\n applyNetWorldState(this, w);\n }\n\n /** Предел: победа над драконом и Врата — однажды случившись, не отменяются (слияние «или»). */\n mergeBeyond(b: unknown): void {\n mergeBeyond(this, b);\n }\n\n /** Сейв этого игрока для мира хоста: позиция, измерение, инвентарь, опыт, эффекты. */\n netPlayerState(): Record<string, unknown> {\n return netPlayerState(this);\n }\n\n /** Ночь пропущена: спали все (сетевая игра) или один (одиночная). */\n skipNight(): void {\n skipNight(this);\n }\n\n /**\n * Гость: мир хоста вместо своего. Свой уходит в «Мои миры» и вернётся при выходе. Все измерения\n * строятся из присланных дифов — порталы работают без ожидания.\n */\n loadNetWorld(w: Welcome, diffs: DiffStore, settings: RoomSettings): void {\n loadNetWorld(this, w, diffs, settings);\n }\n\n /** Стал хостом после ухода прежнего: этот мир теперь наш — новой записью в «Моих мирах». */\n adoptNetWorldAsOwn(): void {\n adoptNetWorldAsOwn(this);\n }\n\n _netWorldName = \"\";\n\n /**\n * Комната пришла из лобби (хост нажал «Играть», или игрок вошёл в уже идущую игру). Хост открывает\n * свой мир — последний, «Моих миров»; гость получает мир хоста. Ошибка — строка для игрока, и\n * комната закрывается: полуначатая сетевая игра хуже одиночной.\n */\n acceptRoom(room: NetRoom): Promise<void> {\n return acceptRoom(this, room);\n }\n\n /**\n * Сетевая игра закончилась. Гость возвращается в свой мир; хост (и ставший хостом) продолжает\n * в этом мире один. `reason` — показать почему (исключили, хост закрыл игру).\n */\n exitNet(reason: string | null): void {\n exitNet(this, reason);\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n return warmup(this, x, z);\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n return warmupAtSpawn(this);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n return rebuildWorld(this, source, dimension);\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n return newWorld(this, source);\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n return changeDimension(this, target, x, y, z);\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n return onDeath(this);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n return respawnPlayer(this);\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n return goHome(this);\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n return tickPortal(this, dt);\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n return tickPlates(this, dt);\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n return beyondTravel(this);\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n return initBeyond(this);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n return arrivalPlatform(this);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n return placeGateway(this, x, y, z);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n return openGateway(this);\n }\n\n /** Идущий набег на деревню (systems/Raid) или null. Не сохраняется: перезагрузка его прекращает. */\n raid: Raid | null = null;\n\n /**\n * Набег: игрок с Дурным знамением у заселённой деревни — знамение сгорает, начинается набег. Волны\n * приходят с одного края деревни (в 28–34 блоках от центра) и идут к центру; победа — «Герой\n * деревни» уровня знамения, поражение — все жители в 64 блоках мертвы или вышло время.\n */\n _tickRaid(dt: number) {\n return tickRaid(this, dt);\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n return tickGateway(this);\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n return dragonDefeated(this);\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n return throwEye(this, x, y, z);\n }\n\n /** Сетевая игра: реестр порталов изменился здесь — разослать (net/NetGame). */\n onPortalChange:\n | ((dim: DimensionId, p: [number, number, number], add: boolean) => void)\n | null = null;\n\n /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\n return rememberPortal(this, dim, x, y, z);\n }\n\n _addPortal(dim: DimensionId, x: number, y: number, z: number): boolean {\n return addPortal(this, dim, x, y, z);\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n return forgetPortal(this, dim, p);\n }\n\n /** Сетевая игра: портал зажгли или сломали у другого игрока. */\n applyNetPortal(dim: DimensionId, p: readonly number[], add: boolean): void {\n applyNetPortal(this, dim, p, add);\n }\n\n /** Ближайший известный портал измерения в квадрате radius вокруг (x, z). */\n _nearestPortal(\n dim: DimensionId,\n x: number,\n z: number,\n radius: number,\n ): [number, number, number] | null {\n return nearestPortal(this, dim, x, z, radius);\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n anchorHome(): THREE.Vector3 | null {\n return anchorHome(this);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n return useBed(this, x, y, z);\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n createMap(this);\n }\n\n _trialScanT = 0;\n\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n tickTrials(this, dt);\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n return trialSpawn(this, x, y, z, mob);\n }\n\n freezing = new Freezing();\n\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n tickFreeze(this, dt);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n goatHorn(this, dt);\n }\n\n /** Скалк, вибрации и Страж (systems/Sculk.ts) — в верхнем мире. */\n sculk = new Sculk({\n world: () => this.world,\n player: () => this.player,\n wardens: () =>\n [...this.entities.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\" && !e.dead && !!e.def.warden,\n ),\n summon: (x, y, z) => {\n const m = this.entities.spawnMob(\"warden\", x, y, z);\n m.emergeT = EMERGE_TIME;\n m.invulnerable = true;\n return m;\n },\n });\n /** Пройдено шагов с последней вибрации (вибрация — каждые 2 блока ходьбы без приседа). */\n _stepDist = 0;\n /** Где игрок умер последний раз — туда указывает компас возвращения (в сейв не пишется). */\n lastDeath: { x: number; y: number; z: number; dim: string } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n tickSculk(this, dt);\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n spyglass(this, dt);\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n surveyHeldMap(this);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n return siege(this);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n return settleVillages(this);\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n return lightning(this, x, y, z);\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n // сетевая игра не встаёт на паузе: мир общий, и в меню игрок остаётся в нём (как в Minecraft)\n if (!this.playing && !this.net) return;\n // Кто что считает (net/sim): своё — игрок, его действия, голод, опасности — считает каждый;\n // мир измерения (мобы, жидкости, блоки, машины) — только владелец измерения; погоду и\n // время суток — хост (остальным их присылают, время между присылками идёт само).\n const own = this.simulating;\n const host = !this.net || this.net.isHost;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n if (host)\n this.weather.tick(\n dt,\n sky && own ? this.world : null,\n this.player.pos.x,\n this.player.pos.z,\n );\n this.world.raining = sky && this.weather.raining;\n this.world.containerAt = this._containerAt;\n this.entities.raining = this.world.raining;\n this.entities.skyDarken = Math.max(\n this.dayNight.skyDarken,\n sky ? this.weather.darken : 0,\n );\n this.entities.isDay = this.dayNight.isDay;\n this.entities.tick(dt); // у реплики — только сглаживание к снимкам владельца\n if (own) this.fluids.tick();\n this.player.tickHazards(dt);\n // черепаший панцирь: над водой копит 10 с Подводного дыхания — под водой их хватает на нырок\n const wb = this.player.effects.get(\"water_breathing\");\n if (\n this.inventory.armor[0]?.item === \"turtle_helmet\" &&\n !this.player.headInWater &&\n (!wb || wb.time < 9.5)\n )\n this.player.addEffect(\"water_breathing\", 10);\n this._tickPortal(dt);\n this._tickGateway();\n if (own) this._tickRaid(dt);\n this._spyglass(dt);\n if (own) this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n if (own) {\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n }\n this.dayNight.tick(dt); // у гостя — между поправками хоста (net WORLD)\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n if (own)\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n if (host) this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n this.netSleeping = false;\n } else if (!day && this._wasDay && own) this._siege(); // стемнело — может начаться осада деревни\n this.entities.insomnia = this.daysAwake >= 3;\n this._wasDay = day;\n this._villageT += dt;\n if (this._villageT >= 1) {\n this._villageT = 0;\n if (own) this._settleVillages();\n }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\n }\n events.emit(\"tick\", { n });\n this.net?.afterTick();\n this._autosaveT += dt;\n if (this._autosaveT >= AUTOSAVE_EVERY) {\n this._autosaveT = 0;\n void this.persist();\n }\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.net?.frame(dt);\n this.beams.update(this.blockEntities, this._time);\n this.displays.update(this.blockEntities, this.world);\n this.signs.update(this.blockEntities, this.world);\n this.banners.update(this.blockEntities, this.world);\n this.armorStands.update(this.blockEntities);\n this.fireworks.update(dt);\n this.dayNight.frame(\n this.player.pos,\n this.player.headInLava\n ? \"lava\"\n : this.player.headInWater\n ? \"water\"\n : this.player.headInPowderSnow\n ? \"powder_snow\"\n : \"air\",\n this.dimension,\n this.player.effects.has(\"night_vision\"),\n DIMENSIONS[this.dimension].hasSky ? this.weather : undefined,\n );\n // Тьма пульсирует: то наползает, то чуть отступает\n if (this.player.effects.has(\"darkness\"))\n this.dayNight.darkness(\n 0.75 + 0.25 * Math.abs(Math.sin(this._time * 1.3)),\n );\n this.weather.frame(\n dt,\n eye,\n DIMENSIONS[this.dimension].hasSky ? this.world : null,\n );\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.invUI.frame();\n this.renderPasses();\n }\n\n /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n this.renderer.shadowMap.needsUpdate = true; // тени — только в мировом проходе\n this.renderer.render(this.scene, this.camera);\n this.renderer.autoClear = false;\n this.renderer.clearDepth();\n this.camera.layers.set(ARM_LAYER);\n this.renderer.render(this.scene, this.camera);\n this.camera.layers.set(0);\n this.renderer.autoClear = true;\n }\n\n /**\n * Снимок экрана для кнопки «поделиться» на странице игры.\n *\n * ⚠ Рисует и читает В ОДИН приём. Рендерер создан без `preserveDrawingBuffer` (и так и надо —\n * флаг стоит памяти и копии каждый кадр), поэтому к моменту, когда до канвы доберётся кто-то\n * ещё, буфер уже очищен: `toBlob` вернёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись.\n */\n async capture(): Promise<Blob | null> {\n if (this._destroyed) return null;\n this.renderPasses();\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n // сетевая игра: уйти из комнаты (хост передаёт мир преемнику) — без возврата в свой мир, игра разбирается\n const net = this.net;\n if (net) {\n net.prepareExit();\n net.dispose();\n void net.leaveRoom();\n }\n // последний снимок уходит до разбора мира; дописывается транзакция уже без нас\n void this.persist().finally(() => this.store?.close());\n this.net = null;\n this._destroyed = true;\n this.worldUI.hide();\n this.loop.stop();\n this.input.releaseLock();\n // Шина `events` и DOM-подписки живут дольше игры: хост пересоздаёт Game после перелогина, и\n // без этого старый экземпляр продолжал бы слушать — `E` эмитился дважды (новый инвентарь\n // открывался и тут же закрывался), звуки двоились, весь старый мир держался в памяти.\n this._abort.abort();\n events.clear();\n this.hud.dispose();\n this.netUI.dispose();\n this.chat.dispose();\n this.signEditor.dispose();\n this.bookScreen.dispose();\n this.nameEditor.dispose();\n this.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n this.input.enabled = true;\n if (!this.loop.running) this.loop.start();\n } else {\n // сетевая игра: мир общий — у хоста и владельцев измерений он не может встать оттого, что\n // игрок открыл другой режим; останавливается только ввод\n if (!this.net) this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
|
|
501
609
|
},
|
|
502
610
|
{
|
|
503
611
|
"path": "module.ts",
|
|
@@ -511,9 +619,45 @@
|
|
|
511
619
|
"path": "net/DiffStore.ts",
|
|
512
620
|
"content": "// Изменённые блоки ВСЕХ измерений у каждого участника сетевой игры. Мир — это сид/рецепт плюс эти\n// дифы, поэтому у кого они есть, тот может построить любое измерение сам: перейти через портал без\n// ожидания, стать владельцем измерения или хостом, если прежний ушёл.\nimport { idx } from \"../world/Chunk\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { ChunkDiffMap } from \"../types\";\nimport type { BlockDelta } from \"./protocol\";\n\ntype ChunkDiff = Map<number, [number, number]>;\n\nexport class DiffStore {\n private readonly dims = new Map<DimensionId, Map<string, ChunkDiff>>();\n\n private dim(d: DimensionId): Map<string, ChunkDiff> {\n let m = this.dims.get(d);\n if (!m) this.dims.set(d, (m = new Map()));\n return m;\n }\n\n /** Заменить дифы измерения целиком (снимок мира, сейв). */\n load(d: DimensionId, diffs: ChunkDiffMap | undefined): void {\n const m = new Map<string, ChunkDiff>();\n for (const [k, arr] of Object.entries(diffs ?? {})) {\n if (!Array.isArray(arr)) continue;\n const cd: ChunkDiff = new Map();\n for (const e of arr)\n if (Array.isArray(e) && e.length === 3 && e.every(Number.isInteger))\n cd.set(e[0], [e[1], e[2]]);\n m.set(k, cd);\n }\n this.dims.set(d, m);\n }\n\n apply(\n d: DimensionId,\n x: number,\n y: number,\n z: number,\n id: number,\n meta: number,\n ): void {\n const k = Math.floor(x / 16) + \",\" + Math.floor(z / 16);\n const m = this.dim(d);\n let cd = m.get(k);\n if (!cd) m.set(k, (cd = new Map()));\n cd.set(idx(x & 15, y, z & 15), [id, meta]);\n }\n\n applyAll(d: DimensionId, list: readonly BlockDelta[]): void {\n for (const b of list) this.apply(d, b.x, b.y, b.z, b.id, b.meta);\n }\n\n toChunkDiffMap(d: DimensionId): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n for (const [k, cd] of this.dims.get(d) ?? [])\n out[k] = [...cd].map(([i, [id, meta]]): [number, number, number] => [\n i,\n id,\n meta,\n ]);\n return out;\n }\n\n /** Все измерения — для снимка мира гостю. */\n all(): Partial<Record<DimensionId, ChunkDiffMap>> {\n const out: Partial<Record<DimensionId, ChunkDiffMap>> = {};\n for (const d of this.dims.keys()) out[d] = this.toChunkDiffMap(d);\n return out;\n }\n\n /** Сколько изменённых блоков в измерении (для отладки и тестов). */\n count(d: DimensionId): number {\n let n = 0;\n for (const cd of this.dims.get(d)?.values() ?? []) n += cd.size;\n return n;\n }\n}\n"
|
|
513
621
|
},
|
|
622
|
+
{
|
|
623
|
+
"path": "net/game/admin.ts",
|
|
624
|
+
"content": "import { encodeJsonSync } from \"../codec\";\nimport { M } from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function mayCheat(ng: NetGame, userId: string): boolean {\n return userId === ng.selfId || ng.ops.has(userId) || ng.settings.cheatsForAll;\n}\n\nexport function routeCommand(\n ng: NetGame,\n name: string,\n text: string,\n): string | null {\n const harmless = [\"help\", \"seed\", \"locate\", \"achievements\", \"music\"];\n if (harmless.includes(name)) return null;\n if (name === \"newworld\")\n return \"В сетевой игре новый мир не создать — сначала выйдите из неё\";\n if (!ng.canCheat) return \"Команды в этой игре — только у операторов\";\n if ([\"time\", \"weather\", \"difficulty\"].includes(name) && !ng.isHost) {\n ng.toHost(\"rel\", encodeJsonSync(M.COMMAND, { text }));\n return \"Команда отправлена хосту\";\n }\n return null;\n}\n\nexport function setOp(ng: NetGame, userId: string, op: boolean): void {\n if (!ng.isHost || userId === ng.selfId) return;\n if (op) ng.ops.add(userId);\n else ng.ops.delete(userId);\n const r = ng.records.get(userId);\n if (r) r.op = op;\n ng.broadcastRoster();\n}\n\nexport async function kick(\n ng: NetGame,\n userId: string,\n ban: boolean,\n): Promise<string | null> {\n const res = await ng.session.kick(userId, ban);\n return res.ok ? null : res.error;\n}\n"
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
"path": "net/game/dimensions.ts",
|
|
628
|
+
"content": "import type { DimensionId } from \"../../world/Dimensions\";\nimport type { DimensionSave } from \"../../types\";\nimport { sim } from \"../sim\";\nimport { RemotePlayer } from \"../RemotePlayer\";\nimport { encodeJsonSync } from \"../codec\";\nimport {\n M,\n type BeMsg,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n} from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nconst EMPTY_DIM = (): DimensionSave => ({\n chunks: {},\n entities: [],\n blockEntities: [],\n});\n\nexport function becomeOwner(ng: NetGame, save: DimensionSave | null): void {\n const g = ng.game;\n sim.authority = true;\n g.entities.replica = false;\n g.entities.localSlot = ng.slot;\n ng.driving = null;\n g.entities.drivenId = null;\n ng.locks.clear();\n if (save) {\n g.entities.deserialize(\n (save.entities ?? []) as Parameters<typeof g.entities.deserialize>[0],\n );\n g.blockEntities.deserialize(save.blockEntities ?? []);\n }\n ng.bc.reset();\n for (const peer of ng.peers.values())\n if (peer.pose && peer.dim === g.dimension && !peer.remote) {\n peer.remote = new RemotePlayer(g.world, peer.slot, peer.userId, ng);\n peer.remote.applyPose(peer.pose);\n }\n g.entities.others = () => ng.remoteActors();\n}\n\nexport function becomeReplica(ng: NetGame): void {\n const g = ng.game;\n sim.authority = false;\n g.entities.replica = true;\n g.entities.localSlot = ng.slot;\n ng.driving = null;\n ng.locks.clear();\n g.entities._nextId = Math.max(g.entities._nextId, 1_000_000_000);\n g.entities.clear();\n g.entities.others = () => [];\n g.entities.onLocalSpawn = (snap) =>\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action: { a: \"spawn\", snap },\n } satisfies EntActionMsg),\n );\n g.entities.onRemoteAction = (action) =>\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action,\n } satisfies EntActionMsg),\n );\n for (const peer of ng.peers.values()) peer.remote = null;\n}\n\nexport function beforeDimension(ng: NetGame, target: DimensionId): void {\n const g = ng.game;\n const iOwnTarget = ng.isHost && !ng.owners.get(target);\n const frozen = ng.frozen.get(target);\n g.dims.set(target, {\n chunks: ng.diffs.toChunkDiffMap(target),\n entities: iOwnTarget ? (frozen?.entities ?? []) : [],\n blockEntities: iOwnTarget\n ? (frozen?.blockEntities ?? [])\n : [...(ng.beStore.get(target)?.values() ?? [])].filter(Boolean),\n });\n if (iOwnTarget) ng.frozen.delete(target);\n ng.pendingOwnTarget = iOwnTarget;\n}\n\nexport function prepareDimension(ng: NetGame): void {\n if (ng.pendingOwnTarget) {\n sim.authority = true;\n ng.game.entities.replica = false;\n } else becomeReplica(ng);\n}\n\nexport function afterDimension(\n ng: NetGame,\n prev: DimensionId,\n target: DimensionId,\n): void {\n const g = ng.game;\n const wasOwner = ng.owners.get(prev) === ng.selfId;\n const handoff = wasOwner ? g.dims.get(prev) : undefined;\n for (const peer of ng.peers.values()) ng.dropPeerView(peer);\n if (ng.isHost) {\n const r = ng.records.get(ng.selfId)!;\n r.dim = target;\n if (handoff)\n ng.frozen.set(prev, {\n chunks: {},\n entities: handoff.entities ?? [],\n blockEntities: handoff.blockEntities ?? [],\n });\n if (wasOwner) ng.owners.delete(prev);\n assignOwner(ng, prev);\n if (!ng.owners.get(target)) {\n ng.owners.set(target, ng.selfId);\n becomeOwner(ng, null); // сущности уже подняты changeDimension из g.dims\n } else enterDim(ng, ng.selfId, target);\n ng.broadcastRoster();\n return;\n }\n // Синхронно и без сжатия: следом пойдут правки нового измерения (портал на той стороне), и по\n // упорядоченному каналу хост обязан узнать о входе РАНЬШЕ — иначе правки ничейного измерения\n // он бы выбросил.\n const msg: DimEnter = {\n dim: target,\n ...(handoff ? { handoff: { dim: prev, save: handoff } } : {}),\n };\n ng.toHost(\"rel\", encodeJsonSync(M.DIM_ENTER, msg));\n // сейв игрока у хоста — сразу, а не через 10 с: уйди хост раньше, гость вернулся бы в прежнее\n // измерение на прежнее место\n ng.sendPlayerState();\n}\n\nexport function onDimEnter(ng: NetGame, from: string, m: DimEnter): void {\n const r = ng.records.get(from);\n if (!r) return;\n const prev = r.dim;\n r.dim = m.dim;\n if (m.handoff && ng.owners.get(m.handoff.dim) === from) {\n ng.frozen.set(m.handoff.dim, {\n chunks: {},\n entities: m.handoff.save.entities ?? [],\n blockEntities: m.handoff.save.blockEntities ?? [],\n });\n for (const b of m.handoff.save.blockEntities ?? []) {\n const be = b as BeMsg[\"be\"];\n if (be) ng.storeBe({ dim: m.handoff.dim, x: be.x, y: be.y, z: be.z, be });\n }\n ng.owners.delete(m.handoff.dim);\n }\n assignOwner(ng, prev);\n enterDim(ng, from, m.dim);\n ng.broadcastRoster();\n}\n\nexport function enterDim(ng: NetGame, who: string, dim: DimensionId): void {\n const owner = ng.owners.get(dim);\n if (!owner) {\n ng.owners.set(dim, who);\n const save = ng.frozen.get(dim) ?? EMPTY_DIM();\n ng.frozen.delete(dim);\n if (who === ng.selfId) becomeOwner(ng, save);\n // синхронно: по упорядоченному каналу владение обязано прийти раньше пересланных следом правок\n else\n void ng.send(\n who,\n \"rel\",\n encodeJsonSync(M.DIM_OWN, { dim, save } satisfies DimOwn),\n );\n return;\n }\n if (owner === who) return;\n void ng.send(owner, \"rel\", encodeJsonSync(M.DIM_RESYNC, { dim }));\n const list = [...(ng.beStore.get(dim) ?? new Map())].map(([k, be]) => {\n const [x, y, z] = k.split(\",\").map(Number) as [number, number, number];\n return { dim, x, y, z, be } satisfies BeMsg;\n });\n // синхронно: следом могут пойти свежие изменения тех же сундуков, старый список не должен их затереть\n if (who !== ng.selfId && list.length)\n void ng.send(who, \"rel\", encodeJsonSync(M.BE_LIST, { dim, list }));\n}\n\nexport function assignOwner(ng: NetGame, dim: DimensionId): void {\n const owner = ng.owners.get(dim);\n if (owner && ng.records.get(owner)?.dim === dim) return;\n ng.owners.delete(dim);\n const next = [...ng.records.entries()]\n .filter(([, r]) => r.dim === dim)\n .sort((a, b) => a[1].slot - b[1].slot)[0];\n if (next) enterDim(ng, next[0], dim);\n}\n"
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
"path": "net/game/handlers.ts",
|
|
632
|
+
"content": "import * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { rules } from \"../../systems/Difficulty\";\nimport type { NetEntityAction } from \"../../entities/EntityManager\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n DIMS,\n M,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type PlayerMeta,\n type PortalMsg,\n type Pose,\n type Roster,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"../protocol\";\nimport type { Peer } from \"./types\";\nimport { HELLO_RETRY_MS } from \"./shared\";\nimport type { NetGame } from \"../NetGame\";\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nexport async function onMessage(\n ng: NetGame,\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): Promise<void> {\n if (ng.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!ng.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") ng.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return onFast(ng, from, type, data);\n switch (type) {\n case M.HELLO:\n if (ng.isHost) await onHello(ng, from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && ng.welcomeWait) ng.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n ng.welcomeWait?.(r?.reason ?? \"Хост отказал во входе\");\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !ng.isHost) ng.applyRoster(r);\n return;\n }\n case M.PMETA:\n return onMeta(ng, from, data);\n case M.BLOCKS:\n case M.EDITS:\n return onBlocks(ng, from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return onEntities(ng, from, type, data);\n case M.ENT_ACTION:\n return onEntAction(ng, from, data);\n case M.BE:\n return onBe(ng, from, data);\n case M.LOCK:\n return ng.onLock(from, data);\n case M.REPLY:\n return ng.onReply(from, data);\n case M.PORTAL:\n return onPortal(ng, from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === ng.game.dimension && !sim.authority)\n for (const b of m.list) applyBe(ng, b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !ng.isHost) {\n ng.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n ng.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return onChat(ng, from, data);\n case M.HIT:\n case M.GIVE:\n return onToPlayer(ng, from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = ng.records.get(from);\n if (s && r && ng.isHost) {\n r.state = s.state;\n ng.game.mergeBeyond((s.state as { beyond?: unknown } | null)?.beyond);\n }\n return;\n }\n case M.DIM_ENTER:\n if (ng.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) ng.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === ng.game.dimension) ng.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) ng.bc.reset();\n return;\n case M.SHADOW:\n ng.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && ng.isHost && ng.mayCheat(from)) ng.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n}\n\nexport function onFast(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n if (type === M.POSE) {\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n ng.toAll(\"fast\", stamped, from);\n applyPose(ng, pose);\n return;\n }\n if (!ng.isHost) applyPose(ng, decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"fast\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const p of decodeEntPoses(data).list)\n ng.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n}\n\nexport function sendHello(ng: NetGame): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: ng.name };\n ng.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n}\n\nexport async function onHello(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await ng.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: \"Версии игры отличаются — обновите страницу\",\n }),\n );\n return;\n }\n const g = ng.game;\n let r = ng.records.get(from);\n // Гость повторяет Hello, пока снимок не дошёл (HELLO_RETRY_MS): только что отправленный снимок\n // ещё в пути — второй такой же не нужен.\n if (r?.welcomedAt && Date.now() - r.welcomedAt < HELLO_RETRY_MS * 2) return;\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: ng.nextSlot++,\n name: (h.name || \"Игрок\").slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: ng.ops.has(from),\n state: saved,\n };\n ng.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: ng.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: ng.roster(),\n };\n r.welcomedAt = Date.now();\n await ng.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n ng.broadcastRoster();\n ng.enterDim(from, r.dim);\n}\n\nexport function applyPose(ng: NetGame, p: Pose): void {\n if (p.slot === ng.slot) return;\n const peer = ng.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === ng.game.dimension && p.dim !== peer.dim && sim.authority)\n ng.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === ng.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name);\n ng.game.scene.add(peer.avatar.group);\n }\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(ng.game.world, p.slot, peer.userId, ng);\n peer.remote.world = ng.game.world;\n peer.remote.held = peer.held;\n peer.remote.applyPose(p);\n }\n } else dropPeerView(ng, peer);\n}\n\nexport function dropPeerView(ng: NetGame, peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(ng.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n}\n\nexport async function onMeta(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n ng.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n}\n\nexport function onBlocks(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n const { dim, list } = decodeBlocks(data);\n if (ng.isHost && from !== ng.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (ng.owners.get(dim) !== from) return;\n ng.diffs.applyAll(dim, list);\n ng.toAll(\"rel\", data, from);\n } else {\n const owner = ng.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n ng.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n ng.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (ng.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = ng.owners.get(dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== ng.game.dimension) return;\n applyBlocks(ng, list, true);\n return;\n }\n if (from === ng.selfId) return;\n if (!ng.isHost) ng.diffs.applyAll(dim, list);\n if (dim === ng.game.dimension && !sim.authority) applyBlocks(ng, list, false);\n}\n\nexport function applyBlocks(\n ng: NetGame,\n list: readonly BlockDelta[],\n asOwner: boolean,\n): void {\n const w = ng.game.world;\n ng.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n ng.applyingRemote = false;\n }\n}\n\nexport async function onEntities(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) ng.toDim(m.dim, \"rel\", data, from);\n if (m.dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const [id, snap] of m.list) ng.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"rel\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const id of ids) ng.game.entities.netRemove(id);\n}\n\nexport async function onEntAction(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (ng.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === ng.selfId ? ng.slot : ng.records.get(from)?.slot;\n const owner = ng.owners.get(m.dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== ng.game.dimension) return;\n const em = ng.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker = m.from !== undefined ? ng.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n ng.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n}\n\nexport function entAction(ng: NetGame, action: NetEntityAction): void {\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: ng.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n}\n\nexport async function onPortal(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (ng.isHost) ng.toAll(\"rel\", data, from);\n ng.game.applyNetPortal(m.dim, m.p, !!m.add);\n}\n\nexport async function onBe(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = ng.owners.get(m.dim);\n if (ng.isHost && from !== ng.selfId) {\n if (owner === from) {\n storeBe(ng, m);\n ng.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n } else if (ng.isHost && owner && owner !== ng.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void ng.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== ng.game.dimension || from === ng.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else applyBe(ng, m);\n}\n\nexport function applyBe(ng: NetGame, m: BeMsg): void {\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n ng.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n}\n\nexport function storeBe(ng: NetGame, m: BeMsg): void {\n let s = ng.beStore.get(m.dim);\n if (!s) ng.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n}\n\nexport async function onToPlayer(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (ng.isHost && m.to !== ng.slot) {\n const to = ng.userOfSlot(m.to);\n if (to && to !== from) void ng.send(to, \"rel\", data);\n return;\n }\n if (m.to !== ng.slot) return;\n const g = ng.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n}\n\nexport async function onChat(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.from = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who = m.from !== undefined ? (ng.peers.get(m.from)?.name ?? \"?\") : \"?\";\n ng.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n}\n"
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
"path": "net/game/hostMigration.ts",
|
|
636
|
+
"content": "import { events } from \"../../core/EventBus\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport type { DimensionSave } from \"../../types\";\nimport { sim } from \"../sim\";\nimport { encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n DIMS,\n M,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function pickSuccessorAndShadow(ng: NetGame): void {\n let best: string | null = null,\n bestRtt = Infinity;\n for (const peer of ng.peers.values()) {\n const s = ng.session.linkStats(peer.userId);\n if (!s || !ng.session.isOpen(peer.userId)) continue;\n const rtt = s.rttMs || 999;\n if (rtt < bestRtt) {\n bestRtt = rtt;\n best = peer.userId;\n }\n }\n if (best !== ng.successor) {\n ng.successor = best;\n ng.session.setSuccessor(best);\n ng.broadcastRoster();\n }\n if (!best) return;\n const g = ng.game;\n const dims: Partial<Record<DimensionId, DimensionSave>> = {};\n for (const [d, s] of ng.frozen) dims[d] = s;\n if (ng.owners.get(g.dimension) === ng.selfId)\n dims[g.dimension] = {\n chunks: {},\n entities: g.entities.serialize(),\n blockEntities: g.blockEntities.serialize(),\n };\n const players: Record<string, unknown> = {};\n for (const [uid, r] of ng.records)\n players[uid] = uid === ng.selfId ? g.netPlayerState() : r.state;\n const shadow: Shadow = {\n world: g.netWorldMeta(),\n dims,\n players,\n settings: ng.settings,\n ops: [...ng.ops],\n };\n void encodeJson(M.SHADOW, shadow).then((b) => ng.send(best!, \"rel\", b));\n}\n\nexport function onPeerOpen(ng: NetGame, peerId: string): void {\n if (!ng.isHost && peerId === ng.hostId) {\n if (ng.welcomeWait) ng.sendHello();\n else {\n // новый хост после передачи: представиться заново\n ng.lastMeta = \"\";\n sendPlayerState(ng);\n }\n }\n}\n\nexport function sendPlayerState(ng: NetGame): void {\n if (ng.isHost) return;\n void encodeJson(M.PSTATE, { state: ng.game.netPlayerState() }).then((b) =>\n ng.toHost(\"rel\", b),\n );\n}\n\nexport function onPeerClose(ng: NetGame, peerId: string): void {\n if (!ng.isHost && peerId === ng.hostId) ng.hostSilentSince = Date.now();\n}\n\nexport function onMemberLeft(ng: NetGame, userId: string): void {\n for (const peer of ng.peers.values())\n if (peer.userId === userId && sim.authority)\n ng.game.entities.netReleaseDriver(peer.slot);\n const r = ng.records.get(userId);\n if (ng.isHost && r) {\n ng.records.delete(userId);\n if (ng.owners.get(r.dim) === userId) {\n ng.owners.delete(r.dim);\n ng.assignOwner(r.dim);\n }\n ng.broadcastRoster();\n }\n for (const [slot, peer] of [...ng.peers])\n if (peer.userId === userId) {\n ng.dropPeerView(peer);\n ng.peers.delete(slot);\n }\n events.emit(\"net:roster\");\n}\n\nexport function watchHost(ng: NetGame): void {\n if (!ng.hostSilentSince || ng.successor !== ng.selfId) return;\n if (ng.session.isOpen(ng.hostId)) {\n ng.hostSilentSince = 0;\n return;\n }\n if (Date.now() - ng.hostSilentSince < 5000 || ng.tickN % 40 !== 0) return;\n void ng.session.claimHost();\n}\n\nexport function onHostChanged(\n ng: NetGame,\n hostId: string,\n previousHostId: string | null,\n): void {\n ng.hostSilentSince = 0;\n ng.hostIdBefore = previousHostId ?? \"\";\n if (hostId !== ng.selfId) {\n // у нового хоста наш сейв из теневой копии (до 10 с давности); по запасной связи, уже открытой,\n // onPeerOpen не сработает — шлём сразу\n if (ng.session.isOpen(hostId)) sendPlayerState(ng);\n return;\n }\n promote(ng);\n}\n\nexport function promote(ng: NetGame): void {\n const g = ng.game;\n const sh = ng.shadow;\n ng.records.clear();\n ng.records.set(ng.selfId, {\n slot: ng.slot,\n name: ng.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n for (const peer of ng.peers.values())\n if (ng.session.members.has(peer.userId))\n ng.records.set(peer.userId, {\n slot: peer.slot,\n name: peer.name,\n dim: peer.dim,\n op: peer.op,\n state: sh?.players[peer.userId] ?? null,\n });\n ng.nextSlot = Math.max(ng.slot, ...[...ng.peers.keys()]) + 1;\n if (sh) {\n ng.settings = sh.settings;\n for (const o of sh.ops) ng.ops.add(o);\n for (const [d, s] of Object.entries(sh.dims) as [\n DimensionId,\n DimensionSave,\n ][])\n if (!ng.owners.has(d) || ng.owners.get(d) === ng.hostIdBefore)\n ng.frozen.set(d, s);\n g.applyNetWorldState({\n ...sh.world,\n difficulty: sh.world.difficulty,\n daysAwake: sh.world.daysAwake,\n } as WorldState);\n }\n ng.ops.add(ng.selfId);\n // владения прежнего хоста: его измерение — следующему там (может, мне), остальное — как было\n for (const [d, owner] of [...ng.owners])\n if (!ng.records.has(owner)) {\n ng.owners.delete(d);\n ng.assignOwner(d);\n }\n if (!ng.owners.get(g.dimension)) {\n ng.owners.set(g.dimension, ng.selfId);\n ng.becomeOwner(ng.frozen.get(g.dimension) ?? null);\n ng.frozen.delete(g.dimension);\n }\n g.adoptNetWorldAsOwn();\n ng.broadcastRoster();\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n g.hud.message(\"Хост ушёл — теперь мир у вас\", 3000);\n}\n\nexport function applyWelcome(ng: NetGame, w: Welcome): void {\n ng.slot = w.you;\n ng.applyRoster(w.roster);\n ng.hostIdBefore = w.roster.hostId;\n for (const d of DIMS) ng.diffs.load(d, w.diffs[d]);\n ng.becomeReplica();\n ng.game.loadNetWorld(w, ng.diffs, ng.settings);\n // правки, накопленные, пока шёл вход, — из СВОЕГО одиночного мира: хосту их слать нельзя\n ng.pending = [];\n ng.welcomed = true;\n // отложенное — по порядку прихода; правки из снимка применятся повторно, это безвредно\n const stash = ng.stash.splice(0);\n for (const [from, data] of stash)\n ng.inbox = ng.inbox.then(() => ng.onMessage(from, \"rel\", data));\n}\n"
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
"path": "net/game/locks.ts",
|
|
640
|
+
"content": "import { sim } from \"../sim\";\nimport { decodeJson, encodeJsonSync } from \"../codec\";\nimport { M, type LockMsg, type ReplyMsg } from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function openContainer(\n ng: NetGame,\n key: string,\n open: () => void,\n): void {\n const g = ng.game;\n if (sim.authority) {\n const by = lockBy(ng, key, ng.slot);\n if (by) return busyHint(ng, by);\n ng.locks.set(key, ng.slot);\n open();\n return;\n }\n if (ng.lockWait.has(key)) return;\n const timer = setTimeout(() => {\n if (ng.lockWait.delete(key))\n g.hud.message(\"Нет ответа от игрока, у которого это измерение\", 2200);\n }, 4000);\n ng.lockWait.set(key, { open, dim: g.dimension, timer });\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: g.dimension,\n key,\n open: true,\n } satisfies LockMsg),\n );\n}\n\nexport function releaseContainer(ng: NetGame, key: string): void {\n if (sim.authority) {\n if (ng.locks.get(key) === ng.slot) ng.locks.delete(key);\n return;\n }\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: ng.game.dimension,\n key,\n open: false,\n } satisfies LockMsg),\n );\n}\n\nexport function busyHint(ng: NetGame, by: string): void {\n ng.game.hud.message(`Сейчас открыто у игрока ${by}`, 2000);\n}\n\nexport function lockBy(ng: NetGame, key: string, slot: number): string | null {\n const cur = ng.locks.get(key);\n if (cur === undefined || cur === slot) return null;\n if (cur === ng.slot) return ng.name;\n const peer = ng.peers.get(cur);\n if (peer && peer.dim === ng.game.dimension) return peer.name;\n ng.locks.delete(key); // держатель ушёл (из измерения или из игры)\n return null;\n}\n\nexport async function onLock(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<LockMsg>(data);\n if (!m || typeof m.key !== \"string\") return;\n if (ng.isHost) {\n m.from = from === ng.selfId ? ng.slot : ng.records.get(from)?.slot;\n const owner = ng.owners.get(m.dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", encodeJsonSync(M.LOCK, m));\n return;\n }\n }\n const slot = m.from;\n if (!sim.authority || m.dim !== ng.game.dimension || slot === undefined)\n return;\n if (!m.open) {\n if (ng.locks.get(m.key) === slot) ng.locks.delete(m.key);\n return;\n }\n const by = lockBy(ng, m.key, slot);\n const reply: ReplyMsg = {\n to: slot,\n kind: \"lock\",\n key: m.key,\n ok: !by,\n ...(by ? { by } : {}),\n };\n if (!by) {\n ng.locks.set(m.key, slot);\n // блочная сущность — отсюда: сундук постройки получает добычу здесь, у реплики катилась бы своя\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b) {\n const be = ng.game.blockEntities.ensure(+b[1]!, +b[2]!, +b[3]!);\n reply.be = be\n ? {\n type: be.type,\n x: be.x,\n y: be.y,\n z: be.z,\n state: JSON.parse(JSON.stringify(be.state)) as typeof be.state,\n }\n : null;\n }\n }\n replyTo(ng, reply);\n}\n\nexport function replyTo(ng: NetGame, r: ReplyMsg): void {\n if (r.to === ng.slot) {\n handleReply(ng, r);\n return;\n }\n const msg = encodeJsonSync(M.REPLY, r);\n if (ng.isHost) {\n const to = ng.userOfSlot(r.to);\n if (to) void ng.send(to, \"rel\", msg);\n } else ng.toHost(\"rel\", msg);\n}\n\nexport async function onReply(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<ReplyMsg>(data);\n if (!m) return;\n if (ng.isHost && m.to !== ng.slot) {\n const to = ng.userOfSlot(m.to);\n if (to && to !== from) void ng.send(to, \"rel\", data);\n return;\n }\n if (m.to === ng.slot) handleReply(ng, m);\n}\n\nexport function handleReply(ng: NetGame, m: ReplyMsg): void {\n const g = ng.game;\n if (m.kind === \"ride\") {\n const r = g.player.riding as unknown as { id?: number } | null;\n if (!m.ok && r && r.id === m.id) {\n g.player.dismount();\n g.hud.message(\"Место уже занято\", 1600);\n }\n return;\n }\n const w = m.key ? ng.lockWait.get(m.key) : undefined;\n if (!w || !m.key) return;\n clearTimeout(w.timer);\n ng.lockWait.delete(m.key);\n if (!m.ok) return busyHint(ng, m.by ?? \"?\");\n if (w.dim !== g.dimension) {\n releaseContainer(ng, m.key);\n return;\n }\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b && m.be !== undefined)\n ng.applyBe({\n dim: g.dimension,\n x: +b[1]!,\n y: +b[2]!,\n z: +b[3]!,\n be: m.be,\n });\n w.open();\n}\n"
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
"path": "net/game/roster.ts",
|
|
644
|
+
"content": "import { events } from \"../../core/EventBus\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { encodeJsonSync } from \"../codec\";\nimport { M, type Roster } from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function roster(ng: NetGame): Roster {\n return {\n hostId: ng.selfId,\n players: [...ng.records.entries()].map(([userId, r]) => ({\n slot: r.slot,\n userId,\n name: r.name,\n dim: r.dim,\n op: ng.ops.has(userId),\n })),\n owners: Object.fromEntries(ng.owners),\n settings: ng.settings,\n ...(ng.successor ? { successor: ng.successor } : {}),\n } as Roster;\n}\n\nexport function broadcastRoster(ng: NetGame): void {\n if (!ng.isHost) return;\n const r = roster(ng);\n applyRoster(ng, r);\n ng.toAll(\"rel\", encodeJsonSync(M.ROSTER, r));\n}\n\nexport function applyRoster(ng: NetGame, r: Roster): void {\n ng.settings = r.settings;\n ng.ops.clear();\n const seen = new Set<number>();\n for (const p of r.players) {\n if (p.op) ng.ops.add(p.userId);\n if (p.userId === ng.selfId) {\n ng.slot = p.slot;\n continue;\n }\n seen.add(p.slot);\n const cur = ng.peers.get(p.slot);\n if (cur) {\n cur.name = p.name;\n cur.op = p.op;\n cur.dim = p.dim;\n } else\n ng.peers.set(p.slot, {\n slot: p.slot,\n userId: p.userId,\n name: p.name,\n dim: p.dim,\n op: p.op,\n pose: null,\n held: null,\n mode: \"survival\",\n sleeping: false,\n avatar: null,\n remote: null,\n });\n }\n for (const [slot, peer] of [...ng.peers])\n if (!seen.has(slot)) {\n ng.dropPeerView(peer);\n ng.peers.delete(slot);\n }\n // владелец моего измерения сменился — старые образы сущностей больше не обновятся\n const ownerHere = r.owners[ng.game.dimension];\n const prevOwner = ng.owners.get(ng.game.dimension);\n ng.owners.clear();\n for (const [d, u] of Object.entries(r.owners))\n if (u) ng.owners.set(d as DimensionId, u);\n if (\n !ng.isHost &&\n ownerHere !== prevOwner &&\n ownerHere !== ng.selfId &&\n !sim.authority\n )\n ng.game.entities.clear();\n const succ = (r as Roster & { successor?: string }).successor ?? null;\n if (succ !== ng.successor) {\n ng.successor = succ;\n // запасная связь с преемником: передача хоста не ждёт сигналинга\n if (succ === ng.selfId)\n ng.session.setExtraPeers(r.players.map((p) => p.userId));\n else ng.session.setExtraPeers(succ ? [succ] : []);\n }\n events.emit(\"net:roster\");\n}\n\nexport function playerList(ng: NetGame): {\n name: string;\n slot: number;\n userId: string;\n host: boolean;\n op: boolean;\n dim: DimensionId;\n rtt: number;\n}[] {\n const out = [\n {\n name: ng.name,\n slot: ng.slot,\n userId: ng.selfId,\n host: ng.isHost,\n op: ng.ops.has(ng.selfId),\n dim: ng.game.dimension,\n rtt: 0,\n },\n ];\n for (const p of ng.peers.values())\n out.push({\n name: p.name,\n slot: p.slot,\n userId: p.userId,\n host: p.userId === ng.hostId,\n op: p.op,\n dim: p.dim,\n rtt: ng.session.linkStats(p.userId)?.rttMs ?? 0,\n });\n return out.sort((a, b) => a.slot - b.slot);\n}\n"
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
"path": "net/game/shared.ts",
|
|
648
|
+
"content": "import type { RoomSettings } from \"../protocol\";\n\n/** Настройки комнаты, как их объявляет модуль (`registerGame.settings`): режим гостей и команды. */\nexport function roomSettingsOf(\n settings: Readonly<Record<string, string | boolean>>,\n): RoomSettings {\n return {\n guestMode: settings.guestMode === \"creative\" ? \"creative\" : \"survival\",\n cheatsForAll: settings.cheats === true,\n };\n}\n\n/**\n * Гость повторяет Hello, пока не придёт снимок мира. Хост берёт комнату только после загрузки своего\n * мира, а гость получает «старт» сразу — его первый Hello приходил хосту, у которого сетевой игры\n * ещё не было, и терялся: через 30 с «Хост не ответил» (тот самый «иногда не подключается»).\n */\nexport const HELLO_RETRY_MS = 2500;\n"
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
"path": "net/game/tick.ts",
|
|
652
|
+
"content": "import type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { encodeJsonSync } from \"../codec\";\nimport {\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n type BeMsg,\n type EntSpawnMsg,\n type PlayerMeta,\n} from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function afterTick(ng: NetGame): void {\n if (ng.closed) return;\n // Сессию закрыли в обход событий (страховка к \"left\"): дальше играть не на чем.\n if (ng.session.closed) return ng.end(null);\n const g = ng.game;\n ng.tickN++;\n const dim = g.dimension;\n\n if (ng.pending.length) {\n const list = ng.pending;\n ng.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (ng.isHost && type === M.BLOCKS) ng.toAll(\"rel\", msg);\n else ng.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) ng.entAction(a);\n syncRide(ng);\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of ng.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) ng.hit(peer.slot, 0, \"knock\", imp);\n }\n if (ng.tickN % 2 === 0 && hasOthersIn(ng, dim)) {\n const d = ng.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n ownerSend(\n ng,\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) ownerSend(ng, \"fast\", b);\n if (d.removes.length)\n ownerSend(ng, \"rel\", encodeEntRemove(dim, d.removes));\n }\n if (ng.tickN % 10 === 0)\n for (const { be, key } of ng.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (ng.isHost) ng.storeBe(msg);\n if (hasOthersIn(ng, dim))\n ownerSend(ng, \"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (ng.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of ng.replicaBe.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [number, number, number];\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n sendPose(ng);\n sendMetaIfChanged(ng);\n if (ng.tickN % 200 === 0 && !ng.isHost) ng.sendPlayerState();\n\n if (ng.isHost) {\n if (ng.tickN % 100 === 0)\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (ng.tickN % 200 === 0) ng.pickSuccessorAndShadow();\n } else ng.watchHost();\n}\n\nexport function syncRide(ng: NetGame): void {\n const g = ng.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== ng.driving) {\n if (ng.driving !== null)\n ng.entAction({ a: \"ride\", id: ng.driving, on: false });\n if (id !== null) ng.entAction({ a: \"ride\", id, on: true });\n ng.driving = id;\n em.drivenId = id;\n }\n if (id === null || ng.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n ng.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n}\n\nexport function ownerSend(\n ng: NetGame,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): void {\n if (ng.isHost) ng.toDim(ng.game.dimension, channel, data, ng.selfId);\n else ng.toHost(channel, data);\n}\n\nexport function hasOthersIn(ng: NetGame, dim: DimensionId): boolean {\n for (const p of ng.peers.values()) if (p.dim === dim) return true;\n return false;\n}\n\nexport function sendPose(ng: NetGame): void {\n const g = ng.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0);\n const msg = encodePose({\n slot: ng.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (ng.isHost) ng.toAll(\"fast\", msg);\n else ng.toHost(\"fast\", msg);\n}\n\nexport function sendMetaIfChanged(ng: NetGame): void {\n const g = ng.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: ng.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: ng.name,\n sleeping: g.netSleeping,\n };\n const j = JSON.stringify(meta);\n if (j === ng.lastMeta) return;\n ng.lastMeta = j;\n if (ng.isHost) {\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n hostSleepCheck(ng, ng.selfId, meta);\n } else ng.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n}\n\nexport function hostSleepCheck(\n ng: NetGame,\n _who: string,\n _m: PlayerMeta,\n): void {\n const g = ng.game;\n const inOverworld = [...ng.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === ng.selfId\n ? g.netSleeping\n : ([...ng.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n}\n"
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
"path": "net/game/types.ts",
|
|
656
|
+
"content": "import type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport type { Pose } from \"../protocol\";\n\n/**\n * Комната, которую отдаёт лобби (`MultiplayerRoom` контракта модулей — здесь только нужные поля, чтобы\n * сетевой код игры не зависел от контракта).\n */\nexport interface NetRoom {\n readonly session: Session;\n readonly isHost: boolean;\n readonly lateJoin: boolean;\n readonly settings: Readonly<Record<string, string | boolean>>;\n leave(): Promise<void>;\n}\n\n/** Всё, что знаем о другом игроке. */\nexport interface Peer {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n pose: Pose | null;\n held: string | null;\n mode: \"survival\" | \"creative\";\n sleeping: boolean;\n avatar: Avatar | null;\n remote: RemotePlayer | null;\n}\n\n/** Хост: сейв игрока (его инвентарь авторитетен у него — здесь копия для мира хоста). */\nexport interface HostRecord {\n slot: number;\n name: string;\n dim: DimensionId;\n op: boolean;\n state: unknown | null;\n /** Когда хост последний раз отправил этому игроку снимок мира (повторный Hello — см. onHello). */\n welcomedAt?: number;\n}\n"
|
|
657
|
+
},
|
|
514
658
|
{
|
|
515
659
|
"path": "net/NetGame.ts",
|
|
516
|
-
"content": "// Сетевая игра VoxelCraft: связывает игру (Game) с `RealtimeSession` (@idosgames/core/realtime).\n// Комнату создаёт и в неё входит ЛОББИ (система мультиплеера, ctx.multiplayer) — сюда приходит уже\n// открытая сессия (`NetGame.fromRoom`); своего списка комнат, создания и входа по коду у игры нет.\n//\n// Топология — звезда (комнаты `Host`): каждый гость связан только с хостом, хост пересылает. Роли и\n// протокол — net/protocol.ts. Кто считает мир — net/sim.ts: у каждого «живого» измерения один\n// владелец (первый вошедший; хост не главнее других), остальные в нём — реплики.\n//\n// Игрок и инвентарь — у каждого свои (вариант А, 27.09.2026): здесь по сети ходят только мир,\n// сущности, блочные сущности и то, что мир делает с игроком (урон, отброс, подобранное).\nimport * as THREE from \"three\";\nimport type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport { events } from \"../core/EventBus\";\nimport { rules } from \"../systems/Difficulty\";\nimport type { NetEntityAction } from \"../entities/EntityManager\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\nimport { sim } from \"./sim\";\nimport { DiffStore } from \"./DiffStore\";\nimport { OwnerBroadcast } from \"./OwnerBroadcast\";\nimport {\n Avatar,\n RemotePlayer,\n remoteInventory,\n type RemoteSink,\n} from \"./RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"./codec\";\nimport {\n DIMS,\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type LockMsg,\n type PlayerMeta,\n type PortalMsg,\n type ReplyMsg,\n type Pose,\n type Roster,\n type RoomSettings,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"./protocol\";\n\n/**\n * Комната, которую отдаёт лобби (`MultiplayerRoom` контракта модулей — здесь только нужные поля, чтобы\n * сетевой код игры не зависел от контракта).\n */\nexport interface NetRoom {\n readonly session: Session;\n readonly isHost: boolean;\n readonly lateJoin: boolean;\n readonly settings: Readonly<Record<string, string | boolean>>;\n leave(): Promise<void>;\n}\n\n/** Настройки комнаты, как их объявляет модуль (`registerGame.settings`): режим гостей и команды. */\nexport function roomSettingsOf(\n settings: Readonly<Record<string, string | boolean>>,\n): RoomSettings {\n return {\n guestMode: settings.guestMode === \"creative\" ? \"creative\" : \"survival\",\n cheatsForAll: settings.cheats === true,\n };\n}\n\n/** Всё, что знаем о другом игроке. */\ninterface Peer {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n pose: Pose | null;\n held: string | null;\n mode: \"survival\" | \"creative\";\n sleeping: boolean;\n avatar: Avatar | null;\n remote: RemotePlayer | null;\n}\n\n/** Хост: сейв игрока (его инвентарь авторитетен у него — здесь копия для мира хоста). */\ninterface HostRecord {\n slot: number;\n name: string;\n dim: DimensionId;\n op: boolean;\n state: unknown | null;\n /** Когда хост последний раз отправил этому игроку снимок мира (повторный Hello — см. onHello). */\n welcomedAt?: number;\n}\n\n/**\n * Гость повторяет Hello, пока не придёт снимок мира. Хост берёт комнату только после загрузки своего\n * мира, а гость получает «старт» сразу — его первый Hello приходил хосту, у которого сетевой игры\n * ещё не было, и терялся: через 30 с «Хост не ответил» (тот самый «иногда не подключается»).\n */\nconst HELLO_RETRY_MS = 2500;\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nconst EMPTY_DIM = (): DimensionSave => ({\n chunks: {},\n entities: [],\n blockEntities: [],\n});\n\nexport class NetGame implements RemoteSink {\n readonly session: Session;\n readonly selfId: string;\n slot = 0;\n settings: RoomSettings;\n /** Операторы (хост — всегда). */\n readonly ops = new Set<string>();\n readonly diffs = new DiffStore();\n /** Другие игроки по слоту. */\n readonly peers = new Map<number, Peer>();\n /** Владелец каждого живого измерения (userId). */\n readonly owners = new Map<DimensionId, string>();\n /** Хост ушёл — кто подхватит (объявляет хост в списке игроков). */\n successor: string | null = null;\n\n // ---- только у хоста\n private readonly records = new Map<string, HostRecord>();\n private nextSlot = 1;\n /** Измерения без владельца: сущности и блочные сущности (дифы — в `diffs`). */\n private readonly frozen = new Map<DimensionId, DimensionSave>();\n /** Блочные сущности всех измерений (для вошедшего в измерение гостя). */\n private readonly beStore = new Map<DimensionId, Map<string, BeMsg[\"be\"]>>();\n\n // ---- владелец/реплика своего измерения\n private readonly bc = new OwnerBroadcast();\n /** Реплика: что последнее прислал владелец по блочным сущностям — своё отличие уходит ему. */\n private readonly replicaBe = new OwnerBroadcast();\n private pending: BlockDelta[] = [];\n private applyingRemote = false;\n private tickN = 0;\n private lastMeta = \"\";\n private shadow: Shadow | null = null;\n private hostSilentSince = 0;\n private readonly offs: (() => void)[] = [];\n private closed = false;\n /** Гость: ждём снимок мира. */\n private welcomeWait: ((w: Welcome | string) => void) | null = null;\n /** Очередь обработки надёжного канала (см. конструктор). */\n private inbox: Promise<void> = Promise.resolve();\n /** Гость получил снимок мира (хост — всегда). До него сообщения копятся в `stash`. */\n private welcomed = true;\n private readonly stash: [string, Uint8Array][] = [];\n /** Владелец измерения: занятые контейнеры — ключ (LockMsg.key) → слот игрока. */\n private readonly locks = new Map<string, number>();\n /** Свои запросы контейнера, ждущие ответа владельца. */\n private readonly lockWait = new Map<\n string,\n { open: () => void; dim: DimensionId; timer: ReturnType<typeof setTimeout> }\n >();\n /** Реплика: id сущности, в которой сидит свой игрок (её ведёт он). */\n private driving: number | null = null;\n\n private constructor(\n readonly game: Game,\n session: Session,\n selfId: string,\n settings: RoomSettings,\n readonly name: string,\n /** Уйти из комнаты — через лобби: оно владеет комнатой и забывает её вместе с сессией. */\n readonly leaveRoom: () => Promise<void>,\n ) {\n this.session = session;\n this.selfId = selfId;\n this.settings = settings;\n this.offs.push(\n session.on(\"message\", ({ from, channel, data }) => {\n // Надёжный канал — строго по порядку: сжатое сообщение распаковывается асинхронно, и без\n // очереди следующее («удалить сущность») обогнало бы предыдущее («создать»). Быстрый канал\n // (позы) порядка не требует и ждать не должен.\n if (channel === \"fast\") void this.onMessage(from, channel, data);\n else\n this.inbox = this.inbox.then(() =>\n this.onMessage(from, channel, data),\n );\n }),\n session.on(\"peerOpen\", ({ peerId }) => this.onPeerOpen(peerId)),\n session.on(\"peerClose\", ({ peerId }) => this.onPeerClose(peerId)),\n session.on(\"memberLeft\", ({ userId }) => this.onMemberLeft(userId)),\n session.on(\"hostChanged\", ({ hostId, previousHostId }) =>\n this.onHostChanged(hostId, previousHostId),\n ),\n session.on(\"chat\", ({ text }) => game.chat.print(text)),\n // исключили: сервер отвечает «не участник» (после уведомления хоста) или прислал PlayerLeft kicked\n session.on(\"removed\", ({ reason }) =>\n this.end(\n /kicked|not a member|removed/i.test(reason)\n ? \"Вас исключили из игры\"\n : \"Связь с игрой потеряна\",\n ),\n ),\n session.on(\"roomClosed\", () => this.end(\"Игра закрыта\")),\n // Вышли из комнаты не отсюда (кнопка «Выйти» в комнате лобби): сессия закрыта — сетевая игра\n // окончена. Без этого игра оставалась на мёртвой сессии и молча игнорировала следующую комнату.\n session.on(\"left\", () => this.end(null)),\n );\n // Реестр порталов общий: зажёг портал любой — знают все (выход на той стороне ищется по нему).\n game.onPortalChange = (dim, pos, add) => {\n const msg = encodeJsonSync(M.PORTAL, {\n dim,\n p: pos,\n add,\n } satisfies PortalMsg);\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n };\n // Правки мира: владелец рассылает все (свои, каскады, пришедшие от реплик), реплика — только свои.\n this.offs.push(\n events.on(\n \"blockChanged\",\n (d: { x: number; y: number; z: number; id: number; meta: number }) => {\n if (this.applyingRemote && !sim.authority) return;\n this.diffs.apply(this.game.dimension, d.x, d.y, d.z, d.id, d.meta);\n this.pending.push({ x: d.x, y: d.y, z: d.z, id: d.id, meta: d.meta });\n },\n ),\n );\n }\n\n get isHost(): boolean {\n return this.session.hostId === this.selfId;\n }\n\n get hostId(): string {\n return this.session.hostId;\n }\n\n /** Может ли этот игрок командами менять мир (хост и операторы, или «команды всем»). */\n get canCheat(): boolean {\n return (\n this.isHost || this.ops.has(this.selfId) || this.settings.cheatsForAll\n );\n }\n\n // =========================================================================== старт\n\n /**\n * Комната пришла из лобби (система мультиплеера, `ctx.multiplayer`): её создали и в неё вошли там,\n * соединения уже открыты — здесь только начинается игра поверх готовой сессии. Хост, запустивший\n * игру, открывает свой мир для остальных; гость (и опоздавший) получает мир хоста.\n */\n static async fromRoom(\n game: Game,\n room: NetRoom,\n fallbackName: string,\n ): Promise<NetGame | string> {\n const session = room.session;\n // имя — публичный профиль игрока в этом тайтле (его кладёт в снимок комнаты сервер)\n const name = session.members.get(session.selfId)?.Username || fallbackName;\n if (room.isHost && !room.lateJoin) {\n const net = new NetGame(\n game,\n session,\n session.selfId,\n roomSettingsOf(room.settings),\n name,\n room.leave,\n );\n net.ops.add(net.selfId);\n net.startAsHost();\n return net;\n }\n const net = new NetGame(\n game,\n session,\n session.selfId,\n { guestMode: \"survival\", cheatsForAll: false },\n name,\n room.leave,\n );\n net.welcomed = false;\n const w = await new Promise<Welcome | string>((resolve) => {\n const retry = setInterval(() => {\n if (session.isOpen(session.hostId)) net.sendHello();\n }, HELLO_RETRY_MS);\n const deadline = setTimeout(() => done(\"Хост не ответил\"), 30_000);\n function done(r: Welcome | string): void {\n clearInterval(retry);\n clearTimeout(deadline);\n resolve(r);\n }\n net.welcomeWait = done;\n if (session.isOpen(session.hostId)) net.sendHello();\n });\n net.welcomeWait = null;\n if (typeof w === \"string\") {\n net.dispose();\n await room.leave();\n return w;\n }\n net.applyWelcome(w);\n return net;\n }\n\n private startAsHost(): void {\n const g = this.game;\n this.slot = 0;\n this.records.set(this.selfId, {\n slot: 0,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n // Мир хоста целиком в хранилище дифов: живое измерение — с мира, остальные — из сейва измерений.\n this.diffs.load(g.dimension, g.world.serializeDiffs());\n for (const [d, save] of g.dims) {\n this.diffs.load(d, save.chunks);\n this.frozen.set(d, {\n chunks: {},\n entities: save.entities ?? [],\n blockEntities: save.blockEntities ?? [],\n });\n }\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(null);\n }\n\n // =========================================================================== отправка\n\n private async send(\n to: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (to === this.selfId) {\n await this.onMessage(this.selfId, channel, data);\n return;\n }\n this.session.send(to, channel, data);\n }\n\n /** Хосту (у хоста — себе). */\n private toHost(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n void this.send(this.hostId, channel, data);\n }\n\n /** Хост: всем, кроме `except`. */\n private toAll(\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n this.session.broadcast(channel, data, except);\n }\n\n /** Хост: всем игрокам измерения, кроме `except` (себе — тоже, если хост там же). */\n private toDim(\n dim: DimensionId,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n for (const [uid, r] of this.records)\n if (r.dim === dim && uid !== except) void this.send(uid, channel, data);\n }\n\n private userOfSlot(slot: number): string | null {\n if (slot === this.slot) return this.selfId;\n return this.peers.get(slot)?.userId ?? null;\n }\n\n // =========================================================================== приём\n\n private async onMessage(\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (this.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!this.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") this.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return this.onFast(from, type, data);\n switch (type) {\n case M.HELLO:\n if (this.isHost) await this.onHello(from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && this.welcomeWait) this.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n this.welcomeWait?.(r?.reason ?? \"Хост отказал во входе\");\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !this.isHost) this.applyRoster(r);\n return;\n }\n case M.PMETA:\n return this.onMeta(from, data);\n case M.BLOCKS:\n case M.EDITS:\n return this.onBlocks(from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return this.onEntities(from, type, data);\n case M.ENT_ACTION:\n return this.onEntAction(from, data);\n case M.BE:\n return this.onBe(from, data);\n case M.LOCK:\n return this.onLock(from, data);\n case M.REPLY:\n return this.onReply(from, data);\n case M.PORTAL:\n return this.onPortal(from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === this.game.dimension && !sim.authority)\n for (const b of m.list) this.applyBe(b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !this.isHost) {\n this.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n this.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return this.onChat(from, data);\n case M.HIT:\n case M.GIVE:\n return this.onToPlayer(from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = this.records.get(from);\n if (s && r && this.isHost) {\n r.state = s.state;\n this.game.mergeBeyond(\n (s.state as { beyond?: unknown } | null)?.beyond,\n );\n }\n return;\n }\n case M.DIM_ENTER:\n if (this.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) this.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === this.game.dimension) this.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) this.bc.reset();\n return;\n case M.SHADOW:\n this.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && this.isHost && this.mayCheat(from))\n this.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n }\n\n private onFast(from: string, type: number, data: Uint8Array): void {\n if (type === M.POSE) {\n if (this.isHost && from !== this.selfId) {\n const r = this.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n this.toAll(\"fast\", stamped, from);\n this.applyPose(pose);\n return;\n }\n if (!this.isHost) this.applyPose(decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (this.isHost && from !== this.selfId)\n this.toDim(dim, \"fast\", data, from);\n if (dim === this.game.dimension && !sim.authority && from !== this.selfId)\n for (const p of decodeEntPoses(data).list)\n this.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n }\n\n // ---- вход гостя (хост)\n\n private sendHello(): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: this.name };\n this.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n }\n\n private async onHello(from: string, data: Uint8Array): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await this.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: \"Версии игры отличаются — обновите страницу\",\n }),\n );\n return;\n }\n const g = this.game;\n let r = this.records.get(from);\n // Гость повторяет Hello, пока снимок не дошёл (HELLO_RETRY_MS): только что отправленный снимок\n // ещё в пути — второй такой же не нужен.\n if (r?.welcomedAt && Date.now() - r.welcomedAt < HELLO_RETRY_MS * 2) return;\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: this.nextSlot++,\n name: (h.name || \"Игрок\").slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: this.ops.has(from),\n state: saved,\n };\n this.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)\n ?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: this.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: this.roster(),\n };\n r.welcomedAt = Date.now();\n await this.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n this.broadcastRoster();\n this.enterDim(from, r.dim);\n }\n\n // ---- поза и метаданные\n\n private applyPose(p: Pose): void {\n if (p.slot === this.slot) return;\n const peer = this.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === this.game.dimension && p.dim !== peer.dim && sim.authority)\n this.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === this.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name);\n this.game.scene.add(peer.avatar.group);\n }\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(\n this.game.world,\n p.slot,\n peer.userId,\n this,\n );\n peer.remote.world = this.game.world;\n peer.remote.held = peer.held;\n peer.remote.applyPose(p);\n }\n } else this.dropPeerView(peer);\n }\n\n private dropPeerView(peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(this.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n }\n\n private async onMeta(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (this.isHost && from !== this.selfId) {\n const r = this.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n this.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n this.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? this.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n }\n\n // ---- блоки\n\n private onBlocks(from: string, type: number, data: Uint8Array): void {\n const { dim, list } = decodeBlocks(data);\n if (this.isHost && from !== this.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (this.owners.get(dim) !== from) return;\n this.diffs.applyAll(dim, list);\n this.toAll(\"rel\", data, from);\n } else {\n const owner = this.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n this.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n this.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (this.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = this.owners.get(dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== this.game.dimension) return;\n this.applyBlocks(list, true);\n return;\n }\n if (from === this.selfId) return;\n if (!this.isHost) this.diffs.applyAll(dim, list);\n if (dim === this.game.dimension && !sim.authority)\n this.applyBlocks(list, false);\n }\n\n private applyBlocks(list: readonly BlockDelta[], asOwner: boolean): void {\n const w = this.game.world;\n this.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n this.applyingRemote = false;\n }\n }\n\n // ---- сущности\n\n private async onEntities(\n from: string,\n type: number,\n data: Uint8Array,\n ): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (this.isHost && from !== this.selfId)\n this.toDim(m.dim, \"rel\", data, from);\n if (\n m.dim === this.game.dimension &&\n !sim.authority &&\n from !== this.selfId\n )\n for (const [id, snap] of m.list)\n this.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (this.isHost && from !== this.selfId) this.toDim(dim, \"rel\", data, from);\n if (dim === this.game.dimension && !sim.authority && from !== this.selfId)\n for (const id of ids) this.game.entities.netRemove(id);\n }\n\n private async onEntAction(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (this.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === this.selfId ? this.slot : this.records.get(from)?.slot;\n const owner = this.owners.get(m.dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== this.game.dimension) return;\n const em = this.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker =\n m.from !== undefined ? this.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n this.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n }\n\n /** Реплика: действие над сущностью — владельцу измерения (через хоста). */\n private entAction(action: NetEntityAction): void {\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: this.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n }\n\n // ---- контейнеры: один игрок — один открытый контейнер\n\n /**\n * Открыть контейнер (сундук, печь, вьюк, груз вагонетки, торговлю с жителем). Владелец измерения\n * решает сам, реплика спрашивает его и открывает по ответу — с его содержимым. Занят — подсказка.\n */\n openContainer(key: string, open: () => void): void {\n const g = this.game;\n if (sim.authority) {\n const by = this.lockBy(key, this.slot);\n if (by) return this.busyHint(by);\n this.locks.set(key, this.slot);\n open();\n return;\n }\n if (this.lockWait.has(key)) return;\n const timer = setTimeout(() => {\n if (this.lockWait.delete(key))\n g.hud.message(\"Нет ответа от игрока, у которого это измерение\", 2200);\n }, 4000);\n this.lockWait.set(key, { open, dim: g.dimension, timer });\n this.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: g.dimension,\n key,\n open: true,\n } satisfies LockMsg),\n );\n }\n\n /** Экран контейнера закрыт — отпустить. */\n releaseContainer(key: string): void {\n if (sim.authority) {\n if (this.locks.get(key) === this.slot) this.locks.delete(key);\n return;\n }\n this.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: this.game.dimension,\n key,\n open: false,\n } satisfies LockMsg),\n );\n }\n\n private busyHint(by: string): void {\n this.game.hud.message(`Сейчас открыто у игрока ${by}`, 2000);\n }\n\n /** Владелец: имя того, кто держит контейнер (если не `slot` и он всё ещё в этом измерении). */\n private lockBy(key: string, slot: number): string | null {\n const cur = this.locks.get(key);\n if (cur === undefined || cur === slot) return null;\n if (cur === this.slot) return this.name;\n const peer = this.peers.get(cur);\n if (peer && peer.dim === this.game.dimension) return peer.name;\n this.locks.delete(key); // держатель ушёл (из измерения или из игры)\n return null;\n }\n\n private async onLock(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<LockMsg>(data);\n if (!m || typeof m.key !== \"string\") return;\n if (this.isHost) {\n m.from = from === this.selfId ? this.slot : this.records.get(from)?.slot;\n const owner = this.owners.get(m.dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", encodeJsonSync(M.LOCK, m));\n return;\n }\n }\n const slot = m.from;\n if (!sim.authority || m.dim !== this.game.dimension || slot === undefined)\n return;\n if (!m.open) {\n if (this.locks.get(m.key) === slot) this.locks.delete(m.key);\n return;\n }\n const by = this.lockBy(m.key, slot);\n const reply: ReplyMsg = {\n to: slot,\n kind: \"lock\",\n key: m.key,\n ok: !by,\n ...(by ? { by } : {}),\n };\n if (!by) {\n this.locks.set(m.key, slot);\n // блочная сущность — отсюда: сундук постройки получает добычу здесь, у реплики катилась бы своя\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b) {\n const be = this.game.blockEntities.ensure(+b[1]!, +b[2]!, +b[3]!);\n reply.be = be\n ? {\n type: be.type,\n x: be.x,\n y: be.y,\n z: be.z,\n state: JSON.parse(JSON.stringify(be.state)) as typeof be.state,\n }\n : null;\n }\n }\n this.replyTo(reply);\n }\n\n /** Ответ игроку `to`: себе — сразу, иначе через хоста (или хост — прямо ему). */\n private replyTo(r: ReplyMsg): void {\n if (r.to === this.slot) {\n this.handleReply(r);\n return;\n }\n const msg = encodeJsonSync(M.REPLY, r);\n if (this.isHost) {\n const to = this.userOfSlot(r.to);\n if (to) void this.send(to, \"rel\", msg);\n } else this.toHost(\"rel\", msg);\n }\n\n private async onReply(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<ReplyMsg>(data);\n if (!m) return;\n if (this.isHost && m.to !== this.slot) {\n const to = this.userOfSlot(m.to);\n if (to && to !== from) void this.send(to, \"rel\", data);\n return;\n }\n if (m.to === this.slot) this.handleReply(m);\n }\n\n private handleReply(m: ReplyMsg): void {\n const g = this.game;\n if (m.kind === \"ride\") {\n const r = g.player.riding as unknown as { id?: number } | null;\n if (!m.ok && r && r.id === m.id) {\n g.player.dismount();\n g.hud.message(\"Место уже занято\", 1600);\n }\n return;\n }\n const w = m.key ? this.lockWait.get(m.key) : undefined;\n if (!w || !m.key) return;\n clearTimeout(w.timer);\n this.lockWait.delete(m.key);\n if (!m.ok) return this.busyHint(m.by ?? \"?\");\n if (w.dim !== g.dimension) {\n this.releaseContainer(m.key);\n return;\n }\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b && m.be !== undefined)\n this.applyBe({\n dim: g.dimension,\n x: +b[1]!,\n y: +b[2]!,\n z: +b[3]!,\n be: m.be,\n });\n w.open();\n }\n\n // ---- порталы\n\n private async onPortal(from: string, data: Uint8Array): Promise<void> {\n if (from === this.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (this.isHost) this.toAll(\"rel\", data, from);\n this.game.applyNetPortal(m.dim, m.p, !!m.add);\n }\n\n // ---- блочные сущности\n\n private async onBe(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = this.owners.get(m.dim);\n if (this.isHost && from !== this.selfId) {\n if (owner === from) {\n this.storeBe(m);\n this.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n } else if (this.isHost && owner && owner !== this.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void this.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== this.game.dimension || from === this.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n this.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else this.applyBe(m);\n }\n\n private applyBe(m: BeMsg): void {\n this.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n this.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n }\n\n private storeBe(m: BeMsg): void {\n let s = this.beStore.get(m.dim);\n if (!s) this.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n }\n\n // ---- игроку: урон, предметы\n\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: Hit[\"effect\"],\n ): void {\n const h: Hit = {\n to: slot,\n amount,\n cause,\n ...(knock ? { knock } : {}),\n ...(effect ? { effect } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.HIT, h));\n }\n\n give(slot: number, stack: ItemStack | null, xp?: number): void {\n const g: Give = {\n to: slot,\n ...(stack ? { stack } : {}),\n ...(xp ? { xp } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.GIVE, g));\n }\n\n private async onToPlayer(\n from: string,\n type: number,\n data: Uint8Array,\n ): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (this.isHost && m.to !== this.slot) {\n const to = this.userOfSlot(m.to);\n if (to && to !== from) void this.send(to, \"rel\", data);\n return;\n }\n if (m.to !== this.slot) return;\n const g = this.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n }\n\n // ---- чат\n\n sendChat(text: string): void {\n const t = text.slice(0, 256);\n this.game.chat.print(`<${this.name}> ${t}`);\n this.toHost(\"rel\", encodeJsonSync(M.CHAT, { from: this.slot, text: t }));\n }\n\n private async onChat(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === this.selfId) return;\n if (this.isHost) {\n m.from = this.records.get(from)?.slot;\n this.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who =\n m.from !== undefined ? (this.peers.get(m.from)?.name ?? \"?\") : \"?\";\n this.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n }\n\n // =========================================================================== владение измерением\n\n /** Стать владельцем своего измерения: сущности и блочные сущности — из переданного сохранения. */\n private becomeOwner(save: DimensionSave | null): void {\n const g = this.game;\n sim.authority = true;\n g.entities.replica = false;\n g.entities.localSlot = this.slot;\n this.driving = null;\n g.entities.drivenId = null;\n this.locks.clear();\n if (save) {\n g.entities.deserialize(\n (save.entities ?? []) as Parameters<typeof g.entities.deserialize>[0],\n );\n g.blockEntities.deserialize(save.blockEntities ?? []);\n }\n this.bc.reset();\n for (const peer of this.peers.values())\n if (peer.pose && peer.dim === g.dimension && !peer.remote) {\n peer.remote = new RemotePlayer(g.world, peer.slot, peer.userId, this);\n peer.remote.applyPose(peer.pose);\n }\n g.entities.others = () => this.remoteActors();\n }\n\n /** Стать репликой: сущности — образы от владельца, локальные id — в отдельном диапазоне. */\n private becomeReplica(): void {\n const g = this.game;\n sim.authority = false;\n g.entities.replica = true;\n g.entities.localSlot = this.slot;\n this.driving = null;\n this.locks.clear();\n g.entities._nextId = Math.max(g.entities._nextId, 1_000_000_000);\n g.entities.clear();\n g.entities.others = () => [];\n g.entities.onLocalSpawn = (snap) =>\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action: { a: \"spawn\", snap },\n } satisfies EntActionMsg),\n );\n g.entities.onRemoteAction = (action) =>\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action,\n } satisfies EntActionMsg),\n );\n for (const peer of this.peers.values()) peer.remote = null;\n }\n\n private remoteActors() {\n const out = [];\n for (const peer of this.peers.values())\n if (peer.remote && peer.dim === this.game.dimension && peer.pose)\n out.push({\n player: peer.remote as RemotePlayer,\n inventory: remoteInventory(peer.remote, this),\n });\n return out;\n }\n\n /**\n * Перед сменой измерения (начало Game.changeDimension): у всех есть дифы любого измерения, так\n * что целевое строится без ожидания. Сущности поднимаем, только если станем владельцем сразу\n * (хост, измерение ничьё); иначе их пришлёт владелец или хост отдаст владение.\n */\n beforeDimension(target: DimensionId): void {\n const g = this.game;\n const iOwnTarget = this.isHost && !this.owners.get(target);\n const frozen = this.frozen.get(target);\n g.dims.set(target, {\n chunks: this.diffs.toChunkDiffMap(target),\n entities: iOwnTarget ? (frozen?.entities ?? []) : [],\n blockEntities: iOwnTarget\n ? (frozen?.blockEntities ?? [])\n : [...(this.beStore.get(target)?.values() ?? [])].filter(Boolean),\n });\n if (iOwnTarget) this.frozen.delete(target);\n this.pendingOwnTarget = iOwnTarget;\n }\n private pendingOwnTarget = false;\n\n /**\n * Середина Game.changeDimension: прежнее измерение уже снято в `dims` (с сущностями — для\n * передачи следующему владельцу), новое ещё не загружено. Здесь переключается роль: реплике нельзя\n * поднимать сущности из сохранения — её spawn ушёл бы владельцу, а сущности там и так есть.\n */\n prepareDimension(): void {\n if (this.pendingOwnTarget) {\n sim.authority = true;\n this.game.entities.replica = false;\n } else this.becomeReplica();\n }\n\n /** После смены измерения: отдать прежнее (если владел) и заявить новое. */\n afterDimension(prev: DimensionId, target: DimensionId): void {\n const g = this.game;\n const wasOwner = this.owners.get(prev) === this.selfId;\n const handoff = wasOwner ? g.dims.get(prev) : undefined;\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n if (this.isHost) {\n const r = this.records.get(this.selfId)!;\n r.dim = target;\n if (handoff)\n this.frozen.set(prev, {\n chunks: {},\n entities: handoff.entities ?? [],\n blockEntities: handoff.blockEntities ?? [],\n });\n if (wasOwner) this.owners.delete(prev);\n this.assignOwner(prev);\n if (!this.owners.get(target)) {\n this.owners.set(target, this.selfId);\n this.becomeOwner(null); // сущности уже подняты changeDimension из g.dims\n } else this.enterDim(this.selfId, target);\n this.broadcastRoster();\n return;\n }\n // Синхронно и без сжатия: следом пойдут правки нового измерения (портал на той стороне), и по\n // упорядоченному каналу хост обязан узнать о входе РАНЬШЕ — иначе правки ничейного измерения\n // он бы выбросил.\n const msg: DimEnter = {\n dim: target,\n ...(handoff ? { handoff: { dim: prev, save: handoff } } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.DIM_ENTER, msg));\n // сейв игрока у хоста — сразу, а не через 10 с: уйди хост раньше, гость вернулся бы в прежнее\n // измерение на прежнее место\n this.sendPlayerState();\n }\n\n /** Хост: игрок вошёл в измерение — владельцем или репликой. */\n private onDimEnter(from: string, m: DimEnter): void {\n const r = this.records.get(from);\n if (!r) return;\n const prev = r.dim;\n r.dim = m.dim;\n if (m.handoff && this.owners.get(m.handoff.dim) === from) {\n this.frozen.set(m.handoff.dim, {\n chunks: {},\n entities: m.handoff.save.entities ?? [],\n blockEntities: m.handoff.save.blockEntities ?? [],\n });\n for (const b of m.handoff.save.blockEntities ?? []) {\n const be = b as BeMsg[\"be\"];\n if (be)\n this.storeBe({ dim: m.handoff.dim, x: be.x, y: be.y, z: be.z, be });\n }\n this.owners.delete(m.handoff.dim);\n }\n this.assignOwner(prev);\n this.enterDim(from, m.dim);\n this.broadcastRoster();\n }\n\n /** Хост: `who` в измерении `dim` — дать владение (если ничьё) или полный снимок от владельца. */\n private enterDim(who: string, dim: DimensionId): void {\n const owner = this.owners.get(dim);\n if (!owner) {\n this.owners.set(dim, who);\n const save = this.frozen.get(dim) ?? EMPTY_DIM();\n this.frozen.delete(dim);\n if (who === this.selfId) this.becomeOwner(save);\n // синхронно: по упорядоченному каналу владение обязано прийти раньше пересланных следом правок\n else\n void this.send(\n who,\n \"rel\",\n encodeJsonSync(M.DIM_OWN, { dim, save } satisfies DimOwn),\n );\n return;\n }\n if (owner === who) return;\n void this.send(owner, \"rel\", encodeJsonSync(M.DIM_RESYNC, { dim }));\n const list = [...(this.beStore.get(dim) ?? new Map())].map(([k, be]) => {\n const [x, y, z] = k.split(\",\").map(Number) as [number, number, number];\n return { dim, x, y, z, be } satisfies BeMsg;\n });\n // синхронно: следом могут пойти свежие изменения тех же сундуков, старый список не должен их затереть\n if (who !== this.selfId && list.length)\n void this.send(who, \"rel\", encodeJsonSync(M.BE_LIST, { dim, list }));\n }\n\n /** Хост: у измерения нет владельца или он ушёл — отдать следующему игроку в нём, иначе заморозить. */\n private assignOwner(dim: DimensionId): void {\n const owner = this.owners.get(dim);\n if (owner && this.records.get(owner)?.dim === dim) return;\n this.owners.delete(dim);\n const next = [...this.records.entries()]\n .filter(([, r]) => r.dim === dim)\n .sort((a, b) => a[1].slot - b[1].slot)[0];\n if (next) this.enterDim(next[0], dim);\n }\n\n // =========================================================================== список игроков\n\n private roster(): Roster {\n return {\n hostId: this.selfId,\n players: [...this.records.entries()].map(([userId, r]) => ({\n slot: r.slot,\n userId,\n name: r.name,\n dim: r.dim,\n op: this.ops.has(userId),\n })),\n owners: Object.fromEntries(this.owners),\n settings: this.settings,\n ...(this.successor ? { successor: this.successor } : {}),\n } as Roster;\n }\n\n private broadcastRoster(): void {\n if (!this.isHost) return;\n const r = this.roster();\n this.applyRoster(r);\n this.toAll(\"rel\", encodeJsonSync(M.ROSTER, r));\n }\n\n private applyRoster(r: Roster): void {\n this.settings = r.settings;\n this.ops.clear();\n const seen = new Set<number>();\n for (const p of r.players) {\n if (p.op) this.ops.add(p.userId);\n if (p.userId === this.selfId) {\n this.slot = p.slot;\n continue;\n }\n seen.add(p.slot);\n const cur = this.peers.get(p.slot);\n if (cur) {\n cur.name = p.name;\n cur.op = p.op;\n cur.dim = p.dim;\n } else\n this.peers.set(p.slot, {\n slot: p.slot,\n userId: p.userId,\n name: p.name,\n dim: p.dim,\n op: p.op,\n pose: null,\n held: null,\n mode: \"survival\",\n sleeping: false,\n avatar: null,\n remote: null,\n });\n }\n for (const [slot, peer] of [...this.peers])\n if (!seen.has(slot)) {\n this.dropPeerView(peer);\n this.peers.delete(slot);\n }\n // владелец моего измерения сменился — старые образы сущностей больше не обновятся\n const ownerHere = r.owners[this.game.dimension];\n const prevOwner = this.owners.get(this.game.dimension);\n this.owners.clear();\n for (const [d, u] of Object.entries(r.owners))\n if (u) this.owners.set(d as DimensionId, u);\n if (\n !this.isHost &&\n ownerHere !== prevOwner &&\n ownerHere !== this.selfId &&\n !sim.authority\n )\n this.game.entities.clear();\n const succ = (r as Roster & { successor?: string }).successor ?? null;\n if (succ !== this.successor) {\n this.successor = succ;\n // запасная связь с преемником: передача хоста не ждёт сигналинга\n if (succ === this.selfId)\n this.session.setExtraPeers(r.players.map((p) => p.userId));\n else this.session.setExtraPeers(succ ? [succ] : []);\n }\n events.emit(\"net:roster\");\n }\n\n /** Игроки для списка по Tab. */\n playerList(): {\n name: string;\n slot: number;\n userId: string;\n host: boolean;\n op: boolean;\n dim: DimensionId;\n rtt: number;\n }[] {\n const out = [\n {\n name: this.name,\n slot: this.slot,\n userId: this.selfId,\n host: this.isHost,\n op: this.ops.has(this.selfId),\n dim: this.game.dimension,\n rtt: 0,\n },\n ];\n for (const p of this.peers.values())\n out.push({\n name: p.name,\n slot: p.slot,\n userId: p.userId,\n host: p.userId === this.hostId,\n op: p.op,\n dim: p.dim,\n rtt: this.session.linkStats(p.userId)?.rttMs ?? 0,\n });\n return out.sort((a, b) => a.slot - b.slot);\n }\n\n // =========================================================================== тик и кадр\n\n /** Конец симтика Game: разослать накопленное. */\n afterTick(): void {\n if (this.closed) return;\n // Сессию закрыли в обход событий (страховка к \"left\"): дальше играть не на чем.\n if (this.session.closed) return this.end(null);\n const g = this.game;\n this.tickN++;\n const dim = g.dimension;\n\n if (this.pending.length) {\n const list = this.pending;\n this.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (this.isHost && type === M.BLOCKS) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) this.entAction(a);\n this.syncRide();\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of this.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) this.hit(peer.slot, 0, \"knock\", imp);\n }\n if (this.tickN % 2 === 0 && this.hasOthersIn(dim)) {\n const d = this.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n this.ownerSend(\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) this.ownerSend(\"fast\", b);\n if (d.removes.length)\n this.ownerSend(\"rel\", encodeEntRemove(dim, d.removes));\n }\n if (this.tickN % 10 === 0)\n for (const { be, key } of this.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (this.isHost) this.storeBe(msg);\n if (this.hasOthersIn(dim))\n this.ownerSend(\"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (this.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of this.replicaBe.diffBlockEntities(\n g.blockEntities,\n )) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n this.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n this.sendPose();\n this.sendMetaIfChanged();\n if (this.tickN % 200 === 0 && !this.isHost) this.sendPlayerState();\n\n if (this.isHost) {\n if (this.tickN % 100 === 0)\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (this.tickN % 200 === 0) this.pickSuccessorAndShadow();\n } else this.watchHost();\n }\n\n /**\n * Реплика в транспорте или верхом: сущность ведёт этот игрок (EntityManager.drivenId), владелец\n * её не тикает и получает позу 10 раз в секунду. Сел/слез — сказать владельцу.\n */\n private syncRide(): void {\n const g = this.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== this.driving) {\n if (this.driving !== null)\n this.entAction({ a: \"ride\", id: this.driving, on: false });\n if (id !== null) this.entAction({ a: \"ride\", id, on: true });\n this.driving = id;\n em.drivenId = id;\n }\n if (id === null || this.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n this.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n }\n\n /** Владелец рассылает игрокам измерения: хост — сам, гость — через хоста. */\n private ownerSend(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n if (this.isHost)\n this.toDim(this.game.dimension, channel, data, this.selfId);\n else this.toHost(channel, data);\n }\n\n private hasOthersIn(dim: DimensionId): boolean {\n for (const p of this.peers.values()) if (p.dim === dim) return true;\n return false;\n }\n\n private sendPose(): void {\n const g = this.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0);\n const msg = encodePose({\n slot: this.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (this.isHost) this.toAll(\"fast\", msg);\n else this.toHost(\"fast\", msg);\n }\n\n private sendMetaIfChanged(): void {\n const g = this.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: this.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: this.name,\n sleeping: g.netSleeping,\n };\n const j = JSON.stringify(meta);\n if (j === this.lastMeta) return;\n this.lastMeta = j;\n if (this.isHost) {\n this.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n this.hostSleepCheck(this.selfId, meta);\n } else this.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n }\n\n /** Сон как в Minecraft: ночь пропускается, когда спят ВСЕ игроки Верхнего мира. */\n private hostSleepCheck(_who: string, _m: PlayerMeta): void {\n const g = this.game;\n const inOverworld = [...this.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === this.selfId\n ? g.netSleeping\n : ([...this.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n }\n\n frame(dt: number): void {\n for (const peer of this.peers.values()) peer.avatar?.frame(dt);\n }\n\n // =========================================================================== команды\n\n private mayCheat(userId: string): boolean {\n return (\n userId === this.selfId ||\n this.ops.has(userId) ||\n this.settings.cheatsForAll\n );\n }\n\n /**\n * Команда в сетевой игре: «про себя» — здесь, «про мир» — у хоста, без прав — отказ.\n * Возвращает null (исполнять локально) или текст ответа.\n */\n routeCommand(name: string, text: string): string | null {\n const harmless = [\"help\", \"seed\", \"locate\", \"achievements\", \"music\"];\n if (harmless.includes(name)) return null;\n if (name === \"newworld\")\n return \"В сетевой игре новый мир не создать — сначала выйдите из неё\";\n if (!this.canCheat) return \"Команды в этой игре — только у операторов\";\n if ([\"time\", \"weather\", \"difficulty\"].includes(name) && !this.isHost) {\n this.toHost(\"rel\", encodeJsonSync(M.COMMAND, { text }));\n return \"Команда отправлена хосту\";\n }\n return null;\n }\n\n /** Хост: выдать или снять права оператора. */\n setOp(userId: string, op: boolean): void {\n if (!this.isHost || userId === this.selfId) return;\n if (op) this.ops.add(userId);\n else this.ops.delete(userId);\n const r = this.records.get(userId);\n if (r) r.op = op;\n this.broadcastRoster();\n }\n\n async kick(userId: string, ban: boolean): Promise<string | null> {\n const res = await this.session.kick(userId, ban);\n return res.ok ? null : res.error;\n }\n\n // =========================================================================== хост ушёл\n\n /** Хост: выбрать преемника (лучшая связь) и отправить ему теневую копию мира. */\n private pickSuccessorAndShadow(): void {\n let best: string | null = null,\n bestRtt = Infinity;\n for (const peer of this.peers.values()) {\n const s = this.session.linkStats(peer.userId);\n if (!s || !this.session.isOpen(peer.userId)) continue;\n const rtt = s.rttMs || 999;\n if (rtt < bestRtt) {\n bestRtt = rtt;\n best = peer.userId;\n }\n }\n if (best !== this.successor) {\n this.successor = best;\n this.session.setSuccessor(best);\n this.broadcastRoster();\n }\n if (!best) return;\n const g = this.game;\n const dims: Partial<Record<DimensionId, DimensionSave>> = {};\n for (const [d, s] of this.frozen) dims[d] = s;\n if (this.owners.get(g.dimension) === this.selfId)\n dims[g.dimension] = {\n chunks: {},\n entities: g.entities.serialize(),\n blockEntities: g.blockEntities.serialize(),\n };\n const players: Record<string, unknown> = {};\n for (const [uid, r] of this.records)\n players[uid] = uid === this.selfId ? g.netPlayerState() : r.state;\n const shadow: Shadow = {\n world: g.netWorldMeta(),\n dims,\n players,\n settings: this.settings,\n ops: [...this.ops],\n };\n void encodeJson(M.SHADOW, shadow).then((b) => this.send(best!, \"rel\", b));\n }\n\n private onPeerOpen(peerId: string): void {\n if (!this.isHost && peerId === this.hostId) {\n if (this.welcomeWait) this.sendHello();\n else {\n // новый хост после передачи: представиться заново\n this.lastMeta = \"\";\n this.sendPlayerState();\n }\n }\n }\n\n /** Гость: свой сейв хосту (он хранит его в своём мире). */\n private sendPlayerState(): void {\n if (this.isHost) return;\n void encodeJson(M.PSTATE, { state: this.game.netPlayerState() }).then((b) =>\n this.toHost(\"rel\", b),\n );\n }\n\n private onPeerClose(peerId: string): void {\n if (!this.isHost && peerId === this.hostId)\n this.hostSilentSince = Date.now();\n }\n\n private onMemberLeft(userId: string): void {\n for (const peer of this.peers.values())\n if (peer.userId === userId && sim.authority)\n this.game.entities.netReleaseDriver(peer.slot);\n const r = this.records.get(userId);\n if (this.isHost && r) {\n this.records.delete(userId);\n if (this.owners.get(r.dim) === userId) {\n this.owners.delete(r.dim);\n this.assignOwner(r.dim);\n }\n this.broadcastRoster();\n }\n for (const [slot, peer] of [...this.peers])\n if (peer.userId === userId) {\n this.dropPeerView(peer);\n this.peers.delete(slot);\n }\n events.emit(\"net:roster\");\n }\n\n /** Гость: связь с хостом пропала. Преемник через 5 с забирает роль (сервер проверит, что хост молчит). */\n private watchHost(): void {\n if (!this.hostSilentSince || this.successor !== this.selfId) return;\n if (this.session.isOpen(this.hostId)) {\n this.hostSilentSince = 0;\n return;\n }\n if (Date.now() - this.hostSilentSince < 5000 || this.tickN % 40 !== 0)\n return;\n void this.session.claimHost();\n }\n\n private onHostChanged(hostId: string, previousHostId: string | null): void {\n this.hostSilentSince = 0;\n this.hostIdBefore = previousHostId ?? \"\";\n if (hostId !== this.selfId) {\n // у нового хоста наш сейв из теневой копии (до 10 с давности); по запасной связи, уже открытой,\n // onPeerOpen не сработает — шлём сразу\n if (this.session.isOpen(hostId)) this.sendPlayerState();\n return;\n }\n this.promote();\n }\n\n /** Стал хостом: мир из теневой копии и своей копии дифов, прежние владельцы измерений — как были. */\n private promote(): void {\n const g = this.game;\n const sh = this.shadow;\n this.records.clear();\n this.records.set(this.selfId, {\n slot: this.slot,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n for (const peer of this.peers.values())\n if (this.session.members.has(peer.userId))\n this.records.set(peer.userId, {\n slot: peer.slot,\n name: peer.name,\n dim: peer.dim,\n op: peer.op,\n state: sh?.players[peer.userId] ?? null,\n });\n this.nextSlot = Math.max(this.slot, ...[...this.peers.keys()]) + 1;\n if (sh) {\n this.settings = sh.settings;\n for (const o of sh.ops) this.ops.add(o);\n for (const [d, s] of Object.entries(sh.dims) as [\n DimensionId,\n DimensionSave,\n ][])\n if (!this.owners.has(d) || this.owners.get(d) === this.hostIdBefore)\n this.frozen.set(d, s);\n g.applyNetWorldState({\n ...sh.world,\n difficulty: sh.world.difficulty,\n daysAwake: sh.world.daysAwake,\n } as WorldState);\n }\n this.ops.add(this.selfId);\n // владения прежнего хоста: его измерение — следующему там (может, мне), остальное — как было\n for (const [d, owner] of [...this.owners])\n if (!this.records.has(owner)) {\n this.owners.delete(d);\n this.assignOwner(d);\n }\n if (!this.owners.get(g.dimension)) {\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(this.frozen.get(g.dimension) ?? null);\n this.frozen.delete(g.dimension);\n }\n g.adoptNetWorldAsOwn();\n this.broadcastRoster();\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n g.hud.message(\"Хост ушёл — теперь мир у вас\", 3000);\n }\n private hostIdBefore = \"\";\n\n // =========================================================================== гость: вход\n\n private applyWelcome(w: Welcome): void {\n this.slot = w.you;\n this.applyRoster(w.roster);\n this.hostIdBefore = w.roster.hostId;\n for (const d of DIMS) this.diffs.load(d, w.diffs[d]);\n this.becomeReplica();\n this.game.loadNetWorld(w, this.diffs, this.settings);\n // правки, накопленные, пока шёл вход, — из СВОЕГО одиночного мира: хосту их слать нельзя\n this.pending = [];\n this.welcomed = true;\n // отложенное — по порядку прихода; правки из снимка применятся повторно, это безвредно\n const stash = this.stash.splice(0);\n for (const [from, data] of stash)\n this.inbox = this.inbox.then(() => this.onMessage(from, \"rel\", data));\n }\n\n // =========================================================================== выход\n\n /** Хост уходит: свежая теневая копия преемнику, и сервер отдаст роль именно ему. */\n prepareExit(): void {\n if (this.closed || !this.isHost) return;\n this.pickSuccessorAndShadow();\n this.session.setSuccessor(this.successor);\n }\n\n /** Хост: сейвы других игроков — в мир хоста (Game.persist). */\n collectPlayerStates(into: Map<string, unknown>): void {\n if (!this.isHost) return;\n for (const [uid, r] of this.records)\n if (uid !== this.selfId && r.state) into.set(uid, r.state);\n }\n\n /** Уйти из сетевой игры (кнопка). Хост передаёт мир преемнику. */\n async leave(): Promise<void> {\n if (this.closed) return;\n this.prepareExit();\n this.dispose();\n await this.leaveRoom();\n this.game.exitNet(null);\n }\n\n /** Сессия уже закрыта снаружи — закончить сетевую игру без сообщения (см. Game.acceptRoom). */\n closeStale(): void {\n this.end(null);\n }\n\n /** The network game is over; `reason` — what to tell the player (null — they left themselves). */\n private end(reason: string | null): void {\n if (this.closed) return;\n this.dispose();\n this.game.exitNet(reason);\n }\n\n dispose(): void {\n if (this.closed) return;\n this.closed = true;\n for (const off of this.offs.splice(0)) off();\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n this.peers.clear();\n const g = this.game;\n g.entities.replica = false;\n g.entities.others = () => [];\n g.entities.onLocalSpawn = null;\n g.entities.onRemoteAction = null;\n g.entities.localSlot = null;\n g.entities.drivenId = null;\n g.onPortalChange = null;\n for (const w of this.lockWait.values()) clearTimeout(w.timer);\n this.lockWait.clear();\n this.locks.clear();\n sim.authority = true;\n }\n}\n"
|
|
660
|
+
"content": "// Сетевая игра VoxelCraft: связывает игру (Game) с `RealtimeSession` (@idosgames/core/realtime).\n// Комнату создаёт и в неё входит ЛОББИ (система мультиплеера, ctx.multiplayer) — сюда приходит уже\n// открытая сессия (`NetGame.fromRoom`); своего списка комнат, создания и входа по коду у игры нет.\n//\n// Топология — звезда (комнаты `Host`): каждый гость связан только с хостом, хост пересылает. Роли и\n// протокол — net/protocol.ts. Кто считает мир — net/sim.ts: у каждого «живого» измерения один\n// владелец (первый вошедший; хост не главнее других), остальные в нём — реплики.\n//\n// Игрок и инвентарь — у каждого свои (вариант А, 27.09.2026): здесь по сети ходят только мир,\n// сущности, блочные сущности и то, что мир делает с игроком (урон, отброс, подобранное).\nimport type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport { events } from \"../core/EventBus\";\nimport type { NetEntityAction } from \"../entities/EntityManager\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\nimport { sim } from \"./sim\";\nimport { DiffStore } from \"./DiffStore\";\nimport { OwnerBroadcast } from \"./OwnerBroadcast\";\nimport { RemotePlayer, remoteInventory, type RemoteSink } from \"./RemotePlayer\";\nimport { encodeJsonSync } from \"./codec\";\nimport {\n M,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type Give,\n type Hit,\n type PlayerMeta,\n type PortalMsg,\n type ReplyMsg,\n type Pose,\n type Roster,\n type RoomSettings,\n type Shadow,\n type Welcome,\n} from \"./protocol\";\nimport {\n onMessage,\n onFast,\n sendHello,\n onHello,\n applyPose,\n dropPeerView,\n onMeta,\n onBlocks,\n applyBlocks,\n onEntities,\n onEntAction,\n entAction,\n onPortal,\n onBe,\n applyBe,\n storeBe,\n onToPlayer,\n onChat,\n} from \"./game/handlers\";\nimport {\n openContainer,\n releaseContainer,\n busyHint,\n lockBy,\n onLock,\n replyTo,\n onReply,\n handleReply,\n} from \"./game/locks\";\nimport {\n becomeOwner,\n becomeReplica,\n beforeDimension,\n prepareDimension,\n afterDimension,\n onDimEnter,\n enterDim,\n assignOwner,\n} from \"./game/dimensions\";\nimport {\n roster,\n broadcastRoster,\n applyRoster,\n playerList,\n} from \"./game/roster\";\nimport {\n pickSuccessorAndShadow,\n onPeerOpen,\n sendPlayerState,\n onPeerClose,\n onMemberLeft,\n watchHost,\n onHostChanged,\n promote,\n applyWelcome,\n} from \"./game/hostMigration\";\nimport {\n afterTick,\n syncRide,\n ownerSend,\n hasOthersIn,\n sendPose,\n sendMetaIfChanged,\n hostSleepCheck,\n} from \"./game/tick\";\nimport { mayCheat, routeCommand, setOp, kick } from \"./game/admin\";\nimport type { NetRoom, Peer, HostRecord } from \"./game/types\";\nimport { roomSettingsOf, HELLO_RETRY_MS } from \"./game/shared\";\n\nexport type { NetRoom } from \"./game/types\";\n\nexport class NetGame implements RemoteSink {\n readonly session: Session;\n readonly selfId: string;\n slot = 0;\n settings: RoomSettings;\n /** Операторы (хост — всегда). */\n readonly ops = new Set<string>();\n readonly diffs = new DiffStore();\n /** Другие игроки по слоту. */\n readonly peers = new Map<number, Peer>();\n /** Владелец каждого живого измерения (userId). */\n readonly owners = new Map<DimensionId, string>();\n /** Хост ушёл — кто подхватит (объявляет хост в списке игроков). */\n successor: string | null = null;\n // ---- только у хоста\n readonly records = new Map<string, HostRecord>();\n nextSlot = 1;\n /** Измерения без владельца: сущности и блочные сущности (дифы — в `diffs`). */\n readonly frozen = new Map<DimensionId, DimensionSave>();\n /** Блочные сущности всех измерений (для вошедшего в измерение гостя). */\n readonly beStore = new Map<DimensionId, Map<string, BeMsg[\"be\"]>>();\n // ---- владелец/реплика своего измерения\n readonly bc = new OwnerBroadcast();\n /** Реплика: что последнее прислал владелец по блочным сущностям — своё отличие уходит ему. */\n readonly replicaBe = new OwnerBroadcast();\n pending: BlockDelta[] = [];\n applyingRemote = false;\n tickN = 0;\n lastMeta = \"\";\n shadow: Shadow | null = null;\n hostSilentSince = 0;\n private readonly offs: (() => void)[] = [];\n closed = false;\n /** Гость: ждём снимок мира. */\n welcomeWait: ((w: Welcome | string) => void) | null = null;\n /** Очередь обработки надёжного канала (см. конструктор). */\n inbox: Promise<void> = Promise.resolve();\n /** Гость получил снимок мира (хост — всегда). До него сообщения копятся в `stash`. */\n welcomed = true;\n readonly stash: [string, Uint8Array][] = [];\n /** Владелец измерения: занятые контейнеры — ключ (LockMsg.key) → слот игрока. */\n readonly locks = new Map<string, number>();\n /** Свои запросы контейнера, ждущие ответа владельца. */\n readonly lockWait = new Map<\n string,\n { open: () => void; dim: DimensionId; timer: ReturnType<typeof setTimeout> }\n >();\n /** Реплика: id сущности, в которой сидит свой игрок (её ведёт он). */\n driving: number | null = null;\n\n private constructor(\n readonly game: Game,\n session: Session,\n selfId: string,\n settings: RoomSettings,\n readonly name: string,\n /** Уйти из комнаты — через лобби: оно владеет комнатой и забывает её вместе с сессией. */\n readonly leaveRoom: () => Promise<void>,\n ) {\n this.session = session;\n this.selfId = selfId;\n this.settings = settings;\n this.offs.push(\n session.on(\"message\", ({ from, channel, data }) => {\n // Надёжный канал — строго по порядку: сжатое сообщение распаковывается асинхронно, и без\n // очереди следующее («удалить сущность») обогнало бы предыдущее («создать»). Быстрый канал\n // (позы) порядка не требует и ждать не должен.\n if (channel === \"fast\") void this.onMessage(from, channel, data);\n else\n this.inbox = this.inbox.then(() =>\n this.onMessage(from, channel, data),\n );\n }),\n session.on(\"peerOpen\", ({ peerId }) => this.onPeerOpen(peerId)),\n session.on(\"peerClose\", ({ peerId }) => this.onPeerClose(peerId)),\n session.on(\"memberLeft\", ({ userId }) => this.onMemberLeft(userId)),\n session.on(\"hostChanged\", ({ hostId, previousHostId }) =>\n this.onHostChanged(hostId, previousHostId),\n ),\n session.on(\"chat\", ({ text }) => game.chat.print(text)),\n // исключили: сервер отвечает «не участник» (после уведомления хоста) или прислал PlayerLeft kicked\n session.on(\"removed\", ({ reason }) =>\n this.end(\n /kicked|not a member|removed/i.test(reason)\n ? \"Вас исключили из игры\"\n : \"Связь с игрой потеряна\",\n ),\n ),\n session.on(\"roomClosed\", () => this.end(\"Игра закрыта\")),\n // Вышли из комнаты не отсюда (кнопка «Выйти» в комнате лобби): сессия закрыта — сетевая игра\n // окончена. Без этого игра оставалась на мёртвой сессии и молча игнорировала следующую комнату.\n session.on(\"left\", () => this.end(null)),\n );\n // Реестр порталов общий: зажёг портал любой — знают все (выход на той стороне ищется по нему).\n game.onPortalChange = (dim, pos, add) => {\n const msg = encodeJsonSync(M.PORTAL, {\n dim,\n p: pos,\n add,\n } satisfies PortalMsg);\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n };\n // Правки мира: владелец рассылает все (свои, каскады, пришедшие от реплик), реплика — только свои.\n this.offs.push(\n events.on(\n \"blockChanged\",\n (d: { x: number; y: number; z: number; id: number; meta: number }) => {\n if (this.applyingRemote && !sim.authority) return;\n this.diffs.apply(this.game.dimension, d.x, d.y, d.z, d.id, d.meta);\n this.pending.push({ x: d.x, y: d.y, z: d.z, id: d.id, meta: d.meta });\n },\n ),\n );\n }\n\n get isHost(): boolean {\n return this.session.hostId === this.selfId;\n }\n\n get hostId(): string {\n return this.session.hostId;\n }\n\n /** Может ли этот игрок командами менять мир (хост и операторы, или «команды всем»). */\n get canCheat(): boolean {\n return (\n this.isHost || this.ops.has(this.selfId) || this.settings.cheatsForAll\n );\n }\n\n // =========================================================================== старт\n\n /**\n * Комната пришла из лобби (система мультиплеера, `ctx.multiplayer`): её создали и в неё вошли там,\n * соединения уже открыты — здесь только начинается игра поверх готовой сессии. Хост, запустивший\n * игру, открывает свой мир для остальных; гость (и опоздавший) получает мир хоста.\n */\n static async fromRoom(\n game: Game,\n room: NetRoom,\n fallbackName: string,\n ): Promise<NetGame | string> {\n const session = room.session;\n // имя — публичный профиль игрока в этом тайтле (его кладёт в снимок комнаты сервер)\n const name = session.members.get(session.selfId)?.Username || fallbackName;\n if (room.isHost && !room.lateJoin) {\n const net = new NetGame(\n game,\n session,\n session.selfId,\n roomSettingsOf(room.settings),\n name,\n room.leave,\n );\n net.ops.add(net.selfId);\n net.startAsHost();\n return net;\n }\n const net = new NetGame(\n game,\n session,\n session.selfId,\n { guestMode: \"survival\", cheatsForAll: false },\n name,\n room.leave,\n );\n net.welcomed = false;\n const w = await new Promise<Welcome | string>((resolve) => {\n const retry = setInterval(() => {\n if (session.isOpen(session.hostId)) net.sendHello();\n }, HELLO_RETRY_MS);\n const deadline = setTimeout(() => done(\"Хост не ответил\"), 30_000);\n function done(r: Welcome | string): void {\n clearInterval(retry);\n clearTimeout(deadline);\n resolve(r);\n }\n net.welcomeWait = done;\n if (session.isOpen(session.hostId)) net.sendHello();\n });\n net.welcomeWait = null;\n if (typeof w === \"string\") {\n net.dispose();\n await room.leave();\n return w;\n }\n net.applyWelcome(w);\n return net;\n }\n\n private startAsHost(): void {\n const g = this.game;\n this.slot = 0;\n this.records.set(this.selfId, {\n slot: 0,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n // Мир хоста целиком в хранилище дифов: живое измерение — с мира, остальные — из сейва измерений.\n this.diffs.load(g.dimension, g.world.serializeDiffs());\n for (const [d, save] of g.dims) {\n this.diffs.load(d, save.chunks);\n this.frozen.set(d, {\n chunks: {},\n entities: save.entities ?? [],\n blockEntities: save.blockEntities ?? [],\n });\n }\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(null);\n }\n\n // =========================================================================== отправка\n\n async send(\n to: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (to === this.selfId) {\n await this.onMessage(this.selfId, channel, data);\n return;\n }\n this.session.send(to, channel, data);\n }\n\n /** Хосту (у хоста — себе). */\n toHost(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n void this.send(this.hostId, channel, data);\n }\n\n /** Хост: всем, кроме `except`. */\n toAll(channel: \"rel\" | \"fast\", data: Uint8Array, except?: string): void {\n this.session.broadcast(channel, data, except);\n }\n\n /** Хост: всем игрокам измерения, кроме `except` (себе — тоже, если хост там же). */\n toDim(\n dim: DimensionId,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n for (const [uid, r] of this.records)\n if (r.dim === dim && uid !== except) void this.send(uid, channel, data);\n }\n\n userOfSlot(slot: number): string | null {\n if (slot === this.slot) return this.selfId;\n return this.peers.get(slot)?.userId ?? null;\n }\n\n // =========================================================================== приём\n\n onMessage(\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n return onMessage(this, from, channel, data);\n }\n\n onFast(from: string, type: number, data: Uint8Array): void {\n onFast(this, from, type, data);\n }\n\n sendHello(): void {\n sendHello(this);\n }\n\n onHello(from: string, data: Uint8Array): Promise<void> {\n return onHello(this, from, data);\n }\n\n applyPose(p: Pose): void {\n applyPose(this, p);\n }\n\n dropPeerView(peer: Peer): void {\n dropPeerView(this, peer);\n }\n\n onMeta(from: string, data: Uint8Array): Promise<void> {\n return onMeta(this, from, data);\n }\n\n onBlocks(from: string, type: number, data: Uint8Array): void {\n onBlocks(this, from, type, data);\n }\n\n applyBlocks(list: readonly BlockDelta[], asOwner: boolean): void {\n applyBlocks(this, list, asOwner);\n }\n\n onEntities(from: string, type: number, data: Uint8Array): Promise<void> {\n return onEntities(this, from, type, data);\n }\n\n onEntAction(from: string, data: Uint8Array): Promise<void> {\n return onEntAction(this, from, data);\n }\n\n /** Реплика: действие над сущностью — владельцу измерения (через хоста). */\n entAction(action: NetEntityAction): void {\n entAction(this, action);\n }\n\n /**\n * Открыть контейнер (сундук, печь, вьюк, груз вагонетки, торговлю с жителем). Владелец измерения\n * решает сам, реплика спрашивает его и открывает по ответу — с его содержимым. Занят — подсказка.\n */\n openContainer(key: string, open: () => void): void {\n openContainer(this, key, open);\n }\n\n /** Экран контейнера закрыт — отпустить. */\n releaseContainer(key: string): void {\n releaseContainer(this, key);\n }\n\n busyHint(by: string): void {\n busyHint(this, by);\n }\n\n /** Владелец: имя того, кто держит контейнер (если не `slot` и он всё ещё в этом измерении). */\n lockBy(key: string, slot: number): string | null {\n return lockBy(this, key, slot);\n }\n\n onLock(from: string, data: Uint8Array): Promise<void> {\n return onLock(this, from, data);\n }\n\n /** Ответ игроку `to`: себе — сразу, иначе через хоста (или хост — прямо ему). */\n replyTo(r: ReplyMsg): void {\n replyTo(this, r);\n }\n\n onReply(from: string, data: Uint8Array): Promise<void> {\n return onReply(this, from, data);\n }\n\n handleReply(m: ReplyMsg): void {\n handleReply(this, m);\n }\n\n onPortal(from: string, data: Uint8Array): Promise<void> {\n return onPortal(this, from, data);\n }\n\n onBe(from: string, data: Uint8Array): Promise<void> {\n return onBe(this, from, data);\n }\n\n applyBe(m: BeMsg): void {\n applyBe(this, m);\n }\n\n storeBe(m: BeMsg): void {\n storeBe(this, m);\n }\n\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: Hit[\"effect\"],\n ): void {\n const h: Hit = {\n to: slot,\n amount,\n cause,\n ...(knock ? { knock } : {}),\n ...(effect ? { effect } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.HIT, h));\n }\n\n give(slot: number, stack: ItemStack | null, xp?: number): void {\n const g: Give = {\n to: slot,\n ...(stack ? { stack } : {}),\n ...(xp ? { xp } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.GIVE, g));\n }\n\n onToPlayer(from: string, type: number, data: Uint8Array): Promise<void> {\n return onToPlayer(this, from, type, data);\n }\n\n sendChat(text: string): void {\n const t = text.slice(0, 256);\n this.game.chat.print(`<${this.name}> ${t}`);\n this.toHost(\"rel\", encodeJsonSync(M.CHAT, { from: this.slot, text: t }));\n }\n\n onChat(from: string, data: Uint8Array): Promise<void> {\n return onChat(this, from, data);\n }\n\n /** Стать владельцем своего измерения: сущности и блочные сущности — из переданного сохранения. */\n becomeOwner(save: DimensionSave | null): void {\n becomeOwner(this, save);\n }\n\n /** Стать репликой: сущности — образы от владельца, локальные id — в отдельном диапазоне. */\n becomeReplica(): void {\n becomeReplica(this);\n }\n\n remoteActors() {\n const out = [];\n for (const peer of this.peers.values())\n if (peer.remote && peer.dim === this.game.dimension && peer.pose)\n out.push({\n player: peer.remote as RemotePlayer,\n inventory: remoteInventory(peer.remote, this),\n });\n return out;\n }\n\n /**\n * Перед сменой измерения (начало Game.changeDimension): у всех есть дифы любого измерения, так\n * что целевое строится без ожидания. Сущности поднимаем, только если станем владельцем сразу\n * (хост, измерение ничьё); иначе их пришлёт владелец или хост отдаст владение.\n */\n beforeDimension(target: DimensionId): void {\n beforeDimension(this, target);\n }\n\n pendingOwnTarget = false;\n\n /**\n * Середина Game.changeDimension: прежнее измерение уже снято в `dims` (с сущностями — для\n * передачи следующему владельцу), новое ещё не загружено. Здесь переключается роль: реплике нельзя\n * поднимать сущности из сохранения — её spawn ушёл бы владельцу, а сущности там и так есть.\n */\n prepareDimension(): void {\n prepareDimension(this);\n }\n\n /** После смены измерения: отдать прежнее (если владел) и заявить новое. */\n afterDimension(prev: DimensionId, target: DimensionId): void {\n afterDimension(this, prev, target);\n }\n\n /** Хост: игрок вошёл в измерение — владельцем или репликой. */\n onDimEnter(from: string, m: DimEnter): void {\n onDimEnter(this, from, m);\n }\n\n /** Хост: `who` в измерении `dim` — дать владение (если ничьё) или полный снимок от владельца. */\n enterDim(who: string, dim: DimensionId): void {\n enterDim(this, who, dim);\n }\n\n /** Хост: у измерения нет владельца или он ушёл — отдать следующему игроку в нём, иначе заморозить. */\n assignOwner(dim: DimensionId): void {\n assignOwner(this, dim);\n }\n\n roster(): Roster {\n return roster(this);\n }\n\n broadcastRoster(): void {\n broadcastRoster(this);\n }\n\n applyRoster(r: Roster): void {\n applyRoster(this, r);\n }\n\n /** Игроки для списка по Tab. */\n playerList(): {\n name: string;\n slot: number;\n userId: string;\n host: boolean;\n op: boolean;\n dim: DimensionId;\n rtt: number;\n }[] {\n return playerList(this);\n }\n\n /** Конец симтика Game: разослать накопленное. */\n afterTick(): void {\n afterTick(this);\n }\n\n /**\n * Реплика в транспорте или верхом: сущность ведёт этот игрок (EntityManager.drivenId), владелец\n * её не тикает и получает позу 10 раз в секунду. Сел/слез — сказать владельцу.\n */\n syncRide(): void {\n syncRide(this);\n }\n\n /** Владелец рассылает игрокам измерения: хост — сам, гость — через хоста. */\n ownerSend(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n ownerSend(this, channel, data);\n }\n\n hasOthersIn(dim: DimensionId): boolean {\n return hasOthersIn(this, dim);\n }\n\n sendPose(): void {\n sendPose(this);\n }\n\n sendMetaIfChanged(): void {\n sendMetaIfChanged(this);\n }\n\n /** Сон как в Minecraft: ночь пропускается, когда спят ВСЕ игроки Верхнего мира. */\n hostSleepCheck(_who: string, _m: PlayerMeta): void {\n hostSleepCheck(this, _who, _m);\n }\n\n frame(dt: number): void {\n for (const peer of this.peers.values()) peer.avatar?.frame(dt);\n }\n\n // =========================================================================== команды\n\n mayCheat(userId: string): boolean {\n return mayCheat(this, userId);\n }\n\n /**\n * Команда в сетевой игре: «про себя» — здесь, «про мир» — у хоста, без прав — отказ.\n * Возвращает null (исполнять локально) или текст ответа.\n */\n routeCommand(name: string, text: string): string | null {\n return routeCommand(this, name, text);\n }\n\n /** Хост: выдать или снять права оператора. */\n setOp(userId: string, op: boolean): void {\n setOp(this, userId, op);\n }\n\n kick(userId: string, ban: boolean): Promise<string | null> {\n return kick(this, userId, ban);\n }\n\n /** Хост: выбрать преемника (лучшая связь) и отправить ему теневую копию мира. */\n pickSuccessorAndShadow(): void {\n pickSuccessorAndShadow(this);\n }\n\n onPeerOpen(peerId: string): void {\n onPeerOpen(this, peerId);\n }\n\n /** Гость: свой сейв хосту (он хранит его в своём мире). */\n sendPlayerState(): void {\n sendPlayerState(this);\n }\n\n onPeerClose(peerId: string): void {\n onPeerClose(this, peerId);\n }\n\n onMemberLeft(userId: string): void {\n onMemberLeft(this, userId);\n }\n\n /** Гость: связь с хостом пропала. Преемник через 5 с забирает роль (сервер проверит, что хост молчит). */\n watchHost(): void {\n watchHost(this);\n }\n\n onHostChanged(hostId: string, previousHostId: string | null): void {\n onHostChanged(this, hostId, previousHostId);\n }\n\n /** Стал хостом: мир из теневой копии и своей копии дифов, прежние владельцы измерений — как были. */\n promote(): void {\n promote(this);\n }\n\n hostIdBefore = \"\";\n\n // =========================================================================== гость: вход\n\n applyWelcome(w: Welcome): void {\n applyWelcome(this, w);\n }\n\n /** Хост уходит: свежая теневая копия преемнику, и сервер отдаст роль именно ему. */\n prepareExit(): void {\n if (this.closed || !this.isHost) return;\n this.pickSuccessorAndShadow();\n this.session.setSuccessor(this.successor);\n }\n\n /** Хост: сейвы других игроков — в мир хоста (Game.persist). */\n collectPlayerStates(into: Map<string, unknown>): void {\n if (!this.isHost) return;\n for (const [uid, r] of this.records)\n if (uid !== this.selfId && r.state) into.set(uid, r.state);\n }\n\n /** Уйти из сетевой игры (кнопка). Хост передаёт мир преемнику. */\n async leave(): Promise<void> {\n if (this.closed) return;\n this.prepareExit();\n this.dispose();\n await this.leaveRoom();\n this.game.exitNet(null);\n }\n\n /** Сессия уже закрыта снаружи — закончить сетевую игру без сообщения (см. Game.acceptRoom). */\n closeStale(): void {\n this.end(null);\n }\n\n /** The network game is over; `reason` — what to tell the player (null — they left themselves). */\n end(reason: string | null): void {\n if (this.closed) return;\n this.dispose();\n this.game.exitNet(reason);\n }\n\n dispose(): void {\n if (this.closed) return;\n this.closed = true;\n for (const off of this.offs.splice(0)) off();\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n this.peers.clear();\n const g = this.game;\n g.entities.replica = false;\n g.entities.others = () => [];\n g.entities.onLocalSpawn = null;\n g.entities.onRemoteAction = null;\n g.entities.localSlot = null;\n g.entities.drivenId = null;\n g.onPortalChange = null;\n for (const w of this.lockWait.values()) clearTimeout(w.timer);\n this.lockWait.clear();\n this.locks.clear();\n sim.authority = true;\n }\n}\n"
|
|
517
661
|
},
|
|
518
662
|
{
|
|
519
663
|
"path": "net/OwnerBroadcast.ts",
|
|
@@ -543,6 +687,30 @@
|
|
|
543
687
|
"path": "player/ArmView.ts",
|
|
544
688
|
"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"
|
|
545
689
|
},
|
|
690
|
+
{
|
|
691
|
+
"path": "player/body/effects.ts",
|
|
692
|
+
"content": "import type { DrinkDef, EffectId } from \"../../types\";\nimport { events } from \"../../core/EventBus\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { P } from \"./shared\";\nimport type { Player } from \"../Player\";\n\nexport function boost(pl: Player): boolean {\n if (!pl.gliding) return false;\n pl._boostT = 1.2;\n return true;\n}\n\nexport function echoHop(pl: Player): boolean {\n for (let i = 0; i < 16; i++) {\n const x = Math.floor(pl.pos.x + (Math.random() - 0.5) * 16),\n z = Math.floor(pl.pos.z + (Math.random() - 0.5) * 16),\n y0 = Math.floor(pl.pos.y + (Math.random() - 0.5) * 16);\n for (let y = Math.min(y0, 126); y > y0 - 16 && y > 1; y--) {\n if (!BLOCKS[pl.world.getBlock(x, y - 1, z)]?.solid) continue;\n if (\n pl.world.getBlock(x, y, z) !== 0 ||\n pl.world.getBlock(x, y + 1, z) !== 0\n )\n break;\n pl.pos.set(x + 0.5, y, z + 0.5);\n pl.vel.set(0, 0, 0);\n pl._fallDist = 0;\n events.emit(\"echoHop\", { x, y, z });\n return true;\n }\n }\n return false;\n}\n\nexport function addEffect(\n pl: Player,\n id: EffectId,\n seconds: number,\n level = 1,\n): void {\n const cur = pl.effects.get(id);\n pl.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\nexport function clearEffects(pl: Player): void {\n pl.effects.clear();\n events.emit(\"effectsChanged\", {});\n}\n\nexport function drink(pl: Player, d: DrinkDef): void {\n if (d.clearsEffects) clearEffects(pl);\n if (d.heal) pl.health = Math.min(P.maxHealth, pl.health + d.heal);\n if (d.damage) pl.damage(d.damage, \"magic\");\n if (d.food) pl.food = Math.min(P.maxFood, pl.food + d.food);\n if (d.effect) addEffect(pl, d.effect, d.seconds ?? 60, d.level ?? 1);\n events.emit(\"playerDrank\", { effect: d.effect ?? null });\n}\n\nexport function tickEffects(pl: Player, dt: number): void {\n for (const [id, e] of pl.effects) {\n e.time -= dt;\n if (e.time <= 0) {\n pl.effects.delete(id);\n events.emit(\"effectsChanged\", { id });\n }\n }\n // Тление: 1 урона раз в 2 с (вдвое чаще за уровень), убивает — в отличие от голода\n const decay = pl.effects.get(\"decay\");\n if (decay) {\n pl._decayT += dt;\n if (pl._decayT >= 2 / decay.level) {\n pl._decayT = 0;\n pl.damage(1, \"decay\");\n }\n } else pl._decayT = 0;\n // Отравление: 1 урона раз в 1.25 с (вдвое чаще за уровень), но не убивает — оставляет 1 здоровья\n const poison = pl.effects.get(\"poison\");\n if (poison) {\n pl._poisonT += dt;\n if (pl._poisonT >= 1.25 / poison.level) {\n pl._poisonT = 0;\n if (pl.health > 1) pl.damage(Math.min(1, pl.health - 1), \"poison\");\n }\n } else pl._poisonT = 0;\n // Голод (удар кадавра): истощение 0.1 в секунду за уровень — сытость тает быстрее\n const hunger = pl.effects.get(\"hunger\");\n if (hunger) pl.addExhaustion(0.1 * hunger.level * dt);\n const regen = pl.effects.get(\"regeneration\");\n if (!regen) return;\n pl._effectRegenT += dt;\n const every = 2.5 / 2 ** (regen.level - 1);\n if (pl._effectRegenT >= every) {\n pl._effectRegenT = 0;\n if (pl.health < P.maxHealth)\n pl.health = Math.min(P.maxHealth, pl.health + 1);\n }\n}\n"
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
"path": "player/body/movement.ts",
|
|
696
|
+
"content": "import { events } from \"../../core/EventBus\";\nimport { BLOCKS, B } from \"../../registry/Blocks\";\nimport { moveBody } from \"../../physics/Collision\";\nimport type { Input } from \"../Input\";\nimport type { Rideable } from \"../../entities/Rideable\";\nimport { GLIDE_TICK, glideTick } from \"../Glide\";\nimport { P } from \"./shared\";\nimport type { Player } from \"../Player\";\n\n/** Для коллизий в кожаных ботинках: снег-порох — полный куб. */\nconst isPowderSnow = (id: number): boolean => id === B.powder_snow;\n\n/** Подъём в снегу-порохе с зажатым прыжком, блоков/с: без кожаных ботинок и в них. */\nconst POWDER_CLIMB = 1.5;\n\nconst POWDER_CLIMB_BOOTS = 2.5;\n\n/**\n * Подскок на выходе из снега-пороха: ноги покинули клетку, а тело ещё на 0.2 ниже кромки — без него\n * упор в край не срабатывает (коробка пересекает тело) и игрок сползает обратно. ~0.5 блока.\n */\nconst POWDER_HOP = 5;\n\nexport function update(pl: Player, dt: number, input: Input): void {\n // дельту забираем и у мёртвого: иначе она копится и после респауна камеру дёргает\n const m = input.consumeMouse();\n if (pl.dead) return;\n // взгляд\n pl.look(-m.dx * 0.0024, -m.dy * 0.0024);\n\n // в вагонетке: едем с ней, «вперёд» толкает, присесть — выйти\n if (pl.riding) {\n const cart = pl.riding;\n if (cart.dead || (input.sneak && !input.uiOpen)) dismount(pl);\n else {\n pl.pos.set(\n cart.renderPos.x,\n cart.renderPos.y + cart.seatY,\n cart.renderPos.z,\n );\n pl.vel.set(0, 0, 0);\n pl.onGround = true;\n pl._fallDist = 0;\n if (input.forward && !input.uiOpen)\n cart.push(-Math.sin(pl.yaw), -Math.cos(pl.yaw), dt);\n return;\n }\n }\n\n // среда\n const feet = pl.world.getBlock(\n Math.floor(pl.pos.x),\n Math.floor(pl.pos.y + 0.2),\n Math.floor(pl.pos.z),\n );\n const head = pl.world.getBlock(\n Math.floor(pl.pos.x),\n Math.floor(pl.pos.y + P.eye),\n Math.floor(pl.pos.z),\n );\n const wet = (id: number): boolean =>\n !!(BLOCKS[id]?.liquid || BLOCKS[id]?.waterlogged);\n pl.inWater = wet(feet) || wet(head);\n pl.headInWater = wet(head);\n pl.inLava = feet === B.lava || head === B.lava;\n pl.headInLava = head === B.lava;\n pl.inFire =\n feet === B.fire ||\n head === B.fire ||\n feet === B.soul_fire ||\n head === B.soul_fire;\n pl.onLadder = touchesLadder(pl);\n pl.inPowderSnow = feet === B.powder_snow || head === B.powder_snow;\n pl.headInPowderSnow = head === B.powder_snow;\n // вылезли из снега-пороха вверх с зажатым прыжком — подскок через кромку\n if (\n pl._wasInPowder &&\n !pl.inPowderSnow &&\n input.jump &&\n !input.uiOpen &&\n pl.vel.y > 0 &&\n !pl.flying\n )\n pl.vel.y = Math.max(pl.vel.y, POWDER_HOP);\n pl._wasInPowder = pl.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(pl.yaw),\n cos = Math.cos(pl.yaw);\n const wishX = ix * cos + iz * sin;\n const wishZ = iz * cos - ix * sin;\n\n // присесть — Ctrl (в полёте Ctrl — вниз); бег доступен, пока еды достаточно (как в Minecraft)\n pl._sneaking = input.sneak && !pl.flying && !input.uiOpen;\n const canSprint =\n input.run && input.forward && !pl._sneaking && pl.food > P.sprintFood;\n pl._sprinting = canSprint && !pl.flying && !pl.inWater;\n const speed =\n pl.speedFactor *\n (pl.flying\n ? P.flySpeed\n : pl.inWater\n ? P.swimSpeed * (pl.inLava ? 0.5 : 1)\n : pl._sneaking\n ? P.sneakSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed);\n\n // Крылья Предела: прыжок в воздухе раскрывает их; земля, вода, лестница, полёт, износ — складывают\n const jumpEdge = input.jump && !pl._jumpHeld && !input.uiOpen;\n pl._jumpHeld = input.jump;\n if (\n pl.gliding &&\n (pl.onGround || pl.inWater || pl.flying || pl.onLadder || !pl.canGlide?.())\n )\n pl.gliding = false;\n else if (\n !pl.gliding &&\n jumpEdge &&\n !pl.onGround &&\n !pl.inWater &&\n !pl.flying &&\n pl.vel.y < 1 &&\n pl.canGlide?.()\n ) {\n pl.gliding = true;\n pl._glideAcc = 0;\n events.emit(\"glideStart\");\n }\n\n if (pl.gliding) {\n // физика Minecraft по тикам; ракета толкает вдоль взгляда\n pl._fallDist = 0;\n pl._glideAcc += dt;\n while (pl._glideAcc >= GLIDE_TICK) {\n pl._glideAcc -= GLIDE_TICK;\n glideTick(pl.vel, pl.pitch, pl.yaw, pl._boostT > 0);\n pl._boostT = Math.max(0, pl._boostT - GLIDE_TICK);\n }\n pl._wearT += dt;\n if (pl._wearT >= 1) {\n pl._wearT -= 1;\n if (pl.mode !== \"creative\") pl.onGlideWear?.();\n }\n } else if (pl.flying) {\n // полёт: прямое управление скоростью, вертикаль на Space/Ctrl\n pl.vel.x = wishX * speed;\n pl.vel.z = wishZ * speed;\n pl.vel.y = (input.jump ? speed : 0) + (input.sneakOrDown ? -speed : 0);\n if (input.uiOpen) pl.vel.y = 0;\n } else if (pl.inPowderSnow) {\n // снег-порох: вязнем — медленно; зажав прыжок, карабкаемся вверх, как по лестнице (в кожаных\n // ботинках быстрее). Без подъёма яма в 2 блока была ловушкой: холод добивал игрока насмерть.\n pl.vel.x = wishX * speed * 0.35;\n pl.vel.z = wishZ * speed * 0.35;\n pl.vel.y =\n input.jump && !input.uiOpen\n ? pl.leatherBoots?.()\n ? POWDER_CLIMB_BOOTS\n : POWDER_CLIMB\n : Math.max(-1.2, pl.vel.y - P.gravity * dt);\n pl._fallDist = 0;\n } else if (pl.inWater) {\n const accel = 24 * dt;\n pl.vel.x += (wishX * speed - pl.vel.x) * Math.min(1, accel);\n pl.vel.z += (wishZ * speed - pl.vel.z) * Math.min(1, accel);\n pl.vel.y -= P.gravity * 0.3 * dt; // ослабленная гравитация\n if (input.jump && !input.uiOpen) pl.vel.y = P.swimUp; // гребок вверх\n pl.vel.y *= 1 - P.waterDrag * dt; // сопротивление воды\n pl._fallDist = 0;\n } else {\n const accel = (pl.onGround ? 18 : 4) * dt;\n pl.vel.x += (wishX * speed - pl.vel.x) * Math.min(1, accel);\n pl.vel.z += (wishZ * speed - pl.vel.z) * Math.min(1, accel);\n pl.vel.y -= P.gravity * dt;\n // Левитация (пуля панцирника): тянет вверх ~1 блок/с за уровень, падения не копит\n const lev = pl.effects.get(\"levitation\");\n if (lev) {\n pl.vel.y += (lev.level - pl.vel.y) * Math.min(1, 5 * dt);\n pl._fallDist = 0;\n }\n if (input.jump && pl.onGround && !input.uiOpen) {\n pl.vel.y = P.jumpSpeed + 1.6 * pl.jumpBoost; // Прыгучесть — выше на ~полблока за уровень\n pl.addExhaustion(pl._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\n }\n // лестница: вверх — прыжок или «вперёд» в стену, присев — висим, иначе медленно сползаем\n if (pl.onLadder) {\n pl._fallDist = 0;\n const climb =\n !input.uiOpen && (input.jump || (input.forward && pl._hitWall));\n if (climb) pl.vel.y = P.climbSpeed;\n else if (pl._sneaking) pl.vel.y = Math.max(pl.vel.y, 0);\n else pl.vel.y = Math.max(pl.vel.y, -P.climbSpeed);\n }\n }\n\n // интеграция с сабстепами — защита от туннелирования на лагающем кадре\n const steps = Math.max(1, Math.ceil((pl.vel.length() * dt) / 0.4));\n const sdt = dt / steps;\n for (let s = 0; s < steps; s++) moveStep(pl, sdt);\n\n // шаги (звук) + истощение от ходьбы/бега\n if (pl.onGround && !pl.inWater) {\n const dist = Math.hypot(pl.vel.x, pl.vel.z) * dt;\n pl._stepDist += dist;\n if (pl.mode === \"survival\")\n pl.addExhaustion(\n dist * (pl._sprinting ? P.exhaustSprint : P.exhaustWalk),\n );\n if (pl._stepDist > 2.2) {\n pl._stepDist = 0;\n events.emit(\"playerStep\");\n }\n }\n\n if (pl.pos.y < -10) pl._die(); // выпал из мира\n}\n\nexport function mount(pl: Player, cart: Rideable): void {\n if (pl.riding) dismount(pl);\n pl.riding = cart;\n cart.rider = true;\n}\n\nexport function dismount(pl: Player): void {\n const cart = pl.riding;\n if (!cart) return;\n cart.rider = false;\n pl.riding = null;\n pl.pos.set(cart.pos.x, cart.pos.y + 1, cart.pos.z);\n pl.vel.set(0, 0, 0);\n}\n\nexport function moveStep(pl: Player, dt: number): void {\n const wasAirborne = !pl.onGround && !pl.flying && !pl.inWater;\n const prevVy = pl.vel.y;\n const hv0 = Math.hypot(pl.vel.x, pl.vel.z);\n const r = moveBody(\n pl.world,\n {\n x: pl.pos.x,\n y: pl.pos.y,\n z: pl.pos.z,\n hw: P.width / 2,\n h: P.height,\n },\n pl.vel.x * dt,\n pl.vel.y * dt,\n pl.vel.z * dt,\n {\n step: pl.onGround && !pl.flying ? P.stepHeight : 0,\n sneak: pl._sneaking,\n onGround: pl.onGround,\n // в кожаных ботинках снег-порох держит (присев — проваливаешься, как в Minecraft)\n extraSolid:\n !pl.inPowderSnow && !pl._sneaking && pl.leatherBoots?.()\n ? isPowderSnow\n : undefined,\n },\n );\n pl.pos.x += r.dx;\n pl.pos.y += r.dy;\n pl.pos.z += r.dz;\n\n if (r.hitDown) {\n // на острый сталагмит больнее: урон вдвое и уже с 2 блоков (как в Minecraft)\n const fx = Math.floor(pl.pos.x),\n fy = Math.floor(pl.pos.y + 0.01),\n fz = Math.floor(pl.pos.z);\n const spike =\n pl.world.getBlock(fx, fy, fz) === B.pointed_dripstone ||\n pl.world.getBlock(fx, fy - 1, fz) === B.pointed_dripstone;\n const safe = spike ? 2 : P.fallSafe + pl.jumpBoost;\n if (wasAirborne && pl._fallDist > safe && pl.mode === \"survival\")\n pl.damage(\n Math.round(pl._fallDist - safe) * (spike ? 2 : 1),\n spike ? \"stalagmite\" : \"fall\",\n );\n pl._fallDist = 0;\n pl.onGround = true;\n pl.vel.y = 0;\n } else {\n pl.onGround = false;\n if (r.hitUp) pl.vel.y = 0; // потолок\n // снижение в полёте — не падение: выключив полёт в воздухе, получили бы урон за весь спуск\n if (pl.flying) pl._fallDist = 0;\n else if (prevVy < 0) pl._fallDist += -prevVy * dt;\n }\n if (r.hitX) pl.vel.x = 0;\n if (r.hitZ) pl.vel.z = 0;\n pl._hitWall = r.hitX || r.hitZ;\n // на крыльях врезался в стену: урон по потерянной скорости, как в Minecraft (блоки за тик)\n if (pl.gliding && pl._hitWall && pl.mode === \"survival\") {\n const dmg = ((hv0 - Math.hypot(pl.vel.x, pl.vel.z)) / 20) * 10 - 3;\n if (dmg > 0) pl.damage(dmg, \"wall\");\n }\n}\n\nexport function touchesLadder(pl: Player): boolean {\n const half = P.width / 2 + 0.05;\n for (let y = Math.floor(pl.pos.y); y <= Math.floor(pl.pos.y + 1); y++)\n for (\n let z = Math.floor(pl.pos.z - half);\n z <= Math.floor(pl.pos.z + half);\n z++\n )\n for (\n let x = Math.floor(pl.pos.x - half);\n x <= Math.floor(pl.pos.x + half);\n x++\n ) {\n // лезут по лестнице и по строительным лесам (systems/Scaffold)\n const sh = BLOCKS[pl.world.getBlock(x, y, z)]?.shape;\n if (sh === \"ladder\" || sh === \"scaffold\") return true;\n }\n return false;\n}\n"
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
"path": "player/body/save.ts",
|
|
700
|
+
"content": "import { clampRep } from \"../../systems/Reputation\";\nimport type { EffectId } from \"../../types\";\nimport type { PlayerSaveData } from \"./types\";\nimport { MAX_AIR, P } from \"./shared\";\nimport type { Player } from \"../Player\";\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\nexport function serialize(pl: Player) {\n return {\n pos: pl.pos.toArray(),\n yaw: pl.yaw,\n pitch: pl.pitch,\n health: pl.health,\n food: pl.food,\n saturation: pl.saturation,\n mode: pl.mode,\n flying: pl.flying,\n spawn: pl.spawnPoint.toArray(),\n xpLevel: pl.xpLevel,\n xpProgress: pl.xpProgress,\n xpTotal: pl.xpTotal,\n bedPos: pl.bedPos,\n anchorPos: pl.anchorPos,\n villageRep: pl.villageRep,\n anchorDim: pl.anchorDim,\n lodestone: pl.lodestone,\n effects: [...pl.effects].map(([id, e]) => ({\n id,\n level: e.level,\n time: e.time,\n })),\n enchSeed: pl.enchSeed,\n };\n}\n\nexport function deserialize(pl: Player, 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 pl.pos.fromArray(d.pos);\n pl.yaw = Number.isFinite(d.yaw) ? d.yaw : 0;\n pl.pitch = Number.isFinite(d.pitch) ? d.pitch : 0;\n // состояние тела прошлого мира не переносится: горение, воздух, крылья, транспорт, падение\n pl.riding = null;\n pl.gliding = false;\n pl._boostT = 0;\n pl.fireT = 0;\n pl._burnT = 0;\n pl.air = MAX_AIR;\n pl._drownT = 0;\n pl._fallDist = 0;\n pl.inPowderSnow = pl.headInPowderSnow = pl._wasInPowder = false;\n // сейв, записанный на экране смерти: здоровье 0 при dead=false убило бы первым же уроном\n pl.health =\n Number.isFinite(d.health) && d.health > 0\n ? Math.min(P.maxHealth, d.health)\n : P.maxHealth;\n pl.mode = d.mode === \"creative\" ? \"creative\" : \"survival\";\n pl.flying = d.flying;\n pl.food = d.food ?? P.maxFood;\n pl.saturation = d.saturation ?? 5;\n pl.exhaustion = 0;\n pl.vel.set(0, 0, 0);\n if (Array.isArray(d.spawn) && d.spawn.length === 3)\n pl.spawnPoint.fromArray(d.spawn);\n else pl.spawnPoint.copy(pl.pos);\n pl.xpLevel = d.xpLevel ?? 0;\n pl.xpProgress = d.xpProgress ?? 0;\n pl.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 pl.anchorPos = cell(d.anchorPos);\n pl.villageRep = clampRep(Number(d.villageRep) || 0);\n pl.anchorDim = typeof d.anchorDim === \"string\" ? d.anchorDim : null;\n pl.lodestone =\n d.lodestone && typeof d.lodestone === \"object\" ? { ...d.lodestone } : null;\n pl.bedPos =\n Array.isArray(d.bedPos) && d.bedPos.length === 3\n ? [d.bedPos[0]!, d.bedPos[1]!, d.bedPos[2]!]\n : null;\n pl.dead = false;\n if (typeof d.enchSeed === \"number\") pl.enchSeed = d.enchSeed;\n pl.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 pl.effects.set(e.id as EffectId, {\n level: e.level || 1,\n time: e.time,\n });\n}\n"
|
|
701
|
+
},
|
|
702
|
+
{
|
|
703
|
+
"path": "player/body/shared.ts",
|
|
704
|
+
"content": "import { CONFIG } from \"../../config\";\n\n/** Запас воздуха под водой, сек (300 тиков Minecraft). */\nexport const MAX_AIR = 15;\n\nexport const P = CONFIG.player;\n"
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
"path": "player/body/types.ts",
|
|
708
|
+
"content": "export type PlayerMode = \"survival\" | \"creative\";\n\n/** Форма persist-блоба игрока (см. serialize/deserialize). */\nexport interface 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"
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
"path": "player/body/vitals.ts",
|
|
712
|
+
"content": "import * as THREE from \"three\";\nimport { rules } from \"../../systems/Difficulty\";\nimport { events } from \"../../core/EventBus\";\nimport { B } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { MAX_AIR, P } from \"./shared\";\nimport type { Player } from \"../Player\";\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\nexport function damage(pl: Player, amount: number, cause: string): void {\n if (pl.mode === \"creative\" || pl.dead || amount <= 0) return;\n // Огнестойкость: огонь и лава не ранят\n if (\n (cause === \"fire\" || cause === \"lava\") &&\n pl.effects.has(\"fire_resistance\")\n )\n return;\n pl.lastCause = cause;\n // броня гасит удары, огонь, лаву и взрывы — формула Minecraft; падение и голод — нет\n if (ARMORED_CAUSES.has(cause) && pl.armorStats) {\n const { points, toughness } = pl.armorStats();\n if (points > 0) {\n pl.onArmorHit?.(amount);\n amount = armorReduce(amount, points, toughness);\n }\n }\n // Защита: −4% за уровень по всей броне, не больше 80% (пустоту и голод не гасит)\n const prot = pl.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 = pl.effects.get(\"resistance\")?.level ?? 0;\n if (res > 0 && cause !== \"void\") amount *= Math.max(0, 1 - 0.2 * res);\n pl.health = Math.max(0, pl.health - amount);\n events.emit(\"playerHurt\", { amount, health: pl.health, cause });\n if (pl.health <= 0) {\n // тотем жизни в руке: 1 здоровья, Регенерация II и Огнестойкость вместо смерти (пустоту не спасает)\n if (cause !== \"void\" && pl.tryTotem?.()) {\n pl.health = 1;\n pl.clearEffects();\n pl.addEffect(\"regeneration\", 45, 2);\n pl.addEffect(\"fire_resistance\", 40);\n events.emit(\"totemUsed\");\n return;\n }\n die(pl);\n }\n}\n\nexport function addExhaustion(pl: Player, n: number): void {\n // В креативе голода нет и tickHunger истощение не списывает — оно копилось бы и при возврате\n // в выживание съедало еду пачкой за один тик.\n if (pl.mode === \"creative\") return;\n pl.exhaustion += n;\n}\n\nexport function tickHazards(pl: Player, dt: number): void {\n if (pl.dead) return;\n pl._tickEffects(dt);\n // воздух: голова под водой тратит запас (Подводное дыхание — нет), кончился — тонем\n if (\n pl.headInWater &&\n !pl.headInLava &&\n pl.mode === \"survival\" &&\n !pl.effects.has(\"water_breathing\")\n ) {\n pl.air = Math.max(0, pl.air - dt);\n if (pl.air <= 0) {\n pl._drownT -= dt;\n if (pl._drownT <= 0) {\n pl._drownT = 1;\n damage(pl, 2, \"drown\");\n }\n }\n } else {\n pl.air = Math.min(MAX_AIR, pl.air + dt * 5);\n pl._drownT = 0;\n }\n // горящий костёр жжёт стоящего на нём: 1 урон в секунду (костёр душ — 2), но не поджигает\n pl._campT = Math.max(0, pl._campT - dt);\n const camp =\n B.campfire !== undefined && typeof pl.world?.getBlock === \"function\"\n ? pl.world.getBlock(\n Math.floor(pl.pos.x),\n Math.floor(pl.pos.y),\n Math.floor(pl.pos.z),\n )\n : -1;\n const soulCamp = camp === B.soul_campfire;\n if (\n pl._campT <= 0 &&\n (camp === B.campfire || soulCamp) &&\n !pl.effects.has(\"fire_resistance\")\n ) {\n pl._campT = 1;\n damage(pl, soulCamp ? 2 : 1, \"fire\");\n }\n // кактус колет: 1 урон каждые полсекунды, пока касаешься\n pl._cactusT = Math.max(0, pl._cactusT - dt);\n if (pl._cactusT <= 0 && pl._touchesCactus()) {\n pl._cactusT = 0.5;\n damage(pl, 1, \"cactus\");\n }\n // пустота под Пределом (и под миром): 4 урона каждые полсекунды, даже в креативе — как в Minecraft\n if (pl.pos.y < -48) {\n pl._voidT -= dt;\n if (pl._voidT <= 0) {\n pl._voidT = 0.5;\n const was = pl.mode;\n pl.mode = \"survival\";\n damage(pl, 4, \"void\");\n pl.mode = was;\n }\n return;\n }\n if (pl.inLava) {\n pl._lavaT -= dt;\n if (pl._lavaT <= 0) {\n pl._lavaT = 0.5;\n damage(pl, 4, \"lava\");\n }\n pl.fireT = 15;\n } else {\n pl._lavaT = 0;\n if (pl.inFire) pl.fireT = Math.max(pl.fireT, 8);\n else if (pl.inWater) pl.fireT = 0;\n }\n if (pl.fireT > 0 && pl.mode !== \"creative\") {\n pl.fireT = Math.max(0, pl.fireT - dt);\n pl._burnT -= dt;\n if (pl._burnT <= 0) {\n pl._burnT = 1;\n damage(pl, 1, \"fire\");\n }\n } else {\n pl.fireT = 0;\n pl._burnT = 0;\n }\n}\n\nexport function tickHunger(pl: Player, dt: number): void {\n if (pl.mode === \"creative\" || pl.dead) return;\n addExhaustion(pl, P.exhaustIdle * dt); // медленная пассивная трата\n while (pl.exhaustion >= P.exhaustionPerFood) {\n pl.exhaustion -= P.exhaustionPerFood;\n if (pl.saturation > 0) pl.saturation = Math.max(0, pl.saturation - 1);\n else pl.food = Math.max(0, pl.food - 1);\n }\n\n // сытость лечит; лечение «стоит» истощения\n if (pl.food >= P.regenFoodThreshold && pl.health < P.maxHealth) {\n pl._regenTimer += dt;\n if (pl._regenTimer >= P.regenInterval) {\n pl._regenTimer = 0;\n pl.health = Math.min(P.maxHealth, pl.health + 1);\n addExhaustion(pl, P.exhaustRegen);\n events.emit(\"playerHeal\", { health: pl.health });\n }\n } else pl._regenTimer = 0;\n\n // голодание: до какого здоровья добивает — по сложности (systems/Difficulty);\n // на мирной голода нет вовсе, на высокой голод убивает\n const floor = rules().starveFloor;\n if (pl.food <= 0 && floor !== null) {\n pl._starveTimer += dt;\n if (pl._starveTimer >= P.starveInterval) {\n pl._starveTimer = 0;\n if (pl.health > floor) damage(pl, 1, \"starve\");\n }\n } else pl._starveTimer = 0;\n\n events.emit(\"foodChanged\", { food: pl.food });\n}\n\nexport function eat(pl: Player, itemName: string): boolean {\n const it = ITEMS[itemName];\n // плод эхоцвета едят и сытым — ради переноса\n if (!it?.food || (pl.food >= P.maxFood && itemName !== \"echo_fruit\"))\n return false;\n pl.food = Math.min(P.maxFood, pl.food + it.food);\n pl.saturation = Math.min(\n pl.food,\n pl.saturation + (it.saturation ?? it.food * 0.6),\n );\n for (const e of it.eatEffects ?? [])\n pl.addEffect(e.id, e.seconds, e.level ?? 1);\n events.emit(\"playerAte\", { item: itemName });\n if (itemName === \"echo_fruit\") pl.echoHop();\n events.emit(\"foodChanged\", { food: pl.food });\n return true;\n}\n\nexport function die(pl: Player): void {\n if (pl.dead) return;\n pl.dead = true;\n pl.fireT = 0;\n pl.air = MAX_AIR;\n pl.vel.set(0, 0, 0);\n // из транспорта — вон: иначе после возрождения update() ставил игрока обратно к вагонетке/лодке/\n // лошади на месте смерти\n if (pl.riding) {\n pl.riding.rider = false;\n pl.riding = null;\n }\n pl.gliding = false;\n pl._boostT = 0;\n // эффекты зелий смерть снимает, как в Minecraft (яд и тление не должны пережить возрождение)\n if (pl.effects.size) pl.clearEffects();\n events.emit(\"playerDied\", { cause: pl.lastCause });\n}\n\nexport function respawn(pl: Player, at: THREE.Vector3): void {\n clearTimeout(pl._respawnT);\n pl.pos.copy(at);\n pl.vel.set(0, 0, 0);\n pl.health = P.maxHealth;\n pl.food = P.maxFood;\n pl.saturation = 5;\n pl.exhaustion = 0;\n pl.fireT = 0;\n pl._fallDist = 0;\n pl.dead = false;\n events.emit(\"playerRespawn\");\n}\n\nexport function resetForNewWorld(pl: Player): void {\n pl.riding = null;\n pl.gliding = false;\n pl._boostT = 0;\n pl.effects.clear();\n pl.fireT = 0;\n pl._burnT = 0;\n pl.air = MAX_AIR;\n pl._drownT = 0;\n pl._fallDist = 0;\n pl.bedPos = null;\n pl.anchorPos = null;\n pl.anchorDim = null;\n pl.lodestone = null;\n pl.villageRep = 0;\n pl.xpLevel = pl.xpProgress = pl.xpTotal = 0;\n pl._xpFrac = 0;\n pl.inPowderSnow = pl.headInPowderSnow = pl._wasInPowder = false;\n events.emit(\"effectsChanged\", {});\n events.emit(\"xpChanged\");\n}\n"
|
|
713
|
+
},
|
|
546
714
|
{
|
|
547
715
|
"path": "player/Glide.ts",
|
|
548
716
|
"content": "// Полёт на Крыльях Предела — физика элитр Minecraft, один тик (1/20 с). Формулы Minecraft работают в\n// блоках за тик и с pitch «вниз — плюс», поэтому скорость пересчитывается туда и обратно.\n// Ракета толкает вдоль взгляда, пока горит (Player.boost).\n\nexport const GLIDE_TICK = 0.05;\n\n/** Один тик планирования: меняет vel (блоки/с) по взгляду (pitch вверх — плюс, yaw как у игрока). */\nexport function glideTick(\n vel: { x: number; y: number; z: number },\n pitch: number,\n yaw: number,\n boost: boolean,\n): void {\n const p = -pitch; // Minecraft: взгляд вниз — положительный\n const cosP = Math.cos(p);\n const lx = -Math.sin(yaw) * cosP,\n ly = -Math.sin(p),\n lz = -Math.cos(yaw) * cosP;\n let vx = vel.x / 20,\n vy = vel.y / 20,\n vz = vel.z / 20;\n const hlook = cosP;\n const hvel = Math.hypot(vx, vz);\n const cos2 = cosP * cosP;\n vy += -0.08 + cos2 * 0.06;\n if (vy < 0 && hlook > 0) {\n // падение переходит в скорость вперёд\n const k = vy * -0.1 * cos2;\n vy += k;\n vx += (lx * k) / hlook;\n vz += (lz * k) / hlook;\n }\n if (p < 0 && hlook > 0) {\n // взгляд вверх: скорость вперёд уходит в набор высоты\n const k = hvel * -Math.sin(p) * 0.04;\n vy += k * 3.2;\n vx -= (lx * k) / hlook;\n vz -= (lz * k) / hlook;\n }\n if (hlook > 0) {\n vx += ((lx / hlook) * hvel - vx) * 0.1;\n vz += ((lz / hlook) * hvel - vz) * 0.1;\n }\n vx *= 0.99;\n vy *= 0.98;\n vz *= 0.99;\n if (boost) {\n vx += lx * 0.1 + (lx * 1.5 - vx) * 0.5;\n vy += ly * 0.1 + (ly * 1.5 - vy) * 0.5;\n vz += lz * 0.1 + (lz * 1.5 - vz) * 0.5;\n }\n vel.x = vx * 20;\n vel.y = vy * 20;\n vel.z = vz * 20;\n}\n"
|
|
@@ -553,7 +721,43 @@
|
|
|
553
721
|
},
|
|
554
722
|
{
|
|
555
723
|
"path": "player/Interaction.ts",
|
|
556
|
-
"content": "// Взаимодействие с миром. Разделено на две половины:\n// frame() — «клиентская»: raycast из камеры, рамка выделения, перевод\n// состояния ввода (зажатая ЛКМ, цель) в интенты;\n// tick() — «серверная»: выгребает очередь интентов, валидирует\n// (дистанция, наличие предмета, кулдаун) и исполняет.\n// В этапе 2 tick()-половина живёт только у хоста; frame()-половина у всех.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentMsg, type IntentQueue } from \"../core/Intents\";\nimport { BLOCKS, B, isWaterSource, packWater } from \"../registry/Blocks\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { itemUseFor } from \"../world/itemUse\";\nimport { isArrow, tippedEffect } from \"../systems/TippedArrow\";\nimport { enchantDrops, enchLevel } from \"../systems/Enchant\";\nimport { ITEMS } from \"../registry/Items\";\nimport { selectionBoxes } from \"../registry/shapes\";\nimport { raycastBlocks, type RaycastHit } from \"../world/Raycast\";\nimport { BEHAVIOURS } from \"../world/behaviours/index\";\nimport { breakSeconds, canHarvest, dropsOf } from \"../systems/Mining\";\nimport { fishLoot } from \"../systems/Fishing\";\nimport { harvestHive, isHive } from \"../world/Hive\";\nimport { planPlacement } from \"./Placement\";\nimport { FISH_BUCKETS } from \"../registry/items/treasure\";\nimport { maceSmash } from \"../registry/items/trial\";\nimport { bundleItems } from \"../systems/Bundle\";\nimport { barterRoll } from \"../systems/Barter\";\nimport { WOLF_ARMOR } from \"../registry/items/armadillo\";\nimport { CROSSBOW_CHARGE, TRIDENT_CHARGE } from \"../registry/items/ranged\";\nimport { CURE_ITEM, CURE_SECONDS } from \"../systems/Zombify\";\n\n/** Кого доят ведром. */\nconst MILKABLE = new Set([\"cow\", \"goat\", \"mooshroom\"]);\nimport { PLAYER_SHOT } from \"../entities/Fireball\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"./Player\";\nimport type { Input } from \"./Input\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EntityManager } from \"../entities/EntityManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Mob } from \"../entities/Mob\";\nimport type { ItemStack } from \"../types\";\n\n/** Ближайшая сущность под прицелом. */\ntype EntityHit = ReturnType<EntityManager[\"raycast\"]>;\n\n/** Сила лука по времени натяжения — формула Minecraft (полная за 1 с). */\nfunction bowPower(seconds: number): number {\n const f = seconds / 1;\n return Math.min(1, (f * f + 2 * f) / 3);\n}\n/** Состояние ломания блока (тиковая половина). */\ninterface BreakInfo {\n x: number;\n y: number;\n z: number;\n id: number;\n /** Доля прочности, сбитая до сих пор (0..1). */\n progress: number;\n /** Секунд ломания на старте — порог показа трещин. */\n total: number;\n}\n/** Накопленный урон по блоку: доля прочности (0..1) + таймер заживления. */\ninterface DamageInfo {\n id: number;\n progress: number;\n heal: number;\n}\n\nexport class Interaction {\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n intents: IntentQueue;\n entities: EntityManager;\n target: RaycastHit | null;\n targetEntity: EntityHit;\n breaking: BreakInfo | null;\n _sentBreakKey: string | null;\n _placeCooldown: number;\n _attackCooldown: number;\n eating: { item: string; time: number } | null;\n _sentEat: string | null;\n damage: Map<string, DamageInfo>;\n highlight: THREE.LineSegments;\n crackMat: THREE.MeshBasicMaterial;\n crackMesh: THREE.Mesh;\n atlas: TextureAtlas;\n _crackStage: number;\n /** Кадровая половина: сколько ждать до следующего замаха, пока ЛКМ зажата по блоку/воздуху. */\n _swingTimer = 0;\n /** ПКМ по двери/люку срабатывает раз на нажатие — иначе удержание хлопало бы дверью. */\n _usedThisPress = false;\n /** Исполнитель команд консоли (подключает Game): интент Command — на тике, как весь ввод. */\n onCommand: ((text: string) => void) | null = null;\n /** Можно ли сменить режим (сетевая игра: только оператору); null — можно всегда. */\n mayToggleMode: (() => boolean) | null = null;\n /** Натяжение лука, сек (кадровая половина), и тянем ли его сейчас. */\n bowCharge = 0;\n _drawing = false;\n\n constructor(\n world: ChunkManager,\n player: Player,\n inventory: Inventory,\n intents: IntentQueue,\n scene: THREE.Scene,\n atlas: TextureAtlas,\n entities: EntityManager,\n ) {\n this.world = world;\n this.player = player;\n this.inventory = inventory;\n this.intents = intents;\n this.entities = entities;\n this.target = null; // текущий блок под прицелом (кадровая половина)\n this.targetEntity = null;\n\n // Состояние ломания (тиковая половина — авторитетно)\n this.breaking = null;\n this._sentBreakKey = null;\n this._placeCooldown = 0;\n this._attackCooldown = 0;\n this.eating = null; // {item, time} — процесс поедания (тиковая половина)\n this._sentEat = null; // защита от повторной отправки EatStart за удержание\n // Накопленный урон по блокам: \"x,y,z\" -> {id, progress, heal}.\n // Сохраняется при отпускании ЛКМ и медленно «заживает» в простое.\n this.damage = new Map();\n // блок сменился (сломан/заменён/затоплен) — забыть его урон\n events.on(\"blockChanged\", ({ x, y, z }) =>\n this.damage.delete(x + \",\" + y + \",\" + z),\n );\n\n // рамка выделения блока (масштабируется под коробки формы)\n const boxGeo = new THREE.BoxGeometry(1.002, 1.002, 1.002);\n this.highlight = new THREE.LineSegments(\n new THREE.EdgesGeometry(boxGeo),\n new THREE.LineBasicMaterial({\n color: 0x111111,\n transparent: true,\n opacity: 0.6,\n }),\n );\n this.highlight.visible = false;\n scene.add(this.highlight);\n\n // decal трещин: чуть увеличенный куб с прозрачной текстурой стадии\n this.crackMat = new THREE.MeshBasicMaterial({\n map: atlas.texture.clone(),\n transparent: true,\n depthWrite: false,\n polygonOffset: true,\n polygonOffsetFactor: -1,\n polygonOffsetUnits: -2,\n });\n this.crackMat.map!.magFilter = this.crackMat.map!.minFilter =\n THREE.NearestFilter;\n this.crackMat.map!.generateMipmaps = false;\n this.crackMesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.004, 1.004, 1.004),\n this.crackMat,\n );\n this.crackMesh.visible = false;\n this.crackMesh.renderOrder = 2;\n scene.add(this.crackMesh);\n this.atlas = atlas;\n this._crackStage = -1;\n }\n\n // ---- кадровая половина ------------------------------------------------------\n\n frame(input: Input): void {\n const eye = this.player.eyePos;\n const dir = this.player.lookDir();\n const hit = raycastBlocks(this.world, eye, dir, CONFIG.player.reach);\n // сущность ближе блока?\n // сущности достаются сквозь траву, снежный слой, огонь (заменяемые блоки): иначе в лугу не\n // погладить волка и не ударить зомби. Вагонетка — своим лучом; моб перехватывает, если ближе\n const blockCap =\n hit && !BLOCKS[hit.id]?.replaceable ? hit.dist : CONFIG.player.reach;\n const cartHit = this.entities.raycastRide(eye, dir, blockCap);\n const entHit = this.entities.raycast(\n eye,\n dir,\n cartHit ? cartHit.t : blockCap,\n );\n const cart = entHit ? null : (cartHit?.cart ?? null);\n this.targetEntity = entHit;\n this.target = entHit || cart ? null : hit;\n this._placeHighlight();\n\n // ЛКМ: по сущности — атака (повторяется по кулдауну), по блоку — ломание (удержание)\n const canAct = !input.uiOpen && !this.player.dead;\n if (input.leftDown && canAct) {\n if (entHit || cart) {\n // Замах по сущности шлёт тиковая половина (_attack) — ровно на каждый состоявшийся удар.\n this.intents.push(Intent.Attack, { entityId: (entHit ?? cart)!.id });\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n } else {\n if (this.target) {\n const k = this.target.x + \",\" + this.target.y + \",\" + this.target.z;\n if (this._sentBreakKey !== k) {\n this.intents.push(Intent.BreakStart, {\n x: this.target.x,\n y: this.target.y,\n z: this.target.z,\n });\n this._sentBreakKey = k;\n }\n } else if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n // по блоку (и в воздух) рука машет непрерывно, пока ЛКМ зажата\n if (this._swingTimer <= 0) {\n events.emit(\"armSwing\");\n this._swingTimer = CONFIG.player.swingTime;\n }\n }\n } else {\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n this._swingTimer = 0; // следующее нажатие — замах сразу\n }\n\n // ПКМ: приоритет — существо, которое можно покормить или остричь > блок с поведением (верстак,\n // дверь; присев — мимо него) > лук (натянуть) > еда > постановка\n const held = this.inventory.heldItem();\n const heldFood = held && (ITEMS[held.item]?.food ?? 0) > 0;\n const heldBow = held?.item === \"bow\";\n const heldCrossbow = held?.item === \"crossbow\";\n const crossbowLoaded = heldCrossbow && !!held?.tag?.loaded;\n const heldTrident = held?.item === \"trident\";\n const heldRod = held?.item === \"fishing_rod\";\n // ножницы и пузырёк по улью — собрать мёд (раньше ведра: пузырёк иначе искал бы воду)\n const hiveHarvest =\n !!this.target &&\n !!held &&\n isHive(this.target.id) &&\n (held.item === \"shears\" || held.item === \"glass_bottle\");\n const heldBoat = !!held?.item.endsWith(\"_boat\");\n const t = this.target;\n // забор «используется», когда у игрока есть животные на поводке — ПКМ привяжет их к нему\n const usable =\n !!t &&\n (!!BEHAVIOURS.get(t.id)?.use ||\n (BLOCKS[t.id]?.shape === \"fence\" && this._leashedHere().length > 0)) &&\n !input.sneak;\n // предмет со своим действием по блоку (мотыга — вспахать, костная мука — удобрить)\n // (пузырёк воды — только по земле, иначе его пьют)\n const itemUse =\n !!t &&\n !!held &&\n !usable &&\n !!itemUseFor(held.item) &&\n (held.item !== \"potion_water\" || t.id === B.dirt);\n // семена, морковь, картофель по верху пашни — посадить, даже если голоден (как в Minecraft)\n const heldBlock = held ? ITEMS[held.item]?.blockId : null;\n const plantHere =\n !!t &&\n typeof heldBlock === \"number\" &&\n BLOCKS[heldBlock]?.shape === \"crop\" &&\n t.id === B.farmland &&\n t.ny === 1;\n // Око Предела: по рамке — вставить (itemUse), куда угодно ещё — бросить к цитадели\n const throwEye = held?.item === \"rim_eye\" && !(t && t.id === B.rim_frame);\n // житель: ПКМ с чем угодно в руке — торговля (присев — мимо)\n const tradable = !!entHit && !!entHit.def.trades && !input.sneak;\n // ручные и приручаемые: лошадь (сесть, седло), волк и кошка (приручить едой, сидеть/встать)\n const petAct =\n !!entHit &&\n !input.sneak &&\n (!!entHit.def.rideable ||\n (!!entHit.def.tameItems &&\n (entHit.tamed ||\n (!!held && entHit.def.tameItems.includes(held.item)))));\n const feedable =\n !!cart ||\n petAct ||\n (!!entHit &&\n (tradable ||\n !!entHit.def.collector ||\n entHit.leashed ||\n (!!held &&\n held.item === \"lead\" &&\n entHit.def.category !== \"hostile\") ||\n (!!entHit.def.chestSlots &&\n ((input.sneak && entHit.items.length > 0) ||\n held?.item === \"chest\")) ||\n (!!held &&\n (held.item === \"name_tag\" ||\n held.item === \"shears\" ||\n (held.item === \"bucket\" && MILKABLE.has(entHit.def.name)) ||\n (held.item === \"bowl\" && entHit.def.name === \"mooshroom\") ||\n (held.item === \"gold_ingot\" && !!entHit.def.barters) ||\n (held.item === \"cookie\" && entHit.def.name === \"parrot\") ||\n (held.item.endsWith(\"_dye\") && !!entHit.def.dyeable) ||\n (held.item === \"brush\" && !!entHit.def.rollsUp) ||\n (held.item === CURE_ITEM && !!entHit.def.curable) ||\n (held.item === \"water_bucket\" && !!entHit.def.needsWater) ||\n !!entHit.def.temptItems?.includes(held.item)))));\n const heldDrink = !!held && !!ITEMS[held.item]?.drink;\n const bucketMode =\n held?.item === \"bucket\" || held?.item === \"glass_bottle\"\n ? \"fill\"\n : held?.item === \"water_bucket\" ||\n held?.item === \"lava_bucket\" ||\n held?.item === \"powder_snow_bucket\" ||\n (!!held && held.item in FISH_BUCKETS)\n ? \"empty\"\n : null;\n let eatingNow = false;\n let drawing = false;\n let blocking = false;\n if (input.rightDown && canAct && feedable) {\n if (!this._usedThisPress) {\n this.intents.push(Intent.InteractEntity, {\n entityId: (cart ?? entHit)!.id,\n });\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n }\n } else if (input.rightDown && canAct) {\n if (usable) {\n if (!this._usedThisPress && this._placeCooldown <= 0) {\n this.intents.push(Intent.UseBlock, { x: t.x, y: t.y, z: t.z });\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n this._placeCooldown = 0.25;\n }\n } else if (throwEye) {\n if (!this._usedThisPress) {\n this.intents.push(Intent.ThrowEye, {});\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n }\n } else if (hiveHarvest) {\n if (!this._usedThisPress) {\n const h = this.target!;\n this.intents.push(Intent.UseItem, {\n x: h.x,\n y: h.y,\n z: h.z,\n nx: h.nx,\n ny: h.ny,\n nz: h.nz,\n });\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n }\n } else if (heldRod) {\n // удочка: забросить или подсечь (Interaction._fish)\n if (!this._usedThisPress) {\n this.intents.push(Intent.Fish, {});\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n }\n } else if (held?.item === \"map\") {\n // пустая карта — завести карту этого участка (Game.createMap)\n if (!this._usedThisPress) {\n this.intents.push(Intent.UseHeld, {});\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n }\n } else if (held?.item === \"rocket\") {\n // ракета: в полёте на крыльях — рывок вдоль взгляда, на земле — фейерверк с той клетки,\n // куда смотрим (нет цели — из-под ног), интент UseHeld\n if (!this._usedThisPress) {\n const h = this.target;\n this.intents.push(\n Intent.UseHeld,\n h ? { x: h.x + h.nx, y: h.y + h.ny, z: h.z + h.nz } : {},\n );\n this._usedThisPress = true;\n }\n } else if (held?.item === \"bundle\") {\n // мешок в мире: ПКМ — высыпать всё (интент UseHeld)\n if (!this._usedThisPress) {\n this.intents.push(Intent.UseHeld, {});\n this._usedThisPress = true;\n }\n } else if (held?.item === \"snowball\" || held?.item === \"rim_pearl\") {\n // снежок: бросок (интент UseHeld), не чаще раза в 0.25 с\n if (!this._usedThisPress && this._placeCooldown <= 0) {\n this.intents.push(Intent.UseHeld, {});\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n this._placeCooldown = 0.25;\n }\n } else if (held?.item === \"wind_charge\") {\n // заряд ветра: бросок (интент UseHeld), не чаще раза в 0.5 с\n if (!this._usedThisPress && this._placeCooldown <= 0) {\n this.intents.push(Intent.UseHeld, {});\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n this._placeCooldown = 0.5;\n }\n } else if (heldBoat) {\n // лодка — на воду под прицелом (обычный луч воду пропускает) или на блок\n const lh = raycastBlocks(\n this.world,\n this.player.eyePos,\n this.player.lookDir(),\n CONFIG.player.reach,\n true,\n );\n if (lh && !this._usedThisPress) {\n this.intents.push(Intent.UseItem, {\n x: lh.x,\n y: lh.y,\n z: lh.z,\n nx: lh.nx,\n ny: lh.ny,\n nz: lh.nz,\n });\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n }\n } else if (itemUse) {\n // кисть чистит, пока ПКМ зажата (взмах раз в 0.5 с); остальные предметы — раз на нажатие\n const brush = held!.item === \"brush\";\n if (brush ? this._placeCooldown <= 0 : !this._usedThisPress) {\n this.intents.push(Intent.UseItem, {\n x: t.x,\n y: t.y,\n z: t.z,\n nx: t.nx,\n ny: t.ny,\n nz: t.nz,\n });\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n if (brush) this._placeCooldown = 0.5;\n }\n } else if (held?.item === \"shield\") {\n blocking = true; // щит: пока держим ПКМ — прикрываемся\n } else if (crossbowLoaded) {\n // заряженный арбалет — выстрел одним нажатием\n if (!this._usedThisPress) {\n this.intents.push(Intent.ShootBow, { power: 1, charge: 0 });\n this._usedThisPress = true;\n }\n } else if (heldBow || heldCrossbow || heldTrident) {\n // лук натягиваем, арбалет заряжаем, трезубец замахиваемся — удержанием ПКМ\n drawing = true;\n } else if (bucketMode === \"fill\") {\n // пустое ведро — по жидкости под прицелом (обычный луч жидкость пропускает)\n const lh = raycastBlocks(\n this.world,\n this.player.eyePos,\n this.player.lookDir(),\n CONFIG.player.reach,\n true,\n );\n if (lh && BLOCKS[lh.id]!.liquid && !this._usedThisPress) {\n this.intents.push(Intent.Bucket, {\n mode: \"fill\",\n x: lh.x,\n y: lh.y,\n z: lh.z,\n });\n this._usedThisPress = true;\n }\n } else if (\n (bucketMode === \"empty\" || held?.item === \"flint_and_steel\") &&\n t\n ) {\n if (!this._usedThisPress) {\n this.intents.push(bucketMode ? Intent.Bucket : Intent.Ignite, {\n mode: \"empty\",\n x: t.x,\n y: t.y,\n z: t.z,\n nx: t.nx,\n ny: t.ny,\n nz: t.nz,\n });\n events.emit(\"armSwing\");\n this._usedThisPress = true;\n }\n } else if (heldDrink) {\n // питьё (зелья, молоко) — как еда, но и сытым, и в креативе\n eatingNow = true;\n if (this._sentEat !== held.item) {\n this.intents.push(Intent.EatStart, { item: held.item });\n this._sentEat = held.item;\n }\n } else if (\n heldFood &&\n !plantHere &&\n this.player.food < CONFIG.player.maxFood &&\n this.player.mode !== \"creative\"\n ) {\n eatingNow = true;\n if (this._sentEat !== held.item) {\n this.intents.push(Intent.EatStart, { item: held.item });\n this._sentEat = held.item;\n }\n } else if (t && this._placeCooldown <= 0) {\n this.intents.push(Intent.PlaceBlock, {\n x: t.x,\n y: t.y,\n z: t.z,\n nx: t.nx,\n ny: t.ny,\n nz: t.nz,\n hy: t.hy,\n yaw: this.player.yaw,\n pitch: this.player.pitch,\n });\n events.emit(\"armSwing\");\n this._placeCooldown = 0.25;\n }\n }\n // лук: отпустили — выстрел с силой натяжения (как в Minecraft: полная сила за 1 секунду)\n if (!drawing && this.bowCharge > 0) {\n if (this.bowCharge >= 0.1)\n this.intents.push(Intent.ShootBow, {\n power: bowPower(this.bowCharge),\n charge: this.bowCharge,\n });\n this.bowCharge = 0;\n }\n this._drawing = drawing;\n this.player.blocking = blocking;\n if (!eatingNow && this._sentEat) {\n this.intents.push(Intent.EatStop);\n this._sentEat = null;\n }\n if (!input.rightDown) {\n this._placeCooldown = 0;\n this._usedThisPress = false;\n }\n\n // визуализация трещин: активное ломание либо запомненный урон на блоке под прицелом\n let crack: { x: number; y: number; z: number; frac: number } | null = null;\n if (this.breaking && this.breaking.total > 0.06) {\n crack = {\n x: this.breaking.x,\n y: this.breaking.y,\n z: this.breaking.z,\n frac: this.breaking.progress,\n };\n } else if (this.target) {\n const d = this.damage.get(\n this.target.x + \",\" + this.target.y + \",\" + this.target.z,\n );\n const total = this._breakSecs(this.target.id);\n if (d && d.id === this.target.id && d.progress > 0 && total > 0.06)\n crack = {\n x: this.target.x,\n y: this.target.y,\n z: this.target.z,\n frac: d.progress,\n };\n }\n\n if (crack) {\n const stage = Math.min(9, Math.floor(crack.frac * 10));\n if (stage !== this._crackStage) {\n const uv = this.atlas.uv(\"crack\" + stage);\n // двигаем offset/repeat клона атласа на нужный тайл\n this.crackMat.map!.repeat.set(uv.u1 - uv.u0, uv.v1 - uv.v0);\n this.crackMat.map!.offset.set(uv.u0, uv.v0);\n this._crackStage = stage;\n }\n this.crackMesh.visible = true;\n this.crackMesh.position.set(crack.x + 0.5, crack.y + 0.5, crack.z + 0.5);\n } else {\n this.crackMesh.visible = false;\n this._crackStage = -1;\n }\n }\n\n /** Рамка выделения по коробкам формы блока под прицелом (у ступеней — ступени, у факела — факел). */\n _placeHighlight(): void {\n const t = this.target;\n if (!t) {\n this.highlight.visible = false;\n return;\n }\n let b = [0, 0, 0, 1, 1, 1];\n const def = BLOCKS[t.id]!;\n if (def.shape !== \"cube\") {\n const boxes = selectionBoxes(def, t.meta, {\n neighbor: (dx, dy, dz) =>\n this.world.getBlock(t.x + dx, t.y + dy, t.z + dz),\n });\n if (boxes.length) {\n b = [...boxes[0]!];\n for (const q of boxes)\n for (let i = 0; i < 3; i++) {\n b[i] = Math.min(b[i]!, q[i]!);\n b[i + 3] = Math.max(b[i + 3]!, q[i + 3]!);\n }\n }\n }\n this.highlight.visible = true;\n this.highlight.scale.set(b[3]! - b[0]!, b[4]! - b[1]!, b[5]! - b[2]!);\n this.highlight.position.set(\n t.x + (b[0]! + b[3]!) / 2,\n t.y + (b[1]! + b[4]!) / 2,\n t.z + (b[2]! + b[5]!) / 2,\n );\n }\n\n frameTimers(dt: number): void {\n if (this._placeCooldown > 0) this._placeCooldown -= dt;\n if (this._swingTimer > 0) this._swingTimer -= dt;\n if (this._drawing) this.bowCharge += dt;\n }\n\n // ---- тиковая половина (симуляция) --------------------------------------------\n\n tick(dt: number): void {\n if (this._attackCooldown > 0) this._attackCooldown -= dt;\n\n this.intents.drain((intent: IntentMsg) => {\n switch (intent.type) {\n case Intent.BreakStart:\n this._startBreak(intent);\n break;\n case Intent.BreakStop:\n this.breaking = null;\n break;\n case Intent.PlaceBlock:\n this._place(intent);\n break;\n case Intent.UseBlock:\n this._use(intent);\n break;\n case Intent.InteractEntity:\n this._interactEntity(intent);\n break;\n case Intent.ShootBow:\n this._shootBow(intent);\n break;\n case Intent.Bucket:\n this._bucket(intent);\n break;\n case Intent.Ignite:\n this._ignite(intent);\n break;\n case Intent.DropItem:\n this._dropItem(intent);\n break;\n case Intent.PickBlock:\n this._pickBlock();\n break;\n case Intent.UseItem:\n this._useItem(intent);\n break;\n case Intent.ThrowEye:\n this._throwEye();\n break;\n case Intent.Fish:\n this._fish();\n break;\n case Intent.UseHeld:\n if (this.inventory.heldItem()?.item === \"map\") this.onUseMap?.();\n else if (this.inventory.heldItem()?.item === \"rocket\") {\n // в полёте на крыльях — рывок, на земле — фейерверк вверх (gfx/Fireworks)\n const boosted = this.player.boost();\n if (!boosted) {\n const p = this.player.pos;\n events.emit(\"fireworkLaunch\", {\n x: Math.floor(intent.x ?? p.x),\n y: Math.floor(intent.y ?? p.y),\n z: Math.floor(intent.z ?? p.z),\n });\n }\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n events.emit(boosted ? \"rocketBoost\" : \"fireworkShot\");\n } else if (this.inventory.heldItem()?.item === \"wind_charge\")\n this._throwWindCharge();\n else if (this.inventory.heldItem()?.item === \"bundle\")\n this._emptyBundle();\n else if (this.inventory.heldItem()?.item === \"snowball\")\n this._throwSnowball();\n else if (this.inventory.heldItem()?.item === \"rim_pearl\")\n this._throwPearl();\n break;\n case Intent.EatStart:\n this._startEat(intent);\n break;\n case Intent.EatStop:\n this.eating = null;\n break;\n case Intent.Attack:\n this._attack(intent);\n break;\n case Intent.SelectSlot:\n if (intent.slot !== undefined) this.inventory.selectSlot(intent.slot);\n else this.inventory.scrollSlot(intent.delta);\n break;\n case Intent.ToggleMode:\n // сетевая игра: креатив — право оператора (Game подключает проверку)\n if (this.mayToggleMode?.() ?? true) this.player.toggleMode();\n else\n events.emit(\"chat\", { text: \"Смена режима — только у операторов\" });\n break;\n case Intent.Command:\n this.onCommand?.(String(intent.text ?? \"\"));\n break;\n }\n });\n\n // прогресс ломания\n let activeKey: string | null = null;\n if (this.breaking) {\n const { x, y, z } = this.breaking;\n if (this.world.getBlock(x, y, z) !== this.breaking.id) {\n this.damage.delete(x + \",\" + y + \",\" + z);\n this.breaking = null;\n } else {\n // доля прочности за тик; инструмент в руке мог смениться посреди ломания\n const secs = this._breakSecs(this.breaking.id);\n this.breaking.progress += secs > 0 ? dt / secs : 1;\n activeKey = x + \",\" + y + \",\" + z;\n // запоминаем урон (heal:0 — таймер заживления сбрасывается, пока бьём)\n this.damage.set(activeKey, {\n id: this.breaking.id,\n progress: this.breaking.progress,\n heal: 0,\n });\n if (this.breaking.progress >= 1) {\n this._finishBreak(x, y, z, this.breaking.id);\n this.damage.delete(activeKey);\n this.breaking = null;\n }\n }\n }\n\n // заживление урона по блокам, которые сейчас не ломают\n for (const [k, d] of this.damage) {\n if (k === activeKey) continue;\n d.heal += dt;\n if (d.heal < CONFIG.player.breakHealDelay) continue;\n d.progress -= dt * CONFIG.player.breakHealRate;\n if (d.progress <= 0) this.damage.delete(k);\n }\n\n // процесс поедания\n if (this.eating) {\n const held = this.inventory.heldItem();\n const drink = ITEMS[this.eating.item]?.drink;\n if (\n !held ||\n held.item !== this.eating.item ||\n (!drink && this.player.food >= CONFIG.player.maxFood)\n ) {\n this.eating = null;\n } else {\n this.eating.time += dt;\n events.emit(\"armSwing\"); // лёгкое подёргивание еды в руке\n if (this.eating.time >= CONFIG.player.eatTime) {\n if (drink) {\n this.player.drink(drink);\n // выпитое оставляет пузырёк или ведро (в креативе ничего не тратится)\n const rem = ITEMS[this.eating.item]?.remainder;\n if (this.player.mode !== \"creative\") {\n if (rem) this._giveHeld(rem);\n else this.inventory.consumeHeld(1);\n }\n } else if (this.player.eat(this.eating.item)) {\n // съеденный суп оставляет миску\n const rem = ITEMS[this.eating.item]?.remainder;\n if (rem && this.player.mode !== \"creative\") this._giveHeld(rem);\n else this.inventory.consumeHeld(1);\n }\n this.eating = null;\n }\n }\n }\n }\n\n _startEat({ item }: IntentMsg): void {\n const held = this.inventory.heldItem();\n if (!held || held.item !== item) return;\n const def = ITEMS[item];\n if (\n !def?.drink &&\n (!def?.food || this.player.food >= CONFIG.player.maxFood)\n )\n return;\n this.eating = { item, time: 0 };\n }\n\n _validDistance(x: number, y: number, z: number): boolean {\n const e = this.player.eyePos;\n const dx = x + 0.5 - e.x,\n dy = y + 0.5 - e.y,\n dz = z + 0.5 - e.z;\n return dx * dx + dy * dy + dz * dz <= (CONFIG.player.reach + 1) ** 2;\n }\n\n /** Описание предмета в руке (null — пустая рука). */\n _heldDef() {\n const held = this.inventory.heldItem();\n return held ? (ITEMS[held.item] ?? null) : null;\n }\n\n /** Секунды ломания блока тем, что сейчас в руке (в креативе — мгновенно). */\n _breakSecs(blockId: number): number {\n if (this.player.mode === \"creative\") return 0;\n // Спешка (маяк): +20% скорости добычи за уровень\n const haste = 1 + 0.2 * (this.player.effects.get(\"haste\")?.level ?? 0);\n // Усталость (древний глубинник): добыча в 3.3, 11 и 370 раз медленнее, как в Minecraft\n const fatigue = [1, 0.3, 0.09, 0.0027, 0.00081][\n Math.min(4, this.player.effects.get(\"mining_fatigue\")?.level ?? 0)\n ]!;\n return (\n breakSeconds(\n BLOCKS[blockId]!,\n this._heldDef(),\n enchLevel(this.inventory.heldItem(), \"efficiency\"),\n ) /\n (haste * fatigue)\n );\n }\n\n _startBreak({ x, y, z }: IntentMsg): void {\n if (!this._validDistance(x, y, z)) return;\n const id = this.world.getBlock(x, y, z);\n const def = BLOCKS[id]!;\n if (id === B.air || def.liquid || def.hardness === Infinity) return;\n const total = this._breakSecs(id);\n // возобновляем с запомненного урона (если это тот же блок), а не с нуля\n const stored = this.damage.get(x + \",\" + y + \",\" + z);\n const progress = stored && stored.id === id ? stored.progress : 0;\n this.breaking = { x, y, z, id, progress, total };\n if (total <= 0.05) {\n // мгновенное ломание\n this._finishBreak(x, y, z, id);\n this.damage.delete(x + \",\" + y + \",\" + z);\n this.breaking = null;\n }\n }\n\n _finishBreak(x: number, y: number, z: number, id: number): void {\n const def = BLOCKS[id]!;\n const meta = this.world.getMeta(x, y, z);\n this.world.setBlock(x, y, z, B.air!);\n events.emit(\"blockBreak\", { x, y, z, id, byPlayer: true });\n this.player.addExhaustion(CONFIG.player.exhaustBreak);\n // В креативе дропов нет; без своего инструмента нужного уровня — тоже (камень рукой ломается\n // впустую, как в Minecraft). Блоки, державшиеся за этот (факел, растение, верх двери),\n // обрушивает systems/BlockUpdates.\n if (this.player.mode !== \"creative\" && canHarvest(def, this._heldDef()))\n // Шёлковое касание и Удача меняют дроп (systems/Enchant)\n for (const d of enchantDrops(\n def,\n dropsOf(def, meta),\n this.inventory.heldItem(),\n ))\n if (ITEMS[d.item])\n this.entities.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n // износ: инструмент тратит 1 прочности на твёрдый блок, меч — 2 (как в Minecraft)\n const tool = this._heldDef();\n if (this.player.mode !== \"creative\" && def.hardness > 0 && tool?.durability)\n this.inventory.damageHeld(tool.toolType === \"sword\" ? 2 : 1);\n }\n\n /** ПКМ по мобу: ножницами — остричь овцу, едой — покормить (двое накормленных дадут детёныша). */\n _interactEntity({ entityId }: IntentMsg): void {\n const m = this.entities.entities.get(entityId);\n // сетевая игра: в ней (на нём) уже сидит другой игрок — место занято\n if (\n (m?.kind === \"minecart\" || m?.kind === \"boat\" || m?.kind === \"mob\") &&\n m.rider &&\n (this.player.riding as unknown) !== m\n )\n return;\n if (m?.kind === \"minecart\" || m?.kind === \"boat\") {\n // сесть в вагонетку или лодку; у вагонетки с сундуком или воронкой — открыть груз\n if (\n !m.dead &&\n m.pos.distanceToSquared(this.player.eyePos) <=\n (CONFIG.player.reach + 1) ** 2\n ) {\n if (m.kind === \"minecart\" && m.variant !== \"plain\") {\n if (m.items.length) events.emit(\"ui:openCart\", { entityId: m.id });\n return;\n }\n this.player.mount(m);\n events.emit(\"cartMount\", { entity: m });\n }\n return;\n }\n if (!m || m.kind !== \"mob\" || m.dead) return;\n if (\n m.pos.distanceToSquared(this.player.eyePos) >\n (CONFIG.player.reach + 1) ** 2\n )\n return;\n if (m.def.trades && !m.baby) {\n events.emit(\"ui:openTrade\", { entityId: m.id });\n return;\n }\n const held = this.inventory.heldItem();\n // золотое яблоко зомби-жителю — начать лечение (systems/Zombify); дважды не лечат\n if (m.def.curable && held?.item === CURE_ITEM && m.curing <= 0) {\n m.curing = CURE_SECONDS;\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n events.emit(\"villagerCuring\", { entity: m });\n return;\n }\n const creative = this.player.mode === \"creative\";\n // поводок: привязанного ПКМ — отвязать (поводок в руку); поводком — привязать к себе\n if (m.leashed) {\n m.leashed = false;\n m.leashPost = null;\n m.leashMobId = null; // и у торговца можно отобрать ламу, как в Minecraft\n m.leashSlot = null;\n if (this.inventory.add(\"lead\", 1) > 0)\n this.entities.spawnDrop(m.pos.x, m.pos.y + 0.5, m.pos.z, \"lead\", 1);\n events.emit(\"mobUnleashed\", { entity: m });\n return;\n }\n if (held?.item === \"lead\" && m.def.category !== \"hostile\" && !m.def.boss) {\n m.leashed = true;\n m.leashSlot = this.entities.localSlot; // в сетевой игре — именно у этого игрока\n if (!creative) this.inventory.consumeHeld(1);\n events.emit(\"mobLeashed\", { entity: m });\n return;\n }\n // вьюк: сундуком — навьючить (осла и мула — ручных); присев, ПКМ — открыть\n if (m.def.chestSlots) {\n if (\n held?.item === \"chest\" &&\n !m.items.length &&\n (m.tamed || !m.def.rideable)\n ) {\n m.items = new Array<ItemStack | null>(m.def.chestSlots).fill(null);\n if (!creative) this.inventory.consumeHeld(1);\n events.emit(\"mobChested\", { entity: m });\n return;\n }\n if (m.items.length && this.player._sneaking) {\n events.emit(\"ui:openCart\", { entityId: m.id });\n return;\n }\n }\n // дух-помощник: предмет — «вот что собирать» (берёт один); пустой рукой — забрать его и собранное\n if (m.def.collector) {\n if (!held && m.carry) {\n const left = this.inventory.add(m.carry.item, 1 + m.bag);\n if (left > 0)\n this.entities.spawnDrop(\n this.player.pos.x,\n this.player.pos.y + 1,\n this.player.pos.z,\n m.carry.item,\n left,\n );\n events.emit(\"allayTaken\", { entity: m, item: m.carry.item });\n m.carry = null;\n m.bag = 0;\n } else if (held && !m.carry) {\n m.carry = { item: held.item, count: 1 };\n if (!creative) this.inventory.consumeHeld(1);\n events.emit(\"allayGiven\", { entity: m, item: held.item });\n }\n return;\n }\n // броненосец: щёткой — щиток (как в Minecraft), щётка изнашивается\n if (m.def.rollsUp && held?.item === \"brush\" && !m.baby) {\n this.entities.spawnDrop(\n m.pos.x,\n m.pos.y + 0.5,\n m.pos.z,\n \"armadillo_scute\",\n 1,\n );\n if (!creative) this.inventory.damageHeld(16);\n events.emit(\"mobBrushed\", { entity: m });\n return;\n }\n // ручной волк: броня из щитков — надеть (до усаживания, иначе ПКМ посадит волка)\n if (\n m.def.wearsArmor &&\n m.tamed &&\n held?.item === \"wolf_armor\" &&\n m.wolfArmor <= 0\n ) {\n m.wolfArmor = WOLF_ARMOR;\n if (!creative) this.inventory.consumeHeld(1);\n events.emit(\"wolfArmored\", { entity: m });\n return;\n }\n if (this._petAction(m, held, creative)) return;\n if (!held) return;\n // бирка с именем: строка ввода, имя вешает ui/NameEditor (он же тратит бирку)\n if (held.item === \"name_tag\") {\n events.emit(\"ui:nameMob\", { entityId: m.id });\n return;\n }\n if (held.item === \"bucket\" && MILKABLE.has(m.def.name) && !m.baby) {\n if (this.inventory.swapHeldOne(\"milk_bucket\"))\n this.entities.spawnDrop(\n this.player.pos.x,\n this.player.pos.y + 1,\n this.player.pos.z,\n \"milk_bucket\",\n 1,\n );\n events.emit(\"bucketFill\", { item: \"milk_bucket\" });\n return;\n }\n // ведро воды по рыбе — поймать её в ведро (как в Minecraft)\n const fishBucket = `${m.def.name}_bucket`;\n if (\n held.item === \"water_bucket\" &&\n m.def.needsWater &&\n fishBucket in FISH_BUCKETS\n ) {\n m.dead = true; // hp > 0 — без дропа: рыба теперь в ведре\n this._giveHeld(fishBucket);\n events.emit(\"bucketFill\", { item: fishBucket });\n return;\n }\n // печенье попугаю ядовито, как в Minecraft\n if (m.def.name === \"parrot\" && held.item === \"cookie\") {\n if (!creative) this.inventory.consumeHeld(1);\n m.damage(m.hp);\n events.emit(\"parrotPoisoned\", { entity: m });\n return;\n }\n // свинорыл: золотой слиток — бартер (случайное из таблицы systems/Barter)\n if (m.def.barters && held.item === \"gold_ingot\" && !m.baby) {\n if (!creative) this.inventory.consumeHeld(1);\n const s = barterRoll(Math.random);\n this.entities.spawnDrop(m.pos.x, m.pos.y + 1, m.pos.z, s.item, s.count);\n events.emit(\"barter\", { entity: m, item: s.item });\n return;\n }\n // грибная корова: миской — суп, ножницами — обычная корова и 5 красных грибов\n if (m.def.name === \"mooshroom\" && !m.baby) {\n if (held.item === \"bowl\") {\n if (creative) this.inventory.add(\"mushroom_stew\", 1);\n else this._giveHeld(\"mushroom_stew\");\n events.emit(\"mooshroomStew\", { entity: m });\n return;\n }\n if (held.item === \"shears\") {\n m.dead = true; // hp > 0 — без дропа: на её месте обычная корова\n this.entities.spawnMob(\"cow\", m.pos.x, m.pos.y, m.pos.z);\n this.entities.spawnDrop(\n m.pos.x,\n m.pos.y + 1,\n m.pos.z,\n \"red_mushroom\",\n 5,\n );\n if (!creative) this.inventory.damageHeld(1);\n events.emit(\"mobSheared\", { entity: m });\n return;\n }\n }\n // овца: краситель — шерсть этого цвета (и у стриженой — отрастёт цветной)\n if (m.def.dyeable && held.item.endsWith(\"_dye\") && !m.baby) {\n const color = held.item.slice(0, -4);\n const next = color === \"white\" ? null : color;\n if (m.woolColor !== next) {\n m.woolColor = next;\n if (!creative) this.inventory.consumeHeld(1);\n events.emit(\"mobDyed\", { entity: m, color });\n }\n return;\n }\n const shear = m.def.shearDrop;\n if (held.item === \"shears\" && shear && !m.sheared && !m.baby) {\n m.sheared = true;\n const n = shear.max\n ? shear.n + Math.floor(Math.random() * (shear.max - shear.n + 1))\n : shear.n;\n this.entities.spawnDrop(m.pos.x, m.pos.y + 1, m.pos.z, m.woolItem(), n);\n if (!creative) this.inventory.damageHeld(1);\n events.emit(\"mobSheared\", { entity: m });\n return;\n }\n if (\n m.def.temptItems?.includes(held.item) &&\n !m.baby &&\n m.loveT <= 0 &&\n m.breedCd <= 0\n ) {\n m.loveT = 30;\n if (!creative) this.inventory.consumeHeld(1);\n events.emit(\"mobFed\", { entity: m });\n }\n }\n\n /**\n * ПКМ по приручаемому, как в Minecraft. Лошадь: дикая — попытка приручить (посадка), ручной\n * надеть седло, осёдланная — сесть. Волк и кошка: дикого — приручить своей едой (шанс), ручного —\n * посадить или поднять. @returns действие состоялось\n */\n _petAction(m: Mob, held: ItemStack | null, creative: boolean): boolean {\n const d = m.def;\n if (m.baby) return false;\n if (d.saddleOnly) {\n // страйдер: седлается без приручения, осёдланный — сесть\n if (!m.saddled && held?.item === \"saddle\") {\n m.saddled = true;\n if (!creative) this.inventory.consumeHeld(1);\n events.emit(\"mobSaddled\", { entity: m });\n } else if (m.saddled) {\n this.player.mount(m);\n events.emit(\"cartMount\", { entity: m });\n } else events.emit(\"ui:hint\", { text: \"Нужно седло\" });\n return true;\n }\n if (d.rideable) {\n if (m.tamed && !m.saddled && held?.item === \"saddle\") {\n m.saddled = true;\n if (!creative) this.inventory.consumeHeld(1);\n events.emit(\"mobSaddled\", { entity: m });\n } else if (!m.tamed) {\n if (Math.random() < (d.tameChance ?? 1 / 3)) {\n m.tamed = true;\n events.emit(\"mobTamed\", { entity: m });\n } else {\n m.vel.y = 5; // встала на дыбы — не далась\n events.emit(\"tameFail\", { entity: m });\n }\n } else if (m.saddled) {\n this.player.mount(m);\n events.emit(\"cartMount\", { entity: m });\n } else events.emit(\"ui:hint\", { text: \"Нужно седло\" });\n return true;\n }\n if (!d.tameItems) return false;\n if (!m.tamed && held && d.tameItems.includes(held.item)) {\n if (!creative) this.inventory.consumeHeld(1);\n if (Math.random() < (d.tameChance ?? 1 / 3)) {\n m.tamed = true;\n m.aggro = false;\n m.sitting = true; // как в Minecraft: только что прирученный садится\n events.emit(\"mobTamed\", { entity: m });\n } else events.emit(\"tameFail\", { entity: m });\n return true;\n }\n if (m.tamed && !(held && d.temptItems?.includes(held.item))) {\n m.sitting = !m.sitting;\n m.speed = 0;\n events.emit(\"mobSit\", { entity: m, sitting: m.sitting });\n return true;\n }\n return false;\n }\n\n /** ПКМ пустой картой — Game заводит карту участка (systems/Maps). */\n onUseMap: (() => void) | null = null;\n\n /**\n * Удочка: поплавка нет — забросить; есть — вытащить. Клюёт в этот момент — улов в инвентарь\n * (не влез — под ноги), опыт 1..6, износ удочки.\n */\n _fish(): void {\n if (this.inventory.heldItem()?.item !== \"fishing_rod\") return;\n const hook = this.entities.playerHook();\n const p = this.player.pos;\n if (hook) {\n if (hook.bite > 0) {\n const loot = fishLoot(Math.random);\n if (this.inventory.addStack(loot) > 0)\n this.entities.spawnDrop(\n p.x,\n p.y + 1,\n p.z,\n loot.item,\n loot.count,\n loot.dmg,\n );\n this.entities.spawnXp(\n p.x,\n p.y + 0.5,\n p.z,\n 1 + Math.floor(Math.random() * 6),\n );\n events.emit(\"fishCaught\", { item: loot.item });\n if (this.player.mode !== \"creative\") this.inventory.damageHeld(1);\n }\n hook.dead = true;\n return;\n }\n const eye = this.player.eyePos,\n dir = this.player.lookDir();\n this.entities.castHook(\n eye.clone().addScaledVector(dir, 0.5),\n dir\n .clone()\n .multiplyScalar(14)\n .add(new THREE.Vector3(0, 2, 0)),\n );\n events.emit(\"fishCast\");\n }\n\n /** Бросить Око Предела: тратится (в креативе — нет), куда лететь, решает Game (throwEye). */\n _throwEye(): void {\n const held = this.inventory.heldItem();\n if (held?.item !== \"rim_eye\") return;\n const from = this.player.eyePos;\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n events.emit(\"throwEye\", { x: from.x, y: from.y, z: from.z });\n }\n\n /** ПКМ предметом со своим действием (мотыга, костная мука) — хук из world/itemUse. */\n _useItem(intent: IntentMsg): void {\n const { x, y, z } = intent;\n if (!this._validDistance(x, y, z)) return;\n const held = this.inventory.heldItem();\n // мёд с полного улья: ножницы — 3 соты у улья, пузырёк — бутылочка в руку\n if (\n held &&\n (held.item === \"shears\" || held.item === \"glass_bottle\") &&\n isHive(this.world.getBlock(x, y, z))\n ) {\n const loot = harvestHive(this.world, x, y, z, held.item);\n if (!loot) return;\n if (held.item === \"shears\") {\n this.entities.spawnDrop(x + 0.5, y + 1, z + 0.5, loot.item, loot.count);\n if (this.player.mode !== \"creative\") this.inventory.damageHeld(1);\n } else this._giveHeld(loot.item);\n return;\n }\n const fn = held ? itemUseFor(held.item) : undefined;\n if (!held || !fn) return;\n const creative = this.player.mode === \"creative\";\n fn({\n world: this.world,\n x,\n y,\n z,\n id: this.world.getBlock(x, y, z),\n meta: this.world.getMeta(x, y, z),\n nx: intent.nx,\n ny: intent.ny,\n nz: intent.nz,\n item: held.item,\n rng: Math.random,\n consume: () => {\n if (!creative) this.inventory.consumeHeld(1);\n },\n wear: (n = 1) => {\n if (!creative) this.inventory.damageHeld(n);\n },\n give: (item) => {\n if (!creative) this._giveHeld(item);\n },\n });\n }\n\n /** Отдать предмет в руку взамен одного из стака; не влезло — под ноги. */\n _giveHeld(item: string): void {\n if (this.inventory.swapHeldOne(item)) {\n const p = this.player.pos;\n this.entities.spawnDrop(p.x, p.y + 1, p.z, item, 1);\n }\n }\n\n /** Ведро: зачерпнуть источник воды/лавы или вылить жидкость у грани (источником). */\n _bucket(intent: IntentMsg): void {\n const { x, y, z } = intent;\n if (!this._validDistance(x, y, z)) return;\n const held = this.inventory.heldItem();\n if (!held) return;\n if (intent.mode === \"fill\") {\n const id = this.world.getBlock(x, y, z);\n if (held.item === \"glass_bottle\") {\n // пузырёк набирает воду, не забирая источник (как в Minecraft)\n if (id !== B.water) return;\n this._giveHeld(\"potion_water\");\n events.emit(\"bucketFill\", { item: \"potion_water\" });\n return;\n }\n if (held.item !== \"bucket\") return;\n if (id === B.powder_snow) {\n this.world.setBlock(x, y, z, B.air!);\n this._giveHeld(\"powder_snow_bucket\");\n events.emit(\"bucketFill\", { item: \"powder_snow_bucket\" });\n return;\n }\n if (!BLOCKS[id]?.liquid || !isWaterSource(this.world.getMeta(x, y, z)))\n return;\n this.world.setBlock(x, y, z, B.air!);\n const full = id === B.lava ? \"lava_bucket\" : \"water_bucket\";\n this._giveHeld(full);\n events.emit(\"bucketFill\", { item: full });\n return;\n }\n // ведро с рыбой выливается водой, а рыба выплывает\n const fish = FISH_BUCKETS[held.item];\n const fluid =\n held.item === \"water_bucket\" || fish\n ? B.water!\n : held.item === \"lava_bucket\"\n ? B.lava!\n : held.item === \"powder_snow_bucket\"\n ? B.powder_snow!\n : -1;\n if (fluid < 0) return;\n const hitId = this.world.getBlock(x, y, z);\n const inPlace = BLOCKS[hitId]?.replaceable ?? false;\n const cx = inPlace ? x : x + intent.nx,\n cy = inPlace ? y : y + intent.ny,\n cz = inPlace ? z : z + intent.nz;\n const cur = this.world.getBlock(cx, cy, cz);\n if (cur !== B.air && !BLOCKS[cur]?.replaceable) return;\n if (fluid === B.water && DIMENSIONS[this.world.dimension].waterEvaporates) {\n events.emit(\"evaporate\", { x: cx, y: cy, z: cz }); // в Пекле вода испаряется\n } else {\n this.world.setBlock(\n cx,\n cy,\n cz,\n fluid,\n fluid === B.powder_snow ? 0 : packWater(8, true),\n );\n if (fish) this.entities.spawnMob(fish, cx + 0.5, cy + 0.1, cz + 0.5);\n }\n if (this.player.mode !== \"creative\") this._giveHeld(\"bucket\");\n events.emit(\"bucketEmpty\", { item: held.item });\n }\n\n /** Огниво: TNT — взвести, иначе огонь в клетке у грани (портал в Пекло зажигается так же). */\n _ignite(intent: IntentMsg): void {\n const { x, y, z } = intent;\n if (!this._validDistance(x, y, z)) return;\n const held = this.inventory.heldItem();\n if (held?.item !== \"flint_and_steel\") return;\n const id = this.world.getBlock(x, y, z);\n if (id === B.tnt) {\n this.world.setBlock(x, y, z, B.air!);\n this.entities.spawnTnt(x + 0.5, y, z + 0.5, 4);\n } else {\n const cx = x + intent.nx,\n cy = y + intent.ny,\n cz = z + intent.nz;\n if (this.world.getBlock(cx, cy, cz) !== B.air) return;\n // на песке и почве душ огниво зажигает огонь душ\n const soul = [B.soul_sand, B.soul_soil].includes(\n this.world.getBlock(cx, cy - 1, cz),\n );\n this.world.setBlock(cx, cy, cz, soul ? B.soul_fire! : B.fire!, 0);\n events.emit(\"fireLit\", { x: cx, y: cy, z: cz }); // в рамке зажжёт портал (behaviours/fire)\n }\n if (this.player.mode !== \"creative\") this.inventory.damageHeld(1);\n }\n\n /** Q: выбросить предмет (или весь стак) вперёд; подобрать можно через 2 секунды. */\n _dropItem(intent: IntentMsg): void {\n const held = this.inventory.heldItem();\n if (!held) return;\n const n = intent.all ? held.count : 1;\n const dir = this.player.lookDir();\n const from = this.player.eyePos.addScaledVector(dir, 0.4);\n const drop = this.entities.spawnDrop(\n from.x,\n from.y - 0.3,\n from.z,\n held.item,\n n,\n held.dmg,\n held.tag,\n );\n drop.vel\n .copy(dir)\n .multiplyScalar(5)\n .setY(dir.y * 5 + 1.5);\n drop.pickupDelay = 2;\n this.inventory.consumeHeld(n);\n events.emit(\"itemThrow\", { item: held.item });\n }\n\n /** Снежок: летит дугой по взгляду; бросившего не задевает (PLAYER_SHOT). */\n _throwSnowball(): void {\n const dir = this.player.lookDir();\n this.entities.spawnFireball(\n \"snowball\",\n this.player.eyePos.clone().addScaledVector(dir, 0.6),\n dir.clone().multiplyScalar(20),\n 0,\n PLAYER_SHOT,\n );\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n events.emit(\"snowballThrow\");\n }\n\n /** Жемчуг Предела: бросок дугой; где упал — туда игрока и переносит (Game слушает pearlLand). */\n _throwPearl(): void {\n const dir = this.player.lookDir();\n this.entities.spawnFireball(\n \"pearl\",\n this.player.eyePos.clone().addScaledVector(dir, 0.6),\n dir.clone().multiplyScalar(22),\n 0,\n PLAYER_SHOT,\n );\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n events.emit(\"pearlThrow\");\n }\n\n /** Мешок в руке: ПКМ в мире — всё содержимое выпадает перед игроком. */\n _emptyBundle(): void {\n const held = this.inventory.heldItem();\n if (held?.item !== \"bundle\") return;\n const items = bundleItems(held);\n if (!items.length) return;\n const p = this.player.eyePos\n .clone()\n .addScaledVector(this.player.lookDir(), 0.8);\n for (const s of items)\n this.entities.spawnDrop(\n p.x,\n p.y - 0.3,\n p.z,\n s.item,\n s.count,\n s.dmg,\n s.tag,\n );\n this.inventory.setSlot(this.inventory.hotbarIndex, {\n ...held,\n tag: { ...(held.tag ?? {}), items: [] },\n });\n events.emit(\"bundleRemove\", { all: true });\n }\n\n /** Заряд ветра: летит по взгляду; порыв в точке попадания отбрасывает всех рядом — и бросившего. */\n _throwWindCharge(): void {\n const dir = this.player.lookDir();\n this.entities.spawnFireball(\n \"wind_charge\",\n this.player.eyePos.clone().addScaledVector(dir, 0.6),\n dir.clone().multiplyScalar(20),\n 0,\n PLAYER_SHOT,\n );\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n events.emit(\"windThrow\");\n }\n\n /** Средняя кнопка: взять блок под прицелом — выбрать его слот в хотбаре, в креативе — выдать. */\n _pickBlock(): void {\n const t = this.target;\n if (!t) return;\n const def = BLOCKS[t.id]!;\n const name = def.item ?? def.name;\n if (!ITEMS[name]) return;\n const inv = this.inventory;\n const hot = inv.slots.findIndex((s, i) => i < 9 && s?.item === name);\n if (hot >= 0) return inv.selectSlot(hot);\n const main = inv.slots.findIndex((s, i) => i >= 9 && s?.item === name);\n if (main >= 0) {\n const tmp = inv.slots[inv.hotbarIndex] ?? null;\n inv.setSlot(inv.hotbarIndex, inv.slots[main] ?? null);\n inv.setSlot(main, tmp);\n return;\n }\n if (this.player.mode === \"creative\")\n inv.setSlot(inv.hotbarIndex, {\n item: name,\n count: ITEMS[name]!.maxStack,\n });\n }\n\n /** Выстрел из лука: тратит стрелу (в креативе — нет) и прочность лука. */\n /** Арбалет: заряженный — выстрел (сильнее и быстрее лука); нет — зарядить, если держали 1.25 с. */\n _crossbow(held: ItemStack, charge: number): void {\n const creative = this.player.mode === \"creative\";\n if (held.tag?.loaded) {\n const dir = this.player.lookDir();\n const from = this.player.eyePos.addScaledVector(dir, 0.4);\n this.entities.spawnArrow(\n from,\n dir.clone().multiplyScalar(75),\n 9,\n true,\n 0,\n );\n this.inventory.setSlot(this.inventory.hotbarIndex, {\n ...held,\n tag: { ...held.tag, loaded: false },\n });\n if (!creative) this.inventory.damageHeld(1);\n events.emit(\"crossbowShoot\");\n return;\n }\n if (charge < CROSSBOW_CHARGE) return;\n if (!creative) {\n const bolt = this.inventory.slots.find((s) => s && isArrow(s.item));\n if (!bolt) return;\n this.inventory.consume(bolt.item, 1);\n }\n this.inventory.setSlot(this.inventory.hotbarIndex, {\n ...held,\n tag: { ...(held.tag ?? {}), loaded: true },\n });\n events.emit(\"crossbowLoad\");\n }\n\n /** Трезубец: держали ПКМ полсекунды — бросок; летит и втыкается, подбирается с износом (entities/Arrow). */\n _throwTrident(held: ItemStack, charge: number): void {\n if (charge < TRIDENT_CHARGE) return;\n const dir = this.player.lookDir();\n const from = this.player.eyePos.addScaledVector(dir, 0.4);\n const a = this.entities.spawnArrow(\n from,\n dir.clone().multiplyScalar(60),\n 8,\n true,\n 0,\n );\n a.asTrident({\n item: \"trident\",\n count: 1,\n dmg: (held.dmg ?? 0) + 1,\n ...(held.tag ? { tag: held.tag } : {}),\n });\n if (this.player.mode !== \"creative\")\n this.inventory.setSlot(this.inventory.hotbarIndex, null);\n events.emit(\"tridentThrow\");\n }\n\n _shootBow({ power, charge }: IntentMsg): void {\n const held = this.inventory.heldItem();\n if (held?.item === \"crossbow\")\n return this._crossbow(held, Number(charge) || 0);\n if (held?.item === \"trident\")\n return this._throwTrident(held, Number(charge) || 0);\n if (held?.item !== \"bow\") return;\n // какой стрелой стреляем: первая по порядку слотов, как в Minecraft (в креативе — обычная)\n const ammo =\n this.player.mode === \"creative\"\n ? \"arrow\"\n : (this.inventory.slots.find((s) => s && isArrow(s.item))?.item ??\n null);\n if (this.player.mode !== \"creative\") {\n if (!ammo) return;\n this.inventory.consume(ammo, 1);\n this.inventory.damageHeld(1);\n }\n const p = Math.max(0.1, Math.min(1, Number(power) || 0));\n const dir = this.player.lookDir();\n const from = this.player.eyePos.addScaledVector(dir, 0.4);\n const shot = this.entities.spawnArrow(\n from,\n dir.clone().multiplyScalar(60 * p),\n // Сила лука: +25% × (уровень + 1)\n Math.max(\n 1,\n Math.round(\n 9 *\n p *\n (1 +\n (enchLevel(held, \"power\") > 0\n ? 0.25 * (enchLevel(held, \"power\") + 1)\n : 0)),\n ),\n ),\n true,\n 0,\n );\n // отравленная стрела несёт свой эффект и подбирается собой (systems/TippedArrow)\n if (ammo && ammo !== \"arrow\") {\n shot.effect = tippedEffect(ammo);\n shot.pick = { item: ammo, count: 1 };\n }\n events.emit(\"bowShoot\", { power: p });\n }\n\n /** ПКМ по блоку с поведением (дверь, люк, верстак). */\n /** Животные на поводке у игрока (не у забора) в 10 блоках. */\n _leashedHere(): Mob[] {\n const out: Mob[] = [];\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.leashed &&\n !e.leashPost &&\n e.leashMobId === null &&\n (e.leashSlot === null || e.leashSlot === this.entities.localSlot) &&\n e.pos.distanceTo(this.player.pos) < 10\n )\n out.push(e);\n return out;\n }\n\n /** Привязать животных на поводке к забору в (x, y, z). @returns false — привязывать некого */\n _tieToPost(x: number, y: number, z: number): boolean {\n const list = this._leashedHere();\n if (!list.length) return false;\n for (const m of list) m.leashPost = [x, y, z];\n events.emit(\"leashTied\", { x, y, z, n: list.length });\n return true;\n }\n\n _use({ x, y, z }: IntentMsg): void {\n if (!this._validDistance(x, y, z)) return;\n const id = this.world.getBlock(x, y, z);\n // забор: животные на поводке у игрока — привязать к нему (узел на столбе)\n if (BLOCKS[id]?.shape === \"fence\" && this._tieToPost(x, y, z)) return;\n const use = BEHAVIOURS.get(id)?.use;\n if (!use) return;\n const creative = this.player.mode === \"creative\";\n use({\n world: this.world,\n x,\n y,\n z,\n id,\n meta: this.world.getMeta(x, y, z),\n held: this.inventory.heldItem()?.item ?? null,\n consume: () => {\n if (!creative) this.inventory.consumeHeld(1);\n },\n wear: (n = 1) => {\n if (!creative) this.inventory.damageHeld(n);\n },\n drop: (item, n = 1) =>\n this.entities.spawnDrop(x + 0.5, y + 0.5, z + 0.5, item, n),\n give: (item) => {\n if (!creative) this._giveHeld(item);\n },\n });\n }\n\n _place(intent: IntentMsg): void {\n const { x, y, z } = intent;\n if (!this._validDistance(x, y, z)) return;\n const held = this.inventory.heldItem();\n if (!held) return;\n const item = ITEMS[held.item];\n if (!item || item.blockId === null) return;\n const id = this.world.getBlock(x, y, z);\n if (id === B.air) return; // блок, по которому кликнули, успел исчезнуть\n const cells = planPlacement(\n this.world,\n item.blockId,\n {\n x,\n y,\n z,\n id,\n nx: intent.nx,\n ny: intent.ny,\n nz: intent.nz,\n hy: intent.hy,\n },\n intent.yaw,\n intent.pitch ?? 0,\n );\n if (!cells) return;\n for (const c of cells) {\n if (!this._validDistance(c.x, c.y, c.z)) return;\n if (BLOCKS[c.id]!.solid && this.player.intersectsBlock(c.x, c.y, c.z))\n return; // не в себя\n }\n for (const c of cells) this.world.setBlock(c.x, c.y, c.z, c.id, c.meta);\n const first = cells[0]!;\n events.emit(\"blockPlace\", {\n x: first.x,\n y: first.y,\n z: first.z,\n id: first.id,\n // панцирный сундук с вещами — они вернутся внутрь (world/blockentities/shell)\n ...(held.tag ? { tag: held.tag } : {}),\n });\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n }\n\n _attack({ entityId }: IntentMsg): void {\n if (this._attackCooldown > 0) return;\n const cart = this.entities.entities.get(entityId);\n if (cart?.kind === \"minecart\" || cart?.kind === \"boat\") {\n // вагонетка и лодка: три удара — сломана, выпадает предметом (в креативе — нет)\n if (\n cart.dead ||\n cart.pos.distanceToSquared(this.player.eyePos) >\n (CONFIG.player.reach + 1) ** 2\n )\n return;\n this._attackCooldown = 0.3;\n events.emit(\"armSwing\");\n if (cart.hit()) {\n if (this.player.riding === cart) this.player.dismount();\n if (this.player.mode !== \"creative\")\n this.entities.spawnDrop(\n cart.pos.x,\n cart.pos.y + 0.5,\n cart.pos.z,\n cart.dropItem,\n 1,\n );\n // груз вагонетки высыпается всегда\n if (cart.kind === \"minecart\")\n for (const s of cart.items)\n if (s)\n this.entities.spawnDrop(\n cart.pos.x,\n cart.pos.y + 0.5,\n cart.pos.z,\n s.item,\n s.count,\n s.dmg,\n s.tag,\n );\n }\n return;\n }\n // та же проверка дистанции, что у блоков: в этапе 2 интент придёт и от удалённого игрока\n const target = this.entities.entities.get(entityId);\n if (!target || target.kind !== \"mob\" || target.dead) return;\n const eye = this.player.eyePos;\n if (target.pos.distanceToSquared(eye) > (CONFIG.player.reach + 1) ** 2)\n return;\n this._attackCooldown = CONFIG.player.attackCooldown;\n this.player.addExhaustion(CONFIG.player.exhaustAttack);\n events.emit(\"armSwing\"); // замах — на каждый удар, в том числе при удержании ЛКМ\n const held = this.inventory.heldItem();\n // Сила: +3 урона за уровень, как в Minecraft\n // Острота: +0.5 × уровень + 0.5 (как в Minecraft)\n const sharp = enchLevel(held, \"sharpness\");\n // булава: удар в падении — урон от высоты, а падение гасится (прыгай с заряда ветра и бей снова)\n const smash =\n held?.item === \"mace\"\n ? maceSmash(this.player._fallDist, enchLevel(held, \"density\"))\n : 0;\n const damage =\n (held ? (ITEMS[held.item]?.damage ?? 1) : CONFIG.player.attackDamage) +\n 3 * (this.player.effects.get(\"strength\")?.level ?? 0) -\n 2 * (this.player.effects.get(\"weakness\")?.level ?? 0) +\n (sharp > 0 ? 0.5 * sharp + 0.5 : 0) +\n smash;\n this.entities.hurt(entityId, damage, this.player.pos);\n if (smash > 0) {\n this.player._fallDist = 0;\n events.emit(\"maceSmash\", { damage });\n // Порыв ветра: после сокрушения подбрасывает — можно бить снова\n const burst = enchLevel(held, \"wind_burst\");\n if (burst > 0) {\n this.player.vel.y = Math.max(this.player.vel.y, 6 + 2 * burst);\n const p = this.player.pos;\n events.emit(\"windBurst\", { x: p.x, y: p.y, z: p.z });\n }\n }\n // износ оружия: меч и топор — 1, прочие инструменты — 2 (как в Minecraft)\n const tool = this._heldDef();\n if (this.player.mode !== \"creative\" && tool?.durability)\n this.inventory.damageHeld(\n tool.toolType === \"sword\" || tool.toolType === \"axe\" ? 1 : 2,\n );\n }\n}\n"
|
|
724
|
+
"content": "// Взаимодействие с миром. Разделено на две половины:\n// frame() — «клиентская»: raycast из камеры, рамка выделения, перевод\n// состояния ввода (зажатая ЛКМ, цель) в интенты;\n// tick() — «серверная»: выгребает очередь интентов, валидирует\n// (дистанция, наличие предмета, кулдаун) и исполняет.\n// В этапе 2 tick()-половина живёт только у хоста; frame()-половина у всех.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { type IntentMsg, type IntentQueue } from \"../core/Intents\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { type RaycastHit } from \"../world/Raycast\";\nimport { BEHAVIOURS } from \"../world/behaviours/index\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"./Player\";\nimport type { Input } from \"./Input\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EntityManager } from \"../entities/EntityManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Mob } from \"../entities/Mob\";\nimport type { ItemStack } from \"../types\";\nimport {\n interactEntity,\n petAction,\n leashedHere,\n tieToPost,\n} from \"./interaction/entities\";\nimport {\n useItem,\n giveHeld,\n bucket,\n ignite,\n dropItem,\n emptyBundle,\n fish,\n throwEye,\n pickBlock,\n} from \"./interaction/items\";\nimport {\n throwSnowball,\n throwPearl,\n throwWindCharge,\n crossbow,\n throwTrident,\n shootBow,\n} from \"./interaction/projectiles\";\nimport {\n breakSecs,\n startBreak,\n finishBreak,\n place,\n placeHighlight,\n} from \"./interaction/blocks\";\nimport { attack, startEat } from \"./interaction/combat\";\nimport type { EntityHit, BreakInfo, DamageInfo } from \"./interaction/types\";\nimport { frame, frameTimers } from \"./interaction/frame\";\nimport { tick } from \"./interaction/tick\";\n\nexport class Interaction {\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n intents: IntentQueue;\n entities: EntityManager;\n target: RaycastHit | null;\n targetEntity: EntityHit;\n breaking: BreakInfo | null;\n _sentBreakKey: string | null;\n _placeCooldown: number;\n _attackCooldown: number;\n eating: { item: string; time: number } | null;\n _sentEat: string | null;\n damage: Map<string, DamageInfo>;\n highlight: THREE.LineSegments;\n crackMat: THREE.MeshBasicMaterial;\n crackMesh: THREE.Mesh;\n atlas: TextureAtlas;\n _crackStage: number;\n /** Кадровая половина: сколько ждать до следующего замаха, пока ЛКМ зажата по блоку/воздуху. */\n _swingTimer = 0;\n /** ПКМ по двери/люку срабатывает раз на нажатие — иначе удержание хлопало бы дверью. */\n _usedThisPress = false;\n /** Исполнитель команд консоли (подключает Game): интент Command — на тике, как весь ввод. */\n onCommand: ((text: string) => void) | null = null;\n /** Можно ли сменить режим (сетевая игра: только оператору); null — можно всегда. */\n mayToggleMode: (() => boolean) | null = null;\n /** Натяжение лука, сек (кадровая половина), и тянем ли его сейчас. */\n bowCharge = 0;\n _drawing = false;\n\n constructor(\n world: ChunkManager,\n player: Player,\n inventory: Inventory,\n intents: IntentQueue,\n scene: THREE.Scene,\n atlas: TextureAtlas,\n entities: EntityManager,\n ) {\n this.world = world;\n this.player = player;\n this.inventory = inventory;\n this.intents = intents;\n this.entities = entities;\n this.target = null; // текущий блок под прицелом (кадровая половина)\n this.targetEntity = null;\n\n // Состояние ломания (тиковая половина — авторитетно)\n this.breaking = null;\n this._sentBreakKey = null;\n this._placeCooldown = 0;\n this._attackCooldown = 0;\n this.eating = null; // {item, time} — процесс поедания (тиковая половина)\n this._sentEat = null; // защита от повторной отправки EatStart за удержание\n // Накопленный урон по блокам: \"x,y,z\" -> {id, progress, heal}.\n // Сохраняется при отпускании ЛКМ и медленно «заживает» в простое.\n this.damage = new Map();\n // блок сменился (сломан/заменён/затоплен) — забыть его урон\n events.on(\"blockChanged\", ({ x, y, z }) =>\n this.damage.delete(x + \",\" + y + \",\" + z),\n );\n\n // рамка выделения блока (масштабируется под коробки формы)\n const boxGeo = new THREE.BoxGeometry(1.002, 1.002, 1.002);\n this.highlight = new THREE.LineSegments(\n new THREE.EdgesGeometry(boxGeo),\n new THREE.LineBasicMaterial({\n color: 0x111111,\n transparent: true,\n opacity: 0.6,\n }),\n );\n this.highlight.visible = false;\n scene.add(this.highlight);\n\n // decal трещин: чуть увеличенный куб с прозрачной текстурой стадии\n this.crackMat = new THREE.MeshBasicMaterial({\n map: atlas.texture.clone(),\n transparent: true,\n depthWrite: false,\n polygonOffset: true,\n polygonOffsetFactor: -1,\n polygonOffsetUnits: -2,\n });\n this.crackMat.map!.magFilter = this.crackMat.map!.minFilter =\n THREE.NearestFilter;\n this.crackMat.map!.generateMipmaps = false;\n this.crackMesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.004, 1.004, 1.004),\n this.crackMat,\n );\n this.crackMesh.visible = false;\n this.crackMesh.renderOrder = 2;\n scene.add(this.crackMesh);\n this.atlas = atlas;\n this._crackStage = -1;\n }\n\n // ---- кадровая половина ------------------------------------------------------\n\n frame(input: Input): void {\n frame(this, input);\n }\n\n /** Рамка выделения по коробкам формы блока под прицелом (у ступеней — ступени, у факела — факел). */\n _placeHighlight(): void {\n placeHighlight(this);\n }\n\n frameTimers(dt: number): void {\n frameTimers(this, dt);\n }\n\n tick(dt: number): void {\n tick(this, dt);\n }\n\n _startEat(arg: IntentMsg): void {\n startEat(this, arg);\n }\n\n _validDistance(x: number, y: number, z: number): boolean {\n const e = this.player.eyePos;\n const dx = x + 0.5 - e.x,\n dy = y + 0.5 - e.y,\n dz = z + 0.5 - e.z;\n return dx * dx + dy * dy + dz * dz <= (CONFIG.player.reach + 1) ** 2;\n }\n\n /** Описание предмета в руке (null — пустая рука). */\n _heldDef() {\n const held = this.inventory.heldItem();\n return held ? (ITEMS[held.item] ?? null) : null;\n }\n\n /** Секунды ломания блока тем, что сейчас в руке (в креативе — мгновенно). */\n _breakSecs(blockId: number): number {\n return breakSecs(this, blockId);\n }\n\n _startBreak(arg: IntentMsg): void {\n startBreak(this, arg);\n }\n\n _finishBreak(x: number, y: number, z: number, id: number): void {\n finishBreak(this, x, y, z, id);\n }\n\n /** ПКМ по мобу: ножницами — остричь овцу, едой — покормить (двое накормленных дадут детёныша). */\n _interactEntity(arg: IntentMsg): void {\n interactEntity(this, arg);\n }\n\n /**\n * ПКМ по приручаемому, как в Minecraft. Лошадь: дикая — попытка приручить (посадка), ручной\n * надеть седло, осёдланная — сесть. Волк и кошка: дикого — приручить своей едой (шанс), ручного —\n * посадить или поднять. @returns действие состоялось\n */\n _petAction(m: Mob, held: ItemStack | null, creative: boolean): boolean {\n return petAction(this, m, held, creative);\n }\n\n /** ПКМ пустой картой — Game заводит карту участка (systems/Maps). */\n onUseMap: (() => void) | null = null;\n\n /**\n * Удочка: поплавка нет — забросить; есть — вытащить. Клюёт в этот момент — улов в инвентарь\n * (не влез — под ноги), опыт 1..6, износ удочки.\n */\n _fish(): void {\n fish(this);\n }\n\n /** Бросить Око Предела: тратится (в креативе — нет), куда лететь, решает Game (throwEye). */\n _throwEye(): void {\n throwEye(this);\n }\n\n /** ПКМ предметом со своим действием (мотыга, костная мука) — хук из world/itemUse. */\n _useItem(intent: IntentMsg): void {\n useItem(this, intent);\n }\n\n /** Отдать предмет в руку взамен одного из стака; не влезло — под ноги. */\n _giveHeld(item: string): void {\n giveHeld(this, item);\n }\n\n /** Ведро: зачерпнуть источник воды/лавы или вылить жидкость у грани (источником). */\n _bucket(intent: IntentMsg): void {\n bucket(this, intent);\n }\n\n /** Огниво: TNT — взвести, иначе огонь в клетке у грани (портал в Пекло зажигается так же). */\n _ignite(intent: IntentMsg): void {\n ignite(this, intent);\n }\n\n /** Q: выбросить предмет (или весь стак) вперёд; подобрать можно через 2 секунды. */\n _dropItem(intent: IntentMsg): void {\n dropItem(this, intent);\n }\n\n /** Снежок: летит дугой по взгляду; бросившего не задевает (PLAYER_SHOT). */\n _throwSnowball(): void {\n throwSnowball(this);\n }\n\n /** Жемчуг Предела: бросок дугой; где упал — туда игрока и переносит (Game слушает pearlLand). */\n _throwPearl(): void {\n throwPearl(this);\n }\n\n /** Мешок в руке: ПКМ в мире — всё содержимое выпадает перед игроком. */\n _emptyBundle(): void {\n emptyBundle(this);\n }\n\n /** Заряд ветра: летит по взгляду; порыв в точке попадания отбрасывает всех рядом — и бросившего. */\n _throwWindCharge(): void {\n throwWindCharge(this);\n }\n\n /** Средняя кнопка: взять блок под прицелом — выбрать его слот в хотбаре, в креативе — выдать. */\n _pickBlock(): void {\n pickBlock(this);\n }\n\n /** Выстрел из лука: тратит стрелу (в креативе — нет) и прочность лука. */\n /** Арбалет: заряженный — выстрел (сильнее и быстрее лука); нет — зарядить, если держали 1.25 с. */\n _crossbow(held: ItemStack, charge: number): void {\n crossbow(this, held, charge);\n }\n\n /** Трезубец: держали ПКМ полсекунды — бросок; летит и втыкается, подбирается с износом (entities/Arrow). */\n _throwTrident(held: ItemStack, charge: number): void {\n throwTrident(this, held, charge);\n }\n\n _shootBow(arg: IntentMsg): void {\n shootBow(this, arg);\n }\n\n /** ПКМ по блоку с поведением (дверь, люк, верстак). */\n /** Животные на поводке у игрока (не у забора) в 10 блоках. */\n _leashedHere(): Mob[] {\n return leashedHere(this);\n }\n\n /** Привязать животных на поводке к забору в (x, y, z). @returns false — привязывать некого */\n _tieToPost(x: number, y: number, z: number): boolean {\n return tieToPost(this, x, y, z);\n }\n\n _use({ x, y, z }: IntentMsg): void {\n if (!this._validDistance(x, y, z)) return;\n const id = this.world.getBlock(x, y, z);\n // забор: животные на поводке у игрока — привязать к нему (узел на столбе)\n if (BLOCKS[id]?.shape === \"fence\" && this._tieToPost(x, y, z)) return;\n const use = BEHAVIOURS.get(id)?.use;\n if (!use) return;\n const creative = this.player.mode === \"creative\";\n use({\n world: this.world,\n x,\n y,\n z,\n id,\n meta: this.world.getMeta(x, y, z),\n held: this.inventory.heldItem()?.item ?? null,\n consume: () => {\n if (!creative) this.inventory.consumeHeld(1);\n },\n wear: (n = 1) => {\n if (!creative) this.inventory.damageHeld(n);\n },\n drop: (item, n = 1) =>\n this.entities.spawnDrop(x + 0.5, y + 0.5, z + 0.5, item, n),\n give: (item) => {\n if (!creative) this._giveHeld(item);\n },\n });\n }\n\n _place(intent: IntentMsg): void {\n place(this, intent);\n }\n\n _attack(arg: IntentMsg): void {\n attack(this, arg);\n }\n}\n"
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
"path": "player/interaction/blocks.ts",
|
|
728
|
+
"content": "import { CONFIG } from \"../../config\";\nimport { events } from \"../../core/EventBus\";\nimport type { IntentMsg } from \"../../core/Intents\";\nimport { BLOCKS, B } from \"../../registry/Blocks\";\nimport { enchantDrops, enchLevel } from \"../../systems/Enchant\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { selectionBoxes } from \"../../registry/shapes\";\nimport { breakSeconds, canHarvest, dropsOf } from \"../../systems/Mining\";\nimport { planPlacement } from \"../Placement\";\nimport type { Interaction } from \"../Interaction\";\n\nexport function placeHighlight(it: Interaction): void {\n const t = it.target;\n if (!t) {\n it.highlight.visible = false;\n return;\n }\n let b = [0, 0, 0, 1, 1, 1];\n const def = BLOCKS[t.id]!;\n if (def.shape !== \"cube\") {\n const boxes = selectionBoxes(def, t.meta, {\n neighbor: (dx, dy, dz) => it.world.getBlock(t.x + dx, t.y + dy, t.z + dz),\n });\n if (boxes.length) {\n b = [...boxes[0]!];\n for (const q of boxes)\n for (let i = 0; i < 3; i++) {\n b[i] = Math.min(b[i]!, q[i]!);\n b[i + 3] = Math.max(b[i + 3]!, q[i + 3]!);\n }\n }\n }\n it.highlight.visible = true;\n it.highlight.scale.set(b[3]! - b[0]!, b[4]! - b[1]!, b[5]! - b[2]!);\n it.highlight.position.set(\n t.x + (b[0]! + b[3]!) / 2,\n t.y + (b[1]! + b[4]!) / 2,\n t.z + (b[2]! + b[5]!) / 2,\n );\n}\n\nexport function breakSecs(it: Interaction, blockId: number): number {\n if (it.player.mode === \"creative\") return 0;\n // Спешка (маяк): +20% скорости добычи за уровень\n const haste = 1 + 0.2 * (it.player.effects.get(\"haste\")?.level ?? 0);\n // Усталость (древний глубинник): добыча в 3.3, 11 и 370 раз медленнее, как в Minecraft\n const fatigue = [1, 0.3, 0.09, 0.0027, 0.00081][\n Math.min(4, it.player.effects.get(\"mining_fatigue\")?.level ?? 0)\n ]!;\n return (\n breakSeconds(\n BLOCKS[blockId]!,\n it._heldDef(),\n enchLevel(it.inventory.heldItem(), \"efficiency\"),\n ) /\n (haste * fatigue)\n );\n}\n\nexport function startBreak(it: Interaction, { x, y, z }: IntentMsg): void {\n if (!it._validDistance(x, y, z)) return;\n const id = it.world.getBlock(x, y, z);\n const def = BLOCKS[id]!;\n if (id === B.air || def.liquid || def.hardness === Infinity) return;\n const total = breakSecs(it, id);\n // возобновляем с запомненного урона (если это тот же блок), а не с нуля\n const stored = it.damage.get(x + \",\" + y + \",\" + z);\n const progress = stored && stored.id === id ? stored.progress : 0;\n it.breaking = { x, y, z, id, progress, total };\n if (total <= 0.05) {\n // мгновенное ломание\n finishBreak(it, x, y, z, id);\n it.damage.delete(x + \",\" + y + \",\" + z);\n it.breaking = null;\n }\n}\n\nexport function finishBreak(\n it: Interaction,\n x: number,\n y: number,\n z: number,\n id: number,\n): void {\n const def = BLOCKS[id]!;\n const meta = it.world.getMeta(x, y, z);\n it.world.setBlock(x, y, z, B.air!);\n events.emit(\"blockBreak\", { x, y, z, id, byPlayer: true });\n it.player.addExhaustion(CONFIG.player.exhaustBreak);\n // В креативе дропов нет; без своего инструмента нужного уровня — тоже (камень рукой ломается\n // впустую, как в Minecraft). Блоки, державшиеся за этот (факел, растение, верх двери),\n // обрушивает systems/BlockUpdates.\n if (it.player.mode !== \"creative\" && canHarvest(def, it._heldDef()))\n // Шёлковое касание и Удача меняют дроп (systems/Enchant)\n for (const d of enchantDrops(\n def,\n dropsOf(def, meta),\n it.inventory.heldItem(),\n ))\n if (ITEMS[d.item])\n it.entities.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n // износ: инструмент тратит 1 прочности на твёрдый блок, меч — 2 (как в Minecraft)\n const tool = it._heldDef();\n if (it.player.mode !== \"creative\" && def.hardness > 0 && tool?.durability)\n it.inventory.damageHeld(tool.toolType === \"sword\" ? 2 : 1);\n}\n\nexport function place(it: Interaction, intent: IntentMsg): void {\n const { x, y, z } = intent;\n if (!it._validDistance(x, y, z)) return;\n const held = it.inventory.heldItem();\n if (!held) return;\n const item = ITEMS[held.item];\n if (!item || item.blockId === null) return;\n const id = it.world.getBlock(x, y, z);\n if (id === B.air) return; // блок, по которому кликнули, успел исчезнуть\n const cells = planPlacement(\n it.world,\n item.blockId,\n {\n x,\n y,\n z,\n id,\n nx: intent.nx,\n ny: intent.ny,\n nz: intent.nz,\n hy: intent.hy,\n },\n intent.yaw,\n intent.pitch ?? 0,\n );\n if (!cells) return;\n for (const c of cells) {\n if (!it._validDistance(c.x, c.y, c.z)) return;\n if (BLOCKS[c.id]!.solid && it.player.intersectsBlock(c.x, c.y, c.z)) return; // не в себя\n }\n for (const c of cells) it.world.setBlock(c.x, c.y, c.z, c.id, c.meta);\n const first = cells[0]!;\n events.emit(\"blockPlace\", {\n x: first.x,\n y: first.y,\n z: first.z,\n id: first.id,\n // панцирный сундук с вещами — они вернутся внутрь (world/blockentities/shell)\n ...(held.tag ? { tag: held.tag } : {}),\n });\n if (it.player.mode !== \"creative\") it.inventory.consumeHeld(1);\n}\n"
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
"path": "player/interaction/combat.ts",
|
|
732
|
+
"content": "import { CONFIG } from \"../../config\";\nimport { events } from \"../../core/EventBus\";\nimport type { IntentMsg } from \"../../core/Intents\";\nimport { enchLevel } from \"../../systems/Enchant\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { maceSmash } from \"../../registry/items/trial\";\nimport type { Interaction } from \"../Interaction\";\n\nexport function startEat(it: Interaction, { item }: IntentMsg): void {\n const held = it.inventory.heldItem();\n if (!held || held.item !== item) return;\n const def = ITEMS[item];\n if (!def?.drink && (!def?.food || it.player.food >= CONFIG.player.maxFood))\n return;\n it.eating = { item, time: 0 };\n}\n\nexport function attack(it: Interaction, { entityId }: IntentMsg): void {\n if (it._attackCooldown > 0) return;\n const cart = it.entities.entities.get(entityId);\n if (cart?.kind === \"minecart\" || cart?.kind === \"boat\") {\n // вагонетка и лодка: три удара — сломана, выпадает предметом (в креативе — нет)\n if (\n cart.dead ||\n cart.pos.distanceToSquared(it.player.eyePos) >\n (CONFIG.player.reach + 1) ** 2\n )\n return;\n it._attackCooldown = 0.3;\n events.emit(\"armSwing\");\n if (cart.hit()) {\n if (it.player.riding === cart) it.player.dismount();\n if (it.player.mode !== \"creative\")\n it.entities.spawnDrop(\n cart.pos.x,\n cart.pos.y + 0.5,\n cart.pos.z,\n cart.dropItem,\n 1,\n );\n // груз вагонетки высыпается всегда\n if (cart.kind === \"minecart\")\n for (const s of cart.items)\n if (s)\n it.entities.spawnDrop(\n cart.pos.x,\n cart.pos.y + 0.5,\n cart.pos.z,\n s.item,\n s.count,\n s.dmg,\n s.tag,\n );\n }\n return;\n }\n // та же проверка дистанции, что у блоков: в этапе 2 интент придёт и от удалённого игрока\n const target = it.entities.entities.get(entityId);\n if (!target || target.kind !== \"mob\" || target.dead) return;\n const eye = it.player.eyePos;\n if (target.pos.distanceToSquared(eye) > (CONFIG.player.reach + 1) ** 2)\n return;\n it._attackCooldown = CONFIG.player.attackCooldown;\n it.player.addExhaustion(CONFIG.player.exhaustAttack);\n events.emit(\"armSwing\"); // замах — на каждый удар, в том числе при удержании ЛКМ\n const held = it.inventory.heldItem();\n // Сила: +3 урона за уровень, как в Minecraft\n // Острота: +0.5 × уровень + 0.5 (как в Minecraft)\n const sharp = enchLevel(held, \"sharpness\");\n // булава: удар в падении — урон от высоты, а падение гасится (прыгай с заряда ветра и бей снова)\n const smash =\n held?.item === \"mace\"\n ? maceSmash(it.player._fallDist, enchLevel(held, \"density\"))\n : 0;\n const damage =\n (held ? (ITEMS[held.item]?.damage ?? 1) : CONFIG.player.attackDamage) +\n 3 * (it.player.effects.get(\"strength\")?.level ?? 0) -\n 2 * (it.player.effects.get(\"weakness\")?.level ?? 0) +\n (sharp > 0 ? 0.5 * sharp + 0.5 : 0) +\n smash;\n it.entities.hurt(entityId, damage, it.player.pos);\n if (smash > 0) {\n it.player._fallDist = 0;\n events.emit(\"maceSmash\", { damage });\n // Порыв ветра: после сокрушения подбрасывает — можно бить снова\n const burst = enchLevel(held, \"wind_burst\");\n if (burst > 0) {\n it.player.vel.y = Math.max(it.player.vel.y, 6 + 2 * burst);\n const p = it.player.pos;\n events.emit(\"windBurst\", { x: p.x, y: p.y, z: p.z });\n }\n }\n // износ оружия: меч и топор — 1, прочие инструменты — 2 (как в Minecraft)\n const tool = it._heldDef();\n if (it.player.mode !== \"creative\" && tool?.durability)\n it.inventory.damageHeld(\n tool.toolType === \"sword\" || tool.toolType === \"axe\" ? 1 : 2,\n );\n}\n"
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
"path": "player/interaction/entities.ts",
|
|
736
|
+
"content": "import { CONFIG } from \"../../config\";\nimport { events } from \"../../core/EventBus\";\nimport type { IntentMsg } from \"../../core/Intents\";\nimport { FISH_BUCKETS } from \"../../registry/items/treasure\";\nimport { barterRoll } from \"../../systems/Barter\";\nimport { WOLF_ARMOR } from \"../../registry/items/armadillo\";\nimport { CURE_ITEM, CURE_SECONDS } from \"../../systems/Zombify\";\nimport type { Mob } from \"../../entities/Mob\";\nimport type { ItemStack } from \"../../types\";\nimport { MILKABLE } from \"./shared\";\nimport type { Interaction } from \"../Interaction\";\n\nexport function interactEntity(it: Interaction, { entityId }: IntentMsg): void {\n const m = it.entities.entities.get(entityId);\n // сетевая игра: в ней (на нём) уже сидит другой игрок — место занято\n if (\n (m?.kind === \"minecart\" || m?.kind === \"boat\" || m?.kind === \"mob\") &&\n m.rider &&\n (it.player.riding as unknown) !== m\n )\n return;\n if (m?.kind === \"minecart\" || m?.kind === \"boat\") {\n // сесть в вагонетку или лодку; у вагонетки с сундуком или воронкой — открыть груз\n if (\n !m.dead &&\n m.pos.distanceToSquared(it.player.eyePos) <=\n (CONFIG.player.reach + 1) ** 2\n ) {\n if (m.kind === \"minecart\" && m.variant !== \"plain\") {\n if (m.items.length) events.emit(\"ui:openCart\", { entityId: m.id });\n return;\n }\n it.player.mount(m);\n events.emit(\"cartMount\", { entity: m });\n }\n return;\n }\n if (!m || m.kind !== \"mob\" || m.dead) return;\n if (\n m.pos.distanceToSquared(it.player.eyePos) >\n (CONFIG.player.reach + 1) ** 2\n )\n return;\n if (m.def.trades && !m.baby) {\n events.emit(\"ui:openTrade\", { entityId: m.id });\n return;\n }\n const held = it.inventory.heldItem();\n // золотое яблоко зомби-жителю — начать лечение (systems/Zombify); дважды не лечат\n if (m.def.curable && held?.item === CURE_ITEM && m.curing <= 0) {\n m.curing = CURE_SECONDS;\n if (it.player.mode !== \"creative\") it.inventory.consumeHeld(1);\n events.emit(\"villagerCuring\", { entity: m });\n return;\n }\n const creative = it.player.mode === \"creative\";\n // поводок: привязанного ПКМ — отвязать (поводок в руку); поводком — привязать к себе\n if (m.leashed) {\n m.leashed = false;\n m.leashPost = null;\n m.leashMobId = null; // и у торговца можно отобрать ламу, как в Minecraft\n m.leashSlot = null;\n if (it.inventory.add(\"lead\", 1) > 0)\n it.entities.spawnDrop(m.pos.x, m.pos.y + 0.5, m.pos.z, \"lead\", 1);\n events.emit(\"mobUnleashed\", { entity: m });\n return;\n }\n if (held?.item === \"lead\" && m.def.category !== \"hostile\" && !m.def.boss) {\n m.leashed = true;\n m.leashSlot = it.entities.localSlot; // в сетевой игре — именно у этого игрока\n if (!creative) it.inventory.consumeHeld(1);\n events.emit(\"mobLeashed\", { entity: m });\n return;\n }\n // вьюк: сундуком — навьючить (осла и мула — ручных); присев, ПКМ — открыть\n if (m.def.chestSlots) {\n if (\n held?.item === \"chest\" &&\n !m.items.length &&\n (m.tamed || !m.def.rideable)\n ) {\n m.items = new Array<ItemStack | null>(m.def.chestSlots).fill(null);\n if (!creative) it.inventory.consumeHeld(1);\n events.emit(\"mobChested\", { entity: m });\n return;\n }\n if (m.items.length && it.player._sneaking) {\n events.emit(\"ui:openCart\", { entityId: m.id });\n return;\n }\n }\n // дух-помощник: предмет — «вот что собирать» (берёт один); пустой рукой — забрать его и собранное\n if (m.def.collector) {\n if (!held && m.carry) {\n const left = it.inventory.add(m.carry.item, 1 + m.bag);\n if (left > 0)\n it.entities.spawnDrop(\n it.player.pos.x,\n it.player.pos.y + 1,\n it.player.pos.z,\n m.carry.item,\n left,\n );\n events.emit(\"allayTaken\", { entity: m, item: m.carry.item });\n m.carry = null;\n m.bag = 0;\n } else if (held && !m.carry) {\n m.carry = { item: held.item, count: 1 };\n if (!creative) it.inventory.consumeHeld(1);\n events.emit(\"allayGiven\", { entity: m, item: held.item });\n }\n return;\n }\n // броненосец: щёткой — щиток (как в Minecraft), щётка изнашивается\n if (m.def.rollsUp && held?.item === \"brush\" && !m.baby) {\n it.entities.spawnDrop(\n m.pos.x,\n m.pos.y + 0.5,\n m.pos.z,\n \"armadillo_scute\",\n 1,\n );\n if (!creative) it.inventory.damageHeld(16);\n events.emit(\"mobBrushed\", { entity: m });\n return;\n }\n // ручной волк: броня из щитков — надеть (до усаживания, иначе ПКМ посадит волка)\n if (\n m.def.wearsArmor &&\n m.tamed &&\n held?.item === \"wolf_armor\" &&\n m.wolfArmor <= 0\n ) {\n m.wolfArmor = WOLF_ARMOR;\n if (!creative) it.inventory.consumeHeld(1);\n events.emit(\"wolfArmored\", { entity: m });\n return;\n }\n if (petAction(it, m, held, creative)) return;\n if (!held) return;\n // бирка с именем: строка ввода, имя вешает ui/NameEditor (он же тратит бирку)\n if (held.item === \"name_tag\") {\n events.emit(\"ui:nameMob\", { entityId: m.id });\n return;\n }\n if (held.item === \"bucket\" && MILKABLE.has(m.def.name) && !m.baby) {\n if (it.inventory.swapHeldOne(\"milk_bucket\"))\n it.entities.spawnDrop(\n it.player.pos.x,\n it.player.pos.y + 1,\n it.player.pos.z,\n \"milk_bucket\",\n 1,\n );\n events.emit(\"bucketFill\", { item: \"milk_bucket\" });\n return;\n }\n // ведро воды по рыбе — поймать её в ведро (как в Minecraft)\n const fishBucket = `${m.def.name}_bucket`;\n if (\n held.item === \"water_bucket\" &&\n m.def.needsWater &&\n fishBucket in FISH_BUCKETS\n ) {\n m.dead = true; // hp > 0 — без дропа: рыба теперь в ведре\n it._giveHeld(fishBucket);\n events.emit(\"bucketFill\", { item: fishBucket });\n return;\n }\n // печенье попугаю ядовито, как в Minecraft\n if (m.def.name === \"parrot\" && held.item === \"cookie\") {\n if (!creative) it.inventory.consumeHeld(1);\n m.damage(m.hp);\n events.emit(\"parrotPoisoned\", { entity: m });\n return;\n }\n // свинорыл: золотой слиток — бартер (случайное из таблицы systems/Barter)\n if (m.def.barters && held.item === \"gold_ingot\" && !m.baby) {\n if (!creative) it.inventory.consumeHeld(1);\n const s = barterRoll(Math.random);\n it.entities.spawnDrop(m.pos.x, m.pos.y + 1, m.pos.z, s.item, s.count);\n events.emit(\"barter\", { entity: m, item: s.item });\n return;\n }\n // грибная корова: миской — суп, ножницами — обычная корова и 5 красных грибов\n if (m.def.name === \"mooshroom\" && !m.baby) {\n if (held.item === \"bowl\") {\n if (creative) it.inventory.add(\"mushroom_stew\", 1);\n else it._giveHeld(\"mushroom_stew\");\n events.emit(\"mooshroomStew\", { entity: m });\n return;\n }\n if (held.item === \"shears\") {\n m.dead = true; // hp > 0 — без дропа: на её месте обычная корова\n it.entities.spawnMob(\"cow\", m.pos.x, m.pos.y, m.pos.z);\n it.entities.spawnDrop(m.pos.x, m.pos.y + 1, m.pos.z, \"red_mushroom\", 5);\n if (!creative) it.inventory.damageHeld(1);\n events.emit(\"mobSheared\", { entity: m });\n return;\n }\n }\n // овца: краситель — шерсть этого цвета (и у стриженой — отрастёт цветной)\n if (m.def.dyeable && held.item.endsWith(\"_dye\") && !m.baby) {\n const color = held.item.slice(0, -4);\n const next = color === \"white\" ? null : color;\n if (m.woolColor !== next) {\n m.woolColor = next;\n if (!creative) it.inventory.consumeHeld(1);\n events.emit(\"mobDyed\", { entity: m, color });\n }\n return;\n }\n const shear = m.def.shearDrop;\n if (held.item === \"shears\" && shear && !m.sheared && !m.baby) {\n m.sheared = true;\n const n = shear.max\n ? shear.n + Math.floor(Math.random() * (shear.max - shear.n + 1))\n : shear.n;\n it.entities.spawnDrop(m.pos.x, m.pos.y + 1, m.pos.z, m.woolItem(), n);\n if (!creative) it.inventory.damageHeld(1);\n events.emit(\"mobSheared\", { entity: m });\n return;\n }\n if (\n m.def.temptItems?.includes(held.item) &&\n !m.baby &&\n m.loveT <= 0 &&\n m.breedCd <= 0\n ) {\n m.loveT = 30;\n if (!creative) it.inventory.consumeHeld(1);\n events.emit(\"mobFed\", { entity: m });\n }\n}\n\nexport function petAction(\n it: Interaction,\n m: Mob,\n held: ItemStack | null,\n creative: boolean,\n): boolean {\n const d = m.def;\n if (m.baby) return false;\n if (d.saddleOnly) {\n // страйдер: седлается без приручения, осёдланный — сесть\n if (!m.saddled && held?.item === \"saddle\") {\n m.saddled = true;\n if (!creative) it.inventory.consumeHeld(1);\n events.emit(\"mobSaddled\", { entity: m });\n } else if (m.saddled) {\n it.player.mount(m);\n events.emit(\"cartMount\", { entity: m });\n } else events.emit(\"ui:hint\", { text: \"Нужно седло\" });\n return true;\n }\n if (d.rideable) {\n if (m.tamed && !m.saddled && held?.item === \"saddle\") {\n m.saddled = true;\n if (!creative) it.inventory.consumeHeld(1);\n events.emit(\"mobSaddled\", { entity: m });\n } else if (!m.tamed) {\n if (Math.random() < (d.tameChance ?? 1 / 3)) {\n m.tamed = true;\n events.emit(\"mobTamed\", { entity: m });\n } else {\n m.vel.y = 5; // встала на дыбы — не далась\n events.emit(\"tameFail\", { entity: m });\n }\n } else if (m.saddled) {\n it.player.mount(m);\n events.emit(\"cartMount\", { entity: m });\n } else events.emit(\"ui:hint\", { text: \"Нужно седло\" });\n return true;\n }\n if (!d.tameItems) return false;\n if (!m.tamed && held && d.tameItems.includes(held.item)) {\n if (!creative) it.inventory.consumeHeld(1);\n if (Math.random() < (d.tameChance ?? 1 / 3)) {\n m.tamed = true;\n m.aggro = false;\n m.sitting = true; // как в Minecraft: только что прирученный садится\n events.emit(\"mobTamed\", { entity: m });\n } else events.emit(\"tameFail\", { entity: m });\n return true;\n }\n if (m.tamed && !(held && d.temptItems?.includes(held.item))) {\n m.sitting = !m.sitting;\n m.speed = 0;\n events.emit(\"mobSit\", { entity: m, sitting: m.sitting });\n return true;\n }\n return false;\n}\n\nexport function leashedHere(it: Interaction): Mob[] {\n const out: Mob[] = [];\n for (const e of it.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.leashed &&\n !e.leashPost &&\n e.leashMobId === null &&\n (e.leashSlot === null || e.leashSlot === it.entities.localSlot) &&\n e.pos.distanceTo(it.player.pos) < 10\n )\n out.push(e);\n return out;\n}\n\nexport function tieToPost(\n it: Interaction,\n x: number,\n y: number,\n z: number,\n): boolean {\n const list = leashedHere(it);\n if (!list.length) return false;\n for (const m of list) m.leashPost = [x, y, z];\n events.emit(\"leashTied\", { x, y, z, n: list.length });\n return true;\n}\n"
|
|
737
|
+
},
|
|
738
|
+
{
|
|
739
|
+
"path": "player/interaction/frame.ts",
|
|
740
|
+
"content": "import { CONFIG } from \"../../config\";\nimport { events } from \"../../core/EventBus\";\nimport { Intent } from \"../../core/Intents\";\nimport { BLOCKS, B } from \"../../registry/Blocks\";\nimport { itemUseFor } from \"../../world/itemUse\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { raycastBlocks } from \"../../world/Raycast\";\nimport { BEHAVIOURS } from \"../../world/behaviours/index\";\nimport { isHive } from \"../../world/Hive\";\nimport { FISH_BUCKETS } from \"../../registry/items/treasure\";\nimport { CURE_ITEM } from \"../../systems/Zombify\";\nimport type { Input } from \"../Input\";\nimport { MILKABLE, bowPower } from \"./shared\";\nimport type { Interaction } from \"../Interaction\";\n\nexport function frame(it: Interaction, input: Input): void {\n const eye = it.player.eyePos;\n const dir = it.player.lookDir();\n const hit = raycastBlocks(it.world, eye, dir, CONFIG.player.reach);\n // сущность ближе блока?\n // сущности достаются сквозь траву, снежный слой, огонь (заменяемые блоки): иначе в лугу не\n // погладить волка и не ударить зомби. Вагонетка — своим лучом; моб перехватывает, если ближе\n const blockCap =\n hit && !BLOCKS[hit.id]?.replaceable ? hit.dist : CONFIG.player.reach;\n const cartHit = it.entities.raycastRide(eye, dir, blockCap);\n const entHit = it.entities.raycast(eye, dir, cartHit ? cartHit.t : blockCap);\n const cart = entHit ? null : (cartHit?.cart ?? null);\n it.targetEntity = entHit;\n it.target = entHit || cart ? null : hit;\n it._placeHighlight();\n\n // ЛКМ: по сущности — атака (повторяется по кулдауну), по блоку — ломание (удержание)\n const canAct = !input.uiOpen && !it.player.dead;\n if (input.leftDown && canAct) {\n if (entHit || cart) {\n // Замах по сущности шлёт тиковая половина (_attack) — ровно на каждый состоявшийся удар.\n it.intents.push(Intent.Attack, { entityId: (entHit ?? cart)!.id });\n if (it._sentBreakKey) {\n it.intents.push(Intent.BreakStop);\n it._sentBreakKey = null;\n }\n } else {\n if (it.target) {\n const k = it.target.x + \",\" + it.target.y + \",\" + it.target.z;\n if (it._sentBreakKey !== k) {\n it.intents.push(Intent.BreakStart, {\n x: it.target.x,\n y: it.target.y,\n z: it.target.z,\n });\n it._sentBreakKey = k;\n }\n } else if (it._sentBreakKey) {\n it.intents.push(Intent.BreakStop);\n it._sentBreakKey = null;\n }\n // по блоку (и в воздух) рука машет непрерывно, пока ЛКМ зажата\n if (it._swingTimer <= 0) {\n events.emit(\"armSwing\");\n it._swingTimer = CONFIG.player.swingTime;\n }\n }\n } else {\n if (it._sentBreakKey) {\n it.intents.push(Intent.BreakStop);\n it._sentBreakKey = null;\n }\n it._swingTimer = 0; // следующее нажатие — замах сразу\n }\n\n // ПКМ: приоритет — существо, которое можно покормить или остричь > блок с поведением (верстак,\n // дверь; присев — мимо него) > лук (натянуть) > еда > постановка\n const held = it.inventory.heldItem();\n const heldFood = held && (ITEMS[held.item]?.food ?? 0) > 0;\n const heldBow = held?.item === \"bow\";\n const heldCrossbow = held?.item === \"crossbow\";\n const crossbowLoaded = heldCrossbow && !!held?.tag?.loaded;\n const heldTrident = held?.item === \"trident\";\n const heldRod = held?.item === \"fishing_rod\";\n // ножницы и пузырёк по улью — собрать мёд (раньше ведра: пузырёк иначе искал бы воду)\n const hiveHarvest =\n !!it.target &&\n !!held &&\n isHive(it.target.id) &&\n (held.item === \"shears\" || held.item === \"glass_bottle\");\n const heldBoat = !!held?.item.endsWith(\"_boat\");\n const t = it.target;\n // забор «используется», когда у игрока есть животные на поводке — ПКМ привяжет их к нему\n const usable =\n !!t &&\n (!!BEHAVIOURS.get(t.id)?.use ||\n (BLOCKS[t.id]?.shape === \"fence\" && it._leashedHere().length > 0)) &&\n !input.sneak;\n // предмет со своим действием по блоку (мотыга — вспахать, костная мука — удобрить)\n // (пузырёк воды — только по земле, иначе его пьют)\n const itemUse =\n !!t &&\n !!held &&\n !usable &&\n !!itemUseFor(held.item) &&\n (held.item !== \"potion_water\" || t.id === B.dirt);\n // семена, морковь, картофель по верху пашни — посадить, даже если голоден (как в Minecraft)\n const heldBlock = held ? ITEMS[held.item]?.blockId : null;\n const plantHere =\n !!t &&\n typeof heldBlock === \"number\" &&\n BLOCKS[heldBlock]?.shape === \"crop\" &&\n t.id === B.farmland &&\n t.ny === 1;\n // Око Предела: по рамке — вставить (itemUse), куда угодно ещё — бросить к цитадели\n const throwEye = held?.item === \"rim_eye\" && !(t && t.id === B.rim_frame);\n // житель: ПКМ с чем угодно в руке — торговля (присев — мимо)\n const tradable = !!entHit && !!entHit.def.trades && !input.sneak;\n // ручные и приручаемые: лошадь (сесть, седло), волк и кошка (приручить едой, сидеть/встать)\n const petAct =\n !!entHit &&\n !input.sneak &&\n (!!entHit.def.rideable ||\n (!!entHit.def.tameItems &&\n (entHit.tamed ||\n (!!held && entHit.def.tameItems.includes(held.item)))));\n const feedable =\n !!cart ||\n petAct ||\n (!!entHit &&\n (tradable ||\n !!entHit.def.collector ||\n entHit.leashed ||\n (!!held && held.item === \"lead\" && entHit.def.category !== \"hostile\") ||\n (!!entHit.def.chestSlots &&\n ((input.sneak && entHit.items.length > 0) ||\n held?.item === \"chest\")) ||\n (!!held &&\n (held.item === \"name_tag\" ||\n held.item === \"shears\" ||\n (held.item === \"bucket\" && MILKABLE.has(entHit.def.name)) ||\n (held.item === \"bowl\" && entHit.def.name === \"mooshroom\") ||\n (held.item === \"gold_ingot\" && !!entHit.def.barters) ||\n (held.item === \"cookie\" && entHit.def.name === \"parrot\") ||\n (held.item.endsWith(\"_dye\") && !!entHit.def.dyeable) ||\n (held.item === \"brush\" && !!entHit.def.rollsUp) ||\n (held.item === CURE_ITEM && !!entHit.def.curable) ||\n (held.item === \"water_bucket\" && !!entHit.def.needsWater) ||\n !!entHit.def.temptItems?.includes(held.item)))));\n const heldDrink = !!held && !!ITEMS[held.item]?.drink;\n const bucketMode =\n held?.item === \"bucket\" || held?.item === \"glass_bottle\"\n ? \"fill\"\n : held?.item === \"water_bucket\" ||\n held?.item === \"lava_bucket\" ||\n held?.item === \"powder_snow_bucket\" ||\n (!!held && held.item in FISH_BUCKETS)\n ? \"empty\"\n : null;\n let eatingNow = false;\n let drawing = false;\n let blocking = false;\n if (input.rightDown && canAct && feedable) {\n if (!it._usedThisPress) {\n it.intents.push(Intent.InteractEntity, {\n entityId: (cart ?? entHit)!.id,\n });\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n }\n } else if (input.rightDown && canAct) {\n if (usable) {\n if (!it._usedThisPress && it._placeCooldown <= 0) {\n it.intents.push(Intent.UseBlock, { x: t.x, y: t.y, z: t.z });\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n it._placeCooldown = 0.25;\n }\n } else if (throwEye) {\n if (!it._usedThisPress) {\n it.intents.push(Intent.ThrowEye, {});\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n }\n } else if (hiveHarvest) {\n if (!it._usedThisPress) {\n const h = it.target!;\n it.intents.push(Intent.UseItem, {\n x: h.x,\n y: h.y,\n z: h.z,\n nx: h.nx,\n ny: h.ny,\n nz: h.nz,\n });\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n }\n } else if (heldRod) {\n // удочка: забросить или подсечь (Interaction._fish)\n if (!it._usedThisPress) {\n it.intents.push(Intent.Fish, {});\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n }\n } else if (held?.item === \"map\") {\n // пустая карта — завести карту этого участка (Game.createMap)\n if (!it._usedThisPress) {\n it.intents.push(Intent.UseHeld, {});\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n }\n } else if (held?.item === \"rocket\") {\n // ракета: в полёте на крыльях — рывок вдоль взгляда, на земле — фейерверк с той клетки,\n // куда смотрим (нет цели — из-под ног), интент UseHeld\n if (!it._usedThisPress) {\n const h = it.target;\n it.intents.push(\n Intent.UseHeld,\n h ? { x: h.x + h.nx, y: h.y + h.ny, z: h.z + h.nz } : {},\n );\n it._usedThisPress = true;\n }\n } else if (held?.item === \"bundle\") {\n // мешок в мире: ПКМ — высыпать всё (интент UseHeld)\n if (!it._usedThisPress) {\n it.intents.push(Intent.UseHeld, {});\n it._usedThisPress = true;\n }\n } else if (held?.item === \"snowball\" || held?.item === \"rim_pearl\") {\n // снежок: бросок (интент UseHeld), не чаще раза в 0.25 с\n if (!it._usedThisPress && it._placeCooldown <= 0) {\n it.intents.push(Intent.UseHeld, {});\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n it._placeCooldown = 0.25;\n }\n } else if (held?.item === \"wind_charge\") {\n // заряд ветра: бросок (интент UseHeld), не чаще раза в 0.5 с\n if (!it._usedThisPress && it._placeCooldown <= 0) {\n it.intents.push(Intent.UseHeld, {});\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n it._placeCooldown = 0.5;\n }\n } else if (heldBoat) {\n // лодка — на воду под прицелом (обычный луч воду пропускает) или на блок\n const lh = raycastBlocks(\n it.world,\n it.player.eyePos,\n it.player.lookDir(),\n CONFIG.player.reach,\n true,\n );\n if (lh && !it._usedThisPress) {\n it.intents.push(Intent.UseItem, {\n x: lh.x,\n y: lh.y,\n z: lh.z,\n nx: lh.nx,\n ny: lh.ny,\n nz: lh.nz,\n });\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n }\n } else if (itemUse) {\n // кисть чистит, пока ПКМ зажата (взмах раз в 0.5 с); остальные предметы — раз на нажатие\n const brush = held!.item === \"brush\";\n if (brush ? it._placeCooldown <= 0 : !it._usedThisPress) {\n it.intents.push(Intent.UseItem, {\n x: t.x,\n y: t.y,\n z: t.z,\n nx: t.nx,\n ny: t.ny,\n nz: t.nz,\n });\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n if (brush) it._placeCooldown = 0.5;\n }\n } else if (held?.item === \"shield\") {\n blocking = true; // щит: пока держим ПКМ — прикрываемся\n } else if (crossbowLoaded) {\n // заряженный арбалет — выстрел одним нажатием\n if (!it._usedThisPress) {\n it.intents.push(Intent.ShootBow, { power: 1, charge: 0 });\n it._usedThisPress = true;\n }\n } else if (heldBow || heldCrossbow || heldTrident) {\n // лук натягиваем, арбалет заряжаем, трезубец замахиваемся — удержанием ПКМ\n drawing = true;\n } else if (bucketMode === \"fill\") {\n // пустое ведро — по жидкости под прицелом (обычный луч жидкость пропускает)\n const lh = raycastBlocks(\n it.world,\n it.player.eyePos,\n it.player.lookDir(),\n CONFIG.player.reach,\n true,\n );\n if (lh && BLOCKS[lh.id]!.liquid && !it._usedThisPress) {\n it.intents.push(Intent.Bucket, {\n mode: \"fill\",\n x: lh.x,\n y: lh.y,\n z: lh.z,\n });\n it._usedThisPress = true;\n }\n } else if (\n (bucketMode === \"empty\" || held?.item === \"flint_and_steel\") &&\n t\n ) {\n if (!it._usedThisPress) {\n it.intents.push(bucketMode ? Intent.Bucket : Intent.Ignite, {\n mode: \"empty\",\n x: t.x,\n y: t.y,\n z: t.z,\n nx: t.nx,\n ny: t.ny,\n nz: t.nz,\n });\n events.emit(\"armSwing\");\n it._usedThisPress = true;\n }\n } else if (heldDrink) {\n // питьё (зелья, молоко) — как еда, но и сытым, и в креативе\n eatingNow = true;\n if (it._sentEat !== held.item) {\n it.intents.push(Intent.EatStart, { item: held.item });\n it._sentEat = held.item;\n }\n } else if (\n heldFood &&\n !plantHere &&\n it.player.food < CONFIG.player.maxFood &&\n it.player.mode !== \"creative\"\n ) {\n eatingNow = true;\n if (it._sentEat !== held.item) {\n it.intents.push(Intent.EatStart, { item: held.item });\n it._sentEat = held.item;\n }\n } else if (t && it._placeCooldown <= 0) {\n it.intents.push(Intent.PlaceBlock, {\n x: t.x,\n y: t.y,\n z: t.z,\n nx: t.nx,\n ny: t.ny,\n nz: t.nz,\n hy: t.hy,\n yaw: it.player.yaw,\n pitch: it.player.pitch,\n });\n events.emit(\"armSwing\");\n it._placeCooldown = 0.25;\n }\n }\n // лук: отпустили — выстрел с силой натяжения (как в Minecraft: полная сила за 1 секунду)\n if (!drawing && it.bowCharge > 0) {\n if (it.bowCharge >= 0.1)\n it.intents.push(Intent.ShootBow, {\n power: bowPower(it.bowCharge),\n charge: it.bowCharge,\n });\n it.bowCharge = 0;\n }\n it._drawing = drawing;\n it.player.blocking = blocking;\n if (!eatingNow && it._sentEat) {\n it.intents.push(Intent.EatStop);\n it._sentEat = null;\n }\n if (!input.rightDown) {\n it._placeCooldown = 0;\n it._usedThisPress = false;\n }\n\n // визуализация трещин: активное ломание либо запомненный урон на блоке под прицелом\n let crack: { x: number; y: number; z: number; frac: number } | null = null;\n if (it.breaking && it.breaking.total > 0.06) {\n crack = {\n x: it.breaking.x,\n y: it.breaking.y,\n z: it.breaking.z,\n frac: it.breaking.progress,\n };\n } else if (it.target) {\n const d = it.damage.get(\n it.target.x + \",\" + it.target.y + \",\" + it.target.z,\n );\n const total = it._breakSecs(it.target.id);\n if (d && d.id === it.target.id && d.progress > 0 && total > 0.06)\n crack = {\n x: it.target.x,\n y: it.target.y,\n z: it.target.z,\n frac: d.progress,\n };\n }\n\n if (crack) {\n const stage = Math.min(9, Math.floor(crack.frac * 10));\n if (stage !== it._crackStage) {\n const uv = it.atlas.uv(\"crack\" + stage);\n // двигаем offset/repeat клона атласа на нужный тайл\n it.crackMat.map!.repeat.set(uv.u1 - uv.u0, uv.v1 - uv.v0);\n it.crackMat.map!.offset.set(uv.u0, uv.v0);\n it._crackStage = stage;\n }\n it.crackMesh.visible = true;\n it.crackMesh.position.set(crack.x + 0.5, crack.y + 0.5, crack.z + 0.5);\n } else {\n it.crackMesh.visible = false;\n it._crackStage = -1;\n }\n}\n\nexport function frameTimers(it: Interaction, dt: number): void {\n if (it._placeCooldown > 0) it._placeCooldown -= dt;\n if (it._swingTimer > 0) it._swingTimer -= dt;\n if (it._drawing) it.bowCharge += dt;\n}\n"
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
"path": "player/interaction/items.ts",
|
|
744
|
+
"content": "import * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport type { IntentMsg } from \"../../core/Intents\";\nimport { BLOCKS, B, isWaterSource, packWater } from \"../../registry/Blocks\";\nimport { DIMENSIONS } from \"../../world/Dimensions\";\nimport { itemUseFor } from \"../../world/itemUse\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { fishLoot } from \"../../systems/Fishing\";\nimport { harvestHive, isHive } from \"../../world/Hive\";\nimport { FISH_BUCKETS } from \"../../registry/items/treasure\";\nimport { bundleItems } from \"../../systems/Bundle\";\nimport type { Interaction } from \"../Interaction\";\n\nexport function fish(it: Interaction): void {\n if (it.inventory.heldItem()?.item !== \"fishing_rod\") return;\n const hook = it.entities.playerHook();\n const p = it.player.pos;\n if (hook) {\n if (hook.bite > 0) {\n const loot = fishLoot(Math.random);\n if (it.inventory.addStack(loot) > 0)\n it.entities.spawnDrop(\n p.x,\n p.y + 1,\n p.z,\n loot.item,\n loot.count,\n loot.dmg,\n );\n it.entities.spawnXp(\n p.x,\n p.y + 0.5,\n p.z,\n 1 + Math.floor(Math.random() * 6),\n );\n events.emit(\"fishCaught\", { item: loot.item });\n if (it.player.mode !== \"creative\") it.inventory.damageHeld(1);\n }\n hook.dead = true;\n return;\n }\n const eye = it.player.eyePos,\n dir = it.player.lookDir();\n it.entities.castHook(\n eye.clone().addScaledVector(dir, 0.5),\n dir\n .clone()\n .multiplyScalar(14)\n .add(new THREE.Vector3(0, 2, 0)),\n );\n events.emit(\"fishCast\");\n}\n\nexport function throwEye(it: Interaction): void {\n const held = it.inventory.heldItem();\n if (held?.item !== \"rim_eye\") return;\n const from = it.player.eyePos;\n if (it.player.mode !== \"creative\") it.inventory.consumeHeld(1);\n events.emit(\"throwEye\", { x: from.x, y: from.y, z: from.z });\n}\n\nexport function useItem(it: Interaction, intent: IntentMsg): void {\n const { x, y, z } = intent;\n if (!it._validDistance(x, y, z)) return;\n const held = it.inventory.heldItem();\n // мёд с полного улья: ножницы — 3 соты у улья, пузырёк — бутылочка в руку\n if (\n held &&\n (held.item === \"shears\" || held.item === \"glass_bottle\") &&\n isHive(it.world.getBlock(x, y, z))\n ) {\n const loot = harvestHive(it.world, x, y, z, held.item);\n if (!loot) return;\n if (held.item === \"shears\") {\n it.entities.spawnDrop(x + 0.5, y + 1, z + 0.5, loot.item, loot.count);\n if (it.player.mode !== \"creative\") it.inventory.damageHeld(1);\n } else giveHeld(it, loot.item);\n return;\n }\n const fn = held ? itemUseFor(held.item) : undefined;\n if (!held || !fn) return;\n const creative = it.player.mode === \"creative\";\n fn({\n world: it.world,\n x,\n y,\n z,\n id: it.world.getBlock(x, y, z),\n meta: it.world.getMeta(x, y, z),\n nx: intent.nx,\n ny: intent.ny,\n nz: intent.nz,\n item: held.item,\n rng: Math.random,\n consume: () => {\n if (!creative) it.inventory.consumeHeld(1);\n },\n wear: (n = 1) => {\n if (!creative) it.inventory.damageHeld(n);\n },\n give: (item) => {\n if (!creative) giveHeld(it, item);\n },\n });\n}\n\nexport function giveHeld(it: Interaction, item: string): void {\n if (it.inventory.swapHeldOne(item)) {\n const p = it.player.pos;\n it.entities.spawnDrop(p.x, p.y + 1, p.z, item, 1);\n }\n}\n\nexport function bucket(it: Interaction, intent: IntentMsg): void {\n const { x, y, z } = intent;\n if (!it._validDistance(x, y, z)) return;\n const held = it.inventory.heldItem();\n if (!held) return;\n if (intent.mode === \"fill\") {\n const id = it.world.getBlock(x, y, z);\n if (held.item === \"glass_bottle\") {\n // пузырёк набирает воду, не забирая источник (как в Minecraft)\n if (id !== B.water) return;\n giveHeld(it, \"potion_water\");\n events.emit(\"bucketFill\", { item: \"potion_water\" });\n return;\n }\n if (held.item !== \"bucket\") return;\n if (id === B.powder_snow) {\n it.world.setBlock(x, y, z, B.air!);\n giveHeld(it, \"powder_snow_bucket\");\n events.emit(\"bucketFill\", { item: \"powder_snow_bucket\" });\n return;\n }\n if (!BLOCKS[id]?.liquid || !isWaterSource(it.world.getMeta(x, y, z)))\n return;\n it.world.setBlock(x, y, z, B.air!);\n const full = id === B.lava ? \"lava_bucket\" : \"water_bucket\";\n giveHeld(it, full);\n events.emit(\"bucketFill\", { item: full });\n return;\n }\n // ведро с рыбой выливается водой, а рыба выплывает\n const fish = FISH_BUCKETS[held.item];\n const fluid =\n held.item === \"water_bucket\" || fish\n ? B.water!\n : held.item === \"lava_bucket\"\n ? B.lava!\n : held.item === \"powder_snow_bucket\"\n ? B.powder_snow!\n : -1;\n if (fluid < 0) return;\n const hitId = it.world.getBlock(x, y, z);\n const inPlace = BLOCKS[hitId]?.replaceable ?? false;\n const cx = inPlace ? x : x + intent.nx,\n cy = inPlace ? y : y + intent.ny,\n cz = inPlace ? z : z + intent.nz;\n const cur = it.world.getBlock(cx, cy, cz);\n if (cur !== B.air && !BLOCKS[cur]?.replaceable) return;\n if (fluid === B.water && DIMENSIONS[it.world.dimension].waterEvaporates) {\n events.emit(\"evaporate\", { x: cx, y: cy, z: cz }); // в Пекле вода испаряется\n } else {\n it.world.setBlock(\n cx,\n cy,\n cz,\n fluid,\n fluid === B.powder_snow ? 0 : packWater(8, true),\n );\n if (fish) it.entities.spawnMob(fish, cx + 0.5, cy + 0.1, cz + 0.5);\n }\n if (it.player.mode !== \"creative\") giveHeld(it, \"bucket\");\n events.emit(\"bucketEmpty\", { item: held.item });\n}\n\nexport function ignite(it: Interaction, intent: IntentMsg): void {\n const { x, y, z } = intent;\n if (!it._validDistance(x, y, z)) return;\n const held = it.inventory.heldItem();\n if (held?.item !== \"flint_and_steel\") return;\n const id = it.world.getBlock(x, y, z);\n if (id === B.tnt) {\n it.world.setBlock(x, y, z, B.air!);\n it.entities.spawnTnt(x + 0.5, y, z + 0.5, 4);\n } else {\n const cx = x + intent.nx,\n cy = y + intent.ny,\n cz = z + intent.nz;\n if (it.world.getBlock(cx, cy, cz) !== B.air) return;\n // на песке и почве душ огниво зажигает огонь душ\n const soul = [B.soul_sand, B.soul_soil].includes(\n it.world.getBlock(cx, cy - 1, cz),\n );\n it.world.setBlock(cx, cy, cz, soul ? B.soul_fire! : B.fire!, 0);\n events.emit(\"fireLit\", { x: cx, y: cy, z: cz }); // в рамке зажжёт портал (behaviours/fire)\n }\n if (it.player.mode !== \"creative\") it.inventory.damageHeld(1);\n}\n\nexport function dropItem(it: Interaction, intent: IntentMsg): void {\n const held = it.inventory.heldItem();\n if (!held) return;\n const n = intent.all ? held.count : 1;\n const dir = it.player.lookDir();\n const from = it.player.eyePos.addScaledVector(dir, 0.4);\n const drop = it.entities.spawnDrop(\n from.x,\n from.y - 0.3,\n from.z,\n held.item,\n n,\n held.dmg,\n held.tag,\n );\n drop.vel\n .copy(dir)\n .multiplyScalar(5)\n .setY(dir.y * 5 + 1.5);\n drop.pickupDelay = 2;\n it.inventory.consumeHeld(n);\n events.emit(\"itemThrow\", { item: held.item });\n}\n\nexport function emptyBundle(it: Interaction): void {\n const held = it.inventory.heldItem();\n if (held?.item !== \"bundle\") return;\n const items = bundleItems(held);\n if (!items.length) return;\n const p = it.player.eyePos.clone().addScaledVector(it.player.lookDir(), 0.8);\n for (const s of items)\n it.entities.spawnDrop(p.x, p.y - 0.3, p.z, s.item, s.count, s.dmg, s.tag);\n it.inventory.setSlot(it.inventory.hotbarIndex, {\n ...held,\n tag: { ...(held.tag ?? {}), items: [] },\n });\n events.emit(\"bundleRemove\", { all: true });\n}\n\nexport function pickBlock(it: Interaction): void {\n const t = it.target;\n if (!t) return;\n const def = BLOCKS[t.id]!;\n const name = def.item ?? def.name;\n if (!ITEMS[name]) return;\n const inv = it.inventory;\n const hot = inv.slots.findIndex((s, i) => i < 9 && s?.item === name);\n if (hot >= 0) return inv.selectSlot(hot);\n const main = inv.slots.findIndex((s, i) => i >= 9 && s?.item === name);\n if (main >= 0) {\n const tmp = inv.slots[inv.hotbarIndex] ?? null;\n inv.setSlot(inv.hotbarIndex, inv.slots[main] ?? null);\n inv.setSlot(main, tmp);\n return;\n }\n if (it.player.mode === \"creative\")\n inv.setSlot(inv.hotbarIndex, {\n item: name,\n count: ITEMS[name]!.maxStack,\n });\n}\n"
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
"path": "player/interaction/projectiles.ts",
|
|
748
|
+
"content": "import { events } from \"../../core/EventBus\";\nimport type { IntentMsg } from \"../../core/Intents\";\nimport { isArrow, tippedEffect } from \"../../systems/TippedArrow\";\nimport { enchLevel } from \"../../systems/Enchant\";\nimport { CROSSBOW_CHARGE, TRIDENT_CHARGE } from \"../../registry/items/ranged\";\nimport { PLAYER_SHOT } from \"../../entities/Fireball\";\nimport type { ItemStack } from \"../../types\";\nimport type { Interaction } from \"../Interaction\";\n\nexport function throwSnowball(it: Interaction): void {\n const dir = it.player.lookDir();\n it.entities.spawnFireball(\n \"snowball\",\n it.player.eyePos.clone().addScaledVector(dir, 0.6),\n dir.clone().multiplyScalar(20),\n 0,\n PLAYER_SHOT,\n );\n if (it.player.mode !== \"creative\") it.inventory.consumeHeld(1);\n events.emit(\"snowballThrow\");\n}\n\nexport function throwPearl(it: Interaction): void {\n const dir = it.player.lookDir();\n it.entities.spawnFireball(\n \"pearl\",\n it.player.eyePos.clone().addScaledVector(dir, 0.6),\n dir.clone().multiplyScalar(22),\n 0,\n PLAYER_SHOT,\n );\n if (it.player.mode !== \"creative\") it.inventory.consumeHeld(1);\n events.emit(\"pearlThrow\");\n}\n\nexport function throwWindCharge(it: Interaction): void {\n const dir = it.player.lookDir();\n it.entities.spawnFireball(\n \"wind_charge\",\n it.player.eyePos.clone().addScaledVector(dir, 0.6),\n dir.clone().multiplyScalar(20),\n 0,\n PLAYER_SHOT,\n );\n if (it.player.mode !== \"creative\") it.inventory.consumeHeld(1);\n events.emit(\"windThrow\");\n}\n\nexport function crossbow(\n it: Interaction,\n held: ItemStack,\n charge: number,\n): void {\n const creative = it.player.mode === \"creative\";\n if (held.tag?.loaded) {\n const dir = it.player.lookDir();\n const from = it.player.eyePos.addScaledVector(dir, 0.4);\n it.entities.spawnArrow(from, dir.clone().multiplyScalar(75), 9, true, 0);\n it.inventory.setSlot(it.inventory.hotbarIndex, {\n ...held,\n tag: { ...held.tag, loaded: false },\n });\n if (!creative) it.inventory.damageHeld(1);\n events.emit(\"crossbowShoot\");\n return;\n }\n if (charge < CROSSBOW_CHARGE) return;\n if (!creative) {\n const bolt = it.inventory.slots.find((s) => s && isArrow(s.item));\n if (!bolt) return;\n it.inventory.consume(bolt.item, 1);\n }\n it.inventory.setSlot(it.inventory.hotbarIndex, {\n ...held,\n tag: { ...(held.tag ?? {}), loaded: true },\n });\n events.emit(\"crossbowLoad\");\n}\n\nexport function throwTrident(\n it: Interaction,\n held: ItemStack,\n charge: number,\n): void {\n if (charge < TRIDENT_CHARGE) return;\n const dir = it.player.lookDir();\n const from = it.player.eyePos.addScaledVector(dir, 0.4);\n const a = it.entities.spawnArrow(\n from,\n dir.clone().multiplyScalar(60),\n 8,\n true,\n 0,\n );\n a.asTrident({\n item: \"trident\",\n count: 1,\n dmg: (held.dmg ?? 0) + 1,\n ...(held.tag ? { tag: held.tag } : {}),\n });\n if (it.player.mode !== \"creative\")\n it.inventory.setSlot(it.inventory.hotbarIndex, null);\n events.emit(\"tridentThrow\");\n}\n\nexport function shootBow(it: Interaction, { power, charge }: IntentMsg): void {\n const held = it.inventory.heldItem();\n if (held?.item === \"crossbow\") return crossbow(it, held, Number(charge) || 0);\n if (held?.item === \"trident\")\n return throwTrident(it, held, Number(charge) || 0);\n if (held?.item !== \"bow\") return;\n // какой стрелой стреляем: первая по порядку слотов, как в Minecraft (в креативе — обычная)\n const ammo =\n it.player.mode === \"creative\"\n ? \"arrow\"\n : (it.inventory.slots.find((s) => s && isArrow(s.item))?.item ?? null);\n if (it.player.mode !== \"creative\") {\n if (!ammo) return;\n it.inventory.consume(ammo, 1);\n it.inventory.damageHeld(1);\n }\n const p = Math.max(0.1, Math.min(1, Number(power) || 0));\n const dir = it.player.lookDir();\n const from = it.player.eyePos.addScaledVector(dir, 0.4);\n const shot = it.entities.spawnArrow(\n from,\n dir.clone().multiplyScalar(60 * p),\n // Сила лука: +25% × (уровень + 1)\n Math.max(\n 1,\n Math.round(\n 9 *\n p *\n (1 +\n (enchLevel(held, \"power\") > 0\n ? 0.25 * (enchLevel(held, \"power\") + 1)\n : 0)),\n ),\n ),\n true,\n 0,\n );\n // отравленная стрела несёт свой эффект и подбирается собой (systems/TippedArrow)\n if (ammo && ammo !== \"arrow\") {\n shot.effect = tippedEffect(ammo);\n shot.pick = { item: ammo, count: 1 };\n }\n events.emit(\"bowShoot\", { power: p });\n}\n"
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
"path": "player/interaction/shared.ts",
|
|
752
|
+
"content": "/** Кого доят ведром. */\nexport const MILKABLE = new Set([\"cow\", \"goat\", \"mooshroom\"]);\n\n/** Сила лука по времени натяжения — формула Minecraft (полная за 1 с). */\nexport function bowPower(seconds: number): number {\n const f = seconds / 1;\n return Math.min(1, (f * f + 2 * f) / 3);\n}\n"
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
"path": "player/interaction/tick.ts",
|
|
756
|
+
"content": "import { CONFIG } from \"../../config\";\nimport { events } from \"../../core/EventBus\";\nimport { Intent, type IntentMsg } from \"../../core/Intents\";\nimport { ITEMS } from \"../../registry/Items\";\nimport type { Interaction } from \"../Interaction\";\n\nexport function tick(it: Interaction, dt: number): void {\n if (it._attackCooldown > 0) it._attackCooldown -= dt;\n\n it.intents.drain((intent: IntentMsg) => {\n switch (intent.type) {\n case Intent.BreakStart:\n it._startBreak(intent);\n break;\n case Intent.BreakStop:\n it.breaking = null;\n break;\n case Intent.PlaceBlock:\n it._place(intent);\n break;\n case Intent.UseBlock:\n it._use(intent);\n break;\n case Intent.InteractEntity:\n it._interactEntity(intent);\n break;\n case Intent.ShootBow:\n it._shootBow(intent);\n break;\n case Intent.Bucket:\n it._bucket(intent);\n break;\n case Intent.Ignite:\n it._ignite(intent);\n break;\n case Intent.DropItem:\n it._dropItem(intent);\n break;\n case Intent.PickBlock:\n it._pickBlock();\n break;\n case Intent.UseItem:\n it._useItem(intent);\n break;\n case Intent.ThrowEye:\n it._throwEye();\n break;\n case Intent.Fish:\n it._fish();\n break;\n case Intent.UseHeld:\n if (it.inventory.heldItem()?.item === \"map\") it.onUseMap?.();\n else if (it.inventory.heldItem()?.item === \"rocket\") {\n // в полёте на крыльях — рывок, на земле — фейерверк вверх (gfx/Fireworks)\n const boosted = it.player.boost();\n if (!boosted) {\n const p = it.player.pos;\n events.emit(\"fireworkLaunch\", {\n x: Math.floor(intent.x ?? p.x),\n y: Math.floor(intent.y ?? p.y),\n z: Math.floor(intent.z ?? p.z),\n });\n }\n if (it.player.mode !== \"creative\") it.inventory.consumeHeld(1);\n events.emit(boosted ? \"rocketBoost\" : \"fireworkShot\");\n } else if (it.inventory.heldItem()?.item === \"wind_charge\")\n it._throwWindCharge();\n else if (it.inventory.heldItem()?.item === \"bundle\") it._emptyBundle();\n else if (it.inventory.heldItem()?.item === \"snowball\")\n it._throwSnowball();\n else if (it.inventory.heldItem()?.item === \"rim_pearl\")\n it._throwPearl();\n break;\n case Intent.EatStart:\n it._startEat(intent);\n break;\n case Intent.EatStop:\n it.eating = null;\n break;\n case Intent.Attack:\n it._attack(intent);\n break;\n case Intent.SelectSlot:\n if (intent.slot !== undefined) it.inventory.selectSlot(intent.slot);\n else it.inventory.scrollSlot(intent.delta);\n break;\n case Intent.ToggleMode:\n // сетевая игра: креатив — право оператора (Game подключает проверку)\n if (it.mayToggleMode?.() ?? true) it.player.toggleMode();\n else\n events.emit(\"chat\", { text: \"Смена режима — только у операторов\" });\n break;\n case Intent.Command:\n it.onCommand?.(String(intent.text ?? \"\"));\n break;\n }\n });\n\n // прогресс ломания\n let activeKey: string | null = null;\n if (it.breaking) {\n const { x, y, z } = it.breaking;\n if (it.world.getBlock(x, y, z) !== it.breaking.id) {\n it.damage.delete(x + \",\" + y + \",\" + z);\n it.breaking = null;\n } else {\n // доля прочности за тик; инструмент в руке мог смениться посреди ломания\n const secs = it._breakSecs(it.breaking.id);\n it.breaking.progress += secs > 0 ? dt / secs : 1;\n activeKey = x + \",\" + y + \",\" + z;\n // запоминаем урон (heal:0 — таймер заживления сбрасывается, пока бьём)\n it.damage.set(activeKey, {\n id: it.breaking.id,\n progress: it.breaking.progress,\n heal: 0,\n });\n if (it.breaking.progress >= 1) {\n it._finishBreak(x, y, z, it.breaking.id);\n it.damage.delete(activeKey);\n it.breaking = null;\n }\n }\n }\n\n // заживление урона по блокам, которые сейчас не ломают\n for (const [k, d] of it.damage) {\n if (k === activeKey) continue;\n d.heal += dt;\n if (d.heal < CONFIG.player.breakHealDelay) continue;\n d.progress -= dt * CONFIG.player.breakHealRate;\n if (d.progress <= 0) it.damage.delete(k);\n }\n\n // процесс поедания\n if (it.eating) {\n const held = it.inventory.heldItem();\n const drink = ITEMS[it.eating.item]?.drink;\n if (\n !held ||\n held.item !== it.eating.item ||\n (!drink && it.player.food >= CONFIG.player.maxFood)\n ) {\n it.eating = null;\n } else {\n it.eating.time += dt;\n events.emit(\"armSwing\"); // лёгкое подёргивание еды в руке\n if (it.eating.time >= CONFIG.player.eatTime) {\n if (drink) {\n it.player.drink(drink);\n // выпитое оставляет пузырёк или ведро (в креативе ничего не тратится)\n const rem = ITEMS[it.eating.item]?.remainder;\n if (it.player.mode !== \"creative\") {\n if (rem) it._giveHeld(rem);\n else it.inventory.consumeHeld(1);\n }\n } else if (it.player.eat(it.eating.item)) {\n // съеденный суп оставляет миску\n const rem = ITEMS[it.eating.item]?.remainder;\n if (rem && it.player.mode !== \"creative\") it._giveHeld(rem);\n else it.inventory.consumeHeld(1);\n }\n it.eating = null;\n }\n }\n }\n}\n"
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
"path": "player/interaction/types.ts",
|
|
760
|
+
"content": "import type { EntityManager } from \"../../entities/EntityManager\";\n\n/** Ближайшая сущность под прицелом. */\nexport type EntityHit = ReturnType<EntityManager[\"raycast\"]>;\n\n/** Состояние ломания блока (тиковая половина). */\nexport interface BreakInfo {\n x: number;\n y: number;\n z: number;\n id: number;\n /** Доля прочности, сбитая до сих пор (0..1). */\n progress: number;\n /** Секунд ломания на старте — порог показа трещин. */\n total: number;\n}\n\n/** Накопленный урон по блоку: доля прочности (0..1) + таймер заживления. */\nexport interface DamageInfo {\n id: number;\n progress: number;\n heal: number;\n}\n"
|
|
557
761
|
},
|
|
558
762
|
{
|
|
559
763
|
"path": "player/Placement.ts",
|
|
@@ -561,7 +765,7 @@
|
|
|
561
765
|
},
|
|
562
766
|
{
|
|
563
767
|
"path": "player/Player.ts",
|
|
564
|
-
"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 /** Присел (Ctrl): медленнее, глаз ниже, с края не сходит. */\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 // присесть — Ctrl (в полёте Ctrl — вниз); бег доступен, пока еды достаточно (как в 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/Ctrl\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"
|
|
768
|
+
"content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\nimport * as THREE from \"three\";\nimport type { DrinkDef, EffectId } from \"../types\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { findSafeSpot, isStandable } from \"../world/safeSpot\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\nimport type { Rideable } from \"../entities/Rideable\";\nimport {\n update,\n moveStep,\n touchesLadder,\n mount,\n dismount,\n} from \"./body/movement\";\nimport {\n damage,\n addExhaustion,\n tickHazards,\n tickHunger,\n eat,\n die,\n respawn,\n resetForNewWorld,\n} from \"./body/vitals\";\nimport {\n addEffect,\n clearEffects,\n drink,\n tickEffects,\n boost,\n echoHop,\n} from \"./body/effects\";\nimport { serialize, deserialize } from \"./body/save\";\nimport type { PlayerMode, PlayerSaveData } from \"./body/types\";\nimport { MAX_AIR, P } from \"./body/shared\";\n\nexport { MAX_AIR } from \"./body/shared\";\nexport { armorReduce } from \"./body/vitals\";\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 /** Присел (Ctrl): медленнее, глаз ниже, с края не сходит. */\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 /** Стоит в огне; сколько ещё гореть (сек) и до следующего урона от горения. */\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 _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\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 update(this, dt, input);\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 mount(this, cart);\n }\n\n /** Выйти из вагонетки — встать над ней. */\n dismount(): void {\n dismount(this);\n }\n\n /** Шаг интеграции: коллизии по коробкам форм (physics/Collision), шаг на ступень, край при приседании. */\n _moveStep(dt: number): void {\n moveStep(this, dt);\n }\n\n /** Касается ли тело лестницы (клетки, которые пересекает AABB, чуть расширенный вбок). */\n _touchesLadder(): boolean {\n return touchesLadder(this);\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 damage(this, amount, cause);\n }\n\n addExhaustion(n: number): void {\n addExhaustion(this, n);\n }\n\n /**\n * Тик опасностей среды (симуляция), как в Minecraft: лава — 4 урона каждые полсекунды и поджигает\n * на 15 с, огонь — поджигает на 8 с; горящий теряет 1 в секунду, вода тушит.\n */\n tickHazards(dt: number): void {\n tickHazards(this, dt);\n }\n\n /** Тик голода (симуляция, 20 TPS): истощение → еда, регенерация, голодание. */\n tickHunger(dt: number): void {\n tickHunger(this, dt);\n }\n\n /** Съесть предмет (если это еда и есть куда). @returns {boolean} успех */\n eat(itemName: string): boolean {\n return eat(this, itemName);\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 return boost(this);\n }\n\n /** Плод эхоцвета: прыжок в случайную точку до 8 блоков, где есть пол и место на рост. */\n echoHop(): boolean {\n return echoHop(this);\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 addEffect(this, id, seconds, level);\n }\n\n clearEffects(): void {\n clearEffects(this);\n }\n\n /** Выпить: зелье накладывает эффект, молоко снимает все. */\n drink(d: DrinkDef): void {\n drink(this, d);\n }\n\n /** Сроки эффектов; Регенерация лечит 1 здоровье за 2.5 с (вдвое чаще за уровень). */\n _tickEffects(dt: number): void {\n tickEffects(this, dt);\n }\n\n /** Смерть: экран «Вы погибли» и выпадение вещей решает Game (подписан на playerDied). */\n _die(): void {\n die(this);\n }\n\n /** Возродиться в точке (кровать или точка мира) с полным здоровьем и сытостью. */\n respawn(at: THREE.Vector3): void {\n respawn(this, at);\n }\n\n /**\n * Новый мир — новый игрок: всё, что принадлежит старому миру (кровать, якорь, путевой камень,\n * репутация деревни) и телу в нём (эффекты, горение, воздух, опыт, полёт на крыльях), не переносится.\n * Режим игры и взгляд остаются. Место и здоровье ставит следом spawn().\n */\n resetForNewWorld(): void {\n resetForNewWorld(this);\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 serialize(this);\n }\n\n deserialize(d: PlayerSaveData): void {\n deserialize(this, d);\n }\n}\n"
|
|
565
769
|
},
|
|
566
770
|
{
|
|
567
771
|
"path": "registry/aliases.ts",
|
|
@@ -1323,9 +1527,33 @@
|
|
|
1323
1527
|
"path": "ui/HUD.ts",
|
|
1324
1528
|
"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"
|
|
1325
1529
|
},
|
|
1530
|
+
{
|
|
1531
|
+
"path": "ui/inventory/build.ts",
|
|
1532
|
+
"content": "import { ITEMS } from \"../../registry/Items\";\nimport { labelOf } from \"../../registry/names\";\nimport { ARMOR_SLOTS } from \"../../systems/Inventory\";\nimport type { FurnaceState } from \"../../world/blockentities/furnace\";\nimport type { BrewingState } from \"../../world/blockentities/brewing\";\nimport { TRIM_MATERIALS } from \"../../registry/items/trims\";\nimport { stonecutOptions } from \"../../systems/Stonecutter\";\nimport { bannerColor, dyeColor } from \"../../systems/Banner\";\nimport { canEnchant } from \"../../systems/Enchant\";\nimport type { ChestState } from \"../../world/blockentities/chest\";\nimport { TIER_NAMES, levelOf } from \"../../systems/Trading\";\nimport { buildCreativePalette } from \"../creativePalette\";\nimport type { ScreenKind, SlotRef } from \"./types\";\nimport type { InventoryUI } from \"../InventoryUI\";\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\nexport function build(ui: InventoryUI) {\n const inv = ui.inventory;\n ui.refs = [];\n ui.resultEl = ui.flameEl = ui.arrowEl = null;\n ui.panelEl.innerHTML = \"\";\n const h = document.createElement(\"h3\");\n h.id = \"invTitle\";\n h.textContent = ui.cart\n ? labelOf(ui.cart.kind === \"mob\" ? ui.cart.def.name : ui.cart.dropItem)\n : ui.kind === \"trade\" && ui.tradeMob\n ? `${labelOf(ui.tradeMob.def.name)} — ${TIER_NAMES[levelOf(ui.tradeMob.villagerXp) - 1]}`\n : // воронка, раздатчик, бочка, коптильня, плавильная печь — своим именем (горящие — как погасшие)\n (ui.kind === \"chest\" || ui.kind === \"furnace\") &&\n ui.be &&\n ![\"chest\", \"furnace\", \"lit_furnace\"].includes(ui._beBlockName())\n ? labelOf(ui._beBlockName().replace(/^lit_/, \"\"))\n : TITLES[ui.kind];\n ui.panelEl.appendChild(h);\n\n if (ui.kind === \"creative\")\n buildCreativePalette(ui.panelEl, (item, right, shift) =>\n ui._clickPalette(item, right, shift),\n );\n\n const top = div(ui, ui.panelEl, \"invTop\");\n if (ui.kind === \"creative\") {\n // корзина: положенный сюда стак исчезает (ПКМ — по одному)\n div(ui, top, \"trashLabel\").textContent = \"Удалить:\";\n slot(ui, top, {\n group: \"trash\",\n get: () => null,\n set: () => {},\n }).classList.add(\"trash\");\n }\n if (ui.kind === \"inventory\") {\n const armor = div(ui, top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n slot(ui, 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 (ui.kind === \"inventory\" || ui.kind === \"crafting\") {\n const n = ui.grid.size;\n const craft = div(ui, top, \"craftGrid\");\n craft.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n for (let i = 0; i < n * n; i++)\n slot(ui, craft, {\n group: \"craft\",\n get: () => ui.grid.slots[i] ?? null,\n set: (s) => (ui.grid.slots[i] = s),\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickResult(e.shiftKey);\n e.preventDefault();\n };\n ui.resultEl = res;\n }\n if (ui.kind === \"armor_stand\" && ui.be) {\n // стойка: те же четыре слота брони, что у игрока, но у клетки (gfx/ArmorStand показывает их)\n const items = (ui.be.state as ChestState).items;\n const col = div(ui, top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n slot(ui, 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 (ui.kind === \"ender\") {\n // сундук Предела: слоты не у клетки, а у игрока — один склад на все сундуки и измерения\n const grid = div(ui, top, \"chestGrid\");\n for (let i = 0; i < inv.ender.length; i++)\n slot(ui, grid, {\n group: \"ender\",\n get: () => inv.ender[i] ?? null,\n set: (s) => (inv.ender[i] = s),\n });\n }\n if (ui.kind === \"chest\" && ui.be) {\n const items = (ui.be.state as ChestState).items;\n const grid = div(ui, top, \"chestGrid\");\n if (items.length === 9) grid.style.gridTemplateColumns = \"repeat(3, 44px)\"; // раздатчик 3×3\n for (let i = 0; i < items.length; i++)\n slot(ui, grid, {\n group: \"chest\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n // панцирный сундук в панцирный не кладётся, как в Minecraft\n accepts: (s) => !(ui.be?.type === \"shell\" && s.item === \"shell_chest\"),\n });\n }\n if (ui.kind === \"furnace\" && ui.be) {\n const st = ui.be.state as FurnaceState;\n const col = div(ui, top, \"furnaceCol\");\n slot(ui, col, {\n group: \"fIn\",\n get: () => st.input,\n set: (s) => (st.input = s),\n });\n ui.flameEl = div(ui, div(ui, col, \"flame\"), \"flameFill\") as HTMLDivElement;\n slot(ui, 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 ui.arrowEl = div(\n ui,\n div(ui, top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n slot(ui, top, {\n group: \"fOut\",\n get: () => st.output,\n set: (s) => (st.output = s),\n takeOnly: true,\n }).classList.add(\"result\");\n }\n\n ui.offersEl = null;\n ui.costEl = null;\n if (ui.kind === \"anvil\") {\n slot(ui, top, {\n group: \"anLeft\",\n get: () => ui.anvil[0] ?? null,\n set: (s) => (ui.anvil[0] = s),\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"anRight\",\n get: () => ui.anvil[1] ?? null,\n set: (s) => (ui.anvil[1] = s),\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickAnvil(e.shiftKey);\n e.preventDefault();\n };\n ui.resultEl = res;\n ui.costEl = div(ui, top, \"anvilCost\");\n }\n if (ui.kind === \"grindstone\") {\n // точило: два слота и результат без зачарований (опыт возвращается игроку)\n slot(ui, top, {\n group: \"grLeft\",\n get: () => ui.grind[0] ?? null,\n set: (s) => (ui.grind[0] = s),\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"grRight\",\n get: () => ui.grind[1] ?? null,\n set: (s) => (ui.grind[1] = s),\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickGrind(e.shiftKey);\n e.preventDefault();\n };\n ui.resultEl = res;\n ui.costEl = div(ui, top, \"anvilCost\");\n }\n if (ui.kind === \"enchanting\") {\n const col = div(ui, top, \"enchCol\");\n slot(ui, col, {\n group: \"enItem\",\n get: () => ui.enchSlots[0] ?? null,\n set: (s) => (ui.enchSlots[0] = s),\n accepts: (s) => canEnchant(s.item),\n });\n slot(ui, col, {\n group: \"enLapis\",\n get: () => ui.enchSlots[1] ?? null,\n set: (s) => (ui.enchSlots[1] = s),\n accepts: (s) => s.item === \"lapis_lazuli\",\n });\n ui.offersEl = div(ui, top, \"enchOffers\");\n }\n if (ui.kind === \"loom\") {\n // ткацкий станок: знамя, краситель и список узоров — щелчок кладёт слой\n slot(ui, top, {\n group: \"loBanner\",\n get: () => ui.loom[0] ?? null,\n set: (s) => (ui.loom[0] = s),\n accepts: (s) => bannerColor(s.item) !== null,\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"loDye\",\n get: () => ui.loom[1] ?? null,\n set: (s) => (ui.loom[1] = s),\n accepts: (s) => dyeColor(s.item) !== null,\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n ui.offersEl = div(ui, top, \"enchOffers tradeOffers loomOffers\");\n }\n if (ui.kind === \"stonecutter\") {\n // камнерез: вход и список изделий — щелчок режет один блок\n slot(ui, top, {\n group: \"scIn\",\n get: () => ui.stoneIn[0] ?? null,\n set: (s) => (ui.stoneIn[0] = s),\n accepts: (s) => stonecutOptions(s.item).length > 0,\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n ui.offersEl = div(ui, top, \"enchOffers tradeOffers\");\n }\n if (ui.kind === \"trade\") ui.offersEl = div(ui, top, \"enchOffers tradeOffers\");\n if (ui.kind === \"beacon\")\n ui.offersEl = div(ui, top, \"enchOffers beaconPowers\");\n if (ui.kind === \"smithing\") {\n // шаблон отделки (необязателен: без него — улучшение до пеклита)\n slot(ui, top, {\n group: \"smTpl\",\n get: () => ui.smith[2] ?? null,\n set: (s) => (ui.smith[2] = s),\n accepts: (s) => s.item.endsWith(\"_smithing_template\"),\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"smBase\",\n get: () => ui.smith[0] ?? null,\n set: (s) => (ui.smith[0] = s),\n accepts: (s) =>\n s.item.startsWith(\"diamond_\") || !!ITEMS[s.item]?.armorSlot,\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"smAdd\",\n get: () => ui.smith[1] ?? null,\n set: (s) => (ui.smith[1] = s),\n accepts: (s) => s.item === \"infernite_ingot\" || s.item in TRIM_MATERIALS,\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickSmith(e.shiftKey);\n e.preventDefault();\n };\n ui.resultEl = res;\n }\n if (ui.kind === \"brewing\" && ui.be) {\n const st = ui.be.state as BrewingState;\n const col = div(ui, top, \"brewCol\");\n slot(ui, col, {\n group: \"bIn\",\n get: () => st.ingredient,\n set: (s) => (st.ingredient = s),\n });\n slot(ui, col, {\n group: \"bFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => s.item === \"flame_powder\",\n });\n ui.arrowEl = div(\n ui,\n div(ui, top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n const row = div(ui, top, \"bottleRow\");\n for (let i = 0; i < 3; i++)\n slot(ui, 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 = div(ui, ui.panelEl, \"invGrid\");\n for (let i = 9; i < 36; i++)\n slot(ui, main, {\n group: \"main\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n const hot = div(ui, ui.panelEl, \"invHotbar\");\n for (let i = 0; i < 9; i++)\n slot(ui, hot, {\n group: \"hotbar\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n}\n\nexport function div(\n ui: InventoryUI,\n parent: HTMLElement,\n cls: string,\n): HTMLElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n}\n\nexport function slot(\n ui: InventoryUI,\n parent: HTMLElement,\n ref: SlotRef,\n): 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 ui._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 ui.refs.push(ref);\n return d;\n}\n"
|
|
1533
|
+
},
|
|
1534
|
+
{
|
|
1535
|
+
"path": "ui/inventory/clicks.ts",
|
|
1536
|
+
"content": "import { events } from \"../../core/EventBus\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { smeltResult } from \"../../registry/recipes/smelting\";\nimport type { FurnaceState } from \"../../world/blockentities/furnace\";\nimport { TRIM_MATERIALS } from \"../../registry/items/trims\";\nimport { bundleInsert, bundleTakeLast } from \"../../systems/Bundle\";\nimport { stonecutOptions } from \"../../systems/Stonecutter\";\nimport { bannerColor, dyeColor } from \"../../systems/Banner\";\nimport { canEnchant } from \"../../systems/Enchant\";\nimport type { ItemStack } from \"../../types\";\nimport type { Group, SlotRef } from \"./types\";\nimport { maxOf } from \"./shared\";\nimport type { InventoryUI } from \"../InventoryUI\";\n\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);\n\nconst stackable = (a: ItemStack, b: ItemStack): boolean =>\n a.item === b.item && !a.dmg && !b.dmg && sameTag(a.tag, b.tag);\n\nexport function clickSlot(\n ui: InventoryUI,\n ref: SlotRef,\n rightClick: boolean,\n shift: boolean,\n) {\n if (shift) {\n quickMove(ui, ref);\n ui._render();\n return;\n }\n const cur = ui.cursor;\n const slot = ref.get();\n\n if (ref.takeOnly) {\n // выход печи: только забрать — целиком в курсор, если влезает\n if (!slot) return;\n if (!cur) {\n ui.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 tookOutput(ui, ref);\n ui._render();\n return;\n }\n\n // мешок: ПКМ мешком по предмету или предметом по мешку — положить; ПКМ пустой рукой по мешку — достать\n if (rightClick && cur?.item === \"bundle\" && slot && slot.item !== \"bundle\") {\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 ui._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 ui.cursor = out;\n ref.set(slot);\n events.emit(\"bundleRemove\", { item: out.item });\n ui._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) ui.cursor = null;\n ref.set(slot);\n events.emit(\"bundleInsert\", { item: cur.item });\n }\n ui._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 ui.cursor = { ...slot, count: take };\n slot.count -= take;\n ref.set(slot.count > 0 ? slot : null);\n } else {\n ui.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) ui.cursor = null;\n } else {\n ref.set(cur);\n ui.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) ui.cursor = null;\n } else {\n // обмен\n ref.set(cur);\n ui.cursor = slot;\n }\n }\n ui._render();\n}\n\nexport function clickPalette(\n ui: InventoryUI,\n item: string,\n rightClick: boolean,\n shift: boolean,\n) {\n if (shift) ui.inventory.add(item, maxOf(item));\n else if (ui.cursor) ui.cursor = null;\n else ui.cursor = { item, count: rightClick ? 1 : maxOf(item) };\n ui._render();\n}\n\nexport function tookOutput(ui: InventoryUI, ref: SlotRef) {\n if (ref.group !== \"fOut\" || !ui.be) return;\n const st = ui.be.state as FurnaceState;\n if (st.xp > 0) events.emit(\"xpGain\", { amount: st.xp });\n st.xp = 0;\n}\n\nexport function targets(ui: InventoryUI, from: Group, s: ItemStack): SlotRef[] {\n const of = (...g: Group[]) => ui.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 (ui.kind === \"chest\") return [...of(\"chest\"), ...player];\n if (ui.kind === \"ender\") return [...of(\"ender\"), ...player];\n if (ui.kind === \"armor_stand\")\n return ITEMS[s.item]?.armorSlot ? [...of(\"stand\"), ...player] : player;\n if (ui.kind === \"stonecutter\" && stonecutOptions(s.item).length)\n return [...of(\"scIn\"), ...player];\n if (ui.kind === \"anvil\")\n return [...of(\"anLeft\"), ...of(\"anRight\"), ...player];\n if (ui.kind === \"grindstone\")\n return [...of(\"grLeft\"), ...of(\"grRight\"), ...player];\n if (ui.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 (ui.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 (ui.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 (ui.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 (ui.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\nexport function quickMove(ui: InventoryUI, ref: SlotRef) {\n const s = ref.get();\n if (!s) return;\n let left = s.count;\n const dests = targets(ui, 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 dests) {\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) tookOutput(ui, ref);\n}\n\nexport function clickResult(ui: InventoryUI, shift: boolean) {\n const res = ui.grid.result();\n if (!res) return;\n if (shift) {\n for (let n = 0; n < 64; n++) {\n const cur = ui.grid.result();\n if (!cur || cur.item !== res.item) break;\n const taken = ui.grid.takeResult()!;\n const left = ui.inventory.addStack(taken);\n if (left > 0) {\n ui._dropAtFeet(taken, left);\n break;\n }\n }\n ui._render();\n return;\n }\n if (\n ui.cursor &&\n (ui.cursor.item !== res.item ||\n // в курсоре такая же вещь, но с износом или tag — не сливаем, иначе новая «прилипла» бы к ней\n ui.cursor.dmg ||\n ui.cursor.tag ||\n ui.cursor.count + res.count > maxOf(res.item))\n )\n return;\n const taken = ui.grid.takeResult()!; // result() уже проверен выше — сетка не менялась, результат есть\n if (ui.cursor) ui.cursor.count += taken.count;\n else ui.cursor = { item: taken.item, count: taken.count };\n ui._render();\n}\n"
|
|
1537
|
+
},
|
|
1538
|
+
{
|
|
1539
|
+
"path": "ui/inventory/render.ts",
|
|
1540
|
+
"content": "import { events } from \"../../core/EventBus\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { labelOf } from \"../../registry/names\";\nimport {\n COOK_TIME,\n type FurnaceState,\n} from \"../../world/blockentities/furnace\";\nimport {\n BREW_TIME,\n type BrewingState,\n} from \"../../world/blockentities/brewing\";\nimport { smithResult } from \"../../systems/Smithing\";\nimport { TOO_EXPENSIVE, anvilResult } from \"../../systems/Anvil\";\nimport { grindResult } from \"../../systems/Grindstone\";\nimport { enchantsOf, enchantSuffix } from \"../../systems/Enchant\";\nimport { drawDurability } from \"../slotView\";\nimport type { ItemStack } from \"../../types\";\nimport type { InventoryUI } from \"../InventoryUI\";\n\nexport function render(ui: InventoryUI) {\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 ui.refs) if (r.el) draw(r.el, r.get());\n if (ui.resultEl) {\n const anvil =\n ui.kind === \"anvil\"\n ? anvilResult(ui.anvil[0] ?? null, ui.anvil[1] ?? null)\n : null;\n const res =\n ui.kind === \"smithing\"\n ? smithResult(\n ui.smith[0] ?? null,\n ui.smith[1] ?? null,\n ui.smith[2] ?? null,\n )\n : ui.kind === \"anvil\"\n ? (anvil?.result ?? null)\n : ui.kind === \"grindstone\"\n ? (grindResult(ui.grind[0] ?? null, ui.grind[1] ?? null)?.result ??\n null)\n : ui.grid.result();\n draw(ui.resultEl, res ? { ...res } : null);\n if (ui.costEl) {\n const pl = ui.game.player;\n ui.costEl.textContent = !anvil\n ? \"\"\n : anvil.cost >= TOO_EXPENSIVE && pl.mode !== \"creative\"\n ? \"Слишком дорого!\"\n : `Цена: ${anvil.cost} ур.`;\n }\n }\n if (ui.be && ui.kind === \"brewing\" && ui.arrowEl) {\n const st = ui.be.state as BrewingState;\n ui.arrowEl.style.width = ((st.brew / BREW_TIME) * 100).toFixed(1) + \"%\";\n }\n if (ui.be && ui.kind === \"furnace\") {\n const st = ui.be.state as FurnaceState;\n if (ui.flameEl)\n ui.flameEl.style.height =\n (st.burnMax > 0 ? (st.burn / st.burnMax) * 100 : 0).toFixed(1) + \"%\";\n if (ui.arrowEl)\n ui.arrowEl.style.width = ((st.cook / COOK_TIME) * 100).toFixed(1) + \"%\";\n }\n\n // курсорный стак\n if (ui.cursor) {\n ui.cursorEl.classList.remove(\"hidden\");\n ui.cursorEl.querySelector(\"img\")!.src =\n ITEMS[ui.cursor.item]?.iconURL ?? \"\";\n ui.cursorEl.querySelector(\".count\")!.textContent =\n ui.cursor.count > 1 ? String(ui.cursor.count) : \"\";\n } else {\n ui.cursorEl.classList.add(\"hidden\");\n }\n ui._renderOffers();\n events.emit(\"invChanged\"); // HUD-хотбар обновится\n}\n"
|
|
1541
|
+
},
|
|
1542
|
+
{
|
|
1543
|
+
"path": "ui/inventory/shared.ts",
|
|
1544
|
+
"content": "import { ITEMS } from \"../../registry/Items\";\n\nexport const maxOf = (item: string): number => ITEMS[item]?.maxStack ?? 64;\n"
|
|
1545
|
+
},
|
|
1546
|
+
{
|
|
1547
|
+
"path": "ui/inventory/stations.ts",
|
|
1548
|
+
"content": "import { events } from \"../../core/EventBus\";\nimport { labelOf } from \"../../registry/names\";\nimport { smithResult } from \"../../systems/Smithing\";\nimport { stonecutOptions } from \"../../systems/Stonecutter\";\nimport { TOO_EXPENSIVE, anvilResult } from \"../../systems/Anvil\";\nimport { grindResult } from \"../../systems/Grindstone\";\nimport {\n BANNER_PATTERNS,\n bannerColor,\n layersOf,\n loomResult,\n} from \"../../systems/Banner\";\nimport { bannerCanvas } from \"../../gfx/BannerArt\";\nimport {\n countShelves,\n enchantLine,\n enchantOffers,\n enchantsOf,\n withEnchants,\n} from \"../../systems/Enchant\";\nimport { B } from \"../../registry/Blocks\";\nimport { renderTradeOffers } from \"../tradeView\";\nimport { renderBeaconPowers } from \"../beaconView\";\nimport type { ItemStack } from \"../../types\";\nimport { maxOf } from \"./shared\";\nimport type { InventoryUI } from \"../InventoryUI\";\n\nexport function shelves(ui: InventoryUI): number {\n const p = ui.enchPos;\n if (!p) return 0;\n const w = ui.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\nexport function stonecut(ui: InventoryUI, out: ItemStack, shift: boolean) {\n const input = ui.stoneIn[0];\n if (!input) return;\n input.count -= 1;\n if (input.count <= 0) ui.stoneIn[0] = null;\n const made: ItemStack = { item: out.item, count: out.count };\n const cur = ui.cursor;\n if (!shift && !cur) ui.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 = ui.inventory.addStack(made);\n if (left > 0) ui._dropAtFeet(made, left);\n }\n events.emit(\"stonecut\", { item: made.item });\n ui._render();\n}\n\nexport function renderOffers(ui: InventoryUI) {\n const el = ui.offersEl;\n if (!el) return;\n if (ui.kind === \"beacon\") {\n if (ui.be)\n renderBeaconPowers(\n el,\n ui.inventory,\n ui.be,\n ui.game.player.mode === \"creative\",\n () => ui._render(),\n );\n return;\n }\n if (ui.kind === \"trade\") {\n if (ui.tradeMob)\n renderTradeOffers(\n el,\n ui.inventory,\n ui.tradeMob,\n (s) => ui._dropAtFeet(s),\n () => ui._render(),\n ui.game.player.effects.get(\"hero\")?.level ?? 0,\n ui.game.player.villageRep,\n );\n return;\n }\n if (ui.kind === \"loom\") {\n el.innerHTML = \"\";\n const banner = ui.loom[0] ?? null;\n const dye = ui.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 loom(ui, p.id, e.shiftKey);\n };\n el.appendChild(b);\n }\n return;\n }\n if (ui.kind === \"stonecutter\") {\n el.innerHTML = \"\";\n const input = ui.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 stonecut(ui, out, e.shiftKey);\n };\n el.appendChild(b);\n }\n return;\n }\n const item = ui.enchSlots[0];\n const offers =\n item && enchantsOf(item).length === 0\n ? enchantOffers(item.item, shelves(ui), ui.game.player.enchSeed)\n : null;\n el.innerHTML = \"\";\n const pl = ui.game.player;\n const lapis = ui.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) enchant(ui, i);\n };\n el.appendChild(b);\n });\n}\n\nexport function enchant(ui: InventoryUI, i: number) {\n const item = ui.enchSlots[0];\n if (!item) return;\n const o = enchantOffers(item.item, shelves(ui), ui.game.player.enchSeed)?.[i];\n if (!o) return;\n const pl = ui.game.player;\n if (pl.mode !== \"creative\") {\n const lapis = ui.enchSlots[1];\n if (!lapis || lapis.count < o.cost || pl.xpLevel < o.level) return;\n lapis.count -= o.cost;\n if (lapis.count <= 0) ui.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 = item.count > 1 ? ui.inventory.add(\"book\", item.count - 1) : 0;\n if (left > 0) ui._dropAtFeet({ item: \"book\", count: left });\n base = { item: \"enchanted_book\", count: 1 };\n }\n ui.enchSlots[0] = withEnchants(base, Object.fromEntries(o.ench));\n pl.enchSeed = (Math.random() * 0xffffffff) >>> 0;\n events.emit(\"enchanted\", { item: item.item });\n ui._render();\n}\n\nexport function clickAnvil(ui: InventoryUI, shift: boolean) {\n const r = anvilResult(ui.anvil[0] ?? null, ui.anvil[1] ?? null);\n if (!r || (!shift && ui.cursor)) return;\n const pl = ui.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 = ui.anvil[0]!;\n left.count -= 1;\n if (left.count <= 0) ui.anvil[0] = null;\n const right = ui.anvil[1]!;\n right.count -= r.useRight;\n if (right.count <= 0) ui.anvil[1] = null;\n if (shift) {\n if (ui.inventory.addStack(r.result) > 0) ui._dropAtFeet(r.result);\n } else ui.cursor = r.result;\n events.emit(\"anvilUse\", { item: r.result.item });\n ui._render();\n}\n\nexport function clickGrind(ui: InventoryUI, shift: boolean) {\n const r = grindResult(ui.grind[0] ?? null, ui.grind[1] ?? null);\n if (!r || (!shift && ui.cursor)) return;\n ui.grind[0] = null;\n if (r.useRight) ui.grind[1] = null;\n if (shift) {\n if (ui.inventory.addStack(r.result) > 0) ui._dropAtFeet(r.result);\n } else ui.cursor = r.result;\n if (r.xp > 0) events.emit(\"xpGain\", { amount: r.xp });\n events.emit(\"grindstoneUse\", { item: r.result.item });\n ui._render();\n}\n\nexport function loom(ui: InventoryUI, pattern: string, shift: boolean) {\n const out = loomResult(ui.loom[0] ?? null, ui.loom[1] ?? null, pattern);\n if (!out || (!shift && ui.cursor)) return;\n const dye = ui.loom[1]!;\n dye.count -= 1;\n if (dye.count <= 0) ui.loom[1] = null;\n const banner = ui.loom[0]!;\n banner.count -= 1;\n if (banner.count <= 0) ui.loom[0] = null;\n if (shift) {\n if (ui.inventory.addStack(out) > 0) ui._dropAtFeet(out);\n } else ui.cursor = out;\n events.emit(\"loomUse\", { item: out.item });\n ui._render();\n}\n\nexport function clickSmith(ui: InventoryUI, shift: boolean) {\n const tpl = ui.smith[2] ?? null;\n const res = smithResult(ui.smith[0] ?? null, ui.smith[1] ?? null, tpl);\n if (!res || (!shift && ui.cursor)) return;\n ui.smith[0] = null;\n const add = ui.smith[1]!;\n add.count -= 1;\n if (add.count <= 0) ui.smith[1] = null;\n if (tpl) {\n tpl.count -= 1; // шаблон тратится, как в Minecraft\n if (tpl.count <= 0) ui.smith[2] = null;\n events.emit(\"armorTrimmed\", { item: res.item });\n }\n if (shift) {\n if (ui.inventory.addStack(res) > 0) ui._dropAtFeet(res);\n } else ui.cursor = res;\n events.emit(\"craft\", { item: res.item, count: 1 });\n ui._render();\n}\n"
|
|
1549
|
+
},
|
|
1550
|
+
{
|
|
1551
|
+
"path": "ui/inventory/types.ts",
|
|
1552
|
+
"content": "import 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\nexport type 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\nexport interface 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"
|
|
1553
|
+
},
|
|
1326
1554
|
{
|
|
1327
1555
|
"path": "ui/InventoryUI.ts",
|
|
1328
|
-
"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 /** Сетевая игра: контейнер, который этот экран держит занятым (net/NetGame.openContainer). */\n private lockKey: string | 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 this._gated(`b:${x},${y},${z}`, () => {\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 this._gated(`e:${entityId}`, () => {\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 );\n events.on(\"ui:openCart\", ({ entityId }: { entityId: number }) =>\n this._gated(`e:${entityId}`, () => {\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 );\n events.on(\n \"ui:openContainer\",\n ({ x, y, z }: { x: number; y: number; z: number }) =>\n this._gated(`b:${x},${y},${z}`, () => this._openContainer(x, y, z)),\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 /** Открыть блочный контейнер (сундук, печь, варочную стойку, воронку, маяк…). */\n private _openContainer(x: number, y: number, z: number): void {\n // ensure: сундук постройки генератора получает сущность (и добычу) при первом открытии\n const be = this.game.blockEntities.ensure(x, y, z);\n if (\n be &&\n (be.type === \"chest\" || be.type === \"furnace\" || be.type === \"brewing\")\n )\n this.show(be.type, be);\n // воронка, раздатчик, панцирный сундук — тот же экран, что сундук (слоты items), со своим заголовком\n else if (\n be &&\n (be.type === \"hopper\" || be.type === \"dispenser\" || be.type === \"shell\")\n )\n this.show(\"chest\", be);\n else if (be?.type === \"beacon\") this.show(\"beacon\", be);\n }\n\n /**\n * Сетевая игра: контейнер открывается, только если он не открыт у другого игрока (иначе двое,\n * перекладывая одно и то же, размножали бы предметы). Одиночная игра — сразу.\n */\n private _gated(key: string, open: () => void): void {\n const net = this.game.net;\n if (!net) return open();\n if (this.open) return;\n net.openContainer(key, () => {\n if (this.open) return net.releaseContainer(key);\n open();\n if (this.open) this.lockKey = key;\n else net.releaseContainer(key);\n });\n }\n\n close() {\n if (!this.open) return;\n this.open = false;\n if (this.lockKey) {\n this.game.net?.releaseContainer(this.lockKey);\n this.lockKey = null;\n }\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"
|
|
1556
|
+
"content": "// Экраны инвентаря, как в Minecraft: инвентарь (броня + крафт 2×2), верстак (3×3), сундук, печь.\n// У всех внизу рюкзак и хотбар игрока. Мышь: ЛКМ — взять/положить стак, ПКМ — половину/по одному,\n// Shift+ЛКМ — перенести в «другую половину» экрана (броню — на тело, руду — в печь).\n// UI мутирует ТОЛЬКО модели (Inventory, CraftingGrid, состояние блочной сущности) и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport type { Mob } from \"../entities/Mob\";\nimport type { Minecart } from \"../entities/Minecart\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\nimport { build, div, slot } from \"./inventory/build\";\nimport {\n clickSlot,\n clickPalette,\n tookOutput,\n targets,\n quickMove,\n clickResult,\n} from \"./inventory/clicks\";\nimport {\n shelves,\n stonecut,\n renderOffers,\n enchant,\n clickAnvil,\n clickGrind,\n loom,\n clickSmith,\n} from \"./inventory/stations\";\nimport { render } from \"./inventory/render\";\nimport type { ScreenKind, Group, SlotRef } from \"./inventory/types\";\n\nexport type { ScreenKind } from \"./inventory/types\";\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 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 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 /** Сетевая игра: контейнер, который этот экран держит занятым (net/NetGame.openContainer). */\n private lockKey: string | null = null;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n panelEl: HTMLElement;\n refs: SlotRef[] = [];\n resultEl: HTMLDivElement | null = null;\n flameEl: HTMLDivElement | null = null;\n 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 this._gated(`b:${x},${y},${z}`, () => {\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 this._gated(`e:${entityId}`, () => {\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 );\n events.on(\"ui:openCart\", ({ entityId }: { entityId: number }) =>\n this._gated(`e:${entityId}`, () => {\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 );\n events.on(\n \"ui:openContainer\",\n ({ x, y, z }: { x: number; y: number; z: number }) =>\n this._gated(`b:${x},${y},${z}`, () => this._openContainer(x, y, z)),\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 /** Открыть блочный контейнер (сундук, печь, варочную стойку, воронку, маяк…). */\n private _openContainer(x: number, y: number, z: number): void {\n // ensure: сундук постройки генератора получает сущность (и добычу) при первом открытии\n const be = this.game.blockEntities.ensure(x, y, z);\n if (\n be &&\n (be.type === \"chest\" || be.type === \"furnace\" || be.type === \"brewing\")\n )\n this.show(be.type, be);\n // воронка, раздатчик, панцирный сундук — тот же экран, что сундук (слоты items), со своим заголовком\n else if (\n be &&\n (be.type === \"hopper\" || be.type === \"dispenser\" || be.type === \"shell\")\n )\n this.show(\"chest\", be);\n else if (be?.type === \"beacon\") this.show(\"beacon\", be);\n }\n\n /**\n * Сетевая игра: контейнер открывается, только если он не открыт у другого игрока (иначе двое,\n * перекладывая одно и то же, размножали бы предметы). Одиночная игра — сразу.\n */\n private _gated(key: string, open: () => void): void {\n const net = this.game.net;\n if (!net) return open();\n if (this.open) return;\n net.openContainer(key, () => {\n if (this.open) return net.releaseContainer(key);\n open();\n if (this.open) this.lockKey = key;\n else net.releaseContainer(key);\n });\n }\n\n close() {\n if (!this.open) return;\n this.open = false;\n if (this.lockKey) {\n this.game.net?.releaseContainer(this.lockKey);\n this.lockKey = null;\n }\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 _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 _build() {\n return build(this);\n }\n\n _div(parent: HTMLElement, cls: string): HTMLElement {\n return div(this, parent, cls);\n }\n\n _slot(parent: HTMLElement, ref: SlotRef): HTMLDivElement {\n return slot(this, parent, ref);\n }\n\n _clickSlot(ref: SlotRef, rightClick: boolean, shift: boolean) {\n return clickSlot(this, ref, rightClick, shift);\n }\n\n /**\n * Креатив: клик по палитре — полный стак в курсор (ПКМ — один); с чем-то в курсоре — удалить\n * это, как в Minecraft; Shift — стак сразу в инвентарь.\n */\n _clickPalette(item: string, rightClick: boolean, shift: boolean) {\n return clickPalette(this, item, rightClick, shift);\n }\n\n /** Книжные полки вокруг открытого стола зачарований. */\n _shelves(): number {\n return shelves(this);\n }\n\n /** Варианты стола: текст, доступность (уровень, лазурит), клик — зачаровать. */\n /** Камнерез: блок из входа → изделие в курсор (не лезет — в инвентарь), с Shift — сразу в инвентарь. */\n _stonecut(out: ItemStack, shift: boolean) {\n return stonecut(this, out, shift);\n }\n\n _renderOffers() {\n return renderOffers(this);\n }\n\n /** Зачаровать вариантом i: списать уровни и лазурит (в креативе — нет), сменить зерно. */\n _enchant(i: number) {\n return enchant(this, i);\n }\n\n /** Результат наковальни: списать уровни (в креативе — нет), левый слот и нужное из правого. */\n _clickAnvil(shift: boolean) {\n return clickAnvil(this, shift);\n }\n\n /** Результат точила: вещь без зачарований, снятый опыт — игроку (как в Minecraft). */\n _clickGrind(shift: boolean) {\n return clickGrind(this, shift);\n }\n\n /** Узор на знамя: тратит один краситель, знамя из слота заменяется на новое со слоем. */\n _loom(pattern: string, shift: boolean) {\n return loom(this, pattern, shift);\n }\n\n /** Результат кузнечного стола: в курсор (рука свободна) или Shift — сразу в инвентарь. */\n _clickSmith(shift: boolean) {\n return clickSmith(this, shift);\n }\n\n /** Забрали выход печи: накопленный опыт — игроку. */\n _tookOutput(ref: SlotRef) {\n return tookOutput(this, ref);\n }\n\n /** Куда Shift-клик переносит стак из этой группы, по порядку предпочтения. */\n _targets(from: Group, s: ItemStack): SlotRef[] {\n return targets(this, from, s);\n }\n\n _quickMove(ref: SlotRef) {\n return quickMove(this, ref);\n }\n\n /** Результат крафта: клик — в курсор; Shift — скрафтить сколько получится прямо в инвентарь. */\n _clickResult(shift: boolean) {\n return clickResult(this, shift);\n }\n\n _render() {\n return render(this);\n }\n}\n"
|
|
1329
1557
|
},
|
|
1330
1558
|
{
|
|
1331
1559
|
"path": "ui/NameEditor.ts",
|
|
@@ -1735,9 +1963,33 @@
|
|
|
1735
1963
|
"path": "worldgen/mushrooms.ts",
|
|
1736
1964
|
"content": "// Огромные грибы, как в Minecraft: у бурого — плоская шляпка 7×7 без углов на высоте 5–7, у красного —\n// купол (крышка 3×3 и три кольца стенок) на высоте 4–6. Генератор ставит их в грибных полях, костная мука\n// выращивает из маленького гриба (world/behaviours/farming).\nimport { B } from \"../registry/Blocks\";\n\n/** Поставить блок; soft — только в пустоту (шляпка не затирает соседей). */\nexport type MushroomPut = (\n x: number,\n y: number,\n z: number,\n id: number,\n soft: boolean,\n) => void;\n\n/** Радиус шляпки — генератор держит гриб в чанке. */\nexport const HUGE_MUSHROOM_RADIUS = 3;\nexport const HUGE_MUSHROOM_HEIGHT = 8;\n\n/** Вырастить огромный гриб с ножкой из (x, y, z) вверх. */\nexport function growHugeMushroom(\n put: MushroomPut,\n x: number,\n y: number,\n z: number,\n red: boolean,\n rng: () => number,\n): void {\n const h = (red ? 4 : 5) + Math.floor(rng() * 3);\n for (let k = 0; k < h; k++) put(x, y + k, z, B.mushroom_stem!, k > 0);\n if (!red) {\n for (let dx = -3; dx <= 3; dx++)\n for (let dz = -3; dz <= 3; dz++)\n if (Math.abs(dx) + Math.abs(dz) < 6)\n put(x + dx, y + h, z + dz, B.brown_mushroom_block!, true);\n return;\n }\n for (let dx = -1; dx <= 1; dx++)\n for (let dz = -1; dz <= 1; dz++)\n put(x + dx, y + h, z + dz, B.red_mushroom_block!, true);\n for (let dy = 1; dy <= 3; dy++)\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++)\n if (\n (Math.abs(dx) === 2 || Math.abs(dz) === 2) &&\n !(Math.abs(dx) === 2 && Math.abs(dz) === 2)\n )\n put(x + dx, y + h - dy, z + dz, B.red_mushroom_block!, true);\n}\n"
|
|
1737
1965
|
},
|
|
1966
|
+
{
|
|
1967
|
+
"path": "worldgen/overworld/biomes.ts",
|
|
1968
|
+
"content": "import { type TreeType } from \"../WorldSpec\";\n\nexport type OverworldBiome =\n | \"ocean\"\n | \"warm_ocean\"\n | \"beach\"\n | \"plains\"\n | \"forest\"\n | \"birch_forest\"\n | \"taiga\"\n | \"snowy\"\n | \"desert\"\n | \"savanna\"\n | \"jungle\"\n | \"swamp\"\n | \"mountains\"\n | \"dark_forest\"\n | \"cherry_grove\"\n | \"mangrove_swamp\"\n | \"mushroom_fields\";\n\ninterface BiomeDef {\n surface: string;\n under: string;\n /** Порода: из рецепта мира (TreeType) или новая (jungle, acacia, dark_oak, cherry — registry/blocks/wood). */\n tree: TreeType | \"jungle\" | \"acacia\" | \"dark_oak\" | \"cherry\" | \"mangrove\";\n /** Доля попыток посадить дерево (10 на чанк). */\n trees: number;\n grass: number;\n flowers: number;\n}\n\nexport const BIOMES: Record<OverworldBiome, BiomeDef> = {\n ocean: {\n surface: \"sand\",\n under: \"sand\",\n tree: \"none\",\n trees: 0,\n grass: 0,\n flowers: 0,\n },\n warm_ocean: {\n surface: \"sand\",\n under: \"sand\",\n tree: \"none\",\n trees: 0,\n grass: 0,\n flowers: 0,\n },\n beach: {\n surface: \"sand\",\n under: \"sand\",\n tree: \"none\",\n trees: 0,\n grass: 0,\n flowers: 0,\n },\n plains: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"oak\",\n trees: 0.05,\n grass: 0.35,\n flowers: 0.06,\n },\n forest: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"oak\",\n trees: 0.9,\n grass: 0.2,\n flowers: 0.04,\n },\n birch_forest: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"birch\",\n trees: 0.8,\n grass: 0.2,\n flowers: 0.05,\n },\n taiga: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"spruce\",\n trees: 0.8,\n grass: 0.15,\n flowers: 0.01,\n },\n snowy: {\n surface: \"snow\",\n under: \"dirt\",\n tree: \"spruce\",\n trees: 0.15,\n grass: 0,\n flowers: 0,\n },\n desert: {\n surface: \"sand\",\n under: \"sandstone\",\n tree: \"dead\",\n trees: 0.03,\n grass: 0,\n flowers: 0,\n },\n savanna: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"acacia\",\n trees: 0.12,\n grass: 0.5,\n flowers: 0.01,\n },\n jungle: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"jungle\",\n trees: 1,\n grass: 0.5,\n flowers: 0.05,\n },\n swamp: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"oak\",\n trees: 0.3,\n grass: 0.3,\n flowers: 0.02,\n },\n mountains: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"spruce\",\n trees: 0.1,\n grass: 0.05,\n flowers: 0,\n },\n // тёмный лес: сплошной полог тёмного дуба, под ним сумрак — там стоят особняки\n dark_forest: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"dark_oak\",\n trees: 1,\n grass: 0.15,\n flowers: 0.02,\n },\n // вишнёвая роща: редкие розовые вишни на холмах, трава в лепестках\n cherry_grove: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"cherry\",\n trees: 0.35,\n grass: 0.4,\n flowers: 0,\n },\n // мангровое болото: дно из грязи на мелководье, мангры на корнях прямо в воде\n mangrove_swamp: {\n surface: \"mud\",\n under: \"mud\",\n tree: \"mangrove\",\n trees: 0.7,\n grass: 0,\n flowers: 0,\n },\n // грибные поля: мицелий, огромные грибы вместо деревьев (extras), только грибные коровы, без монстров\n mushroom_fields: {\n surface: \"mycelium\",\n under: \"dirt\",\n tree: \"none\",\n trees: 0,\n grass: 0,\n flowers: 0,\n },\n};\n"
|
|
1969
|
+
},
|
|
1970
|
+
{
|
|
1971
|
+
"path": "worldgen/overworld/constants.ts",
|
|
1972
|
+
"content": "export const SEA = 62;\n\n/**\n * Руды: [имя, жил на чанк, длина жилы, min y, max y] — пропорции Minecraft 1.18, ужатые в высоту\n * 0..127. Руды, которых ещё нет в реестре, пропускаются.\n */\nexport const ORES: readonly [string, number, number, number, number][] = [\n [\"coal_ore\", 20, 12, 5, 120],\n [\"iron_ore\", 12, 8, 5, 72],\n [\"copper_ore\", 8, 10, 40, 96],\n [\"gold_ore\", 4, 8, 5, 32],\n [\"redstone_ore\", 6, 7, 5, 16],\n [\"lapis_ore\", 2, 6, 5, 30],\n [\"diamond_ore\", 1, 7, 5, 14],\n];\n\n/** Шесть граней клетки (жеоды, озерца пышных пещер). */\nexport const FACES6: readonly (readonly [number, number, number])[] = [\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];\n\n/** Пещера ниже этой высоты заполнена лавой (как «лавовое озеро» старого Minecraft). */\nexport const LAVA_LEVEL = 10;\n\n/** Подземная цитадель: y пола зала портала (рамки стоят на нём). */\nexport const CITADEL_Y = 24;\n"
|
|
1973
|
+
},
|
|
1974
|
+
{
|
|
1975
|
+
"path": "worldgen/overworld/generator/caves.ts",
|
|
1976
|
+
"content": "import { mulberry32, hashString } from \"../../../core/Noise\";\nimport { B, packWater } from \"../../../registry/Blocks\";\nimport { idx, type Chunk } from \"../../../world/Chunk\";\nimport { CAN_SUMMON } from \"../../../registry/blocks/sculk\";\nimport { FACES6, LAVA_LEVEL, SEA } from \"../constants\";\nimport { GX, GY, GZ, SX, SY, SZ } from \"../math\";\nimport type { OverworldGenerator } from \"../OverworldGenerator\";\n\nexport function carveCaves(\n gen: OverworldGenerator,\n chunk: Chunk,\n heights: Int16Array,\n): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const A = new Float32Array(GX * GY * GZ),\n Bf = new Float32Array(GX * GY * GZ),\n C = new Float32Array(GX * GY * GZ);\n const gi = (gx: number, gy: number, gz: number) =>\n gx + gz * GX + gy * GX * GZ;\n for (let gy = 0; gy < GY; gy++)\n for (let gz = 0; gz < GZ; gz++)\n for (let gx = 0; gx < GX; gx++) {\n const x = wx0 + gx * 4,\n y = gy * 8,\n z = wz0 + gz * 4;\n const k = gi(gx, gy, gz);\n A[k] = gen.caveA.noise(x / 48, y / 32, z / 48);\n Bf[k] = gen.caveB.noise(x / 48, y / 32, z / 48);\n C[k] = gen.cheese.noise(x / 64, y / 40, z / 64);\n }\n const lerp = (f: Float32Array, x: number, y: number, z: number): number => {\n const gx = x >> 2,\n gy = y >> 3,\n gz = z >> 2;\n const fx = (x & 3) / 4,\n fy = (y & 7) / 8,\n fz = (z & 3) / 4;\n const c = (dx: number, dy: number, dz: number) =>\n f[gi(gx + dx, gy + dy, gz + dz)]!;\n const x00 = c(0, 0, 0) + (c(1, 0, 0) - c(0, 0, 0)) * fx;\n const x10 = c(0, 1, 0) + (c(1, 1, 0) - c(0, 1, 0)) * fx;\n const x01 = c(0, 0, 1) + (c(1, 0, 1) - c(0, 0, 1)) * fx;\n const x11 = c(0, 1, 1) + (c(1, 1, 1) - c(0, 1, 1)) * fx;\n const y0 = x00 + (x10 - x00) * fy,\n y1 = x01 + (x11 - x01) * fy;\n return y0 + (y1 - y0) * fz;\n };\n const lavaMeta = packWater(8, true);\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const h = heights[x + z * SX]!;\n // под водой и у берега пещеры держатся глубже: иначе море провалилось бы в них\n const ceil = h < SEA + 2 ? h - 8 : h - 1;\n for (let y = 4; y <= Math.min(ceil, SY - 9); y++) {\n const a = lerp(A, x, y, z),\n b = lerp(Bf, x, y, z);\n const tunnel = a * a + b * b < 0.0045;\n const cave = y < 50 && lerp(C, x, y, z) > 0.58;\n if (!tunnel && !cave) continue;\n const cur = chunk.blocks[idx(x, y, z)]!;\n if (cur === B.bedrock || cur === B.water) continue;\n if (y <= LAVA_LEVEL) chunk.setRaw(x, y, z, B.lava!, lavaMeta);\n else chunk.setRaw(x, y, z, B.air!);\n }\n }\n}\n\nexport function geode(\n gen: OverworldGenerator,\n chunk: Chunk,\n heights: Int16Array,\n): void {\n const rng = mulberry32(\n hashString(`ow-geode:${gen.seed}:${chunk.cx},${chunk.cz}`),\n );\n if (rng() > 1 / 24) return;\n let low = SY;\n for (const h of heights) low = Math.min(low, h);\n const top = Math.min(48, low - 10);\n if (top < 14) return;\n const R = 3.5 + rng(); // радиус слоя аметиста\n const cx = 7 + Math.floor(rng() * 2),\n cz = 7 + Math.floor(rng() * 2),\n cy = 12 + Math.floor(rng() * (top - 12));\n const k = Math.ceil(R + 2);\n const budding: number[] = [];\n for (let dy = -k; dy <= k; dy++)\n for (let dz = -k; dz <= k; dz++)\n for (let dx = -k; dx <= k; dx++) {\n const x = cx + dx,\n y = cy + dy,\n z = cz + dz;\n if (x < 0 || x >= SX || z < 0 || z >= SZ || y < 1) continue;\n if (chunk.get(x, y, z) === B.bedrock) continue;\n const d =\n Math.sqrt(dx * dx + dy * dy * 1.2 + dz * dz) + (rng() - 0.5) * 0.3;\n if (d > R + 2) continue;\n let id: number;\n if (d <= R - 1) id = B.air!;\n else if (d <= R) {\n id = rng() < 0.08 ? B.budding_amethyst! : B.amethyst_block!;\n if (id === B.budding_amethyst) budding.push(x, y, z);\n } else if (d <= R + 1) id = B.calcite!;\n else id = B.smooth_basalt!;\n chunk.setRaw(x, y, z, id);\n }\n // почки и друзы на внутренних гранях растущего аметиста\n const buds = [\n B.small_amethyst_bud!,\n B.medium_amethyst_bud!,\n B.large_amethyst_bud!,\n B.amethyst_cluster!,\n ];\n for (let i = 0; i < budding.length; i += 3)\n for (const [dx, dy, dz] of FACES6) {\n const x = budding[i]! + dx,\n y = budding[i + 1]! + dy,\n z = budding[i + 2]! + dz;\n if (x < 0 || x >= SX || z < 0 || z >= SZ) continue;\n if (chunk.get(x, y, z) === B.air && rng() < 0.4)\n chunk.setRaw(x, y, z, buds[Math.floor(rng() * 4)]!);\n }\n}\n\nexport function lushCaves(\n gen: OverworldGenerator,\n chunk: Chunk,\n heights: Int16Array,\n): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const rng = mulberry32(\n hashString(`ow-lush:${gen.seed}:${chunk.cx},${chunk.cz}`),\n );\n const rock = (id: number): boolean =>\n id === B.stone ||\n id === B.deepslate ||\n id === B.dirt ||\n id === B.gravel ||\n id === B.moss_block ||\n id === B.clay;\n const water = packWater(8, true);\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n if (gen.lush.fbm((wx0 + x) * 0.008, (wz0 + z) * 0.008, 2) < 0.3) continue;\n const h = heights[x + z * SX]!;\n if (h < SEA + 2) continue; // не под морем\n for (let y = LAVA_LEVEL + 3; y < h - 6; y++) {\n if (chunk.get(x, y, z) !== B.air) continue;\n if (rock(chunk.get(x, y - 1, z))) {\n // озерцо: клетка пола, со всех сторон и снизу — камень\n const inside = x > 0 && x < SX - 1 && z > 0 && z < SZ - 1;\n const sealed =\n inside &&\n rock(chunk.get(x, y - 2, z)) &&\n FACES6.every(([dx, dy, dz]) => {\n if (dy !== 0) return true;\n const n = chunk.get(x + dx, y - 1, z + dz);\n return rock(n) || n === B.water;\n });\n if (sealed && rng() < 0.18) {\n chunk.setRaw(x, y - 1, z, B.water!, water);\n chunk.setRaw(x, y - 2, z, B.clay!);\n continue;\n }\n chunk.setRaw(x, y - 1, z, B.moss_block!);\n const r = rng();\n if (r < 0.25) chunk.setRaw(x, y, z, B.moss_carpet!);\n else if (r < 0.3) chunk.setRaw(x, y, z, B.azalea!);\n else if (r < 0.33) chunk.setRaw(x, y, z, B.flowering_azalea!);\n else if (r < 0.45) chunk.setRaw(x, y, z, B.tallgrass!);\n }\n if (chunk.get(x, y, z) === B.air && rock(chunk.get(x, y + 1, z))) {\n const r = rng();\n if (r < 0.12) {\n const n = 1 + Math.floor(rng() * 4);\n for (\n let i = 0;\n i < n &&\n chunk.get(x, y - i, z) === B.air &&\n chunk.get(x, y - i - 1, z) === B.air;\n i++\n )\n chunk.setRaw(\n x,\n y - i,\n z,\n rng() < 0.11 ? B.cave_vines_lit! : B.cave_vines!,\n );\n } else if (r < 0.125) chunk.setRaw(x, y, z, B.spore_blossom!);\n else if (r < 0.4) chunk.setRaw(x, y + 1, z, B.moss_block!);\n }\n }\n }\n}\n\nexport function dripstoneCaves(\n gen: OverworldGenerator,\n chunk: Chunk,\n heights: Int16Array,\n): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const rng = mulberry32(\n hashString(`ow-drip:${gen.seed}:${chunk.cx},${chunk.cz}`),\n );\n const rock = (id: number): boolean =>\n id === B.stone ||\n id === B.deepslate ||\n id === B.gravel ||\n id === B.dripstone_block;\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const wx = wx0 + x,\n wz = wz0 + z;\n if (gen.drip.fbm(wx * 0.007, wz * 0.007, 2) < 0.3 || gen.isDeep(wx, wz))\n continue;\n if (gen.lush.fbm(wx * 0.008, wz * 0.008, 2) >= 0.3) continue;\n const h = heights[x + z * SX]!;\n for (let y = LAVA_LEVEL + 2; y < h - 8; y++) {\n if (chunk.get(x, y, z) !== B.air) continue;\n if (rock(chunk.get(x, y - 1, z)) && rng() < 0.6) {\n chunk.setRaw(x, y - 1, z, B.dripstone_block!);\n if (rng() < 0.08) {\n const n = 1 + Math.floor(rng() * 3);\n for (\n let i = 0;\n i < n &&\n chunk.get(x, y + i, z) === B.air &&\n chunk.get(x, y + i + 1, z) === B.air;\n i++\n )\n chunk.setRaw(x, y + i, z, B.pointed_dripstone!);\n }\n }\n if (\n chunk.get(x, y, z) === B.air &&\n rock(chunk.get(x, y + 1, z)) &&\n rng() < 0.6\n ) {\n chunk.setRaw(x, y + 1, z, B.dripstone_block!);\n if (rng() < 0.12) {\n const n = 1 + Math.floor(rng() * 4);\n for (\n let i = 0;\n i < n &&\n chunk.get(x, y - i, z) === B.air &&\n chunk.get(x, y - i - 1, z) === B.air;\n i++\n )\n chunk.setRaw(x, y - i, z, B.pointed_dripstone_down!);\n }\n }\n }\n }\n}\n\nexport function deepDark(\n gen: OverworldGenerator,\n chunk: Chunk,\n heights: Int16Array,\n): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const rng = mulberry32(\n hashString(`ow-deep:${gen.seed}:${chunk.cx},${chunk.cz}`),\n );\n const rock = (id: number): boolean =>\n id === B.stone ||\n id === B.deepslate ||\n id === B.gravel ||\n id === B.dirt ||\n id === B.sculk;\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n if (!gen.isDeep(wx0 + x, wz0 + z)) continue;\n const top = Math.min(34, heights[x + z * SX]! - 12);\n for (let y = LAVA_LEVEL + 1; y <= top; y++) {\n if (chunk.get(x, y, z) !== B.air) continue;\n if (rock(chunk.get(x, y - 1, z))) {\n const r = rng();\n if (r < 0.75)\n chunk.setRaw(x, y - 1, z, r < 0.004 ? B.sculk_catalyst! : B.sculk!);\n const o = rng();\n if (o < 0.02) chunk.setRaw(x, y, z, B.sculk_sensor!);\n else if (o < 0.026)\n chunk.setRaw(x, y, z, B.sculk_shrieker!, CAN_SUMMON);\n else if (o < 0.16) chunk.setRaw(x, y, z, B.sculk_vein!);\n }\n if (\n chunk.get(x, y, z) === B.air &&\n rock(chunk.get(x, y + 1, z)) &&\n rng() < 0.35\n )\n chunk.setRaw(x, y + 1, z, B.sculk!);\n }\n }\n}\n"
|
|
1977
|
+
},
|
|
1978
|
+
{
|
|
1979
|
+
"path": "worldgen/overworld/generator/decor.ts",
|
|
1980
|
+
"content": "import { mulberry32, hashString } from \"../../../core/Noise\";\nimport { B } from \"../../../registry/Blocks\";\nimport type { Chunk } from \"../../../world/Chunk\";\nimport { growTree, TREE_MAX_HEIGHT, TREE_MAX_RADIUS } from \"../../trees\";\nimport {\n HUGE_MUSHROOM_HEIGHT,\n HUGE_MUSHROOM_RADIUS,\n growHugeMushroom,\n} from \"../../mushrooms\";\nimport { FACING_DIRS } from \"../../../registry/meta\";\nimport { HIVE_SEEDED } from \"../../../registry/blocks/bees\";\nimport { seaRoll } from \"../../features/temple\";\nimport { CORALS } from \"../../../registry/blocks/sealife\";\nimport { BIOMES, type OverworldBiome } from \"../biomes\";\nimport { ORES, SEA } from \"../constants\";\nimport type { OverworldGenerator } from \"../OverworldGenerator\";\nimport { SX, SY, SZ } from \"../math\";\n\nexport function seabed(\n gen: OverworldGenerator,\n chunk: Chunk,\n x: number,\n z: number,\n wx: number,\n wz: number,\n h: number,\n biome: OverworldBiome,\n water: number,\n): void {\n if (h >= SEA - 2) return;\n if (\n biome === \"warm_ocean\" &&\n h < SEA - 3 &&\n gen.patch.noise(wx * 0.07, wz * 0.07) > 0.1\n ) {\n const c =\n CORALS[\n Math.floor(seaRoll(gen.seed + 11, wx >> 2, wz >> 2) * CORALS.length)\n ]!;\n chunk.setRaw(x, h, z, B[`${c.id}_coral_block`]!);\n const r = seaRoll(gen.seed + 13, wx, wz);\n if (r < 0.45) chunk.setRaw(x, h + 1, z, B[`${c.id}_coral`]!, water);\n else if (r < 0.5) chunk.setRaw(x, h + 1, z, B.sea_pickle!, water);\n return;\n }\n if (\n biome === \"ocean\" &&\n h < SEA - 5 &&\n seaRoll(gen.seed + 7, wx, wz) < 0.06\n ) {\n const top = Math.min(\n SEA - 1,\n h + 2 + Math.floor(seaRoll(gen.seed + 9, wx, wz) * (SEA - h - 2)),\n );\n for (let y = h + 1; y < top; y++)\n chunk.setRaw(x, y, z, B.kelp_plant!, water);\n chunk.setRaw(x, top, z, B.kelp!, water);\n return;\n }\n if (seaRoll(gen.seed, wx, wz) < 0.15)\n chunk.setRaw(x, h + 1, z, B.seagrass!, water);\n}\n\nexport function ores(\n gen: OverworldGenerator,\n chunk: Chunk,\n rng: () => number,\n biomes: OverworldBiome[],\n): void {\n const rock = (id: number) => id === B.stone || id === B.deepslate;\n for (const [name, veins, size, minY, maxY] of ORES) {\n const id = B[name];\n if (id === undefined) continue;\n for (let v = 0; v < veins; v++) {\n let x = (rng() * SX) | 0,\n y = (minY + rng() * (maxY - minY)) | 0,\n z = (rng() * SZ) | 0;\n for (let i = 0; i < size; i++) {\n if (\n x >= 0 &&\n x < SX &&\n y >= 1 &&\n y < SY &&\n z >= 0 &&\n z < SZ &&\n rock(chunk.get(x, y, z))\n )\n chunk.setRaw(x, y, z, id);\n x += ((rng() * 3) | 0) - 1;\n y += ((rng() * 3) | 0) - 1;\n z += ((rng() * 3) | 0) - 1;\n }\n }\n }\n // изумруды — одиночными блоками и только в горах, как в Minecraft\n const emerald = B.emerald_ore;\n if (emerald !== undefined && biomes.includes(\"mountains\"))\n for (let k = 0; k < 3; k++) {\n const x = (rng() * SX) | 0,\n y = (60 + rng() * 50) | 0,\n z = (rng() * SZ) | 0;\n if (chunk.get(x, y, z) === B.stone) chunk.setRaw(x, y, z, emerald);\n }\n}\n\nexport function vegetation(\n gen: OverworldGenerator,\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n biomes: OverworldBiome[],\n): void {\n const m = TREE_MAX_RADIUS; // крона не выходит за чанк — генерация чанков независима\n for (let t = 0; t < 10; t++) {\n const x = m + ((rng() * (SX - 2 * m)) | 0),\n z = m + ((rng() * (SZ - 2 * m)) | 0);\n const i = x + z * SX;\n const def = BIOMES[biomes[i]!];\n const h = heights[i]!;\n if (rng() > def.trees * 0.6 || def.tree === \"none\") continue;\n // мангр растёт и на мелководье: на грязи под водой до 3 блоков\n const mangrove = def.tree === \"mangrove\";\n if ((mangrove ? h < SEA - 3 : h <= SEA) || h + TREE_MAX_HEIGHT >= SY)\n continue;\n const ground = chunk.get(x, h, z);\n if (\n mangrove\n ? ground !== B.mud\n : def.tree === \"dead\"\n ? ground !== B.sand\n : ground !== B.grass && ground !== B.snow\n )\n continue;\n const above = chunk.get(x, h + 1, z);\n if (above !== B.air && !(mangrove && above === B.water)) continue;\n // в лесу каждое пятое дерево — тёмный дуб\n const kind = biomes[i] === \"forest\" && rng() < 0.2 ? \"dark_oak\" : def.tree;\n if (kind === \"cherry\" && (x < 3 || x > SX - 4 || z < 3 || z > SZ - 4))\n continue; // крона вишни шире\n growTree(kind, x, h, z, rng, (tx, ty, tz, id, soft) => {\n if (ty >= SY || tx < 0 || tx >= SX || tz < 0 || tz >= SZ) return;\n if (soft && chunk.get(tx, ty, tz) !== B.air) return;\n chunk.setRaw(tx, ty, tz, id);\n });\n // пчелиное гнездо на стволе у 5% дубов и берёз равнин и лесов (пчёл заведёт первый тик)\n const b = biomes[i];\n if (\n (kind === \"oak\" || kind === \"birch\") &&\n (b === \"plains\" || b === \"forest\" || b === \"birch_forest\") &&\n rng() < 0.05\n )\n if (z + 1 < SZ && h + 3 < SY)\n chunk.setRaw(x, h + 3, z + 1, B.bee_nest!, HIVE_SEEDED);\n }\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const i = x + z * SX;\n const h = heights[i]!,\n def = BIOMES[biomes[i]!];\n if (h <= SEA || h + 1 >= SY) continue;\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const r = rng();\n const g = def.grass * 0.25;\n if (r < g) chunk.setRaw(x, h + 1, z, B.tallgrass!);\n else if (r < g + def.flowers * 0.2)\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.flower_yellow! : B.flower_red!,\n );\n else if (biomes[i] === \"cherry_grove\" && r < g + 0.2)\n chunk.setRaw(x, h + 1, z, B.pink_petals!);\n }\n extras(gen, chunk, heights, biomes);\n}\n\nexport function extras(\n gen: OverworldGenerator,\n chunk: Chunk,\n heights: Int16Array,\n biomes: OverworldBiome[],\n): void {\n const rng = mulberry32(\n hashString(`ow-extra:${gen.seed}:${chunk.cx},${chunk.cz}`),\n );\n const at = (x: number, z: number) => heights[x + z * SX]!;\n const column = (x: number, y: number, z: number, id: number) => {\n const n = 1 + Math.floor(rng() * 3);\n for (let k = 0; k < n && y + k < SY; k++) chunk.setRaw(x, y + k, z, id);\n };\n for (let t = 0; t < 20; t++) {\n const x = 1 + Math.floor(rng() * (SX - 2)),\n z = 1 + Math.floor(rng() * (SZ - 2));\n const h = at(x, z),\n ground = chunk.get(x, h, z);\n if (chunk.get(x, h + 1, z) !== B.air) continue;\n const biome = biomes[x + z * SX]!;\n const water = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ].some(([dx, dz]) => chunk.get(x + dx!, h, z + dz!) === B.water);\n if (\n water &&\n (ground === B.grass || ground === B.sand || ground === B.dirt)\n ) {\n column(x, h + 1, z, B.sugar_cane!);\n } else if (biome === \"desert\" && ground === B.sand && t % 4 === 0) {\n const free = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ].every(([dx, dz]) => chunk.get(x + dx!, h + 1, z + dz!) === B.air);\n if (free) column(x, h + 1, z, B.cactus!);\n }\n }\n // тыквы и арбузы — редкими грядками\n const patchBiome = biomes[8 + 8 * SX]!;\n const fruit =\n patchBiome === \"jungle\" && rng() < 0.3\n ? B.melon!\n : (patchBiome === \"plains\" || patchBiome === \"forest\") && rng() < 0.04\n ? B.pumpkin!\n : undefined;\n if (fruit !== undefined)\n for (let k = 0; k < 6; k++) {\n const x = 2 + Math.floor(rng() * (SX - 4)),\n z = 2 + Math.floor(rng() * (SZ - 4));\n const h = at(x, z);\n if (chunk.get(x, h, z) === B.grass && chunk.get(x, h + 1, z) === B.air)\n chunk.setRaw(x, h + 1, z, fruit);\n }\n // грибные поля: огромные грибы (шляпка целиком в чанке); там же, в тёмных лесах, болотах и тайге —\n // россыпь маленьких грибов\n const R = HUGE_MUSHROOM_RADIUS;\n for (let t = 0; t < 6; t++) {\n const x = R + Math.floor(rng() * (SX - 2 * R)),\n z = R + Math.floor(rng() * (SZ - 2 * R));\n const b = biomes[x + z * SX]!,\n h = at(x, z),\n ground = chunk.get(x, h, z);\n if (chunk.get(x, h + 1, z) !== B.air || h <= SEA) continue;\n if (\n b === \"mushroom_fields\" &&\n ground === B.mycelium &&\n t < 3 &&\n h + HUGE_MUSHROOM_HEIGHT < SY\n ) {\n growHugeMushroom(\n (tx, ty, tz, id, soft) => {\n if (!soft || chunk.get(tx, ty, tz) === B.air)\n chunk.setRaw(tx, ty, tz, id);\n },\n x,\n h + 1,\n z,\n rng() < 0.5,\n rng,\n );\n } else if (\n (b === \"mushroom_fields\" ||\n b === \"dark_forest\" ||\n b === \"swamp\" ||\n b === \"taiga\") &&\n (ground === B.grass || ground === B.mycelium) &&\n rng() < 0.4\n )\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.brown_mushroom! : B.red_mushroom!,\n );\n }\n // заросли бамбука — в каждом третьем чанке джунглей, стебли 4–11 блоков\n if (biomes[8 + 8 * SX] === \"jungle\" && rng() < 0.35)\n for (let k = 0; k < 12; k++) {\n const x = 1 + Math.floor(rng() * (SX - 2)),\n z = 1 + Math.floor(rng() * (SZ - 2));\n const h = at(x, z);\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const n = 4 + Math.floor(rng() * 8);\n for (\n let s = 1;\n s <= n && h + s < SY && chunk.get(x, h + s, z) === B.air;\n s++\n )\n chunk.setRaw(x, h + s, z, B.bamboo!);\n }\n // какао на стволах джунглей (2–5 блоков над землёй): стручки разного возраста, к стволу лицом\n for (let t = 0; t < 16; t++) {\n const x = 1 + Math.floor(rng() * (SX - 2)),\n z = 1 + Math.floor(rng() * (SZ - 2));\n if (biomes[x + z * SX] !== \"jungle\") continue;\n const h = at(x, z);\n for (let k = 2; k <= 5; k++) {\n const y = h + k;\n if (y >= SY || chunk.get(x, y, z) !== B.jungle_log) break;\n if (rng() > 0.3) continue;\n const f = Math.floor(rng() * 4);\n const [dx, dz] = FACING_DIRS[f]!;\n const cx = x - dx,\n cz = z - dz; // ствол — со стороны f от стручка\n if (\n cx < 0 ||\n cx >= SX ||\n cz < 0 ||\n cz >= SZ ||\n chunk.get(cx, y, cz) !== B.air\n )\n continue;\n chunk.setRaw(cx, y, cz, B.cocoa!, f | (Math.floor(rng() * 3) << 2));\n }\n }\n}\n"
|
|
1981
|
+
},
|
|
1982
|
+
{
|
|
1983
|
+
"path": "worldgen/overworld/generator/structures.ts",
|
|
1984
|
+
"content": "import { mulberry32, hashString } from \"../../../core/Noise\";\nimport type { Chunk } from \"../../../world/Chunk\";\nimport {\n carveCitadels,\n citadelAt,\n makeCitadel,\n nearestCitadel,\n type Citadel,\n} from \"../../features/citadel\";\nimport {\n VILLAGE_REGION,\n planVillage,\n type Village,\n} from \"../../features/village\";\nimport {\n TEMPLE_REGION,\n inTemple,\n planTemple,\n type Temple,\n} from \"../../features/temple\";\nimport {\n WRECK_REGION,\n planWreck,\n wreckLoot,\n type Wreck,\n} from \"../../features/shipwreck\";\nimport {\n OUTPOST_REGION,\n inOutpost,\n outpostLoot,\n planOutpost,\n type Outpost,\n} from \"../../features/outpost\";\nimport {\n MINESHAFT_REGION,\n type Mineshaft,\n inMineshaft,\n mineshaftLoot,\n planMineshaft,\n} from \"../../features/mineshaft\";\nimport {\n TREASURE_REGION,\n planTreasure,\n treasureLoot,\n type Buried,\n} from \"../../features/treasure\";\nimport {\n MANSION_REGION,\n inMansion,\n mansionLoot,\n planMansion,\n type Mansion,\n} from \"../../features/mansion\";\nimport {\n ANCIENT_REGION,\n ancientLoot,\n inAncientCity,\n planAncientCity,\n type AncientCity,\n} from \"../../features/ancientCity\";\nimport {\n RUINS_REGION,\n WELL_REGION,\n archaeologyLoot,\n planRuins,\n planWell,\n type Dig,\n} from \"../../features/archaeology\";\nimport {\n TRIAL_REGION,\n inTrialChamber,\n planTrialChamber,\n type TrialChamber,\n} from \"../../features/trialChambers\";\nimport { vaultLootAt } from \"../../../systems/TrialSpawner\";\nimport type { ItemStack } from \"../../../types\";\nimport { CITADEL_Y } from \"../constants\";\nimport type { OverworldGenerator } from \"../OverworldGenerator\";\n\nexport function planCitadels(gen: OverworldGenerator): Citadel[] {\n const r = mulberry32(hashString(`ow-citadel:${gen.seed}`));\n const a0 = r() * Math.PI * 2;\n return [0, 1, 2].map((k) => {\n const a = a0 + (k * Math.PI * 2) / 3,\n d = 300 + r() * 400;\n return makeCitadel(\n Math.round(Math.cos(a) * d),\n Math.round(Math.sin(a) * d),\n CITADEL_Y,\n r,\n );\n });\n}\n\nexport function citadel(gen: OverworldGenerator, chunk: Chunk): void {\n carveCitadels(chunk, gen.citadels);\n}\n\nexport function villageIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): Village | null {\n const k = rx + \",\" + rz;\n if (!gen.villages.has(k))\n gen.villages.set(\n k,\n planVillage(\n gen,\n gen.seed,\n rx,\n rz,\n mulberry32(hashString(`ow-village:${gen.seed}:${rx},${rz}`)),\n ),\n );\n return gen.villages.get(k)!;\n}\n\nexport function templeIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): Temple | null {\n const k = rx + \",\" + rz;\n if (!gen.temples.has(k))\n gen.temples.set(k, planTemple(gen, gen.seed, rx, rz));\n return gen.temples.get(k)!;\n}\n\nexport function villagesNear(\n gen: OverworldGenerator,\n x: number,\n z: number,\n r: number,\n): Village[] {\n const out: Village[] = [];\n for (const [region, get] of [\n [VILLAGE_REGION, (a: number, b: number) => villageIn(gen, a, b)],\n [TEMPLE_REGION, (a: number, b: number) => templeIn(gen, a, b)],\n [OUTPOST_REGION, (a: number, b: number) => outpostIn(gen, a, b)],\n [MANSION_REGION, (a: number, b: number) => mansionIn(gen, a, b)],\n ] as const) {\n const rx = Math.floor(x / region),\n rz = Math.floor(z / region);\n for (let dx = -1; dx <= 1; dx++)\n for (let dz = -1; dz <= 1; dz++) {\n const v = get(rx + dx, rz + dz);\n if (v && Math.hypot(v.x - x, v.z - z) < r) out.push(v);\n }\n }\n return out;\n}\n\nexport function structureAt(\n gen: OverworldGenerator,\n x: number,\n y: number,\n z: number,\n): string | null {\n if (citadelAt(gen.citadels, x, y, z)) return \"citadel\";\n const fx = Math.floor(x),\n fy = Math.floor(y),\n fz = Math.floor(z);\n const t = templeIn(\n gen,\n Math.floor(x / TEMPLE_REGION),\n Math.floor(z / TEMPLE_REGION),\n );\n if (t && inTemple(t, fx, fy, fz)) return \"sea_temple\";\n const o = outpostIn(\n gen,\n Math.floor(x / OUTPOST_REGION),\n Math.floor(z / OUTPOST_REGION),\n );\n if (o && inOutpost(o, fx, fy, fz)) return \"outpost\";\n const ms = mineshaftIn(\n gen,\n Math.floor(x / MINESHAFT_REGION),\n Math.floor(z / MINESHAFT_REGION),\n );\n if (ms && inMineshaft(ms, fx, fy, fz)) return \"mineshaft\";\n const m = mansionIn(\n gen,\n Math.floor(x / MANSION_REGION),\n Math.floor(z / MANSION_REGION),\n );\n if (m && inMansion(m, fx, fy, fz)) return \"mansion\";\n const ac = ancientCityIn(\n gen,\n Math.floor(x / ANCIENT_REGION),\n Math.floor(z / ANCIENT_REGION),\n );\n if (ac && inAncientCity(ac, fx, fy, fz)) return \"ancient_city\";\n const tc = trialIn(\n gen,\n Math.floor(x / TRIAL_REGION),\n Math.floor(z / TRIAL_REGION),\n );\n return tc && inTrialChamber(tc, fx, fy, fz) ? \"trial_chambers\" : null;\n}\n\nexport function locate(\n gen: OverworldGenerator,\n kind: string,\n x: number,\n z: number,\n): { x: number; y: number; z: number } | null {\n if (kind === \"village\") {\n // ближайшая деревня — кольцами регионов вокруг точки\n const rx = Math.floor(x / VILLAGE_REGION),\n rz = Math.floor(z / VILLAGE_REGION);\n let best: Village | null = null;\n for (let ring = 0; ring <= 6 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const v = villageIn(gen, rx + dx, rz + dz);\n if (\n v &&\n (!best ||\n Math.hypot(v.x - x, v.z - z) < Math.hypot(best.x - x, best.z - z))\n )\n best = v;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"buried_treasure\") {\n const rx = Math.floor(x / TREASURE_REGION),\n rz = Math.floor(z / TREASURE_REGION);\n let best: Buried | null = null;\n for (let ring = 0; ring <= 8 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const t = treasureIn(gen, rx + dx, rz + dz);\n if (\n t &&\n (!best ||\n Math.hypot(t.x - x, t.z - z) < Math.hypot(best.x - x, best.z - z))\n )\n best = t;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"trial_chambers\") {\n const rx = Math.floor(x / TRIAL_REGION),\n rz = Math.floor(z / TRIAL_REGION);\n let best: TrialChamber | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const t = trialIn(gen, rx + dx, rz + dz);\n if (\n t &&\n (!best ||\n Math.hypot(t.x - x, t.z - z) < Math.hypot(best.x - x, best.z - z))\n )\n best = t;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"trail_ruins\") {\n const rx = Math.floor(x / RUINS_REGION),\n rz = Math.floor(z / RUINS_REGION);\n let best: Dig | null = null;\n for (let ring = 0; ring <= 8 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const r = ruinsIn(gen, rx + dx, rz + dz);\n if (\n r &&\n (!best ||\n Math.hypot(r.x - x, r.z - z) < Math.hypot(best.x - x, best.z - z))\n )\n best = r;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"ancient_city\") {\n const rx = Math.floor(x / ANCIENT_REGION),\n rz = Math.floor(z / ANCIENT_REGION);\n let best: AncientCity | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const a = ancientCityIn(gen, rx + dx, rz + dz);\n if (\n a &&\n (!best ||\n Math.hypot(a.x - x, a.z - z) < Math.hypot(best.x - x, best.z - z))\n )\n best = a;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"mansion\") {\n const rx = Math.floor(x / MANSION_REGION),\n rz = Math.floor(z / MANSION_REGION);\n let best: Mansion | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const m = mansionIn(gen, rx + dx, rz + dz);\n if (\n m &&\n (!best ||\n Math.hypot(m.x - x, m.z - z) < Math.hypot(best.x - x, best.z - z))\n )\n best = m;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"outpost\") {\n const rx = Math.floor(x / OUTPOST_REGION),\n rz = Math.floor(z / OUTPOST_REGION);\n let best: Outpost | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const o = outpostIn(gen, rx + dx, rz + dz);\n if (\n o &&\n (!best ||\n Math.hypot(o.x - x, o.z - z) < Math.hypot(best.x - x, best.z - z))\n )\n best = o;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"shipwreck\") {\n const rx = Math.floor(x / WRECK_REGION),\n rz = Math.floor(z / WRECK_REGION);\n let best: Wreck | null = null;\n for (let ring = 0; ring <= 6 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const w = wreckIn(gen, rx + dx, rz + dz);\n if (\n w &&\n (!best ||\n Math.hypot(w.x - x, w.z - z) < Math.hypot(best.x - x, best.z - z))\n )\n best = w;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"temple\") {\n const rx = Math.floor(x / TEMPLE_REGION),\n rz = Math.floor(z / TEMPLE_REGION);\n let best: Temple | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const t = templeIn(gen, rx + dx, rz + dz);\n if (\n t &&\n (!best ||\n Math.hypot(t.x - x, t.z - z) < Math.hypot(best.x - x, best.z - z))\n )\n best = t;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind !== \"citadel\") return null;\n const c = nearestCitadel(gen.citadels, x, z);\n return c ? { x: c.x, y: c.y, z: c.z } : null;\n}\n\nexport function ancientCityIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): AncientCity | null {\n const k = rx + \",\" + rz;\n if (!gen.ancientCities.has(k))\n gen.ancientCities.set(\n k,\n planAncientCity(gen, gen.seed, rx, rz, (x, z) => gen.isDeep(x, z)),\n );\n return gen.ancientCities.get(k)!;\n}\n\nexport function trialIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): TrialChamber | null {\n const k = rx + \",\" + rz;\n if (!gen.trials.has(k))\n gen.trials.set(k, planTrialChamber(gen, gen.seed, rx, rz));\n return gen.trials.get(k)!;\n}\n\nexport function trialSpawnersNear(\n gen: OverworldGenerator,\n x: number,\n z: number,\n r: number,\n): [number, number, number][] {\n const out: [number, number, number][] = [];\n const rx = Math.floor(x / TRIAL_REGION),\n rz = Math.floor(z / TRIAL_REGION);\n for (let dx = -1; dx <= 1; dx++)\n for (let dz = -1; dz <= 1; dz++)\n for (const s of trialIn(gen, rx + dx, rz + dz)?.spawners ?? [])\n if (Math.hypot(s[0] - x, s[2] - z) < r) out.push(s);\n return out;\n}\n\nexport function wellIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): Dig | null {\n const k = rx + \",\" + rz;\n if (!gen.wells.has(k)) gen.wells.set(k, planWell(gen, gen.seed, rx, rz));\n return gen.wells.get(k)!;\n}\n\nexport function ruinsIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): Dig | null {\n const k = rx + \",\" + rz;\n if (!gen.ruins.has(k)) gen.ruins.set(k, planRuins(gen, gen.seed, rx, rz));\n return gen.ruins.get(k)!;\n}\n\nexport function mansionIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): Mansion | null {\n const k = rx + \",\" + rz;\n if (!gen.mansions.has(k))\n gen.mansions.set(k, planMansion(gen, gen.seed, rx, rz));\n return gen.mansions.get(k)!;\n}\n\nexport function treasureIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): Buried | null {\n const k = rx + \",\" + rz;\n if (!gen.treasures.has(k))\n gen.treasures.set(k, planTreasure(gen, gen.seed, rx, rz));\n return gen.treasures.get(k)!;\n}\n\nexport function mineshaftIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): Mineshaft | null {\n const k = rx + \",\" + rz;\n if (!gen.mineshafts.has(k))\n gen.mineshafts.set(k, planMineshaft(gen, gen.seed, rx, rz));\n return gen.mineshafts.get(k)!;\n}\n\nexport function outpostIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): Outpost | null {\n const k = rx + \",\" + rz;\n if (!gen.outposts.has(k)) {\n let o = planOutpost(gen, gen.seed, rx, rz);\n if (o) {\n const vx = Math.floor(o.x / VILLAGE_REGION),\n vz = Math.floor(o.z / VILLAGE_REGION);\n for (let dx = -1; dx <= 1 && o; dx++)\n for (let dz = -1; dz <= 1 && o; dz++) {\n const v = villageIn(gen, vx + dx, vz + dz);\n if (v && Math.hypot(v.x - o.x, v.z - o.z) < 100) o = null;\n }\n }\n gen.outposts.set(k, o);\n }\n return gen.outposts.get(k)!;\n}\n\nexport function wreckIn(\n gen: OverworldGenerator,\n rx: number,\n rz: number,\n): Wreck | null {\n const k = rx + \",\" + rz;\n if (!gen.wrecks.has(k)) gen.wrecks.set(k, planWreck(gen, gen.seed, rx, rz));\n return gen.wrecks.get(k)!;\n}\n\nexport function lootAt(\n gen: OverworldGenerator,\n x: number,\n y: number,\n z: number,\n): (ItemStack | null)[] | null {\n const k = `${x},${y},${z}`;\n const w = wreckIn(\n gen,\n Math.floor(x / WRECK_REGION),\n Math.floor(z / WRECK_REGION),\n );\n const kind = w?.chests.get(k);\n if (kind) {\n const slots = wreckLoot(kind, gen.seed, x, y, z);\n // сундук с картами — карта сокровищ к ближайшему зарытому кладу\n const t = kind === \"map\" ? locate(gen, \"buried_treasure\", x, z) : null;\n if (t)\n slots[13] = { item: \"treasure_map\", count: 1, tag: { x: t.x, z: t.z } };\n return slots;\n }\n const tr = treasureIn(\n gen,\n Math.floor(x / TREASURE_REGION),\n Math.floor(z / TREASURE_REGION),\n );\n if (tr && tr.x === x && tr.y === y && tr.z === z)\n return treasureLoot(gen.seed, x, y, z);\n const ma = mansionIn(\n gen,\n Math.floor(x / MANSION_REGION),\n Math.floor(z / MANSION_REGION),\n );\n if (ma?.chests.has(k)) return mansionLoot(gen.seed, x, y, z);\n const ac = ancientCityIn(\n gen,\n Math.floor(x / ANCIENT_REGION),\n Math.floor(z / ANCIENT_REGION),\n );\n if (ac?.chests.has(k)) return ancientLoot(gen.seed, x, y, z);\n // находки подозрительного песка и гравия (кисть — world/behaviours/archaeology)\n if (\n wellIn(\n gen,\n Math.floor(x / WELL_REGION),\n Math.floor(z / WELL_REGION),\n )?.sus.has(k)\n )\n return archaeologyLoot(\"well\", gen.seed, x, y, z);\n if (\n ruinsIn(\n gen,\n Math.floor(x / RUINS_REGION),\n Math.floor(z / RUINS_REGION),\n )?.sus.has(k)\n )\n return archaeologyLoot(\"ruins\", gen.seed, x, y, z);\n // хранилище испытательных камер (открывается ключом — world/behaviours/trial)\n if (\n trialIn(\n gen,\n Math.floor(x / TRIAL_REGION),\n Math.floor(z / TRIAL_REGION),\n )?.vaults.has(k)\n )\n return vaultLootAt(gen.seed, x, y, z);\n // сундук припасов заброшенной шахты\n if (\n mineshaftIn(\n gen,\n Math.floor(x / MINESHAFT_REGION),\n Math.floor(z / MINESHAFT_REGION),\n )?.chests.has(k)\n )\n return mineshaftLoot(gen.seed, x, y, z);\n const o = outpostIn(\n gen,\n Math.floor(x / OUTPOST_REGION),\n Math.floor(z / OUTPOST_REGION),\n );\n return o?.chest === k ? outpostLoot(gen.seed, x, y, z) : null;\n}\n"
|
|
1985
|
+
},
|
|
1986
|
+
{
|
|
1987
|
+
"path": "worldgen/overworld/math.ts",
|
|
1988
|
+
"content": "import { CHUNK_SIZE } from \"../../world/Chunk\";\n\nexport const { SX, SY, SZ } = CHUNK_SIZE;\n\nexport const clamp = (v: number, a: number, b: number): number =>\n Math.min(b, Math.max(a, v));\n\nexport const smoothstep = (a: number, b: number, x: number): number => {\n const t = clamp((x - a) / (b - a), 0, 1);\n return t * t * (3 - 2 * t);\n};\n\n// решётка 3D-поля пещер: шаг 4 по x/z и 8 по y, как в Minecraft; внутри — трилинейная интерполяция\nexport const GX = SX / 4 + 1,\n GY = SY / 8 + 1,\n GZ = SZ / 4 + 1;\n"
|
|
1989
|
+
},
|
|
1738
1990
|
{
|
|
1739
1991
|
"path": "worldgen/overworld/OverworldGenerator.ts",
|
|
1740
|
-
"content": "// Верхний мир поколения 2 — новые миры по сиду (сейв v3): высота 128, море на 62, материки и\n// океаны, горы до ~120, биомы по температуре и влажности, пещеры из 3D-шума с лавой на дне, руды по\n// глубине, деревья и травы по биомам. Старые миры по сиду остаются на TerrainGenerator: их вывод\n// зафиксирован сейвами v1/v2.\n//\n// ⚠ Вывод этого генератора станет частью формата сейва, как только миры gen 2 уйдут игрокам\n// (релиз). До релиза его можно менять вместе с отпечатком в тесте; после — только новой версией gen.\nimport { Simplex2, Simplex3, mulberry32, hashString } from \"../../core/Noise\";\nimport { B, packWater } from \"../../registry/Blocks\";\nimport { CHUNK_SIZE, idx, type Chunk } from \"../../world/Chunk\";\nimport type { WorldGenerator } from \"../WorldGenerator\";\nimport { growTree, TREE_MAX_HEIGHT, TREE_MAX_RADIUS } from \"../trees\";\nimport {\n HUGE_MUSHROOM_HEIGHT,\n HUGE_MUSHROOM_RADIUS,\n growHugeMushroom,\n} from \"../mushrooms\";\nimport { FACING_DIRS } from \"../../registry/meta\";\nimport type { TreeType } from \"../WorldSpec\";\nimport {\n carveCitadels,\n citadelAt,\n makeCitadel,\n nearestCitadel,\n type Citadel,\n} from \"../features/citadel\";\nimport {\n VILLAGE_REGION,\n carveVillages,\n planVillage,\n type Village,\n} from \"../features/village\";\nimport { HIVE_SEEDED } from \"../../registry/blocks/bees\";\nimport {\n TEMPLE_REGION,\n inTemple,\n planTemple,\n seaRoll,\n type Temple,\n} from \"../features/temple\";\nimport {\n WRECK_REGION,\n planWreck,\n wreckLoot,\n type Wreck,\n} from \"../features/shipwreck\";\nimport {\n OUTPOST_REGION,\n inOutpost,\n outpostLoot,\n planOutpost,\n type Outpost,\n} from \"../features/outpost\";\nimport {\n MINESHAFT_REGION,\n type Mineshaft,\n inMineshaft,\n mineshaftLoot,\n planMineshaft,\n} from \"../features/mineshaft\";\nimport {\n TREASURE_REGION,\n planTreasure,\n treasureLoot,\n type Buried,\n} from \"../features/treasure\";\nimport {\n MANSION_REGION,\n inMansion,\n mansionLoot,\n planMansion,\n type Mansion,\n} from \"../features/mansion\";\nimport {\n ANCIENT_REGION,\n ancientLoot,\n inAncientCity,\n planAncientCity,\n type AncientCity,\n} from \"../features/ancientCity\";\nimport { CAN_SUMMON } from \"../../registry/blocks/sculk\";\nimport {\n RUINS_REGION,\n WELL_REGION,\n archaeologyLoot,\n planRuins,\n planWell,\n type Dig,\n} from \"../features/archaeology\";\nimport {\n TRIAL_REGION,\n inTrialChamber,\n planTrialChamber,\n type TrialChamber,\n} from \"../features/trialChambers\";\nimport { vaultLootAt } from \"../../systems/TrialSpawner\";\nimport { CORALS } from \"../../registry/blocks/sealife\";\nimport type { ItemStack } from \"../../types\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\nexport const SEA = 62;\n\nexport type OverworldBiome =\n | \"ocean\"\n | \"warm_ocean\"\n | \"beach\"\n | \"plains\"\n | \"forest\"\n | \"birch_forest\"\n | \"taiga\"\n | \"snowy\"\n | \"desert\"\n | \"savanna\"\n | \"jungle\"\n | \"swamp\"\n | \"mountains\"\n | \"dark_forest\"\n | \"cherry_grove\"\n | \"mangrove_swamp\"\n | \"mushroom_fields\";\n\ninterface BiomeDef {\n surface: string;\n under: string;\n /** Порода: из рецепта мира (TreeType) или новая (jungle, acacia, dark_oak, cherry — registry/blocks/wood). */\n tree: TreeType | \"jungle\" | \"acacia\" | \"dark_oak\" | \"cherry\" | \"mangrove\";\n /** Доля попыток посадить дерево (10 на чанк). */\n trees: number;\n grass: number;\n flowers: number;\n}\n\nconst BIOMES: Record<OverworldBiome, BiomeDef> = {\n ocean: {\n surface: \"sand\",\n under: \"sand\",\n tree: \"none\",\n trees: 0,\n grass: 0,\n flowers: 0,\n },\n warm_ocean: {\n surface: \"sand\",\n under: \"sand\",\n tree: \"none\",\n trees: 0,\n grass: 0,\n flowers: 0,\n },\n beach: {\n surface: \"sand\",\n under: \"sand\",\n tree: \"none\",\n trees: 0,\n grass: 0,\n flowers: 0,\n },\n plains: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"oak\",\n trees: 0.05,\n grass: 0.35,\n flowers: 0.06,\n },\n forest: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"oak\",\n trees: 0.9,\n grass: 0.2,\n flowers: 0.04,\n },\n birch_forest: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"birch\",\n trees: 0.8,\n grass: 0.2,\n flowers: 0.05,\n },\n taiga: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"spruce\",\n trees: 0.8,\n grass: 0.15,\n flowers: 0.01,\n },\n snowy: {\n surface: \"snow\",\n under: \"dirt\",\n tree: \"spruce\",\n trees: 0.15,\n grass: 0,\n flowers: 0,\n },\n desert: {\n surface: \"sand\",\n under: \"sandstone\",\n tree: \"dead\",\n trees: 0.03,\n grass: 0,\n flowers: 0,\n },\n savanna: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"acacia\",\n trees: 0.12,\n grass: 0.5,\n flowers: 0.01,\n },\n jungle: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"jungle\",\n trees: 1,\n grass: 0.5,\n flowers: 0.05,\n },\n swamp: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"oak\",\n trees: 0.3,\n grass: 0.3,\n flowers: 0.02,\n },\n mountains: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"spruce\",\n trees: 0.1,\n grass: 0.05,\n flowers: 0,\n },\n // тёмный лес: сплошной полог тёмного дуба, под ним сумрак — там стоят особняки\n dark_forest: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"dark_oak\",\n trees: 1,\n grass: 0.15,\n flowers: 0.02,\n },\n // вишнёвая роща: редкие розовые вишни на холмах, трава в лепестках\n cherry_grove: {\n surface: \"grass\",\n under: \"dirt\",\n tree: \"cherry\",\n trees: 0.35,\n grass: 0.4,\n flowers: 0,\n },\n // мангровое болото: дно из грязи на мелководье, мангры на корнях прямо в воде\n mangrove_swamp: {\n surface: \"mud\",\n under: \"mud\",\n tree: \"mangrove\",\n trees: 0.7,\n grass: 0,\n flowers: 0,\n },\n // грибные поля: мицелий, огромные грибы вместо деревьев (extras), только грибные коровы, без монстров\n mushroom_fields: {\n surface: \"mycelium\",\n under: \"dirt\",\n tree: \"none\",\n trees: 0,\n grass: 0,\n flowers: 0,\n },\n};\n\n/**\n * Руды: [имя, жил на чанк, длина жилы, min y, max y] — пропорции Minecraft 1.18, ужатые в высоту\n * 0..127. Руды, которых ещё нет в реестре, пропускаются.\n */\nconst ORES: readonly [string, number, number, number, number][] = [\n [\"coal_ore\", 20, 12, 5, 120],\n [\"iron_ore\", 12, 8, 5, 72],\n [\"copper_ore\", 8, 10, 40, 96],\n [\"gold_ore\", 4, 8, 5, 32],\n [\"redstone_ore\", 6, 7, 5, 16],\n [\"lapis_ore\", 2, 6, 5, 30],\n [\"diamond_ore\", 1, 7, 5, 14],\n];\n\n/** Шесть граней клетки (жеоды, озерца пышных пещер). */\nconst FACES6: readonly (readonly [number, number, number])[] = [\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];\n\n/** Пещера ниже этой высоты заполнена лавой (как «лавовое озеро» старого Minecraft). */\nconst LAVA_LEVEL = 10;\n\n/** Подземная цитадель: y пола зала портала (рамки стоят на нём). */\nexport const CITADEL_Y = 24;\n\nconst clamp = (v: number, a: number, b: number): number =>\n Math.min(b, Math.max(a, v));\nconst smoothstep = (a: number, b: number, x: number): number => {\n const t = clamp((x - a) / (b - a), 0, 1);\n return t * t * (3 - 2 * t);\n};\n\n// решётка 3D-поля пещер: шаг 4 по x/z и 8 по y, как в Minecraft; внутри — трилинейная интерполяция\nconst GX = SX / 4 + 1,\n GY = SY / 8 + 1,\n GZ = SZ / 4 + 1;\n\nexport class OverworldGenerator implements WorldGenerator {\n readonly seed: number;\n readonly waterLevel = SEA;\n private readonly cont: Simplex2;\n private readonly hills: Simplex2;\n private readonly detail: Simplex2;\n private readonly ridge: Simplex2;\n private readonly mtn: Simplex2;\n private readonly temp: Simplex2;\n private readonly hum: Simplex2;\n private readonly patch: Simplex2;\n /** Пятна пышных пещер. */\n private readonly lush: Simplex2;\n /** Пятна глубокой тьмы. */\n private readonly deep: Simplex2;\n /** Пятна капельниковых пещер. */\n private readonly drip: Simplex2;\n private readonly caveA: Simplex3;\n private readonly caveB: Simplex3;\n private readonly cheese: Simplex3;\n private spawn: { x: number; z: number } | null = null;\n /** Три цитадели на кольце 300–700 блоков от центра (в Minecraft — дальше; для браузера ближе). */\n private readonly citadels: Citadel[];\n /** Деревни по регионам (VILLAGE_REGION): план считается при первом обращении. */\n private readonly villages = new Map<string, Village | null>();\n /** Подводные храмы по регионам (TEMPLE_REGION). */\n private readonly temples = new Map<string, Temple | null>();\n /** Затонувшие корабли по регионам (WRECK_REGION). */\n private readonly wrecks = new Map<string, Wreck | null>();\n /** Заброшенные шахты по регионам (MINESHAFT_REGION). */\n private readonly mineshafts = new Map<string, Mineshaft | null>();\n /** Заставы мародёров по регионам (OUTPOST_REGION). */\n private readonly outposts = new Map<string, Outpost | null>();\n /** Зарытые клады по регионам (TREASURE_REGION). */\n private readonly treasures = new Map<string, Buried | null>();\n /** Особняки по регионам (MANSION_REGION). */\n private readonly mansions = new Map<string, Mansion | null>();\n /** Древние города по регионам (ANCIENT_REGION). */\n private readonly ancientCities = new Map<string, AncientCity | null>();\n /** Пустынные колодцы (WELL_REGION) и засыпанные руины (RUINS_REGION) — места раскопок. */\n private readonly wells = new Map<string, Dig | null>();\n private readonly ruins = new Map<string, Dig | null>();\n /** Испытательные камеры по регионам (TRIAL_REGION). */\n private readonly trials = new Map<string, TrialChamber | null>();\n\n constructor(seed: number) {\n this.seed = seed;\n const n2 = (k: string) => new Simplex2(hashString(`ow:${k}:${seed}`));\n const n3 = (k: string) => new Simplex3(hashString(`ow:${k}:${seed}`));\n this.cont = n2(\"continent\");\n this.hills = n2(\"hills\");\n this.detail = n2(\"detail\");\n this.ridge = n2(\"ridge\");\n this.mtn = n2(\"mountain\");\n this.temp = n2(\"temperature\");\n this.hum = n2(\"humidity\");\n this.patch = n2(\"patch\");\n this.lush = n2(\"lush\");\n this.deep = n2(\"deep\");\n this.drip = n2(\"drip\");\n this.caveA = n3(\"caveA\");\n this.caveB = n3(\"caveB\");\n this.cheese = n3(\"cheese\");\n this.citadels = this.planCitadels();\n }\n\n /** Три цитадели на кольце 300–700 блоков от центра (worldgen/features/citadel). */\n private planCitadels(): Citadel[] {\n const r = mulberry32(hashString(`ow-citadel:${this.seed}`));\n const a0 = r() * Math.PI * 2;\n return [0, 1, 2].map((k) => {\n const a = a0 + (k * Math.PI * 2) / 3,\n d = 300 + r() * 400;\n return makeCitadel(\n Math.round(Math.cos(a) * d),\n Math.round(Math.sin(a) * d),\n CITADEL_Y,\n r,\n );\n });\n }\n\n private citadel(chunk: Chunk): void {\n carveCitadels(chunk, this.citadels);\n }\n\n /** Деревня региона (rx, rz) или null (worldgen/features/village). */\n villageIn(rx: number, rz: number): Village | null {\n const k = rx + \",\" + rz;\n if (!this.villages.has(k))\n this.villages.set(\n k,\n planVillage(\n this,\n this.seed,\n rx,\n rz,\n mulberry32(hashString(`ow-village:${this.seed}:${rx},${rz}`)),\n ),\n );\n return this.villages.get(k)!;\n }\n\n /** Подводный храм региона (rx, rz) или null (worldgen/features/temple). */\n templeIn(rx: number, rz: number): Temple | null {\n const k = rx + \",\" + rz;\n if (!this.temples.has(k))\n this.temples.set(k, planTemple(this, this.seed, rx, rz));\n return this.temples.get(k)!;\n }\n\n /** Деревни и подводные храмы с центром ближе r к (x, z) — Game селит в них жителей. */\n villagesNear(x: number, z: number, r: number): Village[] {\n const out: Village[] = [];\n for (const [region, get] of [\n [VILLAGE_REGION, (a: number, b: number) => this.villageIn(a, b)],\n [TEMPLE_REGION, (a: number, b: number) => this.templeIn(a, b)],\n [OUTPOST_REGION, (a: number, b: number) => this.outpostIn(a, b)],\n [MANSION_REGION, (a: number, b: number) => this.mansionIn(a, b)],\n ] as const) {\n const rx = Math.floor(x / region),\n rz = Math.floor(z / region);\n for (let dx = -1; dx <= 1; dx++)\n for (let dz = -1; dz <= 1; dz++) {\n const v = get(rx + dx, rz + dz);\n if (v && Math.hypot(v.x - x, v.z - z) < r) out.push(v);\n }\n }\n return out;\n }\n\n structureAt(x: number, y: number, z: number): string | null {\n if (citadelAt(this.citadels, x, y, z)) return \"citadel\";\n const fx = Math.floor(x),\n fy = Math.floor(y),\n fz = Math.floor(z);\n const t = this.templeIn(\n Math.floor(x / TEMPLE_REGION),\n Math.floor(z / TEMPLE_REGION),\n );\n if (t && inTemple(t, fx, fy, fz)) return \"sea_temple\";\n const o = this.outpostIn(\n Math.floor(x / OUTPOST_REGION),\n Math.floor(z / OUTPOST_REGION),\n );\n if (o && inOutpost(o, fx, fy, fz)) return \"outpost\";\n const ms = this.mineshaftIn(\n Math.floor(x / MINESHAFT_REGION),\n Math.floor(z / MINESHAFT_REGION),\n );\n if (ms && inMineshaft(ms, fx, fy, fz)) return \"mineshaft\";\n const m = this.mansionIn(\n Math.floor(x / MANSION_REGION),\n Math.floor(z / MANSION_REGION),\n );\n if (m && inMansion(m, fx, fy, fz)) return \"mansion\";\n const ac = this.ancientCityIn(\n Math.floor(x / ANCIENT_REGION),\n Math.floor(z / ANCIENT_REGION),\n );\n if (ac && inAncientCity(ac, fx, fy, fz)) return \"ancient_city\";\n const tc = this.trialIn(\n Math.floor(x / TRIAL_REGION),\n Math.floor(z / TRIAL_REGION),\n );\n return tc && inTrialChamber(tc, fx, fy, fz) ? \"trial_chambers\" : null;\n }\n\n locate(\n kind: string,\n x: number,\n z: number,\n ): { x: number; y: number; z: number } | null {\n if (kind === \"village\") {\n // ближайшая деревня — кольцами регионов вокруг точки\n const rx = Math.floor(x / VILLAGE_REGION),\n rz = Math.floor(z / VILLAGE_REGION);\n let best: Village | null = null;\n for (let ring = 0; ring <= 6 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const v = this.villageIn(rx + dx, rz + dz);\n if (\n v &&\n (!best ||\n Math.hypot(v.x - x, v.z - z) <\n Math.hypot(best.x - x, best.z - z))\n )\n best = v;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"buried_treasure\") {\n const rx = Math.floor(x / TREASURE_REGION),\n rz = Math.floor(z / TREASURE_REGION);\n let best: Buried | null = null;\n for (let ring = 0; ring <= 8 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const t = this.treasureIn(rx + dx, rz + dz);\n if (\n t &&\n (!best ||\n Math.hypot(t.x - x, t.z - z) <\n Math.hypot(best.x - x, best.z - z))\n )\n best = t;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"trial_chambers\") {\n const rx = Math.floor(x / TRIAL_REGION),\n rz = Math.floor(z / TRIAL_REGION);\n let best: TrialChamber | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const t = this.trialIn(rx + dx, rz + dz);\n if (\n t &&\n (!best ||\n Math.hypot(t.x - x, t.z - z) <\n Math.hypot(best.x - x, best.z - z))\n )\n best = t;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"trail_ruins\") {\n const rx = Math.floor(x / RUINS_REGION),\n rz = Math.floor(z / RUINS_REGION);\n let best: Dig | null = null;\n for (let ring = 0; ring <= 8 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const r = this.ruinsIn(rx + dx, rz + dz);\n if (\n r &&\n (!best ||\n Math.hypot(r.x - x, r.z - z) <\n Math.hypot(best.x - x, best.z - z))\n )\n best = r;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"ancient_city\") {\n const rx = Math.floor(x / ANCIENT_REGION),\n rz = Math.floor(z / ANCIENT_REGION);\n let best: AncientCity | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const a = this.ancientCityIn(rx + dx, rz + dz);\n if (\n a &&\n (!best ||\n Math.hypot(a.x - x, a.z - z) <\n Math.hypot(best.x - x, best.z - z))\n )\n best = a;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"mansion\") {\n const rx = Math.floor(x / MANSION_REGION),\n rz = Math.floor(z / MANSION_REGION);\n let best: Mansion | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const m = this.mansionIn(rx + dx, rz + dz);\n if (\n m &&\n (!best ||\n Math.hypot(m.x - x, m.z - z) <\n Math.hypot(best.x - x, best.z - z))\n )\n best = m;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"outpost\") {\n const rx = Math.floor(x / OUTPOST_REGION),\n rz = Math.floor(z / OUTPOST_REGION);\n let best: Outpost | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const o = this.outpostIn(rx + dx, rz + dz);\n if (\n o &&\n (!best ||\n Math.hypot(o.x - x, o.z - z) <\n Math.hypot(best.x - x, best.z - z))\n )\n best = o;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"shipwreck\") {\n const rx = Math.floor(x / WRECK_REGION),\n rz = Math.floor(z / WRECK_REGION);\n let best: Wreck | null = null;\n for (let ring = 0; ring <= 6 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const w = this.wreckIn(rx + dx, rz + dz);\n if (\n w &&\n (!best ||\n Math.hypot(w.x - x, w.z - z) <\n Math.hypot(best.x - x, best.z - z))\n )\n best = w;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind === \"temple\") {\n const rx = Math.floor(x / TEMPLE_REGION),\n rz = Math.floor(z / TEMPLE_REGION);\n let best: Temple | null = null;\n for (let ring = 0; ring <= 5 && !best; ring++)\n for (let dx = -ring; dx <= ring; dx++)\n for (let dz = -ring; dz <= ring; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== ring) continue;\n const t = this.templeIn(rx + dx, rz + dz);\n if (\n t &&\n (!best ||\n Math.hypot(t.x - x, t.z - z) <\n Math.hypot(best.x - x, best.z - z))\n )\n best = t;\n }\n return best ? { x: best.x, y: best.y, z: best.z } : null;\n }\n if (kind !== \"citadel\") return null;\n const c = nearestCitadel(this.citadels, x, z);\n return c ? { x: c.x, y: c.y, z: c.z } : null;\n }\n\n /** Сила гор в точке (0..1). */\n private mountains(x: number, z: number): number {\n return smoothstep(\n 0.15,\n 0.55,\n this.mtn.fbm(x * 0.0022 + 31, z * 0.0022 - 17, 3),\n );\n }\n\n /** y верхнего блока земли — чистая функция координат (чанки сходятся без швов). */\n heightAt(x: number, z: number): number {\n const c = this.cont.fbm(x * 0.0016, z * 0.0016, 4);\n const hills = this.hills.fbm(x * 0.009, z * 0.009, 4);\n const detail = this.detail.noise(x * 0.05, z * 0.05);\n let h: number;\n if (c < -0.18) {\n h = SEA - 3 - Math.min(30, (-c - 0.18) * 60) + hills * 3; // океан: дно 30..59\n } else {\n const land = smoothstep(-0.18, 0.04, c); // побережье плавно выходит из воды\n const r = 1 - Math.abs(this.ridge.fbm(x * 0.004, z * 0.004, 4));\n h =\n SEA -\n 3 +\n land * (6 + hills * 9 + detail * 1.5) +\n land * this.mountains(x, z) * r * r * 58;\n }\n return clamp(Math.round(h), 4, SY - 10);\n }\n\n /** Биом колонны по высоте, температуре и влажности. */\n biomeAt(x: number, z: number, h: number): OverworldBiome {\n const t = this.temp.fbm(x * 0.0012, z * 0.0012, 3) * 1.6;\n if (h < SEA - 1) return t > 0.3 ? \"warm_ocean\" : \"ocean\"; // тёплый океан — коралловые рифы\n const w = this.hum.fbm(x * 0.0014 + 11, z * 0.0014 - 7, 3) * 1.6;\n if (h <= SEA + 1 && t > -0.3) return \"beach\";\n // грибные поля — редкими пятнами на невысокой суше\n if (\n h < SEA + 16 &&\n this.patch.noise(x * 0.0021 + 77, z * 0.0021 - 41) > 0.6\n )\n return \"mushroom_fields\";\n if (h > 92 && this.mountains(x, z) > 0.4) return \"mountains\";\n if (t < -0.3) return w > 0 ? \"taiga\" : \"snowy\";\n if (t > 0.3) {\n if (w < -0.1) return \"desert\";\n if (w > 0.25) return \"jungle\";\n return \"savanna\";\n }\n if (w > 0.3 && h < SEA + 6) return t > 0.15 ? \"mangrove_swamp\" : \"swamp\"; // тёплое болото — мангровое\n if (w > 0.18 && t > 0.05) return \"dark_forest\"; // тёплый и влажный лес\n // вишнёвые рощи — пятнами на умеренных холмах\n if (\n h > SEA + 14 &&\n w > -0.05 &&\n t > -0.12 &&\n t < 0.2 &&\n this.patch.noise(x * 0.004, z * 0.004) > 0.3\n )\n return \"cherry_grove\";\n if (w > 0.1) return t < 0 ? \"birch_forest\" : \"forest\";\n if (w > -0.05) return \"forest\";\n return \"plains\";\n }\n\n generate(chunk: Chunk): void {\n const rng = mulberry32(\n hashString(`ow-chunk:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n\n // высоты с каймой в блок — крутизна склона на краях чанка\n const G = SX + 2;\n const grid = new Int16Array(G * (SZ + 2));\n for (let z = -1; z <= SZ; z++)\n for (let x = -1; x <= SX; x++)\n grid[x + 1 + (z + 1) * G] = this.heightAt(wx0 + x, wz0 + z);\n\n const heights = new Int16Array(SX * SZ);\n const biomes: OverworldBiome[] = new Array<OverworldBiome>(SX * SZ);\n const water = packWater(8, true);\n\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const at = (dx: number, dz: number) =>\n grid[x + 1 + dx + (z + 1 + dz) * G]!;\n const h = at(0, 0);\n const slope = Math.max(\n Math.abs(h - at(1, 0)),\n Math.abs(h - at(-1, 0)),\n Math.abs(h - at(0, 1)),\n Math.abs(h - at(0, -1)),\n );\n const wx = wx0 + x,\n wz = wz0 + z;\n const biome = this.biomeAt(wx, wz, h);\n const def = BIOMES[biome];\n let surface = B[def.surface]!,\n under = B[def.under]!;\n if (biome === \"ocean\") {\n const n = this.patch.noise(wx * 0.08, wz * 0.08);\n surface = under =\n n > 0.35 ? B.gravel! : n < -0.45 ? B.clay! : B.sand!;\n } else if (h > 104)\n surface = B.snow!; // снежные шапки\n else if (slope >= 3 && surface === B.grass) {\n surface = B.stone!; // обрывы — голый камень\n under = B.stone!;\n } else if (biome === \"mountains\" && h > 96) surface = under = B.stone!;\n\n for (let y = 0; y <= h; y++) {\n let id: number;\n if (y === 0) id = B.bedrock!;\n else if (y < 4 && rng() < 0.6 - y * 0.15)\n id = B.bedrock!; // рваное дно\n else if (y === h) id = surface;\n else if (y >= h - 3) id = under;\n // глубинный сланец внизу, с рваной границей y 16..19 (как в Minecraft 1.18)\n else if (y < 16 || (y < 20 && rng() < (20 - y) / 5))\n id = B.deepslate!;\n else id = B.stone!;\n chunk.setRaw(x, y, z, id);\n }\n // снег-порох: пятнами в снегах и на снежных вершинах — сугроб в 2 блока вместо снега\n if (\n surface === B.snow &&\n (biome === \"snowy\" || biome === \"mountains\") &&\n this.patch.noise(wx * 0.09 + 50, wz * 0.09 - 30) > 0.45\n ) {\n chunk.setRaw(x, h, z, B.powder_snow!);\n chunk.setRaw(x, h - 1, z, B.powder_snow!);\n }\n for (let y = h + 1; y <= SEA; y++)\n chunk.setRaw(\n x,\n y,\n z,\n y === SEA && (biome === \"snowy\" || biome === \"taiga\")\n ? B.ice!\n : B.water!,\n y === SEA && (biome === \"snowy\" || biome === \"taiga\") ? 0 : water,\n );\n if (biome === \"ocean\" || biome === \"warm_ocean\")\n this.seabed(chunk, x, z, wx, wz, h, biome, water);\n const i = x + z * SX;\n heights[i] = h;\n biomes[i] = biome;\n }\n\n this.carveCaves(chunk, heights);\n this.ores(chunk, rng, biomes);\n this.geode(chunk, heights);\n this.lushCaves(chunk, heights);\n this.deepDark(chunk, heights);\n this.dripstoneCaves(chunk, heights);\n this.vegetation(chunk, rng, heights, biomes);\n this.citadel(chunk);\n const v = this.villageIn(\n Math.floor(wx0 / VILLAGE_REGION),\n Math.floor(wz0 / VILLAGE_REGION),\n );\n if (v) carveVillages(chunk, [v]);\n const t = this.templeIn(\n Math.floor(wx0 / TEMPLE_REGION),\n Math.floor(wz0 / TEMPLE_REGION),\n );\n if (t) carveVillages(chunk, [t]);\n const wr = this.wreckIn(\n Math.floor(wx0 / WRECK_REGION),\n Math.floor(wz0 / WRECK_REGION),\n );\n if (wr) carveVillages(chunk, [wr]);\n const ms = this.mineshaftIn(\n Math.floor(wx0 / MINESHAFT_REGION),\n Math.floor(wz0 / MINESHAFT_REGION),\n );\n if (ms) carveVillages(chunk, [ms]);\n const op = this.outpostIn(\n Math.floor(wx0 / OUTPOST_REGION),\n Math.floor(wz0 / OUTPOST_REGION),\n );\n if (op) carveVillages(chunk, [op]);\n const tr = this.treasureIn(\n Math.floor(wx0 / TREASURE_REGION),\n Math.floor(wz0 / TREASURE_REGION),\n );\n if (tr) carveVillages(chunk, [tr]);\n const ma = this.mansionIn(\n Math.floor(wx0 / MANSION_REGION),\n Math.floor(wz0 / MANSION_REGION),\n );\n if (ma) carveVillages(chunk, [ma]);\n const ac = this.ancientCityIn(\n Math.floor(wx0 / ANCIENT_REGION),\n Math.floor(wz0 / ANCIENT_REGION),\n );\n if (ac) carveVillages(chunk, [ac]);\n const wl = this.wellIn(\n Math.floor(wx0 / WELL_REGION),\n Math.floor(wz0 / WELL_REGION),\n );\n if (wl) carveVillages(chunk, [wl]);\n const ru = this.ruinsIn(\n Math.floor(wx0 / RUINS_REGION),\n Math.floor(wz0 / RUINS_REGION),\n );\n if (ru) carveVillages(chunk, [ru]);\n const tc = this.trialIn(\n Math.floor(wx0 / TRIAL_REGION),\n Math.floor(wz0 / TRIAL_REGION),\n );\n if (tc) carveVillages(chunk, [tc]);\n chunk.generated = true;\n }\n\n /** Древний город региона или null (worldgen/features/ancientCity) — только под глубокой тьмой. */\n ancientCityIn(rx: number, rz: number): AncientCity | null {\n const k = rx + \",\" + rz;\n if (!this.ancientCities.has(k))\n this.ancientCities.set(\n k,\n planAncientCity(this, this.seed, rx, rz, (x, z) => this.isDeep(x, z)),\n );\n return this.ancientCities.get(k)!;\n }\n\n /** Испытательные камеры региона или null (worldgen/features/trialChambers). */\n trialIn(rx: number, rz: number): TrialChamber | null {\n const k = rx + \",\" + rz;\n if (!this.trials.has(k))\n this.trials.set(k, planTrialChamber(this, this.seed, rx, rz));\n return this.trials.get(k)!;\n }\n\n /** Спавнеры испытаний ближе r к (x, z). */\n trialSpawnersNear(\n x: number,\n z: number,\n r: number,\n ): [number, number, number][] {\n const out: [number, number, number][] = [];\n const rx = Math.floor(x / TRIAL_REGION),\n rz = Math.floor(z / TRIAL_REGION);\n for (let dx = -1; dx <= 1; dx++)\n for (let dz = -1; dz <= 1; dz++)\n for (const s of this.trialIn(rx + dx, rz + dz)?.spawners ?? [])\n if (Math.hypot(s[0] - x, s[2] - z) < r) out.push(s);\n return out;\n }\n\n /** Пустынный колодец региона или null (worldgen/features/archaeology). */\n wellIn(rx: number, rz: number): Dig | null {\n const k = rx + \",\" + rz;\n if (!this.wells.has(k))\n this.wells.set(k, planWell(this, this.seed, rx, rz));\n return this.wells.get(k)!;\n }\n\n /** Засыпанные руины региона или null (worldgen/features/archaeology). */\n ruinsIn(rx: number, rz: number): Dig | null {\n const k = rx + \",\" + rz;\n if (!this.ruins.has(k))\n this.ruins.set(k, planRuins(this, this.seed, rx, rz));\n return this.ruins.get(k)!;\n }\n\n /** Глубокая тьма в колонне (и не пышные пещеры). */\n isDeep(x: number, z: number): boolean {\n return (\n this.deep.fbm(x * 0.006, z * 0.006, 2) > 0.25 &&\n this.lush.fbm(x * 0.008, z * 0.008, 2) < 0.3\n );\n }\n\n /** Особняк региона или null (worldgen/features/mansion). */\n mansionIn(rx: number, rz: number): Mansion | null {\n const k = rx + \",\" + rz;\n if (!this.mansions.has(k))\n this.mansions.set(k, planMansion(this, this.seed, rx, rz));\n return this.mansions.get(k)!;\n }\n\n /** Зарытый клад региона или null (worldgen/features/treasure). */\n treasureIn(rx: number, rz: number): Buried | null {\n const k = rx + \",\" + rz;\n if (!this.treasures.has(k))\n this.treasures.set(k, planTreasure(this, this.seed, rx, rz));\n return this.treasures.get(k)!;\n }\n\n /** Заброшенная шахта региона или null (worldgen/features/mineshaft). */\n mineshaftIn(rx: number, rz: number): Mineshaft | null {\n const k = rx + \",\" + rz;\n if (!this.mineshafts.has(k))\n this.mineshafts.set(k, planMineshaft(this, this.seed, rx, rz));\n return this.mineshafts.get(k)!;\n }\n\n /** Застава мародёров региона или null; рядом с деревней (ближе 100) заставы не бывает. */\n outpostIn(rx: number, rz: number): Outpost | null {\n const k = rx + \",\" + rz;\n if (!this.outposts.has(k)) {\n let o = planOutpost(this, this.seed, rx, rz);\n if (o) {\n const vx = Math.floor(o.x / VILLAGE_REGION),\n vz = Math.floor(o.z / VILLAGE_REGION);\n for (let dx = -1; dx <= 1 && o; dx++)\n for (let dz = -1; dz <= 1 && o; dz++) {\n const v = this.villageIn(vx + dx, vz + dz);\n if (v && Math.hypot(v.x - o.x, v.z - o.z) < 100) o = null;\n }\n }\n this.outposts.set(k, o);\n }\n return this.outposts.get(k)!;\n }\n\n /**\n * Дно океана: в тёплом — коралловые рифы пятнами (блоки одного цвета гнёздами 4×4, на них кораллы и\n * морские огурцы), в обычном — столбы ламинарии до поверхности; морская трава — в обоих.\n */\n private seabed(\n chunk: Chunk,\n x: number,\n z: number,\n wx: number,\n wz: number,\n h: number,\n biome: OverworldBiome,\n water: number,\n ): void {\n if (h >= SEA - 2) return;\n if (\n biome === \"warm_ocean\" &&\n h < SEA - 3 &&\n this.patch.noise(wx * 0.07, wz * 0.07) > 0.1\n ) {\n const c =\n CORALS[\n Math.floor(seaRoll(this.seed + 11, wx >> 2, wz >> 2) * CORALS.length)\n ]!;\n chunk.setRaw(x, h, z, B[`${c.id}_coral_block`]!);\n const r = seaRoll(this.seed + 13, wx, wz);\n if (r < 0.45) chunk.setRaw(x, h + 1, z, B[`${c.id}_coral`]!, water);\n else if (r < 0.5) chunk.setRaw(x, h + 1, z, B.sea_pickle!, water);\n return;\n }\n if (\n biome === \"ocean\" &&\n h < SEA - 5 &&\n seaRoll(this.seed + 7, wx, wz) < 0.06\n ) {\n const top = Math.min(\n SEA - 1,\n h + 2 + Math.floor(seaRoll(this.seed + 9, wx, wz) * (SEA - h - 2)),\n );\n for (let y = h + 1; y < top; y++)\n chunk.setRaw(x, y, z, B.kelp_plant!, water);\n chunk.setRaw(x, top, z, B.kelp!, water);\n return;\n }\n if (seaRoll(this.seed, wx, wz) < 0.15)\n chunk.setRaw(x, h + 1, z, B.seagrass!, water);\n }\n\n /** Затонувший корабль региона (rx, rz) или null (worldgen/features/shipwreck). */\n wreckIn(rx: number, rz: number): Wreck | null {\n const k = rx + \",\" + rz;\n if (!this.wrecks.has(k))\n this.wrecks.set(k, planWreck(this, this.seed, rx, rz));\n return this.wrecks.get(k)!;\n }\n\n /** Биом колонны (спавн по биомам). */\n biomeOf(x: number, z: number): string {\n return this.biomeAt(x, z, this.heightAt(x, z));\n }\n\n /** Добыча сундука затонувшего корабля или заставы мародёров. */\n lootAt(x: number, y: number, z: number): (ItemStack | null)[] | null {\n const k = `${x},${y},${z}`;\n const w = this.wreckIn(\n Math.floor(x / WRECK_REGION),\n Math.floor(z / WRECK_REGION),\n );\n const kind = w?.chests.get(k);\n if (kind) {\n const slots = wreckLoot(kind, this.seed, x, y, z);\n // сундук с картами — карта сокровищ к ближайшему зарытому кладу\n const t = kind === \"map\" ? this.locate(\"buried_treasure\", x, z) : null;\n if (t)\n slots[13] = { item: \"treasure_map\", count: 1, tag: { x: t.x, z: t.z } };\n return slots;\n }\n const tr = this.treasureIn(\n Math.floor(x / TREASURE_REGION),\n Math.floor(z / TREASURE_REGION),\n );\n if (tr && tr.x === x && tr.y === y && tr.z === z)\n return treasureLoot(this.seed, x, y, z);\n const ma = this.mansionIn(\n Math.floor(x / MANSION_REGION),\n Math.floor(z / MANSION_REGION),\n );\n if (ma?.chests.has(k)) return mansionLoot(this.seed, x, y, z);\n const ac = this.ancientCityIn(\n Math.floor(x / ANCIENT_REGION),\n Math.floor(z / ANCIENT_REGION),\n );\n if (ac?.chests.has(k)) return ancientLoot(this.seed, x, y, z);\n // находки подозрительного песка и гравия (кисть — world/behaviours/archaeology)\n if (\n this.wellIn(\n Math.floor(x / WELL_REGION),\n Math.floor(z / WELL_REGION),\n )?.sus.has(k)\n )\n return archaeologyLoot(\"well\", this.seed, x, y, z);\n if (\n this.ruinsIn(\n Math.floor(x / RUINS_REGION),\n Math.floor(z / RUINS_REGION),\n )?.sus.has(k)\n )\n return archaeologyLoot(\"ruins\", this.seed, x, y, z);\n // хранилище испытательных камер (открывается ключом — world/behaviours/trial)\n if (\n this.trialIn(\n Math.floor(x / TRIAL_REGION),\n Math.floor(z / TRIAL_REGION),\n )?.vaults.has(k)\n )\n return vaultLootAt(this.seed, x, y, z);\n // сундук припасов заброшенной шахты\n if (\n this.mineshaftIn(\n Math.floor(x / MINESHAFT_REGION),\n Math.floor(z / MINESHAFT_REGION),\n )?.chests.has(k)\n )\n return mineshaftLoot(this.seed, x, y, z);\n const o = this.outpostIn(\n Math.floor(x / OUTPOST_REGION),\n Math.floor(z / OUTPOST_REGION),\n );\n return o?.chest === k ? outpostLoot(this.seed, x, y, z) : null;\n }\n\n /** Пещеры: «спагетти» (пересечение нулей двух шумов) и «сыр» (большие полости) ниже 50. */\n private carveCaves(chunk: Chunk, heights: Int16Array): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const A = new Float32Array(GX * GY * GZ),\n Bf = new Float32Array(GX * GY * GZ),\n C = new Float32Array(GX * GY * GZ);\n const gi = (gx: number, gy: number, gz: number) =>\n gx + gz * GX + gy * GX * GZ;\n for (let gy = 0; gy < GY; gy++)\n for (let gz = 0; gz < GZ; gz++)\n for (let gx = 0; gx < GX; gx++) {\n const x = wx0 + gx * 4,\n y = gy * 8,\n z = wz0 + gz * 4;\n const k = gi(gx, gy, gz);\n A[k] = this.caveA.noise(x / 48, y / 32, z / 48);\n Bf[k] = this.caveB.noise(x / 48, y / 32, z / 48);\n C[k] = this.cheese.noise(x / 64, y / 40, z / 64);\n }\n const lerp = (f: Float32Array, x: number, y: number, z: number): number => {\n const gx = x >> 2,\n gy = y >> 3,\n gz = z >> 2;\n const fx = (x & 3) / 4,\n fy = (y & 7) / 8,\n fz = (z & 3) / 4;\n const c = (dx: number, dy: number, dz: number) =>\n f[gi(gx + dx, gy + dy, gz + dz)]!;\n const x00 = c(0, 0, 0) + (c(1, 0, 0) - c(0, 0, 0)) * fx;\n const x10 = c(0, 1, 0) + (c(1, 1, 0) - c(0, 1, 0)) * fx;\n const x01 = c(0, 0, 1) + (c(1, 0, 1) - c(0, 0, 1)) * fx;\n const x11 = c(0, 1, 1) + (c(1, 1, 1) - c(0, 1, 1)) * fx;\n const y0 = x00 + (x10 - x00) * fy,\n y1 = x01 + (x11 - x01) * fy;\n return y0 + (y1 - y0) * fz;\n };\n const lavaMeta = packWater(8, true);\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const h = heights[x + z * SX]!;\n // под водой и у берега пещеры держатся глубже: иначе море провалилось бы в них\n const ceil = h < SEA + 2 ? h - 8 : h - 1;\n for (let y = 4; y <= Math.min(ceil, SY - 9); y++) {\n const a = lerp(A, x, y, z),\n b = lerp(Bf, x, y, z);\n const tunnel = a * a + b * b < 0.0045;\n const cave = y < 50 && lerp(C, x, y, z) > 0.58;\n if (!tunnel && !cave) continue;\n const cur = chunk.blocks[idx(x, y, z)]!;\n if (cur === B.bedrock || cur === B.water) continue;\n if (y <= LAVA_LEVEL) chunk.setRaw(x, y, z, B.lava!, lavaMeta);\n else chunk.setRaw(x, y, z, B.air!);\n }\n }\n }\n\n /**\n * Аметистовая жеода (как в Minecraft — примерно в 1 чанке из 24): шар в толще камня y 12..48 —\n * снаружи гладкий базальт, под ним кальцит, внутри слой аметиста (8% — растущий) и пустота с почками\n * и друзами на стенках. Целиком внутри чанка — генерация чанков остаётся независимой. Свой поток\n * случайных чисел — прежние проходы не сдвигаются.\n */\n private geode(chunk: Chunk, heights: Int16Array): void {\n const rng = mulberry32(\n hashString(`ow-geode:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n if (rng() > 1 / 24) return;\n let low = SY;\n for (const h of heights) low = Math.min(low, h);\n const top = Math.min(48, low - 10);\n if (top < 14) return;\n const R = 3.5 + rng(); // радиус слоя аметиста\n const cx = 7 + Math.floor(rng() * 2),\n cz = 7 + Math.floor(rng() * 2),\n cy = 12 + Math.floor(rng() * (top - 12));\n const k = Math.ceil(R + 2);\n const budding: number[] = [];\n for (let dy = -k; dy <= k; dy++)\n for (let dz = -k; dz <= k; dz++)\n for (let dx = -k; dx <= k; dx++) {\n const x = cx + dx,\n y = cy + dy,\n z = cz + dz;\n if (x < 0 || x >= SX || z < 0 || z >= SZ || y < 1) continue;\n if (chunk.get(x, y, z) === B.bedrock) continue;\n const d =\n Math.sqrt(dx * dx + dy * dy * 1.2 + dz * dz) + (rng() - 0.5) * 0.3;\n if (d > R + 2) continue;\n let id: number;\n if (d <= R - 1) id = B.air!;\n else if (d <= R) {\n id = rng() < 0.08 ? B.budding_amethyst! : B.amethyst_block!;\n if (id === B.budding_amethyst) budding.push(x, y, z);\n } else if (d <= R + 1) id = B.calcite!;\n else id = B.smooth_basalt!;\n chunk.setRaw(x, y, z, id);\n }\n // почки и друзы на внутренних гранях растущего аметиста\n const buds = [\n B.small_amethyst_bud!,\n B.medium_amethyst_bud!,\n B.large_amethyst_bud!,\n B.amethyst_cluster!,\n ];\n for (let i = 0; i < budding.length; i += 3)\n for (const [dx, dy, dz] of FACES6) {\n const x = budding[i]! + dx,\n y = budding[i + 1]! + dy,\n z = budding[i + 2]! + dz;\n if (x < 0 || x >= SX || z < 0 || z >= SZ) continue;\n if (chunk.get(x, y, z) === B.air && rng() < 0.4)\n chunk.setRaw(x, y, z, buds[Math.floor(rng() * 4)]!);\n }\n }\n\n /**\n * Пышные пещеры: под пятнами шума lush (под сушей) пол пещер зарастает мхом с коврами, азалиями и\n * травой, в полу — озерца с глиной на дне (там живут аксолотли), с потолка свисают пещерные лозы\n * (11% — с ягодами, они светятся) и спороцвет, часть потолка — тоже мох.\n */\n private lushCaves(chunk: Chunk, heights: Int16Array): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const rng = mulberry32(\n hashString(`ow-lush:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const rock = (id: number): boolean =>\n id === B.stone ||\n id === B.deepslate ||\n id === B.dirt ||\n id === B.gravel ||\n id === B.moss_block ||\n id === B.clay;\n const water = packWater(8, true);\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n if (this.lush.fbm((wx0 + x) * 0.008, (wz0 + z) * 0.008, 2) < 0.3)\n continue;\n const h = heights[x + z * SX]!;\n if (h < SEA + 2) continue; // не под морем\n for (let y = LAVA_LEVEL + 3; y < h - 6; y++) {\n if (chunk.get(x, y, z) !== B.air) continue;\n if (rock(chunk.get(x, y - 1, z))) {\n // озерцо: клетка пола, со всех сторон и снизу — камень\n const inside = x > 0 && x < SX - 1 && z > 0 && z < SZ - 1;\n const sealed =\n inside &&\n rock(chunk.get(x, y - 2, z)) &&\n FACES6.every(([dx, dy, dz]) => {\n if (dy !== 0) return true;\n const n = chunk.get(x + dx, y - 1, z + dz);\n return rock(n) || n === B.water;\n });\n if (sealed && rng() < 0.18) {\n chunk.setRaw(x, y - 1, z, B.water!, water);\n chunk.setRaw(x, y - 2, z, B.clay!);\n continue;\n }\n chunk.setRaw(x, y - 1, z, B.moss_block!);\n const r = rng();\n if (r < 0.25) chunk.setRaw(x, y, z, B.moss_carpet!);\n else if (r < 0.3) chunk.setRaw(x, y, z, B.azalea!);\n else if (r < 0.33) chunk.setRaw(x, y, z, B.flowering_azalea!);\n else if (r < 0.45) chunk.setRaw(x, y, z, B.tallgrass!);\n }\n if (chunk.get(x, y, z) === B.air && rock(chunk.get(x, y + 1, z))) {\n const r = rng();\n if (r < 0.12) {\n const n = 1 + Math.floor(rng() * 4);\n for (\n let i = 0;\n i < n &&\n chunk.get(x, y - i, z) === B.air &&\n chunk.get(x, y - i - 1, z) === B.air;\n i++\n )\n chunk.setRaw(\n x,\n y - i,\n z,\n rng() < 0.11 ? B.cave_vines_lit! : B.cave_vines!,\n );\n } else if (r < 0.125) chunk.setRaw(x, y, z, B.spore_blossom!);\n else if (r < 0.4) chunk.setRaw(x, y + 1, z, B.moss_block!);\n }\n }\n }\n }\n\n /**\n * Капельниковые пещеры: где шум drip (и не пышные пещеры, не глубокая тьма) — пол и свод пещер\n * местами из капельникового блока, со свода свисают сталактиты до 4 блоков, с пола растут сталагмиты\n * до 3. Свой поток случайных чисел — прежние проходы не сдвигаются.\n */\n private dripstoneCaves(chunk: Chunk, heights: Int16Array): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const rng = mulberry32(\n hashString(`ow-drip:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const rock = (id: number): boolean =>\n id === B.stone ||\n id === B.deepslate ||\n id === B.gravel ||\n id === B.dripstone_block;\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const wx = wx0 + x,\n wz = wz0 + z;\n if (\n this.drip.fbm(wx * 0.007, wz * 0.007, 2) < 0.3 ||\n this.isDeep(wx, wz)\n )\n continue;\n if (this.lush.fbm(wx * 0.008, wz * 0.008, 2) >= 0.3) continue;\n const h = heights[x + z * SX]!;\n for (let y = LAVA_LEVEL + 2; y < h - 8; y++) {\n if (chunk.get(x, y, z) !== B.air) continue;\n if (rock(chunk.get(x, y - 1, z)) && rng() < 0.6) {\n chunk.setRaw(x, y - 1, z, B.dripstone_block!);\n if (rng() < 0.08) {\n const n = 1 + Math.floor(rng() * 3);\n for (\n let i = 0;\n i < n &&\n chunk.get(x, y + i, z) === B.air &&\n chunk.get(x, y + i + 1, z) === B.air;\n i++\n )\n chunk.setRaw(x, y + i, z, B.pointed_dripstone!);\n }\n }\n if (\n chunk.get(x, y, z) === B.air &&\n rock(chunk.get(x, y + 1, z)) &&\n rng() < 0.6\n ) {\n chunk.setRaw(x, y + 1, z, B.dripstone_block!);\n if (rng() < 0.12) {\n const n = 1 + Math.floor(rng() * 4);\n for (\n let i = 0;\n i < n &&\n chunk.get(x, y - i, z) === B.air &&\n chunk.get(x, y - i - 1, z) === B.air;\n i++\n )\n chunk.setRaw(x, y - i, z, B.pointed_dripstone_down!);\n }\n }\n }\n }\n }\n\n /**\n * Глубокая тьма: где шум deep (и не пышные пещеры) — пещеры ниже y 34 обрастают скалком: пол и часть\n * свода, прожилки, сенсоры, крикуны (умеют будить Стража) и изредка катализатор. Свой поток\n * случайных чисел — прежние проходы не сдвигаются.\n */\n private deepDark(chunk: Chunk, heights: Int16Array): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const rng = mulberry32(\n hashString(`ow-deep:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const rock = (id: number): boolean =>\n id === B.stone ||\n id === B.deepslate ||\n id === B.gravel ||\n id === B.dirt ||\n id === B.sculk;\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n if (!this.isDeep(wx0 + x, wz0 + z)) continue;\n const top = Math.min(34, heights[x + z * SX]! - 12);\n for (let y = LAVA_LEVEL + 1; y <= top; y++) {\n if (chunk.get(x, y, z) !== B.air) continue;\n if (rock(chunk.get(x, y - 1, z))) {\n const r = rng();\n if (r < 0.75)\n chunk.setRaw(\n x,\n y - 1,\n z,\n r < 0.004 ? B.sculk_catalyst! : B.sculk!,\n );\n const o = rng();\n if (o < 0.02) chunk.setRaw(x, y, z, B.sculk_sensor!);\n else if (o < 0.026)\n chunk.setRaw(x, y, z, B.sculk_shrieker!, CAN_SUMMON);\n else if (o < 0.16) chunk.setRaw(x, y, z, B.sculk_vein!);\n }\n if (\n chunk.get(x, y, z) === B.air &&\n rock(chunk.get(x, y + 1, z)) &&\n rng() < 0.35\n )\n chunk.setRaw(x, y + 1, z, B.sculk!);\n }\n }\n }\n\n /** Жилы руд по глубине — только по камню и сланцу и внутри чанка. */\n private ores(\n chunk: Chunk,\n rng: () => number,\n biomes: OverworldBiome[],\n ): void {\n const rock = (id: number) => id === B.stone || id === B.deepslate;\n for (const [name, veins, size, minY, maxY] of ORES) {\n const id = B[name];\n if (id === undefined) continue;\n for (let v = 0; v < veins; v++) {\n let x = (rng() * SX) | 0,\n y = (minY + rng() * (maxY - minY)) | 0,\n z = (rng() * SZ) | 0;\n for (let i = 0; i < size; i++) {\n if (\n x >= 0 &&\n x < SX &&\n y >= 1 &&\n y < SY &&\n z >= 0 &&\n z < SZ &&\n rock(chunk.get(x, y, z))\n )\n chunk.setRaw(x, y, z, id);\n x += ((rng() * 3) | 0) - 1;\n y += ((rng() * 3) | 0) - 1;\n z += ((rng() * 3) | 0) - 1;\n }\n }\n }\n // изумруды — одиночными блоками и только в горах, как в Minecraft\n const emerald = B.emerald_ore;\n if (emerald !== undefined && biomes.includes(\"mountains\"))\n for (let k = 0; k < 3; k++) {\n const x = (rng() * SX) | 0,\n y = (60 + rng() * 50) | 0,\n z = (rng() * SZ) | 0;\n if (chunk.get(x, y, z) === B.stone) chunk.setRaw(x, y, z, emerald);\n }\n }\n\n private vegetation(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n biomes: OverworldBiome[],\n ): void {\n const m = TREE_MAX_RADIUS; // крона не выходит за чанк — генерация чанков независима\n for (let t = 0; t < 10; t++) {\n const x = m + ((rng() * (SX - 2 * m)) | 0),\n z = m + ((rng() * (SZ - 2 * m)) | 0);\n const i = x + z * SX;\n const def = BIOMES[biomes[i]!];\n const h = heights[i]!;\n if (rng() > def.trees * 0.6 || def.tree === \"none\") continue;\n // мангр растёт и на мелководье: на грязи под водой до 3 блоков\n const mangrove = def.tree === \"mangrove\";\n if ((mangrove ? h < SEA - 3 : h <= SEA) || h + TREE_MAX_HEIGHT >= SY)\n continue;\n const ground = chunk.get(x, h, z);\n if (\n mangrove\n ? ground !== B.mud\n : def.tree === \"dead\"\n ? ground !== B.sand\n : ground !== B.grass && ground !== B.snow\n )\n continue;\n const above = chunk.get(x, h + 1, z);\n if (above !== B.air && !(mangrove && above === B.water)) continue;\n // в лесу каждое пятое дерево — тёмный дуб\n const kind =\n biomes[i] === \"forest\" && rng() < 0.2 ? \"dark_oak\" : def.tree;\n if (kind === \"cherry\" && (x < 3 || x > SX - 4 || z < 3 || z > SZ - 4))\n continue; // крона вишни шире\n growTree(kind, x, h, z, rng, (tx, ty, tz, id, soft) => {\n if (ty >= SY || tx < 0 || tx >= SX || tz < 0 || tz >= SZ) return;\n if (soft && chunk.get(tx, ty, tz) !== B.air) return;\n chunk.setRaw(tx, ty, tz, id);\n });\n // пчелиное гнездо на стволе у 5% дубов и берёз равнин и лесов (пчёл заведёт первый тик)\n const b = biomes[i];\n if (\n (kind === \"oak\" || kind === \"birch\") &&\n (b === \"plains\" || b === \"forest\" || b === \"birch_forest\") &&\n rng() < 0.05\n )\n if (z + 1 < SZ && h + 3 < SY)\n chunk.setRaw(x, h + 3, z + 1, B.bee_nest!, HIVE_SEEDED);\n }\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const i = x + z * SX;\n const h = heights[i]!,\n def = BIOMES[biomes[i]!];\n if (h <= SEA || h + 1 >= SY) continue;\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const r = rng();\n const g = def.grass * 0.25;\n if (r < g) chunk.setRaw(x, h + 1, z, B.tallgrass!);\n else if (r < g + def.flowers * 0.2)\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.flower_yellow! : B.flower_red!,\n );\n else if (biomes[i] === \"cherry_grove\" && r < g + 0.2)\n chunk.setRaw(x, h + 1, z, B.pink_petals!);\n }\n this.extras(chunk, heights, biomes);\n }\n\n /**\n * Тростник у воды, кактусы в пустыне, тыквы на равнинах и в лесу, арбузы в джунглях. Свой поток\n * случайных чисел — прежние проходы генерации не сдвигаются.\n */\n private extras(\n chunk: Chunk,\n heights: Int16Array,\n biomes: OverworldBiome[],\n ): void {\n const rng = mulberry32(\n hashString(`ow-extra:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const at = (x: number, z: number) => heights[x + z * SX]!;\n const column = (x: number, y: number, z: number, id: number) => {\n const n = 1 + Math.floor(rng() * 3);\n for (let k = 0; k < n && y + k < SY; k++) chunk.setRaw(x, y + k, z, id);\n };\n for (let t = 0; t < 20; t++) {\n const x = 1 + Math.floor(rng() * (SX - 2)),\n z = 1 + Math.floor(rng() * (SZ - 2));\n const h = at(x, z),\n ground = chunk.get(x, h, z);\n if (chunk.get(x, h + 1, z) !== B.air) continue;\n const biome = biomes[x + z * SX]!;\n const water = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ].some(([dx, dz]) => chunk.get(x + dx!, h, z + dz!) === B.water);\n if (\n water &&\n (ground === B.grass || ground === B.sand || ground === B.dirt)\n ) {\n column(x, h + 1, z, B.sugar_cane!);\n } else if (biome === \"desert\" && ground === B.sand && t % 4 === 0) {\n const free = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ].every(([dx, dz]) => chunk.get(x + dx!, h + 1, z + dz!) === B.air);\n if (free) column(x, h + 1, z, B.cactus!);\n }\n }\n // тыквы и арбузы — редкими грядками\n const patchBiome = biomes[8 + 8 * SX]!;\n const fruit =\n patchBiome === \"jungle\" && rng() < 0.3\n ? B.melon!\n : (patchBiome === \"plains\" || patchBiome === \"forest\") && rng() < 0.04\n ? B.pumpkin!\n : undefined;\n if (fruit !== undefined)\n for (let k = 0; k < 6; k++) {\n const x = 2 + Math.floor(rng() * (SX - 4)),\n z = 2 + Math.floor(rng() * (SZ - 4));\n const h = at(x, z);\n if (chunk.get(x, h, z) === B.grass && chunk.get(x, h + 1, z) === B.air)\n chunk.setRaw(x, h + 1, z, fruit);\n }\n // грибные поля: огромные грибы (шляпка целиком в чанке); там же, в тёмных лесах, болотах и тайге —\n // россыпь маленьких грибов\n const R = HUGE_MUSHROOM_RADIUS;\n for (let t = 0; t < 6; t++) {\n const x = R + Math.floor(rng() * (SX - 2 * R)),\n z = R + Math.floor(rng() * (SZ - 2 * R));\n const b = biomes[x + z * SX]!,\n h = at(x, z),\n ground = chunk.get(x, h, z);\n if (chunk.get(x, h + 1, z) !== B.air || h <= SEA) continue;\n if (\n b === \"mushroom_fields\" &&\n ground === B.mycelium &&\n t < 3 &&\n h + HUGE_MUSHROOM_HEIGHT < SY\n ) {\n growHugeMushroom(\n (tx, ty, tz, id, soft) => {\n if (!soft || chunk.get(tx, ty, tz) === B.air)\n chunk.setRaw(tx, ty, tz, id);\n },\n x,\n h + 1,\n z,\n rng() < 0.5,\n rng,\n );\n } else if (\n (b === \"mushroom_fields\" ||\n b === \"dark_forest\" ||\n b === \"swamp\" ||\n b === \"taiga\") &&\n (ground === B.grass || ground === B.mycelium) &&\n rng() < 0.4\n )\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.brown_mushroom! : B.red_mushroom!,\n );\n }\n // заросли бамбука — в каждом третьем чанке джунглей, стебли 4–11 блоков\n if (biomes[8 + 8 * SX] === \"jungle\" && rng() < 0.35)\n for (let k = 0; k < 12; k++) {\n const x = 1 + Math.floor(rng() * (SX - 2)),\n z = 1 + Math.floor(rng() * (SZ - 2));\n const h = at(x, z);\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const n = 4 + Math.floor(rng() * 8);\n for (\n let s = 1;\n s <= n && h + s < SY && chunk.get(x, h + s, z) === B.air;\n s++\n )\n chunk.setRaw(x, h + s, z, B.bamboo!);\n }\n // какао на стволах джунглей (2–5 блоков над землёй): стручки разного возраста, к стволу лицом\n for (let t = 0; t < 16; t++) {\n const x = 1 + Math.floor(rng() * (SX - 2)),\n z = 1 + Math.floor(rng() * (SZ - 2));\n if (biomes[x + z * SX] !== \"jungle\") continue;\n const h = at(x, z);\n for (let k = 2; k <= 5; k++) {\n const y = h + k;\n if (y >= SY || chunk.get(x, y, z) !== B.jungle_log) break;\n if (rng() > 0.3) continue;\n const f = Math.floor(rng() * 4);\n const [dx, dz] = FACING_DIRS[f]!;\n const cx = x - dx,\n cz = z - dz; // ствол — со стороны f от стручка\n if (\n cx < 0 ||\n cx >= SX ||\n cz < 0 ||\n cz >= SZ ||\n chunk.get(cx, y, cz) !== B.air\n )\n continue;\n chunk.setRaw(cx, y, cz, B.cocoa!, f | (Math.floor(rng() * 3) << 2));\n }\n }\n }\n\n /** Суша ближе всего к началу координат (не океан и не пляж). */\n spawnHint(): { x: number; z: number } {\n if (this.spawn) return this.spawn;\n for (let r = 0; r <= 1024; r += 8) {\n const steps = r === 0 ? 1 : Math.ceil((2 * Math.PI * r) / 8);\n for (let k = 0; k < steps; k++) {\n const a = (k / steps) * Math.PI * 2;\n const x = Math.round(Math.cos(a) * r),\n z = Math.round(Math.sin(a) * r);\n const h = this.heightAt(x, z);\n const b = this.biomeAt(x, z, h);\n if (h > SEA + 1 && !b.endsWith(\"ocean\") && b !== \"beach\" && h < 90)\n return (this.spawn = { x, z });\n }\n }\n return (this.spawn = { x: 0, z: 0 });\n }\n}\n"
|
|
1992
|
+
"content": "// Верхний мир поколения 2 — новые миры по сиду (сейв v3): высота 128, море на 62, материки и\n// океаны, горы до ~120, биомы по температуре и влажности, пещеры из 3D-шума с лавой на дне, руды по\n// глубине, деревья и травы по биомам. Старые миры по сиду остаются на TerrainGenerator: их вывод\n// зафиксирован сейвами v1/v2.\n//\n// ⚠ Вывод этого генератора станет частью формата сейва, как только миры gen 2 уйдут игрокам\n// (релиз). До релиза его можно менять вместе с отпечатком в тесте; после — только новой версией gen.\nimport { Simplex2, Simplex3, mulberry32, hashString } from \"../../core/Noise\";\nimport { B, packWater } from \"../../registry/Blocks\";\nimport { type Chunk } from \"../../world/Chunk\";\nimport type { WorldGenerator } from \"../WorldGenerator\";\nimport { type Citadel } from \"../features/citadel\";\nimport {\n VILLAGE_REGION,\n carveVillages,\n type Village,\n} from \"../features/village\";\nimport { TEMPLE_REGION, type Temple } from \"../features/temple\";\nimport { WRECK_REGION, type Wreck } from \"../features/shipwreck\";\nimport { OUTPOST_REGION, type Outpost } from \"../features/outpost\";\nimport { MINESHAFT_REGION, type Mineshaft } from \"../features/mineshaft\";\nimport { TREASURE_REGION, type Buried } from \"../features/treasure\";\nimport { MANSION_REGION, type Mansion } from \"../features/mansion\";\nimport { ANCIENT_REGION, type AncientCity } from \"../features/ancientCity\";\nimport { RUINS_REGION, WELL_REGION, type Dig } from \"../features/archaeology\";\nimport { TRIAL_REGION, type TrialChamber } from \"../features/trialChambers\";\nimport type { ItemStack } from \"../../types\";\nimport { BIOMES, type OverworldBiome } from \"./biomes\";\nimport { SEA } from \"./constants\";\nimport { SX, SY, SZ, clamp, smoothstep } from \"./math\";\nimport {\n carveCaves,\n geode,\n lushCaves,\n dripstoneCaves,\n deepDark,\n} from \"./generator/caves\";\nimport { ores, vegetation, extras, seabed } from \"./generator/decor\";\nimport {\n planCitadels,\n citadel,\n villageIn,\n templeIn,\n villagesNear,\n structureAt,\n locate,\n ancientCityIn,\n trialIn,\n trialSpawnersNear,\n wellIn,\n ruinsIn,\n mansionIn,\n treasureIn,\n mineshaftIn,\n outpostIn,\n wreckIn,\n lootAt,\n} from \"./generator/structures\";\n\nexport { SEA, CITADEL_Y } from \"./constants\";\nexport type { OverworldBiome } from \"./biomes\";\n\nexport class OverworldGenerator implements WorldGenerator {\n readonly seed: number;\n readonly waterLevel = SEA;\n private readonly cont: Simplex2;\n private readonly hills: Simplex2;\n private readonly detail: Simplex2;\n private readonly ridge: Simplex2;\n private readonly mtn: Simplex2;\n private readonly temp: Simplex2;\n private readonly hum: Simplex2;\n readonly patch: Simplex2;\n /** Пятна пышных пещер. */\n readonly lush: Simplex2;\n /** Пятна глубокой тьмы. */\n private readonly deep: Simplex2;\n /** Пятна капельниковых пещер. */\n readonly drip: Simplex2;\n readonly caveA: Simplex3;\n readonly caveB: Simplex3;\n readonly cheese: Simplex3;\n private spawn: { x: number; z: number } | null = null;\n /** Три цитадели на кольце 300–700 блоков от центра (в Minecraft — дальше; для браузера ближе). */\n readonly citadels: Citadel[];\n /** Деревни по регионам (VILLAGE_REGION): план считается при первом обращении. */\n readonly villages = new Map<string, Village | null>();\n /** Подводные храмы по регионам (TEMPLE_REGION). */\n readonly temples = new Map<string, Temple | null>();\n /** Затонувшие корабли по регионам (WRECK_REGION). */\n readonly wrecks = new Map<string, Wreck | null>();\n /** Заброшенные шахты по регионам (MINESHAFT_REGION). */\n readonly mineshafts = new Map<string, Mineshaft | null>();\n /** Заставы мародёров по регионам (OUTPOST_REGION). */\n readonly outposts = new Map<string, Outpost | null>();\n /** Зарытые клады по регионам (TREASURE_REGION). */\n readonly treasures = new Map<string, Buried | null>();\n /** Особняки по регионам (MANSION_REGION). */\n readonly mansions = new Map<string, Mansion | null>();\n /** Древние города по регионам (ANCIENT_REGION). */\n readonly ancientCities = new Map<string, AncientCity | null>();\n /** Пустынные колодцы (WELL_REGION) и засыпанные руины (RUINS_REGION) — места раскопок. */\n readonly wells = new Map<string, Dig | null>();\n readonly ruins = new Map<string, Dig | null>();\n /** Испытательные камеры по регионам (TRIAL_REGION). */\n readonly trials = new Map<string, TrialChamber | null>();\n\n constructor(seed: number) {\n this.seed = seed;\n const n2 = (k: string) => new Simplex2(hashString(`ow:${k}:${seed}`));\n const n3 = (k: string) => new Simplex3(hashString(`ow:${k}:${seed}`));\n this.cont = n2(\"continent\");\n this.hills = n2(\"hills\");\n this.detail = n2(\"detail\");\n this.ridge = n2(\"ridge\");\n this.mtn = n2(\"mountain\");\n this.temp = n2(\"temperature\");\n this.hum = n2(\"humidity\");\n this.patch = n2(\"patch\");\n this.lush = n2(\"lush\");\n this.deep = n2(\"deep\");\n this.drip = n2(\"drip\");\n this.caveA = n3(\"caveA\");\n this.caveB = n3(\"caveB\");\n this.cheese = n3(\"cheese\");\n this.citadels = this.planCitadels();\n }\n\n /** Три цитадели на кольце 300–700 блоков от центра (worldgen/features/citadel). */\n planCitadels(): Citadel[] {\n return planCitadels(this);\n }\n\n citadel(chunk: Chunk): void {\n citadel(this, chunk);\n }\n\n /** Деревня региона (rx, rz) или null (worldgen/features/village). */\n villageIn(rx: number, rz: number): Village | null {\n return villageIn(this, rx, rz);\n }\n\n /** Подводный храм региона (rx, rz) или null (worldgen/features/temple). */\n templeIn(rx: number, rz: number): Temple | null {\n return templeIn(this, rx, rz);\n }\n\n /** Деревни и подводные храмы с центром ближе r к (x, z) — Game селит в них жителей. */\n villagesNear(x: number, z: number, r: number): Village[] {\n return villagesNear(this, x, z, r);\n }\n\n structureAt(x: number, y: number, z: number): string | null {\n return structureAt(this, x, y, z);\n }\n\n locate(\n kind: string,\n x: number,\n z: number,\n ): { x: number; y: number; z: number } | null {\n return locate(this, kind, x, z);\n }\n\n /** Сила гор в точке (0..1). */\n private mountains(x: number, z: number): number {\n return smoothstep(\n 0.15,\n 0.55,\n this.mtn.fbm(x * 0.0022 + 31, z * 0.0022 - 17, 3),\n );\n }\n\n /** y верхнего блока земли — чистая функция координат (чанки сходятся без швов). */\n heightAt(x: number, z: number): number {\n const c = this.cont.fbm(x * 0.0016, z * 0.0016, 4);\n const hills = this.hills.fbm(x * 0.009, z * 0.009, 4);\n const detail = this.detail.noise(x * 0.05, z * 0.05);\n let h: number;\n if (c < -0.18) {\n h = SEA - 3 - Math.min(30, (-c - 0.18) * 60) + hills * 3; // океан: дно 30..59\n } else {\n const land = smoothstep(-0.18, 0.04, c); // побережье плавно выходит из воды\n const r = 1 - Math.abs(this.ridge.fbm(x * 0.004, z * 0.004, 4));\n h =\n SEA -\n 3 +\n land * (6 + hills * 9 + detail * 1.5) +\n land * this.mountains(x, z) * r * r * 58;\n }\n return clamp(Math.round(h), 4, SY - 10);\n }\n\n /** Биом колонны по высоте, температуре и влажности. */\n biomeAt(x: number, z: number, h: number): OverworldBiome {\n const t = this.temp.fbm(x * 0.0012, z * 0.0012, 3) * 1.6;\n if (h < SEA - 1) return t > 0.3 ? \"warm_ocean\" : \"ocean\"; // тёплый океан — коралловые рифы\n const w = this.hum.fbm(x * 0.0014 + 11, z * 0.0014 - 7, 3) * 1.6;\n if (h <= SEA + 1 && t > -0.3) return \"beach\";\n // грибные поля — редкими пятнами на невысокой суше\n if (\n h < SEA + 16 &&\n this.patch.noise(x * 0.0021 + 77, z * 0.0021 - 41) > 0.6\n )\n return \"mushroom_fields\";\n if (h > 92 && this.mountains(x, z) > 0.4) return \"mountains\";\n if (t < -0.3) return w > 0 ? \"taiga\" : \"snowy\";\n if (t > 0.3) {\n if (w < -0.1) return \"desert\";\n if (w > 0.25) return \"jungle\";\n return \"savanna\";\n }\n if (w > 0.3 && h < SEA + 6) return t > 0.15 ? \"mangrove_swamp\" : \"swamp\"; // тёплое болото — мангровое\n if (w > 0.18 && t > 0.05) return \"dark_forest\"; // тёплый и влажный лес\n // вишнёвые рощи — пятнами на умеренных холмах\n if (\n h > SEA + 14 &&\n w > -0.05 &&\n t > -0.12 &&\n t < 0.2 &&\n this.patch.noise(x * 0.004, z * 0.004) > 0.3\n )\n return \"cherry_grove\";\n if (w > 0.1) return t < 0 ? \"birch_forest\" : \"forest\";\n if (w > -0.05) return \"forest\";\n return \"plains\";\n }\n\n generate(chunk: Chunk): void {\n const rng = mulberry32(\n hashString(`ow-chunk:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n\n // высоты с каймой в блок — крутизна склона на краях чанка\n const G = SX + 2;\n const grid = new Int16Array(G * (SZ + 2));\n for (let z = -1; z <= SZ; z++)\n for (let x = -1; x <= SX; x++)\n grid[x + 1 + (z + 1) * G] = this.heightAt(wx0 + x, wz0 + z);\n\n const heights = new Int16Array(SX * SZ);\n const biomes: OverworldBiome[] = new Array<OverworldBiome>(SX * SZ);\n const water = packWater(8, true);\n\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const at = (dx: number, dz: number) =>\n grid[x + 1 + dx + (z + 1 + dz) * G]!;\n const h = at(0, 0);\n const slope = Math.max(\n Math.abs(h - at(1, 0)),\n Math.abs(h - at(-1, 0)),\n Math.abs(h - at(0, 1)),\n Math.abs(h - at(0, -1)),\n );\n const wx = wx0 + x,\n wz = wz0 + z;\n const biome = this.biomeAt(wx, wz, h);\n const def = BIOMES[biome];\n let surface = B[def.surface]!,\n under = B[def.under]!;\n if (biome === \"ocean\") {\n const n = this.patch.noise(wx * 0.08, wz * 0.08);\n surface = under =\n n > 0.35 ? B.gravel! : n < -0.45 ? B.clay! : B.sand!;\n } else if (h > 104)\n surface = B.snow!; // снежные шапки\n else if (slope >= 3 && surface === B.grass) {\n surface = B.stone!; // обрывы — голый камень\n under = B.stone!;\n } else if (biome === \"mountains\" && h > 96) surface = under = B.stone!;\n\n for (let y = 0; y <= h; y++) {\n let id: number;\n if (y === 0) id = B.bedrock!;\n else if (y < 4 && rng() < 0.6 - y * 0.15)\n id = B.bedrock!; // рваное дно\n else if (y === h) id = surface;\n else if (y >= h - 3) id = under;\n // глубинный сланец внизу, с рваной границей y 16..19 (как в Minecraft 1.18)\n else if (y < 16 || (y < 20 && rng() < (20 - y) / 5))\n id = B.deepslate!;\n else id = B.stone!;\n chunk.setRaw(x, y, z, id);\n }\n // снег-порох: пятнами в снегах и на снежных вершинах — сугроб в 2 блока вместо снега\n if (\n surface === B.snow &&\n (biome === \"snowy\" || biome === \"mountains\") &&\n this.patch.noise(wx * 0.09 + 50, wz * 0.09 - 30) > 0.45\n ) {\n chunk.setRaw(x, h, z, B.powder_snow!);\n chunk.setRaw(x, h - 1, z, B.powder_snow!);\n }\n for (let y = h + 1; y <= SEA; y++)\n chunk.setRaw(\n x,\n y,\n z,\n y === SEA && (biome === \"snowy\" || biome === \"taiga\")\n ? B.ice!\n : B.water!,\n y === SEA && (biome === \"snowy\" || biome === \"taiga\") ? 0 : water,\n );\n if (biome === \"ocean\" || biome === \"warm_ocean\")\n this.seabed(chunk, x, z, wx, wz, h, biome, water);\n const i = x + z * SX;\n heights[i] = h;\n biomes[i] = biome;\n }\n\n this.carveCaves(chunk, heights);\n this.ores(chunk, rng, biomes);\n this.geode(chunk, heights);\n this.lushCaves(chunk, heights);\n this.deepDark(chunk, heights);\n this.dripstoneCaves(chunk, heights);\n this.vegetation(chunk, rng, heights, biomes);\n this.citadel(chunk);\n const v = this.villageIn(\n Math.floor(wx0 / VILLAGE_REGION),\n Math.floor(wz0 / VILLAGE_REGION),\n );\n if (v) carveVillages(chunk, [v]);\n const t = this.templeIn(\n Math.floor(wx0 / TEMPLE_REGION),\n Math.floor(wz0 / TEMPLE_REGION),\n );\n if (t) carveVillages(chunk, [t]);\n const wr = this.wreckIn(\n Math.floor(wx0 / WRECK_REGION),\n Math.floor(wz0 / WRECK_REGION),\n );\n if (wr) carveVillages(chunk, [wr]);\n const ms = this.mineshaftIn(\n Math.floor(wx0 / MINESHAFT_REGION),\n Math.floor(wz0 / MINESHAFT_REGION),\n );\n if (ms) carveVillages(chunk, [ms]);\n const op = this.outpostIn(\n Math.floor(wx0 / OUTPOST_REGION),\n Math.floor(wz0 / OUTPOST_REGION),\n );\n if (op) carveVillages(chunk, [op]);\n const tr = this.treasureIn(\n Math.floor(wx0 / TREASURE_REGION),\n Math.floor(wz0 / TREASURE_REGION),\n );\n if (tr) carveVillages(chunk, [tr]);\n const ma = this.mansionIn(\n Math.floor(wx0 / MANSION_REGION),\n Math.floor(wz0 / MANSION_REGION),\n );\n if (ma) carveVillages(chunk, [ma]);\n const ac = this.ancientCityIn(\n Math.floor(wx0 / ANCIENT_REGION),\n Math.floor(wz0 / ANCIENT_REGION),\n );\n if (ac) carveVillages(chunk, [ac]);\n const wl = this.wellIn(\n Math.floor(wx0 / WELL_REGION),\n Math.floor(wz0 / WELL_REGION),\n );\n if (wl) carveVillages(chunk, [wl]);\n const ru = this.ruinsIn(\n Math.floor(wx0 / RUINS_REGION),\n Math.floor(wz0 / RUINS_REGION),\n );\n if (ru) carveVillages(chunk, [ru]);\n const tc = this.trialIn(\n Math.floor(wx0 / TRIAL_REGION),\n Math.floor(wz0 / TRIAL_REGION),\n );\n if (tc) carveVillages(chunk, [tc]);\n chunk.generated = true;\n }\n\n /** Древний город региона или null (worldgen/features/ancientCity) — только под глубокой тьмой. */\n ancientCityIn(rx: number, rz: number): AncientCity | null {\n return ancientCityIn(this, rx, rz);\n }\n\n /** Испытательные камеры региона или null (worldgen/features/trialChambers). */\n trialIn(rx: number, rz: number): TrialChamber | null {\n return trialIn(this, rx, rz);\n }\n\n /** Спавнеры испытаний ближе r к (x, z). */\n trialSpawnersNear(\n x: number,\n z: number,\n r: number,\n ): [number, number, number][] {\n return trialSpawnersNear(this, x, z, r);\n }\n\n /** Пустынный колодец региона или null (worldgen/features/archaeology). */\n wellIn(rx: number, rz: number): Dig | null {\n return wellIn(this, rx, rz);\n }\n\n /** Засыпанные руины региона или null (worldgen/features/archaeology). */\n ruinsIn(rx: number, rz: number): Dig | null {\n return ruinsIn(this, rx, rz);\n }\n\n /** Глубокая тьма в колонне (и не пышные пещеры). */\n isDeep(x: number, z: number): boolean {\n return (\n this.deep.fbm(x * 0.006, z * 0.006, 2) > 0.25 &&\n this.lush.fbm(x * 0.008, z * 0.008, 2) < 0.3\n );\n }\n\n /** Особняк региона или null (worldgen/features/mansion). */\n mansionIn(rx: number, rz: number): Mansion | null {\n return mansionIn(this, rx, rz);\n }\n\n /** Зарытый клад региона или null (worldgen/features/treasure). */\n treasureIn(rx: number, rz: number): Buried | null {\n return treasureIn(this, rx, rz);\n }\n\n /** Заброшенная шахта региона или null (worldgen/features/mineshaft). */\n mineshaftIn(rx: number, rz: number): Mineshaft | null {\n return mineshaftIn(this, rx, rz);\n }\n\n /** Застава мародёров региона или null; рядом с деревней (ближе 100) заставы не бывает. */\n outpostIn(rx: number, rz: number): Outpost | null {\n return outpostIn(this, rx, rz);\n }\n\n /**\n * Дно океана: в тёплом — коралловые рифы пятнами (блоки одного цвета гнёздами 4×4, на них кораллы и\n * морские огурцы), в обычном — столбы ламинарии до поверхности; морская трава — в обоих.\n */\n seabed(\n chunk: Chunk,\n x: number,\n z: number,\n wx: number,\n wz: number,\n h: number,\n biome: OverworldBiome,\n water: number,\n ): void {\n seabed(this, chunk, x, z, wx, wz, h, biome, water);\n }\n\n /** Затонувший корабль региона (rx, rz) или null (worldgen/features/shipwreck). */\n wreckIn(rx: number, rz: number): Wreck | null {\n return wreckIn(this, rx, rz);\n }\n\n /** Биом колонны (спавн по биомам). */\n biomeOf(x: number, z: number): string {\n return this.biomeAt(x, z, this.heightAt(x, z));\n }\n\n /** Добыча сундука затонувшего корабля или заставы мародёров. */\n lootAt(x: number, y: number, z: number): (ItemStack | null)[] | null {\n return lootAt(this, x, y, z);\n }\n\n /** Пещеры: «спагетти» (пересечение нулей двух шумов) и «сыр» (большие полости) ниже 50. */\n carveCaves(chunk: Chunk, heights: Int16Array): void {\n carveCaves(this, chunk, heights);\n }\n\n /**\n * Аметистовая жеода (как в Minecraft — примерно в 1 чанке из 24): шар в толще камня y 12..48 —\n * снаружи гладкий базальт, под ним кальцит, внутри слой аметиста (8% — растущий) и пустота с почками\n * и друзами на стенках. Целиком внутри чанка — генерация чанков остаётся независимой. Свой поток\n * случайных чисел — прежние проходы не сдвигаются.\n */\n geode(chunk: Chunk, heights: Int16Array): void {\n geode(this, chunk, heights);\n }\n\n /**\n * Пышные пещеры: под пятнами шума lush (под сушей) пол пещер зарастает мхом с коврами, азалиями и\n * травой, в полу — озерца с глиной на дне (там живут аксолотли), с потолка свисают пещерные лозы\n * (11% — с ягодами, они светятся) и спороцвет, часть потолка — тоже мох.\n */\n lushCaves(chunk: Chunk, heights: Int16Array): void {\n lushCaves(this, chunk, heights);\n }\n\n /**\n * Капельниковые пещеры: где шум drip (и не пышные пещеры, не глубокая тьма) — пол и свод пещер\n * местами из капельникового блока, со свода свисают сталактиты до 4 блоков, с пола растут сталагмиты\n * до 3. Свой поток случайных чисел — прежние проходы не сдвигаются.\n */\n dripstoneCaves(chunk: Chunk, heights: Int16Array): void {\n dripstoneCaves(this, chunk, heights);\n }\n\n /**\n * Глубокая тьма: где шум deep (и не пышные пещеры) — пещеры ниже y 34 обрастают скалком: пол и часть\n * свода, прожилки, сенсоры, крикуны (умеют будить Стража) и изредка катализатор. Свой поток\n * случайных чисел — прежние проходы не сдвигаются.\n */\n deepDark(chunk: Chunk, heights: Int16Array): void {\n deepDark(this, chunk, heights);\n }\n\n /** Жилы руд по глубине — только по камню и сланцу и внутри чанка. */\n ores(chunk: Chunk, rng: () => number, biomes: OverworldBiome[]): void {\n ores(this, chunk, rng, biomes);\n }\n\n vegetation(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n biomes: OverworldBiome[],\n ): void {\n vegetation(this, chunk, rng, heights, biomes);\n }\n\n /**\n * Тростник у воды, кактусы в пустыне, тыквы на равнинах и в лесу, арбузы в джунглях. Свой поток\n * случайных чисел — прежние проходы генерации не сдвигаются.\n */\n extras(chunk: Chunk, heights: Int16Array, biomes: OverworldBiome[]): void {\n extras(this, chunk, heights, biomes);\n }\n\n /** Суша ближе всего к началу координат (не океан и не пляж). */\n spawnHint(): { x: number; z: number } {\n if (this.spawn) return this.spawn;\n for (let r = 0; r <= 1024; r += 8) {\n const steps = r === 0 ? 1 : Math.ceil((2 * Math.PI * r) / 8);\n for (let k = 0; k < steps; k++) {\n const a = (k / steps) * Math.PI * 2;\n const x = Math.round(Math.cos(a) * r),\n z = Math.round(Math.sin(a) * r);\n const h = this.heightAt(x, z);\n const b = this.biomeAt(x, z, h);\n if (h > SEA + 1 && !b.endsWith(\"ocean\") && b !== \"beach\" && h < 90)\n return (this.spawn = { x, z });\n }\n }\n return (this.spawn = { x: 0, z: 0 });\n }\n}\n"
|
|
1741
1993
|
},
|
|
1742
1994
|
{
|
|
1743
1995
|
"path": "worldgen/preview.ts",
|
|
@@ -1777,7 +2029,7 @@
|
|
|
1777
2029
|
},
|
|
1778
2030
|
{
|
|
1779
2031
|
"path": "module.meta.json",
|
|
1780
|
-
"content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The 'Create world' screen turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby shows 'With friends' next to the game's 'Play', and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Not included yet: AI-driven villagers and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.4.
|
|
2032
|
+
"content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The 'Create world' screen turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby shows 'With friends' next to the game's 'Play', and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Not included yet: AI-driven villagers and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.4.2\"\n}\n"
|
|
1781
2033
|
}
|
|
1782
2034
|
]
|
|
1783
2035
|
}
|