grechka-game/src3d/render/render3d.js

620 lines
22 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 РЕНДЕР (Three.js) — GrechkaCraft v2 ====================
// Логика, физика, сеть, инвентарь, крафт, погода — из src3d (общие модули).
// Здесь ТОЛЬКО отрисовка: 2D-canvas заменён на Three.js-сцену.
//
// Единицы: 1 тайл = 1 юнит. Игровые координаты px: +y ВНИЗ.
// Three.js: +y ВВЕРХ, +z к зрителю. Поэтому TY(v) = -v / TILE.
import { state } from '../core/state.js';
import { BLOCKS } from '../data/blocks.js';
import { getBiome } from '../world/generation.js';
import { isNight } from '../entities/mob-ai.js';
import { skyColor } from '../world/weather.js';
import { mode } from '../game/modes.js';
import { handleTap, stopMining } from '../input/mouse-handler.js';
import { HERO_COL, HERO_PARTS, HERO_PIVOT, HERO_SCALE } from './hero-rig.js';
const TDY = (v) => -v / state.TILE;
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(s => parseFloat(s));
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 TDisDecor(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.PCFShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8fc7f5);
scene.fog = new THREE.Fog(0x8fc7f5, 40, 110);
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 400);
const hemi = new THREE.HemisphereLight(0xcfe6ff, 0x6ab04c, 0.9);
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = -32; sun.shadow.camera.right = 32;
sun.shadow.camera.top = 32; sun.shadow.camera.bottom = -32;
sun.shadow.camera.near = 1; sun.shadow.camera.far = 180;
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);
// Стена за миром и пол — дают толщину сцене (мир по Z = 1 тайл)
const backWall = new THREE.Mesh(
new THREE.PlaneGeometry(600, 400),
new THREE.MeshBasicMaterial({ color: 0x2b3a2a })
);
backWall.position.set(0, 0, -2.5);
backWall.frustumCulled = false;
scene.add(backWall);
const groundPlane = new THREE.Mesh(
new THREE.PlaneGeometry(600, 400),
new THREE.MeshLambertMaterial({ color: 0x3d5a3a })
);
groundPlane.rotation.x = -Math.PI / 2;
groundPlane.position.set(0, 0, 3);
groundPlane.frustumCulled = false;
groundPlane.receiveShadow = true;
scene.add(groundPlane);
const crack = new THREE.Mesh(
new THREE.BoxGeometry(1.003, 1.003, 1.003),
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0 })
);
crack.visible = false;
scene.add(crack);
const ghost = new THREE.Mesh(
new THREE.BoxGeometry(1.01, 1.01, 1.01),
new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, transparent: true, opacity: 0.9 })
);
ghost.visible = false;
scene.add(ghost);
// облака
const cloudMeshes = [];
const cloudMat = new THREE.MeshLambertMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 });
for (let i = 0; i < 14; i++) {
const m = new THREE.Mesh(new THREE.BoxGeometry(4, 0.7, 1.5), 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.6, 0.09, 0.09), new THREE.MeshBasicMaterial({ color: 0xf1c40f }));
m.visible = false;
scene.add(m);
projPool.push(m);
}
// частицы
const PMAX = 600;
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.14, 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(400, 0x74b9ff, 0.18);
const snow = makeFall(250, 0xffffff, 0.15);
// огни: точечные свет + конусы пламени
const lightPool = [];
for (let i = 0; i < 5; i++) {
const L = new THREE.PointLight(0xff9a3c, 0, 20);
scene.add(L);
lightPool.push(L);
}
const flamePool = [];
for (let i = 0; i < 8; i++) {
const f = new THREE.Mesh(new THREE.ConeGeometry(0.28, 0.7, 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, 0.8),
new THREE.MeshLambertMaterial({ color: 0x8b4513 })
);
boatMesh.visible = false;
boatMesh.castShadow = true;
scene.add(boatMesh);
TD = {
renderer, scene, camera, hemi, sun, amb, hero, backWall, groundPlane,
crack, ghost, cloudMeshes, projPool, points, pPos, pCol, PMAX,
rain, snow, lightPool, flamePool, boatMesh,
blockMeshes: [], lastVersion: -1,
mobs: new Map(), players: new Map(),
theta: 0.30, phi: 0.26, dist: 16,
ptrs: new Map(), pinchD: 0,
lastTap: null, fireCache: null, fireAt: 0,
heroStep: 0, heroT: 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(Math.min(window.devicePixelRatio || 1, 2));
TD.renderer.setSize(w, h, false);
TD.camera.aspect = w / h;
TD.camera.updateProjectionMatrix();
}
// ---------- блоки ----------
function TDrebuildBlocks() {
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;
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 decor = !fluid && TDisDecor(t);
const mesh = new THREE.InstancedMesh(decor ? planeGeo : boxGeo, TDblockMat(t), list.length);
mesh.castShadow = !decor;
mesh.receiveShadow = true;
if (decor) mesh.material.side = THREE.DoubleSide;
const m = new THREE.Matrix4();
for (let i = 0; i < list.length; i++) {
const b = list[i];
if (fluid) {
m.makeScale(1, 0.8, 1);
m.setPosition(b.gx, -b.gy - 0.1, 0);
} else {
m.identity();
m.setPosition(b.gx, -b.gy, 0);
}
mesh.setMatrixAt(i, m);
}
mesh.instanceMatrix.needsUpdate = true;
mesh.userData.list = list;
TD.scene.add(mesh);
TD.blockMeshes.push({ mesh, type: t });
}
TD.lastVersion = state.blocksVersion || 0;
}
// ---------- pick ----------
const TDray = new THREE.Raycaster();
const TDndc = new THREE.Vector2();
const TDplane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
const TDhitP = new THREE.Vector3();
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(e => e.mesh), false);
if (hits.length) {
const h = hits[0];
const b = h.object.userData.list[h.instanceId];
if (b) return { wx: h.point.x * state.TILE, wy: -h.point.y * state.TILE, gx: b.gx, gy: b.gy };
}
if (TDray.ray.intersectPlane(TDplane, TDhitP)) {
return {
wx: TDhitP.x * state.TILE, wy: -TDhitP.y * state.TILE,
gx: Math.floor(TDhitP.x), gy: Math.floor(-TDhitP.y),
};
}
return null;
}
// ---------- ввод: drag = орбита, щипок/колесо = зум, тап = действие ----------
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(32, Math.max(5, TD.dist * TD.pinchD / d));
TD.pinchD = d;
return;
}
if (q.moved) {
TD.theta -= dx * 0.005;
TD.phi = Math.min(1.35, Math.max(0.18, TD.phi + 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 = { gx: hit.gx, gy: hit.gy };
handleTap(hit.wx, hit.wy);
}
}
};
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(32, Math.max(5, 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, 0.6),
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.12, 0.12, 0.05), eyeM);
e1.position.set(-0.15, 0.25, 0.31);
const e2 = e1.clone();
e2.position.x = 0.15;
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 player = 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;
if ((state.blocksVersion || 0) !== TD.lastVersion) TDrebuildBlocks();
const night = isNight();
const px = (player.x + (player.w || 24) / 2) / TILE;
const py = TDY(player.y + (player.h || 70) / 2);
// небо и свет
const sky = skyColor(night, getBiome(Math.floor(player.x / TILE)), 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.32 : 0.9;
TD.amb.intensity = night ? 0.34 : 0.25;
if (state.lightningFlash > 0) TD.hemi.intensity += Math.min(2.2, state.lightningFlash * 2.4);
TD.backWall.position.set(px, py, -2.5);
TD.groundPlane.position.set(px, Math.round(TDY(player.y) - 40), 3);
TD.sun.position.set(px + 25, py + 40, 25);
TD.sun.target.position.set(px, py, 0);
TD.sun.target.updateMatrixWorld();
// камера-орбита вокруг игрока
const cp = Math.cos(TD.phi), sp = Math.sin(TD.phi);
const camY = py + TD.dist * sp;
TD.camera.position.set(
px + TD.dist * Math.sin(TD.theta) * cp,
Math.max(camY, py + 1.2),
TD.dist * Math.cos(TD.theta) * cp
);
TD.camera.lookAt(px, py + 0.3, 0);
// герой: ноги на ступнях коллайдера + анимация шага
TD.hero.group.position.set(px, TDY(player.y), 0);
TD.heroT += dt;
const moving = Math.abs(player.vx || 0) > 20 && player.onGround;
if (moving) TD.heroStep += dt * 11;
TD.hero.P.legs.rotation.z = moving ? Math.sin(TD.heroStep) * 0.34 : 0;
if (player.vx > 20) TD.face = 1;
else if (player.vx < -20) TD.face = -1;
const wantYaw = TD.face * 0.34;
TD.hero.group.rotation.y += (wantYaw - TD.hero.group.rotation.y) * Math.min(1, 9 * 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);
const w = (m.w || 30) / TILE, h = (m.h || 30) / TILE;
e.body.scale.set(w, h, 1);
e.g.position.set((m.x + (m.w || 30) / 2) / TILE, TDY(m.y + (m.h || 30) / 2), 0);
}
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, p] of state.otherPlayers) {
seenP.add(id);
const e = TDplayerEntry(id, p);
e.rig.group.position.set((p.x + 17) / TILE, TDY(p.y), 0);
}
}
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, TDY(state.boat.y), 0);
} 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, TDY(pr.y), 0.35);
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] = TDY(pt.y);
TD.pPos[i * 3 + 2] = 0.3;
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, TDY(c.y), -8);
}
// огни (костры/печи/факелы/светящиеся) — кеш раз в 20 кадров
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));
if (!lit) continue;
const dx = b.gx - px, dy = -b.gy - py;
found.push({ t: b.t, gx: b.gx, gy: b.gy, d: dx * dx + dy * dy });
}
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, -src.gy, 1.6);
L.intensity = 1.5 + 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, -src.gy + 0.72 + Math.sin(now / 90 + src.gy) * 0.06, 0.2);
const s = 0.9 + Math.sin(now / 70 + src.gx * 2) * 0.18;
f.scale.set(s, s * (1 + Math.sin(now / 55) * 0.12), s);
}
}
// погода
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 * 28;
let x = TD.rain.pos[i * 3];
if (y < py - 14) { y = py + 14; x = px + (Math.random() - 0.5) * 44; }
TD.rain.pos[i * 3] = x;
TD.rain.pos[i * 3 + 1] = y;
TD.rain.pos[i * 3 + 2] = 4;
}
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.2;
let x = TD.snow.pos[i * 3] + Math.sin(now / 900 + i) * dt * 1.5;
if (y < py - 14) { y = py + 14; x = px + (Math.random() - 0.5) * 44; }
TD.snow.pos[i * 3] = x;
TD.snow.pos[i * 3 + 1] = y;
TD.snow.pos[i * 3 + 2] = 4;
}
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, -mg.gy, 0);
TD.crack.material.opacity = Math.min(0.55, (mg.progress / mg.hardness) * 0.55);
} else TD.crack.visible = false;
// ghost постройки
if (mode() === 'build' && TD.lastTap && !state.craftOpen && player.hp > 0) {
TD.ghost.visible = true;
TD.ghost.position.set(TD.lastTap.gx, -TD.lastTap.gy, 0);
} 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);
}