@idosgames/mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/cli.js +204 -0
- package/package.json +46 -0
- package/registry/host.json +41 -0
- package/registry/index.json +215 -0
- package/registry/modules/board-game.json +121 -0
- package/registry/modules/currency-hud.json +28 -0
- package/registry/modules/idle-rpg.json +89 -0
- package/registry/modules/voxelcraft.json +163 -0
- package/registry/skills/authentication.json +11 -0
- package/registry/skills/blockchain-system.json +11 -0
- package/registry/skills/character-system.json +11 -0
- package/registry/skills/cloud-code.json +6 -0
- package/registry/skills/collection-system.json +11 -0
- package/registry/skills/coop-event-system.json +11 -0
- package/registry/skills/craft-system.json +11 -0
- package/registry/skills/currency-system.json +11 -0
- package/registry/skills/deal-offer-system.json +11 -0
- package/registry/skills/dev-test-loop.json +6 -0
- package/registry/skills/game-loop-system.json +11 -0
- package/registry/skills/idosgames-compose-modules.json +6 -0
- package/registry/skills/idosgames-getting-started.json +6 -0
- package/registry/skills/idosgames-module-contract.json +6 -0
- package/registry/skills/item-system.json +11 -0
- package/registry/skills/leaderboard-system.json +11 -0
- package/registry/skills/lootbox-system.json +11 -0
- package/registry/skills/marketplace-system.json +11 -0
- package/registry/skills/match-system.json +11 -0
- package/registry/skills/multiplayer-realtime.json +11 -0
- package/registry/skills/premium-system.json +11 -0
- package/registry/skills/quest-system.json +11 -0
- package/registry/skills/referral-system.json +11 -0
- package/registry/skills/reward-system.json +11 -0
- package/registry/skills/season-system.json +11 -0
- package/registry/skills/social-system.json +6 -0
- package/registry/skills/store-system.json +11 -0
- package/registry/skills/timed-boost-system.json +11 -0
- package/registry/skills/timed-event-system.json +11 -0
- package/registry/skills/title-system.json +6 -0
- package/registry/skills/user-custom-data.json +11 -0
- package/registry/skills/user-profile.json +11 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "voxelcraft",
|
|
3
|
+
"meta": {
|
|
4
|
+
"name": "VoxelCraft",
|
|
5
|
+
"type": "game",
|
|
6
|
+
"engine": "three",
|
|
7
|
+
"genre": "sandbox"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@idosgames/module-sdk": "0.1.0",
|
|
11
|
+
"three": "0.185.1"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "config.ts",
|
|
16
|
+
"content": "// Единый конфиг игры. Все тюнинги — здесь, логика магических чисел не содержит.\nexport const CONFIG = {\n chunk: { sx: 16, sy: 64, sz: 16 }, // размер чанка в блоках\n renderDistance: 5, // радиус в чанках\n unloadDistance: 7, // дальше этого — выгрузка\n genBudgetPerFrame: 2, // генераций данных чанков за кадр\n meshBudgetPerFrame: 2, // пересборок мешей за кадр\n\n tps: 20, // тиков симуляции в секунду\n maxCatchupTicks: 4, // защита от «спирали смерти» после сворачивания вкладки\n\n world: {\n seed: 1337,\n waterLevel: 25, // уровень моря (y)\n baseHeight: 31,\n hillAmp: 14, // амплитуда холмов\n },\n\n player: {\n width: 0.6,\n height: 1.8,\n eye: 1.62,\n walkSpeed: 4.3,\n runSpeed: 6.5,\n flySpeed: 11,\n jumpSpeed: 8.2,\n gravity: 24,\n swimSpeed: 2.4,\n swimUp: 4.0,\n waterDrag: 0.6,\n reach: 5, // дистанция взаимодействия с блоками\n breakHealDelay: 1.2, // сек простоя, прежде чем урон блока начнёт заживать\n breakHealRate: 0.6, // сек прогресса, восстанавливаемого за секунду простоя\n maxHealth: 20, // 10 сердечек\n fallSafe: 3.5, // блоков падения без урона\n attackDamage: 1, // урон кулаком\n attackCooldown: 0.35, // сек\n\n // Голод (модель Minecraft: еда + сатурация + накопитель «истощения»)\n maxFood: 20, // 10 «окорочков»\n sprintFood: 6, // ниже — нельзя бежать\n exhaustionPerFood: 4.0, // истощения на -1 очко еды/сатурации\n exhaustIdle: 0.02, // пассивное истощение в секунду (стоя)\n exhaustWalk: 0.04, // истощение на блок ходьбы\n exhaustSprint: 0.08, // истощение на блок бега\n exhaustJump: 0.2, // за прыжок\n exhaustBreak: 0.06, // за сломанный блок\n exhaustAttack: 0.1, // за удар\n exhaustRegen: 6.0, // за восстановленное сердце (регенерация «стоит» еды)\n regenFoodThreshold: 18, // еда >= этого → лечит HP\n regenInterval: 3.5, // сек на +1 HP\n starveInterval: 4.0, // сек на -1 HP при нулевой еде\n eatTime: 1.4, // сек удержания ПКМ, чтобы съесть\n },\n\n drops: {\n magnetRadius: 1.9,\n pickupRadius: 0.6,\n magnetPull: 14,\n gravity: 18,\n bounce: 0.3,\n friction: 0.6,\n maxCount: 60,\n mergeRadius: 0.6,\n scale: 0.25,\n despawnTime: 300, // сек\n },\n\n mobs: {\n targetPopulation: 7, // куриц вокруг игрока\n spawnRadius: [10, 24], // мин/макс дистанция спавна\n despawnRadius: 48,\n },\n\n water: {\n tickEvery: 5, // каждый N-й симтик (20/5 = 4 Гц)\n cellBudget: 400, // макс клеток за водный тик\n maxLevel: 8,\n minFlowLevel: 2, // уровень 2 останавливает растекание (~7 блоков)\n },\n\n dayNight: {\n dayLength: 30,\n nightLength: 30, // секунд\n },\n\n // Задел под этап 2 (мультиплеер «звезда с хостом»). В MVP не используется.\n net: {\n maxPlayers: 8,\n snapshotRateHz: 15, // рассылка позиций unreliable-каналом\n interpBufferMs: 120,\n timeoutMs: 8000,\n protocolVersion: 1,\n },\n\n saveVersion: 1,\n};\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "core/EventBus.ts",
|
|
20
|
+
"content": "// Событийная шина — единственный канал связи между системами.\n// В этапе 2 (мультиплеер) сетевой слой станет просто ещё одним подписчиком:\n// у хоста события уходят в сеть как дельты, у клиента входящие дельты эмитятся сюда же.\n//\n// Основные события:\n// blockChanged {x,y,z,id,meta,prevId} — любое изменение блока (самодостаточно для репликации)\n// blockBreak {x,y,z,id,byPlayer} — блок сломан (уже после blockChanged)\n// blockPlace {x,y,z,id}\n// itemPickup {item,count}\n// playerHurt {amount,health,cause}\n// mobHurt {entity,amount}\n// mobDied {entity}\n// craft {item,count}\n// tick {n} — каждый симуляционный тик (20 TPS)\n// invChanged {} — инвентарь игрока изменился (HUD перерисовать)\n\n// Полезная нагрузка событий намеренно динамическая (разные события несут разные\n// поля-мешки); подписчик получает нетипизированный data — локальный any здесь оправдан.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype EventHandler = (data?: any) => void;\n\nclass EventBus {\n private _map: Map<string, Set<EventHandler>>;\n constructor() {\n this._map = new Map();\n }\n on(name: string, fn: EventHandler): () => void {\n if (!this._map.has(name)) this._map.set(name, new Set());\n this._map.get(name)!.add(fn);\n return () => this.off(name, fn);\n }\n off(name: string, fn: EventHandler): void {\n this._map.get(name)?.delete(fn);\n }\n emit(name: string, data?: unknown): void {\n const set = this._map.get(name);\n if (!set) return;\n for (const fn of set) fn(data);\n }\n}\n\nexport const events = new EventBus();\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "core/GameLoop.ts",
|
|
24
|
+
"content": "// Игровой цикл: rAF-рендер + аккумулятор фиксированного тика (20 TPS).\n// Симуляция (мир, мобы, вода, интенты) — на тике; всё, что касается глаза и руки\n// (камера, физика игрока, интерполяция, рендер) — на кадре.\nimport { CONFIG } from \"../config.js\";\n\nexport class GameLoop {\n onTick: (dt: number, n: number) => void;\n onFrame: (dt: number, alpha: number) => void;\n tickDt: number;\n acc: number;\n tickN: number;\n last: number;\n running: boolean;\n fps: number;\n private _frames: number;\n private _fpsTime: number;\n\n /**\n * @param onTick — фиксированный шаг, dt = 1/TPS\n * @param onFrame — alpha = доля тика для интерполяции\n */\n constructor(\n onTick: (dt: number, n: number) => void,\n onFrame: (dt: number, alpha: number) => void,\n ) {\n this.onTick = onTick;\n this.onFrame = onFrame;\n this.tickDt = 1 / CONFIG.tps;\n this.acc = 0;\n this.tickN = 0;\n this.last = 0;\n this.running = false;\n // FPS: скользящее окно за секунду\n this.fps = 0;\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n start() {\n this.running = true;\n this.last = performance.now();\n const step = (now: number) => {\n if (!this.running) return;\n let dt = (now - this.last) / 1000;\n this.last = now;\n if (dt > 0.25) dt = 0.25; // возврат из фона — не наращивать гигантский долг\n\n this.acc += dt;\n let ticks = 0;\n while (this.acc >= this.tickDt && ticks < CONFIG.maxCatchupTicks) {\n this.onTick(this.tickDt, this.tickN++);\n this.acc -= this.tickDt;\n ticks++;\n }\n if (ticks === CONFIG.maxCatchupTicks) this.acc = 0; // сбросить долг\n\n this._frames++;\n this._fpsTime += dt;\n if (this._fpsTime >= 0.5) {\n this.fps = Math.round(this._frames / this._fpsTime);\n this._frames = 0;\n this._fpsTime = 0;\n }\n\n this.onFrame(Math.min(dt, 0.05), this.acc / this.tickDt);\n requestAnimationFrame(step);\n };\n requestAnimationFrame(step);\n }\n\n stop() {\n this.running = false;\n }\n}\n"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "core/Intents.ts",
|
|
28
|
+
"content": "// Очередь интентов — сетевой шов этапа 2.\n// Ввод НИКОГДА не мутирует мир напрямую: он кладёт сюда команды-намерения,\n// а симуляция на тике достаёт их, валидирует (дистанция, наличие предмета,\n// кулдаун) и исполняет. В мультиплеере хост будет класть в эту же очередь\n// интенты удалённых игроков (с их playerId) — код исполнения один.\n\nexport const Intent = {\n BreakStart: \"BreakStart\", // {x,y,z} — начать ломать блок\n BreakStop: \"BreakStop\", // {} — отпустили ЛКМ / увели прицел\n PlaceBlock: \"PlaceBlock\", // {x,y,z} — поставить блок из активного слота\n EatStart: \"EatStart\", // {item} — начать есть еду из руки (удержание ПКМ)\n EatStop: \"EatStop\", // {} — прекратить есть\n Attack: \"Attack\", // {entityId} — удар по сущности\n SelectSlot: \"SelectSlot\", // {slot} — 0..8\n CraftItem: \"CraftItem\", // {gridIds:[...], size} — взять результат крафта\n MoveInvItem: \"MoveInvItem\", // обрабатывается InventoryUI → Inventory (см. там)\n DropItem: \"DropItem\", // {slot} — выбросить (задел, в MVP не подвязан к клавише)\n ToggleMode: \"ToggleMode\", // {} — выживание/креатив\n};\n\n// Интент — команда-намерение: тип + произвольные поля данных (playerId добавится\n// в этапе 2). Полезная нагрузка динамическая — локальный any здесь оправдан.\nexport interface IntentMsg {\n type: string;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n [key: string]: any;\n}\n\nexport class IntentQueue {\n private _q: IntentMsg[];\n constructor() {\n this._q = [];\n }\n push(type: string, data: Record<string, unknown> = {}): void {\n this._q.push({ type, ...data });\n }\n /** Выгребает всё накопленное; вызывается симуляцией раз в тик. */\n drain(fn: (intent: IntentMsg) => void): void {\n const q = this._q;\n this._q = [];\n for (const intent of q) fn(intent);\n }\n clear(): void {\n this._q.length = 0;\n }\n}\n"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "core/Noise.ts",
|
|
32
|
+
"content": "// Сидированный PRNG (mulberry32) и 2D-симплекс-шум — самодостаточно, без зависимостей.\n\n/** Детерминированный PRNG: одинаковый сид → одинаковый мир. */\nexport function mulberry32(seed: number): () => number {\n let a = seed >>> 0;\n return function () {\n a |= 0;\n a = (a + 0x6d2b79f5) | 0;\n let t = Math.imul(a ^ (a >>> 15), 1 | a);\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\n/** Хэш строки в 32-битный сид (для под-генераторов: \"trees:3,-2\" и т.п.). */\nexport function hashString(str: string): number {\n let h = 2166136261 >>> 0;\n for (let i = 0; i < str.length; i++) {\n h ^= str.charCodeAt(i);\n h = Math.imul(h, 16777619);\n }\n return h >>> 0;\n}\n\nconst GRAD2 = [\n [1, 1],\n [-1, 1],\n [1, -1],\n [-1, -1],\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n];\nconst F2 = 0.5 * (Math.sqrt(3) - 1);\nconst G2 = (3 - Math.sqrt(3)) / 6;\n\nexport class Simplex2 {\n perm: Uint8Array;\n constructor(seed: number) {\n const rand = mulberry32(seed);\n this.perm = new Uint8Array(512);\n const p = new Uint8Array(256);\n for (let i = 0; i < 256; i++) p[i] = i;\n for (let i = 255; i > 0; i--) {\n const j = (rand() * (i + 1)) | 0;\n [p[i], p[j]] = [p[j], p[i]];\n }\n for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];\n }\n\n /** Значение шума в диапазоне примерно [-1, 1]. */\n noise(xin: number, yin: number): number {\n const perm = this.perm;\n const s = (xin + yin) * F2;\n const i = Math.floor(xin + s),\n j = Math.floor(yin + s);\n const t = (i + j) * G2;\n const x0 = xin - (i - t),\n y0 = yin - (j - t);\n const i1 = x0 > y0 ? 1 : 0,\n j1 = x0 > y0 ? 0 : 1;\n const x1 = x0 - i1 + G2,\n y1 = y0 - j1 + G2;\n const x2 = x0 - 1 + 2 * G2,\n y2 = y0 - 1 + 2 * G2;\n const ii = i & 255,\n jj = j & 255;\n let n = 0;\n let t0 = 0.5 - x0 * x0 - y0 * y0;\n if (t0 > 0) {\n const g = GRAD2[perm[ii + perm[jj]] & 7];\n t0 *= t0;\n n += t0 * t0 * (g[0] * x0 + g[1] * y0);\n }\n let t1 = 0.5 - x1 * x1 - y1 * y1;\n if (t1 > 0) {\n const g = GRAD2[perm[ii + i1 + perm[jj + j1]] & 7];\n t1 *= t1;\n n += t1 * t1 * (g[0] * x1 + g[1] * y1);\n }\n let t2 = 0.5 - x2 * x2 - y2 * y2;\n if (t2 > 0) {\n const g = GRAD2[perm[ii + 1 + perm[jj + 1]] & 7];\n t2 *= t2;\n n += t2 * t2 * (g[0] * x2 + g[1] * y2);\n }\n return 70 * n;\n }\n\n /** Фрактальный шум (несколько октав), диапазон примерно [-1, 1]. */\n fbm(x: number, y: number, octaves = 4, lacunarity = 2, gain = 0.5): number {\n let amp = 1,\n freq = 1,\n sum = 0,\n norm = 0;\n for (let o = 0; o < octaves; o++) {\n sum += amp * this.noise(x * freq, y * freq);\n norm += amp;\n amp *= gain;\n freq *= lacunarity;\n }\n return sum / norm;\n }\n}\n"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"path": "entities/EntityManager.ts",
|
|
36
|
+
"content": "// Менеджер сущностей: мобы + дроп-предметы. Тик — симуляция (20 TPS),\n// updateVisuals — интерполяция для рендера. Census поддерживает популяцию\n// куриц вокруг игрока. Игрок в MVP хранится отдельно (Player), но интерфейс\n// сущностей совместим — в этапе 2 удалённые игроки станут ещё одним kind'ом.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config.js\";\nimport { events } from \"../core/EventBus.js\";\nimport { B } from \"../registry/Blocks.js\";\nimport { MOBS } from \"../registry/Mobs.js\";\nimport { Mob } from \"./Mob.js\";\nimport { ItemDrop } from \"./ItemDrop.js\";\nimport type { ChunkManager } from \"../world/ChunkManager.js\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas.js\";\nimport type { Player } from \"../player/Player.js\";\nimport type { Inventory } from \"../systems/Inventory.js\";\n\n// Форма сохранённой сущности (выход serialize() у Mob/ItemDrop) — дискриминируется по `kind`.\ntype EntitySave =\n | { kind: \"mob\"; type: string; pos: [number, number, number]; hp: number }\n | {\n kind: \"drop\";\n pos: [number, number, number];\n item: string;\n count: number;\n };\n\nexport class EntityManager {\n scene: THREE.Scene;\n world: ChunkManager;\n atlas: TextureAtlas;\n player: Player;\n inventory: Inventory;\n entities: Map<number, Mob | ItemDrop>;\n _nextId: number;\n _spawnTimer: number;\n\n constructor(\n scene: THREE.Scene,\n world: ChunkManager,\n atlas: TextureAtlas,\n player: Player,\n inventory: Inventory,\n ) {\n this.scene = scene;\n this.world = world;\n this.atlas = atlas;\n this.player = player;\n this.inventory = inventory;\n this.entities = new Map(); // id -> Mob|ItemDrop\n this._nextId = 1;\n this._spawnTimer = 0;\n\n events.on(\"mobDied\", ({ entity }) => {\n for (const d of entity.def.drops)\n if (Math.random() <= d.chance)\n this.spawnDrop(\n entity.pos.x,\n entity.pos.y + 0.3,\n entity.pos.z,\n d.item,\n d.n,\n );\n });\n }\n\n spawnDrop(\n x: number,\n y: number,\n z: number,\n item: string,\n count: number,\n ): ItemDrop {\n // слияние с ближайшим таким же дропом\n for (const e of this.entities.values()) {\n if (\n e.kind === \"drop\" &&\n e.item === item &&\n !e.dead &&\n e.pos.distanceToSquared({ x, y, z }) < CONFIG.drops.mergeRadius ** 2\n ) {\n e.count += count;\n return e;\n }\n }\n // лимит: убиваем самый старый дроп\n const drops = [...this.entities.values()].filter(\n (e): e is ItemDrop => e.kind === \"drop\",\n );\n if (drops.length >= CONFIG.drops.maxCount) {\n drops.sort((a, b) => b.age - a.age)[0].dead = true;\n }\n const drop = new ItemDrop(this._nextId++, this.atlas, x, y, z, item, count);\n this.entities.set(drop.id, drop);\n this.scene.add(drop.mesh);\n return drop;\n }\n\n spawnMob(type: string, x: number, y: number, z: number): Mob {\n const mob = new Mob(this._nextId++, MOBS[type], x, y, z);\n this.entities.set(mob.id, mob);\n this.scene.add(mob.mesh);\n return mob;\n }\n\n hurt(entityId: number, damage: number, fromPos: THREE.Vector3): void {\n const e = this.entities.get(entityId);\n if (e && e.kind === \"mob\" && !e.dead) e.hurt(damage, fromPos);\n }\n\n tick(dt: number): void {\n for (const e of this.entities.values()) {\n if (e.kind === \"drop\")\n e.tick(dt, this.world, this.player, this.inventory);\n else e.tick(dt, this.world);\n if (e.dead) {\n if (e.kind === \"mob\" && e.hp <= 0)\n events.emit(\"mobDied\", { entity: e });\n e.dispose(this.scene);\n this.entities.delete(e.id);\n }\n }\n this._census(dt);\n }\n\n /** Поддержание популяции куриц вокруг игрока. */\n _census(dt: number): void {\n this._spawnTimer -= dt;\n if (this._spawnTimer > 0) return;\n this._spawnTimer = 1.5;\n\n const p = this.player.pos;\n let count = 0;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\") continue;\n const d = e.pos.distanceTo(p);\n if (d > CONFIG.mobs.despawnRadius) {\n e.dead = true;\n continue;\n }\n count++;\n }\n if (count >= CONFIG.mobs.targetPopulation) return;\n\n // одна попытка за вызов: случайная точка в кольце вокруг игрока\n const [rMin, rMax] = CONFIG.mobs.spawnRadius;\n const ang = Math.random() * Math.PI * 2;\n const r = rMin + Math.random() * (rMax - rMin);\n const x = Math.floor(p.x + Math.cos(ang) * r);\n const z = Math.floor(p.z + Math.sin(ang) * r);\n const y = this.world.surfaceY(x, z);\n if (\n this.world.getBlock(x, y, z) === B.grass &&\n this.world.getBlock(x, y + 1, z) === B.air &&\n this.world.getBlock(x, y + 2, z) === B.air\n ) {\n this.spawnMob(\"chicken\", x + 0.5, y + 1.01, z + 0.5);\n }\n }\n\n /** Луч по AABB мобов (slab-тест) — для атаки. Возвращает ближайшего. */\n raycast(\n origin: THREE.Vector3,\n dir: THREE.Vector3,\n maxDist: number,\n ): Mob | null {\n let best: Mob | null = null,\n bestT = maxDist;\n for (const e of this.entities.values()) {\n if (e.kind !== \"mob\" || e.dead) continue;\n const { w, h } = e.def.box;\n const min = { x: e.pos.x - w / 2, y: e.pos.y, z: e.pos.z - w / 2 };\n const max = { x: e.pos.x + w / 2, y: e.pos.y + h, z: e.pos.z + w / 2 };\n let t0 = 0,\n t1 = bestT,\n ok = true;\n for (const ax of [\"x\", \"y\", \"z\"] as const) {\n const d = dir[ax],\n o = origin[ax];\n if (Math.abs(d) < 1e-9) {\n if (o < min[ax] || o > max[ax]) {\n ok = false;\n break;\n }\n continue;\n }\n let ta = (min[ax] - o) / d,\n tb = (max[ax] - o) / d;\n if (ta > tb) [ta, tb] = [tb, ta];\n t0 = Math.max(t0, ta);\n t1 = Math.min(t1, tb);\n if (t0 > t1) {\n ok = false;\n break;\n }\n }\n if (ok && t0 < bestT) {\n bestT = t0;\n best = e;\n }\n }\n return best;\n }\n\n updateVisuals(alpha: number, time: number): void {\n for (const e of this.entities.values()) e.updateVisual(alpha, time);\n }\n\n clear(): void {\n for (const e of this.entities.values()) e.dispose(this.scene);\n this.entities.clear();\n }\n\n serialize() {\n return [...this.entities.values()].map((e) => e.serialize());\n }\n\n deserialize(list?: EntitySave[]): void {\n this.clear();\n for (const d of list ?? []) {\n if (d.kind === \"mob\" && MOBS[d.type]) {\n const m = this.spawnMob(d.type, d.pos[0], d.pos[1], d.pos[2]);\n m.hp = d.hp;\n } else if (d.kind === \"drop\") {\n const drop = this.spawnDrop(\n d.pos[0],\n d.pos[1],\n d.pos[2],\n d.item,\n d.count,\n );\n if (drop) drop.vel.set(0, 0, 0);\n }\n }\n }\n}\n"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"path": "entities/ItemDrop.ts",
|
|
40
|
+
"content": "// Дроп-предмет: мини-модель блока (или плоская иконка) с физикой —\n// подброс, гравитация, отскок, трение, медленное вращение и «магнит» к игроку.\n// Физика на тике (20 TPS), позиция для рендера интерполируется.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config.js\";\nimport { events } from \"../core/EventBus.js\";\nimport { BLOCKS } from \"../registry/Blocks.js\";\nimport { ITEMS } from \"../registry/Items.js\";\nimport type { BlockDef } from \"../types.js\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas.js\";\nimport type { ChunkManager } from \"../world/ChunkManager.js\";\nimport type { Player } from \"../player/Player.js\";\nimport type { Inventory } from \"../systems/Inventory.js\";\n\nconst D = CONFIG.drops;\n// Гетерогенный кэш: геометрия под ключом key, материал под key+':mat' (разные типы значений).\nconst geoCache = new Map<string, THREE.BufferGeometry | THREE.Material>();\n\nfunction blockGeometry(\n atlas: TextureAtlas,\n blockDef: BlockDef,\n): THREE.BoxGeometry {\n // мини-куб с UV граней из атласа (top/side/bottom)\n const g = new THREE.BoxGeometry(D.scale, D.scale, D.scale);\n const uvAttr = g.getAttribute(\"uv\");\n // порядок граней BoxGeometry: +x,-x,+y,-y,+z,-z (по 4 вершины)\n // tex/грани заведомо заданы для размещаемых блоков, приходящих сюда — non-null asserts.\n const tex = blockDef.tex!;\n const tiles = [\n tex.side!,\n tex.side!,\n tex.top!,\n tex.bottom!,\n tex.side!,\n tex.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = atlas.uv(tiles[f]);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // общий материал чанков — vertexColors:true; без белого атрибута цвета\n // вершины считаются чёрными и гасят текстуру\n g.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(g.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n return g;\n}\n\nexport class ItemDrop {\n id: number;\n kind: \"drop\";\n item: string;\n count: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n age: number;\n dead: boolean;\n _pickupAnim: number;\n mesh: THREE.Mesh;\n\n constructor(\n id: number,\n atlas: TextureAtlas,\n x: number,\n y: number,\n z: number,\n itemName: string,\n count: number,\n ) {\n this.id = id;\n this.kind = \"drop\";\n this.item = itemName;\n this.count = count;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3(\n (Math.random() - 0.5) * 2.4,\n 4 + Math.random() * 1.5,\n (Math.random() - 0.5) * 2.4,\n );\n this.age = 0;\n this.dead = false;\n this._pickupAnim = 0; // >0 — летит в игрока\n\n const def = ITEMS[itemName];\n // Значения кэша гетерогенны (геометрия/материал под разными ключами) — сужаем кастами при чтении.\n const key = \"drop:\" + itemName;\n let geo = geoCache.get(key) as THREE.BufferGeometry | undefined;\n if (def.blockId !== null && !BLOCKS[def.blockId].cross) {\n if (!geo) {\n geo = blockGeometry(atlas, BLOCKS[def.blockId]);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, atlas.opaqueMat);\n } else {\n // плоский предмет: квад с иконкой (или тайлом растения)\n let mat = geoCache.get(key + \":mat\") as\n THREE.MeshLambertMaterial | undefined;\n if (!mat) {\n // не-блочные предметы (материалы/инструменты/крестовины) всегда имеют tile — non-null assert.\n const tex = new THREE.CanvasTexture(\n def.icon ?? atlas.tileCanvas(def.tile!),\n );\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n mat = new THREE.MeshLambertMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n geoCache.set(key + \":mat\", mat);\n }\n if (!geo) {\n geo = new THREE.PlaneGeometry(D.scale * 1.4, D.scale * 1.4);\n geoCache.set(key, geo);\n }\n this.mesh = new THREE.Mesh(geo, mat);\n }\n this.mesh.position.copy(this.pos);\n }\n\n tick(dt: number, world: ChunkManager, player: Player, inventory: Inventory) {\n this.prevPos.copy(this.pos);\n this.age += dt;\n if (this.age > D.despawnTime) {\n this.dead = true;\n return;\n }\n\n const toPlayer = new THREE.Vector3(\n player.pos.x,\n player.pos.y + 0.9,\n player.pos.z,\n ).sub(this.pos);\n const dist = toPlayer.length();\n\n // подбор\n if (dist < D.pickupRadius && this.age > 0.5) {\n const left = inventory.add(this.item, this.count);\n if (left < this.count) {\n events.emit(\"itemPickup\", {\n item: this.item,\n count: this.count - left,\n });\n if (left > 0) this.count = left;\n else {\n this.dead = true;\n return;\n }\n }\n }\n // магнит: тянет к игроку, физика отключается\n if (dist < D.magnetRadius && this.age > 0.5) {\n toPlayer\n .normalize()\n .multiplyScalar(\n D.magnetPull * Math.max(0.3, 1 - dist / D.magnetRadius) + 2,\n );\n this.vel.lerp(toPlayer, 0.5);\n this.pos.addScaledVector(this.vel, dt);\n return;\n }\n\n // обычная физика: гравитация + точечная коллизия с вокселями\n this.vel.y -= D.gravity * dt;\n const nx = this.pos.x + this.vel.x * dt;\n const ny = this.pos.y + this.vel.y * dt;\n const nz = this.pos.z + this.vel.z * dt;\n\n if (\n this.vel.y < 0 &&\n world.isSolid(Math.floor(nx), Math.floor(ny - 0.12), Math.floor(nz))\n ) {\n // отскок от пола\n this.pos.y = Math.floor(ny - 0.12) + 1 + 0.12;\n this.vel.y = Math.abs(this.vel.y) > 1 ? -this.vel.y * D.bounce : 0;\n this.vel.x *= D.friction;\n this.vel.z *= D.friction;\n } else {\n this.pos.y = ny;\n // горизонтальные стенки — просто стоп\n if (\n !world.isSolid(\n Math.floor(nx),\n Math.floor(this.pos.y),\n Math.floor(this.pos.z),\n )\n )\n this.pos.x = nx;\n else this.vel.x = 0;\n if (\n !world.isSolid(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y),\n Math.floor(nz),\n )\n )\n this.pos.z = nz;\n else this.vel.z = 0;\n }\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.mesh.position.set(\n p.x,\n p.y + Math.sin(time * 2 + this.id) * 0.05 + 0.05,\n p.z,\n );\n this.mesh.rotation.y = time * 1.2 + this.id;\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.mesh); /* геометрии/материалы в кэше — общие */\n }\n\n serialize() {\n return {\n kind: \"drop\",\n item: this.item,\n count: this.count,\n pos: this.pos.toArray(),\n };\n }\n}\n"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"path": "entities/Mob.ts",
|
|
44
|
+
"content": "// Универсальный моб: риг собирается из parts-описания реестра (BoxGeometry),\n// ИИ — простое блуждание с испугом, анимации процедурные (ноги/голова/крылья).\n// Добавление свиньи/зомби = запись в Mobs.js, этот код не меняется.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus.js\";\nimport type { ChunkManager } from \"../world/ChunkManager.js\";\nimport type { MobDef } from \"../types.js\";\n\nconst PX = 1 / 16; // части описаны в пикселях: 16 px = 1 блок\n\nexport class Mob {\n id: number;\n kind: \"mob\";\n def: MobDef;\n hp: number;\n pos: THREE.Vector3;\n prevPos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n _targetYaw: number;\n onGround: boolean;\n dead: boolean;\n _state: \"idle\" | \"walk\" | \"flee\";\n _stateTime: number;\n _animTime: number;\n _flash: number;\n group: THREE.Group;\n parts: Record<string, THREE.Group>;\n _materials: THREE.MeshLambertMaterial[];\n mesh: THREE.Object3D;\n\n constructor(id: number, def: MobDef, x: number, y: number, z: number) {\n this.id = id;\n this.kind = \"mob\";\n this.def = def;\n this.hp = def.hp;\n this.pos = new THREE.Vector3(x, y, z);\n this.prevPos = this.pos.clone();\n this.vel = new THREE.Vector3();\n this.yaw = Math.random() * Math.PI * 2;\n this._targetYaw = this.yaw;\n this.onGround = false;\n this.dead = false;\n this._state = \"idle\";\n this._stateTime = 1 + Math.random() * 2;\n this._animTime = 0;\n this._flash = 0; // красная вспышка после удара\n\n // риг: каждая часть — Group в точке pivot, бокс смещён относительно pivot\n this.group = new THREE.Group();\n this.parts = {};\n this._materials = [];\n for (const p of def.parts) {\n const mat = new THREE.MeshLambertMaterial({ color: p.color });\n this._materials.push(mat);\n const mesh = new THREE.Mesh(\n new THREE.BoxGeometry(p.size[0] * PX, p.size[1] * PX, p.size[2] * PX),\n mat,\n );\n const pivot = new THREE.Group();\n const pv = p.pivot ?? p.pos;\n pivot.position.set(pv[0] * PX, pv[1] * PX, pv[2] * PX);\n mesh.position.set(\n (p.pos[0] - pv[0]) * PX,\n (p.pos[1] - pv[1]) * PX,\n (p.pos[2] - pv[2]) * PX,\n );\n pivot.add(mesh);\n this.group.add(pivot);\n if (p.anim) this.parts[p.anim] = pivot;\n }\n this.mesh = this.group; // единообразие с ItemDrop\n }\n\n tick(dt: number, world: ChunkManager) {\n this.prevPos.copy(this.pos);\n if (this._flash > 0) this._flash -= dt;\n\n // ---- ИИ: цепочка состояний idle → walk → idle, flee после удара ----\n this._stateTime -= dt;\n if (this._stateTime <= 0) {\n if (this._state === \"walk\" || this._state === \"flee\") {\n this._state = \"idle\";\n this._stateTime = 1 + Math.random() * 3;\n } else {\n this._state = \"walk\";\n this._targetYaw = Math.random() * Math.PI * 2;\n this._stateTime = 1.5 + Math.random() * 2.5;\n }\n }\n // плавный поворот к целевому направлению\n let dy = this._targetYaw - this.yaw;\n while (dy > Math.PI) dy -= Math.PI * 2;\n while (dy < -Math.PI) dy += Math.PI * 2;\n this.yaw += dy * Math.min(1, 6 * dt);\n\n const speed =\n this._state === \"flee\"\n ? this.def.fleeSpeed\n : this._state === \"walk\"\n ? this.def.walkSpeed\n : 0;\n const wishX = -Math.sin(this.yaw) * speed;\n const wishZ = -Math.cos(this.yaw) * speed;\n const accel = Math.min(1, 10 * dt);\n this.vel.x += (wishX - this.vel.x) * accel;\n this.vel.z += (wishZ - this.vel.z) * accel;\n this.vel.y -= 22 * dt;\n\n // вода: всплывает\n const inWater =\n world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n ) === 11;\n if (inWater) {\n this.vel.y = Math.max(this.vel.y, 1.5);\n }\n\n this._move(dt, world);\n\n // упёрся в стену на ходу — подпрыгнуть (курицы умеют на 1 блок)\n if (\n speed > 0 &&\n this.onGround &&\n Math.hypot(this.vel.x, this.vel.z) < speed * 0.3\n )\n this.vel.y = 7;\n\n this._animTime +=\n dt * (speed > 0 ? Math.hypot(this.vel.x, this.vel.z) * 3 : 0);\n }\n\n _move(dt: number, world: ChunkManager) {\n const { w, h } = this.def.box;\n const half = w / 2,\n eps = 0.001;\n const collides = (px: number, py: number, pz: number) => {\n for (let by = Math.floor(py); by <= Math.floor(py + h - eps); by++)\n for (\n let bx = Math.floor(px - half);\n bx <= Math.floor(px + half - eps);\n bx++\n )\n for (\n let bz = Math.floor(pz - half);\n bz <= Math.floor(pz + half - eps);\n bz++\n )\n if (world.isSolid(bx, by, bz)) return true;\n return false;\n };\n\n // Y\n const ny = this.pos.y + this.vel.y * dt;\n if (collides(this.pos.x, ny, this.pos.z)) {\n if (this.vel.y <= 0) {\n this.pos.y = Math.floor(ny) + 1;\n this.onGround = true;\n } else this.pos.y = Math.floor(ny + h) - h - eps;\n this.vel.y = 0;\n } else {\n this.pos.y = ny;\n this.onGround = false;\n }\n // X\n const nx = this.pos.x + this.vel.x * dt;\n if (!collides(nx, this.pos.y, this.pos.z)) this.pos.x = nx;\n else this.vel.x = 0;\n // Z\n const nz = this.pos.z + this.vel.z * dt;\n if (!collides(this.pos.x, this.pos.y, nz)) this.pos.z = nz;\n else this.vel.z = 0;\n }\n\n /** Удар: урон, отброс от атакующего, красная вспышка, паника. */\n hurt(damage: number, fromPos: THREE.Vector3) {\n this.hp -= damage;\n this._flash = 0.4;\n const away = this.pos.clone().sub(fromPos).setY(0).normalize();\n this.vel.add(away.multiplyScalar(6)).setY(5);\n this._state = \"flee\";\n this._targetYaw = Math.atan2(-away.x, -away.z) + Math.PI; // бежит прочь\n this._stateTime = 1.2;\n events.emit(\"mobHurt\", { entity: this, amount: damage });\n if (this.hp <= 0) this.dead = true;\n }\n\n updateVisual(alpha: number, time: number) {\n const p = this.prevPos.clone().lerp(this.pos, alpha);\n this.group.position.copy(p);\n this.group.rotation.y = this.yaw;\n\n // анимации: шаг ногами, покачивание головы, крылья при падении\n const swing = Math.sin(this._animTime * 6) * 0.7;\n if (this.parts.legL) this.parts.legL.rotation.x = swing;\n if (this.parts.legR) this.parts.legR.rotation.x = -swing;\n if (this.parts.head)\n this.parts.head.rotation.x = Math.sin(time * 1.7) * 0.08;\n const falling = !this.onGround && this.vel.y < -1;\n const wing = falling ? Math.sin(time * 30) * 0.9 : 0.05;\n if (this.parts.wingL) this.parts.wingL.rotation.z = wing;\n if (this.parts.wingR) this.parts.wingR.rotation.z = -wing;\n\n // вспышка урона\n const flash = this._flash > 0;\n for (const m of this._materials) {\n const want = flash ? 0xff3333 : 0x000000;\n if (m.emissive.getHex() !== want) m.emissive.setHex(want);\n }\n }\n\n dispose(scene: THREE.Scene) {\n scene.remove(this.group);\n // traverse даёт Object3D; узкий каст к Mesh для доступа к geometry (проверка isMesh — рантайм).\n this.group.traverse((o) => {\n if ((o as THREE.Mesh).isMesh) (o as THREE.Mesh).geometry.dispose();\n });\n for (const m of this._materials) m.dispose();\n }\n\n serialize() {\n return {\n kind: \"mob\",\n type: this.def.name,\n pos: this.pos.toArray(),\n hp: this.hp,\n };\n }\n}\n"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"path": "gfx/TextureAtlas.ts",
|
|
48
|
+
"content": "// Процедурный texture atlas: все текстуры рисуются на canvas 256×256 (16×16 тайлов\n// по 16px), никаких внешних файлов. NearestFilter + отключённые мипмапы + полутексельный\n// inset UV — честный пиксель-арт без bleeding.\nimport * as THREE from \"three\";\nimport { mulberry32, hashString } from \"../core/Noise.js\";\n\nconst TILE = 16; // пикселей в тайле\nconst GRID = 16; // тайлов в ряду\nconst SIZE = TILE * GRID;\n\n/** Закрашивает один пиксель тайла. */\ntype PxFn = (x: number, y: number, color: string) => void;\n/** Сидированный PRNG. */\ntype RngFn = () => number;\n/** painter-функция: рисует один тайл 16×16. */\ntype Painter = (px: PxFn, rng: RngFn) => void;\n\n/** Смешивание hex-цвета с чёрным/белым: amt в [-1..1]. */\nfunction shade(hex: string, amt: number): string {\n const n = parseInt(hex.slice(1), 16);\n let r = (n >> 16) & 255,\n g = (n >> 8) & 255,\n b = n & 255;\n if (amt >= 0) {\n r += (255 - r) * amt;\n g += (255 - g) * amt;\n b += (255 - b) * amt;\n } else {\n r *= 1 + amt;\n g *= 1 + amt;\n b *= 1 + amt;\n }\n return `rgb(${r | 0},${g | 0},${b | 0})`;\n}\n\n/** База: заливка цветом + пиксельный шум яркости. */\nfunction noisy(px: PxFn, base: string, variance: number, rng: RngFn): void {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(x, y, shade(base, (rng() - 0.5) * 2 * variance));\n}\n\n// ---- painter-функции: (px, rng) → рисуют один тайл 16×16 ---------------------\n// px(x, y, cssColor) закрашивает один пиксель тайла.\n\nconst painters: Record<string, Painter> = {\n grass_top(px, rng) {\n noisy(px, \"#5fa63c\", 0.1, rng);\n },\n dirt(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n },\n grass_side(px, rng) {\n noisy(px, \"#79553a\", 0.12, rng);\n for (let x = 0; x < TILE; x++) {\n const depth = 3 + ((rng() * 2) | 0); // рваная кромка дёрна\n for (let y = 0; y < depth; y++)\n px(x, y, shade(\"#5fa63c\", (rng() - 0.5) * 0.2));\n }\n },\n stone(px, rng) {\n noisy(px, \"#7d7d7d\", 0.08, rng);\n for (let i = 0; i < 5; i++) {\n // светлые/тёмные пятна\n const x = (rng() * TILE) | 0,\n y = (rng() * TILE) | 0,\n c = shade(\"#7d7d7d\", rng() < 0.5 ? -0.15 : 0.12);\n px(x, y, c);\n px((x + 1) & 15, y, c);\n }\n },\n cobblestone(px, rng) {\n noisy(px, \"#828282\", 0.1, rng);\n // «булыжники»: тёмная сетка неровных швов\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n const cell = (x + (y & 4 ? 3 : 0)) % 6 === 0 || y % 6 === 0;\n if (cell && rng() < 0.8)\n px(x, y, shade(\"#4a4a4a\", (rng() - 0.5) * 0.2));\n }\n },\n bedrock(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++)\n px(\n x,\n y,\n shade(rng() < 0.5 ? \"#2b2b2b\" : \"#565656\", (rng() - 0.5) * 0.3),\n );\n },\n sand(px, rng) {\n noisy(px, \"#dcd3a0\", 0.08, rng);\n },\n oak_log_side(px, rng) {\n for (let x = 0; x < TILE; x++) {\n const stripe = x % 4 === 3 ? -0.25 : (rng() - 0.5) * 0.15; // кора полосами\n for (let y = 0; y < TILE; y++)\n px(x, y, shade(\"#6b5030\", stripe + (rng() - 0.5) * 0.08));\n }\n },\n oak_log_top(px, rng) {\n noisy(px, \"#6b5030\", 0.1, rng);\n for (let y = 2; y < 14; y++)\n for (let x = 2; x < 14; x++)\n px(x, y, shade(\"#b8945f\", (rng() - 0.5) * 0.15));\n // годовые кольца\n for (const r of [2, 4]) {\n for (let a = 0; a < 64; a++) {\n const x = 8 + Math.round(Math.cos((a / 64) * Math.PI * 2) * r);\n const y = 8 + Math.round(Math.sin((a / 64) * Math.PI * 2) * r);\n px(x, y, \"#8a6d42\");\n }\n }\n },\n planks(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#a4824c\", (rng() - 0.5) * 0.12);\n if (y % 4 === 3) c = \"#6e5631\"; // горизонтальные швы досок\n if (\n (y < 4 && x === 11) ||\n (y >= 4 && y < 8 && x === 3) ||\n (y >= 8 && y < 12 && x === 13) ||\n (y >= 12 && x === 6)\n )\n c = \"#6e5631\"; // стыки\n px(x, y, c);\n }\n },\n leaves(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n if (rng() < 0.18) continue; // прозрачные «дырки» (alphaTest-вырезы)\n px(x, y, shade(\"#3e7a25\", (rng() - 0.5) * 0.35));\n }\n },\n water(px, rng) {\n for (let y = 0; y < TILE; y++)\n for (let x = 0; x < TILE; x++) {\n let c = shade(\"#3057d8\", (rng() - 0.5) * 0.12);\n if ((x + y * 2) % 8 === 0 && rng() < 0.5) c = shade(\"#4a74ea\", 0.1); // блики\n px(x, y, c);\n }\n },\n coal_ore(px, rng) {\n painters.stone(px, rng);\n oreBlobs(px, rng, \"#2e2e2e\");\n },\n iron_ore(px, rng) {\n painters.stone(px, rng);\n oreBlobs(px, rng, \"#d8af93\");\n },\n crafting_table_top(px, rng) {\n painters.planks(px, rng);\n for (let i = 0; i < TILE; i++) {\n px(i, 0, \"#59431f\");\n px(i, 15, \"#59431f\");\n px(0, i, \"#59431f\");\n px(15, i, \"#59431f\");\n }\n for (let y = 3; y < 13; y++)\n for (let x = 3; x < 13; x++)\n if (x === 3 || x === 12 || y === 3 || y === 12) px(x, y, \"#7d6134\");\n },\n crafting_table_side(px, rng) {\n painters.planks(px, rng);\n // «инструменты» на боковине: тёмные силуэты\n for (let y = 2; y < 7; y++)\n for (let x = 3; x < 6; x++) if (rng() < 0.8) px(x, y, \"#4d3a1c\");\n for (let y = 2; y < 7; y++)\n for (let x = 10; x < 13; x++) if (rng() < 0.8) px(x, y, \"#57422a\");\n },\n};\n\nfunction oreBlobs(px: PxFn, rng: RngFn, color: string): void {\n for (let i = 0; i < 5; i++) {\n const cx = (2 + rng() * 12) | 0,\n cy = (2 + rng() * 12) | 0;\n for (let dy = -1; dy <= 1; dy++)\n for (let dx = -1; dx <= 1; dx++)\n if (Math.abs(dx) + Math.abs(dy) < 2 || rng() < 0.4)\n px((cx + dx) & 15, (cy + dy) & 15, shade(color, (rng() - 0.5) * 0.2));\n }\n}\n\n// ---- пиксель-арт по строковым шаблонам (растения, предметы, инструменты) -----\n// Каждая строка — 16 символов; '.' = прозрачно, буквы — цвета из палитры.\n\nfunction patternPainter(\n rows: string[],\n palette: Record<string, string>,\n): Painter {\n return (px, rng) => {\n for (let y = 0; y < TILE; y++) {\n const row = rows[y] || \"\";\n for (let x = 0; x < TILE; x++) {\n const ch = row[x];\n if (!ch || ch === \".\") continue;\n px(x, y, shade(palette[ch], (rng() - 0.5) * 0.1));\n }\n }\n };\n}\n\nconst P = patternPainter;\n\npainters.tallgrass = P(\n [\n \"................\",\n \"................\",\n \"....g......g....\",\n \"..g.g...g..g.g..\",\n \"..g.g..gg..g.g..\",\n \"..gg.g.g.g.gg...\",\n \"...g.g.g.g.g....\",\n \"...g.gg.gg.g....\",\n \"....gg.g.gg.....\",\n \"....g.gg.g......\",\n \".....g.g.g......\",\n \".....gggg.......\",\n \"......gg........\",\n \"......gg........\",\n \"................\",\n \"................\",\n ],\n { g: \"#4c8a2f\" },\n);\n\npainters.flower_yellow = P(\n [\n \"................\",\n \"................\",\n \"......yy........\",\n \".....yYYy.......\",\n \".....yYYy.......\",\n \"......yy........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { y: \"#e8c11c\", Y: \"#fbe87a\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.flower_red = P(\n [\n \"................\",\n \"................\",\n \"......rr........\",\n \".....rRRr.......\",\n \".....rRRr.......\",\n \"......rr........\",\n \".......s........\",\n \".......s........\",\n \".......s........\",\n \"......ss........\",\n \".......s........\",\n \"....g..s..g.....\",\n \".....g.s.g......\",\n \"......gsg.......\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e86a5f\", s: \"#3e7a25\", g: \"#4c8a2f\" },\n);\n\npainters.stick = P(\n [\n \"................\",\n \"................\",\n \"............w...\",\n \"...........ww...\",\n \"..........ww....\",\n \".........ww.....\",\n \"........ww......\",\n \".......ww.......\",\n \"......ww........\",\n \".....ww.........\",\n \"....ww..........\",\n \"...ww...........\",\n \"...w............\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { w: \"#8a6a3a\" },\n);\n\npainters.feather = P(\n [\n \"................\",\n \"...........ff...\",\n \"..........ffff..\",\n \".........fffff..\",\n \"........ffffff..\",\n \".......ffffff...\",\n \"......ffffff....\",\n \".....ffffff.....\",\n \"....ffffff......\",\n \"....fffff.......\",\n \"...ffff.........\",\n \"...fff..........\",\n \"..qf............\",\n \"..q.............\",\n \".q..............\",\n \"................\",\n ],\n { f: \"#f2f2f2\", q: \"#b9b9b9\" },\n);\n\npainters.raw_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPpppppp....\",\n \"...pPppppppppb..\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#e8a2a2\", P: \"#f6caca\", b: \"#e3ddc4\" },\n);\n\npainters.coal_item = P(\n [\n \"................\",\n \"................\",\n \"................\",\n \".....cccc.......\",\n \"....cccccc......\",\n \"...ccCccccc.....\",\n \"...cCccccccc....\",\n \"...ccccCccccc...\",\n \"....ccccccccc...\",\n \"....cccCccccc...\",\n \".....ccccccc....\",\n \"......ccccc.....\",\n \".......ccc......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { c: \"#2c2c2c\", C: \"#4d4d4d\" },\n);\n\npainters.apple = P(\n [\n \"................\",\n \".......g........\",\n \".......s........\",\n \".....rrsr.......\",\n \"....rRRrrr......\",\n \"...rRRrrrrr.....\",\n \"...rRrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"...rrrrrrrr.....\",\n \"....rrrrrr......\",\n \"....rrrrrr......\",\n \".....rrrr.......\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { r: \"#c22f2f\", R: \"#e8635a\", s: \"#6e4a24\", g: \"#4c8a2f\" },\n);\n\npainters.cooked_chicken = P(\n [\n \"................\",\n \"................\",\n \"......pppp......\",\n \"....pppppppp....\",\n \"...pPPppppbp....\",\n \"...pPpppppppb...\",\n \"...ppppppppp.b..\",\n \"....pppppppbb...\",\n \".....pppppb.....\",\n \"......bbbb......\",\n \".....b....b.....\",\n \"....bb....bb....\",\n \"................\",\n \"................\",\n \"................\",\n \"................\",\n ],\n { p: \"#c8843e\", P: \"#e0a35a\", b: \"#8a5a2a\" },\n);\n\nconst TOOL_PALETTE = { w: \"#a4824c\", h: \"#6e5631\", s: \"#8a6a3a\" };\npainters.wooden_pickaxe = P(\n [\n \"................\",\n \"....wwwwwww.....\",\n \"...ww.....ww....\",\n \"..ww.......ww...\",\n \"..w.........w...\",\n \"..w....s....w...\",\n \".......s........\",\n \"......ss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_axe = P(\n [\n \"................\",\n \".....wwww.......\",\n \"....wwwwww......\",\n \"....ww..ww......\",\n \"....ww.ss.......\",\n \"....wwss........\",\n \"......s.........\",\n \".....ss.........\",\n \".....s..........\",\n \"....ss..........\",\n \"....s...........\",\n \"...ss...........\",\n \"...s............\",\n \"..ss............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\npainters.wooden_sword = P(\n [\n \"................\",\n \"...........ww...\",\n \"..........www...\",\n \".........www....\",\n \"........www.....\",\n \".......www......\",\n \"......www.......\",\n \".....www........\",\n \"..h.www.........\",\n \"...hwww.........\",\n \"...shh..........\",\n \"..ss.hh.........\",\n \"..ss..hh........\",\n \".ss.............\",\n \"................\",\n \"................\",\n ],\n TOOL_PALETTE,\n);\n\n// ---- стадии трещин (ряд 14, тайлы 224..233) ----------------------------------\n\nfunction crackPainter(stage: number): Painter {\n return (px, rng) => {\n // от центра расходятся stage+1 ломаных трещин; глубже стадия — гуще\n const cracks = 2 + stage;\n for (let c = 0; c < cracks; c++) {\n let x = 7 + rng() * 2,\n y = 7 + rng() * 2;\n const ang = rng() * Math.PI * 2;\n let dx = Math.cos(ang),\n dy = Math.sin(ang);\n const len = 3 + stage * 1.2;\n for (let i = 0; i < len; i++) {\n px(Math.round(x) & 15, Math.round(y) & 15, \"rgba(20,15,10,0.85)\");\n x += dx;\n y += dy;\n dx += (rng() - 0.5) * 0.8;\n dy += (rng() - 0.5) * 0.8; // излом\n const n = Math.hypot(dx, dy) || 1;\n dx /= n;\n dy /= n;\n }\n }\n // на поздних стадиях — крошка по всему тайлу\n for (let i = 0; i < stage * 4; i++)\n px((rng() * TILE) | 0, (rng() * TILE) | 0, \"rgba(20,15,10,0.7)\");\n };\n}\nfor (let s = 0; s < 10; s++) painters[\"crack\" + s] = crackPainter(s);\n\n// ---- сборка атласа ------------------------------------------------------------\n\n// Порядок = индекс тайла. Ряд 14 зарезервирован под трещины.\nconst TILE_ORDER = [\n \"grass_top\",\n \"grass_side\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"bedrock\",\n \"sand\",\n \"oak_log_side\",\n \"oak_log_top\",\n \"planks\",\n \"leaves\",\n \"water\",\n \"coal_ore\",\n \"iron_ore\",\n \"crafting_table_top\",\n \"crafting_table_side\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n \"stick\",\n \"feather\",\n \"raw_chicken\",\n \"coal_item\",\n \"apple\",\n \"cooked_chicken\",\n \"wooden_pickaxe\",\n \"wooden_axe\",\n \"wooden_sword\",\n];\nconst CRACK_BASE = 14 * GRID; // индекс тайла crack0\n\nexport class TextureAtlas {\n canvas: HTMLCanvasElement;\n tiles: Record<string, number>;\n texture: THREE.CanvasTexture;\n waterTexture: THREE.CanvasTexture;\n opaqueMat: THREE.MeshLambertMaterial;\n foliageMat: THREE.MeshLambertMaterial;\n waterMat: THREE.MeshLambertMaterial;\n _iconCache: Map<string, HTMLCanvasElement>;\n\n constructor() {\n this.canvas = document.createElement(\"canvas\");\n this.canvas.width = this.canvas.height = SIZE;\n const ctx = this.canvas.getContext(\"2d\")!; // код всегда рассчитывает на валидный 2d-контекст\n this.tiles = {}; // имя → индекс\n\n const paint = (name: string, index: number) => {\n const tx = (index % GRID) * TILE,\n ty = ((index / GRID) | 0) * TILE;\n const rng = mulberry32(hashString(\"tile:\" + name));\n const px: PxFn = (x, y, color) => {\n ctx.fillStyle = color;\n ctx.fillRect(tx + x, ty + y, 1, 1);\n };\n painters[name](px, rng);\n };\n\n TILE_ORDER.forEach((name, i) => {\n this.tiles[name] = i;\n paint(name, i);\n });\n for (let s = 0; s < 10; s++) {\n this.tiles[\"crack\" + s] = CRACK_BASE + s;\n paint(\"crack\" + s, CRACK_BASE + s);\n }\n\n this.texture = new THREE.CanvasTexture(this.canvas);\n this.texture.magFilter = THREE.NearestFilter;\n this.texture.minFilter = THREE.NearestFilter;\n this.texture.generateMipmaps = false;\n this.texture.colorSpace = THREE.SRGBColorSpace;\n\n // Отдельная маленькая текстура воды: у водного материала свой repeat-тайл,\n // чтобы анимировать offset, не сдвигая UV всего атласа.\n const wc = document.createElement(\"canvas\");\n wc.width = wc.height = TILE;\n const wctx = wc.getContext(\"2d\")!; // тот же расчёт на валидный 2d-контекст\n const wrng = mulberry32(hashString(\"tile:water-solo\"));\n painters.water((x, y, c) => {\n wctx.fillStyle = c;\n wctx.fillRect(x, y, 1, 1);\n }, wrng);\n this.waterTexture = new THREE.CanvasTexture(wc);\n this.waterTexture.magFilter = this.waterTexture.minFilter =\n THREE.NearestFilter;\n this.waterTexture.generateMipmaps = false;\n this.waterTexture.wrapS = this.waterTexture.wrapT = THREE.RepeatWrapping;\n this.waterTexture.colorSpace = THREE.SRGBColorSpace;\n\n // Материалы чанков — общие на весь мир (3 draw-call-группы).\n this.opaqueMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n });\n this.foliageMat = new THREE.MeshLambertMaterial({\n map: this.texture,\n vertexColors: true,\n alphaTest: 0.5,\n side: THREE.DoubleSide,\n });\n this.waterMat = new THREE.MeshLambertMaterial({\n map: this.waterTexture,\n vertexColors: true,\n transparent: true,\n opacity: 0.72,\n depthWrite: false,\n });\n\n this._iconCache = new Map();\n }\n\n /** UV-прямоугольник тайла с полутексельным inset. Индекс или имя. */\n uv(tile: string | number): {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n } {\n const i = typeof tile === \"string\" ? this.tiles[tile] : tile;\n const col = i % GRID,\n row = (i / GRID) | 0;\n const inset = 0.5 / SIZE;\n return {\n u0: col / GRID + inset,\n u1: (col + 1) / GRID - inset,\n // canvas рисует сверху вниз, UV в three — снизу вверх\n v0: 1 - (row + 1) / GRID + inset,\n v1: 1 - row / GRID - inset,\n };\n }\n\n /** Канвас-фрагмент тайла (для иконок). */\n tileCanvas(name: string, shadeAmt = 0): HTMLCanvasElement {\n const i = this.tiles[name];\n const c = document.createElement(\"canvas\");\n c.width = c.height = TILE;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.drawImage(\n this.canvas,\n (i % GRID) * TILE,\n ((i / GRID) | 0) * TILE,\n TILE,\n TILE,\n 0,\n 0,\n TILE,\n TILE,\n );\n if (shadeAmt !== 0) {\n ctx.globalCompositeOperation = \"source-atop\";\n ctx.fillStyle = `rgba(0,0,0,${shadeAmt})`;\n ctx.fillRect(0, 0, TILE, TILE);\n }\n return c;\n }\n\n /** Изометрическая иконка блока (48×48) из его top/side тайлов. */\n blockIcon(topName: string, sideName: string): HTMLCanvasElement {\n const key = \"b:\" + topName + \"/\" + sideName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n const top = this.tileCanvas(topName, 0);\n const left = this.tileCanvas(sideName, 0.25);\n const right = this.tileCanvas(sideName, 0.42);\n const k = 21 / TILE; // размер грани\n // верхняя грань (ромб), затем левая и правая\n ctx.setTransform(k, k * 0.5, -k, k * 0.5, 24, 3);\n ctx.drawImage(top, 0, 0);\n ctx.setTransform(k, k * 0.5, 0, k, 24 - k * TILE, 3 + k * TILE * 0.5);\n ctx.drawImage(left, 0, 0);\n ctx.setTransform(k, -k * 0.5, 0, k, 24, 3 + k * TILE);\n ctx.drawImage(right, 0, 0);\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n this._iconCache.set(key, c);\n return c;\n }\n\n /** Плоская иконка предмета (его тайл, 48×48, nearest). */\n flatIcon(tileName: string): HTMLCanvasElement {\n const key = \"f:\" + tileName;\n if (this._iconCache.has(key)) return this._iconCache.get(key)!; // has() гарантирует наличие\n const c = document.createElement(\"canvas\");\n c.width = c.height = 48;\n const ctx = c.getContext(\"2d\")!; // расчёт на валидный 2d-контекст\n ctx.imageSmoothingEnabled = false;\n ctx.drawImage(this.tileCanvas(tileName), 0, 0, 48, 48);\n this._iconCache.set(key, c);\n return c;\n }\n}\n"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"path": "index.ts",
|
|
52
|
+
"content": "// @idosgames/mod-voxelcraft — the VoxelCraft feature module (vanilla Three.js sandbox, no React,\n// no SDK) for the host shell. Scene-only: it registers an engine scene and a nav entry.\n\nexport { voxelcraftModule } from \"./module\";\nexport { createVoxelScene } from \"./scene\";\nexport { Game } from \"./main\";\n"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"path": "main.ts",
|
|
56
|
+
"content": "// Точка сборки: единственное место, где конструируются все системы и\n// внедряются зависимости. Модули общаются через EventBus или переданные ссылки.\n// Тик (20 TPS) — симуляция; кадр — ввод, физика игрока, интерполяция, рендер.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"./config.js\";\nimport { events } from \"./core/EventBus.js\";\nimport { GameLoop } from \"./core/GameLoop.js\";\nimport { IntentQueue } from \"./core/Intents.js\";\nimport { TextureAtlas } from \"./gfx/TextureAtlas.js\";\nimport { buildIcons } from \"./registry/Items.js\";\nimport { ChunkManager } from \"./world/ChunkManager.js\";\nimport { Player } from \"./player/Player.js\";\nimport { Input } from \"./player/Input.js\";\nimport { Interaction } from \"./player/Interaction.js\";\nimport { ArmView } from \"./player/ArmView.js\";\nimport { EntityManager } from \"./entities/EntityManager.js\";\nimport { Inventory } from \"./systems/Inventory.js\";\nimport { WaterSim } from \"./systems/WaterSim.js\";\nimport { DayNight } from \"./systems/DayNight.js\";\nimport { Audio } from \"./systems/Audio.js\";\nimport { SaveSystem } from \"./systems/Save.js\";\nimport { HUD } from \"./ui/HUD.js\";\nimport { InventoryUI } from \"./ui/InventoryUI.js\";\n\nexport class Game {\n renderer: THREE.WebGLRenderer;\n scene: THREE.Scene;\n camera: THREE.PerspectiveCamera;\n private _resizeObserver: ResizeObserver;\n atlas: TextureAtlas;\n intents: IntentQueue;\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n entities: EntityManager;\n interaction: Interaction;\n waterSim: WaterSim;\n dayNight: DayNight;\n audio: Audio;\n input: Input;\n armView: ArmView;\n save: SaveSystem;\n hud: HUD;\n invUI: InventoryUI;\n playing: boolean;\n private _time: number;\n loop: GameLoop;\n\n constructor() {\n // --- рендер ---\n const canvas = document.getElementById(\"game\") as HTMLCanvasElement;\n this.renderer = new THREE.WebGLRenderer({ canvas, antialias: false });\n this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));\n this.scene = new THREE.Scene();\n // Пропорции выставит первый же замер ResizeObserver'а — до него кадров не будет.\n this.camera = new THREE.PerspectiveCamera(\n 75,\n 1,\n 0.1,\n CONFIG.renderDistance * 16 + 60,\n );\n this.camera.rotation.order = \"YXZ\";\n this.scene.add(this.camera); // рука прикреплена к камере\n\n // Размер берём из раскладки самого канваса (CSS растягивает его на вьюпорт), а не из\n // window.innerWidth, и через ResizeObserver, а не событие resize: игра может стартовать\n // в ещё не разложенном контейнере — фоновая вкладка или iframe превью, — где на момент\n // конструктора innerWidth равен нулю. Событие resize там уже не придёт, и канвас навсегда\n // остался бы 0x0: симуляция живая, экран чёрный. ResizeObserver сам отдаёт первый замер,\n // как только раскладка появляется.\n this._resizeObserver = new ResizeObserver(() => this._resize());\n this._resizeObserver.observe(canvas);\n this._resize();\n\n // --- контент: атлас и иконки из одних и тех же процедурных текстур ---\n this.atlas = new TextureAtlas();\n buildIcons(this.atlas);\n\n // --- системы ---\n this.intents = new IntentQueue();\n this.world = new ChunkManager(this.scene, this.atlas, CONFIG.world.seed);\n this.player = new Player(this.world);\n this.inventory = new Inventory();\n this.entities = new EntityManager(\n this.scene,\n this.world,\n this.atlas,\n this.player,\n this.inventory,\n );\n this.interaction = new Interaction(\n this.world,\n this.player,\n this.inventory,\n this.intents,\n this.scene,\n this.atlas,\n this.entities,\n );\n this.waterSim = new WaterSim(this.world);\n this.dayNight = new DayNight(this.scene, this.renderer);\n this.audio = new Audio();\n this.input = new Input(canvas, this.intents);\n this.armView = new ArmView(this.camera, this.atlas);\n this.save = new SaveSystem(this);\n this.hud = new HUD(this);\n this.invUI = new InventoryUI(this);\n\n this.playing = false;\n this._time = 0;\n\n events.on(\"ui:pause\", () => {\n this.playing = false;\n if (this.invUI.open) this.invUI.close();\n this.hud.showOverlay(true);\n });\n events.on(\"ui:resume\", () => {\n this.playing = true;\n this.hud.hideOverlay();\n });\n document.addEventListener(\"keydown\", (e) => {\n if (e.code === \"Escape\" && this.invUI.open) this.invUI.close();\n });\n // редкий случай: лок слетел, оверлея нет — клик по канвасу возвращает захват\n canvas.addEventListener(\"click\", () => {\n if (this.playing && !this.input.locked && !this.input.uiOpen)\n this.input.requestLock();\n });\n\n // --- старт мира ---\n this.warmup(8, 8);\n this.player.spawn();\n\n this.loop = new GameLoop(this.tick.bind(this), this.frame.bind(this));\n this.hud.showOverlay(false);\n this.loop.start();\n }\n\n /** Подгонка рендера под текущую раскладку канваса. Вызывается ResizeObserver'ом. */\n _resize() {\n const canvas = this.renderer.domElement;\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n if (w === 0 || h === 0) return; // ещё не разложено — придёт следующим замером\n\n this.camera.aspect = w / h;\n this.camera.updateProjectionMatrix();\n // updateStyle=false: размерами канваса управляет CSS (#game { position: fixed; inset: 0 }),\n // инлайновый стиль от three конфликтовал бы с ним.\n this.renderer.setSize(w, h, false);\n }\n\n /** Синхронная прогенерация зоны вокруг точки (старт/телепорт/импорт). */\n warmup(x: number, z: number) {\n for (let i = 0; i < 200; i++) {\n this.world.update(x, z);\n const busy =\n this.world.genQueue.length > 0 ||\n [...this.world.chunks.values()].some(\n (c) => c.generated && (c.dirty || c.waterDirty),\n );\n if (!busy) break;\n }\n }\n\n /** Пересоздание мира под новый сид (регенерация/импорт сейва). */\n rebuildWorld(seed: number) {\n this.world.clear();\n this.world = new ChunkManager(this.scene, this.atlas, seed);\n this.player.world = this.world;\n this.entities.world = this.world;\n this.interaction.world = this.world;\n this.waterSim.world = this.world;\n this.waterSim.queue.clear();\n this.interaction.breaking = null;\n this.intents.clear();\n }\n\n /** Полностью новый мир: чистые инвентарь, сущности, время. */\n newWorld(seed: number) {\n this.rebuildWorld(seed);\n this.entities.clear();\n this.inventory.deserialize({\n slots: new Array(36).fill(null),\n hotbarIndex: 0,\n });\n this.dayNight.time = this.dayNight.cycleLength * 0.15;\n this.warmup(8, 8);\n this.player.spawn();\n this.hud.message(\"Новый мир, сид \" + seed, 1800);\n }\n\n startPlay() {\n this.playing = true;\n this.hud.hideOverlay();\n this.audio.unlock();\n this.input.requestLock();\n }\n\n // ---- симуляция (20 TPS) ----\n tick(dt: number, n: number) {\n if (!this.playing) return;\n this.interaction.tick(dt);\n this.player.tickHunger(dt);\n this.entities.tick(dt);\n this.waterSim.tick();\n this.world.tick(); // onTick-блоки (задел автоматизации)\n this.dayNight.tick(dt);\n events.emit(\"tick\", { n });\n }\n\n // ---- кадр ----\n frame(dt: number, alpha: number) {\n this._time += dt;\n\n if (this.playing) {\n this.player.update(dt, this.input);\n this.interaction.frame(this.input);\n this.interaction.frameTimers(dt);\n }\n\n // камера из игрока\n const eye = this.player.eyePos;\n this.camera.position.copy(eye);\n this.camera.rotation.set(this.player.pitch, this.player.yaw, 0);\n\n this.world.update(this.player.pos.x, this.player.pos.z);\n this.entities.updateVisuals(alpha, this._time);\n this.dayNight.frame(this.player.pos, this.player.headInWater);\n\n const walking =\n this.playing &&\n this.player.onGround &&\n (this.input.forward ||\n this.input.back ||\n this.input.left ||\n this.input.right);\n this.armView.frame(dt, this.inventory, walking, this._time);\n\n // анимация поверхности воды: медленный сдвиг repeat-текстуры\n this.atlas.waterTexture.offset.x = this._time * 0.02;\n this.atlas.waterTexture.offset.y = this._time * 0.013;\n\n this.hud.frame();\n this.renderer.render(this.scene, this.camera);\n }\n\n /** Teardown for the host: stop the loop, release the pointer, dispose GPU + observers. Called by\n * the module's EngineScene when the host destroys the stage. (This engine is authored as a\n * standalone app; the host adds this so it can live as a composable module.) */\n destroy() {\n this.loop.stop();\n this.input.releaseLock();\n this._resizeObserver.disconnect();\n this.world.clear();\n this.renderer.dispose();\n this.renderer.forceContextLoss();\n }\n\n /** Pause/resume from the host's Mode Router (only the active mode runs its loop). */\n setRunning(running: boolean) {\n if (running) {\n if (!this.loop.running) this.loop.start();\n } else {\n this.loop.stop();\n this.input.releaseLock();\n this.playing = false;\n }\n }\n}\n"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"path": "module.ts",
|
|
60
|
+
"content": "import { defineModule, type Module } from \"@idosgames/module-sdk\";\nimport { createVoxelScene } from \"./scene\";\n\n// The VoxelCraft feature module. It contributes ONLY an engine scene and a nav entry — no React UI\n// panels (the game draws its own DOM HUD). This exercises the scene-only branch of the contract and\n// proves a module need not use React or the SDK at all.\nexport const voxelcraftModule: Module = defineModule({\n id: \"voxelcraft\",\n meta: {\n name: \"VoxelCraft\",\n type: \"game\",\n genre: \"sandbox\",\n engine: \"three\",\n },\n setup(ctx) {\n ctx.registerScene(createVoxelScene());\n ctx.registerRoute({ id: \"voxelcraft\", label: \"Voxel\", icon: \"🧊\" });\n },\n});\n"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"path": "player/ArmView.ts",
|
|
64
|
+
"content": "// Рука от первого лица: блочная рука в стиле Minecraft, прикреплена к камере.\n// Если в руке блок — вместо руки показывается мини-куб блока; плоские предметы —\n// квад с иконкой. Замах — процедурная анимация по событию armSwing.\nimport * as THREE from \"three\";\nimport { events } from \"../core/EventBus.js\";\nimport { BLOCKS } from \"../registry/Blocks.js\";\nimport { ITEMS } from \"../registry/Items.js\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas.js\";\nimport type { Inventory } from \"../systems/Inventory.js\";\n\nexport class ArmView {\n camera: THREE.PerspectiveCamera;\n atlas: TextureAtlas;\n group: THREE.Group;\n armMesh: THREE.Mesh;\n itemMesh: THREE.Mesh | null;\n _heldKey: string | null;\n _swing: number;\n _texCache: Map<string, THREE.CanvasTexture>;\n _dirty: boolean;\n\n constructor(camera: THREE.PerspectiveCamera, atlas: TextureAtlas) {\n this.camera = camera;\n this.atlas = atlas;\n this.group = new THREE.Group();\n camera.add(this.group);\n this.group.position.set(0.42, -0.42, -0.7);\n\n // сама рука: «кожа» с рукавом\n const armMat = new THREE.MeshLambertMaterial({ color: 0xd8a06c });\n this.armMesh = new THREE.Mesh(\n new THREE.BoxGeometry(0.14, 0.14, 0.5),\n armMat,\n );\n this.armMesh.rotation.set(0.35, -0.25, 0);\n this.group.add(this.armMesh);\n\n this.itemMesh = null; // мини-блок/иконка в руке\n this._heldKey = null;\n this._swing = 0; // 0..1 — фаза замаха\n this._texCache = new Map();\n\n events.on(\"armSwing\", () => {\n this._swing = 1;\n });\n events.on(\"invChanged\", () => {\n this._dirty = true;\n });\n this._dirty = true;\n }\n\n /** Пересоздать меш предмета при смене активного слота. */\n _syncHeld(inventory: Inventory): void {\n const held = inventory.heldItem();\n const key = held ? held.item : null;\n if (key === this._heldKey && !this._dirty) return;\n this._heldKey = key;\n this._dirty = false;\n\n if (this.itemMesh) {\n this.group.remove(this.itemMesh);\n this.itemMesh.geometry.dispose();\n this.itemMesh = null;\n }\n this.armMesh.visible = !key;\n if (!key) return;\n\n const item = ITEMS[key];\n if (item.blockId !== null && !BLOCKS[item.blockId].cross) {\n const def = BLOCKS[item.blockId];\n const geo = new THREE.BoxGeometry(0.3, 0.3, 0.3);\n const uvAttr = geo.getAttribute(\"uv\");\n const tiles = [\n def.tex!.side!,\n def.tex!.side!,\n def.tex!.top!,\n def.tex!.bottom!,\n def.tex!.side!,\n def.tex!.side!,\n ];\n for (let f = 0; f < 6; f++) {\n const r = this.atlas.uv(tiles[f]);\n for (let v = 0; v < 4; v++) {\n const i = f * 4 + v;\n uvAttr.setXY(\n i,\n r.u0 + (r.u1 - r.u0) * uvAttr.getX(i),\n r.v0 + (r.v1 - r.v0) * uvAttr.getY(i),\n );\n }\n }\n // белые вершинные цвета: общий материал чанков ждёт color-атрибут (vertexColors)\n geo.setAttribute(\n \"color\",\n new THREE.Float32BufferAttribute(\n new Float32Array(geo.getAttribute(\"position\").count * 3).fill(1),\n 3,\n ),\n );\n this.itemMesh = new THREE.Mesh(geo, this.atlas.opaqueMat);\n this.itemMesh.position.set(0, -0.02, -0.1);\n this.itemMesh.rotation.set(0.1, Math.PI / 5, 0);\n } else {\n let tex = this._texCache.get(key);\n if (!tex) {\n tex = new THREE.CanvasTexture(item.icon);\n tex.magFilter = tex.minFilter = THREE.NearestFilter;\n tex.generateMipmaps = false;\n tex.colorSpace = THREE.SRGBColorSpace;\n this._texCache.set(key, tex);\n }\n const mat = new THREE.MeshBasicMaterial({\n map: tex,\n transparent: true,\n alphaTest: 0.1,\n side: THREE.DoubleSide,\n });\n this.itemMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.4, 0.4), mat);\n this.itemMesh.position.set(0, 0, -0.1);\n this.itemMesh.rotation.set(-0.15, Math.PI / 6, 0.5);\n }\n this.group.add(this.itemMesh);\n }\n\n frame(\n dt: number,\n inventory: Inventory,\n walking: boolean,\n time: number,\n ): void {\n this._syncHeld(inventory);\n\n // замах: быстрый удар вперёд-вниз с возвратом\n if (this._swing > 0) this._swing = Math.max(0, this._swing - dt * 3.2);\n const s = this._swing;\n const swingAng = Math.sin(s * Math.PI) * 1.1;\n\n // лёгкое покачивание при ходьбе\n const bobX = walking ? Math.sin(time * 7) * 0.015 : 0;\n const bobY = walking ? Math.abs(Math.cos(time * 7)) * 0.02 : 0;\n\n this.group.position.set(\n 0.42 + bobX,\n -0.42 + bobY - swingAng * 0.25,\n -0.7 - swingAng * 0.1,\n );\n this.group.rotation.set(-swingAng * 0.9, swingAng * 0.35, 0);\n }\n}\n"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"path": "player/Input.ts",
|
|
68
|
+
"content": "// Ввод: Pointer Lock, клавиши, мышь. НИКОГДА не трогает мир напрямую —\n// дискретные действия уходят интентами в очередь, непрерывные состояния\n// (WASD, зажатые кнопки мыши, взгляд) читает Player/Interaction на кадре.\nimport { events } from \"../core/EventBus.js\";\nimport { Intent, type IntentQueue } from \"../core/Intents.js\";\n\nexport class Input {\n canvas: HTMLCanvasElement;\n intents: IntentQueue;\n keys: Set<string>;\n leftDown: boolean;\n rightDown: boolean;\n dx: number;\n dy: number;\n locked: boolean;\n uiOpen: boolean;\n _lastSpace: number;\n\n constructor(canvas: HTMLCanvasElement, intents: IntentQueue) {\n this.canvas = canvas;\n this.intents = intents;\n this.keys = new Set(); // KeyboardEvent.code\n this.leftDown = false;\n this.rightDown = false;\n this.dx = 0;\n this.dy = 0; // накопленное движение мыши за кадр\n this.locked = false;\n this.uiOpen = false; // инвентарь открыт — игровой ввод заморожен\n this._lastSpace = 0; // двойной Space → полёт в креативе\n\n document.addEventListener(\"pointerlockchange\", () => {\n this.locked = document.pointerLockElement === canvas;\n if (!this.locked && !this.uiOpen) events.emit(\"ui:pause\");\n if (this.locked) events.emit(\"ui:resume\");\n this.keys.clear();\n this.leftDown = this.rightDown = false;\n });\n\n canvas.addEventListener(\"mousemove\", (e) => {\n if (!this.locked) return;\n this.dx += e.movementX;\n this.dy += e.movementY;\n });\n\n canvas.addEventListener(\"mousedown\", (e) => {\n if (!this.locked) return;\n if (e.button === 0) this.leftDown = true;\n if (e.button === 2) this.rightDown = true;\n });\n canvas.addEventListener(\"mouseup\", (e) => {\n if (e.button === 0) this.leftDown = false;\n if (e.button === 2) this.rightDown = false;\n });\n canvas.addEventListener(\"contextmenu\", (e) => e.preventDefault());\n\n canvas.addEventListener(\n \"wheel\",\n (e) => {\n if (!this.locked) return;\n this.intents.push(Intent.SelectSlot, { delta: Math.sign(e.deltaY) });\n e.preventDefault();\n },\n { passive: false },\n );\n\n document.addEventListener(\"keydown\", (e) => {\n if (e.repeat) return;\n // E работает и без pointer lock (закрыть инвентарь)\n if (e.code === \"KeyE\") {\n events.emit(\"ui:toggleInventory\");\n return;\n }\n if (!this.locked) return;\n this.keys.add(e.code);\n if (e.code.startsWith(\"Digit\")) {\n const n = +e.code.slice(5);\n if (n >= 1 && n <= 9)\n this.intents.push(Intent.SelectSlot, { slot: n - 1 });\n }\n if (e.code === \"KeyC\") this.intents.push(Intent.ToggleMode);\n if (e.code === \"Space\") {\n const now = performance.now();\n if (now - this._lastSpace < 300) events.emit(\"input:doubleSpace\");\n this._lastSpace = now;\n }\n });\n document.addEventListener(\"keyup\", (e) => this.keys.delete(e.code));\n }\n\n /** Захват мыши — только из user gesture (клик по оверлею). */\n requestLock(): void {\n // Браузер может бросить, если Esc был нажат <1.2 c назад — тихо игнорируем.\n this.canvas.requestPointerLock()?.catch?.(() => {});\n }\n releaseLock(): void {\n if (this.locked) document.exitPointerLock();\n }\n\n /** Забрать накопленную дельту мыши (вызывается раз в кадр). */\n consumeMouse(): { dx: number; dy: number } {\n const d = { dx: this.dx, dy: this.dy };\n this.dx = this.dy = 0;\n return d;\n }\n\n get forward(): boolean {\n return this.keys.has(\"KeyW\");\n }\n get back(): boolean {\n return this.keys.has(\"KeyS\");\n }\n get left(): boolean {\n return this.keys.has(\"KeyA\");\n }\n get right(): boolean {\n return this.keys.has(\"KeyD\");\n }\n get jump(): boolean {\n return this.keys.has(\"Space\");\n }\n get sneakOrDown(): boolean {\n return this.keys.has(\"ShiftLeft\") || this.keys.has(\"ShiftRight\");\n }\n get run(): boolean {\n return this.keys.has(\"ControlLeft\") || this.keys.has(\"ShiftLeft\");\n }\n}\n"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"path": "player/Interaction.ts",
|
|
72
|
+
"content": "// Взаимодействие с миром. Разделено на две половины:\n// frame() — «клиентская»: raycast из камеры, рамка выделения, перевод\n// состояния ввода (зажатая ЛКМ, цель) в интенты;\n// tick() — «серверная»: выгребает очередь интентов, валидирует\n// (дистанция, наличие предмета, кулдаун) и исполняет.\n// В этапе 2 tick()-половина живёт только у хоста; frame()-половина у всех.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config.js\";\nimport { events } from \"../core/EventBus.js\";\nimport { Intent, type IntentMsg, type IntentQueue } from \"../core/Intents.js\";\nimport { BLOCKS, B } from \"../registry/Blocks.js\";\nimport { ITEMS } from \"../registry/Items.js\";\nimport { raycastBlocks } from \"../world/Raycast.js\";\nimport type { ChunkManager } from \"../world/ChunkManager.js\";\nimport type { Player } from \"./Player.js\";\nimport type { Input } from \"./Input.js\";\nimport type { Inventory } from \"../systems/Inventory.js\";\nimport type { EntityManager } from \"../entities/EntityManager.js\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas.js\";\n\n/** Блок под прицелом (результат воксельного raycast). */\ntype BlockHit = NonNullable<ReturnType<typeof raycastBlocks>>;\n/** Ближайшая сущность под прицелом. */\ntype EntityHit = ReturnType<EntityManager[\"raycast\"]>;\n/** Состояние ломания блока (тиковая половина). */\ninterface BreakInfo {\n x: number;\n y: number;\n z: number;\n id: number;\n progress: number;\n total: number;\n}\n/** Накопленный урон по блоку: прогресс + таймер заживления. */\ninterface DamageInfo {\n id: number;\n progress: number;\n heal: number;\n}\n\nexport class Interaction {\n world: ChunkManager;\n player: Player;\n inventory: Inventory;\n intents: IntentQueue;\n entities: EntityManager;\n target: BlockHit | null;\n targetEntity: EntityHit;\n breaking: BreakInfo | null;\n _breakHeld: boolean;\n _sentBreakKey: string | null;\n _placeCooldown: number;\n _attackCooldown: number;\n eating: { item: string; time: number } | null;\n _sentEat: string | null;\n damage: Map<string, DamageInfo>;\n highlight: THREE.LineSegments;\n crackMat: THREE.MeshBasicMaterial;\n crackMesh: THREE.Mesh;\n atlas: TextureAtlas;\n _crackStage: number;\n _swungThisHold = false;\n\n constructor(\n world: ChunkManager,\n player: Player,\n inventory: Inventory,\n intents: IntentQueue,\n scene: THREE.Scene,\n atlas: TextureAtlas,\n entities: EntityManager,\n ) {\n this.world = world;\n this.player = player;\n this.inventory = inventory;\n this.intents = intents;\n this.entities = entities;\n this.target = null; // текущий блок под прицелом (кадровая половина)\n this.targetEntity = null;\n\n // Состояние ломания (тиковая половина — авторитетно)\n this.breaking = null; // {x,y,z,id, progress, total}\n this._breakHeld = false;\n this._sentBreakKey = null;\n this._placeCooldown = 0;\n this._attackCooldown = 0;\n this.eating = null; // {item, time} — процесс поедания (тиковая половина)\n this._sentEat = null; // защита от повторной отправки EatStart за удержание\n // Накопленный урон по блокам: \"x,y,z\" -> {id, progress, heal}.\n // Сохраняется при отпускании ЛКМ и медленно «заживает» в простое.\n this.damage = new Map();\n // блок сменился (сломан/заменён/затоплен) — забыть его урон\n events.on(\"blockChanged\", ({ x, y, z }) =>\n this.damage.delete(x + \",\" + y + \",\" + z),\n );\n\n // рамка выделения блока\n const boxGeo = new THREE.BoxGeometry(1.002, 1.002, 1.002);\n this.highlight = new THREE.LineSegments(\n new THREE.EdgesGeometry(boxGeo),\n new THREE.LineBasicMaterial({\n color: 0x111111,\n transparent: true,\n opacity: 0.6,\n }),\n );\n this.highlight.visible = false;\n scene.add(this.highlight);\n\n // decal трещин: чуть увеличенный куб с прозрачной текстурой стадии\n this.crackMat = new THREE.MeshBasicMaterial({\n map: atlas.texture.clone(),\n transparent: true,\n depthWrite: false,\n polygonOffset: true,\n polygonOffsetFactor: -1,\n polygonOffsetUnits: -2,\n });\n this.crackMat.map!.magFilter = this.crackMat.map!.minFilter =\n THREE.NearestFilter;\n this.crackMat.map!.generateMipmaps = false;\n this.crackMesh = new THREE.Mesh(\n new THREE.BoxGeometry(1.004, 1.004, 1.004),\n this.crackMat,\n );\n this.crackMesh.visible = false;\n this.crackMesh.renderOrder = 2;\n scene.add(this.crackMesh);\n this.atlas = atlas;\n this._crackStage = -1;\n }\n\n // ---- кадровая половина ------------------------------------------------------\n\n frame(input: Input): void {\n const eye = this.player.eyePos;\n const dir = this.player.lookDir();\n const hit = raycastBlocks(this.world, eye, dir, CONFIG.player.reach);\n // сущность ближе блока?\n const entHit = this.entities.raycast(\n eye,\n dir,\n hit ? hit.dist : CONFIG.player.reach,\n );\n this.targetEntity = entHit;\n this.target = entHit ? null : hit;\n\n if (this.target) {\n this.highlight.visible = true;\n this.highlight.position.set(\n this.target.x + 0.5,\n this.target.y + 0.5,\n this.target.z + 0.5,\n );\n } else {\n this.highlight.visible = false;\n }\n\n // ЛКМ: по сущности — атака (разовая), по блоку — ломание (удержание)\n if (input.leftDown && !input.uiOpen) {\n if (entHit) {\n this.intents.push(Intent.Attack, { entityId: entHit.id });\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n } else if (this.target) {\n const k = this.target.x + \",\" + this.target.y + \",\" + this.target.z;\n if (this._sentBreakKey !== k) {\n this.intents.push(Intent.BreakStart, {\n x: this.target.x,\n y: this.target.y,\n z: this.target.z,\n });\n this._sentBreakKey = k;\n }\n } else if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n if (!this._swungThisHold) {\n events.emit(\"armSwing\");\n this._swungThisHold = true;\n }\n } else {\n if (this._sentBreakKey) {\n this.intents.push(Intent.BreakStop);\n this._sentBreakKey = null;\n }\n this._swungThisHold = false;\n }\n\n // ПКМ: приоритет — интерактивный блок (верстак) > еда > постановка блока\n const held = this.inventory.heldItem();\n const heldFood = held && ITEMS[held.item]?.food > 0;\n const lookingAtTable =\n this.target && BLOCKS[this.target.id].name === \"crafting_table\";\n let eatingNow = false;\n if (input.rightDown && !input.uiOpen) {\n if (lookingAtTable && this._placeCooldown <= 0) {\n events.emit(\"ui:openCrafting\");\n this._placeCooldown = 0.25;\n } else if (\n heldFood &&\n this.player.food < CONFIG.player.maxFood &&\n this.player.mode !== \"creative\"\n ) {\n eatingNow = true;\n if (this._sentEat !== held.item) {\n this.intents.push(Intent.EatStart, { item: held.item });\n this._sentEat = held.item;\n }\n } else if (this.target && this._placeCooldown <= 0) {\n this.intents.push(Intent.PlaceBlock, {\n x: this.target.x + this.target.nx,\n y: this.target.y + this.target.ny,\n z: this.target.z + this.target.nz,\n });\n events.emit(\"armSwing\");\n this._placeCooldown = 0.25;\n }\n }\n if (!eatingNow && this._sentEat) {\n this.intents.push(Intent.EatStop);\n this._sentEat = null;\n }\n if (!input.rightDown) this._placeCooldown = 0;\n\n // визуализация трещин: активное ломание либо запомненный урон на блоке под прицелом\n let crack: { x: number; y: number; z: number; frac: number } | null = null;\n if (this.breaking && this.breaking.total > 0.06) {\n crack = {\n x: this.breaking.x,\n y: this.breaking.y,\n z: this.breaking.z,\n frac: this.breaking.progress / this.breaking.total,\n };\n } else if (this.target) {\n const d = this.damage.get(\n this.target.x + \",\" + this.target.y + \",\" + this.target.z,\n );\n const total = BLOCKS[this.target.id].hardness;\n if (d && d.id === this.target.id && d.progress > 0 && total > 0.06)\n crack = {\n x: this.target.x,\n y: this.target.y,\n z: this.target.z,\n frac: d.progress / total,\n };\n }\n\n if (crack) {\n const stage = Math.min(9, Math.floor(crack.frac * 10));\n if (stage !== this._crackStage) {\n const uv = this.atlas.uv(\"crack\" + stage);\n // двигаем offset/repeat клона атласа на нужный тайл\n this.crackMat.map!.repeat.set(uv.u1 - uv.u0, uv.v1 - uv.v0);\n this.crackMat.map!.offset.set(uv.u0, uv.v0);\n this._crackStage = stage;\n }\n this.crackMesh.visible = true;\n this.crackMesh.position.set(crack.x + 0.5, crack.y + 0.5, crack.z + 0.5);\n } else {\n this.crackMesh.visible = false;\n this._crackStage = -1;\n }\n }\n\n frameTimers(dt: number): void {\n if (this._placeCooldown > 0) this._placeCooldown -= dt;\n }\n\n // ---- тиковая половина (симуляция) --------------------------------------------\n\n tick(dt: number): void {\n if (this._attackCooldown > 0) this._attackCooldown -= dt;\n\n this.intents.drain((intent: IntentMsg) => {\n switch (intent.type) {\n case Intent.BreakStart:\n this._startBreak(intent);\n break;\n case Intent.BreakStop:\n this.breaking = null;\n break;\n case Intent.PlaceBlock:\n this._place(intent);\n break;\n case Intent.EatStart:\n this._startEat(intent);\n break;\n case Intent.EatStop:\n this.eating = null;\n break;\n case Intent.Attack:\n this._attack(intent);\n break;\n case Intent.SelectSlot:\n if (intent.slot !== undefined) this.inventory.selectSlot(intent.slot);\n else this.inventory.scrollSlot(intent.delta);\n break;\n case Intent.ToggleMode:\n this.player.toggleMode();\n break;\n }\n });\n\n // прогресс ломания\n let activeKey: string | null = null;\n if (this.breaking) {\n const { x, y, z } = this.breaking;\n if (this.world.getBlock(x, y, z) !== this.breaking.id) {\n this.damage.delete(x + \",\" + y + \",\" + z);\n this.breaking = null;\n } else {\n this.breaking.progress += dt * this._breakSpeed(this.breaking.id);\n activeKey = x + \",\" + y + \",\" + z;\n // запоминаем урон (heal:0 — таймер заживления сбрасывается, пока бьём)\n this.damage.set(activeKey, {\n id: this.breaking.id,\n progress: this.breaking.progress,\n heal: 0,\n });\n if (this.breaking.progress >= this.breaking.total) {\n this._finishBreak(x, y, z, this.breaking.id);\n this.damage.delete(activeKey);\n this.breaking = null;\n }\n }\n }\n\n // заживление урона по блокам, которые сейчас не ломают\n for (const [k, d] of this.damage) {\n if (k === activeKey) continue;\n d.heal += dt;\n if (d.heal < CONFIG.player.breakHealDelay) continue;\n d.progress -= dt * CONFIG.player.breakHealRate;\n if (d.progress <= 0) this.damage.delete(k);\n }\n\n // процесс поедания\n if (this.eating) {\n const held = this.inventory.heldItem();\n if (\n !held ||\n held.item !== this.eating.item ||\n this.player.food >= CONFIG.player.maxFood\n ) {\n this.eating = null;\n } else {\n this.eating.time += dt;\n events.emit(\"armSwing\"); // лёгкое подёргивание еды в руке\n if (this.eating.time >= CONFIG.player.eatTime) {\n if (this.player.eat(this.eating.item)) this.inventory.consumeHeld(1);\n this.eating = null;\n }\n }\n }\n }\n\n _startEat({ item }: IntentMsg): void {\n const held = this.inventory.heldItem();\n if (!held || held.item !== item) return;\n if (!ITEMS[item]?.food || this.player.food >= CONFIG.player.maxFood) return;\n this.eating = { item, time: 0 };\n }\n\n _validDistance(x: number, y: number, z: number): boolean {\n const e = this.player.eyePos;\n const dx = x + 0.5 - e.x,\n dy = y + 0.5 - e.y,\n dz = z + 0.5 - e.z;\n return dx * dx + dy * dy + dz * dz <= (CONFIG.player.reach + 1) ** 2;\n }\n\n /** Множитель скорости от инструмента в руке. */\n _breakSpeed(blockId: number): number {\n const def = BLOCKS[blockId];\n const held = this.inventory.heldItem();\n if (held && def.tool && ITEMS[held.item]?.toolType === def.tool)\n return ITEMS[held.item].speed;\n return 1;\n }\n\n _startBreak({ x, y, z }: IntentMsg): void {\n if (!this._validDistance(x, y, z)) return;\n const id = this.world.getBlock(x, y, z);\n const def = BLOCKS[id];\n if (id === B.air || def.liquid || def.hardness === Infinity) return;\n const total = this.player.mode === \"creative\" ? 0 : def.hardness;\n // возобновляем с запомненного урона (если это тот же блок), а не с нуля\n const stored = this.damage.get(x + \",\" + y + \",\" + z);\n const progress = stored && stored.id === id ? stored.progress : 0;\n this.breaking = { x, y, z, id, progress, total };\n if (total <= 0.05) {\n // мгновенное ломание\n this._finishBreak(x, y, z, id);\n this.damage.delete(x + \",\" + y + \",\" + z);\n this.breaking = null;\n }\n }\n\n _finishBreak(x: number, y: number, z: number, id: number): void {\n const def = BLOCKS[id];\n this.world.setBlock(x, y, z, B.air);\n events.emit(\"blockBreak\", { x, y, z, id, byPlayer: true });\n this.player.addExhaustion(CONFIG.player.exhaustBreak);\n // дроп: null = сам блок; [] = ничего; chance — вероятность (по умолчанию 1); в креативе дропов нет\n if (this.player.mode !== \"creative\") {\n const drops = def.drops === null ? [{ item: def.name, n: 1 }] : def.drops;\n for (const d of drops)\n if (\n ITEMS[d.item] &&\n (d.chance === undefined || Math.random() <= d.chance)\n )\n this.entities.spawnDrop(x + 0.5, y + 0.5, z + 0.5, d.item, d.n);\n }\n // если на блоке стояло растение — оно падает (ломается)\n const above = this.world.getBlock(x, y + 1, z);\n if (BLOCKS[above]?.cross) {\n this.world.setBlock(x, y + 1, z, B.air);\n events.emit(\"blockBreak\", { x, y: y + 1, z, id: above, byPlayer: false });\n }\n }\n\n _place({ x, y, z }: IntentMsg): void {\n if (!this._validDistance(x, y, z)) return;\n const held = this.inventory.heldItem();\n if (!held) return;\n const item = ITEMS[held.item];\n if (!item || item.blockId === null) return;\n const cur = this.world.getBlock(x, y, z);\n if (cur !== B.air && !BLOCKS[cur].liquid && !BLOCKS[cur].cross) return;\n if (BLOCKS[item.blockId].solid && this.player.intersectsBlock(x, y, z))\n return; // не в себя\n // крестовины — только на твёрдое основание\n if (BLOCKS[item.blockId].cross && !this.world.isSolid(x, y - 1, z)) return;\n\n this.world.setBlock(x, y, z, item.blockId);\n events.emit(\"blockPlace\", { x, y, z, id: item.blockId });\n if (this.player.mode !== \"creative\") this.inventory.consumeHeld(1);\n }\n\n _attack({ entityId }: IntentMsg): void {\n if (this._attackCooldown > 0) return;\n this._attackCooldown = CONFIG.player.attackCooldown;\n this.player.addExhaustion(CONFIG.player.exhaustAttack);\n const held = this.inventory.heldItem();\n const damage = held\n ? (ITEMS[held.item]?.damage ?? 1)\n : CONFIG.player.attackDamage;\n this.entities.hurt(entityId, damage, this.player.pos);\n }\n}\n"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"path": "player/Player.ts",
|
|
76
|
+
"content": "// Игрок: AABB-физика с поосевым разрешением коллизий, плавание, полёт (креатив),\n// здоровье и урон от падения. Физика игрока считается на КАДРЕ (20 Гц для камеры\n// ощущается плохо); в этапе 2 этот же код становится client-side prediction,\n// а у хоста он и есть авторитативная симуляция.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config.js\";\nimport { events } from \"../core/EventBus.js\";\nimport { BLOCKS, B } from \"../registry/Blocks.js\";\nimport { ITEMS } from \"../registry/Items.js\";\nimport type { ChunkManager } from \"../world/ChunkManager.js\";\nimport type { Input } from \"./Input.js\";\n\nconst P = CONFIG.player;\n\ntype PlayerMode = \"survival\" | \"creative\";\n\n/** Форма persist-блоба игрока (см. serialize/deserialize). */\ninterface PlayerSaveData {\n pos: number[];\n yaw: number;\n pitch: number;\n health: number;\n food?: number;\n saturation?: number;\n mode: PlayerMode;\n flying: boolean;\n}\n\nexport class Player {\n world: ChunkManager;\n pos: THREE.Vector3;\n vel: THREE.Vector3;\n yaw: number;\n pitch: number;\n onGround: boolean;\n inWater: boolean;\n headInWater: boolean;\n mode: PlayerMode;\n flying: boolean;\n health: number;\n food: number;\n saturation: number;\n exhaustion: number;\n dead: boolean;\n spawnPoint: THREE.Vector3;\n _fallDist: number;\n _stepDist: number;\n _regenTimer: number;\n _starveTimer: number;\n _sprinting = false;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.pos = new THREE.Vector3(8.5, 40, 8.5); // ноги; уточняется при спавне\n this.vel = new THREE.Vector3();\n this.yaw = 0;\n this.pitch = 0;\n this.onGround = false;\n this.inWater = false;\n this.headInWater = false;\n this.mode = \"survival\"; // 'survival' | 'creative'\n this.flying = false;\n this.health = P.maxHealth;\n // голод: еда [0..maxFood], сатурация [0..food] (буфер, тратится первой),\n // истощение [0..exhaustionPerFood] — накопитель, при переполнении съедает очко\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n this._stepDist = 0;\n this._regenTimer = 0;\n this._starveTimer = 0;\n this.spawnPoint = new THREE.Vector3();\n\n events.on(\"input:doubleSpace\", () => {\n if (this.mode === \"creative\") this.flying = !this.flying;\n });\n }\n\n spawn(): void {\n // ищем сушу по спирали от (8,8): не спавнимся в океане на произвольном сиде\n let sx = 8,\n sz = 8;\n outer: for (let r = 0; r <= 6; r++) {\n for (let dx = -r; dx <= r; dx += Math.max(1, r)) {\n for (let dz = -r; dz <= r; dz += Math.max(1, r)) {\n const x = 8 + dx * 8,\n z = 8 + dz * 8;\n const y = this.world.surfaceY(x, z);\n if (\n y > CONFIG.world.waterLevel &&\n this.world.getBlock(x, y, z) === B.grass\n ) {\n sx = x;\n sz = z;\n break outer;\n }\n }\n }\n }\n const y = this.world.surfaceY(sx, sz);\n this.pos.set(sx + 0.5, y + 1.01, sz + 0.5);\n this.spawnPoint.copy(this.pos);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n }\n\n get eyePos(): THREE.Vector3 {\n return new THREE.Vector3(this.pos.x, this.pos.y + P.eye, this.pos.z);\n }\n\n lookDir(): THREE.Vector3 {\n return new THREE.Vector3(\n -Math.sin(this.yaw) * Math.cos(this.pitch),\n Math.sin(this.pitch),\n -Math.cos(this.yaw) * Math.cos(this.pitch),\n );\n }\n\n toggleMode(): void {\n this.mode = this.mode === \"survival\" ? \"creative\" : \"survival\";\n if (this.mode === \"survival\") this.flying = false;\n events.emit(\"modeChanged\", { mode: this.mode });\n }\n\n /** Кадровое обновление: взгляд, ускорения, интеграция, коллизии. */\n update(dt: number, input: Input): void {\n if (this.dead) return;\n // взгляд\n const m = input.consumeMouse();\n this.yaw -= m.dx * 0.0024;\n this.pitch = Math.max(\n -Math.PI / 2 + 0.01,\n Math.min(Math.PI / 2 - 0.01, this.pitch - m.dy * 0.0024),\n );\n\n // среда\n const feet = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + 0.2),\n Math.floor(this.pos.z),\n );\n const head = this.world.getBlock(\n Math.floor(this.pos.x),\n Math.floor(this.pos.y + P.eye),\n Math.floor(this.pos.z),\n );\n this.inWater = BLOCKS[feet]?.liquid || BLOCKS[head]?.liquid;\n this.headInWater = !!BLOCKS[head]?.liquid;\n\n // желаемое горизонтальное движение в локальных осях камеры\n let ix = (input.right ? 1 : 0) - (input.left ? 1 : 0);\n let iz = (input.back ? 1 : 0) - (input.forward ? 1 : 0);\n if (input.uiOpen) {\n ix = 0;\n iz = 0;\n }\n const len = Math.hypot(ix, iz) || 1;\n ix /= len;\n iz /= len;\n // перевод ввода в мировые оси: forward = (-sin yaw, -cos yaw), right = (cos yaw, -sin yaw)\n const sin = Math.sin(this.yaw),\n cos = Math.cos(this.yaw);\n const wishX = ix * cos + iz * sin;\n const wishZ = iz * cos - ix * sin;\n\n // бег доступен, пока еды достаточно (как в Minecraft)\n const canSprint = input.run && input.forward && this.food > P.sprintFood;\n this._sprinting = canSprint && !this.flying && !this.inWater;\n const speed = this.flying\n ? P.flySpeed\n : this.inWater\n ? P.swimSpeed\n : canSprint\n ? P.runSpeed\n : P.walkSpeed;\n\n if (this.flying) {\n // полёт: прямое управление скоростью, вертикаль на Space/Shift\n this.vel.x = wishX * speed;\n this.vel.z = wishZ * speed;\n this.vel.y = (input.jump ? speed : 0) + (input.sneakOrDown ? -speed : 0);\n if (input.uiOpen) this.vel.y = 0;\n } else if (this.inWater) {\n const accel = 24 * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * 0.3 * dt; // ослабленная гравитация\n if (input.jump && !input.uiOpen) this.vel.y = P.swimUp; // гребок вверх\n this.vel.y *= 1 - P.waterDrag * dt; // сопротивление воды\n this._fallDist = 0;\n } else {\n const accel = (this.onGround ? 18 : 4) * dt;\n this.vel.x += (wishX * speed - this.vel.x) * Math.min(1, accel);\n this.vel.z += (wishZ * speed - this.vel.z) * Math.min(1, accel);\n this.vel.y -= P.gravity * dt;\n if (input.jump && this.onGround && !input.uiOpen) {\n this.vel.y = P.jumpSpeed;\n this.addExhaustion(this._sprinting ? P.exhaustJump * 4 : P.exhaustJump);\n }\n }\n\n // интеграция с сабстепами — защита от туннелирования на лагающем кадре\n const steps = Math.max(1, Math.ceil((this.vel.length() * dt) / 0.4));\n const sdt = dt / steps;\n for (let s = 0; s < steps; s++) this._moveStep(sdt);\n\n // шаги (звук) + истощение от ходьбы/бега\n if (this.onGround && !this.inWater) {\n const dist = Math.hypot(this.vel.x, this.vel.z) * dt;\n this._stepDist += dist;\n if (this.mode === \"survival\")\n this.addExhaustion(\n dist * (this._sprinting ? P.exhaustSprint : P.exhaustWalk),\n );\n if (this._stepDist > 2.2) {\n this._stepDist = 0;\n events.emit(\"playerStep\");\n }\n }\n\n if (this.pos.y < -10) this._die(); // выпал из мира\n }\n\n _moveStep(dt: number): void {\n const wasAirborne = !this.onGround && !this.flying && !this.inWater;\n const prevVy = this.vel.y;\n\n // ось Y\n this.pos.y += this.vel.y * dt;\n const hit = this._resolveAxis(1);\n const landed = hit === -1; // упёрлись вниз\n if (landed) {\n if (\n wasAirborne &&\n this._fallDist > P.fallSafe &&\n this.mode === \"survival\"\n )\n this.damage(Math.round(this._fallDist - P.fallSafe), \"fall\");\n this._fallDist = 0;\n this.onGround = true;\n this.vel.y = 0;\n } else if (hit === 1) {\n this.vel.y = 0; // потолок\n } else {\n this.onGround = false;\n if (prevVy < 0) this._fallDist += -prevVy * dt;\n }\n\n // ось X\n this.pos.x += this.vel.x * dt;\n if (this._resolveAxis(0)) this.vel.x = 0;\n // ось Z\n this.pos.z += this.vel.z * dt;\n if (this._resolveAxis(2)) this.vel.z = 0;\n }\n\n /**\n * Разрешение коллизии по одной оси: если AABB пересекает твёрдый воксель,\n * позиция клампится к его грани. Возвращает -1/1 (сторону) или 0.\n */\n _resolveAxis(axis: number): number {\n const half = P.width / 2;\n const minX = this.pos.x - half,\n maxX = this.pos.x + half;\n const minY = this.pos.y,\n maxY = this.pos.y + P.height;\n const minZ = this.pos.z - half,\n maxZ = this.pos.z + half;\n const eps = 0.001;\n let result = 0;\n\n for (let by = Math.floor(minY); by <= Math.floor(maxY - eps); by++) {\n for (let bz = Math.floor(minZ); bz <= Math.floor(maxZ - eps); bz++) {\n for (let bx = Math.floor(minX); bx <= Math.floor(maxX - eps); bx++) {\n if (!this.world.isSolid(bx, by, bz)) continue;\n if (axis === 1) {\n if (this.vel.y <= 0 && minY < by + 1 && maxY > by + 1) {\n /* невозможно по одной оси */\n }\n if (this.vel.y <= 0) {\n this.pos.y = by + 1;\n result = -1;\n } else {\n this.pos.y = by - P.height - eps;\n result = 1;\n }\n } else if (axis === 0) {\n if (this.vel.x > 0) this.pos.x = bx - half - eps;\n else this.pos.x = bx + 1 + half + eps;\n result = 1;\n } else {\n if (this.vel.z > 0) this.pos.z = bz - half - eps;\n else this.pos.z = bz + 1 + half + eps;\n result = 1;\n }\n return result; // после клампа пересечений по этой оси больше нет\n }\n }\n }\n return result;\n }\n\n damage(amount: number, cause: string): void {\n if (this.mode === \"creative\" || this.dead || amount <= 0) return;\n this.health = Math.max(0, this.health - amount);\n events.emit(\"playerHurt\", { amount, health: this.health, cause });\n if (this.health <= 0) this._die();\n }\n\n addExhaustion(n: number): void {\n this.exhaustion += n;\n }\n\n /** Тик голода (симуляция, 20 TPS): истощение → еда, регенерация, голодание. */\n tickHunger(dt: number): void {\n if (this.mode === \"creative\" || this.dead) return;\n this.addExhaustion(P.exhaustIdle * dt); // медленная пассивная трата\n while (this.exhaustion >= P.exhaustionPerFood) {\n this.exhaustion -= P.exhaustionPerFood;\n if (this.saturation > 0)\n this.saturation = Math.max(0, this.saturation - 1);\n else this.food = Math.max(0, this.food - 1);\n }\n\n // сытость лечит; лечение «стоит» истощения\n if (this.food >= P.regenFoodThreshold && this.health < P.maxHealth) {\n this._regenTimer += dt;\n if (this._regenTimer >= P.regenInterval) {\n this._regenTimer = 0;\n this.health = Math.min(P.maxHealth, this.health + 1);\n this.addExhaustion(P.exhaustRegen);\n events.emit(\"playerHeal\", { health: this.health });\n }\n } else this._regenTimer = 0;\n\n // голодание: урон при нулевой еде, но не насмерть (минимум 1 HP)\n if (this.food <= 0) {\n this._starveTimer += dt;\n if (this._starveTimer >= P.starveInterval) {\n this._starveTimer = 0;\n if (this.health > 1) this.damage(1, \"starve\");\n }\n } else this._starveTimer = 0;\n\n events.emit(\"foodChanged\", { food: this.food });\n }\n\n /** Съесть предмет (если это еда и есть куда). @returns {boolean} успех */\n eat(itemName: string): boolean {\n const it = ITEMS[itemName];\n if (!it?.food || this.food >= P.maxFood) return false;\n this.food = Math.min(P.maxFood, this.food + it.food);\n this.saturation = Math.min(\n this.food,\n this.saturation + (it.saturation ?? it.food * 0.6),\n );\n events.emit(\"playerAte\", { item: itemName });\n events.emit(\"foodChanged\", { food: this.food });\n return true;\n }\n\n _die(): void {\n if (this.dead) return;\n this.dead = true;\n events.emit(\"playerDied\");\n // простой респаун через секунду\n setTimeout(() => {\n this.pos.copy(this.spawnPoint);\n this.vel.set(0, 0, 0);\n this.health = P.maxHealth;\n this.food = P.maxFood;\n this.saturation = 5;\n this.exhaustion = 0;\n this.dead = false;\n this._fallDist = 0;\n events.emit(\"playerRespawn\");\n }, 1200);\n }\n\n /** Пересекается ли AABB игрока с блоком (запрет постановки «в себя»). */\n intersectsBlock(bx: number, by: number, bz: number): boolean {\n const half = P.width / 2;\n return (\n bx + 1 > this.pos.x - half &&\n bx < this.pos.x + half &&\n by + 1 > this.pos.y &&\n by < this.pos.y + P.height &&\n bz + 1 > this.pos.z - half &&\n bz < this.pos.z + half\n );\n }\n\n serialize() {\n return {\n pos: this.pos.toArray(),\n yaw: this.yaw,\n pitch: this.pitch,\n health: this.health,\n food: this.food,\n saturation: this.saturation,\n mode: this.mode,\n flying: this.flying,\n };\n }\n deserialize(d: PlayerSaveData): void {\n this.pos.fromArray(d.pos);\n this.yaw = d.yaw;\n this.pitch = d.pitch;\n this.health = d.health;\n this.mode = d.mode;\n this.flying = d.flying;\n this.food = d.food ?? P.maxFood;\n this.saturation = d.saturation ?? 5;\n this.exhaustion = 0;\n this.vel.set(0, 0, 0);\n this.spawnPoint.copy(this.pos);\n }\n}\n"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"path": "registry/Blocks.ts",
|
|
80
|
+
"content": "// Реестр блоков — data-driven. Добавить блок = добавить запись здесь\n// (+ painter тайла в TextureAtlas, если нужна новая текстура).\n//\n// Поля:\n// solid — есть коллизия\n// opaque — полностью непрозрачен (соседние грани не рендерятся)\n// cross — растение-крестовина (2 скрещенных квада, без коллизии)\n// liquid — вода (своя логика меша и симуляции)\n// hardness — секунд ломания голой рукой; 0 = мгновенно; Infinity = неломаемый\n// tool — каким инструментом добывается быстрее ('pickaxe'|'axe'|'shovel'|null)\n// drops — что выпадает: [{item, n}] | null = сам блок | [] = ничего\n// tex — тайлы атласа {top, side, bottom, front?}\n// onTick — хук тика для блоков с поведением (печи/конвейеры будущей автоматизации).\n// Сигнатура: onTick(world, x, y, z, meta). В MVP ни у кого не задан,\n// но планировщик в ChunkManager уже вызывает его для активных блоков.\n\nimport type { BlockDef } from \"../types.js\";\n\nfunction block(\n def: Partial<BlockDef> & { id: number; name: string },\n): BlockDef {\n return {\n solid: true,\n opaque: true,\n cross: false,\n liquid: false,\n hardness: 1,\n tool: null,\n drops: null,\n onTick: null,\n tex: null,\n ...def,\n };\n}\n\nexport const BLOCKS: BlockDef[] = [\n block({\n id: 0,\n name: \"air\",\n solid: false,\n opaque: false,\n hardness: Infinity,\n drops: [],\n tex: null,\n }),\n block({\n id: 1,\n name: \"grass\",\n hardness: 0.9,\n tool: \"shovel\",\n drops: [{ item: \"dirt\", n: 1 }],\n tex: { top: \"grass_top\", side: \"grass_side\", bottom: \"dirt\" },\n }),\n block({\n id: 2,\n name: \"dirt\",\n hardness: 0.75,\n tool: \"shovel\",\n tex: { top: \"dirt\", side: \"dirt\", bottom: \"dirt\" },\n }),\n block({\n id: 3,\n name: \"stone\",\n hardness: 4.0,\n tool: \"pickaxe\",\n drops: [{ item: \"cobblestone\", n: 1 }],\n tex: { top: \"stone\", side: \"stone\", bottom: \"stone\" },\n }),\n block({\n id: 4,\n name: \"cobblestone\",\n hardness: 4.5,\n tool: \"pickaxe\",\n tex: { top: \"cobblestone\", side: \"cobblestone\", bottom: \"cobblestone\" },\n }),\n block({\n id: 5,\n name: \"bedrock\",\n hardness: Infinity,\n drops: [],\n tex: { top: \"bedrock\", side: \"bedrock\", bottom: \"bedrock\" },\n }),\n block({\n id: 6,\n name: \"sand\",\n hardness: 0.75,\n tool: \"shovel\",\n tex: { top: \"sand\", side: \"sand\", bottom: \"sand\" },\n }),\n block({\n id: 7,\n name: \"oak_log\",\n hardness: 2.5,\n tool: \"axe\",\n tex: { top: \"oak_log_top\", side: \"oak_log_side\", bottom: \"oak_log_top\" },\n }),\n block({\n id: 8,\n name: \"leaves\",\n opaque: false,\n hardness: 0.05,\n drops: [{ item: \"apple\", n: 1, chance: 0.06 }],\n tex: { top: \"leaves\", side: \"leaves\", bottom: \"leaves\" },\n }),\n block({\n id: 9,\n name: \"planks\",\n hardness: 2.0,\n tool: \"axe\",\n tex: { top: \"planks\", side: \"planks\", bottom: \"planks\" },\n }),\n block({\n id: 10,\n name: \"crafting_table\",\n hardness: 2.0,\n tool: \"axe\",\n tex: {\n top: \"crafting_table_top\",\n side: \"crafting_table_side\",\n bottom: \"planks\",\n },\n }),\n block({\n id: 11,\n name: \"water\",\n solid: false,\n opaque: false,\n liquid: true,\n hardness: Infinity,\n drops: [],\n tex: { top: \"water\", side: \"water\", bottom: \"water\" },\n }),\n block({\n id: 12,\n name: \"coal_ore\",\n hardness: 5.0,\n tool: \"pickaxe\",\n drops: [{ item: \"coal\", n: 1 }],\n tex: { top: \"coal_ore\", side: \"coal_ore\", bottom: \"coal_ore\" },\n }),\n block({\n id: 13,\n name: \"iron_ore\",\n hardness: 5.5,\n tool: \"pickaxe\",\n tex: { top: \"iron_ore\", side: \"iron_ore\", bottom: \"iron_ore\" },\n }),\n block({\n id: 14,\n name: \"tallgrass\",\n solid: false,\n opaque: false,\n cross: true,\n hardness: 0,\n drops: [],\n tex: { side: \"tallgrass\" },\n }),\n block({\n id: 15,\n name: \"flower_yellow\",\n solid: false,\n opaque: false,\n cross: true,\n hardness: 0,\n tex: { side: \"flower_yellow\" },\n }),\n block({\n id: 16,\n name: \"flower_red\",\n solid: false,\n opaque: false,\n cross: true,\n hardness: 0,\n tex: { side: \"flower_red\" },\n }),\n];\n\n/** Быстрый доступ по имени: B.stone === 3. */\nexport const B: Record<string, number> = {};\nfor (const b of BLOCKS) B[b.name] = b.id;\n\n/** Мета-флаг воды: бит 0x08 = source-блок, биты 0..2 = уровень-1 (0..7). */\nexport const WATER_SOURCE = 0x08;\nexport const waterLevel = (meta: number): number => (meta & 0x07) + 1;\nexport const packWater = (level: number, source: boolean): number =>\n ((level - 1) & 0x07) | (source ? WATER_SOURCE : 0);\nexport const isWaterSource = (meta: number): boolean =>\n (meta & WATER_SOURCE) !== 0;\n"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"path": "registry/Items.ts",
|
|
84
|
+
"content": "// Реестр предметов. Каждому размещаемому блоку автоматически создаётся\n// предмет-двойник (blockId), плюс вручную — материалы и инструменты.\n//\n// Поля:\n// maxStack — размер стака (инструменты не стакаются)\n// kind — 'block' | 'material' | 'tool'\n// toolType — 'pickaxe'|'axe'|'sword' (у инструментов)\n// speed — множитель скорости добычи блоков соответствующего tool\n// damage — урон по сущностям (по умолчанию 1 — «кулак»)\n// tile — тайл атласа для плоской иконки (у не-блоков)\nimport { BLOCKS } from \"./Blocks.js\";\nimport type { ItemDef } from \"../types.js\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas.js\";\n\n// food — очки восстановления голода; saturation — буфер сытости (по умолчанию food*0.6)\nfunction item(def: Partial<ItemDef> & { name: string }): ItemDef {\n return {\n maxStack: 64,\n kind: \"material\",\n toolType: null,\n speed: 1,\n damage: 1,\n blockId: null,\n tile: null,\n food: 0,\n saturation: 0,\n ...def,\n };\n}\n\nexport const ITEMS: Record<string, ItemDef> = {};\n\n// Предметы-блоки: всё, что можно держать и ставить.\nconst PLACEABLE = [\n \"grass\",\n \"dirt\",\n \"stone\",\n \"cobblestone\",\n \"sand\",\n \"oak_log\",\n \"leaves\",\n \"planks\",\n \"crafting_table\",\n \"coal_ore\",\n \"iron_ore\",\n \"tallgrass\",\n \"flower_yellow\",\n \"flower_red\",\n];\nfor (const name of PLACEABLE) {\n const b = BLOCKS.find((x) => x.name === name)!;\n ITEMS[name] = item({ name, kind: \"block\", blockId: b.id });\n}\n\n// Материалы.\nITEMS.stick = item({ name: \"stick\", tile: \"stick\" });\nITEMS.coal = item({ name: \"coal\", tile: \"coal_item\" });\nITEMS.feather = item({ name: \"feather\", tile: \"feather\" });\n\n// Еда (food = очки голода из 20). Сырая курятина слабая, жареная — сытная\n// (появится при добавлении печи), яблоко падает с листвы.\nITEMS.raw_chicken = item({\n name: \"raw_chicken\",\n tile: \"raw_chicken\",\n food: 2,\n saturation: 1.2,\n});\nITEMS.cooked_chicken = item({\n name: \"cooked_chicken\",\n tile: \"cooked_chicken\",\n food: 6,\n saturation: 7.2,\n});\nITEMS.apple = item({ name: \"apple\", tile: \"apple\", food: 4, saturation: 2.4 });\n\n// Инструменты. speed — во сколько раз быстрее ломаются блоки с matching tool.\nITEMS.wooden_pickaxe = item({\n name: \"wooden_pickaxe\",\n kind: \"tool\",\n toolType: \"pickaxe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_pickaxe\",\n});\nITEMS.wooden_axe = item({\n name: \"wooden_axe\",\n kind: \"tool\",\n toolType: \"axe\",\n speed: 4,\n damage: 2,\n maxStack: 1,\n tile: \"wooden_axe\",\n});\nITEMS.wooden_sword = item({\n name: \"wooden_sword\",\n kind: \"tool\",\n toolType: \"sword\",\n speed: 1,\n damage: 4,\n maxStack: 1,\n tile: \"wooden_sword\",\n});\n\n/** Русские названия для тултипов HUD. */\nexport const ITEM_LABELS: Record<string, string> = {\n grass: \"Дёрн\",\n dirt: \"Земля\",\n stone: \"Камень\",\n cobblestone: \"Булыжник\",\n sand: \"Песок\",\n oak_log: \"Бревно\",\n leaves: \"Листва\",\n planks: \"Доски\",\n crafting_table: \"Верстак\",\n coal_ore: \"Угольная руда\",\n iron_ore: \"Железная руда\",\n tallgrass: \"Трава\",\n flower_yellow: \"Одуванчик\",\n flower_red: \"Мак\",\n stick: \"Палка\",\n coal: \"Уголь\",\n feather: \"Перо\",\n raw_chicken: \"Сырая курятина\",\n cooked_chicken: \"Жареная курятина\",\n apple: \"Яблоко\",\n wooden_pickaxe: \"Деревянная кирка\",\n wooden_axe: \"Деревянный топор\",\n wooden_sword: \"Деревянный меч\",\n};\n\n/**\n * Генерация иконок для HUD/инвентаря из тех же текстур атласа.\n * Вызывается один раз из main.js после создания атласа.\n */\nexport function buildIcons(atlas: TextureAtlas): void {\n for (const it of Object.values(ITEMS)) {\n if (it.kind === \"block\") {\n const b = BLOCKS[it.blockId!];\n const tex = b.tex!;\n it.icon = b.cross\n ? atlas.flatIcon(tex.side!)\n : atlas.blockIcon(tex.top!, tex.side!);\n } else {\n it.icon = atlas.flatIcon(it.tile!);\n }\n it.iconURL = it.icon!.toDataURL();\n }\n}\n"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"path": "registry/Mobs.ts",
|
|
88
|
+
"content": "// Реестр мобов — полностью данными. Новый моб (свинья, зомби) = новая запись:\n// риг собирается из parts в Mob.js, ИИ параметризуется полями ниже.\n//\n// parts: размеры/позиции в пикселях (1 блок = 16 px), pivot — точка вращения\n// для процедурной анимации; anim: 'legL'|'legR' (шаг), 'head' (покачивание),\n// 'wingL'|'wingR' (взмах при падении/испуге).\n\nimport type { MobDef } from \"../types.js\";\n\nexport const MOBS: Record<string, MobDef> = {\n chicken: {\n name: \"chicken\",\n hp: 4,\n walkSpeed: 1.6,\n fleeSpeed: 3.2, // после удара\n box: { w: 0.5, h: 0.75 }, // AABB для физики\n spawnOn: \"grass\",\n drops: [\n { item: \"feather\", n: 1, chance: 1 },\n { item: \"raw_chicken\", n: 1, chance: 1 },\n ],\n // Палитра частей: белое тело, жёлтый клюв, красная бородка, жёлтые ноги.\n parts: [\n { name: \"body\", size: [6, 8, 6], pos: [0, 8, 0], color: \"#f0f0f0\" },\n {\n name: \"head\",\n size: [4, 6, 3],\n pos: [0, 13, -2.5],\n pivot: [0, 11, -1.5],\n anim: \"head\",\n color: \"#f0f0f0\",\n },\n {\n name: \"beak\",\n size: [2, 2, 2],\n pos: [0, 13.5, -5],\n pivot: [0, 11, -1.5],\n anim: \"head\",\n color: \"#e8b90f\",\n },\n {\n name: \"wattle\",\n size: [2, 2, 1],\n pos: [0, 11.5, -4.5],\n pivot: [0, 11, -1.5],\n anim: \"head\",\n color: \"#c22f2f\",\n },\n {\n name: \"wingL\",\n size: [1, 4, 6],\n pos: [-3.5, 9, 0],\n pivot: [-3, 11, 0],\n anim: \"wingL\",\n color: \"#dedede\",\n },\n {\n name: \"wingR\",\n size: [1, 4, 6],\n pos: [3.5, 9, 0],\n pivot: [3, 11, 0],\n anim: \"wingR\",\n color: \"#dedede\",\n },\n {\n name: \"legL\",\n size: [1, 4, 1],\n pos: [-1.5, 2, 0.5],\n pivot: [-1.5, 4, 0.5],\n anim: \"legL\",\n color: \"#e8b90f\",\n },\n {\n name: \"legR\",\n size: [1, 4, 1],\n pos: [1.5, 2, 0.5],\n pivot: [1.5, 4, 0.5],\n anim: \"legR\",\n color: \"#e8b90f\",\n },\n ],\n },\n};\n"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"path": "registry/Recipes.ts",
|
|
92
|
+
"content": "// Реестр рецептов — декларативный. Новый рецепт = одна запись.\n//\n// shapeless: { shapeless: ['oak_log'], result: { item: 'planks', count: 4 } }\n// shaped: { pattern: [['planks'],['planks']], result: {...} }\n// pattern — массив рядов, null = пустая клетка. При загрузке паттерн\n// нормализуется (обрезаются пустые ряды/колонки) и матчится в любом\n// месте сетки 2×2/3×3, включая зеркальное отражение.\n\nimport type { RecipeResult } from \"../types.js\";\n\ntype Grid = (string | null)[][];\ninterface RawRecipe {\n shapeless?: string[];\n pattern?: Grid;\n result: RecipeResult;\n}\ninterface CompiledRecipe extends RawRecipe {\n sorted?: string[];\n variants?: Grid[];\n}\n\nexport const RECIPES: RawRecipe[] = [\n { shapeless: [\"oak_log\"], result: { item: \"planks\", count: 4 } },\n { pattern: [[\"planks\"], [\"planks\"]], result: { item: \"stick\", count: 4 } },\n {\n pattern: [\n [\"planks\", \"planks\"],\n [\"planks\", \"planks\"],\n ],\n result: { item: \"crafting_table\", count: 1 },\n },\n {\n pattern: [\n [\"planks\", \"planks\", \"planks\"],\n [null, \"stick\", null],\n [null, \"stick\", null],\n ],\n result: { item: \"wooden_pickaxe\", count: 1 },\n },\n {\n pattern: [\n [\"planks\", \"planks\"],\n [\"planks\", \"stick\"],\n [null, \"stick\"],\n ],\n result: { item: \"wooden_axe\", count: 1 },\n },\n {\n pattern: [[\"planks\"], [\"planks\"], [\"stick\"]],\n result: { item: \"wooden_sword\", count: 1 },\n },\n];\n\n/** Обрезает пустые ряды/колонки, возвращает компактную матрицу или null. */\nfunction trimGrid(grid: Grid): Grid | null {\n let minR = Infinity,\n maxR = -1,\n minC = Infinity,\n maxC = -1;\n for (let r = 0; r < grid.length; r++)\n for (let c = 0; c < grid[r].length; c++)\n if (grid[r][c]) {\n minR = Math.min(minR, r);\n maxR = Math.max(maxR, r);\n minC = Math.min(minC, c);\n maxC = Math.max(maxC, c);\n }\n if (maxR < 0) return null;\n const out: Grid = [];\n for (let r = minR; r <= maxR; r++) {\n const row: (string | null)[] = [];\n for (let c = minC; c <= maxC; c++) row.push(grid[r][c] || null);\n out.push(row);\n }\n return out;\n}\n\nconst mirror = (grid: Grid): Grid => grid.map((row) => [...row].reverse());\nconst gridsEqual = (a: Grid, b: Grid): boolean =>\n a.length === b.length &&\n a.every(\n (row, r) =>\n row.length === b[r].length && row.every((v, c) => v === b[r][c]),\n );\n\n// Предвычисляем нормализованные варианты (оригинал + зеркало).\nconst compiled: CompiledRecipe[] = RECIPES.map((r) => {\n if (r.shapeless) return { ...r, sorted: [...r.shapeless].sort() };\n const trimmed = trimGrid(r.pattern!)!;\n const variants: Grid[] = [trimmed];\n const mir = mirror(trimmed);\n if (!gridsEqual(mir, trimmed)) variants.push(mir);\n return { ...r, variants };\n});\n\n/**\n * Матчит содержимое крафт-сетки.\n * @param slots — имена предметов, row-major\n * @param size — 2 или 3\n */\nexport function matchRecipe(\n slots: (string | null)[],\n size: number,\n): RecipeResult | null {\n const grid: Grid = [];\n for (let r = 0; r < size; r++)\n grid.push(slots.slice(r * size, r * size + size));\n const trimmed = trimGrid(grid);\n\n const present = slots.filter(Boolean).sort();\n for (const rec of compiled) {\n if (rec.shapeless) {\n if (\n present.length === rec.sorted!.length &&\n present.every((v, i) => v === rec.sorted![i])\n )\n return rec.result;\n } else if (trimmed) {\n // паттерн должен влезать в сетку (меч 1×3 не собрать в 2×2)\n for (const v of rec.variants!)\n if (v.length <= size && v[0].length <= size && gridsEqual(v, trimmed))\n return rec.result;\n }\n }\n return null;\n}\n"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"path": "scene.ts",
|
|
96
|
+
"content": "import type { EngineScene, SceneMountContext } from \"@idosgames/module-sdk\";\nimport { Game } from \"./main\";\nimport \"./style.css\";\n\n// VoxelCraft is a standalone vanilla-Three game with its own DOM HUD (queried by id) and its own\n// RAF loop — no React, no SDK. It becomes a composable module by (1) injecting the DOM it expects\n// into the host surface instead of relying on index.html, and (2) exposing mount/suspend/destroy.\n// Because the host surface is in the document, the game's document.getElementById(...) lookups keep\n// working unchanged — no rewrite of its HUD/input code needed.\n//\n// This is the \"engine scene with NO React panels\" branch of the contract: the module registers a\n// scene and a nav entry, and nothing else.\n\nconst VOXEL_DOM = `\n <canvas id=\"game\"></canvas>\n <div id=\"crosshair\"></div>\n <div id=\"fps\"></div>\n <div id=\"debug\"></div>\n <div id=\"timeIndicator\"></div>\n <div id=\"hearts\"></div>\n <div id=\"hunger\"></div>\n <div id=\"hotbar\"></div>\n <div id=\"hurtFlash\"></div>\n <div id=\"message\"></div>\n <div id=\"invScreen\" class=\"hidden\">\n <div class=\"invPanel\">\n <h3 id=\"invTitle\">Inventory</h3>\n <div class=\"craftRow\">\n <div id=\"craftGrid\"></div>\n <div class=\"arrow\">→</div>\n <div id=\"craftResult\" class=\"slot result\"></div>\n </div>\n <div id=\"invGrid\"></div>\n <div id=\"invHotbar\"></div>\n </div>\n </div>\n <div id=\"cursorStack\" class=\"hidden\"><img draggable=\"false\" /><span class=\"count\"></span></div>\n <div id=\"overlay\">\n <div class=\"panel\">\n <h1 id=\"overlayTitle\">VoxelCraft</h1>\n <p id=\"overlayHint\">Click to play</p>\n <div class=\"controls\">\n <div><b>WASD</b> move, <b>Space</b> jump / swim up, <b>Shift</b> run</div>\n <div><b>LMB</b> (hold) break / attack, <b>RMB</b> place block</div>\n <div><b>Wheel / 1-9</b> hotbar slot, <b>E</b> inventory & 2×2 craft</div>\n <div><b>RMB (hold) with food</b> eat (watch the hunger bar)</div>\n <div><b>RMB on a workbench</b> 3×3 craft, <b>C</b> creative (double <b>Space</b> to fly)</div>\n <div><b>Esc</b> pause</div>\n </div>\n <div class=\"buttons\">\n <button id=\"btnExport\">Export world</button>\n <button id=\"btnImport\">Import world</button>\n <button id=\"btnNewSeed\">New seed</button>\n </div>\n </div>\n </div>\n`;\n\nexport function createVoxelScene(): EngineScene {\n let game: Game | null = null;\n return {\n surface: \"fullbleed-canvas\",\n mount(ctx: SceneMountContext): void {\n // The game reads its elements by id from the document; provide them inside the host surface.\n ctx.host.innerHTML = VOXEL_DOM;\n game = new Game();\n },\n activate(): void {\n game?.setRunning(true);\n },\n suspend(): void {\n game?.setRunning(false);\n },\n destroy(): void {\n game?.destroy();\n game = null;\n },\n };\n}\n"
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"path": "shims.d.ts",
|
|
100
|
+
"content": "// Side-effect CSS import (the game's HUD styles). Vite handles the actual bundling; this just tells\n// tsc the import is legal.\ndeclare module \"*.css\";\n"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"path": "style.css",
|
|
104
|
+
"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: #000;\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#fps {\n position: fixed;\n top: 8px;\n right: 10px;\n color: #7fff6a;\n z-index: 5;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#debug {\n position: fixed;\n top: 8px;\n left: 10px;\n color: #ddd;\n z-index: 5;\n font: 11px monospace;\n text-shadow: 1px 1px 0 #000;\n opacity: 0.85;\n}\n#timeIndicator {\n position: fixed;\n top: 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#hotbar {\n position: fixed;\n left: 50%;\n bottom: 8px;\n transform: translateX(-50%);\n display: flex;\n gap: 3px;\n z-index: 5;\n padding: 3px;\n background: rgba(0, 0, 0, 0.45);\n border: 2px solid #222;\n border-radius: 4px;\n}\n.slot {\n width: 44px;\n height: 44px;\n position: relative;\n background: rgba(120, 120, 120, 0.35);\n border: 2px solid #555;\n}\n.slot img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n pointer-events: none;\n}\n.slot .count {\n position: absolute;\n right: 2px;\n bottom: 0;\n color: #fff;\n pointer-events: none;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n#hotbar .slot.selected {\n border-color: #fff;\n background: rgba(200, 200, 200, 0.4);\n}\n\n/* сердечки — слева от центра, шкала голода — справа (как в Minecraft) */\n#hearts {\n position: fixed;\n right: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n}\n.heart {\n color: #e03c3c;\n}\n.heart.half {\n color: #e03c3c;\n opacity: 0.55;\n}\n.heart.empty {\n color: #3a3a3a;\n}\n\n#hunger {\n position: fixed;\n left: calc(50% + 8px);\n bottom: 62px;\n white-space: nowrap;\n z-index: 5;\n font-size: 18px;\n letter-spacing: 2px;\n text-shadow: 1px 1px 0 #000;\n direction: rtl; /* пустеет справа налево, как в MC */\n}\n.drumstick {\n color: #c98a3c;\n}\n.drumstick.half {\n color: #c98a3c;\n opacity: 0.55;\n}\n.drumstick.empty {\n color: #3a3a3a;\n}\n\n#hurtFlash {\n position: fixed;\n inset: 0;\n pointer-events: none;\n z-index: 4;\n background: radial-gradient(\n ellipse at center,\n transparent 40%,\n rgba(255, 0, 0, 0.5)\n );\n opacity: 0;\n}\n#hurtFlash.show {\n animation: hurt 0.4s ease-out;\n}\n@keyframes hurt {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n\n#message {\n position: fixed;\n left: 50%;\n top: 30%;\n transform: translateX(-50%);\n color: #fff;\n font: bold 18px monospace;\n text-shadow: 2px 2px 0 #000;\n z-index: 6;\n opacity: 0;\n transition: opacity 0.3s;\n pointer-events: none;\n}\n#message.show {\n opacity: 1;\n}\n\n/* ---------- инвентарь ---------- */\n#invScreen {\n position: fixed;\n inset: 0;\n z-index: 10;\n background: rgba(0, 0, 0, 0.5);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.invPanel {\n background: #c6c6c6;\n border: 3px solid #555;\n border-radius: 4px;\n padding: 14px 16px;\n box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);\n}\n.invPanel h3 {\n color: #3a3a3a;\n margin-bottom: 8px;\n font-size: 15px;\n}\n.invPanel .slot {\n background: #8b8b8b;\n border: 2px solid;\n border-color: #373737 #fff #fff #373737;\n}\n.craftRow {\n display: flex;\n align-items: center;\n gap: 12px;\n margin-bottom: 12px;\n}\n#craftGrid {\n display: grid;\n gap: 3px;\n}\n.arrow {\n font-size: 26px;\n color: #3a3a3a;\n}\n.slot.result {\n width: 50px;\n height: 50px;\n}\n#invGrid {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n margin-bottom: 10px;\n}\n#invHotbar {\n display: grid;\n grid-template-columns: repeat(9, 44px);\n gap: 3px;\n}\n\n#cursorStack {\n position: fixed;\n z-index: 20;\n width: 40px;\n height: 40px;\n pointer-events: none;\n transform: translate(-50%, -50%);\n}\n#cursorStack img {\n width: 100%;\n height: 100%;\n image-rendering: pixelated;\n}\n#cursorStack .count {\n position: absolute;\n right: 0;\n bottom: -2px;\n color: #fff;\n font: bold 13px monospace;\n text-shadow: 1px 1px 0 #000;\n}\n\n/* ---------- оверлей ---------- */\n#overlay {\n position: fixed;\n inset: 0;\n z-index: 30;\n cursor: pointer;\n background: rgba(10, 14, 28, 0.82);\n display: flex;\n align-items: center;\n justify-content: center;\n}\n#overlay .panel {\n text-align: center;\n color: #eee;\n max-width: 560px;\n padding: 20px;\n}\n#overlay h1 {\n font-size: 44px;\n letter-spacing: 2px;\n margin-bottom: 6px;\n color: #9adf6a;\n text-shadow: 3px 3px 0 #2a4d1a;\n}\n#overlayHint {\n font-size: 17px;\n color: #ffd76a;\n margin-bottom: 18px;\n}\n#overlay .controls {\n text-align: left;\n background: rgba(0, 0, 0, 0.35);\n border-radius: 6px;\n padding: 12px 16px;\n font-size: 14px;\n line-height: 1.8;\n margin-bottom: 16px;\n}\n#overlay .controls b {\n color: #9adf6a;\n}\n#overlay .buttons {\n display: flex;\n gap: 10px;\n justify-content: center;\n}\n#overlay button {\n cursor: pointer;\n font-size: 14px;\n padding: 8px 14px;\n border-radius: 4px;\n border: 2px solid #666;\n background: #3a3f4d;\n color: #eee;\n}\n#overlay button:hover {\n background: #4a5163;\n}\n"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"path": "systems/Audio.ts",
|
|
108
|
+
"content": "// Звук: синтез через Web Audio, без файлов. Подписан на события шины —\n// добавление нового звука = ещё одна подписка, игровая логика не знает об аудио.\nimport { events } from \"../core/EventBus.js\";\n\nexport class Audio {\n ctx: AudioContext | null;\n\n constructor() {\n this.ctx = null; // создаётся после первого user gesture (autoplay policy)\n\n events.on(\"blockBreak\", () => this.noise(0.12, 900, 0.25));\n events.on(\"blockPlace\", () => this.noise(0.06, 1600, 0.2));\n events.on(\"itemPickup\", () => this.blip(880, 0.08, \"triangle\", 0.15, 1320));\n events.on(\"playerHurt\", () => this.blip(160, 0.2, \"sawtooth\", 0.2, 110));\n events.on(\"mobHurt\", () => this.blip(520, 0.1, \"square\", 0.12, 380));\n events.on(\"playerStep\", () => this.noise(0.045, 500, 0.06));\n events.on(\"craft\", () => this.blip(660, 0.1, \"triangle\", 0.12, 990));\n events.on(\"armSwing\", () => this.noise(0.04, 2400, 0.04));\n events.on(\"playerAte\", () => {\n this.noise(0.09, 700, 0.14);\n this.blip(300, 0.12, \"sine\", 0.1, 420);\n });\n }\n\n /** Вызвать из обработчика клика (оверлей «Играть»). */\n unlock() {\n if (!this.ctx)\n this.ctx = new (\n window.AudioContext ||\n (window as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext!\n )();\n if (this.ctx.state === \"suspended\") this.ctx.resume();\n }\n\n /** Тональный писк с глиссандо. */\n blip(\n freq: number,\n dur: number,\n type: OscillatorType = \"square\",\n vol = 0.15,\n glideTo: number | null = null,\n ) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const osc = this.ctx.createOscillator();\n const gain = this.ctx.createGain();\n osc.type = type;\n osc.frequency.setValueAtTime(freq, t);\n if (glideTo) osc.frequency.exponentialRampToValueAtTime(glideTo, t + dur);\n gain.gain.setValueAtTime(vol, t);\n gain.gain.exponentialRampToValueAtTime(0.001, t + dur);\n osc.connect(gain).connect(this.ctx.destination);\n osc.start(t);\n osc.stop(t + dur + 0.02);\n }\n\n /** Шумовой щелчок через фильтр (ломание/шаги). */\n noise(dur: number, filterFreq: number, vol = 0.2) {\n if (!this.ctx || this.ctx.state !== \"running\") return;\n const t = this.ctx.currentTime;\n const len = Math.max(1, (dur * this.ctx.sampleRate) | 0);\n const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);\n const data = buf.getChannelData(0);\n for (let i = 0; i < len; i++)\n data[i] = (Math.random() * 2 - 1) * (1 - i / len);\n const src = this.ctx.createBufferSource();\n src.buffer = buf;\n const filter = this.ctx.createBiquadFilter();\n filter.type = \"lowpass\";\n filter.frequency.value = filterFreq;\n const gain = this.ctx.createGain();\n gain.gain.value = vol;\n src.connect(filter).connect(gain).connect(this.ctx.destination);\n src.start(t);\n }\n}\n"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"path": "systems/Crafting.ts",
|
|
112
|
+
"content": "// Крафт: состояние сетки (2×2 инвентарь / 3×3 верстак) + матчинг рецептов.\n// Слоты сетки — такие же стаки, как в инвентаре; UI двигает их drag-and-drop'ом.\nimport { events } from \"../core/EventBus.js\";\nimport { matchRecipe } from \"../registry/Recipes.js\";\nimport type { ItemStack, RecipeResult } from \"../types.js\";\nimport type { Inventory } from \"./Inventory.js\";\n\nexport class CraftingGrid {\n size: number;\n slots: (ItemStack | null)[];\n\n constructor(size = 2) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n setSize(size: number) {\n this.size = size;\n this.slots = new Array(size * size).fill(null);\n }\n\n /** Текущий результат по рецептам (не изымает ингредиенты). */\n result(): RecipeResult | null {\n return matchRecipe(\n this.slots.map((s) => (s ? s.item : null)),\n this.size,\n );\n }\n\n /** Забрать результат: по 1 предмету из каждой занятой клетки. */\n takeResult(): RecipeResult | null {\n const res = this.result();\n if (!res) return null;\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n s.count -= 1;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n events.emit(\"craft\", { item: res.item, count: res.count });\n return { ...res };\n }\n\n /** Вернуть всё из сетки в инвентарь (закрытие окна). */\n dumpInto(inventory: Inventory) {\n for (let i = 0; i < this.slots.length; i++) {\n const s = this.slots[i];\n if (s) {\n const left = inventory.add(s.item, s.count);\n this.slots[i] = left > 0 ? { item: s.item, count: left } : null;\n // если инвентарь полон — остаток остаётся в сетке (не теряем предметы)\n }\n }\n }\n}\n"
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"path": "systems/DayNight.ts",
|
|
116
|
+
"content": "// Цикл суток: 30 c день + 30 c ночь. Плавно лерпаются цвет неба, туман,\n// интенсивность/цвет солнца и ambient; солнце и луна — спрайты на орбите камеры.\nimport * as THREE from \"three\";\nimport { CONFIG } from \"../config.js\";\n\n// Ключевые точки цикла: t в [0..1), 0 = рассвет.\nconst KEYS = [\n {\n t: 0.0,\n sky: 0xffa958,\n sun: 0xffd9a0,\n sunI: 0.55,\n amb: 0.45,\n fog: 0xffc890,\n }, // рассвет\n { t: 0.1, sky: 0x87ceeb, sun: 0xffffff, sunI: 1.0, amb: 0.65, fog: 0xbfe3f5 }, // день\n { t: 0.4, sky: 0x87ceeb, sun: 0xffffff, sunI: 1.0, amb: 0.65, fog: 0xbfe3f5 },\n { t: 0.5, sky: 0xff7b45, sun: 0xffb070, sunI: 0.45, amb: 0.4, fog: 0xe8956a }, // закат\n {\n t: 0.6,\n sky: 0x0a1030,\n sun: 0x223055,\n sunI: 0.12,\n amb: 0.18,\n fog: 0x0e1638,\n }, // ночь\n {\n t: 0.9,\n sky: 0x0a1030,\n sun: 0x223055,\n sunI: 0.12,\n amb: 0.18,\n fog: 0x0e1638,\n },\n {\n t: 1.0,\n sky: 0xffa958,\n sun: 0xffd9a0,\n sunI: 0.55,\n amb: 0.45,\n fog: 0xffc890,\n },\n];\n\nfunction makeDiscSprite(color: string, glow: string): THREE.Sprite {\n const c = document.createElement(\"canvas\");\n c.width = c.height = 64;\n const ctx = c.getContext(\"2d\")!;\n const g = ctx.createRadialGradient(32, 32, 6, 32, 32, 30);\n g.addColorStop(0, color);\n g.addColorStop(0.5, color);\n g.addColorStop(1, glow);\n ctx.fillStyle = g;\n ctx.fillRect(0, 0, 64, 64);\n const tex = new THREE.CanvasTexture(c);\n const mat = new THREE.SpriteMaterial({\n map: tex,\n transparent: true,\n fog: false,\n depthWrite: false,\n });\n const s = new THREE.Sprite(mat);\n s.scale.set(14, 14, 1);\n return s;\n}\n\nexport class DayNight {\n scene: THREE.Scene;\n renderer: THREE.WebGLRenderer;\n cycleLength: number;\n time: number;\n sun: THREE.DirectionalLight;\n ambient: THREE.AmbientLight;\n skyColor: THREE.Color;\n sunSprite: THREE.Sprite;\n moonSprite: THREE.Sprite;\n _colA: THREE.Color;\n _colB: THREE.Color;\n\n constructor(scene: THREE.Scene, renderer: THREE.WebGLRenderer) {\n this.scene = scene;\n this.renderer = renderer;\n this.cycleLength = CONFIG.dayNight.dayLength + CONFIG.dayNight.nightLength;\n this.time = this.cycleLength * 0.15; // старт утром\n this.sun = new THREE.DirectionalLight(0xffffff, 1);\n this.ambient = new THREE.AmbientLight(0xffffff, 0.65);\n scene.add(this.sun, this.ambient);\n scene.fog = new THREE.Fog(0xbfe3f5, 20, CONFIG.renderDistance * 16 - 8);\n this.skyColor = new THREE.Color();\n this.sunSprite = makeDiscSprite(\"#fff4c0\", \"rgba(255,235,150,0)\");\n this.moonSprite = makeDiscSprite(\"#e8ecf5\", \"rgba(190,200,230,0)\");\n this.moonSprite.scale.set(9, 9, 1);\n scene.add(this.sunSprite, this.moonSprite);\n this._colA = new THREE.Color();\n this._colB = new THREE.Color();\n }\n\n tick(dt: number) {\n this.time = (this.time + dt) % this.cycleLength;\n }\n\n /** 0..1 фазы цикла (0 — рассвет). */\n get phase() {\n return this.time / this.cycleLength;\n }\n get isDay() {\n return this.phase < 0.5;\n }\n\n /** Кадровое обновление: интерполяция ключей + позиции светил вокруг игрока. */\n frame(playerPos: THREE.Vector3, underwater: boolean) {\n const t = this.phase;\n let a = KEYS[0],\n b = KEYS[KEYS.length - 1];\n for (let i = 0; i < KEYS.length - 1; i++)\n if (t >= KEYS[i].t && t <= KEYS[i + 1].t) {\n a = KEYS[i];\n b = KEYS[i + 1];\n break;\n }\n const f = (t - a.t) / Math.max(1e-6, b.t - a.t);\n const lerpC = (target: THREE.Color, ca: number, cb: number) =>\n target.copy(this._colA.setHex(ca)).lerp(this._colB.setHex(cb), f);\n\n lerpC(this.skyColor, a.sky, b.sky);\n lerpC(this.sun.color, a.sun, b.sun);\n this.sun.intensity = a.sunI + (b.sunI - a.sunI) * f;\n this.ambient.intensity = a.amb + (b.amb - a.amb) * f;\n\n const fog = this.scene.fog as THREE.Fog;\n lerpC(fog.color, a.fog, b.fog);\n if (underwater) {\n fog.color.setHex(0x1a4a8a);\n fog.near = 2;\n fog.far = 18;\n } else {\n fog.near = 20;\n fog.far = CONFIG.renderDistance * 16 - 8;\n }\n this.renderer.setClearColor(underwater ? 0x1a4a8a : this.skyColor);\n\n // солнце: восход на востоке (+x) в t=0, зенит в t=0.25, закат в t=0.5\n const elev = Math.sin(Math.PI * (0.5 - Math.abs(t - 0.25) * 2)); // 1 в полдень, <0 ночью\n const az = Math.cos((t - 0.25) * Math.PI * 2);\n const sunDir = new THREE.Vector3(az, Math.max(-0.4, elev), 0.3).normalize();\n this.sun.position.copy(playerPos).addScaledVector(sunDir, 60);\n this.sun.target.position.copy(playerPos);\n this.sun.target.updateMatrixWorld();\n\n this.sunSprite.position.copy(playerPos).addScaledVector(sunDir, 70);\n this.moonSprite.position\n .copy(playerPos)\n .addScaledVector(sunDir.clone().negate(), 70);\n this.sunSprite.material.opacity = Math.max(\n 0,\n Math.min(1, sunDir.y * 4 + 0.4),\n );\n this.moonSprite.material.opacity = Math.max(\n 0,\n Math.min(1, -sunDir.y * 4 + 0.4),\n );\n }\n\n serialize() {\n return { time: this.time };\n }\n deserialize(d: { time?: number }) {\n this.time = d.time ?? this.time;\n }\n}\n"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"path": "systems/Inventory.ts",
|
|
120
|
+
"content": "// Инвентарь — чистая модель данных без DOM (UI подписывается на invChanged).\n// 36 слотов: 0..8 — хотбар, 9..35 — рюкзак. Слот: {item: string, count: number} | null.\nimport { events } from \"../core/EventBus.js\";\nimport { ITEMS } from \"../registry/Items.js\";\nimport type { ItemStack, InventorySave } from \"../types.js\";\n\nexport class Inventory {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n\n constructor(size = 36) {\n this.slots = new Array(size).fill(null);\n this.hotbarIndex = 0;\n }\n\n _changed() {\n events.emit(\"invChanged\");\n }\n\n heldItem(): ItemStack | null {\n return this.slots[this.hotbarIndex];\n }\n\n selectSlot(i: number) {\n this.hotbarIndex = Math.max(0, Math.min(8, i));\n this._changed();\n }\n scrollSlot(delta: number) {\n this.hotbarIndex = (this.hotbarIndex + delta + 9) % 9;\n this._changed();\n }\n\n /** Добавить предметы (сначала в существующие стаки, потом в пустые слоты).\n * @returns {number} сколько НЕ поместилось */\n add(itemName: string, count: number): number {\n const max = ITEMS[itemName]?.maxStack ?? 64;\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName && s.count < max) {\n const take = Math.min(count, max - s.count);\n s.count += take;\n count -= take;\n }\n }\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n if (!this.slots[i]) {\n const take = Math.min(count, max);\n this.slots[i] = { item: itemName, count: take };\n count -= take;\n }\n }\n this._changed();\n return count;\n }\n\n consumeHeld(n = 1) {\n const s = this.slots[this.hotbarIndex];\n if (!s) return;\n s.count -= n;\n if (s.count <= 0) this.slots[this.hotbarIndex] = null;\n this._changed();\n }\n\n /** Съесть count предметов itemName из любого места (для крафта). */\n consume(itemName: string, count: number) {\n for (let i = 0; i < this.slots.length && count > 0; i++) {\n const s = this.slots[i];\n if (s && s.item === itemName) {\n const take = Math.min(count, s.count);\n s.count -= take;\n count -= take;\n if (s.count <= 0) this.slots[i] = null;\n }\n }\n this._changed();\n }\n\n setSlot(i: number, stack: ItemStack | null) {\n this.slots[i] = stack;\n this._changed();\n }\n\n serialize(): InventorySave {\n return { slots: this.slots, hotbarIndex: this.hotbarIndex };\n }\n deserialize(d: InventorySave) {\n this.slots = d.slots.map((s) => (s ? { ...s } : null));\n while (this.slots.length < 36) this.slots.push(null);\n this.hotbarIndex = d.hotbarIndex ?? 0;\n this._changed();\n }\n}\n"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"path": "systems/Save.ts",
|
|
124
|
+
"content": "// Сохранение: экспорт/импорт мира в JSON-файл. Формат = полный снапшот\n// этапа 2 (мультиплеер): сид + дифы блоков + игрок + инвентарь + сущности + время.\nimport { CONFIG } from \"../config.js\";\nimport type { Game } from \"../main.js\";\nimport type { SaveData } from \"../types.js\";\n\nexport class SaveSystem {\n game: Game;\n\n constructor(game: Game) {\n this.game = game;\n } // game — фасад из main.js\n\n collect(): SaveData {\n const g = this.game;\n return {\n version: CONFIG.saveVersion,\n seed: g.world.seed,\n time: g.dayNight.serialize(),\n player: { ...g.player.serialize(), inventory: g.inventory.serialize() },\n chunks: g.world.serializeDiffs(),\n entities: g.entities.serialize(),\n };\n }\n\n exportFile() {\n const data = JSON.stringify(this.collect());\n const blob = new Blob([data], { type: \"application/json\" });\n const a = document.createElement(\"a\");\n a.href = URL.createObjectURL(blob);\n a.download = `voxelcraft-save-${this.game.world.seed}.json`;\n a.click();\n URL.revokeObjectURL(a.href);\n }\n\n /** Импорт из выбранного файла. Пересоздаёт мир по сиду и накатывает диф. */\n importFile(onDone?: (err: unknown) => void) {\n const input = document.createElement(\"input\");\n input.type = \"file\";\n input.accept = \".json,application/json\";\n input.onchange = () => {\n const file = input.files?.[0];\n if (!file) return;\n const reader = new FileReader();\n reader.onload = () => {\n try {\n const data = JSON.parse(reader.result as string);\n if (typeof data.seed !== \"number\" || !data.chunks)\n throw new Error(\"не похоже на сейв VoxelCraft\");\n this.apply(data);\n onDone?.(null);\n } catch (e) {\n onDone?.(e);\n }\n };\n reader.readAsText(file);\n };\n input.click();\n }\n\n apply(data: SaveData) {\n const g = this.game;\n g.rebuildWorld(data.seed); // новый ChunkManager с этим сидом\n g.world.loadDiffs(data.chunks);\n g.player.deserialize(data.player);\n g.inventory.deserialize(data.player.inventory);\n g.dayNight.deserialize(data.time);\n g.entities.deserialize(data.entities);\n }\n}\n"
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"path": "systems/WaterSim.ts",
|
|
128
|
+
"content": "// Симуляция воды — клеточный автомат на dirty-set: мир никогда не сканируется,\n// обрабатываются только клетки из очереди. Тикает на каждом 5-м симтике (4 Гц),\n// бюджет клеток за тик жёстко ограничен — остаток «дотекает» в следующие тики.\n//\n// Правила:\n// • source (бит меты) никогда не гаснет;\n// • под водой воздух → вода падает вниз полным уровнем;\n// • иначе растекается горизонтально с уровнем max(соседей)-1, минимум minFlowLevel;\n// • flowing-вода без опоры (нет соседа с большим уровнем и не под водой) убывает.\nimport { CONFIG } from \"../config.js\";\nimport { events } from \"../core/EventBus.js\";\nimport { B, packWater, waterLevel, isWaterSource } from \"../registry/Blocks.js\";\nimport type { ChunkManager } from \"../world/ChunkManager.js\";\n\ninterface Cell {\n x: number;\n y: number;\n z: number;\n}\n\nconst W = CONFIG.water;\nconst key = (x: number, y: number, z: number) => x + \",\" + y + \",\" + z;\n\nexport class WaterSim {\n world: ChunkManager;\n queue: Map<string, Cell>;\n _tickCounter: number;\n\n constructor(world: ChunkManager) {\n this.world = world;\n this.queue = new Map(); // key -> {x,y,z}\n this._tickCounter = 0;\n\n // любое изменение мира будит воду вокруг — так «прорывается дамба»\n events.on(\"blockChanged\", ({ x, y, z }) => {\n this.wake(x, y, z);\n this.wakeNeighbors(x, y, z);\n });\n }\n\n wake(x: number, y: number, z: number) {\n if (this.world.getBlock(x, y, z) === B.water || this._canFlowInto(x, y, z))\n this.queue.set(key(x, y, z), { x, y, z });\n }\n\n wakeNeighbors(x: number, y: number, z: number) {\n this.wake(x + 1, y, z);\n this.wake(x - 1, y, z);\n this.wake(x, y + 1, z);\n this.wake(x, y - 1, z);\n this.wake(x, y, z + 1);\n this.wake(x, y, z - 1);\n }\n\n _canFlowInto(x: number, y: number, z: number) {\n return this.world.getBlock(x, y, z) === B.air; // растения вода не смывает (MVP)\n }\n\n get queueSize() {\n return this.queue.size;\n }\n\n tick() {\n if (++this._tickCounter % W.tickEvery !== 0) return;\n if (this.queue.size === 0) return;\n\n const batch = [];\n for (const cell of this.queue.values()) {\n batch.push(cell);\n if (batch.length >= W.cellBudget) break;\n }\n for (const c of batch) this.queue.delete(key(c.x, c.y, c.z));\n\n for (const { x, y, z } of batch) this._updateCell(x, y, z);\n }\n\n _set(x: number, y: number, z: number, id: number, meta: number) {\n this.world.setBlock(x, y, z, id, meta, { waterOnly: true });\n this.wakeNeighbors(x, y, z);\n this.queue.set(key(x, y, z), { x, y, z });\n }\n\n _updateCell(x: number, y: number, z: number) {\n const id = this.world.getBlock(x, y, z);\n\n if (id === B.air) {\n // может ли вода прийти сюда? сверху — или сбоку с уровнем > minFlowLevel\n const above = this.world.getBlock(x, y + 1, z);\n if (above === B.water) {\n // ВАЖНО: при падении уровень наследуется (не сбрасывается в максимум) —\n // иначе каскад по склону заливает конус до океана (как в MC, но\n // неподъёмно для геймплея «сломал дамбу — лужа растеклась и встала»).\n this._set(\n x,\n y,\n z,\n B.water,\n packWater(waterLevel(this.world.getMeta(x, y + 1, z)), false),\n );\n return;\n }\n let best = 0;\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ]) {\n if (this.world.getBlock(x + dx, y, z + dz) !== B.water) continue;\n // сосед отдаёт воду вбок только если сам стоит на опоре\n const nUnder = this.world.getBlock(x + dx, y - 1, z + dz);\n if (nUnder === B.air) continue;\n best = Math.max(\n best,\n waterLevel(this.world.getMeta(x + dx, y, z + dz)),\n );\n }\n if (best - 1 >= W.minFlowLevel)\n this._set(x, y, z, B.water, packWater(best - 1, false));\n return;\n }\n\n if (id !== B.water) return;\n const meta = this.world.getMeta(x, y, z);\n if (isWaterSource(meta)) {\n this._spread(x, y, z, W.maxLevel);\n return;\n }\n\n // пересчёт уровня flowing-воды: питание сверху или от соседей\n const fedFromAbove = this.world.getBlock(x, y + 1, z) === B.water;\n let want = 0;\n if (fedFromAbove) want = waterLevel(this.world.getMeta(x, y + 1, z));\n else {\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ])\n if (this.world.getBlock(x + dx, y, z + dz) === B.water)\n want = Math.max(\n want,\n waterLevel(this.world.getMeta(x + dx, y, z + dz)) - 1,\n );\n }\n\n const cur = waterLevel(meta);\n if (want < W.minFlowLevel) {\n this._set(x, y, z, B.air, 0);\n return;\n } // высохла\n if (want !== cur) {\n this._set(x, y, z, B.water, packWater(want, false));\n return;\n }\n this._spread(x, y, z, cur);\n }\n\n /** Растекание из клетки: сначала вниз (уровень наследуется), потом в стороны. */\n _spread(x: number, y: number, z: number, level: number) {\n const below = this.world.getBlock(x, y - 1, z);\n if (below === B.air) {\n this._set(x, y - 1, z, B.water, packWater(level, false));\n return; // вода уходит вниз — вбок не льём\n }\n if (level - 1 < W.minFlowLevel) return;\n for (const [dx, dz] of [\n [1, 0],\n [-1, 0],\n [0, 1],\n [0, -1],\n ]) {\n const nid = this.world.getBlock(x + dx, y, z + dz);\n if (nid === B.air)\n this._set(x + dx, y, z + dz, B.water, packWater(level - 1, false));\n else if (\n nid === B.water &&\n waterLevel(this.world.getMeta(x + dx, y, z + dz)) < level - 1\n )\n this._set(x + dx, y, z + dz, B.water, packWater(level - 1, false));\n }\n }\n}\n"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"path": "types.ts",
|
|
132
|
+
"content": "// Общие типы, разделяемые между модулями. Плоские data-driven-формы реестров\n// (блоки/предметы/мобы/рецепты), стек инвентаря, форма сейва и полезные алиасы.\nimport type { ChunkManager } from \"./world/ChunkManager.js\";\n\n/** [x, y, z] — вектор/размер в блоках или пикселях. */\nexport type Vec3 = [number, number, number];\n\n// ---- блоки ------------------------------------------------------------------\n\nexport type ToolType = \"pickaxe\" | \"axe\" | \"shovel\" | \"sword\";\n\n/** Тайлы атласа для граней блока (имена тайлов). */\nexport interface BlockTex {\n top?: string;\n side?: string;\n bottom?: string;\n front?: string;\n}\n\n/** Что выпадает из блока/моба: предмет, количество, опциональный шанс. */\nexport interface BlockDrop {\n item: string;\n n: number;\n chance?: number;\n}\n\n/** Хук тика блока с поведением (печи/конвейеры — задел автоматизации). */\nexport type BlockTickFn = (\n world: ChunkManager,\n x: number,\n y: number,\n z: number,\n meta: number,\n) => void;\n\nexport interface BlockDef {\n id: number;\n name: string;\n solid: boolean;\n opaque: boolean;\n cross: boolean;\n liquid: boolean;\n hardness: number;\n tool: ToolType | null;\n drops: BlockDrop[] | null;\n onTick: BlockTickFn | null;\n tex: BlockTex | null;\n}\n\n// ---- предметы ---------------------------------------------------------------\n\nexport interface ItemDef {\n name: string;\n maxStack: number;\n kind: \"block\" | \"material\" | \"tool\";\n toolType: ToolType | null;\n speed: number;\n damage: number;\n blockId: number | null;\n tile: string | null;\n food: number;\n saturation: number;\n // Заполняются buildIcons() из атласа после его создания.\n icon?: HTMLCanvasElement;\n iconURL?: string;\n}\n\n/** Слот инвентаря / крафт-сетки. */\nexport interface ItemStack {\n item: string;\n count: number;\n}\n\n// ---- мобы -------------------------------------------------------------------\n\nexport interface MobPart {\n name: string;\n size: Vec3;\n pos: Vec3;\n pivot?: Vec3;\n anim?: string;\n color: string;\n}\n\nexport interface MobDef {\n name: string;\n hp: number;\n walkSpeed: number;\n fleeSpeed: number;\n box: { w: number; h: number };\n spawnOn: string;\n drops: BlockDrop[];\n parts: MobPart[];\n}\n\n// ---- рецепты ----------------------------------------------------------------\n\nexport interface RecipeResult {\n item: string;\n count: number;\n}\n\nexport interface ShapelessRecipe {\n shapeless: string[];\n result: RecipeResult;\n}\n\nexport interface ShapedRecipe {\n pattern: (string | null)[][];\n result: RecipeResult;\n}\n\nexport type Recipe = ShapelessRecipe | ShapedRecipe;\n\n// ---- сейв -------------------------------------------------------------------\n\n/** Дифы блоков по чанкам: \"cx,cz\" -> [[index, id, meta], ...]. */\nexport type ChunkDiffMap = Record<string, Array<[number, number, number]>>;\n\nexport interface InventorySave {\n slots: (ItemStack | null)[];\n hotbarIndex: number;\n}\n\n/**\n * Полный снапшот мира. Вложенные serialize()-блобы player/entities намеренно\n * динамические (проходят JSON round-trip и раздаются в *.deserialize()) — any здесь оправдан.\n */\nexport interface SaveData {\n version: number;\n seed: number;\n time: { time?: number };\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n player: any;\n chunks: ChunkDiffMap;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n entities: any;\n}\n"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"path": "ui/HUD.ts",
|
|
136
|
+
"content": "// HUD: прицел, хотбар, сердечки, FPS/дебаг, индикатор времени суток,\n// стартовый оверлей с управлением и пауза. Чистый DOM, перерисовка по dirty-флагу.\nimport { events } from \"../core/EventBus.js\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items.js\";\nimport type { Game } from \"../main.js\";\n\nexport class HUD {\n game: Game;\n $: (id: string) => HTMLElement;\n hotbarEl: HTMLElement;\n heartsEl: HTMLElement;\n fpsEl: HTMLElement;\n debugEl: HTMLElement;\n timeEl: HTMLElement;\n overlayEl: HTMLElement;\n hurtEl: HTMLElement;\n msgEl: HTMLElement;\n hungerEl: HTMLElement;\n _invDirty: boolean;\n _lastHealth: number;\n _lastFood?: number;\n _modeWas?: string;\n _foodModeWas?: string;\n _msgT?: ReturnType<typeof setTimeout>;\n _slots: HTMLDivElement[];\n _hearts: HTMLSpanElement[];\n _drumsticks: HTMLSpanElement[];\n\n constructor(game: Game) {\n this.game = game;\n // Все элементы гарантированно есть в разметке — helper утверждает non-null.\n this.$ = (id: string): HTMLElement => document.getElementById(id)!;\n this.hotbarEl = this.$(\"hotbar\");\n this.heartsEl = this.$(\"hearts\");\n this.fpsEl = this.$(\"fps\");\n this.debugEl = this.$(\"debug\");\n this.timeEl = this.$(\"timeIndicator\");\n this.overlayEl = this.$(\"overlay\");\n this.hurtEl = this.$(\"hurtFlash\");\n this.msgEl = this.$(\"message\");\n\n this._invDirty = true;\n this._lastHealth = -1;\n events.on(\"invChanged\", () => {\n this._invDirty = true;\n });\n events.on(\"playerHurt\", () => this._flashHurt());\n events.on(\"playerDied\", () => this.message(\"Вы погибли…\", 1200));\n events.on(\"modeChanged\", ({ mode }) =>\n this.message(\n mode === \"creative\"\n ? \"Режим: КРЕАТИВ (двойной Space — полёт)\"\n : \"Режим: выживание\",\n 1600,\n ),\n );\n\n // слоты хотбара\n this._slots = [];\n for (let i = 0; i < 9; i++) {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n this.hotbarEl.appendChild(d);\n this._slots.push(d);\n }\n // сердечки\n this._hearts = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"heart\";\n s.textContent = \"❤\";\n this.heartsEl.appendChild(s);\n this._hearts.push(s);\n }\n // окорочка (голод)\n this.hungerEl = this.$(\"hunger\");\n this._drumsticks = [];\n for (let i = 0; i < 10; i++) {\n const s = document.createElement(\"span\");\n s.className = \"drumstick\";\n s.textContent = \"🍗\";\n this.hungerEl.appendChild(s);\n this._drumsticks.push(s);\n }\n\n // кнопки паузы\n this.$(\"btnExport\").onclick = () => game.save.exportFile();\n this.$(\"btnImport\").onclick = () =>\n game.save.importFile((err: unknown) => {\n this.message(\n err ? \"Ошибка импорта: \" + (err as Error).message : \"Мир загружен\",\n 1800,\n );\n });\n this.$(\"btnNewSeed\").onclick = () => {\n const s = prompt(\n \"Сид нового мира (число):\",\n String((Math.random() * 1e9) | 0),\n );\n if (s !== null && s.trim() !== \"\") game.newWorld(Number(s) || 0);\n };\n this.overlayEl.addEventListener(\"click\", (e) => {\n if ((e.target as HTMLElement).closest(\"button\")) return; // кнопки не запускают игру\n game.startPlay();\n });\n }\n\n showOverlay(paused: boolean) {\n this.overlayEl.classList.remove(\"hidden\");\n this.$(\"overlayTitle\").textContent = paused ? \"Пауза\" : \"VoxelCraft\";\n this.$(\"overlayHint\").textContent = paused\n ? \"Клик — продолжить\"\n : \"Клик — играть\";\n }\n hideOverlay() {\n this.overlayEl.classList.add(\"hidden\");\n }\n\n message(text: string, ms = 1500) {\n this.msgEl.textContent = text;\n this.msgEl.classList.add(\"show\");\n clearTimeout(this._msgT);\n this._msgT = setTimeout(() => this.msgEl.classList.remove(\"show\"), ms);\n }\n\n _flashHurt() {\n this.hurtEl.classList.remove(\"show\");\n void this.hurtEl.offsetWidth; // перезапуск CSS-анимации\n this.hurtEl.classList.add(\"show\");\n }\n\n frame() {\n const g = this.game;\n if (this._invDirty) {\n this._renderHotbar();\n this._invDirty = false;\n }\n if (\n g.player.health !== this._lastHealth ||\n this._modeWas !== g.player.mode\n ) {\n this._renderHearts();\n this._lastHealth = g.player.health;\n this._modeWas = g.player.mode;\n }\n if (\n g.player.food !== this._lastFood ||\n this._foodModeWas !== g.player.mode\n ) {\n this._renderHunger();\n this._lastFood = g.player.food;\n this._foodModeWas = g.player.mode;\n }\n\n this.fpsEl.textContent = g.loop.fps + \" FPS\";\n // дебаг-строка\n const p = g.player.pos;\n this.debugEl.textContent =\n `xyz: ${p.x.toFixed(1)} ${p.y.toFixed(1)} ${p.z.toFixed(1)} | ` +\n `чанки: ${g.world.chunks.size} | вода: ${g.waterSim.queueSize} | сид: ${g.world.seed}`;\n\n // индикатор времени: стрелка-дуга солнца/луны\n const ph = g.dayNight.phase;\n this.timeEl.textContent =\n (g.dayNight.isDay ? \"☀\" : \"☾\") + \" \" + (ph < 0.5 ? \"день\" : \"ночь\");\n }\n\n _renderHotbar() {\n const inv = this.game.inventory;\n for (let i = 0; i < 9; i++) {\n const el = this._slots[i];\n const s = inv.slots[i];\n el.classList.toggle(\"selected\", i === inv.hotbarIndex);\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (s) {\n img.src = ITEMS[s.item].iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = ITEM_LABELS[s.item] ?? s.item;\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (s.count > 1 ? s.count : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n }\n }\n }\n\n _renderHearts() {\n const g = this.game;\n this.heartsEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const hp = g.player.health;\n for (let i = 0; i < 10; i++) {\n const v = hp - i * 2;\n this._hearts[i].className =\n \"heart\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n\n _renderHunger() {\n const g = this.game;\n this.hungerEl.style.display = g.player.mode === \"creative\" ? \"none\" : \"\";\n const food = g.player.food;\n for (let i = 0; i < 10; i++) {\n const v = food - i * 2;\n this._drumsticks[i].className =\n \"drumstick\" + (v >= 2 ? \"\" : v >= 1 ? \" half\" : \" empty\");\n }\n }\n}\n"
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
"path": "ui/InventoryUI.ts",
|
|
140
|
+
"content": "// Экран инвентаря и крафта: сетка слотов с drag-and-drop «курсорным стаком»\n// (клик — взять/положить, ПКМ — половина/по одному), крафт 2×2 или 3×3 (верстак).\n// UI мутирует ТОЛЬКО модели Inventory/CraftingGrid и живёт поверх них.\nimport { events } from \"../core/EventBus.js\";\nimport { ITEMS, ITEM_LABELS } from \"../registry/Items.js\";\nimport { CraftingGrid } from \"../systems/Crafting.js\";\nimport type { Game } from \"../main.js\";\nimport type { ItemStack } from \"../types.js\";\n\ntype Section = \"inv\" | \"craft\";\n\nexport class InventoryUI {\n game: Game;\n inventory: Game[\"inventory\"];\n grid: CraftingGrid;\n cursor: ItemStack | null;\n open: boolean;\n rootEl: HTMLElement;\n cursorEl: HTMLElement;\n titleEl: HTMLElement;\n _craftSlots!: HTMLDivElement[]; // строятся в _build() до первого _render()\n _resultSlot!: HTMLElement;\n _invSlots!: HTMLDivElement[];\n\n constructor(game: Game) {\n this.game = game;\n this.inventory = game.inventory;\n this.grid = new CraftingGrid(2);\n this.cursor = null; // стак «в руке» у курсора\n this.open = false;\n\n // Элементы гарантированно есть в разметке — non-null утверждается локально.\n this.rootEl = document.getElementById(\"invScreen\")!;\n this.cursorEl = document.getElementById(\"cursorStack\")!;\n this.titleEl = document.getElementById(\"invTitle\")!;\n\n events.on(\"ui:toggleInventory\", () => this.toggle(2));\n events.on(\"ui:openCrafting\", () => this.toggle(3, true));\n\n document.addEventListener(\"mousemove\", (e) => {\n if (!this.open) return;\n this.cursorEl.style.left = e.clientX + \"px\";\n this.cursorEl.style.top = e.clientY + \"px\";\n });\n }\n\n toggle(craftSize: number, forceOpen = false) {\n if (this.open && !forceOpen) return this.close();\n if (this.open && forceOpen) return; // уже открыт\n this.open = true;\n this.grid.setSize(craftSize);\n this.titleEl.textContent = craftSize === 3 ? \"Верстак\" : \"Инвентарь\";\n this.game.input.uiOpen = true;\n this.game.input.releaseLock();\n this.rootEl.classList.remove(\"hidden\");\n this._build();\n this._render();\n }\n\n close() {\n if (!this.open) return;\n this.open = false;\n // вернуть всё из крафт-сетки и курсора\n this.grid.dumpInto(this.inventory);\n if (this.cursor) {\n const left = this.inventory.add(this.cursor.item, this.cursor.count);\n if (left > 0)\n // некуда класть — выбрасываем под ноги, ничего не теряем\n this.game.entities.spawnDrop(\n this.game.player.pos.x,\n this.game.player.pos.y + 1,\n this.game.player.pos.z,\n this.cursor.item,\n left,\n );\n this.cursor = null;\n }\n this.rootEl.classList.add(\"hidden\");\n this.game.input.uiOpen = false;\n this.game.input.requestLock();\n }\n\n /** Построение DOM под текущий размер крафт-сетки. */\n _build() {\n const n = this.grid.size;\n const craftEl = document.getElementById(\"craftGrid\")!;\n craftEl.style.gridTemplateColumns = `repeat(${n}, 44px)`;\n craftEl.innerHTML = \"\";\n this._craftSlots = [];\n for (let i = 0; i < n * n; i++)\n this._craftSlots.push(this._slot(craftEl, \"craft\", i));\n\n this._resultSlot = document.getElementById(\"craftResult\")!;\n this._resultSlot.innerHTML =\n '<img draggable=\"false\"><span class=\"count\"></span>';\n this._resultSlot.onmousedown = (e) => {\n this._clickResult(e);\n e.preventDefault();\n };\n\n const invEl = document.getElementById(\"invGrid\")!;\n invEl.innerHTML = \"\";\n this._invSlots = [];\n for (let i = 9; i < 36; i++)\n this._invSlots[i] = this._slot(invEl, \"inv\", i);\n const hbEl = document.getElementById(\"invHotbar\")!;\n hbEl.innerHTML = \"\";\n for (let i = 0; i < 9; i++) this._invSlots[i] = this._slot(hbEl, \"inv\", i);\n }\n\n _slot(parent: HTMLElement, section: Section, index: number): HTMLDivElement {\n const d = document.createElement(\"div\");\n d.className = \"slot\";\n d.innerHTML = '<img draggable=\"false\"><span class=\"count\"></span>';\n d.onmousedown = (e) => {\n this._clickSlot(section, index, e.button === 2);\n e.preventDefault();\n };\n d.oncontextmenu = (e) => e.preventDefault();\n parent.appendChild(d);\n return d;\n }\n\n _getStack(section: Section, i: number): ItemStack | null {\n return section === \"inv\" ? this.inventory.slots[i] : this.grid.slots[i];\n }\n _setStack(section: Section, i: number, stack: ItemStack | null) {\n if (section === \"inv\") this.inventory.setSlot(i, stack);\n else this.grid.slots[i] = stack;\n }\n\n _clickSlot(section: Section, i: number, rightClick: boolean) {\n const cur = this.cursor;\n const slot = this._getStack(section, i);\n\n if (!cur) {\n if (!slot) return;\n if (rightClick) {\n // взять половину\n const take = Math.ceil(slot.count / 2);\n this.cursor = { item: slot.item, count: take };\n slot.count -= take;\n this._setStack(section, i, slot.count > 0 ? slot : null);\n } else {\n this.cursor = slot;\n this._setStack(section, i, null);\n }\n } else {\n const max = ITEMS[cur.item].maxStack;\n if (!slot) {\n if (rightClick) {\n this._setStack(section, i, { item: cur.item, count: 1 });\n cur.count -= 1;\n if (cur.count <= 0) this.cursor = null;\n } else {\n this._setStack(section, i, cur);\n this.cursor = null;\n }\n } else if (slot.item === cur.item) {\n const move = rightClick ? 1 : cur.count;\n const take = Math.min(move, max - slot.count);\n slot.count += take;\n cur.count -= take;\n this._setStack(section, i, slot);\n if (cur.count <= 0) this.cursor = null;\n } else {\n // обмен\n this._setStack(section, i, cur);\n this.cursor = slot;\n }\n }\n this._render();\n }\n\n _clickResult(_e?: MouseEvent) {\n const res = this.grid.result();\n if (!res) return;\n if (\n this.cursor &&\n (this.cursor.item !== res.item ||\n this.cursor.count + res.count > ITEMS[res.item].maxStack)\n )\n return;\n const taken = this.grid.takeResult()!; // result() уже проверен выше — сетка не менялась, результат есть\n if (this.cursor) this.cursor.count += taken.count;\n else this.cursor = { item: taken.item, count: taken.count };\n this._render();\n }\n\n _render() {\n const draw = (el: HTMLElement, stack: ItemStack | null) => {\n const img = el.querySelector(\"img\")!; // разметка слота всегда содержит <img>\n const count = el.querySelector(\".count\")!; // …и .count\n if (stack) {\n img.src = ITEMS[stack.item].iconURL!; // iconURL заполняется buildIcons() до первого рендера\n img.style.display = \"\";\n img.title = ITEM_LABELS[stack.item] ?? stack.item;\n // textContent-сеттер сам приводит число к строке; каст только для типов (в JS не компилируется)\n count.textContent = (stack.count > 1\n ? stack.count\n : \"\") as unknown as string;\n } else {\n img.style.display = \"none\";\n count.textContent = \"\";\n }\n };\n for (let i = 0; i < 36; i++)\n draw(this._invSlots[i], this.inventory.slots[i]);\n this._craftSlots.forEach((el, i) => draw(el, this.grid.slots[i]));\n draw(\n this._resultSlot,\n this.grid.result() ? { ...this.grid.result()! } : null,\n );\n\n // курсорный стак\n if (this.cursor) {\n this.cursorEl.classList.remove(\"hidden\");\n this.cursorEl.querySelector(\"img\")!.src =\n ITEMS[this.cursor.item].iconURL!;\n this.cursorEl.querySelector(\".count\")!.textContent = (this.cursor.count >\n 1\n ? this.cursor.count\n : \"\") as unknown as string;\n } else {\n this.cursorEl.classList.add(\"hidden\");\n }\n events.emit(\"invChanged\"); // HUD-хотбар обновится\n }\n}\n"
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"path": "world/Chunk.ts",
|
|
144
|
+
"content": "// Чанк: плоские типизированные массивы блоков и меты.\n// Индекс: x | (z<<4) | (y<<8) — горизонтальные срезы непрерывны (мешинг/вода\n// читают их кэш-дружелюбно).\nimport type * as THREE from \"three\";\nimport { CONFIG } from \"../config.js\";\n\nconst { sx: SX, sy: SY, sz: SZ } = CONFIG.chunk;\nexport const idx = (x: number, y: number, z: number): number =>\n x | (z << 4) | (y << 8);\n\nexport class Chunk {\n cx: number;\n cz: number;\n blocks: Uint8Array;\n meta: Uint8Array;\n modified: Map<number, [number, number]>;\n dirty: boolean;\n waterDirty: boolean;\n generated: boolean;\n solidMesh: THREE.Mesh | null;\n foliageMesh: THREE.Mesh | null;\n waterMesh: THREE.Mesh | null;\n\n constructor(cx: number, cz: number) {\n this.cx = cx;\n this.cz = cz;\n this.blocks = new Uint8Array(SX * SY * SZ);\n this.meta = new Uint8Array(SX * SY * SZ);\n // Диф от генерации: каждый рантайм-setBlock пишется сюда.\n // Это одновременно формат сейва и полного снапшота этапа 2.\n this.modified = new Map(); // index -> [id, meta]\n this.dirty = false; // нужна пересборка твёрдой/растительной геометрии\n this.waterDirty = false; // нужна пересборка только водной геометрии\n this.generated = false;\n // THREE.Mesh-ы (создаются/пересоздаются мешером)\n this.solidMesh = null;\n this.foliageMesh = null;\n this.waterMesh = null;\n }\n\n get(x: number, y: number, z: number): number {\n return this.blocks[idx(x, y, z)];\n }\n getMeta(x: number, y: number, z: number): number {\n return this.meta[idx(x, y, z)];\n }\n\n /** Прямая запись без записи в diff — используется генератором. */\n setRaw(x: number, y: number, z: number, id: number, meta = 0): void {\n const i = idx(x, y, z);\n this.blocks[i] = id;\n this.meta[i] = meta;\n }\n\n /** Рантайм-запись: фиксируется в modified (сейв/снапшот). */\n set(x: number, y: number, z: number, id: number, meta = 0): void {\n const i = idx(x, y, z);\n this.blocks[i] = id;\n this.meta[i] = meta;\n this.modified.set(i, [id, meta]);\n }\n\n /** Применить сейв-диф после генерации. */\n applyDiff(entries: Iterable<[number, [number, number]]>): void {\n for (const [i, [id, meta]] of entries) {\n this.blocks[i] = id;\n this.meta[i] = meta;\n this.modified.set(i, [id, meta]);\n }\n }\n\n serializeDiff(): [number, number, number][] {\n return [...this.modified.entries()].map(\n ([i, [id, meta]]): [number, number, number] => [i, id, meta],\n );\n }\n}\n\nexport const CHUNK_SIZE = { SX, SY, SZ };\n"
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
"path": "world/ChunkManager.ts",
|
|
148
|
+
"content": "// Менеджер чанков: стриминг вокруг игрока, мировые getBlock/setBlock,\n// очередь пересборки мешей с бюджетом на кадр, планировщик onTick-блоков\n// (шов будущей автоматизации: печи/конвейеры регистрируются сами).\nimport type * as THREE from \"three\";\nimport { CONFIG } from \"../config.js\";\nimport { events } from \"../core/EventBus.js\";\nimport { BLOCKS, B } from \"../registry/Blocks.js\";\nimport { Chunk, CHUNK_SIZE, idx } from \"./Chunk.js\";\nimport { TerrainGenerator } from \"./TerrainGenerator.js\";\nimport { ChunkMesher } from \"./ChunkMesher.js\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas.js\";\nimport type { ChunkDiffMap } from \"../types.js\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\nconst key = (cx: number, cz: number): string => cx + \",\" + cz;\n\nexport class ChunkManager {\n scene: THREE.Scene;\n seed: number;\n generator: TerrainGenerator;\n mesher: ChunkMesher;\n chunks: Map<string, Chunk>;\n genQueue: Chunk[];\n pendingDiffs: Map<string, Map<number, [number, number]>>;\n tickingBlocks: Map<string, { x: number; y: number; z: number }>;\n\n constructor(scene: THREE.Scene, atlas: TextureAtlas, seed: number) {\n this.scene = scene;\n this.seed = seed;\n this.generator = new TerrainGenerator(seed);\n this.mesher = new ChunkMesher(atlas, this);\n this.chunks = new Map(); // \"cx,cz\" -> Chunk\n this.genQueue = []; // чанки, ожидающие генерации данных\n this.pendingDiffs = new Map(); // сейв-дифы для ещё не загруженных чанков\n this.tickingBlocks = new Map(); // \"x,y,z\" -> {x,y,z} — блоки с onTick\n }\n\n // ---- доступ к блокам в мировых координатах --------------------------------\n\n chunkAt(wx: number, wz: number): Chunk | undefined {\n return this.chunks.get(key(Math.floor(wx / SX), Math.floor(wz / SZ)));\n }\n\n getBlock(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return B.air;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return B.air;\n return c.blocks[idx(wx & 15, wy, wz & 15)];\n }\n\n /** Как getBlock, но -1 для незагруженного чанка (мешер трактует как «непрозрачно»). */\n getBlockOrUnloaded(wx: number, wy: number, wz: number): number {\n if (wy < 0) return B.bedrock;\n if (wy >= SY) return B.air;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return -1;\n return c.blocks[idx(wx & 15, wy, wz & 15)];\n }\n\n getMeta(wx: number, wy: number, wz: number): number {\n if (wy < 0 || wy >= SY) return 0;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return 0;\n return c.meta[idx(wx & 15, wy, wz & 15)];\n }\n\n isSolid(wx: number, wy: number, wz: number): boolean {\n return BLOCKS[this.getBlock(wx, wy, wz)]?.solid ?? false;\n }\n\n /**\n * Единственная точка мутации мира. Пишет диф, дертит чанк и соседей\n * на границе, ведёт реестр onTick-блоков, эмитит blockChanged.\n */\n setBlock(\n wx: number,\n wy: number,\n wz: number,\n id: number,\n meta = 0,\n opts: { waterOnly?: boolean } = {},\n ): boolean {\n if (wy < 0 || wy >= SY) return false;\n const c = this.chunkAt(wx, wz);\n if (!c || !c.generated) return false;\n const lx = wx & 15,\n lz = wz & 15;\n const prevId = c.blocks[idx(lx, wy, lz)];\n c.set(lx, wy, lz, id, meta);\n\n // только вода → дешёвая пересборка одного водного меша\n if (\n opts.waterOnly &&\n (id === B.water || prevId === B.water) &&\n !(id !== B.water && prevId !== B.water)\n ) {\n c.waterDirty = true;\n } else {\n c.dirty = true;\n if (lx === 0) this._dirtyNeighbor(wx - 1, wz);\n if (lx === 15) this._dirtyNeighbor(wx + 1, wz);\n if (lz === 0) this._dirtyNeighbor(wx, wz - 1);\n if (lz === 15) this._dirtyNeighbor(wx, wz + 1);\n }\n\n const tickKey = wx + \",\" + wy + \",\" + wz;\n if (BLOCKS[id]?.onTick)\n this.tickingBlocks.set(tickKey, { x: wx, y: wy, z: wz });\n else this.tickingBlocks.delete(tickKey);\n\n events.emit(\"blockChanged\", { x: wx, y: wy, z: wz, id, meta, prevId });\n return true;\n }\n\n _dirtyNeighbor(wx: number, wz: number): void {\n const c = this.chunkAt(wx, wz);\n if (c && c.generated) c.dirty = true;\n }\n\n /** Высота поверхности (первый твёрдый блок сверху) — для спавна. */\n surfaceY(wx: number, wz: number): number {\n for (let y = SY - 1; y >= 0; y--)\n if (BLOCKS[this.getBlock(wx, y, wz)].solid) return y;\n return CONFIG.world.baseHeight;\n }\n\n // ---- стриминг ---------------------------------------------------------------\n\n /** Вызывается каждый кадр: догружает кольцо чанков, выгружает дальние. */\n update(px: number, pz: number): void {\n const pcx = Math.floor(px / SX),\n pcz = Math.floor(pz / SZ);\n const R = CONFIG.renderDistance;\n\n // заказать недостающие чанки (по спирали от игрока — ближние раньше)\n for (let r = 0; r <= R; r++) {\n for (let dx = -r; dx <= r; dx++) {\n for (let dz = -r; dz <= r; dz++) {\n if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;\n const k = key(pcx + dx, pcz + dz);\n if (!this.chunks.has(k)) {\n const chunk = new Chunk(pcx + dx, pcz + dz);\n this.chunks.set(k, chunk);\n this.genQueue.push(chunk);\n }\n }\n }\n }\n\n // генерация данных с бюджетом\n let gen = 0;\n while (this.genQueue.length && gen < CONFIG.genBudgetPerFrame) {\n const chunk = this.genQueue.shift()!;\n if (!this.chunks.has(key(chunk.cx, chunk.cz))) continue; // успел выгрузиться\n this.generator.generate(chunk);\n const diff = this.pendingDiffs.get(key(chunk.cx, chunk.cz));\n if (diff) {\n chunk.applyDiff(diff);\n this.pendingDiffs.delete(key(chunk.cx, chunk.cz));\n // восстановить onTick-реестр из дифа\n for (const [i, [id]] of chunk.modified)\n if (BLOCKS[id]?.onTick) {\n const x = chunk.cx * SX + (i & 15),\n y = i >> 8,\n z = chunk.cz * SZ + ((i >> 4) & 15);\n this.tickingBlocks.set(x + \",\" + y + \",\" + z, { x, y, z });\n }\n }\n chunk.dirty = true;\n // сосед уже отрисован с «глухой» границей — пере-дертить\n this._dirtyNeighbor((chunk.cx - 1) * SX, chunk.cz * SZ);\n this._dirtyNeighbor((chunk.cx + 1) * SX + 1, chunk.cz * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz - 1) * SZ);\n this._dirtyNeighbor(chunk.cx * SX, (chunk.cz + 1) * SZ + 1);\n gen++;\n }\n\n // выгрузка дальних\n for (const [k, chunk] of this.chunks) {\n if (\n Math.max(Math.abs(chunk.cx - pcx), Math.abs(chunk.cz - pcz)) >\n CONFIG.unloadDistance\n ) {\n this._disposeChunk(chunk);\n this.chunks.delete(k);\n if (chunk.modified.size) this.pendingDiffs.set(k, chunk.modified);\n }\n }\n\n // пересборка мешей: ближние к игроку — приоритетнее\n const dirty: Chunk[] = [];\n for (const chunk of this.chunks.values()) {\n if (!chunk.generated) continue;\n if (chunk.dirty || chunk.waterDirty) dirty.push(chunk);\n }\n dirty.sort(\n (a, b) =>\n Math.abs(a.cx - pcx) +\n Math.abs(a.cz - pcz) -\n (Math.abs(b.cx - pcx) + Math.abs(b.cz - pcz)),\n );\n for (\n let i = 0;\n i < Math.min(dirty.length, CONFIG.meshBudgetPerFrame);\n i++\n ) {\n const chunk = dirty[i];\n this.mesher.rebuild(chunk, this.scene, !chunk.dirty && chunk.waterDirty);\n }\n }\n\n _disposeChunk(chunk: Chunk): void {\n for (const m of [chunk.solidMesh, chunk.foliageMesh, chunk.waterMesh]) {\n if (m) {\n this.scene.remove(m);\n m.geometry.dispose();\n }\n }\n chunk.solidMesh = chunk.foliageMesh = chunk.waterMesh = null;\n }\n\n /** Тик блоков с поведением (в MVP реестр пуст — шов автоматизации). */\n tick(): void {\n for (const { x, y, z } of this.tickingBlocks.values()) {\n const def = BLOCKS[this.getBlock(x, y, z)];\n def?.onTick?.(this, x, y, z, this.getMeta(x, y, z));\n }\n }\n\n /** Полная очистка (регенерация по новому сиду / импорт сейва). */\n clear(): void {\n for (const chunk of this.chunks.values()) this._disposeChunk(chunk);\n this.chunks.clear();\n this.genQueue.length = 0;\n this.pendingDiffs.clear();\n this.tickingBlocks.clear();\n }\n\n /** Сейв: дифы всех чанков (загруженных и отложенных). */\n serializeDiffs(): ChunkDiffMap {\n const out: ChunkDiffMap = {};\n for (const [k, diff] of this.pendingDiffs)\n out[k] = [...diff.entries()].map(\n ([i, [id, meta]]): [number, number, number] => [i, id, meta],\n );\n for (const [k, chunk] of this.chunks)\n if (chunk.modified.size) out[k] = chunk.serializeDiff();\n return out;\n }\n\n /** Импорт сейва: дифы применятся к чанкам по мере генерации. */\n loadDiffs(obj: ChunkDiffMap): void {\n for (const [k, arr] of Object.entries(obj))\n this.pendingDiffs.set(\n k,\n new Map(\n arr.map(([i, id, meta]): [number, [number, number]] => [\n i,\n [id, meta],\n ]),\n ),\n );\n }\n}\n"
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"path": "world/ChunkMesher.ts",
|
|
152
|
+
"content": "// Мешер чанка: одна пересборка = три геометрии (твёрдая, растительность-крестовины,\n// вода). Рендерятся только грани, видимые наружу: сосед-непрозрачный блок гасит грань,\n// в том числе через границу чанков (незагруженный сосед считается непрозрачным —\n// фронтир прячет туман, при загрузке соседа чанк пере-дертится).\nimport * as THREE from \"three\";\nimport { BLOCKS, B, waterLevel } from \"../registry/Blocks.js\";\nimport { CHUNK_SIZE, idx, type Chunk } from \"./Chunk.js\";\nimport type { TextureAtlas } from \"../gfx/TextureAtlas.js\";\nimport type { ChunkManager } from \"./ChunkManager.js\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\ninterface UVRect {\n u0: number;\n u1: number;\n v0: number;\n v1: number;\n}\n\n// Таблица граней куба: направление, 4 угла (позиция + UV), базовая яркость.\n// Порядок индексов: i, i+1, i+2, i+2, i+1, i+3 (CCW снаружи).\nconst FACES = [\n {\n dir: [-1, 0, 0],\n shade: 0.8,\n corners: [\n [0, 1, 0, 0, 1],\n [0, 0, 0, 0, 0],\n [0, 1, 1, 1, 1],\n [0, 0, 1, 1, 0],\n ],\n },\n {\n dir: [1, 0, 0],\n shade: 0.8,\n corners: [\n [1, 1, 1, 0, 1],\n [1, 0, 1, 0, 0],\n [1, 1, 0, 1, 1],\n [1, 0, 0, 1, 0],\n ],\n },\n {\n dir: [0, -1, 0],\n shade: 0.5,\n corners: [\n [1, 0, 1, 1, 0],\n [0, 0, 1, 0, 0],\n [1, 0, 0, 1, 1],\n [0, 0, 0, 0, 1],\n ],\n },\n {\n dir: [0, 1, 0],\n shade: 1.0,\n corners: [\n [0, 1, 1, 1, 1],\n [1, 1, 1, 0, 1],\n [0, 1, 0, 1, 0],\n [1, 1, 0, 0, 0],\n ],\n },\n {\n dir: [0, 0, -1],\n shade: 0.6,\n corners: [\n [1, 0, 0, 0, 0],\n [0, 0, 0, 1, 0],\n [1, 1, 0, 0, 1],\n [0, 1, 0, 1, 1],\n ],\n },\n {\n dir: [0, 0, 1],\n shade: 0.6,\n corners: [\n [0, 0, 1, 0, 0],\n [1, 0, 1, 1, 0],\n [0, 1, 1, 0, 1],\n [1, 1, 1, 1, 1],\n ],\n },\n];\n\nclass GeoBuilder {\n pos: number[];\n norm: number[];\n uv: number[];\n col: number[];\n index: number[];\n constructor() {\n this.pos = [];\n this.norm = [];\n this.uv = [];\n this.col = [];\n this.index = [];\n }\n get empty(): boolean {\n return this.index.length === 0;\n }\n quad(\n corners: number[][],\n ox: number,\n oy: number,\n oz: number,\n nx: number,\n ny: number,\n nz: number,\n uvRect: UVRect | null,\n shade: number,\n ): void {\n const base = this.pos.length / 3;\n for (const [cx, cy, cz, cu, cv] of corners) {\n this.pos.push(ox + cx, oy + cy, oz + cz);\n this.norm.push(nx, ny, nz);\n this.uv.push(\n uvRect ? uvRect.u0 + (uvRect.u1 - uvRect.u0) * cu : cu,\n uvRect ? uvRect.v0 + (uvRect.v1 - uvRect.v0) * cv : cv,\n );\n this.col.push(shade, shade, shade);\n }\n this.index.push(base, base + 1, base + 2, base + 2, base + 1, base + 3);\n }\n build(): THREE.BufferGeometry {\n const g = new THREE.BufferGeometry();\n g.setAttribute(\"position\", new THREE.Float32BufferAttribute(this.pos, 3));\n g.setAttribute(\"normal\", new THREE.Float32BufferAttribute(this.norm, 3));\n g.setAttribute(\"uv\", new THREE.Float32BufferAttribute(this.uv, 2));\n g.setAttribute(\"color\", new THREE.Float32BufferAttribute(this.col, 3));\n g.setIndex(this.index);\n return g;\n }\n}\n\nexport class ChunkMesher {\n atlas: TextureAtlas;\n world: ChunkManager;\n _uvCache: Map<string, UVRect>;\n\n /** @param {import('../gfx/TextureAtlas.js').TextureAtlas} atlas */\n constructor(atlas: TextureAtlas, world: ChunkManager) {\n this.atlas = atlas;\n this.world = world; // ChunkManager: кросс-чанковые чтения на границах\n this._uvCache = new Map(); // tileName -> uvRect\n }\n\n _uv(tileName: string): UVRect {\n let r = this._uvCache.get(tileName);\n if (!r) {\n r = this.atlas.uv(tileName);\n this._uvCache.set(tileName, r);\n }\n return r;\n }\n\n /** Пересобирает и подменяет меши чанка. Старые геометрии всегда dispose(). */\n rebuild(chunk: Chunk, scene: THREE.Scene, waterOnly = false): void {\n const wx0 = chunk.cx * SX,\n wz0 = chunk.cz * SZ;\n const solid = waterOnly ? null : new GeoBuilder();\n const foliage = waterOnly ? null : new GeoBuilder();\n const water = new GeoBuilder();\n\n // Чтение блока с фолбэком в мир на границах. -1 = чанк не загружен.\n const blockAt = (x: number, y: number, z: number): number => {\n if (y < 0) return B.bedrock; // ниже мира — «непрозрачно»\n if (y >= SY) return B.air;\n if (x >= 0 && x < SX && z >= 0 && z < SZ)\n return chunk.blocks[idx(x, y, z)];\n return this.world.getBlockOrUnloaded(wx0 + x, y, wz0 + z);\n };\n const metaAt = (x: number, y: number, z: number): number => {\n if (x >= 0 && x < SX && z >= 0 && z < SZ && y >= 0 && y < SY)\n return chunk.meta[idx(x, y, z)];\n return this.world.getMeta(wx0 + x, y, wz0 + z);\n };\n const opaqueAt = (x: number, y: number, z: number): boolean => {\n const id = blockAt(x, y, z);\n return id === -1 || BLOCKS[id]?.opaque;\n };\n\n for (let y = 0; y < SY; y++) {\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const id = chunk.blocks[idx(x, y, z)];\n if (id === B.air) continue;\n const def = BLOCKS[id];\n\n if (def.liquid) {\n this._waterCell(water, chunk, x, y, z, wx0, wz0, blockAt, metaAt);\n continue;\n }\n if (waterOnly) continue;\n\n if (def.cross) {\n // растение: два скрещённых квада по диагоналям\n const uv = this._uv(def.tex!.side!);\n const b = foliage!;\n const q = (c: number[][]): void =>\n b.quad(c, x, y, z, 0, 1, 0, uv, 1.0);\n q([\n [0.15, 1, 0.15, 0, 1],\n [0.15, 0, 0.15, 0, 0],\n [0.85, 1, 0.85, 1, 1],\n [0.85, 0, 0.85, 1, 0],\n ]);\n q([\n [0.85, 1, 0.15, 0, 1],\n [0.85, 0, 0.15, 0, 0],\n [0.15, 1, 0.85, 1, 1],\n [0.15, 0, 0.85, 1, 0],\n ]);\n continue;\n }\n\n const target = def.opaque ? solid! : foliage!; // листва — в cutout-проход\n for (const f of FACES) {\n const nx = x + f.dir[0],\n ny = y + f.dir[1],\n nz = z + f.dir[2];\n if (opaqueAt(nx, ny, nz)) continue;\n if (!def.opaque && blockAt(nx, ny, nz) === id) continue; // листва к листве\n const tile =\n f.dir[1] > 0\n ? def.tex!.top!\n : f.dir[1] < 0\n ? def.tex!.bottom!\n : def.tex!.side!;\n target.quad(\n f.corners,\n x,\n y,\n z,\n f.dir[0],\n f.dir[1],\n f.dir[2],\n this._uv(tile),\n f.shade,\n );\n }\n }\n }\n }\n\n if (!waterOnly) {\n chunk.solidMesh = this._swap(\n chunk.solidMesh,\n solid!,\n this.atlas.opaqueMat,\n scene,\n wx0,\n wz0,\n 0,\n );\n chunk.foliageMesh = this._swap(\n chunk.foliageMesh,\n foliage!,\n this.atlas.foliageMat,\n scene,\n wx0,\n wz0,\n 0,\n );\n chunk.dirty = false;\n }\n chunk.waterMesh = this._swap(\n chunk.waterMesh,\n water,\n this.atlas.waterMat,\n scene,\n wx0,\n wz0,\n 1,\n );\n chunk.waterDirty = false;\n }\n\n _waterCell(\n b: GeoBuilder,\n chunk: Chunk,\n x: number,\n y: number,\n z: number,\n wx0: number,\n wz0: number,\n blockAt: (x: number, y: number, z: number) => number,\n _metaAt: (x: number, y: number, z: number) => number,\n ): void {\n const isWater = (id: number): boolean => id === B.water;\n const level = waterLevel(chunk.meta[idx(x, y, z)]);\n const above = blockAt(x, y + 1, z);\n // высота столба: полный, если сверху тоже вода\n const h = isWater(above) ? 1 : 0.125 + (level / 8) * 0.75;\n const wx = wx0 + x,\n wz = wz0 + z; // мировые UV: repeat-текстура тайлится сама\n\n // верх: виден, если сверху не вода\n if (!isWater(above)) {\n b.quad(\n [\n [0, h, 1, 0, 1],\n [1, h, 1, 1, 1],\n [0, h, 0, 0, 0],\n [1, h, 0, 1, 0],\n ].map(([cx, cy, cz]) => [cx, cy, cz, wx + cx, wz + cz]),\n x,\n y,\n z,\n 0,\n 1,\n 0,\n null,\n 1.0,\n );\n }\n // низ\n if (\n blockAt(x, y - 1, z) !== -1 &&\n !BLOCKS[blockAt(x, y - 1, z)]?.opaque &&\n !isWater(blockAt(x, y - 1, z))\n ) {\n b.quad(\n [\n [1, 0, 1, wx + 1, wz + 1],\n [0, 0, 1, wx, wz + 1],\n [1, 0, 0, wx + 1, wz],\n [0, 0, 0, wx, wz],\n ],\n x,\n y,\n z,\n 0,\n -1,\n 0,\n null,\n 0.6,\n );\n }\n // бока: против воздуха/прозрачного, но не против воды и не против непрозрачного\n const sides = [\n {\n d: [-1, 0, 0],\n c: [\n [0, 1, 0],\n [0, 0, 0],\n [0, 1, 1],\n [0, 0, 1],\n ],\n },\n {\n d: [1, 0, 0],\n c: [\n [1, 1, 1],\n [1, 0, 1],\n [1, 1, 0],\n [1, 0, 0],\n ],\n },\n {\n d: [0, 0, -1],\n c: [\n [1, 1, 0],\n [1, 0, 0],\n [0, 1, 0],\n [0, 0, 0],\n ],\n },\n {\n d: [0, 0, 1],\n c: [\n [0, 1, 1],\n [0, 0, 1],\n [1, 1, 1],\n [1, 0, 1],\n ],\n },\n ];\n for (const s of sides) {\n const nid = blockAt(x + s.d[0], y, z + s.d[2]);\n if (nid === -1 || isWater(nid) || BLOCKS[nid]?.opaque) continue;\n const corners = s.c.map(([cx, cy, cz]) => {\n const uAxis = s.d[0] !== 0 ? wz + cz : wx + cx; // горизонтальная UV-ось\n return [cx, cy === 1 ? h : 0, cz, uAxis, y + (cy === 1 ? h : 0)];\n });\n b.quad(corners, x, y, z, s.d[0], 0, s.d[2], null, 0.8);\n }\n }\n\n /** Подмена меша: dispose старой геометрии, пустые геометрии не создают меш. */\n _swap(\n oldMesh: THREE.Mesh | null,\n builder: GeoBuilder,\n material: THREE.Material,\n scene: THREE.Scene,\n wx0: number,\n wz0: number,\n renderOrder: number,\n ): THREE.Mesh | null {\n if (oldMesh) {\n scene.remove(oldMesh);\n oldMesh.geometry.dispose();\n }\n if (builder.empty) return null;\n const mesh = new THREE.Mesh(builder.build(), material);\n mesh.position.set(wx0, 0, wz0);\n mesh.renderOrder = renderOrder;\n mesh.matrixAutoUpdate = false;\n mesh.updateMatrix();\n scene.add(mesh);\n return mesh;\n }\n}\n"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"path": "world/Raycast.ts",
|
|
156
|
+
"content": "// Воксельный raycast — DDA (Amanatides–Woo). O(дистанции), точные координаты\n// блока и нормаль входа (= позиция для постановки). three.js Raycaster\n// сознательно не используется: он O(треугольников) и не знает про блоки.\nimport { BLOCKS } from \"../registry/Blocks.js\";\nimport type { ChunkManager } from \"./ChunkManager.js\";\n\ninterface Vec3Like {\n x: number;\n y: number;\n z: number;\n}\n\ninterface RaycastHit {\n x: number;\n y: number;\n z: number;\n id: number;\n nx: number;\n ny: number;\n nz: number;\n dist: number;\n}\n\n/**\n * @param {ChunkManager} world\n * @param {{x,y,z}} origin — точка глаза\n * @param {{x,y,z}} dir — нормализованное направление\n * @param {number} maxDist\n * @param {boolean} hitLiquid — попадать ли по воде (для ведра в будущем)\n * @returns {{x,y,z,id,nx,ny,nz,dist}|null}\n */\nexport function raycastBlocks(\n world: ChunkManager,\n origin: Vec3Like,\n dir: Vec3Like,\n maxDist: number,\n hitLiquid = false,\n): RaycastHit | null {\n let x = Math.floor(origin.x),\n y = Math.floor(origin.y),\n z = Math.floor(origin.z);\n\n const stepX = Math.sign(dir.x),\n stepY = Math.sign(dir.y),\n stepZ = Math.sign(dir.z);\n const tDeltaX = stepX ? Math.abs(1 / dir.x) : Infinity;\n const tDeltaY = stepY ? Math.abs(1 / dir.y) : Infinity;\n const tDeltaZ = stepZ ? Math.abs(1 / dir.z) : Infinity;\n\n const frac = (v: number): number => v - Math.floor(v);\n let tMaxX =\n stepX > 0\n ? (1 - frac(origin.x)) * tDeltaX\n : stepX < 0\n ? frac(origin.x) * tDeltaX\n : Infinity;\n let tMaxY =\n stepY > 0\n ? (1 - frac(origin.y)) * tDeltaY\n : stepY < 0\n ? frac(origin.y) * tDeltaY\n : Infinity;\n let tMaxZ =\n stepZ > 0\n ? (1 - frac(origin.z)) * tDeltaZ\n : stepZ < 0\n ? frac(origin.z) * tDeltaZ\n : Infinity;\n\n let nx = 0,\n ny = 0,\n nz = 0,\n t = 0;\n while (t <= maxDist) {\n const id = world.getBlock(x, y, z);\n const def = BLOCKS[id];\n if (\n def &&\n id !== 0 &&\n (def.solid || def.cross || (hitLiquid && def.liquid))\n )\n return { x, y, z, id, nx, ny, nz, dist: t };\n\n if (tMaxX < tMaxY && tMaxX < tMaxZ) {\n x += stepX;\n t = tMaxX;\n tMaxX += tDeltaX;\n nx = -stepX;\n ny = 0;\n nz = 0;\n } else if (tMaxY < tMaxZ) {\n y += stepY;\n t = tMaxY;\n tMaxY += tDeltaY;\n nx = 0;\n ny = -stepY;\n nz = 0;\n } else {\n z += stepZ;\n t = tMaxZ;\n tMaxZ += tDeltaZ;\n nx = 0;\n ny = 0;\n nz = -stepZ;\n }\n }\n return null;\n}\n"
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
"path": "world/TerrainGenerator.ts",
|
|
160
|
+
"content": "// Генератор террейна: высотная карта на симплекс-fbm, слои пород, руды,\n// озёра, деревья и растения. Полностью детерминирован сидом:\n// один и тот же сид → бит-в-бит одинаковый мир (важно для сейва и этапа 2,\n// где клиент генерирует мир по сиду и накатывает диф).\nimport { CONFIG } from \"../config.js\";\nimport { Simplex2, mulberry32, hashString } from \"../core/Noise.js\";\nimport { B, packWater } from \"../registry/Blocks.js\";\nimport { CHUNK_SIZE, type Chunk } from \"./Chunk.js\";\n\nconst { SX, SY, SZ } = CHUNK_SIZE;\n\nexport class TerrainGenerator {\n seed: number;\n height: Simplex2;\n detail: Simplex2;\n\n constructor(seed: number) {\n this.seed = seed;\n this.height = new Simplex2(hashString(\"height:\" + seed));\n this.detail = new Simplex2(hashString(\"detail:\" + seed));\n }\n\n /** Высота поверхности в мировых координатах. */\n surfaceHeight(wx: number, wz: number): number {\n const w = CONFIG.world;\n const h =\n this.height.fbm(wx * 0.008, wz * 0.008, 4) * w.hillAmp +\n this.detail.noise(wx * 0.05, wz * 0.05) * 2;\n return Math.max(2, Math.min(SY - 8, Math.round(w.baseHeight + h)));\n }\n\n /** Заполняет данные чанка. Деревья сажаются с отступом от края (крона радиуса 2\n * не пересекает границу чанка — генерация чанков остаётся независимой). */\n generate(chunk: Chunk): void {\n const water = CONFIG.world.waterLevel;\n const rng = mulberry32(\n hashString(`chunk:${this.seed}:${chunk.cx},${chunk.cz}`),\n );\n const heights = new Int16Array(SX * SZ);\n\n for (let z = 0; z < SZ; z++) {\n for (let x = 0; x < SX; x++) {\n const wx = chunk.cx * SX + x,\n wz = chunk.cz * SZ + z;\n const h = this.surfaceHeight(wx, wz);\n heights[x + z * SX] = h;\n const beach = h <= water + 1; // у воды — песок вместо дёрна\n\n for (let y = 0; y <= h; y++) {\n let id: number;\n if (y === 0) id = B.bedrock;\n else if (y === 1 && rng() < 0.5)\n id = B.bedrock; // рваный второй слой\n else if (y >= h - (beach ? 2 : 0) && beach) id = B.sand;\n else if (y === h) id = B.grass;\n else if (y >= h - 3) id = B.dirt;\n else id = B.stone;\n chunk.setRaw(x, y, z, id);\n }\n // озёра: всё, что ниже уровня моря — source-вода\n for (let y = h + 1; y <= water; y++)\n chunk.setRaw(x, y, z, B.water, packWater(8, true));\n }\n }\n\n this._ores(chunk, rng);\n this._trees(chunk, rng, heights, water);\n this._plants(chunk, rng, heights, water);\n chunk.generated = true;\n }\n\n _ores(chunk: Chunk, rng: () => number): void {\n // жилы: случайный «пьяный» шаг из точки, 4-8 блоков\n const vein = (id: number, count: number, maxY: number): void => {\n for (let v = 0; v < count; v++) {\n let x = (rng() * SX) | 0,\n y = (3 + rng() * (maxY - 3)) | 0,\n z = (rng() * SZ) | 0;\n const len = (4 + rng() * 5) | 0;\n for (let i = 0; i < len; i++) {\n if (\n x >= 0 &&\n x < SX &&\n y >= 2 &&\n y < SY &&\n z >= 0 &&\n z < SZ &&\n chunk.get(x, y, z) === B.stone\n )\n chunk.setRaw(x, y, z, id);\n x += ((rng() * 3) | 0) - 1;\n y += ((rng() * 3) | 0) - 1;\n z += ((rng() * 3) | 0) - 1;\n }\n }\n };\n vein(B.coal_ore, 7, 40);\n vein(B.iron_ore, 5, 28);\n }\n\n _trees(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n _water: number,\n ): void {\n const attempts = 3;\n for (let t = 0; t < attempts; t++) {\n // отступ 2 от края — крона не выходит за чанк\n const x = 2 + ((rng() * (SX - 4)) | 0),\n z = 2 + ((rng() * (SZ - 4)) | 0);\n const h = heights[x + z * SX];\n if (rng() > 0.6) continue;\n if (chunk.get(x, h, z) !== B.grass || h + 8 >= SY) continue;\n const trunk = 4 + ((rng() * 2) | 0);\n for (let y = 1; y <= trunk; y++) chunk.setRaw(x, h + y, z, B.oak_log);\n // крона: два слоя 5×5 + два слоя 3×3 сверху, углы прореживаются\n for (let dy = trunk - 1; dy <= trunk + 2; dy++) {\n const r = dy >= trunk + 1 ? 1 : 2;\n for (let dx = -r; dx <= r; dx++)\n for (let dz = -r; dz <= r; dz++) {\n if (dx === 0 && dz === 0 && dy <= trunk) continue;\n if (Math.abs(dx) === r && Math.abs(dz) === r && rng() < 0.5)\n continue;\n const y = h + dy + 1;\n if (y < SY && chunk.get(x + dx, y, z + dz) === B.air)\n chunk.setRaw(x + dx, y, z + dz, B.leaves);\n }\n }\n }\n }\n\n _plants(\n chunk: Chunk,\n rng: () => number,\n heights: Int16Array,\n water: number,\n ): void {\n for (let z = 0; z < SZ; z++)\n for (let x = 0; x < SX; x++) {\n const h = heights[x + z * SX];\n if (h <= water || h + 1 >= SY) continue;\n if (chunk.get(x, h, z) !== B.grass || chunk.get(x, h + 1, z) !== B.air)\n continue;\n const r = rng();\n if (r < 0.08) chunk.setRaw(x, h + 1, z, B.tallgrass);\n else if (r < 0.095)\n chunk.setRaw(\n x,\n h + 1,\n z,\n rng() < 0.5 ? B.flower_yellow : B.flower_red,\n );\n }\n }\n}\n"
|
|
161
|
+
}
|
|
162
|
+
]
|
|
163
|
+
}
|