1117 lines
58 KiB
JavaScript
1117 lines
58 KiB
JavaScript
// ==================== РЕНДЕР 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);
|
||
}
|