// ==================== 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); }