@idosgames/mcp 0.1.16 → 0.1.18
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 +19 -7
- package/registry/index.json +731 -33
- package/registry/modules/board-game.json +4 -4
- package/registry/modules/character-ui.json +78 -0
- package/registry/modules/chat-ui.json +74 -0
- package/registry/modules/craft-ui.json +74 -0
- package/registry/modules/deal-offer-ui.json +73 -0
- package/registry/modules/events-ui.json +80 -0
- package/registry/modules/friends-ui.json +73 -0
- package/registry/modules/game-hud.json +15 -10
- package/registry/modules/idle-rpg.json +4 -4
- package/registry/modules/inventory-ui.json +73 -0
- package/registry/modules/leaderboard-ui.json +75 -0
- package/registry/modules/marketplace-ui.json +75 -0
- package/registry/modules/profile-ui.json +74 -0
- package/registry/modules/pvp-ui.json +72 -0
- package/registry/modules/referral-ui.json +73 -0
- package/registry/modules/rewards-ui.json +75 -0
- package/registry/modules/season-ui.json +79 -0
- package/registry/modules/showcase.json +92 -0
- package/registry/modules/store-ui.json +75 -0
- package/registry/modules/tutorial-ui.json +71 -0
- package/registry/modules/voxelcraft.json +98 -41
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/blockchain-system.json +1 -1
- package/registry/skills/idosgames-module-contract.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +6 -0
- package/registry/skills/leaderboard-system.json +1 -1
- package/registry/skills/multiplayer-realtime.json +3 -3
- package/registry/skills/referral-system.json +2 -2
- package/registry/skills/timed-event-system.json +1 -1
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"catalog": {
|
|
10
10
|
"type": "template",
|
|
11
11
|
"summary": "Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).",
|
|
12
|
-
"description": "A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The 'Create world' screen turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \"voxel-world\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Not included yet:
|
|
12
|
+
"description": "A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The 'Create world' screen turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \"voxel-world\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players, like Minecraft's 'Open to LAN': a player opens their world to the network (by invite code or in the public list, guest game mode, 'commands for everyone' switch), others join from the 'Network game' screen; the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Not included yet: AI-driven villagers and a dedicated server.",
|
|
13
13
|
"provides": [
|
|
14
14
|
"first-person voxel world: mine, place and break blocks",
|
|
15
15
|
"survival and creative modes (health, hunger, air, XP, death and respawn)",
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
"AI world generation via client.ai (feature \"voxel-world\")",
|
|
30
30
|
"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)",
|
|
31
31
|
"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)",
|
|
32
|
+
"co-op multiplayer up to 8 players (P2P over WebRTC, @idosgames/core/realtime)",
|
|
33
|
+
"open the world to the network: invite code or public list, guest game mode, operators, kick and ban",
|
|
34
|
+
"players in different dimensions at once; host migration without stopping the world",
|
|
32
35
|
"autosave and 'My worlds' (IndexedDB)",
|
|
33
36
|
"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)",
|
|
34
37
|
"3D block rendering (Three.js)"
|
|
@@ -46,7 +49,9 @@
|
|
|
46
49
|
"mobile",
|
|
47
50
|
"three",
|
|
48
51
|
"ai",
|
|
49
|
-
"world-generation"
|
|
52
|
+
"world-generation",
|
|
53
|
+
"multiplayer",
|
|
54
|
+
"co-op"
|
|
50
55
|
],
|
|
51
56
|
"media": {
|
|
52
57
|
"image": "https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png",
|
|
@@ -57,18 +62,18 @@
|
|
|
57
62
|
"name": "iDos Games",
|
|
58
63
|
"url": "https://idosgames.com"
|
|
59
64
|
},
|
|
60
|
-
"version": "0.
|
|
65
|
+
"version": "0.4.0"
|
|
61
66
|
},
|
|
62
67
|
"dependencies": {
|
|
63
|
-
"@idosgames/core": "0.
|
|
64
|
-
"@idosgames/module-sdk": "0.
|
|
68
|
+
"@idosgames/core": "0.19.0",
|
|
69
|
+
"@idosgames/module-sdk": "0.5.0",
|
|
65
70
|
"three": "0.185.1"
|
|
66
71
|
},
|
|
67
|
-
"contentHash": "
|
|
72
|
+
"contentHash": "1648a7174756f00cb7952db0fd5fb4f556793acc190238e18c393cc4d607de0a",
|
|
68
73
|
"files": [
|
|
69
74
|
{
|
|
70
75
|
"path": "agent.ts",
|
|
71
|
-
"content": "import type { ModuleAgentApi } from \"@idosgames/module-sdk\";\nimport { difficulty } from \"./systems/Difficulty\";\nimport { events } from \"./core/EventBus\";\nimport { Intent } from \"./core/Intents\";\nimport { BLOCKS } from \"./registry/Blocks\";\nimport type { Game } from \"./main\";\nimport { buildSpecWithoutAI } from \"./worldgen/textSpec\";\nimport { SIZE_PRESETS, parseWorldSpec } from \"./worldgen/WorldSpec\";\n\n// Debug-поверхность VoxelCraft для агента AI-кодера (контракт `ctx.exposeToAgent`).\n//\n// Зачем она нужна именно здесь: агент осматривает работающее приложение, читая DOM, а вся игра —\n// один `<canvas>`. Без этого файла для агента экран пуст, и «персонаж провалился сквозь пол» он\n// не увидит никак.\n//\n// ПРАВИЛО, которое нельзя нарушать: действия идут тем же путём, что и живой ввод — через клавиши\n// `Input` и очередь интентов. Второй реализации ходьбы здесь нет и быть не должно, иначе агент\n// проверял бы не ту игру, в которую играет человек.\n\n/** Потолок удержания клавиши: агент не должен уметь «зажать W» на минуту. */\nconst MAX_HOLD_MS = 5000;\n\n/** Сколько времени на действие, если агент не сказал. */\nconst DEFAULT_HOLD_MS = 400;\n\nconst DIR_KEYS: Record<string, string> = {\n forward: \"KeyW\",\n back: \"KeyS\",\n left: \"KeyA\",\n right: \"KeyD\",\n};\n\nfunction clampMs(value: unknown): number {\n const ms = typeof value === \"number\" && value > 0 ? value : DEFAULT_HOLD_MS;\n return Math.min(ms, MAX_HOLD_MS);\n}\n\nfunction round(value: number, digits = 2): number {\n const k = 10 ** digits;\n return Math.round(value * k) / k;\n}\n\nconst toDegrees = (rad: number): number => round((rad * 180) / Math.PI, 1);\n\n/**\n * @param getGame доступ к текущему экземпляру игры. Именно функция, а не объект: сцена создаёт\n * `Game` в `mount()` и обнуляет в `destroy()`, а поверхность регистрируется один раз на `setup()`.\n */\nexport function createVoxelcraftAgentApi(\n getGame: () => Game | null,\n): ModuleAgentApi {\n /** Игра, готовая принимать команды: существует и не на паузе после действий агента. */\n function live(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n // Действие в игре, которая стоит на паузе, молча не сделало бы ничего — сначала берём\n // управление (это же снимает оверлей паузы, как клик игрока).\n game.input.takeControl();\n return game;\n }\n\n /** Удержать клавишу как живой игрок: нажали, подождали, отпустили. */\n function hold(code: string, ms: number): Promise<void> {\n const game = live();\n game.input.keys.add(code);\n return new Promise((resolve) => {\n setTimeout(() => {\n game.input.keys.delete(code);\n resolve();\n }, ms);\n });\n }\n\n /**\n * Удержать кнопку мыши (ломать/ставить обрабатываются по удержанию). Отпускаем не раньше, чем\n * нажатие увидели два кадра: в фоне кадр идёт раз в 200 мс, и «клик» на 120 мс игра не замечала.\n */\n function holdButton(button: \"left\" | \"right\", ms: number): Promise<void> {\n const game = live();\n if (button === \"left\") game.input.leftDown = true;\n else game.input.rightDown = true;\n const frame0 = game.loop.frameN;\n const t0 = performance.now();\n return new Promise((resolve) => {\n const check = () => {\n const seen = game.loop.frameN - frame0 >= 2;\n if (\n performance.now() - t0 < ms ||\n (!seen && performance.now() - t0 < 1000)\n ) {\n setTimeout(check, 20);\n return;\n }\n if (button === \"left\") game.input.leftDown = false;\n else game.input.rightDown = false;\n resolve();\n };\n setTimeout(check, 20);\n });\n }\n\n /** Игра без захвата управления: смена мира работает и на паузе. */\n function mounted(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n return game;\n }\n\n return {\n state() {\n const game = getGame();\n if (!game) return { mounted: false };\n\n const p = game.player;\n const held = game.inventory.heldItem();\n const src = game.world.source;\n\n return {\n mounted: true,\n // Идёт ли симуляция. false = игра на паузе (оверлей «Click to play»): в этом состоянии\n // персонаж не двигается вообще, и это НЕ баг движения.\n playing: game.playing,\n inputActive: game.input.active,\n player: {\n pos: { x: round(p.pos.x), y: round(p.pos.y), z: round(p.pos.z) },\n velocity: {\n x: round(p.vel.x),\n y: round(p.vel.y),\n z: round(p.vel.z),\n },\n yawDeg: toDegrees(p.yaw),\n pitchDeg: toDegrees(p.pitch),\n health: p.health,\n food: p.food,\n mode: p.mode,\n flying: p.flying,\n gliding: p.gliding,\n xpLevel: p.xpLevel,\n armor: game.inventory.armorStats().points,\n onGround: p.onGround,\n inWater: p.inWater,\n dead: p.dead,\n // на чём едет: minecart | boat | имя животного (horse); null — пешком\n riding: p.riding\n ? ((p.riding as { def?: { name: string } }).def?.name ??\n p.riding.kind)\n : null,\n },\n // открытый экран: inventory | crafting | chest | furnace; null — идёт игра\n screen: game.invUI.open ? game.invUI.kind : null,\n // идущий набег: волна, ожидание или бой, сколько разбойников живы\n raid: game.raid\n ? {\n wave: game.raid.wave,\n state: game.raid.state,\n raiders: game.raid.raiders.length,\n }\n : null,\n // открытые достижения (id; список — systems/Achievements)\n achievements: game.achievements.serialize(),\n // блок под прицелом (то, что сломает ЛКМ и на что сработает ПКМ)\n lookingAt: (() => {\n const t = game.interaction.target;\n return t\n ? { block: BLOCKS[t.id]?.name ?? t.id, x: t.x, y: t.y, z: t.z }\n : null;\n })(),\n // мобы в 32 блоках: сколько каждого вида (что видит игрок вокруг)\n mobsNearby: (() => {\n const out: Record<string, number> = {};\n for (const e of game.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.pos.distanceTo(p.pos) < 32)\n out[e.def.name] = (out[e.def.name] ?? 0) + 1;\n return out;\n })(),\n // босс рядом (Дракон Предела): имя, здоровье — как полоса босса на экране\n boss: (() => {\n for (const e of game.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n return {\n name: e.def.name,\n hp: Math.round(e.hp),\n max: e.def.hp,\n pos: {\n x: round(e.pos.x),\n y: round(e.pos.y),\n z: round(e.pos.z),\n },\n };\n return null;\n })(),\n // Экран «Создать мир» открыт — игра под ним на паузе, ввод заморожен.\n createWorldOpen: game.worldUI.open,\n world: {\n id: game.worldId,\n name: game.worldName,\n // \"noise\" — классический мир по сиду; \"spec\" — мир по рецепту (картинка/описание/ИИ)\n source: src.kind,\n // поколение генератора: 1 — прежний мир, 2 — высота 128, биомы, пещеры\n gen: src.gen ?? 1,\n dimension: game.dimension,\n // погода: clear | rain | thunder (в Пекле и Пределе её не видно)\n weather: game.weather.kind,\n // сложность: peaceful | easy | normal | hard (systems/Difficulty)\n difficulty: difficulty(),\n seed: game.world.seed,\n waterLevel: game.world.waterLevel,\n spawnHint: game.world.generator.spawnHint(),\n ...(src.kind === \"spec\"\n ? {\n size: src.spec.size,\n shape: src.spec.terrain.shape,\n biomes: src.spec.zones.map((z) => z.biome),\n structures: src.spec.structures.map((s) => s.type),\n fromPicture: Boolean(src.spec.terrain.heightmap),\n }\n : {}),\n loadedChunks: game.world.chunks.size,\n // Блок под ногами: по нему видно и «стоит на траве», и «провалился в пустоту».\n blockBelow: game.world.getBlock(\n Math.floor(p.pos.x),\n Math.floor(p.pos.y - 0.1),\n Math.floor(p.pos.z),\n ),\n },\n inventory: {\n hotbarIndex: game.inventory.hotbarIndex,\n inHand: held ? { item: held.item, count: held.count } : null,\n },\n };\n },\n\n actions: {\n move: async (args) => {\n const dir = String(args?.dir ?? \"forward\");\n const code = DIR_KEYS[dir];\n if (!code)\n throw new Error(\n `unknown dir '${dir}' — use forward | back | left | right`,\n );\n await hold(code, clampMs(args?.ms));\n },\n\n jump: async (args) => {\n await hold(\"Space\", clampMs(args?.ms ?? 120));\n },\n\n look: (args) => {\n const game = live();\n const dYaw = Number(args?.yawDeg ?? 0) * (Math.PI / 180);\n const dPitch = Number(args?.pitchDeg ?? 0) * (Math.PI / 180);\n // Через штатный метод игрока — те же ограничения по pitch, что и у мыши.\n game.player.look(dYaw, dPitch);\n },\n\n breakBlock: async (args) => {\n // Ломание идёт по удержанию ЛКМ: короткий «клик» не сломает ничего, кроме мгновенных блоков.\n await holdButton(\"left\", clampMs(args?.ms ?? 1200));\n },\n\n placeBlock: async () => {\n await holdButton(\"right\", 120);\n },\n\n /**\n * Удержать кнопку мыши столько, сколько нужно ДЕЙСТВИЮ, а не одному клику: натянуть лук,\n * зарядить арбалет, выпить зелье, закрыться щитом, размахнуться трезубцем. Без этого действия\n * агент умел только «тапнуть», и все такие вещи проверялись лишь тестами.\n */\n holdButton: async (args) => {\n const button = args?.button === \"left\" ? \"left\" : \"right\";\n await holdButton(button, clampMs(args?.ms ?? 1200));\n },\n\n selectSlot: (args) => {\n const n = Number(args?.slot ?? 1);\n if (!Number.isInteger(n) || n < 1 || n > 9)\n throw new Error(\"slot must be 1..9\");\n live().intents.push(Intent.SelectSlot, { slot: n - 1 });\n },\n\n toggleMode: () => {\n live().intents.push(Intent.ToggleMode, {});\n },\n\n toggleInventory: () => {\n // то же событие, что клавиша E и тач-кнопка: в креативе — палитра всех предметов\n live();\n events.emit(\"ui:toggleInventory\");\n },\n\n respawn: () => {\n // та же кнопка «Возродиться», что на экране смерти\n const game = mounted();\n if (!game.player.dead) throw new Error(\"the player is alive\");\n game.respawnPlayer();\n },\n\n command: async (args) => {\n // Тем же путём, что строка консоли игрока: интент Command → исполнение на тике.\n const game = live();\n const text = String(args?.text ?? \"\").trim();\n if (!text)\n throw new Error(\"text is required, e.g. { text: '/give torch 16' }\");\n const before = game.commands.log.length;\n game.intents.push(Intent.Command, {\n text: text.startsWith(\"/\") ? text : \"/\" + text,\n });\n for (let i = 0; i < 40 && game.commands.log.length === before; i++)\n await new Promise((r) => setTimeout(r, 50));\n return { output: game.commands.log.slice(before) };\n },\n\n generateWorld: (args) => {\n const game = mounted();\n const key = String(args?.size ?? \"island\");\n const size =\n key === \"large\" || key === \"infinite\"\n ? SIZE_PRESETS[key]\n : SIZE_PRESETS.island;\n const seed = Number(args?.seed);\n const spec = buildSpecWithoutAI({\n text: String(args?.text ?? \"\"),\n size: { ...size },\n seed: Number.isFinite(seed)\n ? seed >>> 0\n : (Math.random() * 0xffffffff) >>> 0,\n });\n game.newWorld({ kind: \"spec\", spec });\n return {\n name: spec.name,\n structures: spec.structures.map((s) => s.type),\n };\n },\n\n loadWorldSpec: (args) => {\n const r = parseWorldSpec(args?.spec);\n if (!r.ok) throw new Error(r.error);\n mounted().newWorld({ kind: \"spec\", spec: r.spec });\n return { name: r.spec.name, warnings: r.warnings };\n },\n },\n\n describeActions: {\n move: \"Walk: { dir: forward|back|left|right, ms?: number } — holds the movement key for ms (default 400, max 5000).\",\n jump: \"Jump: { ms?: number } — taps Space.\",\n look: \"Turn the camera by a DELTA in degrees: { yawDeg?: number, pitchDeg?: number }. Positive pitch looks up.\",\n breakBlock:\n \"Hold left mouse to break the block under the crosshair: { ms?: number } (default 1200 — breaking takes time).\",\n placeBlock:\n \"Tap right mouse to place the block from the selected hotbar slot.\",\n holdButton:\n \"Hold a mouse button for a while: { button?: 'left'|'right' (default right), ms?: number } (default 1200, max 5000). This is how you draw a bow, load a crossbow, drink a potion, raise a shield or wind up a trident — a tap is too short for them.\",\n selectSlot: \"Select a hotbar slot: { slot: 1..9 }.\",\n toggleMode: \"Switch between survival and creative.\",\n toggleInventory:\n \"Open/close the inventory like the E key (state.screen shows which one: inventory, or creative — every item with search — in creative mode).\",\n respawn:\n \"Press 'Respawn' on the death screen (player.dead = true): back to the bed or the world spawn.\",\n command:\n \"Run a console command like a player typing it in chat: { text: '/give torch 16' }. Commands: /help, /give <item> [n], /tp x y z, /time set day|noon|night|midnight|<sec>, /weather clear|rain|thunder [sec],/difficulty peaceful|easy|normal|hard, /gamemode survival|creative, /setblock x y z <block> [meta], /fill x1 y1 z1 x2 y2 z2 <block>, /summon <mob> [x y z], /kill [mob] (the player, or every mob of that kind within 64 blocks — a normal death with drops, slimes split), /clear, /seed, /locate fortress|citadel|village (nearest structure of this dimension), /enchant <efficiency|unbreaking|fortune|silk_touch|sharpness|protection|power> [level] (the held item), /newworld [seed] (a new seed world of the current generation; the old world stays in 'My worlds'). Coordinates accept ~ (relative). Minecraft names (grass_block, oak_planks) are accepted. Returns the output lines.\",\n generateWorld:\n \"Build a new world WITHOUT AI from a description (Russian/English keywords): { text: string, size?: island|large|infinite, seed?: number }. Replaces the current world (the old one stays in 'My worlds').\",\n loadWorldSpec:\n \"Build a new world from a WorldSpec recipe object: { spec: object } (see the voxelcraft-worlds skill). Invalid fields are clamped/dropped; returns warnings.\",\n },\n };\n}\n"
|
|
76
|
+
"content": "import type { ModuleAgentApi } from \"@idosgames/module-sdk\";\nimport { difficulty } from \"./systems/Difficulty\";\nimport { events } from \"./core/EventBus\";\nimport { Intent } from \"./core/Intents\";\nimport { BLOCKS } from \"./registry/Blocks\";\nimport { sim } from \"./net/sim\";\nimport type { Game } from \"./main\";\nimport { buildSpecWithoutAI } from \"./worldgen/textSpec\";\nimport { SIZE_PRESETS, parseWorldSpec } from \"./worldgen/WorldSpec\";\n\n// Debug-поверхность VoxelCraft для агента AI-кодера (контракт `ctx.exposeToAgent`).\n//\n// Зачем она нужна именно здесь: агент осматривает работающее приложение, читая DOM, а вся игра —\n// один `<canvas>`. Без этого файла для агента экран пуст, и «персонаж провалился сквозь пол» он\n// не увидит никак.\n//\n// ПРАВИЛО, которое нельзя нарушать: действия идут тем же путём, что и живой ввод — через клавиши\n// `Input` и очередь интентов. Второй реализации ходьбы здесь нет и быть не должно, иначе агент\n// проверял бы не ту игру, в которую играет человек.\n\n/** Потолок удержания клавиши: агент не должен уметь «зажать W» на минуту. */\nconst MAX_HOLD_MS = 5000;\n\n/** Сколько времени на действие, если агент не сказал. */\nconst DEFAULT_HOLD_MS = 400;\n\nconst DIR_KEYS: Record<string, string> = {\n forward: \"KeyW\",\n back: \"KeyS\",\n left: \"KeyA\",\n right: \"KeyD\",\n};\n\nfunction clampMs(value: unknown): number {\n const ms = typeof value === \"number\" && value > 0 ? value : DEFAULT_HOLD_MS;\n return Math.min(ms, MAX_HOLD_MS);\n}\n\nfunction round(value: number, digits = 2): number {\n const k = 10 ** digits;\n return Math.round(value * k) / k;\n}\n\nconst toDegrees = (rad: number): number => round((rad * 180) / Math.PI, 1);\n\n/**\n * @param getGame доступ к текущему экземпляру игры. Именно функция, а не объект: сцена создаёт\n * `Game` в `mount()` и обнуляет в `destroy()`, а поверхность регистрируется один раз на `setup()`.\n */\nexport function createVoxelcraftAgentApi(\n getGame: () => Game | null,\n): ModuleAgentApi {\n /** Игра, готовая принимать команды: существует и не на паузе после действий агента. */\n function live(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n // Действие в игре, которая стоит на паузе, молча не сделало бы ничего — сначала берём\n // управление (это же снимает оверлей паузы, как клик игрока).\n game.input.takeControl();\n return game;\n }\n\n /** Удержать клавишу как живой игрок: нажали, подождали, отпустили. */\n function hold(code: string, ms: number): Promise<void> {\n const game = live();\n game.input.keys.add(code);\n return new Promise((resolve) => {\n setTimeout(() => {\n game.input.keys.delete(code);\n resolve();\n }, ms);\n });\n }\n\n /**\n * Удержать кнопку мыши (ломать/ставить обрабатываются по удержанию). Отпускаем не раньше, чем\n * нажатие увидели два кадра: в фоне кадр идёт раз в 200 мс, и «клик» на 120 мс игра не замечала.\n */\n function holdButton(button: \"left\" | \"right\", ms: number): Promise<void> {\n const game = live();\n if (button === \"left\") game.input.leftDown = true;\n else game.input.rightDown = true;\n const frame0 = game.loop.frameN;\n const t0 = performance.now();\n return new Promise((resolve) => {\n const check = () => {\n const seen = game.loop.frameN - frame0 >= 2;\n if (\n performance.now() - t0 < ms ||\n (!seen && performance.now() - t0 < 1000)\n ) {\n setTimeout(check, 20);\n return;\n }\n if (button === \"left\") game.input.leftDown = false;\n else game.input.rightDown = false;\n resolve();\n };\n setTimeout(check, 20);\n });\n }\n\n /** Игра без захвата управления: смена мира работает и на паузе. */\n function mounted(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n return game;\n }\n\n return {\n state() {\n const game = getGame();\n if (!game) return { mounted: false };\n\n const p = game.player;\n const held = game.inventory.heldItem();\n const src = game.world.source;\n\n return {\n mounted: true,\n // Идёт ли симуляция. false = игра на паузе (оверлей «Click to play»): в этом состоянии\n // персонаж не двигается вообще, и это НЕ баг движения.\n playing: game.playing,\n inputActive: game.input.active,\n player: {\n pos: { x: round(p.pos.x), y: round(p.pos.y), z: round(p.pos.z) },\n velocity: {\n x: round(p.vel.x),\n y: round(p.vel.y),\n z: round(p.vel.z),\n },\n yawDeg: toDegrees(p.yaw),\n pitchDeg: toDegrees(p.pitch),\n health: p.health,\n food: p.food,\n mode: p.mode,\n flying: p.flying,\n gliding: p.gliding,\n xpLevel: p.xpLevel,\n armor: game.inventory.armorStats().points,\n onGround: p.onGround,\n inWater: p.inWater,\n dead: p.dead,\n // на чём едет: minecart | boat | имя животного (horse); null — пешком\n riding: p.riding\n ? ((p.riding as { def?: { name: string } }).def?.name ??\n p.riding.kind)\n : null,\n },\n // открытый экран: inventory | crafting | chest | furnace; null — идёт игра\n screen: game.invUI.open ? game.invUI.kind : null,\n // идущий набег: волна, ожидание или бой, сколько разбойников живы\n raid: game.raid\n ? {\n wave: game.raid.wave,\n state: game.raid.state,\n raiders: game.raid.raiders.length,\n }\n : null,\n // открытые достижения (id; список — systems/Achievements)\n achievements: game.achievements.serialize(),\n // блок под прицелом (то, что сломает ЛКМ и на что сработает ПКМ)\n lookingAt: (() => {\n const t = game.interaction.target;\n return t\n ? { block: BLOCKS[t.id]?.name ?? t.id, x: t.x, y: t.y, z: t.z }\n : null;\n })(),\n // мобы в 32 блоках: сколько каждого вида (что видит игрок вокруг)\n mobsNearby: (() => {\n const out: Record<string, number> = {};\n for (const e of game.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.pos.distanceTo(p.pos) < 32)\n out[e.def.name] = (out[e.def.name] ?? 0) + 1;\n return out;\n })(),\n // босс рядом (Дракон Предела): имя, здоровье — как полоса босса на экране\n boss: (() => {\n for (const e of game.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n return {\n name: e.def.name,\n hp: Math.round(e.hp),\n max: e.def.hp,\n pos: {\n x: round(e.pos.x),\n y: round(e.pos.y),\n z: round(e.pos.z),\n },\n };\n return null;\n })(),\n // Экран «Создать мир» открыт — игра под ним на паузе, ввод заморожен.\n createWorldOpen: game.worldUI.open,\n world: {\n id: game.worldId,\n name: game.worldName,\n // \"noise\" — классический мир по сиду; \"spec\" — мир по рецепту (картинка/описание/ИИ)\n source: src.kind,\n // поколение генератора: 1 — прежний мир, 2 — высота 128, биомы, пещеры\n gen: src.gen ?? 1,\n dimension: game.dimension,\n // погода: clear | rain | thunder (в Пекле и Пределе её не видно)\n weather: game.weather.kind,\n // сложность: peaceful | easy | normal | hard (systems/Difficulty)\n difficulty: difficulty(),\n seed: game.world.seed,\n waterLevel: game.world.waterLevel,\n spawnHint: game.world.generator.spawnHint(),\n ...(src.kind === \"spec\"\n ? {\n size: src.spec.size,\n shape: src.spec.terrain.shape,\n biomes: src.spec.zones.map((z) => z.biome),\n structures: src.spec.structures.map((s) => s.type),\n fromPicture: Boolean(src.spec.terrain.heightmap),\n }\n : {}),\n loadedChunks: game.world.chunks.size,\n // Блок под ногами: по нему видно и «стоит на траве», и «провалился в пустоту».\n blockBelow: game.world.getBlock(\n Math.floor(p.pos.x),\n Math.floor(p.pos.y - 0.1),\n Math.floor(p.pos.z),\n ),\n },\n inventory: {\n hotbarIndex: game.inventory.hotbarIndex,\n inHand: held ? { item: held.item, count: held.count } : null,\n },\n // Сетевая игра: null — одиночная. owner — этот клиент симулирует своё измерение (мобы,\n // жидкости, блочные обновления); false — он реплика и показывает то, что шлёт владелец.\n net: game.net\n ? {\n host: game.net.isHost,\n owner: sim.authority,\n edits: game.net.diffs.count(game.dimension),\n players: game.net.playerList().map((pl) => {\n const pose = game.net?.peers.get(pl.slot)?.pose;\n return {\n name: pl.name,\n host: pl.host,\n op: pl.op,\n dim: pl.dim,\n rttMs: Math.round(pl.rtt),\n pos:\n pl.userId === game.net?.selfId\n ? {\n x: round(p.pos.x),\n y: round(p.pos.y),\n z: round(p.pos.z),\n }\n : pose\n ? {\n x: round(pose.x),\n y: round(pose.y),\n z: round(pose.z),\n }\n : null,\n };\n }),\n }\n : null,\n };\n },\n\n actions: {\n move: async (args) => {\n const dir = String(args?.dir ?? \"forward\");\n const code = DIR_KEYS[dir];\n if (!code)\n throw new Error(\n `unknown dir '${dir}' — use forward | back | left | right`,\n );\n await hold(code, clampMs(args?.ms));\n },\n\n jump: async (args) => {\n await hold(\"Space\", clampMs(args?.ms ?? 120));\n },\n\n look: (args) => {\n const game = live();\n const dYaw = Number(args?.yawDeg ?? 0) * (Math.PI / 180);\n const dPitch = Number(args?.pitchDeg ?? 0) * (Math.PI / 180);\n // Через штатный метод игрока — те же ограничения по pitch, что и у мыши.\n game.player.look(dYaw, dPitch);\n },\n\n breakBlock: async (args) => {\n // Ломание идёт по удержанию ЛКМ: короткий «клик» не сломает ничего, кроме мгновенных блоков.\n await holdButton(\"left\", clampMs(args?.ms ?? 1200));\n },\n\n placeBlock: async () => {\n await holdButton(\"right\", 120);\n },\n\n /**\n * Удержать кнопку мыши столько, сколько нужно ДЕЙСТВИЮ, а не одному клику: натянуть лук,\n * зарядить арбалет, выпить зелье, закрыться щитом, размахнуться трезубцем. Без этого действия\n * агент умел только «тапнуть», и все такие вещи проверялись лишь тестами.\n */\n holdButton: async (args) => {\n const button = args?.button === \"left\" ? \"left\" : \"right\";\n await holdButton(button, clampMs(args?.ms ?? 1200));\n },\n\n selectSlot: (args) => {\n const n = Number(args?.slot ?? 1);\n if (!Number.isInteger(n) || n < 1 || n > 9)\n throw new Error(\"slot must be 1..9\");\n live().intents.push(Intent.SelectSlot, { slot: n - 1 });\n },\n\n toggleMode: () => {\n live().intents.push(Intent.ToggleMode, {});\n },\n\n toggleInventory: () => {\n // то же событие, что клавиша E и тач-кнопка: в креативе — палитра всех предметов\n live();\n events.emit(\"ui:toggleInventory\");\n },\n\n respawn: () => {\n // та же кнопка «Возродиться», что на экране смерти\n const game = mounted();\n if (!game.player.dead) throw new Error(\"the player is alive\");\n game.respawnPlayer();\n },\n\n command: async (args) => {\n // Тем же путём, что строка консоли игрока: интент Command → исполнение на тике.\n const game = live();\n const text = String(args?.text ?? \"\").trim();\n if (!text)\n throw new Error(\"text is required, e.g. { text: '/give torch 16' }\");\n const before = game.commands.log.length;\n game.intents.push(Intent.Command, {\n text: text.startsWith(\"/\") ? text : \"/\" + text,\n });\n for (let i = 0; i < 40 && game.commands.log.length === before; i++)\n await new Promise((r) => setTimeout(r, 50));\n return { output: game.commands.log.slice(before) };\n },\n\n entitiesNear: (args) => {\n // Ближайшие сущности (только чтение): вид, id, позиция, дистанция — прицелиться и сверить\n const game = mounted();\n const r = Math.min(64, Math.max(1, Number(args?.radius ?? 16)));\n const p = game.player.pos;\n const out: ({\n id: number;\n kind: string;\n x: number;\n y: number;\n z: number;\n dist: number;\n } & Record<string, unknown>)[] = [];\n for (const e of game.entities.entities.values()) {\n if (e.dead) continue;\n const dist = e.pos.distanceTo(p);\n if (dist > r) continue;\n out.push({\n id: e.id,\n kind: e.kind,\n ...(e.kind === \"mob\"\n ? {\n type: e.def.name,\n // состояние от действий игрока — сверить у двух игроков сети\n ...(e.sheared ? { sheared: true } : {}),\n ...(e.tamed ? { tamed: true } : {}),\n ...(e.saddled ? { saddled: true } : {}),\n ...(e.leashed ? { leashed: true } : {}),\n ...(e.customName ? { name: e.customName } : {}),\n }\n : {}),\n x: round(e.pos.x),\n y: round(e.pos.y),\n z: round(e.pos.z),\n dist: round(dist),\n ...(\"rider\" in e && e.rider ? { rider: true } : {}),\n });\n }\n return out.sort((a, b) => a.dist - b.dist).slice(0, 30);\n },\n\n blockAt: (args) => {\n // Прочитать блок мира по координатам (только чтение) — сверить мир у двух игроков сети.\n const game = mounted();\n const x = Math.floor(Number(args?.x));\n const y = Math.floor(Number(args?.y));\n const z = Math.floor(Number(args?.z));\n if (![x, y, z].every(Number.isFinite))\n throw new Error(\"x, y, z are required numbers\");\n const id = game.world.getBlock(x, y, z);\n return { block: BLOCKS[id]?.name ?? id, id };\n },\n\n generateWorld: (args) => {\n const game = mounted();\n const key = String(args?.size ?? \"island\");\n const size =\n key === \"large\" || key === \"infinite\"\n ? SIZE_PRESETS[key]\n : SIZE_PRESETS.island;\n const seed = Number(args?.seed);\n const spec = buildSpecWithoutAI({\n text: String(args?.text ?? \"\"),\n size: { ...size },\n seed: Number.isFinite(seed)\n ? seed >>> 0\n : (Math.random() * 0xffffffff) >>> 0,\n });\n game.newWorld({ kind: \"spec\", spec });\n return {\n name: spec.name,\n structures: spec.structures.map((s) => s.type),\n };\n },\n\n loadWorldSpec: (args) => {\n const r = parseWorldSpec(args?.spec);\n if (!r.ok) throw new Error(r.error);\n mounted().newWorld({ kind: \"spec\", spec: r.spec });\n return { name: r.spec.name, warnings: r.warnings };\n },\n },\n\n describeActions: {\n move: \"Walk: { dir: forward|back|left|right, ms?: number } — holds the movement key for ms (default 400, max 5000).\",\n jump: \"Jump: { ms?: number } — taps Space.\",\n look: \"Turn the camera by a DELTA in degrees: { yawDeg?: number, pitchDeg?: number }. Positive pitch looks up.\",\n breakBlock:\n \"Hold left mouse to break the block under the crosshair: { ms?: number } (default 1200 — breaking takes time).\",\n placeBlock:\n \"Tap right mouse to place the block from the selected hotbar slot.\",\n holdButton:\n \"Hold a mouse button for a while: { button?: 'left'|'right' (default right), ms?: number } (default 1200, max 5000). This is how you draw a bow, load a crossbow, drink a potion, raise a shield or wind up a trident — a tap is too short for them.\",\n selectSlot: \"Select a hotbar slot: { slot: 1..9 }.\",\n toggleMode: \"Switch between survival and creative.\",\n toggleInventory:\n \"Open/close the inventory like the E key (state.screen shows which one: inventory, or creative — every item with search — in creative mode).\",\n respawn:\n \"Press 'Respawn' on the death screen (player.dead = true): back to the bed or the world spawn.\",\n command:\n \"Run a console command like a player typing it in chat: { text: '/give torch 16' }. Commands: /help, /give <item> [n], /tp x y z, /time set day|noon|night|midnight|<sec>, /weather clear|rain|thunder [sec],/difficulty peaceful|easy|normal|hard, /gamemode survival|creative, /setblock x y z <block> [meta], /fill x1 y1 z1 x2 y2 z2 <block>, /summon <mob> [x y z], /kill [mob] (the player, or every mob of that kind within 64 blocks — a normal death with drops, slimes split), /clear, /seed, /locate fortress|citadel|village (nearest structure of this dimension), /enchant <efficiency|unbreaking|fortune|silk_touch|sharpness|protection|power> [level] (the held item), /newworld [seed] (a new seed world of the current generation; the old world stays in 'My worlds'). Coordinates accept ~ (relative). Minecraft names (grass_block, oak_planks) are accepted. Returns the output lines.\",\n entitiesNear:\n \"Nearest entities (read-only): { radius?: 1..64 (default 16) } → up to 30 of { id, kind, type?, x, y, z, dist, rider?, sheared?, tamed?, saddled?, leashed?, name? }, nearest first.\",\n blockAt:\n \"Read one block of the current dimension (read-only): { x, y, z } → { block, id }. In a network game compare it across players to check world sync.\",\n generateWorld:\n \"Build a new world WITHOUT AI from a description (Russian/English keywords): { text: string, size?: island|large|infinite, seed?: number }. Replaces the current world (the old one stays in 'My worlds').\",\n loadWorldSpec:\n \"Build a new world from a WorldSpec recipe object: { spec: object } (see the voxelcraft-worlds skill). Invalid fields are clamped/dropped; returns warnings.\",\n },\n };\n}\n"
|
|
72
77
|
},
|
|
73
78
|
{
|
|
74
79
|
"path": "ai/types.ts",
|
|
@@ -96,7 +101,7 @@
|
|
|
96
101
|
},
|
|
97
102
|
{
|
|
98
103
|
"path": "core/GameLoop.ts",
|
|
99
|
-
"content": "// Игровой цикл: rAF-рендер + аккумулятор фиксированного тика (20 TPS).\n// Симуляция (мир, мобы, вода, интенты) — на тике; всё, что касается глаза и руки\n// (камера, физика игрока, интерполяция, рендер) — на кадре.\nimport { CONFIG } from \"../config\";\n\nexport class GameLoop {\n onTick: (dt: number, n: number) => void;\n onFrame: (dt: number, alpha: number) => void;\n tickDt: number;\n acc: number;\n tickN: number;\n last: number;\n running: boolean;\n fps: number;\n /**\n * Сколько кадров отработано. Агент ждёт по нему, что нажатие увидел хотя бы один кадр: при\n * страховочном таймере (200 мс) короткий «клик» иначе проскакивал между кадрами.\n */\n frameN = 0;\n private _frames: number;\n private _fpsTime: number;\n /** id запланированного кадра: stop() обязан его отменить, см. там. */\n private _raf = 0;\n /**\n * Таймер шага для скрытой страницы. В фоне (свёрнутое превью AI-кодера, фоновая вкладка) браузер\n * не зовёт requestAnimationFrame вовсе — симуляция вставала, и действия агента (команды, ходьба)\n * висели до возврата на вкладку. Скрытая страница шагает таймером; браузер его душит до ~1 Гц,\n * но тики (maxCatchupTicks за шаг) идут.\n */\n private _timer: ReturnType<typeof setTimeout> | undefined;\n\n /**\n * @param onTick — фиксированный шаг, dt = 1/TPS\n * @param onFrame — alpha = доля тика для интерполяции\n */\n constructor(\n onTick: (dt: number, n: number) => void,\n onFrame: (dt: number, alpha: number) => void,\n ) {\n this.onTick = onTick;\n this.onFrame = onFrame;\n this.tickDt = 1 / CONFIG.tps;\n this.acc = 0;\n this.tickN = 0;\n this.last = 0;\n this.running = false;\n // FPS: скользящее окно за секунду\n this.fps = 0;\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n start() {\n this.running = true;\n this.last = performance.now();\n const step = (now: number) => {\n if (!this.running) return;\n let dt = (now - this.last) / 1000;\n this.last = now;\n if (dt > 0.25) dt = 0.25; // возврат из фона — не наращивать гигантский долг\n\n this.acc += dt;\n let ticks = 0;\n while (this.acc >= this.tickDt && ticks < CONFIG.maxCatchupTicks) {\n this.onTick(this.tickDt, this.tickN++);\n this.acc -= this.tickDt;\n ticks++;\n }\n if (ticks === CONFIG.maxCatchupTicks) this.acc = 0; // сбросить долг\n\n this._frames++;\n this.frameN++;\n this._fpsTime += dt;\n if (this._fpsTime >= 0.5) {\n this.fps = Math.round(this._frames / this._fpsTime);\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n this.onFrame(Math.min(dt, 0.05), this.acc / this.tickDt);\n schedule();\n };\n const schedule = () => {\n if (typeof document !== \"undefined\" && document.hidden) {\n this._timer = setTimeout(() => step(performance.now()), 50);\n return;\n }\n // Видимая, но неотрисовываемая страница (панель превью за другой вкладкой, iframe вне\n // экрана) не зовёт rAF и при document.hidden === false. Страховочный таймер шагает сам,\n // если кадр не пришёл за 200 мс; кто сработал первым — шагает, второй отменяется.\n this._raf = requestAnimationFrame((now) => {\n clearTimeout(this._timer);\n step(now);\n });\n this._timer = setTimeout(() => {\n cancelAnimationFrame(this._raf);\n step(performance.now());\n }, 200);\n };\n schedule();\n }\n\n stop() {\n this.running = false;\n // Уже запланированный кадр отменяем явно: если start() придёт раньше, чем он сработает\n // (suspend+activate в одном кадре), он увидел бы running=true и продолжил бы СВОЮ цепочку\n // рядом с новой — симуляция пошла бы вдвое быстрее.\n cancelAnimationFrame(this._raf);\n clearTimeout(this._timer);\n }\n}\n"
|
|
104
|
+
"content": "// Игровой цикл: rAF-рендер + аккумулятор фиксированного тика (20 TPS).\n// Симуляция (мир, мобы, вода, интенты) — на тике; всё, что касается глаза и руки\n// (камера, физика игрока, интерполяция, рендер) — на кадре.\nimport { CONFIG } from \"../config\";\n\nexport class GameLoop {\n onTick: (dt: number, n: number) => void;\n onFrame: (dt: number, alpha: number) => void;\n tickDt: number;\n acc: number;\n tickN: number;\n last: number;\n running: boolean;\n fps: number;\n /**\n * Сколько кадров отработано. Агент ждёт по нему, что нажатие увидел хотя бы один кадр: при\n * страховочном таймере (200 мс) короткий «клик» иначе проскакивал между кадрами.\n */\n frameN = 0;\n private _frames: number;\n private _fpsTime: number;\n /** id запланированного кадра: stop() обязан его отменить, см. там. */\n private _raf = 0;\n /**\n * Таймер шага для скрытой страницы. В фоне (свёрнутое превью AI-кодера, фоновая вкладка) браузер\n * не зовёт requestAnimationFrame вовсе — симуляция вставала, и действия агента (команды, ходьба)\n * висели до возврата на вкладку. Скрытая страница шагает таймером; браузер его душит до ~1 Гц,\n * но тики (maxCatchupTicks за шаг) идут.\n */\n private _timer: ReturnType<typeof setTimeout> | undefined;\n /**\n * Сетевая игра: мир общий, и скрытая вкладка хоста или владельца измерения не может считать его\n * раз в секунду (браузер душит таймеры фоновой страницы до ~1 Гц, а тиков за шаг не больше\n * maxCatchupTicks — мир у ВСЕХ шёл бы вчетверо медленнее). Таймеры выделенного воркера так не\n * душатся: в фоне шаг задаёт его сообщение раз в 50 мс. Подключает Game, пока идёт сетевая игра.\n */\n backgroundTicker: (() => boolean) | null = null;\n private _worker: Worker | null = null;\n private _workerUrl: string | null = null;\n private _workerStep: (() => void) | null = null;\n\n /**\n * @param onTick — фиксированный шаг, dt = 1/TPS\n * @param onFrame — alpha = доля тика для интерполяции\n */\n constructor(\n onTick: (dt: number, n: number) => void,\n onFrame: (dt: number, alpha: number) => void,\n ) {\n this.onTick = onTick;\n this.onFrame = onFrame;\n this.tickDt = 1 / CONFIG.tps;\n this.acc = 0;\n this.tickN = 0;\n this.last = 0;\n this.running = false;\n // FPS: скользящее окно за секунду\n this.fps = 0;\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n start() {\n this.running = true;\n this.last = performance.now();\n const step = (now: number) => {\n if (!this.running) return;\n let dt = (now - this.last) / 1000;\n this.last = now;\n if (dt > 0.25) dt = 0.25; // возврат из фона — не наращивать гигантский долг\n\n this.acc += dt;\n let ticks = 0;\n while (this.acc >= this.tickDt && ticks < CONFIG.maxCatchupTicks) {\n this.onTick(this.tickDt, this.tickN++);\n this.acc -= this.tickDt;\n ticks++;\n }\n if (ticks === CONFIG.maxCatchupTicks) this.acc = 0; // сбросить долг\n\n this._frames++;\n this.frameN++;\n this._fpsTime += dt;\n if (this._fpsTime >= 0.5) {\n this.fps = Math.round(this._frames / this._fpsTime);\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n this.onFrame(Math.min(dt, 0.05), this.acc / this.tickDt);\n schedule();\n };\n const schedule = () => {\n if (typeof document !== \"undefined\" && document.hidden) {\n if (\n this.backgroundTicker?.() &&\n this._startWorker(() => step(performance.now()))\n )\n return;\n this._timer = setTimeout(() => step(performance.now()), 50);\n return;\n }\n this._workerStep = null;\n // Видимая, но неотрисовываемая страница (панель превью за другой вкладкой, iframe вне\n // экрана) не зовёт rAF и при document.hidden === false. Страховочный таймер шагает сам,\n // если кадр не пришёл за 200 мс; кто сработал первым — шагает, второй отменяется.\n this._raf = requestAnimationFrame((now) => {\n clearTimeout(this._timer);\n step(now);\n });\n this._timer = setTimeout(() => {\n cancelAnimationFrame(this._raf);\n step(performance.now());\n }, 200);\n };\n schedule();\n }\n\n stop() {\n this.running = false;\n // Уже запланированный кадр отменяем явно: если start() придёт раньше, чем он сработает\n // (suspend+activate в одном кадре), он увидел бы running=true и продолжил бы СВОЮ цепочку\n // рядом с новой — симуляция пошла бы вдвое быстрее.\n cancelAnimationFrame(this._raf);\n clearTimeout(this._timer);\n this._workerStep = null;\n this._worker?.terminate();\n this._worker = null;\n if (this._workerUrl) URL.revokeObjectURL(this._workerUrl);\n this._workerUrl = null;\n }\n\n /**\n * Следующий шаг скрытой страницы — по сообщению воркера. Воркер тикает всегда, пока жив; шаг\n * берёт только один ожидающий (`_workerStep`), поэтому двойной цепочки не бывает. false —\n * воркер не завести (CSP без blob:, нет Worker) — остаётся обычный таймер.\n */\n private _startWorker(next: () => void): boolean {\n try {\n if (!this._worker) {\n const src = \"setInterval(function(){postMessage(0)},50)\";\n this._workerUrl = URL.createObjectURL(\n new Blob([src], { type: \"text/javascript\" }),\n );\n this._worker = new Worker(this._workerUrl);\n this._worker.onmessage = () => {\n const fn = this._workerStep;\n this._workerStep = null;\n fn?.();\n };\n }\n this._workerStep = next;\n return true;\n } catch {\n return false;\n }\n }\n}\n"
|
|
100
105
|
},
|
|
101
106
|
{
|
|
102
107
|
"path": "core/Intents.ts",
|
|
@@ -188,7 +193,7 @@
|
|
|
188
193
|
},
|
|
189
194
|
{
|
|
190
195
|
"path": "entities/EntityManager.ts",
|
|
191
|
-
"content": "// Менеджер сущностей: мобы, дроп-предметы, сферы опыта, стрелы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Перепись (census) держит население мобов вокруг игрока\n// по правилам Minecraft: мирные — на траве при свете, враждебные — в темноте (ночью и в пещерах).\n// Игрок хранится отдельно (Player), но интерфейс сущностей совместим — в этапе 2 удалённые игроки\n// станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { frogFor } from \"../registry/mobs/frog\";\nimport { dropsOf } from \"../systems/Mining\";\nimport { explodeBlocks, explosionDamage } from \"../systems/Explosion\";\nimport { rayBox } from \"../physics/rayBox\";\nimport { raycastBlocks } from \"../world/Raycast\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport { XpOrb } from \"./XpOrb\";\nimport { Arrow } from \"./Arrow\";\nimport { tippedEffect } from \"../systems/TippedArrow\";\nimport { PrimedTnt } from \"./PrimedTnt\";\nimport { Fireball, type FireballKind } from \"./Fireball\";\nimport { RimEye } from \"./RimEye\";\nimport { CART_VARIANTS, Minecart, type CartVariant } from \"./Minecart\";\nimport { Fang } from \"./Fang\";\nimport { extractOne, insertOne } from \"../world/Containers\";\nimport { HOPPER_COOLDOWN } from \"../world/blockentities/hopper\";\nimport { smokedHive } from \"../world/Hive\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { Boat } from \"./Boat\";\nimport { FishingHook } from \"./FishingHook\";\nimport { INFESTED_NAMES } from \"../registry/blocks/infested\";\nimport { findAltar } from \"./AshLord\";\nimport { startCharge } from \"./AshLordBrain\";\nimport { isSnowGolem } from \"./SnowGolemBrain\";\nimport { findJob, jobAt } from \"../systems/Jobs\";\nimport { ZOMBIE_VILLAGER, curedTypeOf } from \"../systems/Zombify\";\nimport {\n GOLEM_COOLDOWN,\n GOLEM_RADIUS,\n isIronGolem,\n shouldSummonGolem,\n} from \"../systems/Golem\";\nimport {\n REP_CURE,\n REP_HIT_VILLAGER,\n REP_TRADE,\n clampRep,\n} from \"../systems/Reputation\";\n\n/** Секунд между пополнениями товара за работой у своего блока. */\nconst VILLAGER_RESTOCK = 120;\n\n/** id заражённых блоков — из них вылезает чешуйница. */\nconst INFESTED_IDS = new Set(\n INFESTED_NAMES.map((n) => B[n]).filter(\n (id): id is number => id !== undefined,\n ),\n);\nimport type { MobCtx } from \"./MobBrain\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { ItemStack, MobDef } from \"../types\";\n\ntype Entity =\n | Mob\n | ItemDrop\n | XpOrb\n | Arrow\n | PrimedTnt\n | Fireball\n | RimEye\n | Minecart\n | Boat\n | FishingHook\n | Fang;\n\n// Форма сохранённой сущности (выход serialize()) — дискриминируется по `kind`. Стрелы не сохраняются.\ntype EntitySave =\n | {\n kind: \"mob\";\n type: string;\n pos: [number, number, number];\n hp: number;\n baby?: boolean;\n sheared?: boolean;\n /** Житель: использованные сделки до пополнения. */\n uses?: number[];\n /** Житель: занятый рабочий блок и «уже торговал» (systems/Jobs). */\n jobSite?: [number, number, number];\n traded?: boolean;\n /** Житель: опыт профессии (уровни сделок). */\n villagerXp?: number;\n /** Зомби-житель: кем он был и сколько осталось лечиться (systems/Zombify). */\n curedType?: string;\n curing?: number;\n tamed?: boolean;\n /** Имя с бирки (registry/items/nametag). */\n customName?: string;\n sitting?: boolean;\n saddled?: boolean;\n /** Овца: цвет шерсти (нет поля — белая). */\n woolColor?: string;\n /** Волк: запас брони из щитков. */\n wolfArmor?: number;\n /** Дух-помощник: образец и собранное. */\n carry?: string;\n bag?: number;\n /** Вьюк осла, мула, ламы; на поводке. */\n items?: (ItemStack | null)[];\n leashed?: boolean;\n leashPost?: [number, number, number];\n /** Секунд до ухода странствующего торговца и его лам. */\n leaveT?: number;\n /** Пчела: свой улей и пыльца. */\n home?: [number, number, number];\n pollen?: boolean;\n /** Капитан мародёров. */\n captain?: boolean;\n }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n dmg?: number;\n /** Зачарования, вещи панцирного сундука. */\n tag?: Record<string, unknown>;\n }\n | { kind: \"xp\"; pos: [number, number, number]; value: number }\n | {\n kind: \"minecart\";\n pos: [number, number, number];\n vel?: [number, number, number];\n /** Вид (нет — обычная) и груз вагонетки с сундуком или воронкой. */\n variant?: CartVariant;\n items?: (ItemStack | null)[];\n }\n | { kind: \"boat\"; pos: [number, number, number]; wood?: string };\n\nconst randInt = (a: number, b: number): number =>\n a + Math.floor(Math.random() * (b - a + 1));\n\nexport class EntityManager {\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Entity>;\n _nextId: number;\n _spawnTimer: number;\n /** На сколько уровней ночь гасит небесный свет и день ли сейчас — задаёт Game из DayNight. */\n skyDarken = 0;\n isDay = true;\n /** Идёт дождь (Game из systems/Weather). */\n raining = false;\n /** Игрок не спал три ночи — прилетают фантомы (Game.daysAwake). */\n insomnia = false;\n /** Секунд до следующего патруля мародёров. */\n private _patrolT = 300 + Math.random() * 300;\n\n constructor(\n scene: THREE.Scene,\n world: ChunkManager,\n atlas: TextureAtlas,\n player: Player,\n inventory: Inventory,\n ) {\n this.scene = scene;\n this.world = world;\n this.atlas = atlas;\n this.player = player;\n this.inventory = inventory;\n this.entities = new Map();\n this._nextId = 1;\n this._spawnTimer = 0;\n // TNT, подожжённый огнём (поведение огня не знает про сущности — просит событием)\n events.on(\n \"tntPrime\",\n (d: { x: number; y: number; z: number; fuse?: number }) =>\n this.spawnTnt(d.x + 0.5, d.y, d.z + 0.5, d.fuse ?? 4),\n );\n // вагонетку поставили на рельс (предмет minecart — world/behaviours/rails)\n events.on(\n \"spawnMinecart\",\n (d: { x: number; y: number; z: number; variant?: CartVariant }) =>\n this.spawnMinecart(d.x + 0.5, d.y, d.z + 0.5, d.variant),\n );\n events.on(\n \"spawnBoat\",\n (d: { x: number; y: number; z: number; wood: string }) =>\n this.spawnBoat(d.x + 0.5, d.y, d.z + 0.5, d.wood),\n );\n // из заражённого блока вылезает чешуйница (registry/blocks/infested)\n events.on(\n \"blockBreak\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (INFESTED_IDS.has(d.id) && MOBS.silverfish)\n this.spawnMob(\"silverfish\", d.x + 0.5, d.y, d.z + 0.5);\n },\n );\n // мёд собрали — пчёлы в 16 блоках от улья злятся\n events.on(\"hiveHarvest\", (d: { x: number; y: number; z: number }) => {\n if (smokedHive(this.world, d.x, d.y, d.z)) return; // дым костра под ульем их успокаивает\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e.def.bee &&\n !e.dead &&\n e.pos.distanceTo(new THREE.Vector3(d.x, d.y, d.z)) < 16\n )\n e.aggro = true;\n });\n // черепашье яйцо вылупилось (world/behaviours/ocean); выросший черепашонок роняет щиток\n events.on(\"turtleHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.turtle) this.spawnMob(\"turtle\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n events.on(\"mobGrew\", ({ entity }: { entity: Mob }) => {\n const item = entity.def.growDrop;\n if (item && ITEMS[item])\n this.spawnDrop(entity.pos.x, entity.pos.y + 0.3, entity.pos.z, item, 1);\n });\n // яйцо нюхача треснуло — детёныш нюхача\n events.on(\"snifferHatch\", (d: { x: number; y: number; z: number }) => {\n if (MOBS.sniffer)\n this.spawnMob(\"sniffer\", d.x + 0.5, d.y, d.z + 0.5, true);\n });\n // лягушачья икра — 2–6 головастиков в воду под ней; головастик вырос — лягушка по биому\n events.on(\"frogspawnHatch\", (d: { x: number; y: number; z: number }) => {\n if (!MOBS.tadpole) return;\n for (let i = randInt(2, 6); i > 0; i--)\n this.spawnMob(\n \"tadpole\",\n d.x + 0.3 + Math.random() * 0.4,\n d.y - 0.8,\n d.z + 0.3 + Math.random() * 0.4,\n );\n });\n events.on(\"mobMature\", ({ entity }: { entity: Mob }) => {\n const into =\n entity.def.growsInto === \"frog\"\n ? frogFor(this.world.generator.biomeOf?.(entity.pos.x, entity.pos.z))\n : entity.def.growsInto;\n if (!into || !MOBS[into] || entity.dead) return;\n this.spawnMob(into, entity.pos.x, entity.pos.y, entity.pos.z);\n entity.dead = true; // hp > 0 — без дропа: превратился\n });\n // третий пепельный череп на Т из песка душ — Пепельный владыка (entities/AshLord)\n events.on(\n \"blockPlace\",\n (d: { x: number; y: number; z: number; id: number }) => {\n if (d.id === B.ash_skull) this.summonAtAltar(d.x, d.y, d.z);\n if (d.id === B.pumpkin)\n // тыква достраивает снеговика или железного голема (systems/Golem)\n if (!this.buildSnowGolem(d.x, d.y, d.z))\n this.buildIronGolem(d.x, d.y, d.z);\n },\n );\n // ручные волки: на того, кого бьёт игрок, и на того, кто бьёт игрока\n events.on(\"playerHitMob\", ({ entity }: { entity: Mob }) => {\n this.guard(entity);\n this.villageAnger(entity);\n });\n // деревня помнит добро: торговля и вылеченный зомби-житель поднимают репутацию\n events.on(\"trade\", () => this._addRep(REP_TRADE));\n events.on(\"villagerCured\", () => this._addRep(REP_CURE));\n // колокол: налётчики вокруг светятся, как в Minecraft\n events.on(\"bellRing\", (p: { x: number; y: number; z: number }) =>\n this.markRaiders(p.x, p.y, p.z),\n );\n events.on(\"mobAttack\", ({ entity }: { entity: Mob }) => this.guard(entity));\n }\n\n /** Убрать всех враждебных мобов (переход на мирную сложность — systems/Difficulty). */\n clearHostiles(): void {\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.category === \"hostile\" && !e.def.boss)\n e.dead = true; // hp > 0 — исчезают без дропа, как при удалении игрока\n }\n\n /** Сдвинуть репутацию игрока в деревне и сказать об этом HUD. */\n private _addRep(by: number): void {\n this.player.villageRep = clampRep(this.player.villageRep + by);\n events.emit(\"villageRep\", { rep: this.player.villageRep });\n }\n\n /**\n * Ударили жителя: репутация падает, а железные големы рядом бросаются на обидчика — как в\n * Minecraft, где деревня помнит зло (systems/Reputation).\n */\n villageAnger(target: Mob): void {\n if (!target.def.trades || target.dead) return;\n this._addRep(REP_HIT_VILLAGER);\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === \"iron_golem\" &&\n e.pos.distanceTo(target.pos) < GOLEM_RADIUS\n )\n e.aggro = true;\n }\n\n /** Звон колокола: налётчики в 32 блоках светятся 3 секунды (видно сквозь стены). */\n markRaiders(x: number, y: number, z: number): void {\n const at = new THREE.Vector3(x + 0.5, y + 0.5, z + 0.5);\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.raider && e.pos.distanceTo(at) < 32)\n e.glowT = 3;\n }\n\n /** Череп в (x,y,z) достроил алтарь — блоки исчезают, встаёт владыка и набирает силу. */\n summonAtAltar(x: number, y: number, z: number): Mob | null {\n const a = findAltar(\n (bx, by, bz) => this.world.getBlock(bx, by, bz),\n x,\n y,\n z,\n );\n if (!a || !MOBS.ash_lord) return null;\n for (const [cx, cy, cz] of a.cells) this.world.setBlock(cx, cy, cz, B.air!);\n const m = this.spawnMob(\"ash_lord\", a.x, a.y, a.z);\n startCharge(m);\n events.emit(\"ashLordSummoned\", { x: a.x, y: a.y, z: a.z });\n return m;\n }\n\n /** Железный голем: тыква на фигуре из четырёх железных блоков — блоки исчезают, встаёт голем. */\n buildIronGolem(x: number, y: number, z: number): Mob | null {\n const get = (bx: number, by: number, bz: number) =>\n this.world.getBlock(bx, by, bz);\n if (!MOBS.iron_golem || !isIronGolem(get, x, y, z)) return null;\n const armsX = get(x - 1, y - 1, z) === B.iron_block;\n for (const [cx, cy, cz] of [\n [x, y, z],\n [x, y - 1, z],\n [x, y - 2, z],\n armsX ? [x - 1, y - 1, z] : [x, y - 1, z - 1],\n armsX ? [x + 1, y - 1, z] : [x, y - 1, z + 1],\n ] as [number, number, number][])\n this.world.setBlock(cx, cy, cz, B.air!);\n const m = this.spawnMob(\"iron_golem\", x + 0.5, y - 2, z + 0.5);\n events.emit(\"ironGolemBuilt\", { x, y, z });\n return m;\n }\n\n /** Снеговик: тыква на двух блоках снега — блоки исчезают, на их месте снеговик. */\n buildSnowGolem(x: number, y: number, z: number): Mob | null {\n if (\n !MOBS.snow_golem ||\n !isSnowGolem((bx, by, bz) => this.world.getBlock(bx, by, bz), x, y, z)\n )\n return null;\n for (let k = 0; k < 3; k++) this.world.setBlock(x, y - k, z, B.air!);\n const m = this.spawnMob(\"snow_golem\", x + 0.5, y - 2, z + 0.5);\n events.emit(\"snowGolemBuilt\", { x, y, z });\n return m;\n }\n\n /** Клыки ворожея: 16 штук по прямой от from к to через 1.25 блока, каждый позже предыдущего. */\n spawnFangs(from: THREE.Vector3, to: THREE.Vector3, ownerId: number): void {\n const dir = to.clone().sub(from).setY(0);\n const len = dir.length();\n if (len < 1e-3) return;\n dir.divideScalar(len);\n for (let i = 1; i <= 16; i++) {\n const x = from.x + dir.x * i * 1.25,\n z = from.z + dir.z * i * 1.25;\n // пол у линии: твёрдый блок в нескольких блоках от высоты ворожея\n let y = Math.floor(from.y) + 2;\n for (\n let k = 0;\n k < 6 &&\n !BLOCKS[this.world.getBlock(Math.floor(x), y - 1, Math.floor(z))]\n ?.solid;\n k++\n )\n y--;\n const f = new Fang(this._nextId++, x, y, z, i * 0.05, ownerId);\n this.entities.set(f.id, f);\n this.scene.add(f.mesh);\n }\n }\n\n /** До следующей попытки прихода странствующего торговца, сек. */\n private _traderT = 1200;\n /** Перезарядка вызова железного голема деревней (systems/Golem). */\n private _golemT = 0;\n\n /** Странствующий торговец с двумя ламами на поводке — на суше в 16–32 блоках от игрока. */\n spawnWanderingTrader(): Mob | null {\n if (this.world.dimension !== \"overworld\" || !MOBS.wandering_trader)\n return null;\n const p = this.player.pos;\n for (let k = 0; k < 8; k++) {\n const a = Math.random() * Math.PI * 2,\n r = 16 + Math.random() * 16;\n const x = Math.floor(p.x + Math.cos(a) * r),\n z = Math.floor(p.z + Math.sin(a) * r);\n const top = this.world.surfaceY(x, z);\n const g = BLOCKS[this.world.getBlock(x, top, z)];\n if (\n !g?.solid ||\n g.liquid ||\n this.world.getBlock(x, top + 1, z) !== 0 ||\n this.world.getBlock(x, top + 2, z) !== 0\n )\n continue;\n const t = this.spawnMob(\"wandering_trader\", x + 0.5, top + 1, z + 0.5);\n for (const dx of [-1.5, 1.5]) {\n const l = this.spawnMob(\"trader_llama\", x + 0.5 + dx, top + 1, z + 0.5);\n l.leashed = true;\n l.leashMobId = t.id;\n }\n events.emit(\"traderArrived\", { x, y: top + 1, z });\n return t;\n }\n return null;\n }\n\n /** Патруль мародёров: 2–5 во главе с капитаном на суше в 24–48 блоках от игрока. */\n spawnPatrol(): number {\n if (this.world.dimension !== \"overworld\" || !MOBS.marauder) return 0;\n const p = this.player.pos;\n const a = Math.random() * Math.PI * 2,\n r = 24 + Math.random() * 24;\n const x = Math.floor(p.x + Math.cos(a) * r),\n z = Math.floor(p.z + Math.sin(a) * r);\n const top = this.world.surfaceY(x, z);\n if (\n !BLOCKS[this.world.getBlock(x, top, z)]?.solid ||\n this.world.getBlock(x, top + 1, z) !== 0\n )\n return 0;\n const n = randInt(2, 5);\n for (let i = 0; i < n; i++) {\n const sx = x + (i ? randInt(-2, 2) : 0),\n sz = z + (i ? randInt(-2, 2) : 0);\n const m = this.spawnMob(\n \"marauder\",\n sx + 0.5,\n this.world.surfaceY(sx, sz) + 1.01,\n sz + 0.5,\n );\n if (i === 0) m.captain = true;\n }\n events.emit(\"patrolSpawned\", { x, z, n });\n return n;\n }\n\n /** Черепаха после размножения откладывает 1–4 яйца на песок рядом. */\n layEggs(m: Mob): number {\n const egg = B[m.def.layEggs ?? \"\"];\n if (egg === undefined) return 0;\n // лягушка — одну икру на воду, нюхач — одно яйцо на любой твёрдый блок, черепаха — 1–4 яйца на песок\n const spawn = m.def.layEggs === \"frogspawn\",\n sniffer = m.def.layEggs === \"sniffer_egg\";\n const baseOk = (id: number): boolean =>\n spawn\n ? id === B.water\n : sniffer\n ? !!BLOCKS[id]?.solid && BLOCKS[id]?.shape === \"cube\"\n : id === B.sand;\n let left = spawn || sniffer ? 1 : randInt(1, 4);\n const x0 = Math.floor(m.pos.x),\n y0 = Math.floor(m.pos.y),\n z0 = Math.floor(m.pos.z);\n for (let dx = -2; dx <= 2 && left > 0; dx++)\n for (let dz = -2; dz <= 2 && left > 0; dz++)\n for (let dy = -1; dy <= 1 && left > 0; dy++) {\n const x = x0 + dx,\n y = y0 + dy,\n z = z0 + dz;\n if (\n !baseOk(this.world.getBlock(x, y - 1, z)) ||\n this.world.getBlock(x, y, z) !== B.air\n )\n continue;\n this.world.setBlock(x, y, z, egg, 0);\n left--;\n }\n events.emit(\"eggsLaid\", { entity: m });\n return left;\n }\n\n /** Вода в квадрате r вокруг клетки (на её высоте и выше на блок) — пляж, а не пустыня. */\n /** Есть ли блок id в кубе радиуса r (аксолотль — мох пышных пещер рядом с озером). */\n private _blockNear(\n x: number,\n y: number,\n z: number,\n id: number | undefined,\n r: number,\n ): boolean {\n if (id === undefined) return false;\n for (let dy = -r; dy <= r; dy++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === id) return true;\n return false;\n }\n\n private _waterNear(x: number, y: number, z: number, r: number): boolean {\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++)\n for (let dy = 0; dy <= 1; dy++)\n if (this.world.getBlock(x + dx, y + dy, z + dz) === B.water)\n return true;\n return false;\n }\n\n /** Выпустить пчелу из улья (x,y,z) в свободную соседнюю клетку; false — места нет. */\n spawnBee(x: number, y: number, z: number): boolean {\n for (const [dx, dy, dz] of [\n [0, 0, -1],\n [1, 0, 0],\n [0, 0, 1],\n [-1, 0, 0],\n [0, 1, 0],\n [0, -1, 0],\n ] as const) {\n if (this.world.getBlock(x + dx, y + dy, z + dz) !== 0) continue;\n const b = this.spawnMob(\"bee\", x + dx + 0.5, y + dy + 0.1, z + dz + 0.5);\n b.home = [x, y, z];\n return true;\n }\n return false;\n }\n\n /** Ручные волки рядом с игроком (24 блока) бросаются на `target`. */\n guard(target: Mob): void {\n if (target.tamed || target.dead) return;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n e !== target &&\n e.tamed &&\n e.def.guards &&\n !e.sitting &&\n !e.dead &&\n e.pos.distanceTo(this.player.pos) < 24\n )\n e.targetId = target.id;\n }\n\n /** Груз вагонетки как контейнер (воронка под рельсом и в вагонетку, экран сундука). */\n /** Груз вагонетки или вьюк моба — как контейнер для экрана сундука. */\n cartBox(c: Minecart | Mob): BlockEntity {\n return {\n type:\n c.kind === \"minecart\" && c.variant === \"hopper\" ? \"hopper\" : \"chest\",\n x: Math.floor(c.pos.x),\n y: Math.floor(c.pos.y),\n z: Math.floor(c.pos.z),\n state: { items: c.items },\n };\n }\n\n /** Вагонетка с грузом в клетке (x, y, z) — как контейнер; null — нет такой. */\n cartItems(x: number, y: number, z: number): BlockEntity | null {\n for (const e of this.entities.values())\n if (\n e.kind === \"minecart\" &&\n !e.dead &&\n e.items.length &&\n Math.floor(e.pos.x) === x &&\n Math.floor(e.pos.y) === y &&\n Math.floor(e.pos.z) === z\n )\n return this.cartBox(e);\n return null;\n }\n\n /**\n * Работа вагонетки за тик: динамит взрывается, запитанные активирующие рельсы высаживают седока и\n * запирают воронку; вагонетка с воронкой берёт из контейнера над собой или лежащие рядом предметы.\n */\n private _cartWork(c: Minecart, dt: number): void {\n if (c.boom) {\n c.dead = true;\n this.explode(c.pos.x, c.pos.y + 0.5, c.pos.z, 4);\n return;\n }\n if (c.onActivator && c.rider && this.player.riding === c)\n this.player.dismount();\n if (c.variant !== \"hopper\" || c.onActivator) return;\n c.hopCd -= dt;\n if (c.hopCd > 0) return;\n c.hopCd = HOPPER_COOLDOWN;\n const box = this.cartBox(c);\n const into = (s: ItemStack) => insertOne(box, s, \"top\");\n const above = this.world.containerAt?.(box.x, box.y + 1, box.z);\n if (above) extractOne(above, into);\n else\n this.takeDrop(\n c.pos.x - 0.7,\n c.pos.y - 0.2,\n c.pos.z - 0.7,\n c.pos.x + 0.7,\n c.pos.y + 1.2,\n c.pos.z + 0.7,\n into,\n );\n }\n\n spawnMinecart(\n x: number,\n y: number,\n z: number,\n variant: CartVariant = \"plain\",\n ): Minecart {\n const c = new Minecart(this._nextId++, x, y, z, variant);\n this.entities.set(c.id, c);\n this.scene.add(c.mesh);\n return c;\n }\n\n spawnBoat(x: number, y: number, z: number, wood: string): Boat {\n const b = new Boat(this._nextId++, x, y, z, wood);\n this.entities.set(b.id, b);\n this.scene.add(b.mesh);\n return b;\n }\n\n /** Забросить поплавок игрока (прежний, если был, исчезает). */\n castHook(pos: THREE.Vector3, vel: THREE.Vector3): FishingHook {\n const old = this.playerHook();\n if (old) old.dead = true;\n const h = new FishingHook(this._nextId++, pos, vel);\n this.entities.set(h.id, h);\n this.scene.add(h.mesh, h.line);\n return h;\n }\n\n /** Поплавок игрока в мире или null. */\n playerHook(): FishingHook | null {\n for (const e of this.entities.values())\n if (e.kind === \"hook\" && !e.dead) return e;\n return null;\n }\n\n /** Луч по транспорту (вагонетки, лодки): ближайший и расстояние до него. */\n raycastRide(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): { cart: Minecart | Boat; t: number } | null {\n let best: { cart: Minecart | Boat; t: number } | null = null;\n for (const e of this.entities.values()) {\n // своя вагонетка или лодка (в ней сидит игрок) лучу не мешает\n if ((e.kind !== \"minecart\" && e.kind !== \"boat\") || e.dead || e.rider)\n continue;\n const hw = e.kind === \"boat\" ? 0.7 : 0.49;\n const t = rayBox(\n origin,\n dir,\n best ? best.t : maxDist,\n { x: e.pos.x - hw, y: e.pos.y, z: e.pos.z - hw },\n {\n x: e.pos.x + hw,\n y: e.pos.y + (e.kind === \"boat\" ? 0.6 : 0.7),\n z: e.pos.z + hw,\n },\n );\n if (t !== null) best = { cart: e, t };\n }\n return best;\n }\n\n // ---- появление ------------------------------------------------------------------------\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n dmg?: number,\n tag?: Record<string, unknown>,\n ): ItemDrop {\n // слияние с ближайшим таким же дропом (инструменты с износом и вещи с tag не сливаются)\n const mergeable = !dmg && !tag && (ITEMS[item]?.maxStack ?? 64) > 1;\n for (const e of mergeable ? this.entities.values() : []) {\n if (\n e.kind === \"drop\" &&\n e.item === item &&\n !e.dead &&\n !e.dmg &&\n e.pos.distanceToSquared({ x, y, z }) < CONFIG.drops.mergeRadius ** 2\n ) {\n e.count += count;\n return e;\n }\n }\n // лимит: убиваем самый старый дроп (уже помеченные на удаление не считаем)\n const drops = [...this.entities.values()].filter(\n (e): e is ItemDrop => e.kind === \"drop\" && !e.dead,\n );\n if (drops.length >= CONFIG.drops.maxCount)\n drops.sort((a, b) => b.age - a.age)[0]!.dead = true;\n const drop = new ItemDrop(this._nextId++, this.atlas, x, y, z, item, count);\n if (dmg) drop.dmg = dmg;\n if (tag) drop.tag = tag;\n this.entities.set(drop.id, drop);\n this.scene.add(drop.mesh);\n return drop;\n }\n\n /** Воронка: забрать из лежащего в коробке дропа один предмет, если accept его взял. */\n takeDrop(\n x0: number,\n y0: number,\n z0: number,\n x1: number,\n y1: number,\n z1: number,\n accept: (s: ItemStack) => boolean,\n ): boolean {\n for (const e of this.entities.values()) {\n if (e.kind !== \"drop\" || e.dead) continue;\n const p = e.pos;\n if (p.x < x0 || p.x > x1 || p.y < y0 || p.y > y1 || p.z < z0 || p.z > z1)\n continue;\n if (!accept({ item: e.item, count: 1, ...(e.dmg ? { dmg: e.dmg } : {}) }))\n continue;\n e.count -= 1;\n if (e.count <= 0) e.dead = true;\n return true;\n }\n return false;\n }\n\n /** Выбрасыватель: стак со скоростью, поднять можно не сразу. */\n ejectItem(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n s: ItemStack,\n ): void {\n const d = this.spawnDrop(x, y, z, s.item, s.count, s.dmg, s.tag);\n d.vel.set(vx, vy, vz);\n d.pickupDelay = 1;\n }\n\n /** Раздатчик: стрела без стрелка; отравленная несёт свой эффект (systems/TippedArrow). */\n shootArrow(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n item = \"arrow\",\n ): void {\n const a = this.spawnArrow(\n new THREE.Vector3(x, y, z),\n new THREE.Vector3(vx, vy, vz),\n 3,\n false,\n -1,\n );\n if (item !== \"arrow\") {\n a.effect = tippedEffect(item);\n a.pick = { item, count: 1 };\n }\n }\n\n spawnMob(type: string, x: number, y: number, z: number, baby = false): Mob {\n const mob = new Mob(this._nextId++, MOBS[type]!, x, y, z, baby);\n this.entities.set(mob.id, mob);\n this.scene.add(mob.mesh);\n return mob;\n }\n\n /** Опыт сферами (крупные номиналы, как в Minecraft), не больше 10 сфер. */\n spawnXp(x: number, y: number, z: number, amount: number): void {\n let left = Math.round(amount);\n for (let n = 0; left > 0 && n < 10; n++) {\n const v =\n n === 9 ? left : left >= 17 ? 17 : left >= 7 ? 7 : left >= 3 ? 3 : 1;\n const orb = new XpOrb(this._nextId++, x, y, z, v);\n this.entities.set(orb.id, orb);\n this.scene.add(orb.mesh);\n left -= v;\n }\n }\n\n spawnArrow(\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n fromPlayer: boolean,\n shooterId: number,\n ): Arrow {\n const a = new Arrow(\n this._nextId++,\n pos,\n vel,\n damage,\n fromPlayer,\n shooterId,\n );\n this.entities.set(a.id, a);\n this.scene.add(a.mesh);\n return a;\n }\n\n /** Огненный шар моба Пекла. */\n spawnFireball(\n kind: FireballKind,\n pos: THREE.Vector3,\n vel: THREE.Vector3,\n damage: number,\n shooterId: number,\n ): Fireball {\n const f = new Fireball(this._nextId++, kind, pos, vel, damage, shooterId);\n this.entities.set(f.id, f);\n this.scene.add(f.mesh);\n return f;\n }\n\n /** Брошенное Око Предела — летит к цели (цитадели). */\n spawnRimEye(from: THREE.Vector3, target: { x: number; z: number }): RimEye {\n const e = new RimEye(this._nextId++, from, target);\n this.entities.set(e.id, e);\n this.scene.add(e.mesh);\n events.emit(\"eyeThrown\", { x: from.x, y: from.y, z: from.z });\n return e;\n }\n\n /** Ударили моба со стайной злостью — злятся все такие же в 32 блоках. */\n private _angerGroup(m: Mob): void {\n m.angerCall = false;\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === m.def.name &&\n e.pos.distanceTo(m.pos) < 32\n )\n e.aggro = true;\n }\n\n /** Взведённая взрывчатка (огниво, огонь, соседний взрыв). */\n spawnTnt(x: number, y: number, z: number, fuse: number): PrimedTnt {\n const t = new PrimedTnt(this._nextId++, this.atlas, x, y, z, fuse);\n this.entities.set(t.id, t);\n this.scene.add(t.mesh);\n events.emit(\"tntFuse\", { x, y, z });\n return t;\n }\n\n /** Удар игрока по мобу. */\n hurt(entityId: number, damage: number, fromPos: THREE.Vector3): void {\n const e = this.entities.get(entityId);\n if (!e || e.kind !== \"mob\" || e.dead) return;\n // дракон: по голове — полный урон, по телу — четверть (как в Minecraft)\n if (e.def.boss === \"dragon\" && !this._headHit(e))\n damage = Math.max(1, damage / 4);\n e.hurt(damage, fromPos, true);\n // шипы глубинника: бьёшь, пока он не заряжает луч, — укол в ответ\n if (e.def.thorns && e.beamT <= 0)\n this.player.damage(e.def.thorns, \"thorns\");\n }\n\n /** Луч взгляда игрока проходит у головы дракона (перед центром, на половине ширины). */\n private _headHit(m: Mob): boolean {\n const fwd = new THREE.Vector3(-Math.sin(m.yaw), 0, -Math.cos(m.yaw));\n const head = m.pos\n .clone()\n .addScaledVector(fwd, m.width * 0.5)\n .setY(m.pos.y + m.height * 0.6);\n const eye = this.player.eyePos,\n dir = this.player.lookDir();\n const t = Math.max(0, head.clone().sub(eye).dot(dir));\n return eye.clone().addScaledVector(dir, t).distanceTo(head) < 2.5;\n }\n\n // ---- взрыв -------------------------------------------------------------------------------\n\n /** Взрыв силы power: блоки (дроп с шансом 1/сила), урон и отброс игроку, мобам и предметам. */\n explode(x: number, y: number, z: number, power: number): void {\n const destroyed = explodeBlocks(this.world, x, y, z, power);\n for (const b of destroyed)\n // соседняя взрывчатка взводится с коротким фитилём — цепная реакция\n if (b.id === B.tnt)\n this.spawnTnt(b.x + 0.5, b.y, b.z + 0.5, 0.5 + Math.random());\n else if (Math.random() < 1 / power)\n for (const d of dropsOf(BLOCKS[b.id]!, b.meta))\n if (ITEMS[d.item])\n this.spawnDrop(b.x + 0.5, b.y + 0.5, b.z + 0.5, d.item, d.n);\n\n const center = new THREE.Vector3(x, y, z);\n const R = power * 2;\n const blast = (\n pos: THREE.Vector3,\n h: number,\n apply: (dmg: number, dir: THREE.Vector3, impact: number) => void,\n ) => {\n const c = new THREE.Vector3(pos.x, pos.y + h / 2, pos.z);\n const dist = c.distanceTo(center);\n if (dist >= R) return;\n const dir = c.clone().sub(center);\n const len = dir.length();\n if (len > 1e-4) dir.divideScalar(len);\n else dir.set(0, 1, 0);\n const exposure =\n len > 1e-4 && raycastBlocks(this.world, center, dir, len) ? 0.3 : 1;\n const impact = (1 - dist / R) * exposure;\n apply(explosionDamage(impact, power), dir, impact);\n };\n blast(this.player.pos, 1.8, (dmg, dir, impact) => {\n this.player.damage(dmg, \"explosion\");\n this.player.vel.addScaledVector(dir, impact * 12);\n });\n for (const e of this.entities.values()) {\n if (e.kind === \"mob\" && !e.dead)\n blast(e.pos, e.height, (dmg, dir, impact) => {\n e.hurt(dmg, center, false);\n e.vel.addScaledVector(dir, impact * 12);\n });\n else if (e.kind === \"drop\" || e.kind === \"tnt\")\n blast(e.pos, 0.5, (_d, dir, impact) =>\n e.vel.addScaledVector(dir, impact * 10),\n );\n }\n events.emit(\"explosion\", { x, y, z, power });\n }\n\n // ---- симуляция ----------------------------------------------------------------------------\n\n private _ctx(): MobCtx {\n const held = this.inventory.heldItem();\n return {\n world: this.world,\n player: this.player,\n isDay: this.isDay,\n skyDarken: this.skyDarken,\n raining: this.raining,\n heldItem: held ? held.item : null,\n shoot: (from, to, speed, damage, shooter, kind = \"arrow\") => {\n if (kind !== \"arrow\" && kind !== \"potion\" && kind !== \"snowball\") {\n // огненный шар летит по прямой — без поправки на падение\n const dir = to.clone().sub(from).normalize();\n this.spawnFireball(\n kind,\n from.clone().addScaledVector(dir, 0.8),\n dir.multiplyScalar(speed),\n damage,\n shooter.id,\n );\n events.emit(\"mobShoot\", { entity: shooter });\n return;\n }\n const d = to.clone().sub(from);\n const dist = d.length();\n const t = dist / speed;\n d.y += 0.5 * 20 * t * t; // поправка на падение стрелы\n d.x += (Math.random() - 0.5) * dist * 0.08;\n d.z += (Math.random() - 0.5) * dist * 0.08;\n d.normalize();\n if (kind === \"potion\" || kind === \"snowball\")\n // зелье ведьмы и снежок снеговика — дугой, как стрела (та же поправка на падение)\n this.spawnFireball(\n kind,\n from.clone().addScaledVector(d, 0.6),\n d.clone().multiplyScalar(speed),\n damage,\n shooter.id,\n );\n else\n this.spawnArrow(\n from.clone().addScaledVector(d, 0.6),\n d.clone().multiplyScalar(speed),\n damage,\n false,\n shooter.id,\n ).effect = shooter.def.ranged?.effect ?? null; // стрела зимогора замедляет\n events.emit(\"mobShoot\", { entity: shooter });\n },\n convert: (villager) => this.convertToZombie(villager),\n crystals: () => {\n const out: Mob[] = [];\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.def.name === \"beyond_crystal\")\n out.push(e);\n return out;\n },\n explode: (x, y, z, p) => this.explode(x, y, z, p),\n summon: (type, x, y, z) =>\n MOBS[type] ? this.spawnMob(type, x, y, z) : null,\n fangs: (from, to, owner) => this.spawnFangs(from, to, owner.id),\n // владыка ломает блоки вокруг себя — с дропом; неломаемые (бедрок, порталы) и жидкости стоят\n breakBlock: (x, y, z) => {\n const id = this.world.getBlock(x, y, z);\n const def = BLOCKS[id];\n if (!id || !def || def.liquid || !Number.isFinite(def.hardness))\n return false;\n const meta = this.world.getMeta(x, y, z);\n this.world.setBlock(x, y, z, B.air!);\n for (const d of dropsOf(def, meta))\n if (ITEMS[d.item])\n this.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n return true;\n },\n mob: (id) => {\n const e = this.entities.get(id);\n return e && e.kind === \"mob\" && !e.dead ? e : null;\n },\n near: (pos, r, pred) => {\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.pos.distanceTo(pos) < r &&\n pred(e)\n )\n return e;\n return null;\n },\n nearDrop: (pos, r, item) => {\n let best: ItemDrop | null = null,\n bd = r * r;\n for (const e of this.entities.values()) {\n if (\n e.kind !== \"drop\" ||\n e.dead ||\n e.item !== item ||\n e.age < e.pickupDelay\n )\n continue;\n const d2 = e.pos.distanceToSquared(pos);\n if (d2 < bd) {\n bd = d2;\n best = e;\n }\n }\n return best ? { id: best.id, pos: best.pos } : null;\n },\n takeDrop: (id, max) => {\n const e = this.entities.get(id);\n if (!e || e.kind !== \"drop\" || e.dead) return 0;\n const n = Math.min(max, e.count);\n e.count -= n;\n if (e.count <= 0) e.dead = true;\n return n;\n },\n giveDrop: (x, y, z, item, count) => {\n this.spawnDrop(x, y, z, item, count);\n },\n findMate: (m) => {\n let best: Mob | null = null,\n bd = 64;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e === m || e.dead || e.baby) continue;\n // своя порода или указанная в mates (лошадь и осёл)\n if ((e.def !== m.def && !m.def.mates?.[e.def.name]) || e.loveT <= 0)\n continue;\n const d2 = e.pos.distanceToSquared(m.pos);\n if (d2 < bd) {\n bd = d2;\n best = e;\n }\n }\n return best;\n },\n breed: (a, b) => {\n a.loveT = b.loveT = 0;\n a.breedCd = b.breedCd = 300; // 5 минут, как в Minecraft\n if (a.def.layEggs)\n this.layEggs(a); // черепаха — яйцами на песок\n else\n this.spawnMob(\n // разные породы — детёныш из таблицы mates (лошадь + осёл → мул)\n a.def === b.def\n ? a.def.name\n : (a.def.mates?.[b.def.name] ?? a.def.name),\n (a.pos.x + b.pos.x) / 2,\n a.pos.y,\n (a.pos.z + b.pos.z) / 2,\n true,\n );\n this.spawnXp(a.pos.x, a.pos.y + 0.5, a.pos.z, randInt(1, 7));\n events.emit(\"mobBred\", { type: a.def.name });\n },\n };\n }\n\n tick(dt: number): void {\n const ctx = this._ctx();\n const mobs = [...this.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\",\n );\n for (const e of this.entities.values()) {\n if (e.kind === \"drop\")\n e.tick(dt, this.world, this.player, this.inventory);\n else if (e.kind === \"mob\") {\n e.tick(dt, ctx);\n if (e.angerCall) this._angerGroup(e);\n } else if (e.kind === \"xp\") e.tick(dt, this.world, this.player);\n else if (e.kind === \"tnt\")\n e.tick(dt, this.world, (x, y, z, p) => this.explode(x, y, z, p));\n else if (e.kind === \"fireball\")\n e.tick(\n dt,\n this.world,\n this.player,\n (x, y, z, p) => this.explode(x, y, z, p),\n mobs,\n );\n else if (e.kind === \"minecart\" || e.kind === \"boat\") {\n e.tick(dt, this.world);\n if (e.kind === \"minecart\") this._cartWork(e, dt);\n } else if (e.kind === \"hook\")\n e.tick(\n dt,\n this.world,\n this.player.pos,\n this.raining,\n this.inventory.heldItem()?.item === \"fishing_rod\",\n );\n else if (e.kind === \"rimeye\") {\n // долетело: в 80% падает предметом, иначе разбивается\n if (e.tick(dt)) {\n if (Math.random() < 0.8)\n this.spawnDrop(e.pos.x, e.pos.y, e.pos.z, \"rim_eye\", 1);\n else\n events.emit(\"eyeShatter\", { x: e.pos.x, y: e.pos.y, z: e.pos.z });\n }\n } else e.tick(dt, this.world, this.player, mobs, this.inventory);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0) this._mobDied(e);\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Смерть моба: дроп по таблице (у детёнышей нет) и опыт, если бил игрок. */\n private _mobDied(m: Mob): void {\n events.emit(\"mobDied\", { entity: m });\n // капитан мародёров от руки игрока — Дурное знамение, уровень растёт до 5 (как в Minecraft)\n if (m.captain && m.hitByPlayerT > 0) {\n const level = Math.min(\n 5,\n (this.player.effects.get(\"bad_omen\")?.level ?? 0) + 1,\n );\n this.player.addEffect(\"bad_omen\", 6000, level);\n events.emit(\"badOmen\", { level });\n }\n if (m.def.deathExplode) {\n // лечивший дракона кристалл, взорвавшись, ранит его на 10 (как в Minecraft)\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && e.def.boss && e.healFrom === m.id) {\n e.healFrom = null;\n e.damage(10);\n }\n this.explode(m.pos.x, m.pos.y + 1, m.pos.z, m.def.deathExplode);\n }\n // слизень распадается на слизней поменьше\n const split = m.def.splitsInto;\n if (split)\n for (let i = randInt(split.n[0], split.n[1]); i > 0; i--)\n this.spawnMob(\n split.mob,\n m.pos.x + (Math.random() - 0.5) * m.width,\n m.pos.y + 0.2,\n m.pos.z + (Math.random() - 0.5) * m.width,\n );\n const drop = (item: string, n: number) => {\n if (n > 0 && ITEMS[item])\n this.spawnDrop(m.pos.x, m.pos.y + 0.3, m.pos.z, item, n);\n };\n if (!m.baby) {\n for (const d of m.def.drops) {\n if (d.chance !== undefined && Math.random() > d.chance) continue;\n drop(d.item, d.max ? randInt(d.n, d.max) : d.n);\n }\n const s = m.def.shearDrop;\n if (s && !m.sheared) drop(m.woolItem(), s.n);\n if (m.carry) drop(m.carry.item, 1 + m.bag); // дух-помощник роняет образец и собранное\n // вьюк: вещи и сам сундук; поводок\n if (m.items.length) {\n drop(\"chest\", 1);\n for (const it of m.items) if (it) drop(it.item, it.count);\n }\n if (m.leashed) drop(\"lead\", 1);\n if (m.hitByPlayerT > 0 && m.def.xp)\n this.spawnXp(\n m.pos.x,\n m.pos.y + 0.5,\n m.pos.z,\n randInt(m.def.xp[0], m.def.xp[1]),\n );\n }\n }\n\n /**\n * Профессия от рабочего блока (systems/Jobs), раз в секунду: безработный житель занимает свободный\n * блок рядом и становится мастером; у кого рабочий блок сломали — теряет профессию, если ни разу\n * не торговал. Житель из деревни рабочего места не имеет и профессию не теряет, как в Minecraft.\n */\n private _villagerJobs(): void {\n const villagers: Mob[] = [];\n for (const e of this.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n (e.def.name === \"villager\" || e.def.name.startsWith(\"villager_\"))\n )\n villagers.push(e);\n if (!villagers.length) return;\n const claimed = villagers.flatMap((v) => (v.jobSite ? [v.jobSite] : []));\n const blockAt = (x: number, y: number, z: number): string =>\n BLOCKS[this.world.getBlock(x, y, z)]?.name ?? \"air\";\n for (const v of villagers) {\n const site = v.jobSite;\n if (v.def.trades) {\n // работа у своего блока пополняет товар (в Minecraft — дважды за день, здесь — по таймеру)\n if (site && v.uses.length) {\n v.workT -= 1;\n if (\n v.workT <= 0 &&\n Math.abs(v.pos.x - (site[0] + 0.5)) < 2.5 &&\n Math.abs(v.pos.z - (site[2] + 0.5)) < 2.5 &&\n Math.abs(v.pos.y - site[1]) < 3\n ) {\n v.uses = [];\n v.workT = VILLAGER_RESTOCK;\n events.emit(\"villagerRestock\", { mob: v.def.name });\n }\n }\n // рабочий блок сломали или подменили — обратно в безработные (торговавший остаётся мастером)\n if (\n site &&\n !v.traded &&\n jobAt(blockAt(site[0], site[1], site[2])) !== v.def.trades\n )\n this._reprofess(v, \"villager\", null);\n continue;\n }\n const found = findJob(\n blockAt,\n Math.floor(v.pos.x),\n Math.floor(v.pos.y),\n Math.floor(v.pos.z),\n (x, y, z) =>\n claimed.some((c) => c[0] === x && c[1] === y && c[2] === z),\n );\n if (!found) continue;\n claimed.push(found.site);\n this._reprofess(v, \"villager_\" + found.job, found.site);\n }\n }\n\n /**\n * Деревня зовёт голема (systems/Golem): рядом с жителями враждебные (ночная осада) — раз в\n * GOLEM_COOLDOWN секунд встаёт один железный голем. Так деревня отвечает на осаду сама.\n */\n private _villagerGolem(): void {\n if (this._golemT > 0) {\n this._golemT -= 1;\n return;\n }\n // ⚠ кандидатов перебираем всех: в мире живёт не одна кучка жителей, и у соседней деревни свой\n // голем уже есть — по первому встречному жителю вызов не сработал бы никогда.\n const mobs: Mob[] = [];\n for (const e of this.entities.values())\n if (e.kind === \"mob\" && !e.dead) mobs.push(e);\n for (const host of mobs) {\n if (!host.def.trades || host.baby) continue;\n let villagers = 0,\n golems = 0,\n danger = false;\n for (const e of mobs) {\n if (e.pos.distanceTo(host.pos) > GOLEM_RADIUS) continue;\n if (e.def.trades) villagers++;\n else if (e.def.name === \"iron_golem\") golems++;\n else if (e.def.category === \"hostile\") danger = true;\n }\n if (!shouldSummonGolem({ villagers, golems, danger, cooldown: 0 }))\n continue;\n const x = Math.floor(host.pos.x) + (Math.random() < 0.5 ? 2 : -2),\n z = Math.floor(host.pos.z) + (Math.random() < 0.5 ? 2 : -2);\n const y = this.world.surfaceY(x, z);\n if (y <= 0) continue;\n this.spawnMob(\"iron_golem\", x + 0.5, y + 1.01, z + 0.5);\n this._golemT = GOLEM_COOLDOWN;\n events.emit(\"golemSummoned\", { x, y, z });\n return;\n }\n }\n\n /** Лечение зомби-жителя (systems/Zombify): раз в секунду, на нуле — обратно в свою профессию. */\n private _tickCuring(): void {\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead || e.curing <= 0) continue;\n e.curing -= 1;\n if (e.curing > 0) continue;\n const back = this._reprofess(e, curedTypeOf(e.curedType), e.jobSite);\n events.emit(\"villagerCured\", { mob: back.def.name });\n }\n }\n\n /**\n * Житель, убитый зомби, встаёт зомби-жителем и помнит профессию (systems/Zombify).\n * Зовёт мозг зомби через MobCtx.convert.\n */\n convertToZombie(v: Mob): Mob {\n const z = this._reprofess(v, ZOMBIE_VILLAGER, v.jobSite);\n z.hp = z.def.hp;\n z.curedType = v.def.name;\n events.emit(\"villagerZombified\", { mob: v.def.name });\n return z;\n }\n\n /** Тот же житель на том же месте, но другим типом моба: типы жителей — по профессии. */\n private _reprofess(\n v: Mob,\n type: string,\n site: [number, number, number] | null,\n ): Mob {\n const n = this.spawnMob(type, v.pos.x, v.pos.y, v.pos.z, v.baby);\n n.hp = v.hp;\n n.yaw = v.yaw;\n n.jobSite = site;\n n.traded = v.traded;\n n.villagerXp = v.villagerXp; // опыт профессии переживает и смену профессии, и лечение\n v.dead = true; // hp > 0 — исчезает без дропа\n events.emit(\"villagerJob\", { mob: type });\n return n;\n }\n\n /** Перепись: враждебные вдали исчезают, население добирается попытками спавна. */\n _census(dt: number): void {\n this._spawnTimer -= dt;\n if (this._spawnTimer > 0) return;\n this._spawnTimer = 1;\n // патрули мародёров — раз в 5–10 минут, как в Minecraft\n this._patrolT -= 1;\n if (this._patrolT <= 0) {\n this._patrolT = 300 + Math.random() * 300;\n this.spawnPatrol();\n }\n // странствующий торговец — раз в 20 минут с шансом 25%, если его ещё нет (как в Minecraft)\n this._traderT -= 1;\n if (this._traderT <= 0) {\n this._traderT = 1200;\n const here = [...this.entities.values()].some(\n (e) => e.kind === \"mob\" && !e.dead && e.def.name === \"wandering_trader\",\n );\n if (!here && Math.random() < 0.25) this.spawnWanderingTrader();\n }\n this._villagerJobs();\n this._tickCuring();\n this._villagerGolem();\n const M = CONFIG.mobs;\n const p = this.player.pos;\n let passive = 0,\n hostile = 0;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead) continue;\n const d = e.pos.distanceTo(p);\n if (e.def.category === \"passive\") {\n if (d < M.countRadius) passive++;\n continue;\n }\n // голем деревни, ручные, названные биркой, набег и волна испытаний не исчезают\n if (\n e.def.persistent ||\n e.tamed ||\n e.customName ||\n e.raider ||\n e.trialFrom\n )\n continue;\n if (d > M.despawnRadius && !e.def.boss && !e.def.stationary) {\n e.dead = true; // hp > 0 — без дропа\n continue;\n }\n if (d < M.countRadius) hostile++;\n }\n for (let a = 0; a < M.spawnAttempts; a++) {\n const wantHostile =\n hostile < M.hostileCap &&\n (passive >= M.passiveCap || Math.random() < 0.75);\n if (!wantHostile && passive >= M.passiveCap) return;\n const n = this._trySpawn(wantHostile ? \"dark\" : \"bright\");\n if (wantHostile) hostile += n;\n else passive += n;\n }\n }\n\n private _pickType(light: \"bright\" | \"dark\"): MobDef | null {\n const dim = this.world.dimension;\n const list = Object.values(MOBS).filter(\n (d) =>\n d.spawn &&\n (d.spawn.light === light ||\n (light === \"dark\" && d.spawn.light === \"any\")) &&\n (d.spawn.dimension ?? \"overworld\") === dim,\n );\n let total = 0;\n for (const d of list) total += d.spawn!.weight;\n let r = Math.random() * total;\n for (const d of list) {\n r -= d.spawn!.weight;\n if (r <= 0) return d;\n }\n return list[0] ?? null;\n }\n\n private _trySpawn(light: \"bright\" | \"dark\"): number {\n const def = this._pickType(light);\n if (!def) return 0;\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\n const p = this.player.pos;\n const ang = Math.random() * Math.PI * 2;\n // жители построек (крепость, храм) — ближе: постройка может быть меньше обычного кольца спавна\n const r = def.spawn!.structure\n ? 6 + Math.random() * 26\n : rMin! + Math.random() * (rMax! - rMin!);\n const x = Math.floor(p.x + Math.cos(ang) * r),\n z = Math.floor(p.z + Math.sin(ang) * r);\n let y: number;\n if (this.world.dimension === \"inferno\") {\n // пещерный мир: «поверхность» — свод из бедрока; берём высоту около игрока и спускаемся до пола\n y = Math.max(8, Math.min(120, Math.floor(p.y) + randInt(-16, 16)));\n for (\n let k = 0;\n k < 24 && y > 1 && !BLOCKS[this.world.getBlock(x, y - 1, z)]?.solid;\n k++\n )\n y--;\n } else {\n const top = this.world.surfaceY(x, z);\n // тьма — на поверхности (ночью) или случайно в толще (пещеры)\n y =\n light === \"bright\" || Math.random() < 0.5\n ? top + 1\n : 5 + Math.floor(Math.random() * Math.max(1, top - 8));\n }\n // не на полу: спрут и утопленник — в толще воды, фантом — высоко в воздухе\n const med = def.spawn!.medium;\n if (med === \"water\" && def.spawn!.underground) {\n // пещерное озеро: случайная высота под поверхностью и вниз до воды\n let wy = 12 + randInt(0, Math.max(0, this.world.surfaceY(x, z) - 24));\n for (\n let k = 0;\n k < 24 && wy > 1 && this.world.getBlock(x, wy, z) !== B.water;\n k++\n )\n wy--;\n y = wy;\n } else if (med === \"water\") {\n let wy = this.world.surfaceY(x, z) + 1;\n for (\n let k = 0;\n k < 12 && wy > 1 && this.world.getBlock(x, wy, z) !== B.water;\n k++\n )\n wy--;\n // в толще у поверхности; житель подводной постройки — на любой глубине\n y = def.spawn!.structure\n ? randInt(8, Math.max(8, wy))\n : wy - randInt(0, 2);\n } else if (med === \"lava\")\n y = this.world.generator.waterLevel + 1; // страйдер — на лавовом море\n else if (med === \"air\")\n // летучая мышь — в воздухе пещер, фантом — высоко над поверхностью\n y = def.spawn!.underground\n ? 5 + randInt(0, Math.max(0, this.world.surfaceY(x, z) - 12))\n : this.world.surfaceY(x, z) + randInt(12, 20);\n if (!this._canSpawn(def, x, y, z)) return 0;\n const [gMin, gMax] = def.spawn!.group;\n const n = randInt(gMin, gMax);\n let spawned = 0;\n for (let i = 0; i < n; i++) {\n const sx = x + (i ? randInt(-2, 2) : 0),\n sz = z + (i ? randInt(-2, 2) : 0);\n if (i && !this._canSpawn(def, sx, y, sz)) continue;\n this.spawnMob(def.name, sx + 0.5, y + 0.01, sz + 0.5);\n spawned++;\n }\n return spawned;\n }\n\n /** Место для спавна: пол непрозрачный, над ним воздух на рост, свет по правилу вида. */\n private _canSpawn(def: MobDef, x: number, y: number, z: number): boolean {\n const w = this.world;\n if (y < 1) return false;\n const sp = def.spawn!;\n // на мирной сложности монстров нет вовсе (systems/Difficulty)\n if (def.category === \"hostile\" && !rules().hostiles) return false;\n if (sp.insomnia && !this.insomnia) return false;\n // по биомам: тропическая рыба — в тёплом океане (у мира без биомов фильтра нет)\n // в грибных полях монстры не появляются (и в пещерах под ними), кроме жителей построек\n if (\n def.category === \"hostile\" &&\n !sp.structure &&\n w.generator.biomeOf?.(x, z) === \"mushroom_fields\"\n )\n return false;\n const bio = sp.biomes ? w.generator.biomeOf?.(x, z) : undefined;\n if (sp.biomes && bio !== undefined && !sp.biomes.includes(bio))\n return false;\n // на лаве: под ногами лава, над ней воздух на рост\n if (sp.medium === \"lava\") {\n if (w.getBlock(x, y - 1, z) !== B.lava) return false;\n for (let dy = 0; dy < Math.ceil(def.box.h); dy++)\n if (w.getBlock(x, y + dy, z) !== 0) return false;\n return true;\n }\n if (sp.medium) {\n // вода или воздух на весь рост, свет — по правилу вида\n const want = sp.medium === \"water\" ? B.water : 0;\n for (let dy = 0; dy < Math.max(1, Math.ceil(def.box.h)); dy++) {\n const id = w.getBlock(x, y + dy, z);\n if (id !== want && !(want === B.water && BLOCKS[id]?.waterlogged))\n return false;\n }\n if (sp.structure && w.generator.structureAt?.(x, y, z) !== sp.structure)\n return false;\n if (sp.underground && y >= w.surfaceY(x, z) - 4) return false;\n if (sp.nearBlock && !this._blockNear(x, y, z, B[sp.nearBlock], 4))\n return false;\n const l = w.getLight(x, y, z);\n const sky = (l >> 4) - this.skyDarken,\n blk = l & 15;\n if (sp.light === \"any\") return true;\n if (sp.light === \"bright\") return Math.max(sky, blk) >= 9;\n return blk === 0 && sky <= 7;\n }\n const bd = BLOCKS[w.getBlock(x, y - 1, z)];\n if (!bd || !bd.solid || !bd.opaque) return false;\n for (let dy = 0; dy < Math.ceil(def.box.h); dy++)\n if (w.getBlock(x, y + dy, z) !== 0) return false;\n const l = w.getLight(x, y, z);\n const sky = (l >> 4) - this.skyDarken,\n blk = l & 15;\n const s = def.spawn!;\n if (s.maxY !== undefined && y > s.maxY) return false;\n if (\n s.structure &&\n this.world.generator.structureAt?.(x, y, z) !== s.structure\n )\n return false;\n if (s.nearWater && !this._waterNear(x, y - 1, z, 4)) return false;\n if (s.light === \"any\") return !s.ground || s.ground.includes(bd.name);\n if (s.light === \"bright\") {\n if (s.ground && !s.ground.includes(bd.name)) return false;\n return Math.max(sky, blk) >= 9;\n }\n return blk === 0 && sky <= 7;\n }\n\n /** Луч по AABB мобов (slab-тест) — для атаки и кормления. Возвращает ближайшего. */\n raycast(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): Mob | null {\n let best: Mob | null = null,\n bestT = maxDist;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead || e.rider) continue; // лошадь под игроком лучу не мешает\n const w = e.width / 2;\n const t = rayBox(\n origin,\n dir,\n bestT,\n { x: e.pos.x - w, y: e.pos.y, z: e.pos.z - w },\n { x: e.pos.x + w, y: e.pos.y + e.height, z: e.pos.z + w },\n );\n if (t !== null && t < bestT) {\n bestT = t;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n for (const e of this.entities.values()) {\n if (e.kind === \"mob\") {\n // свет места: в пещере моб темнее (ночь приглушает свет сцены и так)\n const l = this.world.getLight(\n Math.floor(e.pos.x),\n Math.floor(e.pos.y + e.height * 0.5),\n Math.floor(e.pos.z),\n );\n const lvl = Math.max(l >> 4, l & 15);\n // без неба (Пекло, Предел) свет блоков почти всюду 0 — там свой фоновый порог\n const floor = this.world.dimension === \"overworld\" ? 0.05 : 0.6;\n e.updateVisual(alpha, time, Math.max(floor, Math.pow(0.8, 15 - lvl)));\n } else if (\n e.kind === \"arrow\" ||\n e.kind === \"fireball\" ||\n e.kind === \"minecart\" ||\n e.kind === \"boat\"\n )\n e.updateVisual(alpha);\n else if (e.kind === \"hook\") {\n // леска — от кончика удочки: чуть впереди, правее и ниже глаз\n const eye = this.player.eyePos,\n yaw = this.player.yaw;\n const tip = eye\n .clone()\n .addScaledVector(this.player.lookDir(), 0.7)\n .add(\n new THREE.Vector3(Math.cos(yaw) * 0.3, -0.3, -Math.sin(yaw) * 0.3),\n );\n e.updateVisual(alpha, tip);\n } else e.updateVisual(alpha, time);\n }\n this._beam();\n }\n\n /** Луч от лечащего Кристалла Предела к дракону. */\n private beamLine: THREE.Line | null = null;\n private _beam(): void {\n let pts: THREE.Vector3[] | null = null;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || !e.def.boss || e.healFrom === null) continue;\n const k = this.entities.get(e.healFrom);\n if (k && k.kind === \"mob\" && !k.dead)\n pts = [\n k.group.position.clone().setY(k.group.position.y + 1),\n e.group.position.clone().setY(e.group.position.y + e.height * 0.5),\n ];\n }\n if (!pts) {\n if (this.beamLine) this.beamLine.visible = false;\n return;\n }\n if (!this.beamLine) {\n this.beamLine = new THREE.Line(\n new THREE.BufferGeometry(),\n new THREE.LineBasicMaterial({ color: 0xff80ff }),\n );\n this.scene.add(this.beamLine);\n }\n this.beamLine.geometry.setFromPoints(pts);\n this.beamLine.visible = true;\n }\n\n clear(): void {\n for (const e of this.entities.values()) e.dispose(this.scene);\n this.entities.clear();\n if (this.beamLine) this.beamLine.visible = false;\n }\n\n serialize() {\n const out: unknown[] = [];\n for (const e of this.entities.values())\n if (\n e.kind !== \"arrow\" &&\n e.kind !== \"tnt\" &&\n e.kind !== \"fireball\" &&\n e.kind !== \"rimeye\" &&\n e.kind !== \"hook\" &&\n e.kind !== \"fang\"\n )\n out.push(e.serialize());\n return out;\n }\n\n deserialize(list?: EntitySave[]): void {\n this.clear();\n for (const d of list ?? []) {\n if (d.kind === \"mob\" && MOBS[d.type]) {\n const m = this.spawnMob(d.type, d.pos[0], d.pos[1], d.pos[2], !!d.baby);\n m.hp = d.hp;\n m.sheared = !!d.sheared;\n if (Array.isArray(d.uses))\n m.uses = d.uses.filter((n) => typeof n === \"number\");\n if (Array.isArray(d.jobSite) && d.jobSite.length === 3)\n m.jobSite = [d.jobSite[0], d.jobSite[1], d.jobSite[2]];\n m.traded = !!d.traded;\n if (typeof d.villagerXp === \"number\") m.villagerXp = d.villagerXp;\n if (typeof d.curedType === \"string\") m.curedType = d.curedType;\n if (typeof d.curing === \"number\") m.curing = d.curing;\n m.tamed = !!d.tamed;\n if (typeof d.customName === \"string\") m.setName(d.customName);\n m.sitting = !!d.sitting;\n m.saddled = !!d.saddled;\n // нет поля — белая (случайный цвет новорождённой овцы здесь не нужен)\n m.woolColor = typeof d.woolColor === \"string\" ? d.woolColor : null;\n if (typeof d.wolfArmor === \"number\") m.wolfArmor = d.wolfArmor;\n if (typeof d.carry === \"string\") {\n m.carry = { item: d.carry, count: 1 };\n m.bag = typeof d.bag === \"number\" ? d.bag : 0;\n }\n if (Array.isArray(d.items) && m.def.chestSlots)\n m.items = d.items.map((s) =>\n s && typeof s.item === \"string\" ? { ...s } : null,\n );\n m.leashed = !!d.leashed;\n if (typeof d.leaveT === \"number\") m.leaveT = d.leaveT;\n if (Array.isArray(d.leashPost) && d.leashPost.length === 3)\n m.leashPost = [d.leashPost[0], d.leashPost[1], d.leashPost[2]];\n if (Array.isArray(d.home) && d.home.length === 3)\n m.home = [d.home[0], d.home[1], d.home[2]];\n m.pollen = !!d.pollen;\n m.captain = !!d.captain;\n } else if (d.kind === \"drop\") {\n const drop = this.spawnDrop(\n d.pos[0],\n d.pos[1],\n d.pos[2],\n d.item,\n d.count,\n d.dmg,\n d.tag,\n );\n if (drop) drop.vel.set(0, 0, 0);\n } else if (d.kind === \"xp\")\n this.spawnXp(d.pos[0], d.pos[1], d.pos[2], d.value);\n else if (d.kind === \"minecart\") {\n const variant =\n d.variant && CART_VARIANTS.includes(d.variant) ? d.variant : \"plain\";\n const c = this.spawnMinecart(d.pos[0], d.pos[1], d.pos[2], variant);\n if (Array.isArray(d.vel)) c.vel.fromArray(d.vel);\n if (Array.isArray(d.items))\n d.items.forEach((s, i) => {\n if (i < c.items.length)\n c.items[i] =\n s && typeof s.item === \"string\" && s.count > 0\n ? { ...s }\n : null;\n });\n } else if (d.kind === \"boat\")\n this.spawnBoat(d.pos[0], d.pos[1], d.pos[2], d.wood ?? \"oak\");\n }\n }\n}\n"
|
|
196
|
+
"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"
|
|
192
197
|
},
|
|
193
198
|
{
|
|
194
199
|
"path": "entities/Fang.ts",
|
|
@@ -228,7 +233,7 @@
|
|
|
228
233
|
},
|
|
229
234
|
{
|
|
230
235
|
"path": "entities/ItemDrop.ts",
|
|
231
|
-
"content": "// Дроп-предмет: мини-модель блока (или плоская иконка) с физикой —\n// подброс, гравитация, отскок, трение, медленное вращение и «магнит» к игроку.\n// Физика на тике (20 TPS), позиция для рендера интерполируется.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { BlockDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\nconst D = CONFIG.drops;\n// Гетерогенный кэш: геометрия под ключом key, материал под key+':mat' (разные типы значений).\nconst geoCache = new Map<string, THREE.BufferGeometry | THREE.Material>();\n\nfunction blockGeometry(\n atlas: TextureAtlas,\n blockDef: BlockDef,\n): THREE.BoxGeometry {\n // мини-куб с UV граней из атласа (top/side/bottom)\n const g = new THREE.BoxGeometry(D.scale, D.scale, D.scale);\n const uvAttr = g.getAttribute(\"uv\");\n // порядок граней BoxGeometry: +x,-x,+y,-y,+z,-z (по 4 вершины)\n // tex/грани заведомо заданы для размещаемых блоков, приходящих сюда — non-null asserts.\n const tex = blockDef.tex!;\n const tiles = [\n tex.side!,\n tex.side!,\n tex.top!,\n tex.bottom!,\n tex.side!,\n tex.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = atlas.uv(tiles[f]!);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // общий материал чанков — vertexColors:true; без белого атрибута цвета\n // вершины считаются чёрными и гасят текстуру\n g.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(g.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n return g;\n}\n\nexport class ItemDrop {\n id: number;\n kind: \"drop\";\n item: string;\n count: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n age: number;\n dead: boolean;\n _pickupAnim: number;\n /** Износ инструмента/брони — переживает выбрасывание и подбор. */\n dmg?: number;\n /** Доп. данные стака (зачарования, вещи панцирного сундука) — тоже переживают выброс и подбор. */\n tag?: Record<string, unknown>;\n /** Сколько секунд после появления нельзя подобрать (выброшенное игроком — 2 с, как в Minecraft). */\n pickupDelay = 0.5;\n mesh: THREE.Mesh;\n\n constructor(\n id: number,\n atlas: TextureAtlas,\n x: number,\n y: number,\n z: number,\n itemName: string,\n count: number,\n ) {\n this.id = id;\n this.kind = \"drop\";\n this.item = itemName;\n this.count = count;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3(\n (Math.random() - 0.5) * 2.4,\n 4 + Math.random() * 1.5,\n (Math.random() - 0.5) * 2.4,\n );\n this.age = 0;\n this.dead = false;\n this._pickupAnim = 0; // >0 — летит в игрока\n\n const def = ITEMS[itemName]!;\n // Значения кэша гетерогенны (геометрия/материал под разными ключами) — сужаем кастами при чтении.\n const key = \"drop:\" + itemName;\n let geo = geoCache.get(key) as THREE.BufferGeometry | undefined;\n if (def.blockId !== null && BLOCKS[def.blockId]!.shape === \"cube\") {\n if (!geo) {\n geo = blockGeometry(atlas, BLOCKS[def.blockId]!);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, atlas.opaqueMat);\n } else {\n // плоский предмет: квад с иконкой (или тайлом растения)\n let mat = geoCache.get(key + \":mat\") as\n THREE.MeshLambertMaterial | undefined;\n if (!mat) {\n // не-блочные предметы (материалы/инструменты/крестовины) всегда имеют tile — non-null assert.\n const tex = new THREE.CanvasTexture(\n def.icon ?? atlas.tileCanvas(def.tile!),\n );\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshLambertMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n geoCache.set(key + \":mat\", mat);\n }\n if (!geo) {\n geo = new THREE.PlaneGeometry(D.scale * 1.4, D.scale * 1.4);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, mat);\n }\n this.mesh.castShadow = true;\n this.mesh.position.copy(this.pos);\n }\n\n tick(dt: number, world: ChunkManager, player: Player, inventory: Inventory) {\n this.prevPos.copy(this.pos);\n this.age += dt;\n if (this.age > D.despawnTime) {\n this.dead = true;\n return;\n }\n // в лаве и в огне предметы сгорают\n const here = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n );\n // пеклит и древние обломки в лаве не горят, как в Minecraft\n const fireproof =\n this.item.startsWith(\"infernite\") || this.item === \"ancient_debris\";\n if (\n (here === B.lava || here === B.fire || here === B.soul_fire) &&\n !fireproof\n ) {\n this.dead = true;\n return;\n }\n\n const toPlayer = new THREE.Vector3(\n player.pos.x,\n player.pos.y + 0.9,\n player.pos.z,\n ).sub(this.pos);\n const dist = toPlayer.length();\n\n // подбор\n const canTake = !player.dead && this.age > this.pickupDelay; // мёртвый не подбирает\n if (dist < D.pickupRadius && canTake) {\n const left =\n this.dmg || this.tag\n ? inventory.addStack({\n item: this.item,\n count: this.count,\n ...(this.dmg ? { dmg: this.dmg } : {}),\n ...(this.tag ? { tag: this.tag } : {}),\n })\n : inventory.add(this.item, this.count);\n if (left < this.count) {\n events.emit(\"itemPickup\", {\n item: this.item,\n count: this.count - left,\n });\n if (left > 0) this.count = left;\n else {\n this.dead = true;\n return;\n }\n }\n }\n // магнит: тянет к игроку, физика отключается\n if (dist < D.magnetRadius && canTake) {\n toPlayer\n .normalize()\n .multiplyScalar(\n D.magnetPull * Math.max(0.3, 1 - dist / D.magnetRadius) + 2,\n );\n this.vel.lerp(toPlayer, 0.5);\n this.pos.addScaledVector(this.vel, dt);\n return;\n }\n\n // обычная физика: гравитация + точечная коллизия с вокселями\n this.vel.y -= D.gravity * dt;\n const nx = this.pos.x + this.vel.x * dt;\n const ny = this.pos.y + this.vel.y * dt;\n const nz = this.pos.z + this.vel.z * dt;\n\n if (\n this.vel.y < 0 &&\n world.isSolid(Math.floor(nx), Math.floor(ny - 0.12), Math.floor(nz))\n ) {\n // отскок от пола\n this.pos.y = Math.floor(ny - 0.12) + 1 + 0.12;\n this.vel.y = Math.abs(this.vel.y) > 1 ? -this.vel.y * D.bounce : 0;\n this.vel.x *= D.friction;\n this.vel.z *= D.friction;\n } else {\n this.pos.y = ny;\n // горизонтальные стенки — просто стоп\n if (\n !world.isSolid(\n Math.floor(nx),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n )\n this.pos.x = nx;\n else this.vel.x = 0;\n if (\n !world.isSolid(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(nz),\n )\n )\n this.pos.z = nz;\n else this.vel.z = 0;\n }\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.mesh.position.set(\n p.x,\n p.y + Math.sin(time * 2 + this.id) * 0.05 + 0.05,\n p.z,\n );\n this.mesh.rotation.y = time * 1.2 + this.id;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.mesh); /* геометрии/материалы в кэше — общие */\n }\n\n serialize() {\n return {\n kind: \"drop\",\n item: this.item,\n count: this.count,\n pos: this.pos.toArray(),\n ...(this.dmg ? { dmg: this.dmg } : {}),\n ...(this.tag ? { tag: this.tag } : {}),\n };\n }\n}\n"
|
|
236
|
+
"content": "// Дроп-предмет: мини-модель блока (или плоская иконка) с физикой —\n// подброс, гравитация, отскок, трение, медленное вращение и «магнит» к игроку.\n// Физика на тике (20 TPS), позиция для рендера интерполируется.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { BlockDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\nconst D = CONFIG.drops;\n// Гетерогенный кэш: геометрия под ключом key, материал под key+':mat' (разные типы значений).\nconst geoCache = new Map<string, THREE.BufferGeometry | THREE.Material>();\n\nfunction blockGeometry(\n atlas: TextureAtlas,\n blockDef: BlockDef,\n): THREE.BoxGeometry {\n // мини-куб с UV граней из атласа (top/side/bottom)\n const g = new THREE.BoxGeometry(D.scale, D.scale, D.scale);\n const uvAttr = g.getAttribute(\"uv\");\n // порядок граней BoxGeometry: +x,-x,+y,-y,+z,-z (по 4 вершины)\n // tex/грани заведомо заданы для размещаемых блоков, приходящих сюда — non-null asserts.\n const tex = blockDef.tex!;\n const tiles = [\n tex.side!,\n tex.side!,\n tex.top!,\n tex.bottom!,\n tex.side!,\n tex.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = atlas.uv(tiles[f]!);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // общий материал чанков — vertexColors:true; без белого атрибута цвета\n // вершины считаются чёрными и гасят текстуру\n g.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(g.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n return g;\n}\n\nexport class ItemDrop {\n id: number;\n kind: \"drop\";\n item: string;\n count: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n age: number;\n dead: boolean;\n _pickupAnim: number;\n /** Износ инструмента/брони — переживает выбрасывание и подбор. */\n dmg?: number;\n /** Доп. данные стака (зачарования, вещи панцирного сундука) — тоже переживают выброс и подбор. */\n tag?: Record<string, unknown>;\n /** Сколько секунд после появления нельзя подобрать (выброшенное игроком — 2 с, как в Minecraft). */\n pickupDelay = 0.5;\n mesh: THREE.Mesh;\n\n constructor(\n id: number,\n atlas: TextureAtlas,\n x: number,\n y: number,\n z: number,\n itemName: string,\n count: number,\n ) {\n this.id = id;\n this.kind = \"drop\";\n this.item = itemName;\n this.count = count;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3(\n (Math.random() - 0.5) * 2.4,\n 4 + Math.random() * 1.5,\n (Math.random() - 0.5) * 2.4,\n );\n this.age = 0;\n this.dead = false;\n this._pickupAnim = 0; // >0 — летит в игрока\n\n // предмет, которого нет в игре (сундук из чужого сейва), рисуем камнем: падать из-за него весь\n // кадр не должен\n const def = ITEMS[itemName] ?? ITEMS.stone!;\n // Значения кэша гетерогенны (геометрия/материал под разными ключами) — сужаем кастами при чтении.\n const key = \"drop:\" + itemName;\n let geo = geoCache.get(key) as THREE.BufferGeometry | undefined;\n if (def.blockId !== null && BLOCKS[def.blockId]!.shape === \"cube\") {\n if (!geo) {\n geo = blockGeometry(atlas, BLOCKS[def.blockId]!);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, atlas.opaqueMat);\n } else {\n // плоский предмет: квад с иконкой (или тайлом растения)\n let mat = geoCache.get(key + \":mat\") as\n THREE.MeshLambertMaterial | undefined;\n if (!mat) {\n // не-блочные предметы (материалы/инструменты/крестовины) всегда имеют tile — non-null assert.\n const tex = new THREE.CanvasTexture(\n def.icon ?? atlas.tileCanvas(def.tile!),\n );\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshLambertMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n geoCache.set(key + \":mat\", mat);\n }\n if (!geo) {\n geo = new THREE.PlaneGeometry(D.scale * 1.4, D.scale * 1.4);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, mat);\n }\n this.mesh.castShadow = true;\n this.mesh.position.copy(this.pos);\n }\n\n tick(dt: number, world: ChunkManager, player: Player, inventory: Inventory) {\n this.prevPos.copy(this.pos);\n this.age += dt;\n if (this.age > D.despawnTime) {\n this.dead = true;\n return;\n }\n // в лаве и в огне предметы сгорают\n const here = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n );\n // пеклит и древние обломки в лаве не горят, как в Minecraft\n const fireproof =\n this.item.startsWith(\"infernite\") || this.item === \"ancient_debris\";\n if (\n (here === B.lava || here === B.fire || here === B.soul_fire) &&\n !fireproof\n ) {\n this.dead = true;\n return;\n }\n\n const toPlayer = new THREE.Vector3(\n player.pos.x,\n player.pos.y + 0.9,\n player.pos.z,\n ).sub(this.pos);\n const dist = toPlayer.length();\n\n // подбор\n const canTake = !player.dead && this.age > this.pickupDelay; // мёртвый не подбирает\n if (dist < D.pickupRadius && canTake) {\n const left =\n this.dmg || this.tag\n ? inventory.addStack({\n item: this.item,\n count: this.count,\n ...(this.dmg ? { dmg: this.dmg } : {}),\n ...(this.tag ? { tag: this.tag } : {}),\n })\n : inventory.add(this.item, this.count);\n if (left < this.count) {\n events.emit(\"itemPickup\", {\n item: this.item,\n count: this.count - left,\n });\n if (left > 0) this.count = left;\n else {\n this.dead = true;\n return;\n }\n }\n }\n // магнит: тянет к игроку, физика отключается\n if (dist < D.magnetRadius && canTake) {\n toPlayer\n .normalize()\n .multiplyScalar(\n D.magnetPull * Math.max(0.3, 1 - dist / D.magnetRadius) + 2,\n );\n this.vel.lerp(toPlayer, 0.5);\n this.pos.addScaledVector(this.vel, dt);\n return;\n }\n\n // обычная физика: гравитация + точечная коллизия с вокселями\n this.vel.y -= D.gravity * dt;\n const nx = this.pos.x + this.vel.x * dt;\n const ny = this.pos.y + this.vel.y * dt;\n const nz = this.pos.z + this.vel.z * dt;\n\n if (\n this.vel.y < 0 &&\n world.isSolid(Math.floor(nx), Math.floor(ny - 0.12), Math.floor(nz))\n ) {\n // отскок от пола\n this.pos.y = Math.floor(ny - 0.12) + 1 + 0.12;\n this.vel.y = Math.abs(this.vel.y) > 1 ? -this.vel.y * D.bounce : 0;\n this.vel.x *= D.friction;\n this.vel.z *= D.friction;\n } else {\n this.pos.y = ny;\n // горизонтальные стенки — просто стоп\n if (\n !world.isSolid(\n Math.floor(nx),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n )\n this.pos.x = nx;\n else this.vel.x = 0;\n if (\n !world.isSolid(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(nz),\n )\n )\n this.pos.z = nz;\n else this.vel.z = 0;\n }\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.mesh.position.set(\n p.x,\n p.y + Math.sin(time * 2 + this.id) * 0.05 + 0.05,\n p.z,\n );\n this.mesh.rotation.y = time * 1.2 + this.id;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.mesh); /* геометрии/материалы в кэше — общие */\n }\n\n serialize() {\n return {\n kind: \"drop\",\n item: this.item,\n count: this.count,\n pos: this.pos.toArray(),\n ...(this.dmg ? { dmg: this.dmg } : {}),\n ...(this.tag ? { tag: this.tag } : {}),\n };\n }\n}\n"
|
|
232
237
|
},
|
|
233
238
|
{
|
|
234
239
|
"path": "entities/Jungle.test.ts",
|
|
@@ -244,11 +249,11 @@
|
|
|
244
249
|
},
|
|
245
250
|
{
|
|
246
251
|
"path": "entities/Minecart.ts",
|
|
247
|
-
"content": "// Вагонетка, как в Minecraft: едет по рельсам (world/Rails) — скорость вдоль отрезка между выходами\n// клетки, на поворотах направление меняется без потери скорости, на подъёмах тянет гравитация.\n// Разгонные рельсы под током разгоняют до 8 блоков/с, без тока — тормозят. В тупике встаёт, без\n// рельсов падает. Игрок садится ПКМ (Player.riding), W толкает,
|
|
252
|
+
"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"
|
|
248
253
|
},
|
|
249
254
|
{
|
|
250
255
|
"path": "entities/Mob.ts",
|
|
251
|
-
"content": "// Универсальный моб: риг собирается из parts-описания реестра (BoxGeometry), физика — по коробкам\n// блоков (общая с игроком), решения — entities/MobBrain.ts, анимации процедурные.\n// Добавление моба = запись в registry/mobs/*.ts, этот код не меняется.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { DYE_COLORS } from \"../registry/blocks/colors\";\nimport type { ItemStack as Stack } from \"../types\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { moveBody } from \"../physics/Collision\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { EffectId, MobDef } from \"../types\";\nimport { think, type MobCtx } from \"./MobBrain\";\n\nconst PX = 1 / 16; // части описаны в пикселях: 16 px = 1 блок\n/** Детёныш вырастает за 20 минут, как в Minecraft. */\nexport const GROW_TIME = 1200;\n/** Для страйдера лава — полный куб (physics/Collision, extraSolid). */\nconst isLava = (id: number): boolean => id === B.lava;\n\n/** follow — ручной идёт за хозяином (MobBrain.petThink). */\nexport type MobState =\n \"idle\" | \"wander\" | \"flee\" | \"chase\" | \"tempt\" | \"love\" | \"follow\";\n\ninterface AnimPart {\n g: THREE.Group;\n base: THREE.Euler;\n}\n\n/**\n * Сколько секунд зомби ломится в дверь, прежде чем она рухнет. Счётчик копится, пока моб\n * упирается в дверь, и МЕДЛЕННО стекает, когда он отскочил: иначе каждый отскок сбрасывал бы\n * долбёжку в ноль и дверь стояла бы вечно (проверено на живом зомби).\n */\nconst DOOR_TIME = 3;\n\nexport class Mob {\n id: number;\n kind = \"mob\" as const;\n def: MobDef;\n hp: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n targetYaw: number;\n onGround = false;\n dead = false;\n state: MobState = \"idle\";\n stateTime: number;\n /** Желаемая скорость этого тика (мозг); отрицательная — пятиться. */\n speed = 0;\n /**\n * Эффекты на мобе: id → сколько ещё секунд. Понимает два — яд (1 урона раз в 2 с, но не\n * добивает) и замедление (вдвое медленнее). Их вешают отравленные стрелы (systems/TippedArrow).\n */\n effects = new Map<EffectId, number>();\n /** Имя с бирки: висит над мобом и держит его в мире (не исчезает вдали от игрока). */\n customName: string | null = null;\n private nameSprite: THREE.Sprite | null = null;\n private _poisonT = 0;\n /** Желаемая вертикальная скорость летуна (мозг). */\n climbWish = 0;\n /** Куда идти, если не туда, куда смотрит (владыка летит по кругу, глядя на игрока); null — по yaw. */\n moveYaw: number | null = null;\n /** Пауза до следующего прыжка (слизень). */\n hopT = 0;\n /** Часы пике фантома: из каждых 6 с — 2 с вниз на цель. */\n swoopT = 0;\n /** Пчела: свой улей, есть ли пыльца, цветок-цель, таймеры поиска и сбора. */\n home: [number, number, number] | null = null;\n pollen = false;\n beeTarget: [number, number, number] | null = null;\n beeT = 0;\n beeWork = 0;\n /** Ударил игрок моба со стайной злостью — EntityManager разбудит стаю на тике. */\n angerCall = false;\n /** Дракон: состояние мозга босса (entities/DragonBrain). Не сохраняется — после загрузки кружит. */\n bossState: unknown = null;\n /** Кристалл, что сейчас лечит дракона (id): луч и урон дракону от взрыва этого кристалла. */\n healFrom: number | null = null;\n /** Не получает урона (Пепельный владыка набирает силу). */\n invulnerable = false;\n /** Панцирник: насколько открыта крышка (0 — закрыт, 1 — открыт); иглобрюх — насколько раздут. */\n peek = 0;\n /** Рыба: сколько секунд на суше. */\n dryT = 0;\n /** Страж (entities/WardenBrain): злость, место последней вибрации, заряд и остывание звукового удара. */\n anger = 0;\n suspect: [number, number, number] | null = null;\n boomT = 0;\n boomCd = 0;\n /** Страж: сколько ещё вылезает из земли; сколько простаивает без тревог; до следующей Тьмы. */\n emergeT = 0;\n idleT = 0;\n darkT = 0;\n /** Коза (entities/GoatBrain): до следующего тарана, пригибается, разгон, сколько рогов осталось. */\n ramCd = 0;\n ramT = 0;\n dashT = 0;\n horns = 2;\n /** Кричащая коза (2%, как в Minecraft): таранит в разы чаще и кричит. */\n screaming = Math.random() < 0.02;\n /** Лягушка (entities/FrogBrain): до следующего броска языком. */\n tongueCd = 0;\n /** Нюхач (entities/SnifferBrain): до следующего принюхивания; сколько ещё роет. */\n sniffCd = 0;\n digT = 0;\n /** Моб волны спавнера испытаний: координаты спавнера (Game засчитывает убитого; вдали не исчезает). */\n trialFrom: [number, number, number] | null = null;\n /** Капитан мародёров: знамя на спине, за его смерть — Дурное знамение. */\n captain = false;\n /** Участник набега: идёт к goal (центру деревни) и не исчезает вдали. */\n raider = false;\n /** Секунд подсветки от колокола (systems: EntityManager.markRaiders). */\n glowT = 0;\n goal: THREE.Vector3 | null = null;\n /** Ворожей: до следующего заклинания и призванные бесята. */\n castT = 2;\n minions: Mob[] = [];\n /** Бесёнок: сколько ещё жить (сек). */\n lifeT = Infinity;\n private readonly bannerParts: THREE.Object3D[] = [];\n /** Глубинник: сколько заряжается луч (сек), куда он смотрит (null — не заряжает), до следующей Усталости. */\n beamT = 0;\n beamTo: THREE.Vector3 | null = null;\n fatigueT = 3;\n private beamLine: THREE.Line | null = null;\n inWater = false;\n inLava = false;\n hitWall = false;\n baby: boolean;\n growT = 0;\n /** Секунд «любви» после кормления (ищет пару). */\n loveT = 0;\n breedCd = 0;\n fuse = 0;\n attackCd = 0;\n shootCd = 0;\n burnT = 0;\n burning = false;\n /** Снеговик: накопитель секунд в жаре или воде (урон 1 за секунду). */\n snowT = 0;\n /** Попугай на плече игрока: 0 — левое, 1 — правое, null — нет; shoulderCd — пауза после спрыгивания. */\n onShoulder: 0 | 1 | null = null;\n shoulderCd = 0;\n /** Овца: цвет шерсти (id красителя), null — белая; woolShown — какой цвет уже нарисован. */\n woolColor: string | null = null;\n private woolShown: string | null = null;\n /** Броненосец: секунд до разворота (0 — не свёрнут); до следующего сброшенного щитка. */\n rolledT = 0;\n scuteT = 300 + Math.random() * 300;\n /** Волк: запас брони из щитков (0 — без брони). */\n wolfArmor = 0;\n /** Дух-помощник: что велено собирать (образец) и сколько уже собрано. */\n carry: { item: string; count: number } | null = null;\n bag = 0;\n /** Вьюк (осёл, мул, лама): слоты сундука; пусто — не навьючен. */\n items: (Stack | null)[] = [];\n /** На поводке у игрока; leashTo — куда тянется верёвка (рука игрока), задаёт мозг. */\n leashed = false;\n leashTo: THREE.Vector3 | null = null;\n /** Поводок привязан к забору в этой клетке (null — держит игрок). */\n leashPost: [number, number, number] | null = null;\n /** Караван лам: за кем идёт (id моба), не сохраняется. */\n followId: number | null = null;\n /** Поводок держит другой моб (ламы странствующего торговца), не сохраняется. */\n leashMobId: number | null = null;\n /** Секунд до ухода (MobDef.leavesAfter); Infinity — не уходит. */\n leaveT = Infinity;\n /** С ним торгуют (открыт экран торговли) — стоит и смотрит на игрока. */\n trading = false;\n /** Невидим (странствующий торговец ночью — зелье невидимости). */\n invisible = false;\n private leashLine: THREE.Line | null = null;\n private readonly packParts: THREE.Object3D[] = [];\n private readonly armorParts: THREE.Object3D[] = [];\n private readonly outParts: THREE.Object3D[] = [];\n aggro = false;\n justHurt = false;\n /** Недавно бил игрок — за смерть дадут опыт. */\n hitByPlayerT = 0;\n /** Шипун взорвался сам — дропа нет, как в Minecraft. */\n exploded = false;\n sheared = false;\n /** Житель: сколько раз совершена каждая сделка (по индексу в списке профессии) до пополнения. */\n uses: number[] = [];\n /** Житель: занятый рабочий блок (systems/Jobs), null — безработный. */\n jobSite: [number, number, number] | null = null;\n /** Житель уже торговал — профессию не теряет, даже если рабочий блок сломали. */\n traded = false;\n /** Житель: опыт профессии (systems/Trading, levelOf) — открывает следующие сделки. */\n villagerXp = 0;\n /** Зомби-житель: секунд до выздоровления (0 — не лечится) и кем он был (systems/Zombify). */\n curing = 0;\n curedType: string | null = null;\n /** Секунд до следующего пополнения товара за работой у своего блока (не сохраняется). */\n workT = 0;\n /** Ручной (волк, кошка, лошадь), сидит по команде, осёдлан (лошадь). */\n tamed = false;\n sitting = false;\n saddled = false;\n /** Ручной волк: кого атакует (id моба). */\n targetId: number | null = null;\n /** На лошади сидит игрок (Player.riding): мозг молчит, ведёт седок. */\n rider = false;\n private readonly riderWish = { x: 0, z: 0, t: 0 };\n /** Где нарисован (интерполяция) — за это держится седок. */\n readonly renderPos = new THREE.Vector3();\n private readonly tameParts: THREE.Object3D[] = [];\n private readonly saddleParts: THREE.Object3D[] = [];\n /** Замах руки (атака, выстрел). */\n swing = 0;\n animTime = 0;\n flash = 0;\n private lavaT = 0;\n group: THREE.Group;\n mesh: THREE.Object3D;\n private readonly anims = new Map<string, AnimPart[]>();\n private readonly mats: {\n m: THREE.MeshLambertMaterial | THREE.MeshBasicMaterial;\n base: THREE.Color;\n glow: boolean;\n }[] = [];\n private readonly wool: THREE.Object3D[] = [];\n\n constructor(\n id: number,\n def: MobDef,\n x: number,\n y: number,\n z: number,\n baby = false,\n ) {\n this.id = id;\n this.def = def;\n this.hp = def.hp;\n this.baby = baby;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3();\n this.yaw = Math.random() * Math.PI * 2;\n this.targetYaw = this.yaw;\n this.stateTime = 1 + Math.random() * 2;\n\n // риг: каждая часть — Group в точке pivot, бокс смещён относительно pivot\n this.group = new THREE.Group();\n for (const p of def.parts) {\n const color = new THREE.Color(p.color);\n const mat = p.glow\n ? new THREE.MeshBasicMaterial({ color })\n : new THREE.MeshLambertMaterial({ color });\n this.mats.push({ m: mat, base: color.clone(), glow: !!p.glow });\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(p.size[0] * PX, p.size[1] * PX, p.size[2] * PX),\n mat,\n );\n mesh.castShadow = mesh.receiveShadow = true;\n const pivot = new THREE.Group();\n const pv = p.pivot ?? p.pos;\n pivot.position.set(pv[0] * PX, pv[1] * PX, pv[2] * PX);\n mesh.position.set(\n (p.pos[0] - pv[0]) * PX,\n (p.pos[1] - pv[1]) * PX,\n (p.pos[2] - pv[2]) * PX,\n );\n if (p.pose) pivot.rotation.set(p.pose[0], p.pose[1], p.pose[2]);\n pivot.add(mesh);\n this.group.add(pivot);\n if (p.anim) {\n const list = this.anims.get(p.anim) ?? [];\n list.push({ g: pivot, base: pivot.rotation.clone() });\n this.anims.set(p.anim, list);\n }\n if (p.name.startsWith(\"wool\")) this.wool.push(pivot);\n if (p.name.startsWith(\"tame\")) this.tameParts.push(pivot);\n if (p.name.startsWith(\"saddle\")) this.saddleParts.push(pivot);\n if (p.name.startsWith(\"armor\")) this.armorParts.push(pivot);\n if (p.name.startsWith(\"out\")) this.outParts.push(pivot);\n if (p.name.startsWith(\"pack\")) this.packParts.push(pivot);\n if (p.name.startsWith(\"banner\")) this.bannerParts.push(pivot);\n }\n if (def.lifespan)\n this.lifeT =\n def.lifespan[0] + Math.random() * (def.lifespan[1] - def.lifespan[0]);\n if (def.leavesAfter)\n this.leaveT =\n def.leavesAfter[0] +\n Math.random() * (def.leavesAfter[1] - def.leavesAfter[0]);\n // овцы в Minecraft изредка бывают чёрные, серые, светло-серые, коричневые и совсем редко розовые\n if (def.dyeable) {\n const r = Math.random();\n this.woolColor =\n r < 0.05\n ? \"black\"\n : r < 0.1\n ? \"gray\"\n : r < 0.15\n ? \"light_gray\"\n : r < 0.18\n ? \"brown\"\n : r < 0.1816\n ? \"pink\"\n : null;\n }\n this.mesh = this.group; // единообразие с ItemDrop\n }\n\n /** Что даёт стрижка: шерсть своего цвета (овца), иначе shearDrop. */\n woolItem(): string {\n return this.woolColor\n ? `${this.woolColor}_wool`\n : (this.def.shearDrop?.item ?? \"white_wool\");\n }\n\n /** Высота седока над мобом (лошадь). */\n get seatY(): number {\n return this.def.rideable?.seat ?? 1;\n }\n\n /** Седок жмёт «вперёд»: куда вести лошадь (держится 0.15 с, седок повторяет каждый кадр). */\n push(dx: number, dz: number, _dt: number): void {\n this.riderWish.x = dx;\n this.riderWish.z = dz;\n this.riderWish.t = 0.15;\n }\n\n get width(): number {\n return this.def.box.w * (this.baby ? 0.5 : 1);\n }\n get height(): number {\n return this.def.box.h * (this.baby ? 0.5 : 1);\n }\n\n tick(dt: number, c: MobCtx) {\n this.prevPos.copy(this.pos);\n const dec = (v: number) => Math.max(0, v - dt);\n this.flash = dec(this.flash);\n this.attackCd = dec(this.attackCd);\n this.shootCd = dec(this.shootCd);\n this.loveT = dec(this.loveT);\n this.breedCd = dec(this.breedCd);\n this.hitByPlayerT = dec(this.hitByPlayerT);\n this.swing = dec(this.swing);\n // попугай на плече: едет с игроком (мозг и физика молчат); игрок присел или умер — слетает\n if (this.onShoulder !== null) {\n const p = c.player;\n if (p.dead || p._sneaking || p.shoulders[this.onShoulder] !== this.id) {\n if (p.shoulders[this.onShoulder] === this.id)\n p.shoulders[this.onShoulder] = null;\n this.onShoulder = null;\n this.shoulderCd = 3; // не запрыгивать сразу обратно\n } else {\n const side = this.onShoulder === 1 ? 1 : -1;\n this.pos.set(\n p.pos.x + Math.cos(p.yaw) * 0.4 * side,\n p.pos.y + 1.45,\n p.pos.z - Math.sin(p.yaw) * 0.4 * side,\n );\n this.vel.set(0, 0, 0);\n this.yaw = this.targetYaw = p.yaw;\n this.speed = 0;\n this.animTime += dt;\n return;\n }\n }\n this._tickEffects(dt);\n this.glowT = Math.max(0, this.glowT - dt);\n this.shoulderCd = dec(this.shoulderCd);\n this.rolledT = dec(this.rolledT);\n // странствующий торговец и его ламы: срок вышел — уходят (исчезают без дропа)\n if (this.leaveT !== Infinity) {\n this.leaveT -= dt;\n if (this.leaveT <= 0 && !this.dead) {\n this.dead = true; // hp > 0 — без дропа\n events.emit(\"mobLeft\", { entity: this });\n }\n }\n // броненосец сам сбрасывает щиток раз в 5–10 минут\n if (this.def.rollsUp && !this.baby) {\n this.scuteT -= dt;\n if (this.scuteT <= 0) {\n this.scuteT = 300 + Math.random() * 300;\n events.emit(\"mobDrop\", {\n x: this.pos.x,\n y: this.pos.y + 0.3,\n z: this.pos.z,\n item: \"armadillo_scute\",\n });\n }\n }\n // бесёнок: срок вышел — теряет по 1 здоровью в секунду\n if (this.def.lifespan) {\n this.lifeT -= dt;\n if (\n this.lifeT <= 0 &&\n Math.floor(this.lifeT) !== Math.floor(this.lifeT + dt)\n )\n this.damage(1);\n }\n if (this.baby) {\n this.growT += dt;\n if (this.growT >= GROW_TIME) {\n this.baby = false;\n if (this.def.growDrop) events.emit(\"mobGrew\", { entity: this }); // черепашонок — щиток\n }\n }\n // головастик: вырастает в другой вид (лягушку по биому — EntityManager, событие mobMature)\n // житель Пекла вне Пекла превращается (свинорыл → мёртвый свинорыл, хоглин → зоглин)\n if (\n this.def.growsInto &&\n !this.dead &&\n !(this.def.zombifies && c.world.dimension === \"inferno\")\n ) {\n this.growT += dt;\n if (this.growT >= (this.def.growTime ?? GROW_TIME)) {\n this.growT = -Infinity;\n events.emit(\"mobMature\", { entity: this });\n }\n }\n // Кристалл Предела: ни мозга, ни физики — висит над колонной\n if (this.def.stationary) {\n this.animTime += dt;\n return;\n }\n // панцирник сидит на блоке: мозг решает, открыться ли и стрелять; ни шагов, ни гравитации\n if (this.def.anchored) {\n think(this, c, dt);\n this.animTime += dt;\n return;\n }\n\n const world = c.world;\n const feet = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n this.inWater = feet === B.water || !!BLOCKS[feet]?.waterlogged;\n this.inLava = feet === B.lava;\n // рыба на суше: 5 с — и по 1 урону в секунду; бьётся, подпрыгивая\n if (this.def.needsWater) {\n if (this.inWater) this.dryT = 0;\n else {\n this.dryT += dt;\n // рыба задыхается через 5 с и бьётся на месте; аксолотль ходит по суше dryTime секунд\n const limit = this.def.dryTime ?? 5;\n if (\n this.dryT > limit &&\n Math.floor(this.dryT) !== Math.floor(this.dryT - dt)\n )\n this.damage(1);\n if (!this.def.dryTime && this.onGround && Math.random() < dt * 3)\n this.vel.y = 3;\n }\n }\n\n if (this.rider) {\n // под седоком: направление и галоп — от игрока; ступеньки перепрыгивает сам (hitWall ниже)\n const w = this.riderWish;\n w.t -= dt;\n if (w.t > 0 && (w.x || w.z)) {\n this.targetYaw = this.yaw = Math.atan2(-w.x, -w.z);\n this.speed = this.def.rideable?.speed ?? this.def.walkSpeed;\n } else this.speed = 0;\n } else think(this, c, dt);\n\n // плавный поворот к целевому направлению\n let dy = this.targetYaw - this.yaw;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n this.yaw += dy * Math.min(1, 6 * dt);\n\n const moveYaw = this.moveYaw ?? this.yaw;\n const slow = this.effects.has(\"slowness\") ? 0.5 : 1;\n const wishX = -Math.sin(moveYaw) * this.speed * slow;\n const wishZ = -Math.cos(moveYaw) * this.speed * slow;\n const accel = Math.min(1, 10 * dt);\n this.vel.x += (wishX - this.vel.x) * accel;\n this.vel.z += (wishZ - this.vel.z) * accel;\n if (this.def.hops) {\n // прыжками: на земле стоит и выжидает, в воздухе летит туда, куда смотрит\n if (this.onGround) {\n this.vel.x = 0;\n this.vel.z = 0;\n this.hopT -= dt;\n if (this.speed !== 0 && this.hopT <= 0) {\n this.vel.y = 7;\n this.hopT = 0.5 + Math.random() * 1.5;\n }\n } else {\n this.vel.x = wishX * 1.5;\n this.vel.z = wishZ * 1.5;\n }\n }\n // летун и пловец в воде: без гравитации, высоту задаёт мозг (climbWish)\n if (this.def.flies || (this.def.swims && this.inWater))\n this.vel.y += (this.climbWish - this.vel.y) * accel;\n else {\n this.vel.y -= 22 * dt;\n if (this.inWater || this.inLava) this.vel.y = Math.max(this.vel.y, 1.5); // всплывает\n }\n\n if (this.def.noClip)\n this.pos.addScaledVector(this.vel, dt); // дракон: сквозь блоки\n else this._move(dt, world);\n\n // зомби осады ломает дверь, в которую упёрся (как в Minecraft на высокой сложности)\n // ⚠ у обычного враждебного моба aggro остаётся false: злость — это поле для НЕЙТРАЛЬНЫХ\n // (свинорыл, голем), а зомби враждебен по категории. Дверь ломает и тот, и другой.\n if (\n this.def.breaksDoors &&\n rules().doorBreaking && // только на высокой сложности (systems/Difficulty)\n (this.aggro || this.def.category === \"hostile\") &&\n this.speed !== 0\n )\n this._breakDoor(dt, world);\n else this.doorT = Math.max(0, this.doorT - dt); // короткий отскок не сбрасывает долбёжку\n\n // упёрся в стену на ходу — подпрыгнуть (на 1 блок); паук лезет по стене\n if (this.speed !== 0 && this.hitWall && !this.def.hops) {\n if (this.def.climbs) this.vel.y = 3;\n else if (this.onGround) this.vel.y = 7;\n }\n\n // лава и огонь жгут: 4 и 1 урона каждые полсекунды\n if (\n (this.inLava ||\n feet === B.fire ||\n feet === B.soul_fire ||\n feet === B.campfire ||\n feet === B.soul_campfire) &&\n !this.def.fireImmune\n ) {\n this.lavaT -= dt;\n if (this.lavaT <= 0) {\n this.lavaT = 0.5;\n this.damage(this.inLava ? 4 : 1);\n }\n }\n\n this.animTime +=\n dt * (this.speed !== 0 ? Math.hypot(this.vel.x, this.vel.z) * 3 : 0);\n }\n\n /** Шаг с коллизиями по коробкам форм — тот же код, что у игрока (physics/Collision). */\n _move(dt: number, world: ChunkManager) {\n const r = moveBody(\n world,\n {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n hw: this.width / 2,\n h: this.height,\n },\n this.vel.x * dt,\n this.vel.y * dt,\n this.vel.z * dt,\n // страйдер стоит на лаве, как на полу\n {\n step: this.onGround ? 0.6 : 0,\n onGround: this.onGround,\n ...(this.def.walksOnLava ? { extraSolid: isLava } : {}),\n },\n );\n this.pos.x += r.dx;\n this.pos.y += r.dy;\n this.pos.z += r.dz;\n if (r.hitDown) {\n this.onGround = true;\n this.vel.y = 0;\n } else {\n this.onGround = false;\n if (r.hitUp) this.vel.y = 0;\n }\n if (r.hitX) this.vel.x = 0;\n if (r.hitZ) this.vel.z = 0;\n this.hitWall = r.hitX || r.hitZ;\n }\n\n /** Стрелы отскакивают: Пепельный владыка на половине здоровья, закрытый панцирник. */\n shielded(): boolean {\n return (\n (this.def.boss === \"ash_lord\" && this.hp <= this.def.hp / 2) ||\n (!!this.def.shell && this.peek < 0.3)\n );\n }\n\n /** Урон без отброса (солнце, лава, падение). */\n damage(amount: number) {\n if (this.dead || this.invulnerable) return;\n this.hp -= amount;\n this.flash = 0.4;\n events.emit(\"mobHurt\", { entity: this, amount });\n if (this.hp <= 0) {\n if (this.def.boss === \"dragon\")\n this.hp = 0; // дракон умирает сам — после анимации (DragonBrain)\n else this.dead = true;\n }\n }\n\n /** Удар: урон, отброс от атакующего, вспышка; мирный убегает, остальные злятся. */\n /** Дать имя с бирки: табличка над головой. Пустое имя — снять. */\n setName(name: string | null): void {\n const clean = (name ?? \"\").trim().slice(0, 24);\n this.customName = clean || null;\n if (this.nameSprite) {\n this.group.remove(this.nameSprite);\n this.nameSprite.material.map?.dispose();\n this.nameSprite.material.dispose();\n this.nameSprite = null;\n }\n if (!this.customName) return;\n const c = document.createElement(\"canvas\");\n c.width = 256;\n c.height = 64;\n const g = c.getContext(\"2d\")!;\n g.font = \"bold 34px monospace\";\n g.textAlign = \"center\";\n g.textBaseline = \"middle\";\n g.fillStyle = \"rgba(0,0,0,0.45)\";\n g.fillRect(0, 12, 256, 40);\n g.fillStyle = \"#ffffff\";\n g.fillText(this.customName, 128, 32, 248);\n const tex = new THREE.CanvasTexture(c);\n tex.colorSpace = THREE.SRGBColorSpace;\n const s = new THREE.Sprite(\n new THREE.SpriteMaterial({\n map: tex,\n depthTest: false,\n transparent: true,\n }),\n );\n s.scale.set(1.6, 0.4, 1);\n s.position.set(0, this.height + 0.45, 0);\n this.group.add(s);\n this.nameSprite = s;\n }\n\n /** Повесить эффект (дольше уже висящего — продлить). */\n applyEffect(id: EffectId, seconds: number): void {\n this.effects.set(id, Math.max(this.effects.get(id) ?? 0, seconds));\n }\n\n /** Сроки эффектов и урон от яда — зовётся из tick. */\n private _tickEffects(dt: number): void {\n if (!this.effects.size) return;\n for (const [id, t] of this.effects)\n if (t - dt <= 0) this.effects.delete(id);\n else this.effects.set(id, t - dt);\n if (!this.effects.has(\"poison\")) {\n this._poisonT = 0;\n return;\n }\n this._poisonT += dt;\n if (this._poisonT >= 2) {\n this._poisonT = 0;\n if (this.hp > 1) this.damage(1); // яд не добивает, как в Minecraft\n }\n }\n\n /** Секунд, что моб уже ломится в дверь перед собой. */\n private doorT = 0;\n\n /** Долбить дверь перед собой: через DOOR_TIME секунд она ломается совсем. */\n private _breakDoor(dt: number, world: ChunkManager): void {\n const ahead = new THREE.Vector3(\n -Math.sin(this.yaw),\n 0,\n -Math.cos(this.yaw),\n );\n const x = Math.floor(this.pos.x + ahead.x * 0.7),\n z = Math.floor(this.pos.z + ahead.z * 0.7),\n y = Math.floor(this.pos.y);\n const id = world.getBlock(x, y, z);\n if (BLOCKS[id]?.shape !== \"door\") {\n this.doorT = Math.max(0, this.doorT - dt);\n return;\n }\n this.doorT += dt;\n if (this.doorT < DOOR_TIME) return;\n this.doorT = 0;\n // дверь из двух половин: сносим обе, обломков не остаётся\n world.setBlock(x, y, z, B.air!);\n if (BLOCKS[world.getBlock(x, y + 1, z)]?.shape === \"door\")\n world.setBlock(x, y + 1, z, B.air!);\n if (BLOCKS[world.getBlock(x, y - 1, z)]?.shape === \"door\")\n world.setBlock(x, y - 1, z, B.air!);\n events.emit(\"doorBroken\", { x, y, z });\n }\n\n hurt(damage: number, fromPos: THREE.Vector3, byPlayer = true) {\n if (this.dead || this.invulnerable) return;\n this.justHurt = true;\n // броня волка берёт урон на себя, пока не износится\n if (this.wolfArmor > 0) {\n const a = Math.min(this.wolfArmor, damage);\n this.wolfArmor -= a;\n damage -= a;\n if (this.wolfArmor <= 0) events.emit(\"wolfArmorBroke\", { entity: this });\n }\n // броненосец: свёрнутый держит удар (−3), любой удар сворачивает на 6 с\n if (this.def.rollsUp) {\n if (this.rolledT > 0) damage = Math.max(0, damage - 3);\n this.rolledT = 6;\n }\n if (byPlayer) {\n this.hitByPlayerT = 5;\n events.emit(\"playerHitMob\", { entity: this }); // ручные волки бросаются на обидчика\n }\n const away = this.pos.clone().sub(fromPos).setY(0);\n if (away.lengthSq() < 1e-6)\n away.set(Math.random() - 0.5, 0, Math.random() - 0.5);\n away.normalize();\n if (!this.def.boss) this.vel.add(away.clone().multiplyScalar(6)).setY(5); // дракона не отбросить\n if (this.def.category === \"passive\") {\n this.state = \"flee\";\n // Ход моба — (-sin yaw, -cos yaw), и этот yaw даёт ход вдоль `away`, т.е. прочь от атакующего.\n this.targetYaw = Math.atan2(-away.x, -away.z);\n this.stateTime = 3;\n } else if (!this.tamed) this.aggro = true; // ручной на хозяина не злится\n if (byPlayer && this.def.groupAnger) this.angerCall = true;\n this.damage(damage);\n }\n\n updateVisual(alpha: number, time: number, bright = 1) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.group.position.copy(p);\n this.renderPos.copy(p);\n this._drawBeam(p);\n this._drawLeash();\n this.group.visible = !this.invisible;\n this.group.rotation.y = this.yaw;\n // Шипун раздувается перед взрывом\n // иглобрюх раздувается шаром\n const swell = this.def.explode\n ? 1 + (this.fuse / this.def.explode.fuse) * 0.18\n : this.def.puffs\n ? 1 + this.peek * 0.9\n : 1;\n this.group.scale.setScalar((this.baby ? 0.5 : 1) * swell);\n\n // анимации: шаг ногами, руки в такт (или замах), покачивание головы, крылья при падении\n const walk = Math.sin(this.animTime * 6) * 0.7;\n const swingArm =\n this.swing > 0 ? Math.sin((this.swing / 0.3) * Math.PI) * 1.2 : 0;\n const set = (name: string, fn: (a: AnimPart) => void) =>\n this.anims.get(name)?.forEach(fn);\n set(\"legL\", (a) => (a.g.rotation.x = a.base.x + walk));\n set(\"legR\", (a) => (a.g.rotation.x = a.base.x - walk));\n set(\"armL\", (a) => (a.g.rotation.x = a.base.x - walk * 0.5 - swingArm));\n set(\"armR\", (a) => (a.g.rotation.x = a.base.x + walk * 0.5 - swingArm));\n set(\n \"head\",\n (a) => (a.g.rotation.x = a.base.x + Math.sin(time * 1.7) * 0.08),\n );\n set(\"lid\", (a) => (a.g.rotation.x = a.base.x + this.peek * 0.9));\n const falling = !this.onGround && this.vel.y < -1;\n // курица хлопает, только падая; летуны машут всегда (дракон — медленно и широко)\n const wing = this.def.boss\n ? Math.sin(time * 3) * 0.6\n : falling || this.def.flies\n ? Math.sin(time * 30) * 0.9\n : 0.05;\n set(\"wingL\", (a) => (a.g.rotation.z = wing));\n set(\"wingR\", (a) => (a.g.rotation.z = -wing));\n for (const w of this.wool) w.visible = !this.sheared;\n // окрашенная овца: шерсти — свой материал цвета красителя (общие материалы не трогаем)\n if (this.woolShown !== this.woolColor) {\n this.woolShown = this.woolColor;\n const hex = DYE_COLORS.find(\n (d) => d.id === (this.woolColor ?? \"white\"),\n )?.hex;\n if (hex)\n for (const w of this.wool)\n w.traverse((o) => {\n if (!(o instanceof THREE.Mesh)) return;\n const mat = (o.material as THREE.MeshLambertMaterial).clone();\n mat.color.set(hex);\n o.material = mat;\n });\n }\n for (const t of this.tameParts) t.visible = this.tamed;\n for (const s of this.saddleParts) s.visible = this.saddled;\n for (const a of this.armorParts) a.visible = this.wolfArmor > 0;\n for (const o of this.outParts) o.visible = this.rolledT <= 0; // свёрнутый броненосец — один панцирь\n for (const k of this.packParts) k.visible = this.items.length > 0;\n for (const b of this.bannerParts) b.visible = this.captain;\n\n // свет места (пещера темнее), вспышка урона, горение на солнце, мигание фитиля\n const fuseBlink =\n this.fuse > 0 && Math.sin(time * (8 + this.fuse * 12)) > 0;\n // владыка: набирая силу, мерцает синим; в броне — синеватый отлив\n const emissive =\n this.flash > 0\n ? 0xff3333\n : this.burning && Math.sin(time * 20) > 0\n ? 0xff7a1a\n : fuseBlink\n ? 0xffffff\n : this.invulnerable\n ? Math.sin(time * 10) > 0\n ? 0x3050ff\n : 0x000000\n : this.shielded()\n ? 0x1a2a60\n : 0x000000;\n for (const { m, base, glow } of this.mats) {\n if (glow) continue;\n m.color.copy(base).multiplyScalar(this.glowT > 0 ? 2.4 : bright);\n const lm = m as THREE.MeshLambertMaterial;\n if (lm.emissive.getHex() !== emissive) lm.emissive.setHex(emissive);\n }\n }\n\n /** Луч глубинника: линия от глаза к цели, от лилового к жёлтому по мере зарядки. */\n /** Верёвка поводка — дочерняя линия группы (уходит вместе с мобом): от шеи до руки игрока. */\n private _drawLeash(): void {\n const to = this.leashTo; // к держателю поводка или к ламе впереди в караване (задаёт мозг)\n if (!to) {\n if (this.leashLine) this.leashLine.visible = false;\n return;\n }\n if (!this.leashLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.leashLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x8a6a3a }),\n );\n this.group.add(this.leashLine);\n }\n this.leashLine.visible = true;\n this.group.updateMatrixWorld();\n const s = this.group.scale.x || 1;\n const local = this.group.worldToLocal(to.clone());\n const a = this.leashLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, 0, (this.height * 0.8) / s, 0);\n a.setXYZ(1, local.x, local.y, local.z);\n a.needsUpdate = true;\n }\n\n private _drawBeam(p: THREE.Vector3): void {\n const to = this.beamTo;\n if (!to || !this.group.parent) {\n if (this.beamLine) this.beamLine.visible = false;\n return;\n }\n if (!this.beamLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.beamLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x9a60ff }),\n );\n this.group.parent.add(this.beamLine);\n }\n const a = this.beamLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, p.x, p.y + this.height / 2, p.z);\n a.setXYZ(1, to.x, to.y, to.z);\n a.needsUpdate = true;\n const k = Math.min(1, this.beamT / 3);\n (this.beamLine.material as THREE.LineBasicMaterial).color.setRGB(\n 0.6 + 0.4 * k,\n 0.4 + 0.5 * k,\n 1 - 0.8 * k,\n );\n this.beamLine.visible = true;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.group);\n if (this.beamLine) {\n scene.remove(this.beamLine);\n this.beamLine.geometry.dispose();\n (this.beamLine.material as THREE.Material).dispose();\n }\n // traverse даёт Object3D; узкий каст к Mesh для доступа к geometry (проверка isMesh — рантайм).\n this.group.traverse((o) => {\n if ((o as THREE.Mesh).isMesh) (o as THREE.Mesh).geometry.dispose();\n });\n for (const { m } of this.mats) m.dispose();\n }\n\n serialize() {\n return {\n kind: \"mob\",\n type: this.def.name,\n pos: this.pos.toArray(),\n hp: this.hp,\n baby: this.baby,\n sheared: this.sheared,\n ...(this.uses.length ? { uses: [...this.uses] } : {}),\n ...(this.jobSite ? { jobSite: [...this.jobSite] } : {}),\n ...(this.traded ? { traded: true } : {}),\n ...(this.villagerXp > 0 ? { villagerXp: this.villagerXp } : {}),\n ...(this.curedType ? { curedType: this.curedType } : {}),\n ...(this.curing > 0 ? { curing: Math.round(this.curing) } : {}),\n ...(this.tamed ? { tamed: true } : {}),\n ...(this.customName ? { customName: this.customName } : {}),\n ...(this.sitting ? { sitting: true } : {}),\n ...(this.saddled ? { saddled: true } : {}),\n ...(this.woolColor ? { woolColor: this.woolColor } : {}),\n ...(this.wolfArmor > 0 ? { wolfArmor: this.wolfArmor } : {}),\n ...(this.carry ? { carry: this.carry.item, bag: this.bag } : {}),\n ...(this.items.length\n ? { items: this.items.map((s) => (s ? { ...s } : null)) }\n : {}),\n ...(this.leashed ? { leashed: true } : {}),\n ...(this.leashPost ? { leashPost: [...this.leashPost] } : {}),\n ...(this.leaveT !== Infinity ? { leaveT: Math.round(this.leaveT) } : {}),\n ...(this.home ? { home: [...this.home] } : {}),\n ...(this.pollen ? { pollen: true } : {}),\n ...(this.captain ? { captain: true } : {}),\n };\n }\n}\n"
|
|
256
|
+
"content": "// Универсальный моб: риг собирается из parts-описания реестра (BoxGeometry), физика — по коробкам\n// блоков (общая с игроком), решения — entities/MobBrain.ts, анимации процедурные.\n// Добавление моба = запись в registry/mobs/*.ts, этот код не меняется.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { DYE_COLORS } from \"../registry/blocks/colors\";\nimport type { ItemStack as Stack } from \"../types\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { moveBody } from \"../physics/Collision\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { EffectId, MobDef } from \"../types\";\nimport { think, type MobCtx } from \"./MobBrain\";\n\nconst PX = 1 / 16; // части описаны в пикселях: 16 px = 1 блок\n/** Детёныш вырастает за 20 минут, как в Minecraft. */\nexport const GROW_TIME = 1200;\n/** Для страйдера лава — полный куб (physics/Collision, extraSolid). */\nconst isLava = (id: number): boolean => id === B.lava;\n\n/** follow — ручной идёт за хозяином (MobBrain.petThink). */\nexport type MobState =\n \"idle\" | \"wander\" | \"flee\" | \"chase\" | \"tempt\" | \"love\" | \"follow\";\n\ninterface AnimPart {\n g: THREE.Group;\n base: THREE.Euler;\n}\n\n/**\n * Сколько секунд зомби ломится в дверь, прежде чем она рухнет. Счётчик копится, пока моб\n * упирается в дверь, и МЕДЛЕННО стекает, когда он отскочил: иначе каждый отскок сбрасывал бы\n * долбёжку в ноль и дверь стояла бы вечно (проверено на живом зомби).\n */\nconst DOOR_TIME = 3;\n\nexport class Mob {\n id: number;\n kind = \"mob\" as const;\n def: MobDef;\n hp: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n targetYaw: number;\n onGround = false;\n dead = false;\n state: MobState = \"idle\";\n stateTime: number;\n /** Желаемая скорость этого тика (мозг); отрицательная — пятиться. */\n speed = 0;\n /**\n * Эффекты на мобе: id → сколько ещё секунд. Понимает два — яд (1 урона раз в 2 с, но не\n * добивает) и замедление (вдвое медленнее). Их вешают отравленные стрелы (systems/TippedArrow).\n */\n effects = new Map<EffectId, number>();\n /** Имя с бирки: висит над мобом и держит его в мире (не исчезает вдали от игрока). */\n customName: string | null = null;\n private nameSprite: THREE.Sprite | null = null;\n private _poisonT = 0;\n /** Желаемая вертикальная скорость летуна (мозг). */\n climbWish = 0;\n /** Куда идти, если не туда, куда смотрит (владыка летит по кругу, глядя на игрока); null — по yaw. */\n moveYaw: number | null = null;\n /** Пауза до следующего прыжка (слизень). */\n hopT = 0;\n /** Часы пике фантома: из каждых 6 с — 2 с вниз на цель. */\n swoopT = 0;\n /** Пчела: свой улей, есть ли пыльца, цветок-цель, таймеры поиска и сбора. */\n home: [number, number, number] | null = null;\n pollen = false;\n beeTarget: [number, number, number] | null = null;\n beeT = 0;\n beeWork = 0;\n /** Ударил игрок моба со стайной злостью — EntityManager разбудит стаю на тике. */\n angerCall = false;\n /** Дракон: состояние мозга босса (entities/DragonBrain). Не сохраняется — после загрузки кружит. */\n bossState: unknown = null;\n /** Кристалл, что сейчас лечит дракона (id): луч и урон дракону от взрыва этого кристалла. */\n healFrom: number | null = null;\n /** Не получает урона (Пепельный владыка набирает силу). */\n invulnerable = false;\n /** Панцирник: насколько открыта крышка (0 — закрыт, 1 — открыт); иглобрюх — насколько раздут. */\n peek = 0;\n /** Рыба: сколько секунд на суше. */\n dryT = 0;\n /** Страж (entities/WardenBrain): злость, место последней вибрации, заряд и остывание звукового удара. */\n anger = 0;\n suspect: [number, number, number] | null = null;\n boomT = 0;\n boomCd = 0;\n /** Страж: сколько ещё вылезает из земли; сколько простаивает без тревог; до следующей Тьмы. */\n emergeT = 0;\n idleT = 0;\n darkT = 0;\n /** Коза (entities/GoatBrain): до следующего тарана, пригибается, разгон, сколько рогов осталось. */\n ramCd = 0;\n ramT = 0;\n dashT = 0;\n horns = 2;\n /** Кричащая коза (2%, как в Minecraft): таранит в разы чаще и кричит. */\n screaming = Math.random() < 0.02;\n /** Лягушка (entities/FrogBrain): до следующего броска языком. */\n tongueCd = 0;\n /** Нюхач (entities/SnifferBrain): до следующего принюхивания; сколько ещё роет. */\n sniffCd = 0;\n digT = 0;\n /** Моб волны спавнера испытаний: координаты спавнера (Game засчитывает убитого; вдали не исчезает). */\n trialFrom: [number, number, number] | null = null;\n /** Капитан мародёров: знамя на спине, за его смерть — Дурное знамение. */\n captain = false;\n /** Участник набега: идёт к goal (центру деревни) и не исчезает вдали. */\n raider = false;\n /** Секунд подсветки от колокола (systems: EntityManager.markRaiders). */\n glowT = 0;\n goal: THREE.Vector3 | null = null;\n /** Ворожей: до следующего заклинания и призванные бесята. */\n castT = 2;\n minions: Mob[] = [];\n /** Бесёнок: сколько ещё жить (сек). */\n lifeT = Infinity;\n private readonly bannerParts: THREE.Object3D[] = [];\n /** Глубинник: сколько заряжается луч (сек), куда он смотрит (null — не заряжает), до следующей Усталости. */\n beamT = 0;\n beamTo: THREE.Vector3 | null = null;\n fatigueT = 3;\n private beamLine: THREE.Line | null = null;\n inWater = false;\n inLava = false;\n hitWall = false;\n baby: boolean;\n growT = 0;\n /** Секунд «любви» после кормления (ищет пару). */\n loveT = 0;\n breedCd = 0;\n fuse = 0;\n attackCd = 0;\n shootCd = 0;\n burnT = 0;\n burning = false;\n /** Снеговик: накопитель секунд в жаре или воде (урон 1 за секунду). */\n snowT = 0;\n /** Попугай на плече игрока: 0 — левое, 1 — правое, null — нет; shoulderCd — пауза после спрыгивания. */\n onShoulder: 0 | 1 | null = null;\n shoulderCd = 0;\n /** Овца: цвет шерсти (id красителя), null — белая; woolShown — какой цвет уже нарисован. */\n woolColor: string | null = null;\n private woolShown: string | null = null;\n /** Броненосец: секунд до разворота (0 — не свёрнут); до следующего сброшенного щитка. */\n rolledT = 0;\n scuteT = 300 + Math.random() * 300;\n /** Волк: запас брони из щитков (0 — без брони). */\n wolfArmor = 0;\n /** Дух-помощник: что велено собирать (образец) и сколько уже собрано. */\n carry: { item: string; count: number } | null = null;\n bag = 0;\n /** Вьюк (осёл, мул, лама): слоты сундука; пусто — не навьючен. */\n items: (Stack | null)[] = [];\n /** На поводке у игрока; leashTo — куда тянется верёвка (рука игрока), задаёт мозг. */\n leashed = false;\n leashTo: THREE.Vector3 | null = null;\n /** Поводок привязан к забору в этой клетке (null — держит игрок). */\n leashPost: [number, number, number] | null = null;\n /** Караван лам: за кем идёт (id моба), не сохраняется. */\n followId: number | null = null;\n /** Поводок держит другой моб (ламы странствующего торговца), не сохраняется. */\n leashMobId: number | null = null;\n /** Сетевая игра: поводок держит игрок с этим слотом (null — свой игрок, одиночная игра). */\n leashSlot: number | null = null;\n /** Секунд до ухода (MobDef.leavesAfter); Infinity — не уходит. */\n leaveT = Infinity;\n /** С ним торгуют (открыт экран торговли) — стоит и смотрит на игрока. */\n trading = false;\n /** Невидим (странствующий торговец ночью — зелье невидимости). */\n invisible = false;\n private leashLine: THREE.Line | null = null;\n private readonly packParts: THREE.Object3D[] = [];\n private readonly armorParts: THREE.Object3D[] = [];\n private readonly outParts: THREE.Object3D[] = [];\n aggro = false;\n justHurt = false;\n /** Недавно бил игрок — за смерть дадут опыт. */\n hitByPlayerT = 0;\n /** Шипун взорвался сам — дропа нет, как в Minecraft. */\n exploded = false;\n sheared = false;\n /** Житель: сколько раз совершена каждая сделка (по индексу в списке профессии) до пополнения. */\n uses: number[] = [];\n /** Житель: занятый рабочий блок (systems/Jobs), null — безработный. */\n jobSite: [number, number, number] | null = null;\n /** Житель уже торговал — профессию не теряет, даже если рабочий блок сломали. */\n traded = false;\n /** Житель: опыт профессии (systems/Trading, levelOf) — открывает следующие сделки. */\n villagerXp = 0;\n /** Зомби-житель: секунд до выздоровления (0 — не лечится) и кем он был (systems/Zombify). */\n curing = 0;\n curedType: string | null = null;\n /** Секунд до следующего пополнения товара за работой у своего блока (не сохраняется). */\n workT = 0;\n /** Ручной (волк, кошка, лошадь), сидит по команде, осёдлан (лошадь). */\n tamed = false;\n sitting = false;\n saddled = false;\n /** Ручной волк: кого атакует (id моба). */\n targetId: number | null = null;\n /** На лошади сидит игрок (Player.riding): мозг молчит, ведёт седок. */\n rider = false;\n private readonly riderWish = { x: 0, z: 0, t: 0 };\n /** Где нарисован (интерполяция) — за это держится седок. */\n readonly renderPos = new THREE.Vector3();\n private readonly tameParts: THREE.Object3D[] = [];\n private readonly saddleParts: THREE.Object3D[] = [];\n /** Замах руки (атака, выстрел). */\n swing = 0;\n animTime = 0;\n flash = 0;\n private lavaT = 0;\n group: THREE.Group;\n mesh: THREE.Object3D;\n private readonly anims = new Map<string, AnimPart[]>();\n private readonly mats: {\n m: THREE.MeshLambertMaterial | THREE.MeshBasicMaterial;\n base: THREE.Color;\n glow: boolean;\n }[] = [];\n private readonly wool: THREE.Object3D[] = [];\n\n constructor(\n id: number,\n def: MobDef,\n x: number,\n y: number,\n z: number,\n baby = false,\n ) {\n this.id = id;\n this.def = def;\n this.hp = def.hp;\n this.baby = baby;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3();\n this.yaw = Math.random() * Math.PI * 2;\n this.targetYaw = this.yaw;\n this.stateTime = 1 + Math.random() * 2;\n\n // риг: каждая часть — Group в точке pivot, бокс смещён относительно pivot\n this.group = new THREE.Group();\n for (const p of def.parts) {\n const color = new THREE.Color(p.color);\n const mat = p.glow\n ? new THREE.MeshBasicMaterial({ color })\n : new THREE.MeshLambertMaterial({ color });\n this.mats.push({ m: mat, base: color.clone(), glow: !!p.glow });\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(p.size[0] * PX, p.size[1] * PX, p.size[2] * PX),\n mat,\n );\n mesh.castShadow = mesh.receiveShadow = true;\n const pivot = new THREE.Group();\n const pv = p.pivot ?? p.pos;\n pivot.position.set(pv[0] * PX, pv[1] * PX, pv[2] * PX);\n mesh.position.set(\n (p.pos[0] - pv[0]) * PX,\n (p.pos[1] - pv[1]) * PX,\n (p.pos[2] - pv[2]) * PX,\n );\n if (p.pose) pivot.rotation.set(p.pose[0], p.pose[1], p.pose[2]);\n pivot.add(mesh);\n this.group.add(pivot);\n if (p.anim) {\n const list = this.anims.get(p.anim) ?? [];\n list.push({ g: pivot, base: pivot.rotation.clone() });\n this.anims.set(p.anim, list);\n }\n if (p.name.startsWith(\"wool\")) this.wool.push(pivot);\n if (p.name.startsWith(\"tame\")) this.tameParts.push(pivot);\n if (p.name.startsWith(\"saddle\")) this.saddleParts.push(pivot);\n if (p.name.startsWith(\"armor\")) this.armorParts.push(pivot);\n if (p.name.startsWith(\"out\")) this.outParts.push(pivot);\n if (p.name.startsWith(\"pack\")) this.packParts.push(pivot);\n if (p.name.startsWith(\"banner\")) this.bannerParts.push(pivot);\n }\n if (def.lifespan)\n this.lifeT =\n def.lifespan[0] + Math.random() * (def.lifespan[1] - def.lifespan[0]);\n if (def.leavesAfter)\n this.leaveT =\n def.leavesAfter[0] +\n Math.random() * (def.leavesAfter[1] - def.leavesAfter[0]);\n // овцы в Minecraft изредка бывают чёрные, серые, светло-серые, коричневые и совсем редко розовые\n if (def.dyeable) {\n const r = Math.random();\n this.woolColor =\n r < 0.05\n ? \"black\"\n : r < 0.1\n ? \"gray\"\n : r < 0.15\n ? \"light_gray\"\n : r < 0.18\n ? \"brown\"\n : r < 0.1816\n ? \"pink\"\n : null;\n }\n this.mesh = this.group; // единообразие с ItemDrop\n }\n\n /** Что даёт стрижка: шерсть своего цвета (овца), иначе shearDrop. */\n woolItem(): string {\n return this.woolColor\n ? `${this.woolColor}_wool`\n : (this.def.shearDrop?.item ?? \"white_wool\");\n }\n\n /** Высота седока над мобом (лошадь). */\n get seatY(): number {\n return this.def.rideable?.seat ?? 1;\n }\n\n /** Седок жмёт «вперёд»: куда вести лошадь (держится 0.15 с, седок повторяет каждый кадр). */\n push(dx: number, dz: number, _dt: number): void {\n this.riderWish.x = dx;\n this.riderWish.z = dz;\n this.riderWish.t = 0.15;\n }\n\n get width(): number {\n return this.def.box.w * (this.baby ? 0.5 : 1);\n }\n get height(): number {\n return this.def.box.h * (this.baby ? 0.5 : 1);\n }\n\n tick(dt: number, c: MobCtx) {\n this.prevPos.copy(this.pos);\n const dec = (v: number) => Math.max(0, v - dt);\n this.flash = dec(this.flash);\n this.attackCd = dec(this.attackCd);\n this.shootCd = dec(this.shootCd);\n this.loveT = dec(this.loveT);\n this.breedCd = dec(this.breedCd);\n this.hitByPlayerT = dec(this.hitByPlayerT);\n this.swing = dec(this.swing);\n // попугай на плече: едет с игроком (мозг и физика молчат); игрок присел или умер — слетает\n if (this.onShoulder !== null) {\n const p = c.player;\n if (p.dead || p._sneaking || p.shoulders[this.onShoulder] !== this.id) {\n if (p.shoulders[this.onShoulder] === this.id)\n p.shoulders[this.onShoulder] = null;\n this.onShoulder = null;\n this.shoulderCd = 3; // не запрыгивать сразу обратно\n } else {\n const side = this.onShoulder === 1 ? 1 : -1;\n this.pos.set(\n p.pos.x + Math.cos(p.yaw) * 0.4 * side,\n p.pos.y + 1.45,\n p.pos.z - Math.sin(p.yaw) * 0.4 * side,\n );\n this.vel.set(0, 0, 0);\n this.yaw = this.targetYaw = p.yaw;\n this.speed = 0;\n this.animTime += dt;\n return;\n }\n }\n this._tickEffects(dt);\n this.glowT = Math.max(0, this.glowT - dt);\n this.shoulderCd = dec(this.shoulderCd);\n this.rolledT = dec(this.rolledT);\n // странствующий торговец и его ламы: срок вышел — уходят (исчезают без дропа)\n if (this.leaveT !== Infinity) {\n this.leaveT -= dt;\n if (this.leaveT <= 0 && !this.dead) {\n this.dead = true; // hp > 0 — без дропа\n events.emit(\"mobLeft\", { entity: this });\n }\n }\n // броненосец сам сбрасывает щиток раз в 5–10 минут\n if (this.def.rollsUp && !this.baby) {\n this.scuteT -= dt;\n if (this.scuteT <= 0) {\n this.scuteT = 300 + Math.random() * 300;\n events.emit(\"mobDrop\", {\n x: this.pos.x,\n y: this.pos.y + 0.3,\n z: this.pos.z,\n item: \"armadillo_scute\",\n });\n }\n }\n // бесёнок: срок вышел — теряет по 1 здоровью в секунду\n if (this.def.lifespan) {\n this.lifeT -= dt;\n if (\n this.lifeT <= 0 &&\n Math.floor(this.lifeT) !== Math.floor(this.lifeT + dt)\n )\n this.damage(1);\n }\n if (this.baby) {\n this.growT += dt;\n if (this.growT >= GROW_TIME) {\n this.baby = false;\n if (this.def.growDrop) events.emit(\"mobGrew\", { entity: this }); // черепашонок — щиток\n }\n }\n // головастик: вырастает в другой вид (лягушку по биому — EntityManager, событие mobMature)\n // житель Пекла вне Пекла превращается (свинорыл → мёртвый свинорыл, хоглин → зоглин)\n if (\n this.def.growsInto &&\n !this.dead &&\n !(this.def.zombifies && c.world.dimension === \"inferno\")\n ) {\n this.growT += dt;\n if (this.growT >= (this.def.growTime ?? GROW_TIME)) {\n this.growT = -Infinity;\n events.emit(\"mobMature\", { entity: this });\n }\n }\n // Кристалл Предела: ни мозга, ни физики — висит над колонной\n if (this.def.stationary) {\n this.animTime += dt;\n return;\n }\n // панцирник сидит на блоке: мозг решает, открыться ли и стрелять; ни шагов, ни гравитации\n if (this.def.anchored) {\n think(this, c, dt);\n this.animTime += dt;\n return;\n }\n\n const world = c.world;\n const feet = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n this.inWater = feet === B.water || !!BLOCKS[feet]?.waterlogged;\n this.inLava = feet === B.lava;\n // рыба на суше: 5 с — и по 1 урону в секунду; бьётся, подпрыгивая\n if (this.def.needsWater) {\n if (this.inWater) this.dryT = 0;\n else {\n this.dryT += dt;\n // рыба задыхается через 5 с и бьётся на месте; аксолотль ходит по суше dryTime секунд\n const limit = this.def.dryTime ?? 5;\n if (\n this.dryT > limit &&\n Math.floor(this.dryT) !== Math.floor(this.dryT - dt)\n )\n this.damage(1);\n if (!this.def.dryTime && this.onGround && Math.random() < dt * 3)\n this.vel.y = 3;\n }\n }\n\n if (this.rider) {\n // под седоком: направление и галоп — от игрока; ступеньки перепрыгивает сам (hitWall ниже)\n const w = this.riderWish;\n w.t -= dt;\n if (w.t > 0 && (w.x || w.z)) {\n this.targetYaw = this.yaw = Math.atan2(-w.x, -w.z);\n this.speed = this.def.rideable?.speed ?? this.def.walkSpeed;\n } else this.speed = 0;\n } else think(this, c, dt);\n\n // плавный поворот к целевому направлению\n let dy = this.targetYaw - this.yaw;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n this.yaw += dy * Math.min(1, 6 * dt);\n\n const moveYaw = this.moveYaw ?? this.yaw;\n const slow = this.effects.has(\"slowness\") ? 0.5 : 1;\n const wishX = -Math.sin(moveYaw) * this.speed * slow;\n const wishZ = -Math.cos(moveYaw) * this.speed * slow;\n const accel = Math.min(1, 10 * dt);\n this.vel.x += (wishX - this.vel.x) * accel;\n this.vel.z += (wishZ - this.vel.z) * accel;\n if (this.def.hops) {\n // прыжками: на земле стоит и выжидает, в воздухе летит туда, куда смотрит\n if (this.onGround) {\n this.vel.x = 0;\n this.vel.z = 0;\n this.hopT -= dt;\n if (this.speed !== 0 && this.hopT <= 0) {\n this.vel.y = 7;\n this.hopT = 0.5 + Math.random() * 1.5;\n }\n } else {\n this.vel.x = wishX * 1.5;\n this.vel.z = wishZ * 1.5;\n }\n }\n // летун и пловец в воде: без гравитации, высоту задаёт мозг (climbWish)\n if (this.def.flies || (this.def.swims && this.inWater))\n this.vel.y += (this.climbWish - this.vel.y) * accel;\n else {\n this.vel.y -= 22 * dt;\n if (this.inWater || this.inLava) this.vel.y = Math.max(this.vel.y, 1.5); // всплывает\n }\n\n if (this.def.noClip)\n this.pos.addScaledVector(this.vel, dt); // дракон: сквозь блоки\n else this._move(dt, world);\n // пустота под миром (край острова Предела): падал бы вечно — ручной волк или названная бирка\n // не исчезают по дальности и висели бы в списке сущностей навсегда. Пустота убивает, как в Minecraft\n if (this.pos.y < -64 && !this.def.boss && !this.dead) {\n this.hp = 0;\n this.dead = true;\n }\n\n // зомби осады ломает дверь, в которую упёрся (как в Minecraft на высокой сложности)\n // ⚠ у обычного враждебного моба aggro остаётся false: злость — это поле для НЕЙТРАЛЬНЫХ\n // (свинорыл, голем), а зомби враждебен по категории. Дверь ломает и тот, и другой.\n if (\n this.def.breaksDoors &&\n rules().doorBreaking && // только на высокой сложности (systems/Difficulty)\n (this.aggro || this.def.category === \"hostile\") &&\n this.speed !== 0\n )\n this._breakDoor(dt, world);\n else this.doorT = Math.max(0, this.doorT - dt); // короткий отскок не сбрасывает долбёжку\n\n // упёрся в стену на ходу — подпрыгнуть (на 1 блок); паук лезет по стене\n if (this.speed !== 0 && this.hitWall && !this.def.hops) {\n if (this.def.climbs) this.vel.y = 3;\n else if (this.onGround) this.vel.y = 7;\n }\n\n // лава и огонь жгут: 4 и 1 урона каждые полсекунды\n if (\n (this.inLava ||\n feet === B.fire ||\n feet === B.soul_fire ||\n feet === B.campfire ||\n feet === B.soul_campfire) &&\n !this.def.fireImmune\n ) {\n this.lavaT -= dt;\n if (this.lavaT <= 0) {\n this.lavaT = 0.5;\n this.damage(this.inLava ? 4 : 1);\n }\n }\n\n this.animTime +=\n dt * (this.speed !== 0 ? Math.hypot(this.vel.x, this.vel.z) * 3 : 0);\n }\n\n /** Шаг с коллизиями по коробкам форм — тот же код, что у игрока (physics/Collision). */\n _move(dt: number, world: ChunkManager) {\n const r = moveBody(\n world,\n {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n hw: this.width / 2,\n h: this.height,\n },\n this.vel.x * dt,\n this.vel.y * dt,\n this.vel.z * dt,\n // страйдер стоит на лаве, как на полу\n {\n step: this.onGround ? 0.6 : 0,\n onGround: this.onGround,\n ...(this.def.walksOnLava ? { extraSolid: isLava } : {}),\n },\n );\n this.pos.x += r.dx;\n this.pos.y += r.dy;\n this.pos.z += r.dz;\n if (r.hitDown) {\n this.onGround = true;\n this.vel.y = 0;\n } else {\n this.onGround = false;\n if (r.hitUp) this.vel.y = 0;\n }\n if (r.hitX) this.vel.x = 0;\n if (r.hitZ) this.vel.z = 0;\n this.hitWall = r.hitX || r.hitZ;\n }\n\n /** Стрелы отскакивают: Пепельный владыка на половине здоровья, закрытый панцирник. */\n shielded(): boolean {\n return (\n (this.def.boss === \"ash_lord\" && this.hp <= this.def.hp / 2) ||\n (!!this.def.shell && this.peek < 0.3)\n );\n }\n\n /** Урон без отброса (солнце, лава, падение). */\n damage(amount: number) {\n if (this.dead || this.invulnerable) return;\n this.hp -= amount;\n this.flash = 0.4;\n events.emit(\"mobHurt\", { entity: this, amount });\n if (this.hp <= 0) {\n if (this.def.boss === \"dragon\")\n this.hp = 0; // дракон умирает сам — после анимации (DragonBrain)\n else this.dead = true;\n }\n }\n\n /** Удар: урон, отброс от атакующего, вспышка; мирный убегает, остальные злятся. */\n /** Дать имя с бирки: табличка над головой. Пустое имя — снять. */\n setName(name: string | null): void {\n const clean = (name ?? \"\").trim().slice(0, 24);\n this.customName = clean || null;\n if (this.nameSprite) {\n this.group.remove(this.nameSprite);\n this.nameSprite.material.map?.dispose();\n this.nameSprite.material.dispose();\n this.nameSprite = null;\n }\n if (!this.customName) return;\n const c = document.createElement(\"canvas\");\n c.width = 256;\n c.height = 64;\n const g = c.getContext(\"2d\")!;\n g.font = \"bold 34px monospace\";\n g.textAlign = \"center\";\n g.textBaseline = \"middle\";\n g.fillStyle = \"rgba(0,0,0,0.45)\";\n g.fillRect(0, 12, 256, 40);\n g.fillStyle = \"#ffffff\";\n g.fillText(this.customName, 128, 32, 248);\n const tex = new THREE.CanvasTexture(c);\n tex.colorSpace = THREE.SRGBColorSpace;\n const s = new THREE.Sprite(\n new THREE.SpriteMaterial({\n map: tex,\n depthTest: false,\n transparent: true,\n }),\n );\n s.scale.set(1.6, 0.4, 1);\n s.position.set(0, this.height + 0.45, 0);\n this.group.add(s);\n this.nameSprite = s;\n }\n\n /** Повесить эффект (дольше уже висящего — продлить). */\n applyEffect(id: EffectId, seconds: number): void {\n this.effects.set(id, Math.max(this.effects.get(id) ?? 0, seconds));\n }\n\n /** Сроки эффектов и урон от яда — зовётся из tick. */\n private _tickEffects(dt: number): void {\n if (!this.effects.size) return;\n for (const [id, t] of this.effects)\n if (t - dt <= 0) this.effects.delete(id);\n else this.effects.set(id, t - dt);\n if (!this.effects.has(\"poison\")) {\n this._poisonT = 0;\n return;\n }\n this._poisonT += dt;\n if (this._poisonT >= 2) {\n this._poisonT = 0;\n if (this.hp > 1) this.damage(1); // яд не добивает, как в Minecraft\n }\n }\n\n /** Секунд, что моб уже ломится в дверь перед собой. */\n private doorT = 0;\n\n /** Долбить дверь перед собой: через DOOR_TIME секунд она ломается совсем. */\n private _breakDoor(dt: number, world: ChunkManager): void {\n const ahead = new THREE.Vector3(\n -Math.sin(this.yaw),\n 0,\n -Math.cos(this.yaw),\n );\n const x = Math.floor(this.pos.x + ahead.x * 0.7),\n z = Math.floor(this.pos.z + ahead.z * 0.7),\n y = Math.floor(this.pos.y);\n const id = world.getBlock(x, y, z);\n if (BLOCKS[id]?.shape !== \"door\") {\n this.doorT = Math.max(0, this.doorT - dt);\n return;\n }\n this.doorT += dt;\n if (this.doorT < DOOR_TIME) return;\n this.doorT = 0;\n // дверь из двух половин: сносим обе, обломков не остаётся\n world.setBlock(x, y, z, B.air!);\n if (BLOCKS[world.getBlock(x, y + 1, z)]?.shape === \"door\")\n world.setBlock(x, y + 1, z, B.air!);\n if (BLOCKS[world.getBlock(x, y - 1, z)]?.shape === \"door\")\n world.setBlock(x, y - 1, z, B.air!);\n events.emit(\"doorBroken\", { x, y, z });\n }\n\n hurt(damage: number, fromPos: THREE.Vector3, byPlayer = true) {\n if (this.dead || this.invulnerable) return;\n this.justHurt = true;\n // броня волка берёт урон на себя, пока не износится\n if (this.wolfArmor > 0) {\n const a = Math.min(this.wolfArmor, damage);\n this.wolfArmor -= a;\n damage -= a;\n if (this.wolfArmor <= 0) events.emit(\"wolfArmorBroke\", { entity: this });\n }\n // броненосец: свёрнутый держит удар (−3), любой удар сворачивает на 6 с\n if (this.def.rollsUp) {\n if (this.rolledT > 0) damage = Math.max(0, damage - 3);\n this.rolledT = 6;\n }\n if (byPlayer) {\n this.hitByPlayerT = 5;\n events.emit(\"playerHitMob\", { entity: this }); // ручные волки бросаются на обидчика\n }\n const away = this.pos.clone().sub(fromPos).setY(0);\n if (away.lengthSq() < 1e-6)\n away.set(Math.random() - 0.5, 0, Math.random() - 0.5);\n away.normalize();\n if (!this.def.boss) this.vel.add(away.clone().multiplyScalar(6)).setY(5); // дракона не отбросить\n if (this.def.category === \"passive\") {\n this.state = \"flee\";\n // Ход моба — (-sin yaw, -cos yaw), и этот yaw даёт ход вдоль `away`, т.е. прочь от атакующего.\n this.targetYaw = Math.atan2(-away.x, -away.z);\n this.stateTime = 3;\n } else if (!this.tamed) this.aggro = true; // ручной на хозяина не злится\n if (byPlayer && this.def.groupAnger) this.angerCall = true;\n this.damage(damage);\n }\n\n updateVisual(alpha: number, time: number, bright = 1) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.group.position.copy(p);\n this.renderPos.copy(p);\n this._drawBeam(p);\n this._drawLeash();\n this.group.visible = !this.invisible;\n this.group.rotation.y = this.yaw;\n // Шипун раздувается перед взрывом\n // иглобрюх раздувается шаром\n const swell = this.def.explode\n ? 1 + (this.fuse / this.def.explode.fuse) * 0.18\n : this.def.puffs\n ? 1 + this.peek * 0.9\n : 1;\n this.group.scale.setScalar((this.baby ? 0.5 : 1) * swell);\n\n // анимации: шаг ногами, руки в такт (или замах), покачивание головы, крылья при падении\n const walk = Math.sin(this.animTime * 6) * 0.7;\n const swingArm =\n this.swing > 0 ? Math.sin((this.swing / 0.3) * Math.PI) * 1.2 : 0;\n const set = (name: string, fn: (a: AnimPart) => void) =>\n this.anims.get(name)?.forEach(fn);\n set(\"legL\", (a) => (a.g.rotation.x = a.base.x + walk));\n set(\"legR\", (a) => (a.g.rotation.x = a.base.x - walk));\n set(\"armL\", (a) => (a.g.rotation.x = a.base.x - walk * 0.5 - swingArm));\n set(\"armR\", (a) => (a.g.rotation.x = a.base.x + walk * 0.5 - swingArm));\n set(\n \"head\",\n (a) => (a.g.rotation.x = a.base.x + Math.sin(time * 1.7) * 0.08),\n );\n set(\"lid\", (a) => (a.g.rotation.x = a.base.x + this.peek * 0.9));\n const falling = !this.onGround && this.vel.y < -1;\n // курица хлопает, только падая; летуны машут всегда (дракон — медленно и широко)\n const wing = this.def.boss\n ? Math.sin(time * 3) * 0.6\n : falling || this.def.flies\n ? Math.sin(time * 30) * 0.9\n : 0.05;\n set(\"wingL\", (a) => (a.g.rotation.z = wing));\n set(\"wingR\", (a) => (a.g.rotation.z = -wing));\n for (const w of this.wool) w.visible = !this.sheared;\n // окрашенная овца: шерсти — свой материал цвета красителя (общие материалы не трогаем)\n if (this.woolShown !== this.woolColor) {\n this.woolShown = this.woolColor;\n const hex = DYE_COLORS.find(\n (d) => d.id === (this.woolColor ?? \"white\"),\n )?.hex;\n if (hex)\n for (const w of this.wool)\n w.traverse((o) => {\n if (!(o instanceof THREE.Mesh)) return;\n const mat = (o.material as THREE.MeshLambertMaterial).clone();\n mat.color.set(hex);\n o.material = mat;\n });\n }\n for (const t of this.tameParts) t.visible = this.tamed;\n for (const s of this.saddleParts) s.visible = this.saddled;\n for (const a of this.armorParts) a.visible = this.wolfArmor > 0;\n for (const o of this.outParts) o.visible = this.rolledT <= 0; // свёрнутый броненосец — один панцирь\n for (const k of this.packParts) k.visible = this.items.length > 0;\n for (const b of this.bannerParts) b.visible = this.captain;\n\n // свет места (пещера темнее), вспышка урона, горение на солнце, мигание фитиля\n const fuseBlink =\n this.fuse > 0 && Math.sin(time * (8 + this.fuse * 12)) > 0;\n // владыка: набирая силу, мерцает синим; в броне — синеватый отлив\n const emissive =\n this.flash > 0\n ? 0xff3333\n : this.burning && Math.sin(time * 20) > 0\n ? 0xff7a1a\n : fuseBlink\n ? 0xffffff\n : this.invulnerable\n ? Math.sin(time * 10) > 0\n ? 0x3050ff\n : 0x000000\n : this.shielded()\n ? 0x1a2a60\n : 0x000000;\n for (const { m, base, glow } of this.mats) {\n if (glow) continue;\n m.color.copy(base).multiplyScalar(this.glowT > 0 ? 2.4 : bright);\n const lm = m as THREE.MeshLambertMaterial;\n if (lm.emissive.getHex() !== emissive) lm.emissive.setHex(emissive);\n }\n }\n\n /** Луч глубинника: линия от глаза к цели, от лилового к жёлтому по мере зарядки. */\n /** Верёвка поводка — дочерняя линия группы (уходит вместе с мобом): от шеи до руки игрока. */\n private _drawLeash(): void {\n const to = this.leashTo; // к держателю поводка или к ламе впереди в караване (задаёт мозг)\n if (!to) {\n if (this.leashLine) this.leashLine.visible = false;\n return;\n }\n if (!this.leashLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.leashLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x8a6a3a }),\n );\n this.group.add(this.leashLine);\n }\n this.leashLine.visible = true;\n this.group.updateMatrixWorld();\n const s = this.group.scale.x || 1;\n const local = this.group.worldToLocal(to.clone());\n const a = this.leashLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, 0, (this.height * 0.8) / s, 0);\n a.setXYZ(1, local.x, local.y, local.z);\n a.needsUpdate = true;\n }\n\n private _drawBeam(p: THREE.Vector3): void {\n const to = this.beamTo;\n if (!to || !this.group.parent) {\n if (this.beamLine) this.beamLine.visible = false;\n return;\n }\n if (!this.beamLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.beamLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x9a60ff }),\n );\n this.group.parent.add(this.beamLine);\n }\n const a = this.beamLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, p.x, p.y + this.height / 2, p.z);\n a.setXYZ(1, to.x, to.y, to.z);\n a.needsUpdate = true;\n const k = Math.min(1, this.beamT / 3);\n (this.beamLine.material as THREE.LineBasicMaterial).color.setRGB(\n 0.6 + 0.4 * k,\n 0.4 + 0.5 * k,\n 1 - 0.8 * k,\n );\n this.beamLine.visible = true;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.group);\n if (this.beamLine) {\n scene.remove(this.beamLine);\n this.beamLine.geometry.dispose();\n (this.beamLine.material as THREE.Material).dispose();\n }\n // traverse даёт Object3D; узкий каст к Mesh для доступа к geometry (проверка isMesh — рантайм).\n this.group.traverse((o) => {\n if ((o as THREE.Mesh).isMesh) (o as THREE.Mesh).geometry.dispose();\n });\n for (const { m } of this.mats) m.dispose();\n }\n\n serialize() {\n return {\n kind: \"mob\",\n type: this.def.name,\n pos: this.pos.toArray(),\n hp: this.hp,\n baby: this.baby,\n sheared: this.sheared,\n ...(this.uses.length ? { uses: [...this.uses] } : {}),\n ...(this.jobSite ? { jobSite: [...this.jobSite] } : {}),\n ...(this.traded ? { traded: true } : {}),\n ...(this.villagerXp > 0 ? { villagerXp: this.villagerXp } : {}),\n ...(this.curedType ? { curedType: this.curedType } : {}),\n ...(this.curing > 0 ? { curing: Math.round(this.curing) } : {}),\n ...(this.tamed ? { tamed: true } : {}),\n ...(this.customName ? { customName: this.customName } : {}),\n ...(this.sitting ? { sitting: true } : {}),\n ...(this.saddled ? { saddled: true } : {}),\n ...(this.woolColor ? { woolColor: this.woolColor } : {}),\n ...(this.wolfArmor > 0 ? { wolfArmor: this.wolfArmor } : {}),\n ...(this.carry ? { carry: this.carry.item, bag: this.bag } : {}),\n ...(this.items.length\n ? { items: this.items.map((s) => (s ? { ...s } : null)) }\n : {}),\n ...(this.leashed ? { leashed: true } : {}),\n ...(this.leashPost ? { leashPost: [...this.leashPost] } : {}),\n ...(this.leashSlot !== null ? { leashSlot: this.leashSlot } : {}),\n ...(this.leaveT !== Infinity ? { leaveT: Math.round(this.leaveT) } : {}),\n ...(this.home ? { home: [...this.home] } : {}),\n ...(this.pollen ? { pollen: true } : {}),\n ...(this.captain ? { captain: true } : {}),\n };\n }\n}\n"
|
|
252
257
|
},
|
|
253
258
|
{
|
|
254
259
|
"path": "entities/MobBrain.ts",
|
|
@@ -636,11 +641,47 @@
|
|
|
636
641
|
},
|
|
637
642
|
{
|
|
638
643
|
"path": "main.ts",
|
|
639
|
-
"content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config\";\nimport { events } from \"./core/EventBus\";\nimport { GameLoop } from \"./core/GameLoop\";\nimport { IntentQueue } from \"./core/Intents\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas\";\nimport { buildIcons } from \"./registry/Items\";\nimport { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView, ARM_LAYER } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { BlockUpdates } from \"./systems/BlockUpdates\";\nimport { BlockEntities } from \"./world/BlockEntities\";\nimport {\n DIMENSIONS,\n isDimensionId,\n type DimensionId,\n} from \"./world/Dimensions\";\nimport { FACING_DIRS } from \"./registry/meta\";\nimport { BLOCKS, B } from \"./registry/Blocks\";\nimport { MOBS } from \"./registry/Mobs\";\nimport { buildPortal, findPortalNear, portalsOf } from \"./world/Portal\";\nimport { LAVA_SEA } from \"./worldgen/inferno/InfernoGenerator\";\nimport {\n BEYOND_ARRIVAL,\n BeyondGenerator,\n ISLAND_Y,\n OUTER_LANDING,\n beyondPillars,\n exitPortalCells,\n gatewaySpot,\n} from \"./worldgen/beyond/BeyondGenerator\";\nimport { OUTER_START } from \"./worldgen/beyond/outer\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { MAP_SIZE, newMap, surveyMap, type MapData } from \"./systems/Maps\";\nimport { EXPLORER_MAPS } from \"./registry/items/mansion\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { hitTarget } from \"./world/behaviours/extras\";\nimport { anchorCharges } from \"./world/behaviours/stand\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { dripstoneFallDamage } from \"./world/behaviours/dripstone\";\nimport { Freezing } from \"./systems/Freezing\";\nimport {\n TRIAL_RANGE,\n trialStep,\n type TrialState,\n} from \"./systems/TrialSpawner\";\nimport { TRIAL_MOBS } from \"./registry/blocks/trial\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { CreateWorldUI } from \"./ui/CreateWorldUI\";\nimport { ChatConsole } from \"./ui/ChatConsole\";\nimport { SignEditor } from \"./ui/SignEditor\";\nimport { BookScreen } from \"./ui/BookScreen\";\nimport { NameEditor } from \"./ui/NameEditor\";\nimport { TouchControls, touchWanted } from \"./ui/TouchControls\";\nimport { Commands } from \"./systems/Commands\";\nimport { BeaconBeams } from \"./gfx/BeaconBeams\";\nimport { DisplayItems } from \"./gfx/DisplayItems\";\nimport { SignText } from \"./gfx/SignText\";\nimport { Banners } from \"./gfx/Banners\";\nimport { Achievements } from \"./systems/Achievements\";\nimport { RAID_WAVES, Raid } from \"./systems/Raid\";\nimport { siegeCount, siegeSpot, siegeTonight } from \"./systems/Siege\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { beaconEffects, inBeaconRange, type BeaconPulse } from \"./world/Beacon\";\nimport { WorldStore, newWorldId } from \"./systems/WorldStore\";\nimport type { WorldAI } from \"./ai/types\";\nimport { startSource } from \"./worlds/startWorld\";\nimport { CURRENT_GEN } from \"./worldgen/WorldGenerator\";\nimport type { DimensionSave, EffectId, SaveData, WorldSource } from \"./types\";\n\n/** Автосейв текущего мира в «Мои миры», секунд игры. */\nconst AUTOSAVE_EVERY = 30;\n\nexport interface GameOptions {\n /** ИИ-генерация миров (client.ai). null/нет — только путь без ИИ. */\n ai?: WorldAI | null;\n}\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n /** Снимает разом все DOM-подписки игры (document/window/канвас) в destroy(). */\n private readonly _abort = new AbortController();\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n /** Блоки без опоры (факел, растение, дверь) ломаются вслед за соседом. */\n blockUpdates: BlockUpdates;\n /** Состояние клеток текущего измерения: сундуки, печи… */\n blockEntities: BlockEntities;\n /** Лучи светящих маяков. */\n beams: BeaconBeams;\n /** Предметы, выставленные в рамках. */\n displays: DisplayItems;\n /** Надписи на табличках. */\n signs: SignText;\n banners: Banners;\n armorStands: ArmorStand;\n fireworks: Fireworks;\n /** Достижения мира (systems/Achievements) и музыка (systems/Music). */\n achievements: Achievements;\n music: Music;\n /** Измерение, в котором игрок, и снимки остальных (дифы, сущности) до возвращения в них. */\n dimension: DimensionId = \"overworld\";\n readonly dims = new Map<DimensionId, DimensionSave>();\n interaction: Interaction;\n /** Вода и лава. */\n fluids: FluidSim;\n dayNight: DayNight;\n /** Дождь и гроза (только в измерениях с небом). */\n weather: Weather;\n /** Секунды в портале; lock — не переносить снова, пока игрок не вышел из портала прибытия. */\n _portalT = 0;\n _portalLock = false;\n /** Известные порталы: обратный путь выводит к своему порталу (в верхнем мире — в 128 блоках). */\n portals = portalsOf(null);\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны (сейв v3, `beyond`). */\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны, где Врата (после победы). */\n beyondState: {\n initialized: boolean;\n dragonKilled: boolean;\n creditsShown: boolean;\n gateway: [number, number, number] | null;\n } = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n worldUI: CreateWorldUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n private _time: number;\n loop: GameLoop;\n /** Текущий мир в «Моих мирах». Классический стартовый мир — \"default\". */\n worldId = \"default\";\n worldName = \"VoxelCraft\";\n /** Хранилище миров; null — браузер не даёт IndexedDB (игра работает без сохранения). */\n store: WorldStore | null = null;\n private _autosaveT = 0;\n /** Был ли день на прошлом тике — рассвет пополняет товар жителей. */\n private _wasDay = true;\n /** Деревни, в которые уже поселены жители (id региона; сейв v3, `villages`). */\n villagesSettled = new Set<string>();\n private _villageT = 0;\n /** Ночей без сна (сейв v3 `daysAwake`); _slept — спал этой ночью. */\n daysAwake = 0;\n private _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n private _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n private _worldTouched = false;\n private _destroyed = false;\n\n constructor(opts: GameOptions = {}) {\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n // Казуальная картинка: Neutral-тонмаппинг держит насыщенность ярких цветов (ACES их смывает).\n this.renderer.toneMapping = THREE.NeutralToneMapping;\n this.renderer.toneMappingExposure = CONFIG.graphics.exposure;\n this.renderer.shadowMap.enabled = CONFIG.graphics.shadows;\n this.renderer.shadowMap.type = THREE.PCFShadowMap; // мягкость — shadow.radius (PCFSoft в r185 устарел)\n // Карту теней обновляем сами, раз за кадр (см. frame): теневой проход фильтрует объекты по\n // слоям камеры, и второй проход (рука) перерисовал бы её заново — из одной руки.\n this.renderer.shadowMap.autoUpdate = false;\n this.scene = new THREE.Scene();\n // Пропорции выставит первый же замер ResizeObserver'а — до него кадров не будет.\n this.camera = new THREE.PerspectiveCamera(\n 75,\n 1,\n 0.1,\n CONFIG.renderDistance * 16 + 60,\n );\n this.camera.rotation.order = \"YXZ\";\n this.scene.add(this.camera); // рука прикреплена к камере\n\n // Размер берём из раскладки самого канваса (CSS растягивает его на вьюпорт), а не из\n // window.innerWidth, и через ResizeObserver, а не событие resize: игра может стартовать\n // в ещё не разложенном контейнере — фоновая вкладка или iframe превью, — где на момент\n // конструктора innerWidth равен нулю. Событие resize там уже не придёт, и канвас навсегда\n // остался бы 0x0: симуляция живая, экран чёрный. ResizeObserver сам отдаёт первый замер,\n // как только раскладка появляется.\n this._resizeObserver = new ResizeObserver(() => this._resize());\n this._resizeObserver.observe(canvas);\n this._resize();\n\n // --- контент: атлас и иконки из одних и тех же процедурных текстур ---\n this.atlas = new TextureAtlas();\n buildIcons(this.atlas);\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, startSource());\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.blockUpdates = new BlockUpdates(() => this.world, this.entities);\n // броня из инвентаря гасит урон игрока и изнашивается от ударов\n this.player.armorStats = () => this.inventory.armorStats();\n this.player.onArmorHit = (dmg) => this.inventory.damageArmor(dmg);\n this.player.canGlide = () => this.inventory.canGlide();\n this.player.tryTotem = () => {\n if (this.inventory.heldItem()?.item !== \"life_totem\") return false;\n this.inventory.consumeHeld(1);\n return true;\n };\n this.player.onGlideWear = () => this.inventory.wearWings();\n this.blockEntities = new BlockEntities(() => this.world, this.entities);\n this.beams = new BeaconBeams(this.scene);\n this.displays = new DisplayItems(this.scene, this.atlas);\n this.signs = new SignText(this.scene);\n this.banners = new Banners(this.scene);\n this.armorStands = new ArmorStand(this.scene, this.atlas);\n this.fireworks = new Fireworks(this.scene);\n this.achievements = new Achievements({\n has: (pred) => this.inventory.slots.some((s) => !!s && pred(s.item)),\n wearing: (pred) => this.inventory.armor.some((s) => !!s && pred(s.item)),\n effect: (id) => this.player.effects.has(id as EffectId),\n });\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.fluids = new FluidSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.weather = new Weather(this.scene);\n const start = this.world.source;\n if (start.kind === \"spec\") {\n this.dayNight.setTimeOfDay(start.spec.ambience.timeOfDay);\n this.worldName = start.spec.name;\n }\n this.audio = new Audio();\n this.music = new Music(this.audio);\n // пластинка в проигрывателе играет свою мелодию (сид и лад — systems/Disc), вынули — тишина\n events.on(\"discPlay\", ({ mood, seed }: { mood: Mood; seed: number }) =>\n this.music.play(mood, seed),\n );\n events.on(\"discEject\", () => this.music.stop());\n this.input = new Input(canvas, this.intents, this._abort.signal);\n this.armView = new ArmView(this.camera, this.atlas);\n // второй проход (рука) видит только свой слой — свет мира включаем и на нём\n this.dayNight.sun.layers.enable(ARM_LAYER);\n this.dayNight.hemi.layers.enable(ARM_LAYER);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n new AchievementsView(this.hud.heartsEl.parentElement!, this.achievements);\n this.invUI = new InventoryUI(this, this._abort.signal);\n this.worldUI = new CreateWorldUI(this, this._abort.signal, opts.ai ?? null);\n this.commands = new Commands(this);\n this.chat = new ChatConsole(this, this._abort.signal);\n this.signEditor = new SignEditor(this, this._abort.signal);\n this.bookScreen = new BookScreen(this, this._abort.signal);\n this.nameEditor = new NameEditor(this, this._abort.signal);\n this.touch = touchWanted()\n ? new TouchControls(this, this._abort.signal)\n : null;\n this.interaction.onCommand = (text) => this.commands.run(text);\n this.interaction.onUseMap = () => this.createMap();\n // опыт из печи (и других источников, что не кидают сферы)\n events.on(\"xpGain\", ({ amount }: { amount: number }) =>\n this.player.addXp(amount),\n );\n events.on(\"playerDied\", () => this._onDeath());\n events.on(\"portalLit\", (d: { x: number; y: number; z: number }) =>\n this._rememberPortal(this.dimension, d.x, d.y, d.z),\n );\n events.on(\"throwEye\", (d: { x: number; y: number; z: number }) =>\n this._throwEye(d.x, d.y, d.z),\n );\n events.on(\"dragonDefeated\", () => this._dragonDefeated());\n events.on(\"ui:useBed\", (d: { x: number; y: number; z: number }) =>\n this._useBed(d.x, d.y, d.z),\n );\n events.on(\"lightning\", (d: { x: number; y: number; z: number }) =>\n this._lightning(d.x, d.y, d.z),\n );\n // упавший сталактит бьёт того, кто под ним, и рассыпается острым капельником\n events.on(\n \"dripstoneFall\",\n (p: { x: number; y: number; z: number; landY: number }) => {\n const pl = this.player;\n if (\n !pl.dead &&\n Math.floor(pl.pos.x) === p.x &&\n Math.floor(pl.pos.z) === p.z &&\n pl.pos.y <= p.y &&\n pl.pos.y >= p.landY - 0.5\n )\n pl.damage(dripstoneFallDamage(p.y - pl.pos.y), \"falling_stalactite\");\n this.entities.spawnDrop(\n p.x + 0.5,\n p.landY + 0.2,\n p.z + 0.5,\n \"pointed_dripstone\",\n 1,\n );\n },\n );\n // находка из-под кисти — над расчищенным блоком\n events.on(\n \"brushLoot\",\n (p: { x: number; y: number; z: number; item: string; count: number }) =>\n this.entities.spawnDrop(\n p.x + 0.5,\n p.y + 1.1,\n p.z + 0.5,\n p.item,\n p.count,\n ),\n );\n // дроп, который роняет сам моб (рог козы)\n events.on(\n \"mobDrop\",\n (p: { x: number; y: number; z: number; item: string }) =>\n this.entities.spawnDrop(p.x, p.y, p.z, p.item, 1),\n );\n // глубокая тьма: сломанные и поставленные игроком блоки и взрывы — вибрации; смерть рядом с\n // катализатором — скалк; место смерти игрока — для компаса возвращения\n const overworld = () => this.dimension === \"overworld\";\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; byPlayer?: boolean }) => {\n if (overworld())\n this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n !!p.byPlayer && this.player.mode !== \"creative\",\n );\n },\n );\n // Жемчуг Предела упал: игрока переносит туда, и он расшибается, как в Minecraft; изредка из\n // точки вылезает клещ Предела (registry/mobs/beyond)\n events.on(\"pearlLand\", (p: { x: number; y: number; z: number }) => {\n const pl = this.player;\n if (pl.dead) return;\n this.warmup(p.x, p.z);\n let y = Math.floor(p.y);\n while (\n y > 1 &&\n !this.world.isSolid(Math.floor(p.x), y - 1, Math.floor(p.z)) &&\n y > Math.floor(p.y) - 4\n )\n y--;\n pl.pos.set(Math.floor(p.x) + 0.5, y, Math.floor(p.z) + 0.5);\n pl.vel.set(0, 0, 0);\n pl._fallDist = 0;\n if (pl.mode !== \"creative\") pl.damage(5, \"teleport\");\n events.emit(\"pearlTeleport\", { x: pl.pos.x, y: pl.pos.y, z: pl.pos.z });\n if (Math.random() < 0.05)\n this.entities.spawnMob(\"rim_mite\", pl.pos.x, pl.pos.y, pl.pos.z);\n });\n // фейерверк с земли: ракета взлетает и рассыпается искрами (gfx/Fireworks)\n events.on(\"fireworkLaunch\", (p: { x: number; y: number; z: number }) =>\n this.fireworks.launch(p.x, p.y, p.z),\n );\n // компас по путевому камню: стрелка держится за него, пока камень цел (ui/HUD)\n events.on(\"lodestoneBind\", (p: { x: number; y: number; z: number }) => {\n this.player.lodestone = { x: p.x, z: p.z, dim: this.dimension };\n this.hud.message(\"Компас привязан к путевому камню\", 2000);\n });\n // якорь возрождения: в Пекле — точка возрождения, в верхнем мире взрывается, как в Minecraft\n events.on(\n \"anchorUse\",\n (p: { x: number; y: number; z: number; charges: number }) => {\n if (!DIMENSIONS[this.dimension].bedsExplode) {\n this.world.setBlock(p.x, p.y, p.z, B.air!);\n this.entities.explode(p.x + 0.5, p.y + 0.5, p.z + 0.5, 5);\n return;\n }\n if (p.charges <= 0) {\n this.hud.message(\"Якорь не заряжен — нужен светокамень\", 2200);\n return;\n }\n this.player.anchorPos = [p.x, p.y, p.z];\n this.player.anchorDim = this.dimension;\n this.hud.message(\"Точка возрождения — у якоря\", 2200);\n },\n );\n // стрела в мишень — красный сигнал по близости к центру (world/behaviours/extras)\n events.on(\n \"arrowHit\",\n (p: {\n x: number;\n y: number;\n z: number;\n hx?: number;\n hy?: number;\n hz?: number;\n }) => {\n if (p.hx === undefined || p.hy === undefined || p.hz === undefined)\n return;\n hitTarget(this.world, p.x, p.y, p.z, p.hx, p.hy, p.hz);\n },\n );\n events.on(\"blockPlace\", (p: { x: number; y: number; z: number }) => {\n if (overworld())\n this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n this.player.mode !== \"creative\",\n );\n });\n events.on(\"explosion\", (p: { x: number; y: number; z: number }) => {\n if (overworld()) this.sculk.vibrate(p.x, p.y, p.z, false);\n });\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const e = p.entity;\n if (overworld() && e.hitByPlayerT > 0 && !e.def.warden)\n this.sculk.onDeath(e.pos.x, e.pos.y, e.pos.z, e.def.xp?.[1] ?? 1);\n });\n // моб волны спавнера испытаний убит — засчитать спавнеру\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const f = p.entity.trialFrom;\n const be = f ? this.blockEntities.get(f[0], f[1], f[2]) : undefined;\n if (be?.type === \"trial_spawner\") (be.state as TrialState).killed++;\n });\n events.on(\"playerDied\", () => {\n const pp = this.player.pos;\n this.lastDeath = { x: pp.x, y: pp.y, z: pp.z, dim: this.dimension };\n });\n // проводник: игрок в воде в его радиусе получает силу моря (дыхание, ночное зрение, спешка)\n events.on(\n \"conduitPulse\",\n (p: { x: number; y: number; z: number; range: number }) => {\n const pl = this.player;\n if (\n pl.dead ||\n !pl.inWater ||\n pl.pos.distanceTo(\n new THREE.Vector3(p.x + 0.5, p.y + 0.5, p.z + 0.5),\n ) > p.range\n )\n return;\n for (const id of [\"water_breathing\", \"night_vision\", \"haste\"] as const)\n pl.addEffect(id, 13);\n events.emit(\"conduitPower\");\n },\n );\n // маяк: игрок в зоне получает выбранные силы\n events.on(\"beaconPulse\", (p: BeaconPulse) => {\n const pl = this.player;\n if (pl.dead || !inBeaconRange(p, pl.pos.x, pl.pos.y, pl.pos.z)) return;\n for (const e of beaconEffects(p)) pl.addEffect(e.id, e.seconds, e.level);\n });\n\n this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n void this.persist();\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\n const { signal } = this._abort;\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n },\n { signal },\n );\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\n \"click\",\n () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n },\n { signal },\n );\n\n // --- старт мира ---\n this.warmupAtSpawn();\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n this.hud.showOverlay(false);\n this.loop.start();\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open().then(async (store) => {\n if (!store) return;\n if (this._destroyed) return store.close();\n this.store = store;\n const last = await store.lastPlayed().catch(() => undefined);\n if (!last || this._worldTouched || this.playing || this._destroyed)\n return;\n try {\n this.save.apply(last.save);\n this.worldId = last.id;\n this.worldName = last.name;\n this.hud.message(\"Продолжаем мир «\" + last.name + \"»\", 2200);\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n });\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). */\n async persist(): Promise<void> {\n if (!this.store) return;\n try {\n await this.store.put({\n id: this.worldId,\n name: this.worldName,\n updatedAt: Date.now(),\n save: this.save.collect(),\n });\n } catch {\n // квота/запрет хранилища: игра продолжается без автосейва\n }\n }\n\n /** Открыть мир из «Моих миров». */\n async openStoredWorld(id: string): Promise<boolean> {\n if (!this.store) return false;\n const w = await this.store.get(id).catch(() => undefined);\n if (!w) return false;\n if (id !== this.worldId) await this.persist();\n try {\n this.save.apply(w.save);\n } catch {\n return false;\n }\n this._worldTouched = true;\n this.worldId = w.id;\n this.worldName = w.name;\n return true;\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n // persist() снимает сейв синхронно при вызове — до apply ниже, поэтому уходит именно старый мир.\n void this.persist();\n try {\n this.save.apply(save);\n } catch {\n return false;\n }\n this.adoptNewWorld(name);\n this.hud.message(\"Мир «\" + name + \"» из Мастерской\", 2200);\n return true;\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n this._worldTouched = true;\n this.worldId = newWorldId();\n this.worldName = name;\n void this.persist();\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n this.world.update(x, z);\n const busy =\n this.world.genQueue.length > 0 ||\n [...this.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n const s = this.world.generator.spawnHint();\n this.warmup(s.x, s.z);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, source, dimension);\n this.dimension = dimension;\n this.blockEntities.clear();\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.fluids.world = this.world;\n this.fluids.clear();\n this.interaction.breaking = null;\n this.interaction.damage.clear(); // трещины старого мира не должны лечь на блоки нового\n this.interaction.eating = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n const src: WorldSource =\n typeof source === \"number\"\n ? { kind: \"noise\", seed: source, gen: CURRENT_GEN }\n : // новый мир — текущего поколения (у мира из рецепта: цитадель под островом)\n { ...source, gen: source.gen ?? CURRENT_GEN };\n void this.persist(); // старый мир остаётся в «Моих мирах» таким, каким его оставили\n this.dims.clear();\n this.portals = portalsOf(null);\n this.beyondState = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n this.villagesSettled = new Set();\n this.achievements.load([]); // достижения — у каждого мира свои, как в Minecraft\n this.maps = new Map();\n this.nextMapId = 1;\n this.daysAwake = 0;\n this.rebuildWorld(src, \"overworld\");\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n if (src.kind === \"spec\")\n this.dayNight.setTimeOfDay(src.spec.ambience.timeOfDay);\n else this.dayNight.resetToMorning();\n this.warmupAtSpawn();\n this.player.spawn();\n const name = src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed;\n this.adoptNewWorld(name);\n this.hud.message(\n src.kind === \"spec\"\n ? \"Мир «\" + src.spec.name + \"»\"\n : \"Новый мир, сид \" + src.seed,\n 1800,\n );\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n if (target === this.dimension) return;\n this.dims.set(this.dimension, {\n chunks: this.world.serializeDiffs(),\n entities: this.entities.serialize(),\n blockEntities: this.blockEntities.serialize(),\n });\n const saved = this.dims.get(target);\n this.dims.delete(target);\n this.rebuildWorld(this.world.source, target);\n this.entities.clear();\n if (saved) {\n this.world.loadDiffs(saved.chunks);\n this.entities.deserialize(\n (saved.entities ?? []) as Parameters<EntityManager[\"deserialize\"]>[0],\n );\n this.blockEntities.deserialize(saved.blockEntities);\n }\n this.warmup(x, z);\n this.player.pos.set(x, y, z);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"dimensionChanged\", { dimension: target });\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n if (this.player.mode !== \"creative\") {\n const p = this.player.pos;\n const inv = this.inventory;\n for (const s of [...inv.slots, ...inv.armor])\n if (s)\n this.entities.spawnDrop(\n p.x + (Math.random() - 0.5),\n p.y + 0.5,\n p.z + (Math.random() - 0.5),\n s.item,\n s.count,\n s.dmg,\n );\n inv.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: inv.hotbarIndex,\n });\n const xp = Math.min(100, this.player.xpLevel * 7);\n if (xp > 0) this.entities.spawnXp(p.x, p.y + 0.5, p.z, xp);\n this.player.xpLevel = this.player.xpProgress = this.player.xpTotal = 0;\n }\n if (this.invUI.open) this.invUI.close();\n this.input.uiOpen = true; // до releaseLock: без паузы — вместо неё экран смерти\n this.input.releaseLock();\n this.hud.showDeath(this.player.lastCause);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n if (!this.player.dead) return;\n this.player.respawn(this.goHome());\n this.hud.hideDeath();\n this.input.uiOpen = false;\n this.input.requestLock();\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n // якорь возрождения сильнее кровати и возвращает в СВОЁ измерение (обычно в Пекло)\n const anchor = this.anchorHome();\n if (anchor) return anchor;\n const s = this.player.spawnPoint;\n let at = new THREE.Vector3(s.x, s.y, s.z);\n const bed = this.player.bedPos;\n if (this.dimension !== \"overworld\")\n this.changeDimension(\"overworld\", at.x, at.y, at.z);\n if (bed) {\n this.warmup(bed[0], bed[2]);\n if (BLOCKS[this.world.getBlock(bed[0], bed[1], bed[2])]?.shape === \"bed\")\n at = new THREE.Vector3(bed[0] + 0.5, bed[1] + 0.6, bed[2] + 0.5);\n else {\n this.player.bedPos = null;\n this.hud.message(\"Ваша кровать отсутствует или заблокирована\", 2500);\n }\n }\n this.warmup(at.x, at.z);\n // точку могли застроить — поднимаемся до свободного места на рост\n const bx = Math.floor(at.x),\n bz = Math.floor(at.z);\n let y = Math.floor(at.y);\n while (\n y < 126 &&\n (this.world.isSolid(bx, y, bz) || this.world.isSolid(bx, y + 1, bz))\n )\n y++;\n // …и опускаемся до пола: точка возрождения в воздухе — это смерть от падения сразу после неё\n while (y > 1 && this.world.getBlock(bx, y - 1, bz) === B.air) y--;\n at.y = y;\n return at;\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n const p = this.player.pos;\n const cell = (dy: number) =>\n this.world.getBlock(\n Math.floor(p.x),\n Math.floor(p.y + dy),\n Math.floor(p.z),\n );\n const inBeyond = cell(0) === B.beyond_portal;\n const inside =\n inBeyond || [0, 1].some((dy) => cell(dy) === B.inferno_portal);\n if (!inside || this.player.dead) {\n this._portalT = 0;\n this._portalLock = false;\n return;\n }\n if (this._portalLock) return;\n // портал Предела переносит сразу, как в Minecraft\n if (inBeyond) {\n this._portalLock = true;\n this._beyondTravel();\n return;\n }\n this._portalT += dt;\n if (this._portalT < (this.player.mode === \"creative\" ? 0.25 : 4)) return;\n this._portalT = 0;\n this._portalLock = true;\n const target: DimensionId =\n this.dimension === \"inferno\" ? \"overworld\" : \"inferno\";\n const k =\n target === \"inferno\"\n ? 1 / DIMENSIONS.inferno.scale\n : DIMENSIONS.inferno.scale;\n const tx = Math.floor(p.x * k),\n tz = Math.floor(p.z * k);\n // портал, через который уходим, — тоже известный (порталы из сейвов до реестра)\n this._rememberPortal(\n this.dimension,\n Math.floor(p.x),\n Math.floor(p.y),\n Math.floor(p.z),\n );\n // свой портал на той стороне: в верхнем мире ищем в 128 блоках, в Пекле — в 16, как в Minecraft\n const known = this._nearestPortal(\n target,\n tx,\n tz,\n target === \"overworld\" ? 128 : 16,\n );\n this.changeDimension(\n target,\n (known?.[0] ?? tx) + 0.5,\n 70,\n (known?.[2] ?? tz) + 0.5,\n );\n let at = known ? findPortalNear(this.world, known[0], known[2], 2) : null;\n if (known && !at) this._forgetPortal(target, known); // сломали — забываем\n if (!at) {\n if (known) this.warmup(tx, tz);\n at =\n findPortalNear(this.world, tx, tz, 16) ??\n (target === \"inferno\"\n ? buildPortal(this.world, tx, tz, 64, LAVA_SEA + 2, 110)\n : buildPortal(this.world, tx, tz, 70, 4, 120));\n this._rememberPortal(target, at.x, at.y, at.z);\n }\n this.player.pos.set(at.x + 0.5, at.y, at.z + 0.5);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: target });\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n private readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n const plate = B.stone_pressure_plate;\n if (plate === undefined) return;\n const on = new Set<string>();\n // плиту жмут все, нажимные рельсы — только вагонетки\n const mark = (x: number, y: number, z: number, cart = false) => {\n const bx = Math.floor(x),\n by = Math.floor(y + 0.05),\n bz = Math.floor(z);\n const id = this.world.getBlock(bx, by, bz);\n if (id === plate || (cart && id === B.detector_rail))\n on.add(bx + \",\" + by + \",\" + bz);\n };\n if (!this.player.dead)\n mark(this.player.pos.x, this.player.pos.y, this.player.pos.z);\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead) mark(e.pos.x, e.pos.y, e.pos.z);\n else if (e.kind === \"minecart\" && !e.dead)\n mark(e.pos.x, e.pos.y, e.pos.z, true);\n const at = (k: string) =>\n k.split(\",\").map(Number) as [number, number, number];\n for (const k of on) {\n this._plates.set(k, 1);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z),\n m = this.world.getMeta(x, y, z);\n if (id === plate && m === 0) this.world.setBlock(x, y, z, plate, 1);\n else if (id === B.detector_rail && !(m & 0x8))\n this.world.setBlock(x, y, z, id, m | 0x8);\n }\n for (const [k, left] of this._plates) {\n if (on.has(k)) continue;\n if (left - dt > 0) {\n this._plates.set(k, left - dt);\n continue;\n }\n this._plates.delete(k);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z);\n if (id === plate) this.world.setBlock(x, y, z, plate, 0);\n else if (id === B.detector_rail)\n this.world.setBlock(x, y, z, id, this.world.getMeta(x, y, z) & ~0x8);\n }\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n if (this.dimension === \"beyond\") {\n const s = this.player.spawnPoint;\n this.changeDimension(\"overworld\", s.x, s.y, s.z);\n this.player.pos.copy(this.goHome());\n // первый выход после победы — титры\n if (this.beyondState.dragonKilled && !this.beyondState.creditsShown) {\n this.beyondState.creditsShown = true;\n this.hud.showCredits();\n }\n } else {\n const { x, y, z } = BEYOND_ARRIVAL;\n this.changeDimension(\"beyond\", x + 0.5, y, z + 0.5);\n this._arrivalPlatform();\n if (!this.beyondState.initialized) this._initBeyond();\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: this.dimension });\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n this.beyondState.initialized = true;\n for (const p of beyondPillars(this.world.seed))\n this.entities.spawnMob(\"beyond_crystal\", p.x + 0.5, p.top + 2, p.z + 0.5);\n this.entities.spawnMob(\"void_dragon\", 0, 90, 0);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n const { x, y, z } = BEYOND_ARRIVAL;\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++) {\n this.world.setBlock(x + dx, y - 1, z + dz, B.obsidian!);\n for (let dy = 0; dy < 3; dy++)\n this.world.setBlock(x + dx, y + dy, z + dz, B.air!);\n }\n this.player.pos.set(x + 0.5, y, z + 0.5);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n this.world.setBlock(x, y - 1, z, B.bedrock!);\n this.world.setBlock(x, y, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 1, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 2, z, B.bedrock!);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n const [x, y, z] = gatewaySpot(this.world.seed);\n this.warmup(x, z); // запись в незагруженный чанк пропала бы: победа бывает далеко от врат\n this._placeGateway(x, y, z);\n this.beyondState.gateway = [x, y, z];\n }\n\n /** Идущий набег на деревню (systems/Raid) или null. Не сохраняется: перезагрузка его прекращает. */\n raid: Raid | null = null;\n\n /**\n * Набег: игрок с Дурным знамением у заселённой деревни — знамение сгорает, начинается набег. Волны\n * приходят с одного края деревни (в 28–34 блоках от центра) и идут к центру; победа — «Герой\n * деревни» уровня знамения, поражение — все жители в 64 блоках мертвы или вышло время.\n */\n _tickRaid(dt: number) {\n if (this.dimension !== \"overworld\") return;\n const pl = this.player;\n if (!this.raid) {\n const omen = pl.effects.get(\"bad_omen\");\n if (!omen || pl.dead) return;\n const v = (\n this.world.generator.villagesNear?.(pl.pos.x, pl.pos.z, 48) ?? []\n ).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v) return;\n pl.effects.delete(\"bad_omen\");\n events.emit(\"effectsChanged\", { id: \"bad_omen\" });\n this.raid = new Raid({ x: v.x + 0.5, y: v.y, z: v.z + 0.5 }, omen.level);\n events.emit(\"raidStart\", { x: v.x, z: v.z });\n this.hud.message(\"Набег! Защитите деревню\", 3500);\n return;\n }\n const r = this.raid;\n const center = new THREE.Vector3(r.center.x, r.center.y, r.center.z);\n let villagers = 0;\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.trades &&\n e.pos.distanceTo(center) < 64\n )\n villagers++;\n const res = r.tick(\n dt,\n (id) => {\n const e = this.entities.entities.get(id);\n return e?.kind === \"mob\" && !e.dead;\n },\n villagers,\n );\n if (res.spawn) {\n const a = Math.random() * Math.PI * 2;\n for (const type of res.spawn) {\n if (!MOBS[type]) continue;\n const d = 28 + Math.random() * 6,\n aa = a + (Math.random() - 0.5) * 0.4;\n const x = Math.floor(center.x + Math.cos(aa) * d),\n z = Math.floor(center.z + Math.sin(aa) * d);\n this.warmup(x, z);\n const m = this.entities.spawnMob(\n type,\n x + 0.5,\n this.world.surfaceY(x, z) + 1.01,\n z + 0.5,\n );\n m.raider = true;\n m.goal = center.clone();\n r.add(m.id, m.def.hp);\n }\n this.hud.message(`Набег: волна ${r.wave} из ${RAID_WAVES}`, 2500);\n events.emit(\"raidWave\", { wave: r.wave });\n }\n if (res.end === \"won\") {\n pl.addEffect(\"hero\", 2400, r.level);\n events.emit(\"raidWon\", { level: r.level });\n this.hud.message(\n \"Набег отбит! Вы — герой деревни: жители уступают в цене\",\n 4500,\n );\n this.raid = null;\n } else if (res.end === \"lost\") {\n for (const id of r.raiders) {\n const e = this.entities.entities.get(id);\n if (e?.kind === \"mob\") {\n e.raider = false;\n e.goal = null;\n }\n }\n events.emit(\"raidLost\");\n this.hud.message(\"Набег: деревня пала\", 3500);\n this.raid = null;\n }\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n private _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n if (this.dimension !== \"beyond\" || this.player.dead) return;\n const g = this.beyondState.gateway;\n if (this.beyondState.dragonKilled && !g) this._openGateway();\n // врата не ломаются: записаны, чанк загружен, а их нет — значит, не встали (сейв до починки)\n else if (g && this.world.getBlockOrUnloaded(g[0], g[1], g[2]) === B.air)\n this._placeGateway(g[0], g[1], g[2]);\n const p = this.player.pos;\n let touching = false;\n for (let x = Math.floor(p.x - 0.5); x <= Math.floor(p.x + 0.5); x++)\n for (let y = Math.floor(p.y); y <= Math.floor(p.y + 1.9); y++)\n for (let z = Math.floor(p.z - 0.5); z <= Math.floor(p.z + 0.5); z++)\n if (this.world.getBlock(x, y, z) === B.beyond_gateway)\n touching = true;\n if (!touching) {\n this._gateLock = false;\n return;\n }\n if (this._gateLock) return;\n this._gateLock = true;\n const toOuter = Math.hypot(p.x, p.z) < OUTER_START / 2;\n if (!toOuter) {\n this.warmup(BEYOND_ARRIVAL.x, BEYOND_ARRIVAL.z);\n this._arrivalPlatform();\n } else {\n const a = Math.atan2(p.z, p.x);\n const tx = Math.round(Math.cos(a) * OUTER_LANDING),\n tz = Math.round(Math.sin(a) * OUTER_LANDING);\n this.warmup(tx, tz);\n const gen = this.world.generator;\n const land =\n gen instanceof BeyondGenerator ? gen.landingNear(tx, tz, 96) : null;\n const x = land?.x ?? tx,\n z = land?.z ?? tz;\n let y = land?.y ?? ISLAND_Y;\n if (land) {\n this.warmup(x, z);\n while (\n y < 120 &&\n (this.world.isSolid(x, y, z) || this.world.isSolid(x, y + 1, z))\n )\n y++;\n } else\n // островов рядом нет — площадка из камня Предела, как в Minecraft\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++)\n this.world.setBlock(x + dx, y - 1, z + dz, B.beyond_stone!);\n this._placeGateway(x + 3, y, z); // обратные врата у места прибытия\n this.player.pos.set(x + 0.5, y, z + 0.5);\n this.hud.message(\"Внешние острова Предела\", 2500);\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"gatewayTravel\", { outer: toOuter });\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n this.beyondState.dragonKilled = true;\n for (const [x, y, z] of exitPortalCells())\n this.world.setBlock(x, y, z, B.beyond_portal!);\n this.world.setBlock(0, ISLAND_Y + 4, 0, B.dragon_egg!);\n this._openGateway();\n this.hud.message(\n \"Дракон Предела повержен! Портал домой — в фонтане, Врата на внешние острова — за краем\",\n 4500,\n );\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n const target = this.world.generator.locate?.(\"citadel\", x, z);\n if (!target) {\n this.hud.message(\"Око не чувствует цитадели в этом мире\", 2200);\n if (this.player.mode !== \"creative\") this.inventory.add(\"rim_eye\", 1);\n return;\n }\n this.entities.spawnRimEye(new THREE.Vector3(x, y, z), target);\n }\n\n /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\n const list = this.portals[dim];\n const near = list.some(\n ([px, py, pz]) =>\n Math.abs(px - x) <= 3 && Math.abs(py - y) <= 4 && Math.abs(pz - z) <= 3,\n );\n if (!near) list.push([x, y, z]);\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n this.portals[dim] = this.portals[dim].filter((q) => q !== p);\n }\n\n /** Ближайший известный портал измерения в квадрате radius вокруг (x, z). */\n _nearestPortal(\n dim: DimensionId,\n x: number,\n z: number,\n radius: number,\n ): [number, number, number] | null {\n let best: [number, number, number] | null = null;\n let bestD = Infinity;\n for (const q of this.portals[dim]) {\n const dx = Math.abs(q[0] - x),\n dz = Math.abs(q[2] - z);\n if (dx > radius || dz > radius) continue;\n const d = dx * dx + dz * dz;\n if (d < bestD) {\n best = q;\n bestD = d;\n }\n }\n return best;\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n private anchorHome(): THREE.Vector3 | null {\n const a = this.player.anchorPos;\n const dim = this.player.anchorDim;\n if (!a || !dim || !isDimensionId(dim)) return null;\n const [x, y, z] = a;\n if (this.dimension !== dim)\n this.changeDimension(dim, x + 0.5, y + 1, z + 0.5);\n this.warmup(x, z);\n const charges =\n this.world.getBlock(x, y, z) === B.respawn_anchor\n ? anchorCharges(this.world.getMeta(x, y, z))\n : 0;\n if (charges <= 0) {\n this.player.anchorPos = null;\n this.player.anchorDim = null;\n this.hud.message(\"Якорь возрождения разряжен или сломан\", 2500);\n return null;\n }\n this.world.setBlock(x, y, z, B.respawn_anchor!, charges - 1);\n // рядом с якорем, а не в нём: ищем свободную клетку вокруг\n for (const [dx, dz] of FACING_DIRS) {\n const nx = x + dx,\n nz = z + dz;\n if (!this.world.isSolid(nx, y, nz) && !this.world.isSolid(nx, y + 1, nz))\n return new THREE.Vector3(nx + 0.5, y, nz + 0.5);\n }\n return new THREE.Vector3(x + 0.5, y + 1, z + 0.5);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n if (DIMENSIONS[this.dimension].bedsExplode) {\n // в Пекле и Пределе кровать взрывается, как в Minecraft\n this.world.setBlock(x, y, z, B.air!);\n this.entities.explode(x + 0.5, y + 0.5, z + 0.5, 5);\n return;\n }\n this.player.bedPos = [x, y, z];\n if (this.dayNight.isDay && this.weather.kind !== \"thunder\") {\n this.hud.message(\n \"Спать можно только ночью или в грозу. Точка возрождения — здесь\",\n 2400,\n );\n return;\n }\n const monsters = [...this.entities.entities.values()].some(\n (e) =>\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.category === \"hostile\" &&\n e.pos.distanceTo(this.player.pos) < 8,\n );\n if (monsters) {\n this.hud.message(\"Нельзя спать — рядом монстры\", 2000);\n return;\n }\n this.dayNight.resetToMorning();\n this.weather.set(\"clear\"); // сон пережидает и непогоду, как в Minecraft\n this._slept = true;\n this.daysAwake = 0;\n this.hud.message(\"Доброе утро!\", 1800);\n events.emit(\"slept\");\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n if (this.inventory.heldItem()?.item !== \"map\") return;\n const p = this.player.pos;\n const m = newMap(this.nextMapId++, p.x, p.z, this.dimension);\n this.maps.set(m.id, m);\n for (let ph = 0; ph < 4; ph++) surveyMap(this.world, m, p.x, p.z, 48, ph);\n const filled = { item: \"filled_map\", count: 1, tag: { map: m.id } };\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n const i = this.inventory.hotbarIndex;\n if (!this.inventory.slots[i]) this.inventory.setSlot(i, filled);\n else if (this.inventory.addStack(filled) > 0)\n this.entities.spawnDrop(p.x, p.y + 1, p.z, \"filled_map\", 1);\n events.emit(\"mapCreated\", { id: m.id });\n }\n\n _trialScanT = 0;\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n const p = this.player;\n if ((this._trialScanT -= dt) <= 0) {\n this._trialScanT = 1;\n for (const [x, y, z] of this.world.generator.trialSpawnersNear?.(\n p.pos.x,\n p.pos.z,\n 48,\n ) ?? [])\n if (this.world.getBlockOrUnloaded(x, y, z) === B.trial_spawner)\n this.blockEntities.ensure(x, y, z);\n }\n const hunter = !p.dead && p.mode !== \"creative\";\n for (const be of this.blockEntities.map.values()) {\n if (be.type !== \"trial_spawner\") continue;\n const mob =\n TRIAL_MOBS[this.world.getMeta(be.x, be.y, be.z) % TRIAL_MOBS.length]!;\n trialStep(be.state as TrialState, {\n dt,\n near:\n hunter &&\n Math.hypot(\n p.pos.x - be.x - 0.5,\n p.pos.y - be.y,\n p.pos.z - be.z - 0.5,\n ) <= TRIAL_RANGE,\n rng: Math.random,\n spawn: () => this._trialSpawn(be.x, be.y, be.z, mob),\n reward: (items) => {\n for (const s of items)\n this.entities.spawnDrop(\n be.x + 0.5,\n be.y + 1.2,\n be.z + 0.5,\n s.item,\n s.count,\n );\n events.emit(\"trialReward\", { x: be.x, y: be.y, z: be.z });\n },\n });\n }\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n for (let i = 0; i < 10; i++) {\n const sx = x + Math.floor(Math.random() * 9) - 4,\n sz = z + Math.floor(Math.random() * 9) - 4;\n for (let sy = y - 1; sy <= y + 2; sy++) {\n if (!BLOCKS[this.world.getBlock(sx, sy - 1, sz)]?.solid) continue;\n if (\n this.world.getBlock(sx, sy, sz) !== 0 ||\n this.world.getBlock(sx, sy + 1, sz) !== 0\n )\n continue;\n const m = this.entities.spawnMob(mob, sx + 0.5, sy, sz + 0.5);\n m.trialFrom = [x, y, z];\n events.emit(\"trialSpawn\", { x, y, z, mob });\n return true;\n }\n }\n return false;\n }\n\n freezing = new Freezing();\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n const p = this.player;\n const armor = this.inventory.armor;\n p.leatherBoots = () => armor.some((s) => s?.item === \"leather_boots\");\n p.goldArmor = () => armor.some((s) => !!s?.item.startsWith(\"golden_\"));\n // щит принял удар — теряет прочность по урону (как в Minecraft), не меньше 1\n p.onShieldBlock = (n) => {\n if (this.inventory.heldItem()?.item === \"shield\")\n this.inventory.damageHeld(Math.max(1, Math.floor(n)));\n };\n const leather = armor.some((s) => !!s?.item.startsWith(\"leather_\"));\n const dmg = this.freezing.tick(\n dt,\n p.inPowderSnow && !p.dead,\n leather || p.mode === \"creative\",\n p.fireT > 0,\n );\n if (dmg) p.damage(dmg, \"freeze\");\n this.hud.frost(this.freezing.level);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n this._hornCd = Math.max(0, this._hornCd - dt);\n const press =\n this.input.rightDown &&\n !this.player.dead &&\n this.inventory.heldItem()?.item === \"goat_horn\";\n if (press && !this._hornHeld && this._hornCd <= 0) {\n this._hornCd = 7;\n events.emit(\"goatHorn\");\n const p = this.player.pos;\n if (this.dimension === \"overworld\")\n this.sculk.vibrate(p.x, p.y, p.z, this.player.mode !== \"creative\");\n }\n this._hornHeld = press;\n }\n\n /** Скалк, вибрации и Страж (systems/Sculk.ts) — в верхнем мире. */\n sculk = new Sculk({\n world: () => this.world,\n player: () => this.player,\n wardens: () =>\n [...this.entities.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\" && !e.dead && !!e.def.warden,\n ),\n summon: (x, y, z) => {\n const m = this.entities.spawnMob(\"warden\", x, y, z);\n m.emergeT = EMERGE_TIME;\n m.invulnerable = true;\n return m;\n },\n });\n /** Пройдено шагов с последней вибрации (вибрация — каждые 2 блока ходьбы без приседа). */\n _stepDist = 0;\n /** Где игрок умер последний раз — туда указывает компас возвращения (в сейв не пишется). */\n lastDeath: { x: number; y: number; z: number; dim: string } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n this.sculk.tick(dt);\n const p = this.player;\n if (p.dead || p.flying || !p.onGround || p._sneaking) return;\n this._stepDist += Math.hypot(p.vel.x, p.vel.z) * dt;\n if (this._stepDist < 2) return;\n this._stepDist = 0;\n const x = Math.floor(p.pos.x),\n y = Math.floor(p.pos.y),\n z = Math.floor(p.pos.z);\n if (this.world.getBlock(x, y - 1, z) === B.sculk_shrieker)\n this.sculk.shriek(x, y - 1, z);\n this.sculk.vibrate(p.pos.x, p.pos.y, p.pos.z, p.mode !== \"creative\");\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n const on =\n !this.player.dead &&\n this.input.rightDown &&\n this.inventory.heldItem()?.item === \"spyglass\";\n const want = on ? 10 : 75;\n if (this.camera.fov === want) return;\n this.camera.fov += (want - this.camera.fov) * Math.min(1, dt * 12);\n if (Math.abs(this.camera.fov - want) < 0.1) this.camera.fov = want;\n this.camera.updateProjectionMatrix();\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n const h = this.inventory.heldItem();\n // карта сокровищ или исследователя в руке становится картой участка с крестиком над целью:\n // из тега {x, z}, а без него (карта картографа) — ближайшая к игроку цель такого вида\n const kind = h ? EXPLORER_MAPS[h.item] : undefined;\n if (h && kind) {\n let tx = Number(h.tag?.x),\n tz = Number(h.tag?.z);\n if (!Number.isFinite(tx) || !Number.isFinite(tz)) {\n if (this.dimension !== \"overworld\") return;\n const f = this.world.generator.locate?.(\n kind,\n this.player.pos.x,\n this.player.pos.z,\n );\n if (!f) return;\n tx = f.x;\n tz = f.z;\n }\n const m = newMap(this.nextMapId++, tx, tz, \"overworld\");\n // карта с целью — по центру цели, а не по сетке карт: иначе ✖ бывает у самого края\n m.x0 = Math.floor(tx) - MAP_SIZE / 2;\n m.z0 = Math.floor(tz) - MAP_SIZE / 2;\n m.target = [tx, tz];\n this.maps.set(m.id, m);\n this.inventory.setSlot(this.inventory.hotbarIndex, {\n item: \"filled_map\",\n count: 1,\n tag: { map: m.id },\n });\n events.emit(\"mapCreated\", { id: m.id, treasure: true });\n return;\n }\n if (h?.item !== \"filled_map\") return;\n const m = this.maps.get(Number(h.tag?.map));\n if (!m || m.dim !== this.dimension) return;\n const p = this.player.pos;\n surveyMap(this.world, m, p.x, p.z, 48, this._mapPhase++ & 3);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n if (this.dimension !== \"overworld\" || this.raid) return;\n const p = this.player.pos;\n const v = (this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v || !siegeTonight()) return;\n const angle = Math.random() * Math.PI * 2;\n let n = 0;\n for (let i = siegeCount(); i > 0; i--) {\n const { x, z } = siegeSpot(v.x + 0.5, v.z + 0.5, angle);\n this.warmup(x, z);\n const y = this.world.surfaceY(x, z);\n if (y <= 0) continue;\n const m = this.entities.spawnMob(\"zombie\", x + 0.5, y + 1.01, z + 0.5);\n // цель — центр деревни: без неё отряд разбредается, не дойдя до жителей (MobBrain, huntsLand)\n m.goal = new THREE.Vector3(v.x + 0.5, v.y, v.z + 0.5);\n n++;\n }\n if (n === 0) return;\n events.emit(\"siegeStart\", { x: v.x, z: v.z, count: n });\n this.hud.message(\"Ночная осада! Зомби идут к деревне\", 3500);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n // деревни верхнего мира и города Предела (панцирники) — один путь: villagesNear генератора\n const p = this.player.pos;\n for (const v of this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []) {\n if (this.villagesSettled.has(v.id)) continue;\n const loaded = v.residents.every(\n (r) =>\n this.world.getBlockOrUnloaded(\n Math.floor(r.x),\n Math.floor(r.y),\n Math.floor(r.z),\n ) !== -1,\n );\n if (!loaded) continue;\n for (const r of v.residents)\n if (MOBS[r.type]) {\n const m = this.entities.spawnMob(r.type, r.x, r.y + 0.01, r.z);\n if (r.captain) m.captain = true;\n }\n this.villagesSettled.add(v.id);\n }\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n const near = (p: THREE.Vector3) =>\n Math.hypot(p.x - x, p.z - z) < 3 && Math.abs(p.y - y) < 4;\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && near(e.pos)) e.damage(5);\n if (near(this.player.pos)) this.player.damage(5, \"lightning\");\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n if (!this.playing) return;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n this.weather.tick(\n dt,\n sky ? this.world : null,\n this.player.pos.x,\n this.player.pos.z,\n );\n this.world.raining = sky && this.weather.raining;\n this.world.containerAt = this._containerAt;\n this.entities.raining = this.world.raining;\n this.entities.skyDarken = Math.max(\n this.dayNight.skyDarken,\n sky ? this.weather.darken : 0,\n );\n this.entities.isDay = this.dayNight.isDay;\n this.entities.tick(dt);\n this.fluids.tick();\n this.player.tickHazards(dt);\n // черепаший панцирь: над водой копит 10 с Подводного дыхания — под водой их хватает на нырок\n const wb = this.player.effects.get(\"water_breathing\");\n if (\n this.inventory.armor[0]?.item === \"turtle_helmet\" &&\n !this.player.headInWater &&\n (!wb || wb.time < 9.5)\n )\n this.player.addEffect(\"water_breathing\", 10);\n this._tickPortal(dt);\n this._tickGateway();\n this._tickRaid(dt);\n this._spyglass(dt);\n this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n this.dayNight.tick(dt);\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n } else if (!day && this._wasDay) this._siege(); // стемнело — может начаться осада деревни\n this.entities.insomnia = this.daysAwake >= 3;\n this._wasDay = day;\n this._villageT += dt;\n if (this._villageT >= 1) {\n this._villageT = 0;\n this._settleVillages();\n }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\n }\n events.emit(\"tick\", { n });\n this._autosaveT += dt;\n if (this._autosaveT >= AUTOSAVE_EVERY) {\n this._autosaveT = 0;\n void this.persist();\n }\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.beams.update(this.blockEntities, this._time);\n this.displays.update(this.blockEntities, this.world);\n this.signs.update(this.blockEntities, this.world);\n this.banners.update(this.blockEntities, this.world);\n this.armorStands.update(this.blockEntities);\n this.fireworks.update(dt);\n this.dayNight.frame(\n this.player.pos,\n this.player.headInLava\n ? \"lava\"\n : this.player.headInWater\n ? \"water\"\n : \"air\",\n this.dimension,\n this.player.effects.has(\"night_vision\"),\n DIMENSIONS[this.dimension].hasSky ? this.weather : undefined,\n );\n // Тьма пульсирует: то наползает, то чуть отступает\n if (this.player.effects.has(\"darkness\"))\n this.dayNight.darkness(\n 0.75 + 0.25 * Math.abs(Math.sin(this._time * 1.3)),\n );\n this.weather.frame(\n dt,\n eye,\n DIMENSIONS[this.dimension].hasSky ? this.world : null,\n );\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.invUI.frame();\n this.renderPasses();\n }\n\n /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n this.renderer.shadowMap.needsUpdate = true; // тени — только в мировом проходе\n this.renderer.render(this.scene, this.camera);\n this.renderer.autoClear = false;\n this.renderer.clearDepth();\n this.camera.layers.set(ARM_LAYER);\n this.renderer.render(this.scene, this.camera);\n this.camera.layers.set(0);\n this.renderer.autoClear = true;\n }\n\n /**\n * Снимок экрана для кнопки «поделиться» на странице игры.\n *\n * ⚠ Рисует и читает В ОДИН приём. Рендерер создан без `preserveDrawingBuffer` (и так и надо —\n * флаг стоит памяти и копии каждый кадр), поэтому к моменту, когда до канвы доберётся кто-то\n * ещё, буфер уже очищен: `toBlob` вернёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись.\n */\n async capture(): Promise<Blob | null> {\n if (this._destroyed) return null;\n this.renderPasses();\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n // последний снимок уходит до разбора мира; дописывается транзакция уже без нас\n void this.persist().finally(() => this.store?.close());\n this._destroyed = true;\n this.worldUI.hide();\n this.loop.stop();\n this.input.releaseLock();\n // Шина `events` и DOM-подписки живут дольше игры: хост пересоздаёт Game после перелогина, и\n // без этого старый экземпляр продолжал бы слушать — `E` эмитился дважды (новый инвентарь\n // открывался и тут же закрывался), звуки двоились, весь старый мир держался в памяти.\n this._abort.abort();\n events.clear();\n this.hud.dispose();\n this.chat.dispose();\n this.signEditor.dispose();\n this.bookScreen.dispose();\n this.nameEditor.dispose();\n this.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n this.input.enabled = true;\n if (!this.loop.running) this.loop.start();\n } else {\n this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
|
|
644
|
+
"content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config\";\nimport { events } from \"./core/EventBus\";\nimport { GameLoop } from \"./core/GameLoop\";\nimport { IntentQueue } from \"./core/Intents\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas\";\nimport { buildIcons } from \"./registry/Items\";\nimport { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView, ARM_LAYER } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { BlockUpdates } from \"./systems/BlockUpdates\";\nimport { BlockEntities } from \"./world/BlockEntities\";\nimport {\n DIMENSIONS,\n isDimensionId,\n type DimensionId,\n} from \"./world/Dimensions\";\nimport { BLOCKS, B } from \"./registry/Blocks\";\nimport { findSafeSpot, isStandable } from \"./world/safeSpot\";\nimport { MOBS } from \"./registry/Mobs\";\nimport { buildPortal, findPortalNear, portalsOf } from \"./world/Portal\";\nimport { LAVA_SEA } from \"./worldgen/inferno/InfernoGenerator\";\nimport {\n BEYOND_ARRIVAL,\n BeyondGenerator,\n ISLAND_Y,\n OUTER_LANDING,\n beyondPillars,\n exitPortalCells,\n gatewaySpot,\n} from \"./worldgen/beyond/BeyondGenerator\";\nimport { OUTER_START } from \"./worldgen/beyond/outer\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { MAP_SIZE, newMap, surveyMap, type MapData } from \"./systems/Maps\";\nimport { EXPLORER_MAPS } from \"./registry/items/mansion\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { hitTarget } from \"./world/behaviours/extras\";\nimport { anchorCharges } from \"./world/behaviours/stand\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { dripstoneFallDamage } from \"./world/behaviours/dripstone\";\nimport { Freezing } from \"./systems/Freezing\";\nimport {\n TRIAL_RANGE,\n trialStep,\n type TrialState,\n} from \"./systems/TrialSpawner\";\nimport { TRIAL_MOBS } from \"./registry/blocks/trial\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem, worldSourceOf } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { CreateWorldUI } from \"./ui/CreateWorldUI\";\nimport { NetUI } from \"./ui/NetUI\";\nimport { ChatConsole } from \"./ui/ChatConsole\";\nimport { SignEditor } from \"./ui/SignEditor\";\nimport { BookScreen } from \"./ui/BookScreen\";\nimport { NameEditor } from \"./ui/NameEditor\";\nimport { TouchControls, touchWanted } from \"./ui/TouchControls\";\nimport { Commands } from \"./systems/Commands\";\nimport { BeaconBeams } from \"./gfx/BeaconBeams\";\nimport { DisplayItems } from \"./gfx/DisplayItems\";\nimport { SignText } from \"./gfx/SignText\";\nimport { Banners } from \"./gfx/Banners\";\nimport { Achievements } from \"./systems/Achievements\";\nimport { RAID_WAVES, Raid } from \"./systems/Raid\";\nimport { siegeCount, siegeSpot, siegeTonight } from \"./systems/Siege\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { beaconEffects, inBeaconRange, type BeaconPulse } from \"./world/Beacon\";\nimport { WorldStore, newWorldId } from \"./systems/WorldStore\";\nimport type { WorldAI } from \"./ai/types\";\nimport { startSource } from \"./worlds/startWorld\";\nimport { CURRENT_GEN } from \"./worldgen/WorldGenerator\";\nimport type { DimensionSave, EffectId, SaveData, WorldSource } from \"./types\";\nimport type { RealtimeClient } from \"@idosgames/core/realtime\";\nimport { sim } from \"./net/sim\";\nimport type { NetGame } from \"./net/NetGame\";\nimport type { DiffStore } from \"./net/DiffStore\";\nimport type {\n RoomSettings,\n Welcome,\n WorldMeta,\n WorldState,\n} from \"./net/protocol\";\nimport {\n difficulty,\n parseDifficulty,\n setDifficulty,\n} from \"./systems/Difficulty\";\n\n/** Автосейв текущего мира в «Мои миры», секунд игры. */\nconst AUTOSAVE_EVERY = 30;\n/** Восемь клеток вокруг (сначала стороны, потом углы) — где встать у якоря возрождения. */\nconst AROUND: readonly (readonly [number, number])[] = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n [1, 1],\n [1, -1],\n [-1, 1],\n [-1, -1],\n];\n\nexport interface GameOptions {\n /** ИИ-генерация миров (client.ai). null/нет — только путь без ИИ. */\n ai?: WorldAI | null;\n /** Сетевая игра: SDK-клиент (комнаты и сигналинг движка). null/нет — только одиночная. */\n netClient?: RealtimeClient | null;\n /** Имя игрока в сетевой игре (профиль); нет — «Игрок». */\n playerName?: () => string;\n}\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n /** Снимает разом все DOM-подписки игры (document/window/канвас) в destroy(). */\n private readonly _abort = new AbortController();\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n /** Блоки без опоры (факел, растение, дверь) ломаются вслед за соседом. */\n blockUpdates: BlockUpdates;\n /** Состояние клеток текущего измерения: сундуки, печи… */\n blockEntities: BlockEntities;\n /** Лучи светящих маяков. */\n beams: BeaconBeams;\n /** Предметы, выставленные в рамках. */\n displays: DisplayItems;\n /** Надписи на табличках. */\n signs: SignText;\n banners: Banners;\n armorStands: ArmorStand;\n fireworks: Fireworks;\n /** Достижения мира (systems/Achievements) и музыка (systems/Music). */\n achievements: Achievements;\n music: Music;\n /** Измерение, в котором игрок, и снимки остальных (дифы, сущности) до возвращения в них. */\n dimension: DimensionId = \"overworld\";\n readonly dims = new Map<DimensionId, DimensionSave>();\n interaction: Interaction;\n /** Вода и лава. */\n fluids: FluidSim;\n dayNight: DayNight;\n /** Дождь и гроза (только в измерениях с небом). */\n weather: Weather;\n /** Секунды в портале; lock — не переносить снова, пока игрок не вышел из портала прибытия. */\n _portalT = 0;\n _portalLock = false;\n /** Известные порталы: обратный путь выводит к своему порталу (в верхнем мире — в 128 блоках). */\n portals = portalsOf(null);\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны (сейв v3, `beyond`). */\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны, где Врата (после победы). */\n beyondState: {\n initialized: boolean;\n dragonKilled: boolean;\n creditsShown: boolean;\n gateway: [number, number, number] | null;\n } = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n worldUI: CreateWorldUI;\n /** Экран «Сетевая игра» и список игроков по Tab. */\n netUI: NetUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n private _time: number;\n loop: GameLoop;\n /** Текущий мир в «Моих мирах». Классический стартовый мир — \"default\". */\n worldId = \"default\";\n worldName = \"VoxelCraft\";\n /** Хранилище миров; null — браузер не даёт IndexedDB (игра работает без сохранения). */\n store: WorldStore | null = null;\n private _autosaveT = 0;\n /** Был ли день на прошлом тике — рассвет пополняет товар жителей. */\n private _wasDay = true;\n /** Деревни, в которые уже поселены жители (id региона; сейв v3, `villages`). */\n villagesSettled = new Set<string>();\n private _villageT = 0;\n /** Ночей без сна (сейв v3 `daysAwake`); _slept — спал этой ночью. */\n daysAwake = 0;\n private _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n private _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n private _worldTouched = false;\n private _destroyed = false;\n\n // ---- сетевая игра (net/NetGame) ----\n /** Идёт сетевая игра (хост или гость); null — одиночная. */\n net: NetGame | null = null;\n /** SDK-клиент для сетевой игры (комнаты движка); нет — мультиплеер недоступен. */\n readonly netClient: RealtimeClient | null;\n /** Имя этого игрока для других (профиль SDK). */\n readonly playerName: () => string;\n /**\n * Хост: сохранённые игроки сетевой игры по userId (позиция, инвентарь, опыт…). Лежат в сейве мира\n * хоста (`players`): вернувшийся игрок продолжает с тем же инвентарём и там же.\n */\n netPlayers = new Map<string, unknown>();\n /** Лёг спать в сетевой игре: ночь пропускается, когда спят все (как в Minecraft). */\n netSleeping = false;\n /** Мир, в котором игрок был до входа в чужую игру: вернуться в него при выходе. */\n private _soloWorldBeforeNet: string | null = null;\n\n /** Этот процесс считает своё измерение (одиночная игра, хост, владелец измерения) — net/sim. */\n get simulating(): boolean {\n return sim.authority;\n }\n\n constructor(opts: GameOptions = {}) {\n this.netClient = opts.netClient ?? null;\n this.playerName = opts.playerName ?? (() => \"Игрок\");\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n // Казуальная картинка: Neutral-тонмаппинг держит насыщенность ярких цветов (ACES их смывает).\n this.renderer.toneMapping = THREE.NeutralToneMapping;\n this.renderer.toneMappingExposure = CONFIG.graphics.exposure;\n this.renderer.shadowMap.enabled = CONFIG.graphics.shadows;\n this.renderer.shadowMap.type = THREE.PCFShadowMap; // мягкость — shadow.radius (PCFSoft в r185 устарел)\n // Карту теней обновляем сами, раз за кадр (см. frame): теневой проход фильтрует объекты по\n // слоям камеры, и второй проход (рука) перерисовал бы её заново — из одной руки.\n this.renderer.shadowMap.autoUpdate = false;\n this.scene = new THREE.Scene();\n // Пропорции выставит первый же замер ResizeObserver'а — до него кадров не будет.\n this.camera = new THREE.PerspectiveCamera(\n 75,\n 1,\n 0.1,\n CONFIG.renderDistance * 16 + 60,\n );\n this.camera.rotation.order = \"YXZ\";\n this.scene.add(this.camera); // рука прикреплена к камере\n\n // Размер берём из раскладки самого канваса (CSS растягивает его на вьюпорт), а не из\n // window.innerWidth, и через ResizeObserver, а не событие resize: игра может стартовать\n // в ещё не разложенном контейнере — фоновая вкладка или iframe превью, — где на момент\n // конструктора innerWidth равен нулю. Событие resize там уже не придёт, и канвас навсегда\n // остался бы 0x0: симуляция живая, экран чёрный. ResizeObserver сам отдаёт первый замер,\n // как только раскладка появляется.\n this._resizeObserver = new ResizeObserver(() => this._resize());\n this._resizeObserver.observe(canvas);\n this._resize();\n\n // --- контент: атлас и иконки из одних и тех же процедурных текстур ---\n this.atlas = new TextureAtlas();\n buildIcons(this.atlas);\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, startSource());\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.blockUpdates = new BlockUpdates(() => this.world, this.entities);\n // броня из инвентаря гасит урон игрока и изнашивается от ударов\n this.player.armorStats = () => this.inventory.armorStats();\n this.player.onArmorHit = (dmg) => this.inventory.damageArmor(dmg);\n this.player.canGlide = () => this.inventory.canGlide();\n this.player.tryTotem = () => {\n if (this.inventory.heldItem()?.item !== \"life_totem\") return false;\n this.inventory.consumeHeld(1);\n return true;\n };\n this.player.onGlideWear = () => this.inventory.wearWings();\n this.blockEntities = new BlockEntities(() => this.world, this.entities);\n this.beams = new BeaconBeams(this.scene);\n this.displays = new DisplayItems(this.scene, this.atlas);\n this.signs = new SignText(this.scene);\n this.banners = new Banners(this.scene);\n this.armorStands = new ArmorStand(this.scene, this.atlas);\n this.fireworks = new Fireworks(this.scene);\n this.achievements = new Achievements({\n has: (pred) => this.inventory.slots.some((s) => !!s && pred(s.item)),\n wearing: (pred) => this.inventory.armor.some((s) => !!s && pred(s.item)),\n effect: (id) => this.player.effects.has(id as EffectId),\n });\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.fluids = new FluidSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.weather = new Weather(this.scene);\n const start = this.world.source;\n if (start.kind === \"spec\") {\n this.dayNight.setTimeOfDay(start.spec.ambience.timeOfDay);\n this.worldName = start.spec.name;\n }\n this.audio = new Audio();\n this.music = new Music(this.audio);\n // пластинка в проигрывателе играет свою мелодию (сид и лад — systems/Disc), вынули — тишина\n events.on(\"discPlay\", ({ mood, seed }: { mood: Mood; seed: number }) =>\n this.music.play(mood, seed),\n );\n events.on(\"discEject\", () => this.music.stop());\n this.input = new Input(canvas, this.intents, this._abort.signal);\n this.armView = new ArmView(this.camera, this.atlas);\n // второй проход (рука) видит только свой слой — свет мира включаем и на нём\n this.dayNight.sun.layers.enable(ARM_LAYER);\n this.dayNight.hemi.layers.enable(ARM_LAYER);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n new AchievementsView(this.hud.heartsEl.parentElement!, this.achievements);\n this.invUI = new InventoryUI(this, this._abort.signal);\n this.worldUI = new CreateWorldUI(this, this._abort.signal, opts.ai ?? null);\n this.netUI = new NetUI(this, this._abort.signal);\n this.commands = new Commands(this);\n this.chat = new ChatConsole(this, this._abort.signal);\n this.signEditor = new SignEditor(this, this._abort.signal);\n this.bookScreen = new BookScreen(this, this._abort.signal);\n this.nameEditor = new NameEditor(this, this._abort.signal);\n this.touch = touchWanted()\n ? new TouchControls(this, this._abort.signal)\n : null;\n this.interaction.onCommand = (text) => this.commands.run(text);\n this.interaction.mayToggleMode = () => !this.net || this.net.canCheat;\n this.interaction.onUseMap = () => this.createMap();\n // опыт из печи (и других источников, что не кидают сферы)\n events.on(\"xpGain\", ({ amount }: { amount: number }) =>\n this.player.addXp(amount),\n );\n events.on(\"playerDied\", () => this._onDeath());\n events.on(\"portalLit\", (d: { x: number; y: number; z: number }) =>\n this._rememberPortal(this.dimension, d.x, d.y, d.z),\n );\n events.on(\"throwEye\", (d: { x: number; y: number; z: number }) =>\n this._throwEye(d.x, d.y, d.z),\n );\n events.on(\"dragonDefeated\", () => this._dragonDefeated());\n events.on(\"ui:useBed\", (d: { x: number; y: number; z: number }) =>\n this._useBed(d.x, d.y, d.z),\n );\n events.on(\"lightning\", (d: { x: number; y: number; z: number }) =>\n this._lightning(d.x, d.y, d.z),\n );\n // упавший сталактит бьёт того, кто под ним, и рассыпается острым капельником\n events.on(\n \"dripstoneFall\",\n (p: { x: number; y: number; z: number; landY: number }) => {\n const pl = this.player;\n if (\n !pl.dead &&\n Math.floor(pl.pos.x) === p.x &&\n Math.floor(pl.pos.z) === p.z &&\n pl.pos.y <= p.y &&\n pl.pos.y >= p.landY - 0.5\n )\n pl.damage(dripstoneFallDamage(p.y - pl.pos.y), \"falling_stalactite\");\n this.entities.spawnDrop(\n p.x + 0.5,\n p.landY + 0.2,\n p.z + 0.5,\n \"pointed_dripstone\",\n 1,\n );\n },\n );\n // находка из-под кисти — над расчищенным блоком\n events.on(\n \"brushLoot\",\n (p: { x: number; y: number; z: number; item: string; count: number }) =>\n this.entities.spawnDrop(\n p.x + 0.5,\n p.y + 1.1,\n p.z + 0.5,\n p.item,\n p.count,\n ),\n );\n // дроп, который роняет сам моб (рог козы)\n events.on(\n \"mobDrop\",\n (p: { x: number; y: number; z: number; item: string }) =>\n this.entities.spawnDrop(p.x, p.y, p.z, p.item, 1),\n );\n // глубокая тьма: сломанные и поставленные игроком блоки и взрывы — вибрации; смерть рядом с\n // катализатором — скалк; место смерти игрока — для компаса возвращения\n const overworld = () => this.dimension === \"overworld\";\n events.on(\n \"blockBreak\",\n (p: { x: number; y: number; z: number; byPlayer?: boolean }) => {\n if (overworld())\n this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n !!p.byPlayer && this.player.mode !== \"creative\",\n );\n },\n );\n // Жемчуг Предела упал: игрока переносит туда, и он расшибается, как в Minecraft; изредка из\n // точки вылезает клещ Предела (registry/mobs/beyond)\n events.on(\"pearlLand\", (p: { x: number; y: number; z: number }) => {\n const pl = this.player;\n if (pl.dead) return;\n this.warmup(p.x, p.z);\n let y = Math.floor(p.y);\n while (\n y > 1 &&\n !this.world.isSolid(Math.floor(p.x), y - 1, Math.floor(p.z)) &&\n y > Math.floor(p.y) - 4\n )\n y--;\n pl.pos.set(Math.floor(p.x) + 0.5, y, Math.floor(p.z) + 0.5);\n pl.vel.set(0, 0, 0);\n pl._fallDist = 0;\n if (pl.mode !== \"creative\") pl.damage(5, \"teleport\");\n events.emit(\"pearlTeleport\", { x: pl.pos.x, y: pl.pos.y, z: pl.pos.z });\n if (Math.random() < 0.05)\n this.entities.spawnMob(\"rim_mite\", pl.pos.x, pl.pos.y, pl.pos.z);\n });\n // фейерверк с земли: ракета взлетает и рассыпается искрами (gfx/Fireworks)\n events.on(\"fireworkLaunch\", (p: { x: number; y: number; z: number }) =>\n this.fireworks.launch(p.x, p.y, p.z),\n );\n // компас по путевому камню: стрелка держится за него, пока камень цел (ui/HUD)\n events.on(\"lodestoneBind\", (p: { x: number; y: number; z: number }) => {\n this.player.lodestone = { x: p.x, z: p.z, dim: this.dimension };\n this.hud.message(\"Компас привязан к путевому камню\", 2000);\n });\n // якорь возрождения: в Пекле — точка возрождения, в верхнем мире взрывается, как в Minecraft\n events.on(\n \"anchorUse\",\n (p: { x: number; y: number; z: number; charges: number }) => {\n if (!DIMENSIONS[this.dimension].bedsExplode) {\n this.world.setBlock(p.x, p.y, p.z, B.air!);\n this.entities.explode(p.x + 0.5, p.y + 0.5, p.z + 0.5, 5);\n return;\n }\n if (p.charges <= 0) {\n this.hud.message(\"Якорь не заряжен — нужен светокамень\", 2200);\n return;\n }\n this.player.anchorPos = [p.x, p.y, p.z];\n this.player.anchorDim = this.dimension;\n this.hud.message(\"Точка возрождения — у якоря\", 2200);\n },\n );\n // стрела в мишень — красный сигнал по близости к центру (world/behaviours/extras)\n events.on(\n \"arrowHit\",\n (p: {\n x: number;\n y: number;\n z: number;\n hx?: number;\n hy?: number;\n hz?: number;\n }) => {\n if (p.hx === undefined || p.hy === undefined || p.hz === undefined)\n return;\n hitTarget(this.world, p.x, p.y, p.z, p.hx, p.hy, p.hz);\n },\n );\n events.on(\"blockPlace\", (p: { x: number; y: number; z: number }) => {\n if (overworld())\n this.sculk.vibrate(\n p.x + 0.5,\n p.y + 0.5,\n p.z + 0.5,\n this.player.mode !== \"creative\",\n );\n });\n events.on(\"explosion\", (p: { x: number; y: number; z: number }) => {\n if (overworld()) this.sculk.vibrate(p.x, p.y, p.z, false);\n });\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const e = p.entity;\n if (overworld() && e.hitByPlayerT > 0 && !e.def.warden)\n this.sculk.onDeath(e.pos.x, e.pos.y, e.pos.z, e.def.xp?.[1] ?? 1);\n });\n // моб волны спавнера испытаний убит — засчитать спавнеру\n events.on(\"mobDied\", (p: { entity: Mob }) => {\n const f = p.entity.trialFrom;\n const be = f ? this.blockEntities.get(f[0], f[1], f[2]) : undefined;\n if (be?.type === \"trial_spawner\") (be.state as TrialState).killed++;\n });\n events.on(\"playerDied\", () => {\n const pp = this.player.pos;\n this.lastDeath = { x: pp.x, y: pp.y, z: pp.z, dim: this.dimension };\n });\n // проводник: игрок в воде в его радиусе получает силу моря (дыхание, ночное зрение, спешка)\n events.on(\n \"conduitPulse\",\n (p: { x: number; y: number; z: number; range: number }) => {\n const pl = this.player;\n if (\n pl.dead ||\n !pl.inWater ||\n pl.pos.distanceTo(\n new THREE.Vector3(p.x + 0.5, p.y + 0.5, p.z + 0.5),\n ) > p.range\n )\n return;\n for (const id of [\"water_breathing\", \"night_vision\", \"haste\"] as const)\n pl.addEffect(id, 13);\n events.emit(\"conduitPower\");\n },\n );\n // маяк: игрок в зоне получает выбранные силы\n events.on(\"beaconPulse\", (p: BeaconPulse) => {\n const pl = this.player;\n if (pl.dead || !inBeaconRange(p, pl.pos.x, pl.pos.y, pl.pos.z)) return;\n for (const e of beaconEffects(p)) pl.addEffect(e.id, e.seconds, e.level);\n });\n\n this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n void this.persist();\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\n const { signal } = this._abort;\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n },\n { signal },\n );\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\n \"click\",\n () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n },\n { signal },\n );\n\n // --- старт мира ---\n this.warmupAtSpawn();\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n // сетевая игра: скрытая вкладка продолжает считать мир в полном темпе (core/GameLoop)\n this.loop.backgroundTicker = () => this.net !== null;\n this.hud.showOverlay(false);\n this.loop.start();\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open().then(async (store) => {\n if (!store) return;\n if (this._destroyed) return store.close();\n this.store = store;\n const last = await store.lastPlayed().catch(() => undefined);\n if (!last || this._worldTouched || this.playing || this._destroyed)\n return;\n try {\n this.save.apply(last.save);\n this.worldId = last.id;\n this.worldName = last.name;\n this.hud.message(\"Продолжаем мир «\" + last.name + \"»\", 2200);\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n });\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). Гость чужой мир не сохраняет. */\n async persist(): Promise<void> {\n if (!this.store || this._netGuest) return;\n this.net?.collectPlayerStates(this.netPlayers);\n try {\n await this.store.put({\n id: this.worldId,\n name: this.worldName,\n updatedAt: Date.now(),\n save: this.save.collect(),\n });\n } catch {\n // квота/запрет хранилища: игра продолжается без автосейва\n }\n }\n\n /** Открыть мир из «Моих миров». */\n async openStoredWorld(id: string): Promise<boolean> {\n if (!this.store) return false;\n const w = await this.store.get(id).catch(() => undefined);\n if (!w) return false;\n if (id !== this.worldId) await this.persist();\n try {\n this.save.apply(w.save);\n } catch {\n return false;\n }\n this._worldTouched = true;\n this.worldId = w.id;\n this.worldName = w.name;\n return true;\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n // persist() снимает сейв синхронно при вызове — до apply ниже, поэтому уходит именно старый мир.\n void this.persist();\n try {\n this.save.apply(save);\n } catch {\n return false;\n }\n this.adoptNewWorld(name);\n this.hud.message(\"Мир «\" + name + \"» из Мастерской\", 2200);\n return true;\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n this._worldTouched = true;\n this.worldId = newWorldId();\n this.worldName = name;\n void this.persist();\n }\n\n // ---- сетевая игра (net/NetGame) ---------------------------------------------------------------\n\n /** Гость в чужом мире: мир не наш — не сохраняем его в «Мои миры» (до передачи хоста). */\n private _netGuest = false;\n\n /** Мир для входящего гостя: источник, палитра, время, погода, сложность, Предел, порталы. */\n netWorldMeta(): WorldMeta {\n return {\n source: this.world.source,\n palette: BLOCKS.map((b) => b.name),\n time: this.dayNight.serialize(),\n weather: this.weather.serialize(),\n difficulty: difficulty(),\n beyond: this.beyondState,\n portals: this.portals,\n daysAwake: this.daysAwake,\n name: this.worldName,\n };\n }\n\n /** Общее состояние мира (хост раз в 5 с): время, погода, сложность, Предел. */\n netWorldState(): WorldState {\n return {\n time: this.dayNight.serialize(),\n weather: this.weather.serialize(),\n difficulty: difficulty(),\n beyond: this.beyondState,\n daysAwake: this.daysAwake,\n };\n }\n\n applyNetWorldState(w: WorldState): void {\n this.dayNight.deserialize(w.time as SaveData[\"time\"]);\n this.weather.deserialize(w.weather as SaveData[\"weather\"]);\n setDifficulty(parseDifficulty(w.difficulty) ?? \"normal\");\n this.mergeBeyond(w.beyond);\n if (typeof w.daysAwake === \"number\" && w.daysAwake >= 0)\n this.daysAwake = w.daysAwake;\n }\n\n /** Предел: победа над драконом и Врата — однажды случившись, не отменяются (слияние «или»). */\n mergeBeyond(b: unknown): void {\n if (!b || typeof b !== \"object\") return;\n const s = b as Partial<Game[\"beyondState\"]>;\n const st = this.beyondState;\n st.initialized ||= s.initialized === true;\n st.dragonKilled ||= s.dragonKilled === true;\n st.creditsShown ||= s.creditsShown === true;\n if (\n !st.gateway &&\n Array.isArray(s.gateway) &&\n s.gateway.length === 3 &&\n s.gateway.every(Number.isFinite)\n )\n st.gateway = [s.gateway[0]!, s.gateway[1]!, s.gateway[2]!];\n }\n\n /** Сейв этого игрока для мира хоста: позиция, измерение, инвентарь, опыт, эффекты. */\n netPlayerState(): Record<string, unknown> {\n const p = this.player.pos;\n return {\n ...this.player.serialize(),\n inventory: this.inventory.serialize(),\n dim: this.dimension,\n pos: [p.x, p.y, p.z],\n beyond: this.beyondState,\n };\n }\n\n /** Ночь пропущена: спали все (сетевая игра) или один (одиночная). */\n skipNight(): void {\n this.dayNight.resetToMorning();\n this.weather.set(\"clear\"); // сон пережидает и непогоду, как в Minecraft\n this._slept = true;\n this.daysAwake = 0;\n this.netSleeping = false;\n this.hud.message(\"Доброе утро!\", 1800);\n events.emit(\"slept\");\n }\n\n /**\n * Гость: мир хоста вместо своего. Свой уходит в «Мои миры» и вернётся при выходе. Все измерения\n * строятся из присланных дифов — порталы работают без ожидания.\n */\n loadNetWorld(w: Welcome, diffs: DiffStore, settings: RoomSettings): void {\n void this.persist();\n this._soloWorldBeforeNet = this.worldId;\n this._netGuest = true;\n // сейвы чужих игроков — от СВОЕГО прежнего мира: стань мы хостом этого, они бы переехали в него\n this.netPlayers = new Map();\n this._worldTouched = true;\n if (this.invUI.open) this.invUI.close();\n const dim = isDimensionId(w.spawn.dim) ? w.spawn.dim : \"overworld\";\n const src = worldSourceOf({\n world: w.world.source,\n seed: 0,\n } as unknown as SaveData);\n this.dims.clear();\n for (const d of [\"overworld\", \"inferno\", \"beyond\"] as const)\n if (d !== dim)\n this.dims.set(d, {\n chunks: diffs.toChunkDiffMap(d),\n entities: [],\n blockEntities: [],\n });\n this.rebuildWorld(src, dim);\n this.world.loadDiffs(diffs.toChunkDiffMap(dim));\n this.entities.clear();\n this.blockEntities.clear();\n this.applyNetWorldState({ ...w.world });\n this.portals = portalsOf(w.world.portals);\n this.villagesSettled = new Set();\n this.maps = new Map();\n this.nextMapId = 1;\n this.achievements.load([]);\n const state = w.state as\n (Record<string, unknown> & { inventory?: unknown }) | null;\n if (state && Array.isArray((state as { pos?: unknown }).pos)) {\n this.player.deserialize(\n state as unknown as Parameters<Player[\"deserialize\"]>[0],\n );\n this.inventory.deserialize(\n state.inventory as Parameters<Inventory[\"deserialize\"]>[0],\n );\n } else {\n this.player.resetForNewWorld();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n this.player.mode = settings.guestMode;\n }\n const [x, y, z] = w.spawn.pos;\n this.warmup(x, z);\n this.player.pos.set(x, y, z);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n if (!state) this.player.spawnPoint.set(x, y, z);\n this.worldName = \"Сетевая игра\";\n this._netWorldName =\n typeof w.world.name === \"string\" && w.world.name.trim()\n ? w.world.name.trim().slice(0, 60)\n : \"\";\n this.hud.message(\"Вы в сетевой игре\", 2200);\n events.emit(\"dimensionChanged\", { dimension: dim });\n }\n\n /** Стал хостом после ухода прежнего: этот мир теперь наш — новой записью в «Моих мирах». */\n adoptNetWorldAsOwn(): void {\n this._netGuest = false;\n // имя мира хоста, а не безликое «Сетевой мир»: при переносе мир остаётся тем же миром\n this.adoptNewWorld(this._netWorldName || \"Сетевой мир\");\n }\n private _netWorldName = \"\";\n\n /**\n * Сетевая игра закончилась. Гость возвращается в свой мир; хост (и ставший хостом) продолжает\n * в этом мире один. `reason` — показать почему (исключили, хост закрыл игру).\n */\n exitNet(reason: string | null): void {\n const guest = this._netGuest;\n this.net = null;\n this._netGuest = false;\n this.netSleeping = false;\n sim.authority = true;\n this.entities.replica = false;\n this.entities.others = () => [];\n this.entities.onLocalSpawn = null;\n this.entities.onRemoteAction = null;\n if (guest) {\n const back = this._soloWorldBeforeNet;\n this._soloWorldBeforeNet = null;\n if (back)\n void this.openStoredWorld(back).then(\n (ok) => ok || this.newWorld((Math.random() * 1e9) >>> 0),\n );\n else this.newWorld((Math.random() * 1e9) >>> 0);\n }\n if (reason) this.hud.message(reason, 3500);\n events.emit(\"net:roster\");\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n this.world.update(x, z);\n const busy =\n this.world.genQueue.length > 0 ||\n [...this.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n const s = this.world.generator.spawnHint();\n this.warmup(s.x, s.z);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, source, dimension);\n this.dimension = dimension;\n this.blockEntities.clear();\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.fluids.world = this.world;\n this.fluids.clear();\n this.interaction.breaking = null;\n this.interaction.damage.clear(); // трещины старого мира не должны лечь на блоки нового\n this.interaction.eating = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n const src: WorldSource =\n typeof source === \"number\"\n ? { kind: \"noise\", seed: source, gen: CURRENT_GEN }\n : // новый мир — текущего поколения (у мира из рецепта: цитадель под островом)\n { ...source, gen: source.gen ?? CURRENT_GEN };\n void this.persist(); // старый мир остаётся в «Моих мирах» таким, каким его оставили\n this.dims.clear();\n this.portals = portalsOf(null);\n this.beyondState = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n this.villagesSettled = new Set();\n this.achievements.load([]); // достижения — у каждого мира свои, как в Minecraft\n this.maps = new Map();\n this.nextMapId = 1;\n this.daysAwake = 0;\n this.rebuildWorld(src, \"overworld\");\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n if (src.kind === \"spec\")\n this.dayNight.setTimeOfDay(src.spec.ambience.timeOfDay);\n else this.dayNight.resetToMorning();\n this.warmupAtSpawn();\n this.player.resetForNewWorld(); // кровать, якорь, эффекты, опыт старого мира — не переносятся\n this.freezing.t = 0;\n this.player.spawn();\n const name = src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed;\n this.adoptNewWorld(name);\n this.hud.message(\n src.kind === \"spec\"\n ? \"Мир «\" + src.spec.name + \"»\"\n : \"Новый мир, сид \" + src.seed,\n 1800,\n );\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n if (target === this.dimension) return;\n const prev = this.dimension;\n // сетевая игра: целевое измерение — из общей копии дифов (net/NetGame.beforeDimension)\n this.net?.beforeDimension(target);\n this.dims.set(this.dimension, {\n chunks: this.world.serializeDiffs(),\n entities: this.entities.serialize(),\n blockEntities: this.blockEntities.serialize(),\n });\n // прежнее снято (с сущностями — для следующего владельца); теперь роль в новом измерении\n this.net?.prepareDimension();\n const saved = this.dims.get(target);\n this.dims.delete(target);\n this.rebuildWorld(this.world.source, target);\n this.entities.clear();\n if (saved) {\n this.world.loadDiffs(saved.chunks);\n this.entities.deserialize(\n (saved.entities ?? []) as Parameters<EntityManager[\"deserialize\"]>[0],\n );\n this.blockEntities.deserialize(saved.blockEntities);\n }\n this.warmup(x, z);\n this.player.pos.set(x, y, z);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n // сетевая игра: отдать прежнее измерение (если владел) и заявить новое — до любых правок в нём\n this.net?.afterDimension(prev, target);\n events.emit(\"dimensionChanged\", { dimension: target });\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n if (this.player.mode !== \"creative\") {\n const p = this.player.pos;\n const inv = this.inventory;\n for (const s of [...inv.slots, ...inv.armor])\n if (s)\n this.entities.spawnDrop(\n p.x + (Math.random() - 0.5),\n p.y + 0.5,\n p.z + (Math.random() - 0.5),\n s.item,\n s.count,\n s.dmg,\n );\n inv.clearCarried(); // сундук Предела не трогаем — раньше смерть стирала и его\n const xp = Math.min(100, this.player.xpLevel * 7);\n if (xp > 0) this.entities.spawnXp(p.x, p.y + 0.5, p.z, xp);\n this.player.xpLevel = this.player.xpProgress = this.player.xpTotal = 0;\n }\n if (this.invUI.open) this.invUI.close();\n // чат, табличка, книга, бирка поверх экрана смерти: их закрытие вернуло бы захват мыши\n this.chat.hide();\n this.signEditor.hide();\n this.bookScreen.hide();\n this.nameEditor.hide();\n this.input.uiOpen = true; // до releaseLock: без паузы — вместо неё экран смерти\n this.input.releaseLock();\n this.hud.showDeath(this.player.lastCause);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n if (!this.player.dead) return;\n this.player.respawn(this.goHome());\n this.hud.hideDeath();\n this.input.uiOpen = false;\n this.input.requestLock();\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n // якорь возрождения сильнее кровати и возвращает в СВОЁ измерение (обычно в Пекло)\n const anchor = this.anchorHome();\n if (anchor) return anchor;\n const s = this.player.spawnPoint;\n let at = new THREE.Vector3(s.x, s.y, s.z);\n let atBed = false;\n const bed = this.player.bedPos;\n if (this.dimension !== \"overworld\")\n this.changeDimension(\"overworld\", at.x, at.y, at.z);\n if (bed) {\n this.warmup(bed[0], bed[2]);\n if (\n BLOCKS[this.world.getBlock(bed[0], bed[1], bed[2])]?.shape === \"bed\"\n ) {\n at = new THREE.Vector3(bed[0] + 0.5, bed[1] + 0.6, bed[2] + 0.5);\n atBed = true;\n } else {\n this.player.bedPos = null;\n this.hud.message(\"Ваша кровать отсутствует или заблокирована\", 2500);\n }\n }\n this.warmup(at.x, at.z);\n // точку могли застроить — поднимаемся до свободного места на рост\n const bx = Math.floor(at.x),\n bz = Math.floor(at.z);\n let y = Math.floor(at.y);\n while (\n y < 126 &&\n (this.world.isSolid(bx, y, bz) || this.world.isSolid(bx, y + 1, bz))\n )\n y++;\n // …и опускаемся до пола: точка возрождения в воздухе — это смерть от падения сразу после неё\n while (y > 1 && this.world.getBlock(bx, y - 1, bz) === B.air) y--;\n at.y = y;\n // …и не в яме снега-пороха, не в лаве: иначе возрождение сразу ведёт к новой смерти, и так по кругу\n // (точка мира из старых сейвов могла быть записана прямо на дне такой ямы)\n if (!atBed && !isStandable(this.world, bx, y, bz)) {\n const safe = findSafeSpot(this.world, bx, bz, 16);\n if (safe) at = new THREE.Vector3(safe.x + 0.5, safe.y, safe.z + 0.5);\n }\n return at;\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n const p = this.player.pos;\n const cell = (dy: number) =>\n this.world.getBlock(\n Math.floor(p.x),\n Math.floor(p.y + dy),\n Math.floor(p.z),\n );\n const inBeyond = cell(0) === B.beyond_portal;\n const inside =\n inBeyond || [0, 1].some((dy) => cell(dy) === B.inferno_portal);\n if (!inside || this.player.dead) {\n this._portalT = 0;\n this._portalLock = false;\n return;\n }\n if (this._portalLock) return;\n // портал Предела переносит сразу, как в Minecraft\n if (inBeyond) {\n this._portalLock = true;\n this._beyondTravel();\n return;\n }\n this._portalT += dt;\n if (this._portalT < (this.player.mode === \"creative\" ? 0.25 : 4)) return;\n this._portalT = 0;\n this._portalLock = true;\n const target: DimensionId =\n this.dimension === \"inferno\" ? \"overworld\" : \"inferno\";\n const k =\n target === \"inferno\"\n ? 1 / DIMENSIONS.inferno.scale\n : DIMENSIONS.inferno.scale;\n const tx = Math.floor(p.x * k),\n tz = Math.floor(p.z * k);\n // портал, через который уходим, — тоже известный (порталы из сейвов до реестра)\n this._rememberPortal(\n this.dimension,\n Math.floor(p.x),\n Math.floor(p.y),\n Math.floor(p.z),\n );\n // свой портал на той стороне: в верхнем мире ищем в 128 блоках, в Пекле — в 16, как в Minecraft\n const known = this._nearestPortal(\n target,\n tx,\n tz,\n target === \"overworld\" ? 128 : 16,\n );\n this.changeDimension(\n target,\n (known?.[0] ?? tx) + 0.5,\n 70,\n (known?.[2] ?? tz) + 0.5,\n );\n let at = known ? findPortalNear(this.world, known[0], known[2], 2) : null;\n if (known && !at) this._forgetPortal(target, known); // сломали — забываем\n if (!at) {\n if (known) this.warmup(tx, tz);\n at =\n findPortalNear(this.world, tx, tz, 16) ??\n (target === \"inferno\"\n ? buildPortal(this.world, tx, tz, 64, LAVA_SEA + 2, 110)\n : buildPortal(this.world, tx, tz, 70, 4, 120));\n this._rememberPortal(target, at.x, at.y, at.z);\n }\n this.player.pos.set(at.x + 0.5, at.y, at.z + 0.5);\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: target });\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n private readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n const plate = B.stone_pressure_plate;\n if (plate === undefined) return;\n const on = new Set<string>();\n // плиту жмут все, нажимные рельсы — только вагонетки\n const mark = (x: number, y: number, z: number, cart = false) => {\n const bx = Math.floor(x),\n by = Math.floor(y + 0.05),\n bz = Math.floor(z);\n const id = this.world.getBlock(bx, by, bz);\n if (id === plate || (cart && id === B.detector_rail))\n on.add(bx + \",\" + by + \",\" + bz);\n };\n if (!this.player.dead)\n mark(this.player.pos.x, this.player.pos.y, this.player.pos.z);\n // сетевая игра: плиту жмут и другие игроки этого измерения\n for (const a of this.entities.others())\n if (!a.player.dead) mark(a.player.pos.x, a.player.pos.y, a.player.pos.z);\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead) mark(e.pos.x, e.pos.y, e.pos.z);\n else if (e.kind === \"minecart\" && !e.dead)\n mark(e.pos.x, e.pos.y, e.pos.z, true);\n const at = (k: string) =>\n k.split(\",\").map(Number) as [number, number, number];\n for (const k of on) {\n this._plates.set(k, 1);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z),\n m = this.world.getMeta(x, y, z);\n if (id === plate && m === 0) this.world.setBlock(x, y, z, plate, 1);\n else if (id === B.detector_rail && !(m & 0x8))\n this.world.setBlock(x, y, z, id, m | 0x8);\n }\n for (const [k, left] of this._plates) {\n if (on.has(k)) continue;\n if (left - dt > 0) {\n this._plates.set(k, left - dt);\n continue;\n }\n this._plates.delete(k);\n const [x, y, z] = at(k);\n const id = this.world.getBlock(x, y, z);\n if (id === plate) this.world.setBlock(x, y, z, plate, 0);\n else if (id === B.detector_rail)\n this.world.setBlock(x, y, z, id, this.world.getMeta(x, y, z) & ~0x8);\n }\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n if (this.dimension === \"beyond\") {\n const s = this.player.spawnPoint;\n this.changeDimension(\"overworld\", s.x, s.y, s.z);\n this.player.pos.copy(this.goHome());\n // первый выход после победы — титры\n if (this.beyondState.dragonKilled && !this.beyondState.creditsShown) {\n this.beyondState.creditsShown = true;\n this.hud.showCredits();\n }\n } else {\n const { x, y, z } = BEYOND_ARRIVAL;\n this.changeDimension(\"beyond\", x + 0.5, y, z + 0.5);\n this._arrivalPlatform();\n if (!this.beyondState.initialized) this._initBeyond();\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"portalTravel\", { dimension: this.dimension });\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n this.beyondState.initialized = true;\n for (const p of beyondPillars(this.world.seed))\n this.entities.spawnMob(\"beyond_crystal\", p.x + 0.5, p.top + 2, p.z + 0.5);\n this.entities.spawnMob(\"void_dragon\", 0, 90, 0);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n const { x, y, z } = BEYOND_ARRIVAL;\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++) {\n this.world.setBlock(x + dx, y - 1, z + dz, B.obsidian!);\n for (let dy = 0; dy < 3; dy++)\n this.world.setBlock(x + dx, y + dy, z + dz, B.air!);\n }\n this.player.pos.set(x + 0.5, y, z + 0.5);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n this.world.setBlock(x, y - 1, z, B.bedrock!);\n this.world.setBlock(x, y, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 1, z, B.beyond_gateway!);\n this.world.setBlock(x, y + 2, z, B.bedrock!);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n const [x, y, z] = gatewaySpot(this.world.seed);\n this.warmup(x, z); // запись в незагруженный чанк пропала бы: победа бывает далеко от врат\n this._placeGateway(x, y, z);\n this.beyondState.gateway = [x, y, z];\n }\n\n /** Идущий набег на деревню (systems/Raid) или null. Не сохраняется: перезагрузка его прекращает. */\n raid: Raid | null = null;\n\n /**\n * Набег: игрок с Дурным знамением у заселённой деревни — знамение сгорает, начинается набег. Волны\n * приходят с одного края деревни (в 28–34 блоках от центра) и идут к центру; победа — «Герой\n * деревни» уровня знамения, поражение — все жители в 64 блоках мертвы или вышло время.\n */\n _tickRaid(dt: number) {\n if (this.dimension !== \"overworld\") return;\n const pl = this.player;\n if (!this.raid) {\n const omen = pl.effects.get(\"bad_omen\");\n if (!omen || pl.dead) return;\n const v = (\n this.world.generator.villagesNear?.(pl.pos.x, pl.pos.z, 48) ?? []\n ).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v) return;\n pl.effects.delete(\"bad_omen\");\n events.emit(\"effectsChanged\", { id: \"bad_omen\" });\n this.raid = new Raid({ x: v.x + 0.5, y: v.y, z: v.z + 0.5 }, omen.level);\n events.emit(\"raidStart\", { x: v.x, z: v.z });\n this.hud.message(\"Набег! Защитите деревню\", 3500);\n return;\n }\n const r = this.raid;\n const center = new THREE.Vector3(r.center.x, r.center.y, r.center.z);\n let villagers = 0;\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.trades &&\n e.pos.distanceTo(center) < 64\n )\n villagers++;\n const res = r.tick(\n dt,\n (id) => {\n const e = this.entities.entities.get(id);\n return e?.kind === \"mob\" && !e.dead;\n },\n villagers,\n );\n if (res.spawn) {\n const a = Math.random() * Math.PI * 2;\n for (const type of res.spawn) {\n if (!MOBS[type]) continue;\n const d = 28 + Math.random() * 6,\n aa = a + (Math.random() - 0.5) * 0.4;\n const x = Math.floor(center.x + Math.cos(aa) * d),\n z = Math.floor(center.z + Math.sin(aa) * d);\n this.warmup(x, z);\n const m = this.entities.spawnMob(\n type,\n x + 0.5,\n this.world.surfaceY(x, z) + 1.01,\n z + 0.5,\n );\n m.raider = true;\n m.goal = center.clone();\n r.add(m.id, m.def.hp);\n }\n this.hud.message(`Набег: волна ${r.wave} из ${RAID_WAVES}`, 2500);\n events.emit(\"raidWave\", { wave: r.wave });\n }\n if (res.end === \"won\") {\n pl.addEffect(\"hero\", 2400, r.level);\n events.emit(\"raidWon\", { level: r.level });\n this.hud.message(\n \"Набег отбит! Вы — герой деревни: жители уступают в цене\",\n 4500,\n );\n this.raid = null;\n } else if (res.end === \"lost\") {\n for (const id of r.raiders) {\n const e = this.entities.entities.get(id);\n if (e?.kind === \"mob\") {\n e.raider = false;\n e.goal = null;\n }\n }\n events.emit(\"raidLost\");\n this.hud.message(\"Набег: деревня пала\", 3500);\n this.raid = null;\n }\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n private _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n if (this.dimension !== \"beyond\" || this.player.dead) return;\n const g = this.beyondState.gateway;\n if (this.beyondState.dragonKilled && !g) this._openGateway();\n // врата не ломаются: записаны, чанк загружен, а их нет — значит, не встали (сейв до починки)\n else if (g && this.world.getBlockOrUnloaded(g[0], g[1], g[2]) === B.air)\n this._placeGateway(g[0], g[1], g[2]);\n const p = this.player.pos;\n let touching = false;\n for (let x = Math.floor(p.x - 0.5); x <= Math.floor(p.x + 0.5); x++)\n for (let y = Math.floor(p.y); y <= Math.floor(p.y + 1.9); y++)\n for (let z = Math.floor(p.z - 0.5); z <= Math.floor(p.z + 0.5); z++)\n if (this.world.getBlock(x, y, z) === B.beyond_gateway)\n touching = true;\n if (!touching) {\n this._gateLock = false;\n return;\n }\n if (this._gateLock) return;\n this._gateLock = true;\n const toOuter = Math.hypot(p.x, p.z) < OUTER_START / 2;\n if (!toOuter) {\n this.warmup(BEYOND_ARRIVAL.x, BEYOND_ARRIVAL.z);\n this._arrivalPlatform();\n } else {\n const a = Math.atan2(p.z, p.x);\n const tx = Math.round(Math.cos(a) * OUTER_LANDING),\n tz = Math.round(Math.sin(a) * OUTER_LANDING);\n this.warmup(tx, tz);\n const gen = this.world.generator;\n const land =\n gen instanceof BeyondGenerator ? gen.landingNear(tx, tz, 96) : null;\n const x = land?.x ?? tx,\n z = land?.z ?? tz;\n let y = land?.y ?? ISLAND_Y;\n if (land) {\n this.warmup(x, z);\n while (\n y < 120 &&\n (this.world.isSolid(x, y, z) || this.world.isSolid(x, y + 1, z))\n )\n y++;\n } else\n // островов рядом нет — площадка из камня Предела, как в Minecraft\n for (let dx = -2; dx <= 2; dx++)\n for (let dz = -2; dz <= 2; dz++)\n this.world.setBlock(x + dx, y - 1, z + dz, B.beyond_stone!);\n this._placeGateway(x + 3, y, z); // обратные врата у места прибытия\n this.player.pos.set(x + 0.5, y, z + 0.5);\n this.hud.message(\"Внешние острова Предела\", 2500);\n }\n this.player.vel.set(0, 0, 0);\n this.player._fallDist = 0;\n events.emit(\"gatewayTravel\", { outer: toOuter });\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n this.beyondState.dragonKilled = true;\n for (const [x, y, z] of exitPortalCells())\n this.world.setBlock(x, y, z, B.beyond_portal!);\n this.world.setBlock(0, ISLAND_Y + 4, 0, B.dragon_egg!);\n this._openGateway();\n this.hud.message(\n \"Дракон Предела повержен! Портал домой — в фонтане, Врата на внешние острова — за краем\",\n 4500,\n );\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n const target = this.world.generator.locate?.(\"citadel\", x, z);\n if (!target) {\n this.hud.message(\"Око не чувствует цитадели в этом мире\", 2200);\n if (this.player.mode !== \"creative\") this.inventory.add(\"rim_eye\", 1);\n return;\n }\n this.entities.spawnRimEye(new THREE.Vector3(x, y, z), target);\n }\n\n /** Сетевая игра: реестр порталов изменился здесь — разослать (net/NetGame). */\n onPortalChange:\n | ((dim: DimensionId, p: [number, number, number], add: boolean) => void)\n | null = null;\n\n /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\n if (this._addPortal(dim, x, y, z))\n this.onPortalChange?.(dim, [x, y, z], true);\n }\n\n private _addPortal(\n dim: DimensionId,\n x: number,\n y: number,\n z: number,\n ): boolean {\n const list = this.portals[dim];\n const near = list.some(\n ([px, py, pz]) =>\n Math.abs(px - x) <= 3 && Math.abs(py - y) <= 4 && Math.abs(pz - z) <= 3,\n );\n if (!near) list.push([x, y, z]);\n return !near;\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n this.portals[dim] = this.portals[dim].filter((q) => q !== p);\n this.onPortalChange?.(dim, [p[0] ?? 0, p[1] ?? 0, p[2] ?? 0], false);\n }\n\n /** Сетевая игра: портал зажгли или сломали у другого игрока. */\n applyNetPortal(dim: DimensionId, p: readonly number[], add: boolean): void {\n const [x = 0, y = 0, z = 0] = p;\n if (add) this._addPortal(dim, x, y, z);\n else\n this.portals[dim] = this.portals[dim].filter(\n (q) => q[0] !== x || q[1] !== y || q[2] !== z,\n );\n }\n\n /** Ближайший известный портал измерения в квадрате radius вокруг (x, z). */\n _nearestPortal(\n dim: DimensionId,\n x: number,\n z: number,\n radius: number,\n ): [number, number, number] | null {\n let best: [number, number, number] | null = null;\n let bestD = Infinity;\n for (const q of this.portals[dim]) {\n const dx = Math.abs(q[0] - x),\n dz = Math.abs(q[2] - z);\n if (dx > radius || dz > radius) continue;\n const d = dx * dx + dz * dz;\n if (d < bestD) {\n best = q;\n bestD = d;\n }\n }\n return best;\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n private anchorHome(): THREE.Vector3 | null {\n const a = this.player.anchorPos;\n const dim = this.player.anchorDim;\n if (!a || !dim || !isDimensionId(dim)) return null;\n const [x, y, z] = a;\n if (this.dimension !== dim)\n this.changeDimension(dim, x + 0.5, y + 1, z + 0.5);\n this.warmup(x, z);\n const charges =\n this.world.getBlock(x, y, z) === B.respawn_anchor\n ? anchorCharges(this.world.getMeta(x, y, z))\n : 0;\n if (charges <= 0) {\n this.player.anchorPos = null;\n this.player.anchorDim = null;\n this.hud.message(\"Якорь возрождения разряжен или сломан\", 2500);\n return null;\n }\n this.world.setBlock(x, y, z, B.respawn_anchor!, charges - 1);\n // рядом с якорем, а не в нём: клетка с полом и местом на рост — не лава и не обрыв (в Пекле\n // «не твёрдая» соседняя клетка часто оказывалась озером лавы или пустотой над ним)\n for (const dy of [0, 1, -1])\n for (const [dx, dz] of AROUND) {\n const nx = x + dx,\n ny = y + dy,\n nz = z + dz;\n if (isStandable(this.world, nx, ny, nz))\n return new THREE.Vector3(nx + 0.5, ny, nz + 0.5);\n }\n return new THREE.Vector3(x + 0.5, y + 1, z + 0.5);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n if (DIMENSIONS[this.dimension].bedsExplode) {\n // в Пекле и Пределе кровать взрывается, как в Minecraft\n this.world.setBlock(x, y, z, B.air!);\n this.entities.explode(x + 0.5, y + 0.5, z + 0.5, 5);\n return;\n }\n this.player.bedPos = [x, y, z];\n if (this.dayNight.isDay && this.weather.kind !== \"thunder\") {\n this.hud.message(\n \"Спать можно только ночью или в грозу. Точка возрождения — здесь\",\n 2400,\n );\n return;\n }\n const monsters = [...this.entities.entities.values()].some(\n (e) =>\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.category === \"hostile\" &&\n e.pos.distanceTo(this.player.pos) < 8,\n );\n if (monsters) {\n this.hud.message(\"Нельзя спать — рядом монстры\", 2000);\n return;\n }\n // сетевая игра: ночь пропускает хост, когда спят ВСЕ игроки Верхнего мира (net/NetGame)\n if (this.net) {\n this.netSleeping = true;\n this.hud.message(\"Вы спите. Ночь пройдёт, когда уснут все игроки\", 2600);\n return;\n }\n this.skipNight();\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n if (this.inventory.heldItem()?.item !== \"map\") return;\n const p = this.player.pos;\n const m = newMap(this.nextMapId++, p.x, p.z, this.dimension);\n this.maps.set(m.id, m);\n for (let ph = 0; ph < 4; ph++) surveyMap(this.world, m, p.x, p.z, 48, ph);\n const filled = { item: \"filled_map\", count: 1, tag: { map: m.id } };\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n const i = this.inventory.hotbarIndex;\n if (!this.inventory.slots[i]) this.inventory.setSlot(i, filled);\n else if (this.inventory.addStack(filled) > 0)\n this.entities.spawnDrop(p.x, p.y + 1, p.z, \"filled_map\", 1);\n events.emit(\"mapCreated\", { id: m.id });\n }\n\n _trialScanT = 0;\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n const p = this.player;\n if ((this._trialScanT -= dt) <= 0) {\n this._trialScanT = 1;\n for (const [x, y, z] of this.world.generator.trialSpawnersNear?.(\n p.pos.x,\n p.pos.z,\n 48,\n ) ?? [])\n if (this.world.getBlockOrUnloaded(x, y, z) === B.trial_spawner)\n this.blockEntities.ensure(x, y, z);\n }\n const hunter = !p.dead && p.mode !== \"creative\";\n for (const be of this.blockEntities.map.values()) {\n if (be.type !== \"trial_spawner\") continue;\n const mob =\n TRIAL_MOBS[this.world.getMeta(be.x, be.y, be.z) % TRIAL_MOBS.length]!;\n trialStep(be.state as TrialState, {\n dt,\n near:\n hunter &&\n Math.hypot(\n p.pos.x - be.x - 0.5,\n p.pos.y - be.y,\n p.pos.z - be.z - 0.5,\n ) <= TRIAL_RANGE,\n rng: Math.random,\n spawn: () => this._trialSpawn(be.x, be.y, be.z, mob),\n reward: (items) => {\n for (const s of items)\n this.entities.spawnDrop(\n be.x + 0.5,\n be.y + 1.2,\n be.z + 0.5,\n s.item,\n s.count,\n );\n events.emit(\"trialReward\", { x: be.x, y: be.y, z: be.z });\n },\n });\n }\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n for (let i = 0; i < 10; i++) {\n const sx = x + Math.floor(Math.random() * 9) - 4,\n sz = z + Math.floor(Math.random() * 9) - 4;\n for (let sy = y - 1; sy <= y + 2; sy++) {\n if (!BLOCKS[this.world.getBlock(sx, sy - 1, sz)]?.solid) continue;\n if (\n this.world.getBlock(sx, sy, sz) !== 0 ||\n this.world.getBlock(sx, sy + 1, sz) !== 0\n )\n continue;\n const m = this.entities.spawnMob(mob, sx + 0.5, sy, sz + 0.5);\n m.trialFrom = [x, y, z];\n events.emit(\"trialSpawn\", { x, y, z, mob });\n return true;\n }\n }\n return false;\n }\n\n freezing = new Freezing();\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n const p = this.player;\n const armor = this.inventory.armor;\n p.leatherBoots = () => armor.some((s) => s?.item === \"leather_boots\");\n p.goldArmor = () => armor.some((s) => !!s?.item.startsWith(\"golden_\"));\n // щит принял удар — теряет прочность по урону (как в Minecraft), не меньше 1\n p.onShieldBlock = (n) => {\n if (this.inventory.heldItem()?.item === \"shield\")\n this.inventory.damageHeld(Math.max(1, Math.floor(n)));\n };\n const leather = armor.some((s) => !!s?.item.startsWith(\"leather_\"));\n const dmg = this.freezing.tick(\n dt,\n p.inPowderSnow && !p.dead,\n leather || p.mode === \"creative\",\n p.fireT > 0,\n );\n if (dmg) p.damage(dmg, \"freeze\");\n this.hud.frost(this.freezing.level);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n this._hornCd = Math.max(0, this._hornCd - dt);\n const press =\n this.input.rightDown &&\n !this.player.dead &&\n this.inventory.heldItem()?.item === \"goat_horn\";\n if (press && !this._hornHeld && this._hornCd <= 0) {\n this._hornCd = 7;\n events.emit(\"goatHorn\");\n const p = this.player.pos;\n if (this.dimension === \"overworld\")\n this.sculk.vibrate(p.x, p.y, p.z, this.player.mode !== \"creative\");\n }\n this._hornHeld = press;\n }\n\n /** Скалк, вибрации и Страж (systems/Sculk.ts) — в верхнем мире. */\n sculk = new Sculk({\n world: () => this.world,\n player: () => this.player,\n wardens: () =>\n [...this.entities.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\" && !e.dead && !!e.def.warden,\n ),\n summon: (x, y, z) => {\n const m = this.entities.spawnMob(\"warden\", x, y, z);\n m.emergeT = EMERGE_TIME;\n m.invulnerable = true;\n return m;\n },\n });\n /** Пройдено шагов с последней вибрации (вибрация — каждые 2 блока ходьбы без приседа). */\n _stepDist = 0;\n /** Где игрок умер последний раз — туда указывает компас возвращения (в сейв не пишется). */\n lastDeath: { x: number; y: number; z: number; dim: string } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n if (this.dimension !== \"overworld\") return;\n this.sculk.tick(dt);\n const p = this.player;\n if (p.dead || p.flying || !p.onGround || p._sneaking) return;\n this._stepDist += Math.hypot(p.vel.x, p.vel.z) * dt;\n if (this._stepDist < 2) return;\n this._stepDist = 0;\n const x = Math.floor(p.pos.x),\n y = Math.floor(p.pos.y),\n z = Math.floor(p.pos.z);\n if (this.world.getBlock(x, y - 1, z) === B.sculk_shrieker)\n this.sculk.shriek(x, y - 1, z);\n this.sculk.vibrate(p.pos.x, p.pos.y, p.pos.z, p.mode !== \"creative\");\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n const on =\n !this.player.dead &&\n this.input.rightDown &&\n this.inventory.heldItem()?.item === \"spyglass\";\n const want = on ? 10 : 75;\n if (this.camera.fov === want) return;\n this.camera.fov += (want - this.camera.fov) * Math.min(1, dt * 12);\n if (Math.abs(this.camera.fov - want) < 0.1) this.camera.fov = want;\n this.camera.updateProjectionMatrix();\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n const h = this.inventory.heldItem();\n // карта сокровищ или исследователя в руке становится картой участка с крестиком над целью:\n // из тега {x, z}, а без него (карта картографа) — ближайшая к игроку цель такого вида\n const kind = h ? EXPLORER_MAPS[h.item] : undefined;\n if (h && kind) {\n let tx = Number(h.tag?.x),\n tz = Number(h.tag?.z);\n if (!Number.isFinite(tx) || !Number.isFinite(tz)) {\n if (this.dimension !== \"overworld\") return;\n const f = this.world.generator.locate?.(\n kind,\n this.player.pos.x,\n this.player.pos.z,\n );\n if (!f) return;\n tx = f.x;\n tz = f.z;\n }\n const m = newMap(this.nextMapId++, tx, tz, \"overworld\");\n // карта с целью — по центру цели, а не по сетке карт: иначе ✖ бывает у самого края\n m.x0 = Math.floor(tx) - MAP_SIZE / 2;\n m.z0 = Math.floor(tz) - MAP_SIZE / 2;\n m.target = [tx, tz];\n this.maps.set(m.id, m);\n this.inventory.setSlot(this.inventory.hotbarIndex, {\n item: \"filled_map\",\n count: 1,\n tag: { map: m.id },\n });\n events.emit(\"mapCreated\", { id: m.id, treasure: true });\n return;\n }\n if (h?.item !== \"filled_map\") return;\n const m = this.maps.get(Number(h.tag?.map));\n if (!m || m.dim !== this.dimension) return;\n const p = this.player.pos;\n surveyMap(this.world, m, p.x, p.z, 48, this._mapPhase++ & 3);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n if (this.dimension !== \"overworld\" || this.raid) return;\n const p = this.player.pos;\n const v = (this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []).find(\n (x) =>\n this.villagesSettled.has(x.id) &&\n x.residents.some((r) => r.type.startsWith(\"villager_\")),\n );\n if (!v || !siegeTonight()) return;\n const angle = Math.random() * Math.PI * 2;\n let n = 0;\n for (let i = siegeCount(); i > 0; i--) {\n const { x, z } = siegeSpot(v.x + 0.5, v.z + 0.5, angle);\n this.warmup(x, z);\n const y = this.world.surfaceY(x, z);\n if (y <= 0) continue;\n const m = this.entities.spawnMob(\"zombie\", x + 0.5, y + 1.01, z + 0.5);\n // цель — центр деревни: без неё отряд разбредается, не дойдя до жителей (MobBrain, huntsLand)\n m.goal = new THREE.Vector3(v.x + 0.5, v.y, v.z + 0.5);\n n++;\n }\n if (n === 0) return;\n events.emit(\"siegeStart\", { x: v.x, z: v.z, count: n });\n this.hud.message(\"Ночная осада! Зомби идут к деревне\", 3500);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n // деревни верхнего мира и города Предела (панцирники) — один путь: villagesNear генератора\n const p = this.player.pos;\n for (const v of this.world.generator.villagesNear?.(p.x, p.z, 72) ?? []) {\n if (this.villagesSettled.has(v.id)) continue;\n const loaded = v.residents.every(\n (r) =>\n this.world.getBlockOrUnloaded(\n Math.floor(r.x),\n Math.floor(r.y),\n Math.floor(r.z),\n ) !== -1,\n );\n if (!loaded) continue;\n for (const r of v.residents)\n if (MOBS[r.type]) {\n const m = this.entities.spawnMob(r.type, r.x, r.y + 0.01, r.z);\n if (r.captain) m.captain = true;\n }\n this.villagesSettled.add(v.id);\n }\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n const near = (p: THREE.Vector3) =>\n Math.hypot(p.x - x, p.z - z) < 3 && Math.abs(p.y - y) < 4;\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && near(e.pos)) e.damage(5);\n if (near(this.player.pos)) this.player.damage(5, \"lightning\");\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n // сетевая игра не встаёт на паузе: мир общий, и в меню игрок остаётся в нём (как в Minecraft)\n if (!this.playing && !this.net) return;\n // Кто что считает (net/sim): своё — игрок, его действия, голод, опасности — считает каждый;\n // мир измерения (мобы, жидкости, блоки, машины) — только владелец измерения; погоду и\n // время суток — хост (остальным их присылают, время между присылками идёт само).\n const own = this.simulating;\n const host = !this.net || this.net.isHost;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n if (host)\n this.weather.tick(\n dt,\n sky && own ? this.world : null,\n this.player.pos.x,\n this.player.pos.z,\n );\n this.world.raining = sky && this.weather.raining;\n this.world.containerAt = this._containerAt;\n this.entities.raining = this.world.raining;\n this.entities.skyDarken = Math.max(\n this.dayNight.skyDarken,\n sky ? this.weather.darken : 0,\n );\n this.entities.isDay = this.dayNight.isDay;\n this.entities.tick(dt); // у реплики — только сглаживание к снимкам владельца\n if (own) this.fluids.tick();\n this.player.tickHazards(dt);\n // черепаший панцирь: над водой копит 10 с Подводного дыхания — под водой их хватает на нырок\n const wb = this.player.effects.get(\"water_breathing\");\n if (\n this.inventory.armor[0]?.item === \"turtle_helmet\" &&\n !this.player.headInWater &&\n (!wb || wb.time < 9.5)\n )\n this.player.addEffect(\"water_breathing\", 10);\n this._tickPortal(dt);\n this._tickGateway();\n if (own) this._tickRaid(dt);\n this._spyglass(dt);\n if (own) this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n if (own) {\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n }\n this.dayNight.tick(dt); // у гостя — между поправками хоста (net WORLD)\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n if (own)\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n if (host) this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n this.netSleeping = false;\n } else if (!day && this._wasDay && own) this._siege(); // стемнело — может начаться осада деревни\n this.entities.insomnia = this.daysAwake >= 3;\n this._wasDay = day;\n this._villageT += dt;\n if (this._villageT >= 1) {\n this._villageT = 0;\n if (own) this._settleVillages();\n }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\n }\n events.emit(\"tick\", { n });\n this.net?.afterTick();\n this._autosaveT += dt;\n if (this._autosaveT >= AUTOSAVE_EVERY) {\n this._autosaveT = 0;\n void this.persist();\n }\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.net?.frame(dt);\n this.beams.update(this.blockEntities, this._time);\n this.displays.update(this.blockEntities, this.world);\n this.signs.update(this.blockEntities, this.world);\n this.banners.update(this.blockEntities, this.world);\n this.armorStands.update(this.blockEntities);\n this.fireworks.update(dt);\n this.dayNight.frame(\n this.player.pos,\n this.player.headInLava\n ? \"lava\"\n : this.player.headInWater\n ? \"water\"\n : this.player.headInPowderSnow\n ? \"powder_snow\"\n : \"air\",\n this.dimension,\n this.player.effects.has(\"night_vision\"),\n DIMENSIONS[this.dimension].hasSky ? this.weather : undefined,\n );\n // Тьма пульсирует: то наползает, то чуть отступает\n if (this.player.effects.has(\"darkness\"))\n this.dayNight.darkness(\n 0.75 + 0.25 * Math.abs(Math.sin(this._time * 1.3)),\n );\n this.weather.frame(\n dt,\n eye,\n DIMENSIONS[this.dimension].hasSky ? this.world : null,\n );\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.invUI.frame();\n this.renderPasses();\n }\n\n /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n this.renderer.shadowMap.needsUpdate = true; // тени — только в мировом проходе\n this.renderer.render(this.scene, this.camera);\n this.renderer.autoClear = false;\n this.renderer.clearDepth();\n this.camera.layers.set(ARM_LAYER);\n this.renderer.render(this.scene, this.camera);\n this.camera.layers.set(0);\n this.renderer.autoClear = true;\n }\n\n /**\n * Снимок экрана для кнопки «поделиться» на странице игры.\n *\n * ⚠ Рисует и читает В ОДИН приём. Рендерер создан без `preserveDrawingBuffer` (и так и надо —\n * флаг стоит памяти и копии каждый кадр), поэтому к моменту, когда до канвы доберётся кто-то\n * ещё, буфер уже очищен: `toBlob` вернёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись.\n */\n async capture(): Promise<Blob | null> {\n if (this._destroyed) return null;\n this.renderPasses();\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n // сетевая игра: уйти из комнаты (хост передаёт мир преемнику) — без возврата в свой мир, игра разбирается\n const net = this.net;\n if (net) {\n net.prepareExit();\n net.dispose();\n void net.session.leave();\n }\n // последний снимок уходит до разбора мира; дописывается транзакция уже без нас\n void this.persist().finally(() => this.store?.close());\n this.net = null;\n this._destroyed = true;\n this.worldUI.hide();\n this.loop.stop();\n this.input.releaseLock();\n // Шина `events` и DOM-подписки живут дольше игры: хост пересоздаёт Game после перелогина, и\n // без этого старый экземпляр продолжал бы слушать — `E` эмитился дважды (новый инвентарь\n // открывался и тут же закрывался), звуки двоились, весь старый мир держался в памяти.\n this._abort.abort();\n events.clear();\n this.hud.dispose();\n this.netUI.dispose();\n this.chat.dispose();\n this.signEditor.dispose();\n this.bookScreen.dispose();\n this.nameEditor.dispose();\n this.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n this.input.enabled = true;\n if (!this.loop.running) this.loop.start();\n } else {\n // сетевая игра: мир общий — у хоста и владельцев измерений он не может встать оттого, что\n // игрок открыл другой режим; останавливается только ввод\n if (!this.net) this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
|
|
640
645
|
},
|
|
641
646
|
{
|
|
642
647
|
"path": "module.ts",
|
|
643
|
-
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelcraft } from \"./scene\";\nimport { createClientWorldAI } from \"./ai/worldAi\";\n\n// The VoxelCraft feature module. It contributes ONLY an engine scene and a nav entry — no React UI\n// panels (the game draws its own DOM HUD). This exercises the scene-only branch of the contract and\n// proves a module need not use React.\n//\n// The SDK is used for one thing only: client.ai — building a world from a picture + description\n// (feature \"voxel-world\" of the title). Without that feature the \"Create world\" screen simply\n// offers the local, AI-free path.\nexport const voxelcraftModule: Module = defineModule({\n id: \"voxelcraft\",\n meta: {\n name: \"VoxelCraft\",\n type: \"game\",\n genre: \"sandbox\",\n engine: \"three\",\n },\n setup(ctx) {\n // setup() runs after login, so the client already has a session for client.ai.\n const { scene, agent, content } = createVoxelcraft({\n ai: createClientWorldAI(ctx.client),\n });\n ctx.registerScene(scene);\n ctx.registerRoute({ id: \"voxelcraft\", label: \"Voxel\", icon: \"🧊\" });\n // Миры в Мастерской: модуль-браузер показывает, публикует и открывает их через этот обработчик,\n // не зная ничего про сейвы. Без модуля workshop в проекте регистрация просто ни на что не влияет.\n ctx.content.registerType(content);\n // Глаза и руки агента: у canvas-игры DOM пустой, без этого она для него невидима.\n ctx.exposeToAgent(agent);\n },\n});\n"
|
|
648
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelcraft } from \"./scene\";\nimport { createClientWorldAI } from \"./ai/worldAi\";\n\n// The VoxelCraft feature module. It contributes ONLY an engine scene and a nav entry — no React UI\n// panels (the game draws its own DOM HUD). This exercises the scene-only branch of the contract and\n// proves a module need not use React.\n//\n// The SDK is used for one thing only: client.ai — building a world from a picture + description\n// (feature \"voxel-world\" of the title). Without that feature the \"Create world\" screen simply\n// offers the local, AI-free path.\nexport const voxelcraftModule: Module = defineModule({\n id: \"voxelcraft\",\n meta: {\n name: \"VoxelCraft\",\n type: \"game\",\n genre: \"sandbox\",\n engine: \"three\",\n },\n setup(ctx) {\n // setup() runs after login, so the client already has a session for client.ai.\n const { scene, agent, content } = createVoxelcraft({\n ai: createClientWorldAI(ctx.client),\n // сетевая игра: комнаты и WebRTC-сигналинг движка (client.multiplayer); выключена у тайтла —\n // экран «Сетевая игра» так и скажет\n netClient: ctx.client,\n });\n ctx.registerScene(scene);\n ctx.registerRoute({ id: \"voxelcraft\", label: \"Voxel\", icon: \"🧊\" });\n // Миры в Мастерской: модуль-браузер показывает, публикует и открывает их через этот обработчик,\n // не зная ничего про сейвы. Без модуля workshop в проекте регистрация просто ни на что не влияет.\n ctx.content.registerType(content);\n // Глаза и руки агента: у canvas-игры DOM пустой, без этого она для него невидима.\n ctx.exposeToAgent(agent);\n },\n});\n"
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
"path": "net/codec.ts",
|
|
652
|
+
"content": "// Байтовый кодек сообщений мультиплеера. Частые сообщения (позы игроков и мобов, изменения блоков)\n// — плотные двоичные записи: позы идут 10–20 раз в секунду на каждого, JSON там был бы в 4–6 раз\n// толще. Редкие и сложные (снимок мира, сущности, контейнеры, чат) — JSON, крупный — сжатый.\n//\n// Формат любого сообщения: [тип u8][тело…]. У JSON-сообщений тело — [флаги u8][UTF-8 JSON],\n// флаг 1 — тело сжато deflate-raw (снимок мира с тысячами изменённых блоков сжимается в 5–10 раз).\n\nexport class ByteWriter {\n private buf: Uint8Array;\n private view: DataView;\n private at = 0;\n\n constructor(initial = 256) {\n this.buf = new Uint8Array(initial);\n this.view = new DataView(this.buf.buffer);\n }\n\n private ensure(n: number): void {\n if (this.at + n <= this.buf.length) return;\n let size = this.buf.length * 2;\n while (size < this.at + n) size *= 2;\n const next = new Uint8Array(size);\n next.set(this.buf.subarray(0, this.at));\n this.buf = next;\n this.view = new DataView(next.buffer);\n }\n\n u8(v: number): this {\n this.ensure(1);\n this.view.setUint8(this.at, v);\n this.at += 1;\n return this;\n }\n u16(v: number): this {\n this.ensure(2);\n this.view.setUint16(this.at, v);\n this.at += 2;\n return this;\n }\n i16(v: number): this {\n this.ensure(2);\n this.view.setInt16(this.at, v);\n this.at += 2;\n return this;\n }\n u32(v: number): this {\n this.ensure(4);\n this.view.setUint32(this.at, v >>> 0);\n this.at += 4;\n return this;\n }\n i32(v: number): this {\n this.ensure(4);\n this.view.setInt32(this.at, v | 0);\n this.at += 4;\n return this;\n }\n f32(v: number): this {\n this.ensure(4);\n this.view.setFloat32(this.at, v);\n this.at += 4;\n return this;\n }\n bytes(b: Uint8Array): this {\n this.ensure(b.byteLength);\n this.buf.set(b, this.at);\n this.at += b.byteLength;\n return this;\n }\n /** Угол (радианы) в i16: шаг ~0.0001 рад — глазу неотличимо. */\n angle(rad: number): this {\n let a = rad % (Math.PI * 2);\n if (a > Math.PI) a -= Math.PI * 2;\n if (a < -Math.PI) a += Math.PI * 2;\n return this.i16(Math.round(a * 10_000));\n }\n\n get length(): number {\n return this.at;\n }\n\n finish(): Uint8Array {\n return this.buf.slice(0, this.at);\n }\n}\n\nexport class ByteReader {\n private readonly view: DataView;\n private at = 0;\n\n constructor(private readonly buf: Uint8Array) {\n this.view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);\n }\n\n get left(): number {\n return this.buf.byteLength - this.at;\n }\n\n private need(n: number): void {\n if (this.at + n > this.buf.byteLength)\n throw new RangeError(\"Truncated message\");\n }\n\n u8(): number {\n this.need(1);\n return this.view.getUint8(this.at++);\n }\n u16(): number {\n this.need(2);\n const v = this.view.getUint16(this.at);\n this.at += 2;\n return v;\n }\n i16(): number {\n this.need(2);\n const v = this.view.getInt16(this.at);\n this.at += 2;\n return v;\n }\n u32(): number {\n this.need(4);\n const v = this.view.getUint32(this.at);\n this.at += 4;\n return v;\n }\n i32(): number {\n this.need(4);\n const v = this.view.getInt32(this.at);\n this.at += 4;\n return v;\n }\n f32(): number {\n this.need(4);\n const v = this.view.getFloat32(this.at);\n this.at += 4;\n return v;\n }\n angle(): number {\n return this.i16() / 10_000;\n }\n rest(): Uint8Array {\n const out = this.buf.subarray(this.at);\n this.at = this.buf.byteLength;\n return out;\n }\n}\n\nconst enc = new TextEncoder();\nconst dec = new TextDecoder();\n\n/** Порог сжатия JSON-тела: мелкие сообщения (чат, поза контейнера) сжимать дороже, чем слать. */\nconst COMPRESS_OVER = 2048;\n\nasync function pipe(\n bytes: Uint8Array,\n stream: CompressionStream | DecompressionStream,\n): Promise<Uint8Array> {\n const out = new Blob([bytes as Uint8Array<ArrayBuffer>])\n .stream()\n .pipeThrough(stream);\n return new Uint8Array(await new Response(out).arrayBuffer());\n}\n\nconst canCompress = typeof CompressionStream !== \"undefined\";\n\n/** JSON-сообщение: [тип][флаги][тело]. Большое тело сжимается, если среда умеет. */\nexport async function encodeJson(\n type: number,\n value: unknown,\n): Promise<Uint8Array> {\n let body: Uint8Array = enc.encode(JSON.stringify(value));\n let flags = 0;\n if (canCompress && body.byteLength > COMPRESS_OVER) {\n body = await pipe(body, new CompressionStream(\"deflate-raw\"));\n flags |= 1;\n }\n const out = new Uint8Array(body.byteLength + 2);\n out[0] = type;\n out[1] = flags;\n out.set(body, 2);\n return out;\n}\n\n/** То же без сжатия и синхронно — для мелких частых сообщений. */\nexport function encodeJsonSync(type: number, value: unknown): Uint8Array {\n const body = enc.encode(JSON.stringify(value));\n const out = new Uint8Array(body.byteLength + 2);\n out[0] = type;\n out[1] = 0;\n out.set(body, 2);\n return out;\n}\n\n/** Тело JSON-сообщения (без байта типа). null — битое. */\nexport async function decodeJson<T>(msg: Uint8Array): Promise<T | null> {\n try {\n const flags = msg[1] ?? 0;\n let body = msg.subarray(2);\n if (flags & 1)\n body = await pipe(body, new DecompressionStream(\"deflate-raw\"));\n return JSON.parse(dec.decode(body)) as T;\n } catch {\n return null;\n }\n}\n"
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
"path": "net/DiffStore.ts",
|
|
656
|
+
"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"
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
"path": "net/entities.test.ts",
|
|
660
|
+
"content": "// Сущности в сетевой игре: владелец измерения ↔ реплика без сети — снимки и действия передаются\n// руками, как их передал бы NetGame. Проверяется то, что раньше у реплики было только локально:\n// правки мобов действием игрока, гибель образа, езда, поводок у конкретного игрока.\nimport * as THREE from \"three\";\nimport { describe, expect, it } from \"vitest\";\nimport { B } from \"../registry/Blocks\";\nimport { Chunk } from \"../world/Chunk\";\nimport { ChunkManager } from \"../world/ChunkManager\";\nimport { Player } from \"../player/Player\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport { EntityManager, type NetEntityAction } from \"../entities/EntityManager\";\nimport type { Mob } from \"../entities/Mob\";\n\nfunction world(): ChunkManager {\n const w = new ChunkManager(new THREE.Scene(), {} as TextureAtlas, {\n kind: \"noise\",\n seed: 1,\n });\n for (let cx = -2; cx <= 2; cx++)\n for (let cz = -2; cz <= 2; cz++) {\n const c = new Chunk(cx, cz);\n c.generated = true;\n for (let x = 0; x < 16; x++)\n for (let z = 0; z < 16; z++) c.setRaw(x, 4, z, B.grass!);\n w.chunks.set(cx + \",\" + cz, c);\n }\n return w;\n}\n\nconst inv = { heldItem: () => null } as unknown as Inventory;\n\nfunction manager(): EntityManager {\n const w = world();\n const p = new Player(w);\n p.pos.set(8, 5, 8);\n return new EntityManager(new THREE.Scene(), w, {} as TextureAtlas, p, inv);\n}\n\n/** Владелец с мобом и реплика с его образом (тот же id). */\nfunction pair(type: string) {\n const owner = manager();\n owner.localSlot = 0;\n const mob = owner.spawnMob(type, 9, 5, 9)!;\n const replica = manager();\n replica.replica = true;\n replica.localSlot = 1;\n const sent: NetEntityAction[] = [];\n replica.onRemoteAction = (a) => sent.push(a);\n replica.netApplySpawn(mob.id, owner.netSnapshot(mob)!);\n const image = replica.entities.get(mob.id) as Mob;\n /** Переслать владельцу всё, что реплика натворила за тик (как NetGame.afterTick). */\n const flush = () => {\n const out = [...sent.splice(0), ...replica.netLocalChanges()];\n for (const a of out) {\n if (a.a === \"patch\") owner.netPatch(a.id, a.fields, 1);\n else if (a.a === \"kill\") owner.netKill(a.id, a.drops);\n }\n return out;\n };\n return { owner, replica, mob, image, flush };\n}\n\ndescribe(\"реплика меняет сущности владельца\", () => {\n it(\"остриг образ — остригли и у владельца; повторно не шлётся\", () => {\n const { mob, image, flush } = pair(\"sheep\");\n image.sheared = true;\n const out = flush();\n expect(out).toEqual([\n { a: \"patch\", id: mob.id, fields: { sheared: true } },\n ]);\n expect(mob.sheared).toBe(true);\n expect(flush()).toEqual([]);\n });\n\n it(\"поза владельца не выглядит правкой реплики, а правку из экрана между тиками не затирает\", () => {\n const { owner, replica, mob, image, flush } = pair(\"villager\");\n // владелец посадил/оседлал — поза несёт это флагами: у реплики изменений нет\n mob.sitting = true;\n const pose = owner.netPose(mob);\n replica.netApplyPose(\n mob.id,\n mob.pos.x,\n mob.pos.y,\n mob.pos.z,\n pose.yaw,\n pose.flags,\n pose.extra,\n );\n expect(image.sitting).toBe(true);\n expect(flush()).toEqual([]);\n // торговля в экране (между тиками): приходит поза — правка всё равно уходит владельцу\n image.uses = [1, 0, 2];\n image.traded = true;\n replica.netApplyPose(\n mob.id,\n mob.pos.x + 0.1,\n mob.pos.y,\n mob.pos.z,\n pose.yaw,\n pose.flags,\n pose.extra,\n );\n const out = flush();\n expect(out[0]).toMatchObject({\n a: \"patch\",\n fields: { uses: [1, 0, 2], traded: true },\n });\n expect(mob.uses).toEqual([1, 0, 2]);\n expect(mob.traded).toBe(true);\n });\n\n it(\"накормил — влюбляется у владельца; «любовь» у образа гаснет сама и повторно не шлётся\", () => {\n const { owner, replica, mob, image, flush } = pair(\"cow\");\n image.loveT = 30;\n flush();\n expect(mob.loveT).toBe(30);\n for (let i = 0; i < 700; i++) replica.tick(0.05); // 35 с\n expect(image.loveT).toBe(0);\n expect(flush()).toEqual([]);\n // владелец сам решает: зверю в откате размножения любовь не включается\n mob.loveT = 0;\n mob.breedCd = 100;\n owner.netPatch(mob.id, { love: true }, 1);\n expect(mob.loveT).toBe(0);\n });\n\n it(\"/kill у реплики — смерть с дропом у владельца; исчезновение (рыба в ведро) — без\", () => {\n const a = pair(\"pig\");\n a.image.damage(a.image.hp);\n a.replica.tick(0.05);\n expect(a.flush()).toEqual([{ a: \"kill\", id: a.mob.id, drops: true }]);\n expect(a.mob.dead).toBe(true);\n expect(a.mob.hp).toBeLessThanOrEqual(0);\n\n const b = pair(\"cod\");\n b.image.dead = true; // hp > 0\n b.replica.tick(0.05);\n expect(b.flush()).toEqual([{ a: \"kill\", id: b.mob.id, drops: false }]);\n expect(b.mob.dead).toBe(true);\n expect(b.mob.hp).toBeGreaterThan(0);\n });\n\n it(\"удаление сущности владельцем не отражается ему обратно «убийством»\", () => {\n const { replica, mob, flush } = pair(\"pig\");\n replica.netRemove(mob.id);\n replica.tick(0.05);\n expect(flush()).toEqual([]);\n });\n\n it(\"поводок держит тот, кто привязал: владелец ведёт моба к нему, а не к ближайшему\", () => {\n const { owner, mob, image, flush } = pair(\"cow\");\n image.leashed = true;\n image.leashSlot = 1;\n flush();\n expect(mob.leashed).toBe(true);\n expect(mob.leashSlot).toBe(1);\n const far = new Player(owner.world) as Player & { slot: number };\n far.slot = 1;\n far.pos.set(15, 5, 15);\n owner.others = () => [{ player: far, inventory: inv }];\n const actorFor = (\n owner as unknown as { _actorFor(e: Mob): { player: Player } }\n )._actorFor.bind(owner);\n expect(actorFor(mob).player).toBe(far); // свой игрок (8,5,8) ближе — но поводок у слота 1\n // отвязал — поводок ничей\n image.leashed = false;\n image.leashSlot = null;\n flush();\n expect(mob.leashed).toBe(false);\n expect(mob.leashSlot).toBeNull();\n });\n\n it(\"правка с мусором не ломает моба\", () => {\n const { mob, owner } = pair(\"donkey\");\n owner.netPatch(\n mob.id,\n {\n items: [{ item: \"no_such_item\", count: 5 }],\n wolfArmor: -3,\n carry: 42,\n name: 7,\n uses: \"x\",\n },\n 1,\n );\n expect(mob.items).toEqual([]);\n expect(mob.wolfArmor).toBe(0);\n expect(mob.carry).toBeNull();\n expect(mob.customName).toBeNull();\n });\n});\n\ndescribe(\"езда у реплики\", () => {\n it(\"владелец отдаёт управление одному седоку и не тикает сущность; чужие позы — мимо\", () => {\n const { owner, mob } = pair(\"horse\");\n expect(owner.netRide(mob.id, 1, true)).toBe(true);\n expect(owner.netRide(mob.id, 2, true)).toBe(false); // место занято\n expect(mob.rider).toBe(true);\n expect(owner.netPose(mob).flags & 128).toBe(128);\n\n owner.netDrive(mob.id, 1, [12, 5, 12], [1, 0, 0], 1.5);\n expect(mob.pos.toArray()).toEqual([12, 5, 12]);\n owner.netDrive(mob.id, 2, [0, 5, 0], [0, 0, 0], 0); // не его\n expect(mob.pos.toArray()).toEqual([12, 5, 12]);\n for (let i = 0; i < 10; i++) owner.tick(0.05); // не тикает: позу держит седок\n expect(mob.pos.toArray()).toEqual([12, 5, 12]);\n\n owner.netReleaseDriver(1); // седок ушёл\n expect(mob.rider).toBe(false);\n expect(owner.netRide(mob.id, 2, true)).toBe(true);\n });\n\n it(\"реплика ведёт сущность сама: позы и новые снимки владельца её не трогают\", () => {\n const { owner, replica, mob, image } = pair(\"horse\");\n replica.drivenId = mob.id;\n image.pos.set(14, 5, 14);\n replica.netApplyPose(mob.id, 1, 5, 1, 0, 0, 0);\n replica.tick(0.05);\n expect(image.pos.x).toBeCloseTo(14, 0);\n replica.netApplySpawn(mob.id, owner.netSnapshot(mob)!);\n expect(replica.entities.get(mob.id)).toBe(image); // Player.riding держит этот объект\n });\n\n it(\"чужой седок виден образу: сесть на занятого нельзя (Interaction смотрит rider)\", () => {\n const { owner, replica, mob, image } = pair(\"horse\");\n owner.netRide(mob.id, 2, true);\n const pose = owner.netPose(mob);\n replica.netApplyPose(\n mob.id,\n mob.pos.x,\n mob.pos.y,\n mob.pos.z,\n pose.yaw,\n pose.flags,\n pose.extra,\n );\n expect(image.rider).toBe(true);\n });\n});\n"
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
"path": "net/NetGame.ts",
|
|
664
|
+
"content": "// Сетевая игра VoxelCraft: связывает игру (Game) с `RealtimeSession` (@idosgames/core/realtime).\n//\n// Топология — звезда (комнаты `Host`): каждый гость связан только с хостом, хост пересылает. Роли и\n// протокол — net/protocol.ts. Кто считает мир — net/sim.ts: у каждого «живого» измерения один\n// владелец (первый вошедший; хост не главнее других), остальные в нём — реплики.\n//\n// Игрок и инвентарь — у каждого свои (вариант А, 27.09.2026): здесь по сети ходят только мир,\n// сущности, блочные сущности и то, что мир делает с игроком (урон, отброс, подобранное).\nimport * as THREE from \"three\";\nimport type {\n RealtimeClient,\n RealtimeSession as Session,\n} from \"@idosgames/core/realtime\";\nimport { RealtimeSession } from \"@idosgames/core/realtime\";\nimport { events } from \"../core/EventBus\";\nimport { rules } from \"../systems/Difficulty\";\nimport type { NetEntityAction } from \"../entities/EntityManager\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\nimport { sim } from \"./sim\";\nimport { DiffStore } from \"./DiffStore\";\nimport { OwnerBroadcast } from \"./OwnerBroadcast\";\nimport {\n Avatar,\n RemotePlayer,\n remoteInventory,\n type RemoteSink,\n} from \"./RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"./codec\";\nimport {\n DIMS,\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type LockMsg,\n type PlayerMeta,\n type PortalMsg,\n type ReplyMsg,\n type Pose,\n type Roster,\n type RoomSettings,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"./protocol\";\n\nexport interface NetHostOptions {\n name: string;\n roomName: string;\n visibility: \"Public\" | \"Private\";\n joinCode?: string;\n settings: RoomSettings;\n}\n\n/** Всё, что знаем о другом игроке. */\ninterface Peer {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n pose: Pose | null;\n held: string | null;\n mode: \"survival\" | \"creative\";\n sleeping: boolean;\n avatar: Avatar | null;\n remote: RemotePlayer | null;\n}\n\n/** Хост: сейв игрока (его инвентарь авторитетен у него — здесь копия для мира хоста). */\ninterface HostRecord {\n slot: number;\n name: string;\n dim: DimensionId;\n op: boolean;\n state: unknown | null;\n}\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nconst EMPTY_DIM = (): DimensionSave => ({\n chunks: {},\n entities: [],\n blockEntities: [],\n});\n\nexport class NetGame implements RemoteSink {\n readonly session: Session;\n readonly selfId: string;\n slot = 0;\n settings: RoomSettings;\n /** Операторы (хост — всегда). */\n readonly ops = new Set<string>();\n readonly diffs = new DiffStore();\n /** Другие игроки по слоту. */\n readonly peers = new Map<number, Peer>();\n /** Владелец каждого живого измерения (userId). */\n readonly owners = new Map<DimensionId, string>();\n /** Хост ушёл — кто подхватит (объявляет хост в списке игроков). */\n successor: string | null = null;\n\n // ---- только у хоста\n private readonly records = new Map<string, HostRecord>();\n private nextSlot = 1;\n /** Измерения без владельца: сущности и блочные сущности (дифы — в `diffs`). */\n private readonly frozen = new Map<DimensionId, DimensionSave>();\n /** Блочные сущности всех измерений (для вошедшего в измерение гостя). */\n private readonly beStore = new Map<DimensionId, Map<string, BeMsg[\"be\"]>>();\n\n // ---- владелец/реплика своего измерения\n private readonly bc = new OwnerBroadcast();\n /** Реплика: что последнее прислал владелец по блочным сущностям — своё отличие уходит ему. */\n private readonly replicaBe = new OwnerBroadcast();\n private pending: BlockDelta[] = [];\n private applyingRemote = false;\n private tickN = 0;\n private lastMeta = \"\";\n private shadow: Shadow | null = null;\n private hostSilentSince = 0;\n private readonly offs: (() => void)[] = [];\n private closed = false;\n /** Гость: ждём снимок мира. */\n private welcomeWait: ((w: Welcome | string) => void) | null = null;\n /** Очередь обработки надёжного канала (см. конструктор). */\n private inbox: Promise<void> = Promise.resolve();\n /** Гость получил снимок мира (хост — всегда). До него сообщения копятся в `stash`. */\n private welcomed = true;\n private readonly stash: [string, Uint8Array][] = [];\n /** Владелец измерения: занятые контейнеры — ключ (LockMsg.key) → слот игрока. */\n private readonly locks = new Map<string, number>();\n /** Свои запросы контейнера, ждущие ответа владельца. */\n private readonly lockWait = new Map<\n string,\n { open: () => void; dim: DimensionId; timer: ReturnType<typeof setTimeout> }\n >();\n /** Реплика: id сущности, в которой сидит свой игрок (её ведёт он). */\n private driving: number | null = null;\n\n private constructor(\n readonly game: Game,\n session: Session,\n selfId: string,\n settings: RoomSettings,\n readonly name: string,\n ) {\n this.session = session;\n this.selfId = selfId;\n this.settings = settings;\n this.offs.push(\n session.on(\"message\", ({ from, channel, data }) => {\n // Надёжный канал — строго по порядку: сжатое сообщение распаковывается асинхронно, и без\n // очереди следующее («удалить сущность») обогнало бы предыдущее («создать»). Быстрый канал\n // (позы) порядка не требует и ждать не должен.\n if (channel === \"fast\") void this.onMessage(from, channel, data);\n else\n this.inbox = this.inbox.then(() =>\n this.onMessage(from, channel, data),\n );\n }),\n session.on(\"peerOpen\", ({ peerId }) => this.onPeerOpen(peerId)),\n session.on(\"peerClose\", ({ peerId }) => this.onPeerClose(peerId)),\n session.on(\"memberLeft\", ({ userId }) => this.onMemberLeft(userId)),\n session.on(\"hostChanged\", ({ hostId, previousHostId }) =>\n this.onHostChanged(hostId, previousHostId),\n ),\n session.on(\"chat\", ({ text }) => game.chat.print(text)),\n // исключили: сервер отвечает «не участник» (после уведомления хоста) или прислал PlayerLeft kicked\n session.on(\"removed\", ({ reason }) =>\n this.end(\n /kicked|not a member|removed/i.test(reason)\n ? \"Вас исключили из игры\"\n : \"Связь с игрой потеряна\",\n ),\n ),\n session.on(\"roomClosed\", () => this.end(\"Игра закрыта\")),\n );\n // Реестр порталов общий: зажёг портал любой — знают все (выход на той стороне ищется по нему).\n game.onPortalChange = (dim, pos, add) => {\n const msg = encodeJsonSync(M.PORTAL, {\n dim,\n p: pos,\n add,\n } satisfies PortalMsg);\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n };\n // Правки мира: владелец рассылает все (свои, каскады, пришедшие от реплик), реплика — только свои.\n this.offs.push(\n events.on(\n \"blockChanged\",\n (d: { x: number; y: number; z: number; id: number; meta: number }) => {\n if (this.applyingRemote && !sim.authority) return;\n this.diffs.apply(this.game.dimension, d.x, d.y, d.z, d.id, d.meta);\n this.pending.push({ x: d.x, y: d.y, z: d.z, id: d.id, meta: d.meta });\n },\n ),\n );\n }\n\n get isHost(): boolean {\n return this.session.hostId === this.selfId;\n }\n\n get hostId(): string {\n return this.session.hostId;\n }\n\n /** Может ли этот игрок командами менять мир (хост и операторы, или «команды всем»). */\n get canCheat(): boolean {\n return (\n this.isHost || this.ops.has(this.selfId) || this.settings.cheatsForAll\n );\n }\n\n // =========================================================================== старт\n\n /** Открыть мир для сети: этот игрок — хост, его мир — общий. */\n static async host(\n game: Game,\n client: RealtimeClient,\n o: NetHostOptions,\n ): Promise<NetGame | string> {\n const res = await RealtimeSession.create(client, o.roomName, {\n topology: \"Host\",\n chatMode: \"P2P\",\n maxPlayers: 8,\n visibility: o.visibility,\n joinCode: o.joinCode || undefined,\n });\n if (!res.ok) return res.error;\n // имя — публичный профиль игрока в этом тайтле (его кладёт в снимок комнаты сервер)\n const name = res.data.members.get(res.data.selfId)?.Username || o.name;\n const net = new NetGame(game, res.data, res.data.selfId, o.settings, name);\n net.ops.add(net.selfId);\n net.startAsHost();\n return net;\n }\n\n /** Войти в чужую игру. Мир этого игрока уходит в «Мои миры» и заменяется миром хоста. */\n static async join(\n game: Game,\n client: RealtimeClient,\n roomId: string,\n joinCode: string | undefined,\n name: string,\n ): Promise<NetGame | string> {\n const res = await RealtimeSession.join(client, roomId, joinCode);\n if (!res.ok) return res.error;\n const session = res.data;\n const profileName = session.members.get(session.selfId)?.Username || name;\n const net = new NetGame(\n game,\n session,\n session.selfId,\n { guestMode: \"survival\", cheatsForAll: false },\n profileName,\n );\n net.welcomed = false;\n const w = await new Promise<Welcome | string>((resolve) => {\n net.welcomeWait = resolve;\n setTimeout(() => resolve(\"Хост не ответил\"), 30_000);\n if (session.isOpen(session.hostId)) net.sendHello();\n });\n net.welcomeWait = null;\n if (typeof w === \"string\") {\n net.dispose();\n await session.leave();\n return w;\n }\n net.applyWelcome(w);\n return net;\n }\n\n private startAsHost(): void {\n const g = this.game;\n this.slot = 0;\n this.records.set(this.selfId, {\n slot: 0,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n // Мир хоста целиком в хранилище дифов: живое измерение — с мира, остальные — из сейва измерений.\n this.diffs.load(g.dimension, g.world.serializeDiffs());\n for (const [d, save] of g.dims) {\n this.diffs.load(d, save.chunks);\n this.frozen.set(d, {\n chunks: {},\n entities: save.entities ?? [],\n blockEntities: save.blockEntities ?? [],\n });\n }\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(null);\n }\n\n // =========================================================================== отправка\n\n private async send(\n to: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (to === this.selfId) {\n await this.onMessage(this.selfId, channel, data);\n return;\n }\n this.session.send(to, channel, data);\n }\n\n /** Хосту (у хоста — себе). */\n private toHost(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n void this.send(this.hostId, channel, data);\n }\n\n /** Хост: всем, кроме `except`. */\n private toAll(\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n this.session.broadcast(channel, data, except);\n }\n\n /** Хост: всем игрокам измерения, кроме `except` (себе — тоже, если хост там же). */\n private toDim(\n dim: DimensionId,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n for (const [uid, r] of this.records)\n if (r.dim === dim && uid !== except) void this.send(uid, channel, data);\n }\n\n private userOfSlot(slot: number): string | null {\n if (slot === this.slot) return this.selfId;\n return this.peers.get(slot)?.userId ?? null;\n }\n\n // =========================================================================== приём\n\n private async onMessage(\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (this.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!this.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") this.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return this.onFast(from, type, data);\n switch (type) {\n case M.HELLO:\n if (this.isHost) await this.onHello(from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && this.welcomeWait) this.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n this.welcomeWait?.(r?.reason ?? \"Хост отказал во входе\");\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !this.isHost) this.applyRoster(r);\n return;\n }\n case M.PMETA:\n return this.onMeta(from, data);\n case M.BLOCKS:\n case M.EDITS:\n return this.onBlocks(from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return this.onEntities(from, type, data);\n case M.ENT_ACTION:\n return this.onEntAction(from, data);\n case M.BE:\n return this.onBe(from, data);\n case M.LOCK:\n return this.onLock(from, data);\n case M.REPLY:\n return this.onReply(from, data);\n case M.PORTAL:\n return this.onPortal(from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === this.game.dimension && !sim.authority)\n for (const b of m.list) this.applyBe(b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !this.isHost) {\n this.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n this.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return this.onChat(from, data);\n case M.HIT:\n case M.GIVE:\n return this.onToPlayer(from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = this.records.get(from);\n if (s && r && this.isHost) {\n r.state = s.state;\n this.game.mergeBeyond(\n (s.state as { beyond?: unknown } | null)?.beyond,\n );\n }\n return;\n }\n case M.DIM_ENTER:\n if (this.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) this.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === this.game.dimension) this.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) this.bc.reset();\n return;\n case M.SHADOW:\n this.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && this.isHost && this.mayCheat(from))\n this.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n }\n\n private onFast(from: string, type: number, data: Uint8Array): void {\n if (type === M.POSE) {\n if (this.isHost && from !== this.selfId) {\n const r = this.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n this.toAll(\"fast\", stamped, from);\n this.applyPose(pose);\n return;\n }\n if (!this.isHost) this.applyPose(decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (this.isHost && from !== this.selfId)\n this.toDim(dim, \"fast\", data, from);\n if (dim === this.game.dimension && !sim.authority && from !== this.selfId)\n for (const p of decodeEntPoses(data).list)\n this.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n }\n\n // ---- вход гостя (хост)\n\n private sendHello(): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: this.name };\n this.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n }\n\n private async onHello(from: string, data: Uint8Array): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await this.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: \"Версии игры отличаются — обновите страницу\",\n }),\n );\n return;\n }\n const g = this.game;\n let r = this.records.get(from);\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: this.nextSlot++,\n name: (h.name || \"Игрок\").slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: this.ops.has(from),\n state: saved,\n };\n this.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)\n ?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: this.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: this.roster(),\n };\n await this.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n this.broadcastRoster();\n this.enterDim(from, r.dim);\n }\n\n // ---- поза и метаданные\n\n private applyPose(p: Pose): void {\n if (p.slot === this.slot) return;\n const peer = this.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === this.game.dimension && p.dim !== peer.dim && sim.authority)\n this.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === this.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name);\n this.game.scene.add(peer.avatar.group);\n }\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(\n this.game.world,\n p.slot,\n peer.userId,\n this,\n );\n peer.remote.world = this.game.world;\n peer.remote.held = peer.held;\n peer.remote.applyPose(p);\n }\n } else this.dropPeerView(peer);\n }\n\n private dropPeerView(peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(this.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n }\n\n private async onMeta(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (this.isHost && from !== this.selfId) {\n const r = this.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n this.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n this.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? this.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n }\n\n // ---- блоки\n\n private onBlocks(from: string, type: number, data: Uint8Array): void {\n const { dim, list } = decodeBlocks(data);\n if (this.isHost && from !== this.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (this.owners.get(dim) !== from) return;\n this.diffs.applyAll(dim, list);\n this.toAll(\"rel\", data, from);\n } else {\n const owner = this.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n this.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n this.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (this.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = this.owners.get(dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== this.game.dimension) return;\n this.applyBlocks(list, true);\n return;\n }\n if (from === this.selfId) return;\n if (!this.isHost) this.diffs.applyAll(dim, list);\n if (dim === this.game.dimension && !sim.authority)\n this.applyBlocks(list, false);\n }\n\n private applyBlocks(list: readonly BlockDelta[], asOwner: boolean): void {\n const w = this.game.world;\n this.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n this.applyingRemote = false;\n }\n }\n\n // ---- сущности\n\n private async onEntities(\n from: string,\n type: number,\n data: Uint8Array,\n ): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (this.isHost && from !== this.selfId)\n this.toDim(m.dim, \"rel\", data, from);\n if (\n m.dim === this.game.dimension &&\n !sim.authority &&\n from !== this.selfId\n )\n for (const [id, snap] of m.list)\n this.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (this.isHost && from !== this.selfId) this.toDim(dim, \"rel\", data, from);\n if (dim === this.game.dimension && !sim.authority && from !== this.selfId)\n for (const id of ids) this.game.entities.netRemove(id);\n }\n\n private async onEntAction(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (this.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === this.selfId ? this.slot : this.records.get(from)?.slot;\n const owner = this.owners.get(m.dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== this.game.dimension) return;\n const em = this.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker =\n m.from !== undefined ? this.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n this.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n }\n\n /** Реплика: действие над сущностью — владельцу измерения (через хоста). */\n private entAction(action: NetEntityAction): void {\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: this.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n }\n\n // ---- контейнеры: один игрок — один открытый контейнер\n\n /**\n * Открыть контейнер (сундук, печь, вьюк, груз вагонетки, торговлю с жителем). Владелец измерения\n * решает сам, реплика спрашивает его и открывает по ответу — с его содержимым. Занят — подсказка.\n */\n openContainer(key: string, open: () => void): void {\n const g = this.game;\n if (sim.authority) {\n const by = this.lockBy(key, this.slot);\n if (by) return this.busyHint(by);\n this.locks.set(key, this.slot);\n open();\n return;\n }\n if (this.lockWait.has(key)) return;\n const timer = setTimeout(() => {\n if (this.lockWait.delete(key))\n g.hud.message(\"Нет ответа от игрока, у которого это измерение\", 2200);\n }, 4000);\n this.lockWait.set(key, { open, dim: g.dimension, timer });\n this.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: g.dimension,\n key,\n open: true,\n } satisfies LockMsg),\n );\n }\n\n /** Экран контейнера закрыт — отпустить. */\n releaseContainer(key: string): void {\n if (sim.authority) {\n if (this.locks.get(key) === this.slot) this.locks.delete(key);\n return;\n }\n this.toHost(\n \"rel\",\n encodeJsonSync(M.LOCK, {\n dim: this.game.dimension,\n key,\n open: false,\n } satisfies LockMsg),\n );\n }\n\n private busyHint(by: string): void {\n this.game.hud.message(`Сейчас открыто у игрока ${by}`, 2000);\n }\n\n /** Владелец: имя того, кто держит контейнер (если не `slot` и он всё ещё в этом измерении). */\n private lockBy(key: string, slot: number): string | null {\n const cur = this.locks.get(key);\n if (cur === undefined || cur === slot) return null;\n if (cur === this.slot) return this.name;\n const peer = this.peers.get(cur);\n if (peer && peer.dim === this.game.dimension) return peer.name;\n this.locks.delete(key); // держатель ушёл (из измерения или из игры)\n return null;\n }\n\n private async onLock(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<LockMsg>(data);\n if (!m || typeof m.key !== \"string\") return;\n if (this.isHost) {\n m.from = from === this.selfId ? this.slot : this.records.get(from)?.slot;\n const owner = this.owners.get(m.dim);\n if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", encodeJsonSync(M.LOCK, m));\n return;\n }\n }\n const slot = m.from;\n if (!sim.authority || m.dim !== this.game.dimension || slot === undefined)\n return;\n if (!m.open) {\n if (this.locks.get(m.key) === slot) this.locks.delete(m.key);\n return;\n }\n const by = this.lockBy(m.key, slot);\n const reply: ReplyMsg = {\n to: slot,\n kind: \"lock\",\n key: m.key,\n ok: !by,\n ...(by ? { by } : {}),\n };\n if (!by) {\n this.locks.set(m.key, slot);\n // блочная сущность — отсюда: сундук постройки получает добычу здесь, у реплики катилась бы своя\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b) {\n const be = this.game.blockEntities.ensure(+b[1]!, +b[2]!, +b[3]!);\n reply.be = be\n ? {\n type: be.type,\n x: be.x,\n y: be.y,\n z: be.z,\n state: JSON.parse(JSON.stringify(be.state)) as typeof be.state,\n }\n : null;\n }\n }\n this.replyTo(reply);\n }\n\n /** Ответ игроку `to`: себе — сразу, иначе через хоста (или хост — прямо ему). */\n private replyTo(r: ReplyMsg): void {\n if (r.to === this.slot) {\n this.handleReply(r);\n return;\n }\n const msg = encodeJsonSync(M.REPLY, r);\n if (this.isHost) {\n const to = this.userOfSlot(r.to);\n if (to) void this.send(to, \"rel\", msg);\n } else this.toHost(\"rel\", msg);\n }\n\n private async onReply(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<ReplyMsg>(data);\n if (!m) return;\n if (this.isHost && m.to !== this.slot) {\n const to = this.userOfSlot(m.to);\n if (to && to !== from) void this.send(to, \"rel\", data);\n return;\n }\n if (m.to === this.slot) this.handleReply(m);\n }\n\n private handleReply(m: ReplyMsg): void {\n const g = this.game;\n if (m.kind === \"ride\") {\n const r = g.player.riding as unknown as { id?: number } | null;\n if (!m.ok && r && r.id === m.id) {\n g.player.dismount();\n g.hud.message(\"Место уже занято\", 1600);\n }\n return;\n }\n const w = m.key ? this.lockWait.get(m.key) : undefined;\n if (!w || !m.key) return;\n clearTimeout(w.timer);\n this.lockWait.delete(m.key);\n if (!m.ok) return this.busyHint(m.by ?? \"?\");\n if (w.dim !== g.dimension) {\n this.releaseContainer(m.key);\n return;\n }\n const b = /^b:(-?\\d+),(-?\\d+),(-?\\d+)$/.exec(m.key);\n if (b && m.be !== undefined)\n this.applyBe({\n dim: g.dimension,\n x: +b[1]!,\n y: +b[2]!,\n z: +b[3]!,\n be: m.be,\n });\n w.open();\n }\n\n // ---- порталы\n\n private async onPortal(from: string, data: Uint8Array): Promise<void> {\n if (from === this.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (this.isHost) this.toAll(\"rel\", data, from);\n this.game.applyNetPortal(m.dim, m.p, !!m.add);\n }\n\n // ---- блочные сущности\n\n private async onBe(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = this.owners.get(m.dim);\n if (this.isHost && from !== this.selfId) {\n if (owner === from) {\n this.storeBe(m);\n this.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== this.selfId) {\n void this.send(owner, \"rel\", data);\n return;\n }\n } else if (this.isHost && owner && owner !== this.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void this.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== this.game.dimension || from === this.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n this.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else this.applyBe(m);\n }\n\n private applyBe(m: BeMsg): void {\n this.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n this.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n }\n\n private storeBe(m: BeMsg): void {\n let s = this.beStore.get(m.dim);\n if (!s) this.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n }\n\n // ---- игроку: урон, предметы\n\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: Hit[\"effect\"],\n ): void {\n const h: Hit = {\n to: slot,\n amount,\n cause,\n ...(knock ? { knock } : {}),\n ...(effect ? { effect } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.HIT, h));\n }\n\n give(slot: number, stack: ItemStack | null, xp?: number): void {\n const g: Give = {\n to: slot,\n ...(stack ? { stack } : {}),\n ...(xp ? { xp } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.GIVE, g));\n }\n\n private async onToPlayer(\n from: string,\n type: number,\n data: Uint8Array,\n ): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (this.isHost && m.to !== this.slot) {\n const to = this.userOfSlot(m.to);\n if (to && to !== from) void this.send(to, \"rel\", data);\n return;\n }\n if (m.to !== this.slot) return;\n const g = this.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n }\n\n // ---- чат\n\n sendChat(text: string): void {\n const t = text.slice(0, 256);\n this.game.chat.print(`<${this.name}> ${t}`);\n this.toHost(\"rel\", encodeJsonSync(M.CHAT, { from: this.slot, text: t }));\n }\n\n private async onChat(from: string, data: Uint8Array): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === this.selfId) return;\n if (this.isHost) {\n m.from = this.records.get(from)?.slot;\n this.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who =\n m.from !== undefined ? (this.peers.get(m.from)?.name ?? \"?\") : \"?\";\n this.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n }\n\n // =========================================================================== владение измерением\n\n /** Стать владельцем своего измерения: сущности и блочные сущности — из переданного сохранения. */\n private becomeOwner(save: DimensionSave | null): void {\n const g = this.game;\n sim.authority = true;\n g.entities.replica = false;\n g.entities.localSlot = this.slot;\n this.driving = null;\n g.entities.drivenId = null;\n this.locks.clear();\n if (save) {\n g.entities.deserialize(\n (save.entities ?? []) as Parameters<typeof g.entities.deserialize>[0],\n );\n g.blockEntities.deserialize(save.blockEntities ?? []);\n }\n this.bc.reset();\n for (const peer of this.peers.values())\n if (peer.pose && peer.dim === g.dimension && !peer.remote) {\n peer.remote = new RemotePlayer(g.world, peer.slot, peer.userId, this);\n peer.remote.applyPose(peer.pose);\n }\n g.entities.others = () => this.remoteActors();\n }\n\n /** Стать репликой: сущности — образы от владельца, локальные id — в отдельном диапазоне. */\n private becomeReplica(): void {\n const g = this.game;\n sim.authority = false;\n g.entities.replica = true;\n g.entities.localSlot = this.slot;\n this.driving = null;\n this.locks.clear();\n g.entities._nextId = Math.max(g.entities._nextId, 1_000_000_000);\n g.entities.clear();\n g.entities.others = () => [];\n g.entities.onLocalSpawn = (snap) =>\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action: { a: \"spawn\", snap },\n } satisfies EntActionMsg),\n );\n g.entities.onRemoteAction = (action) =>\n this.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: g.dimension,\n action,\n } satisfies EntActionMsg),\n );\n for (const peer of this.peers.values()) peer.remote = null;\n }\n\n private remoteActors() {\n const out = [];\n for (const peer of this.peers.values())\n if (peer.remote && peer.dim === this.game.dimension && peer.pose)\n out.push({\n player: peer.remote as RemotePlayer,\n inventory: remoteInventory(peer.remote, this),\n });\n return out;\n }\n\n /**\n * Перед сменой измерения (начало Game.changeDimension): у всех есть дифы любого измерения, так\n * что целевое строится без ожидания. Сущности поднимаем, только если станем владельцем сразу\n * (хост, измерение ничьё); иначе их пришлёт владелец или хост отдаст владение.\n */\n beforeDimension(target: DimensionId): void {\n const g = this.game;\n const iOwnTarget = this.isHost && !this.owners.get(target);\n const frozen = this.frozen.get(target);\n g.dims.set(target, {\n chunks: this.diffs.toChunkDiffMap(target),\n entities: iOwnTarget ? (frozen?.entities ?? []) : [],\n blockEntities: iOwnTarget\n ? (frozen?.blockEntities ?? [])\n : [...(this.beStore.get(target)?.values() ?? [])].filter(Boolean),\n });\n if (iOwnTarget) this.frozen.delete(target);\n this.pendingOwnTarget = iOwnTarget;\n }\n private pendingOwnTarget = false;\n\n /**\n * Середина Game.changeDimension: прежнее измерение уже снято в `dims` (с сущностями — для\n * передачи следующему владельцу), новое ещё не загружено. Здесь переключается роль: реплике нельзя\n * поднимать сущности из сохранения — её spawn ушёл бы владельцу, а сущности там и так есть.\n */\n prepareDimension(): void {\n if (this.pendingOwnTarget) {\n sim.authority = true;\n this.game.entities.replica = false;\n } else this.becomeReplica();\n }\n\n /** После смены измерения: отдать прежнее (если владел) и заявить новое. */\n afterDimension(prev: DimensionId, target: DimensionId): void {\n const g = this.game;\n const wasOwner = this.owners.get(prev) === this.selfId;\n const handoff = wasOwner ? g.dims.get(prev) : undefined;\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n if (this.isHost) {\n const r = this.records.get(this.selfId)!;\n r.dim = target;\n if (handoff)\n this.frozen.set(prev, {\n chunks: {},\n entities: handoff.entities ?? [],\n blockEntities: handoff.blockEntities ?? [],\n });\n if (wasOwner) this.owners.delete(prev);\n this.assignOwner(prev);\n if (!this.owners.get(target)) {\n this.owners.set(target, this.selfId);\n this.becomeOwner(null); // сущности уже подняты changeDimension из g.dims\n } else this.enterDim(this.selfId, target);\n this.broadcastRoster();\n return;\n }\n // Синхронно и без сжатия: следом пойдут правки нового измерения (портал на той стороне), и по\n // упорядоченному каналу хост обязан узнать о входе РАНЬШЕ — иначе правки ничейного измерения\n // он бы выбросил.\n const msg: DimEnter = {\n dim: target,\n ...(handoff ? { handoff: { dim: prev, save: handoff } } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.DIM_ENTER, msg));\n // сейв игрока у хоста — сразу, а не через 10 с: уйди хост раньше, гость вернулся бы в прежнее\n // измерение на прежнее место\n this.sendPlayerState();\n }\n\n /** Хост: игрок вошёл в измерение — владельцем или репликой. */\n private onDimEnter(from: string, m: DimEnter): void {\n const r = this.records.get(from);\n if (!r) return;\n const prev = r.dim;\n r.dim = m.dim;\n if (m.handoff && this.owners.get(m.handoff.dim) === from) {\n this.frozen.set(m.handoff.dim, {\n chunks: {},\n entities: m.handoff.save.entities ?? [],\n blockEntities: m.handoff.save.blockEntities ?? [],\n });\n for (const b of m.handoff.save.blockEntities ?? []) {\n const be = b as BeMsg[\"be\"];\n if (be)\n this.storeBe({ dim: m.handoff.dim, x: be.x, y: be.y, z: be.z, be });\n }\n this.owners.delete(m.handoff.dim);\n }\n this.assignOwner(prev);\n this.enterDim(from, m.dim);\n this.broadcastRoster();\n }\n\n /** Хост: `who` в измерении `dim` — дать владение (если ничьё) или полный снимок от владельца. */\n private enterDim(who: string, dim: DimensionId): void {\n const owner = this.owners.get(dim);\n if (!owner) {\n this.owners.set(dim, who);\n const save = this.frozen.get(dim) ?? EMPTY_DIM();\n this.frozen.delete(dim);\n if (who === this.selfId) this.becomeOwner(save);\n // синхронно: по упорядоченному каналу владение обязано прийти раньше пересланных следом правок\n else\n void this.send(\n who,\n \"rel\",\n encodeJsonSync(M.DIM_OWN, { dim, save } satisfies DimOwn),\n );\n return;\n }\n if (owner === who) return;\n void this.send(owner, \"rel\", encodeJsonSync(M.DIM_RESYNC, { dim }));\n const list = [...(this.beStore.get(dim) ?? new Map())].map(([k, be]) => {\n const [x, y, z] = k.split(\",\").map(Number) as [number, number, number];\n return { dim, x, y, z, be } satisfies BeMsg;\n });\n // синхронно: следом могут пойти свежие изменения тех же сундуков, старый список не должен их затереть\n if (who !== this.selfId && list.length)\n void this.send(who, \"rel\", encodeJsonSync(M.BE_LIST, { dim, list }));\n }\n\n /** Хост: у измерения нет владельца или он ушёл — отдать следующему игроку в нём, иначе заморозить. */\n private assignOwner(dim: DimensionId): void {\n const owner = this.owners.get(dim);\n if (owner && this.records.get(owner)?.dim === dim) return;\n this.owners.delete(dim);\n const next = [...this.records.entries()]\n .filter(([, r]) => r.dim === dim)\n .sort((a, b) => a[1].slot - b[1].slot)[0];\n if (next) this.enterDim(next[0], dim);\n }\n\n // =========================================================================== список игроков\n\n private roster(): Roster {\n return {\n hostId: this.selfId,\n players: [...this.records.entries()].map(([userId, r]) => ({\n slot: r.slot,\n userId,\n name: r.name,\n dim: r.dim,\n op: this.ops.has(userId),\n })),\n owners: Object.fromEntries(this.owners),\n settings: this.settings,\n ...(this.successor ? { successor: this.successor } : {}),\n } as Roster;\n }\n\n private broadcastRoster(): void {\n if (!this.isHost) return;\n const r = this.roster();\n this.applyRoster(r);\n this.toAll(\"rel\", encodeJsonSync(M.ROSTER, r));\n }\n\n private applyRoster(r: Roster): void {\n this.settings = r.settings;\n this.ops.clear();\n const seen = new Set<number>();\n for (const p of r.players) {\n if (p.op) this.ops.add(p.userId);\n if (p.userId === this.selfId) {\n this.slot = p.slot;\n continue;\n }\n seen.add(p.slot);\n const cur = this.peers.get(p.slot);\n if (cur) {\n cur.name = p.name;\n cur.op = p.op;\n cur.dim = p.dim;\n } else\n this.peers.set(p.slot, {\n slot: p.slot,\n userId: p.userId,\n name: p.name,\n dim: p.dim,\n op: p.op,\n pose: null,\n held: null,\n mode: \"survival\",\n sleeping: false,\n avatar: null,\n remote: null,\n });\n }\n for (const [slot, peer] of [...this.peers])\n if (!seen.has(slot)) {\n this.dropPeerView(peer);\n this.peers.delete(slot);\n }\n // владелец моего измерения сменился — старые образы сущностей больше не обновятся\n const ownerHere = r.owners[this.game.dimension];\n const prevOwner = this.owners.get(this.game.dimension);\n this.owners.clear();\n for (const [d, u] of Object.entries(r.owners))\n if (u) this.owners.set(d as DimensionId, u);\n if (\n !this.isHost &&\n ownerHere !== prevOwner &&\n ownerHere !== this.selfId &&\n !sim.authority\n )\n this.game.entities.clear();\n const succ = (r as Roster & { successor?: string }).successor ?? null;\n if (succ !== this.successor) {\n this.successor = succ;\n // запасная связь с преемником: передача хоста не ждёт сигналинга\n if (succ === this.selfId)\n this.session.setExtraPeers(r.players.map((p) => p.userId));\n else this.session.setExtraPeers(succ ? [succ] : []);\n }\n events.emit(\"net:roster\");\n }\n\n /** Игроки для списка по Tab. */\n playerList(): {\n name: string;\n slot: number;\n userId: string;\n host: boolean;\n op: boolean;\n dim: DimensionId;\n rtt: number;\n }[] {\n const out = [\n {\n name: this.name,\n slot: this.slot,\n userId: this.selfId,\n host: this.isHost,\n op: this.ops.has(this.selfId),\n dim: this.game.dimension,\n rtt: 0,\n },\n ];\n for (const p of this.peers.values())\n out.push({\n name: p.name,\n slot: p.slot,\n userId: p.userId,\n host: p.userId === this.hostId,\n op: p.op,\n dim: p.dim,\n rtt: this.session.linkStats(p.userId)?.rttMs ?? 0,\n });\n return out.sort((a, b) => a.slot - b.slot);\n }\n\n // =========================================================================== тик и кадр\n\n /** Конец симтика Game: разослать накопленное. */\n afterTick(): void {\n if (this.closed) return;\n const g = this.game;\n this.tickN++;\n const dim = g.dimension;\n\n if (this.pending.length) {\n const list = this.pending;\n this.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (this.isHost && type === M.BLOCKS) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) this.entAction(a);\n this.syncRide();\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of this.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) this.hit(peer.slot, 0, \"knock\", imp);\n }\n if (this.tickN % 2 === 0 && this.hasOthersIn(dim)) {\n const d = this.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n this.ownerSend(\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) this.ownerSend(\"fast\", b);\n if (d.removes.length)\n this.ownerSend(\"rel\", encodeEntRemove(dim, d.removes));\n }\n if (this.tickN % 10 === 0)\n for (const { be, key } of this.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (this.isHost) this.storeBe(msg);\n if (this.hasOthersIn(dim))\n this.ownerSend(\"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (this.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of this.replicaBe.diffBlockEntities(\n g.blockEntities,\n )) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n this.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n this.sendPose();\n this.sendMetaIfChanged();\n if (this.tickN % 200 === 0 && !this.isHost) this.sendPlayerState();\n\n if (this.isHost) {\n if (this.tickN % 100 === 0)\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (this.tickN % 200 === 0) this.pickSuccessorAndShadow();\n } else this.watchHost();\n }\n\n /**\n * Реплика в транспорте или верхом: сущность ведёт этот игрок (EntityManager.drivenId), владелец\n * её не тикает и получает позу 10 раз в секунду. Сел/слез — сказать владельцу.\n */\n private syncRide(): void {\n const g = this.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== this.driving) {\n if (this.driving !== null)\n this.entAction({ a: \"ride\", id: this.driving, on: false });\n if (id !== null) this.entAction({ a: \"ride\", id, on: true });\n this.driving = id;\n em.drivenId = id;\n }\n if (id === null || this.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n this.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n }\n\n /** Владелец рассылает игрокам измерения: хост — сам, гость — через хоста. */\n private ownerSend(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n if (this.isHost)\n this.toDim(this.game.dimension, channel, data, this.selfId);\n else this.toHost(channel, data);\n }\n\n private hasOthersIn(dim: DimensionId): boolean {\n for (const p of this.peers.values()) if (p.dim === dim) return true;\n return false;\n }\n\n private sendPose(): void {\n const g = this.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0);\n const msg = encodePose({\n slot: this.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (this.isHost) this.toAll(\"fast\", msg);\n else this.toHost(\"fast\", msg);\n }\n\n private sendMetaIfChanged(): void {\n const g = this.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: this.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: this.name,\n sleeping: g.netSleeping,\n };\n const j = JSON.stringify(meta);\n if (j === this.lastMeta) return;\n this.lastMeta = j;\n if (this.isHost) {\n this.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n this.hostSleepCheck(this.selfId, meta);\n } else this.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n }\n\n /** Сон как в Minecraft: ночь пропускается, когда спят ВСЕ игроки Верхнего мира. */\n private hostSleepCheck(_who: string, _m: PlayerMeta): void {\n const g = this.game;\n const inOverworld = [...this.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === this.selfId\n ? g.netSleeping\n : ([...this.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n }\n\n frame(dt: number): void {\n for (const peer of this.peers.values()) peer.avatar?.frame(dt);\n }\n\n // =========================================================================== команды\n\n private mayCheat(userId: string): boolean {\n return (\n userId === this.selfId ||\n this.ops.has(userId) ||\n this.settings.cheatsForAll\n );\n }\n\n /**\n * Команда в сетевой игре: «про себя» — здесь, «про мир» — у хоста, без прав — отказ.\n * Возвращает null (исполнять локально) или текст ответа.\n */\n routeCommand(name: string, text: string): string | null {\n const harmless = [\"help\", \"seed\", \"locate\", \"achievements\", \"music\"];\n if (harmless.includes(name)) return null;\n if (name === \"newworld\")\n return \"В сетевой игре новый мир не создать — сначала выйдите из неё\";\n if (!this.canCheat) return \"Команды в этой игре — только у операторов\";\n if ([\"time\", \"weather\", \"difficulty\"].includes(name) && !this.isHost) {\n this.toHost(\"rel\", encodeJsonSync(M.COMMAND, { text }));\n return \"Команда отправлена хосту\";\n }\n return null;\n }\n\n /** Хост: выдать или снять права оператора. */\n setOp(userId: string, op: boolean): void {\n if (!this.isHost || userId === this.selfId) return;\n if (op) this.ops.add(userId);\n else this.ops.delete(userId);\n const r = this.records.get(userId);\n if (r) r.op = op;\n this.broadcastRoster();\n }\n\n async kick(userId: string, ban: boolean): Promise<string | null> {\n const res = await this.session.kick(userId, ban);\n return res.ok ? null : res.error;\n }\n\n // =========================================================================== хост ушёл\n\n /** Хост: выбрать преемника (лучшая связь) и отправить ему теневую копию мира. */\n private pickSuccessorAndShadow(): void {\n let best: string | null = null,\n bestRtt = Infinity;\n for (const peer of this.peers.values()) {\n const s = this.session.linkStats(peer.userId);\n if (!s || !this.session.isOpen(peer.userId)) continue;\n const rtt = s.rttMs || 999;\n if (rtt < bestRtt) {\n bestRtt = rtt;\n best = peer.userId;\n }\n }\n if (best !== this.successor) {\n this.successor = best;\n this.session.setSuccessor(best);\n this.broadcastRoster();\n }\n if (!best) return;\n const g = this.game;\n const dims: Partial<Record<DimensionId, DimensionSave>> = {};\n for (const [d, s] of this.frozen) dims[d] = s;\n if (this.owners.get(g.dimension) === this.selfId)\n dims[g.dimension] = {\n chunks: {},\n entities: g.entities.serialize(),\n blockEntities: g.blockEntities.serialize(),\n };\n const players: Record<string, unknown> = {};\n for (const [uid, r] of this.records)\n players[uid] = uid === this.selfId ? g.netPlayerState() : r.state;\n const shadow: Shadow = {\n world: g.netWorldMeta(),\n dims,\n players,\n settings: this.settings,\n ops: [...this.ops],\n };\n void encodeJson(M.SHADOW, shadow).then((b) => this.send(best!, \"rel\", b));\n }\n\n private onPeerOpen(peerId: string): void {\n if (!this.isHost && peerId === this.hostId) {\n if (this.welcomeWait) this.sendHello();\n else {\n // новый хост после передачи: представиться заново\n this.lastMeta = \"\";\n this.sendPlayerState();\n }\n }\n }\n\n /** Гость: свой сейв хосту (он хранит его в своём мире). */\n private sendPlayerState(): void {\n if (this.isHost) return;\n void encodeJson(M.PSTATE, { state: this.game.netPlayerState() }).then((b) =>\n this.toHost(\"rel\", b),\n );\n }\n\n private onPeerClose(peerId: string): void {\n if (!this.isHost && peerId === this.hostId)\n this.hostSilentSince = Date.now();\n }\n\n private onMemberLeft(userId: string): void {\n for (const peer of this.peers.values())\n if (peer.userId === userId && sim.authority)\n this.game.entities.netReleaseDriver(peer.slot);\n const r = this.records.get(userId);\n if (this.isHost && r) {\n this.records.delete(userId);\n if (this.owners.get(r.dim) === userId) {\n this.owners.delete(r.dim);\n this.assignOwner(r.dim);\n }\n this.broadcastRoster();\n }\n for (const [slot, peer] of [...this.peers])\n if (peer.userId === userId) {\n this.dropPeerView(peer);\n this.peers.delete(slot);\n }\n events.emit(\"net:roster\");\n }\n\n /** Гость: связь с хостом пропала. Преемник через 5 с забирает роль (сервер проверит, что хост молчит). */\n private watchHost(): void {\n if (!this.hostSilentSince || this.successor !== this.selfId) return;\n if (this.session.isOpen(this.hostId)) {\n this.hostSilentSince = 0;\n return;\n }\n if (Date.now() - this.hostSilentSince < 5000 || this.tickN % 40 !== 0)\n return;\n void this.session.claimHost();\n }\n\n private onHostChanged(hostId: string, previousHostId: string | null): void {\n this.hostSilentSince = 0;\n this.hostIdBefore = previousHostId ?? \"\";\n if (hostId !== this.selfId) {\n // у нового хоста наш сейв из теневой копии (до 10 с давности); по запасной связи, уже открытой,\n // onPeerOpen не сработает — шлём сразу\n if (this.session.isOpen(hostId)) this.sendPlayerState();\n return;\n }\n this.promote();\n }\n\n /** Стал хостом: мир из теневой копии и своей копии дифов, прежние владельцы измерений — как были. */\n private promote(): void {\n const g = this.game;\n const sh = this.shadow;\n this.records.clear();\n this.records.set(this.selfId, {\n slot: this.slot,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n for (const peer of this.peers.values())\n if (this.session.members.has(peer.userId))\n this.records.set(peer.userId, {\n slot: peer.slot,\n name: peer.name,\n dim: peer.dim,\n op: peer.op,\n state: sh?.players[peer.userId] ?? null,\n });\n this.nextSlot = Math.max(this.slot, ...[...this.peers.keys()]) + 1;\n if (sh) {\n this.settings = sh.settings;\n for (const o of sh.ops) this.ops.add(o);\n for (const [d, s] of Object.entries(sh.dims) as [\n DimensionId,\n DimensionSave,\n ][])\n if (!this.owners.has(d) || this.owners.get(d) === this.hostIdBefore)\n this.frozen.set(d, s);\n g.applyNetWorldState({\n ...sh.world,\n difficulty: sh.world.difficulty,\n daysAwake: sh.world.daysAwake,\n } as WorldState);\n }\n this.ops.add(this.selfId);\n // владения прежнего хоста: его измерение — следующему там (может, мне), остальное — как было\n for (const [d, owner] of [...this.owners])\n if (!this.records.has(owner)) {\n this.owners.delete(d);\n this.assignOwner(d);\n }\n if (!this.owners.get(g.dimension)) {\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(this.frozen.get(g.dimension) ?? null);\n this.frozen.delete(g.dimension);\n }\n g.adoptNetWorldAsOwn();\n this.broadcastRoster();\n this.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n g.hud.message(\"Хост ушёл — теперь мир у вас\", 3000);\n }\n private hostIdBefore = \"\";\n\n // =========================================================================== гость: вход\n\n private applyWelcome(w: Welcome): void {\n this.slot = w.you;\n this.applyRoster(w.roster);\n this.hostIdBefore = w.roster.hostId;\n for (const d of DIMS) this.diffs.load(d, w.diffs[d]);\n this.becomeReplica();\n this.game.loadNetWorld(w, this.diffs, this.settings);\n // правки, накопленные, пока шёл вход, — из СВОЕГО одиночного мира: хосту их слать нельзя\n this.pending = [];\n this.welcomed = true;\n // отложенное — по порядку прихода; правки из снимка применятся повторно, это безвредно\n const stash = this.stash.splice(0);\n for (const [from, data] of stash)\n this.inbox = this.inbox.then(() => this.onMessage(from, \"rel\", data));\n }\n\n // =========================================================================== выход\n\n /** Хост уходит: свежая теневая копия преемнику, и сервер отдаст роль именно ему. */\n prepareExit(): void {\n if (this.closed || !this.isHost) return;\n this.pickSuccessorAndShadow();\n this.session.setSuccessor(this.successor);\n }\n\n /** Хост: сейвы других игроков — в мир хоста (Game.persist). */\n collectPlayerStates(into: Map<string, unknown>): void {\n if (!this.isHost) return;\n for (const [uid, r] of this.records)\n if (uid !== this.selfId && r.state) into.set(uid, r.state);\n }\n\n /** Уйти из сетевой игры (кнопка). Хост передаёт мир преемнику. */\n async leave(): Promise<void> {\n if (this.closed) return;\n this.prepareExit();\n this.dispose();\n await this.session.leave();\n this.game.exitNet(null);\n }\n\n private end(reason: string): void {\n if (this.closed) return;\n this.dispose();\n this.game.exitNet(reason);\n }\n\n dispose(): void {\n if (this.closed) return;\n this.closed = true;\n for (const off of this.offs.splice(0)) off();\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n this.peers.clear();\n const g = this.game;\n g.entities.replica = false;\n g.entities.others = () => [];\n g.entities.onLocalSpawn = null;\n g.entities.onRemoteAction = null;\n g.entities.localSlot = null;\n g.entities.drivenId = null;\n g.onPortalChange = null;\n for (const w of this.lockWait.values()) clearTimeout(w.timer);\n this.lockWait.clear();\n this.locks.clear();\n sim.authority = true;\n }\n}\n"
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
"path": "net/OwnerBroadcast.ts",
|
|
668
|
+
"content": "// Обязанности ВЛАДЕЛЬЦА измерения (net/sim): каждые два тика сверить свои сущности с тем, что уже\n// разослано, и отправить разницу — появились (снимок), сдвинулись (поза), исчезли. Блочные сущности\n// (сундуки, печи) — раз в полсекунды, по изменению состояния.\nimport type { EntityManager, NetEntitySnap } from \"../entities/EntityManager\";\nimport type { BlockEntities, BlockEntity } from \"../world/BlockEntities\";\nimport type { EntPose } from \"./protocol\";\n\n/** Сдвиг меньше этого (квадрат, блоки²) позу не шлёт: стоящий моб не тратит трафик. */\nconst MOVE_EPS2 = 0.0004;\n\ninterface Sent {\n x: number;\n y: number;\n z: number;\n yaw: number;\n flags: number;\n extra: number;\n /** Отпечаток «вида» (снимок без позы): сменился — снимок заново (остригли, окрасили, назвали). */\n look: string;\n}\n\nexport interface EntityDiff {\n spawns: [number, NetEntitySnap][];\n poses: EntPose[];\n removes: number[];\n}\n\n/** Отпечаток вида: снимок без координат, скорости и здоровья (они меняются постоянно). */\nfunction lookOf(s: NetEntitySnap): string {\n const { pos: _p, ...rest } = s as NetEntitySnap & {\n vel?: unknown;\n hp?: unknown;\n age?: unknown;\n };\n const r = rest as Record<string, unknown>;\n delete r.vel;\n delete r.hp;\n delete r.pickupDelay;\n delete r.fuse;\n return JSON.stringify(r);\n}\n\nexport class OwnerBroadcast {\n private readonly sent = new Map<number, Sent>();\n private readonly beSent = new Map<string, string>();\n\n /** Забыть разосланное: следующий diff отдаст ВСЁ заново (новый игрок, смена владельца). */\n reset(): void {\n this.sent.clear();\n this.beSent.clear();\n }\n\n diffEntities(em: EntityManager): EntityDiff {\n const out: EntityDiff = { spawns: [], poses: [], removes: [] };\n const alive = new Set<number>();\n for (const e of em.entities.values()) {\n if (e.dead) continue;\n const snap = em.netSnapshot(e);\n if (!snap) continue;\n alive.add(e.id);\n const pose = em.netPose(e);\n const look = lookOf(snap);\n const prev = this.sent.get(e.id);\n if (!prev || prev.look !== look) {\n out.spawns.push([e.id, snap]);\n this.sent.set(e.id, {\n x: e.pos.x,\n y: e.pos.y,\n z: e.pos.z,\n ...pose,\n look,\n });\n continue;\n }\n const moved =\n (prev.x - e.pos.x) ** 2 +\n (prev.y - e.pos.y) ** 2 +\n (prev.z - e.pos.z) ** 2 >\n MOVE_EPS2;\n if (\n moved ||\n Math.abs(prev.yaw - pose.yaw) > 0.02 ||\n prev.flags !== pose.flags ||\n prev.extra !== pose.extra\n ) {\n out.poses.push({\n id: e.id,\n x: e.pos.x,\n y: e.pos.y,\n z: e.pos.z,\n ...pose,\n });\n prev.x = e.pos.x;\n prev.y = e.pos.y;\n prev.z = e.pos.z;\n prev.yaw = pose.yaw;\n prev.flags = pose.flags;\n prev.extra = pose.extra;\n }\n }\n for (const id of [...this.sent.keys()])\n if (!alive.has(id)) {\n this.sent.delete(id);\n out.removes.push(id);\n }\n return out;\n }\n\n /** Все сущности как снимки — полный снимок для вошедшего в измерение. */\n fullEntities(em: EntityManager): [number, NetEntitySnap][] {\n const out: [number, NetEntitySnap][] = [];\n for (const e of em.entities.values()) {\n if (e.dead) continue;\n const s = em.netSnapshot(e);\n if (s) out.push([e.id, s]);\n }\n return out;\n }\n\n /** Изменившиеся блочные сущности (и исчезнувшие — как null). */\n diffBlockEntities(\n be: BlockEntities,\n ): { key: string; be: BlockEntity | null }[] {\n const out: { key: string; be: BlockEntity | null }[] = [];\n const seen = new Set<string>();\n for (const b of be.map.values()) {\n const k = b.x + \",\" + b.y + \",\" + b.z;\n seen.add(k);\n const j = JSON.stringify(b.state);\n if (this.beSent.get(k) === j) continue;\n this.beSent.set(k, j);\n out.push({\n key: k,\n be: {\n type: b.type,\n x: b.x,\n y: b.y,\n z: b.z,\n state: JSON.parse(j) as BlockEntity[\"state\"],\n },\n });\n }\n for (const k of [...this.beSent.keys()])\n if (!seen.has(k)) {\n this.beSent.delete(k);\n out.push({ key: k, be: null });\n }\n return out;\n }\n\n /** Принять состояние как уже разосланное (реплика прислала — не отражать обратно ей же лишний раз). */\n markBe(b: BlockEntity | null, key: string): void {\n if (b) this.beSent.set(key, JSON.stringify(b.state));\n else this.beSent.delete(key);\n }\n}\n"
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
"path": "net/protocol.test.ts",
|
|
672
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport {\n ByteReader,\n ByteWriter,\n decodeJson,\n encodeJson,\n encodeJsonSync,\n} from \"./codec\";\nimport {\n M,\n POSE_DEAD,\n POSE_SNEAK,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n withPoseSlot,\n type BlockDelta,\n} from \"./protocol\";\nimport { DiffStore } from \"./DiffStore\";\nimport { OwnerBroadcast } from \"./OwnerBroadcast\";\nimport type { EntityManager, NetEntitySnap } from \"../entities/EntityManager\";\nimport type { BlockEntities, BlockEntity } from \"../world/BlockEntities\";\n\ndescribe(\"codec\", () => {\n it(\"round-trips numbers, angles and raw bytes\", () => {\n const w = new ByteWriter(2)\n .u8(250)\n .u16(65000)\n .i16(-1234)\n .u32(4_000_000_000)\n .i32(-2_000_000_000)\n .f32(1.5)\n .angle(Math.PI * 3.25)\n .bytes(new Uint8Array([9, 8, 7]));\n const r = new ByteReader(w.finish());\n expect(r.u8()).toBe(250);\n expect(r.u16()).toBe(65000);\n expect(r.i16()).toBe(-1234);\n expect(r.u32()).toBe(4_000_000_000);\n expect(r.i32()).toBe(-2_000_000_000);\n expect(r.f32()).toBe(1.5);\n // 3.25π ≡ −0.75π — угол нормализуется в (−π, π]\n expect(r.angle()).toBeCloseTo(-Math.PI * 0.75, 3);\n expect([...r.rest()]).toEqual([9, 8, 7]);\n });\n\n it(\"refuses to read past the end instead of returning garbage\", () => {\n const r = new ByteReader(new Uint8Array([1]));\n expect(() => r.u16()).toThrow(RangeError);\n });\n\n it(\"compresses a large JSON body and decodes it back\", async () => {\n const big = {\n list: Array.from({ length: 2000 }, (_, i) => ({ i, name: \"block\" })),\n };\n const msg = await encodeJson(M.WELCOME, big);\n expect(msg[0]).toBe(M.WELCOME);\n expect(msg[1]! & 1).toBe(1); // сжато\n expect(msg.byteLength).toBeLessThan(JSON.stringify(big).length / 3);\n expect(await decodeJson(msg)).toEqual(big);\n });\n\n it(\"keeps small JSON uncompressed\", async () => {\n const msg = encodeJsonSync(M.CHAT, { text: \"hi\" });\n expect(msg[1]).toBe(0);\n expect(await decodeJson(msg)).toEqual({ text: \"hi\" });\n });\n\n it(\"returns null on a broken body\", async () => {\n expect(await decodeJson(new Uint8Array([M.CHAT, 0, 123, 34]))).toBeNull();\n });\n});\n\ndescribe(\"protocol\", () => {\n it(\"pose round-trip, and the host restamps the slot\", () => {\n const msg = encodePose({\n slot: 3,\n dim: \"inferno\",\n x: 10.25,\n y: 64,\n z: -7.5,\n yaw: 1.2,\n pitch: -0.4,\n flags: POSE_SNEAK | POSE_DEAD,\n health: 17.5,\n });\n const p = decodePose(msg);\n expect(p).toMatchObject({\n slot: 3,\n dim: \"inferno\",\n x: 10.25,\n y: 64,\n z: -7.5,\n flags: POSE_SNEAK | POSE_DEAD,\n health: 17.5,\n });\n expect(p.yaw).toBeCloseTo(1.2, 3);\n expect(decodePose(withPoseSlot(msg, 9)).slot).toBe(9);\n expect(decodePose(msg).slot).toBe(3); // оригинал не тронут\n });\n\n it(\"block deltas round-trip, including negative coordinates\", () => {\n const list: BlockDelta[] = [\n { x: -1, y: 0, z: -16, id: 5, meta: 0 },\n { x: 29_999_999, y: 127, z: -29_999_999, id: 1023, meta: 255 },\n ];\n const [msg] = encodeBlocks(M.EDITS, \"beyond\", list);\n expect(msg![0]).toBe(M.EDITS);\n expect(decodeBlocks(msg!)).toEqual({ dim: \"beyond\", list });\n });\n\n it(\"large block batches are split into bounded messages\", () => {\n const list = Array.from({ length: 2500 }, (_, i) => ({\n x: i,\n y: 1,\n z: 2,\n id: 3,\n meta: 0,\n }));\n const parts = encodeBlocks(M.BLOCKS, \"overworld\", list);\n expect(parts).toHaveLength(3);\n for (const p of parts) expect(p.byteLength).toBeLessThanOrEqual(16 * 1024);\n expect(parts.flatMap((p) => decodeBlocks(p).list)).toEqual(list);\n });\n\n it(\"entity poses and removals round-trip\", () => {\n const poses = [\n { id: 4_000_000_000, x: 1, y: 2, z: 3, yaw: 0.5, flags: 7, extra: 200 },\n ];\n const [pm] = encodeEntPoses(\"overworld\", poses);\n const back = decodeEntPoses(pm!);\n expect(back.dim).toBe(\"overworld\");\n expect(back.list[0]).toMatchObject({\n id: 4_000_000_000,\n x: 1,\n y: 2,\n z: 3,\n flags: 7,\n extra: 200,\n });\n expect(decodeEntRemove(encodeEntRemove(\"inferno\", [1, 2, 3]))).toEqual({\n dim: \"inferno\",\n ids: [1, 2, 3],\n });\n });\n\n it(\"build hash differs when the block or item list differs\", () => {\n const a = buildHash([\"air\", \"stone\"], [\"stick\"]);\n expect(buildHash([\"air\", \"stone\"], [\"stick\"])).toBe(a);\n expect(buildHash([\"air\", \"dirt\"], [\"stick\"])).not.toBe(a);\n expect(buildHash([\"air\", \"stone\"], [\"stick\", \"apple\"])).not.toBe(a);\n });\n});\n\ndescribe(\"DiffStore\", () => {\n it(\"keeps the latest change per block and survives a save round-trip\", () => {\n const s = new DiffStore();\n s.apply(\"overworld\", 17, 5, -3, 1, 0);\n s.apply(\"overworld\", 17, 5, -3, 2, 4); // та же клетка — последняя правка побеждает\n s.apply(\"overworld\", -1, 0, 0, 3, 0);\n expect(s.count(\"overworld\")).toBe(2);\n const map = s.toChunkDiffMap(\"overworld\");\n // клетка (17, 5, -3) — чанк (1, -1), локальные (1, 5, 13)\n expect(map[\"1,-1\"]).toEqual([[1 | (13 << 4) | (5 << 8), 2, 4]]);\n const t = new DiffStore();\n t.load(\"overworld\", map);\n expect(t.toChunkDiffMap(\"overworld\")).toEqual(map);\n });\n\n it(\"keeps dimensions apart\", () => {\n const s = new DiffStore();\n s.apply(\"inferno\", 0, 1, 0, 9, 0);\n expect(s.count(\"overworld\")).toBe(0);\n expect(Object.keys(s.all())).toEqual([\"inferno\"]);\n });\n\n it(\"ignores malformed entries from a foreign save\", () => {\n const s = new DiffStore();\n s.load(\"overworld\", {\n \"0,0\": [\n [1, 2, 3],\n [1.5, 2, 3] as unknown as [number, number, number],\n null as never,\n ],\n });\n expect(s.count(\"overworld\")).toBe(1);\n });\n});\n\n// Владелец измерения шлёт разницу: вещь без позы в мире (pos) отличается только видом.\ndescribe(\"OwnerBroadcast\", () => {\n type FakeEntity = {\n id: number;\n dead: boolean;\n pos: { x: number; y: number; z: number };\n snap: NetEntitySnap;\n yaw: number;\n };\n function fakeEm(list: FakeEntity[]) {\n return {\n entities: new Map(list.map((e) => [e.id, e])),\n netSnapshot: (e: FakeEntity) =>\n ({ ...e.snap, pos: [e.pos.x, e.pos.y, e.pos.z] }) as NetEntitySnap,\n netPose: (e: FakeEntity) => ({ yaw: e.yaw, flags: 0, extra: 0 }),\n } as unknown as EntityManager;\n }\n const mob = (id: number, x: number): FakeEntity => ({\n id,\n dead: false,\n pos: { x, y: 0, z: 0 },\n yaw: 0,\n snap: { kind: \"mob\", type: \"cow\", pos: [x, 0, 0], hp: 10 } as NetEntitySnap,\n });\n\n it(\"first diff spawns everything, then only moves and removals\", () => {\n const a = mob(1, 0),\n b = mob(2, 5);\n const em = fakeEm([a, b]);\n const bc = new OwnerBroadcast();\n const first = bc.diffEntities(em);\n expect(first.spawns.map(([id]) => id)).toEqual([1, 2]);\n expect(first.poses).toEqual([]);\n\n a.pos.x = 1; // сдвинулся\n (em.entities as Map<number, unknown>).delete(2); // исчез\n const second = bc.diffEntities(em);\n expect(second.spawns).toEqual([]);\n expect(second.poses.map((p) => p.id)).toEqual([1]);\n expect(second.removes).toEqual([2]);\n\n const third = bc.diffEntities(em);\n expect(third).toEqual({ spawns: [], poses: [], removes: [] });\n });\n\n it(\"a change of look (sheared, named) resends the full snapshot, health does not\", () => {\n const a = mob(1, 0);\n const em = fakeEm([a]);\n const bc = new OwnerBroadcast();\n bc.diffEntities(em);\n (a.snap as { hp: number }).hp = 3;\n expect(bc.diffEntities(em).spawns).toEqual([]);\n (a.snap as { sheared?: boolean }).sheared = true;\n expect(bc.diffEntities(em).spawns.map(([id]) => id)).toEqual([1]);\n });\n\n it(\"reset makes the next diff a full resend (a new player entered)\", () => {\n const em = fakeEm([mob(1, 0)]);\n const bc = new OwnerBroadcast();\n bc.diffEntities(em);\n bc.reset();\n expect(bc.diffEntities(em).spawns).toHaveLength(1);\n });\n\n it(\"block entities: changed state and removal are reported once\", () => {\n const chest: BlockEntity = {\n type: \"chest\",\n x: 1,\n y: 2,\n z: 3,\n state: { items: [null] },\n };\n const be = { map: new Map([[\"1,2,3\", chest]]) } as unknown as BlockEntities;\n const bc = new OwnerBroadcast();\n expect(bc.diffBlockEntities(be)).toHaveLength(1);\n expect(bc.diffBlockEntities(be)).toHaveLength(0);\n chest.state = { items: [{ item: \"apple\", count: 1 }] };\n expect(bc.diffBlockEntities(be)[0]!.be?.state).toEqual({\n items: [{ item: \"apple\", count: 1 }],\n });\n (be.map as Map<string, BlockEntity>).clear();\n expect(bc.diffBlockEntities(be)).toEqual([{ key: \"1,2,3\", be: null }]);\n });\n\n it(\"markBe suppresses echoing a state that came from the network\", () => {\n const chest: BlockEntity = {\n type: \"chest\",\n x: 0,\n y: 0,\n z: 0,\n state: { items: [] },\n };\n const be = { map: new Map([[\"0,0,0\", chest]]) } as unknown as BlockEntities;\n const bc = new OwnerBroadcast();\n bc.markBe(chest, \"0,0,0\");\n expect(bc.diffBlockEntities(be)).toEqual([]);\n });\n});\n"
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
"path": "net/protocol.ts",
|
|
676
|
+
"content": "// Протокол мультиплеера VoxelCraft поверх `@idosgames/core/realtime` (звезда: всё идёт через хоста).\n//\n// Роли:\n// • ХОСТ — координатор: принимает игроков, ведёт список (roster), назначает владельцев измерений,\n// хранит замороженные измерения и состояния игроков (для сейва и передачи хоста), пересылает.\n// • ВЛАДЕЛЕЦ ИЗМЕРЕНИЯ — считает мир своего измерения (net/sim): хост, если он там, иначе первый\n// вошедший игрок. Рассылает изменения блоков, сущности, блочные сущности.\n// • РЕПЛИКА — любой другой игрок в измерении: шлёт свои правки мира и действия с сущностями.\n//\n// Игрок и инвентарь — у КАЖДОГО свои (решение владельца 27.09.2026, вариант А): игрок сам считает\n// себя (физику, голод, смерть, инвентарь), хост и владельцы измерений его только видят и «выдают»\n// ему подобранное, урон и отброс.\n\nimport { ByteReader, ByteWriter } from \"./codec\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack, WorldSource } from \"../types\";\nimport type { NetEntityAction, NetEntitySnap } from \"../entities/EntityManager\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\n\n/** Версия протокола: разные версии не играют вместе (Hello → «обновите страницу»). */\nexport const PROTOCOL = 2;\n\nexport const M = {\n // ---- надёжный канал (rel)\n HELLO: 1, // гость → хост: JSON Hello\n WELCOME: 2, // хост → гость: JSON Welcome (сжатый)\n REJECT: 3, // хост → гость: JSON { reason }\n ROSTER: 4, // хост → все: JSON Roster\n PMETA: 6, // игрок → хост → все: JSON PlayerMeta (предмет в руке, режим, имя)\n BLOCKS: 7, // владелец → хост → все: двоичные изменения блоков\n EDITS: 8, // реплика → хост → владелец: двоичные правки\n ENT_SPAWN: 9, // владелец → хост → игроки измерения: JSON { dim, list: [id, snap][] }\n ENT_REMOVE: 11, // владелец → …: двоичный список id\n ENT_ACTION: 12, // реплика → хост → владелец: JSON { dim, from, action }\n BE: 13, // владелец/реплика ↔ хост ↔ все: JSON { dim, x, y, z, be }\n WORLD: 15, // хост → все: JSON WorldState (время, погода, сложность, Предел)\n CHAT: 16, // любой → хост → все: JSON { from?, text }\n HIT: 17, // владелец → хост → игрок: JSON Hit (урон, отброс, эффект)\n GIVE: 18, // владелец → хост → игрок: JSON Give (предмет, опыт)\n PSTATE: 19, // игрок → хост: JSON { state } — сейв игрока для мира хоста и передачи хоста\n DIM_ENTER: 20, // игрок → хост: JSON { dim, handoff? } — сменил измерение\n DIM_OWN: 21, // хост → игрок: JSON { dim, save } — теперь ты владелец измерения\n DIM_RESYNC: 22, // хост → владелец: JSON { dim } — пришли всем полный снимок сущностей\n SHADOW: 24, // хост → преемник: JSON Shadow (сжатый) — теневая копия мира\n COMMAND: 25, // игрок → хост: JSON { text } — команда мира (для операторов)\n BE_LIST: 26, // хост → игрок: JSON { dim, list } — блочные сущности измерения при входе\n LOCK: 27, // игрок → хост → владелец: JSON LockMsg — занять / отпустить контейнер\n REPLY: 28, // владелец → хост → игрок: JSON ReplyMsg — ответ на LOCK, отказ сесть\n PORTAL: 29, // игрок → хост → все: JSON PortalMsg — реестр порталов\n // ---- быстрый канал (fast)\n POSE: 5, // игрок → хост → все: двоичная поза\n ENT_POSE: 10, // владелец → …: двоичные позы сущностей\n} as const;\n\n// ============================================================ JSON-сообщения\n\nexport interface Hello {\n v: number;\n /** Хэш набора блоков и предметов: разные сборки игры не совместимы. */\n build: string;\n name: string;\n}\n\nexport interface WorldMeta {\n source: WorldSource;\n palette: string[];\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n portals: unknown;\n daysAwake: number;\n /** Имя мира у хоста — переходит к новому хосту вместе с миром. Нет у старого хоста. */\n name?: string;\n}\n\nexport interface RosterEntry {\n slot: number;\n userId: string;\n name: string;\n dim: DimensionId;\n op: boolean;\n}\n\nexport interface Roster {\n hostId: string;\n players: RosterEntry[];\n /** Владелец каждого «живого» измерения. */\n owners: Partial<Record<DimensionId, string>>;\n /** Настройки комнаты («Открыть для сети»). */\n settings: RoomSettings;\n}\n\nexport interface RoomSettings {\n /** Режим, в котором входят гости. */\n guestMode: \"survival\" | \"creative\";\n /** Команды всем, а не только операторам. */\n cheatsForAll: boolean;\n}\n\nexport interface Welcome {\n v: number;\n you: number;\n world: WorldMeta;\n /** Изменённые блоки ВСЕХ измерений: у гостя полная копия — на ней держится и передача хоста. */\n diffs: Partial<Record<DimensionId, DimensionSave[\"chunks\"]>>;\n /** Где появиться и в каком измерении (у вернувшегося — там, где был). */\n spawn: { dim: DimensionId; pos: [number, number, number] };\n /** Сохранённое состояние этого игрока в мире хоста (вернулся в тот же мир). */\n state: unknown | null;\n roster: Roster;\n}\n\nexport interface PlayerMeta {\n slot?: number;\n held: string | null;\n mode: \"survival\" | \"creative\";\n name: string;\n}\n\nexport interface WorldState {\n time: unknown;\n weather: unknown;\n difficulty: string;\n beyond: unknown;\n daysAwake: number;\n}\n\nexport interface Hit {\n to: number;\n amount: number;\n cause: string;\n /** Толчок (изменение скорости), который насчитал владелец измерения. */\n knock?: [number, number, number];\n effect?: { id: string; seconds: number; level: number };\n}\n\nexport interface Give {\n to: number;\n stack?: ItemStack;\n xp?: number;\n}\n\nexport interface EntSpawnMsg {\n dim: DimensionId;\n list: [number, NetEntitySnap][];\n}\n\nexport interface EntActionMsg {\n dim: DimensionId;\n from?: number;\n action: NetEntityAction;\n}\n\n/**\n * Контейнер (сундук, печь, вьюк, вагонетка с грузом, житель при торговле) открыт — занят этим\n * игроком, пока он не закроет экран: двое, перекладывающие одно и то же, размножали бы предметы.\n * key: \"b:x,y,z\" — блочная сущность, \"e:id\" — сущность.\n */\nexport interface LockMsg {\n dim: DimensionId;\n from?: number;\n key: string;\n open: boolean;\n}\n\nexport interface ReplyMsg {\n to: number;\n kind: \"lock\" | \"ride\";\n key?: string;\n id?: number;\n ok: boolean;\n /** Кто занял (имя) — для подсказки. */\n by?: string;\n /** Блочная сущность от владельца — открыть её, а не свою (добыча постройки катится у него). */\n be?: BlockEntity | null;\n}\n\nexport interface PortalMsg {\n dim: DimensionId;\n p: [number, number, number];\n add: boolean;\n}\n\nexport interface BeMsg {\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n be: BlockEntity | null;\n}\n\nexport interface DimEnter {\n dim: DimensionId;\n /** Уходящий владелец отдаёт своё измерение: сущности и блочные сущности. */\n handoff?: { dim: DimensionId; save: DimensionSave };\n}\n\nexport interface DimOwn {\n dim: DimensionId;\n save: DimensionSave;\n}\n\nexport interface Shadow {\n world: WorldMeta;\n /** Замороженные и живые измерения хоста — с сущностями. */\n dims: Partial<Record<DimensionId, DimensionSave>>;\n players: Record<string, unknown>;\n settings: RoomSettings;\n ops: string[];\n}\n\n// ============================================================ двоичные сообщения\n\nexport const DIMS: readonly DimensionId[] = [\"overworld\", \"inferno\", \"beyond\"];\nexport const dimCode = (d: DimensionId): number => Math.max(0, DIMS.indexOf(d));\nexport const dimOf = (n: number): DimensionId => DIMS[n] ?? \"overworld\";\n\n/** Поза игрока. Флаги — вид аватара. */\nexport interface Pose {\n slot: number;\n dim: DimensionId;\n x: number;\n y: number;\n z: number;\n yaw: number;\n pitch: number;\n flags: number;\n health: number;\n}\n\nexport const POSE_SNEAK = 1,\n POSE_DEAD = 2,\n POSE_FLY = 4,\n POSE_GLIDE = 8,\n POSE_SWIM = 16,\n POSE_SWING = 32,\n POSE_CREATIVE = 64;\n\nexport function encodePose(p: Pose): Uint8Array {\n return new ByteWriter(24)\n .u8(M.POSE)\n .u8(p.slot)\n .u8(dimCode(p.dim))\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .angle(p.pitch)\n .u8(p.flags)\n .u8(Math.max(0, Math.min(255, Math.round(p.health * 10))))\n .finish();\n}\n\nexport function decodePose(msg: Uint8Array): Pose {\n const r = new ByteReader(msg.subarray(1));\n return {\n slot: r.u8(),\n dim: dimOf(r.u8()),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n pitch: r.angle(),\n flags: r.u8(),\n health: r.u8() / 10,\n };\n}\n\n/** Переписать слот позы (хост пересылает позу гостя остальным под его слотом). */\nexport function withPoseSlot(msg: Uint8Array, slot: number): Uint8Array {\n const out = msg.slice();\n out[1] = slot;\n return out;\n}\n\n/** Изменение блока: мировые координаты, id и мета. */\nexport interface BlockDelta {\n x: number;\n y: number;\n z: number;\n id: number;\n meta: number;\n}\n\n/** BLOCKS / EDITS: [тип][dim][n u16] n × [x i32][y u16][z i32][id u16][meta u8] (13 байт). */\nexport function encodeBlocks(\n type: number,\n dim: DimensionId,\n list: readonly BlockDelta[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // одно сообщение ≤ ~16 КБ, но и без того держим пачки небольшими: 1000 правок = 13 КБ\n for (let i = 0; i < list.length; i += 1000) {\n const part = list.slice(i, i + 1000);\n const w = new ByteWriter(4 + part.length * 13)\n .u8(type)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const d of part) w.i32(d.x).u16(d.y).i32(d.z).u16(d.id).u8(d.meta);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeBlocks(msg: Uint8Array): {\n dim: DimensionId;\n list: BlockDelta[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: BlockDelta[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n x: r.i32(),\n y: r.u16(),\n z: r.i32(),\n id: r.u16(),\n meta: r.u8(),\n });\n return { dim, list };\n}\n\n/** Поза сущности (владелец → игроки измерения). */\nexport interface EntPose {\n id: number;\n x: number;\n y: number;\n z: number;\n yaw: number;\n flags: number;\n extra: number;\n}\n\n/** ENT_POSE: [тип][dim][n u16] n × [id u32][x f32][y f32][z f32][yaw i16][flags u8][extra u8] (20 байт). */\nexport function encodeEntPoses(\n dim: DimensionId,\n list: readonly EntPose[],\n): Uint8Array[] {\n const out: Uint8Array[] = [];\n // быстрый канал не режет сообщения — держим каждое заметно меньше 16 КБ\n for (let i = 0; i < list.length; i += 600) {\n const part = list.slice(i, i + 600);\n const w = new ByteWriter(4 + part.length * 20)\n .u8(M.ENT_POSE)\n .u8(dimCode(dim))\n .u16(part.length);\n for (const p of part)\n w.u32(p.id)\n .f32(p.x)\n .f32(p.y)\n .f32(p.z)\n .angle(p.yaw)\n .u8(p.flags)\n .u8(p.extra);\n out.push(w.finish());\n }\n return out;\n}\n\nexport function decodeEntPoses(msg: Uint8Array): {\n dim: DimensionId;\n list: EntPose[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const list: EntPose[] = [];\n for (let i = 0; i < n; i++)\n list.push({\n id: r.u32(),\n x: r.f32(),\n y: r.f32(),\n z: r.f32(),\n yaw: r.angle(),\n flags: r.u8(),\n extra: r.u8(),\n });\n return { dim, list };\n}\n\n/** ENT_REMOVE: [тип][dim][n u16] n × [id u32]. */\nexport function encodeEntRemove(\n dim: DimensionId,\n ids: readonly number[],\n): Uint8Array {\n const w = new ByteWriter(4 + ids.length * 4)\n .u8(M.ENT_REMOVE)\n .u8(dimCode(dim))\n .u16(ids.length);\n for (const id of ids) w.u32(id);\n return w.finish();\n}\n\nexport function decodeEntRemove(msg: Uint8Array): {\n dim: DimensionId;\n ids: number[];\n} {\n const r = new ByteReader(msg.subarray(1));\n const dim = dimOf(r.u8());\n const n = r.u16();\n const ids: number[] = [];\n for (let i = 0; i < n; i++) ids.push(r.u32());\n return { dim, ids };\n}\n\n/**\n * Сборка игры: хэш имён блоков и предметов по порядку. У двух игроков с разными версиями id блоков\n * разошлись бы — один увидел бы на месте сундука печь.\n */\nexport function buildHash(\n blockNames: readonly string[],\n itemNames: readonly string[],\n): string {\n let h = 0x811c9dc5;\n for (const s of [...blockNames, \"|\", ...itemNames])\n for (let i = 0; i < s.length; i++) {\n h ^= s.charCodeAt(i);\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return h.toString(16);\n}\n"
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
"path": "net/RemotePlayer.ts",
|
|
680
|
+
"content": "// Другой игрок сетевой игры — у ВЛАДЕЛЬЦА измерения. Мобы, стрелы, взрывы и подбор работают с ним\n// как с обычным Player (EntityManager.others), но состояние игрока — у самого игрока (вариант А):\n// урон, отброс, эффекты и опыт не применяются здесь, а уходят ему по сети; подобранный предмет\n// «выдаётся» ему, а не кладётся в чужой инвентарь.\nimport * as THREE from \"three\";\nimport { Player } from \"../player/Player\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EffectId, ItemStack } from \"../types\";\nimport { POSE_CREATIVE, POSE_DEAD, POSE_SNEAK, type Pose } from \"./protocol\";\n\n/** Куда уходит то, что с удалённым игроком случилось у владельца измерения. */\nexport interface RemoteSink {\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: { id: string; seconds: number; level: number },\n ): void;\n give(slot: number, stack: ItemStack | null, xp?: number): void;\n}\n\nexport class RemotePlayer extends Player {\n readonly slot: number;\n readonly userId: string;\n /** Предмет в руке (PlayerMeta) — для мобов, которые идут за едой в руке. */\n held: string | null = null;\n /** Скорость из последней позы: всё, что к ней добавили за тик (толчки), уходит игроку. */\n private readonly velBase = new THREE.Vector3();\n\n constructor(\n world: ChunkManager,\n slot: number,\n userId: string,\n private readonly sink: RemoteSink,\n ) {\n super(world);\n this.slot = slot;\n this.userId = userId;\n }\n\n applyPose(p: Pose): void {\n this.pos.set(p.x, p.y, p.z);\n this.yaw = p.yaw;\n this.pitch = p.pitch;\n this.dead = (p.flags & POSE_DEAD) !== 0;\n this._sneaking = (p.flags & POSE_SNEAK) !== 0;\n this.mode = p.flags & POSE_CREATIVE ? \"creative\" : \"survival\";\n this.health = p.health;\n this.vel.set(0, 0, 0);\n this.velBase.set(0, 0, 0);\n }\n\n /** Урон — игроку: он сам решит про щит, броню, тотем и смерть. */\n override damage(amount: number, cause: string): void {\n if (this.dead || this.mode === \"creative\" || amount <= 0) return;\n this.sink.hit(this.slot, amount, cause);\n }\n\n override 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 }\n\n override addEffect(id: EffectId, seconds: number, level = 1): void {\n this.sink.hit(this.slot, 0, \"effect\", undefined, { id, seconds, level });\n }\n\n override addXp(amount: number): void {\n if (amount > 0) this.sink.give(this.slot, null, amount);\n }\n\n /** Щит решает сам игрок (у него инвентарь): здесь не блокирует ничего. */\n override shieldBlocks(): boolean {\n return false;\n }\n\n /**\n * Толчок, накопленный за тик (knock, взрыв добавили к vel): отдать игроку и обнулить. Зовёт\n * NetGame после тика сущностей.\n */\n takeImpulse(): [number, number, number] | null {\n const dx = this.vel.x - this.velBase.x,\n dy = this.vel.y - this.velBase.y,\n dz = this.vel.z - this.velBase.z;\n this.vel.copy(this.velBase);\n if (dx * dx + dy * dy + dz * dz < 0.01) return null;\n return [dx, dy, dz];\n }\n}\n\n/**\n * Инвентарь удалённого игрока у владельца измерения: подобранное уходит игроку. Считается, что\n * место есть — если нет, игрок сам выбросит лишнее у себя (его инвентарь авторитетен).\n */\nexport function remoteInventory(p: RemotePlayer, sink: RemoteSink): Inventory {\n const inv = {\n add(item: string, count: number): number {\n sink.give(p.slot, { item, count });\n return 0;\n },\n addStack(s: ItemStack): number {\n sink.give(p.slot, { ...s });\n return 0;\n },\n heldItem(): ItemStack | null {\n return p.held ? { item: p.held, count: 1 } : null;\n },\n slots: [] as (ItemStack | null)[],\n armor: [] as (ItemStack | null)[],\n };\n return inv as unknown as Inventory;\n}\n\n// ============================================================ аватар\n\nconst SKIN = 0xc69c7c,\n SHIRT = 0x3a8fd6,\n PANTS = 0x2c3e6b,\n HAIR = 0x4a2f1b;\n\nfunction box(w: number, h: number, d: number, color: number): THREE.Mesh {\n const m = new THREE.Mesh(\n new THREE.BoxGeometry(w, h, d),\n new THREE.MeshLambertMaterial({ color }),\n );\n m.castShadow = true;\n return m;\n}\n\n/** Табличка с именем над головой: канвас-текстура на спрайте. */\nfunction nameplate(text: string): THREE.Sprite {\n const c = document.createElement(\"canvas\");\n c.width = 256;\n c.height = 48;\n const g = c.getContext(\"2d\");\n if (g) {\n g.fillStyle = \"rgba(0,0,0,0.45)\";\n g.fillRect(0, 0, 256, 48);\n g.font = \"bold 28px sans-serif\";\n g.fillStyle = \"#fff\";\n g.textAlign = \"center\";\n g.textBaseline = \"middle\";\n g.fillText(text.slice(0, 18), 128, 25);\n }\n const tex = new THREE.CanvasTexture(c);\n const s = new THREE.Sprite(\n new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }),\n );\n s.scale.set(1.6, 0.3, 1);\n s.position.y = 2.15;\n s.renderOrder = 10;\n return s;\n}\n\n/**\n * Модель другого игрока в духе Minecraft: голова, тело, руки, ноги из кубов. Ходьба — качание ног\n * по пройденному пути, замах — правой рукой, присед — наклон, смерть — лёжа.\n */\nexport class Avatar {\n readonly group = new THREE.Group();\n private readonly head: THREE.Group;\n private readonly armL: THREE.Group;\n private readonly armR: THREE.Group;\n private readonly legL: THREE.Group;\n private readonly legR: THREE.Group;\n private readonly body: THREE.Group;\n private walk = 0;\n private swing = 0;\n private readonly from = new THREE.Vector3();\n private readonly to = new THREE.Vector3();\n private yawFrom = 0;\n private yawTo = 0;\n private pitch = 0;\n private t = 1;\n private flags = 0;\n private label: THREE.Sprite;\n\n constructor(name: string) {\n const limb = (color: number, x: number, y: number): THREE.Group => {\n const g = new THREE.Group();\n const m = box(0.25, 0.75, 0.25, color);\n m.position.y = -0.375;\n g.add(m);\n g.position.set(x, y, 0);\n return g;\n };\n this.body = new THREE.Group();\n const torso = box(0.5, 0.75, 0.25, SHIRT);\n torso.position.y = 1.125;\n this.body.add(torso);\n this.head = new THREE.Group();\n const skull = box(0.5, 0.5, 0.5, SKIN);\n skull.position.y = 0.25;\n const hair = box(0.52, 0.14, 0.52, HAIR);\n hair.position.y = 0.46;\n this.head.add(skull, hair);\n this.head.position.y = 1.5;\n this.armL = limb(SHIRT, -0.375, 1.5);\n this.armR = limb(SHIRT, 0.375, 1.5);\n this.legL = limb(PANTS, -0.125, 0.75);\n this.legR = limb(PANTS, 0.125, 0.75);\n this.body.add(this.head, this.armL, this.armR, this.legL, this.legR);\n this.label = nameplate(name);\n this.group.add(this.body, this.label);\n }\n\n setName(name: string): void {\n this.group.remove(this.label);\n this.label.material.map?.dispose();\n this.label.material.dispose();\n this.label = nameplate(name);\n this.group.add(this.label);\n }\n\n /** Новая поза из сети: от текущего положения к новому за ~один интервал поз. */\n target(p: Pose): void {\n this.from.copy(this.group.position);\n this.to.set(p.x, p.y, p.z);\n this.yawFrom = this.group.rotation.y;\n this.yawTo = p.yaw;\n this.pitch = p.pitch;\n this.flags = p.flags;\n if (p.flags & 32) this.swing = 0.3;\n // первая поза или телепорт — без плавания через полкарты\n if (this.t >= 1 && this.from.distanceToSquared(this.to) > 64)\n this.from.copy(this.to);\n this.t = 0;\n }\n\n frame(dt: number): void {\n this.t = Math.min(1, this.t + dt / 0.06);\n const prev = this.group.position.clone();\n this.group.position.lerpVectors(this.from, this.to, this.t);\n let dy = this.yawTo - this.yawFrom;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n this.group.rotation.y = this.yawFrom + dy * this.t;\n this.head.rotation.x = this.pitch;\n const moved = Math.hypot(\n this.group.position.x - prev.x,\n this.group.position.z - prev.z,\n );\n this.walk += moved * 4;\n const a = Math.sin(this.walk) * Math.min(1, moved * 20) * 0.8;\n this.legL.rotation.x = a;\n this.legR.rotation.x = -a;\n this.armL.rotation.x = -a * 0.6;\n this.swing = Math.max(0, this.swing - dt);\n this.armR.rotation.x =\n a * 0.6 -\n (this.swing > 0 ? Math.sin((this.swing / 0.3) * Math.PI) * 1.4 : 0);\n const sneak = (this.flags & POSE_SNEAK) !== 0;\n this.body.rotation.x = sneak ? 0.35 : 0;\n this.body.position.y = sneak ? -0.2 : 0;\n const dead = (this.flags & POSE_DEAD) !== 0;\n this.body.rotation.z = dead ? Math.PI / 2 : 0;\n this.label.visible = !dead;\n }\n\n dispose(scene: THREE.Object3D): void {\n scene.remove(this.group);\n this.group.traverse((o) => {\n if (o instanceof THREE.Mesh) {\n o.geometry.dispose();\n (o.material as THREE.Material).dispose();\n }\n });\n this.label.material.map?.dispose();\n this.label.material.dispose();\n }\n}\n"
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
"path": "net/sim.ts",
|
|
684
|
+
"content": "// Кто считает мир. В одиночной игре — всегда мы. В сетевой — у каждого измерения ровно один\n// «владелец» (хост, если он там; иначе первый вошедший игрок): он крутит симуляцию измерения\n// (мобы, жидкости, опоры блоков, редстоун, случайные тики) и рассылает изменения. Остальные в этом\n// измерении — РЕПЛИКИ: их мир — копия, последствия правок считает владелец.\n//\n// Флаг глобальный на процесс, как и шина событий: в одной вкладке живёт одна игра. Системы, которые\n// слушают события мира и сами меняют мир (BlockUpdates, FluidSim), у реплики молчат — иначе каскад\n// посчитался бы дважды: у реплики и у владельца, и разошёлся бы (в каскадах есть случайность).\n\nexport const sim = {\n /** true — этот процесс считает своё измерение (одиночная игра, хост, владелец измерения). */\n authority: true,\n};\n"
|
|
644
685
|
},
|
|
645
686
|
{
|
|
646
687
|
"path": "physics/Collision.test.ts",
|
|
@@ -656,7 +697,7 @@
|
|
|
656
697
|
},
|
|
657
698
|
{
|
|
658
699
|
"path": "player/ArmView.ts",
|
|
659
|
-
"content": "// Рука от первого лица: блочная рука в стиле Minecraft, прикреплена к камере.\n// Если в руке блок — вместо руки показывается мини-куб блока; плоские предметы —\n// квад с иконкой. Замах — процедурная анимация по событию armSwing.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\n\n/**\n * Слой рендера руки. Рука рисуется ВТОРЫМ проходом поверх мира с очищенным depth (см. Game.frame):\n * в общем depth-буфере она висит в 0.7 м перед глазом и, стоя вплотную к блоку, целиком тонула\n * в нём — замах при добыче было просто не видно.\n */\nexport const ARM_LAYER = 1;\n\nexport class ArmView {\n camera: THREE.PerspectiveCamera;\n atlas: TextureAtlas;\n group: THREE.Group;\n armMesh: THREE.Mesh;\n itemMesh: THREE.Mesh | null;\n _heldKey: string | null;\n _swing: number;\n _matCache: Map<string, THREE.MeshBasicMaterial>;\n\n constructor(camera: THREE.PerspectiveCamera, atlas: TextureAtlas) {\n this.camera = camera;\n this.atlas = atlas;\n this.group = new THREE.Group();\n camera.add(this.group);\n this.group.position.set(0.42, -0.42, -0.7);\n\n // сама рука: «кожа» с рукавом\n const armMat = new THREE.MeshLambertMaterial({ color: 0xd8a06c });\n this.armMesh = new THREE.Mesh(\n new THREE.BoxGeometry(0.14, 0.14, 0.5),\n armMat,\n );\n this.armMesh.rotation.set(0.35, -0.25, 0);\n this.armMesh.layers.set(ARM_LAYER);\n this.group.add(this.armMesh);\n\n this.itemMesh = null; // мини-блок/иконка в руке\n this._heldKey = null;\n this._swing = 0; // 0..1 — фаза замаха\n // Материал плоского предмета — один на предмет: пересоздавать его на каждую смену слота\n // значило бы копить неосвобождённые материалы.\n this._matCache = new Map();\n\n events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const key = held ? held.item : null;\n // Меш зависит только от того, КАКОЙ предмет в руке: смена количества (подбор, постановка\n // блока) пересборки не требует.\n if (key === this._heldKey) return;\n this._heldKey = key;\n\n if (this.itemMesh) {\n this.group.remove(this.itemMesh);\n this.itemMesh.geometry.dispose();\n this.itemMesh = null;\n }\n this.armMesh.visible = !
|
|
700
|
+
"content": "// Рука от первого лица: блочная рука в стиле Minecraft, прикреплена к камере.\n// Если в руке блок — вместо руки показывается мини-куб блока; плоские предметы —\n// квад с иконкой. Замах — процедурная анимация по событию armSwing.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\n\n/**\n * Слой рендера руки. Рука рисуется ВТОРЫМ проходом поверх мира с очищенным depth (см. Game.frame):\n * в общем depth-буфере она висит в 0.7 м перед глазом и, стоя вплотную к блоку, целиком тонула\n * в нём — замах при добыче было просто не видно.\n */\nexport const ARM_LAYER = 1;\n\nexport class ArmView {\n camera: THREE.PerspectiveCamera;\n atlas: TextureAtlas;\n group: THREE.Group;\n armMesh: THREE.Mesh;\n itemMesh: THREE.Mesh | null;\n _heldKey: string | null;\n _swing: number;\n _matCache: Map<string, THREE.MeshBasicMaterial>;\n\n constructor(camera: THREE.PerspectiveCamera, atlas: TextureAtlas) {\n this.camera = camera;\n this.atlas = atlas;\n this.group = new THREE.Group();\n camera.add(this.group);\n this.group.position.set(0.42, -0.42, -0.7);\n\n // сама рука: «кожа» с рукавом\n const armMat = new THREE.MeshLambertMaterial({ color: 0xd8a06c });\n this.armMesh = new THREE.Mesh(\n new THREE.BoxGeometry(0.14, 0.14, 0.5),\n armMat,\n );\n this.armMesh.rotation.set(0.35, -0.25, 0);\n this.armMesh.layers.set(ARM_LAYER);\n this.group.add(this.armMesh);\n\n this.itemMesh = null; // мини-блок/иконка в руке\n this._heldKey = null;\n this._swing = 0; // 0..1 — фаза замаха\n // Материал плоского предмета — один на предмет: пересоздавать его на каждую смену слота\n // значило бы копить неосвобождённые материалы.\n this._matCache = new Map();\n\n events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const key = held ? held.item : null;\n // Меш зависит только от того, КАКОЙ предмет в руке: смена количества (подбор, постановка\n // блока) пересборки не требует.\n if (key === this._heldKey) return;\n this._heldKey = key;\n\n if (this.itemMesh) {\n this.group.remove(this.itemMesh);\n this.itemMesh.geometry.dispose();\n this.itemMesh = null;\n }\n // неизвестный предмет (из сундука чужого сейва) — просто пустая рука, а не падение кадра\n const item = key ? ITEMS[key] : undefined;\n this.armMesh.visible = !item;\n if (!key || !item) return;\n\n if (item.blockId !== null && BLOCKS[item.blockId]!.shape === \"cube\") {\n const def = BLOCKS[item.blockId]!;\n const geo = new THREE.BoxGeometry(0.3, 0.3, 0.3);\n const uvAttr = geo.getAttribute(\"uv\");\n const tiles = [\n def.tex!.side!,\n def.tex!.side!,\n def.tex!.top!,\n def.tex!.bottom!,\n def.tex!.side!,\n def.tex!.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = this.atlas.uv(tiles[f]!);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // белые вершинные цвета: общий материал чанков ждёт color-атрибут (vertexColors)\n geo.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(geo.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n this.itemMesh = new THREE.Mesh(geo, this.atlas.opaqueMat);\n this.itemMesh.position.set(0, -0.02, -0.1);\n this.itemMesh.rotation.set(0.1, Math.PI / 5, 0);\n } else {\n let mat = this._matCache.get(key);\n if (!mat) {\n const tex = new THREE.CanvasTexture(item.icon);\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshBasicMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n this._matCache.set(key, mat);\n }\n this.itemMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.4, 0.4), mat);\n this.itemMesh.position.set(0, 0, -0.1);\n this.itemMesh.rotation.set(-0.15, Math.PI / 6, 0.5);\n }\n this.itemMesh.layers.set(ARM_LAYER);\n this.group.add(this.itemMesh);\n }\n\n frame(\n dt: number,\n inventory: Inventory,\n walking: boolean,\n time: number,\n ): void {\n this._syncHeld(inventory);\n\n // замах: быстрый удар вперёд-вниз с возвратом\n if (this._swing > 0)\n this._swing = Math.max(0, this._swing - dt / CONFIG.player.swingTime);\n const s = this._swing;\n const swingAng = Math.sin(s * Math.PI) * 1.1;\n\n // лёгкое покачивание при ходьбе\n const bobX = walking ? Math.sin(time * 7) * 0.015 : 0;\n const bobY = walking ? Math.abs(Math.cos(time * 7)) * 0.02 : 0;\n\n this.group.position.set(\n 0.42 + bobX,\n -0.42 + bobY - swingAng * 0.25,\n -0.7 - swingAng * 0.1,\n );\n this.group.rotation.set(-swingAng * 0.9, swingAng * 0.35, 0);\n }\n}\n"
|
|
660
701
|
},
|
|
661
702
|
{
|
|
662
703
|
"path": "player/Glide.ts",
|
|
@@ -664,11 +705,11 @@
|
|
|
664
705
|
},
|
|
665
706
|
{
|
|
666
707
|
"path": "player/Input.ts",
|
|
667
|
-
"content": "// Ввод: Pointer Lock, клавиши, мышь. НИКОГДА не трогает мир напрямую —\n// дискретные действия уходят интентами в очередь, непрерывные состояния\n// (WASD, зажатые кнопки мыши, взгляд) читает Player/Interaction на кадре.\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentQueue } from \"../core/Intents\";\n\n/**\n * Клавиатура слушается на всём document, а игра живёт в хосте рядом с чужими модулями: набор\n * текста в их полях (чат, форма) не должен открывать инвентарь или переключать слоты.\n */\nfunction isEditableTarget(target: EventTarget | null): boolean {\n if (!(target instanceof HTMLElement)) return false;\n return (\n target.isContentEditable ||\n target instanceof HTMLInputElement ||\n target instanceof HTMLTextAreaElement ||\n target instanceof HTMLSelectElement\n );\n}\n\nexport class Input {\n canvas: HTMLCanvasElement;\n intents: IntentQueue;\n keys: Set<string>;\n leftDown: boolean;\n rightDown: boolean;\n dx: number;\n dy: number;\n locked: boolean;\n uiOpen: boolean;\n _lastSpace: number;\n /** Двойной W — бег до отпускания W. */\n sprintLatch = false;\n _lastW = 0;\n\n /**\n * Управление без захвата указателя.\n *\n * Pointer Lock есть не везде: в кросс-доменном iframe (превью AI-кодера, встраивание игры на\n * сайте) браузер его запрещает. Раньше это означало, что играть НЕЛЬЗЯ ВООБЩЕ — весь ввод был\n * закрыт проверкой `locked`, а `locked` без API никогда не становился true: игра шла, а клавиши\n * и мышь молчали.\n *\n * Поэтому здесь тот же режим, но своими силами: клик по канвасу «берёт управление»\n * (`softLocked`), Esc отпускает. Мышь двигает камеру без захвата — курсор при этом виден и\n * может уйти за пределы канваса, зато играть можно.\n */\n softLocked: boolean;\n\n /**\n * Слушает ли игра клавиатуру вообще. Выключается, когда роутер режимов хоста приостанавливает\n * сцену (`Game.setRunning(false)`): обработчики висят на document, и без этого `E` открывал бы\n * инвентарь невидимой игры, пока игрок в другом модуле.\n */\n enabled: boolean;\n\n /** Есть ли в этом окружении настоящий Pointer Lock. Если да — мягкий режим не нужен. */\n readonly pointerLockAvailable: boolean;\n\n /** @param signal снимает все подписки разом в `Game.destroy()` */\n constructor(\n canvas: HTMLCanvasElement,\n intents: IntentQueue,\n signal: AbortSignal,\n ) {\n this.canvas = canvas;\n this.intents = intents;\n this.keys = new Set(); // KeyboardEvent.code\n this.leftDown = false;\n this.rightDown = false;\n this.dx = 0;\n this.dy = 0; // накопленное движение мыши за кадр\n this.locked = false;\n this.uiOpen = false; // инвентарь открыт — игровой ввод заморожен\n this._lastSpace = 0; // двойной Space → полёт в креативе\n this.softLocked = false;\n this.enabled = true;\n this.pointerLockAvailable =\n typeof canvas.requestPointerLock === \"function\" &&\n typeof document.exitPointerLock === \"function\";\n const opts = { signal };\n\n document.addEventListener(\n \"pointerlockchange\",\n () => {\n this.locked = document.pointerLockElement === canvas;\n if (!this.locked && !this.uiOpen) events.emit(\"ui:pause\");\n if (this.locked) events.emit(\"ui:resume\");\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n },\n opts,\n );\n\n canvas.addEventListener(\n \"mousemove\",\n (e) => {\n if (!this.active) return;\n // Без захвата курсор реальный: он может уйти с канваса и вернуться, и тогда браузер отдаёт\n // одну гигантскую дельту — камера дёрнулась бы на пол-оборота. Рывки отбрасываем.\n if (\n !this.locked &&\n (Math.abs(e.movementX) > 120 || Math.abs(e.movementY) > 120)\n )\n return;\n this.dx += e.movementX;\n this.dy += e.movementY;\n },\n opts,\n );\n\n canvas.addEventListener(\n \"mousedown\",\n (e) => {\n // Первый клик по канвасу в мягком режиме — это «взять управление», а не удар киркой.\n if (!this.locked && !this.softLocked && !this.uiOpen) {\n this.takeControl();\n return;\n }\n if (!this.active) return;\n if (e.button === 0) this.leftDown = true;\n if (e.button === 2) this.rightDown = true;\n if (e.button === 1) {\n e.preventDefault(); // без автопрокрутки браузера\n this.intents.push(Intent.PickBlock);\n }\n },\n opts,\n );\n // Отпускание — на window, а не на канвасе: без захвата курсор может уйти с канваса, и\n // отпущенная там кнопка до канваса не дошла бы — ломание/атака шли бы бесконечно.\n window.addEventListener(\n \"mouseup\",\n (e) => {\n if (e.button === 0) this.leftDown = false;\n if (e.button === 2) this.rightDown = false;\n },\n opts,\n );\n canvas.addEventListener(\"contextmenu\", (e) => e.preventDefault(), opts);\n\n canvas.addEventListener(\n \"wheel\",\n (e) => {\n if (!this.active) return;\n this.intents.push(Intent.SelectSlot, { delta: Math.sign(e.deltaY) });\n e.preventDefault();\n },\n { passive: false, signal },\n );\n\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.repeat || !this.enabled || isEditableTarget(e.target)) return;\n // E работает и без pointer lock (закрыть инвентарь)\n if (e.code === \"KeyE\") {\n events.emit(\"ui:toggleInventory\");\n return;\n }\n // Esc в мягком режиме играет роль выхода из захвата: браузер сам его не отдаст.\n if (e.code === \"Escape\" && this.softLocked) {\n this.releaseControl();\n return;\n }\n if (!this.active) return;\n this.keys.add(e.code);\n if (e.code === \"KeyW\") {\n const now = performance.now();\n if (now - this._lastW < 300) this.sprintLatch = true;\n this._lastW = now;\n }\n if (e.code.startsWith(\"Digit\")) {\n const n = +e.code.slice(5);\n if (n >= 1 && n <= 9)\n this.intents.push(Intent.SelectSlot, { slot: n - 1 });\n }\n if (e.code === \"KeyC\") this.intents.push(Intent.ToggleMode);\n // L — список достижений, как в Minecraft\n if (e.code === \"KeyL\") events.emit(\"ui:achievements\");\n // Q — выбросить предмет из руки (с Ctrl — весь стак), как в Minecraft\n if (e.code === \"KeyQ\")\n this.intents.push(Intent.DropItem, { all: e.ctrlKey });\n // чат/консоль: символ клавиши не должен попасть в строку — гасим его\n if (e.code === \"KeyT\" || e.code === \"Slash\") {\n e.preventDefault();\n this.keys.delete(e.code);\n events.emit(\"ui:openConsole\", {\n prefix: e.code === \"Slash\" ? \"/\" : \"\",\n });\n }\n if (e.code === \"Space\") {\n const now = performance.now();\n if (now - this._lastSpace < 300) events.emit(\"input:doubleSpace\");\n this._lastSpace = now;\n }\n },\n opts,\n );\n document.addEventListener(\n \"keyup\",\n (e) => {\n this.keys.delete(e.code);\n if (e.code === \"KeyW\") this.sprintLatch = false;\n },\n opts,\n );\n // Окно потеряло фокус (alt-tab, клик в другой фрейм): keyup/mouseup сюда уже не придут —\n // без сброса персонаж шёл бы сам и ломал блоки дальше.\n window.addEventListener(\n \"blur\",\n () => {\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n },\n opts,\n );\n }\n\n /** Читает ли игра ввод прямо сейчас: настоящий захват ИЛИ мягкий режим. */\n get active(): boolean {\n return (this.locked || this.softLocked) && !this.uiOpen;\n }\n\n /** Взять управление без Pointer Lock (мягкий режим) и снять паузу. */\n takeControl(): void {\n if (this.softLocked) return;\n this.softLocked = true;\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n this.dx = this.dy = 0;\n events.emit(\"ui:resume\");\n }\n\n /** Отпустить управление в мягком режиме — аналог выхода из захвата по Esc. */\n releaseControl(): void {\n if (!this.softLocked) return;\n this.softLocked = false;\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n if (!this.uiOpen) events.emit(\"ui:pause\");\n }\n\n /** Захват мыши — только из user gesture (клик по оверлею). */\n requestLock(): void {\n // Там, где Pointer Lock запрещён (кросс-доменный iframe: превью AI-кодера, встраивание игры\n // на сайте), играем без него. Раньше здесь был тихий `return` — и игра оставалась\n // неуправляемой навсегда: `locked` без API не становится true никогда.\n if (!this.pointerLockAvailable) {\n this.takeControl();\n return;\n }\n try {\n // Браузер может бросить, если Esc был нажат <1.2 c назад — тихо игнорируем.\n this.canvas.requestPointerLock()?.catch?.(() => {\n // Обещание отклонено (нет user gesture, политика фрейма) — не оставляем игрока без\n // управления, а переходим в мягкий режим.\n this.takeControl();\n });\n } catch {\n this.takeControl();\n }\n }\n releaseLock(): void {\n if (this.locked && typeof document.exitPointerLock === \"function\") {\n document.exitPointerLock();\n }\n this.releaseControl();\n }\n\n /** Забрать накопленную дельту мыши (вызывается раз в кадр). */\n consumeMouse(): { dx: number; dy: number } {\n const d = { dx: this.dx, dy: this.dy };\n this.dx = this.dy = 0;\n return d;\n }\n\n get forward(): boolean {\n return this.keys.has(\"KeyW\");\n }\n get back(): boolean {\n return this.keys.has(\"KeyS\");\n }\n get left(): boolean {\n return this.keys.has(\"KeyA\");\n }\n get right(): boolean {\n return this.keys.has(\"KeyD\");\n }\n get jump(): boolean {\n return this.keys.has(\"Space\");\n }\n /** Shift: присесть; в полёте — вниз. */\n get sneakOrDown(): boolean {\n return this.keys.has(\"ShiftLeft\") || this.keys.has(\"ShiftRight\");\n }\n get sneak(): boolean {\n return this.sneakOrDown;\n }\n /**\n * Бег — двойное нажатие W (держится, пока W зажата). Не Ctrl, как в Minecraft: в браузере Ctrl+W\n * закрывает вкладку. Ctrl, нажатый отдельно, тоже работает.\n */\n get run(): boolean {\n return (\n this.sprintLatch ||\n this.keys.has(\"ControlLeft\") ||\n this.keys.has(\"ControlRight\")\n );\n }\n}\n"
|
|
708
|
+
"content": "// Ввод: Pointer Lock, клавиши, мышь. НИКОГДА не трогает мир напрямую —\n// дискретные действия уходят интентами в очередь, непрерывные состояния\n// (WASD, зажатые кнопки мыши, взгляд) читает Player/Interaction на кадре.\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentQueue } from \"../core/Intents\";\n\n/** Присед (и спуск в полёте): Ctrl. Сенсорная кнопка «присесть» жмёт первую из них. */\nexport const SNEAK_KEYS = [\"ControlLeft\", \"ControlRight\"] as const;\n/** Бег: Shift (и двойное нажатие W). */\nexport const RUN_KEYS = [\"ShiftLeft\", \"ShiftRight\"] as const;\n\n/**\n * Клавиатура слушается на всём document, а игра живёт в хосте рядом с чужими модулями: набор\n * текста в их полях (чат, форма) не должен открывать инвентарь или переключать слоты.\n */\nfunction isEditableTarget(target: EventTarget | null): boolean {\n if (!(target instanceof HTMLElement)) return false;\n return (\n target.isContentEditable ||\n target instanceof HTMLInputElement ||\n target instanceof HTMLTextAreaElement ||\n target instanceof HTMLSelectElement\n );\n}\n\nexport class Input {\n canvas: HTMLCanvasElement;\n intents: IntentQueue;\n keys: Set<string>;\n leftDown: boolean;\n rightDown: boolean;\n dx: number;\n dy: number;\n locked: boolean;\n uiOpen: boolean;\n _lastSpace: number;\n /** Двойной W — бег до отпускания W. */\n sprintLatch = false;\n _lastW = 0;\n\n /**\n * Управление без захвата указателя.\n *\n * Pointer Lock есть не везде: в кросс-доменном iframe (превью AI-кодера, встраивание игры на\n * сайте) браузер его запрещает. Раньше это означало, что играть НЕЛЬЗЯ ВООБЩЕ — весь ввод был\n * закрыт проверкой `locked`, а `locked` без API никогда не становился true: игра шла, а клавиши\n * и мышь молчали.\n *\n * Поэтому здесь тот же режим, но своими силами: клик по канвасу «берёт управление»\n * (`softLocked`), Esc отпускает. Мышь двигает камеру без захвата — курсор при этом виден и\n * может уйти за пределы канваса, зато играть можно.\n */\n softLocked: boolean;\n\n /**\n * Слушает ли игра клавиатуру вообще. Выключается, когда роутер режимов хоста приостанавливает\n * сцену (`Game.setRunning(false)`): обработчики висят на document, и без этого `E` открывал бы\n * инвентарь невидимой игры, пока игрок в другом модуле.\n */\n enabled: boolean;\n\n /** Есть ли в этом окружении настоящий Pointer Lock. Если да — мягкий режим не нужен. */\n readonly pointerLockAvailable: boolean;\n\n /** @param signal снимает все подписки разом в `Game.destroy()` */\n constructor(\n canvas: HTMLCanvasElement,\n intents: IntentQueue,\n signal: AbortSignal,\n ) {\n this.canvas = canvas;\n this.intents = intents;\n this.keys = new Set(); // KeyboardEvent.code\n this.leftDown = false;\n this.rightDown = false;\n this.dx = 0;\n this.dy = 0; // накопленное движение мыши за кадр\n this.locked = false;\n this.uiOpen = false; // инвентарь открыт — игровой ввод заморожен\n this._lastSpace = 0; // двойной Space → полёт в креативе\n this.softLocked = false;\n this.enabled = true;\n this.pointerLockAvailable =\n typeof canvas.requestPointerLock === \"function\" &&\n typeof document.exitPointerLock === \"function\";\n const opts = { signal };\n\n document.addEventListener(\n \"pointerlockchange\",\n () => {\n this.locked = document.pointerLockElement === canvas;\n if (!this.locked && !this.uiOpen) events.emit(\"ui:pause\");\n if (this.locked) events.emit(\"ui:resume\");\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n },\n opts,\n );\n\n canvas.addEventListener(\n \"mousemove\",\n (e) => {\n if (!this.active) return;\n // Без захвата курсор реальный: он может уйти с канваса и вернуться, и тогда браузер отдаёт\n // одну гигантскую дельту — камера дёрнулась бы на пол-оборота. Рывки отбрасываем.\n if (\n !this.locked &&\n (Math.abs(e.movementX) > 120 || Math.abs(e.movementY) > 120)\n )\n return;\n this.dx += e.movementX;\n this.dy += e.movementY;\n },\n opts,\n );\n\n canvas.addEventListener(\n \"mousedown\",\n (e) => {\n // Первый клик по канвасу в мягком режиме — это «взять управление», а не удар киркой.\n if (!this.locked && !this.softLocked && !this.uiOpen) {\n this.takeControl();\n return;\n }\n if (!this.active) return;\n if (e.button === 0) this.leftDown = true;\n if (e.button === 2) this.rightDown = true;\n if (e.button === 1) {\n e.preventDefault(); // без автопрокрутки браузера\n this.intents.push(Intent.PickBlock);\n }\n },\n opts,\n );\n // Отпускание — на window, а не на канвасе: без захвата курсор может уйти с канваса, и\n // отпущенная там кнопка до канваса не дошла бы — ломание/атака шли бы бесконечно.\n window.addEventListener(\n \"mouseup\",\n (e) => {\n if (e.button === 0) this.leftDown = false;\n if (e.button === 2) this.rightDown = false;\n },\n opts,\n );\n canvas.addEventListener(\"contextmenu\", (e) => e.preventDefault(), opts);\n\n canvas.addEventListener(\n \"wheel\",\n (e) => {\n if (!this.active) return;\n this.intents.push(Intent.SelectSlot, { delta: Math.sign(e.deltaY) });\n e.preventDefault();\n },\n { passive: false, signal },\n );\n\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.repeat || !this.enabled || isEditableTarget(e.target)) return;\n // E работает и без pointer lock (закрыть инвентарь)\n if (e.code === \"KeyE\") {\n events.emit(\"ui:toggleInventory\");\n return;\n }\n // Esc в мягком режиме играет роль выхода из захвата: браузер сам его не отдаст.\n if (e.code === \"Escape\" && this.softLocked) {\n this.releaseControl();\n return;\n }\n if (!this.active) return;\n // Ctrl — присед: его сочетания браузера (Ctrl+S, Ctrl+D, Ctrl+F…) в игре гасим. Ctrl+W,\n // Ctrl+T, Ctrl+N браузер отменить не даёт — от них защита beforeunload ниже.\n if (e.ctrlKey) e.preventDefault();\n this.keys.add(e.code);\n if (e.code === \"KeyW\") {\n const now = performance.now();\n if (now - this._lastW < 300) this.sprintLatch = true;\n this._lastW = now;\n }\n if (e.code.startsWith(\"Digit\")) {\n const n = +e.code.slice(5);\n if (n >= 1 && n <= 9)\n this.intents.push(Intent.SelectSlot, { slot: n - 1 });\n }\n if (e.code === \"KeyC\") this.intents.push(Intent.ToggleMode);\n // L — список достижений, как в Minecraft\n if (e.code === \"KeyL\") events.emit(\"ui:achievements\");\n // Q — выбросить предмет из руки (с Ctrl — весь стак), как в Minecraft\n if (e.code === \"KeyQ\")\n this.intents.push(Intent.DropItem, { all: e.ctrlKey });\n // чат/консоль: символ клавиши не должен попасть в строку — гасим его\n if (e.code === \"KeyT\" || e.code === \"Slash\") {\n e.preventDefault();\n this.keys.delete(e.code);\n events.emit(\"ui:openConsole\", {\n prefix: e.code === \"Slash\" ? \"/\" : \"\",\n });\n }\n if (e.code === \"Space\") {\n const now = performance.now();\n if (now - this._lastSpace < 300) events.emit(\"input:doubleSpace\");\n this._lastSpace = now;\n }\n },\n opts,\n );\n document.addEventListener(\n \"keyup\",\n (e) => {\n this.keys.delete(e.code);\n if (e.code === \"KeyW\") this.sprintLatch = false;\n },\n opts,\n );\n // Присед на Ctrl: «присесть и идти» = Ctrl+W, а это браузер закрывает вкладку, и отменить его\n // игра не может. Пока идёт игра (ввод захвачен и окно в фокусе), браузер спросит «Покинуть\n // сайт?» вместо молчаливого закрытия. Без фокуса — нет: превью AI-кодера перезагружает фрейм,\n // пока издатель пишет в чат рядом, и вопрос там только мешал бы.\n window.addEventListener(\n \"beforeunload\",\n (e) => {\n if (!this.active || !document.hasFocus()) return;\n e.preventDefault();\n e.returnValue = \"\";\n },\n opts,\n );\n // Окно потеряло фокус (alt-tab, клик в другой фрейм): keyup/mouseup сюда уже не придут —\n // без сброса персонаж шёл бы сам и ломал блоки дальше.\n window.addEventListener(\n \"blur\",\n () => {\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n },\n opts,\n );\n }\n\n /** Читает ли игра ввод прямо сейчас: настоящий захват ИЛИ мягкий режим. */\n get active(): boolean {\n return (this.locked || this.softLocked) && !this.uiOpen;\n }\n\n /** Взять управление без Pointer Lock (мягкий режим) и снять паузу. */\n takeControl(): void {\n if (this.softLocked) return;\n this.softLocked = true;\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n this.dx = this.dy = 0;\n events.emit(\"ui:resume\");\n }\n\n /** Отпустить управление в мягком режиме — аналог выхода из захвата по Esc. */\n releaseControl(): void {\n if (!this.softLocked) return;\n this.softLocked = false;\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n if (!this.uiOpen) events.emit(\"ui:pause\");\n }\n\n /** Захват мыши — только из user gesture (клик по оверлею). */\n requestLock(): void {\n // Там, где Pointer Lock запрещён (кросс-доменный iframe: превью AI-кодера, встраивание игры\n // на сайте), играем без него. Раньше здесь был тихий `return` — и игра оставалась\n // неуправляемой навсегда: `locked` без API не становится true никогда.\n if (!this.pointerLockAvailable) {\n this.takeControl();\n return;\n }\n try {\n // Браузер может бросить, если Esc был нажат <1.2 c назад — тихо игнорируем.\n this.canvas.requestPointerLock()?.catch?.(() => {\n // Обещание отклонено (нет user gesture, политика фрейма) — не оставляем игрока без\n // управления, а переходим в мягкий режим.\n this.takeControl();\n });\n } catch {\n this.takeControl();\n }\n }\n releaseLock(): void {\n if (this.locked && typeof document.exitPointerLock === \"function\") {\n document.exitPointerLock();\n }\n this.releaseControl();\n }\n\n /** Забрать накопленную дельту мыши (вызывается раз в кадр). */\n consumeMouse(): { dx: number; dy: number } {\n const d = { dx: this.dx, dy: this.dy };\n this.dx = this.dy = 0;\n return d;\n }\n\n get forward(): boolean {\n return this.keys.has(\"KeyW\");\n }\n get back(): boolean {\n return this.keys.has(\"KeyS\");\n }\n get left(): boolean {\n return this.keys.has(\"KeyA\");\n }\n get right(): boolean {\n return this.keys.has(\"KeyD\");\n }\n get jump(): boolean {\n return this.keys.has(\"Space\");\n }\n /** Ctrl: присесть; в полёте — вниз (решение владельца 27.09.2026: наоборот, чем в Minecraft). */\n get sneakOrDown(): boolean {\n return SNEAK_KEYS.some((k) => this.keys.has(k));\n }\n get sneak(): boolean {\n return this.sneakOrDown;\n }\n /** Бег — Shift или двойное нажатие W (держится, пока W зажата). */\n get run(): boolean {\n return this.sprintLatch || RUN_KEYS.some((k) => this.keys.has(k));\n }\n}\n"
|
|
668
709
|
},
|
|
669
710
|
{
|
|
670
711
|
"path": "player/Interaction.ts",
|
|
671
|
-
"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 /** Натяжение лука, сек (кадровая половина), и тянем ли его сейчас. */\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 this.player.toggleMode();\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 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 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 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.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"
|
|
712
|
+
"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"
|
|
672
713
|
},
|
|
673
714
|
{
|
|
674
715
|
"path": "player/Placement.test.ts",
|
|
@@ -680,7 +721,11 @@
|
|
|
680
721
|
},
|
|
681
722
|
{
|
|
682
723
|
"path": "player/Player.ts",
|
|
683
|
-
"content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { clampRep } from \"../systems/Reputation\";\nimport type { DrinkDef, EffectId } from \"../types\";\n\nconst EFFECT_IDS: readonly EffectId[] = [\n \"strength\",\n \"regeneration\",\n \"night_vision\",\n \"speed\",\n \"fire_resistance\",\n \"haste\",\n \"resistance\",\n \"jump_boost\",\n \"decay\",\n \"levitation\",\n \"water_breathing\",\n \"mining_fatigue\",\n \"poison\",\n \"bad_omen\",\n \"hero\",\n \"darkness\",\n \"slowness\",\n \"hunger\",\n];\n\n/** Запас воздуха под водой, сек (300 тиков Minecraft). */\nexport const MAX_AIR = 15;\n/** Для коллизий в кожаных ботинках: снег-порох — полный куб. */\nconst isPowderSnow = (id: number): boolean => id === B.powder_snow;\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { moveBody } from \"../physics/Collision\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\nimport type { Rideable } from \"../entities/Rideable\";\nimport { GLIDE_TICK, glideTick } from \"./Glide\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\n\n/** Причины урона, которые гасит броня. */\nconst ARMORED_CAUSES: ReadonlySet<string> = new Set([\n \"attack\",\n \"projectile\",\n \"explosion\",\n \"lava\",\n \"fire\",\n]);\n\n/** Урон после брони — формула Minecraft (очки защиты и твёрдость). */\nexport function armorReduce(\n damage: number,\n points: number,\n toughness: number,\n): number {\n const eff = Math.min(\n 20,\n Math.max(points / 5, points - damage / (2 + toughness / 4)),\n );\n return damage * (1 - eff / 25);\n}\n\n/** Форма persist-блоба игрока (см. serialize/deserialize). */\ninterface PlayerSaveData {\n pos: number[];\n yaw: number;\n pitch: number;\n health: number;\n food?: number;\n saturation?: number;\n mode: PlayerMode;\n flying: boolean;\n /** v3: точка возрождения (кровать). Нет — там, где загрузились. */\n spawn?: number[];\n /** v3: кровать, у которой возрождаться (нет — у точки мира). */\n bedPos?: number[] | null;\n /** Якорь возрождения и путевой камень компаса (нет полей — их и не было). */\n anchorPos?: number[] | null;\n villageRep?: number;\n anchorDim?: string | null;\n lodestone?: { x: number; z: number; dim: string } | null;\n /** v3: действующие эффекты зелий. */\n effects?: { id: string; level: number; time: number }[];\n /** v3: зерно вариантов стола зачарований. */\n enchSeed?: number;\n /** v3: опыт — уровень, доля до следующего, всего набрано. */\n xpLevel?: number;\n xpProgress?: number;\n xpTotal?: number;\n}\n\nexport class Player {\n world: ChunkManager;\n pos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n pitch: number;\n onGround: boolean;\n inWater: boolean;\n headInWater: boolean;\n mode: PlayerMode;\n flying: boolean;\n health: number;\n food: number;\n saturation: number;\n exhaustion: number;\n dead: boolean;\n spawnPoint: THREE.Vector3;\n _fallDist: number;\n _stepDist: number;\n _regenTimer: number;\n _starveTimer: number;\n _sprinting = false;\n /** Присел (Shift): медленнее, глаз ниже, с края не сходит. */\n _sneaking = false;\n /** Кто сидит на плечах (id мобов: левое, правое) — попугаи (entities/Mob). */\n shoulders: [number | null, number | null] = [null, null];\n /** Касается лестницы (ladder) — можно лезть. */\n onLadder = false;\n /** Последний шаг упёрся в стену — «вперёд» у лестницы значит «вверх». */\n _hitWall = false;\n /** В лаве (урон, медленно), голова в лаве (оранжевый туман). */\n inLava = false;\n headInLava = false;\n /** До следующего удара лавой, сек (первый — сразу при касании). */\n _lavaT = 0;\n /** До следующего удара пустоты, сек. */\n _voidT = 0;\n /** Эффекты зелий: уровень и сколько ещё секунд. */\n effects = new Map<EffectId, { level: number; time: number }>();\n _effectRegenT = 0;\n _decayT = 0;\n _poisonT = 0;\n _cactusT = 0;\n _campT = 0;\n /** Тотем жизни в руке: потратить и спастись (подключает Game из инвентаря). */\n tryTotem: (() => boolean) | null = null;\n /** Воздух под водой, сек; кончился — 2 урона в секунду (утопление). */\n air = MAX_AIR;\n _drownT = 0;\n /** Летит на Крыльях Предела (player/Glide). */\n gliding = false;\n /** Надеты ли годные крылья и износ за секунду полёта — подключает Game из инвентаря. */\n canGlide: (() => boolean) | null = null;\n onGlideWear: (() => void) | null = null;\n _jumpHeld = false;\n _glideAcc = 0;\n _wearT = 0;\n /** Сколько ещё тянет ракета, сек. */\n _boostT = 0;\n /** Зерно вариантов стола зачарований — меняется после каждого зачарования (как в Minecraft). */\n enchSeed = (Math.random() * 0xffffffff) >>> 0;\n\n /** Касается ли тело кактуса (коробка игрока, раздутая на 0.1 по бокам). */\n _touchesCactus(): boolean {\n if (B.cactus === undefined || typeof this.world?.getBlock !== \"function\")\n return false;\n const r = 0.3 + 0.1;\n for (\n let x = Math.floor(this.pos.x - r);\n x <= Math.floor(this.pos.x + r);\n x++\n )\n for (\n let y = Math.floor(this.pos.y);\n y <= Math.floor(this.pos.y + 1.7);\n y++\n )\n for (\n let z = Math.floor(this.pos.z - r);\n z <= Math.floor(this.pos.z + r);\n z++\n )\n if (this.world.getBlock(x, y, z) === B.cactus) return true;\n return false;\n }\n /** Стоит в огне; сколько ещё гореть (сек) и до следующего урона от горения. */\n inFire = false;\n fireT = 0;\n _burnT = 0;\n /** Кровать — точка возрождения (мировые координаты клетки). */\n bedPos: [number, number, number] | null = null;\n /** Якорь возрождения (Пекло): клетка и измерение — возрождение там, а не у кровати. */\n anchorPos: [number, number, number] | null = null;\n anchorDim: string | null = null;\n /** Репутация в деревне: торговля поднимает, удар по жителю роняет (systems/Reputation). */\n villageRep = 0;\n /** Путевой камень, к которому привязан компас (клетка и измерение). */\n lodestone: { x: number; z: number; dim: string } | null = null;\n /** Последняя причина урона (экран смерти). */\n lastCause = \"\";\n /** Опыт: уровень, доля до следующего уровня (0..1), всего набрано. */\n xpLevel = 0;\n xpProgress = 0;\n xpTotal = 0;\n private _xpFrac = 0;\n\n /** Опыта до следующего уровня — формула Minecraft. */\n static xpToNext(level: number): number {\n return level >= 31\n ? 9 * level - 158\n : level >= 16\n ? 5 * level - 38\n : 2 * level + 7;\n }\n\n /** Добавить опыт (дробный копится: печь даёт по 0.1–1 за предмет). */\n addXp(amount: number): void {\n this._xpFrac += amount;\n const n = Math.floor(this._xpFrac);\n this._xpFrac -= n;\n if (n <= 0) return;\n this.xpTotal += n;\n this.xpProgress += n / Player.xpToNext(this.xpLevel);\n while (this.xpProgress >= 1) {\n this.xpProgress = (this.xpProgress - 1) * Player.xpToNext(this.xpLevel);\n this.xpLevel++;\n this.xpProgress /= Player.xpToNext(this.xpLevel);\n events.emit(\"levelUp\", { level: this.xpLevel });\n }\n events.emit(\"xpChanged\");\n }\n\n /** Отбросить игрока от удара (моб, стрела, взрыв). */\n knock(dx: number, dz: number, strength: number): void {\n const len = Math.hypot(dx, dz) || 1;\n this.vel.x += (dx / len) * strength;\n this.vel.z += (dz / len) * strength;\n this.vel.y = Math.max(this.vel.y, 4);\n this.onGround = false;\n }\n _respawnT?: ReturnType<typeof setTimeout>;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.pos = new THREE.Vector3(8.5, 40, 8.5); // ноги; уточняется при спавне\n this.vel = new THREE.Vector3();\n this.yaw = 0;\n this.pitch = 0;\n this.onGround = false;\n this.inWater = false;\n this.headInWater = false;\n this.mode = \"survival\"; // 'survival' | 'creative'\n this.flying = false;\n this.health = P.maxHealth;\n // голод: еда [0..maxFood], сатурация [0..food] (буфер, тратится первой),\n // истощение [0..exhaustionPerFood] — накопитель, при переполнении съедает очко\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n this._stepDist = 0;\n this._regenTimer = 0;\n this._starveTimer = 0;\n this.spawnPoint = new THREE.Vector3();\n\n events.on(\"input:doubleSpace\", () => {\n if (this.mode === \"creative\") this.flying = !this.flying;\n });\n }\n\n spawn(): void {\n // Ищем сушу по спирали от подсказки генератора (классический мир — (8,8)): не спавнимся в\n // океане на произвольном сиде. Сначала трава; мир из рецепта может быть пустыней или снегом —\n // тогда вторым проходом годится любая сухая твёрдая поверхность.\n const hint = this.world.generator.spawnHint();\n const water = this.world.waterLevel;\n let sx = hint.x,\n sz = hint.z;\n outer: for (const needGrass of [true, false]) {\n for (let r = 0; r <= 6; r++) {\n for (let dx = -r; dx <= r; dx += Math.max(1, r)) {\n for (let dz = -r; dz <= r; dz += Math.max(1, r)) {\n const x = hint.x + dx * 8,\n z = hint.z + dz * 8;\n const y = this.world.surfaceY(x, z);\n const top = this.world.getBlock(x, y, z);\n if (\n y > water &&\n (needGrass ? top === B.grass : BLOCKS[top]?.opaque)\n ) {\n sx = x;\n sz = z;\n break outer;\n }\n }\n }\n }\n }\n const y = this.world.surfaceY(sx, sz);\n this.pos.set(sx + 0.5, y + 1.01, sz + 0.5);\n this.spawnPoint.copy(this.pos);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n }\n\n get eyePos(): THREE.Vector3 {\n return new THREE.Vector3(\n this.pos.x,\n this.pos.y + (this._sneaking ? P.sneakEye : P.eye),\n this.pos.z,\n );\n }\n\n lookDir(): THREE.Vector3 {\n return new THREE.Vector3(\n -Math.sin(this.yaw) * Math.cos(this.pitch),\n Math.sin(this.pitch),\n -Math.cos(this.yaw) * Math.cos(this.pitch),\n );\n }\n\n toggleMode(): void {\n this.mode = this.mode === \"survival\" ? \"creative\" : \"survival\";\n if (this.mode === \"survival\") this.flying = false;\n this.exhaustion = 0; // накопленное в другом режиме не списываем разом\n events.emit(\"modeChanged\", { mode: this.mode });\n }\n\n /**\n * Поворот взгляда в радианах. Отдельным методом, чтобы у поворота был ОДИН источник правды:\n * им пользуется и кадровое обновление по мыши, и агентский API (`agent.ts`) — иначе у агента\n * появилась бы своя копия правил поворота, которая разъехалась бы с настоящей.\n */\n look(dYaw: number, dPitch: number): void {\n this.yaw += dYaw;\n this.pitch = Math.max(\n -Math.PI / 2 + 0.01,\n Math.min(Math.PI / 2 - 0.01, this.pitch + dPitch),\n );\n }\n\n /** Кадровое обновление: взгляд, ускорения, интеграция, коллизии. */\n update(dt: number, input: Input): void {\n // дельту забираем и у мёртвого: иначе она копится и после респауна камеру дёргает\n const m = input.consumeMouse();\n if (this.dead) return;\n // взгляд\n this.look(-m.dx * 0.0024, -m.dy * 0.0024);\n\n // в вагонетке: едем с ней, «вперёд» толкает, присесть — выйти\n if (this.riding) {\n const cart = this.riding;\n if (cart.dead || (input.sneak && !input.uiOpen)) this.dismount();\n else {\n this.pos.set(\n cart.renderPos.x,\n cart.renderPos.y + cart.seatY,\n cart.renderPos.z,\n );\n this.vel.set(0, 0, 0);\n this.onGround = true;\n this._fallDist = 0;\n if (input.forward && !input.uiOpen)\n cart.push(-Math.sin(this.yaw), -Math.cos(this.yaw), dt);\n return;\n }\n }\n\n // среда\n const feet = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n const head = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + P.eye),\n Math.floor(this.pos.z),\n );\n const wet = (id: number): boolean =>\n !!(BLOCKS[id]?.liquid || BLOCKS[id]?.waterlogged);\n this.inWater = wet(feet) || wet(head);\n this.headInWater = wet(head);\n this.inLava = feet === B.lava || head === B.lava;\n this.headInLava = head === B.lava;\n this.inFire =\n feet === B.fire ||\n head === B.fire ||\n feet === B.soul_fire ||\n head === B.soul_fire;\n this.onLadder = this._touchesLadder();\n this.inPowderSnow = feet === B.powder_snow || head === B.powder_snow;\n\n // желаемое горизонтальное движение в локальных осях камеры\n let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);\n let iz = (input.back ? 1 : 0) - (input.forward ? 1 : 0);\n if (input.uiOpen) {\n ix = 0;\n iz = 0;\n }\n const len = Math.hypot(ix, iz) || 1;\n ix /= len;\n iz /= len;\n // перевод ввода в мировые оси: forward = (-sin yaw, -cos yaw), right = (cos yaw, -sin yaw)\n const sin = Math.sin(this.yaw),\n cos = Math.cos(this.yaw);\n const wishX = ix * cos + iz * sin;\n const wishZ = iz * cos - ix * sin;\n\n // присесть — Shift (в полёте Shift — вниз); бег доступен, пока еды достаточно (как в Minecraft)\n this._sneaking = input.sneak && !this.flying && !input.uiOpen;\n const canSprint =\n input.run && input.forward && !this._sneaking && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed =\n this.speedFactor *\n (this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed * (this.inLava ? 0.5 : 1)\n : this._sneaking\n ? P.sneakSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed);\n\n // Крылья Предела: прыжок в воздухе раскрывает их; земля, вода, лестница, полёт, износ — складывают\n const jumpEdge = input.jump && !this._jumpHeld && !input.uiOpen;\n this._jumpHeld = input.jump;\n if (\n this.gliding &&\n (this.onGround ||\n this.inWater ||\n this.flying ||\n this.onLadder ||\n !this.canGlide?.())\n )\n this.gliding = false;\n else if (\n !this.gliding &&\n jumpEdge &&\n !this.onGround &&\n !this.inWater &&\n !this.flying &&\n this.vel.y < 1 &&\n this.canGlide?.()\n ) {\n this.gliding = true;\n this._glideAcc = 0;\n events.emit(\"glideStart\");\n }\n\n if (this.gliding) {\n // физика Minecraft по тикам; ракета толкает вдоль взгляда\n this._fallDist = 0;\n this._glideAcc += dt;\n while (this._glideAcc >= GLIDE_TICK) {\n this._glideAcc -= GLIDE_TICK;\n glideTick(this.vel, this.pitch, this.yaw, this._boostT > 0);\n this._boostT = Math.max(0, this._boostT - GLIDE_TICK);\n }\n this._wearT += dt;\n if (this._wearT >= 1) {\n this._wearT -= 1;\n if (this.mode !== \"creative\") this.onGlideWear?.();\n }\n } else if (this.flying) {\n // полёт: прямое управление скоростью, вертикаль на Space/Shift\n this.vel.x = wishX * speed;\n this.vel.z = wishZ * speed;\n this.vel.y = (input.jump ? speed : 0) + (input.sneakOrDown ? -speed : 0);\n if (input.uiOpen) this.vel.y = 0;\n } else if (this.inPowderSnow) {\n // снег-порох: вязнем — медленно; выбраться прыжками можно только в кожаных ботинках\n this.vel.x = wishX * speed * 0.35;\n this.vel.z = wishZ * speed * 0.35;\n this.vel.y =\n input.jump && !input.uiOpen && this.leatherBoots?.()\n ? 2.5\n : Math.max(-1.2, this.vel.y - P.gravity * dt);\n this._fallDist = 0;\n } else if (this.inWater) {\n const accel = 24 * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * 0.3 * dt; // ослабленная гравитация\n if (input.jump && !input.uiOpen) this.vel.y = P.swimUp; // гребок вверх\n this.vel.y *= 1 - P.waterDrag * dt; // сопротивление воды\n this._fallDist = 0;\n } else {\n const accel = (this.onGround ? 18 : 4) * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * dt;\n // Левитация (пуля панцирника): тянет вверх ~1 блок/с за уровень, падения не копит\n const lev = this.effects.get(\"levitation\");\n if (lev) {\n this.vel.y += (lev.level - this.vel.y) * Math.min(1, 5 * dt);\n this._fallDist = 0;\n }\n if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed + 1.6 * this.jumpBoost; // Прыгучесть — выше на ~полблока за уровень\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\n }\n // лестница: вверх — прыжок или «вперёд» в стену, присев — висим, иначе медленно сползаем\n if (this.onLadder) {\n this._fallDist = 0;\n const climb =\n !input.uiOpen && (input.jump || (input.forward && this._hitWall));\n if (climb) this.vel.y = P.climbSpeed;\n else if (this._sneaking) this.vel.y = Math.max(this.vel.y, 0);\n else this.vel.y = Math.max(this.vel.y, -P.climbSpeed);\n }\n }\n\n // интеграция с сабстепами — защита от туннелирования на лагающем кадре\n const steps = Math.max(1, Math.ceil((this.vel.length() * dt) / 0.4));\n const sdt = dt / steps;\n for (let s = 0; s < steps; s++) this._moveStep(sdt);\n\n // шаги (звук) + истощение от ходьбы/бега\n if (this.onGround && !this.inWater) {\n const dist = Math.hypot(this.vel.x, this.vel.z) * dt;\n this._stepDist += dist;\n if (this.mode === \"survival\")\n this.addExhaustion(\n dist * (this._sprinting ? P.exhaustSprint : P.exhaustWalk),\n );\n if (this._stepDist > 2.2) {\n this._stepDist = 0;\n events.emit(\"playerStep\");\n }\n }\n\n if (this.pos.y < -10) this._die(); // выпал из мира\n }\n\n /** Транспорт, в котором сидит игрок (вагонетка, лодка — entities/Rideable). */\n riding: Rideable | null = null;\n /** Игрок в снегу-порохе (вязнет; мёрзнет — systems/Freezing через Game). */\n inPowderSnow = false;\n /** Хук Game: надеты ли кожаные ботинки — снег-порох держит, из него можно выпрыгнуть. */\n leatherBoots?: () => boolean;\n /** Надета ли хоть одна золотая вещь — свинорылы не нападают (задаёт Game). */\n goldArmor?: () => boolean;\n /** Прикрывается щитом (держит ПКМ со щитом — player/Interaction). */\n blocking = false;\n /** Щит принял удар: изнашивает щит в руке (задаёт Game). */\n onShieldBlock?: (amount: number) => void;\n\n /** Сесть в вагонетку или лодку. */\n mount(cart: Rideable): void {\n if (this.riding) this.dismount();\n this.riding = cart;\n cart.rider = true;\n }\n\n /** Выйти из вагонетки — встать над ней. */\n dismount(): void {\n const cart = this.riding;\n if (!cart) return;\n cart.rider = false;\n this.riding = null;\n this.pos.set(cart.pos.x, cart.pos.y + 1, cart.pos.z);\n this.vel.set(0, 0, 0);\n }\n\n /** Шаг интеграции: коллизии по коробкам форм (physics/Collision), шаг на ступень, край при приседании. */\n _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n const hv0 = Math.hypot(this.vel.x, this.vel.z);\n const r = moveBody(\n this.world,\n {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n hw: P.width / 2,\n h: P.height,\n },\n this.vel.x * dt,\n this.vel.y * dt,\n this.vel.z * dt,\n {\n step: this.onGround && !this.flying ? P.stepHeight : 0,\n sneak: this._sneaking,\n onGround: this.onGround,\n // в кожаных ботинках снег-порох держит (присев — проваливаешься, как в Minecraft)\n extraSolid:\n !this.inPowderSnow && !this._sneaking && this.leatherBoots?.()\n ? isPowderSnow\n : undefined,\n },\n );\n this.pos.x += r.dx;\n this.pos.y += r.dy;\n this.pos.z += r.dz;\n\n if (r.hitDown) {\n // на острый сталагмит больнее: урон вдвое и уже с 2 блоков (как в Minecraft)\n const fx = Math.floor(this.pos.x),\n fy = Math.floor(this.pos.y + 0.01),\n fz = Math.floor(this.pos.z);\n const spike =\n this.world.getBlock(fx, fy, fz) === B.pointed_dripstone ||\n this.world.getBlock(fx, fy - 1, fz) === B.pointed_dripstone;\n const safe = spike ? 2 : P.fallSafe + this.jumpBoost;\n if (wasAirborne && this._fallDist > safe && this.mode === \"survival\")\n this.damage(\n Math.round(this._fallDist - safe) * (spike ? 2 : 1),\n spike ? \"stalagmite\" : \"fall\",\n );\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else {\n this.onGround = false;\n if (r.hitUp) this.vel.y = 0; // потолок\n // снижение в полёте — не падение: выключив полёт в воздухе, получили бы урон за весь спуск\n if (this.flying) this._fallDist = 0;\n else if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n if (r.hitX) this.vel.x = 0;\n if (r.hitZ) this.vel.z = 0;\n this._hitWall = r.hitX || r.hitZ;\n // на крыльях врезался в стену: урон по потерянной скорости, как в Minecraft (блоки за тик)\n if (this.gliding && this._hitWall && this.mode === \"survival\") {\n const dmg = ((hv0 - Math.hypot(this.vel.x, this.vel.z)) / 20) * 10 - 3;\n if (dmg > 0) this.damage(dmg, \"wall\");\n }\n }\n\n /** Касается ли тело лестницы (клетки, которые пересекает AABB, чуть расширенный вбок). */\n _touchesLadder(): boolean {\n const half = P.width / 2 + 0.05;\n for (let y = Math.floor(this.pos.y); y <= Math.floor(this.pos.y + 1); y++)\n for (\n let z = Math.floor(this.pos.z - half);\n z <= Math.floor(this.pos.z + half);\n z++\n )\n for (\n let x = Math.floor(this.pos.x - half);\n x <= Math.floor(this.pos.x + half);\n x++\n ) {\n // лезут по лестнице и по строительным лесам (systems/Scaffold)\n const sh = BLOCKS[this.world.getBlock(x, y, z)]?.shape;\n if (sh === \"ladder\" || sh === \"scaffold\") return true;\n }\n return false;\n }\n\n /** Надетая броня (подключает Game из инвентаря): очки защиты и твёрдость. */\n armorStats:\n (() => { points: number; toughness: number; protection?: number }) | null =\n null;\n /** Удар по броне — износ (подключает Game). */\n onArmorHit: ((damage: number) => void) | null = null;\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n // Огнестойкость: огонь и лава не ранят\n if (\n (cause === \"fire\" || cause === \"lava\") &&\n this.effects.has(\"fire_resistance\")\n )\n return;\n this.lastCause = cause;\n // броня гасит удары, огонь, лаву и взрывы — формула Minecraft; падение и голод — нет\n if (ARMORED_CAUSES.has(cause) && this.armorStats) {\n const { points, toughness } = this.armorStats();\n if (points > 0) {\n this.onArmorHit?.(amount);\n amount = armorReduce(amount, points, toughness);\n }\n }\n // Защита: −4% за уровень по всей броне, не больше 80% (пустоту и голод не гасит)\n const prot = this.armorStats?.().protection ?? 0;\n if (prot > 0 && cause !== \"void\" && cause !== \"starve\")\n amount *= 1 - Math.min(20, prot) * 0.04;\n // Сопротивление (маяк): −20% за уровень (пустоту не гасит)\n const res = this.effects.get(\"resistance\")?.level ?? 0;\n if (res > 0 && cause !== \"void\") amount *= Math.max(0, 1 - 0.2 * res);\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) {\n // тотем жизни в руке: 1 здоровья, Регенерация II и Огнестойкость вместо смерти (пустоту не спасает)\n if (cause !== \"void\" && this.tryTotem?.()) {\n this.health = 1;\n this.clearEffects();\n this.addEffect(\"regeneration\", 45, 2);\n this.addEffect(\"fire_resistance\", 40);\n events.emit(\"totemUsed\");\n return;\n }\n this._die();\n }\n }\n\n addExhaustion(n: number): void {\n // В креативе голода нет и tickHunger истощение не списывает — оно копилось бы и при возврате\n // в выживание съедало еду пачкой за один тик.\n if (this.mode === \"creative\") return;\n this.exhaustion += n;\n }\n\n /**\n * Тик опасностей среды (симуляция), как в Minecraft: лава — 4 урона каждые полсекунды и поджигает\n * на 15 с, огонь — поджигает на 8 с; горящий теряет 1 в секунду, вода тушит.\n */\n tickHazards(dt: number): void {\n if (this.dead) return;\n this._tickEffects(dt);\n // воздух: голова под водой тратит запас (Подводное дыхание — нет), кончился — тонем\n if (\n this.headInWater &&\n !this.headInLava &&\n this.mode === \"survival\" &&\n !this.effects.has(\"water_breathing\")\n ) {\n this.air = Math.max(0, this.air - dt);\n if (this.air <= 0) {\n this._drownT -= dt;\n if (this._drownT <= 0) {\n this._drownT = 1;\n this.damage(2, \"drown\");\n }\n }\n } else {\n this.air = Math.min(MAX_AIR, this.air + dt * 5);\n this._drownT = 0;\n }\n // горящий костёр жжёт стоящего на нём: 1 урон в секунду (костёр душ — 2), но не поджигает\n this._campT = Math.max(0, this._campT - dt);\n const camp =\n B.campfire !== undefined && typeof this.world?.getBlock === \"function\"\n ? this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n : -1;\n const soulCamp = camp === B.soul_campfire;\n if (\n this._campT <= 0 &&\n (camp === B.campfire || soulCamp) &&\n !this.effects.has(\"fire_resistance\")\n ) {\n this._campT = 1;\n this.damage(soulCamp ? 2 : 1, \"fire\");\n }\n // кактус колет: 1 урон каждые полсекунды, пока касаешься\n this._cactusT = Math.max(0, this._cactusT - dt);\n if (this._cactusT <= 0 && this._touchesCactus()) {\n this._cactusT = 0.5;\n this.damage(1, \"cactus\");\n }\n // пустота под Пределом (и под миром): 4 урона каждые полсекунды, даже в креативе — как в Minecraft\n if (this.pos.y < -48) {\n this._voidT -= dt;\n if (this._voidT <= 0) {\n this._voidT = 0.5;\n const was = this.mode;\n this.mode = \"survival\";\n this.damage(4, \"void\");\n this.mode = was;\n }\n return;\n }\n if (this.inLava) {\n this._lavaT -= dt;\n if (this._lavaT <= 0) {\n this._lavaT = 0.5;\n this.damage(4, \"lava\");\n }\n this.fireT = 15;\n } else {\n this._lavaT = 0;\n if (this.inFire) this.fireT = Math.max(this.fireT, 8);\n else if (this.inWater) this.fireT = 0;\n }\n if (this.fireT > 0 && this.mode !== \"creative\") {\n this.fireT = Math.max(0, this.fireT - dt);\n this._burnT -= dt;\n if (this._burnT <= 0) {\n this._burnT = 1;\n this.damage(1, \"fire\");\n }\n } else {\n this.fireT = 0;\n this._burnT = 0;\n }\n }\n\n /** Тик голода (симуляция, 20 TPS): истощение → еда, регенерация, голодание. */\n tickHunger(dt: number): void {\n if (this.mode === \"creative\" || this.dead) return;\n this.addExhaustion(P.exhaustIdle * dt); // медленная пассивная трата\n while (this.exhaustion >= P.exhaustionPerFood) {\n this.exhaustion -= P.exhaustionPerFood;\n if (this.saturation > 0)\n this.saturation = Math.max(0, this.saturation - 1);\n else this.food = Math.max(0, this.food - 1);\n }\n\n // сытость лечит; лечение «стоит» истощения\n if (this.food >= P.regenFoodThreshold && this.health < P.maxHealth) {\n this._regenTimer += dt;\n if (this._regenTimer >= P.regenInterval) {\n this._regenTimer = 0;\n this.health = Math.min(P.maxHealth, this.health + 1);\n this.addExhaustion(P.exhaustRegen);\n events.emit(\"playerHeal\", { health: this.health });\n }\n } else this._regenTimer = 0;\n\n // голодание: до какого здоровья добивает — по сложности (systems/Difficulty);\n // на мирной голода нет вовсе, на высокой голод убивает\n const floor = rules().starveFloor;\n if (this.food <= 0 && floor !== null) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > floor) this.damage(1, \"starve\");\n }\n } else this._starveTimer = 0;\n\n events.emit(\"foodChanged\", { food: this.food });\n }\n\n /** Съесть предмет (если это еда и есть куда). @returns {boolean} успех */\n eat(itemName: string): boolean {\n const it = ITEMS[itemName];\n // плод эхоцвета едят и сытым — ради переноса\n if (!it?.food || (this.food >= P.maxFood && itemName !== \"echo_fruit\"))\n return false;\n this.food = Math.min(P.maxFood, this.food + it.food);\n this.saturation = Math.min(\n this.food,\n this.saturation + (it.saturation ?? it.food * 0.6),\n );\n for (const e of it.eatEffects ?? [])\n this.addEffect(e.id, e.seconds, e.level ?? 1);\n events.emit(\"playerAte\", { item: itemName });\n if (itemName === \"echo_fruit\") this.echoHop();\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n /** Множитель скорости ходьбы: +20% за уровень Скорости и −15% за уровень Замедления, как в Minecraft. */\n get speedFactor(): number {\n const slow = this.effects.get(\"slowness\")?.level ?? 0;\n return (\n (1 + 0.2 * (this.effects.get(\"speed\")?.level ?? 0)) *\n Math.max(0, 1 - 0.15 * slow) *\n (this.blocking ? 0.3 : 1) // прикрываясь щитом, идёшь медленно\n );\n }\n\n /**\n * Щит, как в Minecraft: прикрылся, а удар или снаряд пришёл спереди (из полусферы взгляда) — урона нет,\n * щит изнашивается. @returns true — принял щит (урон и эффекты удара не наносить)\n */\n shieldBlocks(from: { x: number; z: number }, amount: number): boolean {\n if (!this.blocking || this.dead || this.mode === \"creative\") return false;\n const look = this.lookDir();\n if (look.x * (from.x - this.pos.x) + look.z * (from.z - this.pos.z) <= 0)\n return false;\n this.onShieldBlock?.(amount);\n events.emit(\"shieldBlock\", { amount });\n return true;\n }\n\n /** Ракета в полёте на крыльях: рывок на 1.2 с. false — не летим (ракета не тратится). */\n boost(): boolean {\n if (!this.gliding) return false;\n this._boostT = 1.2;\n return true;\n }\n\n /** Плод эхоцвета: прыжок в случайную точку до 8 блоков, где есть пол и место на рост. */\n echoHop(): boolean {\n for (let i = 0; i < 16; i++) {\n const x = Math.floor(this.pos.x + (Math.random() - 0.5) * 16),\n z = Math.floor(this.pos.z + (Math.random() - 0.5) * 16),\n y0 = Math.floor(this.pos.y + (Math.random() - 0.5) * 16);\n for (let y = Math.min(y0, 126); y > y0 - 16 && y > 1; y--) {\n if (!BLOCKS[this.world.getBlock(x, y - 1, z)]?.solid) continue;\n if (\n this.world.getBlock(x, y, z) !== 0 ||\n this.world.getBlock(x, y + 1, z) !== 0\n )\n break;\n this.pos.set(x + 0.5, y, z + 0.5);\n this.vel.set(0, 0, 0);\n this._fallDist = 0;\n events.emit(\"echoHop\", { x, y, z });\n return true;\n }\n }\n return false;\n }\n\n /** Уровень Прыгучести: прыжок выше, безопасное падение длиннее на столько блоков. */\n get jumpBoost(): number {\n return this.effects.get(\"jump_boost\")?.level ?? 0;\n }\n\n /** Наложить эффект: остаётся более сильный уровень и более долгий срок. */\n addEffect(id: EffectId, seconds: number, level = 1): void {\n const cur = this.effects.get(id);\n this.effects.set(id, {\n level: Math.max(level, cur?.level ?? 0),\n time: Math.max(seconds, cur?.time ?? 0),\n });\n events.emit(\"effectsChanged\", { id });\n }\n\n clearEffects(): void {\n this.effects.clear();\n events.emit(\"effectsChanged\", {});\n }\n\n /** Выпить: зелье накладывает эффект, молоко снимает все. */\n drink(d: DrinkDef): void {\n if (d.clearsEffects) this.clearEffects();\n if (d.heal) this.health = Math.min(P.maxHealth, this.health + d.heal);\n if (d.damage) this.damage(d.damage, \"magic\");\n if (d.food) this.food = Math.min(P.maxFood, this.food + d.food);\n if (d.effect) this.addEffect(d.effect, d.seconds ?? 60, d.level ?? 1);\n events.emit(\"playerDrank\", { effect: d.effect ?? null });\n }\n\n /** Сроки эффектов; Регенерация лечит 1 здоровье за 2.5 с (вдвое чаще за уровень). */\n _tickEffects(dt: number): void {\n for (const [id, e] of this.effects) {\n e.time -= dt;\n if (e.time <= 0) {\n this.effects.delete(id);\n events.emit(\"effectsChanged\", { id });\n }\n }\n // Тление: 1 урона раз в 2 с (вдвое чаще за уровень), убивает — в отличие от голода\n const decay = this.effects.get(\"decay\");\n if (decay) {\n this._decayT += dt;\n if (this._decayT >= 2 / decay.level) {\n this._decayT = 0;\n this.damage(1, \"decay\");\n }\n } else this._decayT = 0;\n // Отравление: 1 урона раз в 1.25 с (вдвое чаще за уровень), но не убивает — оставляет 1 здоровья\n const poison = this.effects.get(\"poison\");\n if (poison) {\n this._poisonT += dt;\n if (this._poisonT >= 1.25 / poison.level) {\n this._poisonT = 0;\n if (this.health > 1)\n this.damage(Math.min(1, this.health - 1), \"poison\");\n }\n } else this._poisonT = 0;\n // Голод (удар кадавра): истощение 0.1 в секунду за уровень — сытость тает быстрее\n const hunger = this.effects.get(\"hunger\");\n if (hunger) this.addExhaustion(0.1 * hunger.level * dt);\n const regen = this.effects.get(\"regeneration\");\n if (!regen) return;\n this._effectRegenT += dt;\n const every = 2.5 / 2 ** (regen.level - 1);\n if (this._effectRegenT >= every) {\n this._effectRegenT = 0;\n if (this.health < P.maxHealth)\n this.health = Math.min(P.maxHealth, this.health + 1);\n }\n }\n\n /** Смерть: экран «Вы погибли» и выпадение вещей решает Game (подписан на playerDied). */\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n this.fireT = 0;\n this.air = MAX_AIR;\n this.vel.set(0, 0, 0);\n events.emit(\"playerDied\", { cause: this.lastCause });\n }\n\n /** Возродиться в точке (кровать или точка мира) с полным здоровьем и сытостью. */\n respawn(at: THREE.Vector3): void {\n clearTimeout(this._respawnT);\n this.pos.copy(at);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.fireT = 0;\n this._fallDist = 0;\n this.dead = false;\n events.emit(\"playerRespawn\");\n }\n\n /** Пересекается ли AABB игрока с блоком (запрет постановки «в себя»). */\n intersectsBlock(bx: number, by: number, bz: number): boolean {\n const half = P.width / 2;\n return (\n bx + 1 > this.pos.x - half &&\n bx < this.pos.x + half &&\n by + 1 > this.pos.y &&\n by < this.pos.y + P.height &&\n bz + 1 > this.pos.z - half &&\n bz < this.pos.z + half\n );\n }\n\n /** Teardown: отложенный респаун не должен сработать после уничтожения игры. */\n dispose(): void {\n clearTimeout(this._respawnT);\n }\n\n serialize() {\n return {\n pos: this.pos.toArray(),\n yaw: this.yaw,\n pitch: this.pitch,\n health: this.health,\n food: this.food,\n saturation: this.saturation,\n mode: this.mode,\n flying: this.flying,\n spawn: this.spawnPoint.toArray(),\n xpLevel: this.xpLevel,\n xpProgress: this.xpProgress,\n xpTotal: this.xpTotal,\n bedPos: this.bedPos,\n anchorPos: this.anchorPos,\n villageRep: this.villageRep,\n anchorDim: this.anchorDim,\n lodestone: this.lodestone,\n effects: [...this.effects].map(([id, e]) => ({\n id,\n level: e.level,\n time: e.time,\n })),\n enchSeed: this.enchSeed,\n };\n }\n deserialize(d: PlayerSaveData): void {\n this.pos.fromArray(d.pos);\n this.yaw = d.yaw;\n this.pitch = d.pitch;\n // сейв, записанный на экране смерти: здоровье 0 при dead=false убило бы первым же уроном\n this.health = d.health > 0 ? d.health : P.maxHealth;\n this.mode = d.mode;\n this.flying = d.flying;\n this.food = d.food ?? P.maxFood;\n this.saturation = d.saturation ?? 5;\n this.exhaustion = 0;\n this.vel.set(0, 0, 0);\n if (Array.isArray(d.spawn) && d.spawn.length === 3)\n this.spawnPoint.fromArray(d.spawn);\n else this.spawnPoint.copy(this.pos);\n this.xpLevel = d.xpLevel ?? 0;\n this.xpProgress = d.xpProgress ?? 0;\n this.xpTotal = d.xpTotal ?? 0;\n const cell = (v: unknown): [number, number, number] | null =>\n Array.isArray(v) && v.length === 3 && v.every((n) => Number.isFinite(n))\n ? [Number(v[0]), Number(v[1]), Number(v[2])]\n : null;\n this.anchorPos = cell(d.anchorPos);\n this.villageRep = clampRep(Number(d.villageRep) || 0);\n this.anchorDim = typeof d.anchorDim === \"string\" ? d.anchorDim : null;\n this.lodestone =\n d.lodestone && typeof d.lodestone === \"object\"\n ? { ...d.lodestone }\n : null;\n this.bedPos =\n Array.isArray(d.bedPos) && d.bedPos.length === 3\n ? [d.bedPos[0]!, d.bedPos[1]!, d.bedPos[2]!]\n : null;\n this.dead = false;\n if (typeof d.enchSeed === \"number\") this.enchSeed = d.enchSeed;\n this.effects.clear();\n for (const e of Array.isArray(d.effects) ? d.effects : [])\n if (EFFECT_IDS.includes(e.id as EffectId) && e.time > 0)\n this.effects.set(e.id as EffectId, {\n level: e.level || 1,\n time: e.time,\n });\n }\n}\n"
|
|
724
|
+
"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"
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
"path": "player/PowderSnow.test.ts",
|
|
728
|
+
"content": "import * as THREE from \"three\";\nimport { describe, expect, it } from \"vitest\";\nimport { B } from \"../registry/Blocks\";\nimport { Chunk } from \"../world/Chunk\";\nimport { ChunkManager } from \"../world/ChunkManager\";\nimport { findSafeSpot, isStandable } from \"../world/safeSpot\";\nimport { EntityManager } from \"../entities/EntityManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { Input } from \"./Input\";\nimport { Player } from \"./Player\";\n\n/** Снежное поле: камень на y=4, снег на y=5..6 (верх — y=7) и яма снега-пороха в 2 блока в (8, 8). */\nfunction world(): ChunkManager {\n const w = new ChunkManager(new THREE.Scene(), {} as TextureAtlas, {\n kind: \"noise\",\n seed: 1,\n });\n const c = new Chunk(0, 0);\n c.generated = true;\n for (let x = 0; x < 16; x++)\n for (let z = 0; z < 16; z++) {\n c.setRaw(x, 4, z, B.stone!);\n c.setRaw(x, 5, z, B.snow!);\n c.setRaw(x, 6, z, B.snow!);\n }\n c.setRaw(8, 5, 8, B.powder_snow!);\n c.setRaw(8, 6, 8, B.powder_snow!);\n w.chunks.set(\"0,0\", c);\n return w;\n}\n\nfunction input(keys: { jump?: boolean; forward?: boolean }): Input {\n return {\n consumeMouse: () => ({ dx: 0, dy: 0 }),\n forward: !!keys.forward,\n back: false,\n left: false,\n right: false,\n jump: !!keys.jump,\n sneak: false,\n sneakOrDown: false,\n run: false,\n uiOpen: false,\n } as unknown as Input;\n}\n\n/** Игрок на дне ямы, взгляд на север (−z): впереди — край из обычного снега. */\nfunction inPit(w: ChunkManager): Player {\n const p = new Player(w);\n p.pos.set(8.5, 5, 8.5);\n p.yaw = 0;\n return p;\n}\n\nfunction run(p: Player, inp: Input, seconds: number): void {\n for (let t = 0; t < seconds; t += 1 / 60) p.update(1 / 60, inp);\n}\n\ndescribe(\"снег-порох: не ловушка\", () => {\n it(\"без прыжка игрок вязнет на дне ямы\", () => {\n const p = inPit(world());\n run(p, input({ forward: true }), 3);\n expect(p.inPowderSnow).toBe(true);\n expect(p.pos.y).toBeLessThan(5.1);\n });\n\n it(\"без кожаных ботинок, зажав прыжок и «вперёд», выбирается на край\", () => {\n const p = inPit(world());\n run(p, input({ jump: true, forward: true }), 5);\n expect(p.inPowderSnow).toBe(false);\n expect(p.pos.z).toBeLessThan(8);\n expect(p.pos.y).toBeGreaterThanOrEqual(7 - 1e-6);\n });\n\n it(\"в кожаных ботинках — тоже (раньше на кромке качало вверх-вниз без выхода)\", () => {\n const p = inPit(world());\n p.leatherBoots = () => true;\n run(p, input({ jump: true, forward: true }), 5);\n expect(p.inPowderSnow).toBe(false);\n expect(p.pos.z).toBeLessThan(8);\n expect(p.pos.y).toBeGreaterThanOrEqual(7 - 1e-6);\n });\n});\n\ndescribe(\"безопасная точка\", () => {\n it(\"дно ямы снега-пороха и её верх — не место для спавна, соседняя клетка — место\", () => {\n const w = world();\n expect(isStandable(w, 8, 5, 8)).toBe(false); // на дне ямы\n expect(isStandable(w, 8, 7, 8)).toBe(false); // «поверх» — это верх сугроба, провалишься\n expect(isStandable(w, 7, 7, 8)).toBe(true);\n const s = findSafeSpot(w, 8, 8);\n expect(s).not.toBeNull();\n expect(s!.y).toBe(7);\n expect(s!.x !== 8 || s!.z !== 8).toBe(true);\n });\n\n it(\"лава — не место\", () => {\n const w = world();\n w.setBlock(3, 7, 3, B.lava!);\n expect(isStandable(w, 3, 7, 3)).toBe(false);\n });\n});\n\ndescribe(\"ездок-призрак\", () => {\n it(\"очистка сущностей (портал, новый мир, загрузка) ссаживает игрока\", () => {\n const w = world();\n const p = new Player(w);\n const inv = { heldItem: () => null } as unknown as Inventory;\n const em = new EntityManager(\n new THREE.Scene(),\n w,\n {} as TextureAtlas,\n p,\n inv,\n );\n const cart = em.spawnMinecart(3.5, 7, 3.5);\n p.mount(cart);\n expect(p.riding).toBe(cart);\n em.clear();\n expect(p.riding).toBeNull();\n expect(cart.rider).toBe(false);\n });\n});\n"
|
|
684
729
|
},
|
|
685
730
|
{
|
|
686
731
|
"path": "player/Shield.test.ts",
|
|
@@ -688,7 +733,7 @@
|
|
|
688
733
|
},
|
|
689
734
|
{
|
|
690
735
|
"path": "registry/aliases.ts",
|
|
691
|
-
"content": "// Синонимы имён: Minecraft-имена (их знают игроки и ИИ-модели) → id этой игры. Нужны командам\n// (/give grass_block) и разбору чужих данных. Названия в интерфейсе — отдельно, в names.ts.\nimport { B } from \"./Blocks\";\nimport { ITEMS } from \"./Items\";\n\nexport const ALIASES: Record<string, string> = {\n grass_block: \"grass\",\n short_grass: \"tallgrass\",\n tall_grass: \"tallgrass\",\n dandelion: \"flower_yellow\",\n poppy: \"flower_red\",\n oak_planks: \"planks\",\n oak_leaves: \"leaves\",\n dark_oak_planks: \"dark_planks\",\n snow_block: \"snow\",\n stone_brick: \"stone_bricks\",\n brick_block: \"bricks\",\n wall_torch: \"torch\",\n // Minecraft-имена, которым здесь дано своё название\n netherite_ingot: \"infernite_ingot\",\n netherite_scrap: \"infernite_scrap\",\n netherite_pickaxe: \"infernite_pickaxe\",\n netherite_axe: \"infernite_axe\",\n netherite_shovel: \"infernite_shovel\",\n netherite_hoe: \"infernite_hoe\",\n // Пекло\n netherrack: \"infernal_stone\",\n nether_portal: \"inferno_portal\",\n nether_quartz_ore: \"quartz_ore\",\n nether_bricks: \"inferno_bricks\",\n nether_brick_fence: \"inferno_brick_fence\",\n nether_brick_stairs: \"inferno_brick_stairs\",\n nether_brick: \"inferno_brick\",\n // Предел\n end_portal_frame: \"rim_frame\",\n end_portal: \"beyond_portal\",\n end_stone: \"beyond_stone\",\n ender_eye: \"rim_eye\",\n eye_of_ender: \"rim_eye\",\n blaze_rod: \"flame_rod\",\n blaze_powder: \"flame_powder\",\n ghast_tear: \"wailer_tear\",\n nether_wart: \"inferno_wart\",\n netherite_block: \"infernite_block\",\n reeds: \"sugar_cane\",\n nether_wart_block: \"inferno_wart_block\",\n glistering_melon_slice: \"glistering_melon\",\n netherite_sword: \"infernite_sword\",\n netherite_helmet: \"infernite_helmet\",\n netherite_chestplate: \"infernite_chestplate\",\n netherite_leggings: \"infernite_leggings\",\n netherite_boots: \"infernite_boots\",\n // иссушитель и его добыча\n wither_skeleton_skull: \"ash_skull\",\n nether_star: \"inferno_star\",\n wither_skeleton: \"ash_skeleton\",\n wither: \"ash_lord\",\n // океан\n prismarine: \"sea_stone\",\n prismarine_bricks: \"sea_bricks\",\n dark_prismarine: \"dark_sea_stone\",\n prismarine_shard: \"sea_shard\",\n prismarine_crystals: \"sea_crystals\",\n turtle_scute: \"scute\",\n guardian: \"deepling\",\n elder_guardian: \"elder_deepling\",\n // разбойники\n pillager: \"marauder\",\n vindicator: \"axeman\",\n evoker: \"conjurer\",\n vex: \"wisp\",\n endermite: \"rim_mite\",\n ravager: \"crusher\",\n totem_of_undying: \"life_totem\",\n // сырая крольчатина в Minecraft — просто rabbit (здесь так зовут самого кролика)\n rabbit: \"raw_rabbit\",\n};\n\n/** Имя без префикса `minecraft:` и в нижнем регистре. */\nconst clean = (name: string): string =>\n name\n .trim()\n .toLowerCase()\n .replace(/^minecraft:/, \"\");\n\n/** id предмета по имени или синониму; null — такого нет. */\nexport function resolveItem(name: string): string | null {\n const n = clean(name);\n if (ITEMS[n]) return n;\n const a = ALIASES[n];\n return a && ITEMS[a] ? a : null;\n}\n\n/** id блока по имени или синониму; null — такого нет. */\nexport function resolveBlock(name: string): number | null {\n const n = clean(name);\n return B[n] ?? B[ALIASES[n] ?? \"\"] ?? null;\n}\n"
|
|
736
|
+
"content": "// Синонимы имён: Minecraft-имена (их знают игроки и ИИ-модели) → id этой игры. Нужны командам\n// (/give grass_block) и разбору чужих данных. Названия в интерфейсе — отдельно, в names.ts.\nimport { B } from \"./Blocks\";\nimport { ITEMS } from \"./Items\";\n\nexport const ALIASES: Record<string, string> = {\n grass_block: \"grass\",\n short_grass: \"tallgrass\",\n tall_grass: \"tallgrass\",\n dandelion: \"flower_yellow\",\n poppy: \"flower_red\",\n oak_planks: \"planks\",\n oak_leaves: \"leaves\",\n dark_oak_planks: \"dark_planks\",\n snow_block: \"snow\",\n stone_brick: \"stone_bricks\",\n brick_block: \"bricks\",\n wall_torch: \"torch\",\n // Minecraft-имена, которым здесь дано своё название\n netherite_ingot: \"infernite_ingot\",\n netherite_scrap: \"infernite_scrap\",\n netherite_pickaxe: \"infernite_pickaxe\",\n netherite_axe: \"infernite_axe\",\n netherite_shovel: \"infernite_shovel\",\n netherite_hoe: \"infernite_hoe\",\n // Пекло\n netherrack: \"infernal_stone\",\n nether_portal: \"inferno_portal\",\n nether_quartz_ore: \"quartz_ore\",\n nether_bricks: \"inferno_bricks\",\n nether_brick_fence: \"inferno_brick_fence\",\n nether_brick_stairs: \"inferno_brick_stairs\",\n nether_brick: \"inferno_brick\",\n // Предел\n end_portal_frame: \"rim_frame\",\n end_portal: \"beyond_portal\",\n end_stone: \"beyond_stone\",\n ender_eye: \"rim_eye\",\n eye_of_ender: \"rim_eye\",\n blaze_rod: \"flame_rod\",\n blaze_powder: \"flame_powder\",\n ghast_tear: \"wailer_tear\",\n nether_wart: \"inferno_wart\",\n netherite_block: \"infernite_block\",\n reeds: \"sugar_cane\",\n nether_wart_block: \"inferno_wart_block\",\n glistering_melon_slice: \"glistering_melon\",\n netherite_sword: \"infernite_sword\",\n netherite_helmet: \"infernite_helmet\",\n netherite_chestplate: \"infernite_chestplate\",\n netherite_leggings: \"infernite_leggings\",\n netherite_boots: \"infernite_boots\",\n // иссушитель и его добыча\n wither_skeleton_skull: \"ash_skull\",\n nether_star: \"inferno_star\",\n wither_skeleton: \"ash_skeleton\",\n wither: \"ash_lord\",\n // океан\n prismarine: \"sea_stone\",\n prismarine_bricks: \"sea_bricks\",\n dark_prismarine: \"dark_sea_stone\",\n prismarine_shard: \"sea_shard\",\n prismarine_crystals: \"sea_crystals\",\n turtle_scute: \"scute\",\n guardian: \"deepling\",\n elder_guardian: \"elder_deepling\",\n // разбойники\n pillager: \"marauder\",\n vindicator: \"axeman\",\n evoker: \"conjurer\",\n vex: \"wisp\",\n endermite: \"rim_mite\",\n ravager: \"crusher\",\n totem_of_undying: \"life_totem\",\n // мобы, которым здесь дано своё имя: /summon creeper раньше отвечал «нет такого существа»\n creeper: \"bomber\",\n enderman: \"stalker\",\n ghast: \"wailer\",\n blaze: \"flamer\",\n piglin: \"hogman\",\n piglin_brute: \"hogman_brute\",\n zombified_piglin: \"hogman_dead\",\n ender_dragon: \"void_dragon\",\n end_crystal: \"beyond_crystal\",\n shulker: \"shellkeeper\",\n // сырая крольчатина в Minecraft — просто rabbit (здесь так зовут самого кролика)\n rabbit: \"raw_rabbit\",\n};\n\n/** Имя без префикса `minecraft:` и в нижнем регистре. */\nconst clean = (name: string): string =>\n name\n .trim()\n .toLowerCase()\n .replace(/^minecraft:/, \"\");\n\n/** id предмета по имени или синониму; null — такого нет. */\nexport function resolveItem(name: string): string | null {\n const n = clean(name);\n if (ITEMS[n]) return n;\n const a = ALIASES[n];\n return a && ITEMS[a] ? a : null;\n}\n\n/** id блока по имени или синониму; null — такого нет. */\nexport function resolveBlock(name: string): number | null {\n const n = clean(name);\n return B[n] ?? B[ALIASES[n] ?? \"\"] ?? null;\n}\n"
|
|
692
737
|
},
|
|
693
738
|
{
|
|
694
739
|
"path": "registry/Blocks.ts",
|
|
@@ -836,7 +881,7 @@
|
|
|
836
881
|
},
|
|
837
882
|
{
|
|
838
883
|
"path": "registry/blocks/mountain.ts",
|
|
839
|
-
"content": "// Горы и вишнёвые рощи: снег-порох (не твёрдый — в нём вязнешь и мёрзнешь, в кожаных ботинках держит:\n// Player, physics/Collision, systems/Freezing; набирается ведром) и розовые лепестки под вишнями.\nimport type { BlockDef } from \"../../types\";\nimport { addNames } from \"../names\";\nimport { all, block } from \"./define\";\n\nexport const MOUNTAIN_BLOCKS: BlockDef[] = [\n block({\n name: \"powder_snow\",\n solid: false,\n hardness: 0.25,\n tool: \"shovel\",\n drops: [],\n tex: all(\"powder_snow\"),\n mapColor: 0xf4f8fc,\n }),\n block({\n name: \"pink_petals\",\n shape: \"carpet\",\n solid: false,\n opaque: false,\n hardness: 0,\n flammable: true,\n tex: all(\"pink_petals\"),\n mapColor: 0xf0a8c8,\n }),\n];\n\naddNames({\n powder_snow: \"Снег-порох\",\n pink_petals: \"Розовые лепестки\",\n cherry_grove: \"Вишнёвая роща\",\n});\n"
|
|
884
|
+
"content": "// Горы и вишнёвые рощи: снег-порох (не твёрдый — в нём вязнешь и мёрзнешь, в кожаных ботинках держит:\n// Player, physics/Collision, systems/Freezing; набирается ведром) и розовые лепестки под вишнями.\nimport type { BlockDef } from \"../../types\";\nimport { addNames } from \"../names\";\nimport { all, block } from \"./define\";\n\nexport const MOUNTAIN_BLOCKS: BlockDef[] = [\n block({\n name: \"powder_snow\",\n solid: false,\n // не «непрозрачный»: иначе грани соседей к нему отсекаются и изнутри видно сквозь землю;\n // свет гасит, как и раньше\n opaque: false,\n filter: 15,\n hardness: 0.25,\n tool: \"shovel\",\n drops: [],\n tex: all(\"powder_snow\"),\n mapColor: 0xf4f8fc,\n }),\n block({\n name: \"pink_petals\",\n shape: \"carpet\",\n solid: false,\n opaque: false,\n hardness: 0,\n flammable: true,\n tex: all(\"pink_petals\"),\n mapColor: 0xf0a8c8,\n }),\n];\n\naddNames({\n powder_snow: \"Снег-порох\",\n pink_petals: \"Розовые лепестки\",\n cherry_grove: \"Вишнёвая роща\",\n});\n"
|
|
840
885
|
},
|
|
841
886
|
{
|
|
842
887
|
"path": "registry/blocks/mushroom.ts",
|
|
@@ -1268,7 +1313,7 @@
|
|
|
1268
1313
|
},
|
|
1269
1314
|
{
|
|
1270
1315
|
"path": "scene.ts",
|
|
1271
|
-
"content": "import type {\n ContentTypeHandler,\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { createVoxelWorldContentType } from \"./workshop\";\nimport { Game, type GameOptions } from \"./main\";\nimport \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">→</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>W W</b> sprint, <b>
|
|
1316
|
+
"content": "import type {\n ContentTypeHandler,\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { createVoxelWorldContentType } from \"./workshop\";\nimport { Game, type GameOptions } from \"./main\";\nimport \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">→</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> / <b>W W</b> sprint, <b>Ctrl</b> sneak</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block / open door</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory & 2×2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3×3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"btnWorlds\" class=\"accent\">Create world</button>\n <button id=\"btnExport\">Export world</button>\n <button id=\"btnImport\">Import world</button>\n <button id=\"btnNewSeed\">New seed</button>\n </div>\n </div>\n </div>\n <div id=\"worldScreen\" class=\"hidden\">\n <div class=\"wsPanel\">\n <div class=\"wsHead\">\n <button class=\"wsTab active\" data-tab=\"new\">Новый мир</button>\n <button class=\"wsTab\" data-tab=\"mine\">Мои миры</button>\n <button id=\"wsClose\" class=\"wsClose\" title=\"Закрыть (Esc)\">×</button>\n </div>\n <div id=\"wsNew\" class=\"wsBody\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Картинки-референсы <span class=\"wsNote\">до 4, можно один объект с разных сторон</span></div>\n <div id=\"wsDrop\" class=\"wsDrop\" title=\"Клик, перетаскивание или Ctrl+V\">\n <div id=\"wsThumbs\" class=\"wsThumbs hidden\"></div>\n <span id=\"wsDropHint\">Перетащите картинки, вставьте Ctrl+V или нажмите, чтобы выбрать</span>\n </div>\n <div class=\"wsRow\">\n <label title=\"ИИ сам поймёт по описанию, что на картинках: объект, который надо построить из блоков, стиль или место\">\n <input type=\"radio\" name=\"wsRead\" value=\"reference\" checked /> Образец для ИИ</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"topdown\" /> Карта сверху</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"mood\" /> Настроение</label>\n <button id=\"wsClearImg\" class=\"wsLink hidden\">убрать картинки</button>\n </div>\n <label class=\"wsLabel\" for=\"wsText\">Описание</label>\n <textarea id=\"wsText\" rows=\"3\" maxlength=\"600\"\n placeholder=\"Например: тропический остров, на взлётной полосе стоит самолёт с картинок\"></textarea>\n <div class=\"wsLabel\">Размер</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsSize\" value=\"island\" checked /> Остров 256</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"large\" /> Большой 512</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"infinite\" /> Бесконечный</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"custom\" /> Свой</label>\n <span id=\"wsCustom\" class=\"hidden\">\n <input id=\"wsW\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> ×\n <input id=\"wsD\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> чанков\n </span>\n </div>\n <div class=\"wsLabel\">Как строить</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsMode\" value=\"local\" checked /> Без ИИ</label>\n <label id=\"wsAiLabel\"><input type=\"radio\" name=\"wsMode\" value=\"ai\" disabled /> С ИИ</label>\n <span id=\"wsAiNote\" class=\"wsNote\"></span>\n </div>\n <div class=\"wsButtons\">\n <button id=\"wsGenerate\" class=\"wsPrimary\">Сгенерировать</button>\n <button id=\"wsLoadSpec\">Загрузить рецепт</button>\n </div>\n <div id=\"wsStatus\" class=\"wsStatus\"></div>\n </div>\n <div class=\"wsPreview\">\n <canvas id=\"wsMap\" width=\"256\" height=\"256\"></canvas>\n <div id=\"wsInfo\" class=\"wsInfo\">Здесь появится карта мира</div>\n <div class=\"wsButtons\">\n <button id=\"wsPlay\" class=\"wsPrimary\" disabled>Играть в этом мире</button>\n <button id=\"wsSaveSpec\" disabled>Скачать рецепт</button>\n </div>\n </div>\n </div>\n <div id=\"wsMine\" class=\"wsBody hidden\"><div id=\"wsList\" class=\"wsList\"></div></div>\n </div>\n </div>\n`;\n\n/**\n * Сцена + debug-поверхность для агента + тип контента для Мастерской: все смотрят на ОДИН экземпляр\n * игры, который создаётся в `mount()` и обнуляется в `destroy()`. Поэтому они получают не объект, а\n * доступ к текущему `game` — иначе после пересоздания сцены держали бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(opts: GameOptions = {}): {\n scene: EngineScene;\n agent: ModuleAgentApi;\n content: ContentTypeHandler;\n} {\n let game: Game | null = null;\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n },\n opts,\n );\n return {\n scene,\n agent: createVoxelcraftAgentApi(() => game),\n content: createVoxelWorldContentType(() => game),\n };\n}\n\nfunction buildScene(\n getGame: () => Game | null,\n setGame: (game: Game | null) => void,\n opts: GameOptions,\n): EngineScene {\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n // The game reads its elements by id from the document; provide them inside the host surface.\n ctx.host.innerHTML = VOXEL_DOM;\n setGame(new Game(opts));\n },\n activate(): void {\n getGame()?.setRunning(true);\n },\n suspend(): void {\n getGame()?.setRunning(false);\n },\n capture(): Promise<Blob | null> {\n return getGame()?.capture() ?? Promise.resolve(null);\n },\n destroy(): void {\n getGame()?.destroy();\n setGame(null);\n },\n };\n}\n\n/** @deprecated Используйте `createVoxelcraft()` — он отдаёт ещё и поверхность для агента. */\nexport function createVoxelScene(): EngineScene {\n return createVoxelcraft().scene;\n}\n"
|
|
1272
1317
|
},
|
|
1273
1318
|
{
|
|
1274
1319
|
"path": "shims.d.ts",
|
|
@@ -1276,7 +1321,7 @@
|
|
|
1276
1321
|
},
|
|
1277
1322
|
{
|
|
1278
1323
|
"path": "style.css",
|
|
1279
|
-
"content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #063d99;\n}\nbody {\n font-family: \"Segoe UI\", system-ui, sans-serif;\n user-select: none;\n}\n\n#game {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ---------- HUD ---------- */\n#crosshair {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 18px;\n height: 18px;\n transform: translate(-50%, -50%);\n pointer-events: none;\n z-index: 5;\n}\n#crosshair::before,\n#crosshair::after {\n content: \"\";\n position: absolute;\n background: rgba(255, 255, 255, 0.85);\n mix-blend-mode: difference;\n}\n#crosshair::before {\n left: 8px;\n top: 0;\n width: 2px;\n height: 18px;\n}\n#crosshair::after {\n left: 0;\n top: 8px;\n width: 18px;\n height: 2px;\n}\n\n/* --idos-safe-top: высота общего HUD host'а (0 без него) — служебные строки не уходят под HUD. */\n#fps {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#debug {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 26px);\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* --idos-safe-bottom: высота нижней навигации host'а (0, когда режим один) — хотбар над ней. */\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: calc(var(--idos-safe-bottom, 0px) + 8px);\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n.slot .dur {\n position: absolute;\n left: 4px;\n bottom: 3px;\n height: 3px;\n display: none;\n pointer-events: none;\n box-shadow: 0 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#xpBar {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 60px);\n width: min(400px, 90vw);\n height: 6px;\n background: rgba(20, 20, 20, 0.8);\n border: 1px solid #000;\n z-index: 5;\n pointer-events: none;\n}\n.xpFill {\n height: 100%;\n width: 0;\n background: linear-gradient(#b8ff5a, #5ec21e);\n}\n#xpLevel {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 64px);\n color: #8aff3a;\n font: bold 14px monospace;\n text-shadow:\n 1px 1px 0 #000,\n -1px -1px 0 #000;\n z-index: 6;\n pointer-events: none;\n}\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n/* пузырьки воздуха над едой — под водой */\n#bubbles {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 15px;\n color: #7ad0ff;\n}\n.bubble.empty {\n opacity: 0;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- смерть, горение ---------- */\n#deathScreen {\n position: fixed;\n inset: 0;\n z-index: 35;\n background: rgba(120, 0, 0, 0.55);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.deathPanel {\n text-align: center;\n color: #fff;\n text-shadow: 2px 2px 0 #000;\n}\n.deathPanel h1 {\n font-size: 46px;\n margin-bottom: 8px;\n}\n.deathCause {\n font-size: 16px;\n margin-bottom: 20px;\n}\n.deathBtn {\n cursor: pointer;\n font-size: 16px;\n padding: 10px 28px;\n border-radius: 4px;\n border: 2px solid #aaa;\n background: #3a3f4d;\n color: #eee;\n}\n.deathBtn:hover {\n background: #4a5163;\n}\n#fireOverlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n opacity: 0;\n background: radial-gradient(\n ellipse at 50% 120%,\n rgba(255, 140, 20, 0.75),\n transparent 60%\n );\n transition: opacity 0.2s;\n}\n#fireOverlay.show {\n opacity: 1;\n animation: burn 0.35s infinite alternate;\n}\n@keyframes burn {\n from {\n filter: brightness(0.85);\n }\n to {\n filter: brightness(1.2);\n }\n}\n\n/* ---------- босс и титры ---------- */\n#bossBar {\n position: fixed;\n top: 14px;\n left: 50%;\n transform: translateX(-50%);\n width: min(380px, calc(100% - 32px));\n z-index: 6;\n text-align: center;\n color: #fff;\n text-shadow: 1px 1px 0 #000;\n font-size: 14px;\n pointer-events: none;\n}\n.bossTrack {\n margin-top: 4px;\n height: 8px;\n background: #3a1030;\n border: 1px solid #000;\n}\n.bossFill {\n height: 100%;\n width: 100%;\n background: #c040ff;\n}\n#creditsScreen {\n position: fixed;\n inset: 0;\n z-index: 36;\n background: rgba(20, 10, 40, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.credits {\n font-size: 16px;\n line-height: 1.6;\n margin-bottom: 20px;\n}\n\n/* ---------- чат / консоль ---------- */\n#chat {\n position: fixed;\n left: 10px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n width: min(520px, calc(100% - 20px));\n z-index: 8;\n pointer-events: none;\n font: 13px monospace;\n color: #fff;\n}\n.chatLog {\n display: flex;\n flex-direction: column;\n gap: 2px;\n max-height: 40vh;\n overflow: hidden;\n}\n.chatLine {\n background: rgba(0, 0, 0, 0.45);\n padding: 2px 6px;\n text-shadow: 1px 1px 0 #000;\n animation: chatFade 10s forwards;\n}\n#chat.open .chatLine {\n animation: none;\n opacity: 1;\n}\n@keyframes chatFade {\n 0%,\n 80% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n.chatInput {\n pointer-events: auto;\n width: 100%;\n margin-top: 4px;\n padding: 6px 8px;\n font: 14px monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.7);\n border: 1px solid #888;\n outline: none;\n}\n\n/* ---------- сенсорное управление ---------- */\n.touchUi {\n position: fixed;\n inset: 0;\n z-index: 6; /* над канвасом и HUD, под инвентарём, чатом и оверлеем */\n pointer-events: none;\n touch-action: none;\n}\n.touchUi > * {\n pointer-events: auto;\n touch-action: none;\n user-select: none;\n -webkit-user-select: none;\n}\n.tLook {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 40%;\n}\n.tStick {\n position: absolute;\n left: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 84px);\n width: 132px;\n height: 132px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.12);\n border: 2px solid rgba(255, 255, 255, 0.35);\n}\n.tKnob {\n position: absolute;\n left: 50%;\n top: 50%;\n width: 56px;\n height: 56px;\n margin: -28px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.42);\n pointer-events: none;\n}\n.tBtn {\n position: absolute;\n width: 62px;\n height: 62px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.35);\n border: 2px solid rgba(255, 255, 255, 0.45);\n color: #fff;\n font:\n bold 22px system-ui,\n sans-serif;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.tBtn.on {\n background: rgba(154, 223, 106, 0.5);\n}\n.tJump {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n}\n.tSneak {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 172px);\n}\n.tAttack {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 134px);\n}\n.tUse {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 210px);\n}\n.tInv {\n right: 12px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tDrop {\n right: 124px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tChat {\n right: 68px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n/* на телефоне хотбар над зоной обзора: иначе тап по слоту крутил бы камеру */\nbody.touch #hotbar {\n z-index: 7;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow,\n.invTop {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid,\n.craftGrid {\n display: grid;\n gap: 3px;\n}\n.armorCol {\n display: grid;\n gap: 3px;\n margin-right: 18px;\n}\n.chestGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n.furnaceCol {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n}\n.flame {\n width: 20px;\n height: 20px;\n background: #5a5a5a;\n display: flex;\n align-items: flex-end;\n}\n.flameFill {\n width: 100%;\n height: 0;\n background: linear-gradient(#ffe27a, #f06a14);\n}\n.cookArrow {\n width: 56px;\n height: 14px;\n background: #5a5a5a;\n margin: 0 8px;\n}\n.cookFill {\n height: 100%;\n width: 0;\n background: #f5f5f5;\n}\n.invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n.invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n/* наковальня: цена в уровнях справа от результата */\n.anvilCost {\n min-width: 110px;\n font-size: 12px;\n color: #2a6a2a;\n}\n\n/* стол зачарований: предмет и лазурит, три варианта; зачарованное — с фиолетовым отблеском */\n.enchCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.enchOffers {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 260px;\n}\n.enchOffer {\n padding: 6px 8px;\n font-size: 12px;\n cursor: pointer;\n color: #f0e8ff;\n background: #4a3a6a;\n border: 2px solid #2a1a40;\n}\n.enchOffer:hover {\n background: #5a4a8a;\n}\n/* ткацкий станок: картинка знамени слева от названия узора */\n.loomOffers .enchOffer {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.loomPic {\n width: 24px;\n height: 24px;\n image-rendering: pixelated;\n border: 1px solid #2a1a40;\n}\n/* карта сокровищ: крестик над кладом */\n.mapTreasure {\n position: absolute;\n transform: translate(-50%, -50%);\n color: #c02020;\n font-weight: bold;\n font-size: 14px;\n text-shadow: 0 0 2px #fff;\n pointer-events: none;\n}\n/* достижения: всплывашка справа сверху и список по L */\n#achToast {\n position: absolute;\n top: 12px;\n right: 12px;\n min-width: 220px;\n padding: 8px 12px;\n background: rgba(20, 20, 30, 0.88);\n border: 2px solid #7a6a3a;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 30;\n}\n#achToast .achHead {\n color: #f0d060;\n font-weight: bold;\n margin-bottom: 2px;\n}\n#achList {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: min(560px, 92vw);\n max-height: 80vh;\n overflow-y: auto;\n padding: 10px 14px;\n background: rgba(16, 16, 24, 0.92);\n border: 2px solid #555;\n color: #ccc;\n font-size: 13px;\n z-index: 29;\n}\n#achList h3 {\n margin: 0 0 8px;\n color: #fff;\n}\n#achList .ach {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding: 3px 0;\n border-bottom: 1px solid #2a2a36;\n}\n#achList .ach.done b {\n color: #9ae07a;\n}\n#achList .ach span {\n opacity: 0.7;\n text-align: right;\n}\n.enchOffer.on {\n outline: 2px solid #7ee8e0;\n}\n.beaconInfo {\n font-size: 12px;\n opacity: 0.85;\n margin: 2px 0 4px;\n}\n/* Тление: сердца чернеют */\n#hearts.decay .heart {\n filter: grayscale(1) brightness(0.4);\n}\n.enchOffer.off {\n cursor: default;\n color: #9a90a8;\n background: #5a5560;\n}\n.slot.ench img {\n filter: drop-shadow(0 0 3px #b060ff) saturate(1.3);\n}\n\n/* торговля с жителем: строки сделок с иконками, в тонах дерева */\n.tradeOffers {\n max-height: 260px;\n overflow-y: auto;\n}\n.tradeOffer {\n display: flex;\n align-items: center;\n gap: 4px;\n background: #5a4630;\n border-color: #3a2a18;\n color: #fff4e0;\n}\n.tradeOffer:hover {\n background: #6e583c;\n}\n.tradeOffer img {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n}\n.tradeLeft {\n margin-left: auto;\n padding-left: 8px;\n opacity: 0.75;\n}\n\n/* компас и часы в руке — у правого края хотбара */\n#gadget {\n position: absolute;\n bottom: 58px;\n left: calc(50% + 200px);\n min-width: 40px;\n padding: 4px 8px;\n text-align: center;\n font: 16px/1.2 monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.45);\n border-radius: 6px;\n pointer-events: none;\n}\n#gadget .needle {\n display: inline-block;\n color: #e03030;\n font-size: 22px;\n}\n\n/* карта в руке — справа над хотбаром */\n#mapView {\n position: absolute;\n right: 16px;\n bottom: 70px;\n width: min(256px, 40vw);\n aspect-ratio: 1;\n max-width: 100%;\n border: 4px solid #8a6a40;\n background: #d6be96;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n pointer-events: none;\n}\n#mapView canvas {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#mapView .mapMarker {\n position: absolute;\n color: #fff;\n font-size: 14px;\n text-shadow:\n 0 0 2px #000,\n 0 0 2px #000;\n}\n\n/* варочная стойка: ингредиент и топливо столбиком, три бутылки в ряд */\n.brewCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.bottleRow {\n display: flex;\n gap: 6px;\n}\n/* эффекты зелий — под индикатором времени */\n#effects {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 34px);\n right: 12px;\n z-index: 5;\n color: #fff;\n font-size: 13px;\n text-align: right;\n text-shadow: 1px 1px 0 #000;\n pointer-events: none;\n white-space: pre;\n}\n\n/* креатив: поиск, палитра всех предметов, корзина */\n.creativeSearch {\n width: 100%;\n box-sizing: border-box;\n margin-bottom: 8px;\n padding: 6px 8px;\n font-size: 14px;\n border: 2px solid #555;\n background: #fff;\n}\n.creativeGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n max-height: calc(44px * 5 + 3px * 4);\n overflow-y: auto;\n margin-bottom: 12px;\n padding-right: 4px;\n}\n.creativeGrid .slot {\n cursor: pointer;\n}\n.trashLabel {\n color: #3a3a3a;\n font-size: 13px;\n}\n.slot.trash::before {\n content: \"🗑\";\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 22px;\n opacity: 0.6;\n pointer-events: none;\n}\n#armorBar {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 16px;\n letter-spacing: 3px;\n text-shadow: 1px 1px 0 #000;\n}\n.armorPip {\n color: #cfd6e0;\n}\n.armorPip.half {\n opacity: 0.55;\n}\n.armorPip.empty {\n color: #3a3a3a;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(4, 32, 90, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n#overlay button.accent {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n#overlay button.accent:hover {\n background: #4c8535;\n}\n\n/* ---------- экран «Создать мир» ---------- */\n#worldScreen {\n position: fixed;\n inset: 0;\n z-index: 40; /* над оверлеем паузы */\n background: rgba(4, 20, 50, 0.72);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: calc(var(--idos-safe-top, 0px) + 12px) 12px\n calc(var(--idos-safe-bottom, 0px) + 12px);\n color: #eee;\n font-size: 14px;\n user-select: text;\n}\n.wsPanel {\n background: #22283a;\n border: 2px solid #4a5163;\n border-radius: 8px;\n box-shadow: 0 16px 50px rgba(0, 0, 0, 0.6);\n width: min(880px, 100%);\n max-height: 100%;\n overflow: auto;\n}\n.wsHead {\n display: flex;\n gap: 4px;\n padding: 8px 8px 0;\n border-bottom: 1px solid #3a4157;\n}\n.wsTab,\n.wsClose {\n cursor: pointer;\n background: none;\n border: none;\n color: #aab3c8;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 6px 6px 0 0;\n}\n.wsTab.active {\n color: #9adf6a;\n background: #2c3348;\n}\n.wsClose {\n margin-left: auto;\n font-size: 22px;\n line-height: 1;\n}\n.wsBody {\n display: flex;\n flex-wrap: wrap;\n gap: 18px;\n padding: 16px;\n}\n.wsForm {\n flex: 1 1 360px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n min-width: 0;\n}\n.wsPreview {\n flex: 0 1 280px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n align-items: stretch;\n}\n#wsMap {\n width: 100%;\n aspect-ratio: 1;\n image-rendering: pixelated;\n background: #10141f;\n border: 2px solid #3a4157;\n border-radius: 6px;\n}\n.wsLabel {\n color: #ffd76a;\n font-weight: 600;\n margin-top: 4px;\n}\n.wsNote {\n color: #8f99b0;\n font-weight: normal;\n font-size: 12px;\n}\n.wsRow {\n display: flex;\n flex-wrap: wrap;\n gap: 6px 14px;\n align-items: center;\n}\n.wsRow label {\n cursor: pointer;\n white-space: nowrap;\n}\n.wsRow input[type=\"number\"] {\n width: 56px;\n}\n.wsDrop {\n cursor: pointer;\n min-height: 92px;\n border: 2px dashed #5a6480;\n border-radius: 6px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n padding: 8px;\n color: #8f99b0;\n}\n.wsDrop.over {\n border-color: #9adf6a;\n background: rgba(154, 223, 106, 0.08);\n}\n.wsThumbs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n justify-content: center;\n}\n.wsThumb {\n position: relative;\n}\n.wsThumb img {\n height: 84px;\n max-width: 140px;\n object-fit: cover;\n border-radius: 4px;\n display: block;\n}\n.wsThumbX {\n position: absolute;\n top: -6px;\n right: -6px;\n width: 20px;\n height: 20px;\n line-height: 18px;\n text-align: center;\n border-radius: 50%;\n background: #c0392b;\n color: #fff;\n font-size: 14px;\n cursor: pointer;\n}\n#wsText {\n width: 100%;\n resize: vertical;\n font: inherit;\n padding: 8px;\n border-radius: 6px;\n border: 1px solid #4a5163;\n background: #161b28;\n color: #eee;\n}\n.wsButtons {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 6px;\n}\n#worldScreen button:not(.wsTab):not(.wsClose):not(.wsLink) {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#worldScreen button:disabled {\n opacity: 0.5;\n cursor: default;\n}\n#worldScreen button.wsPrimary {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n.wsLink {\n cursor: pointer;\n background: none;\n border: none;\n color: #8fb8ff;\n text-decoration: underline;\n font-size: 12px;\n}\n.wsStatus {\n min-height: 20px;\n color: #b8e59a;\n}\n.wsStatus.error {\n color: #ff8f80;\n}\n.wsInfo {\n color: #cfd6e6;\n font-size: 13px;\n line-height: 1.4;\n}\n.wsList {\n width: 100%;\n display: flex;\n flex-direction: column;\n gap: 6px;\n color: #aab3c8;\n}\n.wsItem {\n display: flex;\n gap: 8px;\n align-items: center;\n padding: 8px 10px;\n background: #2c3348;\n border-radius: 6px;\n}\n.wsItem > div {\n flex: 1;\n min-width: 0;\n}\n.wsItem.current {\n outline: 1px solid #9adf6a;\n}\n.wsItemName {\n color: #eee;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.wsItemMeta {\n font-size: 12px;\n}\n\n/* Книга на кафедре (ui/BookScreen): страница пергамента поверх игры */\n.bookScreen {\n position: absolute;\n left: 50%;\n top: 50%;\n transform: translate(-50%, -50%);\n width: 320px;\n padding: 14px;\n background: #e8dcb8;\n color: #2a1c0e;\n border: 3px solid #8a6a3a;\n font-size: 13px;\n z-index: 40;\n}\n.bookScreen.hidden {\n display: none;\n}\n.bookTitle {\n font-weight: bold;\n margin-bottom: 8px;\n text-align: center;\n}\n.bookPage {\n min-height: 180px;\n line-height: 15px;\n white-space: pre-wrap;\n}\n.bookPage.hidden,\n.bookNav.hidden {\n display: none;\n}\n.bookEdit {\n width: 100%;\n height: 180px;\n box-sizing: border-box;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n resize: none;\n}\n.bookEdit.hidden {\n display: none;\n}\n.bookNav {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 8px;\n}\n.bookBtn {\n padding: 4px 10px;\n cursor: pointer;\n background: #8a6a3a;\n color: #f4ecd0;\n border: 1px solid #5a4020;\n}\n.bookBtn:hover {\n background: #a07a44;\n}\n.bookCount {\n flex: 1;\n text-align: center;\n}\n.bookTitleInput {\n flex: 1;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n padding: 3px 5px;\n}\n"
|
|
1324
|
+
"content": "/* VoxelCraft HUD/UI. Пиксель-арт: image-rendering: pixelated везде, где иконки. */\n* {\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\nhtml,\nbody {\n width: 100%;\n height: 100%;\n overflow: hidden;\n background: #063d99;\n}\nbody {\n font-family: \"Segoe UI\", system-ui, sans-serif;\n user-select: none;\n}\n\n#game {\n position: fixed;\n inset: 0;\n width: 100%;\n height: 100%;\n display: block;\n}\n\n.hidden {\n display: none !important;\n}\n\n/* ---------- HUD ---------- */\n#crosshair {\n position: fixed;\n left: 50%;\n top: 50%;\n width: 18px;\n height: 18px;\n transform: translate(-50%, -50%);\n pointer-events: none;\n z-index: 5;\n}\n#crosshair::before,\n#crosshair::after {\n content: \"\";\n position: absolute;\n background: rgba(255, 255, 255, 0.85);\n mix-blend-mode: difference;\n}\n#crosshair::before {\n left: 8px;\n top: 0;\n width: 2px;\n height: 18px;\n}\n#crosshair::after {\n left: 0;\n top: 8px;\n width: 18px;\n height: 2px;\n}\n\n/* --idos-safe-top: высота общего HUD host'а (0 без него) — служебные строки не уходят под HUD. */\n#fps {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#debug {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 26px);\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* --idos-safe-bottom: высота нижней навигации host'а (0, когда режим один) — хотбар над ней. */\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: calc(var(--idos-safe-bottom, 0px) + 8px);\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n.slot .dur {\n position: absolute;\n left: 4px;\n bottom: 3px;\n height: 3px;\n display: none;\n pointer-events: none;\n box-shadow: 0 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#xpBar {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 60px);\n width: min(400px, 90vw);\n height: 6px;\n background: rgba(20, 20, 20, 0.8);\n border: 1px solid #000;\n z-index: 5;\n pointer-events: none;\n}\n.xpFill {\n height: 100%;\n width: 0;\n background: linear-gradient(#b8ff5a, #5ec21e);\n}\n#xpLevel {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 64px);\n color: #8aff3a;\n font: bold 14px monospace;\n text-shadow:\n 1px 1px 0 #000,\n -1px -1px 0 #000;\n z-index: 6;\n pointer-events: none;\n}\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n/* пузырьки воздуха над едой — под водой */\n#bubbles {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 15px;\n color: #7ad0ff;\n}\n.bubble.empty {\n opacity: 0;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- смерть, горение ---------- */\n#deathScreen {\n position: fixed;\n inset: 0;\n z-index: 35;\n background: rgba(120, 0, 0, 0.55);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.deathPanel {\n text-align: center;\n color: #fff;\n text-shadow: 2px 2px 0 #000;\n}\n.deathPanel h1 {\n font-size: 46px;\n margin-bottom: 8px;\n}\n.deathCause {\n font-size: 16px;\n margin-bottom: 20px;\n}\n.deathBtn {\n cursor: pointer;\n font-size: 16px;\n padding: 10px 28px;\n border-radius: 4px;\n border: 2px solid #aaa;\n background: #3a3f4d;\n color: #eee;\n}\n.deathBtn:hover {\n background: #4a5163;\n}\n#fireOverlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n opacity: 0;\n background: radial-gradient(\n ellipse at 50% 120%,\n rgba(255, 140, 20, 0.75),\n transparent 60%\n );\n transition: opacity 0.2s;\n}\n#fireOverlay.show {\n opacity: 1;\n animation: burn 0.35s infinite alternate;\n}\n@keyframes burn {\n from {\n filter: brightness(0.85);\n }\n to {\n filter: brightness(1.2);\n }\n}\n\n/* ---------- босс и титры ---------- */\n#bossBar {\n position: fixed;\n top: 14px;\n left: 50%;\n transform: translateX(-50%);\n width: min(380px, calc(100% - 32px));\n z-index: 6;\n text-align: center;\n color: #fff;\n text-shadow: 1px 1px 0 #000;\n font-size: 14px;\n pointer-events: none;\n}\n.bossTrack {\n margin-top: 4px;\n height: 8px;\n background: #3a1030;\n border: 1px solid #000;\n}\n.bossFill {\n height: 100%;\n width: 100%;\n background: #c040ff;\n}\n#creditsScreen {\n position: fixed;\n inset: 0;\n z-index: 36;\n background: rgba(20, 10, 40, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.credits {\n font-size: 16px;\n line-height: 1.6;\n margin-bottom: 20px;\n}\n\n/* ---------- чат / консоль ---------- */\n#chat {\n position: fixed;\n left: 10px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n width: min(520px, calc(100% - 20px));\n z-index: 8;\n pointer-events: none;\n font: 13px monospace;\n color: #fff;\n}\n.chatLog {\n display: flex;\n flex-direction: column;\n gap: 2px;\n max-height: 40vh;\n overflow: hidden;\n}\n.chatLine {\n background: rgba(0, 0, 0, 0.45);\n padding: 2px 6px;\n text-shadow: 1px 1px 0 #000;\n animation: chatFade 10s forwards;\n}\n#chat.open .chatLine {\n animation: none;\n opacity: 1;\n}\n@keyframes chatFade {\n 0%,\n 80% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n.chatInput {\n pointer-events: auto;\n width: 100%;\n margin-top: 4px;\n padding: 6px 8px;\n font: 14px monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.7);\n border: 1px solid #888;\n outline: none;\n}\n\n/* ---------- сенсорное управление ---------- */\n.touchUi {\n position: fixed;\n inset: 0;\n z-index: 6; /* над канвасом и HUD, под инвентарём, чатом и оверлеем */\n pointer-events: none;\n touch-action: none;\n}\n.touchUi > * {\n pointer-events: auto;\n touch-action: none;\n user-select: none;\n -webkit-user-select: none;\n}\n.tLook {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 40%;\n}\n.tStick {\n position: absolute;\n left: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 84px);\n width: 132px;\n height: 132px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.12);\n border: 2px solid rgba(255, 255, 255, 0.35);\n}\n.tKnob {\n position: absolute;\n left: 50%;\n top: 50%;\n width: 56px;\n height: 56px;\n margin: -28px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.42);\n pointer-events: none;\n}\n.tBtn {\n position: absolute;\n width: 62px;\n height: 62px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.35);\n border: 2px solid rgba(255, 255, 255, 0.45);\n color: #fff;\n font:\n bold 22px system-ui,\n sans-serif;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.tBtn.on {\n background: rgba(154, 223, 106, 0.5);\n}\n.tJump {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n}\n.tSneak {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 172px);\n}\n.tAttack {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 134px);\n}\n.tUse {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 210px);\n}\n.tInv {\n right: 12px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tDrop {\n right: 124px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tChat {\n right: 68px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n/* на телефоне хотбар над зоной обзора: иначе тап по слоту крутил бы камеру */\nbody.touch #hotbar {\n z-index: 7;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow,\n.invTop {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid,\n.craftGrid {\n display: grid;\n gap: 3px;\n}\n.armorCol {\n display: grid;\n gap: 3px;\n margin-right: 18px;\n}\n.chestGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n.furnaceCol {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n}\n.flame {\n width: 20px;\n height: 20px;\n background: #5a5a5a;\n display: flex;\n align-items: flex-end;\n}\n.flameFill {\n width: 100%;\n height: 0;\n background: linear-gradient(#ffe27a, #f06a14);\n}\n.cookArrow {\n width: 56px;\n height: 14px;\n background: #5a5a5a;\n margin: 0 8px;\n}\n.cookFill {\n height: 100%;\n width: 0;\n background: #f5f5f5;\n}\n.invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n.invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n/* наковальня: цена в уровнях справа от результата */\n.anvilCost {\n min-width: 110px;\n font-size: 12px;\n color: #2a6a2a;\n}\n\n/* стол зачарований: предмет и лазурит, три варианта; зачарованное — с фиолетовым отблеском */\n.enchCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.enchOffers {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 260px;\n}\n.enchOffer {\n padding: 6px 8px;\n font-size: 12px;\n cursor: pointer;\n color: #f0e8ff;\n background: #4a3a6a;\n border: 2px solid #2a1a40;\n}\n.enchOffer:hover {\n background: #5a4a8a;\n}\n/* ткацкий станок: картинка знамени слева от названия узора */\n.loomOffers .enchOffer {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.loomPic {\n width: 24px;\n height: 24px;\n image-rendering: pixelated;\n border: 1px solid #2a1a40;\n}\n/* карта сокровищ: крестик над кладом */\n.mapTreasure {\n position: absolute;\n transform: translate(-50%, -50%);\n color: #c02020;\n font-weight: bold;\n font-size: 14px;\n text-shadow: 0 0 2px #fff;\n pointer-events: none;\n}\n/* достижения: всплывашка справа сверху и список по L */\n#achToast {\n position: absolute;\n top: 12px;\n right: 12px;\n min-width: 220px;\n padding: 8px 12px;\n background: rgba(20, 20, 30, 0.88);\n border: 2px solid #7a6a3a;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 30;\n}\n#achToast .achHead {\n color: #f0d060;\n font-weight: bold;\n margin-bottom: 2px;\n}\n#achList {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: min(560px, 92vw);\n max-height: 80vh;\n overflow-y: auto;\n padding: 10px 14px;\n background: rgba(16, 16, 24, 0.92);\n border: 2px solid #555;\n color: #ccc;\n font-size: 13px;\n z-index: 29;\n}\n#achList h3 {\n margin: 0 0 8px;\n color: #fff;\n}\n#achList .ach {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding: 3px 0;\n border-bottom: 1px solid #2a2a36;\n}\n#achList .ach.done b {\n color: #9ae07a;\n}\n#achList .ach span {\n opacity: 0.7;\n text-align: right;\n}\n.enchOffer.on {\n outline: 2px solid #7ee8e0;\n}\n.beaconInfo {\n font-size: 12px;\n opacity: 0.85;\n margin: 2px 0 4px;\n}\n/* Тление: сердца чернеют */\n#hearts.decay .heart {\n filter: grayscale(1) brightness(0.4);\n}\n.enchOffer.off {\n cursor: default;\n color: #9a90a8;\n background: #5a5560;\n}\n.slot.ench img {\n filter: drop-shadow(0 0 3px #b060ff) saturate(1.3);\n}\n\n/* торговля с жителем: строки сделок с иконками, в тонах дерева */\n.tradeOffers {\n max-height: 260px;\n overflow-y: auto;\n}\n.tradeOffer {\n display: flex;\n align-items: center;\n gap: 4px;\n background: #5a4630;\n border-color: #3a2a18;\n color: #fff4e0;\n}\n.tradeOffer:hover {\n background: #6e583c;\n}\n.tradeOffer img {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n}\n.tradeLeft {\n margin-left: auto;\n padding-left: 8px;\n opacity: 0.75;\n}\n\n/* компас и часы в руке — у правого края хотбара */\n#gadget {\n position: absolute;\n bottom: 58px;\n left: calc(50% + 200px);\n min-width: 40px;\n padding: 4px 8px;\n text-align: center;\n font: 16px/1.2 monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.45);\n border-radius: 6px;\n pointer-events: none;\n}\n#gadget .needle {\n display: inline-block;\n color: #e03030;\n font-size: 22px;\n}\n\n/* карта в руке — справа над хотбаром */\n#mapView {\n position: absolute;\n right: 16px;\n bottom: 70px;\n width: min(256px, 40vw);\n aspect-ratio: 1;\n max-width: 100%;\n border: 4px solid #8a6a40;\n background: #d6be96;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n pointer-events: none;\n}\n#mapView canvas {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#mapView .mapMarker {\n position: absolute;\n color: #fff;\n font-size: 14px;\n text-shadow:\n 0 0 2px #000,\n 0 0 2px #000;\n}\n\n/* варочная стойка: ингредиент и топливо столбиком, три бутылки в ряд */\n.brewCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.bottleRow {\n display: flex;\n gap: 6px;\n}\n/* эффекты зелий — под индикатором времени */\n#effects {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 34px);\n right: 12px;\n z-index: 5;\n color: #fff;\n font-size: 13px;\n text-align: right;\n text-shadow: 1px 1px 0 #000;\n pointer-events: none;\n white-space: pre;\n}\n\n/* креатив: поиск, палитра всех предметов, корзина */\n.creativeSearch {\n width: 100%;\n box-sizing: border-box;\n margin-bottom: 8px;\n padding: 6px 8px;\n font-size: 14px;\n border: 2px solid #555;\n background: #fff;\n}\n.creativeGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n max-height: calc(44px * 5 + 3px * 4);\n overflow-y: auto;\n margin-bottom: 12px;\n padding-right: 4px;\n}\n.creativeGrid .slot {\n cursor: pointer;\n}\n.trashLabel {\n color: #3a3a3a;\n font-size: 13px;\n}\n.slot.trash::before {\n content: \"🗑\";\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 22px;\n opacity: 0.6;\n pointer-events: none;\n}\n#armorBar {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 16px;\n letter-spacing: 3px;\n text-shadow: 1px 1px 0 #000;\n}\n.armorPip {\n color: #cfd6e0;\n}\n.armorPip.half {\n opacity: 0.55;\n}\n.armorPip.empty {\n color: #3a3a3a;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(4, 32, 90, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n#overlay button.accent {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n#overlay button.accent:hover {\n background: #4c8535;\n}\n\n/* ---------- экран «Создать мир» ---------- */\n#worldScreen {\n position: fixed;\n inset: 0;\n z-index: 40; /* над оверлеем паузы */\n background: rgba(4, 20, 50, 0.72);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: calc(var(--idos-safe-top, 0px) + 12px) 12px\n calc(var(--idos-safe-bottom, 0px) + 12px);\n color: #eee;\n font-size: 14px;\n user-select: text;\n}\n.wsPanel {\n background: #22283a;\n border: 2px solid #4a5163;\n border-radius: 8px;\n box-shadow: 0 16px 50px rgba(0, 0, 0, 0.6);\n width: min(880px, 100%);\n max-height: 100%;\n overflow: auto;\n}\n.wsHead {\n display: flex;\n gap: 4px;\n padding: 8px 8px 0;\n border-bottom: 1px solid #3a4157;\n}\n.wsTab,\n.wsClose {\n cursor: pointer;\n background: none;\n border: none;\n color: #aab3c8;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 6px 6px 0 0;\n}\n.wsTab.active {\n color: #9adf6a;\n background: #2c3348;\n}\n.wsClose {\n margin-left: auto;\n font-size: 22px;\n line-height: 1;\n}\n.wsBody {\n display: flex;\n flex-wrap: wrap;\n gap: 18px;\n padding: 16px;\n}\n.wsForm {\n flex: 1 1 360px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n min-width: 0;\n}\n.wsPreview {\n flex: 0 1 280px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n align-items: stretch;\n}\n#wsMap {\n width: 100%;\n aspect-ratio: 1;\n image-rendering: pixelated;\n background: #10141f;\n border: 2px solid #3a4157;\n border-radius: 6px;\n}\n.wsLabel {\n color: #ffd76a;\n font-weight: 600;\n margin-top: 4px;\n}\n.wsNote {\n color: #8f99b0;\n font-weight: normal;\n font-size: 12px;\n}\n.wsRow {\n display: flex;\n flex-wrap: wrap;\n gap: 6px 14px;\n align-items: center;\n}\n.wsRow label {\n cursor: pointer;\n white-space: nowrap;\n}\n.wsRow input[type=\"number\"] {\n width: 56px;\n}\n.wsDrop {\n cursor: pointer;\n min-height: 92px;\n border: 2px dashed #5a6480;\n border-radius: 6px;\n display: flex;\n align-items: center;\n justify-content: center;\n text-align: center;\n padding: 8px;\n color: #8f99b0;\n}\n.wsDrop.over {\n border-color: #9adf6a;\n background: rgba(154, 223, 106, 0.08);\n}\n.wsThumbs {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n justify-content: center;\n}\n.wsThumb {\n position: relative;\n}\n.wsThumb img {\n height: 84px;\n max-width: 140px;\n object-fit: cover;\n border-radius: 4px;\n display: block;\n}\n.wsThumbX {\n position: absolute;\n top: -6px;\n right: -6px;\n width: 20px;\n height: 20px;\n line-height: 18px;\n text-align: center;\n border-radius: 50%;\n background: #c0392b;\n color: #fff;\n font-size: 14px;\n cursor: pointer;\n}\n#wsText {\n width: 100%;\n resize: vertical;\n font: inherit;\n padding: 8px;\n border-radius: 6px;\n border: 1px solid #4a5163;\n background: #161b28;\n color: #eee;\n}\n.wsButtons {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 6px;\n}\n#worldScreen button:not(.wsTab):not(.wsClose):not(.wsLink) {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#worldScreen button:disabled {\n opacity: 0.5;\n cursor: default;\n}\n#worldScreen button.wsPrimary {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n.wsLink {\n cursor: pointer;\n background: none;\n border: none;\n color: #8fb8ff;\n text-decoration: underline;\n font-size: 12px;\n}\n.wsStatus {\n min-height: 20px;\n color: #b8e59a;\n}\n.wsStatus.error {\n color: #ff8f80;\n}\n.wsInfo {\n color: #cfd6e6;\n font-size: 13px;\n line-height: 1.4;\n}\n.wsList {\n width: 100%;\n display: flex;\n flex-direction: column;\n gap: 6px;\n color: #aab3c8;\n}\n.wsItem {\n display: flex;\n gap: 8px;\n align-items: center;\n padding: 8px 10px;\n background: #2c3348;\n border-radius: 6px;\n}\n.wsItem > div {\n flex: 1;\n min-width: 0;\n}\n.wsItem.current {\n outline: 1px solid #9adf6a;\n}\n.wsItemName {\n color: #eee;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.wsItemMeta {\n font-size: 12px;\n}\n\n/* Книга на кафедре (ui/BookScreen): страница пергамента поверх игры */\n.bookScreen {\n position: absolute;\n left: 50%;\n top: 50%;\n transform: translate(-50%, -50%);\n width: 320px;\n padding: 14px;\n background: #e8dcb8;\n color: #2a1c0e;\n border: 3px solid #8a6a3a;\n font-size: 13px;\n z-index: 40;\n}\n.bookScreen.hidden {\n display: none;\n}\n.bookTitle {\n font-weight: bold;\n margin-bottom: 8px;\n text-align: center;\n}\n.bookPage {\n min-height: 180px;\n line-height: 15px;\n white-space: pre-wrap;\n}\n.bookPage.hidden,\n.bookNav.hidden {\n display: none;\n}\n.bookEdit {\n width: 100%;\n height: 180px;\n box-sizing: border-box;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n resize: none;\n}\n.bookEdit.hidden {\n display: none;\n}\n.bookNav {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 8px;\n}\n.bookBtn {\n padding: 4px 10px;\n cursor: pointer;\n background: #8a6a3a;\n color: #f4ecd0;\n border: 1px solid #5a4020;\n}\n.bookBtn:hover {\n background: #a07a44;\n}\n.bookCount {\n flex: 1;\n text-align: center;\n}\n.bookTitleInput {\n flex: 1;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n padding: 3px 5px;\n}\n\n/* ---------- сетевая игра (ui/NetUI) ---------- */\n#netScreen {\n position: fixed;\n inset: 0;\n z-index: 40;\n background: rgba(4, 20, 50, 0.72);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: calc(var(--idos-safe-top, 0px) + 12px) 12px\n calc(var(--idos-safe-bottom, 0px) + 12px);\n color: #eee;\n font-size: 14px;\n user-select: text;\n}\n#netScreen.hidden,\n#netTabList.hidden,\n#netScreen .hidden {\n display: none;\n}\n#netScreen .wsPanel {\n width: min(620px, 100%);\n}\n#netScreen .wsForm button {\n cursor: pointer;\n font-size: 13px;\n padding: 6px 12px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#netScreen .wsForm button.wsPrimary {\n background: #3d6b2a;\n border-color: #9adf6a;\n}\n#netScreen .wsForm button.wsLink {\n padding: 0 4px;\n border: none;\n background: none;\n color: #8fb8ff;\n}\n.netInput {\n flex: none;\n width: 100%;\n box-sizing: border-box;\n min-width: 0;\n font: inherit;\n padding: 7px 8px;\n border-radius: 6px;\n border: 1px solid #4a5163;\n background: #161b28;\n color: #eee;\n}\n#netTabList {\n position: absolute;\n top: calc(var(--idos-safe-top, 0px) + 40px);\n left: 50%;\n transform: translateX(-50%);\n z-index: 30;\n min-width: 240px;\n max-width: calc(100% - 32px);\n padding: 8px 12px;\n background: rgba(0, 0, 0, 0.6);\n border-radius: 6px;\n color: #fff;\n font-size: 14px;\n pointer-events: none;\n}\n.wsRow .netInput {\n flex: 1 1 200px;\n width: auto;\n}\n.netTabRow {\n display: flex;\n justify-content: space-between;\n gap: 24px;\n padding: 2px 0;\n}\n.netTabPing {\n color: #9adf6a;\n font-size: 12px;\n}\n"
|
|
1280
1325
|
},
|
|
1281
1326
|
{
|
|
1282
1327
|
"path": "systems/Achievements.test.ts",
|
|
@@ -1312,7 +1357,7 @@
|
|
|
1312
1357
|
},
|
|
1313
1358
|
{
|
|
1314
1359
|
"path": "systems/BlockUpdates.ts",
|
|
1315
|
-
"content": "// Обновления соседей: блок, у которого пропала опора (факел, растение, дверь, лестница…), ломается\n// с дропом, как в Minecraft. Слушает blockChanged и перепроверяет шестерых соседей изменённой\n// клетки; цепочки (дверь → её вторая половина) разворачиваются через те же события.\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { readsSignal } from \"../world/Redstone\";\nimport { isSupported } from \"../world/Support\";\nimport { BEHAVIOURS } from \"../world/blockBehaviour\";\nimport { dropsOf } from \"./Mining\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { ItemStack } from \"../types\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\n\n/** Куда падает дроп (EntityManager); остальное — для машин (воронка, раздатчик). */\nexport interface DropSink {\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 ): unknown;\n /** Вагонетка с сундуком или воронкой в клетке — как контейнер (воронка берёт из неё и кладёт в неё). */\n cartItems?(x: number, y: number, z: number): BlockEntity | null;\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 /** Выбросить стак с начальной скоростью (выбрасыватель). */\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 /** Пустить стрелу (раздатчик). */\n shootArrow?(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n /** Вид стрелы: обычная или отравленная (systems/TippedArrow). */\n item?: string,\n ): void;\n /** Выпустить пчелу из улья в (x,y,z); false — рядом нет места. */\n spawnBee?(x: number, y: number, z: number): boolean;\n}\n\nconst NEIGHBORS = [\n [1, 0, 0],\n [-1, 0, 0],\n [0, 1, 0],\n [0, -1, 0],\n [0, 0, 1],\n [0, 0, -1],\n] as const;\n\nexport class BlockUpdates {\n /** @param world — текущий мир (он пересоздаётся при смене мира/измерения) */\n constructor(\n private readonly world: () => ChunkManager,\n private readonly drops: DropSink,\n ) {\n events.on(\n \"blockChanged\",\n ({ x, y, z }: { x: number; y: number; z: number })
|
|
1360
|
+
"content": "// Обновления соседей: блок, у которого пропала опора (факел, растение, дверь, лестница…), ломается\n// с дропом, как в Minecraft. Слушает blockChanged и перепроверяет шестерых соседей изменённой\n// клетки; цепочки (дверь → её вторая половина) разворачиваются через те же события.\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { readsSignal } from \"../world/Redstone\";\nimport { isSupported } from \"../world/Support\";\nimport { BEHAVIOURS } from \"../world/blockBehaviour\";\nimport { dropsOf } from \"./Mining\";\nimport { sim } from \"../net/sim\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { ItemStack } from \"../types\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\n\n/** Куда падает дроп (EntityManager); остальное — для машин (воронка, раздатчик). */\nexport interface DropSink {\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 ): unknown;\n /** Вагонетка с сундуком или воронкой в клетке — как контейнер (воронка берёт из неё и кладёт в неё). */\n cartItems?(x: number, y: number, z: number): BlockEntity | null;\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 /** Выбросить стак с начальной скоростью (выбрасыватель). */\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 /** Пустить стрелу (раздатчик). */\n shootArrow?(\n x: number,\n y: number,\n z: number,\n vx: number,\n vy: number,\n vz: number,\n /** Вид стрелы: обычная или отравленная (systems/TippedArrow). */\n item?: string,\n ): void;\n /** Выпустить пчелу из улья в (x,y,z); false — рядом нет места. */\n spawnBee?(x: number, y: number, z: number): boolean;\n}\n\nconst NEIGHBORS = [\n [1, 0, 0],\n [-1, 0, 0],\n [0, 1, 0],\n [0, -1, 0],\n [0, 0, 1],\n [0, 0, -1],\n] as const;\n\nexport class BlockUpdates {\n /** @param world — текущий мир (он пересоздаётся при смене мира/измерения) */\n constructor(\n private readonly world: () => ChunkManager,\n private readonly drops: DropSink,\n ) {\n // Реплика (сетевая игра, чужое измерение) каскадов не считает: их посчитает владелец измерения\n // и пришлёт результат (net/sim).\n events.on(\n \"blockChanged\",\n ({ x, y, z }: { x: number; y: number; z: number }) => {\n if (sim.authority) this.checkAround(x, y, z);\n },\n );\n // проверить опору одной клетки (растение на своём случайном тике: тростник без воды осыпается)\n events.on(\n \"blockRecheck\",\n ({ x, y, z }: { x: number; y: number; z: number }) => {\n if (sim.authority) this.check(x, y, z);\n },\n );\n // сломать с дропом (поршень упёрся в факел или траву)\n events.on(\n \"blockDestroy\",\n ({ x, y, z }: { x: number; y: number; z: number }) => {\n if (sim.authority) this.destroy(x, y, z);\n },\n );\n }\n\n checkAround(x: number, y: number, z: number): void {\n this.notify(x, y, z); // сам изменённый блок (поставленный песок проверит опору)\n for (const [dx, dy, dz] of NEIGHBORS) this.check(x + dx, y + dy, z + dz);\n // второй круг — только читателям сигнала: сигнал проходит сквозь твёрдый блок\n const w = this.world();\n for (const [ax, ay, az] of NEIGHBORS)\n for (const [bx, by, bz] of NEIGHBORS) {\n const nx = x + ax + bx,\n ny = y + ay + by,\n nz = z + az + bz;\n if (\n (nx !== x || ny !== y || nz !== z) &&\n readsSignal(w.getBlock(nx, ny, nz))\n )\n this.notify(nx, ny, nz);\n }\n }\n\n /** Сообщить блоку, что рядом что-то изменилось (падающие блоки, жидкости). */\n private notify(x: number, y: number, z: number): void {\n const w = this.world();\n const id = w.getBlock(x, y, z);\n const fn = BEHAVIOURS.get(id)?.neighborChanged;\n if (fn)\n fn({ world: w, x, y, z, id, meta: w.getMeta(x, y, z), rng: Math.random });\n }\n\n /** Сломать блок в клетке, если он больше не держится; иначе — сообщить ему о соседе. */\n check(x: number, y: number, z: number): void {\n const w = this.world();\n const id = w.getBlock(x, y, z);\n if (id === B.air) return;\n const meta = w.getMeta(x, y, z);\n if (isSupported(w, x, y, z, id, meta)) {\n this.notify(x, y, z);\n return;\n }\n this.destroy(x, y, z);\n }\n\n /** Сломать блок с дропом: пропала опора, раздавил поршень. */\n destroy(x: number, y: number, z: number): void {\n const w = this.world();\n const id = w.getBlock(x, y, z);\n if (id === B.air) return;\n const meta = w.getMeta(x, y, z);\n w.setBlock(x, y, z, B.air!);\n events.emit(\"blockBreak\", { x, y, z, id, byPlayer: false });\n for (const d of dropsOf(BLOCKS[id]!, meta))\n if (ITEMS[d.item])\n this.drops.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n }\n}\n"
|
|
1316
1361
|
},
|
|
1317
1362
|
{
|
|
1318
1363
|
"path": "systems/Book.test.ts",
|
|
@@ -1332,15 +1377,15 @@
|
|
|
1332
1377
|
},
|
|
1333
1378
|
{
|
|
1334
1379
|
"path": "systems/Commands.ts",
|
|
1335
|
-
"content": "// Команды консоли, как в Minecraft (/give, /tp, /time…). Текст приходит интентом Command (из\n// консоли игрока или от агента AI-кодера) и исполняется на тике — тем же путём, что и остальной\n// ввод. Выключаются целиком CONFIG.commands.enabled (издатель может закрыть читы в своей игре).\nimport { CONFIG } from \"../config\";\nimport {\n DIFFICULTY_NAMES,\n parseDifficulty,\n rules,\n setDifficulty,\n} from \"./Difficulty\";\nimport { events } from \"../core/EventBus\";\nimport { ALIASES, resolveBlock, resolveItem } from \"../registry/aliases\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { ITEMS } from \"../registry/Items\";\nimport { ENCHANTS, enchantLine, withEnchants, type EnchantId } from \"./Enchant\";\nimport { EFFECT_NAMES, labelOf } from \"../registry/names\";\nimport { startCharge } from \"../entities/AshLordBrain\";\nimport { ACHIEVEMENTS } from \"./Achievements\";\nimport type { Game } from \"../main\";\nimport type { EffectId } from \"../types\";\n\ntype Handler = (game: Game, args: string[]) => string;\n\n/** Координата: число или `~`/`~N` относительно игрока. */\nfunction coord(arg: string | undefined, base: number): number {\n if (arg === undefined) throw new Error(\"не хватает координат\");\n if (arg.startsWith(\"~\"))\n return base + (arg.length > 1 ? Number(arg.slice(1)) : 0);\n const v = Number(arg);\n if (!Number.isFinite(v)) throw new Error(\"не число: \" + arg);\n return v;\n}\n\n/** Три координаты начиная с args[i] (целые — для блоков). */\nfunction xyz(\n game: Game,\n args: string[],\n i: number,\n whole: boolean,\n): [number, number, number] {\n const p = game.player.pos;\n const r = [\n coord(args[i], p.x),\n coord(args[i + 1], p.y),\n coord(args[i + 2], p.z),\n ] as [number, number, number];\n return whole ? (r.map(Math.floor) as [number, number, number]) : r;\n}\n\nfunction blockArg(name: string | undefined): number {\n const id = name === undefined ? null : resolveBlock(name);\n if (id === null) throw new Error(\"нет такого блока: \" + name);\n return id;\n}\n\n/** Мета по умолчанию: жидкость ставится источником (как в Minecraft), иначе 0 — вода с метой 0 утекает. */\nconst defaultMeta = (id: number): number =>\n id === B.water || id === B.lava ? packWater(8, true) : 0;\n\nconst TIMES: Record<string, (d: number, n: number) => number> = {\n day: (d) => d * 0.3,\n noon: (d) => d * 0.5,\n sunset: (d) => d * 0.94,\n night: (d, n) => d + n * 0.3,\n midnight: (d, n) => d + n * 0.5,\n};\n\nconst HANDLERS: Record<string, Handler> = {\n help: () =>\n \"Команды: /give <предмет> [n], /tp x y z, /time set day|noon|night|midnight|<сек>, /weather clear|rain|thunder [сек], \" +\n \"/difficulty мирная|лёгкая|обычная|высокая, /gamemode survival|creative, /setblock x y z <блок> [мета], /fill x1 y1 z1 x2 y2 z2 <блок>, \" +\n \"/summon <моб> [x y z], /kill [моб], /effect <эффект>|clear [сек] [уровень], /achievements, /music on|off|next, /clear, /seed, /locate fortress|citadel|village|outpost|temple|shipwreck|buried_treasure|mansion|ancient_city|trail_ruins|trial_chambers|city, /enchant <зачарование> [уровень], /newworld [сид]. Координаты можно с ~ (относительно себя).\",\n\n give: (g, [name, n]) => {\n const item = name === undefined ? null : resolveItem(name);\n if (!item) throw new Error(\"нет такого предмета: \" + name);\n const count = Math.max(\n 1,\n Math.min(64 * 36, Math.floor(Number(n ?? 1)) || 1),\n );\n const left = g.inventory.add(item, count);\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(p.x, p.y + 1, p.z, item, left);\n }\n return `Выдано: ${labelOf(item)} ×${count}`;\n },\n\n tp: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, false);\n g.warmup(x, z);\n if (g.player.riding) g.player.dismount(); // телепорт высаживает из транспорта, как в Minecraft\n g.player.pos.set(x, y, z);\n g.player.vel.set(0, 0, 0);\n g.player._fallDist = 0;\n return `Телепорт: ${x.toFixed(1)} ${y.toFixed(1)} ${z.toFixed(1)}`;\n },\n\n time: (g, [op, v]) => {\n const { dayLength, nightLength } = CONFIG.dayNight;\n const cycle = dayLength + nightLength;\n if (op === \"set\" && v !== undefined) {\n const named = TIMES[v];\n const t = named ? named(dayLength, nightLength) : Number(v);\n if (!Number.isFinite(t))\n throw new Error(\"время: day, noon, night, midnight или секунды\");\n g.dayNight.time = ((t % cycle) + cycle) % cycle;\n } else if (op === \"add\" && v !== undefined) {\n g.dayNight.time =\n (((g.dayNight.time + Number(v)) % cycle) + cycle) % cycle;\n } else throw new Error(\"/time set <день|ночь|сек> или /time add <сек>\");\n return \"Время: \" + Math.round(g.dayNight.time) + \" c\";\n },\n\n weather: (g, [k, sec]) => {\n if (k !== \"clear\" && k !== \"rain\" && k !== \"thunder\")\n throw new Error(\"/weather clear|rain|thunder [сек]\");\n const s = sec === undefined ? undefined : Number(sec);\n if (s !== undefined && !(s > 0)) throw new Error(\"не число: \" + sec);\n g.weather.set(k, s);\n return (\n \"Погода: \" + (k === \"clear\" ? \"ясно\" : k === \"rain\" ? \"дождь\" : \"гроза\")\n );\n },\n\n gamemode: (g, [m]) => {\n const mode =\n m === \"creative\" || m === \"1\" || m === \"c\"\n ? \"creative\"\n : m === \"survival\" || m === \"0\" || m === \"s\"\n ? \"survival\"\n : null;\n if (!mode) throw new Error(\"/gamemode survival|creative\");\n if (g.player.mode !== mode) g.player.toggleMode();\n return \"Режим: \" + (mode === \"creative\" ? \"креатив\" : \"выживание\");\n },\n\n difficulty: (g, [d]) => {\n const next = parseDifficulty(d);\n if (!next)\n throw new Error(\"/difficulty мирная|лёгкая|обычная|высокая (0..3)\");\n setDifficulty(next);\n // на мирной монстры исчезают сразу, как в Minecraft\n if (!rules().hostiles) g.entities.clearHostiles();\n events.emit(\"difficultyChanged\", { difficulty: next });\n return \"Сложность: \" + DIFFICULTY_NAMES[next];\n },\n\n setblock: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, true);\n const id = blockArg(args[3]);\n const meta =\n args[4] === undefined ? defaultMeta(id) : Number(args[4]) & 255;\n if (!g.world.setBlock(x, y, z, id, meta))\n throw new Error(\"клетка вне загруженного мира\");\n return `Блок ${labelOf(args[3]!)} в ${x} ${y} ${z}`;\n },\n\n fill: (g, args) => {\n const [x1, y1, z1] = xyz(g, args, 0, true);\n const [x2, y2, z2] = xyz(g, args, 3, true);\n const id = blockArg(args[6]);\n const cells =\n (Math.abs(x2 - x1) + 1) *\n (Math.abs(y2 - y1) + 1) *\n (Math.abs(z2 - z1) + 1);\n if (cells > 65536)\n throw new Error(`слишком много блоков: ${cells} (максимум 65536)`);\n const meta = defaultMeta(id);\n let n = 0;\n for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++)\n for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++)\n for (let z = Math.min(z1, z2); z <= Math.max(z1, z2); z++)\n if (g.world.setBlock(x, y, z, id, meta)) n++;\n return `Заполнено блоков: ${n}`;\n },\n\n achievements: (g) => {\n const got = ACHIEVEMENTS.filter((a) => g.achievements.done.has(a.id)).map(\n (a) => a.title,\n );\n return `Достижения ${got.length}/${ACHIEVEMENTS.length}: ${got.join(\", \") || \"пока нет\"} (L — список)`;\n },\n\n music: (g, [op]) => {\n if (op === \"off\") {\n g.music.enabled = false;\n g.music.stop();\n return \"Музыка выключена\";\n }\n if (op === \"on\" || op === \"next\") {\n g.music.enabled = true;\n if (op === \"next\") g.music.next();\n return op === \"next\" ? \"Сейчас заиграет\" : \"Музыка включена\";\n }\n return (\n \"Музыка: \" +\n { off: \"выключена\", playing: \"играет\", waiting: \"ждёт своего часа\" }[\n g.music.status\n ]\n );\n },\n\n effect: (g, [id, sec, lvl]) => {\n if (id === \"clear\") {\n g.player.clearEffects();\n return \"Эффекты сняты\";\n }\n if (!id || !EFFECT_NAMES[id])\n throw new Error(\n \"нет такого эффекта: \" +\n id +\n \" (\" +\n Object.keys(EFFECT_NAMES).join(\", \") +\n \")\",\n );\n const seconds = sec === undefined ? 30 : Number(sec),\n level = lvl === undefined ? 1 : Number(lvl);\n if (!(seconds > 0) || !(level >= 1))\n throw new Error(\"время и уровень — положительные числа\");\n g.player.addEffect(id as EffectId, seconds, Math.floor(level));\n return `${EFFECT_NAMES[id]} ${Math.floor(level)} на ${seconds} с`;\n },\n\n summon: (g, args) => {\n // Minecraft-имена существ тоже годятся (/summon wither)\n const type =\n args[0] === undefined\n ? undefined\n : MOBS[args[0]]\n ? args[0]\n : ALIASES[args[0].toLowerCase()];\n if (!type || !MOBS[type])\n throw new Error(\"нет такого существа: \" + args[0]);\n const [x, y, z] =\n args.length >= 4\n ? xyz(g, args, 1, false)\n : [g.player.pos.x + 2, g.player.pos.y, g.player.pos.z];\n const m = g.entities.spawnMob(type, x, y, z);\n if (m.def.boss === \"ash_lord\") startCharge(m); // как призванный: сперва набирает силу\n return \"Призвано: \" + labelOf(type);\n },\n\n // без аргумента — игрок; с видом моба — все такие в 64 блоках, обычной смертью (с дропом и распадом)\n kill: (g, [type]) => {\n if (!type) {\n g.player._die();\n return \"Игрок погиб\";\n }\n if (!MOBS[type]) throw new Error(\"нет такого существа: \" + type);\n let n = 0;\n for (const e of g.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === type &&\n e.pos.distanceTo(g.player.pos) < 64\n ) {\n e.damage(e.hp);\n n++;\n }\n return `Убито: ${labelOf(type)} × ${n}`;\n },\n\n clear: (g) => {\n g.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: g.inventory.hotbarIndex,\n });\n return \"Инвентарь очищен\";\n },\n\n seed: (g) => \"Сид мира: \" + g.world.seed,\n\n enchant: (g, [id, lvl]) => {\n const held = g.inventory.heldItem();\n if (!held) throw new Error(\"в руке пусто\");\n if (!id || !(id in ENCHANTS))\n throw new Error(\"нет такого зачарования: \" + (id ?? \"?\"));\n const e = ENCHANTS[id as EnchantId];\n const def = ITEMS[held.item];\n if (!def || !e.fits(def))\n throw new Error(`${e.name} не подходит к этому предмету`);\n const level = Math.max(\n 1,\n Math.min(e.max, Math.floor(Number(lvl ?? 1)) || 1),\n );\n g.inventory.setSlot(\n g.inventory.hotbarIndex,\n withEnchants(held, { [id]: level }),\n );\n return \"Зачаровано: \" + enchantLine(id as EnchantId, level);\n },\n\n locate: (g, [what]) => {\n const kind =\n what === \"fortress\" || what === \"крепость\"\n ? \"fortress\"\n : what === \"деревня\"\n ? \"village\"\n : what === \"город\" || what === \"end_city\"\n ? \"city\"\n : what === \"храм\" ||\n what === \"monument\" ||\n what === \"ocean_monument\"\n ? \"temple\"\n : what === \"корабль\"\n ? \"shipwreck\"\n : what === \"застава\" || what === \"pillager_outpost\"\n ? \"outpost\"\n : what === \"клад\" || what === \"treasure\"\n ? \"buried_treasure\"\n : what === \"особняк\" || what === \"woodland_mansion\"\n ? \"mansion\"\n : what === \"древний\" || what === \"древний_город\"\n ? \"ancient_city\"\n : what === \"руины\" || what === \"ruins\"\n ? \"trail_ruins\"\n : what === \"камеры\" || what === \"trial\"\n ? \"trial_chambers\"\n : what;\n const p = g.player.pos;\n const found = kind ? g.world.generator.locate?.(kind, p.x, p.z) : null;\n if (!found)\n throw new Error(\n kind === \"fortress\" && g.dimension !== \"inferno\"\n ? \"адские крепости есть только в Пекле\"\n : \"не нашлось: \" + (what ?? \"?\"),\n );\n const d = Math.round(Math.hypot(found.x - p.x, found.z - p.z));\n return `Ближайшая: ${found.x} ${found.y} ${found.z} (${d} блоков)`;\n },\n\n newworld: (g, [s]) => {\n const seed =\n s !== undefined && Number.isFinite(Number(s))\n ? Number(s) >>> 0\n : (Math.random() * 1e9) >>> 0;\n g.newWorld(seed); // текущий мир остаётся в «Моих мирах»\n return \"Новый мир, сид \" + seed;\n },\n};\n\nexport class Commands {\n /** Последние строки вывода (консоль игрока и ответ агенту). */\n log: string[] = [];\n\n constructor(private readonly game: Game) {}\n\n /** Исполнить строку команды (с `/` или без). Возвращает вывод. */\n run(text: string): string {\n const parts = text.trim().replace(/^\\//, \"\").split(/\\s+/);\n const name = (parts.shift() ?? \"\").toLowerCase();\n let out: string;\n if (!CONFIG.commands.enabled) out = \"Команды в этой игре выключены\";\n else {\n const h = HANDLERS[name];\n try {\n out = h\n ? h(this.game, parts)\n : `Неизвестная команда: /${name}. /help — список`;\n } catch (e) {\n out = \"Ошибка: \" + (e instanceof Error ? e.message : String(e));\n }\n }\n this.log.push(out);\n if (this.log.length > 50) this.log.shift();\n events.emit(\"chat\", { text: out });\n return out;\n }\n}\n"
|
|
1380
|
+
"content": "// Команды консоли, как в Minecraft (/give, /tp, /time…). Текст приходит интентом Command (из\n// консоли игрока или от агента AI-кодера) и исполняется на тике — тем же путём, что и остальной\n// ввод. Выключаются целиком CONFIG.commands.enabled (издатель может закрыть читы в своей игре).\nimport { CONFIG } from \"../config\";\nimport {\n DIFFICULTY_NAMES,\n parseDifficulty,\n rules,\n setDifficulty,\n} from \"./Difficulty\";\nimport { events } from \"../core/EventBus\";\nimport { ALIASES, resolveBlock, resolveItem } from \"../registry/aliases\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { ITEMS } from \"../registry/Items\";\nimport { ENCHANTS, enchantLine, withEnchants, type EnchantId } from \"./Enchant\";\nimport { EFFECT_NAMES, labelOf } from \"../registry/names\";\nimport { startCharge } from \"../entities/AshLordBrain\";\nimport { ACHIEVEMENTS } from \"./Achievements\";\nimport type { Game } from \"../main\";\nimport type { EffectId } from \"../types\";\n\ntype Handler = (game: Game, args: string[]) => string;\n\nconst WORLD_BORDER = 30_000_000;\n\n/** Координата: число или `~`/`~N` относительно игрока. */\nfunction coord(arg: string | undefined, base: number): number {\n if (arg === undefined) throw new Error(\"не хватает координат\");\n // `~abc` раньше давал NaN: игрок улетал в NaN-координаты, и мир переставал грузиться насовсем\n const v = arg.startsWith(\"~\")\n ? base + (arg.length > 1 ? Number(arg.slice(1)) : 0)\n : Number(arg);\n if (!Number.isFinite(v)) throw new Error(\"не число: \" + arg);\n // граница мира, как в Minecraft: на 1e300 шум генератора и ключи чанков теряют точность\n if (Math.abs(v) > WORLD_BORDER)\n throw new Error(`за границей мира (±${WORLD_BORDER}): ${arg}`);\n return v;\n}\n\n/** Три координаты начиная с args[i] (целые — для блоков). */\nfunction xyz(\n game: Game,\n args: string[],\n i: number,\n whole: boolean,\n): [number, number, number] {\n const p = game.player.pos;\n const r = [\n coord(args[i], p.x),\n coord(args[i + 1], p.y),\n coord(args[i + 2], p.z),\n ] as [number, number, number];\n return whole ? (r.map(Math.floor) as [number, number, number]) : r;\n}\n\nfunction blockArg(name: string | undefined): number {\n const id = name === undefined ? null : resolveBlock(name);\n if (id === null) throw new Error(\"нет такого блока: \" + name);\n return id;\n}\n\n/** Мета по умолчанию: жидкость ставится источником (как в Minecraft), иначе 0 — вода с метой 0 утекает. */\nconst defaultMeta = (id: number): number =>\n id === B.water || id === B.lava ? packWater(8, true) : 0;\n\nconst TIMES: Record<string, (d: number, n: number) => number> = {\n day: (d) => d * 0.3,\n noon: (d) => d * 0.5,\n sunset: (d) => d * 0.94,\n night: (d, n) => d + n * 0.3,\n midnight: (d, n) => d + n * 0.5,\n};\n\nconst HANDLERS: Record<string, Handler> = {\n help: () =>\n \"Команды: /give <предмет> [n], /tp x y z, /time set day|noon|night|midnight|<сек>, /weather clear|rain|thunder [сек], \" +\n \"/difficulty мирная|лёгкая|обычная|высокая, /gamemode survival|creative, /setblock x y z <блок> [мета], /fill x1 y1 z1 x2 y2 z2 <блок>, \" +\n \"/summon <моб> [x y z], /kill [моб], /effect <эффект>|clear [сек] [уровень], /achievements, /music on|off|next, /clear, /seed, /locate fortress|citadel|village|outpost|temple|shipwreck|buried_treasure|mansion|ancient_city|trail_ruins|trial_chambers|city, /enchant <зачарование> [уровень], /newworld [сид]. Координаты можно с ~ (относительно себя).\",\n\n give: (g, [name, n]) => {\n const item = name === undefined ? null : resolveItem(name);\n if (!item) throw new Error(\"нет такого предмета: \" + name);\n const count = Math.max(\n 1,\n Math.min(64 * 36, Math.floor(Number(n ?? 1)) || 1),\n );\n const left = g.inventory.add(item, count);\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(p.x, p.y + 1, p.z, item, left);\n }\n return `Выдано: ${labelOf(item)} ×${count}`;\n },\n\n tp: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, false);\n g.warmup(x, z);\n if (g.player.riding) g.player.dismount(); // телепорт высаживает из транспорта, как в Minecraft\n g.player.pos.set(x, y, z);\n g.player.vel.set(0, 0, 0);\n g.player._fallDist = 0;\n return `Телепорт: ${x.toFixed(1)} ${y.toFixed(1)} ${z.toFixed(1)}`;\n },\n\n time: (g, [op, v]) => {\n const { dayLength, nightLength } = CONFIG.dayNight;\n const cycle = dayLength + nightLength;\n if (op === \"set\" && v !== undefined) {\n const named = TIMES[v];\n const t = named ? named(dayLength, nightLength) : Number(v);\n if (!Number.isFinite(t))\n throw new Error(\"время: day, noon, night, midnight или секунды\");\n g.dayNight.time = ((t % cycle) + cycle) % cycle;\n } else if (op === \"add\" && v !== undefined) {\n const dt = Number(v);\n if (!Number.isFinite(dt)) throw new Error(\"не число: \" + v); // NaN ломал небо и свет\n g.dayNight.time = (((g.dayNight.time + dt) % cycle) + cycle) % cycle;\n } else throw new Error(\"/time set <день|ночь|сек> или /time add <сек>\");\n return \"Время: \" + Math.round(g.dayNight.time) + \" c\";\n },\n\n weather: (g, [k, sec]) => {\n if (k !== \"clear\" && k !== \"rain\" && k !== \"thunder\")\n throw new Error(\"/weather clear|rain|thunder [сек]\");\n const s = sec === undefined ? undefined : Number(sec);\n if (s !== undefined && !(s > 0)) throw new Error(\"не число: \" + sec);\n g.weather.set(k, s);\n return (\n \"Погода: \" + (k === \"clear\" ? \"ясно\" : k === \"rain\" ? \"дождь\" : \"гроза\")\n );\n },\n\n gamemode: (g, [m]) => {\n const mode =\n m === \"creative\" || m === \"1\" || m === \"c\"\n ? \"creative\"\n : m === \"survival\" || m === \"0\" || m === \"s\"\n ? \"survival\"\n : null;\n if (!mode) throw new Error(\"/gamemode survival|creative\");\n if (g.player.mode !== mode) g.player.toggleMode();\n return \"Режим: \" + (mode === \"creative\" ? \"креатив\" : \"выживание\");\n },\n\n difficulty: (g, [d]) => {\n const next = parseDifficulty(d);\n if (!next)\n throw new Error(\"/difficulty мирная|лёгкая|обычная|высокая (0..3)\");\n setDifficulty(next);\n // на мирной монстры исчезают сразу, как в Minecraft\n if (!rules().hostiles) g.entities.clearHostiles();\n events.emit(\"difficultyChanged\", { difficulty: next });\n return \"Сложность: \" + DIFFICULTY_NAMES[next];\n },\n\n setblock: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, true);\n const id = blockArg(args[3]);\n const meta =\n args[4] === undefined ? defaultMeta(id) : Number(args[4]) & 255;\n if (!g.world.setBlock(x, y, z, id, meta))\n throw new Error(\"клетка вне загруженного мира\");\n return `Блок ${labelOf(args[3]!)} в ${x} ${y} ${z}`;\n },\n\n fill: (g, args) => {\n const [x1, y1, z1] = xyz(g, args, 0, true);\n const [x2, y2, z2] = xyz(g, args, 3, true);\n const id = blockArg(args[6]);\n const cells =\n (Math.abs(x2 - x1) + 1) *\n (Math.abs(y2 - y1) + 1) *\n (Math.abs(z2 - z1) + 1);\n if (cells > 65536)\n throw new Error(`слишком много блоков: ${cells} (максимум 65536)`);\n const meta = defaultMeta(id);\n let n = 0;\n for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++)\n for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++)\n for (let z = Math.min(z1, z2); z <= Math.max(z1, z2); z++)\n if (g.world.setBlock(x, y, z, id, meta)) n++;\n return `Заполнено блоков: ${n}`;\n },\n\n achievements: (g) => {\n const got = ACHIEVEMENTS.filter((a) => g.achievements.done.has(a.id)).map(\n (a) => a.title,\n );\n return `Достижения ${got.length}/${ACHIEVEMENTS.length}: ${got.join(\", \") || \"пока нет\"} (L — список)`;\n },\n\n music: (g, [op]) => {\n if (op === \"off\") {\n g.music.enabled = false;\n g.music.stop();\n return \"Музыка выключена\";\n }\n if (op === \"on\" || op === \"next\") {\n g.music.enabled = true;\n if (op === \"next\") g.music.next();\n return op === \"next\" ? \"Сейчас заиграет\" : \"Музыка включена\";\n }\n return (\n \"Музыка: \" +\n { off: \"выключена\", playing: \"играет\", waiting: \"ждёт своего часа\" }[\n g.music.status\n ]\n );\n },\n\n effect: (g, [id, sec, lvl]) => {\n if (id === \"clear\") {\n g.player.clearEffects();\n return \"Эффекты сняты\";\n }\n if (!id || !EFFECT_NAMES[id])\n throw new Error(\n \"нет такого эффекта: \" +\n id +\n \" (\" +\n Object.keys(EFFECT_NAMES).join(\", \") +\n \")\",\n );\n const seconds = sec === undefined ? 30 : Number(sec),\n level = lvl === undefined ? 1 : Number(lvl);\n if (!(seconds > 0) || !(level >= 1))\n throw new Error(\"время и уровень — положительные числа\");\n g.player.addEffect(id as EffectId, seconds, Math.floor(level));\n return `${EFFECT_NAMES[id]} ${Math.floor(level)} на ${seconds} с`;\n },\n\n summon: (g, args) => {\n // Minecraft-имена существ тоже годятся (/summon wither)\n const type =\n args[0] === undefined\n ? undefined\n : MOBS[args[0]]\n ? args[0]\n : ALIASES[args[0].toLowerCase()];\n if (!type || !MOBS[type])\n throw new Error(\"нет такого существа: \" + args[0]);\n const [x, y, z] =\n args.length >= 4\n ? xyz(g, args, 1, false)\n : [g.player.pos.x + 2, g.player.pos.y, g.player.pos.z];\n const m = g.entities.spawnMob(type, x, y, z);\n if (m.def.boss === \"ash_lord\") startCharge(m); // как призванный: сперва набирает силу\n return \"Призвано: \" + labelOf(type);\n },\n\n // без аргумента — игрок; с видом моба — все такие в 64 блоках, обычной смертью (с дропом и распадом)\n kill: (g, [type]) => {\n if (!type) {\n g.player._die();\n return \"Игрок погиб\";\n }\n if (!MOBS[type]) throw new Error(\"нет такого существа: \" + type);\n let n = 0;\n for (const e of g.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === type &&\n e.pos.distanceTo(g.player.pos) < 64\n ) {\n e.damage(e.hp);\n n++;\n }\n return `Убито: ${labelOf(type)} × ${n}`;\n },\n\n clear: (g) => {\n // карманы и броня; сундук Предела — склад, /clear его не трогает (как в Minecraft)\n g.inventory.clearCarried();\n return \"Инвентарь очищен\";\n },\n\n seed: (g) => \"Сид мира: \" + g.world.seed,\n\n enchant: (g, [id, lvl]) => {\n const held = g.inventory.heldItem();\n if (!held) throw new Error(\"в руке пусто\");\n if (!id || !(id in ENCHANTS))\n throw new Error(\"нет такого зачарования: \" + (id ?? \"?\"));\n const e = ENCHANTS[id as EnchantId];\n const def = ITEMS[held.item];\n if (!def || !e.fits(def))\n throw new Error(`${e.name} не подходит к этому предмету`);\n const level = Math.max(\n 1,\n Math.min(e.max, Math.floor(Number(lvl ?? 1)) || 1),\n );\n g.inventory.setSlot(\n g.inventory.hotbarIndex,\n withEnchants(held, { [id]: level }),\n );\n return \"Зачаровано: \" + enchantLine(id as EnchantId, level);\n },\n\n locate: (g, [what]) => {\n const kind =\n what === \"fortress\" || what === \"крепость\"\n ? \"fortress\"\n : what === \"деревня\"\n ? \"village\"\n : what === \"город\" || what === \"end_city\"\n ? \"city\"\n : what === \"храм\" ||\n what === \"monument\" ||\n what === \"ocean_monument\"\n ? \"temple\"\n : what === \"корабль\"\n ? \"shipwreck\"\n : what === \"застава\" || what === \"pillager_outpost\"\n ? \"outpost\"\n : what === \"клад\" || what === \"treasure\"\n ? \"buried_treasure\"\n : what === \"особняк\" || what === \"woodland_mansion\"\n ? \"mansion\"\n : what === \"древний\" || what === \"древний_город\"\n ? \"ancient_city\"\n : what === \"руины\" || what === \"ruins\"\n ? \"trail_ruins\"\n : what === \"камеры\" || what === \"trial\"\n ? \"trial_chambers\"\n : what;\n const p = g.player.pos;\n const found = kind ? g.world.generator.locate?.(kind, p.x, p.z) : null;\n if (!found)\n throw new Error(\n kind === \"fortress\" && g.dimension !== \"inferno\"\n ? \"адские крепости есть только в Пекле\"\n : \"не нашлось: \" + (what ?? \"?\"),\n );\n const d = Math.round(Math.hypot(found.x - p.x, found.z - p.z));\n return `Ближайшая: ${found.x} ${found.y} ${found.z} (${d} блоков)`;\n },\n\n newworld: (g, [s]) => {\n const seed =\n s !== undefined && Number.isFinite(Number(s))\n ? Number(s) >>> 0\n : (Math.random() * 1e9) >>> 0;\n g.newWorld(seed); // текущий мир остаётся в «Моих мирах»\n return \"Новый мир, сид \" + seed;\n },\n};\n\nexport class Commands {\n /** Последние строки вывода (консоль игрока и ответ агенту). */\n log: string[] = [];\n\n constructor(private readonly game: Game) {}\n\n /** Исполнить строку команды (с `/` или без). Возвращает вывод. */\n run(text: string): string {\n const parts = text.trim().replace(/^\\//, \"\").split(/\\s+/);\n const name = (parts.shift() ?? \"\").toLowerCase();\n let out: string;\n // сетевая игра: права (оператор / «команды всем») и команды «про мир» — у хоста (net/NetGame)\n const routed = this.game.net?.routeCommand(name, text) ?? null;\n if (!CONFIG.commands.enabled) out = \"Команды в этой игре выключены\";\n else if (routed !== null) out = routed;\n else {\n const h = HANDLERS[name];\n try {\n out = h\n ? h(this.game, parts)\n : `Неизвестная команда: /${name}. /help — список`;\n } catch (e) {\n out = \"Ошибка: \" + (e instanceof Error ? e.message : String(e));\n }\n }\n this.log.push(out);\n if (this.log.length > 50) this.log.shift();\n events.emit(\"chat\", { text: out });\n return out;\n }\n}\n"
|
|
1336
1381
|
},
|
|
1337
1382
|
{
|
|
1338
1383
|
"path": "systems/Crafting.ts",
|
|
1339
|
-
"content": "// Крафт: состояние сетки (2×2 инвентарь / 3×3 верстак) + матчинг рецептов.\n// Слоты сетки — такие же стаки, как в инвентаре; UI двигает их drag-and-drop'ом.\nimport { events } from \"../core/EventBus\";\nimport { matchRecipe } from \"../registry/Recipes\";\nimport type { ItemStack, RecipeResult } from \"../types\";\nimport type { Inventory } from \"./Inventory\";\n\nexport class CraftingGrid {\n size: number;\n slots: (ItemStack | null)[];\n\n constructor(size = 2) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n setSize(size: number) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n /** Текущий результат по рецептам (не изымает ингредиенты). */\n result(): RecipeResult | null {\n return matchRecipe(\n this.slots.map((s) => (s ? s.item : null)),\n this.size,\n );\n }\n\n /** Забрать результат: по 1 предмету из каждой занятой клетки. */\n takeResult(): RecipeResult | null {\n const res = this.result();\n if (!res) return null;\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n s.count -= 1;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n events.emit(\"craft\", { item: res.item, count: res.count });\n return { ...res };\n }\n\n /** Вернуть всё из сетки в инвентарь (закрытие окна). */\n dumpInto(inventory: Inventory) {\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n const left
|
|
1384
|
+
"content": "// Крафт: состояние сетки (2×2 инвентарь / 3×3 верстак) + матчинг рецептов.\n// Слоты сетки — такие же стаки, как в инвентаре; UI двигает их drag-and-drop'ом.\nimport { events } from \"../core/EventBus\";\nimport { matchRecipe } from \"../registry/Recipes\";\nimport type { ItemStack, RecipeResult } from \"../types\";\nimport type { Inventory } from \"./Inventory\";\n\nexport class CraftingGrid {\n size: number;\n slots: (ItemStack | null)[];\n\n constructor(size = 2) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n setSize(size: number) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n /** Текущий результат по рецептам (не изымает ингредиенты). */\n result(): RecipeResult | null {\n return matchRecipe(\n this.slots.map((s) => (s ? s.item : null)),\n this.size,\n );\n }\n\n /** Забрать результат: по 1 предмету из каждой занятой клетки. */\n takeResult(): RecipeResult | null {\n const res = this.result();\n if (!res) return null;\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n s.count -= 1;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n events.emit(\"craft\", { item: res.item, count: res.count });\n return { ...res };\n }\n\n /** Вернуть всё из сетки в инвентарь (закрытие окна). */\n dumpInto(inventory: Inventory) {\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n // с износом или tag — через addStack: add() вернул бы «новую» вещь без чар и износа\n const left =\n s.dmg || s.tag\n ? inventory.addStack(s)\n : inventory.add(s.item, s.count);\n this.slots[i] = left > 0 ? { ...s, count: left } : null;\n // если инвентарь полон — остаток остаётся в сетке; выбросить его — дело вызывающего\n // (InventoryUI.close), иначе следующий setSize() его обнулит\n }\n }\n }\n}\n"
|
|
1340
1385
|
},
|
|
1341
1386
|
{
|
|
1342
1387
|
"path": "systems/DayNight.ts",
|
|
1343
|
-
"content": "// Цикл суток: CONFIG.dayNight.dayLength дня + nightLength ночи. Плавно лерпаются цвет неба, туман,\n// свет солнца/луны и небосвода; солнце и луна — спрайты на орбите камеры.\n//\n// Освещение в казуальном стиле: тёплое солнце с мягкими тенями (PCF + radius) и полусферический\n// свет вместо плоского ambient — теневые стороны берут цвет неба сверху и тёплый отражённый от\n// земли снизу, а не проваливаются в серое.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { LIGHT_AMBIENT } from \"../gfx/LightMaterial\";\nimport { DIMENSIONS, type DimensionId } from \"../world/Dimensions\";\n\n/** Ключевая точка цикла; t в [0..1), 0 = рассвет. Цвета — sRGB hex. */\ninterface LightKey {\n t: number;\n sky: number; // цвет неба (clear color)\n fog: number;\n sun: number; // направленный свет: днём солнце, ночью луна\n sunI: number;\n hemiSky: number; // небосвод — подсветка сверху\n hemiGround: number; // отражённый от земли тёплый свет снизу\n hemiI: number;\n}\n\n// Интенсивности подобраны под Lambert three r155+ (освещённость делится на π): сумма на верхней\n// грани в полдень ≈ π — альбедо текстуры выходит в полную яркость. Солнце заметно сильнее\n// небосвода, иначе тень гасит лишь малую долю света и почти не видна; небосвод же не даёт ей\n// провалиться в черноту — тень остаётся мягкой и цветной.\nconst DAWN = {\n sky: 0xffb27a,\n fog: 0xffc9a0,\n sun: 0xffc27f,\n sunI: 1.6,\n hemiSky: 0xffd6c0,\n hemiGround: 0xa08060,\n hemiI: 1.3,\n};\nconst DAY = {\n sky: 0x5ab8f5,\n fog: 0x9fd4f7,\n sun: 0xfff0d6,\n sunI: 2.6,\n hemiSky: 0xd2ecff,\n hemiGround: 0xc9b38a,\n hemiI: 1.4,\n};\nconst SUNSET = {\n sky: 0xff8f5a,\n fog: 0xffac80,\n sun: 0xff9c5c,\n sunI: 1.5,\n hemiSky: 0xffc6a8,\n hemiGround: 0x8a6050,\n hemiI: 1.2,\n};\nconst NIGHT = {\n sky: 0x13204f,\n fog: 0x1a2757,\n sun: 0x9db4ff,\n sunI: 1.0,\n hemiSky: 0x4a62a8,\n hemiGround: 0x2a2f45,\n hemiI: 1.2,\n};\nconst KEYS: LightKey[] = [\n { t: 0.0, ...DAWN },\n { t: 0.1, ...DAY },\n { t: 0.4, ...DAY },\n { t: 0.5, ...SUNSET },\n { t: 0.6, ...NIGHT },\n { t: 0.9, ...NIGHT },\n { t: 1.0, ...DAWN },\n];\n\nconst G = CONFIG.graphics;\nconst NO_WEATHER = { rain: 0, thunder: 0, flash: 0 };\nconst UP = new THREE.Vector3(0, 1, 0);\nconst ORIGIN = new THREE.Vector3();\n\nfunction makeDiscSprite(color: string, glow: string): THREE.Sprite {\n const c = document.createElement(\"canvas\");\n c.width = c.height = 64;\n const ctx = c.getContext(\"2d\")!;\n const g = ctx.createRadialGradient(32, 32, 6, 32, 32, 30);\n g.addColorStop(0, color);\n g.addColorStop(0.5, color);\n g.addColorStop(1, glow);\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 64, 64);\n const tex = new THREE.CanvasTexture(c);\n const mat = new THREE.SpriteMaterial({\n map: tex,\n transparent: true,\n fog: false,\n depthWrite: false,\n });\n const s = new THREE.Sprite(mat);\n s.scale.set(14, 14, 1);\n return s;\n}\n\nexport class DayNight {\n scene: THREE.Scene;\n renderer: THREE.WebGLRenderer;\n cycleLength: number;\n time: number;\n sun: THREE.DirectionalLight;\n hemi: THREE.HemisphereLight;\n skyColor: THREE.Color;\n sunSprite: THREE.Sprite;\n moonSprite: THREE.Sprite;\n _colA: THREE.Color;\n _colB: THREE.Color;\n private readonly _sunDir = new THREE.Vector3();\n private readonly _lightDir = new THREE.Vector3();\n private readonly _center = new THREE.Vector3();\n private readonly _basis = new THREE.Matrix4();\n private readonly _ax = new THREE.Vector3();\n private readonly _ay = new THREE.Vector3();\n\n constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {\n this.scene = scene;\n this.renderer = renderer;\n this.cycleLength = CONFIG.dayNight.dayLength + CONFIG.dayNight.nightLength;\n this.time = 0;\n this.resetToMorning(); // старт утром\n\n this.sun = new THREE.DirectionalLight(0xffffff, 1);\n this.sun.castShadow = G.shadows;\n const sh = this.sun.shadow;\n sh.mapSize.set(G.shadowMapSize, G.shadowMapSize);\n sh.radius = G.shadowSoftness;\n sh.bias = -0.0004;\n sh.normalBias = 0.04;\n const R = G.shadowDistance;\n sh.camera.left = sh.camera.bottom = -R;\n sh.camera.right = sh.camera.top = R;\n sh.camera.near = 1;\n sh.camera.far = R * 6;\n sh.camera.updateProjectionMatrix();\n\n this.hemi = new THREE.HemisphereLight(0xffffff, 0xffffff, 1);\n // target — в сцене, чтобы его матрица обновлялась вместе со сценой\n scene.add(this.sun, this.sun.target, this.hemi);\n scene.fog = new THREE.Fog(0xbfe3f5, 20, CONFIG.renderDistance * 16 - 8);\n this.skyColor = new THREE.Color();\n this.sunSprite = makeDiscSprite(\"#fff4c0\", \"rgba(255,235,150,0)\");\n this.moonSprite = makeDiscSprite(\"#e8ecf5\", \"rgba(190,200,230,0)\");\n this.moonSprite.scale.set(9, 9, 1);\n scene.add(this.sunSprite, this.moonSprite);\n this._colA = new THREE.Color();\n this._colB = new THREE.Color();\n }\n\n tick(dt: number) {\n this.time = (this.time + dt) % this.cycleLength;\n }\n\n /**\n * 0..1 фазы цикла (0 — рассвет, 0.5 — закат). Дневная половина фазы растягивается на dayLength\n * секунд, ночная — на nightLength. Раньше длины только суммировались, и день с ночью всегда\n * делили цикл поровну, какие бы значения ни стояли в конфиге.\n */\n get phase() {\n const { dayLength, nightLength } = CONFIG.dayNight;\n return this.time < dayLength\n ? (0.5 * this.time) / dayLength\n : 0.5 + (0.5 * (this.time - dayLength)) / nightLength;\n }\n\n /** Утро (фаза 0.15 — солнце уже поднялось): старт игры и новый мир. */\n resetToMorning() {\n this.time = CONFIG.dayNight.dayLength * 0.3;\n }\n\n /** Время суток мира из рецепта: рассвет, день, закат или ночь. */\n setTimeOfDay(t: \"dawn\" | \"day\" | \"sunset\" | \"night\") {\n const { dayLength, nightLength } = CONFIG.dayNight;\n // фазы: 0 — рассвет, 0.15 — утро, 0.47 — закат, 0.7 — глубокая ночь\n if (t === \"dawn\") this.time = dayLength * 0.06;\n else if (t === \"sunset\") this.time = dayLength * 0.94;\n else if (t === \"night\") this.time = dayLength + nightLength * 0.4;\n else this.resetToMorning();\n }\n\n get isDay() {\n return this.phase < 0.5;\n }\n\n /**\n * На сколько уровней время суток гасит небесный свет: днём 0, ночью 11, в сумерках плавно — как в\n * Minecraft. По нему спавнятся враждебные мобы (ночью под открытым небом свет 15-11 = 4 ≤ 7).\n */\n get skyDarken(): number {\n const t = this.phase; // 0 рассвет, 0.5 закат\n if (t < 0.05) return 11 * (1 - t / 0.05);\n if (t < 0.45) return 0;\n if (t < 0.55) return 11 * ((t - 0.45) / 0.1);\n if (t < 0.95) return 11;\n return 11 * (1 - (t - 0.95) / 0.05);\n }\n\n /** Кадровое обновление: интерполяция ключей + позиции светил вокруг игрока. */\n frame(\n playerPos: THREE.Vector3,\n medium: \"air\" | \"water\" | \"lava\" = \"air\",\n dimension: DimensionId = \"overworld\",\n nightVision = false,\n weather: { rain: number; thunder: number; flash: number } = NO_WEATHER,\n ) {\n const underwater = medium !== \"air\";\n const t = this.phase;\n let a = KEYS[0]!,\n b = KEYS[KEYS.length - 1]!;\n for (let i = 0; i < KEYS.length - 1; i++)\n if (t >= KEYS[i]!.t && t <= KEYS[i + 1]!.t) {\n a = KEYS[i]!;\n b = KEYS[i + 1]!;\n break;\n }\n const f = (t - a.t) / Math.max(1e-6, b.t - a.t);\n const lerpC = (target: THREE.Color, ca: number, cb: number) =>\n target.copy(this._colA.setHex(ca)).lerp(this._colB.setHex(cb), f);\n const lerpN = (na: number, nb: number) => na + (nb - na) * f;\n\n lerpC(this.skyColor, a.sky, b.sky);\n lerpC(this.sun.color, a.sun, b.sun);\n lerpC(this.hemi.color, a.hemiSky, b.hemiSky);\n lerpC(this.hemi.groundColor, a.hemiGround, b.hemiGround);\n this.hemi.intensity = lerpN(a.hemiI, b.hemiI);\n\n const fog = this.scene.fog as THREE.Fog;\n lerpC(fog.color, a.fog, b.fog);\n if (medium === \"lava\") {\n fog.color.setHex(0xc04a10);\n fog.near = 0.1;\n fog.far = 2.5;\n } else if (underwater) {\n fog.color.setHex(0x1a4a8a);\n fog.near = 2;\n fog.far = 18;\n } else {\n // Туман только прячет край прогрузки: начинается далеко, иначе дымка смывает насыщенность.\n fog.far = CONFIG.renderDistance * 16 - 8;\n fog.near = fog.far * 0.55;\n }\n // дождь: небо и туман серые и темнее, дымка ближе; в грозу ещё темнее\n const { rain, thunder } = weather;\n if (rain > 0) {\n const gray = (c: THREE.Color) => {\n const l = 0.3 * c.r + 0.59 * c.g + 0.11 * c.b;\n c.lerp(this._colA.setRGB(l, l, l), 0.8 * rain).multiplyScalar(\n 1 - 0.35 * rain - 0.3 * thunder,\n );\n };\n gray(this.skyColor);\n if (!underwater) {\n gray(fog.color);\n fog.near = fog.far * (0.55 - 0.4 * rain);\n }\n }\n this.renderer.setClearColor(\n medium === \"lava\" ? 0xc04a10 : underwater ? 0x1a4a8a : this.skyColor,\n );\n\n const dim = DIMENSIONS[dimension];\n // фоновый свет измерения без неба (Пекло, Предел) — из описания измерения\n LIGHT_AMBIENT.value = dim.hasSky ? 0.03 : dim.ambient.level;\n // Ночное зрение: темнота почти не темнит (пещеры и ночь видно)\n if (nightVision) LIGHT_AMBIENT.value = Math.max(LIGHT_AMBIENT.value, 0.85);\n if (!dim.hasSky) {\n // Пекло и Предел: своё постоянное небо и густой туман — без светил и суточного цикла\n this.skyColor.setHex(dim.sky);\n if (!underwater) {\n fog.color.setHex(dim.fog);\n fog.far = CONFIG.renderDistance * 16 * 0.75;\n fog.near = fog.far * 0.2;\n this.renderer.setClearColor(this.skyColor);\n }\n this.sun.intensity = 0;\n this.sun.shadow.intensity = 0;\n // рассеянный свет — свой цвет измерения (цвета тумана и неба почти чёрные: Предел был не виден)\n this.hemi.color.setHex(dim.ambient.color);\n this.hemi.groundColor.setHex(dim.ambient.color).multiplyScalar(0.55);\n this.hemi.intensity = 0.9;\n this.sunSprite.material.opacity = 0;\n this.moonSprite.material.opacity = 0;\n return;\n }\n\n // солнце: восход на востоке (+x) в t=0, зенит в t=0.25, закат в t=0.5, ночью — под горизонтом\n const elev = Math.sin(Math.PI * (0.5 - Math.abs(t - 0.25) * 2)); // 1 в полдень, <0 ночью\n const az = Math.cos((t - 0.25) * Math.PI * 2);\n const sunDir = this._sunDir.set(az, elev, 0.3).normalize();\n\n // Направленный свет всегда сверху: днём от солнца, ночью от луны (она напротив). У горизонта\n // он гаснет и только потом переключается — иначе на закате тени и освещённые грани\n // перескакивали бы на противоположную сторону.\n const lightDir = this._lightDir.copy(sunDir);\n if (lightDir.y < 0) lightDir.negate();\n const horizon = THREE.MathUtils.smoothstep(lightDir.y, 0.02, 0.3);\n this.sun.intensity = lerpN(a.sunI, b.sunI) * horizon;\n this.sun.shadow.intensity = G.shadowStrength * horizon;\n this._placeSun(playerPos, lightDir);\n\n this.sunSprite.position.copy(playerPos).addScaledVector(sunDir, 70);\n this.moonSprite.position.copy(playerPos).addScaledVector(sunDir, -70);\n this.sunSprite.material.opacity = Math.max(\n 0,\n Math.min(1, sunDir.y * 4 + 0.4),\n );\n this.moonSprite.material.opacity = Math.max(\n 0,\n Math.min(1, -sunDir.y * 4 + 0.4),\n );\n // за тучами солнца и луны не видно, тени бледнеют; молния на миг заливает всё светом\n if (rain > 0) {\n this.sun.intensity *= 1 - 0.75 * rain;\n this.sun.shadow.intensity *= 1 - 0.8 * rain;\n this.hemi.intensity *= 1 - 0.25 * rain - 0.2 * thunder;\n this.sunSprite.material.opacity *= 1 - rain;\n this.moonSprite.material.opacity *= 1 - rain;\n }\n if (weather.flash > 0) this.hemi.intensity += 3 * weather.flash;\n }\n\n /**\n * Эффект Тьмы (сила 0..1, main пульсирует ею): поверх кадра frame() туман подходит вплотную, небо,\n * туман и рассеянный свет гаснут. Ночное зрение Тьму не снимает — как в Minecraft.\n */\n darkness(k: number): void {\n if (k <= 0) return;\n const fog = this.scene.fog as THREE.Fog;\n // цвета линейные, на экране — через sRGB: гасить нужно почти до нуля, иначе выходит серая дымка\n const dim = 0.01 + 0.04 * (1 - k);\n fog.color.multiplyScalar(dim);\n fog.far = Math.min(fog.far, 40 - 30 * k);\n fog.near = Math.min(fog.near, 1);\n this.skyColor.multiplyScalar(dim);\n this.renderer.setClearColor(fog.color);\n LIGHT_AMBIENT.value = Math.min(LIGHT_AMBIENT.value, 0.03 * (1 - k));\n this.hemi.intensity *= 1 - 0.8 * k;\n this.sun.intensity *= 1 - 0.8 * k;\n }\n\n /**\n * Карта теней ездит за игроком. Её центр привязан к сетке текселей в пространстве света: без\n * этого при ходьбе край каждой тени пересчитывался бы со сдвигом на долю текселя и «кипел».\n */\n _placeSun(playerPos: THREE.Vector3, lightDir: THREE.Vector3) {\n const R = G.shadowDistance;\n const texel = (2 * R) / G.shadowMapSize;\n // тот же базис, что построит shadow-камера: z смотрит на свет, up — мировой\n this._basis.lookAt(lightDir, ORIGIN, UP);\n this._ax.setFromMatrixColumn(this._basis, 0);\n this._ay.setFromMatrixColumn(this._basis, 1);\n const c = this._center.copy(playerPos);\n const du = c.dot(this._ax);\n const dv = c.dot(this._ay);\n c.addScaledVector(this._ax, Math.round(du / texel) * texel - du);\n c.addScaledVector(this._ay, Math.round(dv / texel) * texel - dv);\n this.sun.position.copy(c).addScaledVector(lightDir, R * 3);\n this.sun.target.position.copy(c);\n }\n\n serialize() {\n return { time: this.time };\n }\n deserialize(d: { time?: number }) {\n this.time = d.time ?? this.time;\n }\n}\n"
|
|
1388
|
+
"content": "// Цикл суток: CONFIG.dayNight.dayLength дня + nightLength ночи. Плавно лерпаются цвет неба, туман,\n// свет солнца/луны и небосвода; солнце и луна — спрайты на орбите камеры.\n//\n// Освещение в казуальном стиле: тёплое солнце с мягкими тенями (PCF + radius) и полусферический\n// свет вместо плоского ambient — теневые стороны берут цвет неба сверху и тёплый отражённый от\n// земли снизу, а не проваливаются в серое.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { LIGHT_AMBIENT } from \"../gfx/LightMaterial\";\nimport { DIMENSIONS, type DimensionId } from \"../world/Dimensions\";\n\n/** Ключевая точка цикла; t в [0..1), 0 = рассвет. Цвета — sRGB hex. */\ninterface LightKey {\n t: number;\n sky: number; // цвет неба (clear color)\n fog: number;\n sun: number; // направленный свет: днём солнце, ночью луна\n sunI: number;\n hemiSky: number; // небосвод — подсветка сверху\n hemiGround: number; // отражённый от земли тёплый свет снизу\n hemiI: number;\n}\n\n// Интенсивности подобраны под Lambert three r155+ (освещённость делится на π): сумма на верхней\n// грани в полдень ≈ π — альбедо текстуры выходит в полную яркость. Солнце заметно сильнее\n// небосвода, иначе тень гасит лишь малую долю света и почти не видна; небосвод же не даёт ей\n// провалиться в черноту — тень остаётся мягкой и цветной.\nconst DAWN = {\n sky: 0xffb27a,\n fog: 0xffc9a0,\n sun: 0xffc27f,\n sunI: 1.6,\n hemiSky: 0xffd6c0,\n hemiGround: 0xa08060,\n hemiI: 1.3,\n};\nconst DAY = {\n sky: 0x5ab8f5,\n fog: 0x9fd4f7,\n sun: 0xfff0d6,\n sunI: 2.6,\n hemiSky: 0xd2ecff,\n hemiGround: 0xc9b38a,\n hemiI: 1.4,\n};\nconst SUNSET = {\n sky: 0xff8f5a,\n fog: 0xffac80,\n sun: 0xff9c5c,\n sunI: 1.5,\n hemiSky: 0xffc6a8,\n hemiGround: 0x8a6050,\n hemiI: 1.2,\n};\nconst NIGHT = {\n sky: 0x13204f,\n fog: 0x1a2757,\n sun: 0x9db4ff,\n sunI: 1.0,\n hemiSky: 0x4a62a8,\n hemiGround: 0x2a2f45,\n hemiI: 1.2,\n};\nconst KEYS: LightKey[] = [\n { t: 0.0, ...DAWN },\n { t: 0.1, ...DAY },\n { t: 0.4, ...DAY },\n { t: 0.5, ...SUNSET },\n { t: 0.6, ...NIGHT },\n { t: 0.9, ...NIGHT },\n { t: 1.0, ...DAWN },\n];\n\nconst G = CONFIG.graphics;\n/** Цвет пелены, когда голова в снегу-порохе. */\nconst POWDER_FOG = 0xdfe9f3;\nconst NO_WEATHER = { rain: 0, thunder: 0, flash: 0 };\nconst UP = new THREE.Vector3(0, 1, 0);\nconst ORIGIN = new THREE.Vector3();\n\nfunction makeDiscSprite(color: string, glow: string): THREE.Sprite {\n const c = document.createElement(\"canvas\");\n c.width = c.height = 64;\n const ctx = c.getContext(\"2d\")!;\n const g = ctx.createRadialGradient(32, 32, 6, 32, 32, 30);\n g.addColorStop(0, color);\n g.addColorStop(0.5, color);\n g.addColorStop(1, glow);\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 64, 64);\n const tex = new THREE.CanvasTexture(c);\n const mat = new THREE.SpriteMaterial({\n map: tex,\n transparent: true,\n fog: false,\n depthWrite: false,\n });\n const s = new THREE.Sprite(mat);\n s.scale.set(14, 14, 1);\n return s;\n}\n\nexport class DayNight {\n scene: THREE.Scene;\n renderer: THREE.WebGLRenderer;\n cycleLength: number;\n time: number;\n sun: THREE.DirectionalLight;\n hemi: THREE.HemisphereLight;\n skyColor: THREE.Color;\n sunSprite: THREE.Sprite;\n moonSprite: THREE.Sprite;\n _colA: THREE.Color;\n _colB: THREE.Color;\n private readonly _sunDir = new THREE.Vector3();\n private readonly _lightDir = new THREE.Vector3();\n private readonly _center = new THREE.Vector3();\n private readonly _basis = new THREE.Matrix4();\n private readonly _ax = new THREE.Vector3();\n private readonly _ay = new THREE.Vector3();\n\n constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {\n this.scene = scene;\n this.renderer = renderer;\n this.cycleLength = CONFIG.dayNight.dayLength + CONFIG.dayNight.nightLength;\n this.time = 0;\n this.resetToMorning(); // старт утром\n\n this.sun = new THREE.DirectionalLight(0xffffff, 1);\n this.sun.castShadow = G.shadows;\n const sh = this.sun.shadow;\n sh.mapSize.set(G.shadowMapSize, G.shadowMapSize);\n sh.radius = G.shadowSoftness;\n sh.bias = -0.0004;\n sh.normalBias = 0.04;\n const R = G.shadowDistance;\n sh.camera.left = sh.camera.bottom = -R;\n sh.camera.right = sh.camera.top = R;\n sh.camera.near = 1;\n sh.camera.far = R * 6;\n sh.camera.updateProjectionMatrix();\n\n this.hemi = new THREE.HemisphereLight(0xffffff, 0xffffff, 1);\n // target — в сцене, чтобы его матрица обновлялась вместе со сценой\n scene.add(this.sun, this.sun.target, this.hemi);\n scene.fog = new THREE.Fog(0xbfe3f5, 20, CONFIG.renderDistance * 16 - 8);\n this.skyColor = new THREE.Color();\n this.sunSprite = makeDiscSprite(\"#fff4c0\", \"rgba(255,235,150,0)\");\n this.moonSprite = makeDiscSprite(\"#e8ecf5\", \"rgba(190,200,230,0)\");\n this.moonSprite.scale.set(9, 9, 1);\n scene.add(this.sunSprite, this.moonSprite);\n this._colA = new THREE.Color();\n this._colB = new THREE.Color();\n }\n\n tick(dt: number) {\n this.time = (this.time + dt) % this.cycleLength;\n }\n\n /**\n * 0..1 фазы цикла (0 — рассвет, 0.5 — закат). Дневная половина фазы растягивается на dayLength\n * секунд, ночная — на nightLength. Раньше длины только суммировались, и день с ночью всегда\n * делили цикл поровну, какие бы значения ни стояли в конфиге.\n */\n get phase() {\n const { dayLength, nightLength } = CONFIG.dayNight;\n return this.time < dayLength\n ? (0.5 * this.time) / dayLength\n : 0.5 + (0.5 * (this.time - dayLength)) / nightLength;\n }\n\n /** Утро (фаза 0.15 — солнце уже поднялось): старт игры и новый мир. */\n resetToMorning() {\n this.time = CONFIG.dayNight.dayLength * 0.3;\n }\n\n /** Время суток мира из рецепта: рассвет, день, закат или ночь. */\n setTimeOfDay(t: \"dawn\" | \"day\" | \"sunset\" | \"night\") {\n const { dayLength, nightLength } = CONFIG.dayNight;\n // фазы: 0 — рассвет, 0.15 — утро, 0.47 — закат, 0.7 — глубокая ночь\n if (t === \"dawn\") this.time = dayLength * 0.06;\n else if (t === \"sunset\") this.time = dayLength * 0.94;\n else if (t === \"night\") this.time = dayLength + nightLength * 0.4;\n else this.resetToMorning();\n }\n\n get isDay() {\n return this.phase < 0.5;\n }\n\n /**\n * На сколько уровней время суток гасит небесный свет: днём 0, ночью 11, в сумерках плавно — как в\n * Minecraft. По нему спавнятся враждебные мобы (ночью под открытым небом свет 15-11 = 4 ≤ 7).\n */\n get skyDarken(): number {\n const t = this.phase; // 0 рассвет, 0.5 закат\n if (t < 0.05) return 11 * (1 - t / 0.05);\n if (t < 0.45) return 0;\n if (t < 0.55) return 11 * ((t - 0.45) / 0.1);\n if (t < 0.95) return 11;\n return 11 * (1 - (t - 0.95) / 0.05);\n }\n\n /** Кадровое обновление: интерполяция ключей + позиции светил вокруг игрока. */\n frame(\n playerPos: THREE.Vector3,\n medium: \"air\" | \"water\" | \"lava\" | \"powder_snow\" = \"air\",\n dimension: DimensionId = \"overworld\",\n nightVision = false,\n weather: { rain: number; thunder: number; flash: number } = NO_WEATHER,\n ) {\n const underwater = medium !== \"air\";\n const t = this.phase;\n let a = KEYS[0]!,\n b = KEYS[KEYS.length - 1]!;\n for (let i = 0; i < KEYS.length - 1; i++)\n if (t >= KEYS[i]!.t && t <= KEYS[i + 1]!.t) {\n a = KEYS[i]!;\n b = KEYS[i + 1]!;\n break;\n }\n const f = (t - a.t) / Math.max(1e-6, b.t - a.t);\n const lerpC = (target: THREE.Color, ca: number, cb: number) =>\n target.copy(this._colA.setHex(ca)).lerp(this._colB.setHex(cb), f);\n const lerpN = (na: number, nb: number) => na + (nb - na) * f;\n\n lerpC(this.skyColor, a.sky, b.sky);\n lerpC(this.sun.color, a.sun, b.sun);\n lerpC(this.hemi.color, a.hemiSky, b.hemiSky);\n lerpC(this.hemi.groundColor, a.hemiGround, b.hemiGround);\n this.hemi.intensity = lerpN(a.hemiI, b.hemiI);\n\n const fog = this.scene.fog as THREE.Fog;\n lerpC(fog.color, a.fog, b.fog);\n if (medium === \"lava\") {\n fog.color.setHex(0xc04a10);\n fog.near = 0.1;\n fog.far = 2.5;\n } else if (medium === \"powder_snow\") {\n // голова в снегу-порохе: белая пелена вплотную, как в Minecraft — а не вид сквозь землю\n fog.color.setHex(POWDER_FOG);\n fog.near = 0;\n fog.far = 1.5;\n } else if (underwater) {\n fog.color.setHex(0x1a4a8a);\n fog.near = 2;\n fog.far = 18;\n } else {\n // Туман только прячет край прогрузки: начинается далеко, иначе дымка смывает насыщенность.\n fog.far = CONFIG.renderDistance * 16 - 8;\n fog.near = fog.far * 0.55;\n }\n // дождь: небо и туман серые и темнее, дымка ближе; в грозу ещё темнее\n const { rain, thunder } = weather;\n if (rain > 0) {\n const gray = (c: THREE.Color) => {\n const l = 0.3 * c.r + 0.59 * c.g + 0.11 * c.b;\n c.lerp(this._colA.setRGB(l, l, l), 0.8 * rain).multiplyScalar(\n 1 - 0.35 * rain - 0.3 * thunder,\n );\n };\n gray(this.skyColor);\n if (!underwater) {\n gray(fog.color);\n fog.near = fog.far * (0.55 - 0.4 * rain);\n }\n }\n this.renderer.setClearColor(\n medium === \"lava\"\n ? 0xc04a10\n : medium === \"powder_snow\"\n ? POWDER_FOG\n : underwater\n ? 0x1a4a8a\n : this.skyColor,\n );\n\n const dim = DIMENSIONS[dimension];\n // фоновый свет измерения без неба (Пекло, Предел) — из описания измерения\n LIGHT_AMBIENT.value = dim.hasSky ? 0.03 : dim.ambient.level;\n // Ночное зрение: темнота почти не темнит (пещеры и ночь видно)\n if (nightVision) LIGHT_AMBIENT.value = Math.max(LIGHT_AMBIENT.value, 0.85);\n if (!dim.hasSky) {\n // Пекло и Предел: своё постоянное небо и густой туман — без светил и суточного цикла\n this.skyColor.setHex(dim.sky);\n if (!underwater) {\n fog.color.setHex(dim.fog);\n fog.far = CONFIG.renderDistance * 16 * 0.75;\n fog.near = fog.far * 0.2;\n this.renderer.setClearColor(this.skyColor);\n }\n this.sun.intensity = 0;\n this.sun.shadow.intensity = 0;\n // рассеянный свет — свой цвет измерения (цвета тумана и неба почти чёрные: Предел был не виден)\n this.hemi.color.setHex(dim.ambient.color);\n this.hemi.groundColor.setHex(dim.ambient.color).multiplyScalar(0.55);\n this.hemi.intensity = 0.9;\n this.sunSprite.material.opacity = 0;\n this.moonSprite.material.opacity = 0;\n return;\n }\n\n // солнце: восход на востоке (+x) в t=0, зенит в t=0.25, закат в t=0.5, ночью — под горизонтом\n const elev = Math.sin(Math.PI * (0.5 - Math.abs(t - 0.25) * 2)); // 1 в полдень, <0 ночью\n const az = Math.cos((t - 0.25) * Math.PI * 2);\n const sunDir = this._sunDir.set(az, elev, 0.3).normalize();\n\n // Направленный свет всегда сверху: днём от солнца, ночью от луны (она напротив). У горизонта\n // он гаснет и только потом переключается — иначе на закате тени и освещённые грани\n // перескакивали бы на противоположную сторону.\n const lightDir = this._lightDir.copy(sunDir);\n if (lightDir.y < 0) lightDir.negate();\n const horizon = THREE.MathUtils.smoothstep(lightDir.y, 0.02, 0.3);\n this.sun.intensity = lerpN(a.sunI, b.sunI) * horizon;\n this.sun.shadow.intensity = G.shadowStrength * horizon;\n this._placeSun(playerPos, lightDir);\n\n this.sunSprite.position.copy(playerPos).addScaledVector(sunDir, 70);\n this.moonSprite.position.copy(playerPos).addScaledVector(sunDir, -70);\n this.sunSprite.material.opacity = Math.max(\n 0,\n Math.min(1, sunDir.y * 4 + 0.4),\n );\n this.moonSprite.material.opacity = Math.max(\n 0,\n Math.min(1, -sunDir.y * 4 + 0.4),\n );\n // за тучами солнца и луны не видно, тени бледнеют; молния на миг заливает всё светом\n if (rain > 0) {\n this.sun.intensity *= 1 - 0.75 * rain;\n this.sun.shadow.intensity *= 1 - 0.8 * rain;\n this.hemi.intensity *= 1 - 0.25 * rain - 0.2 * thunder;\n this.sunSprite.material.opacity *= 1 - rain;\n this.moonSprite.material.opacity *= 1 - rain;\n }\n if (weather.flash > 0) this.hemi.intensity += 3 * weather.flash;\n }\n\n /**\n * Эффект Тьмы (сила 0..1, main пульсирует ею): поверх кадра frame() туман подходит вплотную, небо,\n * туман и рассеянный свет гаснут. Ночное зрение Тьму не снимает — как в Minecraft.\n */\n darkness(k: number): void {\n if (k <= 0) return;\n const fog = this.scene.fog as THREE.Fog;\n // цвета линейные, на экране — через sRGB: гасить нужно почти до нуля, иначе выходит серая дымка\n const dim = 0.01 + 0.04 * (1 - k);\n fog.color.multiplyScalar(dim);\n fog.far = Math.min(fog.far, 40 - 30 * k);\n fog.near = Math.min(fog.near, 1);\n this.skyColor.multiplyScalar(dim);\n this.renderer.setClearColor(fog.color);\n LIGHT_AMBIENT.value = Math.min(LIGHT_AMBIENT.value, 0.03 * (1 - k));\n this.hemi.intensity *= 1 - 0.8 * k;\n this.sun.intensity *= 1 - 0.8 * k;\n }\n\n /**\n * Карта теней ездит за игроком. Её центр привязан к сетке текселей в пространстве света: без\n * этого при ходьбе край каждой тени пересчитывался бы со сдвигом на долю текселя и «кипел».\n */\n _placeSun(playerPos: THREE.Vector3, lightDir: THREE.Vector3) {\n const R = G.shadowDistance;\n const texel = (2 * R) / G.shadowMapSize;\n // тот же базис, что построит shadow-камера: z смотрит на свет, up — мировой\n this._basis.lookAt(lightDir, ORIGIN, UP);\n this._ax.setFromMatrixColumn(this._basis, 0);\n this._ay.setFromMatrixColumn(this._basis, 1);\n const c = this._center.copy(playerPos);\n const du = c.dot(this._ax);\n const dv = c.dot(this._ay);\n c.addScaledVector(this._ax, Math.round(du / texel) * texel - du);\n c.addScaledVector(this._ay, Math.round(dv / texel) * texel - dv);\n this.sun.position.copy(c).addScaledVector(lightDir, R * 3);\n this.sun.target.position.copy(c);\n }\n\n serialize() {\n return { time: this.time };\n }\n deserialize(d: { time?: number } | undefined) {\n // нет поля или не число (чужой файл) — время остаётся: NaN ломал небо, свет и спавн мобов\n const t = Number(d?.time);\n if (Number.isFinite(t) && t >= 0) this.time = t;\n }\n}\n"
|
|
1344
1389
|
},
|
|
1345
1390
|
{
|
|
1346
1391
|
"path": "systems/Difficulty.test.ts",
|
|
@@ -1384,7 +1429,7 @@
|
|
|
1384
1429
|
},
|
|
1385
1430
|
{
|
|
1386
1431
|
"path": "systems/FluidSim.ts",
|
|
1387
|
-
"content": "// Жидкости (вода и лава) — клеточный автомат на dirty-set: мир никогда не сканируется,\n// обрабатываются только клетки из очередей. У каждой жидкости свой темп (вода — каждый 5-й\n// симтик, лава — каждый 30-й, как в Minecraft) и своя дальность (вода ~7 блоков, лава 3).\n// Бюджет клеток за тик жёстко ограничен — остаток «дотекает» в следующие тики.\n//\n// Правила:\n// • source (бит меты) никогда не гаснет;\n// • под жидкостью воздух → жидкость падает вниз, уровень наследуется;\n// • иначе растекается горизонтально с уровнем max(соседей)-step, пока не ниже minLevel;\n// • текущая жидкость без питания убывает;\n// • лава + вода: источник лавы → обсидиан, текущая лава → булыжник, лава сверху на воду → камень.\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { B, packWater, waterLevel, isWaterSource } from \"../registry/Blocks\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\n\ninterface Cell {\n x: number;\n y: number;\n z: number;\n}\n\ninterface Fluid {\n id: number;\n /** Каждый N-й симтик. */\n every: number;\n /** На сколько падает уровень за блок растекания. */\n step: number;\n minLevel: number;\n /** Меняет только водный меш (дешёвая пересборка) — лава в общем меше. */\n waterOnly: boolean;\n queue: Map<string, Cell>;\n}\n\nconst key = (x: number, y: number, z: number) => x + \",\" + y + \",\" + z;\nconst SIDES = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n] as const;\nconst AROUND = [\n [1, 0, 0],\n [-1, 0, 0],\n [0, 1, 0],\n [0, -1, 0],\n [0, 0, 1],\n [0, 0, -1],\n] as const;\n\nexport class FluidSim {\n world: ChunkManager;\n private readonly fluids: Fluid[];\n private _tickCounter = 0;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.fluids = [\n {\n id: B.water!,\n every: CONFIG.water.tickEvery,\n step: 1,\n minLevel: CONFIG.water.minFlowLevel,\n waterOnly: true,\n queue: new Map(),\n },\n {\n id: B.lava!,\n every: CONFIG.lava.tickEvery,\n step: CONFIG.lava.step,\n minLevel: 2,\n waterOnly: false,\n queue: new Map(),\n },\n ];\n\n // любое изменение мира будит жидкости вокруг — так «прорывается
|
|
1432
|
+
"content": "// Жидкости (вода и лава) — клеточный автомат на dirty-set: мир никогда не сканируется,\n// обрабатываются только клетки из очередей. У каждой жидкости свой темп (вода — каждый 5-й\n// симтик, лава — каждый 30-й, как в Minecraft) и своя дальность (вода ~7 блоков, лава 3).\n// Бюджет клеток за тик жёстко ограничен — остаток «дотекает» в следующие тики.\n//\n// Правила:\n// • source (бит меты) никогда не гаснет;\n// • под жидкостью воздух → жидкость падает вниз, уровень наследуется;\n// • иначе растекается горизонтально с уровнем max(соседей)-step, пока не ниже minLevel;\n// • текущая жидкость без питания убывает;\n// • лава + вода: источник лавы → обсидиан, текущая лава → булыжник, лава сверху на воду → камень.\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { B, packWater, waterLevel, isWaterSource } from \"../registry/Blocks\";\nimport { sim } from \"../net/sim\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\n\ninterface Cell {\n x: number;\n y: number;\n z: number;\n}\n\ninterface Fluid {\n id: number;\n /** Каждый N-й симтик. */\n every: number;\n /** На сколько падает уровень за блок растекания. */\n step: number;\n minLevel: number;\n /** Меняет только водный меш (дешёвая пересборка) — лава в общем меше. */\n waterOnly: boolean;\n queue: Map<string, Cell>;\n}\n\nconst key = (x: number, y: number, z: number) => x + \",\" + y + \",\" + z;\nconst SIDES = [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n] as const;\nconst AROUND = [\n [1, 0, 0],\n [-1, 0, 0],\n [0, 1, 0],\n [0, -1, 0],\n [0, 0, 1],\n [0, 0, -1],\n] as const;\n\nexport class FluidSim {\n world: ChunkManager;\n private readonly fluids: Fluid[];\n private _tickCounter = 0;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.fluids = [\n {\n id: B.water!,\n every: CONFIG.water.tickEvery,\n step: 1,\n minLevel: CONFIG.water.minFlowLevel,\n waterOnly: true,\n queue: new Map(),\n },\n {\n id: B.lava!,\n every: CONFIG.lava.tickEvery,\n step: CONFIG.lava.step,\n minLevel: 2,\n waterOnly: false,\n queue: new Map(),\n },\n ];\n\n // любое изменение мира будит жидкости вокруг — так «прорывается дамба». Реплика (net/sim) жидкости\n // не считает: течение пришлёт владелец измерения.\n events.on(\"blockChanged\", ({ x, y, z }) => {\n if (!sim.authority) return;\n this.wake(x, y, z);\n this.wakeNeighbors(x, y, z);\n });\n }\n\n get queueSize(): number {\n let n = 0;\n for (const f of this.fluids) n += f.queue.size;\n return n;\n }\n\n clear(): void {\n for (const f of this.fluids) f.queue.clear();\n }\n\n wake(x: number, y: number, z: number): void {\n const id = this.world.getBlock(x, y, z);\n for (const f of this.fluids)\n if (id === f.id || id === B.air) f.queue.set(key(x, y, z), { x, y, z });\n }\n\n wakeNeighbors(x: number, y: number, z: number): void {\n for (const [dx, dy, dz] of AROUND) this.wake(x + dx, y + dy, z + dz);\n }\n\n tick(): void {\n this._tickCounter++;\n for (const f of this.fluids) {\n if (this._tickCounter % f.every !== 0 || f.queue.size === 0) continue;\n const batch: Cell[] = [];\n for (const cell of f.queue.values()) {\n batch.push(cell);\n if (batch.length >= CONFIG.water.cellBudget) break;\n }\n for (const c of batch) f.queue.delete(key(c.x, c.y, c.z));\n for (const { x, y, z } of batch) this._updateCell(f, x, y, z);\n }\n }\n\n private _set(\n f: Fluid,\n x: number,\n y: number,\n z: number,\n id: number,\n meta: number,\n ): void {\n this.world.setBlock(x, y, z, id, meta, { waterOnly: f.waterOnly });\n this.wakeNeighbors(x, y, z);\n f.queue.set(key(x, y, z), { x, y, z });\n }\n\n /** Встреча лавы с водой. true — клетка перестала быть жидкостью. */\n private _react(f: Fluid, x: number, y: number, z: number): boolean {\n const meta = this.world.getMeta(x, y, z);\n if (f.id === B.lava) {\n for (const [dx, dy, dz] of AROUND)\n // вода прямо под лавой — не сюда: лава стечёт в неё, и вода станет камнем (_spread)\n if (\n dy !== -1 &&\n this.world.getBlock(x + dx, y + dy, z + dz) === B.water\n ) {\n this.world.setBlock(\n x,\n y,\n z,\n isWaterSource(meta) ? B.obsidian! : B.cobblestone!,\n );\n return true;\n }\n return false;\n }\n // вода остывает соседнюю лаву сама (лава тикает редко — ждать её не нужно)\n for (const [dx, dy, dz] of AROUND) {\n if (dy === 1) continue; // лава сверху решает сама: стекает в воду камнем\n const nx = x + dx,\n ny = y + dy,\n nz = z + dz;\n if (this.world.getBlock(nx, ny, nz) !== B.lava) continue;\n const source = isWaterSource(this.world.getMeta(nx, ny, nz));\n this.world.setBlock(nx, ny, nz, source ? B.obsidian! : B.cobblestone!);\n }\n return false;\n }\n\n private _updateCell(f: Fluid, x: number, y: number, z: number): void {\n const id = this.world.getBlock(x, y, z);\n\n if (id === B.air) {\n // может ли жидкость прийти сюда? сверху — или сбоку с уровнем, не ниже minLevel\n if (this.world.getBlock(x, y + 1, z) === f.id) {\n // ВАЖНО: при падении уровень наследуется (не сбрасывается в максимум) —\n // иначе каскад по склону заливает конус до океана (как в MC, но\n // неподъёмно для геймплея «сломал дамбу — лужа растеклась и встала»).\n this._set(\n f,\n x,\n y,\n z,\n f.id,\n packWater(waterLevel(this.world.getMeta(x, y + 1, z)), false),\n );\n return;\n }\n let best = 0;\n for (const [dx, dz] of SIDES) {\n if (this.world.getBlock(x + dx, y, z + dz) !== f.id) continue;\n // сосед отдаёт жидкость вбок, только если сам стоит на опоре\n if (this.world.getBlock(x + dx, y - 1, z + dz) === B.air) continue;\n best = Math.max(\n best,\n waterLevel(this.world.getMeta(x + dx, y, z + dz)),\n );\n }\n if (best - f.step >= f.minLevel)\n this._set(f, x, y, z, f.id, packWater(best - f.step, false));\n return;\n }\n\n if (id !== f.id) return;\n if (this._react(f, x, y, z)) return;\n const meta = this.world.getMeta(x, y, z);\n if (isWaterSource(meta)) {\n this._spread(f, x, y, z, CONFIG.water.maxLevel);\n return;\n }\n\n // пересчёт уровня текущей жидкости: питание сверху или от соседей\n let want = 0;\n if (this.world.getBlock(x, y + 1, z) === f.id)\n want = waterLevel(this.world.getMeta(x, y + 1, z));\n else\n for (const [dx, dz] of SIDES)\n if (this.world.getBlock(x + dx, y, z + dz) === f.id)\n want = Math.max(\n want,\n waterLevel(this.world.getMeta(x + dx, y, z + dz)) - f.step,\n );\n\n const cur = waterLevel(meta);\n if (want < f.minLevel) {\n this._set(f, x, y, z, B.air!, 0); // высохла\n return;\n }\n if (want !== cur) {\n this._set(f, x, y, z, f.id, packWater(want, false));\n return;\n }\n this._spread(f, x, y, z, cur);\n }\n\n /** Растекание из клетки: сначала вниз (уровень наследуется), потом в стороны. */\n private _spread(\n f: Fluid,\n x: number,\n y: number,\n z: number,\n level: number,\n ): void {\n const below = this.world.getBlock(x, y - 1, z);\n if (below === B.air) {\n this._set(f, x, y - 1, z, f.id, packWater(level, false));\n return; // жидкость уходит вниз — вбок не льём\n }\n if (f.id === B.lava && below === B.water) {\n this.world.setBlock(x, y - 1, z, B.stone!); // лава на воду сверху — камень\n return;\n }\n if (level - f.step < f.minLevel) return;\n for (const [dx, dz] of SIDES) {\n const nid = this.world.getBlock(x + dx, y, z + dz);\n if (nid === B.air)\n this._set(f, x + dx, y, z + dz, f.id, packWater(level - f.step, false));\n else if (\n nid === f.id &&\n waterLevel(this.world.getMeta(x + dx, y, z + dz)) < level - f.step\n )\n this._set(f, x + dx, y, z + dz, f.id, packWater(level - f.step, false));\n }\n }\n}\n"
|
|
1388
1433
|
},
|
|
1389
1434
|
{
|
|
1390
1435
|
"path": "systems/Freezing.ts",
|
|
@@ -1408,7 +1453,11 @@
|
|
|
1408
1453
|
},
|
|
1409
1454
|
{
|
|
1410
1455
|
"path": "systems/Inventory.ts",
|
|
1411
|
-
"content": "// Инвентарь — чистая модель данных без DOM (UI подписывается на invChanged).\n// 36 слотов: 0..8 — хотбар, 9..35 — рюкзак. Слот: {item, count, dmg?} | null.\n// Броня — 4 отдельных слота: шлем, нагрудник, поножи, ботинки.\nimport { events } from \"../core/EventBus\";\nimport { enchLevel } from \"./Enchant\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ArmorSlot, ItemStack, InventorySave } from \"../types\";\n\n/** Порядок слотов брони. */\nexport const ARMOR_SLOTS: readonly ArmorSlot[] = [\n \"head\",\n \"chest\",\n \"legs\",\n \"feet\",\n];\n\n/** Сколько слотов у сундука Предела (общего склада игрока). */\nexport const ENDER_SIZE = 27;\n\nexport class Inventory {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n armor: (ItemStack | null)[] = [null, null, null, null];\n /**\n * Сундук Предела: один склад на все свои копии и все измерения, поэтому он у игрока, а не у\n * клетки (world/behaviours/extras, экран — ui/InventoryUI).\n */\n ender: (ItemStack | null)[] = new Array(ENDER_SIZE).fill(null);\n\n constructor(size = 36) {\n this.slots = new Array(size).fill(null);\n this.hotbarIndex = 0;\n }\n\n _changed() {\n events.emit(\"invChanged\");\n }\n\n heldItem(): ItemStack | null {\n return this.slots[this.hotbarIndex]!;\n }\n\n selectSlot(i: number) {\n this.hotbarIndex = Math.max(0, Math.min(8, i));\n this._changed();\n }\n scrollSlot(delta: number) {\n this.hotbarIndex = (this.hotbarIndex + delta + 9) % 9;\n this._changed();\n }\n\n /** Добавить предметы (сначала в существующие стаки, потом в пустые слоты).\n * @returns {number} сколько НЕ поместилось */\n add(itemName: string, count: number): number {\n const max = ITEMS[itemName]?.maxStack ?? 64;\n const requested = count;\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName && s.count < max && !s.dmg) {\n const take = Math.min(count, max - s.count);\n s.count += take;\n count -= take;\n }\n }\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n if (!this.slots[i]) {\n const take = Math.min(count, max);\n this.slots[i] = { item: itemName, count: take };\n count -= take;\n }\n }\n // Ничего не легло (инвентарь полон) — подписчиков не будим: дроп у ног игрока зовёт add()\n // каждый тик, и HUD с рукой перерисовывались бы 20 раз в секунду впустую.\n if (count !== requested) this._changed();\n return count;\n }\n\n /**\n * Положить стак с износом (инструмент, броня) — в пустой слот целиком, износ сохраняется; обычный\n * стак — как add(). @returns сколько не поместилось\n */\n addStack(s: ItemStack): number {\n if (!s.dmg && !s.tag) return this.add(s.item, s.count);\n const i = this.slots.findIndex((x) => !x);\n if (i < 0) return s.count;\n this.slots[i] = { ...s };\n this._changed();\n return 0;\n }\n\n /**\n * Заменить один предмет в руке другим (ведро → ведро воды). Из стака пустых вёдер берётся одно,\n * полное кладётся в инвентарь. @returns true — не поместилось (надо выбросить под ноги)\n */\n swapHeldOne(item: string): boolean {\n const s = this.slots[this.hotbarIndex];\n if (!s) return false;\n if (s.count <= 1) {\n this.slots[this.hotbarIndex] = { item, count: 1 };\n this._changed();\n return false;\n }\n s.count -= 1;\n const left = this.add(item, 1);\n this._changed();\n return left > 0;\n }\n\n consumeHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n if (!s) return;\n s.count -= n;\n if (s.count <= 0) this.slots[this.hotbarIndex] = null;\n this._changed();\n }\n\n /**\n * Износ предмета в руке (инструмент, оружие): n единиц прочности. Кончилась — предмет ломается,\n * как в Minecraft. У предметов без прочности ничего не происходит.\n */\n damageHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n const max = s ? (ITEMS[s.item]?.durability ?? 0) : 0;\n if (!s || !max) return;\n // Прочность: износ тратится лишь с шансом 1/(уровень+1), как в Minecraft\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = (s.dmg ?? 0) + n;\n if (s.dmg >= max) {\n this.slots[this.hotbarIndex] = null;\n events.emit(\"toolBreak\", { item: s.item });\n }\n this._changed();\n }\n\n /** Сколько предметов itemName во всех слотах (торговля). */\n count(itemName: string): number {\n let n = 0;\n for (const s of this.slots) if (s && s.item === itemName) n += s.count;\n return n;\n }\n\n /** Съесть count предметов itemName из любого места (для крафта). */\n consume(itemName: string, count: number) {\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName) {\n const take = Math.min(count, s.count);\n s.count -= take;\n count -= take;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n this._changed();\n }\n\n setSlot(i: number, stack: ItemStack | null) {\n this.slots[i] = stack;\n this._changed();\n }\n\n // ---- броня ----------------------------------------------------------------------------\n\n setArmor(i: number, stack: ItemStack | null) {\n this.armor[i] = stack;\n this._changed();\n }\n\n /** Сумма очков защиты и твёрдости надетой брони. */\n armorStats(): { points: number; toughness: number; protection: number } {\n let points = 0,\n toughness = 0,\n protection = 0;\n for (const s of this.armor) {\n const d = s ? ITEMS[s.item] : undefined;\n if (d) {\n points += d.armor;\n toughness += d.toughness;\n }\n protection += enchLevel(s, \"protection\");\n }\n return { points, toughness, protection };\n }\n\n /** Износ брони от удара: каждая часть теряет max(1, урон/4), как в Minecraft. */\n damageArmor(dmg: number) {\n const wear = Math.max(1, Math.floor(dmg / 4));\n let changed = false;\n this.armor.forEach((s, i) => {\n const max = s ? (ITEMS[s.item]?.durability ?? 0) : 0;\n if (!s || !max || !ITEMS[s.item]?.armor) return; // крылья от ударов не изнашиваются\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = (s.dmg ?? 0) + wear;\n changed = true;\n if (s.dmg >= max) {\n this.armor[i] = null;\n events.emit(\"toolBreak\", { item: s.item });\n }\n });\n if (changed) this._changed();\n }\n\n /** Надеты годные Крылья Предела: последняя единица прочности не летает (как у элитр). */\n canGlide(): boolean {\n const s = this.armor[1];\n return (\n s?.item === \"wings\" && (s.dmg ?? 0) < (ITEMS.wings?.durability ?? 0) - 1\n );\n }\n\n /** Секунда полёта на крыльях — единица износа (Прочность бережёт), до последней единицы. */\n wearWings(): void {\n const s = this.armor[1];\n const max = ITEMS.wings?.durability ?? 0;\n if (s?.item !== \"wings\" || !max) return;\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = Math.min(max - 1, (s.dmg ?? 0) + 1);\n this._changed();\n }\n\n serialize(): InventorySave {\n return {\n slots: this.slots,\n hotbarIndex: this.hotbarIndex,\n armor: this.armor,\n ender: this.ender,\n };\n }\n deserialize(d: InventorySave) {\n
|
|
1456
|
+
"content": "// Инвентарь — чистая модель данных без DOM (UI подписывается на invChanged).\n// 36 слотов: 0..8 — хотбар, 9..35 — рюкзак. Слот: {item, count, dmg?} | null.\n// Броня — 4 отдельных слота: шлем, нагрудник, поножи, ботинки.\nimport { events } from \"../core/EventBus\";\nimport { enchLevel } from \"./Enchant\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ArmorSlot, ItemStack, InventorySave } from \"../types\";\n\n/** Порядок слотов брони. */\nexport const ARMOR_SLOTS: readonly ArmorSlot[] = [\n \"head\",\n \"chest\",\n \"legs\",\n \"feet\",\n];\n\n/** Сколько слотов у сундука Предела (общего склада игрока). */\nexport const ENDER_SIZE = 27;\n\n/**\n * Стак из чужих данных (сейв, Мастерская, старая версия игры): предмет должен существовать в игре,\n * иначе HUD и экран инвентаря падают на `ITEMS[item]!` каждый кадр. Негодное — пустой слот.\n */\nexport function sanitizeStack(s: unknown): ItemStack | null {\n if (!s || typeof s !== \"object\") return null;\n const v = s as Partial<ItemStack>;\n if (typeof v.item !== \"string\" || !ITEMS[v.item]) return null;\n const count = Math.floor(Number(v.count));\n if (!Number.isFinite(count) || count <= 0) return null;\n const out: ItemStack = { ...(v as ItemStack), count };\n if (out.dmg !== undefined && !(Number(out.dmg) >= 0)) delete out.dmg;\n return out;\n}\n\nexport class Inventory {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n armor: (ItemStack | null)[] = [null, null, null, null];\n /**\n * Сундук Предела: один склад на все свои копии и все измерения, поэтому он у игрока, а не у\n * клетки (world/behaviours/extras, экран — ui/InventoryUI).\n */\n ender: (ItemStack | null)[] = new Array(ENDER_SIZE).fill(null);\n\n constructor(size = 36) {\n this.slots = new Array(size).fill(null);\n this.hotbarIndex = 0;\n }\n\n _changed() {\n events.emit(\"invChanged\");\n }\n\n heldItem(): ItemStack | null {\n return this.slots[this.hotbarIndex]!;\n }\n\n selectSlot(i: number) {\n this.hotbarIndex = Math.max(0, Math.min(8, i));\n this._changed();\n }\n scrollSlot(delta: number) {\n this.hotbarIndex = (this.hotbarIndex + delta + 9) % 9;\n this._changed();\n }\n\n /** Добавить предметы (сначала в существующие стаки, потом в пустые слоты).\n * @returns {number} сколько НЕ поместилось */\n add(itemName: string, count: number): number {\n const max = ITEMS[itemName]?.maxStack ?? 64;\n const requested = count;\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n // в стак с износом или tag (зачарование, имя, содержимое) обычные предметы не доливаем\n if (s && s.item === itemName && s.count < max && !s.dmg && !s.tag) {\n const take = Math.min(count, max - s.count);\n s.count += take;\n count -= take;\n }\n }\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n if (!this.slots[i]) {\n const take = Math.min(count, max);\n this.slots[i] = { item: itemName, count: take };\n count -= take;\n }\n }\n // Ничего не легло (инвентарь полон) — подписчиков не будим: дроп у ног игрока зовёт add()\n // каждый тик, и HUD с рукой перерисовывались бы 20 раз в секунду впустую.\n if (count !== requested) this._changed();\n return count;\n }\n\n /**\n * Положить стак с износом (инструмент, броня) — в пустой слот целиком, износ сохраняется; обычный\n * стак — как add(). @returns сколько не поместилось\n */\n addStack(s: ItemStack): number {\n if (!s.dmg && !s.tag) return this.add(s.item, s.count);\n const i = this.slots.findIndex((x) => !x);\n if (i < 0) return s.count;\n this.slots[i] = { ...s };\n this._changed();\n return 0;\n }\n\n /**\n * Заменить один предмет в руке другим (ведро → ведро воды). Из стака пустых вёдер берётся одно,\n * полное кладётся в инвентарь. @returns true — не поместилось (надо выбросить под ноги)\n */\n swapHeldOne(item: string): boolean {\n const s = this.slots[this.hotbarIndex];\n if (!s) return false;\n if (s.count <= 1) {\n this.slots[this.hotbarIndex] = { item, count: 1 };\n this._changed();\n return false;\n }\n s.count -= 1;\n const left = this.add(item, 1);\n this._changed();\n return left > 0;\n }\n\n consumeHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n if (!s) return;\n s.count -= n;\n if (s.count <= 0) this.slots[this.hotbarIndex] = null;\n this._changed();\n }\n\n /**\n * Износ предмета в руке (инструмент, оружие): n единиц прочности. Кончилась — предмет ломается,\n * как в Minecraft. У предметов без прочности ничего не происходит.\n */\n damageHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n const max = s ? (ITEMS[s.item]?.durability ?? 0) : 0;\n if (!s || !max) return;\n // Прочность: износ тратится лишь с шансом 1/(уровень+1), как в Minecraft\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = (s.dmg ?? 0) + n;\n if (s.dmg >= max) {\n this.slots[this.hotbarIndex] = null;\n events.emit(\"toolBreak\", { item: s.item });\n }\n this._changed();\n }\n\n /** Сколько предметов itemName во всех слотах (торговля). */\n count(itemName: string): number {\n let n = 0;\n for (const s of this.slots) if (s && s.item === itemName) n += s.count;\n return n;\n }\n\n /** Съесть count предметов itemName из любого места (для крафта). */\n consume(itemName: string, count: number) {\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName) {\n const take = Math.min(count, s.count);\n s.count -= take;\n count -= take;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n this._changed();\n }\n\n setSlot(i: number, stack: ItemStack | null) {\n this.slots[i] = stack;\n this._changed();\n }\n\n // ---- броня ----------------------------------------------------------------------------\n\n setArmor(i: number, stack: ItemStack | null) {\n this.armor[i] = stack;\n this._changed();\n }\n\n /** Сумма очков защиты и твёрдости надетой брони. */\n armorStats(): { points: number; toughness: number; protection: number } {\n let points = 0,\n toughness = 0,\n protection = 0;\n for (const s of this.armor) {\n const d = s ? ITEMS[s.item] : undefined;\n if (d) {\n points += d.armor;\n toughness += d.toughness;\n }\n protection += enchLevel(s, \"protection\");\n }\n return { points, toughness, protection };\n }\n\n /** Износ брони от удара: каждая часть теряет max(1, урон/4), как в Minecraft. */\n damageArmor(dmg: number) {\n const wear = Math.max(1, Math.floor(dmg / 4));\n let changed = false;\n this.armor.forEach((s, i) => {\n const max = s ? (ITEMS[s.item]?.durability ?? 0) : 0;\n if (!s || !max || !ITEMS[s.item]?.armor) return; // крылья от ударов не изнашиваются\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = (s.dmg ?? 0) + wear;\n changed = true;\n if (s.dmg >= max) {\n this.armor[i] = null;\n events.emit(\"toolBreak\", { item: s.item });\n }\n });\n if (changed) this._changed();\n }\n\n /** Надеты годные Крылья Предела: последняя единица прочности не летает (как у элитр). */\n canGlide(): boolean {\n const s = this.armor[1];\n return (\n s?.item === \"wings\" && (s.dmg ?? 0) < (ITEMS.wings?.durability ?? 0) - 1\n );\n }\n\n /** Секунда полёта на крыльях — единица износа (Прочность бережёт), до последней единицы. */\n wearWings(): void {\n const s = this.armor[1];\n const max = ITEMS.wings?.durability ?? 0;\n if (s?.item !== \"wings\" || !max) return;\n const ub = enchLevel(s, \"unbreaking\");\n if (ub > 0 && Math.random() >= 1 / (ub + 1)) return;\n s.dmg = Math.min(max - 1, (s.dmg ?? 0) + 1);\n this._changed();\n }\n\n serialize(): InventorySave {\n return {\n slots: this.slots,\n hotbarIndex: this.hotbarIndex,\n armor: this.armor,\n ender: this.ender,\n };\n }\n deserialize(d: InventorySave) {\n const slots = Array.isArray(d?.slots) ? d.slots : [];\n this.slots = new Array(36)\n .fill(null)\n .map((_, i) => sanitizeStack(slots[i]));\n const hb = Number(d?.hotbarIndex);\n this.hotbarIndex = Number.isInteger(hb) && hb >= 0 && hb <= 8 ? hb : 0;\n this.armor = [0, 1, 2, 3].map((i) => sanitizeStack(d?.armor?.[i]));\n // сейвы до сундука Предела поля не знают — склад просто пуст\n this.ender = new Array(ENDER_SIZE)\n .fill(null)\n .map((_, i) => sanitizeStack(d?.ender?.[i]));\n this._changed();\n }\n\n /**\n * Смерть: рюкзак, хотбар и броня пусты (их вещи Game уже бросил на землю). Сундук Предела —\n * склад, а не карманы: его содержимое смерть не трогает, как в Minecraft.\n */\n clearCarried() {\n this.slots = new Array(36).fill(null);\n this.armor = [null, null, null, null];\n this._changed();\n }\n}\n"
|
|
1457
|
+
},
|
|
1458
|
+
{
|
|
1459
|
+
"path": "systems/InventoryLoad.test.ts",
|
|
1460
|
+
"content": "import { describe, expect, it } from \"vitest\";\nimport \"../registry/Items\";\nimport { Inventory, sanitizeStack } from \"./Inventory\";\n\ndescribe(\"инвентарь: загрузка и смерть\", () => {\n it(\"предмет, которого нет в игре, и битые стаки из чужого сейва становятся пустыми слотами\", () => {\n expect(sanitizeStack({ item: \"no_such_item_xyz\", count: 3 })).toBeNull();\n expect(sanitizeStack({ item: \"dirt\", count: 0 })).toBeNull();\n expect(sanitizeStack({ item: \"dirt\", count: \"abc\" })).toBeNull();\n expect(sanitizeStack(null)).toBeNull();\n expect(sanitizeStack({ item: \"dirt\", count: 5.7 })).toEqual({\n item: \"dirt\",\n count: 5,\n });\n const inv = new Inventory();\n inv.deserialize({\n slots: [\n { item: \"no_such_item_xyz\", count: 1 },\n { item: \"dirt\", count: 2 },\n ],\n hotbarIndex: 42,\n });\n expect(inv.slots[0]).toBeNull();\n expect(inv.slots[1]).toEqual({ item: \"dirt\", count: 2 });\n expect(inv.slots.length).toBe(36);\n expect(inv.hotbarIndex).toBe(0);\n });\n\n it(\"смерть очищает карманы и броню, но не сундук Предела\", () => {\n const inv = new Inventory();\n inv.slots[0] = { item: \"dirt\", count: 5 };\n inv.armor[3] = { item: \"leather_boots\", count: 1 };\n inv.ender[0] = { item: \"diamond\", count: 7 };\n inv.clearCarried();\n expect(inv.slots.every((s) => s === null)).toBe(true);\n expect(inv.armor.every((s) => s === null)).toBe(true);\n expect(inv.ender[0]).toEqual({ item: \"diamond\", count: 7 });\n });\n\n it(\"обычные предметы не доливаются в стак с tag (чары, имя)\", () => {\n const inv = new Inventory();\n inv.slots[0] = { item: \"dirt\", count: 1, tag: { name: \"Особая\" } };\n inv.add(\"dirt\", 3);\n expect(inv.slots[0]).toEqual({\n item: \"dirt\",\n count: 1,\n tag: { name: \"Особая\" },\n });\n expect(inv.slots[1]).toEqual({ item: \"dirt\", count: 3 });\n });\n});\n"
|
|
1412
1461
|
},
|
|
1413
1462
|
{
|
|
1414
1463
|
"path": "systems/Jobs.test.ts",
|
|
@@ -1468,7 +1517,7 @@
|
|
|
1468
1517
|
},
|
|
1469
1518
|
{
|
|
1470
1519
|
"path": "systems/Save.ts",
|
|
1471
|
-
"content": "// Сохранение: экспорт/импорт мира в JSON-файл. Формат = полный снапшот\n// этапа 2 (мультиплеер): источник мира (сид или рецепт) + дифы блоков + игрок + инвентарь +\n// сущности + время. Мир восстанавливается повторной генерацией, поверх неё ложатся дифы.\n//\n// v1 — только сид (классический мир). v2 — плюс `world`. v3 — плюс палитра id→имя (дифы\n// переносятся по именам), измерения (Пекло, Предел), блочные сущности и точка возрождения.\n// Верхний мир всегда лежит в корне (`chunks`, `entities`) — его читают и Мастерская, и старый код.\n// Импорт понимает все версии и, заодно, голый рецепт мира (файл «Скачать рецепт»).\nimport { CONFIG } from \"../config\";\nimport { difficulty, parseDifficulty, setDifficulty } from \"./Difficulty\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport {\n DIMENSION_IDS,\n isDimensionId,\n type DimensionId,\n} from \"../world/Dimensions\";\nimport type { Game } from \"../main\";\nimport type {\n ChunkDiffMap,\n DimensionSave,\n SaveData,\n WorldSource,\n} from \"../types\";\nimport { parseWorldSpec } from \"../worldgen/WorldSpec\";\nimport { portalsOf } from \"../world/Portal\";\nimport { deserializeMap, serializeMap } from \"./Maps\";\n\nconst isObj = (v: unknown): v is Record<string, unknown> =>\n typeof v === \"object\" && v !== null && !Array.isArray(v);\n\n/** Клетка Врат Предела из сейва: три числа или null. */\nfunction gatewayOf(v: unknown): [number, number, number] | null {\n if (!Array.isArray(v) || v.length !== 3) return null;\n const [x, y, z] = v.map(Number) as [number, number, number];\n return [x, y, z].every(Number.isFinite) ? [x, y, z] : null;\n}\n\n/** Имя файла без запрещённых символов. */\nexport function safeFileName(name: string): string {\n return (\n name\n .replace(/[\\\\/:*?\"<>|]+/g, \"\")\n .replace(/\\s+/g, \"-\")\n .slice(0, 40) || \"world\"\n );\n}\n\n/** Скачать текст файлом. */\nexport function downloadText(\n fileName: string,\n text: string,\n type = \"application/json\",\n) {\n const blob = new Blob([text], { type });\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(blob);\n a.download = fileName;\n a.click();\n // отзывать сразу нельзя: часть браузеров (Firefox, Safari) ещё не начала скачивание\n const url = a.href;\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\n/** Выбрать файл и прочитать его текстом. */\nexport function pickTextFile(accept: string, onText: (text: string) => void) {\n const input = document.createElement(\"input\");\n input.type = \"file\";\n input.accept = accept;\n input.onchange = () => {\n const file = input.files?.[0];\n if (!file) return;\n const reader = new FileReader();\n reader.onload = () => onText(String(reader.result));\n reader.readAsText(file);\n };\n input.click();\n}\n\n/**\n * Источник мира из сейва. Рецепт из файла — чужие данные: проходит ту же проверку, что и ответ\n * модели, иначе сломанный сейв ронял бы генератор.\n */\nexport function worldSourceOf(data: SaveData): WorldSource {\n const w: unknown = data.world;\n // поколение генератора сохраняется как есть: нет поля — поколение 1 (все сейвы до v3)\n const gen =\n isObj(w) && typeof w.gen === \"number\" && Number.isInteger(w.gen)\n ? { gen: w.gen }\n : {};\n if (isObj(w) && w.kind === \"spec\") {\n const parsed = parseWorldSpec(w.spec);\n if (!parsed.ok)\n throw new Error(\"рецепт мира в сейве повреждён: \" + parsed.error);\n return { kind: \"spec\", spec: parsed.spec, ...gen };\n }\n if (isObj(w) && w.kind === \"noise\" && typeof w.seed === \"number\")\n return { kind: \"noise\", seed: w.seed, ...gen };\n return { kind: \"noise\", seed: data.seed };\n}\n\n/**\n * Перенос id блоков по палитре сейва: старый id → имя → нынешний id. null — переносить нечего\n * (палитры нет, как у v1/v2, или она совпадает с нынешним реестром). Блок, которого больше нет в\n * игре, становится воздухом.\n */\nexport function paletteRemap(\n palette: unknown,\n): ((id: number) => number) | null {\n if (!Array.isArray(palette)) return null;\n const map = palette.map((n) =>\n typeof n === \"string\" ? (B[n] ?? B.air!) : B.air!,\n );\n if (map.every((v, i) => v === i)) return null;\n return (id) => map[id] ?? B.air!;\n}\n\n/** Дифы с перенесёнными id. */\nexport function remapDiffs(\n diffs: ChunkDiffMap,\n remap: ((id: number) => number) | null,\n): ChunkDiffMap {\n if (!remap) return diffs;\n const out: ChunkDiffMap = {};\n for (const [k, arr] of Object.entries(diffs))\n out[k] = arr.map(([i, id, meta]): [number, number, number] => [\n i,\n remap(id),\n meta,\n ]);\n return out;\n}\n\nexport class SaveSystem {\n game: Game;\n\n constructor(game: Game) {\n this.game = game;\n } // game — фасад из main.ts\n\n collect(): SaveData {\n const g = this.game;\n const here = g.dimension;\n const current: DimensionSave = {\n chunks: g.world.serializeDiffs(),\n entities: g.entities.serialize(),\n blockEntities: g.blockEntities.serialize(),\n };\n const dimOf = (d: DimensionId): DimensionSave | undefined =>\n d === here ? current : g.dims.get(d);\n const ow = dimOf(\"overworld\") ?? { chunks: {} };\n const others: Partial<Record<DimensionId, DimensionSave>> = {};\n for (const d of DIMENSION_IDS)\n if (d !== \"overworld\") {\n const s = dimOf(d);\n if (s) others[d] = s;\n }\n return {\n version: CONFIG.saveVersion,\n seed: g.world.seed,\n world: g.world.source,\n time: g.dayNight.serialize(),\n weather: g.weather.serialize(),\n difficulty: difficulty(),\n player: { ...g.player.serialize(), inventory: g.inventory.serialize() },\n palette: BLOCKS.map((b) => b.name),\n chunks: ow.chunks,\n entities: ow.entities ?? [],\n blockEntities: ow.blockEntities ?? [],\n dimension: here,\n dimensions: others,\n portals: g.portals,\n beyond: g.beyondState,\n villages: [...g.villagesSettled],\n achievements: g.achievements.serialize(),\n maps: [...g.maps.values()].map(serializeMap),\n daysAwake: g.daysAwake,\n };\n }\n\n exportFile() {\n const src = this.game.world.source;\n const name =\n src.kind === \"spec\" ? safeFileName(src.spec.name) : String(src.seed);\n downloadText(\n `voxelcraft-save-${name}.json`,\n JSON.stringify(this.collect()),\n );\n }\n\n /** Импорт из выбранного файла: сейв (v1/v2/v3) или голый рецепт мира. */\n importFile(onDone?: (err: unknown) => void) {\n pickTextFile(\".json,application/json\", (text) => {\n try {\n this.importText(text);\n onDone?.(null);\n } catch (e) {\n onDone?.(e);\n }\n });\n }\n\n importText(text: string) {\n const data: unknown = JSON.parse(text);\n if (!isObj(data)) throw new Error(\"не похоже на сейв VoxelCraft\");\n if (\n isObj(data.chunks) &&\n (typeof data.seed === \"number\" || isObj(data.world))\n ) {\n const save = data as unknown as SaveData;\n void this.game.persist(); // снимок текущего мира берётся синхронно, до замены\n this.apply(save);\n const src = this.game.world.source;\n this.game.adoptNewWorld(\n src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed,\n );\n return;\n }\n // не сейв — может быть, рецепт мира\n if (\"terrain\" in data || \"zones\" in data || \"structures\" in data) {\n const parsed = parseWorldSpec(data);\n if (!parsed.ok) throw new Error(parsed.error);\n this.game.newWorld({ kind: \"spec\", spec: parsed.spec });\n return;\n }\n throw new Error(\"не похоже на сейв VoxelCraft\");\n }\n\n apply(data: SaveData) {\n const g = this.game;\n const remap = paletteRemap(data.palette);\n const src = worldSourceOf(data);\n const all: Partial<Record<DimensionId, DimensionSave>> = {\n ...(isObj(data.dimensions) ? data.dimensions : {}),\n overworld: {\n chunks: data.chunks,\n entities: data.entities,\n blockEntities: data.blockEntities,\n },\n };\n const here: DimensionId = isDimensionId(data.dimension)\n ? data.dimension\n : \"overworld\";\n\n g.dims.clear();\n let current: DimensionSave = { chunks: {} };\n for (const d of DIMENSION_IDS) {\n const s = all[d];\n if (!s) continue;\n const fixed: DimensionSave = {\n ...s,\n chunks: remapDiffs(isObj(s.chunks) ? s.chunks : {}, remap),\n };\n if (d === here) current = fixed;\n else g.dims.set(d, fixed);\n }\n\n g.rebuildWorld(src, here); // новый ChunkManager с этим источником и измерением\n g.world.loadDiffs(current.chunks);\n g.player.deserialize(data.player);\n g.inventory.deserialize(data.player.inventory);\n g.dayNight.deserialize(data.time);\n g.weather.deserialize(data.weather);\n // сложность мира: нет поля в старом сейве — обычная (systems/Difficulty)\n setDifficulty(parseDifficulty(data.difficulty) ?? \"normal\");\n g.entities.deserialize(\n current.entities as Parameters<Game[\"entities\"][\"deserialize\"]>[0],\n );\n g.blockEntities.deserialize(current.blockEntities);\n g.portals = portalsOf(data.portals);\n g.maps = new Map();\n for (const d of Array.isArray(data.maps) ? data.maps : []) {\n const m = deserializeMap(d);\n if (m) g.maps.set(m.id, m);\n }\n g.nextMapId = Math.max(0, ...g.maps.keys()) + 1;\n g.daysAwake =\n typeof data.daysAwake === \"number\" && data.daysAwake >= 0\n ? data.daysAwake\n : 0;\n g.villagesSettled = new Set(\n Array.isArray(data.villages)\n ? data.villages.filter((s): s is string => typeof s === \"string\")\n : [],\n );\n g.achievements.load(data.achievements);\n const b = isObj(data.beyond) ? data.beyond : {};\n g.beyondState = {\n initialized: b.initialized === true,\n dragonKilled: b.dragonKilled === true,\n creditsShown: b.creditsShown === true,\n gateway: gatewayOf(b.gateway),\n };\n }\n}\n"
|
|
1520
|
+
"content": "// Сохранение: экспорт/импорт мира в JSON-файл. Формат = полный снапшот\n// этапа 2 (мультиплеер): источник мира (сид или рецепт) + дифы блоков + игрок + инвентарь +\n// сущности + время. Мир восстанавливается повторной генерацией, поверх неё ложатся дифы.\n//\n// v1 — только сид (классический мир). v2 — плюс `world`. v3 — плюс палитра id→имя (дифы\n// переносятся по именам), измерения (Пекло, Предел), блочные сущности и точка возрождения.\n// Верхний мир всегда лежит в корне (`chunks`, `entities`) — его читают и Мастерская, и старый код.\n// Импорт понимает все версии и, заодно, голый рецепт мира (файл «Скачать рецепт»).\nimport { CONFIG } from \"../config\";\nimport { difficulty, parseDifficulty, setDifficulty } from \"./Difficulty\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport {\n DIMENSION_IDS,\n isDimensionId,\n type DimensionId,\n} from \"../world/Dimensions\";\nimport type { Game } from \"../main\";\nimport type {\n ChunkDiffMap,\n DimensionSave,\n SaveData,\n WorldSource,\n} from \"../types\";\nimport { parseWorldSpec } from \"../worldgen/WorldSpec\";\nimport { portalsOf } from \"../world/Portal\";\nimport { deserializeMap, serializeMap } from \"./Maps\";\n\nconst isObj = (v: unknown): v is Record<string, unknown> =>\n typeof v === \"object\" && v !== null && !Array.isArray(v);\n\n/** Клетка Врат Предела из сейва: три числа или null. */\nfunction gatewayOf(v: unknown): [number, number, number] | null {\n if (!Array.isArray(v) || v.length !== 3) return null;\n const [x, y, z] = v.map(Number) as [number, number, number];\n return [x, y, z].every(Number.isFinite) ? [x, y, z] : null;\n}\n\n/** Имя файла без запрещённых символов. */\nexport function safeFileName(name: string): string {\n return (\n name\n .replace(/[\\\\/:*?\"<>|]+/g, \"\")\n .replace(/\\s+/g, \"-\")\n .slice(0, 40) || \"world\"\n );\n}\n\n/** Скачать текст файлом. */\nexport function downloadText(\n fileName: string,\n text: string,\n type = \"application/json\",\n) {\n const blob = new Blob([text], { type });\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(blob);\n a.download = fileName;\n a.click();\n // отзывать сразу нельзя: часть браузеров (Firefox, Safari) ещё не начала скачивание\n const url = a.href;\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\n/** Выбрать файл и прочитать его текстом. */\nexport function pickTextFile(accept: string, onText: (text: string) => void) {\n const input = document.createElement(\"input\");\n input.type = \"file\";\n input.accept = accept;\n input.onchange = () => {\n const file = input.files?.[0];\n if (!file) return;\n const reader = new FileReader();\n reader.onload = () => onText(String(reader.result));\n reader.readAsText(file);\n };\n input.click();\n}\n\n/**\n * Источник мира из сейва. Рецепт из файла — чужие данные: проходит ту же проверку, что и ответ\n * модели, иначе сломанный сейв ронял бы генератор.\n */\nexport function worldSourceOf(data: SaveData): WorldSource {\n const w: unknown = data.world;\n // поколение генератора сохраняется как есть: нет поля — поколение 1 (все сейвы до v3)\n const gen =\n isObj(w) && typeof w.gen === \"number\" && Number.isInteger(w.gen)\n ? { gen: w.gen }\n : {};\n if (isObj(w) && w.kind === \"spec\") {\n const parsed = parseWorldSpec(w.spec);\n if (!parsed.ok)\n throw new Error(\"рецепт мира в сейве повреждён: \" + parsed.error);\n return { kind: \"spec\", spec: parsed.spec, ...gen };\n }\n if (isObj(w) && w.kind === \"noise\" && typeof w.seed === \"number\")\n return { kind: \"noise\", seed: w.seed, ...gen };\n return { kind: \"noise\", seed: data.seed };\n}\n\n/**\n * Перенос id блоков по палитре сейва: старый id → имя → нынешний id. null — переносить нечего\n * (палитры нет, как у v1/v2, или она совпадает с нынешним реестром). Блок, которого больше нет в\n * игре, становится воздухом.\n */\nexport function paletteRemap(\n palette: unknown,\n): ((id: number) => number) | null {\n if (!Array.isArray(palette)) return null;\n const map = palette.map((n) =>\n typeof n === \"string\" ? (B[n] ?? B.air!) : B.air!,\n );\n if (map.every((v, i) => v === i)) return null;\n return (id) => map[id] ?? B.air!;\n}\n\n/** Дифы из чужого файла: только записи вида [индекс, id, мета] из целых чисел. */\nfunction cleanDiffs(v: unknown): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n if (!isObj(v)) return out;\n for (const [k, arr] of Object.entries(v)) {\n if (!Array.isArray(arr)) continue;\n out[k] = (arr as unknown[]).filter(\n (e): e is [number, number, number] =>\n Array.isArray(e) && e.length === 3 && e.every(Number.isInteger),\n );\n }\n return out;\n}\n\n/**\n * id вне нынешнего реестра — воздух (сейв без палитры из другой версии): BLOCKS[id]! роняли бы мешер\n * и физику. Только ПОСЛЕ remapDiffs — до него id сейва с палитрой указывают в его палитру, а не в реестр.\n */\nfunction clampIds(diffs: ChunkDiffMap): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n for (const [k, arr] of Object.entries(diffs))\n out[k] = arr.map(([i, id, meta]): [number, number, number] =>\n id >= 0 && id < BLOCKS.length ? [i, id, meta] : [i, B.air!, 0],\n );\n return out;\n}\n\n/** Дифы с перенесёнными id. */\nexport function remapDiffs(\n diffs: ChunkDiffMap,\n remap: ((id: number) => number) | null,\n): ChunkDiffMap {\n if (!remap) return diffs;\n const out: ChunkDiffMap = {};\n for (const [k, arr] of Object.entries(diffs))\n out[k] = arr.map(([i, id, meta]): [number, number, number] => [\n i,\n remap(id),\n meta,\n ]);\n return out;\n}\n\nexport class SaveSystem {\n game: Game;\n\n constructor(game: Game) {\n this.game = game;\n } // game — фасад из main.ts\n\n collect(): SaveData {\n const g = this.game;\n const here = g.dimension;\n const current: DimensionSave = {\n chunks: g.world.serializeDiffs(),\n entities: g.entities.serialize(),\n blockEntities: g.blockEntities.serialize(),\n };\n const dimOf = (d: DimensionId): DimensionSave | undefined =>\n d === here ? current : g.dims.get(d);\n const ow = dimOf(\"overworld\") ?? { chunks: {} };\n const others: Partial<Record<DimensionId, DimensionSave>> = {};\n for (const d of DIMENSION_IDS)\n if (d !== \"overworld\") {\n const s = dimOf(d);\n if (s) others[d] = s;\n }\n return {\n version: CONFIG.saveVersion,\n seed: g.world.seed,\n world: g.world.source,\n time: g.dayNight.serialize(),\n weather: g.weather.serialize(),\n difficulty: difficulty(),\n player: { ...g.player.serialize(), inventory: g.inventory.serialize() },\n palette: BLOCKS.map((b) => b.name),\n chunks: ow.chunks,\n entities: ow.entities ?? [],\n blockEntities: ow.blockEntities ?? [],\n dimension: here,\n dimensions: others,\n portals: g.portals,\n beyond: g.beyondState,\n villages: [...g.villagesSettled],\n achievements: g.achievements.serialize(),\n maps: [...g.maps.values()].map(serializeMap),\n daysAwake: g.daysAwake,\n ...(g.netPlayers.size\n ? { players: Object.fromEntries(g.netPlayers) }\n : {}),\n };\n }\n\n exportFile() {\n const src = this.game.world.source;\n const name =\n src.kind === \"spec\" ? safeFileName(src.spec.name) : String(src.seed);\n downloadText(\n `voxelcraft-save-${name}.json`,\n JSON.stringify(this.collect()),\n );\n }\n\n /** Импорт из выбранного файла: сейв (v1/v2/v3) или голый рецепт мира. */\n importFile(onDone?: (err: unknown) => void) {\n pickTextFile(\".json,application/json\", (text) => {\n try {\n this.importText(text);\n onDone?.(null);\n } catch (e) {\n onDone?.(e);\n }\n });\n }\n\n importText(text: string) {\n const data: unknown = JSON.parse(text);\n if (!isObj(data)) throw new Error(\"не похоже на сейв VoxelCraft\");\n if (\n isObj(data.chunks) &&\n (typeof data.seed === \"number\" || isObj(data.world))\n ) {\n const save = data as unknown as SaveData;\n void this.game.persist(); // снимок текущего мира берётся синхронно, до замены\n this.apply(save);\n const src = this.game.world.source;\n this.game.adoptNewWorld(\n src.kind === \"spec\" ? src.spec.name : \"Мир \" + src.seed,\n );\n return;\n }\n // не сейв — может быть, рецепт мира\n if (\"terrain\" in data || \"zones\" in data || \"structures\" in data) {\n const parsed = parseWorldSpec(data);\n if (!parsed.ok) throw new Error(parsed.error);\n this.game.newWorld({ kind: \"spec\", spec: parsed.spec });\n return;\n }\n throw new Error(\"не похоже на сейв VoxelCraft\");\n }\n\n apply(data: SaveData) {\n const g = this.game;\n // всё, что может бросить, проверяем ДО замены мира: иначе мир уже пересоздан, а игрок и\n // инвентарь — от прошлого, и вызывающий (ловящий исключение) считает, что «мир остался прежним»\n const raw: unknown = data;\n const rawPlayer: unknown = isObj(raw) ? raw.player : undefined;\n if (!isObj(rawPlayer) || !Array.isArray(rawPlayer.pos))\n throw new Error(\"в сейве нет игрока\");\n const remap = paletteRemap(data.palette);\n const src = worldSourceOf(data);\n const all: Partial<Record<DimensionId, DimensionSave>> = {\n ...(isObj(data.dimensions) ? data.dimensions : {}),\n overworld: {\n chunks: data.chunks,\n entities: data.entities,\n blockEntities: data.blockEntities,\n },\n };\n const here: DimensionId = isDimensionId(data.dimension)\n ? data.dimension\n : \"overworld\";\n\n // измерения собираем в стороне и только потом заменяем: раньше dims чистились до разбора, и\n // сломанный сейв, упав на кривом чанке, стирал Пекло и Предел ТЕКУЩЕГО мира\n let current: DimensionSave = { chunks: {} };\n const others = new Map<DimensionId, DimensionSave>();\n for (const d of DIMENSION_IDS) {\n const s = all[d];\n if (!isObj(s)) continue;\n const fixed: DimensionSave = {\n ...s,\n chunks: clampIds(remapDiffs(cleanDiffs(s.chunks), remap)),\n };\n if (d === here) current = fixed;\n else others.set(d, fixed);\n }\n g.dims.clear();\n for (const [d, s] of others) g.dims.set(d, s);\n\n g.rebuildWorld(src, here); // новый ChunkManager с этим источником и измерением\n g.world.loadDiffs(current.chunks);\n g.player.deserialize(data.player);\n g.inventory.deserialize(data.player.inventory);\n g.dayNight.deserialize(data.time);\n g.weather.deserialize(data.weather);\n // сложность мира: нет поля в старом сейве — обычная (systems/Difficulty)\n setDifficulty(parseDifficulty(data.difficulty) ?? \"normal\");\n g.entities.deserialize(\n current.entities as Parameters<Game[\"entities\"][\"deserialize\"]>[0],\n );\n g.blockEntities.deserialize(current.blockEntities);\n g.portals = portalsOf(data.portals);\n g.maps = new Map();\n for (const d of Array.isArray(data.maps) ? data.maps : []) {\n const m = deserializeMap(d);\n if (m) g.maps.set(m.id, m);\n }\n g.nextMapId = Math.max(0, ...g.maps.keys()) + 1;\n g.daysAwake =\n typeof data.daysAwake === \"number\" && data.daysAwake >= 0\n ? data.daysAwake\n : 0;\n g.villagesSettled = new Set(\n Array.isArray(data.villages)\n ? data.villages.filter((s): s is string => typeof s === \"string\")\n : [],\n );\n g.achievements.load(data.achievements);\n g.netPlayers = new Map(\n isObj(data.players) ? Object.entries(data.players) : [],\n );\n const b = isObj(data.beyond) ? data.beyond : {};\n g.beyondState = {\n initialized: b.initialized === true,\n dragonKilled: b.dragonKilled === true,\n creditsShown: b.creditsShown === true,\n gateway: gatewayOf(b.gateway),\n };\n }\n}\n"
|
|
1472
1521
|
},
|
|
1473
1522
|
{
|
|
1474
1523
|
"path": "systems/Scaffold.test.ts",
|
|
@@ -1556,7 +1605,7 @@
|
|
|
1556
1605
|
},
|
|
1557
1606
|
{
|
|
1558
1607
|
"path": "types.ts",
|
|
1559
|
-
"content": "// Общие типы, разделяемые между модулями. Плоские data-driven-формы реестров\n// (блоки/предметы/мобы/рецепты), стек инвентаря, форма сейва и полезные алиасы.\nimport type { ChunkManager } from \"./world/ChunkManager\";\nimport type { DimensionId } from \"./world/Dimensions\";\nimport type { WorldSpec } from \"./worldgen/WorldSpec\";\n\n/** [x, y, z] — вектор/размер в блоках или пикселях. */\nexport type Vec3 = [number, number, number];\n\n// ---- блоки ------------------------------------------------------------------\n\nexport type ToolType =\n \"pickaxe\" | \"axe\" | \"shovel\" | \"hoe\" | \"sword\" | \"shears\";\n\n/**\n * Форма блока: из неё мешер, коллизия и raycast берут коробки (registry/shapes.ts). Мета блока\n * уточняет форму — сторону, половину, открытость, возраст (registry/meta.ts).\n */\nexport type BlockShape =\n | \"cube\"\n | \"cross\"\n | \"liquid\"\n | \"slab\"\n | \"stairs\"\n | \"torch\"\n | \"door\"\n | \"trapdoor\"\n | \"fence\"\n | \"fence_gate\"\n | \"pane\"\n | \"wall\"\n | \"ladder\"\n | \"scaffold\"\n | \"stand\"\n | \"wall_mounted\"\n | \"carpet\"\n | \"crop\"\n | \"portal\"\n | \"bed\"\n | \"chest\"\n | \"frame\"\n | \"end_portal\"\n | \"lever\"\n | \"button\"\n | \"repeater\"\n | \"piston\"\n | \"piston_head\"\n | \"hopper\"\n | \"comparator\"\n | \"rail\"\n | \"farmland\"\n | \"cactus\"\n | \"snow_layer\"\n | \"lantern\"\n | \"fire\"\n | \"egg\"\n | \"skull\"\n | \"rod\"\n | \"stalk\"\n | \"campfire\"\n | \"candle\"\n | \"pod\";\n\n/** Тайлы атласа для граней блока (имена тайлов). */\nexport interface BlockTex {\n top?: string;\n side?: string;\n bottom?: string;\n front?: string;\n /** Верхняя половина двухблочных форм (дверь) или вторая текстура (открытая печь, спелый посев). */\n upper?: string;\n /** Текстура «включённого» состояния: горящая печь, око в рамке. */\n on?: string;\n /** Плоская иконка предмета, если она не совпадает с гранью (дверь). */\n icon?: string;\n}\n\n/** Что выпадает из блока/моба: предмет, количество (или случайно n..max), опциональный шанс. */\nexport interface BlockDrop {\n item: string;\n n: number;\n max?: number;\n chance?: number;\n}\n\n/** Хук тика блока с поведением (печи/конвейеры — задел автоматизации). */\nexport type BlockTickFn = (\n world: ChunkManager,\n x: number,\n y: number,\n z: number,\n meta: number,\n) => void;\n\nexport interface BlockDef {\n id: number;\n name: string;\n solid: boolean;\n opaque: boolean;\n cross: boolean;\n liquid: boolean;\n /**\n * Твёрдость по шкале Minecraft: время ломания = hardness × (1.5 если добывается, иначе 5) /\n * скорость подходящего инструмента. 0 — мгновенно, Infinity — неломаемый.\n */\n hardness: number;\n tool: ToolType | null;\n drops: BlockDrop[] | null;\n onTick: BlockTickFn | null;\n tex: BlockTex | null;\n /** Репрезентативный цвет блока (0xRRGGBB): подбор блока по пикселю картинки и миникарта. */\n mapColor: number;\n /** Форма (коробки меша/коллизии). */\n shape: BlockShape;\n /** Столб с осью в мете (бревно): 0 — y, 1 — x, 2 — z; торцы смотрят вдоль оси. */\n axis: boolean;\n /** Свечение 0..15 (факел 14, светокамень 15, лава 15). */\n light: number;\n /** Сколько уровней света гасит блок: 15 — непрозрачный, листва 1, вода 2, стекло 0. */\n filter: number;\n /** Стойкость к взрыву (как blast resistance в Minecraft). */\n resistance: number;\n /**\n * Уровень добычи: 0 — дроп при любом способе; N — только инструментом `tool` уровня ≥ N\n * (1 дерево/золото, 2 камень, 3 железо, 4 алмаз, 5 незерит). Иначе блок ломается без дропа.\n */\n harvest: number;\n /** Падает, если под ним пусто (песок, гравий). */\n gravity: boolean;\n /** Горит (огонь распространяется на него). */\n flammable: boolean;\n /** Постановка блока заменяет его (трава, снежный слой, огонь, жидкость). */\n replaceable: boolean;\n /** Тип блочной сущности (сундук, печь…) или null. */\n blockEntity: string | null;\n /** Нет предмета-двойника (воздух, жидкости, огонь, портал, посевы, половинки дверей). */\n noItem: boolean;\n /** Предмет, которым этот блок ставится и который выбирает креатив (семена для посева), если не свой. */\n item: string | null;\n /** Куб, смотрящий в любую из 6 сторон (раздатчик): мета — DIR6, лицо — tex.front. */\n facing6?: boolean;\n /** Стоит в воде (морская трава): для меша, плавания и спавна клетка — вода, поверх — растение. */\n waterlogged?: boolean;\n}\n\n// ---- предметы ---------------------------------------------------------------\n\nexport type ArmorSlot = \"head\" | \"chest\" | \"legs\" | \"feet\";\n\n/**\n * Эффекты игрока: зелья (Сила, Регенерация, Ночное зрение, Скорость, Огнестойкость), маяк (Спешка,\n * Сопротивление, Прыгучесть) и Тление — черепа Пепельного владыки и удар Пепельного скелета.\n */\nexport type 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 | \"weakness\";\n\n/** Питьё: эффект на seconds секунд уровня level; clearsEffects — снять все (молоко). */\nexport interface DrinkDef {\n effect?: EffectId;\n seconds?: number;\n level?: number;\n clearsEffects?: boolean;\n /** Мгновенно вылечить (Зелье лечения — 4). */\n heal?: number;\n /** Мгновенно ранить (Зелье урона — 6): та же механика наоборот. */\n damage?: number;\n /** Утолить голод (бутылочка мёда — 6): питьё, а не еда — пьётся и сытым. */\n food?: number;\n}\n\nexport interface ItemDef {\n name: string;\n maxStack: number;\n kind: \"block\" | \"material\" | \"tool\" | \"armor\";\n toolType: ToolType | null;\n /** Множитель скорости добычи блоков своего `tool` (как в Minecraft: дерево 2 … золото 12). */\n speed: number;\n damage: number;\n blockId: number | null;\n tile: string | null;\n food: number;\n saturation: number;\n /** Уровень инструмента: 1 дерево/золото, 2 камень, 3 железо, 4 алмаз, 5 незерит. */\n tier: number;\n /** Прочность (использований до поломки); 0 — не ломается. */\n durability: number;\n /** Слот брони и её очки защиты / прочность брони. */\n armorSlot: ArmorSlot | null;\n armor: number;\n toughness: number;\n /** Секунды горения в печи (0 — не топливо). */\n fuel: number;\n /** Что остаётся в сетке/руке после использования (ведро молока → ведро). */\n remainder: string | null;\n /** Пьётся (зелья, молоко): что даёт; после питья остаётся remainder (пузырёк, ведро). */\n drink?: DrinkDef;\n /** Съеденное накладывает эффекты (иглобрюх — Отравление II). */\n eatEffects?: { id: EffectId; seconds: number; level?: number }[];\n // Заполняются buildIcons() из атласа после его создания.\n icon?: HTMLCanvasElement;\n iconURL?: string;\n}\n\n/** Слот инвентаря / крафт-сетки. */\nexport interface ItemStack {\n item: string;\n count: number;\n /** Износ (потрачено прочности) у инструментов и брони. */\n dmg?: number;\n /** Доп. данные предмета (зачарования, имя, содержимое) — задел, формат задаёт владелец поля. */\n tag?: Record<string, unknown>;\n}\n\n// ---- мобы -------------------------------------------------------------------\n\nexport interface MobPart {\n name: string;\n size: Vec3;\n pos: Vec3;\n pivot?: Vec3;\n /** Анимация: legL/legR (шаг), armL/armR, head, wingL/wingR. Одна метка — у скольких угодно частей. */\n anim?: string;\n color: string;\n /** Поворот части в покое (радианы) — руки зомби вперёд. */\n pose?: Vec3;\n /** Светится (глаза Тенехода): не темнеет в темноте. */\n glow?: boolean;\n}\n\nexport type MobCategory = \"passive\" | \"hostile\" | \"neutral\" | \"boss\";\n\nexport interface MobSpawn {\n /**\n * bright — на траве при свете ≥ 9; dark — при свете блоков 0 и небе ночью/в пещере; any — при\n * любом свете (жители Пекла).\n */\n light: \"bright\" | \"dark\" | \"any\";\n /** Только внутри постройки генератора (адская крепость) — WorldGenerator.structureAt. */\n structure?: string;\n /** На каких блоках появляется (мирные — трава). */\n ground?: readonly string[];\n group: [number, number];\n weight: number;\n dimension?: string;\n /** Только не выше этой высоты (слизни — глубоко под землёй). */\n maxY?: number;\n /** Где появляется, если не на полу: в толще воды (спрут, утопленник) или в воздухе (фантом). */\n medium?: \"water\" | \"air\" | \"lava\";\n /** Только когда игрок не спал три ночи (фантом). */\n insomnia?: boolean;\n /** Только у воды (в 4 блоках) — черепахи на пляжах, а не в пустыне. */\n nearWater?: boolean;\n /** Только в этих биомах (WorldGenerator.biomeOf; мир без биомов — везде). */\n biomes?: readonly string[];\n /** Водный — в пещерных озёрах под землёй, а не у поверхности моря (аксолотль). */\n underground?: boolean;\n /** Только если в 4 блоках есть этот блок (аксолотль — мох пышных пещер). */\n nearBlock?: string;\n}\n\nexport interface MobDef {\n name: string;\n hp: number;\n walkSpeed: number;\n fleeSpeed: number;\n box: { w: number; h: number };\n /** Устаревшее (было у курицы): где появляется. Теперь — spawn.ground. */\n spawnOn?: string;\n drops: BlockDrop[];\n parts: MobPart[];\n category: MobCategory;\n chaseSpeed?: number;\n /** С какого расстояния замечает игрока. */\n follow?: number;\n /** effect — что удар накладывает на игрока (Пепельный скелет — Тление на 10 с); knock — сила отброса. */\n attack?: {\n damage: number;\n reach: number;\n cooldown: number;\n effect?: { id: EffectId; seconds: number };\n knock?: number;\n };\n /** Разбойник: без игрока рядом охотится на жителей и голема, в набеге идёт к центру деревни. */\n illager?: boolean;\n /** Ворожей: держит дистанцию, призывает бесят и пускает клыки из земли (entities/IllagerBrain). */\n conjurer?: boolean;\n /** Топчет листву и посевы на пути (громила). */\n tramples?: boolean;\n /** Живёт столько секунд [от, до], потом слабеет по 1 в секунду (бесёнок). */\n lifespan?: [number, number];\n ranged?: {\n damage: number;\n speed: number;\n cooldown: number;\n range: number;\n /** Чем стреляет: стрела (по умолчанию), шар Плакальщика (взрыв), малый шар Пламени (поджигает). */\n projectile?:\n | \"arrow\"\n | \"fireball\"\n | \"small_fireball\"\n | \"potion\"\n | \"skull\"\n | \"bullet\"\n | \"wind_charge\"\n | \"snowball\"\n | \"spit\";\n /** Выстрелов за залп (Пламень — 3). */\n volley?: number;\n /** Что стрела накладывает на игрока (зимогор — Замедление на 30 с). */\n effect?: { id: EffectId; seconds: number };\n };\n /** Летает без гравитации и держится на height блоков выше цели (или пола). */\n flies?: { height: number };\n /** Лава и огонь не жгут (жители Пекла). */\n fireImmune?: boolean;\n /** Ударил игрок — злится вся стая таких же рядом (Мёртвые свинорылы). */\n groupAnger?: boolean;\n /**\n * Босс: своя логика (entities/DragonBrain.ts, entities/AshLordBrain.ts), полоса здоровья, не\n * исчезает издалека, не отбрасывается.\n */\n boss?: \"dragon\" | \"ash_lord\";\n /** Не двигается (Кристалл Предела): ни шагов, ни отброса, ни гравитации. */\n stationary?: boolean;\n /** Глубинник (страж Minecraft): луч с зарядкой, древний ещё и насылает Усталость (entities/DeepBrain). */\n guardian?: \"guardian\" | \"elder\";\n /** Страж: слепой, по вибрациям и запаху, звуковой удар (entities/WardenBrain). */\n warden?: boolean;\n /** Коза: высокие прыжки и таран, теряет рога об камень (entities/GoatBrain). */\n rams?: boolean;\n /** Лягушка: кого ловит языком и что после него остаётся (entities/FrogBrain). */\n tongue?: Record<string, string>;\n /** Нюхач: иногда роет землю и выкапывает древние семена (entities/SnifferBrain). */\n sniffs?: boolean;\n /** Снеговик: кидает снежки во враждебных, оставляет снежный след, тает в жаре (entities/SnowGolemBrain). */\n throwsSnow?: boolean;\n /** Пугливый (оцелот): игрок ближе 7 блоков без приманки в руке — убегает (entities/MobBrain). */\n shy?: boolean;\n /** Свинорыл: ПКМ золотым слитком — бартер (systems/Barter). */\n barters?: boolean;\n /** Не нападает на игрока в золотой броне (свинорыл). */\n goldPeace?: boolean;\n /** growsInto срабатывает только вне Пекла (свинорыл → мёртвый свинорыл, хоглин → зоглин). */\n zombifies?: boolean;\n /** Ходит по лаве, как по полу (страйдер). */\n walksOnLava?: boolean;\n /** Седлается без приручения (страйдер). */\n saddleOnly?: boolean;\n /** Ручной садится игроку на плечо, присел игрок — слетает (попугай; entities/Mob, MobBrain.petThink). */\n shoulderPet?: boolean;\n /** Красится красителем (ПКМ), стрижётся шерстью своего цвета (овца). */\n dyeable?: boolean;\n /** С кем ещё размножается и кто родится: лошадь + осёл → мул (EntityManager.findMate / breed). */\n mates?: Record<string, string>;\n /** Броненосец: игрок бежит рядом или ударили — сворачивается (замирает, урон −3), роняет щитки. */\n rollsUp?: boolean;\n /** Ручному можно надеть броню из щитков — она берёт урон на себя (волк). */\n wearsArmor?: boolean;\n /** Дух-помощник: дали предмет — собирает такие же выпавшие и несёт игроку (entities/AllayBrain). */\n collector?: boolean;\n /** Навьючивается сундуком на столько слотов (осёл, мул, лама); присев, ПКМ — открыть вьюк. */\n chestSlots?: number;\n /** Ходит караваном за своими на поводке (лама). */\n caravan?: boolean;\n /** Уходит (исчезает без дропа) через столько секунд [от, до] — странствующий торговец и его ламы. */\n leavesAfter?: [number, number];\n /** Ночью невидим (зелье невидимости), днём снова виден (молоко) — странствующий торговец. */\n nightInvisible?: boolean;\n /** Вырастает в другой вид через growTime секунд (головастик → лягушка по биому, EntityManager). */\n growsInto?: string;\n growTime?: number;\n /** Шипы: ударивший рукой получает столько урона (глубинник, пока не заряжает луч). */\n thorns?: number;\n /** Рыба: на суше задыхается и бьётся на месте. */\n needsWater?: boolean;\n /** Сколько секунд живёт на суше, прежде чем начать сохнуть (аксолотль — 300); без поля — 5 и бьётся. */\n dryTime?: number;\n /** На кого охотится в воде (аксолотль — рыбы, утопленники, глубинники). */\n hunts?: string[];\n /** Охотится по списку `hunts` и на суше (зомби — на жителей). */\n huntsLand?: boolean;\n /** Ломает двери, упёршись в них (зомби ночной осады). */\n breaksDoors?: boolean;\n /** Убитая им жертва-житель становится зомби-жителем (systems/Zombify). */\n turnsVillagers?: boolean;\n /** Лечится золотым яблоком обратно в жителя (зомби-житель). */\n curable?: boolean;\n /** Иглобрюх: раздувается рядом с игроком (Mob.peek) и отравляет касанием (entities/FishBrain). */\n puffs?: boolean;\n /** Вместо детёныша откладывает яйца-блоки этого вида на песок (черепаха). */\n layEggs?: string;\n /** Что роняет, вырастая из детёныша (черепаха — щиток). */\n growDrop?: string;\n /** Сидит на блоке (панцирник): мозг работает, но ни шагов, ни гравитации, ни отброса. */\n anchored?: boolean;\n /** Панцирник: приоткрывается и стреляет пулями (entities/ShellBrain.ts), закрытый — стрелы отскакивают. */\n shell?: boolean;\n /** Летит сквозь блоки без коллизий (дракон ломает их сам). */\n noClip?: boolean;\n /** Взрыв этой силы при смерти (Кристалл Предела — 6). */\n deathExplode?: number;\n explode?: { power: number; fuse: number; radius: number };\n /** Пчела: цветы, пыльца и свой улей (MobBrain.beeThink). */\n bee?: boolean;\n /** Ужалив, погибает (пчела). */\n diesOnAttack?: boolean;\n /** Плавает: в воде без гравитации, держится толщи (спрут, дельфин, утопленник). */\n swims?: boolean;\n /** Летун пикирует на цель и снова набирает высоту (фантом). */\n swoops?: boolean;\n /** Приручается этим (волк — кость, кошка — рыба), шанс за попытку (tameChance, по умолчанию 1/3). */\n tameItems?: string[];\n tameChance?: number;\n /** На нём ездят (лошадь): высота седока и скорость; приручается попытками сесть, едут в седле. */\n rideable?: { seat: number; speed: number };\n /** Ручной охраняет: бросается на тех, кого бьёт игрок и кто бьёт игрока (волк). */\n guards?: boolean;\n /** Шипуны обходят его стороной (кошка). */\n scaresBombers?: boolean;\n /** Не исчезает вдали от игрока и не занимает лимит монстров (железный голем деревни). */\n persistent?: boolean;\n /** Житель: профессия, по которой торгует (systems/Trading.ts). ПКМ — экран торговли. */\n trades?: string;\n /** Ходит только прыжками (слизень): на земле стоит, в воздухе летит к цели. */\n hops?: boolean;\n /** Умирая, распадается на n мобов вида mob (слизень → слизни поменьше). */\n splitsInto?: { mob: string; n: [number, number] };\n burnsInSun?: boolean;\n climbs?: boolean;\n teleports?: boolean;\n provokedByLook?: boolean;\n /** Нейтрален на свету (паук): нападает в темноте или если его ударили. */\n neutralInLight?: boolean;\n /** Идёт за этой едой в руке игрока и размножается от неё. */\n temptItems?: string[];\n /** Что даёт стрижка ножницами (и смерть нестриженого). */\n shearDrop?: BlockDrop;\n /** Опыт за убийство игроком: [min, max]. */\n xp?: [number, number];\n spawn?: MobSpawn;\n /** Высота глаз — доля высоты коробки. */\n eye?: number;\n}\n\n// ---- рецепты ----------------------------------------------------------------\n\nexport interface RecipeResult {\n item: string;\n count: number;\n}\n\nexport interface ShapelessRecipe {\n shapeless: string[];\n result: RecipeResult;\n}\n\nexport interface ShapedRecipe {\n pattern: (string | null)[][];\n result: RecipeResult;\n}\n\nexport type Recipe = ShapelessRecipe | ShapedRecipe;\n\n// ---- сейв -------------------------------------------------------------------\n\n/**\n * Откуда берётся мир: шум по сиду или рецепт мира (WorldSpec). `gen` — поколение генератора\n * (worldgen/WorldGenerator.ts): нет поля — поколение 1, как у всех сейвов до v3; новые миры по сиду\n * получают CURRENT_GEN. Вывод генератора — часть формата сейва, поэтому поколение пишется в сейв.\n */\nexport type WorldSource =\n | { kind: \"noise\"; seed: number; gen?: number }\n | { kind: \"spec\"; spec: WorldSpec; gen?: number };\n\n/** Дифы блоков по чанкам: \"cx,cz\" -> [[index, id, meta], ...]. */\nexport type ChunkDiffMap = Record<string, Array<[number, number, number]>>;\n\n/** Одно измерение в сейве: дифы блоков, сущности, блочные сущности. */\nexport interface DimensionSave {\n chunks: ChunkDiffMap;\n entities?: unknown[];\n blockEntities?: unknown[];\n}\n\nexport interface InventorySave {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n /** v3: броня — шлем, нагрудник, поножи, ботинки. */\n armor?: (ItemStack | null)[];\n /** Сундук Предела: общий склад игрока (нет поля — пуст). */\n ender?: (ItemStack | null)[];\n}\n\n/**\n * Полный снапшот мира. Вложенные serialize()-блобы player/entities намеренно\n * динамические (проходят JSON round-trip и раздаются в *.deserialize()) — any здесь оправдан.\n */\nexport interface SaveData {\n version: number;\n /** Сид мира. У мира из рецепта — сид рецепта; сейв v1 восстанавливает мир только по нему. */\n seed: number;\n /** v2+: источник мира. Нет поля (сейв v1) = шумовой мир по `seed`. */\n world?: WorldSource;\n time: { time?: number };\n /** v3: погода — ясно, дождь или гроза, и сколько секунд до смены. Нет поля — ясно. */\n weather?: { kind?: string; left?: number };\n /** Уровень сложности (нет поля — обычная). */\n difficulty?: string;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n player: any;\n /** Дифы верхнего мира (у сейвов v1/v2 — единственного). Остальные измерения — в `dimensions`. */\n chunks: ChunkDiffMap;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n entities: any;\n /** v3: id блока → имя на момент сейва. Дифы переносятся по именам и переживают смену id. */\n palette?: string[];\n /** v3: блочные сущности верхнего мира. */\n blockEntities?: unknown[];\n /** v3: измерение, в котором игрок. Нет — верхний мир. */\n dimension?: DimensionId;\n /** v3: остальные измерения (Пекло, Предел), если в них уже бывали. */\n dimensions?: Partial<Record<DimensionId, DimensionSave>>;\n /** v3: известные порталы по измерениям (клетка внутри каждого) — обратный путь к своему порталу. */\n portals?: Partial<Record<DimensionId, [number, number, number][]>>;\n /** v3: Предел — кристаллы и дракон уже поставлены, дракон убит, титры показаны, где Врата. */\n beyond?: {\n initialized?: boolean;\n dragonKilled?: boolean;\n creditsShown?: boolean;\n gateway?: unknown;\n };\n /** v3: деревни (id региона), в которые уже поселены жители — второй раз не селятся. */\n villages?: string[];\n /** v3: открытые достижения (id из systems/Achievements). */\n achievements?: string[];\n /** v3: карты (systems/Maps) — участок, измерение, пиксели в base64. */\n maps?: unknown[];\n /** v3: сколько ночей игрок не спал (с трёх прилетают фантомы). */\n daysAwake?: number;\n}\n"
|
|
1608
|
+
"content": "// Общие типы, разделяемые между модулями. Плоские data-driven-формы реестров\n// (блоки/предметы/мобы/рецепты), стек инвентаря, форма сейва и полезные алиасы.\nimport type { ChunkManager } from \"./world/ChunkManager\";\nimport type { DimensionId } from \"./world/Dimensions\";\nimport type { WorldSpec } from \"./worldgen/WorldSpec\";\n\n/** [x, y, z] — вектор/размер в блоках или пикселях. */\nexport type Vec3 = [number, number, number];\n\n// ---- блоки ------------------------------------------------------------------\n\nexport type ToolType =\n \"pickaxe\" | \"axe\" | \"shovel\" | \"hoe\" | \"sword\" | \"shears\";\n\n/**\n * Форма блока: из неё мешер, коллизия и raycast берут коробки (registry/shapes.ts). Мета блока\n * уточняет форму — сторону, половину, открытость, возраст (registry/meta.ts).\n */\nexport type BlockShape =\n | \"cube\"\n | \"cross\"\n | \"liquid\"\n | \"slab\"\n | \"stairs\"\n | \"torch\"\n | \"door\"\n | \"trapdoor\"\n | \"fence\"\n | \"fence_gate\"\n | \"pane\"\n | \"wall\"\n | \"ladder\"\n | \"scaffold\"\n | \"stand\"\n | \"wall_mounted\"\n | \"carpet\"\n | \"crop\"\n | \"portal\"\n | \"bed\"\n | \"chest\"\n | \"frame\"\n | \"end_portal\"\n | \"lever\"\n | \"button\"\n | \"repeater\"\n | \"piston\"\n | \"piston_head\"\n | \"hopper\"\n | \"comparator\"\n | \"rail\"\n | \"farmland\"\n | \"cactus\"\n | \"snow_layer\"\n | \"lantern\"\n | \"fire\"\n | \"egg\"\n | \"skull\"\n | \"rod\"\n | \"stalk\"\n | \"campfire\"\n | \"candle\"\n | \"pod\";\n\n/** Тайлы атласа для граней блока (имена тайлов). */\nexport interface BlockTex {\n top?: string;\n side?: string;\n bottom?: string;\n front?: string;\n /** Верхняя половина двухблочных форм (дверь) или вторая текстура (открытая печь, спелый посев). */\n upper?: string;\n /** Текстура «включённого» состояния: горящая печь, око в рамке. */\n on?: string;\n /** Плоская иконка предмета, если она не совпадает с гранью (дверь). */\n icon?: string;\n}\n\n/** Что выпадает из блока/моба: предмет, количество (или случайно n..max), опциональный шанс. */\nexport interface BlockDrop {\n item: string;\n n: number;\n max?: number;\n chance?: number;\n}\n\n/** Хук тика блока с поведением (печи/конвейеры — задел автоматизации). */\nexport type BlockTickFn = (\n world: ChunkManager,\n x: number,\n y: number,\n z: number,\n meta: number,\n) => void;\n\nexport interface BlockDef {\n id: number;\n name: string;\n solid: boolean;\n opaque: boolean;\n cross: boolean;\n liquid: boolean;\n /**\n * Твёрдость по шкале Minecraft: время ломания = hardness × (1.5 если добывается, иначе 5) /\n * скорость подходящего инструмента. 0 — мгновенно, Infinity — неломаемый.\n */\n hardness: number;\n tool: ToolType | null;\n drops: BlockDrop[] | null;\n onTick: BlockTickFn | null;\n tex: BlockTex | null;\n /** Репрезентативный цвет блока (0xRRGGBB): подбор блока по пикселю картинки и миникарта. */\n mapColor: number;\n /** Форма (коробки меша/коллизии). */\n shape: BlockShape;\n /** Столб с осью в мете (бревно): 0 — y, 1 — x, 2 — z; торцы смотрят вдоль оси. */\n axis: boolean;\n /** Свечение 0..15 (факел 14, светокамень 15, лава 15). */\n light: number;\n /** Сколько уровней света гасит блок: 15 — непрозрачный, листва 1, вода 2, стекло 0. */\n filter: number;\n /** Стойкость к взрыву (как blast resistance в Minecraft). */\n resistance: number;\n /**\n * Уровень добычи: 0 — дроп при любом способе; N — только инструментом `tool` уровня ≥ N\n * (1 дерево/золото, 2 камень, 3 железо, 4 алмаз, 5 незерит). Иначе блок ломается без дропа.\n */\n harvest: number;\n /** Падает, если под ним пусто (песок, гравий). */\n gravity: boolean;\n /** Горит (огонь распространяется на него). */\n flammable: boolean;\n /** Постановка блока заменяет его (трава, снежный слой, огонь, жидкость). */\n replaceable: boolean;\n /** Тип блочной сущности (сундук, печь…) или null. */\n blockEntity: string | null;\n /** Нет предмета-двойника (воздух, жидкости, огонь, портал, посевы, половинки дверей). */\n noItem: boolean;\n /** Предмет, которым этот блок ставится и который выбирает креатив (семена для посева), если не свой. */\n item: string | null;\n /** Куб, смотрящий в любую из 6 сторон (раздатчик): мета — DIR6, лицо — tex.front. */\n facing6?: boolean;\n /** Стоит в воде (морская трава): для меша, плавания и спавна клетка — вода, поверх — растение. */\n waterlogged?: boolean;\n}\n\n// ---- предметы ---------------------------------------------------------------\n\nexport type ArmorSlot = \"head\" | \"chest\" | \"legs\" | \"feet\";\n\n/**\n * Эффекты игрока: зелья (Сила, Регенерация, Ночное зрение, Скорость, Огнестойкость), маяк (Спешка,\n * Сопротивление, Прыгучесть) и Тление — черепа Пепельного владыки и удар Пепельного скелета.\n */\nexport type 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 | \"weakness\";\n\n/** Питьё: эффект на seconds секунд уровня level; clearsEffects — снять все (молоко). */\nexport interface DrinkDef {\n effect?: EffectId;\n seconds?: number;\n level?: number;\n clearsEffects?: boolean;\n /** Мгновенно вылечить (Зелье лечения — 4). */\n heal?: number;\n /** Мгновенно ранить (Зелье урона — 6): та же механика наоборот. */\n damage?: number;\n /** Утолить голод (бутылочка мёда — 6): питьё, а не еда — пьётся и сытым. */\n food?: number;\n}\n\nexport interface ItemDef {\n name: string;\n maxStack: number;\n kind: \"block\" | \"material\" | \"tool\" | \"armor\";\n toolType: ToolType | null;\n /** Множитель скорости добычи блоков своего `tool` (как в Minecraft: дерево 2 … золото 12). */\n speed: number;\n damage: number;\n blockId: number | null;\n tile: string | null;\n food: number;\n saturation: number;\n /** Уровень инструмента: 1 дерево/золото, 2 камень, 3 железо, 4 алмаз, 5 незерит. */\n tier: number;\n /** Прочность (использований до поломки); 0 — не ломается. */\n durability: number;\n /** Слот брони и её очки защиты / прочность брони. */\n armorSlot: ArmorSlot | null;\n armor: number;\n toughness: number;\n /** Секунды горения в печи (0 — не топливо). */\n fuel: number;\n /** Что остаётся в сетке/руке после использования (ведро молока → ведро). */\n remainder: string | null;\n /** Пьётся (зелья, молоко): что даёт; после питья остаётся remainder (пузырёк, ведро). */\n drink?: DrinkDef;\n /** Съеденное накладывает эффекты (иглобрюх — Отравление II). */\n eatEffects?: { id: EffectId; seconds: number; level?: number }[];\n // Заполняются buildIcons() из атласа после его создания.\n icon?: HTMLCanvasElement;\n iconURL?: string;\n}\n\n/** Слот инвентаря / крафт-сетки. */\nexport interface ItemStack {\n item: string;\n count: number;\n /** Износ (потрачено прочности) у инструментов и брони. */\n dmg?: number;\n /** Доп. данные предмета (зачарования, имя, содержимое) — задел, формат задаёт владелец поля. */\n tag?: Record<string, unknown>;\n}\n\n// ---- мобы -------------------------------------------------------------------\n\nexport interface MobPart {\n name: string;\n size: Vec3;\n pos: Vec3;\n pivot?: Vec3;\n /** Анимация: legL/legR (шаг), armL/armR, head, wingL/wingR. Одна метка — у скольких угодно частей. */\n anim?: string;\n color: string;\n /** Поворот части в покое (радианы) — руки зомби вперёд. */\n pose?: Vec3;\n /** Светится (глаза Тенехода): не темнеет в темноте. */\n glow?: boolean;\n}\n\nexport type MobCategory = \"passive\" | \"hostile\" | \"neutral\" | \"boss\";\n\nexport interface MobSpawn {\n /**\n * bright — на траве при свете ≥ 9; dark — при свете блоков 0 и небе ночью/в пещере; any — при\n * любом свете (жители Пекла).\n */\n light: \"bright\" | \"dark\" | \"any\";\n /** Только внутри постройки генератора (адская крепость) — WorldGenerator.structureAt. */\n structure?: string;\n /** На каких блоках появляется (мирные — трава). */\n ground?: readonly string[];\n group: [number, number];\n weight: number;\n dimension?: string;\n /** Только не выше этой высоты (слизни — глубоко под землёй). */\n maxY?: number;\n /** Где появляется, если не на полу: в толще воды (спрут, утопленник) или в воздухе (фантом). */\n medium?: \"water\" | \"air\" | \"lava\";\n /** Только когда игрок не спал три ночи (фантом). */\n insomnia?: boolean;\n /** Только у воды (в 4 блоках) — черепахи на пляжах, а не в пустыне. */\n nearWater?: boolean;\n /** Только в этих биомах (WorldGenerator.biomeOf; мир без биомов — везде). */\n biomes?: readonly string[];\n /** Водный — в пещерных озёрах под землёй, а не у поверхности моря (аксолотль). */\n underground?: boolean;\n /** Только если в 4 блоках есть этот блок (аксолотль — мох пышных пещер). */\n nearBlock?: string;\n}\n\nexport interface MobDef {\n name: string;\n hp: number;\n walkSpeed: number;\n fleeSpeed: number;\n box: { w: number; h: number };\n /** Устаревшее (было у курицы): где появляется. Теперь — spawn.ground. */\n spawnOn?: string;\n drops: BlockDrop[];\n parts: MobPart[];\n category: MobCategory;\n chaseSpeed?: number;\n /** С какого расстояния замечает игрока. */\n follow?: number;\n /** effect — что удар накладывает на игрока (Пепельный скелет — Тление на 10 с); knock — сила отброса. */\n attack?: {\n damage: number;\n reach: number;\n cooldown: number;\n effect?: { id: EffectId; seconds: number };\n knock?: number;\n };\n /** Разбойник: без игрока рядом охотится на жителей и голема, в набеге идёт к центру деревни. */\n illager?: boolean;\n /** Ворожей: держит дистанцию, призывает бесят и пускает клыки из земли (entities/IllagerBrain). */\n conjurer?: boolean;\n /** Топчет листву и посевы на пути (громила). */\n tramples?: boolean;\n /** Живёт столько секунд [от, до], потом слабеет по 1 в секунду (бесёнок). */\n lifespan?: [number, number];\n ranged?: {\n damage: number;\n speed: number;\n cooldown: number;\n range: number;\n /** Чем стреляет: стрела (по умолчанию), шар Плакальщика (взрыв), малый шар Пламени (поджигает). */\n projectile?:\n | \"arrow\"\n | \"fireball\"\n | \"small_fireball\"\n | \"potion\"\n | \"skull\"\n | \"bullet\"\n | \"wind_charge\"\n | \"snowball\"\n | \"spit\";\n /** Выстрелов за залп (Пламень — 3). */\n volley?: number;\n /** Что стрела накладывает на игрока (зимогор — Замедление на 30 с). */\n effect?: { id: EffectId; seconds: number };\n };\n /** Летает без гравитации и держится на height блоков выше цели (или пола). */\n flies?: { height: number };\n /** Лава и огонь не жгут (жители Пекла). */\n fireImmune?: boolean;\n /** Ударил игрок — злится вся стая таких же рядом (Мёртвые свинорылы). */\n groupAnger?: boolean;\n /**\n * Босс: своя логика (entities/DragonBrain.ts, entities/AshLordBrain.ts), полоса здоровья, не\n * исчезает издалека, не отбрасывается.\n */\n boss?: \"dragon\" | \"ash_lord\";\n /** Не двигается (Кристалл Предела): ни шагов, ни отброса, ни гравитации. */\n stationary?: boolean;\n /** Глубинник (страж Minecraft): луч с зарядкой, древний ещё и насылает Усталость (entities/DeepBrain). */\n guardian?: \"guardian\" | \"elder\";\n /** Страж: слепой, по вибрациям и запаху, звуковой удар (entities/WardenBrain). */\n warden?: boolean;\n /** Коза: высокие прыжки и таран, теряет рога об камень (entities/GoatBrain). */\n rams?: boolean;\n /** Лягушка: кого ловит языком и что после него остаётся (entities/FrogBrain). */\n tongue?: Record<string, string>;\n /** Нюхач: иногда роет землю и выкапывает древние семена (entities/SnifferBrain). */\n sniffs?: boolean;\n /** Снеговик: кидает снежки во враждебных, оставляет снежный след, тает в жаре (entities/SnowGolemBrain). */\n throwsSnow?: boolean;\n /** Пугливый (оцелот): игрок ближе 7 блоков без приманки в руке — убегает (entities/MobBrain). */\n shy?: boolean;\n /** Свинорыл: ПКМ золотым слитком — бартер (systems/Barter). */\n barters?: boolean;\n /** Не нападает на игрока в золотой броне (свинорыл). */\n goldPeace?: boolean;\n /** growsInto срабатывает только вне Пекла (свинорыл → мёртвый свинорыл, хоглин → зоглин). */\n zombifies?: boolean;\n /** Ходит по лаве, как по полу (страйдер). */\n walksOnLava?: boolean;\n /** Седлается без приручения (страйдер). */\n saddleOnly?: boolean;\n /** Ручной садится игроку на плечо, присел игрок — слетает (попугай; entities/Mob, MobBrain.petThink). */\n shoulderPet?: boolean;\n /** Красится красителем (ПКМ), стрижётся шерстью своего цвета (овца). */\n dyeable?: boolean;\n /** С кем ещё размножается и кто родится: лошадь + осёл → мул (EntityManager.findMate / breed). */\n mates?: Record<string, string>;\n /** Броненосец: игрок бежит рядом или ударили — сворачивается (замирает, урон −3), роняет щитки. */\n rollsUp?: boolean;\n /** Ручному можно надеть броню из щитков — она берёт урон на себя (волк). */\n wearsArmor?: boolean;\n /** Дух-помощник: дали предмет — собирает такие же выпавшие и несёт игроку (entities/AllayBrain). */\n collector?: boolean;\n /** Навьючивается сундуком на столько слотов (осёл, мул, лама); присев, ПКМ — открыть вьюк. */\n chestSlots?: number;\n /** Ходит караваном за своими на поводке (лама). */\n caravan?: boolean;\n /** Уходит (исчезает без дропа) через столько секунд [от, до] — странствующий торговец и его ламы. */\n leavesAfter?: [number, number];\n /** Ночью невидим (зелье невидимости), днём снова виден (молоко) — странствующий торговец. */\n nightInvisible?: boolean;\n /** Вырастает в другой вид через growTime секунд (головастик → лягушка по биому, EntityManager). */\n growsInto?: string;\n growTime?: number;\n /** Шипы: ударивший рукой получает столько урона (глубинник, пока не заряжает луч). */\n thorns?: number;\n /** Рыба: на суше задыхается и бьётся на месте. */\n needsWater?: boolean;\n /** Сколько секунд живёт на суше, прежде чем начать сохнуть (аксолотль — 300); без поля — 5 и бьётся. */\n dryTime?: number;\n /** На кого охотится в воде (аксолотль — рыбы, утопленники, глубинники). */\n hunts?: string[];\n /** Охотится по списку `hunts` и на суше (зомби — на жителей). */\n huntsLand?: boolean;\n /** Ломает двери, упёршись в них (зомби ночной осады). */\n breaksDoors?: boolean;\n /** Убитая им жертва-житель становится зомби-жителем (systems/Zombify). */\n turnsVillagers?: boolean;\n /** Лечится золотым яблоком обратно в жителя (зомби-житель). */\n curable?: boolean;\n /** Иглобрюх: раздувается рядом с игроком (Mob.peek) и отравляет касанием (entities/FishBrain). */\n puffs?: boolean;\n /** Вместо детёныша откладывает яйца-блоки этого вида на песок (черепаха). */\n layEggs?: string;\n /** Что роняет, вырастая из детёныша (черепаха — щиток). */\n growDrop?: string;\n /** Сидит на блоке (панцирник): мозг работает, но ни шагов, ни гравитации, ни отброса. */\n anchored?: boolean;\n /** Панцирник: приоткрывается и стреляет пулями (entities/ShellBrain.ts), закрытый — стрелы отскакивают. */\n shell?: boolean;\n /** Летит сквозь блоки без коллизий (дракон ломает их сам). */\n noClip?: boolean;\n /** Взрыв этой силы при смерти (Кристалл Предела — 6). */\n deathExplode?: number;\n explode?: { power: number; fuse: number; radius: number };\n /** Пчела: цветы, пыльца и свой улей (MobBrain.beeThink). */\n bee?: boolean;\n /** Ужалив, погибает (пчела). */\n diesOnAttack?: boolean;\n /** Плавает: в воде без гравитации, держится толщи (спрут, дельфин, утопленник). */\n swims?: boolean;\n /** Летун пикирует на цель и снова набирает высоту (фантом). */\n swoops?: boolean;\n /** Приручается этим (волк — кость, кошка — рыба), шанс за попытку (tameChance, по умолчанию 1/3). */\n tameItems?: string[];\n tameChance?: number;\n /** На нём ездят (лошадь): высота седока и скорость; приручается попытками сесть, едут в седле. */\n rideable?: { seat: number; speed: number };\n /** Ручной охраняет: бросается на тех, кого бьёт игрок и кто бьёт игрока (волк). */\n guards?: boolean;\n /** Шипуны обходят его стороной (кошка). */\n scaresBombers?: boolean;\n /** Не исчезает вдали от игрока и не занимает лимит монстров (железный голем деревни). */\n persistent?: boolean;\n /** Житель: профессия, по которой торгует (systems/Trading.ts). ПКМ — экран торговли. */\n trades?: string;\n /** Ходит только прыжками (слизень): на земле стоит, в воздухе летит к цели. */\n hops?: boolean;\n /** Умирая, распадается на n мобов вида mob (слизень → слизни поменьше). */\n splitsInto?: { mob: string; n: [number, number] };\n burnsInSun?: boolean;\n climbs?: boolean;\n teleports?: boolean;\n provokedByLook?: boolean;\n /** Нейтрален на свету (паук): нападает в темноте или если его ударили. */\n neutralInLight?: boolean;\n /** Идёт за этой едой в руке игрока и размножается от неё. */\n temptItems?: string[];\n /** Что даёт стрижка ножницами (и смерть нестриженого). */\n shearDrop?: BlockDrop;\n /** Опыт за убийство игроком: [min, max]. */\n xp?: [number, number];\n spawn?: MobSpawn;\n /** Высота глаз — доля высоты коробки. */\n eye?: number;\n}\n\n// ---- рецепты ----------------------------------------------------------------\n\nexport interface RecipeResult {\n item: string;\n count: number;\n}\n\nexport interface ShapelessRecipe {\n shapeless: string[];\n result: RecipeResult;\n}\n\nexport interface ShapedRecipe {\n pattern: (string | null)[][];\n result: RecipeResult;\n}\n\nexport type Recipe = ShapelessRecipe | ShapedRecipe;\n\n// ---- сейв -------------------------------------------------------------------\n\n/**\n * Откуда берётся мир: шум по сиду или рецепт мира (WorldSpec). `gen` — поколение генератора\n * (worldgen/WorldGenerator.ts): нет поля — поколение 1, как у всех сейвов до v3; новые миры по сиду\n * получают CURRENT_GEN. Вывод генератора — часть формата сейва, поэтому поколение пишется в сейв.\n */\nexport type WorldSource =\n | { kind: \"noise\"; seed: number; gen?: number }\n | { kind: \"spec\"; spec: WorldSpec; gen?: number };\n\n/** Дифы блоков по чанкам: \"cx,cz\" -> [[index, id, meta], ...]. */\nexport type ChunkDiffMap = Record<string, Array<[number, number, number]>>;\n\n/** Одно измерение в сейве: дифы блоков, сущности, блочные сущности. */\nexport interface DimensionSave {\n chunks: ChunkDiffMap;\n entities?: unknown[];\n blockEntities?: unknown[];\n}\n\nexport interface InventorySave {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n /** v3: броня — шлем, нагрудник, поножи, ботинки. */\n armor?: (ItemStack | null)[];\n /** Сундук Предела: общий склад игрока (нет поля — пуст). */\n ender?: (ItemStack | null)[];\n}\n\n/**\n * Полный снапшот мира. Вложенные serialize()-блобы player/entities намеренно\n * динамические (проходят JSON round-trip и раздаются в *.deserialize()) — any здесь оправдан.\n */\nexport interface SaveData {\n version: number;\n /** Сид мира. У мира из рецепта — сид рецепта; сейв v1 восстанавливает мир только по нему. */\n seed: number;\n /** v2+: источник мира. Нет поля (сейв v1) = шумовой мир по `seed`. */\n world?: WorldSource;\n time: { time?: number };\n /** v3: погода — ясно, дождь или гроза, и сколько секунд до смены. Нет поля — ясно. */\n weather?: { kind?: string; left?: number };\n /** Уровень сложности (нет поля — обычная). */\n difficulty?: string;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n player: any;\n /** Дифы верхнего мира (у сейвов v1/v2 — единственного). Остальные измерения — в `dimensions`. */\n chunks: ChunkDiffMap;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n entities: any;\n /** v3: id блока → имя на момент сейва. Дифы переносятся по именам и переживают смену id. */\n palette?: string[];\n /** v3: блочные сущности верхнего мира. */\n blockEntities?: unknown[];\n /** v3: измерение, в котором игрок. Нет — верхний мир. */\n dimension?: DimensionId;\n /** v3: остальные измерения (Пекло, Предел), если в них уже бывали. */\n dimensions?: Partial<Record<DimensionId, DimensionSave>>;\n /** v3: известные порталы по измерениям (клетка внутри каждого) — обратный путь к своему порталу. */\n portals?: Partial<Record<DimensionId, [number, number, number][]>>;\n /** v3: Предел — кристаллы и дракон уже поставлены, дракон убит, титры показаны, где Врата. */\n beyond?: {\n initialized?: boolean;\n dragonKilled?: boolean;\n creditsShown?: boolean;\n gateway?: unknown;\n };\n /** v3: деревни (id региона), в которые уже поселены жители — второй раз не селятся. */\n villages?: string[];\n /** v3: открытые достижения (id из systems/Achievements). */\n achievements?: string[];\n /** v3: карты (systems/Maps) — участок, измерение, пиксели в base64. */\n maps?: unknown[];\n /** v3: сколько ночей игрок не спал (с трёх прилетают фантомы). */\n daysAwake?: number;\n /**\n * Сетевая игра: другие игроки этого мира по userId (позиция, измерение, инвентарь, опыт) —\n * вернувшийся продолжает там же и с тем же. Нет поля — в мире не играли по сети.\n */\n players?: Record<string, unknown>;\n}\n"
|
|
1560
1609
|
},
|
|
1561
1610
|
{
|
|
1562
1611
|
"path": "ui/AchievementsView.ts",
|
|
@@ -1568,11 +1617,11 @@
|
|
|
1568
1617
|
},
|
|
1569
1618
|
{
|
|
1570
1619
|
"path": "ui/BookScreen.ts",
|
|
1571
|
-
"content": "// Книга на кафедре: ПКМ кладёт книгу из руки, открывает её на правку («Книга с пером») или на\n// чтение (написанная). Правка — по страницам, «Подписать» превращает книгу в написанную.\n// Чистая часть (страницы, подпись, перенос строк) — systems/Book; хранение — блочная сущность\n// lectern. Экран устроен как редактор таблички: ввод забирает клавиши у игры и отпускает захват.\nimport { events } from \"../core/EventBus\";\nimport {\n MAX_PAGES,\n bookOf,\n isBook,\n isWritable,\n pageLines,\n signBook,\n withPages,\n} from \"../systems/Book\";\nimport type { LecternState } from \"../world/blockentities/lectern\";\nimport type { ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\n\ntype At = { x: number; y: number; z: number };\n\nexport class BookScreen {\n open = false;\n readonly root: HTMLDivElement;\n private readonly titleEl: HTMLDivElement;\n private readonly pageEl: HTMLDivElement;\n private readonly editEl: HTMLTextAreaElement;\n private readonly countEl: HTMLDivElement;\n private readonly signRow: HTMLDivElement;\n private readonly titleInput: HTMLInputElement;\n private at: At | null = null;\n private page = 0;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"bookScreen\";\n this.root.className = \"bookScreen hidden\";\n\n this.titleEl = this._div(this.root, \"bookTitle\");\n this.pageEl = this._div(this.root, \"bookPage\");\n this.editEl = document.createElement(\"textarea\");\n this.editEl.className = \"bookEdit hidden\";\n this.editEl.maxLength = 900;\n this.editEl.spellcheck = false;\n this.root.appendChild(this.editEl);\n\n const nav = this._div(this.root, \"bookNav\");\n this._button(nav, \"◀\", () => this._turn(-1));\n this.countEl = this._div(nav, \"bookCount\");\n this._button(nav, \"▶\", () => this._turn(1));\n\n this.signRow = this._div(this.root, \"bookNav\");\n this.titleInput = document.createElement(\"input\");\n this.titleInput.className = \"bookTitleInput\";\n this.titleInput.placeholder = \"Название\";\n this.titleInput.maxLength = 32;\n this.signRow.appendChild(this.titleInput);\n this._button(this.signRow, \"Подписать\", () => this._sign());\n\n const foot = this._div(this.root, \"bookNav\");\n this._button(foot, \"Забрать\", () => this._take());\n this._button(foot, \"Закрыть\", () => this.hide());\n\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openLectern\", (p: At) => this.show(p));\n for (const el of [this.editEl, this.titleInput] as HTMLElement[])\n el.addEventListener(\n \"keydown\",\n (e: KeyboardEvent) => {\n e.stopPropagation(); // клавиши ввода — не игре\n if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n private _div(parent: HTMLElement, cls: string): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n }\n\n private _button(parent: HTMLElement, text: string, fn: () => void): void {\n const b = document.createElement(\"div\");\n b.className = \"bookBtn\";\n b.textContent = text;\n b.onmousedown = (e) => {\n e.preventDefault();\n fn();\n };\n parent.appendChild(b);\n }\n\n /** Книга, лежащая на кафедре. */\n private _book(): ItemStack | null {\n const at = this.at;\n if (!at) return null;\n const be = this.game.blockEntities.ensure(at.x, at.y, at.z);\n return be?.type === \"lectern\"\n ? ((be.state as LecternState).items[0] ?? null)\n : null;\n }\n\n private _setBook(s: ItemStack | null): void {\n const at = this.at;\n if (!at) return;\n const be = this.game.blockEntities.ensure(at.x, at.y, at.z);\n if (be?.type === \"lectern\") (be.state as LecternState).items[0] = s;\n }\n\n show(p: At): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.ensure(p.x, p.y, p.z);\n if (be?.type !== \"lectern\") return;\n this.at = p;\n // кафедра пуста, а в руке книга — она ложится на кафедру, как в Minecraft\n const st = be.state as LecternState;\n const held = g.inventory.heldItem();\n if (!st.items[0] && held && isBook(held.item)) {\n st.items[0] = { ...held, count: 1 };\n g.inventory.consumeHeld(1);\n events.emit(\"bookPlaced\", { ...p, item: held.item });\n }\n if (!st.items[0]) {\n this.at = null;\n return; // класть нечего и читать нечего\n }\n this.open = true;\n this.page = 0;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.remove(\"hidden\");\n this._render();\n }\n\n hide(): void {\n if (!this.open) return;\n this._save();\n this.open = false;\n this.at = null;\n this.root.classList.add(\"hidden\");\n this.editEl.blur();\n this.titleInput.blur();\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Записать набранную страницу в книгу (правится только «книга с пером»). */\n private _save(): void {\n const book = this._book();\n if (!book || !isWritable(book)) return;\n const pages = bookOf(book).pages.slice();\n while (pages.length <= this.page) pages.push(\"\");\n pages[this.page] = this.editEl.value;\n this._setBook(withPages(book, pages));\n }\n\n private _turn(d: number): void {\n this._save();\n const book = this._book();\n if (!book) return;\n const pages = bookOf(book).pages;\n const last = isWritable(book)\n ? Math.min(pages.length, MAX_PAGES - 1) // у книги с пером можно завести следующую\n : pages.length - 1;\n this.page = Math.max(0, Math.min(last, this.page + d));\n this._render();\n }\n\n /** Подписать: книга с пером становится написанной и больше не правится. */\n private _sign(): void {\n this._save();\n const signed = signBook(this._book(), this.titleInput.value, \"Игрок\");\n if (!signed) return;\n this._setBook(signed);\n this.titleInput.value = \"\";\n this.page = 0;\n events.emit(\"bookSigned\", { title: signed.tag!.title as string });\n this._render();\n }\n\n /** Забрать книгу с кафедры в инвентарь. */\n private _take(): void {\n this._save();\n const book = this._book();\n if (!book) return this.hide();\n this._setBook(null);\n const p = this.game.player.pos;\n if (this.game.inventory.addStack(book) > 0)\n this.game.entities.spawnDrop(\n p.x,\n p.y + 1,\n p.z,\n book.item,\n 1,\n undefined,\n book.tag,\n );\n this.hide();\n }\n\n private _render(): void {\n const book = this._book();\n if (!book) return this.hide();\n const { title, author, pages } = bookOf(book);\n const writable = isWritable(book);\n this.titleEl.textContent = writable\n ? \"Книга с пером\"\n : title + (author ? ` — ${author}` : \"\");\n this.countEl.textContent = `${this.page + 1} / ${Math.max(pages.length, this.page + 1)}`;\n this.signRow.classList.toggle(\"hidden\", !writable);\n this.editEl.classList.toggle(\"hidden\", !writable);\n this.pageEl.classList.toggle(\"hidden\", writable);\n if (writable) {\n this.editEl.value = pages[this.page] ?? \"\";\n this.editEl.focus();\n } else {\n this.pageEl.textContent = \"\";\n for (const line of pageLines(pages[this.page] ?? \"\")) {\n const d = document.createElement(\"div\");\n d.textContent = line;\n this.pageEl.appendChild(d);\n }\n }\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1620
|
+
"content": "// Книга на кафедре: ПКМ кладёт книгу из руки, открывает её на правку («Книга с пером») или на\n// чтение (написанная). Правка — по страницам, «Подписать» превращает книгу в написанную.\n// Чистая часть (страницы, подпись, перенос строк) — systems/Book; хранение — блочная сущность\n// lectern. Экран устроен как редактор таблички: ввод забирает клавиши у игры и отпускает захват.\nimport { events } from \"../core/EventBus\";\nimport {\n MAX_PAGES,\n bookOf,\n isBook,\n isWritable,\n pageLines,\n signBook,\n withPages,\n} from \"../systems/Book\";\nimport type { LecternState } from \"../world/blockentities/lectern\";\nimport type { ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\n\ntype At = { x: number; y: number; z: number };\n\nexport class BookScreen {\n open = false;\n readonly root: HTMLDivElement;\n private readonly titleEl: HTMLDivElement;\n private readonly pageEl: HTMLDivElement;\n private readonly editEl: HTMLTextAreaElement;\n private readonly countEl: HTMLDivElement;\n private readonly signRow: HTMLDivElement;\n private readonly titleInput: HTMLInputElement;\n private at: At | null = null;\n private page = 0;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"bookScreen\";\n this.root.className = \"bookScreen hidden\";\n\n this.titleEl = this._div(this.root, \"bookTitle\");\n this.pageEl = this._div(this.root, \"bookPage\");\n this.editEl = document.createElement(\"textarea\");\n this.editEl.className = \"bookEdit hidden\";\n this.editEl.maxLength = 900;\n this.editEl.spellcheck = false;\n this.root.appendChild(this.editEl);\n\n const nav = this._div(this.root, \"bookNav\");\n this._button(nav, \"◀\", () => this._turn(-1));\n this.countEl = this._div(nav, \"bookCount\");\n this._button(nav, \"▶\", () => this._turn(1));\n\n this.signRow = this._div(this.root, \"bookNav\");\n this.titleInput = document.createElement(\"input\");\n this.titleInput.className = \"bookTitleInput\";\n this.titleInput.placeholder = \"Название\";\n this.titleInput.maxLength = 32;\n this.signRow.appendChild(this.titleInput);\n this._button(this.signRow, \"Подписать\", () => this._sign());\n\n const foot = this._div(this.root, \"bookNav\");\n this._button(foot, \"Забрать\", () => this._take());\n this._button(foot, \"Закрыть\", () => this.hide());\n\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openLectern\", (p: At) => this.show(p));\n for (const el of [this.editEl, this.titleInput] as HTMLElement[])\n el.addEventListener(\n \"keydown\",\n (e: KeyboardEvent) => {\n e.stopPropagation(); // клавиши ввода — не игре\n if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n private _div(parent: HTMLElement, cls: string): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n }\n\n private _button(parent: HTMLElement, text: string, fn: () => void): void {\n const b = document.createElement(\"div\");\n b.className = \"bookBtn\";\n b.textContent = text;\n b.onmousedown = (e) => {\n e.preventDefault();\n fn();\n };\n parent.appendChild(b);\n }\n\n /** Книга, лежащая на кафедре. */\n private _book(): ItemStack | null {\n const at = this.at;\n if (!at) return null;\n const be = this.game.blockEntities.ensure(at.x, at.y, at.z);\n return be?.type === \"lectern\"\n ? ((be.state as LecternState).items[0] ?? null)\n : null;\n }\n\n private _setBook(s: ItemStack | null): void {\n const at = this.at;\n if (!at) return;\n const be = this.game.blockEntities.ensure(at.x, at.y, at.z);\n if (be?.type === \"lectern\") (be.state as LecternState).items[0] = s;\n }\n\n show(p: At): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.ensure(p.x, p.y, p.z);\n if (be?.type !== \"lectern\") return;\n this.at = p;\n // кафедра пуста, а в руке книга — она ложится на кафедру, как в Minecraft\n const st = be.state as LecternState;\n const held = g.inventory.heldItem();\n if (!st.items[0] && held && isBook(held.item)) {\n st.items[0] = { ...held, count: 1 };\n g.inventory.consumeHeld(1);\n events.emit(\"bookPlaced\", { ...p, item: held.item });\n }\n if (!st.items[0]) {\n this.at = null;\n return; // класть нечего и читать нечего\n }\n this.open = true;\n this.page = 0;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.remove(\"hidden\");\n this._render();\n }\n\n hide(): void {\n if (!this.open) return;\n this._save();\n this.open = false;\n this.at = null;\n this.root.classList.add(\"hidden\");\n this.editEl.blur();\n this.titleInput.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Записать набранную страницу в книгу (правится только «книга с пером»). */\n private _save(): void {\n const book = this._book();\n if (!book || !isWritable(book)) return;\n const pages = bookOf(book).pages.slice();\n while (pages.length <= this.page) pages.push(\"\");\n pages[this.page] = this.editEl.value;\n this._setBook(withPages(book, pages));\n }\n\n private _turn(d: number): void {\n this._save();\n const book = this._book();\n if (!book) return;\n const pages = bookOf(book).pages;\n const last = isWritable(book)\n ? Math.min(pages.length, MAX_PAGES - 1) // у книги с пером можно завести следующую\n : pages.length - 1;\n this.page = Math.max(0, Math.min(last, this.page + d));\n this._render();\n }\n\n /** Подписать: книга с пером становится написанной и больше не правится. */\n private _sign(): void {\n this._save();\n const signed = signBook(this._book(), this.titleInput.value, \"Игрок\");\n if (!signed) return;\n this._setBook(signed);\n this.titleInput.value = \"\";\n this.page = 0;\n events.emit(\"bookSigned\", { title: signed.tag!.title as string });\n this._render();\n }\n\n /** Забрать книгу с кафедры в инвентарь. */\n private _take(): void {\n this._save();\n const book = this._book();\n if (!book) return this.hide();\n this._setBook(null);\n const p = this.game.player.pos;\n if (this.game.inventory.addStack(book) > 0)\n this.game.entities.spawnDrop(\n p.x,\n p.y + 1,\n p.z,\n book.item,\n 1,\n undefined,\n book.tag,\n );\n this.hide();\n }\n\n private _render(): void {\n const book = this._book();\n if (!book) return this.hide();\n const { title, author, pages } = bookOf(book);\n const writable = isWritable(book);\n this.titleEl.textContent = writable\n ? \"Книга с пером\"\n : title + (author ? ` — ${author}` : \"\");\n this.countEl.textContent = `${this.page + 1} / ${Math.max(pages.length, this.page + 1)}`;\n this.signRow.classList.toggle(\"hidden\", !writable);\n this.editEl.classList.toggle(\"hidden\", !writable);\n this.pageEl.classList.toggle(\"hidden\", writable);\n if (writable) {\n this.editEl.value = pages[this.page] ?? \"\";\n this.editEl.focus();\n } else {\n this.pageEl.textContent = \"\";\n for (const line of pageLines(pages[this.page] ?? \"\")) {\n const d = document.createElement(\"div\");\n d.textContent = line;\n this.pageEl.appendChild(d);\n }\n }\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1572
1621
|
},
|
|
1573
1622
|
{
|
|
1574
1623
|
"path": "ui/ChatConsole.ts",
|
|
1575
|
-
"content": "// Чат/консоль: T — открыть строку, / — открыть сразу с «/». Строка с «/» уходит командой\n// (интент Command → systems/Commands), остальное — реплика в чат (одиночная игра — только эхо).\n// Лог показывает последние строки и гаснет сам; открытая консоль показывает всю историю.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport type { Game } from \"../main\";\n\nconst MAX_LINES = 30;\n\nexport class ChatConsole {\n open = false;\n readonly root: HTMLDivElement;\n private readonly logEl: HTMLDivElement;\n private readonly input: HTMLInputElement;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"chat\";\n this.logEl = document.createElement(\"div\");\n this.logEl.className = \"chatLog\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 256;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.logEl, this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openConsole\", (d?: { prefix?: string }) =>\n this.show(d?.prefix ?? \"\"),\n );\n events.on(\"chat\", ({ text }: { text: string }) => this.print(text));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n // клавиши строки — не игре (Esc не должен ставить паузу, E — открывать инвентарь)\n e.stopPropagation();\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(prefix = \"\"): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open) return;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = prefix;\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n private submit(): void {\n const text = this.input.value.trim();\n this.hide();\n if (!text) return;\n if (text.startsWith(\"/\")) this.game.intents.push(Intent.Command, { text });\n else this.print(\"<Вы> \" + text);\n }\n\n print(text: string): void {\n const line = document.createElement(\"div\");\n line.className = \"chatLine\";\n line.textContent = text;\n this.logEl.appendChild(line);\n while (this.logEl.children.length > MAX_LINES)\n this.logEl.firstElementChild!.remove();\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1624
|
+
"content": "// Чат/консоль: T — открыть строку, / — открыть сразу с «/». Строка с «/» уходит командой\n// (интент Command → systems/Commands), остальное — реплика в чат (одиночная игра — только эхо).\n// Лог показывает последние строки и гаснет сам; открытая консоль показывает всю историю.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport type { Game } from \"../main\";\n\nconst MAX_LINES = 30;\n\nexport class ChatConsole {\n open = false;\n readonly root: HTMLDivElement;\n private readonly logEl: HTMLDivElement;\n private readonly input: HTMLInputElement;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"chat\";\n this.logEl = document.createElement(\"div\");\n this.logEl.className = \"chatLog\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 256;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.logEl, this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openConsole\", (d?: { prefix?: string }) =>\n this.show(d?.prefix ?? \"\"),\n );\n events.on(\"chat\", ({ text }: { text: string }) => this.print(text));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n // клавиши строки — не игре (Esc не должен ставить паузу, E — открывать инвентарь)\n e.stopPropagation();\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(prefix = \"\"): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open) return;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = prefix;\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n private submit(): void {\n const text = this.input.value.trim();\n this.hide();\n if (!text) return;\n if (text.startsWith(\"/\")) this.game.intents.push(Intent.Command, { text });\n else if (this.game.net)\n this.game.net.sendChat(text); // сетевая игра: реплика — всем\n else this.print(\"<Вы> \" + text);\n }\n\n print(text: string): void {\n const line = document.createElement(\"div\");\n line.className = \"chatLine\";\n line.textContent = text;\n this.logEl.appendChild(line);\n while (this.logEl.children.length > MAX_LINES)\n this.logEl.firstElementChild!.remove();\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1576
1625
|
},
|
|
1577
1626
|
{
|
|
1578
1627
|
"path": "ui/CreateWorldUI.ts",
|
|
@@ -1584,19 +1633,23 @@
|
|
|
1584
1633
|
},
|
|
1585
1634
|
{
|
|
1586
1635
|
"path": "ui/HUD.ts",
|
|
1587
|
-
"content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport { ITEMS } from \"../registry/Items\";\nimport { EFFECT_NAMES, labelOf } from \"../registry/names\";\nimport { MAX_AIR } from \"../player/Player\";\nimport { RAID_WAVES } from \"../systems/Raid\";\nimport { drawDurability } from \"./slotView\";\nimport { enchantsOf, enchantSuffix } from \"../systems/Enchant\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { MAP_SIZE, mapRGB, type MapData } from \"../systems/Maps\";\nimport type { Game } from \"../main\";\n\nexport class HUD {\n game: Game;\n $: (id: string) => HTMLElement;\n hotbarEl: HTMLElement;\n heartsEl: HTMLElement;\n fpsEl: HTMLElement;\n debugEl: HTMLElement;\n timeEl: HTMLElement;\n overlayEl: HTMLElement;\n hurtEl: HTMLElement;\n msgEl: HTMLElement;\n hungerEl: HTMLElement;\n _invDirty: boolean;\n _lastHealth: number;\n _lastFood?: number;\n _modeWas?: string;\n _foodModeWas?: string;\n _msgT?: ReturnType<typeof setTimeout>;\n _slots: HTMLDivElement[];\n _hearts: HTMLSpanElement[];\n _drumsticks: HTMLSpanElement[];\n /** Экран смерти и оранжевая кайма горения. */\n deathEl!: HTMLElement;\n fireEl!: HTMLElement;\n /** Полоса здоровья босса и экран титров. */\n bossEl!: HTMLElement;\n creditsEl!: HTMLElement;\n /** Список эффектов зелий с оставшимся временем. */\n effectsEl!: HTMLElement;\n /** Пузырьки воздуха над едой — только под водой. */\n bubblesEl!: HTMLElement;\n private readonly _bubbles: HTMLSpanElement[] = [];\n /** Компас или часы в руке — стрелка или время у хотбара. */\n gadgetEl!: HTMLElement;\n /** Карта в руке. */\n mapEl!: HTMLElement;\n mapCanvas!: HTMLCanvasElement;\n mapMarker!: HTMLElement;\n /** Крестик карты сокровищ. */\n mapTreasure!: HTMLElement;\n private _mapShown: MapData | null = null;\n private _mapVersion = -1;\n /** Полоса опыта над хотбаром и номер уровня. */\n xpFillEl: HTMLElement;\n xpLevelEl: HTMLElement;\n _lastXp = \"\";\n /** Шкала брони над сердцами (10 значков, по 2 очка защиты). */\n armorEl: HTMLElement;\n _armorPips: HTMLSpanElement[] = [];\n _lastArmor = -1;\n\n constructor(game: Game) {\n this.game = game;\n // Все элементы гарантированно есть в разметке — helper утверждает non-null.\n this.$ = (id: string): HTMLElement => document.getElementById(id)!;\n this.hotbarEl = this.$(\"hotbar\");\n this.heartsEl = this.$(\"hearts\");\n this.fpsEl = this.$(\"fps\");\n this.debugEl = this.$(\"debug\");\n this.timeEl = this.$(\"timeIndicator\");\n this.overlayEl = this.$(\"overlay\");\n this.hurtEl = this.$(\"hurtFlash\");\n this.msgEl = this.$(\"message\");\n\n this._invDirty = true;\n this._lastHealth = -1;\n events.on(\"invChanged\", () => {\n this._invDirty = true;\n });\n events.on(\"playerHurt\", () => this._flashHurt());\n // короткие подсказки из систем без доступа к HUD («Нужно седло»)\n events.on(\"ui:hint\", ({ text }: { text: string }) =>\n this.message(text, 1600),\n );\n // экран смерти и огонь на экране\n this.deathEl = document.createElement(\"div\");\n this.deathEl.id = \"deathScreen\";\n this.deathEl.className = \"hidden\";\n this.deathEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Вы погибли</h1><p class=\"deathCause\"></p>' +\n '<button class=\"deathBtn\">Возродиться</button></div>';\n this.deathEl.querySelector(\"button\")!.onclick = () => game.respawnPlayer();\n this.fireEl = document.createElement(\"div\");\n this.fireEl.id = \"fireOverlay\";\n // полоса босса и титры после победы над драконом\n this.bossEl = document.createElement(\"div\");\n this.bossEl.id = \"bossBar\";\n this.bossEl.className = \"hidden\";\n this.bossEl.innerHTML =\n '<div class=\"bossName\"></div><div class=\"bossTrack\"><div class=\"bossFill\"></div></div>';\n this.creditsEl = document.createElement(\"div\");\n this.creditsEl.id = \"creditsScreen\";\n this.creditsEl.className = \"hidden\";\n this.creditsEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Победа</h1><p class=\"credits\">Дракон Предела повержен.<br>' +\n \"Путь пройден: верхний мир — Пекло — Предел.<br>Мир остаётся твоим: строй, исследуй, \" +\n \"возвращайся.<br><br>Спасибо, что играешь в VoxelCraft.</p>\" +\n '<button class=\"deathBtn\">Продолжить</button></div>';\n this.creditsEl.querySelector(\"button\")!.onclick = () => {\n this.creditsEl.classList.add(\"hidden\");\n game.input.uiOpen = false;\n game.input.requestLock();\n };\n this.effectsEl = document.createElement(\"div\");\n this.effectsEl.id = \"effects\";\n // компас и часы в руке\n this.gadgetEl = document.createElement(\"div\");\n this.gadgetEl.id = \"gadget\";\n this.gadgetEl.className = \"hidden\";\n this.heartsEl.parentElement!.appendChild(this.gadgetEl);\n // карта в руке: холст 128×128 (растянут) и метка игрока\n this.mapEl = document.createElement(\"div\");\n this.mapEl.id = \"mapView\";\n this.mapEl.className = \"hidden\";\n this.mapCanvas = document.createElement(\"canvas\");\n this.mapCanvas.width = this.mapCanvas.height = MAP_SIZE;\n this.mapMarker = document.createElement(\"span\");\n this.mapMarker.className = \"mapMarker\";\n this.mapMarker.textContent = \"▲\";\n this.mapTreasure = document.createElement(\"span\");\n this.mapTreasure.className = \"mapTreasure\";\n this.mapTreasure.textContent = \"✖\";\n this.mapEl.append(this.mapCanvas, this.mapTreasure, this.mapMarker);\n this.heartsEl.parentElement!.appendChild(this.mapEl);\n this.heartsEl.parentElement!.append(\n this.deathEl,\n this.fireEl,\n this.bossEl,\n this.creditsEl,\n this.effectsEl,\n );\n events.on(\"modeChanged\", ({ mode }) =>\n this.message(\n mode === \"creative\"\n ? \"Режим: КРЕАТИВ (двойной Space — полёт)\"\n : \"Режим: выживание\",\n 1600,\n ),\n );\n\n // слоты хотбара\n this._slots = [];\n for (let i = 0; i < 9; i++) {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n // тап/клик по слоту — выбрать его (на телефоне колёсика и цифр нет)\n d.addEventListener(\"pointerdown\", () =>\n game.intents.push(Intent.SelectSlot, { slot: i }),\n );\n this.hotbarEl.appendChild(d);\n this._slots.push(d);\n }\n // сердечки\n this._hearts = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"heart\";\n s.textContent = \"❤\";\n this.heartsEl.appendChild(s);\n this._hearts.push(s);\n }\n // опыт\n const xpBar = document.createElement(\"div\");\n xpBar.id = \"xpBar\";\n this.xpFillEl = document.createElement(\"div\");\n this.xpFillEl.className = \"xpFill\";\n xpBar.appendChild(this.xpFillEl);\n this.xpLevelEl = document.createElement(\"div\");\n this.xpLevelEl.id = \"xpLevel\";\n this.heartsEl.parentElement!.append(xpBar, this.xpLevelEl);\n // броня\n this.armorEl = document.createElement(\"div\");\n this.armorEl.id = \"armorBar\";\n this.heartsEl.parentElement!.appendChild(this.armorEl);\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"armorPip\";\n s.textContent = \"⛨\";\n this.armorEl.appendChild(s);\n this._armorPips.push(s);\n }\n // окорочка (голод)\n this.hungerEl = this.$(\"hunger\");\n this._drumsticks = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"drumstick\";\n s.textContent = \"🍗\";\n this.hungerEl.appendChild(s);\n this._drumsticks.push(s);\n }\n this.bubblesEl = document.createElement(\"div\");\n this.bubblesEl.id = \"bubbles\";\n this.bubblesEl.style.display = \"none\";\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"bubble\";\n s.textContent = \"◯\";\n this.bubblesEl.appendChild(s);\n this._bubbles.push(s);\n }\n this.hungerEl.parentElement!.appendChild(this.bubblesEl);\n\n // кнопки паузы\n this.$(\"btnExport\").onclick = () => game.save.exportFile();\n this.$(\"btnImport\").onclick = () =>\n game.save.importFile((err: unknown) => {\n this.message(\n err ? \"Ошибка импорта: \" + (err as Error).message : \"Мир загружен\",\n 1800,\n );\n });\n this.$(\"btnNewSeed\").onclick = () => {\n const s = prompt(\n \"Сид нового мира (число):\",\n String((Math.random() * 1e9) | 0),\n );\n if (s !== null && s.trim() !== \"\") game.newWorld(Number(s) || 0);\n };\n this.overlayEl.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).closest(\"button\")) return; // кнопки не запускают игру\n game.startPlay();\n });\n }\n\n showOverlay(paused: boolean) {\n this.overlayEl.classList.remove(\"hidden\");\n this.$(\"overlayTitle\").textContent = paused ? \"Пауза\" : \"VoxelCraft\";\n this.$(\"overlayHint\").textContent = paused\n ? \"Клик — продолжить\"\n : \"Клик — играть\";\n }\n hideOverlay() {\n this.overlayEl.classList.add(\"hidden\");\n }\n\n /** Экран «Вы погибли» с причиной и кнопкой возрождения. */\n showDeath(cause: string) {\n const CAUSES: Record<string, string> = {\n attack: \"Вас убил монстр\",\n projectile: \"Вас застрелили\",\n explosion: \"Вас взорвали\",\n lava: \"Вы искупались в лаве\",\n fire: \"Вы сгорели\",\n fall: \"Вы упали с высоты\",\n starve: \"Вы умерли от голода\",\n void: \"Вы выпали из мира\",\n cactus: \"Вас искололо кактусом\",\n magic: \"Вас сгубило колдовство\",\n lightning: \"Вас поразила молния\",\n decay: \"Вы истлели\",\n skull: \"Вас сразил Пепельный владыка\",\n bullet: \"Вас подбил панцирник\",\n drown: \"Вы утонули\",\n beam: \"Вас сжёг луч глубинника\",\n thorns: \"Вы укололись о шипы глубинника\",\n wall: \"Вы врезались в стену на лету\",\n };\n this.deathEl.querySelector(\".deathCause\")!.textContent =\n (CAUSES[cause] ?? \"\") + (this.game.player.xpTotal ? \"\" : \"\");\n this.deathEl.classList.remove(\"hidden\");\n }\n hideDeath() {\n this.deathEl.classList.add(\"hidden\");\n }\n\n /** Титры после первой победы над драконом (кнопка «Продолжить» возвращает управление). */\n showCredits() {\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.creditsEl.classList.remove(\"hidden\");\n }\n\n message(text: string, ms = 1500) {\n this.msgEl.textContent = text;\n this.msgEl.classList.add(\"show\");\n clearTimeout(this._msgT);\n this._msgT = setTimeout(() => this.msgEl.classList.remove(\"show\"), ms);\n }\n\n /** Teardown: таймер сообщения не должен пережить игру. */\n dispose() {\n clearTimeout(this._msgT);\n }\n\n _flashHurt() {\n this.hurtEl.classList.remove(\"show\");\n void this.hurtEl.offsetWidth; // перезапуск CSS-анимации\n this.hurtEl.classList.add(\"show\");\n }\n\n frame() {\n const g = this.game;\n if (this._invDirty) {\n this._renderHotbar();\n this._invDirty = false;\n }\n if (\n g.player.health !== this._lastHealth ||\n this._modeWas !== g.player.mode\n ) {\n this._renderHearts();\n this._lastHealth = g.player.health;\n this._modeWas = g.player.mode;\n }\n if (\n g.player.food !== this._lastFood ||\n this._foodModeWas !== g.player.mode\n ) {\n this._renderHunger();\n this._lastFood = g.player.food;\n this._foodModeWas = g.player.mode;\n }\n\n const xpKey =\n g.player.xpLevel +\n \":\" +\n Math.round(g.player.xpProgress * 200) +\n \":\" +\n g.player.mode;\n if (xpKey !== this._lastXp) {\n this._lastXp = xpKey;\n const show = g.player.mode !== \"creative\";\n this.xpFillEl.parentElement!.style.display = show ? \"\" : \"none\";\n this.xpFillEl.style.width = (g.player.xpProgress * 100).toFixed(1) + \"%\";\n this.xpLevelEl.textContent =\n show && g.player.xpLevel > 0 ? String(g.player.xpLevel) : \"\";\n }\n\n const armor = g.inventory.armorStats().points;\n if (armor !== this._lastArmor) {\n this._lastArmor = armor;\n this.armorEl.style.display =\n armor > 0 && g.player.mode !== \"creative\" ? \"\" : \"none\";\n for (let i = 0; i < 10; i++) {\n const v = armor - i * 2;\n this._armorPips[i]!.className =\n \"armorPip\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n this.fireEl.classList.toggle(\"show\", g.player.fireT > 0 && !g.player.dead);\n // эффекты зелий и маяка: «Сила 2:31»; Тление чернит сердца\n this.heartsEl.classList.toggle(\"decay\", g.player.effects.has(\"decay\"));\n // пузырьки: сколько воздуха осталось под водой\n const pl = g.player;\n const showAir =\n pl.mode !== \"creative\" && !pl.dead && pl.air < MAX_AIR - 0.01;\n this.bubblesEl.style.display = showAir ? \"\" : \"none\";\n if (showAir) {\n const n = Math.ceil((pl.air / MAX_AIR) * 10);\n this._bubbles.forEach(\n (b, i) => (b.className = \"bubble\" + (i < n ? \"\" : \" empty\")),\n );\n }\n let fx = \"\";\n for (const [id, e] of g.player.effects) {\n const s = Math.max(0, Math.ceil(e.time));\n fx +=\n `${EFFECT_NAMES[id] ?? id}${e.level > 1 ? \" \" + e.level : \"\"} ` +\n `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}\\n`;\n }\n if (this.effectsEl.textContent !== fx) this.effectsEl.textContent = fx;\n // полоса босса: живой босс в загруженном мире\n let boss: { name: string; hp: number; max: number } | null = null;\n for (const e of g.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n boss = { name: e.def.name, hp: e.hp, max: e.def.hp };\n // нет босса, идёт набег — полоса набега: здоровье живых разбойников волны\n const raid = g.raid;\n if (!boss && raid) {\n let hp = 0;\n for (const id of raid.raiders) {\n const e = g.entities.entities.get(id);\n if (e?.kind === \"mob\" && !e.dead) hp += e.hp;\n }\n boss =\n raid.state === \"fighting\"\n ? {\n name: `Набег · волна ${raid.wave} из ${RAID_WAVES}`,\n hp,\n max: Math.max(1, raid.hpTotal),\n }\n : {\n name: `Набег · волна ${raid.wave + 1} из ${RAID_WAVES} — скоро`,\n hp: 0,\n max: 1,\n };\n }\n this.bossEl.classList.toggle(\"hidden\", !boss);\n if (boss) {\n this.bossEl.querySelector(\".bossName\")!.textContent = labelOf(boss.name);\n (this.bossEl.querySelector(\".bossFill\") as HTMLElement).style.width =\n ((boss.hp / boss.max) * 100).toFixed(1) + \"%\";\n }\n this.fpsEl.textContent = g.loop.fps + \" FPS\";\n // дебаг-строка\n const p = g.player.pos;\n this.debugEl.textContent =\n `xyz: ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)} | ` +\n `чанки: ${g.world.chunks.size} | жидкости: ${g.fluids.queueSize} | сид: ${g.world.seed}`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n // в Пекле и Пределе неба нет — и времени суток на экране тоже\n this.timeEl.textContent = DIMENSIONS[g.dimension].hasSky\n ? (g.dayNight.isDay ? \"☀\" : \"☾\") +\n \" \" +\n (ph < 0.5 ? \"день\" : \"ночь\") +\n (g.weather.kind === \"thunder\"\n ? \" · гроза\"\n : g.weather.kind === \"rain\"\n ? \" · дождь\"\n : \"\")\n : \"\";\n this._gadget();\n this._mapView();\n }\n\n /** Карта в руке: холст перерисовывается, только когда карта изменилась; метка — каждый кадр. */\n private _mapView() {\n const g = this.game;\n const h = g.inventory.heldItem();\n const m =\n h?.item === \"filled_map\" ? g.maps.get(Number(h.tag?.map)) : undefined;\n if (!m) {\n this.mapEl.classList.add(\"hidden\");\n return;\n }\n this.mapEl.classList.remove(\"hidden\");\n if (this._mapShown !== m || this._mapVersion !== m.version) {\n this._mapShown = m;\n this._mapVersion = m.version;\n const ctx = this.mapCanvas.getContext(\"2d\");\n if (ctx) {\n const img = ctx.createImageData(MAP_SIZE, MAP_SIZE);\n for (let i = 0; i < m.px.length; i++) {\n const c = mapRGB(m.px[i]!);\n const o = i * 4;\n img.data[o] = c < 0 ? 214 : (c >> 16) & 255;\n img.data[o + 1] = c < 0 ? 190 : (c >> 8) & 255;\n img.data[o + 2] = c < 0 ? 150 : c & 255;\n img.data[o + 3] = 255;\n }\n ctx.putImageData(img, 0, 0);\n }\n }\n const p = g.player.pos;\n const u = (p.x - m.x0) / MAP_SIZE,\n v = (p.z - m.z0) / MAP_SIZE;\n const inside =\n m.dim === g.dimension && u >= 0 && u <= 1 && v >= 0 && v <= 1;\n this.mapMarker.style.display = inside ? \"\" : \"none\";\n this.mapMarker.style.left = (u * 100).toFixed(2) + \"%\";\n this.mapMarker.style.top = (v * 100).toFixed(2) + \"%\";\n this.mapMarker.style.transform = `translate(-50%, -50%) rotate(${((-g.player.yaw * 180) / Math.PI).toFixed(1)}deg)`;\n // карта сокровищ: красный крестик над кладом\n const t = m.target;\n this.mapTreasure.style.display = t ? \"\" : \"none\";\n if (t) {\n this.mapTreasure.style.left =\n (((t[0] + 0.5 - m.x0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n this.mapTreasure.style.top =\n (((t[1] + 0.5 - m.z0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n }\n }\n\n private frostEl: HTMLElement | null = null;\n /** Иней по краям экрана — насколько игрок промёрз в снегу-порохе (0..1, systems/Freezing). */\n frost(k: number): void {\n if (!this.frostEl) {\n if (k <= 0) return;\n const el = document.createElement(\"div\");\n el.style.cssText =\n \"position:fixed;inset:0;pointer-events:none;opacity:0;\" +\n \"background:radial-gradient(ellipse at center,rgba(255,255,255,0) 40%,rgba(220,240,255,.9) 100%)\";\n this.heartsEl.parentElement!.appendChild(el);\n this.frostEl = el;\n }\n this.frostEl.style.opacity = k.toFixed(2);\n }\n\n /**\n * Компас и часы в руке: у хотбара — стрелка на точку появления мира (в Пекле и Пределе крутится)\n * или время суток (без неба — «??:??»), как у приборов Minecraft.\n */\n private _gadget() {\n const g = this.game;\n const held = g.inventory.heldItem()?.item;\n const el = this.gadgetEl;\n if (held !== \"compass\" && held !== \"clock\" && held !== \"recovery_compass\") {\n el.classList.add(\"hidden\");\n return;\n }\n el.classList.remove(\"hidden\");\n if (held === \"compass\" || held === \"recovery_compass\") {\n // компас — на путевой камень (если привязан здесь) или на точку появления,\n // компас возвращения — на место последней смерти в этом измерении\n const d = g.lastDeath;\n const lode = g.player.lodestone;\n const s =\n held === \"compass\"\n ? lode && lode.dim === g.dimension\n ? { x: lode.x, z: lode.z }\n : g.dimension === \"overworld\"\n ? g.world.generator.spawnHint()\n : null\n : d && d.dim === g.dimension\n ? { x: Math.floor(d.x), z: Math.floor(d.z) }\n : null;\n let deg: number;\n if (!s) deg = (performance.now() / 3) % 360;\n else {\n const p = g.player.pos;\n const target = Math.atan2(-(s.x + 0.5 - p.x), -(s.z + 0.5 - p.z));\n deg = (-(target - g.player.yaw) * 180) / Math.PI;\n }\n el.innerHTML = `<span class=\"needle\" style=\"transform:rotate(${deg.toFixed(1)}deg)\">▲</span>`;\n return;\n }\n if (!DIMENSIONS[g.dimension].hasSky) {\n el.textContent = \"🕒 ??:??\";\n return;\n }\n const h = (6 + g.dayNight.phase * 24) % 24;\n el.textContent = `🕒 ${String(Math.floor(h)).padStart(2, \"0\")}:${String(Math.floor((h % 1) * 60)).padStart(2, \"0\")}`;\n }\n\n _renderHotbar() {\n const inv = this.game.inventory;\n for (let i = 0; i < 9; i++) {\n const el = this._slots[i]!;\n const s = inv.slots[i];\n el.classList.toggle(\"selected\", i === inv.hotbarIndex);\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (s) {\n img.src = ITEMS[s.item]!.iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = labelOf(s.item) + enchantSuffix(s);\n el.classList.toggle(\"ench\", enchantsOf(s).length > 0);\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (s.count > 1 ? s.count : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n el.classList.remove(\"ench\");\n }\n drawDurability(el, s ?? null);\n }\n }\n\n _renderHearts() {\n const g = this.game;\n this.heartsEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const hp = g.player.health;\n for (let i = 0; i < 10; i++) {\n const v = hp - i * 2;\n this._hearts[i]!.className =\n \"heart\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n _renderHunger() {\n const g = this.game;\n this.hungerEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const food = g.player.food;\n for (let i = 0; i < 10; i++) {\n const v = food - i * 2;\n this._drumsticks[i]!.className =\n \"drumstick\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n}\n"
|
|
1636
|
+
"content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport { ITEMS } from \"../registry/Items\";\nimport { EFFECT_NAMES, labelOf } from \"../registry/names\";\nimport { MAX_AIR } from \"../player/Player\";\nimport { RAID_WAVES } from \"../systems/Raid\";\nimport { drawDurability } from \"./slotView\";\nimport { enchantsOf, enchantSuffix } from \"../systems/Enchant\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { MAP_SIZE, mapRGB, type MapData } from \"../systems/Maps\";\nimport type { Game } from \"../main\";\n\nexport class HUD {\n game: Game;\n $: (id: string) => HTMLElement;\n hotbarEl: HTMLElement;\n heartsEl: HTMLElement;\n fpsEl: HTMLElement;\n debugEl: HTMLElement;\n timeEl: HTMLElement;\n overlayEl: HTMLElement;\n hurtEl: HTMLElement;\n msgEl: HTMLElement;\n hungerEl: HTMLElement;\n _invDirty: boolean;\n _lastHealth: number;\n _lastFood?: number;\n _modeWas?: string;\n _foodModeWas?: string;\n _msgT?: ReturnType<typeof setTimeout>;\n _slots: HTMLDivElement[];\n _hearts: HTMLSpanElement[];\n _drumsticks: HTMLSpanElement[];\n /** Экран смерти и оранжевая кайма горения. */\n deathEl!: HTMLElement;\n fireEl!: HTMLElement;\n /** Полоса здоровья босса и экран титров. */\n bossEl!: HTMLElement;\n creditsEl!: HTMLElement;\n /** Список эффектов зелий с оставшимся временем. */\n effectsEl!: HTMLElement;\n /** Пузырьки воздуха над едой — только под водой. */\n bubblesEl!: HTMLElement;\n private readonly _bubbles: HTMLSpanElement[] = [];\n /** Компас или часы в руке — стрелка или время у хотбара. */\n gadgetEl!: HTMLElement;\n /** Карта в руке. */\n mapEl!: HTMLElement;\n mapCanvas!: HTMLCanvasElement;\n mapMarker!: HTMLElement;\n /** Крестик карты сокровищ. */\n mapTreasure!: HTMLElement;\n private _mapShown: MapData | null = null;\n private _mapVersion = -1;\n /** Полоса опыта над хотбаром и номер уровня. */\n xpFillEl: HTMLElement;\n xpLevelEl: HTMLElement;\n _lastXp = \"\";\n /** Шкала брони над сердцами (10 значков, по 2 очка защиты). */\n armorEl: HTMLElement;\n _armorPips: HTMLSpanElement[] = [];\n _lastArmor = -1;\n\n constructor(game: Game) {\n this.game = game;\n // Все элементы гарантированно есть в разметке — helper утверждает non-null.\n this.$ = (id: string): HTMLElement => document.getElementById(id)!;\n this.hotbarEl = this.$(\"hotbar\");\n this.heartsEl = this.$(\"hearts\");\n this.fpsEl = this.$(\"fps\");\n this.debugEl = this.$(\"debug\");\n this.timeEl = this.$(\"timeIndicator\");\n this.overlayEl = this.$(\"overlay\");\n this.hurtEl = this.$(\"hurtFlash\");\n this.msgEl = this.$(\"message\");\n\n this._invDirty = true;\n this._lastHealth = -1;\n events.on(\"invChanged\", () => {\n this._invDirty = true;\n });\n events.on(\"playerHurt\", () => this._flashHurt());\n // короткие подсказки из систем без доступа к HUD («Нужно седло»)\n events.on(\"ui:hint\", ({ text }: { text: string }) =>\n this.message(text, 1600),\n );\n // экран смерти и огонь на экране\n this.deathEl = document.createElement(\"div\");\n this.deathEl.id = \"deathScreen\";\n this.deathEl.className = \"hidden\";\n this.deathEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Вы погибли</h1><p class=\"deathCause\"></p>' +\n '<button class=\"deathBtn\">Возродиться</button></div>';\n this.deathEl.querySelector(\"button\")!.onclick = () => game.respawnPlayer();\n this.fireEl = document.createElement(\"div\");\n this.fireEl.id = \"fireOverlay\";\n // полоса босса и титры после победы над драконом\n this.bossEl = document.createElement(\"div\");\n this.bossEl.id = \"bossBar\";\n this.bossEl.className = \"hidden\";\n this.bossEl.innerHTML =\n '<div class=\"bossName\"></div><div class=\"bossTrack\"><div class=\"bossFill\"></div></div>';\n this.creditsEl = document.createElement(\"div\");\n this.creditsEl.id = \"creditsScreen\";\n this.creditsEl.className = \"hidden\";\n this.creditsEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Победа</h1><p class=\"credits\">Дракон Предела повержен.<br>' +\n \"Путь пройден: верхний мир — Пекло — Предел.<br>Мир остаётся твоим: строй, исследуй, \" +\n \"возвращайся.<br><br>Спасибо, что играешь в VoxelCraft.</p>\" +\n '<button class=\"deathBtn\">Продолжить</button></div>';\n this.creditsEl.querySelector(\"button\")!.onclick = () => {\n this.creditsEl.classList.add(\"hidden\");\n game.input.uiOpen = false;\n game.input.requestLock();\n };\n this.effectsEl = document.createElement(\"div\");\n this.effectsEl.id = \"effects\";\n // компас и часы в руке\n this.gadgetEl = document.createElement(\"div\");\n this.gadgetEl.id = \"gadget\";\n this.gadgetEl.className = \"hidden\";\n this.heartsEl.parentElement!.appendChild(this.gadgetEl);\n // карта в руке: холст 128×128 (растянут) и метка игрока\n this.mapEl = document.createElement(\"div\");\n this.mapEl.id = \"mapView\";\n this.mapEl.className = \"hidden\";\n this.mapCanvas = document.createElement(\"canvas\");\n this.mapCanvas.width = this.mapCanvas.height = MAP_SIZE;\n this.mapMarker = document.createElement(\"span\");\n this.mapMarker.className = \"mapMarker\";\n this.mapMarker.textContent = \"▲\";\n this.mapTreasure = document.createElement(\"span\");\n this.mapTreasure.className = \"mapTreasure\";\n this.mapTreasure.textContent = \"✖\";\n this.mapEl.append(this.mapCanvas, this.mapTreasure, this.mapMarker);\n this.heartsEl.parentElement!.appendChild(this.mapEl);\n this.heartsEl.parentElement!.append(\n this.deathEl,\n this.fireEl,\n this.bossEl,\n this.creditsEl,\n this.effectsEl,\n );\n events.on(\"modeChanged\", ({ mode }) =>\n this.message(\n mode === \"creative\"\n ? \"Режим: КРЕАТИВ (двойной Space — полёт)\"\n : \"Режим: выживание\",\n 1600,\n ),\n );\n\n // слоты хотбара\n this._slots = [];\n for (let i = 0; i < 9; i++) {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n // тап/клик по слоту — выбрать его (на телефоне колёсика и цифр нет)\n d.addEventListener(\"pointerdown\", () =>\n game.intents.push(Intent.SelectSlot, { slot: i }),\n );\n this.hotbarEl.appendChild(d);\n this._slots.push(d);\n }\n // сердечки\n this._hearts = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"heart\";\n s.textContent = \"❤\";\n this.heartsEl.appendChild(s);\n this._hearts.push(s);\n }\n // опыт\n const xpBar = document.createElement(\"div\");\n xpBar.id = \"xpBar\";\n this.xpFillEl = document.createElement(\"div\");\n this.xpFillEl.className = \"xpFill\";\n xpBar.appendChild(this.xpFillEl);\n this.xpLevelEl = document.createElement(\"div\");\n this.xpLevelEl.id = \"xpLevel\";\n this.heartsEl.parentElement!.append(xpBar, this.xpLevelEl);\n // броня\n this.armorEl = document.createElement(\"div\");\n this.armorEl.id = \"armorBar\";\n this.heartsEl.parentElement!.appendChild(this.armorEl);\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"armorPip\";\n s.textContent = \"⛨\";\n this.armorEl.appendChild(s);\n this._armorPips.push(s);\n }\n // окорочка (голод)\n this.hungerEl = this.$(\"hunger\");\n this._drumsticks = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"drumstick\";\n s.textContent = \"🍗\";\n this.hungerEl.appendChild(s);\n this._drumsticks.push(s);\n }\n this.bubblesEl = document.createElement(\"div\");\n this.bubblesEl.id = \"bubbles\";\n this.bubblesEl.style.display = \"none\";\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"bubble\";\n s.textContent = \"◯\";\n this.bubblesEl.appendChild(s);\n this._bubbles.push(s);\n }\n this.hungerEl.parentElement!.appendChild(this.bubblesEl);\n\n // кнопки паузы\n this.$(\"btnExport\").onclick = () => game.save.exportFile();\n this.$(\"btnImport\").onclick = () =>\n game.save.importFile((err: unknown) => {\n this.message(\n err ? \"Ошибка импорта: \" + (err as Error).message : \"Мир загружен\",\n 1800,\n );\n });\n this.$(\"btnNewSeed\").onclick = () => {\n const s = prompt(\n \"Сид нового мира (число):\",\n String((Math.random() * 1e9) | 0),\n );\n if (s !== null && s.trim() !== \"\") game.newWorld(Number(s) || 0);\n };\n this.overlayEl.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).closest(\"button\")) return; // кнопки не запускают игру\n game.startPlay();\n });\n }\n\n showOverlay(paused: boolean) {\n this.overlayEl.classList.remove(\"hidden\");\n this.$(\"overlayTitle\").textContent = paused ? \"Пауза\" : \"VoxelCraft\";\n this.$(\"overlayHint\").textContent = paused\n ? \"Клик — продолжить\"\n : \"Клик — играть\";\n }\n hideOverlay() {\n this.overlayEl.classList.add(\"hidden\");\n }\n\n /** Экран «Вы погибли» с причиной и кнопкой возрождения. */\n showDeath(cause: string) {\n const CAUSES: Record<string, string> = {\n attack: \"Вас убил монстр\",\n projectile: \"Вас застрелили\",\n explosion: \"Вас взорвали\",\n lava: \"Вы искупались в лаве\",\n fire: \"Вы сгорели\",\n fall: \"Вы упали с высоты\",\n starve: \"Вы умерли от голода\",\n void: \"Вы выпали из мира\",\n cactus: \"Вас искололо кактусом\",\n magic: \"Вас сгубило колдовство\",\n lightning: \"Вас поразила молния\",\n decay: \"Вы истлели\",\n skull: \"Вас сразил Пепельный владыка\",\n bullet: \"Вас подбил панцирник\",\n drown: \"Вы утонули\",\n beam: \"Вас сжёг луч глубинника\",\n thorns: \"Вы укололись о шипы глубинника\",\n wall: \"Вы врезались в стену на лету\",\n // причины, для которых экран смерти молчал (замёрзнуть в снегу-порохе — самая частая)\n freeze: \"Вы замёрзли\",\n stalagmite: \"Вас пронзил сталагмит\",\n falling_stalactite: \"На вас упал сталактит\",\n teleport: \"Неудачный бросок жемчуга\",\n sonic_boom: \"Вас оглушил вопль глубинника\",\n wind: \"Вас сдуло ветром\",\n poison: \"Вас отравили\",\n };\n this.deathEl.querySelector(\".deathCause\")!.textContent =\n CAUSES[cause] ?? \"\"; // заголовок панели уже «Вы погибли» — без повтора\n this.deathEl.classList.remove(\"hidden\");\n }\n hideDeath() {\n this.deathEl.classList.add(\"hidden\");\n }\n\n /** Титры после первой победы над драконом (кнопка «Продолжить» возвращает управление). */\n showCredits() {\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.creditsEl.classList.remove(\"hidden\");\n }\n\n message(text: string, ms = 1500) {\n this.msgEl.textContent = text;\n this.msgEl.classList.add(\"show\");\n clearTimeout(this._msgT);\n this._msgT = setTimeout(() => this.msgEl.classList.remove(\"show\"), ms);\n }\n\n /** Teardown: таймер сообщения не должен пережить игру. */\n dispose() {\n clearTimeout(this._msgT);\n }\n\n _flashHurt() {\n this.hurtEl.classList.remove(\"show\");\n void this.hurtEl.offsetWidth; // перезапуск CSS-анимации\n this.hurtEl.classList.add(\"show\");\n }\n\n frame() {\n const g = this.game;\n if (this._invDirty) {\n this._renderHotbar();\n this._invDirty = false;\n }\n if (\n g.player.health !== this._lastHealth ||\n this._modeWas !== g.player.mode\n ) {\n this._renderHearts();\n this._lastHealth = g.player.health;\n this._modeWas = g.player.mode;\n }\n if (\n g.player.food !== this._lastFood ||\n this._foodModeWas !== g.player.mode\n ) {\n this._renderHunger();\n this._lastFood = g.player.food;\n this._foodModeWas = g.player.mode;\n }\n\n const xpKey =\n g.player.xpLevel +\n \":\" +\n Math.round(g.player.xpProgress * 200) +\n \":\" +\n g.player.mode;\n if (xpKey !== this._lastXp) {\n this._lastXp = xpKey;\n const show = g.player.mode !== \"creative\";\n this.xpFillEl.parentElement!.style.display = show ? \"\" : \"none\";\n this.xpFillEl.style.width = (g.player.xpProgress * 100).toFixed(1) + \"%\";\n this.xpLevelEl.textContent =\n show && g.player.xpLevel > 0 ? String(g.player.xpLevel) : \"\";\n }\n\n const armor = g.inventory.armorStats().points;\n if (armor !== this._lastArmor) {\n this._lastArmor = armor;\n this.armorEl.style.display =\n armor > 0 && g.player.mode !== \"creative\" ? \"\" : \"none\";\n for (let i = 0; i < 10; i++) {\n const v = armor - i * 2;\n this._armorPips[i]!.className =\n \"armorPip\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n this.fireEl.classList.toggle(\"show\", g.player.fireT > 0 && !g.player.dead);\n // эффекты зелий и маяка: «Сила 2:31»; Тление чернит сердца\n this.heartsEl.classList.toggle(\"decay\", g.player.effects.has(\"decay\"));\n // пузырьки: сколько воздуха осталось под водой\n const pl = g.player;\n const showAir =\n pl.mode !== \"creative\" && !pl.dead && pl.air < MAX_AIR - 0.01;\n this.bubblesEl.style.display = showAir ? \"\" : \"none\";\n if (showAir) {\n const n = Math.ceil((pl.air / MAX_AIR) * 10);\n this._bubbles.forEach(\n (b, i) => (b.className = \"bubble\" + (i < n ? \"\" : \" empty\")),\n );\n }\n let fx = \"\";\n for (const [id, e] of g.player.effects) {\n const s = Math.max(0, Math.ceil(e.time));\n fx +=\n `${EFFECT_NAMES[id] ?? id}${e.level > 1 ? \" \" + e.level : \"\"} ` +\n `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}\\n`;\n }\n if (this.effectsEl.textContent !== fx) this.effectsEl.textContent = fx;\n // полоса босса: живой босс в загруженном мире\n let boss: { name: string; hp: number; max: number } | null = null;\n for (const e of g.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n boss = { name: e.def.name, hp: e.hp, max: e.def.hp };\n // нет босса, идёт набег — полоса набега: здоровье живых разбойников волны\n const raid = g.raid;\n if (!boss && raid) {\n let hp = 0;\n for (const id of raid.raiders) {\n const e = g.entities.entities.get(id);\n if (e?.kind === \"mob\" && !e.dead) hp += e.hp;\n }\n boss =\n raid.state === \"fighting\"\n ? {\n name: `Набег · волна ${raid.wave} из ${RAID_WAVES}`,\n hp,\n max: Math.max(1, raid.hpTotal),\n }\n : {\n name: `Набег · волна ${raid.wave + 1} из ${RAID_WAVES} — скоро`,\n hp: 0,\n max: 1,\n };\n }\n this.bossEl.classList.toggle(\"hidden\", !boss);\n if (boss) {\n this.bossEl.querySelector(\".bossName\")!.textContent = labelOf(boss.name);\n (this.bossEl.querySelector(\".bossFill\") as HTMLElement).style.width =\n ((boss.hp / boss.max) * 100).toFixed(1) + \"%\";\n }\n this.fpsEl.textContent = g.loop.fps + \" FPS\";\n // дебаг-строка\n const p = g.player.pos;\n this.debugEl.textContent =\n `xyz: ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)} | ` +\n `чанки: ${g.world.chunks.size} | жидкости: ${g.fluids.queueSize} | сид: ${g.world.seed}`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n // в Пекле и Пределе неба нет — и времени суток на экране тоже\n this.timeEl.textContent = DIMENSIONS[g.dimension].hasSky\n ? (g.dayNight.isDay ? \"☀\" : \"☾\") +\n \" \" +\n (ph < 0.5 ? \"день\" : \"ночь\") +\n (g.weather.kind === \"thunder\"\n ? \" · гроза\"\n : g.weather.kind === \"rain\"\n ? \" · дождь\"\n : \"\")\n : \"\";\n this._gadget();\n this._mapView();\n }\n\n /** Карта в руке: холст перерисовывается, только когда карта изменилась; метка — каждый кадр. */\n private _mapView() {\n const g = this.game;\n const h = g.inventory.heldItem();\n const m =\n h?.item === \"filled_map\" ? g.maps.get(Number(h.tag?.map)) : undefined;\n if (!m) {\n this.mapEl.classList.add(\"hidden\");\n return;\n }\n this.mapEl.classList.remove(\"hidden\");\n if (this._mapShown !== m || this._mapVersion !== m.version) {\n this._mapShown = m;\n this._mapVersion = m.version;\n const ctx = this.mapCanvas.getContext(\"2d\");\n if (ctx) {\n const img = ctx.createImageData(MAP_SIZE, MAP_SIZE);\n for (let i = 0; i < m.px.length; i++) {\n const c = mapRGB(m.px[i]!);\n const o = i * 4;\n img.data[o] = c < 0 ? 214 : (c >> 16) & 255;\n img.data[o + 1] = c < 0 ? 190 : (c >> 8) & 255;\n img.data[o + 2] = c < 0 ? 150 : c & 255;\n img.data[o + 3] = 255;\n }\n ctx.putImageData(img, 0, 0);\n }\n }\n const p = g.player.pos;\n const u = (p.x - m.x0) / MAP_SIZE,\n v = (p.z - m.z0) / MAP_SIZE;\n const inside =\n m.dim === g.dimension && u >= 0 && u <= 1 && v >= 0 && v <= 1;\n this.mapMarker.style.display = inside ? \"\" : \"none\";\n this.mapMarker.style.left = (u * 100).toFixed(2) + \"%\";\n this.mapMarker.style.top = (v * 100).toFixed(2) + \"%\";\n this.mapMarker.style.transform = `translate(-50%, -50%) rotate(${((-g.player.yaw * 180) / Math.PI).toFixed(1)}deg)`;\n // карта сокровищ: красный крестик над кладом\n const t = m.target;\n this.mapTreasure.style.display = t ? \"\" : \"none\";\n if (t) {\n this.mapTreasure.style.left =\n (((t[0] + 0.5 - m.x0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n this.mapTreasure.style.top =\n (((t[1] + 0.5 - m.z0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n }\n }\n\n private frostEl: HTMLElement | null = null;\n /** Иней по краям экрана — насколько игрок промёрз в снегу-порохе (0..1, systems/Freezing). */\n frost(k: number): void {\n if (!this.frostEl) {\n if (k <= 0) return;\n const el = document.createElement(\"div\");\n el.style.cssText =\n \"position:fixed;inset:0;pointer-events:none;opacity:0;\" +\n \"background:radial-gradient(ellipse at center,rgba(255,255,255,0) 40%,rgba(220,240,255,.9) 100%)\";\n this.heartsEl.parentElement!.appendChild(el);\n this.frostEl = el;\n }\n this.frostEl.style.opacity = k.toFixed(2);\n }\n\n /**\n * Компас и часы в руке: у хотбара — стрелка на точку появления мира (в Пекле и Пределе крутится)\n * или время суток (без неба — «??:??»), как у приборов Minecraft.\n */\n private _gadget() {\n const g = this.game;\n const held = g.inventory.heldItem()?.item;\n const el = this.gadgetEl;\n if (held !== \"compass\" && held !== \"clock\" && held !== \"recovery_compass\") {\n el.classList.add(\"hidden\");\n return;\n }\n el.classList.remove(\"hidden\");\n if (held === \"compass\" || held === \"recovery_compass\") {\n // компас — на путевой камень (если привязан здесь) или на точку появления,\n // компас возвращения — на место последней смерти в этом измерении\n const d = g.lastDeath;\n const lode = g.player.lodestone;\n const s =\n held === \"compass\"\n ? lode && lode.dim === g.dimension\n ? { x: lode.x, z: lode.z }\n : g.dimension === \"overworld\"\n ? g.world.generator.spawnHint()\n : null\n : d && d.dim === g.dimension\n ? { x: Math.floor(d.x), z: Math.floor(d.z) }\n : null;\n let deg: number;\n if (!s) deg = (performance.now() / 3) % 360;\n else {\n const p = g.player.pos;\n const target = Math.atan2(-(s.x + 0.5 - p.x), -(s.z + 0.5 - p.z));\n deg = (-(target - g.player.yaw) * 180) / Math.PI;\n }\n el.innerHTML = `<span class=\"needle\" style=\"transform:rotate(${deg.toFixed(1)}deg)\">▲</span>`;\n return;\n }\n if (!DIMENSIONS[g.dimension].hasSky) {\n el.textContent = \"🕒 ??:??\";\n return;\n }\n const h = (6 + g.dayNight.phase * 24) % 24;\n el.textContent = `🕒 ${String(Math.floor(h)).padStart(2, \"0\")}:${String(Math.floor((h % 1) * 60)).padStart(2, \"0\")}`;\n }\n\n _renderHotbar() {\n const inv = this.game.inventory;\n for (let i = 0; i < 9; i++) {\n const el = this._slots[i]!;\n const s = inv.slots[i];\n el.classList.toggle(\"selected\", i === inv.hotbarIndex);\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (s) {\n img.src = ITEMS[s.item]?.iconURL ?? \"\"; // iconURL заполняется buildIcons() до первого рендера; неизвестный предмет (чужой сейв) — пустая иконка, а не падение\n img.style.display = \"\";\n img.title = labelOf(s.item) + enchantSuffix(s);\n el.classList.toggle(\"ench\", enchantsOf(s).length > 0);\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (s.count > 1 ? s.count : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n el.classList.remove(\"ench\");\n }\n drawDurability(el, s ?? null);\n }\n }\n\n _renderHearts() {\n const g = this.game;\n this.heartsEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const hp = g.player.health;\n for (let i = 0; i < 10; i++) {\n const v = hp - i * 2;\n this._hearts[i]!.className =\n \"heart\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n _renderHunger() {\n const g = this.game;\n this.hungerEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const food = g.player.food;\n for (let i = 0; i < 10; i++) {\n const v = food - i * 2;\n this._drumsticks[i]!.className =\n \"drumstick\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n}\n"
|
|
1588
1637
|
},
|
|
1589
1638
|
{
|
|
1590
1639
|
"path": "ui/InventoryUI.ts",
|
|
1591
|
-
"content": "// Экраны инвентаря, как в Minecraft: инвентарь (броня + крафт 2×2), верстак (3×3), сундук, печь.\n// У всех внизу рюкзак и хотбар игрока. Мышь: ЛКМ — взять/положить стак, ПКМ — половину/по одному,\n// Shift+ЛКМ — перенести в «другую половину» экрана (броню — на тело, руду — в печь).\n// UI мутирует ТОЛЬКО модели (Inventory, CraftingGrid, состояние блочной сущности) и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS } from \"../registry/Items\";\nimport { labelOf } from \"../registry/names\";\nimport { smeltResult } from \"../registry/recipes/smelting\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport { ARMOR_SLOTS } from \"../systems/Inventory\";\nimport { COOK_TIME, type FurnaceState } from \"../world/blockentities/furnace\";\nimport { BREW_TIME, type BrewingState } from \"../world/blockentities/brewing\";\nimport { smithResult } from \"../systems/Smithing\";\nimport { TRIM_MATERIALS } from \"../registry/items/trims\";\nimport { bundleInsert, bundleTakeLast } from \"../systems/Bundle\";\nimport { stonecutOptions } from \"../systems/Stonecutter\";\nimport { TOO_EXPENSIVE, anvilResult } from \"../systems/Anvil\";\nimport { grindResult } from \"../systems/Grindstone\";\nimport {\n BANNER_PATTERNS,\n bannerColor,\n dyeColor,\n layersOf,\n loomResult,\n} from \"../systems/Banner\";\nimport { bannerCanvas } from \"../gfx/BannerArt\";\nimport {\n canEnchant,\n countShelves,\n enchantLine,\n enchantOffers,\n enchantsOf,\n enchantSuffix,\n withEnchants,\n} from \"../systems/Enchant\";\nimport { B, BLOCKS } from \"../registry/Blocks\";\nimport type { ChestState } from \"../world/blockentities/chest\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport { drawDurability } from \"./slotView\";\nimport { renderTradeOffers } from \"./tradeView\";\nimport { TIER_NAMES, levelOf } from \"../systems/Trading\";\nimport type { Mob } from \"../entities/Mob\";\nimport type { Minecart } from \"../entities/Minecart\";\nimport { buildCreativePalette } from \"./creativePalette\";\nimport { renderBeaconPowers } from \"./beaconView\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\n\nexport type ScreenKind =\n | \"inventory\"\n | \"crafting\"\n | \"chest\"\n | \"furnace\"\n | \"brewing\"\n | \"smithing\"\n | \"enchanting\"\n | \"anvil\"\n | \"trade\"\n | \"beacon\"\n | \"creative\"\n | \"stonecutter\"\n | \"grindstone\"\n | \"loom\"\n | \"ender\"\n | \"armor_stand\";\n\ntype Group =\n | \"hotbar\"\n | \"main\"\n | \"armor\"\n | \"craft\"\n | \"chest\"\n | \"fIn\"\n | \"fFuel\"\n | \"fOut\"\n | \"bIn\"\n | \"bFuel\"\n | \"bottle\"\n | \"smTpl\"\n | \"smBase\"\n | \"smAdd\"\n | \"enItem\"\n | \"enLapis\"\n | \"anLeft\"\n | \"anRight\"\n | \"scIn\"\n | \"grLeft\"\n | \"grRight\"\n | \"loBanner\"\n | \"loDye\"\n | \"ender\"\n | \"stand\"\n | \"trash\";\n\ninterface SlotRef {\n group: Group;\n get(): ItemStack | null;\n set(s: ItemStack | null): void;\n /** Можно ли положить (броня — только в свой слот, топливо — только топливо). */\n accepts?(s: ItemStack): boolean;\n /** Только забирать (выход печи). */\n takeOnly?: boolean;\n el?: HTMLDivElement;\n}\n\nconst TITLES: Record<ScreenKind, string> = {\n inventory: \"Инвентарь\",\n crafting: \"Верстак\",\n chest: \"Сундук\",\n furnace: \"Печь\",\n creative: \"Креатив\",\n brewing: \"Варочная стойка\",\n smithing: \"Кузнечный стол\",\n enchanting: \"Стол зачарований\",\n anvil: \"Наковальня\",\n trade: \"Торговля\",\n beacon: \"Маяк\",\n stonecutter: \"Камнерез\",\n grindstone: \"Точило\",\n loom: \"Ткацкий станок\",\n ender: \"Сундук Предела\",\n armor_stand: \"Стойка для брони\",\n};\n\nconst maxOf = (item: string): number => ITEMS[item]?.maxStack ?? 64;\n/**\n * Складываются ли два стака: один предмет, без износа и с одинаковыми доп. данными. Сравнение tag\n * обязательно: иначе знамя с узорами, слитое со стопкой чистых, потеряло бы узоры (systems/Banner).\n */\nconst sameTag = (\n a?: Record<string, unknown>,\n b?: Record<string, unknown>,\n): boolean => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);\nconst stackable = (a: ItemStack, b: ItemStack): boolean =>\n a.item === b.item && !a.dmg && !b.dmg && sameTag(a.tag, b.tag);\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n kind: ScreenKind = \"inventory\";\n /** Кузнечный стол: основа (алмазная вещь) и добавка (пеклитовый слиток). */\n /** Кузнечный стол: вещь, добавка (пеклит или материал отделки), шаблон отделки. */\n smith: (ItemStack | null)[] = [null, null, null];\n /** Стол зачарований: предмет и лазурит; где стоит стол (полки вокруг); список вариантов. */\n enchSlots: (ItemStack | null)[] = [null, null];\n enchPos: { x: number; y: number; z: number } | null = null;\n private offersEl: HTMLElement | null = null;\n /** Наковальня: левый и правый слот; строка цены. */\n anvil: (ItemStack | null)[] = [null, null];\n /** Вход камнереза (экран \"stonecutter\"). */\n stoneIn: (ItemStack | null)[] = [null];\n /** Точило: два слота (экран \"grindstone\"). */\n grind: (ItemStack | null)[] = [null, null];\n /** Ткацкий станок: знамя и краситель (экран \"loom\"). */\n loom: (ItemStack | null)[] = [null, null];\n private costEl: HTMLElement | null = null;\n /** Житель, с которым торгуем (экран \"trade\"). */\n tradeMob: Mob | null = null;\n /** Вагонетка, чей груз открыт (экран \"chest\" поверх её слотов). */\n cart: Minecart | Mob | null = null;\n /** Блочная сущность открытого сундука/печи. */\n be: BlockEntity | null = null;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n private panelEl: HTMLElement;\n private refs: SlotRef[] = [];\n private resultEl: HTMLDivElement | null = null;\n private flameEl: HTMLDivElement | null = null;\n private arrowEl: HTMLDivElement | null = null;\n\n constructor(game: Game, signal: AbortSignal) {\n this.game = game;\n this.inventory = game.inventory;\n this.grid = new CraftingGrid(2);\n this.cursor = null; // стак «в руке» у курсора\n this.open = false;\n\n // Элементы гарантированно есть в разметке — non-null утверждается локально.\n this.rootEl = document.getElementById(\"invScreen\")!;\n this.cursorEl = document.getElementById(\"cursorStack\")!;\n this.panelEl = this.rootEl.querySelector(\".invPanel\")!;\n\n events.on(\"ui:toggleInventory\", () => {\n if (this.open) this.close();\n // в креативе E открывает палитру всех предметов, как в Minecraft\n else\n this.show(game.player.mode === \"creative\" ? \"creative\" : \"inventory\");\n });\n events.on(\"ui:openCrafting\", () => this.show(\"crafting\"));\n events.on(\"ui:openSmithing\", () => this.show(\"smithing\"));\n events.on(\"ui:openAnvil\", () => this.show(\"anvil\"));\n events.on(\"ui:openStonecutter\", () => this.show(\"stonecutter\"));\n events.on(\"ui:openGrindstone\", () => this.show(\"grindstone\"));\n events.on(\"ui:openLoom\", () => this.show(\"loom\"));\n events.on(\"ui:openEnder\", () => this.show(\"ender\"));\n events.on(\n \"ui:openArmorStand\",\n ({ x, y, z }: { x: number; y: number; z: number }) => {\n const be = game.blockEntities.ensure(x, y, z);\n if (be?.type === \"armor_stand\") this.show(\"armor_stand\", be);\n },\n );\n events.on(\"ui:openEnchanting\", (p: { x: number; y: number; z: number }) => {\n this.enchPos = p;\n this.show(\"enchanting\");\n });\n events.on(\"ui:openTrade\", ({ entityId }: { entityId: number }) => {\n const m = game.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || !m.def.trades) return;\n this.tradeMob = m;\n this.show(\"trade\");\n if (this.open) m.trading = true; // пока торгуем — стоит и смотрит на игрока (MobBrain)\n });\n events.on(\"ui:openCart\", ({ entityId }: { entityId: number }) => {\n const c = game.entities.entities.get(entityId);\n // груз вагонетки или вьюк осла, мула, ламы\n if (\n (c?.kind !== \"minecart\" && c?.kind !== \"mob\") ||\n !c.items.length ||\n this.open\n )\n return;\n this.cart = c;\n this.show(\"chest\", game.entities.cartBox(c));\n if (!this.open) this.cart = null;\n });\n events.on(\n \"ui:openContainer\",\n ({ x, y, z }: { x: number; y: number; z: number }) => {\n // ensure: сундук постройки генератора получает сущность (и добычу) при первом открытии\n const be = game.blockEntities.ensure(x, y, z);\n if (\n be &&\n (be.type === \"chest\" ||\n be.type === \"furnace\" ||\n be.type === \"brewing\")\n )\n this.show(be.type, be);\n // воронка, раздатчик, панцирный сундук — тот же экран, что сундук (слоты items), со своим заголовком\n else if (\n be &&\n (be.type === \"hopper\" ||\n be.type === \"dispenser\" ||\n be.type === \"shell\")\n )\n this.show(\"chest\", be);\n else if (be?.type === \"beacon\") this.show(\"beacon\", be);\n },\n );\n\n document.addEventListener(\n \"mousemove\",\n (e) => {\n if (!this.open) return;\n this.cursorEl.style.left = e.clientX + \"px\";\n this.cursorEl.style.top = e.clientY + \"px\";\n },\n { signal },\n );\n }\n\n /** Открыть экран. Уже открыт — ничего (закрывается E или Esc). */\n show(kind: ScreenKind, be: BlockEntity | null = null) {\n if (this.open) return;\n // На стартовом оверлее и паузе игра стоит: инвентарь открылся бы ПОД оверлеем, а его закрытие\n // (requestLock → takeControl) запускало бы игру в обход оверлея.\n if (!this.game.playing) return;\n this.open = true;\n this.kind = kind;\n this.be = be;\n this.grid.setSize(kind === \"crafting\" ? 3 : 2);\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.rootEl.classList.remove(\"hidden\");\n this._build();\n this._render();\n }\n\n /** Совместимость со старым вызовом: toggle(2) — инвентарь, toggle(3, true) — верстак. */\n toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n this.show(craftSize === 3 ? \"crafting\" : \"inventory\");\n }\n\n close() {\n if (!this.open) return;\n this.open = false;\n // вернуть всё из крафт-сетки и курсора; некуда класть — выбрасываем под ноги, ничего не теряем\n this.grid.dumpInto(this.inventory);\n // Остаток сетки выбрасываем обязательно: следующий show() пересоздаёт её (setSize), и то,\n // что «осталось в сетке», пропадало бы бесследно.\n this.grid.slots.forEach((s, i) => {\n if (!s) return;\n this._dropAtFeet(s.item, s.count);\n this.grid.slots[i] = null;\n });\n // кузнечный стол: всё обратно в инвентарь (с износом), не влезло — под ноги\n for (const slots of [\n this.smith,\n this.enchSlots,\n this.anvil,\n this.stoneIn,\n this.grind,\n ])\n for (let i = 0; i < slots.length; i++) {\n const s = slots[i];\n if (s && this.inventory.addStack(s) > 0)\n this._dropAtFeet(s.item, s.count);\n slots[i] = null;\n }\n if (this.cursor) {\n // addStack — инструмент в курсоре не теряет износ (раньше add() его обнулял)\n const left = this.inventory.addStack(this.cursor);\n if (left > 0) this._dropAtFeet(this.cursor.item, left);\n this.cursor = null;\n }\n this.be = null;\n if (this.tradeMob) this.tradeMob.trading = false;\n this.tradeMob = null;\n this.cart = null;\n this.rootEl.classList.add(\"hidden\");\n this.cursorEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Кадр: у печи бегут шкалы; сломанный сундук/печь закрывают свой экран. */\n frame() {\n if (this.open && this.kind === \"trade\") {\n // житель умер или ушёл — торговля закрывается\n const m = this.tradeMob;\n if (!m || m.dead || m.pos.distanceTo(this.game.player.pos) > 8)\n this.close();\n return;\n }\n if (this.open && this.cart) {\n // вагонетку сломали или она уехала — экран закрывается\n const c = this.cart;\n if (c.dead || c.pos.distanceTo(this.game.player.pos) > 8) this.close();\n return;\n }\n if (!this.open || !this.be) return;\n const be = this.be;\n if (this.game.blockEntities.get(be.x, be.y, be.z) !== be)\n return this.close();\n if (this.kind === \"furnace\" || this.kind === \"brewing\") this._render();\n }\n\n _dropAtFeet(item: string, count: number) {\n const p = this.game.player.pos;\n this.game.entities.spawnDrop(p.x, p.y + 1, p.z, item, count);\n }\n\n // ---- построение ------------------------------------------------------------------------\n\n /** Имя блока под открытой блочной сущностью (для заголовка). */\n private _beBlockName(): string {\n const be = this.be;\n if (!be) return \"\";\n return BLOCKS[this.game.world.getBlock(be.x, be.y, be.z)]?.name ?? be.type;\n }\n\n private _build() {\n const inv = this.inventory;\n this.refs = [];\n this.resultEl = this.flameEl = this.arrowEl = null;\n this.panelEl.innerHTML = \"\";\n const h = document.createElement(\"h3\");\n h.id = \"invTitle\";\n h.textContent = this.cart\n ? labelOf(\n this.cart.kind === \"mob\" ? this.cart.def.name : this.cart.dropItem,\n )\n : this.kind === \"trade\" && this.tradeMob\n ? `${labelOf(this.tradeMob.def.name)} — ${TIER_NAMES[levelOf(this.tradeMob.villagerXp) - 1]}`\n : // воронка, раздатчик, бочка, коптильня, плавильная печь — своим именем (горящие — как погасшие)\n (this.kind === \"chest\" || this.kind === \"furnace\") &&\n this.be &&\n ![\"chest\", \"furnace\", \"lit_furnace\"].includes(this._beBlockName())\n ? labelOf(this._beBlockName().replace(/^lit_/, \"\"))\n : TITLES[this.kind];\n this.panelEl.appendChild(h);\n\n if (this.kind === \"creative\")\n buildCreativePalette(this.panelEl, (item, right, shift) =>\n this._clickPalette(item, right, shift),\n );\n\n const top = this._div(this.panelEl, \"invTop\");\n if (this.kind === \"creative\") {\n // корзина: положенный сюда стак исчезает (ПКМ — по одному)\n this._div(top, \"trashLabel\").textContent = \"Удалить:\";\n this._slot(top, {\n group: \"trash\",\n get: () => null,\n set: () => {},\n }).classList.add(\"trash\");\n }\n if (this.kind === \"inventory\") {\n const armor = this._div(top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n this._slot(armor, {\n group: \"armor\",\n get: () => inv.armor[i] ?? null,\n set: (s) => inv.setArmor(i, s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n }\n if (this.kind === \"inventory\" || this.kind === \"crafting\") {\n const n = this.grid.size;\n const craft = this._div(top, \"craftGrid\");\n craft.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n for (let i = 0; i < n * n; i++)\n this._slot(craft, {\n group: \"craft\",\n get: () => this.grid.slots[i] ?? null,\n set: (s) => (this.grid.slots[i] = s),\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickResult(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n }\n if (this.kind === \"armor_stand\" && this.be) {\n // стойка: те же четыре слота брони, что у игрока, но у клетки (gfx/ArmorStand показывает их)\n const items = (this.be.state as ChestState).items;\n const col = this._div(top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n this._slot(col, {\n group: \"stand\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n }\n if (this.kind === \"ender\") {\n // сундук Предела: слоты не у клетки, а у игрока — один склад на все сундуки и измерения\n const grid = this._div(top, \"chestGrid\");\n for (let i = 0; i < inv.ender.length; i++)\n this._slot(grid, {\n group: \"ender\",\n get: () => inv.ender[i] ?? null,\n set: (s) => (inv.ender[i] = s),\n });\n }\n if (this.kind === \"chest\" && this.be) {\n const items = (this.be.state as ChestState).items;\n const grid = this._div(top, \"chestGrid\");\n if (items.length === 9)\n grid.style.gridTemplateColumns = \"repeat(3, 44px)\"; // раздатчик 3×3\n for (let i = 0; i < items.length; i++)\n this._slot(grid, {\n group: \"chest\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n // панцирный сундук в панцирный не кладётся, как в Minecraft\n accepts: (s) =>\n !(this.be?.type === \"shell\" && s.item === \"shell_chest\"),\n });\n }\n if (this.kind === \"furnace\" && this.be) {\n const st = this.be.state as FurnaceState;\n const col = this._div(top, \"furnaceCol\");\n this._slot(col, {\n group: \"fIn\",\n get: () => st.input,\n set: (s) => (st.input = s),\n });\n this.flameEl = this._div(\n this._div(col, \"flame\"),\n \"flameFill\",\n ) as HTMLDivElement;\n this._slot(col, {\n group: \"fFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => (ITEMS[s.item]?.fuel ?? 0) > 0,\n });\n this.arrowEl = this._div(\n this._div(top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n this._slot(top, {\n group: \"fOut\",\n get: () => st.output,\n set: (s) => (st.output = s),\n takeOnly: true,\n }).classList.add(\"result\");\n }\n\n this.offersEl = null;\n this.costEl = null;\n if (this.kind === \"anvil\") {\n this._slot(top, {\n group: \"anLeft\",\n get: () => this.anvil[0] ?? null,\n set: (s) => (this.anvil[0] = s),\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"anRight\",\n get: () => this.anvil[1] ?? null,\n set: (s) => (this.anvil[1] = s),\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickAnvil(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n this.costEl = this._div(top, \"anvilCost\");\n }\n if (this.kind === \"grindstone\") {\n // точило: два слота и результат без зачарований (опыт возвращается игроку)\n this._slot(top, {\n group: \"grLeft\",\n get: () => this.grind[0] ?? null,\n set: (s) => (this.grind[0] = s),\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"grRight\",\n get: () => this.grind[1] ?? null,\n set: (s) => (this.grind[1] = s),\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickGrind(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n this.costEl = this._div(top, \"anvilCost\");\n }\n if (this.kind === \"enchanting\") {\n const col = this._div(top, \"enchCol\");\n this._slot(col, {\n group: \"enItem\",\n get: () => this.enchSlots[0] ?? null,\n set: (s) => (this.enchSlots[0] = s),\n accepts: (s) => canEnchant(s.item),\n });\n this._slot(col, {\n group: \"enLapis\",\n get: () => this.enchSlots[1] ?? null,\n set: (s) => (this.enchSlots[1] = s),\n accepts: (s) => s.item === \"lapis_lazuli\",\n });\n this.offersEl = this._div(top, \"enchOffers\");\n }\n if (this.kind === \"loom\") {\n // ткацкий станок: знамя, краситель и список узоров — щелчок кладёт слой\n this._slot(top, {\n group: \"loBanner\",\n get: () => this.loom[0] ?? null,\n set: (s) => (this.loom[0] = s),\n accepts: (s) => bannerColor(s.item) !== null,\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"loDye\",\n get: () => this.loom[1] ?? null,\n set: (s) => (this.loom[1] = s),\n accepts: (s) => dyeColor(s.item) !== null,\n });\n this._div(top, \"arrow\").textContent = \"→\";\n this.offersEl = this._div(top, \"enchOffers tradeOffers loomOffers\");\n }\n if (this.kind === \"stonecutter\") {\n // камнерез: вход и список изделий — щелчок режет один блок\n this._slot(top, {\n group: \"scIn\",\n get: () => this.stoneIn[0] ?? null,\n set: (s) => (this.stoneIn[0] = s),\n accepts: (s) => stonecutOptions(s.item).length > 0,\n });\n this._div(top, \"arrow\").textContent = \"→\";\n this.offersEl = this._div(top, \"enchOffers tradeOffers\");\n }\n if (this.kind === \"trade\")\n this.offersEl = this._div(top, \"enchOffers tradeOffers\");\n if (this.kind === \"beacon\")\n this.offersEl = this._div(top, \"enchOffers beaconPowers\");\n if (this.kind === \"smithing\") {\n // шаблон отделки (необязателен: без него — улучшение до пеклита)\n this._slot(top, {\n group: \"smTpl\",\n get: () => this.smith[2] ?? null,\n set: (s) => (this.smith[2] = s),\n accepts: (s) => s.item.endsWith(\"_smithing_template\"),\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"smBase\",\n get: () => this.smith[0] ?? null,\n set: (s) => (this.smith[0] = s),\n accepts: (s) =>\n s.item.startsWith(\"diamond_\") || !!ITEMS[s.item]?.armorSlot,\n });\n this._div(top, \"arrow\").textContent = \"+\";\n this._slot(top, {\n group: \"smAdd\",\n get: () => this.smith[1] ?? null,\n set: (s) => (this.smith[1] = s),\n accepts: (s) =>\n s.item === \"infernite_ingot\" || s.item in TRIM_MATERIALS,\n });\n this._div(top, \"arrow\").textContent = \"→\";\n const res = this._div(top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n this._clickSmith(e.shiftKey);\n e.preventDefault();\n };\n this.resultEl = res;\n }\n if (this.kind === \"brewing\" && this.be) {\n const st = this.be.state as BrewingState;\n const col = this._div(top, \"brewCol\");\n this._slot(col, {\n group: \"bIn\",\n get: () => st.ingredient,\n set: (s) => (st.ingredient = s),\n });\n this._slot(col, {\n group: \"bFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => s.item === \"flame_powder\",\n });\n this.arrowEl = this._div(\n this._div(top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n const row = this._div(top, \"bottleRow\");\n for (let i = 0; i < 3; i++)\n this._slot(row, {\n group: \"bottle\",\n get: () => st.bottles[i] ?? null,\n set: (s) => (st.bottles[i] = s),\n accepts: (s) =>\n s.item.startsWith(\"potion_\") || s.item === \"glass_bottle\",\n });\n }\n\n const main = this._div(this.panelEl, \"invGrid\");\n for (let i = 9; i < 36; i++)\n this._slot(main, {\n group: \"main\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n const hot = this._div(this.panelEl, \"invHotbar\");\n for (let i = 0; i < 9; i++)\n this._slot(hot, {\n group: \"hotbar\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n }\n\n private _div(parent: HTMLElement, cls: string): HTMLElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n }\n\n private _slot(parent: HTMLElement, ref: SlotRef): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n d.onmousedown = (e) => {\n this._clickSlot(ref, e.button === 2, e.shiftKey);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n parent.appendChild(d);\n ref.el = d;\n this.refs.push(ref);\n return d;\n }\n\n // ---- клики -------------------------------------------------------------------------------\n\n private _clickSlot(ref: SlotRef, rightClick: boolean, shift: boolean) {\n if (shift) {\n this._quickMove(ref);\n this._render();\n return;\n }\n const cur = this.cursor;\n const slot = ref.get();\n\n if (ref.takeOnly) {\n // выход печи: только забрать — целиком в курсор, если влезает\n if (!slot) return;\n if (!cur) {\n this.cursor = slot;\n ref.set(null);\n } else if (\n stackable(cur, slot) &&\n cur.count + slot.count <= maxOf(cur.item)\n ) {\n cur.count += slot.count;\n ref.set(null);\n } else return;\n this._tookOutput(ref);\n this._render();\n return;\n }\n\n // мешок: ПКМ мешком по предмету или предметом по мешку — положить; ПКМ пустой рукой по мешку — достать\n if (\n rightClick &&\n cur?.item === \"bundle\" &&\n slot &&\n slot.item !== \"bundle\"\n ) {\n const moved = bundleInsert(cur, slot);\n if (moved > 0) {\n slot.count -= moved;\n ref.set(slot.count > 0 ? slot : null);\n events.emit(\"bundleInsert\", { item: slot.item });\n this._render();\n }\n return;\n }\n if (rightClick && slot?.item === \"bundle\" && cur?.item !== \"bundle\") {\n if (!cur) {\n const out = bundleTakeLast(slot);\n if (out) {\n this.cursor = out;\n ref.set(slot);\n events.emit(\"bundleRemove\", { item: out.item });\n this._render();\n return;\n }\n } else {\n const moved = bundleInsert(slot, cur);\n if (moved > 0) {\n cur.count -= moved;\n if (cur.count <= 0) this.cursor = null;\n ref.set(slot);\n events.emit(\"bundleInsert\", { item: cur.item });\n }\n this._render();\n return;\n }\n }\n\n if (!cur) {\n if (!slot) return;\n if (rightClick) {\n // взять половину\n const take = Math.ceil(slot.count / 2);\n this.cursor = { ...slot, count: take };\n slot.count -= take;\n ref.set(slot.count > 0 ? slot : null);\n } else {\n this.cursor = slot;\n ref.set(null);\n }\n } else {\n if (ref.accepts && !ref.accepts(cur)) return;\n const max = maxOf(cur.item);\n if (!slot) {\n if (rightClick) {\n ref.set({ ...cur, count: 1 });\n cur.count -= 1;\n if (cur.count <= 0) this.cursor = null;\n } else {\n ref.set(cur);\n this.cursor = null;\n }\n } else if (stackable(slot, cur)) {\n const move = rightClick ? 1 : cur.count;\n const take = Math.min(move, max - slot.count);\n slot.count += take;\n cur.count -= take;\n ref.set(slot);\n if (cur.count <= 0) this.cursor = null;\n } else {\n // обмен\n ref.set(cur);\n this.cursor = slot;\n }\n }\n this._render();\n }\n\n /**\n * Креатив: клик по палитре — полный стак в курсор (ПКМ — один); с чем-то в курсоре — удалить\n * это, как в Minecraft; Shift — стак сразу в инвентарь.\n */\n private _clickPalette(item: string, rightClick: boolean, shift: boolean) {\n if (shift) this.inventory.add(item, maxOf(item));\n else if (this.cursor) this.cursor = null;\n else this.cursor = { item, count: rightClick ? 1 : maxOf(item) };\n this._render();\n }\n\n /** Книжные полки вокруг открытого стола зачарований. */\n private _shelves(): number {\n const p = this.enchPos;\n if (!p) return 0;\n const w = this.game.world;\n return countShelves(\n (x, y, z) => w.getBlock(x, y, z),\n B.bookshelf!,\n p.x,\n p.y,\n p.z,\n );\n }\n\n /** Варианты стола: текст, доступность (уровень, лазурит), клик — зачаровать. */\n /** Камнерез: блок из входа → изделие в курсор (не лезет — в инвентарь), с Shift — сразу в инвентарь. */\n private _stonecut(out: ItemStack, shift: boolean) {\n const input = this.stoneIn[0];\n if (!input) return;\n input.count -= 1;\n if (input.count <= 0) this.stoneIn[0] = null;\n const made: ItemStack = { item: out.item, count: out.count };\n const cur = this.cursor;\n if (!shift && !cur) this.cursor = made;\n else if (\n !shift &&\n cur &&\n cur.item === made.item &&\n !cur.dmg &&\n cur.count + made.count <= maxOf(made.item)\n )\n cur.count += made.count;\n else {\n const left = this.inventory.addStack(made);\n if (left > 0) this._dropAtFeet(made.item, left);\n }\n events.emit(\"stonecut\", { item: made.item });\n this._render();\n }\n\n private _renderOffers() {\n const el = this.offersEl;\n if (!el) return;\n if (this.kind === \"beacon\") {\n if (this.be)\n renderBeaconPowers(\n el,\n this.inventory,\n this.be,\n this.game.player.mode === \"creative\",\n () => this._render(),\n );\n return;\n }\n if (this.kind === \"trade\") {\n if (this.tradeMob)\n renderTradeOffers(\n el,\n this.inventory,\n this.tradeMob,\n (s) => this._dropAtFeet(s.item, s.count),\n () => this._render(),\n this.game.player.effects.get(\"hero\")?.level ?? 0,\n this.game.player.villageRep,\n );\n return;\n }\n if (this.kind === \"loom\") {\n el.innerHTML = \"\";\n const banner = this.loom[0] ?? null;\n const dye = this.loom[1] ?? null;\n const base = banner ? bannerColor(banner.item) : null;\n for (const p of BANNER_PATTERNS) {\n const out = loomResult(banner, dye, p.id);\n if (!out) continue;\n const b = document.createElement(\"div\");\n b.className = \"enchOffer\";\n const pic = bannerCanvas(base, layersOf(out), 2);\n pic.className = \"loomPic\";\n b.appendChild(pic);\n b.appendChild(document.createTextNode(p.name));\n b.onmousedown = (e) => {\n e.preventDefault();\n this._loom(p.id, e.shiftKey);\n };\n el.appendChild(b);\n }\n return;\n }\n if (this.kind === \"stonecutter\") {\n el.innerHTML = \"\";\n const input = this.stoneIn[0];\n for (const out of input ? stonecutOptions(input.item) : []) {\n const b = document.createElement(\"div\");\n b.className = \"enchOffer\";\n b.textContent = `${labelOf(input!.item)} → ${out.count > 1 ? out.count + \"× \" : \"\"}${labelOf(out.item)}`;\n b.onmousedown = (e) => {\n e.preventDefault();\n this._stonecut(out, e.shiftKey);\n };\n el.appendChild(b);\n }\n return;\n }\n const item = this.enchSlots[0];\n const offers =\n item && enchantsOf(item).length === 0\n ? enchantOffers(item.item, this._shelves(), this.game.player.enchSeed)\n : null;\n el.innerHTML = \"\";\n const pl = this.game.player;\n const lapis = this.enchSlots[1]?.count ?? 0;\n (offers ?? []).forEach((o, i) => {\n const ok =\n pl.mode === \"creative\" || (pl.xpLevel >= o.level && lapis >= o.cost);\n const b = document.createElement(\"div\");\n b.className = \"enchOffer\" + (ok ? \"\" : \" off\");\n b.textContent =\n o.ench.map(([id, l]) => enchantLine(id, l)).join(\", \") +\n ` · ${o.level} ур. · ${o.cost} лаз.`;\n b.onmousedown = (e) => {\n e.preventDefault();\n if (ok) this._enchant(i);\n };\n el.appendChild(b);\n });\n }\n\n /** Зачаровать вариантом i: списать уровни и лазурит (в креативе — нет), сменить зерно. */\n private _enchant(i: number) {\n const item = this.enchSlots[0];\n if (!item) return;\n const o = enchantOffers(\n item.item,\n this._shelves(),\n this.game.player.enchSeed,\n )?.[i];\n if (!o) return;\n const pl = this.game.player;\n if (pl.mode !== \"creative\") {\n const lapis = this.enchSlots[1];\n if (!lapis || lapis.count < o.cost || pl.xpLevel < o.level) return;\n lapis.count -= o.cost;\n if (lapis.count <= 0) this.enchSlots[1] = null;\n pl.xpLevel = Math.max(0, pl.xpLevel - o.cost);\n }\n // книга становится зачарованной книгой (одна; остальные книги — обратно в инвентарь)\n let base = item;\n if (item.item === \"book\") {\n if (item.count > 1 && this.inventory.add(\"book\", item.count - 1) > 0)\n this._dropAtFeet(\"book\", item.count - 1);\n base = { item: \"enchanted_book\", count: 1 };\n }\n this.enchSlots[0] = withEnchants(base, Object.fromEntries(o.ench));\n pl.enchSeed = (Math.random() * 0xffffffff) >>> 0;\n events.emit(\"enchanted\", { item: item.item });\n this._render();\n }\n\n /** Результат наковальни: списать уровни (в креативе — нет), левый слот и нужное из правого. */\n private _clickAnvil(shift: boolean) {\n const r = anvilResult(this.anvil[0] ?? null, this.anvil[1] ?? null);\n if (!r || (!shift && this.cursor)) return;\n const pl = this.game.player;\n if (pl.mode !== \"creative\") {\n if (r.cost >= TOO_EXPENSIVE || pl.xpLevel < r.cost) return;\n pl.xpLevel -= r.cost;\n }\n this.anvil[0] = null;\n const right = this.anvil[1]!;\n right.count -= r.useRight;\n if (right.count <= 0) this.anvil[1] = null;\n if (shift) {\n if (this.inventory.addStack(r.result) > 0)\n this._dropAtFeet(r.result.item, 1);\n } else this.cursor = r.result;\n events.emit(\"anvilUse\", { item: r.result.item });\n this._render();\n }\n\n /** Результат точила: вещь без зачарований, снятый опыт — игроку (как в Minecraft). */\n private _clickGrind(shift: boolean) {\n const r = grindResult(this.grind[0] ?? null, this.grind[1] ?? null);\n if (!r || (!shift && this.cursor)) return;\n this.grind[0] = null;\n if (r.useRight) this.grind[1] = null;\n if (shift) {\n if (this.inventory.addStack(r.result) > 0)\n this._dropAtFeet(r.result.item, 1);\n } else this.cursor = r.result;\n if (r.xp > 0) events.emit(\"xpGain\", { amount: r.xp });\n events.emit(\"grindstoneUse\", { item: r.result.item });\n this._render();\n }\n\n /** Узор на знамя: тратит один краситель, знамя из слота заменяется на новое со слоем. */\n private _loom(pattern: string, shift: boolean) {\n const out = loomResult(this.loom[0] ?? null, this.loom[1] ?? null, pattern);\n if (!out || (!shift && this.cursor)) return;\n const dye = this.loom[1]!;\n dye.count -= 1;\n if (dye.count <= 0) this.loom[1] = null;\n const banner = this.loom[0]!;\n banner.count -= 1;\n if (banner.count <= 0) this.loom[0] = null;\n if (shift) {\n if (this.inventory.addStack(out) > 0) this._dropAtFeet(out.item, 1);\n } else this.cursor = out;\n events.emit(\"loomUse\", { item: out.item });\n this._render();\n }\n\n /** Результат кузнечного стола: в курсор (рука свободна) или Shift — сразу в инвентарь. */\n private _clickSmith(shift: boolean) {\n const tpl = this.smith[2] ?? null;\n const res = smithResult(this.smith[0] ?? null, this.smith[1] ?? null, tpl);\n if (!res || (!shift && this.cursor)) return;\n this.smith[0] = null;\n const add = this.smith[1]!;\n add.count -= 1;\n if (add.count <= 0) this.smith[1] = null;\n if (tpl) {\n tpl.count -= 1; // шаблон тратится, как в Minecraft\n if (tpl.count <= 0) this.smith[2] = null;\n events.emit(\"armorTrimmed\", { item: res.item });\n }\n if (shift) {\n if (this.inventory.addStack(res) > 0) this._dropAtFeet(res.item, 1);\n } else this.cursor = res;\n events.emit(\"craft\", { item: res.item, count: 1 });\n this._render();\n }\n\n /** Забрали выход печи: накопленный опыт — игроку. */\n private _tookOutput(ref: SlotRef) {\n if (ref.group !== \"fOut\" || !this.be) return;\n const st = this.be.state as FurnaceState;\n if (st.xp > 0) events.emit(\"xpGain\", { amount: st.xp });\n st.xp = 0;\n }\n\n /** Куда Shift-клик переносит стак из этой группы, по порядку предпочтения. */\n private _targets(from: Group, s: ItemStack): SlotRef[] {\n const of = (...g: Group[]) => this.refs.filter((r) => g.includes(r.group));\n const player =\n from === \"hotbar\"\n ? of(\"main\")\n : from === \"main\"\n ? of(\"hotbar\")\n : of(\"hotbar\", \"main\");\n if (from === \"hotbar\" || from === \"main\") {\n const def = ITEMS[s.item];\n if (def?.armorSlot) {\n const armor = of(\"armor\").filter((r) => !r.get() && r.accepts?.(s));\n if (armor.length) return armor;\n }\n if (this.kind === \"chest\") return [...of(\"chest\"), ...player];\n if (this.kind === \"ender\") return [...of(\"ender\"), ...player];\n if (this.kind === \"armor_stand\")\n return ITEMS[s.item]?.armorSlot ? [...of(\"stand\"), ...player] : player;\n if (this.kind === \"stonecutter\" && stonecutOptions(s.item).length)\n return [...of(\"scIn\"), ...player];\n if (this.kind === \"anvil\")\n return [...of(\"anLeft\"), ...of(\"anRight\"), ...player];\n if (this.kind === \"grindstone\")\n return [...of(\"grLeft\"), ...of(\"grRight\"), ...player];\n if (this.kind === \"loom\") {\n if (bannerColor(s.item)) return [...of(\"loBanner\"), ...player];\n if (dyeColor(s.item)) return [...of(\"loDye\"), ...player];\n return player;\n }\n if (this.kind === \"enchanting\") {\n if (s.item === \"lapis_lazuli\") return [...of(\"enLapis\"), ...player];\n if (canEnchant(s.item)) return [...of(\"enItem\"), ...player];\n return player;\n }\n if (this.kind === \"smithing\") {\n if (s.item.endsWith(\"_smithing_template\"))\n return [...of(\"smTpl\"), ...player];\n if (s.item.startsWith(\"diamond_\") || ITEMS[s.item]?.armorSlot)\n return [...of(\"smBase\"), ...player];\n if (s.item === \"infernite_ingot\" || s.item in TRIM_MATERIALS)\n return [...of(\"smAdd\"), ...player];\n return player;\n }\n if (this.kind === \"brewing\") {\n if (s.item.startsWith(\"potion_\")) return [...of(\"bottle\"), ...player];\n if (s.item === \"flame_powder\")\n return [...of(\"bFuel\"), ...of(\"bIn\"), ...player];\n return [...of(\"bIn\"), ...player];\n }\n if (this.kind === \"furnace\") {\n if (smeltResult(s.item)) return [...of(\"fIn\"), ...player];\n if ((def?.fuel ?? 0) > 0) return [...of(\"fFuel\"), ...player];\n }\n return player;\n }\n return of(\"hotbar\", \"main\");\n }\n\n private _quickMove(ref: SlotRef) {\n const s = ref.get();\n if (!s) return;\n let left = s.count;\n const targets = this._targets(ref.group, s).filter(\n (t) => t !== ref && !t.takeOnly && (!t.accepts || t.accepts(s)),\n );\n // сначала досыпать в такие же стаки, потом — в пустые\n for (const pass of [0, 1])\n for (const t of targets) {\n if (left <= 0) break;\n const ts = t.get();\n if (pass === 0 && ts && stackable(ts, s)) {\n const take = Math.min(left, maxOf(s.item) - ts.count);\n if (take > 0) {\n ts.count += take;\n left -= take;\n t.set(ts);\n }\n } else if (pass === 1 && !ts) {\n const take = Math.min(left, maxOf(s.item));\n t.set({ ...s, count: take });\n left -= take;\n }\n }\n ref.set(left > 0 ? { ...s, count: left } : null);\n if (left < s.count) this._tookOutput(ref);\n }\n\n /** Результат крафта: клик — в курсор; Shift — скрафтить сколько получится прямо в инвентарь. */\n private _clickResult(shift: boolean) {\n const res = this.grid.result();\n if (!res) return;\n if (shift) {\n for (let n = 0; n < 64; n++) {\n const cur = this.grid.result();\n if (!cur || cur.item !== res.item) break;\n const taken = this.grid.takeResult()!;\n const left = this.inventory.add(taken.item, taken.count);\n if (left > 0) {\n this._dropAtFeet(taken.item, left);\n break;\n }\n }\n this._render();\n return;\n }\n if (\n this.cursor &&\n (this.cursor.item !== res.item ||\n this.cursor.count + res.count > maxOf(res.item))\n )\n return;\n const taken = this.grid.takeResult()!; // result() уже проверен выше — сетка не менялась, результат есть\n if (this.cursor) this.cursor.count += taken.count;\n else this.cursor = { item: taken.item, count: taken.count };\n this._render();\n }\n\n // ---- отрисовка ----------------------------------------------------------------------------\n\n _render() {\n const draw = (el: HTMLElement, stack: ItemStack | null) => {\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (stack) {\n img.src = ITEMS[stack.item]!.iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = labelOf(stack.item) + enchantSuffix(stack);\n count.textContent = stack.count > 1 ? String(stack.count) : \"\";\n el.classList.toggle(\"ench\", enchantsOf(stack).length > 0);\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n el.classList.remove(\"ench\");\n }\n drawDurability(el, stack);\n };\n for (const r of this.refs) if (r.el) draw(r.el, r.get());\n if (this.resultEl) {\n const anvil =\n this.kind === \"anvil\"\n ? anvilResult(this.anvil[0] ?? null, this.anvil[1] ?? null)\n : null;\n const res =\n this.kind === \"smithing\"\n ? smithResult(\n this.smith[0] ?? null,\n this.smith[1] ?? null,\n this.smith[2] ?? null,\n )\n : this.kind === \"anvil\"\n ? (anvil?.result ?? null)\n : this.kind === \"grindstone\"\n ? (grindResult(this.grind[0] ?? null, this.grind[1] ?? null)\n ?.result ?? null)\n : this.grid.result();\n draw(this.resultEl, res ? { ...res } : null);\n if (this.costEl) {\n const pl = this.game.player;\n this.costEl.textContent = !anvil\n ? \"\"\n : anvil.cost >= TOO_EXPENSIVE && pl.mode !== \"creative\"\n ? \"Слишком дорого!\"\n : `Цена: ${anvil.cost} ур.`;\n }\n }\n if (this.be && this.kind === \"brewing\" && this.arrowEl) {\n const st = this.be.state as BrewingState;\n this.arrowEl.style.width = ((st.brew / BREW_TIME) * 100).toFixed(1) + \"%\";\n }\n if (this.be && this.kind === \"furnace\") {\n const st = this.be.state as FurnaceState;\n if (this.flameEl)\n this.flameEl.style.height =\n (st.burnMax > 0 ? (st.burn / st.burnMax) * 100 : 0).toFixed(1) + \"%\";\n if (this.arrowEl)\n this.arrowEl.style.width =\n ((st.cook / COOK_TIME) * 100).toFixed(1) + \"%\";\n }\n\n // курсорный стак\n if (this.cursor) {\n this.cursorEl.classList.remove(\"hidden\");\n this.cursorEl.querySelector(\"img\")!.src =\n ITEMS[this.cursor.item]!.iconURL!;\n this.cursorEl.querySelector(\".count\")!.textContent =\n this.cursor.count > 1 ? String(this.cursor.count) : \"\";\n } else {\n this.cursorEl.classList.add(\"hidden\");\n }\n this._renderOffers();\n events.emit(\"invChanged\"); // HUD-хотбар обновится\n }\n}\n"
|
|
1640
|
+
"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"
|
|
1592
1641
|
},
|
|
1593
1642
|
{
|
|
1594
1643
|
"path": "ui/NameEditor.ts",
|
|
1595
|
-
"content": "// Ввод имени для бирки: ПКМ биркой по мобу открывает строку (как редактор таблички), Enter даёт\n// имя, Esc отменяет. Имя вешает сам моб (Mob.setName), бирка тратится только при удаче.\nimport { events } from \"../core/EventBus\";\nimport type { Game } from \"../main\";\n\nexport class NameEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private entityId: number | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"nameEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 24;\n this.input.placeholder = \"Имя\";\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:nameMob\", (p: { entityId: number }) => this.show(p.entityId));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(entityId: number): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const m = g.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || m.dead) return;\n this.entityId = entityId;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = m.customName ?? \"\";\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.entityId = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Дать мобу имя и потратить бирку. */\n submit(): void {\n const id = this.entityId;\n const name = this.input.value.trim();\n this.hide();\n if (id === null || !name) return;\n const m = this.game.entities.entities.get(id);\n if (m?.kind !== \"mob\" || m.dead) return;\n m.setName(name);\n if (this.game.player.mode !== \"creative\")\n this.game.inventory.consumeHeld(1);\n events.emit(\"mobNamed\", { entityId: id, name });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1644
|
+
"content": "// Ввод имени для бирки: ПКМ биркой по мобу открывает строку (как редактор таблички), Enter даёт\n// имя, Esc отменяет. Имя вешает сам моб (Mob.setName), бирка тратится только при удаче.\nimport { events } from \"../core/EventBus\";\nimport type { Game } from \"../main\";\n\nexport class NameEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private entityId: number | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"nameEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 24;\n this.input.placeholder = \"Имя\";\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:nameMob\", (p: { entityId: number }) => this.show(p.entityId));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(entityId: number): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const m = g.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || m.dead) return;\n this.entityId = entityId;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = m.customName ?? \"\";\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.entityId = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Дать мобу имя и потратить бирку. */\n submit(): void {\n const id = this.entityId;\n const name = this.input.value.trim();\n this.hide();\n if (id === null || !name) return;\n const m = this.game.entities.entities.get(id);\n if (m?.kind !== \"mob\" || m.dead) return;\n m.setName(name);\n if (this.game.player.mode !== \"creative\")\n this.game.inventory.consumeHeld(1);\n events.emit(\"mobNamed\", { entityId: id, name });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1645
|
+
},
|
|
1646
|
+
{
|
|
1647
|
+
"path": "ui/NetUI.ts",
|
|
1648
|
+
"content": "// Экран «Сетевая игра»: открыть свой мир для других (как «Открыть для сети» в Minecraft), войти в\n// чужой (список открытых игр или код приглашения), игроки с правами оператора и исключением. Плюс\n// список игроков по Tab во время игры. Чистый DOM, как остальной HUD.\nimport { events } from \"../core/EventBus\";\nimport { NetGame } from \"../net/NetGame\";\nimport type { Game } from \"../main\";\nimport type { RoomListItem } from \"@idosgames/core\";\n\ntype Tab = \"host\" | \"join\" | \"players\";\n\n/** Тексты движка → понятные игроку (текст сервера дословно не показываем). */\nfunction friendly(err: string): string {\n const e = err || \"\";\n if (/disabled/i.test(e))\n return \"Сетевая игра выключена в настройках этой игры (LiveOps → Мультиплеер)\";\n if (/full/i.test(e)) return \"В этой игре нет мест\";\n if (/join code/i.test(e)) return \"Неверный код приглашения\";\n if (/removed from this room/i.test(e)) return \"Вас исключили из этой игры\";\n if (/not found|no longer exists/i.test(e))\n return \"Игра не найдена или уже закрыта\";\n if (/already in room/i.test(e)) return \"Вы уже в другой сетевой игре\";\n if (/closed/i.test(e)) return \"Игра закрыта\";\n if (/Версии|обновите/i.test(e)) return e;\n if (/хост/i.test(e)) return e;\n return \"Не получилось. Попробуйте ещё раз\";\n}\n\nfunction code6(): string {\n const abc = \"ABCDEFGHJKLMNPQRSTUVWXYZ23456789\";\n let s = \"\";\n const r = crypto.getRandomValues(new Uint8Array(6));\n for (const b of r) s += abc[b % abc.length];\n return s;\n}\n\nconst DIM_NAMES: Record<string, string> = {\n overworld: \"Верхний мир\",\n inferno: \"Пекло\",\n beyond: \"Предел\",\n};\n\nexport class NetUI {\n open = false;\n private readonly root: HTMLDivElement;\n private readonly tabList: HTMLDivElement;\n private busy = false;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"netScreen\";\n this.root.className = \"hidden\";\n this.root.innerHTML = `\n <div class=\"wsPanel\">\n <div class=\"wsHead\">\n <button class=\"wsTab\" data-tab=\"host\">Открыть мой мир</button>\n <button class=\"wsTab\" data-tab=\"join\">Присоединиться</button>\n <button class=\"wsTab\" data-tab=\"players\">Игроки</button>\n <button class=\"wsClose\" data-act=\"close\" title=\"Закрыть (Esc)\">×</button>\n </div>\n <div class=\"wsBody\" data-body=\"host\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Название</div>\n <input class=\"netInput\" data-f=\"room\" maxlength=\"40\" />\n <div class=\"wsLabel\">Кто может войти</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"netVis\" value=\"Private\" checked /> По коду приглашения</label>\n <label><input type=\"radio\" name=\"netVis\" value=\"Public\" /> Все (в списке открытых игр)</label>\n </div>\n <div class=\"wsLabel\">Режим гостей</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"netMode\" value=\"survival\" checked /> Выживание</label>\n <label><input type=\"radio\" name=\"netMode\" value=\"creative\" /> Творчество</label>\n </div>\n <div class=\"wsRow\">\n <label><input type=\"checkbox\" data-f=\"cheats\" /> Команды всем <span class=\"wsNote\">(иначе — только вам и операторам)</span></label>\n </div>\n <div class=\"wsButtons\"><button class=\"wsPrimary\" data-act=\"host\">Открыть для сети</button></div>\n <div class=\"wsStatus\" data-f=\"hostStatus\"></div>\n </div>\n </div>\n <div class=\"wsBody hidden\" data-body=\"join\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Код приглашения</div>\n <div class=\"wsRow\">\n <input class=\"netInput\" data-f=\"invite\" placeholder=\"Например: 66f1c0…:K7P2QX\" />\n <button class=\"wsPrimary\" data-act=\"joinCode\">Войти</button>\n </div>\n <div class=\"wsLabel\">Открытые игры <button class=\"wsLink\" data-act=\"refresh\">обновить</button></div>\n <div class=\"wsList\" data-f=\"rooms\"></div>\n <div class=\"wsStatus\" data-f=\"joinStatus\"></div>\n <div class=\"wsNote\">Ваш мир сохранится в «Моих мирах» и вернётся, когда вы выйдете из чужой игры.</div>\n </div>\n </div>\n <div class=\"wsBody hidden\" data-body=\"players\">\n <div class=\"wsForm\">\n <div class=\"wsList\" data-f=\"players\"></div>\n <div class=\"wsButtons\"><button data-act=\"leave\">Выйти из сетевой игры</button></div>\n <div class=\"wsStatus\" data-f=\"playersStatus\"></div>\n </div>\n </div>\n </div>`;\n this.tabList = document.createElement(\"div\");\n this.tabList.id = \"netTabList\";\n this.tabList.className = \"hidden\";\n const host = document.getElementById(\"game\")!.parentElement!;\n host.append(this.root, this.tabList);\n\n // кнопка в меню паузы — только если у игры есть SDK-клиент\n const buttons = document.querySelector(\"#overlay .buttons\");\n if (buttons && game.netClient) {\n const b = document.createElement(\"button\");\n b.id = \"btnNet\";\n b.textContent = \"Сетевая игра\";\n b.addEventListener(\n \"click\",\n () => this.show(game.net ? \"players\" : \"host\"),\n { signal },\n );\n buttons.prepend(b);\n }\n\n this.root.addEventListener(\n \"click\",\n (e) => {\n const t = e.target as HTMLElement;\n const tab = t.closest<HTMLElement>(\".wsTab\")?.dataset.tab as\n Tab | undefined;\n if (tab) return this.selectTab(tab);\n const act = t.closest<HTMLElement>(\"[data-act]\")?.dataset.act;\n if (act) void this.action(act, t.closest<HTMLElement>(\"[data-act]\")!);\n },\n { signal },\n );\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (this.open && e.code === \"Escape\") this.hide();\n if (e.code === \"Tab\" && game.net && game.playing && !this.open) {\n e.preventDefault();\n this.renderTabList();\n this.tabList.classList.remove(\"hidden\");\n }\n },\n { signal },\n );\n document.addEventListener(\n \"keyup\",\n (e) => {\n if (e.code === \"Tab\") this.tabList.classList.add(\"hidden\");\n },\n { signal },\n );\n events.on(\"net:roster\", () => {\n if (this.open) this.renderPlayers();\n if (!this.tabList.classList.contains(\"hidden\")) this.renderTabList();\n });\n }\n\n private $(f: string): HTMLElement {\n return this.root.querySelector(`[data-f=\"${f}\"]`)!;\n }\n\n private radio(name: string): string {\n return (\n this.root.querySelector<HTMLInputElement>(`input[name=\"${name}\"]:checked`)\n ?.value ?? \"\"\n );\n }\n\n show(tab: Tab): void {\n if (!this.game.netClient) return;\n this.open = true;\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.root.classList.remove(\"hidden\");\n (this.$(\"room\") as HTMLInputElement).value ||= this.game.worldName;\n // вне сетевой игры строки «Мир открыт. Код…» и «Игрок исключён» — от прошлой игры\n if (!this.game.net)\n for (const f of [\"hostStatus\", \"joinStatus\", \"playersStatus\"])\n this.status(f, \"\");\n this.selectTab(tab);\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.game.input.uiOpen = false;\n this.root.classList.add(\"hidden\");\n }\n\n private selectTab(tab: Tab): void {\n // в чужой игре открыть свой мир нельзя, а вне игры список игроков пуст\n if (tab === \"host\" && this.game.net) tab = \"players\";\n for (const t of this.root.querySelectorAll<HTMLElement>(\".wsTab\")) {\n t.classList.toggle(\"active\", t.dataset.tab === tab);\n t.classList.toggle(\n \"hidden\",\n (t.dataset.tab === \"players\") !== !!this.game.net &&\n t.dataset.tab !== \"join\",\n );\n }\n for (const b of this.root.querySelectorAll<HTMLElement>(\"[data-body]\"))\n b.classList.toggle(\"hidden\", b.dataset.body !== tab);\n if (tab === \"join\") void this.refreshRooms();\n if (tab === \"players\") this.renderPlayers();\n }\n\n private status(f: string, text: string, error = false): void {\n const el = this.$(f);\n el.textContent = text;\n el.classList.toggle(\"error\", error);\n }\n\n private async action(act: string, el: HTMLElement): Promise<void> {\n const g = this.game;\n if (act === \"close\") return this.hide();\n if (act === \"refresh\") return void this.refreshRooms();\n if (this.busy) return;\n this.busy = true;\n try {\n if (act === \"host\") await this.openWorld();\n else if (act === \"joinCode\") {\n const raw = (this.$(\"invite\") as HTMLInputElement).value.trim();\n const [roomId, code] = raw.split(\":\");\n if (!roomId)\n this.status(\"joinStatus\", \"Вставьте код приглашения\", true);\n else await this.join(roomId, code);\n } else if (act === \"join\")\n await this.join(el.dataset.room ?? \"\", undefined);\n else if (act === \"leave\") {\n await g.net?.leave();\n this.hide();\n } else if (act === \"op\" || act === \"deop\")\n g.net?.setOp(el.dataset.user ?? \"\", act === \"op\");\n else if (act === \"kick\" || act === \"ban\") {\n const err = await g.net?.kick(el.dataset.user ?? \"\", act === \"ban\");\n this.status(\n \"playersStatus\",\n err\n ? friendly(err)\n : act === \"ban\"\n ? \"Игрок исключён навсегда\"\n : \"Игрок исключён\",\n !!err,\n );\n }\n } finally {\n this.busy = false;\n }\n }\n\n private async openWorld(): Promise<void> {\n const g = this.game;\n if (!g.netClient || g.net) return;\n const visibility = this.radio(\"netVis\") === \"Public\" ? \"Public\" : \"Private\";\n const joinCode = visibility === \"Private\" ? code6() : undefined;\n this.status(\"hostStatus\", \"Открываем…\");\n const res = await NetGame.host(g, g.netClient, {\n name: g.playerName(),\n roomName:\n (this.$(\"room\") as HTMLInputElement).value.trim() || g.worldName,\n visibility,\n joinCode,\n settings: {\n guestMode:\n this.radio(\"netMode\") === \"creative\" ? \"creative\" : \"survival\",\n cheatsForAll: (this.$(\"cheats\") as HTMLInputElement).checked,\n },\n });\n if (typeof res === \"string\")\n return this.status(\"hostStatus\", friendly(res), true);\n g.net = res;\n const invite = joinCode\n ? `${res.session.roomId}:${joinCode}`\n : res.session.roomId;\n this.status(\"hostStatus\", `Мир открыт. Код приглашения: ${invite}`);\n void navigator.clipboard?.writeText(invite).catch(() => undefined);\n g.hud.message(\"Мир открыт для сети — код скопирован\", 2600);\n events.emit(\"net:roster\");\n }\n\n private async join(roomId: string, code: string | undefined): Promise<void> {\n const g = this.game;\n if (!g.netClient || g.net || !roomId) return;\n this.status(\"joinStatus\", \"Входим…\");\n const res = await NetGame.join(\n g,\n g.netClient,\n roomId,\n code,\n g.playerName(),\n );\n if (typeof res === \"string\")\n return this.status(\"joinStatus\", friendly(res), true);\n g.net = res;\n this.status(\"joinStatus\", \"\");\n this.hide();\n g.startPlay();\n }\n\n private async refreshRooms(): Promise<void> {\n const list = this.$(\"rooms\");\n const client = this.game.netClient as unknown as {\n multiplayer: {\n listRooms(\n p?: number,\n s?: number,\n ): Promise<{\n ok: boolean;\n data?: { Rooms?: RoomListItem[] | null };\n error?: string;\n }>;\n };\n } | null;\n if (!client) return;\n list.textContent = \"Загрузка…\";\n const res = await client.multiplayer.listRooms(0, 30);\n list.textContent = \"\";\n if (!res.ok) {\n this.status(\"joinStatus\", friendly(res.error ?? \"\"), true);\n return;\n }\n const rooms = res.data?.Rooms ?? [];\n if (!rooms.length) {\n list.textContent = \"Открытых игр пока нет\";\n return;\n }\n for (const r of rooms) {\n const item = document.createElement(\"div\");\n item.className = \"wsItem\";\n const info = document.createElement(\"div\");\n const name = document.createElement(\"div\");\n name.className = \"wsItemName\";\n name.textContent = r.Name || \"Игра\";\n const meta = document.createElement(\"div\");\n meta.className = \"wsItemMeta\";\n meta.textContent = `Игроков: ${r.MemberCount ?? 0} из ${r.MaxPlayers ?? 8}`;\n info.append(name, meta);\n const btn = document.createElement(\"button\");\n btn.className = \"wsPrimary\";\n btn.dataset.act = \"join\";\n btn.dataset.room = r.RoomID ?? \"\";\n btn.textContent = \"Войти\";\n item.append(info, btn);\n list.appendChild(item);\n }\n }\n\n private renderPlayers(): void {\n const net = this.game.net;\n const list = this.$(\"players\");\n list.textContent = \"\";\n if (!net) {\n list.textContent = \"Вы не в сетевой игре\";\n return;\n }\n for (const p of net.playerList()) {\n const item = document.createElement(\"div\");\n item.className = \"wsItem\";\n const info = document.createElement(\"div\");\n const name = document.createElement(\"div\");\n name.className = \"wsItemName\";\n name.textContent =\n p.name +\n (p.userId === net.selfId ? \" (вы)\" : \"\") +\n (p.host ? \" — хост\" : p.op ? \" — оператор\" : \"\");\n const meta = document.createElement(\"div\");\n meta.className = \"wsItemMeta\";\n meta.textContent = DIM_NAMES[p.dim] ?? p.dim;\n info.append(name, meta);\n item.appendChild(info);\n if (net.isHost && p.userId !== net.selfId) {\n const mk = (act: string, text: string) => {\n const b = document.createElement(\"button\");\n b.dataset.act = act;\n b.dataset.user = p.userId;\n b.textContent = text;\n return b;\n };\n item.append(\n p.op ? mk(\"deop\", \"Снять оператора\") : mk(\"op\", \"Сделать оператором\"),\n mk(\"kick\", \"Исключить\"),\n mk(\"ban\", \"Бан\"),\n );\n }\n list.appendChild(item);\n }\n }\n\n private renderTabList(): void {\n const net = this.game.net;\n this.tabList.textContent = \"\";\n if (!net) return;\n for (const p of net.playerList()) {\n const row = document.createElement(\"div\");\n row.className = \"netTabRow\";\n const n = document.createElement(\"span\");\n n.textContent = (p.host ? \"★ \" : p.op ? \"☆ \" : \"\") + p.name;\n const r = document.createElement(\"span\");\n r.className = \"netTabPing\";\n r.textContent =\n p.userId === net.selfId ? \"\" : p.rtt ? `${p.rtt} мс` : \"…\";\n row.append(n, r);\n this.tabList.appendChild(row);\n }\n }\n\n dispose(): void {\n this.root.remove();\n this.tabList.remove();\n document.getElementById(\"btnNet\")?.remove();\n }\n}\n"
|
|
1596
1649
|
},
|
|
1597
1650
|
{
|
|
1598
1651
|
"path": "ui/SignEditor.ts",
|
|
1599
|
-
"content": "// Ввод текста таблички: ПКМ по табличке открывает строку (как консоль чата), Enter сохраняет,\n// Esc отменяет. Разбивку по строкам делает systems/SignText, показ в мире — gfx/SignText.\nimport { events } from \"../core/EventBus\";\nimport { wrapSignText } from \"../systems/SignText\";\nimport type { SignState } from \"../world/blockentities/sign\";\nimport type { Game } from \"../main\";\n\nexport class SignEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private at: { x: number; y: number; z: number } | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"signEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 96;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openSign\", (p: { x: number; y: number; z: number }) =>\n this.show(p),\n );\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(p: { x: number; y: number; z: number }): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.get(p.x, p.y, p.z);\n if (be?.type !== \"sign\") return;\n this.at = p;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = (be.state as SignState).lines.join(\" \");\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.at = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Сохранить набранное в табличку. */\n submit(): void {\n const at = this.at;\n const text = this.input.value;\n this.hide();\n if (!at) return;\n const be = this.game.blockEntities.get(at.x, at.y, at.z);\n if (be?.type !== \"sign\") return;\n (be.state as SignState).lines = wrapSignText(text);\n events.emit(\"signEdited\", { ...at, text });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1652
|
+
"content": "// Ввод текста таблички: ПКМ по табличке открывает строку (как консоль чата), Enter сохраняет,\n// Esc отменяет. Разбивку по строкам делает systems/SignText, показ в мире — gfx/SignText.\nimport { events } from \"../core/EventBus\";\nimport { wrapSignText } from \"../systems/SignText\";\nimport type { SignState } from \"../world/blockentities/sign\";\nimport type { Game } from \"../main\";\n\nexport class SignEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private at: { x: number; y: number; z: number } | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"signEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 96;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openSign\", (p: { x: number; y: number; z: number }) =>\n this.show(p),\n );\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(p: { x: number; y: number; z: number }): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.get(p.x, p.y, p.z);\n if (be?.type !== \"sign\") return;\n this.at = p;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = (be.state as SignState).lines.join(\" \");\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.at = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Сохранить набранное в табличку. */\n submit(): void {\n const at = this.at;\n const text = this.input.value;\n this.hide();\n if (!at) return;\n const be = this.game.blockEntities.get(at.x, at.y, at.z);\n if (be?.type !== \"sign\") return;\n (be.state as SignState).lines = wrapSignText(text);\n events.emit(\"signEdited\", { ...at, text });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1600
1653
|
},
|
|
1601
1654
|
{
|
|
1602
1655
|
"path": "ui/slotView.ts",
|
|
@@ -1604,7 +1657,7 @@
|
|
|
1604
1657
|
},
|
|
1605
1658
|
{
|
|
1606
1659
|
"path": "ui/TouchControls.ts",
|
|
1607
|
-
"content": "// Сенсорное управление (телефон, планшет): джойстик слева, обзор перетаскиванием справа, кнопки.\n// Пишет в те же поля Input, что клавиатура и мышь (keys, dx/dy, leftDown/rightDown, sprintLatch), —\n// второго пути ввода нет, поэтому вся игровая логика и агент работают как с клавиатурой.\n//\n// Жесты зоны обзора — как в Minecraft для телефона: короткий тап — поставить блок / открыть дверь,\n// удержание без движения — ломать, перетаскивание — поворот камеры.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport type { Game } from \"../main\";\n\nconst LOOK_SENS = 2.2; // «пикселей мыши» на пиксель пальца\nconst DEAD = 0.28; // мёртвая зона джойстика (доля радиуса)\nconst SPRINT_AT = 0.93; // джойстик до упора вперёд — бег\nconst HOLD_MS = 300; // удержание без движения — ломать\nconst TAP_MOVE = 10; // пикселей: больше — уже поворот, а не тап\n\n/** Нужно ли сенсорное управление: грубый указатель (палец) или ?touch=1 в адресе (проверка). */\nexport function touchWanted(): boolean {\n try {\n if (new URLSearchParams(location.search).get(\"touch\") === \"1\") return true;\n return (\n typeof matchMedia === \"function\" &&\n matchMedia(\"(pointer: coarse)\").matches\n );\n } catch {\n return false;\n }\n}\n\nfunction el(cls: string, text = \"\"): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n d.textContent = text;\n return d;\n}\n\nexport class TouchControls {\n readonly root: HTMLDivElement;\n private readonly knob: HTMLDivElement;\n private readonly timers = new Set<ReturnType<typeof setTimeout>>();\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = el(\"touchUi\");\n const opts = { signal };\n\n // ---- зона обзора (правые 60% экрана) ----\n const look = el(\"tLook\");\n let lookId: number | null = null,\n sx = 0,\n sy = 0,\n lx = 0,\n ly = 0,\n t0 = 0,\n moved = false,\n holding = false,\n holdT: ReturnType<typeof setTimeout> | undefined;\n look.addEventListener(\n \"pointerdown\",\n (e) => {\n if (lookId !== null || !this.ready()) return;\n lookId = e.pointerId;\n look.setPointerCapture(e.pointerId);\n sx = lx = e.clientX;\n sy = ly = e.clientY;\n t0 = performance.now();\n moved = holding = false;\n holdT = this.later(() => {\n if (!moved) {\n holding = true;\n game.input.leftDown = true; // удержание — ломать\n }\n }, HOLD_MS);\n },\n opts,\n );\n look.addEventListener(\n \"pointermove\",\n (e) => {\n if (e.pointerId !== lookId) return;\n game.input.dx += (e.clientX - lx) * LOOK_SENS;\n game.input.dy += (e.clientY - ly) * LOOK_SENS;\n lx = e.clientX;\n ly = e.clientY;\n if (Math.hypot(e.clientX - sx, e.clientY - sy) > TAP_MOVE) moved = true;\n },\n opts,\n );\n const lookEnd = (e: PointerEvent) => {\n if (e.pointerId !== lookId) return;\n lookId = null;\n clearTimeout(holdT);\n if (holding) game.input.leftDown = false;\n else if (!moved && performance.now() - t0 < HOLD_MS) this.pulseUse();\n };\n look.addEventListener(\"pointerup\", lookEnd, opts);\n look.addEventListener(\"pointercancel\", lookEnd, opts);\n\n // ---- джойстик ----\n const stick = el(\"tStick\");\n this.knob = el(\"tKnob\");\n stick.append(this.knob);\n let stickId: number | null = null;\n const setDir = (vx: number, vy: number) => {\n const k = game.input.keys;\n const set = (code: string, on: boolean) =>\n on ? k.add(code) : k.delete(code);\n set(\"KeyW\", vy < -DEAD);\n set(\"KeyS\", vy > DEAD);\n set(\"KeyA\", vx < -DEAD);\n set(\"KeyD\", vx > DEAD);\n game.input.sprintLatch = -vy > SPRINT_AT;\n this.knob.style.transform = `translate(${vx * 40}px, ${vy * 40}px)`;\n };\n const stickMove = (e: PointerEvent) => {\n const r = stick.getBoundingClientRect();\n const R = r.width / 2;\n let vx = (e.clientX - (r.left + R)) / R,\n vy = (e.clientY - (r.top + R)) / R;\n const len = Math.hypot(vx, vy);\n if (len > 1) {\n vx /= len;\n vy /= len;\n }\n setDir(vx, vy);\n };\n stick.addEventListener(\n \"pointerdown\",\n (e) => {\n if (stickId !== null || !this.ready()) return;\n stickId = e.pointerId;\n stick.setPointerCapture(e.pointerId);\n stickMove(e);\n },\n opts,\n );\n stick.addEventListener(\n \"pointermove\",\n (e) => {\n if (e.pointerId === stickId) stickMove(e);\n },\n opts,\n );\n const stickEnd = (e: PointerEvent) => {\n if (e.pointerId !== stickId) return;\n stickId = null;\n setDir(0, 0);\n };\n stick.addEventListener(\"pointerup\", stickEnd, opts);\n stick.addEventListener(\"pointercancel\", stickEnd, opts);\n\n // ---- кнопки ----\n let lastJump = 0;\n const hold = (\n cls: string,\n text: string,\n down: () => void,\n up: () => void = () => {},\n ) => {\n const b = el(\"tBtn \" + cls, text);\n b.addEventListener(\n \"pointerdown\",\n (e) => {\n if (!this.ready()) return;\n b.setPointerCapture(e.pointerId);\n b.classList.add(\"on\");\n down();\n },\n opts,\n );\n const end = () => {\n b.classList.remove(\"on\");\n up();\n };\n b.addEventListener(\"pointerup\", end, opts);\n b.addEventListener(\"pointercancel\", end, opts);\n return b;\n };\n const jump = hold(\n \"tJump\",\n \"⤒\",\n () => {\n game.input.keys.add(\"Space\");\n const now = performance.now();\n if (now - lastJump < 300) events.emit(\"input:doubleSpace\"); // полёт в креативе\n lastJump = now;\n },\n () => game.input.keys.delete(\"Space\"),\n );\n const sneak = el(\"tBtn tSneak\", \"⇩\");\n sneak.addEventListener(\n \"pointerdown\",\n () => {\n if (!this.ready()) return;\n const k = game.input.keys;\n const on = !k.has(
|
|
1660
|
+
"content": "// Сенсорное управление (телефон, планшет): джойстик слева, обзор перетаскиванием справа, кнопки.\n// Пишет в те же поля Input, что клавиатура и мышь (keys, dx/dy, leftDown/rightDown, sprintLatch), —\n// второго пути ввода нет, поэтому вся игровая логика и агент работают как с клавиатурой.\n//\n// Жесты зоны обзора — как в Minecraft для телефона: короткий тап — поставить блок / открыть дверь,\n// удержание без движения — ломать, перетаскивание — поворот камеры.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport { SNEAK_KEYS } from \"../player/Input\";\nimport type { Game } from \"../main\";\n\nconst LOOK_SENS = 2.2; // «пикселей мыши» на пиксель пальца\nconst DEAD = 0.28; // мёртвая зона джойстика (доля радиуса)\nconst SPRINT_AT = 0.93; // джойстик до упора вперёд — бег\nconst HOLD_MS = 300; // удержание без движения — ломать\nconst TAP_MOVE = 10; // пикселей: больше — уже поворот, а не тап\n\n/** Нужно ли сенсорное управление: грубый указатель (палец) или ?touch=1 в адресе (проверка). */\nexport function touchWanted(): boolean {\n try {\n if (new URLSearchParams(location.search).get(\"touch\") === \"1\") return true;\n return (\n typeof matchMedia === \"function\" &&\n matchMedia(\"(pointer: coarse)\").matches\n );\n } catch {\n return false;\n }\n}\n\nfunction el(cls: string, text = \"\"): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n d.textContent = text;\n return d;\n}\n\nexport class TouchControls {\n readonly root: HTMLDivElement;\n private readonly knob: HTMLDivElement;\n private readonly timers = new Set<ReturnType<typeof setTimeout>>();\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = el(\"touchUi\");\n const opts = { signal };\n\n // ---- зона обзора (правые 60% экрана) ----\n const look = el(\"tLook\");\n let lookId: number | null = null,\n sx = 0,\n sy = 0,\n lx = 0,\n ly = 0,\n t0 = 0,\n moved = false,\n holding = false,\n holdT: ReturnType<typeof setTimeout> | undefined;\n look.addEventListener(\n \"pointerdown\",\n (e) => {\n if (lookId !== null || !this.ready()) return;\n lookId = e.pointerId;\n look.setPointerCapture(e.pointerId);\n sx = lx = e.clientX;\n sy = ly = e.clientY;\n t0 = performance.now();\n moved = holding = false;\n holdT = this.later(() => {\n if (!moved) {\n holding = true;\n game.input.leftDown = true; // удержание — ломать\n }\n }, HOLD_MS);\n },\n opts,\n );\n look.addEventListener(\n \"pointermove\",\n (e) => {\n if (e.pointerId !== lookId) return;\n game.input.dx += (e.clientX - lx) * LOOK_SENS;\n game.input.dy += (e.clientY - ly) * LOOK_SENS;\n lx = e.clientX;\n ly = e.clientY;\n if (Math.hypot(e.clientX - sx, e.clientY - sy) > TAP_MOVE) moved = true;\n },\n opts,\n );\n const lookEnd = (e: PointerEvent) => {\n if (e.pointerId !== lookId) return;\n lookId = null;\n clearTimeout(holdT);\n if (holding) game.input.leftDown = false;\n else if (!moved && performance.now() - t0 < HOLD_MS) this.pulseUse();\n };\n look.addEventListener(\"pointerup\", lookEnd, opts);\n look.addEventListener(\"pointercancel\", lookEnd, opts);\n\n // ---- джойстик ----\n const stick = el(\"tStick\");\n this.knob = el(\"tKnob\");\n stick.append(this.knob);\n let stickId: number | null = null;\n const setDir = (vx: number, vy: number) => {\n const k = game.input.keys;\n const set = (code: string, on: boolean) =>\n on ? k.add(code) : k.delete(code);\n set(\"KeyW\", vy < -DEAD);\n set(\"KeyS\", vy > DEAD);\n set(\"KeyA\", vx < -DEAD);\n set(\"KeyD\", vx > DEAD);\n game.input.sprintLatch = -vy > SPRINT_AT;\n this.knob.style.transform = `translate(${vx * 40}px, ${vy * 40}px)`;\n };\n const stickMove = (e: PointerEvent) => {\n const r = stick.getBoundingClientRect();\n const R = r.width / 2;\n let vx = (e.clientX - (r.left + R)) / R,\n vy = (e.clientY - (r.top + R)) / R;\n const len = Math.hypot(vx, vy);\n if (len > 1) {\n vx /= len;\n vy /= len;\n }\n setDir(vx, vy);\n };\n stick.addEventListener(\n \"pointerdown\",\n (e) => {\n if (stickId !== null || !this.ready()) return;\n stickId = e.pointerId;\n stick.setPointerCapture(e.pointerId);\n stickMove(e);\n },\n opts,\n );\n stick.addEventListener(\n \"pointermove\",\n (e) => {\n if (e.pointerId === stickId) stickMove(e);\n },\n opts,\n );\n const stickEnd = (e: PointerEvent) => {\n if (e.pointerId !== stickId) return;\n stickId = null;\n setDir(0, 0);\n };\n stick.addEventListener(\"pointerup\", stickEnd, opts);\n stick.addEventListener(\"pointercancel\", stickEnd, opts);\n\n // ---- кнопки ----\n let lastJump = 0;\n const hold = (\n cls: string,\n text: string,\n down: () => void,\n up: () => void = () => {},\n ) => {\n const b = el(\"tBtn \" + cls, text);\n b.addEventListener(\n \"pointerdown\",\n (e) => {\n if (!this.ready()) return;\n b.setPointerCapture(e.pointerId);\n b.classList.add(\"on\");\n down();\n },\n opts,\n );\n const end = () => {\n b.classList.remove(\"on\");\n up();\n };\n b.addEventListener(\"pointerup\", end, opts);\n b.addEventListener(\"pointercancel\", end, opts);\n return b;\n };\n const jump = hold(\n \"tJump\",\n \"⤒\",\n () => {\n game.input.keys.add(\"Space\");\n const now = performance.now();\n if (now - lastJump < 300) events.emit(\"input:doubleSpace\"); // полёт в креативе\n lastJump = now;\n },\n () => game.input.keys.delete(\"Space\"),\n );\n const sneak = el(\"tBtn tSneak\", \"⇩\");\n sneak.addEventListener(\n \"pointerdown\",\n () => {\n if (!this.ready()) return;\n const k = game.input.keys;\n const on = !k.has(SNEAK_KEYS[0]);\n if (on) k.add(SNEAK_KEYS[0]);\n else k.delete(SNEAK_KEYS[0]);\n sneak.classList.toggle(\"on\", on);\n },\n opts,\n );\n const attack = hold(\n \"tAttack\",\n \"⚒\",\n () => (game.input.leftDown = true),\n () => (game.input.leftDown = false),\n );\n const use = hold(\"tUse\", \"✋\", () => this.pulseUse());\n const inv = hold(\"tInv\", \"▦\", () => events.emit(\"ui:toggleInventory\"));\n const chat = hold(\"tChat\", \"/\", () =>\n events.emit(\"ui:openConsole\", { prefix: \"/\" }),\n );\n\n // выбросить предмет из руки (как Q)\n const drop = hold(\"tDrop\", \"⇣\", () =>\n game.intents.push(Intent.DropItem, { all: false }),\n );\n\n this.root.append(look, stick, jump, sneak, attack, use, inv, chat, drop);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n document.body.classList.add(\"touch\");\n }\n\n /** Игра принимает ввод: берём управление (мягкий режим без Pointer Lock), если его ещё нет. */\n private ready(): boolean {\n const g = this.game;\n if (!g.playing || g.input.uiOpen) return false;\n if (!g.input.active) g.input.takeControl();\n return true;\n }\n\n /** «Нажать ПКМ» на мгновение: Interaction поставит блок или откроет дверь ровно раз. */\n private pulseUse(): void {\n this.game.input.rightDown = true;\n this.later(() => (this.game.input.rightDown = false), 120);\n }\n\n private later(fn: () => void, ms: number): ReturnType<typeof setTimeout> {\n const t = setTimeout(() => {\n this.timers.delete(t);\n fn();\n }, ms);\n this.timers.add(t);\n return t;\n }\n\n dispose(): void {\n for (const t of this.timers) clearTimeout(t);\n this.root.remove();\n document.body.classList.remove(\"touch\");\n }\n}\n"
|
|
1608
1661
|
},
|
|
1609
1662
|
{
|
|
1610
1663
|
"path": "ui/tradeView.ts",
|
|
@@ -1800,7 +1853,7 @@
|
|
|
1800
1853
|
},
|
|
1801
1854
|
{
|
|
1802
1855
|
"path": "world/BlockEntities.ts",
|
|
1803
|
-
"content": "// Блочные сущности: у клетки своё состояние — содержимое сундука, топливо и плавка печи, око в\n// рамке портала. Создаются, когда в клетку ставят блок с `blockEntity`, и исчезают вместе с ним\n// (содержимое выпадает). Тикают на симтике, сохраняются в сейве по измерениям.\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport type { ItemStack } from \"../types\";\nimport type { ChunkManager } from \"./ChunkManager\";\nimport type { DropSink } from \"../systems/BlockUpdates\";\n\n/** Состояние — только JSON-совместимые данные: оно целиком уходит в сейв. */\nexport type BlockEntityState = Record<string, unknown>;\n\nexport interface BlockEntity {\n type: string;\n x: number;\n y: number;\n z: number;\n state: BlockEntityState;\n}\n\nexport interface BlockEntityCtx {\n world: ChunkManager;\n be: BlockEntity;\n dt: number;\n /** Блочная сущность соседней клетки (воронка кладёт в сундук под собой). */\n at?(x: number, y: number, z: number): BlockEntity | undefined;\n /** Сущности мира: дроп, стрелы (воронка, раздатчик). */\n sink?: DropSink;\n}\n\nexport interface BlockEntityType {\n create(): BlockEntityState;\n tick?(c: BlockEntityCtx): void;\n /** Что выпадает при разрушении блока (содержимое инвентаря). */\n contents?(state: BlockEntityState): readonly (ItemStack | null)[];\n /** Что выпадает вместо содержимого — сам блок с вещами в tag (панцирный сундук). */\n keep?(state: BlockEntityState): ItemStack | null;\n /**\n * События мира про клетку ({x, y, z, …}): пчела влетела в улей, генератор завёл пчёл в гнезде.\n * Сущности ещё нет, а блок её требует (поставлен генератором) — она создаётся (ensure).\n */\n on?: Record<\n string,\n (be: BlockEntity, payload: Record<string, unknown>) => void\n >;\n}\n\nconst TYPES = new Map<string, BlockEntityType>();\n\nexport function registerBlockEntity(type: string, def: BlockEntityType): void {\n TYPES.set(type, def);\n}\n\nconst key = (x: number, y: number, z: number): string => x + \",\" + y + \",\" + z;\n\nconst isObj = (v: unknown): v is Record<string, unknown> =>\n typeof v === \"object\" && v !== null && !Array.isArray(v);\n\nexport class BlockEntities {\n readonly map = new Map<string, BlockEntity>();\n\n constructor(\n private readonly world: () => ChunkManager,\n private readonly drops: DropSink,\n ) {\n events.on(\n \"blockChanged\",\n (d: { x: number; y: number; z: number; id: number; prevId: number }) =>\n this.onChanged(d.x, d.y, d.z, d.prevId, d.id),\n );\n for (const [type, def] of TYPES)\n for (const [ev, fn] of Object.entries(def.on ?? {}))\n events.on(ev, (p: { x: number; y: number; z: number }) => {\n const be = this.ensure(p.x, p.y, p.z);\n if (be?.type === type)\n fn(be, p as unknown as Record<string, unknown>);\n });\n }\n\n get(x: number, y: number, z: number): BlockEntity | undefined {\n return this.map.get(key(x, y, z));\n }\n\n /** Сущность клетки; нет, а блок её требует (поставлен генератором), — создать. */\n ensure(x: number, y: number, z: number): BlockEntity | undefined {\n const k = key(x, y, z);\n const cur = this.map.get(k);\n if (cur) return cur;\n const type = BLOCKS[this.world().getBlock(x, y, z)]?.blockEntity;\n if (!type) return undefined;\n const be = this.fresh(type, x, y, z);\n this.map.set(k, be);\n return be;\n }\n\n /** Сущность блока, поставленного генератором: сундук постройки получает её добычу (lootAt). */\n private fresh(type: string, x: number, y: number, z: number): BlockEntity {\n const state = TYPES.get(type)?.create() ?? {};\n const loot = this.world().generator?.lootAt?.(x, y, z);\n if (loot && Array.isArray(state.items))\n state.items = loot.map((s) => (s ? { ...s } : null));\n return { type, x, y, z, state };\n }\n\n private onChanged(\n x: number,\n y: number,\n z: number,\n prevId: number,\n id: number,\n ): void {\n const was = BLOCKS[prevId]?.blockEntity ?? null;\n const now = BLOCKS[id]?.blockEntity ?? null;\n if (was === now) return; // тот же тип (печь загорелась — сменилась лишь мета): состояние живёт\n const k = key(x, y, z);\n // нетронутый сундук генератора сущности не имеет — ломая его, получаем его добычу\n const old = this.map.get(k) ?? (was ? this.fresh(was, x, y, z) : undefined);\n if (old) {\n this.map.delete(k);\n for (const s of TYPES.get(old.type)?.contents?.(old.state) ?? [])\n if (s)\n this.drops.spawnDrop(\n x + 0.5,\n y + 0.5,\n z + 0.5,\n s.item,\n s.count,\n s.dmg,\n s.tag,\n );\n const kept = TYPES.get(old.type)?.keep?.(old.state);\n if (kept)\n this.drops.spawnDrop(\n x + 0.5,\n y + 0.5,\n z + 0.5,\n kept.item,\n kept.count,\n kept.dmg,\n kept.tag,\n );\n }\n if (now)\n this.map.set(k, {\n type: now,\n x,\n y,\n z,\n state: TYPES.get(now)?.create() ?? {},\n });\n }\n\n tick(dt: number): void {\n const world = this.world();\n const at = (x: number, y: number, z: number) => this.get(x, y, z);\n for (const be of this.map.values())\n TYPES.get(be.type)?.tick?.({ world, be, dt, at, sink: this.drops });\n }\n\n clear(): void {\n this.map.clear();\n }\n\n serialize(): BlockEntity[] {\n return [...this.map.values()].map((b) => ({ ...b }));\n }\n\n deserialize(list: unknown): void {\n this.clear();\n if (!Array.isArray(list)) return;\n for (const b of list) {\n if (!isObj(b) || typeof b.type !== \"string\" || !isObj(b.state)) continue;\n const x = Number(b.x),\n y = Number(b.y),\n z = Number(b.z);\n if (![x, y, z].every(Number.isFinite)) continue;\n this.map.set(key(x, y, z), { type: b.type, x, y, z, state: b.state });\n }\n }\n}\n"
|
|
1856
|
+
"content": "// Блочные сущности: у клетки своё состояние — содержимое сундука, топливо и плавка печи, око в\n// рамке портала. Создаются, когда в клетку ставят блок с `blockEntity`, и исчезают вместе с ним\n// (содержимое выпадает). Тикают на симтике, сохраняются в сейве по измерениям.\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { sim } from \"../net/sim\";\nimport type { ItemStack } from \"../types\";\nimport type { ChunkManager } from \"./ChunkManager\";\nimport type { DropSink } from \"../systems/BlockUpdates\";\n\n/** Состояние — только JSON-совместимые данные: оно целиком уходит в сейв. */\nexport type BlockEntityState = Record<string, unknown>;\n\nexport interface BlockEntity {\n type: string;\n x: number;\n y: number;\n z: number;\n state: BlockEntityState;\n}\n\nexport interface BlockEntityCtx {\n world: ChunkManager;\n be: BlockEntity;\n dt: number;\n /** Блочная сущность соседней клетки (воронка кладёт в сундук под собой). */\n at?(x: number, y: number, z: number): BlockEntity | undefined;\n /** Сущности мира: дроп, стрелы (воронка, раздатчик). */\n sink?: DropSink;\n}\n\nexport interface BlockEntityType {\n create(): BlockEntityState;\n tick?(c: BlockEntityCtx): void;\n /** Что выпадает при разрушении блока (содержимое инвентаря). */\n contents?(state: BlockEntityState): readonly (ItemStack | null)[];\n /** Что выпадает вместо содержимого — сам блок с вещами в tag (панцирный сундук). */\n keep?(state: BlockEntityState): ItemStack | null;\n /**\n * События мира про клетку ({x, y, z, …}): пчела влетела в улей, генератор завёл пчёл в гнезде.\n * Сущности ещё нет, а блок её требует (поставлен генератором) — она создаётся (ensure).\n */\n on?: Record<\n string,\n (be: BlockEntity, payload: Record<string, unknown>) => void\n >;\n}\n\nconst TYPES = new Map<string, BlockEntityType>();\n\nexport function registerBlockEntity(type: string, def: BlockEntityType): void {\n TYPES.set(type, def);\n}\n\nconst key = (x: number, y: number, z: number): string => x + \",\" + y + \",\" + z;\n\nconst isObj = (v: unknown): v is Record<string, unknown> =>\n typeof v === \"object\" && v !== null && !Array.isArray(v);\n\nexport class BlockEntities {\n readonly map = new Map<string, BlockEntity>();\n\n constructor(\n private readonly world: () => ChunkManager,\n private readonly drops: DropSink,\n ) {\n events.on(\n \"blockChanged\",\n (d: { x: number; y: number; z: number; id: number; prevId: number }) =>\n this.onChanged(d.x, d.y, d.z, d.prevId, d.id),\n );\n for (const [type, def] of TYPES)\n for (const [ev, fn] of Object.entries(def.on ?? {}))\n events.on(ev, (p: { x: number; y: number; z: number }) => {\n const be = this.ensure(p.x, p.y, p.z);\n if (be?.type === type)\n fn(be, p as unknown as Record<string, unknown>);\n });\n }\n\n get(x: number, y: number, z: number): BlockEntity | undefined {\n return this.map.get(key(x, y, z));\n }\n\n /** Сущность клетки; нет, а блок её требует (поставлен генератором), — создать. */\n ensure(x: number, y: number, z: number): BlockEntity | undefined {\n const k = key(x, y, z);\n const cur = this.map.get(k);\n if (cur) return cur;\n const type = BLOCKS[this.world().getBlock(x, y, z)]?.blockEntity;\n if (!type) return undefined;\n const be = this.fresh(type, x, y, z);\n this.map.set(k, be);\n return be;\n }\n\n /** Сущность блока, поставленного генератором: сундук постройки получает её добычу (lootAt). */\n private fresh(type: string, x: number, y: number, z: number): BlockEntity {\n const state = TYPES.get(type)?.create() ?? {};\n const loot = this.world().generator?.lootAt?.(x, y, z);\n if (loot && Array.isArray(state.items))\n state.items = loot.map((s) => (s ? { ...s } : null));\n return { type, x, y, z, state };\n }\n\n private onChanged(\n x: number,\n y: number,\n z: number,\n prevId: number,\n id: number,\n ): void {\n const was = BLOCKS[prevId]?.blockEntity ?? null;\n const now = BLOCKS[id]?.blockEntity ?? null;\n if (was === now) return; // тот же тип (печь загорелась — сменилась лишь мета): состояние живёт\n const k = key(x, y, z);\n // нетронутый сундук генератора сущности не имеет — ломая его, получаем его добычу\n const old = this.map.get(k) ?? (was ? this.fresh(was, x, y, z) : undefined);\n // Реплика (net/sim) содержимое не выбрасывает: это сделает владелец измерения, применив ту же\n // правку, — иначе сундук высыпался бы дважды.\n if (old && !sim.authority) this.map.delete(k);\n else if (old) {\n this.map.delete(k);\n for (const s of TYPES.get(old.type)?.contents?.(old.state) ?? [])\n if (s)\n this.drops.spawnDrop(\n x + 0.5,\n y + 0.5,\n z + 0.5,\n s.item,\n s.count,\n s.dmg,\n s.tag,\n );\n const kept = TYPES.get(old.type)?.keep?.(old.state);\n if (kept)\n this.drops.spawnDrop(\n x + 0.5,\n y + 0.5,\n z + 0.5,\n kept.item,\n kept.count,\n kept.dmg,\n kept.tag,\n );\n }\n if (now)\n this.map.set(k, {\n type: now,\n x,\n y,\n z,\n state: TYPES.get(now)?.create() ?? {},\n });\n }\n\n tick(dt: number): void {\n const world = this.world();\n const at = (x: number, y: number, z: number) => this.get(x, y, z);\n for (const be of this.map.values())\n TYPES.get(be.type)?.tick?.({ world, be, dt, at, sink: this.drops });\n }\n\n clear(): void {\n this.map.clear();\n }\n\n /**\n * Сетевая игра: состояние клетки от владельца измерения (или правка реплики у владельца). null —\n * сущности в клетке больше нет. Меняется только состояние: события и дроп — дело того, кто считает.\n */\n applyRemote(x: number, y: number, z: number, be: BlockEntity | null): void {\n const k = key(x, y, z);\n if (!be) {\n this.map.delete(k);\n return;\n }\n if (!isObj(be.state) || typeof be.type !== \"string\") return;\n this.map.set(k, { type: be.type, x, y, z, state: be.state });\n }\n\n serialize(): BlockEntity[] {\n return [...this.map.values()].map((b) => ({ ...b }));\n }\n\n deserialize(list: unknown): void {\n this.clear();\n if (!Array.isArray(list)) return;\n for (const b of list) {\n if (!isObj(b) || typeof b.type !== \"string\" || !isObj(b.state)) continue;\n const x = Number(b.x),\n y = Number(b.y),\n z = Number(b.z);\n if (![x, y, z].every(Number.isFinite)) continue;\n this.map.set(key(x, y, z), { type: b.type, x, y, z, state: b.state });\n }\n }\n}\n"
|
|
1804
1857
|
},
|
|
1805
1858
|
{
|
|
1806
1859
|
"path": "world/blockentities/armorStand.ts",
|
|
@@ -1892,11 +1945,11 @@
|
|
|
1892
1945
|
},
|
|
1893
1946
|
{
|
|
1894
1947
|
"path": "world/ChunkManager.ts",
|
|
1895
|
-
"content": "// Менеджер чанков: стриминг вокруг игрока, мировые getBlock/setBlock,\n// очередь пересборки мешей с бюджетом на кадр, планировщик onTick-блоков\n// (шов будущей автоматизации: печи/конвейеры регистрируются сами).\nimport type * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { Chunk, CHUNK_SIZE, idx } from \"./Chunk\";\nimport { ChunkMesher } from \"./ChunkMesher\";\nimport { Lighting, OPEN_SKY } from \"./Lighting\";\nimport type { WorldGenerator } from \"../worldgen/WorldGenerator\";\nimport { DIMENSIONS, dimensionGenerator, type DimensionId } from \"./Dimensions\";\nimport { BEHAVIOURS, type Behaviour } from \"./blockBehaviour\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { BlockEntity } from \"./BlockEntities\";\nimport type { ChunkDiffMap, WorldSource } from \"../types\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\nconst key = (cx: number, cz: number): string => cx + \",\" + cz;\n\nexport class ChunkManager {\n scene: THREE.Scene;\n seed: number;\n /** Из чего построен мир — пишется в сейв, чтобы восстановить его генерацией. */\n readonly source: WorldSource;\n generator: WorldGenerator;\n mesher: ChunkMesher;\n chunks: Map<string, Chunk>;\n genQueue: Chunk[];\n pendingDiffs: Map<string, Map<number, [number, number]>>;\n tickingBlocks: Map<string, { x: number; y: number; z: number }>;\n /** Свет клеток (небесный и от блоков): заливка чанков и обновления на setBlock. */\n readonly lighting: Lighting;\n /** Измерение этого мира (у каждого свой ChunkManager). */\n readonly dimension: DimensionId;\n /** Номер симтика — время запланированных тиков. */\n tickN = 0;\n /** Идёт дождь (ставит Game из systems/Weather): огонь под небом гаснет, пашня мокнет. */\n raining = false;\n /** Блочная сущность клетки (ставит Game): компаратор читает заполненность сундука. */\n containerAt:\n ((x: number, y: number, z: number) => BlockEntity | undefined) | null =\n null;\n private readonly scheduled = new Map<\n string,\n { x: number; y: number; z: number; due: number }\n >();\n /** id → есть ли у блока случайный тик (строится при первом тике: поведения уже подключены). */\n private randomTickable: Uint8Array | null = null;\n\n constructor(\n scene: THREE.Scene,\n atlas: TextureAtlas,\n source: WorldSource,\n dimension: DimensionId = \"overworld\",\n ) {\n this.scene = scene;\n this.source = source;\n this.dimension = dimension;\n this.generator = dimensionGenerator(dimension, source);\n this.seed = this.generator.seed;\n this.mesher = new ChunkMesher(atlas, this);\n this.chunks = new Map(); // \"cx,cz\" -> Chunk\n this.genQueue = []; // чанки, ожидающие генерации данных\n this.pendingDiffs = new Map(); // сейв-дифы для ещё не загруженных чанков\n this.tickingBlocks = new Map(); // \"x,y,z\" -> {x,y,z} — блоки с onTick\n this.lighting = new Lighting(this, DIMENSIONS[dimension].hasSky);\n }\n\n /** Свет клетки: (небо << 4) | блоки. Незагруженное и выше мира — открытое небо (если оно есть). */\n getLight(wx: number, wy: number, wz: number): number {\n const open = this.lighting.hasSky ? OPEN_SKY : 0;\n if (wy >= SY) return open;\n if (wy < 0) return 0;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.lit) return open;\n return c.light[idx(wx & 15, wy, wz & 15)]!;\n }\n\n /** Заказать тик блока через delay симтиков (у раньше заказанного остаётся ближайший срок). */\n scheduleTick(x: number, y: number, z: number, delay: number): void {\n const k = x + \",\" + y + \",\" + z;\n const due = this.tickN + Math.max(1, delay);\n const cur = this.scheduled.get(k);\n if (!cur || cur.due > due) this.scheduled.set(k, { x, y, z, due });\n }\n\n /** Вызвать тик поведения блока в клетке. */\n private runTick(\n x: number,\n y: number,\n z: number,\n kind: \"randomTick\" | \"scheduledTick\",\n ): void {\n const id = this.getBlock(x, y, z);\n const fn: Behaviour[typeof kind] = BEHAVIOURS.get(id)?.[kind];\n if (fn)\n fn({\n world: this,\n x,\n y,\n z,\n id,\n meta: this.getMeta(x, y, z),\n rng: Math.random,\n });\n }\n\n /** Случайные тики: CONFIG.randomTickSpeed клеток на каждую секцию 16³ каждого чанка, как в Minecraft. */\n private randomTicks(): void {\n const n = CONFIG.randomTickSpeed;\n if (n <= 0) return;\n const table = (this.randomTickable ??= Uint8Array.from(BLOCKS, (b) =>\n BEHAVIOURS.get(b.id)?.randomTick ? 1 : 0,\n ));\n for (const c of this.chunks.values()) {\n if (!c.generated) continue;\n const sections = (c.top >> 4) + 1;\n for (let s = 0; s < sections; s++)\n for (let k = 0; k < n; k++) {\n const r = (Math.random() * 4096) | 0;\n const lx = r & 15,\n lz = (r >> 4) & 15,\n ly = (s << 4) + (r >> 8);\n if (ly >= SY || !table[c.blocks[idx(lx, ly, lz)]!]) continue;\n this.runTick(c.cx * SX + lx, ly, c.cz * SZ + lz, \"randomTick\");\n }\n }\n }\n\n /** Уровень моря текущего мира (у мира из рецепта он свой). */\n get waterLevel(): number {\n return this.generator.waterLevel;\n }\n\n // ---- доступ к блокам в мировых координатах --------------------------------\n\n chunkAt(wx: number, wz: number): Chunk | undefined {\n return this.chunks.get(key(Math.floor(wx / SX), Math.floor(wz / SZ)));\n }\n\n getBlock(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return B.air!;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return B.air!;\n return c.blocks[idx(wx & 15, wy, wz & 15)]!;\n }\n\n /** Как getBlock, но -1 для незагруженного чанка (мешер трактует как «непрозрачно»). */\n getBlockOrUnloaded(wx: number, wy: number, wz: number): number {\n if (wy < 0) return B.bedrock!;\n if (wy >= SY) return B.air!;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return -1;\n return c.blocks[idx(wx & 15, wy, wz & 15)]!;\n }\n\n getMeta(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return 0;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return 0;\n return c.meta[idx(wx & 15, wy, wz & 15)]!;\n }\n\n isSolid(wx: number, wy: number, wz: number): boolean {\n return BLOCKS[this.getBlock(wx, wy, wz)]?.solid ?? false;\n }\n\n /**\n * Единственная точка мутации мира. Пишет диф, дертит чанк и соседей\n * на границе, ведёт реестр onTick-блоков, эмитит blockChanged.\n */\n setBlock(\n wx: number,\n wy: number,\n wz: number,\n id: number,\n meta = 0,\n opts: { waterOnly?: boolean } = {},\n ): boolean {\n if (wy < 0 || wy >= SY) return false;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return false;\n const lx = wx & 15,\n lz = wz & 15;\n const prevId = c.blocks[idx(lx, wy, lz)]!;\n c.set(lx, wy, lz, id, meta);\n this.lighting.onChange(wx, wy, wz, prevId, id);\n\n // только вода → дешёвая пересборка одного водного меша\n if (\n opts.waterOnly &&\n (id === B.water || prevId === B.water) &&\n !(id !== B.water && prevId !== B.water)\n ) {\n c.waterDirty = true;\n } else {\n c.dirty = true;\n if (lx === 0) this._dirtyNeighbor(wx - 1, wz);\n if (lx === 15) this._dirtyNeighbor(wx + 1, wz);\n if (lz === 0) this._dirtyNeighbor(wx, wz - 1);\n if (lz === 15) this._dirtyNeighbor(wx, wz + 1);\n }\n\n const tickKey = wx + \",\" + wy + \",\" + wz;\n if (BLOCKS[id]?.onTick)\n this.tickingBlocks.set(tickKey, { x: wx, y: wy, z: wz });\n else this.tickingBlocks.delete(tickKey);\n\n events.emit(\"blockChanged\", { x: wx, y: wy, z: wz, id, meta, prevId });\n return true;\n }\n\n _dirtyNeighbor(wx: number, wz: number): void {\n const c = this.chunkAt(wx, wz);\n if (c && c.generated) c.dirty = true;\n }\n\n /** Высота поверхности (первый твёрдый блок сверху) — для спавна. */\n surfaceY(wx: number, wz: number): number {\n for (let y = SY - 1; y >= 0; y--)\n if (BLOCKS[this.getBlock(wx, y, wz)]!.solid) return y;\n return CONFIG.world.baseHeight;\n }\n\n // ---- стриминг ---------------------------------------------------------------\n\n /** Вызывается каждый кадр: догружает кольцо чанков, выгружает дальние. */\n update(px: number, pz: number): void {\n const pcx = Math.floor(px / SX),\n pcz = Math.floor(pz / SZ);\n const R = CONFIG.renderDistance;\n\n // заказать недостающие чанки (по спирали от игрока — ближние раньше)\n for (let r = 0; r <= R; r++) {\n for (let dx = -r; dx <= r; dx++) {\n for (let dz = -r; dz <= r; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;\n const k = key(pcx + dx, pcz + dz);\n if (!this.chunks.has(k)) {\n const chunk = new Chunk(pcx + dx, pcz + dz);\n this.chunks.set(k, chunk);\n this.genQueue.push(chunk);\n }\n }\n }\n }\n\n // генерация данных с бюджетом\n let gen = 0;\n while (this.genQueue.length && gen < CONFIG.genBudgetPerFrame) {\n const chunk = this.genQueue.shift()!;\n // Успел выгрузиться. Если на его месте уже новый чанк — генерировать надо тот, а этот сирота.\n if (this.chunks.get(key(chunk.cx, chunk.cz)) !== chunk) continue;\n this.generator.generate(chunk);\n const diff = this.pendingDiffs.get(key(chunk.cx, chunk.cz));\n if (diff) {\n chunk.applyDiff(diff);\n this.pendingDiffs.delete(key(chunk.cx, chunk.cz));\n // восстановить onTick-реестр из дифа\n for (const [i, [id]] of chunk.modified)\n if (BLOCKS[id]?.onTick) {\n const x = chunk.cx * SX + (i & 15),\n y = i >> 8,\n z = chunk.cz * SZ + ((i >> 4) & 15);\n this.tickingBlocks.set(x + \",\" + y + \",\" + z, { x, y, z });\n }\n }\n chunk.recomputeTop();\n this.lighting.initChunk(chunk); // заливка света + обмен с освещёнными соседями\n chunk.dirty = true;\n // сосед уже отрисован с «глухой» границей — пере-дертить\n this._dirtyNeighbor((chunk.cx - 1) * SX, chunk.cz * SZ);\n this._dirtyNeighbor((chunk.cx + 1) * SX + 1, chunk.cz * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz - 1) * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz + 1) * SZ + 1);\n gen++;\n }\n\n // выгрузка дальних\n for (const [k, chunk] of this.chunks) {\n if (\n Math.max(Math.abs(chunk.cx - pcx), Math.abs(chunk.cz - pcz)) >\n CONFIG.unloadDistance\n ) {\n this._disposeChunk(chunk);\n this.chunks.delete(k);\n this.lighting.forget();\n if (chunk.modified.size) this.pendingDiffs.set(k, chunk.modified);\n }\n }\n\n // пересборка мешей: ближние к игроку — приоритетнее\n const dirty: Chunk[] = [];\n for (const chunk of this.chunks.values()) {\n if (!chunk.generated) continue;\n if (chunk.dirty || chunk.waterDirty) dirty.push(chunk);\n }\n dirty.sort(\n (a, b) =>\n Math.abs(a.cx - pcx) +\n Math.abs(a.cz - pcz) -\n (Math.abs(b.cx - pcx) + Math.abs(b.cz - pcz)),\n );\n for (\n let i = 0;\n i < Math.min(dirty.length, CONFIG.meshBudgetPerFrame);\n i++\n ) {\n const chunk = dirty[i]!;\n this.mesher.rebuild(chunk, this.scene, !chunk.dirty && chunk.waterDirty);\n }\n }\n\n _disposeChunk(chunk: Chunk): void {\n for (const m of [chunk.solidMesh, chunk.foliageMesh, chunk.waterMesh]) {\n if (m) {\n this.scene.remove(m);\n m.geometry.dispose();\n }\n }\n chunk.solidMesh = chunk.foliageMesh = chunk.waterMesh = null;\n }\n\n /** Симтик мира: onTick-блоки, запланированные тики, случайные тики. */\n tick(): void {\n this.tickN++;\n for (const { x, y, z } of this.tickingBlocks.values()) {\n const def = BLOCKS[this.getBlock(x, y, z)];\n def?.onTick?.(this, x, y, z, this.getMeta(x, y, z));\n }\n if (this.scheduled.size) {\n const due: { x: number; y: number; z: number }[] = [];\n for (const [k, s] of this.scheduled)\n if (s.due <= this.tickN) {\n due.push(s);\n this.scheduled.delete(k);\n }\n for (const s of due) this.runTick(s.x, s.y, s.z, \"scheduledTick\");\n }\n this.randomTicks();\n }\n\n /** Полная очистка (регенерация по новому сиду / импорт сейва). */\n clear(): void {\n for (const chunk of this.chunks.values()) this._disposeChunk(chunk);\n this.chunks.clear();\n this.genQueue.length = 0;\n this.pendingDiffs.clear();\n this.tickingBlocks.clear();\n this.scheduled.clear();\n this.lighting.forget();\n }\n\n /** Сейв: дифы всех чанков (загруженных и отложенных). */\n serializeDiffs(): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n for (const [k, diff] of this.pendingDiffs)\n out[k] = [...diff.entries()].map(\n ([i, [id, meta]]): [number, number, number] => [i, id, meta],\n );\n for (const [k, chunk] of this.chunks)\n if (chunk.modified.size) out[k] = chunk.serializeDiff();\n return out;\n }\n\n /** Импорт сейва: дифы применятся к чанкам по мере генерации. */\n loadDiffs(obj: ChunkDiffMap): void {\n for (const [k, arr] of Object.entries(obj))\n this.pendingDiffs.set(\n k,\n new Map(\n arr.map(([i, id, meta]): [number, [number, number]] => [\n i,\n [id, meta],\n ]),\n ),\n );\n }\n}\n"
|
|
1948
|
+
"content": "// Менеджер чанков: стриминг вокруг игрока, мировые getBlock/setBlock,\n// очередь пересборки мешей с бюджетом на кадр, планировщик onTick-блоков\n// (шов будущей автоматизации: печи/конвейеры регистрируются сами).\nimport type * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { Chunk, CHUNK_SIZE, idx } from \"./Chunk\";\nimport { ChunkMesher } from \"./ChunkMesher\";\nimport { Lighting, OPEN_SKY } from \"./Lighting\";\nimport type { WorldGenerator } from \"../worldgen/WorldGenerator\";\nimport { DIMENSIONS, dimensionGenerator, type DimensionId } from \"./Dimensions\";\nimport { BEHAVIOURS, type Behaviour } from \"./blockBehaviour\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { BlockEntity } from \"./BlockEntities\";\nimport type { ChunkDiffMap, WorldSource } from \"../types\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\nconst key = (cx: number, cz: number): string => cx + \",\" + cz;\n\nexport class ChunkManager {\n scene: THREE.Scene;\n seed: number;\n /** Из чего построен мир — пишется в сейв, чтобы восстановить его генерацией. */\n readonly source: WorldSource;\n generator: WorldGenerator;\n mesher: ChunkMesher;\n chunks: Map<string, Chunk>;\n genQueue: Chunk[];\n pendingDiffs: Map<string, Map<number, [number, number]>>;\n tickingBlocks: Map<string, { x: number; y: number; z: number }>;\n /** Свет клеток (небесный и от блоков): заливка чанков и обновления на setBlock. */\n readonly lighting: Lighting;\n /** Измерение этого мира (у каждого свой ChunkManager). */\n readonly dimension: DimensionId;\n /** Номер симтика — время запланированных тиков. */\n tickN = 0;\n /** Идёт дождь (ставит Game из systems/Weather): огонь под небом гаснет, пашня мокнет. */\n raining = false;\n /** Блочная сущность клетки (ставит Game): компаратор читает заполненность сундука. */\n containerAt:\n ((x: number, y: number, z: number) => BlockEntity | undefined) | null =\n null;\n private readonly scheduled = new Map<\n string,\n { x: number; y: number; z: number; due: number }\n >();\n /** id → есть ли у блока случайный тик (строится при первом тике: поведения уже подключены). */\n private randomTickable: Uint8Array | null = null;\n\n constructor(\n scene: THREE.Scene,\n atlas: TextureAtlas,\n source: WorldSource,\n dimension: DimensionId = \"overworld\",\n ) {\n this.scene = scene;\n this.source = source;\n this.dimension = dimension;\n this.generator = dimensionGenerator(dimension, source);\n this.seed = this.generator.seed;\n this.mesher = new ChunkMesher(atlas, this);\n this.chunks = new Map(); // \"cx,cz\" -> Chunk\n this.genQueue = []; // чанки, ожидающие генерации данных\n this.pendingDiffs = new Map(); // сейв-дифы для ещё не загруженных чанков\n this.tickingBlocks = new Map(); // \"x,y,z\" -> {x,y,z} — блоки с onTick\n this.lighting = new Lighting(this, DIMENSIONS[dimension].hasSky);\n }\n\n /** Свет клетки: (небо << 4) | блоки. Незагруженное и выше мира — открытое небо (если оно есть). */\n getLight(wx: number, wy: number, wz: number): number {\n const open = this.lighting.hasSky ? OPEN_SKY : 0;\n if (wy >= SY) return open;\n if (wy < 0) return 0;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.lit) return open;\n return c.light[idx(wx & 15, wy, wz & 15)]!;\n }\n\n /** Заказать тик блока через delay симтиков (у раньше заказанного остаётся ближайший срок). */\n scheduleTick(x: number, y: number, z: number, delay: number): void {\n const k = x + \",\" + y + \",\" + z;\n const due = this.tickN + Math.max(1, delay);\n const cur = this.scheduled.get(k);\n if (!cur || cur.due > due) this.scheduled.set(k, { x, y, z, due });\n }\n\n /** Вызвать тик поведения блока в клетке. */\n private runTick(\n x: number,\n y: number,\n z: number,\n kind: \"randomTick\" | \"scheduledTick\",\n ): void {\n const id = this.getBlock(x, y, z);\n const fn: Behaviour[typeof kind] = BEHAVIOURS.get(id)?.[kind];\n if (fn)\n fn({\n world: this,\n x,\n y,\n z,\n id,\n meta: this.getMeta(x, y, z),\n rng: Math.random,\n });\n }\n\n /** Случайные тики: CONFIG.randomTickSpeed клеток на каждую секцию 16³ каждого чанка, как в Minecraft. */\n private randomTicks(): void {\n const n = CONFIG.randomTickSpeed;\n if (n <= 0) return;\n const table = (this.randomTickable ??= Uint8Array.from(BLOCKS, (b) =>\n BEHAVIOURS.get(b.id)?.randomTick ? 1 : 0,\n ));\n for (const c of this.chunks.values()) {\n if (!c.generated) continue;\n const sections = (c.top >> 4) + 1;\n for (let s = 0; s < sections; s++)\n for (let k = 0; k < n; k++) {\n const r = (Math.random() * 4096) | 0;\n const lx = r & 15,\n lz = (r >> 4) & 15,\n ly = (s << 4) + (r >> 8);\n if (ly >= SY || !table[c.blocks[idx(lx, ly, lz)]!]) continue;\n this.runTick(c.cx * SX + lx, ly, c.cz * SZ + lz, \"randomTick\");\n }\n }\n }\n\n /** Уровень моря текущего мира (у мира из рецепта он свой). */\n get waterLevel(): number {\n return this.generator.waterLevel;\n }\n\n // ---- доступ к блокам в мировых координатах --------------------------------\n\n chunkAt(wx: number, wz: number): Chunk | undefined {\n return this.chunks.get(key(Math.floor(wx / SX), Math.floor(wz / SZ)));\n }\n\n getBlock(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return B.air!;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return B.air!;\n return c.blocks[idx(wx & 15, wy, wz & 15)]!;\n }\n\n /** Как getBlock, но -1 для незагруженного чанка (мешер трактует как «непрозрачно»). */\n getBlockOrUnloaded(wx: number, wy: number, wz: number): number {\n if (wy < 0) return B.bedrock!;\n if (wy >= SY) return B.air!;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return -1;\n return c.blocks[idx(wx & 15, wy, wz & 15)]!;\n }\n\n getMeta(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return 0;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return 0;\n return c.meta[idx(wx & 15, wy, wz & 15)]!;\n }\n\n isSolid(wx: number, wy: number, wz: number): boolean {\n return BLOCKS[this.getBlock(wx, wy, wz)]?.solid ?? false;\n }\n\n /**\n * Единственная точка мутации мира. Пишет диф, дертит чанк и соседей\n * на границе, ведёт реестр onTick-блоков, эмитит blockChanged.\n */\n setBlock(\n wx: number,\n wy: number,\n wz: number,\n id: number,\n meta = 0,\n opts: { waterOnly?: boolean } = {},\n ): boolean {\n if (wy < 0 || wy >= SY) return false;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return false;\n const lx = wx & 15,\n lz = wz & 15;\n const prevId = c.blocks[idx(lx, wy, lz)]!;\n c.set(lx, wy, lz, id, meta);\n this.lighting.onChange(wx, wy, wz, prevId, id);\n\n // только вода → дешёвая пересборка одного водного меша\n if (\n opts.waterOnly &&\n (id === B.water || prevId === B.water) &&\n !(id !== B.water && prevId !== B.water)\n ) {\n c.waterDirty = true;\n } else {\n c.dirty = true;\n if (lx === 0) this._dirtyNeighbor(wx - 1, wz);\n if (lx === 15) this._dirtyNeighbor(wx + 1, wz);\n if (lz === 0) this._dirtyNeighbor(wx, wz - 1);\n if (lz === 15) this._dirtyNeighbor(wx, wz + 1);\n }\n\n const tickKey = wx + \",\" + wy + \",\" + wz;\n if (BLOCKS[id]?.onTick)\n this.tickingBlocks.set(tickKey, { x: wx, y: wy, z: wz });\n else this.tickingBlocks.delete(tickKey);\n\n events.emit(\"blockChanged\", { x: wx, y: wy, z: wz, id, meta, prevId });\n return true;\n }\n\n _dirtyNeighbor(wx: number, wz: number): void {\n const c = this.chunkAt(wx, wz);\n if (c && c.generated) c.dirty = true;\n }\n\n /** Высота поверхности (первый твёрдый блок сверху) — для спавна. */\n surfaceY(wx: number, wz: number): number {\n for (let y = SY - 1; y >= 0; y--)\n if (BLOCKS[this.getBlock(wx, y, wz)]!.solid) return y;\n return CONFIG.world.baseHeight;\n }\n\n // ---- стриминг ---------------------------------------------------------------\n\n /** Вызывается каждый кадр: догружает кольцо чанков, выгружает дальние. */\n update(px: number, pz: number): void {\n const pcx = Math.floor(px / SX),\n pcz = Math.floor(pz / SZ);\n const R = CONFIG.renderDistance;\n\n // заказать недостающие чанки (по спирали от игрока — ближние раньше)\n for (let r = 0; r <= R; r++) {\n for (let dx = -r; dx <= r; dx++) {\n for (let dz = -r; dz <= r; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;\n const k = key(pcx + dx, pcz + dz);\n if (!this.chunks.has(k)) {\n const chunk = new Chunk(pcx + dx, pcz + dz);\n this.chunks.set(k, chunk);\n this.genQueue.push(chunk);\n }\n }\n }\n }\n\n // генерация данных с бюджетом\n let gen = 0;\n while (this.genQueue.length && gen < CONFIG.genBudgetPerFrame) {\n const chunk = this.genQueue.shift()!;\n // Успел выгрузиться. Если на его месте уже новый чанк — генерировать надо тот, а этот сирота.\n if (this.chunks.get(key(chunk.cx, chunk.cz)) !== chunk) continue;\n this.generator.generate(chunk);\n const diff = this.pendingDiffs.get(key(chunk.cx, chunk.cz));\n if (diff) {\n chunk.applyDiff(diff);\n this.pendingDiffs.delete(key(chunk.cx, chunk.cz));\n // восстановить onTick-реестр из дифа\n for (const [i, [id]] of chunk.modified)\n if (BLOCKS[id]?.onTick) {\n const x = chunk.cx * SX + (i & 15),\n y = i >> 8,\n z = chunk.cz * SZ + ((i >> 4) & 15);\n this.tickingBlocks.set(x + \",\" + y + \",\" + z, { x, y, z });\n }\n }\n chunk.recomputeTop();\n this.lighting.initChunk(chunk); // заливка света + обмен с освещёнными соседями\n chunk.dirty = true;\n // сосед уже отрисован с «глухой» границей — пере-дертить\n this._dirtyNeighbor((chunk.cx - 1) * SX, chunk.cz * SZ);\n this._dirtyNeighbor((chunk.cx + 1) * SX + 1, chunk.cz * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz - 1) * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz + 1) * SZ + 1);\n gen++;\n }\n\n // выгрузка дальних\n for (const [k, chunk] of this.chunks) {\n if (\n Math.max(Math.abs(chunk.cx - pcx), Math.abs(chunk.cz - pcz)) >\n CONFIG.unloadDistance\n ) {\n this._disposeChunk(chunk);\n this.chunks.delete(k);\n this.lighting.forget();\n if (chunk.modified.size) this.pendingDiffs.set(k, chunk.modified);\n }\n }\n\n // пересборка мешей: ближние к игроку — приоритетнее\n const dirty: Chunk[] = [];\n for (const chunk of this.chunks.values()) {\n if (!chunk.generated) continue;\n if (chunk.dirty || chunk.waterDirty) dirty.push(chunk);\n }\n dirty.sort(\n (a, b) =>\n Math.abs(a.cx - pcx) +\n Math.abs(a.cz - pcz) -\n (Math.abs(b.cx - pcx) + Math.abs(b.cz - pcz)),\n );\n for (\n let i = 0;\n i < Math.min(dirty.length, CONFIG.meshBudgetPerFrame);\n i++\n ) {\n const chunk = dirty[i]!;\n this.mesher.rebuild(chunk, this.scene, !chunk.dirty && chunk.waterDirty);\n }\n }\n\n _disposeChunk(chunk: Chunk): void {\n for (const m of [chunk.solidMesh, chunk.foliageMesh, chunk.waterMesh]) {\n if (m) {\n this.scene.remove(m);\n m.geometry.dispose();\n }\n }\n chunk.solidMesh = chunk.foliageMesh = chunk.waterMesh = null;\n }\n\n /** Симтик мира: onTick-блоки, запланированные тики, случайные тики. */\n tick(): void {\n this.tickN++;\n for (const { x, y, z } of this.tickingBlocks.values()) {\n const def = BLOCKS[this.getBlock(x, y, z)];\n def?.onTick?.(this, x, y, z, this.getMeta(x, y, z));\n }\n if (this.scheduled.size) {\n const due: { x: number; y: number; z: number }[] = [];\n for (const [k, s] of this.scheduled)\n if (s.due <= this.tickN) {\n due.push(s);\n this.scheduled.delete(k);\n }\n for (const s of due) this.runTick(s.x, s.y, s.z, \"scheduledTick\");\n }\n this.randomTicks();\n }\n\n /** Полная очистка (регенерация по новому сиду / импорт сейва). */\n clear(): void {\n for (const chunk of this.chunks.values()) this._disposeChunk(chunk);\n this.chunks.clear();\n this.genQueue.length = 0;\n this.pendingDiffs.clear();\n this.tickingBlocks.clear();\n this.scheduled.clear();\n this.lighting.forget();\n }\n\n /** Сейв: дифы всех чанков (загруженных и отложенных). */\n serializeDiffs(): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n for (const [k, diff] of this.pendingDiffs)\n out[k] = [...diff.entries()].map(\n ([i, [id, meta]]): [number, number, number] => [i, id, meta],\n );\n for (const [k, chunk] of this.chunks)\n if (chunk.modified.size) out[k] = chunk.serializeDiff();\n return out;\n }\n\n /**\n * Сетевая игра: изменение блока от владельца измерения. В загруженный чанк — как обычная правка\n * (меш, свет, события); в ещё не сгенерированный — в отложенные дифы, как у сейва: `setBlock` такую\n * правку молча выбросил бы, и чанк, догруженный позже, показал бы мир без неё.\n */\n applyRemoteBlock(\n wx: number,\n wy: number,\n wz: number,\n id: number,\n meta: number,\n ): void {\n if (wy < 0 || wy >= SY) return;\n const c = this.chunkAt(wx, wz);\n if (c?.generated) {\n this.setBlock(wx, wy, wz, id, meta);\n return;\n }\n const k = key(Math.floor(wx / SX), Math.floor(wz / SZ));\n let diff = this.pendingDiffs.get(k);\n if (!diff) this.pendingDiffs.set(k, (diff = new Map()));\n diff.set(idx(wx & 15, wy, wz & 15), [id, meta]);\n }\n\n /** Импорт сейва: дифы применятся к чанкам по мере генерации. */\n loadDiffs(obj: ChunkDiffMap): void {\n for (const [k, arr] of Object.entries(obj))\n this.pendingDiffs.set(\n k,\n new Map(\n arr.map(([i, id, meta]): [number, [number, number]] => [\n i,\n [id, meta],\n ]),\n ),\n );\n }\n}\n"
|
|
1896
1949
|
},
|
|
1897
1950
|
{
|
|
1898
1951
|
"path": "world/ChunkMesher.ts",
|
|
1899
|
-
"content": "// Мешер чанка: одна пересборка = три геометрии (твёрдая, растительность и формы, вода).\n// Рендерятся только грани, видимые наружу: сосед-непрозрачный блок гасит грань,\n// в том числе через границу чанков (незагруженный сосед считается непрозрачным —\n// фронтир прячет туман, при загрузке соседа чанк пере-дертится).\n// Блоки-не-кубы (ступени, факел, дверь…) — коробки своей формы (ShapeMesher) в cutout-проходе.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { BLOCKS, B, waterLevel } from \"../registry/Blocks\";\nimport { CENTERED_UV, faceTile, visualBoxes } from \"../registry/shapes\";\nimport { CHUNK_SIZE, idx, type Chunk } from \"./Chunk\";\nimport { FACES, GeoBuilder, type UVRect } from \"./GeoBuilder\";\nimport { meshBoxes } from \"./ShapeMesher\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"./ChunkManager\";\nimport type { BlockDef } from \"../types\";\n\n/** Тайл посева по стадии роста (мета 0..7): у пшеницы 8 картинок, у остальных 4. */\nfunction cropTile(def: BlockDef, meta: number): string {\n const age = meta & 7;\n return `${def.tex!.side!}_${def.name === \"wheat\" ? age : age >> 1}`;\n}\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\n/**\n * Вершинное AO: у каждого угла грани — три соседа в слое перед гранью (две стороны и диагональ).\n * Выводится из той же таблицы FACES: касательные оси — все, кроме оси нормали, а знак смещения по\n * оси — сторона, на которой лежит угол (координата 0 → -1, 1 → +1).\n */\nconst FACE_AO = FACES.map((f) => {\n const axis = f.dir.findIndex((d) => d !== 0);\n const [u, v] = [0, 1, 2].filter((a) => a !== axis) as [number, number];\n return f.corners.map((c): [number[], number[], number[]] => {\n const su = c[u] === 1 ? 1 : -1;\n const sv = c[v] === 1 ? 1 : -1;\n const at = (du: number, dv: number): number[] =>\n f.dir.map((d, a) => d + (a === u ? du : a === v ? dv : 0));\n return [at(su, 0), at(0, sv), at(su, sv)];\n });\n});\n\n/** Яркость угла по числу затеняющих соседей: 0 — угол зажат с двух сторон, 3 — открыт. */\nconst AO_CURVE = [0.45, 0.63, 0.81, 1];\n\nexport class ChunkMesher {\n atlas: TextureAtlas;\n world: ChunkManager;\n _uvCache: Map<string, UVRect>;\n\n constructor(atlas: TextureAtlas, world: ChunkManager) {\n this.atlas = atlas;\n this.world = world; // ChunkManager: кросс-чанковые чтения на границах\n this._uvCache = new Map(); // tileName -> uvRect\n }\n\n _uv(tileName: string): UVRect {\n let r = this._uvCache.get(tileName);\n if (!r) {\n r = this.atlas.uv(tileName);\n this._uvCache.set(tileName, r);\n }\n return r;\n }\n\n /** Пересобирает и подменяет меши чанка. Старые геометрии всегда dispose(). */\n rebuild(chunk: Chunk, scene: THREE.Scene, waterOnly = false): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const solid = waterOnly ? null : new GeoBuilder();\n const foliage = waterOnly ? null : new GeoBuilder();\n const water = new GeoBuilder();\n\n // Чтение блока с фолбэком в мир на границах. -1 = чанк не загружен.\n const blockAt = (x: number, y: number, z: number): number => {\n if (y < 0) return B.bedrock!; // ниже мира — «непрозрачно»\n if (y >= SY) return B.air!;\n if (x >= 0 && x < SX && z >= 0 && z < SZ)\n return chunk.blocks[idx(x, y, z)]!;\n return this.world.getBlockOrUnloaded(wx0 + x, y, wz0 + z);\n };\n const metaAt = (x: number, y: number, z: number): number => {\n if (x >= 0 && x < SX && z >= 0 && z < SZ && y >= 0 && y < SY)\n return chunk.meta[idx(x, y, z)]!;\n return this.world.getMeta(wx0 + x, y, wz0 + z);\n };\n const opaqueAt = (x: number, y: number, z: number): boolean => {\n const id = blockAt(x, y, z);\n return id === -1 || (BLOCKS[id]?.opaque ?? false);\n };\n // Для AO затеняют только загруженные непрозрачные блоки: незагруженный сосед (-1) дал бы тёмную\n // кайму по краю фронтира, которую уже никто не перестроит.\n const occludes = (x: number, y: number, z: number): number => {\n const id = blockAt(x, y, z);\n return id !== -1 && BLOCKS[id]?.opaque ? 1 : 0;\n };\n const useAO = CONFIG.graphics.ao;\n const ao = [1, 1, 1, 1];\n // свет клетки: (небо << 4) | блоки; вне чанка и вне высоты мира — через ChunkManager\n const lightAt = (x: number, y: number, z: number): number =>\n x >= 0 && x < SX && z >= 0 && z < SZ && y >= 0 && y < SY\n ? chunk.light[idx(x, y, z)]!\n : this.world.getLight(wx0 + x, y, wz0 + z);\n const lit = [0, 0, 0, 0, 0, 0, 0, 0]; // [небо, блоки] × 4 угла\n\n // пустое небо над верхним непустым слоем не обходим: с высотой 128 это половина чанка\n const yTop = Math.min(SY - 1, chunk.top);\n for (let y = 0; y <= yTop; y++) {\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const id = chunk.blocks[idx(x, y, z)]!;\n if (id === B.air) continue;\n const def = BLOCKS[id]!;\n\n // растение в воде (waterlogged): клетка рисуется водой, поверх — крестик растения (ниже)\n if (def.waterlogged && B.water !== undefined)\n this._liquidCell(\n water,\n chunk,\n x,\n y,\n z,\n wx0,\n wz0,\n blockAt,\n metaAt,\n chunk.light[idx(x, y, z)]!,\n B.water,\n null,\n );\n\n if (def.liquid) {\n // вода — свой прозрачный проход с бегущей текстурой; лава — непрозрачная, в общем меше\n if (id === B.water)\n this._liquidCell(\n water,\n chunk,\n x,\n y,\n z,\n wx0,\n wz0,\n blockAt,\n metaAt,\n chunk.light[idx(x, y, z)]!,\n id,\n null,\n );\n else if (!waterOnly)\n this._liquidCell(\n solid!,\n chunk,\n x,\n y,\n z,\n wx0,\n wz0,\n blockAt,\n metaAt,\n chunk.light[idx(x, y, z)]!,\n id,\n this._uv(def.tex!.side!),\n );\n continue;\n }\n if (waterOnly) continue;\n const meta = chunk.meta[idx(x, y, z)]!;\n const own = chunk.light[idx(x, y, z)]!; // свет своей (прозрачной) клетки\n\n if (def.shape === \"crop\") {\n // посев: «решётка» из четырёх плоскостей, картинка по стадии роста\n const uv = this._uv(cropTile(def, meta));\n const b = foliage!;\n const q = (c: number[][]): void =>\n b.quad(c, x, y, z, 0, 1, 0, uv, 1.0, undefined, own);\n for (const k of [0.25, 0.75]) {\n q([\n [0, 1, k, 0, 1],\n [0, 0, k, 0, 0],\n [1, 1, k, 1, 1],\n [1, 0, k, 1, 0],\n ]);\n q([\n [k, 1, 0, 0, 1],\n [k, 0, 0, 0, 0],\n [k, 1, 1, 1, 1],\n [k, 0, 1, 1, 0],\n ]);\n }\n continue;\n }\n\n if (def.cross || def.shape === \"fire\") {\n // растение: два скрещённых квада по диагоналям\n const uv = this._uv(def.tex!.side!);\n const b = foliage!;\n const q = (c: number[][]): void =>\n b.quad(c, x, y, z, 0, 1, 0, uv, 1.0, undefined, own);\n q([\n [0.15, 1, 0.15, 0, 1],\n [0.15, 0, 0.15, 0, 0],\n [0.85, 1, 0.85, 1, 1],\n [0.85, 0, 0.85, 1, 0],\n ]);\n q([\n [0.85, 1, 0.15, 0, 1],\n [0.85, 0, 0.15, 0, 0],\n [0.15, 1, 0.85, 1, 1],\n [0.15, 0, 0.85, 1, 0],\n ]);\n continue;\n }\n\n if (def.shape !== \"cube\") {\n const cx = x,\n cy = y,\n cz = z;\n const ctx = {\n neighbor: (dx: number, dy: number, dz: number) =>\n blockAt(cx + dx, cy + dy, cz + dz),\n };\n meshBoxes(\n foliage!,\n visualBoxes(def, meta, ctx),\n x,\n y,\n z,\n (fi) => this._uv(faceTile(def, meta, fi)),\n opaqueAt,\n CENTERED_UV.has(def.shape),\n own,\n );\n continue;\n }\n\n const target = def.opaque ? solid! : foliage!; // листва — в cutout-проход\n for (let fi = 0; fi < FACES.length; fi++) {\n const f = FACES[fi]!;\n const nx = x + f.dir[0]!,\n ny = y + f.dir[1]!,\n nz = z + f.dir[2]!;\n if (opaqueAt(nx, ny, nz)) continue;\n if (!def.opaque && blockAt(nx, ny, nz) === id) continue; // листва к листве\n const fl = lightAt(nx, ny, nz); // клетка перед гранью\n if (useAO) {\n const table = FACE_AO[fi]!;\n for (let c = 0; c < 4; c++) {\n const [s1, s2, sc] = table[c]!;\n const a = occludes(x + s1[0]!, y + s1[1]!, z + s1[2]!);\n const b = occludes(x + s2[0]!, y + s2[1]!, z + s2[2]!);\n const k = occludes(x + sc[0]!, y + sc[1]!, z + sc[2]!);\n ao[c] = AO_CURVE[a && b ? 0 : 3 - a - b - k]!;\n // мягкий свет угла: среднее по клетке грани и её прозрачным соседям у этого угла\n let sky = fl >> 4,\n blk = fl & 15,\n n = 1;\n if (!a) {\n const l = lightAt(x + s1[0]!, y + s1[1]!, z + s1[2]!);\n sky += l >> 4;\n blk += l & 15;\n n++;\n }\n if (!b) {\n const l = lightAt(x + s2[0]!, y + s2[1]!, z + s2[2]!);\n sky += l >> 4;\n blk += l & 15;\n n++;\n }\n if (!k && !(a && b)) {\n const l = lightAt(x + sc[0]!, y + sc[1]!, z + sc[2]!);\n sky += l >> 4;\n blk += l & 15;\n n++;\n }\n lit[c * 2] = sky / n;\n lit[c * 2 + 1] = blk / n;\n }\n }\n target.quad(\n f.corners,\n x,\n y,\n z,\n f.dir[0]!,\n f.dir[1]!,\n f.dir[2]!,\n this._uv(faceTile(def, meta, fi)),\n f.shade,\n useAO ? ao : undefined,\n useAO ? lit : fl,\n );\n }\n }\n }\n }\n\n if (!waterOnly) {\n chunk.solidMesh = this._swap(\n chunk.solidMesh,\n solid!,\n this.atlas.opaqueMat,\n scene,\n wx0,\n wz0,\n 0,\n true,\n );\n chunk.foliageMesh = this._swap(\n chunk.foliageMesh,\n foliage!,\n this.atlas.foliageMat,\n scene,\n wx0,\n wz0,\n 0,\n true,\n );\n chunk.dirty = false;\n }\n chunk.waterMesh = this._swap(\n chunk.waterMesh,\n water,\n this.atlas.waterMat,\n scene,\n wx0,\n wz0,\n 1,\n false, // вода тени принимает, но не отбрасывает\n );\n chunk.waterDirty = false;\n }\n\n /**\n * Клетка жидкости. uv = null — вода: мировые UV на repeat-текстуре (анимируется сдвигом);\n * иначе — тайл атласа (лава) с UV внутри клетки.\n */\n _liquidCell(\n b: GeoBuilder,\n chunk: Chunk,\n x: number,\n y: number,\n z: number,\n wx0: number,\n wz0: number,\n blockAt: (x: number, y: number, z: number) => number,\n _metaAt: (x: number, y: number, z: number) => number,\n light: number, // свет клетки жидкости — на все её грани\n id: number,\n uv: UVRect | null,\n ): void {\n // та же жидкость (и растение в воде) — грань между ними не нужна\n const isWater = (n: number): boolean =>\n n === id || (id === B.water && !!BLOCKS[n]?.waterlogged);\n // у растения в воде уровень — полный источник (его мета — не уровень воды)\n const level = BLOCKS[chunk.blocks[idx(x, y, z)]!]?.waterlogged\n ? 8\n : waterLevel(chunk.meta[idx(x, y, z)]!);\n const above = blockAt(x, y + 1, z);\n // высота столба: полный, если сверху тоже вода\n const h = isWater(above) ? 1 : 0.125 + (level / 8) * 0.75;\n // вода — мировые UV (repeat-текстура тайлится сама); тайл атласа — UV в пределах клетки\n const wx = uv ? 0 : wx0 + x,\n wz = uv ? 0 : wz0 + z,\n vy = uv ? 0 : y;\n\n // верх: виден, если сверху не вода\n if (!isWater(above)) {\n b.quad(\n [\n [0, h, 1, 0, 1],\n [1, h, 1, 1, 1],\n [0, h, 0, 0, 0],\n [1, h, 0, 1, 0],\n ].map(([cx, cy, cz]) => [cx!, cy!, cz!, wx + cx!, wz + cz!]),\n x,\n y,\n z,\n 0,\n 1,\n 0,\n uv,\n 1.0,\n undefined,\n light,\n );\n }\n // низ\n if (\n blockAt(x, y - 1, z) !== -1 &&\n !BLOCKS[blockAt(x, y - 1, z)]?.opaque &&\n !isWater(blockAt(x, y - 1, z))\n ) {\n b.quad(\n [\n [1, 0, 1, wx + 1, wz + 1],\n [0, 0, 1, wx, wz + 1],\n [1, 0, 0, wx + 1, wz],\n [0, 0, 0, wx, wz],\n ],\n x,\n y,\n z,\n 0,\n -1,\n 0,\n uv,\n 0.6,\n undefined,\n light,\n );\n }\n // бока: против воздуха/прозрачного, но не против воды и не против непрозрачного\n const sides = [\n {\n d: [-1, 0, 0],\n c: [\n [0, 1, 0],\n [0, 0, 0],\n [0, 1, 1],\n [0, 0, 1],\n ],\n },\n {\n d: [1, 0, 0],\n c: [\n [1, 1, 1],\n [1, 0, 1],\n [1, 1, 0],\n [1, 0, 0],\n ],\n },\n {\n d: [0, 0, -1],\n c: [\n [1, 1, 0],\n [1, 0, 0],\n [0, 1, 0],\n [0, 0, 0],\n ],\n },\n {\n d: [0, 0, 1],\n c: [\n [0, 1, 1],\n [0, 0, 1],\n [1, 1, 1],\n [1, 0, 1],\n ],\n },\n ];\n for (const s of sides) {\n const nid = blockAt(x + s.d[0]!, y, z + s.d[2]!);\n if (nid === -1 || isWater(nid) || BLOCKS[nid]?.opaque) continue;\n const corners = s.c.map(([cx, cy, cz]) => {\n const uAxis = s.d[0] !== 0 ? wz + cz! : wx + cx!; // горизонтальная UV-ось\n return [cx!, cy === 1 ? h : 0, cz!, uAxis, vy + (cy === 1 ? h : 0)];\n });\n b.quad(corners, x, y, z, s.d[0]!, 0, s.d[2]!, uv, 0.8, undefined, light);\n }\n }\n\n /** Подмена меша: dispose старой геометрии, пустые геометрии не создают меш. */\n _swap(\n oldMesh: THREE.Mesh | null,\n builder: GeoBuilder,\n material: THREE.Material,\n scene: THREE.Scene,\n wx0: number,\n wz0: number,\n renderOrder: number,\n castShadow: boolean,\n ): THREE.Mesh | null {\n if (oldMesh) {\n scene.remove(oldMesh);\n oldMesh.geometry.dispose();\n }\n if (builder.empty) return null;\n const mesh = new THREE.Mesh(builder.build(), material);\n mesh.position.set(wx0, 0, wz0);\n mesh.renderOrder = renderOrder;\n mesh.castShadow = castShadow && CONFIG.graphics.shadows;\n mesh.receiveShadow = CONFIG.graphics.shadows;\n mesh.matrixAutoUpdate = false;\n mesh.updateMatrix();\n scene.add(mesh);\n return mesh;\n }\n}\n"
|
|
1952
|
+
"content": "// Мешер чанка: одна пересборка = три геометрии (твёрдая, растительность и формы, вода).\n// Рендерятся только грани, видимые наружу: сосед-непрозрачный блок гасит грань,\n// в том числе через границу чанков (незагруженный сосед считается непрозрачным —\n// фронтир прячет туман, при загрузке соседа чанк пере-дертится).\n// Блоки-не-кубы (ступени, факел, дверь…) — коробки своей формы (ShapeMesher) в cutout-проходе.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { BLOCKS, B, waterLevel } from \"../registry/Blocks\";\nimport { CENTERED_UV, faceTile, visualBoxes } from \"../registry/shapes\";\nimport { CHUNK_SIZE, idx, type Chunk } from \"./Chunk\";\nimport { FACES, GeoBuilder, type UVRect } from \"./GeoBuilder\";\nimport { meshBoxes } from \"./ShapeMesher\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"./ChunkManager\";\nimport type { BlockDef } from \"../types\";\n\n/** Тайл посева по стадии роста (мета 0..7): у пшеницы 8 картинок, у остальных 4. */\nfunction cropTile(def: BlockDef, meta: number): string {\n const age = meta & 7;\n return `${def.tex!.side!}_${def.name === \"wheat\" ? age : age >> 1}`;\n}\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\n/**\n * Вершинное AO: у каждого угла грани — три соседа в слое перед гранью (две стороны и диагональ).\n * Выводится из той же таблицы FACES: касательные оси — все, кроме оси нормали, а знак смещения по\n * оси — сторона, на которой лежит угол (координата 0 → -1, 1 → +1).\n */\nconst FACE_AO = FACES.map((f) => {\n const axis = f.dir.findIndex((d) => d !== 0);\n const [u, v] = [0, 1, 2].filter((a) => a !== axis) as [number, number];\n return f.corners.map((c): [number[], number[], number[]] => {\n const su = c[u] === 1 ? 1 : -1;\n const sv = c[v] === 1 ? 1 : -1;\n const at = (du: number, dv: number): number[] =>\n f.dir.map((d, a) => d + (a === u ? du : a === v ? dv : 0));\n return [at(su, 0), at(0, sv), at(su, sv)];\n });\n});\n\n/** Яркость угла по числу затеняющих соседей: 0 — угол зажат с двух сторон, 3 — открыт. */\nconst AO_CURVE = [0.45, 0.63, 0.81, 1];\n\nexport class ChunkMesher {\n atlas: TextureAtlas;\n world: ChunkManager;\n _uvCache: Map<string, UVRect>;\n\n constructor(atlas: TextureAtlas, world: ChunkManager) {\n this.atlas = atlas;\n this.world = world; // ChunkManager: кросс-чанковые чтения на границах\n this._uvCache = new Map(); // tileName -> uvRect\n }\n\n _uv(tileName: string): UVRect {\n let r = this._uvCache.get(tileName);\n if (!r) {\n r = this.atlas.uv(tileName);\n this._uvCache.set(tileName, r);\n }\n return r;\n }\n\n /** Пересобирает и подменяет меши чанка. Старые геометрии всегда dispose(). */\n rebuild(chunk: Chunk, scene: THREE.Scene, waterOnly = false): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const solid = waterOnly ? null : new GeoBuilder();\n const foliage = waterOnly ? null : new GeoBuilder();\n const water = new GeoBuilder();\n\n // Чтение блока с фолбэком в мир на границах. -1 = чанк не загружен.\n const blockAt = (x: number, y: number, z: number): number => {\n if (y < 0) return B.bedrock!; // ниже мира — «непрозрачно»\n if (y >= SY) return B.air!;\n if (x >= 0 && x < SX && z >= 0 && z < SZ)\n return chunk.blocks[idx(x, y, z)]!;\n return this.world.getBlockOrUnloaded(wx0 + x, y, wz0 + z);\n };\n const metaAt = (x: number, y: number, z: number): number => {\n if (x >= 0 && x < SX && z >= 0 && z < SZ && y >= 0 && y < SY)\n return chunk.meta[idx(x, y, z)]!;\n return this.world.getMeta(wx0 + x, y, wz0 + z);\n };\n const opaqueAt = (x: number, y: number, z: number): boolean => {\n const id = blockAt(x, y, z);\n return id === -1 || (BLOCKS[id]?.opaque ?? false);\n };\n // Для AO затеняют только загруженные непрозрачные блоки: незагруженный сосед (-1) дал бы тёмную\n // кайму по краю фронтира, которую уже никто не перестроит.\n const occludes = (x: number, y: number, z: number): number => {\n const id = blockAt(x, y, z);\n return id !== -1 && BLOCKS[id]?.opaque ? 1 : 0;\n };\n const useAO = CONFIG.graphics.ao;\n const ao = [1, 1, 1, 1];\n // свет клетки: (небо << 4) | блоки; вне чанка и вне высоты мира — через ChunkManager\n const lightAt = (x: number, y: number, z: number): number =>\n x >= 0 && x < SX && z >= 0 && z < SZ && y >= 0 && y < SY\n ? chunk.light[idx(x, y, z)]!\n : this.world.getLight(wx0 + x, y, wz0 + z);\n const lit = [0, 0, 0, 0, 0, 0, 0, 0]; // [небо, блоки] × 4 угла\n\n // пустое небо над верхним непустым слоем не обходим: с высотой 128 это половина чанка\n const yTop = Math.min(SY - 1, chunk.top);\n for (let y = 0; y <= yTop; y++) {\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const id = chunk.blocks[idx(x, y, z)]!;\n if (id === B.air) continue;\n const def = BLOCKS[id]!;\n\n // растение в воде (waterlogged): клетка рисуется водой, поверх — крестик растения (ниже)\n if (def.waterlogged && B.water !== undefined)\n this._liquidCell(\n water,\n chunk,\n x,\n y,\n z,\n wx0,\n wz0,\n blockAt,\n metaAt,\n chunk.light[idx(x, y, z)]!,\n B.water,\n null,\n );\n\n if (def.liquid) {\n // вода — свой прозрачный проход с бегущей текстурой; лава — непрозрачная, в общем меше\n if (id === B.water)\n this._liquidCell(\n water,\n chunk,\n x,\n y,\n z,\n wx0,\n wz0,\n blockAt,\n metaAt,\n chunk.light[idx(x, y, z)]!,\n id,\n null,\n );\n else if (!waterOnly)\n this._liquidCell(\n solid!,\n chunk,\n x,\n y,\n z,\n wx0,\n wz0,\n blockAt,\n metaAt,\n chunk.light[idx(x, y, z)]!,\n id,\n this._uv(def.tex!.side!),\n );\n continue;\n }\n if (waterOnly) continue;\n const meta = chunk.meta[idx(x, y, z)]!;\n const own = chunk.light[idx(x, y, z)]!; // свет своей (прозрачной) клетки\n\n if (def.shape === \"crop\") {\n // посев: «решётка» из четырёх плоскостей, картинка по стадии роста\n const uv = this._uv(cropTile(def, meta));\n const b = foliage!;\n const q = (c: number[][]): void =>\n b.quad(c, x, y, z, 0, 1, 0, uv, 1.0, undefined, own);\n for (const k of [0.25, 0.75]) {\n q([\n [0, 1, k, 0, 1],\n [0, 0, k, 0, 0],\n [1, 1, k, 1, 1],\n [1, 0, k, 1, 0],\n ]);\n q([\n [k, 1, 0, 0, 1],\n [k, 0, 0, 0, 0],\n [k, 1, 1, 1, 1],\n [k, 0, 1, 1, 0],\n ]);\n }\n continue;\n }\n\n if (def.cross || def.shape === \"fire\") {\n // растение: два скрещённых квада по диагоналям\n const uv = this._uv(def.tex!.side!);\n const b = foliage!;\n const q = (c: number[][]): void =>\n b.quad(c, x, y, z, 0, 1, 0, uv, 1.0, undefined, own);\n q([\n [0.15, 1, 0.15, 0, 1],\n [0.15, 0, 0.15, 0, 0],\n [0.85, 1, 0.85, 1, 1],\n [0.85, 0, 0.85, 1, 0],\n ]);\n q([\n [0.85, 1, 0.15, 0, 1],\n [0.85, 0, 0.15, 0, 0],\n [0.15, 1, 0.85, 1, 1],\n [0.15, 0, 0.85, 1, 0],\n ]);\n continue;\n }\n\n if (def.shape !== \"cube\") {\n const cx = x,\n cy = y,\n cz = z;\n const ctx = {\n neighbor: (dx: number, dy: number, dz: number) =>\n blockAt(cx + dx, cy + dy, cz + dz),\n };\n meshBoxes(\n foliage!,\n visualBoxes(def, meta, ctx),\n x,\n y,\n z,\n (fi) => this._uv(faceTile(def, meta, fi)),\n opaqueAt,\n CENTERED_UV.has(def.shape),\n own,\n );\n continue;\n }\n\n const target = def.opaque ? solid! : foliage!; // листва — в cutout-проход\n for (let fi = 0; fi < FACES.length; fi++) {\n const f = FACES[fi]!;\n const nx = x + f.dir[0]!,\n ny = y + f.dir[1]!,\n nz = z + f.dir[2]!;\n if (opaqueAt(nx, ny, nz)) continue;\n if (!def.opaque && blockAt(nx, ny, nz) === id) continue; // листва к листве\n const fl = lightAt(nx, ny, nz); // клетка перед гранью\n if (useAO) {\n const table = FACE_AO[fi]!;\n for (let c = 0; c < 4; c++) {\n const [s1, s2, sc] = table[c]!;\n const a = occludes(x + s1[0]!, y + s1[1]!, z + s1[2]!);\n const b = occludes(x + s2[0]!, y + s2[1]!, z + s2[2]!);\n const k = occludes(x + sc[0]!, y + sc[1]!, z + sc[2]!);\n ao[c] = AO_CURVE[a && b ? 0 : 3 - a - b - k]!;\n // мягкий свет угла: среднее по клетке грани и её прозрачным соседям у этого угла\n let sky = fl >> 4,\n blk = fl & 15,\n n = 1;\n if (!a) {\n const l = lightAt(x + s1[0]!, y + s1[1]!, z + s1[2]!);\n sky += l >> 4;\n blk += l & 15;\n n++;\n }\n if (!b) {\n const l = lightAt(x + s2[0]!, y + s2[1]!, z + s2[2]!);\n sky += l >> 4;\n blk += l & 15;\n n++;\n }\n if (!k && !(a && b)) {\n const l = lightAt(x + sc[0]!, y + sc[1]!, z + sc[2]!);\n sky += l >> 4;\n blk += l & 15;\n n++;\n }\n lit[c * 2] = sky / n;\n lit[c * 2 + 1] = blk / n;\n }\n }\n target.quad(\n f.corners,\n x,\n y,\n z,\n f.dir[0]!,\n f.dir[1]!,\n f.dir[2]!,\n this._uv(faceTile(def, meta, fi)),\n f.shade,\n useAO ? ao : undefined,\n useAO ? lit : fl,\n );\n }\n }\n }\n }\n\n if (!waterOnly) {\n chunk.solidMesh = this._swap(\n chunk.solidMesh,\n solid!,\n this.atlas.opaqueMat,\n scene,\n wx0,\n wz0,\n 0,\n true,\n );\n chunk.foliageMesh = this._swap(\n chunk.foliageMesh,\n foliage!,\n this.atlas.foliageMat,\n scene,\n wx0,\n wz0,\n 0,\n true,\n );\n chunk.dirty = false;\n }\n chunk.waterMesh = this._swap(\n chunk.waterMesh,\n water,\n this.atlas.waterMat,\n scene,\n wx0,\n wz0,\n 1,\n false, // вода тени принимает, но не отбрасывает\n );\n chunk.waterDirty = false;\n }\n\n /**\n * Клетка жидкости. uv = null — вода: мировые UV на repeat-текстуре (анимируется сдвигом);\n * иначе — тайл атласа (лава) с UV внутри клетки.\n */\n _liquidCell(\n b: GeoBuilder,\n chunk: Chunk,\n x: number,\n y: number,\n z: number,\n wx0: number,\n wz0: number,\n blockAt: (x: number, y: number, z: number) => number,\n metaAt: (x: number, y: number, z: number) => number,\n light: number, // свет клетки жидкости — на все её грани\n id: number,\n uv: UVRect | null,\n ): void {\n // та же жидкость (и растение в воде) — грань между ними не нужна\n const isWater = (n: number): boolean =>\n n === id || (id === B.water && !!BLOCKS[n]?.waterlogged);\n // высота поверхности клетки: полная, если сверху тоже вода; у растения в воде — источник\n // (его мета — не уровень воды)\n const heightOf = (cx: number, cz: number): number => {\n if (isWater(blockAt(cx, y + 1, cz))) return 1;\n const level = BLOCKS[blockAt(cx, y, cz)]?.waterlogged\n ? 8\n : waterLevel(metaAt(cx, y, cz));\n return 0.125 + (level / 8) * 0.75;\n };\n const above = blockAt(x, y + 1, z);\n const h = heightOf(x, z);\n // Высота УГЛА — средняя по клеткам этой жидкости, что в нём сходятся (как в Minecraft): у двух\n // соседей общий край на одной высоте, поверхность — плавный скат. С плоской плиткой на клетку\n // соседи разной высоты давали ступеньку, а боковой грани между водой и водой нет — в щель\n // было видно насквозь («разрывы» текущей воды и водопадов). Столб с водой сверху тянет угол\n // до полной высоты.\n const cornerH = (ox: number, oz: number): number => {\n let sum = 0,\n n = 0;\n for (const dx of [ox - 1, ox])\n for (const dz of [oz - 1, oz]) {\n const cx = x + dx,\n cz = z + dz;\n if (!isWater(blockAt(cx, y, cz))) continue;\n const ch = heightOf(cx, cz);\n if (ch >= 1) return 1;\n sum += ch;\n n++;\n }\n return n ? sum / n : h;\n };\n const top = [\n [cornerH(0, 0), cornerH(0, 1)],\n [cornerH(1, 0), cornerH(1, 1)],\n ] as const;\n const hAt = (cx: number, cz: number): number => top[cx]![cz]!;\n // вода — мировые UV (repeat-текстура тайлится сама); тайл атласа — UV в пределах клетки\n const wx = uv ? 0 : wx0 + x,\n wz = uv ? 0 : wz0 + z,\n vy = uv ? 0 : y;\n\n // верх: виден, если сверху не вода\n if (!isWater(above)) {\n b.quad(\n [\n [0, 1, 0, 1],\n [1, 1, 1, 1],\n [0, 0, 0, 0],\n [1, 0, 1, 0],\n ].map(([cx, cz, u, v]) => [cx!, hAt(cx!, cz!), cz!, wx + u!, wz + v!]),\n x,\n y,\n z,\n 0,\n 1,\n 0,\n uv,\n 1.0,\n undefined,\n light,\n );\n }\n // низ\n if (\n blockAt(x, y - 1, z) !== -1 &&\n !BLOCKS[blockAt(x, y - 1, z)]?.opaque &&\n !isWater(blockAt(x, y - 1, z))\n ) {\n b.quad(\n [\n [1, 0, 1, wx + 1, wz + 1],\n [0, 0, 1, wx, wz + 1],\n [1, 0, 0, wx + 1, wz],\n [0, 0, 0, wx, wz],\n ],\n x,\n y,\n z,\n 0,\n -1,\n 0,\n uv,\n 0.6,\n undefined,\n light,\n );\n }\n // бока: против воздуха/прозрачного, но не против воды и не против непрозрачного\n const sides = [\n {\n d: [-1, 0, 0],\n c: [\n [0, 1, 0],\n [0, 0, 0],\n [0, 1, 1],\n [0, 0, 1],\n ],\n },\n {\n d: [1, 0, 0],\n c: [\n [1, 1, 1],\n [1, 0, 1],\n [1, 1, 0],\n [1, 0, 0],\n ],\n },\n {\n d: [0, 0, -1],\n c: [\n [1, 1, 0],\n [1, 0, 0],\n [0, 1, 0],\n [0, 0, 0],\n ],\n },\n {\n d: [0, 0, 1],\n c: [\n [0, 1, 1],\n [0, 0, 1],\n [1, 1, 1],\n [1, 0, 1],\n ],\n },\n ];\n for (const s of sides) {\n const nid = blockAt(x + s.d[0]!, y, z + s.d[2]!);\n if (nid === -1 || isWater(nid) || BLOCKS[nid]?.opaque) continue;\n const corners = s.c.map(([cx, cy, cz]) => {\n const uAxis = s.d[0] !== 0 ? wz + cz! : wx + cx!; // горизонтальная UV-ось\n const ch = cy === 1 ? hAt(cx!, cz!) : 0;\n return [cx!, ch, cz!, uAxis, vy + ch];\n });\n b.quad(corners, x, y, z, s.d[0]!, 0, s.d[2]!, uv, 0.8, undefined, light);\n }\n }\n\n /** Подмена меша: dispose старой геометрии, пустые геометрии не создают меш. */\n _swap(\n oldMesh: THREE.Mesh | null,\n builder: GeoBuilder,\n material: THREE.Material,\n scene: THREE.Scene,\n wx0: number,\n wz0: number,\n renderOrder: number,\n castShadow: boolean,\n ): THREE.Mesh | null {\n if (oldMesh) {\n scene.remove(oldMesh);\n oldMesh.geometry.dispose();\n }\n if (builder.empty) return null;\n const mesh = new THREE.Mesh(builder.build(), material);\n mesh.position.set(wx0, 0, wz0);\n mesh.renderOrder = renderOrder;\n mesh.castShadow = castShadow && CONFIG.graphics.shadows;\n mesh.receiveShadow = CONFIG.graphics.shadows;\n mesh.matrixAutoUpdate = false;\n mesh.updateMatrix();\n scene.add(mesh);\n return mesh;\n }\n}\n"
|
|
1900
1953
|
},
|
|
1901
1954
|
{
|
|
1902
1955
|
"path": "world/Containers.test.ts",
|
|
@@ -1966,6 +2019,10 @@
|
|
|
1966
2019
|
"path": "world/Redstone.ts",
|
|
1967
2020
|
"content": "// Красный камень, как в Minecraft (упрощённо): источники — рычаг, кнопка, нажимная плита, красный\n// факел, блок красной пыли — питают соседей; провод несёт сигнал 15 и теряет 1 за блок; твёрдый\n// блок с включённым рычагом или кнопкой на нём (или с горящим факелом под ним) «сильно заряжен» и\n// питает своих соседей. Потребители — лампа, двери и люки, TNT; красный факел — инвертор.\n// Поведения блоков — world/behaviours/redstone.ts.\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { DIR6, FACING_DIRS } from \"../registry/meta\";\nimport { fillLevel } from \"./Containers\";\nimport type { ChunkManager } from \"./ChunkManager\";\n\n/** Бит «включено» у рычага и кнопки. */\nexport const ON = 0x8;\n\nconst N6 = [\n [1, 0, 0],\n [-1, 0, 0],\n [0, 1, 0],\n [0, -1, 0],\n [0, 0, 1],\n [0, 0, -1],\n] as const;\n\nlet readers: Set<number> | null = null;\n\n/**\n * Блок читает сигнал (провод, факел, лампа, TNT, двери и люки). Таких будят и через клетку от\n * изменения: рычаг на камне питает факел по ту сторону камня (systems/BlockUpdates).\n */\nexport function readsSignal(id: number): boolean {\n if (!readers) {\n readers = new Set<number>();\n for (const n of [\n \"redstone_wire\",\n \"redstone_torch\",\n \"redstone_torch_off\",\n \"redstone_lamp\",\n \"redstone_lamp_on\",\n \"repeater\",\n \"repeater_on\",\n \"piston\",\n \"sticky_piston\",\n \"powered_rail\",\n \"activator_rail\",\n \"tnt\",\n \"note_block\",\n ])\n if (B[n] !== undefined) readers.add(B[n]!);\n for (const d of BLOCKS)\n if (d.shape === \"door\" || d.shape === \"trapdoor\") readers.add(d.id);\n }\n return readers.has(id);\n}\n\n/** Клетка, к которой прикреплён рычаг/кнопка/факел (мета: 0 — пол, 1..4 — стена). */\nexport function attachedTo(\n x: number,\n y: number,\n z: number,\n meta: number,\n): [number, number, number] {\n const m = meta & 7;\n if (m === 0) return [x, y - 1, z];\n const [dx, dz] = FACING_DIRS[(m - 1) & 3]!;\n return [x + dx, y, z + dz];\n}\n\n/** Сигнал, который блок в (x,y,z) отдаёт соседу (tx,ty,tz) сам, без провода. */\nfunction directPower(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n tx: number,\n ty: number,\n tz: number,\n): number {\n const id = w.getBlock(x, y, z);\n if (id === 0) return 0;\n const meta = w.getMeta(x, y, z);\n if (id === B.redstone_block) return 15;\n if (id === B.sculk_sensor_on) return 15; // сенсор скалка, пока горит после вибрации\n if ((id === B.lever || id === B.stone_button) && meta & ON) return 15;\n if (id === B.stone_pressure_plate && meta > 0) return 15;\n if (id === B.target && meta > 0) return meta & 15; // мишень: сила по близости попадания к центру\n if (id === B.detector_rail && meta & 0x8) return 15; // на нажимных рельсах вагонетка\n if (id === B.repeater_on) {\n // повторитель питает только клетку перед собой\n const [dx, dz] = FACING_DIRS[meta & 3]!;\n return x + dx === tx && y === ty && z + dz === tz ? 15 : 0;\n }\n if (id === B.comparator) {\n // компаратор — своей силой, только вперёд\n const [dx, dz] = FACING_DIRS[meta & 3]!;\n return x + dx === tx && y === ty && z + dz === tz ? comparatorOut(meta) : 0;\n }\n if (id === B.observer && meta & OBSERVER_ON) {\n // наблюдатель — импульс с тыла (напротив лица)\n const [dx, dy, dz] = DIR6[meta & 7]!;\n return x - dx === tx && y - dy === ty && z - dz === tz ? 15 : 0;\n }\n if (id === B.redstone_torch) {\n const [ax, ay, az] = attachedTo(x, y, z, meta);\n return ax === tx && ay === ty && az === tz ? 0 : 15; // свой блок факел не питает\n }\n return 0;\n}\n\n/** Твёрдый блок сильно заряжен: на нём включённый рычаг/кнопка или под ним горит красный факел. */\nfunction strongly(w: ChunkManager, x: number, y: number, z: number): boolean {\n if (!BLOCKS[w.getBlock(x, y, z)]?.solid) return false;\n for (const [dx, dy, dz] of N6) {\n const nx = x + dx,\n ny = y + dy,\n nz = z + dz;\n const id = w.getBlock(nx, ny, nz);\n if (\n (id === B.lever || id === B.stone_button) &&\n w.getMeta(nx, ny, nz) & ON\n ) {\n const [ax, ay, az] = attachedTo(nx, ny, nz, w.getMeta(nx, ny, nz));\n if (ax === x && ay === y && az === z) return true;\n }\n if (id === B.redstone_torch && dy === -1) return true; // факел снизу\n if (id === B.repeater_on || id === B.comparator) {\n // включённый повторитель или компаратор, смотрящий в этот блок\n const m = w.getMeta(nx, ny, nz);\n const [fx, fz] = FACING_DIRS[m & 3]!;\n if (\n (id === B.repeater_on || comparatorOut(m) > 0) &&\n nx + fx === x &&\n ny === y &&\n nz + fz === z\n )\n return true;\n }\n if (id === B.observer) {\n const m = w.getMeta(nx, ny, nz);\n const [fx, fy, fz] = DIR6[m & 7]!;\n if (m & OBSERVER_ON && nx - fx === x && ny - fy === y && nz - fz === z)\n return true;\n }\n }\n return false;\n}\n\n/** Компаратор: режим «вычитание» (иначе сравнение) и сила выхода в битах 3..6 меты. */\nexport const COMPARATOR_SUB = 0x4;\nexport const comparatorOut = (meta: number): number => (meta >> 3) & 15;\n/** Наблюдатель: бит импульса (мета — направление лица DIR6). */\nexport const OBSERVER_ON = 0x8;\n\n/** Сила сигнала 0..15, которую клетка s отдаёт в t: источник, провод, заряженный блок. */\nfunction signalInto(\n w: ChunkManager,\n sx: number,\n sy: number,\n sz: number,\n tx: number,\n ty: number,\n tz: number,\n): number {\n const d = directPower(w, sx, sy, sz, tx, ty, tz);\n if (d > 0) return d;\n const id = w.getBlock(sx, sy, sz);\n if (id === B.redstone_wire) return w.getMeta(sx, sy, sz);\n return BLOCKS[id]?.solid && isPowered(w, sx, sy, sz) ? 15 : 0;\n}\n\n/**\n * Тик компаратора (onTick — каждый симтик: заполненность сундука не будит соседей). Вход сзади —\n * сигнал или заполненность контейнера, сбоку — боковой сигнал. Сравнение: вход, если он не меньше\n * бокового, иначе 0; вычитание: вход − боковой.\n */\nexport function comparatorTick(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n meta: number,\n): void {\n const [dx, dz] = FACING_DIRS[meta & 3]!;\n const bx = x - dx,\n bz = z - dz;\n const box = w.containerAt?.(bx, y, bz);\n const input = box ? fillLevel(box) : signalInto(w, bx, y, bz, x, y, z);\n const side = Math.max(\n signalInto(w, x - dz, y, z + dx, x, y, z),\n signalInto(w, x + dz, y, z - dx, x, y, z),\n );\n const out =\n meta & COMPARATOR_SUB\n ? Math.max(0, input - side)\n : input >= side\n ? input\n : 0;\n if (out !== comparatorOut(meta))\n w.setBlock(x, y, z, B.comparator!, (meta & 7) | (out << 3));\n}\n\n/** Вход повторителя — только клетка позади него: источник, заряженный провод, заряженный блок. */\nexport function repeaterInput(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n meta: number,\n): boolean {\n const [dx, dz] = FACING_DIRS[meta & 3]!;\n const bx = x - dx,\n bz = z - dz;\n if (directPower(w, bx, y, bz, x, y, z) > 0) return true;\n const back = w.getBlock(bx, y, bz);\n if (back === B.redstone_wire) return w.getMeta(bx, y, bz) > 0;\n return !!BLOCKS[back]?.solid && isPowered(w, bx, y, bz);\n}\n\n/** Получает ли клетка сигнал: источник рядом, заряженный провод сбоку/сверху, сильно заряженный блок. */\nexport function isPowered(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n except?: readonly [number, number, number],\n): boolean {\n for (const [dx, dy, dz] of N6) {\n if (except && dx === except[0] && dy === except[1] && dz === except[2])\n continue; // лицо поршня\n const nx = x + dx,\n ny = y + dy,\n nz = z + dz;\n if (directPower(w, nx, ny, nz, x, y, z) > 0) return true;\n // провод питает блок, на котором лежит, и соседей сбоку — но не блок над собой\n if (\n w.getBlock(nx, ny, nz) === B.redstone_wire &&\n w.getMeta(nx, ny, nz) > 0 &&\n dy !== -1\n )\n return true;\n if (strongly(w, nx, ny, nz)) return true;\n }\n return false;\n}\n\n/** Соседи провода по сети: четыре стороны на своём уровне и ступеньки на блок выше и ниже. */\nfunction wireLinks(\n x: number,\n y: number,\n z: number,\n): [number, number, number][] {\n const out: [number, number, number][] = [];\n for (const [dx, dz] of FACING_DIRS)\n for (const dy of [0, 1, -1]) out.push([x + dx, y + dy, z + dz]);\n return out;\n}\n\n/**\n * Пересчитать сеть проводов, в которую входит (x,y,z) или её соседи: 15 у провода рядом с источником\n * (или сильно заряженным блоком), дальше −1 за блок. Мета провода — его сила.\n */\nexport function updateWires(\n w: ChunkManager,\n x: number,\n y: number,\n z: number,\n): void {\n const key = (ax: number, ay: number, az: number) => ax + \",\" + ay + \",\" + az;\n const net = new Map<string, [number, number, number]>();\n const stack: [number, number, number][] = [];\n const add = (ax: number, ay: number, az: number) => {\n if (net.size > 2048 || w.getBlock(ax, ay, az) !== B.redstone_wire) return;\n const k = key(ax, ay, az);\n if (net.has(k)) return;\n net.set(k, [ax, ay, az]);\n stack.push([ax, ay, az]);\n };\n add(x, y, z);\n for (const [dx, dy, dz] of N6) add(x + dx, y + dy, z + dz);\n while (stack.length) {\n const [cx, cy, cz] = stack.pop()!;\n for (const [nx, ny, nz] of wireLinks(cx, cy, cz)) add(nx, ny, nz);\n }\n\n const power = new Map<string, number>();\n const queue: [number, number, number, number][] = [];\n // затравка: сила самого сильного источника рядом (компаратор даёт свою, остальные — 15)\n for (const [k, [cx, cy, cz]] of net) {\n let p = 0;\n for (const [dx, dy, dz] of N6) {\n const nx = cx + dx,\n ny = cy + dy,\n nz = cz + dz;\n p = Math.max(\n p,\n directPower(w, nx, ny, nz, cx, cy, cz),\n strongly(w, nx, ny, nz) ? 15 : 0,\n );\n if (p === 15) break;\n }\n if (p > 0) {\n power.set(k, p);\n queue.push([cx, cy, cz, p]);\n }\n }\n for (let i = 0; i < queue.length; i++) {\n const [cx, cy, cz, p] = queue[i]!;\n if (p <= 1) continue;\n for (const [nx, ny, nz] of wireLinks(cx, cy, cz)) {\n const k = key(nx, ny, nz);\n if (!net.has(k) || (power.get(k) ?? 0) >= p - 1) continue;\n power.set(k, p - 1);\n queue.push([nx, ny, nz, p - 1]);\n }\n }\n for (const [k, [cx, cy, cz]] of net) {\n const p = power.get(k) ?? 0;\n if (w.getMeta(cx, cy, cz) !== p)\n w.setBlock(cx, cy, cz, B.redstone_wire!, p);\n }\n}\n"
|
|
1968
2021
|
},
|
|
2022
|
+
{
|
|
2023
|
+
"path": "world/safeSpot.ts",
|
|
2024
|
+
"content": "// Безопасная точка для игрока: спавн, возрождение, якорь. Верх колонки по `surfaceY` — это верхний\n// ТВЁРДЫЙ блок, а снег-порох, вода и лава не твёрдые: без этой проверки игрок возрождался на дне\n// ямы снега-пороха, замерзал и возрождался там же снова.\nimport { BLOCKS, B } from \"../registry/Blocks\";\n\nexport interface SpotWorld {\n getBlock(x: number, y: number, z: number): number;\n surfaceY(x: number, z: number): number;\n}\n\n/** Клетка, где может быть тело: не твёрдая, не жидкость, не снег-порох, не огонь. */\nfunction roomy(id: number): boolean {\n const def = BLOCKS[id];\n if (!def) return id === 0;\n return (\n !def.solid &&\n !def.liquid &&\n !def.waterlogged &&\n id !== B.powder_snow &&\n id !== B.fire &&\n id !== B.soul_fire\n );\n}\n\n/** Можно ли встать ногами в клетку (x, y, z): твёрдый пол под ней и две клетки на рост. */\nexport function isStandable(\n w: SpotWorld,\n x: number,\n y: number,\n z: number,\n): boolean {\n const floor = BLOCKS[w.getBlock(x, y - 1, z)];\n return (\n !!floor?.solid &&\n floor.id !== B.magma_block &&\n roomy(w.getBlock(x, y, z)) &&\n roomy(w.getBlock(x, y + 1, z))\n );\n}\n\n/**\n * Ближайшая колонка (по кольцам вокруг (x, z), до `radius`), где на верхнем твёрдом блоке можно\n * стоять. Возвращает клетку ног или null — тогда вызывающий оставляет свою точку.\n */\nexport function findSafeSpot(\n w: SpotWorld,\n x: number,\n z: number,\n radius = 8,\n above?: number,\n): { x: number; y: number; z: number } | null {\n for (let r = 0; r <= radius; r++)\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue; // только кольцо\n const cx = x + dx,\n cz = z + dz;\n const y = w.surfaceY(cx, cz) + 1;\n if (above !== undefined && y <= above) continue;\n if (isStandable(w, cx, y, cz)) return { x: cx, y, z: cz };\n }\n return null;\n}\n"
|
|
2025
|
+
},
|
|
1969
2026
|
{
|
|
1970
2027
|
"path": "world/Scaffolding.ts",
|
|
1971
2028
|
"content": "// Расстояние до опоры для клетки строительных лесов, прочитанное из мира: соседей берём из блоков,\n// саму арифметику — из systems/Scaffold (она чистая и покрыта тестами).\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { FACING_DIRS } from \"../registry/meta\";\nimport { NO_SUPPORT, scaffoldDistance } from \"../systems/Scaffold\";\nimport type { ChunkManager } from \"./ChunkManager\";\n\n/** Расстояние до опоры у клетки (сам блок в ней может ещё не стоять — так считает постановка). */\nexport function scaffoldDistanceAt(\n world: ChunkManager,\n x: number,\n y: number,\n z: number,\n): number {\n const scaffold = B.scaffolding;\n const below = world.getBlock(x, y - 1, z);\n const belowSolid = below !== scaffold && (BLOCKS[below]?.solid ?? false);\n const belowScaffold = below === scaffold ? world.getMeta(x, y - 1, z) : null;\n const sides: number[] = [];\n for (const [dx, dz] of FACING_DIRS)\n if (world.getBlock(x + dx, y, z + dz) === scaffold) {\n const d = world.getMeta(x + dx, y, z + dz);\n if (d < NO_SUPPORT) sides.push(d);\n }\n return scaffoldDistance(belowSolid, belowScaffold, sides);\n}\n"
|
|
@@ -2160,7 +2217,7 @@
|
|
|
2160
2217
|
},
|
|
2161
2218
|
{
|
|
2162
2219
|
"path": "module.meta.json",
|
|
2163
|
-
"content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The 'Create world' screen turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Not included yet:
|
|
2220
|
+
"content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The 'Create world' screen turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players, like Minecraft's 'Open to LAN': a player opens their world to the network (by invite code or in the public list, guest game mode, 'commands for everyone' switch), others join from the 'Network game' screen; the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Not included yet: AI-driven villagers and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC, @idosgames/core/realtime)\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.4.0\"\n}\n"
|
|
2164
2221
|
}
|
|
2165
2222
|
]
|
|
2166
2223
|
}
|