// ==================== РЕНДЕР 3D — GrechkaCraft v3 ==================== // Объёмный мир: у каждого блока есть gx/gy/gz. Рендерим кубы по всем трём // осям, отсекаем те, что не видны с камеры (соседи со всех шести сторон). import { state } from '../core/state.js'; import { BLOCKS } from '../data/blocks.js'; import { isNight } from '../entities/mob-ai3d.js'; import { skyColor } from '../world/weather.js'; import { mode } from '../game/modes.js'; import { handleTap, stopMining } from '../input/mouse-handler3d.js'; import { getBlock } from '../world/world-storage3d.js'; import { occGrid, occIndex, OCC_OPAQUE, OCC_MIN, OCC_MAX, OCC_MAX_GY, OCC_STEP_X, OCC_STEP_Z, OCC_STEP_Y } from '../world/occ3d.js'; import { camDir } from '../game/loop3d.js'; import { HERO_COL, HERO_PARTS, HERO_PIVOT, HERO_SCALE } from './hero-rig.js'; import { MOB_SPRITES } from '../entities/mob-sprites3d.js'; import { MOB_TEX, TDmobTex } from '../entities/mob-tex3d.js'; let TD = null; function TDparseColor(c) { const m = /rgba?\(([^)]+)\)/.exec(c); if (!m) return { color: new THREE.Color(c), opacity: 1 }; const p = m[1].split(',').map(x => parseFloat(x)); return { color: new THREE.Color(p[0] / 255, p[1] / 255, p[2] / 255), opacity: p.length > 3 ? p[3] : 1 }; } function TDblockMat(t) { const def = BLOCKS[t] || {}; const { color, opacity } = TDparseColor(def.c || '#ff00ff'); const a = def.alpha !== undefined ? def.alpha : opacity; return new THREE.MeshLambertMaterial({ color, transparent: a < 1, opacity: a }); } // ---------- ОККЛЮЗИЯ: живёт в world/occ3d.js ---------- // isHiddenAt проверяла 6 соседей через getBlock, а тот строил строку "gx,gy,gz" // и искал в Map. При 68k кандидатах это ~409k строковых конкатенаций — // профиль показал 129 мс из 153 мс всего перестроения мешей. Теперь окклюзия // читает плотный Uint8Array (occ3d.js), обновляемый из setBlock/removeBlock. function TDisFluid(t) { const d = BLOCKS[t]; return !!(d && d.fluid); } function TDisFlat(t) { const d = BLOCKS[t]; if (!d) return false; return !d.solid || !!d.decor || !!d.climbable; } // ---------- герой: объёмная гречка ---------- // Пиксельный рисунок 2D-игры (hero-rig.js) выдавливается в глубину: каждая // часть — настоящий объём одинаковой глубины, а не плоская пластина. Поэтому // при вращении камеры герой остаётся объёмным, а не превращается в картонку. function TDbuildHeroRig() { const group = new THREE.Group(); const P = {}; // Toon-растушёвка как в эталонной игре: 4 ступени дают «мультяшный» вид, // к которому привык игрок, вместо плоского диффузного Lambert. const MAT = {}; const gradTex = (() => { const steps = 4; const g = new THREE.DataTexture(new Uint8Array(steps * 4), steps, 1, THREE.RGBAFormat); for (let i = 0; i < steps; i++) { const v = Math.round(255 * (0.72 + 0.28 * (i / (steps - 1)))); g.image.data.set([v, v, v, 255], i * 4); } g.minFilter = THREE.LinearMipmapLinearFilter; g.magFilter = THREE.LinearFilter; g.generateMipmaps = true; g.needsUpdate = true; return g; })(); for (const k of Object.keys(HERO_COL)) { MAT[k] = new THREE.MeshToonMaterial({ color: HERO_COL[k].hex, gradientMap: gradTex }); } // глубина тела в мировых единицах — ширина одного «пикселя» рисунка // Риг как в эталонной платформенной игре: каждый пиксель рисунка — призма, // глубина которой берётся ИЗ ЦВЕТА (COL[colorKey].d), а не из общей константы. // Общая константа давала «растянутую с двух сторон пластину»: у синего слоя // d=0.6, у тёмно-синих глаз d=1.12 — разная толщина и есть объём. // Плюс renderOrder = d*1000 — иначе Three.js сортирует слои случайно и // дальние прямоугольники рисуются поверх ближних. const dOf = (colorKey) => (HERO_COL[colorKey] && HERO_COL[colorKey].d) || 0.8; for (const name of Object.keys(HERO_PARTS)) { const g = new THREE.Group(); // узел вращения (сустав) const j = HERO_PIVOT[name]; g.position.set(j[0] * HERO_SCALE, j[1] * HERO_SCALE, 0); const inner = new THREE.Group(); // содержимое узла inner.position.set(-j[0] * HERO_SCALE, -j[1] * HERO_SCALE, 0); g.add(inner); group.add(g); P[name] = g; for (const r of HERO_PARTS[name]) { const [rx, ry, rw, rh, colorKey] = r; const d = dOf(colorKey) * HERO_SCALE; const w = rw * HERO_SCALE; const h = rh * HERO_SCALE; const geo = new THREE.BoxGeometry(w, h, d); // центрируем по X (в рисунке левый край -1.17.., центр около -0.24), // по Z смещаем к зрителю, чтобы слои не уходили вглубь земли geo.translate((rx - 0.24) * HERO_SCALE + w / 2, ry * HERO_SCALE + h / 2, d / 2); const m = new THREE.Mesh(geo, MAT[colorKey] || MAT.blue); m.renderOrder = Math.round(d * 1000); m.castShadow = true; m.receiveShadow = true; inner.add(m); } } // Нос — объёмный, выносим на переднюю грань тела const nose = new THREE.Mesh( new THREE.BoxGeometry(0.16, 0.12, 0.18), new THREE.MeshToonMaterial({ color: 0xf7c873, gradientMap: gradTex }) ); nose.position.set(0.05, 2.13 * HERO_SCALE, 0.30 * HERO_SCALE + 0.02); P.head.add(nose); return { group, P }; } // Простое склеивание геометрий (буферы BoxGeometry одинакового формата) function TDmergeGeometries(geos) { if (geos.length === 1) return geos[0]; let vCount = 0, iCount = 0; for (const g of geos) { vCount += g.attributes.position.count; iCount += g.index ? g.index.count : 0; } const pos = new Float32Array(vCount * 3); const nor = new Float32Array(vCount * 3); const uv = new Float32Array(vCount * 2); const idx = new Uint32Array(iCount); let vo = 0, io = 0; for (const g of geos) { const p = g.attributes.position, n = g.attributes.normal, u = g.attributes.uv; pos.set(p.array, vo * 3); if (n) nor.set(n.array, vo * 3); if (u) uv.set(u.array, vo * 2); if (g.index) { const gi = g.index.array; for (let k = 0; k < gi.length; k++) idx[io + k] = gi[k] + vo; io += gi.length; } vo += p.count; } const out = new THREE.BufferGeometry(); out.setAttribute('position', new THREE.BufferAttribute(pos, 3)); out.setAttribute('normal', new THREE.BufferAttribute(nor, 3)); out.setAttribute('uv', new THREE.BufferAttribute(uv, 2)); out.setIndex(new THREE.BufferAttribute(idx, 1)); out.computeBoundingSphere(); return out; } const TD_MOB_COLORS = { zombie: 0x2ecc71, pig: 0xffb6c1, chicken: 0xecf0f1, creeper: 0x4caf50, skeleton: 0xeceff1, scorpion: 0x5d4037, polar_bear: 0xeceff1, slime: 0x4caf50, eagle: 0x5d4037, }; function TDnameSprite(name) { const cv = document.createElement('canvas'); cv.width = 256; cv.height = 48; const g = cv.getContext('2d'); g.font = 'bold 26px system-ui'; g.textAlign = 'center'; g.fillStyle = 'rgba(0,0,0,0.45)'; g.fillRect(0, 0, 256, 48); g.fillStyle = '#fff'; g.fillText(String(name || '?').slice(0, 14), 128, 33); const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(cv), depthTest: false })); sp.scale.set(2.2, 0.41, 1); return sp; } // ==================== INIT ==================== function TDinit() { const canvas = state.canvas; // preserveDrawingBuffer ОБЯЗАТЕЛЕН: без него canvas.toDataURL() // возвращает ЧЁРНЫЙ кадр, и невозможно снять скриншот для проверки вида. // Стоит копейка производительности, но даёт рабочую диагностику. const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }); if (renderer.toneMapping !== undefined && THREE.ACESFilmicToneMapping !== undefined) { renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 0.92; // >1 даёт сплошной засвет } if (renderer.outputEncoding !== undefined && THREE.sRGBEncoding !== undefined) { renderer.outputEncoding = THREE.sRGBEncoding; } renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.BasicShadowMap; const scene = new THREE.Scene(); scene.background = new THREE.Color(0x8fc7f5); // Туман заканчивается на границе прорисовки (VIEW_R = 14 тайлов = 560). // Было 30..90 при TILE=40 — то есть видимость всего ~2 тайла, отсюда // жалоба «прорисовка десять блоков». Теперь туман мягкий и соответствует дальности. // Дальность: VIEW_R = 26 тайлов (1040 px). При 14 тайлах мир обрывался // заметно раньше тумана — отсюда «остров, висящий в пространстве». // Туман начинается за пределами прорисовки (VIEW_R=30 тайлов = 1200 px), // иначе мир «обрывается» раньше, чем заканчиваются блоки. // VIEW_R = 30 тайлов = 1200 px. Туман должен начинаться ЗНАЧИТЕЛЬНО дальше // этой границы, иначе весь видимый мир оказывается в сплошном цвете неба — // это и был «сплошной засвет». Держим запас: туман с 60% дальности. // VIEW_R = 46 тайлов = 1840 px. Туман ОТКРЫВАЕТСЯ ДО края прорисовки // (near 760) и полностью гаснет на 1560: рельеф растворяется в дымке, // вместо того чтобы обрываться в пустое небо. Раньше near=1000 был позже // края — отсюда «парящий остров». scene.fog = new THREE.Fog(0x8fc7f5, 760, 1560); const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 1700); // Свет: сумма была 2.6 при отсутствии tone mapping — отсюда ультра-яркие // пастельные тона. Ставим суммарно ~1.15 и включаем тонмаппинг. // Свет суммарно ~0.95. При 1.3 и без tone mapping земля выгорала в бежевый // («ультра-яркие тона»), терялся цвет блоков. const hemi = new THREE.HemisphereLight(0xcfe6ff, 0x5a8f3c, 0.34); const sun = new THREE.DirectionalLight(0xfff4e0, 0.61); sun.castShadow = true; sun.shadow.mapSize.set(512, 512); const sc = sun.shadow.camera; sc.left = -26; sc.right = 26; sc.top = 26; sc.bottom = -26; sc.near = 1; sc.far = 140; sun.shadow.normalBias = 0.05; const amb = new THREE.AmbientLight(0xffffff, 0.06); scene.add(hemi, sun, sun.target, amb); const hero = TDbuildHeroRig(); scene.add(hero.group); // Рабочая область: только блоки рядом с игроком (по всем осям) const crack = new THREE.Mesh( new THREE.BoxGeometry(1.004, 1.004, 1.004), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0 }) ); crack.visible = false; scene.add(crack); const ghost = new THREE.Mesh( new THREE.BoxGeometry(1.02, 1.02, 1.02), new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.9 }) ); ghost.visible = false; scene.add(ghost); // куб подсветки выбранного блока const outline = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.BoxGeometry(1.02, 1.02, 1.02)), new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85 }) ); outline.visible = false; scene.add(outline); // Прицел: блок, на который смотрит игрок. Обновляется каждый кадр рейкастом. // Камера стоит за игроком, поэтому целимся от камеры вперёд по направлению взгляда. const raycaster = new THREE.Raycaster(); const _rcDir = new THREE.Vector3(); const aim = { gx: 0, gy: 0, gz: 0, px: 0, py: 0, face: null, dist: 0, valid: false }; const cloudMeshes = []; // Облака: MeshBasic, а не Lambert. Lambert без нормального света давал серые // пятна в небе (визуальный артефакт). Basic всегда чисто-белый. const cloudMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85, depthWrite: false }); for (let i = 0; i < 16; i++) { const m = new THREE.Mesh(new THREE.BoxGeometry(4, 0.8, 2.2), cloudMat); scene.add(m); cloudMeshes.push(m); } // ---------- Прицел: блок перед игроком по направлению взгляда ---------- const AIM_MAX = 7; // тайлов, как в майнкрафте const projPool = []; for (let i = 0; i < 24; i++) { const m = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.1, 0.1), new THREE.MeshBasicMaterial({ color: 0xf1c40f })); m.visible = false; scene.add(m); projPool.push(m); } const PMAX = 800; const pGeo = new THREE.BufferGeometry(); const pPos = new Float32Array(PMAX * 3); const pCol = new Float32Array(PMAX * 3); pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3)); pGeo.setAttribute('color', new THREE.BufferAttribute(pCol, 3)); const points = new THREE.Points(pGeo, new THREE.PointsMaterial({ size: 0.16, vertexColors: true, depthWrite: false })); points.frustumCulled = false; scene.add(points); function makeFall(n, color, size) { const g = new THREE.BufferGeometry(); const pos = new Float32Array(n * 3); g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); const pts = new THREE.Points(g, new THREE.PointsMaterial({ color, size, transparent: true, opacity: 0.7, depthWrite: false })); pts.frustumCulled = false; pts.visible = false; scene.add(pts); return { pts, pos, n }; } const rain = makeFall(500, 0x74b9ff, 0.2); const snow = makeFall(300, 0xffffff, 0.17); const lightPool = []; for (let i = 0; i < 6; i++) { const L = new THREE.PointLight(0xff9a3c, 0, 16); scene.add(L); lightPool.push(L); } const flamePool = []; for (let i = 0; i < 8; i++) { const f = new THREE.Mesh(new THREE.ConeGeometry(0.3, 0.75, 6), new THREE.MeshBasicMaterial({ color: 0xff7a1a })); f.visible = false; scene.add(f); flamePool.push(f); } const boatMesh = new THREE.Mesh( new THREE.BoxGeometry(1.4, 0.5, 1.0), new THREE.MeshLambertMaterial({ color: 0x8b4513 }) ); boatMesh.visible = false; boatMesh.castShadow = true; scene.add(boatMesh); let TDtarget = null; // блок под прицелом — для кнопки копки TD = { get target() { return TDtarget; }, updateAim(px, py, pz) { // Направление луча = КУДА СМОТРИТ КАМЕРА, а не «на игрока». // Раньше здесь стояло (px - camera.position.x, ...), то есть луч всегда // упирался в самого героя, и прицел показывал блок ПОД НОГАМИ игрока // (face: {y:1}) — добыть и поставить было невозможно. // Камера уже развёрнута lookAt вдоль курса, поэтому берём его из неё. camera.getWorldDirection(_rcDir); // Дистанция камера->игрок — отдельная величина. getWorldDirection уже // возвращает ЕДИНИЧНЫЙ вектор, поэтому брать длину из _rcDir нельзя: // получалось camDist = 1, near = 1.15, и всё, что дальше, отбрасывалось // как «за игроком» — прицел всегда указывал в опору под ногами. const camDist = Math.sqrt( (px - camera.position.x) ** 2 + (py - camera.position.y) ** 2 + (pz - camera.position.z) ** 2 ); if (camDist < 1e-6) return aim; _rcDir.normalize(); // Рейкаст ОБЯЗАН быть направленным: без set() он стреляет из (0,0,0) // в -Z, то есть мимо мира — прицел и добыча не работали вообще. // Луч: из камеры через игрока. Блоки между камерой и игроком отбрасываем. raycaster.set(camera.position, _rcDir); raycaster.far = camDist + AIM_MAX; // Луч идёт ОТ КАМЕРЫ ЧЕРЕЗ ИГРОКА. Всё, что ближе игрока (distance < // camDist), — это блоки между камерой и игроком, их надо отбросить. // Граница должна быть СТРОГО больше дистанции до игрока, иначе блок // ОПОРЫ под ногами (его верхняя грань лежит ровно на луче) попадает // в выборку, и прицел всегда указывает в землю под игроком: // target = { gx: <колонка игрока>, face: {y: 1} }, добыть что-то нельзя. raycaster.near = camDist + 0.15; const meshes = TD.blockMeshes; TD.aimCalls = (TD.aimCalls || 0) + 1; let best = null, bestD = camDist + AIM_MAX, _rawHits = 0; for (const e of meshes) { const hits = raycaster.intersectObject(e.mesh, false); for (const h of hits) { _rawHits++; if (h.distance < camDist - 0.5) continue; // это блок ПЕРЕД игроком, не наш if (h.distance < bestD) { bestD = h.distance; best = { h, e }; } } } if (!best || best.h.instanceId === undefined) { outline.visible = false; aim.valid = false; TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2), why: best ? 'no-instance' : 'no-best' }; return aim; } // Точные координаты блока лежат в меше (userData.list). Матричная // конвертация была неверной: по X/Z давала +1 тайл, по Y — минус. // Поэтому прицел указывал в пустоту с мусором вроде (-15,-15,15). const _b = best.e.mesh.userData.list[best.h.instanceId]; if (!_b) { outline.visible = false; aim.valid = false; TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2), why: 'no-list' }; return aim; } const gx = _b.gx, gy = _b.gy, gz = (_b.gz || 0); const n = best.h.face ? best.h.face.normal : null; // нормаль в локальных осях куба = мировые (инстансы только переносятся) const face = n ? { x: Math.round(n.x), y: Math.round(n.y), z: Math.round(n.z) } : null; aim.gx = gx; aim.gy = gy; aim.gz = gz; aim.px = gx + 0.5; aim.py = gy + 0.5; aim.face = face; aim.dist = bestD - camDist; aim.valid = true; // дистанция от ИГРОКА, не от камеры TD.aimDbg = { hits: _rawHits, meshes: meshes.length, camDist: +camDist.toFixed(2) }; TDtarget = aim; // Y в three инвертирован: позиция инстанса (gx+0.5,-gy-0.5,gz+0.5). // Было (aim.px, aim.py=gy+0.5, ...) — рамка висела в зеркальной точке. outline.position.set(gx + 0.5, -gy - 0.5, gz + 0.5); outline.visible = true; }, renderer, scene, camera, hemi, sun, amb, hero, crack, ghost, outline, cloudMeshes, projPool, points, pPos, pCol, PMAX, rain, snow, lightPool, flamePool, boatMesh, blockMeshes: [], lastVersion: -1, meshT: 0, mobs: new Map(), players: new Map(), yaw: 0.0, pitch: 0.28, dist: 10, // 0.28 — вид сбоку; 0.55 смотрел в небо ptrs: new Map(), pinchD: 0, lastTap: null, hovered: null, fireCache: null, heroStep: 0, heroTime: 0, face: 1, frame: 0, lastT: 0, colCache: new Map(), }; // DIAG-зонд: повторяет луч прицела и возвращает сырые цифры. TD.probeAim = function() { const out = { meshes: TD.blockMeshes.length, near: +raycaster.near.toFixed(2), far: +raycaster.far.toFixed(2) }; try { const all = []; for (const e of TD.blockMeshes) { const h = raycaster.intersectObject(e.mesh, false); if (h.length) all.push({ type: e.type, n: h.length, d0: +h[0].distance.toFixed(2), iid: h[0].instanceId }); } out.hits = all.slice(0, 8); out.total = all.reduce((a, x) => a + x.n, 0); out.rayO = camera.position.toArray().map(v => +v.toFixed(2)); // контрольный замер без near/far: если хиты есть — режет фильтр дистанций const _n = raycaster.near, _f = raycaster.far; raycaster.near = 0; raycaster.far = 100; let wide = 0, firstD = -1; for (const e of TD.blockMeshes) { const h = raycaster.intersectObject(e.mesh, false); if (h.length && !wide) { wide = h.length; firstD = +h[0].distance.toFixed(2); } else if (h.length) wide += h.length; } raycaster.near = _n; raycaster.far = _f; out.wide = wide; out.firstD = firstD; } catch (err) { out.err = String(err).slice(0, 100); } return out; }; TD.aim = aim; TD.outline = outline; TD.pick = TDpick; TD.tap = handleTap; // TD публикуется ВСЕГДА: кнопка копки на мобильном читает отсюда // цель (window.__td.target). Раньше объект создавался только по // ?diag=1, и тач-кнопка «копка» молча не работала в обычной игре. window.__td = TD; if (/[?&]diag=1/.test(location.search)) { /* диаг-режим: хук уже есть */ } TDbindInput(canvas); TDresize(); window.addEventListener('resize', TDresize); } function TDresize() { if (!TD) return; const w = window.innerWidth, h = window.innerHeight; state.W = w; state.H = h; TD.renderer.setPixelRatio(1); TD.renderer.setSize(w, h, false); TD.camera.aspect = w / h; TD.camera.updateProjectionMatrix(); } // ==================== БЛОКИ ==================== // Полный пересбор мешей редкий; в обычном кадре — только обновление матриц. // Радиус прорисовки. Должен ЗНАЧИТЕЛЬНО превышать дистанцию камеры // (макс 34 тайла) — иначе за краем сетки видно пустое небо, и мир выглядит // парящим островом. 46 тайлов при тумане с 1000 до 1500: край рельефа // успевает уйти в туман, а не обрываться в пустоту. const VIEW_R = 46; // тайлов по всем осям // Границы плотной сетки: за ними occIndex отдаёт -1, и «сосед» через край // массива не должен заворачиваться на противоположную сторону мира. function isHiddenAt(gx, gy, gz) { // Окклюзия читает плотную сетку occ3d.js, а не getBlock: 6 чтений массива // против 6 строковых ключей "gx,gy,gz". Семантика та же — все 6 соседей // непрозрачные. Раскладка индекса: (gy*OCC_Z + (gz-OCC_MIN))*OCC_X + (gx-OCC_MIN), // то есть шаг по gx = 1, по gz = OCC_X, по gy = OCC_X*OCC_Z. if (gx <= OCC_MIN || gx >= OCC_MAX || gz <= OCC_MIN || gz >= OCC_MAX || gy <= 0 || gy >= OCC_MAX_GY) return false; const i = occIndex(gx, gy, gz); return occGrid[i - OCC_STEP_X] === OCC_OPAQUE && occGrid[i + OCC_STEP_X] === OCC_OPAQUE && occGrid[i - OCC_STEP_Z] === OCC_OPAQUE && occGrid[i + OCC_STEP_Z] === OCC_OPAQUE && occGrid[i - OCC_STEP_Y] === OCC_OPAQUE && occGrid[i + OCC_STEP_Y] === OCC_OPAQUE; } function isOpaqueAt(gx, gy, gz) { // Одинокий lookup по сетке окклюзии (а не getBlock со строковым ключом). // Используется точечно там, где нужен именно факт непрозрачности клетки. const i = occIndex(gx, gy, gz); return i >= 0 && occGrid[i] === OCC_OPAQUE; } function TDrebuildBlocks(px, py, pz) { for (const e of TD.blockMeshes) { TD.scene.remove(e.mesh); e.mesh.geometry.dispose(); e.mesh.material.dispose(); } TD.blockMeshes = []; const byType = new Map(); for (const b of state.blocks) { if (b.dead) continue; if (!BLOCKS[b.t]) continue; const dx = b.gx - px, dy = b.gy - py, dz = (b.gz || 0) - pz; // py — игровой gy if (dx * dx + dy * dy + dz * dz > VIEW_R * VIEW_R) continue; if (!TDisFluid(b.t) && !TDisFlat(b.t) && isHiddenAt(b.gx, b.gy, b.gz || 0)) continue; // Воду рисуем ТОЛЬКО верхним слоем. Раньше в колонке было до 30 кубов воды // подряд, и каждый был полупрозрачным — вместе они давали «мир в // полупрозрачных квадратах» и 29k лишних инстансов. if (TDisFluid(b.t)) { const up = getBlock(b.gx, b.gy - 1, b.gz || 0); if (up && TDisFluid(up.t)) continue; // не верхний слой — пропускаем } if (!byType.has(b.t)) byType.set(b.t, []); byType.get(b.t).push(b); } const boxGeo = new THREE.BoxGeometry(1, 1, 1); const planeGeo = new THREE.PlaneGeometry(0.92, 0.92); for (const [t, list] of byType) { const fluid = TDisFluid(t); const flat = !fluid && TDisFlat(t); const mesh = new THREE.InstancedMesh(flat ? planeGeo : boxGeo, TDblockMat(t), list.length); mesh.castShadow = !flat; mesh.receiveShadow = true; if (flat) mesh.material.side = THREE.DoubleSide; const m = new THREE.Matrix4(); const q = new THREE.Quaternion(); const e = new THREE.Euler(); for (let i = 0; i < list.length; i++) { const b = list[i]; if (flat) { // плоские блоки (цветы) — поворачиваем к камере, стоят на земле e.set(-Math.PI / 2, 0, 0); q.setFromEuler(e); m.compose(new THREE.Vector3(b.gx + 0.5, -b.gy + 0.05, (b.gz || 0) + 0.5), q, new THREE.Vector3(1, 1, 1)); } else if (fluid) { m.makeScale(1, 0.9, 1); m.setPosition(b.gx + 0.5, -b.gy - 0.5, (b.gz || 0) + 0.5); } else { m.identity(); m.setPosition(b.gx + 0.5, -b.gy - 0.5, (b.gz || 0) + 0.5); } mesh.setMatrixAt(i, m); } mesh.instanceMatrix.needsUpdate = true; mesh.userData.list = list; TD.scene.add(mesh); TD.blockMeshes.push({ mesh, type: t }); } } // ==================== PICK (рейкаст по 3D) ==================== const TDray = new THREE.Raycaster(); const TDndc = new THREE.Vector2(); function TDpick(sx, sy) { const r = state.canvas.getBoundingClientRect(); TDndc.x = ((sx - r.left) / r.width) * 2 - 1; TDndc.y = -((sy - r.top) / r.height) * 2 + 1; TDray.setFromCamera(TDndc, TD.camera); // Отбрасываем блоки МЕЖДУ камерой и игроком — ровно как делает updateAim. // Без этого pick возвращал ПЕРВОЕ пересечение луча (землю под ногами // героя или блок за его спиной), а прицел показывал блок ПОСЛЕ игрока. // Клик в итоге попадал на 1-9 тайлов в сторону от того, что видно. const _pl = state.player; const _px = (_pl.x + _pl.w / 2) / state.TILE; const _py = -(_pl.y + _pl.h / 2) / state.TILE; const _pz = ((_pl.z || 0) + (_pl.d || _pl.w) / 2) / state.TILE; const _camDist = Math.hypot(_px - TD.camera.position.x, _py - TD.camera.position.y, _pz - TD.camera.position.z); const hits = TDray.intersectObjects(TD.blockMeshes.map(x => x.mesh), false); let h = null; for (const cand of hits) { if (cand.distance < _camDist + 0.15) continue; // камера, герой, опора h = cand; break; } if (!h) return null; const b = h.object.userData.list[h.instanceId]; if (!b) return null; // ТОЧНЫЕ координаты блока берём из userData.list, а НЕ из h.point. // При попадании в грань куба h.point лежит РОВНО на границе тайла // (например z = -3.0), и floor() давал СОСЕДНИЙ блок: прицел показывал // один блок, а клик ломал другой (в 1 тайле в сторону). Из-за этого печь // «не открывалась»: клик уходил в соседний тайл, где furnace нет. const gx = b.gx; const gy = b.gy; const gz = b.gz != null ? b.gz : (b.gz || 0); // point нужен для точной точки попадания (установка по грани) const wx = (h.point.x) * state.TILE; const wy = (-h.point.y) * state.TILE; return { gx, gy, gz, wx, wy, face: h.face ? h.face.normal : null }; } // ==================== ВВОД ==================== function TDbindInput(canvas) { canvas.addEventListener('pointerdown', (e) => { TD.ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, moved: false, t: performance.now() }); if (TD.ptrs.size === 2) { const q = [...TD.ptrs.values()]; TD.pinchD = Math.hypot(q[0].x - q[1].x, q[0].y - q[1].y); } stopMining(); }); canvas.addEventListener('pointermove', (e) => { const q = TD.ptrs.get(e.pointerId); if (!q) return; const dx = e.clientX - q.x, dy = e.clientY - q.y; q.x = e.clientX; q.y = e.clientY; if (Math.hypot(e.clientX - q.sx, e.clientY - q.sy) > 8) q.moved = true; if (TD.ptrs.size === 2) { const v = [...TD.ptrs.values()]; const d = Math.hypot(v[0].x - v[1].x, v[0].y - v[1].y); if (TD.pinchD > 0 && d > 0) TD.dist = Math.min(34, Math.max(4, TD.dist * TD.pinchD / d)); TD.pinchD = d; return; } if (q.moved) { TD.yaw -= dx * 0.005; TD.pitch = Math.min(0.95, Math.max(0.06, TD.pitch + dy * 0.005)); // >0.9 — камера в небе, горизонта нет } }); const up = (e) => { TD.tapDbg = TD.tapDbg || { ups: 0, noq: 0, moved: 0, slow: 0, picks: 0, taps: 0 }; TD.tapDbg.ups++; const q = TD.ptrs.get(e.pointerId); TD.ptrs.delete(e.pointerId); TD.pinchD = 0; if (!q) { TD.tapDbg.noq++; return; } if (q.moved) { TD.tapDbg.moved++; return; } // Ограничение в 600 мс БЫЛО БАГОМ: игрок нажимает и держит (например, // целится), а потом отпускает — и действие отменялось совсем, добыча и // установка становились невозможны. Долгое удержание нужно не отменять, // а считать осознанным: если указатель не двигался, действуем по отпусканию. // Отменяем только если указатель двигался (это поворот камеры) — // проверка q.moved уже стоит выше. TD.tapDbg.picks++; const hit = TDpick(e.clientX, e.clientY); if (hit) { TD.tapDbg.taps++; TD.lastTap = hit; // Передаём ТАЙЛОВЫЕ координаты блока: handleTap больше не делит // wx/wy на TILE, иначе цель смещалась на 1 тайл. handleTap(hit.gx, hit.gy, hit.gz, hit.face); } }; canvas.addEventListener('pointerup', up); canvas.addEventListener('pointercancel', (e) => { TD.ptrs.delete(e.pointerId); TD.pinchD = 0; }); canvas.addEventListener('contextmenu', (e) => e.preventDefault()); canvas.addEventListener('wheel', (e) => { e.preventDefault(); e.stopPropagation(); TD.dist = Math.min(34, Math.max(4, TD.dist * (1 + e.deltaY * 0.001))); }, { passive: false, capture: true }); } // ==================== СУЩНОСТИ ==================== function TDmobMesh(m) { let e = TD.mobs.get(m); if (!e) { const g = new THREE.Group(); // Точные копии мобов 2D-игры (MOB_SPRITES: те же rects и цвета, что в game.js), // выдавлены в глубину. Координаты нормализованы (0..1) — реальный размер // задаёт e.body.scale ниже, как раньше делал бокс 1x1x1. const bodyG = new THREE.Group(); g.add(bodyG); const spec = MOB_SPRITES[m.kind]; if (MOB_TEX[m.kind]) { // NN-текстуры (одобрены игроком): фас +z, спина -z, профиль ±x, // верх/низ — плоский средний цвет. alphaTest: силуэт по альфе спрайта. const mat = (view, mirror) => new THREE.MeshLambertMaterial({ map: TDmobTex(m.kind, view, mirror), alphaTest: 0.5, }); const top = new THREE.MeshLambertMaterial({ color: MOB_TEX[m.kind].top }); const bm = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), [mat('side'), mat('side', true), top, top, mat('front'), mat('back')] ); bm.castShadow = true; bodyG.add(bm); } else if (spec) { const zBody = 0.6; spec.rects.forEach(([rx, ry, rw, rh, c], i) => { const deep = i === 0 ? zBody : zBody + 0.15; const geo = new THREE.BoxGeometry(rw / spec.w, rh / spec.h, deep); geo.translate((rx + rw / 2) / spec.w - 0.5, (ry + rh / 2) / spec.h - 0.5, deep / 2); const mesh = new THREE.Mesh(geo, new THREE.MeshLambertMaterial({ color: c })); mesh.renderOrder = 100 + i; mesh.castShadow = true; bodyG.add(mesh); }); } else { const bm = new THREE.Mesh( new THREE.BoxGeometry(1, 1, 1), new THREE.MeshLambertMaterial({ color: TD_MOB_COLORS[m.kind] || 0x2ecc71 }) ); bm.castShadow = true; bodyG.add(bm); const eyeM = new THREE.MeshBasicMaterial({ color: 0x111111 }); const e1 = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.05), eyeM); e1.position.set(-0.16, 0.22, 0.26); const e2 = e1.clone(); e2.position.x = 0.16; bodyG.add(e1, e2); } TD.scene.add(g); e = { g, body: bodyG }; TD.mobs.set(m, e); } return e; } function TDplayerEntry(id, p) { let e = TD.players.get(id); if (!e) { const rig = TDbuildHeroRig(); const label = TDnameSprite(p.name); label.position.y = 2.2; rig.group.add(label); TD.scene.add(rig.group); e = { rig, label }; TD.players.set(id, e); } return e; } // ==================== КАДР ==================== export function renderFrame3D(now) { if (!TD) TDinit(); const p = state.player; const TILE = state.TILE; TD.frame++; const dt = Math.min(0.05, Math.max(0.001, (now - (TD.lastT || now - 16)) / 1000)); TD.lastT = now; const ptx = (p.x + (p.w || 34) / 2) / TILE; const pgy = (p.y + (p.h || 34) / 2) / TILE; // игровый Y (вниз) const py3 = -pgy; // three Y (вверх) const ptz = ((p.z || 0) + (p.d || 34) / 2) / TILE; // пересборка мешей: при изменении мира или раз в ~2 сек (для плавной подгрузки) // пересборка не чаще раза в 700 мс и только при изменении мира const ver = state.blocksVersion || 0; if ((ver !== TD.lastVersion && now - TD.meshT > 700) || !TD.blockMeshes.length) { TD.meshT = now; TD.lastVersion = ver; TDrebuildBlocks(ptx, pgy, ptz); } // небо/свет. Первые секунды после старта держим полдень, чтобы не было «рассвета» const night = isNight(); const sky = skyColor(night, 'plains', state.weatherType, state.worldTime); TD.scene.background.set(sky); TD.scene.fog.color.copy(TD.scene.background); TD.sun.intensity = night ? 0.14 : 1.5; TD.hemi.intensity = night ? 0.3 : 0.85; TD.amb.intensity = night ? 0.34 : 0.25; if (state.lightningFlash > 0) TD.hemi.intensity += Math.min(2.2, state.lightningFlash * 2.4); TD.sun.position.set(ptx + 20, py3 + 36, ptz + 20); TD.sun.target.position.set(ptx, py3, ptz); TD.sun.target.updateMatrixWorld(); // камера-орбита; yaw — тот же угол, что и для WASD camDir.yaw = TD.yaw; const cp = Math.cos(TD.pitch), sp2 = Math.sin(TD.pitch); // Камера на уровне центра игрока. Поднимать базовую высоту нельзя: // при 2.4/2.6 тайла мир ПРОПАДАЛ совсем (проверено) — вероятно, блоки // отсекаются по вертикали относительно игрока, а не относительно камеры. // Камера от игрока (вид сбоку). Требования: // 1) не садиться ВНУТРЬ блока — иначе экран забит текстурой; // 2) зум должен ОТЪЕЗЖАТЬ, а не упираться: игрок в яме, земля сзади // уже на 1.6 тайла, поэтому просто укорачивать бесполезно — // при блокировке поднимаем камеру выше и смотрим сверху; // 3) минимальная дистанция 3.0 — ближе герой занимает треть экрана. { // точка занята твёрдым блоком с учётом зарадиуса R (камера «толстая») // ---------- КАМЕРА: единый якорь и честная проверка ---------- // Игровая Y направлена ВНИЗ (гравитация тянет в +y), three Y — вверх. // Всё ниже работает в three-координатах: eyeY = -(gameY) + запас. const CAM_R = 0.22; // запас от грани куба const EYE = 0.62; // высота глаз над пятками (в тайлах) const solidTile = (gx, gy, gz) => { const b = getBlock(gx, gy, gz); if (!b) return false; const d = BLOCKS[b.t]; return !!(d && d.solid && !d.fluid && !d.decor && !d.climbable); }; // ЖЁСТКАЯ ГАРАНТИЯ: только свой тайл. Если он твёрдый — камера внутри. // Именно эта проверка обязана быть непротиворечивой, всё остальное — // только для плавности. const insideSolid = (x, y, z) => solidTile(Math.floor(x), Math.floor(-y), Math.floor(z)); // Мягкая проверка: свой тайл + близость к грани соседнего куба. const solidNear = (x, y, z) => { const gx = Math.floor(x), gy = Math.floor(-y), gz = Math.floor(z); if (solidTile(gx, gy, gz)) return true; if (solidTile(gx + 1, gy, gz) && x - (gx + 1) < 0.5 + CAM_R) return true; if (solidTile(gx - 1, gy, gz) && (gx + 1) - x < 0.5 + CAM_R) return true; if (solidTile(gx, gy + 1, gz) && (-y) - (gy + 1) < 0.5 + CAM_R) return true; if (solidTile(gx, gy - 1, gz) && (gy + 1) - (-y) < 0.5 + CAM_R) return true; if (solidTile(gx, gy, gz + 1) && z - (gz + 1) < 0.5 + CAM_R) return true; if (solidTile(gx, gy, gz - 1) && (gz + 1) - z < 0.5 + CAM_R) return true; return false; }; // Якорь — точка на уровне глаз героя. Физика не даёт игроку быть в блоке, // поэтому якорь гарантированно свободен и является последним откатом. const anchorX = ptx, anchorY = py3 + EYE, anchorZ = ptz; // Высота камеры над якорем. Раньше жёстко 1.2 + d*0.16: в яме глубиной // 4 тайла этого НЕ хватало, solveDist возвращал 0, включался первый план // (camDist=0) и кадр показывал пустое небо — игрок не видел вообще ничего. // Наклон растёт с дистанцией, и на больших d камера должна явно подниматься. const heightFor = (d) => 1.2 + d * 0.22; // Смещение камеры относительно якоря при дистанции d и наклоне pitch. const offsetAt = (d, pitch) => { const maxSin = Math.min(0.55, heightFor(d) / Math.max(d, 0.001)); const sn = Math.min(Math.sin(pitch), maxSin); const c = Math.sqrt(Math.max(0.05, 1 - sn * sn)); return { dx: Math.sin(TD.yaw) * c * d, dy: sn * d, dz: Math.cos(TD.yaw) * c * d }; }; // Ищем максимальную безопасную дистанцию: идём от желаемой к якорю и // берём последнюю свободную точку. Никакого «угадывания» на глаз. const solveDist = (d, pitch) => { const o = offsetAt(d, pitch); if (!solidNear(anchorX + o.dx, anchorY + o.dy, anchorZ + o.dz)) return d; const steps = Math.ceil(d / 0.2); for (let i = steps - 1; i >= 1; i--) { const t = d * (i / steps); const p2 = offsetAt(t, pitch); const x = anchorX + p2.dx, y = anchorY + p2.dy, z = anchorZ + p2.dz; if (!solidNear(x, y, z)) { // Жёсткая проверка по СВОЕМУ тайлу обязана выполняться. if (!insideSolid(x, y, z)) return t; } } return 0; }; const PITCH_MAX_AUTO = 0.62; // выше — вид сверху, горизонта нет let pitch = Math.min(TD.pitch, PITCH_MAX_AUTO); let dist = solveDist(TD.dist, pitch); if (dist <= 0.2) { // За спиной глухо на этой высоте — пробуем поднять камеру. for (let cand = pitch + 0.12; cand <= PITCH_MAX_AUTO + 0.001; cand += 0.12) { const d2 = solveDist(TD.dist, cand); if (d2 > 0.2) { pitch = cand; dist = d2; break; } } } let firstPerson = false; if (dist <= 0.2) { // Свободного места за спиной нет вообще: вид от первого лица. firstPerson = true; // Ищем свободный тайл для глаза: перебираем вверх от якоря. // Ищем свободное место для глаза рядом с головой, а НЕ где угодно // выше. Диапазон раньше был 0..10 тайлов: если игрок в углублении, // камера улетала на 8 тайлов вверх, где уже нет блоков, и прицел // смотрел в пустоту (target: null, pick: gy = -1). Игрок при этом // стоял на gy = 7, то есть в 8 тайлах ниже камеры. let ex = anchorX, ey = anchorY, ez = anchorZ, found = false; const hd = Math.sin(TD.yaw) * 0.18, hz = Math.cos(TD.yaw) * 0.18; for (let up = 0; up <= 4.0 && !found; up += 0.35) { const cx = anchorX + hd, cy = anchorY + up, cz = anchorZ + hz; if (!solidNear(cx, cy, cz)) { ex = cx; ey = cy; ez = cz; found = true; } } if (!found) { ex = anchorX; ey = anchorY; ez = anchorZ; } TD.camera.position.set(ex, ey, ez); TD.camDist = 0; TD.camPitch = +pitch.toFixed(2); TD.camHeight = 0; // Смотрим вдоль курса с небольшим наклоном вниз, как в обычном // third-person: горизонтальный lookAt на 8 тайлов уводил взгляд // в небо, когда камера оказывалась высоко над игроком. // Направление взгляда = МИНУС курса yaw. Камера в первом лице стоит // на якоре плюс смещение hd/hz = (sin(yaw), cos(yaw)) * 0.18, то есть // СЗАДИ игрока по курсу. Смотреть в (sin(yaw), cos(yaw)) — значит // смотреть НАЗАД, в упор в тот блок, ради которого включился // первый план: наблюдалось взгляд [0,0,1] при camDist = 0. TD.camera.lookAt( ex - Math.sin(TD.yaw) * 8, ey - Math.sin(pitch) * 8, ez - Math.cos(TD.yaw) * 8 ); TD.camFirstPerson = true; TD.updateAim(ptx, py3, ptz); if (TD.hero && TD.hero.group) { // Риг ставим на землю иначе после return основной путь не дойдёт. const PWH0 = p.h || 34; TD.hero.group.scale.setScalar((PWH0 / TILE) / (3.2 * HERO_SCALE)); TD.hero.group.position.set(ptx, -Math.round(((p.y || 0) + PWH0) / TILE), ptz); if (p.facing !== undefined) TD.hero.group.rotation.y = -p.facing; TD.hero.group.visible = false; } return; } TD.camFirstPerson = false; if (TD.hero && TD.hero.group) TD.hero.group.visible = true; const ofs = offsetAt(dist, pitch); TD.camHeight = +ofs.dy.toFixed(2); TD.camPitch = +pitch.toFixed(2); TD.camDist = +dist.toFixed(2); TD.camera.position.set(anchorX + ofs.dx, anchorY + ofs.dy, anchorZ + ofs.dz); // Позиция уже задана выше. Ничего не перезаписываем: старый код ставил // камеру второй раз, по другой формуле, и возвращал её в грунт. } // Взгляд идёт ОТ камеры ЧЕРЕЗ игрока и дальше — так прицел попадает в // точку перед героем, а не ему в спину. Раньше lookAt(ptx, lookY, ptz) // разворачивал камеру НА игрока, и updateAim всегда целился в него самого: // target был { gx: <колонка игрока>, face: { y: 1 } }, то есть опора под // ногами. Добыть или поставить что-либо было невозможно. // Направление взгляда: камера стоит ЗА игроком, поэтому «вперёд» = от камеры // мимо игрока, то есть player - camera. Если развернуть камеру НА игрока // (lookAt(ptx,...)), луч уходит назад и попадает в опору под ногами: // наблюдалось target gz: -4 при игроке gz: 0 и face {y: 1}. const _fwd = new THREE.Vector3(ptx - TD.camera.position.x, py3 + 0.5 - TD.camera.position.y, ptz - TD.camera.position.z); if (_fwd.lengthSq() < 1e-6) _fwd.set(Math.sin(TD.yaw), 0, Math.cos(TD.yaw)); _fwd.normalize(); TD.camera.lookAt( TD.camera.position.x + _fwd.x, TD.camera.position.y + _fwd.y, TD.camera.position.z + _fwd.z ); // Прицел: луч из камеры вдоль взгляда. Обновляем после позиционирования. TD.updateAim(ptx, py3, ptz); // Герой стоит НОГАМИ на поверхности: p.y в пикселях, низ коллайдера = p.y + p.h. // Раньше ставился p.y — верх коллайдера, из-за чего персонаж тонул в блоках. // Ноги = p.y + p.h. Физика держит игрока ГЛУБОКО в блоке-опоре (feet уходит // на h пикселей внутрь), поэтому ставим ноги ровно на верхнюю грань // блока под ними: -floor(feet/TILE). Иначе персонаж тонет в текстуре. // Физика и рисунок ДОЛЖНЫ совпадать. Коллайдер игрока h=34 при TILE=40 — // это 0.85 тайла, а риг из рисунка при HERO_SCALE=0.5 даёт 1.6 тайла. // Из-за этого персонаж выглядел втрое выше, чем есть, и «висел» над блоком. // Масштабируем риг под коллайдер и ставим ноги ровно на верхнюю грань опоры. const PWH = p.h || 34; const rigH = 3.2 * HERO_SCALE; // высота рига в тайлах const heroScale = (PWH / TILE) / rigH; // подгонка под коллайдер TD.hero.group.scale.setScalar(heroScale); // feet = p.y + h в пикселях; в тайлах это и есть тайл опоры, на верхней // грани которого стоят ноги. Y направлена вниз, поэтому знак минус. const feetGY = Math.round(((p.y || 0) + PWH) / TILE); const heroFeetY = -feetGY; TD.hero.group.position.set(ptx, heroFeetY, ptz); if (p.facing !== undefined) { const wantYaw = -p.facing; TD.hero.group.rotation.y += (wantYaw - TD.hero.group.rotation.y) * Math.min(1, 9 * dt); } // Анимация — как в эталонной игре: ноги качаются, хвост всегда живой, // корпус сжимается в прыжке и чуть дышит в покое, голова наклоняется // по скорости. Без этого риг выглядит как неподвижная картинка. TD.heroTime += dt; const moving = Math.hypot(p.vx || 0, p.vz || 0) > 20 && p.grounded; if (moving) TD.heroStep += dt * 11; const HP = TD.hero.P; HP.legs.rotation.z = moving ? Math.sin(TD.heroStep) * 0.34 : 0; HP.legs.rotation.x = moving ? Math.cos(TD.heroStep) * 0.22 : 0; // хвост: покачивание всегда, сильнее при беге const tailSwing = Math.sin(TD.heroTime * 6 + (moving ? TD.heroStep : 0)) * (moving ? 0.3 : 0.14); HP.tail.rotation.z += (tailSwing - HP.tail.rotation.z) * Math.min(1, 8 * dt); // голова чуть наклоняется по горизонтальной скорости const lean = (p.vx || 0) * 0.0006; HP.head.rotation.z += (-lean - HP.head.rotation.z) * Math.min(1, 6 * dt); // корпус: в прыжке вытянут вверх, на земле «дышит» if (!p.grounded) { const stretch = (p.vy || 0) < 0 ? 1.08 : 0.94; // вверх — вытянут, вниз — сжат HP.body.scale.y += (stretch - HP.body.scale.y) * Math.min(1, 8 * dt); } else { const breathe = 1 + 0.015 * Math.sin(TD.heroTime * 2.2); HP.body.scale.y += (breathe - HP.body.scale.y) * Math.min(1, 6 * dt); } // мобы const allMobs = state.isMultiplayer ? Array.from(state.serverMobs.values()) : state.mobs; const seen = new Set(); for (const m of allMobs) { if (m.dead) continue; seen.add(m); const e = TDmobMesh(m); e.body.scale.set((m.w || 30) / TILE, (m.h || 30) / TILE, (m.d || m.w || 30) / TILE); e.g.position.set( (m.x + (m.w || 30) / 2) / TILE, -(m.y + (m.h || 30) / 2) / TILE, ((m.z || 0) + (m.d || m.w || 30) / 2) / TILE ); } for (const [m, e] of TD.mobs) { if (!seen.has(m)) { TD.scene.remove(e.g); TD.mobs.delete(m); } } // игроки сети const seenP = new Set(); if (state.otherPlayers) { for (const [id, pl] of state.otherPlayers) { seenP.add(id); const e = TDplayerEntry(id, pl); e.rig.group.position.set((pl.x + 17) / TILE, -pl.y / TILE, ((pl.z || 0) + 17) / TILE); } } for (const [id, e] of TD.players) { if (!seenP.has(id)) { TD.scene.remove(e.rig.group); TD.players.delete(id); } } // лодка if (state.boat && state.boat.active) { TD.boatMesh.visible = true; TD.boatMesh.position.set(state.boat.x / TILE, -state.boat.y / TILE, (state.boat.z || 0) / TILE); } else TD.boatMesh.visible = false; // снаряды const projs = state.projectiles || []; for (let i = 0; i < TD.projPool.length; i++) { const mesh = TD.projPool[i]; const pr = projs[i]; if (!pr) { mesh.visible = false; continue; } mesh.visible = true; mesh.material.color.set(pr.owner === 'mob' ? 0xc0392b : 0xf1c40f); mesh.position.set(pr.x / TILE, -pr.y / TILE, (pr.z || 0) / TILE); mesh.rotation.z = -Math.atan2(pr.vy, pr.vx); } // частицы const parts = state.parts || []; const n = Math.min(parts.length, TD.PMAX); for (let i = 0; i < n; i++) { const pt = parts[i]; TD.pPos[i * 3] = pt.x / TILE; TD.pPos[i * 3 + 1] = -pt.y / TILE; TD.pPos[i * 3 + 2] = (pt.z || 0) / TILE; let cc = TD.colCache.get(pt.c); if (!cc) { cc = new THREE.Color(pt.c || '#ffffff'); TD.colCache.set(pt.c, cc); } TD.pCol[i * 3] = cc.r; TD.pCol[i * 3 + 1] = cc.g; TD.pCol[i * 3 + 2] = cc.b; } TD.points.geometry.setDrawRange(0, n); TD.points.geometry.attributes.position.needsUpdate = true; TD.points.geometry.attributes.color.needsUpdate = true; // облака const clouds = state.clouds || []; for (let i = 0; i < TD.cloudMeshes.length; i++) { const cm = TD.cloudMeshes[i]; const c = clouds.length ? clouds[i % clouds.length] : null; if (!c) { cm.visible = false; continue; } cm.visible = true; cm.scale.set((c.w || 120) / TILE, 1, 1); // Выше и дальше игрока: раньше облака висели в 12 тайлах по Z прямо перед // камерой и перекрывали обзор серой пеленой. cm.position.set(c.x / TILE, -c.y / TILE, ptz - 30); } // огни if (TD.frame % 20 === 0 || !TD.fireCache) { const found = []; for (const b of state.blocks) { if (b.dead) continue; const def = BLOCKS[b.t]; if (!def) continue; const lit = def.lightRadius || b.t === 'campfire' || (b.t === 'furnace' && state.activeFurnaces && state.activeFurnaces.has(b.gx + ',' + b.gy + ',' + (b.gz || 0))); if (!lit) continue; const dx = b.gx - ptx, dy = -b.gy + pty, dz = (b.gz || 0) - ptz; found.push({ t: b.t, gx: b.gx, gy: b.gy, gz: b.gz || 0, d: dx * dx + dy * dy + dz * dz }); } found.sort((a, b) => a.d - b.d); TD.fireCache = found.slice(0, 8); } for (const L of TD.lightPool) L.intensity = 0; for (const f of TD.flamePool) f.visible = false; let li = 0, fi = 0; for (const src of (TD.fireCache || [])) { if (li < TD.lightPool.length) { const L = TD.lightPool[li++]; L.position.set(src.gx + 0.5, -src.gy + 0.5, src.gz + 0.5); L.intensity = 1.6 + Math.sin(now / 130 + src.gx) * 0.35; } if ((src.t === 'campfire' || src.t === 'furnace') && fi < TD.flamePool.length) { const f = TD.flamePool[fi++]; f.visible = true; f.position.set(src.gx + 0.5, -src.gy + 0.78 + Math.sin(now / 90 + src.gy) * 0.06, src.gz + 0.5); const sc2 = 0.9 + Math.sin(now / 70 + src.gx * 2) * 0.18; f.scale.set(sc2, sc2 * (1 + Math.sin(now / 55) * 0.12), sc2); } } // погода if (state.isRaining) { TD.rain.pts.visible = true; for (let i = 0; i < TD.rain.n; i++) { let y = TD.rain.pos[i * 3 + 1] - dt * 26; let x = TD.rain.pos[i * 3]; if (y < py3 - 12) { y = py3 + 12; x = ptx + (Math.random() - 0.5) * 40; } TD.rain.pos[i * 3] = x; TD.rain.pos[i * 3 + 1] = y; TD.rain.pos[i * 3 + 2] = ptz + 6; } TD.rain.pts.geometry.attributes.position.needsUpdate = true; } else TD.rain.pts.visible = false; if (state.weatherType === 'snow') { TD.snow.pts.visible = true; for (let i = 0; i < TD.snow.n; i++) { let y = TD.snow.pos[i * 3 + 1] - dt * 2; let x = TD.snow.pos[i * 3] + Math.sin(now / 900 + i) * dt * 1.5; if (y < py3 - 12) { y = py3 + 12; x = ptx + (Math.random() - 0.5) * 40; } TD.snow.pos[i * 3] = x; TD.snow.pos[i * 3 + 1] = y; TD.snow.pos[i * 3 + 2] = ptz + 6; } TD.snow.pts.geometry.attributes.position.needsUpdate = true; } else TD.snow.pts.visible = false; // подсветка блока под курсором / прогресс добычи const mg = state.mining; if (mg && mg.gx !== null && mg.hardness > 0) { TD.crack.visible = true; TD.crack.position.set(mg.gx + 0.5, -mg.gy - 0.5, (mg.gz || 0) + 0.5); TD.crack.material.opacity = Math.min(0.55, (mg.progress / mg.hardness) * 0.55); } else TD.crack.visible = false; if (mode() === 'build' && TD.lastTap) { TD.ghost.visible = true; TD.ghost.position.set(TD.lastTap.gx + 0.5, -TD.lastTap.gy - 0.5, (TD.lastTap.gz || 0) + 0.5); } else TD.ghost.visible = false; if (state.W !== window.innerWidth || state.H !== window.innerHeight) TDresize(); TD.camera.aspect = window.innerWidth / window.innerHeight; TD.camera.updateProjectionMatrix(); TD.renderer.render(TD.scene, TD.camera); }