626 lines
23 KiB
JavaScript
626 lines
23 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 { camDir } from '../game/loop3d.js';
|
||
import { HERO_COL, HERO_PARTS, HERO_PIVOT, HERO_SCALE } from './hero-rig.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 });
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// ---------- герой: тот же пиксельный рисунок, кубики ----------
|
||
function TDbuildHeroRig() {
|
||
const group = new THREE.Group();
|
||
const P = {};
|
||
const MAT = {};
|
||
for (const k of Object.keys(HERO_COL)) MAT[k] = new THREE.MeshLambertMaterial({ color: HERO_COL[k].hex });
|
||
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 depth = HERO_COL[r[4]].d * HERO_SCALE;
|
||
const m = new THREE.Mesh(
|
||
new THREE.BoxGeometry(r[2] * HERO_SCALE, r[3] * HERO_SCALE, depth),
|
||
MAT[r[4]]
|
||
);
|
||
m.position.set((r[0] - 0.24) * HERO_SCALE, r[1] * HERO_SCALE, depth / 2);
|
||
m.castShadow = true;
|
||
m.receiveShadow = true;
|
||
inner.add(m);
|
||
}
|
||
}
|
||
return { group, P };
|
||
}
|
||
|
||
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;
|
||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||
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);
|
||
scene.fog = new THREE.Fog(0x8fc7f5, 30, 90);
|
||
const camera = new THREE.PerspectiveCamera(60, 1, 0.1, 300);
|
||
|
||
const hemi = new THREE.HemisphereLight(0xcfe6ff, 0x6ab04c, 0.85);
|
||
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
|
||
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.25);
|
||
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.03, 1.03, 1.03)),
|
||
new THREE.LineBasicMaterial({ color: 0xffffff })
|
||
);
|
||
outline.visible = false;
|
||
scene.add(outline);
|
||
|
||
const cloudMeshes = [];
|
||
const cloudMat = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.75 });
|
||
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 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);
|
||
|
||
TD = {
|
||
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.42, dist: 10,
|
||
ptrs: new Map(), pinchD: 0,
|
||
lastTap: null, hovered: null, fireCache: null,
|
||
heroStep: 0, face: 1, frame: 0, lastT: 0,
|
||
colCache: new Map(),
|
||
};
|
||
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();
|
||
}
|
||
|
||
// ==================== БЛОКИ ====================
|
||
// Полный пересбор мешей редкий; в обычном кадре — только обновление матриц.
|
||
const VIEW_R = 14; // радиус прорисовки в тайлах по всем осям
|
||
|
||
function isHiddenAt(gx, gy, gz) {
|
||
// спрятан ли блок: все 6 соседей непрозрачные?
|
||
return isOpaqueAt(gx + 1, gy, gz) && isOpaqueAt(gx - 1, gy, gz) &&
|
||
isOpaqueAt(gx, gy + 1, gz) && isOpaqueAt(gx, gy - 1, gz) &&
|
||
isOpaqueAt(gx, gy, gz + 1) && isOpaqueAt(gx, gy, gz - 1);
|
||
}
|
||
|
||
function isOpaqueAt(gx, gy, gz) {
|
||
const b = getBlock(gx, gy, gz);
|
||
if (!b) return false;
|
||
const def = BLOCKS[b.t];
|
||
if (!def) return false;
|
||
return !!def.solid && !def.fluid && !def.decor && !def.climbable && def.alpha === undefined;
|
||
}
|
||
|
||
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;
|
||
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.82, 1);
|
||
m.setPosition(b.gx + 0.5, -b.gy - 0.09, (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);
|
||
const hits = TDray.intersectObjects(TD.blockMeshes.map(x => x.mesh), false);
|
||
if (!hits.length) return null;
|
||
const h = hits[0];
|
||
const b = h.object.userData.list[h.instanceId];
|
||
if (!b) return null;
|
||
// мировые игровые координаты точки удара
|
||
const gx = Math.floor(h.point.x);
|
||
const gy = -Math.floor(h.point.y);
|
||
const gz = Math.floor(h.point.z);
|
||
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(1.45, Math.max(0.06, TD.pitch + dy * 0.005));
|
||
}
|
||
});
|
||
|
||
const up = (e) => {
|
||
const q = TD.ptrs.get(e.pointerId);
|
||
TD.ptrs.delete(e.pointerId);
|
||
TD.pinchD = 0;
|
||
if (q && !q.moved && performance.now() - q.t < 600) {
|
||
const hit = TDpick(e.clientX, e.clientY);
|
||
if (hit) {
|
||
TD.lastTap = hit;
|
||
handleTap(hit.wx, hit.wy, 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();
|
||
const body = new THREE.Mesh(
|
||
new THREE.BoxGeometry(1, 1, 1),
|
||
new THREE.MeshLambertMaterial({ color: TD_MOB_COLORS[m.kind] || 0x2ecc71 })
|
||
);
|
||
body.castShadow = true;
|
||
g.add(body);
|
||
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;
|
||
g.add(e1, e2);
|
||
TD.scene.add(g);
|
||
e = { g, body };
|
||
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);
|
||
TD.camera.position.set(
|
||
ptx + TD.dist * Math.sin(TD.yaw) * cp,
|
||
py3 + TD.dist * sp2,
|
||
ptz + TD.dist * Math.cos(TD.yaw) * cp
|
||
);
|
||
TD.camera.lookAt(ptx, py3, ptz);
|
||
|
||
// герой: позиция в ногах коллайдера, поворот по направлению взгляда
|
||
TD.hero.group.position.set(ptx, -p.y / TILE, 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);
|
||
}
|
||
const moving = Math.hypot(p.vx || 0, p.vz || 0) > 20 && p.grounded;
|
||
if (moving) TD.heroStep += dt * 11;
|
||
TD.hero.P.legs.rotation.z = moving ? Math.sin(TD.heroStep) * 0.34 : 0;
|
||
TD.hero.P.legs.rotation.x = moving ? Math.cos(TD.heroStep) * 0.22 : 0;
|
||
|
||
// мобы
|
||
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);
|
||
cm.position.set(c.x / TILE, -c.y / TILE, ptz - 12);
|
||
}
|
||
|
||
// огни
|
||
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);
|
||
}
|