grechka-game/srcv3/render/render3d.js

626 lines
23 KiB
JavaScript
Raw 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 { 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);
}