@idosgames/mcp 0.1.42 → 0.1.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/package.json +1 -1
- package/registry/host.json +1 -1
- package/registry/index.json +135 -135
- package/registry/modules/ai.json +3 -3
- package/registry/modules/board-game.json +15 -15
- package/registry/modules/boosts.json +3 -3
- package/registry/modules/character.json +8 -8
- package/registry/modules/chat.json +3 -3
- package/registry/modules/collections.json +3 -3
- package/registry/modules/coop-events.json +3 -3
- package/registry/modules/craft.json +3 -3
- package/registry/modules/deals.json +3 -3
- package/registry/modules/events.json +7 -7
- package/registry/modules/friends.json +3 -3
- package/registry/modules/hero-run.json +20 -16
- package/registry/modules/idle-rpg.json +17 -13
- package/registry/modules/inventory.json +3 -3
- package/registry/modules/invite.json +3 -3
- package/registry/modules/leaderboard.json +7 -7
- package/registry/modules/lootboxes.json +7 -7
- package/registry/modules/mailbox.json +7 -7
- package/registry/modules/marketplace.json +3 -3
- package/registry/modules/mini-game.json +7 -7
- package/registry/modules/multiplayer.json +3 -3
- package/registry/modules/premium.json +3 -3
- package/registry/modules/profile.json +3 -3
- package/registry/modules/pvp.json +3 -3
- package/registry/modules/quests.json +3 -3
- package/registry/modules/rewards.json +5 -5
- package/registry/modules/season.json +3 -3
- package/registry/modules/store.json +7 -7
- package/registry/modules/tutorials.json +7 -7
- package/registry/modules/video-studio.json +4 -4
- package/registry/modules/voxelcraft.json +36 -28
- package/registry/modules/workshop.json +3 -3
- package/registry/skills/ai-generation-system.json +2 -2
- package/registry/skills/idosgames-ui-kit.json +1 -1
- package/registry/skills/rewarded-ads.json +1 -1
- package/registry/skills/store-system.json +1 -1
|
@@ -81,17 +81,17 @@
|
|
|
81
81
|
"name": "iDos Games",
|
|
82
82
|
"url": "https://idosgames.com"
|
|
83
83
|
},
|
|
84
|
-
"version": "0.7.
|
|
84
|
+
"version": "0.7.4"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@idosgames/characters": "0.1.
|
|
88
|
-
"@idosgames/core": "0.
|
|
89
|
-
"@idosgames/module-sdk": "0.13.
|
|
90
|
-
"@idosgames/react": "0.6.
|
|
87
|
+
"@idosgames/characters": "0.1.2",
|
|
88
|
+
"@idosgames/core": "0.25.0",
|
|
89
|
+
"@idosgames/module-sdk": "0.13.2",
|
|
90
|
+
"@idosgames/react": "0.6.3",
|
|
91
91
|
"react": "19.2.7",
|
|
92
92
|
"three": "0.185.1"
|
|
93
93
|
},
|
|
94
|
-
"contentHash": "
|
|
94
|
+
"contentHash": "fd96e0faccf698f9fa5c169bd51acbe1b11b1eae3eba6f6dfc5dd8ab72df438e",
|
|
95
95
|
"files": [
|
|
96
96
|
{
|
|
97
97
|
"path": "agent.ts",
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
},
|
|
124
124
|
{
|
|
125
125
|
"path": "character/CharacterActor.ts",
|
|
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 /** Свет
|
|
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 newEntityLight,\n patchEntityLight,\n setEntityLight,\n type EntityLight,\n type PlaceLight,\n} from \"../gfx/LightMaterial\";\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 /** Свет клетки, где стоит (character/light `lightAt`): небо и блоки, как у вершин чанка. */\n light?: PlaceLight;\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 /** Униформы света фигуры: значения на кадр, шейдер общий. */\n private readonly light: EntityLight = newEntityLight();\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 // светится светом своей клетки, как блоки вокруг (gfx/LightMaterial patchEntityLight).\n // ⚠ `materials` появился в @idosgames/characters после 0.1.1: со старым пакетом: прежний свет сцены\n const view = this.view as CharacterView & {\n materials?: () => readonly THREE.MeshLambertMaterial[];\n };\n for (const m of view.materials?.() ?? []) patchEntityLight(m, this.light);\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.light) setEntityLight(this.light, f.light);\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"
|
|
127
127
|
},
|
|
128
128
|
{
|
|
129
129
|
"path": "character/CharacterPreview.ts",
|
|
@@ -147,7 +147,7 @@
|
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
149
|
"path": "character/light.ts",
|
|
150
|
-
"content": "// Свет места для
|
|
150
|
+
"content": "// Свет места для фигуры (персонаж, моб): доли неба и блоков в её клетке, той же кривой, что у\n// вершин чанка (gfx/LightMaterial): 0.8 в степени «недостачи» уровня, как в Minecraft. Фигура\n// освещается ими в шейдере (patchEntityLight): небо гасит свет сцены, факел добавляет тёплый.\nimport type { PlaceLight } from \"../gfx/LightMaterial\";\nimport type { ChunkManager } from \"../world/ChunkManager\";\n\nexport function lightAt(\n world: ChunkManager,\n x: number,\n y: number,\n z: number,\n out: PlaceLight = { sky: 1, block: 0 },\n): PlaceLight {\n const l = world.getLight(Math.floor(x), Math.floor(y), Math.floor(z));\n const sky = l >> 4;\n const blk = l & 15;\n out.sky = Math.pow(0.8, 15 - sky);\n out.block = blk > 0 ? Math.pow(0.8, 15 - blk) : 0;\n return out;\n}\n"
|
|
151
151
|
},
|
|
152
152
|
{
|
|
153
153
|
"path": "character/Looks.ts",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
},
|
|
164
164
|
{
|
|
165
165
|
"path": "core/GameLoop.ts",
|
|
166
|
-
"content": "// Игровой цикл: rAF-рендер + аккумулятор фиксированного тика (20 TPS).\n// Симуляция (мир, мобы, вода, интенты) — на тике; всё, что касается глаза и руки\n// (камера, физика игрока, интерполяция, рендер) — на кадре.\nimport { CONFIG } from \"../config\";\n\nexport class GameLoop {\n onTick: (dt: number, n: number) => void;\n onFrame: (dt: number, alpha: number) => void;\n tickDt: number;\n acc: number;\n tickN: number;\n last: number;\n running: boolean;\n fps: number;\n /**\n * Сколько кадров отработано. Агент ждёт по нему, что нажатие увидел хотя бы один кадр: при\n * страховочном таймере (200 мс) короткий «клик» иначе проскакивал между кадрами.\n */\n frameN = 0;\n private _frames: number;\n private _fpsTime: number;\n /** id запланированного кадра: stop() обязан его отменить, см. там. */\n private _raf = 0;\n /**\n * Таймер шага для скрытой страницы. В фоне (свёрнутое превью AI-кодера, фоновая вкладка) браузер\n * не зовёт requestAnimationFrame вовсе — симуляция вставала, и действия агента (команды, ходьба)\n * висели до возврата на вкладку. Скрытая страница шагает таймером; браузер его душит до ~1 Гц,\n * но тики (maxCatchupTicks за шаг) идут.\n */\n private _timer: ReturnType<typeof setTimeout> | undefined;\n /**\n * Сетевая игра: мир общий, и скрытая вкладка хоста или владельца измерения не может считать его\n * раз в секунду (браузер душит таймеры фоновой страницы до ~1 Гц, а тиков за шаг не больше\n * maxCatchupTicks — мир у ВСЕХ шёл бы вчетверо медленнее). Таймеры выделенного воркера так не\n * душатся: в фоне шаг задаёт его сообщение раз в 50 мс. Подключает Game, пока идёт сетевая игра.\n */\n backgroundTicker: (() => boolean) | null = null;\n private _worker: Worker | null = null;\n private _workerUrl: string | null = null;\n private _workerStep: (() => void) | null = null;\n\n /**\n * @param onTick — фиксированный шаг, dt = 1/TPS\n * @param onFrame — alpha = доля тика для интерполяции\n */\n constructor(\n onTick: (dt: number, n: number) => void,\n onFrame: (dt: number, alpha: number) => void,\n ) {\n this.onTick = onTick;\n this.onFrame = onFrame;\n this.tickDt = 1 / CONFIG.tps;\n this.acc = 0;\n this.tickN = 0;\n this.last = 0;\n this.running = false;\n // FPS: скользящее окно за секунду\n this.fps = 0;\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n start() {\n this.running = true;\n this.last = performance.now();\n const step = (now: number) => {\n if (!this.running) return;\n let dt = (now - this.last) / 1000;\n this.last = now;\n if (dt > 0.25) dt = 0.25; // возврат из фона — не наращивать гигантский долг\n\n this.acc += dt;\n let ticks = 0;\n while (this.acc >= this.tickDt && ticks < CONFIG.maxCatchupTicks) {\n try {\n this.onTick(this.tickDt, this.tickN++);\n } catch (e) {\n this._report(e);\n }\n this.acc -= this.tickDt;\n ticks++;\n }\n if (ticks === CONFIG.maxCatchupTicks) this.acc = 0; // сбросить долг\n\n this._frames++;\n this.frameN++;\n this._fpsTime += dt;\n if (this._fpsTime >= 0.5) {\n this.fps = Math.round(this._frames / this._fpsTime);\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n try {\n this.onFrame(Math.min(dt, 0.05), this.acc / this.tickDt);\n } catch (e) {\n this._report(e);\n }\n schedule();\n };\n const schedule = () => {\n if (typeof document !== \"undefined\" && document.hidden) {\n if (\n this.backgroundTicker?.() &&\n this._startWorker(() => step(performance.now()))\n )\n return;\n this._timer = setTimeout(() => step(performance.now()), 50);\n return;\n }\n this._workerStep = null;\n // Видимая, но неотрисовываемая страница (панель превью за другой вкладкой, iframe
|
|
166
|
+
"content": "// Игровой цикл: rAF-рендер + аккумулятор фиксированного тика (20 TPS).\n// Симуляция (мир, мобы, вода, интенты) — на тике; всё, что касается глаза и руки\n// (камера, физика игрока, интерполяция, рендер) — на кадре.\nimport { CONFIG } from \"../config\";\n\n/** Кадра нет дольше этого, мс: сторож шагает сам (страница видна, но не отрисовывается). */\nconst WATCH_MS = 200;\n\nexport class GameLoop {\n onTick: (dt: number, n: number) => void;\n onFrame: (dt: number, alpha: number) => void;\n tickDt: number;\n acc: number;\n tickN: number;\n last: number;\n running: boolean;\n fps: number;\n /**\n * Сколько кадров отработано. Агент ждёт по нему, что нажатие увидел хотя бы один кадр: при\n * страховочном таймере (200 мс) короткий «клик» иначе проскакивал между кадрами.\n */\n frameN = 0;\n private _frames: number;\n private _fpsTime: number;\n /** id запланированного кадра: stop() обязан его отменить, см. там. */\n private _raf = 0;\n /**\n * Таймер шага для скрытой страницы. В фоне (свёрнутое превью AI-кодера, фоновая вкладка) браузер\n * не зовёт requestAnimationFrame вовсе — симуляция вставала, и действия агента (команды, ходьба)\n * висели до возврата на вкладку. Скрытая страница шагает таймером; браузер его душит до ~1 Гц,\n * но тики (maxCatchupTicks за шаг) идут.\n */\n private _timer: ReturnType<typeof setTimeout> | undefined;\n /** Сторож кадра (см. schedule): один таймер на всё время игры, а не новый на каждый кадр. */\n private _watch: ReturnType<typeof setInterval> | undefined;\n /**\n * Сетевая игра: мир общий, и скрытая вкладка хоста или владельца измерения не может считать его\n * раз в секунду (браузер душит таймеры фоновой страницы до ~1 Гц, а тиков за шаг не больше\n * maxCatchupTicks — мир у ВСЕХ шёл бы вчетверо медленнее). Таймеры выделенного воркера так не\n * душатся: в фоне шаг задаёт его сообщение раз в 50 мс. Подключает Game, пока идёт сетевая игра.\n */\n backgroundTicker: (() => boolean) | null = null;\n private _worker: Worker | null = null;\n private _workerUrl: string | null = null;\n private _workerStep: (() => void) | null = null;\n\n /**\n * @param onTick — фиксированный шаг, dt = 1/TPS\n * @param onFrame — alpha = доля тика для интерполяции\n */\n constructor(\n onTick: (dt: number, n: number) => void,\n onFrame: (dt: number, alpha: number) => void,\n ) {\n this.onTick = onTick;\n this.onFrame = onFrame;\n this.tickDt = 1 / CONFIG.tps;\n this.acc = 0;\n this.tickN = 0;\n this.last = 0;\n this.running = false;\n // FPS: скользящее окно за секунду\n this.fps = 0;\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n start() {\n this.running = true;\n this.last = performance.now();\n const step = (now: number) => {\n if (!this.running) return;\n let dt = (now - this.last) / 1000;\n this.last = now;\n if (dt > 0.25) dt = 0.25; // возврат из фона — не наращивать гигантский долг\n\n this.acc += dt;\n let ticks = 0;\n while (this.acc >= this.tickDt && ticks < CONFIG.maxCatchupTicks) {\n try {\n this.onTick(this.tickDt, this.tickN++);\n } catch (e) {\n this._report(e);\n }\n this.acc -= this.tickDt;\n ticks++;\n }\n if (ticks === CONFIG.maxCatchupTicks) this.acc = 0; // сбросить долг\n\n this._frames++;\n this.frameN++;\n this._fpsTime += dt;\n if (this._fpsTime >= 0.5) {\n this.fps = Math.round(this._frames / this._fpsTime);\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n try {\n this.onFrame(Math.min(dt, 0.05), this.acc / this.tickDt);\n } catch (e) {\n this._report(e);\n }\n schedule();\n };\n const schedule = () => {\n if (typeof document !== \"undefined\" && document.hidden) {\n if (\n this.backgroundTicker?.() &&\n this._startWorker(() => step(performance.now()))\n )\n return;\n this._timer = setTimeout(() => step(performance.now()), 50);\n return;\n }\n this._workerStep = null;\n this._raf = requestAnimationFrame(step);\n };\n // Видимая, но неотрисовываемая страница (панель превью за другой вкладкой, iframe вне экрана) не\n // зовёт rAF и при document.hidden === false. Сторож раз в 200 мс шагает сам, если кадра не было\n // дольше 200 мс. Раньше страховочный таймер ставился и снимался на КАЖДОМ кадре (60 в секунду).\n clearInterval(this._watch);\n this._watch = setInterval(() => {\n if (!this.running) return;\n if (typeof document !== \"undefined\" && document.hidden) return;\n if (performance.now() - this.last < WATCH_MS) return;\n cancelAnimationFrame(this._raf);\n step(performance.now());\n }, WATCH_MS);\n schedule();\n }\n\n /**\n * Ошибка тика или кадра. Раньше она вылетала из step до schedule() — следующего кадра не было\n * НИКОГДА: игра застывала намертво (в сетевой игре — у хоста, и мир вставал у всех). Теперь цикл\n * идёт дальше; одна и та же ошибка пишется в консоль один раз, а не 20 раз в секунду.\n */\n private _report(e: unknown): void {\n this.errors++;\n const key = e instanceof Error ? (e.stack ?? e.message) : String(e);\n if (this._reported.has(key)) return;\n this._reported.add(key);\n console.error(\"[voxelcraft] game loop error\", e);\n }\n\n /** Сколько тиков/кадров упало с ошибкой (для отладки и тестов). */\n errors = 0;\n private readonly _reported = new Set<string>();\n\n stop() {\n this.running = false;\n // Уже запланированный кадр отменяем явно: если start() придёт раньше, чем он сработает\n // (suspend+activate в одном кадре), он увидел бы running=true и продолжил бы СВОЮ цепочку\n // рядом с новой — симуляция пошла бы вдвое быстрее.\n cancelAnimationFrame(this._raf);\n clearTimeout(this._timer);\n clearInterval(this._watch);\n this._watch = undefined;\n this._workerStep = null;\n this._worker?.terminate();\n this._worker = null;\n if (this._workerUrl) URL.revokeObjectURL(this._workerUrl);\n this._workerUrl = null;\n }\n\n /**\n * Следующий шаг скрытой страницы — по сообщению воркера. Воркер тикает всегда, пока жив; шаг\n * берёт только один ожидающий (`_workerStep`), поэтому двойной цепочки не бывает. false —\n * воркер не завести (CSP без blob:, нет Worker) — остаётся обычный таймер.\n */\n private _startWorker(next: () => void): boolean {\n try {\n if (!this._worker) {\n const src = \"setInterval(function(){postMessage(0)},50)\";\n this._workerUrl = URL.createObjectURL(\n new Blob([src], { type: \"text/javascript\" }),\n );\n this._worker = new Worker(this._workerUrl);\n this._worker.onmessage = () => {\n const fn = this._workerStep;\n this._workerStep = null;\n fn?.();\n };\n }\n this._workerStep = next;\n return true;\n } catch {\n return false;\n }\n }\n}\n"
|
|
167
167
|
},
|
|
168
168
|
{
|
|
169
169
|
"path": "core/Intents.ts",
|
|
@@ -283,7 +283,7 @@
|
|
|
283
283
|
},
|
|
284
284
|
{
|
|
285
285
|
"path": "entities/manager/visuals.ts",
|
|
286
|
-
"content": "import * as THREE from \"three\";\nimport type { EntityManager } from \"../EntityManager\";\n\n/** Ближе этого моб отбрасывает тень (квадрат расстояния, блоки). */\nconst MOB_SHADOW_R2 = 24 * 24;\n/** Сущности ближе этого двигают тени — карту теней надо перерисовывать каждый кадр. */\nconst SHADOW_AREA_R2 = 40 * 40;\n\nexport function updateVisuals(\n em: EntityManager,\n alpha: number,\n time: number,\n): void {\n const pp = em.player.pos;\n let casters = 0;\n for (const e of em.entities.values()) {\n const d2 = e.pos.distanceToSquared(pp);\n if (d2 < SHADOW_AREA_R2 && e.kind !== \"xp\") casters++;\n if (e.kind === \"mob\") {\n e.setShadow(d2 < MOB_SHADOW_R2);\n // свет
|
|
286
|
+
"content": "import * as THREE from \"three\";\nimport type { EntityManager } from \"../EntityManager\";\nimport { lightAt } from \"../../character/light\";\nimport type { PlaceLight } from \"../../gfx/LightMaterial\";\n\n/** Свет клетки моба за кадр: один объект на всех (моб копирует значения в свои униформы). */\nconst place: PlaceLight = { sky: 1, block: 0 };\n\n/** Ближе этого моб отбрасывает тень (квадрат расстояния, блоки). */\nconst MOB_SHADOW_R2 = 24 * 24;\n/** Сущности ближе этого двигают тени — карту теней надо перерисовывать каждый кадр. */\nconst SHADOW_AREA_R2 = 40 * 40;\n\nexport function updateVisuals(\n em: EntityManager,\n alpha: number,\n time: number,\n): void {\n const pp = em.player.pos;\n let casters = 0;\n for (const e of em.entities.values()) {\n const d2 = e.pos.distanceToSquared(pp);\n if (d2 < SHADOW_AREA_R2 && e.kind !== \"xp\") casters++;\n if (e.kind === \"mob\") {\n e.setShadow(d2 < MOB_SHADOW_R2);\n // свет клетки: небо гасит свет сцены (пещера), факел светит тёплым: как блоки вокруг\n e.updateVisual(\n alpha,\n time,\n lightAt(em.world, e.pos.x, e.pos.y + e.height * 0.5, e.pos.z, place),\n );\n } else if (\n e.kind === \"arrow\" ||\n e.kind === \"fireball\" ||\n e.kind === \"minecart\" ||\n e.kind === \"boat\"\n )\n e.updateVisual(alpha);\n else if (e.kind === \"hook\") {\n // леска — от кончика удочки: чуть впереди, правее и ниже глаз\n const eye = em.player.eyePos,\n yaw = em.player.yaw;\n const tip = eye\n .clone()\n .addScaledVector(em.player.lookDir(), 0.7)\n .add(\n new THREE.Vector3(Math.cos(yaw) * 0.3, -0.3, -Math.sin(yaw) * 0.3),\n );\n e.updateVisual(alpha, tip);\n } else e.updateVisual(alpha, time);\n }\n em.shadowCasters = casters;\n beam(em);\n}\n\nexport function beam(em: EntityManager): void {\n let pts: THREE.Vector3[] | null = null;\n for (const e of em.entities.values()) {\n if (e.kind !== \"mob\" || !e.def.boss || e.healFrom === null) continue;\n const k = em.entities.get(e.healFrom);\n if (k && k.kind === \"mob\" && !k.dead)\n pts = [\n k.group.position.clone().setY(k.group.position.y + 1),\n e.group.position.clone().setY(e.group.position.y + e.height * 0.5),\n ];\n }\n if (!pts) {\n if (em.beamLine) em.beamLine.visible = false;\n return;\n }\n if (!em.beamLine) {\n em.beamLine = new THREE.Line(\n new THREE.BufferGeometry(),\n new THREE.LineBasicMaterial({ color: 0xff80ff }),\n );\n em.scene.add(em.beamLine);\n }\n em.beamLine.geometry.setFromPoints(pts);\n em.beamLine.visible = true;\n}\n"
|
|
287
287
|
},
|
|
288
288
|
{
|
|
289
289
|
"path": "entities/Minecart.ts",
|
|
@@ -291,7 +291,7 @@
|
|
|
291
291
|
},
|
|
292
292
|
{
|
|
293
293
|
"path": "entities/Mob.ts",
|
|
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"
|
|
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 {\n newEntityLight,\n patchEntityLight,\n setEntityLight,\n type EntityLight,\n type PlaceLight,\n} from \"../gfx/LightMaterial\";\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 /** Униформы света клетки (gfx/LightMaterial): значения на кадр, шейдер общий на всех мобов. */\n private readonly light: EntityLight = newEntityLight();\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 // светится светом своей клетки, как блоки вокруг (gfx/LightMaterial patchEntityLight)\n if (mat instanceof THREE.MeshLambertMaterial)\n patchEntityLight(mat, this.light);\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 light: PlaceLight | undefined,\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 ...(light ? { light } : {}),\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, light?: PlaceLight) {\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 patchEntityLight(mat, this.light); // clone() не переносит onBeforeCompile\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, light, emissive);\n if (light) setEntityLight(this.light, light);\n for (const { m, base, glow } of this.mats) {\n if (glow) continue;\n m.color.copy(base).multiplyScalar(this.glowT > 0 ? 2.4 : 1);\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"
|
|
295
295
|
},
|
|
296
296
|
{
|
|
297
297
|
"path": "entities/MobBrain.ts",
|
|
@@ -355,7 +355,7 @@
|
|
|
355
355
|
},
|
|
356
356
|
{
|
|
357
357
|
"path": "game/playerCharacter.ts",
|
|
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"
|
|
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 { lightAt } from \"../character/light\";\nimport type { PlaceLight } from \"../gfx/LightMaterial\";\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 /**\n * Где холст игры на экране: меряется при смене размера, а не каждый кадр (getBoundingClientRect\n * после записи стилей прицела заставлял браузер пересчитывать раскладку на каждом кадре).\n */\n private canvasRect: DOMRect | null = null;\n private canvasWatch: ResizeObserver | null = null;\n private readonly aim = new THREE.Vector3();\n /** Свет клетки за кадр (один объект на кадры). */\n private readonly place: PlaceLight = { sky: 1, block: 0 };\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 light: lightAt(g.world, p.pos.x, p.pos.y + 1, p.pos.z, this.place),\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 // от первого лица прицел в центре: стиль сбрасывается один раз, а не каждый кадр\n if (this.camera === \"first\") {\n if (el.style.cssText) el.style.cssText = \"\";\n return;\n }\n if (this.camera === \"front\") {\n if (el.style.display !== \"none\") 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 canvas = g.renderer.domElement as HTMLCanvasElement;\n if (!this.canvasWatch && typeof ResizeObserver !== \"undefined\") {\n this.canvasWatch = new ResizeObserver(() => (this.canvasRect = null));\n this.canvasWatch.observe(canvas);\n window.addEventListener(\"scroll\", this.dropRect, { passive: true });\n }\n const r = (this.canvasRect ??= canvas.getBoundingClientRect());\n if (el.style.display) el.style.display = \"\";\n const left = (r.left + ((this.aim.x + 1) / 2) * r.width).toFixed(1) + \"px\";\n const top = (r.top + ((1 - this.aim.y) / 2) * r.height).toFixed(1) + \"px\";\n if (el.style.left !== left) el.style.left = left;\n if (el.style.top !== top) el.style.top = top;\n }\n\n private readonly dropRect = (): void => {\n this.canvasRect = null;\n };\n\n dispose(): void {\n this.canvasWatch?.disconnect();\n this.canvasWatch = null;\n window.removeEventListener(\"scroll\", this.dropRect);\n this.offLooks();\n this.actor.dispose();\n this.doll?.dispose();\n this.doll = null;\n }\n}\n"
|
|
359
359
|
},
|
|
360
360
|
{
|
|
361
361
|
"path": "game/portals.ts",
|
|
@@ -447,7 +447,7 @@
|
|
|
447
447
|
},
|
|
448
448
|
{
|
|
449
449
|
"path": "gfx/LightMaterial.ts",
|
|
450
|
-
"content": "// Свет клеток в материалах чанков: атрибут вершины lightLevel = (1 - небо/15, блоки/15), его пишет\n// мешер. Небесный свет гасит и солнце, и небосвод (пещеры тёмные даже днём), свет блоков\n// добавляется тёплым (факел). Кривая яркости — 0.8 в степени «недостачи» уровня, как в Minecraft.\n//\n// Кодировка «недостача неба» выбрана нарочно: у мешей без атрибута (кубик в руке, дроп) WebGL\n// подставляет 0 — это полное небо и ноль от блоков, то есть обычная яркость, а не чёрный.\n//\n// Тени солнца — два каскада (systems/DayNight): ближний (подробная карта вокруг игрока, как раньше)\n// и дальний (большая область, грубее, перерисовывается редко — для дальних чанков и LOD). Свет даёт\n// только ближнее «солнце»; у дальнего цвет нулевой, от него берётся лишь карта теней там, куда не\n// достаёт ближняя (с мягким переходом на краю ближней).\nimport * as THREE from \"three\";\n\n/** Цвет света от блоков (факел, лава). */\nexport const BLOCK_LIGHT_COLOR = new THREE.Color(1.0, 0.8, 0.55);\n\n/**\n * Нижний порог рассеянного света без неба: наверху пещеры почти чёрные (0.03), в Пекле и Пределе\n * неба нет вовсе — там свой фоновый свет, как ambient-свет измерения в Minecraft (ставит DayNight).\n */\nexport const LIGHT_AMBIENT = { value: 0.03 };\n\n/**\n * Цикл направленных источников three.js — его заменяем выбором каскада теней. Ищется по началу и\n * концу (сборка three выкидывает пустые строки из шейдеров) и сверяется по содержимому.\n */\nconst DIR_LOOP =\n /#pragma unroll_loop_start\\s*for \\( int i = 0; i < NUM_DIR_LIGHTS; i \\+\\+ \\) \\{[\\s\\S]*?#pragma unroll_loop_end/;\n\n/**\n * Тот же цикл в два прохода: сначала тень каждого каскада (у дальнего — нулевой цвет), потом свет\n * с тенью ближнего каскада внутри его карты и дальнего — снаружи (у края — плавный переход).\n */\nconst DIR_LOOP_CASCADED = `\tfloat vcNear = 1.0, vcFar = 1.0, vcNearW = 0.0;\n\t#if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\t{ // свой блок: развёрнутый three цикл повторяет тело без области видимости\n\t\tdirectionalLightShadow = directionalLightShadows[ i ];\n\t\tfloat vcS = receiveShadow ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowIntensity, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\tif ( dot( directionalLights[ i ].color, vec3( 1.0 ) ) < 1e-6 ) {\n\t\t\tvcFar = vcS;\n\t\t} else {\n\t\t\tvcNear = vcS;\n\t\t\tvec3 vcP = vDirectionalShadowCoord[ i ].xyz / vDirectionalShadowCoord[ i ].w;\n\t\t\tvec2 vcE = min( vcP.xy, 1.0 - vcP.xy );\n\t\t\tvcNearW = vcP.z <= 1.0 ? clamp( min( vcE.x, vcE.y ) / 0.08, 0.0, 1.0 ) : 0.0;\n\t\t}\n\t\t}\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\tfloat vcShadow = mix( vcFar, vcNear, vcNearW );\n\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\n\t\tdirectionalLight = directionalLights[ i ];\n\n\t\tgetDirectionalLightInfo( directionalLight, directLight );\n\n\t\tdirectLight.color *= ( directLight.visible ) ? vcShadow : 1.0;\n\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\n\t}\n\t#pragma unroll_loop_end`;\n\n/** Чанк lights_fragment_begin с каскадами; null — у этой версии three цикл другой (тогда как было). */\nconst CASCADED_LIGHTS = ((): string | null => {\n const chunk = THREE.ShaderChunk.lights_fragment_begin;\n const loop = DIR_LOOP.exec(chunk)?.[0];\n if (\n !loop ||\n !loop.includes(\"getShadow( directionalShadowMap[ i ]\") ||\n !loop.includes(\"RE_Direct( directLight\")\n )\n return null;\n return chunk.replace(loop, DIR_LOOP_CASCADED);\n})();\n\nexport function patchLightMaterial(mat: THREE.MeshLambertMaterial): void {\n mat.onBeforeCompile = (shader) => {\n shader.uniforms.uBlockLightColor = { value: BLOCK_LIGHT_COLOR };\n shader.uniforms.uAmbient = LIGHT_AMBIENT;\n shader.vertexShader = shader.vertexShader\n .replace(\n \"#include <common>\",\n \"#include <common>\\nattribute vec2 lightLevel;\\nvarying vec2 vLightLevel;\",\n )\n .replace(\n \"#include <begin_vertex>\",\n \"#include <begin_vertex>\\nvLightLevel = lightLevel;\",\n );\n let fs = shader.fragmentShader\n .replace(\n \"#include <common>\",\n \"#include <common>\\nvarying vec2 vLightLevel;\\nuniform vec3 uBlockLightColor;\\nuniform float uAmbient;\",\n )\n .replace(\n \"#include <aomap_fragment>\",\n [\n \"#include <aomap_fragment>\",\n \"float vcSky = pow(0.8, vLightLevel.x * 15.0);\",\n \"float vcBlk = vLightLevel.y > 0.0 ? pow(0.8, (1.0 - vLightLevel.y) * 15.0) : 0.0;\",\n \"reflectedLight.directDiffuse *= vcSky;\",\n \"reflectedLight.indirectDiffuse *= max(vcSky, uAmbient);\",\n \"reflectedLight.indirectDiffuse += diffuseColor.rgb * uBlockLightColor * vcBlk;\",\n ].join(\"\\n\"),\n );\n if (CASCADED_LIGHTS)\n fs = fs.replace(\"#include <lights_fragment_begin>\", CASCADED_LIGHTS);\n shader.fragmentShader = fs;\n };\n // одна программа на все материалы с этим патчем\n mat.customProgramCacheKey = () => \"vc-light\";\n}\n\n/** Шейдер умеет каскады (иначе дальний каскад не нужен — DayNight его не заводит). */\nexport const CASCADED_SHADOWS = CASCADED_LIGHTS !== null;\n"
|
|
450
|
+
"content": "// Свет клеток в материалах чанков: атрибут вершины lightLevel = (1 - небо/15, блоки/15), его пишет\n// мешер. Небесный свет гасит и солнце, и небосвод (пещеры тёмные даже днём), свет блоков\n// добавляется тёплым (факел). Кривая яркости — 0.8 в степени «недостачи» уровня, как в Minecraft.\n//\n// Кодировка «недостача неба» выбрана нарочно: у мешей без атрибута (кубик в руке, дроп) WebGL\n// подставляет 0 — это полное небо и ноль от блоков, то есть обычная яркость, а не чёрный.\n//\n// Тени солнца — два каскада (systems/DayNight): ближний (подробная карта вокруг игрока, как раньше)\n// и дальний (большая область, грубее, перерисовывается редко — для дальних чанков и LOD). Свет даёт\n// только ближнее «солнце»; у дальнего цвет нулевой, от него берётся лишь карта теней там, куда не\n// достаёт ближняя (с мягким переходом на краю ближней).\nimport * as THREE from \"three\";\n\n/** Цвет света от блоков (факел, лава). */\nexport const BLOCK_LIGHT_COLOR = new THREE.Color(1.0, 0.8, 0.55);\n\n/**\n * Нижний порог рассеянного света без неба: наверху пещеры почти чёрные (0.03), в Пекле и Пределе\n * неба нет вовсе — там свой фоновый свет, как ambient-свет измерения в Minecraft (ставит DayNight).\n */\nexport const LIGHT_AMBIENT = { value: 0.03 };\n\n/**\n * Цикл направленных источников three.js — его заменяем выбором каскада теней. Ищется по началу и\n * концу (сборка three выкидывает пустые строки из шейдеров) и сверяется по содержимому.\n */\nconst DIR_LOOP =\n /#pragma unroll_loop_start\\s*for \\( int i = 0; i < NUM_DIR_LIGHTS; i \\+\\+ \\) \\{[\\s\\S]*?#pragma unroll_loop_end/;\n\n/**\n * Тот же цикл в два прохода: сначала тень каждого каскада (у дальнего — нулевой цвет), потом свет\n * с тенью ближнего каскада внутри его карты и дальнего — снаружи (у края — плавный переход).\n */\nconst DIR_LOOP_CASCADED = `\tfloat vcNear = 1.0, vcFar = 1.0, vcNearW = 0.0;\n\t#if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHT_SHADOWS; i ++ ) {\n\t\t{ // свой блок: развёрнутый three цикл повторяет тело без области видимости\n\t\tdirectionalLightShadow = directionalLightShadows[ i ];\n\t\tfloat vcS = receiveShadow ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowIntensity, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\tif ( dot( directionalLights[ i ].color, vec3( 1.0 ) ) < 1e-6 ) {\n\t\t\tvcFar = vcS;\n\t\t} else {\n\t\t\tvcNear = vcS;\n\t\t\tvec3 vcP = vDirectionalShadowCoord[ i ].xyz / vDirectionalShadowCoord[ i ].w;\n\t\t\tvec2 vcE = min( vcP.xy, 1.0 - vcP.xy );\n\t\t\tvcNearW = vcP.z <= 1.0 ? clamp( min( vcE.x, vcE.y ) / 0.08, 0.0, 1.0 ) : 0.0;\n\t\t}\n\t\t}\n\t}\n\t#pragma unroll_loop_end\n\t#endif\n\tfloat vcShadow = mix( vcFar, vcNear, vcNearW );\n\n\t#pragma unroll_loop_start\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\n\t\tdirectionalLight = directionalLights[ i ];\n\n\t\tgetDirectionalLightInfo( directionalLight, directLight );\n\n\t\tdirectLight.color *= ( directLight.visible ) ? vcShadow : 1.0;\n\n\t\tRE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );\n\n\t}\n\t#pragma unroll_loop_end`;\n\n/** Чанк lights_fragment_begin с каскадами; null — у этой версии three цикл другой (тогда как было). */\nconst CASCADED_LIGHTS = ((): string | null => {\n const chunk = THREE.ShaderChunk.lights_fragment_begin;\n const loop = DIR_LOOP.exec(chunk)?.[0];\n if (\n !loop ||\n !loop.includes(\"getShadow( directionalShadowMap[ i ]\") ||\n !loop.includes(\"RE_Direct( directLight\")\n )\n return null;\n return chunk.replace(loop, DIR_LOOP_CASCADED);\n})();\n\nexport function patchLightMaterial(mat: THREE.MeshLambertMaterial): void {\n mat.onBeforeCompile = (shader) => {\n shader.uniforms.uBlockLightColor = { value: BLOCK_LIGHT_COLOR };\n shader.uniforms.uAmbient = LIGHT_AMBIENT;\n shader.vertexShader = shader.vertexShader\n .replace(\n \"#include <common>\",\n \"#include <common>\\nattribute vec2 lightLevel;\\nvarying vec2 vLightLevel;\",\n )\n .replace(\n \"#include <begin_vertex>\",\n \"#include <begin_vertex>\\nvLightLevel = lightLevel;\",\n );\n let fs = shader.fragmentShader\n .replace(\n \"#include <common>\",\n \"#include <common>\\nvarying vec2 vLightLevel;\\nuniform vec3 uBlockLightColor;\\nuniform float uAmbient;\",\n )\n .replace(\n \"#include <aomap_fragment>\",\n [\n \"#include <aomap_fragment>\",\n \"float vcSky = pow(0.8, vLightLevel.x * 15.0);\",\n \"float vcBlk = vLightLevel.y > 0.0 ? pow(0.8, (1.0 - vLightLevel.y) * 15.0) : 0.0;\",\n \"reflectedLight.directDiffuse *= vcSky;\",\n \"reflectedLight.indirectDiffuse *= max(vcSky, uAmbient);\",\n \"reflectedLight.indirectDiffuse += diffuseColor.rgb * uBlockLightColor * vcBlk;\",\n ].join(\"\\n\"),\n );\n if (CASCADED_LIGHTS)\n fs = fs.replace(\"#include <lights_fragment_begin>\", CASCADED_LIGHTS);\n shader.fragmentShader = fs;\n };\n // одна программа на все материалы с этим патчем\n mat.customProgramCacheKey = () => \"vc-light\";\n}\n\n/** Шейдер умеет каскады (иначе дальний каскад не нужен — DayNight его не заводит). */\nexport const CASCADED_SHADOWS = CASCADED_LIGHTS !== null;\n\n/**\n * Свет клетки для фигуры (персонаж, моб): те же доли, что у вершин чанка, только на всю фигуру\n * сразу. `sky`: доля света сцены (солнце, небосвод), `block`: тёплый свет блоков (факел, лава).\n */\nexport interface PlaceLight {\n sky: number;\n block: number;\n}\n\n/** Униформы одной фигуры: значения меняются каждый кадр, программа шейдера общая на все фигуры. */\nexport interface EntityLight {\n sky: { value: number };\n block: { value: number };\n}\n\nexport function newEntityLight(): EntityLight {\n return { sky: { value: 1 }, block: { value: 0 } };\n}\n\nexport function setEntityLight(light: EntityLight, at: PlaceLight): void {\n light.sky.value = at.sky;\n light.block.value = at.block;\n}\n\n/**\n * Освещает фигуру светом её клетки ТОЙ ЖЕ формулой, что блоки вокруг (patchLightMaterial): небо гасит\n * свет сцены, свет блоков ДОБАВЛЯЕТСЯ тёплым.\n *\n * ⚠ Раньше фигура брала только свет сцены, помноженный на яркость места (цвет материала ≤ 1). Ночью\n * свет сцены: тусклый синий небосвод, и под факелом персонаж оставался синим, хотя блоки рядом\n * светились тёпло (владелец, 05.11.2026): множитель может лишь затемнить, тёплого света он не даёт.\n */\nexport function patchEntityLight(\n mat: THREE.MeshLambertMaterial,\n light: EntityLight,\n): void {\n mat.onBeforeCompile = (shader) => {\n shader.uniforms.uBlockLightColor = { value: BLOCK_LIGHT_COLOR };\n shader.uniforms.uAmbient = LIGHT_AMBIENT;\n shader.uniforms.uSkyLight = light.sky;\n shader.uniforms.uBlockLight = light.block;\n shader.fragmentShader = shader.fragmentShader\n .replace(\n \"#include <common>\",\n \"#include <common>\\nuniform vec3 uBlockLightColor;\\nuniform float uAmbient;\\nuniform float uSkyLight;\\nuniform float uBlockLight;\",\n )\n .replace(\n \"#include <aomap_fragment>\",\n [\n \"#include <aomap_fragment>\",\n \"reflectedLight.directDiffuse *= uSkyLight;\",\n \"reflectedLight.indirectDiffuse *= max(uSkyLight, uAmbient);\",\n \"reflectedLight.indirectDiffuse += diffuseColor.rgb * uBlockLightColor * uBlockLight;\",\n ].join(\"\\n\"),\n );\n };\n mat.customProgramCacheKey = () => \"vc-entity-light\";\n mat.needsUpdate = true;\n}\n"
|
|
451
451
|
},
|
|
452
452
|
{
|
|
453
453
|
"path": "gfx/paint.ts",
|
|
@@ -811,7 +811,7 @@
|
|
|
811
811
|
},
|
|
812
812
|
{
|
|
813
813
|
"path": "net/NetGame.ts",
|
|
814
|
-
"content": "// Сетевая игра VoxelCraft: связывает игру (Game) с `RealtimeSession` (@idosgames/core/realtime).\n// Комнату создаёт и в неё входит ЛОББИ (система мультиплеера, ctx.multiplayer) — сюда приходит уже\n// открытая сессия (`NetGame.fromRoom`); своего списка комнат, создания и входа по коду у игры нет.\n//\n// Топология — звезда (комнаты `Host`): каждый гость связан только с хостом, хост пересылает. Роли и\n// протокол — net/protocol.ts. Кто считает мир — net/sim.ts: у каждого «живого» измерения один\n// владелец (первый вошедший; хост не главнее других), остальные в нём — реплики.\n//\n// Игрок и инвентарь — у каждого свои (вариант А, 27.09.2026): здесь по сети ходят только мир,\n// сущности, блочные сущности и то, что мир делает с игроком (урон, отброс, подобранное).\nimport { brightnessAt } from \"../character/light\";\nimport type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport { events } from \"../core/EventBus\";\nimport type { NetEntityAction } from \"../entities/EntityManager\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\nimport { sim } from \"./sim\";\nimport { DiffStore } from \"./DiffStore\";\nimport { OwnerBroadcast } from \"./OwnerBroadcast\";\nimport { RemotePlayer, remoteInventory, type RemoteSink } from \"./RemotePlayer\";\nimport { encodeJsonSync } from \"./codec\";\nimport {\n M,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type Give,\n type Hit,\n type PlayerMeta,\n type EmoteMsg,\n type PinMsg,\n type PortalMsg,\n type ReplyMsg,\n type Pose,\n type Roster,\n type RoomSettings,\n type Shadow,\n type Welcome,\n} from \"./protocol\";\nimport {\n onMessage,\n onFast,\n sendHello,\n onHello,\n applyPose,\n dropPeerView,\n onMeta,\n onBlocks,\n applyBlocks,\n onEntities,\n onEntAction,\n entAction,\n onPortal,\n onBe,\n applyBe,\n storeBe,\n onToPlayer,\n onChat,\n} from \"./game/handlers\";\nimport {\n openContainer,\n releaseContainer,\n busyHint,\n lockBy,\n onLock,\n replyTo,\n onReply,\n handleReply,\n} from \"./game/locks\";\nimport {\n becomeOwner,\n becomeReplica,\n beforeDimension,\n prepareDimension,\n afterDimension,\n onDimEnter,\n enterDim,\n assignOwner,\n} from \"./game/dimensions\";\nimport {\n roster,\n broadcastRoster,\n applyRoster,\n playerList,\n} from \"./game/roster\";\nimport {\n pickSuccessorAndShadow,\n onPeerOpen,\n sendPlayerState,\n onPeerClose,\n onMemberLeft,\n watchHost,\n onHostChanged,\n promote,\n applyWelcome,\n} from \"./game/hostMigration\";\nimport {\n afterTick,\n syncRide,\n ownerSend,\n hasOthersIn,\n sendPose,\n sendMetaIfChanged,\n hostSleepCheck,\n} from \"./game/tick\";\nimport { mayCheat, routeCommand, setOp, kick } from \"./game/admin\";\nimport type { NetRoom, Peer, HostRecord } from \"./game/types\";\nimport { roomSettingsOf, HELLO_RETRY_MS } from \"./game/shared\";\nimport { tr } from \"../i18n\";\n\nexport type { NetRoom } from \"./game/types\";\n\nexport class NetGame implements RemoteSink {\n readonly session: Session;\n readonly selfId: string;\n slot = 0;\n settings: RoomSettings;\n /** Операторы (хост — всегда). */\n readonly ops = new Set<string>();\n readonly diffs = new DiffStore();\n /** Другие игроки по слоту. */\n readonly peers = new Map<number, Peer>();\n /** Владелец каждого живого измерения (userId). */\n readonly owners = new Map<DimensionId, string>();\n /** Хост ушёл — кто подхватит (объявляет хост в списке игроков). */\n successor: string | null = null;\n // ---- только у хоста\n readonly records = new Map<string, HostRecord>();\n nextSlot = 1;\n /** Измерения без владельца: сущности и блочные сущности (дифы — в `diffs`). */\n readonly frozen = new Map<DimensionId, DimensionSave>();\n /** Блочные сущности всех измерений (для вошедшего в измерение гостя). */\n readonly beStore = new Map<DimensionId, Map<string, BeMsg[\"be\"]>>();\n // ---- владелец/реплика своего измерения\n readonly bc = new OwnerBroadcast();\n /** Реплика: что последнее прислал владелец по блочным сущностям — своё отличие уходит ему. */\n readonly replicaBe = new OwnerBroadcast();\n pending: BlockDelta[] = [];\n applyingRemote = false;\n tickN = 0;\n lastMeta = \"\";\n /** Ключ последнего отправленного облика (LOOK); \"\" — отправить заново. */\n lastLook = \"\";\n shadow: Shadow | null = null;\n hostSilentSince = 0;\n private readonly offs: (() => void)[] = [];\n closed = false;\n /** Гость: ждём снимок мира. */\n welcomeWait: ((w: Welcome | string) => void) | null = null;\n /** Очередь обработки надёжного канала (см. конструктор). */\n inbox: Promise<void> = Promise.resolve();\n /** Гость получил снимок мира (хост — всегда). До него сообщения копятся в `stash`. */\n welcomed = true;\n readonly stash: [string, Uint8Array][] = [];\n /** Владелец измерения: занятые контейнеры — ключ (LockMsg.key) → слот игрока. */\n readonly locks = new Map<string, number>();\n /** Свои запросы контейнера, ждущие ответа владельца. */\n readonly lockWait = new Map<\n string,\n { open: () => void; dim: DimensionId; timer: ReturnType<typeof setTimeout> }\n >();\n /** Реплика: id сущности, в которой сидит свой игрок (её ведёт он). */\n driving: number | null = null;\n\n private constructor(\n readonly game: Game,\n session: Session,\n selfId: string,\n settings: RoomSettings,\n readonly name: string,\n /** Уйти из комнаты — через лобби: оно владеет комнатой и забывает её вместе с сессией. */\n readonly leaveRoom: () => Promise<void>,\n ) {\n this.session = session;\n this.selfId = selfId;\n this.settings = settings;\n this.offs.push(\n session.on(\"message\", ({ from, channel, data }) => {\n // Надёжный канал — строго по порядку: сжатое сообщение распаковывается асинхронно, и без\n // очереди следующее («удалить сущность») обогнало бы предыдущее («создать»). Быстрый канал\n // (позы) порядка не требует и ждать не должен.\n if (channel === \"fast\") void this.onMessage(from, channel, data);\n else\n this.inbox = this.inbox.then(() =>\n this.onMessage(from, channel, data),\n );\n }),\n session.on(\"peerOpen\", ({ peerId }) => this.onPeerOpen(peerId)),\n session.on(\"peerClose\", ({ peerId }) => this.onPeerClose(peerId)),\n session.on(\"memberLeft\", ({ userId }) => this.onMemberLeft(userId)),\n session.on(\"hostChanged\", ({ hostId, previousHostId }) =>\n this.onHostChanged(hostId, previousHostId),\n ),\n session.on(\"chat\", ({ text }) => game.chat.print(text)),\n // исключили: сервер отвечает «не участник» (после уведомления хоста) или прислал PlayerLeft kicked\n session.on(\"removed\", ({ reason }) =>\n this.end(\n /kicked|not a member|removed/i.test(reason)\n ? tr({\n en: \"You were removed from the game\",\n ru: \"Вас исключили из игры\",\n })\n : tr({\n en: \"Connection to the game was lost\",\n ru: \"Связь с игрой потеряна\",\n }),\n ),\n ),\n session.on(\"roomClosed\", () =>\n this.end(tr({ en: \"The game is closed\", ru: \"Игра закрыта\" })),\n ),\n // Вышли из комнаты не отсюда (кнопка «Выйти» в комнате лобби): сессия закрыта — сетевая игра\n // окончена. Без этого игра оставалась на мёртвой сессии и молча игнорировала следующую комнату.\n session.on(\"left\", () => this.end(null)),\n );\n // Реестр порталов общий: зажёг портал любой — знают все (выход на той стороне ищется по нему).\n game.onPortalChange = (dim, pos, add) => {\n const msg = encodeJsonSync(M.PORTAL, {\n dim,\n p: pos,\n add,\n } satisfies PortalMsg);\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n };\n // Метки карты общие: свою правку — всем через хоста (автора ставит хост, onPin).\n game.worldMap.onPinChange = (c) => {\n const msg = encodeJsonSync(M.PIN, c satisfies PinMsg);\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n };\n // Правки мира: владелец рассылает все (свои, каскады, пришедшие от реплик), реплика — только свои.\n this.offs.push(\n events.on(\n \"blockChanged\",\n (d: { x: number; y: number; z: number; id: number; meta: number }) => {\n if (this.applyingRemote && !sim.authority) return;\n this.diffs.apply(this.game.dimension, d.x, d.y, d.z, d.id, d.meta);\n this.pending.push({ x: d.x, y: d.y, z: d.z, id: d.id, meta: d.meta });\n },\n ),\n );\n }\n\n get isHost(): boolean {\n return this.session.hostId === this.selfId;\n }\n\n get hostId(): string {\n return this.session.hostId;\n }\n\n /** Может ли этот игрок командами менять мир (хост и операторы, или «команды всем»). */\n get canCheat(): boolean {\n return (\n this.isHost || this.ops.has(this.selfId) || this.settings.cheatsForAll\n );\n }\n\n // =========================================================================== старт\n\n /**\n * Комната пришла из лобби (система мультиплеера, `ctx.multiplayer`): её создали и в неё вошли там,\n * соединения уже открыты — здесь только начинается игра поверх готовой сессии. Хост, запустивший\n * игру, открывает свой мир для остальных; гость (и опоздавший) получает мир хоста.\n */\n static async fromRoom(\n game: Game,\n room: NetRoom,\n fallbackName: string,\n ): Promise<NetGame | string> {\n const session = room.session;\n // имя — публичный профиль игрока в этом тайтле (его кладёт в снимок комнаты сервер)\n const name = session.members.get(session.selfId)?.Username || fallbackName;\n if (room.isHost && !room.lateJoin) {\n const net = new NetGame(\n game,\n session,\n session.selfId,\n roomSettingsOf(room.settings),\n name,\n room.leave,\n );\n net.ops.add(net.selfId);\n net.startAsHost();\n return net;\n }\n const net = new NetGame(\n game,\n session,\n session.selfId,\n { guestMode: \"survival\", cheatsForAll: false },\n name,\n room.leave,\n );\n net.welcomed = false;\n const w = await new Promise<Welcome | string>((resolve) => {\n const retry = setInterval(() => {\n if (session.isOpen(session.hostId)) net.sendHello();\n }, HELLO_RETRY_MS);\n const deadline = setTimeout(\n () =>\n done(tr({ en: \"The host did not answer\", ru: \"Хост не ответил\" })),\n 30_000,\n );\n function done(r: Welcome | string): void {\n clearInterval(retry);\n clearTimeout(deadline);\n resolve(r);\n }\n net.welcomeWait = done;\n if (session.isOpen(session.hostId)) net.sendHello();\n });\n net.welcomeWait = null;\n if (typeof w === \"string\") {\n net.dispose();\n await room.leave();\n return w;\n }\n net.applyWelcome(w);\n return net;\n }\n\n private startAsHost(): void {\n const g = this.game;\n this.slot = 0;\n this.records.set(this.selfId, {\n slot: 0,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n // Мир хоста целиком в хранилище дифов: живое измерение — с мира, остальные — из сейва измерений.\n this.diffs.load(g.dimension, g.world.serializeDiffs());\n for (const [d, save] of g.dims) {\n this.diffs.load(d, save.chunks);\n this.frozen.set(d, {\n chunks: {},\n entities: save.entities ?? [],\n blockEntities: save.blockEntities ?? [],\n });\n }\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(null);\n }\n\n // =========================================================================== отправка\n\n async send(\n to: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (to === this.selfId) {\n await this.onMessage(this.selfId, channel, data);\n return;\n }\n this.session.send(to, channel, data);\n }\n\n /** Хосту (у хоста — себе). */\n toHost(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n void this.send(this.hostId, channel, data);\n }\n\n /** Хост: всем, кроме `except`. */\n toAll(channel: \"rel\" | \"fast\", data: Uint8Array, except?: string): void {\n this.session.broadcast(channel, data, except);\n }\n\n /** Хост: всем игрокам измерения, кроме `except` (себе — тоже, если хост там же). */\n toDim(\n dim: DimensionId,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n for (const [uid, r] of this.records)\n if (r.dim === dim && uid !== except) void this.send(uid, channel, data);\n }\n\n userOfSlot(slot: number): string | null {\n if (slot === this.slot) return this.selfId;\n return this.peers.get(slot)?.userId ?? null;\n }\n\n // =========================================================================== приём\n\n onMessage(\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n return onMessage(this, from, channel, data);\n }\n\n onFast(from: string, type: number, data: Uint8Array): void {\n onFast(this, from, type, data);\n }\n\n sendHello(): void {\n sendHello(this);\n }\n\n onHello(from: string, data: Uint8Array): Promise<void> {\n return onHello(this, from, data);\n }\n\n applyPose(p: Pose): void {\n applyPose(this, p);\n }\n\n dropPeerView(peer: Peer): void {\n dropPeerView(this, peer);\n }\n\n onMeta(from: string, data: Uint8Array): Promise<void> {\n return onMeta(this, from, data);\n }\n\n onBlocks(from: string, type: number, data: Uint8Array): void {\n onBlocks(this, from, type, data);\n }\n\n applyBlocks(list: readonly BlockDelta[], asOwner: boolean): void {\n applyBlocks(this, list, asOwner);\n }\n\n onEntities(from: string, type: number, data: Uint8Array): Promise<void> {\n return onEntities(this, from, type, data);\n }\n\n onEntAction(from: string, data: Uint8Array): Promise<void> {\n return onEntAction(this, from, data);\n }\n\n /** Реплика: действие над сущностью — владельцу измерения (через хоста). */\n entAction(action: NetEntityAction): void {\n entAction(this, action);\n }\n\n /**\n * Открыть контейнер (сундук, печь, вьюк, груз вагонетки, торговлю с жителем). Владелец измерения\n * решает сам, реплика спрашивает его и открывает по ответу — с его содержимым. Занят — подсказка.\n */\n openContainer(key: string, open: () => void): void {\n openContainer(this, key, open);\n }\n\n /** Экран контейнера закрыт — отпустить. */\n releaseContainer(key: string): void {\n releaseContainer(this, key);\n }\n\n busyHint(by: string): void {\n busyHint(this, by);\n }\n\n /** Владелец: имя того, кто держит контейнер (если не `slot` и он всё ещё в этом измерении). */\n lockBy(key: string, slot: number): string | null {\n return lockBy(this, key, slot);\n }\n\n onLock(from: string, data: Uint8Array): Promise<void> {\n return onLock(this, from, data);\n }\n\n /** Ответ игроку `to`: себе — сразу, иначе через хоста (или хост — прямо ему). */\n replyTo(r: ReplyMsg): void {\n replyTo(this, r);\n }\n\n onReply(from: string, data: Uint8Array): Promise<void> {\n return onReply(this, from, data);\n }\n\n handleReply(m: ReplyMsg): void {\n handleReply(this, m);\n }\n\n onPortal(from: string, data: Uint8Array): Promise<void> {\n return onPortal(this, from, data);\n }\n\n onBe(from: string, data: Uint8Array): Promise<void> {\n return onBe(this, from, data);\n }\n\n applyBe(m: BeMsg): void {\n applyBe(this, m);\n }\n\n storeBe(m: BeMsg): void {\n storeBe(this, m);\n }\n\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: Hit[\"effect\"],\n ): void {\n const h: Hit = {\n to: slot,\n amount,\n cause,\n ...(knock ? { knock } : {}),\n ...(effect ? { effect } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.HIT, h));\n }\n\n give(slot: number, stack: ItemStack | null, xp?: number): void {\n const g: Give = {\n to: slot,\n ...(stack ? { stack } : {}),\n ...(xp ? { xp } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.GIVE, g));\n }\n\n onToPlayer(from: string, type: number, data: Uint8Array): Promise<void> {\n return onToPlayer(this, from, type, data);\n }\n\n /** Жест/эмоция своего персонажа — всем (хост рассылает сам, гость — через хоста). */\n sendEmote(m: EmoteMsg): void {\n const msg = encodeJsonSync(M.EMOTE, { ...m, slot: this.slot });\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n }\n\n sendChat(text: string): void {\n const t = text.slice(0, 256);\n this.game.chat.print(`<${this.name}> ${t}`);\n this.toHost(\"rel\", encodeJsonSync(M.CHAT, { from: this.slot, text: t }));\n }\n\n onChat(from: string, data: Uint8Array): Promise<void> {\n return onChat(this, from, data);\n }\n\n /** Стать владельцем своего измерения: сущности и блочные сущности — из переданного сохранения. */\n becomeOwner(save: DimensionSave | null): void {\n becomeOwner(this, save);\n }\n\n /** Стать репликой: сущности — образы от владельца, локальные id — в отдельном диапазоне. */\n becomeReplica(): void {\n becomeReplica(this);\n }\n\n remoteActors() {\n const out = [];\n for (const peer of this.peers.values())\n if (peer.remote && peer.dim === this.game.dimension && peer.pose)\n out.push({\n player: peer.remote as RemotePlayer,\n inventory: remoteInventory(peer.remote, this),\n });\n return out;\n }\n\n /**\n * Перед сменой измерения (начало Game.changeDimension): у всех есть дифы любого измерения, так\n * что целевое строится без ожидания. Сущности поднимаем, только если станем владельцем сразу\n * (хост, измерение ничьё); иначе их пришлёт владелец или хост отдаст владение.\n */\n beforeDimension(target: DimensionId): void {\n beforeDimension(this, target);\n }\n\n pendingOwnTarget = false;\n\n /**\n * Середина Game.changeDimension: прежнее измерение уже снято в `dims` (с сущностями — для\n * передачи следующему владельцу), новое ещё не загружено. Здесь переключается роль: реплике нельзя\n * поднимать сущности из сохранения — её spawn ушёл бы владельцу, а сущности там и так есть.\n */\n prepareDimension(): void {\n prepareDimension(this);\n }\n\n /** После смены измерения: отдать прежнее (если владел) и заявить новое. */\n afterDimension(prev: DimensionId, target: DimensionId): void {\n afterDimension(this, prev, target);\n }\n\n /** Хост: игрок вошёл в измерение — владельцем или репликой. */\n onDimEnter(from: string, m: DimEnter): void {\n onDimEnter(this, from, m);\n }\n\n /** Хост: `who` в измерении `dim` — дать владение (если ничьё) или полный снимок от владельца. */\n enterDim(who: string, dim: DimensionId): void {\n enterDim(this, who, dim);\n }\n\n /** Хост: у измерения нет владельца или он ушёл — отдать следующему игроку в нём, иначе заморозить. */\n assignOwner(dim: DimensionId): void {\n assignOwner(this, dim);\n }\n\n roster(): Roster {\n return roster(this);\n }\n\n broadcastRoster(): void {\n broadcastRoster(this);\n }\n\n applyRoster(r: Roster): void {\n applyRoster(this, r);\n }\n\n /** Игроки для списка по Tab. */\n playerList(): {\n name: string;\n slot: number;\n userId: string;\n host: boolean;\n op: boolean;\n dim: DimensionId;\n rtt: number;\n }[] {\n return playerList(this);\n }\n\n /** Конец симтика Game: разослать накопленное. */\n afterTick(): void {\n afterTick(this);\n }\n\n /**\n * Реплика в транспорте или верхом: сущность ведёт этот игрок (EntityManager.drivenId), владелец\n * её не тикает и получает позу 10 раз в секунду. Сел/слез — сказать владельцу.\n */\n syncRide(): void {\n syncRide(this);\n }\n\n /** Владелец рассылает игрокам измерения: хост — сам, гость — через хоста. */\n ownerSend(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n ownerSend(this, channel, data);\n }\n\n hasOthersIn(dim: DimensionId): boolean {\n return hasOthersIn(this, dim);\n }\n\n sendPose(): void {\n sendPose(this);\n }\n\n sendMetaIfChanged(): void {\n sendMetaIfChanged(this);\n }\n\n /** Сон как в Minecraft: ночь пропускается, когда спят ВСЕ игроки Верхнего мира. */\n hostSleepCheck(_who: string, _m: PlayerMeta): void {\n hostSleepCheck(this, _who, _m);\n }\n\n frame(dt: number): void {\n const w = this.game.world;\n for (const peer of this.peers.values()) {\n const a = peer.avatar;\n if (!a) continue;\n const p = a.group.position;\n a.frame(dt, brightnessAt(w, p.x, p.y + 1, p.z));\n }\n }\n\n // =========================================================================== команды\n\n mayCheat(userId: string): boolean {\n return mayCheat(this, userId);\n }\n\n /**\n * Команда в сетевой игре: «про себя» — здесь, «про мир» — у хоста, без прав — отказ.\n * Возвращает null (исполнять локально) или текст ответа.\n */\n routeCommand(name: string, text: string): string | null {\n return routeCommand(this, name, text);\n }\n\n /** Хост: выдать или снять права оператора. */\n setOp(userId: string, op: boolean): void {\n setOp(this, userId, op);\n }\n\n kick(userId: string, ban: boolean): Promise<string | null> {\n return kick(this, userId, ban);\n }\n\n /** Хост: выбрать преемника (лучшая связь) и отправить ему теневую копию мира. */\n pickSuccessorAndShadow(): void {\n pickSuccessorAndShadow(this);\n }\n\n onPeerOpen(peerId: string): void {\n onPeerOpen(this, peerId);\n }\n\n /** Гость: свой сейв хосту (он хранит его в своём мире). */\n sendPlayerState(): void {\n sendPlayerState(this);\n }\n\n onPeerClose(peerId: string): void {\n onPeerClose(this, peerId);\n }\n\n onMemberLeft(userId: string): void {\n onMemberLeft(this, userId);\n }\n\n /** Гость: связь с хостом пропала. Преемник через 5 с забирает роль (сервер проверит, что хост молчит). */\n watchHost(): void {\n watchHost(this);\n }\n\n onHostChanged(hostId: string, previousHostId: string | null): void {\n onHostChanged(this, hostId, previousHostId);\n }\n\n /** Стал хостом: мир из теневой копии и своей копии дифов, прежние владельцы измерений — как были. */\n promote(): void {\n promote(this);\n }\n\n hostIdBefore = \"\";\n\n // =========================================================================== гость: вход\n\n applyWelcome(w: Welcome): void {\n applyWelcome(this, w);\n }\n\n /** Хост уходит: свежая теневая копия преемнику, и сервер отдаст роль именно ему. */\n prepareExit(): void {\n if (this.closed || !this.isHost) return;\n this.pickSuccessorAndShadow();\n this.session.setSuccessor(this.successor);\n }\n\n /** Хост: сейвы других игроков — в мир хоста (Game.persist). */\n collectPlayerStates(into: Map<string, unknown>): void {\n if (!this.isHost) return;\n for (const [uid, r] of this.records)\n if (uid !== this.selfId && r.state) into.set(uid, r.state);\n }\n\n /** Уйти из сетевой игры (кнопка). Хост передаёт мир преемнику. */\n async leave(): Promise<void> {\n if (this.closed) return;\n this.prepareExit();\n this.dispose();\n await this.leaveRoom();\n this.game.exitNet(null);\n }\n\n /** Сессия уже закрыта снаружи — закончить сетевую игру без сообщения (см. Game.acceptRoom). */\n closeStale(): void {\n this.end(null);\n }\n\n /** The network game is over; `reason` — what to tell the player (null — they left themselves). */\n end(reason: string | null): void {\n if (this.closed) return;\n this.dispose();\n this.game.exitNet(reason);\n }\n\n dispose(): void {\n if (this.closed) return;\n this.closed = true;\n for (const off of this.offs.splice(0)) off();\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n this.peers.clear();\n const g = this.game;\n g.entities.replica = false;\n g.entities.others = () => [];\n g.entities.onLocalSpawn = null;\n g.entities.onRemoteAction = null;\n g.entities.localSlot = null;\n g.entities.drivenId = null;\n g.onPortalChange = null;\n g.worldMap.onPinChange = null;\n for (const w of this.lockWait.values()) clearTimeout(w.timer);\n this.lockWait.clear();\n this.locks.clear();\n sim.authority = true;\n }\n}\n"
|
|
814
|
+
"content": "// Сетевая игра VoxelCraft: связывает игру (Game) с `RealtimeSession` (@idosgames/core/realtime).\n// Комнату создаёт и в неё входит ЛОББИ (система мультиплеера, ctx.multiplayer) — сюда приходит уже\n// открытая сессия (`NetGame.fromRoom`); своего списка комнат, создания и входа по коду у игры нет.\n//\n// Топология — звезда (комнаты `Host`): каждый гость связан только с хостом, хост пересылает. Роли и\n// протокол — net/protocol.ts. Кто считает мир — net/sim.ts: у каждого «живого» измерения один\n// владелец (первый вошедший; хост не главнее других), остальные в нём — реплики.\n//\n// Игрок и инвентарь — у каждого свои (вариант А, 27.09.2026): здесь по сети ходят только мир,\n// сущности, блочные сущности и то, что мир делает с игроком (урон, отброс, подобранное).\nimport { lightAt } from \"../character/light\";\nimport type { RealtimeSession as Session } from \"@idosgames/core/realtime\";\nimport { events } from \"../core/EventBus\";\nimport type { NetEntityAction } from \"../entities/EntityManager\";\nimport type { DimensionId } from \"../world/Dimensions\";\nimport type { DimensionSave, ItemStack } from \"../types\";\nimport type { Game } from \"../main\";\nimport { sim } from \"./sim\";\nimport { DiffStore } from \"./DiffStore\";\nimport { OwnerBroadcast } from \"./OwnerBroadcast\";\nimport { RemotePlayer, remoteInventory, type RemoteSink } from \"./RemotePlayer\";\nimport { encodeJsonSync } from \"./codec\";\nimport {\n M,\n type BeMsg,\n type BlockDelta,\n type DimEnter,\n type Give,\n type Hit,\n type PlayerMeta,\n type EmoteMsg,\n type PinMsg,\n type PortalMsg,\n type ReplyMsg,\n type Pose,\n type Roster,\n type RoomSettings,\n type Shadow,\n type Welcome,\n} from \"./protocol\";\nimport {\n onMessage,\n onFast,\n sendHello,\n onHello,\n applyPose,\n dropPeerView,\n onMeta,\n onBlocks,\n applyBlocks,\n onEntities,\n onEntAction,\n entAction,\n onPortal,\n onBe,\n applyBe,\n storeBe,\n onToPlayer,\n onChat,\n} from \"./game/handlers\";\nimport {\n openContainer,\n releaseContainer,\n busyHint,\n lockBy,\n onLock,\n replyTo,\n onReply,\n handleReply,\n} from \"./game/locks\";\nimport {\n becomeOwner,\n becomeReplica,\n beforeDimension,\n prepareDimension,\n afterDimension,\n onDimEnter,\n enterDim,\n assignOwner,\n} from \"./game/dimensions\";\nimport {\n roster,\n broadcastRoster,\n applyRoster,\n playerList,\n} from \"./game/roster\";\nimport {\n pickSuccessorAndShadow,\n onPeerOpen,\n sendPlayerState,\n onPeerClose,\n onMemberLeft,\n watchHost,\n onHostChanged,\n promote,\n applyWelcome,\n} from \"./game/hostMigration\";\nimport {\n afterTick,\n syncRide,\n ownerSend,\n hasOthersIn,\n sendPose,\n sendMetaIfChanged,\n hostSleepCheck,\n} from \"./game/tick\";\nimport { mayCheat, routeCommand, setOp, kick } from \"./game/admin\";\nimport type { NetRoom, Peer, HostRecord } from \"./game/types\";\nimport { roomSettingsOf, HELLO_RETRY_MS } from \"./game/shared\";\nimport { tr } from \"../i18n\";\n\nexport type { NetRoom } from \"./game/types\";\n\nexport class NetGame implements RemoteSink {\n readonly session: Session;\n readonly selfId: string;\n slot = 0;\n settings: RoomSettings;\n /** Операторы (хост — всегда). */\n readonly ops = new Set<string>();\n readonly diffs = new DiffStore();\n /** Другие игроки по слоту. */\n readonly peers = new Map<number, Peer>();\n /** Владелец каждого живого измерения (userId). */\n readonly owners = new Map<DimensionId, string>();\n /** Хост ушёл — кто подхватит (объявляет хост в списке игроков). */\n successor: string | null = null;\n // ---- только у хоста\n readonly records = new Map<string, HostRecord>();\n nextSlot = 1;\n /** Измерения без владельца: сущности и блочные сущности (дифы — в `diffs`). */\n readonly frozen = new Map<DimensionId, DimensionSave>();\n /** Блочные сущности всех измерений (для вошедшего в измерение гостя). */\n readonly beStore = new Map<DimensionId, Map<string, BeMsg[\"be\"]>>();\n // ---- владелец/реплика своего измерения\n readonly bc = new OwnerBroadcast();\n /** Реплика: что последнее прислал владелец по блочным сущностям — своё отличие уходит ему. */\n readonly replicaBe = new OwnerBroadcast();\n pending: BlockDelta[] = [];\n applyingRemote = false;\n tickN = 0;\n lastMeta = \"\";\n /** Ключ последнего отправленного облика (LOOK); \"\" — отправить заново. */\n lastLook = \"\";\n shadow: Shadow | null = null;\n hostSilentSince = 0;\n private readonly offs: (() => void)[] = [];\n closed = false;\n /** Гость: ждём снимок мира. */\n welcomeWait: ((w: Welcome | string) => void) | null = null;\n /** Очередь обработки надёжного канала (см. конструктор). */\n inbox: Promise<void> = Promise.resolve();\n /** Гость получил снимок мира (хост — всегда). До него сообщения копятся в `stash`. */\n welcomed = true;\n readonly stash: [string, Uint8Array][] = [];\n /** Владелец измерения: занятые контейнеры — ключ (LockMsg.key) → слот игрока. */\n readonly locks = new Map<string, number>();\n /** Свои запросы контейнера, ждущие ответа владельца. */\n readonly lockWait = new Map<\n string,\n { open: () => void; dim: DimensionId; timer: ReturnType<typeof setTimeout> }\n >();\n /** Реплика: id сущности, в которой сидит свой игрок (её ведёт он). */\n driving: number | null = null;\n\n private constructor(\n readonly game: Game,\n session: Session,\n selfId: string,\n settings: RoomSettings,\n readonly name: string,\n /** Уйти из комнаты — через лобби: оно владеет комнатой и забывает её вместе с сессией. */\n readonly leaveRoom: () => Promise<void>,\n ) {\n this.session = session;\n this.selfId = selfId;\n this.settings = settings;\n this.offs.push(\n session.on(\"message\", ({ from, channel, data }) => {\n // Надёжный канал — строго по порядку: сжатое сообщение распаковывается асинхронно, и без\n // очереди следующее («удалить сущность») обогнало бы предыдущее («создать»). Быстрый канал\n // (позы) порядка не требует и ждать не должен.\n if (channel === \"fast\") void this.onMessage(from, channel, data);\n else\n this.inbox = this.inbox.then(() =>\n this.onMessage(from, channel, data),\n );\n }),\n session.on(\"peerOpen\", ({ peerId }) => this.onPeerOpen(peerId)),\n session.on(\"peerClose\", ({ peerId }) => this.onPeerClose(peerId)),\n session.on(\"memberLeft\", ({ userId }) => this.onMemberLeft(userId)),\n session.on(\"hostChanged\", ({ hostId, previousHostId }) =>\n this.onHostChanged(hostId, previousHostId),\n ),\n session.on(\"chat\", ({ text }) => game.chat.print(text)),\n // исключили: сервер отвечает «не участник» (после уведомления хоста) или прислал PlayerLeft kicked\n session.on(\"removed\", ({ reason }) =>\n this.end(\n /kicked|not a member|removed/i.test(reason)\n ? tr({\n en: \"You were removed from the game\",\n ru: \"Вас исключили из игры\",\n })\n : tr({\n en: \"Connection to the game was lost\",\n ru: \"Связь с игрой потеряна\",\n }),\n ),\n ),\n session.on(\"roomClosed\", () =>\n this.end(tr({ en: \"The game is closed\", ru: \"Игра закрыта\" })),\n ),\n // Вышли из комнаты не отсюда (кнопка «Выйти» в комнате лобби): сессия закрыта — сетевая игра\n // окончена. Без этого игра оставалась на мёртвой сессии и молча игнорировала следующую комнату.\n session.on(\"left\", () => this.end(null)),\n );\n // Реестр порталов общий: зажёг портал любой — знают все (выход на той стороне ищется по нему).\n game.onPortalChange = (dim, pos, add) => {\n const msg = encodeJsonSync(M.PORTAL, {\n dim,\n p: pos,\n add,\n } satisfies PortalMsg);\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n };\n // Метки карты общие: свою правку — всем через хоста (автора ставит хост, onPin).\n game.worldMap.onPinChange = (c) => {\n const msg = encodeJsonSync(M.PIN, c satisfies PinMsg);\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n };\n // Правки мира: владелец рассылает все (свои, каскады, пришедшие от реплик), реплика — только свои.\n this.offs.push(\n events.on(\n \"blockChanged\",\n (d: { x: number; y: number; z: number; id: number; meta: number }) => {\n if (this.applyingRemote && !sim.authority) return;\n this.diffs.apply(this.game.dimension, d.x, d.y, d.z, d.id, d.meta);\n this.pending.push({ x: d.x, y: d.y, z: d.z, id: d.id, meta: d.meta });\n },\n ),\n );\n }\n\n get isHost(): boolean {\n return this.session.hostId === this.selfId;\n }\n\n get hostId(): string {\n return this.session.hostId;\n }\n\n /** Может ли этот игрок командами менять мир (хост и операторы, или «команды всем»). */\n get canCheat(): boolean {\n return (\n this.isHost || this.ops.has(this.selfId) || this.settings.cheatsForAll\n );\n }\n\n // =========================================================================== старт\n\n /**\n * Комната пришла из лобби (система мультиплеера, `ctx.multiplayer`): её создали и в неё вошли там,\n * соединения уже открыты — здесь только начинается игра поверх готовой сессии. Хост, запустивший\n * игру, открывает свой мир для остальных; гость (и опоздавший) получает мир хоста.\n */\n static async fromRoom(\n game: Game,\n room: NetRoom,\n fallbackName: string,\n ): Promise<NetGame | string> {\n const session = room.session;\n // имя — публичный профиль игрока в этом тайтле (его кладёт в снимок комнаты сервер)\n const name = session.members.get(session.selfId)?.Username || fallbackName;\n if (room.isHost && !room.lateJoin) {\n const net = new NetGame(\n game,\n session,\n session.selfId,\n roomSettingsOf(room.settings),\n name,\n room.leave,\n );\n net.ops.add(net.selfId);\n net.startAsHost();\n return net;\n }\n const net = new NetGame(\n game,\n session,\n session.selfId,\n { guestMode: \"survival\", cheatsForAll: false },\n name,\n room.leave,\n );\n net.welcomed = false;\n const w = await new Promise<Welcome | string>((resolve) => {\n const retry = setInterval(() => {\n if (session.isOpen(session.hostId)) net.sendHello();\n }, HELLO_RETRY_MS);\n const deadline = setTimeout(\n () =>\n done(tr({ en: \"The host did not answer\", ru: \"Хост не ответил\" })),\n 30_000,\n );\n function done(r: Welcome | string): void {\n clearInterval(retry);\n clearTimeout(deadline);\n resolve(r);\n }\n net.welcomeWait = done;\n if (session.isOpen(session.hostId)) net.sendHello();\n });\n net.welcomeWait = null;\n if (typeof w === \"string\") {\n net.dispose();\n await room.leave();\n return w;\n }\n net.applyWelcome(w);\n return net;\n }\n\n private startAsHost(): void {\n const g = this.game;\n this.slot = 0;\n this.records.set(this.selfId, {\n slot: 0,\n name: this.name,\n dim: g.dimension,\n op: true,\n state: null,\n });\n // Мир хоста целиком в хранилище дифов: живое измерение — с мира, остальные — из сейва измерений.\n this.diffs.load(g.dimension, g.world.serializeDiffs());\n for (const [d, save] of g.dims) {\n this.diffs.load(d, save.chunks);\n this.frozen.set(d, {\n chunks: {},\n entities: save.entities ?? [],\n blockEntities: save.blockEntities ?? [],\n });\n }\n this.owners.set(g.dimension, this.selfId);\n this.becomeOwner(null);\n }\n\n // =========================================================================== отправка\n\n async send(\n to: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n if (to === this.selfId) {\n await this.onMessage(this.selfId, channel, data);\n return;\n }\n this.session.send(to, channel, data);\n }\n\n /** Хосту (у хоста — себе). */\n toHost(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n void this.send(this.hostId, channel, data);\n }\n\n /** Хост: всем, кроме `except`. */\n toAll(channel: \"rel\" | \"fast\", data: Uint8Array, except?: string): void {\n this.session.broadcast(channel, data, except);\n }\n\n /** Хост: всем игрокам измерения, кроме `except` (себе — тоже, если хост там же). */\n toDim(\n dim: DimensionId,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n except?: string,\n ): void {\n for (const [uid, r] of this.records)\n if (r.dim === dim && uid !== except) void this.send(uid, channel, data);\n }\n\n userOfSlot(slot: number): string | null {\n if (slot === this.slot) return this.selfId;\n return this.peers.get(slot)?.userId ?? null;\n }\n\n // =========================================================================== приём\n\n onMessage(\n from: string,\n channel: \"rel\" | \"fast\",\n data: Uint8Array,\n ): Promise<void> {\n return onMessage(this, from, channel, data);\n }\n\n onFast(from: string, type: number, data: Uint8Array): void {\n onFast(this, from, type, data);\n }\n\n sendHello(): void {\n sendHello(this);\n }\n\n onHello(from: string, data: Uint8Array): Promise<void> {\n return onHello(this, from, data);\n }\n\n applyPose(p: Pose): void {\n applyPose(this, p);\n }\n\n dropPeerView(peer: Peer): void {\n dropPeerView(this, peer);\n }\n\n onMeta(from: string, data: Uint8Array): Promise<void> {\n return onMeta(this, from, data);\n }\n\n onBlocks(from: string, type: number, data: Uint8Array): void {\n onBlocks(this, from, type, data);\n }\n\n applyBlocks(list: readonly BlockDelta[], asOwner: boolean): void {\n applyBlocks(this, list, asOwner);\n }\n\n onEntities(from: string, type: number, data: Uint8Array): Promise<void> {\n return onEntities(this, from, type, data);\n }\n\n onEntAction(from: string, data: Uint8Array): Promise<void> {\n return onEntAction(this, from, data);\n }\n\n /** Реплика: действие над сущностью — владельцу измерения (через хоста). */\n entAction(action: NetEntityAction): void {\n entAction(this, action);\n }\n\n /**\n * Открыть контейнер (сундук, печь, вьюк, груз вагонетки, торговлю с жителем). Владелец измерения\n * решает сам, реплика спрашивает его и открывает по ответу — с его содержимым. Занят — подсказка.\n */\n openContainer(key: string, open: () => void): void {\n openContainer(this, key, open);\n }\n\n /** Экран контейнера закрыт — отпустить. */\n releaseContainer(key: string): void {\n releaseContainer(this, key);\n }\n\n busyHint(by: string): void {\n busyHint(this, by);\n }\n\n /** Владелец: имя того, кто держит контейнер (если не `slot` и он всё ещё в этом измерении). */\n lockBy(key: string, slot: number): string | null {\n return lockBy(this, key, slot);\n }\n\n onLock(from: string, data: Uint8Array): Promise<void> {\n return onLock(this, from, data);\n }\n\n /** Ответ игроку `to`: себе — сразу, иначе через хоста (или хост — прямо ему). */\n replyTo(r: ReplyMsg): void {\n replyTo(this, r);\n }\n\n onReply(from: string, data: Uint8Array): Promise<void> {\n return onReply(this, from, data);\n }\n\n handleReply(m: ReplyMsg): void {\n handleReply(this, m);\n }\n\n onPortal(from: string, data: Uint8Array): Promise<void> {\n return onPortal(this, from, data);\n }\n\n onBe(from: string, data: Uint8Array): Promise<void> {\n return onBe(this, from, data);\n }\n\n applyBe(m: BeMsg): void {\n applyBe(this, m);\n }\n\n storeBe(m: BeMsg): void {\n storeBe(this, m);\n }\n\n hit(\n slot: number,\n amount: number,\n cause: string,\n knock?: [number, number, number],\n effect?: Hit[\"effect\"],\n ): void {\n const h: Hit = {\n to: slot,\n amount,\n cause,\n ...(knock ? { knock } : {}),\n ...(effect ? { effect } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.HIT, h));\n }\n\n give(slot: number, stack: ItemStack | null, xp?: number): void {\n const g: Give = {\n to: slot,\n ...(stack ? { stack } : {}),\n ...(xp ? { xp } : {}),\n };\n this.toHost(\"rel\", encodeJsonSync(M.GIVE, g));\n }\n\n onToPlayer(from: string, type: number, data: Uint8Array): Promise<void> {\n return onToPlayer(this, from, type, data);\n }\n\n /** Жест/эмоция своего персонажа — всем (хост рассылает сам, гость — через хоста). */\n sendEmote(m: EmoteMsg): void {\n const msg = encodeJsonSync(M.EMOTE, { ...m, slot: this.slot });\n if (this.isHost) this.toAll(\"rel\", msg);\n else this.toHost(\"rel\", msg);\n }\n\n sendChat(text: string): void {\n const t = text.slice(0, 256);\n this.game.chat.print(`<${this.name}> ${t}`);\n this.toHost(\"rel\", encodeJsonSync(M.CHAT, { from: this.slot, text: t }));\n }\n\n onChat(from: string, data: Uint8Array): Promise<void> {\n return onChat(this, from, data);\n }\n\n /** Стать владельцем своего измерения: сущности и блочные сущности — из переданного сохранения. */\n becomeOwner(save: DimensionSave | null): void {\n becomeOwner(this, save);\n }\n\n /** Стать репликой: сущности — образы от владельца, локальные id — в отдельном диапазоне. */\n becomeReplica(): void {\n becomeReplica(this);\n }\n\n remoteActors() {\n const out = [];\n for (const peer of this.peers.values())\n if (peer.remote && peer.dim === this.game.dimension && peer.pose)\n out.push({\n player: peer.remote as RemotePlayer,\n inventory: remoteInventory(peer.remote, this),\n });\n return out;\n }\n\n /**\n * Перед сменой измерения (начало Game.changeDimension): у всех есть дифы любого измерения, так\n * что целевое строится без ожидания. Сущности поднимаем, только если станем владельцем сразу\n * (хост, измерение ничьё); иначе их пришлёт владелец или хост отдаст владение.\n */\n beforeDimension(target: DimensionId): void {\n beforeDimension(this, target);\n }\n\n pendingOwnTarget = false;\n\n /**\n * Середина Game.changeDimension: прежнее измерение уже снято в `dims` (с сущностями — для\n * передачи следующему владельцу), новое ещё не загружено. Здесь переключается роль: реплике нельзя\n * поднимать сущности из сохранения — её spawn ушёл бы владельцу, а сущности там и так есть.\n */\n prepareDimension(): void {\n prepareDimension(this);\n }\n\n /** После смены измерения: отдать прежнее (если владел) и заявить новое. */\n afterDimension(prev: DimensionId, target: DimensionId): void {\n afterDimension(this, prev, target);\n }\n\n /** Хост: игрок вошёл в измерение — владельцем или репликой. */\n onDimEnter(from: string, m: DimEnter): void {\n onDimEnter(this, from, m);\n }\n\n /** Хост: `who` в измерении `dim` — дать владение (если ничьё) или полный снимок от владельца. */\n enterDim(who: string, dim: DimensionId): void {\n enterDim(this, who, dim);\n }\n\n /** Хост: у измерения нет владельца или он ушёл — отдать следующему игроку в нём, иначе заморозить. */\n assignOwner(dim: DimensionId): void {\n assignOwner(this, dim);\n }\n\n roster(): Roster {\n return roster(this);\n }\n\n broadcastRoster(): void {\n broadcastRoster(this);\n }\n\n applyRoster(r: Roster): void {\n applyRoster(this, r);\n }\n\n /** Игроки для списка по Tab. */\n playerList(): {\n name: string;\n slot: number;\n userId: string;\n host: boolean;\n op: boolean;\n dim: DimensionId;\n rtt: number;\n }[] {\n return playerList(this);\n }\n\n /** Конец симтика Game: разослать накопленное. */\n afterTick(): void {\n afterTick(this);\n }\n\n /**\n * Реплика в транспорте или верхом: сущность ведёт этот игрок (EntityManager.drivenId), владелец\n * её не тикает и получает позу 10 раз в секунду. Сел/слез — сказать владельцу.\n */\n syncRide(): void {\n syncRide(this);\n }\n\n /** Владелец рассылает игрокам измерения: хост — сам, гость — через хоста. */\n ownerSend(channel: \"rel\" | \"fast\", data: Uint8Array): void {\n ownerSend(this, channel, data);\n }\n\n hasOthersIn(dim: DimensionId): boolean {\n return hasOthersIn(this, dim);\n }\n\n sendPose(): void {\n sendPose(this);\n }\n\n sendMetaIfChanged(): void {\n sendMetaIfChanged(this);\n }\n\n /** Сон как в Minecraft: ночь пропускается, когда спят ВСЕ игроки Верхнего мира. */\n hostSleepCheck(_who: string, _m: PlayerMeta): void {\n hostSleepCheck(this, _who, _m);\n }\n\n frame(dt: number): void {\n const w = this.game.world;\n for (const peer of this.peers.values()) {\n const a = peer.avatar;\n if (!a) continue;\n const p = a.group.position;\n a.frame(dt, lightAt(w, p.x, p.y + 1, p.z));\n }\n }\n\n // =========================================================================== команды\n\n mayCheat(userId: string): boolean {\n return mayCheat(this, userId);\n }\n\n /**\n * Команда в сетевой игре: «про себя» — здесь, «про мир» — у хоста, без прав — отказ.\n * Возвращает null (исполнять локально) или текст ответа.\n */\n routeCommand(name: string, text: string): string | null {\n return routeCommand(this, name, text);\n }\n\n /** Хост: выдать или снять права оператора. */\n setOp(userId: string, op: boolean): void {\n setOp(this, userId, op);\n }\n\n kick(userId: string, ban: boolean): Promise<string | null> {\n return kick(this, userId, ban);\n }\n\n /** Хост: выбрать преемника (лучшая связь) и отправить ему теневую копию мира. */\n pickSuccessorAndShadow(): void {\n pickSuccessorAndShadow(this);\n }\n\n onPeerOpen(peerId: string): void {\n onPeerOpen(this, peerId);\n }\n\n /** Гость: свой сейв хосту (он хранит его в своём мире). */\n sendPlayerState(): void {\n sendPlayerState(this);\n }\n\n onPeerClose(peerId: string): void {\n onPeerClose(this, peerId);\n }\n\n onMemberLeft(userId: string): void {\n onMemberLeft(this, userId);\n }\n\n /** Гость: связь с хостом пропала. Преемник через 5 с забирает роль (сервер проверит, что хост молчит). */\n watchHost(): void {\n watchHost(this);\n }\n\n onHostChanged(hostId: string, previousHostId: string | null): void {\n onHostChanged(this, hostId, previousHostId);\n }\n\n /** Стал хостом: мир из теневой копии и своей копии дифов, прежние владельцы измерений — как были. */\n promote(): void {\n promote(this);\n }\n\n hostIdBefore = \"\";\n\n // =========================================================================== гость: вход\n\n applyWelcome(w: Welcome): void {\n applyWelcome(this, w);\n }\n\n /** Хост уходит: свежая теневая копия преемнику, и сервер отдаст роль именно ему. */\n prepareExit(): void {\n if (this.closed || !this.isHost) return;\n this.pickSuccessorAndShadow();\n this.session.setSuccessor(this.successor);\n }\n\n /** Хост: сейвы других игроков — в мир хоста (Game.persist). */\n collectPlayerStates(into: Map<string, unknown>): void {\n if (!this.isHost) return;\n for (const [uid, r] of this.records)\n if (uid !== this.selfId && r.state) into.set(uid, r.state);\n }\n\n /** Уйти из сетевой игры (кнопка). Хост передаёт мир преемнику. */\n async leave(): Promise<void> {\n if (this.closed) return;\n this.prepareExit();\n this.dispose();\n await this.leaveRoom();\n this.game.exitNet(null);\n }\n\n /** Сессия уже закрыта снаружи — закончить сетевую игру без сообщения (см. Game.acceptRoom). */\n closeStale(): void {\n this.end(null);\n }\n\n /** The network game is over; `reason` — what to tell the player (null — they left themselves). */\n end(reason: string | null): void {\n if (this.closed) return;\n this.dispose();\n this.game.exitNet(reason);\n }\n\n dispose(): void {\n if (this.closed) return;\n this.closed = true;\n for (const off of this.offs.splice(0)) off();\n for (const peer of this.peers.values()) this.dropPeerView(peer);\n this.peers.clear();\n const g = this.game;\n g.entities.replica = false;\n g.entities.others = () => [];\n g.entities.onLocalSpawn = null;\n g.entities.onRemoteAction = null;\n g.entities.localSlot = null;\n g.entities.drivenId = null;\n g.onPortalChange = null;\n g.worldMap.onPinChange = null;\n for (const w of this.lockWait.values()) clearTimeout(w.timer);\n this.lockWait.clear();\n this.locks.clear();\n sim.authority = true;\n }\n}\n"
|
|
815
815
|
},
|
|
816
816
|
{
|
|
817
817
|
"path": "net/OwnerBroadcast.ts",
|
|
@@ -823,7 +823,7 @@
|
|
|
823
823
|
},
|
|
824
824
|
{
|
|
825
825
|
"path": "net/RemotePlayer.ts",
|
|
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,
|
|
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 type { PlaceLight } from \"../gfx/LightMaterial\";\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, light?: PlaceLight): 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 ...(light ? { light } : {}),\n });\n }\n\n dispose(scene: THREE.Object3D): void {\n scene.remove(this.actor.group);\n this.actor.dispose();\n }\n}\n"
|
|
827
827
|
},
|
|
828
828
|
{
|
|
829
829
|
"path": "net/sim.ts",
|
|
@@ -1507,7 +1507,7 @@
|
|
|
1507
1507
|
},
|
|
1508
1508
|
{
|
|
1509
1509
|
"path": "style.css",
|
|
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"
|
|
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: none;\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/* тёмный фон под открытой консолью: касание или клик по нему закрывает её */\n.chatBack {\n position: fixed;\n inset: 0;\n z-index: 7; /* над игрой и кнопками касаний (.touchUi, z 6), под строкой (#chat, z 8) */\n background: rgba(0, 0, 0, 0.35);\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: none;\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: none;\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: none;\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 /* над слоем касаний (.touchUi, z 6): карточку задания можно нажать */\n z-index: 7;\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/* трекер коротко: карточка нажимается (детали), остальное поле сквозь трекер не перехватывается */\n#questTracker .qCard {\n pointer-events: auto;\n cursor: pointer;\n width: fit-content;\n max-width: 100%;\n -webkit-user-select: none;\n user-select: none;\n}\n#questTracker .qCard:not(.open) {\n padding: 4px 8px;\n}\n#questTracker .qCard:not(.open) .qHead {\n margin-bottom: 1px;\n}\n.qMore {\n margin-left: auto;\n padding-left: 6px;\n opacity: 0.7;\n font-size: 10px;\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: none;\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: none;\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: none;\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: none;\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: none;\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: none;\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}\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: none;\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\n/* ---------- сенсорный экран: раскладка по ориентации (05.11.2026) ----------\n Размеры как в мобильных играх: на телефоне кнопки действий 52 px, мелкие 34 px, джойстик 104 px\n (владелец: «на мобилках кнопки большие»); на планшете крупнее (медиазапрос в конце блока).\n Нижняя панель (хотбар, сердечки, голод, броня, опыт) ужата zoom'ом под ширину экрана\n (--vc-hud-zoom, считает ui/mobileFit.ts), над ней джойстик и кнопки (--vc-hud-h: её высота).\n Вверху слева ряд мелких кнопок, справа миникарта; компас между ними, ниже трекер заданий. */\nbody.touch {\n --vc-btn: 52px;\n --vc-btn-gap: 8px;\n --vc-small: 34px;\n --vc-stick: 104px;\n --vc-map: 72px;\n}\nbody.touch #debug,\nbody.touch #fps {\n display: none;\n}\nbody.touch #hotbar,\nbody.touch #hearts,\nbody.touch #hunger,\nbody.touch #armorBar,\nbody.touch #bubbles,\nbody.touch #xpBar,\nbody.touch #xpLevel {\n zoom: var(--vc-hud-zoom, 1);\n}\nbody.touch #hearts,\nbody.touch #hunger {\n font-size: 15px;\n letter-spacing: 0;\n}\nbody.touch .tBtn {\n border-width: 2px;\n border-radius: 10px;\n}\nbody.touch .tJump,\nbody.touch .tSneak,\nbody.touch .tAttack,\nbody.touch .tUse {\n width: var(--vc-btn);\n height: var(--vc-btn);\n font-size: 19px;\n}\nbody.touch .tStick {\n width: var(--vc-stick);\n height: var(--vc-stick);\n}\nbody.touch .tKnob {\n width: 44px;\n height: 44px;\n margin: -22px;\n}\nbody.touch .tPause,\nbody.touch .tQuests,\nbody.touch .tView,\nbody.touch .tEmote,\nbody.touch .tChat {\n top: calc(var(--idos-safe-top, 0px) + 8px);\n right: auto;\n width: var(--vc-small);\n height: var(--vc-small);\n font-size: 14px;\n}\nbody.touch .tPause {\n left: 8px;\n}\nbody.touch .tQuests {\n left: calc(8px + (var(--vc-small) + 6px));\n}\nbody.touch .tView {\n left: calc(8px + (var(--vc-small) + 6px) * 2);\n}\nbody.touch .tEmote {\n left: calc(8px + (var(--vc-small) + 6px) * 3);\n}\nbody.touch .tChat {\n left: calc(8px + (var(--vc-small) + 6px) * 4);\n}\nbody.touch .tInv,\nbody.touch .tDrop {\n top: auto;\n width: 38px;\n height: 38px;\n font-size: 15px;\n}\nbody.touch #minimap {\n top: calc(var(--idos-safe-top, 0px) + 8px);\n right: 8px;\n width: var(--vc-map);\n}\nbody.touch #minimap canvas {\n width: var(--vc-map);\n height: var(--vc-map);\n}\nbody.touch .mmCoords {\n font-size: 10px;\n}\nbody.touch #timeIndicator {\n top: calc(var(--idos-safe-top, 0px) + var(--vc-map) + 26px);\n right: 8px;\n font-size: 11px;\n}\nbody.touch #effects {\n top: calc(var(--idos-safe-top, 0px) + var(--vc-map) + 44px);\n right: 8px;\n font-size: 11px;\n}\nbody.touch #questTracker {\n width: min(230px, calc(100vw - 110px));\n font-size: 11px;\n}\nbody.touch #navCompass {\n transform: none;\n}\n\n/* кнопки действий 2×2 у правого края: прыжок и присед справа, удар и ✋ левее; ▦ и «выбросить» над ними */\nbody.touch .tJump {\n right: 10px;\n bottom: var(--vc-touch-b);\n}\nbody.touch .tSneak {\n right: 10px;\n bottom: calc(var(--vc-touch-b) + var(--vc-btn) + var(--vc-btn-gap));\n}\nbody.touch .tAttack {\n right: calc(10px + var(--vc-btn) + var(--vc-btn-gap));\n bottom: var(--vc-touch-b);\n}\nbody.touch .tUse {\n right: calc(10px + var(--vc-btn) + var(--vc-btn-gap));\n bottom: calc(var(--vc-touch-b) + var(--vc-btn) + var(--vc-btn-gap));\n}\nbody.touch .tInv {\n right: 10px;\n bottom: calc(var(--vc-touch-b) + (var(--vc-btn) + var(--vc-btn-gap)) * 2);\n}\nbody.touch .tDrop {\n right: 54px;\n bottom: calc(var(--vc-touch-b) + (var(--vc-btn) + var(--vc-btn-gap)) * 2);\n}\n\n/* вертикально: компас под рядом кнопок, джойстик и кнопки над нижней панелью */\n@media (orientation: portrait) {\n body.touch {\n --vc-touch-b: calc(\n var(--idos-safe-bottom, 0px) + var(--vc-hud-h, 94px) + 12px\n );\n }\n body.touch #navCompass {\n left: 8px;\n top: calc(var(--idos-safe-top, 0px) + var(--vc-small) + 12px);\n width: min(360px, calc(100vw - var(--vc-map) - 28px));\n }\n body.touch #questTracker {\n top: calc(var(--idos-safe-top, 0px) + var(--vc-small) + 48px);\n left: 8px;\n }\n body.touch .tStick {\n left: 14px;\n bottom: var(--vc-touch-b);\n }\n}\n\n/* горизонтально: джойстик и кнопки по углам низа, хотбар между ними, компас в верхней строке */\n@media (orientation: landscape) {\n body.touch {\n --vc-touch-b: calc(var(--idos-safe-bottom, 0px) + 10px);\n }\n body.touch #navCompass {\n left: calc(8px + (var(--vc-small) + 6px) * 5 + 8px);\n top: calc(var(--idos-safe-top, 0px) + 8px);\n width: min(\n 360px,\n calc(100vw - (var(--vc-small) + 6px) * 5 - var(--vc-map) - 40px)\n );\n }\n body.touch #questTracker {\n top: calc(var(--idos-safe-top, 0px) + var(--vc-small) + 16px);\n left: 8px;\n }\n body.touch .tStick {\n left: calc(var(--idos-safe-left, 0px) + 16px);\n bottom: var(--vc-touch-b);\n }\n}\n\n/* планшет: короткая сторона от 640 px, места много, кнопки крупнее */\n@media (min-width: 640px) and (min-height: 640px) {\n body.touch {\n --vc-btn: 62px;\n --vc-btn-gap: 10px;\n --vc-small: 40px;\n --vc-stick: 124px;\n --vc-map: 96px;\n }\n}\n"
|
|
1511
1511
|
},
|
|
1512
1512
|
{
|
|
1513
1513
|
"path": "systems/Achievements.ts",
|
|
@@ -1723,11 +1723,11 @@
|
|
|
1723
1723
|
},
|
|
1724
1724
|
{
|
|
1725
1725
|
"path": "ui/ChatConsole.ts",
|
|
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
|
|
1726
|
+
"content": "// Чат/консоль: T — открыть строку, / — открыть сразу с «/». Строка с «/» уходит командой\n// (интент Command → systems/Commands), остальное — реплика в чат (одиночная игра — только эхо).\n// Лог показывает последние строки и гаснет сам; открытая консоль показывает всю историю.\n// Закрыть: Esc, Enter (отправить), касание или клик по тёмному фону вокруг, спрятать клавиатуру\n// телефона (строка теряет фокус). Раньше только Esc, и на телефоне из консоли было не выйти\n// (владелец, 05.11.2026).\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 private readonly back: HTMLDivElement;\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 // клавиша Enter на клавиатуре телефона подписана «Отправить»\n this.input.enterKeyHint = \"send\";\n this.root.append(this.logEl, this.input);\n this.back = document.createElement(\"div\");\n this.back.className = \"chatBack hidden\";\n const host = document.getElementById(\"game\")!.parentElement!;\n host.append(this.back, this.root);\n this.back.addEventListener(\n \"pointerdown\",\n (e) => {\n // совместимый mousedown не уходит в игру ударом\n e.preventDefault();\n this.hide();\n },\n { signal },\n );\n // клавиатуру телефона спрятали (или ушли со строки): консоль закрывается, игра не стоит\n this.input.addEventListener(\"blur\", () => this.hide(), { signal });\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.back.classList.remove(\"hidden\");\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.back.classList.add(\"hidden\");\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.back.remove();\n this.root.remove();\n }\n}\n"
|
|
1727
1727
|
},
|
|
1728
1728
|
{
|
|
1729
1729
|
"path": "ui/creativePalette.ts",
|
|
1730
|
-
"content": "// Палитра креатива: все предметы реестра с поиском по названию (своему и id), как вкладка «Поиск»\n// в Minecraft. Бесконечный источник — что делает клик, решает InventoryUI (onPick).\nimport { ITEMS } from \"../registry/Items\";\nimport { labelOf } from \"../registry/names\";\nimport { setSlotTip } from \"./itemTooltip\";\nimport { tr } from \"../i18n\";\n\nexport function buildCreativePalette(\n parent: HTMLElement,\n onPick: (item: string, rightClick: boolean, shift: boolean) => void,\n): void {\n const search = document.createElement(\"input\");\n search.type = \"search\";\n search.className = \"creativeSearch\";\n search.placeholder = tr({ en: \"Search items…\", ru: \"Поиск предметов…\" });\n // буквы поиска не должны доходить до игры (E закрыл бы экран); Esc закрывает, как обычно\n search.addEventListener(\"keydown\", (e) => {\n if (e.key !== \"Escape\") e.stopPropagation();\n });\n const grid = document.createElement(\"div\");\n grid.className = \"creativeGrid\";\n parent.append(search, grid);\n\n const all = Object.keys(ITEMS);\n const fill = () => {\n const q = search.value.trim().toLowerCase();\n grid.innerHTML = \"\";\n for (const name of all) {\n if (q && !name.includes(q) && !labelOf(name).toLowerCase().includes(q))\n continue;\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n const img = document.createElement(\"img\");\n img.draggable = false;\n img.src = ITEMS[name]!.iconURL ?? \"\";\n d.appendChild(img);\n setSlotTip(d, { item: name, count: 1 });\n d.onmousedown = (e) => {\n onPick(name, e.button === 2, e.shiftKey);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n grid.appendChild(d);\n }\n };\n search.addEventListener(\"input\", fill);\n fill();\n}\n"
|
|
1730
|
+
"content": "// Палитра креатива: все предметы реестра с поиском по названию (своему и id), как вкладка «Поиск»\n// в Minecraft. Бесконечный источник — что делает клик, решает InventoryUI (onPick).\nimport { ITEMS } from \"../registry/Items\";\nimport { labelOf } from \"../registry/names\";\nimport { setSlotTip } from \"./itemTooltip\";\nimport { tr } from \"../i18n\";\nimport { touchHold } from \"./touchHold\";\n\nexport function buildCreativePalette(\n parent: HTMLElement,\n onPick: (item: string, rightClick: boolean, shift: boolean) => void,\n): void {\n const search = document.createElement(\"input\");\n search.type = \"search\";\n search.className = \"creativeSearch\";\n search.placeholder = tr({ en: \"Search items…\", ru: \"Поиск предметов…\" });\n // буквы поиска не должны доходить до игры (E закрыл бы экран); Esc закрывает, как обычно\n search.addEventListener(\"keydown\", (e) => {\n if (e.key !== \"Escape\") e.stopPropagation();\n });\n const grid = document.createElement(\"div\");\n grid.className = \"creativeGrid\";\n parent.append(search, grid);\n\n const all = Object.keys(ITEMS);\n const fill = () => {\n const q = search.value.trim().toLowerCase();\n grid.innerHTML = \"\";\n for (const name of all) {\n if (q && !name.includes(q) && !labelOf(name).toLowerCase().includes(q))\n continue;\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n const img = document.createElement(\"img\");\n img.draggable = false;\n img.src = ITEMS[name]!.iconURL ?? \"\";\n d.appendChild(img);\n setSlotTip(d, { item: name, count: 1 });\n d.onmousedown = (e) => {\n onPick(name, e.button === 2, e.shiftKey);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n // пальцем: касание = стак, удержание = один предмет\n touchHold(d, (held) => onPick(name, held, false));\n grid.appendChild(d);\n }\n };\n search.addEventListener(\"input\", fill);\n fill();\n}\n"
|
|
1731
1731
|
},
|
|
1732
1732
|
{
|
|
1733
1733
|
"path": "ui/EmoteWheel.ts",
|
|
@@ -1735,11 +1735,11 @@
|
|
|
1735
1735
|
},
|
|
1736
1736
|
{
|
|
1737
1737
|
"path": "ui/HUD.ts",
|
|
1738
|
-
"content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { chooseLang, lang, tr, type Loc } from \"../i18n\";\nimport { stackIconURL } from \"../character/items/skins\";\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport { effectLabel, labelOf } from \"../registry/names\";\nimport { MAX_AIR } from \"../player/Player\";\nimport { RAID_WAVES } from \"../systems/Raid\";\nimport { drawDurability } from \"./slotView\";\nimport { setSlotTip } from \"./itemTooltip\";\nimport { ct } from \"./catalog/strings\";\nimport { enchantsOf } from \"../systems/Enchant\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { MAP_SIZE, mapRGB, type MapData } from \"../systems/Maps\";\nimport type { Game } from \"../main\";\nimport { worldPerf } from \"../world/perf\";\n\nexport class HUD {\n game: Game;\n $: (id: string) => HTMLElement;\n hotbarEl: HTMLElement;\n heartsEl: HTMLElement;\n fpsEl: HTMLElement;\n debugEl: HTMLElement;\n timeEl: HTMLElement;\n overlayEl: HTMLElement;\n hurtEl: HTMLElement;\n msgEl: HTMLElement;\n hungerEl: HTMLElement;\n _invDirty: boolean;\n _lastHealth: number;\n _lastFood?: number;\n _modeWas?: string;\n _foodModeWas?: string;\n _msgT?: ReturnType<typeof setTimeout>;\n _slots: HTMLDivElement[];\n _hearts: HTMLSpanElement[];\n _drumsticks: HTMLSpanElement[];\n /** Экран смерти и оранжевая кайма горения. */\n deathEl!: HTMLElement;\n fireEl!: HTMLElement;\n /** Полоса здоровья босса и экран титров. */\n bossEl!: HTMLElement;\n creditsEl!: HTMLElement;\n /** Список эффектов зелий с оставшимся временем. */\n effectsEl!: HTMLElement;\n /** Пузырьки воздуха над едой — только под водой. */\n bubblesEl!: HTMLElement;\n private readonly _bubbles: HTMLSpanElement[] = [];\n /** Компас или часы в руке — стрелка или время у хотбара. */\n gadgetEl!: HTMLElement;\n /** Карта в руке. */\n mapEl!: HTMLElement;\n mapCanvas!: HTMLCanvasElement;\n mapMarker!: HTMLElement;\n /** Крестик карты сокровищ. */\n mapTreasure!: HTMLElement;\n private _mapShown: MapData | null = null;\n private _mapVersion = -1;\n /** Полоса опыта над хотбаром и номер уровня. */\n xpFillEl: HTMLElement;\n xpLevelEl: HTMLElement;\n _lastXp = \"\";\n /** Шкала брони над сердцами (10 значков, по 2 очка защиты). */\n armorEl: HTMLElement;\n _armorPips: HTMLSpanElement[] = [];\n _lastArmor = -1;\n\n constructor(game: Game) {\n this.game = game;\n // Все элементы гарантированно есть в разметке — helper утверждает non-null.\n this.$ = (id: string): HTMLElement => document.getElementById(id)!;\n this.hotbarEl = this.$(\"hotbar\");\n this.heartsEl = this.$(\"hearts\");\n this.fpsEl = this.$(\"fps\");\n this.debugEl = this.$(\"debug\");\n this.timeEl = this.$(\"timeIndicator\");\n this.overlayEl = this.$(\"overlay\");\n this.hurtEl = this.$(\"hurtFlash\");\n this.msgEl = this.$(\"message\");\n\n this._invDirty = true;\n this._lastHealth = -1;\n events.on(\"invChanged\", () => {\n this._invDirty = true;\n });\n events.on(\"playerHurt\", () => this._flashHurt());\n // короткие подсказки из систем без доступа к HUD («Нужно седло»)\n events.on(\"ui:hint\", ({ text }: { text: string }) =>\n this.message(text, 1600),\n );\n // экран смерти и огонь на экране\n this.deathEl = document.createElement(\"div\");\n this.deathEl.id = \"deathScreen\";\n this.deathEl.className = \"hidden\";\n this.deathEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Вы погибли</h1><p class=\"deathCause\"></p>' +\n '<button class=\"deathBtn\">Возродиться</button></div>';\n this.deathEl.querySelector(\"button\")!.onclick = () => game.respawnPlayer();\n this.fireEl = document.createElement(\"div\");\n this.fireEl.id = \"fireOverlay\";\n // полоса босса и титры после победы над драконом\n this.bossEl = document.createElement(\"div\");\n this.bossEl.id = \"bossBar\";\n this.bossEl.className = \"hidden\";\n this.bossEl.innerHTML =\n '<div class=\"bossName\"></div><div class=\"bossTrack\"><div class=\"bossFill\"></div></div>';\n this.creditsEl = document.createElement(\"div\");\n this.creditsEl.id = \"creditsScreen\";\n this.creditsEl.className = \"hidden\";\n this.creditsEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Победа</h1><p class=\"credits\">Дракон Предела повержен.<br>' +\n \"Путь пройден: верхний мир — Пекло — Предел.<br>Мир остаётся твоим: строй, исследуй, \" +\n \"возвращайся.<br><br>Спасибо, что играешь в VoxelCraft.</p>\" +\n '<button class=\"deathBtn\">Продолжить</button></div>';\n this.creditsEl.querySelector(\"button\")!.onclick = () => {\n this.creditsEl.classList.add(\"hidden\");\n game.input.uiOpen = false;\n game.input.requestLock();\n };\n this.effectsEl = document.createElement(\"div\");\n this.effectsEl.id = \"effects\";\n // компас и часы в руке\n this.gadgetEl = document.createElement(\"div\");\n this.gadgetEl.id = \"gadget\";\n this.gadgetEl.className = \"hidden\";\n this.heartsEl.parentElement!.appendChild(this.gadgetEl);\n // карта в руке: холст 128×128 (растянут) и метка игрока\n this.mapEl = document.createElement(\"div\");\n this.mapEl.id = \"mapView\";\n this.mapEl.className = \"hidden\";\n this.mapCanvas = document.createElement(\"canvas\");\n this.mapCanvas.width = this.mapCanvas.height = MAP_SIZE;\n this.mapMarker = document.createElement(\"span\");\n this.mapMarker.className = \"mapMarker\";\n this.mapMarker.textContent = \"▲\";\n this.mapTreasure = document.createElement(\"span\");\n this.mapTreasure.className = \"mapTreasure\";\n this.mapTreasure.textContent = \"✖\";\n this.mapEl.append(this.mapCanvas, this.mapTreasure, this.mapMarker);\n this.heartsEl.parentElement!.appendChild(this.mapEl);\n this.heartsEl.parentElement!.append(\n this.deathEl,\n this.fireEl,\n this.bossEl,\n this.creditsEl,\n this.effectsEl,\n );\n events.on(\"modeChanged\", ({ mode }) =>\n this.message(\n mode === \"creative\"\n ? tr({\n en: \"Mode: CREATIVE (double Space — fly)\",\n ru: \"Режим: КРЕАТИВ (двойной Space — полёт)\",\n })\n : tr({ en: \"Mode: survival\", ru: \"Режим: выживание\" }),\n 1600,\n ),\n );\n\n // слоты хотбара\n this._slots = [];\n for (let i = 0; i < 9; i++) {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n // тап/клик по слоту — выбрать его (на телефоне колёсика и цифр нет)\n d.addEventListener(\"pointerdown\", () =>\n game.intents.push(Intent.SelectSlot, { slot: i }),\n );\n this.hotbarEl.appendChild(d);\n this._slots.push(d);\n }\n // сердечки\n this._hearts = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"heart\";\n s.textContent = \"❤\";\n this.heartsEl.appendChild(s);\n this._hearts.push(s);\n }\n // опыт\n const xpBar = document.createElement(\"div\");\n xpBar.id = \"xpBar\";\n this.xpFillEl = document.createElement(\"div\");\n this.xpFillEl.className = \"xpFill\";\n xpBar.appendChild(this.xpFillEl);\n this.xpLevelEl = document.createElement(\"div\");\n this.xpLevelEl.id = \"xpLevel\";\n this.heartsEl.parentElement!.append(xpBar, this.xpLevelEl);\n // броня\n this.armorEl = document.createElement(\"div\");\n this.armorEl.id = \"armorBar\";\n this.heartsEl.parentElement!.appendChild(this.armorEl);\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"armorPip\";\n s.textContent = \"⛨\";\n this.armorEl.appendChild(s);\n this._armorPips.push(s);\n }\n // окорочка (голод)\n this.hungerEl = this.$(\"hunger\");\n this._drumsticks = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"drumstick\";\n s.textContent = \"🍗\";\n this.hungerEl.appendChild(s);\n this._drumsticks.push(s);\n }\n this.bubblesEl = document.createElement(\"div\");\n this.bubblesEl.id = \"bubbles\";\n this.bubblesEl.style.display = \"none\";\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"bubble\";\n s.textContent = \"◯\";\n this.bubblesEl.appendChild(s);\n this._bubbles.push(s);\n }\n this.hungerEl.parentElement!.appendChild(this.bubblesEl);\n\n // кнопки паузы; создание миров, экспорт и импорт — в лобби (ui/Lobby)\n this.$(\"btnResume\").onclick = () => game.startPlay();\n this.$(\"btnLobby\").onclick = () => game.toLobby();\n this.$(\"btnBook\").onclick = () => game.catalogUI.show();\n this.overlayEl.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).closest(\"button\")) return; // кнопки не запускают игру\n game.startPlay();\n });\n }\n\n /** Последний показ паузы (пауза или старт) — перерисовать после смены языка. */\n private _overlayPaused = false;\n\n showOverlay(paused: boolean) {\n this._overlayPaused = paused;\n this.overlayEl.classList.remove(\"hidden\");\n this.$(\"overlayTitle\").textContent = paused\n ? tr({ en: \"Paused\", ru: \"Пауза\" })\n : \"VoxelCraft\";\n const touch = isTouchUi();\n this.$(\"overlayHint\").textContent = paused\n ? touch\n ? tr({ en: \"Tap to continue\", ru: \"Нажмите — продолжить\" })\n : tr({ en: \"Click to continue\", ru: \"Клик — продолжить\" })\n : touch\n ? tr({ en: \"Tap to play\", ru: \"Нажмите — играть\" })\n : tr({ en: \"Click to play\", ru: \"Клик — играть\" });\n this.$(\"btnResume\").textContent = paused\n ? tr({ en: \"Continue\", ru: \"Продолжить\" })\n : tr({ en: \"Play\", ru: \"Играть\" });\n this.$(\"overlayControls\").innerHTML = controlsHtml(touch);\n this.$(\"btnBook\").textContent = \"📖 \" + ct(\"title\");\n const lobby = this.$(\"btnLobby\");\n lobby.textContent = tr({ en: \"To the lobby\", ru: \"В лобби\" });\n lobby.classList.toggle(\"hidden\", !this.game.lobbyApi?.available());\n this.game.netUI?.syncButton(); // «Игроки» — только в сетевой игре\n this._langRow();\n }\n\n /** Язык в паузе: English / Русский — для всей игры и лобби SDK (i18n chooseLang). */\n private _langRow(): void {\n const row = this.$(\"overlayLang\");\n row.innerHTML = \"\";\n const label = document.createElement(\"span\");\n label.textContent = tr({ en: \"Language\", ru: \"Язык\" });\n row.append(label);\n for (const [l, name] of [\n [\"en\", \"English\"],\n [\"ru\", \"Русский\"],\n ] as const) {\n const b = document.createElement(\"button\");\n b.className = \"idos-press langBtn\" + (lang() === l ? \" on\" : \"\");\n b.textContent = name;\n b.onclick = () => chooseLang(l);\n row.append(b);\n }\n }\n /** Язык сменили (здесь или в лобби) — открытая пауза перерисовывается сразу. */\n langChanged(): void {\n if (!this.overlayEl.classList.contains(\"hidden\"))\n this.showOverlay(this._overlayPaused);\n }\n\n hideOverlay() {\n this.overlayEl.classList.add(\"hidden\");\n }\n\n /** Экран «Вы погибли» с причиной и кнопкой возрождения. */\n showDeath(cause: string) {\n const CAUSES: Record<string, Loc> = {\n attack: { en: \"You were killed by a monster\", ru: \"Вас убил монстр\" },\n projectile: { en: \"You were shot\", ru: \"Вас застрелили\" },\n explosion: { en: \"You were blown up\", ru: \"Вас взорвали\" },\n lava: { en: \"You tried to swim in lava\", ru: \"Вы искупались в лаве\" },\n fire: { en: \"You burned to death\", ru: \"Вы сгорели\" },\n fall: { en: \"You fell from a high place\", ru: \"Вы упали с высоты\" },\n starve: { en: \"You starved to death\", ru: \"Вы умерли от голода\" },\n void: { en: \"You fell out of the world\", ru: \"Вы выпали из мира\" },\n cactus: {\n en: \"You were pricked to death by a cactus\",\n ru: \"Вас искололо кактусом\",\n },\n magic: { en: \"You were killed by magic\", ru: \"Вас сгубило колдовство\" },\n lightning: {\n en: \"You were struck by lightning\",\n ru: \"Вас поразила молния\",\n },\n decay: { en: \"You withered away\", ru: \"Вы истлели\" },\n skull: {\n en: \"You were slain by the Ash Lord\",\n ru: \"Вас сразил Пепельный владыка\",\n },\n bullet: {\n en: \"You were shot by a shellkeeper\",\n ru: \"Вас подбил панцирник\",\n },\n drown: { en: \"You drowned\", ru: \"Вы утонули\" },\n beam: {\n en: \"You were burned by a deepling's beam\",\n ru: \"Вас сжёг луч глубинника\",\n },\n thorns: {\n en: \"You were pricked by a deepling's thorns\",\n ru: \"Вы укололись о шипы глубинника\",\n },\n wall: { en: \"You flew into a wall\", ru: \"Вы врезались в стену на лету\" },\n // причины, для которых экран смерти молчал (замёрзнуть в снегу-порохе — самая частая)\n freeze: { en: \"You froze to death\", ru: \"Вы замёрзли\" },\n stalagmite: {\n en: \"You were impaled on a stalagmite\",\n ru: \"Вас пронзил сталагмит\",\n },\n falling_stalactite: {\n en: \"A stalactite fell on you\",\n ru: \"На вас упал сталактит\",\n },\n teleport: {\n en: \"An unlucky pearl throw\",\n ru: \"Неудачный бросок жемчуга\",\n },\n sonic_boom: {\n en: \"You were obliterated by a sonic shriek\",\n ru: \"Вас оглушил вопль глубинника\",\n },\n wind: { en: \"You were blown away by the wind\", ru: \"Вас сдуло ветром\" },\n poison: { en: \"You were poisoned\", ru: \"Вас отравили\" },\n };\n this.deathEl.querySelector(\".deathCause\")!.textContent =\n cause in CAUSES ? tr(CAUSES[cause]!) : \"\"; // заголовок панели уже «Вы погибли» — без повтора\n // заголовок и кнопка — на текущем языке (разметка собрана в конструкторе)\n this.deathEl.querySelector(\"h1\")!.textContent = tr({\n en: \"You died\",\n ru: \"Вы погибли\",\n });\n this.deathEl.querySelector(\"button\")!.textContent = tr({\n en: \"Respawn\",\n ru: \"Возродиться\",\n });\n this.deathEl.classList.remove(\"hidden\");\n }\n hideDeath() {\n this.deathEl.classList.add(\"hidden\");\n }\n\n /** Титры после первой победы над драконом (кнопка «Продолжить» возвращает управление). */\n showCredits() {\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.creditsEl.querySelector(\"h1\")!.textContent = tr({\n en: \"Victory\",\n ru: \"Победа\",\n });\n this.creditsEl.querySelector(\".credits\")!.innerHTML = tr({\n en: \"The Beyond Dragon is defeated.<br>The path is complete: the Overworld — the Inferno — the Beyond.<br>The world stays yours: build, explore, come back.<br><br>Thank you for playing VoxelCraft.\",\n ru: \"Дракон Предела повержен.<br>Путь пройден: верхний мир — Пекло — Предел.<br>Мир остаётся твоим: строй, исследуй, возвращайся.<br><br>Спасибо, что играешь в VoxelCraft.\",\n });\n this.creditsEl.querySelector(\"button\")!.textContent = tr({\n en: \"Continue\",\n ru: \"Продолжить\",\n });\n this.creditsEl.classList.remove(\"hidden\");\n }\n\n message(text: string, ms = 1500) {\n this.msgEl.textContent = text;\n this.msgEl.classList.add(\"show\");\n clearTimeout(this._msgT);\n this._msgT = setTimeout(() => this.msgEl.classList.remove(\"show\"), ms);\n }\n\n /** Teardown: таймер сообщения не должен пережить игру. */\n dispose() {\n clearTimeout(this._msgT);\n }\n\n _flashHurt() {\n this.hurtEl.classList.remove(\"show\");\n void this.hurtEl.offsetWidth; // перезапуск CSS-анимации\n this.hurtEl.classList.add(\"show\");\n }\n\n frame() {\n const g = this.game;\n if (this._invDirty) {\n this._renderHotbar();\n this._invDirty = false;\n }\n if (\n g.player.health !== this._lastHealth ||\n this._modeWas !== g.player.mode\n ) {\n this._renderHearts();\n this._lastHealth = g.player.health;\n this._modeWas = g.player.mode;\n }\n if (\n g.player.food !== this._lastFood ||\n this._foodModeWas !== g.player.mode\n ) {\n this._renderHunger();\n this._lastFood = g.player.food;\n this._foodModeWas = g.player.mode;\n }\n\n const xpKey =\n g.player.xpLevel +\n \":\" +\n Math.round(g.player.xpProgress * 200) +\n \":\" +\n g.player.mode;\n if (xpKey !== this._lastXp) {\n this._lastXp = xpKey;\n const show = g.player.mode !== \"creative\";\n this.xpFillEl.parentElement!.style.display = show ? \"\" : \"none\";\n this.xpFillEl.style.width = (g.player.xpProgress * 100).toFixed(1) + \"%\";\n this.xpLevelEl.textContent =\n show && g.player.xpLevel > 0 ? String(g.player.xpLevel) : \"\";\n }\n\n const armor = g.inventory.armorStats().points;\n if (armor !== this._lastArmor) {\n this._lastArmor = armor;\n this.armorEl.style.display =\n armor > 0 && g.player.mode !== \"creative\" ? \"\" : \"none\";\n for (let i = 0; i < 10; i++) {\n const v = armor - i * 2;\n this._armorPips[i]!.className =\n \"armorPip\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n this.fireEl.classList.toggle(\"show\", g.player.fireT > 0 && !g.player.dead);\n // эффекты зелий и маяка: «Сила 2:31»; Тление чернит сердца\n this.heartsEl.classList.toggle(\"decay\", g.player.effects.has(\"decay\"));\n // пузырьки: сколько воздуха осталось под водой\n const pl = g.player;\n const showAir =\n pl.mode !== \"creative\" && !pl.dead && pl.air < MAX_AIR - 0.01;\n this.bubblesEl.style.display = showAir ? \"\" : \"none\";\n if (showAir) {\n const n = Math.ceil((pl.air / MAX_AIR) * 10);\n this._bubbles.forEach(\n (b, i) => (b.className = \"bubble\" + (i < n ? \"\" : \" empty\")),\n );\n }\n let fx = \"\";\n for (const [id, e] of g.player.effects) {\n const s = Math.max(0, Math.ceil(e.time));\n fx +=\n `${effectLabel(id)}${e.level > 1 ? \" \" + e.level : \"\"} ` +\n `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}\\n`;\n }\n if (this.effectsEl.textContent !== fx) this.effectsEl.textContent = fx;\n // полоса босса: живой босс в загруженном мире\n let boss: { name: string; hp: number; max: number } | null = null;\n for (const e of g.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n boss = { name: labelOf(e.def.name), hp: e.hp, max: e.def.hp };\n // нет босса, идёт набег — полоса набега: здоровье живых разбойников волны\n const raid = g.raid;\n if (!boss && raid) {\n let hp = 0;\n for (const id of raid.raiders) {\n const e = g.entities.entities.get(id);\n if (e?.kind === \"mob\" && !e.dead) hp += e.hp;\n }\n boss =\n raid.state === \"fighting\"\n ? {\n name: tr(\n {\n en: \"Raid · wave {w} of {n}\",\n ru: \"Набег · волна {w} из {n}\",\n },\n { w: raid.wave, n: RAID_WAVES },\n ),\n hp,\n max: Math.max(1, raid.hpTotal),\n }\n : {\n name: tr(\n {\n en: \"Raid · wave {w} of {n} — soon\",\n ru: \"Набег · волна {w} из {n} — скоро\",\n },\n { w: raid.wave + 1, n: RAID_WAVES },\n ),\n hp: 0,\n max: 1,\n };\n }\n this.bossEl.classList.toggle(\"hidden\", !boss);\n if (boss) {\n this.bossEl.querySelector(\".bossName\")!.textContent = boss.name;\n (this.bossEl.querySelector(\".bossFill\") as HTMLElement).style.width =\n ((boss.hp / boss.max) * 100).toFixed(1) + \"%\";\n }\n this.fpsEl.textContent = g.loop.fps + \" FPS\";\n // дебаг-строка\n const p = g.player.pos;\n this.debugEl.textContent =\n `xyz: ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)} | ` +\n `chunks: ${g.world.chunks.size} | fluids: ${g.fluids.queueSize} | seed: ${g.world.seed}` +\n // загрузка мира и рендер: мс кадра на генерацию/мешинг в главном потоке, очереди, draw call'ы\n ` | gen ${worldPerf.genMs.toFixed(1)} ms · mesh ${worldPerf.meshMs.toFixed(1)} ms` +\n ` · queue ${worldPerf.genQueue}/${worldPerf.dirty}` +\n (worldPerf.workers ? ` · workers ${worldPerf.workerJobs}` : \"\") +\n (worldPerf.lodChunks ? ` · LOD ${worldPerf.lodChunks}` : \"\") +\n ` | DC ${g.renderStats.calls} · △ ${(g.renderStats.triangles / 1000).toFixed(0)}k`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n // в Пекле и Пределе неба нет — и времени суток на экране тоже\n this.timeEl.textContent = DIMENSIONS[g.dimension].hasSky\n ? (g.dayNight.isDay ? \"☀\" : \"☾\") +\n \" \" +\n (ph < 0.5\n ? tr({ en: \"day\", ru: \"день\" })\n : tr({ en: \"night\", ru: \"ночь\" })) +\n (g.weather.kind === \"thunder\"\n ? tr({ en: \" · thunder\", ru: \" · гроза\" })\n : g.weather.kind === \"rain\"\n ? tr({ en: \" · rain\", ru: \" · дождь\" })\n : \"\")\n : \"\";\n this._gadget();\n this._mapView();\n }\n\n /** Карта в руке: холст перерисовывается, только когда карта изменилась; метка — каждый кадр. */\n private _mapView() {\n const g = this.game;\n const h = g.inventory.heldItem();\n const m =\n h?.item === \"filled_map\" ? g.maps.get(Number(h.tag?.map)) : undefined;\n if (!m) {\n this.mapEl.classList.add(\"hidden\");\n return;\n }\n this.mapEl.classList.remove(\"hidden\");\n if (this._mapShown !== m || this._mapVersion !== m.version) {\n this._mapShown = m;\n this._mapVersion = m.version;\n const ctx = this.mapCanvas.getContext(\"2d\");\n if (ctx) {\n const img = ctx.createImageData(MAP_SIZE, MAP_SIZE);\n for (let i = 0; i < m.px.length; i++) {\n const c = mapRGB(m.px[i]!);\n const o = i * 4;\n img.data[o] = c < 0 ? 214 : (c >> 16) & 255;\n img.data[o + 1] = c < 0 ? 190 : (c >> 8) & 255;\n img.data[o + 2] = c < 0 ? 150 : c & 255;\n img.data[o + 3] = 255;\n }\n ctx.putImageData(img, 0, 0);\n }\n }\n const p = g.player.pos;\n const u = (p.x - m.x0) / MAP_SIZE,\n v = (p.z - m.z0) / MAP_SIZE;\n const inside =\n m.dim === g.dimension && u >= 0 && u <= 1 && v >= 0 && v <= 1;\n this.mapMarker.style.display = inside ? \"\" : \"none\";\n this.mapMarker.style.left = (u * 100).toFixed(2) + \"%\";\n this.mapMarker.style.top = (v * 100).toFixed(2) + \"%\";\n this.mapMarker.style.transform = `translate(-50%, -50%) rotate(${((-g.player.yaw * 180) / Math.PI).toFixed(1)}deg)`;\n // карта сокровищ: красный крестик над кладом\n const t = m.target;\n this.mapTreasure.style.display = t ? \"\" : \"none\";\n if (t) {\n this.mapTreasure.style.left =\n (((t[0] + 0.5 - m.x0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n this.mapTreasure.style.top =\n (((t[1] + 0.5 - m.z0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n }\n }\n\n private frostEl: HTMLElement | null = null;\n /** Иней по краям экрана — насколько игрок промёрз в снегу-порохе (0..1, systems/Freezing). */\n frost(k: number): void {\n if (!this.frostEl) {\n if (k <= 0) return;\n const el = document.createElement(\"div\");\n el.style.cssText =\n \"position:fixed;inset:0;pointer-events:none;opacity:0;\" +\n \"background:radial-gradient(ellipse at center,rgba(255,255,255,0) 40%,rgba(220,240,255,.9) 100%)\";\n this.heartsEl.parentElement!.appendChild(el);\n this.frostEl = el;\n }\n this.frostEl.style.opacity = k.toFixed(2);\n }\n\n /**\n * Компас и часы в руке: у хотбара — стрелка на точку появления мира (в Пекле и Пределе крутится)\n * или время суток (без неба — «??:??»), как у приборов Minecraft.\n */\n private _gadget() {\n const g = this.game;\n const held = g.inventory.heldItem()?.item;\n const el = this.gadgetEl;\n if (held !== \"compass\" && held !== \"clock\" && held !== \"recovery_compass\") {\n el.classList.add(\"hidden\");\n return;\n }\n el.classList.remove(\"hidden\");\n if (held === \"compass\" || held === \"recovery_compass\") {\n // компас — на путевой камень (если привязан здесь) или на точку появления,\n // компас возвращения — на место последней смерти в этом измерении\n const d = g.lastDeath;\n const lode = g.player.lodestone;\n const s =\n held === \"compass\"\n ? lode && lode.dim === g.dimension\n ? { x: lode.x, z: lode.z }\n : g.dimension === \"overworld\"\n ? g.world.generator.spawnHint()\n : null\n : d && d.dim === g.dimension\n ? { x: Math.floor(d.x), z: Math.floor(d.z) }\n : null;\n let deg: number;\n if (!s) deg = (performance.now() / 3) % 360;\n else {\n const p = g.player.pos;\n const target = Math.atan2(-(s.x + 0.5 - p.x), -(s.z + 0.5 - p.z));\n deg = (-(target - g.player.yaw) * 180) / Math.PI;\n }\n el.innerHTML = `<span class=\"needle\" style=\"transform:rotate(${deg.toFixed(1)}deg)\">▲</span>`;\n return;\n }\n if (!DIMENSIONS[g.dimension].hasSky) {\n el.textContent = \"🕒 ??:??\";\n return;\n }\n const h = (6 + g.dayNight.phase * 24) % 24;\n el.textContent = `🕒 ${String(Math.floor(h)).padStart(2, \"0\")}:${String(Math.floor((h % 1) * 60)).padStart(2, \"0\")}`;\n }\n\n _renderHotbar() {\n const inv = this.game.inventory;\n for (let i = 0; i < 9; i++) {\n const el = this._slots[i]!;\n const s = inv.slots[i];\n el.classList.toggle(\"selected\", i === inv.hotbarIndex);\n setSlotTip(el, s ?? null);\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (s) {\n img.src = stackIconURL(s); // иконка облика или предмета; неизвестный предмет (чужой сейв) — пустая иконка, а не падение\n img.style.display = \"\";\n el.classList.toggle(\"ench\", enchantsOf(s).length > 0);\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (s.count > 1 ? s.count : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n el.classList.remove(\"ench\");\n }\n drawDurability(el, s ?? null);\n }\n }\n\n _renderHearts() {\n const g = this.game;\n this.heartsEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const hp = g.player.health;\n for (let i = 0; i < 10; i++) {\n const v = hp - i * 2;\n this._hearts[i]!.className =\n \"heart\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n _renderHunger() {\n const g = this.game;\n this.hungerEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const food = g.player.food;\n for (let i = 0; i < 10; i++) {\n const v = food - i * 2;\n this._drumsticks[i]!.className =\n \"drumstick\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n}\n\n/** Управление на экране паузы: клавиши — на компьютере, кнопки — на тач-экране. */\nfunction controlsHtml(touch: boolean): string {\n const rows: Loc[] = touch\n ? [\n {\n en: \"<b>Left stick</b> move, <b>swipe right side</b> look around\",\n ru: \"<b>Левый джойстик</b> — идти, <b>проведи справа</b> — осмотреться\",\n },\n {\n en: \"<b>⚒</b> break / attack, <b>✋</b> place / use, <b>⤒</b> jump\",\n ru: \"<b>⚒</b> — ломать / бить, <b>✋</b> — поставить / использовать, <b>⤒</b> — прыжок\",\n },\n {\n en: \"<b>▦</b> inventory, <b>📜</b> quests, <b>minimap</b> — the map, <b>⏸</b> pause\",\n ru: \"<b>▦</b> — инвентарь, <b>📜</b> — задания, <b>миникарта</b> — карта, <b>⏸</b> — пауза\",\n },\n ]\n : [\n {\n en: \"<b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> / <b>W W</b> sprint, <b>Ctrl</b> sneak\",\n ru: \"<b>WASD</b> — идти, <b>Пробел</b> — прыжок / всплыть, <b>Shift</b> / <b>W W</b> — бег, <b>Ctrl</b> — присесть\",\n },\n {\n en: \"<b>LMB</b> (hold) break / attack, <b>RMB</b> place block / open door\",\n ru: \"<b>ЛКМ</b> (держать) — ломать / бить, <b>ПКМ</b> — поставить блок / открыть дверь\",\n },\n {\n en: \"<b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory & 2×2 craft\",\n ru: \"<b>Колесо / 1-9</b> — слот, <b>E</b> — инвентарь и крафт 2×2\",\n },\n {\n en: \"<b>RMB (hold) with food</b> eat (watch the hunger bar)\",\n ru: \"<b>ПКМ (держать) с едой</b> — есть (следи за сытостью)\",\n },\n {\n en: \"<b>RMB on a workbench</b> 3×3 craft, <b>C</b> creative (double <b>Space</b> to fly, <b>Shift</b> / <b>Ctrl</b> down)\",\n ru: \"<b>ПКМ по верстаку</b> — крафт 3×3, <b>C</b> — творческий режим (двойной <b>Пробел</b> — полёт, <b>Shift</b> / <b>Ctrl</b> — вниз)\",\n },\n {\n en: \"<b>M</b> map & pins, <b>J</b> quest book, <b>Esc</b> pause\",\n ru: \"<b>M</b> — карта и метки, <b>J</b> — книга заданий, <b>Esc</b> — пауза\",\n },\n ];\n return rows.map((r) => `<div>${tr(r)}</div>`).join(\"\");\n}\n\nfunction isTouchUi(): boolean {\n return document.body.classList.contains(\"touch\");\n}\n"
|
|
1738
|
+
"content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { chooseLang, lang, tr, type Loc } from \"../i18n\";\nimport { stackIconURL } from \"../character/items/skins\";\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport { effectLabel, labelOf } from \"../registry/names\";\nimport { MAX_AIR } from \"../player/Player\";\nimport { RAID_WAVES } from \"../systems/Raid\";\nimport { drawDurability } from \"./slotView\";\nimport { setSlotTip } from \"./itemTooltip\";\nimport { ct } from \"./catalog/strings\";\nimport { enchantsOf } from \"../systems/Enchant\";\nimport { DIMENSIONS } from \"../world/Dimensions\";\nimport { MAP_SIZE, mapRGB, type MapData } from \"../systems/Maps\";\nimport type { Game } from \"../main\";\nimport { worldPerf } from \"../world/perf\";\n\nexport class HUD {\n game: Game;\n $: (id: string) => HTMLElement;\n hotbarEl: HTMLElement;\n heartsEl: HTMLElement;\n fpsEl: HTMLElement;\n debugEl: HTMLElement;\n timeEl: HTMLElement;\n overlayEl: HTMLElement;\n hurtEl: HTMLElement;\n msgEl: HTMLElement;\n hungerEl: HTMLElement;\n _invDirty: boolean;\n _lastHealth: number;\n _lastFood?: number;\n _modeWas?: string;\n _foodModeWas?: string;\n _msgT?: ReturnType<typeof setTimeout>;\n _slots: HTMLDivElement[];\n _hearts: HTMLSpanElement[];\n _drumsticks: HTMLSpanElement[];\n /** Экран смерти и оранжевая кайма горения. */\n deathEl!: HTMLElement;\n fireEl!: HTMLElement;\n /** Полоса здоровья босса и экран титров. */\n bossEl!: HTMLElement;\n creditsEl!: HTMLElement;\n /** Список эффектов зелий с оставшимся временем. */\n effectsEl!: HTMLElement;\n /** Пузырьки воздуха над едой — только под водой. */\n bubblesEl!: HTMLElement;\n private readonly _bubbles: HTMLSpanElement[] = [];\n /** Компас или часы в руке — стрелка или время у хотбара. */\n gadgetEl!: HTMLElement;\n /** Карта в руке. */\n mapEl!: HTMLElement;\n mapCanvas!: HTMLCanvasElement;\n mapMarker!: HTMLElement;\n /** Крестик карты сокровищ. */\n mapTreasure!: HTMLElement;\n private _mapShown: MapData | null = null;\n private _mapVersion = -1;\n /** Полоса опыта над хотбаром и номер уровня. */\n xpFillEl: HTMLElement;\n xpLevelEl: HTMLElement;\n _lastXp = \"\";\n /** Шкала брони над сердцами (10 значков, по 2 очка защиты). */\n armorEl: HTMLElement;\n _armorPips: HTMLSpanElement[] = [];\n _lastArmor = -1;\n\n constructor(game: Game) {\n this.game = game;\n // Все элементы гарантированно есть в разметке — helper утверждает non-null.\n this.$ = (id: string): HTMLElement => document.getElementById(id)!;\n this.hotbarEl = this.$(\"hotbar\");\n this.heartsEl = this.$(\"hearts\");\n this.fpsEl = this.$(\"fps\");\n this.debugEl = this.$(\"debug\");\n this.timeEl = this.$(\"timeIndicator\");\n this.overlayEl = this.$(\"overlay\");\n this.hurtEl = this.$(\"hurtFlash\");\n this.msgEl = this.$(\"message\");\n\n this._invDirty = true;\n this._lastHealth = -1;\n events.on(\"invChanged\", () => {\n this._invDirty = true;\n });\n events.on(\"playerHurt\", () => this._flashHurt());\n // короткие подсказки из систем без доступа к HUD («Нужно седло»)\n events.on(\"ui:hint\", ({ text }: { text: string }) =>\n this.message(text, 1600),\n );\n // экран смерти и огонь на экране\n this.deathEl = document.createElement(\"div\");\n this.deathEl.id = \"deathScreen\";\n this.deathEl.className = \"hidden\";\n this.deathEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Вы погибли</h1><p class=\"deathCause\"></p>' +\n '<button class=\"deathBtn\">Возродиться</button></div>';\n this.deathEl.querySelector(\"button\")!.onclick = () => game.respawnPlayer();\n this.fireEl = document.createElement(\"div\");\n this.fireEl.id = \"fireOverlay\";\n // полоса босса и титры после победы над драконом\n this.bossEl = document.createElement(\"div\");\n this.bossEl.id = \"bossBar\";\n this.bossEl.className = \"hidden\";\n this.bossEl.innerHTML =\n '<div class=\"bossName\"></div><div class=\"bossTrack\"><div class=\"bossFill\"></div></div>';\n this.creditsEl = document.createElement(\"div\");\n this.creditsEl.id = \"creditsScreen\";\n this.creditsEl.className = \"hidden\";\n this.creditsEl.innerHTML =\n '<div class=\"deathPanel\"><h1>Победа</h1><p class=\"credits\">Дракон Предела повержен.<br>' +\n \"Путь пройден: верхний мир — Пекло — Предел.<br>Мир остаётся твоим: строй, исследуй, \" +\n \"возвращайся.<br><br>Спасибо, что играешь в VoxelCraft.</p>\" +\n '<button class=\"deathBtn\">Продолжить</button></div>';\n this.creditsEl.querySelector(\"button\")!.onclick = () => {\n this.creditsEl.classList.add(\"hidden\");\n game.input.uiOpen = false;\n game.input.requestLock();\n };\n this.effectsEl = document.createElement(\"div\");\n this.effectsEl.id = \"effects\";\n // компас и часы в руке\n this.gadgetEl = document.createElement(\"div\");\n this.gadgetEl.id = \"gadget\";\n this.gadgetEl.className = \"hidden\";\n this.heartsEl.parentElement!.appendChild(this.gadgetEl);\n // карта в руке: холст 128×128 (растянут) и метка игрока\n this.mapEl = document.createElement(\"div\");\n this.mapEl.id = \"mapView\";\n this.mapEl.className = \"hidden\";\n this.mapCanvas = document.createElement(\"canvas\");\n this.mapCanvas.width = this.mapCanvas.height = MAP_SIZE;\n this.mapMarker = document.createElement(\"span\");\n this.mapMarker.className = \"mapMarker\";\n this.mapMarker.textContent = \"▲\";\n this.mapTreasure = document.createElement(\"span\");\n this.mapTreasure.className = \"mapTreasure\";\n this.mapTreasure.textContent = \"✖\";\n this.mapEl.append(this.mapCanvas, this.mapTreasure, this.mapMarker);\n this.heartsEl.parentElement!.appendChild(this.mapEl);\n this.heartsEl.parentElement!.append(\n this.deathEl,\n this.fireEl,\n this.bossEl,\n this.creditsEl,\n this.effectsEl,\n );\n events.on(\"modeChanged\", ({ mode }) =>\n this.message(\n mode === \"creative\"\n ? tr({\n en: \"Mode: CREATIVE (double Space — fly)\",\n ru: \"Режим: КРЕАТИВ (двойной Space — полёт)\",\n })\n : tr({ en: \"Mode: survival\", ru: \"Режим: выживание\" }),\n 1600,\n ),\n );\n\n // слоты хотбара\n this._slots = [];\n for (let i = 0; i < 9; i++) {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n // тап/клик по слоту — выбрать его (на телефоне колёсика и цифр нет)\n d.addEventListener(\"pointerdown\", () =>\n game.intents.push(Intent.SelectSlot, { slot: i }),\n );\n this.hotbarEl.appendChild(d);\n this._slots.push(d);\n }\n // сердечки\n this._hearts = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"heart\";\n s.textContent = \"❤\";\n this.heartsEl.appendChild(s);\n this._hearts.push(s);\n }\n // опыт\n const xpBar = document.createElement(\"div\");\n xpBar.id = \"xpBar\";\n this.xpFillEl = document.createElement(\"div\");\n this.xpFillEl.className = \"xpFill\";\n xpBar.appendChild(this.xpFillEl);\n this.xpLevelEl = document.createElement(\"div\");\n this.xpLevelEl.id = \"xpLevel\";\n this.heartsEl.parentElement!.append(xpBar, this.xpLevelEl);\n // броня\n this.armorEl = document.createElement(\"div\");\n this.armorEl.id = \"armorBar\";\n this.heartsEl.parentElement!.appendChild(this.armorEl);\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"armorPip\";\n s.textContent = \"⛨\";\n this.armorEl.appendChild(s);\n this._armorPips.push(s);\n }\n // окорочка (голод)\n this.hungerEl = this.$(\"hunger\");\n this._drumsticks = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"drumstick\";\n s.textContent = \"🍗\";\n this.hungerEl.appendChild(s);\n this._drumsticks.push(s);\n }\n this.bubblesEl = document.createElement(\"div\");\n this.bubblesEl.id = \"bubbles\";\n this.bubblesEl.style.display = \"none\";\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"bubble\";\n s.textContent = \"◯\";\n this.bubblesEl.appendChild(s);\n this._bubbles.push(s);\n }\n this.hungerEl.parentElement!.appendChild(this.bubblesEl);\n\n // кнопки паузы; создание миров, экспорт и импорт — в лобби (ui/Lobby)\n this.$(\"btnResume\").onclick = () => game.startPlay();\n this.$(\"btnLobby\").onclick = () => game.toLobby();\n this.$(\"btnBook\").onclick = () => game.catalogUI.show();\n this.overlayEl.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).closest(\"button\")) return; // кнопки не запускают игру\n game.startPlay();\n });\n }\n\n /** Последний показ паузы (пауза или старт) — перерисовать после смены языка. */\n private _overlayPaused = false;\n\n showOverlay(paused: boolean) {\n this._overlayPaused = paused;\n this.overlayEl.classList.remove(\"hidden\");\n this.$(\"overlayTitle\").textContent = paused\n ? tr({ en: \"Paused\", ru: \"Пауза\" })\n : \"VoxelCraft\";\n const touch = isTouchUi();\n this.$(\"overlayHint\").textContent = paused\n ? touch\n ? tr({ en: \"Tap to continue\", ru: \"Нажмите — продолжить\" })\n : tr({ en: \"Click to continue\", ru: \"Клик — продолжить\" })\n : touch\n ? tr({ en: \"Tap to play\", ru: \"Нажмите — играть\" })\n : tr({ en: \"Click to play\", ru: \"Клик — играть\" });\n this.$(\"btnResume\").textContent = paused\n ? tr({ en: \"Continue\", ru: \"Продолжить\" })\n : tr({ en: \"Play\", ru: \"Играть\" });\n this.$(\"overlayControls\").innerHTML = controlsHtml(touch);\n this.$(\"btnBook\").textContent = \"📖 \" + ct(\"title\");\n const lobby = this.$(\"btnLobby\");\n lobby.textContent = tr({ en: \"To the lobby\", ru: \"В лобби\" });\n lobby.classList.toggle(\"hidden\", !this.game.lobbyApi?.available());\n this.game.netUI?.syncButton(); // «Игроки» — только в сетевой игре\n this._langRow();\n }\n\n /** Язык в паузе: English / Русский — для всей игры и лобби SDK (i18n chooseLang). */\n private _langRow(): void {\n const row = this.$(\"overlayLang\");\n row.innerHTML = \"\";\n const label = document.createElement(\"span\");\n label.textContent = tr({ en: \"Language\", ru: \"Язык\" });\n row.append(label);\n for (const [l, name] of [\n [\"en\", \"English\"],\n [\"ru\", \"Русский\"],\n ] as const) {\n const b = document.createElement(\"button\");\n b.className = \"idos-press langBtn\" + (lang() === l ? \" on\" : \"\");\n b.textContent = name;\n b.onclick = () => chooseLang(l);\n row.append(b);\n }\n }\n /** Язык сменили (здесь или в лобби) — открытая пауза перерисовывается сразу. */\n langChanged(): void {\n if (!this.overlayEl.classList.contains(\"hidden\"))\n this.showOverlay(this._overlayPaused);\n }\n\n hideOverlay() {\n this.overlayEl.classList.add(\"hidden\");\n }\n\n /** Экран «Вы погибли» с причиной и кнопкой возрождения. */\n showDeath(cause: string) {\n const CAUSES: Record<string, Loc> = {\n attack: { en: \"You were killed by a monster\", ru: \"Вас убил монстр\" },\n projectile: { en: \"You were shot\", ru: \"Вас застрелили\" },\n explosion: { en: \"You were blown up\", ru: \"Вас взорвали\" },\n lava: { en: \"You tried to swim in lava\", ru: \"Вы искупались в лаве\" },\n fire: { en: \"You burned to death\", ru: \"Вы сгорели\" },\n fall: { en: \"You fell from a high place\", ru: \"Вы упали с высоты\" },\n starve: { en: \"You starved to death\", ru: \"Вы умерли от голода\" },\n void: { en: \"You fell out of the world\", ru: \"Вы выпали из мира\" },\n cactus: {\n en: \"You were pricked to death by a cactus\",\n ru: \"Вас искололо кактусом\",\n },\n magic: { en: \"You were killed by magic\", ru: \"Вас сгубило колдовство\" },\n lightning: {\n en: \"You were struck by lightning\",\n ru: \"Вас поразила молния\",\n },\n decay: { en: \"You withered away\", ru: \"Вы истлели\" },\n skull: {\n en: \"You were slain by the Ash Lord\",\n ru: \"Вас сразил Пепельный владыка\",\n },\n bullet: {\n en: \"You were shot by a shellkeeper\",\n ru: \"Вас подбил панцирник\",\n },\n drown: { en: \"You drowned\", ru: \"Вы утонули\" },\n beam: {\n en: \"You were burned by a deepling's beam\",\n ru: \"Вас сжёг луч глубинника\",\n },\n thorns: {\n en: \"You were pricked by a deepling's thorns\",\n ru: \"Вы укололись о шипы глубинника\",\n },\n wall: { en: \"You flew into a wall\", ru: \"Вы врезались в стену на лету\" },\n // причины, для которых экран смерти молчал (замёрзнуть в снегу-порохе — самая частая)\n freeze: { en: \"You froze to death\", ru: \"Вы замёрзли\" },\n stalagmite: {\n en: \"You were impaled on a stalagmite\",\n ru: \"Вас пронзил сталагмит\",\n },\n falling_stalactite: {\n en: \"A stalactite fell on you\",\n ru: \"На вас упал сталактит\",\n },\n teleport: {\n en: \"An unlucky pearl throw\",\n ru: \"Неудачный бросок жемчуга\",\n },\n sonic_boom: {\n en: \"You were obliterated by a sonic shriek\",\n ru: \"Вас оглушил вопль глубинника\",\n },\n wind: { en: \"You were blown away by the wind\", ru: \"Вас сдуло ветром\" },\n poison: { en: \"You were poisoned\", ru: \"Вас отравили\" },\n };\n this.deathEl.querySelector(\".deathCause\")!.textContent =\n cause in CAUSES ? tr(CAUSES[cause]!) : \"\"; // заголовок панели уже «Вы погибли» — без повтора\n // заголовок и кнопка — на текущем языке (разметка собрана в конструкторе)\n this.deathEl.querySelector(\"h1\")!.textContent = tr({\n en: \"You died\",\n ru: \"Вы погибли\",\n });\n this.deathEl.querySelector(\"button\")!.textContent = tr({\n en: \"Respawn\",\n ru: \"Возродиться\",\n });\n this.deathEl.classList.remove(\"hidden\");\n }\n hideDeath() {\n this.deathEl.classList.add(\"hidden\");\n }\n\n /** Титры после первой победы над драконом (кнопка «Продолжить» возвращает управление). */\n showCredits() {\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.creditsEl.querySelector(\"h1\")!.textContent = tr({\n en: \"Victory\",\n ru: \"Победа\",\n });\n this.creditsEl.querySelector(\".credits\")!.innerHTML = tr({\n en: \"The Beyond Dragon is defeated.<br>The path is complete: the Overworld — the Inferno — the Beyond.<br>The world stays yours: build, explore, come back.<br><br>Thank you for playing VoxelCraft.\",\n ru: \"Дракон Предела повержен.<br>Путь пройден: верхний мир — Пекло — Предел.<br>Мир остаётся твоим: строй, исследуй, возвращайся.<br><br>Спасибо, что играешь в VoxelCraft.\",\n });\n this.creditsEl.querySelector(\"button\")!.textContent = tr({\n en: \"Continue\",\n ru: \"Продолжить\",\n });\n this.creditsEl.classList.remove(\"hidden\");\n }\n\n message(text: string, ms = 1500) {\n this.msgEl.textContent = text;\n this.msgEl.classList.add(\"show\");\n clearTimeout(this._msgT);\n this._msgT = setTimeout(() => this.msgEl.classList.remove(\"show\"), ms);\n }\n\n /** Teardown: таймер сообщения не должен пережить игру. */\n dispose() {\n clearTimeout(this._msgT);\n }\n\n _flashHurt() {\n this.hurtEl.classList.remove(\"show\");\n void this.hurtEl.offsetWidth; // перезапуск CSS-анимации\n this.hurtEl.classList.add(\"show\");\n }\n\n frame() {\n const g = this.game;\n if (this._invDirty) {\n this._renderHotbar();\n this._invDirty = false;\n }\n if (\n g.player.health !== this._lastHealth ||\n this._modeWas !== g.player.mode\n ) {\n this._renderHearts();\n this._lastHealth = g.player.health;\n this._modeWas = g.player.mode;\n }\n if (\n g.player.food !== this._lastFood ||\n this._foodModeWas !== g.player.mode\n ) {\n this._renderHunger();\n this._lastFood = g.player.food;\n this._foodModeWas = g.player.mode;\n }\n\n const xpKey =\n g.player.xpLevel +\n \":\" +\n Math.round(g.player.xpProgress * 200) +\n \":\" +\n g.player.mode;\n if (xpKey !== this._lastXp) {\n this._lastXp = xpKey;\n const show = g.player.mode !== \"creative\";\n this.xpFillEl.parentElement!.style.display = show ? \"\" : \"none\";\n this.xpFillEl.style.width = (g.player.xpProgress * 100).toFixed(1) + \"%\";\n this.xpLevelEl.textContent =\n show && g.player.xpLevel > 0 ? String(g.player.xpLevel) : \"\";\n }\n\n const armor = g.inventory.armorStats().points;\n if (armor !== this._lastArmor) {\n this._lastArmor = armor;\n this.armorEl.style.display =\n armor > 0 && g.player.mode !== \"creative\" ? \"\" : \"none\";\n for (let i = 0; i < 10; i++) {\n const v = armor - i * 2;\n this._armorPips[i]!.className =\n \"armorPip\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n this.fireEl.classList.toggle(\"show\", g.player.fireT > 0 && !g.player.dead);\n // эффекты зелий и маяка: «Сила 2:31»; Тление чернит сердца\n this.heartsEl.classList.toggle(\"decay\", g.player.effects.has(\"decay\"));\n // пузырьки: сколько воздуха осталось под водой\n const pl = g.player;\n const showAir =\n pl.mode !== \"creative\" && !pl.dead && pl.air < MAX_AIR - 0.01;\n this.bubblesEl.style.display = showAir ? \"\" : \"none\";\n if (showAir) {\n const n = Math.ceil((pl.air / MAX_AIR) * 10);\n this._bubbles.forEach(\n (b, i) => (b.className = \"bubble\" + (i < n ? \"\" : \" empty\")),\n );\n }\n let fx = \"\";\n for (const [id, e] of g.player.effects) {\n const s = Math.max(0, Math.ceil(e.time));\n fx +=\n `${effectLabel(id)}${e.level > 1 ? \" \" + e.level : \"\"} ` +\n `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}\\n`;\n }\n if (this.effectsEl.textContent !== fx) this.effectsEl.textContent = fx;\n // полоса босса: живой босс в загруженном мире\n let boss: { name: string; hp: number; max: number } | null = null;\n for (const e of g.entities.entities.values())\n if (e.kind === \"mob\" && e.def.boss && !e.dead)\n boss = { name: labelOf(e.def.name), hp: e.hp, max: e.def.hp };\n // нет босса, идёт набег — полоса набега: здоровье живых разбойников волны\n const raid = g.raid;\n if (!boss && raid) {\n let hp = 0;\n for (const id of raid.raiders) {\n const e = g.entities.entities.get(id);\n if (e?.kind === \"mob\" && !e.dead) hp += e.hp;\n }\n boss =\n raid.state === \"fighting\"\n ? {\n name: tr(\n {\n en: \"Raid · wave {w} of {n}\",\n ru: \"Набег · волна {w} из {n}\",\n },\n { w: raid.wave, n: RAID_WAVES },\n ),\n hp,\n max: Math.max(1, raid.hpTotal),\n }\n : {\n name: tr(\n {\n en: \"Raid · wave {w} of {n} — soon\",\n ru: \"Набег · волна {w} из {n} — скоро\",\n },\n { w: raid.wave + 1, n: RAID_WAVES },\n ),\n hp: 0,\n max: 1,\n };\n }\n this.bossEl.classList.toggle(\"hidden\", !boss);\n if (boss) {\n put(this.bossEl.querySelector<HTMLElement>(\".bossName\")!, boss.name);\n (this.bossEl.querySelector(\".bossFill\") as HTMLElement).style.width =\n ((boss.hp / boss.max) * 100).toFixed(1) + \"%\";\n }\n // FPS и дебаг-строка: 4 раза в секунду и только когда видны (на телефоне скрыты). Раньше обе\n // переписывались каждый кадр: лишний пересчёт стилей на каждом кадре игры (05.11.2026).\n const now = performance.now();\n if (now - this._statsAt >= STATS_MS) {\n this._statsAt = now;\n this._renderStats();\n }\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n // в Пекле и Пределе неба нет — и времени суток на экране тоже\n put(\n this.timeEl,\n DIMENSIONS[g.dimension].hasSky\n ? (g.dayNight.isDay ? \"☀\" : \"☾\") +\n \" \" +\n (ph < 0.5\n ? tr({ en: \"day\", ru: \"день\" })\n : tr({ en: \"night\", ru: \"ночь\" })) +\n (g.weather.kind === \"thunder\"\n ? tr({ en: \" · thunder\", ru: \" · гроза\" })\n : g.weather.kind === \"rain\"\n ? tr({ en: \" · rain\", ru: \" · дождь\" })\n : \"\")\n : \"\",\n );\n this._gadget();\n this._mapView();\n }\n\n private _statsAt = 0;\n private _renderStats(): void {\n const g = this.game;\n if (this.fpsEl.offsetParent) put(this.fpsEl, g.loop.fps + \" FPS\");\n if (!this.debugEl.offsetParent) return;\n const p = g.player.pos;\n put(\n this.debugEl,\n `xyz: ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)} | ` +\n `chunks: ${g.world.chunks.size} | fluids: ${g.fluids.queueSize} | seed: ${g.world.seed}` +\n // загрузка мира и рендер: мс кадра на генерацию/мешинг в главном потоке, очереди, draw call'ы\n ` | gen ${worldPerf.genMs.toFixed(1)} ms · mesh ${worldPerf.meshMs.toFixed(1)} ms` +\n ` · queue ${worldPerf.genQueue}/${worldPerf.dirty}` +\n (worldPerf.workers ? ` · workers ${worldPerf.workerJobs}` : \"\") +\n (worldPerf.lodChunks ? ` · LOD ${worldPerf.lodChunks}` : \"\") +\n ` | DC ${g.renderStats.calls} · △ ${(g.renderStats.triangles / 1000).toFixed(0)}k`,\n );\n }\n\n /** Карта в руке: холст перерисовывается, только когда карта изменилась; метка — каждый кадр. */\n private _mapView() {\n const g = this.game;\n const h = g.inventory.heldItem();\n const m =\n h?.item === \"filled_map\" ? g.maps.get(Number(h.tag?.map)) : undefined;\n // toggle(cls, force) не трогает DOM, если класс уже такой; add/remove пишут атрибут КАЖДЫЙ раз\n this.mapEl.classList.toggle(\"hidden\", !m);\n if (!m) return;\n if (this._mapShown !== m || this._mapVersion !== m.version) {\n this._mapShown = m;\n this._mapVersion = m.version;\n const ctx = this.mapCanvas.getContext(\"2d\");\n if (ctx) {\n const img = ctx.createImageData(MAP_SIZE, MAP_SIZE);\n for (let i = 0; i < m.px.length; i++) {\n const c = mapRGB(m.px[i]!);\n const o = i * 4;\n img.data[o] = c < 0 ? 214 : (c >> 16) & 255;\n img.data[o + 1] = c < 0 ? 190 : (c >> 8) & 255;\n img.data[o + 2] = c < 0 ? 150 : c & 255;\n img.data[o + 3] = 255;\n }\n ctx.putImageData(img, 0, 0);\n }\n }\n const p = g.player.pos;\n const u = (p.x - m.x0) / MAP_SIZE,\n v = (p.z - m.z0) / MAP_SIZE;\n const inside =\n m.dim === g.dimension && u >= 0 && u <= 1 && v >= 0 && v <= 1;\n this.mapMarker.style.display = inside ? \"\" : \"none\";\n this.mapMarker.style.left = (u * 100).toFixed(2) + \"%\";\n this.mapMarker.style.top = (v * 100).toFixed(2) + \"%\";\n this.mapMarker.style.transform = `translate(-50%, -50%) rotate(${((-g.player.yaw * 180) / Math.PI).toFixed(1)}deg)`;\n // карта сокровищ: красный крестик над кладом\n const t = m.target;\n this.mapTreasure.style.display = t ? \"\" : \"none\";\n if (t) {\n this.mapTreasure.style.left =\n (((t[0] + 0.5 - m.x0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n this.mapTreasure.style.top =\n (((t[1] + 0.5 - m.z0) / MAP_SIZE) * 100).toFixed(2) + \"%\";\n }\n }\n\n private frostEl: HTMLElement | null = null;\n /** Иней по краям экрана — насколько игрок промёрз в снегу-порохе (0..1, systems/Freezing). */\n frost(k: number): void {\n if (!this.frostEl) {\n if (k <= 0) return;\n const el = document.createElement(\"div\");\n el.style.cssText =\n \"position:fixed;inset:0;pointer-events:none;opacity:0;\" +\n \"background:radial-gradient(ellipse at center,rgba(255,255,255,0) 40%,rgba(220,240,255,.9) 100%)\";\n this.heartsEl.parentElement!.appendChild(el);\n this.frostEl = el;\n }\n const o = k.toFixed(2);\n if (this.frostEl.style.opacity !== o) this.frostEl.style.opacity = o;\n }\n\n /**\n * Компас и часы в руке: у хотбара — стрелка на точку появления мира (в Пекле и Пределе крутится)\n * или время суток (без неба — «??:??»), как у приборов Minecraft.\n */\n private _gadget() {\n const g = this.game;\n const held = g.inventory.heldItem()?.item;\n const el = this.gadgetEl;\n const on =\n held === \"compass\" || held === \"clock\" || held === \"recovery_compass\";\n el.classList.toggle(\"hidden\", !on);\n if (!on) return;\n if (held === \"compass\" || held === \"recovery_compass\") {\n // компас — на путевой камень (если привязан здесь) или на точку появления,\n // компас возвращения — на место последней смерти в этом измерении\n const d = g.lastDeath;\n const lode = g.player.lodestone;\n const s =\n held === \"compass\"\n ? lode && lode.dim === g.dimension\n ? { x: lode.x, z: lode.z }\n : g.dimension === \"overworld\"\n ? g.world.generator.spawnHint()\n : null\n : d && d.dim === g.dimension\n ? { x: Math.floor(d.x), z: Math.floor(d.z) }\n : null;\n let deg: number;\n if (!s) deg = (performance.now() / 3) % 360;\n else {\n const p = g.player.pos;\n const target = Math.atan2(-(s.x + 0.5 - p.x), -(s.z + 0.5 - p.z));\n deg = (-(target - g.player.yaw) * 180) / Math.PI;\n }\n // стрелка одна на всё время: крутится её transform, разметка не пересобирается каждый кадр\n let needle = el.querySelector<HTMLElement>(\".needle\");\n if (!needle) {\n el.textContent = \"\";\n needle = document.createElement(\"span\");\n needle.className = \"needle\";\n needle.textContent = \"▲\";\n el.append(needle);\n }\n const rot = `rotate(${deg.toFixed(1)}deg)`;\n if (needle.style.transform !== rot) needle.style.transform = rot;\n return;\n }\n if (!DIMENSIONS[g.dimension].hasSky) {\n put(el, \"🕒 ??:??\");\n return;\n }\n const h = (6 + g.dayNight.phase * 24) % 24;\n put(\n el,\n `🕒 ${String(Math.floor(h)).padStart(2, \"0\")}:${String(Math.floor((h % 1) * 60)).padStart(2, \"0\")}`,\n );\n }\n\n _renderHotbar() {\n const inv = this.game.inventory;\n for (let i = 0; i < 9; i++) {\n const el = this._slots[i]!;\n const s = inv.slots[i];\n el.classList.toggle(\"selected\", i === inv.hotbarIndex);\n setSlotTip(el, s ?? null);\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (s) {\n img.src = stackIconURL(s); // иконка облика или предмета; неизвестный предмет (чужой сейв) — пустая иконка, а не падение\n img.style.display = \"\";\n el.classList.toggle(\"ench\", enchantsOf(s).length > 0);\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (s.count > 1 ? s.count : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n el.classList.remove(\"ench\");\n }\n drawDurability(el, s ?? null);\n }\n }\n\n _renderHearts() {\n const g = this.game;\n this.heartsEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const hp = g.player.health;\n for (let i = 0; i < 10; i++) {\n const v = hp - i * 2;\n this._hearts[i]!.className =\n \"heart\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n _renderHunger() {\n const g = this.game;\n this.hungerEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const food = g.player.food;\n for (let i = 0; i < 10; i++) {\n const v = food - i * 2;\n this._drumsticks[i]!.className =\n \"drumstick\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n}\n\n/** Управление на экране паузы: клавиши — на компьютере, кнопки — на тач-экране. */\nfunction controlsHtml(touch: boolean): string {\n const rows: Loc[] = touch\n ? [\n {\n en: \"<b>Left stick</b> move, <b>swipe right side</b> look around\",\n ru: \"<b>Левый джойстик</b>: идти, <b>проведи справа</b>: осмотреться\",\n },\n {\n en: \"<b>Tap</b> the screen: place / open, <b>hold</b> it: break; with food, a bow or a shield in hand <b>hold</b> eats, draws, blocks\",\n ru: \"<b>Тап</b> по экрану: поставить / открыть, <b>удержание</b>: ломать; с едой, луком или щитом в руке <b>удержание</b>: есть, натянуть, закрыться\",\n },\n {\n en: \"<b>⚒</b> break / attack, <b>✋</b> (hold) use: eat, draw a bow, block, <b>⤒</b> jump, <b>⇩</b> sneak\",\n ru: \"<b>⚒</b>: ломать / бить, <b>✋</b> (держать): использовать, есть, натянуть лук, <b>⤒</b>: прыжок, <b>⇩</b>: присесть\",\n },\n {\n en: \"<b>▦</b> inventory (<b>hold</b> a slot: half a stack / one item, <b>double tap</b>: move it across), <b>📜</b> quests, <b>minimap</b> the map (two fingers: zoom), <b>⏸</b> pause\",\n ru: \"<b>▦</b>: инвентарь (<b>удержание</b> слота: половина стопки / один предмет, <b>двойное касание</b>: переложить), <b>📜</b>: задания, <b>миникарта</b>: карта (двумя пальцами: масштаб), <b>⏸</b>: пауза\",\n },\n ]\n : [\n {\n en: \"<b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> / <b>W W</b> sprint, <b>Ctrl</b> sneak\",\n ru: \"<b>WASD</b> — идти, <b>Пробел</b> — прыжок / всплыть, <b>Shift</b> / <b>W W</b> — бег, <b>Ctrl</b> — присесть\",\n },\n {\n en: \"<b>LMB</b> (hold) break / attack, <b>RMB</b> place block / open door\",\n ru: \"<b>ЛКМ</b> (держать) — ломать / бить, <b>ПКМ</b> — поставить блок / открыть дверь\",\n },\n {\n en: \"<b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory & 2×2 craft\",\n ru: \"<b>Колесо / 1-9</b> — слот, <b>E</b> — инвентарь и крафт 2×2\",\n },\n {\n en: \"<b>RMB (hold) with food</b> eat (watch the hunger bar)\",\n ru: \"<b>ПКМ (держать) с едой</b> — есть (следи за сытостью)\",\n },\n {\n en: \"<b>RMB on a workbench</b> 3×3 craft, <b>C</b> creative (double <b>Space</b> to fly, <b>Shift</b> / <b>Ctrl</b> down)\",\n ru: \"<b>ПКМ по верстаку</b> — крафт 3×3, <b>C</b> — творческий режим (двойной <b>Пробел</b> — полёт, <b>Shift</b> / <b>Ctrl</b> — вниз)\",\n },\n {\n en: \"<b>M</b> map & pins, <b>J</b> quest book, <b>Esc</b> pause\",\n ru: \"<b>M</b> — карта и метки, <b>J</b> — книга заданий, <b>Esc</b> — пауза\",\n },\n ];\n return rows.map((r) => `<div>${tr(r)}</div>`).join(\"\");\n}\n\nfunction isTouchUi(): boolean {\n return document.body.classList.contains(\"touch\");\n}\n\n/** Как часто обновлять FPS и дебаг-строку, мс. */\nconst STATS_MS = 250;\n\n/** Текст элемента, только если он поменялся: запись того же текста всё равно пересобирает узел. */\nfunction put(el: HTMLElement, text: string): void {\n if (el.textContent !== text) el.textContent = text;\n}\n"
|
|
1739
1739
|
},
|
|
1740
1740
|
{
|
|
1741
1741
|
"path": "ui/inventory/build.ts",
|
|
1742
|
-
"content": "import { ITEMS } from \"../../registry/Items\";\nimport { labelOf } from \"../../registry/names\";\nimport { ARMOR_SLOTS } from \"../../systems/Inventory\";\nimport type { FurnaceState } from \"../../world/blockentities/furnace\";\nimport type { BrewingState } from \"../../world/blockentities/brewing\";\nimport { TRIM_MATERIALS } from \"../../registry/items/trims\";\nimport { stonecutOptions } from \"../../systems/Stonecutter\";\nimport { bannerColor, dyeColor } from \"../../systems/Banner\";\nimport { canEnchant } from \"../../systems/Enchant\";\nimport type { ChestState } from \"../../world/blockentities/chest\";\nimport { TIER_NAMES, levelOf } from \"../../systems/Trading\";\nimport { buildCreativePalette } from \"../creativePalette\";\nimport type { ScreenKind, SlotRef } from \"./types\";\nimport type { InventoryUI } from \"../InventoryUI\";\nimport { tr, type Loc } from \"../../i18n\";\nimport { ct } from \"../catalog/strings\";\nimport { gatherToCursor, sortBackpack } from \"./clicks\";\n\nconst TITLES: Record<ScreenKind, Loc> = {\n inventory: { en: \"Inventory\", ru: \"Инвентарь\" },\n crafting: { en: \"Crafting Table\", ru: \"Верстак\" },\n chest: { en: \"Chest\", ru: \"Сундук\" },\n furnace: { en: \"Furnace\", ru: \"Печь\" },\n creative: { en: \"Creative\", ru: \"Креатив\" },\n brewing: { en: \"Brewing Stand\", ru: \"Варочная стойка\" },\n smithing: { en: \"Smithing Table\", ru: \"Кузнечный стол\" },\n enchanting: { en: \"Enchanting Table\", ru: \"Стол зачарований\" },\n anvil: { en: \"Anvil\", ru: \"Наковальня\" },\n trade: { en: \"Trading\", ru: \"Торговля\" },\n beacon: { en: \"Beacon\", ru: \"Маяк\" },\n stonecutter: { en: \"Stonecutter\", ru: \"Камнерез\" },\n grindstone: { en: \"Grindstone\", ru: \"Точило\" },\n loom: { en: \"Loom\", ru: \"Ткацкий станок\" },\n ender: { en: \"Beyond Chest\", ru: \"Сундук Предела\" },\n armor_stand: { en: \"Armor Stand\", ru: \"Стойка для брони\" },\n};\n\nexport function build(ui: InventoryUI) {\n const inv = ui.inventory;\n ui.refs = [];\n ui.resultEl = ui.flameEl = ui.arrowEl = null;\n ui.panelEl.innerHTML = \"\";\n const h = document.createElement(\"h3\");\n h.id = \"invTitle\";\n h.textContent = ui.cart\n ? labelOf(ui.cart.kind === \"mob\" ? ui.cart.def.name : ui.cart.dropItem)\n : ui.kind === \"trade\" && ui.tradeMob\n ? `${labelOf(ui.tradeMob.def.name)} — ${tr(TIER_NAMES[levelOf(ui.tradeMob.villagerXp) - 1]!)}`\n : // воронка, раздатчик, бочка, коптильня, плавильная печь — своим именем (горящие — как погасшие)\n (ui.kind === \"chest\" || ui.kind === \"furnace\") &&\n ui.be &&\n ![\"chest\", \"furnace\", \"lit_furnace\"].includes(ui._beBlockName())\n ? labelOf(ui._beBlockName().replace(/^lit_/, \"\"))\n : tr(TITLES[ui.kind]);\n // справочник (R): как получить предмет и из чего он делается\n const book = document.createElement(\"button\");\n book.className = \"ctOpenBtn\";\n book.textContent = \"📖 \" + ct(\"open\");\n book.addEventListener(\"click\", () => ui.game.catalogUI.show());\n h.append(book);\n // сортировка рюкзака (хотбар не трогаем — там то, что игрок разложил под руку)\n if (ui.kind !== \"creative\" && ui.kind !== \"trade\") {\n const sort = document.createElement(\"button\");\n sort.className = \"ctOpenBtn\";\n sort.textContent = \"⇅ \" + tr({ en: \"Sort\", ru: \"Сортировать\" });\n sort.title = tr({\n en: \"Sort the backpack: merges stacks, groups by kind. Double-click an item in hand — gather the same ones\",\n ru: \"Сортировать рюкзак: сливает стопки, группирует по видам. Двойной клик предметом в руке — собрать такие же\",\n });\n sort.addEventListener(\"click\", () => sortBackpack(ui));\n h.append(sort);\n }\n ui.panelEl.appendChild(h);\n\n if (ui.kind === \"creative\")\n buildCreativePalette(ui.panelEl, (item, right, shift) =>\n ui._clickPalette(item, right, shift),\n );\n\n const top = div(ui, ui.panelEl, \"invTop\");\n if (ui.kind === \"creative\") {\n // корзина: положенный сюда стак исчезает (ПКМ — по одному)\n div(ui, top, \"trashLabel\").textContent = tr({\n en: \"Delete:\",\n ru: \"Удалить:\",\n });\n slot(ui, top, {\n group: \"trash\",\n get: () => null,\n set: () => {},\n }).classList.add(\"trash\");\n }\n if (ui.kind === \"inventory\") {\n const armor = div(ui, top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n slot(ui, armor, {\n group: \"armor\",\n get: () => inv.armor[i] ?? null,\n set: (s) => inv.setArmor(i, s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n // кукла персонажа (тянуть — поворачивать); студия персонажа — в лобби (ui/Lobby)\n const doll = div(ui, top, \"dollBox\");\n doll.appendChild(ui.game.character.dollCanvas());\n }\n if (ui.kind === \"inventory\" || ui.kind === \"crafting\") {\n const n = ui.grid.size;\n const craft = div(ui, top, \"craftGrid\");\n craft.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n for (let i = 0; i < n * n; i++)\n slot(ui, craft, {\n group: \"craft\",\n get: () => ui.grid.slots[i] ?? null,\n set: (s) => (ui.grid.slots[i] = s),\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickResult(e.shiftKey);\n e.preventDefault();\n };\n ui.resultEl = res;\n }\n if (ui.kind === \"armor_stand\" && ui.be) {\n // стойка: те же четыре слота брони, что у игрока, но у клетки (gfx/ArmorStand показывает их)\n const items = (ui.be.state as ChestState).items;\n const col = div(ui, top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n slot(ui, col, {\n group: \"stand\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n }\n if (ui.kind === \"ender\") {\n // сундук Предела: слоты не у клетки, а у игрока — один склад на все сундуки и измерения\n const grid = div(ui, top, \"chestGrid\");\n for (let i = 0; i < inv.ender.length; i++)\n slot(ui, grid, {\n group: \"ender\",\n get: () => inv.ender[i] ?? null,\n set: (s) => (inv.ender[i] = s),\n });\n }\n if (ui.kind === \"chest\" && ui.be) {\n const items = (ui.be.state as ChestState).items;\n const grid = div(ui, top, \"chestGrid\");\n if (items.length === 9) grid.style.gridTemplateColumns = \"repeat(3, 44px)\"; // раздатчик 3×3\n for (let i = 0; i < items.length; i++)\n slot(ui, grid, {\n group: \"chest\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n // панцирный сундук в панцирный не кладётся, как в Minecraft\n accepts: (s) => !(ui.be?.type === \"shell\" && s.item === \"shell_chest\"),\n });\n }\n if (ui.kind === \"furnace\" && ui.be) {\n const st = ui.be.state as FurnaceState;\n const col = div(ui, top, \"furnaceCol\");\n slot(ui, col, {\n group: \"fIn\",\n get: () => st.input,\n set: (s) => (st.input = s),\n });\n ui.flameEl = div(ui, div(ui, col, \"flame\"), \"flameFill\") as HTMLDivElement;\n slot(ui, col, {\n group: \"fFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => (ITEMS[s.item]?.fuel ?? 0) > 0,\n });\n ui.arrowEl = div(\n ui,\n div(ui, top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n slot(ui, top, {\n group: \"fOut\",\n get: () => st.output,\n set: (s) => (st.output = s),\n takeOnly: true,\n }).classList.add(\"result\");\n }\n\n ui.offersEl = null;\n ui.costEl = null;\n if (ui.kind === \"anvil\") {\n slot(ui, top, {\n group: \"anLeft\",\n get: () => ui.anvil[0] ?? null,\n set: (s) => (ui.anvil[0] = s),\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"anRight\",\n get: () => ui.anvil[1] ?? null,\n set: (s) => (ui.anvil[1] = s),\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickAnvil(e.shiftKey);\n e.preventDefault();\n };\n ui.resultEl = res;\n ui.costEl = div(ui, top, \"anvilCost\");\n }\n if (ui.kind === \"grindstone\") {\n // точило: два слота и результат без зачарований (опыт возвращается игроку)\n slot(ui, top, {\n group: \"grLeft\",\n get: () => ui.grind[0] ?? null,\n set: (s) => (ui.grind[0] = s),\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"grRight\",\n get: () => ui.grind[1] ?? null,\n set: (s) => (ui.grind[1] = s),\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickGrind(e.shiftKey);\n e.preventDefault();\n };\n ui.resultEl = res;\n ui.costEl = div(ui, top, \"anvilCost\");\n }\n if (ui.kind === \"enchanting\") {\n const col = div(ui, top, \"enchCol\");\n slot(ui, col, {\n group: \"enItem\",\n get: () => ui.enchSlots[0] ?? null,\n set: (s) => (ui.enchSlots[0] = s),\n accepts: (s) => canEnchant(s.item),\n });\n slot(ui, col, {\n group: \"enLapis\",\n get: () => ui.enchSlots[1] ?? null,\n set: (s) => (ui.enchSlots[1] = s),\n accepts: (s) => s.item === \"lapis_lazuli\",\n });\n ui.offersEl = div(ui, top, \"enchOffers\");\n }\n if (ui.kind === \"loom\") {\n // ткацкий станок: знамя, краситель и список узоров — щелчок кладёт слой\n slot(ui, top, {\n group: \"loBanner\",\n get: () => ui.loom[0] ?? null,\n set: (s) => (ui.loom[0] = s),\n accepts: (s) => bannerColor(s.item) !== null,\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"loDye\",\n get: () => ui.loom[1] ?? null,\n set: (s) => (ui.loom[1] = s),\n accepts: (s) => dyeColor(s.item) !== null,\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n ui.offersEl = div(ui, top, \"enchOffers tradeOffers loomOffers\");\n }\n if (ui.kind === \"stonecutter\") {\n // камнерез: вход и список изделий — щелчок режет один блок\n slot(ui, top, {\n group: \"scIn\",\n get: () => ui.stoneIn[0] ?? null,\n set: (s) => (ui.stoneIn[0] = s),\n accepts: (s) => stonecutOptions(s.item).length > 0,\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n ui.offersEl = div(ui, top, \"enchOffers tradeOffers\");\n }\n if (ui.kind === \"trade\") ui.offersEl = div(ui, top, \"enchOffers tradeOffers\");\n if (ui.kind === \"beacon\")\n ui.offersEl = div(ui, top, \"enchOffers beaconPowers\");\n if (ui.kind === \"smithing\") {\n // шаблон отделки (необязателен: без него — улучшение до пеклита)\n slot(ui, top, {\n group: \"smTpl\",\n get: () => ui.smith[2] ?? null,\n set: (s) => (ui.smith[2] = s),\n accepts: (s) => s.item.endsWith(\"_smithing_template\"),\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"smBase\",\n get: () => ui.smith[0] ?? null,\n set: (s) => (ui.smith[0] = s),\n accepts: (s) =>\n s.item.startsWith(\"diamond_\") || !!ITEMS[s.item]?.armorSlot,\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"smAdd\",\n get: () => ui.smith[1] ?? null,\n set: (s) => (ui.smith[1] = s),\n accepts: (s) => s.item === \"infernite_ingot\" || s.item in TRIM_MATERIALS,\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickSmith(e.shiftKey);\n e.preventDefault();\n };\n ui.resultEl = res;\n }\n if (ui.kind === \"brewing\" && ui.be) {\n const st = ui.be.state as BrewingState;\n const col = div(ui, top, \"brewCol\");\n slot(ui, col, {\n group: \"bIn\",\n get: () => st.ingredient,\n set: (s) => (st.ingredient = s),\n });\n slot(ui, col, {\n group: \"bFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => s.item === \"flame_powder\",\n });\n ui.arrowEl = div(\n ui,\n div(ui, top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n const row = div(ui, top, \"bottleRow\");\n for (let i = 0; i < 3; i++)\n slot(ui, row, {\n group: \"bottle\",\n get: () => st.bottles[i] ?? null,\n set: (s) => (st.bottles[i] = s),\n accepts: (s) =>\n s.item.startsWith(\"potion_\") || s.item === \"glass_bottle\",\n });\n }\n\n const main = div(ui, ui.panelEl, \"invGrid\");\n for (let i = 9; i < 36; i++)\n slot(ui, main, {\n group: \"main\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n const hot = div(ui, ui.panelEl, \"invHotbar\");\n for (let i = 0; i < 9; i++)\n slot(ui, hot, {\n group: \"hotbar\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n}\n\nexport function div(\n ui: InventoryUI,\n parent: HTMLElement,\n cls: string,\n): HTMLElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n}\n\nexport function slot(\n ui: InventoryUI,\n parent: HTMLElement,\n ref: SlotRef,\n): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n d.onmousedown = (e) => {\n e.preventDefault();\n // двойной клик со стаком в руке — собрать такие же в курсор\n if (e.button === 0 && e.detail === 2 && !e.shiftKey && ui.cursor)\n return gatherToCursor(ui);\n ui._clickSlot(ref, e.button === 2, e.shiftKey);\n };\n d.oncontextmenu = (e) => e.preventDefault();\n parent.appendChild(d);\n ref.el = d;\n ui.refs.push(ref);\n return d;\n}\n"
|
|
1742
|
+
"content": "import { ITEMS } from \"../../registry/Items\";\nimport { labelOf } from \"../../registry/names\";\nimport { ARMOR_SLOTS } from \"../../systems/Inventory\";\nimport type { FurnaceState } from \"../../world/blockentities/furnace\";\nimport type { BrewingState } from \"../../world/blockentities/brewing\";\nimport { TRIM_MATERIALS } from \"../../registry/items/trims\";\nimport { stonecutOptions } from \"../../systems/Stonecutter\";\nimport { bannerColor, dyeColor } from \"../../systems/Banner\";\nimport { canEnchant } from \"../../systems/Enchant\";\nimport type { ChestState } from \"../../world/blockentities/chest\";\nimport { TIER_NAMES, levelOf } from \"../../systems/Trading\";\nimport { buildCreativePalette } from \"../creativePalette\";\nimport type { ScreenKind, SlotRef } from \"./types\";\nimport type { InventoryUI } from \"../InventoryUI\";\nimport { tr, type Loc } from \"../../i18n\";\nimport { ct } from \"../catalog/strings\";\nimport { gatherToCursor, sortBackpack } from \"./clicks\";\nimport { HOLD_MS, MOVE_PX, touchHold } from \"../touchHold\";\n\nconst TITLES: Record<ScreenKind, Loc> = {\n inventory: { en: \"Inventory\", ru: \"Инвентарь\" },\n crafting: { en: \"Crafting Table\", ru: \"Верстак\" },\n chest: { en: \"Chest\", ru: \"Сундук\" },\n furnace: { en: \"Furnace\", ru: \"Печь\" },\n creative: { en: \"Creative\", ru: \"Креатив\" },\n brewing: { en: \"Brewing Stand\", ru: \"Варочная стойка\" },\n smithing: { en: \"Smithing Table\", ru: \"Кузнечный стол\" },\n enchanting: { en: \"Enchanting Table\", ru: \"Стол зачарований\" },\n anvil: { en: \"Anvil\", ru: \"Наковальня\" },\n trade: { en: \"Trading\", ru: \"Торговля\" },\n beacon: { en: \"Beacon\", ru: \"Маяк\" },\n stonecutter: { en: \"Stonecutter\", ru: \"Камнерез\" },\n grindstone: { en: \"Grindstone\", ru: \"Точило\" },\n loom: { en: \"Loom\", ru: \"Ткацкий станок\" },\n ender: { en: \"Beyond Chest\", ru: \"Сундук Предела\" },\n armor_stand: { en: \"Armor Stand\", ru: \"Стойка для брони\" },\n};\n\nexport function build(ui: InventoryUI) {\n const inv = ui.inventory;\n ui.refs = [];\n ui.resultEl = ui.flameEl = ui.arrowEl = null;\n ui.panelEl.innerHTML = \"\";\n const h = document.createElement(\"h3\");\n h.id = \"invTitle\";\n h.textContent = ui.cart\n ? labelOf(ui.cart.kind === \"mob\" ? ui.cart.def.name : ui.cart.dropItem)\n : ui.kind === \"trade\" && ui.tradeMob\n ? `${labelOf(ui.tradeMob.def.name)} — ${tr(TIER_NAMES[levelOf(ui.tradeMob.villagerXp) - 1]!)}`\n : // воронка, раздатчик, бочка, коптильня, плавильная печь — своим именем (горящие — как погасшие)\n (ui.kind === \"chest\" || ui.kind === \"furnace\") &&\n ui.be &&\n ![\"chest\", \"furnace\", \"lit_furnace\"].includes(ui._beBlockName())\n ? labelOf(ui._beBlockName().replace(/^lit_/, \"\"))\n : tr(TITLES[ui.kind]);\n // справочник (R): как получить предмет и из чего он делается\n const book = document.createElement(\"button\");\n book.className = \"ctOpenBtn\";\n book.textContent = \"📖 \" + ct(\"open\");\n book.addEventListener(\"click\", () => ui.game.catalogUI.show());\n h.append(book);\n // сортировка рюкзака (хотбар не трогаем — там то, что игрок разложил под руку)\n if (ui.kind !== \"creative\" && ui.kind !== \"trade\") {\n const sort = document.createElement(\"button\");\n sort.className = \"ctOpenBtn\";\n sort.textContent = \"⇅ \" + tr({ en: \"Sort\", ru: \"Сортировать\" });\n sort.title = tr({\n en: \"Sort the backpack: merges stacks, groups by kind. Double-click an item in hand — gather the same ones\",\n ru: \"Сортировать рюкзак: сливает стопки, группирует по видам. Двойной клик предметом в руке — собрать такие же\",\n });\n sort.addEventListener(\"click\", () => sortBackpack(ui));\n h.append(sort);\n }\n ui.panelEl.appendChild(h);\n\n if (ui.kind === \"creative\")\n buildCreativePalette(ui.panelEl, (item, right, shift) =>\n ui._clickPalette(item, right, shift),\n );\n\n const top = div(ui, ui.panelEl, \"invTop\");\n if (ui.kind === \"creative\") {\n // корзина: положенный сюда стак исчезает (ПКМ — по одному)\n div(ui, top, \"trashLabel\").textContent = tr({\n en: \"Delete:\",\n ru: \"Удалить:\",\n });\n slot(ui, top, {\n group: \"trash\",\n get: () => null,\n set: () => {},\n }).classList.add(\"trash\");\n }\n if (ui.kind === \"inventory\") {\n const armor = div(ui, top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n slot(ui, armor, {\n group: \"armor\",\n get: () => inv.armor[i] ?? null,\n set: (s) => inv.setArmor(i, s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n // кукла персонажа (тянуть — поворачивать); студия персонажа — в лобби (ui/Lobby)\n const doll = div(ui, top, \"dollBox\");\n doll.appendChild(ui.game.character.dollCanvas());\n }\n if (ui.kind === \"inventory\" || ui.kind === \"crafting\") {\n const n = ui.grid.size;\n const craft = div(ui, top, \"craftGrid\");\n craft.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n for (let i = 0; i < n * n; i++)\n slot(ui, craft, {\n group: \"craft\",\n get: () => ui.grid.slots[i] ?? null,\n set: (s) => (ui.grid.slots[i] = s),\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickResult(e.shiftKey);\n e.preventDefault();\n };\n touchHold(res, (held) => ui._clickResult(held));\n ui.resultEl = res;\n }\n if (ui.kind === \"armor_stand\" && ui.be) {\n // стойка: те же четыре слота брони, что у игрока, но у клетки (gfx/ArmorStand показывает их)\n const items = (ui.be.state as ChestState).items;\n const col = div(ui, top, \"armorCol\");\n ARMOR_SLOTS.forEach((slotName, i) =>\n slot(ui, col, {\n group: \"stand\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n accepts: (s) => ITEMS[s.item]?.armorSlot === slotName,\n }),\n );\n }\n if (ui.kind === \"ender\") {\n // сундук Предела: слоты не у клетки, а у игрока — один склад на все сундуки и измерения\n const grid = div(ui, top, \"chestGrid\");\n for (let i = 0; i < inv.ender.length; i++)\n slot(ui, grid, {\n group: \"ender\",\n get: () => inv.ender[i] ?? null,\n set: (s) => (inv.ender[i] = s),\n });\n }\n if (ui.kind === \"chest\" && ui.be) {\n const items = (ui.be.state as ChestState).items;\n const grid = div(ui, top, \"chestGrid\");\n if (items.length === 9) grid.style.gridTemplateColumns = \"repeat(3, 44px)\"; // раздатчик 3×3\n for (let i = 0; i < items.length; i++)\n slot(ui, grid, {\n group: \"chest\",\n get: () => items[i] ?? null,\n set: (s) => (items[i] = s),\n // панцирный сундук в панцирный не кладётся, как в Minecraft\n accepts: (s) => !(ui.be?.type === \"shell\" && s.item === \"shell_chest\"),\n });\n }\n if (ui.kind === \"furnace\" && ui.be) {\n const st = ui.be.state as FurnaceState;\n const col = div(ui, top, \"furnaceCol\");\n slot(ui, col, {\n group: \"fIn\",\n get: () => st.input,\n set: (s) => (st.input = s),\n });\n ui.flameEl = div(ui, div(ui, col, \"flame\"), \"flameFill\") as HTMLDivElement;\n slot(ui, col, {\n group: \"fFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => (ITEMS[s.item]?.fuel ?? 0) > 0,\n });\n ui.arrowEl = div(\n ui,\n div(ui, top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n slot(ui, top, {\n group: \"fOut\",\n get: () => st.output,\n set: (s) => (st.output = s),\n takeOnly: true,\n }).classList.add(\"result\");\n }\n\n ui.offersEl = null;\n ui.costEl = null;\n if (ui.kind === \"anvil\") {\n slot(ui, top, {\n group: \"anLeft\",\n get: () => ui.anvil[0] ?? null,\n set: (s) => (ui.anvil[0] = s),\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"anRight\",\n get: () => ui.anvil[1] ?? null,\n set: (s) => (ui.anvil[1] = s),\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickAnvil(e.shiftKey);\n e.preventDefault();\n };\n touchHold(res, (held) => ui._clickAnvil(held));\n ui.resultEl = res;\n ui.costEl = div(ui, top, \"anvilCost\");\n }\n if (ui.kind === \"grindstone\") {\n // точило: два слота и результат без зачарований (опыт возвращается игроку)\n slot(ui, top, {\n group: \"grLeft\",\n get: () => ui.grind[0] ?? null,\n set: (s) => (ui.grind[0] = s),\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"grRight\",\n get: () => ui.grind[1] ?? null,\n set: (s) => (ui.grind[1] = s),\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickGrind(e.shiftKey);\n e.preventDefault();\n };\n touchHold(res, (held) => ui._clickGrind(held));\n ui.resultEl = res;\n ui.costEl = div(ui, top, \"anvilCost\");\n }\n if (ui.kind === \"enchanting\") {\n const col = div(ui, top, \"enchCol\");\n slot(ui, col, {\n group: \"enItem\",\n get: () => ui.enchSlots[0] ?? null,\n set: (s) => (ui.enchSlots[0] = s),\n accepts: (s) => canEnchant(s.item),\n });\n slot(ui, col, {\n group: \"enLapis\",\n get: () => ui.enchSlots[1] ?? null,\n set: (s) => (ui.enchSlots[1] = s),\n accepts: (s) => s.item === \"lapis_lazuli\",\n });\n ui.offersEl = div(ui, top, \"enchOffers\");\n }\n if (ui.kind === \"loom\") {\n // ткацкий станок: знамя, краситель и список узоров — щелчок кладёт слой\n slot(ui, top, {\n group: \"loBanner\",\n get: () => ui.loom[0] ?? null,\n set: (s) => (ui.loom[0] = s),\n accepts: (s) => bannerColor(s.item) !== null,\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"loDye\",\n get: () => ui.loom[1] ?? null,\n set: (s) => (ui.loom[1] = s),\n accepts: (s) => dyeColor(s.item) !== null,\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n ui.offersEl = div(ui, top, \"enchOffers tradeOffers loomOffers\");\n }\n if (ui.kind === \"stonecutter\") {\n // камнерез: вход и список изделий — щелчок режет один блок\n slot(ui, top, {\n group: \"scIn\",\n get: () => ui.stoneIn[0] ?? null,\n set: (s) => (ui.stoneIn[0] = s),\n accepts: (s) => stonecutOptions(s.item).length > 0,\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n ui.offersEl = div(ui, top, \"enchOffers tradeOffers\");\n }\n if (ui.kind === \"trade\") ui.offersEl = div(ui, top, \"enchOffers tradeOffers\");\n if (ui.kind === \"beacon\")\n ui.offersEl = div(ui, top, \"enchOffers beaconPowers\");\n if (ui.kind === \"smithing\") {\n // шаблон отделки (необязателен: без него — улучшение до пеклита)\n slot(ui, top, {\n group: \"smTpl\",\n get: () => ui.smith[2] ?? null,\n set: (s) => (ui.smith[2] = s),\n accepts: (s) => s.item.endsWith(\"_smithing_template\"),\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"smBase\",\n get: () => ui.smith[0] ?? null,\n set: (s) => (ui.smith[0] = s),\n accepts: (s) =>\n s.item.startsWith(\"diamond_\") || !!ITEMS[s.item]?.armorSlot,\n });\n div(ui, top, \"arrow\").textContent = \"+\";\n slot(ui, top, {\n group: \"smAdd\",\n get: () => ui.smith[1] ?? null,\n set: (s) => (ui.smith[1] = s),\n accepts: (s) => s.item === \"infernite_ingot\" || s.item in TRIM_MATERIALS,\n });\n div(ui, top, \"arrow\").textContent = \"→\";\n const res = div(ui, top, \"slot result\") as HTMLDivElement;\n res.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n res.onmousedown = (e) => {\n ui._clickSmith(e.shiftKey);\n e.preventDefault();\n };\n touchHold(res, (held) => ui._clickSmith(held));\n ui.resultEl = res;\n }\n if (ui.kind === \"brewing\" && ui.be) {\n const st = ui.be.state as BrewingState;\n const col = div(ui, top, \"brewCol\");\n slot(ui, col, {\n group: \"bIn\",\n get: () => st.ingredient,\n set: (s) => (st.ingredient = s),\n });\n slot(ui, col, {\n group: \"bFuel\",\n get: () => st.fuel,\n set: (s) => (st.fuel = s),\n accepts: (s) => s.item === \"flame_powder\",\n });\n ui.arrowEl = div(\n ui,\n div(ui, top, \"cookArrow\"),\n \"cookFill\",\n ) as HTMLDivElement;\n const row = div(ui, top, \"bottleRow\");\n for (let i = 0; i < 3; i++)\n slot(ui, row, {\n group: \"bottle\",\n get: () => st.bottles[i] ?? null,\n set: (s) => (st.bottles[i] = s),\n accepts: (s) =>\n s.item.startsWith(\"potion_\") || s.item === \"glass_bottle\",\n });\n }\n\n const main = div(ui, ui.panelEl, \"invGrid\");\n for (let i = 9; i < 36; i++)\n slot(ui, main, {\n group: \"main\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n const hot = div(ui, ui.panelEl, \"invHotbar\");\n for (let i = 0; i < 9; i++)\n slot(ui, hot, {\n group: \"hotbar\",\n get: () => inv.slots[i] ?? null,\n set: (s) => inv.setSlot(i, s),\n });\n}\n\nexport function div(\n ui: InventoryUI,\n parent: HTMLElement,\n cls: string,\n): HTMLElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n parent.appendChild(d);\n return d;\n}\n\nexport function slot(\n ui: InventoryUI,\n parent: HTMLElement,\n ref: SlotRef,\n): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n d.onmousedown = (e) => {\n e.preventDefault();\n // двойной клик со стаком в руке — собрать такие же в курсор\n if (e.button === 0 && e.detail === 2 && !e.shiftKey && ui.cursor)\n return gatherToCursor(ui);\n ui._clickSlot(ref, e.button === 2, e.shiftKey);\n };\n d.oncontextmenu = (e) => e.preventDefault();\n touchSlot(ui, d, ref);\n parent.appendChild(d);\n ref.el = d;\n ui.refs.push(ref);\n return d;\n}\n\n/** Второе касание того же слота быстрее этого = двойное (быстро переложить, как Shift), мс. */\nconst DOUBLE_MS = 350;\nlet lastTap: { ref: SlotRef; at: number } | null = null;\n\n/**\n * Палец на телефоне и планшете (05.11.2026): правой кнопки нет, поэтому касание = левый клик,\n * удержание = правый (взять половину, положить один), двойное касание = Shift-клик (переложить в\n * сундук / из хотбара в рюкзак). Мышь идёт старым путём (onmousedown выше).\n * ⚠ preventDefault на pointerdown гасит совместимые mousedown, иначе касание кликало бы дважды.\n */\nfunction touchSlot(ui: InventoryUI, d: HTMLDivElement, ref: SlotRef): void {\n let timer = 0;\n let id: number | null = null;\n let x0 = 0,\n y0 = 0;\n const stop = () => {\n clearTimeout(timer);\n timer = 0;\n id = null;\n };\n d.addEventListener(\"pointerdown\", (e) => {\n if (e.pointerType === \"mouse\") return;\n e.preventDefault();\n id = e.pointerId;\n x0 = e.clientX;\n y0 = e.clientY;\n timer = window.setTimeout(() => {\n timer = 0;\n id = null;\n lastTap = null;\n ui._clickSlot(ref, true, false);\n navigator.vibrate?.(15);\n }, HOLD_MS);\n });\n d.addEventListener(\"pointermove\", (e) => {\n if (e.pointerId !== id) return;\n if (Math.hypot(e.clientX - x0, e.clientY - y0) > MOVE_PX) stop();\n });\n d.addEventListener(\"pointerup\", (e) => {\n if (e.pointerId !== id || !timer) return stop();\n stop();\n const now = performance.now();\n const twice = lastTap?.ref === ref && now - lastTap.at < DOUBLE_MS;\n lastTap = twice ? null : { ref, at: now };\n if (twice) {\n // первое касание взяло стак в руку: вернуть его и переложить, как Shift-клик\n if (ui.cursor && !ref.get()) ui._clickSlot(ref, false, false);\n ui._clickSlot(ref, false, true);\n return;\n }\n ui._clickSlot(ref, false, false);\n });\n d.addEventListener(\"pointercancel\", stop);\n}\n"
|
|
1743
1743
|
},
|
|
1744
1744
|
{
|
|
1745
1745
|
"path": "ui/inventory/clicks.ts",
|
|
@@ -1767,7 +1767,7 @@
|
|
|
1767
1767
|
},
|
|
1768
1768
|
{
|
|
1769
1769
|
"path": "ui/InventoryUI.ts",
|
|
1770
|
-
"content": "// Экраны инвентаря, как в Minecraft: инвентарь (броня + крафт 2×2), верстак (3×3), сундук, печь.\n// У всех внизу рюкзак и хотбар игрока. Мышь: ЛКМ — взять/положить стак, ПКМ — половину/по одному,\n// Shift+ЛКМ — перенести в «другую половину» экрана (броню — на тело, руду — в печь).\n// UI мутирует ТОЛЬКО модели (Inventory, CraftingGrid, состояние блочной сущности) и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport type { Mob } from \"../entities/Mob\";\nimport type { Minecart } from \"../entities/Minecart\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\nimport { build, div, slot } from \"./inventory/build\";\nimport {\n clickSlot,\n clickPalette,\n tookOutput,\n targets,\n quickMove,\n clickResult,\n} from \"./inventory/clicks\";\nimport {\n shelves,\n stonecut,\n renderOffers,\n enchant,\n clickAnvil,\n clickGrind,\n loom,\n clickSmith,\n} from \"./inventory/stations\";\nimport { render } from \"./inventory/render\";\nimport type { ScreenKind, Group, SlotRef } from \"./inventory/types\";\n\nexport type { ScreenKind } from \"./inventory/types\";\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n kind: ScreenKind = \"inventory\";\n /** Кузнечный стол: основа (алмазная вещь) и добавка (пеклитовый слиток). */\n /** Кузнечный стол: вещь, добавка (пеклит или материал отделки), шаблон отделки. */\n smith: (ItemStack | null)[] = [null, null, null];\n /** Стол зачарований: предмет и лазурит; где стоит стол (полки вокруг); список вариантов. */\n enchSlots: (ItemStack | null)[] = [null, null];\n enchPos: { x: number; y: number; z: number } | null = null;\n offersEl: HTMLElement | null = null;\n /** Наковальня: левый и правый слот; строка цены. */\n anvil: (ItemStack | null)[] = [null, null];\n /** Вход камнереза (экран \"stonecutter\"). */\n stoneIn: (ItemStack | null)[] = [null];\n /** Точило: два слота (экран \"grindstone\"). */\n grind: (ItemStack | null)[] = [null, null];\n /** Ткацкий станок: знамя и краситель (экран \"loom\"). */\n loom: (ItemStack | null)[] = [null, null];\n costEl: HTMLElement | null = null;\n /** Житель, с которым торгуем (экран \"trade\"). */\n tradeMob: Mob | null = null;\n /** Вагонетка, чей груз открыт (экран \"chest\" поверх её слотов). */\n cart: Minecart | Mob | null = null;\n /** Блочная сущность открытого сундука/печи. */\n be: BlockEntity | null = null;\n /** Сетевая игра: контейнер, который этот экран держит занятым (net/NetGame.openContainer). */\n private lockKey: string | null = null;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n panelEl: HTMLElement;\n refs: SlotRef[] = [];\n resultEl: HTMLDivElement | null = null;\n flameEl: HTMLDivElement | null = null;\n arrowEl: HTMLDivElement | null = null;\n\n constructor(game: Game, signal: AbortSignal) {\n this.game = game;\n this.inventory = game.inventory;\n this.grid = new CraftingGrid(2);\n this.cursor = null; // стак «в руке» у курсора\n this.open = false;\n\n // Элементы гарантированно есть в разметке — non-null утверждается локально.\n this.rootEl = document.getElementById(\"invScreen\")!;\n this.cursorEl = document.getElementById(\"cursorStack\")!;\n this.panelEl = this.rootEl.querySelector(\".invPanel\")!;\n\n events.on(\"ui:toggleInventory\", () => {\n if (this.open) this.close();\n // E, наведённая на верстак, печь, сундук… — открывает его (как ПКМ); иначе — инвентарь\n // (только пока игра ведёт ввод: без него действие ПКМ из очереди не выполнится)\n else if (\n game.playing &&\n game.input.active &&\n game.interaction.openTargetScreen()\n )\n return;\n // в креативе E открывает палитру всех предметов, как в Minecraft\n else\n this.show(game.player.mode === \"creative\" ? \"creative\" : \"inventory\");\n });\n events.on(\"ui:openCrafting\", () => this.show(\"crafting\"));\n events.on(\"ui:openSmithing\", () => this.show(\"smithing\"));\n events.on(\"ui:openAnvil\", () => this.show(\"anvil\"));\n events.on(\"ui:openStonecutter\", () => this.show(\"stonecutter\"));\n events.on(\"ui:openGrindstone\", () => this.show(\"grindstone\"));\n events.on(\"ui:openLoom\", () => this.show(\"loom\"));\n events.on(\"ui:openEnder\", () => this.show(\"ender\"));\n events.on(\n \"ui:openArmorStand\",\n ({ x, y, z }: { x: number; y: number; z: number }) =>\n this._gated(`b:${x},${y},${z}`, () => {\n const be = game.blockEntities.ensure(x, y, z);\n if (be?.type === \"armor_stand\") this.show(\"armor_stand\", be);\n }),\n );\n events.on(\"ui:openEnchanting\", (p: { x: number; y: number; z: number }) => {\n this.enchPos = p;\n this.show(\"enchanting\");\n });\n events.on(\"ui:openTrade\", ({ entityId }: { entityId: number }) =>\n this._gated(`e:${entityId}`, () => {\n const m = game.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || !m.def.trades) return;\n this.tradeMob = m;\n this.show(\"trade\");\n if (this.open) m.trading = true; // пока торгуем — стоит и смотрит на игрока (MobBrain)\n }),\n );\n events.on(\"ui:openCart\", ({ entityId }: { entityId: number }) =>\n this._gated(`e:${entityId}`, () => {\n const c = game.entities.entities.get(entityId);\n // груз вагонетки или вьюк осла, мула, ламы\n if (\n (c?.kind !== \"minecart\" && c?.kind !== \"mob\") ||\n !c.items.length ||\n this.open\n )\n return;\n this.cart = c;\n this.show(\"chest\", game.entities.cartBox(c));\n if (!this.open) this.cart = null;\n }),\n );\n events.on(\n \"ui:openContainer\",\n ({ x, y, z }: { x: number; y: number; z: number }) =>\n this._gated(`b:${x},${y},${z}`, () => this._openContainer(x, y, z)),\n );\n document.addEventListener(\n \"mousemove\",\n (e) => {\n if (!this.open) return;\n this.cursorEl.style.left = e.clientX + \"px\";\n this.cursorEl.style.top = e.clientY + \"px\";\n },\n { signal },\n );\n }\n\n /** Открыть экран. Уже открыт — ничего (закрывается E или Esc). */\n show(kind: ScreenKind, be: BlockEntity | null = null) {\n if (this.open) return;\n // На стартовом оверлее и паузе игра стоит: инвентарь открылся бы ПОД оверлеем, а его закрытие\n // (requestLock → takeControl) запускало бы игру в обход оверлея.\n if (!this.game.playing) return;\n // Поверх другого экрана (смерть, титры, табличка, чат) не открываемся: закрытие инвентаря\n // захватывало мышь, и кнопку «Возродиться» на экране смерти становилось нечем нажать.\n if (this.game.player.dead || this.game.input.uiOpen) return;\n this.open = true;\n this.kind = kind;\n this.be = be;\n // открыл хранилище (сундук, печь, верстак…) — для книги заданий\n events.emit(\"containerOpen\", { kind });\n this.grid.setSize(kind === \"crafting\" ? 3 : 2);\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.rootEl.classList.remove(\"hidden\");\n this._build();\n this._render();\n }\n\n /** Совместимость со старым вызовом: toggle(2) — инвентарь, toggle(3, true) — верстак. */\n toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n this.show(craftSize === 3 ? \"crafting\" : \"inventory\");\n }\n\n /** Открыть блочный контейнер (сундук, печь, варочную стойку, воронку, маяк…). */\n private _openContainer(x: number, y: number, z: number): void {\n // ensure: сундук постройки генератора получает сущность (и добычу) при первом открытии\n const be = this.game.blockEntities.ensure(x, y, z);\n if (\n be &&\n (be.type === \"chest\" || be.type === \"furnace\" || be.type === \"brewing\")\n )\n this.show(be.type, be);\n // воронка, раздатчик, панцирный сундук — тот же экран, что сундук (слоты items), со своим заголовком\n else if (\n be &&\n (be.type === \"hopper\" || be.type === \"dispenser\" || be.type === \"shell\")\n )\n this.show(\"chest\", be);\n else if (be?.type === \"beacon\") this.show(\"beacon\", be);\n }\n\n /**\n * Сетевая игра: контейнер открывается, только если он не открыт у другого игрока (иначе двое,\n * перекладывая одно и то же, размножали бы предметы). Одиночная игра — сразу.\n */\n private _gated(key: string, open: () => void): void {\n const net = this.game.net;\n if (!net) return open();\n if (this.open) return;\n net.openContainer(key, () => {\n if (this.open) return net.releaseContainer(key);\n open();\n if (this.open) this.lockKey = key;\n else net.releaseContainer(key);\n });\n }\n\n /** `relock` false — сразу открывается другое окно (справочник): мышь не захватывать. */\n close(relock = true) {\n if (!this.open) return;\n this.open = false;\n this.game.character.stopDoll();\n if (this.lockKey) {\n this.game.net?.releaseContainer(this.lockKey);\n this.lockKey = null;\n }\n // вернуть всё из крафт-сетки и курсора; некуда класть — выбрасываем под ноги, ничего не теряем\n this.grid.dumpInto(this.inventory);\n // Остаток сетки выбрасываем обязательно: следующий show() пересоздаёт её (setSize), и то,\n // что «осталось в сетке», пропадало бы бесследно.\n this.grid.slots.forEach((s, i) => {\n if (!s) return;\n this._dropAtFeet(s);\n this.grid.slots[i] = null;\n });\n // кузнечный стол: всё обратно в инвентарь (с износом), не влезло — под ноги\n for (const slots of [\n this.smith,\n this.enchSlots,\n this.anvil,\n this.stoneIn,\n this.grind,\n ])\n for (let i = 0; i < slots.length; i++) {\n const s = slots[i];\n if (s && this.inventory.addStack(s) > 0) this._dropAtFeet(s);\n slots[i] = null;\n }\n if (this.cursor) {\n // addStack — инструмент в курсоре не теряет износ (раньше add() его обнулял)\n const left = this.inventory.addStack(this.cursor);\n if (left > 0) this._dropAtFeet(this.cursor, left);\n this.cursor = null;\n }\n this.be = null;\n if (this.tradeMob) this.tradeMob.trading = false;\n this.tradeMob = null;\n this.cart = null;\n this.rootEl.classList.add(\"hidden\");\n this.cursorEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n // закрыт смертью — мышь не захватываем, экран смерти должен остаться кликабельным\n if (relock && !this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Кадр: у печи бегут шкалы; сломанный сундук/печь закрывают свой экран. */\n frame() {\n if (this.open && this.kind === \"trade\") {\n // житель умер или ушёл — торговля закрывается\n const m = this.tradeMob;\n if (!m || m.dead || m.pos.distanceTo(this.game.player.pos) > 8)\n this.close();\n return;\n }\n if (this.open && this.cart) {\n // вагонетку сломали или она уехала — экран закрывается\n const c = this.cart;\n if (c.dead || c.pos.distanceTo(this.game.player.pos) > 8) this.close();\n return;\n }\n if (!this.open || !this.be) return;\n const be = this.be;\n if (this.game.blockEntities.get(be.x, be.y, be.z) !== be)\n return this.close();\n if (this.kind === \"furnace\" || this.kind === \"brewing\") this._render();\n }\n\n /** Бросить стак под ноги — целиком, с износом и tag (зачарования, имя): иначе вещь «чинилась» и\n * теряла чары, если в инвентаре не нашлось места. */\n _dropAtFeet(s: ItemStack, count = s.count) {\n if (count <= 0) return;\n const p = this.game.player.pos;\n this.game.entities.spawnDrop(\n p.x,\n p.y + 1,\n p.z,\n s.item,\n count,\n s.dmg,\n s.tag,\n );\n }\n\n // ---- построение ------------------------------------------------------------------------\n\n /** Имя блока под открытой блочной сущностью (для заголовка). */\n _beBlockName(): string {\n const be = this.be;\n if (!be) return \"\";\n return BLOCKS[this.game.world.getBlock(be.x, be.y, be.z)]?.name ?? be.type;\n }\n\n _build() {\n return build(this);\n }\n\n _div(parent: HTMLElement, cls: string): HTMLElement {\n return div(this, parent, cls);\n }\n\n _slot(parent: HTMLElement, ref: SlotRef): HTMLDivElement {\n return slot(this, parent, ref);\n }\n\n _clickSlot(ref: SlotRef, rightClick: boolean, shift: boolean) {\n return clickSlot(this, ref, rightClick, shift);\n }\n\n /**\n * Креатив: клик по палитре — полный стак в курсор (ПКМ — один); с чем-то в курсоре — удалить\n * это, как в Minecraft; Shift — стак сразу в инвентарь.\n */\n _clickPalette(item: string, rightClick: boolean, shift: boolean) {\n return clickPalette(this, item, rightClick, shift);\n }\n\n /** Книжные полки вокруг открытого стола зачарований. */\n _shelves(): number {\n return shelves(this);\n }\n\n /** Варианты стола: текст, доступность (уровень, лазурит), клик — зачаровать. */\n /** Камнерез: блок из входа → изделие в курсор (не лезет — в инвентарь), с Shift — сразу в инвентарь. */\n _stonecut(out: ItemStack, shift: boolean) {\n return stonecut(this, out, shift);\n }\n\n _renderOffers() {\n return renderOffers(this);\n }\n\n /** Зачаровать вариантом i: списать уровни и лазурит (в креативе — нет), сменить зерно. */\n _enchant(i: number) {\n return enchant(this, i);\n }\n\n /** Результат наковальни: списать уровни (в креативе — нет), левый слот и нужное из правого. */\n _clickAnvil(shift: boolean) {\n return clickAnvil(this, shift);\n }\n\n /** Результат точила: вещь без зачарований, снятый опыт — игроку (как в Minecraft). */\n _clickGrind(shift: boolean) {\n return clickGrind(this, shift);\n }\n\n /** Узор на знамя: тратит один краситель, знамя из слота заменяется на новое со слоем. */\n _loom(pattern: string, shift: boolean) {\n return loom(this, pattern, shift);\n }\n\n /** Результат кузнечного стола: в курсор (рука свободна) или Shift — сразу в инвентарь. */\n _clickSmith(shift: boolean) {\n return clickSmith(this, shift);\n }\n\n /** Забрали выход печи: накопленный опыт — игроку, событие `smelt`. */\n _tookOutput(ref: SlotRef, item: string, count: number) {\n return tookOutput(this, ref, item, count);\n }\n\n /** Куда Shift-клик переносит стак из этой группы, по порядку предпочтения. */\n _targets(from: Group, s: ItemStack): SlotRef[] {\n return targets(this, from, s);\n }\n\n _quickMove(ref: SlotRef) {\n return quickMove(this, ref);\n }\n\n /** Результат крафта: клик — в курсор; Shift — скрафтить сколько получится прямо в инвентарь. */\n _clickResult(shift: boolean) {\n return clickResult(this, shift);\n }\n\n _render() {\n return render(this);\n }\n}\n"
|
|
1770
|
+
"content": "// Экраны инвентаря, как в Minecraft: инвентарь (броня + крафт 2×2), верстак (3×3), сундук, печь.\n// У всех внизу рюкзак и хотбар игрока. Мышь: ЛКМ — взять/положить стак, ПКМ — половину/по одному,\n// Shift+ЛКМ — перенести в «другую половину» экрана (броню — на тело, руду — в печь).\n// UI мутирует ТОЛЬКО модели (Inventory, CraftingGrid, состояние блочной сущности) и живёт поверх них.\nimport { events } from \"../core/EventBus\";\nimport { CraftingGrid } from \"../systems/Crafting\";\nimport { BLOCKS } from \"../registry/Blocks\";\nimport type { BlockEntity } from \"../world/BlockEntities\";\nimport type { Mob } from \"../entities/Mob\";\nimport type { Minecart } from \"../entities/Minecart\";\nimport type { Game } from \"../main\";\nimport type { ItemStack } from \"../types\";\nimport { build, div, slot } from \"./inventory/build\";\nimport {\n clickSlot,\n clickPalette,\n tookOutput,\n targets,\n quickMove,\n clickResult,\n} from \"./inventory/clicks\";\nimport {\n shelves,\n stonecut,\n renderOffers,\n enchant,\n clickAnvil,\n clickGrind,\n loom,\n clickSmith,\n} from \"./inventory/stations\";\nimport { render } from \"./inventory/render\";\nimport type { ScreenKind, Group, SlotRef } from \"./inventory/types\";\n\nexport type { ScreenKind } from \"./inventory/types\";\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n kind: ScreenKind = \"inventory\";\n /** Кузнечный стол: основа (алмазная вещь) и добавка (пеклитовый слиток). */\n /** Кузнечный стол: вещь, добавка (пеклит или материал отделки), шаблон отделки. */\n smith: (ItemStack | null)[] = [null, null, null];\n /** Стол зачарований: предмет и лазурит; где стоит стол (полки вокруг); список вариантов. */\n enchSlots: (ItemStack | null)[] = [null, null];\n enchPos: { x: number; y: number; z: number } | null = null;\n offersEl: HTMLElement | null = null;\n /** Наковальня: левый и правый слот; строка цены. */\n anvil: (ItemStack | null)[] = [null, null];\n /** Вход камнереза (экран \"stonecutter\"). */\n stoneIn: (ItemStack | null)[] = [null];\n /** Точило: два слота (экран \"grindstone\"). */\n grind: (ItemStack | null)[] = [null, null];\n /** Ткацкий станок: знамя и краситель (экран \"loom\"). */\n loom: (ItemStack | null)[] = [null, null];\n costEl: HTMLElement | null = null;\n /** Житель, с которым торгуем (экран \"trade\"). */\n tradeMob: Mob | null = null;\n /** Вагонетка, чей груз открыт (экран \"chest\" поверх её слотов). */\n cart: Minecart | Mob | null = null;\n /** Блочная сущность открытого сундука/печи. */\n be: BlockEntity | null = null;\n /** Сетевая игра: контейнер, который этот экран держит занятым (net/NetGame.openContainer). */\n private lockKey: string | null = null;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n panelEl: HTMLElement;\n refs: SlotRef[] = [];\n resultEl: HTMLDivElement | null = null;\n flameEl: HTMLDivElement | null = null;\n arrowEl: HTMLDivElement | null = null;\n\n constructor(game: Game, signal: AbortSignal) {\n this.game = game;\n this.inventory = game.inventory;\n this.grid = new CraftingGrid(2);\n this.cursor = null; // стак «в руке» у курсора\n this.open = false;\n\n // Элементы гарантированно есть в разметке — non-null утверждается локально.\n this.rootEl = document.getElementById(\"invScreen\")!;\n this.cursorEl = document.getElementById(\"cursorStack\")!;\n this.panelEl = this.rootEl.querySelector(\".invPanel\")!;\n // клик или касание по тёмному фону мимо окна закрывает его (владелец, 05.11.2026), и мышью, и\n // пальцем: раньше окно закрывалось только E и Esc, и на телефоне из инвентаря было не выйти.\n // preventDefault гасит совместимый mousedown, чтобы он не ушёл в игру ударом.\n this.rootEl.addEventListener(\n \"pointerdown\",\n (e) => {\n if (e.target !== this.rootEl) return;\n e.preventDefault();\n this.close();\n },\n { signal },\n );\n\n events.on(\"ui:toggleInventory\", () => {\n if (this.open) this.close();\n // E, наведённая на верстак, печь, сундук… — открывает его (как ПКМ); иначе — инвентарь\n // (только пока игра ведёт ввод: без него действие ПКМ из очереди не выполнится)\n else if (\n game.playing &&\n game.input.active &&\n game.interaction.openTargetScreen()\n )\n return;\n // в креативе E открывает палитру всех предметов, как в Minecraft\n else\n this.show(game.player.mode === \"creative\" ? \"creative\" : \"inventory\");\n });\n events.on(\"ui:openCrafting\", () => this.show(\"crafting\"));\n events.on(\"ui:openSmithing\", () => this.show(\"smithing\"));\n events.on(\"ui:openAnvil\", () => this.show(\"anvil\"));\n events.on(\"ui:openStonecutter\", () => this.show(\"stonecutter\"));\n events.on(\"ui:openGrindstone\", () => this.show(\"grindstone\"));\n events.on(\"ui:openLoom\", () => this.show(\"loom\"));\n events.on(\"ui:openEnder\", () => this.show(\"ender\"));\n events.on(\n \"ui:openArmorStand\",\n ({ x, y, z }: { x: number; y: number; z: number }) =>\n this._gated(`b:${x},${y},${z}`, () => {\n const be = game.blockEntities.ensure(x, y, z);\n if (be?.type === \"armor_stand\") this.show(\"armor_stand\", be);\n }),\n );\n events.on(\"ui:openEnchanting\", (p: { x: number; y: number; z: number }) => {\n this.enchPos = p;\n this.show(\"enchanting\");\n });\n events.on(\"ui:openTrade\", ({ entityId }: { entityId: number }) =>\n this._gated(`e:${entityId}`, () => {\n const m = game.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || !m.def.trades) return;\n this.tradeMob = m;\n this.show(\"trade\");\n if (this.open) m.trading = true; // пока торгуем — стоит и смотрит на игрока (MobBrain)\n }),\n );\n events.on(\"ui:openCart\", ({ entityId }: { entityId: number }) =>\n this._gated(`e:${entityId}`, () => {\n const c = game.entities.entities.get(entityId);\n // груз вагонетки или вьюк осла, мула, ламы\n if (\n (c?.kind !== \"minecart\" && c?.kind !== \"mob\") ||\n !c.items.length ||\n this.open\n )\n return;\n this.cart = c;\n this.show(\"chest\", game.entities.cartBox(c));\n if (!this.open) this.cart = null;\n }),\n );\n events.on(\n \"ui:openContainer\",\n ({ x, y, z }: { x: number; y: number; z: number }) =>\n this._gated(`b:${x},${y},${z}`, () => this._openContainer(x, y, z)),\n );\n // стак «в руке» идёт за мышью, а на телефоне встаёт туда, где палец (касаний без движения\n // mousemove не бывает)\n const follow = (e: PointerEvent) => {\n if (!this.open) return;\n this.cursorEl.style.left = e.clientX + \"px\";\n this.cursorEl.style.top = e.clientY + \"px\";\n };\n document.addEventListener(\"pointermove\", follow, { signal });\n document.addEventListener(\"pointerdown\", follow, { signal });\n }\n\n /** Открыть экран. Уже открыт — ничего (закрывается E или Esc). */\n show(kind: ScreenKind, be: BlockEntity | null = null) {\n if (this.open) return;\n // На стартовом оверлее и паузе игра стоит: инвентарь открылся бы ПОД оверлеем, а его закрытие\n // (requestLock → takeControl) запускало бы игру в обход оверлея.\n if (!this.game.playing) return;\n // Поверх другого экрана (смерть, титры, табличка, чат) не открываемся: закрытие инвентаря\n // захватывало мышь, и кнопку «Возродиться» на экране смерти становилось нечем нажать.\n if (this.game.player.dead || this.game.input.uiOpen) return;\n this.open = true;\n this.kind = kind;\n this.be = be;\n // открыл хранилище (сундук, печь, верстак…) — для книги заданий\n events.emit(\"containerOpen\", { kind });\n this.grid.setSize(kind === \"crafting\" ? 3 : 2);\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.rootEl.classList.remove(\"hidden\");\n this._build();\n this._render();\n }\n\n /** Совместимость со старым вызовом: toggle(2) — инвентарь, toggle(3, true) — верстак. */\n toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n this.show(craftSize === 3 ? \"crafting\" : \"inventory\");\n }\n\n /** Открыть блочный контейнер (сундук, печь, варочную стойку, воронку, маяк…). */\n private _openContainer(x: number, y: number, z: number): void {\n // ensure: сундук постройки генератора получает сущность (и добычу) при первом открытии\n const be = this.game.blockEntities.ensure(x, y, z);\n if (\n be &&\n (be.type === \"chest\" || be.type === \"furnace\" || be.type === \"brewing\")\n )\n this.show(be.type, be);\n // воронка, раздатчик, панцирный сундук — тот же экран, что сундук (слоты items), со своим заголовком\n else if (\n be &&\n (be.type === \"hopper\" || be.type === \"dispenser\" || be.type === \"shell\")\n )\n this.show(\"chest\", be);\n else if (be?.type === \"beacon\") this.show(\"beacon\", be);\n }\n\n /**\n * Сетевая игра: контейнер открывается, только если он не открыт у другого игрока (иначе двое,\n * перекладывая одно и то же, размножали бы предметы). Одиночная игра — сразу.\n */\n private _gated(key: string, open: () => void): void {\n const net = this.game.net;\n if (!net) return open();\n if (this.open) return;\n net.openContainer(key, () => {\n if (this.open) return net.releaseContainer(key);\n open();\n if (this.open) this.lockKey = key;\n else net.releaseContainer(key);\n });\n }\n\n /** `relock` false — сразу открывается другое окно (справочник): мышь не захватывать. */\n close(relock = true) {\n if (!this.open) return;\n this.open = false;\n this.game.character.stopDoll();\n if (this.lockKey) {\n this.game.net?.releaseContainer(this.lockKey);\n this.lockKey = null;\n }\n // вернуть всё из крафт-сетки и курсора; некуда класть — выбрасываем под ноги, ничего не теряем\n this.grid.dumpInto(this.inventory);\n // Остаток сетки выбрасываем обязательно: следующий show() пересоздаёт её (setSize), и то,\n // что «осталось в сетке», пропадало бы бесследно.\n this.grid.slots.forEach((s, i) => {\n if (!s) return;\n this._dropAtFeet(s);\n this.grid.slots[i] = null;\n });\n // кузнечный стол: всё обратно в инвентарь (с износом), не влезло — под ноги\n for (const slots of [\n this.smith,\n this.enchSlots,\n this.anvil,\n this.stoneIn,\n this.grind,\n ])\n for (let i = 0; i < slots.length; i++) {\n const s = slots[i];\n if (s && this.inventory.addStack(s) > 0) this._dropAtFeet(s);\n slots[i] = null;\n }\n if (this.cursor) {\n // addStack — инструмент в курсоре не теряет износ (раньше add() его обнулял)\n const left = this.inventory.addStack(this.cursor);\n if (left > 0) this._dropAtFeet(this.cursor, left);\n this.cursor = null;\n }\n this.be = null;\n if (this.tradeMob) this.tradeMob.trading = false;\n this.tradeMob = null;\n this.cart = null;\n this.rootEl.classList.add(\"hidden\");\n this.cursorEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n // закрыт смертью — мышь не захватываем, экран смерти должен остаться кликабельным\n if (relock && !this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Кадр: у печи бегут шкалы; сломанный сундук/печь закрывают свой экран. */\n frame() {\n if (this.open && this.kind === \"trade\") {\n // житель умер или ушёл — торговля закрывается\n const m = this.tradeMob;\n if (!m || m.dead || m.pos.distanceTo(this.game.player.pos) > 8)\n this.close();\n return;\n }\n if (this.open && this.cart) {\n // вагонетку сломали или она уехала — экран закрывается\n const c = this.cart;\n if (c.dead || c.pos.distanceTo(this.game.player.pos) > 8) this.close();\n return;\n }\n if (!this.open || !this.be) return;\n const be = this.be;\n if (this.game.blockEntities.get(be.x, be.y, be.z) !== be)\n return this.close();\n if (this.kind === \"furnace\" || this.kind === \"brewing\") this._render();\n }\n\n /** Бросить стак под ноги — целиком, с износом и tag (зачарования, имя): иначе вещь «чинилась» и\n * теряла чары, если в инвентаре не нашлось места. */\n _dropAtFeet(s: ItemStack, count = s.count) {\n if (count <= 0) return;\n const p = this.game.player.pos;\n this.game.entities.spawnDrop(\n p.x,\n p.y + 1,\n p.z,\n s.item,\n count,\n s.dmg,\n s.tag,\n );\n }\n\n // ---- построение ------------------------------------------------------------------------\n\n /** Имя блока под открытой блочной сущностью (для заголовка). */\n _beBlockName(): string {\n const be = this.be;\n if (!be) return \"\";\n return BLOCKS[this.game.world.getBlock(be.x, be.y, be.z)]?.name ?? be.type;\n }\n\n _build() {\n return build(this);\n }\n\n _div(parent: HTMLElement, cls: string): HTMLElement {\n return div(this, parent, cls);\n }\n\n _slot(parent: HTMLElement, ref: SlotRef): HTMLDivElement {\n return slot(this, parent, ref);\n }\n\n _clickSlot(ref: SlotRef, rightClick: boolean, shift: boolean) {\n return clickSlot(this, ref, rightClick, shift);\n }\n\n /**\n * Креатив: клик по палитре — полный стак в курсор (ПКМ — один); с чем-то в курсоре — удалить\n * это, как в Minecraft; Shift — стак сразу в инвентарь.\n */\n _clickPalette(item: string, rightClick: boolean, shift: boolean) {\n return clickPalette(this, item, rightClick, shift);\n }\n\n /** Книжные полки вокруг открытого стола зачарований. */\n _shelves(): number {\n return shelves(this);\n }\n\n /** Варианты стола: текст, доступность (уровень, лазурит), клик — зачаровать. */\n /** Камнерез: блок из входа → изделие в курсор (не лезет — в инвентарь), с Shift — сразу в инвентарь. */\n _stonecut(out: ItemStack, shift: boolean) {\n return stonecut(this, out, shift);\n }\n\n _renderOffers() {\n return renderOffers(this);\n }\n\n /** Зачаровать вариантом i: списать уровни и лазурит (в креативе — нет), сменить зерно. */\n _enchant(i: number) {\n return enchant(this, i);\n }\n\n /** Результат наковальни: списать уровни (в креативе — нет), левый слот и нужное из правого. */\n _clickAnvil(shift: boolean) {\n return clickAnvil(this, shift);\n }\n\n /** Результат точила: вещь без зачарований, снятый опыт — игроку (как в Minecraft). */\n _clickGrind(shift: boolean) {\n return clickGrind(this, shift);\n }\n\n /** Узор на знамя: тратит один краситель, знамя из слота заменяется на новое со слоем. */\n _loom(pattern: string, shift: boolean) {\n return loom(this, pattern, shift);\n }\n\n /** Результат кузнечного стола: в курсор (рука свободна) или Shift — сразу в инвентарь. */\n _clickSmith(shift: boolean) {\n return clickSmith(this, shift);\n }\n\n /** Забрали выход печи: накопленный опыт — игроку, событие `smelt`. */\n _tookOutput(ref: SlotRef, item: string, count: number) {\n return tookOutput(this, ref, item, count);\n }\n\n /** Куда Shift-клик переносит стак из этой группы, по порядку предпочтения. */\n _targets(from: Group, s: ItemStack): SlotRef[] {\n return targets(this, from, s);\n }\n\n _quickMove(ref: SlotRef) {\n return quickMove(this, ref);\n }\n\n /** Результат крафта: клик — в курсор; Shift — скрафтить сколько получится прямо в инвентарь. */\n _clickResult(shift: boolean) {\n return clickResult(this, shift);\n }\n\n _render() {\n return render(this);\n }\n}\n"
|
|
1771
1771
|
},
|
|
1772
1772
|
{
|
|
1773
1773
|
"path": "ui/itemTooltip.ts",
|
|
@@ -1783,7 +1783,7 @@
|
|
|
1783
1783
|
},
|
|
1784
1784
|
{
|
|
1785
1785
|
"path": "ui/map/NavHud.ts",
|
|
1786
|
-
"content": "// Навигация на экране игры:\n// - компас сверху — стороны света и все отметки (задание, метки, место смерти, дом) по направлению,\n// под ним — к чему ведут и сколько идти;\n// - миникарта справа сверху — по направлению взгляда (вверх — куда смотришь), отметки за краем\n// прижаты к ободу; клик / касание — большая карта;\n// - метка в мире — знак над целью прямо в сцене с расстоянием; цель за кадром — стрелка у края\n// экрана, повёрнутая к ней.\n// Данные — ui/map/nav (атлас game.worldMap, цель задания из трекера).\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport type { Game } from \"../../main\";\nimport { itemIcon } from \"../quests/strings\";\nimport {\n bearing,\n distanceText,\n drawMark,\n drawPlayerArrow,\n drawTerrain,\n guidedMark,\n markLabel,\n navMarks,\n playerBearing,\n wrapAngle,\n type NavMark,\n} from \"./nav\";\nimport { mt } from \"./strings\";\n\n/** Пикселей на блок на миникарте. */\nconst MINI_SCALE = 1.5;\n/** Половина обзора компаса. */\nconst COMPASS_HALF = (100 * Math.PI) / 180;\n/** Отступ стрелки от края экрана. */\nconst EDGE = 56;\n\nexport class NavHud {\n private readonly mini: HTMLDivElement;\n private readonly miniCanvas: HTMLCanvasElement;\n private readonly coords: HTMLDivElement;\n private readonly compass: HTMLDivElement;\n private readonly compassCanvas: HTMLCanvasElement;\n private readonly info: HTMLDivElement;\n private readonly world: HTMLDivElement;\n private readonly markEls = new Map<string, HTMLDivElement>();\n private infoSig = \"-\";\n private readonly v = new THREE.Vector3();\n /** Миникарта и компас включены (настройка игрока). */\n on = readOn();\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n const host = document.getElementById(\"game\")!.parentElement!;\n this.compass = div(\"navCompass\");\n this.compassCanvas = document.createElement(\"canvas\");\n this.info = div();\n this.info.className = \"ncInfo\";\n this.compass.append(this.compassCanvas, this.info);\n\n this.mini = div(\"minimap\");\n this.miniCanvas = document.createElement(\"canvas\");\n this.coords = div();\n this.coords.className = \"mmCoords\";\n this.mini.append(this.miniCanvas, this.coords);\n this.mini.title = mt(\"mapKey\");\n this.mini.addEventListener(\n \"pointerdown\",\n (e) => {\n e.preventDefault();\n e.stopPropagation();\n events.emit(\"ui:map\");\n },\n { signal },\n );\n\n this.world = div(\"navWorld\");\n host.append(this.world, this.compass, this.mini);\n }\n\n setOn(on: boolean): void {\n this.on = on;\n try {\n localStorage.setItem(\"voxelcraft:minimap\", on ? \"1\" : \"0\");\n } catch {\n // приватное окно — до перезагрузки\n }\n }\n\n private visible(): boolean {\n const g = this.game;\n return (\n this.on &&\n g.playing &&\n !g.player.dead &&\n !g.mapUI?.open &&\n !g.questBookUI?.open\n );\n }\n\n frame(): void {\n const vis = this.visible();\n this.mini.classList.toggle(\"hidden\", !vis);\n this.compass.classList.toggle(\"hidden\", !vis);\n this.world.classList.toggle(\"hidden\", !vis || this.game.invUI.open);\n if (!vis) return;\n // дошёл до места смерти — дальше туда не ведём (компас возвращения по-прежнему указывает)\n const d = this.game.lastDeath;\n const p = this.game.player.pos;\n if (\n d?.guide &&\n !this.game.player.dead &&\n d.dim === this.game.dimension &&\n Math.hypot(d.x - p.x, d.z - p.z) < 4\n )\n d.guide = false;\n const marks = navMarks(this.game);\n this.drawMini(marks);\n this.drawCompass(marks);\n this.placeWorldMarks(marks);\n }\n\n // ---- миникарта --------------------------------------------------------------\n\n private drawMini(marks: NavMark[]): void {\n const g = this.game;\n const c = this.miniCanvas;\n const css = this.mini.clientWidth || 150;\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n const px = Math.round(css * dpr);\n if (c.width !== px) c.width = c.height = px;\n const ctx = c.getContext(\"2d\");\n if (!ctx) return;\n const p = g.player.pos;\n const yaw = g.player.yaw;\n const R = css / 2;\n const s = MINI_SCALE;\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, css, css);\n ctx.save();\n ctx.beginPath();\n ctx.arc(R, R, R - 2, 0, Math.PI * 2);\n ctx.clip();\n ctx.fillStyle = \"#1d1f24\";\n ctx.fillRect(0, 0, css, css);\n // местность: центр — игрок, поворот — по взгляду\n ctx.save();\n ctx.translate(R, R);\n ctx.rotate(yaw);\n ctx.scale(s, s);\n ctx.translate(-p.x, -p.z);\n const reach = (R / s) * 1.5;\n drawTerrain(ctx, g, {\n x0: p.x - reach,\n z0: p.z - reach,\n x1: p.x + reach,\n z1: p.z + reach,\n });\n ctx.restore();\n // отметки: за ободом — прижатые к нему (кроме других игроков)\n const cos = Math.cos(yaw),\n sin = Math.sin(yaw);\n const rim = R - 11;\n const sorted = [...marks].sort((a, b) => +a.guided - +b.guided);\n for (const m of sorted) {\n const dx = (m.x - p.x) * s,\n dz = (m.z - p.z) * s;\n let x = dx * cos - dz * sin,\n y = dx * sin + dz * cos;\n const d = Math.hypot(x, y);\n if (d > rim) {\n if (m.kind === \"player\") continue;\n x = (x / d) * rim;\n y = (y / d) * rim;\n }\n drawMark(ctx, m, R + x, R + y, m.guided ? 16 : 13);\n }\n drawPlayerArrow(ctx, R, R, 0, 7);\n ctx.restore();\n // обод и «С» на нём\n ctx.beginPath();\n ctx.arc(R, R, R - 2, 0, Math.PI * 2);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"rgba(240, 208, 96, 0.9)\";\n ctx.stroke();\n const nx = R + Math.sin(yaw) * (R - 2),\n ny = R - Math.cos(yaw) * (R - 2);\n ctx.beginPath();\n ctx.arc(nx, ny, 8, 0, Math.PI * 2);\n ctx.fillStyle = \"#b02020\";\n ctx.fill();\n ctx.fillStyle = \"#fff\";\n ctx.font = \"bold 10px sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(mt(\"N\"), nx, ny + 0.5);\n const text = `${Math.floor(p.x)}, ${Math.floor(p.y)}, ${Math.floor(p.z)}`;\n if (this.coords.textContent !== text) this.coords.textContent = text;\n }\n\n // ---- компас -----------------------------------------------------------------\n\n private drawCompass(marks: NavMark[]): void {\n const g = this.game;\n const c = this.compassCanvas;\n const W = this.compass.clientWidth || 420;\n const H = 34;\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n if (c.width !== Math.round(W * dpr) || c.height !== Math.round(H * dpr)) {\n c.width = Math.round(W * dpr);\n c.height = Math.round(H * dpr);\n }\n const ctx = c.getContext(\"2d\");\n if (!ctx) return;\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, W, H);\n const half = W / 2;\n const k = half / COMPASS_HALF;\n const head = playerBearing(g.player.yaw);\n // фон с затуханием к краям\n const grad = ctx.createLinearGradient(0, 0, W, 0);\n grad.addColorStop(0, \"rgba(0,0,0,0)\");\n grad.addColorStop(0.12, \"rgba(0,0,0,0.55)\");\n grad.addColorStop(0.88, \"rgba(0,0,0,0.55)\");\n grad.addColorStop(1, \"rgba(0,0,0,0)\");\n ctx.fillStyle = grad;\n ctx.fillRect(0, 0, W, H);\n // деления каждые 15°, буквы на 90°, промежуточные на 45°\n const letters: Record<number, string> = {\n 0: mt(\"N\"),\n 90: mt(\"E\"),\n 180: mt(\"S\"),\n 270: mt(\"W\"),\n 45: mt(\"N\") + mt(\"E\"),\n 135: mt(\"S\") + mt(\"E\"),\n 225: mt(\"S\") + mt(\"W\"),\n 315: mt(\"N\") + mt(\"W\"),\n };\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n for (let deg = 0; deg < 360; deg += 15) {\n const rel = wrapAngle((deg * Math.PI) / 180 - head);\n if (Math.abs(rel) > COMPASS_HALF) continue;\n const x = half + rel * k;\n const fade = 1 - Math.abs(rel) / COMPASS_HALF;\n const l = letters[deg];\n if (l) {\n const main = deg % 90 === 0;\n ctx.globalAlpha = 0.35 + 0.65 * fade;\n ctx.fillStyle = deg === 0 ? \"#ff6a5a\" : \"#fff\";\n ctx.font = `bold ${main ? 15 : 11}px sans-serif`;\n ctx.fillText(l, x, H / 2);\n } else {\n ctx.globalAlpha = 0.25 + 0.5 * fade;\n ctx.fillStyle = \"#fff\";\n ctx.fillRect(x - 0.5, H / 2 - 4, 1, 8);\n }\n }\n ctx.globalAlpha = 1;\n // отметки; ведомая — поверх, за краем прижата к нему\n const p = g.player.pos;\n const sorted = marks\n .filter((m) => m.compass)\n .sort((a, b) => +a.guided - +b.guided);\n for (const m of sorted) {\n let rel = wrapAngle(bearing(m.x - p.x, m.z - p.z) - head);\n if (Math.abs(rel) > COMPASS_HALF) {\n if (!m.guided) continue;\n rel = Math.sign(rel) * COMPASS_HALF * 0.97;\n }\n drawMark(ctx, m, half + rel * k, H / 2, m.guided ? 26 : 20);\n }\n // центр — куда смотришь\n ctx.fillStyle = \"#f0d060\";\n ctx.beginPath();\n ctx.moveTo(half - 6, 0);\n ctx.lineTo(half + 6, 0);\n ctx.lineTo(half, 7);\n ctx.closePath();\n ctx.fill();\n\n // подпись: к чему ведут и сколько идти\n const gm = guidedMark(marks);\n let sig = \"\";\n let text = \"\";\n if (gm) {\n const dist = Math.hypot(gm.x - p.x, gm.z - p.z);\n const dy = gm.y !== undefined ? gm.y - p.y : null;\n text = `${markLabel(gm)} · ${distanceText(dist, dy)}`;\n sig = gm.key + text;\n }\n if (sig !== this.infoSig) {\n this.infoSig = sig;\n this.info.innerHTML = \"\";\n this.info.classList.toggle(\"hidden\", !gm);\n if (gm) {\n const dot = document.createElement(\"span\");\n dot.className = \"ncDot\";\n dot.style.color = gm.color;\n dot.textContent = gm.glyph;\n this.info.append(dot);\n if (gm.icon) this.info.append(itemIcon(gm.icon));\n this.info.append(\" \" + text);\n }\n }\n }\n\n // ---- метки в мире -----------------------------------------------------------\n\n private placeWorldMarks(marks: NavMark[]): void {\n const g = this.game;\n const cam = g.camera;\n const W = window.innerWidth,\n H = window.innerHeight;\n const show = marks.filter(\n (m) => m.guided || m.kind === \"quest\" || m.kind === \"death\",\n );\n const keep = new Set<string>();\n const eye = g.player.eyePos;\n cam.updateMatrixWorld();\n for (const m of show) {\n keep.add(m.key);\n let el = this.markEls.get(m.key);\n if (!el) {\n el = div();\n el.className = \"navMark\";\n el.innerHTML = `<div class=\"nmArrow\"></div><div class=\"nmIcon\"></div><div class=\"nmText\"></div>`;\n this.world.append(el);\n this.markEls.set(m.key, el);\n }\n const icon = el.children[1] as HTMLElement;\n const label = el.children[2] as HTMLElement;\n const arrow = el.children[0] as HTMLElement;\n const iconKey = m.glyph + m.color + (m.icon ?? \"\");\n if (icon.dataset.k !== iconKey) {\n icon.dataset.k = iconKey;\n icon.innerHTML = \"\";\n icon.style.borderColor = m.color;\n icon.style.color = m.color;\n if (m.icon) icon.append(itemIcon(m.icon, \"nmItem\"));\n else icon.textContent = m.glyph;\n }\n el.classList.toggle(\"guided\", m.guided);\n arrow.style.color = m.color;\n // знак — над землёй метки; у цели задания — на самом блоке или мобе\n const ty =\n m.y === undefined ? eye.y : m.y + (m.kind === \"quest\" ? 0 : 1.2);\n const dist = Math.hypot(m.x - eye.x, m.z - eye.z);\n const dy = m.y !== undefined ? m.y - g.player.pos.y : null;\n const name = markLabel(m);\n const t = (m.guided && name ? name + \" · \" : \"\") + distanceText(dist, dy);\n if (label.textContent !== t) label.textContent = t;\n\n // в пространство камеры: впереди — z < 0\n this.v.set(m.x, ty, m.z).applyMatrix4(cam.matrixWorldInverse);\n const front = this.v.z < -0.2;\n let sx = 0,\n sy = 0,\n onScreen = false;\n if (front) {\n const cx = this.v.x,\n cy = this.v.y,\n cz = this.v.z;\n this.v.applyMatrix4(cam.projectionMatrix);\n sx = ((this.v.x + 1) / 2) * W;\n sy = ((1 - this.v.y) / 2) * H;\n onScreen =\n sx > EDGE && sx < W - EDGE && sy > EDGE + 40 && sy < H - EDGE - 60;\n this.v.set(cx, cy, cz);\n }\n if (onScreen) {\n arrow.style.display = \"none\";\n el.style.transform = `translate(${sx.toFixed(1)}px, ${sy.toFixed(1)}px)`;\n el.classList.remove(\"edge\");\n } else {\n // направление на экране к цели (y вниз); цель сзади — те же x/y камеры\n let dx = this.v.x,\n dy2 = -this.v.y;\n if (!front && Math.abs(dx) < 1e-3 && Math.abs(dy2) < 1e-3) dy2 = 1;\n // сзади точка «уходит» вниз — тянем её на горизонталь, чтобы стрелка звала повернуться\n if (!front)\n dy2 = Math.max(-0.4, Math.min(0.4, dy2 / (Math.abs(dx) + 1)));\n const len = Math.hypot(dx, dy2) || 1;\n dx /= len;\n dy2 /= len;\n const hw = W / 2 - EDGE - 50, // место под подпись по центру знака\n hh = H / 2 - EDGE - 50;\n const tt = Math.min(\n Math.abs(dx) > 1e-6 ? hw / Math.abs(dx) : Infinity,\n Math.abs(dy2) > 1e-6 ? hh / Math.abs(dy2) : Infinity,\n );\n sx = W / 2 + dx * tt;\n sy = H / 2 + dy2 * tt;\n arrow.style.display = \"\";\n // ▲ смотрит вверх: повернуть к направлению (dx, dy2) и вынести за кружок\n arrow.style.transform = `rotate(${Math.atan2(dx, -dy2).toFixed(3)}rad) translateY(-32px)`;\n el.style.transform = `translate(${sx.toFixed(1)}px, ${sy.toFixed(1)}px)`;\n el.classList.add(\"edge\");\n }\n }\n for (const [k, el] of this.markEls)\n if (!keep.has(k)) {\n el.remove();\n this.markEls.delete(k);\n }\n }\n\n dispose(): void {\n this.mini.remove();\n this.compass.remove();\n this.world.remove();\n }\n}\n\nfunction div(id?: string): HTMLDivElement {\n const d = document.createElement(\"div\");\n if (id) d.id = id;\n return d;\n}\n\nfunction readOn(): boolean {\n try {\n return localStorage.getItem(\"voxelcraft:minimap\") !== \"0\";\n } catch {\n return true;\n }\n}\n"
|
|
1786
|
+
"content": "// Навигация на экране игры:\n// - компас сверху — стороны света и все отметки (задание, метки, место смерти, дом) по направлению,\n// под ним — к чему ведут и сколько идти;\n// - миникарта справа сверху — по направлению взгляда (вверх — куда смотришь), отметки за краем\n// прижаты к ободу; клик / касание — большая карта;\n// - метка в мире — знак над целью прямо в сцене с расстоянием; цель за кадром — стрелка у края\n// экрана, повёрнутая к ней.\n// Данные — ui/map/nav (атлас game.worldMap, цель задания из трекера).\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport type { Game } from \"../../main\";\nimport { itemIcon } from \"../quests/strings\";\nimport {\n bearing,\n distanceText,\n drawMark,\n drawPlayerArrow,\n drawTerrain,\n guidedMark,\n markLabel,\n navMarks,\n playerBearing,\n wrapAngle,\n type NavMark,\n} from \"./nav\";\nimport { mt } from \"./strings\";\n\n/** Пикселей на блок на миникарте. */\nconst MINI_SCALE = 1.5;\n/** Половина обзора компаса. */\nconst COMPASS_HALF = (100 * Math.PI) / 180;\n/** Отступ стрелки от края экрана. */\nconst EDGE = 56;\n/**\n * Раз в столько мс миникарта перерисовывается и без движения: атлас мог открыть новые клетки рядом.\n * В остальное время холсты рисуются, только когда игрок сдвинулся, повернулся или сменились\n * отметки (05.11.2026: раньше оба холста рисовались заново каждый кадр, и стоя на месте тоже).\n */\nconst IDLE_REDRAW_MS = 1000;\n\nexport class NavHud {\n private readonly mini: HTMLDivElement;\n private readonly miniCanvas: HTMLCanvasElement;\n private readonly coords: HTMLDivElement;\n private readonly compass: HTMLDivElement;\n private readonly compassCanvas: HTMLCanvasElement;\n private readonly info: HTMLDivElement;\n private readonly world: HTMLDivElement;\n private readonly markEls = new Map<string, HTMLDivElement>();\n private infoSig = \"-\";\n /** Что нарисовано на миникарте и компасе сейчас: то же — не перерисовываем. */\n private drawnSig = \"\";\n private drawnAt = 0;\n /** Ширина миникарты и компаса: меряет ResizeObserver, кадр не читает раскладку. */\n private miniW = 0;\n private compassW = 0;\n private readonly v = new THREE.Vector3();\n /** Миникарта и компас включены (настройка игрока). */\n on = readOn();\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n const host = document.getElementById(\"game\")!.parentElement!;\n this.compass = div(\"navCompass\");\n this.compassCanvas = document.createElement(\"canvas\");\n this.info = div();\n this.info.className = \"ncInfo\";\n this.compass.append(this.compassCanvas, this.info);\n\n this.mini = div(\"minimap\");\n this.miniCanvas = document.createElement(\"canvas\");\n this.coords = div();\n this.coords.className = \"mmCoords\";\n this.mini.append(this.miniCanvas, this.coords);\n this.mini.title = mt(\"mapKey\");\n this.mini.addEventListener(\n \"pointerdown\",\n (e) => {\n e.preventDefault();\n e.stopPropagation();\n events.emit(\"ui:map\");\n },\n { signal },\n );\n\n this.world = div(\"navWorld\");\n host.append(this.world, this.compass, this.mini);\n if (typeof ResizeObserver !== \"undefined\") {\n const ro = new ResizeObserver(() => {\n this.miniW = this.mini.clientWidth;\n this.compassW = this.compass.clientWidth;\n this.drawnSig = \"\";\n });\n ro.observe(this.mini);\n ro.observe(this.compass);\n signal.addEventListener(\"abort\", () => ro.disconnect());\n }\n }\n\n setOn(on: boolean): void {\n this.on = on;\n try {\n localStorage.setItem(\"voxelcraft:minimap\", on ? \"1\" : \"0\");\n } catch {\n // приватное окно — до перезагрузки\n }\n }\n\n private visible(): boolean {\n const g = this.game;\n return (\n this.on &&\n g.playing &&\n !g.player.dead &&\n !g.mapUI?.open &&\n !g.questBookUI?.open\n );\n }\n\n frame(): void {\n const vis = this.visible();\n this.mini.classList.toggle(\"hidden\", !vis);\n this.compass.classList.toggle(\"hidden\", !vis);\n this.world.classList.toggle(\"hidden\", !vis || this.game.invUI.open);\n if (!vis) return;\n // дошёл до места смерти — дальше туда не ведём (компас возвращения по-прежнему указывает)\n const d = this.game.lastDeath;\n const p = this.game.player.pos;\n if (\n d?.guide &&\n !this.game.player.dead &&\n d.dim === this.game.dimension &&\n Math.hypot(d.x - p.x, d.z - p.z) < 4\n )\n d.guide = false;\n const marks = navMarks(this.game);\n const yaw = this.game.player.yaw;\n const sig =\n `${Math.round(p.x * 4)},${Math.round(p.z * 4)},${Math.floor(p.y)},${Math.round(yaw * 500)}|` +\n marks\n .map(\n (m) =>\n `${m.key}:${Math.round(m.x)},${Math.round(m.z)}${m.guided ? \"!\" : \"\"}`,\n )\n .join(\";\");\n const now = performance.now();\n if (sig !== this.drawnSig || now - this.drawnAt >= IDLE_REDRAW_MS) {\n this.drawnSig = sig;\n this.drawnAt = now;\n this.drawMini(marks);\n this.drawCompass(marks);\n }\n if (marks.some((m) => m.guided || m.kind === \"quest\" || m.kind === \"death\"))\n this.placeWorldMarks(marks);\n else if (this.markEls.size) this.placeWorldMarks([]);\n }\n\n // ---- миникарта --------------------------------------------------------------\n\n private drawMini(marks: NavMark[]): void {\n const g = this.game;\n const c = this.miniCanvas;\n const css = this.miniW || this.mini.clientWidth || 150;\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n const px = Math.round(css * dpr);\n if (c.width !== px) c.width = c.height = px;\n const ctx = c.getContext(\"2d\");\n if (!ctx) return;\n const p = g.player.pos;\n const yaw = g.player.yaw;\n const R = css / 2;\n const s = MINI_SCALE;\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, css, css);\n ctx.save();\n ctx.beginPath();\n ctx.arc(R, R, R - 2, 0, Math.PI * 2);\n ctx.clip();\n ctx.fillStyle = \"#1d1f24\";\n ctx.fillRect(0, 0, css, css);\n // местность: центр — игрок, поворот — по взгляду\n ctx.save();\n ctx.translate(R, R);\n ctx.rotate(yaw);\n ctx.scale(s, s);\n ctx.translate(-p.x, -p.z);\n const reach = (R / s) * 1.5;\n drawTerrain(ctx, g, {\n x0: p.x - reach,\n z0: p.z - reach,\n x1: p.x + reach,\n z1: p.z + reach,\n });\n ctx.restore();\n // отметки: за ободом — прижатые к нему (кроме других игроков)\n const cos = Math.cos(yaw),\n sin = Math.sin(yaw);\n const rim = R - 11;\n const sorted = [...marks].sort((a, b) => +a.guided - +b.guided);\n for (const m of sorted) {\n const dx = (m.x - p.x) * s,\n dz = (m.z - p.z) * s;\n let x = dx * cos - dz * sin,\n y = dx * sin + dz * cos;\n const d = Math.hypot(x, y);\n if (d > rim) {\n if (m.kind === \"player\") continue;\n x = (x / d) * rim;\n y = (y / d) * rim;\n }\n drawMark(ctx, m, R + x, R + y, m.guided ? 16 : 13);\n }\n drawPlayerArrow(ctx, R, R, 0, 7);\n ctx.restore();\n // обод и «С» на нём\n ctx.beginPath();\n ctx.arc(R, R, R - 2, 0, Math.PI * 2);\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"rgba(240, 208, 96, 0.9)\";\n ctx.stroke();\n const nx = R + Math.sin(yaw) * (R - 2),\n ny = R - Math.cos(yaw) * (R - 2);\n ctx.beginPath();\n ctx.arc(nx, ny, 8, 0, Math.PI * 2);\n ctx.fillStyle = \"#b02020\";\n ctx.fill();\n ctx.fillStyle = \"#fff\";\n ctx.font = \"bold 10px sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n ctx.fillText(mt(\"N\"), nx, ny + 0.5);\n const text = `${Math.floor(p.x)}, ${Math.floor(p.y)}, ${Math.floor(p.z)}`;\n if (this.coords.textContent !== text) this.coords.textContent = text;\n }\n\n // ---- компас -----------------------------------------------------------------\n\n private drawCompass(marks: NavMark[]): void {\n const g = this.game;\n const c = this.compassCanvas;\n const W = this.compassW || this.compass.clientWidth || 420;\n const H = 34;\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n if (c.width !== Math.round(W * dpr) || c.height !== Math.round(H * dpr)) {\n c.width = Math.round(W * dpr);\n c.height = Math.round(H * dpr);\n }\n const ctx = c.getContext(\"2d\");\n if (!ctx) return;\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.clearRect(0, 0, W, H);\n const half = W / 2;\n const k = half / COMPASS_HALF;\n const head = playerBearing(g.player.yaw);\n // фон с затуханием к краям\n const grad = ctx.createLinearGradient(0, 0, W, 0);\n grad.addColorStop(0, \"rgba(0,0,0,0)\");\n grad.addColorStop(0.12, \"rgba(0,0,0,0.55)\");\n grad.addColorStop(0.88, \"rgba(0,0,0,0.55)\");\n grad.addColorStop(1, \"rgba(0,0,0,0)\");\n ctx.fillStyle = grad;\n ctx.fillRect(0, 0, W, H);\n // деления каждые 15°, буквы на 90°, промежуточные на 45°\n const letters: Record<number, string> = {\n 0: mt(\"N\"),\n 90: mt(\"E\"),\n 180: mt(\"S\"),\n 270: mt(\"W\"),\n 45: mt(\"N\") + mt(\"E\"),\n 135: mt(\"S\") + mt(\"E\"),\n 225: mt(\"S\") + mt(\"W\"),\n 315: mt(\"N\") + mt(\"W\"),\n };\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"middle\";\n for (let deg = 0; deg < 360; deg += 15) {\n const rel = wrapAngle((deg * Math.PI) / 180 - head);\n if (Math.abs(rel) > COMPASS_HALF) continue;\n const x = half + rel * k;\n const fade = 1 - Math.abs(rel) / COMPASS_HALF;\n const l = letters[deg];\n if (l) {\n const main = deg % 90 === 0;\n ctx.globalAlpha = 0.35 + 0.65 * fade;\n ctx.fillStyle = deg === 0 ? \"#ff6a5a\" : \"#fff\";\n ctx.font = `bold ${main ? 15 : 11}px sans-serif`;\n ctx.fillText(l, x, H / 2);\n } else {\n ctx.globalAlpha = 0.25 + 0.5 * fade;\n ctx.fillStyle = \"#fff\";\n ctx.fillRect(x - 0.5, H / 2 - 4, 1, 8);\n }\n }\n ctx.globalAlpha = 1;\n // отметки; ведомая — поверх, за краем прижата к нему\n const p = g.player.pos;\n const sorted = marks\n .filter((m) => m.compass)\n .sort((a, b) => +a.guided - +b.guided);\n for (const m of sorted) {\n let rel = wrapAngle(bearing(m.x - p.x, m.z - p.z) - head);\n if (Math.abs(rel) > COMPASS_HALF) {\n if (!m.guided) continue;\n rel = Math.sign(rel) * COMPASS_HALF * 0.97;\n }\n drawMark(ctx, m, half + rel * k, H / 2, m.guided ? 26 : 20);\n }\n // центр — куда смотришь\n ctx.fillStyle = \"#f0d060\";\n ctx.beginPath();\n ctx.moveTo(half - 6, 0);\n ctx.lineTo(half + 6, 0);\n ctx.lineTo(half, 7);\n ctx.closePath();\n ctx.fill();\n\n // подпись: к чему ведут и сколько идти\n const gm = guidedMark(marks);\n let sig = \"\";\n let text = \"\";\n if (gm) {\n const dist = Math.hypot(gm.x - p.x, gm.z - p.z);\n const dy = gm.y !== undefined ? gm.y - p.y : null;\n text = `${markLabel(gm)} · ${distanceText(dist, dy)}`;\n sig = gm.key + text;\n }\n if (sig !== this.infoSig) {\n this.infoSig = sig;\n this.info.innerHTML = \"\";\n this.info.classList.toggle(\"hidden\", !gm);\n if (gm) {\n const dot = document.createElement(\"span\");\n dot.className = \"ncDot\";\n dot.style.color = gm.color;\n dot.textContent = gm.glyph;\n this.info.append(dot);\n if (gm.icon) this.info.append(itemIcon(gm.icon));\n this.info.append(\" \" + text);\n }\n }\n }\n\n // ---- метки в мире -----------------------------------------------------------\n\n private placeWorldMarks(marks: NavMark[]): void {\n const g = this.game;\n const cam = g.camera;\n const W = window.innerWidth,\n H = window.innerHeight;\n const show = marks.filter(\n (m) => m.guided || m.kind === \"quest\" || m.kind === \"death\",\n );\n const keep = new Set<string>();\n const eye = g.player.eyePos;\n cam.updateMatrixWorld();\n for (const m of show) {\n keep.add(m.key);\n let el = this.markEls.get(m.key);\n if (!el) {\n el = div();\n el.className = \"navMark\";\n el.innerHTML = `<div class=\"nmArrow\"></div><div class=\"nmIcon\"></div><div class=\"nmText\"></div>`;\n this.world.append(el);\n this.markEls.set(m.key, el);\n }\n const icon = el.children[1] as HTMLElement;\n const label = el.children[2] as HTMLElement;\n const arrow = el.children[0] as HTMLElement;\n const iconKey = m.glyph + m.color + (m.icon ?? \"\");\n if (icon.dataset.k !== iconKey) {\n icon.dataset.k = iconKey;\n icon.innerHTML = \"\";\n icon.style.borderColor = m.color;\n icon.style.color = m.color;\n if (m.icon) icon.append(itemIcon(m.icon, \"nmItem\"));\n else icon.textContent = m.glyph;\n }\n el.classList.toggle(\"guided\", m.guided);\n arrow.style.color = m.color;\n // знак — над землёй метки; у цели задания — на самом блоке или мобе\n const ty =\n m.y === undefined ? eye.y : m.y + (m.kind === \"quest\" ? 0 : 1.2);\n const dist = Math.hypot(m.x - eye.x, m.z - eye.z);\n const dy = m.y !== undefined ? m.y - g.player.pos.y : null;\n const name = markLabel(m);\n const t = (m.guided && name ? name + \" · \" : \"\") + distanceText(dist, dy);\n if (label.textContent !== t) label.textContent = t;\n\n // в пространство камеры: впереди — z < 0\n this.v.set(m.x, ty, m.z).applyMatrix4(cam.matrixWorldInverse);\n const front = this.v.z < -0.2;\n let sx = 0,\n sy = 0,\n onScreen = false;\n if (front) {\n const cx = this.v.x,\n cy = this.v.y,\n cz = this.v.z;\n this.v.applyMatrix4(cam.projectionMatrix);\n sx = ((this.v.x + 1) / 2) * W;\n sy = ((1 - this.v.y) / 2) * H;\n onScreen =\n sx > EDGE && sx < W - EDGE && sy > EDGE + 40 && sy < H - EDGE - 60;\n this.v.set(cx, cy, cz);\n }\n if (onScreen) {\n arrow.style.display = \"none\";\n el.style.transform = `translate(${sx.toFixed(1)}px, ${sy.toFixed(1)}px)`;\n el.classList.toggle(\"edge\", false);\n } else {\n // направление на экране к цели (y вниз); цель сзади — те же x/y камеры\n let dx = this.v.x,\n dy2 = -this.v.y;\n if (!front && Math.abs(dx) < 1e-3 && Math.abs(dy2) < 1e-3) dy2 = 1;\n // сзади точка «уходит» вниз — тянем её на горизонталь, чтобы стрелка звала повернуться\n if (!front)\n dy2 = Math.max(-0.4, Math.min(0.4, dy2 / (Math.abs(dx) + 1)));\n const len = Math.hypot(dx, dy2) || 1;\n dx /= len;\n dy2 /= len;\n const hw = W / 2 - EDGE - 50, // место под подпись по центру знака\n hh = H / 2 - EDGE - 50;\n const tt = Math.min(\n Math.abs(dx) > 1e-6 ? hw / Math.abs(dx) : Infinity,\n Math.abs(dy2) > 1e-6 ? hh / Math.abs(dy2) : Infinity,\n );\n sx = W / 2 + dx * tt;\n sy = H / 2 + dy2 * tt;\n arrow.style.display = \"\";\n // ▲ смотрит вверх: повернуть к направлению (dx, dy2) и вынести за кружок\n arrow.style.transform = `rotate(${Math.atan2(dx, -dy2).toFixed(3)}rad) translateY(-32px)`;\n el.style.transform = `translate(${sx.toFixed(1)}px, ${sy.toFixed(1)}px)`;\n el.classList.toggle(\"edge\", true);\n }\n }\n for (const [k, el] of this.markEls)\n if (!keep.has(k)) {\n el.remove();\n this.markEls.delete(k);\n }\n }\n\n dispose(): void {\n this.mini.remove();\n this.compass.remove();\n this.world.remove();\n }\n}\n\nfunction div(id?: string): HTMLDivElement {\n const d = document.createElement(\"div\");\n if (id) d.id = id;\n return d;\n}\n\nfunction readOn(): boolean {\n try {\n return localStorage.getItem(\"voxelcraft:minimap\") !== \"0\";\n } catch {\n return true;\n }\n}\n"
|
|
1787
1787
|
},
|
|
1788
1788
|
{
|
|
1789
1789
|
"path": "ui/map/strings.ts",
|
|
@@ -1791,11 +1791,15 @@
|
|
|
1791
1791
|
},
|
|
1792
1792
|
{
|
|
1793
1793
|
"path": "ui/map/WorldMapUI.ts",
|
|
1794
|
-
"content": "// Большая карта (M, на тач-экране — касание миникарты): разведанная местность севером вверх,\n// перетаскивание, масштаб колесом и кнопками ±. Клик по пустому месту — новая метка (название, цвет),\n// клик по метке — правка: «Вести сюда», переименовать, удалить. Справа — список меток. Данные —\n// game.worldMap (systems/WorldAtlas), отметки — ui/map/nav.\nimport { events } from \"../../core/EventBus\";\nimport { PIN_COLORS, type MapPin } from \"../../systems/WorldAtlas\";\nimport type { Game } from \"../../main\";\nimport { isTouch } from \"../quests/strings\";\nimport {\n distanceText,\n drawMark,\n drawPlayerArrow,\n drawTerrain,\n markLabel,\n navMarks,\n type NavMark,\n} from \"./nav\";\nimport { mt } from \"./strings\";\n\nconst MIN_SCALE = 0.25;\nconst MAX_SCALE = 8;\n\nexport class WorldMapUI {\n open = false;\n private readonly root: HTMLDivElement;\n private readonly canvas: HTMLCanvasElement;\n private readonly side: HTMLDivElement;\n private readonly pop: HTMLDivElement;\n private readonly help: HTMLDivElement;\n private cx = 0;\n private cz = 0;\n private scale = 2;\n private drag: {\n id: number;\n x: number;\n y: number;\n cx: number;\n cz: number;\n moved: boolean;\n } | null = null;\n private sideSig = \"\";\n private marks: NavMark[] = [];\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = el(\"div\");\n this.root.id = \"worldMap\";\n this.root.className = \"hidden\";\n const panel = el(\"div\", \"wmPanel\");\n const top = el(\"div\", \"wmTop\");\n const h = el(\"h3\");\n h.textContent = mt(\"map\");\n top.append(\n h,\n button(mt(\"centerMe\"), () => this.centerOnPlayer()),\n button(mt(\"pinHere\"), () => {\n const p = this.game.player.pos;\n this.newPin(p.x, p.z, p.y);\n }),\n button(\"−\", () => this.zoom(0.5)),\n button(\"+\", () => this.zoom(2)),\n button(\"✕\", () => this.hide()),\n );\n const body = el(\"div\", \"wmBody\");\n const view = el(\"div\", \"wmView\");\n this.canvas = document.createElement(\"canvas\");\n this.pop = el(\"div\", \"wmPop hidden\");\n view.append(this.canvas, this.pop);\n this.side = el(\"div\", \"wmSide\");\n body.append(view, this.side);\n this.help = el(\"div\", \"wmHelp\");\n panel.append(top, body, this.help);\n this.root.append(panel);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n const opts = { signal };\n this.canvas.addEventListener(\"pointerdown\", (e) => this.down(e), opts);\n this.canvas.addEventListener(\"pointermove\", (e) => this.move(e), opts);\n this.canvas.addEventListener(\"pointerup\", (e) => this.up(e), opts);\n this.canvas.addEventListener(\n \"pointercancel\",\n () => (this.drag = null),\n opts,\n );\n this.canvas.addEventListener(\n \"wheel\",\n (e) => {\n e.preventDefault();\n const r = this.canvas.getBoundingClientRect();\n this.zoom(\n e.deltaY < 0 ? 1.25 : 0.8,\n e.clientX - r.left,\n e.clientY - r.top,\n );\n },\n { passive: false, signal },\n );\n this.root.addEventListener(\n \"mousedown\",\n (e) => {\n if (e.target === this.root) this.hide();\n },\n opts,\n );\n\n events.on(\"ui:map\", () => (this.open ? this.hide() : this.show()));\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (!this.open) return;\n const typing =\n e.target instanceof HTMLInputElement ||\n e.target instanceof HTMLTextAreaElement;\n if (e.code === \"Escape\") {\n e.stopPropagation();\n if (!this.pop.classList.contains(\"hidden\")) this.closePop();\n else this.hide();\n } else if (e.code === \"KeyM\" && !e.repeat && !typing) {\n e.stopPropagation();\n this.hide();\n }\n },\n { signal, capture: true },\n );\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.questBookUI.open) g.questBookUI.hide();\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.centerOnPlayer();\n this.closePop();\n this.sideSig = \"\";\n // тач-управление включается после конструктора — подсказку выбираем при показе\n this.help.textContent = isTouch() ? mt(\"helpTouch\") : mt(\"help\");\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.drag = null;\n this.game.input.uiOpen = false;\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n private centerOnPlayer(): void {\n const p = this.game.player.pos;\n this.cx = p.x;\n this.cz = p.z;\n }\n\n /** Масштаб в `k` раз вокруг точки холста (по умолчанию — центр). */\n private zoom(k: number, ax?: number, ay?: number): void {\n const W = this.canvas.clientWidth,\n H = this.canvas.clientHeight;\n const x = ax ?? W / 2,\n y = ay ?? H / 2;\n const before = this.toWorld(x, y);\n this.scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, this.scale * k));\n const after = this.toWorld(x, y);\n this.cx += before.x - after.x;\n this.cz += before.z - after.z;\n }\n\n private toWorld(x: number, y: number): { x: number; z: number } {\n const W = this.canvas.clientWidth,\n H = this.canvas.clientHeight;\n return {\n x: this.cx + (x - W / 2) / this.scale,\n z: this.cz + (y - H / 2) / this.scale,\n };\n }\n\n private toScreen(x: number, z: number): { x: number; y: number } {\n const W = this.canvas.clientWidth,\n H = this.canvas.clientHeight;\n return {\n x: W / 2 + (x - this.cx) * this.scale,\n y: H / 2 + (z - this.cz) * this.scale,\n };\n }\n\n // ---- указатель ----------------------------------------------------------------\n\n private down(e: PointerEvent): void {\n if (e.button !== 0 && e.pointerType === \"mouse\") return;\n this.canvas.setPointerCapture(e.pointerId);\n this.drag = {\n id: e.pointerId,\n x: e.clientX,\n y: e.clientY,\n cx: this.cx,\n cz: this.cz,\n moved: false,\n };\n }\n\n private move(e: PointerEvent): void {\n const d = this.drag;\n if (!d || d.id !== e.pointerId) return;\n const dx = e.clientX - d.x,\n dy = e.clientY - d.y;\n if (!d.moved && Math.hypot(dx, dy) > 5) {\n d.moved = true;\n this.closePop();\n }\n if (d.moved) {\n this.cx = d.cx - dx / this.scale;\n this.cz = d.cz - dy / this.scale;\n }\n }\n\n private up(e: PointerEvent): void {\n const d = this.drag;\n this.drag = null;\n if (!d || d.id !== e.pointerId || d.moved) return;\n const r = this.canvas.getBoundingClientRect();\n const x = e.clientX - r.left,\n y = e.clientY - r.top;\n const hit = this.hitMark(x, y);\n if (hit?.pinId !== undefined) {\n const pin = this.game.worldMap.pins.find((p) => p.id === hit.pinId);\n if (pin) this.editPin(pin);\n return;\n }\n if (hit) return;\n if (!this.pop.classList.contains(\"hidden\")) {\n this.closePop();\n return;\n }\n const w = this.toWorld(x, y);\n this.newPin(w.x, w.z);\n }\n\n private hitMark(x: number, y: number): NavMark | null {\n let best: NavMark | null = null;\n let bestD = 14 * 14;\n for (const m of this.marks) {\n if (m.kind === \"player\") continue;\n const s = this.toScreen(m.x, m.z);\n const d = (s.x - x) ** 2 + (s.y - y) ** 2;\n if (d < bestD) {\n bestD = d;\n best = m;\n }\n }\n return best;\n }\n\n // ---- метки --------------------------------------------------------------------\n\n private newPin(x: number, z: number, y?: number): void {\n const g = this.game;\n const n = g.worldMap.pins.length + 1;\n this.pinForm(\n { name: `${mt(\"newPin\")} ${n}`, color: n % PIN_COLORS.length },\n x,\n z,\n (name, color, guide) => {\n const pin = g.worldMap.addPin({\n dim: g.dimension,\n x: Math.floor(x) + 0.5,\n z: Math.floor(z) + 0.5,\n ...(y !== undefined ? { y: Math.floor(y) } : {}),\n name,\n color,\n // в сетевой игре метку видят все; автор — я (хост проверит и поставит сам)\n ...(g.net ? { by: g.net.selfId, byName: g.net.name } : {}),\n });\n if (!pin) {\n g.hud.message(mt(\"full\"), 2200);\n return;\n }\n if (guide) g.worldMap.setTrack(pin.id);\n },\n );\n }\n\n private editPin(pin: MapPin): void {\n const g = this.game;\n const mine = canEditPin(g, pin);\n this.pinForm(\n pin,\n pin.x,\n pin.z,\n (name, color, guide) => {\n if (mine) g.worldMap.updatePin(pin.id, { name, color });\n if (guide !== null) g.worldMap.setTrack(guide ? pin.id : null);\n },\n mine ? () => g.worldMap.removePin(pin.id) : undefined,\n g.worldMap.track === pin.id,\n mine ? undefined : (pin.byName ?? \"?\"),\n );\n }\n\n /**\n * Форма метки у точки на карте. `onSave(name, color, guide)`: guide — нажата «Вести сюда» (true),\n * «Не вести» (false) или просто «Сохранить» (null).\n */\n private pinForm(\n init: { name: string; color: number },\n x: number,\n z: number,\n onSave: (name: string, color: number, guide: boolean | null) => void,\n onRemove?: () => void,\n guided = false,\n /** Чужая метка (имя автора): только «Вести сюда» / «Не вести». */\n author?: string,\n ): void {\n const pop = this.pop;\n pop.innerHTML = \"\";\n if (author !== undefined) {\n const title = el(\"div\", \"wmPinTitle\");\n title.textContent = init.name;\n const by = el(\"div\", \"wmNote\");\n by.textContent = mt(\"pinBy\", { name: author });\n const row = el(\"div\", \"wmRow\");\n row.append(\n button(guided ? mt(\"stopGuide\") : mt(\"guide\"), () => {\n onSave(init.name, init.color, !guided);\n this.closePop();\n this.sideSig = \"\";\n }),\n );\n pop.append(title, by, row);\n this.placePop(x, z);\n return;\n }\n let color = init.color;\n const input = document.createElement(\"input\");\n input.className = \"wmName\";\n input.maxLength = 32;\n input.value = init.name;\n input.placeholder = mt(\"name\");\n const colors = el(\"div\", \"wmColors\");\n const paint = (): void => {\n for (const [i, b] of [...colors.children].entries())\n b.classList.toggle(\"on\", i === color);\n };\n PIN_COLORS.forEach((c, i) => {\n const b = el(\"button\", \"wmColor\");\n b.style.background = c;\n b.addEventListener(\"click\", () => {\n color = i;\n paint();\n });\n colors.append(b);\n });\n paint();\n const done = (guide: boolean | null): void => {\n onSave(input.value || init.name, color, guide);\n this.closePop();\n this.sideSig = \"\";\n };\n input.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") done(null);\n });\n const row = el(\"div\", \"wmRow\");\n row.append(\n button(guided ? mt(\"stopGuide\") : mt(\"guide\"), () => done(!guided)),\n button(mt(\"save\"), () => done(null)),\n );\n if (onRemove)\n row.append(\n button(mt(\"remove\"), () => {\n onRemove();\n this.closePop();\n this.sideSig = \"\";\n }),\n );\n pop.append(input, colors, row);\n this.placePop(x, z);\n if (!isTouch()) {\n input.focus();\n input.select();\n }\n }\n\n /** Показать форму у точки карты, но внутри окна. */\n private placePop(x: number, z: number): void {\n const pop = this.pop;\n pop.classList.remove(\"hidden\");\n const s = this.toScreen(x, z);\n const W = this.canvas.clientWidth,\n H = this.canvas.clientHeight;\n const pw = 230,\n ph = 130;\n pop.style.left = Math.max(6, Math.min(W - pw - 6, s.x + 14)) + \"px\";\n pop.style.top = Math.max(6, Math.min(H - ph - 6, s.y - 20)) + \"px\";\n }\n\n private closePop(): void {\n this.pop.classList.add(\"hidden\");\n this.pop.innerHTML = \"\";\n }\n\n private renderSide(): void {\n const g = this.game;\n const p = g.player.pos;\n const sig = JSON.stringify([\n g.worldMap.pinsVersion,\n g.worldMap.track,\n g.dimension,\n Math.floor(p.x / 8),\n Math.floor(p.z / 8),\n // «Дом» сменился (легли в кровать, зарядили якорь) — список тоже\n this.marks.map((m) => m.key + m.label),\n ]);\n if (sig === this.sideSig) return;\n this.sideSig = sig;\n const s = this.side;\n s.innerHTML = \"\";\n const h = el(\"h4\");\n h.textContent = mt(\"pins\");\n s.append(h);\n const list = this.marks.filter((m) => m.kind !== \"player\");\n if (!g.worldMap.pins.some((x) => x.dim === g.dimension)) {\n const n = el(\"div\", \"wmNote\");\n n.textContent = mt(\"noPins\");\n s.append(n);\n }\n for (const m of list) {\n const row = el(\"div\", \"wmItem\" + (m.guided ? \" on\" : \"\"));\n const dot = el(\"span\", \"wmDot\");\n dot.textContent = m.glyph;\n dot.style.color = m.color;\n const name = el(\"span\", \"wmItemName\");\n name.textContent = markLabel(m) + (m.author ? ` · ${m.author}` : \"\");\n const dist = el(\"span\", \"wmDist\");\n dist.textContent = distanceText(Math.hypot(m.x - p.x, m.z - p.z), null);\n row.append(dot, name, dist);\n row.addEventListener(\"click\", () => {\n this.cx = m.x;\n this.cz = m.z;\n if (m.pinId !== undefined) {\n const pin = g.worldMap.pins.find((x) => x.id === m.pinId);\n if (pin) this.editPin(pin);\n }\n });\n s.append(row);\n }\n }\n\n // ---- кадр ---------------------------------------------------------------------\n\n frame(): void {\n if (!this.open) return;\n const g = this.game;\n this.marks = navMarks(g);\n this.renderSide();\n const c = this.canvas;\n const W = c.clientWidth,\n H = c.clientHeight;\n if (!W || !H) return;\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n if (c.width !== Math.round(W * dpr) || c.height !== Math.round(H * dpr)) {\n c.width = Math.round(W * dpr);\n c.height = Math.round(H * dpr);\n }\n const ctx = c.getContext(\"2d\");\n if (!ctx) return;\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.fillStyle = \"#1d1f24\";\n ctx.fillRect(0, 0, W, H);\n ctx.save();\n ctx.translate(W / 2, H / 2);\n ctx.scale(this.scale, this.scale);\n ctx.translate(-this.cx, -this.cz);\n const hw = W / 2 / this.scale,\n hh = H / 2 / this.scale;\n drawTerrain(ctx, g, {\n x0: this.cx - hw,\n z0: this.cz - hh,\n x1: this.cx + hw,\n z1: this.cz + hh,\n });\n ctx.restore();\n // отметки с подписями\n const sorted = [...this.marks].sort((a, b) => +a.guided - +b.guided);\n for (const m of sorted) {\n const s = this.toScreen(m.x, m.z);\n if (s.x < -20 || s.y < -20 || s.x > W + 20 || s.y > H + 20) continue;\n drawMark(ctx, m, s.x, s.y, m.guided ? 24 : 20);\n const label = m.kind === \"player\" ? \"\" : markLabel(m);\n if (label) {\n ctx.font = \"bold 12px sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"top\";\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"rgba(0,0,0,0.85)\";\n ctx.strokeText(label, s.x, s.y + 14);\n ctx.fillStyle = \"#fff\";\n ctx.fillText(label, s.x, s.y + 14);\n }\n }\n const p = g.player.pos;\n const me = this.toScreen(p.x, p.z);\n drawPlayerArrow(ctx, me.x, me.y, -g.player.yaw, 9);\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n\n/** Свою метку меняет автор; чужую в сетевой игре — только хост. Метки без автора — хоста мира. */\nfunction canEditPin(g: Game, pin: MapPin): boolean {\n const net = g.net;\n if (!net || net.isHost) return true;\n return pin.by === net.selfId;\n}\n\nfunction el<K extends keyof HTMLElementTagNameMap>(\n tag: K,\n cls?: string,\n): HTMLElementTagNameMap[K] {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n return e;\n}\n\nfunction button(text: string, onClick: () => void): HTMLButtonElement {\n const b = el(\"button\", \"wmBtn\");\n b.textContent = text;\n b.addEventListener(\"click\", onClick);\n return b;\n}\n"
|
|
1794
|
+
"content": "// Большая карта (M, на тач-экране — касание миникарты): разведанная местность севером вверх,\n// перетаскивание, масштаб колесом и кнопками ±. Клик по пустому месту — новая метка (название, цвет),\n// клик по метке — правка: «Вести сюда», переименовать, удалить. Справа — список меток. Данные —\n// game.worldMap (systems/WorldAtlas), отметки — ui/map/nav.\nimport { events } from \"../../core/EventBus\";\nimport { PIN_COLORS, type MapPin } from \"../../systems/WorldAtlas\";\nimport type { Game } from \"../../main\";\nimport { isTouch } from \"../quests/strings\";\nimport {\n distanceText,\n drawMark,\n drawPlayerArrow,\n drawTerrain,\n markLabel,\n navMarks,\n type NavMark,\n} from \"./nav\";\nimport { mt } from \"./strings\";\n\nconst MIN_SCALE = 0.25;\nconst MAX_SCALE = 8;\n\nexport class WorldMapUI {\n open = false;\n private readonly root: HTMLDivElement;\n private readonly canvas: HTMLCanvasElement;\n private readonly side: HTMLDivElement;\n private readonly pop: HTMLDivElement;\n private readonly help: HTMLDivElement;\n private cx = 0;\n private cz = 0;\n private scale = 2;\n private drag: {\n id: number;\n x: number;\n y: number;\n cx: number;\n cz: number;\n moved: boolean;\n } | null = null;\n /** Пальцы на холсте (два = щипок: масштаб и сдвиг вокруг середины между ними). */\n private readonly touches = new Map<number, { x: number; y: number }>();\n private pinch: { dist: number; x: number; y: number } | null = null;\n private sideSig = \"\";\n private marks: NavMark[] = [];\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = el(\"div\");\n this.root.id = \"worldMap\";\n this.root.className = \"hidden\";\n const panel = el(\"div\", \"wmPanel\");\n const top = el(\"div\", \"wmTop\");\n const h = el(\"h3\");\n h.textContent = mt(\"map\");\n top.append(\n h,\n button(mt(\"centerMe\"), () => this.centerOnPlayer()),\n button(mt(\"pinHere\"), () => {\n const p = this.game.player.pos;\n this.newPin(p.x, p.z, p.y);\n }),\n button(\"−\", () => this.zoom(0.5)),\n button(\"+\", () => this.zoom(2)),\n button(\"✕\", () => this.hide()),\n );\n const body = el(\"div\", \"wmBody\");\n const view = el(\"div\", \"wmView\");\n this.canvas = document.createElement(\"canvas\");\n this.pop = el(\"div\", \"wmPop hidden\");\n view.append(this.canvas, this.pop);\n this.side = el(\"div\", \"wmSide\");\n body.append(view, this.side);\n this.help = el(\"div\", \"wmHelp\");\n panel.append(top, body, this.help);\n this.root.append(panel);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n const opts = { signal };\n this.canvas.addEventListener(\"pointerdown\", (e) => this.down(e), opts);\n this.canvas.addEventListener(\"pointermove\", (e) => this.move(e), opts);\n this.canvas.addEventListener(\"pointerup\", (e) => this.up(e), opts);\n this.canvas.addEventListener(\n \"pointercancel\",\n (e) => {\n this.drag = null;\n this.lift(e);\n },\n opts,\n );\n this.canvas.addEventListener(\n \"wheel\",\n (e) => {\n e.preventDefault();\n const r = this.canvas.getBoundingClientRect();\n this.zoom(\n e.deltaY < 0 ? 1.25 : 0.8,\n e.clientX - r.left,\n e.clientY - r.top,\n );\n },\n { passive: false, signal },\n );\n this.root.addEventListener(\n \"mousedown\",\n (e) => {\n if (e.target === this.root) this.hide();\n },\n opts,\n );\n\n events.on(\"ui:map\", () => (this.open ? this.hide() : this.show()));\n document.addEventListener(\n \"keydown\",\n (e) => {\n if (!this.open) return;\n const typing =\n e.target instanceof HTMLInputElement ||\n e.target instanceof HTMLTextAreaElement;\n if (e.code === \"Escape\") {\n e.stopPropagation();\n if (!this.pop.classList.contains(\"hidden\")) this.closePop();\n else this.hide();\n } else if (e.code === \"KeyM\" && !e.repeat && !typing) {\n e.stopPropagation();\n this.hide();\n }\n },\n { signal, capture: true },\n );\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.questBookUI.open) g.questBookUI.hide();\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.centerOnPlayer();\n this.closePop();\n this.sideSig = \"\";\n // тач-управление включается после конструктора — подсказку выбираем при показе\n this.help.textContent = isTouch() ? mt(\"helpTouch\") : mt(\"help\");\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.drag = null;\n this.game.input.uiOpen = false;\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n private centerOnPlayer(): void {\n const p = this.game.player.pos;\n this.cx = p.x;\n this.cz = p.z;\n }\n\n /** Масштаб в `k` раз вокруг точки холста (по умолчанию — центр). */\n private zoom(k: number, ax?: number, ay?: number): void {\n const W = this.canvas.clientWidth,\n H = this.canvas.clientHeight;\n const x = ax ?? W / 2,\n y = ay ?? H / 2;\n const before = this.toWorld(x, y);\n this.scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, this.scale * k));\n const after = this.toWorld(x, y);\n this.cx += before.x - after.x;\n this.cz += before.z - after.z;\n }\n\n private toWorld(x: number, y: number): { x: number; z: number } {\n const W = this.canvas.clientWidth,\n H = this.canvas.clientHeight;\n return {\n x: this.cx + (x - W / 2) / this.scale,\n z: this.cz + (y - H / 2) / this.scale,\n };\n }\n\n private toScreen(x: number, z: number): { x: number; y: number } {\n const W = this.canvas.clientWidth,\n H = this.canvas.clientHeight;\n return {\n x: W / 2 + (x - this.cx) * this.scale,\n y: H / 2 + (z - this.cz) * this.scale,\n };\n }\n\n // ---- указатель ----------------------------------------------------------------\n\n private down(e: PointerEvent): void {\n if (e.button !== 0 && e.pointerType === \"mouse\") return;\n this.canvas.setPointerCapture(e.pointerId);\n if (e.pointerType !== \"mouse\") {\n this.touches.set(e.pointerId, { x: e.clientX, y: e.clientY });\n if (this.touches.size >= 2) {\n // второй палец: это щипок, не перетаскивание и не метка\n this.drag = null;\n this.pinch = this.pinchNow();\n this.closePop();\n return;\n }\n }\n this.drag = {\n id: e.pointerId,\n x: e.clientX,\n y: e.clientY,\n cx: this.cx,\n cz: this.cz,\n moved: false,\n };\n }\n\n private move(e: PointerEvent): void {\n const t = this.touches.get(e.pointerId);\n if (t) {\n t.x = e.clientX;\n t.y = e.clientY;\n }\n if (this.pinch && this.touches.size >= 2) {\n const now = this.pinchNow();\n const r = this.canvas.getBoundingClientRect();\n // сдвиг середины тянет карту, расстояние между пальцами масштабирует вокруг середины\n this.cx -= (now.x - this.pinch.x) / this.scale;\n this.cz -= (now.y - this.pinch.y) / this.scale;\n if (this.pinch.dist > 0)\n this.zoom(now.dist / this.pinch.dist, now.x - r.left, now.y - r.top);\n this.pinch = now;\n return;\n }\n const d = this.drag;\n if (!d || d.id !== e.pointerId) return;\n const dx = e.clientX - d.x,\n dy = e.clientY - d.y;\n if (!d.moved && Math.hypot(dx, dy) > 5) {\n d.moved = true;\n this.closePop();\n }\n if (d.moved) {\n this.cx = d.cx - dx / this.scale;\n this.cz = d.cz - dy / this.scale;\n }\n }\n\n private up(e: PointerEvent): void {\n const wasPinch = this.pinch !== null;\n this.lift(e);\n if (wasPinch) {\n this.drag = null;\n return;\n }\n const d = this.drag;\n this.drag = null;\n if (!d || d.id !== e.pointerId || d.moved) return;\n const r = this.canvas.getBoundingClientRect();\n const x = e.clientX - r.left,\n y = e.clientY - r.top;\n const hit = this.hitMark(x, y);\n if (hit?.pinId !== undefined) {\n const pin = this.game.worldMap.pins.find((p) => p.id === hit.pinId);\n if (pin) this.editPin(pin);\n return;\n }\n if (hit) return;\n if (!this.pop.classList.contains(\"hidden\")) {\n this.closePop();\n return;\n }\n const w = this.toWorld(x, y);\n this.newPin(w.x, w.z);\n }\n\n /** Палец убран: щипок кончается, когда пальцев меньше двух (оставшийся карту не тянет). */\n private lift(e: PointerEvent): void {\n this.touches.delete(e.pointerId);\n if (this.touches.size < 2) this.pinch = null;\n }\n\n private pinchNow(): { dist: number; x: number; y: number } {\n const [a, b] = [...this.touches.values()];\n if (!a || !b) return { dist: 0, x: 0, y: 0 };\n return {\n dist: Math.hypot(a.x - b.x, a.y - b.y),\n x: (a.x + b.x) / 2,\n y: (a.y + b.y) / 2,\n };\n }\n\n private hitMark(x: number, y: number): NavMark | null {\n let best: NavMark | null = null;\n let bestD = 14 * 14;\n for (const m of this.marks) {\n if (m.kind === \"player\") continue;\n const s = this.toScreen(m.x, m.z);\n const d = (s.x - x) ** 2 + (s.y - y) ** 2;\n if (d < bestD) {\n bestD = d;\n best = m;\n }\n }\n return best;\n }\n\n // ---- метки --------------------------------------------------------------------\n\n private newPin(x: number, z: number, y?: number): void {\n const g = this.game;\n const n = g.worldMap.pins.length + 1;\n this.pinForm(\n { name: `${mt(\"newPin\")} ${n}`, color: n % PIN_COLORS.length },\n x,\n z,\n (name, color, guide) => {\n const pin = g.worldMap.addPin({\n dim: g.dimension,\n x: Math.floor(x) + 0.5,\n z: Math.floor(z) + 0.5,\n ...(y !== undefined ? { y: Math.floor(y) } : {}),\n name,\n color,\n // в сетевой игре метку видят все; автор — я (хост проверит и поставит сам)\n ...(g.net ? { by: g.net.selfId, byName: g.net.name } : {}),\n });\n if (!pin) {\n g.hud.message(mt(\"full\"), 2200);\n return;\n }\n if (guide) g.worldMap.setTrack(pin.id);\n },\n );\n }\n\n private editPin(pin: MapPin): void {\n const g = this.game;\n const mine = canEditPin(g, pin);\n this.pinForm(\n pin,\n pin.x,\n pin.z,\n (name, color, guide) => {\n if (mine) g.worldMap.updatePin(pin.id, { name, color });\n if (guide !== null) g.worldMap.setTrack(guide ? pin.id : null);\n },\n mine ? () => g.worldMap.removePin(pin.id) : undefined,\n g.worldMap.track === pin.id,\n mine ? undefined : (pin.byName ?? \"?\"),\n );\n }\n\n /**\n * Форма метки у точки на карте. `onSave(name, color, guide)`: guide — нажата «Вести сюда» (true),\n * «Не вести» (false) или просто «Сохранить» (null).\n */\n private pinForm(\n init: { name: string; color: number },\n x: number,\n z: number,\n onSave: (name: string, color: number, guide: boolean | null) => void,\n onRemove?: () => void,\n guided = false,\n /** Чужая метка (имя автора): только «Вести сюда» / «Не вести». */\n author?: string,\n ): void {\n const pop = this.pop;\n pop.innerHTML = \"\";\n if (author !== undefined) {\n const title = el(\"div\", \"wmPinTitle\");\n title.textContent = init.name;\n const by = el(\"div\", \"wmNote\");\n by.textContent = mt(\"pinBy\", { name: author });\n const row = el(\"div\", \"wmRow\");\n row.append(\n button(guided ? mt(\"stopGuide\") : mt(\"guide\"), () => {\n onSave(init.name, init.color, !guided);\n this.closePop();\n this.sideSig = \"\";\n }),\n );\n pop.append(title, by, row);\n this.placePop(x, z);\n return;\n }\n let color = init.color;\n const input = document.createElement(\"input\");\n input.className = \"wmName\";\n input.maxLength = 32;\n input.value = init.name;\n input.placeholder = mt(\"name\");\n const colors = el(\"div\", \"wmColors\");\n const paint = (): void => {\n for (const [i, b] of [...colors.children].entries())\n b.classList.toggle(\"on\", i === color);\n };\n PIN_COLORS.forEach((c, i) => {\n const b = el(\"button\", \"wmColor\");\n b.style.background = c;\n b.addEventListener(\"click\", () => {\n color = i;\n paint();\n });\n colors.append(b);\n });\n paint();\n const done = (guide: boolean | null): void => {\n onSave(input.value || init.name, color, guide);\n this.closePop();\n this.sideSig = \"\";\n };\n input.addEventListener(\"keydown\", (e) => {\n if (e.key === \"Enter\") done(null);\n });\n const row = el(\"div\", \"wmRow\");\n row.append(\n button(guided ? mt(\"stopGuide\") : mt(\"guide\"), () => done(!guided)),\n button(mt(\"save\"), () => done(null)),\n );\n if (onRemove)\n row.append(\n button(mt(\"remove\"), () => {\n onRemove();\n this.closePop();\n this.sideSig = \"\";\n }),\n );\n pop.append(input, colors, row);\n this.placePop(x, z);\n if (!isTouch()) {\n input.focus();\n input.select();\n }\n }\n\n /** Показать форму у точки карты, но внутри окна. */\n private placePop(x: number, z: number): void {\n const pop = this.pop;\n pop.classList.remove(\"hidden\");\n const s = this.toScreen(x, z);\n const W = this.canvas.clientWidth,\n H = this.canvas.clientHeight;\n const pw = 230,\n ph = 130;\n pop.style.left = Math.max(6, Math.min(W - pw - 6, s.x + 14)) + \"px\";\n pop.style.top = Math.max(6, Math.min(H - ph - 6, s.y - 20)) + \"px\";\n }\n\n private closePop(): void {\n this.pop.classList.add(\"hidden\");\n this.pop.innerHTML = \"\";\n }\n\n private renderSide(): void {\n const g = this.game;\n const p = g.player.pos;\n const sig = JSON.stringify([\n g.worldMap.pinsVersion,\n g.worldMap.track,\n g.dimension,\n Math.floor(p.x / 8),\n Math.floor(p.z / 8),\n // «Дом» сменился (легли в кровать, зарядили якорь) — список тоже\n this.marks.map((m) => m.key + m.label),\n ]);\n if (sig === this.sideSig) return;\n this.sideSig = sig;\n const s = this.side;\n s.innerHTML = \"\";\n const h = el(\"h4\");\n h.textContent = mt(\"pins\");\n s.append(h);\n const list = this.marks.filter((m) => m.kind !== \"player\");\n if (!g.worldMap.pins.some((x) => x.dim === g.dimension)) {\n const n = el(\"div\", \"wmNote\");\n n.textContent = mt(\"noPins\");\n s.append(n);\n }\n for (const m of list) {\n const row = el(\"div\", \"wmItem\" + (m.guided ? \" on\" : \"\"));\n const dot = el(\"span\", \"wmDot\");\n dot.textContent = m.glyph;\n dot.style.color = m.color;\n const name = el(\"span\", \"wmItemName\");\n name.textContent = markLabel(m) + (m.author ? ` · ${m.author}` : \"\");\n const dist = el(\"span\", \"wmDist\");\n dist.textContent = distanceText(Math.hypot(m.x - p.x, m.z - p.z), null);\n row.append(dot, name, dist);\n row.addEventListener(\"click\", () => {\n this.cx = m.x;\n this.cz = m.z;\n if (m.pinId !== undefined) {\n const pin = g.worldMap.pins.find((x) => x.id === m.pinId);\n if (pin) this.editPin(pin);\n }\n });\n s.append(row);\n }\n }\n\n // ---- кадр ---------------------------------------------------------------------\n\n frame(): void {\n if (!this.open) return;\n const g = this.game;\n this.marks = navMarks(g);\n this.renderSide();\n const c = this.canvas;\n const W = c.clientWidth,\n H = c.clientHeight;\n if (!W || !H) return;\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n if (c.width !== Math.round(W * dpr) || c.height !== Math.round(H * dpr)) {\n c.width = Math.round(W * dpr);\n c.height = Math.round(H * dpr);\n }\n const ctx = c.getContext(\"2d\");\n if (!ctx) return;\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.fillStyle = \"#1d1f24\";\n ctx.fillRect(0, 0, W, H);\n ctx.save();\n ctx.translate(W / 2, H / 2);\n ctx.scale(this.scale, this.scale);\n ctx.translate(-this.cx, -this.cz);\n const hw = W / 2 / this.scale,\n hh = H / 2 / this.scale;\n drawTerrain(ctx, g, {\n x0: this.cx - hw,\n z0: this.cz - hh,\n x1: this.cx + hw,\n z1: this.cz + hh,\n });\n ctx.restore();\n // отметки с подписями\n const sorted = [...this.marks].sort((a, b) => +a.guided - +b.guided);\n for (const m of sorted) {\n const s = this.toScreen(m.x, m.z);\n if (s.x < -20 || s.y < -20 || s.x > W + 20 || s.y > H + 20) continue;\n drawMark(ctx, m, s.x, s.y, m.guided ? 24 : 20);\n const label = m.kind === \"player\" ? \"\" : markLabel(m);\n if (label) {\n ctx.font = \"bold 12px sans-serif\";\n ctx.textAlign = \"center\";\n ctx.textBaseline = \"top\";\n ctx.lineWidth = 3;\n ctx.strokeStyle = \"rgba(0,0,0,0.85)\";\n ctx.strokeText(label, s.x, s.y + 14);\n ctx.fillStyle = \"#fff\";\n ctx.fillText(label, s.x, s.y + 14);\n }\n }\n const p = g.player.pos;\n const me = this.toScreen(p.x, p.z);\n drawPlayerArrow(ctx, me.x, me.y, -g.player.yaw, 9);\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n\n/** Свою метку меняет автор; чужую в сетевой игре — только хост. Метки без автора — хоста мира. */\nfunction canEditPin(g: Game, pin: MapPin): boolean {\n const net = g.net;\n if (!net || net.isHost) return true;\n return pin.by === net.selfId;\n}\n\nfunction el<K extends keyof HTMLElementTagNameMap>(\n tag: K,\n cls?: string,\n): HTMLElementTagNameMap[K] {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n return e;\n}\n\nfunction button(text: string, onClick: () => void): HTMLButtonElement {\n const b = el(\"button\", \"wmBtn\");\n b.textContent = text;\n b.addEventListener(\"click\", onClick);\n return b;\n}\n"
|
|
1795
|
+
},
|
|
1796
|
+
{
|
|
1797
|
+
"path": "ui/mobileFit.ts",
|
|
1798
|
+
"content": "// Сенсорный экран любого размера и в обеих ориентациях: нижняя панель (хотбар, сердечки, голод, броня,\n// опыт) и открытые окна (инвентарь, сундук, печь, верстак, каталог, задания…) рисуются CSS `zoom`\n// ровно такого размера, чтобы влезть. Раньше на телефоне вертикально хотбар и инвентарь были шире\n// экрана и обрезались с обеих сторон (владелец, 05.11.2026).\n//\n// Где что стоит (джойстик, кнопки, миникарта) решает CSS по ориентации (style.css, body.touch). Здесь\n// только числа, которые CSS посчитать не может: во сколько раз ужать, чтобы влезло.\n// ⚠ Окно меряется БЕЗ своего zoom: у окна шириной «100%» под zoom растёт и своя ширина, и подгонка\n// по замеру с zoom уходила бы в ноль. `zoom` не трогает холсты игры (их тут нет): кукла в инвентаре\n// рисует three по своей ширине, просто показывается меньше.\n// ⚠ Карту мира (.wmPanel) не ужимаем: она считает клики по своему холсту в экранных пикселях.\n// ⚠ Меряется только по событию: смена размера экрана и ResizeObserver на панели и окна (окно\n// появилось, сменило содержимое). Никаких таймеров: во время игры этот код не работает вовсе\n// (раньше проверял все окна 4 раза в секунду и каждый раз пересчитывал раскладку).\n\n// Нижнюю панель (#hotbar, #hearts, #hunger, #armorBar, #bubbles, #xpBar, #xpLevel) ужимает CSS по\n// --vc-hud-zoom: у всех отступы от низа в px, под zoom они ужимаются вместе.\n/** Окна поверх игры: корневая панель каждого. */\nconst PANELS = [\n \".invPanel\",\n \".ctPanel\",\n \".emotePanel\",\n \".deathPanel\",\n \".wsPanel\",\n \".qbPanel\",\n \"#overlay .panel\",\n];\n/** Сколько места по бокам хотбара занимают джойстик и кнопки в горизонтальной ориентации, px. */\nconst SIDE_LANDSCAPE = 140;\n/** Высота ряда сердечек над низом экрана при zoom 1 (их верх), px: над ним джойстик и кнопки. */\nconst HUD_TOP = 94;\nconst MIN_ZOOM = 0.55;\nconst EDGE = 12;\n/** Нижняя панель: её ширину меряем (хотбар и ряды по обе стороны от центра). */\nconst HUD_IDS = [\"hotbar\", \"hearts\", \"hunger\", \"armorBar\", \"bubbles\"];\n\nconst clamp = (v: number, lo: number, hi: number): number =>\n Math.max(lo, Math.min(hi, v));\n\nexport function fitTouchUi(signal: AbortSignal): void {\n const root = document.documentElement;\n let last = \"\";\n\n const fitHud = (): void => {\n const vw = window.innerWidth,\n vh = window.innerHeight;\n const el = (id: string) => document.getElementById(id);\n const hotbar = el(\"hotbar\");\n if (!hotbar) return;\n // ряды по обе стороны от центра (сердечки слева, голод справа): ширина = две самые широкие половины\n const half = Math.max(\n ...[\"hearts\", \"hunger\", \"armorBar\", \"bubbles\"].map(\n (id) => el(id)?.offsetWidth ?? 0,\n ),\n );\n const need = Math.max(hotbar.offsetWidth, half * 2 + 24);\n const landscape = vw > vh;\n const room = landscape ? vw - SIDE_LANDSCAPE * 2 : vw - EDGE * 2;\n const z = clamp(room / Math.max(1, need), MIN_ZOOM, 1);\n const key = `${z.toFixed(3)}`;\n if (key === last) return;\n last = key;\n root.style.setProperty(\"--vc-hud-zoom\", z.toFixed(3));\n root.style.setProperty(\"--vc-hud-h\", `${Math.round(HUD_TOP * z)}px`);\n };\n\n /** Одно окно: снять zoom, померить, поставить такой, чтобы влезло (скрытое: без zoom). */\n const fitPanel = (p: HTMLElement): void => {\n const vw = window.innerWidth,\n vh = window.innerHeight;\n p.style.zoom = \"\";\n const w = p.offsetWidth,\n h = p.offsetHeight;\n if (!w || !h) return;\n const z = clamp(Math.min(1, (vw - EDGE) / w, (vh - EDGE) / h), MIN_ZOOM, 1);\n const next = z < 0.995 ? z.toFixed(3) : \"\";\n p.style.zoom = next;\n };\n\n // Окна создаются по ходу игры (свои UI-классы, иногда позже этого вызова): новые ловит\n // MutationObserver, дальше каждое смотрит свой ResizeObserver (появилось, сменило содержимое).\n const watched = new WeakSet<Element>();\n const panelRo =\n typeof ResizeObserver !== \"undefined\"\n ? new ResizeObserver((list) => {\n for (const e of list) fitPanel(e.target as HTMLElement);\n })\n : null;\n const watchPanels = (scope: ParentNode): void => {\n for (const sel of PANELS)\n for (const p of scope.querySelectorAll<HTMLElement>(sel)) {\n if (watched.has(p)) continue;\n watched.add(p);\n panelRo?.observe(p);\n }\n };\n const hudRo =\n typeof ResizeObserver !== \"undefined\"\n ? new ResizeObserver(() => fitHud())\n : null;\n for (const id of HUD_IDS) {\n const e = document.getElementById(id);\n if (e) hudRo?.observe(e);\n }\n const added =\n typeof MutationObserver !== \"undefined\"\n ? new MutationObserver((list) => {\n for (const r of list)\n for (const n of r.addedNodes)\n if (n instanceof Element) {\n if (PANELS.some((sel) => n.matches(sel)))\n watchPanels(n.parentNode ?? n);\n else if (n.childElementCount) watchPanels(n);\n }\n })\n : null;\n added?.observe(document.body, { childList: true, subtree: true });\n\n const refit = (): void => {\n fitHud();\n for (const sel of PANELS)\n for (const p of document.querySelectorAll<HTMLElement>(sel)) fitPanel(p);\n };\n watchPanels(document);\n fitHud();\n window.addEventListener(\"resize\", refit, { signal });\n window.addEventListener(\"orientationchange\", refit, { signal });\n signal.addEventListener(\"abort\", () => {\n panelRo?.disconnect();\n hudRo?.disconnect();\n added?.disconnect();\n root.style.removeProperty(\"--vc-hud-zoom\");\n root.style.removeProperty(\"--vc-hud-h\");\n });\n}\n"
|
|
1795
1799
|
},
|
|
1796
1800
|
{
|
|
1797
1801
|
"path": "ui/NameEditor.ts",
|
|
1798
|
-
"content": "// Ввод имени для бирки: ПКМ биркой по мобу открывает строку (как редактор таблички), Enter даёт\n// имя, Esc отменяет. Имя вешает сам моб (Mob.setName), бирка тратится только при удаче.\nimport { events } from \"../core/EventBus\";\nimport type { Game } from \"../main\";\nimport { tr } from \"../i18n\";\n\nexport class NameEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private entityId: number | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"nameEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 24;\n\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:nameMob\", (p: { entityId: number }) => this.show(p.entityId));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(entityId: number): void {\n this.input.placeholder = tr({ en: \"Name\", ru: \"Имя\" });\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const m = g.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || m.dead) return;\n this.entityId = entityId;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = m.customName ?? \"\";\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.entityId = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Дать мобу имя и потратить бирку. */\n submit(): void {\n const id = this.entityId;\n const name = this.input.value.trim();\n this.hide();\n if (id === null || !name) return;\n const m = this.game.entities.entities.get(id);\n if (m?.kind !== \"mob\" || m.dead) return;\n m.setName(name);\n if (this.game.player.mode !== \"creative\")\n this.game.inventory.consumeHeld(1);\n events.emit(\"mobNamed\", { entityId: id, name });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1802
|
+
"content": "// Ввод имени для бирки: ПКМ биркой по мобу открывает строку (как редактор таблички), Enter даёт\n// имя, Esc отменяет. Имя вешает сам моб (Mob.setName), бирка тратится только при удаче.\nimport { events } from \"../core/EventBus\";\nimport type { Game } from \"../main\";\nimport { tr } from \"../i18n\";\n\nexport class NameEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private entityId: number | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"nameEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 24;\n\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.input.enterKeyHint = \"done\";\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:nameMob\", (p: { entityId: number }) => this.show(p.entityId));\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n // клавиатуру телефона спрятали (Esc там нет): закрыть без имени, бирка не тратится\n this.input.addEventListener(\"blur\", () => this.hide(), { signal });\n }\n\n show(entityId: number): void {\n this.input.placeholder = tr({ en: \"Name\", ru: \"Имя\" });\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const m = g.entities.entities.get(entityId);\n if (m?.kind !== \"mob\" || m.dead) return;\n this.entityId = entityId;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = m.customName ?? \"\";\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.entityId = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Дать мобу имя и потратить бирку. */\n submit(): void {\n const id = this.entityId;\n const name = this.input.value.trim();\n this.hide();\n if (id === null || !name) return;\n const m = this.game.entities.entities.get(id);\n if (m?.kind !== \"mob\" || m.dead) return;\n m.setName(name);\n if (this.game.player.mode !== \"creative\")\n this.game.inventory.consumeHeld(1);\n events.emit(\"mobNamed\", { entityId: id, name });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1799
1803
|
},
|
|
1800
1804
|
{
|
|
1801
1805
|
"path": "ui/NetUI.ts",
|
|
@@ -1807,7 +1811,7 @@
|
|
|
1807
1811
|
},
|
|
1808
1812
|
{
|
|
1809
1813
|
"path": "ui/quests/QuestTracker.ts",
|
|
1810
|
-
"content": "// Трекер заданий на экране: одно текущее задание с прогрессом, подсказка (точнее, если игрок застрял),\n// рецепт картинками, совет по ситуации и всплывашка «Задание выполнено!». Ближайшую цель подсказки\n// (блок, моб) ищет здесь же и отдаёт `goal()` — её показывают компас, миникарта, карта и метка в мире\n// (ui/map). Данные — game.quests (quests/QuestBook); здесь только вид.\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { matches, taskNeed } from \"../../quests/tasks\";\nimport type { RewardDef } from \"../../quests/types\";\nimport type { QuestEntry } from \"../../quests/QuestBook\";\nimport { lang, tr } from \"../../i18n\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { craftPlan, oreDepth, type PlanStep } from \"../../systems/Catalog\";\nimport { labelOf } from \"../../registry/names\";\nimport { stepText } from \"../catalog/howText\";\nimport type { Game } from \"../../main\";\nimport {\n isTouch,\n itemIcon,\n itemName,\n qt,\n questRecipes,\n recipeView,\n} from \"./strings\";\n\nconst TRACKER_KEY = \"voxelcraft:quests:tracker\";\nconst TIP_MS = 9000;\nconst TOAST_MS = 4000;\n/** Радиус поиска цели стрелки, блоков; поиск идёт по кусочку за кадр (SCAN_BUDGET блоков). */\nconst FIND_R = 64;\nconst FIND_DY = 16;\nconst SCAN_BUDGET = 60000;\n/** Руду ищем не глубже и не выше этого от игрока (дальше всё равно не дойти быстро). */\nconst ORE_DOWN = 64;\nconst ORE_UP = 24;\n\ntype Find = { blocks?: string[]; mobs?: string[] };\n\n/** Ведение по заданию: строки трекера и что искать стрелкой — по плану справочника. */\ninterface Guide {\n lines: string[];\n find: Find | null;\n /** Где встречается руда (для «копайте вниз», если рядом не нашлось). */\n depth: { min: number; max: number } | null;\n}\n\n/** Идущий поиск блоков: колонка за колонкой вокруг точки старта. */\ninterface Scan {\n blocks: string[];\n want: boolean[];\n cx: number;\n cz: number;\n ymin: number;\n ymax: number;\n x: number;\n best: THREE.Vector3 | null;\n bestD: number;\n depth: Guide[\"depth\"];\n}\n\nexport class QuestTracker {\n readonly root: HTMLDivElement;\n private readonly toast: HTMLDivElement;\n private sig = \"\";\n private tip: { text: string; until: number } | null = null;\n private toastQueue: { title: string; rewards: RewardDef[] }[] = [];\n private toastBusy = false;\n private renderT = 0;\n private findT = 0;\n private target: THREE.Vector3 | null = null;\n private targetOf: QuestEntry | null = null;\n private findKey = \"\";\n /** Ведение первого задания (пересчёт раз в полсекунды, в render). */\n private guide: Guide | null = null;\n private scanning: Scan | null = null;\n /** «Рядом не видно — копайте вниз до y …», если поиск ничего не нашёл. */\n private missText: string | null = null;\n /** Трекер включён (настройка игрока). */\n on: boolean;\n\n constructor(private readonly game: Game) {\n this.on = readPref(TRACKER_KEY);\n const host = document.getElementById(\"game\")!.parentElement!;\n this.root = div(\"questTracker\");\n this.toast = div(\"questToast\");\n this.toast.className = \"hidden\";\n host.append(this.root, this.toast);\n\n events.on(\"questsChanged\", () => (this.renderT = 1));\n events.on(\"questTip\", ({ text }: { text: string }) => {\n this.tip = { text, until: performance.now() + TIP_MS };\n this.renderT = 1;\n });\n events.on(\n \"questDone\",\n ({ title, rewards }: { title: string; rewards: RewardDef[] }) => {\n this.toastQueue.push({ title, rewards });\n this.nextToast();\n },\n );\n }\n\n setOn(on: boolean): void {\n this.on = on;\n writePref(TRACKER_KEY, on);\n this.renderT = 1;\n }\n\n private visible(): boolean {\n const g = this.game;\n return (\n this.on &&\n g.playing &&\n g.player.mode === \"survival\" &&\n !g.questBookUI?.open\n );\n }\n\n /** Цель подсказки текущего задания (ближайший подходящий блок или моб), если она найдена. */\n goal(): { pos: THREE.Vector3; title: string; icon: string } | null {\n const e = this.targetOf;\n if (!this.target || !e) return null;\n return { pos: this.target, title: tr(e.def.title), icon: e.def.icon };\n }\n\n /** Каждый кадр: поиск цели; раз в полсекунды — проверка, не пора ли перерисовать. */\n frame(dt: number): void {\n this.renderT += dt;\n if (this.renderT >= 0.5) {\n this.renderT = 0;\n this.render();\n }\n this.findT += dt;\n const found = this.findWhat();\n if (!found || !this.visible() || !this.game.quests.hintsOn) {\n this.target = null;\n this.targetOf = null;\n this.scanning = null;\n this.findKey = \"\";\n this.setMiss(null);\n return;\n }\n const [find, entry, depth] = found;\n this.targetOf = entry;\n const key = JSON.stringify(find);\n if (key !== this.findKey) {\n // цель сменилась — старая стрелка больше не про то\n this.findKey = key;\n this.target = null;\n this.setMiss(null);\n this.startScan(find, depth);\n } else if (!this.scanning && this.findT >= 1.5) this.startScan(find, depth);\n if (this.scanning) this.stepScan();\n }\n\n /**\n * Что искать стрелкой: сырьё по плану справочника (ведёт до конца: 3 руды выкопано, нужно 8 —\n * ищет следующую), иначе — метка подсказки задания, когда она открылась.\n */\n private findWhat(): [Find, QuestEntry, Guide[\"depth\"]] | null {\n const e = this.game.quests.focus()[0];\n if (!e || !e.open) return null;\n if (this.guide?.find) return [this.guide.find, e, this.guide.depth];\n const hs = this.game.quests.hints(e);\n for (let i = hs.length - 1; i >= 0; i--) {\n const f = hs[i]!.find;\n if (!f) continue;\n const b = f.blocks?.length === 1 ? f.blocks[0]! : \"\";\n return [f, e, oreDepth(b)];\n }\n return null;\n }\n\n private setMiss(text: string | null): void {\n if (text === this.missText) return;\n this.missText = text;\n this.renderT = 1;\n }\n\n /** Новый поиск: мобы — сразу, блоки — по кусочку за кадр (stepScan). */\n private startScan(find: Find, depth: Guide[\"depth\"]): void {\n this.findT = 0;\n const g = this.game;\n const p = g.player.pos;\n let best: THREE.Vector3 | null = null;\n let bestD = Infinity;\n const mobs = find.mobs;\n if (mobs?.length)\n for (const e of g.entities.entities.values()) {\n if (e.kind !== \"mob\" || e.dead) continue;\n const m = e as unknown as {\n def: { name: string; category?: string };\n pos: THREE.Vector3;\n height: number;\n };\n if (!matches(mobs, m.def.name, m.def.category)) continue;\n const d = m.pos.distanceToSquared(p);\n if (d < bestD && d < 96 * 96) {\n bestD = d;\n best = new THREE.Vector3(m.pos.x, m.pos.y + m.height / 2, m.pos.z);\n }\n }\n const blocks = find.blocks;\n if (!blocks?.length) {\n this.target = best;\n if (best) this.setMiss(null);\n return;\n }\n const py = Math.floor(p.y);\n // руда — на своих высотах (железо — y 5–72), остальное — около игрока\n const ymin = depth ? Math.max(depth.min, py - ORE_DOWN) : py - FIND_DY;\n const ymax = depth ? Math.min(depth.max, py + ORE_UP) : py + FIND_DY;\n if (ymin > ymax) {\n this.target = best;\n this.missNear(blocks, depth, best);\n return;\n }\n this.scanning = {\n blocks,\n want: BLOCKS.map((b) => matches(blocks, b.name)),\n cx: Math.floor(p.x),\n cz: Math.floor(p.z),\n ymin,\n ymax,\n x: -FIND_R,\n best,\n bestD,\n depth,\n };\n }\n\n /** Кусочек поиска; дошли до края — ближайшая находка становится целью. */\n private stepScan(): void {\n const s = this.scanning!;\n const g = this.game;\n const py = Math.floor(g.player.pos.y);\n let budget = SCAN_BUDGET;\n while (s.x <= FIND_R && budget > 0) {\n const x = s.cx + s.x;\n for (let z = s.cz - FIND_R; z <= s.cz + FIND_R; z++)\n for (let y = s.ymin; y <= s.ymax; y++) {\n const dx = x - s.cx,\n dy = y - py,\n dz = z - s.cz;\n const d = dx * dx + dy * dy + dz * dz;\n if (d >= s.bestD) continue;\n if (!s.want[g.world.getBlock(x, y, z)]) continue;\n s.bestD = d;\n s.best = new THREE.Vector3(x + 0.5, y + 0.5, z + 0.5);\n }\n budget -= (2 * FIND_R + 1) * (s.ymax - s.ymin + 1);\n s.x++;\n }\n if (s.x <= FIND_R) return;\n this.scanning = null;\n this.target = s.best;\n this.missNear(s.blocks, s.depth, s.best);\n }\n\n /** Ничего не нашлось — подсказать, где искать дальше. */\n private missNear(\n blocks: string[],\n depth: Guide[\"depth\"],\n found: THREE.Vector3 | null,\n ): void {\n if (found) return this.setMiss(null);\n const b =\n blocks.length === 1 && !/[*#@]/.test(blocks[0]!) ? blocks[0]! : \"\";\n const name = b ? labelOf(b) : \"\";\n this.setMiss(\n depth\n ? tr({\n en: `No ${name.toLowerCase()} nearby. Dig down to y ${depth.min}–${depth.max} and look in caves — the marker appears when one is close.`,\n ru: `Рядом не видно: ${name.toLowerCase()}. Копайте вниз до y ${depth.min}–${depth.max} и ищите в пещерах — метка появится, когда руда будет близко.`,\n })\n : tr({\n en: `No ${name.toLowerCase() || \"target\"} nearby — explore further, the marker appears when one is close.`,\n ru: `Рядом не видно${name ? \": \" + name.toLowerCase() : \"\"} — идите дальше, метка появится, когда цель будет близко.`,\n }),\n );\n }\n\n private render(): void {\n const g = this.game;\n const book = g.quests;\n const vis = this.visible();\n const now = performance.now();\n if (this.tip && this.tip.until < now) this.tip = null;\n const focus = vis ? book.focus() : [];\n this.guide = focus[0] && book.hintsOn ? this.guideOf(focus[0]) : null;\n const step = this.guide?.lines ?? [];\n if (this.missText) step.push(this.missText);\n const sig = JSON.stringify([\n step,\n vis,\n book.hintsOn,\n this.tip?.text,\n focus.map((e) => [e.id, e.state.p, e.claimable, book.hints(e).length]),\n isTouch(),\n lang(), // сменили язык — перерисовать\n ]);\n if (sig === this.sig) return;\n this.sig = sig;\n this.root.innerHTML = \"\";\n this.root.classList.toggle(\"hidden\", !vis);\n if (!vis) return;\n if (this.tip && book.hintsOn) {\n const tip = div();\n tip.className = \"qTip\";\n tip.textContent = \"💡 \" + this.tip.text;\n this.root.append(tip);\n }\n for (const e of focus) this.root.append(this.card(e, step));\n if (focus.length === 0) {\n const none = div();\n none.className = \"qNote\";\n none.textContent = qt(\"allDone\");\n this.root.append(none);\n }\n const foot = div();\n foot.className = \"qFoot\";\n foot.textContent = isTouch() ? qt(\"bookTouch\") : qt(\"bookKey\");\n this.root.append(foot);\n }\n\n /**\n * Ведение к предмету первой невыполненной задачи «сделать / иметь / надеть» — по дереву справочника\n * и инвентарю: следующий шаг («Сделайте 3 × железный слиток — печь»), сырьё, которого ещё нет\n * («Добудьте 5 × сырое железо — …»), и что искать стрелкой. Из нескольких предметов (любая\n * железная броня) — тот, что в рецепте подсказки, иначе самый дешёвый.\n */\n private guideOf(e: QuestEntry): Guide | null {\n if (e.claimable || !e.open) return null;\n const inv = this.game.inventory;\n const have = (id: string): number => inv.count(id);\n const hinted = (e.def.hints ?? []).map((h) => h.recipe).filter(Boolean);\n for (let i = 0; i < e.def.tasks.length; i++) {\n const t = e.def.tasks[i]!;\n if (t.kind !== \"craft\" && t.kind !== \"have\" && t.kind !== \"wear\")\n continue;\n const need = taskNeed(t);\n const done = e.state.p[i] ?? 0;\n if (done >= need) continue;\n const items = (t.of ?? []).filter((x) => !/[*#@]/.test(x) && !!ITEMS[x]);\n if (!items.length) continue;\n const n = t.kind === \"wear\" ? 1 : need - done;\n // «сделать» и «добыть» считают новые штуки — то, что уже лежит, не в счёт\n const plans = items.map(\n (x) =>\n [\n x,\n craftPlan(\n x,\n n,\n t.kind === \"wear\" ? have : (id) => (id === x ? 0 : have(id)),\n ),\n ] as const,\n );\n const missing = (p: (typeof plans)[number][1]): number =>\n [...p.missing.values()].reduce((a, b) => a + b, 0);\n const pick =\n plans.find(([x]) => hinted.includes(x)) ??\n plans.slice().sort((a, b) => missing(a[1]) - missing(b[1]))[0]!;\n const plan = pick[1];\n if (!plan.next) continue;\n const lines = [stepText(plan.next)];\n // делать уже есть что, но сырья на всё не хватит — где брать остальное\n if (plan.next.kind === \"make\" && plan.gather)\n lines.push(stepText(plan.gather));\n return { lines, ...findOf(plan.gather) };\n }\n return null;\n }\n\n private card(e: QuestEntry, step: string[] = []): HTMLElement {\n const book = this.game.quests;\n const card = div();\n card.className = \"qCard\";\n const head = div();\n head.className = \"qHead\";\n const title = document.createElement(\"b\");\n title.textContent = tr(e.def.title);\n head.append(itemIcon(e.def.icon), title);\n card.append(head);\n if (e.claimable) {\n const n = div();\n n.className = \"qNote\";\n n.textContent = qt(\"claimInBook\");\n card.append(n);\n return card;\n }\n e.def.tasks.forEach((t, i) => {\n const need = taskNeed(t);\n const have = Math.min(need, e.state.p[i] ?? 0);\n const row = div();\n row.className = \"qTask\" + (have >= need ? \" ok\" : \"\");\n row.textContent =\n (have >= need ? \"✔ \" : \"• \") +\n tr(t.label) +\n (need > 1 ? ` ${have}/${need}` : \"\");\n card.append(row);\n });\n if (!book.hintsOn) return card;\n if (book.focus()[0] === e)\n for (const line of step) {\n const n = div();\n n.className = \"qNext\";\n n.textContent = \"➜ \" + line;\n card.append(n);\n }\n const hs = book.hints(e);\n // главная подсказка всегда, уточнение (стрелка, «копай вниз») — под ней, когда открылось\n const shown = hs.length > 1 ? [hs[0]!, hs[hs.length - 1]!] : hs;\n if (shown.length) {\n for (const h of shown) {\n const p = div();\n p.className = \"qHint\";\n p.textContent = book.hintText(h);\n card.append(p);\n }\n // рецепт ближайшей невыполненной задачи (одна картинка — трекер маленький)\n const rec = questRecipes(\n e.def,\n e.state.p,\n hs.map((x) => x.recipe),\n )[0];\n const view = rec ? recipeView(rec) : null;\n if (view) card.append(view);\n } else if (book.focus()[0] === e) {\n const p = div();\n p.className = \"qHint\";\n p.textContent = book.descText(e.def);\n card.append(p);\n }\n return card;\n }\n\n private nextToast(): void {\n if (this.toastBusy) return;\n const t = this.toastQueue.shift();\n if (!t) return;\n this.toastBusy = true;\n this.toast.innerHTML = \"\";\n const head = div();\n head.className = \"qtHead\";\n head.textContent = qt(\"questDone\");\n const body = div();\n body.className = \"qtBody\";\n body.textContent = t.title;\n this.toast.append(head, body);\n if (t.rewards.length) {\n const r = div();\n r.className = \"qtRewards\";\n for (const x of t.rewards) {\n const span = document.createElement(\"span\");\n span.append(itemIcon(x.item), ` ${x.count} × ${itemName(x.item)}`);\n r.append(span);\n }\n this.toast.append(r);\n }\n this.toast.classList.remove(\"hidden\");\n setTimeout(() => {\n this.toast.classList.add(\"hidden\");\n this.toastBusy = false;\n setTimeout(() => this.nextToast(), 250);\n }, TOAST_MS);\n }\n\n dispose(): void {\n this.root.remove();\n this.toast.remove();\n }\n}\n\nfunction div(id?: string): HTMLDivElement {\n const d = document.createElement(\"div\");\n if (id) d.id = id;\n return d;\n}\n\nexport function readPref(key: string): boolean {\n try {\n return localStorage.getItem(key) !== \"0\";\n } catch {\n return true;\n }\n}\n\nexport function writePref(key: string, on: boolean): void {\n try {\n localStorage.setItem(key, on ? \"1\" : \"0\");\n } catch {\n // приватное окно — до перезагрузки\n }\n}\n\n/** Что искать стрелкой для шага «добыть»: блок (и его высоты) или моба. */\nfunction findOf(step: PlanStep | null): Pick<Guide, \"find\" | \"depth\"> {\n const src = step?.source;\n if (src?.kind === \"mine\")\n return { find: { blocks: [src.block] }, depth: oreDepth(src.block) };\n if (src?.kind === \"mob\") return { find: { mobs: [src.mob] }, depth: null };\n return { find: null, depth: null };\n}\n"
|
|
1814
|
+
"content": "// Трекер заданий на экране: одно текущее задание с прогрессом, подсказка (точнее, если игрок застрял),\n// рецепт картинками, совет по ситуации и всплывашка «Задание выполнено!». Ближайшую цель подсказки\n// (блок, моб) ищет здесь же и отдаёт `goal()` — её показывают компас, миникарта, карта и метка в мире\n// (ui/map). Данные — game.quests (quests/QuestBook); здесь только вид.\n// Коротко (05.11.2026, владелец: «задание меньше, детали не расписывать, детали, если на задание\n// нажать»): карточка = значок, название и прогресс задач. Шаг, подсказки, рецепт и описание\n// раскрываются касанием (кликом) по карточке и сворачиваются повторным.\nimport * as THREE from \"three\";\nimport { events } from \"../../core/EventBus\";\nimport { BLOCKS } from \"../../registry/Blocks\";\nimport { matches, taskNeed } from \"../../quests/tasks\";\nimport type { RewardDef } from \"../../quests/types\";\nimport type { QuestEntry } from \"../../quests/QuestBook\";\nimport { lang, tr } from \"../../i18n\";\nimport { ITEMS } from \"../../registry/Items\";\nimport { craftPlan, oreDepth, type PlanStep } from \"../../systems/Catalog\";\nimport { labelOf } from \"../../registry/names\";\nimport { stepText } from \"../catalog/howText\";\nimport type { Game } from \"../../main\";\nimport {\n isTouch,\n itemIcon,\n itemName,\n qt,\n questRecipes,\n recipeView,\n} from \"./strings\";\n\nconst TRACKER_KEY = \"voxelcraft:quests:tracker\";\nconst TIP_MS = 9000;\nconst TOAST_MS = 4000;\n/** Радиус поиска цели стрелки, блоков; поиск идёт по кусочку за кадр (SCAN_BUDGET блоков). */\nconst FIND_R = 64;\nconst FIND_DY = 16;\nconst SCAN_BUDGET = 60000;\n/** Руду ищем не глубже и не выше этого от игрока (дальше всё равно не дойти быстро). */\nconst ORE_DOWN = 64;\nconst ORE_UP = 24;\n\ntype Find = { blocks?: string[]; mobs?: string[] };\n\n/** Ведение по заданию: строки трекера и что искать стрелкой — по плану справочника. */\ninterface Guide {\n lines: string[];\n find: Find | null;\n /** Где встречается руда (для «копайте вниз», если рядом не нашлось). */\n depth: { min: number; max: number } | null;\n}\n\n/** Идущий поиск блоков: колонка за колонкой вокруг точки старта. */\ninterface Scan {\n blocks: string[];\n want: boolean[];\n cx: number;\n cz: number;\n ymin: number;\n ymax: number;\n x: number;\n best: THREE.Vector3 | null;\n bestD: number;\n depth: Guide[\"depth\"];\n}\n\nexport class QuestTracker {\n readonly root: HTMLDivElement;\n private readonly toast: HTMLDivElement;\n private sig = \"\";\n private tip: { text: string; until: number } | null = null;\n private toastQueue: { title: string; rewards: RewardDef[] }[] = [];\n private toastBusy = false;\n private renderT = 0;\n private findT = 0;\n private target: THREE.Vector3 | null = null;\n private targetOf: QuestEntry | null = null;\n private findKey = \"\";\n /** Ведение первого задания (пересчёт раз в полсекунды, в render). */\n private guide: Guide | null = null;\n private scanning: Scan | null = null;\n /** «Рядом не видно — копайте вниз до y …», если поиск ничего не нашёл. */\n private missText: string | null = null;\n /** Трекер включён (настройка игрока). */\n on: boolean;\n /** Задание, у которого раскрыты детали (касание по карточке); null — все коротко. */\n private expanded: string | null = null;\n\n constructor(private readonly game: Game) {\n this.on = readPref(TRACKER_KEY);\n const host = document.getElementById(\"game\")!.parentElement!;\n this.root = div(\"questTracker\");\n this.toast = div(\"questToast\");\n this.toast.className = \"hidden\";\n host.append(this.root, this.toast);\n\n events.on(\"questsChanged\", () => (this.renderT = 1));\n events.on(\"questTip\", ({ text }: { text: string }) => {\n this.tip = { text, until: performance.now() + TIP_MS };\n this.renderT = 1;\n });\n events.on(\n \"questDone\",\n ({ title, rewards }: { title: string; rewards: RewardDef[] }) => {\n this.toastQueue.push({ title, rewards });\n this.nextToast();\n },\n );\n }\n\n setOn(on: boolean): void {\n this.on = on;\n writePref(TRACKER_KEY, on);\n this.renderT = 1;\n }\n\n private visible(): boolean {\n const g = this.game;\n return (\n this.on &&\n g.playing &&\n g.player.mode === \"survival\" &&\n !g.questBookUI?.open\n );\n }\n\n /** Цель подсказки текущего задания (ближайший подходящий блок или моб), если она найдена. */\n goal(): { pos: THREE.Vector3; title: string; icon: string } | null {\n const e = this.targetOf;\n if (!this.target || !e) return null;\n return { pos: this.target, title: tr(e.def.title), icon: e.def.icon };\n }\n\n /** Каждый кадр: поиск цели; раз в полсекунды — проверка, не пора ли перерисовать. */\n frame(dt: number): void {\n this.renderT += dt;\n if (this.renderT >= 0.5) {\n this.renderT = 0;\n this.render();\n }\n this.findT += dt;\n const found = this.findWhat();\n if (!found || !this.visible() || !this.game.quests.hintsOn) {\n this.target = null;\n this.targetOf = null;\n this.scanning = null;\n this.findKey = \"\";\n this.setMiss(null);\n return;\n }\n const [find, entry, depth] = found;\n this.targetOf = entry;\n const key = JSON.stringify(find);\n if (key !== this.findKey) {\n // цель сменилась — старая стрелка больше не про то\n this.findKey = key;\n this.target = null;\n this.setMiss(null);\n this.startScan(find, depth);\n } else if (!this.scanning && this.findT >= 1.5) this.startScan(find, depth);\n if (this.scanning) this.stepScan();\n }\n\n /**\n * Что искать стрелкой: сырьё по плану справочника (ведёт до конца: 3 руды выкопано, нужно 8 —\n * ищет следующую), иначе — метка подсказки задания, когда она открылась.\n */\n private findWhat(): [Find, QuestEntry, Guide[\"depth\"]] | null {\n const e = this.game.quests.focus()[0];\n if (!e || !e.open) return null;\n if (this.guide?.find) return [this.guide.find, e, this.guide.depth];\n const hs = this.game.quests.hints(e);\n for (let i = hs.length - 1; i >= 0; i--) {\n const f = hs[i]!.find;\n if (!f) continue;\n const b = f.blocks?.length === 1 ? f.blocks[0]! : \"\";\n return [f, e, oreDepth(b)];\n }\n return null;\n }\n\n private setMiss(text: string | null): void {\n if (text === this.missText) return;\n this.missText = text;\n this.renderT = 1;\n }\n\n /** Новый поиск: мобы — сразу, блоки — по кусочку за кадр (stepScan). */\n private startScan(find: Find, depth: Guide[\"depth\"]): void {\n this.findT = 0;\n const g = this.game;\n const p = g.player.pos;\n let best: THREE.Vector3 | null = null;\n let bestD = Infinity;\n const mobs = find.mobs;\n if (mobs?.length)\n for (const e of g.entities.entities.values()) {\n if (e.kind !== \"mob\" || e.dead) continue;\n const m = e as unknown as {\n def: { name: string; category?: string };\n pos: THREE.Vector3;\n height: number;\n };\n if (!matches(mobs, m.def.name, m.def.category)) continue;\n const d = m.pos.distanceToSquared(p);\n if (d < bestD && d < 96 * 96) {\n bestD = d;\n best = new THREE.Vector3(m.pos.x, m.pos.y + m.height / 2, m.pos.z);\n }\n }\n const blocks = find.blocks;\n if (!blocks?.length) {\n this.target = best;\n if (best) this.setMiss(null);\n return;\n }\n const py = Math.floor(p.y);\n // руда — на своих высотах (железо — y 5–72), остальное — около игрока\n const ymin = depth ? Math.max(depth.min, py - ORE_DOWN) : py - FIND_DY;\n const ymax = depth ? Math.min(depth.max, py + ORE_UP) : py + FIND_DY;\n if (ymin > ymax) {\n this.target = best;\n this.missNear(blocks, depth, best);\n return;\n }\n this.scanning = {\n blocks,\n want: BLOCKS.map((b) => matches(blocks, b.name)),\n cx: Math.floor(p.x),\n cz: Math.floor(p.z),\n ymin,\n ymax,\n x: -FIND_R,\n best,\n bestD,\n depth,\n };\n }\n\n /** Кусочек поиска; дошли до края — ближайшая находка становится целью. */\n private stepScan(): void {\n const s = this.scanning!;\n const g = this.game;\n const py = Math.floor(g.player.pos.y);\n let budget = SCAN_BUDGET;\n while (s.x <= FIND_R && budget > 0) {\n const x = s.cx + s.x;\n for (let z = s.cz - FIND_R; z <= s.cz + FIND_R; z++)\n for (let y = s.ymin; y <= s.ymax; y++) {\n const dx = x - s.cx,\n dy = y - py,\n dz = z - s.cz;\n const d = dx * dx + dy * dy + dz * dz;\n if (d >= s.bestD) continue;\n if (!s.want[g.world.getBlock(x, y, z)]) continue;\n s.bestD = d;\n s.best = new THREE.Vector3(x + 0.5, y + 0.5, z + 0.5);\n }\n budget -= (2 * FIND_R + 1) * (s.ymax - s.ymin + 1);\n s.x++;\n }\n if (s.x <= FIND_R) return;\n this.scanning = null;\n this.target = s.best;\n this.missNear(s.blocks, s.depth, s.best);\n }\n\n /** Ничего не нашлось — подсказать, где искать дальше. */\n private missNear(\n blocks: string[],\n depth: Guide[\"depth\"],\n found: THREE.Vector3 | null,\n ): void {\n if (found) return this.setMiss(null);\n const b =\n blocks.length === 1 && !/[*#@]/.test(blocks[0]!) ? blocks[0]! : \"\";\n const name = b ? labelOf(b) : \"\";\n this.setMiss(\n depth\n ? tr({\n en: `No ${name.toLowerCase()} nearby. Dig down to y ${depth.min}–${depth.max} and look in caves — the marker appears when one is close.`,\n ru: `Рядом не видно: ${name.toLowerCase()}. Копайте вниз до y ${depth.min}–${depth.max} и ищите в пещерах — метка появится, когда руда будет близко.`,\n })\n : tr({\n en: `No ${name.toLowerCase() || \"target\"} nearby — explore further, the marker appears when one is close.`,\n ru: `Рядом не видно${name ? \": \" + name.toLowerCase() : \"\"} — идите дальше, метка появится, когда цель будет близко.`,\n }),\n );\n }\n\n private render(): void {\n const g = this.game;\n const book = g.quests;\n const vis = this.visible();\n const now = performance.now();\n if (this.tip && this.tip.until < now) this.tip = null;\n const focus = vis ? book.focus() : [];\n this.guide = focus[0] && book.hintsOn ? this.guideOf(focus[0]) : null;\n const step = this.guide?.lines ?? [];\n if (this.missText) step.push(this.missText);\n const sig = JSON.stringify([\n step,\n vis,\n book.hintsOn,\n this.tip?.text,\n focus.map((e) => [e.id, e.state.p, e.claimable, book.hints(e).length]),\n isTouch(),\n this.expanded,\n lang(), // сменили язык — перерисовать\n ]);\n if (sig === this.sig) return;\n this.sig = sig;\n this.root.innerHTML = \"\";\n this.root.classList.toggle(\"hidden\", !vis);\n if (!vis) return;\n if (this.tip && book.hintsOn) {\n const tip = div();\n tip.className = \"qTip\";\n tip.textContent = \"💡 \" + this.tip.text;\n this.root.append(tip);\n }\n for (const e of focus) this.root.append(this.card(e, step));\n if (focus.length === 0) {\n const none = div();\n none.className = \"qNote\";\n none.textContent = qt(\"allDone\");\n this.root.append(none);\n }\n if (!this.expanded) return;\n const foot = div();\n foot.className = \"qFoot\";\n foot.textContent = isTouch() ? qt(\"bookTouch\") : qt(\"bookKey\");\n this.root.append(foot);\n }\n\n /** Касание по карточке: раскрыть её детали или свернуть. */\n private toggle(id: string): void {\n this.expanded = this.expanded === id ? null : id;\n this.render();\n }\n\n /**\n * Ведение к предмету первой невыполненной задачи «сделать / иметь / надеть» — по дереву справочника\n * и инвентарю: следующий шаг («Сделайте 3 × железный слиток — печь»), сырьё, которого ещё нет\n * («Добудьте 5 × сырое железо — …»), и что искать стрелкой. Из нескольких предметов (любая\n * железная броня) — тот, что в рецепте подсказки, иначе самый дешёвый.\n */\n private guideOf(e: QuestEntry): Guide | null {\n if (e.claimable || !e.open) return null;\n const inv = this.game.inventory;\n const have = (id: string): number => inv.count(id);\n const hinted = (e.def.hints ?? []).map((h) => h.recipe).filter(Boolean);\n for (let i = 0; i < e.def.tasks.length; i++) {\n const t = e.def.tasks[i]!;\n if (t.kind !== \"craft\" && t.kind !== \"have\" && t.kind !== \"wear\")\n continue;\n const need = taskNeed(t);\n const done = e.state.p[i] ?? 0;\n if (done >= need) continue;\n const items = (t.of ?? []).filter((x) => !/[*#@]/.test(x) && !!ITEMS[x]);\n if (!items.length) continue;\n const n = t.kind === \"wear\" ? 1 : need - done;\n // «сделать» и «добыть» считают новые штуки — то, что уже лежит, не в счёт\n const plans = items.map(\n (x) =>\n [\n x,\n craftPlan(\n x,\n n,\n t.kind === \"wear\" ? have : (id) => (id === x ? 0 : have(id)),\n ),\n ] as const,\n );\n const missing = (p: (typeof plans)[number][1]): number =>\n [...p.missing.values()].reduce((a, b) => a + b, 0);\n const pick =\n plans.find(([x]) => hinted.includes(x)) ??\n plans.slice().sort((a, b) => missing(a[1]) - missing(b[1]))[0]!;\n const plan = pick[1];\n if (!plan.next) continue;\n const lines = [stepText(plan.next)];\n // делать уже есть что, но сырья на всё не хватит — где брать остальное\n if (plan.next.kind === \"make\" && plan.gather)\n lines.push(stepText(plan.gather));\n return { lines, ...findOf(plan.gather) };\n }\n return null;\n }\n\n private card(e: QuestEntry, step: string[] = []): HTMLElement {\n const book = this.game.quests;\n const card = div();\n const open = this.expanded === e.id;\n card.className = \"qCard\" + (open ? \" open\" : \"\");\n card.addEventListener(\"pointerdown\", (ev) => {\n ev.preventDefault();\n ev.stopPropagation();\n this.toggle(e.id);\n });\n const head = div();\n head.className = \"qHead\";\n const title = document.createElement(\"b\");\n title.textContent = tr(e.def.title);\n const more = document.createElement(\"span\");\n more.className = \"qMore\";\n more.textContent = open ? \"▴\" : \"▾\";\n head.append(itemIcon(e.def.icon), title, more);\n card.append(head);\n if (e.claimable) {\n const n = div();\n n.className = \"qNote\";\n n.textContent = qt(\"claimInBook\");\n card.append(n);\n return card;\n }\n e.def.tasks.forEach((t, i) => {\n const need = taskNeed(t);\n const have = Math.min(need, e.state.p[i] ?? 0);\n const row = div();\n row.className = \"qTask\" + (have >= need ? \" ok\" : \"\");\n row.textContent =\n (have >= need ? \"✔ \" : \"• \") +\n tr(t.label) +\n (need > 1 ? ` ${have}/${need}` : \"\");\n card.append(row);\n });\n if (!open || !book.hintsOn) return card;\n if (book.focus()[0] === e)\n for (const line of step) {\n const n = div();\n n.className = \"qNext\";\n n.textContent = \"➜ \" + line;\n card.append(n);\n }\n const hs = book.hints(e);\n // главная подсказка всегда, уточнение (стрелка, «копай вниз») — под ней, когда открылось\n const shown = hs.length > 1 ? [hs[0]!, hs[hs.length - 1]!] : hs;\n if (shown.length) {\n for (const h of shown) {\n const p = div();\n p.className = \"qHint\";\n p.textContent = book.hintText(h);\n card.append(p);\n }\n // рецепт ближайшей невыполненной задачи (одна картинка — трекер маленький)\n const rec = questRecipes(\n e.def,\n e.state.p,\n hs.map((x) => x.recipe),\n )[0];\n const view = rec ? recipeView(rec) : null;\n if (view) card.append(view);\n } else if (book.focus()[0] === e) {\n const p = div();\n p.className = \"qHint\";\n p.textContent = book.descText(e.def);\n card.append(p);\n }\n return card;\n }\n\n private nextToast(): void {\n if (this.toastBusy) return;\n const t = this.toastQueue.shift();\n if (!t) return;\n this.toastBusy = true;\n this.toast.innerHTML = \"\";\n const head = div();\n head.className = \"qtHead\";\n head.textContent = qt(\"questDone\");\n const body = div();\n body.className = \"qtBody\";\n body.textContent = t.title;\n this.toast.append(head, body);\n if (t.rewards.length) {\n const r = div();\n r.className = \"qtRewards\";\n for (const x of t.rewards) {\n const span = document.createElement(\"span\");\n span.append(itemIcon(x.item), ` ${x.count} × ${itemName(x.item)}`);\n r.append(span);\n }\n this.toast.append(r);\n }\n this.toast.classList.remove(\"hidden\");\n setTimeout(() => {\n this.toast.classList.add(\"hidden\");\n this.toastBusy = false;\n setTimeout(() => this.nextToast(), 250);\n }, TOAST_MS);\n }\n\n dispose(): void {\n this.root.remove();\n this.toast.remove();\n }\n}\n\nfunction div(id?: string): HTMLDivElement {\n const d = document.createElement(\"div\");\n if (id) d.id = id;\n return d;\n}\n\nexport function readPref(key: string): boolean {\n try {\n return localStorage.getItem(key) !== \"0\";\n } catch {\n return true;\n }\n}\n\nexport function writePref(key: string, on: boolean): void {\n try {\n localStorage.setItem(key, on ? \"1\" : \"0\");\n } catch {\n // приватное окно — до перезагрузки\n }\n}\n\n/** Что искать стрелкой для шага «добыть»: блок (и его высоты) или моба. */\nfunction findOf(step: PlanStep | null): Pick<Guide, \"find\" | \"depth\"> {\n const src = step?.source;\n if (src?.kind === \"mine\")\n return { find: { blocks: [src.block] }, depth: oreDepth(src.block) };\n if (src?.kind === \"mob\") return { find: { mobs: [src.mob] }, depth: null };\n return { find: null, depth: null };\n}\n"
|
|
1811
1815
|
},
|
|
1812
1816
|
{
|
|
1813
1817
|
"path": "ui/quests/strings.ts",
|
|
@@ -1819,7 +1823,7 @@
|
|
|
1819
1823
|
},
|
|
1820
1824
|
{
|
|
1821
1825
|
"path": "ui/SignEditor.ts",
|
|
1822
|
-
"content": "// Ввод текста таблички: ПКМ по табличке открывает строку (как консоль чата), Enter сохраняет,\n// Esc отменяет. Разбивку по строкам делает systems/SignText, показ в мире — gfx/SignText.\nimport { events } from \"../core/EventBus\";\nimport { wrapSignText } from \"../systems/SignText\";\nimport type { SignState } from \"../world/blockentities/sign\";\nimport type { Game } from \"../main\";\n\nexport class SignEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private at: { x: number; y: number; z: number } | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"signEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 96;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openSign\", (p: { x: number; y: number; z: number }) =>\n this.show(p),\n );\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n }\n\n show(p: { x: number; y: number; z: number }): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.get(p.x, p.y, p.z);\n if (be?.type !== \"sign\") return;\n this.at = p;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = (be.state as SignState).lines.join(\" \");\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.at = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Сохранить набранное в табличку. */\n submit(): void {\n const at = this.at;\n const text = this.input.value;\n this.hide();\n if (!at) return;\n const be = this.game.blockEntities.get(at.x, at.y, at.z);\n if (be?.type !== \"sign\") return;\n (be.state as SignState).lines = wrapSignText(text);\n events.emit(\"signEdited\", { ...at, text });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1826
|
+
"content": "// Ввод текста таблички: ПКМ по табличке открывает строку (как консоль чата), Enter сохраняет,\n// Esc отменяет. Разбивку по строкам делает systems/SignText, показ в мире — gfx/SignText.\nimport { events } from \"../core/EventBus\";\nimport { wrapSignText } from \"../systems/SignText\";\nimport type { SignState } from \"../world/blockentities/sign\";\nimport type { Game } from \"../main\";\n\nexport class SignEditor {\n open = false;\n readonly root: HTMLDivElement;\n private readonly input: HTMLInputElement;\n private at: { x: number; y: number; z: number } | null = null;\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = document.createElement(\"div\");\n this.root.id = \"signEditor\";\n this.root.className = \"chat\";\n this.input = document.createElement(\"input\");\n this.input.className = \"chatInput hidden\";\n this.input.maxLength = 96;\n this.input.autocomplete = \"off\";\n this.input.spellcheck = false;\n this.input.enterKeyHint = \"done\";\n this.root.append(this.input);\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n\n events.on(\"ui:openSign\", (p: { x: number; y: number; z: number }) =>\n this.show(p),\n );\n this.input.addEventListener(\n \"keydown\",\n (e) => {\n e.stopPropagation(); // клавиши строки — не игре\n if (e.key === \"Enter\") this.submit();\n else if (e.key === \"Escape\") this.hide();\n },\n { signal },\n );\n // клавиатуру телефона спрятали (Esc там нет): сохранить набранное и закрыть, игра не стоит\n this.input.addEventListener(\n \"blur\",\n () => {\n if (this.open) this.submit();\n },\n { signal },\n );\n }\n\n show(p: { x: number; y: number; z: number }): void {\n const g = this.game;\n if (this.open || !g.playing || g.invUI.open || g.chat.open) return;\n const be = g.blockEntities.get(p.x, p.y, p.z);\n if (be?.type !== \"sign\") return;\n this.at = p;\n this.open = true;\n g.input.uiOpen = true; // до releaseLock: иначе выход из захвата поставил бы паузу\n g.input.releaseLock();\n this.root.classList.add(\"open\");\n this.input.classList.remove(\"hidden\");\n this.input.value = (be.state as SignState).lines.join(\" \");\n this.input.focus();\n }\n\n hide(): void {\n if (!this.open) return;\n this.open = false;\n this.at = null;\n this.root.classList.remove(\"open\");\n this.input.classList.add(\"hidden\");\n this.input.blur();\n this.game.input.uiOpen = false;\n // мёртвому мышь не захватываем: иначе на экране смерти нечем нажать «Возродиться»\n if (!this.game.player.dead) this.game.input.requestLock();\n }\n\n /** Сохранить набранное в табличку. */\n submit(): void {\n const at = this.at;\n const text = this.input.value;\n this.hide();\n if (!at) return;\n const be = this.game.blockEntities.get(at.x, at.y, at.z);\n if (be?.type !== \"sign\") return;\n (be.state as SignState).lines = wrapSignText(text);\n events.emit(\"signEdited\", { ...at, text });\n }\n\n dispose(): void {\n this.root.remove();\n }\n}\n"
|
|
1823
1827
|
},
|
|
1824
1828
|
{
|
|
1825
1829
|
"path": "ui/slotView.ts",
|
|
@@ -1827,7 +1831,11 @@
|
|
|
1827
1831
|
},
|
|
1828
1832
|
{
|
|
1829
1833
|
"path": "ui/TouchControls.ts",
|
|
1830
|
-
"content": "// Сенсорное управление (телефон, планшет): джойстик слева, обзор перетаскиванием справа, кнопки.\n// Пишет в те же поля Input, что клавиатура и мышь (keys, dx/dy, leftDown/rightDown, sprintLatch), —\n// второго пути ввода нет, поэтому вся игровая логика и агент работают как с клавиатурой.\n//\n// Жесты зоны обзора — как в Minecraft для телефона: короткий тап — поставить блок / открыть дверь,\n// удержание без движения — ломать, перетаскивание — поворот камеры.\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport { SNEAK_KEYS } from \"../player/Input\";\nimport type { Game } from \"../main\";\n\nconst LOOK_SENS = 2.2; // «пикселей мыши» на пиксель пальца\nconst DEAD = 0.28; // мёртвая зона джойстика (доля радиуса)\nconst SPRINT_AT = 0.93; // джойстик до упора вперёд — бег\nconst HOLD_MS = 300; // удержание без движения — ломать\nconst TAP_MOVE = 10; // пикселей: больше — уже поворот, а не тап\n\n/** Нужно ли сенсорное управление: грубый указатель (палец) или ?touch=1 в адресе (проверка). */\nexport function touchWanted(): boolean {\n try {\n if (new URLSearchParams(location.search).get(\"touch\") === \"1\") return true;\n return (\n typeof matchMedia === \"function\" &&\n matchMedia(\"(pointer: coarse)\").matches\n );\n } catch {\n return false;\n }\n}\n\nfunction el(cls: string, text = \"\"): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n d.textContent = text;\n return d;\n}\n\nexport class TouchControls {\n readonly root: HTMLDivElement;\n private readonly knob: HTMLDivElement;\n private readonly timers = new Set<ReturnType<typeof setTimeout>>();\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = el(\"touchUi\");\n const opts = { signal };\n\n // ---- зона обзора (правые 60% экрана) ----\n const look = el(\"tLook\");\n let lookId: number | null = null,\n sx = 0,\n sy = 0,\n lx = 0,\n ly = 0,\n t0 = 0,\n moved = false,\n holding = false,\n holdT: ReturnType<typeof setTimeout> | undefined;\n look.addEventListener(\n \"pointerdown\",\n (e) => {\n if (lookId !== null || !this.ready()) return;\n lookId = e.pointerId;\n look.setPointerCapture(e.pointerId);\n sx = lx = e.clientX;\n sy = ly = e.clientY;\n t0 = performance.now();\n moved = holding = false;\n holdT = this.later(() => {\n if (!moved) {\n holding = true;\n game.input.leftDown = true; // удержание — ломать\n }\n }, HOLD_MS);\n },\n opts,\n );\n look.addEventListener(\n \"pointermove\",\n (e) => {\n if (e.pointerId !== lookId) return;\n game.input.dx += (e.clientX - lx) * LOOK_SENS;\n game.input.dy += (e.clientY - ly) * LOOK_SENS;\n lx = e.clientX;\n ly = e.clientY;\n if (Math.hypot(e.clientX - sx, e.clientY - sy) > TAP_MOVE) moved = true;\n },\n opts,\n );\n const lookEnd = (e: PointerEvent) => {\n if (e.pointerId !== lookId) return;\n lookId = null;\n clearTimeout(holdT);\n if (holding) game.input.leftDown = false;\n else if (!moved && performance.now() - t0 < HOLD_MS) this.pulseUse();\n };\n look.addEventListener(\"pointerup\", lookEnd, opts);\n look.addEventListener(\"pointercancel\", lookEnd, opts);\n\n // ---- джойстик ----\n const stick = el(\"tStick\");\n this.knob = el(\"tKnob\");\n stick.append(this.knob);\n let stickId: number | null = null;\n const setDir = (vx: number, vy: number) => {\n const k = game.input.keys;\n const set = (code: string, on: boolean) =>\n on ? k.add(code) : k.delete(code);\n set(\"KeyW\", vy < -DEAD);\n set(\"KeyS\", vy > DEAD);\n set(\"KeyA\", vx < -DEAD);\n set(\"KeyD\", vx > DEAD);\n game.input.sprintLatch = -vy > SPRINT_AT;\n this.knob.style.transform = `translate(${vx * 40}px, ${vy * 40}px)`;\n };\n const stickMove = (e: PointerEvent) => {\n const r = stick.getBoundingClientRect();\n const R = r.width / 2;\n let vx = (e.clientX - (r.left + R)) / R,\n vy = (e.clientY - (r.top + R)) / R;\n const len = Math.hypot(vx, vy);\n if (len > 1) {\n vx /= len;\n vy /= len;\n }\n setDir(vx, vy);\n };\n stick.addEventListener(\n \"pointerdown\",\n (e) => {\n if (stickId !== null || !this.ready()) return;\n stickId = e.pointerId;\n stick.setPointerCapture(e.pointerId);\n stickMove(e);\n },\n opts,\n );\n stick.addEventListener(\n \"pointermove\",\n (e) => {\n if (e.pointerId === stickId) stickMove(e);\n },\n opts,\n );\n const stickEnd = (e: PointerEvent) => {\n if (e.pointerId !== stickId) return;\n stickId = null;\n setDir(0, 0);\n };\n stick.addEventListener(\"pointerup\", stickEnd, opts);\n stick.addEventListener(\"pointercancel\", stickEnd, opts);\n\n // ---- кнопки ----\n let lastJump = 0;\n const hold = (\n cls: string,\n text: string,\n down: () => void,\n up: () => void = () => {},\n ) => {\n const b = el(\"tBtn \" + cls, text);\n b.addEventListener(\n \"pointerdown\",\n (e) => {\n if (!this.ready()) return;\n b.setPointerCapture(e.pointerId);\n b.classList.add(\"on\");\n down();\n },\n opts,\n );\n const end = () => {\n b.classList.remove(\"on\");\n up();\n };\n b.addEventListener(\"pointerup\", end, opts);\n b.addEventListener(\"pointercancel\", end, opts);\n return b;\n };\n const jump = hold(\n \"tJump\",\n \"⤒\",\n () => {\n game.input.keys.add(\"Space\");\n const now = performance.now();\n if (now - lastJump < 300) events.emit(\"input:doubleSpace\"); // полёт в креативе\n lastJump = now;\n },\n () => game.input.keys.delete(\"Space\"),\n );\n const sneak = el(\"tBtn tSneak\", \"⇩\");\n sneak.addEventListener(\n \"pointerdown\",\n () => {\n if (!this.ready()) return;\n const k = game.input.keys;\n const on = !k.has(SNEAK_KEYS[0]);\n if (on) k.add(SNEAK_KEYS[0]);\n else k.delete(SNEAK_KEYS[0]);\n sneak.classList.toggle(\"on\", on);\n },\n opts,\n );\n const attack = hold(\n \"tAttack\",\n \"⚒\",\n () => (game.input.leftDown = true),\n () => (game.input.leftDown = false),\n );\n const use = hold(\"tUse\", \"✋\", () => this.pulseUse());\n const inv = hold(\"tInv\", \"▦\", () => events.emit(\"ui:toggleInventory\"));\n const chat = hold(\"tChat\", \"/\", () =>\n events.emit(\"ui:openConsole\", { prefix: \"/\" }),\n );\n\n // выбросить предмет из руки (как Q)\n const drop = hold(\"tDrop\", \"⇣\", () =>\n game.intents.push(Intent.DropItem, { all: false }),\n );\n\n // жесты и эмоции (G), вид от третьего лица (F5)\n const emotes = hold(\"tEmote\", \"☺\", () => events.emit(\"ui:emotes\"));\n const view = hold(\"tView\", \"👁\", () => events.emit(\"view:cycleCamera\"));\n const quests = hold(\"tQuests\", \"📜\", () => events.emit(\"ui:quests\"));\n // пауза (как Esc): в ней «Продолжить» и «В лобби» — своей кнопки «назад» база над игрой не рисует\n const pause = el(\"tBtn tPause\", \"⏸\");\n pause.addEventListener(\n \"pointerdown\",\n (e) => {\n e.preventDefault();\n if (game.playing && !game.input.uiOpen) game.input.releaseControl();\n },\n opts,\n );\n\n this.root.append(\n look,\n stick,\n jump,\n sneak,\n attack,\n use,\n inv,\n chat,\n drop,\n emotes,\n view,\n quests,\n pause,\n );\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n document.body.classList.add(\"touch\");\n }\n\n /** Игра принимает ввод: берём управление (мягкий режим без Pointer Lock), если его ещё нет. */\n private ready(): boolean {\n const g = this.game;\n if (!g.playing || g.input.uiOpen) return false;\n if (!g.input.active) g.input.takeControl();\n return true;\n }\n\n /** «Нажать ПКМ» на мгновение: Interaction поставит блок или откроет дверь ровно раз. */\n private pulseUse(): void {\n this.game.input.rightDown = true;\n this.later(() => (this.game.input.rightDown = false), 120);\n }\n\n private later(fn: () => void, ms: number): ReturnType<typeof setTimeout> {\n const t = setTimeout(() => {\n this.timers.delete(t);\n fn();\n }, ms);\n this.timers.add(t);\n return t;\n }\n\n dispose(): void {\n for (const t of this.timers) clearTimeout(t);\n this.root.remove();\n document.body.classList.remove(\"touch\");\n }\n}\n"
|
|
1834
|
+
"content": "// Сенсорное управление (телефон, планшет): джойстик слева, обзор перетаскиванием справа, кнопки.\n// Пишет в те же поля Input, что клавиатура и мышь (keys, dx/dy, leftDown/rightDown, sprintLatch), —\n// второго пути ввода нет, поэтому вся игровая логика и агент работают как с клавиатурой.\n//\n// Жесты зоны обзора, как в Minecraft для телефона: короткий тап ставит блок / открывает дверь,\n// удержание без движения ломает, перетаскивание поворачивает камеру. С едой, луком, щитом (всё,\n// что на ПК работает удержанием ПКМ, `holdsToUse`) удержание ИСПОЛЬЗУЕТ: ест, натягивает, закрывается.\n// ⚠ Кнопка ✋ держит ПКМ, пока на ней палец (05.11.2026): раньше она «нажимала» на 120 мс, и\n// съесть яблоко на телефоне было нельзя вовсе (еда ест, пока ПКМ зажата).\nimport { events } from \"../core/EventBus\";\nimport { Intent } from \"../core/Intents\";\nimport { SNEAK_KEYS } from \"../player/Input\";\nimport type { Game } from \"../main\";\nimport { ITEMS } from \"../registry/Items\";\nimport { fitTouchUi } from \"./mobileFit\";\n\n/** Предметы, которые на ПК работают удержанием ПКМ (не раз на нажатие). */\nconst HOLD_ITEMS = new Set([\n \"bow\",\n \"crossbow\",\n \"trident\",\n \"shield\",\n \"spyglass\",\n \"brush\",\n \"goat_horn\",\n]);\n\n/** Удержание на экране с этим предметом в руке использует его, а не ломает блок. */\nexport function holdsToUse(item: string | undefined): boolean {\n if (!item) return false;\n const def = ITEMS[item];\n return HOLD_ITEMS.has(item) || (def?.food ?? 0) > 0 || !!def?.drink;\n}\n\nconst LOOK_SENS = 2.2; // «пикселей мыши» на пиксель пальца\nconst DEAD = 0.28; // мёртвая зона джойстика (доля радиуса)\nconst SPRINT_AT = 0.93; // джойстик до упора вперёд — бег\nconst HOLD_MS = 300; // удержание без движения — ломать\nconst TAP_MOVE = 10; // пикселей: больше — уже поворот, а не тап\n\n/** Нужно ли сенсорное управление: грубый указатель (палец) или ?touch=1 в адресе (проверка). */\nexport function touchWanted(): boolean {\n try {\n if (new URLSearchParams(location.search).get(\"touch\") === \"1\") return true;\n return (\n typeof matchMedia === \"function\" &&\n matchMedia(\"(pointer: coarse)\").matches\n );\n } catch {\n return false;\n }\n}\n\nfunction el(cls: string, text = \"\"): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = cls;\n d.textContent = text;\n return d;\n}\n\nexport class TouchControls {\n readonly root: HTMLDivElement;\n private readonly knob: HTMLDivElement;\n private readonly timers = new Set<ReturnType<typeof setTimeout>>();\n\n constructor(\n private readonly game: Game,\n signal: AbortSignal,\n ) {\n this.root = el(\"touchUi\");\n const opts = { signal };\n\n // ---- зона обзора (правые 60% экрана) ----\n const look = el(\"tLook\");\n let lookId: number | null = null,\n sx = 0,\n sy = 0,\n lx = 0,\n ly = 0,\n t0 = 0,\n moved = false,\n holding: \"break\" | \"use\" | null = null,\n holdT: ReturnType<typeof setTimeout> | undefined;\n look.addEventListener(\n \"pointerdown\",\n (e) => {\n if (lookId !== null || !this.ready()) return;\n lookId = e.pointerId;\n look.setPointerCapture(e.pointerId);\n sx = lx = e.clientX;\n sy = ly = e.clientY;\n t0 = performance.now();\n moved = false;\n holding = null;\n holdT = this.later(() => {\n if (moved) return;\n // удержание: с едой / луком / щитом в руке использовать, иначе ломать\n if (holdsToUse(game.inventory.heldItem()?.item)) {\n holding = \"use\";\n game.input.rightDown = true;\n } else {\n holding = \"break\";\n game.input.leftDown = true;\n }\n }, HOLD_MS);\n },\n opts,\n );\n look.addEventListener(\n \"pointermove\",\n (e) => {\n if (e.pointerId !== lookId) return;\n game.input.dx += (e.clientX - lx) * LOOK_SENS;\n game.input.dy += (e.clientY - ly) * LOOK_SENS;\n lx = e.clientX;\n ly = e.clientY;\n if (Math.hypot(e.clientX - sx, e.clientY - sy) > TAP_MOVE) moved = true;\n },\n opts,\n );\n const lookEnd = (e: PointerEvent) => {\n if (e.pointerId !== lookId) return;\n lookId = null;\n clearTimeout(holdT);\n if (holding === \"break\") game.input.leftDown = false;\n else if (holding === \"use\") game.input.rightDown = false;\n else if (!moved && performance.now() - t0 < HOLD_MS) this.pulseUse();\n holding = null;\n };\n look.addEventListener(\"pointerup\", lookEnd, opts);\n look.addEventListener(\"pointercancel\", lookEnd, opts);\n\n // ---- джойстик ----\n const stick = el(\"tStick\");\n this.knob = el(\"tKnob\");\n stick.append(this.knob);\n let stickId: number | null = null;\n const setDir = (vx: number, vy: number) => {\n const k = game.input.keys;\n const set = (code: string, on: boolean) =>\n on ? k.add(code) : k.delete(code);\n set(\"KeyW\", vy < -DEAD);\n set(\"KeyS\", vy > DEAD);\n set(\"KeyA\", vx < -DEAD);\n set(\"KeyD\", vx > DEAD);\n game.input.sprintLatch = -vy > SPRINT_AT;\n // ход ручки: треть ширины джойстика (на телефоне он меньше, чем на планшете)\n const t = stick.clientWidth * 0.3;\n this.knob.style.transform = `translate(${vx * t}px, ${vy * t}px)`;\n };\n const stickMove = (e: PointerEvent) => {\n const r = stick.getBoundingClientRect();\n const R = r.width / 2;\n let vx = (e.clientX - (r.left + R)) / R,\n vy = (e.clientY - (r.top + R)) / R;\n const len = Math.hypot(vx, vy);\n if (len > 1) {\n vx /= len;\n vy /= len;\n }\n setDir(vx, vy);\n };\n stick.addEventListener(\n \"pointerdown\",\n (e) => {\n if (stickId !== null || !this.ready()) return;\n stickId = e.pointerId;\n stick.setPointerCapture(e.pointerId);\n stickMove(e);\n },\n opts,\n );\n stick.addEventListener(\n \"pointermove\",\n (e) => {\n if (e.pointerId === stickId) stickMove(e);\n },\n opts,\n );\n const stickEnd = (e: PointerEvent) => {\n if (e.pointerId !== stickId) return;\n stickId = null;\n setDir(0, 0);\n };\n stick.addEventListener(\"pointerup\", stickEnd, opts);\n stick.addEventListener(\"pointercancel\", stickEnd, opts);\n\n // ---- кнопки ----\n let lastJump = 0;\n const hold = (\n cls: string,\n text: string,\n down: () => void,\n up: () => void = () => {},\n ) => {\n const b = el(\"tBtn \" + cls, text);\n b.addEventListener(\n \"pointerdown\",\n (e) => {\n if (!this.ready()) return;\n // совместимый mousedown после касания увёл бы фокус со строки консоли («/») и закрыл её\n e.preventDefault();\n b.setPointerCapture(e.pointerId);\n b.classList.add(\"on\");\n down();\n },\n opts,\n );\n const end = () => {\n b.classList.remove(\"on\");\n up();\n };\n b.addEventListener(\"pointerup\", end, opts);\n b.addEventListener(\"pointercancel\", end, opts);\n return b;\n };\n const jump = hold(\n \"tJump\",\n \"⤒\",\n () => {\n game.input.keys.add(\"Space\");\n const now = performance.now();\n if (now - lastJump < 300) events.emit(\"input:doubleSpace\"); // полёт в креативе\n lastJump = now;\n },\n () => game.input.keys.delete(\"Space\"),\n );\n const sneak = el(\"tBtn tSneak\", \"⇩\");\n sneak.addEventListener(\n \"pointerdown\",\n () => {\n if (!this.ready()) return;\n const k = game.input.keys;\n const on = !k.has(SNEAK_KEYS[0]);\n if (on) k.add(SNEAK_KEYS[0]);\n else k.delete(SNEAK_KEYS[0]);\n sneak.classList.toggle(\"on\", on);\n },\n opts,\n );\n const attack = hold(\n \"tAttack\",\n \"⚒\",\n () => (game.input.leftDown = true),\n () => (game.input.leftDown = false),\n );\n // ПКМ, пока палец на кнопке: поставить блок (раз на нажатие), есть, натягивать лук, закрываться щитом\n const use = hold(\n \"tUse\",\n \"✋\",\n () => (game.input.rightDown = true),\n () => (game.input.rightDown = false),\n );\n const inv = hold(\"tInv\", \"▦\", () => events.emit(\"ui:toggleInventory\"));\n const chat = hold(\"tChat\", \"/\", () =>\n events.emit(\"ui:openConsole\", { prefix: \"/\" }),\n );\n\n // выбросить предмет из руки (как Q)\n const drop = hold(\"tDrop\", \"⇣\", () =>\n game.intents.push(Intent.DropItem, { all: false }),\n );\n\n // жесты и эмоции (G), вид от третьего лица (F5)\n const emotes = hold(\"tEmote\", \"☺\", () => events.emit(\"ui:emotes\"));\n const view = hold(\"tView\", \"👁\", () => events.emit(\"view:cycleCamera\"));\n const quests = hold(\"tQuests\", \"📜\", () => events.emit(\"ui:quests\"));\n // пауза (как Esc): в ней «Продолжить» и «В лобби» — своей кнопки «назад» база над игрой не рисует\n const pause = el(\"tBtn tPause\", \"⏸\");\n pause.addEventListener(\n \"pointerdown\",\n (e) => {\n e.preventDefault();\n if (game.playing && !game.input.uiOpen) game.input.releaseControl();\n },\n opts,\n );\n\n this.root.append(\n look,\n stick,\n jump,\n sneak,\n attack,\n use,\n inv,\n chat,\n drop,\n emotes,\n view,\n quests,\n pause,\n );\n document.getElementById(\"game\")!.parentElement!.appendChild(this.root);\n document.body.classList.add(\"touch\");\n // нижняя панель и окна под размер экрана, в обеих ориентациях (ui/mobileFit)\n fitTouchUi(signal);\n }\n\n /** Игра принимает ввод: берём управление (мягкий режим без Pointer Lock), если его ещё нет. */\n private ready(): boolean {\n const g = this.game;\n if (!g.playing || g.input.uiOpen) return false;\n if (!g.input.active) g.input.takeControl();\n return true;\n }\n\n /** «Нажать ПКМ» на мгновение: Interaction поставит блок или откроет дверь ровно раз. */\n private pulseUse(): void {\n this.game.input.rightDown = true;\n this.later(() => (this.game.input.rightDown = false), 120);\n }\n\n private later(fn: () => void, ms: number): ReturnType<typeof setTimeout> {\n const t = setTimeout(() => {\n this.timers.delete(t);\n fn();\n }, ms);\n this.timers.add(t);\n return t;\n }\n\n dispose(): void {\n for (const t of this.timers) clearTimeout(t);\n this.root.remove();\n document.body.classList.remove(\"touch\");\n }\n}\n"
|
|
1835
|
+
},
|
|
1836
|
+
{
|
|
1837
|
+
"path": "ui/touchHold.ts",
|
|
1838
|
+
"content": "// Касание и удержание пальцем там, где мышь различает кнопки (05.11.2026): у пальца правой кнопки\n// нет, поэтому удержание = «правый» вариант (выход верстака: всё сразу, как Shift; палитра креатива:\n// один предмет). Мышь идёт старым путём (onmousedown элемента).\n// ⚠ preventDefault на pointerdown гасит совместимые mousedown: иначе касание кликало бы дважды.\n\n/** Сколько держать палец, чтобы это было удержание, мс. */\nexport const HOLD_MS = 420;\n/** Палец уехал дальше, px: это прокрутка окна, не нажатие. */\nexport const MOVE_PX = 10;\n\n/** Касание = fire(false), удержание = fire(true), увёл палец = ничего. */\nexport function touchHold(\n el: HTMLElement,\n fire: (held: boolean) => void,\n): void {\n let timer = 0;\n let id: number | null = null;\n let x0 = 0,\n y0 = 0;\n const stop = () => {\n clearTimeout(timer);\n timer = 0;\n id = null;\n };\n el.addEventListener(\"pointerdown\", (e) => {\n if (e.pointerType === \"mouse\") return;\n e.preventDefault();\n id = e.pointerId;\n x0 = e.clientX;\n y0 = e.clientY;\n timer = window.setTimeout(() => {\n timer = 0;\n id = null;\n fire(true);\n navigator.vibrate?.(15);\n }, HOLD_MS);\n });\n el.addEventListener(\"pointermove\", (e) => {\n if (e.pointerId !== id) return;\n if (Math.hypot(e.clientX - x0, e.clientY - y0) > MOVE_PX) stop();\n });\n el.addEventListener(\"pointerup\", (e) => {\n if (e.pointerId !== id) return;\n const tap = timer !== 0;\n stop();\n if (tap) fire(false);\n });\n el.addEventListener(\"pointercancel\", stop);\n}\n"
|
|
1831
1839
|
},
|
|
1832
1840
|
{
|
|
1833
1841
|
"path": "ui/tradeView.ts",
|
|
@@ -2327,7 +2335,7 @@
|
|
|
2327
2335
|
},
|
|
2328
2336
|
{
|
|
2329
2337
|
"path": "module.meta.json",
|
|
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.
|
|
2338
|
+
"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.4\"\n}\n"
|
|
2331
2339
|
}
|
|
2332
2340
|
]
|
|
2333
2341
|
}
|