grechka-game/srcv3/render/render3d.js

1117 lines
58 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==================== РЕНДЕР 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);
}