@idosgames/mcp 0.1.36 → 0.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/cli.js +2204 -27
- package/package.json +4 -1
- package/registry/host.json +7 -7
- package/registry/index.json +310 -156
- package/registry/modules/ai.json +7 -7
- package/registry/modules/board-game.json +14 -14
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +32 -15
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +7 -7
- package/registry/modules/coop-events.json +7 -7
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +7 -7
- package/registry/modules/events.json +8 -8
- package/registry/modules/friends.json +9 -9
- package/registry/modules/hero-run.json +498 -0
- package/registry/modules/idle-rpg.json +69 -17
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +7 -7
- package/registry/modules/leaderboard.json +10 -10
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +8 -8
- package/registry/modules/marketplace.json +8 -8
- package/registry/modules/mini-game.json +8 -8
- package/registry/modules/multiplayer.json +7 -7
- package/registry/modules/premium.json +7 -7
- package/registry/modules/profile.json +7 -7
- package/registry/modules/pvp.json +7 -7
- package/registry/modules/quests.json +8 -8
- package/registry/modules/rewards.json +8 -8
- package/registry/modules/season.json +7 -7
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/video-studio.json +228 -0
- package/registry/modules/voxelcraft.json +42 -125
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/authentication.json +1 -1
- package/registry/skills/hero-run.json +6 -0
- package/registry/skills/idosgames-module-contract.json +2 -2
- package/registry/skills/idosgames-project-structure.json +1 -1
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/video-studio.json +6 -0
- package/registry/video.json +9800 -0
|
@@ -81,28 +81,29 @@
|
|
|
81
81
|
"name": "iDos Games",
|
|
82
82
|
"url": "https://idosgames.com"
|
|
83
83
|
},
|
|
84
|
-
"version": "0.7.
|
|
84
|
+
"version": "0.7.2"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@idosgames/
|
|
88
|
-
"@idosgames/
|
|
89
|
-
"@idosgames/
|
|
87
|
+
"@idosgames/characters": "0.1.0",
|
|
88
|
+
"@idosgames/core": "0.23.0",
|
|
89
|
+
"@idosgames/module-sdk": "0.13.0",
|
|
90
|
+
"@idosgames/react": "0.6.0",
|
|
90
91
|
"react": "19.2.7",
|
|
91
92
|
"three": "0.185.1"
|
|
92
93
|
},
|
|
93
|
-
"contentHash": "
|
|
94
|
+
"contentHash": "42da445bf001d19dd0e96483de896c87ea9069478e87bbd8f8434a81b1cc8f9a",
|
|
94
95
|
"files": [
|
|
95
96
|
{
|
|
96
97
|
"path": "agent.ts",
|
|
97
|
-
"content": "import { FEATURE } from \"./lobby/ids\";\nimport { tr } from \"./i18n\";\nimport { taskNeed } from \"./quests/tasks\";\nimport { parseCharacterSpec, specHash } from \"./character/CharacterSpec\";\nimport { parseItemSkin } from \"./character/items/ItemSkinSpec\";\nimport { registerSkin, stackSkinName } from \"./character/items/skins\";\nimport { EMOTE_IDS, isEmote } from \"./character/anim/emotes\";\nimport { EMOTIONS, isEmotion } from \"./character/anim/emotions\";\nimport { CHAR_BEHAVIORS, type CharBehavior } from \"./entities/characterMobs\";\nimport type { ModuleAgentApi } from \"@idosgames/module-sdk\";\nimport { difficulty } from \"./systems/Difficulty\";\nimport { events } from \"./core/EventBus\";\nimport { Intent } from \"./core/Intents\";\nimport { BLOCKS } from \"./registry/Blocks\";\nimport { sim } from \"./net/sim\";\nimport type { Game } from \"./main\";\nimport { buildSpecWithoutAI } from \"./worldgen/textSpec\";\nimport { SIZE_PRESETS, parseWorldSpec } from \"./worldgen/WorldSpec\";\nimport { worldPerf } from \"./world/perf\";\n\n// Debug-поверхность VoxelCraft для агента AI-кодера (контракт `ctx.exposeToAgent`).\n//\n// Зачем она нужна именно здесь: агент осматривает работающее приложение, читая DOM, а вся игра —\n// один `<canvas>`. Без этого файла для агента экран пуст, и «персонаж провалился сквозь пол» он\n// не увидит никак.\n//\n// ПРАВИЛО, которое нельзя нарушать: действия идут тем же путём, что и живой ввод — через клавиши\n// `Input` и очередь интентов. Второй реализации ходьбы здесь нет и быть не должно, иначе агент\n// проверял бы не ту игру, в которую играет человек.\n\n/** Потолок удержания клавиши: агент не должен уметь «зажать W» на минуту. */\nconst MAX_HOLD_MS = 5000;\n\n/** Сколько времени на действие, если агент не сказал. */\nconst DEFAULT_HOLD_MS = 400;\n\nconst DIR_KEYS: Record<string, string> = {\n forward: \"KeyW\",\n back: \"KeyS\",\n left: \"KeyA\",\n right: \"KeyD\",\n};\n\nfunction clampMs(value: unknown): number {\n const ms = typeof value === \"number\" && value > 0 ? value : DEFAULT_HOLD_MS;\n return Math.min(ms, MAX_HOLD_MS);\n}\n\nfunction round(value: number, digits = 2): number {\n const k = 10 ** digits;\n return Math.round(value * k) / k;\n}\n\nconst toDegrees = (rad: number): number => round((rad * 180) / Math.PI, 1);\n\n/**\n * @param getGame доступ к текущему экземпляру игры. Именно функция, а не объект: сцена создаёт\n * `Game` в `mount()` и обнуляет в `destroy()`, а поверхность регистрируется один раз на `setup()`.\n */\nexport function createVoxelcraftAgentApi(\n getGame: () => Game | null,\n): ModuleAgentApi {\n /** Игра, готовая принимать команды: существует и не на паузе после действий агента. */\n function live(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n // Действие в игре, которая стоит на паузе, молча не сделало бы ничего — сначала берём\n // управление (это же снимает оверлей паузы, как клик игрока).\n game.input.takeControl();\n return game;\n }\n\n /** Удержать клавишу как живой игрок: нажали, подождали, отпустили. */\n function hold(code: string, ms: number): Promise<void> {\n const game = live();\n game.input.keys.add(code);\n return new Promise((resolve) => {\n setTimeout(() => {\n game.input.keys.delete(code);\n resolve();\n }, ms);\n });\n }\n\n /**\n * Удержать кнопку мыши (ломать/ставить обрабатываются по удержанию). Отпускаем не раньше, чем\n * нажатие увидели два кадра: в фоне кадр идёт раз в 200 мс, и «клик» на 120 мс игра не замечала.\n */\n function holdButton(button: \"left\" | \"right\", ms: number): Promise<void> {\n const game = live();\n if (button === \"left\") game.input.leftDown = true;\n else game.input.rightDown = true;\n const frame0 = game.loop.frameN;\n const t0 = performance.now();\n return new Promise((resolve) => {\n const check = () => {\n const seen = game.loop.frameN - frame0 >= 2;\n if (\n performance.now() - t0 < ms ||\n (!seen && performance.now() - t0 < 1000)\n ) {\n setTimeout(check, 20);\n return;\n }\n if (button === \"left\") game.input.leftDown = false;\n else game.input.rightDown = false;\n resolve();\n };\n setTimeout(check, 20);\n });\n }\n\n /** Игра без захвата управления: смена мира работает и на паузе. */\n function mounted(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n return game;\n }\n\n return {\n state() {\n const game = getGame();\n if (!game) return { mounted: false };\n\n const p = game.player;\n const held = game.inventory.heldItem();\n const src = game.world.source;\n\n return {\n mounted: true,\n // Идёт ли симуляция. false = игра на паузе (оверлей «Click to play»): в этом состоянии\n // персонаж не двигается вообще, и это НЕ баг движения.\n playing: game.playing,\n inputActive: game.input.active,\n player: {\n pos: { x: round(p.pos.x), y: round(p.pos.y), z: round(p.pos.z) },\n velocity: {\n x: round(p.vel.x),\n y: round(p.vel.y),\n z: round(p.vel.z),\n },\n yawDeg: toDegrees(p.yaw),\n pitchDeg: toDegrees(p.pitch),\n health: p.health,\n food: p.food,\n mode: p.mode,\n flying: p.flying,\n gliding: p.gliding,\n xpLevel: p.xpLevel,\n armor: game.inventory.armorStats().points,\n onGround: p.onGround,\n inWater: p.inWater,\n dead: p.dead,\n // на чём едет: minecart | boat | имя животного (horse); null — пешком\n riding: p.riding\n ? ((p.riding as { def?: { name: string } }).def?.name ??\n p.riding.kind)\n : null,\n },\n // открытый экран: inventory | crafting | chest | furnace; null — идёт игра\n screen: game.invUI.open ? game.invUI.kind : null,\n // идущий набег: волна, ожидание или бой, сколько разбойников живы\n raid: game.raid\n ? {\n wave: game.raid.wave,\n state: game.raid.state,\n raiders: game.raid.raiders.length,\n }\n : null,\n // открытые достижения (id; список — systems/Achievements)\n achievements: game.achievements.serialize(),\n // книга заданий (quests/QuestBook): что сейчас в трекере и сколько выполнено\n quests: {\n done: game.quests.entries().filter((e) => e.done).length,\n total: game.quests.entries().length,\n focus: game.quests.focus().map((e) => ({\n id: e.id,\n title: tr(e.def.title),\n progress: e.def.tasks.map((t, i) => [\n e.state.p[i] ?? 0,\n taskNeed(t),\n ]),\n })),\n },\n // своя карта (systems/WorldAtlas): метки игрока и к чему ведёт навигация\n map: {\n open: game.mapUI.open,\n pins: game.worldMap.pins.map((p) => ({\n id: p.id,\n name: p.name,\n x: p.x,\n z: p.z,\n dim: p.dim,\n })),\n guided: game.worldMap.track,\n questTarget: (() => {\n const t = game.questTracker.goal();\n return t ? { x: t.pos.x, y: t.pos.y, z: t.pos.z } : null;\n })(),\n },\n // блок под прицелом (то, что сломает ЛКМ и на что сработает ПКМ)\n lookingAt: (() => {\n const t = game.interaction.target;\n return t\n ? { block: BLOCKS[t.id]?.name ?? t.id, x: t.x, y: t.y, z: t.z }\n : null;\n })(),\n // мобы в 32 блоках: сколько каждого вида (что видит игрок вокруг)\n mobsNearby: (() => {\n const out: Record<string, number> = {};\n for (const e of game.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.pos.distanceTo(p.pos) < 32)\n out[e.def.name] = (out[e.def.name] ?? 0) + 1;\n return out;\n })(),\n // босс рядом (Дракон Предела): имя, здоровье — как полоса босса на экране\n boss: (() => {\n for (const e of game.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n return {\n name: e.def.name,\n hp: Math.round(e.hp),\n max: e.def.hp,\n pos: {\n x: round(e.pos.x),\n y: round(e.pos.y),\n z: round(e.pos.z),\n },\n };\n return null;\n })(),\n // Персонаж игрока: рецепт (имя, скелет, хэш), камера F5, жест и эмоция, надетое, студия\n character: {\n name: game.character.spec.name,\n rig: game.character.spec.rig,\n hash: specHash(game.character.spec),\n camera: game.character.camera,\n emote: game.character.actor.animator.emoting,\n emotion: game.character.actor.animator.emotion,\n state: game.character.actor.animator.state,\n outfit: { ...game.character.outfit },\n heldSkin: stackSkinName(held),\n },\n world: {\n id: game.worldId,\n name: game.worldName,\n // \"noise\" — классический мир по сиду; \"spec\" — мир по рецепту (картинка/описание/ИИ)\n source: src.kind,\n // поколение генератора: 1 — прежний мир, 2 — высота 128, биомы, пещеры\n gen: src.gen ?? 1,\n dimension: game.dimension,\n // погода: clear | rain | thunder (в Пекле и Пределе её не видно)\n weather: game.weather.kind,\n // сложность: peaceful | easy | normal | hard (systems/Difficulty)\n difficulty: difficulty(),\n seed: game.world.seed,\n waterLevel: game.world.waterLevel,\n spawnHint: game.world.generator.spawnHint(),\n ...(src.kind === \"spec\"\n ? {\n size: src.spec.size,\n shape: src.spec.terrain.shape,\n biomes: src.spec.zones.map((z) => z.biome),\n structures: src.spec.structures.map((s) => s.type),\n fromPicture: Boolean(src.spec.terrain.heightmap),\n }\n : {}),\n loadedChunks: game.world.chunks.size,\n // производительность: мс кадра на генерацию/мешинг в главном потоке, очереди, воркеры,\n // draw call'ы и треугольники мирового прохода (оптимизации — docs/feature-history/voxelcraft-performance.md)\n perf: {\n fps: game.loop.fps,\n genMs: +worldPerf.genMs.toFixed(2),\n meshMs: +worldPerf.meshMs.toFixed(2),\n genQueue: worldPerf.genQueue,\n dirtyChunks: worldPerf.dirty,\n workers: worldPerf.workers,\n workerJobs: worldPerf.workerJobs,\n lodChunks: worldPerf.lodChunks,\n drawCalls: game.renderStats.calls,\n triangles: game.renderStats.triangles,\n },\n // Блок под ногами: по нему видно и «стоит на траве», и «провалился в пустоту».\n blockBelow: game.world.getBlock(\n Math.floor(p.pos.x),\n Math.floor(p.pos.y - 0.1),\n Math.floor(p.pos.z),\n ),\n },\n inventory: {\n hotbarIndex: game.inventory.hotbarIndex,\n inHand: held ? { item: held.item, count: held.count } : null,\n },\n // Сетевая игра: null — одиночная. owner — этот клиент симулирует своё измерение (мобы,\n // жидкости, блочные обновления); false — он реплика и показывает то, что шлёт владелец.\n net: game.net\n ? {\n host: game.net.isHost,\n owner: sim.authority,\n edits: game.net.diffs.count(game.dimension),\n players: game.net.playerList().map((pl) => {\n const pose = game.net?.peers.get(pl.slot)?.pose;\n return {\n name: pl.name,\n host: pl.host,\n op: pl.op,\n dim: pl.dim,\n rttMs: Math.round(pl.rtt),\n pos:\n pl.userId === game.net?.selfId\n ? {\n x: round(p.pos.x),\n y: round(p.pos.y),\n z: round(p.pos.z),\n }\n : pose\n ? {\n x: round(pose.x),\n y: round(pose.y),\n z: round(pose.z),\n }\n : null,\n };\n }),\n }\n : null,\n };\n },\n\n actions: {\n move: async (args) => {\n const dir = String(args?.dir ?? \"forward\");\n const code = DIR_KEYS[dir];\n if (!code)\n throw new Error(\n `unknown dir '${dir}' — use forward | back | left | right`,\n );\n await hold(code, clampMs(args?.ms));\n },\n\n jump: async (args) => {\n await hold(\"Space\", clampMs(args?.ms ?? 120));\n },\n\n look: (args) => {\n const game = live();\n const dYaw = Number(args?.yawDeg ?? 0) * (Math.PI / 180);\n const dPitch = Number(args?.pitchDeg ?? 0) * (Math.PI / 180);\n // Через штатный метод игрока — те же ограничения по pitch, что и у мыши.\n game.player.look(dYaw, dPitch);\n },\n\n breakBlock: async (args) => {\n // Ломание идёт по удержанию ЛКМ: короткий «клик» не сломает ничего, кроме мгновенных блоков.\n await holdButton(\"left\", clampMs(args?.ms ?? 1200));\n },\n\n placeBlock: async () => {\n await holdButton(\"right\", 120);\n },\n\n /**\n * Удержать кнопку мыши столько, сколько нужно ДЕЙСТВИЮ, а не одному клику: натянуть лук,\n * зарядить арбалет, выпить зелье, закрыться щитом, размахнуться трезубцем. Без этого действия\n * агент умел только «тапнуть», и все такие вещи проверялись лишь тестами.\n */\n holdButton: async (args) => {\n const button = args?.button === \"left\" ? \"left\" : \"right\";\n await holdButton(button, clampMs(args?.ms ?? 1200));\n },\n\n selectSlot: (args) => {\n const n = Number(args?.slot ?? 1);\n if (!Number.isInteger(n) || n < 1 || n > 9)\n throw new Error(\"slot must be 1..9\");\n live().intents.push(Intent.SelectSlot, { slot: n - 1 });\n },\n\n toggleMode: () => {\n live().intents.push(Intent.ToggleMode, {});\n },\n\n toggleInventory: () => {\n // то же событие, что клавиша E и тач-кнопка: в креативе — палитра всех предметов\n live();\n events.emit(\"ui:toggleInventory\");\n },\n\n respawn: () => {\n // та же кнопка «Возродиться», что на экране смерти\n const game = mounted();\n if (!game.player.dead) throw new Error(\"the player is alive\");\n game.respawnPlayer();\n },\n\n command: async (args) => {\n // Тем же путём, что строка консоли игрока: интент Command → исполнение на тике.\n const game = live();\n const text = String(args?.text ?? \"\").trim();\n if (!text)\n throw new Error(\"text is required, e.g. { text: '/give torch 16' }\");\n const before = game.commands.log.length;\n game.intents.push(Intent.Command, {\n text: text.startsWith(\"/\") ? text : \"/\" + text,\n });\n for (let i = 0; i < 40 && game.commands.log.length === before; i++)\n await new Promise((r) => setTimeout(r, 50));\n return { output: game.commands.log.slice(before) };\n },\n\n entitiesNear: (args) => {\n // Ближайшие сущности (только чтение): вид, id, позиция, дистанция — прицелиться и сверить\n const game = mounted();\n const r = Math.min(64, Math.max(1, Number(args?.radius ?? 16)));\n const p = game.player.pos;\n const out: ({\n id: number;\n kind: string;\n x: number;\n y: number;\n z: number;\n dist: number;\n } & Record<string, unknown>)[] = [];\n for (const e of game.entities.entities.values()) {\n if (e.dead) continue;\n const dist = e.pos.distanceTo(p);\n if (dist > r) continue;\n out.push({\n id: e.id,\n kind: e.kind,\n ...(e.kind === \"mob\"\n ? {\n type: e.def.name,\n // состояние от действий игрока — сверить у двух игроков сети\n ...(e.sheared ? { sheared: true } : {}),\n ...(e.tamed ? { tamed: true } : {}),\n ...(e.saddled ? { saddled: true } : {}),\n ...(e.leashed ? { leashed: true } : {}),\n ...(e.customName ? { name: e.customName } : {}),\n }\n : {}),\n x: round(e.pos.x),\n y: round(e.pos.y),\n z: round(e.pos.z),\n dist: round(dist),\n ...(\"rider\" in e && e.rider ? { rider: true } : {}),\n });\n }\n return out.sort((a, b) => a.dist - b.dist).slice(0, 30);\n },\n\n blockAt: (args) => {\n // Прочитать блок мира по координатам (только чтение) — сверить мир у двух игроков сети.\n const game = mounted();\n const x = Math.floor(Number(args?.x));\n const y = Math.floor(Number(args?.y));\n const z = Math.floor(Number(args?.z));\n if (![x, y, z].every(Number.isFinite))\n throw new Error(\"x, y, z are required numbers\");\n const id = game.world.getBlock(x, y, z);\n return { block: BLOCKS[id]?.name ?? id, id };\n },\n\n generateWorld: (args) => {\n const game = mounted();\n const key = String(args?.size ?? \"island\");\n const size =\n key === \"large\" || key === \"infinite\"\n ? SIZE_PRESETS[key]\n : SIZE_PRESETS.island;\n const seed = Number(args?.seed);\n const spec = buildSpecWithoutAI({\n text: String(args?.text ?? \"\"),\n size: { ...size },\n seed: Number.isFinite(seed)\n ? seed >>> 0\n : (Math.random() * 0xffffffff) >>> 0,\n });\n game.newWorld({ kind: \"spec\", spec });\n return {\n name: spec.name,\n structures: spec.structures.map((s) => s.type),\n };\n },\n\n loadCharacterSpec: (args) => {\n const game = mounted();\n const r = parseCharacterSpec(args?.spec);\n if (!r.ok) throw new Error(r.error);\n if (args?.play === false) game.character.setSpec(r.spec);\n else void game.character.useCharacter(r.spec);\n return {\n name: r.spec.name,\n rig: r.spec.rig,\n hash: specHash(r.spec),\n warnings: r.warnings,\n };\n },\n\n loadItemSkin: (args) => {\n const game = mounted();\n const r = parseItemSkin(args?.spec);\n if (!r.ok) throw new Error(r.error);\n const skin = registerSkin(r.spec);\n void game.character.saveItem(r.spec);\n if (r.spec.kind === \"wearable\" && r.spec.slot && args?.wear !== false)\n game.character.wear(r.spec.slot, skin.hash);\n if (r.spec.kind === \"held\" && r.spec.base && args?.give !== false)\n game.inventory.addStack({\n item: r.spec.base,\n count: 1,\n tag: { skin: skin.hash },\n });\n return {\n name: r.spec.name,\n kind: r.spec.kind,\n base: r.spec.base,\n slot: r.spec.slot,\n hash: skin.hash,\n warnings: r.warnings,\n };\n },\n\n emote: (args) => {\n const id = String(args?.id ?? \"\");\n if (!isEmote(id)) throw new Error(\"emotes: \" + EMOTE_IDS.join(\", \"));\n mounted().character.emote(id);\n return { emote: id };\n },\n\n setEmotion: (args) => {\n const e = String(args?.emotion ?? \"\");\n if (!isEmotion(e)) throw new Error(\"emotions: \" + EMOTIONS.join(\", \"));\n const sec = Number(args?.seconds);\n mounted().character.feel(e, Number.isFinite(sec) ? sec : 8);\n return { emotion: e };\n },\n\n setCamera: (args) => {\n const game = mounted();\n const mode = String(args?.mode ?? \"\");\n if (mode !== \"first\" && mode !== \"back\" && mode !== \"front\")\n throw new Error(\"mode: first | back | front\");\n game.character.camera = mode;\n return { camera: mode };\n },\n\n spawnCharacter: (args) => {\n const game = mounted();\n const behavior = String(args?.behavior ?? \"npc\");\n if (!(CHAR_BEHAVIORS as readonly string[]).includes(behavior))\n throw new Error(\"behavior: \" + CHAR_BEHAVIORS.join(\" | \"));\n let spec = game.character.spec;\n if (args?.spec !== undefined) {\n const r = parseCharacterSpec(args.spec);\n if (!r.ok) throw new Error(r.error);\n spec = r.spec;\n }\n const m = game.summonCharacter(spec, behavior as CharBehavior);\n if (!m) throw new Error(\"could not summon\");\n return { id: m.id, type: m.def.name, name: spec.name };\n },\n\n completeQuest: (args) => {\n const id = String(args?.id ?? \"\");\n const full = id.includes(\":\") ? id : \"local:\" + id;\n return { done: mounted().quests.complete(full) };\n },\n\n openQuests: () => {\n mounted().questBookUI.show();\n return { open: mounted().questBookUI.open };\n },\n\n openHandbook: (args) => {\n const item = args?.item === undefined ? null : String(args.item);\n mounted().catalogUI.show(item);\n return { open: mounted().catalogUI.open };\n },\n\n openMap: () => {\n mounted().mapUI.show();\n return { open: mounted().mapUI.open };\n },\n\n addPin: (args) => {\n const game = mounted();\n const p = game.player.pos;\n const x = Number(args?.x ?? p.x),\n z = Number(args?.z ?? p.z);\n if (!Number.isFinite(x) || !Number.isFinite(z))\n throw new Error(\"x and z must be numbers\");\n const pin = game.worldMap.addPin({\n dim: game.dimension,\n x,\n z,\n ...(args?.x === undefined ? { y: Math.floor(p.y) } : {}),\n name: String(args?.name ?? \"Pin\"),\n color: Number(args?.color ?? 0),\n });\n if (!pin) throw new Error(\"too many pins\");\n if (args?.guide) game.worldMap.setTrack(pin.id);\n return { id: pin.id };\n },\n\n guidePin: (args) => {\n const id = args?.id === null ? null : Number(args?.id);\n mounted().worldMap.setTrack(id);\n return { guided: mounted().worldMap.track };\n },\n\n removePin: (args) => {\n mounted().worldMap.removePin(Number(args?.id));\n return { pins: mounted().worldMap.pins.length };\n },\n\n openStudio: (args) => {\n const tab = String(args?.tab ?? \"char\");\n const id =\n tab === \"item\"\n ? FEATURE.items\n : tab === \"mine\"\n ? FEATURE.locker\n : tab === \"worlds\"\n ? FEATURE.worlds\n : FEATURE.character;\n return { open: mounted().hub.openTab(id) };\n },\n\n loadWorldSpec: (args) => {\n const r = parseWorldSpec(args?.spec);\n if (!r.ok) throw new Error(r.error);\n mounted().newWorld({ kind: \"spec\", spec: r.spec });\n return { name: r.spec.name, warnings: r.warnings };\n },\n },\n\n describeActions: {\n move: \"Walk: { dir: forward|back|left|right, ms?: number } — holds the movement key for ms (default 400, max 5000).\",\n jump: \"Jump: { ms?: number } — taps Space.\",\n look: \"Turn the camera by a DELTA in degrees: { yawDeg?: number, pitchDeg?: number }. Positive pitch looks up.\",\n breakBlock:\n \"Hold left mouse to break the block under the crosshair: { ms?: number } (default 1200 — breaking takes time).\",\n placeBlock:\n \"Tap right mouse to place the block from the selected hotbar slot.\",\n holdButton:\n \"Hold a mouse button for a while: { button?: 'left'|'right' (default right), ms?: number } (default 1200, max 5000). This is how you draw a bow, load a crossbow, drink a potion, raise a shield or wind up a trident — a tap is too short for them.\",\n selectSlot: \"Select a hotbar slot: { slot: 1..9 }.\",\n toggleMode: \"Switch between survival and creative.\",\n toggleInventory:\n \"Open/close the inventory like the E key (state.screen shows which one: inventory, or creative — every item with search — in creative mode).\",\n respawn:\n \"Press 'Respawn' on the death screen (player.dead = true): back to the bed or the world spawn.\",\n command:\n \"Run a console command like a player typing it in chat: { text: '/give torch 16' }. Commands: /help, /give <item> [n], /tp x y z, /time set day|noon|night|midnight|<sec>, /weather clear|rain|thunder [sec],/difficulty peaceful|easy|normal|hard, /gamemode survival|creative, /setblock x y z <block> [meta], /fill x1 y1 z1 x2 y2 z2 <block>, /summon <mob> [x y z], /kill [mob] (the player, or every mob of that kind within 64 blocks — a normal death with drops, slimes split), /clear, /seed, /locate fortress|citadel|village (nearest structure of this dimension), /enchant <efficiency|unbreaking|fortune|silk_touch|sharpness|protection|power> [level] (the held item), /newworld [seed] (a new seed world of the current generation; the old world stays in 'My worlds'). Coordinates accept ~ (relative). Minecraft names (grass_block, oak_planks) are accepted. Returns the output lines.\",\n entitiesNear:\n \"Nearest entities (read-only): { radius?: 1..64 (default 16) } → up to 30 of { id, kind, type?, x, y, z, dist, rider?, sheared?, tamed?, saddled?, leashed?, name? }, nearest first.\",\n blockAt:\n \"Read one block of the current dimension (read-only): { x, y, z } → { block, id }. In a network game compare it across players to check world sync.\",\n generateWorld:\n \"Build a new world WITHOUT AI from a description (Russian/English keywords): { text: string, size?: island|large|infinite, seed?: number }. Replaces the current world (the old one stays in 'My worlds').\",\n loadCharacterSpec:\n \"Become a character from a CharacterSpec recipe: { spec: object, play?: boolean } (see the voxelcraft-characters skill; play=false only shows it). Saved to 'My characters'. Returns name, rig, hash and warnings.\",\n loadItemSkin:\n \"Add an item look from an ItemSkinSpec recipe: { spec: object, wear?: boolean, give?: boolean } — a wearable is put on (unless wear=false), a held look is given as its base item with that look (unless give=false). Saved to 'My items'.\",\n emote:\n \"Play an emote of the player's character (others see it in a network game): { id: wave|cheer|dance|point|laugh|cry|bow|shrug|facepalm|sit }.\",\n setEmotion:\n \"Show an emotion on the character's face and posture for a while: { emotion: neutral|happy|sad|angry|surprised|scared|tired|hurt, seconds?: number }.\",\n setCamera:\n \"Camera like F5: { mode: first|back|front } — back/front show the player's own character.\",\n spawnCharacter:\n \"Summon a creature with a character look 2 blocks ahead: { behavior: npc|passive|neutral|hostile|pet, spec?: CharacterSpec } (default — the player's own look). Stats come from the behavior preset, not from the recipe.\",\n completeQuest:\n \"Mark a quest of the quest book complete (its reward is given): { id } — 'wood' or a full id 'local:wood'. state().quests lists what is being tracked.\",\n openQuests:\n \"Open the quest book (J): chapters, quests, hints and rewards.\",\n openHandbook:\n \"Open the handbook (R): how to get an item, its full recipe tree down to raw materials (where to mine, which tool, ore depth), and what it is used for: { item?: id }.\",\n openMap:\n \"Open the big map (M): explored land, pins, the quest target. state().map lists pins and what is guided.\",\n addPin:\n \"Put a map pin: { x?, z?, name?, color?: 0-6, guide?: boolean } — no x/z: where the player stands. guide: the compass, minimap and on-screen marker lead to it.\",\n guidePin:\n \"Lead the compass / minimap / on-screen marker to a pin: { id } — null: back to the quest target.\",\n removePin: \"Delete a map pin: { id }.\",\n openStudio:\n \"Open a VoxelCraft tab of the SDK lobby (pictures + description → world/character/item, with or without AI): { tab?: char|item|mine|worlds }. Returns { open: false } without a lobby.\",\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"
|
|
98
|
+
"content": "import { FEATURE } from \"./lobby/ids\";\nimport { tr } from \"./i18n\";\nimport { taskNeed } from \"./quests/tasks\";\nimport {\n parseCharacterSpec,\n specHash,\n EMOTE_IDS,\n isEmote,\n EMOTIONS,\n isEmotion,\n} from \"@idosgames/characters/voxel\";\nimport { parseItemSkin } from \"./character/items/ItemSkinSpec\";\nimport { registerSkin, stackSkinName } from \"./character/items/skins\";\nimport { CHAR_BEHAVIORS, type CharBehavior } from \"./entities/characterMobs\";\nimport type { ModuleAgentApi } from \"@idosgames/module-sdk\";\nimport { difficulty } from \"./systems/Difficulty\";\nimport { events } from \"./core/EventBus\";\nimport { Intent } from \"./core/Intents\";\nimport { BLOCKS } from \"./registry/Blocks\";\nimport { sim } from \"./net/sim\";\nimport type { Game } from \"./main\";\nimport { buildSpecWithoutAI } from \"./worldgen/textSpec\";\nimport { SIZE_PRESETS, parseWorldSpec } from \"./worldgen/WorldSpec\";\nimport { worldPerf } from \"./world/perf\";\n\n// Debug-поверхность VoxelCraft для агента AI-кодера (контракт `ctx.exposeToAgent`).\n//\n// Зачем она нужна именно здесь: агент осматривает работающее приложение, читая DOM, а вся игра —\n// один `<canvas>`. Без этого файла для агента экран пуст, и «персонаж провалился сквозь пол» он\n// не увидит никак.\n//\n// ПРАВИЛО, которое нельзя нарушать: действия идут тем же путём, что и живой ввод — через клавиши\n// `Input` и очередь интентов. Второй реализации ходьбы здесь нет и быть не должно, иначе агент\n// проверял бы не ту игру, в которую играет человек.\n\n/** Потолок удержания клавиши: агент не должен уметь «зажать W» на минуту. */\nconst MAX_HOLD_MS = 5000;\n\n/** Сколько времени на действие, если агент не сказал. */\nconst DEFAULT_HOLD_MS = 400;\n\nconst DIR_KEYS: Record<string, string> = {\n forward: \"KeyW\",\n back: \"KeyS\",\n left: \"KeyA\",\n right: \"KeyD\",\n};\n\nfunction clampMs(value: unknown): number {\n const ms = typeof value === \"number\" && value > 0 ? value : DEFAULT_HOLD_MS;\n return Math.min(ms, MAX_HOLD_MS);\n}\n\nfunction round(value: number, digits = 2): number {\n const k = 10 ** digits;\n return Math.round(value * k) / k;\n}\n\nconst toDegrees = (rad: number): number => round((rad * 180) / Math.PI, 1);\n\n/**\n * @param getGame доступ к текущему экземпляру игры. Именно функция, а не объект: сцена создаёт\n * `Game` в `mount()` и обнуляет в `destroy()`, а поверхность регистрируется один раз на `setup()`.\n */\nexport function createVoxelcraftAgentApi(\n getGame: () => Game | null,\n): ModuleAgentApi {\n /** Игра, готовая принимать команды: существует и не на паузе после действий агента. */\n function live(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n // Действие в игре, которая стоит на паузе, молча не сделало бы ничего — сначала берём\n // управление (это же снимает оверлей паузы, как клик игрока).\n game.input.takeControl();\n return game;\n }\n\n /** Удержать клавишу как живой игрок: нажали, подождали, отпустили. */\n function hold(code: string, ms: number): Promise<void> {\n const game = live();\n game.input.keys.add(code);\n return new Promise((resolve) => {\n setTimeout(() => {\n game.input.keys.delete(code);\n resolve();\n }, ms);\n });\n }\n\n /**\n * Удержать кнопку мыши (ломать/ставить обрабатываются по удержанию). Отпускаем не раньше, чем\n * нажатие увидели два кадра: в фоне кадр идёт раз в 200 мс, и «клик» на 120 мс игра не замечала.\n */\n function holdButton(button: \"left\" | \"right\", ms: number): Promise<void> {\n const game = live();\n if (button === \"left\") game.input.leftDown = true;\n else game.input.rightDown = true;\n const frame0 = game.loop.frameN;\n const t0 = performance.now();\n return new Promise((resolve) => {\n const check = () => {\n const seen = game.loop.frameN - frame0 >= 2;\n if (\n performance.now() - t0 < ms ||\n (!seen && performance.now() - t0 < 1000)\n ) {\n setTimeout(check, 20);\n return;\n }\n if (button === \"left\") game.input.leftDown = false;\n else game.input.rightDown = false;\n resolve();\n };\n setTimeout(check, 20);\n });\n }\n\n /** Игра без захвата управления: смена мира работает и на паузе. */\n function mounted(): Game {\n const game = getGame();\n if (!game) throw new Error(\"VoxelCraft is not mounted yet\");\n return game;\n }\n\n return {\n state() {\n const game = getGame();\n if (!game) return { mounted: false };\n\n const p = game.player;\n const held = game.inventory.heldItem();\n const src = game.world.source;\n\n return {\n mounted: true,\n // Идёт ли симуляция. false = игра на паузе (оверлей «Click to play»): в этом состоянии\n // персонаж не двигается вообще, и это НЕ баг движения.\n playing: game.playing,\n inputActive: game.input.active,\n player: {\n pos: { x: round(p.pos.x), y: round(p.pos.y), z: round(p.pos.z) },\n velocity: {\n x: round(p.vel.x),\n y: round(p.vel.y),\n z: round(p.vel.z),\n },\n yawDeg: toDegrees(p.yaw),\n pitchDeg: toDegrees(p.pitch),\n health: p.health,\n food: p.food,\n mode: p.mode,\n flying: p.flying,\n gliding: p.gliding,\n xpLevel: p.xpLevel,\n armor: game.inventory.armorStats().points,\n onGround: p.onGround,\n inWater: p.inWater,\n dead: p.dead,\n // на чём едет: minecart | boat | имя животного (horse); null — пешком\n riding: p.riding\n ? ((p.riding as { def?: { name: string } }).def?.name ??\n p.riding.kind)\n : null,\n },\n // открытый экран: inventory | crafting | chest | furnace; null — идёт игра\n screen: game.invUI.open ? game.invUI.kind : null,\n // идущий набег: волна, ожидание или бой, сколько разбойников живы\n raid: game.raid\n ? {\n wave: game.raid.wave,\n state: game.raid.state,\n raiders: game.raid.raiders.length,\n }\n : null,\n // открытые достижения (id; список — systems/Achievements)\n achievements: game.achievements.serialize(),\n // книга заданий (quests/QuestBook): что сейчас в трекере и сколько выполнено\n quests: {\n done: game.quests.entries().filter((e) => e.done).length,\n total: game.quests.entries().length,\n focus: game.quests.focus().map((e) => ({\n id: e.id,\n title: tr(e.def.title),\n progress: e.def.tasks.map((t, i) => [\n e.state.p[i] ?? 0,\n taskNeed(t),\n ]),\n })),\n },\n // своя карта (systems/WorldAtlas): метки игрока и к чему ведёт навигация\n map: {\n open: game.mapUI.open,\n pins: game.worldMap.pins.map((p) => ({\n id: p.id,\n name: p.name,\n x: p.x,\n z: p.z,\n dim: p.dim,\n })),\n guided: game.worldMap.track,\n questTarget: (() => {\n const t = game.questTracker.goal();\n return t ? { x: t.pos.x, y: t.pos.y, z: t.pos.z } : null;\n })(),\n },\n // блок под прицелом (то, что сломает ЛКМ и на что сработает ПКМ)\n lookingAt: (() => {\n const t = game.interaction.target;\n return t\n ? { block: BLOCKS[t.id]?.name ?? t.id, x: t.x, y: t.y, z: t.z }\n : null;\n })(),\n // мобы в 32 блоках: сколько каждого вида (что видит игрок вокруг)\n mobsNearby: (() => {\n const out: Record<string, number> = {};\n for (const e of game.entities.entities.values())\n if (e.kind === \"mob\" && !e.dead && e.pos.distanceTo(p.pos) < 32)\n out[e.def.name] = (out[e.def.name] ?? 0) + 1;\n return out;\n })(),\n // босс рядом (Дракон Предела): имя, здоровье — как полоса босса на экране\n boss: (() => {\n for (const e of game.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n return {\n name: e.def.name,\n hp: Math.round(e.hp),\n max: e.def.hp,\n pos: {\n x: round(e.pos.x),\n y: round(e.pos.y),\n z: round(e.pos.z),\n },\n };\n return null;\n })(),\n // Персонаж игрока: рецепт (имя, скелет, хэш), камера F5, жест и эмоция, надетое, студия\n character: {\n name: game.character.spec.name,\n rig: game.character.spec.rig,\n hash: specHash(game.character.spec),\n camera: game.character.camera,\n emote: game.character.actor.animator.emoting,\n emotion: game.character.actor.animator.emotion,\n state: game.character.actor.animator.state,\n outfit: { ...game.character.outfit },\n heldSkin: stackSkinName(held),\n },\n world: {\n id: game.worldId,\n name: game.worldName,\n // \"noise\" — классический мир по сиду; \"spec\" — мир по рецепту (картинка/описание/ИИ)\n source: src.kind,\n // поколение генератора: 1 — прежний мир, 2 — высота 128, биомы, пещеры\n gen: src.gen ?? 1,\n dimension: game.dimension,\n // погода: clear | rain | thunder (в Пекле и Пределе её не видно)\n weather: game.weather.kind,\n // сложность: peaceful | easy | normal | hard (systems/Difficulty)\n difficulty: difficulty(),\n seed: game.world.seed,\n waterLevel: game.world.waterLevel,\n spawnHint: game.world.generator.spawnHint(),\n ...(src.kind === \"spec\"\n ? {\n size: src.spec.size,\n shape: src.spec.terrain.shape,\n biomes: src.spec.zones.map((z) => z.biome),\n structures: src.spec.structures.map((s) => s.type),\n fromPicture: Boolean(src.spec.terrain.heightmap),\n }\n : {}),\n loadedChunks: game.world.chunks.size,\n // производительность: мс кадра на генерацию/мешинг в главном потоке, очереди, воркеры,\n // draw call'ы и треугольники мирового прохода (оптимизации — docs/feature-history/voxelcraft-performance.md)\n perf: {\n fps: game.loop.fps,\n genMs: +worldPerf.genMs.toFixed(2),\n meshMs: +worldPerf.meshMs.toFixed(2),\n genQueue: worldPerf.genQueue,\n dirtyChunks: worldPerf.dirty,\n workers: worldPerf.workers,\n workerJobs: worldPerf.workerJobs,\n lodChunks: worldPerf.lodChunks,\n drawCalls: game.renderStats.calls,\n triangles: game.renderStats.triangles,\n },\n // Блок под ногами: по нему видно и «стоит на траве», и «провалился в пустоту».\n blockBelow: game.world.getBlock(\n Math.floor(p.pos.x),\n Math.floor(p.pos.y - 0.1),\n Math.floor(p.pos.z),\n ),\n },\n inventory: {\n hotbarIndex: game.inventory.hotbarIndex,\n inHand: held ? { item: held.item, count: held.count } : null,\n },\n // Сетевая игра: null — одиночная. owner — этот клиент симулирует своё измерение (мобы,\n // жидкости, блочные обновления); false — он реплика и показывает то, что шлёт владелец.\n net: game.net\n ? {\n host: game.net.isHost,\n owner: sim.authority,\n edits: game.net.diffs.count(game.dimension),\n players: game.net.playerList().map((pl) => {\n const pose = game.net?.peers.get(pl.slot)?.pose;\n return {\n name: pl.name,\n host: pl.host,\n op: pl.op,\n dim: pl.dim,\n rttMs: Math.round(pl.rtt),\n pos:\n pl.userId === game.net?.selfId\n ? {\n x: round(p.pos.x),\n y: round(p.pos.y),\n z: round(p.pos.z),\n }\n : pose\n ? {\n x: round(pose.x),\n y: round(pose.y),\n z: round(pose.z),\n }\n : null,\n };\n }),\n }\n : null,\n };\n },\n\n actions: {\n move: async (args) => {\n const dir = String(args?.dir ?? \"forward\");\n const code = DIR_KEYS[dir];\n if (!code)\n throw new Error(\n `unknown dir '${dir}' — use forward | back | left | right`,\n );\n await hold(code, clampMs(args?.ms));\n },\n\n jump: async (args) => {\n await hold(\"Space\", clampMs(args?.ms ?? 120));\n },\n\n look: (args) => {\n const game = live();\n const dYaw = Number(args?.yawDeg ?? 0) * (Math.PI / 180);\n const dPitch = Number(args?.pitchDeg ?? 0) * (Math.PI / 180);\n // Через штатный метод игрока — те же ограничения по pitch, что и у мыши.\n game.player.look(dYaw, dPitch);\n },\n\n breakBlock: async (args) => {\n // Ломание идёт по удержанию ЛКМ: короткий «клик» не сломает ничего, кроме мгновенных блоков.\n await holdButton(\"left\", clampMs(args?.ms ?? 1200));\n },\n\n placeBlock: async () => {\n await holdButton(\"right\", 120);\n },\n\n /**\n * Удержать кнопку мыши столько, сколько нужно ДЕЙСТВИЮ, а не одному клику: натянуть лук,\n * зарядить арбалет, выпить зелье, закрыться щитом, размахнуться трезубцем. Без этого действия\n * агент умел только «тапнуть», и все такие вещи проверялись лишь тестами.\n */\n holdButton: async (args) => {\n const button = args?.button === \"left\" ? \"left\" : \"right\";\n await holdButton(button, clampMs(args?.ms ?? 1200));\n },\n\n selectSlot: (args) => {\n const n = Number(args?.slot ?? 1);\n if (!Number.isInteger(n) || n < 1 || n > 9)\n throw new Error(\"slot must be 1..9\");\n live().intents.push(Intent.SelectSlot, { slot: n - 1 });\n },\n\n toggleMode: () => {\n live().intents.push(Intent.ToggleMode, {});\n },\n\n toggleInventory: () => {\n // то же событие, что клавиша E и тач-кнопка: в креативе — палитра всех предметов\n live();\n events.emit(\"ui:toggleInventory\");\n },\n\n respawn: () => {\n // та же кнопка «Возродиться», что на экране смерти\n const game = mounted();\n if (!game.player.dead) throw new Error(\"the player is alive\");\n game.respawnPlayer();\n },\n\n command: async (args) => {\n // Тем же путём, что строка консоли игрока: интент Command → исполнение на тике.\n const game = live();\n const text = String(args?.text ?? \"\").trim();\n if (!text)\n throw new Error(\"text is required, e.g. { text: '/give torch 16' }\");\n const before = game.commands.log.length;\n game.intents.push(Intent.Command, {\n text: text.startsWith(\"/\") ? text : \"/\" + text,\n });\n for (let i = 0; i < 40 && game.commands.log.length === before; i++)\n await new Promise((r) => setTimeout(r, 50));\n return { output: game.commands.log.slice(before) };\n },\n\n entitiesNear: (args) => {\n // Ближайшие сущности (только чтение): вид, id, позиция, дистанция — прицелиться и сверить\n const game = mounted();\n const r = Math.min(64, Math.max(1, Number(args?.radius ?? 16)));\n const p = game.player.pos;\n const out: ({\n id: number;\n kind: string;\n x: number;\n y: number;\n z: number;\n dist: number;\n } & Record<string, unknown>)[] = [];\n for (const e of game.entities.entities.values()) {\n if (e.dead) continue;\n const dist = e.pos.distanceTo(p);\n if (dist > r) continue;\n out.push({\n id: e.id,\n kind: e.kind,\n ...(e.kind === \"mob\"\n ? {\n type: e.def.name,\n // состояние от действий игрока — сверить у двух игроков сети\n ...(e.sheared ? { sheared: true } : {}),\n ...(e.tamed ? { tamed: true } : {}),\n ...(e.saddled ? { saddled: true } : {}),\n ...(e.leashed ? { leashed: true } : {}),\n ...(e.customName ? { name: e.customName } : {}),\n }\n : {}),\n x: round(e.pos.x),\n y: round(e.pos.y),\n z: round(e.pos.z),\n dist: round(dist),\n ...(\"rider\" in e && e.rider ? { rider: true } : {}),\n });\n }\n return out.sort((a, b) => a.dist - b.dist).slice(0, 30);\n },\n\n blockAt: (args) => {\n // Прочитать блок мира по координатам (только чтение) — сверить мир у двух игроков сети.\n const game = mounted();\n const x = Math.floor(Number(args?.x));\n const y = Math.floor(Number(args?.y));\n const z = Math.floor(Number(args?.z));\n if (![x, y, z].every(Number.isFinite))\n throw new Error(\"x, y, z are required numbers\");\n const id = game.world.getBlock(x, y, z);\n return { block: BLOCKS[id]?.name ?? id, id };\n },\n\n generateWorld: (args) => {\n const game = mounted();\n const key = String(args?.size ?? \"island\");\n const size =\n key === \"large\" || key === \"infinite\"\n ? SIZE_PRESETS[key]\n : SIZE_PRESETS.island;\n const seed = Number(args?.seed);\n const spec = buildSpecWithoutAI({\n text: String(args?.text ?? \"\"),\n size: { ...size },\n seed: Number.isFinite(seed)\n ? seed >>> 0\n : (Math.random() * 0xffffffff) >>> 0,\n });\n game.newWorld({ kind: \"spec\", spec });\n return {\n name: spec.name,\n structures: spec.structures.map((s) => s.type),\n };\n },\n\n loadCharacterSpec: (args) => {\n const game = mounted();\n const r = parseCharacterSpec(args?.spec);\n if (!r.ok) throw new Error(r.error);\n if (args?.play === false) game.character.setSpec(r.spec);\n else void game.character.useCharacter(r.spec);\n return {\n name: r.spec.name,\n rig: r.spec.rig,\n hash: specHash(r.spec),\n warnings: r.warnings,\n };\n },\n\n loadItemSkin: (args) => {\n const game = mounted();\n const r = parseItemSkin(args?.spec);\n if (!r.ok) throw new Error(r.error);\n const skin = registerSkin(r.spec);\n void game.character.saveItem(r.spec);\n if (r.spec.kind === \"wearable\" && r.spec.slot && args?.wear !== false)\n game.character.wear(r.spec.slot, skin.hash);\n if (r.spec.kind === \"held\" && r.spec.base && args?.give !== false)\n game.inventory.addStack({\n item: r.spec.base,\n count: 1,\n tag: { skin: skin.hash },\n });\n return {\n name: r.spec.name,\n kind: r.spec.kind,\n base: r.spec.base,\n slot: r.spec.slot,\n hash: skin.hash,\n warnings: r.warnings,\n };\n },\n\n emote: (args) => {\n const id = String(args?.id ?? \"\");\n if (!isEmote(id)) throw new Error(\"emotes: \" + EMOTE_IDS.join(\", \"));\n mounted().character.emote(id);\n return { emote: id };\n },\n\n setEmotion: (args) => {\n const e = String(args?.emotion ?? \"\");\n if (!isEmotion(e)) throw new Error(\"emotions: \" + EMOTIONS.join(\", \"));\n const sec = Number(args?.seconds);\n mounted().character.feel(e, Number.isFinite(sec) ? sec : 8);\n return { emotion: e };\n },\n\n setCamera: (args) => {\n const game = mounted();\n const mode = String(args?.mode ?? \"\");\n if (mode !== \"first\" && mode !== \"back\" && mode !== \"front\")\n throw new Error(\"mode: first | back | front\");\n game.character.camera = mode;\n return { camera: mode };\n },\n\n spawnCharacter: (args) => {\n const game = mounted();\n const behavior = String(args?.behavior ?? \"npc\");\n if (!(CHAR_BEHAVIORS as readonly string[]).includes(behavior))\n throw new Error(\"behavior: \" + CHAR_BEHAVIORS.join(\" | \"));\n let spec = game.character.spec;\n if (args?.spec !== undefined) {\n const r = parseCharacterSpec(args.spec);\n if (!r.ok) throw new Error(r.error);\n spec = r.spec;\n }\n const m = game.summonCharacter(spec, behavior as CharBehavior);\n if (!m) throw new Error(\"could not summon\");\n return { id: m.id, type: m.def.name, name: spec.name };\n },\n\n completeQuest: (args) => {\n const id = String(args?.id ?? \"\");\n const full = id.includes(\":\") ? id : \"local:\" + id;\n return { done: mounted().quests.complete(full) };\n },\n\n openQuests: () => {\n mounted().questBookUI.show();\n return { open: mounted().questBookUI.open };\n },\n\n openHandbook: (args) => {\n const item = args?.item === undefined ? null : String(args.item);\n mounted().catalogUI.show(item);\n return { open: mounted().catalogUI.open };\n },\n\n openMap: () => {\n mounted().mapUI.show();\n return { open: mounted().mapUI.open };\n },\n\n addPin: (args) => {\n const game = mounted();\n const p = game.player.pos;\n const x = Number(args?.x ?? p.x),\n z = Number(args?.z ?? p.z);\n if (!Number.isFinite(x) || !Number.isFinite(z))\n throw new Error(\"x and z must be numbers\");\n const pin = game.worldMap.addPin({\n dim: game.dimension,\n x,\n z,\n ...(args?.x === undefined ? { y: Math.floor(p.y) } : {}),\n name: String(args?.name ?? \"Pin\"),\n color: Number(args?.color ?? 0),\n });\n if (!pin) throw new Error(\"too many pins\");\n if (args?.guide) game.worldMap.setTrack(pin.id);\n return { id: pin.id };\n },\n\n guidePin: (args) => {\n const id = args?.id === null ? null : Number(args?.id);\n mounted().worldMap.setTrack(id);\n return { guided: mounted().worldMap.track };\n },\n\n removePin: (args) => {\n mounted().worldMap.removePin(Number(args?.id));\n return { pins: mounted().worldMap.pins.length };\n },\n\n openStudio: (args) => {\n const tab = String(args?.tab ?? \"char\");\n const id =\n tab === \"item\"\n ? FEATURE.items\n : tab === \"mine\"\n ? FEATURE.locker\n : tab === \"worlds\"\n ? FEATURE.worlds\n : FEATURE.character;\n return { open: mounted().hub.openTab(id) };\n },\n\n loadWorldSpec: (args) => {\n const r = parseWorldSpec(args?.spec);\n if (!r.ok) throw new Error(r.error);\n mounted().newWorld({ kind: \"spec\", spec: r.spec });\n return { name: r.spec.name, warnings: r.warnings };\n },\n },\n\n describeActions: {\n move: \"Walk: { dir: forward|back|left|right, ms?: number } — holds the movement key for ms (default 400, max 5000).\",\n jump: \"Jump: { ms?: number } — taps Space.\",\n look: \"Turn the camera by a DELTA in degrees: { yawDeg?: number, pitchDeg?: number }. Positive pitch looks up.\",\n breakBlock:\n \"Hold left mouse to break the block under the crosshair: { ms?: number } (default 1200 — breaking takes time).\",\n placeBlock:\n \"Tap right mouse to place the block from the selected hotbar slot.\",\n holdButton:\n \"Hold a mouse button for a while: { button?: 'left'|'right' (default right), ms?: number } (default 1200, max 5000). This is how you draw a bow, load a crossbow, drink a potion, raise a shield or wind up a trident — a tap is too short for them.\",\n selectSlot: \"Select a hotbar slot: { slot: 1..9 }.\",\n toggleMode: \"Switch between survival and creative.\",\n toggleInventory:\n \"Open/close the inventory like the E key (state.screen shows which one: inventory, or creative — every item with search — in creative mode).\",\n respawn:\n \"Press 'Respawn' on the death screen (player.dead = true): back to the bed or the world spawn.\",\n command:\n \"Run a console command like a player typing it in chat: { text: '/give torch 16' }. Commands: /help, /give <item> [n], /tp x y z, /time set day|noon|night|midnight|<sec>, /weather clear|rain|thunder [sec],/difficulty peaceful|easy|normal|hard, /gamemode survival|creative, /setblock x y z <block> [meta], /fill x1 y1 z1 x2 y2 z2 <block>, /summon <mob> [x y z], /kill [mob] (the player, or every mob of that kind within 64 blocks — a normal death with drops, slimes split), /clear, /seed, /locate fortress|citadel|village (nearest structure of this dimension), /enchant <efficiency|unbreaking|fortune|silk_touch|sharpness|protection|power> [level] (the held item), /newworld [seed] (a new seed world of the current generation; the old world stays in 'My worlds'). Coordinates accept ~ (relative). Minecraft names (grass_block, oak_planks) are accepted. Returns the output lines.\",\n entitiesNear:\n \"Nearest entities (read-only): { radius?: 1..64 (default 16) } → up to 30 of { id, kind, type?, x, y, z, dist, rider?, sheared?, tamed?, saddled?, leashed?, name? }, nearest first.\",\n blockAt:\n \"Read one block of the current dimension (read-only): { x, y, z } → { block, id }. In a network game compare it across players to check world sync.\",\n generateWorld:\n \"Build a new world WITHOUT AI from a description (Russian/English keywords): { text: string, size?: island|large|infinite, seed?: number }. Replaces the current world (the old one stays in 'My worlds').\",\n loadCharacterSpec:\n \"Become a character from a CharacterSpec recipe: { spec: object, play?: boolean } (see the voxelcraft-characters skill; play=false only shows it). Saved to 'My characters'. Returns name, rig, hash and warnings.\",\n loadItemSkin:\n \"Add an item look from an ItemSkinSpec recipe: { spec: object, wear?: boolean, give?: boolean } — a wearable is put on (unless wear=false), a held look is given as its base item with that look (unless give=false). Saved to 'My items'.\",\n emote:\n \"Play an emote of the player's character (others see it in a network game): { id: wave|cheer|dance|point|laugh|cry|bow|shrug|facepalm|sit }.\",\n setEmotion:\n \"Show an emotion on the character's face and posture for a while: { emotion: neutral|happy|sad|angry|surprised|scared|tired|hurt, seconds?: number }.\",\n setCamera:\n \"Camera like F5: { mode: first|back|front } — back/front show the player's own character.\",\n spawnCharacter:\n \"Summon a creature with a character look 2 blocks ahead: { behavior: npc|passive|neutral|hostile|pet, spec?: CharacterSpec } (default — the player's own look). Stats come from the behavior preset, not from the recipe.\",\n completeQuest:\n \"Mark a quest of the quest book complete (its reward is given): { id } — 'wood' or a full id 'local:wood'. state().quests lists what is being tracked.\",\n openQuests:\n \"Open the quest book (J): chapters, quests, hints and rewards.\",\n openHandbook:\n \"Open the handbook (R): how to get an item, its full recipe tree down to raw materials (where to mine, which tool, ore depth), and what it is used for: { item?: id }.\",\n openMap:\n \"Open the big map (M): explored land, pins, the quest target. state().map lists pins and what is guided.\",\n addPin:\n \"Put a map pin: { x?, z?, name?, color?: 0-6, guide?: boolean } — no x/z: where the player stands. guide: the compass, minimap and on-screen marker lead to it.\",\n guidePin:\n \"Lead the compass / minimap / on-screen marker to a pin: { id } — null: back to the quest target.\",\n removePin: \"Delete a map pin: { id }.\",\n openStudio:\n \"Open a VoxelCraft tab of the SDK lobby (pictures + description → world/character/item, with or without AI): { tab?: char|item|mine|worlds }. Returns { open: false } without a lobby.\",\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"
|
|
98
99
|
},
|
|
99
100
|
{
|
|
100
101
|
"path": "ai/characterAi.ts",
|
|
101
|
-
"content": "// ИИ-генерация персонажа и облика предмета поверх client.ai: фичи тайтла \"voxel-character\" и\n// \"voxel-item\" (Text с картинками — как \"voxel-world\"). Механика общая (ai/specAi), здесь — запрос,\n// промпт и разбор ответа в рецепт (character/CharacterSpec, character/items/ItemSkinSpec).\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport { CONFIG } from \"../config\";\nimport {\n parseCharacterSpec,\n type CharacterSpec,\n
|
|
102
|
+
"content": "// ИИ-генерация персонажа и облика предмета поверх client.ai: фичи тайтла \"voxel-character\" и\n// \"voxel-item\" (Text с картинками — как \"voxel-world\"). Механика общая (ai/specAi), здесь — запрос,\n// промпт и разбор ответа в рецепт (character/CharacterSpec, character/items/ItemSkinSpec).\nimport type { IDosGamesClient } from \"@idosgames/core\";\nimport { CONFIG } from \"../config\";\nimport {\n parseCharacterSpec,\n type CharacterSpec,\n type RigId,\n} from \"@idosgames/characters/voxel\";\nimport {\n parseItemSkin,\n type ItemSkinSpec,\n type SkinKind,\n type WearSlot,\n} from \"../character/items/ItemSkinSpec\";\nimport { buildCharacterPrompt, buildItemPrompt } from \"./characterPrompt\";\nimport { createSpecAI, type SpecAI, type SpecAIOptions } from \"./specAi\";\n\n/** Картинка-образец и с какой стороны на ней персонаж или предмет. */\nexport interface RefImage {\n dataUrl: string;\n contentType: string;\n bytes: number;\n view?: \"front\" | \"side\" | \"back\" | \"detail\" | \"any\";\n}\n\nexport interface CharacterAIRequest {\n text: string;\n images: RefImage[];\n /** Шаблон скелета; \"auto\" — выберет модель. */\n rig?: RigId | \"auto\";\n /** Доработка: прошлый рецепт и просьба в text («сделай шляпу красной»). */\n previous?: CharacterSpec | null;\n}\n\nexport interface ItemAIRequest {\n text: string;\n images: RefImage[];\n kind?: SkinKind | \"auto\";\n base?: string;\n slot?: WearSlot;\n previous?: ItemSkinSpec | null;\n}\n\nexport type CharacterAI = SpecAI<CharacterAIRequest, CharacterSpec>;\nexport type ItemAI = SpecAI<ItemAIRequest, ItemSkinSpec>;\n\nconst images = (req: { images: RefImage[] }) =>\n req.images.map((im) => ({\n dataUrl: im.dataUrl,\n contentType: im.contentType,\n }));\n\nexport function createClientCharacterAI(\n client: IDosGamesClient,\n opts: SpecAIOptions = {},\n): CharacterAI {\n return createSpecAI<CharacterAIRequest, CharacterSpec, RigId | null>(\n client,\n {\n featureKey: CONFIG.ai.characterFeatureKey,\n pendingName: \"ai-character\",\n buildPrompt: buildCharacterPrompt,\n images,\n extra: (req) => (req.rig && req.rig !== \"auto\" ? req.rig : null),\n parse(text, rig) {\n const r = parseCharacterSpec(text);\n if (!r.ok) return { ok: false, truncated: r.truncated };\n // скелет выбирал игрок — модель его не меняет\n if (rig && r.spec.rig !== rig) {\n const again = parseCharacterSpec({ ...r.spec, rig });\n if (again.ok)\n return { ok: true, spec: again.spec, warnings: r.warnings };\n }\n return r;\n },\n texts: {\n what: { en: \"characters\", ru: \"персонажей\" },\n working: {\n en: \"AI is putting the character together…\",\n ru: \"ИИ собирает персонажа…\",\n },\n login: {\n en: \"Sign in to create characters with AI.\",\n ru: \"Войдите в игру, чтобы создавать персонажей с ИИ.\",\n },\n failed: {\n en: \"AI could not make the character.\",\n ru: \"ИИ не смог собрать персонажа.\",\n },\n invalid: {\n en: \"AI did not answer with a character recipe.\",\n ru: \"ИИ ответил не рецептом персонажа.\",\n },\n truncated: {\n en: \"The AI answer was cut off — the character came out too detailed.\",\n ru: \"Ответ ИИ оборван — персонаж получился слишком подробным.\",\n },\n resuming: {\n en: \"Waiting for the character from last time…\",\n ru: \"Дожидаюсь персонажа с прошлого раза…\",\n },\n slow: {\n en: \"AI is taking too long. Open the studio later — the result will wait for you.\",\n ru: \"ИИ отвечает слишком долго. Откройте студию позже — результат дождётся вас.\",\n },\n },\n },\n { pollMs: CONFIG.ai.pollMs, timeoutMs: CONFIG.ai.pollTimeoutMs, ...opts },\n );\n}\n\nexport function createClientItemAI(\n client: IDosGamesClient,\n opts: SpecAIOptions = {},\n): ItemAI {\n return createSpecAI<\n ItemAIRequest,\n ItemSkinSpec,\n { kind: SkinKind | null; base: string | null; slot: WearSlot | null }\n >(\n client,\n {\n featureKey: CONFIG.ai.itemFeatureKey,\n pendingName: \"ai-item\",\n buildPrompt: buildItemPrompt,\n images,\n extra: (req) => ({\n kind: req.kind && req.kind !== \"auto\" ? req.kind : null,\n base: req.base ?? null,\n slot: req.slot ?? null,\n }),\n parse(text, want) {\n const r = parseItemSkin(text);\n if (!r.ok) return { ok: false, truncated: r.truncated };\n // вид, базовый предмет и слот выбирал игрок — модель их не меняет\n const fix: Record<string, unknown> = {};\n if (want.kind && r.spec.kind !== want.kind) fix.kind = want.kind;\n if (want.base && r.spec.base !== want.base) fix.base = want.base;\n if (want.slot && r.spec.slot !== want.slot) fix.slot = want.slot;\n if (Object.keys(fix).length) {\n const again = parseItemSkin({ ...r.spec, ...fix });\n if (again.ok)\n return { ok: true, spec: again.spec, warnings: r.warnings };\n }\n return r;\n },\n texts: {\n what: { en: \"items\", ru: \"предметов\" },\n working: { en: \"AI is crafting the item…\", ru: \"ИИ мастерит предмет…\" },\n login: {\n en: \"Sign in to create items with AI.\",\n ru: \"Войдите в игру, чтобы создавать предметы с ИИ.\",\n },\n failed: {\n en: \"AI could not craft the item.\",\n ru: \"ИИ не смог смастерить предмет.\",\n },\n invalid: {\n en: \"AI did not answer with an item recipe.\",\n ru: \"ИИ ответил не рецептом предмета.\",\n },\n truncated: {\n en: \"The AI answer was cut off — the item came out too detailed.\",\n ru: \"Ответ ИИ оборван — предмет получился слишком подробным.\",\n },\n resuming: {\n en: \"Waiting for the item from last time…\",\n ru: \"Дожидаюсь предмета с прошлого раза…\",\n },\n slow: {\n en: \"AI is taking too long. Open the studio later — the result will wait for you.\",\n ru: \"ИИ отвечает слишком долго. Откройте студию позже — результат дождётся вас.\",\n },\n },\n },\n { pollMs: CONFIG.ai.pollMs, timeoutMs: CONFIG.ai.pollTimeoutMs, ...opts },\n );\n}\n"
|
|
102
103
|
},
|
|
103
104
|
{
|
|
104
105
|
"path": "ai/characterPrompt.ts",
|
|
105
|
-
"content": "// Промпты ИИ-генерации персонажа и облика предмета.\n//\n// VOXEL_CHARACTER_SYSTEM_PROMPT / VOXEL_ITEM_SYSTEM_PROMPT — системные промпты фич тайтла\n// \"voxel-character\" и \"voxel-item\". Как и у мира (ai/worldSpecPrompt), живут они в КОНФИГЕ тайтла\n// (cfg.AI.Features[...].Behavior.SystemInstructions), здесь — эталон, который издатель копирует.\n// Меняется формат рецепта — меняется этот текст; тест держит в нём все допустимые значения.\nimport {\n EXPRESSIONS,\n IDLE_STYLES,\n WALK_STYLES,\n} from \"../character/CharacterSpec\";\nimport { EXTRA_BONE_ROLES, RIGS, RIG_IDS } from \"../character/rigs\";\nimport { GRIP_POSES, WEAR_SLOT_IDS } from \"../character/items/ItemSkinSpec\";\nimport { MODEL_SHAPES } from \"../worldgen/WorldSpec\";\nimport type { CharacterAIRequest, ItemAIRequest } from \"./characterAi\";\n\nconst rigLines = RIG_IDS.map((id) => {\n const r = RIGS[id];\n const bones = r.bones\n .map((b) => `${b.name}@[${b.pivot.join(\",\")}]`)\n .join(\" \");\n const body = r.defaultParts\n .map((p) => `${p.bone} ${p.size.join(\"x\")} at [${p.pos.join(\",\")}]`)\n .join(\"; \");\n return `- \"${id}\" — ${r.hint}. Bones (name@pivot): ${bones}. Default body: ${body}.`;\n}).join(\"\\n\");\n\nexport const VOXEL_CHARACTER_SYSTEM_PROMPT = `You design blocky voxel characters for a Minecraft-like game. Reply with ONE JSON object — a CharacterSpec — and nothing else: no markdown, no comments, no explanations.\n\nThe game builds and animates the character from your recipe. You give the SHAPE (boxes on a skeleton), the COLOURS (a palette) and small PIXEL-ART pictures for faces and clothes. Animations (walk, run, sneak, swim, fly, emotes, emotions) come from the skeleton — you never animate.\n\nCOORDINATES are in model PIXELS (16 px = 1 block): x to the character's RIGHT hand, y UP (y = 0 is the soles of the feet), z BACKWARDS — the character LOOKS TOWARD -z (its face is on the -z side). A normal person is 32 px tall.\n\nSKELETONS (\"rig\"):\n${rigLines}\n\nCharacterSpec:\n{\n \"version\": 1,\n \"name\": string (<= 40 chars, in the player's language),\n \"description\": string (<= 200 chars),\n \"rig\": ${RIG_IDS.map((r) => `\"${r}\"`).join(\" | \")},\n \"scale\": 0.25..3 (overall size; 1 = normal),\n \"palette\": { \"<one character>\": \"#rrggbb\", ... } (<= 32 colours; \".\" and \" \" are transparent),\n \"textures\": { \"<id>\": { \"w\": 1..32, \"h\": 1..32, \"rows\": [ \"<w palette characters>\", ... h rows, top row first ] } },\n \"parts\": [ <= 64 boxes:\n { \"bone\": \"<bone>\", \"size\": [w, h, d] (px, <= 32), \"pos\": [x, y, z] (the box CENTRE in model px),\n \"color\": \"<palette char>\" (faces without a texture),\n \"faces\": { \"front\"|\"back\"|\"left\"|\"right\"|\"top\"|\"bottom\": \"<texture id>\" or \"<palette char>\" },\n \"rot\": [x, y, z] (degrees, optional tilt around the box centre),\n \"mirror\": true (also adds the mirrored copy on the paired bone: armL -> armR, legFL -> legFR),\n \"name\": string } ],\n \"bones\": [ <= 16 EXTRA bones for tails, ears, wings, capes, hair, antennas:\n { \"name\": string, \"parent\": \"<bone>\", \"pivot\": [x, y, z], \"role\": ${EXTRA_BONE_ROLES.map((r) => `\"${r}\"`).join(\" | \")} } ]\n (a role makes the bone move by itself: a tail sways, ears twitch, a cape flutters when running, wings flap in flight; \"static\" — no motion),\n \"face\": { \"bone\": \"head\", \"rect\": [x, y, w, h] (where the face sits on the FRONT face of the head box, px from its top-left corner as seen by a viewer),\n \"expressions\": { ${EXPRESSIONS.map((e) => `\"${e}\"`).join(\", \")}: \"<texture id of size w x h>\" } } — eyes and mouth, drawn over the head; \"neutral\" is required, draw as many others as you can (at least blink, happy, sad, angry, surprised),\n \"style\": { \"walk\": ${WALK_STYLES.map((s) => `\"${s}\"`).join(\" | \")}, \"idle\": ${IDLE_STYLES.map((s) => `\"${s}\"`).join(\" | \")}, \"energy\": 0..1 },\n \"voice\": { \"pitch\": 0.5..2 },\n \"tags\": [ <= 8 short words ],\n \"finish\": { \"noise\": 0..1, \"outline\": 0..1, \"shade\": 0..1, \"density\": 1..4 } (optional; defaults 0.5 / 0.4 / 0.25 / 2 — the game adds fabric noise, a dark outline on the edge of every box face and top-to-bottom shading by itself; set 0 for a clean flat look)\n}\n\nFACES OF A BOX as seen from OUTSIDE: \"front\" is on the -z side, left/right are the CHARACTER's left (-x) and right (+x). A texture is stretched over the whole face, its first row is the top. On the front face the viewer's LEFT is the character's RIGHT hand side. The top face is seen from above with the back edge as its first row.\n\nHOW TO BUILD A GOOD, DETAILED CHARACTER (the game's style: blocky people with fine pixel-art clothes, like a voxel animation):\n- Draw textures at 2 texels per model pixel: an 8x8 head face is a 16x16 texture, an 8x12 torso front is 16x24, a 3x12 arm side is 6x24, a 4x12 leg side is 8x24. Use 2–3 shades of every colour (base, lighter, darker) for seams, folds, collars, cuffs and highlights; use soft, natural colours unless the pictures say otherwise.\n- LAYER BOXES for volume — this is what makes a character look rich:\n hair: a slightly bigger cap over the head (e.g. [8.6, 1.6, 8.6] at y 32.2) plus a thin piece on the back of the head, curls or bangs as small boxes, long hair as thin boxes down the back and the sides;\n a nose: a 1x1.5x0.8 box sticking out of the face; ears, beards, glasses, hats (a wide thin brim + a crown) — boxes on \"head\";\n clothes: sleeves as a box slightly bigger than the arm covering its top part (short, rolled or full length, with a lighter cuff band at the bottom); a belt box around the waist; collars, hoods, scarves, vests, armour plates, pockets, backpacks;\n shoes: a box slightly bigger than the bottom of the leg (boots ~4.5 px tall with laces drawn on the front, sneakers ~2.5 px with a white sole).\n Overlay boxes are 0.2–0.6 px bigger than what they cover so they never flicker.\n- Slim arms (3 px wide, at x = ±5.5) look more natural for people; 4 px for strong ones.\n- Start from the default body of the skeleton and change proportions to match the pictures: a big head for cute characters, long legs for elegant ones, a wide body for strong ones. Keep boxes on the right bones — a hat on \"head\", a backpack or a cape on \"body\", a sword hand on \"armR\".\n- Use \"mirror\": true for every symmetric pair (arms, legs, sleeves, shoes, ears, wings) — describe the left one only.\n- Clothes in the textures: a shirt front with a collar, a button placket and pockets; a striped t-shirt; a knitted sweater with a pattern band and a ribbed hem; trousers with a crease; shorts with skin below and socks; the belt with a buckle.\n- The head front texture: hair on the top rows and the sides, skin elsewhere, a slightly darker chin row; the eyes, brows and mouth go into \"face\" (e.g. rect [0, 3, 8, 5] with 16x10 pictures): white eyes 3 texels wide with a coloured iris in the middle, brows above them, a 4-texel mouth — one picture per expression, the same size, transparent where the skin shows through.\n- Add detail with extra boxes: hair fringe, a hood, armour plates, a crown, horns, a tail (an extra bone with role \"tail\").\n- When the player sends pictures (possibly of ONE character from different angles: front, side, back) study EVERY picture: get the silhouette, proportions, colours, clothes and the face right. Pictures can be photos, drawings or 3D renders — turn them into a clean blocky version.`;\n\nexport const VOXEL_ITEM_SYSTEM_PROMPT = `You design blocky voxel items for a Minecraft-like game. Reply with ONE JSON object — an ItemSkinSpec — and nothing else: no markdown, no comments, no explanations.\n\nThere are two kinds:\n- \"held\": a new LOOK for an existing game item (\"base\"): a sword stays an iron_sword in the game (damage, durability), only its model and icon change. Common bases: wooden_sword, stone_sword, iron_sword, golden_sword, diamond_sword, iron_axe, iron_pickaxe, iron_shovel, iron_hoe, bow, crossbow, shield, trident, stick, fishing_rod.\n- \"wearable\": cosmetics on the body — slot ${WEAR_SLOT_IDS.map((s) => `\"${s}\"`).join(\" | \")} (a hat on \"head\", glasses or a mask on \"face\", a cape, wings or a backpack on \"back\", pauldrons on \"shoulders\", a belt or a skirt on \"waist\").\n\nThe model is a grid of PIXELS (1 px = 1/16 block): x to the right, y UP, z BACKWARDS (the front of the item and of the wearer is the -z side).\n- A held item is built STANDING UP as if held in the right hand: the handle at \"grip.at\", the blade or tip pointing UP (+y), the flat side facing -z. A sword is about 16–20 px long, an axe 14–18, a staff 24–32.\n- A wearable is built around its attachment point \"grip.at\": for \"head\" it is the CENTRE OF THE TOP of the head (the head is 8x8x8 px, so a hat brim is about 10–12 px wide, built upward from y = grip y); for \"back\" it is the middle of the back surface (the torso is 8 px wide and 12 px tall, the cape hangs down behind, toward +z); for \"face\" — the middle of the face (the face is on the -z side); for \"shoulders\" — the top middle of the torso; for \"waist\" — the middle of the belt.\n\nItemSkinSpec:\n{\n \"version\": 1,\n \"name\": string (<= 40 chars, in the player's language),\n \"description\": string (<= 200 chars),\n \"kind\": \"held\" | \"wearable\",\n \"base\": \"<game item>\" (held only),\n \"slot\": \"<slot>\" (wearable only),\n \"size\": [x, y, z] (grid size, each 1..32),\n \"grip\": { \"at\": [x, y, z], \"pose\": ${GRIP_POSES.map((p) => `\"${p}\"`).join(\" | \")} (held only) },\n \"palette\": { \"<one character>\": \"#rrggbb\", ... } (<= 16 colours),\n \"parts\": [ <= 48 shapes drawn in order, a later one overwrites an earlier one:\n { \"shape\": \"box\", \"color\": c, \"from\": [x,y,z], \"to\": [x,y,z], \"hollow\": true|false }\n { \"shape\": \"cylinder\", \"color\": c, \"axis\": \"x\"|\"y\"|\"z\", \"center\": [x,y,z], \"radius\": r, \"length\": n } — \"center\" is the middle of the start cap\n { \"shape\": \"cone\", same fields + \"radius2\": radius at the far end (0 = a point) }\n { \"shape\": \"sphere\", \"color\": c, \"center\": [x,y,z], \"radii\": [rx, ry, rz] }\n { \"shape\": \"line\", \"color\": c, \"from\": [x,y,z], \"to\": [x,y,z], \"radius\": 0..4 }\n \"mirror\": \"x\" | \"z\" also draws the mirror image across the middle of the grid ],\n \"voxels\": [ [x, y, z, c], ... ] (<= 512 single pixels for small details: gems, runes, eyes),\n \"icon\": { \"rows\": [ 16 strings of 16 palette characters ] } (optional inventory icon, \".\" transparent; without it the game draws the front view)\n}\n\nShapes available: ${MODEL_SHAPES.join(\", \")}. Make the item readable at a small size: a clear silhouette, 3–6 colours with a light and a dark shade, 1–3 px thick blades. When the player sends pictures (possibly ONE item from different angles) study every picture and get the silhouette, proportions and colours right.`;\n\nconst VIEW_NAMES = {\n front: \"the FRONT view\",\n side: \"a SIDE view\",\n back: \"the BACK view\",\n detail: \"a close-up DETAIL\",\n any: \"a reference\",\n} as const;\n\n/** Служебный текст промпта без описания игрока: оставшееся место — под описание. */\nconst OVERHEAD = 420;\n\nfunction pictures(req: {\n images: { view?: keyof typeof VIEW_NAMES }[];\n}): string {\n if (!req.images.length) return \"none\";\n return req.images\n .map((im, i) => `picture ${i + 1} — ${VIEW_NAMES[im.view ?? \"any\"]}`)\n .join(\"; \");\n}\n\n/** Прошлый рецепт для доработки — если влезает в лимит промпта фичи. */\nfunction previous(prev: unknown, room: number): string | null {\n if (!prev) return null;\n const json = JSON.stringify(prev);\n return json.length <= room ? json : null;\n}\n\nexport function buildCharacterPrompt(\n req: CharacterAIRequest,\n maxPromptChars: number | null,\n): string {\n const limit = maxPromptChars ?? 24000;\n const text = req.text\n .trim()\n .slice(0, Math.max(40, Math.min(1200, limit - OVERHEAD)));\n const lines = [\n \"Design a CharacterSpec. Answer with JSON only.\",\n `Description: ${text || \"(none — invent a friendly adventurer)\"}`,\n `Skeleton: ${req.rig && req.rig !== \"auto\" ? `\"${req.rig}\" (keep it)` : \"choose the best one\"}.`,\n `Pictures: ${pictures(req)}.`,\n ];\n const used = lines.join(\"\\n\").length + 80;\n const prev = previous(req.previous, limit - used);\n if (prev)\n lines.push(\n \"Change THIS existing character as the description asks and keep everything else:\",\n prev,\n );\n return lines.join(\"\\n\").slice(0, limit);\n}\n\nexport function buildItemPrompt(\n req: ItemAIRequest,\n maxPromptChars: number | null,\n): string {\n const limit = maxPromptChars ?? 24000;\n const text = req.text\n .trim()\n .slice(0, Math.max(40, Math.min(1200, limit - OVERHEAD)));\n const kind =\n req.kind === \"held\"\n ? `\"held\"${req.base ? `, base \"${req.base}\" (keep it)` : \"\"}`\n : req.kind === \"wearable\"\n ? `\"wearable\"${req.slot ? `, slot \"${req.slot}\" (keep it)` : \"\"}`\n : \"choose from the description\";\n const lines = [\n \"Design an ItemSkinSpec. Answer with JSON only.\",\n `Description: ${text || \"(none — invent a cool item)\"}`,\n `Kind: ${kind}.`,\n `Pictures: ${pictures(req)}.`,\n ];\n const used = lines.join(\"\\n\").length + 80;\n const prev = previous(req.previous, limit - used);\n if (prev)\n lines.push(\n \"Change THIS existing item as the description asks and keep everything else:\",\n prev,\n );\n return lines.join(\"\\n\").slice(0, limit);\n}\n"
|
|
106
|
+
"content": "// Промпты ИИ-генерации персонажа и облика предмета.\n//\n// VOXEL_CHARACTER_SYSTEM_PROMPT (из пакета персонажей) / VOXEL_ITEM_SYSTEM_PROMPT — системные промпты фич тайтла\n// \"voxel-character\" и \"voxel-item\". Как и у мира (ai/worldSpecPrompt), живут они в КОНФИГЕ тайтла\n// (cfg.AI.Features[...].Behavior.SystemInstructions), здесь — эталон, который издатель копирует.\n// Меняется формат рецепта — меняется этот текст; тест держит в нём все допустимые значения.\nimport { GRIP_POSES, WEAR_SLOT_IDS } from \"../character/items/ItemSkinSpec\";\nimport { MODEL_SHAPES } from \"../worldgen/WorldSpec\";\nimport type { CharacterAIRequest, ItemAIRequest } from \"./characterAi\";\n\n// Промпт персонажа описывает формат CharacterSpec и живёт рядом с ним — в пакете персонажей.\nexport { VOXEL_CHARACTER_SYSTEM_PROMPT } from \"@idosgames/characters/voxel\";\n\nexport const VOXEL_ITEM_SYSTEM_PROMPT = `You design blocky voxel items for a Minecraft-like game. Reply with ONE JSON object — an ItemSkinSpec — and nothing else: no markdown, no comments, no explanations.\n\nThere are two kinds:\n- \"held\": a new LOOK for an existing game item (\"base\"): a sword stays an iron_sword in the game (damage, durability), only its model and icon change. Common bases: wooden_sword, stone_sword, iron_sword, golden_sword, diamond_sword, iron_axe, iron_pickaxe, iron_shovel, iron_hoe, bow, crossbow, shield, trident, stick, fishing_rod.\n- \"wearable\": cosmetics on the body — slot ${WEAR_SLOT_IDS.map((s) => `\"${s}\"`).join(\" | \")} (a hat on \"head\", glasses or a mask on \"face\", a cape, wings or a backpack on \"back\", pauldrons on \"shoulders\", a belt or a skirt on \"waist\").\n\nThe model is a grid of PIXELS (1 px = 1/16 block): x to the right, y UP, z BACKWARDS (the front of the item and of the wearer is the -z side).\n- A held item is built STANDING UP as if held in the right hand: the handle at \"grip.at\", the blade or tip pointing UP (+y), the flat side facing -z. A sword is about 16–20 px long, an axe 14–18, a staff 24–32.\n- A wearable is built around its attachment point \"grip.at\": for \"head\" it is the CENTRE OF THE TOP of the head (the head is 8x8x8 px, so a hat brim is about 10–12 px wide, built upward from y = grip y); for \"back\" it is the middle of the back surface (the torso is 8 px wide and 12 px tall, the cape hangs down behind, toward +z); for \"face\" — the middle of the face (the face is on the -z side); for \"shoulders\" — the top middle of the torso; for \"waist\" — the middle of the belt.\n\nItemSkinSpec:\n{\n \"version\": 1,\n \"name\": string (<= 40 chars, in the player's language),\n \"description\": string (<= 200 chars),\n \"kind\": \"held\" | \"wearable\",\n \"base\": \"<game item>\" (held only),\n \"slot\": \"<slot>\" (wearable only),\n \"size\": [x, y, z] (grid size, each 1..32),\n \"grip\": { \"at\": [x, y, z], \"pose\": ${GRIP_POSES.map((p) => `\"${p}\"`).join(\" | \")} (held only) },\n \"palette\": { \"<one character>\": \"#rrggbb\", ... } (<= 16 colours),\n \"parts\": [ <= 48 shapes drawn in order, a later one overwrites an earlier one:\n { \"shape\": \"box\", \"color\": c, \"from\": [x,y,z], \"to\": [x,y,z], \"hollow\": true|false }\n { \"shape\": \"cylinder\", \"color\": c, \"axis\": \"x\"|\"y\"|\"z\", \"center\": [x,y,z], \"radius\": r, \"length\": n } — \"center\" is the middle of the start cap\n { \"shape\": \"cone\", same fields + \"radius2\": radius at the far end (0 = a point) }\n { \"shape\": \"sphere\", \"color\": c, \"center\": [x,y,z], \"radii\": [rx, ry, rz] }\n { \"shape\": \"line\", \"color\": c, \"from\": [x,y,z], \"to\": [x,y,z], \"radius\": 0..4 }\n \"mirror\": \"x\" | \"z\" also draws the mirror image across the middle of the grid ],\n \"voxels\": [ [x, y, z, c], ... ] (<= 512 single pixels for small details: gems, runes, eyes),\n \"icon\": { \"rows\": [ 16 strings of 16 palette characters ] } (optional inventory icon, \".\" transparent; without it the game draws the front view)\n}\n\nShapes available: ${MODEL_SHAPES.join(\", \")}. Make the item readable at a small size: a clear silhouette, 3–6 colours with a light and a dark shade, 1–3 px thick blades. When the player sends pictures (possibly ONE item from different angles) study every picture and get the silhouette, proportions and colours right.`;\n\nconst VIEW_NAMES = {\n front: \"the FRONT view\",\n side: \"a SIDE view\",\n back: \"the BACK view\",\n detail: \"a close-up DETAIL\",\n any: \"a reference\",\n} as const;\n\n/** Служебный текст промпта без описания игрока: оставшееся место — под описание. */\nconst OVERHEAD = 420;\n\nfunction pictures(req: {\n images: { view?: keyof typeof VIEW_NAMES }[];\n}): string {\n if (!req.images.length) return \"none\";\n return req.images\n .map((im, i) => `picture ${i + 1} — ${VIEW_NAMES[im.view ?? \"any\"]}`)\n .join(\"; \");\n}\n\n/** Прошлый рецепт для доработки — если влезает в лимит промпта фичи. */\nfunction previous(prev: unknown, room: number): string | null {\n if (!prev) return null;\n const json = JSON.stringify(prev);\n return json.length <= room ? json : null;\n}\n\nexport function buildCharacterPrompt(\n req: CharacterAIRequest,\n maxPromptChars: number | null,\n): string {\n const limit = maxPromptChars ?? 24000;\n const text = req.text\n .trim()\n .slice(0, Math.max(40, Math.min(1200, limit - OVERHEAD)));\n const lines = [\n \"Design a CharacterSpec. Answer with JSON only.\",\n `Description: ${text || \"(none — invent a friendly adventurer)\"}`,\n `Skeleton: ${req.rig && req.rig !== \"auto\" ? `\"${req.rig}\" (keep it)` : \"choose the best one\"}.`,\n `Pictures: ${pictures(req)}.`,\n ];\n const used = lines.join(\"\\n\").length + 80;\n const prev = previous(req.previous, limit - used);\n if (prev)\n lines.push(\n \"Change THIS existing character as the description asks and keep everything else:\",\n prev,\n );\n return lines.join(\"\\n\").slice(0, limit);\n}\n\nexport function buildItemPrompt(\n req: ItemAIRequest,\n maxPromptChars: number | null,\n): string {\n const limit = maxPromptChars ?? 24000;\n const text = req.text\n .trim()\n .slice(0, Math.max(40, Math.min(1200, limit - OVERHEAD)));\n const kind =\n req.kind === \"held\"\n ? `\"held\"${req.base ? `, base \"${req.base}\" (keep it)` : \"\"}`\n : req.kind === \"wearable\"\n ? `\"wearable\"${req.slot ? `, slot \"${req.slot}\" (keep it)` : \"\"}`\n : \"choose from the description\";\n const lines = [\n \"Design an ItemSkinSpec. Answer with JSON only.\",\n `Description: ${text || \"(none — invent a cool item)\"}`,\n `Kind: ${kind}.`,\n `Pictures: ${pictures(req)}.`,\n ];\n const used = lines.join(\"\\n\").length + 80;\n const prev = previous(req.previous, limit - used);\n if (prev)\n lines.push(\n \"Change THIS existing item as the description asks and keep everything else:\",\n prev,\n );\n return lines.join(\"\\n\").slice(0, limit);\n}\n"
|
|
106
107
|
},
|
|
107
108
|
{
|
|
108
109
|
"path": "ai/specAi.ts",
|
|
@@ -120,61 +121,17 @@
|
|
|
120
121
|
"path": "ai/worldSpecPrompt.ts",
|
|
121
122
|
"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"
|
|
122
123
|
},
|
|
123
|
-
{
|
|
124
|
-
"path": "character/anim/Animator.ts",
|
|
125
|
-
"content": "// Аниматор персонажа: слои поверх друг друга, каждый кадр из одного AnimInput.\n//\n// 1. Передвижение — граф состояний LOCOMOTION (правила по приоритету) с плавным переходом.\n// 2. Жест (колесо жестов, /emote, ИИ-NPC) — перекрывает свои роли, отменяется движением.\n// 3. Действия — разовые (замах, бросок) перекрывают свои роли; удар и приземление — добавкой;\n// «держать ПКМ» (есть, пить, лук, щит) — пока держат.\n// 4. Взгляд — голова к цели.\n// 5. Эмоция — осанка (добавка) и темп; лицо — выражение рецепта по цепочке замен, моргание, рот.\n// 6. Вторичное — пружинное качание хвоста, ушей, волос, плаща; крылья машут в полёте.\n//\n// Расширение — данными: новое состояние = строка в LOCOMOTION, новый клип = clips.ts, новый жест =\n// emotes.ts, новая эмоция = emotions.ts. Аниматор не знает скелета: всё по ролям.\nimport type { CharStyle, Expression } from \"../CharacterSpec\";\nimport type { RigId } from \"../rigs\";\nimport {\n ACTION_TIME,\n CLIPS,\n RIG_CLIPS,\n styleParams,\n type ClipCtx,\n type ClipId,\n type StyleParams,\n} from \"./clips\";\nimport { EMOTES, sampleEmote, type EmoteDef, type EmoteId } from \"./emotes\";\nimport {\n EMOTIONS,\n EXPRESSION_CHAIN,\n POSTURE,\n applyPosture,\n type Emotion,\n} from \"./emotions\";\nimport {\n CH,\n REST,\n add,\n bone,\n clearPose,\n copyPose,\n lerpPose,\n newAnimInput,\n type AnimEvent,\n type AnimInput,\n type Pose,\n} from \"./types\";\n\nexport type LocoState =\n | \"idle\"\n | \"walk\"\n | \"run\"\n | \"sneak\"\n | \"swim\"\n | \"fly\"\n | \"glide\"\n | \"fall\"\n | \"ride\"\n | \"sit\"\n | \"sleep\"\n | \"dead\";\n\ninterface LocoRule {\n state: LocoState;\n when: (i: AnimInput, a: Animator) => boolean;\n clip: ClipId;\n /** Время перехода В это состояние, с. */\n blend: number;\n}\n\n/** Граф передвижения: первое подходящее правило сверху. */\nexport const LOCOMOTION: LocoRule[] = [\n { state: \"dead\", when: (i) => i.dead, clip: \"dead\", blend: 0.25 },\n { state: \"sleep\", when: (i) => i.sleeping, clip: \"sleep\", blend: 0.3 },\n { state: \"ride\", when: (i) => i.riding, clip: \"ride\", blend: 0.2 },\n { state: \"sit\", when: (i) => i.sitting, clip: \"sit\", blend: 0.3 },\n {\n state: \"swim\",\n when: (i) => i.inWater && !i.onGround && !i.flying,\n clip: \"swim\",\n blend: 0.3,\n },\n { state: \"glide\", when: (i) => i.gliding, clip: \"glide\", blend: 0.25 },\n { state: \"fly\", when: (i) => i.flying, clip: \"fly\", blend: 0.25 },\n {\n state: \"fall\",\n when: (i, a) => a.airTime > 0.35 && i.vy < -4,\n clip: \"fall\",\n blend: 0.2,\n },\n { state: \"sneak\", when: (i) => i.sneaking, clip: \"sneak\", blend: 0.15 },\n {\n state: \"run\",\n when: (i) => (i.sprinting && i.speed > 3) || i.speed > 6.5,\n clip: \"run\",\n blend: 0.15,\n },\n { state: \"walk\", when: (i) => i.speed > 0.4, clip: \"walk\", blend: 0.15 },\n { state: \"idle\", when: () => true, clip: \"idle\", blend: 0.2 },\n];\n\n/** Разовые действия, которые ДОБАВЛЯЮТСЯ к позе, а не перекрывают её. */\nconst ADDITIVE = new Set<ClipId>([\"hurt\", \"land\"]);\n\nconst USE_CLIP = {\n eat: \"eat\",\n drink: \"drink\",\n draw: \"draw\",\n block: \"block\",\n} as const satisfies Record<string, ClipId>;\n\n/** Вторичные роли: цель качания по входу, пружина её догоняет (с перелётом — живее). */\nconst SECONDARY: Record<\n string,\n (i: AnimInput, clock: number) => [ch: \"rx\" | \"ry\" | \"rz\", v: number][]\n> = {\n tail: (i, c) => [\n [\"ry\", -i.yawRate * 0.25 + 0.12 * Math.sin(c * 2)],\n [\"rx\", -(0.1 + Math.min(i.speed, 8) * 0.06)],\n ],\n cape: (i, c) => [\n [\n \"rx\",\n -(0.05 + Math.min(i.speed, 8) * 0.12) -\n (i.speed > 1 ? 0.06 * Math.sin(c * 9) : 0) -\n (i.vy < -3 ? 0.4 : 0),\n ],\n ],\n hair: (i) => [[\"rx\", -Math.min(i.speed, 8) * 0.03]],\n ear: (i, c) => [\n [\"rz\", 0.08 * Math.sin(c * 1.3) + Math.min(i.speed, 6) * 0.02],\n ],\n antenna: (i, c) => [[\"rx\", 0.1 * Math.sin(c * 3) - i.vy * 0.02]],\n wingL: (i, c) =>\n i.flying || i.gliding || (!i.onGround && !i.inWater)\n ? [[\"rz\", i.gliding ? -0.05 : -0.7 * Math.sin(c * 12)]]\n : [[\"rz\", 1.1 + 0.05 * Math.sin(c * 1.5)]],\n wingR: (i, c) =>\n i.flying || i.gliding || (!i.onGround && !i.inWater)\n ? [[\"rz\", i.gliding ? 0.05 : 0.7 * Math.sin(c * 12)]]\n : [[\"rz\", -1.1 - 0.05 * Math.sin(c * 1.5)]],\n};\n\ninterface Action {\n clip: ClipId;\n t: number;\n dur: number;\n}\n\ninterface Spring {\n x: number;\n v: number;\n}\n\nconst smooth = (u: number) => {\n const x = Math.max(0, Math.min(1, u));\n return x * x * (3 - 2 * x);\n};\n\nexport class Animator {\n readonly rig: RigId;\n private style: StyleParams;\n /** Какие выражения нарисованы в рецепте. */\n private readonly faces: Set<Expression>;\n\n /** Итоговая поза кадра (роль → каналы). */\n readonly pose: Pose = new Map();\n /** Выражение лица кадра. */\n expression: Expression = \"neutral\";\n\n state: LocoState = \"idle\";\n private stateT = 0;\n private clock = 0;\n private phase = 0;\n airTime = 0;\n private wasOnGround = true;\n\n private readonly loco: Pose = new Map();\n private readonly from: Pose = new Map();\n private readonly lastLoco: Pose = new Map();\n private blendT = 1;\n private blendDur = 0.2;\n\n private readonly tmp: Pose = new Map();\n private actions: Action[] = [];\n private useClip: ClipId | null = null;\n private useW = 0;\n\n private emote: { id: EmoteId; def: EmoteDef; t: number } | null = null;\n\n /** Фоновое настроение и вспышка поверх него. */\n private mood: Emotion = \"neutral\";\n private flash: { emotion: Emotion; left: number } | null = null;\n private readonly postureW = new Map<Emotion, number>();\n\n private blinkT = 3;\n private blinking = 0;\n private talkT = 0;\n\n private readonly springs = new Map<string, Spring>();\n\n constructor(rig: RigId, style: CharStyle, faces: Iterable<Expression> = []) {\n this.rig = rig;\n this.style = styleParams(style);\n this.faces = new Set(faces);\n }\n\n setStyle(style: CharStyle): void {\n this.style = styleParams(style);\n }\n\n /** Эмоция: с seconds — вспышка на время, без — настроение до следующей смены. */\n setEmotion(e: Emotion, seconds?: number): void {\n if (seconds && seconds > 0) this.flash = { emotion: e, left: seconds };\n else this.mood = e;\n }\n\n /** Эмоция, которую сейчас показывает персонаж. */\n get emotion(): Emotion {\n if (this.emote?.def.emotion) return this.emote.def.emotion;\n return this.flash?.emotion ?? this.mood;\n }\n\n get emoting(): EmoteId | null {\n return this.emote?.id ?? null;\n }\n\n playEmote(id: EmoteId): void {\n this.emote = { id, def: EMOTES[id], t: 0 };\n }\n\n stopEmote(): void {\n this.emote = null;\n }\n\n /** Разовое событие: замах, удар по персонажу, приземление… */\n trigger(ev: AnimEvent): void {\n switch (ev) {\n case \"swing\":\n case \"place\":\n case \"throw\":\n case \"hurt\":\n case \"land\": {\n const clip = ev as ClipId;\n // повторный замах — заново, а не второй поверх\n this.actions = this.actions.filter((a) => a.clip !== clip);\n this.actions.push({ clip, t: 0, dur: ACTION_TIME[clip] ?? 0.3 });\n if (ev === \"hurt\") this.setEmotion(\"hurt\", 0.8);\n break;\n }\n case \"jump\":\n break;\n }\n }\n\n update(dt: number, input: AnimInput = newAnimInput()): Pose {\n dt = Math.min(dt, 0.1);\n this.clock += dt;\n const emo = this.emotion;\n const tempo = POSTURE[emo].tempo * this.style.tempo;\n\n // в воздухе; приземление после заметного полёта — сплющиться\n if (!input.onGround && !input.inWater) this.airTime += dt;\n else {\n if (!this.wasOnGround && this.airTime > 0.3 && input.onGround)\n this.trigger(\"land\");\n this.airTime = 0;\n }\n this.wasOnGround = input.onGround;\n this.phase += input.speed * dt * 3.5 * tempo;\n\n // 1. передвижение\n const rule = LOCOMOTION.find((r) => r.when(input, this))!;\n if (rule.state !== this.state) {\n copyPose(this.from, this.lastLoco);\n this.state = rule.state;\n this.stateT = 0;\n this.blendT = 0;\n this.blendDur = rule.blend;\n }\n this.stateT += dt;\n clearPose(this.loco);\n const clipId = RIG_CLIPS[this.rig]?.[rule.clip] ?? rule.clip;\n const ctx: ClipCtx = {\n t: this.stateT,\n k: 0,\n phase: this.phase,\n input,\n style: this.style,\n clock: this.clock * tempo,\n };\n CLIPS[clipId](this.loco, ctx);\n clearPose(this.pose);\n if (this.blendT < this.blendDur) {\n this.blendT += dt;\n lerpPose(\n this.pose,\n this.from,\n this.loco,\n smooth(this.blendT / this.blendDur),\n );\n } else copyPose(this.pose, this.loco);\n copyPose(this.lastLoco, this.pose);\n\n // 2. жест: двинулся или не стоит на месте — жест прерывается\n if (this.emote) {\n const e = this.emote;\n e.t += dt;\n const moving = input.speed > 1 || this.state !== \"idle\";\n if (moving || (!e.def.loop && e.t >= e.def.duration)) this.emote = null;\n else {\n const fade = 0.15;\n const w = e.def.loop\n ? smooth(e.t / fade)\n : smooth(Math.min(e.t / fade, (e.def.duration - e.t) / fade));\n clearPose(this.tmp);\n sampleEmote(this.tmp, e.def, e.t);\n this.overlay(this.tmp, w, false);\n }\n }\n\n // 3. действия: «держать ПКМ», затем разовые\n const want = input.use ? USE_CLIP[input.use] : null;\n if (want) this.useClip = want;\n this.useW = Math.max(0, Math.min(1, this.useW + (want ? dt : -dt) * 8));\n if (this.useClip && this.useW > 0) {\n clearPose(this.tmp);\n CLIPS[this.useClip](this.tmp, { ...ctx, t: this.clock });\n this.overlay(this.tmp, smooth(this.useW), false);\n } else if (input.held !== \"none\" && !this.emote) {\n add(this.pose, \"armR\", \"rx\", input.held === \"staff\" ? 0.35 : 0.2);\n }\n for (const a of this.actions) {\n a.t += dt;\n const k = Math.min(1, a.t / a.dur);\n clearPose(this.tmp);\n CLIPS[a.clip](this.tmp, { ...ctx, t: a.t, k });\n this.overlay(this.tmp, 1, ADDITIVE.has(a.clip));\n }\n this.actions = this.actions.filter((a) => a.t < a.dur);\n\n // 4. взгляд (лёжа и мёртвым — нет)\n if (this.state !== \"dead\" && this.state !== \"sleep\") {\n add(\n this.pose,\n \"head\",\n \"ry\",\n Math.max(-1.1, Math.min(1.1, input.lookYaw)),\n );\n add(\n this.pose,\n \"head\",\n \"rx\",\n Math.max(-1.2, Math.min(1.2, input.lookPitch)),\n );\n }\n\n // 5. эмоция: осанка с плавной сменой, вспышка гаснет\n if (this.flash && (this.flash.left -= dt) <= 0) this.flash = null;\n for (const e of EMOTIONS) {\n const w0 = this.postureW.get(e) ?? 0;\n const w = w0 + ((e === emo ? 1 : 0) - w0) * Math.min(1, dt * 6);\n this.postureW.set(e, w);\n if (this.state !== \"dead\") applyPosture(this.pose, e, w);\n }\n\n // 6. вторичное — пружины\n for (const [role, target] of Object.entries(SECONDARY))\n for (const [ch, v] of target(input, this.clock)) {\n const key = role + \".\" + ch;\n let s = this.springs.get(key);\n if (!s) this.springs.set(key, (s = { x: v, v: 0 }));\n const k = 60,\n c = 9;\n s.v += (k * (v - s.x) - c * s.v) * dt;\n s.x += s.v * dt;\n add(this.pose, role, ch, s.x);\n }\n\n this.expression = this.face(input, dt, emo);\n return this.pose;\n }\n\n /** Наложить позу: перекрыть роли с весом w или прибавить отклонения от покоя. */\n private overlay(src: Pose, w: number, additive: boolean): void {\n for (const [role, b] of src) {\n const o = bone(this.pose, role);\n for (let i = 0; i < 9; i++) {\n if (additive) {\n if (i >= CH.sx) o[i] = o[i]! * (1 + (b[i]! - 1) * w);\n else o[i] = o[i]! + (b[i]! - REST[i]!) * w;\n } else o[i] = o[i]! + (b[i]! - o[i]!) * w;\n }\n }\n }\n\n /** Выражение лица: эмоция по цепочке замен, моргание, рот при разговоре. */\n private face(input: AnimInput, dt: number, emo: Emotion): Expression {\n const pickFace = (chain: readonly Expression[]): Expression =>\n chain.find((e) => this.faces.has(e)) ?? \"neutral\";\n if (input.dead || this.state === \"dead\" || input.sleeping)\n return pickFace([\"blink\", \"hurt\", \"neutral\"]);\n const base = pickFace(EXPRESSION_CHAIN[emo]);\n if (input.talk) {\n this.talkT += dt;\n return Math.floor(this.talkT / 0.12) % 2 === 0\n ? pickFace([\"talk\", base])\n : base;\n }\n this.talkT = 0;\n if (this.blinking > 0) {\n this.blinking -= dt;\n return pickFace([\"blink\", base]);\n }\n this.blinkT -= dt;\n if (this.blinkT <= 0) {\n this.blinkT = 2.5 + Math.random() * 3;\n if (emo !== \"surprised\" && emo !== \"scared\" && emo !== \"hurt\")\n this.blinking = 0.13;\n }\n return base;\n }\n}\n"
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
"path": "character/anim/clips.ts",
|
|
129
|
-
"content": "// Клипы — процедурные анимации состояний и действий. Клип пишет в позу только свои роли; роли,\n// которых нет у скелета, ничего не двигают, поэтому один «шаг» работает и у человека, и у собаки.\n//\n// Новый клип — функция здесь и строка в CLIPS; состояние, которое его играет, — в Animator\n// (LOCOMOTION) или в RIG_CLIPS (подмена клипа для шаблона: у капли «ходьба» — прыжки).\nimport type { CharStyle } from \"../CharacterSpec\";\nimport type { RigId } from \"../rigs\";\nimport { add, setCh, type AnimInput, type Pose } from \"./types\";\n\n/** Параметры походки из стиля рецепта. */\nexport interface StyleParams {\n /** Размах ног и рук. */\n amp: number;\n arm: number;\n /** Подпрыгивание при шаге, пиксели. */\n bob: number;\n /** Темп шага. */\n tempo: number;\n /** Раскачка корпуса. */\n sway: number;\n /** Шаг рывками (робот). */\n quant: boolean;\n /** Дыхание, переминание, грудь колесом. */\n breath: number;\n fidget: number;\n chest: number;\n}\n\nconst WALK: Record<CharStyle[\"walk\"], Partial<StyleParams>> = {\n normal: {},\n bouncy: { amp: 0.9, arm: 0.8, bob: 1.6, tempo: 1.1, sway: 0.04 },\n heavy: { amp: 0.6, arm: 0.4, bob: 0.6, tempo: 0.8, sway: 0.12 },\n sneaky: { amp: 0.5, arm: 0.3, tempo: 1.2 },\n robotic: { amp: 0.7, arm: 0.7, quant: true },\n};\nconst IDLE: Record<CharStyle[\"idle\"], Partial<StyleParams>> = {\n calm: {},\n fidgety: { fidget: 1, breath: 1.3 },\n proud: { chest: 0.08, breath: 0.8 },\n};\n\nexport function styleParams(s: CharStyle): StyleParams {\n const p: StyleParams = {\n amp: 0.8,\n arm: 0.6,\n bob: 0,\n tempo: 1,\n sway: 0,\n quant: false,\n breath: 1,\n fidget: 0,\n chest: 0,\n ...WALK[s.walk],\n ...IDLE[s.idle],\n };\n p.tempo *= 0.8 + 0.4 * s.energy;\n p.amp *= 0.85 + 0.3 * s.energy;\n return p;\n}\n\nexport interface ClipCtx {\n /** Секунд в состоянии / с начала действия. */\n t: number;\n /** Доля действия 0..1 (у бесконечных — 0). */\n k: number;\n /** Фаза шага, радианы (растёт с пройденным путём). */\n phase: number;\n input: AnimInput;\n style: StyleParams;\n /** Время с создания аниматора — для дыхания и колыханий, которые не сбрасываются. */\n clock: number;\n}\n\nexport type ClipFn = (out: Pose, c: ClipCtx) => void;\n\n/** Синус шага; у робота — ступеньками. */\nfunction stepWave(c: ClipCtx, shift = 0): number {\n const s = Math.sin(c.phase + shift);\n return c.style.quant ? Math.max(-1, Math.min(1, s * 3)) : s;\n}\n\nfunction legs(out: Pose, c: ClipCtx, amp: number): void {\n const s = stepWave(c) * amp;\n setCh(out, \"legL\", \"rx\", s);\n setCh(out, \"legR\", \"rx\", -s);\n // четвероногие — диагональными парами\n setCh(out, \"legFL\", \"rx\", s);\n setCh(out, \"legBR\", \"rx\", s);\n setCh(out, \"legFR\", \"rx\", -s);\n setCh(out, \"legBL\", \"rx\", -s);\n}\n\nfunction arms(out: Pose, c: ClipCtx, amp: number): void {\n const s = stepWave(c) * amp;\n setCh(out, \"armL\", \"rx\", -s);\n setCh(out, \"armR\", \"rx\", s);\n}\n\nconst idle: ClipFn = (out, c) => {\n const b = Math.sin(c.clock * 2) * c.style.breath;\n setCh(out, \"armL\", \"rz\", -0.04 - 0.03 * b);\n setCh(out, \"armR\", \"rz\", 0.04 + 0.03 * b);\n setCh(out, \"head\", \"rx\", 0.03 * Math.sin(c.clock * 1.7) + c.style.chest);\n setCh(out, \"body\", \"rx\", c.style.chest * -0.5);\n if (c.style.fidget) {\n setCh(\n out,\n \"head\",\n \"ry\",\n 0.35 * Math.sin(c.clock * 0.7) * Math.sin(c.clock * 0.31),\n );\n setCh(out, \"root\", \"ry\", 0.05 * Math.sin(c.clock * 0.9));\n }\n // капля «дышит» сплющиванием\n setCh(out, \"body\", \"sy\", 1 + 0.04 * Math.sin(c.clock * 3));\n};\n\nconst walk: ClipFn = (out, c) => {\n legs(out, c, c.style.amp);\n arms(out, c, c.style.arm);\n setCh(out, \"root\", \"py\", c.style.bob * Math.abs(Math.cos(c.phase)));\n setCh(out, \"body\", \"rz\", c.style.sway * stepWave(c));\n};\n\nconst run: ClipFn = (out, c) => {\n legs(out, c, c.style.amp * 1.3);\n arms(out, c, c.style.arm * 1.6);\n setCh(out, \"body\", \"rx\", -0.18);\n setCh(out, \"head\", \"rx\", 0.18);\n setCh(out, \"root\", \"py\", (0.8 + c.style.bob) * Math.abs(Math.cos(c.phase)));\n};\n\nconst sneak: ClipFn = (out, c) => {\n legs(out, c, c.style.amp * 0.5 * Math.min(1, c.input.speed));\n arms(out, c, c.style.arm * 0.4 * Math.min(1, c.input.speed));\n setCh(out, \"body\", \"rx\", -0.45);\n setCh(out, \"head\", \"rx\", 0.45);\n add(out, \"armL\", \"rx\", 0.25);\n add(out, \"armR\", \"rx\", 0.25);\n setCh(out, \"root\", \"py\", -1.5);\n};\n\nconst swim: ClipFn = (out, c) => {\n setCh(out, \"root\", \"rx\", -1.3);\n setCh(out, \"head\", \"rx\", 1.0);\n setCh(out, \"armL\", \"rx\", Math.PI - 0.5 + 0.5 * Math.sin(c.t * 3));\n setCh(out, \"armR\", \"rx\", Math.PI - 0.5 + 0.5 * Math.sin(c.t * 3 + Math.PI));\n setCh(out, \"legL\", \"rx\", 0.3 * Math.sin(c.t * 8));\n setCh(out, \"legR\", \"rx\", -0.3 * Math.sin(c.t * 8));\n};\n\nconst fly: ClipFn = (out, c) => {\n setCh(out, \"legL\", \"rx\", 0.15);\n setCh(out, \"legR\", \"rx\", 0.1);\n setCh(out, \"armL\", \"rz\", -0.15);\n setCh(out, \"armR\", \"rz\", 0.15);\n setCh(out, \"root\", \"py\", 0.6 * Math.sin(c.t * 2));\n};\n\nconst glide: ClipFn = (out) => {\n setCh(out, \"root\", \"rx\", -1.35);\n setCh(out, \"head\", \"rx\", 1.1);\n setCh(out, \"armL\", \"rz\", -1.3);\n setCh(out, \"armR\", \"rz\", 1.3);\n};\n\nconst fall: ClipFn = (out, c) => {\n const f = 0.15 * Math.sin(c.t * 10);\n setCh(out, \"armL\", \"rz\", -0.6 - f);\n setCh(out, \"armR\", \"rz\", 0.6 + f);\n setCh(out, \"legL\", \"rx\", 0.25);\n setCh(out, \"legR\", \"rx\", -0.25);\n};\n\nconst sit: ClipFn = (out) => {\n setCh(out, \"legL\", \"rx\", 1.45);\n setCh(out, \"legR\", \"rx\", 1.45);\n setCh(out, \"legL\", \"ry\", 0.1);\n setCh(out, \"legR\", \"ry\", -0.1);\n setCh(out, \"root\", \"py\", -10);\n // четвероногое садится на задние лапы\n setCh(out, \"body\", \"rx\", 0.4);\n setCh(out, \"legBL\", \"rx\", 1.2);\n setCh(out, \"legBR\", \"rx\", 1.2);\n};\n\nconst ride: ClipFn = (out) => {\n setCh(out, \"legL\", \"rx\", 1.4);\n setCh(out, \"legR\", \"rx\", 1.4);\n setCh(out, \"legL\", \"rz\", -0.3);\n setCh(out, \"legR\", \"rz\", 0.3);\n setCh(out, \"armL\", \"rx\", 0.45);\n setCh(out, \"armR\", \"rx\", 0.45);\n};\n\nconst sleep: ClipFn = (out) => {\n setCh(out, \"root\", \"rx\", Math.PI / 2);\n setCh(out, \"root\", \"py\", 2);\n};\n\nconst dead: ClipFn = (out) => {\n setCh(out, \"root\", \"rz\", Math.PI / 2);\n setCh(out, \"root\", \"py\", 2);\n};\n\n/** Капля ходит прыжками: подлетает и сплющивается при приземлении. */\nconst hop: ClipFn = (out, c) => {\n const h = Math.sin(c.phase * 0.5);\n setCh(out, \"root\", \"py\", 6 * Math.max(0, h));\n const squash = h < 0 ? 0.2 * -h : 0;\n setCh(out, \"body\", \"sy\", 1 - squash + 0.1 * Math.max(0, h));\n setCh(out, \"body\", \"sx\", 1 + squash * 0.6);\n setCh(out, \"body\", \"sz\", 1 + squash * 0.6);\n};\n\n// ---- действия (k — доля 0..1) --------------------------------------------------------\n\nconst swing: ClipFn = (out, c) => {\n const s = Math.sin(Math.PI * c.k);\n setCh(out, \"armR\", \"rx\", 1.9 * s);\n setCh(out, \"armR\", \"ry\", 0.3 * s);\n setCh(out, \"body\", \"ry\", -0.3 * s);\n};\n\nconst place: ClipFn = (out, c) => {\n const s = Math.sin(Math.PI * c.k);\n setCh(out, \"armR\", \"rx\", 0.9 * s);\n};\n\nconst throwClip: ClipFn = (out, c) => {\n const v = c.k < 0.4 ? -1.5 * (c.k / 0.4) : -1.5 + 3.5 * ((c.k - 0.4) / 0.6);\n setCh(out, \"armR\", \"rx\", c.k > 0.9 ? v * (1 - c.k) * 10 : v);\n};\n\nconst hurt: ClipFn = (out, c) => {\n const s = Math.sin(Math.PI * c.k);\n setCh(out, \"body\", \"rx\", 0.15 * s);\n setCh(out, \"head\", \"rx\", 0.2 * s);\n setCh(out, \"root\", \"rz\", 0.05 * s);\n};\n\nconst land: ClipFn = (out, c) => {\n const s = Math.sin(Math.PI * c.k);\n setCh(out, \"root\", \"py\", -1.5 * s);\n setCh(out, \"legL\", \"rx\", 0.2 * s);\n setCh(out, \"legR\", \"rx\", 0.2 * s);\n setCh(out, \"body\", \"sy\", 1 - 0.2 * s);\n setCh(out, \"body\", \"sx\", 1 + 0.12 * s);\n setCh(out, \"body\", \"sz\", 1 + 0.12 * s);\n};\n\nconst eat: ClipFn = (out, c) => {\n setCh(out, \"armR\", \"rx\", 1.3 + 0.1 * Math.sin(c.t * 16));\n setCh(out, \"armR\", \"ry\", -0.5);\n setCh(out, \"head\", \"rx\", -0.1 + 0.08 * Math.sin(c.t * 16));\n};\n\nconst drink: ClipFn = (out) => {\n setCh(out, \"armR\", \"rx\", 2.0);\n setCh(out, \"armR\", \"ry\", -0.4);\n setCh(out, \"head\", \"rx\", 0.35);\n};\n\nconst draw: ClipFn = (out) => {\n setCh(out, \"armR\", \"rx\", 1.57);\n setCh(out, \"armR\", \"ry\", -0.1);\n setCh(out, \"armL\", \"rx\", 1.57);\n setCh(out, \"armL\", \"ry\", 0.35);\n};\n\nconst block: ClipFn = (out) => {\n setCh(out, \"armL\", \"rx\", 0.9);\n setCh(out, \"armL\", \"ry\", -0.6);\n};\n\nexport const CLIPS = {\n idle,\n walk,\n run,\n sneak,\n swim,\n fly,\n glide,\n fall,\n sit,\n ride,\n sleep,\n dead,\n hop,\n swing,\n place,\n throw: throwClip,\n hurt,\n land,\n eat,\n drink,\n draw,\n block,\n} satisfies Record<string, ClipFn>;\nexport type ClipId = keyof typeof CLIPS;\n\n/** Подмена клипа состояния для шаблона: у капли ходьба и бег — прыжки. */\nexport const RIG_CLIPS: Partial<\n Record<RigId, Partial<Record<ClipId, ClipId>>>\n> = {\n blob: { walk: \"hop\", run: \"hop\", sneak: \"hop\" },\n};\n\n/** Длительность разовых действий, с. */\nexport const ACTION_TIME: Partial<Record<ClipId, number>> = {\n swing: 0.3,\n place: 0.2,\n throw: 0.45,\n hurt: 0.3,\n land: 0.22,\n};\n"
|
|
130
|
-
},
|
|
131
|
-
{
|
|
132
|
-
"path": "character/anim/emotes.ts",
|
|
133
|
-
"content": "// Жесты — ключевые анимации ДАННЫМИ: дорожка = роль.канал → ключи [время, значение], между ключами\n// плавно (smoothstep). Этот же формат позже сможет присылать ИИ (свои жесты персонажа).\n//\n// Новый жест — запись здесь; колесо жестов, команда /emote и действие агента берут список отсюда.\nimport type { Emotion } from \"./emotions\";\nimport { CH, setCh, type Channel, type Pose } from \"./types\";\n\nexport type Track = [number, number][];\n\nexport interface EmoteDef {\n label: string;\n /** Длительность цикла, с. */\n duration: number;\n /** Повторяется, пока персонаж не двинется. */\n loop?: boolean;\n /** Лицо на время жеста. */\n emotion?: Emotion;\n /** \"роль.канал\" → ключи. */\n tracks: Record<string, Track>;\n}\n\nconst PI = Math.PI;\n\nexport const EMOTES = {\n wave: {\n label: \"Помахать\",\n duration: 2,\n emotion: \"happy\",\n tracks: {\n \"armR.rz\": [\n [0, 0],\n [0.25, 2.7],\n [0.5, 2.3],\n [0.75, 2.8],\n [1, 2.3],\n [1.25, 2.8],\n [1.5, 2.4],\n [2, 0],\n ],\n \"armR.rx\": [\n [0, 0],\n [0.25, 0.2],\n [1.75, 0.2],\n [2, 0],\n ],\n \"head.rz\": [\n [0, 0],\n [0.5, 0.1],\n [1.5, 0.1],\n [2, 0],\n ],\n },\n },\n cheer: {\n label: \"Ура!\",\n duration: 1.6,\n emotion: \"happy\",\n tracks: {\n \"armL.rz\": [\n [0, 0],\n [0.25, -2.8],\n [1.35, -2.8],\n [1.6, 0],\n ],\n \"armR.rz\": [\n [0, 0],\n [0.25, 2.8],\n [1.35, 2.8],\n [1.6, 0],\n ],\n \"root.py\": [\n [0, 0],\n [0.3, 3],\n [0.55, 0],\n [0.8, 3],\n [1.05, 0],\n ],\n },\n },\n dance: {\n label: \"Танец\",\n duration: 2,\n loop: true,\n emotion: \"happy\",\n tracks: {\n \"root.ry\": [\n [0, 0.3],\n [0.5, -0.3],\n [1, 0.3],\n [1.5, -0.3],\n [2, 0.3],\n ],\n \"armL.rz\": [\n [0, -0.4],\n [0.5, -2.4],\n [1, -0.4],\n [1.5, -2.4],\n [2, -0.4],\n ],\n \"armR.rz\": [\n [0, 2.4],\n [0.5, 0.4],\n [1, 2.4],\n [1.5, 0.4],\n [2, 2.4],\n ],\n \"legL.rx\": [\n [0, 0],\n [0.5, 0.5],\n [1, 0],\n [2, 0],\n ],\n \"legR.rx\": [\n [0, 0],\n [1, 0],\n [1.5, 0.5],\n [2, 0],\n ],\n \"root.py\": [\n [0, 0],\n [0.25, 1.5],\n [0.5, 0],\n [0.75, 1.5],\n [1, 0],\n [1.25, 1.5],\n [1.5, 0],\n [1.75, 1.5],\n [2, 0],\n ],\n \"tail.ry\": [\n [0, 0.6],\n [0.5, -0.6],\n [1, 0.6],\n [1.5, -0.6],\n [2, 0.6],\n ],\n },\n },\n point: {\n label: \"Указать\",\n duration: 1.6,\n tracks: {\n \"armR.rx\": [\n [0, 0],\n [0.2, 1.55],\n [1.4, 1.55],\n [1.6, 0],\n ],\n },\n },\n laugh: {\n label: \"Смех\",\n duration: 1.6,\n emotion: \"happy\",\n tracks: {\n \"body.rx\": [\n [0, 0],\n [0.2, 0.15],\n [0.4, 0.05],\n [0.6, 0.15],\n [0.8, 0.05],\n [1, 0.15],\n [1.2, 0.05],\n [1.6, 0],\n ],\n \"head.rx\": [\n [0, 0],\n [0.2, 0.3],\n [1.4, 0.3],\n [1.6, 0],\n ],\n \"armL.rx\": [\n [0, 0],\n [0.2, 0.5],\n [1.4, 0.5],\n [1.6, 0],\n ],\n \"armR.rx\": [\n [0, 0],\n [0.2, 0.5],\n [1.4, 0.5],\n [1.6, 0],\n ],\n },\n },\n cry: {\n label: \"Плакать\",\n duration: 2.4,\n emotion: \"sad\",\n tracks: {\n \"head.rx\": [\n [0, 0],\n [0.3, -0.4],\n [2.1, -0.4],\n [2.4, 0],\n ],\n \"head.rz\": [\n [0, 0],\n [0.6, 0.08],\n [0.9, -0.08],\n [1.2, 0.08],\n [1.5, -0.08],\n [2.4, 0],\n ],\n \"armR.rx\": [\n [0, 0],\n [0.3, 2.3],\n [2.1, 2.3],\n [2.4, 0],\n ],\n \"armR.ry\": [\n [0, 0],\n [0.3, -0.6],\n [2.1, -0.6],\n [2.4, 0],\n ],\n \"armL.rx\": [\n [0, 0],\n [0.3, 2.3],\n [2.1, 2.3],\n [2.4, 0],\n ],\n \"armL.ry\": [\n [0, 0],\n [0.3, 0.6],\n [2.1, 0.6],\n [2.4, 0],\n ],\n },\n },\n bow: {\n label: \"Поклон\",\n duration: 1.4,\n tracks: {\n \"body.rx\": [\n [0, 0],\n [0.4, -0.7],\n [1, -0.7],\n [1.4, 0],\n ],\n \"armR.rx\": [\n [0, 0],\n [0.4, 0.5],\n [1, 0.5],\n [1.4, 0],\n ],\n },\n },\n shrug: {\n label: \"Пожать плечами\",\n duration: 1.2,\n tracks: {\n \"armL.rz\": [\n [0, 0],\n [0.3, -0.5],\n [0.9, -0.5],\n [1.2, 0],\n ],\n \"armR.rz\": [\n [0, 0],\n [0.3, 0.5],\n [0.9, 0.5],\n [1.2, 0],\n ],\n \"armL.rx\": [\n [0, 0],\n [0.3, 0.6],\n [0.9, 0.6],\n [1.2, 0],\n ],\n \"armR.rx\": [\n [0, 0],\n [0.3, 0.6],\n [0.9, 0.6],\n [1.2, 0],\n ],\n \"head.rz\": [\n [0, 0],\n [0.3, 0.25],\n [0.9, 0.25],\n [1.2, 0],\n ],\n },\n },\n facepalm: {\n label: \"Рука-лицо\",\n duration: 1.6,\n emotion: \"tired\",\n tracks: {\n \"armR.rx\": [\n [0, 0],\n [0.3, 2.5],\n [1.3, 2.5],\n [1.6, 0],\n ],\n \"armR.rz\": [\n [0, 0],\n [0.3, -0.4],\n [1.3, -0.4],\n [1.6, 0],\n ],\n \"head.rx\": [\n [0, 0],\n [0.3, -0.35],\n [1.3, -0.35],\n [1.6, 0],\n ],\n },\n },\n sit: {\n label: \"Сесть\",\n duration: 1,\n loop: true,\n tracks: {\n \"legL.rx\": [\n [0, PI * 0.46],\n [1, PI * 0.46],\n ],\n \"legR.rx\": [\n [0, PI * 0.46],\n [1, PI * 0.46],\n ],\n \"root.py\": [\n [0, -10],\n [1, -10],\n ],\n },\n },\n} satisfies Record<string, EmoteDef>;\nexport type EmoteId = keyof typeof EMOTES;\nexport const EMOTE_IDS = Object.keys(EMOTES) as EmoteId[];\n\nexport function isEmote(v: unknown): v is EmoteId {\n return typeof v === \"string\" && v in EMOTES;\n}\n\n/** Значение дорожки в момент t (между ключами — smoothstep). */\nexport function sampleTrack(track: Track, t: number): number {\n const first = track[0]!;\n if (t <= first[0]) return first[1];\n for (let i = 1; i < track.length; i++) {\n const b = track[i]!;\n if (t <= b[0]) {\n const a = track[i - 1]!;\n const u = (t - a[0]) / Math.max(1e-6, b[0] - a[0]);\n const s = u * u * (3 - 2 * u);\n return a[1] + (b[1] - a[1]) * s;\n }\n }\n return track[track.length - 1]![1];\n}\n\n/** Поза жеста в момент t (у повторяющегося — по модулю длительности). */\nexport function sampleEmote(out: Pose, def: EmoteDef, t: number): void {\n const tt = def.loop ? t % def.duration : Math.min(t, def.duration);\n for (const [key, track] of Object.entries(def.tracks)) {\n const dot = key.lastIndexOf(\".\");\n const role = key.slice(0, dot),\n ch = key.slice(dot + 1) as Channel;\n if (!(ch in CH)) continue;\n setCh(out, role, ch, sampleTrack(track, tt));\n }\n}\n"
|
|
134
|
-
},
|
|
135
|
-
{
|
|
136
|
-
"path": "character/anim/emotions.ts",
|
|
137
|
-
"content": "// Эмоции персонажа: лицо (выражение рецепта по цепочке замен) + осанка (добавка к позе) + темп\n// движений. Эмоцию ставит игра по событиям (EMOTION_EVENTS), игрок смайликом в чате или жестом, а\n// позже — ответ ИИ-NPC ({ say, emotion, emote }).\nimport type { Expression } from \"../CharacterSpec\";\nimport { add, type Channel, type Pose } from \"./types\";\n\nexport const EMOTIONS = [\n \"neutral\",\n \"happy\",\n \"sad\",\n \"angry\",\n \"surprised\",\n \"scared\",\n \"tired\",\n \"hurt\",\n] as const;\nexport type Emotion = (typeof EMOTIONS)[number];\n\nexport function isEmotion(v: unknown): v is Emotion {\n return typeof v === \"string\" && (EMOTIONS as readonly string[]).includes(v);\n}\n\n/** Какое выражение лица искать для эмоции — первое, что есть в рецепте. */\nexport const EXPRESSION_CHAIN: Record<Emotion, Expression[]> = {\n neutral: [\"neutral\"],\n happy: [\"happy\", \"neutral\"],\n sad: [\"sad\", \"tired\", \"neutral\"],\n angry: [\"angry\", \"neutral\"],\n surprised: [\"surprised\", \"scared\", \"neutral\"],\n scared: [\"scared\", \"surprised\", \"sad\", \"neutral\"],\n tired: [\"tired\", \"sad\", \"neutral\"],\n hurt: [\"hurt\", \"angry\", \"sad\", \"neutral\"],\n};\n\nexport interface Posture {\n /** \"роль.канал\" → добавка. */\n add: Partial<Record<string, number>>;\n /** Множитель темпа движений. */\n tempo: number;\n}\n\nexport const POSTURE: Record<Emotion, Posture> = {\n neutral: { add: {}, tempo: 1 },\n happy: { add: { \"head.rx\": 0.08 }, tempo: 1.1 },\n sad: {\n add: { \"head.rx\": -0.3, \"body.rx\": -0.08, \"tail.rx\": 0.5 },\n tempo: 0.8,\n },\n angry: {\n add: {\n \"body.rx\": -0.12,\n \"head.rx\": 0.1,\n \"armL.rz\": -0.1,\n \"armR.rz\": 0.1,\n },\n tempo: 1.1,\n },\n surprised: { add: { \"head.rx\": 0.15, \"body.rx\": 0.05 }, tempo: 1 },\n scared: {\n add: {\n \"body.rx\": -0.1,\n \"head.rx\": -0.1,\n \"armL.rx\": 0.3,\n \"armR.rx\": 0.3,\n \"tail.rx\": 0.6,\n },\n tempo: 1.3,\n },\n tired: { add: { \"head.rx\": -0.35, \"body.rx\": -0.12 }, tempo: 0.7 },\n hurt: { add: { \"body.rx\": -0.1 }, tempo: 1 },\n};\n\n/** Добавить осанку эмоции к позе с весом w (переход между эмоциями плавный). */\nexport function applyPosture(out: Pose, e: Emotion, w: number): void {\n if (w <= 0) return;\n for (const [key, v] of Object.entries(POSTURE[e].add)) {\n const dot = key.lastIndexOf(\".\");\n add(out, key.slice(0, dot), key.slice(dot + 1) as Channel, (v ?? 0) * w);\n }\n}\n\n/**\n * Игровые события → эмоция. seconds — вспышка на время; без seconds — фоновое настроение, пока\n * условие держится. Таблица — данные: новое событие игры — строка здесь и emit в game/wire.\n */\nexport const EMOTION_EVENTS = {\n damaged: { emotion: \"hurt\", seconds: 0.8 },\n lowHealth: { emotion: \"tired\" },\n nearDeath: { emotion: \"scared\" },\n achievement: { emotion: \"happy\", seconds: 3 },\n bossKilled: { emotion: \"happy\", seconds: 5 },\n tamed: { emotion: \"happy\", seconds: 3 },\n traded: { emotion: \"happy\", seconds: 2 },\n petDied: { emotion: \"sad\", seconds: 6 },\n explosion: { emotion: \"surprised\", seconds: 1.5 },\n} satisfies Record<string, { emotion: Emotion; seconds?: number }>;\nexport type EmotionEvent = keyof typeof EMOTION_EVENTS;\n\n/** Смайлик в сообщении чата → эмоция (последний найденный); null — нет смайликов. */\nexport function emotionFromText(text: string): Emotion | null {\n const rules: [RegExp, Emotion][] = [\n [/(>:\\(|😠|😡|🤬)/u, \"angry\"],\n [/(:'\\(|😢|😭)/u, \"sad\"],\n [/(:\\(|☹|🙁|😞)/u, \"sad\"],\n [/(:o|:O|😮|😲)/u, \"surprised\"],\n [/(😱|😨)/u, \"scared\"],\n [/(-_-|😴|😩|🥱)/u, \"tired\"],\n [/(:\\)|:D|\\^\\^|😀|😃|😄|😁|🙂|😊|😂)/u, \"happy\"],\n ];\n for (const [re, e] of rules) if (re.test(text)) return e;\n return null;\n}\n"
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
"path": "character/anim/types.ts",
|
|
141
|
-
"content": "// Общие типы анимации персонажа.\n//\n// Поза — смещения костей ОТ ПОКОЯ по РОЛЯМ (character/rigs): поворот (rx, ry, rz — радианы),\n// сдвиг (px, py, pz — пиксели модели) и масштаб (sx, sy, sz — множители). Анимации ничего не знают о\n// конкретном скелете: «шаг» двигает legL/legR, у кого они есть; роли, которых нет у персонажа,\n// просто никуда не применяются.\n//\n// Знаки (как у мобов): rx > 0 — конечность вперёд, голова вверх; ry > 0 — голова влево персонажа;\n// у левой руки/крыла rz < 0 — в сторону от тела, у правой rz > 0.\n\nexport const CH = {\n rx: 0,\n ry: 1,\n rz: 2,\n px: 3,\n py: 4,\n pz: 5,\n sx: 6,\n sy: 7,\n sz: 8,\n} as const;\nexport type Channel = keyof typeof CH;\nexport const CHANNELS = Object.keys(CH) as Channel[];\n/** Значения покоя каналов (масштаб — 1). */\nexport const REST = [0, 0, 0, 0, 0, 0, 1, 1, 1] as const;\n\n/** Поза: роль → 9 каналов. Нет роли — кость в покое. */\nexport type Pose = Map<string, Float64Array>;\n\nexport function bone(pose: Pose, role: string): Float64Array {\n let b = pose.get(role);\n if (!b) {\n b = Float64Array.from(REST);\n pose.set(role, b);\n }\n return b;\n}\n\n/** Прибавить к каналу (масштаб — умножить). */\nexport function add(pose: Pose, role: string, ch: Channel, v: number): void {\n const b = bone(pose, role);\n const i = CH[ch];\n if (i >= 6) b[i] = b[i]! * v;\n else b[i] = b[i]! + v;\n}\n\nexport function setCh(pose: Pose, role: string, ch: Channel, v: number): void {\n bone(pose, role)[CH[ch]] = v;\n}\n\nexport function clearPose(pose: Pose): void {\n for (const b of pose.values()) b.set(REST);\n}\n\n/** out = a + (b − a)·w по всем ролям обеих поз (нет роли — покой). */\nexport function lerpPose(out: Pose, a: Pose, b: Pose, w: number): void {\n const roles = new Set([...a.keys(), ...b.keys()]);\n for (const r of roles) {\n const x = a.get(r) ?? REST,\n y = b.get(r) ?? REST,\n o = bone(out, r);\n for (let i = 0; i < 9; i++) o[i] = x[i]! + (y[i]! - x[i]!) * w;\n }\n}\n\nexport function copyPose(out: Pose, src: Pose): void {\n clearPose(out);\n for (const [r, b] of src) bone(out, r).set(b);\n}\n\n/** Что персонаж держит — от этого поза руки и действия (лук натягивают, щит держат). */\nexport const HELD_KINDS = [\n \"none\",\n \"block\",\n \"tool\",\n \"sword\",\n \"bow\",\n \"shield\",\n \"food\",\n \"staff\",\n \"item\",\n] as const;\nexport type HeldKind = (typeof HELD_KINDS)[number];\n\n/** Действие «держать ПКМ». */\nexport type UseAction = \"eat\" | \"drink\" | \"draw\" | \"block\";\n\n/**\n * Всё, что анимация знает о персонаже за кадр. Заполняют: свой игрок (Player), сетевой\n * (RemotePlayer по позе), NPC и моб. Новое состояние игры — новое поле здесь, потребители не\n * меняются: у кого поля нет, у того оно в значении по умолчанию (newAnimInput).\n */\nexport interface AnimInput {\n /** Горизонтальная скорость, блоков/с. */\n speed: number;\n /** Вертикальная скорость, блоков/с. */\n vy: number;\n onGround: boolean;\n inWater: boolean;\n flying: boolean;\n gliding: boolean;\n sneaking: boolean;\n sprinting: boolean;\n riding: boolean;\n sitting: boolean;\n sleeping: boolean;\n dead: boolean;\n /** Голова относительно тела: поворот (влево +) и наклон (вверх +), радианы. */\n lookYaw: number;\n lookPitch: number;\n /** Скорость поворота тела, рад/с (вторичное качание хвоста и плаща). */\n yawRate: number;\n held: HeldKind;\n use: UseAction | null;\n /** Говорит — рот открывается (речь ИИ-NPC, чат). */\n talk: boolean;\n}\n\nexport function newAnimInput(): AnimInput {\n return {\n speed: 0,\n vy: 0,\n onGround: true,\n inWater: false,\n flying: false,\n gliding: false,\n sneaking: false,\n sprinting: false,\n riding: false,\n sitting: false,\n sleeping: false,\n dead: false,\n lookYaw: 0,\n lookPitch: 0,\n yawRate: 0,\n held: \"none\",\n use: null,\n talk: false,\n };\n}\n\n/** Разовые события кадра: замах, удар по персонажу, приземление… */\nexport const ANIM_EVENTS = [\n \"swing\",\n \"hurt\",\n \"place\",\n \"land\",\n \"jump\",\n \"throw\",\n] as const;\nexport type AnimEvent = (typeof ANIM_EVENTS)[number];\n"
|
|
142
|
-
},
|
|
143
124
|
{
|
|
144
125
|
"path": "character/CharacterActor.ts",
|
|
145
|
-
"content": "// Персонаж в мире целиком: вид (CharacterView) + аниматор + поворот тела за головой + предмет в\n// руке. Один класс для своего игрока (вид от третьего лица), сетевых игроков (net/RemotePlayer),\n// NPC и своих мобов: каждый раз в кадр ему дают ActorFrame — где стоит, куда смотрит, что делает.\nimport { WEAR_SLOT_IDS, type WearSlot } from \"./items/ItemSkinSpec\";\nimport { skinObject, skinOf, wearSocket } from \"./items/skins\";\nimport type * as THREE from \"three\";\nimport { heldKindOf, thirdPersonItem } from \"../gfx/itemModel\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport type
|
|
146
|
-
},
|
|
147
|
-
{
|
|
148
|
-
"path": "character/CharacterAsset.ts",
|
|
149
|
-
"content": "// GPU-ресурсы персонажа по рецепту: геометрия костей и атлас. Кэш по хэшу рецепта со счётчиком\n// ссылок — двадцать одинаковых NPC держат одну геометрию и одну текстуру.\nimport * as THREE from \"three\";\nimport { specHash, type CharacterSpec } from \"./CharacterSpec\";\nimport { layoutCharacter, type CharacterLayout } from \"./layout\";\n\nexport interface CharacterAsset {\n readonly layout: CharacterLayout;\n /** Кость → геометрия (у кости без коробок — null). */\n readonly geometries: Map<string, THREE.BufferGeometry | null>;\n readonly texture: THREE.DataTexture;\n refs: number;\n}\n\nconst CACHE = new Map<string, CharacterAsset>();\n\nfunction build(layout: CharacterLayout): CharacterAsset {\n const geometries = new Map<string, THREE.BufferGeometry | null>();\n for (const b of layout.bones) {\n if (!b.indices.length) {\n geometries.set(b.name, null);\n continue;\n }\n const g = new THREE.BufferGeometry();\n g.setAttribute(\"position\", new THREE.BufferAttribute(b.positions, 3));\n g.setAttribute(\"normal\", new THREE.BufferAttribute(b.normals, 3));\n g.setAttribute(\"uv\", new THREE.BufferAttribute(b.uvs, 2));\n g.setIndex(new THREE.BufferAttribute(b.indices, 1));\n g.computeBoundingSphere();\n // общая на всех персонажей с этим рецептом: Mob.dispose и прочие обходы её не трогают\n g.userData.shared = true;\n geometries.set(b.name, g);\n }\n const { w, h, data } = layout.atlas;\n const texture = new THREE.DataTexture(data, w, h, THREE.RGBAFormat);\n texture.magFilter = THREE.NearestFilter;\n texture.minFilter = THREE.NearestFilter;\n texture.generateMipmaps = false;\n texture.colorSpace = THREE.SRGBColorSpace;\n texture.needsUpdate = true;\n return { layout, geometries, texture, refs: 0 };\n}\n\n/** Ресурсы рецепта (общие для всех персонажей с тем же рецептом). Отдать — releaseCharacter. */\nexport function acquireCharacter(spec: CharacterSpec): CharacterAsset {\n let a = CACHE.get(specHash(spec));\n if (!a) {\n a = build(layoutCharacter(spec));\n CACHE.set(a.layout.hash, a);\n }\n a.refs++;\n return a;\n}\n\nexport function releaseCharacter(a: CharacterAsset): void {\n if (--a.refs > 0) return;\n CACHE.delete(a.layout.hash);\n for (const g of a.geometries.values()) g?.dispose();\n a.texture.dispose();\n}\n"
|
|
126
|
+
"content": "// Персонаж в мире целиком: вид (CharacterView) + аниматор + поворот тела за головой + предмет в\n// руке. Один класс для своего игрока (вид от третьего лица), сетевых игроков (net/RemotePlayer),\n// NPC и своих мобов: каждый раз в кадр ему дают ActorFrame — где стоит, куда смотрит, что делает.\nimport { WEAR_SLOT_IDS, type WearSlot } from \"./items/ItemSkinSpec\";\nimport { skinObject, skinOf, wearSocket } from \"./items/skins\";\nimport type * as THREE from \"three\";\nimport { heldKindOf, thirdPersonItem } from \"../gfx/itemModel\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport {\n type CharacterSpec,\n CharacterView,\n type CharacterViewOptions,\n Animator,\n type EmoteId,\n type Emotion,\n newAnimInput,\n type AnimEvent,\n type AnimInput,\n type UseAction,\n} from \"@idosgames/characters/voxel\";\n\n/** Состояние персонажа за кадр — в мировых единицах (блоки, радианы). */\nexport interface ActorFrame {\n x: number;\n y: number;\n z: number;\n /** Куда смотрит голова. */\n yaw: number;\n /** Куда повёрнуто тело; нет — тело само догоняет голову (как в Minecraft). */\n bodyYaw?: number;\n pitch: number;\n speed: number;\n vy: number;\n onGround: boolean;\n inWater?: boolean;\n flying?: boolean;\n gliding?: boolean;\n sneaking?: boolean;\n sprinting?: boolean;\n riding?: boolean;\n sitting?: boolean;\n sleeping?: boolean;\n dead?: boolean;\n /** Предмет в руке (id из ITEMS) и облик предмета (хэш из tag.skin), если есть. */\n held?: string | null;\n heldSkin?: string | null;\n use?: UseAction | null;\n talk?: boolean;\n /** Свет места (0..1), как у мобов. */\n bright?: number;\n}\n\n/** Предел поворота головы относительно тела, пока стоит. */\nconst NECK = 0.85;\n\nconst wrap = (a: number) => {\n while (a > Math.PI) a -= Math.PI * 2;\n while (a < -Math.PI) a += Math.PI * 2;\n return a;\n};\n\nexport type HeldModel = (key: string) => THREE.Object3D | null;\n\nexport class CharacterActor {\n readonly view: CharacterView;\n animator: Animator;\n private readonly input: AnimInput = newAnimInput();\n private bodyYaw: number | null = null;\n private heldKey: string | null = null;\n /** Надетое: слот → хэш облика (character/items). */\n private outfit: Partial<Record<WearSlot, string>> = {};\n private outfitShown = \"\";\n /** Модель предмета в руке: по умолчанию — иконка/куб; облики от ИИ подставляют свою. */\n heldModel: HeldModel | null;\n\n constructor(\n spec?: CharacterSpec,\n opts: CharacterViewOptions & { atlas?: TextureAtlas | null } = {},\n ) {\n this.view = new CharacterView(spec, opts);\n const atlas = opts.atlas ?? null;\n this.heldModel = atlas ? (key) => thirdPersonItem(atlas, key) : null;\n this.animator = this.newAnimator();\n }\n\n private newAnimator(): Animator {\n const s = this.view.spec;\n return new Animator(s.rig, s.style, this.view.expressions);\n }\n\n get group(): THREE.Group {\n return this.view.group;\n }\n\n /** Сменить рецепт (облик по сети, свой персонаж): эмоция и жест — заново. */\n setSpec(spec: CharacterSpec): void {\n const before = this.view.hash;\n this.view.setSpec(spec);\n if (this.view.hash !== before) this.animator = this.newAnimator();\n }\n\n trigger(ev: AnimEvent): void {\n this.animator.trigger(ev);\n }\n\n playEmote(id: EmoteId): void {\n this.animator.playEmote(id);\n }\n\n setEmotion(e: Emotion, seconds?: number): void {\n this.animator.setEmotion(e, seconds);\n }\n\n update(dt: number, f: ActorFrame): void {\n const g = this.view.group;\n g.position.set(f.x, f.y, f.z);\n\n // тело поворачивается за головой: в движении — сразу, стоя — когда шея уже не достаёт\n const prev = this.bodyYaw ?? f.yaw;\n let body = prev;\n const diff = wrap(f.yaw - body);\n if (f.bodyYaw !== undefined) body = f.bodyYaw;\n else if (f.speed > 0.3 || f.riding || f.dead)\n body += diff * Math.min(1, dt * 10);\n else if (Math.abs(diff) > NECK) body = f.yaw - Math.sign(diff) * NECK;\n body = wrap(body);\n this.bodyYaw = body;\n g.rotation.y = body;\n\n const i = this.input;\n i.speed = f.speed;\n i.vy = f.vy;\n i.onGround = f.onGround;\n i.inWater = !!f.inWater;\n i.flying = !!f.flying;\n i.gliding = !!f.gliding;\n i.sneaking = !!f.sneaking;\n i.sprinting = !!f.sprinting;\n i.riding = !!f.riding;\n i.sitting = !!f.sitting;\n i.sleeping = !!f.sleeping;\n i.dead = !!f.dead;\n i.lookYaw = wrap(f.yaw - body);\n i.lookPitch = f.pitch;\n i.yawRate = dt > 0 ? wrap(body - prev) / dt : 0;\n i.held = heldKindOf(f.held ?? null);\n i.use = f.use ?? null;\n i.talk = !!f.talk;\n\n this.syncHeld(f.held ?? null, f.heldSkin ?? null);\n this.syncOutfit();\n // крылья на спине раскрываются в планировании и полёте\n const back = this.view.attachedAt(\"back\");\n if (back && this.wings) {\n const open = f.gliding || f.flying ? 1 : 0.55;\n back.scale.x += (open * (1 / 16) - back.scale.x) * Math.min(1, dt * 8);\n }\n const pose = this.animator.update(dt, i);\n this.view.applyPose(pose);\n this.view.setExpression(this.animator.expression);\n if (f.bright !== undefined) this.view.setBrightness(f.bright);\n this.view.setLabelVisible(!f.dead);\n }\n\n private syncHeld(key: string | null, skinHash: string | null): void {\n // облик, который ещё не дошёл (по сети), — пока обычный предмет; дойдёт — подменится\n const skin = skinHash ? skinOf(skinHash) : undefined;\n const id = key\n ? key + \"|\" + (skin && skin.spec.base === key ? skin.hash : \"\")\n : null;\n if (id === this.heldKey) return;\n this.heldKey = id;\n const obj = !key\n ? null\n : skin && skin.spec.base === key\n ? skinObject(skin, \"hand\")\n : this.heldModel\n ? this.heldModel(key)\n : null;\n this.view.attach(\"handR\", obj);\n }\n\n /** Надеть косметику: слот → хэш облика (null/нет — снять). */\n setOutfit(outfit: Partial<Record<WearSlot, string>>): void {\n this.outfit = { ...outfit };\n }\n\n /** Надетое на спину — крылья (широкое): раскрываются в полёте. */\n private wings = false;\n\n private syncOutfit(): void {\n // ключ — с учётом того, какие облики уже загружены\n let key = \"\";\n for (const slot of WEAR_SLOT_IDS) {\n const h = this.outfit[slot];\n key += slot + \":\" + (h && skinOf(h) ? h : \"\") + \";\";\n }\n if (key === this.outfitShown) return;\n this.outfitShown = key;\n this.wings = false;\n for (const slot of WEAR_SLOT_IDS) {\n const skin = skinOf(this.outfit[slot]);\n const ok =\n skin && skin.spec.kind === \"wearable\" && skin.spec.slot === slot;\n this.view.attach(wearSocket(slot), ok ? skinObject(skin, \"wear\") : null);\n if (ok && slot === \"back\" && skin.spec.size[0] >= 12) this.wings = true;\n }\n }\n\n dispose(): void {\n this.view.dispose();\n }\n}\n"
|
|
150
127
|
},
|
|
151
128
|
{
|
|
152
129
|
"path": "character/CharacterPreview.ts",
|
|
153
|
-
"content": "// Превью персонажа на своём холсте: кукла в инвентаре и вертушка студии персонажа. Своя маленькая\n// сцена со светом и свой рендерер — мир игры для показа персонажа не нужен. Тянешь мышью/пальцем —\n// поворачивается; демонстрация состояний (ходьба, бег, присед, полёт…), эмоций и жестов.\nimport * as THREE from \"three\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport { CharacterActor, type ActorFrame } from \"./CharacterActor\";\nimport type {
|
|
154
|
-
},
|
|
155
|
-
{
|
|
156
|
-
"path": "character/characters.ts",
|
|
157
|
-
"content": "// Известные рецепты персонажей в памяти: хэш → рецепт. Сюда попадают свой персонаж, «Мои\n// персонажи», облики других игроков (по сети), NPC и свои мобы из сейва мира, Мастерская. По хэшу\n// их находят сеть (облик игрока — хэш в PlayerMeta) и сущности (свой моб — \"c:<хэш>\").\nimport { specHash, type CharacterSpec } from \"./CharacterSpec\";\n\nconst KNOWN = new Map<string, CharacterSpec>();\nconst listeners = new Set<(hash: string) => void>();\n\nexport function registerCharacter(spec: CharacterSpec): string {\n const h = specHash(spec);\n if (!KNOWN.has(h)) {\n KNOWN.set(h, spec);\n for (const fn of listeners) fn(h);\n }\n return h;\n}\n\nexport function characterOf(hash: unknown): CharacterSpec | undefined {\n return typeof hash === \"string\" ? KNOWN.get(hash) : undefined;\n}\n\nexport function characterSpecs(\n hashes: Iterable<string>,\n): Record<string, CharacterSpec> {\n const out: Record<string, CharacterSpec> = {};\n for (const h of hashes) {\n const s = KNOWN.get(h);\n if (s) out[h] = s;\n }\n return out;\n}\n\n/** Подписка: рецепт стал известен (дошёл по сети). Возвращает отписку. */\nexport function onCharacterKnown(fn: (hash: string) => void): () => void {\n listeners.add(fn);\n return () => listeners.delete(fn);\n}\n"
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
"path": "character/CharacterSpec.ts",
|
|
161
|
-
"content": "// Рецепт персонажа — ЕДИНСТВЕННЫЙ формат персонажа: его пишут ИИ, путь без ИИ и студия; его\n// хранят «Мои персонажи», Мастерская, сейв мира (свои мобы, NPC) и передаёт сеть. Модель из рецепта\n// строит игра (character/buildCharacter) — как мир из WorldSpec.\n//\n// Как и у мира, ИИ пишет не геометрию, а конструкцию: коробки на костях шаблона, палитру и\n// пиксель-арт граней (лицо, одежда). Пиксели — строки символов палитры: модель хорошо «рисует»\n// маленькие картинки 8×8 текстом, а три вершины на пиксель она перечислить не может.\n//\n// В модуле нет zod (см. worldgen/WorldSpec) — валидатор ручной. Правило то же: принять что угодно,\n// дополнить пропуски, зажать числа, выбросить неизвестное с предупреждением; отказ — только если это\n// не объект или оборванный JSON.\nimport { hashString } from \"../core/Noise\";\nimport { extractJsonObject } from \"../worldgen/WorldSpec\";\nimport {\n EXTRA_BONE_ROLES,\n RIGS,\n RIG_IDS,\n type ExtraBoneRole,\n type RigId,\n type RigTemplate,\n type V3,\n} from \"./rigs\";\nimport { tr } from \"../i18n\";\n\nexport const CHARACTER_SPEC_VERSION = 1 as const;\n\n/** Грани коробки: front смотрит туда же, куда лицо (−z), left — на левую руку персонажа (−x). */\nexport const FACE_NAMES = [\n \"front\",\n \"back\",\n \"left\",\n \"right\",\n \"top\",\n \"bottom\",\n] as const;\nexport type FaceName = (typeof FACE_NAMES)[number];\n\n/**\n * Выражения лица. Обязательно только neutral — остальные, если их нет, берутся по цепочке замен\n * (anim/emotions.ts): «испуган» без своей картинки — «удивлён», без неё — обычное лицо.\n */\nexport const EXPRESSIONS = [\n \"neutral\",\n \"blink\",\n \"happy\",\n \"sad\",\n \"angry\",\n \"surprised\",\n \"scared\",\n \"tired\",\n \"hurt\",\n \"talk\",\n] as const;\nexport type Expression = (typeof EXPRESSIONS)[number];\n\nexport const WALK_STYLES = [\n \"normal\",\n \"bouncy\",\n \"heavy\",\n \"sneaky\",\n \"robotic\",\n] as const;\nexport type WalkStyle = (typeof WALK_STYLES)[number];\nexport const IDLE_STYLES = [\"calm\", \"fidgety\", \"proud\"] as const;\nexport type IdleStyle = (typeof IDLE_STYLES)[number];\n\nexport const CHARACTER_LIMITS = {\n parts: 64,\n bones: 16,\n palette: 32,\n textures: 48,\n /** Всего пикселей во всех текстурах. */\n texturePixels: 32768,\n /** Сторона одной текстуры. */\n textureSide: 32,\n /** Сторона коробки и координаты — в пикселях модели. */\n partSide: 32,\n coord: 64,\n} as const;\n\n/** Картинка из символов палитры; \".\" и пробел — прозрачно. rows[0] — верхняя строка. */\nexport interface PixelTex {\n w: number;\n h: number;\n rows: string[];\n}\n\nexport interface CharPart {\n /** Кость, вместе с которой двигается коробка. */\n bone: string;\n /** [ширина x, высота y, глубина z] в пикселях. */\n size: V3;\n /** Центр коробки в пикселях модели. */\n pos: V3;\n /** Символ палитры — цвет граней без своей текстуры. */\n color: string;\n /** Грань → id текстуры или символ палитры. */\n faces?: Partial<Record<FaceName, string>>;\n /** Поворот коробки вокруг её центра, градусы [x, y, z]. */\n rot?: V3;\n /** Ещё одна такая же коробка зеркально по x (на парную кость: armL ↔ armR). */\n mirror?: boolean;\n name?: string;\n}\n\n/** Своя кость рецепта: хвост, уши, плащ… Или новая точка вращения кости шаблона (то же имя). */\nexport interface ExtraBone {\n name: string;\n parent: string;\n pivot: V3;\n role: ExtraBoneRole;\n}\n\nexport interface FaceSpec {\n /** Кость с лицом (по умолчанию — голова шаблона). */\n bone: string;\n /**\n * Где на передней грани главной коробки кости: [x, y, w, h] в пикселях, от левого верхнего угла\n * так, как лицо видит смотрящий на персонажа.\n */\n rect: [number, number, number, number];\n /** Выражение → id текстуры размером w×h. */\n expressions: Partial<Record<Expression, string>>;\n}\n\nexport interface CharStyle {\n walk: WalkStyle;\n idle: IdleStyle;\n /** 0 — вялый, 1 — заводной: темп и размах движений. */\n energy: number;\n}\n\n/**\n * Отделка граней — то, что превращает плоские цвета в «ткань» (как на референсе владельца\n * 11.10.2026): мелкий шум и пятна, тёмный контур по краю каждой грани, светотень сверху вниз.\n * Применяется к каждой грани при сборке (character/layout); модель ИИ задаёт только силу.\n */\nexport interface CharFinish {\n /** Шум ткани 0..1. */\n noise: number;\n /** Тёмный контур по краю грани 0..1. */\n outline: number;\n /** Светотень: верх светлее, низ темнее 0..1. */\n shade: number;\n /** Текселей на пиксель модели у граней без своей картинки (1..4). */\n density: number;\n}\n\nexport const DEFAULT_FINISH: CharFinish = {\n noise: 0.5,\n outline: 0.4,\n shade: 0.25,\n density: 2,\n};\n\nexport interface CharacterSpec {\n version: typeof CHARACTER_SPEC_VERSION;\n name: string;\n description: string;\n rig: RigId;\n /** Масштаб модели (рост NPC и мобов; игроку — в пределах 0.8–1.15). */\n scale: number;\n /** Символ → \"#rrggbb\". */\n palette: Record<string, string>;\n textures: Record<string, PixelTex>;\n parts: CharPart[];\n bones: ExtraBone[];\n face: FaceSpec | null;\n style: CharStyle;\n /** Высота голоса (звуки, будущая речь NPC). */\n voice: { pitch: number };\n /** Метки для поиска и будущего поведения NPC (\"friendly\", \"merchant\"…). */\n tags: string[];\n finish: CharFinish;\n}\n\nexport type CharacterParseResult =\n | { ok: true; spec: CharacterSpec; warnings: string[] }\n | { ok: false; error: string; truncated?: boolean };\n\n// ---- разбор -------------------------------------------------------------------------\n\ntype Obj = Record<string, unknown>;\nexport const isObj = (v: unknown): v is Obj =>\n typeof v === \"object\" && v !== null && !Array.isArray(v);\nconst arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);\n\nexport function 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\nexport function pick<T extends string>(\n v: unknown,\n allowed: readonly T[],\n def: T,\n): T {\n if (typeof v !== \"string\") return def;\n const s = v.trim();\n if ((allowed as readonly string[]).includes(s)) return s as T;\n const low = s.toLowerCase().replace(/[\\s-]+/g, \"_\");\n const hit = allowed.find((a) => a.toLowerCase() === low);\n return hit ?? 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\nexport function vec3(\n v: unknown,\n lim: number,\n def: V3 | null = null,\n): V3 | null {\n if (!Array.isArray(v) || v.length < 3) return def;\n const out = v.slice(0, 3).map((x) => num(x, NaN, -lim, lim));\n return out.every(Number.isFinite) ? (out as V3) : def;\n}\n\nconst NAMED_COLORS: Record<string, string> = {\n black: \"#1d1d21\",\n white: \"#f0f0f0\",\n gray: \"#808080\",\n grey: \"#808080\",\n red: \"#c0392b\",\n green: \"#3c9a3c\",\n blue: \"#2f5fd0\",\n yellow: \"#f2d33c\",\n orange: \"#e8862a\",\n purple: \"#8a4fc4\",\n pink: \"#f08cb4\",\n brown: \"#7a4f2a\",\n cyan: \"#2ab0c0\",\n gold: \"#d8b030\",\n silver: \"#c0c4c8\",\n skin: \"#c69c7c\",\n};\n\n/** \"#abc\", \"#aabbcc\", \"aabbcc\", имя цвета → \"#aabbcc\"; иначе null. */\nexport function normColor(v: unknown): string | null {\n if (typeof v !== \"string\") return null;\n const s = v.trim().toLowerCase();\n if (NAMED_COLORS[s]) return NAMED_COLORS[s];\n const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s);\n if (!m) return null;\n const h = m[1]!;\n return (\n \"#\" +\n (h.length === 3\n ? h\n .split(\"\")\n .map((c) => c + c)\n .join(\"\")\n : h)\n );\n}\n\n/** Прозрачные символы пиксель-арта. */\nexport const CLEAR = new Set([\".\", \" \"]);\n\nconst idOk = (s: string) => /^[A-Za-z0-9_]{1,24}$/.test(s);\n\n/** Синонимы шаблонов, которыми модели называют скелет. */\nconst RIG_ALIASES: Record<string, RigId> = {\n human: \"humanoid\",\n biped: \"humanoid\",\n person: \"humanoid\",\n animal: \"quadruped\",\n four_legs: \"quadruped\",\n quadrupedal: \"quadruped\",\n bird: \"flyer\",\n winged: \"flyer\",\n dragon: \"flyer\",\n slime: \"blob\",\n ghost: \"blob\",\n};\n\nfunction rigOf(v: unknown): RigId {\n if (typeof v !== \"string\") return \"humanoid\";\n const s = v\n .trim()\n .toLowerCase()\n .replace(/[\\s-]+/g, \"_\");\n if ((RIG_IDS as readonly string[]).includes(s)) return s as RigId;\n return RIG_ALIASES[s] ?? \"humanoid\";\n}\n\n/** Парная кость: armL ↔ armR, legFL ↔ legFR, earL ↔ earR; нет пары — та же. */\nexport function mirrorBone(\n name: string,\n exists: (n: string) => boolean,\n): string {\n const m = /^(.*?)(L|R)$/.exec(name);\n if (!m) return name;\n const other = m[1]! + (m[2] === \"L\" ? \"R\" : \"L\");\n return exists(other) ? other : name;\n}\n\n/**\n * Единственная дверь в игру для рецепта персонажа: объект или текст ответа модели → нормализованный\n * рецепт. Отказ — только если это не объект или оборванный JSON.\n */\nexport function parseCharacterSpec(input: unknown): CharacterParseResult {\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))\n return {\n ok: false,\n error: tr({\n en: \"the character recipe is not an object\",\n ru: \"рецепт персонажа — не объект\",\n }),\n };\n // { \"character\": { ... } } — разворачиваем\n if (!(\"parts\" in input) && !(\"rig\" in input)) {\n const inner = input.character ?? input.spec ?? input.characterSpec;\n if (isObj(inner)) input = inner;\n }\n const o = input as Obj;\n const L = CHARACTER_LIMITS;\n const warnings: string[] = [];\n if (typeof o.version === \"number\" && o.version > CHARACTER_SPEC_VERSION)\n warnings.push(\n tr(\n {\n en: \"recipe version {v} is newer than the game — read what could be read\",\n ru: \"рецепт версии {v} — новее игры, разобрано что понятно\",\n },\n { v: o.version },\n ),\n );\n\n const rig = rigOf(o.rig);\n const tpl = RIGS[rig];\n\n // палитра: символ → цвет\n const palette: Record<string, string> = {};\n if (isObj(o.palette))\n for (const [k, v] of Object.entries(o.palette)) {\n if (Object.keys(palette).length >= L.palette) {\n warnings.push(\n tr(\n {\n en: \"the palette is longer than {n} — the rest was dropped\",\n ru: \"палитра длиннее {n} — лишнее отброшено\",\n },\n { n: L.palette },\n ),\n );\n break;\n }\n const c = normColor(v);\n if (k.length !== 1 || CLEAR.has(k) || !c) {\n warnings.push(\n tr(\n {\n en: \"palette colour «{k}» was not understood\",\n ru: \"цвет палитры «{k}» не понят\",\n },\n { k },\n ),\n );\n continue;\n }\n palette[k] = c;\n }\n // цвет вне палитры (\"#ff0000\" прямо в детали) — заводим ему свой символ\n const FREE = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789\";\n const colorKey = (v: unknown, def: string): string => {\n if (typeof v !== \"string\") return def;\n if (v.length === 1 && palette[v]) return v;\n const c = normColor(v);\n if (!c) return def;\n const have = Object.entries(palette).find(([, x]) => x === c);\n if (have) return have[0];\n if (Object.keys(palette).length >= L.palette) return def;\n const free = [...FREE].find((ch) => !palette[ch]);\n if (!free) return def;\n palette[free] = c;\n return free;\n };\n\n // текстуры\n const textures: Record<string, PixelTex> = {};\n let pixels = 0;\n if (isObj(o.textures))\n for (const [id, raw] of Object.entries(o.textures)) {\n if (!idOk(id)) {\n warnings.push(\n tr(\n {\n en: \"texture name «{id}» does not fit\",\n ru: \"имя текстуры «{id}» не подходит\",\n },\n { id: id.slice(0, 30) },\n ),\n );\n continue;\n }\n if (Object.keys(textures).length >= L.textures) {\n warnings.push(\n tr(\n {\n en: \"more than {n} textures — the rest were dropped\",\n ru: \"текстур больше {n} — лишние отброшены\",\n },\n { n: L.textures },\n ),\n );\n break;\n }\n const t = normTexture(raw, palette, warnings, id);\n if (!t) continue;\n if (pixels + t.w * t.h > L.texturePixels) {\n warnings.push(\n tr(\n {\n en: \"texture «{id}» did not fit the pixel limit\",\n ru: \"текстура «{id}» не влезла в лимит пикселей\",\n },\n { id },\n ),\n );\n continue;\n }\n pixels += t.w * t.h;\n textures[id] = t;\n }\n\n // свои кости\n const boneNames = new Set(tpl.bones.map((b) => b.name));\n const bones: ExtraBone[] = [];\n for (const raw of arr(o.bones)) {\n if (!isObj(raw)) continue;\n const name = str(raw.name, \"\", 24);\n if (!name || !idOk(name)) continue;\n const pivot = vec3(raw.pivot, L.coord);\n if (!pivot) continue;\n if (bones.some((b) => b.name === name)) continue;\n if (bones.length >= L.bones) {\n warnings.push(\n tr(\n {\n en: \"more than {n} bones — the rest were dropped\",\n ru: \"костей больше {n} — лишние отброшены\",\n },\n { n: L.bones },\n ),\n );\n break;\n }\n const isTpl = boneNames.has(name);\n const parentRaw = str(raw.parent, \"\", 24);\n const parent = isTpl\n ? (tpl.bones.find((b) => b.name === name)!.parent ?? \"\")\n : boneNames.has(parentRaw) || bones.some((b) => b.name === parentRaw)\n ? parentRaw\n : tpl.bones.find((b) => b.role === \"body\")!.name;\n bones.push({\n name,\n parent,\n pivot,\n role: isTpl ? \"static\" : pick(raw.role, EXTRA_BONE_ROLES, \"static\"),\n });\n boneNames.add(name);\n }\n\n // детали\n const fallbackColor = Object.keys(palette)[0] ?? \"s\";\n const parts: CharPart[] = [];\n for (const raw of arr(o.parts)) {\n if (parts.length >= L.parts) {\n warnings.push(\n tr(\n {\n en: \"more than {n} parts — the rest were dropped\",\n ru: \"деталей больше {n} — лишние отброшены\",\n },\n { n: L.parts },\n ),\n );\n break;\n }\n if (!isObj(raw)) continue;\n const size = vec3(raw.size, L.partSide);\n const pos = vec3(raw.pos ?? raw.center ?? raw.at, L.coord);\n if (!size || !pos || size.some((s) => s <= 0)) continue;\n let bone = str(raw.bone, \"\", 24);\n if (!boneNames.has(bone)) {\n if (bone)\n warnings.push(\n tr(\n {\n en: \"bone «{b}» not found — the part is on the body\",\n ru: \"кость «{b}» не найдена — деталь на теле\",\n },\n { b: bone },\n ),\n );\n bone = tpl.bones.find((b) => b.role === \"body\")!.name;\n }\n const part: CharPart = {\n bone,\n size,\n pos,\n color: colorKey(raw.color, fallbackColor),\n };\n if (isObj(raw.faces)) {\n const faces: Partial<Record<FaceName, string>> = {};\n for (const f of FACE_NAMES) {\n const v = raw.faces[f];\n if (typeof v !== \"string\") continue;\n if (textures[v]) faces[f] = v;\n else {\n const k = colorKey(v, \"\");\n if (k) faces[f] = k;\n }\n }\n if (Object.keys(faces).length) part.faces = faces;\n }\n const rot = vec3(raw.rot ?? raw.rotation, 180);\n if (rot && rot.some((r) => r !== 0)) part.rot = rot;\n if (raw.mirror === true || raw.mirror === \"x\") part.mirror = true;\n const name = str(raw.name, \"\", 24);\n if (name) part.name = name;\n parts.push(part);\n }\n if (!parts.length) {\n // без деталей — тело шаблона; цвета, которых нет в палитре, — по умолчанию\n for (const [k, c] of Object.entries(DEFAULT_COLORS))\n if (!palette[k]) palette[k] = c;\n parts.push(...tpl.defaultParts.map(clonePart));\n }\n\n // лицо\n let face: FaceSpec | null = null;\n if (isObj(o.face)) {\n const f = o.face;\n const bone = boneNames.has(str(f.bone, \"\", 24))\n ? str(f.bone, \"\", 24)\n : tpl.faceBone;\n const r = arr(f.rect).map((x) => Math.round(num(x, NaN, 0, L.partSide)));\n const expressions: Partial<Record<Expression, string>> = {};\n if (isObj(f.expressions))\n for (const e of EXPRESSIONS) {\n const id = f.expressions[e];\n if (typeof id === \"string\" && textures[id]) expressions[e] = id;\n }\n if (!expressions.neutral) {\n const first = Object.values(expressions)[0];\n if (first) expressions.neutral = first;\n }\n if (expressions.neutral) {\n const t = textures[expressions.neutral]!;\n face = {\n bone,\n rect:\n r.length === 4 && r.every(Number.isFinite) && r[2]! > 0 && r[3]! > 0\n ? (r as [number, number, number, number])\n : [0, 0, t.w, t.h],\n expressions,\n };\n } else if (Object.keys(f).length)\n warnings.push(\n tr({\n en: \"the face has no pictures — no face is drawn\",\n ru: \"у лица нет ни одной картинки — лицо не нарисовано\",\n }),\n );\n }\n\n const so = isObj(o.style) ? o.style : {};\n const vo = isObj(o.voice) ? o.voice : {};\n const fo = isObj(o.finish) ? o.finish : {};\n const spec: CharacterSpec = {\n version: CHARACTER_SPEC_VERSION,\n name: str(o.name, defaultCharacterName(), 40) || defaultCharacterName(),\n description: str(o.description, \"\", 300),\n rig,\n scale: num(o.scale, 1, 0.25, 3),\n palette,\n textures,\n parts,\n bones,\n face,\n style: {\n walk: pick(so.walk, WALK_STYLES, \"normal\"),\n idle: pick(so.idle, IDLE_STYLES, \"calm\"),\n energy: num(so.energy, 0.5, 0, 1),\n },\n voice: { pitch: num(vo.pitch, 1, 0.5, 2) },\n tags: arr(o.tags)\n .filter((t): t is string => typeof t === \"string\" && !!t.trim())\n .slice(0, 8)\n .map((t) => t.trim().slice(0, 24)),\n finish: {\n noise: num(fo.noise, DEFAULT_FINISH.noise, 0, 1),\n outline: num(fo.outline, DEFAULT_FINISH.outline, 0, 1),\n shade: num(fo.shade, DEFAULT_FINISH.shade, 0, 1),\n density: Math.round(num(fo.density, DEFAULT_FINISH.density, 1, 4)),\n },\n };\n return { ok: true, spec, warnings };\n}\n\n/** Цвета тела шаблона по умолчанию: s — кожа/голова, t — туловище, p — ноги. */\nexport const DEFAULT_COLORS: Record<string, string> = {\n s: \"#c69c7c\",\n t: \"#3a8fd6\",\n p: \"#2c3e6b\",\n};\n\nfunction clonePart(p: CharPart): CharPart {\n return JSON.parse(JSON.stringify(p)) as CharPart;\n}\n\nfunction normTexture(\n raw: unknown,\n palette: Record<string, string>,\n warnings: string[],\n id: string,\n): PixelTex | null {\n const o = Array.isArray(raw) ? { rows: raw } : isObj(raw) ? raw : null;\n if (!o) return null;\n const S = CHARACTER_LIMITS.textureSide;\n let rows = arr(o.rows ?? o.pixels).filter(\n (r): r is string => typeof r === \"string\",\n );\n if (!rows.length) return null;\n const h = Math.round(num(o.h ?? o.height, rows.length, 1, S));\n const w = Math.round(\n num(o.w ?? o.width, Math.max(...rows.map((r) => r.length)), 1, S),\n );\n let unknown = false;\n rows = rows.slice(0, h).map((r) => {\n let s = \"\";\n for (let i = 0; i < w; i++) {\n const c = r[i] ?? \".\";\n if (CLEAR.has(c) || palette[c]) s += c === \" \" ? \".\" : c;\n else {\n unknown = true;\n s += \".\";\n }\n }\n return s;\n });\n while (rows.length < h) rows.push(\".\".repeat(w));\n if (unknown)\n warnings.push(\n tr(\n {\n en: \"texture «{id}» has symbols not in the palette — they became transparent\",\n ru: \"в текстуре «{id}» символы не из палитры — стали прозрачными\",\n },\n { id },\n ),\n );\n return { w, h, rows };\n}\n\n// ---- хэш и служебное --------------------------------------------------------------\n\n/** JSON с отсортированными ключами: одинаковый рецепт → одинаковая строка (и хэш). */\nexport function stableStringify(v: unknown): string {\n if (Array.isArray(v)) return \"[\" + v.map(stableStringify).join(\",\") + \"]\";\n if (isObj(v))\n return (\n \"{\" +\n Object.keys(v)\n .filter((k) => v[k] !== undefined)\n .sort()\n .map((k) => JSON.stringify(k) + \":\" + stableStringify(v[k]))\n .join(\",\") +\n \"}\"\n );\n return JSON.stringify(v);\n}\n\n/** Хэш рецепта (16 hex): ключ кэша модели, сетевой «облик», id своего моба. */\nexport function specHash(spec: unknown): string {\n const s = stableStringify(spec);\n const a = hashString(s),\n b = hashString(\"~\" + s);\n return a.toString(16).padStart(8, \"0\") + b.toString(16).padStart(8, \"0\");\n}\n\n/** Шаблон рецепта. */\nexport function rigOfSpec(spec: CharacterSpec): RigTemplate {\n return RIGS[spec.rig];\n}\n\n/** Все кости рецепта (шаблон + свои) с итоговыми точками вращения и ролями. */\nexport function bonesOf(\n spec: CharacterSpec,\n): { name: string; role: string; parent: string | null; pivot: V3 }[] {\n const tpl = RIGS[spec.rig];\n const out = tpl.bones.map((b) => {\n const over = spec.bones.find((x) => x.name === b.name);\n return {\n name: b.name,\n role: b.role as string,\n parent: b.parent,\n pivot: (over?.pivot ?? b.pivot).slice() as V3,\n };\n });\n for (const b of spec.bones)\n if (!out.some((x) => x.name === b.name))\n out.push({\n name: b.name,\n role: b.role,\n parent: b.parent,\n pivot: b.pivot.slice() as V3,\n });\n return out;\n}\n\n/** Имя персонажа без имени — на языке игры; Мастерская узнаёт его на обоих языках. */\nexport function defaultCharacterName(): string {\n return tr({ en: \"Character\", ru: \"Персонаж\" });\n}\n\n/** Это имя по умолчанию (на любом языке)? */\nexport const isDefaultCharacterName = (n: string): boolean =>\n n === \"Персонаж\" || n === \"Character\";\n"
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
"path": "character/CharacterView.ts",
|
|
165
|
-
"content": "// Персонаж в сцене: кости (группы в точках вращения), их меши, лицо-декаль, сокеты для предметов и\n// одежды, табличка с именем. Позу ставит Animator (applyPose), выражение лица — setExpression.\n//\n// Иерархия: group (позиция в мире, поворот yaw) → root (пиксели → блоки, роль \"root\": лечь, присесть,\n// подпрыгнуть) → кости по родителям. Модель смотрит в −z, как мобы, поэтому rotation.y = yaw игрока.\nimport * as THREE from \"three\";\nimport type { CharacterSpec, Expression } from \"./CharacterSpec\";\nimport {\n acquireCharacter,\n releaseCharacter,\n type CharacterAsset,\n} from \"./CharacterAsset\";\nimport { defaultCharacter } from \"./defaultCharacter\";\nimport { disposeNameplate, nameplate } from \"./nameplate\";\nimport type { SocketName, V3 } from \"./rigs\";\nimport { CH, type Pose } from \"./anim/types\";\n\nconst PX = 1 / 16;\n\ninterface BoneNode {\n name: string;\n role: string;\n group: THREE.Group;\n rest: V3;\n}\n\nexport interface CharacterViewOptions {\n /** Табличка с именем над головой (сетевые игроки, NPC). */\n name?: string | null;\n /** Отбрасывает тень (дальние персонажи — нет). */\n shadows?: boolean;\n /** Слой рендера (кукла в инвентаре рисуется отдельной сценой — не нужен). */\n layer?: number;\n}\n\nexport class CharacterView {\n readonly group = new THREE.Group();\n private readonly root = new THREE.Group();\n private asset: CharacterAsset;\n spec: CharacterSpec;\n private bones = new Map<string, BoneNode>();\n /** Кости по ролям (у одной роли может быть несколько костей: два уха). */\n private byRole = new Map<string, BoneNode[]>();\n private meshes: THREE.Mesh[] = [];\n private readonly material: THREE.MeshLambertMaterial;\n private readonly decalMat: THREE.MeshLambertMaterial;\n private decal: THREE.Mesh | null = null;\n private expression: Expression | null = null;\n private readonly sockets = new Map<SocketName, THREE.Object3D>();\n /** То, что висит на сокетах — переживает смену рецепта. */\n private readonly attached = new Map<SocketName, THREE.Object3D>();\n private label: THREE.Sprite | null = null;\n private shadows: boolean;\n private readonly layer: number;\n private brightness = 1;\n\n constructor(\n spec: CharacterSpec = defaultCharacter(),\n opts: CharacterViewOptions = {},\n ) {\n this.spec = spec;\n this.shadows = opts.shadows ?? true;\n this.layer = opts.layer ?? 0;\n this.material = new THREE.MeshLambertMaterial({ alphaTest: 0.5 });\n this.decalMat = new THREE.MeshLambertMaterial({\n alphaTest: 0.5,\n polygonOffset: true,\n polygonOffsetFactor: -2,\n polygonOffsetUnits: -2,\n });\n this.group.add(this.root);\n this.asset = acquireCharacter(spec);\n this.build();\n if (opts.name) this.setName(opts.name);\n }\n\n /** Рост модели в блоках. */\n get height(): number {\n const l = this.asset.layout;\n return l.height * l.scale * PX;\n }\n\n /** Высота глаз в блоках. */\n get eye(): number {\n const l = this.asset.layout;\n return l.eye * l.scale * PX;\n }\n\n /** Какие выражения нарисованы в рецепте (для аниматора). */\n get expressions(): Expression[] {\n return Object.keys(this.asset.layout.face?.uv ?? {}) as Expression[];\n }\n\n get hash(): string {\n return this.asset.layout.hash;\n }\n\n /** Подменить рецепт (облик пришёл по сети, игрок сменил персонажа). Одежда и предметы остаются. */\n setSpec(spec: CharacterSpec): void {\n const next = acquireCharacter(spec);\n if (next === this.asset) {\n releaseCharacter(next);\n return;\n }\n this.clear();\n releaseCharacter(this.asset);\n this.asset = next;\n this.spec = spec;\n this.build();\n }\n\n private build(): void {\n const { layout, geometries, texture } = this.asset;\n this.material.map = texture;\n this.material.needsUpdate = true;\n this.decalMat.map = texture;\n this.decalMat.needsUpdate = true;\n this.root.scale.setScalar(layout.scale * PX);\n\n const pivots = new Map(layout.bones.map((b) => [b.name, b.pivot]));\n for (const b of layout.bones) {\n const g = new THREE.Group();\n const parentPivot: V3 = b.parent\n ? (pivots.get(b.parent) ?? [0, 0, 0])\n : [0, 0, 0];\n const rest: V3 = [\n b.pivot[0] - parentPivot[0],\n b.pivot[1] - parentPivot[1],\n b.pivot[2] - parentPivot[2],\n ];\n g.position.set(...rest);\n const node: BoneNode = { name: b.name, role: b.role, group: g, rest };\n this.bones.set(b.name, node);\n const list = this.byRole.get(b.role) ?? [];\n list.push(node);\n this.byRole.set(b.role, list);\n const geo = geometries.get(b.name);\n if (geo) {\n const m = new THREE.Mesh(geo, this.material);\n m.castShadow = m.receiveShadow = this.shadows;\n m.layers.set(this.layer);\n this.meshes.push(m);\n g.add(m);\n }\n }\n for (const b of layout.bones) {\n const node = this.bones.get(b.name)!;\n const parent = b.parent ? this.bones.get(b.parent) : undefined;\n (parent?.group ?? this.root).add(node.group);\n }\n\n // лицо — квад перед гранью головы; выражение меняет только его UV\n const face = layout.face;\n if (face) {\n const geo = new THREE.BufferGeometry();\n geo.setAttribute(\"position\", new THREE.BufferAttribute(face.corners, 3));\n const n = new Float32Array(12);\n for (let i = 0; i < 4; i++) n[i * 3 + 2] = -1;\n geo.setAttribute(\"normal\", new THREE.BufferAttribute(n, 3));\n geo.setAttribute(\"uv\", new THREE.BufferAttribute(new Float32Array(8), 2));\n geo.setIndex([0, 2, 1, 0, 3, 2]);\n this.decal = new THREE.Mesh(geo, this.decalMat);\n this.decal.layers.set(this.layer);\n this.bones.get(face.bone)?.group.add(this.decal);\n this.expression = null;\n this.setExpression(\"neutral\");\n }\n\n for (const s of layout.sockets) {\n const o = new THREE.Object3D();\n o.position.set(...s.at);\n // предметы и одежда — в блоках, сокет компенсирует масштаб пикселей\n o.scale.setScalar(1 / (layout.scale * PX));\n this.bones.get(s.bone)?.group.add(o);\n this.sockets.set(s.name, o);\n const item = this.attached.get(s.name);\n if (item) o.add(item);\n }\n this.placeLabel();\n this.setBrightness(this.brightness);\n }\n\n /** Снять всё, кроме того, что висит на сокетах (его переносит build). */\n private clear(): void {\n for (const s of this.sockets.values())\n for (const c of [...s.children]) s.remove(c);\n this.root.clear();\n this.decal?.geometry.dispose();\n this.decal = null;\n this.meshes = [];\n this.bones = new Map();\n this.byRole = new Map();\n this.sockets.clear();\n }\n\n applyPose(pose: Pose): void {\n const root = pose.get(\"root\");\n if (root) {\n this.root.rotation.set(root[CH.rx]!, root[CH.ry]!, root[CH.rz]!);\n // сдвиг корня — в пикселях модели\n const k = this.asset.layout.scale * PX;\n this.root.position.set(\n root[CH.px]! * k,\n root[CH.py]! * k,\n root[CH.pz]! * k,\n );\n } else {\n this.root.rotation.set(0, 0, 0);\n this.root.position.set(0, 0, 0);\n }\n for (const [role, nodes] of this.byRole) {\n const b = pose.get(role);\n for (const n of nodes) {\n const g = n.group;\n if (!b) {\n g.rotation.set(0, 0, 0);\n g.position.set(...n.rest);\n g.scale.set(1, 1, 1);\n continue;\n }\n g.rotation.set(b[CH.rx]!, b[CH.ry]!, b[CH.rz]!);\n g.position.set(\n n.rest[0] + b[CH.px]!,\n n.rest[1] + b[CH.py]!,\n n.rest[2] + b[CH.pz]!,\n );\n g.scale.set(b[CH.sx]!, b[CH.sy]!, b[CH.sz]!);\n }\n }\n }\n\n setExpression(e: Expression): void {\n if (e === this.expression || !this.decal) return;\n const uv = this.asset.layout.face?.uv;\n const r = uv?.[e] ?? uv?.neutral;\n if (!r) return;\n this.expression = e;\n const [u0, v0, u1, v1] = r;\n const a = this.decal.geometry.getAttribute(\"uv\") as THREE.BufferAttribute;\n a.setXY(0, u0, v0);\n a.setXY(1, u1, v0);\n a.setXY(2, u1, v1);\n a.setXY(3, u0, v1);\n a.needsUpdate = true;\n }\n\n /** Свет места (как у мобов: пещера темнее). */\n setBrightness(b: number): void {\n this.brightness = b;\n this.material.color.setScalar(b);\n this.decalMat.color.setScalar(b);\n }\n\n /** Вспышка урона и прочие подсветки: 0 — без подсветки. */\n setEmissive(hex: number): void {\n if (this.material.emissive.getHex() === hex) return;\n this.material.emissive.setHex(hex);\n this.decalMat.emissive.setHex(hex);\n }\n\n setShadows(on: boolean): void {\n if (on === this.shadows) return;\n this.shadows = on;\n for (const m of this.meshes) m.castShadow = on;\n }\n\n /** Повесить предмет/одежду на сокет (координаты объекта — в блоках). null — снять. */\n attach(socket: SocketName, obj: THREE.Object3D | null): void {\n const old = this.attached.get(socket);\n if (old === obj) return;\n if (old) old.removeFromParent();\n if (!obj) {\n this.attached.delete(socket);\n return;\n }\n this.attached.set(socket, obj);\n this.sockets.get(socket)?.add(obj);\n }\n\n attachedAt(socket: SocketName): THREE.Object3D | null {\n return this.attached.get(socket) ?? null;\n }\n\n setName(name: string | null): void {\n if (this.label) disposeNameplate(this.label);\n this.label = name ? nameplate(name) : null;\n if (this.label) this.group.add(this.label);\n this.placeLabel();\n }\n\n setLabelVisible(on: boolean): void {\n if (this.label) this.label.visible = on;\n }\n\n private placeLabel(): void {\n if (this.label) this.label.position.y = this.height + 0.25;\n }\n\n /** Все меши модели (кукла инвентаря красит их в свой слой). */\n forEachMesh(fn: (m: THREE.Mesh) => void): void {\n for (const m of this.meshes) fn(m);\n if (this.decal) fn(this.decal);\n }\n\n dispose(): void {\n this.group.removeFromParent();\n for (const o of this.attached.values()) o.removeFromParent();\n this.attached.clear();\n this.clear();\n if (this.label) disposeNameplate(this.label);\n this.label = null;\n this.material.dispose();\n this.decalMat.dispose();\n releaseCharacter(this.asset);\n }\n}\n"
|
|
166
|
-
},
|
|
167
|
-
{
|
|
168
|
-
"path": "character/defaultCharacter.ts",
|
|
169
|
-
"content": "// Персонаж по умолчанию — «Путник» в детальном стиле референса владельца (11.10.2026): рубашка с\n// воротником, пуговицами и карманами, закатанные рукава с манжетами, ремень с пряжкой, брюки со\n// стрелками, ботинки со шнуровкой, объёмные волосы, нос, глаза с радужкой. Текстуры — 2 текселя на\n// пиксель модели (голова 8×8 → 16×16); поверх — отделка граней (шум ткани, контур, светотень).\n//\n// Это игрок, пока он не создал своего, другие игроки, пока их облик не дошёл по сети, основа NPC\n// (character/npcLook) и пути без ИИ (character/fromImage) — они перекрашивают его через recolor().\n// И образец для промпта ИИ и тестов.\nimport { parseCharacterSpec, type CharacterSpec } from \"./CharacterSpec\";\nimport { Art } from \"./pixelArt\";\nimport { tr } from \"../i18n\";\n\n/** Палитра: символ → цвет. Базовые цвета и их оттенки (см. recolor). */\nconst PALETTE: Record<string, string> = {\n s: \"#d9a982\", // кожа\n S: \"#bf8d68\", // кожа в тени\n h: \"#6b4a2f\", // волосы\n H: \"#8a6644\", // блики волос\n k: \"#3a2818\", // брови, веки, тёмные пряди\n w: \"#f4f4ef\", // белки глаз\n b: \"#3f8f9c\", // радужка\n m: \"#9a5544\", // губы\n o: \"#4a2219\", // открытый рот\n t: \"#a7bea9\", // рубашка\n T: \"#c4d6c4\", // рубашка светлее (воротник, манжеты)\n D: \"#84a08a\", // рубашка темнее (швы, складки)\n u: \"#ece6d2\", // пуговицы\n B: \"#6a4e33\", // ремень\n g: \"#d8c48c\", // пряжка\n p: \"#8c9ca6\", // брюки\n P: \"#6f808a\", // брюки темнее (стрелки, швы)\n z: \"#a07a55\", // ботинки\n Z: \"#7c5c3f\", // ботинки темнее (подошва, мысок)\n l: \"#e9e3cf\", // шнурки\n};\n\nfunction shade(hex: string, k: number): string {\n const n = parseInt(hex.slice(1), 16);\n const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) =>\n Math.max(0, Math.min(255, Math.round(v * k))),\n );\n return \"#\" + c.map((v) => v.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\n/** Основные цвета «Путника» — остальные оттенки выводятся из них. */\nexport interface LookColors {\n skin?: string;\n hair?: string;\n shirt?: string;\n pants?: string;\n boots?: string;\n eyes?: string;\n}\n\n/** Перекрасить палитру «Путника»: базовый цвет и согласованные с ним оттенки. */\nexport function recolor(\n palette: Record<string, string>,\n c: LookColors,\n): Record<string, string> {\n const p = { ...palette };\n if (c.skin) {\n p.s = c.skin;\n p.S = shade(c.skin, 0.86);\n p.m = shade(c.skin, 0.66);\n }\n if (c.hair) {\n p.h = c.hair;\n p.H = shade(c.hair, 1.25);\n p.k = shade(c.hair, 0.6);\n }\n if (c.shirt) {\n p.t = c.shirt;\n p.T = shade(c.shirt, 1.15);\n p.D = shade(c.shirt, 0.8);\n }\n if (c.pants) {\n p.p = c.pants;\n p.P = shade(c.pants, 0.8);\n }\n if (c.boots) {\n p.z = c.boots;\n p.Z = shade(c.boots, 0.78);\n }\n if (c.eyes) p.b = c.eyes;\n return p;\n}\n\n// ---- текстуры -------------------------------------------------------------------------\n\n/** Голова спереди: волосы сверху и по вискам, низ лица чуть в тени. Глаза и рот — лицо-декаль. */\nconst headFront = new Art(16, 16, \"s\")\n .rect(0, 0, 16, 3, \"h\")\n .row(3, 0, 4, \"h\")\n .row(3, 10, 15, \"h\")\n .row(4, 0, 1, \"h\")\n .row(4, 13, 15, \"h\")\n .col(0, 5, 8, \"h\")\n .col(15, 5, 8, \"h\")\n .row(1, 3, 6, \"H\")\n .row(1, 9, 12, \"H\")\n .row(15, 2, 13, \"S\");\n\n/** Левый висок (перёд — слева): волосы сзади и сверху, ухо. Правый — зеркало. */\nconst headLeft = new Art(16, 16, \"s\")\n .rect(0, 0, 16, 4, \"h\")\n .rect(7, 4, 9, 7, \"h\")\n .rect(10, 11, 6, 2, \"h\")\n .rect(5, 7, 2, 3, \"S\")\n .row(1, 2, 13, \"H\")\n .where((x, y) => y > 4 && y < 11 && x > 8 && (x + y) % 4 === 0, \"k\");\nconst headRight = headLeft.flipped();\n\nconst headBack = new Art(16, 16, \"h\")\n .where((x, y) => (x + 2 * y) % 5 === 0, \"H\")\n .where((x, y) => y > 8 && (x * 3 + y) % 7 === 0, \"k\")\n .rect(4, 14, 8, 2, \"s\");\n\nconst hairTop = new Art(16, 16, \"h\")\n .where((x, y) => (x + 2 * y) % 5 === 0, \"H\")\n .where((x, y) => (3 * x + y) % 9 === 0, \"k\");\n\n/** Пряди по краю шапки волос. */\nconst hairBand = new Art(18, 4, \"h\")\n .where((x, y) => (x * 7 + y * 3) % 5 === 0, \"H\")\n .where((x, y) => y === 3 && (x * 5) % 3 === 0, \"k\");\n\n/** Рубашка спереди: воротник, планка с пуговицами, два нагрудных кармана, ремень с пряжкой. */\nconst shirtFront = new Art(16, 24, \"t\")\n .row(0, 6, 9, \"s\")\n .row(1, 7, 8, \"s\")\n .row(0, 3, 5, \"T\")\n .row(0, 10, 12, \"T\")\n .row(1, 4, 6, \"T\")\n .row(1, 9, 11, \"T\")\n .row(2, 5, 6, \"T\")\n .row(2, 9, 10, \"T\")\n .col(8, 2, 19, \"D\")\n .px(7, 5, \"u\")\n .px(7, 9, \"u\")\n .px(7, 13, \"u\")\n .px(7, 17, \"u\")\n // карманы: клапан, края, пуговица\n .row(6, 2, 5, \"D\")\n .col(2, 7, 10, \"D\")\n .col(5, 7, 10, \"D\")\n .row(10, 2, 5, \"D\")\n .px(4, 7, \"u\")\n .row(6, 10, 13, \"D\")\n .col(10, 7, 10, \"D\")\n .col(13, 7, 10, \"D\")\n .row(10, 10, 13, \"D\")\n .px(11, 7, \"u\")\n .row(19, 0, 15, \"D\")\n .rect(0, 20, 16, 2, \"B\")\n .rect(7, 20, 2, 2, \"g\")\n .rect(0, 22, 16, 2, \"p\");\n\nconst shirtBack = new Art(16, 24, \"t\")\n .row(5, 0, 15, \"D\")\n .col(8, 6, 19, \"D\")\n .row(19, 0, 15, \"D\")\n .rect(0, 20, 16, 2, \"B\")\n .rect(0, 22, 16, 2, \"p\");\n\nconst shirtSide = new Art(8, 24, \"t\")\n .col(4, 0, 19, \"D\")\n .row(19, 0, 7, \"D\")\n .rect(0, 20, 8, 2, \"B\")\n .rect(0, 22, 8, 2, \"p\");\n\n/** Ремень поверх рубашки (объём). */\nconst belt = new Art(16, 2, \"B\")\n .rect(7, 0, 2, 2, \"g\")\n .px(3, 0, \"k\")\n .px(12, 0, \"k\");\n\n/** Рука: кожа, ладонь и пальцы снизу в тени. */\nconst armSkin = new Art(6, 24, \"s\").row(23, 0, 5, \"S\").col(2, 21, 23, \"S\");\n\n/** Закатанный рукав: ткань и отворот-манжета снизу. */\nconst sleeve = new Art(8, 13, \"t\")\n .col(0, 0, 9, \"D\")\n .row(9, 0, 7, \"D\")\n .rect(0, 10, 8, 3, \"T\")\n .row(12, 0, 7, \"D\");\n\nconst pantsFront = new Art(8, 24, \"p\").col(4, 1, 21, \"P\").row(0, 0, 7, \"P\");\nconst pantsBack = new Art(8, 24, \"p\").col(3, 0, 21, \"P\").row(0, 0, 7, \"P\");\nconst pantsSide = new Art(8, 24, \"p\").col(3, 0, 21, \"P\");\n\n/** Ботинок спереди: шнуровка «лесенкой», мысок и подошва темнее. */\nconst bootFront = new Art(10, 9, \"z\")\n .row(0, 0, 9, \"Z\")\n .row(2, 3, 6, \"l\")\n .row(4, 3, 6, \"l\")\n .row(6, 3, 6, \"l\")\n .col(2, 1, 6, \"Z\")\n .col(7, 1, 6, \"Z\")\n .rect(0, 7, 10, 2, \"Z\");\nconst bootSide = new Art(10, 9, \"z\")\n .row(0, 0, 9, \"Z\")\n .rect(0, 7, 10, 2, \"Z\")\n .col(5, 1, 6, \"Z\");\n\n// ---- лицо (16×10 на прямоугольник 8×5 передней грани головы) ----------------------------\n\ntype Brows = \"flat\" | \"up\" | \"down\" | \"high\";\ninterface FaceDraw {\n brows: Brows;\n /** Высота глаз в текселях (0 — закрыты линией). */\n eyes: 0 | 1 | 2 | 3;\n mouth: \"line\" | \"smile\" | \"frown\" | \"open\" | \"small\" | \"wide\";\n}\n\nfunction faceArt(f: FaceDraw): Art {\n const a = new Art(16, 10, \".\");\n // брови: левая (у смотрящего) — колонки 3..5, правая — 10..12\n const brow = (x0: number, inner: number, outer: number) => {\n if (f.brows === \"flat\") a.row(1, x0, x0 + 2, \"k\");\n else if (f.brows === \"high\") a.row(0, x0, x0 + 2, \"k\");\n else {\n const lo = f.brows === \"down\" ? 1 : 0,\n hi = 1 - lo;\n a.px(inner, lo, \"k\").px(x0 + 1, f.brows === \"down\" ? 0 : 1, \"k\");\n a.px(outer, hi, \"k\");\n }\n };\n brow(3, 5, 3);\n brow(10, 10, 12);\n // глаза: белок с радужкой посередине\n const eye = (x0: number) => {\n if (f.eyes === 0) {\n a.row(3, x0, x0 + 2, \"k\");\n return;\n }\n const top = f.eyes === 3 ? 2 : f.eyes === 2 ? 2 : 3;\n for (let y = top; y < top + f.eyes; y++)\n a.px(x0, y, \"w\")\n .px(x0 + 1, y, \"b\")\n .px(x0 + 2, y, \"w\");\n };\n eye(3);\n eye(10);\n switch (f.mouth) {\n case \"line\":\n a.row(7, 6, 9, \"m\");\n break;\n case \"smile\":\n a.px(5, 6, \"m\").px(10, 6, \"m\").row(7, 6, 9, \"m\");\n break;\n case \"frown\":\n a.row(7, 6, 9, \"m\").px(5, 8, \"m\").px(10, 8, \"m\");\n break;\n case \"open\":\n a.rect(6, 7, 4, 2, \"o\");\n break;\n case \"small\":\n a.rect(7, 7, 2, 2, \"o\");\n break;\n case \"wide\":\n a.row(7, 5, 10, \"m\").rect(6, 8, 4, 1, \"o\");\n break;\n }\n return a;\n}\n\nconst FACES = {\n f_neutral: faceArt({ brows: \"flat\", eyes: 2, mouth: \"line\" }),\n f_blink: faceArt({ brows: \"flat\", eyes: 0, mouth: \"line\" }),\n f_happy: faceArt({ brows: \"high\", eyes: 2, mouth: \"smile\" }),\n f_sad: faceArt({ brows: \"up\", eyes: 2, mouth: \"frown\" }),\n f_angry: faceArt({ brows: \"down\", eyes: 1, mouth: \"wide\" }),\n f_surprised: faceArt({ brows: \"high\", eyes: 3, mouth: \"small\" }),\n f_scared: faceArt({ brows: \"up\", eyes: 3, mouth: \"open\" }),\n f_tired: faceArt({ brows: \"flat\", eyes: 1, mouth: \"line\" }),\n f_hurt: faceArt({ brows: \"down\", eyes: 0, mouth: \"open\" }),\n f_talk: faceArt({ brows: \"flat\", eyes: 2, mouth: \"open\" }),\n};\n\n// ---- другие наряды (референс: футболка в полоску, шорты, кеды, свитер, шляпа) --------------\n\nconst teeFront = new Art(16, 24, \"t\")\n .row(0, 5, 10, \"s\")\n .row(1, 6, 9, \"s\")\n .px(5, 1, \"T\")\n .px(10, 1, \"T\")\n .row(7, 0, 15, \"D\")\n .row(10, 0, 15, \"D\")\n .rect(0, 18, 16, 2, \"T\")\n .rect(0, 20, 16, 4, \"p\")\n .row(20, 0, 15, \"P\");\nconst teeBack = new Art(16, 24, \"t\")\n .row(0, 5, 10, \"T\")\n .row(7, 0, 15, \"D\")\n .row(10, 0, 15, \"D\")\n .rect(0, 18, 16, 2, \"T\")\n .rect(0, 20, 16, 4, \"p\")\n .row(20, 0, 15, \"P\");\nconst teeSide = new Art(8, 24, \"t\")\n .row(7, 0, 7, \"D\")\n .row(10, 0, 7, \"D\")\n .rect(0, 18, 8, 2, \"T\")\n .rect(0, 20, 8, 4, \"p\")\n .row(20, 0, 7, \"P\");\nconst teeSleeve = new Art(8, 9, \"t\").row(3, 0, 7, \"D\").rect(0, 7, 8, 2, \"T\");\n\n/** Свитер: вязаный ворот, узорная полоса на груди, резинка внизу. */\nconst knit = (a: Art, y0: number) =>\n a\n .row(y0 - 1, 0, a.w - 1, \"D\")\n .where((x, y) => y >= y0 && y <= y0 + 3 && (x + y) % 2 === 0, \"T\")\n .row(y0 + 4, 0, a.w - 1, \"D\");\nconst sweaterFront = knit(new Art(16, 24, \"t\"), 6)\n .row(0, 6, 9, \"s\")\n .row(0, 4, 5, \"T\")\n .row(0, 10, 11, \"T\")\n .row(1, 4, 11, \"T\")\n .where((x, y) => y >= 18 && y <= 19 && x % 2 === 0, \"D\")\n .where((x, y) => y >= 18 && y <= 19 && x % 2 === 1, \"T\")\n .rect(0, 20, 16, 4, \"p\");\nconst sweaterBack = knit(new Art(16, 24, \"t\"), 6)\n .row(0, 4, 11, \"T\")\n .where((x, y) => y >= 18 && y <= 19, \"D\")\n .rect(0, 20, 16, 4, \"p\");\nconst sweaterSide = knit(new Art(8, 24, \"t\"), 6)\n .where((x, y) => y >= 18 && y <= 19, \"D\")\n .rect(0, 20, 8, 4, \"p\");\nconst sweaterSleeve = knit(new Art(8, 22, \"t\"), 9).where(\n (x, y) => y >= 19 && x % 2 === 0,\n \"D\",\n);\n\nconst shorts = (seam: number) =>\n new Art(8, 24, \"s\")\n .rect(0, 0, 8, 12, \"p\")\n .col(seam, 1, 10, \"P\")\n .row(11, 0, 7, \"P\")\n .row(12, 0, 7, \"S\")\n .rect(0, 19, 8, 5, \"l\");\nconst shortsFront = shorts(4);\nconst shortsSide = shorts(3);\n\nconst sneakerFront = new Art(9, 5, \"z\").row(1, 3, 5, \"l\").rect(0, 3, 9, 2, \"l\");\nconst sneakerSide = new Art(10, 5, \"z\")\n .row(1, 3, 6, \"Z\")\n .rect(0, 3, 10, 2, \"l\");\n\nconst hatCrown = new Art(17, 6, \"a\").rect(0, 4, 17, 2, \"A\");\nconst hatBrim = new Art(22, 22, \"a\").where(\n (x, y) => (x * 3 + y * 5) % 11 === 0,\n \"A\",\n);\nconst hairLong = new Art(17, 20, \"h\")\n .where((x, y) => (x * 5 + y) % 4 === 0, \"H\")\n .where((x, y) => y > 10 && (x * 3 + y * 2) % 7 === 0, \"k\");\n\nconst tex = (a: Art) => a.tex();\n\nexport const HUMAN_TOPS = [\"shirt\", \"tee\", \"sweater\"] as const;\nexport const HUMAN_BOTTOMS = [\"pants\", \"shorts\"] as const;\nexport const HUMAN_SHOES = [\"boots\", \"sneakers\"] as const;\nexport const HUMAN_HAIR = [\"short\", \"long\", \"curly\", \"none\"] as const;\n\n/** Наряд человека из набора частей — разнообразие NPC и основа пути без ИИ. */\nexport interface HumanLook {\n top?: (typeof HUMAN_TOPS)[number];\n bottom?: (typeof HUMAN_BOTTOMS)[number];\n shoes?: (typeof HUMAN_SHOES)[number];\n hair?: (typeof HUMAN_HAIR)[number];\n hat?: boolean;\n colors?: LookColors & { hat?: string };\n name?: string;\n description?: string;\n tags?: string[];\n}\n\ntype Faces = Partial<\n Record<\"front\" | \"back\" | \"left\" | \"right\" | \"top\" | \"bottom\", string>\n>;\nconst all = (t: string, extra: Faces = {}): Faces => ({\n front: t,\n back: t,\n left: t,\n right: t,\n ...extra,\n});\n\n/** Рецепт человека в детальном стиле: голова, волосы, нос, наряд, обувь, по желанию шляпа. */\nexport function humanSource(look: HumanLook = {}) {\n const top = look.top ?? \"shirt\",\n bottom = look.bottom ?? \"pants\",\n shoes = look.shoes ?? \"boots\",\n hair = look.hair ?? \"short\";\n let palette = recolor(PALETTE, look.colors ?? {});\n palette = { ...palette, a: look.colors?.hat ?? \"#d9c9a3\" };\n palette.A = shade(palette.a!, 0.72);\n\n const textures: Record<string, ReturnType<typeof tex>> = {\n head_front: tex(headFront),\n head_left: tex(headLeft),\n head_right: tex(headRight),\n head_back: tex(headBack),\n hair_top: tex(hairTop),\n hair_band: tex(hairBand),\n arm_skin: tex(armSkin),\n ...Object.fromEntries(Object.entries(FACES).map(([k, a]) => [k, tex(a)])),\n };\n const parts: Record<string, unknown>[] = [\n {\n name: \"head\",\n bone: \"head\",\n size: [8, 8, 8],\n pos: [0, 28, 0],\n color: \"s\",\n faces: {\n front: \"head_front\",\n left: \"head_left\",\n right: \"head_right\",\n back: \"head_back\",\n top: \"hair_top\",\n },\n },\n {\n name: \"nose\",\n bone: \"head\",\n size: [1, 1.5, 0.8],\n pos: [0, 26.9, -4.4],\n color: \"S\",\n faces: { front: \"s\" },\n },\n ];\n\n // волосы: объём шапкой и прядями (под шляпой — только пряди сзади)\n if (hair !== \"none\") {\n if (!look.hat)\n parts.push({\n name: \"hair\",\n bone: \"head\",\n size: hair === \"curly\" ? [9, 2.2, 9] : [8.6, 1.6, 8.6],\n pos: [0, hair === \"curly\" ? 32.5 : 32.2, 0.15],\n color: \"h\",\n faces: all(\"hair_band\", { top: \"hair_top\" }),\n });\n if (hair === \"curly\" && !look.hat)\n for (const [x, z] of [\n [-2, -1.5],\n [2, 0.5],\n [-0.5, 2.5],\n ] as const)\n parts.push({\n name: \"curl\",\n bone: \"head\",\n size: [3.2, 1.4, 3.2],\n pos: [x, 33.9, z],\n color: \"h\",\n faces: all(\"hair_band\", { top: \"hair_top\" }),\n });\n if (hair === \"long\") {\n textures.hair_long = tex(hairLong);\n parts.push(\n {\n name: \"hairLong\",\n bone: \"head\",\n size: [8.8, 10, 1],\n pos: [0, 27, 4.4],\n color: \"h\",\n faces: all(\"hair_long\", { top: \"hair_top\" }),\n },\n {\n name: \"hairSide\",\n bone: \"head\",\n size: [0.8, 9, 6],\n pos: [-4.4, 27.5, 1],\n color: \"h\",\n faces: all(\"hair_long\", { top: \"hair_top\" }),\n mirror: true,\n },\n );\n } else\n parts.push({\n name: \"hairBack\",\n bone: \"head\",\n size: [8.6, 5, 0.8],\n pos: [0, 29.6, 4.25],\n color: \"h\",\n faces: { back: \"head_back\", left: \"hair_band\", right: \"hair_band\" },\n });\n }\n if (look.hat) {\n textures.hat_crown = tex(hatCrown);\n textures.hat_brim = tex(hatBrim);\n parts.push(\n {\n name: \"hatBrim\",\n bone: \"head\",\n size: [11, 0.8, 11],\n pos: [0, 32.4, 0],\n color: \"a\",\n faces: { top: \"hat_brim\", bottom: \"hat_brim\" },\n },\n {\n name: \"hatCrown\",\n bone: \"head\",\n size: [8.4, 3, 8.4],\n pos: [0, 34.3, 0],\n color: \"a\",\n faces: all(\"hat_crown\"),\n },\n );\n }\n\n // верх\n const tops = {\n shirt: [shirtFront, shirtBack, shirtSide],\n tee: [teeFront, teeBack, teeSide],\n sweater: [sweaterFront, sweaterBack, sweaterSide],\n } as const;\n const [tf, tb, ts] = tops[top];\n textures.top_front = tex(tf);\n textures.top_back = tex(tb);\n textures.top_side = tex(ts);\n parts.push({\n name: \"torso\",\n bone: \"body\",\n size: [8, 12, 4],\n pos: [0, 18, 0],\n color: \"t\",\n faces: {\n front: \"top_front\",\n back: \"top_back\",\n left: \"top_side\",\n right: \"top_side\",\n bottom: \"p\",\n },\n });\n if (top === \"shirt\") {\n textures.belt = tex(belt);\n parts.push({\n name: \"belt\",\n bone: \"body\",\n size: [8.4, 1, 4.4],\n pos: [0, 13.5, 0],\n color: \"B\",\n faces: { front: \"belt\", back: \"belt\" },\n });\n }\n parts.push({\n name: \"arm\",\n bone: \"armL\",\n size: [3, 12, 3],\n pos: [-5.5, 18, 0],\n color: \"s\",\n faces: all(\"arm_skin\", { top: \"t\", bottom: \"S\" }),\n mirror: true,\n });\n const sleeveLen = top === \"sweater\" ? 11 : top === \"tee\" ? 4.5 : 6.5;\n textures.sleeve = tex(\n top === \"sweater\" ? sweaterSleeve : top === \"tee\" ? teeSleeve : sleeve,\n );\n parts.push({\n name: \"sleeve\",\n bone: \"armL\",\n size: [3.6, sleeveLen, 3.6],\n pos: [-5.5, 24 - sleeveLen / 2, 0],\n color: \"t\",\n faces: all(\"sleeve\", { bottom: \"D\" }),\n mirror: true,\n });\n\n // низ и обувь\n if (bottom === \"shorts\") {\n textures.leg_front = tex(shortsFront);\n textures.leg_side = tex(shortsSide);\n } else {\n textures.leg_front = tex(pantsFront);\n textures.leg_back = tex(pantsBack);\n textures.leg_side = tex(pantsSide);\n }\n parts.push({\n name: \"leg\",\n bone: \"legL\",\n size: [4, 12, 4],\n pos: [-2, 6, 0],\n color: \"p\",\n faces: {\n front: \"leg_front\",\n back: bottom === \"shorts\" ? \"leg_front\" : \"leg_back\",\n left: \"leg_side\",\n right: \"leg_side\",\n },\n mirror: true,\n });\n if (shoes === \"boots\") {\n textures.shoe_front = tex(bootFront);\n textures.shoe_side = tex(bootSide);\n } else {\n textures.shoe_front = tex(sneakerFront);\n textures.shoe_side = tex(sneakerSide);\n }\n const h = shoes === \"boots\" ? 4.5 : 2.5;\n parts.push({\n name: \"shoe\",\n bone: \"legL\",\n size: [4.6, h, 5.2],\n pos: [-2, h / 2, -0.3],\n color: \"z\",\n faces: {\n front: \"shoe_front\",\n back: \"shoe_side\",\n left: \"shoe_side\",\n right: \"shoe_side\",\n bottom: \"Z\",\n },\n mirror: true,\n });\n\n return {\n version: 1,\n name: look.name ?? tr({ en: \"Traveler\", ru: \"Путник\" }),\n description:\n look.description ??\n tr({\n en: \"A traveler in a shirt with pockets, trousers and laced boots\",\n ru: \"Путник в рубашке с карманами, брюках и шнурованных ботинках\",\n }),\n rig: \"humanoid\",\n palette,\n textures,\n parts,\n face: {\n bone: \"head\",\n rect: [0, 3, 8, 5],\n expressions: {\n neutral: \"f_neutral\",\n blink: \"f_blink\",\n happy: \"f_happy\",\n sad: \"f_sad\",\n angry: \"f_angry\",\n surprised: \"f_surprised\",\n scared: \"f_scared\",\n tired: \"f_tired\",\n hurt: \"f_hurt\",\n talk: \"f_talk\",\n },\n },\n style: { walk: \"normal\", idle: \"calm\", energy: 0.5 },\n finish: { noise: 0.5, outline: 0.4, shade: 0.25, density: 2 },\n tags: look.tags ?? [\"player\"],\n };\n}\n\nexport const DEFAULT_CHARACTER_SOURCE = humanSource();\n\nlet cached: CharacterSpec | null = null;\n\n/** Рецепт «Путника» (разобран один раз; не менять — отдавайте копию, если надо править). */\nexport function defaultCharacter(): CharacterSpec {\n if (!cached) {\n const r = parseCharacterSpec(DEFAULT_CHARACTER_SOURCE);\n if (!r.ok) throw new Error(r.error);\n cached = r.spec;\n }\n return cached;\n}\n"
|
|
170
|
-
},
|
|
171
|
-
{
|
|
172
|
-
"path": "character/fromImage.ts",
|
|
173
|
-
"content": "// Персонаж по картинке БЕЗ ИИ: шаблон скелета + цвета, снятые с картинки по горизонтальным полосам\n// фигуры (волосы, лицо, верх, низ, обувь). Фон — цвет углов картинки; фигура — всё, что от него\n// заметно отличается. Грубо, но мгновенно и бесплатно; дальше игрок правит палитру в студии.\nimport type { RGBAImage } from \"../worldgen/ImageAnalyzer\";\nimport { parseCharacterSpec, type CharacterSpec } from \"./CharacterSpec\";\nimport {\n DEFAULT_CHARACTER_SOURCE,\n defaultCharacter,\n recolor,\n} from \"./defaultCharacter\";\nimport type { RigId } from \"./rigs\";\nimport { defaultCharacterName } from \"./CharacterSpec\";\n\ntype RGB = [number, number, number];\n\nconst hex = ([r, g, b]: RGB) =>\n \"#\" +\n [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, \"0\")).join(\"\");\n\n/** Медианный цвет набора пикселей (по каждому каналу) — устойчив к бликам и контурам. */\nfunction median(px: RGB[]): RGB | null {\n if (!px.length) return null;\n const ch = (i: 0 | 1 | 2) => {\n const v = px.map((p) => p[i]).sort((a, b) => a - b);\n return v[v.length >> 1]!;\n };\n return [ch(0), ch(1), ch(2)];\n}\n\n/** Цвета полос фигуры сверху вниз (доли высоты фигуры). */\nexport function bandColors(\n img: RGBAImage,\n bands: readonly [number, number][],\n): (RGB | null)[] {\n const { width: W, height: H, data } = img;\n const at = (x: number, y: number): RGB => {\n const i = (y * W + x) * 4;\n return [data[i]!, data[i + 1]!, data[i + 2]!];\n };\n // фон — средний цвет четырёх углов\n const corners = [at(0, 0), at(W - 1, 0), at(0, H - 1), at(W - 1, H - 1)];\n const bg: RGB = [0, 1, 2].map(\n (i) => corners.reduce((s, c) => s + c[i]!, 0) / 4,\n ) as RGB;\n const isFig = (c: RGB) =>\n Math.abs(c[0] - bg[0]) + Math.abs(c[1] - bg[1]) + Math.abs(c[2] - bg[2]) >\n 60;\n // рамка фигуры\n let x0 = W,\n x1 = -1,\n y0 = H,\n y1 = -1;\n for (let y = 0; y < H; y += 2)\n for (let x = 0; x < W; x += 2)\n if (isFig(at(x, y))) {\n x0 = Math.min(x0, x);\n x1 = Math.max(x1, x);\n y0 = Math.min(y0, y);\n y1 = Math.max(y1, y);\n }\n if (x1 < 0) {\n x0 = 0;\n x1 = W - 1;\n y0 = 0;\n y1 = H - 1;\n }\n const fh = y1 - y0 + 1,\n fw = x1 - x0 + 1;\n // середина по ширине: руки и фон по бокам не мешают\n const cx0 = x0 + Math.floor(fw * 0.3),\n cx1 = x0 + Math.ceil(fw * 0.7);\n return bands.map(([a, b]) => {\n const px: RGB[] = [];\n for (let y = y0 + Math.floor(fh * a); y < y0 + Math.ceil(fh * b); y++)\n for (let x = cx0; x < cx1; x++) {\n if (y < 0 || y >= H || x < 0 || x >= W) continue;\n const c = at(x, y);\n if (isFig(c)) px.push(c);\n }\n return median(px);\n });\n}\n\n/** Полосы человека: волосы, лицо, верх, низ, обувь. */\nconst HUMAN_BANDS: [number, number][] = [\n [0, 0.06],\n [0.1, 0.22],\n [0.3, 0.52],\n [0.6, 0.86],\n [0.92, 1],\n];\n\n/**\n * Персонаж по картинке: человек — «Путник» в цветах картинки; другие шаблоны — их тело по\n * умолчанию в цветах полос (верх — голова, середина — тело, низ — лапы).\n */\nexport function characterFromImage(\n img: RGBAImage,\n rig: RigId = \"humanoid\",\n name = defaultCharacterName(),\n): CharacterSpec {\n const [hair, skin, top, bottom, shoes] = bandColors(img, HUMAN_BANDS);\n if (rig === \"humanoid\") {\n const src = JSON.parse(JSON.stringify(DEFAULT_CHARACTER_SOURCE)) as {\n name: string;\n palette: Record<string, string>;\n tags: string[];\n };\n src.palette = recolor(src.palette, {\n ...(hair ? { hair: hex(hair) } : {}),\n ...(skin ? { skin: hex(skin) } : {}),\n ...(top ? { shirt: hex(top) } : {}),\n ...(bottom ? { pants: hex(bottom) } : {}),\n ...(shoes ? { boots: hex(shoes) } : {}),\n });\n src.name = name;\n src.tags = [];\n const r = parseCharacterSpec(src);\n return r.ok ? r.spec : defaultCharacter();\n }\n const r = parseCharacterSpec({\n name,\n rig,\n palette: {\n s: hex(skin ?? hair ?? [198, 156, 124]),\n t: hex(top ?? [58, 143, 214]),\n p: hex(bottom ?? shoes ?? [44, 62, 107]),\n w: \"#f4f4f4\",\n b: \"#1d1d21\",\n },\n textures: {\n f_neutral: { w: 4, h: 2, rows: [\"wbbw\", \"....\"] },\n f_blink: { w: 4, h: 2, rows: [\"bbbb\", \"....\"] },\n },\n face: {\n // лицо в середине передней грани головы (у капли — тела)\n rect:\n rig === \"blob\"\n ? [4, 4, 4, 2]\n : rig === \"flyer\"\n ? [0, 1, 4, 2]\n : [2, 2, 4, 2],\n expressions: { neutral: \"f_neutral\", blink: \"f_blink\" },\n },\n });\n return r.ok ? r.spec : defaultCharacter();\n}\n"
|
|
130
|
+
"content": "// Превью персонажа на своём холсте: кукла в инвентаре и вертушка студии персонажа. Своя маленькая\n// сцена со светом и свой рендерер — мир игры для показа персонажа не нужен. Тянешь мышью/пальцем —\n// поворачивается; демонстрация состояний (ходьба, бег, присед, полёт…), эмоций и жестов.\nimport * as THREE from \"three\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport { CharacterActor, type ActorFrame } from \"./CharacterActor\";\nimport type {\n CharacterSpec,\n EmoteId,\n Emotion,\n} from \"@idosgames/characters/voxel\";\n\n/** Демонстрации состояний: что подставить в кадр персонажа. */\nexport const PREVIEW_STATES = {\n idle: { label: \"Стоит\", frame: {} },\n walk: { label: \"Идёт\", frame: { speed: 4.3 } },\n run: { label: \"Бежит\", frame: { speed: 5.6, sprinting: true } },\n sneak: { label: \"Крадётся\", frame: { speed: 1.3, sneaking: true } },\n swim: {\n label: \"Плывёт\",\n frame: { inWater: true, onGround: false, speed: 2 },\n },\n fly: { label: \"Летит\", frame: { flying: true, onGround: false } },\n glide: { label: \"Планирует\", frame: { gliding: true, onGround: false } },\n sit: { label: \"Сидит\", frame: { sitting: true } },\n sleep: { label: \"Спит\", frame: { sleeping: true } },\n dead: { label: \"Повержен\", frame: { dead: true } },\n} satisfies Record<string, { label: string; frame: Partial<ActorFrame> }>;\nexport type PreviewState = keyof typeof PREVIEW_STATES;\n\nexport class CharacterPreview {\n readonly renderer: THREE.WebGLRenderer;\n private readonly scene = new THREE.Scene();\n private readonly camera = new THREE.PerspectiveCamera(30, 1, 0.05, 50);\n readonly actor: CharacterActor;\n private yaw = 0.5;\n /** Сам поворачивается, пока его не тянут. */\n autoRotate = true;\n state: PreviewState = \"idle\";\n held: string | null = null;\n /** Облик предмета в руке (хэш). */\n heldSkin: string | null = null;\n private raf = 0;\n private last = 0;\n private readonly abort = new AbortController();\n private drag: number | null = null;\n\n constructor(\n readonly canvas: HTMLCanvasElement,\n spec?: CharacterSpec,\n atlas: TextureAtlas | null = null,\n ) {\n this.renderer = new THREE.WebGLRenderer({\n canvas,\n antialias: true,\n alpha: true,\n preserveDrawingBuffer: true, // превью для Мастерской снимается toBlob\n });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.renderer.toneMapping = THREE.NeutralToneMapping;\n this.scene.add(new THREE.HemisphereLight(0xffffff, 0x8a7a6a, 1.6));\n const sun = new THREE.DirectionalLight(0xffffff, 1.6);\n sun.position.set(2, 4, -3);\n this.scene.add(sun);\n this.actor = new CharacterActor(spec, { atlas, shadows: false });\n this.scene.add(this.actor.group);\n this.frameCamera();\n\n const sig = { signal: this.abort.signal };\n canvas.style.touchAction = \"none\";\n canvas.addEventListener(\n \"pointerdown\",\n (e) => {\n this.drag = e.clientX;\n canvas.setPointerCapture(e.pointerId);\n },\n sig,\n );\n canvas.addEventListener(\n \"pointermove\",\n (e) => {\n if (this.drag === null) return;\n this.yaw += (e.clientX - this.drag) * 0.012;\n this.drag = e.clientX;\n this.autoRotate = false;\n },\n sig,\n );\n canvas.addEventListener(\"pointerup\", () => (this.drag = null), sig);\n }\n\n setSpec(spec: CharacterSpec): void {\n this.actor.setSpec(spec);\n this.frameCamera();\n }\n\n setState(s: PreviewState): void {\n this.state = s;\n }\n\n playEmote(id: EmoteId): void {\n this.state = \"idle\";\n this.actor.playEmote(id);\n }\n\n setEmotion(e: Emotion, seconds = 4): void {\n this.actor.setEmotion(e, seconds);\n }\n\n /** Сцена позади персонажа (лобби: блоки, деревья, облака) — с туманом к горизонту. */\n private backdrop: THREE.Group | null = null;\n setBackdrop(stage: THREE.Group | null, fog = 0xbfe3ff): void {\n if (this.backdrop) this.scene.remove(this.backdrop);\n this.backdrop = stage;\n if (stage) this.scene.add(stage);\n this.scene.fog = stage ? new THREE.Fog(fog, 16, 46) : null;\n this.frameCamera();\n }\n\n /**\n * Где на холсте стоит персонаж (CSS-пиксели холста). Холст лобби — во весь экран, а персонаж —\n * в своей колонке: проекция сдвигается так, что он вписан в этот прямоугольник, а сцена вокруг\n * заполняет остальной экран.\n */\n stageRect: { x: number; y: number; w: number; h: number } | null = null;\n\n /** Каждый кадр до отрисовки (лобби: облака плывут, прямоугольник персонажа следует за раскладкой). */\n onFrame: ((dt: number) => void) | null = null;\n\n /** Камера так, чтобы персонаж целиком помещался. */\n private frameCamera(): void {\n const h = Math.max(0.5, this.actor.view.height);\n const tan = Math.tan((this.camera.fov * Math.PI) / 360);\n if (this.backdrop) {\n // чуть сверху: видно подиум и лужайку за ним\n // персонаж — две трети высоты места, ниже — подиум и лужайка\n const dist = (h * 1.2) / tan;\n this.camera.position.set(0, h * 1.1, -dist);\n this.camera.lookAt(0, h * 0.42, 0);\n return;\n }\n const dist = (h * 0.75) / tan + 0.6;\n this.camera.position.set(0, h * 0.55, -dist);\n this.camera.lookAt(0, h * 0.5, 0);\n }\n\n private resize(): void {\n const w = this.canvas.clientWidth,\n h = this.canvas.clientHeight;\n if (!w || !h) return;\n const c = this.renderer.domElement;\n const pr = this.renderer.getPixelRatio();\n if (c.width !== Math.round(w * pr) || c.height !== Math.round(h * pr))\n this.renderer.setSize(w, h, false);\n const r = this.stageRect;\n if (r && r.w > 0 && r.h > 0) {\n this.camera.aspect = r.w / r.h;\n this.camera.setViewOffset(r.w, r.h, -r.x, -r.y, w, h);\n } else {\n this.camera.clearViewOffset();\n this.camera.aspect = w / h;\n }\n this.camera.updateProjectionMatrix();\n }\n\n /** Один кадр (вызывает свой цикл; снаружи — для снимка). */\n render(dt: number): void {\n this.resize();\n this.onFrame?.(dt);\n if (this.autoRotate) this.yaw += dt * 0.5;\n const st = PREVIEW_STATES[this.state].frame as Partial<ActorFrame>;\n this.actor.update(dt, {\n x: 0,\n y: 0,\n z: 0,\n // голова смотрит туда же, куда тело: персонаж крутится целиком\n yaw: this.yaw,\n bodyYaw: this.yaw,\n pitch: 0,\n speed: 0,\n vy: 0,\n onGround: true,\n held: this.held,\n heldSkin: this.heldSkin,\n ...st,\n });\n this.renderer.render(this.scene, this.camera);\n }\n\n start(): void {\n if (this.raf) return;\n this.last = performance.now();\n const loop = (t: number) => {\n this.raf = requestAnimationFrame(loop);\n const dt = Math.min(0.1, (t - this.last) / 1000);\n this.last = t;\n this.render(dt);\n };\n this.raf = requestAnimationFrame(loop);\n }\n\n stop(): void {\n cancelAnimationFrame(this.raf);\n this.raf = 0;\n }\n\n /** Снимок кадра (превью публикации). */\n async snapshot(type = \"image/webp\"): Promise<Blob | null> {\n this.render(0);\n return new Promise((r) => this.canvas.toBlob(r, type, 0.9));\n }\n\n dispose(): void {\n this.stop();\n this.abort.abort();\n this.actor.dispose();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n}\n"
|
|
174
131
|
},
|
|
175
132
|
{
|
|
176
133
|
"path": "character/items/ItemSkinSpec.ts",
|
|
177
|
-
"content": "// Облик предмета — рецепт, как у персонажа и мира: ИИ (или студия) описывает вещь фигурами в\n// пиксельной сетке, игра строит модель сама.\n//\n// Два вида (решение владельца 11.10.2026):\n// - held — новый ВИД существующего предмета (base): меч остаётся железным мечом — урон, прочность,\n// крафт и сеть от него, облик живёт в метке стака (ItemStack.tag.skin);\n// - wearable — косметика на теле (шляпа, плащ, крылья, рюкзак): игру не меняет.\n//\n// Координаты — клетки сетки size (1 клетка = 1 пиксель = 1/16 блока): x вправо, y вверх, z назад\n// (перёд персонажа и предмета — −z, как у мобов). Предмет в руке строится стоя: рукоять в точке\n// grip.at, остриё вверх (+y). Надеваемое крепится точкой grip.at к сокету тела (character/rigs).\nimport { resolveItem } from \"../../registry/aliases\";\nimport { ITEMS } from \"../../registry/Items\";\nimport {\n MODEL_AXES,\n MODEL_MIRRORS,\n MODEL_SHAPES,\n extractJsonObject,\n type ModelPart,\n type ModelShape,\n} from \"../../worldgen/WorldSpec\";\nimport {\n CLEAR,\n isObj,\n normColor,\n num,\n pick,\n vec3,\n type PixelTex,\n} from \"../CharacterSpec\";\nimport type { SocketName, V3 } from \"../rigs\";\nimport { tr } from \"../../i18n\";\n\nexport const ITEM_SKIN_VERSION = 1 as const;\nexport const SKIN_KINDS = [\"held\", \"wearable\"] as const;\nexport type SkinKind = (typeof SKIN_KINDS)[number];\n\n/** Куда надевается косметика → сокет скелета. */\nexport const WEAR_SLOTS = {\n head: \"headTop\",\n face: \"face\",\n back: \"back\",\n shoulders: \"shoulders\",\n waist: \"waist\",\n} as const satisfies Record<string, SocketName>;\nexport type WearSlot = keyof typeof WEAR_SLOTS;\nexport const WEAR_SLOT_IDS = Object.keys(WEAR_SLOTS) as WearSlot[];\n\n/** Как держат предмет: поза руки и поворот модели в кулаке. */\nexport const GRIP_POSES = [\n \"one_hand\",\n \"two_hand\",\n \"bow\",\n \"shield\",\n \"staff\",\n] as const;\nexport type GripPose = (typeof GRIP_POSES)[number];\n\nexport const SKIN_LIMITS = {\n /** Сторона сетки, клеток (32 = 2 блока). */\n side: 32,\n parts: 48,\n voxels: 512,\n palette: 16,\n} as const;\n\n/** Фигура облика: как у моделей мира (worldgen/voxelModel), только цвет палитры вместо блока. */\nexport interface SkinShape {\n shape: ModelShape;\n color: string;\n from?: V3;\n to?: V3;\n axis?: (typeof MODEL_AXES)[number];\n center?: V3;\n radius?: number;\n radius2?: number;\n length?: number;\n radii?: V3;\n hollow?: boolean;\n mirror?: (typeof MODEL_MIRRORS)[number];\n}\n\nexport interface ItemSkinSpec {\n version: typeof ITEM_SKIN_VERSION;\n name: string;\n description: string;\n kind: SkinKind;\n /** held: какой предмет игры это (id из ITEMS). */\n base: string | null;\n /** wearable: куда надевается. */\n slot: WearSlot | null;\n /** Размер сетки [x, y, z], клеток. */\n size: V3;\n grip: { at: V3; pose: GripPose };\n palette: Record<string, string>;\n parts: SkinShape[];\n voxels: [number, number, number, string][];\n /** Иконка 16×16 в слотах; нет — рисуется видом спереди. */\n icon: PixelTex | null;\n}\n\nexport type SkinParseResult =\n | { ok: true; spec: ItemSkinSpec; warnings: string[] }\n | { ok: false; error: string; truncated?: boolean };\n\nconst arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);\n\nfunction str(v: unknown, def: string, max: number): string {\n return typeof v === \"string\" ? v.trim().slice(0, max) : def;\n}\n\n/** Догадка о базовом предмете по названию, если модель его не назвала. */\nconst BASE_GUESS: [RegExp, string][] = [\n [/pick/i, \"iron_pickaxe\"],\n [/axe|топор/i, \"iron_axe\"],\n [/shovel|лопат/i, \"iron_shovel\"],\n [/hoe|мотыг/i, \"iron_hoe\"],\n [/crossbow|арбалет/i, \"crossbow\"],\n [/bow|лук/i, \"bow\"],\n [/shield|щит/i, \"shield\"],\n [/trident|трезуб/i, \"trident\"],\n [/sword|blade|katana|меч|клинок|катан/i, \"iron_sword\"],\n];\n\nexport function parseItemSkin(input: unknown): SkinParseResult {\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))\n return {\n ok: false,\n error: tr({\n en: \"the item look is not an object\",\n ru: \"облик предмета — не объект\",\n }),\n };\n if (!(\"parts\" in input) && !(\"voxels\" in input)) {\n const inner = input.item ?? input.skin ?? input.spec;\n if (isObj(inner)) input = inner;\n }\n const o = input as Record<string, unknown>;\n const L = SKIN_LIMITS;\n const warnings: string[] = [];\n const fallback = tr({ en: \"Item\", ru: \"Предмет\" });\n const name = str(o.name, fallback, 40) || fallback;\n\n const slotRaw = typeof o.slot === \"string\" ? o.slot.trim().toLowerCase() : \"\";\n const slot = (WEAR_SLOT_IDS as string[]).includes(slotRaw)\n ? (slotRaw as WearSlot)\n : null;\n const kind = pick(o.kind, SKIN_KINDS, slot ? \"wearable\" : \"held\");\n\n let base: string | null = null;\n if (kind === \"held\") {\n base = typeof o.base === \"string\" ? resolveItem(o.base) : null;\n if (base && ITEMS[base]?.blockId !== null) {\n warnings.push(\n tr(\n {\n en: \"«{b}» is a block; only items can have a look\",\n ru: \"«{b}» — блок; облик бывает только у предметов\",\n },\n { b: base },\n ),\n );\n base = null;\n }\n if (!base) {\n const text = name + \" \" + str(o.description, \"\", 300);\n base = BASE_GUESS.find(([re]) => re.test(text))?.[1] ?? \"iron_sword\";\n warnings.push(\n tr(\n {\n en: \"no base item given — «{b}» was taken\",\n ru: \"базовый предмет не указан — взят «{b}»\",\n },\n { b: base },\n ),\n );\n }\n }\n\n // палитра\n const palette: Record<string, string> = {};\n if (isObj(o.palette))\n for (const [k, v] of Object.entries(o.palette)) {\n const c = normColor(v);\n if (k.length !== 1 || CLEAR.has(k) || !c) continue;\n if (Object.keys(palette).length >= L.palette) break;\n palette[k] = c;\n }\n const FREE = \"abcdefghijklmnopqrstuvwxyz\";\n const colorKey = (v: unknown): string | null => {\n if (typeof v !== \"string\") return null;\n if (v.length === 1 && palette[v]) return v;\n const c = normColor(v);\n if (!c) return null;\n const have = Object.entries(palette).find(([, x]) => x === c);\n if (have) return have[0];\n if (Object.keys(palette).length >= L.palette) return null;\n const free = [...FREE].find((ch) => !palette[ch]);\n if (!free) return null;\n palette[free] = c;\n return free;\n };\n\n const size = (vec3(o.size, L.side) ?? [16, 16, 16]).map((n) =>\n Math.max(1, Math.min(L.side, Math.round(n))),\n ) as V3;\n const lim = L.side + 4;\n\n const parts: SkinShape[] = [];\n for (const raw of arr(o.parts)) {\n if (parts.length >= L.parts) {\n warnings.push(\n tr(\n {\n en: \"more than {n} shapes — the rest were dropped\",\n ru: \"фигур больше {n} — лишние отброшены\",\n },\n { n: L.parts },\n ),\n );\n break;\n }\n if (!isObj(raw)) continue;\n const shape = pick(raw.shape, MODEL_SHAPES, \"box\");\n const color = colorKey(raw.color);\n if (!color) continue;\n const p: SkinShape = { shape, color };\n if (shape === \"box\" || shape === \"line\") {\n const from = vec3(raw.from, lim),\n to = vec3(raw.to, lim);\n if (!from || !to) continue;\n p.from = from;\n p.to = to;\n if (shape === \"line\") p.radius = num(raw.radius, 0, 0, 4);\n } else {\n const center = vec3(raw.center, lim);\n if (!center) continue;\n p.center = center;\n if (shape === \"sphere\") {\n const r = num(raw.radius, 2, 0.5, L.side);\n const rr = vec3(raw.radii, L.side) ?? [r, r, r];\n p.radii = rr.map((n) => Math.max(0.5, n)) as V3;\n } else {\n p.axis = pick(raw.axis, MODEL_AXES, \"y\");\n p.radius = num(raw.radius, 1, 0, L.side);\n p.length = Math.round(num(raw.length, 1, -lim, lim)) || 1;\n if (shape === \"cone\") p.radius2 = num(raw.radius2, 0, 0, L.side);\n }\n }\n if (raw.hollow === true && shape !== \"line\") p.hollow = true;\n if (raw.mirror === \"x\" || raw.mirror === \"z\") p.mirror = raw.mirror;\n parts.push(p);\n }\n\n const voxels: [number, number, number, string][] = [];\n for (const raw of arr(o.voxels)) {\n if (voxels.length >= L.voxels) break;\n if (!Array.isArray(raw) || raw.length < 4) continue;\n const c = colorKey(raw[3]);\n const [x, y, z] = raw\n .slice(0, 3)\n .map((n) => Math.round(num(n, NaN, 0, L.side)));\n if (!c || ![x, y, z].every(Number.isFinite)) continue;\n voxels.push([x!, y!, z!, c]);\n }\n if (!parts.length && !voxels.length)\n return {\n ok: false,\n error: tr({\n en: \"the look has no shapes\",\n ru: \"в облике нет ни одной фигуры\",\n }),\n };\n\n const go = isObj(o.grip) ? o.grip : {};\n const grip = {\n at: (vec3(go.at, lim) ?? [size[0] / 2, 1, size[2] / 2]) as V3,\n pose: pick(go.pose, GRIP_POSES, \"one_hand\"),\n };\n\n let icon: PixelTex | null = null;\n if (isObj(o.icon) || Array.isArray(o.icon)) {\n const io = Array.isArray(o.icon)\n ? { rows: o.icon }\n : (o.icon as Record<string, unknown>);\n const rows = arr(io.rows)\n .filter((r): r is string => typeof r === \"string\")\n .slice(0, 16)\n .map((r) => {\n let s = \"\";\n for (let i = 0; i < 16; i++) {\n const c = r[i] ?? \".\";\n s += palette[c] ? c : \".\";\n }\n return s;\n });\n if (rows.length) {\n while (rows.length < 16) rows.push(\".\".repeat(16));\n icon = { w: 16, h: 16, rows };\n }\n }\n\n return {\n ok: true,\n spec: {\n version: ITEM_SKIN_VERSION,\n name,\n description: str(o.description, \"\", 300),\n kind,\n base,\n slot: kind === \"wearable\" ? (slot ?? \"head\") : null,\n size,\n grip,\n palette,\n parts,\n voxels,\n icon,\n },\n warnings,\n };\n}\n\n/** Фигура облика как фигура модели мира (для общего растеризатора). */\nexport function asModelPart(p: SkinShape): ModelPart {\n const { color: _c, ...rest } = p;\n return { ...rest, block: \"air\" };\n}\n"
|
|
134
|
+
"content": "// Облик предмета — рецепт, как у персонажа и мира: ИИ (или студия) описывает вещь фигурами в\n// пиксельной сетке, игра строит модель сама.\n//\n// Два вида (решение владельца 11.10.2026):\n// - held — новый ВИД существующего предмета (base): меч остаётся железным мечом — урон, прочность,\n// крафт и сеть от него, облик живёт в метке стака (ItemStack.tag.skin);\n// - wearable — косметика на теле (шляпа, плащ, крылья, рюкзак): игру не меняет.\n//\n// Координаты — клетки сетки size (1 клетка = 1 пиксель = 1/16 блока): x вправо, y вверх, z назад\n// (перёд персонажа и предмета — −z, как у мобов). Предмет в руке строится стоя: рукоять в точке\n// grip.at, остриё вверх (+y). Надеваемое крепится точкой grip.at к сокету тела (character/rigs).\nimport { resolveItem } from \"../../registry/aliases\";\nimport { ITEMS } from \"../../registry/Items\";\nimport {\n MODEL_AXES,\n MODEL_MIRRORS,\n MODEL_SHAPES,\n extractJsonObject,\n type ModelPart,\n type ModelShape,\n} from \"../../worldgen/WorldSpec\";\nimport {\n CLEAR,\n isObj,\n normColor,\n num,\n pick,\n vec3,\n type PixelTex,\n type SocketName,\n type V3,\n} from \"@idosgames/characters/voxel\";\nimport { tr } from \"../../i18n\";\n\nexport const ITEM_SKIN_VERSION = 1 as const;\nexport const SKIN_KINDS = [\"held\", \"wearable\"] as const;\nexport type SkinKind = (typeof SKIN_KINDS)[number];\n\n/** Куда надевается косметика → сокет скелета. */\nexport const WEAR_SLOTS = {\n head: \"headTop\",\n face: \"face\",\n back: \"back\",\n shoulders: \"shoulders\",\n waist: \"waist\",\n} as const satisfies Record<string, SocketName>;\nexport type WearSlot = keyof typeof WEAR_SLOTS;\nexport const WEAR_SLOT_IDS = Object.keys(WEAR_SLOTS) as WearSlot[];\n\n/** Как держат предмет: поза руки и поворот модели в кулаке. */\nexport const GRIP_POSES = [\n \"one_hand\",\n \"two_hand\",\n \"bow\",\n \"shield\",\n \"staff\",\n] as const;\nexport type GripPose = (typeof GRIP_POSES)[number];\n\nexport const SKIN_LIMITS = {\n /** Сторона сетки, клеток (32 = 2 блока). */\n side: 32,\n parts: 48,\n voxels: 512,\n palette: 16,\n} as const;\n\n/** Фигура облика: как у моделей мира (worldgen/voxelModel), только цвет палитры вместо блока. */\nexport interface SkinShape {\n shape: ModelShape;\n color: string;\n from?: V3;\n to?: V3;\n axis?: (typeof MODEL_AXES)[number];\n center?: V3;\n radius?: number;\n radius2?: number;\n length?: number;\n radii?: V3;\n hollow?: boolean;\n mirror?: (typeof MODEL_MIRRORS)[number];\n}\n\nexport interface ItemSkinSpec {\n version: typeof ITEM_SKIN_VERSION;\n name: string;\n description: string;\n kind: SkinKind;\n /** held: какой предмет игры это (id из ITEMS). */\n base: string | null;\n /** wearable: куда надевается. */\n slot: WearSlot | null;\n /** Размер сетки [x, y, z], клеток. */\n size: V3;\n grip: { at: V3; pose: GripPose };\n palette: Record<string, string>;\n parts: SkinShape[];\n voxels: [number, number, number, string][];\n /** Иконка 16×16 в слотах; нет — рисуется видом спереди. */\n icon: PixelTex | null;\n}\n\nexport type SkinParseResult =\n | { ok: true; spec: ItemSkinSpec; warnings: string[] }\n | { ok: false; error: string; truncated?: boolean };\n\nconst arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);\n\nfunction str(v: unknown, def: string, max: number): string {\n return typeof v === \"string\" ? v.trim().slice(0, max) : def;\n}\n\n/** Догадка о базовом предмете по названию, если модель его не назвала. */\nconst BASE_GUESS: [RegExp, string][] = [\n [/pick/i, \"iron_pickaxe\"],\n [/axe|топор/i, \"iron_axe\"],\n [/shovel|лопат/i, \"iron_shovel\"],\n [/hoe|мотыг/i, \"iron_hoe\"],\n [/crossbow|арбалет/i, \"crossbow\"],\n [/bow|лук/i, \"bow\"],\n [/shield|щит/i, \"shield\"],\n [/trident|трезуб/i, \"trident\"],\n [/sword|blade|katana|меч|клинок|катан/i, \"iron_sword\"],\n];\n\nexport function parseItemSkin(input: unknown): SkinParseResult {\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))\n return {\n ok: false,\n error: tr({\n en: \"the item look is not an object\",\n ru: \"облик предмета — не объект\",\n }),\n };\n if (!(\"parts\" in input) && !(\"voxels\" in input)) {\n const inner = input.item ?? input.skin ?? input.spec;\n if (isObj(inner)) input = inner;\n }\n const o = input as Record<string, unknown>;\n const L = SKIN_LIMITS;\n const warnings: string[] = [];\n const fallback = tr({ en: \"Item\", ru: \"Предмет\" });\n const name = str(o.name, fallback, 40) || fallback;\n\n const slotRaw = typeof o.slot === \"string\" ? o.slot.trim().toLowerCase() : \"\";\n const slot = (WEAR_SLOT_IDS as string[]).includes(slotRaw)\n ? (slotRaw as WearSlot)\n : null;\n const kind = pick(o.kind, SKIN_KINDS, slot ? \"wearable\" : \"held\");\n\n let base: string | null = null;\n if (kind === \"held\") {\n base = typeof o.base === \"string\" ? resolveItem(o.base) : null;\n if (base && ITEMS[base]?.blockId !== null) {\n warnings.push(\n tr(\n {\n en: \"«{b}» is a block; only items can have a look\",\n ru: \"«{b}» — блок; облик бывает только у предметов\",\n },\n { b: base },\n ),\n );\n base = null;\n }\n if (!base) {\n const text = name + \" \" + str(o.description, \"\", 300);\n base = BASE_GUESS.find(([re]) => re.test(text))?.[1] ?? \"iron_sword\";\n warnings.push(\n tr(\n {\n en: \"no base item given — «{b}» was taken\",\n ru: \"базовый предмет не указан — взят «{b}»\",\n },\n { b: base },\n ),\n );\n }\n }\n\n // палитра\n const palette: Record<string, string> = {};\n if (isObj(o.palette))\n for (const [k, v] of Object.entries(o.palette)) {\n const c = normColor(v);\n if (k.length !== 1 || CLEAR.has(k) || !c) continue;\n if (Object.keys(palette).length >= L.palette) break;\n palette[k] = c;\n }\n const FREE = \"abcdefghijklmnopqrstuvwxyz\";\n const colorKey = (v: unknown): string | null => {\n if (typeof v !== \"string\") return null;\n if (v.length === 1 && palette[v]) return v;\n const c = normColor(v);\n if (!c) return null;\n const have = Object.entries(palette).find(([, x]) => x === c);\n if (have) return have[0];\n if (Object.keys(palette).length >= L.palette) return null;\n const free = [...FREE].find((ch) => !palette[ch]);\n if (!free) return null;\n palette[free] = c;\n return free;\n };\n\n const size = (vec3(o.size, L.side) ?? [16, 16, 16]).map((n) =>\n Math.max(1, Math.min(L.side, Math.round(n))),\n ) as V3;\n const lim = L.side + 4;\n\n const parts: SkinShape[] = [];\n for (const raw of arr(o.parts)) {\n if (parts.length >= L.parts) {\n warnings.push(\n tr(\n {\n en: \"more than {n} shapes — the rest were dropped\",\n ru: \"фигур больше {n} — лишние отброшены\",\n },\n { n: L.parts },\n ),\n );\n break;\n }\n if (!isObj(raw)) continue;\n const shape = pick(raw.shape, MODEL_SHAPES, \"box\");\n const color = colorKey(raw.color);\n if (!color) continue;\n const p: SkinShape = { shape, color };\n if (shape === \"box\" || shape === \"line\") {\n const from = vec3(raw.from, lim),\n to = vec3(raw.to, lim);\n if (!from || !to) continue;\n p.from = from;\n p.to = to;\n if (shape === \"line\") p.radius = num(raw.radius, 0, 0, 4);\n } else {\n const center = vec3(raw.center, lim);\n if (!center) continue;\n p.center = center;\n if (shape === \"sphere\") {\n const r = num(raw.radius, 2, 0.5, L.side);\n const rr = vec3(raw.radii, L.side) ?? [r, r, r];\n p.radii = rr.map((n) => Math.max(0.5, n)) as V3;\n } else {\n p.axis = pick(raw.axis, MODEL_AXES, \"y\");\n p.radius = num(raw.radius, 1, 0, L.side);\n p.length = Math.round(num(raw.length, 1, -lim, lim)) || 1;\n if (shape === \"cone\") p.radius2 = num(raw.radius2, 0, 0, L.side);\n }\n }\n if (raw.hollow === true && shape !== \"line\") p.hollow = true;\n if (raw.mirror === \"x\" || raw.mirror === \"z\") p.mirror = raw.mirror;\n parts.push(p);\n }\n\n const voxels: [number, number, number, string][] = [];\n for (const raw of arr(o.voxels)) {\n if (voxels.length >= L.voxels) break;\n if (!Array.isArray(raw) || raw.length < 4) continue;\n const c = colorKey(raw[3]);\n const [x, y, z] = raw\n .slice(0, 3)\n .map((n) => Math.round(num(n, NaN, 0, L.side)));\n if (!c || ![x, y, z].every(Number.isFinite)) continue;\n voxels.push([x!, y!, z!, c]);\n }\n if (!parts.length && !voxels.length)\n return {\n ok: false,\n error: tr({\n en: \"the look has no shapes\",\n ru: \"в облике нет ни одной фигуры\",\n }),\n };\n\n const go = isObj(o.grip) ? o.grip : {};\n const grip = {\n at: (vec3(go.at, lim) ?? [size[0] / 2, 1, size[2] / 2]) as V3,\n pose: pick(go.pose, GRIP_POSES, \"one_hand\"),\n };\n\n let icon: PixelTex | null = null;\n if (isObj(o.icon) || Array.isArray(o.icon)) {\n const io = Array.isArray(o.icon)\n ? { rows: o.icon }\n : (o.icon as Record<string, unknown>);\n const rows = arr(io.rows)\n .filter((r): r is string => typeof r === \"string\")\n .slice(0, 16)\n .map((r) => {\n let s = \"\";\n for (let i = 0; i < 16; i++) {\n const c = r[i] ?? \".\";\n s += palette[c] ? c : \".\";\n }\n return s;\n });\n if (rows.length) {\n while (rows.length < 16) rows.push(\".\".repeat(16));\n icon = { w: 16, h: 16, rows };\n }\n }\n\n return {\n ok: true,\n spec: {\n version: ITEM_SKIN_VERSION,\n name,\n description: str(o.description, \"\", 300),\n kind,\n base,\n slot: kind === \"wearable\" ? (slot ?? \"head\") : null,\n size,\n grip,\n palette,\n parts,\n voxels,\n icon,\n },\n warnings,\n };\n}\n\n/** Фигура облика как фигура модели мира (для общего растеризатора). */\nexport function asModelPart(p: SkinShape): ModelPart {\n const { color: _c, ...rest } = p;\n return { ...rest, block: \"air\" };\n}\n"
|
|
178
135
|
},
|
|
179
136
|
{
|
|
180
137
|
"path": "character/items/samples.ts",
|
|
@@ -182,15 +139,11 @@
|
|
|
182
139
|
},
|
|
183
140
|
{
|
|
184
141
|
"path": "character/items/skinModel.ts",
|
|
185
|
-
"content": "// Облик предмета → данные модели: сетка клеток (общий растеризатор фигур), видимые грани с цветами\n// вершин и иконка. Чистые данные без three — для теста-отпечатка (облик в метке стака лежит в\n// сейвах).\nimport { rasterizeShapes } from \"../../worldgen/voxelModel\";\nimport { CLEAR } from \"
|
|
142
|
+
"content": "// Облик предмета → данные модели: сетка клеток (общий растеризатор фигур), видимые грани с цветами\n// вершин и иконка. Чистые данные без three — для теста-отпечатка (облик в метке стака лежит в\n// сейвах).\nimport { rasterizeShapes } from \"../../worldgen/voxelModel\";\nimport { CLEAR } from \"@idosgames/characters/voxel\";\nimport type { ModelPart } from \"../../worldgen/WorldSpec\";\nimport { asModelPart, type ItemSkinSpec } from \"./ItemSkinSpec\";\n\nexport interface SkinMesh {\n /** Вершины в пикселях относительно точки хвата. */\n positions: Float32Array;\n normals: Float32Array;\n /** Линейные цвета 0..1. */\n colors: Float32Array;\n indices: Uint32Array;\n /** Иконка: сторона и RGBA (сверху вниз). */\n icon: { side: number; data: Uint8ClampedArray<ArrayBuffer> };\n}\n\nconst FACES: { n: [number, number, number]; c: [number, number, number][] }[] =\n [\n {\n n: [1, 0, 0],\n c: [\n [1, 0, 0],\n [1, 1, 0],\n [1, 1, 1],\n [1, 0, 1],\n ],\n },\n {\n n: [-1, 0, 0],\n c: [\n [0, 0, 1],\n [0, 1, 1],\n [0, 1, 0],\n [0, 0, 0],\n ],\n },\n {\n n: [0, 1, 0],\n c: [\n [0, 1, 1],\n [1, 1, 1],\n [1, 1, 0],\n [0, 1, 0],\n ],\n },\n {\n n: [0, -1, 0],\n c: [\n [0, 0, 0],\n [1, 0, 0],\n [1, 0, 1],\n [0, 0, 1],\n ],\n },\n {\n n: [0, 0, 1],\n c: [\n [1, 0, 1],\n [1, 1, 1],\n [0, 1, 1],\n [0, 0, 1],\n ],\n },\n {\n n: [0, 0, -1],\n c: [\n [0, 0, 0],\n [0, 1, 0],\n [1, 1, 0],\n [1, 0, 0],\n ],\n },\n ];\n\nconst srgbToLinear = (c: number) =>\n c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);\n\nexport function buildSkinMesh(spec: ItemSkinSpec): SkinMesh {\n const keys = Object.keys(spec.palette);\n const index = new Map(keys.map((k, i) => [k, i + 1]));\n const colorOf = new Map<ModelPart, string>();\n const shapes = spec.parts.map((p) => {\n const m = asModelPart(p);\n colorOf.set(m, p.color);\n return m;\n });\n // у фигуры свой цвет: номер в палитре + 1 (0 — пусто); позже нарисованная перекрывает раннюю\n const r = rasterizeShapes(\n spec.size,\n shapes,\n (p) => index.get(colorOf.get(p) ?? \"\") ?? 0,\n spec.voxels.map(([x, y, z, k]) => [x, y, z, index.get(k) ?? 0]),\n );\n const [w, h, d] = spec.size;\n\n const cell = (x: number, y: number, z: number) =>\n x < 0 || y < 0 || z < 0 || x >= w || y >= h || z >= d\n ? 0\n : r.cells[x + z * w + y * w * d]!;\n const rgb = keys.map((k) => {\n const n = parseInt(spec.palette[k]!.slice(1), 16);\n return [\n srgbToLinear(((n >> 16) & 255) / 255),\n srgbToLinear(((n >> 8) & 255) / 255),\n srgbToLinear((n & 255) / 255),\n ];\n });\n\n const pos: number[] = [],\n nrm: number[] = [],\n col: number[] = [],\n idx: number[] = [];\n const [gx, gy, gz] = spec.grip.at;\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 v = cell(x, y, z);\n if (!v) continue;\n const c = rgb[v - 1]!;\n for (const f of FACES) {\n if (cell(x + f.n[0], y + f.n[1], z + f.n[2])) continue;\n const base = pos.length / 3;\n for (const [cx, cy, cz] of f.c) {\n pos.push(x + cx - gx, y + cy - gy, z + cz - gz);\n nrm.push(...f.n);\n col.push(c[0]!, c[1]!, c[2]!);\n }\n idx.push(base, base + 1, base + 2, base, base + 2, base + 3);\n }\n }\n\n return {\n positions: Float32Array.from(pos),\n normals: Float32Array.from(nrm),\n colors: Float32Array.from(col),\n indices: Uint32Array.from(idx),\n icon: iconOf(spec, cell),\n };\n}\n\n/** Иконка: нарисованная моделью 16×16 или вид спереди (ближняя клетка по z), вписанный в квадрат. */\nfunction iconOf(\n spec: ItemSkinSpec,\n cell: (x: number, y: number, z: number) => number,\n): SkinMesh[\"icon\"] {\n const keys = Object.keys(spec.palette);\n const hex = (k: string) => parseInt(spec.palette[k]!.slice(1), 16);\n if (spec.icon) {\n const data = new Uint8ClampedArray(16 * 16 * 4);\n spec.icon.rows.forEach((row, y) => {\n for (let x = 0; x < 16; x++) {\n const ch = row[x]!;\n if (CLEAR.has(ch) || !spec.palette[ch]) continue;\n const n = hex(ch),\n i = (y * 16 + x) * 4;\n data[i] = (n >> 16) & 255;\n data[i + 1] = (n >> 8) & 255;\n data[i + 2] = n & 255;\n data[i + 3] = 255;\n }\n });\n return { side: 16, data };\n }\n const [w, h, d] = spec.size;\n const side = Math.max(w, h);\n const ox = Math.floor((side - w) / 2),\n oy = Math.floor((side - h) / 2);\n const data = new Uint8ClampedArray(side * side * 4);\n for (let y = 0; y < h; y++)\n for (let x = 0; x < w; x++) {\n let v = 0,\n depth = 0;\n for (let z = 0; z < d && !v; z++) {\n v = cell(x, y, z);\n depth = z;\n }\n if (!v) continue;\n // смотрим спереди (из −z): справа у смотрящего — левый край модели (−x)\n const px = ox + (w - 1 - x),\n py = oy + (h - 1 - y);\n const n = hex(keys[v - 1]!);\n const shade = 1 - Math.min(0.35, depth / Math.max(4, d) / 2);\n const i = (py * side + px) * 4;\n data[i] = ((n >> 16) & 255) * shade;\n data[i + 1] = ((n >> 8) & 255) * shade;\n data[i + 2] = (n & 255) * shade;\n data[i + 3] = 255;\n }\n return { side, data };\n}\n"
|
|
186
143
|
},
|
|
187
144
|
{
|
|
188
145
|
"path": "character/items/skins.ts",
|
|
189
|
-
"content": "// Реестр обликов предметов в памяти: хэш → рецепт + готовая геометрия и иконка. Облики попадают\n// сюда из «Моих предметов», из сейва мира (save.skins), из сети (по хэшу) и из Мастерской. Стак\n// ссылается на облик хэшем в метке: ItemStack.tag.skin.\nimport * as THREE from \"three\";\nimport { specHash } from \"
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
"path": "character/layout.ts",
|
|
193
|
-
"content": "// Рецепт → готовые к GPU массивы: геометрия каждой кости, атлас текстур персонажа (RGBA), лицо,\n// сокеты. Чистые данные без three и DOM — поэтому вывод сборки фиксируется тестом-отпечатком: рецепт\n// своего моба лежит в сейве мира, и любая правка здесь меняет вид уже сохранённых существ.\n//\n// Грань коробки описана так, как её видит смотрящий СНАРУЖИ: левый верхний угол картинки, ось\n// «вправо» (u) и ось «вниз» (d). Лицо (front) смотрит в −z, поэтому у смотрящего слева — правая рука\n// персонажа (+x). Нормаль = d × u, треугольники (0,2,1)(0,3,2) — против часовой снаружи.\nimport {\n CLEAR,\n DEFAULT_FINISH,\n FACE_NAMES,\n bonesOf,\n mirrorBone,\n specHash,\n type CharFinish,\n type CharPart,\n type CharacterSpec,\n type Expression,\n type FaceName,\n type PixelTex,\n} from \"./CharacterSpec\";\nimport { RIGS, type SocketName, type V3 } from \"./rigs\";\nimport { hashString } from \"../core/Noise\";\n\nexport interface BoneGeo {\n name: string;\n role: string;\n parent: string | null;\n /** Точка вращения в пикселях модели. */\n pivot: V3;\n /** Вершины относительно pivot, пиксели. */\n positions: Float32Array;\n normals: Float32Array;\n uvs: Float32Array;\n indices: Uint16Array;\n}\n\nexport interface FaceDecal {\n bone: string;\n /** 4 угла квада (TL, TR, BR, BL снаружи) относительно pivot кости, пиксели. */\n corners: Float32Array;\n /** Выражение → [u0, v0, u1, v1] в атласе. */\n uv: Partial<Record<Expression, [number, number, number, number]>>;\n}\n\nexport interface CharacterLayout {\n hash: string;\n rig: CharacterSpec[\"rig\"];\n bones: BoneGeo[];\n atlas: { w: number; h: number; data: Uint8Array };\n face: FaceDecal | null;\n /** Сокеты относительно pivot своей кости, пиксели. */\n sockets: { name: SocketName; bone: string; at: V3 }[];\n /** Рост модели (верх самой высокой коробки), пиксели. */\n height: number;\n /** Множитель пикселей модели: масштаб рецепта × подгонка роста под пределы шаблона. */\n scale: number;\n /** Высота глаз, пиксели модели. */\n eye: number;\n}\n\n// ---- грани ----------------------------------------------------------------------------\n\ninterface FaceFrame {\n /** Левый верхний угол в долях полуразмеров (−1/+1). */\n tl: V3;\n u: V3;\n d: V3;\n n: V3;\n}\n\nconst FRAMES: Record<FaceName, FaceFrame> = {\n front: { tl: [1, 1, -1], u: [-1, 0, 0], d: [0, -1, 0], n: [0, 0, -1] },\n back: { tl: [-1, 1, 1], u: [1, 0, 0], d: [0, -1, 0], n: [0, 0, 1] },\n right: { tl: [1, 1, 1], u: [0, 0, -1], d: [0, -1, 0], n: [1, 0, 0] },\n left: { tl: [-1, 1, -1], u: [0, 0, 1], d: [0, -1, 0], n: [-1, 0, 0] },\n top: { tl: [1, 1, 1], u: [-1, 0, 0], d: [0, 0, -1], n: [0, 1, 0] },\n bottom: { tl: [-1, -1, 1], u: [1, 0, 0], d: [0, 0, -1], n: [0, -1, 0] },\n};\n\n// ---- атлас ----------------------------------------------------------------------------\n\ninterface Cell {\n key: string;\n w: number;\n h: number;\n x: number;\n y: number;\n paint: (\n data: Uint8Array,\n W: number,\n H: number,\n x0: number,\n y0: number,\n ) => void;\n}\n\nfunction rgb(hex: string): [number, number, number] {\n const n = parseInt(hex.slice(1), 16);\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\n/** Пиксель атласа (верхняя строка — y = 0; в данных текстуры строки снизу вверх). */\nfunction put(\n data: Uint8Array,\n W: number,\n H: number,\n x: number,\n y: number,\n c: [number, number, number],\n): void {\n const i = ((H - 1 - y) * W + x) * 4;\n data[i] = c[0];\n data[i + 1] = c[1];\n data[i + 2] = c[2];\n data[i + 3] = 255;\n}\n\n/** Полки: высокие клетки первыми; ширина — наименьшая степень двойки, при которой атлас не выше. */\nfunction pack(cells: Cell[]): { w: number; h: number } {\n cells.sort((a, b) => b.h - a.h || b.w - a.w || (a.key < b.key ? -1 : 1));\n for (let W = 16; ; W *= 2) {\n let x = 0,\n y = 0,\n shelf = 0;\n for (const c of cells) {\n if (x + c.w > W) {\n x = 0;\n y += shelf;\n shelf = 0;\n }\n c.x = x;\n c.y = y;\n x += c.w;\n shelf = Math.max(shelf, c.h);\n }\n const used = y + shelf;\n if (used <= W || W >= 1024) {\n let H = 16;\n while (H < used) H *= 2;\n return { w: W, h: H };\n }\n }\n}\n\n// ---- отделка ---------------------------------------------------------------------------\n\n/** Детерминированный шум текселя 0..1 (одинаковый на всех устройствах — вид не «плывёт»). */\nfunction noise01(seed: number, x: number, y: number): number {\n let h =\n Math.imul(x, 374761393) +\n Math.imul(y, 668265263) +\n Math.imul(seed, 2246822519);\n h = Math.imul(h ^ (h >>> 13), 1274126177);\n return ((h ^ (h >>> 16)) >>> 0) / 4294967296;\n}\n\n/**\n * Цвет текселя грани с отделкой: шум ткани (мелкий + пятна 3×3), светотень (боковые грани — сверху\n * светлее, снизу темнее; верх светлее, низ темнее) и тёмный контур по краю грани.\n */\nfunction finished(\n c: [number, number, number],\n x: number,\n y: number,\n w: number,\n h: number,\n kind: \"s\" | \"t\" | \"b\",\n seed: number,\n f: CharFinish,\n): [number, number, number] {\n const n =\n 0.55 * noise01(seed, x, y) +\n 0.45 * noise01(seed ^ 0x5bd1e995, Math.floor(x / 3), Math.floor(y / 3)) -\n 0.5;\n let k = 1 + n * 2 * f.noise * 0.14;\n if (kind === \"s\") k += f.shade * 0.16 * (0.5 - (y + 0.5) / h);\n else if (kind === \"t\") k += f.shade * 0.08;\n else k -= f.shade * 0.2;\n if (w >= 3 && h >= 3 && (x === 0 || y === 0 || x === w - 1 || y === h - 1))\n k *= 1 - 0.5 * f.outline;\n return [\n Math.max(0, Math.min(255, c[0] * k)),\n Math.max(0, Math.min(255, c[1] * k)),\n Math.max(0, Math.min(255, c[2] * k)),\n ];\n}\n\n// ---- сборка ---------------------------------------------------------------------------\n\nconst DEG = Math.PI / 180;\n\n/** Поворот вектора углами Эйлера XYZ (градусы), как THREE.Euler('XYZ'). */\nfunction rotate(v: V3, r: V3 | undefined): V3 {\n if (!r) return v;\n let [x, y, z] = v;\n const [ax, ay, az] = [r[0] * DEG, r[1] * DEG, r[2] * DEG];\n // R = Rx·Ry·Rz, применяем справа налево\n let c = Math.cos(az),\n s = Math.sin(az);\n [x, y] = [x * c - y * s, x * s + y * c];\n c = Math.cos(ay);\n s = Math.sin(ay);\n [x, z] = [x * c + z * s, -x * s + z * c];\n c = Math.cos(ax);\n s = Math.sin(ax);\n [y, z] = [y * c - z * s, y * s + z * c];\n return [x, y, z];\n}\n\n/** Коробки рецепта с зеркальными копиями (копия — на парной кости, грани left/right меняются). */\nfunction expandParts(\n spec: CharacterSpec,\n exists: (b: string) => boolean,\n): (CharPart & { flip: boolean })[] {\n const out: (CharPart & { flip: boolean })[] = [];\n for (const p of spec.parts) {\n out.push({ ...p, flip: false });\n if (!p.mirror) continue;\n const faces = p.faces\n ? { ...p.faces, left: p.faces.right, right: p.faces.left }\n : undefined;\n if (faces) {\n if (faces.left === undefined) delete faces.left;\n if (faces.right === undefined) delete faces.right;\n }\n const m: CharPart & { flip: boolean } = {\n ...p,\n bone: mirrorBone(p.bone, exists),\n pos: [-p.pos[0], p.pos[1], p.pos[2]],\n flip: true,\n };\n if (faces) m.faces = faces;\n else delete m.faces;\n if (p.rot) m.rot = [p.rot[0], -p.rot[1], -p.rot[2]];\n out.push(m);\n }\n return out;\n}\n\nexport function layoutCharacter(spec: CharacterSpec): CharacterLayout {\n const tpl = RIGS[spec.rig];\n const bones = bonesOf(spec);\n const has = (b: string) => bones.some((x) => x.name === b);\n const parts = expandParts(spec, has);\n\n // Клетки атласа. Грань коробки — своя клетка в разрешении грани (finish.density текселей на\n // пиксель модели) с отделкой: шум ткани, контур, светотень. Одинаковые грани (тот же цвет или\n // картинка, тот же размер) — одна клетка: у зеркальной руки та же ткань.\n // рецепт старой версии (без отделки) — отделка по умолчанию\n const fin = spec.finish ?? DEFAULT_FINISH;\n const cells = new Map<string, Cell>();\n const faceCell = (p: CharPart, f: FaceName): Cell => {\n const v = p.faces?.[f];\n const tex = v !== undefined ? spec.textures[v] : undefined;\n const colorKey = v !== undefined && spec.palette[v] ? v : p.color;\n const [fw, fh] =\n f === \"front\" || f === \"back\"\n ? [p.size[0], p.size[1]]\n : f === \"left\" || f === \"right\"\n ? [p.size[2], p.size[1]]\n : [p.size[0], p.size[2]];\n const side = (n: number, base: number) =>\n Math.max(\n 1,\n Math.min(64, base * Math.max(1, Math.round((n * fin.density) / base))),\n );\n const cw = tex ? side(fw, tex.w) : side(fw, 1);\n const ch = tex ? side(fh, tex.h) : side(fh, 1);\n const kind = f === \"top\" ? \"t\" : f === \"bottom\" ? \"b\" : \"s\";\n const key = (tex ? \"t:\" + v : \"c:\" + colorKey) + \"|\" + cw + \"x\" + ch + kind;\n let c = cells.get(key);\n if (c) return c;\n const seed = hashString(key);\n const solid = tex ? null : rgb(spec.palette[colorKey] ?? \"#ff00ff\");\n c = {\n key,\n w: cw,\n h: ch,\n x: 0,\n y: 0,\n paint: (d, W, H, x0, y0) => {\n for (let y = 0; y < ch; y++)\n for (let x = 0; x < cw; x++) {\n let col = solid;\n if (tex) {\n const chr =\n tex.rows[Math.floor((y * tex.h) / ch)]![\n Math.floor((x * tex.w) / cw)\n ]!;\n if (CLEAR.has(chr)) continue;\n const hex = spec.palette[chr];\n if (!hex) continue;\n col = rgb(hex);\n }\n put(\n d,\n W,\n H,\n x0 + x,\n y0 + y,\n finished(col!, x, y, cw, ch, kind, seed, fin),\n );\n }\n },\n };\n cells.set(key, c);\n return c;\n };\n // лицо — без отделки: глаза и рот должны читаться чётко\n const exprCell = (id: string, t: PixelTex): Cell => {\n const key = \"e:\" + id;\n let c = cells.get(key);\n if (!c) {\n c = {\n key,\n w: t.w,\n h: t.h,\n x: 0,\n y: 0,\n paint: (d, W, H, x0, y0) => {\n for (let y = 0; y < t.h; y++)\n for (let x = 0; x < t.w; x++) {\n const chr = t.rows[y]![x]!;\n if (CLEAR.has(chr)) continue;\n const hex = spec.palette[chr];\n if (hex) put(d, W, H, x0 + x, y0 + y, rgb(hex));\n }\n },\n };\n cells.set(key, c);\n }\n return c;\n };\n\n // сначала собираем, какие клетки нужны (грани и лицо), потом пакуем, потом пишем вершины\n const plan = parts.map((p) => ({\n p,\n faces: FACE_NAMES.map((f) => ({ f, cell: faceCell(p, f) })),\n }));\n const face = spec.face;\n const exprCells: [Expression, Cell][] = [];\n if (face)\n for (const [e, id] of Object.entries(face.expressions) as [\n Expression,\n string,\n ][]) {\n const t = spec.textures[id];\n if (t) exprCells.push([e, exprCell(id, t)]);\n }\n const { w: W, h: H } = pack([...cells.values()]);\n const data = new Uint8Array(W * H * 4);\n for (const c of cells.values()) c.paint(data, W, H, c.x, c.y);\n\n // UV клетки: внутрь на сотую текселя, чтобы ближайший тексель не брался из соседней клетки\n const E = 0.01;\n const uvRect = (c: Cell): [number, number, number, number] => [\n (c.x + E) / W,\n 1 - (c.y + E) / H,\n (c.x + c.w - E) / W,\n 1 - (c.y + c.h - E) / H,\n ];\n\n // геометрия по костям\n const acc = new Map<\n string,\n { pos: number[]; nrm: number[]; uv: number[]; idx: number[] }\n >();\n for (const b of bones) acc.set(b.name, { pos: [], nrm: [], uv: [], idx: [] });\n let top = 0;\n for (const { p, faces } of plan) {\n const a = acc.get(p.bone)!;\n const pivot = bones.find((b) => b.name === p.bone)!.pivot;\n const half: V3 = [p.size[0] / 2, p.size[1] / 2, p.size[2] / 2];\n top = Math.max(top, p.pos[1] + half[1]);\n for (const { f, cell } of faces) {\n const fr = FRAMES[f];\n const at = (cu: number, cd: number): V3 => {\n const local: V3 = [\n half[0] * (fr.tl[0] + 2 * (fr.u[0] * cu + fr.d[0] * cd)),\n half[1] * (fr.tl[1] + 2 * (fr.u[1] * cu + fr.d[1] * cd)),\n half[2] * (fr.tl[2] + 2 * (fr.u[2] * cu + fr.d[2] * cd)),\n ];\n const r = rotate(local, p.rot);\n return [\n p.pos[0] + r[0] - pivot[0],\n p.pos[1] + r[1] - pivot[1],\n p.pos[2] + r[2] - pivot[2],\n ];\n };\n const n = rotate(fr.n, p.rot);\n const base = a.pos.length / 3;\n const [u0, v0, u1, v1] = uvRect(cell);\n // зеркальная копия — картинка отражается по горизонтали\n const [ua, ub] = p.flip ? [u1, u0] : [u0, u1];\n const corners: [number, number, number, number][] = [\n [0, 0, ua, v0],\n [1, 0, ub, v0],\n [1, 1, ub, v1],\n [0, 1, ua, v1],\n ];\n for (const [cu, cd, u, v] of corners) {\n a.pos.push(...at(cu, cd));\n a.nrm.push(...n);\n a.uv.push(u, v);\n }\n a.idx.push(base, base + 2, base + 1, base, base + 3, base + 2);\n }\n }\n const boneGeos: BoneGeo[] = bones.map((b) => {\n const a = acc.get(b.name)!;\n return {\n name: b.name,\n role: b.role,\n parent: b.parent,\n pivot: b.pivot,\n positions: Float32Array.from(a.pos),\n normals: Float32Array.from(a.nrm),\n uvs: Float32Array.from(a.uv),\n indices: Uint16Array.from(a.idx),\n };\n });\n\n // лицо: квад чуть перед передней гранью главной коробки кости лица\n let decal: FaceDecal | null = null;\n if (face && exprCells.length) {\n const own = spec.parts.filter((p) => p.bone === face.bone);\n const main = own.sort(\n (a, b) =>\n b.size[0] * b.size[1] * b.size[2] - a.size[0] * a.size[1] * a.size[2],\n )[0];\n const bone = bones.find((b) => b.name === face.bone);\n if (main && bone) {\n const [rx, ry, rw, rh] = face.rect;\n const left = main.pos[0] + main.size[0] / 2 - rx;\n const topY = main.pos[1] + main.size[1] / 2 - ry;\n const z = main.pos[2] - main.size[2] / 2 - 0.05;\n const pv = bone.pivot;\n const c = (x: number, y: number) => [x - pv[0], y - pv[1], z - pv[2]];\n const uv: FaceDecal[\"uv\"] = {};\n for (const [e, cell] of exprCells) uv[e] = uvRect(cell);\n decal = {\n bone: face.bone,\n corners: Float32Array.from([\n ...c(left, topY),\n ...c(left - rw, topY),\n ...c(left - rw, topY - rh),\n ...c(left, topY - rh),\n ]),\n uv,\n };\n }\n }\n\n // сокеты: точка шаблона сдвигается вместе с переопределённой точкой вращения кости\n const sockets = tpl.sockets\n .filter((s) => has(s.bone))\n .map((s) => {\n const tb = tpl.bones.find((x) => x.name === s.bone)!;\n return {\n name: s.name,\n bone: s.bone,\n at: [\n s.at[0] - tb.pivot[0],\n s.at[1] - tb.pivot[1],\n s.at[2] - tb.pivot[2],\n ] as V3,\n };\n });\n\n const height = Math.max(1, top);\n const fit =\n height > tpl.height.max\n ? tpl.height.max / height\n : height < tpl.height.min\n ? tpl.height.min / height\n : 1;\n return {\n hash: specHash(spec),\n rig: spec.rig,\n bones: boneGeos,\n atlas: { w: W, h: H, data },\n face: decal,\n sockets,\n height,\n scale: spec.scale * fit,\n // глаза — на той же доле роста, что у тела шаблона\n eye: (tpl.eye * height) / templateHeight(spec.rig),\n };\n}\n\n/** Рост тела шаблона по умолчанию, пиксели. */\nfunction templateHeight(rig: CharacterSpec[\"rig\"]): number {\n let top = 1;\n for (const p of RIGS[rig].defaultParts)\n top = Math.max(top, p.pos[1] + p.size[1] / 2);\n return top;\n}\n"
|
|
146
|
+
"content": "// Реестр обликов предметов в памяти: хэш → рецепт + готовая геометрия и иконка. Облики попадают\n// сюда из «Моих предметов», из сейва мира (save.skins), из сети (по хэшу) и из Мастерской. Стак\n// ссылается на облик хэшем в метке: ItemStack.tag.skin.\nimport * as THREE from \"three\";\nimport { specHash } from \"@idosgames/characters/voxel\";\nimport type { ItemStack } from \"../../types\";\nimport { ITEMS } from \"../../registry/Items\";\nimport {\n WEAR_SLOTS,\n type GripPose,\n type ItemSkinSpec,\n type WearSlot,\n} from \"./ItemSkinSpec\";\nimport { buildSkinMesh, type SkinMesh } from \"./skinModel\";\n\nexport interface LoadedSkin {\n hash: string;\n spec: ItemSkinSpec;\n data: SkinMesh;\n geometry: THREE.BufferGeometry;\n /** Иконка для слотов (в браузере). */\n iconURL: string;\n /** Та же модель с центром в нуле (выброшенный предмет крутится вокруг середины). */\n centered?: THREE.BufferGeometry;\n}\n\nconst SKINS = new Map<string, LoadedSkin>();\nconst listeners = new Set<(hash: string) => void>();\nlet material: THREE.MeshLambertMaterial | null = null;\n\n/** Общий материал обликов: цвета — в вершинах. */\nexport function skinMaterial(): THREE.MeshLambertMaterial {\n return (material ??= new THREE.MeshLambertMaterial({ vertexColors: true }));\n}\n\nfunction iconURLOf(icon: SkinMesh[\"icon\"]): string {\n if (typeof document === \"undefined\") return \"\";\n const c = document.createElement(\"canvas\");\n c.width = c.height = icon.side;\n const g = c.getContext(\"2d\");\n if (!g) return \"\";\n g.putImageData(new ImageData(icon.data, icon.side, icon.side), 0, 0);\n return c.toDataURL(\"image/png\");\n}\n\n/** Зарегистрировать облик (повторно — тот же объект). */\nexport function registerSkin(spec: ItemSkinSpec): LoadedSkin {\n const hash = specHash(spec);\n const have = SKINS.get(hash);\n if (have) return have;\n const data = buildSkinMesh(spec);\n const g = new THREE.BufferGeometry();\n g.setAttribute(\"position\", new THREE.BufferAttribute(data.positions, 3));\n g.setAttribute(\"normal\", new THREE.BufferAttribute(data.normals, 3));\n g.setAttribute(\"color\", new THREE.BufferAttribute(data.colors, 3));\n g.setIndex(new THREE.BufferAttribute(data.indices, 1));\n g.computeBoundingBox();\n g.computeBoundingSphere();\n g.userData.shared = true;\n const s: LoadedSkin = {\n hash,\n spec,\n data,\n geometry: g,\n iconURL: iconURLOf(data.icon),\n };\n SKINS.set(hash, s);\n for (const fn of listeners) fn(hash);\n return s;\n}\n\nexport function skinOf(hash: unknown): LoadedSkin | undefined {\n return typeof hash === \"string\" ? SKINS.get(hash) : undefined;\n}\n\n/** Облик стака (метка tag.skin), если он загружен и подходит к предмету. */\nexport function stackSkin(\n stack: ItemStack | null | undefined,\n): LoadedSkin | undefined {\n const s = skinOf(stack?.tag?.skin);\n return s && s.spec.base === stack!.item ? s : undefined;\n}\n\n/** Хэш облика стака (даже если сам облик ещё не загружен — для запроса по сети). */\nexport function stackSkinHash(\n stack: ItemStack | null | undefined,\n): string | null {\n const h = stack?.tag?.skin;\n return typeof h === \"string\" ? h : null;\n}\n\n/** Иконка стака: облик или иконка предмета. */\nexport function stackIconURL(stack: ItemStack | null | undefined): string {\n if (!stack) return \"\";\n return stackSkin(stack)?.iconURL || ITEMS[stack.item]?.iconURL || \"\";\n}\n\n/** Имя стака с обликом («Клинок дракона»), иначе null. */\nexport function stackSkinName(\n stack: ItemStack | null | undefined,\n): string | null {\n return stackSkin(stack)?.spec.name ?? null;\n}\n\n/** Облики по хэшам (для сейва и сети). */\nexport function skinSpecs(\n hashes: Iterable<string>,\n): Record<string, ItemSkinSpec> {\n const out: Record<string, ItemSkinSpec> = {};\n for (const h of hashes) {\n const s = SKINS.get(h);\n if (s) out[h] = s.spec;\n }\n return out;\n}\n\n/** Все загруженные облики. */\nexport function allSkins(): LoadedSkin[] {\n return [...SKINS.values()];\n}\n\n/** Подписка: облик загрузился (перерисовать руки, слоты). Возвращает отписку. */\nexport function onSkinLoaded(fn: (hash: string) => void): () => void {\n listeners.add(fn);\n return () => listeners.delete(fn);\n}\n\nconst PX = 1 / 16;\n\n/** Поворот модели в кулаке: модель построена стоя (остриё вверх, лицом в −z). */\nconst GRIP: Record<GripPose, [number, number, number]> = {\n one_hand: [-0.97, Math.PI / 2, 0],\n two_hand: [-0.45, Math.PI / 2, 0],\n staff: [-0.15, Math.PI / 2, 0],\n bow: [-0.3, Math.PI / 2, 0],\n shield: [0, 0, 0],\n};\n\n/** Модель облика: в руке персонажа (от третьего лица), на теле, у выброшенного предмета. */\nexport function skinObject(\n skin: LoadedSkin,\n where: \"hand\" | \"wear\" | \"drop\",\n): THREE.Mesh {\n const m = new THREE.Mesh(skin.geometry, skinMaterial());\n m.castShadow = true;\n if (where === \"hand\") {\n m.scale.setScalar(PX);\n m.rotation.set(...GRIP[skin.spec.grip.pose]);\n } else if (where === \"wear\") m.scale.setScalar(PX);\n return m;\n}\n\n/** Выброшенный предмет с обликом: модель длиной ~полблока, вращается вокруг середины. */\nexport function dropMesh(skin: LoadedSkin, mesh: THREE.Mesh): void {\n if (!skin.centered) {\n const g = skin.geometry.clone();\n const c = new THREE.Vector3();\n g.boundingBox!.getCenter(c);\n g.translate(-c.x, -c.y, -c.z);\n g.userData.shared = true;\n skin.centered = g;\n }\n mesh.geometry = skin.centered;\n mesh.material = skinMaterial();\n mesh.scale.setScalar(0.55 / Math.max(...skin.spec.size));\n}\n\n/** Модель в руке от первого лица (player/ArmView): чуть крупнее и под углом к камере. */\nexport function firstPersonSkin(skin: LoadedSkin): THREE.Mesh {\n const m = new THREE.Mesh(skin.geometry, skinMaterial());\n m.scale.setScalar(PX * 0.9);\n const pose = skin.spec.grip.pose;\n if (pose === \"shield\") m.rotation.set(0, 0.4, 0);\n else m.rotation.set(-0.35, -Math.PI / 2 + 0.25, 0.2);\n m.position.set(0, -0.12, 0);\n return m;\n}\n\n/** Сокет тела, к которому крепится надеваемое этого слота. */\nexport function wearSocket(slot: WearSlot): (typeof WEAR_SLOTS)[WearSlot] {\n return WEAR_SLOTS[slot];\n}\n"
|
|
194
147
|
},
|
|
195
148
|
{
|
|
196
149
|
"path": "character/light.ts",
|
|
@@ -198,43 +151,7 @@
|
|
|
198
151
|
},
|
|
199
152
|
{
|
|
200
153
|
"path": "character/Looks.ts",
|
|
201
|
-
"content": "// Облик своего игрока — ОДИН на модуль: им пользуются и лобби (персонаж на подиуме, вкладки\n// «Персонаж» / «Предметы» / «Шкафчик»), и игра (тело в мире, рука, кукла инвентаря). Игры может ещё\n// не быть (игрок в лобби), поэтому облик живёт отдельно от неё: выбранный персонаж и надетое — в\n// localStorage, «Мои персонажи» и «Мои предметы» — в IndexedDB (systems/CharacterStore). Кто\n// показывает персонажа, подписывается на изменения.\nimport { registerSkin } from \"./items/skins\";\nimport type { ItemSkinSpec, WearSlot } from \"./items/ItemSkinSpec\";\nimport {
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
"path": "character/nameplate.ts",
|
|
205
|
-
"content": "// Табличка с именем над головой: канвас-текстура на спрайте (была в net/RemotePlayer).\nimport * as THREE from \"three\";\n\nexport function nameplate(text: string): THREE.Sprite {\n const c = document.createElement(\"canvas\");\n c.width = 256;\n c.height = 48;\n const g = c.getContext(\"2d\");\n if (g) {\n g.fillStyle = \"rgba(0,0,0,0.45)\";\n g.fillRect(0, 0, 256, 48);\n g.font = \"bold 28px sans-serif\";\n g.fillStyle = \"#fff\";\n g.textAlign = \"center\";\n g.textBaseline = \"middle\";\n g.fillText(text.slice(0, 18), 128, 25);\n }\n const tex = new THREE.CanvasTexture(c);\n const s = new THREE.Sprite(\n new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true }),\n );\n s.scale.set(1.6, 0.3, 1);\n s.renderOrder = 10;\n return s;\n}\n\nexport function disposeNameplate(s: THREE.Sprite): void {\n s.removeFromParent();\n s.material.map?.dispose();\n s.material.dispose();\n}\n"
|
|
206
|
-
},
|
|
207
|
-
{
|
|
208
|
-
"path": "character/npcLook.ts",
|
|
209
|
-
"content": "// Облик NPC без рецепта (NPC из рецепта мира — WorldSpec.npcs): наряд из набора частей\n// (character/defaultCharacter, humanSource) в приглушённых цветах, выбранный по имени.\n// Детерминированно: одно имя — один облик на любом устройстве и у любого игрока сети. Роль и\n// характер (persona) ложатся в описание и метки — их прочитает будущий ИИ-NPC.\nimport { hashString, mulberry32 } from \"../core/Noise\";\nimport { parseCharacterSpec, type CharacterSpec } from \"./CharacterSpec\";\nimport {\n HUMAN_BOTTOMS,\n HUMAN_SHOES,\n HUMAN_TOPS,\n defaultCharacter,\n humanSource,\n} from \"./defaultCharacter\";\n\n// Приглушённая «пастель» референса владельца: шалфей, крем, беж, пыльно-голубой, табак.\nconst BOOTS = [\"#b08a62\", \"#8a6a4a\", \"#c8a878\", \"#9a9a92\", \"#7a5c44\"];\nconst SKINS = [\n \"#f1c9a5\",\n \"#e6b892\",\n \"#d9a982\",\n \"#c08e68\",\n \"#a0704c\",\n \"#7a5238\",\n];\nconst HAIR = [\n \"#6b4a2f\",\n \"#4a3424\",\n \"#8a5a3a\",\n \"#c27a48\",\n \"#d8b878\",\n \"#2e2622\",\n \"#a07850\",\n \"#9a9690\",\n];\nconst CLOTH = [\n \"#a7bea9\",\n \"#e6e0cc\",\n \"#d9c9a3\",\n \"#b9a083\",\n \"#a9b8c4\",\n \"#c8b4a0\",\n \"#9fb0a0\",\n \"#d8d2c4\",\n \"#b98f74\",\n \"#8fa3b5\",\n];\nconst PANTS = [\n \"#8c9ca6\",\n \"#a9b8b4\",\n \"#8a8e94\",\n \"#9fb3ad\",\n \"#7d8a8f\",\n \"#b0a58f\",\n];\nconst HATS = [\"#d9c9a3\", \"#c8b48a\", \"#a08a6a\", \"#e6e0cc\"];\n\nexport function npcCharacter(\n name: string,\n role = \"\",\n persona = \"\",\n): CharacterSpec {\n const rnd = mulberry32(hashString(\"npc:\" + name + \"|\" + role));\n const pick = <T>(a: readonly T[]): T => a[Math.floor(rnd() * a.length)]!;\n // наряд из набора частей (как люди на референсе: рубашка, футболка, свитер, шорты, шляпа…)\n const src = humanSource({\n top: pick(HUMAN_TOPS),\n bottom: pick(HUMAN_BOTTOMS),\n shoes: pick(HUMAN_SHOES),\n hair: pick([\"short\", \"short\", \"long\", \"curly\"] as const),\n hat: rnd() < 0.2,\n colors: {\n skin: pick(SKINS),\n hair: pick(HAIR),\n shirt: pick(CLOTH),\n pants: pick(PANTS),\n boots: pick(BOOTS),\n hat: pick(HATS),\n },\n }) as {\n name: string;\n description: string;\n style: { walk: string; idle: string; energy: number };\n tags: string[];\n };\n src.name = name.slice(0, 40);\n src.description = [role, persona].filter(Boolean).join(\" — \").slice(0, 300);\n src.style = {\n walk: pick([\"normal\", \"normal\", \"bouncy\", \"heavy\", \"sneaky\"]),\n idle: pick([\"calm\", \"calm\", \"fidgety\", \"proud\"]),\n energy: Math.round(rnd() * 10) / 10,\n };\n src.tags = [\"npc\", ...(role ? [role.slice(0, 24)] : [])];\n const r = parseCharacterSpec(src);\n return r.ok ? r.spec : defaultCharacter();\n}\n"
|
|
210
|
-
},
|
|
211
|
-
{
|
|
212
|
-
"path": "character/pixelArt.ts",
|
|
213
|
-
"content": "// Маленький «холст» для пиксель-арта рецептов, которые пишет сам код (персонаж по умолчанию, NPC):\n// прямоугольники и точки символами палитры → строки PixelTex. Рецепт остаётся данными, а\n// рисовать 16×24 построчно руками не приходится.\nimport type { PixelTex } from \"./CharacterSpec\";\n\nexport class Art {\n private readonly g: string[][];\n\n constructor(\n readonly w: number,\n readonly h: number,\n fill = \".\",\n ) {\n this.g = Array.from({ length: h }, () => new Array<string>(w).fill(fill));\n }\n\n px(x: number, y: number, c: string): this {\n if (x >= 0 && y >= 0 && x < this.w && y < this.h) this.g[y]![x] = c;\n return this;\n }\n\n rect(x: number, y: number, w: number, h: number, c: string): this {\n for (let j = y; j < y + h; j++)\n for (let i = x; i < x + w; i++) this.px(i, j, c);\n return this;\n }\n\n /** Горизонтальная линия от x0 до x1 включительно. */\n row(y: number, x0: number, x1: number, c: string): this {\n return this.rect(x0, y, x1 - x0 + 1, 1, c);\n }\n\n /** Вертикальная линия от y0 до y1 включительно. */\n col(x: number, y0: number, y1: number, c: string): this {\n return this.rect(x, y0, 1, y1 - y0 + 1, c);\n }\n\n /** Каждая клетка, где f(x, y) — правда, получает цвет c (штриховка, пряди, блики). */\n where(f: (x: number, y: number) => boolean, c: string): this {\n for (let y = 0; y < this.h; y++)\n for (let x = 0; x < this.w; x++) if (f(x, y)) this.px(x, y, c);\n return this;\n }\n\n /** Зеркальная копия по горизонтали (левая и правая грань одной коробки). */\n flipped(): Art {\n const a = new Art(this.w, this.h);\n for (let y = 0; y < this.h; y++)\n for (let x = 0; x < this.w; x++) a.px(this.w - 1 - x, y, this.g[y]![x]!);\n return a;\n }\n\n tex(): PixelTex {\n return { w: this.w, h: this.h, rows: this.g.map((r) => r.join(\"\")) };\n }\n}\n"
|
|
214
|
-
},
|
|
215
|
-
{
|
|
216
|
-
"path": "character/rigs/blob.ts",
|
|
217
|
-
"content": "// Капля: слизни, духи, кексы, призраки — одно тело с лицом, движение прыжками и сплющиванием.\nimport type { RigTemplate } from \"./types\";\n\nexport const blob: RigTemplate = {\n id: \"blob\",\n label: \"Капля\",\n hint: \"slimes, ghosts, spirits, living objects — one body with a face, moves by hopping\",\n bones: [{ name: \"body\", role: \"body\", parent: null, pivot: [0, 0, 0] }],\n sockets: [\n { name: \"headTop\", bone: \"body\", at: [0, 12, 0] },\n { name: \"face\", bone: \"body\", at: [0, 7, -6] },\n { name: \"back\", bone: \"body\", at: [0, 6, 6] },\n { name: \"waist\", bone: \"body\", at: [0, 3, 0] },\n { name: \"shoulders\", bone: \"body\", at: [0, 10, 0] },\n { name: \"handR\", bone: \"body\", at: [6, 5, -2] },\n { name: \"handL\", bone: \"body\", at: [-6, 5, -2] },\n ],\n hitbox: { w: 0.8, h: 0.8 },\n eye: 8,\n height: { min: 4, max: 40 },\n faceBone: \"body\",\n defaultParts: [\n { bone: \"body\", size: [12, 12, 12], pos: [0, 6, 0], color: \"t\" },\n ],\n};\n"
|
|
218
|
-
},
|
|
219
|
-
{
|
|
220
|
-
"path": "character/rigs/flyer.ts",
|
|
221
|
-
"content": "// Летун: птицы, драконы, феи, летучие мыши — туловище, голова, два крыла, лапки и хвост.\nimport type { RigTemplate } from \"./types\";\n\nexport const flyer: RigTemplate = {\n id: \"flyer\",\n label: \"Крылатый\",\n hint: \"birds, bats, dragons, fairies, insects — creatures whose wings matter\",\n bones: [\n { name: \"body\", role: \"body\", parent: null, pivot: [0, 6, 0] },\n { name: \"head\", role: \"head\", parent: \"body\", pivot: [0, 11, -3] },\n { name: \"wingL\", role: \"wingL\", parent: \"body\", pivot: [-3, 10, 0] },\n { name: \"wingR\", role: \"wingR\", parent: \"body\", pivot: [3, 10, 0] },\n { name: \"legL\", role: \"legL\", parent: null, pivot: [-1.5, 4, 0] },\n { name: \"legR\", role: \"legR\", parent: null, pivot: [1.5, 4, 0] },\n { name: \"tail\", role: \"tail\", parent: \"body\", pivot: [0, 8, 4] },\n ],\n sockets: [\n { name: \"headTop\", bone: \"head\", at: [0, 16, -4] },\n { name: \"face\", bone: \"head\", at: [0, 13, -7] },\n { name: \"back\", bone: \"body\", at: [0, 11, 1] },\n { name: \"waist\", bone: \"body\", at: [0, 6, 0] },\n { name: \"shoulders\", bone: \"body\", at: [0, 10, -2] },\n { name: \"handR\", bone: \"legR\", at: [1.5, 0, -1] },\n { name: \"handL\", bone: \"legL\", at: [-1.5, 0, -1] },\n ],\n hitbox: { w: 0.6, h: 0.9 },\n eye: 14,\n height: { min: 6, max: 40 },\n faceBone: \"head\",\n defaultParts: [\n { bone: \"body\", size: [6, 6, 8], pos: [0, 8, 0], color: \"t\" },\n { bone: \"head\", size: [5, 5, 5], pos: [0, 13, -4], color: \"s\" },\n { bone: \"wingL\", size: [8, 1, 6], pos: [-7, 10, 0], color: \"t\" },\n { bone: \"wingR\", size: [8, 1, 6], pos: [7, 10, 0], color: \"t\" },\n { bone: \"legL\", size: [1, 4, 1], pos: [-1.5, 2, 0], color: \"p\" },\n { bone: \"legR\", size: [1, 4, 1], pos: [1.5, 2, 0], color: \"p\" },\n { bone: \"tail\", size: [4, 1, 4], pos: [0, 8, 6], color: \"t\" },\n ],\n};\n"
|
|
222
|
-
},
|
|
223
|
-
{
|
|
224
|
-
"path": "character/rigs/humanoid.ts",
|
|
225
|
-
"content": "// Человек: пропорции игрока Minecraft (рост 32 px = 2 блока). Ноги и тело вращаются от бедра,\n// руки — от плеча, голова — от шеи; руки и голова — дети тела (наклон в присяде уносит их с собой).\nimport type { RigTemplate } from \"./types\";\n\nexport const humanoid: RigTemplate = {\n id: \"humanoid\",\n label: \"Человек\",\n hint: \"people, humanoids, robots, elves, zombies — anything on two legs with two arms\",\n bones: [\n { name: \"body\", role: \"body\", parent: null, pivot: [0, 12, 0] },\n { name: \"head\", role: \"head\", parent: \"body\", pivot: [0, 24, 0] },\n { name: \"armL\", role: \"armL\", parent: \"body\", pivot: [-6, 22, 0] },\n { name: \"armR\", role: \"armR\", parent: \"body\", pivot: [6, 22, 0] },\n { name: \"legL\", role: \"legL\", parent: null, pivot: [-2, 12, 0] },\n { name: \"legR\", role: \"legR\", parent: null, pivot: [2, 12, 0] },\n ],\n sockets: [\n { name: \"handR\", bone: \"armR\", at: [6, 11, -1] },\n { name: \"handL\", bone: \"armL\", at: [-6, 11, -1] },\n { name: \"headTop\", bone: \"head\", at: [0, 32, 0] },\n { name: \"face\", bone: \"head\", at: [0, 28, -4] },\n { name: \"back\", bone: \"body\", at: [0, 18, 2] },\n { name: \"waist\", bone: \"body\", at: [0, 12, 0] },\n { name: \"shoulders\", bone: \"body\", at: [0, 24, 0] },\n ],\n hitbox: { w: 0.6, h: 1.8 },\n eye: 27,\n height: { min: 16, max: 48 },\n faceBone: \"head\",\n defaultParts: [\n { bone: \"head\", size: [8, 8, 8], pos: [0, 28, 0], color: \"s\" },\n { bone: \"body\", size: [8, 12, 4], pos: [0, 18, 0], color: \"t\" },\n { bone: \"armL\", size: [4, 12, 4], pos: [-6, 18, 0], color: \"s\" },\n { bone: \"armR\", size: [4, 12, 4], pos: [6, 18, 0], color: \"s\" },\n { bone: \"legL\", size: [4, 12, 4], pos: [-2, 6, 0], color: \"p\" },\n { bone: \"legR\", size: [4, 12, 4], pos: [2, 6, 0], color: \"p\" },\n ],\n};\n"
|
|
226
|
-
},
|
|
227
|
-
{
|
|
228
|
-
"path": "character/rigs/index.ts",
|
|
229
|
-
"content": "// Реестр шаблонов скелета. Новый шаблон: файл рядом + строка здесь + id в RIG_IDS (types.ts).\nimport { blob } from \"./blob\";\nimport { flyer } from \"./flyer\";\nimport { humanoid } from \"./humanoid\";\nimport { quadruped } from \"./quadruped\";\nimport type { RigId, RigTemplate } from \"./types\";\n\nexport const RIGS: Record<RigId, RigTemplate> = {\n humanoid,\n quadruped,\n flyer,\n blob,\n};\n\nexport * from \"./types\";\n"
|
|
230
|
-
},
|
|
231
|
-
{
|
|
232
|
-
"path": "character/rigs/quadruped.ts",
|
|
233
|
-
"content": "// Четвероногое: туловище вдоль z, голова спереди (−z), хвост сзади. Шаг — диагональными парами.\nimport type { RigTemplate } from \"./types\";\n\nexport const quadruped: RigTemplate = {\n id: \"quadruped\",\n label: \"Четвероногое\",\n hint: \"dogs, cats, horses, wolves, bears, lizards — any animal on four legs\",\n bones: [\n { name: \"body\", role: \"body\", parent: null, pivot: [0, 12, 0] },\n { name: \"head\", role: \"head\", parent: \"body\", pivot: [0, 14, -8] },\n { name: \"legFL\", role: \"legFL\", parent: null, pivot: [-3, 8, -6] },\n { name: \"legFR\", role: \"legFR\", parent: null, pivot: [3, 8, -6] },\n { name: \"legBL\", role: \"legBL\", parent: null, pivot: [-3, 8, 6] },\n { name: \"legBR\", role: \"legBR\", parent: null, pivot: [3, 8, 6] },\n { name: \"tail\", role: \"tail\", parent: \"body\", pivot: [0, 14, 8] },\n ],\n sockets: [\n { name: \"headTop\", bone: \"head\", at: [0, 20, -12] },\n { name: \"face\", bone: \"head\", at: [0, 16, -16] },\n { name: \"back\", bone: \"body\", at: [0, 16, 0] },\n { name: \"waist\", bone: \"body\", at: [0, 12, 4] },\n { name: \"shoulders\", bone: \"body\", at: [0, 16, -6] },\n { name: \"handR\", bone: \"head\", at: [2, 13, -16] },\n { name: \"handL\", bone: \"head\", at: [-2, 13, -16] },\n ],\n hitbox: { w: 0.9, h: 1.0 },\n eye: 17,\n height: { min: 8, max: 40 },\n faceBone: \"head\",\n defaultParts: [\n { bone: \"body\", size: [8, 8, 16], pos: [0, 12, 0], color: \"t\" },\n { bone: \"head\", size: [8, 8, 8], pos: [0, 16, -12], color: \"s\" },\n { bone: \"legFL\", size: [3, 8, 3], pos: [-3, 4, -6], color: \"p\" },\n { bone: \"legFR\", size: [3, 8, 3], pos: [3, 4, -6], color: \"p\" },\n { bone: \"legBL\", size: [3, 8, 3], pos: [-3, 4, 6], color: \"p\" },\n { bone: \"legBR\", size: [3, 8, 3], pos: [3, 4, 6], color: \"p\" },\n { bone: \"tail\", size: [2, 2, 8], pos: [0, 14, 12], color: \"t\" },\n ],\n};\n"
|
|
234
|
-
},
|
|
235
|
-
{
|
|
236
|
-
"path": "character/rigs/types.ts",
|
|
237
|
-
"content": "// Шаблон скелета персонажа — ДАННЫЕ, а не код: кости с ролями, сокеты для предметов и одежды,\n// хитбокс, пределы роста и тело по умолчанию. Новый шаблон — новый файл рядом и строка в index.ts.\n//\n// Координаты — в пикселях модели (16 px = 1 блок): x — вправо от персонажа (+x — его правая\n// рука), y — вверх (y = 0 — ступни), z — назад (лицо смотрит в −z, как у мобов).\nimport type { CharPart } from \"../CharacterSpec\";\n\nexport type V3 = [number, number, number];\n\n/**\n * Роль кости — то, к чему обращаются анимации. Анимация «шаг» двигает legL/legR у любого\n * персонажа, в каком бы файле ни был описан его скелет; свои кости персонажа (хвост, уши, плащ)\n * получают вторичную анимацию по роли.\n */\nexport const BONE_ROLES = [\n \"root\",\n \"body\",\n \"head\",\n \"armL\",\n \"armR\",\n \"legL\",\n \"legR\",\n \"legFL\",\n \"legFR\",\n \"legBL\",\n \"legBR\",\n \"tail\",\n \"wingL\",\n \"wingR\",\n \"ear\",\n \"hair\",\n \"cape\",\n \"antenna\",\n \"static\",\n] as const;\nexport type BoneRole = (typeof BONE_ROLES)[number];\n\n/** Роли, которые можно дать СВОЕЙ кости рецепта (основные задаёт шаблон). */\nexport const EXTRA_BONE_ROLES = [\n \"tail\",\n \"wingL\",\n \"wingR\",\n \"ear\",\n \"hair\",\n \"cape\",\n \"antenna\",\n \"static\",\n] as const satisfies readonly BoneRole[];\nexport type ExtraBoneRole = (typeof EXTRA_BONE_ROLES)[number];\n\n/**\n * Сокет — точка, к которой крепится предмет или одежда: рука, макушка, спина… Пояс, плечи и лицо\n * — для надеваемого (character/items).\n */\nexport const SOCKETS = [\n \"handR\",\n \"handL\",\n \"headTop\",\n \"face\",\n \"back\",\n \"waist\",\n \"shoulders\",\n] as const;\nexport type SocketName = (typeof SOCKETS)[number];\n\nexport interface RigBone {\n name: string;\n role: BoneRole;\n /** Родитель; null — корень модели (ступни). */\n parent: string | null;\n /** Точка вращения в пикселях модели. */\n pivot: V3;\n}\n\nexport interface RigSocket {\n name: SocketName;\n bone: string;\n /** Где в пикселях модели (поворачивается вместе с костью). */\n at: V3;\n}\n\nexport const RIG_IDS = [\"humanoid\", \"quadruped\", \"flyer\", \"blob\"] as const;\nexport type RigId = (typeof RIG_IDS)[number];\n\nexport interface RigTemplate {\n id: RigId;\n /** Подпись в студии. */\n label: string;\n /** Подсказка модели: кто подходит под шаблон. */\n hint: string;\n bones: RigBone[];\n sockets: RigSocket[];\n /** Хитбокс NPC/моба в блоках (игрок — всегда 0.6×1.8). */\n hitbox: { w: number; h: number };\n /** Высота глаз в пикселях — куда смотрит камера от третьего лица и откуда «говорит» NPC. */\n eye: number;\n /** Пределы роста модели в пикселях (рецепт за ними масштабируется). */\n height: { min: number; max: number };\n /** Тело по умолчанию: персонаж без деталей и путь без ИИ. */\n defaultParts: CharPart[];\n /** Кость с лицом. */\n faceBone: string;\n}\n"
|
|
154
|
+
"content": "// Облик своего игрока — ОДИН на модуль: им пользуются и лобби (персонаж на подиуме, вкладки\n// «Персонаж» / «Предметы» / «Шкафчик»), и игра (тело в мире, рука, кукла инвентаря). Игры может ещё\n// не быть (игрок в лобби), поэтому облик живёт отдельно от неё: выбранный персонаж и надетое — в\n// localStorage, «Мои персонажи» и «Мои предметы» — в IndexedDB (systems/CharacterStore). Кто\n// показывает персонажа, подписывается на изменения.\nimport { registerSkin } from \"./items/skins\";\nimport type { ItemSkinSpec, WearSlot } from \"./items/ItemSkinSpec\";\nimport {\n registerCharacter,\n specHash,\n type CharacterSpec,\n defaultCharacter,\n} from \"@idosgames/characters/voxel\";\nimport {\n CharacterStore,\n loadLookPrefs,\n saveLookPrefs,\n} from \"../systems/CharacterStore\";\n\nexport class Looks {\n spec: CharacterSpec = defaultCharacter();\n /** Надетое: слот → хэш облика. */\n outfit: Partial<Record<WearSlot, string>> = loadLookPrefs().outfit;\n /** Выбранный персонаж и облики «Моих предметов» загружены (до этого — «Путник»). */\n readonly ready: Promise<void>;\n private storeP: Promise<CharacterStore | null> | null = null;\n private readonly listeners = new Set<() => void>();\n\n constructor() {\n registerCharacter(this.spec);\n this.ready = this.restore();\n }\n\n /** «Мои персонажи» и «Мои предметы» (null — браузер не даёт IndexedDB). */\n store(): Promise<CharacterStore | null> {\n return (this.storeP ??= CharacterStore.open());\n }\n\n subscribe(fn: () => void): () => void {\n this.listeners.add(fn);\n return () => {\n this.listeners.delete(fn);\n };\n }\n\n private changed(): void {\n for (const fn of [...this.listeners]) fn();\n }\n\n private async restore(): Promise<void> {\n const store = await this.store();\n if (!store) return;\n try {\n for (const it of await store.items()) registerSkin(it.spec);\n const id = loadLookPrefs().character;\n const c = id ? await store.getCharacter(id) : undefined;\n if (c) this.setSpec(c.spec);\n else this.changed(); // облики предметов зарегистрированы — надетое можно рисовать\n } catch {\n /* битая запись не мешает играть «Путником» */\n }\n }\n\n /** Показать другого персонажа (без сохранения выбора). */\n setSpec(spec: CharacterSpec): void {\n this.spec = spec;\n registerCharacter(spec);\n this.changed();\n }\n\n /** Играть этим персонажем: сменить, сохранить в «Мои» и запомнить выбор. */\n async useCharacter(spec: CharacterSpec): Promise<void> {\n this.setSpec(spec);\n await this.saveCharacter(spec);\n saveLookPrefs({ ...loadLookPrefs(), character: specHash(spec) });\n }\n\n /** Надеть или снять косметику (null — снять). Запоминается между запусками. */\n wear(slot: WearSlot, hash: string | null): void {\n if (hash) this.outfit[slot] = hash;\n else delete this.outfit[slot];\n this.outfit = { ...this.outfit };\n saveLookPrefs({ ...loadLookPrefs(), outfit: this.outfit });\n this.changed();\n }\n\n /** Сохранить в «Мои персонажи»; false — хранилища нет. */\n async saveCharacter(spec: CharacterSpec): Promise<boolean> {\n const store = await this.store();\n if (!store) return false;\n await store.putCharacter({\n id: specHash(spec),\n name: spec.name,\n updatedAt: Date.now(),\n spec,\n });\n this.changed();\n return true;\n }\n\n /** Сохранить облик в «Мои предметы» (и зарегистрировать); false — хранилища нет. */\n async saveItem(spec: ItemSkinSpec): Promise<boolean> {\n const skin = registerSkin(spec);\n const store = await this.store();\n if (!store) return false;\n await store.putItem({\n id: skin.hash,\n name: spec.name,\n updatedAt: Date.now(),\n spec,\n });\n this.changed();\n return true;\n }\n\n async deleteCharacter(id: string): Promise<void> {\n await (await this.store())?.deleteCharacter(id);\n this.changed();\n }\n\n async deleteItem(id: string): Promise<void> {\n await (await this.store())?.deleteItem(id);\n this.changed();\n }\n}\n"
|
|
238
155
|
},
|
|
239
156
|
{
|
|
240
157
|
"path": "config.ts",
|
|
@@ -254,7 +171,7 @@
|
|
|
254
171
|
},
|
|
255
172
|
{
|
|
256
173
|
"path": "core/Noise.ts",
|
|
257
|
-
"content": "// Сидированный PRNG (mulberry32) и 2D
|
|
174
|
+
"content": "// Сидированный PRNG (mulberry32) и 2D-симплекс-шум. PRNG и хэш строки — общие с персонажами\n// (@idosgames/characters): одинаковый сид → одинаковый мир и одинаковый персонаж.\n\nimport { hashString, mulberry32 } from \"@idosgames/characters\";\n\nexport { hashString, mulberry32 };\n\nconst GRAD2 = [\n [1, 1],\n [-1, 1],\n [1, -1],\n [-1, -1],\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n];\nconst F2 = 0.5 * (Math.sqrt(3) - 1);\nconst G2 = (3 - Math.sqrt(3)) / 6;\n\nexport class Simplex2 {\n perm: Uint8Array;\n constructor(seed: number) {\n const rand = mulberry32(seed);\n this.perm = new Uint8Array(512);\n const p = new Uint8Array(256);\n for (let i = 0; i < 256; i++) p[i] = i;\n for (let i = 255; i > 0; i--) {\n const j = (rand() * (i + 1)) | 0;\n const pi = p[i]!,\n pj = p[j]!;\n p[i] = pj;\n p[j] = pi;\n }\n for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255]!;\n }\n\n /** Значение шума в диапазоне примерно [-1, 1]. */\n noise(xin: number, yin: number): number {\n const perm = this.perm;\n const s = (xin + yin) * F2;\n const i = Math.floor(xin + s),\n j = Math.floor(yin + s);\n const t = (i + j) * G2;\n const x0 = xin - (i - t),\n y0 = yin - (j - t);\n const i1 = x0 > y0 ? 1 : 0,\n j1 = x0 > y0 ? 0 : 1;\n const x1 = x0 - i1 + G2,\n y1 = y0 - j1 + G2;\n const x2 = x0 - 1 + 2 * G2,\n y2 = y0 - 1 + 2 * G2;\n const ii = i & 255,\n jj = j & 255;\n let n = 0;\n let t0 = 0.5 - x0 * x0 - y0 * y0;\n if (t0 > 0) {\n const g = GRAD2[perm[ii + perm[jj]!]! & 7]!;\n t0 *= t0;\n n += t0 * t0 * (g[0]! * x0 + g[1]! * y0);\n }\n let t1 = 0.5 - x1 * x1 - y1 * y1;\n if (t1 > 0) {\n const g = GRAD2[perm[ii + i1 + perm[jj + j1]!]! & 7]!;\n t1 *= t1;\n n += t1 * t1 * (g[0]! * x1 + g[1]! * y1);\n }\n let t2 = 0.5 - x2 * x2 - y2 * y2;\n if (t2 > 0) {\n const g = GRAD2[perm[ii + 1 + perm[jj + 1]!]! & 7]!;\n t2 *= t2;\n n += t2 * t2 * (g[0]! * x2 + g[1]! * y2);\n }\n return 70 * n;\n }\n\n /** Фрактальный шум (несколько октав), диапазон примерно [-1, 1]. */\n fbm(x: number, y: number, octaves = 4, lacunarity = 2, gain = 0.5): number {\n let amp = 1,\n freq = 1,\n sum = 0,\n norm = 0;\n for (let o = 0; o < octaves; o++) {\n sum += amp * this.noise(x * freq, y * freq);\n norm += amp;\n amp *= gain;\n freq *= lacunarity;\n }\n return sum / norm;\n }\n}\n\nconst GRAD3 = [\n [1, 1, 0],\n [-1, 1, 0],\n [1, -1, 0],\n [-1, -1, 0],\n [1, 0, 1],\n [-1, 0, 1],\n [1, 0, -1],\n [-1, 0, -1],\n [0, 1, 1],\n [0, -1, 1],\n [0, 1, -1],\n [0, -1, -1],\n];\nconst F3 = 1 / 3;\nconst G3 = 1 / 6;\n\n/** 3D-симплекс-шум (пещеры, пласты). Значение примерно в [-1, 1]. */\nexport class Simplex3 {\n private readonly perm: Uint8Array;\n private readonly pm12: Uint8Array;\n\n constructor(seed: number) {\n const rand = mulberry32(seed);\n const p = new Uint8Array(256);\n for (let i = 0; i < 256; i++) p[i] = i;\n for (let i = 255; i > 0; i--) {\n const j = (rand() * (i + 1)) | 0;\n const pi = p[i]!,\n pj = p[j]!;\n p[i] = pj;\n p[j] = pi;\n }\n this.perm = new Uint8Array(512);\n this.pm12 = new Uint8Array(512);\n for (let i = 0; i < 512; i++) {\n this.perm[i] = p[i & 255]!;\n this.pm12[i] = this.perm[i]! % 12;\n }\n }\n\n noise(xin: number, yin: number, zin: number): number {\n const p = this.perm,\n pm = this.pm12;\n const s = (xin + yin + zin) * F3;\n const i = Math.floor(xin + s),\n j = Math.floor(yin + s),\n k = Math.floor(zin + s);\n const t = (i + j + k) * G3;\n const x0 = xin - (i - t),\n y0 = yin - (j - t),\n z0 = zin - (k - t);\n let i1: number, j1: number, k1: number, i2: number, j2: number, k2: number;\n if (x0 >= y0) {\n if (y0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 1, 0];\n else if (x0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 0, 1];\n else [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 1, 0, 1];\n } else if (y0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 0, 1, 1];\n else if (x0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 0, 1, 1];\n else [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 1, 1, 0];\n const x1 = x0 - i1 + G3,\n y1 = y0 - j1 + G3,\n z1 = z0 - k1 + G3;\n const x2 = x0 - i2 + 2 * G3,\n y2 = y0 - j2 + 2 * G3,\n z2 = z0 - k2 + 2 * G3;\n const x3 = x0 - 1 + 3 * G3,\n y3 = y0 - 1 + 3 * G3,\n z3 = z0 - 1 + 3 * G3;\n const ii = i & 255,\n jj = j & 255,\n kk = k & 255;\n const corner = (g: number, x: number, y: number, z: number): number => {\n let tt = 0.6 - x * x - y * y - z * z;\n if (tt < 0) return 0;\n const gr = GRAD3[g]!;\n tt *= tt;\n return tt * tt * (gr[0]! * x + gr[1]! * y + gr[2]! * z);\n };\n const n =\n corner(pm[ii + p[jj + p[kk]!]!]!, x0, y0, z0) +\n corner(pm[ii + i1 + p[jj + j1 + p[kk + k1]!]!]!, x1, y1, z1) +\n corner(pm[ii + i2 + p[jj + j2 + p[kk + k2]!]!]!, x2, y2, z2) +\n corner(pm[ii + 1 + p[jj + 1 + p[kk + 1]!]!]!, x3, y3, z3);\n return 32 * n;\n }\n}\n"
|
|
258
175
|
},
|
|
259
176
|
{
|
|
260
177
|
"path": "entities/AllayBrain.ts",
|
|
@@ -278,7 +195,7 @@
|
|
|
278
195
|
},
|
|
279
196
|
{
|
|
280
197
|
"path": "entities/characterMobs.ts",
|
|
281
|
-
"content": "// Свои мобы и NPC из рецепта персонажа (character/). Поведение — из существующих мозгов\n// (entities/MobBrain) по пресету; характеристики — из таблицы ниже, НЕ от ИИ: здоровье, урон и\n// скорость сбалансированы игрой, рецепт задаёт только вид и размер.\n//\n// Тип моба — \"c:<хэш рецепта>:<поведение>\"; определение заводится во время игры (MOBS), а рецепт едет\n// вместе с мобом в сейве и в сетевом снимке (поле look), так что мир с такими мобами открывается и\n// на другом устройстве, и у другого игрока.\nimport {\n parseCharacterSpec,\n specHash,\n type CharacterSpec,\n
|
|
198
|
+
"content": "// Свои мобы и NPC из рецепта персонажа (character/). Поведение — из существующих мозгов\n// (entities/MobBrain) по пресету; характеристики — из таблицы ниже, НЕ от ИИ: здоровье, урон и\n// скорость сбалансированы игрой, рецепт задаёт только вид и размер.\n//\n// Тип моба — \"c:<хэш рецепта>:<поведение>\"; определение заводится во время игры (MOBS), а рецепт едет\n// вместе с мобом в сейве и в сетевом снимке (поле look), так что мир с такими мобами открывается и\n// на другом устройстве, и у другого игрока.\nimport {\n parseCharacterSpec,\n specHash,\n type CharacterSpec,\n registerCharacter,\n characterOf,\n layoutCharacter,\n RIGS,\n} from \"@idosgames/characters/voxel\";\nimport { MOBS } from \"../registry/Mobs\";\nimport type { MobDef } from \"../types\";\n\nexport const CHAR_BEHAVIORS = [\n \"npc\",\n \"passive\",\n \"neutral\",\n \"hostile\",\n \"pet\",\n] as const;\nexport type CharBehavior = (typeof CHAR_BEHAVIORS)[number];\n\n/** Пресеты поведения: всё, кроме вида. */\nconst PRESETS: Record<CharBehavior, Partial<MobDef>> = {\n // житель: гуляет, не убегает далеко, не исчезает; позже — разговор с ИИ\n npc: { category: \"passive\", hp: 20, walkSpeed: 1.0, fleeSpeed: 2.2 },\n passive: { category: \"passive\", hp: 10, walkSpeed: 1.3, fleeSpeed: 3 },\n neutral: {\n category: \"neutral\",\n hp: 20,\n walkSpeed: 1.3,\n fleeSpeed: 2.6,\n chaseSpeed: 2.8,\n follow: 16,\n attack: { damage: 4, reach: 1.2, cooldown: 1 },\n },\n hostile: {\n category: \"hostile\",\n hp: 20,\n walkSpeed: 1.2,\n fleeSpeed: 2.3,\n chaseSpeed: 2.4,\n follow: 24,\n attack: { damage: 3, reach: 1.2, cooldown: 1 },\n xp: [5, 5],\n },\n // питомец: призывается ручным, идёт за хозяином и защищает его (как волк)\n pet: {\n category: \"neutral\",\n hp: 20,\n walkSpeed: 1.6,\n fleeSpeed: 3,\n chaseSpeed: 3.2,\n follow: 16,\n attack: { damage: 4, reach: 1.1, cooldown: 1 },\n guards: true,\n tameItems: [\"bone\"],\n },\n};\n\nexport function characterMobType(spec: CharacterSpec, b: CharBehavior): string {\n return `c:${specHash(spec)}:${b}`;\n}\n\nexport function isCharacterMobType(type: string): boolean {\n return type.startsWith(\"c:\");\n}\n\n/**\n * Определение моба по типу \"c:<хэш>:<поведение>\" (заводится один раз). Рецепт — переданный (из\n * сейва, снимка сети) или уже известный; нет ни того, ни другого — null.\n */\nexport function ensureCharacterMob(\n type: string,\n look?: unknown,\n): MobDef | null {\n const have = MOBS[type];\n if (have) return have;\n const m = /^c:([0-9a-f]{16}):([a-z]+)$/.exec(type);\n if (!m) return null;\n const behavior = (CHAR_BEHAVIORS as readonly string[]).includes(m[2]!)\n ? (m[2] as CharBehavior)\n : null;\n if (!behavior) return null;\n let spec = characterOf(m[1]);\n if (!spec && look) {\n const r = parseCharacterSpec(look);\n // рецепт из чужого файла: принимаем, только если это тот самый (хэш сходится)\n if (r.ok && specHash(r.spec) === m[1]) spec = r.spec;\n }\n if (!spec) return null;\n registerCharacter(spec);\n const def = defFor(spec, behavior, type);\n MOBS[type] = def;\n return def;\n}\n\nfunction defFor(spec: CharacterSpec, b: CharBehavior, name: string): MobDef {\n const tpl = RIGS[spec.rig];\n const l = layoutCharacter(spec);\n const k = l.scale / 16;\n // хитбокс — ширина шаблона, высота — по росту модели (в пределах разумного)\n const h = Math.max(0.4, Math.min(3.5, l.height * k));\n const w = Math.max(0.3, Math.min(2.5, tpl.hitbox.w * (h / tpl.hitbox.h)));\n return {\n name,\n hp: 20,\n walkSpeed: 1.2,\n fleeSpeed: 2.5,\n box: { w, h },\n drops: [],\n parts: [],\n category: \"passive\",\n persistent: true,\n eye: Math.min(h, l.eye * k),\n ...PRESETS[b],\n character: { spec, behavior: b },\n };\n}\n"
|
|
282
199
|
},
|
|
283
200
|
{
|
|
284
201
|
"path": "entities/DeepBrain.ts",
|
|
@@ -374,7 +291,7 @@
|
|
|
374
291
|
},
|
|
375
292
|
{
|
|
376
293
|
"path": "entities/Mob.ts",
|
|
377
|
-
"content": "// Универсальный моб: риг собирается из parts-описания реестра (BoxGeometry), физика — по коробкам\n// блоков (общая с игроком), решения — entities/MobBrain.ts, анимации процедурные.\n// Добавление моба = запись в registry/mobs/*.ts, этот код не меняется.\nimport { CharacterActor } from \"../character/CharacterActor\";\nimport type { Emotion } from \"../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { DYE_COLORS } from \"../registry/blocks/colors\";\nimport type { ItemStack as Stack } from \"../types\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { moveBody } from \"../physics/Collision\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { EffectId, MobDef } from \"../types\";\nimport { think, type MobCtx } from \"./MobBrain\";\n\nconst PX = 1 / 16; // части описаны в пикселях: 16 px = 1 блок\n\n/**\n * Коробка части по размеру — одна на всех мобов (раньше у каждой части каждого моба была своя\n * геометрия). Помечена shared: dispose моба её не трогает.\n */\nconst PART_BOXES = new Map<string, THREE.BoxGeometry>();\nfunction partBox(size: readonly number[]): THREE.BoxGeometry {\n const k = size.join(\",\");\n let g = PART_BOXES.get(k);\n if (!g) {\n g = new THREE.BoxGeometry(size[0]! * PX, size[1]! * PX, size[2]! * PX);\n g.userData.shared = true;\n PART_BOXES.set(k, g);\n }\n return g;\n}\n/** Детёныш вырастает за 20 минут, как в Minecraft. */\nexport const GROW_TIME = 1200;\n/** Для страйдера лава — полный куб (physics/Collision, extraSolid). */\nconst isLava = (id: number): boolean => id === B.lava;\n\n/** follow — ручной идёт за хозяином (MobBrain.petThink). */\nexport type MobState =\n \"idle\" | \"wander\" | \"flee\" | \"chase\" | \"tempt\" | \"love\" | \"follow\";\n\ninterface AnimPart {\n g: THREE.Group;\n base: THREE.Euler;\n}\n\n/**\n * Сколько секунд зомби ломится в дверь, прежде чем она рухнет. Счётчик копится, пока моб\n * упирается в дверь, и МЕДЛЕННО стекает, когда он отскочил: иначе каждый отскок сбрасывал бы\n * долбёжку в ноль и дверь стояла бы вечно (проверено на живом зомби).\n */\nconst DOOR_TIME = 3;\n\nexport class Mob {\n id: number;\n kind = \"mob\" as const;\n def: MobDef;\n hp: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n targetYaw: number;\n onGround = false;\n dead = false;\n state: MobState = \"idle\";\n stateTime: number;\n /** Желаемая скорость этого тика (мозг); отрицательная — пятиться. */\n speed = 0;\n /**\n * Эффекты на мобе: id → сколько ещё секунд. Понимает два — яд (1 урона раз в 2 с, но не\n * добивает) и замедление (вдвое медленнее). Их вешают отравленные стрелы (systems/TippedArrow).\n */\n effects = new Map<EffectId, number>();\n /** Имя с бирки: висит над мобом и держит его в мире (не исчезает вдали от игрока). */\n customName: string | null = null;\n private nameSprite: THREE.Sprite | null = null;\n private _poisonT = 0;\n /** Желаемая вертикальная скорость летуна (мозг). */\n climbWish = 0;\n /** Куда идти, если не туда, куда смотрит (владыка летит по кругу, глядя на игрока); null — по yaw. */\n moveYaw: number | null = null;\n /** Пауза до следующего прыжка (слизень). */\n hopT = 0;\n /** Часы пике фантома: из каждых 6 с — 2 с вниз на цель. */\n swoopT = 0;\n /** Пчела: свой улей, есть ли пыльца, цветок-цель, таймеры поиска и сбора. */\n home: [number, number, number] | null = null;\n pollen = false;\n beeTarget: [number, number, number] | null = null;\n beeT = 0;\n beeWork = 0;\n /** Ударил игрок моба со стайной злостью — EntityManager разбудит стаю на тике. */\n angerCall = false;\n /** Дракон: состояние мозга босса (entities/DragonBrain). Не сохраняется — после загрузки кружит. */\n bossState: unknown = null;\n /** Кристалл, что сейчас лечит дракона (id): луч и урон дракону от взрыва этого кристалла. */\n healFrom: number | null = null;\n /** Не получает урона (Пепельный владыка набирает силу). */\n invulnerable = false;\n /** Панцирник: насколько открыта крышка (0 — закрыт, 1 — открыт); иглобрюх — насколько раздут. */\n peek = 0;\n /** Рыба: сколько секунд на суше. */\n dryT = 0;\n /** Страж (entities/WardenBrain): злость, место последней вибрации, заряд и остывание звукового удара. */\n anger = 0;\n suspect: [number, number, number] | null = null;\n boomT = 0;\n boomCd = 0;\n /** Страж: сколько ещё вылезает из земли; сколько простаивает без тревог; до следующей Тьмы. */\n emergeT = 0;\n idleT = 0;\n darkT = 0;\n /** Коза (entities/GoatBrain): до следующего тарана, пригибается, разгон, сколько рогов осталось. */\n ramCd = 0;\n ramT = 0;\n dashT = 0;\n horns = 2;\n /** Кричащая коза (2%, как в Minecraft): таранит в разы чаще и кричит. */\n screaming = Math.random() < 0.02;\n /** Лягушка (entities/FrogBrain): до следующего броска языком. */\n tongueCd = 0;\n /** Нюхач (entities/SnifferBrain): до следующего принюхивания; сколько ещё роет. */\n sniffCd = 0;\n digT = 0;\n /** Моб волны спавнера испытаний: координаты спавнера (Game засчитывает убитого; вдали не исчезает). */\n trialFrom: [number, number, number] | null = null;\n /** Капитан мародёров: знамя на спине, за его смерть — Дурное знамение. */\n captain = false;\n /** Участник набега: идёт к goal (центру деревни) и не исчезает вдали. */\n raider = false;\n /** Секунд подсветки от колокола (systems: EntityManager.markRaiders). */\n glowT = 0;\n goal: THREE.Vector3 | null = null;\n /** Ворожей: до следующего заклинания и призванные бесята. */\n castT = 2;\n minions: Mob[] = [];\n /** Бесёнок: сколько ещё жить (сек). */\n lifeT = Infinity;\n private readonly bannerParts: THREE.Object3D[] = [];\n /** Глубинник: сколько заряжается луч (сек), куда он смотрит (null — не заряжает), до следующей Усталости. */\n beamT = 0;\n beamTo: THREE.Vector3 | null = null;\n fatigueT = 3;\n private beamLine: THREE.Line | null = null;\n inWater = false;\n inLava = false;\n hitWall = false;\n baby: boolean;\n growT = 0;\n /** Секунд «любви» после кормления (ищет пару). */\n loveT = 0;\n breedCd = 0;\n fuse = 0;\n attackCd = 0;\n shootCd = 0;\n burnT = 0;\n burning = false;\n /** Снеговик: накопитель секунд в жаре или воде (урон 1 за секунду). */\n snowT = 0;\n /** Попугай на плече игрока: 0 — левое, 1 — правое, null — нет; shoulderCd — пауза после спрыгивания. */\n onShoulder: 0 | 1 | null = null;\n shoulderCd = 0;\n /** Овца: цвет шерсти (id красителя), null — белая; woolShown — какой цвет уже нарисован. */\n woolColor: string | null = null;\n private woolShown: string | null = null;\n /** Броненосец: секунд до разворота (0 — не свёрнут); до следующего сброшенного щитка. */\n rolledT = 0;\n scuteT = 300 + Math.random() * 300;\n /** Волк: запас брони из щитков (0 — без брони). */\n wolfArmor = 0;\n /** Дух-помощник: что велено собирать (образец) и сколько уже собрано. */\n carry: { item: string; count: number } | null = null;\n bag = 0;\n /** Вьюк (осёл, мул, лама): слоты сундука; пусто — не навьючен. */\n items: (Stack | null)[] = [];\n /** На поводке у игрока; leashTo — куда тянется верёвка (рука игрока), задаёт мозг. */\n leashed = false;\n leashTo: THREE.Vector3 | null = null;\n /** Поводок привязан к забору в этой клетке (null — держит игрок). */\n leashPost: [number, number, number] | null = null;\n /** Караван лам: за кем идёт (id моба), не сохраняется. */\n followId: number | null = null;\n /** Поводок держит другой моб (ламы странствующего торговца), не сохраняется. */\n leashMobId: number | null = null;\n /** Сетевая игра: поводок держит игрок с этим слотом (null — свой игрок, одиночная игра). */\n leashSlot: number | null = null;\n /** Секунд до ухода (MobDef.leavesAfter); Infinity — не уходит. */\n leaveT = Infinity;\n /** С ним торгуют (открыт экран торговли) — стоит и смотрит на игрока. */\n trading = false;\n /** Невидим (странствующий торговец ночью — зелье невидимости). */\n invisible = false;\n private leashLine: THREE.Line | null = null;\n private readonly packParts: THREE.Object3D[] = [];\n private readonly armorParts: THREE.Object3D[] = [];\n private readonly outParts: THREE.Object3D[] = [];\n aggro = false;\n justHurt = false;\n /** Недавно бил игрок — за смерть дадут опыт. */\n hitByPlayerT = 0;\n /** Шипун взорвался сам — дропа нет, как в Minecraft. */\n exploded = false;\n sheared = false;\n /** Житель: сколько раз совершена каждая сделка (по индексу в списке профессии) до пополнения. */\n uses: number[] = [];\n /** Житель: занятый рабочий блок (systems/Jobs), null — безработный. */\n jobSite: [number, number, number] | null = null;\n /** Житель уже торговал — профессию не теряет, даже если рабочий блок сломали. */\n traded = false;\n /** Житель: опыт профессии (systems/Trading, levelOf) — открывает следующие сделки. */\n villagerXp = 0;\n /** Зомби-житель: секунд до выздоровления (0 — не лечится) и кем он был (systems/Zombify). */\n curing = 0;\n curedType: string | null = null;\n /** Секунд до следующего пополнения товара за работой у своего блока (не сохраняется). */\n workT = 0;\n /** Ручной (волк, кошка, лошадь), сидит по команде, осёдлан (лошадь). */\n tamed = false;\n sitting = false;\n saddled = false;\n /** Ручной волк: кого атакует (id моба). */\n targetId: number | null = null;\n /** На лошади сидит игрок (Player.riding): мозг молчит, ведёт седок. */\n rider = false;\n private readonly riderWish = { x: 0, z: 0, t: 0 };\n /** Где нарисован (интерполяция) — за это держится седок. */\n readonly renderPos = new THREE.Vector3();\n private readonly tameParts: THREE.Object3D[] = [];\n private readonly saddleParts: THREE.Object3D[] = [];\n /** Замах руки (атака, выстрел). */\n swing = 0;\n animTime = 0;\n flash = 0;\n private lavaT = 0;\n group: THREE.Group;\n mesh: THREE.Object3D;\n private readonly anims = new Map<string, AnimPart[]>();\n private readonly mats: {\n m: THREE.MeshLambertMaterial | THREE.MeshBasicMaterial;\n base: THREE.Color;\n glow: boolean;\n }[] = [];\n private readonly wool: THREE.Object3D[] = [];\n /** Свой моб/NPC из рецепта (entities/characterMobs): персонаж с аниматором вместо частей. */\n readonly character: CharacterActor | null = null;\n private charT = -1;\n private charFlash = 0;\n private charSwing = 0;\n private readonly charLast = new THREE.Vector3();\n private charMood: Emotion = \"neutral\";\n\n constructor(\n id: number,\n def: MobDef,\n x: number,\n y: number,\n z: number,\n baby = false,\n ) {\n this.id = id;\n this.def = def;\n this.hp = def.hp;\n this.baby = baby;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3();\n this.yaw = Math.random() * Math.PI * 2;\n this.targetYaw = this.yaw;\n this.stateTime = 1 + Math.random() * 2;\n\n // риг: каждая часть — Group в точке pivot, бокс смещён относительно pivot\n this.group = new THREE.Group();\n for (const p of def.parts) {\n const color = new THREE.Color(p.color);\n const mat = p.glow\n ? new THREE.MeshBasicMaterial({ color })\n : new THREE.MeshLambertMaterial({ color });\n this.mats.push({ m: mat, base: color.clone(), glow: !!p.glow });\n const mesh = new THREE.Mesh(partBox(p.size), mat);\n mesh.castShadow = mesh.receiveShadow = true;\n const pivot = new THREE.Group();\n const pv = p.pivot ?? p.pos;\n pivot.position.set(pv[0] * PX, pv[1] * PX, pv[2] * PX);\n mesh.position.set(\n (p.pos[0] - pv[0]) * PX,\n (p.pos[1] - pv[1]) * PX,\n (p.pos[2] - pv[2]) * PX,\n );\n if (p.pose) pivot.rotation.set(p.pose[0], p.pose[1], p.pose[2]);\n pivot.add(mesh);\n this.group.add(pivot);\n if (p.anim) {\n const list = this.anims.get(p.anim) ?? [];\n list.push({ g: pivot, base: pivot.rotation.clone() });\n this.anims.set(p.anim, list);\n }\n if (p.name.startsWith(\"wool\")) this.wool.push(pivot);\n if (p.name.startsWith(\"tame\")) this.tameParts.push(pivot);\n if (p.name.startsWith(\"saddle\")) this.saddleParts.push(pivot);\n if (p.name.startsWith(\"armor\")) this.armorParts.push(pivot);\n if (p.name.startsWith(\"out\")) this.outParts.push(pivot);\n if (p.name.startsWith(\"pack\")) this.packParts.push(pivot);\n if (p.name.startsWith(\"banner\")) this.bannerParts.push(pivot);\n }\n if (def.lifespan)\n this.lifeT =\n def.lifespan[0] + Math.random() * (def.lifespan[1] - def.lifespan[0]);\n if (def.leavesAfter)\n this.leaveT =\n def.leavesAfter[0] +\n Math.random() * (def.leavesAfter[1] - def.leavesAfter[0]);\n // овцы в Minecraft изредка бывают чёрные, серые, светло-серые, коричневые и совсем редко розовые\n if (def.dyeable) {\n const r = Math.random();\n this.woolColor =\n r < 0.05\n ? \"black\"\n : r < 0.1\n ? \"gray\"\n : r < 0.15\n ? \"light_gray\"\n : r < 0.18\n ? \"brown\"\n : r < 0.1816\n ? \"pink\"\n : null;\n }\n if (def.character) {\n const c = new CharacterActor(def.character.spec);\n this.group.add(c.group);\n (this as { character: CharacterActor | null }).character = c;\n }\n this.mesh = this.group; // единообразие с ItemDrop\n }\n\n /** Кадр персонажа: шаг по пройденному, удар, замах, настроение по состоянию мозга. */\n private _characterFrame(\n time: number,\n bright: number,\n emissive: number,\n ): void {\n const c = this.character!;\n const dt =\n this.charT < 0 ? 0 : Math.min(0.1, Math.max(0, time - this.charT));\n this.charT = time;\n const p = this.renderPos;\n const speed =\n dt > 0\n ? Math.hypot(p.x - this.charLast.x, p.z - this.charLast.z) / dt\n : 0;\n this.charLast.copy(p);\n if (this.flash > this.charFlash + 0.05) c.trigger(\"hurt\");\n this.charFlash = this.flash;\n if (this.swing > 0 && this.charSwing <= 0) c.trigger(\"swing\");\n this.charSwing = this.swing;\n const mood: Emotion = this.dead\n ? \"neutral\"\n : this.aggro || this.state === \"chase\"\n ? \"angry\"\n : this.state === \"flee\"\n ? \"scared\"\n : this.tamed || this.state === \"love\"\n ? \"happy\"\n : \"neutral\";\n if (mood !== this.charMood) {\n this.charMood = mood;\n c.setEmotion(mood);\n }\n c.update(dt, {\n x: 0,\n y: 0,\n z: 0,\n yaw: 0,\n bodyYaw: 0,\n pitch: 0,\n speed: Math.min(speed, 12),\n vy: this.vel.y,\n onGround: this.onGround,\n inWater: this.inWater,\n flying: !!this.def.flies,\n sitting: this.sitting,\n dead: this.dead,\n bright,\n });\n c.view.setEmissive(emissive);\n }\n\n /** Что даёт стрижка: шерсть своего цвета (овца), иначе shearDrop. */\n woolItem(): string {\n return this.woolColor\n ? `${this.woolColor}_wool`\n : (this.def.shearDrop?.item ?? \"white_wool\");\n }\n\n /** Высота седока над мобом (лошадь). */\n get seatY(): number {\n return this.def.rideable?.seat ?? 1;\n }\n\n /** Седок жмёт «вперёд»: куда вести лошадь (держится 0.15 с, седок повторяет каждый кадр). */\n push(dx: number, dz: number, _dt: number): void {\n this.riderWish.x = dx;\n this.riderWish.z = dz;\n this.riderWish.t = 0.15;\n }\n\n get width(): number {\n return this.def.box.w * (this.baby ? 0.5 : 1);\n }\n get height(): number {\n return this.def.box.h * (this.baby ? 0.5 : 1);\n }\n\n tick(dt: number, c: MobCtx) {\n this.prevPos.copy(this.pos);\n const dec = (v: number) => Math.max(0, v - dt);\n this.flash = dec(this.flash);\n this.attackCd = dec(this.attackCd);\n this.shootCd = dec(this.shootCd);\n this.loveT = dec(this.loveT);\n this.breedCd = dec(this.breedCd);\n this.hitByPlayerT = dec(this.hitByPlayerT);\n this.swing = dec(this.swing);\n // попугай на плече: едет с игроком (мозг и физика молчат); игрок присел или умер — слетает\n if (this.onShoulder !== null) {\n const p = c.player;\n if (p.dead || p._sneaking || p.shoulders[this.onShoulder] !== this.id) {\n if (p.shoulders[this.onShoulder] === this.id)\n p.shoulders[this.onShoulder] = null;\n this.onShoulder = null;\n this.shoulderCd = 3; // не запрыгивать сразу обратно\n } else {\n const side = this.onShoulder === 1 ? 1 : -1;\n this.pos.set(\n p.pos.x + Math.cos(p.yaw) * 0.4 * side,\n p.pos.y + 1.45,\n p.pos.z - Math.sin(p.yaw) * 0.4 * side,\n );\n this.vel.set(0, 0, 0);\n this.yaw = this.targetYaw = p.yaw;\n this.speed = 0;\n this.animTime += dt;\n return;\n }\n }\n this._tickEffects(dt);\n this.glowT = Math.max(0, this.glowT - dt);\n this.shoulderCd = dec(this.shoulderCd);\n this.rolledT = dec(this.rolledT);\n // странствующий торговец и его ламы: срок вышел — уходят (исчезают без дропа)\n if (this.leaveT !== Infinity) {\n this.leaveT -= dt;\n if (this.leaveT <= 0 && !this.dead) {\n this.dead = true; // hp > 0 — без дропа\n events.emit(\"mobLeft\", { entity: this });\n }\n }\n // броненосец сам сбрасывает щиток раз в 5–10 минут\n if (this.def.rollsUp && !this.baby) {\n this.scuteT -= dt;\n if (this.scuteT <= 0) {\n this.scuteT = 300 + Math.random() * 300;\n events.emit(\"mobDrop\", {\n x: this.pos.x,\n y: this.pos.y + 0.3,\n z: this.pos.z,\n item: \"armadillo_scute\",\n });\n }\n }\n // бесёнок: срок вышел — теряет по 1 здоровью в секунду\n if (this.def.lifespan) {\n this.lifeT -= dt;\n if (\n this.lifeT <= 0 &&\n Math.floor(this.lifeT) !== Math.floor(this.lifeT + dt)\n )\n this.damage(1);\n }\n if (this.baby) {\n this.growT += dt;\n if (this.growT >= GROW_TIME) {\n this.baby = false;\n if (this.def.growDrop) events.emit(\"mobGrew\", { entity: this }); // черепашонок — щиток\n }\n }\n // головастик: вырастает в другой вид (лягушку по биому — EntityManager, событие mobMature)\n // житель Пекла вне Пекла превращается (свинорыл → мёртвый свинорыл, хоглин → зоглин)\n if (\n this.def.growsInto &&\n !this.dead &&\n !(this.def.zombifies && c.world.dimension === \"inferno\")\n ) {\n this.growT += dt;\n if (this.growT >= (this.def.growTime ?? GROW_TIME)) {\n this.growT = -Infinity;\n events.emit(\"mobMature\", { entity: this });\n }\n }\n // Кристалл Предела: ни мозга, ни физики — висит над колонной\n if (this.def.stationary) {\n this.animTime += dt;\n return;\n }\n // панцирник сидит на блоке: мозг решает, открыться ли и стрелять; ни шагов, ни гравитации\n if (this.def.anchored) {\n think(this, c, dt);\n this.animTime += dt;\n return;\n }\n\n const world = c.world;\n const feet = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n this.inWater = feet === B.water || !!BLOCKS[feet]?.waterlogged;\n this.inLava = feet === B.lava;\n // рыба на суше: 5 с — и по 1 урону в секунду; бьётся, подпрыгивая\n if (this.def.needsWater) {\n if (this.inWater) this.dryT = 0;\n else {\n this.dryT += dt;\n // рыба задыхается через 5 с и бьётся на месте; аксолотль ходит по суше dryTime секунд\n const limit = this.def.dryTime ?? 5;\n if (\n this.dryT > limit &&\n Math.floor(this.dryT) !== Math.floor(this.dryT - dt)\n )\n this.damage(1);\n if (!this.def.dryTime && this.onGround && Math.random() < dt * 3)\n this.vel.y = 3;\n }\n }\n\n if (this.rider) {\n // под седоком: направление и галоп — от игрока; ступеньки перепрыгивает сам (hitWall ниже)\n const w = this.riderWish;\n w.t -= dt;\n if (w.t > 0 && (w.x || w.z)) {\n this.targetYaw = this.yaw = Math.atan2(-w.x, -w.z);\n this.speed = this.def.rideable?.speed ?? this.def.walkSpeed;\n } else this.speed = 0;\n } else think(this, c, dt);\n\n // плавный поворот к целевому направлению\n let dy = this.targetYaw - this.yaw;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n this.yaw += dy * Math.min(1, 6 * dt);\n\n const moveYaw = this.moveYaw ?? this.yaw;\n const slow = this.effects.has(\"slowness\") ? 0.5 : 1;\n const wishX = -Math.sin(moveYaw) * this.speed * slow;\n const wishZ = -Math.cos(moveYaw) * this.speed * slow;\n const accel = Math.min(1, 10 * dt);\n this.vel.x += (wishX - this.vel.x) * accel;\n this.vel.z += (wishZ - this.vel.z) * accel;\n if (this.def.hops) {\n // прыжками: на земле стоит и выжидает, в воздухе летит туда, куда смотрит\n if (this.onGround) {\n this.vel.x = 0;\n this.vel.z = 0;\n this.hopT -= dt;\n if (this.speed !== 0 && this.hopT <= 0) {\n this.vel.y = 7;\n this.hopT = 0.5 + Math.random() * 1.5;\n }\n } else {\n this.vel.x = wishX * 1.5;\n this.vel.z = wishZ * 1.5;\n }\n }\n // летун и пловец в воде: без гравитации, высоту задаёт мозг (climbWish)\n if (this.def.flies || (this.def.swims && this.inWater))\n this.vel.y += (this.climbWish - this.vel.y) * accel;\n else {\n this.vel.y -= 22 * dt;\n if (this.inWater || this.inLava) {\n // Плывёт, погрузившись наполовину (как в Minecraft): всплывает, пока середина тела в воде,\n // выше — медленно оседает. Прежде всплывал, пока мокрые хотя бы ноги, и стоял на воде.\n const mid = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + this.height * 0.55),\n Math.floor(this.pos.z),\n );\n const deep =\n mid === B.water || mid === B.lava || !!BLOCKS[mid]?.waterlogged;\n this.vel.y = deep\n ? Math.max(this.vel.y, 1.2)\n : Math.max(this.vel.y, -0.6);\n }\n }\n\n if (this.def.noClip)\n this.pos.addScaledVector(this.vel, dt); // дракон: сквозь блоки\n else this._move(dt, world);\n // пустота под миром (край острова Предела): падал бы вечно — ручной волк или названная бирка\n // не исчезают по дальности и висели бы в списке сущностей навсегда. Пустота убивает, как в Minecraft\n if (this.pos.y < -64 && !this.def.boss && !this.dead) {\n this.hp = 0;\n this.dead = true;\n }\n\n // зомби осады ломает дверь, в которую упёрся (как в Minecraft на высокой сложности)\n // ⚠ у обычного враждебного моба aggro остаётся false: злость — это поле для НЕЙТРАЛЬНЫХ\n // (свинорыл, голем), а зомби враждебен по категории. Дверь ломает и тот, и другой.\n if (\n this.def.breaksDoors &&\n rules().doorBreaking && // только на высокой сложности (systems/Difficulty)\n (this.aggro || this.def.category === \"hostile\") &&\n this.speed !== 0\n )\n this._breakDoor(dt, world);\n else this.doorT = Math.max(0, this.doorT - dt); // короткий отскок не сбрасывает долбёжку\n\n // упёрся в стену на ходу — подпрыгнуть (на 1 блок); паук лезет по стене\n if (this.speed !== 0 && this.hitWall && !this.def.hops) {\n if (this.def.climbs) this.vel.y = 3;\n else if (this.onGround) this.vel.y = 7;\n }\n\n // лава и огонь жгут: 4 и 1 урона каждые полсекунды\n if (\n (this.inLava ||\n feet === B.fire ||\n feet === B.soul_fire ||\n feet === B.campfire ||\n feet === B.soul_campfire) &&\n !this.def.fireImmune\n ) {\n this.lavaT -= dt;\n if (this.lavaT <= 0) {\n this.lavaT = 0.5;\n this.damage(this.inLava ? 4 : 1);\n }\n }\n\n this.animTime +=\n dt * (this.speed !== 0 ? Math.hypot(this.vel.x, this.vel.z) * 3 : 0);\n }\n\n /** Шаг с коллизиями по коробкам форм — тот же код, что у игрока (physics/Collision). */\n _move(dt: number, world: ChunkManager) {\n const r = moveBody(\n world,\n {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n hw: this.width / 2,\n h: this.height,\n },\n this.vel.x * dt,\n this.vel.y * dt,\n this.vel.z * dt,\n // страйдер стоит на лаве, как на полу\n {\n step: this.onGround ? 0.6 : 0,\n onGround: this.onGround,\n ...(this.def.walksOnLava ? { extraSolid: isLava } : {}),\n },\n );\n this.pos.x += r.dx;\n this.pos.y += r.dy;\n this.pos.z += r.dz;\n if (r.hitDown) {\n this.onGround = true;\n this.vel.y = 0;\n } else {\n this.onGround = false;\n if (r.hitUp) this.vel.y = 0;\n }\n if (r.hitX) this.vel.x = 0;\n if (r.hitZ) this.vel.z = 0;\n this.hitWall = r.hitX || r.hitZ;\n }\n\n /** Стрелы отскакивают: Пепельный владыка на половине здоровья, закрытый панцирник. */\n shielded(): boolean {\n return (\n (this.def.boss === \"ash_lord\" && this.hp <= this.def.hp / 2) ||\n (!!this.def.shell && this.peek < 0.3)\n );\n }\n\n /** Урон без отброса (солнце, лава, падение). */\n damage(amount: number) {\n if (this.dead || this.invulnerable) return;\n this.hp -= amount;\n this.flash = 0.4;\n events.emit(\"mobHurt\", { entity: this, amount });\n if (this.hp <= 0) {\n if (this.def.boss === \"dragon\")\n this.hp = 0; // дракон умирает сам — после анимации (DragonBrain)\n else this.dead = true;\n }\n }\n\n /** Удар: урон, отброс от атакующего, вспышка; мирный убегает, остальные злятся. */\n /** Дать имя с бирки: табличка над головой. Пустое имя — снять. */\n setName(name: string | null): void {\n const clean = (name ?? \"\").trim().slice(0, 24);\n this.customName = clean || null;\n if (this.nameSprite) {\n this.group.remove(this.nameSprite);\n this.nameSprite.material.map?.dispose();\n this.nameSprite.material.dispose();\n this.nameSprite = null;\n }\n if (!this.customName) return;\n const c = document.createElement(\"canvas\");\n c.width = 256;\n c.height = 64;\n const g = c.getContext(\"2d\")!;\n g.font = \"bold 34px monospace\";\n g.textAlign = \"center\";\n g.textBaseline = \"middle\";\n g.fillStyle = \"rgba(0,0,0,0.45)\";\n g.fillRect(0, 12, 256, 40);\n g.fillStyle = \"#ffffff\";\n g.fillText(this.customName, 128, 32, 248);\n const tex = new THREE.CanvasTexture(c);\n tex.colorSpace = THREE.SRGBColorSpace;\n const s = new THREE.Sprite(\n new THREE.SpriteMaterial({\n map: tex,\n depthTest: false,\n transparent: true,\n }),\n );\n s.scale.set(1.6, 0.4, 1);\n s.position.set(0, this.height + 0.45, 0);\n this.group.add(s);\n this.nameSprite = s;\n }\n\n /** Повесить эффект (дольше уже висящего — продлить). */\n applyEffect(id: EffectId, seconds: number): void {\n this.effects.set(id, Math.max(this.effects.get(id) ?? 0, seconds));\n }\n\n /** Сроки эффектов и урон от яда — зовётся из tick. */\n private _tickEffects(dt: number): void {\n if (!this.effects.size) return;\n for (const [id, t] of this.effects)\n if (t - dt <= 0) this.effects.delete(id);\n else this.effects.set(id, t - dt);\n if (!this.effects.has(\"poison\")) {\n this._poisonT = 0;\n return;\n }\n this._poisonT += dt;\n if (this._poisonT >= 2) {\n this._poisonT = 0;\n if (this.hp > 1) this.damage(1); // яд не добивает, как в Minecraft\n }\n }\n\n /** Секунд, что моб уже ломится в дверь перед собой. */\n private doorT = 0;\n\n /** Долбить дверь перед собой: через DOOR_TIME секунд она ломается совсем. */\n private _breakDoor(dt: number, world: ChunkManager): void {\n const ahead = new THREE.Vector3(\n -Math.sin(this.yaw),\n 0,\n -Math.cos(this.yaw),\n );\n const x = Math.floor(this.pos.x + ahead.x * 0.7),\n z = Math.floor(this.pos.z + ahead.z * 0.7),\n y = Math.floor(this.pos.y);\n const id = world.getBlock(x, y, z);\n if (BLOCKS[id]?.shape !== \"door\") {\n this.doorT = Math.max(0, this.doorT - dt);\n return;\n }\n this.doorT += dt;\n if (this.doorT < DOOR_TIME) return;\n this.doorT = 0;\n // дверь из двух половин: сносим обе, обломков не остаётся\n world.setBlock(x, y, z, B.air!);\n if (BLOCKS[world.getBlock(x, y + 1, z)]?.shape === \"door\")\n world.setBlock(x, y + 1, z, B.air!);\n if (BLOCKS[world.getBlock(x, y - 1, z)]?.shape === \"door\")\n world.setBlock(x, y - 1, z, B.air!);\n events.emit(\"doorBroken\", { x, y, z });\n }\n\n hurt(damage: number, fromPos: THREE.Vector3, byPlayer = true) {\n if (this.dead || this.invulnerable) return;\n this.justHurt = true;\n // броня волка берёт урон на себя, пока не износится\n if (this.wolfArmor > 0) {\n const a = Math.min(this.wolfArmor, damage);\n this.wolfArmor -= a;\n damage -= a;\n if (this.wolfArmor <= 0) events.emit(\"wolfArmorBroke\", { entity: this });\n }\n // броненосец: свёрнутый держит удар (−3), любой удар сворачивает на 6 с\n if (this.def.rollsUp) {\n if (this.rolledT > 0) damage = Math.max(0, damage - 3);\n this.rolledT = 6;\n }\n if (byPlayer) {\n this.hitByPlayerT = 5;\n events.emit(\"playerHitMob\", { entity: this }); // ручные волки бросаются на обидчика\n }\n const away = this.pos.clone().sub(fromPos).setY(0);\n if (away.lengthSq() < 1e-6)\n away.set(Math.random() - 0.5, 0, Math.random() - 0.5);\n away.normalize();\n if (!this.def.boss) this.vel.add(away.clone().multiplyScalar(6)).setY(5); // дракона не отбросить\n if (this.def.category === \"passive\") {\n this.state = \"flee\";\n // Ход моба — (-sin yaw, -cos yaw), и этот yaw даёт ход вдоль `away`, т.е. прочь от атакующего.\n this.targetYaw = Math.atan2(-away.x, -away.z);\n this.stateTime = 3;\n } else if (!this.tamed) this.aggro = true; // ручной на хозяина не злится\n if (byPlayer && this.def.groupAnger) this.angerCall = true;\n this.damage(damage);\n }\n\n updateVisual(alpha: number, time: number, bright = 1) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.group.position.copy(p);\n this.renderPos.copy(p);\n this._drawBeam(p);\n this._drawLeash();\n this.group.visible = !this.invisible;\n this.group.rotation.y = this.yaw;\n // Шипун раздувается перед взрывом\n // иглобрюх раздувается шаром\n const swell = this.def.explode\n ? 1 + (this.fuse / this.def.explode.fuse) * 0.18\n : this.def.puffs\n ? 1 + this.peek * 0.9\n : 1;\n this.group.scale.setScalar((this.baby ? 0.5 : 1) * swell);\n\n // анимации: шаг ногами, руки в такт (или замах), покачивание головы, крылья при падении\n const walk = Math.sin(this.animTime * 6) * 0.7;\n const swingArm =\n this.swing > 0 ? Math.sin((this.swing / 0.3) * Math.PI) * 1.2 : 0;\n const set = (name: string, fn: (a: AnimPart) => void) =>\n this.anims.get(name)?.forEach(fn);\n set(\"legL\", (a) => (a.g.rotation.x = a.base.x + walk));\n set(\"legR\", (a) => (a.g.rotation.x = a.base.x - walk));\n set(\"armL\", (a) => (a.g.rotation.x = a.base.x - walk * 0.5 - swingArm));\n set(\"armR\", (a) => (a.g.rotation.x = a.base.x + walk * 0.5 - swingArm));\n set(\n \"head\",\n (a) => (a.g.rotation.x = a.base.x + Math.sin(time * 1.7) * 0.08),\n );\n set(\"lid\", (a) => (a.g.rotation.x = a.base.x + this.peek * 0.9));\n const falling = !this.onGround && this.vel.y < -1;\n // курица хлопает, только падая; летуны машут всегда (дракон — медленно и широко)\n const wing = this.def.boss\n ? Math.sin(time * 3) * 0.6\n : falling || this.def.flies\n ? Math.sin(time * 30) * 0.9\n : 0.05;\n set(\"wingL\", (a) => (a.g.rotation.z = wing));\n set(\"wingR\", (a) => (a.g.rotation.z = -wing));\n for (const w of this.wool) w.visible = !this.sheared;\n // окрашенная овца: шерсти — свой материал цвета красителя (общие материалы не трогаем)\n if (this.woolShown !== this.woolColor) {\n this.woolShown = this.woolColor;\n const hex = DYE_COLORS.find(\n (d) => d.id === (this.woolColor ?? \"white\"),\n )?.hex;\n if (hex)\n for (const w of this.wool)\n w.traverse((o) => {\n if (!(o instanceof THREE.Mesh)) return;\n const mat = (o.material as THREE.MeshLambertMaterial).clone();\n mat.color.set(hex);\n o.material = mat;\n });\n }\n for (const t of this.tameParts) t.visible = this.tamed;\n for (const s of this.saddleParts) s.visible = this.saddled;\n for (const a of this.armorParts) a.visible = this.wolfArmor > 0;\n for (const o of this.outParts) o.visible = this.rolledT <= 0; // свёрнутый броненосец — один панцирь\n for (const k of this.packParts) k.visible = this.items.length > 0;\n for (const b of this.bannerParts) b.visible = this.captain;\n\n // свет места (пещера темнее), вспышка урона, горение на солнце, мигание фитиля\n const fuseBlink =\n this.fuse > 0 && Math.sin(time * (8 + this.fuse * 12)) > 0;\n // владыка: набирая силу, мерцает синим; в броне — синеватый отлив\n const emissive =\n this.flash > 0\n ? 0xff3333\n : this.burning && Math.sin(time * 20) > 0\n ? 0xff7a1a\n : fuseBlink\n ? 0xffffff\n : this.invulnerable\n ? Math.sin(time * 10) > 0\n ? 0x3050ff\n : 0x000000\n : this.shielded()\n ? 0x1a2a60\n : 0x000000;\n if (this.character) this._characterFrame(time, bright, emissive);\n for (const { m, base, glow } of this.mats) {\n if (glow) continue;\n m.color.copy(base).multiplyScalar(this.glowT > 0 ? 2.4 : bright);\n const lm = m as THREE.MeshLambertMaterial;\n if (lm.emissive.getHex() !== emissive) lm.emissive.setHex(emissive);\n }\n }\n\n /** Луч глубинника: линия от глаза к цели, от лилового к жёлтому по мере зарядки. */\n /** Верёвка поводка — дочерняя линия группы (уходит вместе с мобом): от шеи до руки игрока. */\n private _drawLeash(): void {\n const to = this.leashTo; // к держателю поводка или к ламе впереди в караване (задаёт мозг)\n if (!to) {\n if (this.leashLine) this.leashLine.visible = false;\n return;\n }\n if (!this.leashLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.leashLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x8a6a3a }),\n );\n this.group.add(this.leashLine);\n }\n this.leashLine.visible = true;\n this.group.updateMatrixWorld();\n const s = this.group.scale.x || 1;\n const local = this.group.worldToLocal(to.clone());\n const a = this.leashLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, 0, (this.height * 0.8) / s, 0);\n a.setXYZ(1, local.x, local.y, local.z);\n a.needsUpdate = true;\n }\n\n private _drawBeam(p: THREE.Vector3): void {\n const to = this.beamTo;\n if (!to || !this.group.parent) {\n if (this.beamLine) this.beamLine.visible = false;\n return;\n }\n if (!this.beamLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.beamLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x9a60ff }),\n );\n this.group.parent.add(this.beamLine);\n }\n const a = this.beamLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, p.x, p.y + this.height / 2, p.z);\n a.setXYZ(1, to.x, to.y, to.z);\n a.needsUpdate = true;\n const k = Math.min(1, this.beamT / 3);\n (this.beamLine.material as THREE.LineBasicMaterial).color.setRGB(\n 0.6 + 0.4 * k,\n 0.4 + 0.5 * k,\n 1 - 0.8 * k,\n );\n this.beamLine.visible = true;\n }\n\n /** Тень отбрасывают только мобы рядом с игроком: дальние лишь удлиняют теневой проход. */\n private shadowOn = true;\n setShadow(on: boolean): void {\n if (on === this.shadowOn) return;\n this.shadowOn = on;\n this.group.traverse((o) => {\n if ((o as THREE.Mesh).isMesh) o.castShadow = on;\n });\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.group);\n if (this.beamLine) {\n scene.remove(this.beamLine);\n this.beamLine.geometry.dispose();\n (this.beamLine.material as THREE.Material).dispose();\n }\n // traverse даёт Object3D; узкий каст к Mesh для доступа к geometry (проверка isMesh — рантайм).\n this.group.traverse((o) => {\n const g = (o as THREE.Mesh).isMesh ? (o as THREE.Mesh).geometry : null;\n if (g && !g.userData.shared) g.dispose(); // коробки частей общие на всех мобов\n });\n for (const { m } of this.mats) m.dispose();\n this.character?.dispose();\n }\n\n serialize() {\n return {\n kind: \"mob\",\n type: this.def.name,\n pos: this.pos.toArray(),\n hp: this.hp,\n baby: this.baby,\n sheared: this.sheared,\n ...(this.uses.length ? { uses: [...this.uses] } : {}),\n ...(this.jobSite ? { jobSite: [...this.jobSite] } : {}),\n ...(this.traded ? { traded: true } : {}),\n ...(this.villagerXp > 0 ? { villagerXp: this.villagerXp } : {}),\n ...(this.curedType ? { curedType: this.curedType } : {}),\n ...(this.curing > 0 ? { curing: Math.round(this.curing) } : {}),\n ...(this.tamed ? { tamed: true } : {}),\n ...(this.customName ? { customName: this.customName } : {}),\n ...(this.sitting ? { sitting: true } : {}),\n ...(this.saddled ? { saddled: true } : {}),\n ...(this.woolColor ? { woolColor: this.woolColor } : {}),\n ...(this.wolfArmor > 0 ? { wolfArmor: this.wolfArmor } : {}),\n ...(this.carry ? { carry: this.carry.item, bag: this.bag } : {}),\n ...(this.items.length\n ? { items: this.items.map((s) => (s ? { ...s } : null)) }\n : {}),\n ...(this.leashed ? { leashed: true } : {}),\n ...(this.leashPost ? { leashPost: [...this.leashPost] } : {}),\n ...(this.leashSlot !== null ? { leashSlot: this.leashSlot } : {}),\n ...(this.leaveT !== Infinity ? { leaveT: Math.round(this.leaveT) } : {}),\n ...(this.home ? { home: [...this.home] } : {}),\n ...(this.pollen ? { pollen: true } : {}),\n ...(this.captain ? { captain: true } : {}),\n // рецепт своего моба едет с ним: мир открывается и там, где этого рецепта нет\n ...(this.def.character ? { look: this.def.character.spec } : {}),\n };\n }\n}\n"
|
|
294
|
+
"content": "// Универсальный моб: риг собирается из parts-описания реестра (BoxGeometry), физика — по коробкам\n// блоков (общая с игроком), решения — entities/MobBrain.ts, анимации процедурные.\n// Добавление моба = запись в registry/mobs/*.ts, этот код не меняется.\nimport { CharacterActor } from \"../character/CharacterActor\";\nimport type { Emotion } from \"@idosgames/characters/voxel\";\nimport * as THREE from \"three\";\nimport { rules } from \"../systems/Difficulty\";\nimport { DYE_COLORS } from \"../registry/blocks/colors\";\nimport type { ItemStack as Stack } from \"../types\";\nimport { events } from \"../core/EventBus\";\nimport { BLOCKS, B } from \"../registry/Blocks\";\nimport { moveBody } from \"../physics/Collision\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { EffectId, MobDef } from \"../types\";\nimport { think, type MobCtx } from \"./MobBrain\";\n\nconst PX = 1 / 16; // части описаны в пикселях: 16 px = 1 блок\n\n/**\n * Коробка части по размеру — одна на всех мобов (раньше у каждой части каждого моба была своя\n * геометрия). Помечена shared: dispose моба её не трогает.\n */\nconst PART_BOXES = new Map<string, THREE.BoxGeometry>();\nfunction partBox(size: readonly number[]): THREE.BoxGeometry {\n const k = size.join(\",\");\n let g = PART_BOXES.get(k);\n if (!g) {\n g = new THREE.BoxGeometry(size[0]! * PX, size[1]! * PX, size[2]! * PX);\n g.userData.shared = true;\n PART_BOXES.set(k, g);\n }\n return g;\n}\n/** Детёныш вырастает за 20 минут, как в Minecraft. */\nexport const GROW_TIME = 1200;\n/** Для страйдера лава — полный куб (physics/Collision, extraSolid). */\nconst isLava = (id: number): boolean => id === B.lava;\n\n/** follow — ручной идёт за хозяином (MobBrain.petThink). */\nexport type MobState =\n \"idle\" | \"wander\" | \"flee\" | \"chase\" | \"tempt\" | \"love\" | \"follow\";\n\ninterface AnimPart {\n g: THREE.Group;\n base: THREE.Euler;\n}\n\n/**\n * Сколько секунд зомби ломится в дверь, прежде чем она рухнет. Счётчик копится, пока моб\n * упирается в дверь, и МЕДЛЕННО стекает, когда он отскочил: иначе каждый отскок сбрасывал бы\n * долбёжку в ноль и дверь стояла бы вечно (проверено на живом зомби).\n */\nconst DOOR_TIME = 3;\n\nexport class Mob {\n id: number;\n kind = \"mob\" as const;\n def: MobDef;\n hp: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n targetYaw: number;\n onGround = false;\n dead = false;\n state: MobState = \"idle\";\n stateTime: number;\n /** Желаемая скорость этого тика (мозг); отрицательная — пятиться. */\n speed = 0;\n /**\n * Эффекты на мобе: id → сколько ещё секунд. Понимает два — яд (1 урона раз в 2 с, но не\n * добивает) и замедление (вдвое медленнее). Их вешают отравленные стрелы (systems/TippedArrow).\n */\n effects = new Map<EffectId, number>();\n /** Имя с бирки: висит над мобом и держит его в мире (не исчезает вдали от игрока). */\n customName: string | null = null;\n private nameSprite: THREE.Sprite | null = null;\n private _poisonT = 0;\n /** Желаемая вертикальная скорость летуна (мозг). */\n climbWish = 0;\n /** Куда идти, если не туда, куда смотрит (владыка летит по кругу, глядя на игрока); null — по yaw. */\n moveYaw: number | null = null;\n /** Пауза до следующего прыжка (слизень). */\n hopT = 0;\n /** Часы пике фантома: из каждых 6 с — 2 с вниз на цель. */\n swoopT = 0;\n /** Пчела: свой улей, есть ли пыльца, цветок-цель, таймеры поиска и сбора. */\n home: [number, number, number] | null = null;\n pollen = false;\n beeTarget: [number, number, number] | null = null;\n beeT = 0;\n beeWork = 0;\n /** Ударил игрок моба со стайной злостью — EntityManager разбудит стаю на тике. */\n angerCall = false;\n /** Дракон: состояние мозга босса (entities/DragonBrain). Не сохраняется — после загрузки кружит. */\n bossState: unknown = null;\n /** Кристалл, что сейчас лечит дракона (id): луч и урон дракону от взрыва этого кристалла. */\n healFrom: number | null = null;\n /** Не получает урона (Пепельный владыка набирает силу). */\n invulnerable = false;\n /** Панцирник: насколько открыта крышка (0 — закрыт, 1 — открыт); иглобрюх — насколько раздут. */\n peek = 0;\n /** Рыба: сколько секунд на суше. */\n dryT = 0;\n /** Страж (entities/WardenBrain): злость, место последней вибрации, заряд и остывание звукового удара. */\n anger = 0;\n suspect: [number, number, number] | null = null;\n boomT = 0;\n boomCd = 0;\n /** Страж: сколько ещё вылезает из земли; сколько простаивает без тревог; до следующей Тьмы. */\n emergeT = 0;\n idleT = 0;\n darkT = 0;\n /** Коза (entities/GoatBrain): до следующего тарана, пригибается, разгон, сколько рогов осталось. */\n ramCd = 0;\n ramT = 0;\n dashT = 0;\n horns = 2;\n /** Кричащая коза (2%, как в Minecraft): таранит в разы чаще и кричит. */\n screaming = Math.random() < 0.02;\n /** Лягушка (entities/FrogBrain): до следующего броска языком. */\n tongueCd = 0;\n /** Нюхач (entities/SnifferBrain): до следующего принюхивания; сколько ещё роет. */\n sniffCd = 0;\n digT = 0;\n /** Моб волны спавнера испытаний: координаты спавнера (Game засчитывает убитого; вдали не исчезает). */\n trialFrom: [number, number, number] | null = null;\n /** Капитан мародёров: знамя на спине, за его смерть — Дурное знамение. */\n captain = false;\n /** Участник набега: идёт к goal (центру деревни) и не исчезает вдали. */\n raider = false;\n /** Секунд подсветки от колокола (systems: EntityManager.markRaiders). */\n glowT = 0;\n goal: THREE.Vector3 | null = null;\n /** Ворожей: до следующего заклинания и призванные бесята. */\n castT = 2;\n minions: Mob[] = [];\n /** Бесёнок: сколько ещё жить (сек). */\n lifeT = Infinity;\n private readonly bannerParts: THREE.Object3D[] = [];\n /** Глубинник: сколько заряжается луч (сек), куда он смотрит (null — не заряжает), до следующей Усталости. */\n beamT = 0;\n beamTo: THREE.Vector3 | null = null;\n fatigueT = 3;\n private beamLine: THREE.Line | null = null;\n inWater = false;\n inLava = false;\n hitWall = false;\n baby: boolean;\n growT = 0;\n /** Секунд «любви» после кормления (ищет пару). */\n loveT = 0;\n breedCd = 0;\n fuse = 0;\n attackCd = 0;\n shootCd = 0;\n burnT = 0;\n burning = false;\n /** Снеговик: накопитель секунд в жаре или воде (урон 1 за секунду). */\n snowT = 0;\n /** Попугай на плече игрока: 0 — левое, 1 — правое, null — нет; shoulderCd — пауза после спрыгивания. */\n onShoulder: 0 | 1 | null = null;\n shoulderCd = 0;\n /** Овца: цвет шерсти (id красителя), null — белая; woolShown — какой цвет уже нарисован. */\n woolColor: string | null = null;\n private woolShown: string | null = null;\n /** Броненосец: секунд до разворота (0 — не свёрнут); до следующего сброшенного щитка. */\n rolledT = 0;\n scuteT = 300 + Math.random() * 300;\n /** Волк: запас брони из щитков (0 — без брони). */\n wolfArmor = 0;\n /** Дух-помощник: что велено собирать (образец) и сколько уже собрано. */\n carry: { item: string; count: number } | null = null;\n bag = 0;\n /** Вьюк (осёл, мул, лама): слоты сундука; пусто — не навьючен. */\n items: (Stack | null)[] = [];\n /** На поводке у игрока; leashTo — куда тянется верёвка (рука игрока), задаёт мозг. */\n leashed = false;\n leashTo: THREE.Vector3 | null = null;\n /** Поводок привязан к забору в этой клетке (null — держит игрок). */\n leashPost: [number, number, number] | null = null;\n /** Караван лам: за кем идёт (id моба), не сохраняется. */\n followId: number | null = null;\n /** Поводок держит другой моб (ламы странствующего торговца), не сохраняется. */\n leashMobId: number | null = null;\n /** Сетевая игра: поводок держит игрок с этим слотом (null — свой игрок, одиночная игра). */\n leashSlot: number | null = null;\n /** Секунд до ухода (MobDef.leavesAfter); Infinity — не уходит. */\n leaveT = Infinity;\n /** С ним торгуют (открыт экран торговли) — стоит и смотрит на игрока. */\n trading = false;\n /** Невидим (странствующий торговец ночью — зелье невидимости). */\n invisible = false;\n private leashLine: THREE.Line | null = null;\n private readonly packParts: THREE.Object3D[] = [];\n private readonly armorParts: THREE.Object3D[] = [];\n private readonly outParts: THREE.Object3D[] = [];\n aggro = false;\n justHurt = false;\n /** Недавно бил игрок — за смерть дадут опыт. */\n hitByPlayerT = 0;\n /** Шипун взорвался сам — дропа нет, как в Minecraft. */\n exploded = false;\n sheared = false;\n /** Житель: сколько раз совершена каждая сделка (по индексу в списке профессии) до пополнения. */\n uses: number[] = [];\n /** Житель: занятый рабочий блок (systems/Jobs), null — безработный. */\n jobSite: [number, number, number] | null = null;\n /** Житель уже торговал — профессию не теряет, даже если рабочий блок сломали. */\n traded = false;\n /** Житель: опыт профессии (systems/Trading, levelOf) — открывает следующие сделки. */\n villagerXp = 0;\n /** Зомби-житель: секунд до выздоровления (0 — не лечится) и кем он был (systems/Zombify). */\n curing = 0;\n curedType: string | null = null;\n /** Секунд до следующего пополнения товара за работой у своего блока (не сохраняется). */\n workT = 0;\n /** Ручной (волк, кошка, лошадь), сидит по команде, осёдлан (лошадь). */\n tamed = false;\n sitting = false;\n saddled = false;\n /** Ручной волк: кого атакует (id моба). */\n targetId: number | null = null;\n /** На лошади сидит игрок (Player.riding): мозг молчит, ведёт седок. */\n rider = false;\n private readonly riderWish = { x: 0, z: 0, t: 0 };\n /** Где нарисован (интерполяция) — за это держится седок. */\n readonly renderPos = new THREE.Vector3();\n private readonly tameParts: THREE.Object3D[] = [];\n private readonly saddleParts: THREE.Object3D[] = [];\n /** Замах руки (атака, выстрел). */\n swing = 0;\n animTime = 0;\n flash = 0;\n private lavaT = 0;\n group: THREE.Group;\n mesh: THREE.Object3D;\n private readonly anims = new Map<string, AnimPart[]>();\n private readonly mats: {\n m: THREE.MeshLambertMaterial | THREE.MeshBasicMaterial;\n base: THREE.Color;\n glow: boolean;\n }[] = [];\n private readonly wool: THREE.Object3D[] = [];\n /** Свой моб/NPC из рецепта (entities/characterMobs): персонаж с аниматором вместо частей. */\n readonly character: CharacterActor | null = null;\n private charT = -1;\n private charFlash = 0;\n private charSwing = 0;\n private readonly charLast = new THREE.Vector3();\n private charMood: Emotion = \"neutral\";\n\n constructor(\n id: number,\n def: MobDef,\n x: number,\n y: number,\n z: number,\n baby = false,\n ) {\n this.id = id;\n this.def = def;\n this.hp = def.hp;\n this.baby = baby;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3();\n this.yaw = Math.random() * Math.PI * 2;\n this.targetYaw = this.yaw;\n this.stateTime = 1 + Math.random() * 2;\n\n // риг: каждая часть — Group в точке pivot, бокс смещён относительно pivot\n this.group = new THREE.Group();\n for (const p of def.parts) {\n const color = new THREE.Color(p.color);\n const mat = p.glow\n ? new THREE.MeshBasicMaterial({ color })\n : new THREE.MeshLambertMaterial({ color });\n this.mats.push({ m: mat, base: color.clone(), glow: !!p.glow });\n const mesh = new THREE.Mesh(partBox(p.size), mat);\n mesh.castShadow = mesh.receiveShadow = true;\n const pivot = new THREE.Group();\n const pv = p.pivot ?? p.pos;\n pivot.position.set(pv[0] * PX, pv[1] * PX, pv[2] * PX);\n mesh.position.set(\n (p.pos[0] - pv[0]) * PX,\n (p.pos[1] - pv[1]) * PX,\n (p.pos[2] - pv[2]) * PX,\n );\n if (p.pose) pivot.rotation.set(p.pose[0], p.pose[1], p.pose[2]);\n pivot.add(mesh);\n this.group.add(pivot);\n if (p.anim) {\n const list = this.anims.get(p.anim) ?? [];\n list.push({ g: pivot, base: pivot.rotation.clone() });\n this.anims.set(p.anim, list);\n }\n if (p.name.startsWith(\"wool\")) this.wool.push(pivot);\n if (p.name.startsWith(\"tame\")) this.tameParts.push(pivot);\n if (p.name.startsWith(\"saddle\")) this.saddleParts.push(pivot);\n if (p.name.startsWith(\"armor\")) this.armorParts.push(pivot);\n if (p.name.startsWith(\"out\")) this.outParts.push(pivot);\n if (p.name.startsWith(\"pack\")) this.packParts.push(pivot);\n if (p.name.startsWith(\"banner\")) this.bannerParts.push(pivot);\n }\n if (def.lifespan)\n this.lifeT =\n def.lifespan[0] + Math.random() * (def.lifespan[1] - def.lifespan[0]);\n if (def.leavesAfter)\n this.leaveT =\n def.leavesAfter[0] +\n Math.random() * (def.leavesAfter[1] - def.leavesAfter[0]);\n // овцы в Minecraft изредка бывают чёрные, серые, светло-серые, коричневые и совсем редко розовые\n if (def.dyeable) {\n const r = Math.random();\n this.woolColor =\n r < 0.05\n ? \"black\"\n : r < 0.1\n ? \"gray\"\n : r < 0.15\n ? \"light_gray\"\n : r < 0.18\n ? \"brown\"\n : r < 0.1816\n ? \"pink\"\n : null;\n }\n if (def.character) {\n const c = new CharacterActor(def.character.spec);\n this.group.add(c.group);\n (this as { character: CharacterActor | null }).character = c;\n }\n this.mesh = this.group; // единообразие с ItemDrop\n }\n\n /** Кадр персонажа: шаг по пройденному, удар, замах, настроение по состоянию мозга. */\n private _characterFrame(\n time: number,\n bright: number,\n emissive: number,\n ): void {\n const c = this.character!;\n const dt =\n this.charT < 0 ? 0 : Math.min(0.1, Math.max(0, time - this.charT));\n this.charT = time;\n const p = this.renderPos;\n const speed =\n dt > 0\n ? Math.hypot(p.x - this.charLast.x, p.z - this.charLast.z) / dt\n : 0;\n this.charLast.copy(p);\n if (this.flash > this.charFlash + 0.05) c.trigger(\"hurt\");\n this.charFlash = this.flash;\n if (this.swing > 0 && this.charSwing <= 0) c.trigger(\"swing\");\n this.charSwing = this.swing;\n const mood: Emotion = this.dead\n ? \"neutral\"\n : this.aggro || this.state === \"chase\"\n ? \"angry\"\n : this.state === \"flee\"\n ? \"scared\"\n : this.tamed || this.state === \"love\"\n ? \"happy\"\n : \"neutral\";\n if (mood !== this.charMood) {\n this.charMood = mood;\n c.setEmotion(mood);\n }\n c.update(dt, {\n x: 0,\n y: 0,\n z: 0,\n yaw: 0,\n bodyYaw: 0,\n pitch: 0,\n speed: Math.min(speed, 12),\n vy: this.vel.y,\n onGround: this.onGround,\n inWater: this.inWater,\n flying: !!this.def.flies,\n sitting: this.sitting,\n dead: this.dead,\n bright,\n });\n c.view.setEmissive(emissive);\n }\n\n /** Что даёт стрижка: шерсть своего цвета (овца), иначе shearDrop. */\n woolItem(): string {\n return this.woolColor\n ? `${this.woolColor}_wool`\n : (this.def.shearDrop?.item ?? \"white_wool\");\n }\n\n /** Высота седока над мобом (лошадь). */\n get seatY(): number {\n return this.def.rideable?.seat ?? 1;\n }\n\n /** Седок жмёт «вперёд»: куда вести лошадь (держится 0.15 с, седок повторяет каждый кадр). */\n push(dx: number, dz: number, _dt: number): void {\n this.riderWish.x = dx;\n this.riderWish.z = dz;\n this.riderWish.t = 0.15;\n }\n\n get width(): number {\n return this.def.box.w * (this.baby ? 0.5 : 1);\n }\n get height(): number {\n return this.def.box.h * (this.baby ? 0.5 : 1);\n }\n\n tick(dt: number, c: MobCtx) {\n this.prevPos.copy(this.pos);\n const dec = (v: number) => Math.max(0, v - dt);\n this.flash = dec(this.flash);\n this.attackCd = dec(this.attackCd);\n this.shootCd = dec(this.shootCd);\n this.loveT = dec(this.loveT);\n this.breedCd = dec(this.breedCd);\n this.hitByPlayerT = dec(this.hitByPlayerT);\n this.swing = dec(this.swing);\n // попугай на плече: едет с игроком (мозг и физика молчат); игрок присел или умер — слетает\n if (this.onShoulder !== null) {\n const p = c.player;\n if (p.dead || p._sneaking || p.shoulders[this.onShoulder] !== this.id) {\n if (p.shoulders[this.onShoulder] === this.id)\n p.shoulders[this.onShoulder] = null;\n this.onShoulder = null;\n this.shoulderCd = 3; // не запрыгивать сразу обратно\n } else {\n const side = this.onShoulder === 1 ? 1 : -1;\n this.pos.set(\n p.pos.x + Math.cos(p.yaw) * 0.4 * side,\n p.pos.y + 1.45,\n p.pos.z - Math.sin(p.yaw) * 0.4 * side,\n );\n this.vel.set(0, 0, 0);\n this.yaw = this.targetYaw = p.yaw;\n this.speed = 0;\n this.animTime += dt;\n return;\n }\n }\n this._tickEffects(dt);\n this.glowT = Math.max(0, this.glowT - dt);\n this.shoulderCd = dec(this.shoulderCd);\n this.rolledT = dec(this.rolledT);\n // странствующий торговец и его ламы: срок вышел — уходят (исчезают без дропа)\n if (this.leaveT !== Infinity) {\n this.leaveT -= dt;\n if (this.leaveT <= 0 && !this.dead) {\n this.dead = true; // hp > 0 — без дропа\n events.emit(\"mobLeft\", { entity: this });\n }\n }\n // броненосец сам сбрасывает щиток раз в 5–10 минут\n if (this.def.rollsUp && !this.baby) {\n this.scuteT -= dt;\n if (this.scuteT <= 0) {\n this.scuteT = 300 + Math.random() * 300;\n events.emit(\"mobDrop\", {\n x: this.pos.x,\n y: this.pos.y + 0.3,\n z: this.pos.z,\n item: \"armadillo_scute\",\n });\n }\n }\n // бесёнок: срок вышел — теряет по 1 здоровью в секунду\n if (this.def.lifespan) {\n this.lifeT -= dt;\n if (\n this.lifeT <= 0 &&\n Math.floor(this.lifeT) !== Math.floor(this.lifeT + dt)\n )\n this.damage(1);\n }\n if (this.baby) {\n this.growT += dt;\n if (this.growT >= GROW_TIME) {\n this.baby = false;\n if (this.def.growDrop) events.emit(\"mobGrew\", { entity: this }); // черепашонок — щиток\n }\n }\n // головастик: вырастает в другой вид (лягушку по биому — EntityManager, событие mobMature)\n // житель Пекла вне Пекла превращается (свинорыл → мёртвый свинорыл, хоглин → зоглин)\n if (\n this.def.growsInto &&\n !this.dead &&\n !(this.def.zombifies && c.world.dimension === \"inferno\")\n ) {\n this.growT += dt;\n if (this.growT >= (this.def.growTime ?? GROW_TIME)) {\n this.growT = -Infinity;\n events.emit(\"mobMature\", { entity: this });\n }\n }\n // Кристалл Предела: ни мозга, ни физики — висит над колонной\n if (this.def.stationary) {\n this.animTime += dt;\n return;\n }\n // панцирник сидит на блоке: мозг решает, открыться ли и стрелять; ни шагов, ни гравитации\n if (this.def.anchored) {\n think(this, c, dt);\n this.animTime += dt;\n return;\n }\n\n const world = c.world;\n const feet = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n this.inWater = feet === B.water || !!BLOCKS[feet]?.waterlogged;\n this.inLava = feet === B.lava;\n // рыба на суше: 5 с — и по 1 урону в секунду; бьётся, подпрыгивая\n if (this.def.needsWater) {\n if (this.inWater) this.dryT = 0;\n else {\n this.dryT += dt;\n // рыба задыхается через 5 с и бьётся на месте; аксолотль ходит по суше dryTime секунд\n const limit = this.def.dryTime ?? 5;\n if (\n this.dryT > limit &&\n Math.floor(this.dryT) !== Math.floor(this.dryT - dt)\n )\n this.damage(1);\n if (!this.def.dryTime && this.onGround && Math.random() < dt * 3)\n this.vel.y = 3;\n }\n }\n\n if (this.rider) {\n // под седоком: направление и галоп — от игрока; ступеньки перепрыгивает сам (hitWall ниже)\n const w = this.riderWish;\n w.t -= dt;\n if (w.t > 0 && (w.x || w.z)) {\n this.targetYaw = this.yaw = Math.atan2(-w.x, -w.z);\n this.speed = this.def.rideable?.speed ?? this.def.walkSpeed;\n } else this.speed = 0;\n } else think(this, c, dt);\n\n // плавный поворот к целевому направлению\n let dy = this.targetYaw - this.yaw;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n this.yaw += dy * Math.min(1, 6 * dt);\n\n const moveYaw = this.moveYaw ?? this.yaw;\n const slow = this.effects.has(\"slowness\") ? 0.5 : 1;\n const wishX = -Math.sin(moveYaw) * this.speed * slow;\n const wishZ = -Math.cos(moveYaw) * this.speed * slow;\n const accel = Math.min(1, 10 * dt);\n this.vel.x += (wishX - this.vel.x) * accel;\n this.vel.z += (wishZ - this.vel.z) * accel;\n if (this.def.hops) {\n // прыжками: на земле стоит и выжидает, в воздухе летит туда, куда смотрит\n if (this.onGround) {\n this.vel.x = 0;\n this.vel.z = 0;\n this.hopT -= dt;\n if (this.speed !== 0 && this.hopT <= 0) {\n this.vel.y = 7;\n this.hopT = 0.5 + Math.random() * 1.5;\n }\n } else {\n this.vel.x = wishX * 1.5;\n this.vel.z = wishZ * 1.5;\n }\n }\n // летун и пловец в воде: без гравитации, высоту задаёт мозг (climbWish)\n if (this.def.flies || (this.def.swims && this.inWater))\n this.vel.y += (this.climbWish - this.vel.y) * accel;\n else {\n this.vel.y -= 22 * dt;\n if (this.inWater || this.inLava) {\n // Плывёт, погрузившись наполовину (как в Minecraft): всплывает, пока середина тела в воде,\n // выше — медленно оседает. Прежде всплывал, пока мокрые хотя бы ноги, и стоял на воде.\n const mid = world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + this.height * 0.55),\n Math.floor(this.pos.z),\n );\n const deep =\n mid === B.water || mid === B.lava || !!BLOCKS[mid]?.waterlogged;\n this.vel.y = deep\n ? Math.max(this.vel.y, 1.2)\n : Math.max(this.vel.y, -0.6);\n }\n }\n\n if (this.def.noClip)\n this.pos.addScaledVector(this.vel, dt); // дракон: сквозь блоки\n else this._move(dt, world);\n // пустота под миром (край острова Предела): падал бы вечно — ручной волк или названная бирка\n // не исчезают по дальности и висели бы в списке сущностей навсегда. Пустота убивает, как в Minecraft\n if (this.pos.y < -64 && !this.def.boss && !this.dead) {\n this.hp = 0;\n this.dead = true;\n }\n\n // зомби осады ломает дверь, в которую упёрся (как в Minecraft на высокой сложности)\n // ⚠ у обычного враждебного моба aggro остаётся false: злость — это поле для НЕЙТРАЛЬНЫХ\n // (свинорыл, голем), а зомби враждебен по категории. Дверь ломает и тот, и другой.\n if (\n this.def.breaksDoors &&\n rules().doorBreaking && // только на высокой сложности (systems/Difficulty)\n (this.aggro || this.def.category === \"hostile\") &&\n this.speed !== 0\n )\n this._breakDoor(dt, world);\n else this.doorT = Math.max(0, this.doorT - dt); // короткий отскок не сбрасывает долбёжку\n\n // упёрся в стену на ходу — подпрыгнуть (на 1 блок); паук лезет по стене\n if (this.speed !== 0 && this.hitWall && !this.def.hops) {\n if (this.def.climbs) this.vel.y = 3;\n else if (this.onGround) this.vel.y = 7;\n }\n\n // лава и огонь жгут: 4 и 1 урона каждые полсекунды\n if (\n (this.inLava ||\n feet === B.fire ||\n feet === B.soul_fire ||\n feet === B.campfire ||\n feet === B.soul_campfire) &&\n !this.def.fireImmune\n ) {\n this.lavaT -= dt;\n if (this.lavaT <= 0) {\n this.lavaT = 0.5;\n this.damage(this.inLava ? 4 : 1);\n }\n }\n\n this.animTime +=\n dt * (this.speed !== 0 ? Math.hypot(this.vel.x, this.vel.z) * 3 : 0);\n }\n\n /** Шаг с коллизиями по коробкам форм — тот же код, что у игрока (physics/Collision). */\n _move(dt: number, world: ChunkManager) {\n const r = moveBody(\n world,\n {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n hw: this.width / 2,\n h: this.height,\n },\n this.vel.x * dt,\n this.vel.y * dt,\n this.vel.z * dt,\n // страйдер стоит на лаве, как на полу\n {\n step: this.onGround ? 0.6 : 0,\n onGround: this.onGround,\n ...(this.def.walksOnLava ? { extraSolid: isLava } : {}),\n },\n );\n this.pos.x += r.dx;\n this.pos.y += r.dy;\n this.pos.z += r.dz;\n if (r.hitDown) {\n this.onGround = true;\n this.vel.y = 0;\n } else {\n this.onGround = false;\n if (r.hitUp) this.vel.y = 0;\n }\n if (r.hitX) this.vel.x = 0;\n if (r.hitZ) this.vel.z = 0;\n this.hitWall = r.hitX || r.hitZ;\n }\n\n /** Стрелы отскакивают: Пепельный владыка на половине здоровья, закрытый панцирник. */\n shielded(): boolean {\n return (\n (this.def.boss === \"ash_lord\" && this.hp <= this.def.hp / 2) ||\n (!!this.def.shell && this.peek < 0.3)\n );\n }\n\n /** Урон без отброса (солнце, лава, падение). */\n damage(amount: number) {\n if (this.dead || this.invulnerable) return;\n this.hp -= amount;\n this.flash = 0.4;\n events.emit(\"mobHurt\", { entity: this, amount });\n if (this.hp <= 0) {\n if (this.def.boss === \"dragon\")\n this.hp = 0; // дракон умирает сам — после анимации (DragonBrain)\n else this.dead = true;\n }\n }\n\n /** Удар: урон, отброс от атакующего, вспышка; мирный убегает, остальные злятся. */\n /** Дать имя с бирки: табличка над головой. Пустое имя — снять. */\n setName(name: string | null): void {\n const clean = (name ?? \"\").trim().slice(0, 24);\n this.customName = clean || null;\n if (this.nameSprite) {\n this.group.remove(this.nameSprite);\n this.nameSprite.material.map?.dispose();\n this.nameSprite.material.dispose();\n this.nameSprite = null;\n }\n if (!this.customName) return;\n const c = document.createElement(\"canvas\");\n c.width = 256;\n c.height = 64;\n const g = c.getContext(\"2d\")!;\n g.font = \"bold 34px monospace\";\n g.textAlign = \"center\";\n g.textBaseline = \"middle\";\n g.fillStyle = \"rgba(0,0,0,0.45)\";\n g.fillRect(0, 12, 256, 40);\n g.fillStyle = \"#ffffff\";\n g.fillText(this.customName, 128, 32, 248);\n const tex = new THREE.CanvasTexture(c);\n tex.colorSpace = THREE.SRGBColorSpace;\n const s = new THREE.Sprite(\n new THREE.SpriteMaterial({\n map: tex,\n depthTest: false,\n transparent: true,\n }),\n );\n s.scale.set(1.6, 0.4, 1);\n s.position.set(0, this.height + 0.45, 0);\n this.group.add(s);\n this.nameSprite = s;\n }\n\n /** Повесить эффект (дольше уже висящего — продлить). */\n applyEffect(id: EffectId, seconds: number): void {\n this.effects.set(id, Math.max(this.effects.get(id) ?? 0, seconds));\n }\n\n /** Сроки эффектов и урон от яда — зовётся из tick. */\n private _tickEffects(dt: number): void {\n if (!this.effects.size) return;\n for (const [id, t] of this.effects)\n if (t - dt <= 0) this.effects.delete(id);\n else this.effects.set(id, t - dt);\n if (!this.effects.has(\"poison\")) {\n this._poisonT = 0;\n return;\n }\n this._poisonT += dt;\n if (this._poisonT >= 2) {\n this._poisonT = 0;\n if (this.hp > 1) this.damage(1); // яд не добивает, как в Minecraft\n }\n }\n\n /** Секунд, что моб уже ломится в дверь перед собой. */\n private doorT = 0;\n\n /** Долбить дверь перед собой: через DOOR_TIME секунд она ломается совсем. */\n private _breakDoor(dt: number, world: ChunkManager): void {\n const ahead = new THREE.Vector3(\n -Math.sin(this.yaw),\n 0,\n -Math.cos(this.yaw),\n );\n const x = Math.floor(this.pos.x + ahead.x * 0.7),\n z = Math.floor(this.pos.z + ahead.z * 0.7),\n y = Math.floor(this.pos.y);\n const id = world.getBlock(x, y, z);\n if (BLOCKS[id]?.shape !== \"door\") {\n this.doorT = Math.max(0, this.doorT - dt);\n return;\n }\n this.doorT += dt;\n if (this.doorT < DOOR_TIME) return;\n this.doorT = 0;\n // дверь из двух половин: сносим обе, обломков не остаётся\n world.setBlock(x, y, z, B.air!);\n if (BLOCKS[world.getBlock(x, y + 1, z)]?.shape === \"door\")\n world.setBlock(x, y + 1, z, B.air!);\n if (BLOCKS[world.getBlock(x, y - 1, z)]?.shape === \"door\")\n world.setBlock(x, y - 1, z, B.air!);\n events.emit(\"doorBroken\", { x, y, z });\n }\n\n hurt(damage: number, fromPos: THREE.Vector3, byPlayer = true) {\n if (this.dead || this.invulnerable) return;\n this.justHurt = true;\n // броня волка берёт урон на себя, пока не износится\n if (this.wolfArmor > 0) {\n const a = Math.min(this.wolfArmor, damage);\n this.wolfArmor -= a;\n damage -= a;\n if (this.wolfArmor <= 0) events.emit(\"wolfArmorBroke\", { entity: this });\n }\n // броненосец: свёрнутый держит удар (−3), любой удар сворачивает на 6 с\n if (this.def.rollsUp) {\n if (this.rolledT > 0) damage = Math.max(0, damage - 3);\n this.rolledT = 6;\n }\n if (byPlayer) {\n this.hitByPlayerT = 5;\n events.emit(\"playerHitMob\", { entity: this }); // ручные волки бросаются на обидчика\n }\n const away = this.pos.clone().sub(fromPos).setY(0);\n if (away.lengthSq() < 1e-6)\n away.set(Math.random() - 0.5, 0, Math.random() - 0.5);\n away.normalize();\n if (!this.def.boss) this.vel.add(away.clone().multiplyScalar(6)).setY(5); // дракона не отбросить\n if (this.def.category === \"passive\") {\n this.state = \"flee\";\n // Ход моба — (-sin yaw, -cos yaw), и этот yaw даёт ход вдоль `away`, т.е. прочь от атакующего.\n this.targetYaw = Math.atan2(-away.x, -away.z);\n this.stateTime = 3;\n } else if (!this.tamed) this.aggro = true; // ручной на хозяина не злится\n if (byPlayer && this.def.groupAnger) this.angerCall = true;\n this.damage(damage);\n }\n\n updateVisual(alpha: number, time: number, bright = 1) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.group.position.copy(p);\n this.renderPos.copy(p);\n this._drawBeam(p);\n this._drawLeash();\n this.group.visible = !this.invisible;\n this.group.rotation.y = this.yaw;\n // Шипун раздувается перед взрывом\n // иглобрюх раздувается шаром\n const swell = this.def.explode\n ? 1 + (this.fuse / this.def.explode.fuse) * 0.18\n : this.def.puffs\n ? 1 + this.peek * 0.9\n : 1;\n this.group.scale.setScalar((this.baby ? 0.5 : 1) * swell);\n\n // анимации: шаг ногами, руки в такт (или замах), покачивание головы, крылья при падении\n const walk = Math.sin(this.animTime * 6) * 0.7;\n const swingArm =\n this.swing > 0 ? Math.sin((this.swing / 0.3) * Math.PI) * 1.2 : 0;\n const set = (name: string, fn: (a: AnimPart) => void) =>\n this.anims.get(name)?.forEach(fn);\n set(\"legL\", (a) => (a.g.rotation.x = a.base.x + walk));\n set(\"legR\", (a) => (a.g.rotation.x = a.base.x - walk));\n set(\"armL\", (a) => (a.g.rotation.x = a.base.x - walk * 0.5 - swingArm));\n set(\"armR\", (a) => (a.g.rotation.x = a.base.x + walk * 0.5 - swingArm));\n set(\n \"head\",\n (a) => (a.g.rotation.x = a.base.x + Math.sin(time * 1.7) * 0.08),\n );\n set(\"lid\", (a) => (a.g.rotation.x = a.base.x + this.peek * 0.9));\n const falling = !this.onGround && this.vel.y < -1;\n // курица хлопает, только падая; летуны машут всегда (дракон — медленно и широко)\n const wing = this.def.boss\n ? Math.sin(time * 3) * 0.6\n : falling || this.def.flies\n ? Math.sin(time * 30) * 0.9\n : 0.05;\n set(\"wingL\", (a) => (a.g.rotation.z = wing));\n set(\"wingR\", (a) => (a.g.rotation.z = -wing));\n for (const w of this.wool) w.visible = !this.sheared;\n // окрашенная овца: шерсти — свой материал цвета красителя (общие материалы не трогаем)\n if (this.woolShown !== this.woolColor) {\n this.woolShown = this.woolColor;\n const hex = DYE_COLORS.find(\n (d) => d.id === (this.woolColor ?? \"white\"),\n )?.hex;\n if (hex)\n for (const w of this.wool)\n w.traverse((o) => {\n if (!(o instanceof THREE.Mesh)) return;\n const mat = (o.material as THREE.MeshLambertMaterial).clone();\n mat.color.set(hex);\n o.material = mat;\n });\n }\n for (const t of this.tameParts) t.visible = this.tamed;\n for (const s of this.saddleParts) s.visible = this.saddled;\n for (const a of this.armorParts) a.visible = this.wolfArmor > 0;\n for (const o of this.outParts) o.visible = this.rolledT <= 0; // свёрнутый броненосец — один панцирь\n for (const k of this.packParts) k.visible = this.items.length > 0;\n for (const b of this.bannerParts) b.visible = this.captain;\n\n // свет места (пещера темнее), вспышка урона, горение на солнце, мигание фитиля\n const fuseBlink =\n this.fuse > 0 && Math.sin(time * (8 + this.fuse * 12)) > 0;\n // владыка: набирая силу, мерцает синим; в броне — синеватый отлив\n const emissive =\n this.flash > 0\n ? 0xff3333\n : this.burning && Math.sin(time * 20) > 0\n ? 0xff7a1a\n : fuseBlink\n ? 0xffffff\n : this.invulnerable\n ? Math.sin(time * 10) > 0\n ? 0x3050ff\n : 0x000000\n : this.shielded()\n ? 0x1a2a60\n : 0x000000;\n if (this.character) this._characterFrame(time, bright, emissive);\n for (const { m, base, glow } of this.mats) {\n if (glow) continue;\n m.color.copy(base).multiplyScalar(this.glowT > 0 ? 2.4 : bright);\n const lm = m as THREE.MeshLambertMaterial;\n if (lm.emissive.getHex() !== emissive) lm.emissive.setHex(emissive);\n }\n }\n\n /** Луч глубинника: линия от глаза к цели, от лилового к жёлтому по мере зарядки. */\n /** Верёвка поводка — дочерняя линия группы (уходит вместе с мобом): от шеи до руки игрока. */\n private _drawLeash(): void {\n const to = this.leashTo; // к держателю поводка или к ламе впереди в караване (задаёт мозг)\n if (!to) {\n if (this.leashLine) this.leashLine.visible = false;\n return;\n }\n if (!this.leashLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.leashLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x8a6a3a }),\n );\n this.group.add(this.leashLine);\n }\n this.leashLine.visible = true;\n this.group.updateMatrixWorld();\n const s = this.group.scale.x || 1;\n const local = this.group.worldToLocal(to.clone());\n const a = this.leashLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, 0, (this.height * 0.8) / s, 0);\n a.setXYZ(1, local.x, local.y, local.z);\n a.needsUpdate = true;\n }\n\n private _drawBeam(p: THREE.Vector3): void {\n const to = this.beamTo;\n if (!to || !this.group.parent) {\n if (this.beamLine) this.beamLine.visible = false;\n return;\n }\n if (!this.beamLine) {\n const geo = new THREE.BufferGeometry().setFromPoints([\n new THREE.Vector3(),\n new THREE.Vector3(),\n ]);\n this.beamLine = new THREE.Line(\n geo,\n new THREE.LineBasicMaterial({ color: 0x9a60ff }),\n );\n this.group.parent.add(this.beamLine);\n }\n const a = this.beamLine.geometry.getAttribute(\n \"position\",\n ) as THREE.BufferAttribute;\n a.setXYZ(0, p.x, p.y + this.height / 2, p.z);\n a.setXYZ(1, to.x, to.y, to.z);\n a.needsUpdate = true;\n const k = Math.min(1, this.beamT / 3);\n (this.beamLine.material as THREE.LineBasicMaterial).color.setRGB(\n 0.6 + 0.4 * k,\n 0.4 + 0.5 * k,\n 1 - 0.8 * k,\n );\n this.beamLine.visible = true;\n }\n\n /** Тень отбрасывают только мобы рядом с игроком: дальние лишь удлиняют теневой проход. */\n private shadowOn = true;\n setShadow(on: boolean): void {\n if (on === this.shadowOn) return;\n this.shadowOn = on;\n this.group.traverse((o) => {\n if ((o as THREE.Mesh).isMesh) o.castShadow = on;\n });\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.group);\n if (this.beamLine) {\n scene.remove(this.beamLine);\n this.beamLine.geometry.dispose();\n (this.beamLine.material as THREE.Material).dispose();\n }\n // traverse даёт Object3D; узкий каст к Mesh для доступа к geometry (проверка isMesh — рантайм).\n this.group.traverse((o) => {\n const g = (o as THREE.Mesh).isMesh ? (o as THREE.Mesh).geometry : null;\n if (g && !g.userData.shared) g.dispose(); // коробки частей общие на всех мобов\n });\n for (const { m } of this.mats) m.dispose();\n this.character?.dispose();\n }\n\n serialize() {\n return {\n kind: \"mob\",\n type: this.def.name,\n pos: this.pos.toArray(),\n hp: this.hp,\n baby: this.baby,\n sheared: this.sheared,\n ...(this.uses.length ? { uses: [...this.uses] } : {}),\n ...(this.jobSite ? { jobSite: [...this.jobSite] } : {}),\n ...(this.traded ? { traded: true } : {}),\n ...(this.villagerXp > 0 ? { villagerXp: this.villagerXp } : {}),\n ...(this.curedType ? { curedType: this.curedType } : {}),\n ...(this.curing > 0 ? { curing: Math.round(this.curing) } : {}),\n ...(this.tamed ? { tamed: true } : {}),\n ...(this.customName ? { customName: this.customName } : {}),\n ...(this.sitting ? { sitting: true } : {}),\n ...(this.saddled ? { saddled: true } : {}),\n ...(this.woolColor ? { woolColor: this.woolColor } : {}),\n ...(this.wolfArmor > 0 ? { wolfArmor: this.wolfArmor } : {}),\n ...(this.carry ? { carry: this.carry.item, bag: this.bag } : {}),\n ...(this.items.length\n ? { items: this.items.map((s) => (s ? { ...s } : null)) }\n : {}),\n ...(this.leashed ? { leashed: true } : {}),\n ...(this.leashPost ? { leashPost: [...this.leashPost] } : {}),\n ...(this.leashSlot !== null ? { leashSlot: this.leashSlot } : {}),\n ...(this.leaveT !== Infinity ? { leaveT: Math.round(this.leaveT) } : {}),\n ...(this.home ? { home: [...this.home] } : {}),\n ...(this.pollen ? { pollen: true } : {}),\n ...(this.captain ? { captain: true } : {}),\n // рецепт своего моба едет с ним: мир открывается и там, где этого рецепта нет\n ...(this.def.character ? { look: this.def.character.spec } : {}),\n };\n }\n}\n"
|
|
378
295
|
},
|
|
379
296
|
{
|
|
380
297
|
"path": "entities/MobBrain.ts",
|
|
@@ -418,7 +335,7 @@
|
|
|
418
335
|
},
|
|
419
336
|
{
|
|
420
337
|
"path": "game/characterWire.ts",
|
|
421
|
-
"content": "// Персонаж игрока и события игры: замах, удар, эмоции по EMOTION_EVENTS (character/anim/emotions),\n// камера F5. Таблица «событие → эмоция» — данные; здесь только, какое событие игры чем считается.\nimport { events } from \"../core/EventBus\";\nimport { EMOTION_EVENTS, type EmotionEvent } from \"
|
|
338
|
+
"content": "// Персонаж игрока и события игры: замах, удар, эмоции по EMOTION_EVENTS (character/anim/emotions),\n// камера F5. Таблица «событие → эмоция» — данные; здесь только, какое событие игры чем считается.\nimport { events } from \"../core/EventBus\";\nimport { EMOTION_EVENTS, type EmotionEvent } from \"@idosgames/characters/voxel\";\nimport type { Game } from \"../main\";\nimport { tr } from \"../i18n\";\n\nexport function wireCharacter(game: Game): void {\n const actor = () => game.character.actor;\n const feel = (ev: EmotionEvent) => {\n const e = EMOTION_EVENTS[ev];\n actor().setEmotion(e.emotion, \"seconds\" in e ? e.seconds : undefined);\n };\n events.on(\"view:cycleCamera\", () => {\n const mode = game.character.cycleCamera();\n game.hud.message(\n mode === \"first\"\n ? tr({ en: \"First-person view\", ru: \"Вид от первого лица\" })\n : mode === \"back\"\n ? tr({ en: \"View from behind\", ru: \"Вид сзади\" })\n : tr({ en: \"View from the front\", ru: \"Вид спереди\" }),\n 900,\n );\n });\n events.on(\"armSwing\", () => actor().trigger(\"swing\"));\n events.on(\"playerHurt\", () => {\n actor().trigger(\"hurt\");\n feel(\"damaged\");\n });\n events.on(\"achievement\", () => feel(\"achievement\"));\n events.on(\"questDone\", () => feel(\"achievement\"));\n events.on(\"levelUp\", () => feel(\"achievement\"));\n events.on(\"mobTamed\", () => feel(\"tamed\"));\n events.on(\"trade\", () => feel(\"traded\"));\n events.on(\"dragonDefeated\", () => feel(\"bossKilled\"));\n events.on(\"raidWon\", () => feel(\"bossKilled\"));\n events.on(\n \"explosion\",\n (p: { x: number; y: number; z: number; power?: number }) => {\n const pp = game.player.pos;\n if ((p.x - pp.x) ** 2 + (p.y - pp.y) ** 2 + (p.z - pp.z) ** 2 < 16 * 16)\n feel(\"explosion\");\n },\n );\n}\n"
|
|
422
339
|
},
|
|
423
340
|
{
|
|
424
341
|
"path": "game/environment.ts",
|
|
@@ -434,11 +351,11 @@
|
|
|
434
351
|
},
|
|
435
352
|
{
|
|
436
353
|
"path": "game/npcs.ts",
|
|
437
|
-
"content": "// NPC из рецепта мира (WorldSpec.npcs): встают в мир, как только прогрузилось их место (раньше —\n// провалились бы сквозь незагруженную землю), каждый — один раз. Ещё не вставшие лежат в сейве\n// (pendingNpcs). Облик — по имени (character/npcLook), поведение — пресет \"npc\"\n// (entities/characterMobs): гуляет рядом, не исчезает, с именем над головой.\nimport type { WorldSpec } from \"../worldgen/WorldSpec\";\nimport { specBlocks } from \"../worldgen/WorldSpec\";\nimport { npcCharacter } from \"
|
|
354
|
+
"content": "// NPC из рецепта мира (WorldSpec.npcs): встают в мир, как только прогрузилось их место (раньше —\n// провалились бы сквозь незагруженную землю), каждый — один раз. Ещё не вставшие лежат в сейве\n// (pendingNpcs). Облик — по имени (character/npcLook), поведение — пресет \"npc\"\n// (entities/characterMobs): гуляет рядом, не исчезает, с именем над головой.\nimport type { WorldSpec } from \"../worldgen/WorldSpec\";\nimport { specBlocks } from \"../worldgen/WorldSpec\";\nimport { npcCharacter } from \"@idosgames/characters/voxel\";\nimport type { Game } from \"../main\";\n\nexport interface PendingNpc {\n name: string;\n role: string;\n persona: string;\n x: number;\n z: number;\n}\n\n/** Новый мир по рецепту: NPC — в очередь, встанут по мере прогрузки. */\nexport function queueSpecNpcs(game: Game, spec: WorldSpec): void {\n const { W, D } = specBlocks(spec);\n game.pendingNpcs = spec.npcs.map((n) => ({\n name: n.name,\n role: n.role,\n persona: n.persona,\n x: Math.floor(n.x * (W - 1)),\n z: Math.floor(n.z * (D - 1)),\n }));\n}\n\n/** Раз в секунду: тех, чьё место прогрузилось, поставить на землю. */\nexport function tickNpcs(game: Game): void {\n if (!game.pendingNpcs.length || game.dimension !== \"overworld\") return;\n const w = game.world;\n game.pendingNpcs = game.pendingNpcs.filter((n) => {\n if (!w.chunkAt(n.x, n.z)?.generated) return true; // ещё не прогружено — ждём\n // земля: сверху вниз первая твёрдая клетка с воздухом над ней\n for (let y = 120; y > 1; y--)\n if (w.isSolid(n.x, y, n.z) && !w.isSolid(n.x, y + 1, n.z)) {\n const spec = npcCharacter(n.name, n.role, n.persona);\n const m = game.summonCharacterAt(\n spec,\n \"npc\",\n n.x + 0.5,\n y + 1,\n n.z + 0.5,\n );\n return !m; // не вышло — попробуем позже\n }\n return false; // земли нет (вода до дна, пустота) — не ставим\n });\n}\n"
|
|
438
355
|
},
|
|
439
356
|
{
|
|
440
357
|
"path": "game/playerCharacter.ts",
|
|
441
|
-
"content": "// Персонаж своего игрока: тело в мире (видно от третьего лица и другим игрокам — у них свой Avatar),\n// рука от первого лица из того же рецепта, камера F5 (от первого лица → сзади → спереди).\nimport * as THREE from \"three\";\nimport { stackSkinHash } from \"../character/items/skins\";\nimport type { ItemSkinSpec, WearSlot } from \"../character/items/ItemSkinSpec\";\nimport type
|
|
358
|
+
"content": "// Персонаж своего игрока: тело в мире (видно от третьего лица и другим игрокам — у них свой Avatar),\n// рука от первого лица из того же рецепта, камера F5 (от первого лица → сзади → спереди).\nimport * as THREE from \"three\";\nimport { stackSkinHash } from \"../character/items/skins\";\nimport type { ItemSkinSpec, WearSlot } from \"../character/items/ItemSkinSpec\";\nimport {\n type EmoteId,\n type CharacterSpec,\n type UseAction,\n EMOTION_EVENTS,\n type Emotion,\n} from \"@idosgames/characters/voxel\";\nimport { CharacterPreview } from \"../character/CharacterPreview\";\nimport { CharacterActor } from \"../character/CharacterActor\";\nimport type { CharacterStore } from \"../systems/CharacterStore\";\nimport type { Looks } from \"../character/Looks\";\nimport { brightnessAt } from \"../character/light\";\nimport { ITEMS } from \"../registry/Items\";\nimport { CONFIG } from \"../config\";\nimport type { Game } from \"../main\";\n\nexport type CameraMode = \"first\" | \"back\" | \"front\";\nconst MODES: CameraMode[] = [\"first\", \"back\", \"front\"];\n/** Как далеко отъезжает камера от глаз, блоков. */\nconst CAM_DIST = 4;\n/** Сдвиг камеры «сзади» вправо и вверх (в осях камеры), блоков. */\nconst SHOULDER = new THREE.Vector3(0.9, 0.45, 0);\n\nexport class PlayerCharacter {\n readonly actor: CharacterActor;\n camera: CameraMode = \"first\";\n private readonly last = new THREE.Vector3();\n private placed = false;\n private speed = 0;\n private vy = 0;\n /** Фоновое настроение по здоровью (EMOTION_EVENTS lowHealth / nearDeath). */\n private mood: Emotion = \"neutral\";\n private crosshair: HTMLElement | null = null;\n private readonly aim = new THREE.Vector3();\n private readonly offLooks: () => void;\n\n /**\n * `looks` — облик игрока, общий с лобби (character/Looks): сменили персонажа или надетое в лобби —\n * тело, рука и кукла здесь следуют за ним.\n */\n constructor(\n private readonly game: Game,\n readonly looks: Looks,\n ) {\n this.actor = new CharacterActor(looks.spec, { atlas: game.atlas });\n this.actor.group.visible = false;\n game.scene.add(this.actor.group);\n game.armView.setCharacter(looks.spec);\n this.actor.setOutfit(looks.outfit);\n let shown = looks.spec;\n this.offLooks = looks.subscribe(() => {\n if (looks.spec !== shown) {\n shown = looks.spec;\n this.actor.setSpec(shown);\n this.game.armView.setCharacter(shown);\n this.doll?.setSpec(shown);\n }\n this.actor.setOutfit(looks.outfit);\n this.doll?.actor.setOutfit(looks.outfit);\n });\n }\n\n get spec(): CharacterSpec {\n return this.looks.spec;\n }\n\n /** Надетое: слот → хэш облика. */\n get outfit(): Partial<Record<WearSlot, string>> {\n return this.looks.outfit;\n }\n\n /** «Мои персонажи» и «Мои предметы» (null — браузер не даёт IndexedDB). */\n store(): Promise<CharacterStore | null> {\n return this.looks.store();\n }\n\n /** Надеть или снять косметику (облик null — снять). Запоминается между запусками. */\n wear(slot: WearSlot, hash: string | null): void {\n this.looks.wear(slot, hash);\n }\n\n /** Сменить своего персонажа: тело, рука, облик для других игроков. */\n setSpec(spec: CharacterSpec): void {\n this.looks.setSpec(spec);\n }\n\n /** Играть этим персонажем: сменить, сохранить в «Мои» и запомнить выбор. */\n useCharacter(spec: CharacterSpec): Promise<void> {\n return this.looks.useCharacter(spec);\n }\n\n /** Сохранить в «Мои персонажи»; false — хранилища нет. */\n saveCharacter(spec: CharacterSpec): Promise<boolean> {\n return this.looks.saveCharacter(spec);\n }\n\n /** Сохранить облик в «Мои предметы» (и зарегистрировать); false — хранилища нет. */\n saveItem(spec: ItemSkinSpec): Promise<boolean> {\n return this.looks.saveItem(spec);\n }\n\n /** Кукла в инвентаре: свой холст и маленькая сцена, создаётся при первом открытии. */\n private doll: CharacterPreview | null = null;\n\n dollCanvas(): HTMLCanvasElement {\n if (!this.doll) {\n const c = document.createElement(\"canvas\");\n c.className = \"dollCanvas\";\n this.doll = new CharacterPreview(c, this.spec, this.game.atlas);\n this.doll.actor.setOutfit(this.outfit);\n }\n const held = this.game.inventory.heldItem();\n this.doll.held = held?.item ?? null;\n this.doll.heldSkin = stackSkinHash(held);\n this.doll.start();\n return this.doll.canvas;\n }\n\n stopDoll(): void {\n this.doll?.stop();\n }\n\n /** Сыграть жест: свой персонаж и все в сетевой игре. */\n emote(id: EmoteId): void {\n this.actor.playEmote(id);\n this.game.net?.sendEmote({ emote: id });\n }\n\n /** Эмоция на время (смайлик, панель жестов, ИИ-NPC — тем же путём). */\n feel(e: Emotion, seconds = 4): void {\n this.actor.setEmotion(e, seconds);\n this.game.net?.sendEmote({ emotion: e, seconds });\n }\n\n cycleCamera(): CameraMode {\n this.camera = MODES[(MODES.indexOf(this.camera) + 1) % MODES.length]!;\n return this.camera;\n }\n\n private useAction(): UseAction | null {\n const g = this.game;\n const it = g.interaction;\n if (g.player.blocking) return \"block\";\n if (it._drawing) return \"draw\";\n if (it.eating) return ITEMS[it.eating.item]?.drink ? \"drink\" : \"eat\";\n return null;\n }\n\n /** Кадр: тело за игроком; от третьего лица — камера отъезжает (не сквозь блоки). */\n frame(dt: number): void {\n const g = this.game;\n const p = g.player;\n if (!this.placed) {\n this.last.copy(p.pos);\n this.placed = true;\n }\n if (dt > 0) {\n const v = Math.hypot(p.pos.x - this.last.x, p.pos.z - this.last.z) / dt;\n this.speed += (Math.min(v, 30) - this.speed) * Math.min(1, dt * 12);\n this.vy +=\n ((p.pos.y - this.last.y) / dt - this.vy) * Math.min(1, dt * 12);\n }\n // телепорт — без «бега» на полкарты\n if (this.last.distanceToSquared(p.pos) > 64) this.speed = 0;\n this.last.copy(p.pos);\n\n const mood: Emotion =\n p.dead || p.mode === \"creative\"\n ? \"neutral\"\n : p.health <= 4\n ? EMOTION_EVENTS.nearDeath.emotion\n : p.health <= 8\n ? EMOTION_EVENTS.lowHealth.emotion\n : \"neutral\";\n if (mood !== this.mood) {\n this.mood = mood;\n this.actor.setEmotion(mood);\n }\n\n const third = this.camera !== \"first\";\n this.actor.group.visible = third;\n g.armView.group.visible = !third;\n this.actor.update(dt, {\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n speed: this.speed,\n vy: this.vy,\n onGround: p.onGround,\n inWater: p.inWater,\n flying: p.flying,\n gliding: p.gliding,\n sneaking: p._sneaking,\n sprinting: p._sprinting,\n riding: !!p.riding,\n sleeping: g.netSleeping,\n dead: p.dead,\n held: g.inventory.heldItem()?.item ?? null,\n heldSkin: stackSkinHash(g.inventory.heldItem()),\n use: this.useAction(),\n bright: brightnessAt(g.world, p.pos.x, p.pos.y + 1, p.pos.z),\n });\n if (third) this.placeCamera();\n this.placeCrosshair();\n }\n\n /**\n * Камера сзади (через правое плечо — голова не закрывает прицел) или спереди на CAM_DIST,\n * ближе — если мешает блок.\n */\n private placeCamera(): void {\n const g = this.game;\n const cam = g.camera;\n const eye = cam.position.clone();\n const look = g.player.lookDir();\n const off =\n this.camera === \"back\"\n ? look\n .clone()\n .multiplyScalar(-CAM_DIST)\n .add(SHOULDER.clone().applyQuaternion(cam.quaternion))\n : look.clone().multiplyScalar(CAM_DIST);\n const len = off.length();\n const dir = off.divideScalar(len);\n let dist = len;\n for (let t = 0.3; t <= len; t += 0.1) {\n const x = eye.x + dir.x * t,\n y = eye.y + dir.y * t,\n z = eye.z + dir.z * t;\n if (g.world.isSolid(Math.floor(x), Math.floor(y), Math.floor(z))) {\n dist = Math.max(0.3, t - 0.3);\n break;\n }\n }\n cam.position.addScaledVector(dir, dist);\n if (this.camera === \"front\")\n cam.rotation.set(-g.player.pitch, g.player.yaw + Math.PI, 0);\n }\n\n /**\n * Прицел — туда, куда смотрит луч из глаз (камера сзади смещена, центр экрана с ним не совпадает).\n * Спереди луч уходит за камеру — прицел прячется.\n */\n private placeCrosshair(): void {\n const el = (this.crosshair ??= document.getElementById(\"crosshair\"));\n if (!el) return;\n if (this.camera === \"first\") {\n el.style.cssText = \"\";\n return;\n }\n if (this.camera === \"front\") {\n el.style.display = \"none\";\n return;\n }\n const g = this.game;\n const p = g.player;\n const it = g.interaction;\n const ent = it.targetEntity;\n const eye = p.eyePos;\n const d = ent\n ? eye.distanceTo(\n this.aim.set(ent.pos.x, ent.pos.y + ent.height / 2, ent.pos.z),\n )\n : (it.target?.dist ?? CONFIG.player.reach);\n const cam = g.camera;\n cam.updateMatrixWorld();\n this.aim.copy(eye).addScaledVector(p.lookDir(), d).project(cam);\n const r = (\n g.renderer.domElement as HTMLCanvasElement\n ).getBoundingClientRect();\n el.style.display = \"\";\n el.style.left = r.left + ((this.aim.x + 1) / 2) * r.width + \"px\";\n el.style.top = r.top + ((1 - this.aim.y) / 2) * r.height + \"px\";\n }\n\n dispose(): void {\n this.offLooks();\n this.actor.dispose();\n this.doll?.dispose();\n this.doll = null;\n }\n}\n"
|
|
442
359
|
},
|
|
443
360
|
{
|
|
444
361
|
"path": "game/portals.ts",
|
|
@@ -526,7 +443,7 @@
|
|
|
526
443
|
},
|
|
527
444
|
{
|
|
528
445
|
"path": "gfx/itemModel.ts",
|
|
529
|
-
"content": "// Модель предмета «в руке»: мини-куб блока или объёмный предмет из иконки (как в Minecraft). Общая для руки от первого лица\n// (player/ArmView) и руки персонажа от третьего лица (character/CharacterActor). Облик предмета\n// от ИИ (tag.skin) подменяет её своей моделью — character/items.\nimport * as THREE from \"three\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { HeldKind } from \"../character/anim/types\";\nimport type { TextureAtlas } from \"./TextureAtlas\";\n\n/** Материал плоского предмета — один на предмет на всю игру. */\nconst ICON_MATS = new Map<string, THREE.MeshBasicMaterial>();\nconst CUBES = new Map<string, THREE.BufferGeometry>();\n\n/** Куб блока с гранями из атласа; белые вершинные цвета — общий материал чанков их ждёт. */\nexport function blockCube(\n atlas: TextureAtlas,\n key: string,\n size: number,\n): THREE.BufferGeometry | null {\n const item = ITEMS[key];\n if (!item || item.blockId === null) return null;\n const def = BLOCKS[item.blockId];\n if (!def || def.shape !== \"cube\" || !def.tex?.side) return null;\n const ck = key + \"@\" + size;\n const hit = CUBES.get(ck);\n if (hit) return hit;\n const geo = new THREE.BoxGeometry(size, size, size);\n const uvAttr = geo.getAttribute(\"uv\");\n const side = def.tex.side;\n const tiles = [\n side,\n side,\n def.tex.top ?? side,\n def.tex.bottom ?? side,\n side,\n 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 geo.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(geo.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n geo.userData.shared = true;\n CUBES.set(ck, geo);\n return geo;\n}\n\n/** Материал иконки предмета (null — у предмета нет иконки). */\nexport function iconMaterial(key: string): THREE.MeshBasicMaterial | null {\n const item = ITEMS[key];\n if (!item?.icon) return null;\n let mat = ICON_MATS.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 ICON_MATS.set(key, mat);\n }\n return mat;\n}\n\n/** Что персонаж держит — поза руки и действия аниматора (character/anim). */\nexport function heldKindOf(key: string | null): HeldKind {\n if (!key) return \"none\";\n const item = ITEMS[key];\n if (!item) return \"none\";\n if (item.blockId !== null) return \"block\";\n if (key === \"bow\" || key === \"crossbow\") return \"bow\";\n if (key === \"shield\") return \"shield\";\n if (item.toolType === \"sword\") return \"sword\";\n if (item.toolType) return \"tool\";\n if (key === \"trident\" || key.endsWith(\"_rod\") || key.endsWith(\"staff\"))\n return \"staff\";\n if (item.food > 0 || item.drink) return \"food\";\n return \"item\";\n}\n\n// ---------- объёмный предмет из иконки (как в Minecraft) ----------\n\n/** Сетка пиксель-арта иконок: иконка 48×48 — это 16×16, увеличенные втрое. */\nconst GRID = 16;\nconst EXTRUDED = new Map<string, THREE.BufferGeometry | null>();\nconst ITEM_MATS = new Map<string, THREE.MeshLambertMaterial>();\n\ntype V3 = [number, number, number];\n\n/**\n * Иконка, «выдавленная» в объём: лицевая и задняя грань — сама иконка (прозрачное отсекается),\n * боковые грани — по краю каждого непрозрачного пикселя, цветом этого пикселя. Квадрат 1×1 с\n * центром в нуле, толщина — один пиксель (1/16). Кэш на предмет.\n */\nexport function extrudedGeometry(key: string): THREE.BufferGeometry | null {\n if (EXTRUDED.has(key)) return EXTRUDED.get(key)!;\n const icon = ITEMS[key]?.icon;\n let geo: THREE.BufferGeometry | null = null;\n const ctx = icon?.getContext(\"2d\", { willReadFrequently: true });\n if (icon && ctx) {\n const cell = icon.width / GRID;\n const data = ctx.getImageData(0, 0, icon.width, icon.height).data;\n const solid = (i: number, j: number): boolean => {\n if (i < 0 || j < 0 || i >= GRID || j >= GRID) return false;\n const x = Math.floor((i + 0.5) * cell);\n const y = Math.floor((j + 0.5) * cell);\n return data[(y * icon.width + x) * 4 + 3]! >= 128;\n };\n const pos: number[] = [];\n const nor: number[] = [];\n const uv: number[] = [];\n const idx: number[] = [];\n const h = 0.5 / GRID;\n const quad = (v: V3[], n: V3, uvs: [number, number][]) => {\n // обход против часовой снаружи — сверяем с нормалью\n const a = v[0]!,\n b = v[1]!,\n c = v[2]!;\n const e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]] as V3;\n const e2 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]] as V3;\n const cx = e1[1] * e2[2] - e1[2] * e2[1];\n const cy = e1[2] * e2[0] - e1[0] * e2[2];\n const cz = e1[0] * e2[1] - e1[1] * e2[0];\n const flip = cx * n[0] + cy * n[1] + cz * n[2] < 0;\n const base = pos.length / 3;\n for (let k = 0; k < 4; k++) {\n pos.push(...v[k]!);\n nor.push(...n);\n uv.push(...uvs[k]!);\n }\n if (flip) idx.push(base, base + 2, base + 1, base, base + 3, base + 2);\n else idx.push(base, base + 1, base + 2, base, base + 2, base + 3);\n };\n const full: [number, number][] = [\n [0, 0],\n [1, 0],\n [1, 1],\n [0, 1],\n ];\n for (const z of [h, -h])\n quad(\n [\n [-0.5, -0.5, z],\n [0.5, -0.5, z],\n [0.5, 0.5, z],\n [-0.5, 0.5, z],\n ],\n [0, 0, z > 0 ? 1 : -1],\n full,\n );\n // боковые грани по краю непрозрачных пикселей\n const p = 1 / GRID;\n for (let j = 0; j < GRID; j++)\n for (let i = 0; i < GRID; i++) {\n if (!solid(i, j)) continue;\n const x0 = -0.5 + i * p,\n x1 = x0 + p,\n y1 = 0.5 - j * p,\n y0 = y1 - p;\n const u = (i + 0.5) / GRID,\n w = 1 - (j + 0.5) / GRID;\n const t: [number, number][] = [\n [u, w],\n [u, w],\n [u, w],\n [u, w],\n ];\n if (!solid(i - 1, j))\n quad(\n [\n [x0, y0, -h],\n [x0, y0, h],\n [x0, y1, h],\n [x0, y1, -h],\n ],\n [-1, 0, 0],\n t,\n );\n if (!solid(i + 1, j))\n quad(\n [\n [x1, y0, -h],\n [x1, y0, h],\n [x1, y1, h],\n [x1, y1, -h],\n ],\n [1, 0, 0],\n t,\n );\n if (!solid(i, j - 1))\n quad(\n [\n [x0, y1, -h],\n [x1, y1, -h],\n [x1, y1, h],\n [x0, y1, h],\n ],\n [0, 1, 0],\n t,\n );\n if (!solid(i, j + 1))\n quad(\n [\n [x0, y0, -h],\n [x1, y0, -h],\n [x1, y0, h],\n [x0, y0, h],\n ],\n [0, -1, 0],\n t,\n );\n }\n geo = new THREE.BufferGeometry();\n geo.setAttribute(\"position\", new THREE.Float32BufferAttribute(pos, 3));\n geo.setAttribute(\"normal\", new THREE.Float32BufferAttribute(nor, 3));\n geo.setAttribute(\"uv\", new THREE.Float32BufferAttribute(uv, 2));\n geo.setIndex(idx);\n geo.computeBoundingSphere();\n geo.userData.shared = true;\n geo.userData.grip = gripOf(key, solid);\n }\n EXTRUDED.set(key, geo);\n return geo;\n}\n\n/** Где предмет держат (координаты квадрата): инструмент — за рукоять, остальное — за середину. */\nfunction gripOf(\n key: string,\n solid: (i: number, j: number) => boolean,\n): [number, number] {\n const kind = heldKindOf(key);\n const long =\n kind === \"sword\" ||\n kind === \"tool\" ||\n kind === \"staff\" ||\n /stick|torch|rod/.test(key);\n let minX = GRID,\n maxX = -1,\n minY = GRID,\n maxY = -1,\n best: [number, number] | null = null,\n bestS = Infinity;\n for (let j = 0; j < GRID; j++)\n for (let i = 0; i < GRID; i++) {\n if (!solid(i, j)) continue;\n minX = Math.min(minX, i);\n maxX = Math.max(maxX, i);\n minY = Math.min(minY, j);\n maxY = Math.max(maxY, j);\n const s = i - j; // нижний левый пиксель: меньше i, больше j\n if (s < bestS) {\n bestS = s;\n best = [i, j];\n }\n }\n if (maxX < 0) return [0, 0];\n const q = (i: number, j: number): [number, number] => [\n -0.5 + (i + 0.5) / GRID,\n 0.5 - (j + 0.5) / GRID,\n ];\n if (long && best) {\n // на пару пикселей внутрь по диагонали — кулак обхватывает рукоять, а не её торец\n const v = q(best[0], best[1]);\n return [v[0] + 2.5 / GRID, v[1] + 2.5 / GRID];\n }\n return q((minX + maxX) / 2, (minY + maxY) / 2);\n}\n\n/** Материал объёмного предмета (освещается, как всё в мире). */\nexport function itemMaterial(key: string): THREE.MeshLambertMaterial | null {\n const item = ITEMS[key];\n if (!item?.icon) return null;\n let mat = ITEM_MATS.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.MeshLambertMaterial({ map: tex, alphaTest: 0.5 });\n ITEM_MATS.set(key, mat);\n }\n return mat;\n}\n\n/**\n * Объёмный предмет размером `size` блоков: группа с началом координат в точке хвата. Иконка лежит\n * в плоскости XY (рукоять внизу слева, остриё вверху справа) — поворачивает тот, кто держит.\n */\nexport function heldItemObject(key: string, size: number): THREE.Group | null {\n const geo = extrudedGeometry(key);\n const mat = itemMaterial(key);\n if (!geo || !mat) return null;\n const g = new THREE.Group();\n const m = new THREE.Mesh(geo, mat);\n const [gx, gy] = geo.userData.grip as [number, number];\n m.scale.setScalar(size);\n m.position.set(-gx * size, -gy * size, 0);\n m.castShadow = true;\n g.add(m);\n return g;\n}\n\n/**\n * Предмет в руке персонажа от третьего лица: объект в блоках, начало координат — в кулаке. Плоский\n * предмет стоит ребром вдоль взгляда (остриё вперёд и вверх), блок — маленьким кубом.\n */\nexport function thirdPersonItem(\n atlas: TextureAtlas,\n key: string,\n): THREE.Object3D | null {\n const cube = blockCube(atlas, key, 0.25);\n if (cube) {\n const m = new THREE.Mesh(cube, atlas.opaqueMat);\n m.position.set(0, -0.06, -0.12);\n m.castShadow = true;\n return m;\n }\n const g = heldItemObject(key, 0.8);\n if (!g) return null;\n // объём в YZ: правый край иконки (остриё) — вперёд, верх — вверх; хват — в кулаке\n g.rotation.set(0, Math.PI / 2, 0);\n return g;\n}\n"
|
|
446
|
+
"content": "// Модель предмета «в руке»: мини-куб блока или объёмный предмет из иконки (как в Minecraft). Общая для руки от первого лица\n// (player/ArmView) и руки персонажа от третьего лица (character/CharacterActor). Облик предмета\n// от ИИ (tag.skin) подменяет её своей моделью — character/items.\nimport * as THREE from \"three\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport { ITEMS } from \"../registry/Items\";\nimport type { HeldKind } from \"@idosgames/characters/voxel\";\nimport type { TextureAtlas } from \"./TextureAtlas\";\n\n/** Материал плоского предмета — один на предмет на всю игру. */\nconst ICON_MATS = new Map<string, THREE.MeshBasicMaterial>();\nconst CUBES = new Map<string, THREE.BufferGeometry>();\n\n/** Куб блока с гранями из атласа; белые вершинные цвета — общий материал чанков их ждёт. */\nexport function blockCube(\n atlas: TextureAtlas,\n key: string,\n size: number,\n): THREE.BufferGeometry | null {\n const item = ITEMS[key];\n if (!item || item.blockId === null) return null;\n const def = BLOCKS[item.blockId];\n if (!def || def.shape !== \"cube\" || !def.tex?.side) return null;\n const ck = key + \"@\" + size;\n const hit = CUBES.get(ck);\n if (hit) return hit;\n const geo = new THREE.BoxGeometry(size, size, size);\n const uvAttr = geo.getAttribute(\"uv\");\n const side = def.tex.side;\n const tiles = [\n side,\n side,\n def.tex.top ?? side,\n def.tex.bottom ?? side,\n side,\n 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 geo.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(geo.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n geo.userData.shared = true;\n CUBES.set(ck, geo);\n return geo;\n}\n\n/** Материал иконки предмета (null — у предмета нет иконки). */\nexport function iconMaterial(key: string): THREE.MeshBasicMaterial | null {\n const item = ITEMS[key];\n if (!item?.icon) return null;\n let mat = ICON_MATS.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 ICON_MATS.set(key, mat);\n }\n return mat;\n}\n\n/** Что персонаж держит — поза руки и действия аниматора (character/anim). */\nexport function heldKindOf(key: string | null): HeldKind {\n if (!key) return \"none\";\n const item = ITEMS[key];\n if (!item) return \"none\";\n if (item.blockId !== null) return \"block\";\n if (key === \"bow\" || key === \"crossbow\") return \"bow\";\n if (key === \"shield\") return \"shield\";\n if (item.toolType === \"sword\") return \"sword\";\n if (item.toolType) return \"tool\";\n if (key === \"trident\" || key.endsWith(\"_rod\") || key.endsWith(\"staff\"))\n return \"staff\";\n if (item.food > 0 || item.drink) return \"food\";\n return \"item\";\n}\n\n// ---------- объёмный предмет из иконки (как в Minecraft) ----------\n\n/** Сетка пиксель-арта иконок: иконка 48×48 — это 16×16, увеличенные втрое. */\nconst GRID = 16;\nconst EXTRUDED = new Map<string, THREE.BufferGeometry | null>();\nconst ITEM_MATS = new Map<string, THREE.MeshLambertMaterial>();\n\ntype V3 = [number, number, number];\n\n/**\n * Иконка, «выдавленная» в объём: лицевая и задняя грань — сама иконка (прозрачное отсекается),\n * боковые грани — по краю каждого непрозрачного пикселя, цветом этого пикселя. Квадрат 1×1 с\n * центром в нуле, толщина — один пиксель (1/16). Кэш на предмет.\n */\nexport function extrudedGeometry(key: string): THREE.BufferGeometry | null {\n if (EXTRUDED.has(key)) return EXTRUDED.get(key)!;\n const icon = ITEMS[key]?.icon;\n let geo: THREE.BufferGeometry | null = null;\n const ctx = icon?.getContext(\"2d\", { willReadFrequently: true });\n if (icon && ctx) {\n const cell = icon.width / GRID;\n const data = ctx.getImageData(0, 0, icon.width, icon.height).data;\n const solid = (i: number, j: number): boolean => {\n if (i < 0 || j < 0 || i >= GRID || j >= GRID) return false;\n const x = Math.floor((i + 0.5) * cell);\n const y = Math.floor((j + 0.5) * cell);\n return data[(y * icon.width + x) * 4 + 3]! >= 128;\n };\n const pos: number[] = [];\n const nor: number[] = [];\n const uv: number[] = [];\n const idx: number[] = [];\n const h = 0.5 / GRID;\n const quad = (v: V3[], n: V3, uvs: [number, number][]) => {\n // обход против часовой снаружи — сверяем с нормалью\n const a = v[0]!,\n b = v[1]!,\n c = v[2]!;\n const e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]] as V3;\n const e2 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]] as V3;\n const cx = e1[1] * e2[2] - e1[2] * e2[1];\n const cy = e1[2] * e2[0] - e1[0] * e2[2];\n const cz = e1[0] * e2[1] - e1[1] * e2[0];\n const flip = cx * n[0] + cy * n[1] + cz * n[2] < 0;\n const base = pos.length / 3;\n for (let k = 0; k < 4; k++) {\n pos.push(...v[k]!);\n nor.push(...n);\n uv.push(...uvs[k]!);\n }\n if (flip) idx.push(base, base + 2, base + 1, base, base + 3, base + 2);\n else idx.push(base, base + 1, base + 2, base, base + 2, base + 3);\n };\n const full: [number, number][] = [\n [0, 0],\n [1, 0],\n [1, 1],\n [0, 1],\n ];\n for (const z of [h, -h])\n quad(\n [\n [-0.5, -0.5, z],\n [0.5, -0.5, z],\n [0.5, 0.5, z],\n [-0.5, 0.5, z],\n ],\n [0, 0, z > 0 ? 1 : -1],\n full,\n );\n // боковые грани по краю непрозрачных пикселей\n const p = 1 / GRID;\n for (let j = 0; j < GRID; j++)\n for (let i = 0; i < GRID; i++) {\n if (!solid(i, j)) continue;\n const x0 = -0.5 + i * p,\n x1 = x0 + p,\n y1 = 0.5 - j * p,\n y0 = y1 - p;\n const u = (i + 0.5) / GRID,\n w = 1 - (j + 0.5) / GRID;\n const t: [number, number][] = [\n [u, w],\n [u, w],\n [u, w],\n [u, w],\n ];\n if (!solid(i - 1, j))\n quad(\n [\n [x0, y0, -h],\n [x0, y0, h],\n [x0, y1, h],\n [x0, y1, -h],\n ],\n [-1, 0, 0],\n t,\n );\n if (!solid(i + 1, j))\n quad(\n [\n [x1, y0, -h],\n [x1, y0, h],\n [x1, y1, h],\n [x1, y1, -h],\n ],\n [1, 0, 0],\n t,\n );\n if (!solid(i, j - 1))\n quad(\n [\n [x0, y1, -h],\n [x1, y1, -h],\n [x1, y1, h],\n [x0, y1, h],\n ],\n [0, 1, 0],\n t,\n );\n if (!solid(i, j + 1))\n quad(\n [\n [x0, y0, -h],\n [x1, y0, -h],\n [x1, y0, h],\n [x0, y0, h],\n ],\n [0, -1, 0],\n t,\n );\n }\n geo = new THREE.BufferGeometry();\n geo.setAttribute(\"position\", new THREE.Float32BufferAttribute(pos, 3));\n geo.setAttribute(\"normal\", new THREE.Float32BufferAttribute(nor, 3));\n geo.setAttribute(\"uv\", new THREE.Float32BufferAttribute(uv, 2));\n geo.setIndex(idx);\n geo.computeBoundingSphere();\n geo.userData.shared = true;\n geo.userData.grip = gripOf(key, solid);\n }\n EXTRUDED.set(key, geo);\n return geo;\n}\n\n/** Где предмет держат (координаты квадрата): инструмент — за рукоять, остальное — за середину. */\nfunction gripOf(\n key: string,\n solid: (i: number, j: number) => boolean,\n): [number, number] {\n const kind = heldKindOf(key);\n const long =\n kind === \"sword\" ||\n kind === \"tool\" ||\n kind === \"staff\" ||\n /stick|torch|rod/.test(key);\n let minX = GRID,\n maxX = -1,\n minY = GRID,\n maxY = -1,\n best: [number, number] | null = null,\n bestS = Infinity;\n for (let j = 0; j < GRID; j++)\n for (let i = 0; i < GRID; i++) {\n if (!solid(i, j)) continue;\n minX = Math.min(minX, i);\n maxX = Math.max(maxX, i);\n minY = Math.min(minY, j);\n maxY = Math.max(maxY, j);\n const s = i - j; // нижний левый пиксель: меньше i, больше j\n if (s < bestS) {\n bestS = s;\n best = [i, j];\n }\n }\n if (maxX < 0) return [0, 0];\n const q = (i: number, j: number): [number, number] => [\n -0.5 + (i + 0.5) / GRID,\n 0.5 - (j + 0.5) / GRID,\n ];\n if (long && best) {\n // на пару пикселей внутрь по диагонали — кулак обхватывает рукоять, а не её торец\n const v = q(best[0], best[1]);\n return [v[0] + 2.5 / GRID, v[1] + 2.5 / GRID];\n }\n return q((minX + maxX) / 2, (minY + maxY) / 2);\n}\n\n/** Материал объёмного предмета (освещается, как всё в мире). */\nexport function itemMaterial(key: string): THREE.MeshLambertMaterial | null {\n const item = ITEMS[key];\n if (!item?.icon) return null;\n let mat = ITEM_MATS.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.MeshLambertMaterial({ map: tex, alphaTest: 0.5 });\n ITEM_MATS.set(key, mat);\n }\n return mat;\n}\n\n/**\n * Объёмный предмет размером `size` блоков: группа с началом координат в точке хвата. Иконка лежит\n * в плоскости XY (рукоять внизу слева, остриё вверху справа) — поворачивает тот, кто держит.\n */\nexport function heldItemObject(key: string, size: number): THREE.Group | null {\n const geo = extrudedGeometry(key);\n const mat = itemMaterial(key);\n if (!geo || !mat) return null;\n const g = new THREE.Group();\n const m = new THREE.Mesh(geo, mat);\n const [gx, gy] = geo.userData.grip as [number, number];\n m.scale.setScalar(size);\n m.position.set(-gx * size, -gy * size, 0);\n m.castShadow = true;\n g.add(m);\n return g;\n}\n\n/**\n * Предмет в руке персонажа от третьего лица: объект в блоках, начало координат — в кулаке. Плоский\n * предмет стоит ребром вдоль взгляда (остриё вперёд и вверх), блок — маленьким кубом.\n */\nexport function thirdPersonItem(\n atlas: TextureAtlas,\n key: string,\n): THREE.Object3D | null {\n const cube = blockCube(atlas, key, 0.25);\n if (cube) {\n const m = new THREE.Mesh(cube, atlas.opaqueMat);\n m.position.set(0, -0.06, -0.12);\n m.castShadow = true;\n return m;\n }\n const g = heldItemObject(key, 0.8);\n if (!g) return null;\n // объём в YZ: правый край иконки (остриё) — вперёд, верх — вверх; хват — в кулаке\n g.rotation.set(0, Math.PI / 2, 0);\n return g;\n}\n"
|
|
530
447
|
},
|
|
531
448
|
{
|
|
532
449
|
"path": "gfx/LightMaterial.ts",
|
|
@@ -790,7 +707,7 @@
|
|
|
790
707
|
},
|
|
791
708
|
{
|
|
792
709
|
"path": "i18n.ts",
|
|
793
|
-
"content": "// Язык интерфейса VoxelCraft: английский и русский. Выбор — общий с UI kit платформы\n// (`@idosgames/react/ui`): выбор игрока в ключе localStorage «idos:language» (переключатель в\n// профиле лобби, в окне аккаунта или в паузе игры), без него — по языку браузера (русский и для\n// соседних языков, чьи игроки его читают). Модуль на ванильном three и kit не тянет, поэтому ключ,\n// событие «idos:language» и правило повторены здесь; меняются — в обоих.\n//\n// Текст из данных (задания, подсказки) — `Loc`: строка (одна на оба языка) или { en, ru }.\n// Строки экрана — таблица `makeT(EN, RU)`: русская обязана иметь каждый ключ английской.\n\nexport type Lang = \"en\" | \"ru\";\n\nconst RU_LIKE = [\"ru\", \"kk\", \"uk\", \"be\", \"ky\", \"uz\", \"tg\"];\n/** Ключ и событие выбора языка — те же, что LANGUAGE_KEY / LANGUAGE_EVENT в UI kit. */\nconst KEY = \"idos:language\";\nconst EVENT = \"idos:language\";\n\nexport function pickLang(): Lang {\n try {\n const v = globalThis.localStorage?.getItem(KEY);\n if (v === \"en\" || v === \"ru\") return v;\n } catch {\n // приватное окно — по браузеру\n }\n if (typeof navigator === \"undefined\") return \"en\";\n const l = (navigator.language || \"\").toLowerCase();\n return RU_LIKE.some((p) => l.startsWith(p)) ? \"ru\" : \"en\";\n}\n\nlet current: Lang = pickLang();\nconst listeners = new Set<(l: Lang) => void>();\n\n/** Текущий язык игры. */\nexport function lang(): Lang {\n return current;\n}\n\n/** Сменить язык только в памяти (тесты). Тексты на экране перерисовываются при следующем показе. */\nexport function setLang(l: Lang): void {\n current = l;\n}\n\n/**\n * Выбор игрока (настройки в паузе): язык всей игры и всего приложения — сохраняется, лобби SDK\n * слышит событие и перерисовывается.\n */\nexport function chooseLang(l: Lang): void {\n if (l !== \"en\" && l !== \"ru\") return;\n const changed = l !== current;\n current = l;\n try {\n globalThis.localStorage?.setItem(KEY, l);\n } catch {\n // не сохранилось — до перезагрузки\n }\n if (!changed) return;\n for (const fn of [...listeners]) fn(l);\n if (typeof window !== \"undefined\" && typeof CustomEvent !== \"undefined\")\n window.dispatchEvent(new CustomEvent(EVENT, { detail: l }));\n}\n\n/** Язык сменился (здесь или в лобби SDK). Возвращает отписку. */\nexport function onLangChange(fn: (l: Lang) => void): () => void {\n listeners.add(fn);\n return () => listeners.delete(fn);\n}\n\n// язык сменили в лобби SDK (профиль, окно аккаунта)\nif (typeof window !== \"undefined\")\n window.addEventListener(EVENT, (e) => {\n const l = (e as CustomEvent<unknown>).detail;\n if ((l === \"en\" || l === \"ru\") && l !== current) {\n current = l;\n for (const fn of [...listeners]) fn(l);\n }\n });\n\n/** Текст на двух языках; строка — один текст для обоих (числа, эмодзи, имена). */\nexport type Loc = string | { en: string; ru: string };\n\n/** Текст на текущем языке; `{name}` в нём заменяются из vars. */\nexport function tr(text: Loc, vars?: Record<string, string | number>): string {\n let s = typeof text === \"string\" ? text : (text[current] ?? text.en);\n if (vars)\n for (const [k, v] of Object.entries(vars))\n s = s.split(`{${k}}`).join(String(v));\n return s;\n}\n\n/** Таблица строк экрана: `const t = makeT(EN, RU)`, потом `t(\"close\")`. */\nexport function makeT<K extends string>(\n en: Record<K, string>,\n ru: Record<K, string>,\n): (key: K, vars?: Record<string, string | number>) => string {\n return (key, vars) => tr({ en: en[key], ru: ru[key] ?? en[key] }, vars);\n}\n\n/**\n * Слово для числа из форм через «|»: английское «log|logs», русское «бревно|бревна|брёвен»\n * (1 / 2–4 / 5+).\n */\nexport function plural(n: number, forms: Loc): string {\n const [one, few = one, many = few] = tr(forms).split(\"|\") as [\n string,\n string?,\n string?,\n ];\n const rule = new Intl.PluralRules(current).select(Math.abs(Math.trunc(n)));\n if (current === \"ru\")\n return rule === \"one\" ? one : rule === \"few\" ? few : many;\n return rule === \"one\" ? one : few;\n}\n"
|
|
710
|
+
"content": "// Язык интерфейса VoxelCraft: английский и русский. Выбор — общий с UI kit платформы\n// (`@idosgames/react/ui`): выбор игрока в ключе localStorage «idos:language» (переключатель в\n// профиле лобби, в окне аккаунта или в паузе игры), без него — по языку браузера (русский и для\n// соседних языков, чьи игроки его читают). Модуль на ванильном three и kit не тянет, поэтому ключ,\n// событие «idos:language» и правило повторены здесь; меняются — в обоих.\n//\n// Текст из данных (задания, подсказки) — `Loc`: строка (одна на оба языка) или { en, ru }.\n// Строки экрана — таблица `makeT(EN, RU)`: русская обязана иметь каждый ключ английской.\n\nimport { setLang as setCharactersLang } from \"@idosgames/characters\";\n\nexport type Lang = \"en\" | \"ru\";\n\nconst RU_LIKE = [\"ru\", \"kk\", \"uk\", \"be\", \"ky\", \"uz\", \"tg\"];\n/** Ключ и событие выбора языка — те же, что LANGUAGE_KEY / LANGUAGE_EVENT в UI kit. */\nconst KEY = \"idos:language\";\nconst EVENT = \"idos:language\";\n\nexport function pickLang(): Lang {\n try {\n const v = globalThis.localStorage?.getItem(KEY);\n if (v === \"en\" || v === \"ru\") return v;\n } catch {\n // приватное окно — по браузеру\n }\n if (typeof navigator === \"undefined\") return \"en\";\n const l = (navigator.language || \"\").toLowerCase();\n return RU_LIKE.some((p) => l.startsWith(p)) ? \"ru\" : \"en\";\n}\n\nlet current: Lang = pickLang();\nconst listeners = new Set<(l: Lang) => void>();\n\n/** Текущий язык игры. */\nexport function lang(): Lang {\n return current;\n}\n\n/** Сменить язык только в памяти (тесты). Тексты на экране перерисовываются при следующем показе. */\nexport function setLang(l: Lang): void {\n current = l;\n setCharactersLang(l);\n}\n\n/**\n * Выбор игрока (настройки в паузе): язык всей игры и всего приложения — сохраняется, лобби SDK\n * слышит событие и перерисовывается.\n */\nexport function chooseLang(l: Lang): void {\n if (l !== \"en\" && l !== \"ru\") return;\n const changed = l !== current;\n current = l;\n setCharactersLang(l);\n try {\n globalThis.localStorage?.setItem(KEY, l);\n } catch {\n // не сохранилось — до перезагрузки\n }\n if (!changed) return;\n for (const fn of [...listeners]) fn(l);\n if (typeof window !== \"undefined\" && typeof CustomEvent !== \"undefined\")\n window.dispatchEvent(new CustomEvent(EVENT, { detail: l }));\n}\n\n/** Язык сменился (здесь или в лобби SDK). Возвращает отписку. */\nexport function onLangChange(fn: (l: Lang) => void): () => void {\n listeners.add(fn);\n return () => listeners.delete(fn);\n}\n\n// язык сменили в лобби SDK (профиль, окно аккаунта)\nif (typeof window !== \"undefined\")\n window.addEventListener(EVENT, (e) => {\n const l = (e as CustomEvent<unknown>).detail;\n if ((l === \"en\" || l === \"ru\") && l !== current) {\n current = l;\n for (const fn of [...listeners]) fn(l);\n }\n });\n\n/** Текст на двух языках; строка — один текст для обоих (числа, эмодзи, имена). */\nexport type Loc = string | { en: string; ru: string };\n\n/** Текст на текущем языке; `{name}` в нём заменяются из vars. */\nexport function tr(text: Loc, vars?: Record<string, string | number>): string {\n let s = typeof text === \"string\" ? text : (text[current] ?? text.en);\n if (vars)\n for (const [k, v] of Object.entries(vars))\n s = s.split(`{${k}}`).join(String(v));\n return s;\n}\n\n/** Таблица строк экрана: `const t = makeT(EN, RU)`, потом `t(\"close\")`. */\nexport function makeT<K extends string>(\n en: Record<K, string>,\n ru: Record<K, string>,\n): (key: K, vars?: Record<string, string | number>) => string {\n return (key, vars) => tr({ en: en[key], ru: ru[key] ?? en[key] }, vars);\n}\n\n/**\n * Слово для числа из форм через «|»: английское «log|logs», русское «бревно|бревна|брёвен»\n * (1 / 2–4 / 5+).\n */\nexport function plural(n: number, forms: Loc): string {\n const [one, few = one, many = few] = tr(forms).split(\"|\") as [\n string,\n string?,\n string?,\n ];\n const rule = new Intl.PluralRules(current).select(Math.abs(Math.trunc(n)));\n if (current === \"ru\")\n return rule === \"one\" ? one : rule === \"few\" ? few : many;\n return rule === \"one\" ? one : few;\n}\n"
|
|
794
711
|
},
|
|
795
712
|
{
|
|
796
713
|
"path": "index.ts",
|
|
@@ -798,15 +715,15 @@
|
|
|
798
715
|
},
|
|
799
716
|
{
|
|
800
717
|
"path": "lobby/CharacterScreen.tsx",
|
|
801
|
-
"content": "// Вкладка лобби «Персонаж»: свой персонаж по картинкам (с разных ракурсов) и описанию — с ИИ (фича\n// тайтла \"voxel-character\") или без него (цвета с картинки, сборка из частей), правка цветов и роста,\n// «Играть этим персонажем». Справа — 3D-превью: состояния, жесты, эмоции. Облик общий с игрой и\n// подиумом лобби (character/Looks).\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { Button, Icon, TextArea, panel, v } from \"@idosgames/react/ui\";\nimport type { VoxelHub } from \"../hub\";\nimport type { RefImage } from \"../ai/characterAi\";\nimport type { SpecAIResult, SpecAIStatus } from \"../ai/specAi\";\nimport {\n parseCharacterSpec,\n type CharacterSpec,\n} from \"../character/CharacterSpec\";\nimport {\n PREVIEW_STATES,\n type CharacterPreview,\n type PreviewState,\n} from \"../character/CharacterPreview\";\nimport { characterFromImage } from \"../character/fromImage\";\nimport { humanSource } from \"../character/defaultCharacter\";\nimport { RIG_IDS, type RigId } from \"../character/rigs\";\nimport { EMOTE_IDS, type EmoteId } from \"../character/anim/emotes\";\nimport { EMOTIONS, type Emotion } from \"../character/anim/emotions\";\nimport {\n emoteLabel,\n emotionLabel,\n rigLabel,\n stateLabel,\n} from \"../ui/charLabels\";\nimport {\n ImagePicker,\n Label,\n Pick,\n Preview3D,\n Status,\n TwoColumns,\n row,\n type PickedImage,\n} from \"./controls\";\nimport { useLooks } from \"./useLooks\";\nimport { t } from \"./studioStrings\";\n\ntype View = NonNullable<RefImage[\"view\"]>;\nconst MAX_IMAGES = 4;\nexport const VIEWS: View[] = [\"front\", \"side\", \"back\", \"detail\", \"any\"];\nexport const viewOptions = (): [View, string][] => VIEWS.map((x) => [x, t(x)]);\nexport const firstFreeView = (used: View[]): View =>\n ([\"front\", \"side\", \"back\", \"detail\"] as View[]).find(\n (x) => !used.includes(x),\n ) ?? \"any\";\n\nexport function makeCharacterScreen(hub: VoxelHub) {\n return function CharacterScreen(_: FeatureScreenProps): ReactNode {\n const looks = useLooks(hub);\n const ai = hub.ai.character;\n const [draft, setDraft] = useState<CharacterSpec>(looks.spec);\n const [pics, setPics] = useState<PickedImage<View>[]>([]);\n const [text, setText] = useState(\"\");\n const [rig, setRig] = useState<RigId | \"auto\">(\"auto\");\n const [aiStatus, setAiStatus] = useState<SpecAIStatus | null>(null);\n const [busy, setBusy] = useState(false);\n const [status, setStatus] = useState<{ text: string; error?: boolean }>({\n text: \"\",\n });\n const [outfitPick, setOutfitPick] = useState({\n top: \"shirt\",\n bottom: \"pants\",\n shoes: \"boots\",\n hair: \"short\",\n hat: \"\",\n });\n const preview = useRef<CharacterPreview | null>(null);\n const job = useRef<AbortController | null>(null);\n const live = useRef(true);\n const say = (s: string, error = false): void => {\n if (live.current) setStatus({ text: s, error });\n };\n\n useEffect(() => {\n live.current = true;\n if (ai)\n void ai.status().then((st) => {\n if (!live.current) return;\n setAiStatus(st);\n if (st.available) void runJob((o) => ai.resume(o), got);\n });\n return () => {\n live.current = false;\n job.current?.abort();\n };\n }, []);\n\n async function runJob(\n start: (o: {\n signal: AbortSignal;\n onProgress: (p: number | null, note: string) => void;\n }) => Promise<SpecAIResult<CharacterSpec> | null>,\n done: (r: SpecAIResult<CharacterSpec>) => void,\n ): Promise<void> {\n if (job.current && !job.current.signal.aborted) return;\n const ctl = new AbortController();\n job.current = ctl;\n setBusy(true);\n try {\n const r = await start({\n signal: ctl.signal,\n onProgress: (p, note) =>\n say(note + (p !== null ? ` ${Math.round(p)}%` : \"\")),\n });\n if (r && live.current) done(r);\n } finally {\n if (job.current === ctl) job.current = null;\n if (live.current) setBusy(false);\n }\n }\n\n function got(r: SpecAIResult<CharacterSpec>): void {\n if (!r.ok) {\n if (r.kind !== \"cancelled\")\n say(r.message + (r.refunded ? t(\"refunded\") : \"\"), true);\n return;\n }\n setDraft(r.spec);\n say(\n t(\"done\") +\n (r.warnings.length\n ? t(\"notes\", { w: r.warnings.slice(0, 2).join(\"; \") })\n : \"\"),\n );\n }\n\n const generate = (previous: CharacterSpec | null): void => {\n if (!ai) return;\n if (previous && !text.trim()) return say(t(\"writeChange\"), true);\n void runJob(\n (o) =>\n ai.generate(\n {\n text,\n rig,\n previous,\n images: pics.map((p) => ({\n dataUrl: p.aiDataUrl,\n contentType: p.aiContentType,\n bytes: p.aiBytes,\n view: p.view,\n })),\n },\n o,\n ),\n got,\n );\n };\n\n const fromPicture = (): void => {\n const pic = pics.find((p) => p.view === \"front\") ?? pics[0];\n if (!pic) return say(t(\"needPic\"), true);\n setDraft(\n characterFromImage(\n pic.image,\n rig === \"auto\" ? \"humanoid\" : rig,\n text.trim().slice(0, 40) || t(\"defName\"),\n ),\n );\n say(t(\"fromPic\"));\n };\n\n const assemble = (): void => {\n const p = draft.palette;\n const r = parseCharacterSpec(\n humanSource({\n top: outfitPick.top as \"shirt\",\n bottom: outfitPick.bottom as \"pants\",\n shoes: outfitPick.shoes as \"boots\",\n hair: outfitPick.hair as \"short\",\n hat: outfitPick.hat === \"hat\",\n colors: {\n ...(p.s ? { skin: p.s } : {}),\n ...(p.h ? { hair: p.h } : {}),\n ...(p.t ? { shirt: p.t } : {}),\n ...(p.p ? { pants: p.p } : {}),\n ...(p.z ? { boots: p.z } : {}),\n ...(p.a ? { hat: p.a } : {}),\n },\n name: draft.name,\n description: draft.description,\n }),\n );\n if (r.ok) setDraft(r.spec);\n };\n\n const edit = (patch: Partial<CharacterSpec>): void => {\n const r = parseCharacterSpec({ ...draft, ...patch });\n if (r.ok) setDraft(r.spec);\n };\n\n const price =\n aiStatus?.available && aiStatus.feature.priceLabel\n ? ` · ${aiStatus.feature.priceLabel}`\n : \"\";\n const pickPart = (\n key: keyof typeof outfitPick,\n options: [string, string][],\n ): ReactNode => (\n <Pick\n value={outfitPick[key]}\n options={options}\n onChange={(val) => setOutfitPick({ ...outfitPick, [key]: val })}\n style={{ flex: \"1 1 120px\" }}\n />\n );\n\n return (\n <TwoColumns\n left={\n <>\n <Label text={t(\"charPics\")} note={t(\"charPicsNote\")} />\n <ImagePicker\n images={pics}\n onChange={setPics}\n max={MAX_IMAGES}\n hint={t(\"dropHint\")}\n views={viewOptions()}\n newView={firstFreeView}\n onError={() => say(t(\"badPic\"), true)}\n />\n <Label text={t(\"desc\")} />\n <TextArea\n rows={3}\n value={text}\n placeholder={t(\"charPh\")}\n onChange={(e) => setText(e.target.value)}\n />\n <div style={row}>\n <Label text={t(\"rig\")} />\n <Pick\n value={rig}\n options={[\n [\"auto\", t(\"rigAuto\")],\n ...RIG_IDS.map((id): [RigId, string] => [id, rigLabel(id)]),\n ]}\n onChange={setRig}\n />\n </div>\n <div style={row}>\n {ai && aiStatus?.available ? (\n <>\n <Button busy={busy} onClick={() => generate(null)}>\n {t(\"genAi\") + price}\n </Button>\n <Button\n tone=\"blue\"\n disabled={busy}\n title={t(\"editAiTitle\")}\n onClick={() => generate(draft)}\n >\n {t(\"editAi\")}\n </Button>\n </>\n ) : null}\n <Button tone=\"blue\" onClick={fromPicture}>\n {t(\"noAi\")}\n </Button>\n </div>\n {ai && aiStatus && !aiStatus.available ? (\n <div style={{ fontSize: 12, color: v.textDim, fontWeight: 700 }}>\n {t(\"ai\", { r: aiStatus.reason })}\n </div>\n ) : null}\n <Status text={status.text} error={status.error} />\n\n <Label text={t(\"outfit\")} />\n <div style={row}>\n {pickPart(\"top\", [\n [\"shirt\", t(\"shirt\")],\n [\"tee\", t(\"tee\")],\n [\"sweater\", t(\"sweater\")],\n ])}\n {pickPart(\"bottom\", [\n [\"pants\", t(\"pants\")],\n [\"shorts\", t(\"shorts\")],\n ])}\n {pickPart(\"shoes\", [\n [\"boots\", t(\"boots\")],\n [\"sneakers\", t(\"sneakers\")],\n ])}\n {pickPart(\"hair\", [\n [\"short\", t(\"hairShort\")],\n [\"long\", t(\"hairLong\")],\n [\"curly\", t(\"hairCurly\")],\n [\"none\", t(\"hairNone\")],\n ])}\n {pickPart(\"hat\", [\n [\"\", t(\"noHat\")],\n [\"hat\", t(\"hat\")],\n ])}\n <Button size=\"sm\" tone=\"blue\" onClick={assemble}>\n {t(\"assemble\")}\n </Button>\n </div>\n </>\n }\n right={\n <>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"baseline\",\n gap: 8,\n justifyContent: \"space-between\",\n }}\n >\n <span\n style={{ fontFamily: v.font, fontSize: 18, fontWeight: 900 }}\n >\n «{draft.name}»\n </span>\n <span style={{ fontSize: 12, color: v.textDim }}>\n {rigLabel(draft.rig)}\n </span>\n </div>\n <Preview3D\n atlas={hub.atlas()}\n spec={draft}\n outfit={looks.outfit}\n onReady={(p) => (preview.current = p)}\n />\n <PreviewControls preview={preview} />\n {/* цвета рецепта: поменял — персонаж перекрашен сразу */}\n <div style={{ ...row, gap: 6 }}>\n {Object.entries(draft.palette).map(([k, c]) => (\n <input\n key={k}\n type=\"color\"\n value={c}\n title={k}\n onChange={(e) =>\n edit({\n palette: { ...draft.palette, [k]: e.target.value },\n })\n }\n style={{\n width: 34,\n height: 34,\n border: \"none\",\n padding: 0,\n background: \"none\",\n cursor: \"pointer\",\n }}\n />\n ))}\n </div>\n <div style={row}>\n <Label text={t(\"height\")} />\n <input\n type=\"range\"\n min={0.8}\n max={1.15}\n step={0.05}\n value={Math.min(1.15, Math.max(0.8, draft.scale))}\n onChange={(e) => edit({ scale: Number(e.target.value) })}\n style={{ flex: 1, accentColor: v.gold }}\n />\n </div>\n <div style={row}>\n <Button\n tone=\"gold\"\n style={{ flex: 1 }}\n onClick={() => {\n void hub.looks.useCharacter(draft);\n say(t(\"playing\", { n: draft.name }));\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"playAs\")}\n </Button>\n <Button\n tone=\"blue\"\n onClick={() =>\n void hub.looks\n .saveCharacter(draft)\n .then((ok) => say(ok ? t(\"saved\") : t(\"noSave\"), !ok))\n }\n >\n {t(\"saveMine\")}\n </Button>\n </div>\n <SummonRow hub={hub} spec={draft} say={say} />\n </div>\n </>\n }\n />\n );\n };\n}\n\n/** Демонстрация на превью: состояние, жест, эмоция. */\nexport function PreviewControls({\n preview,\n}: {\n preview: { current: CharacterPreview | null };\n}): ReactNode {\n const [state, setState] = useState<PreviewState>(\"idle\");\n return (\n <div style={row}>\n <Pick\n value={state}\n options={(Object.keys(PREVIEW_STATES) as PreviewState[]).map(\n (id): [PreviewState, string] => [id, stateLabel(id)],\n )}\n onChange={(s) => {\n setState(s);\n preview.current?.setState(s);\n }}\n style={{ flex: \"1 1 110px\" }}\n />\n <Pick<\"\" | EmoteId>\n value=\"\"\n options={[\n [\"\", t(\"emote\")],\n ...EMOTE_IDS.map((id): [EmoteId, string] => [id, emoteLabel(id)]),\n ]}\n onChange={(id) => {\n if (!id) return;\n setState(\"idle\");\n preview.current?.playEmote(id);\n }}\n style={{ flex: \"1 1 110px\" }}\n />\n <Pick<\"\" | Emotion>\n value=\"\"\n options={[\n [\"\", t(\"emotion\")],\n ...EMOTIONS.map((e): [Emotion, string] => [e, emotionLabel(e)]),\n ]}\n onChange={(e) => e && preview.current?.setEmotion(e)}\n style={{ flex: \"1 1 110px\" }}\n />\n </div>\n );\n}\n\n/** Призвать этим обликом NPC или моба — только в открытой игре в творческом режиме. */\nfunction SummonRow({\n hub,\n spec,\n say,\n}: {\n hub: VoxelHub;\n spec: CharacterSpec;\n say: (s: string) => void;\n}): ReactNode {\n const g = hub.game;\n if (!g || g.player.mode !== \"creative\" || g.net) return null;\n return (\n <div style={row}>\n <Button\n size=\"sm\"\n tone=\"grey\"\n title={t(\"summonNpcTitle\")}\n onClick={() => {\n g.summonCharacter(spec, \"npc\");\n say(t(\"npcHere\"));\n }}\n >\n {t(\"summonNpc\")}\n </Button>\n <Pick<\"\" | \"passive\" | \"neutral\" | \"hostile\" | \"pet\">\n value=\"\"\n options={[\n [\"\", t(\"summonMob\")],\n [\"passive\", t(\"passive\")],\n [\"neutral\", t(\"neutral\")],\n [\"hostile\", t(\"hostile\")],\n [\"pet\", t(\"pet\")],\n ]}\n onChange={(b) => {\n if (!b) return;\n g.summonCharacter(spec, b);\n say(t(\"mobHere\"));\n }}\n />\n </div>\n );\n}\n"
|
|
718
|
+
"content": "// Вкладка лобби «Персонаж»: свой персонаж по картинкам (с разных ракурсов) и описанию — с ИИ (фича\n// тайтла \"voxel-character\") или без него (цвета с картинки, сборка из частей), правка цветов и роста,\n// «Играть этим персонажем». Справа — 3D-превью: состояния, жесты, эмоции. Облик общий с игрой и\n// подиумом лобби (character/Looks).\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport type { FeatureScreenProps } from \"@idosgames/module-sdk\";\nimport { Button, Icon, TextArea, panel, v } from \"@idosgames/react/ui\";\nimport type { VoxelHub } from \"../hub\";\nimport type { RefImage } from \"../ai/characterAi\";\nimport type { SpecAIResult, SpecAIStatus } from \"../ai/specAi\";\nimport {\n parseCharacterSpec,\n type CharacterSpec,\n characterFromImage,\n humanSource,\n RIG_IDS,\n type RigId,\n EMOTE_IDS,\n type EmoteId,\n EMOTIONS,\n type Emotion,\n} from \"@idosgames/characters/voxel\";\nimport {\n PREVIEW_STATES,\n type CharacterPreview,\n type PreviewState,\n} from \"../character/CharacterPreview\";\nimport {\n emoteLabel,\n emotionLabel,\n rigLabel,\n stateLabel,\n} from \"../ui/charLabels\";\nimport {\n ImagePicker,\n Label,\n Pick,\n Preview3D,\n Status,\n TwoColumns,\n row,\n type PickedImage,\n} from \"./controls\";\nimport { useLooks } from \"./useLooks\";\nimport { t } from \"./studioStrings\";\n\ntype View = NonNullable<RefImage[\"view\"]>;\nconst MAX_IMAGES = 4;\nexport const VIEWS: View[] = [\"front\", \"side\", \"back\", \"detail\", \"any\"];\nexport const viewOptions = (): [View, string][] => VIEWS.map((x) => [x, t(x)]);\nexport const firstFreeView = (used: View[]): View =>\n ([\"front\", \"side\", \"back\", \"detail\"] as View[]).find(\n (x) => !used.includes(x),\n ) ?? \"any\";\n\nexport function makeCharacterScreen(hub: VoxelHub) {\n return function CharacterScreen(_: FeatureScreenProps): ReactNode {\n const looks = useLooks(hub);\n const ai = hub.ai.character;\n const [draft, setDraft] = useState<CharacterSpec>(looks.spec);\n const [pics, setPics] = useState<PickedImage<View>[]>([]);\n const [text, setText] = useState(\"\");\n const [rig, setRig] = useState<RigId | \"auto\">(\"auto\");\n const [aiStatus, setAiStatus] = useState<SpecAIStatus | null>(null);\n const [busy, setBusy] = useState(false);\n const [status, setStatus] = useState<{ text: string; error?: boolean }>({\n text: \"\",\n });\n const [outfitPick, setOutfitPick] = useState({\n top: \"shirt\",\n bottom: \"pants\",\n shoes: \"boots\",\n hair: \"short\",\n hat: \"\",\n });\n const preview = useRef<CharacterPreview | null>(null);\n const job = useRef<AbortController | null>(null);\n const live = useRef(true);\n const say = (s: string, error = false): void => {\n if (live.current) setStatus({ text: s, error });\n };\n\n useEffect(() => {\n live.current = true;\n if (ai)\n void ai.status().then((st) => {\n if (!live.current) return;\n setAiStatus(st);\n if (st.available) void runJob((o) => ai.resume(o), got);\n });\n return () => {\n live.current = false;\n job.current?.abort();\n };\n }, []);\n\n async function runJob(\n start: (o: {\n signal: AbortSignal;\n onProgress: (p: number | null, note: string) => void;\n }) => Promise<SpecAIResult<CharacterSpec> | null>,\n done: (r: SpecAIResult<CharacterSpec>) => void,\n ): Promise<void> {\n if (job.current && !job.current.signal.aborted) return;\n const ctl = new AbortController();\n job.current = ctl;\n setBusy(true);\n try {\n const r = await start({\n signal: ctl.signal,\n onProgress: (p, note) =>\n say(note + (p !== null ? ` ${Math.round(p)}%` : \"\")),\n });\n if (r && live.current) done(r);\n } finally {\n if (job.current === ctl) job.current = null;\n if (live.current) setBusy(false);\n }\n }\n\n function got(r: SpecAIResult<CharacterSpec>): void {\n if (!r.ok) {\n if (r.kind !== \"cancelled\")\n say(r.message + (r.refunded ? t(\"refunded\") : \"\"), true);\n return;\n }\n setDraft(r.spec);\n say(\n t(\"done\") +\n (r.warnings.length\n ? t(\"notes\", { w: r.warnings.slice(0, 2).join(\"; \") })\n : \"\"),\n );\n }\n\n const generate = (previous: CharacterSpec | null): void => {\n if (!ai) return;\n if (previous && !text.trim()) return say(t(\"writeChange\"), true);\n void runJob(\n (o) =>\n ai.generate(\n {\n text,\n rig,\n previous,\n images: pics.map((p) => ({\n dataUrl: p.aiDataUrl,\n contentType: p.aiContentType,\n bytes: p.aiBytes,\n view: p.view,\n })),\n },\n o,\n ),\n got,\n );\n };\n\n const fromPicture = (): void => {\n const pic = pics.find((p) => p.view === \"front\") ?? pics[0];\n if (!pic) return say(t(\"needPic\"), true);\n setDraft(\n characterFromImage(\n pic.image,\n rig === \"auto\" ? \"humanoid\" : rig,\n text.trim().slice(0, 40) || t(\"defName\"),\n ),\n );\n say(t(\"fromPic\"));\n };\n\n const assemble = (): void => {\n const p = draft.palette;\n const r = parseCharacterSpec(\n humanSource({\n top: outfitPick.top as \"shirt\",\n bottom: outfitPick.bottom as \"pants\",\n shoes: outfitPick.shoes as \"boots\",\n hair: outfitPick.hair as \"short\",\n hat: outfitPick.hat === \"hat\",\n colors: {\n ...(p.s ? { skin: p.s } : {}),\n ...(p.h ? { hair: p.h } : {}),\n ...(p.t ? { shirt: p.t } : {}),\n ...(p.p ? { pants: p.p } : {}),\n ...(p.z ? { boots: p.z } : {}),\n ...(p.a ? { hat: p.a } : {}),\n },\n name: draft.name,\n description: draft.description,\n }),\n );\n if (r.ok) setDraft(r.spec);\n };\n\n const edit = (patch: Partial<CharacterSpec>): void => {\n const r = parseCharacterSpec({ ...draft, ...patch });\n if (r.ok) setDraft(r.spec);\n };\n\n const price =\n aiStatus?.available && aiStatus.feature.priceLabel\n ? ` · ${aiStatus.feature.priceLabel}`\n : \"\";\n const pickPart = (\n key: keyof typeof outfitPick,\n options: [string, string][],\n ): ReactNode => (\n <Pick\n value={outfitPick[key]}\n options={options}\n onChange={(val) => setOutfitPick({ ...outfitPick, [key]: val })}\n style={{ flex: \"1 1 120px\" }}\n />\n );\n\n return (\n <TwoColumns\n left={\n <>\n <Label text={t(\"charPics\")} note={t(\"charPicsNote\")} />\n <ImagePicker\n images={pics}\n onChange={setPics}\n max={MAX_IMAGES}\n hint={t(\"dropHint\")}\n views={viewOptions()}\n newView={firstFreeView}\n onError={() => say(t(\"badPic\"), true)}\n />\n <Label text={t(\"desc\")} />\n <TextArea\n rows={3}\n value={text}\n placeholder={t(\"charPh\")}\n onChange={(e) => setText(e.target.value)}\n />\n <div style={row}>\n <Label text={t(\"rig\")} />\n <Pick\n value={rig}\n options={[\n [\"auto\", t(\"rigAuto\")],\n ...RIG_IDS.map((id): [RigId, string] => [id, rigLabel(id)]),\n ]}\n onChange={setRig}\n />\n </div>\n <div style={row}>\n {ai && aiStatus?.available ? (\n <>\n <Button busy={busy} onClick={() => generate(null)}>\n {t(\"genAi\") + price}\n </Button>\n <Button\n tone=\"blue\"\n disabled={busy}\n title={t(\"editAiTitle\")}\n onClick={() => generate(draft)}\n >\n {t(\"editAi\")}\n </Button>\n </>\n ) : null}\n <Button tone=\"blue\" onClick={fromPicture}>\n {t(\"noAi\")}\n </Button>\n </div>\n {ai && aiStatus && !aiStatus.available ? (\n <div style={{ fontSize: 12, color: v.textDim, fontWeight: 700 }}>\n {t(\"ai\", { r: aiStatus.reason })}\n </div>\n ) : null}\n <Status text={status.text} error={status.error} />\n\n <Label text={t(\"outfit\")} />\n <div style={row}>\n {pickPart(\"top\", [\n [\"shirt\", t(\"shirt\")],\n [\"tee\", t(\"tee\")],\n [\"sweater\", t(\"sweater\")],\n ])}\n {pickPart(\"bottom\", [\n [\"pants\", t(\"pants\")],\n [\"shorts\", t(\"shorts\")],\n ])}\n {pickPart(\"shoes\", [\n [\"boots\", t(\"boots\")],\n [\"sneakers\", t(\"sneakers\")],\n ])}\n {pickPart(\"hair\", [\n [\"short\", t(\"hairShort\")],\n [\"long\", t(\"hairLong\")],\n [\"curly\", t(\"hairCurly\")],\n [\"none\", t(\"hairNone\")],\n ])}\n {pickPart(\"hat\", [\n [\"\", t(\"noHat\")],\n [\"hat\", t(\"hat\")],\n ])}\n <Button size=\"sm\" tone=\"blue\" onClick={assemble}>\n {t(\"assemble\")}\n </Button>\n </div>\n </>\n }\n right={\n <>\n <div style={{ ...panel, padding: 12, display: \"grid\", gap: 10 }}>\n <div\n style={{\n display: \"flex\",\n alignItems: \"baseline\",\n gap: 8,\n justifyContent: \"space-between\",\n }}\n >\n <span\n style={{ fontFamily: v.font, fontSize: 18, fontWeight: 900 }}\n >\n «{draft.name}»\n </span>\n <span style={{ fontSize: 12, color: v.textDim }}>\n {rigLabel(draft.rig)}\n </span>\n </div>\n <Preview3D\n atlas={hub.atlas()}\n spec={draft}\n outfit={looks.outfit}\n onReady={(p) => (preview.current = p)}\n />\n <PreviewControls preview={preview} />\n {/* цвета рецепта: поменял — персонаж перекрашен сразу */}\n <div style={{ ...row, gap: 6 }}>\n {Object.entries(draft.palette).map(([k, c]) => (\n <input\n key={k}\n type=\"color\"\n value={c}\n title={k}\n onChange={(e) =>\n edit({\n palette: { ...draft.palette, [k]: e.target.value },\n })\n }\n style={{\n width: 34,\n height: 34,\n border: \"none\",\n padding: 0,\n background: \"none\",\n cursor: \"pointer\",\n }}\n />\n ))}\n </div>\n <div style={row}>\n <Label text={t(\"height\")} />\n <input\n type=\"range\"\n min={0.8}\n max={1.15}\n step={0.05}\n value={Math.min(1.15, Math.max(0.8, draft.scale))}\n onChange={(e) => edit({ scale: Number(e.target.value) })}\n style={{ flex: 1, accentColor: v.gold }}\n />\n </div>\n <div style={row}>\n <Button\n tone=\"gold\"\n style={{ flex: 1 }}\n onClick={() => {\n void hub.looks.useCharacter(draft);\n say(t(\"playing\", { n: draft.name }));\n }}\n >\n <Icon glyph=\"check\" size={18} /> {t(\"playAs\")}\n </Button>\n <Button\n tone=\"blue\"\n onClick={() =>\n void hub.looks\n .saveCharacter(draft)\n .then((ok) => say(ok ? t(\"saved\") : t(\"noSave\"), !ok))\n }\n >\n {t(\"saveMine\")}\n </Button>\n </div>\n <SummonRow hub={hub} spec={draft} say={say} />\n </div>\n </>\n }\n />\n );\n };\n}\n\n/** Демонстрация на превью: состояние, жест, эмоция. */\nexport function PreviewControls({\n preview,\n}: {\n preview: { current: CharacterPreview | null };\n}): ReactNode {\n const [state, setState] = useState<PreviewState>(\"idle\");\n return (\n <div style={row}>\n <Pick\n value={state}\n options={(Object.keys(PREVIEW_STATES) as PreviewState[]).map(\n (id): [PreviewState, string] => [id, stateLabel(id)],\n )}\n onChange={(s) => {\n setState(s);\n preview.current?.setState(s);\n }}\n style={{ flex: \"1 1 110px\" }}\n />\n <Pick<\"\" | EmoteId>\n value=\"\"\n options={[\n [\"\", t(\"emote\")],\n ...EMOTE_IDS.map((id): [EmoteId, string] => [id, emoteLabel(id)]),\n ]}\n onChange={(id) => {\n if (!id) return;\n setState(\"idle\");\n preview.current?.playEmote(id);\n }}\n style={{ flex: \"1 1 110px\" }}\n />\n <Pick<\"\" | Emotion>\n value=\"\"\n options={[\n [\"\", t(\"emotion\")],\n ...EMOTIONS.map((e): [Emotion, string] => [e, emotionLabel(e)]),\n ]}\n onChange={(e) => e && preview.current?.setEmotion(e)}\n style={{ flex: \"1 1 110px\" }}\n />\n </div>\n );\n}\n\n/** Призвать этим обликом NPC или моба — только в открытой игре в творческом режиме. */\nfunction SummonRow({\n hub,\n spec,\n say,\n}: {\n hub: VoxelHub;\n spec: CharacterSpec;\n say: (s: string) => void;\n}): ReactNode {\n const g = hub.game;\n if (!g || g.player.mode !== \"creative\" || g.net) return null;\n return (\n <div style={row}>\n <Button\n size=\"sm\"\n tone=\"grey\"\n title={t(\"summonNpcTitle\")}\n onClick={() => {\n g.summonCharacter(spec, \"npc\");\n say(t(\"npcHere\"));\n }}\n >\n {t(\"summonNpc\")}\n </Button>\n <Pick<\"\" | \"passive\" | \"neutral\" | \"hostile\" | \"pet\">\n value=\"\"\n options={[\n [\"\", t(\"summonMob\")],\n [\"passive\", t(\"passive\")],\n [\"neutral\", t(\"neutral\")],\n [\"hostile\", t(\"hostile\")],\n [\"pet\", t(\"pet\")],\n ]}\n onChange={(b) => {\n if (!b) return;\n g.summonCharacter(spec, b);\n say(t(\"mobHere\"));\n }}\n />\n </div>\n );\n}\n"
|
|
802
719
|
},
|
|
803
720
|
{
|
|
804
721
|
"path": "lobby/controls.tsx",
|
|
805
|
-
"content": "// Общие детали вкладок лобби VoxelCraft — поверх UI kit (@idosgames/react/ui): картинки-образцы\n// (клик, перетаскивание, Ctrl+V), выбор из нескольких вариантов, 3D-превью персонажа.\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n Icon,\n Select,\n Tabs,\n outlined,\n panel,\n useUiKit,\n v,\n well,\n} from \"@idosgames/react/ui\";\nimport { CharacterPreview } from \"../character/CharacterPreview\";\nimport type { CharacterSpec } from \"../character/CharacterSpec\";\nimport type { WearSlot } from \"../character/items/ItemSkinSpec\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport { loadImageFile, type LoadedImage } from \"../worldgen/loadImage\";\n\n/** Колонка формы: отступы между полями. */\nexport const column: CSSProperties = {\n display: \"grid\",\n gap: 12,\n gridTemplateColumns: \"minmax(0, 1fr)\",\n alignContent: \"start\",\n};\n\n/** Ряд кнопок / полей с переносом. */\nexport const row: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n};\n\n/** Подпись поля с примечанием. */\nexport function Label({\n text,\n note,\n}: {\n text: string;\n note?: string;\n}): ReactNode {\n return (\n <div style={{ ...outlined, fontSize: 14 }}>\n {text}{\" \"}\n {note ? (\n <span style={{ fontSize: 12, color: v.textDim }}>{note}</span>\n ) : null}\n </div>\n );\n}\n\n/** Строка статуса под кнопками: ход генерации, итог, ошибка. */\nexport function Status({\n text,\n error,\n}: {\n text: string;\n error?: boolean;\n}): ReactNode {\n if (!text) return null;\n return (\n <div\n style={{\n ...well,\n padding: \"8px 12px\",\n fontSize: 13,\n fontWeight: 700,\n color: error ? v.red : v.text,\n }}\n >\n {text}\n </div>\n );\n}\n\n/** Выбор одного из нескольких вариантов (сегменты). */\nexport function Choice<T extends string>({\n value,\n options,\n onChange,\n}: {\n value: T;\n options: [T, string][];\n onChange: (v: T) => void;\n}): ReactNode {\n return (\n <Tabs\n tabs={options.map(([id, label]) => ({ id, label }))}\n value={value}\n onChange={onChange}\n // варианты переносятся на новую строку, а не прячутся за прокруткой\n style={{ flexWrap: \"wrap\" }}\n />\n );\n}\n\n/** Выпадающий список значений. */\nexport function Pick<T extends string>({\n value,\n options,\n onChange,\n style,\n}: {\n value: T;\n options: [T, string][];\n onChange: (v: T) => void;\n style?: CSSProperties;\n}): ReactNode {\n return (\n <Select\n value={value}\n onChange={(e) => onChange(e.target.value as T)}\n style={style}\n >\n {options.map(([id, label]) => (\n <option key={id} value={id}>\n {label}\n </option>\n ))}\n </Select>\n );\n}\n\n/** Картинка-образец (+ необязательная подпись ракурса). */\nexport interface PickedImage<V extends string = string> extends LoadedImage {\n view?: V;\n}\n\n/**\n * Картинки: клик — выбрать файлы, перетаскивание, Ctrl+V (пока поле на экране). `views` — у каждой\n * картинки выбор ракурса («спереди», «сбоку»…).\n */\nexport function ImagePicker<V extends string>({\n images,\n onChange,\n max,\n hint,\n views,\n onError,\n newView,\n}: {\n images: PickedImage<V>[];\n onChange: (next: PickedImage<V>[]) => void;\n max: number;\n hint: string;\n views?: [V, string][];\n onError?: (files: number) => void;\n /** Ракурс новой картинки (первый свободный). */\n newView?: (used: V[]) => V;\n}): ReactNode {\n const kit = useUiKit();\n const [over, setOver] = useState(false);\n const latest = useRef(images);\n latest.current = images;\n\n const add = async (files: File[]): Promise<void> => {\n const pics = files.filter((f) => f.type.startsWith(\"image/\"));\n let list = [...latest.current];\n let failed = 0;\n for (const f of pics) {\n if (list.length >= max) break;\n try {\n const img: PickedImage<V> = await loadImageFile(f);\n if (newView) img.view = newView(list.map((p) => p.view as V));\n list = [...list, img];\n } catch {\n failed++;\n }\n }\n onChange(list);\n if (failed || (files.length && !pics.length))\n onError?.(failed || files.length);\n };\n\n // Ctrl+V — пока поле на экране\n useEffect(() => {\n const paste = (e: ClipboardEvent): void => {\n const files = [...(e.clipboardData?.files ?? [])].filter((x) =>\n x.type.startsWith(\"image/\"),\n );\n if (files.length) void add(files);\n };\n document.addEventListener(\"paste\", paste);\n return () => document.removeEventListener(\"paste\", paste);\n });\n\n const choose = (): void => {\n kit.play(\"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 add([...(input.files ?? [])]);\n input.click();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div\n role=\"button\"\n tabIndex={0}\n className=\"idos-press\"\n onClick={choose}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") choose();\n }}\n onDragOver={(e) => {\n e.preventDefault();\n setOver(true);\n }}\n onDragLeave={() => setOver(false)}\n onDrop={(e) => {\n e.preventDefault();\n setOver(false);\n void add([...e.dataTransfer.files]);\n }}\n style={{\n ...well,\n padding: 16,\n minHeight: 64,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 10,\n cursor: \"pointer\",\n textAlign: \"center\",\n border: `2px dashed ${over ? v.gold : v.panelEdge}`,\n color: v.textDim,\n fontSize: 13,\n fontWeight: 700,\n }}\n >\n <Icon glyph=\"plus\" size={22} /> {hint}\n </div>\n {images.length ? (\n <div style={{ ...row, alignItems: \"flex-start\" }}>\n {images.map((im, i) => (\n <div\n key={im.thumbUrl + i}\n style={{ display: \"grid\", gap: 4, width: 92 }}\n >\n <div\n style={{\n ...panel,\n position: \"relative\",\n width: 92,\n height: 92,\n padding: 0,\n overflow: \"hidden\",\n }}\n >\n <img\n src={im.thumbUrl}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n <button\n type=\"button\"\n aria-label=\"×\"\n onClick={() => onChange(images.filter((_, k) => k !== i))}\n style={{\n position: \"absolute\",\n top: 4,\n right: 4,\n width: 24,\n height: 24,\n borderRadius: \"50%\",\n border: \"none\",\n cursor: \"pointer\",\n background: v.red,\n color: \"#fff\",\n fontWeight: 900,\n lineHeight: \"24px\",\n padding: 0,\n }}\n >\n ×\n </button>\n </div>\n {views ? (\n <Pick\n value={(im.view ?? views[0]![0]) as V}\n options={views}\n style={{ padding: \"4px 6px\", fontSize: 12 }}\n onChange={(view) =>\n onChange(\n images.map((p, k) => (k === i ? { ...p, view } : p)),\n )\n }\n />\n ) : null}\n </div>\n ))}\n </div>\n ) : null}\n </div>\n );\n}\n\n/**\n * Персонаж в 3D на своём холсте (тянешь — поворачивается). Превью живёт, пока компонент на экране;\n * `onReady` отдаёт его наружу — показать состояние, жест, эмоцию, предмет в руке.\n */\nexport function Preview3D({\n atlas,\n spec,\n outfit,\n held,\n heldSkin,\n onReady,\n height = 320,\n}: {\n atlas: TextureAtlas;\n spec: CharacterSpec;\n outfit: Partial<Record<WearSlot, string>>;\n held?: string | null;\n heldSkin?: string | null;\n onReady?: (p: CharacterPreview | null) => void;\n height?: number;\n}): ReactNode {\n const canvas = useRef<HTMLCanvasElement>(null);\n const preview = useRef<CharacterPreview | null>(null);\n\n useEffect(() => {\n const c = canvas.current;\n if (!c) return;\n const p = new CharacterPreview(c, spec, atlas);\n preview.current = p;\n p.start();\n onReady?.(p);\n return () => {\n onReady?.(null);\n p.dispose();\n preview.current = null;\n };\n // превью создаётся один раз; рецепт и одежда меняются эффектами ниже\n }, [atlas]);\n useEffect(() => preview.current?.setSpec(spec), [spec]);\n useEffect(() => preview.current?.actor.setOutfit(outfit), [outfit]);\n useEffect(() => {\n const p = preview.current;\n if (!p) return;\n p.held = held ?? null;\n p.heldSkin = heldSkin ?? null;\n }, [held, heldSkin]);\n\n return (\n <canvas\n ref={canvas}\n style={{\n ...well,\n width: \"100%\",\n height,\n display: \"block\",\n cursor: \"grab\",\n background: `radial-gradient(ellipse at 50% 70%, color-mix(in srgb, ${v.blue} 35%, transparent), transparent 70%)`,\n }}\n />\n );\n}\n\n/** Две колонки на широком экране (форма | превью), одна — на телефоне. */\nexport function TwoColumns({\n left,\n right,\n}: {\n left: ReactNode;\n right: ReactNode;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"grid\",\n gap: 16,\n gridTemplateColumns: \"repeat(auto-fit, minmax(280px, 1fr))\",\n alignItems: \"start\",\n }}\n >\n <div style={column}>{left}</div>\n <div style={column}>{right}</div>\n </div>\n );\n}\n"
|
|
722
|
+
"content": "// Общие детали вкладок лобби VoxelCraft — поверх UI kit (@idosgames/react/ui): картинки-образцы\n// (клик, перетаскивание, Ctrl+V), выбор из нескольких вариантов, 3D-превью персонажа.\nimport {\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type ReactNode,\n} from \"react\";\nimport {\n Icon,\n Select,\n Tabs,\n outlined,\n panel,\n useUiKit,\n v,\n well,\n} from \"@idosgames/react/ui\";\nimport { CharacterPreview } from \"../character/CharacterPreview\";\nimport type { CharacterSpec } from \"@idosgames/characters/voxel\";\nimport type { WearSlot } from \"../character/items/ItemSkinSpec\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\nimport { loadImageFile, type LoadedImage } from \"../worldgen/loadImage\";\n\n/** Колонка формы: отступы между полями. */\nexport const column: CSSProperties = {\n display: \"grid\",\n gap: 12,\n gridTemplateColumns: \"minmax(0, 1fr)\",\n alignContent: \"start\",\n};\n\n/** Ряд кнопок / полей с переносом. */\nexport const row: CSSProperties = {\n display: \"flex\",\n gap: 8,\n flexWrap: \"wrap\",\n alignItems: \"center\",\n};\n\n/** Подпись поля с примечанием. */\nexport function Label({\n text,\n note,\n}: {\n text: string;\n note?: string;\n}): ReactNode {\n return (\n <div style={{ ...outlined, fontSize: 14 }}>\n {text}{\" \"}\n {note ? (\n <span style={{ fontSize: 12, color: v.textDim }}>{note}</span>\n ) : null}\n </div>\n );\n}\n\n/** Строка статуса под кнопками: ход генерации, итог, ошибка. */\nexport function Status({\n text,\n error,\n}: {\n text: string;\n error?: boolean;\n}): ReactNode {\n if (!text) return null;\n return (\n <div\n style={{\n ...well,\n padding: \"8px 12px\",\n fontSize: 13,\n fontWeight: 700,\n color: error ? v.red : v.text,\n }}\n >\n {text}\n </div>\n );\n}\n\n/** Выбор одного из нескольких вариантов (сегменты). */\nexport function Choice<T extends string>({\n value,\n options,\n onChange,\n}: {\n value: T;\n options: [T, string][];\n onChange: (v: T) => void;\n}): ReactNode {\n return (\n <Tabs\n tabs={options.map(([id, label]) => ({ id, label }))}\n value={value}\n onChange={onChange}\n // варианты переносятся на новую строку, а не прячутся за прокруткой\n style={{ flexWrap: \"wrap\" }}\n />\n );\n}\n\n/** Выпадающий список значений. */\nexport function Pick<T extends string>({\n value,\n options,\n onChange,\n style,\n}: {\n value: T;\n options: [T, string][];\n onChange: (v: T) => void;\n style?: CSSProperties;\n}): ReactNode {\n return (\n <Select\n value={value}\n onChange={(e) => onChange(e.target.value as T)}\n style={style}\n >\n {options.map(([id, label]) => (\n <option key={id} value={id}>\n {label}\n </option>\n ))}\n </Select>\n );\n}\n\n/** Картинка-образец (+ необязательная подпись ракурса). */\nexport interface PickedImage<V extends string = string> extends LoadedImage {\n view?: V;\n}\n\n/**\n * Картинки: клик — выбрать файлы, перетаскивание, Ctrl+V (пока поле на экране). `views` — у каждой\n * картинки выбор ракурса («спереди», «сбоку»…).\n */\nexport function ImagePicker<V extends string>({\n images,\n onChange,\n max,\n hint,\n views,\n onError,\n newView,\n}: {\n images: PickedImage<V>[];\n onChange: (next: PickedImage<V>[]) => void;\n max: number;\n hint: string;\n views?: [V, string][];\n onError?: (files: number) => void;\n /** Ракурс новой картинки (первый свободный). */\n newView?: (used: V[]) => V;\n}): ReactNode {\n const kit = useUiKit();\n const [over, setOver] = useState(false);\n const latest = useRef(images);\n latest.current = images;\n\n const add = async (files: File[]): Promise<void> => {\n const pics = files.filter((f) => f.type.startsWith(\"image/\"));\n let list = [...latest.current];\n let failed = 0;\n for (const f of pics) {\n if (list.length >= max) break;\n try {\n const img: PickedImage<V> = await loadImageFile(f);\n if (newView) img.view = newView(list.map((p) => p.view as V));\n list = [...list, img];\n } catch {\n failed++;\n }\n }\n onChange(list);\n if (failed || (files.length && !pics.length))\n onError?.(failed || files.length);\n };\n\n // Ctrl+V — пока поле на экране\n useEffect(() => {\n const paste = (e: ClipboardEvent): void => {\n const files = [...(e.clipboardData?.files ?? [])].filter((x) =>\n x.type.startsWith(\"image/\"),\n );\n if (files.length) void add(files);\n };\n document.addEventListener(\"paste\", paste);\n return () => document.removeEventListener(\"paste\", paste);\n });\n\n const choose = (): void => {\n kit.play(\"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 add([...(input.files ?? [])]);\n input.click();\n };\n\n return (\n <div style={{ display: \"grid\", gap: 8 }}>\n <div\n role=\"button\"\n tabIndex={0}\n className=\"idos-press\"\n onClick={choose}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" || e.key === \" \") choose();\n }}\n onDragOver={(e) => {\n e.preventDefault();\n setOver(true);\n }}\n onDragLeave={() => setOver(false)}\n onDrop={(e) => {\n e.preventDefault();\n setOver(false);\n void add([...e.dataTransfer.files]);\n }}\n style={{\n ...well,\n padding: 16,\n minHeight: 64,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n gap: 10,\n cursor: \"pointer\",\n textAlign: \"center\",\n border: `2px dashed ${over ? v.gold : v.panelEdge}`,\n color: v.textDim,\n fontSize: 13,\n fontWeight: 700,\n }}\n >\n <Icon glyph=\"plus\" size={22} /> {hint}\n </div>\n {images.length ? (\n <div style={{ ...row, alignItems: \"flex-start\" }}>\n {images.map((im, i) => (\n <div\n key={im.thumbUrl + i}\n style={{ display: \"grid\", gap: 4, width: 92 }}\n >\n <div\n style={{\n ...panel,\n position: \"relative\",\n width: 92,\n height: 92,\n padding: 0,\n overflow: \"hidden\",\n }}\n >\n <img\n src={im.thumbUrl}\n alt=\"\"\n style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n />\n <button\n type=\"button\"\n aria-label=\"×\"\n onClick={() => onChange(images.filter((_, k) => k !== i))}\n style={{\n position: \"absolute\",\n top: 4,\n right: 4,\n width: 24,\n height: 24,\n borderRadius: \"50%\",\n border: \"none\",\n cursor: \"pointer\",\n background: v.red,\n color: \"#fff\",\n fontWeight: 900,\n lineHeight: \"24px\",\n padding: 0,\n }}\n >\n ×\n </button>\n </div>\n {views ? (\n <Pick\n value={(im.view ?? views[0]![0]) as V}\n options={views}\n style={{ padding: \"4px 6px\", fontSize: 12 }}\n onChange={(view) =>\n onChange(\n images.map((p, k) => (k === i ? { ...p, view } : p)),\n )\n }\n />\n ) : null}\n </div>\n ))}\n </div>\n ) : null}\n </div>\n );\n}\n\n/**\n * Персонаж в 3D на своём холсте (тянешь — поворачивается). Превью живёт, пока компонент на экране;\n * `onReady` отдаёт его наружу — показать состояние, жест, эмоцию, предмет в руке.\n */\nexport function Preview3D({\n atlas,\n spec,\n outfit,\n held,\n heldSkin,\n onReady,\n height = 320,\n}: {\n atlas: TextureAtlas;\n spec: CharacterSpec;\n outfit: Partial<Record<WearSlot, string>>;\n held?: string | null;\n heldSkin?: string | null;\n onReady?: (p: CharacterPreview | null) => void;\n height?: number;\n}): ReactNode {\n const canvas = useRef<HTMLCanvasElement>(null);\n const preview = useRef<CharacterPreview | null>(null);\n\n useEffect(() => {\n const c = canvas.current;\n if (!c) return;\n const p = new CharacterPreview(c, spec, atlas);\n preview.current = p;\n p.start();\n onReady?.(p);\n return () => {\n onReady?.(null);\n p.dispose();\n preview.current = null;\n };\n // превью создаётся один раз; рецепт и одежда меняются эффектами ниже\n }, [atlas]);\n useEffect(() => preview.current?.setSpec(spec), [spec]);\n useEffect(() => preview.current?.actor.setOutfit(outfit), [outfit]);\n useEffect(() => {\n const p = preview.current;\n if (!p) return;\n p.held = held ?? null;\n p.heldSkin = heldSkin ?? null;\n }, [held, heldSkin]);\n\n return (\n <canvas\n ref={canvas}\n style={{\n ...well,\n width: \"100%\",\n height,\n display: \"block\",\n cursor: \"grab\",\n background: `radial-gradient(ellipse at 50% 70%, color-mix(in srgb, ${v.blue} 35%, transparent), transparent 70%)`,\n }}\n />\n );\n}\n\n/** Две колонки на широком экране (форма | превью), одна — на телефоне. */\nexport function TwoColumns({\n left,\n right,\n}: {\n left: ReactNode;\n right: ReactNode;\n}): ReactNode {\n return (\n <div\n style={{\n display: \"grid\",\n gap: 16,\n gridTemplateColumns: \"repeat(auto-fit, minmax(280px, 1fr))\",\n alignItems: \"start\",\n }}\n >\n <div style={column}>{left}</div>\n <div style={column}>{right}</div>\n </div>\n );\n}\n"
|
|
806
723
|
},
|
|
807
724
|
{
|
|
808
725
|
"path": "lobby/hero.ts",
|
|
809
|
-
"content": "// Подиум VoxelCraft на главной лобби SDK (ctx.lobby.setHero): свой персонаж в мини-сцене из блоков\n// (lobbyStage) во весь экран за интерфейсом лобби — тянешь, он поворачивается. Под ним лобби рисует\n// имя, «Изменить» и «Жест»; рядом — карточку мира («Сменить», «Новый», ⋯) и «Играть». Всё вокруг\n// сцены рисует лобби SDK своим UI kit — здесь только сцена и описание кнопок.\nimport type {\n FeatureRegistry,\n LobbyHeroAction,\n LobbyHeroDefinition,\n LobbyHeroView,\n} from \"@idosgames/module-sdk\";\nimport type * as THREE from \"three\";\nimport type { VoxelHub } from \"../hub\";\nimport { CharacterPreview } from \"../character/CharacterPreview\";\nimport { EMOTE_IDS } from \"
|
|
726
|
+
"content": "// Подиум VoxelCraft на главной лобби SDK (ctx.lobby.setHero): свой персонаж в мини-сцене из блоков\n// (lobbyStage) во весь экран за интерфейсом лобби — тянешь, он поворачивается. Под ним лобби рисует\n// имя, «Изменить» и «Жест»; рядом — карточку мира («Сменить», «Новый», ⋯) и «Играть». Всё вокруг\n// сцены рисует лобби SDK своим UI kit — здесь только сцена и описание кнопок.\nimport type {\n FeatureRegistry,\n LobbyHeroAction,\n LobbyHeroDefinition,\n LobbyHeroView,\n} from \"@idosgames/module-sdk\";\nimport type * as THREE from \"three\";\nimport type { VoxelHub } from \"../hub\";\nimport { CharacterPreview } from \"../character/CharacterPreview\";\nimport { EMOTE_IDS } from \"@idosgames/characters/voxel\";\nimport { emoteLabel } from \"../ui/charLabels\";\nimport { buildLobbyStage, driftStage } from \"../ui/lobbyStage\";\nimport { downloadText, pickTextFile, safeFileName } from \"../systems/Save\";\nimport { FEATURE, MODE, t } from \"./ids\";\n\n/** Небо мини-сцены: холст прозрачный, блоки рисуются поверх градиента. */\nconst SKY = \"linear-gradient(to bottom, #3f86e0 0%, #82bdf5 45%, #bfe3ff 70%)\";\n\nexport function createHero(\n hub: VoxelHub,\n features: FeatureRegistry,\n): LobbyHeroDefinition {\n const listeners = new Set<() => void>();\n const notify = (): void => {\n for (const fn of [...listeners]) fn();\n };\n let worldName: string | null = null;\n const refreshWorld = (): void =>\n void hub.worldName().then((n) => {\n if (n === worldName) return;\n worldName = n;\n notify();\n });\n hub.subscribe(() => {\n refreshWorld();\n notify();\n });\n hub.looks.subscribe(notify);\n\n let preview: CharacterPreview | null = null;\n let stage: THREE.Group | null = null;\n const wrap = document.createElement(\"div\");\n Object.assign(wrap.style, {\n position: \"absolute\",\n inset: \"0\",\n background: SKY,\n });\n const canvas = document.createElement(\"canvas\");\n Object.assign(canvas.style, {\n position: \"absolute\",\n inset: \"0\",\n width: \"100%\",\n height: \"100%\",\n cursor: \"grab\",\n touchAction: \"none\",\n });\n wrap.append(canvas);\n\n const exportWorld = async (): Promise<void> => {\n if (hub.game) return hub.game.save.exportFile();\n const store = await hub.worlds();\n const last = (await store?.list())?.[0];\n const full = last ? await store?.get(last.id) : undefined;\n if (full)\n downloadText(\n `voxelcraft-save-${safeFileName(full.name)}.json`,\n JSON.stringify(full.save),\n );\n };\n\n const view = (): LobbyHeroView => {\n const looks = hub.looks;\n const actions: LobbyHeroAction[] = [\n {\n id: \"edit\",\n label: t(\"edit\"),\n icon: \"edit\",\n run: () => features.open(FEATURE.character),\n },\n {\n id: \"emote\",\n label: t(\"emote\"),\n icon: \"star\",\n menu: EMOTE_IDS.map((id) => ({\n id,\n label: emoteLabel(id),\n run: () => preview?.playEmote(id),\n })),\n },\n ];\n const online = hub.game?.net != null;\n return {\n title: looks.spec.name,\n actions,\n play: online\n ? { caption: t(\"online\"), title: hub.game?.worldName ?? \"\" }\n : {\n caption: t(\"world\"),\n title: worldName ?? t(\"firstWorld\"),\n actions: [\n {\n id: \"change\",\n label: t(\"change\"),\n icon: \"globe\",\n run: () => features.open(FEATURE.worlds, { tab: \"mine\" }),\n },\n {\n id: \"new\",\n label: t(\"newWorld\"),\n icon: \"plus\",\n run: () => features.open(FEATURE.worlds, { tab: \"new\" }),\n },\n ],\n more: [\n {\n id: \"export\",\n label: t(\"exportW\"),\n disabled: worldName === null,\n run: () => void exportWorld(),\n },\n {\n id: \"import\",\n label: t(\"importW\"),\n run: () =>\n pickTextFile(\".json,application/json\", (text) =>\n hub.play({ kind: \"file\", text }),\n ),\n },\n {\n id: \"seed\",\n label: t(\"seed\"),\n run: () => {\n const s = prompt(\n t(\"seedPrompt\"),\n String((Math.random() * 1e9) | 0),\n );\n if (s === null || s.trim() === \"\") return;\n const seed = Number(s) || 0;\n hub.play(\n { kind: \"new\", source: seed },\n t(\"seedWorld\", { s: seed }),\n );\n },\n },\n ],\n },\n };\n };\n\n return {\n mode: MODE,\n mount(host, place) {\n if (!preview) {\n preview = new CharacterPreview(canvas, hub.looks.spec, hub.atlas());\n preview.autoRotate = false;\n stage = buildLobbyStage(hub.atlas());\n preview.setBackdrop(stage);\n }\n const p = preview;\n const st = stage!;\n p.onFrame = (dt) => {\n driftStage(st, dt);\n p.stageRect = place.spot();\n };\n let shown = hub.looks.spec;\n p.setSpec(shown);\n p.actor.setOutfit(hub.looks.outfit);\n const off = hub.looks.subscribe(() => {\n if (hub.looks.spec !== shown) {\n shown = hub.looks.spec;\n p.setSpec(shown);\n }\n p.actor.setOutfit(hub.looks.outfit);\n });\n host.append(wrap);\n p.start();\n refreshWorld();\n return () => {\n off();\n p.stop();\n wrap.remove();\n };\n },\n view,\n subscribe(fn) {\n listeners.add(fn);\n return () => {\n listeners.delete(fn);\n };\n },\n };\n}\n"
|
|
810
727
|
},
|
|
811
728
|
{
|
|
812
729
|
"path": "lobby/ids.ts",
|
|
@@ -818,7 +735,7 @@
|
|
|
818
735
|
},
|
|
819
736
|
{
|
|
820
737
|
"path": "lobby/LockerScreen.tsx",
|
|
821
|
-
"content": "// Вкладка лобби «Шкафчик»: «Мои персонажи» (играть, показать в редакторе, удалить) и «Мои предметы»\n// (надеть / снять, открыть в редакторе предметов, удалить). Хранилище — IndexedDB (CharacterStore),\n// облик — общий с игрой и подиумом (character/Looks).\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n SectionTitle,\n outlined,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport type { VoxelHub } from \"../hub\";\nimport { specHash } from \"
|
|
738
|
+
"content": "// Вкладка лобби «Шкафчик»: «Мои персонажи» (играть, показать в редакторе, удалить) и «Мои предметы»\n// (надеть / снять, открыть в редакторе предметов, удалить). Хранилище — IndexedDB (CharacterStore),\n// облик — общий с игрой и подиумом (character/Looks).\nimport { useEffect, useState, type ReactNode } from \"react\";\nimport type {\n FeatureRegistry,\n FeatureScreenProps,\n} from \"@idosgames/module-sdk\";\nimport {\n Button,\n Card,\n EmptyState,\n Icon,\n SectionTitle,\n outlined,\n useStagger,\n v,\n} from \"@idosgames/react/ui\";\nimport type { VoxelHub } from \"../hub\";\nimport { specHash } from \"@idosgames/characters/voxel\";\nimport { registerSkin } from \"../character/items/skins\";\nimport type { StoredCharacter, StoredItem } from \"../systems/CharacterStore\";\nimport { itemName } from \"../ui/quests/strings\";\nimport { rigLabel, slotLabel } from \"../ui/charLabels\";\nimport { useLooks } from \"./useLooks\";\nimport { FEATURE } from \"./ids\";\nimport { t } from \"./studioStrings\";\n\nexport function makeLockerScreen(hub: VoxelHub, features: FeatureRegistry) {\n return function LockerScreen(_: FeatureScreenProps): ReactNode {\n const looks = useLooks(hub);\n const stagger = useStagger();\n const [chars, setChars] = useState<StoredCharacter[] | null>(null);\n const [items, setItems] = useState<StoredItem[]>([]);\n const [noStore, setNoStore] = useState(false);\n\n useEffect(() => {\n let live = true;\n const load = async (): Promise<void> => {\n const store = await looks.store();\n if (!live) return;\n if (!store) return setNoStore(true);\n const [c, i] = await Promise.all([store.characters(), store.items()]);\n if (!live) return;\n setChars(c);\n setItems(i);\n };\n void load();\n const off = looks.subscribe(() => void load());\n return () => {\n live = false;\n off();\n };\n }, [looks]);\n\n if (noStore) return <EmptyState glyph=\"box\" text={t(\"noStore\")} />;\n if (!chars) return null;\n const current = specHash(looks.spec);\n const name = (s: string): ReactNode => (\n <div\n style={{\n ...outlined,\n fontSize: 16,\n overflow: \"hidden\",\n textOverflow: \"ellipsis\",\n whiteSpace: \"nowrap\",\n }}\n >\n {s}\n </div>\n );\n const meta = (s: string): ReactNode => (\n <div style={{ fontSize: 12, color: v.textDim, fontWeight: 700 }}>{s}</div>\n );\n const line = {\n display: \"flex\",\n alignItems: \"center\",\n gap: 10,\n flexWrap: \"wrap\" as const,\n };\n\n return (\n <div style={{ display: \"grid\", gap: 16 }}>\n <div style={{ display: \"grid\", gap: 10 }}>\n <SectionTitle>{t(\"myChars\")}</SectionTitle>\n {chars.length ? null : (\n <EmptyState glyph=\"hero\" text={t(\"noChars\")} />\n )}\n {chars.map((c, i) => (\n <Card\n key={c.id}\n highlight={c.id === current}\n style={{ ...stagger(i), ...line }}\n >\n <Icon glyph=\"hero\" size={34} />\n <div style={{ flex: \"1 1 140px\", minWidth: 0 }}>\n {name(c.name)}\n {meta(rigLabel(c.spec.rig))}\n </div>\n {c.id === current ? (\n <Icon glyph=\"check\" size={24} />\n ) : (\n <Button\n size=\"sm\"\n tone=\"gold\"\n onClick={() => void looks.useCharacter(c.spec)}\n >\n {t(\"play\")}\n </Button>\n )}\n <Button\n size=\"sm\"\n tone=\"red\"\n onClick={() => void looks.deleteCharacter(c.id)}\n >\n <Icon glyph=\"trash\" size={16} />\n </Button>\n </Card>\n ))}\n </div>\n\n <div style={{ display: \"grid\", gap: 10 }}>\n <SectionTitle>{t(\"myItems\")}</SectionTitle>\n {items.length ? null : (\n <EmptyState glyph=\"sword\" text={t(\"noItems\")} />\n )}\n {items.map((it, i) => {\n const skin = registerSkin(it.spec);\n const slot = it.spec.kind === \"wearable\" ? it.spec.slot : undefined;\n const on = slot ? looks.outfit[slot] === skin.hash : false;\n return (\n <Card key={it.id} style={{ ...stagger(i), ...line }}>\n <img\n src={skin.iconURL}\n alt=\"\"\n style={{ width: 40, height: 40, imageRendering: \"pixelated\" }}\n />\n <div style={{ flex: \"1 1 140px\", minWidth: 0 }}>\n {name(it.name)}\n {meta(\n it.spec.kind === \"held\"\n ? t(\"lookOf\", { i: itemName(it.spec.base ?? \"\") })\n : t(\"wearOn\", { s: slotLabel(slot ?? \"head\") }),\n )}\n </div>\n <Button\n size=\"sm\"\n tone=\"blue\"\n onClick={() =>\n features.open(FEATURE.items, { item: it.spec })\n }\n >\n {t(\"show\")}\n </Button>\n {slot ? (\n <Button\n size=\"sm\"\n tone={on ? \"grey\" : \"gold\"}\n onClick={() => looks.wear(slot, on ? null : skin.hash)}\n >\n {on ? t(\"takeOff\") : t(\"wear\")}\n </Button>\n ) : null}\n <Button\n size=\"sm\"\n tone=\"red\"\n onClick={() => void looks.deleteItem(it.id)}\n >\n <Icon glyph=\"trash\" size={16} />\n </Button>\n </Card>\n );\n })}\n </div>\n </div>\n );\n };\n}\n"
|
|
822
739
|
},
|
|
823
740
|
{
|
|
824
741
|
"path": "lobby/register.ts",
|
|
@@ -842,7 +759,7 @@
|
|
|
842
759
|
},
|
|
843
760
|
{
|
|
844
761
|
"path": "main.ts",
|
|
845
|
-
"content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport { tr } from \"./i18n\";\nimport { tickNpcs, type PendingNpc } from \"./game/npcs\";\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 { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView, ARM_LAYER } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { BlockUpdates } from \"./systems/BlockUpdates\";\nimport { BlockEntities } from \"./world/BlockEntities\";\nimport { DIMENSIONS, type DimensionId } from \"./world/Dimensions\";\nimport { portalsOf } from \"./world/Portal\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { type MapData } from \"./systems/Maps\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { Freezing } from \"./systems/Freezing\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { NetUI } from \"./ui/NetUI\";\nimport { ChatConsole } from \"./ui/ChatConsole\";\nimport { SignEditor } from \"./ui/SignEditor\";\nimport { BookScreen } from \"./ui/BookScreen\";\nimport { NameEditor } from \"./ui/NameEditor\";\nimport { TouchControls, touchWanted } from \"./ui/TouchControls\";\nimport { Commands } from \"./systems/Commands\";\nimport { BeaconBeams } from \"./gfx/BeaconBeams\";\nimport { DisplayItems } from \"./gfx/DisplayItems\";\nimport { SignText } from \"./gfx/SignText\";\nimport { Banners } from \"./gfx/Banners\";\nimport { Achievements } from \"./systems/Achievements\";\nimport { Raid } from \"./systems/Raid\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { WorldStore } from \"./systems/WorldStore\";\nimport { startSource } from \"./worlds/startWorld\";\nimport type { DimensionSave, EffectId, SaveData, WorldSource } from \"./types\";\nimport { sim } from \"./net/sim\";\nimport { NetGame, type NetRoom } from \"./net/NetGame\";\nimport type { DiffStore } from \"./net/DiffStore\";\nimport type {\n RoomSettings,\n Welcome,\n WorldMeta,\n WorldState,\n} from \"./net/protocol\";\nimport {\n netWorldMeta,\n netWorldState,\n applyNetWorldState,\n mergeBeyond,\n netPlayerState,\n loadNetWorld,\n adoptNetWorldAsOwn,\n acceptRoom,\n exitNet,\n} from \"./game/netSession\";\nimport {\n persist,\n openStoredWorld,\n openSharedWorld,\n adoptNewWorld,\n warmup,\n warmupAtSpawn,\n rebuildWorld,\n newWorld,\n changeDimension,\n} from \"./game/worlds\";\nimport {\n onDeath,\n respawnPlayer,\n goHome,\n anchorHome,\n useBed,\n skipNight,\n} from \"./game/respawn\";\nimport {\n tickPortal,\n rememberPortal,\n addPortal,\n forgetPortal,\n applyNetPortal,\n nearestPortal,\n} from \"./game/portals\";\nimport {\n tickPlates,\n beyondTravel,\n initBeyond,\n arrivalPlatform,\n placeGateway,\n openGateway,\n tickGateway,\n dragonDefeated,\n throwEye,\n} from \"./game/beyond\";\nimport { tickRaid, siege, settleVillages } from \"./game/raid\";\nimport {\n tickTrials,\n trialSpawn,\n tickFreeze,\n tickSculk,\n lightning,\n} from \"./game/environment\";\nimport { goatHorn, spyglass, surveyHeldMap, createMap } from \"./game/items\";\nimport type { GameOptions } from \"./game/types\";\nimport { AUTOSAVE_EVERY } from \"./game/shared\";\nimport { wireEvents } from \"./game/wire\";\nimport { AdaptiveQuality, applyGraphicsQuality } from \"./gfx/quality\";\nimport { BlockParticles } from \"./gfx/BlockParticles\";\nimport { PlayerCharacter } from \"./game/playerCharacter\";\nimport { EmoteWheel } from \"./ui/EmoteWheel\";\nimport { QuestBook } from \"./quests/QuestBook\";\nimport { survivalSource } from \"./quests/survival\";\nimport { QuestTracker } from \"./ui/quests/QuestTracker\";\nimport { AimHint } from \"./ui/AimHint\";\nimport { RecipeToast } from \"./ui/RecipeToast\";\nimport { QuestBookUI } from \"./ui/quests/QuestBookUI\";\nimport { WorldAtlas } from \"./systems/WorldAtlas\";\nimport { onLangChange } from \"./i18n\";\nimport { NavHud } from \"./ui/map/NavHud\";\nimport { installItemTooltip } from \"./ui/itemTooltip\";\nimport { WorldMapUI } from \"./ui/map/WorldMapUI\";\nimport { CatalogUI } from \"./ui/catalog/CatalogUI\";\nimport { VoxelHub, type WorldLaunch } from \"./hub\";\nimport type { CharacterSpec } from \"./character/CharacterSpec\";\nimport {\n characterMobType,\n ensureCharacterMob,\n type CharBehavior,\n} from \"./entities/characterMobs\";\nimport type { ShadowStamp } from \"./systems/DayNight\";\nimport { ChunkWorkerPool } from \"./world/worker/ChunkWorkerPool\";\n\nexport type { GameOptions } from \"./game/types\";\n\n/** Карта теней источника сдвинулась (клетка привязки) или солнце повернулось больше angle радиан. */\nfunction stampMoved(\n light: THREE.DirectionalLight,\n seen: ShadowStamp,\n angle: number,\n): boolean {\n const st = light.userData.shadowStamp as ShadowStamp | undefined;\n if (!st) return true;\n return (\n st.iu !== seen.iu ||\n st.iv !== seen.iv ||\n st.dir.dot(seen.dir) < Math.cos(angle)\n );\n}\n\nfunction seeStamp(light: THREE.DirectionalLight, seen: ShadowStamp): void {\n const st = light.userData.shadowStamp as ShadowStamp | undefined;\n if (!st) return;\n seen.iu = st.iu;\n seen.iv = st.iv;\n seen.dir.copy(st.dir);\n}\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n /** Адаптивное качество: долго низкий FPS — рендер в 1× пикселей. */\n private readonly quality: AdaptiveQuality;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n /** Снимает разом все DOM-подписки игры (document/window/канвас) в destroy(). */\n private readonly _abort = new AbortController();\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n /** Блоки без опоры (факел, растение, дверь) ломаются вслед за соседом. */\n blockUpdates: BlockUpdates;\n /** Состояние клеток текущего измерения: сундуки, печи… */\n blockEntities: BlockEntities;\n /** Лучи светящих маяков. */\n beams: BeaconBeams;\n /** Предметы, выставленные в рамках. */\n displays: DisplayItems;\n /** Надписи на табличках. */\n signs: SignText;\n banners: Banners;\n armorStands: ArmorStand;\n fireworks: Fireworks;\n /** Осколки блоков: при ударе по блоку и при разрушении (gfx/BlockParticles). */\n particles: BlockParticles;\n /** Достижения мира (systems/Achievements) и музыка (systems/Music). */\n achievements: Achievements;\n music: Music;\n /** Измерение, в котором игрок, и снимки остальных (дифы, сущности) до возвращения в них. */\n dimension: DimensionId = \"overworld\";\n readonly dims = new Map<DimensionId, DimensionSave>();\n interaction: Interaction;\n /** Вода и лава. */\n fluids: FluidSim;\n dayNight: DayNight;\n /** Дождь и гроза (только в измерениях с небом). */\n weather: Weather;\n /** Секунды в портале; lock — не переносить снова, пока игрок не вышел из портала прибытия. */\n _portalT = 0;\n _portalLock = false;\n /** Известные порталы: обратный путь выводит к своему порталу (в верхнем мире — в 128 блоках). */\n portals = portalsOf(null);\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны (сейв v3, `beyond`). */\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны, где Врата (после победы). */\n beyondState: {\n initialized: boolean;\n dragonKilled: boolean;\n creditsShown: boolean;\n gateway: [number, number, number] | null;\n } = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n audio: Audio;\n /** Яркие моменты для роликов платформы (GameOptions.highlight); null — записи нет. */\n highlight: ((name: string, weight: number) => void) | null = null;\n input: Input;\n armView: ArmView;\n /** Персонаж игрока: тело от третьего лица, камера F5, рецепт облика (game/playerCharacter). */\n character: PlayerCharacter;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n /** Экран «Сетевая игра» и список игроков по Tab. */\n netUI: NetUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Жесты и эмоции персонажа (G). */\n emoteWheel: EmoteWheel;\n /** Книга заданий (quests/QuestBook): линия «Выживание» и будущие источники заданий. */\n quests: QuestBook;\n /** Трекер заданий на экране и сама книга (J). */\n questTracker: QuestTracker;\n aimHint: AimHint;\n recipeToast: RecipeToast;\n questBookUI: QuestBookUI;\n /** Своя карта игрока (systems/WorldAtlas): разведанная местность и метки; сейв `atlas`. */\n worldMap = new WorldAtlas();\n /** Компас, миникарта и метка цели в мире (ui/map/NavHud). */\n navHud: NavHud;\n /** Большая карта (M). */\n mapUI: WorldMapUI;\n /** Справочник (R): как получить предмет, из чего он делается, для чего нужен. */\n catalogUI: CatalogUI;\n /** Студия персонажа и предметов (кнопка «Персонаж» в инвентаре). */\n /**\n * Общее с лобби SDK (hub.ts): атлас, облик игрока, мир, выбранный в лобби. Без лобби (стенд,\n * тесты) — свой.\n */\n readonly hub: VoxelHub;\n /** Лобби SDK (ctx.lobby): «В лобби» в паузе. null — лобби нет, кнопки нет. */\n readonly lobbyApi: { available(): boolean; open(): boolean } | null;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n _time: number;\n loop: GameLoop;\n /** Текущий мир в «Моих мирах». Классический стартовый мир — \"default\". */\n worldId = \"default\";\n worldName = \"VoxelCraft\";\n /** Хранилище миров; null — браузер не даёт IndexedDB (игра работает без сохранения). */\n store: WorldStore | null = null;\n private _autosaveT = 0;\n /** Был ли день на прошлом тике — рассвет пополняет товар жителей. */\n private _wasDay = true;\n /** Деревни, в которые уже поселены жители (id региона; сейв v3, `villages`). */\n villagesSettled = new Set<string>();\n private _villageT = 0;\n /** Ночей без сна (сейв v3 `daysAwake`); _slept — спал этой ночью. */\n daysAwake = 0;\n _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n _worldTouched = false;\n _destroyed = false;\n // ---- сетевая игра (net/NetGame) ----\n /** Идёт сетевая игра (хост или гость); null — одиночная. */\n net: NetGame | null = null;\n /** Последний мир восстановлен из «Моих миров» (или его нет) — хосту открывать сеть можно. */\n readonly restored: Promise<void>;\n private _restored!: () => void;\n /** Имя этого игрока для других (профиль SDK). */\n readonly playerName: () => string;\n /**\n * Хост: сохранённые игроки сетевой игры по userId (позиция, инвентарь, опыт…). Лежат в сейве мира\n * хоста (`players`): вернувшийся игрок продолжает с тем же инвентарём и там же.\n */\n netPlayers = new Map<string, unknown>();\n /** Лёг спать в сетевой игре: ночь пропускается, когда спят все (как в Minecraft). */\n netSleeping = false;\n /** Мир, в котором игрок был до входа в чужую игру: вернуться в него при выходе. */\n _soloWorldBeforeNet: string | null = null;\n\n /** Этот процесс считает своё измерение (одиночная игра, хост, владелец измерения) — net/sim. */\n get simulating(): boolean {\n return sim.authority;\n }\n\n constructor(opts: GameOptions = {}) {\n this.hub = opts.hub ?? new VoxelHub();\n this.lobbyApi = opts.lobby ?? null;\n this.restored = new Promise<void>((r) => (this._restored = r));\n this.playerName =\n opts.playerName ?? (() => tr({ en: \"Player\", ru: \"Игрок\" }));\n this.highlight = opts.highlight ?? null;\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n applyGraphicsQuality(); // пресет по устройству — до рендера и мира: они читают CONFIG\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(\n Math.min(window.devicePixelRatio, CONFIG.graphics.maxPixelRatio),\n );\n this.quality = new AdaptiveQuality(() => {\n if (this.renderer.getPixelRatio() <= 1) return;\n this.renderer.setPixelRatio(1);\n this._resize();\n });\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 = this.hub.atlas();\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, startSource());\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.blockUpdates = new BlockUpdates(() => this.world, this.entities);\n // броня из инвентаря гасит урон игрока и изнашивается от ударов\n this.player.armorStats = () => this.inventory.armorStats();\n this.player.onArmorHit = (dmg) => this.inventory.damageArmor(dmg);\n this.player.canGlide = () => this.inventory.canGlide();\n this.player.tryTotem = () => {\n if (this.inventory.heldItem()?.item !== \"life_totem\") return false;\n this.inventory.consumeHeld(1);\n return true;\n };\n this.player.onGlideWear = () => this.inventory.wearWings();\n this.blockEntities = new BlockEntities(() => this.world, this.entities);\n this.beams = new BeaconBeams(this.scene);\n this.displays = new DisplayItems(this.scene, this.atlas);\n this.signs = new SignText(this.scene);\n this.banners = new Banners(this.scene);\n this.armorStands = new ArmorStand(this.scene, this.atlas);\n this.fireworks = new Fireworks(this.scene);\n this.particles = new BlockParticles(this.scene, this.atlas, this.world);\n this.achievements = new Achievements({\n has: (pred) => this.inventory.slots.some((s) => !!s && pred(s.item)),\n wearing: (pred) => this.inventory.armor.some((s) => !!s && pred(s.item)),\n effect: (id) => this.player.effects.has(id as EffectId),\n });\n this.quests = new QuestBook({\n active: () => this.playing && this.player.mode === \"survival\",\n items: () => this.inventory.slots,\n armor: () => this.inventory.armor,\n y: () => this.player.pos.y,\n light: () => {\n const e = this.player.eyePos;\n const l = this.world.getLight(\n Math.floor(e.x),\n Math.floor(e.y),\n Math.floor(e.z),\n );\n return { sky: l >> 4, block: l & 15 };\n },\n phase: () => this.dayNight.phase,\n isDay: () => this.dayNight.isDay,\n food: () => this.player.food,\n health: () => this.player.health,\n dead: () => this.player.dead,\n dimension: () => this.dimension,\n near: (mobs, r) => {\n const p = this.player.pos;\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n mobs.includes((e as Mob).def.name) &&\n e.pos.distanceToSquared(p) <= r * r\n )\n return true;\n return false;\n },\n give: (item, count) => {\n const left = this.inventory.add(item, count);\n if (left > 0) {\n const p = this.player.pos;\n this.entities.spawnDrop(p.x, p.y + 0.5, p.z, item, left);\n }\n },\n touch: () => document.body.classList.contains(\"touch\"),\n });\n this.quests.addSource(survivalSource);\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.fluids = new FluidSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.weather = new Weather(this.scene);\n const start = this.world.source;\n if (start.kind === \"spec\") {\n this.dayNight.setTimeOfDay(start.spec.ambience.timeOfDay);\n this.worldName = start.spec.name;\n }\n this.audio = new Audio();\n this.music = new Music(this.audio);\n // пластинка в проигрывателе играет свою мелодию (сид и лад — systems/Disc), вынули — тишина\n events.on(\"discPlay\", ({ mood, seed }: { mood: Mood; seed: number }) =>\n this.music.play(mood, seed),\n );\n events.on(\"discEject\", () => this.music.stop());\n this.input = new Input(canvas, this.intents, this._abort.signal);\n this.armView = new ArmView(this.camera, this.atlas);\n this.character = new PlayerCharacter(this, this.hub.looks);\n // второй проход (рука) видит только свой слой — свет мира включаем и на нём\n this.dayNight.sun.layers.enable(ARM_LAYER);\n this.dayNight.hemi.layers.enable(ARM_LAYER);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n new AchievementsView(this.hud.heartsEl.parentElement!, this.achievements);\n this.invUI = new InventoryUI(this, this._abort.signal);\n this.netUI = new NetUI(this, this._abort.signal);\n this.commands = new Commands(this);\n this.chat = new ChatConsole(this, this._abort.signal);\n this.signEditor = new SignEditor(this, this._abort.signal);\n this.bookScreen = new BookScreen(this, this._abort.signal);\n this.nameEditor = new NameEditor(this, this._abort.signal);\n this.emoteWheel = new EmoteWheel(this, this._abort.signal);\n this.questTracker = new QuestTracker(this);\n this.aimHint = new AimHint(this);\n this.recipeToast = new RecipeToast(this);\n this.questBookUI = new QuestBookUI(this, this._abort.signal);\n this.navHud = new NavHud(this, this._abort.signal);\n // имя предмета при наведении на любой слот (инвентарь, сундук, печь, хотбар, палитра)\n installItemTooltip(\n document.getElementById(\"game\")!.parentElement!,\n this._abort.signal,\n );\n this.mapUI = new WorldMapUI(this, this._abort.signal);\n this.catalogUI = new CatalogUI(this, this._abort.signal);\n // язык сменили (пауза или лобби SDK) — открытое перерисовать сейчас, остальное — при показе\n const offLang = onLangChange(() => {\n this.hud.langChanged();\n this.hud._renderHotbar(); // подсказки слотов\n if (this.invUI.open) this.invUI._render();\n events.emit(\"questsChanged\"); // трекер и книга заданий\n this.catalogUI.refresh();\n });\n this._abort.signal.addEventListener(\"abort\", offLang);\n this.touch = touchWanted()\n ? new TouchControls(this, this._abort.signal)\n : null;\n this.interaction.onCommand = (text) => this.commands.run(text);\n this.interaction.mayToggleMode = () => !this.net || this.net.canCheat;\n this.interaction.onUseMap = () => this.createMap();\n this.playing = false;\n this._time = 0;\n wireEvents(this);\n const { signal } = this._abort;\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n },\n { signal },\n );\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\n \"click\",\n () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n },\n { signal },\n );\n\n // --- старт мира ---\n this.warmupAtSpawn();\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n // сетевая игра: скрытая вкладка продолжает считать мир в полном темпе (core/GameLoop)\n this.loop.backgroundTicker = () => this.net !== null;\n this.hud.showOverlay(false); // захват мыши — только по клику игрока\n this.loop.start();\n this.hub.attach(this);\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open()\n .then(async (store) => {\n if (store && this._destroyed) return store.close();\n if (this._destroyed) return;\n this.store = store;\n // мир, выбранный в лобби («Играть в этом мире», «Мои миры», файл) — вместо последнего\n const launch = this.hub.takeLaunch();\n if (launch) {\n await this.applyLaunch(launch);\n return;\n }\n if (!store) return;\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(\n tr(\n { en: \"Continuing world «{n}»\", ru: \"Продолжаем мир «{n}»\" },\n { n: last.name },\n ),\n 2200,\n );\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n })\n .finally(() => this._restored());\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). Гость чужой мир не сохраняет. */\n persist(): Promise<void> {\n return persist(this);\n }\n\n /** Открыть мир из «Моих миров». */\n openStoredWorld(id: string): Promise<boolean> {\n return openStoredWorld(this, id);\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n return openSharedWorld(this, save, name);\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n return adoptNewWorld(this, name);\n }\n\n /** Гость в чужом мире: мир не наш — не сохраняем его в «Мои миры» (до передачи хоста). */\n _netGuest = false;\n\n /** Мир для входящего гостя: источник, палитра, время, погода, сложность, Предел, порталы. */\n netWorldMeta(): WorldMeta {\n return netWorldMeta(this);\n }\n\n /** Общее состояние мира (хост раз в 5 с): время, погода, сложность, Предел. */\n netWorldState(): WorldState {\n return netWorldState(this);\n }\n\n applyNetWorldState(w: WorldState): void {\n applyNetWorldState(this, w);\n }\n\n /** Предел: победа над драконом и Врата — однажды случившись, не отменяются (слияние «или»). */\n mergeBeyond(b: unknown): void {\n mergeBeyond(this, b);\n }\n\n /** Сейв этого игрока для мира хоста: позиция, измерение, инвентарь, опыт, эффекты. */\n netPlayerState(): Record<string, unknown> {\n return netPlayerState(this);\n }\n\n /** Ночь пропущена: спали все (сетевая игра) или один (одиночная). */\n skipNight(): void {\n skipNight(this);\n }\n\n /**\n * Гость: мир хоста вместо своего. Свой уходит в «Мои миры» и вернётся при выходе. Все измерения\n * строятся из присланных дифов — порталы работают без ожидания.\n */\n loadNetWorld(w: Welcome, diffs: DiffStore, settings: RoomSettings): void {\n loadNetWorld(this, w, diffs, settings);\n }\n\n /** Стал хостом после ухода прежнего: этот мир теперь наш — новой записью в «Моих мирах». */\n adoptNetWorldAsOwn(): void {\n adoptNetWorldAsOwn(this);\n }\n\n _netWorldName = \"\";\n\n /**\n * Комната пришла из лобби (хост нажал «Играть», или игрок вошёл в уже идущую игру). Хост открывает\n * свой мир — последний, «Моих миров»; гость получает мир хоста. Ошибка — строка для игрока, и\n * комната закрывается: полуначатая сетевая игра хуже одиночной.\n */\n acceptRoom(room: NetRoom): Promise<void> {\n return acceptRoom(this, room);\n }\n\n /**\n * Сетевая игра закончилась. Гость возвращается в свой мир; хост (и ставший хостом) продолжает\n * в этом мире один. `reason` — показать почему (исключили, хост закрыл игру).\n */\n exitNet(reason: string | null): void {\n exitNet(this, reason);\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n return warmup(this, x, z);\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n return warmupAtSpawn(this);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n return rebuildWorld(this, source, dimension);\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n return newWorld(this, source);\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n return changeDimension(this, target, x, y, z);\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n return onDeath(this);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n return respawnPlayer(this);\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n return goHome(this);\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n return tickPortal(this, dt);\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n return tickPlates(this, dt);\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n return beyondTravel(this);\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n return initBeyond(this);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n return arrivalPlatform(this);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n return placeGateway(this, x, y, z);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n return openGateway(this);\n }\n\n /** Идущий набег на деревню (systems/Raid) или null. Не сохраняется: перезагрузка его прекращает. */\n raid: Raid | null = null;\n\n /**\n * Набег: игрок с Дурным знамением у заселённой деревни — знамение сгорает, начинается набег. Волны\n * приходят с одного края деревни (в 28–34 блоках от центра) и идут к центру; победа — «Герой\n * деревни» уровня знамения, поражение — все жители в 64 блоках мертвы или вышло время.\n */\n _tickRaid(dt: number) {\n return tickRaid(this, dt);\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n return tickGateway(this);\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n return dragonDefeated(this);\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n return throwEye(this, x, y, z);\n }\n\n /** Сетевая игра: реестр порталов изменился здесь — разослать (net/NetGame). */\n onPortalChange:\n | ((dim: DimensionId, p: [number, number, number], add: boolean) => void)\n | null = null;\n\n /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\n return rememberPortal(this, dim, x, y, z);\n }\n\n _addPortal(dim: DimensionId, x: number, y: number, z: number): boolean {\n return addPortal(this, dim, x, y, z);\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n return forgetPortal(this, dim, p);\n }\n\n /** Сетевая игра: портал зажгли или сломали у другого игрока. */\n applyNetPortal(dim: DimensionId, p: readonly number[], add: boolean): void {\n applyNetPortal(this, dim, p, add);\n }\n\n /** Ближайший известный портал измерения в квадрате radius вокруг (x, z). */\n _nearestPortal(\n dim: DimensionId,\n x: number,\n z: number,\n radius: number,\n ): [number, number, number] | null {\n return nearestPortal(this, dim, x, z, radius);\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n anchorHome(): THREE.Vector3 | null {\n return anchorHome(this);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n return useBed(this, x, y, z);\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n createMap(this);\n }\n\n _trialScanT = 0;\n\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n tickTrials(this, dt);\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n return trialSpawn(this, x, y, z, mob);\n }\n\n freezing = new Freezing();\n\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n tickFreeze(this, dt);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n goatHorn(this, dt);\n }\n\n /** Скалк, вибрации и Страж (systems/Sculk.ts) — в верхнем мире. */\n sculk = new Sculk({\n world: () => this.world,\n player: () => this.player,\n wardens: () =>\n [...this.entities.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\" && !e.dead && !!e.def.warden,\n ),\n summon: (x, y, z) => {\n const m = this.entities.spawnMob(\"warden\", x, y, z);\n m.emergeT = EMERGE_TIME;\n m.invulnerable = true;\n return m;\n },\n });\n /** Пройдено шагов с последней вибрации (вибрация — каждые 2 блока ходьбы без приседа). */\n _stepDist = 0;\n /**\n * Где игрок умер последний раз — туда указывает компас возвращения (в сейв не пишется). `guide` —\n * компас и стрелка ведут туда за вещами, пока игрок не дойдёт (ui/map/NavHud).\n */\n lastDeath: {\n x: number;\n y: number;\n z: number;\n dim: string;\n guide?: boolean;\n } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n tickSculk(this, dt);\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n spyglass(this, dt);\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n surveyHeldMap(this);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n return siege(this);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n return settleVillages(this);\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n return lightning(this, x, y, z);\n }\n\n /**\n * Призвать существо с обликом персонажа в двух блоках перед игроком: NPC (с именем, гуляет рядом)\n * или моба с пресетом поведения (entities/characterMobs). Питомец появляется ручным.\n */\n summonCharacter(spec: CharacterSpec, behavior: CharBehavior): Mob | null {\n const p = this.player;\n const d = p.lookDir();\n const len = Math.hypot(d.x, d.z) || 1;\n return this.summonCharacterAt(\n spec,\n behavior,\n p.pos.x + (d.x / len) * 2,\n p.pos.y + 0.1,\n p.pos.z + (d.z / len) * 2,\n );\n }\n\n /** Существо с обликом персонажа в точке (NPC из рецепта мира, агент). */\n summonCharacterAt(\n spec: CharacterSpec,\n behavior: CharBehavior,\n x: number,\n y: number,\n z: number,\n ): Mob | null {\n const type = characterMobType(spec, behavior);\n if (!ensureCharacterMob(type, spec)) return null;\n const m = this.entities.spawnMob(type, x, y, z);\n if (behavior === \"npc\") m.setName(spec.name);\n if (behavior === \"pet\") m.tamed = true;\n return m;\n }\n\n /** NPC рецепта мира, которые ещё не встали в мир (game/npcs; сейв pendingNpcs). */\n pendingNpcs: PendingNpc[] = [];\n\n /** Из паузы — в лобби SDK: мир сохраняется, «Играть» в лобби вернёт в него. */\n toLobby() {\n this.playing = false;\n void this.persist().finally(() => this.hub.changed());\n this.lobbyApi?.open();\n }\n\n /** Мир, выбранный в лобби: новый (рецепт, сид), из «Моих миров» или из файла. */\n async applyLaunch(l: WorldLaunch): Promise<void> {\n if (l.kind === \"new\") this.newWorld(l.source);\n else if (l.kind === \"stored\") {\n if (l.id !== this.worldId && !(await this.openStoredWorld(l.id)))\n this.hud.message(\n tr({ en: \"Could not open the world\", ru: \"Не удалось открыть мир\" }),\n 2200,\n );\n } else\n try {\n this.save.importText(l.text);\n } catch (e) {\n this.hud.message(\n tr({ en: \"Could not load: \", ru: \"Не загрузилось: \" }) +\n (e as Error).message,\n 2600,\n );\n }\n this._worldTouched = true;\n this.hub.changed();\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n // сетевая игра не встаёт на паузе: мир общий, и в меню игрок остаётся в нём (как в Minecraft)\n if (!this.playing && !this.net) return;\n // Кто что считает (net/sim): своё — игрок, его действия, голод, опасности — считает каждый;\n // мир измерения (мобы, жидкости, блоки, машины) — только владелец измерения; погоду и\n // время суток — хост (остальным их присылают, время между присылками идёт само).\n const own = this.simulating;\n const host = !this.net || this.net.isHost;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n if (host)\n this.weather.tick(\n dt,\n sky && own ? this.world : null,\n this.player.pos.x,\n this.player.pos.z,\n );\n this.world.raining = sky && this.weather.raining;\n this.world.containerAt = this._containerAt;\n this.entities.raining = this.world.raining;\n this.entities.skyDarken = Math.max(\n this.dayNight.skyDarken,\n sky ? this.weather.darken : 0,\n );\n this.entities.isDay = this.dayNight.isDay;\n this.entities.tick(dt); // у реплики — только сглаживание к снимкам владельца\n if (own) this.fluids.tick();\n this.player.tickHazards(dt);\n // черепаший панцирь: над водой копит 10 с Подводного дыхания — под водой их хватает на нырок\n const wb = this.player.effects.get(\"water_breathing\");\n if (\n this.inventory.armor[0]?.item === \"turtle_helmet\" &&\n !this.player.headInWater &&\n (!wb || wb.time < 9.5)\n )\n this.player.addEffect(\"water_breathing\", 10);\n this._tickPortal(dt);\n this._tickGateway();\n if (own) this._tickRaid(dt);\n this._spyglass(dt);\n if (own) this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n if (own) {\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n }\n this.dayNight.tick(dt); // у гостя — между поправками хоста (net WORLD)\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n if (own)\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n if (host) this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n this.netSleeping = false;\n } else if (!day && this._wasDay && own) this._siege(); // стемнело — может начаться осада деревни\n this.entities.insomnia = this.daysAwake >= 3;\n this._wasDay = day;\n this._villageT += dt;\n if (this._villageT >= 1) {\n this._villageT = 0;\n if (own) this._settleVillages();\n if (own) tickNpcs(this); // NPC рецепта мира — как только прогрузилось их место\n }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\n // своя карта: чанки вокруг, которые прогрузились или изменились\n const p = this.player.pos;\n this.worldMap.survey(this.world, this.dimension, p.x, p.z, 96);\n }\n events.emit(\"tick\", { n });\n this.net?.afterTick();\n this._autosaveT += dt;\n if (this._autosaveT >= AUTOSAVE_EVERY) {\n this._autosaveT = 0;\n void this.persist();\n }\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.quality.frame(dt, this.loop.fps);\n this.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n this.quests.tick(dt);\n }\n\n // дальность обзора: с дальним кольцом LOD — дальше полных чанков (туман и дальняя плоскость камеры)\n const view = this.world.viewChunks;\n if (this.dayNight.viewChunks !== view) {\n this.dayNight.viewChunks = view;\n this.camera.far = view * 16 + 60;\n this.camera.updateProjectionMatrix();\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 this.character.frame(dt); // тело игрока; от третьего лица — камера отъезжает\n this.questTracker.frame(dt);\n this.aimHint.frame(dt);\n this.recipeToast.frame(dt);\n this.navHud.frame();\n this.mapUI.frame();\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.net?.frame(dt);\n this.beams.update(this.blockEntities, this._time);\n this.displays.update(this.blockEntities, this.world);\n this.signs.update(this.blockEntities, this.world);\n this.banners.update(this.blockEntities, this.world);\n this.armorStands.update(this.blockEntities);\n this.fireworks.update(dt);\n this.particles.skyDarken = DIMENSIONS[this.dimension].hasSky\n ? this.dayNight.skyDarken\n : 0;\n this.particles.update(dt);\n this.dayNight.frame(\n this.player.pos,\n this.player.headInLava\n ? \"lava\"\n : this.player.headInWater\n ? \"water\"\n : this.player.headInPowderSnow\n ? \"powder_snow\"\n : \"air\",\n this.dimension,\n this.player.effects.has(\"night_vision\"),\n DIMENSIONS[this.dimension].hasSky ? this.weather : undefined,\n );\n // Тьма пульсирует: то наползает, то чуть отступает\n if (this.player.effects.has(\"darkness\"))\n this.dayNight.darkness(\n 0.75 + 0.25 * Math.abs(Math.sin(this._time * 1.3)),\n );\n this.weather.frame(\n dt,\n eye,\n DIMENSIONS[this.dimension].hasSky ? this.world : null,\n );\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.invUI.frame();\n this.renderPasses();\n }\n\n /** Draw call'ы и треугольники мирового прохода прошлого кадра (отладочная строка, агент). */\n readonly renderStats = { calls: 0, triangles: 0 };\n private readonly _nearSeen: ShadowStamp = {\n iu: NaN,\n iv: 0,\n dir: new THREE.Vector3(),\n };\n private _shadowAge = 0;\n private readonly _farSeen: ShadowStamp = {\n iu: NaN,\n iv: 0,\n dir: new THREE.Vector3(),\n };\n private _farAge = 0;\n /** Меши мира менялись с прошлой перерисовки дальнего каскада. */\n private _farMeshes = false;\n\n /**\n * Ближний каскад теней перерисовываем, когда тени могли сдвинуться: сдвинулась карта или солнце,\n * подменился меш чанка, рядом движутся сущности (мобы, дроп, стрелы), идёт сетевая игра (чужие\n * игроки), или прошло полсекунды. Картинка та же, что при перерисовке каждый кадр.\n * Дальний — как LOD: только при сдвиге своей карты или солнца, при изменившихся мешах — не чаще\n * раза в полсекунды, и раз в 10 секунд на всякий случай.\n */\n private _shadowsChanged(): { near: boolean; far: boolean } {\n if (!CONFIG.graphics.shadows) return { near: false, far: false };\n this._shadowAge++;\n this._farAge++;\n const meshes =\n this.world.mesher.swapped > 0 || (this.world.lod?.rebuilt ?? 0) > 0;\n if (meshes) this._farMeshes = true;\n const near =\n stampMoved(this.dayNight.sun, this._nearSeen, 0.00125) ||\n this._shadowAge >= 30 ||\n this.world.mesher.swapped > 0 ||\n this.entities.shadowCasters > 0 ||\n !!this.net;\n if (near) {\n seeStamp(this.dayNight.sun, this._nearSeen);\n this._shadowAge = 0;\n }\n const farLight = this.dayNight.sunFar;\n let far = false;\n if (farLight) {\n far =\n stampMoved(farLight, this._farSeen, 0.004) ||\n (this._farMeshes && this._farAge >= 30) ||\n this._farAge >= 600;\n if (far) {\n seeStamp(farLight, this._farSeen);\n this._farAge = 0;\n this._farMeshes = false;\n }\n farLight.shadow.needsUpdate = far;\n }\n this.dayNight.sun.shadow.needsUpdate = near;\n return { near, far };\n }\n\n /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n const shadows = this._shadowsChanged(); // тени — только в мировом проходе\n this.renderer.shadowMap.needsUpdate = shadows.near || shadows.far;\n this.renderer.render(this.scene, this.camera);\n const info = this.renderer.info.render;\n this.renderStats.calls = info.calls;\n this.renderStats.triangles = info.triangles;\n this.renderer.autoClear = false;\n this.renderer.clearDepth();\n this.camera.layers.set(ARM_LAYER);\n this.renderer.render(this.scene, this.camera);\n this.camera.layers.set(0);\n this.renderer.autoClear = true;\n }\n\n /**\n * Снимок экрана для кнопки «поделиться» на странице игры.\n *\n * ⚠ Рисует и читает В ОДИН приём. Рендерер создан без `preserveDrawingBuffer` (и так и надо —\n * флаг стоит памяти и копии каждый кадр), поэтому к моменту, когда до канвы доберётся кто-то\n * ещё, буфер уже очищен: `toBlob` вернёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись.\n */\n async capture(): Promise<Blob | null> {\n if (this._destroyed) return null;\n this.renderPasses();\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n // сетевая игра: уйти из комнаты (хост передаёт мир преемнику) — без возврата в свой мир, игра разбирается\n const net = this.net;\n if (net) {\n net.prepareExit();\n net.dispose();\n void net.leaveRoom();\n }\n // последний снимок уходит до разбора мира; дописывается транзакция уже без нас\n void this.persist().finally(() => this.store?.close());\n this.net = null;\n this._destroyed = true;\n this.loop.stop();\n this.input.releaseLock();\n // Шина `events` и DOM-подписки живут дольше игры: хост пересоздаёт Game после перелогина, и\n // без этого старый экземпляр продолжал бы слушать — `E` эмитился дважды (новый инвентарь\n // открывался и тут же закрывался), звуки двоились, весь старый мир держался в памяти.\n this._abort.abort();\n events.clear();\n this.hud.dispose();\n this.netUI.dispose();\n this.chat.dispose();\n this.signEditor.dispose();\n this.bookScreen.dispose();\n this.nameEditor.dispose();\n this.emoteWheel.dispose();\n this.quests.dispose();\n this.questTracker.dispose();\n this.aimHint.dispose();\n this.recipeToast.dispose();\n this.questBookUI.dispose();\n this.navHud.dispose();\n this.mapUI.dispose();\n this.catalogUI.dispose();\n this.hub.attach(null);\n this.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.particles.dispose();\n this.character.dispose();\n ChunkWorkerPool.shutdown(); // воркеры чанков держат свой экземпляр мира — освободить\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 // вернулись из лобби: мир, выбранный там, и «Клик — играть» (захват мыши — по клику)\n const launch = this.hub.takeLaunch();\n if (launch) void this.applyLaunch(launch);\n if (!this.playing && !this.player.dead) this.hud.showOverlay(false);\n } else {\n // сетевая игра: мир общий — у хоста и владельцев измерений он не может встать оттого, что\n // игрок открыл другой режим; останавливается только ввод\n if (!this.net) this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
|
|
762
|
+
"content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport { tr } from \"./i18n\";\nimport { tickNpcs, type PendingNpc } from \"./game/npcs\";\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 { ChunkManager } from \"./world/ChunkManager\";\nimport { Player } from \"./player/Player\";\nimport { Input } from \"./player/Input\";\nimport { Interaction } from \"./player/Interaction\";\nimport { ArmView, ARM_LAYER } from \"./player/ArmView\";\nimport { EntityManager } from \"./entities/EntityManager\";\nimport { Inventory } from \"./systems/Inventory\";\nimport { BlockUpdates } from \"./systems/BlockUpdates\";\nimport { BlockEntities } from \"./world/BlockEntities\";\nimport { DIMENSIONS, type DimensionId } from \"./world/Dimensions\";\nimport { portalsOf } from \"./world/Portal\";\nimport \"./world/behaviours/index\"; // поведения блоков регистрируются при импорте\nimport \"./world/blockentities/index\"; // типы блочных сущностей (печь, сундук) — тоже\nimport { FluidSim } from \"./systems/FluidSim\";\nimport { DayNight } from \"./systems/DayNight\";\nimport { Weather } from \"./systems/Weather\";\nimport { type MapData } from \"./systems/Maps\";\nimport { Sculk } from \"./systems/Sculk\";\nimport { ArmorStand } from \"./gfx/ArmorStand\";\nimport { Fireworks } from \"./gfx/Fireworks\";\nimport { EMERGE_TIME } from \"./entities/WardenBrain\";\nimport { Freezing } from \"./systems/Freezing\";\nimport type { Mob } from \"./entities/Mob\";\nimport { Audio } from \"./systems/Audio\";\nimport { SaveSystem } from \"./systems/Save\";\nimport { HUD } from \"./ui/HUD\";\nimport { InventoryUI } from \"./ui/InventoryUI\";\nimport { NetUI } from \"./ui/NetUI\";\nimport { ChatConsole } from \"./ui/ChatConsole\";\nimport { SignEditor } from \"./ui/SignEditor\";\nimport { BookScreen } from \"./ui/BookScreen\";\nimport { NameEditor } from \"./ui/NameEditor\";\nimport { TouchControls, touchWanted } from \"./ui/TouchControls\";\nimport { Commands } from \"./systems/Commands\";\nimport { BeaconBeams } from \"./gfx/BeaconBeams\";\nimport { DisplayItems } from \"./gfx/DisplayItems\";\nimport { SignText } from \"./gfx/SignText\";\nimport { Banners } from \"./gfx/Banners\";\nimport { Achievements } from \"./systems/Achievements\";\nimport { Raid } from \"./systems/Raid\";\nimport { Music, type Mood } from \"./systems/Music\";\nimport { AchievementsView } from \"./ui/AchievementsView\";\nimport { WorldStore } from \"./systems/WorldStore\";\nimport { startSource } from \"./worlds/startWorld\";\nimport type { DimensionSave, EffectId, SaveData, WorldSource } from \"./types\";\nimport { sim } from \"./net/sim\";\nimport { NetGame, type NetRoom } from \"./net/NetGame\";\nimport type { DiffStore } from \"./net/DiffStore\";\nimport type {\n RoomSettings,\n Welcome,\n WorldMeta,\n WorldState,\n} from \"./net/protocol\";\nimport {\n netWorldMeta,\n netWorldState,\n applyNetWorldState,\n mergeBeyond,\n netPlayerState,\n loadNetWorld,\n adoptNetWorldAsOwn,\n acceptRoom,\n exitNet,\n} from \"./game/netSession\";\nimport {\n persist,\n openStoredWorld,\n openSharedWorld,\n adoptNewWorld,\n warmup,\n warmupAtSpawn,\n rebuildWorld,\n newWorld,\n changeDimension,\n} from \"./game/worlds\";\nimport {\n onDeath,\n respawnPlayer,\n goHome,\n anchorHome,\n useBed,\n skipNight,\n} from \"./game/respawn\";\nimport {\n tickPortal,\n rememberPortal,\n addPortal,\n forgetPortal,\n applyNetPortal,\n nearestPortal,\n} from \"./game/portals\";\nimport {\n tickPlates,\n beyondTravel,\n initBeyond,\n arrivalPlatform,\n placeGateway,\n openGateway,\n tickGateway,\n dragonDefeated,\n throwEye,\n} from \"./game/beyond\";\nimport { tickRaid, siege, settleVillages } from \"./game/raid\";\nimport {\n tickTrials,\n trialSpawn,\n tickFreeze,\n tickSculk,\n lightning,\n} from \"./game/environment\";\nimport { goatHorn, spyglass, surveyHeldMap, createMap } from \"./game/items\";\nimport type { GameOptions } from \"./game/types\";\nimport { AUTOSAVE_EVERY } from \"./game/shared\";\nimport { wireEvents } from \"./game/wire\";\nimport { AdaptiveQuality, applyGraphicsQuality } from \"./gfx/quality\";\nimport { BlockParticles } from \"./gfx/BlockParticles\";\nimport { PlayerCharacter } from \"./game/playerCharacter\";\nimport { EmoteWheel } from \"./ui/EmoteWheel\";\nimport { QuestBook } from \"./quests/QuestBook\";\nimport { survivalSource } from \"./quests/survival\";\nimport { QuestTracker } from \"./ui/quests/QuestTracker\";\nimport { AimHint } from \"./ui/AimHint\";\nimport { RecipeToast } from \"./ui/RecipeToast\";\nimport { QuestBookUI } from \"./ui/quests/QuestBookUI\";\nimport { WorldAtlas } from \"./systems/WorldAtlas\";\nimport { onLangChange } from \"./i18n\";\nimport { NavHud } from \"./ui/map/NavHud\";\nimport { installItemTooltip } from \"./ui/itemTooltip\";\nimport { WorldMapUI } from \"./ui/map/WorldMapUI\";\nimport { CatalogUI } from \"./ui/catalog/CatalogUI\";\nimport { VoxelHub, type WorldLaunch } from \"./hub\";\nimport type { CharacterSpec } from \"@idosgames/characters/voxel\";\nimport {\n characterMobType,\n ensureCharacterMob,\n type CharBehavior,\n} from \"./entities/characterMobs\";\nimport type { ShadowStamp } from \"./systems/DayNight\";\nimport { ChunkWorkerPool } from \"./world/worker/ChunkWorkerPool\";\n\nexport type { GameOptions } from \"./game/types\";\n\n/** Карта теней источника сдвинулась (клетка привязки) или солнце повернулось больше angle радиан. */\nfunction stampMoved(\n light: THREE.DirectionalLight,\n seen: ShadowStamp,\n angle: number,\n): boolean {\n const st = light.userData.shadowStamp as ShadowStamp | undefined;\n if (!st) return true;\n return (\n st.iu !== seen.iu ||\n st.iv !== seen.iv ||\n st.dir.dot(seen.dir) < Math.cos(angle)\n );\n}\n\nfunction seeStamp(light: THREE.DirectionalLight, seen: ShadowStamp): void {\n const st = light.userData.shadowStamp as ShadowStamp | undefined;\n if (!st) return;\n seen.iu = st.iu;\n seen.iv = st.iv;\n seen.dir.copy(st.dir);\n}\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n /** Адаптивное качество: долго низкий FPS — рендер в 1× пикселей. */\n private readonly quality: AdaptiveQuality;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n /** Снимает разом все DOM-подписки игры (document/window/канвас) в destroy(). */\n private readonly _abort = new AbortController();\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n /** Блоки без опоры (факел, растение, дверь) ломаются вслед за соседом. */\n blockUpdates: BlockUpdates;\n /** Состояние клеток текущего измерения: сундуки, печи… */\n blockEntities: BlockEntities;\n /** Лучи светящих маяков. */\n beams: BeaconBeams;\n /** Предметы, выставленные в рамках. */\n displays: DisplayItems;\n /** Надписи на табличках. */\n signs: SignText;\n banners: Banners;\n armorStands: ArmorStand;\n fireworks: Fireworks;\n /** Осколки блоков: при ударе по блоку и при разрушении (gfx/BlockParticles). */\n particles: BlockParticles;\n /** Достижения мира (systems/Achievements) и музыка (systems/Music). */\n achievements: Achievements;\n music: Music;\n /** Измерение, в котором игрок, и снимки остальных (дифы, сущности) до возвращения в них. */\n dimension: DimensionId = \"overworld\";\n readonly dims = new Map<DimensionId, DimensionSave>();\n interaction: Interaction;\n /** Вода и лава. */\n fluids: FluidSim;\n dayNight: DayNight;\n /** Дождь и гроза (только в измерениях с небом). */\n weather: Weather;\n /** Секунды в портале; lock — не переносить снова, пока игрок не вышел из портала прибытия. */\n _portalT = 0;\n _portalLock = false;\n /** Известные порталы: обратный путь выводит к своему порталу (в верхнем мире — в 128 блоках). */\n portals = portalsOf(null);\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны (сейв v3, `beyond`). */\n /** Предел: кристаллы и дракон поставлены, дракон убит, титры показаны, где Врата (после победы). */\n beyondState: {\n initialized: boolean;\n dragonKilled: boolean;\n creditsShown: boolean;\n gateway: [number, number, number] | null;\n } = {\n initialized: false,\n dragonKilled: false,\n creditsShown: false,\n gateway: null,\n };\n audio: Audio;\n /** Яркие моменты для роликов платформы (GameOptions.highlight); null — записи нет. */\n highlight: ((name: string, weight: number) => void) | null = null;\n input: Input;\n armView: ArmView;\n /** Персонаж игрока: тело от третьего лица, камера F5, рецепт облика (game/playerCharacter). */\n character: PlayerCharacter;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n /** Экран «Сетевая игра» и список игроков по Tab. */\n netUI: NetUI;\n /** Команды консоли и чат (T, /). */\n commands: Commands;\n chat: ChatConsole;\n /** Ввод текста таблички. */\n signEditor: SignEditor;\n bookScreen: BookScreen;\n nameEditor: NameEditor;\n /** Жесты и эмоции персонажа (G). */\n emoteWheel: EmoteWheel;\n /** Книга заданий (quests/QuestBook): линия «Выживание» и будущие источники заданий. */\n quests: QuestBook;\n /** Трекер заданий на экране и сама книга (J). */\n questTracker: QuestTracker;\n aimHint: AimHint;\n recipeToast: RecipeToast;\n questBookUI: QuestBookUI;\n /** Своя карта игрока (systems/WorldAtlas): разведанная местность и метки; сейв `atlas`. */\n worldMap = new WorldAtlas();\n /** Компас, миникарта и метка цели в мире (ui/map/NavHud). */\n navHud: NavHud;\n /** Большая карта (M). */\n mapUI: WorldMapUI;\n /** Справочник (R): как получить предмет, из чего он делается, для чего нужен. */\n catalogUI: CatalogUI;\n /** Студия персонажа и предметов (кнопка «Персонаж» в инвентаре). */\n /**\n * Общее с лобби SDK (hub.ts): атлас, облик игрока, мир, выбранный в лобби. Без лобби (стенд,\n * тесты) — свой.\n */\n readonly hub: VoxelHub;\n /** Лобби SDK (ctx.lobby): «В лобби» в паузе. null — лобби нет, кнопки нет. */\n readonly lobbyApi: { available(): boolean; open(): boolean } | null;\n /** Сенсорное управление — только на устройствах с пальцем (или ?touch=1). */\n touch: TouchControls | null;\n playing: boolean;\n _time: number;\n loop: GameLoop;\n /** Текущий мир в «Моих мирах». Классический стартовый мир — \"default\". */\n worldId = \"default\";\n worldName = \"VoxelCraft\";\n /** Хранилище миров; null — браузер не даёт IndexedDB (игра работает без сохранения). */\n store: WorldStore | null = null;\n private _autosaveT = 0;\n /** Был ли день на прошлом тике — рассвет пополняет товар жителей. */\n private _wasDay = true;\n /** Деревни, в которые уже поселены жители (id региона; сейв v3, `villages`). */\n villagesSettled = new Set<string>();\n private _villageT = 0;\n /** Ночей без сна (сейв v3 `daysAwake`); _slept — спал этой ночью. */\n daysAwake = 0;\n _slept = false;\n /** Карты мира по номеру (systems/Maps; сейв v3 `maps`). */\n maps = new Map<number, MapData>();\n nextMapId = 1;\n private _mapT = 0;\n _mapPhase = 0;\n /** Для компаратора: блочная сущность клетки текущего измерения. */\n private readonly _containerAt = (x: number, y: number, z: number) =>\n this.blockEntities.get(x, y, z);\n /** Игрок уже сменил мир до того, как открылось хранилище, — не подменять его последним сейвом. */\n _worldTouched = false;\n _destroyed = false;\n // ---- сетевая игра (net/NetGame) ----\n /** Идёт сетевая игра (хост или гость); null — одиночная. */\n net: NetGame | null = null;\n /** Последний мир восстановлен из «Моих миров» (или его нет) — хосту открывать сеть можно. */\n readonly restored: Promise<void>;\n private _restored!: () => void;\n /** Имя этого игрока для других (профиль SDK). */\n readonly playerName: () => string;\n /**\n * Хост: сохранённые игроки сетевой игры по userId (позиция, инвентарь, опыт…). Лежат в сейве мира\n * хоста (`players`): вернувшийся игрок продолжает с тем же инвентарём и там же.\n */\n netPlayers = new Map<string, unknown>();\n /** Лёг спать в сетевой игре: ночь пропускается, когда спят все (как в Minecraft). */\n netSleeping = false;\n /** Мир, в котором игрок был до входа в чужую игру: вернуться в него при выходе. */\n _soloWorldBeforeNet: string | null = null;\n\n /** Этот процесс считает своё измерение (одиночная игра, хост, владелец измерения) — net/sim. */\n get simulating(): boolean {\n return sim.authority;\n }\n\n constructor(opts: GameOptions = {}) {\n this.hub = opts.hub ?? new VoxelHub();\n this.lobbyApi = opts.lobby ?? null;\n this.restored = new Promise<void>((r) => (this._restored = r));\n this.playerName =\n opts.playerName ?? (() => tr({ en: \"Player\", ru: \"Игрок\" }));\n this.highlight = opts.highlight ?? null;\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n applyGraphicsQuality(); // пресет по устройству — до рендера и мира: они читают CONFIG\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(\n Math.min(window.devicePixelRatio, CONFIG.graphics.maxPixelRatio),\n );\n this.quality = new AdaptiveQuality(() => {\n if (this.renderer.getPixelRatio() <= 1) return;\n this.renderer.setPixelRatio(1);\n this._resize();\n });\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 = this.hub.atlas();\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, startSource());\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.blockUpdates = new BlockUpdates(() => this.world, this.entities);\n // броня из инвентаря гасит урон игрока и изнашивается от ударов\n this.player.armorStats = () => this.inventory.armorStats();\n this.player.onArmorHit = (dmg) => this.inventory.damageArmor(dmg);\n this.player.canGlide = () => this.inventory.canGlide();\n this.player.tryTotem = () => {\n if (this.inventory.heldItem()?.item !== \"life_totem\") return false;\n this.inventory.consumeHeld(1);\n return true;\n };\n this.player.onGlideWear = () => this.inventory.wearWings();\n this.blockEntities = new BlockEntities(() => this.world, this.entities);\n this.beams = new BeaconBeams(this.scene);\n this.displays = new DisplayItems(this.scene, this.atlas);\n this.signs = new SignText(this.scene);\n this.banners = new Banners(this.scene);\n this.armorStands = new ArmorStand(this.scene, this.atlas);\n this.fireworks = new Fireworks(this.scene);\n this.particles = new BlockParticles(this.scene, this.atlas, this.world);\n this.achievements = new Achievements({\n has: (pred) => this.inventory.slots.some((s) => !!s && pred(s.item)),\n wearing: (pred) => this.inventory.armor.some((s) => !!s && pred(s.item)),\n effect: (id) => this.player.effects.has(id as EffectId),\n });\n this.quests = new QuestBook({\n active: () => this.playing && this.player.mode === \"survival\",\n items: () => this.inventory.slots,\n armor: () => this.inventory.armor,\n y: () => this.player.pos.y,\n light: () => {\n const e = this.player.eyePos;\n const l = this.world.getLight(\n Math.floor(e.x),\n Math.floor(e.y),\n Math.floor(e.z),\n );\n return { sky: l >> 4, block: l & 15 };\n },\n phase: () => this.dayNight.phase,\n isDay: () => this.dayNight.isDay,\n food: () => this.player.food,\n health: () => this.player.health,\n dead: () => this.player.dead,\n dimension: () => this.dimension,\n near: (mobs, r) => {\n const p = this.player.pos;\n for (const e of this.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n mobs.includes((e as Mob).def.name) &&\n e.pos.distanceToSquared(p) <= r * r\n )\n return true;\n return false;\n },\n give: (item, count) => {\n const left = this.inventory.add(item, count);\n if (left > 0) {\n const p = this.player.pos;\n this.entities.spawnDrop(p.x, p.y + 0.5, p.z, item, left);\n }\n },\n touch: () => document.body.classList.contains(\"touch\"),\n });\n this.quests.addSource(survivalSource);\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.fluids = new FluidSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.weather = new Weather(this.scene);\n const start = this.world.source;\n if (start.kind === \"spec\") {\n this.dayNight.setTimeOfDay(start.spec.ambience.timeOfDay);\n this.worldName = start.spec.name;\n }\n this.audio = new Audio();\n this.music = new Music(this.audio);\n // пластинка в проигрывателе играет свою мелодию (сид и лад — systems/Disc), вынули — тишина\n events.on(\"discPlay\", ({ mood, seed }: { mood: Mood; seed: number }) =>\n this.music.play(mood, seed),\n );\n events.on(\"discEject\", () => this.music.stop());\n this.input = new Input(canvas, this.intents, this._abort.signal);\n this.armView = new ArmView(this.camera, this.atlas);\n this.character = new PlayerCharacter(this, this.hub.looks);\n // второй проход (рука) видит только свой слой — свет мира включаем и на нём\n this.dayNight.sun.layers.enable(ARM_LAYER);\n this.dayNight.hemi.layers.enable(ARM_LAYER);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n new AchievementsView(this.hud.heartsEl.parentElement!, this.achievements);\n this.invUI = new InventoryUI(this, this._abort.signal);\n this.netUI = new NetUI(this, this._abort.signal);\n this.commands = new Commands(this);\n this.chat = new ChatConsole(this, this._abort.signal);\n this.signEditor = new SignEditor(this, this._abort.signal);\n this.bookScreen = new BookScreen(this, this._abort.signal);\n this.nameEditor = new NameEditor(this, this._abort.signal);\n this.emoteWheel = new EmoteWheel(this, this._abort.signal);\n this.questTracker = new QuestTracker(this);\n this.aimHint = new AimHint(this);\n this.recipeToast = new RecipeToast(this);\n this.questBookUI = new QuestBookUI(this, this._abort.signal);\n this.navHud = new NavHud(this, this._abort.signal);\n // имя предмета при наведении на любой слот (инвентарь, сундук, печь, хотбар, палитра)\n installItemTooltip(\n document.getElementById(\"game\")!.parentElement!,\n this._abort.signal,\n );\n this.mapUI = new WorldMapUI(this, this._abort.signal);\n this.catalogUI = new CatalogUI(this, this._abort.signal);\n // язык сменили (пауза или лобби SDK) — открытое перерисовать сейчас, остальное — при показе\n const offLang = onLangChange(() => {\n this.hud.langChanged();\n this.hud._renderHotbar(); // подсказки слотов\n if (this.invUI.open) this.invUI._render();\n events.emit(\"questsChanged\"); // трекер и книга заданий\n this.catalogUI.refresh();\n });\n this._abort.signal.addEventListener(\"abort\", offLang);\n this.touch = touchWanted()\n ? new TouchControls(this, this._abort.signal)\n : null;\n this.interaction.onCommand = (text) => this.commands.run(text);\n this.interaction.mayToggleMode = () => !this.net || this.net.canCheat;\n this.interaction.onUseMap = () => this.createMap();\n this.playing = false;\n this._time = 0;\n wireEvents(this);\n const { signal } = this._abort;\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n },\n { signal },\n );\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\n \"click\",\n () => {\n if (this.playing && !this.input.active && !this.input.uiOpen)\n this.input.requestLock();\n },\n { signal },\n );\n\n // --- старт мира ---\n this.warmupAtSpawn();\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n // сетевая игра: скрытая вкладка продолжает считать мир в полном темпе (core/GameLoop)\n this.loop.backgroundTicker = () => this.net !== null;\n this.hud.showOverlay(false); // захват мыши — только по клику игрока\n this.loop.start();\n this.hub.attach(this);\n\n // «Мои миры»: открыть хранилище и продолжить последний мир, если игрок ещё не начал другой\n void WorldStore.open()\n .then(async (store) => {\n if (store && this._destroyed) return store.close();\n if (this._destroyed) return;\n this.store = store;\n // мир, выбранный в лобби («Играть в этом мире», «Мои миры», файл) — вместо последнего\n const launch = this.hub.takeLaunch();\n if (launch) {\n await this.applyLaunch(launch);\n return;\n }\n if (!store) return;\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(\n tr(\n { en: \"Continuing world «{n}»\", ru: \"Продолжаем мир «{n}»\" },\n { n: last.name },\n ),\n 2200,\n );\n } catch {\n // повреждённый сейв не должен ломать запуск — остаёмся в стартовом мире\n }\n })\n .finally(() => this._restored());\n }\n\n /** Сохранить текущий мир в «Мои миры» (без хранилища — ничего). Гость чужой мир не сохраняет. */\n persist(): Promise<void> {\n return persist(this);\n }\n\n /** Открыть мир из «Моих миров». */\n openStoredWorld(id: string): Promise<boolean> {\n return openStoredWorld(this, id);\n }\n\n /**\n * Мир из Мастерской: становится НОВОЙ записью в «Моих мирах» — правки игрока не трогают ни\n * публикацию, ни прежний открытый мир (он сохраняется таким, каким его оставили).\n * false — сейв не читается (повреждён или от несовместимой версии), мир остаётся прежним.\n */\n openSharedWorld(save: SaveData, name: string): boolean {\n return openSharedWorld(this, save, name);\n }\n\n /** Мир сменился на новый (импорт сейва, новый сид, рецепт): новая запись в «Моих мирах». */\n adoptNewWorld(name: string) {\n return adoptNewWorld(this, name);\n }\n\n /** Гость в чужом мире: мир не наш — не сохраняем его в «Мои миры» (до передачи хоста). */\n _netGuest = false;\n\n /** Мир для входящего гостя: источник, палитра, время, погода, сложность, Предел, порталы. */\n netWorldMeta(): WorldMeta {\n return netWorldMeta(this);\n }\n\n /** Общее состояние мира (хост раз в 5 с): время, погода, сложность, Предел. */\n netWorldState(): WorldState {\n return netWorldState(this);\n }\n\n applyNetWorldState(w: WorldState): void {\n applyNetWorldState(this, w);\n }\n\n /** Предел: победа над драконом и Врата — однажды случившись, не отменяются (слияние «или»). */\n mergeBeyond(b: unknown): void {\n mergeBeyond(this, b);\n }\n\n /** Сейв этого игрока для мира хоста: позиция, измерение, инвентарь, опыт, эффекты. */\n netPlayerState(): Record<string, unknown> {\n return netPlayerState(this);\n }\n\n /** Ночь пропущена: спали все (сетевая игра) или один (одиночная). */\n skipNight(): void {\n skipNight(this);\n }\n\n /**\n * Гость: мир хоста вместо своего. Свой уходит в «Мои миры» и вернётся при выходе. Все измерения\n * строятся из присланных дифов — порталы работают без ожидания.\n */\n loadNetWorld(w: Welcome, diffs: DiffStore, settings: RoomSettings): void {\n loadNetWorld(this, w, diffs, settings);\n }\n\n /** Стал хостом после ухода прежнего: этот мир теперь наш — новой записью в «Моих мирах». */\n adoptNetWorldAsOwn(): void {\n adoptNetWorldAsOwn(this);\n }\n\n _netWorldName = \"\";\n\n /**\n * Комната пришла из лобби (хост нажал «Играть», или игрок вошёл в уже идущую игру). Хост открывает\n * свой мир — последний, «Моих миров»; гость получает мир хоста. Ошибка — строка для игрока, и\n * комната закрывается: полуначатая сетевая игра хуже одиночной.\n */\n acceptRoom(room: NetRoom): Promise<void> {\n return acceptRoom(this, room);\n }\n\n /**\n * Сетевая игра закончилась. Гость возвращается в свой мир; хост (и ставший хостом) продолжает\n * в этом мире один. `reason` — показать почему (исключили, хост закрыл игру).\n */\n exitNet(reason: string | null): void {\n exitNet(this, reason);\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n return warmup(this, x, z);\n }\n\n /** Прогрузка вокруг точки спавна текущего мира (подсказка генератора). */\n warmupAtSpawn() {\n return warmupAtSpawn(this);\n }\n\n /** Пересоздание мира под источник (сид или рецепт) и измерение: регенерация/импорт/портал. */\n rebuildWorld(source: WorldSource, dimension: DimensionId = this.dimension) {\n return rebuildWorld(this, source, dimension);\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. Число — классический мир по сиду. */\n newWorld(source: WorldSource | number) {\n return newWorld(this, source);\n }\n\n /**\n * Перейти в другое измерение (портал, смерть в Пределе): текущее уходит снимком в `dims`,\n * целевое строится из своих дифов, игрок ставится в (x, y, z) целевого измерения.\n */\n changeDimension(target: DimensionId, x: number, y: number, z: number) {\n return changeDimension(this, target, x, y, z);\n }\n\n /** Смерть: вещи, броня и опыт выпадают на месте (в выживании), показывается экран смерти. */\n _onDeath() {\n return onDeath(this);\n }\n\n /** Возродиться: у кровати, если она цела, иначе у точки мира; из других измерений — домой. */\n respawnPlayer() {\n return respawnPlayer(this);\n }\n\n /**\n * Домой: в верхний мир (из Пекла и Предела — сменой измерения) к кровати, если она цела, иначе к\n * точке мира; возвращает место на полу. Возрождение и выходной портал Предела.\n */\n goHome(): THREE.Vector3 {\n return goHome(this);\n }\n\n /**\n * Портал: 4 с внутри (в креативе — сразу) → другое измерение, координаты Пекла — /8, как в\n * Minecraft. На той стороне — ближайший портал в 16 блоках или новый; обратно — только выйдя из\n * портала прибытия и зайдя снова.\n */\n _tickPortal(dt: number) {\n return tickPortal(this, dt);\n }\n\n /** Нажатые плиты: клетка → сколько ещё держать после того, как с неё сошли (сек). */\n readonly _plates = new Map<string, number>();\n\n /** Нажимные плиты: пока на плите стоит игрок или моб — сигнал; сошли — через секунду отпускает. */\n _tickPlates(dt: number) {\n return tickPlates(this, dt);\n }\n\n /** Портал Предела: из верхнего мира — на обсидиановую площадку Предела; из Предела — домой. */\n _beyondTravel() {\n return beyondTravel(this);\n }\n\n /** Первый визит в Предел: Кристаллы на вершины колонн и дракон над островом. */\n _initBeyond() {\n return initBeyond(this);\n }\n\n /** Площадка 5×5 из обсидиана у BEYOND_ARRIVAL и воздух над ней — заново при каждом прибытии, как в Minecraft. */\n _arrivalPlatform() {\n return arrivalPlatform(this);\n }\n\n /** Врата Предела с нижней клеткой в (x, y, z): две клетки врат, бедрок сверху и снизу. */\n _placeGateway(x: number, y: number, z: number) {\n return placeGateway(this, x, y, z);\n }\n\n /** Врата на кольце вокруг главного острова — после победы (сейв до врат получает их при визите). */\n _openGateway() {\n return openGateway(this);\n }\n\n /** Идущий набег на деревню (systems/Raid) или null. Не сохраняется: перезагрузка его прекращает. */\n raid: Raid | null = null;\n\n /**\n * Набег: игрок с Дурным знамением у заселённой деревни — знамение сгорает, начинается набег. Волны\n * приходят с одного края деревни (в 28–34 блоках от центра) и идут к центру; победа — «Герой\n * деревни» уровня знамения, поражение — все жители в 64 блоках мертвы или вышло время.\n */\n _tickRaid(dt: number) {\n return tickRaid(this, dt);\n }\n\n /** Держим, пока игрок не отойдёт от врат прибытия (иначе они сразу вернули бы его). */\n _gateLock = false;\n\n /**\n * Врата Предела: коснулся — с главного острова на внешние острова в ту же сторону (OUTER_LANDING),\n * рядом с местом прибытия встают обратные врата; с внешних островов — к площадке прибытия.\n */\n _tickGateway() {\n return tickGateway(this);\n }\n\n /** Дракон повержен: выходной портал в фонтане и яйцо на его колонне. */\n _dragonDefeated() {\n return dragonDefeated(this);\n }\n\n /** Бросок Ока Предела: к ближайшей цитадели; в мире без цитаделей Око возвращается в руку. */\n _throwEye(x: number, y: number, z: number) {\n return throwEye(this, x, y, z);\n }\n\n /** Сетевая игра: реестр порталов изменился здесь — разослать (net/NetGame). */\n onPortalChange:\n | ((dim: DimensionId, p: [number, number, number], add: boolean) => void)\n | null = null;\n\n /** Запомнить портал: одна запись на портал — рядом с уже известным не дублируем. */\n _rememberPortal(dim: DimensionId, x: number, y: number, z: number) {\n return rememberPortal(this, dim, x, y, z);\n }\n\n _addPortal(dim: DimensionId, x: number, y: number, z: number): boolean {\n return addPortal(this, dim, x, y, z);\n }\n\n _forgetPortal(dim: DimensionId, p: readonly number[]) {\n return forgetPortal(this, dim, p);\n }\n\n /** Сетевая игра: портал зажгли или сломали у другого игрока. */\n applyNetPortal(dim: DimensionId, p: readonly number[], add: boolean): void {\n applyNetPortal(this, dim, p, add);\n }\n\n /** Ближайший известный портал измерения в квадрате radius вокруг (x, z). */\n _nearestPortal(\n dim: DimensionId,\n x: number,\n z: number,\n radius: number,\n ): [number, number, number] | null {\n return nearestPortal(this, dim, x, z, radius);\n }\n\n /**\n * Возрождение у заряженного якоря: переносит в его измерение, тратит один заряд. null — якоря\n * нет, он сломан или разряжен (тогда действует обычный путь домой).\n */\n anchorHome(): THREE.Vector3 | null {\n return anchorHome(this);\n }\n\n /** ПКМ по кровати: точка возрождения; ночью без монстров рядом — сон до утра. */\n _useBed(x: number, y: number, z: number) {\n return useBed(this, x, y, z);\n }\n\n /** ПКМ пустой картой: карта участка вокруг игрока в руку (как в Minecraft; сразу разведан круг). */\n createMap(): void {\n createMap(this);\n }\n\n _trialScanT = 0;\n\n /** Спавнеры испытаний: раз в секунду завести сущности спавнерам камер рядом и шагнуть их машины. */\n _tickTrials(dt: number): void {\n tickTrials(this, dt);\n }\n\n /** Моб волны спавнера испытаний: клетка с полом и воздухом на рост в 4 блоках от спавнера. */\n _trialSpawn(x: number, y: number, z: number, mob: string): boolean {\n return trialSpawn(this, x, y, z, mob);\n }\n\n freezing = new Freezing();\n\n /** Снег-порох: холод, урон, иней на экране; кожаные ботинки — снег держит (Player.leatherBoots). */\n _tickFreeze(dt: number): void {\n tickFreeze(this, dt);\n }\n\n _hornCd = 0;\n _hornHeld = false;\n\n /** Козий рог: ПКМ — протрубить (слышно далеко, будит сенсоры скалка), не чаще раза в 7 с. */\n _goatHorn(dt: number): void {\n goatHorn(this, dt);\n }\n\n /** Скалк, вибрации и Страж (systems/Sculk.ts) — в верхнем мире. */\n sculk = new Sculk({\n world: () => this.world,\n player: () => this.player,\n wardens: () =>\n [...this.entities.entities.values()].filter(\n (e): e is Mob => e.kind === \"mob\" && !e.dead && !!e.def.warden,\n ),\n summon: (x, y, z) => {\n const m = this.entities.spawnMob(\"warden\", x, y, z);\n m.emergeT = EMERGE_TIME;\n m.invulnerable = true;\n return m;\n },\n });\n /** Пройдено шагов с последней вибрации (вибрация — каждые 2 блока ходьбы без приседа). */\n _stepDist = 0;\n /**\n * Где игрок умер последний раз — туда указывает компас возвращения (в сейв не пишется). `guide` —\n * компас и стрелка ведут туда за вещами, пока игрок не дойдёт (ui/map/NavHud).\n */\n lastDeath: {\n x: number;\n y: number;\n z: number;\n dim: string;\n guide?: boolean;\n } | null = null;\n\n /** Шаги игрока в верхнем мире — вибрации; наступил на крикуна — он кричит. */\n _tickSculk(dt: number): void {\n tickSculk(this, dt);\n }\n\n /** Подзорная труба: ПКМ держать — поле зрения плавно сужается до 10° (как в Minecraft). */\n _spyglass(dt: number): void {\n spyglass(this, dt);\n }\n\n /** Карта в руке дорисовывает местность вокруг игрока (четверть строк за раз). */\n _surveyHeldMap(): void {\n surveyHeldMap(this);\n }\n\n /**\n * Ночная осада (systems/Siege): с наступлением ночи к заселённой деревне рядом с игроком выходит\n * отряд зомби с одной стороны. Они охотятся на жителей (MobDef.huntsLand), а убитый житель встаёт\n * зомби-жителем — так зомби-жители появляются в игре сами, без команд. Во время набега осады нет.\n */\n _siege() {\n return siege(this);\n }\n\n /**\n * Деревня рядом, в которой ещё никто не живёт: поселить жителей и голема (один раз — список в\n * сейве). Ждёт, пока прогрузятся чанки всех домов, иначе жители упали бы сквозь незагруженное.\n */\n _settleVillages() {\n return settleVillages(this);\n }\n\n /** Удар молнии: 5 урона всем в 3 блоках (огонь ставит сама погода). */\n _lightning(x: number, y: number, z: number) {\n return lightning(this, x, y, z);\n }\n\n /**\n * Призвать существо с обликом персонажа в двух блоках перед игроком: NPC (с именем, гуляет рядом)\n * или моба с пресетом поведения (entities/characterMobs). Питомец появляется ручным.\n */\n summonCharacter(spec: CharacterSpec, behavior: CharBehavior): Mob | null {\n const p = this.player;\n const d = p.lookDir();\n const len = Math.hypot(d.x, d.z) || 1;\n return this.summonCharacterAt(\n spec,\n behavior,\n p.pos.x + (d.x / len) * 2,\n p.pos.y + 0.1,\n p.pos.z + (d.z / len) * 2,\n );\n }\n\n /** Существо с обликом персонажа в точке (NPC из рецепта мира, агент). */\n summonCharacterAt(\n spec: CharacterSpec,\n behavior: CharBehavior,\n x: number,\n y: number,\n z: number,\n ): Mob | null {\n const type = characterMobType(spec, behavior);\n if (!ensureCharacterMob(type, spec)) return null;\n const m = this.entities.spawnMob(type, x, y, z);\n if (behavior === \"npc\") m.setName(spec.name);\n if (behavior === \"pet\") m.tamed = true;\n return m;\n }\n\n /** NPC рецепта мира, которые ещё не встали в мир (game/npcs; сейв pendingNpcs). */\n pendingNpcs: PendingNpc[] = [];\n\n /** Из паузы — в лобби SDK: мир сохраняется, «Играть» в лобби вернёт в него. */\n toLobby() {\n this.playing = false;\n void this.persist().finally(() => this.hub.changed());\n this.lobbyApi?.open();\n }\n\n /** Мир, выбранный в лобби: новый (рецепт, сид), из «Моих миров» или из файла. */\n async applyLaunch(l: WorldLaunch): Promise<void> {\n if (l.kind === \"new\") this.newWorld(l.source);\n else if (l.kind === \"stored\") {\n if (l.id !== this.worldId && !(await this.openStoredWorld(l.id)))\n this.hud.message(\n tr({ en: \"Could not open the world\", ru: \"Не удалось открыть мир\" }),\n 2200,\n );\n } else\n try {\n this.save.importText(l.text);\n } catch (e) {\n this.hud.message(\n tr({ en: \"Could not load: \", ru: \"Не загрузилось: \" }) +\n (e as Error).message,\n 2600,\n );\n }\n this._worldTouched = true;\n this.hub.changed();\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n // сетевая игра не встаёт на паузе: мир общий, и в меню игрок остаётся в нём (как в Minecraft)\n if (!this.playing && !this.net) return;\n // Кто что считает (net/sim): своё — игрок, его действия, голод, опасности — считает каждый;\n // мир измерения (мобы, жидкости, блоки, машины) — только владелец измерения; погоду и\n // время суток — хост (остальным их присылают, время между присылками идёт само).\n const own = this.simulating;\n const host = !this.net || this.net.isHost;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n // спавн мобов зависит от времени суток и погоды: ночью небо «гаснет» до 4, в грозу — и днём\n const sky = DIMENSIONS[this.dimension].hasSky;\n if (host)\n this.weather.tick(\n dt,\n sky && own ? this.world : null,\n this.player.pos.x,\n this.player.pos.z,\n );\n this.world.raining = sky && this.weather.raining;\n this.world.containerAt = this._containerAt;\n this.entities.raining = this.world.raining;\n this.entities.skyDarken = Math.max(\n this.dayNight.skyDarken,\n sky ? this.weather.darken : 0,\n );\n this.entities.isDay = this.dayNight.isDay;\n this.entities.tick(dt); // у реплики — только сглаживание к снимкам владельца\n if (own) this.fluids.tick();\n this.player.tickHazards(dt);\n // черепаший панцирь: над водой копит 10 с Подводного дыхания — под водой их хватает на нырок\n const wb = this.player.effects.get(\"water_breathing\");\n if (\n this.inventory.armor[0]?.item === \"turtle_helmet\" &&\n !this.player.headInWater &&\n (!wb || wb.time < 9.5)\n )\n this.player.addEffect(\"water_breathing\", 10);\n this._tickPortal(dt);\n this._tickGateway();\n if (own) this._tickRaid(dt);\n this._spyglass(dt);\n if (own) this._tickSculk(dt);\n this._goatHorn(dt);\n this._tickFreeze(dt);\n if (own) {\n this._tickTrials(dt);\n this._tickPlates(dt);\n this.world.tick(); // onTick-блоки, запланированные и случайные тики\n this.blockEntities.tick(dt);\n }\n this.dayNight.tick(dt); // у гостя — между поправками хоста (net WORLD)\n // утром жители пополняют товар\n const day = this.dayNight.isDay;\n if (day && !this._wasDay) {\n if (own)\n for (const e of this.entities.entities.values())\n if (e.kind === \"mob\" && e.def.trades) e.uses = [];\n // ещё одна ночь без сна (сон её обнуляет) — с трёх прилетают фантомы\n if (host) this.daysAwake = this._slept ? 0 : this.daysAwake + 1;\n this._slept = false;\n this.netSleeping = false;\n } else if (!day && this._wasDay && own) this._siege(); // стемнело — может начаться осада деревни\n this.entities.insomnia = this.daysAwake >= 3;\n this._wasDay = day;\n this._villageT += dt;\n if (this._villageT >= 1) {\n this._villageT = 0;\n if (own) this._settleVillages();\n if (own) tickNpcs(this); // NPC рецепта мира — как только прогрузилось их место\n }\n this._mapT += dt;\n if (this._mapT >= 0.25) {\n this._mapT = 0;\n this._surveyHeldMap();\n // своя карта: чанки вокруг, которые прогрузились или изменились\n const p = this.player.pos;\n this.worldMap.survey(this.world, this.dimension, p.x, p.z, 96);\n }\n events.emit(\"tick\", { n });\n this.net?.afterTick();\n this._autosaveT += dt;\n if (this._autosaveT >= AUTOSAVE_EVERY) {\n this._autosaveT = 0;\n void this.persist();\n }\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.quality.frame(dt, this.loop.fps);\n this.music.tick(dt, this.dimension);\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n this.quests.tick(dt);\n }\n\n // дальность обзора: с дальним кольцом LOD — дальше полных чанков (туман и дальняя плоскость камеры)\n const view = this.world.viewChunks;\n if (this.dayNight.viewChunks !== view) {\n this.dayNight.viewChunks = view;\n this.camera.far = view * 16 + 60;\n this.camera.updateProjectionMatrix();\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 this.character.frame(dt); // тело игрока; от третьего лица — камера отъезжает\n this.questTracker.frame(dt);\n this.aimHint.frame(dt);\n this.recipeToast.frame(dt);\n this.navHud.frame();\n this.mapUI.frame();\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.net?.frame(dt);\n this.beams.update(this.blockEntities, this._time);\n this.displays.update(this.blockEntities, this.world);\n this.signs.update(this.blockEntities, this.world);\n this.banners.update(this.blockEntities, this.world);\n this.armorStands.update(this.blockEntities);\n this.fireworks.update(dt);\n this.particles.skyDarken = DIMENSIONS[this.dimension].hasSky\n ? this.dayNight.skyDarken\n : 0;\n this.particles.update(dt);\n this.dayNight.frame(\n this.player.pos,\n this.player.headInLava\n ? \"lava\"\n : this.player.headInWater\n ? \"water\"\n : this.player.headInPowderSnow\n ? \"powder_snow\"\n : \"air\",\n this.dimension,\n this.player.effects.has(\"night_vision\"),\n DIMENSIONS[this.dimension].hasSky ? this.weather : undefined,\n );\n // Тьма пульсирует: то наползает, то чуть отступает\n if (this.player.effects.has(\"darkness\"))\n this.dayNight.darkness(\n 0.75 + 0.25 * Math.abs(Math.sin(this._time * 1.3)),\n );\n this.weather.frame(\n dt,\n eye,\n DIMENSIONS[this.dimension].hasSky ? this.world : null,\n );\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.invUI.frame();\n this.renderPasses();\n }\n\n /** Draw call'ы и треугольники мирового прохода прошлого кадра (отладочная строка, агент). */\n readonly renderStats = { calls: 0, triangles: 0 };\n private readonly _nearSeen: ShadowStamp = {\n iu: NaN,\n iv: 0,\n dir: new THREE.Vector3(),\n };\n private _shadowAge = 0;\n private readonly _farSeen: ShadowStamp = {\n iu: NaN,\n iv: 0,\n dir: new THREE.Vector3(),\n };\n private _farAge = 0;\n /** Меши мира менялись с прошлой перерисовки дальнего каскада. */\n private _farMeshes = false;\n\n /**\n * Ближний каскад теней перерисовываем, когда тени могли сдвинуться: сдвинулась карта или солнце,\n * подменился меш чанка, рядом движутся сущности (мобы, дроп, стрелы), идёт сетевая игра (чужие\n * игроки), или прошло полсекунды. Картинка та же, что при перерисовке каждый кадр.\n * Дальний — как LOD: только при сдвиге своей карты или солнца, при изменившихся мешах — не чаще\n * раза в полсекунды, и раз в 10 секунд на всякий случай.\n */\n private _shadowsChanged(): { near: boolean; far: boolean } {\n if (!CONFIG.graphics.shadows) return { near: false, far: false };\n this._shadowAge++;\n this._farAge++;\n const meshes =\n this.world.mesher.swapped > 0 || (this.world.lod?.rebuilt ?? 0) > 0;\n if (meshes) this._farMeshes = true;\n const near =\n stampMoved(this.dayNight.sun, this._nearSeen, 0.00125) ||\n this._shadowAge >= 30 ||\n this.world.mesher.swapped > 0 ||\n this.entities.shadowCasters > 0 ||\n !!this.net;\n if (near) {\n seeStamp(this.dayNight.sun, this._nearSeen);\n this._shadowAge = 0;\n }\n const farLight = this.dayNight.sunFar;\n let far = false;\n if (farLight) {\n far =\n stampMoved(farLight, this._farSeen, 0.004) ||\n (this._farMeshes && this._farAge >= 30) ||\n this._farAge >= 600;\n if (far) {\n seeStamp(farLight, this._farSeen);\n this._farAge = 0;\n this._farMeshes = false;\n }\n farLight.shadow.needsUpdate = far;\n }\n this.dayNight.sun.shadow.needsUpdate = near;\n return { near, far };\n }\n\n /** Отрисовка кадра как таковая, без шага симуляции. Вынесена из `frame`, потому что её зовёт ещё\n * и `capture()`: снимок обязан рисовать РОВНО то же, что игрок видит, а не свою копию проходов. */\n private renderPasses() {\n // Два прохода: мир, затем рука поверх него с чистым depth — иначе вплотную к блоку она уходит\n // внутрь него. Второй проход видит только слой руки (и свет, включённый на этот слой).\n const shadows = this._shadowsChanged(); // тени — только в мировом проходе\n this.renderer.shadowMap.needsUpdate = shadows.near || shadows.far;\n this.renderer.render(this.scene, this.camera);\n const info = this.renderer.info.render;\n this.renderStats.calls = info.calls;\n this.renderStats.triangles = info.triangles;\n this.renderer.autoClear = false;\n this.renderer.clearDepth();\n this.camera.layers.set(ARM_LAYER);\n this.renderer.render(this.scene, this.camera);\n this.camera.layers.set(0);\n this.renderer.autoClear = true;\n }\n\n /**\n * Снимок экрана для кнопки «поделиться» на странице игры.\n *\n * ⚠ Рисует и читает В ОДИН приём. Рендерер создан без `preserveDrawingBuffer` (и так и надо —\n * флаг стоит памяти и копии каждый кадр), поэтому к моменту, когда до канвы доберётся кто-то\n * ещё, буфер уже очищен: `toBlob` вернёт ЧЁРНЫЙ прямоугольник, ничем не пожаловавшись.\n */\n async capture(): Promise<Blob | null> {\n if (this._destroyed) return null;\n this.renderPasses();\n return await new Promise<Blob | null>((resolve) => {\n this.renderer.domElement.toBlob(resolve, \"image/png\");\n });\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n // сетевая игра: уйти из комнаты (хост передаёт мир преемнику) — без возврата в свой мир, игра разбирается\n const net = this.net;\n if (net) {\n net.prepareExit();\n net.dispose();\n void net.leaveRoom();\n }\n // последний снимок уходит до разбора мира; дописывается транзакция уже без нас\n void this.persist().finally(() => this.store?.close());\n this.net = null;\n this._destroyed = true;\n this.loop.stop();\n this.input.releaseLock();\n // Шина `events` и DOM-подписки живут дольше игры: хост пересоздаёт Game после перелогина, и\n // без этого старый экземпляр продолжал бы слушать — `E` эмитился дважды (новый инвентарь\n // открывался и тут же закрывался), звуки двоились, весь старый мир держался в памяти.\n this._abort.abort();\n events.clear();\n this.hud.dispose();\n this.netUI.dispose();\n this.chat.dispose();\n this.signEditor.dispose();\n this.bookScreen.dispose();\n this.nameEditor.dispose();\n this.emoteWheel.dispose();\n this.quests.dispose();\n this.questTracker.dispose();\n this.aimHint.dispose();\n this.recipeToast.dispose();\n this.questBookUI.dispose();\n this.navHud.dispose();\n this.mapUI.dispose();\n this.catalogUI.dispose();\n this.hub.attach(null);\n this.touch?.dispose();\n this.player.dispose();\n this.audio.dispose();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.particles.dispose();\n this.character.dispose();\n ChunkWorkerPool.shutdown(); // воркеры чанков держат свой экземпляр мира — освободить\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 // вернулись из лобби: мир, выбранный там, и «Клик — играть» (захват мыши — по клику)\n const launch = this.hub.takeLaunch();\n if (launch) void this.applyLaunch(launch);\n if (!this.playing && !this.player.dead) this.hud.showOverlay(false);\n } else {\n // сетевая игра: мир общий — у хоста и владельцев измерений он не может встать оттого, что\n // игрок открыл другой режим; останавливается только ввод\n if (!this.net) this.loop.stop();\n this.input.releaseLock();\n this.input.enabled = false; // клавиатура на document — не слушать, пока игрок в другом режиме\n this.playing = false;\n }\n }\n}\n"
|
|
846
763
|
},
|
|
847
764
|
{
|
|
848
765
|
"path": "module.ts",
|
|
@@ -866,7 +783,7 @@
|
|
|
866
783
|
},
|
|
867
784
|
{
|
|
868
785
|
"path": "net/game/handlers.ts",
|
|
869
|
-
"content": "import { parseCharacterSpec } from \"../../character/CharacterSpec\";\nimport { characterOf, registerCharacter } from \"../../character/characters\";\nimport {\n parseItemSkin,\n type WearSlot,\n} from \"../../character/items/ItemSkinSpec\";\nimport { registerSkin } from \"../../character/items/skins\";\nimport { isEmote } from \"../../character/anim/emotes\";\nimport { emotionFromText, isEmotion } from \"../../character/anim/emotions\";\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { rules } from \"../../systems/Difficulty\";\nimport { cleanPin } from \"../../systems/WorldAtlas\";\nimport type { NetEntityAction } from \"../../entities/EntityManager\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n DIMS,\n M,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type PlayerMeta,\n type EmoteMsg,\n type LookMsg,\n type PinMsg,\n type PortalMsg,\n type Pose,\n type Roster,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"../protocol\";\nimport type { Peer } from \"./types\";\nimport { HELLO_RETRY_MS } from \"./shared\";\nimport type { NetGame } from \"../NetGame\";\nimport { tr } from \"../../i18n\";\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nexport async function onMessage(\n ng: NetGame,\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): Promise<void> {\n if (ng.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!ng.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") ng.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return onFast(ng, from, type, data);\n switch (type) {\n case M.HELLO:\n if (ng.isHost) await onHello(ng, from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && ng.welcomeWait) ng.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n ng.welcomeWait?.(\n r?.reason ??\n tr({ en: \"The host refused entry\", ru: \"Хост отказал во входе\" }),\n );\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !ng.isHost) ng.applyRoster(r);\n return;\n }\n case M.PMETA:\n return onMeta(ng, from, data);\n case M.BLOCKS:\n case M.EDITS:\n return onBlocks(ng, from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return onEntities(ng, from, type, data);\n case M.ENT_ACTION:\n return onEntAction(ng, from, data);\n case M.BE:\n return onBe(ng, from, data);\n case M.LOCK:\n return ng.onLock(from, data);\n case M.REPLY:\n return ng.onReply(from, data);\n case M.PORTAL:\n return onPortal(ng, from, data);\n case M.PIN:\n return onPin(ng, from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === ng.game.dimension && !sim.authority)\n for (const b of m.list) applyBe(ng, b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !ng.isHost) {\n ng.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n ng.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return onChat(ng, from, data);\n case M.EMOTE:\n return onEmote(ng, from, data);\n case M.LOOK:\n return onLook(ng, from, data);\n case M.HIT:\n case M.GIVE:\n return onToPlayer(ng, from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = ng.records.get(from);\n if (s && r && ng.isHost) {\n r.state = s.state;\n ng.game.mergeBeyond((s.state as { beyond?: unknown } | null)?.beyond);\n }\n return;\n }\n case M.DIM_ENTER:\n if (ng.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) ng.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === ng.game.dimension) ng.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) ng.bc.reset();\n return;\n case M.SHADOW:\n ng.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && ng.isHost && ng.mayCheat(from)) ng.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n}\n\nexport function onFast(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n if (type === M.POSE) {\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n ng.toAll(\"fast\", stamped, from);\n applyPose(ng, pose);\n return;\n }\n if (!ng.isHost) applyPose(ng, decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"fast\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const p of decodeEntPoses(data).list)\n ng.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n}\n\nexport function sendHello(ng: NetGame): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: ng.name };\n ng.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n}\n\nexport async function onHello(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await ng.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: tr({\n en: \"Game versions differ — reload the page\",\n ru: \"Версии игры отличаются — обновите страницу\",\n }),\n }),\n );\n return;\n }\n const g = ng.game;\n let r = ng.records.get(from);\n // Гость повторяет Hello, пока снимок не дошёл (HELLO_RETRY_MS): только что отправленный снимок\n // ещё в пути — второй такой же не нужен.\n if (r?.welcomedAt && Date.now() - r.welcomedAt < HELLO_RETRY_MS * 2) return;\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: ng.nextSlot++,\n name: (h.name || tr({ en: \"Player\", ru: \"Игрок\" })).slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: ng.ops.has(from),\n state: saved,\n };\n ng.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: ng.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: ng.roster(),\n };\n r.welcomedAt = Date.now();\n await ng.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n ng.broadcastRoster();\n ng.enterDim(from, r.dim);\n}\n\nexport function applyPose(ng: NetGame, p: Pose): void {\n if (p.slot === ng.slot) return;\n const peer = ng.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === ng.game.dimension && p.dim !== peer.dim && sim.authority)\n ng.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === ng.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name, ng.game.atlas);\n ng.game.scene.add(peer.avatar.group);\n applyPeerLook(peer);\n }\n peer.avatar.held = peer.held;\n peer.avatar.sleeping = peer.sleeping;\n peer.avatar.heldSkin = peer.heldSkin ?? null;\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(ng.game.world, p.slot, peer.userId, ng);\n peer.remote.world = ng.game.world;\n peer.remote.held = peer.held;\n peer.remote.gold = !!peer.gold;\n peer.remote.applyPose(p);\n }\n } else dropPeerView(ng, peer);\n}\n\nexport function dropPeerView(ng: NetGame, peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(ng.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n}\n\nexport async function onMeta(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n ng.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.look = typeof m.look === \"string\" ? m.look : null;\n peer.outfit = m.outfit && typeof m.outfit === \"object\" ? m.outfit : {};\n peer.heldSkin = typeof m.heldSkin === \"string\" ? m.heldSkin : null;\n peer.gold = m.gold === true;\n if (peer.remote) peer.remote.gold = peer.gold;\n applyPeerLook(peer);\n if (peer.avatar) peer.avatar.riding = !!m.riding;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n}\n\nexport function onBlocks(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n const { dim, list } = decodeBlocks(data);\n if (ng.isHost && from !== ng.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (ng.owners.get(dim) !== from) return;\n ng.diffs.applyAll(dim, list);\n ng.toAll(\"rel\", data, from);\n } else {\n const owner = ng.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n ng.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n ng.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (ng.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = ng.owners.get(dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== ng.game.dimension) return;\n applyBlocks(ng, list, true);\n return;\n }\n if (from === ng.selfId) return;\n if (!ng.isHost) ng.diffs.applyAll(dim, list);\n if (dim === ng.game.dimension && !sim.authority) applyBlocks(ng, list, false);\n}\n\nexport function applyBlocks(\n ng: NetGame,\n list: readonly BlockDelta[],\n asOwner: boolean,\n): void {\n const w = ng.game.world;\n ng.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n ng.applyingRemote = false;\n }\n}\n\nexport async function onEntities(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) ng.toDim(m.dim, \"rel\", data, from);\n if (m.dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const [id, snap] of m.list) ng.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"rel\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const id of ids) ng.game.entities.netRemove(id);\n}\n\nexport async function onEntAction(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (ng.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === ng.selfId ? ng.slot : ng.records.get(from)?.slot;\n const owner = ng.owners.get(m.dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== ng.game.dimension) return;\n const em = ng.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker = m.from !== undefined ? ng.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n ng.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n}\n\nexport function entAction(ng: NetGame, action: NetEntityAction): void {\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: ng.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n}\n\nexport async function onPortal(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (ng.isHost) ng.toAll(\"rel\", data, from);\n ng.game.applyNetPortal(m.dim, m.p, !!m.add);\n}\n\n/**\n * Метка карты. Хост проверяет право (менять и удалять — автор или хост), ставит автора новой метке\n * и рассылает остальным; гость верит хосту.\n */\nexport async function onPin(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PinMsg>(data);\n if (!m) return;\n const map = ng.game.worldMap;\n if (!ng.isHost) {\n map.applyRemote(\n m.op === \"remove\"\n ? { op: \"remove\", id: Number(m.id) }\n : { op: \"set\", pin: cleanPin(m.pin)! },\n );\n return;\n }\n const id = m.op === \"remove\" ? Number(m.id) : cleanPin(m.pin)?.id;\n if (id === undefined || !Number.isInteger(id)) return;\n const old = map.pins.find((p) => p.id === id);\n if (old && old.by !== from) return; // чужая метка (или хоста)\n if (m.op === \"remove\") {\n if (map.applyRemote({ op: \"remove\", id }))\n ng.toAll(\"rel\", encodeJsonSync(M.PIN, m), from);\n return;\n }\n const pin = cleanPin(m.pin);\n if (!pin) return;\n pin.by = from;\n pin.byName = ng.records.get(from)?.name ?? pin.byName;\n if (map.applyRemote({ op: \"set\", pin }))\n ng.toAll(\n \"rel\",\n encodeJsonSync(M.PIN, { op: \"set\", pin } satisfies PinMsg),\n from,\n );\n}\n\nexport async function onBe(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = ng.owners.get(m.dim);\n if (ng.isHost && from !== ng.selfId) {\n if (owner === from) {\n storeBe(ng, m);\n ng.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n } else if (ng.isHost && owner && owner !== ng.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void ng.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== ng.game.dimension || from === ng.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else applyBe(ng, m);\n}\n\nexport function applyBe(ng: NetGame, m: BeMsg): void {\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n ng.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n}\n\nexport function storeBe(ng: NetGame, m: BeMsg): void {\n let s = ng.beStore.get(m.dim);\n if (!s) ng.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n}\n\nexport async function onToPlayer(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (ng.isHost && m.to !== ng.slot) {\n const to = ng.userOfSlot(m.to);\n if (to && to !== from) void ng.send(to, \"rel\", data);\n return;\n }\n if (m.to !== ng.slot) return;\n const g = ng.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n}\n\nexport async function onChat(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.from = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who = m.from !== undefined ? (ng.peers.get(m.from)?.name ?? \"?\") : \"?\";\n ng.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n // смайлик в сообщении — эмоция на лице его персонажа\n const feel = emotionFromText(String(m.text));\n if (feel && m.from !== undefined)\n ng.peers.get(m.from)?.avatar?.actor.setEmotion(feel, 4);\n}\n\n/** Облик другого игрока на его аватаре: персонаж (если рецепт уже дошёл) и надетое. */\nexport function applyPeerLook(peer: Peer): void {\n const a = peer.avatar;\n if (!a) return;\n const spec = characterOf(peer.look);\n if (spec) a.setSpec(spec);\n a.actor.setOutfit((peer.outfit ?? {}) as Partial<Record<WearSlot, string>>);\n}\n\n/** Рецепты облика другого игрока: запомнить (персонажа и предметы) и надеть на его аватар. */\nexport async function onLook(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<LookMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", await encodeJson(M.LOOK, m), from);\n }\n const c = parseCharacterSpec(m.character);\n if (c.ok) registerCharacter(c.spec);\n if (m.skins && typeof m.skins === \"object\")\n for (const raw of Object.values(m.skins).slice(0, 8)) {\n const s = parseItemSkin(raw);\n if (s.ok) registerSkin(s.spec);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (peer) applyPeerLook(peer);\n}\n\n/** Жест или эмоция другого игрока: хост ставит слот отправителя и рассылает. */\nexport async function onEmote(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EmoteMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.EMOTE, m), from);\n }\n const actor =\n m.slot !== undefined ? ng.peers.get(m.slot)?.avatar?.actor : undefined;\n if (!actor) return;\n if (isEmote(m.emote)) actor.playEmote(m.emote);\n if (isEmotion(m.emotion))\n actor.setEmotion(\n m.emotion,\n Math.min(30, Math.max(0, Number(m.seconds) || 4)),\n );\n}\n"
|
|
786
|
+
"content": "import {\n parseCharacterSpec,\n characterOf,\n registerCharacter,\n isEmote,\n emotionFromText,\n isEmotion,\n} from \"@idosgames/characters/voxel\";\nimport {\n parseItemSkin,\n type WearSlot,\n} from \"../../character/items/ItemSkinSpec\";\nimport { registerSkin } from \"../../character/items/skins\";\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { rules } from \"../../systems/Difficulty\";\nimport { cleanPin } from \"../../systems/WorldAtlas\";\nimport type { NetEntityAction } from \"../../entities/EntityManager\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { ITEMS } from \"../../registry/Items\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { Avatar, RemotePlayer } from \"../RemotePlayer\";\nimport { decodeJson, encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n DIMS,\n M,\n PROTOCOL,\n buildHash,\n decodeBlocks,\n decodeEntPoses,\n decodeEntRemove,\n decodePose,\n encodeBlocks,\n withPoseSlot,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type DimOwn,\n type EntActionMsg,\n type EntSpawnMsg,\n type Give,\n type Hello,\n type Hit,\n type PlayerMeta,\n type EmoteMsg,\n type LookMsg,\n type PinMsg,\n type PortalMsg,\n type Pose,\n type Roster,\n type Shadow,\n type Welcome,\n type WorldState,\n} from \"../protocol\";\nimport type { Peer } from \"./types\";\nimport { HELLO_RETRY_MS } from \"./shared\";\nimport type { NetGame } from \"../NetGame\";\nimport { tr } from \"../../i18n\";\n\nconst BUILD = buildHash(\n BLOCKS.map((b) => b.name),\n Object.keys(ITEMS),\n);\n\nexport async function onMessage(\n ng: NetGame,\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): Promise<void> {\n if (ng.closed || data.length === 0) return;\n const type = data[0]!;\n // Гость до снимка мира: всё, кроме самого снимка, — в отложенные. Правки хоста легли бы на ЕГО\n // одиночный мир (и ушли бы в «Мои миры»), а выбросить их — значит отстать от снимка навсегда.\n if (!ng.welcomed && type !== M.WELCOME && type !== M.REJECT) {\n if (channel === \"rel\") ng.stash.push([from, data]);\n return;\n }\n try {\n if (channel === \"fast\") return onFast(ng, from, type, data);\n switch (type) {\n case M.HELLO:\n if (ng.isHost) await onHello(ng, from, data);\n return;\n case M.WELCOME: {\n const w = await decodeJson<Welcome>(data);\n if (w && ng.welcomeWait) ng.welcomeWait(w);\n return;\n }\n case M.REJECT: {\n const r = await decodeJson<{ reason: string }>(data);\n ng.welcomeWait?.(\n r?.reason ??\n tr({ en: \"The host refused entry\", ru: \"Хост отказал во входе\" }),\n );\n return;\n }\n case M.ROSTER: {\n const r = await decodeJson<Roster>(data);\n if (r && !ng.isHost) ng.applyRoster(r);\n return;\n }\n case M.PMETA:\n return onMeta(ng, from, data);\n case M.BLOCKS:\n case M.EDITS:\n return onBlocks(ng, from, type, data);\n case M.ENT_SPAWN:\n case M.ENT_REMOVE:\n return onEntities(ng, from, type, data);\n case M.ENT_ACTION:\n return onEntAction(ng, from, data);\n case M.BE:\n return onBe(ng, from, data);\n case M.LOCK:\n return ng.onLock(from, data);\n case M.REPLY:\n return ng.onReply(from, data);\n case M.PORTAL:\n return onPortal(ng, from, data);\n case M.PIN:\n return onPin(ng, from, data);\n case M.BE_LIST: {\n const m = await decodeJson<{ dim: DimensionId; list: BeMsg[] }>(data);\n if (m && m.dim === ng.game.dimension && !sim.authority)\n for (const b of m.list) applyBe(ng, b);\n return;\n }\n case M.WORLD: {\n const w = await decodeJson<WorldState>(data);\n if (w && !ng.isHost) {\n ng.game.applyNetWorldState(w);\n // мирная сложность: враждебные исчезают и в измерении, которое считаю я\n if (sim.authority && !rules().hostiles)\n ng.game.entities.clearHostiles();\n }\n return;\n }\n case M.CHAT:\n return onChat(ng, from, data);\n case M.EMOTE:\n return onEmote(ng, from, data);\n case M.LOOK:\n return onLook(ng, from, data);\n case M.HIT:\n case M.GIVE:\n return onToPlayer(ng, from, type, data);\n case M.PSTATE: {\n const s = await decodeJson<{ state: unknown }>(data);\n const r = ng.records.get(from);\n if (s && r && ng.isHost) {\n r.state = s.state;\n ng.game.mergeBeyond((s.state as { beyond?: unknown } | null)?.beyond);\n }\n return;\n }\n case M.DIM_ENTER:\n if (ng.isHost) {\n const m = await decodeJson<DimEnter>(data);\n if (m) ng.onDimEnter(from, m);\n }\n return;\n case M.DIM_OWN: {\n const m = await decodeJson<DimOwn>(data);\n if (m && m.dim === ng.game.dimension) ng.becomeOwner(m.save);\n return;\n }\n case M.DIM_RESYNC:\n if (sim.authority) ng.bc.reset();\n return;\n case M.SHADOW:\n ng.shadow = await decodeJson<Shadow>(data);\n return;\n case M.COMMAND: {\n const m = await decodeJson<{ text: string }>(data);\n if (m && ng.isHost && ng.mayCheat(from)) ng.game.commands.run(m.text);\n return;\n }\n }\n } catch (e) {\n console.warn(\"[net] bad message\", type, e);\n }\n}\n\nexport function onFast(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n if (type === M.POSE) {\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n const stamped = withPoseSlot(data, r.slot);\n const pose = decodePose(stamped);\n if (r.dim !== pose.dim) r.dim = pose.dim;\n ng.toAll(\"fast\", stamped, from);\n applyPose(ng, pose);\n return;\n }\n if (!ng.isHost) applyPose(ng, decodePose(data));\n return;\n }\n if (type === M.ENT_POSE) {\n const { dim } = decodeEntPoses(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"fast\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const p of decodeEntPoses(data).list)\n ng.game.entities.netApplyPose(\n p.id,\n p.x,\n p.y,\n p.z,\n p.yaw,\n p.flags,\n p.extra,\n );\n }\n}\n\nexport function sendHello(ng: NetGame): void {\n const hello: Hello = { v: PROTOCOL, build: BUILD, name: ng.name };\n ng.toHost(\"rel\", encodeJsonSync(M.HELLO, hello));\n}\n\nexport async function onHello(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const h = await decodeJson<Hello>(data);\n if (!h) return;\n if (h.v !== PROTOCOL || h.build !== BUILD) {\n await ng.send(\n from,\n \"rel\",\n encodeJsonSync(M.REJECT, {\n reason: tr({\n en: \"Game versions differ — reload the page\",\n ru: \"Версии игры отличаются — обновите страницу\",\n }),\n }),\n );\n return;\n }\n const g = ng.game;\n let r = ng.records.get(from);\n // Гость повторяет Hello, пока снимок не дошёл (HELLO_RETRY_MS): только что отправленный снимок\n // ещё в пути — второй такой же не нужен.\n if (r?.welcomedAt && Date.now() - r.welcomedAt < HELLO_RETRY_MS * 2) return;\n if (!r) {\n const saved = g.netPlayers.get(from) ?? null;\n const savedDim = (saved as { dim?: DimensionId } | null)?.dim;\n r = {\n slot: ng.nextSlot++,\n name: (h.name || tr({ en: \"Player\", ru: \"Игрок\" })).slice(0, 24),\n dim: savedDim && DIMS.includes(savedDim) ? savedDim : \"overworld\",\n op: ng.ops.has(from),\n state: saved,\n };\n ng.records.set(from, r);\n }\n const spawnPos = (r.state as { pos?: [number, number, number] } | null)?.pos;\n const sp = g.player.spawnPoint;\n const welcome: Welcome = {\n v: PROTOCOL,\n you: r.slot,\n world: g.netWorldMeta(),\n diffs: ng.diffs.all(),\n spawn: {\n dim: r.dim,\n pos:\n Array.isArray(spawnPos) && spawnPos.length === 3\n ? spawnPos\n : [sp.x, sp.y, sp.z],\n },\n state: r.state,\n roster: ng.roster(),\n };\n r.welcomedAt = Date.now();\n await ng.send(from, \"rel\", await encodeJson(M.WELCOME, welcome));\n ng.broadcastRoster();\n ng.enterDim(from, r.dim);\n}\n\nexport function applyPose(ng: NetGame, p: Pose): void {\n if (p.slot === ng.slot) return;\n const peer = ng.peers.get(p.slot);\n if (!peer) return;\n // ушёл из измерения — то, что он вёл, снова считаю я\n if (peer.dim === ng.game.dimension && p.dim !== peer.dim && sim.authority)\n ng.game.entities.netReleaseDriver(peer.slot);\n peer.pose = p;\n peer.dim = p.dim;\n const here = p.dim === ng.game.dimension;\n if (here) {\n if (!peer.avatar) {\n peer.avatar = new Avatar(peer.name, ng.game.atlas);\n ng.game.scene.add(peer.avatar.group);\n applyPeerLook(peer);\n }\n peer.avatar.held = peer.held;\n peer.avatar.sleeping = peer.sleeping;\n peer.avatar.heldSkin = peer.heldSkin ?? null;\n peer.avatar.target(p);\n if (sim.authority) {\n if (!peer.remote)\n peer.remote = new RemotePlayer(ng.game.world, p.slot, peer.userId, ng);\n peer.remote.world = ng.game.world;\n peer.remote.held = peer.held;\n peer.remote.gold = !!peer.gold;\n peer.remote.applyPose(p);\n }\n } else dropPeerView(ng, peer);\n}\n\nexport function dropPeerView(ng: NetGame, peer: Peer): void {\n if (peer.avatar) {\n peer.avatar.dispose(ng.game.scene);\n peer.avatar = null;\n }\n peer.remote = null;\n}\n\nexport async function onMeta(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<PlayerMeta>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) {\n const r = ng.records.get(from);\n if (!r) return;\n m.slot = r.slot;\n r.name = (m.name || r.name).slice(0, 24);\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, m), from);\n ng.hostSleepCheck(from, m);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (!peer) return;\n peer.held = m.held;\n peer.look = typeof m.look === \"string\" ? m.look : null;\n peer.outfit = m.outfit && typeof m.outfit === \"object\" ? m.outfit : {};\n peer.heldSkin = typeof m.heldSkin === \"string\" ? m.heldSkin : null;\n peer.gold = m.gold === true;\n if (peer.remote) peer.remote.gold = peer.gold;\n applyPeerLook(peer);\n if (peer.avatar) peer.avatar.riding = !!m.riding;\n peer.mode = m.mode;\n peer.sleeping = !!(m as { sleeping?: boolean }).sleeping;\n if (m.name && m.name !== peer.name) {\n peer.name = m.name;\n peer.avatar?.setName(m.name);\n }\n}\n\nexport function onBlocks(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): void {\n const { dim, list } = decodeBlocks(data);\n if (ng.isHost && from !== ng.selfId) {\n if (type === M.BLOCKS) {\n // от владельца измерения — всем; дифы хоста для передачи хоста и новых гостей\n if (ng.owners.get(dim) !== from) return;\n ng.diffs.applyAll(dim, list);\n ng.toAll(\"rel\", data, from);\n } else {\n const owner = ng.owners.get(dim);\n if (!owner) return;\n if (owner === from) {\n // правки, сделанные ещё репликой, от того, кто уже стал владельцем (вошёл первым и сразу\n // строил портал): у него они есть — остальным от его имени\n ng.diffs.applyAll(dim, list);\n for (const b of encodeBlocks(M.BLOCKS, dim, list))\n ng.toAll(\"rel\", b, from);\n return;\n }\n if (owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n } else if (ng.isHost && type === M.EDITS) {\n // хост сам — реплика (измерение считает гость): его правки — владельцу, иначе терялись\n const owner = ng.owners.get(dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n }\n if (type === M.EDITS) {\n // я владелец: правка реплики — как своя (каскады посчитаются здесь и разойдутся BLOCKS)\n if (!sim.authority || dim !== ng.game.dimension) return;\n applyBlocks(ng, list, true);\n return;\n }\n if (from === ng.selfId) return;\n if (!ng.isHost) ng.diffs.applyAll(dim, list);\n if (dim === ng.game.dimension && !sim.authority) applyBlocks(ng, list, false);\n}\n\nexport function applyBlocks(\n ng: NetGame,\n list: readonly BlockDelta[],\n asOwner: boolean,\n): void {\n const w = ng.game.world;\n ng.applyingRemote = !asOwner;\n try {\n for (const b of list) {\n if (b.id < 0 || b.id >= BLOCKS.length) continue;\n w.applyRemoteBlock(b.x, b.y, b.z, b.id, b.meta);\n }\n } finally {\n ng.applyingRemote = false;\n }\n}\n\nexport async function onEntities(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n if (type === M.ENT_SPAWN) {\n const m = await decodeJson<EntSpawnMsg>(data);\n if (!m) return;\n if (ng.isHost && from !== ng.selfId) ng.toDim(m.dim, \"rel\", data, from);\n if (m.dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const [id, snap] of m.list) ng.game.entities.netApplySpawn(id, snap);\n return;\n }\n const { dim, ids } = decodeEntRemove(data);\n if (ng.isHost && from !== ng.selfId) ng.toDim(dim, \"rel\", data, from);\n if (dim === ng.game.dimension && !sim.authority && from !== ng.selfId)\n for (const id of ids) ng.game.entities.netRemove(id);\n}\n\nexport async function onEntAction(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EntActionMsg>(data);\n if (!m) return;\n if (ng.isHost) {\n // и от самого хоста, когда он реплика: раньше такие действия до владельца не доходили\n m.from = from === ng.selfId ? ng.slot : ng.records.get(from)?.slot;\n const owner = ng.owners.get(m.dim);\n if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", encodeJsonSync(M.ENT_ACTION, m));\n return;\n }\n }\n if (!sim.authority || m.dim !== ng.game.dimension) return;\n const em = ng.game.entities;\n const a = m.action;\n if (a.a === \"spawn\") em.netSpawnFromRemote(a.snap);\n else if (a.a === \"hurt\") {\n const attacker = m.from !== undefined ? ng.peers.get(m.from)?.remote : null;\n em.hurt(\n a.id,\n a.damage,\n new THREE.Vector3(a.from[0], a.from[1], a.from[2]),\n attacker ?? undefined,\n );\n } else if (a.a === \"explode\") em.explode(a.x, a.y, a.z, a.power);\n else if (a.a === \"patch\") em.netPatch(a.id, a.fields ?? {}, m.from);\n else if (a.a === \"kill\") em.netKill(a.id, !!a.drops);\n else if (a.a === \"ride\") {\n if (m.from !== undefined && !em.netRide(a.id, m.from, !!a.on))\n ng.replyTo({ to: m.from, kind: \"ride\", id: a.id, ok: false });\n } else if (a.a === \"drive\") {\n if (m.from !== undefined && Array.isArray(a.pos) && Array.isArray(a.vel))\n em.netDrive(a.id, m.from, a.pos, a.vel, Number(a.yaw));\n }\n}\n\nexport function entAction(ng: NetGame, action: NetEntityAction): void {\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.ENT_ACTION, {\n dim: ng.game.dimension,\n action,\n } satisfies EntActionMsg),\n );\n}\n\nexport async function onPortal(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PortalMsg>(data);\n if (\n !m ||\n !DIMS.includes(m.dim) ||\n !Array.isArray(m.p) ||\n m.p.length !== 3 ||\n !m.p.every(Number.isFinite)\n )\n return;\n if (ng.isHost) ng.toAll(\"rel\", data, from);\n ng.game.applyNetPortal(m.dim, m.p, !!m.add);\n}\n\n/**\n * Метка карты. Хост проверяет право (менять и удалять — автор или хост), ставит автора новой метке\n * и рассылает остальным; гость верит хосту.\n */\nexport async function onPin(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n if (from === ng.selfId) return;\n const m = await decodeJson<PinMsg>(data);\n if (!m) return;\n const map = ng.game.worldMap;\n if (!ng.isHost) {\n map.applyRemote(\n m.op === \"remove\"\n ? { op: \"remove\", id: Number(m.id) }\n : { op: \"set\", pin: cleanPin(m.pin)! },\n );\n return;\n }\n const id = m.op === \"remove\" ? Number(m.id) : cleanPin(m.pin)?.id;\n if (id === undefined || !Number.isInteger(id)) return;\n const old = map.pins.find((p) => p.id === id);\n if (old && old.by !== from) return; // чужая метка (или хоста)\n if (m.op === \"remove\") {\n if (map.applyRemote({ op: \"remove\", id }))\n ng.toAll(\"rel\", encodeJsonSync(M.PIN, m), from);\n return;\n }\n const pin = cleanPin(m.pin);\n if (!pin) return;\n pin.by = from;\n pin.byName = ng.records.get(from)?.name ?? pin.byName;\n if (map.applyRemote({ op: \"set\", pin }))\n ng.toAll(\n \"rel\",\n encodeJsonSync(M.PIN, { op: \"set\", pin } satisfies PinMsg),\n from,\n );\n}\n\nexport async function onBe(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<BeMsg>(data);\n if (!m) return;\n const owner = ng.owners.get(m.dim);\n if (ng.isHost && from !== ng.selfId) {\n if (owner === from) {\n storeBe(ng, m);\n ng.toDim(m.dim, \"rel\", data, from);\n } else if (owner && owner !== ng.selfId) {\n void ng.send(owner, \"rel\", data);\n return;\n }\n } else if (ng.isHost && owner && owner !== ng.selfId) {\n // хост сам — реплика: его правка сундука — владельцу (раньше оставалась только у него)\n void ng.send(owner, \"rel\", data);\n return;\n }\n if (m.dim !== ng.game.dimension || from === ng.selfId) return;\n if (sim.authority) {\n // правка реплики (открытый сундук): принять — diff разошлёт её всем, включая приславшего\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n } else applyBe(ng, m);\n}\n\nexport function applyBe(ng: NetGame, m: BeMsg): void {\n ng.game.blockEntities.applyRemote(m.x, m.y, m.z, m.be);\n ng.replicaBe.markBe(m.be, m.x + \",\" + m.y + \",\" + m.z);\n}\n\nexport function storeBe(ng: NetGame, m: BeMsg): void {\n let s = ng.beStore.get(m.dim);\n if (!s) ng.beStore.set(m.dim, (s = new Map()));\n const k = m.x + \",\" + m.y + \",\" + m.z;\n if (m.be) s.set(k, m.be);\n else s.delete(k);\n}\n\nexport async function onToPlayer(\n ng: NetGame,\n from: string,\n type: number,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<Hit & Give>(data);\n if (!m) return;\n if (ng.isHost && m.to !== ng.slot) {\n const to = ng.userOfSlot(m.to);\n if (to && to !== from) void ng.send(to, \"rel\", data);\n return;\n }\n if (m.to !== ng.slot) return;\n const g = ng.game;\n if (type === M.HIT) {\n if (m.effect)\n g.player.addEffect(\n m.effect.id as Parameters<typeof g.player.addEffect>[0],\n m.effect.seconds,\n m.effect.level,\n );\n if (m.amount > 0) g.player.damage(m.amount, m.cause);\n if (m.knock)\n g.player.vel.set(\n g.player.vel.x + m.knock[0],\n Math.max(g.player.vel.y, m.knock[1]),\n g.player.vel.z + m.knock[2],\n );\n } else {\n if (m.xp) g.player.addXp(m.xp);\n if (m.stack && ITEMS[m.stack.item]) {\n const left = g.inventory.addStack(m.stack);\n if (left < m.stack.count)\n events.emit(\"itemPickup\", {\n item: m.stack.item,\n count: m.stack.count - left,\n });\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(\n p.x,\n p.y + 0.5,\n p.z,\n m.stack.item,\n left,\n m.stack.dmg,\n m.stack.tag,\n );\n }\n }\n }\n}\n\nexport async function onChat(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<{ from?: number; text: string }>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.from = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.CHAT, m), from);\n }\n const who = m.from !== undefined ? (ng.peers.get(m.from)?.name ?? \"?\") : \"?\";\n ng.game.chat.print(`<${who}> ${String(m.text).slice(0, 256)}`);\n // смайлик в сообщении — эмоция на лице его персонажа\n const feel = emotionFromText(String(m.text));\n if (feel && m.from !== undefined)\n ng.peers.get(m.from)?.avatar?.actor.setEmotion(feel, 4);\n}\n\n/** Облик другого игрока на его аватаре: персонаж (если рецепт уже дошёл) и надетое. */\nexport function applyPeerLook(peer: Peer): void {\n const a = peer.avatar;\n if (!a) return;\n const spec = characterOf(peer.look);\n if (spec) a.setSpec(spec);\n a.actor.setOutfit((peer.outfit ?? {}) as Partial<Record<WearSlot, string>>);\n}\n\n/** Рецепты облика другого игрока: запомнить (персонажа и предметы) и надеть на его аватар. */\nexport async function onLook(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<LookMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", await encodeJson(M.LOOK, m), from);\n }\n const c = parseCharacterSpec(m.character);\n if (c.ok) registerCharacter(c.spec);\n if (m.skins && typeof m.skins === \"object\")\n for (const raw of Object.values(m.skins).slice(0, 8)) {\n const s = parseItemSkin(raw);\n if (s.ok) registerSkin(s.spec);\n }\n const peer = m.slot !== undefined ? ng.peers.get(m.slot) : undefined;\n if (peer) applyPeerLook(peer);\n}\n\n/** Жест или эмоция другого игрока: хост ставит слот отправителя и рассылает. */\nexport async function onEmote(\n ng: NetGame,\n from: string,\n data: Uint8Array,\n): Promise<void> {\n const m = await decodeJson<EmoteMsg>(data);\n if (!m || from === ng.selfId) return;\n if (ng.isHost) {\n m.slot = ng.records.get(from)?.slot;\n ng.toAll(\"rel\", encodeJsonSync(M.EMOTE, m), from);\n }\n const actor =\n m.slot !== undefined ? ng.peers.get(m.slot)?.avatar?.actor : undefined;\n if (!actor) return;\n if (isEmote(m.emote)) actor.playEmote(m.emote);\n if (isEmotion(m.emotion))\n actor.setEmotion(\n m.emotion,\n Math.min(30, Math.max(0, Number(m.seconds) || 4)),\n );\n}\n"
|
|
870
787
|
},
|
|
871
788
|
{
|
|
872
789
|
"path": "net/game/hostMigration.ts",
|
|
@@ -886,7 +803,7 @@
|
|
|
886
803
|
},
|
|
887
804
|
{
|
|
888
805
|
"path": "net/game/tick.ts",
|
|
889
|
-
"content": "import { specHash } from \"
|
|
806
|
+
"content": "import { specHash } from \"@idosgames/characters/voxel\";\nimport { skinOf, skinSpecs, stackSkinHash } from \"../../character/items/skins\";\nimport type { DimensionId } from \"../../world/Dimensions\";\nimport { sim } from \"../sim\";\nimport { encodeJson, encodeJsonSync } from \"../codec\";\nimport {\n M,\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SWIM,\n POSE_SWING,\n POSE_SPRINT,\n encodeBlocks,\n encodeEntPoses,\n encodeEntRemove,\n encodePose,\n type BeMsg,\n type EntSpawnMsg,\n type PlayerMeta,\n type LookMsg,\n} from \"../protocol\";\nimport type { NetGame } from \"../NetGame\";\n\nexport function afterTick(ng: NetGame): void {\n if (ng.closed) return;\n // Сессию закрыли в обход событий (страховка к \"left\"): дальше играть не на чем.\n if (ng.session.closed) return ng.end(null);\n const g = ng.game;\n ng.tickN++;\n const dim = g.dimension;\n\n if (ng.pending.length) {\n const list = ng.pending;\n ng.pending = [];\n const type = sim.authority ? M.BLOCKS : M.EDITS;\n for (const msg of encodeBlocks(type, dim, list))\n if (ng.isHost && type === M.BLOCKS) ng.toAll(\"rel\", msg);\n else ng.toHost(\"rel\", msg);\n }\n\n if (!sim.authority) {\n // свои действия над сущностями (остриг, оседлал, назвал, поторговал) — владельцу\n for (const a of g.entities.netLocalChanges()) ng.entAction(a);\n syncRide(ng);\n }\n\n if (sim.authority) {\n // толчки удалённых игроков (взрыв, удар моба) — им\n for (const peer of ng.peers.values()) {\n const imp = peer.remote?.takeImpulse();\n if (imp && peer.remote) ng.hit(peer.slot, 0, \"knock\", imp);\n }\n if (ng.tickN % 2 === 0 && hasOthersIn(ng, dim)) {\n const d = ng.bc.diffEntities(g.entities);\n // синхронно: появление обязано уйти раньше удаления следующего тика\n if (d.spawns.length)\n ownerSend(\n ng,\n \"rel\",\n encodeJsonSync(M.ENT_SPAWN, {\n dim,\n list: d.spawns,\n } satisfies EntSpawnMsg),\n );\n for (const b of encodeEntPoses(dim, d.poses)) ownerSend(ng, \"fast\", b);\n if (d.removes.length)\n ownerSend(ng, \"rel\", encodeEntRemove(dim, d.removes));\n }\n if (ng.tickN % 10 === 0)\n for (const { be, key } of ng.bc.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [\n number,\n number,\n number,\n ];\n const msg: BeMsg = { dim, x, y, z, be };\n if (ng.isHost) ng.storeBe(msg);\n if (hasOthersIn(ng, dim))\n ownerSend(ng, \"rel\", encodeJsonSync(M.BE, msg));\n }\n } else if (ng.tickN % 10 === 0) {\n // реплика: своё изменение блочной сущности (переложил в сундуке) — владельцу\n for (const { be, key } of ng.replicaBe.diffBlockEntities(g.blockEntities)) {\n const [x, y, z] = key.split(\",\").map(Number) as [number, number, number];\n ng.toHost(\n \"rel\",\n encodeJsonSync(M.BE, { dim, x, y, z, be } satisfies BeMsg),\n );\n }\n }\n\n // своя поза — каждый тик; метаданные — по изменению; сейв игрока — раз в 10 с\n sendPose(ng);\n sendMetaIfChanged(ng);\n void sendLookIfChanged(ng);\n if (ng.tickN % 200 === 0 && !ng.isHost) ng.sendPlayerState();\n\n if (ng.isHost) {\n if (ng.tickN % 100 === 0)\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n if (ng.tickN % 200 === 0) ng.pickSuccessorAndShadow();\n } else ng.watchHost();\n}\n\nexport function syncRide(ng: NetGame): void {\n const g = ng.game;\n const em = g.entities;\n const r = g.player.riding as unknown as { id?: number } | null;\n const id =\n r && typeof r.id === \"number\" && (em.entities.get(r.id) as unknown) === r\n ? r.id\n : null;\n if (id !== ng.driving) {\n if (ng.driving !== null)\n ng.entAction({ a: \"ride\", id: ng.driving, on: false });\n if (id !== null) ng.entAction({ a: \"ride\", id, on: true });\n ng.driving = id;\n em.drivenId = id;\n }\n if (id === null || ng.tickN % 2 !== 0) return;\n const e = em.entities.get(id);\n if (!e) return;\n const vel = \"vel\" in e ? e.vel : null;\n ng.entAction({\n a: \"drive\",\n id,\n pos: [e.pos.x, e.pos.y, e.pos.z],\n vel: vel ? [vel.x, vel.y, vel.z] : [0, 0, 0],\n yaw: e.kind === \"mob\" ? e.yaw : 0,\n });\n}\n\nexport function ownerSend(\n ng: NetGame,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n): void {\n if (ng.isHost) ng.toDim(ng.game.dimension, channel, data, ng.selfId);\n else ng.toHost(channel, data);\n}\n\nexport function hasOthersIn(ng: NetGame, dim: DimensionId): boolean {\n for (const p of ng.peers.values()) if (p.dim === dim) return true;\n return false;\n}\n\nexport function sendPose(ng: NetGame): void {\n const g = ng.game;\n const p = g.player;\n const flags =\n (p._sneaking ? POSE_SNEAK : 0) |\n (p.dead ? POSE_DEAD : 0) |\n (p.flying ? POSE_FLY : 0) |\n (p.gliding ? POSE_GLIDE : 0) |\n (p.inWater ? POSE_SWIM : 0) |\n (g.armView._swing > 0 ? POSE_SWING : 0) |\n (p.mode === \"creative\" ? POSE_CREATIVE : 0) |\n (p._sprinting ? POSE_SPRINT : 0);\n const msg = encodePose({\n slot: ng.slot,\n dim: g.dimension,\n x: p.pos.x,\n y: p.pos.y,\n z: p.pos.z,\n yaw: p.yaw,\n pitch: p.pitch,\n flags,\n health: p.health,\n });\n if (ng.isHost) ng.toAll(\"fast\", msg);\n else ng.toHost(\"fast\", msg);\n}\n\nexport function sendMetaIfChanged(ng: NetGame): void {\n const g = ng.game;\n const meta: PlayerMeta & { sleeping: boolean } = {\n slot: ng.slot,\n held: g.inventory.heldItem()?.item ?? null,\n mode: g.player.mode,\n name: ng.name,\n sleeping: g.netSleeping,\n riding: !!g.player.riding,\n look: specHash(g.character.spec),\n outfit: { ...g.character.outfit },\n heldSkin: stackSkinHash(g.inventory.heldItem()),\n gold: !!g.player.goldArmor?.(),\n };\n const j = JSON.stringify(meta);\n if (j === ng.lastMeta) return;\n ng.lastMeta = j;\n if (ng.isHost) {\n ng.toAll(\"rel\", encodeJsonSync(M.PMETA, meta));\n hostSleepCheck(ng, ng.selfId, meta);\n } else ng.toHost(\"rel\", encodeJsonSync(M.PMETA, meta));\n}\n\n/** Облик сменился (или вошёл новый игрок) — разослать рецепт персонажа и облики предметов. */\nexport async function sendLookIfChanged(ng: NetGame): Promise<void> {\n const g = ng.game;\n const hashes = [\n ...Object.values(g.character.outfit),\n stackSkinHash(g.inventory.heldItem()),\n ].filter((h): h is string => !!h && !!skinOf(h));\n const key = specHash(g.character.spec) + \"|\" + hashes.sort().join(\",\");\n if (key === ng.lastLook) return;\n ng.lastLook = key;\n const msg: LookMsg = {\n slot: ng.slot,\n character: g.character.spec,\n skins: skinSpecs(hashes),\n };\n const data = await encodeJson(M.LOOK, msg);\n if (ng.isHost) ng.toAll(\"rel\", data);\n else ng.toHost(\"rel\", data);\n}\n\nexport function hostSleepCheck(\n ng: NetGame,\n _who: string,\n _m: PlayerMeta,\n): void {\n const g = ng.game;\n const inOverworld = [...ng.records.entries()].filter(\n ([, r]) => r.dim === \"overworld\",\n );\n if (!inOverworld.length) return;\n const allAsleep = inOverworld.every(([uid]) =>\n uid === ng.selfId\n ? g.netSleeping\n : ([...ng.peers.values()].find((p) => p.userId === uid)?.sleeping ??\n false),\n );\n if (allAsleep) {\n g.skipNight();\n ng.toAll(\"rel\", encodeJsonSync(M.WORLD, g.netWorldState()));\n }\n}\n"
|
|
890
807
|
},
|
|
891
808
|
{
|
|
892
809
|
"path": "net/game/types.ts",
|
|
@@ -906,7 +823,7 @@
|
|
|
906
823
|
},
|
|
907
824
|
{
|
|
908
825
|
"path": "net/RemotePlayer.ts",
|
|
909
|
-
"content": "// Другой игрок сетевой игры — у ВЛАДЕЛЬЦА измерения. Мобы, стрелы, взрывы и подбор работают с ним\n// как с обычным Player (EntityManager.others), но состояние игрока — у самого игрока (вариант А):\n// урон, отброс, эффекты и опыт не применяются здесь, а уходят ему по сети; подобранный предмет\n// «выдаётся» ему, а не кладётся в чужой инвентарь.\nimport {
|
|
826
|
+
"content": "// Другой игрок сетевой игры — у ВЛАДЕЛЬЦА измерения. Мобы, стрелы, взрывы и подбор работают с ним\n// как с обычным Player (EntityManager.others), но состояние игрока — у самого игрока (вариант А):\n// урон, отброс, эффекты и опыт не применяются здесь, а уходят ему по сети; подобранный предмет\n// «выдаётся» ему, а не кладётся в чужой инвентарь.\nimport {\n EMOTION_EVENTS,\n type Emotion,\n type CharacterSpec,\n} from \"@idosgames/characters/voxel\";\nimport * as THREE from \"three\";\nimport { Player } from \"../player/Player\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\nimport type { Inventory } from \"../systems/Inventory\";\nimport type { EffectId, ItemStack } from \"../types\";\nimport {\n POSE_CREATIVE,\n POSE_DEAD,\n POSE_FLY,\n POSE_GLIDE,\n POSE_SNEAK,\n POSE_SPRINT,\n POSE_SWIM,\n POSE_SWING,\n type Pose,\n} from \"./protocol\";\nimport { CharacterActor } from \"../character/CharacterActor\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas\";\n\n/** Куда уходит то, что с удалённым игроком случилось у владельца измерения. */\nexport interface RemoteSink {\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: { id: string; seconds: number; level: number },\n ): void;\n give(slot: number, stack: ItemStack | null, xp?: number): void;\n}\n\nexport class RemotePlayer extends Player {\n readonly slot: number;\n readonly userId: string;\n /** Предмет в руке (PlayerMeta) — для мобов, которые идут за едой в руке. */\n held: string | null = null;\n /** Надета золотая вещь (PlayerMeta.gold) — свинорыл не нападает, как на своего игрока. */\n gold = false;\n override goldArmor = (): boolean => this.gold;\n /** Скорость из последней позы: всё, что к ней добавили за тик (толчки), уходит игроку. */\n private readonly velBase = new THREE.Vector3();\n\n constructor(\n world: ChunkManager,\n slot: number,\n userId: string,\n private readonly sink: RemoteSink,\n ) {\n super(world);\n this.slot = slot;\n this.userId = userId;\n }\n\n applyPose(p: Pose): void {\n this.pos.set(p.x, p.y, p.z);\n this.yaw = p.yaw;\n this.pitch = p.pitch;\n this.dead = (p.flags & POSE_DEAD) !== 0;\n this._sneaking = (p.flags & POSE_SNEAK) !== 0;\n this.mode = p.flags & POSE_CREATIVE ? \"creative\" : \"survival\";\n this.health = p.health;\n this.vel.set(0, 0, 0);\n this.velBase.set(0, 0, 0);\n }\n\n /** Урон — игроку: он сам решит про щит, броню, тотем и смерть. */\n override damage(amount: number, cause: string): void {\n if (this.dead || this.mode === \"creative\" || amount <= 0) return;\n this.sink.hit(this.slot, amount, cause);\n }\n\n override knock(dx: number, dz: number, strength: number): void {\n const len = Math.hypot(dx, dz) || 1;\n this.vel.x += (dx / len) * strength;\n this.vel.z += (dz / len) * strength;\n this.vel.y = Math.max(this.vel.y, 4);\n }\n\n override addEffect(id: EffectId, seconds: number, level = 1): void {\n this.sink.hit(this.slot, 0, \"effect\", undefined, { id, seconds, level });\n }\n\n override addXp(amount: number): void {\n if (amount > 0) this.sink.give(this.slot, null, amount);\n }\n\n /** Щит решает сам игрок (у него инвентарь): здесь не блокирует ничего. */\n override shieldBlocks(): boolean {\n return false;\n }\n\n /**\n * Толчок, накопленный за тик (knock, взрыв добавили к vel): отдать игроку и обнулить. Зовёт\n * NetGame после тика сущностей.\n */\n takeImpulse(): [number, number, number] | null {\n const dx = this.vel.x - this.velBase.x,\n dy = this.vel.y - this.velBase.y,\n dz = this.vel.z - this.velBase.z;\n this.vel.copy(this.velBase);\n if (dx * dx + dy * dy + dz * dz < 0.01) return null;\n return [dx, dy, dz];\n }\n}\n\n/**\n * Инвентарь удалённого игрока у владельца измерения: подобранное уходит игроку. Считается, что\n * место есть — если нет, игрок сам выбросит лишнее у себя (его инвентарь авторитетен).\n */\nexport function remoteInventory(p: RemotePlayer, sink: RemoteSink): Inventory {\n const inv = {\n add(item: string, count: number): number {\n sink.give(p.slot, { item, count });\n return 0;\n },\n addStack(s: ItemStack): number {\n sink.give(p.slot, { ...s });\n return 0;\n },\n heldItem(): ItemStack | null {\n return p.held ? { item: p.held, count: 1 } : null;\n },\n // магнит предмета (ItemDrop) спрашивает место: без метода цикл сущностей хозяина падал целиком\n hasRoom(): boolean {\n return true;\n },\n slots: [] as (ItemStack | null)[],\n armor: [] as (ItemStack | null)[],\n };\n return inv as unknown as Inventory;\n}\n\n// ============================================================ аватар\n\n/**\n * Другой игрок на экране: его персонаж (рецепт по сети, пока не пришёл — «Путник»), анимации по\n * позам — шаг, бег, присед, плавание, полёт, планер, замах, смерть, — жесты и эмоции из сети,\n * предмет в руке. Между позами (15 в секунду) — плавное движение к новой.\n */\nexport class Avatar {\n readonly actor: CharacterActor;\n private readonly from = new THREE.Vector3();\n private readonly to = new THREE.Vector3();\n private readonly pos = new THREE.Vector3();\n private yawFrom = 0;\n private yawTo = 0;\n private pitch = 0;\n private t = 1;\n private flags = 0;\n private speed = 0;\n private vy = 0;\n private placed = false;\n private mood: Emotion = \"neutral\";\n private health = 20;\n /** Предмет в руке и сон/езда — из PlayerMeta (NetGame ставит). */\n held: string | null = null;\n heldSkin: string | null = null;\n sleeping = false;\n riding = false;\n\n constructor(\n name: string,\n atlas: TextureAtlas | null = null,\n spec?: CharacterSpec,\n ) {\n this.actor = new CharacterActor(spec, { name, atlas });\n }\n\n get group(): THREE.Group {\n return this.actor.group;\n }\n\n setName(name: string): void {\n this.actor.view.setName(name);\n }\n\n setSpec(spec: CharacterSpec): void {\n this.actor.setSpec(spec);\n }\n\n /** Новая поза из сети: от текущего положения к новому за ~один интервал поз. */\n target(p: Pose): void {\n this.from.copy(this.pos);\n this.to.set(p.x, p.y, p.z);\n this.yawFrom = this.yawTo;\n this.yawTo = p.yaw;\n this.pitch = p.pitch;\n // замах — по фронту флага (флаг держится, пока у игрока идёт анимация замаха)\n if (p.flags & POSE_SWING && !(this.flags & POSE_SWING))\n this.actor.trigger(\"swing\");\n this.flags = p.flags;\n // настроение по здоровью — как у своего персонажа (game/playerCharacter)\n const mood: Emotion =\n p.flags & (POSE_DEAD | POSE_CREATIVE)\n ? \"neutral\"\n : p.health <= 4\n ? EMOTION_EVENTS.nearDeath.emotion\n : p.health <= 8\n ? EMOTION_EVENTS.lowHealth.emotion\n : \"neutral\";\n if (mood !== this.mood) {\n this.mood = mood;\n this.actor.setEmotion(mood);\n }\n if (p.health < this.health) this.actor.trigger(\"hurt\");\n this.health = p.health;\n // первая поза или телепорт — без плавания через полкарты\n if (!this.placed || this.from.distanceToSquared(this.to) > 64) {\n this.from.copy(this.to);\n this.pos.copy(this.to);\n this.yawFrom = p.yaw;\n this.placed = true;\n }\n this.t = 0;\n }\n\n frame(dt: number, bright?: number): void {\n const prevX = this.pos.x,\n prevY = this.pos.y,\n prevZ = this.pos.z;\n this.t = Math.min(1, this.t + dt / 0.07);\n this.pos.lerpVectors(this.from, this.to, this.t);\n let dy = this.yawTo - this.yawFrom;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n const yaw = this.yawFrom + dy * this.t;\n if (dt > 0) {\n // скорость по пройденному — сглаженно, иначе шаг дёргается между позами\n const v = Math.hypot(this.pos.x - prevX, this.pos.z - prevZ) / dt;\n this.speed += (v - this.speed) * Math.min(1, dt * 12);\n this.vy += ((this.pos.y - prevY) / dt - this.vy) * Math.min(1, dt * 12);\n }\n const f = this.flags;\n const flying = (f & POSE_FLY) !== 0;\n this.actor.update(dt, {\n x: this.pos.x,\n y: this.pos.y,\n z: this.pos.z,\n yaw,\n pitch: this.pitch,\n speed: this.speed,\n vy: this.vy,\n onGround: !flying && Math.abs(this.vy) < 0.5,\n inWater: (f & POSE_SWIM) !== 0,\n flying,\n gliding: (f & POSE_GLIDE) !== 0,\n sneaking: (f & POSE_SNEAK) !== 0,\n sprinting: (f & POSE_SPRINT) !== 0,\n dead: (f & POSE_DEAD) !== 0,\n sleeping: this.sleeping,\n riding: this.riding,\n held: this.held,\n heldSkin: this.heldSkin,\n ...(bright !== undefined ? { bright } : {}),\n });\n }\n\n dispose(scene: THREE.Object3D): void {\n scene.remove(this.actor.group);\n this.actor.dispose();\n }\n}\n"
|
|
910
827
|
},
|
|
911
828
|
{
|
|
912
829
|
"path": "net/sim.ts",
|
|
@@ -922,7 +839,7 @@
|
|
|
922
839
|
},
|
|
923
840
|
{
|
|
924
841
|
"path": "player/ArmView.ts",
|
|
925
|
-
"content": "// Рука от первого лица: правая рука персонажа игрока (рецепт, character/), прикреплена к камере.\n// Предмет — в кулаке: инструменты и прочее — объёмная модель из иконки (gfx/itemModel, как в\n// Minecraft), блок — мини-куб, облик от ИИ — своя модель. Замах — процедурная анимация по armSwing.\nimport { firstPersonSkin, stackSkin } from \"../character/items/skins\";\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { ITEMS } from \"../registry/Items\";\nimport { blockCube, heldItemObject, heldKindOf } from \"../gfx/itemModel\";\nimport type
|
|
842
|
+
"content": "// Рука от первого лица: правая рука персонажа игрока (рецепт, character/), прикреплена к камере.\n// Предмет — в кулаке: инструменты и прочее — объёмная модель из иконки (gfx/itemModel, как в\n// Minecraft), блок — мини-куб, облик от ИИ — своя модель. Замах — процедурная анимация по armSwing.\nimport { firstPersonSkin, stackSkin } from \"../character/items/skins\";\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config\";\nimport { events } from \"../core/EventBus\";\nimport { ITEMS } from \"../registry/Items\";\nimport { blockCube, heldItemObject, heldKindOf } from \"../gfx/itemModel\";\nimport {\n type CharacterSpec,\n acquireCharacter,\n releaseCharacter,\n type CharacterAsset,\n} from \"@idosgames/characters/voxel\";\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\n/** Прежняя рука-коробка: у персонажа без правой руки. */\nconst BOX = new THREE.BoxGeometry(0.14, 0.14, 0.5);\n\n/** Размер предмета в руке, блоков. */\nconst ITEM_SIZE = 0.85;\n/**\n * Поворот предмета в кулаке (в осях камеры): остриё вверх и чуть к центру экрана, плоскость\n * развёрнута боком — видна толщина. Порядок XYZ: сначала Z (наклон в плоскости иконки).\n */\nconst ITEM_ROT = new THREE.Euler(-0.1, -0.45, 0.95);\n/** Сдвиг хвата от центра кулака (в осях камеры): рукоять — в ладони, а не на её правом краю. */\nconst ITEM_GRIP = new THREE.Vector3(-0.09, -0.02, 0);\n/** Меч держат за рукоять под гардой — ещё левее, чем инструменты. */\nconst SWORD_GRIP = new THREE.Vector3(-0.06, 0, 0);\n\nexport class ArmView {\n camera: THREE.PerspectiveCamera;\n atlas: TextureAtlas;\n group: THREE.Group;\n armMesh: THREE.Mesh;\n itemMesh: THREE.Object3D | null;\n _heldKey: string | null;\n _swing: number;\n /** Рецепт персонажа, из которого рука (держим ссылку на общие ресурсы). */\n private _asset: CharacterAsset | null = null;\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(BOX, armMat);\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 events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n }\n\n /**\n * Рука — из рецепта персонажа (кость armR: рукав, кожа, перчатка). Пиксели модели → блоки,\n * плечо у камеры, кисть вперёд-вверх, как прежняя коробка.\n */\n setCharacter(spec: CharacterSpec): void {\n const next = acquireCharacter(spec);\n const old = this._asset;\n this._asset = next;\n const geo = next.geometries.get(\"armR\") ?? null;\n const mat = this.armMesh.material as THREE.MeshLambertMaterial;\n if (geo) {\n this.armMesh.geometry = geo;\n mat.map = next.texture;\n mat.color.setScalar(1);\n const k = next.layout.scale / 16;\n this.armMesh.scale.setScalar(k);\n this.armMesh.rotation.set(Math.PI / 2 + 0.45, -0.3, 0.1);\n this.armMesh.position.set(0.12, -0.2, 0.12);\n } else {\n // у персонажа нет правой руки (капля, четвероногое) — прежняя коробка цвета кожи\n this.armMesh.geometry = BOX;\n mat.map = null;\n mat.color.setHex(0xd8a06c);\n this.armMesh.scale.setScalar(1);\n this.armMesh.rotation.set(0.35, -0.25, 0);\n this.armMesh.position.set(0, 0, 0);\n }\n mat.needsUpdate = true;\n this._heldKey = \"\\u0000\"; // кулак сместился — предмет пересоберётся в следующем кадре\n if (old) releaseCharacter(old);\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const skin = stackSkin(held);\n const key = held ? held.item + (skin ? \"|\" + skin.hash : \"\") : 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 = null;\n }\n // неизвестный предмет (из сундука чужого сейва) — просто пустая рука, а не падение кадра\n const item = held ? ITEMS[held.item] : undefined;\n this.armMesh.visible = true; // рука видна всегда — предмет у неё в кулаке\n if (!held || !item) return;\n\n const fist = this.fistPoint();\n const cube = skin ? null : blockCube(this.atlas, held.item, 0.24);\n let obj: THREE.Object3D | null;\n if (skin) {\n obj = firstPersonSkin(skin);\n obj.position.add(fist);\n } else if (cube) {\n obj = new THREE.Mesh(cube, this.atlas.opaqueMat);\n obj.position.copy(fist).add(new THREE.Vector3(-0.02, 0.1, -0.06));\n obj.rotation.set(0.1, Math.PI / 5, 0);\n } else {\n obj = heldItemObject(held.item, ITEM_SIZE);\n if (!obj) return;\n obj.position.copy(fist).add(ITEM_GRIP);\n if (heldKindOf(held.item) === \"sword\") obj.position.add(SWORD_GRIP);\n obj.rotation.copy(ITEM_ROT);\n }\n obj.traverse((o) => o.layers.set(ARM_LAYER));\n this.itemMesh = obj;\n this.group.add(obj);\n }\n\n /** Кулак правой руки в осях группы: низ кости руки (у коробки — её дальний конец). */\n private fistPoint(): THREE.Vector3 {\n const geo = this.armMesh.geometry;\n let p: THREE.Vector3;\n if (geo === BOX) p = new THREE.Vector3(0, 0, -0.22);\n else {\n if (!geo.boundingBox) geo.computeBoundingBox();\n const b = geo.boundingBox!;\n p = new THREE.Vector3(\n (b.min.x + b.max.x) / 2,\n b.min.y + (b.max.y - b.min.y) * 0.1,\n (b.min.z + b.max.z) / 2,\n );\n }\n this.armMesh.updateMatrix();\n return p.applyMatrix4(this.armMesh.matrix);\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"
|
|
926
843
|
},
|
|
927
844
|
{
|
|
928
845
|
"path": "player/body/effects.ts",
|
|
@@ -1590,7 +1507,7 @@
|
|
|
1590
1507
|
},
|
|
1591
1508
|
{
|
|
1592
1509
|
"path": "style.css",
|
|
1593
|
-
"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/* подсказка под прицелом (ui/AimHint) */\n#aimHint {\n position: fixed;\n left: 50%;\n top: calc(50% + 22px);\n transform: translateX(-50%);\n pointer-events: none;\n z-index: 5;\n text-align: center;\n font-size: 13px;\n text-shadow: 1px 1px 0 #000;\n max-width: min(92vw, 520px);\n}\n#aimHint div {\n background: rgba(0, 0, 0, 0.45);\n border-radius: 4px;\n padding: 2px 8px;\n margin-top: 3px;\n display: inline-block;\n}\n#aimHint .ahUse {\n color: #fff;\n}\n#aimHint .ahWarn {\n color: #ffb36b;\n}\n#aimHint div + div {\n display: table;\n margin: 3px auto 0;\n}\n\n/* --idos-safe-top: высота общего HUD host'а (0 без него) — служебные строки не уходят под HUD. */\n#fps {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n/* под круглой кнопкой «в лобби» базы (40px в левом верхнем углу): строка не уходит под неё */\n#debug {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 56px);\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 26px);\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* --idos-safe-bottom: высота нижней навигации host'а (0, когда режим один) — хотбар над ней. */\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: calc(var(--idos-safe-bottom, 0px) + 8px);\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n.slot .dur {\n position: absolute;\n left: 4px;\n bottom: 3px;\n height: 3px;\n display: none;\n pointer-events: none;\n box-shadow: 0 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#xpBar {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 60px);\n width: min(400px, 90vw);\n height: 6px;\n background: rgba(20, 20, 20, 0.8);\n border: 1px solid #000;\n z-index: 5;\n pointer-events: none;\n}\n.xpFill {\n height: 100%;\n width: 0;\n background: linear-gradient(#b8ff5a, #5ec21e);\n}\n#xpLevel {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 64px);\n color: #8aff3a;\n font: bold 14px monospace;\n text-shadow:\n 1px 1px 0 #000,\n -1px -1px 0 #000;\n z-index: 6;\n pointer-events: none;\n}\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n/* пузырьки воздуха над едой — под водой */\n#bubbles {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 15px;\n color: #7ad0ff;\n}\n.bubble.empty {\n opacity: 0;\n}\n/* окорочок — эмодзи: цвет на него не действует, пустые и половинки гасим фильтром */\n.drumstick.half {\n filter: grayscale(0.7) brightness(0.75);\n opacity: 0.7;\n}\n.drumstick.empty {\n filter: grayscale(1) brightness(0.3);\n opacity: 0.6;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- смерть, горение ---------- */\n#deathScreen {\n position: fixed;\n inset: 0;\n z-index: 35;\n background: rgba(120, 0, 0, 0.55);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.deathPanel {\n text-align: center;\n color: #fff;\n text-shadow: 2px 2px 0 #000;\n}\n.deathPanel h1 {\n font-size: 46px;\n margin-bottom: 8px;\n}\n.deathCause {\n font-size: 16px;\n margin-bottom: 20px;\n}\n.deathBtn {\n cursor: pointer;\n font-size: 16px;\n padding: 10px 28px;\n border-radius: 4px;\n border: 2px solid #aaa;\n background: #3a3f4d;\n color: #eee;\n}\n.deathBtn:hover {\n background: #4a5163;\n}\n#fireOverlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n opacity: 0;\n background: radial-gradient(\n ellipse at 50% 120%,\n rgba(255, 140, 20, 0.75),\n transparent 60%\n );\n transition: opacity 0.2s;\n}\n#fireOverlay.show {\n opacity: 1;\n animation: burn 0.35s infinite alternate;\n}\n@keyframes burn {\n from {\n filter: brightness(0.85);\n }\n to {\n filter: brightness(1.2);\n }\n}\n\n/* ---------- босс и титры ---------- */\n#bossBar {\n position: fixed;\n top: 14px;\n left: 50%;\n transform: translateX(-50%);\n width: min(380px, calc(100% - 32px));\n z-index: 6;\n text-align: center;\n color: #fff;\n text-shadow: 1px 1px 0 #000;\n font-size: 14px;\n pointer-events: none;\n}\n.bossTrack {\n margin-top: 4px;\n height: 8px;\n background: #3a1030;\n border: 1px solid #000;\n}\n.bossFill {\n height: 100%;\n width: 100%;\n background: #c040ff;\n}\n#creditsScreen {\n position: fixed;\n inset: 0;\n z-index: 36;\n background: rgba(20, 10, 40, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.credits {\n font-size: 16px;\n line-height: 1.6;\n margin-bottom: 20px;\n}\n\n/* ---------- чат / консоль ---------- */\n#chat {\n position: fixed;\n left: 10px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n width: min(520px, calc(100% - 20px));\n z-index: 8;\n pointer-events: none;\n font: 13px monospace;\n color: #fff;\n}\n.chatLog {\n display: flex;\n flex-direction: column;\n gap: 2px;\n max-height: 40vh;\n overflow: hidden;\n}\n.chatLine {\n background: rgba(0, 0, 0, 0.45);\n padding: 2px 6px;\n text-shadow: 1px 1px 0 #000;\n animation: chatFade 10s forwards;\n}\n#chat.open .chatLine {\n animation: none;\n opacity: 1;\n}\n@keyframes chatFade {\n 0%,\n 80% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n.chatInput {\n pointer-events: auto;\n width: 100%;\n margin-top: 4px;\n padding: 6px 8px;\n font: 14px monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.7);\n border: 1px solid #888;\n outline: none;\n}\n\n/* ---------- сенсорное управление ---------- */\n.touchUi {\n position: fixed;\n inset: 0;\n z-index: 6; /* над канвасом и HUD, под инвентарём, чатом и оверлеем */\n pointer-events: none;\n touch-action: none;\n}\n.touchUi > * {\n pointer-events: auto;\n touch-action: none;\n user-select: none;\n -webkit-user-select: none;\n}\n.tLook {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 40%;\n}\n.tStick {\n position: absolute;\n left: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 84px);\n width: 132px;\n height: 132px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.12);\n border: 2px solid rgba(255, 255, 255, 0.35);\n}\n.tKnob {\n position: absolute;\n left: 50%;\n top: 50%;\n width: 56px;\n height: 56px;\n margin: -28px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.42);\n pointer-events: none;\n}\n.tBtn {\n position: absolute;\n width: 62px;\n height: 62px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.35);\n border: 2px solid rgba(255, 255, 255, 0.45);\n color: #fff;\n font:\n bold 22px system-ui,\n sans-serif;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.tBtn.on {\n background: rgba(154, 223, 106, 0.5);\n}\n.tJump {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n}\n.tSneak {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 172px);\n}\n.tAttack {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 134px);\n}\n.tUse {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 210px);\n}\n.tInv {\n right: 12px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tDrop {\n right: 124px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tChat {\n right: 68px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tEmote {\n right: 180px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tView {\n right: 236px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 16px;\n}\n/* на телефоне хотбар над зоной обзора: иначе тап по слоту крутил бы камеру */\nbody.touch #hotbar {\n z-index: 7;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow,\n.invTop {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid,\n.craftGrid {\n display: grid;\n gap: 3px;\n}\n.armorCol {\n display: grid;\n gap: 3px;\n margin-right: 18px;\n}\n/* кукла персонажа рядом с бронёй */\n.dollBox {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n margin-right: 14px;\n}\n.dollCanvas {\n width: 96px;\n height: 150px;\n background: #2b2b2b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n cursor: grab;\n}\n\n.chestGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n.furnaceCol {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n}\n.flame {\n width: 20px;\n height: 20px;\n background: #5a5a5a;\n display: flex;\n align-items: flex-end;\n}\n.flameFill {\n width: 100%;\n height: 0;\n background: linear-gradient(#ffe27a, #f06a14);\n}\n.cookArrow {\n width: 56px;\n height: 14px;\n background: #5a5a5a;\n margin: 0 8px;\n}\n.cookFill {\n height: 100%;\n width: 0;\n background: #f5f5f5;\n}\n.invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n.invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n/* наковальня: цена в уровнях справа от результата */\n.anvilCost {\n min-width: 110px;\n font-size: 12px;\n color: #2a6a2a;\n}\n\n/* стол зачарований: предмет и лазурит, три варианта; зачарованное — с фиолетовым отблеском */\n.enchCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.enchOffers {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 260px;\n}\n.enchOffer {\n padding: 6px 8px;\n font-size: 12px;\n cursor: pointer;\n color: #f0e8ff;\n background: #4a3a6a;\n border: 2px solid #2a1a40;\n}\n.enchOffer:hover {\n background: #5a4a8a;\n}\n/* ткацкий станок: картинка знамени слева от названия узора */\n.loomOffers .enchOffer {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.loomPic {\n width: 24px;\n height: 24px;\n image-rendering: pixelated;\n border: 1px solid #2a1a40;\n}\n/* карта сокровищ: крестик над кладом */\n.mapTreasure {\n position: absolute;\n transform: translate(-50%, -50%);\n color: #c02020;\n font-weight: bold;\n font-size: 14px;\n text-shadow: 0 0 2px #fff;\n pointer-events: none;\n}\n/* достижения: всплывашка справа сверху и список по L */\n#achToast {\n position: absolute;\n top: 12px;\n right: 12px;\n min-width: 220px;\n padding: 8px 12px;\n background: rgba(20, 20, 30, 0.88);\n border: 2px solid #7a6a3a;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 30;\n}\n#achToast .achHead {\n color: #f0d060;\n font-weight: bold;\n margin-bottom: 2px;\n}\n#achList {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: min(560px, 92vw);\n max-height: 80vh;\n overflow-y: auto;\n padding: 10px 14px;\n background: rgba(16, 16, 24, 0.92);\n border: 2px solid #555;\n color: #ccc;\n font-size: 13px;\n z-index: 29;\n}\n#achList h3 {\n margin: 0 0 8px;\n color: #fff;\n}\n#achList .ach {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding: 3px 0;\n border-bottom: 1px solid #2a2a36;\n}\n#achList .ach.done b {\n color: #9ae07a;\n}\n#achList .ach span {\n opacity: 0.7;\n text-align: right;\n}\n.enchOffer.on {\n outline: 2px solid #7ee8e0;\n}\n.beaconInfo {\n font-size: 12px;\n opacity: 0.85;\n margin: 2px 0 4px;\n}\n/* Тление: сердца чернеют */\n#hearts.decay .heart {\n filter: grayscale(1) brightness(0.4);\n}\n.enchOffer.off {\n cursor: default;\n color: #9a90a8;\n background: #5a5560;\n}\n.slot.ench img {\n filter: drop-shadow(0 0 3px #b060ff) saturate(1.3);\n}\n\n/* торговля с жителем: строки сделок с иконками, в тонах дерева */\n.tradeOffers {\n max-height: 260px;\n overflow-y: auto;\n}\n.tradeOffer {\n display: flex;\n align-items: center;\n gap: 4px;\n background: #5a4630;\n border-color: #3a2a18;\n color: #fff4e0;\n}\n.tradeOffer:hover {\n background: #6e583c;\n}\n.tradeOffer img {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n}\n.tradeLeft {\n margin-left: auto;\n padding-left: 8px;\n opacity: 0.75;\n}\n\n/* компас и часы в руке — у правого края хотбара */\n#gadget {\n position: absolute;\n bottom: 58px;\n left: calc(50% + 200px);\n min-width: 40px;\n padding: 4px 8px;\n text-align: center;\n font: 16px/1.2 monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.45);\n border-radius: 6px;\n pointer-events: none;\n}\n#gadget .needle {\n display: inline-block;\n color: #e03030;\n font-size: 22px;\n}\n\n/* карта в руке — справа над хотбаром */\n#mapView {\n position: absolute;\n right: 16px;\n bottom: 70px;\n width: min(256px, 40vw);\n aspect-ratio: 1;\n max-width: 100%;\n border: 4px solid #8a6a40;\n background: #d6be96;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n pointer-events: none;\n}\n#mapView canvas {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#mapView .mapMarker {\n position: absolute;\n color: #fff;\n font-size: 14px;\n text-shadow:\n 0 0 2px #000,\n 0 0 2px #000;\n}\n\n/* варочная стойка: ингредиент и топливо столбиком, три бутылки в ряд */\n.brewCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.bottleRow {\n display: flex;\n gap: 6px;\n}\n/* эффекты зелий — под миникартой (ui/map) */\n#effects {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 226px);\n right: 12px;\n z-index: 5;\n color: #fff;\n font-size: 13px;\n text-align: right;\n text-shadow: 1px 1px 0 #000;\n pointer-events: none;\n white-space: pre;\n}\n\n/* креатив: поиск, палитра всех предметов, корзина */\n.creativeSearch {\n width: 100%;\n box-sizing: border-box;\n margin-bottom: 8px;\n padding: 6px 8px;\n font-size: 14px;\n border: 2px solid #555;\n background: #fff;\n}\n.creativeGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n max-height: calc(44px * 5 + 3px * 4);\n overflow-y: auto;\n margin-bottom: 12px;\n padding-right: 4px;\n}\n.creativeGrid .slot {\n cursor: pointer;\n}\n.trashLabel {\n color: #3a3a3a;\n font-size: 13px;\n}\n.slot.trash::before {\n content: \"🗑\";\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 22px;\n opacity: 0.6;\n pointer-events: none;\n}\n#armorBar {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 16px;\n letter-spacing: 3px;\n text-shadow: 1px 1px 0 #000;\n}\n.armorPip {\n color: #cfd6e0;\n}\n.armorPip.half {\n opacity: 0.55;\n}\n.armorPip.empty {\n color: #3a3a3a;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n/* Пауза и старт — в теме UI kit SDK (переменные --idos-ui-* ставит корень kit'а над игрой); без\n лобби SDK (свой стенд) — запасные цвета той же темы. */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: var(--idos-ui-backdrop, rgba(8, 12, 40, 0.72));\n backdrop-filter: blur(3px);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n font-family: var(--idos-ui-font, \"Trebuchet MS\", system-ui, sans-serif);\n}\n#overlay .panel {\n cursor: default;\n text-align: center;\n color: var(--idos-ui-text, #fff);\n width: min(560px, 100%);\n max-height: 100%;\n overflow: auto;\n padding: 22px 22px 24px;\n box-sizing: border-box;\n background: linear-gradient(\n 180deg,\n var(--idos-ui-panel, #2c3480) 0%,\n var(--idos-ui-panel-deep, #1b2160) 100%\n );\n border: 2px solid var(--idos-ui-panel-edge, #4a56b8);\n border-radius: var(--idos-ui-radius, 18px);\n box-shadow: var(--idos-ui-panel-shadow, 0 12px 40px rgba(0, 0, 0, 0.5));\n}\n#overlay h1 {\n margin: 0 0 4px;\n font-size: 40px;\n font-weight: 800;\n letter-spacing: 1px;\n color: var(--idos-ui-gold, #ffd24a);\n text-shadow: var(--idos-ui-text-outline, 0 2px 0 rgba(0, 0, 0, 0.45));\n}\n#overlayHint {\n margin: 0 0 16px;\n font-size: 15px;\n font-weight: 700;\n color: var(--idos-ui-text-dim, #b9c0ee);\n}\n#overlay .controls {\n text-align: left;\n background: var(--idos-ui-well, rgba(0, 0, 0, 0.3));\n border: 2px solid var(--idos-ui-panel-edge, #4a56b8);\n box-shadow: var(--idos-ui-well-shadow, inset 0 2px 6px rgba(0, 0, 0, 0.35));\n border-radius: calc(var(--idos-ui-radius, 18px) * 0.6);\n padding: 10px 14px;\n font-size: 13px;\n line-height: 1.75;\n margin-bottom: 18px;\n}\n#overlay .controls b {\n color: var(--idos-ui-gold, #ffd24a);\n}\n#overlay .buttons {\n display: flex;\n flex-wrap: wrap;\n gap: 12px;\n justify-content: center;\n}\n/* язык в паузе: подпись и две кнопки-переключателя */\n#overlay .langRow {\n display: flex;\n gap: 8px;\n align-items: center;\n justify-content: center;\n margin: -6px 0 16px;\n font-size: 14px;\n font-weight: 700;\n color: var(--idos-ui-text-dim, #b9c0ee);\n}\n#overlay .langRow .langBtn {\n min-height: 32px;\n padding: 4px 14px;\n font-size: 14px;\n opacity: 0.6;\n}\n#overlay .langRow .langBtn.on {\n opacity: 1;\n outline: 2px solid var(--idos-ui-gold, #ffd24a);\n}\n/* кнопки — как Button UI kit: градиент тона, «ступенька» снизу, текст с обводкой */\n#overlay button {\n cursor: pointer;\n min-height: 44px;\n padding: 10px 22px;\n border: none;\n border-radius: var(--idos-ui-button-radius, 14px);\n font-family: inherit;\n font-size: 16px;\n font-weight: 800;\n color: var(--idos-ui-on-blue, #fff);\n text-shadow: var(--idos-ui-on-blue-outline, 0 1px 0 rgba(0, 0, 0, 0.4));\n background: linear-gradient(\n 180deg,\n var(--idos-ui-blue, #4aa8ff) 0%,\n var(--idos-ui-blue-deep, #2a6fd6) 100%\n );\n box-shadow:\n 0 4px 0 var(--idos-ui-edge, rgba(0, 0, 0, 0.35)),\n inset 0 2px 0 rgba(255, 255, 255, 0.35);\n}\n#overlay button.accent {\n min-width: 160px;\n color: var(--idos-ui-on-green, #fff);\n text-shadow: var(--idos-ui-on-green-outline, 0 1px 0 rgba(0, 0, 0, 0.4));\n background: linear-gradient(\n 180deg,\n var(--idos-ui-green, #5fd35b) 0%,\n var(--idos-ui-green-deep, #2f9a3a) 100%\n );\n}\n/* без kit (свой стенд) — нажатие как у .idos-press */\n#overlay button:hover {\n filter: brightness(1.07);\n}\n#overlay button:active {\n transform: translateY(3px) scale(0.96);\n}\nbody.touch #overlay h1 {\n font-size: 32px;\n}\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\n.wsButtons {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 6px;\n}\n\n.wsStatus {\n min-height: 20px;\n color: #b8e59a;\n}\n.wsStatus.error {\n color: #ff8f80;\n}\n\n.wsList {\n width: 100%;\n display: flex;\n flex-direction: column;\n gap: 6px;\n color: #aab3c8;\n}\n.wsItem {\n display: flex;\n gap: 8px;\n align-items: center;\n padding: 8px 10px;\n background: #2c3348;\n border-radius: 6px;\n}\n.wsItem > div {\n flex: 1;\n min-width: 0;\n}\n.wsItem.current {\n outline: 1px solid #9adf6a;\n}\n.wsItemName {\n color: #eee;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.wsItemMeta {\n font-size: 12px;\n}\n\n/* Книга на кафедре (ui/BookScreen): страница пергамента поверх игры */\n.bookScreen {\n position: absolute;\n left: 50%;\n top: 50%;\n transform: translate(-50%, -50%);\n width: 320px;\n padding: 14px;\n background: #e8dcb8;\n color: #2a1c0e;\n border: 3px solid #8a6a3a;\n font-size: 13px;\n z-index: 40;\n}\n.bookScreen.hidden {\n display: none;\n}\n.bookTitle {\n font-weight: bold;\n margin-bottom: 8px;\n text-align: center;\n}\n.bookPage {\n min-height: 180px;\n line-height: 15px;\n white-space: pre-wrap;\n}\n.bookPage.hidden,\n.bookNav.hidden {\n display: none;\n}\n.bookEdit {\n width: 100%;\n height: 180px;\n box-sizing: border-box;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n resize: none;\n}\n.bookEdit.hidden {\n display: none;\n}\n.bookNav {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 8px;\n}\n.bookBtn {\n padding: 4px 10px;\n cursor: pointer;\n background: #8a6a3a;\n color: #f4ecd0;\n border: 1px solid #5a4020;\n}\n.bookBtn:hover {\n background: #a07a44;\n}\n.bookCount {\n flex: 1;\n text-align: center;\n}\n.bookTitleInput {\n flex: 1;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n padding: 3px 5px;\n}\n\n/* ---------- сетевая игра (ui/NetUI) ---------- */\n#netScreen {\n position: fixed;\n inset: 0;\n z-index: 40;\n background: rgba(4, 20, 50, 0.72);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: calc(var(--idos-safe-top, 0px) + 12px) 12px\n calc(var(--idos-safe-bottom, 0px) + 12px);\n color: #eee;\n font-size: 14px;\n user-select: text;\n}\n#netScreen.hidden,\n#netTabList.hidden,\n#netScreen .hidden {\n display: none;\n}\n#netScreen .wsPanel {\n width: min(620px, 100%);\n}\n#netScreen .wsForm button {\n cursor: pointer;\n font-size: 13px;\n padding: 6px 12px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n\n.netInput {\n flex: none;\n width: 100%;\n box-sizing: border-box;\n min-width: 0;\n font: inherit;\n padding: 7px 8px;\n border-radius: 6px;\n border: 1px solid #4a5163;\n background: #161b28;\n color: #eee;\n}\n#netTabList {\n position: absolute;\n top: calc(var(--idos-safe-top, 0px) + 40px);\n left: 50%;\n transform: translateX(-50%);\n z-index: 30;\n min-width: 240px;\n max-width: calc(100% - 32px);\n padding: 8px 12px;\n background: rgba(0, 0, 0, 0.6);\n border-radius: 6px;\n color: #fff;\n font-size: 14px;\n pointer-events: none;\n}\n\n.netTabRow {\n display: flex;\n justify-content: space-between;\n gap: 24px;\n padding: 2px 0;\n}\n.netTabPing {\n color: #9adf6a;\n font-size: 12px;\n}\n\n/* жесты и эмоции персонажа (ui/EmoteWheel) */\n#emoteWheel {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.35);\n z-index: 30;\n}\n#emoteWheel.hidden {\n display: none;\n}\n.emotePanel {\n background: #c6c6c6;\n border: 3px solid;\n border-color: #fff #555 #555 #fff;\n padding: 10px 12px;\n width: min(560px, calc(100vw - 32px));\n max-height: calc(100% - 24px);\n overflow-y: auto;\n}\n.emotePanel h3 {\n color: #3a3a3a;\n font-size: 14px;\n margin: 2px 0 6px;\n}\n.emoteGrid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));\n gap: 4px;\n margin-bottom: 8px;\n}\n.emoteBtn {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 4px 6px;\n text-align: left;\n font: inherit;\n font-size: 11px;\n color: #222;\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n cursor: pointer;\n}\n.emoteBtn:hover {\n background: #a8a8c8;\n}\n.emoteIcon {\n font-size: 20px;\n line-height: 1;\n}\n.emoteHint {\n color: #555;\n font-size: 11px;\n}\n\n/* ---------- Задания: трекер, стрелка, всплывашка, книга (ui/quests) ---------- */\n#questTracker {\n position: fixed;\n left: 10px;\n top: calc(var(--idos-safe-top, 0px) + 80px);\n width: 270px;\n color: #fff;\n font-size: 12px;\n line-height: 1.35;\n pointer-events: none;\n z-index: 6;\n text-shadow: 1px 1px 0 #000;\n}\n.qTip {\n background: rgba(60, 48, 10, 0.85);\n border-left: 3px solid #f0d060;\n padding: 5px 8px;\n margin-bottom: 6px;\n}\n.qCard {\n background: rgba(16, 16, 24, 0.72);\n border-left: 3px solid #f0d060;\n padding: 6px 8px;\n margin-bottom: 6px;\n}\n.qHead {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-bottom: 3px;\n}\n.qHead b {\n color: #f0d060;\n}\n.qIcon {\n width: 16px;\n height: 16px;\n flex: none;\n image-rendering: pixelated;\n}\n.qIcon.big {\n width: 32px;\n height: 32px;\n}\n.qTask {\n opacity: 0.95;\n}\n.qTask.ok {\n color: #9ae07a;\n}\n.qHint {\n margin: 4px 0 0;\n color: #e8e0c0;\n}\n.qNote {\n margin-top: 4px;\n color: #9ae07a;\n}\n.qFoot {\n font-size: 11px;\n opacity: 0.6;\n}\n.qRecipe {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-top: 5px;\n}\n.qGrid {\n display: grid;\n gap: 2px;\n}\n.qCell {\n position: relative;\n width: 22px;\n height: 22px;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(139, 139, 139, 0.55);\n border: 1px solid #373737;\n}\n.qCellIcon {\n width: 18px;\n height: 18px;\n image-rendering: pixelated;\n}\n.qOut b {\n position: absolute;\n right: 1px;\n bottom: -2px;\n font-size: 10px;\n}\n.qArrowR {\n font-size: 14px;\n}\n/* «Теперь можно сделать» (ui/RecipeToast) */\n#recipeToast {\n position: fixed;\n right: 12px;\n top: 45%;\n min-width: 190px;\n max-width: 260px;\n padding: 8px 12px;\n background: rgba(20, 20, 30, 0.88);\n border: 2px solid #3f7f3a;\n border-radius: 8px;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 6;\n}\n#recipeToast .rtHead {\n color: #8fe07f;\n font-weight: bold;\n margin-bottom: 4px;\n}\n#recipeToast .rtItem {\n display: flex;\n align-items: center;\n gap: 4px;\n margin: 2px 0;\n}\n#recipeToast .rtIcon {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n}\n#recipeToast .rtFoot {\n margin-top: 4px;\n color: #aab;\n font-size: 11px;\n}\n#questToast {\n position: fixed;\n left: 50%;\n top: calc(var(--idos-safe-top, 0px) + 72px);\n transform: translateX(-50%);\n min-width: 240px;\n padding: 8px 14px;\n text-align: center;\n background: rgba(20, 20, 30, 0.9);\n border: 2px solid #7a6a3a;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 31;\n}\n.qtHead {\n color: #f0d060;\n font-weight: bold;\n}\n.qtRewards {\n margin-top: 4px;\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n font-size: 12px;\n}\n.qtRewards span,\n.qbRewards span {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n}\n#questBook {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.45);\n z-index: 30;\n}\n.qbPanel {\n width: min(920px, 96vw);\n max-height: 88vh;\n display: flex;\n flex-direction: column;\n background: rgba(16, 16, 24, 0.96);\n border: 2px solid #555;\n color: #ddd;\n font-size: 13px;\n padding: 10px 12px;\n box-sizing: border-box;\n}\n.qbTop {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 6px;\n}\n.qbTop h3 {\n flex: 1;\n margin: 0;\n color: #f0d060;\n}\n.qbBtn {\n font: inherit;\n font-size: 12px;\n padding: 4px 10px;\n color: #fff;\n background: #3a3a4a;\n border: 1px solid #666;\n cursor: pointer;\n}\n.qbBtn:hover {\n background: #4a4a60;\n}\n.qbNote {\n color: #c8b870;\n font-size: 12px;\n margin: 2px 0 6px;\n}\n.qbBody {\n display: grid;\n grid-template-columns: 190px 230px 1fr;\n gap: 10px;\n min-height: 0;\n flex: 1;\n overflow: hidden;\n}\n.qbChapters,\n.qbQuests,\n.qbDetail {\n overflow-y: auto;\n min-height: 0;\n max-height: calc(88vh - 70px);\n}\n.qbChapter,\n.qbQuest {\n width: 100%;\n display: flex;\n align-items: center;\n gap: 6px;\n margin-bottom: 4px;\n padding: 5px 6px;\n font: inherit;\n font-size: 12px;\n text-align: left;\n color: #ddd;\n background: #22222e;\n border: 1px solid #333;\n cursor: pointer;\n}\n.qbChapter.on,\n.qbQuest.on {\n background: #34344a;\n border-color: #f0d060;\n}\n.qbName {\n flex: 1;\n}\n.qbCount {\n opacity: 0.7;\n}\n.qbQuest.locked {\n opacity: 0.5;\n}\n.qbQuest.done .qbMark {\n color: #9ae07a;\n}\n.qbQuest.tracked .qbName::after {\n content: \" 📌\";\n}\n.qbChapterDesc {\n font-size: 12px;\n opacity: 0.75;\n margin-bottom: 6px;\n}\n.qbDHead {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.qbDHead h4 {\n margin: 0;\n color: #f0d060;\n font-size: 15px;\n}\n.qbDesc {\n margin: 6px 0;\n line-height: 1.4;\n}\n.qbLocked {\n color: #c8b870;\n margin-bottom: 6px;\n}\n.qbSection {\n margin-top: 8px;\n}\n.qbSTitle {\n font-weight: bold;\n color: #aaa;\n font-size: 11px;\n text-transform: uppercase;\n margin-bottom: 3px;\n}\n.qbDetail .qHint {\n line-height: 1.4;\n}\n.qbRewards {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n}\n.qbActions {\n margin-top: 12px;\n display: flex;\n gap: 8px;\n}\n@media (max-width: 720px) {\n .qbBody {\n grid-template-columns: 1fr;\n grid-auto-rows: max-content;\n overflow-y: auto;\n }\n .qbChapters,\n .qbQuests,\n .qbDetail {\n max-height: none;\n min-height: auto;\n overflow: visible;\n }\n .qbChapters {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n }\n .qbChapter {\n width: auto;\n margin: 0;\n }\n .qbTop .qbBtn {\n padding: 4px 6px;\n }\n #questTracker {\n width: 210px;\n font-size: 11px;\n }\n}\n.tPause {\n left: 10px;\n top: calc(var(--idos-safe-top, 0px) + 10px);\n width: 40px;\n height: 40px;\n font-size: 16px;\n}\n.tQuests {\n right: 292px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n/* трекер: следующий шаг по дереву справочника */\n.qNext {\n margin-top: 4px;\n color: #ffd75e;\n font-size: 12px;\n}\n.qbRecipeName {\n margin-top: 6px;\n font-size: 12px;\n color: #aaa;\n}\n/* рецепт в книге заданий — кнопка в справочник */\n.qbRecipe {\n display: inline-block;\n cursor: pointer;\n border-radius: 6px;\n padding: 0 6px 4px;\n}\n.qbRecipe:hover {\n background: rgba(255, 255, 255, 0.07);\n}\n.qbRecipe:hover .qbRecipeName {\n color: #ffd75e;\n}\n.qbRecipe [data-item]:hover {\n outline: 1px solid #ffd75e;\n}\n\n/* ---------- Карта: компас, миникарта, метки в мире, большая карта (ui/map) ---------- */\n#navCompass {\n position: fixed;\n left: 50%;\n top: calc(var(--idos-safe-top, 0px) + 6px);\n transform: translateX(-50%);\n width: min(440px, 52vw);\n z-index: 6;\n pointer-events: none;\n text-align: center;\n}\n#navCompass canvas {\n display: block;\n width: 100%;\n height: 34px;\n}\n.ncInfo {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n margin-top: 2px;\n padding: 2px 10px;\n background: rgba(0, 0, 0, 0.55);\n border-radius: 10px;\n color: #fff;\n font: bold 12px sans-serif;\n text-shadow: 1px 1px 0 #000;\n white-space: nowrap;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.ncDot {\n font-size: 13px;\n}\n.ncInfo .qIcon {\n width: 16px;\n height: 16px;\n}\n#minimap {\n position: fixed;\n right: 10px;\n top: calc(var(--idos-safe-top, 0px) + 44px);\n width: 150px;\n /* над слоем «смотреть» тач-управления (.touchUi, z 6): касание открывает карту */\n z-index: 7;\n cursor: pointer;\n pointer-events: auto;\n}\n#minimap canvas {\n display: block;\n width: 150px;\n height: 150px;\n border-radius: 50%;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);\n}\n.mmCoords {\n margin-top: 3px;\n text-align: center;\n color: #fff;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n}\nbody.touch #minimap {\n top: calc(var(--idos-safe-top, 0px) + 104px);\n width: 112px;\n}\nbody.touch #minimap canvas {\n width: 112px;\n height: 112px;\n}\nbody.touch #effects {\n top: calc(var(--idos-safe-top, 0px) + 248px);\n}\nbody.touch #navCompass {\n width: min(360px, 40vw);\n}\n#navWorld {\n position: fixed;\n inset: 0;\n pointer-events: none;\n /* над трекером заданий (z 6): стрелка у левого края не прячется под ним */\n z-index: 7;\n overflow: hidden;\n}\n.navMark {\n position: absolute;\n left: 0;\n top: 0;\n width: 0;\n height: 0;\n will-change: transform;\n}\n.nmIcon {\n position: absolute;\n left: -14px;\n top: -14px;\n width: 28px;\n height: 28px;\n box-sizing: border-box;\n border: 3px solid currentColor;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.6);\n display: flex;\n align-items: center;\n justify-content: center;\n font: bold 15px sans-serif;\n box-shadow: 0 0 10px rgba(0, 0, 0, 0.6);\n}\n.navMark.guided .nmIcon {\n left: -18px;\n top: -18px;\n width: 36px;\n height: 36px;\n font-size: 19px;\n animation: nmPulse 1.4s ease-in-out infinite;\n}\n@keyframes nmPulse {\n 50% {\n box-shadow: 0 0 0 6px rgba(240, 208, 96, 0.25);\n }\n}\n.nmItem {\n width: 18px;\n height: 18px;\n image-rendering: pixelated;\n}\n.navMark.guided .nmItem {\n width: 22px;\n height: 22px;\n}\n.nmText {\n position: absolute;\n left: 0;\n top: 22px;\n transform: translateX(-50%);\n white-space: nowrap;\n padding: 1px 6px;\n border-radius: 8px;\n background: rgba(0, 0, 0, 0.55);\n color: #fff;\n font: bold 12px sans-serif;\n text-shadow: 1px 1px 0 #000;\n}\n.nmArrow {\n /* треугольник остриём вверх; NavHud поворачивает его к цели и выносит за кружок */\n position: absolute;\n left: -15px;\n top: -13px;\n width: 0;\n height: 0;\n border-left: 15px solid transparent;\n border-right: 15px solid transparent;\n border-bottom: 26px solid currentColor;\n filter: drop-shadow(0 0 1.5px #000) drop-shadow(0 0 1.5px #000);\n}\n#worldMap {\n position: fixed;\n inset: 0;\n z-index: 30;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.55);\n}\n.wmPanel {\n width: min(1100px, calc(100vw - 24px));\n height: min(760px, calc(100vh - 24px));\n display: flex;\n flex-direction: column;\n background: #2b2b33;\n border: 3px solid #7a6a3a;\n border-radius: 6px;\n color: #fff;\n box-shadow: 0 6px 30px rgba(0, 0, 0, 0.6);\n}\n.wmTop {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 8px 10px;\n flex-wrap: wrap;\n}\n.wmTop h3 {\n margin: 0 auto 0 0;\n color: #f0d060;\n font-size: 18px;\n}\n.wmBtn {\n padding: 6px 10px;\n border: 1px solid #555;\n border-radius: 4px;\n background: #444452;\n color: #fff;\n font: bold 13px sans-serif;\n cursor: pointer;\n}\n.wmBtn:hover {\n background: #565668;\n}\n.wmBody {\n flex: 1;\n min-height: 0;\n display: flex;\n gap: 8px;\n padding: 0 10px;\n}\n.wmView {\n position: relative;\n flex: 1;\n min-width: 0;\n border: 2px solid #111;\n border-radius: 4px;\n overflow: hidden;\n}\n.wmView canvas {\n display: block;\n width: 100%;\n height: 100%;\n cursor: crosshair;\n touch-action: none;\n}\n.wmSide {\n width: 220px;\n overflow-y: auto;\n font-size: 13px;\n}\n.wmSide h4 {\n margin: 4px 0 6px;\n color: #f0d060;\n}\n.wmNote {\n color: #bbb;\n font-size: 12px;\n margin-bottom: 8px;\n}\n.wmItem {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 5px 6px;\n border-radius: 4px;\n cursor: pointer;\n}\n.wmItem:hover {\n background: rgba(255, 255, 255, 0.08);\n}\n.wmItem.on {\n background: rgba(240, 208, 96, 0.18);\n}\n.wmDot {\n width: 16px;\n text-align: center;\n font-weight: bold;\n}\n.wmItemName {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.wmDist {\n color: #aaa;\n font-size: 11px;\n}\n.wmHelp {\n padding: 6px 10px 8px;\n color: #aaa;\n font-size: 12px;\n}\n.wmPop {\n position: absolute;\n width: 230px;\n box-sizing: border-box;\n padding: 8px;\n display: grid;\n gap: 6px;\n background: rgba(30, 30, 38, 0.96);\n border: 2px solid #7a6a3a;\n border-radius: 6px;\n}\n.wmPinTitle {\n font-weight: bold;\n color: #f0d060;\n}\n.wmName {\n width: 100%;\n box-sizing: border-box;\n padding: 5px 6px;\n border: 1px solid #555;\n border-radius: 4px;\n background: #1b1b22;\n color: #fff;\n font-size: 13px;\n}\n.wmColors {\n display: flex;\n gap: 5px;\n}\n.wmColor {\n width: 22px;\n height: 22px;\n border: 2px solid #222;\n border-radius: 50%;\n cursor: pointer;\n padding: 0;\n}\n.wmColor.on {\n border-color: #fff;\n box-shadow: 0 0 0 2px #000;\n}\n.wmRow {\n display: flex;\n flex-wrap: wrap;\n gap: 5px;\n}\n@media (max-width: 700px) {\n /* слева сверху — круглая кнопка «в лобби» базы */\n .wmTop {\n padding-left: 54px;\n }\n .wmBody {\n flex-direction: column;\n }\n .wmSide {\n width: auto;\n max-height: 120px;\n }\n}\n\n/* подсказка предмета при наведении на слот (ui/itemTooltip) — как в Minecraft */\n#itemTip {\n position: fixed;\n z-index: 60;\n pointer-events: none;\n max-width: 280px;\n padding: 5px 8px;\n background: rgba(16, 0, 16, 0.94);\n border: 2px solid #2a0a5e;\n outline: 1px solid #000;\n border-radius: 3px;\n color: #fff;\n font: 14px/1.35 sans-serif;\n text-shadow: 1px 1px 0 #3f3f3f;\n white-space: nowrap;\n}\n#itemTip .itName {\n font-weight: bold;\n}\n#itemTip .itLine {\n color: #a8a8ff;\n font-size: 12px;\n}\n\n/* ---------- Справочник (ui/catalog) ---------- */\n#catalog {\n position: fixed;\n inset: 0;\n z-index: 35;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.55);\n}\n.ctPanel {\n width: min(1180px, calc(100vw - 24px));\n height: min(800px, calc(100vh - 24px));\n display: flex;\n flex-direction: column;\n background: #2b2b33;\n border: 3px solid #7a6a3a;\n border-radius: 6px;\n color: #fff;\n box-shadow: 0 6px 30px rgba(0, 0, 0, 0.6);\n}\n.ctTop {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 10px;\n}\n.ctTop h3 {\n margin: 0;\n color: #f0d060;\n font-size: 18px;\n}\n.ctSearch {\n flex: 1;\n min-width: 0;\n padding: 6px 8px;\n border: 1px solid #555;\n border-radius: 4px;\n background: #1b1b22;\n color: #fff;\n font-size: 14px;\n}\n.ctBtn,\n.ctChip,\n.ctOpenBtn {\n padding: 5px 10px;\n border: 1px solid #555;\n border-radius: 4px;\n background: #444452;\n color: #fff;\n font: bold 13px sans-serif;\n cursor: pointer;\n}\n.ctBtn:hover,\n.ctChip:hover,\n.ctOpenBtn:hover {\n background: #565668;\n}\n.ctBtn.on {\n background: #7a6a3a;\n border-color: #f0d060;\n}\n.ctOpenBtn {\n float: right;\n margin-top: -3px;\n font-size: 12px;\n}\n.ctBody {\n flex: 1;\n min-height: 0;\n display: flex;\n gap: 10px;\n padding: 0 10px 10px;\n}\n.ctLeft {\n width: 40%;\n min-width: 260px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n min-height: 0;\n}\n.ctCats {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n}\n.ctGrid {\n flex: 1;\n min-height: 0;\n overflow-y: auto;\n display: grid;\n grid-template-columns: repeat(auto-fill, 40px);\n gap: 4px;\n align-content: start;\n padding: 4px;\n background: #1d1d24;\n border-radius: 4px;\n}\n.ctSlot {\n width: 40px;\n height: 40px;\n display: flex;\n align-items: center;\n justify-content: center;\n background: #3a3a46;\n border: 2px solid transparent;\n border-radius: 3px;\n cursor: pointer;\n}\n.ctSlot:hover {\n border-color: #888;\n}\n.ctSlot.on {\n border-color: #f0d060;\n}\n.ctIcon {\n width: 30px;\n height: 30px;\n image-rendering: pixelated;\n}\n.ctIconS {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n vertical-align: middle;\n margin-right: 4px;\n}\n.ctBig {\n width: 56px;\n height: 56px;\n image-rendering: pixelated;\n}\n.ctDetail {\n flex: 1;\n min-width: 0;\n overflow-y: auto;\n padding: 4px 6px;\n font-size: 14px;\n}\n.ctHead {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 6px;\n}\n.ctHead h4 {\n margin: 0;\n font-size: 20px;\n color: #f0d060;\n}\n.ctInfo,\n.ctDim {\n color: #aab;\n font-size: 12px;\n}\n.ctSection {\n margin: 14px 0 6px;\n font-size: 13px;\n text-transform: uppercase;\n letter-spacing: 1px;\n color: #f0d060;\n}\n.ctSource {\n padding: 6px 8px;\n margin-bottom: 6px;\n background: rgba(255, 255, 255, 0.05);\n border-radius: 4px;\n}\n.ctSrcLabel {\n font-size: 12px;\n color: #ccd;\n margin-bottom: 4px;\n}\n.ctLine {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 4px;\n}\n.ctGridR {\n display: grid;\n gap: 2px;\n}\n.ctCell {\n position: relative;\n width: 34px;\n height: 34px;\n display: flex;\n align-items: center;\n justify-content: center;\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n cursor: pointer;\n}\n.ctCell .ctIcon {\n width: 26px;\n height: 26px;\n}\n.ctCell b {\n position: absolute;\n right: 1px;\n bottom: -1px;\n font-size: 12px;\n color: #fff;\n text-shadow: 1px 1px 0 #000;\n}\n.ctTree,\n.ctTree ul {\n list-style: none;\n margin: 0;\n padding-left: 18px;\n border-left: 1px dashed #556;\n}\n.ctTree {\n padding-left: 4px;\n border-left: none;\n}\n.ctTreeRow {\n display: flex;\n align-items: center;\n padding: 2px 4px;\n border-radius: 3px;\n cursor: pointer;\n}\n.ctTreeRow:hover {\n background: rgba(255, 255, 255, 0.08);\n}\n.ctRaw,\n.ctChips {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n align-items: center;\n margin-top: 8px;\n}\n.ctRaw b {\n margin-right: 4px;\n}\n.ctNote {\n color: #ccd;\n padding: 4px 0;\n}\n/* план: что уже есть и что делать дальше */\n.ctPlan {\n margin: 6px 0 2px;\n}\n.ctNext {\n background: rgba(255, 215, 94, 0.1);\n border-left: 3px solid #ffd75e;\n border-radius: 4px;\n padding: 6px 8px;\n margin: 4px 0;\n cursor: pointer;\n color: #eee;\n}\n.ctNext b {\n color: #ffd75e;\n}\n.ctMissing > b {\n color: #ff8a7a;\n}\n.ctHave {\n color: #aab;\n font-size: 11px;\n}\n.ctHave.ok {\n color: #7ddc7a;\n}\n.ctChip .ctHave {\n margin-left: 4px;\n font-size: 12px;\n}\n.ctChip .ctHave.miss {\n color: #ff8a7a;\n}\n.ctFill {\n margin-top: 6px;\n background: #3f7f3a;\n border-color: #6fbf5f;\n}\n.ctFill:hover {\n background: #4d9446;\n}\n@media (max-width: 760px) {\n .ctBody {\n flex-direction: column;\n }\n .ctLeft {\n width: auto;\n min-width: 0;\n height: 38%;\n }\n}\n.ctTreeRow .ctDim {\n margin-left: 6px;\n}\n"
|
|
1510
|
+
"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/* подсказка под прицелом (ui/AimHint) */\n#aimHint {\n position: fixed;\n left: 50%;\n top: calc(50% + 22px);\n transform: translateX(-50%);\n pointer-events: none;\n z-index: 5;\n text-align: center;\n font-size: 13px;\n text-shadow: 1px 1px 0 #000;\n max-width: min(92vw, 520px);\n}\n#aimHint div {\n background: rgba(0, 0, 0, 0.45);\n border-radius: 4px;\n padding: 2px 8px;\n margin-top: 3px;\n display: inline-block;\n}\n#aimHint .ahUse {\n color: #fff;\n}\n#aimHint .ahWarn {\n color: #ffb36b;\n}\n#aimHint div + div {\n display: table;\n margin: 3px auto 0;\n}\n\n/* --idos-safe-top: высота общего HUD host'а (0 без него) — служебные строки не уходят под HUD. */\n#fps {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 8px);\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n/* под круглой кнопкой «в лобби» базы (40px в левом верхнем углу): строка не уходит под неё */\n#debug {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 56px);\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 26px);\n right: 10px;\n color: #ffe9a0;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* --idos-safe-bottom: высота нижней навигации host'а (0, когда режим один) — хотбар над ней. */\n#hotbar {\n position: fixed;\n left: 50%;\n bottom: calc(var(--idos-safe-bottom, 0px) + 8px);\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n.slot .dur {\n position: absolute;\n left: 4px;\n bottom: 3px;\n height: 3px;\n display: none;\n pointer-events: none;\n box-shadow: 0 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#xpBar {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 60px);\n width: min(400px, 90vw);\n height: 6px;\n background: rgba(20, 20, 20, 0.8);\n border: 1px solid #000;\n z-index: 5;\n pointer-events: none;\n}\n.xpFill {\n height: 100%;\n width: 0;\n background: linear-gradient(#b8ff5a, #5ec21e);\n}\n#xpLevel {\n position: fixed;\n left: 50%;\n transform: translateX(-50%);\n bottom: calc(var(--idos-safe-bottom, 0px) + 64px);\n color: #8aff3a;\n font: bold 14px monospace;\n text-shadow:\n 1px 1px 0 #000,\n -1px -1px 0 #000;\n z-index: 6;\n pointer-events: none;\n}\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 72px);\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n/* пузырьки воздуха над едой — под водой */\n#bubbles {\n position: fixed;\n left: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 15px;\n color: #7ad0ff;\n}\n.bubble.empty {\n opacity: 0;\n}\n/* окорочок — эмодзи: цвет на него не действует, пустые и половинки гасим фильтром */\n.drumstick.half {\n filter: grayscale(0.7) brightness(0.75);\n opacity: 0.7;\n}\n.drumstick.empty {\n filter: grayscale(1) brightness(0.3);\n opacity: 0.6;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- смерть, горение ---------- */\n#deathScreen {\n position: fixed;\n inset: 0;\n z-index: 35;\n background: rgba(120, 0, 0, 0.55);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.deathPanel {\n text-align: center;\n color: #fff;\n text-shadow: 2px 2px 0 #000;\n}\n.deathPanel h1 {\n font-size: 46px;\n margin-bottom: 8px;\n}\n.deathCause {\n font-size: 16px;\n margin-bottom: 20px;\n}\n.deathBtn {\n cursor: pointer;\n font-size: 16px;\n padding: 10px 28px;\n border-radius: 4px;\n border: 2px solid #aaa;\n background: #3a3f4d;\n color: #eee;\n}\n.deathBtn:hover {\n background: #4a5163;\n}\n#fireOverlay {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n opacity: 0;\n background: radial-gradient(\n ellipse at 50% 120%,\n rgba(255, 140, 20, 0.75),\n transparent 60%\n );\n transition: opacity 0.2s;\n}\n#fireOverlay.show {\n opacity: 1;\n animation: burn 0.35s infinite alternate;\n}\n@keyframes burn {\n from {\n filter: brightness(0.85);\n }\n to {\n filter: brightness(1.2);\n }\n}\n\n/* ---------- босс и титры ---------- */\n#bossBar {\n position: fixed;\n top: 14px;\n left: 50%;\n transform: translateX(-50%);\n width: min(380px, calc(100% - 32px));\n z-index: 6;\n text-align: center;\n color: #fff;\n text-shadow: 1px 1px 0 #000;\n font-size: 14px;\n pointer-events: none;\n}\n.bossTrack {\n margin-top: 4px;\n height: 8px;\n background: #3a1030;\n border: 1px solid #000;\n}\n.bossFill {\n height: 100%;\n width: 100%;\n background: #c040ff;\n}\n#creditsScreen {\n position: fixed;\n inset: 0;\n z-index: 36;\n background: rgba(20, 10, 40, 0.85);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.credits {\n font-size: 16px;\n line-height: 1.6;\n margin-bottom: 20px;\n}\n\n/* ---------- чат / консоль ---------- */\n#chat {\n position: fixed;\n left: 10px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n width: min(520px, calc(100% - 20px));\n z-index: 8;\n pointer-events: none;\n font: 13px monospace;\n color: #fff;\n}\n.chatLog {\n display: flex;\n flex-direction: column;\n gap: 2px;\n max-height: 40vh;\n overflow: hidden;\n}\n.chatLine {\n background: rgba(0, 0, 0, 0.45);\n padding: 2px 6px;\n text-shadow: 1px 1px 0 #000;\n animation: chatFade 10s forwards;\n}\n#chat.open .chatLine {\n animation: none;\n opacity: 1;\n}\n@keyframes chatFade {\n 0%,\n 80% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n.chatInput {\n pointer-events: auto;\n width: 100%;\n margin-top: 4px;\n padding: 6px 8px;\n font: 14px monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.7);\n border: 1px solid #888;\n outline: none;\n}\n\n/* ---------- сенсорное управление ---------- */\n.touchUi {\n position: fixed;\n inset: 0;\n z-index: 6; /* над канвасом и HUD, под инвентарём, чатом и оверлеем */\n pointer-events: none;\n touch-action: none;\n}\n.touchUi > * {\n pointer-events: auto;\n touch-action: none;\n user-select: none;\n -webkit-user-select: none;\n}\n.tLook {\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 40%;\n}\n.tStick {\n position: absolute;\n left: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 84px);\n width: 132px;\n height: 132px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.12);\n border: 2px solid rgba(255, 255, 255, 0.35);\n}\n.tKnob {\n position: absolute;\n left: 50%;\n top: 50%;\n width: 56px;\n height: 56px;\n margin: -28px;\n border-radius: 50%;\n background: rgba(255, 255, 255, 0.42);\n pointer-events: none;\n}\n.tBtn {\n position: absolute;\n width: 62px;\n height: 62px;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.35);\n border: 2px solid rgba(255, 255, 255, 0.45);\n color: #fff;\n font:\n bold 22px system-ui,\n sans-serif;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.tBtn.on {\n background: rgba(154, 223, 106, 0.5);\n}\n.tJump {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n}\n.tSneak {\n right: 24px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 172px);\n}\n.tAttack {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 134px);\n}\n.tUse {\n right: 100px;\n bottom: calc(var(--idos-safe-bottom, 0px) + 210px);\n}\n.tInv {\n right: 12px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tDrop {\n right: 124px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tChat {\n right: 68px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tEmote {\n right: 180px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n.tView {\n right: 236px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 16px;\n}\n/* на телефоне хотбар над зоной обзора: иначе тап по слоту крутил бы камеру */\nbody.touch #hotbar {\n z-index: 7;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow,\n.invTop {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid,\n.craftGrid {\n display: grid;\n gap: 3px;\n}\n.armorCol {\n display: grid;\n gap: 3px;\n margin-right: 18px;\n}\n/* кукла персонажа рядом с бронёй */\n.dollBox {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n margin-right: 14px;\n}\n.dollCanvas {\n width: 96px;\n height: 150px;\n background: #2b2b2b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n cursor: grab;\n}\n\n.chestGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n.furnaceCol {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: 4px;\n}\n.flame {\n width: 20px;\n height: 20px;\n background: #5a5a5a;\n display: flex;\n align-items: flex-end;\n}\n.flameFill {\n width: 100%;\n height: 0;\n background: linear-gradient(#ffe27a, #f06a14);\n}\n.cookArrow {\n width: 56px;\n height: 14px;\n background: #5a5a5a;\n margin: 0 8px;\n}\n.cookFill {\n height: 100%;\n width: 0;\n background: #f5f5f5;\n}\n.invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n.invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n/* наковальня: цена в уровнях справа от результата */\n.anvilCost {\n min-width: 110px;\n font-size: 12px;\n color: #2a6a2a;\n}\n\n/* стол зачарований: предмет и лазурит, три варианта; зачарованное — с фиолетовым отблеском */\n.enchCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.enchOffers {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 260px;\n}\n.enchOffer {\n padding: 6px 8px;\n font-size: 12px;\n cursor: pointer;\n color: #f0e8ff;\n background: #4a3a6a;\n border: 2px solid #2a1a40;\n}\n.enchOffer:hover {\n background: #5a4a8a;\n}\n/* ткацкий станок: картинка знамени слева от названия узора */\n.loomOffers .enchOffer {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.loomPic {\n width: 24px;\n height: 24px;\n image-rendering: pixelated;\n border: 1px solid #2a1a40;\n}\n/* карта сокровищ: крестик над кладом */\n.mapTreasure {\n position: absolute;\n transform: translate(-50%, -50%);\n color: #c02020;\n font-weight: bold;\n font-size: 14px;\n text-shadow: 0 0 2px #fff;\n pointer-events: none;\n}\n/* достижения: всплывашка справа сверху и список по L */\n#achToast {\n position: absolute;\n top: 12px;\n right: 12px;\n min-width: 220px;\n padding: 8px 12px;\n background: rgba(20, 20, 30, 0.88);\n border: 2px solid #7a6a3a;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 30;\n}\n#achToast .achHead {\n color: #f0d060;\n font-weight: bold;\n margin-bottom: 2px;\n}\n#achList {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: min(560px, 92vw);\n max-height: 80vh;\n overflow-y: auto;\n padding: 10px 14px;\n background: rgba(16, 16, 24, 0.92);\n border: 2px solid #555;\n color: #ccc;\n font-size: 13px;\n z-index: 29;\n}\n#achList h3 {\n margin: 0 0 8px;\n color: #fff;\n}\n#achList .ach {\n display: flex;\n justify-content: space-between;\n gap: 12px;\n padding: 3px 0;\n border-bottom: 1px solid #2a2a36;\n}\n#achList .ach.done b {\n color: #9ae07a;\n}\n#achList .ach span {\n opacity: 0.7;\n text-align: right;\n}\n.enchOffer.on {\n outline: 2px solid #7ee8e0;\n}\n.beaconInfo {\n font-size: 12px;\n opacity: 0.85;\n margin: 2px 0 4px;\n}\n/* Тление: сердца чернеют */\n#hearts.decay .heart {\n filter: grayscale(1) brightness(0.4);\n}\n.enchOffer.off {\n cursor: default;\n color: #9a90a8;\n background: #5a5560;\n}\n.slot.ench img {\n filter: drop-shadow(0 0 3px #b060ff) saturate(1.3);\n}\n\n/* торговля с жителем: строки сделок с иконками, в тонах дерева */\n.tradeOffers {\n max-height: 260px;\n overflow-y: auto;\n}\n.tradeOffer {\n display: flex;\n align-items: center;\n gap: 4px;\n background: #5a4630;\n border-color: #3a2a18;\n color: #fff4e0;\n}\n.tradeOffer:hover {\n background: #6e583c;\n}\n.tradeOffer img {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n}\n.tradeLeft {\n margin-left: auto;\n padding-left: 8px;\n opacity: 0.75;\n}\n\n/* компас и часы в руке — у правого края хотбара */\n#gadget {\n position: absolute;\n bottom: 58px;\n left: calc(50% + 200px);\n min-width: 40px;\n padding: 4px 8px;\n text-align: center;\n font: 16px/1.2 monospace;\n color: #fff;\n background: rgba(0, 0, 0, 0.45);\n border-radius: 6px;\n pointer-events: none;\n}\n#gadget .needle {\n display: inline-block;\n color: #e03030;\n font-size: 22px;\n}\n\n/* карта в руке — справа над хотбаром */\n#mapView {\n position: absolute;\n right: 16px;\n bottom: 70px;\n width: min(256px, 40vw);\n aspect-ratio: 1;\n max-width: 100%;\n border: 4px solid #8a6a40;\n background: #d6be96;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);\n pointer-events: none;\n}\n#mapView canvas {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#mapView .mapMarker {\n position: absolute;\n color: #fff;\n font-size: 14px;\n text-shadow:\n 0 0 2px #000,\n 0 0 2px #000;\n}\n\n/* варочная стойка: ингредиент и топливо столбиком, три бутылки в ряд */\n.brewCol {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n.bottleRow {\n display: flex;\n gap: 6px;\n}\n/* эффекты зелий — под миникартой (ui/map) */\n#effects {\n position: fixed;\n top: calc(var(--idos-safe-top, 0px) + 226px);\n right: 12px;\n z-index: 5;\n color: #fff;\n font-size: 13px;\n text-align: right;\n text-shadow: 1px 1px 0 #000;\n pointer-events: none;\n white-space: pre;\n}\n\n/* креатив: поиск, палитра всех предметов, корзина */\n.creativeSearch {\n width: 100%;\n box-sizing: border-box;\n margin-bottom: 8px;\n padding: 6px 8px;\n font-size: 14px;\n border: 2px solid #555;\n background: #fff;\n}\n.creativeGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n max-height: calc(44px * 5 + 3px * 4);\n overflow-y: auto;\n margin-bottom: 12px;\n padding-right: 4px;\n}\n.creativeGrid .slot {\n cursor: pointer;\n}\n.trashLabel {\n color: #3a3a3a;\n font-size: 13px;\n}\n.slot.trash::before {\n content: \"🗑\";\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 22px;\n opacity: 0.6;\n pointer-events: none;\n}\n#armorBar {\n position: fixed;\n right: calc(50% + 8px);\n bottom: calc(var(--idos-safe-bottom, 0px) + 96px);\n white-space: nowrap;\n z-index: 5;\n font-size: 16px;\n letter-spacing: 3px;\n text-shadow: 1px 1px 0 #000;\n}\n.armorPip {\n color: #cfd6e0;\n}\n.armorPip.half {\n opacity: 0.55;\n}\n.armorPip.empty {\n color: #3a3a3a;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n/* Пауза и старт — в теме UI kit SDK (переменные --idos-ui-* ставит корень kit'а над игрой); без\n лобби SDK (свой стенд) — запасные цвета той же темы. */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: var(--idos-ui-backdrop, rgba(8, 12, 40, 0.72));\n backdrop-filter: blur(3px);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n font-family: var(--idos-ui-font, \"Trebuchet MS\", system-ui, sans-serif);\n}\n#overlay .panel {\n cursor: default;\n text-align: center;\n color: var(--idos-ui-text, #fff);\n width: min(560px, 100%);\n max-height: 100%;\n overflow: auto;\n padding: 22px 22px 24px;\n box-sizing: border-box;\n background: linear-gradient(\n 180deg,\n var(--idos-ui-panel, #2c3480) 0%,\n var(--idos-ui-panel-deep, #1b2160) 100%\n );\n border: 2px solid var(--idos-ui-panel-edge, #4a56b8);\n border-radius: var(--idos-ui-radius, 18px);\n box-shadow: var(--idos-ui-panel-shadow, 0 12px 40px rgba(0, 0, 0, 0.5));\n}\n#overlay h1 {\n margin: 0 0 4px;\n font-size: 40px;\n font-weight: 800;\n letter-spacing: 1px;\n color: var(--idos-ui-gold, #ffd24a);\n text-shadow: var(--idos-ui-text-outline, 0 2px 0 rgba(0, 0, 0, 0.45));\n}\n#overlayHint {\n margin: 0 0 16px;\n font-size: 15px;\n font-weight: 700;\n color: var(--idos-ui-text-dim, #b9c0ee);\n}\n#overlay .controls {\n text-align: left;\n background: var(--idos-ui-well, rgba(0, 0, 0, 0.3));\n border: 2px solid var(--idos-ui-panel-edge, #4a56b8);\n box-shadow: var(--idos-ui-well-shadow, inset 0 2px 6px rgba(0, 0, 0, 0.35));\n border-radius: calc(var(--idos-ui-radius, 18px) * 0.6);\n padding: 10px 14px;\n font-size: 13px;\n line-height: 1.75;\n margin-bottom: 18px;\n}\n#overlay .controls b {\n color: var(--idos-ui-gold, #ffd24a);\n}\n#overlay .buttons {\n display: flex;\n flex-wrap: wrap;\n gap: 12px;\n justify-content: center;\n}\n/* язык в паузе: подпись и две кнопки-переключателя */\n#overlay .langRow {\n display: flex;\n gap: 8px;\n align-items: center;\n justify-content: center;\n margin: -6px 0 16px;\n font-size: 14px;\n font-weight: 700;\n color: var(--idos-ui-text-dim, #b9c0ee);\n}\n#overlay .langRow .langBtn {\n min-height: 32px;\n padding: 4px 14px;\n font-size: 14px;\n opacity: 0.6;\n}\n#overlay .langRow .langBtn.on {\n opacity: 1;\n outline: 2px solid var(--idos-ui-gold, #ffd24a);\n}\n/* кнопки — как Button UI kit: градиент тона, «ступенька» снизу, текст с обводкой */\n#overlay button {\n cursor: pointer;\n min-height: 44px;\n padding: 10px 22px;\n border: none;\n border-radius: var(--idos-ui-button-radius, 14px);\n font-family: inherit;\n font-size: 16px;\n font-weight: 800;\n color: var(--idos-ui-on-blue, #fff);\n text-shadow: var(--idos-ui-on-blue-outline, 0 1px 0 rgba(0, 0, 0, 0.4));\n background: linear-gradient(\n 180deg,\n var(--idos-ui-blue, #4aa8ff) 0%,\n var(--idos-ui-blue-deep, #2a6fd6) 100%\n );\n box-shadow:\n 0 4px 0 var(--idos-ui-edge, rgba(0, 0, 0, 0.35)),\n inset 0 2px 0 rgba(255, 255, 255, 0.35);\n}\n#overlay button.accent {\n min-width: 160px;\n color: var(--idos-ui-on-green, #fff);\n text-shadow: var(--idos-ui-on-green-outline, 0 1px 0 rgba(0, 0, 0, 0.4));\n background: linear-gradient(\n 180deg,\n var(--idos-ui-green, #5fd35b) 0%,\n var(--idos-ui-green-deep, #2f9a3a) 100%\n );\n}\n/* без kit (свой стенд) — нажатие как у .idos-press */\n#overlay button:hover {\n filter: brightness(1.07);\n}\n#overlay button:active {\n transform: translateY(3px) scale(0.96);\n}\nbody.touch #overlay h1 {\n font-size: 32px;\n}\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\n.wsButtons {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n margin-top: 6px;\n}\n\n.wsStatus {\n min-height: 20px;\n color: #b8e59a;\n}\n.wsStatus.error {\n color: #ff8f80;\n}\n\n.wsList {\n width: 100%;\n display: flex;\n flex-direction: column;\n gap: 6px;\n color: #aab3c8;\n}\n.wsItem {\n display: flex;\n gap: 8px;\n align-items: center;\n padding: 8px 10px;\n background: #2c3348;\n border-radius: 6px;\n}\n.wsItem > div {\n flex: 1;\n min-width: 0;\n}\n.wsItem.current {\n outline: 1px solid #9adf6a;\n}\n.wsItemName {\n color: #eee;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.wsItemMeta {\n font-size: 12px;\n}\n\n/* Книга на кафедре (ui/BookScreen): страница пергамента поверх игры */\n.bookScreen {\n position: absolute;\n left: 50%;\n top: 50%;\n transform: translate(-50%, -50%);\n width: 320px;\n padding: 14px;\n background: #e8dcb8;\n color: #2a1c0e;\n border: 3px solid #8a6a3a;\n font-size: 13px;\n z-index: 40;\n}\n.bookScreen.hidden {\n display: none;\n}\n.bookTitle {\n font-weight: bold;\n margin-bottom: 8px;\n text-align: center;\n}\n.bookPage {\n min-height: 180px;\n line-height: 15px;\n white-space: pre-wrap;\n}\n.bookPage.hidden,\n.bookNav.hidden {\n display: none;\n}\n.bookEdit {\n width: 100%;\n height: 180px;\n box-sizing: border-box;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n resize: none;\n}\n.bookEdit.hidden {\n display: none;\n}\n.bookNav {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-top: 8px;\n}\n.bookBtn {\n padding: 4px 10px;\n cursor: pointer;\n background: #8a6a3a;\n color: #f4ecd0;\n border: 1px solid #5a4020;\n}\n.bookBtn:hover {\n background: #a07a44;\n}\n.bookCount {\n flex: 1;\n text-align: center;\n}\n.bookTitleInput {\n flex: 1;\n background: #f4ecd0;\n color: #2a1c0e;\n border: 1px solid #8a6a3a;\n font: 13px monospace;\n padding: 3px 5px;\n}\n\n/* ---------- сетевая игра (ui/NetUI) ---------- */\n#netScreen {\n position: fixed;\n inset: 0;\n z-index: 40;\n background: rgba(4, 20, 50, 0.72);\n display: flex;\n align-items: center;\n justify-content: center;\n padding: calc(var(--idos-safe-top, 0px) + 12px) 12px\n calc(var(--idos-safe-bottom, 0px) + 12px);\n color: #eee;\n font-size: 14px;\n user-select: text;\n}\n#netScreen.hidden,\n#netTabList.hidden,\n#netScreen .hidden {\n display: none;\n}\n#netScreen .wsPanel {\n width: min(620px, 100%);\n}\n#netScreen .wsForm button {\n cursor: pointer;\n font-size: 13px;\n padding: 6px 12px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n\n.netInput {\n flex: none;\n width: 100%;\n box-sizing: border-box;\n min-width: 0;\n font: inherit;\n padding: 7px 8px;\n border-radius: 6px;\n border: 1px solid #4a5163;\n background: #161b28;\n color: #eee;\n}\n#netTabList {\n position: absolute;\n top: calc(var(--idos-safe-top, 0px) + 40px);\n left: 50%;\n transform: translateX(-50%);\n z-index: 30;\n min-width: 240px;\n max-width: calc(100% - 32px);\n padding: 8px 12px;\n background: rgba(0, 0, 0, 0.6);\n border-radius: 6px;\n color: #fff;\n font-size: 14px;\n pointer-events: none;\n}\n\n.netTabRow {\n display: flex;\n justify-content: space-between;\n gap: 24px;\n padding: 2px 0;\n}\n.netTabPing {\n color: #9adf6a;\n font-size: 12px;\n}\n\n/* жесты и эмоции персонажа (ui/EmoteWheel) */\n#emoteWheel {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.35);\n z-index: 30;\n}\n#emoteWheel.hidden {\n display: none;\n}\n.emotePanel {\n background: #c6c6c6;\n border: 3px solid;\n border-color: #fff #555 #555 #fff;\n padding: 10px 12px;\n width: min(560px, calc(100vw - 32px));\n max-height: calc(100% - 24px);\n overflow-y: auto;\n}\n.emotePanel h3 {\n color: #3a3a3a;\n font-size: 14px;\n margin: 2px 0 6px;\n}\n.emoteGrid {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));\n gap: 4px;\n margin-bottom: 8px;\n}\n.emoteBtn {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 4px 6px;\n text-align: left;\n font: inherit;\n font-size: 11px;\n color: #222;\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n cursor: pointer;\n}\n.emoteBtn:hover {\n background: #a8a8c8;\n}\n.emoteIcon {\n font-size: 20px;\n line-height: 1;\n}\n.emoteHint {\n color: #555;\n font-size: 11px;\n}\n\n/* ---------- Задания: трекер, стрелка, всплывашка, книга (ui/quests) ---------- */\n#questTracker {\n position: fixed;\n left: 10px;\n top: calc(var(--idos-safe-top, 0px) + 80px);\n width: 270px;\n color: #fff;\n font-size: 12px;\n line-height: 1.35;\n pointer-events: none;\n z-index: 6;\n text-shadow: 1px 1px 0 #000;\n}\n.qTip {\n background: rgba(60, 48, 10, 0.85);\n border-left: 3px solid #f0d060;\n padding: 5px 8px;\n margin-bottom: 6px;\n}\n.qCard {\n background: rgba(16, 16, 24, 0.72);\n border-left: 3px solid #f0d060;\n padding: 6px 8px;\n margin-bottom: 6px;\n}\n.qHead {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-bottom: 3px;\n}\n.qHead b {\n color: #f0d060;\n}\n.qIcon {\n width: 16px;\n height: 16px;\n flex: none;\n image-rendering: pixelated;\n}\n.qIcon.big {\n width: 32px;\n height: 32px;\n}\n.qTask {\n opacity: 0.95;\n}\n.qTask.ok {\n color: #9ae07a;\n}\n.qHint {\n margin: 4px 0 0;\n color: #e8e0c0;\n}\n.qNote {\n margin-top: 4px;\n color: #9ae07a;\n}\n.qFoot {\n font-size: 11px;\n opacity: 0.6;\n}\n.qRecipe {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-top: 5px;\n}\n.qGrid {\n display: grid;\n gap: 2px;\n}\n.qCell {\n position: relative;\n width: 22px;\n height: 22px;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(139, 139, 139, 0.55);\n border: 1px solid #373737;\n}\n.qCellIcon {\n width: 18px;\n height: 18px;\n image-rendering: pixelated;\n}\n.qOut b {\n position: absolute;\n right: 1px;\n bottom: -2px;\n font-size: 10px;\n}\n.qArrowR {\n font-size: 14px;\n}\n/* «Теперь можно сделать» (ui/RecipeToast) */\n#recipeToast {\n position: fixed;\n right: 12px;\n top: 45%;\n min-width: 190px;\n max-width: 260px;\n padding: 8px 12px;\n background: rgba(20, 20, 30, 0.88);\n border: 2px solid #3f7f3a;\n border-radius: 8px;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 6;\n}\n#recipeToast .rtHead {\n color: #8fe07f;\n font-weight: bold;\n margin-bottom: 4px;\n}\n#recipeToast .rtItem {\n display: flex;\n align-items: center;\n gap: 4px;\n margin: 2px 0;\n}\n#recipeToast .rtIcon {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n}\n#recipeToast .rtFoot {\n margin-top: 4px;\n color: #aab;\n font-size: 11px;\n}\n#questToast {\n position: fixed;\n left: 50%;\n top: calc(var(--idos-safe-top, 0px) + 72px);\n transform: translateX(-50%);\n min-width: 240px;\n padding: 8px 14px;\n text-align: center;\n background: rgba(20, 20, 30, 0.9);\n border: 2px solid #7a6a3a;\n color: #fff;\n font-size: 13px;\n pointer-events: none;\n z-index: 31;\n}\n.qtHead {\n color: #f0d060;\n font-weight: bold;\n}\n.qtRewards {\n margin-top: 4px;\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n font-size: 12px;\n}\n.qtRewards span,\n.qbRewards span {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n}\n#questBook {\n position: absolute;\n inset: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.45);\n z-index: 30;\n}\n.qbPanel {\n width: min(920px, 96vw);\n max-height: 88vh;\n display: flex;\n flex-direction: column;\n background: rgba(16, 16, 24, 0.96);\n border: 2px solid #555;\n color: #ddd;\n font-size: 13px;\n padding: 10px 12px;\n box-sizing: border-box;\n}\n.qbTop {\n display: flex;\n align-items: center;\n gap: 8px;\n margin-bottom: 6px;\n}\n.qbTop h3 {\n flex: 1;\n margin: 0;\n color: #f0d060;\n}\n.qbBtn {\n font: inherit;\n font-size: 12px;\n padding: 4px 10px;\n color: #fff;\n background: #3a3a4a;\n border: 1px solid #666;\n cursor: pointer;\n}\n.qbBtn:hover {\n background: #4a4a60;\n}\n.qbNote {\n color: #c8b870;\n font-size: 12px;\n margin: 2px 0 6px;\n}\n.qbBody {\n display: grid;\n grid-template-columns: 190px 230px 1fr;\n gap: 10px;\n min-height: 0;\n flex: 1;\n overflow: hidden;\n}\n.qbChapters,\n.qbQuests,\n.qbDetail {\n overflow-y: auto;\n min-height: 0;\n max-height: calc(88vh - 70px);\n}\n.qbChapter,\n.qbQuest {\n width: 100%;\n display: flex;\n align-items: center;\n gap: 6px;\n margin-bottom: 4px;\n padding: 5px 6px;\n font: inherit;\n font-size: 12px;\n text-align: left;\n color: #ddd;\n background: #22222e;\n border: 1px solid #333;\n cursor: pointer;\n}\n.qbChapter.on,\n.qbQuest.on {\n background: #34344a;\n border-color: #f0d060;\n}\n.qbName {\n flex: 1;\n}\n.qbCount {\n opacity: 0.7;\n}\n.qbQuest.locked {\n opacity: 0.5;\n}\n.qbQuest.done .qbMark {\n color: #9ae07a;\n}\n.qbQuest.tracked .qbName::after {\n content: \" 📌\";\n}\n.qbChapterDesc {\n font-size: 12px;\n opacity: 0.75;\n margin-bottom: 6px;\n}\n.qbDHead {\n display: flex;\n align-items: center;\n gap: 8px;\n}\n.qbDHead h4 {\n margin: 0;\n color: #f0d060;\n font-size: 15px;\n}\n.qbDesc {\n margin: 6px 0;\n line-height: 1.4;\n}\n.qbLocked {\n color: #c8b870;\n margin-bottom: 6px;\n}\n.qbSection {\n margin-top: 8px;\n}\n.qbSTitle {\n font-weight: bold;\n color: #aaa;\n font-size: 11px;\n text-transform: uppercase;\n margin-bottom: 3px;\n}\n.qbDetail .qHint {\n line-height: 1.4;\n}\n.qbRewards {\n display: flex;\n gap: 12px;\n flex-wrap: wrap;\n}\n.qbActions {\n margin-top: 12px;\n display: flex;\n gap: 8px;\n}\n@media (max-width: 720px) {\n .qbBody {\n grid-template-columns: 1fr;\n grid-auto-rows: max-content;\n overflow-y: auto;\n }\n .qbChapters,\n .qbQuests,\n .qbDetail {\n max-height: none;\n min-height: auto;\n overflow: visible;\n }\n .qbChapters {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n }\n .qbChapter {\n width: auto;\n margin: 0;\n }\n .qbTop .qbBtn {\n padding: 4px 6px;\n }\n #questTracker {\n width: 210px;\n font-size: 11px;\n }\n}\n.tPause {\n left: 10px;\n top: calc(var(--idos-safe-top, 0px) + 10px);\n width: 40px;\n height: 40px;\n font-size: 16px;\n}\n.tQuests {\n right: 292px;\n top: calc(var(--idos-safe-top, 0px) + 48px);\n width: 48px;\n height: 48px;\n font-size: 18px;\n}\n/* трекер: следующий шаг по дереву справочника */\n.qNext {\n margin-top: 4px;\n color: #ffd75e;\n font-size: 12px;\n}\n.qbRecipeName {\n margin-top: 6px;\n font-size: 12px;\n color: #aaa;\n}\n/* рецепт в книге заданий — кнопка в справочник */\n.qbRecipe {\n display: inline-block;\n cursor: pointer;\n border-radius: 6px;\n padding: 0 6px 4px;\n}\n.qbRecipe:hover {\n background: rgba(255, 255, 255, 0.07);\n}\n.qbRecipe:hover .qbRecipeName {\n color: #ffd75e;\n}\n.qbRecipe [data-item]:hover {\n outline: 1px solid #ffd75e;\n}\n\n/* ---------- Карта: компас, миникарта, метки в мире, большая карта (ui/map) ---------- */\n#navCompass {\n position: fixed;\n left: 50%;\n top: calc(var(--idos-safe-top, 0px) + 6px);\n transform: translateX(-50%);\n width: min(440px, 52vw);\n z-index: 6;\n pointer-events: none;\n text-align: center;\n}\n#navCompass canvas {\n display: block;\n width: 100%;\n height: 34px;\n}\n.ncInfo {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n margin-top: 2px;\n padding: 2px 10px;\n background: rgba(0, 0, 0, 0.55);\n border-radius: 10px;\n color: #fff;\n font: bold 12px sans-serif;\n text-shadow: 1px 1px 0 #000;\n white-space: nowrap;\n max-width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.ncDot {\n font-size: 13px;\n}\n.ncInfo .qIcon {\n width: 16px;\n height: 16px;\n}\n#minimap {\n position: fixed;\n right: 10px;\n top: calc(var(--idos-safe-top, 0px) + 44px);\n width: 150px;\n /* над слоем «смотреть» тач-управления (.touchUi, z 6): касание открывает карту */\n z-index: 7;\n cursor: pointer;\n pointer-events: auto;\n}\n#minimap canvas {\n display: block;\n width: 150px;\n height: 150px;\n border-radius: 50%;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);\n}\n.mmCoords {\n margin-top: 3px;\n text-align: center;\n color: #fff;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n}\nbody.touch #minimap {\n top: calc(var(--idos-safe-top, 0px) + 104px);\n width: 112px;\n}\nbody.touch #minimap canvas {\n width: 112px;\n height: 112px;\n}\nbody.touch #effects {\n top: calc(var(--idos-safe-top, 0px) + 248px);\n}\nbody.touch #navCompass {\n width: min(360px, 40vw);\n}\n#navWorld {\n position: fixed;\n inset: 0;\n pointer-events: none;\n /* над трекером заданий (z 6): стрелка у левого края не прячется под ним */\n z-index: 7;\n overflow: hidden;\n}\n.navMark {\n position: absolute;\n left: 0;\n top: 0;\n width: 0;\n height: 0;\n will-change: transform;\n}\n.nmIcon {\n position: absolute;\n left: -14px;\n top: -14px;\n width: 28px;\n height: 28px;\n box-sizing: border-box;\n border: 3px solid currentColor;\n border-radius: 50%;\n background: rgba(0, 0, 0, 0.6);\n display: flex;\n align-items: center;\n justify-content: center;\n font: bold 15px sans-serif;\n box-shadow: 0 0 10px rgba(0, 0, 0, 0.6);\n}\n.navMark.guided .nmIcon {\n left: -18px;\n top: -18px;\n width: 36px;\n height: 36px;\n font-size: 19px;\n animation: nmPulse 1.4s ease-in-out infinite;\n}\n@keyframes nmPulse {\n 50% {\n box-shadow: 0 0 0 6px rgba(240, 208, 96, 0.25);\n }\n}\n.nmItem {\n width: 18px;\n height: 18px;\n image-rendering: pixelated;\n}\n.navMark.guided .nmItem {\n width: 22px;\n height: 22px;\n}\n.nmText {\n position: absolute;\n left: 0;\n top: 22px;\n transform: translateX(-50%);\n white-space: nowrap;\n padding: 1px 6px;\n border-radius: 8px;\n background: rgba(0, 0, 0, 0.55);\n color: #fff;\n font: bold 12px sans-serif;\n text-shadow: 1px 1px 0 #000;\n}\n.nmArrow {\n /* треугольник остриём вверх; NavHud поворачивает его к цели и выносит за кружок */\n position: absolute;\n left: -15px;\n top: -13px;\n width: 0;\n height: 0;\n border-left: 15px solid transparent;\n border-right: 15px solid transparent;\n border-bottom: 26px solid currentColor;\n filter: drop-shadow(0 0 1.5px #000) drop-shadow(0 0 1.5px #000);\n}\n#worldMap {\n position: fixed;\n inset: 0;\n z-index: 30;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.55);\n}\n.wmPanel {\n width: min(1100px, calc(100vw - 24px));\n height: min(760px, calc(100vh - 24px));\n display: flex;\n flex-direction: column;\n background: #2b2b33;\n border: 3px solid #7a6a3a;\n border-radius: 6px;\n color: #fff;\n box-shadow: 0 6px 30px rgba(0, 0, 0, 0.6);\n}\n.wmTop {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 8px 10px;\n flex-wrap: wrap;\n}\n.wmTop h3 {\n margin: 0 auto 0 0;\n color: #f0d060;\n font-size: 18px;\n}\n.wmBtn {\n padding: 6px 10px;\n border: 1px solid #555;\n border-radius: 4px;\n background: #444452;\n color: #fff;\n font: bold 13px sans-serif;\n cursor: pointer;\n}\n.wmBtn:hover {\n background: #565668;\n}\n.wmBody {\n flex: 1;\n min-height: 0;\n display: flex;\n gap: 8px;\n padding: 0 10px;\n}\n.wmView {\n position: relative;\n flex: 1;\n min-width: 0;\n border: 2px solid #111;\n border-radius: 4px;\n overflow: hidden;\n}\n.wmView canvas {\n display: block;\n width: 100%;\n height: 100%;\n cursor: crosshair;\n touch-action: none;\n}\n.wmSide {\n width: 220px;\n overflow-y: auto;\n font-size: 13px;\n}\n.wmSide h4 {\n margin: 4px 0 6px;\n color: #f0d060;\n}\n.wmNote {\n color: #bbb;\n font-size: 12px;\n margin-bottom: 8px;\n}\n.wmItem {\n display: flex;\n align-items: center;\n gap: 6px;\n padding: 5px 6px;\n border-radius: 4px;\n cursor: pointer;\n}\n.wmItem:hover {\n background: rgba(255, 255, 255, 0.08);\n}\n.wmItem.on {\n background: rgba(240, 208, 96, 0.18);\n}\n.wmDot {\n width: 16px;\n text-align: center;\n font-weight: bold;\n}\n.wmItemName {\n flex: 1;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.wmDist {\n color: #aaa;\n font-size: 11px;\n}\n.wmHelp {\n padding: 6px 10px 8px;\n color: #aaa;\n font-size: 12px;\n}\n.wmPop {\n position: absolute;\n width: 230px;\n box-sizing: border-box;\n padding: 8px;\n display: grid;\n gap: 6px;\n background: rgba(30, 30, 38, 0.96);\n border: 2px solid #7a6a3a;\n border-radius: 6px;\n}\n.wmPinTitle {\n font-weight: bold;\n color: #f0d060;\n}\n.wmName {\n width: 100%;\n box-sizing: border-box;\n padding: 5px 6px;\n border: 1px solid #555;\n border-radius: 4px;\n background: #1b1b22;\n color: #fff;\n font-size: 13px;\n}\n.wmColors {\n display: flex;\n gap: 5px;\n}\n.wmColor {\n width: 22px;\n height: 22px;\n border: 2px solid #222;\n border-radius: 50%;\n cursor: pointer;\n padding: 0;\n}\n.wmColor.on {\n border-color: #fff;\n box-shadow: 0 0 0 2px #000;\n}\n.wmRow {\n display: flex;\n flex-wrap: wrap;\n gap: 5px;\n}\n@media (max-width: 700px) {\n /* слева сверху — круглая кнопка «в лобби» базы */\n .wmTop {\n padding-left: 54px;\n }\n .wmBody {\n flex-direction: column;\n }\n .wmSide {\n width: auto;\n max-height: 120px;\n }\n}\n\n/* подсказка предмета при наведении на слот (ui/itemTooltip) — как в Minecraft */\n#itemTip {\n position: fixed;\n z-index: 60;\n pointer-events: none;\n max-width: 280px;\n padding: 5px 8px;\n background: rgba(16, 0, 16, 0.94);\n border: 2px solid #2a0a5e;\n outline: 1px solid #000;\n border-radius: 3px;\n color: #fff;\n font: 14px/1.35 sans-serif;\n text-shadow: 1px 1px 0 #3f3f3f;\n white-space: nowrap;\n}\n#itemTip .itName {\n font-weight: bold;\n}\n#itemTip .itLine {\n color: #a8a8ff;\n font-size: 12px;\n}\n\n/* ---------- Справочник (ui/catalog) ---------- */\n#catalog {\n position: fixed;\n inset: 0;\n z-index: 35;\n display: flex;\n align-items: center;\n justify-content: center;\n background: rgba(0, 0, 0, 0.55);\n}\n.ctPanel {\n width: min(1180px, calc(100vw - 24px));\n height: min(800px, calc(100vh - 24px));\n display: flex;\n flex-direction: column;\n background: #2b2b33;\n border: 3px solid #7a6a3a;\n border-radius: 6px;\n color: #fff;\n box-shadow: 0 6px 30px rgba(0, 0, 0, 0.6);\n}\n.ctTop {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 10px;\n}\n.ctTop h3 {\n margin: 0;\n color: #f0d060;\n font-size: 18px;\n}\n.ctSearch {\n flex: 1;\n min-width: 0;\n padding: 6px 8px;\n border: 1px solid #555;\n border-radius: 4px;\n background: #1b1b22;\n color: #fff;\n font-size: 14px;\n}\n.ctBtn,\n.ctChip,\n.ctOpenBtn {\n padding: 5px 10px;\n border: 1px solid #555;\n border-radius: 4px;\n background: #444452;\n color: #fff;\n font: bold 13px sans-serif;\n cursor: pointer;\n}\n.ctBtn:hover,\n.ctChip:hover,\n.ctOpenBtn:hover {\n background: #565668;\n}\n.ctBtn.on {\n background: #7a6a3a;\n border-color: #f0d060;\n}\n.ctOpenBtn {\n float: right;\n margin-top: -3px;\n font-size: 12px;\n}\n.ctBody {\n flex: 1;\n min-height: 0;\n display: flex;\n gap: 10px;\n padding: 0 10px 10px;\n}\n.ctLeft {\n width: 40%;\n min-width: 260px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n min-height: 0;\n}\n.ctCats {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n}\n.ctGrid {\n flex: 1;\n min-height: 0;\n overflow-y: auto;\n display: grid;\n grid-template-columns: repeat(auto-fill, 40px);\n gap: 4px;\n align-content: start;\n padding: 4px;\n background: #1d1d24;\n border-radius: 4px;\n}\n.ctSlot {\n width: 40px;\n height: 40px;\n display: flex;\n align-items: center;\n justify-content: center;\n background: #3a3a46;\n border: 2px solid transparent;\n border-radius: 3px;\n cursor: pointer;\n}\n.ctSlot:hover {\n border-color: #888;\n}\n.ctSlot.on {\n border-color: #f0d060;\n}\n.ctIcon {\n width: 30px;\n height: 30px;\n image-rendering: pixelated;\n}\n.ctIconS {\n width: 20px;\n height: 20px;\n image-rendering: pixelated;\n vertical-align: middle;\n margin-right: 4px;\n}\n.ctBig {\n width: 56px;\n height: 56px;\n image-rendering: pixelated;\n}\n.ctDetail {\n flex: 1;\n min-width: 0;\n overflow-y: auto;\n padding: 4px 6px;\n font-size: 14px;\n}\n.ctHead {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 6px;\n}\n.ctHead h4 {\n margin: 0;\n font-size: 20px;\n color: #f0d060;\n}\n.ctInfo,\n.ctDim {\n color: #aab;\n font-size: 12px;\n}\n.ctSection {\n margin: 14px 0 6px;\n font-size: 13px;\n text-transform: uppercase;\n letter-spacing: 1px;\n color: #f0d060;\n}\n.ctSource {\n padding: 6px 8px;\n margin-bottom: 6px;\n background: rgba(255, 255, 255, 0.05);\n border-radius: 4px;\n}\n.ctSrcLabel {\n font-size: 12px;\n color: #ccd;\n margin-bottom: 4px;\n}\n.ctLine {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 4px;\n}\n.ctGridR {\n display: grid;\n gap: 2px;\n}\n.ctCell {\n position: relative;\n width: 34px;\n height: 34px;\n display: flex;\n align-items: center;\n justify-content: center;\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n cursor: pointer;\n}\n.ctCell .ctIcon {\n width: 26px;\n height: 26px;\n}\n.ctCell b {\n position: absolute;\n right: 1px;\n bottom: -1px;\n font-size: 12px;\n color: #fff;\n text-shadow: 1px 1px 0 #000;\n}\n.ctTree,\n.ctTree ul {\n list-style: none;\n margin: 0;\n padding-left: 18px;\n border-left: 1px dashed #556;\n}\n.ctTree {\n padding-left: 4px;\n border-left: none;\n}\n.ctTreeRow {\n display: flex;\n align-items: center;\n padding: 2px 4px;\n border-radius: 3px;\n cursor: pointer;\n}\n.ctTreeRow:hover {\n background: rgba(255, 255, 255, 0.08);\n}\n.ctRaw,\n.ctChips {\n display: flex;\n flex-wrap: wrap;\n gap: 4px;\n align-items: center;\n margin-top: 8px;\n}\n.ctRaw b {\n margin-right: 4px;\n}\n.ctNote {\n color: #ccd;\n padding: 4px 0;\n}\n/* план: что уже есть и что делать дальше */\n.ctPlan {\n margin: 6px 0 2px;\n}\n.ctNext {\n background: rgba(255, 215, 94, 0.1);\n border-left: 3px solid #ffd75e;\n border-radius: 4px;\n padding: 6px 8px;\n margin: 4px 0;\n cursor: pointer;\n color: #eee;\n}\n.ctNext b {\n color: #ffd75e;\n}\n.ctMissing > b {\n color: #ff8a7a;\n}\n.ctHave {\n color: #aab;\n font-size: 11px;\n}\n.ctHave.ok {\n color: #7ddc7a;\n}\n.ctChip .ctHave {\n margin-left: 4px;\n font-size: 12px;\n}\n.ctChip .ctHave.miss {\n color: #ff8a7a;\n}\n.ctFill {\n margin-top: 6px;\n background: #3f7f3a;\n border-color: #6fbf5f;\n}\n.ctFill:hover {\n background: #4d9446;\n}\n@media (max-width: 760px) {\n .ctBody {\n flex-direction: column;\n }\n .ctLeft {\n width: auto;\n min-width: 0;\n height: 38%;\n }\n}\n.ctTreeRow .ctDim {\n margin-left: 6px;\n}\n\n/* ---------- Вид UI kit: Layer Lab «Vertical Casual» (решение владельца 22.10.2026) ----------\n Окна, слоты, кнопки и подсказки игры — в общем виде SDK: синие панели и тёмные плашки в толстой\n обводке ink, глянцевые кнопки, белые буквы с обводкой. Цвета — переменные темы кита\n (src/ui.config.ts проекта); запасные значения — для узлов вне корня кита (подсказка предмета,\n курсорный стек). Книга-пергамент и бумажная карта остаются предметами мира. Блок стоит последним:\n при той же специфичности его правила перекрывают прежние. */\n\nbody {\n font-family: var(--idos-ui-font, \"Rubik\", system-ui, sans-serif);\n background: var(--idos-ui-bg-bottom, #5a3fd6);\n}\n\n/* окна: инвентарь и верстаки, эмоции, мастерская, книга заданий, карта мира, справочник, ачивки */\n.invPanel,\n.emotePanel,\n.wsPanel,\n.qbPanel,\n.wmPanel,\n.ctPanel,\n#achList {\n background: linear-gradient(\n 180deg,\n var(--idos-ui-panel, #3d8ff5) 0%,\n var(--idos-ui-panel-deep, #2f6de3) 100%\n );\n border: 3px solid var(--idos-ui-ink, #17193a);\n border-radius: 14px;\n box-shadow:\n 0 5px 0 rgba(18, 16, 70, 0.45),\n inset 0 2px 0 rgba(255, 255, 255, 0.25);\n color: #fff;\n}\n.invPanel h3,\n.emotePanel h3,\n.qbTop h3,\n.wmTop h3,\n.ctTop h3,\n#achList h3,\n#achToast .achHead,\n.qtHead,\n#recipeToast .rtHead {\n color: #fff;\n font-weight: 900;\n text-shadow: 0 0.08em 0 var(--idos-ui-ink, #17193a);\n -webkit-text-stroke: 0.16em var(--idos-ui-ink, #17193a);\n paint-order: stroke fill;\n}\n.trashLabel,\n.arrow,\n.emoteHint,\n.anvilCost {\n color: #fff;\n}\n\n/* слоты: тёмные плашки в обводке; в окнах — на тон светлее фона окна */\n.slot,\n.ctCell,\n.qCell,\n.ctSlot {\n background: var(--idos-ui-well, #1e2052);\n border: 2px solid var(--idos-ui-ink, #17193a);\n border-radius: 8px;\n box-shadow: inset 0 3px 0 rgba(0, 0, 0, 0.3);\n}\n.invPanel .slot,\n.ctCell {\n background: color-mix(\n in srgb,\n var(--idos-ui-panel-deep, #2f6de3) 50%,\n var(--idos-ui-ink, #17193a)\n );\n border-color: var(--idos-ui-ink, #17193a);\n}\n/* слот не сжимается в строке окна (результат крафта рядом со стрелкой) */\n.slot {\n flex: none;\n}\n.dollCanvas {\n background: var(--idos-ui-well, #1e2052);\n border: 2px solid var(--idos-ui-ink, #17193a);\n border-radius: 10px;\n}\n.slot .count,\n#cursorStack .count {\n font: 900 13px var(--idos-ui-font, \"Rubik\", system-ui, sans-serif);\n text-shadow: 0 1px 0 #17193a;\n -webkit-text-stroke: 2px #17193a;\n paint-order: stroke fill;\n}\n.ctSlot.on,\n.ctSlot:hover {\n border-color: var(--idos-ui-gold, #ffd43b);\n}\n\n/* хотбар: тёмно-синяя полоса меню, выбранный слот — золотая рамка */\n#hotbar {\n gap: 4px;\n padding: 4px;\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--idos-ui-nav, #2c3794) 82%, #fff) 0%,\n var(--idos-ui-nav, #2c3794) 30%,\n color-mix(in srgb, var(--idos-ui-nav, #2c3794) 80%, #17193a) 100%\n );\n border: 3px solid var(--idos-ui-ink, #17193a);\n border-radius: 12px;\n box-shadow: 0 4px 0 rgba(18, 16, 70, 0.45);\n}\n#hotbar .slot.selected {\n border-color: var(--idos-ui-gold, #ffd43b);\n background: color-mix(\n in srgb,\n var(--idos-ui-gold, #ffd43b) 30%,\n var(--idos-ui-well, #1e2052)\n );\n box-shadow: 0 0 10px 2px\n color-mix(in srgb, var(--idos-ui-gold, #ffd43b) 60%, transparent);\n}\n\n/* шкалы */\n#xpBar,\n.bossTrack {\n border: 2px solid var(--idos-ui-ink, #17193a);\n border-radius: 6px;\n}\n\n/* кнопки: тёмная глянцевая плашка кита; активные вкладки — синяя */\n.deathBtn,\n#netScreen .wsForm button,\n.qbBtn,\n.wmBtn,\n.ctOpenBtn,\n.ctBtn,\n.bookBtn {\n background: linear-gradient(180deg, #8a8cab 0%, #6b6d8c 42%, #45475f 100%);\n border: 3px solid var(--idos-ui-ink, #17193a);\n border-radius: 12px;\n color: #fff;\n font-family: var(--idos-ui-font, \"Rubik\", system-ui, sans-serif);\n font-weight: 900;\n text-shadow: 0 0.08em 0 #17193a;\n box-shadow:\n inset 0 -4px 0 rgba(0, 0, 0, 0.28),\n inset 0 2px 0 rgba(255, 255, 255, 0.45),\n 0 3px 0 rgba(18, 16, 70, 0.45);\n}\n.deathBtn:hover,\n#netScreen .wsForm button:hover,\n.qbBtn:hover,\n.wmBtn:hover,\n.ctOpenBtn:hover,\n.ctBtn:hover,\n.bookBtn:hover {\n background: linear-gradient(180deg, #9a9cbb 0%, #7b7d9c 42%, #55576f 100%);\n}\n.ctBtn.on,\n.wsTab.active,\n.ctFill {\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--idos-ui-blue, #46a8ff) 60%, #fff) 0%,\n var(--idos-ui-blue, #46a8ff) 42%,\n var(--idos-ui-blue-deep, #1f72ea) 100%\n );\n border: 3px solid var(--idos-ui-ink, #17193a);\n color: #fff;\n}\n.ctFill {\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--idos-ui-green, #6ee02c) 60%, #fff) 0%,\n var(--idos-ui-green, #6ee02c) 42%,\n var(--idos-ui-green-deep, #2fae1c) 100%\n );\n border-radius: 12px;\n}\n.emoteBtn {\n background: linear-gradient(180deg, #8a8cab 0%, #6b6d8c 42%, #45475f 100%);\n border: 3px solid var(--idos-ui-ink, #17193a);\n border-radius: 12px;\n color: #fff;\n}\n.emoteBtn:hover {\n background: linear-gradient(\n 180deg,\n var(--idos-ui-blue, #46a8ff) 0%,\n var(--idos-ui-blue-deep, #1f72ea) 100%\n );\n}\n\n/* поля ввода: тёмная плашка в обводке */\n.chatInput,\n.netInput,\n.wmName,\n.ctSearch,\n.creativeSearch {\n background: var(--idos-ui-well, #1e2052);\n border: 2px solid var(--idos-ui-ink, #17193a);\n border-radius: 10px;\n color: #fff;\n}\n\n/* строки списков: полоса на тон темнее окна */\n.qbQuest,\n.wsItem,\n.wmItem {\n background: linear-gradient(\n 180deg,\n color-mix(in srgb, var(--idos-ui-panel-deep, #2f6de3) 82%, #fff) 0%,\n var(--idos-ui-panel-deep, #2f6de3) 100%\n );\n border: none;\n border-radius: 10px;\n box-shadow:\n inset 0 -3px 0 rgba(0, 0, 0, 0.18),\n inset 0 2px 0 rgba(255, 255, 255, 0.18);\n color: #fff;\n}\n.qbQuest.on,\n.wmItem.on {\n box-shadow:\n 0 0 0 3px var(--idos-ui-gold, #ffd43b),\n inset 0 -3px 0 rgba(0, 0, 0, 0.18);\n}\n.ctGrid,\n.wmView {\n background: var(--idos-ui-well, #1e2052);\n border: 2px solid var(--idos-ui-ink, #17193a);\n border-radius: 10px;\n}\n\n/* всплывашки и карточки поверх мира: тёмная плашка в обводке */\n#achToast,\n#recipeToast,\n#questToast,\n.wmPop,\n#itemTip,\n.ncInfo,\n.nmText,\n#gadget {\n background: color-mix(in srgb, var(--idos-ui-well, #1e2052) 94%, transparent);\n border: 2px solid var(--idos-ui-ink, #17193a);\n border-radius: 10px;\n box-shadow: 0 3px 0 rgba(18, 16, 70, 0.45);\n}\n.qCard,\n.qTip {\n background: color-mix(in srgb, var(--idos-ui-well, #1e2052) 90%, transparent);\n border: 2px solid var(--idos-ui-ink, #17193a);\n border-left: 5px solid var(--idos-ui-gold, #ffd43b);\n border-radius: 10px;\n}\n.chatLine {\n background: color-mix(in srgb, #17193a 60%, transparent);\n border-radius: 6px;\n}\n\n/* сенсорные кнопки: полупрозрачные плашки в обводке */\n.tBtn {\n background: color-mix(in srgb, #45475f 70%, transparent);\n border: 3px solid var(--idos-ui-ink, #17193a);\n border-radius: 12px;\n box-shadow: inset 0 2px 0 rgba(255, 255, 255, 0.3);\n}\n.tBtn.on {\n background: color-mix(\n in srgb,\n var(--idos-ui-green, #6ee02c) 70%,\n transparent\n );\n}\n.tStick {\n border: 3px solid var(--idos-ui-ink, #17193a);\n}\n"
|
|
1594
1511
|
},
|
|
1595
1512
|
{
|
|
1596
1513
|
"path": "systems/Achievements.ts",
|
|
@@ -1630,11 +1547,11 @@
|
|
|
1630
1547
|
},
|
|
1631
1548
|
{
|
|
1632
1549
|
"path": "systems/CharacterStore.ts",
|
|
1633
|
-
"content": "// «Мои персонажи» и «Мои предметы»: рецепты в IndexedDB этого браузера (отдельная база — у миров\n// своя, и её версию не трогаем). Плюс в localStorage: какой персонаж выбран и что надето — это\n// настройки игрока, а не мира: они переходят из мира в мир.\n// Без IndexedDB open() отдаёт null — игра работает с «Путником» и без сохранения.\nimport {
|
|
1550
|
+
"content": "// «Мои персонажи» и «Мои предметы»: рецепты в IndexedDB этого браузера (отдельная база — у миров\n// своя, и её версию не трогаем). Плюс в localStorage: какой персонаж выбран и что надето — это\n// настройки игрока, а не мира: они переходят из мира в мир.\n// Без IndexedDB open() отдаёт null — игра работает с «Путником» и без сохранения.\nimport {\n parseCharacterSpec,\n type CharacterSpec,\n} from \"@idosgames/characters/voxel\";\nimport { parseItemSkin } from \"../character/items/ItemSkinSpec\";\nimport type { ItemSkinSpec, WearSlot } from \"../character/items/ItemSkinSpec\";\n\nexport interface StoredCharacter {\n /** Хэш рецепта: один персонаж — одна запись, повторное сохранение не плодит копии. */\n id: string;\n name: string;\n updatedAt: number;\n spec: CharacterSpec;\n}\n\nexport interface StoredItem {\n id: string;\n name: string;\n updatedAt: number;\n spec: ItemSkinSpec;\n}\n\nconst DB = \"voxelcraft-characters\";\nconst CHARS = \"characters\";\nconst ITEMS = \"items\";\nconst PREF = \"voxelcraft:look\";\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\n/** Выбранный персонаж и надетое (localStorage; без него — по умолчанию). */\nexport interface LookPrefs {\n character: string | null;\n outfit: Partial<Record<WearSlot, string>>;\n}\n\nexport function loadLookPrefs(): LookPrefs {\n try {\n const raw = localStorage.getItem(PREF);\n const v = raw ? (JSON.parse(raw) as Partial<LookPrefs>) : {};\n return {\n character: typeof v.character === \"string\" ? v.character : null,\n outfit: v.outfit && typeof v.outfit === \"object\" ? v.outfit : {},\n };\n } catch {\n return { character: null, outfit: {} };\n }\n}\n\nexport function saveLookPrefs(p: LookPrefs): void {\n try {\n localStorage.setItem(PREF, JSON.stringify(p));\n } catch {\n /* без хранилища выбор живёт до перезагрузки */\n }\n}\n\n/**\n * Рецепт из хранилища мог быть сохранён старой версией игры: прогоняем через разбор — он дополнит\n * новые поля значениями по умолчанию (точка миграции рецептов).\n */\nfunction migrateCharacter(c: StoredCharacter): StoredCharacter {\n const r = parseCharacterSpec(c.spec);\n return r.ok ? { ...c, spec: r.spec } : c;\n}\n\nfunction migrateItem(i: StoredItem): StoredItem {\n const r = parseItemSkin(i.spec);\n return r.ok ? { ...i, spec: r.spec } : i;\n}\n\nexport class CharacterStore {\n private constructor(private readonly db: IDBDatabase) {}\n\n static async open(): Promise<CharacterStore | null> {\n try {\n if (typeof indexedDB === \"undefined\") return null;\n const open = indexedDB.open(DB, 1);\n open.onupgradeneeded = () => {\n const db = open.result;\n if (!db.objectStoreNames.contains(CHARS))\n db.createObjectStore(CHARS, { keyPath: \"id\" });\n if (!db.objectStoreNames.contains(ITEMS))\n db.createObjectStore(ITEMS, { keyPath: \"id\" });\n };\n return new CharacterStore(await req(open));\n } catch {\n return null;\n }\n }\n\n private tx(store: string, mode: IDBTransactionMode): IDBObjectStore {\n return this.db.transaction(store, mode).objectStore(store);\n }\n\n async putCharacter(c: StoredCharacter): Promise<void> {\n await req(this.tx(CHARS, \"readwrite\").put(c));\n }\n\n async getCharacter(id: string): Promise<StoredCharacter | undefined> {\n const c = (await req(this.tx(CHARS, \"readonly\").get(id))) as\n StoredCharacter | undefined;\n return c && migrateCharacter(c);\n }\n\n async deleteCharacter(id: string): Promise<void> {\n await req(this.tx(CHARS, \"readwrite\").delete(id));\n }\n\n /** Все персонажи, свежие сверху. */\n async characters(): Promise<StoredCharacter[]> {\n const all = (await req(\n this.tx(CHARS, \"readonly\").getAll(),\n )) as StoredCharacter[];\n return all.map(migrateCharacter).sort((a, b) => b.updatedAt - a.updatedAt);\n }\n\n async putItem(i: StoredItem): Promise<void> {\n await req(this.tx(ITEMS, \"readwrite\").put(i));\n }\n\n async deleteItem(id: string): Promise<void> {\n await req(this.tx(ITEMS, \"readwrite\").delete(id));\n }\n\n async items(): Promise<StoredItem[]> {\n const all = (await req(\n this.tx(ITEMS, \"readonly\").getAll(),\n )) as StoredItem[];\n return all.map(migrateItem).sort((a, b) => b.updatedAt - a.updatedAt);\n }\n\n close(): void {\n this.db.close();\n }\n}\n"
|
|
1634
1551
|
},
|
|
1635
1552
|
{
|
|
1636
1553
|
"path": "systems/Commands.ts",
|
|
1637
|
-
"content": "// Команды консоли, как в Minecraft (/give, /tp, /time…). Текст приходит интентом Command (из\n// консоли игрока или от агента AI-кодера) и исполняется на тике — тем же путём, что и остальной\n// ввод. Выключаются целиком CONFIG.commands.enabled (издатель может закрыть читы в своей игре).\nimport { EMOTE_IDS, isEmote } from \"../character/anim/emotes\";\nimport { EMOTIONS, isEmotion } from \"../character/anim/emotions\";\nimport { CONFIG } from \"../config\";\nimport {\n DIFFICULTY_NAMES,\n parseDifficulty,\n rules,\n setDifficulty,\n} from \"./Difficulty\";\nimport { events } from \"../core/EventBus\";\nimport { ALIASES, resolveBlock, resolveItem } from \"../registry/aliases\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { ITEMS } from \"../registry/Items\";\nimport { ENCHANTS, enchantLine, withEnchants, type EnchantId } from \"./Enchant\";\nimport { EFFECT_NAMES, effectLabel, labelOf } from \"../registry/names\";\nimport { startCharge } from \"../entities/AshLordBrain\";\nimport { makeT, tr } from \"../i18n\";\nimport { ACHIEVEMENTS } from \"./Achievements\";\nimport type { Game } from \"../main\";\nimport type { EffectId } from \"../types\";\n\ntype Handler = (game: Game, args: string[]) => string;\n\n// Ответы команд на двух языках. Имена в самих командах (/difficulty мирная, /locate деревня)\n// понимаются на обоих — это ввод игрока, не текст экрана.\nconst EN = {\n noCoords: \"missing coordinates\",\n notNumber: \"not a number: {v}\",\n border: \"outside the world border (±{max}): {v}\",\n noBlock: \"no such block: {v}\",\n help:\n \"Commands: /give <item> [n], /tp x y z, /time set day|noon|night|midnight|<sec>, /weather clear|rain|thunder [sec], \" +\n \"/difficulty peaceful|easy|normal|hard, /gamemode survival|creative, /setblock x y z <block> [meta], /fill x1 y1 z1 x2 y2 z2 <block>, \" +\n \"/summon <mob> [x y z], /emote <emote>, /feel <emotion> [sec], /kill [mob], /effect <effect>|clear [sec] [level], /achievements, /quests [done <id>|reset], /music on|off|next, /clear, /seed, /locate fortress|citadel|village|outpost|temple|shipwreck|buried_treasure|mansion|ancient_city|trail_ruins|trial_chambers|city, /enchant <enchantment> [level], /newworld [seed]. Coordinates may use ~ (relative to you).\",\n noItem: \"no such item: {v}\",\n given: \"Given: {item} ×{n}\",\n teleport: \"Teleport: {x} {y} {z}\",\n timeArg: \"time: day, noon, night, midnight or seconds\",\n timeUsage: \"/time set <day|night|sec> or /time add <sec>\",\n time: \"Time: {t} s\",\n weatherUsage: \"/weather clear|rain|thunder [sec]\",\n weather: \"Weather: {w}\",\n clear: \"clear\",\n rain: \"rain\",\n thunder: \"thunderstorm\",\n mode: \"Mode: {m}\",\n creative: \"creative\",\n survival: \"survival\",\n difficultyUsage: \"/difficulty peaceful|easy|normal|hard (0..3)\",\n difficulty: \"Difficulty: {d}\",\n outside: \"the cell is outside the loaded world\",\n block: \"Block {b} at {x} {y} {z}\",\n tooMany: \"too many blocks: {n} (max 65536)\",\n filled: \"Blocks filled: {n}\",\n achievements: \"Achievements {n}/{total}: {list} (L — the list)\",\n none: \"none yet\",\n questsReset: \"Quests reset\",\n noQuest: \"no such open quest: {v}\",\n questDone: \"Quest completed: {v}\",\n quests: \"Quests {n}/{total}; now: {list} (J — the book)\",\n allDone: \"all done\",\n musicOff: \"Music off\",\n musicNext: \"Next song coming up\",\n musicOn: \"Music on\",\n music: \"Music: {s}\",\n off: \"off\",\n playing: \"playing\",\n waiting: \"waiting for its moment\",\n effectsCleared: \"Effects removed\",\n noEffect: \"no such effect: {v} ({list})\",\n effectArgs: \"time and level must be positive numbers\",\n effect: \"{e} {lvl} for {sec} s\",\n noMob: \"no such creature: {v}\",\n summoned: \"Summoned: {m}\",\n playerDied: \"The player died\",\n killed: \"Killed: {m} × {n}\",\n inventoryCleared: \"Inventory cleared\",\n seed: \"World seed: {v}\",\n emptyHand: \"your hand is empty\",\n noEnchant: \"no such enchantment: {v}\",\n enchantNoFit: \"{e} does not fit this item\",\n enchanted: \"Enchanted: {v}\",\n fortressOnly: \"inferno fortresses exist only in the Inferno\",\n notFound: \"not found: {v}\",\n nearest: \"Nearest: {x} {y} {z} ({d} blocks)\",\n emotes: \"Emotes: {v}\",\n emotions: \"Emotions: {v}\",\n newWorld: \"New world, seed {v}\",\n off_: \"Commands are off in this game\",\n unknown: \"Unknown command: /{v}. /help — the list\",\n error: \"Error: {v}\",\n} as const;\n\nconst ct = makeT<keyof typeof EN>(EN, {\n noCoords: \"не хватает координат\",\n notNumber: \"не число: {v}\",\n border: \"за границей мира (±{max}): {v}\",\n noBlock: \"нет такого блока: {v}\",\n help:\n \"Команды: /give <предмет> [n], /tp x y z, /time set day|noon|night|midnight|<сек>, /weather clear|rain|thunder [сек], \" +\n \"/difficulty мирная|лёгкая|обычная|высокая, /gamemode survival|creative, /setblock x y z <блок> [мета], /fill x1 y1 z1 x2 y2 z2 <блок>, \" +\n \"/summon <моб> [x y z], /emote <жест>, /feel <эмоция> [сек], /kill [моб], /effect <эффект>|clear [сек] [уровень], /achievements, /quests [done <id>|reset], /music on|off|next, /clear, /seed, /locate fortress|citadel|village|outpost|temple|shipwreck|buried_treasure|mansion|ancient_city|trail_ruins|trial_chambers|city, /enchant <зачарование> [уровень], /newworld [сид]. Координаты можно с ~ (относительно себя).\",\n noItem: \"нет такого предмета: {v}\",\n given: \"Выдано: {item} ×{n}\",\n teleport: \"Телепорт: {x} {y} {z}\",\n timeArg: \"время: day, noon, night, midnight или секунды\",\n timeUsage: \"/time set <день|ночь|сек> или /time add <сек>\",\n time: \"Время: {t} c\",\n weatherUsage: \"/weather clear|rain|thunder [сек]\",\n weather: \"Погода: {w}\",\n clear: \"ясно\",\n rain: \"дождь\",\n thunder: \"гроза\",\n mode: \"Режим: {m}\",\n creative: \"креатив\",\n survival: \"выживание\",\n difficultyUsage: \"/difficulty мирная|лёгкая|обычная|высокая (0..3)\",\n difficulty: \"Сложность: {d}\",\n outside: \"клетка вне загруженного мира\",\n block: \"Блок {b} в {x} {y} {z}\",\n tooMany: \"слишком много блоков: {n} (максимум 65536)\",\n filled: \"Заполнено блоков: {n}\",\n achievements: \"Достижения {n}/{total}: {list} (L — список)\",\n none: \"пока нет\",\n questsReset: \"Задания сброшены\",\n noQuest: \"нет такого открытого задания: {v}\",\n questDone: \"Задание выполнено: {v}\",\n quests: \"Задания {n}/{total}; сейчас: {list} (J — книга)\",\n allDone: \"все выполнены\",\n musicOff: \"Музыка выключена\",\n musicNext: \"Сейчас заиграет\",\n musicOn: \"Музыка включена\",\n music: \"Музыка: {s}\",\n off: \"выключена\",\n playing: \"играет\",\n waiting: \"ждёт своего часа\",\n effectsCleared: \"Эффекты сняты\",\n noEffect: \"нет такого эффекта: {v} ({list})\",\n effectArgs: \"время и уровень — положительные числа\",\n effect: \"{e} {lvl} на {sec} с\",\n noMob: \"нет такого существа: {v}\",\n summoned: \"Призвано: {m}\",\n playerDied: \"Игрок погиб\",\n killed: \"Убито: {m} × {n}\",\n inventoryCleared: \"Инвентарь очищен\",\n seed: \"Сид мира: {v}\",\n emptyHand: \"в руке пусто\",\n noEnchant: \"нет такого зачарования: {v}\",\n enchantNoFit: \"{e} не подходит к этому предмету\",\n enchanted: \"Зачаровано: {v}\",\n fortressOnly: \"адские крепости есть только в Пекле\",\n notFound: \"не нашлось: {v}\",\n nearest: \"Ближайшая: {x} {y} {z} ({d} блоков)\",\n emotes: \"Жесты: {v}\",\n emotions: \"Эмоции: {v}\",\n newWorld: \"Новый мир, сид {v}\",\n off_: \"Команды в этой игре выключены\",\n unknown: \"Неизвестная команда: /{v}. /help — список\",\n error: \"Ошибка: {v}\",\n});\n\nconst WORLD_BORDER = 30_000_000;\n\n/** Координата: число или `~`/`~N` относительно игрока. */\nfunction coord(arg: string | undefined, base: number): number {\n if (arg === undefined) throw new Error(ct(\"noCoords\"));\n // `~abc` раньше давал NaN: игрок улетал в NaN-координаты, и мир переставал грузиться насовсем\n const v = arg.startsWith(\"~\")\n ? base + (arg.length > 1 ? Number(arg.slice(1)) : 0)\n : Number(arg);\n if (!Number.isFinite(v)) throw new Error(ct(\"notNumber\", { v: arg }));\n // граница мира, как в Minecraft: на 1e300 шум генератора и ключи чанков теряют точность\n if (Math.abs(v) > WORLD_BORDER)\n throw new Error(ct(\"border\", { max: WORLD_BORDER, v: arg }));\n return v;\n}\n\n/** Три координаты начиная с args[i] (целые — для блоков). */\nfunction xyz(\n game: Game,\n args: string[],\n i: number,\n whole: boolean,\n): [number, number, number] {\n const p = game.player.pos;\n const r = [\n coord(args[i], p.x),\n coord(args[i + 1], p.y),\n coord(args[i + 2], p.z),\n ] as [number, number, number];\n return whole ? (r.map(Math.floor) as [number, number, number]) : r;\n}\n\nfunction blockArg(name: string | undefined): number {\n const id = name === undefined ? null : resolveBlock(name);\n if (id === null) throw new Error(ct(\"noBlock\", { v: String(name) }));\n return id;\n}\n\n/** Мета по умолчанию: жидкость ставится источником (как в Minecraft), иначе 0 — вода с метой 0 утекает. */\nconst defaultMeta = (id: number): number =>\n id === B.water || id === B.lava ? packWater(8, true) : 0;\n\nconst TIMES: Record<string, (d: number, n: number) => number> = {\n day: (d) => d * 0.3,\n noon: (d) => d * 0.5,\n sunset: (d) => d * 0.94,\n night: (d, n) => d + n * 0.3,\n midnight: (d, n) => d + n * 0.5,\n};\n\nconst HANDLERS: Record<string, Handler> = {\n help: () => ct(\"help\"),\n\n give: (g, [name, n]) => {\n const item = name === undefined ? null : resolveItem(name);\n if (!item) throw new Error(ct(\"noItem\", { v: String(name) }));\n const count = Math.max(\n 1,\n Math.min(64 * 36, Math.floor(Number(n ?? 1)) || 1),\n );\n const left = g.inventory.add(item, count);\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(p.x, p.y + 1, p.z, item, left);\n }\n return ct(\"given\", { item: labelOf(item), n: count });\n },\n\n tp: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, false);\n g.warmup(x, z);\n if (g.player.riding) g.player.dismount(); // телепорт высаживает из транспорта, как в Minecraft\n g.player.pos.set(x, y, z);\n g.player.vel.set(0, 0, 0);\n g.player._fallDist = 0;\n return ct(\"teleport\", {\n x: x.toFixed(1),\n y: y.toFixed(1),\n z: z.toFixed(1),\n });\n },\n\n time: (g, [op, v]) => {\n const { dayLength, nightLength } = CONFIG.dayNight;\n const cycle = dayLength + nightLength;\n if (op === \"set\" && v !== undefined) {\n const named = TIMES[v];\n const t = named ? named(dayLength, nightLength) : Number(v);\n if (!Number.isFinite(t)) throw new Error(ct(\"timeArg\"));\n g.dayNight.time = ((t % cycle) + cycle) % cycle;\n } else if (op === \"add\" && v !== undefined) {\n const dt = Number(v);\n if (!Number.isFinite(dt)) throw new Error(ct(\"notNumber\", { v })); // NaN ломал небо и свет\n g.dayNight.time = (((g.dayNight.time + dt) % cycle) + cycle) % cycle;\n } else throw new Error(ct(\"timeUsage\"));\n return ct(\"time\", { t: Math.round(g.dayNight.time) });\n },\n\n weather: (g, [k, sec]) => {\n if (k !== \"clear\" && k !== \"rain\" && k !== \"thunder\")\n throw new Error(ct(\"weatherUsage\"));\n const s = sec === undefined ? undefined : Number(sec);\n if (s !== undefined && !(s > 0))\n throw new Error(ct(\"notNumber\", { v: String(sec) }));\n g.weather.set(k, s);\n return ct(\"weather\", { w: ct(k) });\n },\n\n gamemode: (g, [m]) => {\n const mode =\n m === \"creative\" || m === \"1\" || m === \"c\"\n ? \"creative\"\n : m === \"survival\" || m === \"0\" || m === \"s\"\n ? \"survival\"\n : null;\n if (!mode) throw new Error(\"/gamemode survival|creative\");\n if (g.player.mode !== mode) g.player.toggleMode();\n return ct(\"mode\", { m: ct(mode) });\n },\n\n difficulty: (g, [d]) => {\n const next = parseDifficulty(d);\n if (!next) throw new Error(ct(\"difficultyUsage\"));\n setDifficulty(next);\n // на мирной монстры исчезают сразу, как в Minecraft\n if (!rules().hostiles) g.entities.clearHostiles();\n events.emit(\"difficultyChanged\", { difficulty: next });\n return ct(\"difficulty\", { d: tr(DIFFICULTY_NAMES[next]) });\n },\n\n setblock: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, true);\n const id = blockArg(args[3]);\n const meta =\n args[4] === undefined ? defaultMeta(id) : Number(args[4]) & 255;\n if (!g.world.setBlock(x, y, z, id, meta)) throw new Error(ct(\"outside\"));\n return ct(\"block\", { b: labelOf(args[3]!), x, y, z });\n },\n\n fill: (g, args) => {\n const [x1, y1, z1] = xyz(g, args, 0, true);\n const [x2, y2, z2] = xyz(g, args, 3, true);\n const id = blockArg(args[6]);\n const cells =\n (Math.abs(x2 - x1) + 1) *\n (Math.abs(y2 - y1) + 1) *\n (Math.abs(z2 - z1) + 1);\n if (cells > 65536) throw new Error(ct(\"tooMany\", { n: cells }));\n const meta = defaultMeta(id);\n let n = 0;\n for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++)\n for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++)\n for (let z = Math.min(z1, z2); z <= Math.max(z1, z2); z++)\n if (g.world.setBlock(x, y, z, id, meta)) n++;\n return ct(\"filled\", { n });\n },\n\n achievements: (g) => {\n const got = ACHIEVEMENTS.filter((a) => g.achievements.done.has(a.id)).map(\n (a) => tr(a.title),\n );\n return ct(\"achievements\", {\n n: got.length,\n total: ACHIEVEMENTS.length,\n list: got.join(\", \") || ct(\"none\"),\n });\n },\n\n quests: (g, [op, id]) => {\n const book = g.quests;\n if (op === \"reset\") {\n book.load(null);\n return ct(\"questsReset\");\n }\n if (op === \"done\") {\n const full = id?.includes(\":\") ? id : \"local:\" + id;\n if (!book.complete(full))\n throw new Error(ct(\"noQuest\", { v: String(id) }));\n return ct(\"questDone\", { v: full });\n }\n const done = book.entries().filter((e) => e.done).length;\n const now = book\n .focus()\n .map((e) => `${tr(e.def.title)} (${e.id})`)\n .join(\", \");\n return ct(\"quests\", {\n n: done,\n total: book.entries().length,\n list: now || ct(\"allDone\"),\n });\n },\n\n music: (g, [op]) => {\n if (op === \"off\") {\n g.music.enabled = false;\n g.music.stop();\n return ct(\"musicOff\");\n }\n if (op === \"on\" || op === \"next\") {\n g.music.enabled = true;\n if (op === \"next\") g.music.next();\n return op === \"next\" ? ct(\"musicNext\") : ct(\"musicOn\");\n }\n return ct(\"music\", { s: ct(g.music.status) });\n },\n\n effect: (g, [id, sec, lvl]) => {\n if (id === \"clear\") {\n g.player.clearEffects();\n return ct(\"effectsCleared\");\n }\n if (!id || !EFFECT_NAMES[id])\n throw new Error(\n ct(\"noEffect\", {\n v: String(id),\n list: Object.keys(EFFECT_NAMES).join(\", \"),\n }),\n );\n const seconds = sec === undefined ? 30 : Number(sec),\n level = lvl === undefined ? 1 : Number(lvl);\n if (!(seconds > 0) || !(level >= 1)) throw new Error(ct(\"effectArgs\"));\n g.player.addEffect(id as EffectId, seconds, Math.floor(level));\n return ct(\"effect\", {\n e: effectLabel(id),\n lvl: Math.floor(level),\n sec: seconds,\n });\n },\n\n summon: (g, args) => {\n // Minecraft-имена существ тоже годятся (/summon wither)\n const type =\n args[0] === undefined\n ? undefined\n : MOBS[args[0]]\n ? args[0]\n : ALIASES[args[0].toLowerCase()];\n if (!type || !MOBS[type])\n throw new Error(ct(\"noMob\", { v: String(args[0]) }));\n const [x, y, z] =\n args.length >= 4\n ? xyz(g, args, 1, false)\n : [g.player.pos.x + 2, g.player.pos.y, g.player.pos.z];\n const m = g.entities.spawnMob(type, x, y, z);\n if (m.def.boss === \"ash_lord\") startCharge(m); // как призванный: сперва набирает силу\n return ct(\"summoned\", { m: labelOf(type) });\n },\n\n // без аргумента — игрок; с видом моба — все такие в 64 блоках, обычной смертью (с дропом и распадом)\n kill: (g, [type]) => {\n if (!type) {\n g.player._die();\n return ct(\"playerDied\");\n }\n if (!MOBS[type]) throw new Error(ct(\"noMob\", { v: type }));\n let n = 0;\n for (const e of g.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === type &&\n e.pos.distanceTo(g.player.pos) < 64\n ) {\n e.damage(e.hp);\n n++;\n }\n return ct(\"killed\", { m: labelOf(type), n });\n },\n\n clear: (g) => {\n // карманы и броня; сундук Предела — склад, /clear его не трогает (как в Minecraft)\n g.inventory.clearCarried();\n return ct(\"inventoryCleared\");\n },\n\n seed: (g) => ct(\"seed\", { v: g.world.seed }),\n\n enchant: (g, [id, lvl]) => {\n const held = g.inventory.heldItem();\n if (!held) throw new Error(ct(\"emptyHand\"));\n if (!id || !(id in ENCHANTS))\n throw new Error(ct(\"noEnchant\", { v: id ?? \"?\" }));\n const e = ENCHANTS[id as EnchantId];\n const def = ITEMS[held.item];\n if (!def || !e.fits(def))\n throw new Error(ct(\"enchantNoFit\", { e: tr(e.name) }));\n const level = Math.max(\n 1,\n Math.min(e.max, Math.floor(Number(lvl ?? 1)) || 1),\n );\n g.inventory.setSlot(\n g.inventory.hotbarIndex,\n withEnchants(held, { [id]: level }),\n );\n return ct(\"enchanted\", { v: enchantLine(id as EnchantId, level) });\n },\n\n locate: (g, [what]) => {\n const kind =\n what === \"fortress\" || what === \"крепость\"\n ? \"fortress\"\n : what === \"деревня\"\n ? \"village\"\n : what === \"город\" || what === \"end_city\"\n ? \"city\"\n : what === \"храм\" ||\n what === \"monument\" ||\n what === \"ocean_monument\"\n ? \"temple\"\n : what === \"корабль\"\n ? \"shipwreck\"\n : what === \"застава\" || what === \"pillager_outpost\"\n ? \"outpost\"\n : what === \"клад\" || what === \"treasure\"\n ? \"buried_treasure\"\n : what === \"особняк\" || what === \"woodland_mansion\"\n ? \"mansion\"\n : what === \"древний\" || what === \"древний_город\"\n ? \"ancient_city\"\n : what === \"руины\" || what === \"ruins\"\n ? \"trail_ruins\"\n : what === \"камеры\" || what === \"trial\"\n ? \"trial_chambers\"\n : what;\n const p = g.player.pos;\n const found = kind ? g.world.generator.locate?.(kind, p.x, p.z) : null;\n if (!found)\n throw new Error(\n kind === \"fortress\" && g.dimension !== \"inferno\"\n ? ct(\"fortressOnly\")\n : ct(\"notFound\", { v: what ?? \"?\" }),\n );\n const d = Math.round(Math.hypot(found.x - p.x, found.z - p.z));\n return ct(\"nearest\", { x: found.x, y: found.y, z: found.z, d });\n },\n\n emote: (g, [id]) => {\n if (!isEmote(id)) return ct(\"emotes\", { v: EMOTE_IDS.join(\", \") });\n g.character.emote(id);\n return \"\";\n },\n feel: (g, [e, sec]) => {\n if (!isEmotion(e)) return ct(\"emotions\", { v: EMOTIONS.join(\", \") });\n g.character.feel(e, sec !== undefined ? Number(sec) || 4 : 8);\n return \"\";\n },\n newworld: (g, [s]) => {\n const seed =\n s !== undefined && Number.isFinite(Number(s))\n ? Number(s) >>> 0\n : (Math.random() * 1e9) >>> 0;\n g.newWorld(seed); // текущий мир остаётся в «Моих мирах»\n return ct(\"newWorld\", { v: seed });\n },\n};\n\nexport class Commands {\n /** Последние строки вывода (консоль игрока и ответ агенту). */\n log: string[] = [];\n\n constructor(private readonly game: Game) {}\n\n /** Исполнить строку команды (с `/` или без). Возвращает вывод. */\n run(text: string): string {\n const parts = text.trim().replace(/^\\//, \"\").split(/\\s+/);\n const name = (parts.shift() ?? \"\").toLowerCase();\n let out: string;\n // сетевая игра: права (оператор / «команды всем») и команды «про мир» — у хоста (net/NetGame)\n const routed = this.game.net?.routeCommand(name, text) ?? null;\n if (!CONFIG.commands.enabled) out = ct(\"off_\");\n else if (routed !== null) out = routed;\n else {\n const h = HANDLERS[name];\n try {\n out = h ? h(this.game, parts) : ct(\"unknown\", { v: name });\n } catch (e) {\n out = ct(\"error\", { v: e instanceof Error ? e.message : String(e) });\n }\n }\n this.log.push(out);\n if (this.log.length > 50) this.log.shift();\n if (out) events.emit(\"chat\", { text: out }); // жест и эмоция отвечают молча\n return out;\n }\n}\n"
|
|
1554
|
+
"content": "// Команды консоли, как в Minecraft (/give, /tp, /time…). Текст приходит интентом Command (из\n// консоли игрока или от агента AI-кодера) и исполняется на тике — тем же путём, что и остальной\n// ввод. Выключаются целиком CONFIG.commands.enabled (издатель может закрыть читы в своей игре).\nimport {\n EMOTE_IDS,\n isEmote,\n EMOTIONS,\n isEmotion,\n} from \"@idosgames/characters/voxel\";\nimport { CONFIG } from \"../config\";\nimport {\n DIFFICULTY_NAMES,\n parseDifficulty,\n rules,\n setDifficulty,\n} from \"./Difficulty\";\nimport { events } from \"../core/EventBus\";\nimport { ALIASES, resolveBlock, resolveItem } from \"../registry/aliases\";\nimport { B, packWater } from \"../registry/Blocks\";\nimport { MOBS } from \"../registry/Mobs\";\nimport { ITEMS } from \"../registry/Items\";\nimport { ENCHANTS, enchantLine, withEnchants, type EnchantId } from \"./Enchant\";\nimport { EFFECT_NAMES, effectLabel, labelOf } from \"../registry/names\";\nimport { startCharge } from \"../entities/AshLordBrain\";\nimport { makeT, tr } from \"../i18n\";\nimport { ACHIEVEMENTS } from \"./Achievements\";\nimport type { Game } from \"../main\";\nimport type { EffectId } from \"../types\";\n\ntype Handler = (game: Game, args: string[]) => string;\n\n// Ответы команд на двух языках. Имена в самих командах (/difficulty мирная, /locate деревня)\n// понимаются на обоих — это ввод игрока, не текст экрана.\nconst EN = {\n noCoords: \"missing coordinates\",\n notNumber: \"not a number: {v}\",\n border: \"outside the world border (±{max}): {v}\",\n noBlock: \"no such block: {v}\",\n help:\n \"Commands: /give <item> [n], /tp x y z, /time set day|noon|night|midnight|<sec>, /weather clear|rain|thunder [sec], \" +\n \"/difficulty peaceful|easy|normal|hard, /gamemode survival|creative, /setblock x y z <block> [meta], /fill x1 y1 z1 x2 y2 z2 <block>, \" +\n \"/summon <mob> [x y z], /emote <emote>, /feel <emotion> [sec], /kill [mob], /effect <effect>|clear [sec] [level], /achievements, /quests [done <id>|reset], /music on|off|next, /clear, /seed, /locate fortress|citadel|village|outpost|temple|shipwreck|buried_treasure|mansion|ancient_city|trail_ruins|trial_chambers|city, /enchant <enchantment> [level], /newworld [seed]. Coordinates may use ~ (relative to you).\",\n noItem: \"no such item: {v}\",\n given: \"Given: {item} ×{n}\",\n teleport: \"Teleport: {x} {y} {z}\",\n timeArg: \"time: day, noon, night, midnight or seconds\",\n timeUsage: \"/time set <day|night|sec> or /time add <sec>\",\n time: \"Time: {t} s\",\n weatherUsage: \"/weather clear|rain|thunder [sec]\",\n weather: \"Weather: {w}\",\n clear: \"clear\",\n rain: \"rain\",\n thunder: \"thunderstorm\",\n mode: \"Mode: {m}\",\n creative: \"creative\",\n survival: \"survival\",\n difficultyUsage: \"/difficulty peaceful|easy|normal|hard (0..3)\",\n difficulty: \"Difficulty: {d}\",\n outside: \"the cell is outside the loaded world\",\n block: \"Block {b} at {x} {y} {z}\",\n tooMany: \"too many blocks: {n} (max 65536)\",\n filled: \"Blocks filled: {n}\",\n achievements: \"Achievements {n}/{total}: {list} (L — the list)\",\n none: \"none yet\",\n questsReset: \"Quests reset\",\n noQuest: \"no such open quest: {v}\",\n questDone: \"Quest completed: {v}\",\n quests: \"Quests {n}/{total}; now: {list} (J — the book)\",\n allDone: \"all done\",\n musicOff: \"Music off\",\n musicNext: \"Next song coming up\",\n musicOn: \"Music on\",\n music: \"Music: {s}\",\n off: \"off\",\n playing: \"playing\",\n waiting: \"waiting for its moment\",\n effectsCleared: \"Effects removed\",\n noEffect: \"no such effect: {v} ({list})\",\n effectArgs: \"time and level must be positive numbers\",\n effect: \"{e} {lvl} for {sec} s\",\n noMob: \"no such creature: {v}\",\n summoned: \"Summoned: {m}\",\n playerDied: \"The player died\",\n killed: \"Killed: {m} × {n}\",\n inventoryCleared: \"Inventory cleared\",\n seed: \"World seed: {v}\",\n emptyHand: \"your hand is empty\",\n noEnchant: \"no such enchantment: {v}\",\n enchantNoFit: \"{e} does not fit this item\",\n enchanted: \"Enchanted: {v}\",\n fortressOnly: \"inferno fortresses exist only in the Inferno\",\n notFound: \"not found: {v}\",\n nearest: \"Nearest: {x} {y} {z} ({d} blocks)\",\n emotes: \"Emotes: {v}\",\n emotions: \"Emotions: {v}\",\n newWorld: \"New world, seed {v}\",\n off_: \"Commands are off in this game\",\n unknown: \"Unknown command: /{v}. /help — the list\",\n error: \"Error: {v}\",\n} as const;\n\nconst ct = makeT<keyof typeof EN>(EN, {\n noCoords: \"не хватает координат\",\n notNumber: \"не число: {v}\",\n border: \"за границей мира (±{max}): {v}\",\n noBlock: \"нет такого блока: {v}\",\n help:\n \"Команды: /give <предмет> [n], /tp x y z, /time set day|noon|night|midnight|<сек>, /weather clear|rain|thunder [сек], \" +\n \"/difficulty мирная|лёгкая|обычная|высокая, /gamemode survival|creative, /setblock x y z <блок> [мета], /fill x1 y1 z1 x2 y2 z2 <блок>, \" +\n \"/summon <моб> [x y z], /emote <жест>, /feel <эмоция> [сек], /kill [моб], /effect <эффект>|clear [сек] [уровень], /achievements, /quests [done <id>|reset], /music on|off|next, /clear, /seed, /locate fortress|citadel|village|outpost|temple|shipwreck|buried_treasure|mansion|ancient_city|trail_ruins|trial_chambers|city, /enchant <зачарование> [уровень], /newworld [сид]. Координаты можно с ~ (относительно себя).\",\n noItem: \"нет такого предмета: {v}\",\n given: \"Выдано: {item} ×{n}\",\n teleport: \"Телепорт: {x} {y} {z}\",\n timeArg: \"время: day, noon, night, midnight или секунды\",\n timeUsage: \"/time set <день|ночь|сек> или /time add <сек>\",\n time: \"Время: {t} c\",\n weatherUsage: \"/weather clear|rain|thunder [сек]\",\n weather: \"Погода: {w}\",\n clear: \"ясно\",\n rain: \"дождь\",\n thunder: \"гроза\",\n mode: \"Режим: {m}\",\n creative: \"креатив\",\n survival: \"выживание\",\n difficultyUsage: \"/difficulty мирная|лёгкая|обычная|высокая (0..3)\",\n difficulty: \"Сложность: {d}\",\n outside: \"клетка вне загруженного мира\",\n block: \"Блок {b} в {x} {y} {z}\",\n tooMany: \"слишком много блоков: {n} (максимум 65536)\",\n filled: \"Заполнено блоков: {n}\",\n achievements: \"Достижения {n}/{total}: {list} (L — список)\",\n none: \"пока нет\",\n questsReset: \"Задания сброшены\",\n noQuest: \"нет такого открытого задания: {v}\",\n questDone: \"Задание выполнено: {v}\",\n quests: \"Задания {n}/{total}; сейчас: {list} (J — книга)\",\n allDone: \"все выполнены\",\n musicOff: \"Музыка выключена\",\n musicNext: \"Сейчас заиграет\",\n musicOn: \"Музыка включена\",\n music: \"Музыка: {s}\",\n off: \"выключена\",\n playing: \"играет\",\n waiting: \"ждёт своего часа\",\n effectsCleared: \"Эффекты сняты\",\n noEffect: \"нет такого эффекта: {v} ({list})\",\n effectArgs: \"время и уровень — положительные числа\",\n effect: \"{e} {lvl} на {sec} с\",\n noMob: \"нет такого существа: {v}\",\n summoned: \"Призвано: {m}\",\n playerDied: \"Игрок погиб\",\n killed: \"Убито: {m} × {n}\",\n inventoryCleared: \"Инвентарь очищен\",\n seed: \"Сид мира: {v}\",\n emptyHand: \"в руке пусто\",\n noEnchant: \"нет такого зачарования: {v}\",\n enchantNoFit: \"{e} не подходит к этому предмету\",\n enchanted: \"Зачаровано: {v}\",\n fortressOnly: \"адские крепости есть только в Пекле\",\n notFound: \"не нашлось: {v}\",\n nearest: \"Ближайшая: {x} {y} {z} ({d} блоков)\",\n emotes: \"Жесты: {v}\",\n emotions: \"Эмоции: {v}\",\n newWorld: \"Новый мир, сид {v}\",\n off_: \"Команды в этой игре выключены\",\n unknown: \"Неизвестная команда: /{v}. /help — список\",\n error: \"Ошибка: {v}\",\n});\n\nconst WORLD_BORDER = 30_000_000;\n\n/** Координата: число или `~`/`~N` относительно игрока. */\nfunction coord(arg: string | undefined, base: number): number {\n if (arg === undefined) throw new Error(ct(\"noCoords\"));\n // `~abc` раньше давал NaN: игрок улетал в NaN-координаты, и мир переставал грузиться насовсем\n const v = arg.startsWith(\"~\")\n ? base + (arg.length > 1 ? Number(arg.slice(1)) : 0)\n : Number(arg);\n if (!Number.isFinite(v)) throw new Error(ct(\"notNumber\", { v: arg }));\n // граница мира, как в Minecraft: на 1e300 шум генератора и ключи чанков теряют точность\n if (Math.abs(v) > WORLD_BORDER)\n throw new Error(ct(\"border\", { max: WORLD_BORDER, v: arg }));\n return v;\n}\n\n/** Три координаты начиная с args[i] (целые — для блоков). */\nfunction xyz(\n game: Game,\n args: string[],\n i: number,\n whole: boolean,\n): [number, number, number] {\n const p = game.player.pos;\n const r = [\n coord(args[i], p.x),\n coord(args[i + 1], p.y),\n coord(args[i + 2], p.z),\n ] as [number, number, number];\n return whole ? (r.map(Math.floor) as [number, number, number]) : r;\n}\n\nfunction blockArg(name: string | undefined): number {\n const id = name === undefined ? null : resolveBlock(name);\n if (id === null) throw new Error(ct(\"noBlock\", { v: String(name) }));\n return id;\n}\n\n/** Мета по умолчанию: жидкость ставится источником (как в Minecraft), иначе 0 — вода с метой 0 утекает. */\nconst defaultMeta = (id: number): number =>\n id === B.water || id === B.lava ? packWater(8, true) : 0;\n\nconst TIMES: Record<string, (d: number, n: number) => number> = {\n day: (d) => d * 0.3,\n noon: (d) => d * 0.5,\n sunset: (d) => d * 0.94,\n night: (d, n) => d + n * 0.3,\n midnight: (d, n) => d + n * 0.5,\n};\n\nconst HANDLERS: Record<string, Handler> = {\n help: () => ct(\"help\"),\n\n give: (g, [name, n]) => {\n const item = name === undefined ? null : resolveItem(name);\n if (!item) throw new Error(ct(\"noItem\", { v: String(name) }));\n const count = Math.max(\n 1,\n Math.min(64 * 36, Math.floor(Number(n ?? 1)) || 1),\n );\n const left = g.inventory.add(item, count);\n if (left > 0) {\n const p = g.player.pos;\n g.entities.spawnDrop(p.x, p.y + 1, p.z, item, left);\n }\n return ct(\"given\", { item: labelOf(item), n: count });\n },\n\n tp: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, false);\n g.warmup(x, z);\n if (g.player.riding) g.player.dismount(); // телепорт высаживает из транспорта, как в Minecraft\n g.player.pos.set(x, y, z);\n g.player.vel.set(0, 0, 0);\n g.player._fallDist = 0;\n return ct(\"teleport\", {\n x: x.toFixed(1),\n y: y.toFixed(1),\n z: z.toFixed(1),\n });\n },\n\n time: (g, [op, v]) => {\n const { dayLength, nightLength } = CONFIG.dayNight;\n const cycle = dayLength + nightLength;\n if (op === \"set\" && v !== undefined) {\n const named = TIMES[v];\n const t = named ? named(dayLength, nightLength) : Number(v);\n if (!Number.isFinite(t)) throw new Error(ct(\"timeArg\"));\n g.dayNight.time = ((t % cycle) + cycle) % cycle;\n } else if (op === \"add\" && v !== undefined) {\n const dt = Number(v);\n if (!Number.isFinite(dt)) throw new Error(ct(\"notNumber\", { v })); // NaN ломал небо и свет\n g.dayNight.time = (((g.dayNight.time + dt) % cycle) + cycle) % cycle;\n } else throw new Error(ct(\"timeUsage\"));\n return ct(\"time\", { t: Math.round(g.dayNight.time) });\n },\n\n weather: (g, [k, sec]) => {\n if (k !== \"clear\" && k !== \"rain\" && k !== \"thunder\")\n throw new Error(ct(\"weatherUsage\"));\n const s = sec === undefined ? undefined : Number(sec);\n if (s !== undefined && !(s > 0))\n throw new Error(ct(\"notNumber\", { v: String(sec) }));\n g.weather.set(k, s);\n return ct(\"weather\", { w: ct(k) });\n },\n\n gamemode: (g, [m]) => {\n const mode =\n m === \"creative\" || m === \"1\" || m === \"c\"\n ? \"creative\"\n : m === \"survival\" || m === \"0\" || m === \"s\"\n ? \"survival\"\n : null;\n if (!mode) throw new Error(\"/gamemode survival|creative\");\n if (g.player.mode !== mode) g.player.toggleMode();\n return ct(\"mode\", { m: ct(mode) });\n },\n\n difficulty: (g, [d]) => {\n const next = parseDifficulty(d);\n if (!next) throw new Error(ct(\"difficultyUsage\"));\n setDifficulty(next);\n // на мирной монстры исчезают сразу, как в Minecraft\n if (!rules().hostiles) g.entities.clearHostiles();\n events.emit(\"difficultyChanged\", { difficulty: next });\n return ct(\"difficulty\", { d: tr(DIFFICULTY_NAMES[next]) });\n },\n\n setblock: (g, args) => {\n const [x, y, z] = xyz(g, args, 0, true);\n const id = blockArg(args[3]);\n const meta =\n args[4] === undefined ? defaultMeta(id) : Number(args[4]) & 255;\n if (!g.world.setBlock(x, y, z, id, meta)) throw new Error(ct(\"outside\"));\n return ct(\"block\", { b: labelOf(args[3]!), x, y, z });\n },\n\n fill: (g, args) => {\n const [x1, y1, z1] = xyz(g, args, 0, true);\n const [x2, y2, z2] = xyz(g, args, 3, true);\n const id = blockArg(args[6]);\n const cells =\n (Math.abs(x2 - x1) + 1) *\n (Math.abs(y2 - y1) + 1) *\n (Math.abs(z2 - z1) + 1);\n if (cells > 65536) throw new Error(ct(\"tooMany\", { n: cells }));\n const meta = defaultMeta(id);\n let n = 0;\n for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++)\n for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++)\n for (let z = Math.min(z1, z2); z <= Math.max(z1, z2); z++)\n if (g.world.setBlock(x, y, z, id, meta)) n++;\n return ct(\"filled\", { n });\n },\n\n achievements: (g) => {\n const got = ACHIEVEMENTS.filter((a) => g.achievements.done.has(a.id)).map(\n (a) => tr(a.title),\n );\n return ct(\"achievements\", {\n n: got.length,\n total: ACHIEVEMENTS.length,\n list: got.join(\", \") || ct(\"none\"),\n });\n },\n\n quests: (g, [op, id]) => {\n const book = g.quests;\n if (op === \"reset\") {\n book.load(null);\n return ct(\"questsReset\");\n }\n if (op === \"done\") {\n const full = id?.includes(\":\") ? id : \"local:\" + id;\n if (!book.complete(full))\n throw new Error(ct(\"noQuest\", { v: String(id) }));\n return ct(\"questDone\", { v: full });\n }\n const done = book.entries().filter((e) => e.done).length;\n const now = book\n .focus()\n .map((e) => `${tr(e.def.title)} (${e.id})`)\n .join(\", \");\n return ct(\"quests\", {\n n: done,\n total: book.entries().length,\n list: now || ct(\"allDone\"),\n });\n },\n\n music: (g, [op]) => {\n if (op === \"off\") {\n g.music.enabled = false;\n g.music.stop();\n return ct(\"musicOff\");\n }\n if (op === \"on\" || op === \"next\") {\n g.music.enabled = true;\n if (op === \"next\") g.music.next();\n return op === \"next\" ? ct(\"musicNext\") : ct(\"musicOn\");\n }\n return ct(\"music\", { s: ct(g.music.status) });\n },\n\n effect: (g, [id, sec, lvl]) => {\n if (id === \"clear\") {\n g.player.clearEffects();\n return ct(\"effectsCleared\");\n }\n if (!id || !EFFECT_NAMES[id])\n throw new Error(\n ct(\"noEffect\", {\n v: String(id),\n list: Object.keys(EFFECT_NAMES).join(\", \"),\n }),\n );\n const seconds = sec === undefined ? 30 : Number(sec),\n level = lvl === undefined ? 1 : Number(lvl);\n if (!(seconds > 0) || !(level >= 1)) throw new Error(ct(\"effectArgs\"));\n g.player.addEffect(id as EffectId, seconds, Math.floor(level));\n return ct(\"effect\", {\n e: effectLabel(id),\n lvl: Math.floor(level),\n sec: seconds,\n });\n },\n\n summon: (g, args) => {\n // Minecraft-имена существ тоже годятся (/summon wither)\n const type =\n args[0] === undefined\n ? undefined\n : MOBS[args[0]]\n ? args[0]\n : ALIASES[args[0].toLowerCase()];\n if (!type || !MOBS[type])\n throw new Error(ct(\"noMob\", { v: String(args[0]) }));\n const [x, y, z] =\n args.length >= 4\n ? xyz(g, args, 1, false)\n : [g.player.pos.x + 2, g.player.pos.y, g.player.pos.z];\n const m = g.entities.spawnMob(type, x, y, z);\n if (m.def.boss === \"ash_lord\") startCharge(m); // как призванный: сперва набирает силу\n return ct(\"summoned\", { m: labelOf(type) });\n },\n\n // без аргумента — игрок; с видом моба — все такие в 64 блоках, обычной смертью (с дропом и распадом)\n kill: (g, [type]) => {\n if (!type) {\n g.player._die();\n return ct(\"playerDied\");\n }\n if (!MOBS[type]) throw new Error(ct(\"noMob\", { v: type }));\n let n = 0;\n for (const e of g.entities.entities.values())\n if (\n e.kind === \"mob\" &&\n !e.dead &&\n e.def.name === type &&\n e.pos.distanceTo(g.player.pos) < 64\n ) {\n e.damage(e.hp);\n n++;\n }\n return ct(\"killed\", { m: labelOf(type), n });\n },\n\n clear: (g) => {\n // карманы и броня; сундук Предела — склад, /clear его не трогает (как в Minecraft)\n g.inventory.clearCarried();\n return ct(\"inventoryCleared\");\n },\n\n seed: (g) => ct(\"seed\", { v: g.world.seed }),\n\n enchant: (g, [id, lvl]) => {\n const held = g.inventory.heldItem();\n if (!held) throw new Error(ct(\"emptyHand\"));\n if (!id || !(id in ENCHANTS))\n throw new Error(ct(\"noEnchant\", { v: id ?? \"?\" }));\n const e = ENCHANTS[id as EnchantId];\n const def = ITEMS[held.item];\n if (!def || !e.fits(def))\n throw new Error(ct(\"enchantNoFit\", { e: tr(e.name) }));\n const level = Math.max(\n 1,\n Math.min(e.max, Math.floor(Number(lvl ?? 1)) || 1),\n );\n g.inventory.setSlot(\n g.inventory.hotbarIndex,\n withEnchants(held, { [id]: level }),\n );\n return ct(\"enchanted\", { v: enchantLine(id as EnchantId, level) });\n },\n\n locate: (g, [what]) => {\n const kind =\n what === \"fortress\" || what === \"крепость\"\n ? \"fortress\"\n : what === \"деревня\"\n ? \"village\"\n : what === \"город\" || what === \"end_city\"\n ? \"city\"\n : what === \"храм\" ||\n what === \"monument\" ||\n what === \"ocean_monument\"\n ? \"temple\"\n : what === \"корабль\"\n ? \"shipwreck\"\n : what === \"застава\" || what === \"pillager_outpost\"\n ? \"outpost\"\n : what === \"клад\" || what === \"treasure\"\n ? \"buried_treasure\"\n : what === \"особняк\" || what === \"woodland_mansion\"\n ? \"mansion\"\n : what === \"древний\" || what === \"древний_город\"\n ? \"ancient_city\"\n : what === \"руины\" || what === \"ruins\"\n ? \"trail_ruins\"\n : what === \"камеры\" || what === \"trial\"\n ? \"trial_chambers\"\n : what;\n const p = g.player.pos;\n const found = kind ? g.world.generator.locate?.(kind, p.x, p.z) : null;\n if (!found)\n throw new Error(\n kind === \"fortress\" && g.dimension !== \"inferno\"\n ? ct(\"fortressOnly\")\n : ct(\"notFound\", { v: what ?? \"?\" }),\n );\n const d = Math.round(Math.hypot(found.x - p.x, found.z - p.z));\n return ct(\"nearest\", { x: found.x, y: found.y, z: found.z, d });\n },\n\n emote: (g, [id]) => {\n if (!isEmote(id)) return ct(\"emotes\", { v: EMOTE_IDS.join(\", \") });\n g.character.emote(id);\n return \"\";\n },\n feel: (g, [e, sec]) => {\n if (!isEmotion(e)) return ct(\"emotions\", { v: EMOTIONS.join(\", \") });\n g.character.feel(e, sec !== undefined ? Number(sec) || 4 : 8);\n return \"\";\n },\n newworld: (g, [s]) => {\n const seed =\n s !== undefined && Number.isFinite(Number(s))\n ? Number(s) >>> 0\n : (Math.random() * 1e9) >>> 0;\n g.newWorld(seed); // текущий мир остаётся в «Моих мирах»\n return ct(\"newWorld\", { v: seed });\n },\n};\n\nexport class Commands {\n /** Последние строки вывода (консоль игрока и ответ агенту). */\n log: string[] = [];\n\n constructor(private readonly game: Game) {}\n\n /** Исполнить строку команды (с `/` или без). Возвращает вывод. */\n run(text: string): string {\n const parts = text.trim().replace(/^\\//, \"\").split(/\\s+/);\n const name = (parts.shift() ?? \"\").toLowerCase();\n let out: string;\n // сетевая игра: права (оператор / «команды всем») и команды «про мир» — у хоста (net/NetGame)\n const routed = this.game.net?.routeCommand(name, text) ?? null;\n if (!CONFIG.commands.enabled) out = ct(\"off_\");\n else if (routed !== null) out = routed;\n else {\n const h = HANDLERS[name];\n try {\n out = h ? h(this.game, parts) : ct(\"unknown\", { v: name });\n } catch (e) {\n out = ct(\"error\", { v: e instanceof Error ? e.message : String(e) });\n }\n }\n this.log.push(out);\n if (this.log.length > 50) this.log.shift();\n if (out) events.emit(\"chat\", { text: out }); // жест и эмоция отвечают молча\n return out;\n }\n}\n"
|
|
1638
1555
|
},
|
|
1639
1556
|
{
|
|
1640
1557
|
"path": "systems/Crafting.ts",
|
|
@@ -1770,7 +1687,7 @@
|
|
|
1770
1687
|
},
|
|
1771
1688
|
{
|
|
1772
1689
|
"path": "types.ts",
|
|
1773
|
-
"content": "// Общие типы, разделяемые между модулями. Плоские data-driven-формы реестров\n// (блоки/предметы/мобы/рецепты), стек инвентаря, форма сейва и полезные алиасы.\nimport type { CharacterSpec } from \"./character/CharacterSpec\";\nimport type { ChunkManager } from \"./world/ChunkManager\";\nimport type { DimensionId } from \"./world/Dimensions\";\nimport type { WorldSpec } from \"./worldgen/WorldSpec\";\n\n/** [x, y, z] — вектор/размер в блоках или пикселях. */\nexport type Vec3 = [number, number, number];\n\n// ---- блоки ------------------------------------------------------------------\n\nexport type ToolType =\n \"pickaxe\" | \"axe\" | \"shovel\" | \"hoe\" | \"sword\" | \"shears\";\n\n/**\n * Форма блока: из неё мешер, коллизия и raycast берут коробки (registry/shapes.ts). Мета блока\n * уточняет форму — сторону, половину, открытость, возраст (registry/meta.ts).\n */\nexport type BlockShape =\n | \"cube\"\n | \"cross\"\n | \"liquid\"\n | \"slab\"\n | \"stairs\"\n | \"torch\"\n | \"door\"\n | \"trapdoor\"\n | \"fence\"\n | \"fence_gate\"\n | \"pane\"\n | \"wall\"\n | \"ladder\"\n | \"scaffold\"\n | \"stand\"\n | \"wall_mounted\"\n | \"carpet\"\n | \"crop\"\n | \"portal\"\n | \"bed\"\n | \"chest\"\n | \"frame\"\n | \"end_portal\"\n | \"lever\"\n | \"button\"\n | \"repeater\"\n | \"piston\"\n | \"piston_head\"\n | \"hopper\"\n | \"comparator\"\n | \"rail\"\n | \"farmland\"\n | \"cactus\"\n | \"snow_layer\"\n | \"lantern\"\n | \"fire\"\n | \"egg\"\n | \"skull\"\n | \"rod\"\n | \"stalk\"\n | \"campfire\"\n | \"candle\"\n | \"pod\";\n\n/** Тайлы атласа для граней блока (имена тайлов). */\nexport interface BlockTex {\n top?: string;\n side?: string;\n bottom?: string;\n front?: string;\n /** Верхняя половина двухблочных форм (дверь) или вторая текстура (открытая печь, спелый посев). */\n upper?: string;\n /** Текстура «включённого» состояния: горящая печь, око в рамке. */\n on?: string;\n /** Плоская иконка предмета, если она не совпадает с гранью (дверь). */\n icon?: string;\n}\n\n/** Что выпадает из блока/моба: предмет, количество (или случайно n..max), опциональный шанс. */\nexport interface BlockDrop {\n item: string;\n n: number;\n max?: number;\n chance?: number;\n}\n\n/** Хук тика блока с поведением (печи/конвейеры — задел автоматизации). */\nexport type BlockTickFn = (\n world: ChunkManager,\n x: number,\n y: number,\n z: number,\n meta: number,\n) => void;\n\nexport interface BlockDef {\n id: number;\n name: string;\n solid: boolean;\n opaque: boolean;\n cross: boolean;\n liquid: boolean;\n /**\n * Твёрдость по шкале Minecraft: время ломания = hardness × (1.5 если добывается, иначе 5) /\n * скорость подходящего инструмента. 0 — мгновенно, Infinity — неломаемый.\n */\n hardness: number;\n tool: ToolType | null;\n drops: BlockDrop[] | null;\n onTick: BlockTickFn | null;\n tex: BlockTex | null;\n /** Репрезентативный цвет блока (0xRRGGBB): подбор блока по пикселю картинки и миникарта. */\n mapColor: number;\n /** Форма (коробки меша/коллизии). */\n shape: BlockShape;\n /** Столб с осью в мете (бревно): 0 — y, 1 — x, 2 — z; торцы смотрят вдоль оси. */\n axis: boolean;\n /** Свечение 0..15 (факел 14, светокамень 15, лава 15). */\n light: number;\n /** Сколько уровней света гасит блок: 15 — непрозрачный, листва 1, вода 2, стекло 0. */\n filter: number;\n /** Стойкость к взрыву (как blast resistance в Minecraft). */\n resistance: number;\n /**\n * Уровень добычи: 0 — дроп при любом способе; N — только инструментом `tool` уровня ≥ N\n * (1 дерево/золото, 2 камень, 3 железо, 4 алмаз, 5 незерит). Иначе блок ломается без дропа.\n */\n harvest: number;\n /** Падает, если под ним пусто (песок, гравий). */\n gravity: boolean;\n /** Горит (огонь распространяется на него). */\n flammable: boolean;\n /** Постановка блока заменяет его (трава, снежный слой, огонь, жидкость). */\n replaceable: boolean;\n /** Тип блочной сущности (сундук, печь…) или null. */\n blockEntity: string | null;\n /** Нет предмета-двойника (воздух, жидкости, огонь, портал, посевы, половинки дверей). */\n noItem: boolean;\n /** Предмет, которым этот блок ставится и который выбирает креатив (семена для посева), если не свой. */\n item: string | null;\n /** Куб, смотрящий в любую из 6 сторон (раздатчик): мета — DIR6, лицо — tex.front. */\n facing6?: boolean;\n /** Стоит в воде (морская трава): для меша, плавания и спавна клетка — вода, поверх — растение. */\n waterlogged?: boolean;\n}\n\n// ---- предметы ---------------------------------------------------------------\n\nexport type ArmorSlot = \"head\" | \"chest\" | \"legs\" | \"feet\";\n\n/**\n * Эффекты игрока: зелья (Сила, Регенерация, Ночное зрение, Скорость, Огнестойкость), маяк (Спешка,\n * Сопротивление, Прыгучесть) и Тление — черепа Пепельного владыки и удар Пепельного скелета.\n */\nexport type EffectId =\n | \"strength\"\n | \"regeneration\"\n | \"night_vision\"\n | \"speed\"\n | \"fire_resistance\"\n | \"haste\"\n | \"resistance\"\n | \"jump_boost\"\n | \"decay\"\n | \"levitation\"\n | \"water_breathing\"\n | \"mining_fatigue\"\n | \"poison\"\n | \"bad_omen\"\n | \"hero\"\n | \"darkness\"\n | \"slowness\"\n | \"hunger\"\n | \"weakness\";\n\n/** Питьё: эффект на seconds секунд уровня level; clearsEffects — снять все (молоко). */\nexport interface DrinkDef {\n effect?: EffectId;\n seconds?: number;\n level?: number;\n clearsEffects?: boolean;\n /** Мгновенно вылечить (Зелье лечения — 4). */\n heal?: number;\n /** Мгновенно ранить (Зелье урона — 6): та же механика наоборот. */\n damage?: number;\n /** Утолить голод (бутылочка мёда — 6): питьё, а не еда — пьётся и сытым. */\n food?: number;\n}\n\nexport interface ItemDef {\n name: string;\n maxStack: number;\n kind: \"block\" | \"material\" | \"tool\" | \"armor\";\n toolType: ToolType | null;\n /** Множитель скорости добычи блоков своего `tool` (как в Minecraft: дерево 2 … золото 12). */\n speed: number;\n damage: number;\n blockId: number | null;\n tile: string | null;\n food: number;\n saturation: number;\n /** Уровень инструмента: 1 дерево/золото, 2 камень, 3 железо, 4 алмаз, 5 незерит. */\n tier: number;\n /** Прочность (использований до поломки); 0 — не ломается. */\n durability: number;\n /** Слот брони и её очки защиты / прочность брони. */\n armorSlot: ArmorSlot | null;\n armor: number;\n toughness: number;\n /** Секунды горения в печи (0 — не топливо). */\n fuel: number;\n /** Что остаётся в сетке/руке после использования (ведро молока → ведро). */\n remainder: string | null;\n /** Пьётся (зелья, молоко): что даёт; после питья остаётся remainder (пузырёк, ведро). */\n drink?: DrinkDef;\n /** Съеденное накладывает эффекты (иглобрюх — Отравление II). */\n eatEffects?: { id: EffectId; seconds: number; level?: number }[];\n // Заполняются buildIcons() из атласа после его создания.\n icon?: HTMLCanvasElement;\n iconURL?: string;\n}\n\n/** Слот инвентаря / крафт-сетки. */\nexport interface ItemStack {\n item: string;\n count: number;\n /** Износ (потрачено прочности) у инструментов и брони. */\n dmg?: number;\n /** Доп. данные предмета (зачарования, имя, содержимое) — задел, формат задаёт владелец поля. */\n tag?: Record<string, unknown>;\n}\n\n// ---- мобы -------------------------------------------------------------------\n\nexport interface MobPart {\n name: string;\n size: Vec3;\n pos: Vec3;\n pivot?: Vec3;\n /** Анимация: legL/legR (шаг), armL/armR, head, wingL/wingR. Одна метка — у скольких угодно частей. */\n anim?: string;\n color: string;\n /** Поворот части в покое (радианы) — руки зомби вперёд. */\n pose?: Vec3;\n /** Светится (глаза Тенехода): не темнеет в темноте. */\n glow?: boolean;\n}\n\nexport type MobCategory = \"passive\" | \"hostile\" | \"neutral\" | \"boss\";\n\nexport interface MobSpawn {\n /**\n * bright — на траве при свете ≥ 9; dark — при свете блоков 0 и небе ночью/в пещере; any — при\n * любом свете (жители Пекла).\n */\n light: \"bright\" | \"dark\" | \"any\";\n /** Только внутри постройки генератора (адская крепость) — WorldGenerator.structureAt. */\n structure?: string;\n /** На каких блоках появляется (мирные — трава). */\n ground?: readonly string[];\n group: [number, number];\n weight: number;\n dimension?: string;\n /** Только не выше этой высоты (слизни — глубоко под землёй). */\n maxY?: number;\n /** Где появляется, если не на полу: в толще воды (спрут, утопленник) или в воздухе (фантом). */\n medium?: \"water\" | \"air\" | \"lava\";\n /** Только когда игрок не спал три ночи (фантом). */\n insomnia?: boolean;\n /** Только у воды (в 4 блоках) — черепахи на пляжах, а не в пустыне. */\n nearWater?: boolean;\n /** Только в этих биомах (WorldGenerator.biomeOf; мир без биомов — везде). */\n biomes?: readonly string[];\n /** Водный — в пещерных озёрах под землёй, а не у поверхности моря (аксолотль). */\n underground?: boolean;\n /** Только если в 4 блоках есть этот блок (аксолотль — мох пышных пещер). */\n nearBlock?: string;\n}\n\nexport interface MobDef {\n name: string;\n /**\n * Свой моб или NPC из рецепта персонажа (entities/characterMobs): вид — CharacterView и Animator\n * вместо parts, поведение — пресет.\n */\n character?: { spec: CharacterSpec; behavior: string };\n hp: number;\n walkSpeed: number;\n fleeSpeed: number;\n box: { w: number; h: number };\n /** Устаревшее (было у курицы): где появляется. Теперь — spawn.ground. */\n spawnOn?: string;\n drops: BlockDrop[];\n parts: MobPart[];\n category: MobCategory;\n chaseSpeed?: number;\n /** С какого расстояния замечает игрока. */\n follow?: number;\n /** effect — что удар накладывает на игрока (Пепельный скелет — Тление на 10 с); knock — сила отброса. */\n attack?: {\n damage: number;\n reach: number;\n cooldown: number;\n effect?: { id: EffectId; seconds: number };\n knock?: number;\n };\n /** Разбойник: без игрока рядом охотится на жителей и голема, в набеге идёт к центру деревни. */\n illager?: boolean;\n /** Ворожей: держит дистанцию, призывает бесят и пускает клыки из земли (entities/IllagerBrain). */\n conjurer?: boolean;\n /** Топчет листву и посевы на пути (громила). */\n tramples?: boolean;\n /** Живёт столько секунд [от, до], потом слабеет по 1 в секунду (бесёнок). */\n lifespan?: [number, number];\n ranged?: {\n damage: number;\n speed: number;\n cooldown: number;\n range: number;\n /** Чем стреляет: стрела (по умолчанию), шар Плакальщика (взрыв), малый шар Пламени (поджигает). */\n projectile?:\n | \"arrow\"\n | \"fireball\"\n | \"small_fireball\"\n | \"potion\"\n | \"skull\"\n | \"bullet\"\n | \"wind_charge\"\n | \"snowball\"\n | \"spit\";\n /** Выстрелов за залп (Пламень — 3). */\n volley?: number;\n /** Что стрела накладывает на игрока (зимогор — Замедление на 30 с). */\n effect?: { id: EffectId; seconds: number };\n };\n /** Летает без гравитации и держится на height блоков выше цели (или пола). */\n flies?: { height: number };\n /** Лава и огонь не жгут (жители Пекла). */\n fireImmune?: boolean;\n /** Ударил игрок — злится вся стая таких же рядом (Мёртвые свинорылы). */\n groupAnger?: boolean;\n /**\n * Босс: своя логика (entities/DragonBrain.ts, entities/AshLordBrain.ts), полоса здоровья, не\n * исчезает издалека, не отбрасывается.\n */\n boss?: \"dragon\" | \"ash_lord\";\n /** Не двигается (Кристалл Предела): ни шагов, ни отброса, ни гравитации. */\n stationary?: boolean;\n /** Глубинник (страж Minecraft): луч с зарядкой, древний ещё и насылает Усталость (entities/DeepBrain). */\n guardian?: \"guardian\" | \"elder\";\n /** Страж: слепой, по вибрациям и запаху, звуковой удар (entities/WardenBrain). */\n warden?: boolean;\n /** Коза: высокие прыжки и таран, теряет рога об камень (entities/GoatBrain). */\n rams?: boolean;\n /** Лягушка: кого ловит языком и что после него остаётся (entities/FrogBrain). */\n tongue?: Record<string, string>;\n /** Нюхач: иногда роет землю и выкапывает древние семена (entities/SnifferBrain). */\n sniffs?: boolean;\n /** Снеговик: кидает снежки во враждебных, оставляет снежный след, тает в жаре (entities/SnowGolemBrain). */\n throwsSnow?: boolean;\n /** Пугливый (оцелот): игрок ближе 7 блоков без приманки в руке — убегает (entities/MobBrain). */\n shy?: boolean;\n /** Свинорыл: ПКМ золотым слитком — бартер (systems/Barter). */\n barters?: boolean;\n /** Не нападает на игрока в золотой броне (свинорыл). */\n goldPeace?: boolean;\n /** growsInto срабатывает только вне Пекла (свинорыл → мёртвый свинорыл, хоглин → зоглин). */\n zombifies?: boolean;\n /** Ходит по лаве, как по полу (страйдер). */\n walksOnLava?: boolean;\n /** Седлается без приручения (страйдер). */\n saddleOnly?: boolean;\n /** Ручной садится игроку на плечо, присел игрок — слетает (попугай; entities/Mob, MobBrain.petThink). */\n shoulderPet?: boolean;\n /** Красится красителем (ПКМ), стрижётся шерстью своего цвета (овца). */\n dyeable?: boolean;\n /** С кем ещё размножается и кто родится: лошадь + осёл → мул (EntityManager.findMate / breed). */\n mates?: Record<string, string>;\n /** Броненосец: игрок бежит рядом или ударили — сворачивается (замирает, урон −3), роняет щитки. */\n rollsUp?: boolean;\n /** Ручному можно надеть броню из щитков — она берёт урон на себя (волк). */\n wearsArmor?: boolean;\n /** Дух-помощник: дали предмет — собирает такие же выпавшие и несёт игроку (entities/AllayBrain). */\n collector?: boolean;\n /** Навьючивается сундуком на столько слотов (осёл, мул, лама); присев, ПКМ — открыть вьюк. */\n chestSlots?: number;\n /** Ходит караваном за своими на поводке (лама). */\n caravan?: boolean;\n /** Уходит (исчезает без дропа) через столько секунд [от, до] — странствующий торговец и его ламы. */\n leavesAfter?: [number, number];\n /** Ночью невидим (зелье невидимости), днём снова виден (молоко) — странствующий торговец. */\n nightInvisible?: boolean;\n /** Вырастает в другой вид через growTime секунд (головастик → лягушка по биому, EntityManager). */\n growsInto?: string;\n growTime?: number;\n /** Шипы: ударивший рукой получает столько урона (глубинник, пока не заряжает луч). */\n thorns?: number;\n /** Рыба: на суше задыхается и бьётся на месте. */\n needsWater?: boolean;\n /** Сколько секунд живёт на суше, прежде чем начать сохнуть (аксолотль — 300); без поля — 5 и бьётся. */\n dryTime?: number;\n /** На кого охотится в воде (аксолотль — рыбы, утопленники, глубинники). */\n hunts?: string[];\n /** Охотится по списку `hunts` и на суше (зомби — на жителей). */\n huntsLand?: boolean;\n /** Ломает двери, упёршись в них (зомби ночной осады). */\n breaksDoors?: boolean;\n /** Убитая им жертва-житель становится зомби-жителем (systems/Zombify). */\n turnsVillagers?: boolean;\n /** Лечится золотым яблоком обратно в жителя (зомби-житель). */\n curable?: boolean;\n /** Иглобрюх: раздувается рядом с игроком (Mob.peek) и отравляет касанием (entities/FishBrain). */\n puffs?: boolean;\n /** Вместо детёныша откладывает яйца-блоки этого вида на песок (черепаха). */\n layEggs?: string;\n /** Что роняет, вырастая из детёныша (черепаха — щиток). */\n growDrop?: string;\n /** Сидит на блоке (панцирник): мозг работает, но ни шагов, ни гравитации, ни отброса. */\n anchored?: boolean;\n /** Панцирник: приоткрывается и стреляет пулями (entities/ShellBrain.ts), закрытый — стрелы отскакивают. */\n shell?: boolean;\n /** Летит сквозь блоки без коллизий (дракон ломает их сам). */\n noClip?: boolean;\n /** Взрыв этой силы при смерти (Кристалл Предела — 6). */\n deathExplode?: number;\n explode?: { power: number; fuse: number; radius: number };\n /** Пчела: цветы, пыльца и свой улей (MobBrain.beeThink). */\n bee?: boolean;\n /** Ужалив, погибает (пчела). */\n diesOnAttack?: boolean;\n /** Плавает: в воде без гравитации, держится толщи (спрут, дельфин, утопленник). */\n swims?: boolean;\n /** Летун пикирует на цель и снова набирает высоту (фантом). */\n swoops?: boolean;\n /** Приручается этим (волк — кость, кошка — рыба), шанс за попытку (tameChance, по умолчанию 1/3). */\n tameItems?: string[];\n tameChance?: number;\n /** На нём ездят (лошадь): высота седока и скорость; приручается попытками сесть, едут в седле. */\n rideable?: { seat: number; speed: number };\n /** Ручной охраняет: бросается на тех, кого бьёт игрок и кто бьёт игрока (волк). */\n guards?: boolean;\n /** Шипуны обходят его стороной (кошка). */\n scaresBombers?: boolean;\n /** Не исчезает вдали от игрока и не занимает лимит монстров (железный голем деревни). */\n persistent?: boolean;\n /** Житель: профессия, по которой торгует (systems/Trading.ts). ПКМ — экран торговли. */\n trades?: string;\n /** Ходит только прыжками (слизень): на земле стоит, в воздухе летит к цели. */\n hops?: boolean;\n /** Умирая, распадается на n мобов вида mob (слизень → слизни поменьше). */\n splitsInto?: { mob: string; n: [number, number] };\n burnsInSun?: boolean;\n climbs?: boolean;\n teleports?: boolean;\n provokedByLook?: boolean;\n /** Нейтрален на свету (паук): нападает в темноте или если его ударили. */\n neutralInLight?: boolean;\n /** Идёт за этой едой в руке игрока и размножается от неё. */\n temptItems?: string[];\n /** Что даёт стрижка ножницами (и смерть нестриженого). */\n shearDrop?: BlockDrop;\n /** Опыт за убийство игроком: [min, max]. */\n xp?: [number, number];\n spawn?: MobSpawn;\n /** Высота глаз — доля высоты коробки. */\n eye?: number;\n}\n\n// ---- рецепты ----------------------------------------------------------------\n\nexport interface RecipeResult {\n item: string;\n count: number;\n}\n\nexport interface ShapelessRecipe {\n shapeless: string[];\n result: RecipeResult;\n}\n\nexport interface ShapedRecipe {\n pattern: (string | null)[][];\n result: RecipeResult;\n}\n\nexport type Recipe = ShapelessRecipe | ShapedRecipe;\n\n// ---- сейв -------------------------------------------------------------------\n\n/**\n * Откуда берётся мир: шум по сиду или рецепт мира (WorldSpec). `gen` — поколение генератора\n * (worldgen/WorldGenerator.ts): нет поля — поколение 1, как у всех сейвов до v3; новые миры по сиду\n * получают CURRENT_GEN. Вывод генератора — часть формата сейва, поэтому поколение пишется в сейв.\n */\nexport type WorldSource =\n | { kind: \"noise\"; seed: number; gen?: number }\n | { kind: \"spec\"; spec: WorldSpec; gen?: number };\n\n/** Дифы блоков по чанкам: \"cx,cz\" -> [[index, id, meta], ...]. */\nexport type ChunkDiffMap = Record<string, Array<[number, number, number]>>;\n\n/** Одно измерение в сейве: дифы блоков, сущности, блочные сущности. */\nexport interface DimensionSave {\n chunks: ChunkDiffMap;\n entities?: unknown[];\n blockEntities?: unknown[];\n}\n\nexport interface InventorySave {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n /** v3: броня — шлем, нагрудник, поножи, ботинки. */\n armor?: (ItemStack | null)[];\n /** Сундук Предела: общий склад игрока (нет поля — пуст). */\n ender?: (ItemStack | null)[];\n}\n\n/**\n * Полный снапшот мира. Вложенные serialize()-блобы player/entities намеренно\n * динамические (проходят JSON round-trip и раздаются в *.deserialize()) — any здесь оправдан.\n */\nexport interface SaveData {\n version: number;\n /** Сид мира. У мира из рецепта — сид рецепта; сейв v1 восстанавливает мир только по нему. */\n seed: number;\n /** v2+: источник мира. Нет поля (сейв v1) = шумовой мир по `seed`. */\n world?: WorldSource;\n time: { time?: number };\n /** v3: погода — ясно, дождь или гроза, и сколько секунд до смены. Нет поля — ясно. */\n weather?: { kind?: string; left?: number };\n /** Уровень сложности (нет поля — обычная). */\n difficulty?: string;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n player: any;\n /** Дифы верхнего мира (у сейвов v1/v2 — единственного). Остальные измерения — в `dimensions`. */\n chunks: ChunkDiffMap;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n entities: any;\n /** v3: id блока → имя на момент сейва. Дифы переносятся по именам и переживают смену id. */\n palette?: string[];\n /** v3: блочные сущности верхнего мира. */\n blockEntities?: unknown[];\n /** v3: измерение, в котором игрок. Нет — верхний мир. */\n dimension?: DimensionId;\n /** v3: остальные измерения (Пекло, Предел), если в них уже бывали. */\n dimensions?: Partial<Record<DimensionId, DimensionSave>>;\n /** v3: известные порталы по измерениям (клетка внутри каждого) — обратный путь к своему порталу. */\n portals?: Partial<Record<DimensionId, [number, number, number][]>>;\n /** v3: Предел — кристаллы и дракон уже поставлены, дракон убит, титры показаны, где Врата. */\n beyond?: {\n initialized?: boolean;\n dragonKilled?: boolean;\n creditsShown?: boolean;\n gateway?: unknown;\n };\n /** v3: деревни (id региона), в которые уже поселены жители — второй раз не селятся. */\n villages?: string[];\n /** v3: открытые достижения (id из systems/Achievements). */\n achievements?: string[];\n /** Книга заданий мира (quests/types QuestSave); разбирает QuestBook.load. */\n quests?: unknown;\n /** Своя карта игрока: разведанная местность и метки (systems/WorldAtlas AtlasSave). */\n atlas?: unknown;\n /** Облики предметов (tag.skin) мира: хэш → рецепт (character/items). */\n skins?: Record<string, unknown>;\n /** NPC рецепта мира, которые ещё не встали в мир (game/npcs). */\n pendingNpcs?: {\n name: string;\n role: string;\n persona: string;\n x: number;\n z: number;\n }[];\n /** v3: карты (systems/Maps) — участок, измерение, пиксели в base64. */\n maps?: unknown[];\n /** v3: сколько ночей игрок не спал (с трёх прилетают фантомы). */\n daysAwake?: number;\n /**\n * Сетевая игра: другие игроки этого мира по userId (позиция, измерение, инвентарь, опыт) —\n * вернувшийся продолжает там же и с тем же. Нет поля — в мире не играли по сети.\n */\n players?: Record<string, unknown>;\n}\n"
|
|
1690
|
+
"content": "// Общие типы, разделяемые между модулями. Плоские data-driven-формы реестров\n// (блоки/предметы/мобы/рецепты), стек инвентаря, форма сейва и полезные алиасы.\nimport type { CharacterSpec } from \"@idosgames/characters/voxel\";\nimport type { ChunkManager } from \"./world/ChunkManager\";\nimport type { DimensionId } from \"./world/Dimensions\";\nimport type { WorldSpec } from \"./worldgen/WorldSpec\";\n\n/** [x, y, z] — вектор/размер в блоках или пикселях. */\nexport type Vec3 = [number, number, number];\n\n// ---- блоки ------------------------------------------------------------------\n\nexport type ToolType =\n \"pickaxe\" | \"axe\" | \"shovel\" | \"hoe\" | \"sword\" | \"shears\";\n\n/**\n * Форма блока: из неё мешер, коллизия и raycast берут коробки (registry/shapes.ts). Мета блока\n * уточняет форму — сторону, половину, открытость, возраст (registry/meta.ts).\n */\nexport type BlockShape =\n | \"cube\"\n | \"cross\"\n | \"liquid\"\n | \"slab\"\n | \"stairs\"\n | \"torch\"\n | \"door\"\n | \"trapdoor\"\n | \"fence\"\n | \"fence_gate\"\n | \"pane\"\n | \"wall\"\n | \"ladder\"\n | \"scaffold\"\n | \"stand\"\n | \"wall_mounted\"\n | \"carpet\"\n | \"crop\"\n | \"portal\"\n | \"bed\"\n | \"chest\"\n | \"frame\"\n | \"end_portal\"\n | \"lever\"\n | \"button\"\n | \"repeater\"\n | \"piston\"\n | \"piston_head\"\n | \"hopper\"\n | \"comparator\"\n | \"rail\"\n | \"farmland\"\n | \"cactus\"\n | \"snow_layer\"\n | \"lantern\"\n | \"fire\"\n | \"egg\"\n | \"skull\"\n | \"rod\"\n | \"stalk\"\n | \"campfire\"\n | \"candle\"\n | \"pod\";\n\n/** Тайлы атласа для граней блока (имена тайлов). */\nexport interface BlockTex {\n top?: string;\n side?: string;\n bottom?: string;\n front?: string;\n /** Верхняя половина двухблочных форм (дверь) или вторая текстура (открытая печь, спелый посев). */\n upper?: string;\n /** Текстура «включённого» состояния: горящая печь, око в рамке. */\n on?: string;\n /** Плоская иконка предмета, если она не совпадает с гранью (дверь). */\n icon?: string;\n}\n\n/** Что выпадает из блока/моба: предмет, количество (или случайно n..max), опциональный шанс. */\nexport interface BlockDrop {\n item: string;\n n: number;\n max?: number;\n chance?: number;\n}\n\n/** Хук тика блока с поведением (печи/конвейеры — задел автоматизации). */\nexport type BlockTickFn = (\n world: ChunkManager,\n x: number,\n y: number,\n z: number,\n meta: number,\n) => void;\n\nexport interface BlockDef {\n id: number;\n name: string;\n solid: boolean;\n opaque: boolean;\n cross: boolean;\n liquid: boolean;\n /**\n * Твёрдость по шкале Minecraft: время ломания = hardness × (1.5 если добывается, иначе 5) /\n * скорость подходящего инструмента. 0 — мгновенно, Infinity — неломаемый.\n */\n hardness: number;\n tool: ToolType | null;\n drops: BlockDrop[] | null;\n onTick: BlockTickFn | null;\n tex: BlockTex | null;\n /** Репрезентативный цвет блока (0xRRGGBB): подбор блока по пикселю картинки и миникарта. */\n mapColor: number;\n /** Форма (коробки меша/коллизии). */\n shape: BlockShape;\n /** Столб с осью в мете (бревно): 0 — y, 1 — x, 2 — z; торцы смотрят вдоль оси. */\n axis: boolean;\n /** Свечение 0..15 (факел 14, светокамень 15, лава 15). */\n light: number;\n /** Сколько уровней света гасит блок: 15 — непрозрачный, листва 1, вода 2, стекло 0. */\n filter: number;\n /** Стойкость к взрыву (как blast resistance в Minecraft). */\n resistance: number;\n /**\n * Уровень добычи: 0 — дроп при любом способе; N — только инструментом `tool` уровня ≥ N\n * (1 дерево/золото, 2 камень, 3 железо, 4 алмаз, 5 незерит). Иначе блок ломается без дропа.\n */\n harvest: number;\n /** Падает, если под ним пусто (песок, гравий). */\n gravity: boolean;\n /** Горит (огонь распространяется на него). */\n flammable: boolean;\n /** Постановка блока заменяет его (трава, снежный слой, огонь, жидкость). */\n replaceable: boolean;\n /** Тип блочной сущности (сундук, печь…) или null. */\n blockEntity: string | null;\n /** Нет предмета-двойника (воздух, жидкости, огонь, портал, посевы, половинки дверей). */\n noItem: boolean;\n /** Предмет, которым этот блок ставится и который выбирает креатив (семена для посева), если не свой. */\n item: string | null;\n /** Куб, смотрящий в любую из 6 сторон (раздатчик): мета — DIR6, лицо — tex.front. */\n facing6?: boolean;\n /** Стоит в воде (морская трава): для меша, плавания и спавна клетка — вода, поверх — растение. */\n waterlogged?: boolean;\n}\n\n// ---- предметы ---------------------------------------------------------------\n\nexport type ArmorSlot = \"head\" | \"chest\" | \"legs\" | \"feet\";\n\n/**\n * Эффекты игрока: зелья (Сила, Регенерация, Ночное зрение, Скорость, Огнестойкость), маяк (Спешка,\n * Сопротивление, Прыгучесть) и Тление — черепа Пепельного владыки и удар Пепельного скелета.\n */\nexport type EffectId =\n | \"strength\"\n | \"regeneration\"\n | \"night_vision\"\n | \"speed\"\n | \"fire_resistance\"\n | \"haste\"\n | \"resistance\"\n | \"jump_boost\"\n | \"decay\"\n | \"levitation\"\n | \"water_breathing\"\n | \"mining_fatigue\"\n | \"poison\"\n | \"bad_omen\"\n | \"hero\"\n | \"darkness\"\n | \"slowness\"\n | \"hunger\"\n | \"weakness\";\n\n/** Питьё: эффект на seconds секунд уровня level; clearsEffects — снять все (молоко). */\nexport interface DrinkDef {\n effect?: EffectId;\n seconds?: number;\n level?: number;\n clearsEffects?: boolean;\n /** Мгновенно вылечить (Зелье лечения — 4). */\n heal?: number;\n /** Мгновенно ранить (Зелье урона — 6): та же механика наоборот. */\n damage?: number;\n /** Утолить голод (бутылочка мёда — 6): питьё, а не еда — пьётся и сытым. */\n food?: number;\n}\n\nexport interface ItemDef {\n name: string;\n maxStack: number;\n kind: \"block\" | \"material\" | \"tool\" | \"armor\";\n toolType: ToolType | null;\n /** Множитель скорости добычи блоков своего `tool` (как в Minecraft: дерево 2 … золото 12). */\n speed: number;\n damage: number;\n blockId: number | null;\n tile: string | null;\n food: number;\n saturation: number;\n /** Уровень инструмента: 1 дерево/золото, 2 камень, 3 железо, 4 алмаз, 5 незерит. */\n tier: number;\n /** Прочность (использований до поломки); 0 — не ломается. */\n durability: number;\n /** Слот брони и её очки защиты / прочность брони. */\n armorSlot: ArmorSlot | null;\n armor: number;\n toughness: number;\n /** Секунды горения в печи (0 — не топливо). */\n fuel: number;\n /** Что остаётся в сетке/руке после использования (ведро молока → ведро). */\n remainder: string | null;\n /** Пьётся (зелья, молоко): что даёт; после питья остаётся remainder (пузырёк, ведро). */\n drink?: DrinkDef;\n /** Съеденное накладывает эффекты (иглобрюх — Отравление II). */\n eatEffects?: { id: EffectId; seconds: number; level?: number }[];\n // Заполняются buildIcons() из атласа после его создания.\n icon?: HTMLCanvasElement;\n iconURL?: string;\n}\n\n/** Слот инвентаря / крафт-сетки. */\nexport interface ItemStack {\n item: string;\n count: number;\n /** Износ (потрачено прочности) у инструментов и брони. */\n dmg?: number;\n /** Доп. данные предмета (зачарования, имя, содержимое) — задел, формат задаёт владелец поля. */\n tag?: Record<string, unknown>;\n}\n\n// ---- мобы -------------------------------------------------------------------\n\nexport interface MobPart {\n name: string;\n size: Vec3;\n pos: Vec3;\n pivot?: Vec3;\n /** Анимация: legL/legR (шаг), armL/armR, head, wingL/wingR. Одна метка — у скольких угодно частей. */\n anim?: string;\n color: string;\n /** Поворот части в покое (радианы) — руки зомби вперёд. */\n pose?: Vec3;\n /** Светится (глаза Тенехода): не темнеет в темноте. */\n glow?: boolean;\n}\n\nexport type MobCategory = \"passive\" | \"hostile\" | \"neutral\" | \"boss\";\n\nexport interface MobSpawn {\n /**\n * bright — на траве при свете ≥ 9; dark — при свете блоков 0 и небе ночью/в пещере; any — при\n * любом свете (жители Пекла).\n */\n light: \"bright\" | \"dark\" | \"any\";\n /** Только внутри постройки генератора (адская крепость) — WorldGenerator.structureAt. */\n structure?: string;\n /** На каких блоках появляется (мирные — трава). */\n ground?: readonly string[];\n group: [number, number];\n weight: number;\n dimension?: string;\n /** Только не выше этой высоты (слизни — глубоко под землёй). */\n maxY?: number;\n /** Где появляется, если не на полу: в толще воды (спрут, утопленник) или в воздухе (фантом). */\n medium?: \"water\" | \"air\" | \"lava\";\n /** Только когда игрок не спал три ночи (фантом). */\n insomnia?: boolean;\n /** Только у воды (в 4 блоках) — черепахи на пляжах, а не в пустыне. */\n nearWater?: boolean;\n /** Только в этих биомах (WorldGenerator.biomeOf; мир без биомов — везде). */\n biomes?: readonly string[];\n /** Водный — в пещерных озёрах под землёй, а не у поверхности моря (аксолотль). */\n underground?: boolean;\n /** Только если в 4 блоках есть этот блок (аксолотль — мох пышных пещер). */\n nearBlock?: string;\n}\n\nexport interface MobDef {\n name: string;\n /**\n * Свой моб или NPC из рецепта персонажа (entities/characterMobs): вид — CharacterView и Animator\n * вместо parts, поведение — пресет.\n */\n character?: { spec: CharacterSpec; behavior: string };\n hp: number;\n walkSpeed: number;\n fleeSpeed: number;\n box: { w: number; h: number };\n /** Устаревшее (было у курицы): где появляется. Теперь — spawn.ground. */\n spawnOn?: string;\n drops: BlockDrop[];\n parts: MobPart[];\n category: MobCategory;\n chaseSpeed?: number;\n /** С какого расстояния замечает игрока. */\n follow?: number;\n /** effect — что удар накладывает на игрока (Пепельный скелет — Тление на 10 с); knock — сила отброса. */\n attack?: {\n damage: number;\n reach: number;\n cooldown: number;\n effect?: { id: EffectId; seconds: number };\n knock?: number;\n };\n /** Разбойник: без игрока рядом охотится на жителей и голема, в набеге идёт к центру деревни. */\n illager?: boolean;\n /** Ворожей: держит дистанцию, призывает бесят и пускает клыки из земли (entities/IllagerBrain). */\n conjurer?: boolean;\n /** Топчет листву и посевы на пути (громила). */\n tramples?: boolean;\n /** Живёт столько секунд [от, до], потом слабеет по 1 в секунду (бесёнок). */\n lifespan?: [number, number];\n ranged?: {\n damage: number;\n speed: number;\n cooldown: number;\n range: number;\n /** Чем стреляет: стрела (по умолчанию), шар Плакальщика (взрыв), малый шар Пламени (поджигает). */\n projectile?:\n | \"arrow\"\n | \"fireball\"\n | \"small_fireball\"\n | \"potion\"\n | \"skull\"\n | \"bullet\"\n | \"wind_charge\"\n | \"snowball\"\n | \"spit\";\n /** Выстрелов за залп (Пламень — 3). */\n volley?: number;\n /** Что стрела накладывает на игрока (зимогор — Замедление на 30 с). */\n effect?: { id: EffectId; seconds: number };\n };\n /** Летает без гравитации и держится на height блоков выше цели (или пола). */\n flies?: { height: number };\n /** Лава и огонь не жгут (жители Пекла). */\n fireImmune?: boolean;\n /** Ударил игрок — злится вся стая таких же рядом (Мёртвые свинорылы). */\n groupAnger?: boolean;\n /**\n * Босс: своя логика (entities/DragonBrain.ts, entities/AshLordBrain.ts), полоса здоровья, не\n * исчезает издалека, не отбрасывается.\n */\n boss?: \"dragon\" | \"ash_lord\";\n /** Не двигается (Кристалл Предела): ни шагов, ни отброса, ни гравитации. */\n stationary?: boolean;\n /** Глубинник (страж Minecraft): луч с зарядкой, древний ещё и насылает Усталость (entities/DeepBrain). */\n guardian?: \"guardian\" | \"elder\";\n /** Страж: слепой, по вибрациям и запаху, звуковой удар (entities/WardenBrain). */\n warden?: boolean;\n /** Коза: высокие прыжки и таран, теряет рога об камень (entities/GoatBrain). */\n rams?: boolean;\n /** Лягушка: кого ловит языком и что после него остаётся (entities/FrogBrain). */\n tongue?: Record<string, string>;\n /** Нюхач: иногда роет землю и выкапывает древние семена (entities/SnifferBrain). */\n sniffs?: boolean;\n /** Снеговик: кидает снежки во враждебных, оставляет снежный след, тает в жаре (entities/SnowGolemBrain). */\n throwsSnow?: boolean;\n /** Пугливый (оцелот): игрок ближе 7 блоков без приманки в руке — убегает (entities/MobBrain). */\n shy?: boolean;\n /** Свинорыл: ПКМ золотым слитком — бартер (systems/Barter). */\n barters?: boolean;\n /** Не нападает на игрока в золотой броне (свинорыл). */\n goldPeace?: boolean;\n /** growsInto срабатывает только вне Пекла (свинорыл → мёртвый свинорыл, хоглин → зоглин). */\n zombifies?: boolean;\n /** Ходит по лаве, как по полу (страйдер). */\n walksOnLava?: boolean;\n /** Седлается без приручения (страйдер). */\n saddleOnly?: boolean;\n /** Ручной садится игроку на плечо, присел игрок — слетает (попугай; entities/Mob, MobBrain.petThink). */\n shoulderPet?: boolean;\n /** Красится красителем (ПКМ), стрижётся шерстью своего цвета (овца). */\n dyeable?: boolean;\n /** С кем ещё размножается и кто родится: лошадь + осёл → мул (EntityManager.findMate / breed). */\n mates?: Record<string, string>;\n /** Броненосец: игрок бежит рядом или ударили — сворачивается (замирает, урон −3), роняет щитки. */\n rollsUp?: boolean;\n /** Ручному можно надеть броню из щитков — она берёт урон на себя (волк). */\n wearsArmor?: boolean;\n /** Дух-помощник: дали предмет — собирает такие же выпавшие и несёт игроку (entities/AllayBrain). */\n collector?: boolean;\n /** Навьючивается сундуком на столько слотов (осёл, мул, лама); присев, ПКМ — открыть вьюк. */\n chestSlots?: number;\n /** Ходит караваном за своими на поводке (лама). */\n caravan?: boolean;\n /** Уходит (исчезает без дропа) через столько секунд [от, до] — странствующий торговец и его ламы. */\n leavesAfter?: [number, number];\n /** Ночью невидим (зелье невидимости), днём снова виден (молоко) — странствующий торговец. */\n nightInvisible?: boolean;\n /** Вырастает в другой вид через growTime секунд (головастик → лягушка по биому, EntityManager). */\n growsInto?: string;\n growTime?: number;\n /** Шипы: ударивший рукой получает столько урона (глубинник, пока не заряжает луч). */\n thorns?: number;\n /** Рыба: на суше задыхается и бьётся на месте. */\n needsWater?: boolean;\n /** Сколько секунд живёт на суше, прежде чем начать сохнуть (аксолотль — 300); без поля — 5 и бьётся. */\n dryTime?: number;\n /** На кого охотится в воде (аксолотль — рыбы, утопленники, глубинники). */\n hunts?: string[];\n /** Охотится по списку `hunts` и на суше (зомби — на жителей). */\n huntsLand?: boolean;\n /** Ломает двери, упёршись в них (зомби ночной осады). */\n breaksDoors?: boolean;\n /** Убитая им жертва-житель становится зомби-жителем (systems/Zombify). */\n turnsVillagers?: boolean;\n /** Лечится золотым яблоком обратно в жителя (зомби-житель). */\n curable?: boolean;\n /** Иглобрюх: раздувается рядом с игроком (Mob.peek) и отравляет касанием (entities/FishBrain). */\n puffs?: boolean;\n /** Вместо детёныша откладывает яйца-блоки этого вида на песок (черепаха). */\n layEggs?: string;\n /** Что роняет, вырастая из детёныша (черепаха — щиток). */\n growDrop?: string;\n /** Сидит на блоке (панцирник): мозг работает, но ни шагов, ни гравитации, ни отброса. */\n anchored?: boolean;\n /** Панцирник: приоткрывается и стреляет пулями (entities/ShellBrain.ts), закрытый — стрелы отскакивают. */\n shell?: boolean;\n /** Летит сквозь блоки без коллизий (дракон ломает их сам). */\n noClip?: boolean;\n /** Взрыв этой силы при смерти (Кристалл Предела — 6). */\n deathExplode?: number;\n explode?: { power: number; fuse: number; radius: number };\n /** Пчела: цветы, пыльца и свой улей (MobBrain.beeThink). */\n bee?: boolean;\n /** Ужалив, погибает (пчела). */\n diesOnAttack?: boolean;\n /** Плавает: в воде без гравитации, держится толщи (спрут, дельфин, утопленник). */\n swims?: boolean;\n /** Летун пикирует на цель и снова набирает высоту (фантом). */\n swoops?: boolean;\n /** Приручается этим (волк — кость, кошка — рыба), шанс за попытку (tameChance, по умолчанию 1/3). */\n tameItems?: string[];\n tameChance?: number;\n /** На нём ездят (лошадь): высота седока и скорость; приручается попытками сесть, едут в седле. */\n rideable?: { seat: number; speed: number };\n /** Ручной охраняет: бросается на тех, кого бьёт игрок и кто бьёт игрока (волк). */\n guards?: boolean;\n /** Шипуны обходят его стороной (кошка). */\n scaresBombers?: boolean;\n /** Не исчезает вдали от игрока и не занимает лимит монстров (железный голем деревни). */\n persistent?: boolean;\n /** Житель: профессия, по которой торгует (systems/Trading.ts). ПКМ — экран торговли. */\n trades?: string;\n /** Ходит только прыжками (слизень): на земле стоит, в воздухе летит к цели. */\n hops?: boolean;\n /** Умирая, распадается на n мобов вида mob (слизень → слизни поменьше). */\n splitsInto?: { mob: string; n: [number, number] };\n burnsInSun?: boolean;\n climbs?: boolean;\n teleports?: boolean;\n provokedByLook?: boolean;\n /** Нейтрален на свету (паук): нападает в темноте или если его ударили. */\n neutralInLight?: boolean;\n /** Идёт за этой едой в руке игрока и размножается от неё. */\n temptItems?: string[];\n /** Что даёт стрижка ножницами (и смерть нестриженого). */\n shearDrop?: BlockDrop;\n /** Опыт за убийство игроком: [min, max]. */\n xp?: [number, number];\n spawn?: MobSpawn;\n /** Высота глаз — доля высоты коробки. */\n eye?: number;\n}\n\n// ---- рецепты ----------------------------------------------------------------\n\nexport interface RecipeResult {\n item: string;\n count: number;\n}\n\nexport interface ShapelessRecipe {\n shapeless: string[];\n result: RecipeResult;\n}\n\nexport interface ShapedRecipe {\n pattern: (string | null)[][];\n result: RecipeResult;\n}\n\nexport type Recipe = ShapelessRecipe | ShapedRecipe;\n\n// ---- сейв -------------------------------------------------------------------\n\n/**\n * Откуда берётся мир: шум по сиду или рецепт мира (WorldSpec). `gen` — поколение генератора\n * (worldgen/WorldGenerator.ts): нет поля — поколение 1, как у всех сейвов до v3; новые миры по сиду\n * получают CURRENT_GEN. Вывод генератора — часть формата сейва, поэтому поколение пишется в сейв.\n */\nexport type WorldSource =\n | { kind: \"noise\"; seed: number; gen?: number }\n | { kind: \"spec\"; spec: WorldSpec; gen?: number };\n\n/** Дифы блоков по чанкам: \"cx,cz\" -> [[index, id, meta], ...]. */\nexport type ChunkDiffMap = Record<string, Array<[number, number, number]>>;\n\n/** Одно измерение в сейве: дифы блоков, сущности, блочные сущности. */\nexport interface DimensionSave {\n chunks: ChunkDiffMap;\n entities?: unknown[];\n blockEntities?: unknown[];\n}\n\nexport interface InventorySave {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n /** v3: броня — шлем, нагрудник, поножи, ботинки. */\n armor?: (ItemStack | null)[];\n /** Сундук Предела: общий склад игрока (нет поля — пуст). */\n ender?: (ItemStack | null)[];\n}\n\n/**\n * Полный снапшот мира. Вложенные serialize()-блобы player/entities намеренно\n * динамические (проходят JSON round-trip и раздаются в *.deserialize()) — any здесь оправдан.\n */\nexport interface SaveData {\n version: number;\n /** Сид мира. У мира из рецепта — сид рецепта; сейв v1 восстанавливает мир только по нему. */\n seed: number;\n /** v2+: источник мира. Нет поля (сейв v1) = шумовой мир по `seed`. */\n world?: WorldSource;\n time: { time?: number };\n /** v3: погода — ясно, дождь или гроза, и сколько секунд до смены. Нет поля — ясно. */\n weather?: { kind?: string; left?: number };\n /** Уровень сложности (нет поля — обычная). */\n difficulty?: string;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n player: any;\n /** Дифы верхнего мира (у сейвов v1/v2 — единственного). Остальные измерения — в `dimensions`. */\n chunks: ChunkDiffMap;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n entities: any;\n /** v3: id блока → имя на момент сейва. Дифы переносятся по именам и переживают смену id. */\n palette?: string[];\n /** v3: блочные сущности верхнего мира. */\n blockEntities?: unknown[];\n /** v3: измерение, в котором игрок. Нет — верхний мир. */\n dimension?: DimensionId;\n /** v3: остальные измерения (Пекло, Предел), если в них уже бывали. */\n dimensions?: Partial<Record<DimensionId, DimensionSave>>;\n /** v3: известные порталы по измерениям (клетка внутри каждого) — обратный путь к своему порталу. */\n portals?: Partial<Record<DimensionId, [number, number, number][]>>;\n /** v3: Предел — кристаллы и дракон уже поставлены, дракон убит, титры показаны, где Врата. */\n beyond?: {\n initialized?: boolean;\n dragonKilled?: boolean;\n creditsShown?: boolean;\n gateway?: unknown;\n };\n /** v3: деревни (id региона), в которые уже поселены жители — второй раз не селятся. */\n villages?: string[];\n /** v3: открытые достижения (id из systems/Achievements). */\n achievements?: string[];\n /** Книга заданий мира (quests/types QuestSave); разбирает QuestBook.load. */\n quests?: unknown;\n /** Своя карта игрока: разведанная местность и метки (systems/WorldAtlas AtlasSave). */\n atlas?: unknown;\n /** Облики предметов (tag.skin) мира: хэш → рецепт (character/items). */\n skins?: Record<string, unknown>;\n /** NPC рецепта мира, которые ещё не встали в мир (game/npcs). */\n pendingNpcs?: {\n name: string;\n role: string;\n persona: string;\n x: number;\n z: number;\n }[];\n /** v3: карты (systems/Maps) — участок, измерение, пиксели в base64. */\n maps?: unknown[];\n /** v3: сколько ночей игрок не спал (с трёх прилетают фантомы). */\n daysAwake?: number;\n /**\n * Сетевая игра: другие игроки этого мира по userId (позиция, измерение, инвентарь, опыт) —\n * вернувшийся продолжает там же и с тем же. Нет поля — в мире не играли по сети.\n */\n players?: Record<string, unknown>;\n}\n"
|
|
1774
1691
|
},
|
|
1775
1692
|
{
|
|
1776
1693
|
"path": "ui/AchievementsView.ts",
|
|
@@ -1802,11 +1719,11 @@
|
|
|
1802
1719
|
},
|
|
1803
1720
|
{
|
|
1804
1721
|
"path": "ui/charLabels.ts",
|
|
1805
|
-
"content": "// Подписи персонажа на языке игры: шаблоны скелета, демонстрации, жесты, эмоции, слоты надеваемого.\n// Русские подписи живут в своих реестрах (rigs, PREVIEW_STATES, EMOTES) — здесь английские к ним.\nimport { lang } from \"../i18n\";\nimport {
|
|
1722
|
+
"content": "// Подписи персонажа на языке игры: шаблоны скелета, демонстрации, жесты, эмоции, слоты надеваемого.\n// Русские подписи живут в своих реестрах (rigs, PREVIEW_STATES, EMOTES) — здесь английские к ним.\nimport { lang } from \"../i18n\";\nimport {\n RIGS,\n type RigId,\n EMOTES,\n type EmoteId,\n type Emotion,\n} from \"@idosgames/characters/voxel\";\nimport {\n PREVIEW_STATES,\n type PreviewState,\n} from \"../character/CharacterPreview\";\nimport type { WearSlot } from \"../character/items/ItemSkinSpec\";\n\nconst RIG_EN: Record<RigId, string> = {\n humanoid: \"Human\",\n quadruped: \"Four-legged\",\n flyer: \"Winged\",\n blob: \"Blob\",\n};\n\nconst STATE_EN: Record<PreviewState, string> = {\n idle: \"Standing\",\n walk: \"Walking\",\n run: \"Running\",\n sneak: \"Sneaking\",\n swim: \"Swimming\",\n fly: \"Flying\",\n glide: \"Gliding\",\n sit: \"Sitting\",\n sleep: \"Sleeping\",\n dead: \"Defeated\",\n};\n\nconst EMOTE_EN: Record<EmoteId, string> = {\n wave: \"Wave\",\n cheer: \"Cheer!\",\n dance: \"Dance\",\n point: \"Point\",\n laugh: \"Laugh\",\n cry: \"Cry\",\n bow: \"Bow\",\n shrug: \"Shrug\",\n facepalm: \"Facepalm\",\n sit: \"Sit\",\n};\n\nconst EMOTION: Record<Emotion, [en: string, ru: string]> = {\n neutral: [\"calm\", \"спокойствие\"],\n happy: [\"joy\", \"радость\"],\n sad: [\"sadness\", \"грусть\"],\n angry: [\"anger\", \"злость\"],\n surprised: [\"surprise\", \"удивление\"],\n scared: [\"fear\", \"страх\"],\n tired: [\"tiredness\", \"усталость\"],\n hurt: [\"pain\", \"боль\"],\n};\n\nconst SLOT: Record<WearSlot, [en: string, ru: string]> = {\n head: [\"on the head\", \"на голову\"],\n face: [\"on the face\", \"на лицо\"],\n back: [\"on the back\", \"на спину\"],\n shoulders: [\"on the shoulders\", \"на плечи\"],\n waist: [\"on the waist\", \"на пояс\"],\n};\n\nconst ru = () => lang() === \"ru\";\n\nexport function rigLabel(id: RigId): string {\n return ru() ? (RIGS[id]?.label ?? id) : (RIG_EN[id] ?? id);\n}\n\nexport function stateLabel(id: PreviewState): string {\n return ru() ? PREVIEW_STATES[id].label : STATE_EN[id];\n}\n\nexport function emoteLabel(id: EmoteId): string {\n return ru() ? EMOTES[id].label : (EMOTE_EN[id] ?? id);\n}\n\nexport function emotionLabel(e: Emotion): string {\n return EMOTION[e][ru() ? 1 : 0];\n}\n\nexport function slotLabel(s: WearSlot): string {\n return SLOT[s][ru() ? 1 : 0];\n}\n"
|
|
1806
1723
|
},
|
|
1807
1724
|
{
|
|
1808
1725
|
"path": "ui/ChatConsole.ts",
|
|
1809
|
-
"content": "// Чат/консоль: T — открыть строку, / — открыть сразу с «/». Строка с «/» уходит командой\n// (интент Command → systems/Commands), остальное — реплика в чат (одиночная игра — только эхо).\n// Лог показывает последние строки и гаснет сам; открытая консоль показывает всю историю.\nimport { emotionFromText } from \"
|
|
1726
|
+
"content": "// Чат/консоль: T — открыть строку, / — открыть сразу с «/». Строка с «/» уходит командой\n// (интент Command → systems/Commands), остальное — реплика в чат (одиночная игра — только эхо).\n// Лог показывает последние строки и гаснет сам; открытая консоль показывает всю историю.\nimport { emotionFromText } from \"@idosgames/characters/voxel\";\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport type { Game } from \"../main\";\nimport { tr } from \"../i18n\";\n\nconst MAX_LINES = 30;\n\nexport class ChatConsole {\n open = false;\n readonly root: HTMLDivElement;\n private readonly logEl: HTMLDivElement;\n private readonly input: HTMLInputElement;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"chat\";\n this.logEl = document.createElement(\"div\");\n this.logEl.className = \"chatLog\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 256;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.logEl, this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openConsole\", (d?: { prefix?: string }) =>\n this.show(d?.prefix ?? \"\"),\n );\n events.on(\"chat\", ({ text }: { text: string }) => this.print(text));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n // клавиши строки — не игре (Esc не должен ставить паузу, E — открывать инвентарь)\n e.stopPropagation();\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(prefix = \"\"): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open) return;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = prefix;\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n private submit(): void {\n const text = this.input.value.trim();\n this.hide();\n if (!text) return;\n if (text.startsWith(\"/\")) {\n this.game.intents.push(Intent.Command, { text });\n return;\n }\n // смайлик — эмоция на лице своего персонажа (у других — из их чата, net onChat)\n const feel = emotionFromText(text);\n if (feel) this.game.character.actor.setEmotion(feel, 4);\n if (this.game.net)\n this.game.net.sendChat(text); // сетевая игра: реплика — всем\n else this.print(tr({ en: \"<You> \", ru: \"<Вы> \" }) + text);\n }\n\n print(text: string): void {\n const line = document.createElement(\"div\");\n line.className = \"chatLine\";\n line.textContent = text;\n this.logEl.appendChild(line);\n while (this.logEl.children.length > MAX_LINES)\n this.logEl.firstElementChild!.remove();\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1810
1727
|
},
|
|
1811
1728
|
{
|
|
1812
1729
|
"path": "ui/creativePalette.ts",
|
|
@@ -1814,7 +1731,7 @@
|
|
|
1814
1731
|
},
|
|
1815
1732
|
{
|
|
1816
1733
|
"path": "ui/EmoteWheel.ts",
|
|
1817
|
-
"content": "// Жесты и эмоции персонажа: G (или кнопка на тач-экране) открывает панель — жест играет персонаж,\n// эмоция ставит лицо на время. Видят все в сетевой игре (game/playerCharacter → net EMOTE).\n// Список — из реестров (character/anim/emotes, emotions): новый жест появляется здесь сам.\nimport { events } from \"../core/EventBus\";\nimport { EMOTE_IDS } from \"
|
|
1734
|
+
"content": "// Жесты и эмоции персонажа: G (или кнопка на тач-экране) открывает панель — жест играет персонаж,\n// эмоция ставит лицо на время. Видят все в сетевой игре (game/playerCharacter → net EMOTE).\n// Список — из реестров (character/anim/emotes, emotions): новый жест появляется здесь сам.\nimport { events } from \"../core/EventBus\";\nimport { EMOTE_IDS, type Emotion } from \"@idosgames/characters/voxel\";\nimport { tr } from \"../i18n\";\nimport { emoteLabel, emotionLabel } from \"./charLabels\";\nimport type { Game } from \"../main\";\n\n/** Эмоции на панели (neutral — «снять»; hurt — только от урона). */\nconst FEELINGS: [Emotion, string][] = [\n [\"happy\", \"😊\"],\n [\"sad\", \"😢\"],\n [\"angry\", \"😠\"],\n [\"surprised\", \"😮\"],\n [\"scared\", \"😱\"],\n [\"tired\", \"😩\"],\n [\"neutral\", \"😐\"],\n];\n\nconst EMOTE_ICONS: Record<string, string> = {\n wave: \"👋\",\n cheer: \"🙌\",\n dance: \"💃\",\n point: \"👉\",\n laugh: \"😂\",\n cry: \"😭\",\n bow: \"🙇\",\n shrug: \"🤷\",\n facepalm: \"🤦\",\n sit: \"🪑\",\n};\n\nexport class EmoteWheel {\n open = false;\n readonly root: HTMLDivElement;\n private readonly panel: HTMLDivElement;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"emoteWheel\";\n this.root.className = \"hidden\";\n this.panel = document.createElement(\"div\");\n this.panel.className = \"emotePanel\";\n this.root.append(this.panel);\n this.root.addEventListener(\n \"mousedown\",\n (e) => {\n if (e.target === this.root) this.hide();\n },\n { signal },\n );\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:emotes\", () => (this.open ? this.hide() : this.show()));\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (!this.open) return;\n if (e.code === \"Escape\" || (e.code === \"KeyG\" && !e.repeat)) {\n e.stopPropagation();\n this.hide();\n }\n },\n { signal, capture: true },\n );\n }\n\n private button(icon: string, label: string, act: () => void): HTMLElement {\n const b = document.createElement(\"button\");\n b.className = \"emoteBtn\";\n b.innerHTML = `<span class=\"emoteIcon\"></span><span class=\"emoteLabel\"></span>`;\n (b.firstElementChild as HTMLElement).textContent = icon;\n (b.lastElementChild as HTMLElement).textContent = label;\n b.onclick = () => {\n act();\n this.hide();\n };\n return b;\n }\n\n /** Панель на текущем языке — собирается при каждом показе. */\n private fill(): void {\n const g = this.game;\n const panel = this.panel;\n panel.innerHTML = \"\";\n const title = document.createElement(\"h3\");\n title.textContent = tr({ en: \"Emotes\", ru: \"Жесты\" });\n panel.append(title);\n const grid = document.createElement(\"div\");\n grid.className = \"emoteGrid\";\n for (const id of EMOTE_IDS)\n grid.append(\n this.button(EMOTE_ICONS[id] ?? \"✨\", emoteLabel(id), () =>\n g.character.emote(id),\n ),\n );\n panel.append(grid);\n const t2 = document.createElement(\"h3\");\n t2.textContent = tr({ en: \"Emotions\", ru: \"Эмоции\" });\n panel.append(t2);\n const grid2 = document.createElement(\"div\");\n grid2.className = \"emoteGrid\";\n for (const [e, icon] of FEELINGS)\n grid2.append(\n this.button(icon, emotionLabel(e), () =>\n g.character.feel(e, e === \"neutral\" ? 0.01 : 8),\n ),\n );\n panel.append(grid2);\n const hint = document.createElement(\"div\");\n hint.className = \"emoteHint\";\n hint.textContent = tr({\n en: \"G — close · F5 — look at yourself\",\n ru: \"G — закрыть · F5 — посмотреть на себя\",\n });\n panel.append(hint);\n }\n\n show(): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n if (g.player.dead) return;\n this.fill();\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.remove(\"hidden\");\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.root.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1818
1735
|
},
|
|
1819
1736
|
{
|
|
1820
1737
|
"path": "ui/HUD.ts",
|
|
@@ -1922,7 +1839,7 @@
|
|
|
1922
1839
|
},
|
|
1923
1840
|
{
|
|
1924
1841
|
"path": "workshopCharacters.ts",
|
|
1925
|
-
"content": "import type {\n CapturedContent,\n ContentTypeHandler,\n DownloadedContent,\n LocalContentItem,\n} from \"@idosgames/module-sdk\";\nimport type { Game } from \"./main\";\nimport { CharacterStore } from \"./systems/CharacterStore\";\nimport {\n parseCharacterSpec,\n specHash,\n type CharacterSpec,\n} from \"
|
|
1842
|
+
"content": "import type {\n CapturedContent,\n ContentTypeHandler,\n DownloadedContent,\n LocalContentItem,\n} from \"@idosgames/module-sdk\";\nimport type { Game } from \"./main\";\nimport { CharacterStore } from \"./systems/CharacterStore\";\nimport {\n parseCharacterSpec,\n specHash,\n type CharacterSpec,\n isDefaultCharacterName,\n} from \"@idosgames/characters/voxel\";\nimport { CharacterPreview } from \"./character/CharacterPreview\";\nimport { parseItemSkin } from \"./character/items/ItemSkinSpec\";\nimport { registerSkin } from \"./character/items/skins\";\nimport { buildSkinMesh } from \"./character/items/skinModel\";\nimport { FEATURE } from \"./lobby/ids\";\nimport { tr } from \"./i18n\";\nimport { rigLabel } from \"./ui/charLabels\";\n\n// Персонажи и облики предметов VoxelCraft в Мастерской: типы \"voxelcraft.character\" и\n// \"voxelcraft.item\". Как у миров (workshop.ts): Мастерская общая и про персонажей ничего не знает —\n// отсюда она берёт «Мои персонажи / предметы», файл публикации (это сам РЕЦЕПТ, второго формата нет)\n// и как открыть скачанное: оно ложится в «Мои» и открывается вкладка лобби «Шкафчик».\n//\n// ⚠ Типы должны быть заведены в конфиге тайтла (Workshop.ContentTypes[\"voxelcraft.character\"] и\n// [\"voxelcraft.item\"]) с ролью \"main\" формата application/json — иначе сервер откажет в публикации.\n\nexport const VOXEL_CHARACTER_CONTENT_TYPE = \"voxelcraft.character\";\nexport const VOXEL_ITEM_CONTENT_TYPE = \"voxelcraft.item\";\n\nasync function withStore<T>(\n game: Game | null,\n work: (store: CharacterStore) => Promise<T>,\n): Promise<T> {\n const live = game ? await game.character.store() : null;\n if (live) return work(live);\n const own = await CharacterStore.open();\n if (!own)\n throw new Error(\n tr({\n en: \"This browser does not let the game store characters (private mode or storage blocked).\",\n ru: \"Этот браузер не даёт хранить персонажей (приватный режим или запрет хранилища).\",\n }),\n );\n try {\n return await work(own);\n } finally {\n own.close();\n }\n}\n\nfunction readJson(content: DownloadedContent): unknown {\n const main = content.files.find((f) => f.role === \"main\") ?? content.files[0];\n if (!main)\n throw new Error(\n tr({ en: \"The publication has no file.\", ru: \"В публикации нет файла.\" }),\n );\n try {\n return JSON.parse(new TextDecoder().decode(main.data));\n } catch {\n throw new Error(\n tr({\n en: \"The publication file is not JSON.\",\n ru: \"Файл публикации — не JSON.\",\n }),\n );\n }\n}\n\n/** Превью персонажа — кадр вертушки (вид спереди-сбоку). */\nasync function characterThumb(\n spec: CharacterSpec,\n): Promise<CapturedContent[\"thumbnail\"]> {\n if (typeof document === \"undefined\") return undefined;\n const canvas = document.createElement(\"canvas\");\n canvas.width = 192;\n canvas.height = 256;\n canvas.style.cssText = \"position:fixed;left:-9999px;width:192px;height:256px\";\n document.body.appendChild(canvas);\n const p = new CharacterPreview(canvas, spec);\n try {\n p.autoRotate = false;\n p.render(0.1);\n const blob = await p.snapshot(\"image/webp\");\n if (!blob) return undefined;\n return {\n contentType: blob.type === \"image/png\" ? \"image/png\" : \"image/webp\",\n data: blob,\n };\n } catch {\n return undefined; // превью — украшение\n } finally {\n p.dispose();\n canvas.remove();\n }\n}\n\nexport function createCharacterContentType(\n getGame: () => Game | null,\n): ContentTypeHandler {\n return {\n type: VOXEL_CHARACTER_CONTENT_TYPE,\n label: tr({ en: \"VoxelCraft characters\", ru: \"Персонажи VoxelCraft\" }),\n icon: \"🧍\",\n modeId: \"voxelcraft\",\n async listLocal(): Promise<LocalContentItem[]> {\n return withStore(getGame(), async (s) =>\n (await s.characters()).map((c) => ({\n id: c.id,\n name: c.name,\n updatedAt: c.updatedAt,\n subtitle: rigLabel(c.spec.rig),\n })),\n );\n },\n async capture(localId: string): Promise<CapturedContent> {\n const c = await withStore(getGame(), (s) => s.getCharacter(localId));\n if (!c)\n throw new Error(\n tr({\n en: \"The character was not found in «My characters».\",\n ru: \"Персонаж не найден в «Моих персонажах».\",\n }),\n );\n return {\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(c.spec),\n },\n ],\n thumbnail: await characterThumb(c.spec),\n suggestedTitle: c.name,\n metadata: { rig: c.spec.rig },\n };\n },\n async open(content: DownloadedContent): Promise<void> {\n const r = parseCharacterSpec(readJson(content));\n if (!r.ok)\n throw new Error(\n tr({\n en: \"This is not a VoxelCraft character.\",\n ru: \"Это не персонаж VoxelCraft.\",\n }),\n );\n const spec =\n content.title?.trim() && isDefaultCharacterName(r.spec.name)\n ? { ...r.spec, name: content.title.trim().slice(0, 40) }\n : r.spec;\n const game = getGame();\n if (game) {\n await game.character.saveCharacter(spec);\n game.hub.openTab(FEATURE.locker);\n return;\n }\n await withStore(null, (s) =>\n s.putCharacter({\n id: specHash(spec),\n name: spec.name,\n updatedAt: Date.now(),\n spec,\n }),\n );\n },\n };\n}\n\nexport function createItemContentType(\n getGame: () => Game | null,\n): ContentTypeHandler {\n return {\n type: VOXEL_ITEM_CONTENT_TYPE,\n label: tr({ en: \"VoxelCraft items\", ru: \"Предметы VoxelCraft\" }),\n icon: \"🗡️\",\n modeId: \"voxelcraft\",\n async listLocal(): Promise<LocalContentItem[]> {\n return withStore(getGame(), async (s) =>\n (await s.items()).map((i) => ({\n id: i.id,\n name: i.name,\n updatedAt: i.updatedAt,\n subtitle:\n i.spec.kind === \"held\"\n ? tr({ en: \"item look\", ru: \"облик предмета\" })\n : tr({ en: \"wearable\", ru: \"надеваемое\" }),\n })),\n );\n },\n async capture(localId: string): Promise<CapturedContent> {\n const it = await withStore(getGame(), async (s) =>\n (await s.items()).find((x) => x.id === localId),\n );\n if (!it)\n throw new Error(\n tr({\n en: \"The item was not found in «My items».\",\n ru: \"Предмет не найден в «Моих предметах».\",\n }),\n );\n return {\n files: [\n {\n role: \"main\",\n contentType: \"application/json\",\n data: JSON.stringify(it.spec),\n },\n ],\n thumbnail: await itemThumb(it.spec),\n suggestedTitle: it.name,\n metadata:\n it.spec.kind === \"held\"\n ? { kind: \"held\", base: it.spec.base ?? \"\" }\n : { kind: \"wearable\", slot: it.spec.slot ?? \"\" },\n };\n },\n async open(content: DownloadedContent): Promise<void> {\n const r = parseItemSkin(readJson(content));\n if (!r.ok)\n throw new Error(\n tr({\n en: \"This is not a VoxelCraft item.\",\n ru: \"Это не предмет VoxelCraft.\",\n }),\n );\n const game = getGame();\n if (game) {\n await game.character.saveItem(r.spec);\n game.hub.openTab(FEATURE.locker);\n return;\n }\n const hash = registerSkin(r.spec).hash;\n await withStore(null, (s) =>\n s.putItem({\n id: hash,\n name: r.spec.name,\n updatedAt: Date.now(),\n spec: r.spec,\n }),\n );\n },\n };\n}\n\n/** Превью предмета — его иконка, увеличенная без размытия. */\nasync function itemThumb(\n spec: Parameters<typeof buildSkinMesh>[0],\n): Promise<CapturedContent[\"thumbnail\"]> {\n if (typeof document === \"undefined\") return undefined;\n try {\n const { icon } = buildSkinMesh(spec);\n const src = document.createElement(\"canvas\");\n src.width = src.height = icon.side;\n src\n .getContext(\"2d\")!\n .putImageData(new ImageData(icon.data, icon.side), 0, 0);\n const c = document.createElement(\"canvas\");\n c.width = c.height = 256;\n const g = c.getContext(\"2d\")!;\n g.imageSmoothingEnabled = false;\n g.drawImage(src, 0, 0, 256, 256);\n const blob = await new Promise<Blob | null>((r) =>\n c.toBlob(r, \"image/png\"),\n );\n return blob ? { contentType: \"image/png\", data: blob } : undefined;\n } catch {\n return undefined;\n }\n}\n"
|
|
1926
1843
|
},
|
|
1927
1844
|
{
|
|
1928
1845
|
"path": "world/Beacon.ts",
|
|
@@ -2402,7 +2319,7 @@
|
|
|
2402
2319
|
},
|
|
2403
2320
|
{
|
|
2404
2321
|
"path": "worldgen/WorldSpec.ts",
|
|
2405
|
-
"content": "// Рецепт мира (WorldSpec) — компактный JSON, по которому SpecGenerator детерминированно строит мир.\n//\n// Зачем рецепт, а не блоки: мир на 256×256×64 — это четыре миллиона блоков; ИИ такое не выдаст, а\n// рецепт на пару килобайт — выдаст. Тот же рецепт собирается и без ИИ (картинка разбирается прямо\n// в браузере, текст — по ключевым словам) и пишется руками или агентом. Один генератор на всё.\n//\n// Координаты в рецепте НОРМИРОВАНЫ (0..1 от ширины/глубины мира): рецепт не зависит от размера,\n// и модели не надо считать блоки. Каждое поле необязательно: parseWorldSpec() дополняет\n// значениями по умолчанию, зажимает числа в допустимые границы и отбрасывает мусор — это\n// единственная дверь, через которую рецепт попадает в игру (ИИ, файл, сейв, агент).\nimport { BLOCKS, LEGACY_BLOCK_COUNT, WOOL_BLOCKS } from \"../registry/Blocks\";\nimport { hashString } from \"../core/Noise\";\nimport { CONFIG } from \"../config\";\nimport { tr, type Loc } from \"../i18n\";\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/**\n * Палитра рецепта — только блоки, что были до сейва v3. Системный промпт ИИ живёт в конфиге тайтла\n * (правит владелец) и перечисляет ровно их; новые блоки сюда добавляются отдельным шагом вместе\n * с промптом, иначе ИИ о них не знал бы, а валидатор уже принимал бы.\n */\nconst WORLDGEN_BLOCKS = BLOCKS.slice(0, LEGACY_BLOCK_COUNT);\n\n/** Блоки, которыми можно покрыть землю (цельные, непрозрачные). */\nexport const SURFACE_BLOCKS: readonly string[] = WORLDGEN_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[] = WORLDGEN_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 {\n ok: false,\n error: tr({\n en: \"the answer has no JSON object\",\n ru: \"в ответе нет JSON-объекта\",\n }),\n truncated: false,\n };\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)\n return {\n ok: false,\n error: tr({ en: \"the JSON is cut off\", ru: \"JSON оборван\" }),\n truncated: true,\n };\n try {\n return { ok: true, value: JSON.parse(t.slice(start, end + 1)) };\n } catch {\n return {\n ok: false,\n error: tr({ en: \"invalid JSON\", ru: \"невалидный JSON\" }),\n truncated: false,\n };\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 tr({\n en: \"the height map is damaged — terrain without it\",\n ru: \"карта высот повреждена — рельеф без неё\",\n }),\n );\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 tr({\n en: \"the surface map is damaged — surface by biomes\",\n ru: \"карта поверхности повреждена — поверхность по биомам\",\n }),\n );\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(\n tr({\n en: \"the terrain sketch is smaller than 4×4 — terrain without it\",\n ru: \"набросок рельефа меньше 4×4 — рельеф без него\",\n }),\n );\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\"];\n// ⚠ только 12 прежних цветов: вывод генератора рецепта — часть сейва, и «cyan» в старых мирах\n// должен и дальше сводиться к голубой шерсти (COLOR_ALIAS), а не к бирюзовой шерсти сейва v3\nconst WOOL_COLORS = new Set(\n WOOL_BLOCKS.filter(\n (n) => BLOCKS.findIndex((b) => b.name === n) < LEGACY_BLOCK_COUNT,\n ).map((n) => n.replace(/_wool$/, \"\")),\n);\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(\n tr(\n {\n en: \"models: kept the first {n}\",\n ru: \"модели: оставлены первые {n}\",\n },\n { n: L.maxModels },\n ),\n );\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(\n tr(\n {\n en: \"model {id}: kept the first {n} parts\",\n ru: \"модель {id}: оставлены первые {n} деталей\",\n },\n { id, n: L.maxParts },\n ),\n );\n const dropped = Math.min(raw.length, L.maxParts) - parts.length;\n if (dropped > 0)\n warnings.push(\n tr(\n {\n en: \"model {id}: parts dropped: {n}\",\n ru: \"модель {id}: отброшено деталей: {n}\",\n },\n { id, n: dropped },\n ),\n );\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(\n tr(\n {\n en: \"model {id} is empty — skipped\",\n ru: \"модель {id} пуста — пропущена\",\n },\n { id },\n ),\n );\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))\n return {\n ok: false,\n error: tr({\n en: \"the world recipe is not an object\",\n ru: \"рецепт мира — не объект\",\n }),\n };\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 fallback = tr({ en: \"New world\", ru: \"Новый мир\" });\n const name = str(o.name, fallback, 60) || fallback;\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: Loc): T[] => {\n const ok = list.filter((x): x is T => x !== null);\n if (ok.length > max)\n warnings.push(\n tr(\n {\n en: \"{what}: kept the first {n}\",\n ru: \"{what}: оставлены первые {n}\",\n },\n { what: tr(what), n: max },\n ),\n );\n return ok.slice(0, max);\n };\n const zones = cap(arr(o.zones).map(normZone), L.maxZones, {\n en: \"zones\",\n ru: \"зоны\",\n });\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 { en: \"structures\", ru: \"постройки\" },\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"
|
|
2322
|
+
"content": "// Рецепт мира (WorldSpec) — компактный JSON, по которому SpecGenerator детерминированно строит мир.\n//\n// Зачем рецепт, а не блоки: мир на 256×256×64 — это четыре миллиона блоков; ИИ такое не выдаст, а\n// рецепт на пару килобайт — выдаст. Тот же рецепт собирается и без ИИ (картинка разбирается прямо\n// в браузере, текст — по ключевым словам) и пишется руками или агентом. Один генератор на всё.\n//\n// Координаты в рецепте НОРМИРОВАНЫ (0..1 от ширины/глубины мира): рецепт не зависит от размера,\n// и модели не надо считать блоки. Каждое поле необязательно: parseWorldSpec() дополняет\n// значениями по умолчанию, зажимает числа в допустимые границы и отбрасывает мусор — это\n// единственная дверь, через которую рецепт попадает в игру (ИИ, файл, сейв, агент).\nimport { BLOCKS, LEGACY_BLOCK_COUNT, WOOL_BLOCKS } from \"../registry/Blocks\";\nimport { extractJsonObject, type JsonExtract } from \"@idosgames/characters\";\nimport { hashString } from \"../core/Noise\";\nimport { CONFIG } from \"../config\";\nimport { tr, type Loc } from \"../i18n\";\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/**\n * Палитра рецепта — только блоки, что были до сейва v3. Системный промпт ИИ живёт в конфиге тайтла\n * (правит владелец) и перечисляет ровно их; новые блоки сюда добавляются отдельным шагом вместе\n * с промптом, иначе ИИ о них не знал бы, а валидатор уже принимал бы.\n */\nconst WORLDGEN_BLOCKS = BLOCKS.slice(0, LEGACY_BLOCK_COUNT);\n\n/** Блоки, которыми можно покрыть землю (цельные, непрозрачные). */\nexport const SURFACE_BLOCKS: readonly string[] = WORLDGEN_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[] = WORLDGEN_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 { extractJsonObject, type JsonExtract };\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 tr({\n en: \"the height map is damaged — terrain without it\",\n ru: \"карта высот повреждена — рельеф без неё\",\n }),\n );\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 tr({\n en: \"the surface map is damaged — surface by biomes\",\n ru: \"карта поверхности повреждена — поверхность по биомам\",\n }),\n );\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(\n tr({\n en: \"the terrain sketch is smaller than 4×4 — terrain without it\",\n ru: \"набросок рельефа меньше 4×4 — рельеф без него\",\n }),\n );\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\"];\n// ⚠ только 12 прежних цветов: вывод генератора рецепта — часть сейва, и «cyan» в старых мирах\n// должен и дальше сводиться к голубой шерсти (COLOR_ALIAS), а не к бирюзовой шерсти сейва v3\nconst WOOL_COLORS = new Set(\n WOOL_BLOCKS.filter(\n (n) => BLOCKS.findIndex((b) => b.name === n) < LEGACY_BLOCK_COUNT,\n ).map((n) => n.replace(/_wool$/, \"\")),\n);\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(\n tr(\n {\n en: \"models: kept the first {n}\",\n ru: \"модели: оставлены первые {n}\",\n },\n { n: L.maxModels },\n ),\n );\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(\n tr(\n {\n en: \"model {id}: kept the first {n} parts\",\n ru: \"модель {id}: оставлены первые {n} деталей\",\n },\n { id, n: L.maxParts },\n ),\n );\n const dropped = Math.min(raw.length, L.maxParts) - parts.length;\n if (dropped > 0)\n warnings.push(\n tr(\n {\n en: \"model {id}: parts dropped: {n}\",\n ru: \"модель {id}: отброшено деталей: {n}\",\n },\n { id, n: dropped },\n ),\n );\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(\n tr(\n {\n en: \"model {id} is empty — skipped\",\n ru: \"модель {id} пуста — пропущена\",\n },\n { id },\n ),\n );\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))\n return {\n ok: false,\n error: tr({\n en: \"the world recipe is not an object\",\n ru: \"рецепт мира — не объект\",\n }),\n };\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 fallback = tr({ en: \"New world\", ru: \"Новый мир\" });\n const name = str(o.name, fallback, 60) || fallback;\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: Loc): T[] => {\n const ok = list.filter((x): x is T => x !== null);\n if (ok.length > max)\n warnings.push(\n tr(\n {\n en: \"{what}: kept the first {n}\",\n ru: \"{what}: оставлены первые {n}\",\n },\n { what: tr(what), n: max },\n ),\n );\n return ok.slice(0, max);\n };\n const zones = cap(arr(o.zones).map(normZone), L.maxZones, {\n en: \"zones\",\n ru: \"зоны\",\n });\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 { en: \"structures\", ru: \"постройки\" },\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"
|
|
2406
2323
|
},
|
|
2407
2324
|
{
|
|
2408
2325
|
"path": "worlds/startWorld.ts",
|
|
@@ -2410,7 +2327,7 @@
|
|
|
2410
2327
|
},
|
|
2411
2328
|
{
|
|
2412
2329
|
"path": "module.meta.json",
|
|
2413
|
-
"content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The lobby's 'Worlds' tab turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby's home shows 'With friends' next to the game's 'Play' and a 'Multiplayer' tab, and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Characters: the player's own blocky character (third-person view on F5, the first-person arm and an inventory doll from the same recipe) is a CharacterSpec recipe — skeleton template (humanoid, quadruped, winged, blob), boxes on bones, palette and pixel-art faces with expressions — animated by a layered animator (locomotion state graph, actions, emotes, emotions with faces and posture, secondary motion of tails and capes). The lobby's 'Character' and 'Items' tabs turn reference pictures (front, side, back) and a description into a character or an item look — with InApp AI (title features \\\"voxel-character\\\" and \\\"voxel-item\\\") or without it (colours from the picture); item looks re-skin an existing item (stats stay) or are cosmetics worn on the body (hats, capes, wings). Characters also become NPCs and custom mobs with preset behaviours; looks, emotes and emotions sync over the network; characters and items publish to the Workshop. Not included yet: AI dialogue for NPCs and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"player character from a recipe: third-person view (F5), first-person arm and inventory doll\",\n \"character animation: state graph (walk, run, sneak, swim, fly, glide, ride, sleep, death), actions, emotes, emotions with faces\",\n \"AI character from pictures (front/side/back) + description via client.ai (feature \\\"voxel-character\\\"), or colours from a picture without AI\",\n \"AI item looks via client.ai (feature \\\"voxel-item\\\"): new look of an existing item, or cosmetics (hats, capes, wings)\",\n \"NPCs and custom mobs from a character with preset behaviours (npc, passive, neutral, hostile, pet)\",\n \"emote wheel (G), /emote, /feel, chat smileys; synced over the network\",\n \"characters and item looks in the Workshop (voxelcraft.character, voxelcraft.item)\",\n \"quest book (J): a survival line that doubles as the tutorial — chapters, quests with parents, hints that get more precise when the player is stuck, an arrow to the nearest target, recipes as pictures, situational tips; English and Russian\",\n \"pluggable quest sources (QuestSource) — the base for server quests; new task kinds as registry entries\",\n \"lives inside the SDK lobby, Fortnite-style: the player's character on a pedestal in a block diorama on the lobby's home (ctx.lobby hero) with Edit / Emote, the world card and Play\",\n \"lobby tabs on the UI kit next to every system: Worlds (new world from pictures + description, My worlds), Character (studio), Items (looks and wearables), Locker\",\n \"\\\"To the lobby\\\" in the pause menu returns to the SDK lobby; the world chosen there opens on Play\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"character\",\n \"avatar\",\n \"animation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.7.
|
|
2330
|
+
"content": "{\n \"id\": \"voxelcraft\",\n \"type\": \"template\",\n \"uses\": [\"multiplayer\", \"workshop\"],\n \"summary\": \"Minecraft-like voxel survival game: mine, craft, build, farm and fight from the first night to the dragon — with worlds generated from a picture + description (with or without AI).\",\n \"description\": \"A first-person voxel game rendered in Three.js that follows Minecraft's mechanics 1:1 under its own names (every player-facing name lives in one table, so a commercial game can re-skin it; Minecraft names are accepted as aliases). Survival and creative modes: ores and tool tiers, crafting, smelting, armor, hunger, air and drowning, XP, enchanting, anvils, smithing, potions and beacons; farming, animals, taming and riding, fishing, boats and minecarts; villages with trading villagers, raids, and dozens of hostile and neutral mobs. Three dimensions — the overworld (biomes from deserts and jungles to the deep dark, caves, ocean temples, mansions, trial chambers, archaeology), the Inferno (Nether-like: fortresses, its own mobs and biomes) and the Beyond (End-like: citadel portal, the dragon boss, outer islands and cities, wings). Redstone (wire, torches, repeaters, comparators, pistons, observers, hoppers, dispensers, rails), weather, day/night, block light, fluids, achievements, generated music, a chat console with commands, and touch controls. The lobby's 'Worlds' tab turns a reference picture and a text description into a world — locally in the browser (the picture read as a top-down map or as a mood, the text by keywords) or with InApp AI (client.ai, title feature \\\"voxel-world\\\") — via a compact WorldSpec recipe. Worlds autosave to 'My worlds'. The agent sees and drives the game through its debug surface (state, lookingAt, console commands). Covers any 'Minecraft clone', survival / crafting / mining game, block-building sandbox or 'generate a world from a picture' request; by far the largest feature module. Co-op multiplayer for up to 8 players through the lobby's rooms (the multiplayer module, ctx.multiplayer): the lobby's home shows 'With friends' next to the game's 'Play' and a 'Multiplayer' tab, and the pause menu's 'Play with friends' opens 'Create a room' there (public or by invite, room settings 'guest game mode' and 'commands for everyone'); the host's 'Play' takes the whole room into the host's world, late joiners included; in game the host grants operator, kicks and bans. Peer-to-peer over WebRTC (the SDK transport @idosgames/core/realtime, rooms and signaling on the engine's Multiplayer module; TURN relay from the engine when configured): each live dimension is simulated by one player and players may be in different dimensions at once; everyone's own inventory and progress stay theirs; when the host leaves or its tab dies the world moves to another player without stopping. Needs the title's Multiplayer section enabled (Host topology). Characters: the player's own blocky character (third-person view on F5, the first-person arm and an inventory doll from the same recipe) is a CharacterSpec recipe — skeleton template (humanoid, quadruped, winged, blob), boxes on bones, palette and pixel-art faces with expressions — animated by a layered animator (locomotion state graph, actions, emotes, emotions with faces and posture, secondary motion of tails and capes). The lobby's 'Character' and 'Items' tabs turn reference pictures (front, side, back) and a description into a character or an item look — with InApp AI (title features \\\"voxel-character\\\" and \\\"voxel-item\\\") or without it (colours from the picture); item looks re-skin an existing item (stats stay) or are cosmetics worn on the body (hats, capes, wings). Characters also become NPCs and custom mobs with preset behaviours; looks, emotes and emotions sync over the network; characters and items publish to the Workshop. Not included yet: AI dialogue for NPCs and a dedicated server.\",\n \"provides\": [\n \"first-person voxel world: mine, place and break blocks\",\n \"survival and creative modes (health, hunger, air, XP, death and respawn)\",\n \"crafting, smelting, tool and armor tiers\",\n \"enchanting, anvil, smithing, potions, beacon\",\n \"farming, animals, taming and riding, fishing, boats\",\n \"villages, trading villagers, raids\",\n \"dozens of hostile and neutral mobs, bosses (dragon, ash lord)\",\n \"three dimensions: overworld, Inferno (Nether-like), Beyond (End-like)\",\n \"biomes, caves and structures (temples, mansions, fortresses, trial chambers, ruins)\",\n \"redstone: wire, repeaters, comparators, pistons, observers, hoppers, rails and minecarts\",\n \"weather, day/night cycle, block light and fluid simulation\",\n \"achievements and procedurally generated music\",\n \"chat console with commands (/give, /tp, /locate, /summon, …)\",\n \"touch controls for mobile\",\n \"world generation from a picture + description (WorldSpec recipe)\",\n \"AI world generation via client.ai (feature \\\"voxel-world\\\")\",\n \"AI builds objects from pictures out of coloured blocks (a plane, a car… — models of shapes)\",\n \"AI recreates landscapes from pictures (terrain sketch, rivers, biomes)\",\n \"co-op multiplayer up to 8 players (P2P over WebRTC) through the lobby's rooms (ctx.multiplayer), late joiners included\",\n \"open the world to the network: invite code or public list, guest game mode, operators, kick and ban\",\n \"players in different dimensions at once; host migration without stopping the world\",\n \"autosave and 'My worlds' (IndexedDB)\",\n \"player character from a recipe: third-person view (F5), first-person arm and inventory doll\",\n \"character animation: state graph (walk, run, sneak, swim, fly, glide, ride, sleep, death), actions, emotes, emotions with faces\",\n \"AI character from pictures (front/side/back) + description via client.ai (feature \\\"voxel-character\\\"), or colours from a picture without AI\",\n \"AI item looks via client.ai (feature \\\"voxel-item\\\"): new look of an existing item, or cosmetics (hats, capes, wings)\",\n \"NPCs and custom mobs from a character with preset behaviours (npc, passive, neutral, hostile, pet)\",\n \"emote wheel (G), /emote, /feel, chat smileys; synced over the network\",\n \"characters and item looks in the Workshop (voxelcraft.character, voxelcraft.item)\",\n \"quest book (J): a survival line that doubles as the tutorial — chapters, quests with parents, hints that get more precise when the player is stuck, an arrow to the nearest target, recipes as pictures, situational tips; English and Russian\",\n \"pluggable quest sources (QuestSource) — the base for server quests; new task kinds as registry entries\",\n \"lives inside the SDK lobby, Fortnite-style: the player's character on a pedestal in a block diorama on the lobby's home (ctx.lobby hero) with Edit / Emote, the world card and Play\",\n \"lobby tabs on the UI kit next to every system: Worlds (new world from pictures + description, My worlds), Character (studio), Items (looks and wearables), Locker\",\n \"\\\"To the lobby\\\" in the pause menu returns to the SDK lobby; the world chosen there opens on Play\",\n \"own player-facing names for a re-skinnable commercial game (Minecraft names as aliases)\",\n \"3D block rendering (Three.js)\"\n ],\n \"tags\": [\n \"voxel\",\n \"sandbox\",\n \"survival\",\n \"crafting\",\n \"minecraft\",\n \"3d\",\n \"first-person\",\n \"open-world\",\n \"redstone\",\n \"mobile\",\n \"three\",\n \"ai\",\n \"world-generation\",\n \"character\",\n \"avatar\",\n \"animation\",\n \"multiplayer\",\n \"co-op\"\n ],\n \"media\": {\n \"image\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/cover.png\",\n \"video\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo.mp4\"\n },\n \"demoUrl\": \"https://cloud.idosgames.com/drive/modules/voxelcraft/demo/\",\n \"author\": {\n \"name\": \"iDos Games\",\n \"url\": \"https://idosgames.com\"\n },\n \"version\": \"0.7.2\"\n}\n"
|
|
2414
2331
|
}
|
|
2415
2332
|
]
|
|
2416
2333
|
}
|