@idosgames/mcp 0.1.12 → 0.1.13

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.
@@ -8,12 +8,17 @@
8
8
  },
9
9
  "catalog": {
10
10
  "type": "template",
11
- "summary": "Voxel sandbox (Minecraft-like): walk a first-person world, place and break blocks.",
12
- "description": "A first-person voxel sandbox rendered in Three.js. Players explore a procedurally chunked world, place and break blocks, and move with WASD + mouse-look. Covers any 'build / mine / explore a block world' or open-world sandbox request; the largest of the feature modules.",
11
+ "summary": "Voxel sandbox (Minecraft-like): build a world from a picture + description (with or without AI), then walk it, place and break blocks.",
12
+ "description": "A first-person voxel sandbox rendered in Three.js. 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: terrain shape, biome zones, 14 structure types (castle, village, bridge…), NPC spawn stubs. Worlds autosave to 'My worlds'. Covers any 'build / mine / explore a block world', open-world sandbox or 'generate a world from a picture' request; the largest of the feature modules.",
13
13
  "provides": [
14
14
  "first-person voxel world exploration",
15
15
  "place and break blocks",
16
16
  "procedural chunked terrain",
17
+ "world generation from a picture + description (WorldSpec recipe)",
18
+ "AI world generation via client.ai (feature \"voxel-world\")",
19
+ "AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)",
20
+ "AI recreates landscapes from pictures (terrain sketch, rivers, biomes)",
21
+ "autosave and 'My worlds' (IndexedDB)",
17
22
  "WASD + mouse-look player controller",
18
23
  "3D block rendering (Three.js)"
19
24
  ],
@@ -23,7 +28,9 @@
23
28
  "minecraft",
24
29
  "3d",
25
30
  "first-person",
26
- "three"
31
+ "three",
32
+ "ai",
33
+ "world-generation"
27
34
  ],
28
35
  "media": {
29
36
  "image": "https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png",
@@ -34,29 +41,46 @@
34
41
  "name": "iDos Games",
35
42
  "url": "https://idosgames.com"
36
43
  },
37
- "version": "0.1.0"
44
+ "version": "0.2.0"
38
45
  },
39
46
  "dependencies": {
40
- "@idosgames/module-sdk": "0.2.0",
47
+ "@idosgames/core": "0.14.0",
48
+ "@idosgames/module-sdk": "0.3.0",
41
49
  "three": "0.185.1"
42
50
  },
43
- "contentHash": "4af383a9c61787c8e20a3087c15d194ef743989f0e1583bbb0db6584d9c872d2",
51
+ "contentHash": "cbc5bfa59f373c698f6c11c85ad7255f9092fd05dd38117dc6f57e40a7c3f5b8",
44
52
  "files": [
45
53
  {
46
54
  "path": "agent.ts",
47
- "content": "import type { ModuleAgentApi } from \"@idosgames/module-sdk\";\nimport { Intent } from \"./core/Intents\";\nimport type { Game } from \"./main\";\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 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 return new Promise((resolve) => {\n setTimeout(() => {\n if (button === \"left\") game.input.leftDown = false;\n else game.input.rightDown = false;\n resolve();\n }, ms);\n });\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\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 onGround: p.onGround,\n inWater: p.inWater,\n dead: p.dead,\n },\n world: {\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 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\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 selectSlot: \"Select a hotbar slot: { slot: 1..9 }.\",\n toggleMode: \"Switch between survival and creative.\",\n },\n };\n}\n"
55
+ "content": "import type { ModuleAgentApi } from \"@idosgames/module-sdk\";\nimport { Intent } from \"./core/Intents\";\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 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 return new Promise((resolve) => {\n setTimeout(() => {\n if (button === \"left\") game.input.leftDown = false;\n else game.input.rightDown = false;\n resolve();\n }, ms);\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 onGround: p.onGround,\n inWater: p.inWater,\n dead: p.dead,\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 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 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 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 selectSlot: \"Select a hotbar slot: { slot: 1..9 }.\",\n toggleMode: \"Switch between survival and creative.\",\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"
56
+ },
57
+ {
58
+ "path": "ai/types.ts",
59
+ "content": "// Контракт ИИ-генерации мира для экрана «Создать мир». UI знает только его: реализация поверх\n// client.ai — в worldAi.ts, а standalone-запуск (без платформы) передаёт null — и кнопка ИИ\n// просто не появляется, путь без ИИ работает как всегда.\nimport type { WorldSize, WorldSpec } from \"../worldgen/WorldSpec\";\n\nexport interface WorldAIFeature {\n /** Принимает ли фича картинку. Нет — картинка разбирается только локально. */\n allowImages: boolean;\n maxImageBytes: number | null;\n imageTypes: readonly string[];\n /** Асинхронная задача (опрос) — ответ может идти минуту-две. */\n async: boolean;\n /** «10 GEM» или null — бесплатно. */\n priceLabel: string | null;\n maxPromptChars: number | null;\n /** Сколько картинок фича принимает за раз (1..4). */\n maxImages: number;\n}\n\nexport type WorldAIStatus =\n | { available: true; feature: WorldAIFeature }\n | { available: false; reason: string };\n\nexport interface WorldAIRequest {\n text: string;\n /** Картинки игрока (до 4): образцы объектов, в том числе один объект с разных сторон. */\n images: { dataUrl: string; contentType: string; bytes: number }[];\n size: WorldSize;\n /**\n * Как читать картинки: reference — ИИ сам решает по описанию (объект построить моделью, стиль,\n * место); topdown — первая картинка это карта мира сверху; mood — только настроение.\n */\n readMode: \"reference\" | \"topdown\" | \"mood\";\n}\n\nexport type WorldAIFailure =\n /** Сервер отказал до генерации: ничего не списано. */\n | \"refused\"\n /** Генерация запускалась и упала: списанное возвращено, если refunded. */\n | \"failed\"\n /** Ответ модели — не рецепт. */\n | \"invalid\"\n /** Ответ оборван лимитом токенов (такой движок списывает, не возвращая). */\n | \"truncated\"\n /** Сеть: генерацию можно повторить тем же ключом без второго списания. */\n | \"connection\"\n /** Игрок закрыл экран: задача дорабатывает на сервере, результат можно дождаться позже. */\n | \"cancelled\";\n\nexport type WorldAIResult =\n | { ok: true; spec: WorldSpec; warnings: string[] }\n | { ok: false; kind: WorldAIFailure; message: string; refunded?: boolean };\n\nexport interface WorldAIProgress {\n (percent: number | null, note: string): void;\n}\n\nexport interface WorldAI {\n status(): Promise<WorldAIStatus>;\n generate(\n req: WorldAIRequest,\n opts: { signal: AbortSignal; onProgress: WorldAIProgress },\n ): Promise<WorldAIResult>;\n /** Недождавшаяся задача с прошлого раза, если есть: дождаться её результата. */\n resume(opts: {\n signal: AbortSignal;\n onProgress: WorldAIProgress;\n }): Promise<WorldAIResult | null>;\n}\n"
60
+ },
61
+ {
62
+ "path": "ai/worldAi.test.ts",
63
+ "content": "import { describe, expect, it, vi } from \"vitest\";\nimport type {\n AIGenerationView,\n AIPublicDefinitions,\n IDosGamesClient,\n OperationResult,\n} from \"@idosgames/core\";\nimport { createClientWorldAI } from \"./worldAi\";\nimport { VOXEL_WORLD_SYSTEM_PROMPT, buildUserPrompt } from \"./worldSpecPrompt\";\nimport type { WorldAIRequest } from \"./types\";\nimport {\n BIOMES,\n BUILD_BLOCKS,\n MODEL_SHAPES,\n SIZE_MODES,\n STRUCTURE_TYPES,\n TERRAIN_SHAPES,\n TIMES_OF_DAY,\n TREE_TYPES,\n} from \"../worldgen/WorldSpec\";\n\ntype R<T> = OperationResult<T>;\nconst ok = <T>(data: T): R<T> => ({ ok: true, data });\nconst fail = (\n reason: \"server\" | \"connection\" | \"throttled\" | \"unauthorized\",\n error: string,\n) => ({ ok: false, reason, error }) as R<never>;\n\nconst RECIPE = JSON.stringify({\n name: \"Frost\",\n size: { mode: \"infinite\", widthChunks: 40, depthChunks: 40 },\n zones: [{ biome: \"snow\" }],\n structures: [{ type: \"castle\", x: 0.5, z: 0.5 }],\n});\n\nconst view = (over: Partial<AIGenerationView>): AIGenerationView => ({\n GenerationID: \"g1\",\n Status: \"completed\",\n ...over,\n});\n\nfunction defs(feature: Record<string, unknown> | null): AIPublicDefinitions {\n return {\n Enabled: true,\n Features: feature\n ? { \"voxel-world\": { Modality: \"Text\", ...feature } }\n : {},\n };\n}\n\n/** Поддельный client.ai: ровно та поверхность, что зовёт worldAi. */\nfunction fakeClient(opts: {\n definitions?: AIPublicDefinitions;\n generate?: (() => R<AIGenerationView>)[];\n poll?: (() => R<AIGenerationView>)[];\n}) {\n const generate = [...(opts.generate ?? [])];\n const poll = [...(opts.poll ?? [])];\n const ai = {\n getDefinitions: vi.fn(async () => ok(opts.definitions ?? defs({}))),\n generateText: vi.fn(async (..._args: unknown[]) =>\n (generate.shift() ?? (() => fail(\"server\", \"no more\")))(),\n ),\n getText: vi.fn(async (..._args: unknown[]) =>\n (poll.shift() ?? (() => fail(\"server\", \"no more\")))(),\n ),\n isTerminal: (s: string | null | undefined) =>\n s !== \"pending\" && s !== \"running\",\n };\n const client = { titleID: \"T1\", ai } as unknown as IDosGamesClient;\n return { client, ai };\n}\n\nfunction memoryStorage() {\n const m = new Map<string, string>();\n return {\n getItem: (k: string) => m.get(k) ?? null,\n setItem: (k: string, v: string) => void m.set(k, v),\n removeItem: (k: string) => void m.delete(k),\n map: m,\n };\n}\n\nconst picture = (n: number) => ({\n dataUrl: `data:image/jpeg;base64,AAA${n}`,\n contentType: \"image/jpeg\",\n bytes: 3,\n});\n\nconst request: WorldAIRequest = {\n text: \"остров с самолётом с картинок\",\n size: { mode: \"island\", widthChunks: 16, depthChunks: 16 },\n readMode: \"reference\",\n images: [picture(1)],\n};\n\nconst noSleep = async () => {};\nconst run = (client: IDosGamesClient, storage = memoryStorage()) =>\n createClientWorldAI(client, { storage, sleep: noSleep, pollMs: 0 });\nconst jobOpts = () => ({\n signal: new AbortController().signal,\n onProgress: vi.fn(),\n});\n\ndescribe(\"worldAi.status\", () => {\n it(\"is unavailable when the title has no voxel-world feature\", async () => {\n const s = await run(\n fakeClient({ definitions: defs(null) }).client,\n ).status();\n expect(s).toEqual({\n available: false,\n reason: \"ИИ-генерация миров не настроена\",\n });\n });\n\n it(\"describes the feature: images, async, price\", async () => {\n const { client } = fakeClient({\n definitions: defs({\n AllowImages: true,\n Async: true,\n MaxInputImageBytes: 4194304,\n PriceOptions: {\n gems: {\n Cost: {\n Standard: {\n Entries: [\n { Type: \"VirtualCurrency\", CurrencyID: \"GEM\", Amount: 5 },\n ],\n },\n },\n },\n },\n }),\n });\n const s = await run(client).status();\n expect(s.available && s.feature).toMatchObject({\n allowImages: true,\n async: true,\n maxImageBytes: 4194304,\n priceLabel: \"5 GEM\",\n });\n });\n});\n\ndescribe(\"worldAi.generate\", () => {\n it(\"turns a completed answer into a recipe with the player's size\", async () => {\n const { client, ai } = fakeClient({\n definitions: defs({ AllowImages: true }),\n generate: [\n () => ok(view({ OutputText: \"```json\\n\" + RECIPE + \"\\n```\" })),\n ],\n });\n const res = await run(client).generate(request, jobOpts());\n expect(res.ok).toBe(true);\n if (!res.ok) return;\n expect(res.spec.name).toBe(\"Frost\");\n expect(res.spec.size).toEqual(request.size); // модель просила infinite 40×40 — игрок выбрал остров\n const [key, input] = ai.generateText.mock.calls[0]! as [\n string,\n { Prompt: string; Images?: unknown[] },\n ];\n expect(key).toBe(\"voxel-world\");\n expect(input.Images).toEqual([\n { Base64: request.images[0]!.dataUrl, ContentType: \"image/jpeg\" },\n ]);\n expect(input.Prompt).toMatch(/REFERENCES/);\n });\n\n it(\"sends several pictures — as many as the feature takes\", () => {\n const { client, ai } = fakeClient({\n definitions: defs({ AllowImages: true, MaxInputImages: 2 }),\n generate: [() => ok(view({ OutputText: RECIPE }))],\n });\n return run(client)\n .generate(\n { ...request, images: [picture(1), picture(2), picture(3)] },\n jobOpts(),\n )\n .then(() => {\n const input = ai.generateText.mock.calls[0]![1] as {\n Images?: { Base64: string }[];\n };\n expect(input.Images?.map((i) => i.Base64)).toEqual([\n picture(1).dataUrl,\n picture(2).dataUrl,\n ]);\n });\n });\n\n it(\"sends more than four pictures when the publisher allows it (no platform cap)\", () => {\n const { client, ai } = fakeClient({\n definitions: defs({ AllowImages: true, MaxInputImages: 8 }),\n generate: [() => ok(view({ OutputText: RECIPE }))],\n });\n const six = [1, 2, 3, 4, 5, 6].map(picture);\n return run(client)\n .generate({ ...request, images: six }, jobOpts())\n .then(() => {\n const input = ai.generateText.mock.calls[0]![1] as {\n Images?: { Base64: string }[];\n };\n expect(input.Images?.map((i) => i.Base64)).toEqual(\n six.map((p) => p.dataUrl),\n );\n });\n });\n\n it(\"does not send the picture to a feature that does not take images\", async () => {\n const { client, ai } = fakeClient({\n generate: [() => ok(view({ OutputText: RECIPE }))],\n });\n await run(client).generate(request, jobOpts());\n const input = ai.generateText.mock.calls[0]![1] as { Images?: unknown[] };\n expect(input.Images).toBeUndefined();\n });\n\n it(\"retries a dropped connection ONCE with the same idempotency key\", async () => {\n const { client, ai } = fakeClient({\n generate: [\n () => fail(\"connection\", \"timeout\"),\n () => ok(view({ OutputText: RECIPE })),\n ],\n });\n const res = await run(client).generate(request, jobOpts());\n expect(res.ok).toBe(true);\n expect(ai.generateText).toHaveBeenCalledTimes(2);\n const k1 = (\n ai.generateText.mock.calls[0]![2] as { relatedEntityID: string }\n ).relatedEntityID;\n const k2 = (\n ai.generateText.mock.calls[1]![2] as { relatedEntityID: string }\n ).relatedEntityID;\n expect(k1).toBe(k2);\n });\n\n it(\"polls an async job until it completes and reports progress\", async () => {\n const storage = memoryStorage();\n const { client, ai } = fakeClient({\n generate: [() => ok(view({ Status: \"running\", Progress: 10 }))],\n poll: [\n () => ok(view({ Status: \"running\", Progress: 60 })),\n () => ok(view({ OutputText: RECIPE })),\n ],\n });\n const opts = jobOpts();\n const res = await run(client, storage).generate(request, opts);\n expect(res.ok).toBe(true);\n expect(ai.getText).toHaveBeenCalledTimes(2);\n expect(opts.onProgress).toHaveBeenCalledWith(60, expect.any(String));\n expect(storage.map.size).toBe(0); // дождались — задача больше не висит\n });\n\n it(\"keeps an abandoned job and resumes it later\", async () => {\n const storage = memoryStorage();\n const first = fakeClient({\n generate: [() => ok(view({ Status: \"running\" }))],\n });\n const ctrl = new AbortController();\n ctrl.abort();\n const res = await run(first.client, storage).generate(request, {\n signal: ctrl.signal,\n onProgress: vi.fn(),\n });\n expect(res).toMatchObject({ ok: false, kind: \"cancelled\" });\n expect(storage.map.size).toBe(1);\n\n const later = fakeClient({\n poll: [() => ok(view({ OutputText: RECIPE }))],\n });\n const resumed = await run(later.client, storage).resume(jobOpts());\n expect(resumed?.ok).toBe(true);\n expect(later.ai.getText).toHaveBeenCalledWith(\"g1\");\n expect(storage.map.size).toBe(0);\n });\n\n it(\"has nothing to resume without a saved job\", async () => {\n expect(await run(fakeClient({}).client).resume(jobOpts())).toBeNull();\n });\n\n it(\"reports a failed generation and whether it was refunded\", async () => {\n const { client } = fakeClient({\n generate: [\n () =>\n ok(\n view({\n Status: \"failed\",\n PlayerRefunded: true,\n Error: { Code: \"AI_GENERATION_FAILED\", Message: \"Provider down\" },\n }),\n ),\n ],\n });\n expect(await run(client).generate(request, jobOpts())).toEqual({\n ok: false,\n kind: \"failed\",\n message: \"Provider down\",\n refunded: true,\n });\n });\n\n it(\"recognises an answer cut off by the token limit\", async () => {\n const { client } = fakeClient({\n generate: [() => ok(view({ OutputText: RECIPE.slice(0, 60) }))],\n });\n expect(await run(client).generate(request, jobOpts())).toMatchObject({\n ok: false,\n kind: \"truncated\",\n });\n });\n\n it(\"recognises an answer that is not a recipe\", async () => {\n const { client } = fakeClient({\n generate: [() => ok(view({ OutputText: \"Sorry, I can't.\" }))],\n });\n expect(await run(client).generate(request, jobOpts())).toMatchObject({\n ok: false,\n kind: \"invalid\",\n });\n });\n\n it(\"maps server refusals to player-facing messages\", async () => {\n const { client } = fakeClient({\n generate: [\n () => fail(\"server\", \"AI_LIMIT_REACHED: Daily limit reached.\"),\n ],\n });\n expect(await run(client).generate(request, jobOpts())).toMatchObject({\n ok: false,\n kind: \"refused\",\n message: expect.stringContaining(\"Лимит\"),\n });\n });\n});\n\ndescribe(\"prompts\", () => {\n it(\"the system prompt lists every value the recipe accepts\", () => {\n const values = [\n ...SIZE_MODES,\n ...TERRAIN_SHAPES,\n ...BIOMES,\n ...TREE_TYPES,\n ...STRUCTURE_TYPES,\n ...TIMES_OF_DAY,\n ...MODEL_SHAPES,\n ...BUILD_BLOCKS.filter((b) => b !== \"crafting_table\"),\n ];\n for (const v of values)\n expect(VOXEL_WORLD_SYSTEM_PROMPT, v).toContain(`${v}`);\n });\n\n it(\"the user prompt fits the feature's prompt limit\", () => {\n const long = { ...request, text: \"x\".repeat(5000) };\n expect(buildUserPrompt(long, 1000).length).toBeLessThanOrEqual(1000);\n });\n});\n"
64
+ },
65
+ {
66
+ "path": "ai/worldAi.ts",
67
+ "content": "// ИИ-генерация мира поверх client.ai (@idosgames/core). Правила — по скиллу ai-generation-system:\n//\n// - клиент называет только фичу (\"voxel-world\"): модель, системный промпт и цена — в конфиге тайтла;\n// - отказ (ok: false, \"AI_CODE: сообщение\") и провал генерации (Status: \"failed\") — разные вещи;\n// - у generate нет авто-ретрая транспорта: при обрыве связи повторяем САМИ и с тем же\n// relatedEntityID — сервер вернёт ту же генерацию и второй раз не спишет;\n// - асинхронную задачу опрашиваем сами, пока игрок ждёт; закрыл экран — задача дорабатывает на\n// сервере, её GenerationID сохраняется, и resume() дождётся результата в следующий раз.\n// От @idosgames/core — только типы: сам клиент модулю отдаёт хост (ctx.client), runtime-код core\n// модулю не нужен. Поэтому коды отказов ниже — литералами, зеркалом AIErrorCode из core.\nimport type {\n AIGenerationView,\n AIPublicFeature,\n AITextInput,\n IDosGamesClient,\n OperationResult,\n} from \"@idosgames/core\";\nimport { CONFIG } from \"../config\";\nimport { parseWorldSpec, type WorldSize } from \"../worldgen/WorldSpec\";\nimport type {\n WorldAI,\n WorldAIFeature,\n WorldAIProgress,\n WorldAIRequest,\n WorldAIResult,\n WorldAIStatus,\n} from \"./types\";\nimport { buildUserPrompt } from \"./worldSpecPrompt\";\n\ntype Failure = Extract<OperationResult<unknown>, { ok: false }>;\n\n/** Коды отказов AI-модуля (AIErrorCode в @idosgames/core; сервер — InAppAIErrors). */\nconst AIErrorCode = {\n Disabled: \"AI_DISABLED\",\n FeatureNotFound: \"AI_FEATURE_NOT_FOUND\",\n FeatureDisabled: \"AI_FEATURE_DISABLED\",\n NotAvailableForPlayer: \"AI_NOT_AVAILABLE_FOR_PLAYER\",\n FeatureClosed: \"AI_FEATURE_CLOSED\",\n LimitReached: \"AI_LIMIT_REACHED\",\n Cooldown: \"AI_COOLDOWN\",\n BadInput: \"AI_BAD_INPUT\",\n PromptRejected: \"AI_PROMPT_REJECTED\",\n Unavailable: \"AI_UNAVAILABLE\",\n GenerationNotFound: \"AI_GENERATION_NOT_FOUND\",\n} as const;\n\n/** Код из отказа \"AI_COOLDOWN: …\" (как parseAIErrorCode в core); null — отказ без кода. */\nfunction parseAIErrorCode(error: string | null | undefined): string | null {\n if (!error) return null;\n return /\\b(AI_[A-Z_]+)\\s*:/.exec(error)?.[1] ?? null;\n}\n\n/** Недождавшаяся задача: сохраняется до результата, переживает перезагрузку. */\ninterface PendingJob {\n id: string;\n size: WorldSize;\n}\n\nexport interface WorldAIOptions {\n featureKey?: string;\n pollMs?: number;\n timeoutMs?: number;\n /** localStorage по умолчанию; null — не сохранять задачу между запусками. */\n storage?: Pick<Storage, \"getItem\" | \"setItem\" | \"removeItem\"> | null;\n sleep?: (ms: number, signal: AbortSignal) => Promise<void>;\n}\n\nconst DEFAULT_TYPES = [\"image/png\", \"image/jpeg\", \"image/webp\"];\n\nfunction defaultStorage(): WorldAIOptions[\"storage\"] {\n try {\n return typeof localStorage === \"undefined\" ? null : localStorage;\n } catch {\n return null; // хранилище запрещено (фрейм, настройки браузера)\n }\n}\n\nfunction abortableSleep(ms: number, signal: AbortSignal): Promise<void> {\n return new Promise((resolve) => {\n const t = setTimeout(resolve, ms);\n signal.addEventListener(\"abort\", () => {\n clearTimeout(t);\n resolve();\n });\n });\n}\n\nfunction newKey(): string {\n return typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID()\n : Date.now().toString(36) + \"-\" + Math.random().toString(36).slice(2, 12);\n}\n\n/** «10 GEM» из первой опции цены; null — бесплатно. */\nexport function priceLabel(\n options: AIPublicFeature[\"PriceOptions\"],\n): string | null {\n const first = options ? Object.values(options)[0] : undefined;\n if (!first) return null;\n if (first.Name) return first.Name;\n const parts = (first.Cost?.Standard?.Entries ?? [])\n .map((e) => `${e.Amount ?? \"\"} ${e.CurrencyID ?? e.ItemID ?? \"\"}`.trim())\n .filter(Boolean);\n return parts.length ? parts.join(\" + \") : null;\n}\n\nfunction toFeature(f: AIPublicFeature): WorldAIFeature {\n return {\n allowImages: f.AllowImages === true,\n maxImageBytes: f.MaxInputImageBytes || null,\n imageTypes: f.AllowedImageTypes?.length\n ? f.AllowedImageTypes\n : DEFAULT_TYPES,\n async: f.Async === true,\n priceLabel: priceLabel(f.PriceOptions),\n maxPromptChars: f.MaxPromptChars || null,\n // у фичи по умолчанию одна картинка; потолка платформы нет — сколько разрешил издатель\n maxImages: Math.max(1, f.MaxInputImages || 1),\n };\n}\n\n/** Безопасное сообщение из отказа \"AI_CODE: сообщение\". */\nfunction messageOf(error: string): string {\n const m = /\\bAI_[A-Z_]+\\s*:\\s*([\\s\\S]*)$/.exec(error);\n return (m?.[1] ?? error).trim();\n}\n\n/** Отказ сервера (ничего не сгенерировано и не списано) — в понятный игроку текст. */\nfunction refusal(res: Failure): WorldAIResult {\n if (res.reason === \"connection\")\n return {\n ok: false,\n kind: \"connection\",\n message: \"Нет связи с сервером. Попробуйте ещё раз.\",\n };\n if (res.reason === \"unauthorized\")\n return {\n ok: false,\n kind: \"refused\",\n message: \"Войдите в игру, чтобы строить миры с ИИ.\",\n };\n if (res.reason === \"throttled\")\n return {\n ok: false,\n kind: \"refused\",\n message: \"Генерация уже идёт — дождитесь её.\",\n };\n const code = parseAIErrorCode(res.error);\n switch (code) {\n case AIErrorCode.LimitReached:\n case AIErrorCode.Cooldown:\n return {\n ok: false,\n kind: \"refused\",\n message: \"Лимит ИИ-генераций на сейчас исчерпан. Загляните позже.\",\n };\n case AIErrorCode.PromptRejected:\n return {\n ok: false,\n kind: \"refused\",\n message: \"Описание не прошло проверку. Переформулируйте его.\",\n };\n case AIErrorCode.BadInput:\n return { ok: false, kind: \"refused\", message: messageOf(res.error) };\n case AIErrorCode.FeatureClosed:\n return {\n ok: false,\n kind: \"refused\",\n message: \"ИИ-генерация миров сейчас закрыта.\",\n };\n case AIErrorCode.Unavailable:\n case AIErrorCode.Disabled:\n case AIErrorCode.FeatureNotFound:\n case AIErrorCode.FeatureDisabled:\n case AIErrorCode.NotAvailableForPlayer:\n return { ok: false, kind: \"refused\", message: \"ИИ сейчас недоступен.\" };\n default:\n return {\n ok: false,\n kind: \"refused\",\n message: code\n ? messageOf(res.error)\n : \"Не удалось запустить генерацию.\",\n };\n }\n}\n\nexport function createClientWorldAI(\n client: IDosGamesClient,\n opts: WorldAIOptions = {},\n): WorldAI {\n const featureKey = opts.featureKey ?? CONFIG.ai.featureKey;\n const pollMs = opts.pollMs ?? CONFIG.ai.pollMs;\n const timeoutMs = opts.timeoutMs ?? CONFIG.ai.pollTimeoutMs;\n const storage = opts.storage === undefined ? defaultStorage() : opts.storage;\n const sleep = opts.sleep ?? abortableSleep;\n const pendingKey = `voxelcraft:ai-world:${client.titleID}`;\n let feature: WorldAIFeature | null = null;\n\n const savePending = (job: PendingJob) => {\n try {\n storage?.setItem(pendingKey, JSON.stringify(job));\n } catch {\n /* без хранилища задача просто не переживёт перезагрузку */\n }\n };\n const loadPending = (): PendingJob | null => {\n try {\n const raw = storage?.getItem(pendingKey);\n const job = raw ? (JSON.parse(raw) as Partial<PendingJob>) : null;\n return job && typeof job.id === \"string\" && job.size\n ? (job as PendingJob)\n : null;\n } catch {\n return null;\n }\n };\n const clearPending = () => {\n try {\n storage?.removeItem(pendingKey);\n } catch {\n /* нечего чистить */\n }\n };\n\n async function status(): Promise<WorldAIStatus> {\n const res = await client.ai.getDefinitions();\n if (!res.ok)\n return {\n available: false,\n reason:\n res.reason === \"unauthorized\"\n ? \"войдите, чтобы строить с ИИ\"\n : \"ИИ сейчас недоступен\",\n };\n if (res.data.Enabled === false)\n return { available: false, reason: \"ИИ выключен в этой игре\" };\n const f = res.data.Features?.[featureKey];\n if (!f || (f.Modality && f.Modality !== \"Text\"))\n return { available: false, reason: \"ИИ-генерация миров не настроена\" };\n feature = toFeature(f);\n return { available: true, feature };\n }\n\n /** Опрос асинхронной задачи, пока игрок ждёт. */\n async function waitFor(\n first: AIGenerationView,\n signal: AbortSignal,\n onProgress: WorldAIProgress,\n ): Promise<AIGenerationView | WorldAIResult> {\n const deadline = Date.now() + timeoutMs;\n let view = first;\n while (!client.ai.isTerminal(view.Status)) {\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n if (Date.now() > deadline)\n return {\n ok: false,\n kind: \"connection\",\n message:\n \"ИИ отвечает слишком долго. Откройте «Создать мир» позже — результат дождётся вас.\",\n };\n onProgress(view.Progress ?? null, \"ИИ строит мир…\");\n await sleep(pollMs, signal);\n if (signal.aborted) return { ok: false, kind: \"cancelled\", message: \"\" };\n const next = await client.ai.getText(view.GenerationID);\n if (!next.ok) {\n if (next.reason === \"throttled\" || next.reason === \"connection\")\n continue;\n if (parseAIErrorCode(next.error) === AIErrorCode.GenerationNotFound)\n clearPending();\n return refusal(next);\n }\n view = next.data;\n }\n return view;\n }\n\n /** Довести генерацию до рецепта: дождаться, разобрать, зажать под запрошенный размер. */\n async function settle(\n first: AIGenerationView,\n size: WorldSize,\n signal: AbortSignal,\n onProgress: WorldAIProgress,\n ): Promise<WorldAIResult> {\n let view = first;\n if (!client.ai.isTerminal(view.Status)) {\n savePending({ id: view.GenerationID, size });\n const waited = await waitFor(view, signal, onProgress);\n if (\"ok\" in waited) return waited; // отменено / таймаут / отказ — задача остаётся сохранённой\n view = waited;\n }\n clearPending();\n if (view.Status !== \"completed\")\n return {\n ok: false,\n kind: \"failed\",\n message: view.Error?.Message || \"ИИ не смог построить мир.\",\n refunded: view.PlayerRefunded === true,\n };\n const parsed = parseWorldSpec(view.OutputText ?? \"\");\n if (!parsed.ok)\n return parsed.truncated\n ? {\n ok: false,\n kind: \"truncated\",\n message: \"Ответ ИИ оборван — мир получился слишком длинным.\",\n }\n : {\n ok: false,\n kind: \"invalid\",\n message: \"ИИ ответил не рецептом мира.\",\n };\n // размер выбирал игрок — модель его не меняет\n const sized = parseWorldSpec({ ...parsed.spec, size });\n return sized.ok\n ? { ok: true, spec: sized.spec, warnings: parsed.warnings }\n : { ok: false, kind: \"invalid\", message: \"ИИ ответил не рецептом мира.\" };\n }\n\n async function generate(\n req: WorldAIRequest,\n {\n signal,\n onProgress,\n }: { signal: AbortSignal; onProgress: WorldAIProgress },\n ): Promise<WorldAIResult> {\n if (!feature) {\n const s = await status();\n if (!s.available)\n return { ok: false, kind: \"refused\", message: s.reason };\n }\n const f = feature!; // status() выставил её или вернул отказ выше\n const input: AITextInput = {\n Prompt: buildUserPrompt(req, f.maxPromptChars),\n };\n if (req.images.length && f.allowImages)\n input.Images = req.images\n .slice(0, f.maxImages)\n .map((im) => ({ Base64: im.dataUrl, ContentType: im.contentType }));\n\n // Один ключ на нажатие: повтор после обрыва связи — та же генерация, без второго списания.\n const key = newKey();\n onProgress(null, \"Отправляю запрос ИИ…\");\n let res = await client.ai.generateText(featureKey, input, {\n relatedEntityID: key,\n });\n if (!res.ok && res.reason === \"connection\" && !signal.aborted) {\n onProgress(null, \"Связь прервалась, повторяю…\");\n res = await client.ai.generateText(featureKey, input, {\n relatedEntityID: key,\n });\n }\n if (!res.ok) {\n const code = parseAIErrorCode(res.error);\n if (\n code === AIErrorCode.FeatureNotFound ||\n code === AIErrorCode.FeatureDisabled\n )\n feature = null;\n return refusal(res);\n }\n return settle(res.data, req.size, signal, onProgress);\n }\n\n async function resume({\n signal,\n onProgress,\n }: {\n signal: AbortSignal;\n onProgress: WorldAIProgress;\n }): Promise<WorldAIResult | null> {\n const job = loadPending();\n if (!job) return null;\n onProgress(null, \"Дожидаюсь мира с прошлого раза…\");\n const res = await client.ai.getText(job.id);\n if (!res.ok) {\n if (res.reason === \"connection\" || res.reason === \"throttled\")\n return null; // попробуем в другой раз\n clearPending(); // задачи больше нет (или она чужая) — не спрашивать о ней снова\n return null;\n }\n return settle(res.data, job.size, signal, onProgress);\n }\n\n return { status, generate, resume };\n}\n"
68
+ },
69
+ {
70
+ "path": "ai/worldSpecPrompt.ts",
71
+ "content": "// Промпты ИИ-генерации мира.\n//\n// VOXEL_WORLD_SYSTEM_PROMPT — системный промпт фичи тайтла \"voxel-world\". Он живёт в КОНФИГЕ\n// тайтла (cfg.AI.Features[\"voxel-world\"].Behavior.SystemInstructions), а не в клиенте: клиент\n// системный промпт не задаёт вовсе. Здесь — эталон, версионируемый вместе с генератором: издатель\n// копирует его в конфиг (дашборд или save_ai). Меняется формат WorldSpec — меняется этот текст,\n// и тест держит в нём все допустимые значения.\n//\n// Ответ модели движок в JSON не превращает (JSON-режима нет), а оборванный по лимиту токенов\n// ответ считает успехом и списывает — поэтому промпт требует рецепт без лишнего и с лимитами.\nimport type { WorldAIRequest } from \"./types\";\n\nexport const VOXEL_WORLD_SYSTEM_PROMPT = `You design voxel worlds for a Minecraft-like game and build objects out of coloured blocks. Reply with ONE JSON object — a WorldSpec — and nothing else: no markdown, no comments, no explanations.\n\nThe world is built by a deterministic generator from your recipe. World coordinates x, z, x2, z2 and radius are FRACTIONS of the world (0..1): x=0 west, x=1 east, z=0 north, z=1 south. Limits: at most 8 zones, 20 structures, 6 npcs, 4 models. Omit fields you don't need — every field has a sensible default.\n\nWorldSpec:\n{\n \"version\": 1,\n \"name\": string (<= 40 chars, in the player's language),\n \"description\": string (<= 200 chars),\n \"size\": { \"mode\": \"island\" | \"bounded\" | \"infinite\", \"widthChunks\": 4..48, \"depthChunks\": 4..48 },\n \"terrain\": {\n \"shape\": \"island\" | \"archipelago\" | \"continent\" | \"valley\" | \"mountains\" | \"plateau\" | \"flat\",\n \"baseHeight\": 6..48 (average land height; sea level is 25; the world is 64 blocks high),\n \"amplitude\": 0..30 (hill height), \"roughness\": 0..1, \"waterLevel\": 2..44,\n \"sketch\": { \"rows\": [ \"0012345\", ... ] } — optional TOP-DOWN elevation map that YOU draw: 4..32 rows (north to south) of 4..32 digits (west to east). 0 deep water, 1 shallow water, 2 shore at sea level, 3 lowland, 4 plain, 5 hills, 6 high hills, 7 mountains, 8 high mountains, 9 peaks. The game smooths it and adds natural detail; with a sketch YOU draw the coastline (the island shape is not applied). 16x16 is usually enough.\n },\n \"zones\": [ { \"x\", \"z\", \"radius\": 0.02..1.5,\n \"biome\": \"meadow\" | \"forest\" | \"birch_forest\" | \"taiga\" | \"snow\" | \"desert\" | \"badlands\" | \"beach\" | \"swamp\" | \"rocky\" | \"savanna\" | \"jungle\",\n optional overrides: \"surface\": block, \"subsurface\": block, \"trees\": 0..1, \"treeType\": \"oak\" | \"birch\" | \"spruce\" | \"palm\" | \"dead\" | \"none\", \"grass\": 0..1, \"flowers\": 0..1, \"height\": -20..20 } ],\n \"structures\": [ { \"type\": \"house\" | \"tower\" | \"castle\" | \"village\" | \"wall\" | \"road\" | \"bridge\" | \"ruins\" | \"pond\" | \"rock\" | \"tree_cluster\" | \"pyramid\" | \"well\" | \"farm\" | \"model\" | \"river\",\n \"x\", \"z\", \"size\": \"small\" | \"medium\" | \"large\", \"rotation\": 0 | 90 | 180 | 270,\n \"x2\", \"z2\" (end point — only for wall, road, bridge), optional \"material\": block, \"roof\": block,\n for \"model\": \"model\": \"<id from models>\", \"elevation\": 0..30 (blocks above the ground, e.g. a plane in flight),\n for \"river\": \"path\": [[x, z], ...] instead of x/z — 2..16 points from the source to the mouth; it always flows downhill and cuts its bed through hills; size = width } ],\n \"models\": { \"<id>\": {\n \"size\": [length_x, height_y, width_z] (blocks; <= 64 x 40 x 64),\n \"parts\": [ ... <= 150 shapes, drawn in order: a later shape overwrites an earlier one ... ],\n \"voxels\": [ [x, y, z, block], ... ] (<= 300 single blocks for small details: lights, eyes, logos, numbers)\n } },\n \"npcs\": [ { \"name\", \"role\", \"x\", \"z\", \"persona\": string (<= 200 chars) } ],\n \"palette\": { \"wall\": block, \"roof\": block, \"floor\": block, \"path\": block, \"accent\": block },\n \"ambience\": { \"timeOfDay\": \"dawn\" | \"day\" | \"sunset\" | \"night\" }\n}\n\nMODELS — objects built from blocks (a plane, a car, a ship, a statue, a creature…):\n- Model coordinates are in blocks: x runs from the FRONT (x=0, e.g. the nose) to the back, y is UP (y=0 stands on the ground), z runs from left to right. The model's middle plane is z = (width_z - 1) / 2.\n- Shapes (every shape has \"block\"; \"block\": \"air\" carves a hole — windows, cockpits, arches):\n { \"shape\": \"box\", \"from\": [x,y,z], \"to\": [x,y,z], \"hollow\": true|false }\n { \"shape\": \"cylinder\", \"axis\": \"x\" | \"y\" | \"z\", \"center\": [x,y,z], \"radius\": r, \"length\": n, \"hollow\": true|false } — \"center\" is the middle of the START cap; the cylinder runs n blocks along +axis (negative n: along -axis)\n { \"shape\": \"cone\", same fields as cylinder + \"radius2\": radius at the far end (0 = a point) } — noses, tails, roofs, tree tops\n { \"shape\": \"sphere\", \"center\": [x,y,z], \"radii\": [rx, ry, rz], \"hollow\": true|false } — domes, heads, wheels seen from the side\n { \"shape\": \"line\", \"from\": [x,y,z], \"to\": [x,y,z], \"radius\": 0..3 } — masts, antennas, rods, cables\n \"mirror\": \"x\" | \"z\" on any shape also draws its mirror image across the model's middle — use \"z\" for symmetric left/right halves (wings, wheels, headlights) so you describe them once.\n- Build an object to scale and big enough to show its shape: a car about 16 long, a plane 30..50 long, a ship 30..60 long; keep the height <= 24.\n- Pick blocks by colour: wool for paint (white, red, orange, yellow, green, blue, light_blue, purple, pink, brown, gray, black), glass for windows, black_wool for tyres, gray_wool / stone for metal, planks / dark_planks for wood.\n- When the player sends pictures of an object and asks for it: study EVERY picture (they may show the same object from different angles), get its proportions, silhouette, colours and details right, build it as a model and place it with a \"model\" structure where the description says. Several copies of one model can be placed.\n\nPICTURES can show anything — recreate what they show, flexibly, with every tool you have:\n- a LANDSCAPE → rebuild its layout: imagine the scene seen from above and draw it with \"sketch\" (where the mountains, valleys, lakes, cliffs and coast are), add \"river\"s, choose zones, biomes and surface blocks that match its colours (any block can be a surface: snow caps, red sand, even wool), trees, time of day;\n- an OBJECT (a plane, a car, a ship, a statue, a creature) → build it as a model;\n- a BUILDING or a landmark → a model (or a ready structure when it is a plain house, tower, castle…);\n- a SCENE with several things → combine all of the above.\n\nBlocks: grass, dirt, stone, cobblestone, mossy_cobblestone, sand, red_sand, gravel, clay, snow, ice, sandstone, terracotta, stone_bricks, bricks, planks, dark_planks, oak_log, spruce_log, birch_log, glass, dirt_path, leaves, spruce_leaves, birch_leaves, white_wool, red_wool, orange_wool, yellow_wool, green_wool, blue_wool, light_blue_wool, purple_wool, pink_wool, brown_wool, gray_wool, black_wool.\n\nRules:\n- Keep the size the player asked for.\n- rotation turns a structure around its vertical axis; for buildings it is the door side: 0 north, 90 east, 180 south, 270 west.\n- A bridge needs water under it: use a \"valley\" shape (a river crosses the world north-south near x=0.5) or put it across a pond or a strait.\n- Put structures on land, away from the edges of an island (0.2..0.8), not on top of each other. A village already contains houses, paths and a well.\n- If the first picture should be read as a TOP-DOWN MAP, place zones and structures where they appear in it; the terrain height and ground colours are taken from the picture by the game.\n- If the pictures only set the MOOD, take the climate, colours and atmosphere from them.\n- Match the player's description first; invent pleasant details when it is vague.`;\n\n/** Сколько символов занимает служебная часть пользовательского промпта. */\nconst PROMPT_OVERHEAD = 520;\n\n/**\n * Пользовательский промпт: описание игрока + параметры запроса. Короткий: MaxPromptChars фичи\n * считается по нему, и описание обрезается под лимит, а не отбивается сервером.\n */\nexport function buildUserPrompt(\n req: WorldAIRequest,\n maxPromptChars: number | null,\n): string {\n const room = maxPromptChars\n ? Math.max(40, maxPromptChars - PROMPT_OVERHEAD)\n : 600;\n const text = req.text.trim().slice(0, room);\n const n = req.images.length;\n const picture = !n\n ? \"none\"\n : req.readMode === \"topdown\"\n ? `${n} attached — the FIRST one is a TOP-DOWN MAP of the world`\n : req.readMode === \"mood\"\n ? `${n} attached — use only their MOOD (climate, colours, atmosphere)`\n : `${n} attached — REFERENCES. Decide from the description and the pictures what to recreate: a landscape (rebuild its layout with sketch, rivers, zones), an object (a plane, a car… possibly the same object from different angles — build it as a block model with its real proportions and colours and place it), a building, or a whole scene`;\n const s = req.size;\n return [\n \"Design a WorldSpec. Answer with JSON only.\",\n `Description: ${text || \"(none — invent a pleasant world)\"}`,\n `Size: mode=${s.mode}, widthChunks=${s.widthChunks}, depthChunks=${s.depthChunks} (keep it).`,\n `Pictures: ${picture}.`,\n ].join(\"\\n\");\n}\n"
48
72
  },
49
73
  {
50
74
  "path": "config.ts",
51
- "content": "// Единый конфиг игры. Все тюнинги — здесь, логика магических чисел не содержит.\nexport const CONFIG = {\n chunk: { sx: 16, sy: 64, sz: 16 }, // размер чанка в блоках\n renderDistance: 5, // радиус в чанках\n unloadDistance: 7, // дальше этого — выгрузка\n genBudgetPerFrame: 2, // генераций данных чанков за кадр\n meshBudgetPerFrame: 2, // пересборок мешей за кадр\n\n tps: 20, // тиков симуляции в секунду\n maxCatchupTicks: 4, // защита от «спирали смерти» после сворачивания вкладки\n\n world: {\n seed: 1337,\n waterLevel: 25, // уровень моря (y)\n baseHeight: 31,\n hillAmp: 14, // амплитуда холмов\n },\n\n player: {\n width: 0.6,\n height: 1.8,\n eye: 1.62,\n walkSpeed: 4.3,\n runSpeed: 6.5,\n flySpeed: 11,\n jumpSpeed: 8.2,\n gravity: 24,\n swimSpeed: 2.4,\n swimUp: 4.0,\n waterDrag: 0.6,\n reach: 5, // дистанция взаимодействия с блоками\n breakHealDelay: 1.2, // сек простоя, прежде чем урон блока начнёт заживать\n breakHealRate: 0.6, // сек прогресса, восстанавливаемого за секунду простоя\n maxHealth: 20, // 10 сердечек\n fallSafe: 3.5, // блоков падения без урона\n attackDamage: 1, // урон кулаком\n attackCooldown: 0.35, // сек\n\n // Голод (модель Minecraft: еда + сатурация + накопитель «истощения»)\n maxFood: 20, // 10 «окорочков»\n sprintFood: 6, // ниже — нельзя бежать\n exhaustionPerFood: 4.0, // истощения на -1 очко еды/сатурации\n exhaustIdle: 0.02, // пассивное истощение в секунду (стоя)\n exhaustWalk: 0.04, // истощение на блок ходьбы\n exhaustSprint: 0.08, // истощение на блок бега\n exhaustJump: 0.2, // за прыжок\n exhaustBreak: 0.06, // за сломанный блок\n exhaustAttack: 0.1, // за удар\n exhaustRegen: 6.0, // за восстановленное сердце (регенерация «стоит» еды)\n regenFoodThreshold: 18, // еда >= этого → лечит HP\n regenInterval: 3.5, // сек на +1 HP\n starveInterval: 4.0, // сек на -1 HP при нулевой еде\n eatTime: 1.4, // сек удержания ПКМ, чтобы съесть\n },\n\n drops: {\n magnetRadius: 1.9,\n pickupRadius: 0.6,\n magnetPull: 14,\n gravity: 18,\n bounce: 0.3,\n friction: 0.6,\n maxCount: 60,\n mergeRadius: 0.6,\n scale: 0.25,\n despawnTime: 300, // сек\n },\n\n mobs: {\n targetPopulation: 7, // куриц вокруг игрока\n spawnRadius: [10, 24], // мин/макс дистанция спавна\n despawnRadius: 48,\n },\n\n water: {\n tickEvery: 5, // каждый N-й симтик (20/5 = 4 Гц)\n cellBudget: 400, // макс клеток за водный тик\n maxLevel: 8,\n minFlowLevel: 2, // уровень 2 останавливает растекание (~7 блоков)\n },\n\n dayNight: {\n dayLength: 30,\n nightLength: 30, // секунд\n },\n\n // Задел под этап 2 (мультиплеер «звезда с хостом»). В MVP не используется.\n net: {\n maxPlayers: 8,\n snapshotRateHz: 15, // рассылка позиций unreliable-каналом\n interpBufferMs: 120,\n timeoutMs: 8000,\n protocolVersion: 1,\n },\n\n saveVersion: 1,\n};\n"
75
+ "content": "// Единый конфиг игры. Все тюнинги — здесь, логика магических чисел не содержит.\nexport const CONFIG = {\n chunk: { sx: 16, sy: 64, sz: 16 }, // размер чанка в блоках\n renderDistance: 5, // радиус в чанках\n unloadDistance: 7, // дальше этого — выгрузка\n genBudgetPerFrame: 2, // генераций данных чанков за кадр\n meshBudgetPerFrame: 2, // пересборок мешей за кадр\n\n tps: 20, // тиков симуляции в секунду\n maxCatchupTicks: 4, // защита от «спирали смерти» после сворачивания вкладки\n\n world: {\n seed: 1337,\n waterLevel: 25, // уровень моря (y)\n baseHeight: 31,\n hillAmp: 14, // амплитуда холмов\n },\n\n player: {\n width: 0.6,\n height: 1.8,\n eye: 1.62,\n walkSpeed: 4.3,\n runSpeed: 6.5,\n flySpeed: 11,\n jumpSpeed: 8.2,\n gravity: 24,\n swimSpeed: 2.4,\n swimUp: 4.0,\n waterDrag: 0.6,\n reach: 5, // дистанция взаимодействия с блоками\n breakHealDelay: 1.2, // сек простоя, прежде чем урон блока начнёт заживать\n breakHealRate: 0.6, // сек прогресса, восстанавливаемого за секунду простоя\n maxHealth: 20, // 10 сердечек\n fallSafe: 3.5, // блоков падения без урона\n attackDamage: 1, // урон кулаком\n attackCooldown: 0.35, // сек\n swingTime: 0.3, // сек на замах руки; он же период замахов, пока ЛКМ зажата по блоку\n\n // Голод (модель Minecraft: еда + сатурация + накопитель «истощения»)\n maxFood: 20, // 10 «окорочков»\n sprintFood: 6, // ниже — нельзя бежать\n exhaustionPerFood: 4.0, // истощения на -1 очко еды/сатурации\n exhaustIdle: 0.02, // пассивное истощение в секунду (стоя)\n exhaustWalk: 0.04, // истощение на блок ходьбы\n exhaustSprint: 0.08, // истощение на блок бега\n exhaustJump: 0.2, // за прыжок\n exhaustBreak: 0.06, // за сломанный блок\n exhaustAttack: 0.1, // за удар\n exhaustRegen: 6.0, // за восстановленное сердце (регенерация «стоит» еды)\n regenFoodThreshold: 18, // еда >= этого → лечит HP\n regenInterval: 3.5, // сек на +1 HP\n starveInterval: 4.0, // сек на -1 HP при нулевой еде\n eatTime: 1.4, // сек удержания ПКМ, чтобы съесть\n },\n\n drops: {\n magnetRadius: 1.9,\n pickupRadius: 0.6,\n magnetPull: 14,\n gravity: 18,\n bounce: 0.3,\n friction: 0.6,\n maxCount: 60,\n mergeRadius: 0.6,\n scale: 0.25,\n despawnTime: 300, // сек\n },\n\n mobs: {\n targetPopulation: 7, // куриц вокруг игрока\n spawnRadius: [10, 24], // мин/макс дистанция спавна\n despawnRadius: 48,\n },\n\n water: {\n tickEvery: 5, // каждый N-й симтик (20/5 = 4 Гц)\n cellBudget: 400, // макс клеток за водный тик\n maxLevel: 8,\n minFlowLevel: 2, // уровень 2 останавливает растекание (~7 блоков)\n },\n\n dayNight: {\n // секунд; цикл 20 минут, как в Minecraft. День включает рассвет, ночь — сумерки.\n dayLength: 720,\n nightLength: 480,\n },\n\n // Графика. Тени и AO — главные слагаемые «казуальной» картинки; на слабых устройствах их можно\n // выключить здесь, игровая логика от них не зависит.\n graphics: {\n shadows: true,\n shadowMapSize: 2048,\n shadowDistance: 40, // полуразмер области теней вокруг игрока, блоков\n shadowSoftness: 6, // радиус PCF-размытия края тени, текселей\n shadowStrength: 0.75, // 1 — тень без подсветки; меньше — светлее и мягче\n ao: true, // вершинное ambient occlusion: мягкие контактные тени в стыках блоков\n exposure: 1.0,\n },\n\n // Задел под этап 2 (мультиплеер «звезда с хостом»). В MVP не используется.\n // ИИ-генерация миров (client.ai). Фичу с этим ключом издатель настраивает в конфиге тайтла.\n ai: {\n featureKey: \"voxel-world\",\n pollMs: 4000, // опрос асинхронной задачи — каждый опрос платит издатель\n pollTimeoutMs: 10 * 60_000,\n },\n\n net: {\n maxPlayers: 8,\n snapshotRateHz: 15, // рассылка позиций unreliable-каналом\n interpBufferMs: 120,\n timeoutMs: 8000,\n protocolVersion: 1,\n },\n\n // 2: в сейве есть `world` — источник мира (сид или рецепт). Сейвы v1 грузятся как мир по сиду.\n saveVersion: 2,\n};\n"
52
76
  },
53
77
  {
54
78
  "path": "core/EventBus.ts",
55
- "content": "// Событийная шина — единственный канал связи между системами.\n// В этапе 2 (мультиплеер) сетевой слой станет просто ещё одним подписчиком:\n// у хоста события уходят в сеть как дельты, у клиента входящие дельты эмитятся сюда же.\n//\n// Основные события:\n// blockChanged {x,y,z,id,meta,prevId} — любое изменение блока (самодостаточно для репликации)\n// blockBreak {x,y,z,id,byPlayer} — блок сломан (уже после blockChanged)\n// blockPlace {x,y,z,id}\n// itemPickup {item,count}\n// playerHurt {amount,health,cause}\n// mobHurt {entity,amount}\n// mobDied {entity}\n// craft {item,count}\n// tick {n} — каждый симуляционный тик (20 TPS)\n// invChanged {} — инвентарь игрока изменился (HUD перерисовать)\n\n// Полезная нагрузка событий намеренно динамическая (разные события несут разные\n// поля-мешки); подписчик получает нетипизированный data — локальный any здесь оправдан.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype EventHandler = (data?: any) => void;\n\nclass EventBus {\n private _map: Map<string, Set<EventHandler>>;\n constructor() {\n this._map = new Map();\n }\n on(name: string, fn: EventHandler): () => void {\n if (!this._map.has(name)) this._map.set(name, new Set());\n this._map.get(name)!.add(fn);\n return () => this.off(name, fn);\n }\n off(name: string, fn: EventHandler): void {\n this._map.get(name)?.delete(fn);\n }\n emit(name: string, data?: unknown): void {\n const set = this._map.get(name);\n if (!set) return;\n for (const fn of set) fn(data);\n }\n}\n\nexport const events = new EventBus();\n"
79
+ "content": "// Событийная шина — единственный канал связи между системами.\n// В этапе 2 (мультиплеер) сетевой слой станет просто ещё одним подписчиком:\n// у хоста события уходят в сеть как дельты, у клиента входящие дельты эмитятся сюда же.\n//\n// Основные события:\n// blockChanged {x,y,z,id,meta,prevId} — любое изменение блока (самодостаточно для репликации)\n// blockBreak {x,y,z,id,byPlayer} — блок сломан (уже после blockChanged)\n// blockPlace {x,y,z,id}\n// itemPickup {item,count}\n// playerHurt {amount,health,cause}\n// mobHurt {entity,amount}\n// mobDied {entity}\n// craft {item,count}\n// tick {n} — каждый симуляционный тик (20 TPS)\n// invChanged {} — инвентарь игрока изменился (HUD перерисовать)\n\n// Полезная нагрузка событий намеренно динамическая (разные события несут разные\n// поля-мешки); подписчик получает нетипизированный data — локальный any здесь оправдан.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype EventHandler = (data?: any) => void;\n\nclass EventBus {\n private _map: Map<string, Set<EventHandler>>;\n constructor() {\n this._map = new Map();\n }\n on(name: string, fn: EventHandler): () => void {\n if (!this._map.has(name)) this._map.set(name, new Set());\n this._map.get(name)!.add(fn);\n return () => this.off(name, fn);\n }\n off(name: string, fn: EventHandler): void {\n this._map.get(name)?.delete(fn);\n }\n emit(name: string, data?: unknown): void {\n const set = this._map.get(name);\n if (!set) return;\n for (const fn of set) fn(data);\n }\n /** Снять ВСЕ подписки. Зовёт Game.destroy(): шина — синглтон модуля и переживает игру. */\n clear(): void {\n this._map.clear();\n }\n}\n\nexport const events = new EventBus();\n"
56
80
  },
57
81
  {
58
82
  "path": "core/GameLoop.ts",
59
- "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 private _frames: number;\n private _fpsTime: number;\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._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 requestAnimationFrame(step);\n };\n requestAnimationFrame(step);\n }\n\n stop() {\n this.running = false;\n }\n}\n"
83
+ "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 private _frames: number;\n private _fpsTime: number;\n /** id запланированного кадра: stop() обязан его отменить, см. там. */\n private _raf = 0;\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._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 this._raf = requestAnimationFrame(step);\n };\n this._raf = requestAnimationFrame(step);\n }\n\n stop() {\n this.running = false;\n // Уже запланированный кадр отменяем явно: если start() придёт раньше, чем он сработает\n // (suspend+activate в одном кадре), он увидел бы running=true и продолжил бы СВОЮ цепочку\n // рядом с новой — симуляция пошла бы вдвое быстрее.\n cancelAnimationFrame(this._raf);\n }\n}\n"
60
84
  },
61
85
  {
62
86
  "path": "core/Intents.ts",
@@ -68,19 +92,19 @@
68
92
  },
69
93
  {
70
94
  "path": "entities/EntityManager.ts",
71
- "content": "// Менеджер сущностей: мобы + дроп-предметы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Census поддерживает популяцию\n// куриц вокруг игрока. Игрок в MVP хранится отдельно (Player), но интерфейс\n// сущностей совместим — в этапе 2 удалённые игроки станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\n// Форма сохранённой сущности (выход serialize() у Mob/ItemDrop) — дискриминируется по `kind`.\ntype EntitySave =\n | { kind: \"mob\"; type: string; pos: [number, number, number]; hp: number }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n };\n\nexport class EntityManager {\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Mob | ItemDrop>;\n _nextId: number;\n _spawnTimer: number;\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(); // id -> Mob|ItemDrop\n this._nextId = 1;\n this._spawnTimer = 0;\n\n events.on(\"mobDied\", ({ entity }) => {\n for (const d of entity.def.drops)\n if (Math.random() <= d.chance)\n this.spawnDrop(\n entity.pos.x,\n entity.pos.y + 0.3,\n entity.pos.z,\n d.item,\n d.n,\n );\n });\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n ): ItemDrop {\n // слияние с ближайшим таким же дропом\n for (const e of this.entities.values()) {\n if (\n e.kind === \"drop\" &&\n e.item === item &&\n !e.dead &&\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\",\n );\n if (drops.length >= CONFIG.drops.maxCount) {\n drops.sort((a, b) => b.age - a.age)[0]!.dead = true;\n }\n const drop = new ItemDrop(this._nextId++, this.atlas, x, y, z, item, count);\n this.entities.set(drop.id, drop);\n this.scene.add(drop.mesh);\n return drop;\n }\n\n spawnMob(type: string, x: number, y: number, z: number): Mob {\n const mob = new Mob(this._nextId++, MOBS[type]!, x, y, z);\n this.entities.set(mob.id, mob);\n this.scene.add(mob.mesh);\n return mob;\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) e.hurt(damage, fromPos);\n }\n\n tick(dt: number): void {\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 e.tick(dt, this.world);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0)\n events.emit(\"mobDied\", { entity: e });\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Поддержание популяции куриц вокруг игрока. */\n _census(dt: number): void {\n this._spawnTimer -= dt;\n if (this._spawnTimer > 0) return;\n this._spawnTimer = 1.5;\n\n const p = this.player.pos;\n let count = 0;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\") continue;\n const d = e.pos.distanceTo(p);\n if (d > CONFIG.mobs.despawnRadius) {\n e.dead = true;\n continue;\n }\n count++;\n }\n if (count >= CONFIG.mobs.targetPopulation) return;\n\n // одна попытка за вызов: случайная точка в кольце вокруг игрока\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\n const ang = Math.random() * Math.PI * 2;\n const r = rMin! + Math.random() * (rMax! - rMin!);\n const x = Math.floor(p.x + Math.cos(ang) * r);\n const z = Math.floor(p.z + Math.sin(ang) * r);\n const y = this.world.surfaceY(x, z);\n if (\n this.world.getBlock(x, y, z) === B.grass &&\n this.world.getBlock(x, y + 1, z) === B.air &&\n this.world.getBlock(x, y + 2, z) === B.air\n ) {\n this.spawnMob(\"chicken\", x + 0.5, y + 1.01, z + 0.5);\n }\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) continue;\n const { w, h } = e.def.box;\n const min = { x: e.pos.x - w / 2, y: e.pos.y, z: e.pos.z - w / 2 };\n const max = { x: e.pos.x + w / 2, y: e.pos.y + h, z: e.pos.z + w / 2 };\n let t0 = 0,\n t1 = bestT,\n ok = true;\n for (const ax of [\"x\", \"y\", \"z\"] as const) {\n const d = dir[ax],\n o = origin[ax];\n if (Math.abs(d) < 1e-9) {\n if (o < min[ax] || o > max[ax]) {\n ok = false;\n break;\n }\n continue;\n }\n let ta = (min[ax] - o) / d,\n tb = (max[ax] - o) / d;\n if (ta > tb) [ta, tb] = [tb, ta];\n t0 = Math.max(t0, ta);\n t1 = Math.min(t1, tb);\n if (t0 > t1) {\n ok = false;\n break;\n }\n }\n if (ok && t0 < bestT) {\n bestT = t0;\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()) e.updateVisual(alpha, time);\n }\n\n clear(): void {\n for (const e of this.entities.values()) e.dispose(this.scene);\n this.entities.clear();\n }\n\n serialize() {\n return [...this.entities.values()].map((e) => e.serialize());\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]);\n m.hp = d.hp;\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 );\n if (drop) drop.vel.set(0, 0, 0);\n }\n }\n }\n}\n"
95
+ "content": "// Менеджер сущностей: мобы + дроп-предметы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Census поддерживает популяцию\n// куриц вокруг игрока. Игрок в MVP хранится отдельно (Player), но интерфейс\n// сущностей совместим — в этапе 2 удалённые игроки станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { Mob } from \"./Mob\";\nimport { ItemDrop } from \"./ItemDrop\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Player } from \"../player/Player\";\nimport type { Inventory } from \"../systems/Inventory\";\n\n// Форма сохранённой сущности (выход serialize() у Mob/ItemDrop) — дискриминируется по `kind`.\ntype EntitySave =\n | { kind: \"mob\"; type: string; pos: [number, number, number]; hp: number }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n };\n\nexport class EntityManager {\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Mob | ItemDrop>;\n _nextId: number;\n _spawnTimer: number;\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(); // id -> Mob|ItemDrop\n this._nextId = 1;\n this._spawnTimer = 0;\n\n events.on(\"mobDied\", ({ entity }) => {\n for (const d of entity.def.drops)\n if (Math.random() <= d.chance)\n this.spawnDrop(\n entity.pos.x,\n entity.pos.y + 0.3,\n entity.pos.z,\n d.item,\n d.n,\n );\n });\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n ): ItemDrop {\n // слияние с ближайшим таким же дропом\n for (const e of this.entities.values()) {\n if (\n e.kind === \"drop\" &&\n e.item === item &&\n !e.dead &&\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 // уже помеченные на удаление не считаем — иначе повторно «убивали» бы тот же дроп,\n // а лимит расползался бы\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 }\n const drop = new ItemDrop(this._nextId++, this.atlas, x, y, z, item, count);\n this.entities.set(drop.id, drop);\n this.scene.add(drop.mesh);\n return drop;\n }\n\n spawnMob(type: string, x: number, y: number, z: number): Mob {\n const mob = new Mob(this._nextId++, MOBS[type]!, x, y, z);\n this.entities.set(mob.id, mob);\n this.scene.add(mob.mesh);\n return mob;\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) e.hurt(damage, fromPos);\n }\n\n tick(dt: number): void {\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 e.tick(dt, this.world);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0)\n events.emit(\"mobDied\", { entity: e });\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Поддержание популяции куриц вокруг игрока. */\n _census(dt: number): void {\n this._spawnTimer -= dt;\n if (this._spawnTimer > 0) return;\n this._spawnTimer = 1.5;\n\n const p = this.player.pos;\n let count = 0;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\") continue;\n const d = e.pos.distanceTo(p);\n if (d > CONFIG.mobs.despawnRadius) {\n e.dead = true;\n continue;\n }\n count++;\n }\n if (count >= CONFIG.mobs.targetPopulation) return;\n\n // одна попытка за вызов: случайная точка в кольце вокруг игрока\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\n const ang = Math.random() * Math.PI * 2;\n const r = rMin! + Math.random() * (rMax! - rMin!);\n const x = Math.floor(p.x + Math.cos(ang) * r);\n const z = Math.floor(p.z + Math.sin(ang) * r);\n const y = this.world.surfaceY(x, z);\n if (\n this.world.getBlock(x, y, z) === B.grass &&\n this.world.getBlock(x, y + 1, z) === B.air &&\n this.world.getBlock(x, y + 2, z) === B.air\n ) {\n this.spawnMob(\"chicken\", x + 0.5, y + 1.01, z + 0.5);\n }\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) continue;\n const { w, h } = e.def.box;\n const min = { x: e.pos.x - w / 2, y: e.pos.y, z: e.pos.z - w / 2 };\n const max = { x: e.pos.x + w / 2, y: e.pos.y + h, z: e.pos.z + w / 2 };\n let t0 = 0,\n t1 = bestT,\n ok = true;\n for (const ax of [\"x\", \"y\", \"z\"] as const) {\n const d = dir[ax],\n o = origin[ax];\n if (Math.abs(d) < 1e-9) {\n if (o < min[ax] || o > max[ax]) {\n ok = false;\n break;\n }\n continue;\n }\n let ta = (min[ax] - o) / d,\n tb = (max[ax] - o) / d;\n if (ta > tb) [ta, tb] = [tb, ta];\n t0 = Math.max(t0, ta);\n t1 = Math.min(t1, tb);\n if (t0 > t1) {\n ok = false;\n break;\n }\n }\n if (ok && t0 < bestT) {\n bestT = t0;\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()) e.updateVisual(alpha, time);\n }\n\n clear(): void {\n for (const e of this.entities.values()) e.dispose(this.scene);\n this.entities.clear();\n }\n\n serialize() {\n return [...this.entities.values()].map((e) => e.serialize());\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]);\n m.hp = d.hp;\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 );\n if (drop) drop.vel.set(0, 0, 0);\n }\n }\n }\n}\n"
72
96
  },
73
97
  {
74
98
  "path": "entities/ItemDrop.ts",
75
- "content": "// Дроп-предмет: мини-модель блока (или плоская иконка) с физикой —\n// подброс, гравитация, отскок, трение, медленное вращение и «магнит» к игроку.\n// Физика на тике (20 TPS), позиция для рендера интерполируется.\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 { 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 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]!.cross) {\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.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 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 if (dist < D.pickupRadius && this.age > 0.5) {\n const left = 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 && this.age > 0.5) {\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 };\n }\n}\n"
99
+ "content": "// Дроп-предмет: мини-модель блока (или плоская иконка) с физикой —\n// подброс, гравитация, отскок, трение, медленное вращение и «магнит» к игроку.\n// Физика на тике (20 TPS), позиция для рендера интерполируется.\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 { 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 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]!.cross) {\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 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 if (dist < D.pickupRadius && this.age > 0.5) {\n const left = 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 && this.age > 0.5) {\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 };\n }\n}\n"
76
100
  },
77
101
  {
78
102
  "path": "entities/Mob.ts",
79
- "content": "// Универсальный моб: риг собирается из parts-описания реестра (BoxGeometry),\n// ИИ — простое блуждание с испугом, анимации процедурные (ноги/голова/крылья).\n// Добавление свиньи/зомби = запись в Mobs.js, этот код не меняется.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { MobDef } from \"../types\";\n\nconst PX = 1 / 16; // части описаны в пикселях: 16 px = 1 блок\n\nexport class Mob {\n id: number;\n kind: \"mob\";\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: boolean;\n dead: boolean;\n _state: \"idle\" | \"walk\" | \"flee\";\n _stateTime: number;\n _animTime: number;\n _flash: number;\n group: THREE.Group;\n parts: Record<string, THREE.Group>;\n _materials: THREE.MeshLambertMaterial[];\n mesh: THREE.Object3D;\n\n constructor(id: number, def: MobDef, x: number, y: number, z: number) {\n this.id = id;\n this.kind = \"mob\";\n this.def = def;\n this.hp = def.hp;\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.onGround = false;\n this.dead = false;\n this._state = \"idle\";\n this._stateTime = 1 + Math.random() * 2;\n this._animTime = 0;\n this._flash = 0; // красная вспышка после удара\n\n // риг: каждая часть — Group в точке pivot, бокс смещён относительно pivot\n this.group = new THREE.Group();\n this.parts = {};\n this._materials = [];\n for (const p of def.parts) {\n const mat = new THREE.MeshLambertMaterial({ color: p.color });\n this._materials.push(mat);\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 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 pivot.add(mesh);\n this.group.add(pivot);\n if (p.anim) this.parts[p.anim] = pivot;\n }\n this.mesh = this.group; // единообразие с ItemDrop\n }\n\n tick(dt: number, world: ChunkManager) {\n this.prevPos.copy(this.pos);\n if (this._flash > 0) this._flash -= dt;\n\n // ---- ИИ: цепочка состояний idle → walk → idle, flee после удара ----\n this._stateTime -= dt;\n if (this._stateTime <= 0) {\n if (this._state === \"walk\" || this._state === \"flee\") {\n this._state = \"idle\";\n this._stateTime = 1 + Math.random() * 3;\n } else {\n this._state = \"walk\";\n this._targetYaw = Math.random() * Math.PI * 2;\n this._stateTime = 1.5 + Math.random() * 2.5;\n }\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 speed =\n this._state === \"flee\"\n ? this.def.fleeSpeed\n : this._state === \"walk\"\n ? this.def.walkSpeed\n : 0;\n const wishX = -Math.sin(this.yaw) * speed;\n const wishZ = -Math.cos(this.yaw) * speed;\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 this.vel.y -= 22 * dt;\n\n // вода: всплывает\n const inWater =\n world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n ) === 11;\n if (inWater) {\n this.vel.y = Math.max(this.vel.y, 1.5);\n }\n\n this._move(dt, world);\n\n // упёрся в стену на ходу — подпрыгнуть (курицы умеют на 1 блок)\n if (\n speed > 0 &&\n this.onGround &&\n Math.hypot(this.vel.x, this.vel.z) < speed * 0.3\n )\n this.vel.y = 7;\n\n this._animTime +=\n dt * (speed > 0 ? Math.hypot(this.vel.x, this.vel.z) * 3 : 0);\n }\n\n _move(dt: number, world: ChunkManager) {\n const { w, h } = this.def.box;\n const half = w / 2,\n eps = 0.001;\n const collides = (px: number, py: number, pz: number) => {\n for (let by = Math.floor(py); by <= Math.floor(py + h - eps); by++)\n for (\n let bx = Math.floor(px - half);\n bx <= Math.floor(px + half - eps);\n bx++\n )\n for (\n let bz = Math.floor(pz - half);\n bz <= Math.floor(pz + half - eps);\n bz++\n )\n if (world.isSolid(bx, by, bz)) return true;\n return false;\n };\n\n // Y\n const ny = this.pos.y + this.vel.y * dt;\n if (collides(this.pos.x, ny, this.pos.z)) {\n if (this.vel.y <= 0) {\n this.pos.y = Math.floor(ny) + 1;\n this.onGround = true;\n } else this.pos.y = Math.floor(ny + h) - h - eps;\n this.vel.y = 0;\n } else {\n this.pos.y = ny;\n this.onGround = false;\n }\n // X\n const nx = this.pos.x + this.vel.x * dt;\n if (!collides(nx, this.pos.y, this.pos.z)) this.pos.x = nx;\n else this.vel.x = 0;\n // Z\n const nz = this.pos.z + this.vel.z * dt;\n if (!collides(this.pos.x, this.pos.y, nz)) this.pos.z = nz;\n else this.vel.z = 0;\n }\n\n /** Удар: урон, отброс от атакующего, красная вспышка, паника. */\n hurt(damage: number, fromPos: THREE.Vector3) {\n this.hp -= damage;\n this._flash = 0.4;\n const away = this.pos.clone().sub(fromPos).setY(0).normalize();\n this.vel.add(away.multiplyScalar(6)).setY(5);\n this._state = \"flee\";\n this._targetYaw = Math.atan2(-away.x, -away.z) + Math.PI; // бежит прочь\n this._stateTime = 1.2;\n events.emit(\"mobHurt\", { entity: this, amount: damage });\n if (this.hp <= 0) this.dead = true;\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.group.position.copy(p);\n this.group.rotation.y = this.yaw;\n\n // анимации: шаг ногами, покачивание головы, крылья при падении\n const swing = Math.sin(this._animTime * 6) * 0.7;\n if (this.parts.legL) this.parts.legL.rotation.x = swing;\n if (this.parts.legR) this.parts.legR.rotation.x = -swing;\n if (this.parts.head)\n this.parts.head.rotation.x = Math.sin(time * 1.7) * 0.08;\n const falling = !this.onGround && this.vel.y < -1;\n const wing = falling ? Math.sin(time * 30) * 0.9 : 0.05;\n if (this.parts.wingL) this.parts.wingL.rotation.z = wing;\n if (this.parts.wingR) this.parts.wingR.rotation.z = -wing;\n\n // вспышка урона\n const flash = this._flash > 0;\n for (const m of this._materials) {\n const want = flash ? 0xff3333 : 0x000000;\n if (m.emissive.getHex() !== want) m.emissive.setHex(want);\n }\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.group);\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._materials) 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 };\n }\n}\n"
103
+ "content": "// Универсальный моб: риг собирается из parts-описания реестра (BoxGeometry),\n// ИИ — простое блуждание с испугом, анимации процедурные (ноги/голова/крылья).\n// Добавление свиньи/зомби = запись в Mobs.js, этот код не меняется.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus\";\nimport { B } from \"../registry/Blocks\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { MobDef } from \"../types\";\n\nconst PX = 1 / 16; // части описаны в пикселях: 16 px = 1 блок\n\nexport class Mob {\n id: number;\n kind: \"mob\";\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: boolean;\n dead: boolean;\n _state: \"idle\" | \"walk\" | \"flee\";\n _stateTime: number;\n _animTime: number;\n _flash: number;\n group: THREE.Group;\n parts: Record<string, THREE.Group>;\n _materials: THREE.MeshLambertMaterial[];\n mesh: THREE.Object3D;\n\n constructor(id: number, def: MobDef, x: number, y: number, z: number) {\n this.id = id;\n this.kind = \"mob\";\n this.def = def;\n this.hp = def.hp;\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.onGround = false;\n this.dead = false;\n this._state = \"idle\";\n this._stateTime = 1 + Math.random() * 2;\n this._animTime = 0;\n this._flash = 0; // красная вспышка после удара\n\n // риг: каждая часть — Group в точке pivot, бокс смещён относительно pivot\n this.group = new THREE.Group();\n this.parts = {};\n this._materials = [];\n for (const p of def.parts) {\n const mat = new THREE.MeshLambertMaterial({ color: p.color });\n this._materials.push(mat);\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 pivot.add(mesh);\n this.group.add(pivot);\n if (p.anim) this.parts[p.anim] = pivot;\n }\n this.mesh = this.group; // единообразие с ItemDrop\n }\n\n tick(dt: number, world: ChunkManager) {\n this.prevPos.copy(this.pos);\n if (this._flash > 0) this._flash -= dt;\n\n // ---- ИИ: цепочка состояний idle → walk → idle, flee после удара ----\n this._stateTime -= dt;\n if (this._stateTime <= 0) {\n if (this._state === \"walk\" || this._state === \"flee\") {\n this._state = \"idle\";\n this._stateTime = 1 + Math.random() * 3;\n } else {\n this._state = \"walk\";\n this._targetYaw = Math.random() * Math.PI * 2;\n this._stateTime = 1.5 + Math.random() * 2.5;\n }\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 speed =\n this._state === \"flee\"\n ? this.def.fleeSpeed\n : this._state === \"walk\"\n ? this.def.walkSpeed\n : 0;\n const wishX = -Math.sin(this.yaw) * speed;\n const wishZ = -Math.cos(this.yaw) * speed;\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 this.vel.y -= 22 * dt;\n\n // вода: всплывает\n const inWater =\n world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n ) === B.water;\n if (inWater) {\n this.vel.y = Math.max(this.vel.y, 1.5);\n }\n\n this._move(dt, world);\n\n // упёрся в стену на ходу — подпрыгнуть (курицы умеют на 1 блок)\n if (\n speed > 0 &&\n this.onGround &&\n Math.hypot(this.vel.x, this.vel.z) < speed * 0.3\n )\n this.vel.y = 7;\n\n this._animTime +=\n dt * (speed > 0 ? Math.hypot(this.vel.x, this.vel.z) * 3 : 0);\n }\n\n _move(dt: number, world: ChunkManager) {\n const { w, h } = this.def.box;\n const half = w / 2,\n eps = 0.001;\n const collides = (px: number, py: number, pz: number) => {\n for (let by = Math.floor(py); by <= Math.floor(py + h - eps); by++)\n for (\n let bx = Math.floor(px - half);\n bx <= Math.floor(px + half - eps);\n bx++\n )\n for (\n let bz = Math.floor(pz - half);\n bz <= Math.floor(pz + half - eps);\n bz++\n )\n if (world.isSolid(bx, by, bz)) return true;\n return false;\n };\n\n // Y\n const ny = this.pos.y + this.vel.y * dt;\n if (collides(this.pos.x, ny, this.pos.z)) {\n if (this.vel.y <= 0) {\n this.pos.y = Math.floor(ny) + 1;\n this.onGround = true;\n } else this.pos.y = Math.floor(ny + h) - h - eps;\n this.vel.y = 0;\n } else {\n this.pos.y = ny;\n this.onGround = false;\n }\n // X\n const nx = this.pos.x + this.vel.x * dt;\n if (!collides(nx, this.pos.y, this.pos.z)) this.pos.x = nx;\n else this.vel.x = 0;\n // Z\n const nz = this.pos.z + this.vel.z * dt;\n if (!collides(this.pos.x, this.pos.y, nz)) this.pos.z = nz;\n else this.vel.z = 0;\n }\n\n /** Удар: урон, отброс от атакующего, красная вспышка, паника. */\n hurt(damage: number, fromPos: THREE.Vector3) {\n this.hp -= damage;\n this._flash = 0.4;\n const away = this.pos.clone().sub(fromPos).setY(0).normalize();\n this.vel.add(away.multiplyScalar(6)).setY(5);\n this._state = \"flee\";\n // Ход моба — (-sin yaw, -cos yaw), и этот yaw даёт ход вдоль `away`, т.е. прочь от атакующего.\n // (Лишний `+ Math.PI` здесь разворачивал курицу прямо на игрока.)\n this._targetYaw = Math.atan2(-away.x, -away.z);\n this._stateTime = 1.2;\n events.emit(\"mobHurt\", { entity: this, amount: damage });\n if (this.hp <= 0) this.dead = true;\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.group.position.copy(p);\n this.group.rotation.y = this.yaw;\n\n // анимации: шаг ногами, покачивание головы, крылья при падении\n const swing = Math.sin(this._animTime * 6) * 0.7;\n if (this.parts.legL) this.parts.legL.rotation.x = swing;\n if (this.parts.legR) this.parts.legR.rotation.x = -swing;\n if (this.parts.head)\n this.parts.head.rotation.x = Math.sin(time * 1.7) * 0.08;\n const falling = !this.onGround && this.vel.y < -1;\n const wing = falling ? Math.sin(time * 30) * 0.9 : 0.05;\n if (this.parts.wingL) this.parts.wingL.rotation.z = wing;\n if (this.parts.wingR) this.parts.wingR.rotation.z = -wing;\n\n // вспышка урона\n const flash = this._flash > 0;\n for (const m of this._materials) {\n const want = flash ? 0xff3333 : 0x000000;\n if (m.emissive.getHex() !== want) m.emissive.setHex(want);\n }\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.group);\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._materials) 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 };\n }\n}\n"
80
104
  },
81
105
  {
82
106
  "path": "gfx/TextureAtlas.ts",
83
- "content": "// Процедурный texture atlas: все текстуры рисуются на canvas 256×256 (16×16 тайлов\n// по 16px), никаких внешних файлов. NearestFilter + отключённые мипмапы + полутексельный\n// inset UV — честный пиксель-арт без bleeding.\nimport * as THREE from \"three\";\nimport { mulberry32, hashString } from \"../core/Noise\";\n\nconst TILE = 16; // пикселей в тайле\nconst GRID = 16; // тайлов в ряду\nconst SIZE = TILE * GRID;\n\n/** Закрашивает один пиксель тайла. */\ntype PxFn = (x: number, y: number, color: string) => void;\n/** Сидированный PRNG. */\ntype RngFn = () => number;\n/** painter-функция: рисует один тайл 16×16. */\ntype Painter = (px: PxFn, rng: RngFn) => void;\n\n/** Смешивание hex-цвета с чёрным/белым: amt в [-1..1]. */\nfunction shade(hex: string, amt: number): string {\n const n = parseInt(hex.slice(1), 16);\n let r = (n >> 16) & 255,\n g = (n >> 8) & 255,\n b = n & 255;\n if (amt >= 0) {\n r += (255 - r) * amt;\n g += (255 - g) * amt;\n b += (255 - b) * amt;\n } else {\n r *= 1 + amt;\n g *= 1 + amt;\n b *= 1 + amt;\n }\n return `rgb(${r | 0},${g | 0},${b | 0})`;\n}\n\n/** База: заливка цветом + пиксельный шум яркости. */\nfunction noisy(px: PxFn, base: string, variance: number, rng: RngFn): void {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(x, y, shade(base, (rng() - 0.5) * 2 * variance));\n}\n\n// ---- painter-функции: (px, rng) → рисуют один тайл 16×16 ---------------------\n// px(x, y, cssColor) закрашивает один пиксель тайла.\n\nconst painters: Record<string, Painter> = {\n grass_top(px, rng) {\n noisy(px, \"#5fa63c\", 0.1, rng);\n },\n dirt(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n },\n grass_side(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n for (let x = 0; x < TILE; x++) {\n const depth = 3 + ((rng() * 2) | 0); // рваная кромка дёрна\n for (let y = 0; y < depth; y++)\n px(x, y, shade(\"#5fa63c\", (rng() - 0.5) * 0.2));\n }\n },\n stone(px, rng) {\n noisy(px, \"#7d7d7d\", 0.08, rng);\n for (let i = 0; i < 5; i++) {\n // светлые/тёмные пятна\n const x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0,\n c = shade(\"#7d7d7d\", rng() < 0.5 ? -0.15 : 0.12);\n px(x, y, c);\n px((x + 1) & 15, y, c);\n }\n },\n cobblestone(px, rng) {\n noisy(px, \"#828282\", 0.1, rng);\n // «булыжники»: тёмная сетка неровных швов\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const cell = (x + (y & 4 ? 3 : 0)) % 6 === 0 || y % 6 === 0;\n if (cell && rng() < 0.8)\n px(x, y, shade(\"#4a4a4a\", (rng() - 0.5) * 0.2));\n }\n },\n bedrock(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(rng() < 0.5 ? \"#2b2b2b\" : \"#565656\", (rng() - 0.5) * 0.3),\n );\n },\n sand(px, rng) {\n noisy(px, \"#dcd3a0\", 0.08, rng);\n },\n oak_log_side(px, rng) {\n for (let x = 0; x < TILE; x++) {\n const stripe = x % 4 === 3 ? -0.25 : (rng() - 0.5) * 0.15; // кора полосами\n for (let y = 0; y < TILE; y++)\n px(x, y, shade(\"#6b5030\", stripe + (rng() - 0.5) * 0.08));\n }\n },\n oak_log_top(px, rng) {\n noisy(px, \"#6b5030\", 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++)\n px(x, y, shade(\"#b8945f\", (rng() - 0.5) * 0.15));\n // годовые кольца\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, \"#8a6d42\");\n }\n }\n },\n planks(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#a4824c\", (rng() - 0.5) * 0.12);\n if (y % 4 === 3) c = \"#6e5631\"; // горизонтальные швы досок\n if (\n (y < 4 && x === 11) ||\n (y >= 4 && y < 8 && x === 3) ||\n (y >= 8 && y < 12 && x === 13) ||\n (y >= 12 && x === 6)\n )\n c = \"#6e5631\"; // стыки\n px(x, y, c);\n }\n },\n leaves(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < 0.18) continue; // прозрачные «дырки» (alphaTest-вырезы)\n px(x, y, shade(\"#3e7a25\", (rng() - 0.5) * 0.35));\n }\n },\n water(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#3057d8\", (rng() - 0.5) * 0.12);\n if ((x + y * 2) % 8 === 0 && rng() < 0.5) c = shade(\"#4a74ea\", 0.1); // блики\n px(x, y, c);\n }\n },\n coal_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#2e2e2e\");\n },\n iron_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#d8af93\");\n },\n crafting_table_top(px, rng) {\n painters.planks!(px, rng);\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#59431f\");\n px(i, 15, \"#59431f\");\n px(0, i, \"#59431f\");\n px(15, i, \"#59431f\");\n }\n for (let y = 3; y < 13; y++)\n for (let x = 3; x < 13; x++)\n if (x === 3 || x === 12 || y === 3 || y === 12) px(x, y, \"#7d6134\");\n },\n crafting_table_side(px, rng) {\n painters.planks!(px, rng);\n // «инструменты» на боковине: тёмные силуэты\n for (let y = 2; y < 7; y++)\n for (let x = 3; x < 6; x++) if (rng() < 0.8) px(x, y, \"#4d3a1c\");\n for (let y = 2; y < 7; y++)\n for (let x = 10; x < 13; x++) if (rng() < 0.8) px(x, y, \"#57422a\");\n },\n};\n\nfunction oreBlobs(px: PxFn, rng: RngFn, color: string): void {\n for (let i = 0; i < 5; i++) {\n const cx = (2 + rng() * 12) | 0,\n cy = (2 + rng() * 12) | 0;\n for (let dy = -1; dy <= 1; dy++)\n for (let dx = -1; dx <= 1; dx++)\n if (Math.abs(dx) + Math.abs(dy) < 2 || rng() < 0.4)\n px((cx + dx) & 15, (cy + dy) & 15, shade(color, (rng() - 0.5) * 0.2));\n }\n}\n\n// ---- пиксель-арт по строковым шаблонам (растения, предметы, инструменты) -----\n// Каждая строка — 16 символов; '.' = прозрачно, буквы — цвета из палитры.\n\nfunction patternPainter(\n rows: string[],\n palette: Record<string, string>,\n): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++) {\n const row = rows[y] || \"\";\n for (let x = 0; x < TILE; x++) {\n const ch = row[x];\n if (!ch || ch === \".\") continue;\n px(x, y, shade(palette[ch]!, (rng() - 0.5) * 0.1));\n }\n }\n };\n}\n\nconst P = patternPainter;\n\npainters.tallgrass = P(\n [\n \"................\",\n \"................\",\n \"....g......g....\",\n \"..g.g...g..g.g..\",\n \"..g.g..gg..g.g..\",\n \"..gg.g.g.g.gg...\",\n \"...g.g.g.g.g....\",\n \"...g.gg.gg.g....\",\n \"....gg.g.gg.....\",\n \"....g.gg.g......\",\n \".....g.g.g......\",\n \".....gggg.......\",\n \"......gg........\",\n \"......gg........\",\n \"................\",\n \"................\",\n ],\n { g: \"#4c8a2f\" },\n);\n\npainters.flower_yellow = P(\n [\n \"................\",\n \"................\",\n \"......yy........\",\n \".....yYYy.......\",\n \".....yYYy.......\",\n \"......yy........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { y: \"#e8c11c\", Y: \"#fbe87a\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.flower_red = P(\n [\n \"................\",\n \"................\",\n \"......rr........\",\n \".....rRRr.......\",\n \".....rRRr.......\",\n \"......rr........\",\n \".......s........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e86a5f\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.stick = P(\n [\n \"................\",\n \"................\",\n \"............w...\",\n \"...........ww...\",\n \"..........ww....\",\n \".........ww.....\",\n \"........ww......\",\n \".......ww.......\",\n \"......ww........\",\n \".....ww.........\",\n \"....ww..........\",\n \"...ww...........\",\n \"...w............\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { w: \"#8a6a3a\" },\n);\n\npainters.feather = P(\n [\n \"................\",\n \"...........ff...\",\n \"..........ffff..\",\n \".........fffff..\",\n \"........ffffff..\",\n \".......ffffff...\",\n \"......ffffff....\",\n \".....ffffff.....\",\n \"....ffffff......\",\n \"....fffff.......\",\n \"...ffff.........\",\n \"...fff..........\",\n \"..qf............\",\n \"..q.............\",\n \".q..............\",\n \"................\",\n ],\n { f: \"#f2f2f2\", q: \"#b9b9b9\" },\n);\n\npainters.raw_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPpppppp....\",\n \"...pPppppppppb..\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#e8a2a2\", P: \"#f6caca\", b: \"#e3ddc4\" },\n);\n\npainters.coal_item = P(\n [\n \"................\",\n \"................\",\n \"................\",\n \".....cccc.......\",\n \"....cccccc......\",\n \"...ccCccccc.....\",\n \"...cCccccccc....\",\n \"...ccccCccccc...\",\n \"....ccccccccc...\",\n \"....cccCccccc...\",\n \".....ccccccc....\",\n \"......ccccc.....\",\n \".......ccc......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { c: \"#2c2c2c\", C: \"#4d4d4d\" },\n);\n\npainters.apple = P(\n [\n \"................\",\n \".......g........\",\n \".......s........\",\n \".....rrsr.......\",\n \"....rRRrrr......\",\n \"...rRRrrrrr.....\",\n \"...rRrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"....rrrrrr......\",\n \"....rrrrrr......\",\n \".....rrrr.......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e8635a\", s: \"#6e4a24\", g: \"#4c8a2f\" },\n);\n\npainters.cooked_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPppppbp....\",\n \"...pPpppppppb...\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#c8843e\", P: \"#e0a35a\", b: \"#8a5a2a\" },\n);\n\nconst TOOL_PALETTE = { w: \"#a4824c\", h: \"#6e5631\", s: \"#8a6a3a\" };\npainters.wooden_pickaxe = P(\n [\n \"................\",\n \"....wwwwwww.....\",\n \"...ww.....ww....\",\n \"..ww.......ww...\",\n \"..w.........w...\",\n \"..w....s....w...\",\n \".......s........\",\n \"......ss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_axe = P(\n [\n \"................\",\n \".....wwww.......\",\n \"....wwwwww......\",\n \"....ww..ww......\",\n \"....ww.ss.......\",\n \"....wwss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"..ss............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_sword = P(\n [\n \"................\",\n \"...........ww...\",\n \"..........www...\",\n \".........www....\",\n \"........www.....\",\n \".......www......\",\n \"......www.......\",\n \".....www........\",\n \"..h.www.........\",\n \"...hwww.........\",\n \"...shh..........\",\n \"..ss.hh.........\",\n \"..ss..hh........\",\n \".ss.............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\n\n// ---- стадии трещин (ряд 14, тайлы 224..233) ----------------------------------\n\nfunction crackPainter(stage: number): Painter {\n return (px, rng) => {\n // от центра расходятся stage+1 ломаных трещин; глубже стадия — гуще\n const cracks = 2 + stage;\n for (let c = 0; c < cracks; c++) {\n let x = 7 + rng() * 2,\n y = 7 + rng() * 2;\n const ang = rng() * Math.PI * 2;\n let dx = Math.cos(ang),\n dy = Math.sin(ang);\n const len = 3 + stage * 1.2;\n for (let i = 0; i < len; i++) {\n px(Math.round(x) & 15, Math.round(y) & 15, \"rgba(20,15,10,0.85)\");\n x += dx;\n y += dy;\n dx += (rng() - 0.5) * 0.8;\n dy += (rng() - 0.5) * 0.8; // излом\n const n = Math.hypot(dx, dy) || 1;\n dx /= n;\n dy /= n;\n }\n }\n // на поздних стадиях — крошка по всему тайлу\n for (let i = 0; i < stage * 4; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"rgba(20,15,10,0.7)\");\n };\n}\nfor (let s = 0; s < 10; s++) painters[\"crack\" + s] = crackPainter(s);\n\n// ---- сборка атласа ------------------------------------------------------------\n\n// Порядок = индекс тайла. Ряд 14 зарезервирован под трещины.\nconst TILE_ORDER = [\n \"grass_top\",\n \"grass_side\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"bedrock\",\n \"sand\",\n \"oak_log_side\",\n \"oak_log_top\",\n \"planks\",\n \"leaves\",\n \"water\",\n \"coal_ore\",\n \"iron_ore\",\n \"crafting_table_top\",\n \"crafting_table_side\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n \"stick\",\n \"feather\",\n \"raw_chicken\",\n \"coal_item\",\n \"apple\",\n \"cooked_chicken\",\n \"wooden_pickaxe\",\n \"wooden_axe\",\n \"wooden_sword\",\n];\nconst CRACK_BASE = 14 * GRID; // индекс тайла crack0\n\nexport class TextureAtlas {\n canvas: HTMLCanvasElement;\n tiles: Record<string, number>;\n texture: THREE.CanvasTexture;\n waterTexture: THREE.CanvasTexture;\n opaqueMat: THREE.MeshLambertMaterial;\n foliageMat: THREE.MeshLambertMaterial;\n waterMat: THREE.MeshLambertMaterial;\n _iconCache: Map<string, HTMLCanvasElement>;\n\n constructor() {\n this.canvas = document.createElement(\"canvas\");\n this.canvas.width = this.canvas.height = SIZE;\n const ctx = this.canvas.getContext(\"2d\")!; // код всегда рассчитывает на валидный 2d-контекст\n this.tiles = {}; // имя → индекс\n\n const paint = (name: string, index: number) => {\n const tx = (index % GRID) * TILE,\n ty = ((index / GRID) | 0) * TILE;\n const rng = mulberry32(hashString(\"tile:\" + name));\n const px: PxFn = (x, y, color) => {\n ctx.fillStyle = color;\n ctx.fillRect(tx + x, ty + y, 1, 1);\n };\n painters[name]!(px, rng);\n };\n\n TILE_ORDER.forEach((name, i) => {\n this.tiles[name] = i;\n paint(name, i);\n });\n for (let s = 0; s < 10; s++) {\n this.tiles[\"crack\" + s] = CRACK_BASE + s;\n paint(\"crack\" + s, CRACK_BASE + s);\n }\n\n this.texture = new THREE.CanvasTexture(this.canvas);\n this.texture.magFilter = THREE.NearestFilter;\n this.texture.minFilter = THREE.NearestFilter;\n this.texture.generateMipmaps = false;\n this.texture.colorSpace = THREE.SRGBColorSpace;\n\n // Отдельная маленькая текстура воды: у водного материала свой repeat-тайл,\n // чтобы анимировать offset, не сдвигая UV всего атласа.\n const wc = document.createElement(\"canvas\");\n wc.width = wc.height = TILE;\n const wctx = wc.getContext(\"2d\")!; // тот же расчёт на валидный 2d-контекст\n const wrng = mulberry32(hashString(\"tile:water-solo\"));\n painters.water!((x, y, c) => {\n wctx.fillStyle = c;\n wctx.fillRect(x, y, 1, 1);\n }, wrng);\n this.waterTexture = new THREE.CanvasTexture(wc);\n this.waterTexture.magFilter = this.waterTexture.minFilter =\n THREE.NearestFilter;\n this.waterTexture.generateMipmaps = false;\n this.waterTexture.wrapS = this.waterTexture.wrapT = THREE.RepeatWrapping;\n this.waterTexture.colorSpace = THREE.SRGBColorSpace;\n\n // Материалы чанков — общие на весь мир (3 draw-call-группы).\n this.opaqueMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n });\n this.foliageMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n alphaTest: 0.5,\n side: THREE.DoubleSide,\n });\n this.waterMat = new THREE.MeshLambertMaterial({\n map: this.waterTexture,\n vertexColors: true,\n transparent: true,\n opacity: 0.72,\n depthWrite: false,\n });\n\n this._iconCache = new Map();\n }\n\n /** UV-прямоугольник тайла с полутексельным inset. Индекс или имя. */\n uv(tile: string | number): {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n } {\n const i = typeof tile === \"string\" ? this.tiles[tile]! : tile;\n const col = i % GRID,\n row = (i / GRID) | 0;\n const inset = 0.5 / SIZE;\n return {\n u0: col / GRID + inset,\n u1: (col + 1) / GRID - inset,\n // canvas рисует сверху вниз, UV в three — снизу вверх\n v0: 1 - (row + 1) / GRID + inset,\n v1: 1 - row / GRID - inset,\n };\n }\n\n /** Канвас-фрагмент тайла (для иконок). */\n tileCanvas(name: string, shadeAmt = 0): HTMLCanvasElement {\n const i = this.tiles[name]!;\n const c = document.createElement(\"canvas\");\n c.width = c.height = TILE;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.drawImage(\n this.canvas,\n (i % GRID) * TILE,\n ((i / GRID) | 0) * TILE,\n TILE,\n TILE,\n 0,\n 0,\n TILE,\n TILE,\n );\n if (shadeAmt !== 0) {\n ctx.globalCompositeOperation = \"source-atop\";\n ctx.fillStyle = `rgba(0,0,0,${shadeAmt})`;\n ctx.fillRect(0, 0, TILE, TILE);\n }\n return c;\n }\n\n /** Изометрическая иконка блока (48×48) из его top/side тайлов. */\n blockIcon(topName: string, sideName: string): HTMLCanvasElement {\n const key = \"b:\" + topName + \"/\" + sideName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n const top = this.tileCanvas(topName, 0);\n const left = this.tileCanvas(sideName, 0.25);\n const right = this.tileCanvas(sideName, 0.42);\n const k = 21 / TILE; // размер грани\n // верхняя грань (ромб), затем левая и правая\n ctx.setTransform(k, k * 0.5, -k, k * 0.5, 24, 3);\n ctx.drawImage(top, 0, 0);\n ctx.setTransform(k, k * 0.5, 0, k, 24 - k * TILE, 3 + k * TILE * 0.5);\n ctx.drawImage(left, 0, 0);\n ctx.setTransform(k, -k * 0.5, 0, k, 24, 3 + k * TILE);\n ctx.drawImage(right, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n this._iconCache.set(key, c);\n return c;\n }\n\n /** Плоская иконка предмета (его тайл, 48×48, nearest). */\n flatIcon(tileName: string): HTMLCanvasElement {\n const key = \"f:\" + tileName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n ctx.drawImage(this.tileCanvas(tileName), 0, 0, 48, 48);\n this._iconCache.set(key, c);\n return c;\n }\n}\n"
107
+ "content": "// Процедурный texture atlas: все текстуры рисуются на canvas 256×256 (16×16 тайлов\n// по 16px), никаких внешних файлов. NearestFilter + отключённые мипмапы + полутексельный\n// inset UV — честный пиксель-арт без bleeding.\nimport * as THREE from \"three\";\nimport { mulberry32, hashString } from \"../core/Noise\";\n\nconst TILE = 16; // пикселей в тайле\nconst GRID = 16; // тайлов в ряду\nconst SIZE = TILE * GRID;\n\n/** Закрашивает один пиксель тайла. */\ntype PxFn = (x: number, y: number, color: string) => void;\n/** Сидированный PRNG. */\ntype RngFn = () => number;\n/** painter-функция: рисует один тайл 16×16. */\ntype Painter = (px: PxFn, rng: RngFn) => void;\n\n/** Смешивание hex-цвета с чёрным/белым: amt в [-1..1]. */\nfunction shade(hex: string, amt: number): string {\n const n = parseInt(hex.slice(1), 16);\n let r = (n >> 16) & 255,\n g = (n >> 8) & 255,\n b = n & 255;\n if (amt >= 0) {\n r += (255 - r) * amt;\n g += (255 - g) * amt;\n b += (255 - b) * amt;\n } else {\n r *= 1 + amt;\n g *= 1 + amt;\n b *= 1 + amt;\n }\n return `rgb(${r | 0},${g | 0},${b | 0})`;\n}\n\n/** База: заливка цветом + пиксельный шум яркости. */\nfunction noisy(px: PxFn, base: string, variance: number, rng: RngFn): void {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(x, y, shade(base, (rng() - 0.5) * 2 * variance));\n}\n\n// ---- painter-функции: (px, rng) → рисуют один тайл 16×16 ---------------------\n// px(x, y, cssColor) закрашивает один пиксель тайла.\n\nconst painters: Record<string, Painter> = {\n grass_top(px, rng) {\n noisy(px, \"#5fa63c\", 0.1, rng);\n },\n dirt(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n },\n grass_side(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n for (let x = 0; x < TILE; x++) {\n const depth = 3 + ((rng() * 2) | 0); // рваная кромка дёрна\n for (let y = 0; y < depth; y++)\n px(x, y, shade(\"#5fa63c\", (rng() - 0.5) * 0.2));\n }\n },\n stone(px, rng) {\n noisy(px, \"#7d7d7d\", 0.08, rng);\n for (let i = 0; i < 5; i++) {\n // светлые/тёмные пятна\n const x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0,\n c = shade(\"#7d7d7d\", rng() < 0.5 ? -0.15 : 0.12);\n px(x, y, c);\n px((x + 1) & 15, y, c);\n }\n },\n cobblestone(px, rng) {\n noisy(px, \"#828282\", 0.1, rng);\n // «булыжники»: тёмная сетка неровных швов\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const cell = (x + (y & 4 ? 3 : 0)) % 6 === 0 || y % 6 === 0;\n if (cell && rng() < 0.8)\n px(x, y, shade(\"#4a4a4a\", (rng() - 0.5) * 0.2));\n }\n },\n bedrock(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(rng() < 0.5 ? \"#2b2b2b\" : \"#565656\", (rng() - 0.5) * 0.3),\n );\n },\n sand(px, rng) {\n noisy(px, \"#dcd3a0\", 0.08, rng);\n },\n oak_log_side(px, rng) {\n for (let x = 0; x < TILE; x++) {\n const stripe = x % 4 === 3 ? -0.25 : (rng() - 0.5) * 0.15; // кора полосами\n for (let y = 0; y < TILE; y++)\n px(x, y, shade(\"#6b5030\", stripe + (rng() - 0.5) * 0.08));\n }\n },\n oak_log_top(px, rng) {\n noisy(px, \"#6b5030\", 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++)\n px(x, y, shade(\"#b8945f\", (rng() - 0.5) * 0.15));\n // годовые кольца\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, \"#8a6d42\");\n }\n }\n },\n planks(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#a4824c\", (rng() - 0.5) * 0.12);\n if (y % 4 === 3) c = \"#6e5631\"; // горизонтальные швы досок\n if (\n (y < 4 && x === 11) ||\n (y >= 4 && y < 8 && x === 3) ||\n (y >= 8 && y < 12 && x === 13) ||\n (y >= 12 && x === 6)\n )\n c = \"#6e5631\"; // стыки\n px(x, y, c);\n }\n },\n leaves(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < 0.18) continue; // прозрачные «дырки» (alphaTest-вырезы)\n px(x, y, shade(\"#3e7a25\", (rng() - 0.5) * 0.35));\n }\n },\n water(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#3057d8\", (rng() - 0.5) * 0.12);\n if ((x + y * 2) % 8 === 0 && rng() < 0.5) c = shade(\"#4a74ea\", 0.1); // блики\n px(x, y, c);\n }\n },\n coal_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#2e2e2e\");\n },\n iron_ore(px, rng) {\n painters.stone!(px, rng);\n oreBlobs(px, rng, \"#d8af93\");\n },\n crafting_table_top(px, rng) {\n painters.planks!(px, rng);\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#59431f\");\n px(i, 15, \"#59431f\");\n px(0, i, \"#59431f\");\n px(15, i, \"#59431f\");\n }\n for (let y = 3; y < 13; y++)\n for (let x = 3; x < 13; x++)\n if (x === 3 || x === 12 || y === 3 || y === 12) px(x, y, \"#7d6134\");\n },\n crafting_table_side(px, rng) {\n painters.planks!(px, rng);\n // «инструменты» на боковине: тёмные силуэты\n for (let y = 2; y < 7; y++)\n for (let x = 3; x < 6; x++) if (rng() < 0.8) px(x, y, \"#4d3a1c\");\n for (let y = 2; y < 7; y++)\n for (let x = 10; x < 13; x++) if (rng() < 0.8) px(x, y, \"#57422a\");\n },\n};\n\n// ---- тайлы блоков генератора миров ---------------------------------------------\n// Отдельные функции, а не правка существующих painter'ов: у каждого тайла свой сид PRNG, и\n// общий код менял бы порядок случайных чисел — старые тайлы перерисовались бы иначе.\n\n/** Кора бревна вертикальными полосами. */\nfunction logSidePainter(bark: string, stripe: string): Painter {\n return (px, rng) => {\n for (let x = 0; x < TILE; x++) {\n const dark = x % 4 === 3;\n for (let y = 0; y < TILE; y++)\n px(x, y, dark ? stripe : shade(bark, (rng() - 0.5) * 0.14));\n }\n };\n}\n\n/** Спил бревна: кора по краю, светлая древесина и годовые кольца. */\nfunction logTopPainter(bark: string, wood: string, ring: string): Painter {\n return (px, rng) => {\n noisy(px, bark, 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++) px(x, y, shade(wood, (rng() - 0.5) * 0.14));\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, ring);\n }\n }\n };\n}\n\n/** Листва с прозрачными «дырками» (alphaTest-вырезы). */\nfunction leavesPainter(color: string, holes: number): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < holes) continue;\n px(x, y, shade(color, (rng() - 0.5) * 0.35));\n }\n };\n}\n\n/** Шерсть: ровный цвет с лёгким «плетением». */\nfunction woolPainter(color: string): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const weave = (x + y) % 4 === 0 ? -0.07 : 0;\n px(x, y, shade(color, weave + (rng() - 0.5) * 0.08));\n }\n };\n}\n\nObject.assign(painters, {\n snow(px: PxFn, rng: RngFn) {\n noisy(px, \"#eef3f8\", 0.04, rng);\n for (let i = 0; i < 6; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"#ffffff\");\n },\n ice(px: PxFn, rng: RngFn) {\n noisy(px, \"#9cc7f0\", 0.05, rng);\n // светлые трещины-блики по диагонали\n for (let i = 0; i < 3; i++) {\n let x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0;\n for (let k = 0; k < 6; k++) {\n px(x & 15, y & 15, \"#d6ecff\");\n x += 1;\n y += rng() < 0.5 ? 1 : 0;\n }\n }\n },\n gravel(px: PxFn, rng: RngFn) {\n const tones = [\"#857f7a\", \"#6e6863\", \"#9d9690\", \"#5b5550\"];\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(tones[(rng() * tones.length) | 0]!, (rng() - 0.5) * 0.1),\n );\n },\n clay(px: PxFn, rng: RngFn) {\n noisy(px, \"#9ea4b0\", 0.06, rng);\n },\n sandstone_top(px: PxFn, rng: RngFn) {\n noisy(px, \"#d9c98f\", 0.05, rng);\n },\n sandstone_side(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const base = y < 3 ? \"#e3d49c\" : y >= 12 ? \"#c4b27a\" : \"#d9c98f\";\n px(x, y, y === 3 ? \"#b9a66f\" : shade(base, (rng() - 0.5) * 0.08));\n }\n },\n red_sand(px: PxFn, rng: RngFn) {\n noisy(px, \"#c2703a\", 0.08, rng);\n },\n terracotta(px: PxFn, rng: RngFn) {\n noisy(px, \"#a05a3c\", 0.05, rng);\n },\n stone_bricks(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const joint = y % 8 === 7 || x === (y < 8 ? 7 : 15);\n px(x, y, joint ? \"#555555\" : shade(\"#7a7a7a\", (rng() - 0.5) * 0.14));\n }\n },\n mossy_cobblestone(px: PxFn, rng: RngFn) {\n painters.cobblestone!(px, rng);\n for (let i = 0; i < 7; i++) {\n const cx = (rng() * TILE) | 0,\n cy = (rng() * TILE) | 0;\n for (let d = 0; d < 4; d++)\n px(\n (cx + ((rng() * 3) | 0) - 1) & 15,\n (cy + ((rng() * 3) | 0) - 1) & 15,\n shade(\"#5a7a3a\", (rng() - 0.5) * 0.2),\n );\n }\n },\n bricks(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const row = y >> 2;\n const offset = row % 2 ? 4 : 0;\n const mortar = y % 4 === 3 || (x + offset) % 8 === 7;\n px(x, y, mortar ? \"#c9bfb3\" : shade(\"#9a4a3a\", (rng() - 0.5) * 0.16));\n }\n },\n glass(px: PxFn, _rng: RngFn) {\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#dff2f7\");\n px(i, 15, \"#dff2f7\");\n px(0, i, \"#dff2f7\");\n px(15, i, \"#dff2f7\");\n }\n for (let x = 3; x < 9; x++) px(x, x - 1, \"#f4fbfd\"); // блик\n for (let x = 4; x < 8; x++) px(x + 1, x - 1, \"#f4fbfd\");\n },\n spruce_log_side: logSidePainter(\"#4a3520\", \"#33241a\"),\n spruce_log_top: logTopPainter(\"#4a3520\", \"#8a6a42\", \"#6e5232\"),\n spruce_leaves: leavesPainter(\"#2d5a3a\", 0.14),\n birch_log_side(px: PxFn, rng: RngFn) {\n noisy(px, \"#d7d2c4\", 0.05, rng);\n for (let i = 0; i < 9; i++) {\n const x = (rng() * 14) | 0,\n y = (rng() * TILE) | 0;\n px(x, y, \"#3a3a3a\");\n px(x + 1, y, \"#3a3a3a\");\n }\n },\n birch_log_top: logTopPainter(\"#d7d2c4\", \"#c9b27a\", \"#a88f5a\"),\n birch_leaves: leavesPainter(\"#5c8f3a\", 0.18),\n dark_planks(px: PxFn, rng: RngFn) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const seam =\n y % 4 === 3 ||\n (y < 4 && x === 5) ||\n (y >= 4 && y < 8 && x === 12) ||\n (y >= 8 && y < 12 && x === 2) ||\n (y >= 12 && x === 9);\n px(x, y, seam ? \"#3a2714\" : shade(\"#5a3d22\", (rng() - 0.5) * 0.12));\n }\n },\n dirt_path_top(px: PxFn, rng: RngFn) {\n noisy(px, \"#9a7a45\", 0.1, rng);\n },\n white_wool: woolPainter(\"#e9ecec\"),\n red_wool: woolPainter(\"#a12722\"),\n orange_wool: woolPainter(\"#f07613\"),\n yellow_wool: woolPainter(\"#f8c527\"),\n green_wool: woolPainter(\"#5e7c16\"),\n blue_wool: woolPainter(\"#35399d\"),\n light_blue_wool: woolPainter(\"#3aafd9\"),\n purple_wool: woolPainter(\"#7a2aa9\"),\n pink_wool: woolPainter(\"#ed8dac\"),\n brown_wool: woolPainter(\"#724728\"),\n gray_wool: woolPainter(\"#3e4447\"),\n black_wool: woolPainter(\"#1d1d21\"),\n} satisfies Record<string, Painter>);\n\nfunction oreBlobs(px: PxFn, rng: RngFn, color: string): void {\n for (let i = 0; i < 5; i++) {\n const cx = (2 + rng() * 12) | 0,\n cy = (2 + rng() * 12) | 0;\n for (let dy = -1; dy <= 1; dy++)\n for (let dx = -1; dx <= 1; dx++)\n if (Math.abs(dx) + Math.abs(dy) < 2 || rng() < 0.4)\n px((cx + dx) & 15, (cy + dy) & 15, shade(color, (rng() - 0.5) * 0.2));\n }\n}\n\n// ---- пиксель-арт по строковым шаблонам (растения, предметы, инструменты) -----\n// Каждая строка — 16 символов; '.' = прозрачно, буквы — цвета из палитры.\n\nfunction patternPainter(\n rows: string[],\n palette: Record<string, string>,\n): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++) {\n const row = rows[y] || \"\";\n for (let x = 0; x < TILE; x++) {\n const ch = row[x];\n if (!ch || ch === \".\") continue;\n px(x, y, shade(palette[ch]!, (rng() - 0.5) * 0.1));\n }\n }\n };\n}\n\nconst P = patternPainter;\n\npainters.tallgrass = P(\n [\n \"................\",\n \"................\",\n \"....g......g....\",\n \"..g.g...g..g.g..\",\n \"..g.g..gg..g.g..\",\n \"..gg.g.g.g.gg...\",\n \"...g.g.g.g.g....\",\n \"...g.gg.gg.g....\",\n \"....gg.g.gg.....\",\n \"....g.gg.g......\",\n \".....g.g.g......\",\n \".....gggg.......\",\n \"......gg........\",\n \"......gg........\",\n \"................\",\n \"................\",\n ],\n { g: \"#4c8a2f\" },\n);\n\npainters.flower_yellow = P(\n [\n \"................\",\n \"................\",\n \"......yy........\",\n \".....yYYy.......\",\n \".....yYYy.......\",\n \"......yy........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { y: \"#e8c11c\", Y: \"#fbe87a\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.flower_red = P(\n [\n \"................\",\n \"................\",\n \"......rr........\",\n \".....rRRr.......\",\n \".....rRRr.......\",\n \"......rr........\",\n \".......s........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e86a5f\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.stick = P(\n [\n \"................\",\n \"................\",\n \"............w...\",\n \"...........ww...\",\n \"..........ww....\",\n \".........ww.....\",\n \"........ww......\",\n \".......ww.......\",\n \"......ww........\",\n \".....ww.........\",\n \"....ww..........\",\n \"...ww...........\",\n \"...w............\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { w: \"#8a6a3a\" },\n);\n\npainters.feather = P(\n [\n \"................\",\n \"...........ff...\",\n \"..........ffff..\",\n \".........fffff..\",\n \"........ffffff..\",\n \".......ffffff...\",\n \"......ffffff....\",\n \".....ffffff.....\",\n \"....ffffff......\",\n \"....fffff.......\",\n \"...ffff.........\",\n \"...fff..........\",\n \"..qf............\",\n \"..q.............\",\n \".q..............\",\n \"................\",\n ],\n { f: \"#f2f2f2\", q: \"#b9b9b9\" },\n);\n\npainters.raw_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPpppppp....\",\n \"...pPppppppppb..\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#e8a2a2\", P: \"#f6caca\", b: \"#e3ddc4\" },\n);\n\npainters.coal_item = P(\n [\n \"................\",\n \"................\",\n \"................\",\n \".....cccc.......\",\n \"....cccccc......\",\n \"...ccCccccc.....\",\n \"...cCccccccc....\",\n \"...ccccCccccc...\",\n \"....ccccccccc...\",\n \"....cccCccccc...\",\n \".....ccccccc....\",\n \"......ccccc.....\",\n \".......ccc......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { c: \"#2c2c2c\", C: \"#4d4d4d\" },\n);\n\npainters.apple = P(\n [\n \"................\",\n \".......g........\",\n \".......s........\",\n \".....rrsr.......\",\n \"....rRRrrr......\",\n \"...rRRrrrrr.....\",\n \"...rRrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"....rrrrrr......\",\n \"....rrrrrr......\",\n \".....rrrr.......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e8635a\", s: \"#6e4a24\", g: \"#4c8a2f\" },\n);\n\npainters.cooked_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPppppbp....\",\n \"...pPpppppppb...\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#c8843e\", P: \"#e0a35a\", b: \"#8a5a2a\" },\n);\n\nconst TOOL_PALETTE = { w: \"#a4824c\", h: \"#6e5631\", s: \"#8a6a3a\" };\npainters.wooden_pickaxe = P(\n [\n \"................\",\n \"....wwwwwww.....\",\n \"...ww.....ww....\",\n \"..ww.......ww...\",\n \"..w.........w...\",\n \"..w....s....w...\",\n \".......s........\",\n \"......ss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_axe = P(\n [\n \"................\",\n \".....wwww.......\",\n \"....wwwwww......\",\n \"....ww..ww......\",\n \"....ww.ss.......\",\n \"....wwss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"..ss............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_sword = P(\n [\n \"................\",\n \"...........ww...\",\n \"..........www...\",\n \".........www....\",\n \"........www.....\",\n \".......www......\",\n \"......www.......\",\n \".....www........\",\n \"..h.www.........\",\n \"...hwww.........\",\n \"...shh..........\",\n \"..ss.hh.........\",\n \"..ss..hh........\",\n \".ss.............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\n\n// ---- стадии трещин (ряд 14, тайлы 224..233) ----------------------------------\n\nfunction crackPainter(stage: number): Painter {\n return (px, rng) => {\n // от центра расходятся stage+1 ломаных трещин; глубже стадия — гуще\n const cracks = 2 + stage;\n for (let c = 0; c < cracks; c++) {\n let x = 7 + rng() * 2,\n y = 7 + rng() * 2;\n const ang = rng() * Math.PI * 2;\n let dx = Math.cos(ang),\n dy = Math.sin(ang);\n const len = 3 + stage * 1.2;\n for (let i = 0; i < len; i++) {\n px(Math.round(x) & 15, Math.round(y) & 15, \"rgba(20,15,10,0.85)\");\n x += dx;\n y += dy;\n dx += (rng() - 0.5) * 0.8;\n dy += (rng() - 0.5) * 0.8; // излом\n const n = Math.hypot(dx, dy) || 1;\n dx /= n;\n dy /= n;\n }\n }\n // на поздних стадиях — крошка по всему тайлу\n for (let i = 0; i < stage * 4; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"rgba(20,15,10,0.7)\");\n };\n}\nfor (let s = 0; s < 10; s++) painters[\"crack\" + s] = crackPainter(s);\n\n// ---- сборка атласа ------------------------------------------------------------\n\n// Порядок = индекс тайла. Ряд 14 зарезервирован под трещины.\nconst TILE_ORDER = [\n \"grass_top\",\n \"grass_side\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"bedrock\",\n \"sand\",\n \"oak_log_side\",\n \"oak_log_top\",\n \"planks\",\n \"leaves\",\n \"water\",\n \"coal_ore\",\n \"iron_ore\",\n \"crafting_table_top\",\n \"crafting_table_side\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n \"stick\",\n \"feather\",\n \"raw_chicken\",\n \"coal_item\",\n \"apple\",\n \"cooked_chicken\",\n \"wooden_pickaxe\",\n \"wooden_axe\",\n \"wooden_sword\",\n // блоки генератора миров (дописываются в конец — индексы старых тайлов не сдвигаются)\n \"snow\",\n \"ice\",\n \"gravel\",\n \"clay\",\n \"sandstone_top\",\n \"sandstone_side\",\n \"red_sand\",\n \"terracotta\",\n \"stone_bricks\",\n \"mossy_cobblestone\",\n \"bricks\",\n \"glass\",\n \"spruce_log_side\",\n \"spruce_log_top\",\n \"spruce_leaves\",\n \"birch_log_side\",\n \"birch_log_top\",\n \"birch_leaves\",\n \"dark_planks\",\n \"dirt_path_top\",\n \"white_wool\",\n \"red_wool\",\n \"orange_wool\",\n \"yellow_wool\",\n \"green_wool\",\n \"blue_wool\",\n \"light_blue_wool\",\n \"purple_wool\",\n \"pink_wool\",\n \"brown_wool\",\n \"gray_wool\",\n \"black_wool\",\n];\nconst CRACK_BASE = 14 * GRID; // индекс тайла crack0\n\nexport class TextureAtlas {\n canvas: HTMLCanvasElement;\n tiles: Record<string, number>;\n texture: THREE.CanvasTexture;\n waterTexture: THREE.CanvasTexture;\n opaqueMat: THREE.MeshLambertMaterial;\n foliageMat: THREE.MeshLambertMaterial;\n waterMat: THREE.MeshLambertMaterial;\n _iconCache: Map<string, HTMLCanvasElement>;\n\n constructor() {\n this.canvas = document.createElement(\"canvas\");\n this.canvas.width = this.canvas.height = SIZE;\n const ctx = this.canvas.getContext(\"2d\")!; // код всегда рассчитывает на валидный 2d-контекст\n this.tiles = {}; // имя → индекс\n\n const paint = (name: string, index: number) => {\n const tx = (index % GRID) * TILE,\n ty = ((index / GRID) | 0) * TILE;\n const rng = mulberry32(hashString(\"tile:\" + name));\n const px: PxFn = (x, y, color) => {\n ctx.fillStyle = color;\n ctx.fillRect(tx + x, ty + y, 1, 1);\n };\n painters[name]!(px, rng);\n };\n\n TILE_ORDER.forEach((name, i) => {\n this.tiles[name] = i;\n paint(name, i);\n });\n for (let s = 0; s < 10; s++) {\n this.tiles[\"crack\" + s] = CRACK_BASE + s;\n paint(\"crack\" + s, CRACK_BASE + s);\n }\n\n this.texture = new THREE.CanvasTexture(this.canvas);\n this.texture.magFilter = THREE.NearestFilter;\n this.texture.minFilter = THREE.NearestFilter;\n this.texture.generateMipmaps = false;\n this.texture.colorSpace = THREE.SRGBColorSpace;\n\n // Отдельная маленькая текстура воды: у водного материала свой repeat-тайл,\n // чтобы анимировать offset, не сдвигая UV всего атласа.\n const wc = document.createElement(\"canvas\");\n wc.width = wc.height = TILE;\n const wctx = wc.getContext(\"2d\")!; // тот же расчёт на валидный 2d-контекст\n const wrng = mulberry32(hashString(\"tile:water-solo\"));\n painters.water!((x, y, c) => {\n wctx.fillStyle = c;\n wctx.fillRect(x, y, 1, 1);\n }, wrng);\n this.waterTexture = new THREE.CanvasTexture(wc);\n this.waterTexture.magFilter = this.waterTexture.minFilter =\n THREE.NearestFilter;\n this.waterTexture.generateMipmaps = false;\n this.waterTexture.wrapS = this.waterTexture.wrapT = THREE.RepeatWrapping;\n this.waterTexture.colorSpace = THREE.SRGBColorSpace;\n\n // Материалы чанков — общие на весь мир (3 draw-call-группы).\n this.opaqueMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n });\n this.foliageMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n alphaTest: 0.5,\n side: THREE.DoubleSide,\n });\n this.waterMat = new THREE.MeshLambertMaterial({\n map: this.waterTexture,\n vertexColors: true,\n transparent: true,\n opacity: 0.72,\n depthWrite: false,\n });\n\n this._iconCache = new Map();\n }\n\n /** UV-прямоугольник тайла с полутексельным inset. Индекс или имя. */\n uv(tile: string | number): {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n } {\n const i = typeof tile === \"string\" ? this.tiles[tile]! : tile;\n const col = i % GRID,\n row = (i / GRID) | 0;\n const inset = 0.5 / SIZE;\n return {\n u0: col / GRID + inset,\n u1: (col + 1) / GRID - inset,\n // canvas рисует сверху вниз, UV в three — снизу вверх\n v0: 1 - (row + 1) / GRID + inset,\n v1: 1 - row / GRID - inset,\n };\n }\n\n /** Канвас-фрагмент тайла (для иконок). */\n tileCanvas(name: string, shadeAmt = 0): HTMLCanvasElement {\n const i = this.tiles[name]!;\n const c = document.createElement(\"canvas\");\n c.width = c.height = TILE;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.drawImage(\n this.canvas,\n (i % GRID) * TILE,\n ((i / GRID) | 0) * TILE,\n TILE,\n TILE,\n 0,\n 0,\n TILE,\n TILE,\n );\n if (shadeAmt !== 0) {\n ctx.globalCompositeOperation = \"source-atop\";\n ctx.fillStyle = `rgba(0,0,0,${shadeAmt})`;\n ctx.fillRect(0, 0, TILE, TILE);\n }\n return c;\n }\n\n /** Изометрическая иконка блока (48×48) из его top/side тайлов. */\n blockIcon(topName: string, sideName: string): HTMLCanvasElement {\n const key = \"b:\" + topName + \"/\" + sideName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n const top = this.tileCanvas(topName, 0);\n const left = this.tileCanvas(sideName, 0.25);\n const right = this.tileCanvas(sideName, 0.42);\n const k = 21 / TILE; // размер грани\n // верхняя грань (ромб), затем левая и правая\n ctx.setTransform(k, k * 0.5, -k, k * 0.5, 24, 3);\n ctx.drawImage(top, 0, 0);\n ctx.setTransform(k, k * 0.5, 0, k, 24 - k * TILE, 3 + k * TILE * 0.5);\n ctx.drawImage(left, 0, 0);\n ctx.setTransform(k, -k * 0.5, 0, k, 24, 3 + k * TILE);\n ctx.drawImage(right, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n this._iconCache.set(key, c);\n return c;\n }\n\n /** Плоская иконка предмета (его тайл, 48×48, nearest). */\n flatIcon(tileName: string): HTMLCanvasElement {\n const key = \"f:\" + tileName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n ctx.drawImage(this.tileCanvas(tileName), 0, 0, 48, 48);\n this._iconCache.set(key, c);\n return c;\n }\n}\n"
84
108
  },
85
109
  {
86
110
  "path": "index.ts",
@@ -88,35 +112,35 @@
88
112
  },
89
113
  {
90
114
  "path": "main.ts",
91
- "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 } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { WaterSim } from \"./systems/WaterSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n interaction: Interaction;\n waterSim: WaterSim;\n dayNight: DayNight;\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n playing: boolean;\n private _time: number;\n loop: GameLoop;\n\n constructor() {\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 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, CONFIG.world.seed);\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.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.waterSim = new WaterSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.audio = new Audio();\n this.input = new Input(canvas, this.intents);\n this.armView = new ArmView(this.camera, this.atlas);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n this.invUI = new InventoryUI(this);\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 });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\n document.addEventListener(\"keydown\", (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n });\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\"click\", () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n });\n\n // --- старт мира ---\n this.warmup(8, 8);\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 /** Подгонка рендера под текущую раскладку канваса. Вызывается 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 rebuildWorld(seed: number) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, seed);\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.waterSim.world = this.world;\n this.waterSim.queue.clear();\n this.interaction.breaking = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. */\n newWorld(seed: number) {\n this.rebuildWorld(seed);\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n this.dayNight.time = this.dayNight.cycleLength * 0.15;\n this.warmup(8, 8);\n this.player.spawn();\n this.hud.message(\"Новый мир, сид \" + seed, 1800);\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 this.entities.tick(dt);\n this.waterSim.tick();\n this.world.tick(); // onTick-блоки (задел автоматизации)\n this.dayNight.tick(dt);\n events.emit(\"tick\", { n });\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\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.dayNight.frame(this.player.pos, this.player.headInWater);\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.renderer.render(this.scene, this.camera);\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 this.loop.stop();\n this.input.releaseLock();\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 if (!this.loop.running) this.loop.start();\n } else {\n this.loop.stop();\n this.input.releaseLock();\n this.playing = false;\n }\n }\n}\n"
115
+ "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 { WaterSim } from \"./systems/WaterSim\";\nimport { DayNight } from \"./systems/DayNight\";\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 { WorldStore, newWorldId } from \"./systems/WorldStore\";\nimport type { WorldAI } from \"./ai/types\";\nimport { startSource } from \"./worlds/startWorld\";\nimport type { 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 interaction: Interaction;\n waterSim: WaterSim;\n dayNight: DayNight;\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n worldUI: CreateWorldUI;\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 _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.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.waterSim = new WaterSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\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.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 this.invUI = new InventoryUI(this, this._abort.signal);\n this.worldUI = new CreateWorldUI(this, this._abort.signal, opts.ai ?? null);\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) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, source);\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.waterSim.world = this.world;\n this.waterSim.queue.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\" ? { kind: \"noise\", seed: source } : source;\n void this.persist(); // старый мир остаётся в «Моих мирах» таким, каким его оставили\n this.rebuildWorld(src);\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 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 this.entities.tick(dt);\n this.waterSim.tick();\n this.world.tick(); // onTick-блоки (задел автоматизации)\n this.dayNight.tick(dt);\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.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.dayNight.frame(this.player.pos, this.player.headInWater);\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 // Два прохода: мир, затем рука поверх него с чистым 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 /** 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.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"
92
116
  },
93
117
  {
94
118
  "path": "module.ts",
95
- "content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelcraft } from \"./scene\";\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 or the SDK at all.\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 const { scene, agent } = createVoxelcraft();\n ctx.registerScene(scene);\n ctx.registerRoute({ id: \"voxelcraft\", label: \"Voxel\", icon: \"🧊\" });\n // Глаза и руки агента: у canvas-игры DOM пустой, без этого она для него невидима.\n ctx.exposeToAgent(agent);\n },\n});\n"
119
+ "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"
96
120
  },
97
121
  {
98
122
  "path": "player/ArmView.ts",
99
- "content": "// Рука от первого лица: блочная рука в стиле Minecraft, прикреплена к камере.\n// Если в руке блок — вместо руки показывается мини-куб блока; плоские предметы —\n// квад с иконкой. Замах — процедурная анимация по событию armSwing.\nimport * as THREE from \"three\";\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\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 _texCache: Map<string, THREE.CanvasTexture>;\n _dirty: boolean;\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.group.add(this.armMesh);\n\n this.itemMesh = null; // мини-блок/иконка в руке\n this._heldKey = null;\n this._swing = 0; // 0..1 — фаза замаха\n this._texCache = new Map();\n\n events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n events.on(\"invChanged\", () => {\n this._dirty = true;\n });\n this._dirty = true;\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const key = held ? held.item : null;\n if (key === this._heldKey && !this._dirty) return;\n this._heldKey = key;\n this._dirty = false;\n\n if (this.itemMesh) {\n this.group.remove(this.itemMesh);\n this.itemMesh.geometry.dispose();\n this.itemMesh = null;\n }\n this.armMesh.visible = !key;\n if (!key) return;\n\n const item = ITEMS[key]!;\n if (item.blockId !== null && !BLOCKS[item.blockId]!.cross) {\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 tex = this._texCache.get(key);\n if (!tex) {\n tex = new THREE.CanvasTexture(item.icon);\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n this._texCache.set(key, tex);\n }\n const mat = new THREE.MeshBasicMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\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.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) this._swing = Math.max(0, this._swing - dt * 3.2);\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"
123
+ "content": "// Рука от первого лица: блочная рука в стиле Minecraft, прикреплена к камере.\n// Если в руке блок — вместо руки показывается мини-куб блока; плоские предметы —\n// квад с иконкой. Замах — процедурная анимация по событию armSwing.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { Inventory } from \"../systems/Inventory\";\n\n/**\n * Слой рендера руки. Рука рисуется ВТОРЫМ проходом поверх мира с очищенным depth (см. Game.frame):\n * в общем depth-буфере она висит в 0.7 м перед глазом и, стоя вплотную к блоку, целиком тонула\n * в нём — замах при добыче было просто не видно.\n */\nexport const ARM_LAYER = 1;\n\nexport class ArmView {\n camera: THREE.PerspectiveCamera;\n atlas: TextureAtlas;\n group: THREE.Group;\n armMesh: THREE.Mesh;\n itemMesh: THREE.Mesh | null;\n _heldKey: string | null;\n _swing: number;\n _matCache: Map<string, THREE.MeshBasicMaterial>;\n\n constructor(camera: THREE.PerspectiveCamera, atlas: TextureAtlas) {\n this.camera = camera;\n this.atlas = atlas;\n this.group = new THREE.Group();\n camera.add(this.group);\n this.group.position.set(0.42, -0.42, -0.7);\n\n // сама рука: «кожа» с рукавом\n const armMat = new THREE.MeshLambertMaterial({ color: 0xd8a06c });\n this.armMesh = new THREE.Mesh(\n new THREE.BoxGeometry(0.14, 0.14, 0.5),\n armMat,\n );\n this.armMesh.rotation.set(0.35, -0.25, 0);\n this.armMesh.layers.set(ARM_LAYER);\n this.group.add(this.armMesh);\n\n this.itemMesh = null; // мини-блок/иконка в руке\n this._heldKey = null;\n this._swing = 0; // 0..1 — фаза замаха\n // Материал плоского предмета — один на предмет: пересоздавать его на каждую смену слота\n // значило бы копить неосвобождённые материалы.\n this._matCache = new Map();\n\n events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const key = held ? held.item : null;\n // Меш зависит только от того, КАКОЙ предмет в руке: смена количества (подбор, постановка\n // блока) пересборки не требует.\n if (key === this._heldKey) return;\n this._heldKey = key;\n\n if (this.itemMesh) {\n this.group.remove(this.itemMesh);\n this.itemMesh.geometry.dispose();\n this.itemMesh = null;\n }\n this.armMesh.visible = !key;\n if (!key) return;\n\n const item = ITEMS[key]!;\n if (item.blockId !== null && !BLOCKS[item.blockId]!.cross) {\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"
100
124
  },
101
125
  {
102
126
  "path": "player/Input.ts",
103
- "content": "// Ввод: Pointer Lock, клавиши, мышь. НИКОГДА не трогает мир напрямую —\n// дискретные действия уходят интентами в очередь, непрерывные состояния\n// (WASD, зажатые кнопки мыши, взгляд) читает Player/Interaction на кадре.\nimport { events } from \"../core/EventBus\";\nimport { Intent, type IntentQueue } from \"../core/Intents\";\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\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 /** Есть ли в этом окружении настоящий Pointer Lock. Если да — мягкий режим не нужен. */\n readonly pointerLockAvailable: boolean;\n\n constructor(canvas: HTMLCanvasElement, intents: IntentQueue) {\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.pointerLockAvailable =\n typeof canvas.requestPointerLock === \"function\" &&\n typeof document.exitPointerLock === \"function\";\n\n document.addEventListener(\"pointerlockchange\", () => {\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\n canvas.addEventListener(\"mousemove\", (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\n canvas.addEventListener(\"mousedown\", (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 });\n canvas.addEventListener(\"mouseup\", (e) => {\n if (e.button === 0) this.leftDown = false;\n if (e.button === 2) this.rightDown = false;\n });\n canvas.addEventListener(\"contextmenu\", (e) => e.preventDefault());\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 },\n );\n\n document.addEventListener(\"keydown\", (e) => {\n if (e.repeat) 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.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 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 document.addEventListener(\"keyup\", (e) => this.keys.delete(e.code));\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 get sneakOrDown(): boolean {\n return this.keys.has(\"ShiftLeft\") || this.keys.has(\"ShiftRight\");\n }\n get run(): boolean {\n return this.keys.has(\"ControlLeft\") || this.keys.has(\"ShiftLeft\");\n }\n}\n"
127
+ "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\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 },\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.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 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(\"keyup\", (e) => this.keys.delete(e.code), opts);\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 get sneakOrDown(): boolean {\n return this.keys.has(\"ShiftLeft\") || this.keys.has(\"ShiftRight\");\n }\n get run(): boolean {\n return this.keys.has(\"ControlLeft\") || this.keys.has(\"ShiftLeft\");\n }\n}\n"
104
128
  },
105
129
  {
106
130
  "path": "player/Interaction.ts",
107
- "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 } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { raycastBlocks } from \"../world/Raycast\";\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\";\n\n/** Блок под прицелом (результат воксельного raycast). */\ntype BlockHit = NonNullable<ReturnType<typeof raycastBlocks>>;\n/** Ближайшая сущность под прицелом. */\ntype EntityHit = ReturnType<EntityManager[\"raycast\"]>;\n/** Состояние ломания блока (тиковая половина). */\ninterface BreakInfo {\n x: number;\n y: number;\n z: number;\n id: number;\n progress: number;\n total: number;\n}\n/** Накопленный урон по блоку: прогресс + таймер заживления. */\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: BlockHit | null;\n targetEntity: EntityHit;\n breaking: BreakInfo | null;\n _breakHeld: boolean;\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 _swungThisHold = 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; // {x,y,z,id, progress, total}\n this._breakHeld = false;\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 const entHit = this.entities.raycast(\n eye,\n dir,\n hit ? hit.dist : CONFIG.player.reach,\n );\n this.targetEntity = entHit;\n this.target = entHit ? null : hit;\n\n if (this.target) {\n this.highlight.visible = true;\n this.highlight.position.set(\n this.target.x + 0.5,\n this.target.y + 0.5,\n this.target.z + 0.5,\n );\n } else {\n this.highlight.visible = false;\n }\n\n // ЛКМ: по сущности — атака (разовая), по блоку — ломание (удержание)\n if (input.leftDown && !input.uiOpen) {\n if (entHit) {\n this.intents.push(Intent.Attack, { entityId: entHit.id });\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n } else 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 if (!this._swungThisHold) {\n events.emit(\"armSwing\");\n this._swungThisHold = true;\n }\n } else {\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n this._swungThisHold = false;\n }\n\n // ПКМ: приоритет — интерактивный блок (верстак) > еда > постановка блока\n const held = this.inventory.heldItem();\n const heldFood = held && (ITEMS[held.item]?.food ?? 0) > 0;\n const lookingAtTable =\n this.target && BLOCKS[this.target.id]!.name === \"crafting_table\";\n let eatingNow = false;\n if (input.rightDown && !input.uiOpen) {\n if (lookingAtTable && this._placeCooldown <= 0) {\n events.emit(\"ui:openCrafting\");\n this._placeCooldown = 0.25;\n } else if (\n heldFood &&\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 (this.target && this._placeCooldown <= 0) {\n this.intents.push(Intent.PlaceBlock, {\n x: this.target.x + this.target.nx,\n y: this.target.y + this.target.ny,\n z: this.target.z + this.target.nz,\n });\n events.emit(\"armSwing\");\n this._placeCooldown = 0.25;\n }\n }\n if (!eatingNow && this._sentEat) {\n this.intents.push(Intent.EatStop);\n this._sentEat = null;\n }\n if (!input.rightDown) this._placeCooldown = 0;\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 / this.breaking.total,\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 = BLOCKS[this.target.id]!.hardness;\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 / total,\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 frameTimers(dt: number): void {\n if (this._placeCooldown > 0) this._placeCooldown -= 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.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 }\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 this.breaking.progress += dt * this._breakSpeed(this.breaking.id);\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 >= this.breaking.total) {\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 if (\n !held ||\n held.item !== this.eating.item ||\n 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 (this.player.eat(this.eating.item)) this.inventory.consumeHeld(1);\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 if (!ITEMS[item]?.food || this.player.food >= CONFIG.player.maxFood) 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 /** Множитель скорости от инструмента в руке. */\n _breakSpeed(blockId: number): number {\n const def = BLOCKS[blockId]!;\n const held = this.inventory.heldItem();\n if (held && def.tool && ITEMS[held.item]?.toolType === def.tool)\n return ITEMS[held.item]!.speed;\n return 1;\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.player.mode === \"creative\" ? 0 : def.hardness;\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 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 // дроп: null = сам блок; [] = ничего; chance — вероятность (по умолчанию 1); в креативе дропов нет\n if (this.player.mode !== \"creative\") {\n const drops = def.drops === null ? [{ item: def.name, n: 1 }] : def.drops;\n for (const d of drops)\n if (\n ITEMS[d.item] &&\n (d.chance === undefined || Math.random() <= d.chance)\n )\n this.entities.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n }\n // если на блоке стояло растение — оно падает (ломается)\n const above = this.world.getBlock(x, y + 1, z);\n if (BLOCKS[above]?.cross) {\n this.world.setBlock(x, y + 1, z, B.air!);\n events.emit(\"blockBreak\", { x, y: y + 1, z, id: above, byPlayer: false });\n }\n }\n\n _place({ x, y, z }: IntentMsg): void {\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 cur = this.world.getBlock(x, y, z);\n if (cur !== B.air && !BLOCKS[cur]!.liquid && !BLOCKS[cur]!.cross) return;\n if (BLOCKS[item.blockId]!.solid && this.player.intersectsBlock(x, y, z))\n return; // не в себя\n // крестовины — только на твёрдое основание\n if (BLOCKS[item.blockId]!.cross && !this.world.isSolid(x, y - 1, z)) return;\n\n this.world.setBlock(x, y, z, item.blockId);\n events.emit(\"blockPlace\", { x, y, z, id: item.blockId });\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n }\n\n _attack({ entityId }: IntentMsg): void {\n if (this._attackCooldown > 0) return;\n this._attackCooldown = CONFIG.player.attackCooldown;\n this.player.addExhaustion(CONFIG.player.exhaustAttack);\n const held = this.inventory.heldItem();\n const damage = held\n ? (ITEMS[held.item]?.damage ?? 1)\n : CONFIG.player.attackDamage;\n this.entities.hurt(entityId, damage, this.player.pos);\n }\n}\n"
131
+ "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 } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport { raycastBlocks } from \"../world/Raycast\";\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\";\n\n/** Блок под прицелом (результат воксельного raycast). */\ntype BlockHit = NonNullable<ReturnType<typeof raycastBlocks>>;\n/** Ближайшая сущность под прицелом. */\ntype EntityHit = ReturnType<EntityManager[\"raycast\"]>;\n/** Состояние ломания блока (тиковая половина). */\ninterface BreakInfo {\n x: number;\n y: number;\n z: number;\n id: number;\n progress: number;\n total: number;\n}\n/** Накопленный урон по блоку: прогресс + таймер заживления. */\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: BlockHit | null;\n targetEntity: EntityHit;\n breaking: BreakInfo | null;\n _breakHeld: boolean;\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 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; // {x,y,z,id, progress, total}\n this._breakHeld = false;\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 const entHit = this.entities.raycast(\n eye,\n dir,\n hit ? hit.dist : CONFIG.player.reach,\n );\n this.targetEntity = entHit;\n this.target = entHit ? null : hit;\n\n if (this.target) {\n this.highlight.visible = true;\n this.highlight.position.set(\n this.target.x + 0.5,\n this.target.y + 0.5,\n this.target.z + 0.5,\n );\n } else {\n this.highlight.visible = false;\n }\n\n // ЛКМ: по сущности — атака (повторяется по кулдауну), по блоку — ломание (удержание)\n const canAct = !input.uiOpen && !this.player.dead;\n if (input.leftDown && canAct) {\n if (entHit) {\n // Замах по сущности шлёт тиковая половина (_attack) — ровно на каждый состоявшийся удар.\n // Раньше замах был один на всё удержание, а удары шли дальше по кулдауну без анимации.\n this.intents.push(Intent.Attack, { entityId: entHit.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 const held = this.inventory.heldItem();\n const heldFood = held && (ITEMS[held.item]?.food ?? 0) > 0;\n const lookingAtTable =\n this.target && BLOCKS[this.target.id]!.name === \"crafting_table\";\n let eatingNow = false;\n if (input.rightDown && canAct) {\n if (lookingAtTable && this._placeCooldown <= 0) {\n events.emit(\"ui:openCrafting\");\n this._placeCooldown = 0.25;\n } else if (\n heldFood &&\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 (this.target && this._placeCooldown <= 0) {\n this.intents.push(Intent.PlaceBlock, {\n x: this.target.x + this.target.nx,\n y: this.target.y + this.target.ny,\n z: this.target.z + this.target.nz,\n });\n events.emit(\"armSwing\");\n this._placeCooldown = 0.25;\n }\n }\n if (!eatingNow && this._sentEat) {\n this.intents.push(Intent.EatStop);\n this._sentEat = null;\n }\n if (!input.rightDown) this._placeCooldown = 0;\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 / this.breaking.total,\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 = BLOCKS[this.target.id]!.hardness;\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 / total,\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 frameTimers(dt: number): void {\n if (this._placeCooldown > 0) this._placeCooldown -= dt;\n if (this._swingTimer > 0) this._swingTimer -= 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.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 }\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 this.breaking.progress += dt * this._breakSpeed(this.breaking.id);\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 >= this.breaking.total) {\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 if (\n !held ||\n held.item !== this.eating.item ||\n 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 (this.player.eat(this.eating.item)) this.inventory.consumeHeld(1);\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 if (!ITEMS[item]?.food || this.player.food >= CONFIG.player.maxFood) 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 /** Множитель скорости от инструмента в руке. */\n _breakSpeed(blockId: number): number {\n const def = BLOCKS[blockId]!;\n const held = this.inventory.heldItem();\n if (held && def.tool && ITEMS[held.item]?.toolType === def.tool)\n return ITEMS[held.item]!.speed;\n return 1;\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.player.mode === \"creative\" ? 0 : def.hardness;\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 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 // дроп: null = сам блок; [] = ничего; chance — вероятность (по умолчанию 1); в креативе дропов нет\n if (this.player.mode !== \"creative\") {\n const drops = def.drops === null ? [{ item: def.name, n: 1 }] : def.drops;\n for (const d of drops)\n if (\n ITEMS[d.item] &&\n (d.chance === undefined || Math.random() <= d.chance)\n )\n this.entities.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n }\n // если на блоке стояло растение — оно падает (ломается)\n const above = this.world.getBlock(x, y + 1, z);\n if (BLOCKS[above]?.cross) {\n this.world.setBlock(x, y + 1, z, B.air!);\n events.emit(\"blockBreak\", { x, y: y + 1, z, id: above, byPlayer: false });\n }\n }\n\n _place({ x, y, z }: IntentMsg): void {\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 cur = this.world.getBlock(x, y, z);\n if (cur !== B.air && !BLOCKS[cur]!.liquid && !BLOCKS[cur]!.cross) return;\n if (BLOCKS[item.blockId]!.solid && this.player.intersectsBlock(x, y, z))\n return; // не в себя\n // крестовины — только на твёрдое основание\n if (BLOCKS[item.blockId]!.cross && !this.world.isSolid(x, y - 1, z)) return;\n\n this.world.setBlock(x, y, z, item.blockId);\n events.emit(\"blockPlace\", { x, y, z, id: item.blockId });\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n }\n\n _attack({ entityId }: IntentMsg): void {\n if (this._attackCooldown > 0) return;\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 const damage = held\n ? (ITEMS[held.item]?.damage ?? 1)\n : CONFIG.player.attackDamage;\n this.entities.hurt(entityId, damage, this.player.pos);\n }\n}\n"
108
132
  },
109
133
  {
110
134
  "path": "player/Player.ts",
111
- "content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\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 { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\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}\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\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 let sx = 8,\n sz = 8;\n outer: 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 = 8 + dx * 8,\n z = 8 + dz * 8;\n const y = this.world.surfaceY(x, z);\n if (\n y > CONFIG.world.waterLevel &&\n this.world.getBlock(x, y, z) === B.grass\n ) {\n sx = x;\n sz = z;\n break outer;\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(this.pos.x, this.pos.y + P.eye, this.pos.z);\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 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 if (this.dead) return;\n // взгляд\n const m = input.consumeMouse();\n this.look(-m.dx * 0.0024, -m.dy * 0.0024);\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 this.inWater = !!(BLOCKS[feet]?.liquid || BLOCKS[head]?.liquid);\n this.headInWater = !!BLOCKS[head]?.liquid;\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 // бег доступен, пока еды достаточно (как в Minecraft)\n const canSprint = input.run && input.forward && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed = this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed;\n\n 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.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 if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed;\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\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 _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n\n // ось Y\n this.pos.y += this.vel.y * dt;\n const hit = this._resolveAxis(1);\n const landed = hit === -1; // упёрлись вниз\n if (landed) {\n if (\n wasAirborne &&\n this._fallDist > P.fallSafe &&\n this.mode === \"survival\"\n )\n this.damage(Math.round(this._fallDist - P.fallSafe), \"fall\");\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else if (hit === 1) {\n this.vel.y = 0; // потолок\n } else {\n this.onGround = false;\n if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n\n // ось X\n this.pos.x += this.vel.x * dt;\n if (this._resolveAxis(0)) this.vel.x = 0;\n // ось Z\n this.pos.z += this.vel.z * dt;\n if (this._resolveAxis(2)) this.vel.z = 0;\n }\n\n /**\n * Разрешение коллизии по одной оси: если AABB пересекает твёрдый воксель,\n * позиция клампится к его грани. Возвращает -1/1 (сторону) или 0.\n */\n _resolveAxis(axis: number): number {\n const half = P.width / 2;\n const minX = this.pos.x - half,\n maxX = this.pos.x + half;\n const minY = this.pos.y,\n maxY = this.pos.y + P.height;\n const minZ = this.pos.z - half,\n maxZ = this.pos.z + half;\n const eps = 0.001;\n let result = 0;\n\n for (let by = Math.floor(minY); by <= Math.floor(maxY - eps); by++) {\n for (let bz = Math.floor(minZ); bz <= Math.floor(maxZ - eps); bz++) {\n for (let bx = Math.floor(minX); bx <= Math.floor(maxX - eps); bx++) {\n if (!this.world.isSolid(bx, by, bz)) continue;\n if (axis === 1) {\n if (this.vel.y <= 0 && minY < by + 1 && maxY > by + 1) {\n /* невозможно по одной оси */\n }\n if (this.vel.y <= 0) {\n this.pos.y = by + 1;\n result = -1;\n } else {\n this.pos.y = by - P.height - eps;\n result = 1;\n }\n } else if (axis === 0) {\n if (this.vel.x > 0) this.pos.x = bx - half - eps;\n else this.pos.x = bx + 1 + half + eps;\n result = 1;\n } else {\n if (this.vel.z > 0) this.pos.z = bz - half - eps;\n else this.pos.z = bz + 1 + half + eps;\n result = 1;\n }\n return result; // после клампа пересечений по этой оси больше нет\n }\n }\n }\n return result;\n }\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) this._die();\n }\n\n addExhaustion(n: number): void {\n this.exhaustion += 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 // голодание: урон при нулевой еде, но не насмерть (минимум 1 HP)\n if (this.food <= 0) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > 1) 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 if (!it?.food || this.food >= P.maxFood) 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 events.emit(\"playerAte\", { item: itemName });\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n events.emit(\"playerDied\");\n // простой респаун через секунду\n setTimeout(() => {\n this.pos.copy(this.spawnPoint);\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 this._fallDist = 0;\n events.emit(\"playerRespawn\");\n }, 1200);\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 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 };\n }\n deserialize(d: PlayerSaveData): void {\n this.pos.fromArray(d.pos);\n this.yaw = d.yaw;\n this.pitch = d.pitch;\n this.health = d.health;\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 this.spawnPoint.copy(this.pos);\n }\n}\n"
135
+ "content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\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 { ChunkManager } from \"../world/ChunkManager\";\nimport type { Input } from \"./Input\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\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}\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 _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(this.pos.x, this.pos.y + P.eye, this.pos.z);\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 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 this.inWater = !!(BLOCKS[feet]?.liquid || BLOCKS[head]?.liquid);\n this.headInWater = !!BLOCKS[head]?.liquid;\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 // бег доступен, пока еды достаточно (как в Minecraft)\n const canSprint = input.run && input.forward && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed = this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed;\n\n 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.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 if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed;\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\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 _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n\n // ось Y\n this.pos.y += this.vel.y * dt;\n const hit = this._resolveAxis(1);\n const landed = hit === -1; // упёрлись вниз\n if (landed) {\n if (\n wasAirborne &&\n this._fallDist > P.fallSafe &&\n this.mode === \"survival\"\n )\n this.damage(Math.round(this._fallDist - P.fallSafe), \"fall\");\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else if (hit === 1) {\n this.vel.y = 0; // потолок\n } else {\n this.onGround = false;\n // снижение в полёте — не падение: выключив полёт в воздухе, получили бы урон за весь спуск\n if (this.flying) this._fallDist = 0;\n else if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n\n // ось X\n this.pos.x += this.vel.x * dt;\n if (this._resolveAxis(0)) this.vel.x = 0;\n // ось Z\n this.pos.z += this.vel.z * dt;\n if (this._resolveAxis(2)) this.vel.z = 0;\n }\n\n /**\n * Разрешение коллизии по одной оси: если AABB пересекает твёрдый воксель,\n * позиция клампится к его грани. Возвращает -1/1 (сторону) или 0.\n */\n _resolveAxis(axis: number): number {\n const half = P.width / 2;\n const minX = this.pos.x - half,\n maxX = this.pos.x + half;\n const minY = this.pos.y,\n maxY = this.pos.y + P.height;\n const minZ = this.pos.z - half,\n maxZ = this.pos.z + half;\n const eps = 0.001;\n let result = 0;\n\n for (let by = Math.floor(minY); by <= Math.floor(maxY - eps); by++) {\n for (let bz = Math.floor(minZ); bz <= Math.floor(maxZ - eps); bz++) {\n for (let bx = Math.floor(minX); bx <= Math.floor(maxX - eps); bx++) {\n if (!this.world.isSolid(bx, by, bz)) continue;\n if (axis === 1) {\n if (this.vel.y <= 0 && minY < by + 1 && maxY > by + 1) {\n /* невозможно по одной оси */\n }\n if (this.vel.y <= 0) {\n this.pos.y = by + 1;\n result = -1;\n } else {\n this.pos.y = by - P.height - eps;\n result = 1;\n }\n } else if (axis === 0) {\n if (this.vel.x > 0) this.pos.x = bx - half - eps;\n else this.pos.x = bx + 1 + half + eps;\n result = 1;\n } else {\n if (this.vel.z > 0) this.pos.z = bz - half - eps;\n else this.pos.z = bz + 1 + half + eps;\n result = 1;\n }\n return result; // после клампа пересечений по этой оси больше нет\n }\n }\n }\n return result;\n }\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) this._die();\n }\n\n addExhaustion(n: number): void {\n // В креативе голода нет и tickHunger истощение не списывает — оно копилось бы и при возврате\n // в выживание съедало еду пачкой за один тик.\n if (this.mode === \"creative\") return;\n this.exhaustion += 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 // голодание: урон при нулевой еде, но не насмерть (минимум 1 HP)\n if (this.food <= 0) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > 1) 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 if (!it?.food || this.food >= P.maxFood) 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 events.emit(\"playerAte\", { item: itemName });\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n events.emit(\"playerDied\");\n // простой респаун через секунду\n this._respawnT = setTimeout(() => {\n this.pos.copy(this.spawnPoint);\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 this._fallDist = 0;\n events.emit(\"playerRespawn\");\n }, 1200);\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 };\n }\n deserialize(d: PlayerSaveData): void {\n this.pos.fromArray(d.pos);\n this.yaw = d.yaw;\n this.pitch = d.pitch;\n this.health = d.health;\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 this.spawnPoint.copy(this.pos);\n }\n}\n"
112
136
  },
113
137
  {
114
138
  "path": "registry/Blocks.ts",
115
- "content": "// Реестр блоков — data-driven. Добавить блок = добавить запись здесь\n// (+ painter тайла в TextureAtlas, если нужна новая текстура).\n//\n// Поля:\n// solid — есть коллизия\n// opaque — полностью непрозрачен (соседние грани не рендерятся)\n// cross — растение-крестовина (2 скрещенных квада, без коллизии)\n// liquid — вода (своя логика меша и симуляции)\n// hardness — секунд ломания голой рукой; 0 = мгновенно; Infinity = неломаемый\n// tool — каким инструментом добывается быстрее ('pickaxe'|'axe'|'shovel'|null)\n// drops — что выпадает: [{item, n}] | null = сам блок | [] = ничего\n// tex — тайлы атласа {top, side, bottom, front?}\n// onTick — хук тика для блоков с поведением (печи/конвейеры будущей автоматизации).\n// Сигнатура: onTick(world, x, y, z, meta). В MVP ни у кого не задан,\n// но планировщик в ChunkManager уже вызывает его для активных блоков.\n\nimport type { BlockDef } from \"../types\";\n\nfunction block(\n def: Partial<BlockDef> & { id: number; name: string },\n): BlockDef {\n return {\n solid: true,\n opaque: true,\n cross: false,\n liquid: false,\n hardness: 1,\n tool: null,\n drops: null,\n onTick: null,\n tex: null,\n ...def,\n };\n}\n\nexport const BLOCKS: BlockDef[] = [\n block({\n id: 0,\n name: \"air\",\n solid: false,\n opaque: false,\n hardness: Infinity,\n drops: [],\n tex: null,\n }),\n block({\n id: 1,\n name: \"grass\",\n hardness: 0.9,\n tool: \"shovel\",\n drops: [{ item: \"dirt\", n: 1 }],\n tex: { top: \"grass_top\", side: \"grass_side\", bottom: \"dirt\" },\n }),\n block({\n id: 2,\n name: \"dirt\",\n hardness: 0.75,\n tool: \"shovel\",\n tex: { top: \"dirt\", side: \"dirt\", bottom: \"dirt\" },\n }),\n block({\n id: 3,\n name: \"stone\",\n hardness: 4.0,\n tool: \"pickaxe\",\n drops: [{ item: \"cobblestone\", n: 1 }],\n tex: { top: \"stone\", side: \"stone\", bottom: \"stone\" },\n }),\n block({\n id: 4,\n name: \"cobblestone\",\n hardness: 4.5,\n tool: \"pickaxe\",\n tex: { top: \"cobblestone\", side: \"cobblestone\", bottom: \"cobblestone\" },\n }),\n block({\n id: 5,\n name: \"bedrock\",\n hardness: Infinity,\n drops: [],\n tex: { top: \"bedrock\", side: \"bedrock\", bottom: \"bedrock\" },\n }),\n block({\n id: 6,\n name: \"sand\",\n hardness: 0.75,\n tool: \"shovel\",\n tex: { top: \"sand\", side: \"sand\", bottom: \"sand\" },\n }),\n block({\n id: 7,\n name: \"oak_log\",\n hardness: 2.5,\n tool: \"axe\",\n tex: { top: \"oak_log_top\", side: \"oak_log_side\", bottom: \"oak_log_top\" },\n }),\n block({\n id: 8,\n name: \"leaves\",\n opaque: false,\n hardness: 0.05,\n drops: [{ item: \"apple\", n: 1, chance: 0.06 }],\n tex: { top: \"leaves\", side: \"leaves\", bottom: \"leaves\" },\n }),\n block({\n id: 9,\n name: \"planks\",\n hardness: 2.0,\n tool: \"axe\",\n tex: { top: \"planks\", side: \"planks\", bottom: \"planks\" },\n }),\n block({\n id: 10,\n name: \"crafting_table\",\n hardness: 2.0,\n tool: \"axe\",\n tex: {\n top: \"crafting_table_top\",\n side: \"crafting_table_side\",\n bottom: \"planks\",\n },\n }),\n block({\n id: 11,\n name: \"water\",\n solid: false,\n opaque: false,\n liquid: true,\n hardness: Infinity,\n drops: [],\n tex: { top: \"water\", side: \"water\", bottom: \"water\" },\n }),\n block({\n id: 12,\n name: \"coal_ore\",\n hardness: 5.0,\n tool: \"pickaxe\",\n drops: [{ item: \"coal\", n: 1 }],\n tex: { top: \"coal_ore\", side: \"coal_ore\", bottom: \"coal_ore\" },\n }),\n block({\n id: 13,\n name: \"iron_ore\",\n hardness: 5.5,\n tool: \"pickaxe\",\n tex: { top: \"iron_ore\", side: \"iron_ore\", bottom: \"iron_ore\" },\n }),\n block({\n id: 14,\n name: \"tallgrass\",\n solid: false,\n opaque: false,\n cross: true,\n hardness: 0,\n drops: [],\n tex: { side: \"tallgrass\" },\n }),\n block({\n id: 15,\n name: \"flower_yellow\",\n solid: false,\n opaque: false,\n cross: true,\n hardness: 0,\n tex: { side: \"flower_yellow\" },\n }),\n block({\n id: 16,\n name: \"flower_red\",\n solid: false,\n opaque: false,\n cross: true,\n hardness: 0,\n tex: { side: \"flower_red\" },\n }),\n];\n\n/** Быстрый доступ по имени: B.stone === 3. */\nexport const B: Record<string, number> = {};\nfor (const b of BLOCKS) B[b.name] = b.id;\n\n/** Мета-флаг воды: бит 0x08 = source-блок, биты 0..2 = уровень-1 (0..7). */\nexport const WATER_SOURCE = 0x08;\nexport const waterLevel = (meta: number): number => (meta & 0x07) + 1;\nexport const packWater = (level: number, source: boolean): number =>\n ((level - 1) & 0x07) | (source ? WATER_SOURCE : 0);\nexport const isWaterSource = (meta: number): boolean =>\n (meta & WATER_SOURCE) !== 0;\n"
139
+ "content": "// Реестр блоков — data-driven. Добавить блок = добавить запись здесь\n// (+ painter тайла в TextureAtlas, если нужна новая текстура).\n//\n// Поля:\n// solid — есть коллизия\n// opaque — полностью непрозрачен (соседние грани не рендерятся)\n// cross — растение-крестовина (2 скрещенных квада, без коллизии)\n// liquid — вода (своя логика меша и симуляции)\n// hardness — секунд ломания голой рукой; 0 = мгновенно; Infinity = неломаемый\n// tool — каким инструментом добывается быстрее ('pickaxe'|'axe'|'shovel'|null)\n// drops — что выпадает: [{item, n}] | null = сам блок | [] = ничего\n// tex — тайлы атласа {top, side, bottom, front?}\n// mapColor — репрезентативный цвет: подбор блока по пикселю картинки (генератор миров) и миникарта\n// onTick — хук тика для блоков с поведением (печи/конвейеры будущей автоматизации).\n// Сигнатура: onTick(world, x, y, z, meta). В MVP ни у кого не задан,\n// но планировщик в ChunkManager уже вызывает его для активных блоков.\n//\n// ⚠ id — часть формата сейва (дифы хранят id блоков). Только дописывать в конец, никогда не\n// переиспользовать и не переставлять.\n\nimport type { BlockDef } from \"../types\";\n\nfunction block(\n def: Partial<BlockDef> & { id: number; name: string },\n): BlockDef {\n return {\n solid: true,\n opaque: true,\n cross: false,\n liquid: false,\n hardness: 1,\n tool: null,\n drops: null,\n onTick: null,\n tex: null,\n mapColor: 0x808080,\n ...def,\n };\n}\n\n/** Одинаковый тайл на всех гранях. */\nconst all = (tile: string) => ({ top: tile, side: tile, bottom: tile });\n\n/** Цветная шерсть: мягкий блок без инструмента, тайл рисуется по цвету. */\nconst wool = (id: number, name: string, color: number): BlockDef =>\n block({ id, name, hardness: 0.8, tex: all(name), mapColor: color });\n\nexport const BLOCKS: BlockDef[] = [\n block({\n id: 0,\n name: \"air\",\n solid: false,\n opaque: false,\n hardness: Infinity,\n drops: [],\n tex: null,\n mapColor: 0x000000,\n }),\n block({\n id: 1,\n name: \"grass\",\n hardness: 0.9,\n tool: \"shovel\",\n drops: [{ item: \"dirt\", n: 1 }],\n tex: { top: \"grass_top\", side: \"grass_side\", bottom: \"dirt\" },\n mapColor: 0x5fa63c,\n }),\n block({\n id: 2,\n name: \"dirt\",\n hardness: 0.75,\n tool: \"shovel\",\n tex: all(\"dirt\"),\n mapColor: 0x79553a,\n }),\n block({\n id: 3,\n name: \"stone\",\n hardness: 4.0,\n tool: \"pickaxe\",\n drops: [{ item: \"cobblestone\", n: 1 }],\n tex: all(\"stone\"),\n mapColor: 0x7d7d7d,\n }),\n block({\n id: 4,\n name: \"cobblestone\",\n hardness: 4.5,\n tool: \"pickaxe\",\n tex: all(\"cobblestone\"),\n mapColor: 0x828282,\n }),\n block({\n id: 5,\n name: \"bedrock\",\n hardness: Infinity,\n drops: [],\n tex: all(\"bedrock\"),\n mapColor: 0x3a3a3a,\n }),\n block({\n id: 6,\n name: \"sand\",\n hardness: 0.75,\n tool: \"shovel\",\n tex: all(\"sand\"),\n mapColor: 0xdcd3a0,\n }),\n block({\n id: 7,\n name: \"oak_log\",\n hardness: 2.5,\n tool: \"axe\",\n tex: { top: \"oak_log_top\", side: \"oak_log_side\", bottom: \"oak_log_top\" },\n mapColor: 0x6b5030,\n }),\n block({\n id: 8,\n name: \"leaves\",\n opaque: false,\n hardness: 0.05,\n drops: [{ item: \"apple\", n: 1, chance: 0.06 }],\n tex: all(\"leaves\"),\n mapColor: 0x3e7a25,\n }),\n block({\n id: 9,\n name: \"planks\",\n hardness: 2.0,\n tool: \"axe\",\n tex: all(\"planks\"),\n mapColor: 0xa4824c,\n }),\n block({\n id: 10,\n name: \"crafting_table\",\n hardness: 2.0,\n tool: \"axe\",\n tex: {\n top: \"crafting_table_top\",\n side: \"crafting_table_side\",\n bottom: \"planks\",\n },\n mapColor: 0x8a6a3a,\n }),\n block({\n id: 11,\n name: \"water\",\n solid: false,\n opaque: false,\n liquid: true,\n hardness: Infinity,\n drops: [],\n tex: all(\"water\"),\n mapColor: 0x3057d8,\n }),\n block({\n id: 12,\n name: \"coal_ore\",\n hardness: 5.0,\n tool: \"pickaxe\",\n drops: [{ item: \"coal\", n: 1 }],\n tex: all(\"coal_ore\"),\n mapColor: 0x5f5f5f,\n }),\n block({\n id: 13,\n name: \"iron_ore\",\n hardness: 5.5,\n tool: \"pickaxe\",\n tex: all(\"iron_ore\"),\n mapColor: 0x8c8079,\n }),\n block({\n id: 14,\n name: \"tallgrass\",\n solid: false,\n opaque: false,\n cross: true,\n hardness: 0,\n drops: [],\n tex: { side: \"tallgrass\" },\n mapColor: 0x4c8a2f,\n }),\n block({\n id: 15,\n name: \"flower_yellow\",\n solid: false,\n opaque: false,\n cross: true,\n hardness: 0,\n tex: { side: \"flower_yellow\" },\n mapColor: 0xe8c11c,\n }),\n block({\n id: 16,\n name: \"flower_red\",\n solid: false,\n opaque: false,\n cross: true,\n hardness: 0,\n tex: { side: \"flower_red\" },\n mapColor: 0xc22f2f,\n }),\n\n // ---- блоки генератора миров: природа ----------------------------------------\n block({\n id: 17,\n name: \"snow\",\n hardness: 0.5,\n tool: \"shovel\",\n tex: all(\"snow\"),\n mapColor: 0xf2f6fa,\n }),\n block({\n id: 18,\n name: \"ice\",\n hardness: 0.8,\n tool: \"pickaxe\",\n tex: all(\"ice\"),\n mapColor: 0x9cc7f0,\n }),\n block({\n id: 19,\n name: \"gravel\",\n hardness: 0.9,\n tool: \"shovel\",\n tex: all(\"gravel\"),\n mapColor: 0x857f7a,\n }),\n block({\n id: 20,\n name: \"clay\",\n hardness: 0.9,\n tool: \"shovel\",\n tex: all(\"clay\"),\n mapColor: 0x9ea4b0,\n }),\n block({\n id: 21,\n name: \"sandstone\",\n hardness: 3.0,\n tool: \"pickaxe\",\n tex: {\n top: \"sandstone_top\",\n side: \"sandstone_side\",\n bottom: \"sandstone_top\",\n },\n mapColor: 0xd9c98f,\n }),\n block({\n id: 22,\n name: \"red_sand\",\n hardness: 0.75,\n tool: \"shovel\",\n tex: all(\"red_sand\"),\n mapColor: 0xc2703a,\n }),\n block({\n id: 23,\n name: \"terracotta\",\n hardness: 3.0,\n tool: \"pickaxe\",\n tex: all(\"terracotta\"),\n mapColor: 0xa05a3c,\n }),\n\n // ---- строительные ------------------------------------------------------------\n block({\n id: 24,\n name: \"stone_bricks\",\n hardness: 4.0,\n tool: \"pickaxe\",\n tex: all(\"stone_bricks\"),\n mapColor: 0x7a7a7a,\n }),\n block({\n id: 25,\n name: \"mossy_cobblestone\",\n hardness: 4.5,\n tool: \"pickaxe\",\n tex: all(\"mossy_cobblestone\"),\n mapColor: 0x6f7d5f,\n }),\n block({\n id: 26,\n name: \"bricks\",\n hardness: 4.0,\n tool: \"pickaxe\",\n tex: all(\"bricks\"),\n mapColor: 0x9a4a3a,\n }),\n block({\n id: 27,\n name: \"glass\",\n opaque: false, // cutout-проход, как листва: рамка видна, середина прозрачна\n hardness: 0.4,\n tex: all(\"glass\"),\n mapColor: 0xcfe8f0,\n }),\n block({\n id: 28,\n name: \"spruce_log\",\n hardness: 2.5,\n tool: \"axe\",\n tex: {\n top: \"spruce_log_top\",\n side: \"spruce_log_side\",\n bottom: \"spruce_log_top\",\n },\n mapColor: 0x4a3520,\n }),\n block({\n id: 29,\n name: \"spruce_leaves\",\n opaque: false,\n hardness: 0.05,\n drops: [],\n tex: all(\"spruce_leaves\"),\n mapColor: 0x2d5a3a,\n }),\n block({\n id: 30,\n name: \"birch_log\",\n hardness: 2.5,\n tool: \"axe\",\n tex: {\n top: \"birch_log_top\",\n side: \"birch_log_side\",\n bottom: \"birch_log_top\",\n },\n mapColor: 0xd7d2c4,\n }),\n block({\n id: 31,\n name: \"birch_leaves\",\n opaque: false,\n hardness: 0.05,\n drops: [],\n tex: all(\"birch_leaves\"),\n mapColor: 0x5c8f3a,\n }),\n block({\n id: 32,\n name: \"dark_planks\",\n hardness: 2.0,\n tool: \"axe\",\n tex: all(\"dark_planks\"),\n mapColor: 0x5a3d22,\n }),\n block({\n id: 33,\n name: \"dirt_path\",\n hardness: 0.8,\n tool: \"shovel\",\n drops: [{ item: \"dirt\", n: 1 }],\n tex: { top: \"dirt_path_top\", side: \"dirt\", bottom: \"dirt\" },\n mapColor: 0x9a7a45,\n }),\n\n // ---- шерсть: палитра для картинок с яркими цветами -----------------------------\n wool(34, \"white_wool\", 0xe9ecec),\n wool(35, \"red_wool\", 0xa12722),\n wool(36, \"orange_wool\", 0xf07613),\n wool(37, \"yellow_wool\", 0xf8c527),\n wool(38, \"green_wool\", 0x5e7c16),\n wool(39, \"blue_wool\", 0x35399d),\n wool(40, \"light_blue_wool\", 0x3aafd9),\n wool(41, \"purple_wool\", 0x7a2aa9),\n wool(42, \"pink_wool\", 0xed8dac),\n wool(43, \"brown_wool\", 0x724728),\n wool(44, \"gray_wool\", 0x3e4447),\n wool(45, \"black_wool\", 0x1d1d21),\n];\n\n/** Быстрый доступ по имени: B.stone === 3. */\nexport const B: Record<string, number> = {};\nfor (const b of BLOCKS) B[b.name] = b.id;\n\n/** Имена шерсти — отдельным списком: палитра картинок и подписи. */\nexport const WOOL_BLOCKS = BLOCKS.filter((b) => b.name.endsWith(\"_wool\")).map(\n (b) => b.name,\n);\n\n/** Мета-флаг воды: бит 0x08 = source-блок, биты 0..2 = уровень-1 (0..7). */\nexport const WATER_SOURCE = 0x08;\nexport const waterLevel = (meta: number): number => (meta & 0x07) + 1;\nexport const packWater = (level: number, source: boolean): number =>\n ((level - 1) & 0x07) | (source ? WATER_SOURCE : 0);\nexport const isWaterSource = (meta: number): boolean =>\n (meta & WATER_SOURCE) !== 0;\n"
116
140
  },
117
141
  {
118
142
  "path": "registry/Items.ts",
119
- "content": "// Реестр предметов. Каждому размещаемому блоку автоматически создаётся\n// предмет-двойник (blockId), плюс вручную — материалы и инструменты.\n//\n// Поля:\n// maxStack — размер стака (инструменты не стакаются)\n// kind — 'block' | 'material' | 'tool'\n// toolType — 'pickaxe'|'axe'|'sword' (у инструментов)\n// speed — множитель скорости добычи блоков соответствующего tool\n// damage — урон по сущностям (по умолчанию 1 — «кулак»)\n// tile — тайл атласа для плоской иконки (у не-блоков)\nimport { BLOCKS } from \"./Blocks\";\nimport type { ItemDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n// food — очки восстановления голода; saturation — буфер сытости (по умолчанию food*0.6)\nfunction item(def: Partial<ItemDef> & { name: string }): ItemDef {\n return {\n maxStack: 64,\n kind: \"material\",\n toolType: null,\n speed: 1,\n damage: 1,\n blockId: null,\n tile: null,\n food: 0,\n saturation: 0,\n ...def,\n };\n}\n\nexport const ITEMS: Record<string, ItemDef> = {};\n\n// Предметы-блоки: всё, что можно держать и ставить.\nconst PLACEABLE = [\n \"grass\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"sand\",\n \"oak_log\",\n \"leaves\",\n \"planks\",\n \"crafting_table\",\n \"coal_ore\",\n \"iron_ore\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n];\nfor (const name of PLACEABLE) {\n const b = BLOCKS.find((x) => x.name === name)!;\n ITEMS[name] = item({ name, kind: \"block\", blockId: b.id });\n}\n\n// Материалы.\nITEMS.stick = item({ name: \"stick\", tile: \"stick\" });\nITEMS.coal = item({ name: \"coal\", tile: \"coal_item\" });\nITEMS.feather = item({ name: \"feather\", tile: \"feather\" });\n\n// Еда (food = очки голода из 20). Сырая курятина слабая, жареная — сытная\n// (появится при добавлении печи), яблоко падает с листвы.\nITEMS.raw_chicken = item({\n name: \"raw_chicken\",\n tile: \"raw_chicken\",\n food: 2,\n saturation: 1.2,\n});\nITEMS.cooked_chicken = item({\n name: \"cooked_chicken\",\n tile: \"cooked_chicken\",\n food: 6,\n saturation: 7.2,\n});\nITEMS.apple = item({ name: \"apple\", tile: \"apple\", food: 4, saturation: 2.4 });\n\n// Инструменты. speed — во сколько раз быстрее ломаются блоки с matching tool.\nITEMS.wooden_pickaxe = item({\n name: \"wooden_pickaxe\",\n kind: \"tool\",\n toolType: \"pickaxe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_pickaxe\",\n});\nITEMS.wooden_axe = item({\n name: \"wooden_axe\",\n kind: \"tool\",\n toolType: \"axe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_axe\",\n});\nITEMS.wooden_sword = item({\n name: \"wooden_sword\",\n kind: \"tool\",\n toolType: \"sword\",\n speed: 1,\n damage: 4,\n maxStack: 1,\n tile: \"wooden_sword\",\n});\n\n/** Русские названия для тултипов HUD. */\nexport const ITEM_LABELS: Record<string, string> = {\n grass: \"Дёрн\",\n dirt: \"Земля\",\n stone: \"Камень\",\n cobblestone: \"Булыжник\",\n sand: \"Песок\",\n oak_log: \"Бревно\",\n leaves: \"Листва\",\n planks: \"Доски\",\n crafting_table: \"Верстак\",\n coal_ore: \"Угольная руда\",\n iron_ore: \"Железная руда\",\n tallgrass: \"Трава\",\n flower_yellow: \"Одуванчик\",\n flower_red: \"Мак\",\n stick: \"Палка\",\n coal: \"Уголь\",\n feather: \"Перо\",\n raw_chicken: \"Сырая курятина\",\n cooked_chicken: \"Жареная курятина\",\n apple: \"Яблоко\",\n wooden_pickaxe: \"Деревянная кирка\",\n wooden_axe: \"Деревянный топор\",\n wooden_sword: \"Деревянный меч\",\n};\n\n/**\n * Генерация иконок для HUD/инвентаря из тех же текстур атласа.\n * Вызывается один раз из main.js после создания атласа.\n */\nexport function buildIcons(atlas: TextureAtlas): void {\n for (const it of Object.values(ITEMS)) {\n if (it.kind === \"block\") {\n const b = BLOCKS[it.blockId!]!;\n const tex = b.tex!;\n it.icon = b.cross\n ? atlas.flatIcon(tex.side!)\n : atlas.blockIcon(tex.top!, tex.side!);\n } else {\n it.icon = atlas.flatIcon(it.tile!);\n }\n it.iconURL = it.icon!.toDataURL();\n }\n}\n"
143
+ "content": "// Реестр предметов. Каждому размещаемому блоку автоматически создаётся\n// предмет-двойник (blockId), плюс вручную — материалы и инструменты.\n//\n// Поля:\n// maxStack — размер стака (инструменты не стакаются)\n// kind — 'block' | 'material' | 'tool'\n// toolType — 'pickaxe'|'axe'|'sword' (у инструментов)\n// speed — множитель скорости добычи блоков соответствующего tool\n// damage — урон по сущностям (по умолчанию 1 — «кулак»)\n// tile — тайл атласа для плоской иконки (у не-блоков)\nimport { BLOCKS, WOOL_BLOCKS } from \"./Blocks\";\nimport type { ItemDef } from \"../types\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n// food — очки восстановления голода; saturation — буфер сытости (по умолчанию food*0.6)\nfunction item(def: Partial<ItemDef> & { name: string }): ItemDef {\n return {\n maxStack: 64,\n kind: \"material\",\n toolType: null,\n speed: 1,\n damage: 1,\n blockId: null,\n tile: null,\n food: 0,\n saturation: 0,\n ...def,\n };\n}\n\nexport const ITEMS: Record<string, ItemDef> = {};\n\n// Предметы-блоки: всё, что можно держать и ставить.\nconst PLACEABLE = [\n \"grass\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"sand\",\n \"oak_log\",\n \"leaves\",\n \"planks\",\n \"crafting_table\",\n \"coal_ore\",\n \"iron_ore\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n // блоки генератора миров\n \"snow\",\n \"ice\",\n \"gravel\",\n \"clay\",\n \"sandstone\",\n \"red_sand\",\n \"terracotta\",\n \"stone_bricks\",\n \"mossy_cobblestone\",\n \"bricks\",\n \"glass\",\n \"spruce_log\",\n \"spruce_leaves\",\n \"birch_log\",\n \"birch_leaves\",\n \"dark_planks\",\n \"dirt_path\",\n ...WOOL_BLOCKS,\n];\nfor (const name of PLACEABLE) {\n const b = BLOCKS.find((x) => x.name === name)!;\n ITEMS[name] = item({ name, kind: \"block\", blockId: b.id });\n}\n\n// Материалы.\nITEMS.stick = item({ name: \"stick\", tile: \"stick\" });\nITEMS.coal = item({ name: \"coal\", tile: \"coal_item\" });\nITEMS.feather = item({ name: \"feather\", tile: \"feather\" });\n\n// Еда (food = очки голода из 20). Сырая курятина слабая, жареная — сытная\n// (появится при добавлении печи), яблоко падает с листвы.\nITEMS.raw_chicken = item({\n name: \"raw_chicken\",\n tile: \"raw_chicken\",\n food: 2,\n saturation: 1.2,\n});\nITEMS.cooked_chicken = item({\n name: \"cooked_chicken\",\n tile: \"cooked_chicken\",\n food: 6,\n saturation: 7.2,\n});\nITEMS.apple = item({ name: \"apple\", tile: \"apple\", food: 4, saturation: 2.4 });\n\n// Инструменты. speed — во сколько раз быстрее ломаются блоки с matching tool.\nITEMS.wooden_pickaxe = item({\n name: \"wooden_pickaxe\",\n kind: \"tool\",\n toolType: \"pickaxe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_pickaxe\",\n});\nITEMS.wooden_axe = item({\n name: \"wooden_axe\",\n kind: \"tool\",\n toolType: \"axe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_axe\",\n});\nITEMS.wooden_sword = item({\n name: \"wooden_sword\",\n kind: \"tool\",\n toolType: \"sword\",\n speed: 1,\n damage: 4,\n maxStack: 1,\n tile: \"wooden_sword\",\n});\n\n/** Русские названия для тултипов HUD. */\nexport const ITEM_LABELS: Record<string, string> = {\n grass: \"Дёрн\",\n dirt: \"Земля\",\n stone: \"Камень\",\n cobblestone: \"Булыжник\",\n sand: \"Песок\",\n oak_log: \"Бревно\",\n leaves: \"Листва\",\n planks: \"Доски\",\n crafting_table: \"Верстак\",\n coal_ore: \"Угольная руда\",\n iron_ore: \"Железная руда\",\n tallgrass: \"Трава\",\n flower_yellow: \"Одуванчик\",\n flower_red: \"Мак\",\n snow: \"Снег\",\n ice: \"Лёд\",\n gravel: \"Гравий\",\n clay: \"Глина\",\n sandstone: \"Песчаник\",\n red_sand: \"Красный песок\",\n terracotta: \"Терракота\",\n stone_bricks: \"Каменный кирпич\",\n mossy_cobblestone: \"Замшелый булыжник\",\n bricks: \"Кирпич\",\n glass: \"Стекло\",\n spruce_log: \"Еловое бревно\",\n spruce_leaves: \"Хвоя\",\n birch_log: \"Берёзовое бревно\",\n birch_leaves: \"Берёзовая листва\",\n dark_planks: \"Тёмные доски\",\n dirt_path: \"Тропа\",\n white_wool: \"Белая шерсть\",\n red_wool: \"Красная шерсть\",\n orange_wool: \"Оранжевая шерсть\",\n yellow_wool: \"Жёлтая шерсть\",\n green_wool: \"Зелёная шерсть\",\n blue_wool: \"Синяя шерсть\",\n light_blue_wool: \"Голубая шерсть\",\n purple_wool: \"Фиолетовая шерсть\",\n pink_wool: \"Розовая шерсть\",\n brown_wool: \"Коричневая шерсть\",\n gray_wool: \"Серая шерсть\",\n black_wool: \"Чёрная шерсть\",\n stick: \"Палка\",\n coal: \"Уголь\",\n feather: \"Перо\",\n raw_chicken: \"Сырая курятина\",\n cooked_chicken: \"Жареная курятина\",\n apple: \"Яблоко\",\n wooden_pickaxe: \"Деревянная кирка\",\n wooden_axe: \"Деревянный топор\",\n wooden_sword: \"Деревянный меч\",\n};\n\n/**\n * Генерация иконок для HUD/инвентаря из тех же текстур атласа.\n * Вызывается один раз из main.js после создания атласа.\n */\nexport function buildIcons(atlas: TextureAtlas): void {\n for (const it of Object.values(ITEMS)) {\n if (it.kind === \"block\") {\n const b = BLOCKS[it.blockId!]!;\n const tex = b.tex!;\n it.icon = b.cross\n ? atlas.flatIcon(tex.side!)\n : atlas.blockIcon(tex.top!, tex.side!);\n } else {\n it.icon = atlas.flatIcon(it.tile!);\n }\n it.iconURL = it.icon!.toDataURL();\n }\n}\n"
120
144
  },
121
145
  {
122
146
  "path": "registry/Mobs.ts",
@@ -128,7 +152,7 @@
128
152
  },
129
153
  {
130
154
  "path": "scene.ts",
131
- "content": "import type {\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { Game } from \"./main\";\nimport \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">&rarr;</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> run</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory &amp; 2&times;2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3&times;3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"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`;\n\n/**\n * Сцена + debug-поверхность для агента одной парой: обе смотрят на ОДИН экземпляр игры, который\n * создаётся в `mount()` и обнуляется в `destroy()`. Поэтому агент получает не объект, а доступ к\n * текущему `game` — иначе после пересоздания сцены он держал бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(): {\n scene: EngineScene;\n agent: ModuleAgentApi;\n} {\n let game: Game | null = null;\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n },\n );\n return { scene, agent: createVoxelcraftAgentApi(() => game) };\n}\n\nfunction buildScene(\n getGame: () => Game | null,\n setGame: (game: Game | null) => void,\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());\n },\n activate(): void {\n getGame()?.setRunning(true);\n },\n suspend(): void {\n getGame()?.setRunning(false);\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"
155
+ "content": "import type {\n ContentTypeHandler,\n EngineScene,\n ModuleAgentApi,\n SceneMountContext,\n} from \"@idosgames/module-sdk\";\nimport { createVoxelcraftAgentApi } from \"./agent\";\nimport { createVoxelWorldContentType } from \"./workshop\";\nimport { Game, type GameOptions } from \"./main\";\nimport \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">&rarr;</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> run</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory &amp; 2&times;2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3&times;3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"btnWorlds\" class=\"accent\">Create world</button>\n <button id=\"btnExport\">Export world</button>\n <button id=\"btnImport\">Import world</button>\n <button id=\"btnNewSeed\">New seed</button>\n </div>\n </div>\n </div>\n <div id=\"worldScreen\" class=\"hidden\">\n <div class=\"wsPanel\">\n <div class=\"wsHead\">\n <button class=\"wsTab active\" data-tab=\"new\">Новый мир</button>\n <button class=\"wsTab\" data-tab=\"mine\">Мои миры</button>\n <button id=\"wsClose\" class=\"wsClose\" title=\"Закрыть (Esc)\">&times;</button>\n </div>\n <div id=\"wsNew\" class=\"wsBody\">\n <div class=\"wsForm\">\n <div class=\"wsLabel\">Картинки-референсы <span class=\"wsNote\">до 4, можно один объект с разных сторон</span></div>\n <div id=\"wsDrop\" class=\"wsDrop\" title=\"Клик, перетаскивание или Ctrl+V\">\n <div id=\"wsThumbs\" class=\"wsThumbs hidden\"></div>\n <span id=\"wsDropHint\">Перетащите картинки, вставьте Ctrl+V или нажмите, чтобы выбрать</span>\n </div>\n <div class=\"wsRow\">\n <label title=\"ИИ сам поймёт по описанию, что на картинках: объект, который надо построить из блоков, стиль или место\">\n <input type=\"radio\" name=\"wsRead\" value=\"reference\" checked /> Образец для ИИ</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"topdown\" /> Карта сверху</label>\n <label><input type=\"radio\" name=\"wsRead\" value=\"mood\" /> Настроение</label>\n <button id=\"wsClearImg\" class=\"wsLink hidden\">убрать картинки</button>\n </div>\n <label class=\"wsLabel\" for=\"wsText\">Описание</label>\n <textarea id=\"wsText\" rows=\"3\" maxlength=\"600\"\n placeholder=\"Например: тропический остров, на взлётной полосе стоит самолёт с картинок\"></textarea>\n <div class=\"wsLabel\">Размер</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsSize\" value=\"island\" checked /> Остров 256</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"large\" /> Большой 512</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"infinite\" /> Бесконечный</label>\n <label><input type=\"radio\" name=\"wsSize\" value=\"custom\" /> Свой</label>\n <span id=\"wsCustom\" class=\"hidden\">\n <input id=\"wsW\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> &times;\n <input id=\"wsD\" type=\"number\" min=\"4\" max=\"48\" value=\"24\" /> чанков\n </span>\n </div>\n <div class=\"wsLabel\">Как строить</div>\n <div class=\"wsRow\">\n <label><input type=\"radio\" name=\"wsMode\" value=\"local\" checked /> Без ИИ</label>\n <label id=\"wsAiLabel\"><input type=\"radio\" name=\"wsMode\" value=\"ai\" disabled /> С ИИ</label>\n <span id=\"wsAiNote\" class=\"wsNote\"></span>\n </div>\n <div class=\"wsButtons\">\n <button id=\"wsGenerate\" class=\"wsPrimary\">Сгенерировать</button>\n <button id=\"wsLoadSpec\">Загрузить рецепт</button>\n </div>\n <div id=\"wsStatus\" class=\"wsStatus\"></div>\n </div>\n <div class=\"wsPreview\">\n <canvas id=\"wsMap\" width=\"256\" height=\"256\"></canvas>\n <div id=\"wsInfo\" class=\"wsInfo\">Здесь появится карта мира</div>\n <div class=\"wsButtons\">\n <button id=\"wsPlay\" class=\"wsPrimary\" disabled>Играть в этом мире</button>\n <button id=\"wsSaveSpec\" disabled>Скачать рецепт</button>\n </div>\n </div>\n </div>\n <div id=\"wsMine\" class=\"wsBody hidden\"><div id=\"wsList\" class=\"wsList\"></div></div>\n </div>\n </div>\n`;\n\n/**\n * Сцена + debug-поверхность для агента + тип контента для Мастерской: все смотрят на ОДИН экземпляр\n * игры, который создаётся в `mount()` и обнуляется в `destroy()`. Поэтому они получают не объект, а\n * доступ к текущему `game` — иначе после пересоздания сцены держали бы ссылку на мёртвую игру.\n */\nexport function createVoxelcraft(opts: GameOptions = {}): {\n scene: EngineScene;\n agent: ModuleAgentApi;\n content: ContentTypeHandler;\n} {\n let game: Game | null = null;\n const scene = buildScene(\n () => game,\n (next) => {\n game = next;\n },\n opts,\n );\n return {\n scene,\n agent: createVoxelcraftAgentApi(() => game),\n content: createVoxelWorldContentType(() => game),\n };\n}\n\nfunction buildScene(\n getGame: () => Game | null,\n setGame: (game: Game | null) => void,\n opts: GameOptions,\n): EngineScene {\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n // The game reads its elements by id from the document; provide them inside the host surface.\n ctx.host.innerHTML = VOXEL_DOM;\n setGame(new Game(opts));\n },\n activate(): void {\n getGame()?.setRunning(true);\n },\n suspend(): void {\n getGame()?.setRunning(false);\n },\n 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"
132
156
  },
133
157
  {
134
158
  "path": "shims.d.ts",
@@ -136,43 +160,55 @@
136
160
  },
137
161
  {
138
162
  "path": "style.css",
139
- "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#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 62px);\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) + 62px);\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.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#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 display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid {\n display: grid;\n gap: 3px;\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"
163
+ "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#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 62px);\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) + 62px);\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.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#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 display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid {\n display: grid;\n gap: 3px;\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"
140
164
  },
141
165
  {
142
166
  "path": "systems/Audio.ts",
143
- "content": "// Звук: синтез через Web Audio, без файлов. Подписан на события шины —\n// добавление нового звука = ещё одна подписка, игровая логика не знает об аудио.\nimport { events } from \"../core/EventBus\";\n\nexport class Audio {\n ctx: AudioContext | null;\n\n constructor() {\n this.ctx = null; // создаётся после первого user gesture (autoplay policy)\n\n events.on(\"blockBreak\", () => this.noise(0.12, 900, 0.25));\n events.on(\"blockPlace\", () => this.noise(0.06, 1600, 0.2));\n events.on(\"itemPickup\", () => this.blip(880, 0.08, \"triangle\", 0.15, 1320));\n events.on(\"playerHurt\", () => this.blip(160, 0.2, \"sawtooth\", 0.2, 110));\n events.on(\"mobHurt\", () => this.blip(520, 0.1, \"square\", 0.12, 380));\n events.on(\"playerStep\", () => this.noise(0.045, 500, 0.06));\n events.on(\"craft\", () => this.blip(660, 0.1, \"triangle\", 0.12, 990));\n events.on(\"armSwing\", () => this.noise(0.04, 2400, 0.04));\n events.on(\"playerAte\", () => {\n this.noise(0.09, 700, 0.14);\n this.blip(300, 0.12, \"sine\", 0.1, 420);\n });\n }\n\n /** Вызвать из обработчика клика (оверлей «Играть»). */\n unlock() {\n if (!this.ctx)\n this.ctx = new (\n window.AudioContext ||\n (window as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext!\n )();\n if (this.ctx.state === \"suspended\") this.ctx.resume();\n }\n\n /** Тональный писк с глиссандо. */\n blip(\n freq: number,\n dur: number,\n type: OscillatorType = \"square\",\n vol = 0.15,\n glideTo: number | null = null,\n ) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const osc = this.ctx.createOscillator();\n const gain = this.ctx.createGain();\n osc.type = type;\n osc.frequency.setValueAtTime(freq, t);\n if (glideTo) osc.frequency.exponentialRampToValueAtTime(glideTo, t + dur);\n gain.gain.setValueAtTime(vol, t);\n gain.gain.exponentialRampToValueAtTime(0.001, t + dur);\n osc.connect(gain).connect(this.ctx.destination);\n osc.start(t);\n osc.stop(t + dur + 0.02);\n }\n\n /** Шумовой щелчок через фильтр (ломание/шаги). */\n noise(dur: number, filterFreq: number, vol = 0.2) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const len = Math.max(1, (dur * this.ctx.sampleRate) | 0);\n const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);\n const data = buf.getChannelData(0);\n for (let i = 0; i < len; i++)\n data[i] = (Math.random() * 2 - 1) * (1 - i / len);\n const src = this.ctx.createBufferSource();\n src.buffer = buf;\n const filter = this.ctx.createBiquadFilter();\n filter.type = \"lowpass\";\n filter.frequency.value = filterFreq;\n const gain = this.ctx.createGain();\n gain.gain.value = vol;\n src.connect(filter).connect(gain).connect(this.ctx.destination);\n src.start(t);\n }\n}\n"
167
+ "content": "// Звук: синтез через Web Audio, без файлов. Подписан на события шины —\n// добавление нового звука = ещё одна подписка, игровая логика не знает об аудио.\nimport { events } from \"../core/EventBus\";\n\nexport class Audio {\n ctx: AudioContext | null;\n\n constructor() {\n this.ctx = null; // создаётся после первого user gesture (autoplay policy)\n\n events.on(\"blockBreak\", () => this.noise(0.12, 900, 0.25));\n events.on(\"blockPlace\", () => this.noise(0.06, 1600, 0.2));\n events.on(\"itemPickup\", () => this.blip(880, 0.08, \"triangle\", 0.15, 1320));\n events.on(\"playerHurt\", () => this.blip(160, 0.2, \"sawtooth\", 0.2, 110));\n events.on(\"mobHurt\", () => this.blip(520, 0.1, \"square\", 0.12, 380));\n events.on(\"playerStep\", () => this.noise(0.045, 500, 0.06));\n events.on(\"craft\", () => this.blip(660, 0.1, \"triangle\", 0.12, 990));\n events.on(\"armSwing\", () => this.noise(0.04, 2400, 0.04));\n events.on(\"playerAte\", () => {\n this.noise(0.09, 700, 0.14);\n this.blip(300, 0.12, \"sine\", 0.1, 420);\n });\n }\n\n /** Вызвать из обработчика клика (оверлей «Играть»). */\n unlock() {\n if (!this.ctx)\n this.ctx = new (\n window.AudioContext ||\n (window as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext!\n )();\n if (this.ctx.state === \"suspended\") this.ctx.resume();\n }\n\n /** Teardown: у браузера ограниченное число AudioContext — старый нужно закрыть. */\n dispose() {\n void this.ctx?.close();\n this.ctx = null;\n }\n\n /** Тональный писк с глиссандо. */\n blip(\n freq: number,\n dur: number,\n type: OscillatorType = \"square\",\n vol = 0.15,\n glideTo: number | null = null,\n ) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const osc = this.ctx.createOscillator();\n const gain = this.ctx.createGain();\n osc.type = type;\n osc.frequency.setValueAtTime(freq, t);\n if (glideTo) osc.frequency.exponentialRampToValueAtTime(glideTo, t + dur);\n gain.gain.setValueAtTime(vol, t);\n gain.gain.exponentialRampToValueAtTime(0.001, t + dur);\n osc.connect(gain).connect(this.ctx.destination);\n osc.start(t);\n osc.stop(t + dur + 0.02);\n }\n\n /** Шумовой щелчок через фильтр (ломание/шаги). */\n noise(dur: number, filterFreq: number, vol = 0.2) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const len = Math.max(1, (dur * this.ctx.sampleRate) | 0);\n const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);\n const data = buf.getChannelData(0);\n for (let i = 0; i < len; i++)\n data[i] = (Math.random() * 2 - 1) * (1 - i / len);\n const src = this.ctx.createBufferSource();\n src.buffer = buf;\n const filter = this.ctx.createBiquadFilter();\n filter.type = \"lowpass\";\n filter.frequency.value = filterFreq;\n const gain = this.ctx.createGain();\n gain.gain.value = vol;\n src.connect(filter).connect(gain).connect(this.ctx.destination);\n src.start(t);\n }\n}\n"
144
168
  },
145
169
  {
146
170
  "path": "systems/Crafting.ts",
147
- "content": "// Крафт: состояние сетки (2×2 инвентарь / 3×3 верстак) + матчинг рецептов.\n// Слоты сетки — такие же стаки, как в инвентаре; UI двигает их drag-and-drop'ом.\nimport { events } from \"../core/EventBus\";\nimport { matchRecipe } from \"../registry/Recipes\";\nimport type { ItemStack, RecipeResult } from \"../types\";\nimport type { Inventory } from \"./Inventory\";\n\nexport class CraftingGrid {\n size: number;\n slots: (ItemStack | null)[];\n\n constructor(size = 2) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n setSize(size: number) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n /** Текущий результат по рецептам (не изымает ингредиенты). */\n result(): RecipeResult | null {\n return matchRecipe(\n this.slots.map((s) => (s ? s.item : null)),\n this.size,\n );\n }\n\n /** Забрать результат: по 1 предмету из каждой занятой клетки. */\n takeResult(): RecipeResult | null {\n const res = this.result();\n if (!res) return null;\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n s.count -= 1;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n events.emit(\"craft\", { item: res.item, count: res.count });\n return { ...res };\n }\n\n /** Вернуть всё из сетки в инвентарь (закрытие окна). */\n dumpInto(inventory: Inventory) {\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n const left = inventory.add(s.item, s.count);\n this.slots[i] = left > 0 ? { item: s.item, count: left } : null;\n // если инвентарь полон — остаток остаётся в сетке (не теряем предметы)\n }\n }\n }\n}\n"
171
+ "content": "// Крафт: состояние сетки (2×2 инвентарь / 3×3 верстак) + матчинг рецептов.\n// Слоты сетки — такие же стаки, как в инвентаре; UI двигает их drag-and-drop'ом.\nimport { events } from \"../core/EventBus\";\nimport { matchRecipe } from \"../registry/Recipes\";\nimport type { ItemStack, RecipeResult } from \"../types\";\nimport type { Inventory } from \"./Inventory\";\n\nexport class CraftingGrid {\n size: number;\n slots: (ItemStack | null)[];\n\n constructor(size = 2) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n setSize(size: number) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n /** Текущий результат по рецептам (не изымает ингредиенты). */\n result(): RecipeResult | null {\n return matchRecipe(\n this.slots.map((s) => (s ? s.item : null)),\n this.size,\n );\n }\n\n /** Забрать результат: по 1 предмету из каждой занятой клетки. */\n takeResult(): RecipeResult | null {\n const res = this.result();\n if (!res) return null;\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n s.count -= 1;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n events.emit(\"craft\", { item: res.item, count: res.count });\n return { ...res };\n }\n\n /** Вернуть всё из сетки в инвентарь (закрытие окна). */\n dumpInto(inventory: Inventory) {\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n const left = inventory.add(s.item, s.count);\n this.slots[i] = left > 0 ? { item: s.item, count: left } : null;\n // если инвентарь полон — остаток остаётся в сетке; выбросить его — дело вызывающего\n // (InventoryUI.close), иначе следующий setSize() его обнулит\n }\n }\n }\n}\n"
148
172
  },
149
173
  {
150
174
  "path": "systems/DayNight.ts",
151
- "content": "// Цикл суток: 30 c день + 30 c ночь. Плавно лерпаются цвет неба, туман,\n// интенсивность/цвет солнца и ambient; солнце и луна — спрайты на орбите камеры.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\n\n// Ключевые точки цикла: t в [0..1), 0 = рассвет.\nconst KEYS = [\n {\n t: 0.0,\n sky: 0xffa958,\n sun: 0xffd9a0,\n sunI: 0.55,\n amb: 0.45,\n fog: 0xffc890,\n }, // рассвет\n { t: 0.1, sky: 0x87ceeb, sun: 0xffffff, sunI: 1.0, amb: 0.65, fog: 0xbfe3f5 }, // день\n { t: 0.4, sky: 0x87ceeb, sun: 0xffffff, sunI: 1.0, amb: 0.65, fog: 0xbfe3f5 },\n { t: 0.5, sky: 0xff7b45, sun: 0xffb070, sunI: 0.45, amb: 0.4, fog: 0xe8956a }, // закат\n {\n t: 0.6,\n sky: 0x0a1030,\n sun: 0x223055,\n sunI: 0.12,\n amb: 0.18,\n fog: 0x0e1638,\n }, // ночь\n {\n t: 0.9,\n sky: 0x0a1030,\n sun: 0x223055,\n sunI: 0.12,\n amb: 0.18,\n fog: 0x0e1638,\n },\n {\n t: 1.0,\n sky: 0xffa958,\n sun: 0xffd9a0,\n sunI: 0.55,\n amb: 0.45,\n fog: 0xffc890,\n },\n];\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 ambient: THREE.AmbientLight;\n skyColor: THREE.Color;\n sunSprite: THREE.Sprite;\n moonSprite: THREE.Sprite;\n _colA: THREE.Color;\n _colB: THREE.Color;\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 = this.cycleLength * 0.15; // старт утром\n this.sun = new THREE.DirectionalLight(0xffffff, 1);\n this.ambient = new THREE.AmbientLight(0xffffff, 0.65);\n scene.add(this.sun, this.ambient);\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 /** 0..1 фазы цикла (0 — рассвет). */\n get phase() {\n return this.time / this.cycleLength;\n }\n get isDay() {\n return this.phase < 0.5;\n }\n\n /** Кадровое обновление: интерполяция ключей + позиции светил вокруг игрока. */\n frame(playerPos: THREE.Vector3, underwater: boolean) {\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\n lerpC(this.skyColor, a.sky, b.sky);\n lerpC(this.sun.color, a.sun, b.sun);\n this.sun.intensity = a.sunI + (b.sunI - a.sunI) * f;\n this.ambient.intensity = a.amb + (b.amb - a.amb) * f;\n\n const fog = this.scene.fog as THREE.Fog;\n lerpC(fog.color, a.fog, b.fog);\n if (underwater) {\n fog.color.setHex(0x1a4a8a);\n fog.near = 2;\n fog.far = 18;\n } else {\n fog.near = 20;\n fog.far = CONFIG.renderDistance * 16 - 8;\n }\n this.renderer.setClearColor(underwater ? 0x1a4a8a : this.skyColor);\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 = new THREE.Vector3(az, Math.max(-0.4, elev), 0.3).normalize();\n this.sun.position.copy(playerPos).addScaledVector(sunDir, 60);\n this.sun.target.position.copy(playerPos);\n this.sun.target.updateMatrixWorld();\n\n this.sunSprite.position.copy(playerPos).addScaledVector(sunDir, 70);\n this.moonSprite.position\n .copy(playerPos)\n .addScaledVector(sunDir.clone().negate(), 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\n serialize() {\n return { time: this.time };\n }\n deserialize(d: { time?: number }) {\n this.time = d.time ?? this.time;\n }\n}\n"
175
+ "content": "// Цикл суток: CONFIG.dayNight.dayLength дня + nightLength ночи. Плавно лерпаются цвет неба, туман,\n// свет солнца/луны и небосвода; солнце и луна — спрайты на орбите камеры.\n//\n// Освещение в казуальном стиле: тёплое солнце с мягкими тенями (PCF + radius) и полусферический\n// свет вместо плоского ambient — теневые стороны берут цвет неба сверху и тёплый отражённый от\n// земли снизу, а не проваливаются в серое.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\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 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 frame(playerPos: THREE.Vector3, underwater: boolean) {\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 (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 this.renderer.setClearColor(underwater ? 0x1a4a8a : this.skyColor);\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\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"
152
176
  },
153
177
  {
154
178
  "path": "systems/Inventory.ts",
155
- "content": "// Инвентарь — чистая модель данных без DOM (UI подписывается на invChanged).\n// 36 слотов: 0..8 — хотбар, 9..35 — рюкзак. Слот: {item: string, count: number} | null.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ItemStack, InventorySave } from \"../types\";\n\nexport class Inventory {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\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 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) {\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 this._changed();\n return count;\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 /** Съесть 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 serialize(): InventorySave {\n return { slots: this.slots, hotbarIndex: this.hotbarIndex };\n }\n deserialize(d: InventorySave) {\n this.slots = d.slots.map((s) => (s ? { ...s } : null));\n while (this.slots.length < 36) this.slots.push(null);\n this.hotbarIndex = d.hotbarIndex ?? 0;\n this._changed();\n }\n}\n"
179
+ "content": "// Инвентарь — чистая модель данных без DOM (UI подписывается на invChanged).\n// 36 слотов: 0..8 — хотбар, 9..35 — рюкзак. Слот: {item: string, count: number} | null.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { ItemStack, InventorySave } from \"../types\";\n\nexport class Inventory {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\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) {\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 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 /** Съесть 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 serialize(): InventorySave {\n return { slots: this.slots, hotbarIndex: this.hotbarIndex };\n }\n deserialize(d: InventorySave) {\n this.slots = d.slots.map((s) => (s ? { ...s } : null));\n while (this.slots.length < 36) this.slots.push(null);\n this.hotbarIndex = d.hotbarIndex ?? 0;\n this._changed();\n }\n}\n"
156
180
  },
157
181
  {
158
182
  "path": "systems/Save.ts",
159
- "content": "// Сохранение: экспорт/импорт мира в JSON-файл. Формат = полный снапшот\n// этапа 2 (мультиплеер): сид + дифы блоков + игрок + инвентарь + сущности + время.\nimport { CONFIG } from \"../config\";\nimport type { Game } from \"../main\";\nimport type { SaveData } from \"../types\";\n\nexport class SaveSystem {\n game: Game;\n\n constructor(game: Game) {\n this.game = game;\n } // game — фасад из main.js\n\n collect(): SaveData {\n const g = this.game;\n return {\n version: CONFIG.saveVersion,\n seed: g.world.seed,\n time: g.dayNight.serialize(),\n player: { ...g.player.serialize(), inventory: g.inventory.serialize() },\n chunks: g.world.serializeDiffs(),\n entities: g.entities.serialize(),\n };\n }\n\n exportFile() {\n const data = JSON.stringify(this.collect());\n const blob = new Blob([data], { type: \"application/json\" });\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(blob);\n a.download = `voxelcraft-save-${this.game.world.seed}.json`;\n a.click();\n URL.revokeObjectURL(a.href);\n }\n\n /** Импорт из выбранного файла. Пересоздаёт мир по сиду и накатывает диф. */\n importFile(onDone?: (err: unknown) => void) {\n const input = document.createElement(\"input\");\n input.type = \"file\";\n input.accept = \".json,application/json\";\n input.onchange = () => {\n const file = input.files?.[0];\n if (!file) return;\n const reader = new FileReader();\n reader.onload = () => {\n try {\n const data = JSON.parse(reader.result as string);\n if (typeof data.seed !== \"number\" || !data.chunks)\n throw new Error(\"не похоже на сейв VoxelCraft\");\n this.apply(data);\n onDone?.(null);\n } catch (e) {\n onDone?.(e);\n }\n };\n reader.readAsText(file);\n };\n input.click();\n }\n\n apply(data: SaveData) {\n const g = this.game;\n g.rebuildWorld(data.seed); // новый ChunkManager с этим сидом\n g.world.loadDiffs(data.chunks);\n g.player.deserialize(data.player);\n g.inventory.deserialize(data.player.inventory);\n g.dayNight.deserialize(data.time);\n g.entities.deserialize(data.entities);\n }\n}\n"
183
+ "content": "// Сохранение: экспорт/импорт мира в JSON-файл. Формат = полный снапшот\n// этапа 2 (мультиплеер): источник мира (сид или рецепт) + дифы блоков + игрок + инвентарь +\n// сущности + время. Мир восстанавливается повторной генерацией, поверх неё ложатся дифы.\n//\n// v1 — только сид (классический мир). v2 — плюс `world`. Импорт понимает оба и, заодно, голый\n// рецепт мира (файл «Скачать рецепт»): такой файл открывает новый мир по рецепту.\nimport { CONFIG } from \"../config\";\nimport type { Game } from \"../main\";\nimport type { SaveData, WorldSource } from \"../types\";\nimport { parseWorldSpec } from \"../worldgen/WorldSpec\";\n\nconst isObj = (v: unknown): v is Record<string, unknown> =>\n typeof v === \"object\" && v !== null && !Array.isArray(v);\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 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 };\n }\n if (isObj(w) && w.kind === \"noise\" && typeof w.seed === \"number\")\n return { kind: \"noise\", seed: w.seed };\n return { kind: \"noise\", seed: data.seed };\n}\n\nexport class SaveSystem {\n game: Game;\n\n constructor(game: Game) {\n this.game = game;\n } // game — фасад из main.js\n\n collect(): SaveData {\n const g = this.game;\n return {\n version: CONFIG.saveVersion,\n seed: g.world.seed,\n world: g.world.source,\n time: g.dayNight.serialize(),\n player: { ...g.player.serialize(), inventory: g.inventory.serialize() },\n chunks: g.world.serializeDiffs(),\n entities: g.entities.serialize(),\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) или голый рецепт мира. */\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 g.rebuildWorld(worldSourceOf(data)); // новый ChunkManager с этим источником\n g.world.loadDiffs(data.chunks);\n g.player.deserialize(data.player);\n g.inventory.deserialize(data.player.inventory);\n g.dayNight.deserialize(data.time);\n g.entities.deserialize(data.entities);\n }\n}\n"
160
184
  },
161
185
  {
162
186
  "path": "systems/WaterSim.ts",
163
187
  "content": "// Симуляция воды — клеточный автомат на dirty-set: мир никогда не сканируется,\n// обрабатываются только клетки из очереди. Тикает на каждом 5-м симтике (4 Гц),\n// бюджет клеток за тик жёстко ограничен — остаток «дотекает» в следующие тики.\n//\n// Правила:\n// • source (бит меты) никогда не гаснет;\n// • под водой воздух → вода падает вниз полным уровнем;\n// • иначе растекается горизонтально с уровнем max(соседей)-1, минимум minFlowLevel;\n// • flowing-вода без опоры (нет соседа с большим уровнем и не под водой) убывает.\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\nconst W = CONFIG.water;\nconst key = (x: number, y: number, z: number) => x + \",\" + y + \",\" + z;\n\nexport class WaterSim {\n world: ChunkManager;\n queue: Map<string, Cell>;\n _tickCounter: number;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.queue = new Map(); // key -> {x,y,z}\n this._tickCounter = 0;\n\n // любое изменение мира будит воду вокруг — так «прорывается дамба»\n events.on(\"blockChanged\", ({ x, y, z }) => {\n this.wake(x, y, z);\n this.wakeNeighbors(x, y, z);\n });\n }\n\n wake(x: number, y: number, z: number) {\n if (this.world.getBlock(x, y, z) === B.water || this._canFlowInto(x, y, z))\n this.queue.set(key(x, y, z), { x, y, z });\n }\n\n wakeNeighbors(x: number, y: number, z: number) {\n this.wake(x + 1, y, z);\n this.wake(x - 1, y, z);\n this.wake(x, y + 1, z);\n this.wake(x, y - 1, z);\n this.wake(x, y, z + 1);\n this.wake(x, y, z - 1);\n }\n\n _canFlowInto(x: number, y: number, z: number) {\n return this.world.getBlock(x, y, z) === B.air; // растения вода не смывает (MVP)\n }\n\n get queueSize() {\n return this.queue.size;\n }\n\n tick() {\n if (++this._tickCounter % W.tickEvery !== 0) return;\n if (this.queue.size === 0) return;\n\n const batch = [];\n for (const cell of this.queue.values()) {\n batch.push(cell);\n if (batch.length >= W.cellBudget) break;\n }\n for (const c of batch) this.queue.delete(key(c.x, c.y, c.z));\n\n for (const { x, y, z } of batch) this._updateCell(x, y, z);\n }\n\n _set(x: number, y: number, z: number, id: number, meta: number) {\n this.world.setBlock(x, y, z, id, meta, { waterOnly: true });\n this.wakeNeighbors(x, y, z);\n this.queue.set(key(x, y, z), { x, y, z });\n }\n\n _updateCell(x: number, y: number, z: number) {\n const id = this.world.getBlock(x, y, z);\n\n if (id === B.air) {\n // может ли вода прийти сюда? сверху — или сбоку с уровнем > minFlowLevel\n const above = this.world.getBlock(x, y + 1, z);\n if (above === B.water) {\n // ВАЖНО: при падении уровень наследуется (не сбрасывается в максимум) —\n // иначе каскад по склону заливает конус до океана (как в MC, но\n // неподъёмно для геймплея «сломал дамбу — лужа растеклась и встала»).\n this._set(\n x,\n y,\n z,\n B.water,\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 [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ]) {\n if (this.world.getBlock(x + dx!, y, z + dz!) !== B.water) continue;\n // сосед отдаёт воду вбок только если сам стоит на опоре\n const nUnder = this.world.getBlock(x + dx!, y - 1, z + dz!);\n if (nUnder === B.air) continue;\n best = Math.max(\n best,\n waterLevel(this.world.getMeta(x + dx!, y, z + dz!)),\n );\n }\n if (best - 1 >= W.minFlowLevel)\n this._set(x, y, z, B.water!, packWater(best - 1, false));\n return;\n }\n\n if (id !== B.water) return;\n const meta = this.world.getMeta(x, y, z);\n if (isWaterSource(meta)) {\n this._spread(x, y, z, W.maxLevel);\n return;\n }\n\n // пересчёт уровня flowing-воды: питание сверху или от соседей\n const fedFromAbove = this.world.getBlock(x, y + 1, z) === B.water;\n let want = 0;\n if (fedFromAbove) want = waterLevel(this.world.getMeta(x, y + 1, z));\n else {\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ])\n if (this.world.getBlock(x + dx!, y, z + dz!) === B.water)\n want = Math.max(\n want,\n waterLevel(this.world.getMeta(x + dx!, y, z + dz!)) - 1,\n );\n }\n\n const cur = waterLevel(meta);\n if (want < W.minFlowLevel) {\n this._set(x, y, z, B.air!, 0);\n return;\n } // высохла\n if (want !== cur) {\n this._set(x, y, z, B.water, packWater(want, false));\n return;\n }\n this._spread(x, y, z, cur);\n }\n\n /** Растекание из клетки: сначала вниз (уровень наследуется), потом в стороны. */\n _spread(x: number, y: number, z: number, level: number) {\n const below = this.world.getBlock(x, y - 1, z);\n if (below === B.air) {\n this._set(x, y - 1, z, B.water!, packWater(level, false));\n return; // вода уходит вниз — вбок не льём\n }\n if (level - 1 < W.minFlowLevel) return;\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ]) {\n const nid = this.world.getBlock(x + dx!, y, z + dz!);\n if (nid === B.air)\n this._set(x + dx!, y, z + dz!, B.water!, packWater(level - 1, false));\n else if (\n nid === B.water &&\n waterLevel(this.world.getMeta(x + dx!, y, z + dz!)) < level - 1\n )\n this._set(x + dx!, y, z + dz!, B.water, packWater(level - 1, false));\n }\n }\n}\n"
164
188
  },
189
+ {
190
+ "path": "systems/WorldStore.ts",
191
+ "content": "// «Мои миры»: сейвы в IndexedDB этого браузера. Автосейв текущего мира + список для продолжения.\n// IndexedDB может не быть (приватный режим, запрет хранилища во фрейме) — тогда open() отдаёт null,\n// и игра работает как раньше, без сохранения между запусками.\nimport type { SaveData } from \"../types\";\n\nexport interface StoredWorld {\n id: string;\n name: string;\n updatedAt: number;\n save: SaveData;\n}\n\nexport type StoredWorldInfo = Omit<StoredWorld, \"save\"> & {\n kind: \"noise\" | \"spec\";\n};\n\nconst DB = \"voxelcraft\";\nconst STORE = \"worlds\";\n\nfunction req<T>(r: IDBRequest<T>): Promise<T> {\n return new Promise((resolve, reject) => {\n r.onsuccess = () => resolve(r.result);\n r.onerror = () => reject(r.error);\n });\n}\n\nexport function newWorldId(): string {\n return typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID()\n : Date.now().toString(36) + Math.random().toString(36).slice(2);\n}\n\nexport class WorldStore {\n private constructor(private readonly db: IDBDatabase) {}\n\n static async open(): Promise<WorldStore | null> {\n try {\n if (typeof indexedDB === \"undefined\") return null;\n const open = indexedDB.open(DB, 1);\n open.onupgradeneeded = () => {\n if (!open.result.objectStoreNames.contains(STORE))\n open.result.createObjectStore(STORE, { keyPath: \"id\" });\n };\n return new WorldStore(await req(open));\n } catch {\n return null;\n }\n }\n\n private tx(mode: IDBTransactionMode): IDBObjectStore {\n return this.db.transaction(STORE, mode).objectStore(STORE);\n }\n\n async put(world: StoredWorld): Promise<void> {\n await req(this.tx(\"readwrite\").put(world));\n }\n\n async get(id: string): Promise<StoredWorld | undefined> {\n return (await req(this.tx(\"readonly\").get(id))) as StoredWorld | undefined;\n }\n\n async delete(id: string): Promise<void> {\n await req(this.tx(\"readwrite\").delete(id));\n }\n\n /** Все миры, свежие сверху (без тяжёлых сейвов). */\n async list(): Promise<StoredWorldInfo[]> {\n const all = (await req(this.tx(\"readonly\").getAll())) as StoredWorld[];\n return all\n .map((w) => ({\n id: w.id,\n name: w.name,\n updatedAt: w.updatedAt,\n kind:\n w.save.world?.kind === \"spec\"\n ? (\"spec\" as const)\n : (\"noise\" as const),\n }))\n .sort((a, b) => b.updatedAt - a.updatedAt);\n }\n\n async lastPlayed(): Promise<StoredWorld | undefined> {\n const [top] = await this.list();\n return top ? this.get(top.id) : undefined;\n }\n\n close(): void {\n this.db.close();\n }\n}\n"
192
+ },
165
193
  {
166
194
  "path": "types.ts",
167
- "content": "// Общие типы, разделяемые между модулями. Плоские data-driven-формы реестров\n// (блоки/предметы/мобы/рецепты), стек инвентаря, форма сейва и полезные алиасы.\nimport type { ChunkManager } from \"./world/ChunkManager\";\n\n/** [x, y, z] — вектор/размер в блоках или пикселях. */\nexport type Vec3 = [number, number, number];\n\n// ---- блоки ------------------------------------------------------------------\n\nexport type ToolType = \"pickaxe\" | \"axe\" | \"shovel\" | \"sword\";\n\n/** Тайлы атласа для граней блока (имена тайлов). */\nexport interface BlockTex {\n top?: string;\n side?: string;\n bottom?: string;\n front?: string;\n}\n\n/** Что выпадает из блока/моба: предмет, количество, опциональный шанс. */\nexport interface BlockDrop {\n item: string;\n n: 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 hardness: number;\n tool: ToolType | null;\n drops: BlockDrop[] | null;\n onTick: BlockTickFn | null;\n tex: BlockTex | null;\n}\n\n// ---- предметы ---------------------------------------------------------------\n\nexport interface ItemDef {\n name: string;\n maxStack: number;\n kind: \"block\" | \"material\" | \"tool\";\n toolType: ToolType | null;\n speed: number;\n damage: number;\n blockId: number | null;\n tile: string | null;\n food: number;\n saturation: number;\n // Заполняются buildIcons() из атласа после его создания.\n icon?: HTMLCanvasElement;\n iconURL?: string;\n}\n\n/** Слот инвентаря / крафт-сетки. */\nexport interface ItemStack {\n item: string;\n count: number;\n}\n\n// ---- мобы -------------------------------------------------------------------\n\nexport interface MobPart {\n name: string;\n size: Vec3;\n pos: Vec3;\n pivot?: Vec3;\n anim?: string;\n color: 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 spawnOn: string;\n drops: BlockDrop[];\n parts: MobPart[];\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/** Дифы блоков по чанкам: \"cx,cz\" -> [[index, id, meta], ...]. */\nexport type ChunkDiffMap = Record<string, Array<[number, number, number]>>;\n\nexport interface InventorySave {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n}\n\n/**\n * Полный снапшот мира. Вложенные serialize()-блобы player/entities намеренно\n * динамические (проходят JSON round-trip и раздаются в *.deserialize()) — any здесь оправдан.\n */\nexport interface SaveData {\n version: number;\n seed: number;\n time: { time?: number };\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n player: any;\n chunks: ChunkDiffMap;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n entities: any;\n}\n"
195
+ "content": "// Общие типы, разделяемые между модулями. Плоские data-driven-формы реестров\n// (блоки/предметы/мобы/рецепты), стек инвентаря, форма сейва и полезные алиасы.\nimport type { ChunkManager } from \"./world/ChunkManager\";\nimport type { WorldSpec } from \"./worldgen/WorldSpec\";\n\n/** [x, y, z] — вектор/размер в блоках или пикселях. */\nexport type Vec3 = [number, number, number];\n\n// ---- блоки ------------------------------------------------------------------\n\nexport type ToolType = \"pickaxe\" | \"axe\" | \"shovel\" | \"sword\";\n\n/** Тайлы атласа для граней блока (имена тайлов). */\nexport interface BlockTex {\n top?: string;\n side?: string;\n bottom?: string;\n front?: string;\n}\n\n/** Что выпадает из блока/моба: предмет, количество, опциональный шанс. */\nexport interface BlockDrop {\n item: string;\n n: 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 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\n// ---- предметы ---------------------------------------------------------------\n\nexport interface ItemDef {\n name: string;\n maxStack: number;\n kind: \"block\" | \"material\" | \"tool\";\n toolType: ToolType | null;\n speed: number;\n damage: number;\n blockId: number | null;\n tile: string | null;\n food: number;\n saturation: number;\n // Заполняются buildIcons() из атласа после его создания.\n icon?: HTMLCanvasElement;\n iconURL?: string;\n}\n\n/** Слот инвентаря / крафт-сетки. */\nexport interface ItemStack {\n item: string;\n count: number;\n}\n\n// ---- мобы -------------------------------------------------------------------\n\nexport interface MobPart {\n name: string;\n size: Vec3;\n pos: Vec3;\n pivot?: Vec3;\n anim?: string;\n color: 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 spawnOn: string;\n drops: BlockDrop[];\n parts: MobPart[];\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/** Откуда берётся мир: шум по сиду (классика) или рецепт мира (WorldSpec). */\nexport type WorldSource =\n { kind: \"noise\"; seed: number } | { kind: \"spec\"; spec: WorldSpec };\n\n/** Дифы блоков по чанкам: \"cx,cz\" -> [[index, id, meta], ...]. */\nexport type ChunkDiffMap = Record<string, Array<[number, number, number]>>;\n\nexport interface InventorySave {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\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 // eslint-disable-next-line @typescript-eslint/no-explicit-any\n player: any;\n chunks: ChunkDiffMap;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n entities: any;\n}\n"
196
+ },
197
+ {
198
+ "path": "ui/CreateWorldUI.ts",
199
+ "content": "// Экран «Создать мир»: картинки-референсы + описание → рецепт мира → миникарта → игра.\n// Два пути к одному рецепту: без ИИ (первая картинка разбирается в браузере, текст — по ключевым\n// словам) и с ИИ (client.ai, фича тайтла \"voxel-world\"): ИИ видит все картинки и может собрать\n// показанный на них объект — самолёт, машину — из блоков. Плюс вкладка «Мои миры».\n//\n// Чистый DOM, как весь HUD. Пока экран открыт, игровой ввод заморожен (input.uiOpen), а игра\n// стоит на паузе под ним.\nimport type { Game } from \"../main\";\nimport type { WorldAI, WorldAIResult, WorldAIStatus } from \"../ai/types\";\nimport {\n analyzeMood,\n analyzeTopDown,\n type TopDownResult,\n} from \"../worldgen/ImageAnalyzer\";\nimport { loadImageFile, type LoadedImage } from \"../worldgen/loadImage\";\nimport { SpecGenerator } from \"../worldgen/SpecGenerator\";\nimport { drawPreview } from \"../worldgen/preview\";\nimport { buildSpecWithoutAI, topDownLayers } from \"../worldgen/textSpec\";\nimport {\n SIZE_PRESETS,\n SPEC_LIMITS,\n parseWorldSpec,\n type WorldSize,\n type WorldSpec,\n} from \"../worldgen/WorldSpec\";\nimport { downloadText, pickTextFile, safeFileName } from \"../systems/Save\";\n\ntype ReadMode = \"reference\" | \"topdown\" | \"mood\";\n\n/**\n * Сколько картинок экран принимает всегда. Больше — если ИИ-фича тайтла разрешает больше\n * (`maxImages()`): потолка у платформы нет, число задаёт издатель.\n */\nconst MAX_IMAGES = 4;\n\nconst BIOME_RU: Record<string, string> = {\n meadow: \"луг\",\n forest: \"лес\",\n birch_forest: \"березняк\",\n taiga: \"тайга\",\n snow: \"снега\",\n desert: \"пустыня\",\n badlands: \"красные скалы\",\n beach: \"пляж\",\n swamp: \"болото\",\n rocky: \"камни\",\n savanna: \"саванна\",\n jungle: \"джунгли\",\n};\n\nconst STRUCTURE_RU: Record<string, string> = {\n house: \"дом\",\n tower: \"башня\",\n castle: \"замок\",\n village: \"деревня\",\n wall: \"стена\",\n road: \"дорога\",\n bridge: \"мост\",\n ruins: \"руины\",\n pond: \"пруд\",\n rock: \"скала\",\n tree_cluster: \"роща\",\n pyramid: \"пирамида\",\n well: \"колодец\",\n farm: \"ферма\",\n};\n\nexport class CreateWorldUI {\n open = false;\n private readonly root: HTMLElement;\n private readonly $: (id: string) => HTMLElement;\n private images: LoadedImage[] = [];\n private spec: WorldSpec | null = null;\n private busy = false;\n private job: AbortController | null = null;\n private aiStatus: WorldAIStatus | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n private readonly ai: WorldAI | null,\n ) {\n // Все элементы гарантированно есть в разметке (scene.ts) — helper утверждает non-null.\n this.$ = (id: string) => document.getElementById(id)!;\n this.root = this.$(\"worldScreen\");\n const on = <K extends keyof HTMLElementEventMap>(\n el: EventTarget,\n type: K,\n fn: (e: HTMLElementEventMap[K]) => void,\n ) => el.addEventListener(type, fn as EventListener, { signal });\n\n on(this.$(\"btnWorlds\"), \"click\", () => this.show(\"new\"));\n on(this.$(\"wsClose\"), \"click\", () => this.hide());\n on(document, \"keydown\", (e) => {\n if (this.open && e.code === \"Escape\") this.hide();\n });\n for (const tab of this.root.querySelectorAll<HTMLButtonElement>(\".wsTab\"))\n on(tab, \"click\", () =>\n this.selectTab(tab.dataset.tab === \"mine\" ? \"mine\" : \"new\"),\n );\n\n // картинки: клик, перетаскивание, вставка из буфера — сразу несколько\n const drop = this.$(\"wsDrop\");\n on(drop, \"click\", () => {\n const input = document.createElement(\"input\");\n input.type = \"file\";\n input.multiple = true;\n input.accept = \"image/png,image/jpeg,image/webp,image/gif\";\n input.onchange = () => void this.addImages([...(input.files ?? [])]);\n input.click();\n });\n on(drop, \"dragover\", (e) => {\n e.preventDefault();\n drop.classList.add(\"over\");\n });\n on(drop, \"dragleave\", () => drop.classList.remove(\"over\"));\n on(drop, \"drop\", (e) => {\n e.preventDefault();\n drop.classList.remove(\"over\");\n void this.addImages([...(e.dataTransfer?.files ?? [])]);\n });\n on(document, \"paste\", (e) => {\n if (!this.open) return;\n const files = [...(e.clipboardData?.files ?? [])].filter((x) =>\n x.type.startsWith(\"image/\"),\n );\n if (files.length) void this.addImages(files);\n });\n on(this.$(\"wsClearImg\"), \"click\", () => {\n this.images = [];\n this.renderThumbs();\n });\n\n for (const r of this.root.querySelectorAll<HTMLInputElement>(\n 'input[name=\"wsSize\"]',\n ))\n on(r, \"change\", () =>\n this.$(\"wsCustom\").classList.toggle(\n \"hidden\",\n this.radio(\"wsSize\") !== \"custom\",\n ),\n );\n\n on(this.$(\"wsGenerate\"), \"click\", () => void this.generate());\n on(this.$(\"wsPlay\"), \"click\", () => this.play());\n on(this.$(\"wsSaveSpec\"), \"click\", () => this.downloadSpec());\n on(this.$(\"wsLoadSpec\"), \"click\", () => this.loadSpec());\n }\n\n // ---- открытие -------------------------------------------------------------------\n\n show(tab: \"new\" | \"mine\" = \"new\"): void {\n if (this.open) return;\n this.open = true;\n this.game.input.uiOpen = true; // игровой ввод заморожен\n this.game.input.releaseLock();\n this.root.classList.remove(\"hidden\");\n this.selectTab(tab);\n void this.refreshAi();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n // Незаконченная ИИ-задача не теряется: сервер доделает её, а resume() дождётся в следующий раз.\n this.job?.abort();\n this.game.input.uiOpen = false;\n this.root.classList.add(\"hidden\");\n }\n\n private selectTab(tab: \"new\" | \"mine\"): void {\n for (const t of this.root.querySelectorAll<HTMLButtonElement>(\".wsTab\"))\n t.classList.toggle(\"active\", t.dataset.tab === tab);\n this.$(\"wsNew\").classList.toggle(\"hidden\", tab !== \"new\");\n this.$(\"wsMine\").classList.toggle(\"hidden\", tab !== \"mine\");\n if (tab === \"mine\") void this.renderMine();\n }\n\n // ---- ИИ ------------------------------------------------------------------------\n\n private async refreshAi(): Promise<void> {\n const label = this.$(\"wsAiLabel\");\n const radio = label.querySelector(\"input\")!; // разметка: радио внутри подписи\n const note = this.$(\"wsAiNote\");\n if (!this.ai) {\n label.classList.add(\"hidden\");\n return;\n }\n note.textContent = \"проверяю…\";\n this.aiStatus = await this.ai\n .status()\n .catch((): WorldAIStatus => ({\n available: false,\n reason: \"ИИ недоступен\",\n }));\n if (this.aiStatus.available) {\n radio.disabled = false;\n const f = this.aiStatus.feature;\n note.textContent = [\n f.priceLabel ? `цена: ${f.priceLabel}` : \"бесплатно\",\n f.allowImages\n ? `ИИ видит до ${f.maxImages} ${f.maxImages === 1 ? \"картинки\" : \"картинок\"}`\n : \"картинки ИИ не видит — они читаются локально\",\n ].join(\" · \");\n // незаконченная задача с прошлого раза — дождаться её\n if (!this.busy)\n void this.runAi(() => this.ai!.resume(this.jobOpts()), true);\n } else {\n radio.disabled = true;\n if (this.radio(\"wsMode\") === \"ai\") this.setRadio(\"wsMode\", \"local\");\n note.textContent = this.aiStatus.reason;\n }\n }\n\n private jobOpts() {\n this.job = new AbortController();\n return {\n signal: this.job.signal,\n onProgress: (p: number | null, text: string) =>\n this.status(p === null ? text : `${text} ${Math.round(p)}%`),\n };\n }\n\n /** Запустить ИИ-задачу и разобрать итог. resumed = дожидаемся задачи с прошлого раза. */\n private async runAi(\n run: () => Promise<WorldAIResult | null>,\n resumed = false,\n ): Promise<void> {\n this.setBusy(true);\n try {\n const res = await run();\n if (!res) return; // нечего было дожидаться\n if (res.ok) {\n const spec = this.withImageLayers(res.spec);\n this.setSpec(\n spec,\n res.warnings.length\n ? \"Поправлено: \" + res.warnings.join(\"; \")\n : resumed\n ? \"Готов мир с прошлого раза\"\n : \"\",\n );\n return;\n }\n if (res.kind === \"cancelled\") return;\n if (res.kind === \"invalid\" || res.kind === \"truncated\") {\n // модель ответила не рецептом — строим без ИИ, чтобы игрок не остался ни с чем\n this.buildLocal();\n this.status(`${res.message} Мир построен без ИИ.`);\n return;\n }\n this.status(\n res.refunded ? `${res.message} Списанное возвращено.` : res.message,\n true,\n );\n } finally {\n this.setBusy(false);\n }\n }\n\n /** Режим «карта сверху»: рельеф и поверхность — из пикселей первой картинки, смысл — от ИИ. */\n private withImageLayers(spec: WorldSpec): WorldSpec {\n const td = this.topDown(spec.size);\n if (!td) return spec;\n const merged = parseWorldSpec({\n ...spec,\n terrain: {\n ...spec.terrain,\n ...topDownLayers(td, spec.terrain.waterLevel),\n },\n });\n return merged.ok ? merged.spec : spec;\n }\n\n // ---- генерация ---------------------------------------------------------------------\n\n private async generate(): Promise<void> {\n if (this.busy) return;\n const useAi =\n this.radio(\"wsMode\") === \"ai\" && this.aiStatus?.available === true;\n if (!useAi) {\n this.status(\"Строю мир…\");\n await nextFrame(); // дать экрану показать статус до синхронной работы\n this.buildLocal();\n if (this.images.length && this.readMode() === \"reference\")\n this.status(\n \"Без ИИ картинка задаёт только стиль мира. Объекты с картинок строит ИИ.\",\n );\n return;\n }\n const feature = this.aiStatus?.available ? this.aiStatus.feature : null;\n const images = feature?.allowImages\n ? this.images.slice(0, feature.maxImages)\n : [];\n if (\n feature?.maxImageBytes &&\n images.some((im) => im.aiBytes > feature.maxImageBytes!)\n ) {\n this.status(\n \"Одна из картинок слишком большая для ИИ. Возьмите поменьше.\",\n true,\n );\n return;\n }\n await this.runAi(() =>\n this.ai!.generate(\n {\n text: this.text(),\n size: this.size(),\n readMode: this.readMode(),\n images: images.map((im) => ({\n dataUrl: im.aiDataUrl,\n contentType: im.aiContentType,\n bytes: im.aiBytes,\n })),\n },\n this.jobOpts(),\n ),\n );\n }\n\n private buildLocal(): void {\n const size = this.size();\n const seed = (Math.random() * 0xffffffff) >>> 0; // «Сгенерировать» ещё раз — другой вариант\n const first = this.images[0];\n const spec = buildSpecWithoutAI({\n text: this.text(),\n size,\n seed,\n topDown: this.topDown(size),\n mood:\n first && this.readMode() !== \"topdown\"\n ? analyzeMood(first.image)\n : undefined,\n });\n this.setSpec(spec, \"\");\n }\n\n private topDown(size: WorldSize): TopDownResult | undefined {\n const first = this.images[0];\n if (!first || this.readMode() !== \"topdown\") return undefined;\n const w = Math.min(128, size.widthChunks * 16),\n h = Math.min(128, size.depthChunks * 16);\n return analyzeTopDown(first.image, w, h);\n }\n\n private setSpec(spec: WorldSpec, note: string): void {\n this.spec = spec;\n const gen = new SpecGenerator(spec);\n drawPreview(this.$(\"wsMap\") as HTMLCanvasElement, gen, 256);\n const biomes = [\n ...new Set(\n (spec.zones.length ? spec.zones : [{ biome: \"meadow\" }]).map(\n (z) => BIOME_RU[z.biome] ?? z.biome,\n ),\n ),\n ];\n const built = spec.structures.map((s) =>\n s.type === \"model\"\n ? (s.model ?? \"объект\")\n : (STRUCTURE_RU[s.type] ?? s.type),\n );\n const blocks = `${spec.size.widthChunks * 16}×${spec.size.depthChunks * 16}`;\n this.$(\"wsInfo\").textContent =\n `«${spec.name}» · ${spec.size.mode === \"infinite\" ? \"бесконечный, область \" + blocks : blocks} · ` +\n `${biomes.join(\", \")}` +\n (built.length ? ` · ${[...new Set(built)].join(\", \")}` : \"\");\n (this.$(\"wsPlay\") as HTMLButtonElement).disabled = false;\n (this.$(\"wsSaveSpec\") as HTMLButtonElement).disabled = false;\n this.$(\"wsGenerate\").textContent = \"Перегенерировать\";\n this.status(note || \"Готово. Красная точка — место появления.\");\n }\n\n private play(): void {\n if (!this.spec) return;\n this.game.newWorld({ kind: \"spec\", spec: this.spec });\n this.hide();\n this.game.startPlay(); // клик по кнопке — жест пользователя, захват мыши разрешён\n }\n\n private downloadSpec(): void {\n if (!this.spec) return;\n downloadText(\n `voxelcraft-world-${safeFileName(this.spec.name)}.json`,\n JSON.stringify(this.spec, null, 2),\n );\n }\n\n private loadSpec(): void {\n pickTextFile(\".json,application/json\", (text) => {\n const r = parseWorldSpec(text);\n if (!r.ok) {\n this.status(\"Это не рецепт мира: \" + r.error, true);\n return;\n }\n this.setSpec(\n r.spec,\n r.warnings.length\n ? \"Поправлено: \" + r.warnings.join(\"; \")\n : \"Рецепт загружен\",\n );\n });\n }\n\n // ---- картинки ------------------------------------------------------------------------\n\n /**\n * Сколько картинок можно положить на экран: не меньше {@link MAX_IMAGES}, а если ИИ-фича тайтла\n * принимает больше — столько, сколько разрешил издатель (потолка платформы нет).\n */\n private maxImages(): number {\n const f = this.aiStatus?.available ? this.aiStatus.feature : null;\n return Math.max(MAX_IMAGES, f?.allowImages ? f.maxImages : 0);\n }\n\n private async addImages(files: File[]): Promise<void> {\n const pics = files.filter((f) => f.type.startsWith(\"image/\"));\n if (!pics.length) {\n if (files.length) this.status(\"Нужны картинки (PNG, JPEG, WebP).\", true);\n return;\n }\n const limit = this.maxImages();\n const room = limit - this.images.length;\n if (room <= 0) {\n this.status(`Не больше ${limit} картинок — уберите лишние.`, true);\n return;\n }\n this.status(\"Читаю картинки…\");\n let failed = 0;\n for (const f of pics.slice(0, room)) {\n try {\n this.images.push(await loadImageFile(f));\n } catch {\n failed++;\n }\n }\n this.renderThumbs();\n const skipped = pics.length - Math.min(pics.length, room);\n this.status(\n failed\n ? \"Часть картинок не удалось прочитать.\"\n : skipped\n ? `Добавлены первые ${room}: больше ${MAX_IMAGES} картинок ИИ не примет.`\n : `Картинок: ${this.images.length}.`,\n failed > 0,\n );\n }\n\n private renderThumbs(): void {\n const box = this.$(\"wsThumbs\");\n box.replaceChildren();\n this.images.forEach((im, i) => {\n const cell = document.createElement(\"div\");\n cell.className = \"wsThumb\";\n const img = document.createElement(\"img\");\n img.src = im.thumbUrl;\n img.alt = \"\";\n const x = document.createElement(\"span\");\n x.className = \"wsThumbX\";\n x.textContent = \"×\";\n x.title = \"Убрать\";\n x.onclick = (e) => {\n e.stopPropagation(); // клик по крестику — не выбор новых файлов\n this.images.splice(i, 1);\n this.renderThumbs();\n };\n cell.append(img, x);\n box.append(cell);\n });\n const any = this.images.length > 0;\n box.classList.toggle(\"hidden\", !any);\n this.$(\"wsDropHint\").classList.toggle(\"hidden\", any);\n this.$(\"wsClearImg\").classList.toggle(\"hidden\", !any);\n }\n\n // ---- «Мои миры» ------------------------------------------------------------------------\n\n private async renderMine(): Promise<void> {\n const list = this.$(\"wsList\");\n const store = this.game.store;\n if (!store) {\n list.textContent =\n \"Этот браузер не даёт сохранять миры (приватный режим или запрет хранилища).\";\n return;\n }\n const worlds = await store.list();\n list.replaceChildren();\n if (!worlds.length) {\n list.textContent = \"Пока пусто. Сохранение идёт само, пока вы играете.\";\n return;\n }\n for (const w of worlds) {\n const row = document.createElement(\"div\");\n row.className = \"wsItem\" + (w.id === this.game.worldId ? \" current\" : \"\");\n const title = document.createElement(\"div\");\n title.className = \"wsItemName\";\n title.textContent = w.name;\n const meta = document.createElement(\"div\");\n meta.className = \"wsItemMeta\";\n meta.textContent =\n (w.kind === \"spec\" ? \"по рецепту\" : \"по сиду\") +\n \" · \" +\n new Date(w.updatedAt).toLocaleString() +\n (w.id === this.game.worldId ? \" · сейчас открыт\" : \"\");\n const play = document.createElement(\"button\");\n play.textContent = \"Играть\";\n play.onclick = () => void this.continueWorld(w.id);\n const del = document.createElement(\"button\");\n del.textContent = \"Удалить\";\n del.onclick = async () => {\n if (!confirm(`Удалить мир «${w.name}»? Это нельзя отменить.`)) return;\n await store.delete(w.id);\n void this.renderMine();\n };\n const text = document.createElement(\"div\");\n text.append(title, meta);\n row.append(text, play, del);\n list.append(row);\n }\n }\n\n private async continueWorld(id: string): Promise<void> {\n const ok = await this.game.openStoredWorld(id);\n if (!ok) {\n this.status(\"Не удалось открыть мир.\", true);\n return;\n }\n this.hide();\n this.game.startPlay();\n }\n\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 private setRadio(name: string, value: string): void {\n const r = this.root.querySelector<HTMLInputElement>(\n `input[name=\"${name}\"][value=\"${value}\"]`,\n );\n if (r) r.checked = true;\n }\n\n private readMode(): ReadMode {\n const v = this.radio(\"wsRead\");\n return v === \"topdown\" || v === \"mood\" ? v : \"reference\";\n }\n\n private text(): string {\n return (this.$(\"wsText\") as HTMLTextAreaElement).value.trim();\n }\n\n private size(): WorldSize {\n const v = this.radio(\"wsSize\");\n if (v === \"large\" || v === \"infinite\" || v === \"island\")\n return { ...SIZE_PRESETS[v] };\n const clamp = (n: number) =>\n Math.min(\n SPEC_LIMITS.maxChunks,\n Math.max(SPEC_LIMITS.minChunks, Math.round(n) || 16),\n );\n return {\n mode: \"island\",\n widthChunks: clamp(Number((this.$(\"wsW\") as HTMLInputElement).value)),\n depthChunks: clamp(Number((this.$(\"wsD\") as HTMLInputElement).value)),\n };\n }\n\n private setBusy(busy: boolean): void {\n this.busy = busy;\n const btn = this.$(\"wsGenerate\") as HTMLButtonElement;\n btn.disabled = busy; // второй generate того же действия сервер всё равно отбил бы\n if (busy) btn.textContent = \"ИИ думает…\";\n else btn.textContent = this.spec ? \"Перегенерировать\" : \"Сгенерировать\";\n }\n\n private status(text: string, error = false): void {\n const el = this.$(\"wsStatus\");\n el.textContent = text;\n el.classList.toggle(\"error\", error);\n }\n}\n\n/**\n * Дать браузеру отрисовать статус перед синхронной работой. Не requestAnimationFrame: в фоновой\n * вкладке он не срабатывает вовсе, и генерация висела бы до возврата игрока.\n */\nfunction nextFrame(): Promise<void> {\n return new Promise((r) => setTimeout(r, 30));\n}\n"
168
200
  },
169
201
  {
170
202
  "path": "ui/HUD.ts",
171
- "content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items\";\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 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 events.on(\"playerDied\", () => this.message(\"Вы погибли…\", 1200));\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 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 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\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 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 _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 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.waterSim.queueSize} | сид: ${g.world.seed}`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n this.timeEl.textContent =\n (g.dayNight.isDay ? \"☀\" : \"☾\") + \" \" + (ph < 0.5 ? \"день\" : \"ночь\");\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 = ITEM_LABELS[s.item] ?? s.item;\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 }\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"
203
+ "content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items\";\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 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 events.on(\"playerDied\", () => this.message(\"Вы погибли…\", 1200));\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 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 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\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 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 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.waterSim.queueSize} | сид: ${g.world.seed}`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n this.timeEl.textContent =\n (g.dayNight.isDay ? \"☀\" : \"☾\") + \" \" + (ph < 0.5 ? \"день\" : \"ночь\");\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 = ITEM_LABELS[s.item] ?? s.item;\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 }\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"
172
204
  },
173
205
  {
174
206
  "path": "ui/InventoryUI.ts",
175
- "content": "// Экран инвентаря и крафта: сетка слотов с drag-and-drop «курсорным стаком»\n// (клик — взять/положить, ПКМ — половина/по одному), крафт 2×2 или 3×3 (верстак).\n// UI мутирует ТОЛЬКО модели Inventory/CraftingGrid и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\n\ntype Section = \"inv\" | \"craft\";\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n titleEl: HTMLElement;\n _craftSlots!: HTMLDivElement[]; // строятся в _build() до первого _render()\n _resultSlot!: HTMLElement;\n _invSlots!: HTMLDivElement[];\n\n constructor(game: Game) {\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.titleEl = document.getElementById(\"invTitle\")!;\n\n events.on(\"ui:toggleInventory\", () => this.toggle(2));\n events.on(\"ui:openCrafting\", () => this.toggle(3, true));\n\n document.addEventListener(\"mousemove\", (e) => {\n if (!this.open) return;\n this.cursorEl.style.left = e.clientX + \"px\";\n this.cursorEl.style.top = e.clientY + \"px\";\n });\n }\n\n toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n if (this.open && forceOpen) return; // уже открыт\n this.open = true;\n this.grid.setSize(craftSize);\n this.titleEl.textContent = craftSize === 3 ? \"Верстак\" : \"Инвентарь\";\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 close() {\n if (!this.open) return;\n this.open = false;\n // вернуть всё из крафт-сетки и курсора\n this.grid.dumpInto(this.inventory);\n if (this.cursor) {\n const left = this.inventory.add(this.cursor.item, this.cursor.count);\n if (left > 0)\n // некуда класть — выбрасываем под ноги, ничего не теряем\n this.game.entities.spawnDrop(\n this.game.player.pos.x,\n this.game.player.pos.y + 1,\n this.game.player.pos.z,\n this.cursor.item,\n left,\n );\n this.cursor = null;\n }\n this.rootEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Построение DOM под текущий размер крафт-сетки. */\n _build() {\n const n = this.grid.size;\n const craftEl = document.getElementById(\"craftGrid\")!;\n craftEl.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n craftEl.innerHTML = \"\";\n this._craftSlots = [];\n for (let i = 0; i < n * n; i++)\n this._craftSlots.push(this._slot(craftEl, \"craft\", i));\n\n this._resultSlot = document.getElementById(\"craftResult\")!;\n this._resultSlot.innerHTML =\n '<img draggable=\"false\"><span class=\"count\"></span>';\n this._resultSlot.onmousedown = (e) => {\n this._clickResult(e);\n e.preventDefault();\n };\n\n const invEl = document.getElementById(\"invGrid\")!;\n invEl.innerHTML = \"\";\n this._invSlots = [];\n for (let i = 9; i < 36; i++)\n this._invSlots[i] = this._slot(invEl, \"inv\", i);\n const hbEl = document.getElementById(\"invHotbar\")!;\n hbEl.innerHTML = \"\";\n for (let i = 0; i < 9; i++) this._invSlots[i] = this._slot(hbEl, \"inv\", i);\n }\n\n _slot(parent: HTMLElement, section: Section, index: number): 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(section, index, e.button === 2);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n parent.appendChild(d);\n return d;\n }\n\n _getStack(section: Section, i: number): ItemStack | null {\n return section === \"inv\" ? this.inventory.slots[i]! : this.grid.slots[i]!;\n }\n _setStack(section: Section, i: number, stack: ItemStack | null) {\n if (section === \"inv\") this.inventory.setSlot(i, stack);\n else this.grid.slots[i] = stack;\n }\n\n _clickSlot(section: Section, i: number, rightClick: boolean) {\n const cur = this.cursor;\n const slot = this._getStack(section, i);\n\n if (!cur) {\n if (!slot) return;\n if (rightClick) {\n // взять половину\n const take = Math.ceil(slot.count / 2);\n this.cursor = { item: slot.item, count: take };\n slot.count -= take;\n this._setStack(section, i, slot.count > 0 ? slot : null);\n } else {\n this.cursor = slot;\n this._setStack(section, i, null);\n }\n } else {\n const max = ITEMS[cur.item]!.maxStack;\n if (!slot) {\n if (rightClick) {\n this._setStack(section, i, { item: cur.item, count: 1 });\n cur.count -= 1;\n if (cur.count <= 0) this.cursor = null;\n } else {\n this._setStack(section, i, cur);\n this.cursor = null;\n }\n } else if (slot.item === cur.item) {\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 this._setStack(section, i, slot);\n if (cur.count <= 0) this.cursor = null;\n } else {\n // обмен\n this._setStack(section, i, cur);\n this.cursor = slot;\n }\n }\n this._render();\n }\n\n _clickResult(_e?: MouseEvent) {\n const res = this.grid.result();\n if (!res) return;\n if (\n this.cursor &&\n (this.cursor.item !== res.item ||\n this.cursor.count + res.count > ITEMS[res.item]!.maxStack)\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 _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 = ITEM_LABELS[stack.item] ?? stack.item;\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (stack.count > 1\n ? stack.count\n : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n }\n };\n for (let i = 0; i < 36; i++)\n draw(this._invSlots[i]!, this.inventory.slots[i]!);\n this._craftSlots.forEach((el, i) => draw(el, this.grid.slots[i]!));\n draw(\n this._resultSlot,\n this.grid.result() ? { ...this.grid.result()! } : null,\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 = (this.cursor.count >\n 1\n ? this.cursor.count\n : \"\") as unknown as string;\n } else {\n this.cursorEl.classList.add(\"hidden\");\n }\n events.emit(\"invChanged\"); // HUD-хотбар обновится\n }\n}\n"
207
+ "content": "// Экран инвентаря и крафта: сетка слотов с drag-and-drop «курсорным стаком»\n// (клик — взять/положить, ПКМ — половина/по одному), крафт 2×2 или 3×3 (верстак).\n// UI мутирует ТОЛЬКО модели Inventory/CraftingGrid и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\n\ntype Section = \"inv\" | \"craft\";\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n titleEl: HTMLElement;\n _craftSlots!: HTMLDivElement[]; // строятся в _build() до первого _render()\n _resultSlot!: HTMLElement;\n _invSlots!: HTMLDivElement[];\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.titleEl = document.getElementById(\"invTitle\")!;\n\n events.on(\"ui:toggleInventory\", () => this.toggle(2));\n events.on(\"ui:openCrafting\", () => this.toggle(3, true));\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 toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n if (this.open && forceOpen) return; // уже открыт\n // На стартовом оверлее и паузе игра стоит: инвентарь открылся бы ПОД оверлеем, а его закрытие\n // (requestLock → takeControl) запускало бы игру в обход оверлея.\n if (!this.game.playing) return;\n this.open = true;\n this.grid.setSize(craftSize);\n this.titleEl.textContent = craftSize === 3 ? \"Верстак\" : \"Инвентарь\";\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 close() {\n if (!this.open) return;\n this.open = false;\n // вернуть всё из крафт-сетки и курсора; некуда класть — выбрасываем под ноги, ничего не теряем\n this.grid.dumpInto(this.inventory);\n // Остаток сетки выбрасываем обязательно: следующий toggle() пересоздаёт её (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 if (this.cursor) {\n const left = this.inventory.add(this.cursor.item, this.cursor.count);\n if (left > 0) this._dropAtFeet(this.cursor.item, left);\n this.cursor = null;\n }\n this.rootEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\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 /** Построение DOM под текущий размер крафт-сетки. */\n _build() {\n const n = this.grid.size;\n const craftEl = document.getElementById(\"craftGrid\")!;\n craftEl.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n craftEl.innerHTML = \"\";\n this._craftSlots = [];\n for (let i = 0; i < n * n; i++)\n this._craftSlots.push(this._slot(craftEl, \"craft\", i));\n\n this._resultSlot = document.getElementById(\"craftResult\")!;\n this._resultSlot.innerHTML =\n '<img draggable=\"false\"><span class=\"count\"></span>';\n this._resultSlot.onmousedown = (e) => {\n this._clickResult(e);\n e.preventDefault();\n };\n\n const invEl = document.getElementById(\"invGrid\")!;\n invEl.innerHTML = \"\";\n this._invSlots = [];\n for (let i = 9; i < 36; i++)\n this._invSlots[i] = this._slot(invEl, \"inv\", i);\n const hbEl = document.getElementById(\"invHotbar\")!;\n hbEl.innerHTML = \"\";\n for (let i = 0; i < 9; i++) this._invSlots[i] = this._slot(hbEl, \"inv\", i);\n }\n\n _slot(parent: HTMLElement, section: Section, index: number): 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(section, index, e.button === 2);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n parent.appendChild(d);\n return d;\n }\n\n _getStack(section: Section, i: number): ItemStack | null {\n return section === \"inv\" ? this.inventory.slots[i]! : this.grid.slots[i]!;\n }\n _setStack(section: Section, i: number, stack: ItemStack | null) {\n if (section === \"inv\") this.inventory.setSlot(i, stack);\n else this.grid.slots[i] = stack;\n }\n\n _clickSlot(section: Section, i: number, rightClick: boolean) {\n const cur = this.cursor;\n const slot = this._getStack(section, i);\n\n if (!cur) {\n if (!slot) return;\n if (rightClick) {\n // взять половину\n const take = Math.ceil(slot.count / 2);\n this.cursor = { item: slot.item, count: take };\n slot.count -= take;\n this._setStack(section, i, slot.count > 0 ? slot : null);\n } else {\n this.cursor = slot;\n this._setStack(section, i, null);\n }\n } else {\n const max = ITEMS[cur.item]!.maxStack;\n if (!slot) {\n if (rightClick) {\n this._setStack(section, i, { item: cur.item, count: 1 });\n cur.count -= 1;\n if (cur.count <= 0) this.cursor = null;\n } else {\n this._setStack(section, i, cur);\n this.cursor = null;\n }\n } else if (slot.item === cur.item) {\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 this._setStack(section, i, slot);\n if (cur.count <= 0) this.cursor = null;\n } else {\n // обмен\n this._setStack(section, i, cur);\n this.cursor = slot;\n }\n }\n this._render();\n }\n\n _clickResult(_e?: MouseEvent) {\n const res = this.grid.result();\n if (!res) return;\n if (\n this.cursor &&\n (this.cursor.item !== res.item ||\n this.cursor.count + res.count > ITEMS[res.item]!.maxStack)\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 _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 = ITEM_LABELS[stack.item] ?? stack.item;\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (stack.count > 1\n ? stack.count\n : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n }\n };\n for (let i = 0; i < 36; i++)\n draw(this._invSlots[i]!, this.inventory.slots[i]!);\n this._craftSlots.forEach((el, i) => draw(el, this.grid.slots[i]!));\n draw(\n this._resultSlot,\n this.grid.result() ? { ...this.grid.result()! } : null,\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 = (this.cursor.count >\n 1\n ? this.cursor.count\n : \"\") as unknown as string;\n } else {\n this.cursorEl.classList.add(\"hidden\");\n }\n events.emit(\"invChanged\"); // HUD-хотбар обновится\n }\n}\n"
208
+ },
209
+ {
210
+ "path": "workshop.ts",
211
+ "content": "import type {\n CapturedContent,\n ContentTypeHandler,\n DownloadedContent,\n LocalContentItem,\n} from \"@idosgames/module-sdk\";\nimport type { Game } from \"./main\";\nimport type { SaveData } from \"./types\";\nimport { WorldStore, newWorldId } from \"./systems/WorldStore\";\nimport { SpecGenerator } from \"./worldgen/SpecGenerator\";\nimport { drawPreview } from \"./worldgen/preview\";\n\n// VoxelCraft в Мастерской: тип контента \"voxelcraft.world\".\n//\n// Мастерская общая для всех игр и про миры ничего не знает — она берёт отсюда три вещи: какие миры\n// есть у игрока («Мои миры»), как один из них превратить в файлы для публикации и как открыть\n// скачанный. Файл публикации — это СЕЙВ (рецепт или сид + дифы блоков), тот же, что пишет автосейв:\n// второго формата у мира нет.\n//\n// ⚠ Тип обязан быть заведён в конфиге тайтла (Workshop.ContentTypes[\"voxelcraft.world\"]) с ролью\n// \"main\" формата application/json — иначе сервер откажет в публикации.\n\nexport const VOXEL_WORLD_CONTENT_TYPE = \"voxelcraft.world\";\n\n/** Сторона превью в пикселях. Превью — миникарта рецепта: у мира по сиду её нет. */\nconst THUMB_SIDE = 256;\n\n/**\n * Обработчик типа. Игра создаётся при монтировании сцены и может ещё не существовать (игрок открыл\n * Мастерскую раньше игры) — поэтому доступ к ней через геттер, а хранилище при необходимости\n * открывается своё.\n */\nexport function createVoxelWorldContentType(\n getGame: () => Game | null,\n): ContentTypeHandler {\n const withStore = async <T>(\n work: (store: WorldStore) => Promise<T>,\n ): Promise<T> => {\n const live = getGame()?.store;\n if (live) return work(live);\n const own = await WorldStore.open();\n if (!own)\n throw new Error(\n \"Этот браузер не даёт хранить миры (приватный режим или запрет хранилища).\",\n );\n try {\n return await work(own);\n } finally {\n own.close();\n }\n };\n\n return {\n type: VOXEL_WORLD_CONTENT_TYPE,\n label: \"Миры VoxelCraft\",\n icon: \"🧊\",\n modeId: \"voxelcraft\",\n\n async listLocal(): Promise<LocalContentItem[]> {\n // Текущий мир сохраняем перед показом списка — иначе опубликовался бы сейв полуминутной давности.\n await getGame()?.persist();\n return withStore(async (store) =>\n (await store.list()).map((w) => ({\n id: w.id,\n name: w.name,\n updatedAt: w.updatedAt,\n subtitle: w.kind === \"spec\" ? \"мир по рецепту\" : \"мир по сиду\",\n })),\n );\n },\n\n async capture(localId: string): Promise<CapturedContent> {\n const game = getGame();\n if (game?.worldId === localId) await game.persist();\n\n const stored = await withStore((store) => store.get(localId));\n if (!stored) throw new Error(\"Мир не найден в «Моих мирах».\");\n\n return {\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(stored.save),\n },\n ],\n thumbnail: await thumbnailOf(stored.save),\n suggestedTitle: stored.name,\n metadata: worldMetadata(stored.save),\n };\n },\n\n async open(content: DownloadedContent): Promise<void> {\n const main =\n content.files.find((f) => f.role === \"main\") ?? content.files[0];\n if (!main) throw new Error(\"В публикации нет файла мира.\");\n\n const save = parseSave(main.data);\n const name = content.title?.trim() || \"Мир из Мастерской\";\n\n const game = getGame();\n if (game) {\n if (!game.openSharedWorld(save, name))\n throw new Error(\n \"Этот мир не открывается: сейв повреждён или от несовместимой версии.\",\n );\n return;\n }\n\n // Игры ещё нет — кладём мир в «Мои миры» новой записью. Запущенная игра продолжает самый\n // свежий мир сама (см. Game: «продолжить последний мир»), то есть откроет именно этот.\n await withStore((store) =>\n store.put({ id: newWorldId(), name, updatedAt: Date.now(), save }),\n );\n },\n };\n}\n\n/** Сейв из байтов публикации. Проверка — только формы: остальное проверит SaveSystem.apply. */\nfunction parseSave(data: ArrayBuffer): SaveData {\n let parsed: unknown;\n try {\n parsed = JSON.parse(new TextDecoder().decode(data));\n } catch {\n throw new Error(\"Файл мира — не JSON.\");\n }\n if (\n !parsed ||\n typeof parsed !== \"object\" ||\n typeof (parsed as { version?: unknown }).version !== \"number\" ||\n typeof (parsed as { chunks?: unknown }).chunks !== \"object\"\n )\n throw new Error(\"Это не сейв VoxelCraft.\");\n return parsed as SaveData;\n}\n\n/** Метаданные для фильтров каталога: вид мира и имя рецепта. Ключи — латиница без точек (путь в базе). */\nfunction worldMetadata(save: SaveData): Record<string, string> {\n const source = save.world;\n // У рецепта сид необязателен (по умолчанию — хеш имени): без проверки в каталог ушло бы \"undefined\".\n if (source?.kind === \"spec\")\n return source.spec.seed === undefined || source.spec.seed === null\n ? { kind: \"spec\" }\n : { kind: \"spec\", seed: String(source.spec.seed) };\n return { kind: \"noise\", seed: String(save.seed) };\n}\n\n/** Превью — миникарта рецепта в webp. У мира по сиду превью нет: рисовать нечего без генерации. */\nasync function thumbnailOf(\n save: SaveData,\n): Promise<CapturedContent[\"thumbnail\"]> {\n const source = save.world;\n if (source?.kind !== \"spec\" || typeof document === \"undefined\")\n return undefined;\n try {\n const canvas = document.createElement(\"canvas\");\n drawPreview(canvas, new SpecGenerator(source.spec), THUMB_SIDE);\n const blob = await new Promise<Blob | null>((resolve) =>\n canvas.toBlob(resolve, \"image/webp\", 0.85),\n );\n if (!blob) return undefined;\n // Браузер без webp-кодека молча отдаёт png — объявляем то, что получилось на самом деле.\n const type = blob.type === \"image/png\" ? \"image/png\" : \"image/webp\";\n return { contentType: type, data: blob };\n } catch {\n return undefined; // превью — украшение: без него мир всё равно публикуется\n }\n}\n"
176
212
  },
177
213
  {
178
214
  "path": "world/Chunk.ts",
@@ -180,23 +216,87 @@
180
216
  },
181
217
  {
182
218
  "path": "world/ChunkManager.ts",
183
- "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 { TerrainGenerator } from \"./TerrainGenerator\";\nimport { ChunkMesher } from \"./ChunkMesher\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkDiffMap } 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 generator: TerrainGenerator;\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\n constructor(scene: THREE.Scene, atlas: TextureAtlas, seed: number) {\n this.scene = scene;\n this.seed = seed;\n this.generator = new TerrainGenerator(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 }\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\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 if (!this.chunks.has(key(chunk.cx, chunk.cz))) 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.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 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 /** Тик блоков с поведением (в MVP реестр пуст — шов автоматизации). */\n tick(): void {\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 }\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 }\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"
219
+ "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 {\n createGenerator,\n type WorldGenerator,\n} from \"../worldgen/WorldGenerator\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\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\n constructor(scene: THREE.Scene, atlas: TextureAtlas, source: WorldSource) {\n this.scene = scene;\n this.source = source;\n this.generator = createGenerator(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 }\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\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.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 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 /** Тик блоков с поведением (в MVP реестр пуст — шов автоматизации). */\n tick(): void {\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 }\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 }\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"
184
220
  },
185
221
  {
186
222
  "path": "world/ChunkMesher.ts",
187
- "content": "// Мешер чанка: одна пересборка = три геометрии (твёрдая, растительность-крестовины,\n// вода). Рендерятся только грани, видимые наружу: сосед-непрозрачный блок гасит грань,\n// в том числе через границу чанков (незагруженный сосед считается непрозрачным —\n// фронтир прячет туман, при загрузке соседа чанк пере-дертится).\nimport * as THREE from \"three\";\nimport { BLOCKS, B, waterLevel } from \"../registry/Blocks\";\nimport { CHUNK_SIZE, idx, type Chunk } from \"./Chunk\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"./ChunkManager\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\ninterface UVRect {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n}\n\n// Таблица граней куба: направление, 4 угла (позиция + UV), базовая яркость.\n// Порядок индексов: i, i+1, i+2, i+2, i+1, i+3 (CCW снаружи).\nconst FACES = [\n {\n dir: [-1, 0, 0],\n shade: 0.8,\n corners: [\n [0, 1, 0, 0, 1],\n [0, 0, 0, 0, 0],\n [0, 1, 1, 1, 1],\n [0, 0, 1, 1, 0],\n ],\n },\n {\n dir: [1, 0, 0],\n shade: 0.8,\n corners: [\n [1, 1, 1, 0, 1],\n [1, 0, 1, 0, 0],\n [1, 1, 0, 1, 1],\n [1, 0, 0, 1, 0],\n ],\n },\n {\n dir: [0, -1, 0],\n shade: 0.5,\n corners: [\n [1, 0, 1, 1, 0],\n [0, 0, 1, 0, 0],\n [1, 0, 0, 1, 1],\n [0, 0, 0, 0, 1],\n ],\n },\n {\n dir: [0, 1, 0],\n shade: 1.0,\n corners: [\n [0, 1, 1, 1, 1],\n [1, 1, 1, 0, 1],\n [0, 1, 0, 1, 0],\n [1, 1, 0, 0, 0],\n ],\n },\n {\n dir: [0, 0, -1],\n shade: 0.6,\n corners: [\n [1, 0, 0, 0, 0],\n [0, 0, 0, 1, 0],\n [1, 1, 0, 0, 1],\n [0, 1, 0, 1, 1],\n ],\n },\n {\n dir: [0, 0, 1],\n shade: 0.6,\n corners: [\n [0, 0, 1, 0, 0],\n [1, 0, 1, 1, 0],\n [0, 1, 1, 0, 1],\n [1, 1, 1, 1, 1],\n ],\n },\n];\n\nclass GeoBuilder {\n pos: number[];\n norm: number[];\n uv: number[];\n col: number[];\n index: number[];\n constructor() {\n this.pos = [];\n this.norm = [];\n this.uv = [];\n this.col = [];\n this.index = [];\n }\n get empty(): boolean {\n return this.index.length === 0;\n }\n quad(\n corners: number[][],\n ox: number,\n oy: number,\n oz: number,\n nx: number,\n ny: number,\n nz: number,\n uvRect: UVRect | null,\n shade: number,\n ): void {\n const base = this.pos.length / 3;\n for (const [cx, cy, cz, cu, cv] of corners) {\n this.pos.push(ox + cx!, oy + cy!, oz + cz!);\n this.norm.push(nx, ny, nz);\n this.uv.push(\n uvRect ? uvRect.u0 + (uvRect.u1 - uvRect.u0) * cu! : cu!,\n uvRect ? uvRect.v0 + (uvRect.v1 - uvRect.v0) * cv! : cv!,\n );\n this.col.push(shade, shade, shade);\n }\n this.index.push(base, base + 1, base + 2, base + 2, base + 1, base + 3);\n }\n build(): THREE.BufferGeometry {\n const g = new THREE.BufferGeometry();\n g.setAttribute(\"position\", new THREE.Float32BufferAttribute(this.pos, 3));\n g.setAttribute(\"normal\", new THREE.Float32BufferAttribute(this.norm, 3));\n g.setAttribute(\"uv\", new THREE.Float32BufferAttribute(this.uv, 2));\n g.setAttribute(\"color\", new THREE.Float32BufferAttribute(this.col, 3));\n g.setIndex(this.index);\n return g;\n }\n}\n\nexport class ChunkMesher {\n atlas: TextureAtlas;\n world: ChunkManager;\n _uvCache: Map<string, UVRect>;\n\n /** @param {import('../gfx/TextureAtlas').TextureAtlas} atlas */\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\n for (let y = 0; y < SY; 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 if (def.liquid) {\n this._waterCell(water, chunk, x, y, z, wx0, wz0, blockAt, metaAt);\n continue;\n }\n if (waterOnly) continue;\n\n if (def.cross) {\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);\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 const target = def.opaque ? solid! : foliage!; // листва — в cutout-проход\n for (const f of FACES) {\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 tile =\n f.dir[1]! > 0\n ? def.tex!.top!\n : f.dir[1]! < 0\n ? def.tex!.bottom!\n : def.tex!.side!;\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(tile),\n f.shade,\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 );\n chunk.foliageMesh = this._swap(\n chunk.foliageMesh,\n foliage!,\n this.atlas.foliageMat,\n scene,\n wx0,\n wz0,\n 0,\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 );\n chunk.waterDirty = false;\n }\n\n _waterCell(\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 ): void {\n const isWater = (id: number): boolean => id === B.water;\n const level = 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 const wx = wx0 + x,\n wz = wz0 + z; // мировые UV: repeat-текстура тайлится сама\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 null,\n 1.0,\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 null,\n 0.6,\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, y + (cy === 1 ? h : 0)];\n });\n b.quad(corners, x, y, z, s.d[0]!, 0, s.d[2]!, null, 0.8);\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 ): 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.matrixAutoUpdate = false;\n mesh.updateMatrix();\n scene.add(mesh);\n return mesh;\n }\n}\n"
223
+ "content": "// Мешер чанка: одна пересборка = три геометрии (твёрдая, растительность-крестовины,\n// вода). Рендерятся только грани, видимые наружу: сосед-непрозрачный блок гасит грань,\n// в том числе через границу чанков (незагруженный сосед считается непрозрачным —\n// фронтир прячет туман, при загрузке соседа чанк пере-дертится).\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { BLOCKS, B, waterLevel } from \"../registry/Blocks\";\nimport { CHUNK_SIZE, idx, type Chunk } from \"./Chunk\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type { ChunkManager } from \"./ChunkManager\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\ninterface UVRect {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n}\n\n// Таблица граней куба: направление, 4 угла (позиция + UV), базовая яркость.\n// Порядок индексов: i, i+1, i+2, i+2, i+1, i+3 (CCW снаружи).\n// Яркость граней мягче классической: направление теперь даёт настоящий свет (солнце + небосвод),\n// запечённый шейд лишь подчёркивает объём, а не делает бока серыми.\nconst FACES = [\n {\n dir: [-1, 0, 0],\n shade: 0.86,\n corners: [\n [0, 1, 0, 0, 1],\n [0, 0, 0, 0, 0],\n [0, 1, 1, 1, 1],\n [0, 0, 1, 1, 0],\n ],\n },\n {\n dir: [1, 0, 0],\n shade: 0.86,\n corners: [\n [1, 1, 1, 0, 1],\n [1, 0, 1, 0, 0],\n [1, 1, 0, 1, 1],\n [1, 0, 0, 1, 0],\n ],\n },\n {\n dir: [0, -1, 0],\n shade: 0.62,\n corners: [\n [1, 0, 1, 1, 0],\n [0, 0, 1, 0, 0],\n [1, 0, 0, 1, 1],\n [0, 0, 0, 0, 1],\n ],\n },\n {\n dir: [0, 1, 0],\n shade: 1.0,\n corners: [\n [0, 1, 1, 1, 1],\n [1, 1, 1, 0, 1],\n [0, 1, 0, 1, 0],\n [1, 1, 0, 0, 0],\n ],\n },\n {\n dir: [0, 0, -1],\n shade: 0.76,\n corners: [\n [1, 0, 0, 0, 0],\n [0, 0, 0, 1, 0],\n [1, 1, 0, 0, 1],\n [0, 1, 0, 1, 1],\n ],\n },\n {\n dir: [0, 0, 1],\n shade: 0.76,\n corners: [\n [0, 0, 1, 0, 0],\n [1, 0, 1, 1, 0],\n [0, 1, 1, 0, 1],\n [1, 1, 1, 1, 1],\n ],\n },\n];\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\n/**\n * Квад режется по диагонали 1–2 или 0–3 (обход периметра 0-1-3-2). С AO диагональ ведём через\n * «выбивающийся» угол — иначе затемнение одного угла ложится треугольником с резкой гранью.\n */\nfunction aoFlip(ao: readonly number[]): boolean {\n const d03 = Math.abs(ao[0]! - ao[3]!);\n const d12 = Math.abs(ao[1]! - ao[2]!);\n return d03 !== d12 ? d03 > d12 : ao[0]! + ao[3]! > ao[1]! + ao[2]!;\n}\n\nclass GeoBuilder {\n pos: number[];\n norm: number[];\n uv: number[];\n col: number[];\n index: number[];\n constructor() {\n this.pos = [];\n this.norm = [];\n this.uv = [];\n this.col = [];\n this.index = [];\n }\n get empty(): boolean {\n return this.index.length === 0;\n }\n quad(\n corners: number[][],\n ox: number,\n oy: number,\n oz: number,\n nx: number,\n ny: number,\n nz: number,\n uvRect: UVRect | null,\n shade: number,\n ao?: readonly number[], // яркость 4 углов (вершинное AO); нет — грань без затенения\n ): void {\n const base = this.pos.length / 3;\n let i = 0;\n for (const [cx, cy, cz, cu, cv] of corners) {\n this.pos.push(ox + cx!, oy + cy!, oz + cz!);\n this.norm.push(nx, ny, nz);\n this.uv.push(\n uvRect ? uvRect.u0 + (uvRect.u1 - uvRect.u0) * cu! : cu!,\n uvRect ? uvRect.v0 + (uvRect.v1 - uvRect.v0) * cv! : cv!,\n );\n const s = ao ? shade * ao[i]! : shade;\n this.col.push(s, s, s);\n i++;\n }\n if (ao && aoFlip(ao))\n this.index.push(base, base + 1, base + 3, base, base + 3, base + 2);\n else\n this.index.push(base, base + 1, base + 2, base + 2, base + 1, base + 3);\n }\n build(): THREE.BufferGeometry {\n const g = new THREE.BufferGeometry();\n g.setAttribute(\"position\", new THREE.Float32BufferAttribute(this.pos, 3));\n g.setAttribute(\"normal\", new THREE.Float32BufferAttribute(this.norm, 3));\n g.setAttribute(\"uv\", new THREE.Float32BufferAttribute(this.uv, 2));\n g.setAttribute(\"color\", new THREE.Float32BufferAttribute(this.col, 3));\n g.setIndex(this.index);\n return g;\n }\n}\n\nexport class ChunkMesher {\n atlas: TextureAtlas;\n world: ChunkManager;\n _uvCache: Map<string, UVRect>;\n\n /** @param {import('../gfx/TextureAtlas').TextureAtlas} atlas */\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\n for (let y = 0; y < SY; 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 if (def.liquid) {\n this._waterCell(water, chunk, x, y, z, wx0, wz0, blockAt, metaAt);\n continue;\n }\n if (waterOnly) continue;\n\n if (def.cross) {\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);\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 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 tile =\n f.dir[1]! > 0\n ? def.tex!.top!\n : f.dir[1]! < 0\n ? def.tex!.bottom!\n : def.tex!.side!;\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 }\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(tile),\n f.shade,\n useAO ? ao : undefined,\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 _waterCell(\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 ): void {\n const isWater = (id: number): boolean => id === B.water;\n const level = 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 const wx = wx0 + x,\n wz = wz0 + z; // мировые UV: repeat-текстура тайлится сама\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 null,\n 1.0,\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 null,\n 0.6,\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, y + (cy === 1 ? h : 0)];\n });\n b.quad(corners, x, y, z, s.d[0]!, 0, s.d[2]!, null, 0.8);\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"
188
224
  },
189
225
  {
190
226
  "path": "world/Raycast.ts",
191
227
  "content": "// Воксельный raycast — DDA (Amanatides–Woo). O(дистанции), точные координаты\n// блока и нормаль входа (= позиция для постановки). three.js Raycaster\n// сознательно не используется: он O(треугольников) и не знает про блоки.\nimport { BLOCKS } from \"../registry/Blocks\";\nimport type { ChunkManager } from \"./ChunkManager\";\n\ninterface Vec3Like {\n x: number;\n y: number;\n z: number;\n}\n\ninterface RaycastHit {\n x: number;\n y: number;\n z: number;\n id: number;\n nx: number;\n ny: number;\n nz: number;\n dist: number;\n}\n\n/**\n * @param {ChunkManager} world\n * @param {{x,y,z}} origin — точка глаза\n * @param {{x,y,z}} dir — нормализованное направление\n * @param {number} maxDist\n * @param {boolean} hitLiquid — попадать ли по воде (для ведра в будущем)\n * @returns {{x,y,z,id,nx,ny,nz,dist}|null}\n */\nexport function raycastBlocks(\n world: ChunkManager,\n origin: Vec3Like,\n dir: Vec3Like,\n maxDist: number,\n hitLiquid = false,\n): RaycastHit | null {\n let x = Math.floor(origin.x),\n y = Math.floor(origin.y),\n z = Math.floor(origin.z);\n\n const stepX = Math.sign(dir.x),\n stepY = Math.sign(dir.y),\n stepZ = Math.sign(dir.z);\n const tDeltaX = stepX ? Math.abs(1 / dir.x) : Infinity;\n const tDeltaY = stepY ? Math.abs(1 / dir.y) : Infinity;\n const tDeltaZ = stepZ ? Math.abs(1 / dir.z) : Infinity;\n\n const frac = (v: number): number => v - Math.floor(v);\n let tMaxX =\n stepX > 0\n ? (1 - frac(origin.x)) * tDeltaX\n : stepX < 0\n ? frac(origin.x) * tDeltaX\n : Infinity;\n let tMaxY =\n stepY > 0\n ? (1 - frac(origin.y)) * tDeltaY\n : stepY < 0\n ? frac(origin.y) * tDeltaY\n : Infinity;\n let tMaxZ =\n stepZ > 0\n ? (1 - frac(origin.z)) * tDeltaZ\n : stepZ < 0\n ? frac(origin.z) * tDeltaZ\n : Infinity;\n\n let nx = 0,\n ny = 0,\n nz = 0,\n t = 0;\n while (t <= maxDist) {\n const id = world.getBlock(x, y, z);\n const def = BLOCKS[id];\n if (\n def &&\n id !== 0 &&\n (def.solid || def.cross || (hitLiquid && def.liquid))\n )\n return { x, y, z, id, nx, ny, nz, dist: t };\n\n if (tMaxX < tMaxY && tMaxX < tMaxZ) {\n x += stepX;\n t = tMaxX;\n tMaxX += tDeltaX;\n nx = -stepX;\n ny = 0;\n nz = 0;\n } else if (tMaxY < tMaxZ) {\n y += stepY;\n t = tMaxY;\n tMaxY += tDeltaY;\n nx = 0;\n ny = -stepY;\n nz = 0;\n } else {\n z += stepZ;\n t = tMaxZ;\n tMaxZ += tDeltaZ;\n nx = 0;\n ny = 0;\n nz = -stepZ;\n }\n }\n return null;\n}\n"
192
228
  },
229
+ {
230
+ "path": "world/TerrainGenerator.test.ts",
231
+ "content": "import { describe, expect, it } from \"vitest\";\nimport { Chunk } from \"./Chunk\";\nimport { TerrainGenerator } from \"./TerrainGenerator\";\n\n// Сейв v1 хранит только сид + дифы: мир восстанавливается повторной генерацией. Любое изменение\n// вывода генератора молча портит ВСЕ сохранённые миры (дифы лягут не на те блоки), поэтому вывод\n// зафиксирован отпечатком. Сломался тест — значит, сломалась совместимость сейвов, а не тест.\nfunction fingerprint(seed: number, cx: number, cz: number): number {\n const chunk = new Chunk(cx, cz);\n new TerrainGenerator(seed).generate(chunk);\n let h = 2166136261 >>> 0;\n for (const arr of [chunk.blocks, chunk.meta])\n for (let i = 0; i < arr.length; i++) {\n h ^= arr[i]!;\n h = Math.imul(h, 16777619);\n }\n return h >>> 0;\n}\n\ndescribe(\"TerrainGenerator (noise worlds, save v1 compatibility)\", () => {\n it(\"generates the same chunks it always did\", () => {\n const got = [\n fingerprint(1337, 0, 0),\n fingerprint(1337, -1, 2),\n fingerprint(1337, 5, -3),\n fingerprint(42, 0, 0),\n ];\n expect(got).toEqual([3415399371, 833855014, 855609521, 274105386]);\n });\n});\n"
232
+ },
193
233
  {
194
234
  "path": "world/TerrainGenerator.ts",
195
- "content": "// Генератор террейна: высотная карта на симплекс-fbm, слои пород, руды,\n// озёра, деревья и растения. Полностью детерминирован сидом:\n// один и тот же сид → бит-в-бит одинаковый мир (важно для сейва и этапа 2,\n// где клиент генерирует мир по сиду и накатывает диф).\nimport { CONFIG } from \"../config\";\nimport { Simplex2, mulberry32, hashString } from \"../core/Noise\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { CHUNK_SIZE, type Chunk } from \"./Chunk\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\nexport class TerrainGenerator {\n seed: number;\n height: Simplex2;\n detail: Simplex2;\n\n constructor(seed: number) {\n this.seed = seed;\n this.height = new Simplex2(hashString(\"height:\" + seed));\n this.detail = new Simplex2(hashString(\"detail:\" + seed));\n }\n\n /** Высота поверхности в мировых координатах. */\n surfaceHeight(wx: number, wz: number): number {\n const w = CONFIG.world;\n const h =\n this.height.fbm(wx * 0.008, wz * 0.008, 4) * w.hillAmp +\n this.detail.noise(wx * 0.05, wz * 0.05) * 2;\n return Math.max(2, Math.min(SY - 8, Math.round(w.baseHeight + h)));\n }\n\n /** Заполняет данные чанка. Деревья сажаются с отступом от края (крона радиуса 2\n * не пересекает границу чанка — генерация чанков остаётся независимой). */\n generate(chunk: Chunk): void {\n const water = CONFIG.world.waterLevel;\n const rng = mulberry32(\n hashString(`chunk:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const heights = new Int16Array(SX * SZ);\n\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const wx = chunk.cx * SX + x,\n wz = chunk.cz * SZ + z;\n const h = this.surfaceHeight(wx, wz);\n heights[x + z * SX] = h;\n const beach = h <= water + 1; // у воды — песок вместо дёрна\n\n for (let y = 0; y <= h; y++) {\n let id: number;\n if (y === 0) id = B.bedrock!;\n else if (y === 1 && rng() < 0.5)\n id = B.bedrock!; // рваный второй слой\n else if (y >= h - (beach ? 2 : 0) && beach) id = B.sand!;\n else if (y === h) id = B.grass!;\n else if (y >= h - 3) id = B.dirt!;\n else id = B.stone!;\n chunk.setRaw(x, y, z, id);\n }\n // озёра: всё, что ниже уровня моря — source-вода\n for (let y = h + 1; y <= water; y++)\n chunk.setRaw(x, y, z, B.water!, packWater(8, true));\n }\n }\n\n this._ores(chunk, rng);\n this._trees(chunk, rng, heights, water);\n this._plants(chunk, rng, heights, water);\n chunk.generated = true;\n }\n\n _ores(chunk: Chunk, rng: () => number): void {\n // жилы: случайный «пьяный» шаг из точки, 4-8 блоков\n const vein = (id: number, count: number, maxY: number): void => {\n for (let v = 0; v < count; v++) {\n let x = (rng() * SX) | 0,\n y = (3 + rng() * (maxY - 3)) | 0,\n z = (rng() * SZ) | 0;\n const len = (4 + rng() * 5) | 0;\n for (let i = 0; i < len; i++) {\n if (\n x >= 0 &&\n x < SX &&\n y >= 2 &&\n y < SY &&\n z >= 0 &&\n z < SZ &&\n chunk.get(x, y, z) === B.stone\n )\n chunk.setRaw(x, y, z, id);\n x += ((rng() * 3) | 0) - 1;\n y += ((rng() * 3) | 0) - 1;\n z += ((rng() * 3) | 0) - 1;\n }\n }\n };\n vein(B.coal_ore!, 7, 40);\n vein(B.iron_ore!, 5, 28);\n }\n\n _trees(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n _water: number,\n ): void {\n const attempts = 3;\n for (let t = 0; t < attempts; t++) {\n // отступ 2 от края — крона не выходит за чанк\n const x = 2 + ((rng() * (SX - 4)) | 0),\n z = 2 + ((rng() * (SZ - 4)) | 0);\n const h = heights[x + z * SX]!;\n if (rng() > 0.6) continue;\n if (chunk.get(x, h, z) !== B.grass || h + 8 >= SY) continue;\n const trunk = 4 + ((rng() * 2) | 0);\n for (let y = 1; y <= trunk; y++) chunk.setRaw(x, h + y, z, B.oak_log!);\n // крона: два слоя 5×5 + два слоя 3×3 сверху, углы прореживаются\n for (let dy = trunk - 1; dy <= trunk + 2; dy++) {\n const r = dy >= trunk + 1 ? 1 : 2;\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (dx === 0 && dz === 0 && dy <= trunk) continue;\n if (Math.abs(dx) === r && Math.abs(dz) === r && rng() < 0.5)\n continue;\n const y = h + dy + 1;\n if (y < SY && chunk.get(x + dx, y, z + dz) === B.air)\n chunk.setRaw(x + dx, y, z + dz, B.leaves!);\n }\n }\n }\n }\n\n _plants(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n water: number,\n ): void {\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const h = heights[x + z * SX]!;\n if (h <= water || h + 1 >= SY) continue;\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const r = rng();\n if (r < 0.08) chunk.setRaw(x, h + 1, z, B.tallgrass!);\n else if (r < 0.095)\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.flower_yellow! : B.flower_red!,\n );\n }\n }\n}\n"
235
+ "content": "// Генератор террейна: высотная карта на симплекс-fbm, слои пород, руды,\n// озёра, деревья и растения. Полностью детерминирован сидом:\n// один и тот же сид → бит-в-бит одинаковый мир (важно для сейва и этапа 2,\n// где клиент генерирует мир по сиду и накатывает диф).\nimport { CONFIG } from \"../config\";\nimport { Simplex2, mulberry32, hashString } from \"../core/Noise\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { CHUNK_SIZE, type Chunk } from \"./Chunk\";\nimport type { WorldGenerator } from \"../worldgen/WorldGenerator\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\n/** Рудные жилы: случайный «пьяный» шаг из точки, 4-8 блоков, только по камню. Общие с SpecGenerator. */\nexport function generateOres(chunk: Chunk, rng: () => number): void {\n const vein = (id: number, count: number, maxY: number): void => {\n for (let v = 0; v < count; v++) {\n let x = (rng() * SX) | 0,\n y = (3 + rng() * (maxY - 3)) | 0,\n z = (rng() * SZ) | 0;\n const len = (4 + rng() * 5) | 0;\n for (let i = 0; i < len; i++) {\n if (\n x >= 0 &&\n x < SX &&\n y >= 2 &&\n y < SY &&\n z >= 0 &&\n z < SZ &&\n chunk.get(x, y, z) === B.stone\n )\n chunk.setRaw(x, y, z, id);\n x += ((rng() * 3) | 0) - 1;\n y += ((rng() * 3) | 0) - 1;\n z += ((rng() * 3) | 0) - 1;\n }\n }\n };\n vein(B.coal_ore!, 7, 40);\n vein(B.iron_ore!, 5, 28);\n}\n\n/** Классический мир по сиду. Его вывод зафиксирован тестом: на нём стоят все сейвы v1. */\nexport class TerrainGenerator implements WorldGenerator {\n seed: number;\n height: Simplex2;\n detail: Simplex2;\n readonly waterLevel = CONFIG.world.waterLevel;\n\n constructor(seed: number) {\n this.seed = seed;\n this.height = new Simplex2(hashString(\"height:\" + seed));\n this.detail = new Simplex2(hashString(\"detail:\" + seed));\n }\n\n /** Высота поверхности в мировых координатах. */\n surfaceHeight(wx: number, wz: number): number {\n const w = CONFIG.world;\n const h =\n this.height.fbm(wx * 0.008, wz * 0.008, 4) * w.hillAmp +\n this.detail.noise(wx * 0.05, wz * 0.05) * 2;\n return Math.max(2, Math.min(SY - 8, Math.round(w.baseHeight + h)));\n }\n\n /** Заполняет данные чанка. Деревья сажаются с отступом от края (крона радиуса 2\n * не пересекает границу чанка — генерация чанков остаётся независимой). */\n generate(chunk: Chunk): void {\n const water = CONFIG.world.waterLevel;\n const rng = mulberry32(\n hashString(`chunk:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const heights = new Int16Array(SX * SZ);\n\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const wx = chunk.cx * SX + x,\n wz = chunk.cz * SZ + z;\n const h = this.surfaceHeight(wx, wz);\n heights[x + z * SX] = h;\n const beach = h <= water + 1; // у воды — песок вместо дёрна\n\n for (let y = 0; y <= h; y++) {\n let id: number;\n if (y === 0) id = B.bedrock!;\n else if (y === 1 && rng() < 0.5)\n id = B.bedrock!; // рваный второй слой\n else if (y >= h - (beach ? 2 : 0) && beach) id = B.sand!;\n else if (y === h) id = B.grass!;\n else if (y >= h - 3) id = B.dirt!;\n else id = B.stone!;\n chunk.setRaw(x, y, z, id);\n }\n // озёра: всё, что ниже уровня моря — source-вода\n for (let y = h + 1; y <= water; y++)\n chunk.setRaw(x, y, z, B.water!, packWater(8, true));\n }\n }\n\n this._ores(chunk, rng);\n this._trees(chunk, rng, heights, water);\n this._plants(chunk, rng, heights, water);\n chunk.generated = true;\n }\n\n /** Спавн классического мира ищется от (8, 8) как всегда. */\n spawnHint(): { x: number; z: number } {\n return { x: 8, z: 8 };\n }\n\n _ores(chunk: Chunk, rng: () => number): void {\n generateOres(chunk, rng);\n }\n\n _trees(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n _water: number,\n ): void {\n const attempts = 3;\n for (let t = 0; t < attempts; t++) {\n // отступ 2 от края — крона не выходит за чанк\n const x = 2 + ((rng() * (SX - 4)) | 0),\n z = 2 + ((rng() * (SZ - 4)) | 0);\n const h = heights[x + z * SX]!;\n if (rng() > 0.6) continue;\n if (chunk.get(x, h, z) !== B.grass || h + 8 >= SY) continue;\n const trunk = 4 + ((rng() * 2) | 0);\n for (let y = 1; y <= trunk; y++) chunk.setRaw(x, h + y, z, B.oak_log!);\n // крона: два слоя 5×5 + два слоя 3×3 сверху, углы прореживаются\n for (let dy = trunk - 1; dy <= trunk + 2; dy++) {\n const r = dy >= trunk + 1 ? 1 : 2;\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (dx === 0 && dz === 0 && dy <= trunk) continue;\n if (Math.abs(dx) === r && Math.abs(dz) === r && rng() < 0.5)\n continue;\n const y = h + dy + 1;\n if (y < SY && chunk.get(x + dx, y, z + dz) === B.air)\n chunk.setRaw(x + dx, y, z + dz, B.leaves!);\n }\n }\n }\n }\n\n _plants(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n water: number,\n ): void {\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const h = heights[x + z * SX]!;\n if (h <= water || h + 1 >= SY) continue;\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const r = rng();\n if (r < 0.08) chunk.setRaw(x, h + 1, z, B.tallgrass!);\n else if (r < 0.095)\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.flower_yellow! : B.flower_red!,\n );\n }\n }\n}\n"
236
+ },
237
+ {
238
+ "path": "worldgen/ImageAnalyzer.test.ts",
239
+ "content": "import { describe, expect, it } from \"vitest\";\nimport {\n IMAGE_LAND_PALETTE,\n analyzeMood,\n analyzeTopDown,\n type RGBAImage,\n} from \"./ImageAnalyzer\";\nimport { buildSpecWithoutAI } from \"./textSpec\";\nimport { SpecGenerator } from \"./SpecGenerator\";\nimport { SIZE_PRESETS } from \"./WorldSpec\";\n\n/** Синтетическая картинка: цвет по координатам. */\nfunction image(\n w: number,\n h: number,\n color: (x: number, y: number) => [number, number, number],\n): RGBAImage {\n const data = new Uint8ClampedArray(w * h * 4);\n for (let y = 0; y < h; y++)\n for (let x = 0; x < w; x++) {\n const [r, g, b] = color(x, y);\n data.set([r, g, b, 255], (x + y * w) * 4);\n }\n return { width: w, height: h, data };\n}\n\nconst SEA: [number, number, number] = [40, 90, 200];\nconst MEADOW: [number, number, number] = [95, 166, 60];\n\ndescribe(\"analyzeTopDown\", () => {\n const img = image(100, 100, (x) => (x < 50 ? SEA : MEADOW));\n const td = analyzeTopDown(img, 20, 20);\n\n it(\"reads blue as water and green as grass\", () => {\n expect(td.waterShare).toBeCloseTo(0.5, 1);\n expect(td.water[0]).toBe(1); // левый край\n expect(td.water[19]).toBe(0); // правый край\n expect(IMAGE_LAND_PALETTE[td.surface[19]!]).toBe(\"grass\");\n });\n\n it(\"raises land away from the shore\", () => {\n expect(td.elevation[19]!).toBeGreaterThan(td.elevation[11]!);\n });\n});\n\ndescribe(\"analyzeMood\", () => {\n it(\"sees a snowy picture as snow\", () => {\n const m = analyzeMood(image(64, 64, () => [245, 248, 252]));\n expect(m.shares.snow).toBeGreaterThan(0.9);\n expect(m.dominant[0]!.share).toBeGreaterThan(0.9);\n });\n});\n\ndescribe(\"buildSpecWithoutAI\", () => {\n const size = SIZE_PRESETS.island;\n\n it(\"understands Russian keywords\", () => {\n const spec = buildSpecWithoutAI({\n text: \"Замок у озера в снежных горах, ночь\",\n size,\n seed: 1,\n });\n expect(spec.terrain.shape).toBe(\"mountains\");\n expect(spec.zones[0]!.biome).toBe(\"snow\");\n expect(spec.structures.map((s) => s.type)).toEqual(\n expect.arrayContaining([\"castle\", \"pond\"]),\n );\n expect(spec.ambience.timeOfDay).toBe(\"night\");\n });\n\n it(\"matches Russian words in any case form\", () => {\n const spec = buildSpecWithoutAI({\n text: \"Остров с замком и деревней, обнесённый стеной\",\n size,\n seed: 4,\n });\n expect(spec.structures.map((s) => s.type)).toEqual(\n expect.arrayContaining([\"castle\", \"village\", \"wall\"]),\n );\n });\n\n it(\"understands English keywords\", () => {\n const spec = buildSpecWithoutAI({\n text: \"a desert village with a pyramid\",\n size,\n seed: 2,\n });\n expect(spec.zones[0]!.biome).toBe(\"desert\");\n expect(spec.structures.map((s) => s.type)).toEqual(\n expect.arrayContaining([\"village\", \"pyramid\"]),\n );\n });\n\n it(\"turns a top-down picture into heightmap + surface layers the generator follows\", () => {\n const td = analyzeTopDown(\n image(64, 64, (x) => (x < 32 ? SEA : MEADOW)),\n 64,\n 64,\n );\n const spec = buildSpecWithoutAI({\n text: \"\",\n size: SIZE_PRESETS.large,\n seed: 3,\n topDown: td,\n });\n expect(spec.terrain.heightmap).toBeDefined();\n expect(spec.terrain.surfaceMap).toBeDefined();\n const g = new SpecGenerator(spec);\n // левая половина картинки — вода, правая — суша (по центру по глубине, в стороне от краёв)\n expect(g.heightAt(Math.round(g.W * 0.3), g.D >> 1)).toBeLessThan(\n g.waterLevel,\n );\n expect(g.heightAt(Math.round(g.W * 0.7), g.D >> 1)).toBeGreaterThan(\n g.waterLevel,\n );\n });\n});\n"
240
+ },
241
+ {
242
+ "path": "worldgen/ImageAnalyzer.ts",
243
+ "content": "// Разбор картинки БЕЗ ИИ, прямо в браузере. Два прочтения:\n//\n// - «карта сверху»: картинка — вид на мир сверху. Синее — вода, остальное — суша; высота суши\n// растёт от берега вглубь и от яркости; цвет пикселя → ближайший по цвету блок поверхности.\n// - «настроение»: картинка задаёт только характер мира — доли воды/зелени/песка/снега и\n// доминирующие цвета (из них — биомы, форма и материалы построек).\n//\n// Здесь только чистые функции над RGBA-массивом: они тестируются без DOM. Загрузка файла и canvas —\n// в loadImage.ts.\nimport { B, BLOCKS } from \"../registry/Blocks\";\n\nexport interface RGBAImage {\n width: number;\n height: number;\n data: Uint8ClampedArray | Uint8Array;\n}\n\n/** Блоки, которыми картинка может покрыть землю в режиме «карта сверху». */\nexport const IMAGE_LAND_PALETTE: readonly string[] = [\n \"grass\",\n \"dirt\",\n \"stone\",\n \"sand\",\n \"red_sand\",\n \"gravel\",\n \"clay\",\n \"snow\",\n \"ice\",\n \"sandstone\",\n \"terracotta\",\n \"dirt_path\",\n \"planks\",\n \"dark_planks\",\n \"stone_bricks\",\n \"bricks\",\n \"white_wool\",\n \"red_wool\",\n \"orange_wool\",\n \"yellow_wool\",\n \"green_wool\",\n \"purple_wool\",\n \"pink_wool\",\n \"brown_wool\",\n \"gray_wool\",\n \"black_wool\",\n];\n\n// ---- цвет -----------------------------------------------------------------------\n\ntype Lab = [number, number, number];\n\nfunction srgbToLinear(c: number): number {\n const s = c / 255;\n return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);\n}\n\n/** sRGB → CIE Lab (D65): расстояние в Lab ближе к тому, как цвета различает глаз. */\nexport function rgbToLab(r: number, g: number, b: number): Lab {\n const R = srgbToLinear(r),\n G = srgbToLinear(g),\n Bl = srgbToLinear(b);\n const x = (R * 0.4124 + G * 0.3576 + Bl * 0.1805) / 0.95047;\n const y = R * 0.2126 + G * 0.7152 + Bl * 0.0722;\n const z = (R * 0.0193 + G * 0.1192 + Bl * 0.9505) / 1.08883;\n const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);\n const fx = f(x),\n fy = f(y),\n fz = f(z);\n return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];\n}\n\n/** HSV: hue 0..360, s и v 0..1. */\nexport function rgbToHsv(\n r: number,\n g: number,\n b: number,\n): [number, number, number] {\n const R = r / 255,\n G = g / 255,\n Bl = b / 255;\n const max = Math.max(R, G, Bl),\n min = Math.min(R, G, Bl);\n const d = max - min;\n let h = 0;\n if (d > 0) {\n if (max === R) h = ((G - Bl) / d) % 6;\n else if (max === G) h = (Bl - R) / d + 2;\n else h = (R - G) / d + 4;\n h *= 60;\n if (h < 0) h += 360;\n }\n return [h, max === 0 ? 0 : d / max, max];\n}\n\nconst labCache = new Map<string, Lab>();\nfunction blockLab(name: string): Lab {\n let lab = labCache.get(name);\n if (!lab) {\n const c = BLOCKS[B[name]!]!.mapColor;\n lab = rgbToLab((c >> 16) & 255, (c >> 8) & 255, c & 255);\n labCache.set(name, lab);\n }\n return lab;\n}\n\n/** Индекс ближайшего по цвету блока палитры. */\nexport function nearestBlock(\n r: number,\n g: number,\n b: number,\n palette: readonly string[],\n): number {\n const [L, A, Bv] = rgbToLab(r, g, b);\n let best = 0,\n bestD = Infinity;\n palette.forEach((name, i) => {\n const [l2, a2, b2] = blockLab(name);\n const d = (L - l2) ** 2 + (A - a2) ** 2 + (Bv - b2) ** 2;\n if (d < bestD) {\n bestD = d;\n best = i;\n }\n });\n return best;\n}\n\n/** Похоже ли на воду: насыщенный голубой/синий. */\nexport function looksLikeWater(r: number, g: number, b: number): boolean {\n const [h, s, v] = rgbToHsv(r, g, b);\n return h >= 185 && h <= 250 && s > 0.25 && v > 0.15;\n}\n\n// ---- пересэмплирование ----------------------------------------------------------\n\n/**\n * Картинка → сетка w×h усреднённых RGB с кадрированием «cover»: пропорции мира сохраняются,\n * лишнее по краям обрезается. На клетку — до 16 выборок (быстро даже для больших фото).\n */\nexport function resampleCover(\n img: RGBAImage,\n w: number,\n h: number,\n): Float32Array {\n const out = new Float32Array(w * h * 3);\n const srcAspect = img.width / img.height,\n dstAspect = w / h;\n let cx0 = 0,\n cy0 = 0,\n cw = img.width,\n ch = img.height;\n if (srcAspect > dstAspect) {\n cw = img.height * dstAspect;\n cx0 = (img.width - cw) / 2;\n } else {\n ch = img.width / dstAspect;\n cy0 = (img.height - ch) / 2;\n }\n const cellW = cw / w,\n cellH = ch / h;\n const sx = Math.max(1, Math.min(4, Math.floor(cellW))),\n sy = Math.max(1, Math.min(4, Math.floor(cellH)));\n for (let y = 0; y < h; y++)\n for (let x = 0; x < w; x++) {\n let r = 0,\n g = 0,\n b = 0,\n n = 0;\n for (let j = 0; j < sy; j++)\n for (let i = 0; i < sx; i++) {\n const px = Math.min(\n img.width - 1,\n Math.floor(cx0 + (x + (i + 0.5) / sx) * cellW),\n );\n const py = Math.min(\n img.height - 1,\n Math.floor(cy0 + (y + (j + 0.5) / sy) * cellH),\n );\n const k = (px + py * img.width) * 4;\n const a = img.data[k + 3]! / 255;\n // прозрачное — как белый фон\n r += img.data[k]! * a + 255 * (1 - a);\n g += img.data[k + 1]! * a + 255 * (1 - a);\n b += img.data[k + 2]! * a + 255 * (1 - a);\n n++;\n }\n const o = (x + y * w) * 3;\n out[o] = r / n;\n out[o + 1] = g / n;\n out[o + 2] = b / n;\n }\n return out;\n}\n\n// ---- «карта сверху» ------------------------------------------------------------------\n\nexport interface TopDownResult {\n w: number;\n h: number;\n /** 1 — вода. */\n water: Uint8Array;\n /** Высота суши 0..1 (у воды — 0). */\n elevation: Float32Array;\n /** Индекс блока поверхности в IMAGE_LAND_PALETTE (у воды — 0, не используется). */\n surface: Uint8Array;\n waterShare: number;\n}\n\nexport function analyzeTopDown(\n img: RGBAImage,\n w: number,\n h: number,\n): TopDownResult {\n const rgb = resampleCover(img, w, h);\n const n = w * h;\n const water = new Uint8Array(n);\n const lum = new Float32Array(n);\n const surface = new Uint8Array(n);\n let waterCount = 0;\n for (let i = 0; i < n; i++) {\n const r = rgb[i * 3]!,\n g = rgb[i * 3 + 1]!,\n b = rgb[i * 3 + 2]!;\n lum[i] = (0.299 * r + 0.587 * g + 0.114 * b) / 255;\n if (looksLikeWater(r, g, b)) {\n water[i] = 1;\n waterCount++;\n } else surface[i] = nearestBlock(r, g, b, IMAGE_LAND_PALETTE);\n }\n\n // расстояние до воды (BFS по 8 соседям): суша поднимается от берега вглубь\n const dist = new Float32Array(n).fill(Infinity);\n const queue: number[] = [];\n for (let i = 0; i < n; i++)\n if (water[i]) {\n dist[i] = 0;\n queue.push(i);\n }\n for (let q = 0; q < queue.length; q++) {\n const i = queue[q]!;\n const x = i % w,\n y = (i / w) | 0;\n for (let dy = -1; dy <= 1; dy++)\n for (let dx = -1; dx <= 1; dx++) {\n const nx = x + dx,\n ny = y + dy;\n if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;\n const j = nx + ny * w;\n if (dist[j]! > dist[i]! + 1) {\n dist[j] = dist[i]! + 1;\n queue.push(j);\n }\n }\n }\n\n const waterShare = waterCount / n;\n const reach = Math.max(4, Math.max(w, h) / 5);\n let elevation = new Float32Array(n);\n for (let i = 0; i < n; i++) {\n if (water[i]) continue;\n const d = waterShare > 0.02 ? Math.min(1, dist[i]! / reach) : 1;\n elevation[i] =\n waterShare > 0.02 ? 0.1 + 0.55 * d + 0.35 * lum[i]! : 0.1 + 0.9 * lum[i]!;\n }\n // сгладить сушу: шум пикселей не должен превращаться в частокол\n for (let pass = 0; pass < 2; pass++) {\n const next = new Float32Array(n);\n for (let y = 0; y < h; y++)\n for (let x = 0; x < w; x++) {\n const i = x + y * w;\n if (water[i]) continue;\n let s = 0,\n c = 0;\n for (let dy = -1; dy <= 1; dy++)\n for (let dx = -1; dx <= 1; dx++) {\n const nx = x + dx,\n ny = y + dy;\n if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue;\n const j = nx + ny * w;\n if (water[j]) continue;\n s += elevation[j]!;\n c++;\n }\n next[i] = s / c;\n }\n elevation = next;\n }\n return { w, h, water, elevation, surface, waterShare };\n}\n\n// ---- «настроение» ------------------------------------------------------------------\n\nexport interface MoodResult {\n /** Доли клеток картинки по классам 0..1. */\n shares: {\n water: number;\n green: number;\n sand: number;\n snow: number;\n red: number;\n dark: number;\n gray: number;\n };\n /** Доминирующие цвета (k-means), по убыванию доли. */\n dominant: { r: number; g: number; b: number; share: number }[];\n luma: number;\n}\n\nexport function analyzeMood(img: RGBAImage): MoodResult {\n const G = 48;\n const rgb = resampleCover(img, G, G);\n const n = G * G;\n const shares = {\n water: 0,\n green: 0,\n sand: 0,\n snow: 0,\n red: 0,\n dark: 0,\n gray: 0,\n };\n let luma = 0;\n for (let i = 0; i < n; i++) {\n const r = rgb[i * 3]!,\n g = rgb[i * 3 + 1]!,\n b = rgb[i * 3 + 2]!;\n const [hue, s, v] = rgbToHsv(r, g, b);\n luma += v;\n if (looksLikeWater(r, g, b)) shares.water++;\n else if (v < 0.2) shares.dark++;\n else if (s < 0.12 && v > 0.85) shares.snow++;\n else if (s < 0.15) shares.gray++;\n else if (hue >= 65 && hue <= 170 && s > 0.2) shares.green++;\n else if (hue >= 30 && hue < 65 && v > 0.5) shares.sand++;\n else if (hue < 30 || hue > 330) shares.red++;\n }\n for (const k of Object.keys(shares) as (keyof typeof shares)[])\n shares[k] /= n;\n\n // k-means на 5 цветов; старт — клетки, равномерно разнесённые по яркости (детерминированно)\n const K = 5;\n const order = [...Array(n).keys()].sort(\n (a, b) =>\n rgb[a * 3]! +\n rgb[a * 3 + 1]! +\n rgb[a * 3 + 2]! -\n (rgb[b * 3]! + rgb[b * 3 + 1]! + rgb[b * 3 + 2]!),\n );\n const cent = Array.from({ length: K }, (_, k) => {\n const i = order[Math.floor(((k + 0.5) / K) * n)]!;\n return [rgb[i * 3]!, rgb[i * 3 + 1]!, rgb[i * 3 + 2]!];\n });\n const assign = new Uint8Array(n);\n for (let iter = 0; iter < 8; iter++) {\n const sum = Array.from({ length: K }, () => [0, 0, 0, 0]);\n for (let i = 0; i < n; i++) {\n let best = 0,\n bd = Infinity;\n for (let k = 0; k < K; k++) {\n const c = cent[k]!;\n const d =\n (rgb[i * 3]! - c[0]!) ** 2 +\n (rgb[i * 3 + 1]! - c[1]!) ** 2 +\n (rgb[i * 3 + 2]! - c[2]!) ** 2;\n if (d < bd) {\n bd = d;\n best = k;\n }\n }\n assign[i] = best;\n const s = sum[best]!;\n s[0]! += rgb[i * 3]!;\n s[1]! += rgb[i * 3 + 1]!;\n s[2]! += rgb[i * 3 + 2]!;\n s[3]! += 1;\n }\n sum.forEach((s, k) => {\n if (s[3]) cent[k] = [s[0]! / s[3]!, s[1]! / s[3]!, s[2]! / s[3]!];\n });\n }\n const counts = new Array<number>(K).fill(0);\n for (let i = 0; i < n; i++) counts[assign[i]!]!++;\n const dominant = cent\n .map((c, k) => ({\n r: Math.round(c[0]!),\n g: Math.round(c[1]!),\n b: Math.round(c[2]!),\n share: counts[k]! / n,\n }))\n .filter((d) => d.share > 0)\n .sort((a, b) => b.share - a.share);\n\n return { shares, dominant, luma: luma / n };\n}\n"
244
+ },
245
+ {
246
+ "path": "worldgen/loadImage.ts",
247
+ "content": "// Загрузка картинки игрока в браузере: пиксели для разбора без ИИ + сжатая копия для ИИ.\nimport type { RGBAImage } from \"./ImageAnalyzer\";\n\nexport interface LoadedImage {\n /** Пиксели для ImageAnalyzer (длинная сторона ≤ analysisMax). */\n image: RGBAImage;\n /** Копия для ИИ: JPEG, длинная сторона ≤ aiMax — меньше байт, та же картинка для модели. */\n aiDataUrl: string;\n aiBytes: number;\n aiContentType: string;\n /** Маленькое превью для экрана. */\n thumbUrl: string;\n}\n\nfunction draw(bitmap: ImageBitmap, maxSide: number): HTMLCanvasElement {\n const k = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));\n const c = document.createElement(\"canvas\");\n c.width = Math.max(1, Math.round(bitmap.width * k));\n c.height = Math.max(1, Math.round(bitmap.height * k));\n const ctx = c.getContext(\"2d\")!; // 2d-контекст у свежего canvas есть всегда\n ctx.fillStyle = \"#ffffff\"; // прозрачный PNG → белый фон (JPEG альфу не хранит)\n ctx.fillRect(0, 0, c.width, c.height);\n ctx.drawImage(bitmap, 0, 0, c.width, c.height);\n return c;\n}\n\n/** Байт в data-URL base64 (без префикса). */\nfunction dataUrlBytes(url: string): number {\n const b64 = url.slice(url.indexOf(\",\") + 1);\n return Math.floor((b64.length * 3) / 4);\n}\n\nexport async function loadImageFile(\n file: Blob,\n { analysisMax = 512, aiMax = 1568, thumbMax = 160 } = {},\n): Promise<LoadedImage> {\n const bitmap = await createImageBitmap(file);\n try {\n const a = draw(bitmap, analysisMax);\n const data = a.getContext(\"2d\")!.getImageData(0, 0, a.width, a.height).data;\n const ai = draw(bitmap, aiMax);\n const aiDataUrl = ai.toDataURL(\"image/jpeg\", 0.85);\n return {\n image: { width: a.width, height: a.height, data },\n aiDataUrl,\n aiBytes: dataUrlBytes(aiDataUrl),\n aiContentType: \"image/jpeg\",\n thumbUrl: draw(bitmap, thumbMax).toDataURL(\"image/jpeg\", 0.8),\n };\n } finally {\n bitmap.close();\n }\n}\n"
248
+ },
249
+ {
250
+ "path": "worldgen/preview.ts",
251
+ "content": "// Миникарта мира по рецепту: вид сверху до входа в мир. Считается из тех же функций, что и\n// генерация (высота, поверхность, постройки), поэтому показывает ровно тот мир, что построится.\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { CONFIG } from \"../config\";\nimport type { SpecGenerator } from \"./SpecGenerator\";\nimport type { TreeType } from \"./WorldSpec\";\n\nconst SY = CONFIG.chunk.sy;\n\nconst LEAF_OF: Record<TreeType, string> = {\n oak: \"leaves\",\n birch: \"birch_leaves\",\n spruce: \"spruce_leaves\",\n palm: \"leaves\",\n dead: \"oak_log\",\n none: \"leaves\",\n};\n\nconst hash01 = (x: number, z: number): number =>\n (((Math.imul(x, 73856093) ^ Math.imul(z, 19349663)) >>> 0) % 1000) / 1000;\n\nexport interface PreviewPixels {\n w: number;\n h: number;\n data: Uint8ClampedArray<ArrayBuffer>;\n}\n\n/** RGBA-пиксели миникарты области рецепта; длинная сторона — maxSide. */\nexport function previewPixels(\n gen: SpecGenerator,\n maxSide: number,\n): PreviewPixels {\n const scale = Math.max(gen.W, gen.D) / maxSide;\n const w = Math.max(1, Math.round(gen.W / scale)),\n h = Math.max(1, Math.round(gen.D / scale));\n const heights = new Int16Array(w * h);\n const wxOf = (px: number) =>\n Math.min(gen.W - 1, Math.floor((px + 0.5) * scale));\n const wzOf = (pz: number) =>\n Math.min(gen.D - 1, Math.floor((pz + 0.5) * scale));\n for (let pz = 0; pz < h; pz++)\n for (let px = 0; px < w; px++)\n heights[px + pz * w] = gen.heightAt(wxOf(px), wzOf(pz));\n\n const data = new Uint8ClampedArray(w * h * 4);\n const water = gen.waterLevel;\n const waterColor = BLOCKS[B.water!]!.mapColor;\n for (let pz = 0; pz < h; pz++)\n for (let px = 0; px < w; px++) {\n const i = px + pz * w;\n const wx = wxOf(px),\n wz = wzOf(pz);\n const hh = heights[i]!;\n const left = heights[Math.max(0, px - 1) + pz * w]!,\n up = heights[px + Math.max(0, pz - 1) * w]!;\n const slope =\n Math.max(Math.abs(hh - left), Math.abs(hh - up)) / Math.max(1, scale);\n const info = gen.columnInfo(wx, wz, hh, slope);\n const top = gen.stampTopAt(wx, wz);\n\n let color: number,\n y = hh,\n k = 1;\n // постройка выше земли — или срыла её верх (русло реки, пруд): видна постройка\n if (top && (top.y >= hh || top.dug <= hh)) {\n color = BLOCKS[top.id]!.mapColor;\n y = top.y;\n } else if (hh < water) {\n color = waterColor;\n k = Math.max(0.55, 1 - (water - hh) * 0.06); // глубже — темнее\n } else {\n color = BLOCKS[info.surface]!.mapColor;\n const trees = info.zone.trees;\n if (\n trees > 0 &&\n info.zone.treeType !== \"none\" &&\n hash01(wx, wz) < trees * 0.45\n )\n color = BLOCKS[B[LEAF_OF[info.zone.treeType]]!]!.mapColor;\n }\n if (!(hh < water && !top)) {\n // освещение с северо-запада + высота\n const shadeLight = Math.min(\n 1.2,\n Math.max(0.72, 1 + (hh - (left + up) / 2) * 0.08),\n );\n k *= shadeLight * (0.8 + 0.3 * (y / SY));\n }\n data[i * 4] = ((color >> 16) & 255) * k;\n data[i * 4 + 1] = ((color >> 8) & 255) * k;\n data[i * 4 + 2] = (color & 255) * k;\n data[i * 4 + 3] = 255;\n }\n return { w, h, data };\n}\n\n/** Нарисовать миникарту в canvas (размер canvas подгоняется). */\nexport function drawPreview(\n canvas: HTMLCanvasElement,\n gen: SpecGenerator,\n maxSide = 256,\n): void {\n const px = previewPixels(gen, maxSide);\n canvas.width = px.w;\n canvas.height = px.h;\n const ctx = canvas.getContext(\"2d\")!; // 2d-контекст у canvas есть всегда\n ctx.putImageData(new ImageData(px.data, px.w, px.h), 0, 0);\n // точка спавна\n const s = gen.spawnHint();\n ctx.fillStyle = \"#ff3b30\";\n ctx.fillRect((s.x / gen.W) * px.w - 2, (s.z / gen.D) * px.h - 2, 4, 4);\n}\n"
252
+ },
253
+ {
254
+ "path": "worldgen/SpecGenerator.test.ts",
255
+ "content": "import { describe, expect, it } from \"vitest\";\nimport { B } from \"../registry/Blocks\";\nimport { Chunk, CHUNK_SIZE } from \"../world/Chunk\";\nimport { SpecGenerator } from \"./SpecGenerator\";\nimport { bytesToBase64, parseWorldSpec, type WorldSpec } from \"./WorldSpec\";\nimport { previewPixels } from \"./preview\";\nimport { rasterizeModel } from \"./voxelModel\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\nfunction spec(input: Record<string, unknown>): WorldSpec {\n const r = parseWorldSpec(input);\n if (!r.ok) throw new Error(r.error);\n return r.spec;\n}\n\nfunction gen(input: Record<string, unknown>) {\n return new SpecGenerator(spec(input));\n}\n\nfunction chunkOf(g: SpecGenerator, cx: number, cz: number): Chunk {\n const c = new Chunk(cx, cz);\n g.generate(c);\n return c;\n}\n\nfunction sameBytes(a: Uint8Array, b: Uint8Array): boolean {\n return a.length === b.length && a.every((v, i) => v === b[i]);\n}\n\nfunction count(c: Chunk, id: number): number {\n let n = 0;\n for (const b of c.blocks) if (b === id) n++;\n return n;\n}\n\n/** Отпечаток блоков и меты чанка (FNV-1a). */\nfunction fingerprint(c: Chunk): number {\n let h = 2166136261 >>> 0;\n for (const arr of [c.blocks, c.meta])\n for (let i = 0; i < arr.length; i++) {\n h ^= arr[i]!;\n h = Math.imul(h, 16777619);\n }\n return h >>> 0;\n}\n\ndescribe(\"SpecGenerator (saved recipe worlds)\", () => {\n // Сейв мира по рецепту = рецепт + дифы: мир восстанавливается повторной генерацией. Любая правка\n // вывода генератора (рельеф, зоны, деревья, постройки) молча сдвигает дифы ВСЕХ сохранённых\n // миров по рецептам. Сломался тест — значит, сломалась совместимость сейвов, а не тест: либо\n // верните прежний вывод, либо заведите новую версию рецепта и сохраните старое поведение для v1.\n it(\"generates the same chunks it always did\", () => {\n const g = gen({\n seed: 2026,\n terrain: { shape: \"mountains\" },\n zones: [\n { biome: \"snow\", x: 0.5, z: 0.5, radius: 0.4 },\n { biome: \"forest\", x: 0.2, z: 0.8, radius: 0.3 },\n ],\n structures: [\n { type: \"castle\", x: 0.5, z: 0.5 },\n { type: \"village\", x: 0.25, z: 0.72 },\n { type: \"bridge\", x: 0.3, z: 0.3, x2: 0.45, z2: 0.3 },\n ],\n });\n expect([\n fingerprint(chunkOf(g, 8, 8)),\n fingerprint(chunkOf(g, 4, 11)),\n fingerprint(chunkOf(g, 12, 3)),\n fingerprint(chunkOf(g, -2, 5)),\n ]).toEqual([3901884212, 1554083939, 667641257, 2435129015]);\n });\n});\n\ndescribe(\"SpecGenerator\", () => {\n it(\"is deterministic: one recipe, one world\", () => {\n const input = {\n seed: 7,\n zones: [{ biome: \"forest\" }],\n structures: [{ type: \"village\", x: 0.5, z: 0.5 }],\n };\n const a = chunkOf(gen(input), 7, 8);\n const b = chunkOf(gen(input), 7, 8);\n expect(a.generated).toBe(true);\n expect(sameBytes(a.blocks, b.blocks)).toBe(true);\n expect(sameBytes(a.meta, b.meta)).toBe(true);\n });\n\n it(\"different seeds give different worlds\", () => {\n const a = chunkOf(gen({ seed: 1 }), 6, 6);\n const b = chunkOf(gen({ seed: 2 }), 6, 6);\n expect(sameBytes(a.blocks, b.blocks)).toBe(false);\n });\n\n it(\"an island is surrounded by sea\", () => {\n const g = gen({ seed: 3 });\n const far = chunkOf(g, -4, -4); // за пределами области рецепта\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++)\n expect(far.get(x, g.waterLevel, z)).toBe(B.water);\n });\n\n it(\"spawns the player on dry land near the centre\", () => {\n for (const shape of [\n \"island\",\n \"archipelago\",\n \"valley\",\n \"mountains\",\n \"plateau\",\n \"flat\",\n \"continent\",\n ]) {\n const g = gen({ seed: 11, terrain: { shape } });\n const s = g.spawnHint();\n expect(g.heightAt(s.x, s.z), shape).toBeGreaterThan(g.waterLevel);\n }\n });\n\n it(\"never builds above the world ceiling or below bedrock\", () => {\n const g = gen({\n seed: 5,\n terrain: { shape: \"mountains\", baseHeight: 44, amplitude: 30 },\n });\n for (let x = 0; x < 256; x += 7)\n for (let z = 0; z < 256; z += 7) {\n const h = g.heightAt(x, z);\n expect(h).toBeGreaterThanOrEqual(2);\n expect(h).toBeLessThan(SY - 11);\n }\n });\n\n it(\"stamps a castle into the chunks it covers\", () => {\n const g = gen({\n seed: 9,\n terrain: { shape: \"flat\" },\n structures: [{ type: \"castle\", x: 0.5, z: 0.5, size: \"large\" }],\n });\n // центр 256-мира — блок 128, чанк 8\n expect(count(chunkOf(g, 8, 8), B.stone_bricks!)).toBeGreaterThan(50);\n // стены по краю (±18 блоков) заходят в соседние чанки\n expect(count(chunkOf(g, 6, 8), B.stone_bricks!)).toBeGreaterThan(10);\n });\n\n it(\"follows the picture's heightmap\", () => {\n const hm = (value: number) => ({\n w: 4,\n h: 4,\n low: 10,\n high: 50,\n data: bytesToBase64(new Uint8Array(16).fill(value)),\n });\n const low = gen({ seed: 4, terrain: { shape: \"flat\", heightmap: hm(20) } });\n const high = gen({\n seed: 4,\n terrain: { shape: \"flat\", heightmap: hm(240) },\n });\n expect(high.heightAt(128, 128) - low.heightAt(128, 128)).toBeGreaterThan(\n 20,\n );\n });\n\n it(\"uses the zone's surface and trees\", () => {\n const g = gen({\n seed: 8,\n terrain: { shape: \"flat\" },\n zones: [{ biome: \"desert\", radius: 2 }],\n });\n const c = chunkOf(g, 8, 8);\n expect(count(c, B.sand!)).toBeGreaterThan(100);\n expect(count(c, B.grass!)).toBe(0);\n });\n\n it(\"places a block model on the ground, turned and lifted\", () => {\n const plane = {\n size: [20, 6, 11],\n parts: [\n {\n shape: \"cylinder\",\n axis: \"x\",\n center: [0, 2, 5],\n radius: 1.5,\n length: 20,\n block: \"white_wool\",\n },\n {\n shape: \"box\",\n from: [8, 2, 0],\n to: [11, 2, 4],\n block: \"red_wool\",\n mirror: \"z\",\n },\n ],\n };\n const onGround = gen({\n seed: 21,\n terrain: { shape: \"flat\" },\n models: { plane },\n structures: [\n { type: \"model\", model: \"plane\", x: 0.5, z: 0.5, rotation: 90 },\n ],\n });\n // центр — блок 128 (чанк 8); повёрнутый на 90° самолёт вытянут вдоль z\n const c = chunkOf(onGround, 8, 8);\n // модель целиком и ни блоком больше: сумма по четырём чанкам вокруг центра = растр модели\n const raster = rasterizeModel(spec({ models: { plane } }).models.plane!);\n const around = [\n chunkOf(onGround, 7, 7),\n chunkOf(onGround, 7, 8),\n chunkOf(onGround, 8, 7),\n c,\n ];\n for (const id of [B.white_wool!, B.red_wool!])\n expect(around.reduce((s, ch) => s + count(ch, id), 0)).toBe(\n raster.cells.filter((v) => v === id).length,\n );\n\n const flying = gen({\n seed: 21,\n terrain: { shape: \"flat\" },\n models: { plane },\n structures: [\n {\n type: \"model\",\n model: \"plane\",\n x: 0.5,\n z: 0.5,\n rotation: 90,\n elevation: 15,\n },\n ],\n });\n const lowest = (ch: Chunk) => {\n for (let y = 0; y < SY; y++)\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++)\n if (ch.get(x, y, z) === B.white_wool) return y;\n return -1;\n };\n expect(lowest(chunkOf(flying, 8, 8)) - lowest(c)).toBe(15);\n });\n\n it(\"follows the AI's terrain sketch\", () => {\n // запад — море, восток — горы\n const rows = Array.from({ length: 8 }, () => \"00449999\");\n const g = gen({\n seed: 3,\n size: { mode: \"bounded\" },\n terrain: { shape: \"continent\", sketch: { rows } },\n });\n expect(g.heightAt(24, 128)).toBeLessThan(g.waterLevel);\n expect(g.heightAt(225, 128)).toBeGreaterThan(g.waterLevel + 15);\n });\n\n it(\"carves a river along its path, flowing downhill\", () => {\n const g = gen({\n seed: 3,\n terrain: { shape: \"flat\", baseHeight: 32 },\n zones: [{ biome: \"meadow\", trees: 0 }],\n structures: [\n {\n type: \"river\",\n path: [\n [0.3, 0.5],\n [0.7, 0.5],\n ],\n size: \"large\",\n },\n ],\n });\n // русло вдоль z = 128 (чанк 8, локально z 0..4) — над уровнем моря, на суше\n expect(count(chunkOf(g, 8, 8), B.water!)).toBeGreaterThan(40);\n expect(count(chunkOf(g, 8, 10), B.water!)).toBe(0);\n });\n\n it(\"draws a preview of the same world\", () => {\n const g = gen({\n seed: 12,\n structures: [{ type: \"castle\", x: 0.5, z: 0.5 }],\n });\n const px = previewPixels(g, 64);\n expect([px.w, px.h]).toEqual([64, 64]);\n expect(px.data.length).toBe(64 * 64 * 4);\n // угол острова — море (синий канал преобладает)\n const [r, gg, b] = [px.data[0]!, px.data[1]!, px.data[2]!];\n expect(b).toBeGreaterThan(r);\n expect(b).toBeGreaterThan(gg);\n });\n});\n"
256
+ },
257
+ {
258
+ "path": "worldgen/SpecGenerator.ts",
259
+ "content": "// Генератор мира по рецепту (WorldSpec). Детерминирован целиком: один рецепт → бит-в-бит один мир,\n// поэтому сейв хранит рецепт + дифы, как классический мир хранит сид + дифы.\n//\n// Порядок для каждого чанка: рельеф (форма + шум + карта высот картинки + сдвиг зон + маска\n// острова) → слои пород и поверхность (зона, карта поверхности картинки, пляж, снег, скалы) →\n// вода → руды → растительность → штампы построек. Высота — чистая функция (x, z), поэтому\n// соседние чанки сходятся без швов, а постройки заранее «штампуются» по всей своей площади.\nimport { B } from \"../registry/Blocks\";\nimport { packWater } from \"../registry/Blocks\";\nimport { Simplex2, mulberry32, hashString } from \"../core/Noise\";\nimport { CHUNK_SIZE, idx, type Chunk } from \"../world/Chunk\";\nimport { generateOres } from \"../world/TerrainGenerator\";\nimport type { WorldGenerator } from \"./WorldGenerator\";\nimport {\n BIOME_PRESETS,\n SKETCH_OFFSETS,\n base64ToBytes,\n specBlocks,\n type Biome,\n type TreeType,\n type WorldSpec,\n type WorldZone,\n} from \"./WorldSpec\";\nimport { buildStructure, resolvePalette, type StampTarget } from \"./structures\";\nimport { plantTree, TREE_MAX_HEIGHT, TREE_MAX_RADIUS } from \"./trees\";\nimport { rasterizeModel, type RasterModel } from \"./voxelModel\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\n/** Ячейка карты поверхности «здесь вода» (в палитре картинки — имя \"water\"). */\nconst WATER_CELL = 255;\n\n/** [id, meta, soft]: soft = листва, ставится только туда, где остаётся воздух. */\ntype StampEntry = [number, number, 0 | 1];\n\nconst clamp01 = (x: number): number => Math.min(1, Math.max(0, x));\nconst smoothstep = (a: number, b: number, x: number): number => {\n const t = clamp01((x - a) / (b - a));\n return t * t * (3 - 2 * t);\n};\n/** Ключ колонны в мировых координатах (|x|, |z| < 32768 — с запасом больше любого мира). */\nconst colKey = (x: number, z: number): number =>\n (x + 0x8000) * 0x10000 + (z + 0x8000);\n\n/** Биомы без песчаного пляжа у воды. */\nconst NO_BEACH: ReadonlySet<Biome> = new Set([\n \"snow\",\n \"swamp\",\n \"rocky\",\n \"badlands\",\n]);\n/** Биомы, где вершины не заснежены. */\nconst NO_SNOW: ReadonlySet<Biome> = new Set([\n \"desert\",\n \"badlands\",\n \"beach\",\n \"savanna\",\n \"jungle\",\n]);\n\n/** Порода под поверхностью по блоку поверхности (для карты поверхности из картинки). */\nconst SUBSURFACE_OF: Record<string, string> = {\n sand: \"sand\",\n red_sand: \"terracotta\",\n sandstone: \"sandstone\",\n terracotta: \"terracotta\",\n stone: \"stone\",\n gravel: \"stone\",\n cobblestone: \"stone\",\n};\n\n/** На чём растёт порода. */\nconst GROWS_ON: Record<TreeType, readonly string[]> = {\n oak: [\"grass\", \"dirt\"],\n birch: [\"grass\", \"dirt\"],\n spruce: [\"grass\", \"dirt\", \"snow\"],\n palm: [\"sand\", \"grass\"],\n dead: [\"sand\", \"red_sand\", \"terracotta\", \"grass\", \"dirt\"],\n none: [],\n};\n\nexport interface ColumnInfo {\n surface: number;\n subsurface: number;\n zone: WorldZone;\n underwater: boolean;\n}\n\nexport class SpecGenerator implements WorldGenerator {\n readonly spec: WorldSpec;\n readonly seed: number;\n readonly waterLevel: number;\n /** Размер области рецепта в блоках. Область — [0, W) × [0, D). */\n readonly W: number;\n readonly D: number;\n\n private readonly hNoise: Simplex2;\n private readonly dNoise: Simplex2;\n private readonly ridge: Simplex2;\n private readonly warp: Simplex2;\n private readonly patch: Simplex2;\n private readonly far: Simplex2;\n private readonly zones: WorldZone[];\n private readonly hm: {\n w: number;\n h: number;\n low: number;\n high: number;\n data: Uint8Array;\n } | null;\n private readonly sm: { w: number; h: number; ids: Uint8Array } | null;\n private readonly snowLine: number;\n private readonly growsOn: Record<TreeType, Set<number>>;\n private readonly stamps = new Map<string, Map<number, StampEntry>>();\n private readonly stampedCols = new Set<number>();\n private stampTops: Map<\n number,\n { y: number; id: number; dug: number }\n > | null = null;\n private readonly spawn: { x: number; z: number };\n /** Модели объектов, растеризованные один раз (одна модель может стоять в мире много раз). */\n private readonly models: Record<string, RasterModel>;\n /** Набросок рельефа от ИИ в высотах; карта высот картинки главнее — при ней набросок не читается. */\n private readonly sketch: {\n w: number;\n h: number;\n heights: Float32Array;\n } | null;\n\n constructor(spec: WorldSpec) {\n this.spec = spec;\n this.seed = spec.seed;\n this.waterLevel = spec.terrain.waterLevel;\n const { W, D } = specBlocks(spec);\n this.W = W;\n this.D = D;\n const s = spec.seed;\n this.hNoise = new Simplex2(hashString(\"spec-height:\" + s));\n this.dNoise = new Simplex2(hashString(\"spec-detail:\" + s));\n this.ridge = new Simplex2(hashString(\"spec-ridge:\" + s));\n this.warp = new Simplex2(hashString(\"spec-warp:\" + s));\n this.patch = new Simplex2(hashString(\"spec-patch:\" + s));\n this.far = new Simplex2(hashString(\"spec-far:\" + s));\n this.zones = spec.zones.length ? spec.zones : [defaultZone(spec)];\n this.snowLine = Math.max(this.waterLevel + 16, SY - 18);\n this.growsOn = Object.fromEntries(\n Object.entries(GROWS_ON).map(([k, names]) => [\n k,\n new Set(names.map((n) => B[n]!)),\n ]),\n ) as Record<TreeType, Set<number>>;\n\n const hm = spec.terrain.heightmap;\n const hmBytes = hm ? base64ToBytes(hm.data) : null;\n this.hm =\n hm && hmBytes && hmBytes.length === hm.w * hm.h\n ? { w: hm.w, h: hm.h, low: hm.low, high: hm.high, data: hmBytes }\n : null;\n const sm = spec.terrain.surfaceMap;\n const smBytes = sm ? base64ToBytes(sm.data) : null;\n if (sm && smBytes && smBytes.length === sm.w * sm.h) {\n const lut = sm.palette.map((n) =>\n n === \"water\" ? WATER_CELL : (B[n] ?? B.grass!),\n );\n this.sm = {\n w: sm.w,\n h: sm.h,\n ids: smBytes.map((i) => lut[i] ?? B.grass!),\n };\n } else this.sm = null;\n\n const sk = spec.terrain.sketch;\n if (sk && !this.hm) {\n const h = sk.rows.length,\n w = sk.rows[0]!.length;\n const heights = new Float32Array(w * h);\n sk.rows.forEach((row, z) => {\n for (let x = 0; x < w; x++)\n heights[x + z * w] =\n this.waterLevel + (SKETCH_OFFSETS[Number(row[x])] ?? 3);\n });\n this.sketch = { w, h, heights };\n } else this.sketch = null;\n\n this.models = Object.fromEntries(\n Object.entries(spec.models).map(([id, m]) => [id, rasterizeModel(m)]),\n );\n this.buildStamps();\n this.spawn = this.findSpawn();\n }\n\n // ---- рельеф -------------------------------------------------------------------\n\n /** y верхнего блока земли. Чистая функция координат: соседние чанки сходятся без швов. */\n heightAt(wx: number, wz: number): number {\n const { W, D } = this;\n const mode = this.spec.size.mode;\n const e = Math.max(-wx, wx - (W - 1), -wz, wz - (D - 1)); // > 0 — вне области рецепта\n const sea = this.waterLevel - 7;\n let h: number;\n if (e > 0 && mode !== \"infinite\") {\n h = sea - Math.min(5, e / 6); // открытое море вокруг\n } else {\n const u = clamp01(wx / (W - 1)),\n v = clamp01(wz / (D - 1));\n h = this.terrainHeight(wx, wz, u, v);\n if (mode === \"infinite\") {\n if (e > 0) {\n // за областью рецепта мир продолжается обычной холмистой сушей\n const t = this.spec.terrain;\n const farH =\n t.baseHeight +\n this.far.fbm(wx * 0.006, wz * 0.006, 4) * Math.max(6, t.amplitude);\n h += (farH - h) * smoothstep(0, 64, e);\n }\n } else {\n h = sea + (h - sea) * smoothstep(0, 20, -e); // берег у края ограниченного мира\n }\n }\n return Math.max(2, Math.min(SY - 12, Math.round(h)));\n }\n\n private terrainHeight(wx: number, wz: number, u: number, v: number): number {\n const t = this.spec.terrain;\n const f = 0.003 + t.roughness * 0.017;\n const octaves = 3 + Math.round(t.roughness * 2);\n const n = this.hNoise.fbm(wx * f, wz * f, octaves);\n const d =\n this.dNoise.noise(wx * 0.06, wz * 0.06) * (0.4 + t.roughness * 1.4);\n const A = t.amplitude,\n base = t.baseHeight;\n let h: number;\n switch (t.shape) {\n case \"flat\":\n h = base + n * A * 0.15 + d * 0.3;\n break;\n case \"mountains\": {\n const r = 1 - Math.abs(this.ridge.fbm(wx * f * 0.7, wz * f * 0.7, 4));\n h = base - A * 0.3 + r * r * A * 1.8 + n * A * 0.25 + d;\n break;\n }\n case \"valley\": {\n // извилистое русло поперёк мира: дно ниже воды → река\n const c =\n 0.5 + 0.18 * Math.sin(v * Math.PI * 2 + (this.seed % 628) / 100);\n const dist = Math.min(1, Math.abs(u - c) * 2.2);\n h = base - A * 0.5 + A * 1.4 * Math.pow(dist, 1.5) + n * A * 0.3 + d;\n break;\n }\n case \"plateau\": {\n const raw = base + n * A * 1.2;\n const step = Math.max(3, Math.round(A / 3));\n h = Math.floor(raw / step) * step + d * 0.3;\n break;\n }\n default:\n h = base + n * A + d;\n }\n if (this.hm) h += (this.sampleHeightmap(u, v) + d * 0.4 - h) * 0.85;\n else if (this.sketch)\n h += (this.sampleSketch(u, v) + n * A * 0.2 + d * 0.6 - h) * 0.85;\n h += this.zoneOffset(u, v);\n // С наброском береговую линию рисует сам ИИ — маска острова её бы перерезала.\n if (!this.sketch && (t.shape === \"island\" || t.shape === \"archipelago\")) {\n const sea = this.waterLevel - 7;\n h = sea + (h - sea) * this.islandMask(wx, wz, u, v);\n }\n return h;\n }\n\n private islandMask(wx: number, wz: number, u: number, v: number): number {\n const ex = (u - 0.5) * 2,\n ez = (v - 0.5) * 2;\n const r =\n Math.sqrt(ex * ex + ez * ez) +\n this.warp.noise(wx * 0.012, wz * 0.012) * 0.18;\n if (this.spec.terrain.shape === \"island\")\n return 1 - smoothstep(0.6, 0.95, r);\n const blob = this.warp.fbm(wx * 0.02 + 97, wz * 0.02 - 41, 3);\n let m = smoothstep(0, 0.2, blob + 0.2 - r * 0.35);\n m = Math.max(m, 1 - smoothstep(0.08, 0.22, r)); // центральный остров — под спавн\n return m * (1 - smoothstep(0.85, 1, r));\n }\n\n private sampleHeightmap(u: number, v: number): number {\n const hm = this.hm!;\n const gx = u * (hm.w - 1),\n gz = v * (hm.h - 1);\n const x0 = Math.floor(gx),\n z0 = Math.floor(gz);\n const x1 = Math.min(hm.w - 1, x0 + 1),\n z1 = Math.min(hm.h - 1, z0 + 1);\n const fx = gx - x0,\n fz = gz - z0;\n const at = (x: number, z: number) => hm.data[x + z * hm.w]!;\n const top = at(x0, z0) + (at(x1, z0) - at(x0, z0)) * fx;\n const bot = at(x0, z1) + (at(x1, z1) - at(x0, z1)) * fx;\n const val = top + (bot - top) * fz;\n return hm.low + (val / 255) * (hm.high - hm.low);\n }\n\n /** Набросок ИИ: билинейно между центрами клеток. */\n private sampleSketch(u: number, v: number): number {\n const s = this.sketch!;\n const gx = Math.min(s.w - 1, Math.max(0, u * s.w - 0.5)),\n gz = Math.min(s.h - 1, Math.max(0, v * s.h - 0.5));\n const x0 = Math.floor(gx),\n z0 = Math.floor(gz);\n const x1 = Math.min(s.w - 1, x0 + 1),\n z1 = Math.min(s.h - 1, z0 + 1);\n const fx = gx - x0,\n fz = gz - z0;\n const at = (x: number, z: number) => s.heights[x + z * s.w]!;\n const top = at(x0, z0) + (at(x1, z0) - at(x0, z0)) * fx;\n const bot = at(x0, z1) + (at(x1, z1) - at(x0, z1)) * fx;\n return top + (bot - top) * fz;\n }\n\n private sampleSurface(u: number, v: number): number {\n const sm = this.sm!;\n const x = Math.min(sm.w - 1, Math.floor(u * sm.w)),\n z = Math.min(sm.h - 1, Math.floor(v * sm.h));\n return sm.ids[x + z * sm.w]!;\n }\n\n /** Сдвиг рельефа зон — мягко, к краю зоны сходит на нет. */\n private zoneOffset(u: number, v: number): number {\n let num = 0,\n den = 0;\n for (const z of this.zones) {\n if (!z.height) continue;\n const d = Math.hypot(u - z.x, v - z.z) / z.radius;\n if (d >= 1) continue;\n const w = (1 - d) * (1 - d);\n num += w * z.height;\n den += w;\n }\n return den > 1 ? num / den : num;\n }\n\n /** Зона точки: ближайшая с учётом радиуса (взвешенная диаграмма Вороного). */\n zoneAt(u: number, v: number): WorldZone {\n let best = this.zones[0]!,\n bestScore = Infinity;\n for (const z of this.zones) {\n const s = Math.hypot(u - z.x, v - z.z) / z.radius;\n if (s < bestScore) {\n bestScore = s;\n best = z;\n }\n }\n return best;\n }\n\n zoneAtWorld(wx: number, wz: number): WorldZone {\n return this.zoneAt(wx / (this.W - 1), wz / (this.D - 1));\n }\n\n /** Поверхность колонны: зона → карта картинки → пляж / снег / скалы. */\n columnInfo(wx: number, wz: number, h: number, slope: number): ColumnInfo {\n const u = wx / (this.W - 1),\n v = wz / (this.D - 1);\n const zone = this.zoneAt(u, v);\n const water = this.waterLevel;\n let surface = B[zone.surface] ?? B.grass!;\n let subsurface = B[zone.subsurface] ?? B.dirt!;\n\n if (h < water - 1) {\n // дно\n const n = this.patch.noise(wx * 0.08, wz * 0.08);\n surface =\n zone.biome === \"swamp\"\n ? B.clay!\n : n > 0.35\n ? B.gravel!\n : n < -0.4\n ? B.clay!\n : B.sand!;\n subsurface = surface === B.clay ? B.clay! : B.sand!;\n return { surface, subsurface, zone, underwater: true };\n }\n\n const inCore = u >= 0 && u <= 1 && v >= 0 && v <= 1;\n const mapped = inCore && this.sm ? this.sampleSurface(u, v) : -1;\n if (mapped === WATER_CELL) {\n surface = subsurface = B.sand!;\n } else if (mapped >= 0) {\n surface = mapped;\n subsurface = B[SUBSURFACE_OF[nameOf(mapped)] ?? \"dirt\"]!;\n } else if (h <= water + 1 && !NO_BEACH.has(zone.biome)) {\n surface = subsurface = B.sand!;\n } else if (h >= this.snowLine && !NO_SNOW.has(zone.biome)) {\n surface = B.snow!;\n } else if (slope >= 3 && (surface === B.grass || surface === B.snow)) {\n surface = B.stone!; // обрывы — голый камень\n subsurface = B.stone!;\n }\n return { surface, subsurface, zone, underwater: h < water };\n }\n\n // ---- чанк ---------------------------------------------------------------------\n\n generate(chunk: Chunk): void {\n const water = this.waterLevel;\n const rng = mulberry32(\n hashString(`spec:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n\n // высоты с каймой в блок — для крутизны склона на краях чанка\n const G = SX + 2;\n const grid = new Int16Array(G * (SZ + 2));\n for (let z = -1; z <= SZ; z++)\n for (let x = -1; x <= SX; x++)\n grid[x + 1 + (z + 1) * G] = this.heightAt(wx0 + x, wz0 + z);\n\n const heights = new Int16Array(SX * SZ);\n const surfaces = new Uint8Array(SX * SZ);\n const zoneOf: WorldZone[] = new Array<WorldZone>(SX * SZ);\n const iceTop = B.ice!,\n waterMeta = packWater(8, true);\n\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const at = (dx: number, dz: number) =>\n grid[x + 1 + dx + (z + 1 + dz) * G]!;\n const h = at(0, 0);\n const slope = Math.max(\n Math.abs(h - at(1, 0)),\n Math.abs(h - at(-1, 0)),\n Math.abs(h - at(0, 1)),\n Math.abs(h - at(0, -1)),\n );\n const info = this.columnInfo(wx0 + x, wz0 + z, h, slope);\n for (let y = 0; y <= h; y++) {\n let id: number;\n if (y === 0) id = B.bedrock!;\n else if (y === 1 && rng() < 0.5) id = B.bedrock!;\n else if (y === h) id = info.surface;\n else if (y >= h - 3) id = info.subsurface;\n else id = B.stone!;\n chunk.setRaw(x, y, z, id);\n }\n const frozen = info.zone.biome === \"snow\";\n for (let y = h + 1; y <= water; y++) {\n if (frozen && y === water) chunk.setRaw(x, y, z, iceTop);\n else chunk.setRaw(x, y, z, B.water!, waterMeta);\n }\n const i = x + z * SX;\n heights[i] = h;\n surfaces[i] = info.surface;\n zoneOf[i] = info.zone;\n }\n\n generateOres(chunk, rng);\n this.vegetation(chunk, rng, heights, surfaces, zoneOf);\n this.applyStamps(chunk);\n chunk.generated = true;\n }\n\n private vegetation(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n surfaces: Uint8Array,\n zoneOf: WorldZone[],\n ): void {\n const water = this.waterLevel;\n const m = TREE_MAX_RADIUS; // крона не выходит за чанк\n for (let t = 0; t < 10; t++) {\n const x = m + ((rng() * (SX - 2 * m)) | 0),\n z = m + ((rng() * (SZ - 2 * m)) | 0);\n const i = x + z * SX;\n const zone = zoneOf[i]!,\n h = heights[i]!;\n if (rng() > zone.trees * 0.6) continue;\n if (h <= water || h + TREE_MAX_HEIGHT >= SY) continue;\n if (!this.growsOn[zone.treeType].has(surfaces[i]!)) continue;\n if (chunk.get(x, h + 1, z) !== B.air) continue;\n plantTree(zone.treeType, x, h, z, rng, (tx, ty, tz, id, soft) => {\n if (ty >= SY || tx < 0 || tx >= SX || tz < 0 || tz >= SZ) return;\n if (soft && chunk.get(tx, ty, tz) !== B.air) return;\n chunk.setRaw(tx, ty, tz, id);\n });\n }\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const i = x + z * SX;\n const h = heights[i]!,\n zone = zoneOf[i]!;\n if (surfaces[i] !== B.grass || h <= water || h + 1 >= SY) continue;\n if (chunk.get(x, h + 1, z) !== B.air) continue;\n const r = rng();\n const g = zone.grass * 0.25;\n if (r < g) chunk.setRaw(x, h + 1, z, B.tallgrass!);\n else if (r < g + zone.flowers * 0.2)\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.flower_yellow! : B.flower_red!,\n );\n }\n }\n\n // ---- постройки (штампы) --------------------------------------------------------\n\n private buildStamps(): void {\n const pal = resolvePalette(this.spec.palette);\n const target: StampTarget = {\n groundAt: (x, z) => this.heightAt(x, z),\n surfaceAt: (x, z) =>\n this.columnInfo(x, z, this.heightAt(x, z), 0).surface,\n waterLevel: this.waterLevel,\n treeTypeAt: (x, z) => this.zoneAtWorld(x, z).treeType,\n put: (x, y, z, id, meta = 0) => this.stamp(x, y, z, id, meta, 0),\n putSoft: (x, y, z, id) => this.stamp(x, y, z, id, 0, 1),\n };\n const W1 = this.W - 1,\n D1 = this.D - 1;\n this.spec.structures.forEach((s, i) => {\n const rng = mulberry32(hashString(`struct:${this.seed}:${i}`));\n buildStructure(\n s,\n Math.round(s.x * W1),\n Math.round(s.z * D1),\n Math.round((s.x2 ?? s.x) * W1),\n Math.round((s.z2 ?? s.z) * D1),\n pal,\n target,\n rng,\n this.models,\n s.path?.map(([x, z]): [number, number] => [\n Math.round(x * W1),\n Math.round(z * D1),\n ]),\n );\n });\n }\n\n private stamp(\n x: number,\n y: number,\n z: number,\n id: number,\n meta: number,\n soft: 0 | 1,\n ): void {\n if (y < 1 || y >= SY) return;\n const cx = Math.floor(x / SX),\n cz = Math.floor(z / SZ);\n const key = cx + \",\" + cz;\n let m = this.stamps.get(key);\n if (!m) this.stamps.set(key, (m = new Map()));\n const i = idx(x - cx * SX, y, z - cz * SZ);\n if (soft) {\n const prev = m.get(i);\n if (prev && !prev[2] && prev[0] !== B.air) return; // листва не затирает стены\n }\n m.set(i, [id, meta, soft]);\n if (id !== B.air) this.stampedCols.add(colKey(x, z));\n }\n\n private applyStamps(chunk: Chunk): void {\n const m = this.stamps.get(chunk.cx + \",\" + chunk.cz);\n if (!m) return;\n for (const [i, [id, meta, soft]] of m) {\n if (soft && chunk.blocks[i] !== B.air) continue;\n chunk.blocks[i] = id;\n chunk.meta[i] = meta;\n }\n }\n\n /**\n * Верх построек в колонне — для миникарты: верхний непустой блок и нижний вырезанный (air).\n * Вырез ниже поверхности значит, что постройка срыла верх (русло, пруд) — тогда видна она, а не\n * земля над ней.\n */\n stampTopAt(\n wx: number,\n wz: number,\n ): { y: number; id: number; dug: number } | undefined {\n if (!this.stampTops) {\n const tops = new Map<number, { y: number; id: number; dug: number }>();\n for (const [key, m] of this.stamps) {\n const [cx, cz] = key.split(\",\").map(Number) as [number, number];\n for (const [i, [id, , soft]] of m) {\n const x = cx * SX + (i & 15),\n z = cz * SZ + ((i >> 4) & 15),\n y = i >> 8;\n const k = colKey(x, z);\n let t = tops.get(k);\n if (!t) tops.set(k, (t = { y: -1, id: B.air!, dug: Infinity }));\n if (id === B.air) {\n if (!soft) t.dug = Math.min(t.dug, y);\n } else if (y > t.y) {\n t.y = y;\n t.id = id;\n }\n }\n }\n for (const [k, t] of tops) if (t.y < 0) tops.delete(k); // одни вырезы без блоков — не постройка\n this.stampTops = tops;\n }\n return this.stampTops.get(colKey(wx, wz));\n }\n\n // ---- спавн ----------------------------------------------------------------------\n\n /** Суша ближе всего к центру, с площадкой 5×5 без построек: не внутри дома и не вплотную к стене. */\n private findSpawn(): { x: number; z: number } {\n const cx = Math.floor(this.W / 2),\n cz = Math.floor(this.D / 2);\n const clear = (x: number, z: number): boolean => {\n for (let dz = -2; dz <= 2; dz++)\n for (let dx = -2; dx <= 2; dx++)\n if (this.stampedCols.has(colKey(x + dx, z + dz))) return false;\n return true;\n };\n const maxR = Math.max(this.W, this.D) / 2;\n for (let r = 0; r <= maxR; r += 4) {\n const steps = r === 0 ? 1 : Math.ceil((2 * Math.PI * r) / 4);\n for (let k = 0; k < steps; k++) {\n const a = (k / steps) * Math.PI * 2;\n const x = Math.round(cx + Math.cos(a) * r),\n z = Math.round(cz + Math.sin(a) * r);\n if (this.heightAt(x, z) > this.waterLevel + 1 && clear(x, z))\n return { x, z };\n }\n }\n return { x: cx, z: cz };\n }\n\n spawnHint(): { x: number; z: number } {\n return this.spawn;\n }\n}\n\nfunction defaultZone(spec: WorldSpec): WorldZone {\n const biome: Biome = spec.terrain.shape === \"mountains\" ? \"taiga\" : \"meadow\";\n return { x: 0.5, z: 0.5, radius: 2, biome, ...BIOME_PRESETS[biome] };\n}\n\nconst NAMES: string[] = [];\nfunction nameOf(id: number): string {\n if (!NAMES.length) for (const [n, i] of Object.entries(B)) NAMES[i] = n;\n return NAMES[id] ?? \"\";\n}\n"
260
+ },
261
+ {
262
+ "path": "worldgen/structures.ts",
263
+ "content": "// Строители построек рецепта мира. Каждый пишет блоки через StampTarget в «штамп» — разреженную\n// карту поверх рельефа, которую SpecGenerator накладывает на чанки при их генерации. Постройка\n// поэтому может пересекать границы чанков, а генерация чанков остаётся независимой.\n//\n// Постройки стоят на рельефе: groundAt() — высота земли (без других построек). Под полом —\n// фундамент до земли, над полом — расчистка (деревья и бугры не прорастают сквозь дом).\nimport { B, packWater } from \"../registry/Blocks\";\nimport { CONFIG } from \"../config\";\nimport type {\n StructureSize,\n TreeType,\n WorldPalette,\n WorldStructure,\n} from \"./WorldSpec\";\nimport { plantTree } from \"./trees\";\nimport type { RasterModel } from \"./voxelModel\";\n\nconst SY = CONFIG.chunk.sy;\n\nexport interface StampTarget {\n /** y верхнего блока земли в мировых координатах (рельеф рецепта, без построек). */\n groundAt(wx: number, wz: number): number;\n /** Блок поверхности рельефа в этой точке — чтобы перекрыть срезанный под постройку бугор. */\n surfaceAt(wx: number, wz: number): number;\n readonly waterLevel: number;\n /** Порода деревьев в этой точке (зона рецепта). */\n treeTypeAt(wx: number, wz: number): TreeType;\n /** Жёсткая запись: перекрывает рельеф и более ранние постройки. */\n put(wx: number, wy: number, wz: number, id: number, meta?: number): void;\n /** Мягкая (листва): только туда, где в итоге окажется воздух. */\n putSoft(wx: number, wy: number, wz: number, id: number): void;\n}\n\nexport interface ResolvedPalette {\n wall: number;\n roof: number;\n floor: number;\n path: number;\n accent: number;\n}\n\nexport function resolvePalette(p: WorldPalette): ResolvedPalette {\n const id = (name: string, def: string): number => B[name] ?? B[def]!;\n return {\n wall: id(p.wall, \"planks\"),\n roof: id(p.roof, \"dark_planks\"),\n floor: id(p.floor, \"planks\"),\n path: id(p.path, \"dirt_path\"),\n accent: id(p.accent, \"stone_bricks\"),\n };\n}\n\nconst SIZE_INDEX: Record<StructureSize, 0 | 1 | 2> = {\n small: 0,\n medium: 1,\n large: 2,\n};\nconst bySize = <T>(size: StructureSize, values: readonly [T, T, T]): T =>\n values[SIZE_INDEX[size]];\n\n/** Сторона двери по повороту: 0 → -z, 90 → +x, 180 → +z, 270 → -x. */\nfunction doorDir(rotation: number): [number, number] {\n switch (rotation) {\n case 90:\n return [1, 0];\n case 180:\n return [0, 1];\n case 270:\n return [-1, 0];\n default:\n return [0, -1];\n }\n}\n\n/** Поворот, при котором дверь смотрит в сторону (dx, dz). */\nfunction rotationFacing(dx: number, dz: number): 0 | 90 | 180 | 270 {\n if (Math.abs(dz) >= Math.abs(dx)) return dz < 0 ? 0 : 180;\n return dx > 0 ? 90 : 270;\n}\n\nclass Builder {\n constructor(\n readonly t: StampTarget,\n readonly pal: ResolvedPalette,\n readonly rng: () => number,\n ) {}\n\n put(x: number, y: number, z: number, id: number, meta = 0): void {\n if (y < 1 || y >= SY) return;\n this.t.put(x, y, z, id, meta);\n }\n\n box(\n x0: number,\n y0: number,\n z0: number,\n x1: number,\n y1: number,\n z1: number,\n id: number,\n ): void {\n for (let y = y0; y <= y1; y++)\n for (let z = z0; z <= z1; z++)\n for (let x = x0; x <= x1; x++) this.put(x, y, z, id);\n }\n\n /** Воздух над площадкой: убрать деревья, бугры и склон, в который врезается постройка. */\n clear(\n x0: number,\n z0: number,\n x1: number,\n z1: number,\n fromY: number,\n toY: number,\n ): void {\n for (let z = z0; z <= z1; z++)\n for (let x = x0; x <= x1; x++) {\n const g = this.t.groundAt(x, z);\n const top = Math.max(toY, g + 1);\n for (let y = fromY; y <= Math.min(top, SY - 1); y++)\n this.put(x, y, z, B.air!);\n // Срезали бугор — на новой поверхности та же порода, что вокруг, а не голая земля из-под дёрна.\n if (g >= fromY && fromY > 1)\n this.put(x, fromY - 1, z, this.t.surfaceAt(x, z));\n }\n }\n\n /** Пол на floorY и фундамент от земли до пола — постройка не висит над склоном. */\n foundation(\n x0: number,\n z0: number,\n x1: number,\n z1: number,\n floorY: number,\n floorId: number,\n ): void {\n for (let z = z0; z <= z1; z++)\n for (let x = x0; x <= x1; x++) {\n const g = this.t.groundAt(x, z);\n for (let y = Math.min(g + 1, floorY); y < floorY; y++)\n this.put(x, y, z, this.pal.accent);\n this.put(x, floorY, z, floorId);\n }\n }\n\n /** Отрезок по земле: колбэк на каждой колонне полосы шириной width. */\n line(\n ax: number,\n az: number,\n bx: number,\n bz: number,\n width: number,\n fn: (x: number, z: number, step: number, offset: number) => void,\n ): void {\n const dx = bx - ax,\n dz = bz - az;\n const steps = Math.max(1, Math.round(Math.max(Math.abs(dx), Math.abs(dz))));\n // поперечное смещение — по второстепенной оси отрезка\n const alongX = Math.abs(dx) >= Math.abs(dz);\n const lo = -Math.floor((width - 1) / 2),\n hi = lo + width - 1;\n for (let i = 0; i <= steps; i++) {\n const x = Math.round(ax + (dx * i) / steps),\n z = Math.round(az + (dz * i) / steps);\n for (let o = lo; o <= hi; o++)\n fn(alongX ? x : x + o, alongX ? z + o : z, i, o);\n }\n }\n}\n\n// ---- постройки ------------------------------------------------------------------\n\nfunction house(\n b: Builder,\n cx: number,\n cz: number,\n size: StructureSize,\n rot: number,\n wall: number,\n roof: number,\n): void {\n const [w0, d0] = bySize(size, [\n [7, 6],\n [9, 7],\n [11, 9],\n ] as const);\n const [w, d] = rot % 180 ? [d0, w0] : [w0, d0];\n const x0 = cx - (w >> 1),\n z0 = cz - (d >> 1),\n x1 = x0 + w - 1,\n z1 = z0 + d - 1;\n const floorY = Math.max(b.t.groundAt(cx, cz), b.t.waterLevel);\n const wallH = bySize(size, [3, 4, 4] as const);\n const roofLayers = Math.ceil(Math.min(w, d) / 2) + 1;\n\n b.clear(\n x0 - 1,\n z0 - 1,\n x1 + 1,\n z1 + 1,\n floorY + 1,\n floorY + wallH + roofLayers + 1,\n );\n b.foundation(x0, z0, x1, z1, floorY, b.pal.floor);\n\n // стены: брёвна по углам, окна через блок на уровне глаз\n for (let y = floorY + 1; y <= floorY + wallH; y++)\n for (let z = z0; z <= z1; z++)\n for (let x = x0; x <= x1; x++) {\n const edgeX = x === x0 || x === x1,\n edgeZ = z === z0 || z === z1;\n if (!edgeX && !edgeZ) continue;\n const corner = edgeX && edgeZ;\n const along = edgeZ ? x - x0 : z - z0;\n const window = !corner && y === floorY + 2 && along % 3 === 1;\n b.put(x, y, z, corner ? B.oak_log! : window ? B.glass! : wall);\n }\n\n // дверь посередине стены со стороны поворота\n const [ddx, ddz] = doorDir(rot);\n const doorX = ddx === 0 ? cx : ddx > 0 ? x1 : x0;\n const doorZ = ddz === 0 ? cz : ddz > 0 ? z1 : z0;\n b.put(doorX, floorY + 1, doorZ, B.air!);\n b.put(doorX, floorY + 2, doorZ, B.air!);\n\n // ступенчатая крыша с выносом в блок\n for (let k = 0; k < roofLayers; k++) {\n const xs = x0 - 1 + k,\n xe = x1 + 1 - k,\n zs = z0 - 1 + k,\n ze = z1 + 1 - k;\n if (xs > xe || zs > ze) break;\n b.box(xs, floorY + wallH + 1 + k, zs, xe, floorY + wallH + 1 + k, ze, roof);\n }\n // верстак в дальнем углу\n b.put(\n ddx > 0 ? x0 + 1 : x1 - 1,\n floorY + 1,\n ddz > 0 ? z0 + 1 : z1 - 1,\n B.crafting_table!,\n );\n}\n\nfunction tower(\n b: Builder,\n cx: number,\n cz: number,\n r: number,\n height: number,\n wall: number,\n): void {\n const g = Math.max(b.t.groundAt(cx, cz), b.t.waterLevel);\n const top = Math.min(SY - 3, g + height);\n const inDisc = (dx: number, dz: number, rr: number) =>\n dx * dx + dz * dz <= (rr + 0.5) * (rr + 0.5);\n\n b.clear(cx - r - 1, cz - r - 1, cx + r + 1, cz + r + 1, g + 1, top + 2);\n for (let dz = -r; dz <= r; dz++)\n for (let dx = -r; dx <= r; dx++) {\n if (!inDisc(dx, dz, r)) continue;\n const x = cx + dx,\n z = cz + dz;\n const ring = !inDisc(dx, dz, r - 1);\n const gc = b.t.groundAt(x, z);\n for (let y = Math.min(gc + 1, g); y <= g; y++) b.put(x, y, z, wall);\n for (let y = g + 1; y <= top; y++) {\n if (ring) {\n const window = (y - g) % 5 === 3 && (dx === 0 || dz === 0);\n b.put(x, y, z, window ? B.glass! : wall);\n } else if ((y - g) % 5 === 0) b.put(x, y, z, b.pal.floor);\n }\n if (ring && (dx + dz) % 2 === 0) b.put(x, top + 1, z, wall); // зубцы\n if (!ring) b.put(x, top, z, b.pal.floor);\n }\n // вход\n b.put(cx, g + 1, cz - r, B.air!);\n b.put(cx, g + 2, cz - r, B.air!);\n}\n\nfunction castle(\n b: Builder,\n cx: number,\n cz: number,\n size: StructureSize,\n rot: number,\n wall: number,\n): void {\n const L = bySize(size, [21, 29, 37] as const);\n const half = L >> 1;\n const wallH = bySize(size, [5, 6, 7] as const);\n const g = Math.max(b.t.groundAt(cx, cz), b.t.waterLevel);\n const x0 = cx - half,\n x1 = cx + half,\n z0 = cz - half,\n z1 = cz + half;\n\n b.clear(x0, z0, x1, z1, g + 1, g + wallH + 10); // двор без деревьев\n // стены по периметру идут по рельефу\n for (let z = z0; z <= z1; z++)\n for (let x = x0; x <= x1; x++) {\n if (x !== x0 && x !== x1 && z !== z0 && z !== z1) continue;\n const gb = b.t.groundAt(x, z);\n const topY = Math.max(g + wallH, gb + 2);\n for (let y = gb + 1; y <= topY; y++) b.put(x, y, z, wall);\n if ((x + z) % 2 === 0) b.put(x, topY + 1, z, wall);\n }\n // ворота со стороны поворота\n const [ddx, ddz] = doorDir(rot);\n for (let o = -1; o <= 1; o++) {\n const gx = ddx === 0 ? cx + o : ddx > 0 ? x1 : x0;\n const gz = ddz === 0 ? cz + o : ddz > 0 ? z1 : z0;\n const gb = b.t.groundAt(gx, gz);\n for (let y = gb + 1; y <= gb + 4; y++) b.put(gx, y, gz, B.air!);\n }\n // угловые башни\n for (const [tx, tz] of [\n [x0, z0],\n [x1, z0],\n [x0, z1],\n [x1, z1],\n ] as const)\n tower(b, tx, tz, 3, wallH + 5, wall);\n // донжон\n const k = bySize(size, [3, 4, 5] as const);\n b.foundation(cx - k, cz - k, cx + k, cz + k, g, b.pal.floor);\n const keepTop = g + wallH + 4;\n for (let y = g + 1; y <= keepTop; y++)\n for (let z = cz - k; z <= cz + k; z++)\n for (let x = cx - k; x <= cx + k; x++) {\n const edge =\n x === cx - k || x === cx + k || z === cz - k || z === cz + k;\n if (!edge) continue;\n const window = (y - g) % 4 === 2 && (x === cx || z === cz);\n b.put(x, y, z, window ? B.glass! : wall);\n }\n b.box(cx - k, keepTop + 1, cz - k, cx + k, keepTop + 1, cz + k, b.pal.floor);\n for (let z = cz - k; z <= cz + k; z++)\n for (let x = cx - k; x <= cx + k; x++)\n if (\n (x === cx - k || x === cx + k || z === cz - k || z === cz + k) &&\n (x + z) % 2 === 0\n )\n b.put(x, keepTop + 2, z, wall);\n // вход в донжон — к воротам\n const kx = ddx === 0 ? cx : cx + ddx * k,\n kz = ddz === 0 ? cz : cz + ddz * k;\n b.put(kx, g + 1, kz, B.air!);\n b.put(kx, g + 2, kz, B.air!);\n}\n\nfunction road(\n b: Builder,\n ax: number,\n az: number,\n bx: number,\n bz: number,\n width: number,\n): void {\n b.line(ax, az, bx, bz, width, (x, z) => {\n const g = b.t.groundAt(x, z);\n if (g < b.t.waterLevel) {\n // дорога через воду становится настилом\n b.put(x, b.t.waterLevel, z, b.pal.floor);\n for (let y = b.t.waterLevel + 1; y <= b.t.waterLevel + 3; y++)\n b.put(x, y, z, B.air!);\n return;\n }\n b.put(x, g, z, b.pal.path);\n for (let y = g + 1; y <= g + 4; y++) b.put(x, y, z, B.air!);\n });\n}\n\nfunction bridge(\n b: Builder,\n ax: number,\n az: number,\n bx: number,\n bz: number,\n): void {\n const deckY = Math.max(\n b.t.waterLevel + 2,\n Math.min(b.t.groundAt(ax, az), b.t.groundAt(bx, bz)) + 1,\n );\n b.line(ax, az, bx, bz, 5, (x, z, step, o) => {\n for (let y = deckY + 1; y <= deckY + 4; y++) b.put(x, y, z, B.air!);\n if (Math.abs(o) <= 1) {\n b.put(x, deckY, z, b.pal.floor);\n // опоры каждые 6 шагов\n if (o !== 0 && step % 6 === 0)\n for (let y = b.t.groundAt(x, z) + 1; y < deckY; y++)\n b.put(x, y, z, B.oak_log!);\n } else {\n b.put(x, deckY, z, b.pal.floor);\n b.put(x, deckY + 1, z, step % 4 === 0 ? B.oak_log! : B.dark_planks!); // перила\n }\n });\n}\n\nfunction wallLine(\n b: Builder,\n ax: number,\n az: number,\n bx: number,\n bz: number,\n size: StructureSize,\n wall: number,\n): void {\n const thick = bySize(size, [1, 2, 2] as const);\n const height = bySize(size, [3, 4, 6] as const);\n b.line(ax, az, bx, bz, thick, (x, z, step) => {\n const g = b.t.groundAt(x, z);\n for (let y = g + 1; y <= g + height; y++) b.put(x, y, z, wall);\n if (step % 2 === 0) b.put(x, g + height + 1, z, wall);\n });\n}\n\nfunction ruins(b: Builder, cx: number, cz: number, size: StructureSize): void {\n const side = bySize(size, [9, 13, 17] as const);\n const half = side >> 1;\n const maxH = bySize(size, [3, 4, 6] as const);\n const stone = () => (b.rng() < 0.4 ? B.mossy_cobblestone! : B.cobblestone!);\n for (let z = cz - half; z <= cz + half; z++)\n for (let x = cx - half; x <= cx + half; x++) {\n const edge =\n x === cx - half ||\n x === cx + half ||\n z === cz - half ||\n z === cz + half;\n const pillar = (x - cx) % 4 === 0 && (z - cz) % 4 === 0;\n const g = b.t.groundAt(x, z);\n if (edge || pillar) {\n if (b.rng() < 0.25) continue; // обвалившийся кусок\n const h = 1 + ((b.rng() * maxH) | 0);\n for (let y = g + 1; y <= g + h; y++) b.put(x, y, z, stone());\n } else if (b.rng() < 0.05)\n b.put(x, g + 1, z, stone()); // обломки\n else b.put(x, g, z, b.rng() < 0.5 ? B.gravel! : stone()); // старый пол\n }\n}\n\nfunction pond(b: Builder, cx: number, cz: number, size: StructureSize): void {\n const R = bySize(size, [3, 5, 8] as const);\n const stretch = 0.75 + b.rng() * 0.5;\n const rx = R * stretch,\n rz = R / stretch;\n // уровень воды — по самой низкой точке берега, иначе вода «висела» бы над склоном\n let surface = b.t.groundAt(cx, cz);\n for (let a = 0; a < 16; a++) {\n const ang = (a / 16) * Math.PI * 2;\n surface = Math.min(\n surface,\n b.t.groundAt(\n Math.round(cx + Math.cos(ang) * (rx + 1)),\n Math.round(cz + Math.sin(ang) * (rz + 1)),\n ),\n );\n }\n const maxDepth = bySize(size, [2, 3, 4] as const);\n for (let z = Math.floor(cz - rz); z <= Math.ceil(cz + rz); z++)\n for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx); x++) {\n const d = Math.hypot((x - cx) / rx, (z - cz) / rz);\n if (d > 1) continue;\n const depth = 1 + Math.round((1 - d) * (maxDepth - 1));\n const floorY = surface - depth;\n const g = b.t.groundAt(x, z);\n b.put(x, floorY, z, d > 0.7 ? B.sand! : B.clay!);\n for (let y = floorY + 1; y <= surface; y++)\n b.put(x, y, z, B.water!, packWater(8, true));\n for (let y = surface + 1; y <= Math.max(g, surface) + 6; y++)\n b.put(x, y, z, B.air!);\n }\n}\n\nfunction rock(b: Builder, cx: number, cz: number, size: StructureSize): void {\n const R = bySize(size, [2, 3, 5] as const);\n const g = b.t.groundAt(cx, cz);\n for (let dy = -1; dy <= R; dy++)\n for (let dz = -R; dz <= R; dz++)\n for (let dx = -R; dx <= R; dx++) {\n const d = Math.hypot(dx / R, dy / (R * 0.8), dz / R);\n if (d > 1 - b.rng() * 0.15) continue;\n const r = b.rng();\n b.put(\n cx + dx,\n g + dy,\n cz + dz,\n r < 0.55\n ? B.stone!\n : r < 0.85\n ? B.cobblestone!\n : B.mossy_cobblestone!,\n );\n }\n}\n\nfunction treeCluster(\n b: Builder,\n cx: number,\n cz: number,\n size: StructureSize,\n): void {\n const n = bySize(size, [5, 9, 15] as const);\n const radius = bySize(size, [5, 8, 12] as const);\n for (let i = 0; i < n; i++) {\n const ang = b.rng() * Math.PI * 2,\n r = Math.sqrt(b.rng()) * radius;\n const x = Math.round(cx + Math.cos(ang) * r),\n z = Math.round(cz + Math.sin(ang) * r);\n const g = b.t.groundAt(x, z);\n if (g <= b.t.waterLevel || g + 12 >= SY) continue;\n const zoneType = b.t.treeTypeAt(x, z);\n const type: TreeType = zoneType === \"none\" ? \"oak\" : zoneType;\n plantTree(type, x, g, z, b.rng, (tx, ty, tz, id, soft) => {\n if (ty < 1 || ty >= SY) return;\n if (soft) b.t.putSoft(tx, ty, tz, id);\n else b.t.put(tx, ty, tz, id);\n });\n }\n}\n\nfunction pyramid(\n b: Builder,\n cx: number,\n cz: number,\n size: StructureSize,\n rot: number,\n stone: number,\n): void {\n const side = bySize(size, [11, 17, 25] as const);\n const half = side >> 1;\n const g = b.t.groundAt(cx, cz);\n b.clear(cx - half, cz - half, cx + half, cz + half, g + 1, g + half + 2);\n b.foundation(cx - half, cz - half, cx + half, cz + half, g, stone);\n for (let k = 0; k <= half; k++) {\n const y = g + 1 + k;\n if (y >= SY) break;\n b.box(\n cx - half + k,\n y,\n cz - half + k,\n cx + half - k,\n y,\n cz + half - k,\n stone,\n );\n }\n // вход и камера внутри\n const [ddx, ddz] = doorDir(rot);\n for (let s = 0; s <= half; s++) {\n const x = cx + ddx * (half - s),\n z = cz + ddz * (half - s);\n b.put(x, g + 1, z, B.air!);\n b.put(x, g + 2, z, B.air!);\n }\n b.box(cx - 1, g + 1, cz - 1, cx + 1, g + 3, cz + 1, B.air!);\n}\n\nfunction well(b: Builder, cx: number, cz: number): void {\n const g = b.t.groundAt(cx, cz);\n b.clear(cx - 2, cz - 2, cx + 2, cz + 2, g + 1, g + 5);\n for (let dz = -2; dz <= 2; dz++)\n for (let dx = -2; dx <= 2; dx++) {\n const x = cx + dx,\n z = cz + dz;\n const inner = Math.abs(dx) <= 1 && Math.abs(dz) <= 1;\n if (inner) {\n b.put(x, g - 2, z, B.cobblestone!);\n b.put(x, g - 1, z, B.water!, packWater(8, true));\n b.put(x, g, z, B.water!, packWater(8, true));\n } else {\n b.put(x, g, z, B.cobblestone!);\n b.put(x, g + 1, z, B.cobblestone!);\n }\n if (Math.abs(dx) === 2 && Math.abs(dz) === 2) {\n b.put(x, g + 2, z, B.oak_log!);\n b.put(x, g + 3, z, B.oak_log!);\n }\n b.put(x, g + 4, z, b.pal.roof);\n }\n}\n\nfunction farm(\n b: Builder,\n cx: number,\n cz: number,\n size: StructureSize,\n rot: number,\n): void {\n const [w0, d0] = bySize(size, [\n [7, 7],\n [9, 9],\n [13, 9],\n ] as const);\n const [w, d] = rot % 180 ? [d0, w0] : [w0, d0];\n const x0 = cx - (w >> 1),\n z0 = cz - (d >> 1),\n x1 = x0 + w - 1,\n z1 = z0 + d - 1;\n const g = Math.max(b.t.groundAt(cx, cz), b.t.waterLevel + 1);\n b.clear(x0, z0, x1, z1, g + 1, g + 4);\n const [ddx, ddz] = doorDir(rot);\n for (let z = z0; z <= z1; z++)\n for (let x = x0; x <= x1; x++) {\n const gc = b.t.groundAt(x, z);\n for (let y = Math.min(gc + 1, g); y < g; y++) b.put(x, y, z, B.dirt!);\n const edge = x === x0 || x === x1 || z === z0 || z === z1;\n if (edge) {\n b.put(x, g, z, B.dirt!);\n const gate =\n (ddx === 0 && x === cx && z === (ddz > 0 ? z1 : z0)) ||\n (ddz === 0 && z === cz && x === (ddx > 0 ? x1 : x0));\n if (!gate) b.put(x, g + 1, z, B.oak_log!);\n continue;\n }\n const row = (rot % 180 ? x - x0 : z - z0) % 4;\n if (row === 2) b.put(x, g, z, B.water!, packWater(8, true));\n else {\n b.put(x, g, z, B.dirt!);\n b.put(x, g + 1, z, B.tallgrass!); // «посевы»\n }\n }\n}\n\nfunction village(\n b: Builder,\n cx: number,\n cz: number,\n size: StructureSize,\n wall: number,\n roof: number,\n): void {\n const n = bySize(size, [3, 5, 7] as const);\n const R = bySize(size, [11, 15, 19] as const);\n const spots: [number, number][] = [];\n const turn = b.rng() * Math.PI * 2;\n for (let i = 0; i < n; i++) {\n const ang = turn + (i / n) * Math.PI * 2 + (b.rng() - 0.5) * 0.4;\n spots.push([\n Math.round(cx + Math.cos(ang) * R),\n Math.round(cz + Math.sin(ang) * R),\n ]);\n }\n // сначала тропы от колодца к домам, потом дома — дом перекрывает конец тропы\n for (const [hx, hz] of spots) road(b, cx, cz, hx, hz, 2);\n well(b, cx, cz);\n spots.forEach(([hx, hz], i) => {\n const small: StructureSize =\n i % 3 === 2 && size !== \"small\" ? \"medium\" : \"small\";\n house(b, hx, hz, small, rotationFacing(cx - hx, cz - hz), wall, roof);\n });\n if (size === \"large\") {\n const ang = turn + Math.PI / n;\n farm(\n b,\n Math.round(cx + Math.cos(ang) * (R + 6)),\n Math.round(cz + Math.sin(ang) * (R + 6)),\n \"medium\",\n 0,\n );\n }\n}\n\n/**\n * Построить одну постройку. (cx, cz) и (ex, ez) — уже в мировых блоках.\n * rng — свой на каждую постройку: порядок построек не влияет на вид каждой.\n */\nexport function buildStructure(\n s: WorldStructure,\n cx: number,\n cz: number,\n ex: number,\n ez: number,\n pal: ResolvedPalette,\n target: StampTarget,\n rng: () => number,\n models: Readonly<Record<string, RasterModel>> = {},\n /** type \"river\": точки пути, уже в мировых блоках. */\n path: readonly [number, number][] = [],\n): void {\n const b = new Builder(target, pal, rng);\n const wall = s.material ? (B[s.material] ?? pal.wall) : pal.wall;\n const roof = s.roof ? (B[s.roof] ?? pal.roof) : pal.roof;\n const stoneWall = s.material ? wall : pal.accent;\n switch (s.type) {\n case \"house\":\n return house(b, cx, cz, s.size, s.rotation, wall, roof);\n case \"tower\":\n return tower(\n b,\n cx,\n cz,\n bySize(s.size, [2, 3, 4] as const),\n bySize(s.size, [10, 14, 18] as const),\n stoneWall,\n );\n case \"castle\":\n return castle(b, cx, cz, s.size, s.rotation, stoneWall);\n case \"village\":\n return village(b, cx, cz, s.size, wall, roof);\n case \"wall\":\n return wallLine(b, cx, cz, ex, ez, s.size, stoneWall);\n case \"road\":\n return road(b, cx, cz, ex, ez, bySize(s.size, [2, 3, 4] as const));\n case \"bridge\":\n return bridge(b, cx, cz, ex, ez);\n case \"ruins\":\n return ruins(b, cx, cz, s.size);\n case \"pond\":\n return pond(b, cx, cz, s.size);\n case \"rock\":\n return rock(b, cx, cz, s.size);\n case \"tree_cluster\":\n return treeCluster(b, cx, cz, s.size);\n case \"pyramid\":\n return pyramid(\n b,\n cx,\n cz,\n s.size,\n s.rotation,\n s.material ? wall : B.sandstone!,\n );\n case \"well\":\n return well(b, cx, cz);\n case \"farm\":\n return farm(b, cx, cz, s.size, s.rotation);\n case \"model\": {\n const m = s.model ? models[s.model] : undefined;\n if (m) placeModel(b, cx, cz, s.rotation, s.elevation ?? 0, m);\n return;\n }\n case \"river\":\n return river(b, path, s.size);\n }\n}\n\n/**\n * Река по ломаной от истока к устью. Уровень воды вдоль русла только опускается (вода не течёт в\n * гору): где земля выше — русло прорезается, у моря река вливается в него. Где берег ниже воды,\n * насыпается бережок — иначе вода висела бы в воздухе.\n */\nfunction river(\n b: Builder,\n pts: readonly [number, number][],\n size: StructureSize,\n): void {\n if (pts.length < 2) return;\n const R = bySize(size, [1.6, 2.6, 4] as const);\n const maxDepth = bySize(size, [2, 2, 3] as const);\n const samples: [number, number][] = [];\n for (let i = 0; i + 1 < pts.length; i++) {\n const [ax, az] = pts[i]!,\n [bx, bz] = pts[i + 1]!;\n const n = Math.max(1, Math.ceil(Math.hypot(bx - ax, bz - az)));\n for (let k = 0; k < n; k++)\n samples.push([ax + ((bx - ax) * k) / n, az + ((bz - az) * k) / n]);\n }\n samples.push(pts[pts.length - 1]!);\n\n const sea = b.t.waterLevel;\n const wet = new Set<string>();\n let level = Infinity;\n const r = Math.ceil(R) + 1;\n for (const [sx, sz] of samples) {\n const cx = Math.round(sx),\n cz = Math.round(sz);\n level = Math.max(sea, Math.min(level, b.t.groundAt(cx, cz) - 1));\n for (let dz = -r; dz <= r; dz++)\n for (let dx = -r; dx <= r; dx++) {\n const d = Math.hypot(dx, dz);\n const x = cx + dx,\n z = cz + dz;\n const g = b.t.groundAt(x, z);\n if (d <= R) {\n const depth = 1 + Math.round((1 - d / R) * (maxDepth - 1));\n const bed = level - depth;\n b.put(x, bed, z, d < R * 0.5 ? B.gravel! : B.sand!);\n for (let y = bed + 1; y <= level; y++)\n b.put(x, y, z, B.water!, packWater(8, true));\n for (let y = level + 1; y <= Math.max(g, level) + 3; y++)\n b.put(x, y, z, B.air!);\n wet.add(x + \",\" + z);\n } else if (d <= R + 1.5 && g < level && !wet.has(x + \",\" + z)) {\n for (let y = g + 1; y < level; y++) b.put(x, y, z, B.dirt!);\n b.put(x, level, z, B.grass!);\n }\n }\n }\n}\n\n/**\n * Поставить модель объекта: центр модели — в (cx, cz), поворот вокруг вертикали, низ модели — на\n * земле под центром (+ elevation — самолёт в полёте). Площадка под моделью расчищается: деревья\n * и бугры не прорастают сквозь самолёт; на воде модель стоит на её поверхности.\n */\nfunction placeModel(\n b: Builder,\n cx: number,\n cz: number,\n rot: number,\n elevation: number,\n m: RasterModel,\n): void {\n const { w, h, d, cells } = m;\n const hx = (w - 1) / 2,\n hz = (d - 1) / 2;\n // У всех точек одинаковая дробная часть (0 или .5), поэтому округление — сдвиг без дыр и наложений.\n const toWorld = (lx: number, lz: number): [number, number] => {\n const x = lx - hx,\n z = lz - hz;\n const [rx, rz] =\n rot === 90\n ? [-z, x]\n : rot === 180\n ? [-x, -z]\n : rot === 270\n ? [z, -x]\n : [x, z];\n return [cx + Math.round(rx), cz + Math.round(rz)];\n };\n const base = Math.max(b.t.groundAt(cx, cz), b.t.waterLevel);\n const y0 = base + 1 + elevation;\n const [ax, az] = toWorld(0, 0),\n [bx, bz] = toWorld(w - 1, d - 1);\n b.clear(\n Math.min(ax, bx),\n Math.min(az, bz),\n Math.max(ax, bx),\n Math.max(az, bz),\n elevation ? y0 : base + 1,\n y0 + h,\n );\n for (let y = 0; y < h; y++)\n for (let z = 0; z < d; z++)\n for (let x = 0; x < w; x++) {\n const id = cells[x + z * w + y * w * d]!;\n if (!id) continue;\n const [wx, wz] = toWorld(x, z);\n b.put(wx, y0 + y, wz, id);\n }\n}\n"
264
+ },
265
+ {
266
+ "path": "worldgen/textSpec.ts",
267
+ "content": "// Рецепт мира БЕЗ ИИ: из текста (ключевые слова, русский и английский) и разбора картинки.\n// Бесплатно и офлайн. ИИ-путь отдаёт тот же WorldSpec — всё дальше общее.\nimport { CONFIG } from \"../config\";\nimport { mulberry32 } from \"../core/Noise\";\nimport {\n bytesToBase64,\n parseWorldSpec,\n type Biome,\n type StructureType,\n type TerrainShape,\n type TimeOfDay,\n type WorldSize,\n type WorldSpec,\n} from \"./WorldSpec\";\nimport {\n IMAGE_LAND_PALETTE,\n nearestBlock,\n type MoodResult,\n type TopDownResult,\n} from \"./ImageAnalyzer\";\n\ntype Rule<T> = [RegExp, T];\n\nconst SHAPE_RULES: Rule<TerrainShape>[] = [\n [/архипелаг|острова|archipelago|islands/, \"archipelago\"],\n [/остров|island/, \"island\"],\n [/гор[аыноу]|горн|скал|пик|mountain|peak|cliff/, \"mountains\"],\n [/долин|каньон|ущель|рек[аиу]|valley|canyon|river/, \"valley\"],\n [/плато|столов|plateau|mesa/, \"plateau\"],\n [/равнин|степ|поле|plain|flat|field|steppe/, \"flat\"],\n [/материк|континент|continent/, \"continent\"],\n];\n\nconst BIOME_RULES: Rule<Biome>[] = [\n [/берёз|берез|birch/, \"birch_forest\"],\n [/тайг|ель|ёл|елов|хвой|pine|spruce|taiga|conifer/, \"taiga\"],\n [/джунгл|тропическ|jungle|rainforest/, \"jungle\"],\n [/лес|роща|чащ|forest|wood/, \"forest\"],\n [\n /снег|снеж|зим|лёд|лед|ледян|мороз|арктик|snow|winter|ice|frozen|arctic/,\n \"snow\",\n ],\n [/пустын|дюн|бархан|desert|dune/, \"desert\"],\n [/красн.*скал|бесплод|badlands|red rock/, \"badlands\"],\n [/пляж|пальм|побереж|beach|palm|coast/, \"beach\"],\n [/болот|топь|трясин|swamp|marsh|bog/, \"swamp\"],\n [/саванн|savanna/, \"savanna\"],\n [/камен|валун|stone|rocky|boulder/, \"rocky\"],\n [/луг|цвет|поляна|meadow|flower|glade/, \"meadow\"],\n];\n\n// Русские слова — по основе, а не по словарной форме: «с замком», «у замка», «стеной».\nconst STRUCTURE_RULES: Rule<StructureType>[] = [\n [/замок|замк|крепост|цитадел|castle|fortress|citadel/, \"castle\"],\n [/деревн|посёл|посел|город|village|town|hamlet/, \"village\"],\n [/дом|хижин|избушк|house|cabin|hut|cottage/, \"house\"],\n [/башн|маяк|tower|lighthouse/, \"tower\"],\n [/мост|bridge/, \"bridge\"],\n [/стен[аыуое]|стенк|оград|wall/, \"wall\"],\n [/руин|развалин|древн|ruin|ancient/, \"ruins\"],\n [/пирамид|pyramid/, \"pyramid\"],\n [/колодец|колодц|well/, \"well\"],\n [/ферм|огород|поле пшен|farm|crop/, \"farm\"],\n [/озер|озёр|пруд|lake|pond/, \"pond\"],\n [/дорог|тропа|путь|road|path/, \"road\"],\n];\n\nconst TIME_RULES: Rule<TimeOfDay>[] = [\n [/ноч|луна|звёзд|звезд|night|moon|star/, \"night\"],\n [/закат|вечер|sunset|evening|dusk/, \"sunset\"],\n [/рассвет|утр|dawn|sunrise|morning/, \"dawn\"],\n];\n\nconst all = <T>(text: string, rules: Rule<T>[]): T[] =>\n rules.filter(([re]) => re.test(text)).map(([, v]) => v);\n\n/** Биом по «настроению» картинки, если текст про биомы молчит. */\nfunction biomesFromMood(m: MoodResult): Biome[] {\n const s = m.shares;\n const out: Biome[] = [];\n if (s.snow > 0.25) out.push(\"snow\");\n if (s.sand > 0.25) out.push(\"desert\");\n if (s.red > 0.2) out.push(\"badlands\");\n if (s.green > 0.2) out.push(m.luma < 0.45 ? \"forest\" : \"meadow\");\n if (s.gray > 0.35) out.push(\"rocky\");\n return out;\n}\n\n/** Материал стен/крыши по доминирующим цветам картинки. */\nfunction paletteFromMood(m: MoodResult): { wall?: string; roof?: string } {\n const build = [\n \"planks\",\n \"dark_planks\",\n \"stone_bricks\",\n \"bricks\",\n \"sandstone\",\n \"cobblestone\",\n \"white_wool\",\n \"terracotta\",\n ];\n const warm = m.dominant.filter(\n (d) => Math.max(d.r, d.g, d.b) - Math.min(d.r, d.g, d.b) > 30,\n );\n const pickFor = (d: { r: number; g: number; b: number } | undefined) =>\n d ? build[nearestBlock(d.r, d.g, d.b, build)] : undefined;\n return {\n wall: pickFor(m.dominant[0]),\n roof: pickFor(warm[0] ?? m.dominant[1]),\n };\n}\n\n/** Слои картинки «карта сверху» в формате рецепта. */\nexport function topDownLayers(td: TopDownResult, waterLevel: number) {\n const low = Math.max(2, waterLevel - 6);\n const high = Math.min(CONFIG.chunk.sy - 14, waterLevel + 22);\n const v = (y: number) => Math.round(((y - low) / (high - low)) * 255);\n const vWater = v(waterLevel - 3),\n vShore = v(waterLevel + 1);\n const n = td.w * td.h;\n const heights = new Uint8Array(n);\n const surface = new Uint8Array(n);\n const palette = [...IMAGE_LAND_PALETTE, \"water\"];\n const waterIdx = palette.length - 1;\n for (let i = 0; i < n; i++) {\n if (td.water[i]) {\n heights[i] = vWater;\n surface[i] = waterIdx;\n } else {\n heights[i] = Math.round(vShore + td.elevation[i]! * (255 - vShore));\n surface[i] = td.surface[i]!;\n }\n }\n return {\n heightmap: { w: td.w, h: td.h, low, high, data: bytesToBase64(heights) },\n surfaceMap: { w: td.w, h: td.h, palette, data: bytesToBase64(surface) },\n };\n}\n\nexport interface NoAIInput {\n text: string;\n size: WorldSize;\n seed: number;\n topDown?: TopDownResult;\n mood?: MoodResult;\n}\n\n/** Рецепт из текста и разбора картинки. Всегда валиден (проходит parseWorldSpec). */\nexport function buildSpecWithoutAI(input: NoAIInput): WorldSpec {\n const text = input.text.toLowerCase();\n const rng = mulberry32(input.seed ^ 0x5bd1e995);\n const water = CONFIG.world.waterLevel;\n\n let shape: TerrainShape | undefined = all(text, SHAPE_RULES)[0];\n let biomes = all(text, BIOME_RULES);\n if (!biomes.length && input.mood) biomes = biomesFromMood(input.mood);\n if (!shape && input.mood && input.mood.shares.water > 0.35)\n shape = \"archipelago\";\n const structures = all(text, STRUCTURE_RULES);\n if (structures.includes(\"bridge\") && !shape) shape = \"valley\"; // мосту нужна река\n shape ??= input.size.mode === \"island\" ? \"island\" : \"continent\";\n const time = all(text, TIME_RULES)[0] ?? \"day\";\n\n const zones: Record<string, unknown>[] = [];\n const primary = biomes[0] ?? (shape === \"mountains\" ? \"taiga\" : \"meadow\");\n zones.push({ x: 0.5, z: 0.5, radius: 0.5, biome: primary });\n biomes.slice(1, 5).forEach((biome, i, rest) => {\n const a = (i / rest.length) * Math.PI * 2 + rng() * 0.6;\n zones.push({\n x: 0.5 + Math.cos(a) * 0.3,\n z: 0.5 + Math.sin(a) * 0.3,\n radius: 0.28,\n biome,\n });\n });\n\n const placed: Record<string, unknown>[] = [];\n const spot = () => ({ x: 0.3 + rng() * 0.4, z: 0.3 + rng() * 0.4 });\n const settlements: { x: number; z: number }[] = [];\n for (const type of structures) {\n if (type === \"road\") continue; // дороги — ниже, между поселениями\n if (type === \"bridge\") {\n placed.push({ type, x: 0.35, z: 0.5, x2: 0.65, z2: 0.5 });\n continue;\n }\n if (type === \"wall\") {\n const p = spot();\n placed.push({\n type,\n x: p.x - 0.15,\n z: p.z,\n x2: p.x + 0.15,\n z2: p.z,\n size: \"medium\",\n });\n continue;\n }\n const p = type === \"castle\" ? { x: 0.5, z: 0.45 } : spot();\n placed.push({\n type,\n ...p,\n size: type === \"castle\" || type === \"village\" ? \"large\" : \"medium\",\n rotation: Math.floor(rng() * 4) * 90,\n });\n if ([\"castle\", \"village\", \"house\", \"tower\"].includes(type))\n settlements.push(p);\n }\n if (structures.includes(\"road\") || settlements.length > 1)\n for (let i = 1; i < settlements.length; i++) {\n const a = settlements[0]!,\n b = settlements[i]!;\n placed.push({\n type: \"road\",\n x: a.x,\n z: a.z,\n x2: b.x,\n z2: b.z,\n size: \"small\",\n });\n }\n // немного природы, если постройки не заказаны\n if (!placed.length) {\n if (primary === \"rocky\" || shape === \"mountains\")\n placed.push({ type: \"rock\", ...spot(), size: \"large\" });\n if ([\"forest\", \"birch_forest\", \"taiga\", \"jungle\"].includes(primary))\n placed.push({ type: \"tree_cluster\", ...spot(), size: \"large\" });\n if (primary === \"meadow\")\n placed.push({ type: \"pond\", ...spot(), size: \"medium\" });\n }\n\n const amplitude = shape === \"mountains\" ? 22 : shape === \"flat\" ? 4 : 12;\n const terrain: Record<string, unknown> = {\n shape,\n baseHeight: shape === \"mountains\" ? 30 : water + 5,\n amplitude,\n roughness: shape === \"mountains\" ? 0.7 : 0.45,\n waterLevel: water,\n };\n if (input.topDown)\n Object.assign(terrain, topDownLayers(input.topDown, water));\n\n const trimmed = input.text.trim().replace(/\\s+/g, \" \");\n const name = trimmed\n ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1, 40)\n : input.topDown || input.mood\n ? \"Мир по картинке\"\n : \"Новый мир\";\n\n const parsed = parseWorldSpec(\n {\n name,\n description: trimmed.slice(0, 500),\n seed: input.seed,\n size: input.size,\n terrain,\n zones,\n structures: placed,\n palette: input.mood ? paletteFromMood(input.mood) : {},\n ambience: { timeOfDay: time },\n },\n { fallbackSeed: input.seed },\n );\n if (!parsed.ok) throw new Error(parsed.error); // объект собран здесь же — отказа быть не может\n return parsed.spec;\n}\n"
268
+ },
269
+ {
270
+ "path": "worldgen/trees.ts",
271
+ "content": "// Деревья генератора миров. Одна реализация на два случая: дерево внутри чанка (генерация чанка,\n// крона не выходит за его край) и дерево как часть постройки (роща — штамп, может пересекать\n// чанки). Разница только в функции put.\nimport { B } from \"../registry/Blocks\";\nimport type { TreeType } from \"./WorldSpec\";\n\n/**\n * Поставить блок дерева. soft = листва: ставится только в воздух (не затирает соседние стволы\n * и постройки), ствол — всегда.\n */\nexport type TreePut = (\n x: number,\n y: number,\n z: number,\n id: number,\n soft: boolean,\n) => void;\n\n/** Максимальная высота дерева над землёй — для проверки «влезет ли под потолок мира». */\nexport const TREE_MAX_HEIGHT = 11;\n\n/** Радиус кроны — отступ от края чанка, чтобы дерево не выходило за него. */\nexport const TREE_MAX_RADIUS = 2;\n\n/** Посадить дерево на блок земли (x, groundY, z). */\nexport function plantTree(\n type: TreeType,\n x: number,\n groundY: number,\n z: number,\n rng: () => number,\n put: TreePut,\n): void {\n switch (type) {\n case \"oak\":\n return roundTree(\n B.oak_log!,\n B.leaves!,\n 4 + ((rng() * 2) | 0),\n x,\n groundY,\n z,\n rng,\n put,\n );\n case \"birch\":\n return roundTree(\n B.birch_log!,\n B.birch_leaves!,\n 5 + ((rng() * 2) | 0),\n x,\n groundY,\n z,\n rng,\n put,\n );\n case \"spruce\":\n return spruce(x, groundY, z, rng, put);\n case \"palm\":\n return palm(x, groundY, z, rng, put);\n case \"dead\":\n return dead(x, groundY, z, rng, put);\n case \"none\":\n return;\n }\n}\n\n/** Лиственное дерево: ствол + два слоя 5×5 и два слоя 3×3, углы прореживаются. */\nfunction roundTree(\n log: number,\n leaves: number,\n trunk: number,\n x: number,\n h: number,\n z: number,\n rng: () => number,\n put: TreePut,\n): void {\n for (let y = 1; y <= trunk; y++) put(x, h + y, z, log, false);\n for (let dy = trunk - 1; dy <= trunk + 2; dy++) {\n const r = dy >= trunk + 1 ? 1 : 2;\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (dx === 0 && dz === 0 && dy <= trunk) continue;\n if (Math.abs(dx) === r && Math.abs(dz) === r && rng() < 0.5) continue;\n put(x + dx, h + dy + 1, z + dz, leaves, true);\n }\n }\n}\n\n/** Ель: высокий ствол и конус хвои кольцами 2-1-2-1-…-0. */\nfunction spruce(\n x: number,\n h: number,\n z: number,\n rng: () => number,\n put: TreePut,\n): void {\n const trunk = 6 + ((rng() * 3) | 0);\n for (let y = 1; y <= trunk; y++) put(x, h + y, z, B.spruce_log!, false);\n const top = h + trunk + 1;\n put(x, top, z, B.spruce_leaves!, true);\n let r = 1;\n for (let y = top - 1; y >= h + 3; y--) {\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (dx === 0 && dz === 0) continue;\n if (Math.abs(dx) === r && Math.abs(dz) === r && r > 1) continue;\n put(x + dx, y, z + dz, B.spruce_leaves!, true);\n }\n r = r === 1 ? 2 : 1;\n }\n}\n\n/** Пальма: тонкий ствол и крест из «листьев-вай» наверху. */\nfunction palm(\n x: number,\n h: number,\n z: number,\n rng: () => number,\n put: TreePut,\n): void {\n const trunk = 5 + ((rng() * 3) | 0);\n for (let y = 1; y <= trunk; y++) put(x, h + y, z, B.oak_log!, false);\n const top = h + trunk + 1;\n put(x, top, z, B.leaves!, true);\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ] as const) {\n put(x + dx, top, z + dz, B.leaves!, true);\n put(x + dx * 2, top - 1, z + dz * 2, B.leaves!, true);\n }\n}\n\n/** Сухое дерево: голый ствол с обломком ветки. */\nfunction dead(\n x: number,\n h: number,\n z: number,\n rng: () => number,\n put: TreePut,\n): void {\n const trunk = 2 + ((rng() * 3) | 0);\n for (let y = 1; y <= trunk; y++) put(x, h + y, z, B.oak_log!, false);\n if (rng() < 0.6)\n put(x + (rng() < 0.5 ? 1 : -1), h + trunk, z, B.oak_log!, false);\n}\n"
272
+ },
273
+ {
274
+ "path": "worldgen/voxelModel.test.ts",
275
+ "content": "import { describe, expect, it } from \"vitest\";\nimport { B } from \"../registry/Blocks\";\nimport { rasterizeModel, type RasterModel } from \"./voxelModel\";\nimport { parseWorldSpec, type VoxelModel } from \"./WorldSpec\";\n\n/** Модель через ту же дверь, что и ответ ИИ. */\nfunction model(input: Record<string, unknown>): VoxelModel {\n const r = parseWorldSpec({ models: { m: input } });\n if (!r.ok) throw new Error(r.error);\n const m = r.spec.models.m;\n if (!m) throw new Error(\"model dropped: \" + r.warnings.join(\"; \"));\n return m;\n}\n\nconst at = (r: RasterModel, x: number, y: number, z: number) =>\n r.cells[x + z * r.w + y * r.w * r.d]!;\nconst count = (r: RasterModel, id?: number) =>\n r.cells.filter((c) => (id === undefined ? c !== 0 : c === id)).length;\n\ndescribe(\"rasterizeModel\", () => {\n it(\"fills a box and a hollow box\", () => {\n const solid = rasterizeModel(\n model({\n size: [5, 5, 5],\n parts: [\n { shape: \"box\", from: [0, 0, 0], to: [4, 4, 4], block: \"stone\" },\n ],\n }),\n );\n expect(count(solid)).toBe(125);\n const hollow = rasterizeModel(\n model({\n size: [5, 5, 5],\n parts: [\n {\n shape: \"box\",\n from: [0, 0, 0],\n to: [4, 4, 4],\n block: \"stone\",\n hollow: true,\n },\n ],\n }),\n );\n expect(count(hollow)).toBe(125 - 27);\n });\n\n it(\"runs a cylinder along its axis with a round section\", () => {\n const r = rasterizeModel(\n model({\n size: [20, 9, 9],\n parts: [\n {\n shape: \"cylinder\",\n axis: \"x\",\n center: [2, 4, 4],\n radius: 3,\n length: 10,\n block: \"white_wool\",\n },\n ],\n }),\n );\n expect(at(r, 2, 4, 4)).toBe(B.white_wool);\n expect(at(r, 11, 4, 4)).toBe(B.white_wool);\n expect(at(r, 12, 4, 4)).toBe(0); // длина 10: x = 2..11\n expect(at(r, 5, 4, 7)).toBe(B.white_wool); // на радиусе\n expect(at(r, 5, 7, 7)).toBe(0); // угол квадрата — вне круга\n });\n\n it(\"narrows a cone to its tip and runs backwards with a negative length\", () => {\n const r = rasterizeModel(\n model({\n size: [10, 9, 9],\n parts: [\n {\n shape: \"cone\",\n axis: \"x\",\n center: [8, 4, 4],\n radius: 3,\n radius2: 0,\n length: -6,\n block: \"red_wool\",\n },\n ],\n }),\n );\n expect(at(r, 8, 4, 7)).toBe(B.red_wool); // широкий торец\n expect(at(r, 3, 4, 4)).toBe(B.red_wool); // остриё — 6 блоков назад\n expect(at(r, 3, 4, 6)).toBe(0);\n });\n\n it(\"mirrors a part across the middle\", () => {\n const r = rasterizeModel(\n model({\n size: [4, 2, 9],\n parts: [\n {\n shape: \"box\",\n from: [0, 0, 0],\n to: [3, 0, 2],\n block: \"gray_wool\",\n mirror: \"z\",\n },\n ],\n }),\n );\n expect(at(r, 1, 0, 1)).toBe(B.gray_wool);\n expect(at(r, 1, 0, 7)).toBe(B.gray_wool); // 8 - 1\n expect(at(r, 1, 0, 4)).toBe(0);\n });\n\n it(\"carves with air and adds single voxels last\", () => {\n const r = rasterizeModel(\n model({\n size: [3, 3, 3],\n parts: [\n { shape: \"box\", from: [0, 0, 0], to: [2, 2, 2], block: \"planks\" },\n { shape: \"box\", from: [1, 1, 0], to: [1, 1, 2], block: \"air\" },\n ],\n voxels: [[1, 1, 1, \"glass\"]],\n }),\n );\n expect(at(r, 1, 1, 0)).toBe(0);\n expect(at(r, 1, 1, 1)).toBe(B.glass);\n expect(count(r, B.planks)).toBe(27 - 3);\n });\n\n it(\"clips shapes to the model's size\", () => {\n const r = rasterizeModel(\n model({\n size: [4, 4, 4],\n parts: [\n {\n shape: \"sphere\",\n center: [0, 0, 0],\n radii: [6, 6, 6],\n block: \"stone\",\n },\n ],\n }),\n );\n expect(count(r)).toBe(64);\n });\n});\n\ndescribe(\"models in a recipe\", () => {\n it(\"maps Minecraft block names to our colours and drops what it cannot build\", () => {\n const r = parseWorldSpec({\n models: {\n Car: {\n size: [16, 6, 8],\n parts: [\n {\n shape: \"box\",\n from: [0, 1, 0],\n to: [15, 3, 7],\n block: \"minecraft:red_concrete\",\n },\n {\n shape: \"box\",\n from: [0, 1, 0],\n to: [1, 1, 1],\n block: \"cyan_terracotta\",\n },\n {\n shape: \"box\",\n from: [0, 1, 0],\n to: [1, 1, 1],\n block: \"diamond_block\",\n },\n { shape: \"teapot\", block: \"stone\" },\n { shape: \"cylinder\", block: \"black_wool\" }, // без центра — нечего строить\n ],\n },\n },\n structures: [\n { type: \"model\", model: \"car\", x: 0.5, z: 0.5 },\n { type: \"model\", model: \"spaceship\", x: 0.5, z: 0.5 },\n ],\n });\n if (!r.ok) throw new Error(r.error);\n const car = r.spec.models.car!;\n expect(car.parts.map((p) => p.block)).toEqual([\n \"red_wool\",\n \"light_blue_wool\",\n ]);\n expect(r.warnings.join()).toMatch(/отброшено деталей: 3/);\n // ссылка на несуществующую модель — не ставится\n expect(r.spec.structures).toHaveLength(1);\n expect(r.spec.structures[0]).toMatchObject({\n type: \"model\",\n model: \"car\",\n elevation: 0,\n });\n });\n\n it(\"clamps a model's size to what the world can hold\", () => {\n const r = parseWorldSpec({\n models: { big: { size: [500, 500, 500], voxels: [[0, 0, 0, \"stone\"]] } },\n });\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.models.big!.size).toEqual([64, 40, 64]);\n });\n});\n"
276
+ },
277
+ {
278
+ "path": "worldgen/voxelModel.ts",
279
+ "content": "// Растеризатор моделей объектов: фигуры (коробка, цилиндр, конус, эллипсоид, линия) → блоки.\n//\n// Зачем фигуры, а не сетка блоков от ИИ: языковая модель хорошо мыслит конструкцией — «фюзеляж —\n// цилиндр 30 блоков, крылья — плоские коробки, симметрично», — а перечислить тысячи блоков по\n// одному она не может ни по точности, ни по длине ответа. Фигуры дают пропорции и объём за сотню\n// строк; мелочи дописываются одиночными блоками (voxels).\nimport { B } from \"../registry/Blocks\";\nimport type { ModelPart, VoxelModel } from \"./WorldSpec\";\n\nexport interface RasterModel {\n w: number;\n h: number;\n d: number;\n /** id блока по индексу x + z*w + y*w*d; 0 — пусто. */\n cells: Uint8Array;\n}\n\nconst AXIS: Record<\"x\" | \"y\" | \"z\", 0 | 1 | 2> = { x: 0, y: 1, z: 2 };\n\nexport function rasterizeModel(m: VoxelModel): RasterModel {\n const [w, h, d] = m.size;\n const cells = new Uint8Array(w * h * d);\n\n const write = (x: number, y: number, z: number, id: number) => {\n if (x < 0 || y < 0 || z < 0 || x >= w || y >= h || z >= d) return;\n cells[x + z * w + y * w * d] = id;\n };\n const put = (\n x: number,\n y: number,\n z: number,\n id: number,\n part: ModelPart,\n ) => {\n write(x, y, z, id);\n if (part.mirror === \"x\") write(w - 1 - x, y, z, id);\n else if (part.mirror === \"z\") write(x, y, d - 1 - z, id);\n };\n\n for (const part of m.parts) {\n const id = B[part.block] ?? B.air!;\n switch (part.shape) {\n case \"box\":\n box(part, id, put);\n break;\n case \"cylinder\":\n case \"cone\":\n tube(part, id, put);\n break;\n case \"sphere\":\n sphere(part, id, put);\n break;\n case \"line\":\n line(part, id, put);\n break;\n }\n }\n for (const [x, y, z, name] of m.voxels) write(x, y, z, B[name] ?? B.air!);\n return { w, h, d, cells };\n}\n\ntype Put = (\n x: number,\n y: number,\n z: number,\n id: number,\n part: ModelPart,\n) => void;\n\nfunction box(p: ModelPart, id: number, put: Put): void {\n const [ax, ay, az] = p.from!,\n [bx, by, bz] = p.to!;\n const x0 = Math.round(Math.min(ax, bx)),\n x1 = Math.round(Math.max(ax, bx));\n const y0 = Math.round(Math.min(ay, by)),\n y1 = Math.round(Math.max(ay, by));\n const z0 = Math.round(Math.min(az, bz)),\n z1 = Math.round(Math.max(az, bz));\n for (let y = y0; y <= y1; y++)\n for (let z = z0; z <= z1; z++)\n for (let x = x0; x <= x1; x++) {\n if (\n p.hollow &&\n x > x0 &&\n x < x1 &&\n y > y0 &&\n y < y1 &&\n z > z0 &&\n z < z1\n )\n continue;\n put(x, y, z, id, p);\n }\n}\n\n/** Цилиндр и конус: круглые сечения вдоль оси, радиус плавно меняется от radius к radius2. */\nfunction tube(p: ModelPart, id: number, put: Put): void {\n const a = AXIS[p.axis ?? \"x\"];\n const [u, v] = ([0, 1, 2] as const).filter((i) => i !== a) as [\n 0 | 1 | 2,\n 0 | 1 | 2,\n ];\n const c = p.center!;\n const len = p.length ?? 1;\n const n = Math.max(1, Math.abs(len));\n const dir = len < 0 ? -1 : 1;\n const r0 = p.radius ?? 1;\n const r1 = p.shape === \"cone\" ? (p.radius2 ?? 0) : r0;\n const start = Math.round(c[a]);\n for (let t = 0; t < n; t++) {\n const r = r0 + (r1 - r0) * (n > 1 ? t / (n - 1) : 0);\n const lim = Math.max(r, 0.5) + 0.45; // тонкая фигура не пропадает совсем\n const cu = c[u],\n cv = c[v];\n for (let pu = Math.floor(cu - lim); pu <= Math.ceil(cu + lim); pu++)\n for (let pv = Math.floor(cv - lim); pv <= Math.ceil(cv + lim); pv++) {\n const dist = Math.hypot(pu - cu, pv - cv);\n if (dist > lim) continue;\n if (p.hollow && dist < lim - 1.2) continue;\n const q: [number, number, number] = [0, 0, 0];\n q[a] = start + dir * t;\n q[u] = pu;\n q[v] = pv;\n put(q[0], q[1], q[2], id, p);\n }\n }\n}\n\nfunction sphere(p: ModelPart, id: number, put: Put): void {\n const [cx, cy, cz] = p.center!;\n const [rx, ry, rz] = p.radii!.map((r) => r + 0.45) as [\n number,\n number,\n number,\n ];\n const inside = (x: number, y: number, z: number, k: number) =>\n ((x - cx) / (rx - k)) ** 2 +\n ((y - cy) / (ry - k)) ** 2 +\n ((z - cz) / (rz - k)) ** 2 <=\n 1;\n for (let y = Math.floor(cy - ry); y <= Math.ceil(cy + ry); y++)\n for (let z = Math.floor(cz - rz); z <= Math.ceil(cz + rz); z++)\n for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx); x++) {\n if (!inside(x, y, z, 0)) continue;\n if (p.hollow && Math.min(rx, ry, rz) > 1.5 && inside(x, y, z, 1))\n continue;\n put(x, y, z, id, p);\n }\n}\n\n/** Отрезок: антенны, стойки, тросы; с radius — утолщённый. */\nfunction line(p: ModelPart, id: number, put: Put): void {\n const [ax, ay, az] = p.from!,\n [bx, by, bz] = p.to!;\n const steps = Math.max(\n 1,\n Math.ceil(\n Math.max(Math.abs(bx - ax), Math.abs(by - ay), Math.abs(bz - az)),\n ),\n );\n const r = p.radius ?? 0;\n for (let i = 0; i <= steps; i++) {\n const t = i / steps;\n const x = ax + (bx - ax) * t,\n y = ay + (by - ay) * t,\n z = az + (bz - az) * t;\n if (r < 0.5) {\n put(Math.round(x), Math.round(y), Math.round(z), id, p);\n continue;\n }\n const R = Math.ceil(r);\n for (let dy = -R; dy <= R; dy++)\n for (let dz = -R; dz <= R; dz++)\n for (let dx = -R; dx <= R; dx++)\n if (Math.hypot(dx, dy, dz) <= r + 0.45)\n put(\n Math.round(x) + dx,\n Math.round(y) + dy,\n Math.round(z) + dz,\n id,\n p,\n );\n }\n}\n"
280
+ },
281
+ {
282
+ "path": "worldgen/WorldGenerator.ts",
283
+ "content": "// Контракт генератора мира. ChunkManager знает только его: заполнить чанк, подсказать точку спавна\n// и уровень моря. Реализации — шум по сиду (TerrainGenerator, классический мир и сейвы v1) и\n// рецепт мира (SpecGenerator).\nimport type { Chunk } from \"../world/Chunk\";\nimport type { WorldSource } from \"../types\";\nimport { TerrainGenerator } from \"../world/TerrainGenerator\";\nimport { SpecGenerator } from \"./SpecGenerator\";\n\nexport interface WorldGenerator {\n readonly seed: number;\n /** Уровень моря (y): спавн ищет сушу выше него. */\n readonly waterLevel: number;\n /** Заполнить данные чанка через chunk.setRaw и выставить chunk.generated = true. */\n generate(chunk: Chunk): void;\n /** Откуда начинать поиск суши для спавна (мировые x, z). */\n spawnHint(): { x: number; z: number };\n}\n\nexport function createGenerator(source: WorldSource): WorldGenerator {\n return source.kind === \"spec\"\n ? new SpecGenerator(source.spec)\n : new TerrainGenerator(source.seed);\n}\n"
284
+ },
285
+ {
286
+ "path": "worldgen/WorldSpec.test.ts",
287
+ "content": "import { describe, expect, it } from \"vitest\";\nimport {\n SPEC_LIMITS,\n bytesToBase64,\n extractJsonObject,\n parseWorldSpec,\n} from \"./WorldSpec\";\n\ndescribe(\"parseWorldSpec — the only door for a world recipe\", () => {\n it(\"fills an empty object with a playable island\", () => {\n const r = parseWorldSpec({});\n expect(r.ok).toBe(true);\n if (!r.ok) return;\n expect(r.spec.size).toEqual({\n mode: \"island\",\n widthChunks: 16,\n depthChunks: 16,\n });\n expect(r.spec.terrain.shape).toBe(\"island\");\n expect(r.spec.zones).toEqual([]);\n expect(r.spec.ambience.timeOfDay).toBe(\"day\");\n });\n\n it(\"clamps numbers instead of trusting them\", () => {\n const r = parseWorldSpec({\n size: { widthChunks: 9999, depthChunks: -5 },\n terrain: { baseHeight: 999, amplitude: -3, waterLevel: \"30\" },\n });\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.size.widthChunks).toBe(SPEC_LIMITS.maxChunks);\n expect(r.spec.size.depthChunks).toBe(SPEC_LIMITS.minChunks);\n expect(r.spec.terrain.baseHeight).toBe(SPEC_LIMITS.maxBaseHeight);\n expect(r.spec.terrain.amplitude).toBe(0);\n expect(r.spec.terrain.waterLevel).toBe(30);\n });\n\n it(\"drops unknown structures and blocks, keeps known ones\", () => {\n const r = parseWorldSpec({\n structures: [\n { type: \"spaceport\", x: 0.5, z: 0.5 },\n {\n type: \"Castle\",\n x: 2,\n z: -1,\n material: \"diamond_block\",\n size: \"huge\",\n },\n { type: \"road\", x: 0.1, z: 0.1 },\n ],\n zones: [{ biome: \"Birch Forest\", surface: \"lava\" }],\n });\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.structures.map((s) => s.type)).toEqual([\"castle\", \"road\"]);\n const castle = r.spec.structures[0]!;\n expect([castle.x, castle.z, castle.size, castle.material]).toEqual([\n 1,\n 0,\n \"medium\",\n undefined,\n ]);\n expect(r.spec.structures[1]!.x2).toBeDefined(); // линии получают конец отрезка\n expect(r.spec.zones[0]!.biome).toBe(\"birch_forest\");\n expect(r.spec.zones[0]!.surface).toBe(\"grass\"); // «lava» заменена значением биома\n });\n\n it(\"caps list lengths\", () => {\n const r = parseWorldSpec({\n zones: Array.from({ length: 30 }, () => ({ biome: \"forest\" })),\n });\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.zones).toHaveLength(SPEC_LIMITS.maxZones);\n expect(r.warnings.length).toBeGreaterThan(0);\n });\n\n it(\"reads a model answer wrapped in a ```json fence and chatter\", () => {\n const text =\n 'Here is your world:\\n```json\\n{\"name\":\"Frost\",\"zones\":[{\"biome\":\"snow\"}]}\\n```\\nEnjoy!';\n const r = parseWorldSpec(text);\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.name).toBe(\"Frost\");\n expect(r.spec.zones[0]!.biome).toBe(\"snow\");\n });\n\n it(\"unwraps a { world: {...} } answer\", () => {\n const r = parseWorldSpec({\n world: { name: \"Inner\", terrain: { shape: \"valley\" } },\n });\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.terrain.shape).toBe(\"valley\");\n });\n\n it(\"recognises a truncated answer\", () => {\n const r = parseWorldSpec('{\"name\":\"Cut\",\"zones\":[{\"biome\":\"fo');\n expect(r.ok).toBe(false);\n if (r.ok) return;\n expect(r.truncated).toBe(true);\n });\n\n it(\"refuses something that is not an object\", () => {\n expect(parseWorldSpec(\"no json here\").ok).toBe(false);\n expect(parseWorldSpec([1, 2]).ok).toBe(false);\n });\n\n it(\"drops a heightmap whose data does not match its size\", () => {\n const r = parseWorldSpec({\n terrain: {\n heightmap: { w: 4, h: 4, data: bytesToBase64(new Uint8Array(3)) },\n },\n });\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.terrain.heightmap).toBeUndefined();\n expect(r.warnings.join()).toMatch(/карта высот/);\n });\n\n it(\"is stable: parsing its own output changes nothing\", () => {\n const first = parseWorldSpec({\n name: \"Loop\",\n structures: [{ type: \"village\" }],\n zones: [{ biome: \"desert\" }],\n });\n if (!first.ok) throw new Error(first.error);\n const second = parseWorldSpec(JSON.parse(JSON.stringify(first.spec)));\n if (!second.ok) throw new Error(second.error);\n expect(second.spec).toEqual(first.spec);\n });\n});\n\ndescribe(\"landscape tools\", () => {\n it(\"squares a terrain sketch and keeps only digits\", () => {\n const r = parseWorldSpec({\n terrain: {\n sketch: { rows: [\"0123\", \"45x\", [9, 9, 1, 0], \"7777\", \" 8 8 8 8 \"] },\n },\n });\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.terrain.sketch?.rows).toEqual([\n \"0123\",\n \"4544\",\n \"9910\",\n \"7777\",\n \"8888\",\n ]);\n });\n\n it(\"drops a sketch that is too small to mean anything\", () => {\n const r = parseWorldSpec({ terrain: { sketch: { rows: [\"01\", \"23\"] } } });\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.terrain.sketch).toBeUndefined();\n expect(r.warnings.join()).toMatch(/набросок/);\n });\n\n it(\"keeps a river's path and drops a river without one\", () => {\n const r = parseWorldSpec({\n structures: [\n { type: \"river\", path: [[0.1, 0.1], [0.5, \"0.5\"], [2, -1], \"bad\"] },\n { type: \"river\", path: [[0.3, 0.3]] },\n ],\n });\n if (!r.ok) throw new Error(r.error);\n expect(r.spec.structures).toHaveLength(1);\n expect(r.spec.structures[0]!.path).toEqual([\n [0.1, 0.1],\n [0.5, 0.5],\n [1, 0],\n ]);\n });\n});\n\ndescribe(\"extractJsonObject\", () => {\n it(\"ignores braces inside strings\", () => {\n const r = extractJsonObject('{\"a\":\"}{\",\"b\":1}');\n expect(r).toEqual({ ok: true, value: { a: \"}{\", b: 1 } });\n });\n});\n"
288
+ },
289
+ {
290
+ "path": "worldgen/WorldSpec.ts",
291
+ "content": "// Рецепт мира (WorldSpec) — компактный JSON, по которому SpecGenerator детерминированно строит мир.\n//\n// Зачем рецепт, а не блоки: мир на 256×256×64 — это четыре миллиона блоков; ИИ такое не выдаст, а\n// рецепт на пару килобайт — выдаст. Тот же рецепт собирается и без ИИ (картинка разбирается прямо\n// в браузере, текст — по ключевым словам) и пишется руками или агентом. Один генератор на всё.\n//\n// Координаты в рецепте НОРМИРОВАНЫ (0..1 от ширины/глубины мира): рецепт не зависит от размера,\n// и модели не надо считать блоки. Каждое поле необязательно: parseWorldSpec() дополняет\n// значениями по умолчанию, зажимает числа в допустимые границы и отбрасывает мусор — это\n// единственная дверь, через которую рецепт попадает в игру (ИИ, файл, сейв, агент).\nimport { BLOCKS, WOOL_BLOCKS } from \"../registry/Blocks\";\nimport { hashString } from \"../core/Noise\";\nimport { CONFIG } from \"../config\";\n\nexport const WORLD_SPEC_VERSION = 1 as const;\n\nexport const SIZE_MODES = [\"island\", \"bounded\", \"infinite\"] as const;\nexport type SizeMode = (typeof SIZE_MODES)[number];\n\nexport const TERRAIN_SHAPES = [\n \"island\",\n \"archipelago\",\n \"continent\",\n \"valley\",\n \"mountains\",\n \"plateau\",\n \"flat\",\n] as const;\nexport type TerrainShape = (typeof TERRAIN_SHAPES)[number];\n\nexport const BIOMES = [\n \"meadow\",\n \"forest\",\n \"birch_forest\",\n \"taiga\",\n \"snow\",\n \"desert\",\n \"badlands\",\n \"beach\",\n \"swamp\",\n \"rocky\",\n \"savanna\",\n \"jungle\",\n] as const;\nexport type Biome = (typeof BIOMES)[number];\n\nexport const TREE_TYPES = [\n \"oak\",\n \"birch\",\n \"spruce\",\n \"palm\",\n \"dead\",\n \"none\",\n] as const;\nexport type TreeType = (typeof TREE_TYPES)[number];\n\nexport const STRUCTURE_TYPES = [\n \"house\",\n \"tower\",\n \"castle\",\n \"village\",\n \"wall\",\n \"road\",\n \"bridge\",\n \"ruins\",\n \"pond\",\n \"rock\",\n \"tree_cluster\",\n \"pyramid\",\n \"well\",\n \"farm\",\n /** Объект, который ИИ собрал из фигур (самолёт, машина…) — ссылка на WorldSpec.models. */\n \"model\",\n /** Река по ломаной path: течёт только вниз, прорезает русло сквозь холмы. */\n \"river\",\n] as const;\nexport type StructureType = (typeof STRUCTURE_TYPES)[number];\n\n/** Фигуры, из которых ИИ собирает модель объекта. */\nexport const MODEL_SHAPES = [\n \"box\",\n \"cylinder\",\n \"cone\",\n \"sphere\",\n \"line\",\n] as const;\nexport type ModelShape = (typeof MODEL_SHAPES)[number];\nexport const MODEL_AXES = [\"x\", \"y\", \"z\"] as const;\nexport const MODEL_MIRRORS = [\"x\", \"z\"] as const;\n/** Линейные постройки: им нужен конец отрезка (x2, z2). */\nexport const LINE_STRUCTURES: readonly StructureType[] = [\n \"wall\",\n \"road\",\n \"bridge\",\n];\n\nexport const STRUCTURE_SIZES = [\"small\", \"medium\", \"large\"] as const;\nexport type StructureSize = (typeof STRUCTURE_SIZES)[number];\n\nexport const TIMES_OF_DAY = [\"dawn\", \"day\", \"sunset\", \"night\"] as const;\nexport type TimeOfDay = (typeof TIMES_OF_DAY)[number];\n\n/** Жёсткие рамки — рецепт длиннее этого генератор не строит, откуда бы он ни пришёл. */\nexport const SPEC_LIMITS = {\n minChunks: 4,\n maxChunks: 48,\n maxZones: 12,\n maxStructures: 40,\n maxNpcs: 12,\n maxGrid: 256,\n maxModels: 4,\n maxParts: 200,\n maxVoxels: 400,\n minSketch: 4,\n maxSketch: 32,\n maxRiverPoints: 16,\n maxModelSide: 64,\n /** Мир высотой 64: модель выше этого просто не поместится над землёй. */\n maxModelHeight: 40,\n minBaseHeight: 6,\n /** Над сушей остаётся место для деревьев и построек (мир высотой CONFIG.chunk.sy). */\n maxBaseHeight: CONFIG.chunk.sy - 16,\n maxAmplitude: 30,\n minWater: 2,\n maxWater: CONFIG.chunk.sy - 20,\n} as const;\n\n// ---- типы -------------------------------------------------------------------\n\nexport interface WorldSize {\n mode: SizeMode;\n /** Ширина/глубина основной области в чанках (16 блоков). У infinite — область рецепта. */\n widthChunks: number;\n depthChunks: number;\n}\n\n/** Карта высот из картинки: байт 0..255 → высота low..high. Данные — base64, построчно. */\nexport interface HeightmapLayer {\n w: number;\n h: number;\n low: number;\n high: number;\n data: string;\n}\n\n/** Карта поверхности из картинки: байт = индекс в palette (имена блоков). */\nexport interface SurfaceLayer {\n w: number;\n h: number;\n palette: string[];\n data: string;\n}\n\nexport interface WorldTerrain {\n shape: TerrainShape;\n /** Средняя высота суши (y). */\n baseHeight: number;\n /** Размах холмов в блоках. */\n amplitude: number;\n /** 0 — плавно, 1 — изрезанно. */\n roughness: number;\n /** Уровень моря (y). */\n waterLevel: number;\n heightmap?: HeightmapLayer;\n surfaceMap?: SurfaceLayer;\n /** Набросок рельефа от ИИ: карта высот сверху цифрами (см. SKETCH_OFFSETS). */\n sketch?: TerrainSketch;\n}\n\n/**\n * Карта высот, которую ИИ «рисует» по картинке пейзажа: строки с севера на юг, в строке цифры с\n * запада на восток, 4..32 × 4..32. Цифра — уровень (0 глубокая вода … 9 пики), генератор сглаживает\n * сетку и добавляет естественные детали. Ляжет почти любой пейзаж: горы слева, озеро, обрыв к морю.\n */\nexport interface TerrainSketch {\n rows: string[];\n}\n\n/** Высота уровня наброска относительно уровня моря: 0 — глубина, 2 — берег, 9 — пики. */\nexport const SKETCH_OFFSETS = [-8, -4, -1, 1, 3, 6, 10, 14, 19, 25] as const;\n\nexport interface WorldZone {\n x: number;\n z: number;\n radius: number;\n biome: Biome;\n surface: string;\n subsurface: string;\n /** Плотность деревьев 0..1. */\n trees: number;\n treeType: TreeType;\n grass: number;\n flowers: number;\n /** Сдвиг рельефа зоны в блоках (-20..20). */\n height: number;\n}\n\nexport interface WorldStructure {\n type: StructureType;\n x: number;\n z: number;\n /** Конец отрезка для wall / road / bridge. */\n x2?: number;\n z2?: number;\n size: StructureSize;\n rotation: 0 | 90 | 180 | 270;\n /** Основной материал (стены). Нет — из palette. */\n material?: string;\n /** Материал крыши. Нет — из palette. */\n roof?: string;\n /** type \"model\": id модели в WorldSpec.models. */\n model?: string;\n /** type \"model\": на сколько блоков над землёй (самолёт в полёте). */\n elevation?: number;\n /** type \"river\": точки от истока к устью, [x, z] в долях мира. */\n path?: [number, number][];\n}\n\nexport interface WorldNpc {\n name: string;\n role: string;\n x: number;\n z: number;\n persona: string;\n}\n\nexport type V3 = [number, number, number];\n\n/**\n * Фигура модели в блоках модели: x — от носа к хвосту (длина, x=0 — перёд), y — вверх (y=0 стоит\n * на земле), z — слева направо. Позже нарисованная фигура перекрывает раннюю; block \"air\" вырезает.\n */\nexport interface ModelPart {\n shape: ModelShape;\n block: string;\n /** box, line: углы/концы. */\n from?: V3;\n to?: V3;\n /** cylinder, cone: ось, центр начального торца, радиус, длина вдоль оси (минус — в обратную). */\n axis?: (typeof MODEL_AXES)[number];\n center?: V3;\n radius?: number;\n /** cone: радиус дальнего торца (0 — остриё). */\n radius2?: number;\n length?: number;\n /** sphere: полуоси эллипсоида. */\n radii?: V3;\n /** Только оболочка (box, cylinder, cone, sphere). */\n hollow?: boolean;\n /** Дорисовать зеркальную копию через середину модели — крылья, колёса, фары. */\n mirror?: (typeof MODEL_MIRRORS)[number];\n}\n\n/** Объект из блоков, собранный ИИ по картинкам (самолёт, машина, корабль…). */\nexport interface VoxelModel {\n /** [длина x, высота y, ширина z] в блоках. */\n size: V3;\n parts: ModelPart[];\n /** Одиночные блоки для мелких деталей: [x, y, z, блок]. */\n voxels: [number, number, number, string][];\n}\n\nexport interface WorldPalette {\n wall: string;\n roof: string;\n floor: string;\n path: string;\n accent: string;\n}\n\nexport interface WorldAmbience {\n timeOfDay: TimeOfDay;\n}\n\nexport interface WorldSpec {\n version: typeof WORLD_SPEC_VERSION;\n name: string;\n description: string;\n seed: number;\n size: WorldSize;\n terrain: WorldTerrain;\n zones: WorldZone[];\n structures: WorldStructure[];\n /** Модели объектов по id; ставятся постройками type \"model\". */\n models: Record<string, VoxelModel>;\n npcs: WorldNpc[];\n palette: WorldPalette;\n ambience: WorldAmbience;\n}\n\n// ---- биомы --------------------------------------------------------------------\n\nexport interface BiomePreset {\n surface: string;\n subsurface: string;\n trees: number;\n treeType: TreeType;\n grass: number;\n flowers: number;\n height: number;\n}\n\nexport const BIOME_PRESETS: Record<Biome, BiomePreset> = {\n meadow: {\n surface: \"grass\",\n subsurface: \"dirt\",\n trees: 0.06,\n treeType: \"oak\",\n grass: 0.35,\n flowers: 0.08,\n height: 0,\n },\n forest: {\n surface: \"grass\",\n subsurface: \"dirt\",\n trees: 0.65,\n treeType: \"oak\",\n grass: 0.2,\n flowers: 0.03,\n height: 1,\n },\n birch_forest: {\n surface: \"grass\",\n subsurface: \"dirt\",\n trees: 0.6,\n treeType: \"birch\",\n grass: 0.25,\n flowers: 0.05,\n height: 1,\n },\n taiga: {\n surface: \"grass\",\n subsurface: \"dirt\",\n trees: 0.55,\n treeType: \"spruce\",\n grass: 0.12,\n flowers: 0.01,\n height: 3,\n },\n snow: {\n surface: \"snow\",\n subsurface: \"dirt\",\n trees: 0.12,\n treeType: \"spruce\",\n grass: 0,\n flowers: 0,\n height: 4,\n },\n desert: {\n surface: \"sand\",\n subsurface: \"sandstone\",\n trees: 0.02,\n treeType: \"dead\",\n grass: 0,\n flowers: 0,\n height: 0,\n },\n badlands: {\n surface: \"red_sand\",\n subsurface: \"terracotta\",\n trees: 0.02,\n treeType: \"dead\",\n grass: 0,\n flowers: 0,\n height: 5,\n },\n beach: {\n surface: \"sand\",\n subsurface: \"sand\",\n trees: 0.06,\n treeType: \"palm\",\n grass: 0.02,\n flowers: 0,\n height: -3,\n },\n swamp: {\n surface: \"grass\",\n subsurface: \"clay\",\n trees: 0.3,\n treeType: \"oak\",\n grass: 0.45,\n flowers: 0,\n height: -3,\n },\n rocky: {\n surface: \"stone\",\n subsurface: \"stone\",\n trees: 0.03,\n treeType: \"spruce\",\n grass: 0.02,\n flowers: 0,\n height: 4,\n },\n savanna: {\n surface: \"grass\",\n subsurface: \"dirt\",\n trees: 0.08,\n treeType: \"oak\",\n grass: 0.55,\n flowers: 0.02,\n height: 0,\n },\n jungle: {\n surface: \"grass\",\n subsurface: \"dirt\",\n trees: 0.85,\n treeType: \"oak\",\n grass: 0.6,\n flowers: 0.05,\n height: 1,\n },\n};\n\n// ---- допустимые блоки ------------------------------------------------------------\n\nconst NOT_FOR_WORLDGEN = new Set([\n \"air\",\n \"bedrock\",\n \"crafting_table\",\n \"coal_ore\",\n \"iron_ore\",\n \"water\",\n]);\n\n/** Блоки, которыми можно покрыть землю (цельные, непрозрачные). */\nexport const SURFACE_BLOCKS: readonly string[] = BLOCKS.filter(\n (b) => b.solid && b.opaque && !NOT_FOR_WORLDGEN.has(b.name),\n).map((b) => b.name);\n\n/** Блоки для построек: всё твёрдое, включая стекло и листву. */\nexport const BUILD_BLOCKS: readonly string[] = BLOCKS.filter(\n (b) => b.solid && !b.liquid && !NOT_FOR_WORLDGEN.has(b.name),\n).map((b) => b.name);\n\nconst DEFAULT_PALETTE: WorldPalette = {\n wall: \"planks\",\n roof: \"dark_planks\",\n floor: \"planks\",\n path: \"dirt_path\",\n accent: \"stone_bricks\",\n};\n\nexport const DEFAULT_SIZE: WorldSize = {\n mode: \"island\",\n widthChunks: 16,\n depthChunks: 16,\n};\n\n/** Готовые размеры для экрана «Создать мир». */\nexport const SIZE_PRESETS = {\n island: { mode: \"island\", widthChunks: 16, depthChunks: 16 },\n large: { mode: \"island\", widthChunks: 32, depthChunks: 32 },\n infinite: { mode: \"infinite\", widthChunks: 16, depthChunks: 16 },\n} as const satisfies Record<string, WorldSize>;\n\n/** Размер области рецепта в блоках. */\nexport function specBlocks(spec: WorldSpec): { W: number; D: number } {\n const { sx, sz } = CONFIG.chunk;\n return {\n W: spec.size.widthChunks * sx,\n D: spec.size.depthChunks * sz,\n };\n}\n\n// ---- base64 для слоёв картинки ----------------------------------------------------\n\nexport function bytesToBase64(bytes: Uint8Array): string {\n let s = \"\";\n for (let i = 0; i < bytes.length; i += 0x8000)\n s += String.fromCharCode(...bytes.subarray(i, i + 0x8000));\n return btoa(s);\n}\n\nexport function base64ToBytes(b64: string): Uint8Array | null {\n try {\n const s = atob(b64);\n const out = new Uint8Array(s.length);\n for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);\n return out;\n } catch {\n return null;\n }\n}\n\n// ---- разбор ответа модели ----------------------------------------------------------\n\nexport type JsonExtract =\n | { ok: true; value: unknown }\n | { ok: false; error: string; truncated: boolean };\n\n/**\n * Достаёт первый JSON-объект из текста модели: срезает ```json-обёртку и болтовню вокруг.\n * Обрезанный ответ (модель упёрлась в лимит токенов — движок такой считает успехом и списывает)\n * распознаётся по незакрытым скобкам: `truncated: true`.\n */\nexport function extractJsonObject(text: string): JsonExtract {\n let t = text.trim();\n const fence = /```(?:json)?\\s*([\\s\\S]*?)(?:```|$)/i.exec(t);\n if (fence && fence[1] !== undefined) t = fence[1];\n const start = t.indexOf(\"{\");\n if (start < 0)\n return { ok: false, error: \"в ответе нет JSON-объекта\", truncated: false };\n let depth = 0,\n inStr = false,\n esc = false,\n end = -1;\n for (let i = start; i < t.length; i++) {\n const c = t[i];\n if (inStr) {\n if (esc) esc = false;\n else if (c === \"\\\\\") esc = true;\n else if (c === '\"') inStr = false;\n continue;\n }\n if (c === '\"') inStr = true;\n else if (c === \"{\" || c === \"[\") depth++;\n else if (c === \"}\" || c === \"]\") {\n depth--;\n if (depth === 0) {\n end = i;\n break;\n }\n }\n }\n if (end < 0) return { ok: false, error: \"JSON оборван\", truncated: true };\n try {\n return { ok: true, value: JSON.parse(t.slice(start, end + 1)) };\n } catch {\n return { ok: false, error: \"невалидный JSON\", truncated: false };\n }\n}\n\n// ---- нормализация ---------------------------------------------------------------\n\ntype Obj = Record<string, unknown>;\nconst isObj = (v: unknown): v is Obj =>\n typeof v === \"object\" && v !== null && !Array.isArray(v);\n\nfunction num(v: unknown, def: number, min: number, max: number): number {\n const n = typeof v === \"number\" ? v : typeof v === \"string\" ? Number(v) : NaN;\n return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : def;\n}\n\nfunction pick<T extends string>(v: unknown, allowed: readonly T[], def: T): T {\n if (typeof v !== \"string\") return def;\n const s = v\n .trim()\n .toLowerCase()\n .replace(/[\\s-]+/g, \"_\");\n return (allowed as readonly string[]).includes(s) ? (s as T) : def;\n}\n\nfunction str(v: unknown, def: string, maxLen: number): string {\n return typeof v === \"string\" ? v.trim().slice(0, maxLen) : def;\n}\n\nfunction blockName(\n v: unknown,\n allowed: readonly string[],\n def: string,\n): string {\n if (typeof v !== \"string\") return def;\n const s = v\n .trim()\n .toLowerCase()\n .replace(/[\\s-]+/g, \"_\");\n return allowed.includes(s) ? s : def;\n}\n\nconst rotation = (v: unknown): WorldStructure[\"rotation\"] => {\n const n = num(v, 0, -3600, 3600);\n return ((((Math.round(n / 90) % 4) + 4) % 4) *\n 90) as WorldStructure[\"rotation\"];\n};\n\nconst arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);\n\nfunction normHeightmap(\n v: unknown,\n warnings: string[],\n): HeightmapLayer | undefined {\n if (!isObj(v)) return undefined;\n const w = Math.round(num(v.w, 0, 0, SPEC_LIMITS.maxGrid));\n const h = Math.round(num(v.h, 0, 0, SPEC_LIMITS.maxGrid));\n const data = typeof v.data === \"string\" ? v.data : \"\";\n const bytes = w >= 2 && h >= 2 ? base64ToBytes(data) : null;\n if (!bytes || bytes.length !== w * h) {\n warnings.push(\"карта высот повреждена — рельеф без неё\");\n return undefined;\n }\n const low = Math.round(num(v.low, 16, 1, CONFIG.chunk.sy - 12));\n const high = Math.round(num(v.high, 44, low, CONFIG.chunk.sy - 12));\n return { w, h, low, high, data };\n}\n\nfunction normSurfaceMap(\n v: unknown,\n warnings: string[],\n): SurfaceLayer | undefined {\n if (!isObj(v)) return undefined;\n const w = Math.round(num(v.w, 0, 0, SPEC_LIMITS.maxGrid));\n const h = Math.round(num(v.h, 0, 0, SPEC_LIMITS.maxGrid));\n const palette = arr(v.palette).map((p) =>\n blockName(p, [...SURFACE_BLOCKS, \"water\"], \"grass\"),\n );\n const data = typeof v.data === \"string\" ? v.data : \"\";\n const bytes = w >= 2 && h >= 2 && palette.length ? base64ToBytes(data) : null;\n if (\n !bytes ||\n bytes.length !== w * h ||\n palette.length > 255 ||\n bytes.some((b) => b >= palette.length)\n ) {\n warnings.push(\"карта поверхности повреждена — поверхность по биомам\");\n return undefined;\n }\n return { w, h, palette, data };\n}\n\n/** Набросок рельефа: строки цифр 0..9 (или массивы чисел), выровненные в прямоугольник. */\nfunction normSketch(v: unknown, warnings: string[]): TerrainSketch | undefined {\n const raw = isObj(v) ? v.rows : v;\n if (!Array.isArray(raw) || !raw.length) return undefined;\n const L = SPEC_LIMITS;\n const rows = raw\n .map((r) =>\n typeof r === \"string\"\n ? r.replace(/\\s+/g, \"\").replace(/[^0-9]/g, \"4\")\n : Array.isArray(r)\n ? r.map((n) => String(Math.round(num(n, 4, 0, 9)))).join(\"\")\n : \"\",\n )\n .filter((r) => r.length > 0)\n .slice(0, L.maxSketch);\n const w = Math.min(L.maxSketch, Math.max(0, ...rows.map((r) => r.length)));\n if (rows.length < L.minSketch || w < L.minSketch) {\n warnings.push(\"набросок рельефа меньше 4×4 — рельеф без него\");\n return undefined;\n }\n return {\n rows: rows.map((r) => r.slice(0, w).padEnd(w, r[r.length - 1] ?? \"4\")),\n };\n}\n\nfunction normZone(v: unknown): WorldZone | null {\n if (!isObj(v)) return null;\n const biome = pick(v.biome, BIOMES, \"meadow\");\n const p = BIOME_PRESETS[biome];\n return {\n x: num(v.x, 0.5, 0, 1),\n z: num(v.z, 0.5, 0, 1),\n radius: num(v.radius, 0.3, 0.02, 1.5),\n biome,\n surface: blockName(v.surface, SURFACE_BLOCKS, p.surface),\n subsurface: blockName(v.subsurface, SURFACE_BLOCKS, p.subsurface),\n trees: num(v.trees, p.trees, 0, 1),\n treeType: pick(v.treeType, TREE_TYPES, p.treeType),\n grass: num(v.grass, p.grass, 0, 1),\n flowers: num(v.flowers, p.flowers, 0, 1),\n height: num(v.height, p.height, -20, 20),\n };\n}\n\nfunction normStructure(\n v: unknown,\n modelIds: ReadonlySet<string>,\n): WorldStructure | null {\n if (!isObj(v)) return null;\n // Неизвестный тип постройки отбрасывается, а не превращается в дом: «космодром» от модели\n // не должен молча стать хижиной посреди мира.\n const raw =\n typeof v.type === \"string\"\n ? v.type\n .trim()\n .toLowerCase()\n .replace(/[\\s-]+/g, \"_\")\n : \"\";\n if (!(STRUCTURE_TYPES as readonly string[]).includes(raw)) return null;\n const type = raw as StructureType;\n const s: WorldStructure = {\n type,\n x: num(v.x, 0.5, 0, 1),\n z: num(v.z, 0.5, 0, 1),\n size: pick(v.size, STRUCTURE_SIZES, \"medium\"),\n rotation: rotation(v.rotation),\n };\n if (LINE_STRUCTURES.includes(type)) {\n s.x2 = num(v.x2, Math.min(1, s.x + 0.2), 0, 1);\n s.z2 = num(v.z2, s.z, 0, 1);\n }\n if (v.material !== undefined) {\n const m = blockName(v.material, BUILD_BLOCKS, \"\");\n if (m) s.material = m;\n }\n if (v.roof !== undefined) {\n const r = blockName(v.roof, BUILD_BLOCKS, \"\");\n if (r) s.roof = r;\n }\n if (type === \"river\") {\n const pts = arr(v.path)\n .map((p): [number, number] | null =>\n Array.isArray(p) && p.length >= 2\n ? [num(p[0], NaN, 0, 1), num(p[1], NaN, 0, 1)]\n : null,\n )\n .filter(\n (p): p is [number, number] =>\n p !== null && !Number.isNaN(p[0]) && !Number.isNaN(p[1]),\n )\n .slice(0, SPEC_LIMITS.maxRiverPoints);\n if (pts.length < 2) return null; // река — это хотя бы отрезок\n s.path = pts;\n [s.x, s.z] = pts[0]!;\n }\n if (type === \"model\") {\n const id = modelId(v.model);\n if (!modelIds.has(id)) return null; // ссылка на несуществующую модель — нечего ставить\n s.model = id;\n s.elevation = Math.round(num(v.elevation, 0, 0, 30));\n }\n return s;\n}\n\nfunction normNpc(v: unknown): WorldNpc | null {\n if (!isObj(v)) return null;\n const name = str(v.name, \"\", 40);\n if (!name) return null;\n return {\n name,\n role: str(v.role, \"\", 40),\n x: num(v.x, 0.5, 0, 1),\n z: num(v.z, 0.5, 0, 1),\n persona: str(v.persona, \"\", 400),\n };\n}\n\n// ---- модели объектов ------------------------------------------------------------\n\n/** Блоки для моделей: всё строительное + \"air\" (вырез). */\nconst MODEL_BLOCKS: readonly string[] = [...BUILD_BLOCKS, \"air\"];\nconst WOOL_COLORS = new Set(WOOL_BLOCKS.map((n) => n.replace(/_wool$/, \"\")));\n/** Цвета Minecraft, которых нет среди нашей шерсти, — на ближайший. */\nconst COLOR_ALIAS: Record<string, string> = {\n cyan: \"light_blue\",\n lime: \"green\",\n magenta: \"pink\",\n light_gray: \"white\",\n silver: \"white\",\n};\n\n/**\n * Блок модели. Модели знают блоки Minecraft и охотно пишут red_concrete или cyan_terracotta —\n * такие имена сводятся к шерсти того же цвета, а не выбрасывают деталь целиком.\n */\nfunction modelBlock(v: unknown): string | null {\n if (typeof v !== \"string\") return null;\n const s = v\n .trim()\n .toLowerCase()\n .replace(/^minecraft:/, \"\")\n .replace(/[\\s-]+/g, \"_\");\n if (MODEL_BLOCKS.includes(s)) return s;\n const m =\n /^([a-z_]+?)_(concrete_powder|concrete|glazed_terracotta|terracotta|stained_glass_pane|stained_glass|carpet|wool)$/.exec(\n s,\n );\n if (!m) return null;\n if (m[2]!.startsWith(\"stained_glass\")) return \"glass\";\n const color = COLOR_ALIAS[m[1]!] ?? m[1]!;\n return WOOL_COLORS.has(color) ? color + \"_wool\" : null;\n}\n\n/** id модели: латиница, цифры, _ и -; как в models, так и в ссылке постройки. */\nfunction modelId(v: unknown): string {\n return typeof v === \"string\"\n ? v\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9_-]+/g, \"_\")\n .slice(0, 32)\n : \"\";\n}\n\nfunction oneOf<T extends string>(\n v: unknown,\n allowed: readonly T[],\n): T | undefined {\n if (typeof v !== \"string\") return undefined;\n const s = v.trim().toLowerCase();\n return (allowed as readonly string[]).includes(s) ? (s as T) : undefined;\n}\n\n/** Точка модели: три числа, с шагом 0.5 (центр между блоками), в разумных пределах. */\nfunction vec3(v: unknown, lim: number): V3 | null {\n if (!Array.isArray(v) || v.length !== 3) return null;\n const out = v.map((n) => num(n, NaN, -lim, lim * 2));\n if (out.some((n) => Number.isNaN(n))) return null;\n return out.map((n) => Math.round(n * 2) / 2) as V3;\n}\n\nfunction normPart(v: unknown, size: V3): ModelPart | null {\n if (!isObj(v)) return null;\n const shape = oneOf(v.shape, MODEL_SHAPES);\n const block = modelBlock(v.block);\n if (!shape || !block) return null;\n const lim = Math.max(...size) + 4;\n const part: ModelPart = { shape, block };\n if (shape === \"box\" || shape === \"line\") {\n const from = vec3(v.from, lim),\n to = vec3(v.to, lim);\n if (!from || !to) return null;\n part.from = from;\n part.to = to;\n if (shape === \"line\") part.radius = num(v.radius, 0, 0, 3);\n } else {\n const center = vec3(v.center, lim);\n if (!center) return null;\n part.center = center;\n if (shape === \"sphere\") {\n const r = num(v.radius, 2, 0.5, 32);\n const radii =\n Array.isArray(v.radii) && v.radii.length === 3 ? v.radii : [r, r, r];\n part.radii = radii.map((n) => num(n, r, 0.5, 32)) as V3;\n } else {\n part.axis = oneOf(v.axis, MODEL_AXES) ?? \"x\";\n part.radius = num(v.radius, 1, 0, 32);\n part.length = Math.round(num(v.length, 1, -lim, lim)) || 1;\n if (shape === \"cone\") part.radius2 = num(v.radius2, 0, 0, 32);\n }\n }\n if (v.hollow === true && shape !== \"line\") part.hollow = true;\n const mirror = oneOf(v.mirror, MODEL_MIRRORS);\n if (mirror) part.mirror = mirror;\n return part;\n}\n\nfunction normModels(\n v: unknown,\n warnings: string[],\n): Record<string, VoxelModel> {\n if (!isObj(v)) return {};\n const L = SPEC_LIMITS;\n const out: Record<string, VoxelModel> = {};\n for (const [rawId, mv] of Object.entries(v)) {\n const id = modelId(rawId);\n if (!id || !isObj(mv) || out[id]) continue;\n if (Object.keys(out).length >= L.maxModels) {\n warnings.push(`модели: оставлены первые ${L.maxModels}`);\n break;\n }\n const sz = Array.isArray(mv.size) ? mv.size : [];\n const size: V3 = [\n Math.round(num(sz[0], 16, 1, L.maxModelSide)),\n Math.round(num(sz[1], 8, 1, L.maxModelHeight)),\n Math.round(num(sz[2], 16, 1, L.maxModelSide)),\n ];\n const raw = arr(mv.parts);\n const parts = raw\n .slice(0, L.maxParts)\n .map((p) => normPart(p, size))\n .filter((p): p is ModelPart => p !== null);\n if (raw.length > L.maxParts)\n warnings.push(`модель ${id}: оставлены первые ${L.maxParts} деталей`);\n const dropped = Math.min(raw.length, L.maxParts) - parts.length;\n if (dropped > 0)\n warnings.push(`модель ${id}: отброшено деталей: ${dropped}`);\n const voxels = arr(mv.voxels)\n .slice(0, L.maxVoxels)\n .map((vx): [number, number, number, string] | null => {\n if (!Array.isArray(vx) || vx.length !== 4) return null;\n const b = modelBlock(vx[3]);\n const c = [0, 1, 2].map((i) =>\n Math.round(num(vx[i], NaN, 0, size[i]! - 1)),\n );\n if (!b || c.some((n) => Number.isNaN(n))) return null;\n return [c[0]!, c[1]!, c[2]!, b];\n })\n .filter((x): x is [number, number, number, string] => x !== null);\n if (!parts.length && !voxels.length) {\n warnings.push(`модель ${id} пуста — пропущена`);\n continue;\n }\n out[id] = { size, parts, voxels };\n }\n return out;\n}\n\nexport type SpecParseResult =\n | { ok: true; spec: WorldSpec; warnings: string[] }\n | { ok: false; error: string; truncated?: boolean };\n\n/**\n * Единственная дверь в игру для рецепта: принимает объект или текст ответа модели, дополняет\n * пропуски значениями по умолчанию, зажимает числа, отбрасывает неизвестное. Отказ — только если\n * это вообще не объект (или оборванный JSON).\n */\nexport function parseWorldSpec(\n input: unknown,\n opts: { fallbackSeed?: number } = {},\n): SpecParseResult {\n if (typeof input === \"string\") {\n const ex = extractJsonObject(input);\n if (!ex.ok) return { ok: false, error: ex.error, truncated: ex.truncated };\n input = ex.value;\n }\n if (!isObj(input)) return { ok: false, error: \"рецепт мира — не объект\" };\n // Небрежные ответы вида { \"world\": { ... } } — разворачиваем.\n if (!(\"terrain\" in input) && !(\"size\" in input) && !(\"zones\" in input)) {\n const inner = input.world ?? input.spec ?? input.worldSpec;\n if (isObj(inner)) input = inner;\n }\n const o = input as Obj;\n const warnings: string[] = [];\n\n const name = str(o.name, \"Новый мир\", 60) || \"Новый мир\";\n const description = str(o.description, \"\", 500);\n const seed =\n typeof o.seed === \"number\" && Number.isFinite(o.seed)\n ? Math.abs(Math.floor(o.seed)) >>> 0\n : typeof o.seed === \"string\" && o.seed.trim()\n ? hashString(o.seed.trim())\n : (opts.fallbackSeed ?? hashString(name + \"|\" + description)) >>> 0;\n\n const so = isObj(o.size) ? o.size : {};\n const mode = pick(so.mode, SIZE_MODES, DEFAULT_SIZE.mode);\n const L = SPEC_LIMITS;\n const size: WorldSize = {\n mode,\n widthChunks: Math.round(\n num(so.widthChunks, DEFAULT_SIZE.widthChunks, L.minChunks, L.maxChunks),\n ),\n depthChunks: Math.round(\n num(so.depthChunks, DEFAULT_SIZE.depthChunks, L.minChunks, L.maxChunks),\n ),\n };\n\n const to = isObj(o.terrain) ? o.terrain : {};\n const terrain: WorldTerrain = {\n shape: pick(\n to.shape,\n TERRAIN_SHAPES,\n mode === \"island\" ? \"island\" : \"continent\",\n ),\n baseHeight: Math.round(\n num(to.baseHeight, 30, L.minBaseHeight, L.maxBaseHeight),\n ),\n amplitude: num(to.amplitude, 12, 0, L.maxAmplitude),\n roughness: num(to.roughness, 0.5, 0, 1),\n waterLevel: Math.round(\n num(to.waterLevel, CONFIG.world.waterLevel, L.minWater, L.maxWater),\n ),\n };\n const hm = normHeightmap(to.heightmap, warnings);\n if (hm) terrain.heightmap = hm;\n const sm = normSurfaceMap(to.surfaceMap, warnings);\n if (sm) terrain.surfaceMap = sm;\n const sketch = normSketch(to.sketch, warnings);\n if (sketch) terrain.sketch = sketch;\n\n const cap = <T>(list: (T | null)[], max: number, what: string): T[] => {\n const ok = list.filter((x): x is T => x !== null);\n if (ok.length > max) warnings.push(`${what}: оставлены первые ${max}`);\n return ok.slice(0, max);\n };\n const zones = cap(arr(o.zones).map(normZone), L.maxZones, \"зоны\");\n const models = normModels(o.models, warnings);\n const modelIds = new Set(Object.keys(models));\n const structures = cap(\n arr(o.structures).map((s) => normStructure(s, modelIds)),\n L.maxStructures,\n \"постройки\",\n );\n const npcs = cap(arr(o.npcs).map(normNpc), L.maxNpcs, \"NPC\");\n\n const po = isObj(o.palette) ? o.palette : {};\n const palette: WorldPalette = {\n wall: blockName(po.wall, BUILD_BLOCKS, DEFAULT_PALETTE.wall),\n roof: blockName(po.roof, BUILD_BLOCKS, DEFAULT_PALETTE.roof),\n floor: blockName(po.floor, BUILD_BLOCKS, DEFAULT_PALETTE.floor),\n path: blockName(po.path, SURFACE_BLOCKS, DEFAULT_PALETTE.path),\n accent: blockName(po.accent, BUILD_BLOCKS, DEFAULT_PALETTE.accent),\n };\n\n const ao = isObj(o.ambience) ? o.ambience : {};\n const ambience: WorldAmbience = {\n timeOfDay: pick(ao.timeOfDay, TIMES_OF_DAY, \"day\"),\n };\n\n return {\n ok: true,\n spec: {\n version: WORLD_SPEC_VERSION,\n name,\n description,\n seed,\n size,\n terrain,\n zones,\n structures,\n models,\n npcs,\n palette,\n ambience,\n },\n warnings,\n };\n}\n\n/** Рецепт по умолчанию (пустой ввод → остров-луг). */\nexport function defaultSpec(seed: number): WorldSpec {\n const r = parseWorldSpec({ seed });\n if (!r.ok) throw new Error(r.error); // пустой объект всегда валиден\n return r.spec;\n}\n"
292
+ },
293
+ {
294
+ "path": "worlds/startWorld.ts",
295
+ "content": "// Стартовый мир модуля: рецепт WorldSpec или null — классический мир по сиду CONFIG.world.seed.\n//\n// Как сделать свой: в игре «Create world» → «Скачать рецепт» → вставить содержимое файла вместо\n// null (объектом, не строкой). Или агент пишет рецепт сам — формат в скилле voxelcraft-worlds.\n// Рецепт проходит parseWorldSpec, как любой другой: сломанное поле не уронит запуск.\n//\n// Игрок с сохранённым миром продолжит его («Мои миры»); стартовый мир — для первого запуска.\nimport { CONFIG } from \"../config\";\nimport type { WorldSource } from \"../types\";\nimport { parseWorldSpec } from \"../worldgen/WorldSpec\";\n\nexport const START_WORLD: unknown = null;\n\nexport function startSource(): WorldSource {\n if (START_WORLD) {\n const r = parseWorldSpec(START_WORLD);\n if (r.ok) return { kind: \"spec\", spec: r.spec };\n }\n return { kind: \"noise\", seed: CONFIG.world.seed };\n}\n"
196
296
  },
197
297
  {
198
298
  "path": "module.meta.json",
199
- "content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"summary\": \"Voxel sandbox (Minecraft-like): walk a first-person world, place and break blocks.\",\n \"description\": \"A first-person voxel sandbox rendered in Three.js. Players explore a procedurally chunked world, place and break blocks, and move with WASD + mouse-look. Covers any 'build / mine / explore a block world' or open-world sandbox request; the largest of the feature modules.\",\n \"provides\": [\n \"first-person voxel world exploration\",\n \"place and break blocks\",\n \"procedural chunked terrain\",\n \"WASD + mouse-look player controller\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\"voxel\", \"sandbox\", \"minecraft\", \"3d\", \"first-person\", \"three\"],\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\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.1.0\"\n}\n"
299
+ "content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"summary\": \"Voxel sandbox (Minecraft-like): build a world from a picture + description (with or without AI), then walk it, place and break blocks.\",\n \"description\": \"A first-person voxel sandbox rendered in Three.js. 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: terrain shape, biome zones, 14 structure types (castle, village, bridge…), NPC spawn stubs. Worlds autosave to 'My worlds'. Covers any 'build / mine / explore a block world', open-world sandbox or 'generate a world from a picture' request; the largest of the feature modules.\",\n \"provides\": [\n \"first-person voxel world exploration\",\n \"place and break blocks\",\n \"procedural chunked terrain\",\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 \"autosave and 'My worlds' (IndexedDB)\",\n \"WASD + mouse-look player controller\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"three\",\n \"ai\",\n \"world-generation\"\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\": { \"name\": \"iDos Games\", \"url\": \"https://idosgames.com\" },\n \"version\": \"0.2.0\"\n}\n"
200
300
  }
201
301
  ]
202
302
  }