import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; export class World { constructor(game) { this.game = game; this.scene = game.scene; this.interactables = []; this.colliders = []; this.sunLight = null; this.ambientLight = null; // Для миникарты this.buildingRects = []; // 3D модель машины this.carModel = null; this.carModelBaseRotation = 0; // корректировка ориентации модели this.parkedCars = []; // для замены припаркованных машин this.vehicleColliders = []; // динамические коллайдеры машин this.mapConfig = null; } async loadMapConfig() { try { const resp = await fetch('data/map-config.json'); this.mapConfig = await resp.json(); } catch(e) { console.warn('Map config not found, using defaults'); this.mapConfig = null; } } async build() { this.interactables = []; this.colliders = []; this.buildingRects = []; this.vehicleColliders = []; await this.loadMapConfig(); this.createGround(); this.createLighting(); this.createBuildings(); this.createPark(); this.createDumpsters(); this.createBenches(); this.createShop(); this.createShelter(); this.createStreetLamps(); this.createTrashPiles(); this.createFountain(); this.createPhoneBooth(); this.createParkingLot(); this.createBusStop(); this.createDecorations(); this.createChurch(); this.createConstructionSite(); this.createJobBoard(); this.createHospital(); this.createMarket(); this.createCampSpot(); } createGround() { const groundGeo = new THREE.PlaneGeometry(300, 300); const groundMat = new THREE.MeshStandardMaterial({ color: 0x555555, roughness: 0.9 }); const ground = new THREE.Mesh(groundGeo, groundMat); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; this.scene.add(ground); // === ДОРОЖНАЯ СЕТКА (из конфига) === const roads = this.mapConfig?.roads || [ { x: 0, z: 0, width: 300, height: 12, rotation: 0, sidewalkWidth: 3 }, { x: 0, z: 0, width: 12, height: 300, rotation: Math.PI / 2, sidewalkWidth: 3 }, ]; for (const road of roads) { this.createRoad(road.x, 0.01, road.z, road.width, road.height, road.rotation); } // === АВТОГЕНЕРАЦИЯ ТРОТУАРОВ И БОРДЮРОВ (с вырезами на перекрёстках) === const sidewalkMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8 }); const curbMat = new THREE.MeshStandardMaterial({ color: 0x999999 }); const ewRoads = roads.filter(r => Math.abs(r.rotation) < 0.1); const nsRoads = roads.filter(r => Math.abs(r.rotation) >= 0.1); for (const road of roads) { const sw = road.sidewalkWidth ?? 3; if (sw <= 0) continue; const isEW = Math.abs(road.rotation) < 0.1; if (isEW) { const halfH = road.height / 2; const roadStart = road.x - road.width / 2; const roadEnd = road.x + road.width / 2; // Находим пересечения с N-S дорогами → вырезы по X const gaps = []; for (const ns of nsRoads) { const nsHalfW = ns.width / 2; const nsSW = ns.sidewalkWidth ?? 3; const nsZMin = ns.z - ns.height / 2; const nsZMax = ns.z + ns.height / 2; if (road.z >= nsZMin && road.z <= nsZMax && ns.x >= roadStart && ns.x <= roadEnd) { gaps.push({ start: ns.x - nsHalfW - nsSW, end: ns.x + nsHalfW + nsSW }); } } gaps.sort((a, b) => a.start - b.start); // Сегменты тротуара между вырезами const segments = []; let pos = roadStart; for (const g of gaps) { if (g.start > pos) segments.push({ start: pos, end: g.start }); pos = Math.max(pos, g.end); } if (pos < roadEnd) segments.push({ start: pos, end: roadEnd }); for (const seg of segments) { const len = seg.end - seg.start; const cx = (seg.start + seg.end) / 2; const swGeo = new THREE.BoxGeometry(len, 0.15, sw); [1, -1].forEach(side => { const swMesh = new THREE.Mesh(swGeo, sidewalkMat); swMesh.position.set(cx, 0.075, road.z + side * (halfH + sw / 2)); swMesh.receiveShadow = true; this.scene.add(swMesh); }); const curbGeo = new THREE.BoxGeometry(len, 0.2, 0.15); [halfH, halfH + sw, -halfH, -(halfH + sw)].forEach(offset => { const curb = new THREE.Mesh(curbGeo, curbMat); curb.position.set(cx, 0.1, road.z + offset); this.scene.add(curb); }); } } else { const halfW = road.width / 2; const roadStart = road.z - road.height / 2; const roadEnd = road.z + road.height / 2; // Находим пересечения с E-W дорогами → вырезы по Z const gaps = []; for (const ew of ewRoads) { const ewHalfH = ew.height / 2; const ewSW = ew.sidewalkWidth ?? 3; const ewXMin = ew.x - ew.width / 2; const ewXMax = ew.x + ew.width / 2; if (road.x >= ewXMin && road.x <= ewXMax && ew.z >= roadStart && ew.z <= roadEnd) { gaps.push({ start: ew.z - ewHalfH - ewSW, end: ew.z + ewHalfH + ewSW }); } } gaps.sort((a, b) => a.start - b.start); const segments = []; let pos = roadStart; for (const g of gaps) { if (g.start > pos) segments.push({ start: pos, end: g.start }); pos = Math.max(pos, g.end); } if (pos < roadEnd) segments.push({ start: pos, end: roadEnd }); for (const seg of segments) { const len = seg.end - seg.start; const cz = (seg.start + seg.end) / 2; const swGeo = new THREE.BoxGeometry(sw, 0.15, len); [1, -1].forEach(side => { const swMesh = new THREE.Mesh(swGeo, sidewalkMat); swMesh.position.set(road.x + side * (halfW + sw / 2), 0.075, cz); swMesh.receiveShadow = true; this.scene.add(swMesh); }); const curbGeo = new THREE.BoxGeometry(0.15, 0.2, len); [halfW, halfW + sw, -halfW, -(halfW + sw)].forEach(offset => { const curb = new THREE.Mesh(curbGeo, curbMat); curb.position.set(road.x + offset, 0.1, cz); this.scene.add(curb); }); } } } } createRoad(x, y, z, w, h, rot) { const roadGeo = new THREE.PlaneGeometry(w, h); const roadMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.85 }); const road = new THREE.Mesh(roadGeo, roadMat); road.rotation.x = -Math.PI / 2; road.rotation.z = rot; road.position.set(x, y, z); road.receiveShadow = true; this.scene.add(road); // Пунктирная разметка по центру дороги const lineW = 1.5; const lineGap = 3; const roadLen = rot === 0 ? w : h; const step = lineW + lineGap; const count = Math.floor(roadLen / step); const totalLen = count * step; const lineMat = new THREE.MeshStandardMaterial({ color: 0xffffcc }); for (let i = 0; i < count; i++) { const lineGeo = new THREE.PlaneGeometry(lineW, 0.15); const line = new THREE.Mesh(lineGeo, lineMat); line.rotation.x = -Math.PI / 2; line.rotation.z = rot; const offset = -totalLen / 2 + i * step + lineW / 2; if (rot === 0) { line.position.set(x + offset, y + 0.01, z); } else { line.position.set(x, y + 0.01, z + offset); } this.scene.add(line); } } createLighting() { this.ambientLight = new THREE.AmbientLight(0xffffff, 0.4); this.scene.add(this.ambientLight); this.sunLight = new THREE.DirectionalLight(0xffffff, 1.0); this.sunLight.position.set(50, 80, 30); this.sunLight.castShadow = true; this.sunLight.shadow.mapSize.set(2048, 2048); this.sunLight.shadow.camera.left = -120; this.sunLight.shadow.camera.right = 120; this.sunLight.shadow.camera.top = 120; this.sunLight.shadow.camera.bottom = -120; this.sunLight.shadow.camera.near = 1; this.sunLight.shadow.camera.far = 350; this.scene.add(this.sunLight); const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x444444, 0.3); this.scene.add(hemiLight); } updateLighting(gameTime) { const hour = gameTime / 60; let sunIntensity, ambientIntensity, exposure; let skyColor; if (hour >= 5 && hour < 7) { const t = (hour - 5) / 2; sunIntensity = THREE.MathUtils.lerp(0.05, 0.8, t); ambientIntensity = THREE.MathUtils.lerp(0.08, 0.35, t); exposure = THREE.MathUtils.lerp(0.25, 0.9, t); skyColor = new THREE.Color().lerpColors(new THREE.Color(0x1a1a3e), new THREE.Color(0xff9966), t); } else if (hour >= 7 && hour < 9) { const t = (hour - 7) / 2; sunIntensity = THREE.MathUtils.lerp(0.8, 1.0, t); ambientIntensity = THREE.MathUtils.lerp(0.35, 0.4, t); exposure = THREE.MathUtils.lerp(0.9, 1.0, t); skyColor = new THREE.Color().lerpColors(new THREE.Color(0xff9966), new THREE.Color(0x87CEEB), t); } else if (hour >= 9 && hour < 17) { sunIntensity = 1.0; ambientIntensity = 0.4; exposure = 1.0; skyColor = new THREE.Color(0x87CEEB); } else if (hour >= 17 && hour < 19) { const t = (hour - 17) / 2; sunIntensity = THREE.MathUtils.lerp(1.0, 0.5, t); ambientIntensity = THREE.MathUtils.lerp(0.4, 0.2, t); exposure = THREE.MathUtils.lerp(1.0, 0.6, t); skyColor = new THREE.Color().lerpColors(new THREE.Color(0x87CEEB), new THREE.Color(0xff6633), t); } else if (hour >= 19 && hour < 21) { const t = (hour - 19) / 2; sunIntensity = THREE.MathUtils.lerp(0.5, 0.05, t); ambientIntensity = THREE.MathUtils.lerp(0.2, 0.08, t); exposure = THREE.MathUtils.lerp(0.6, 0.25, t); skyColor = new THREE.Color().lerpColors(new THREE.Color(0xff6633), new THREE.Color(0x0a0a1e), t); } else { sunIntensity = 0.05; ambientIntensity = 0.08; exposure = 0.25; skyColor = new THREE.Color(0x0a0a1e); } this.sunLight.intensity = sunIntensity; this.ambientLight.intensity = ambientIntensity; this.game.renderer.toneMappingExposure = exposure; this.scene.background = skyColor; if (this.scene.fog) this.scene.fog.color = skyColor; const sunAngle = ((hour - 6) / 12) * Math.PI; this.sunLight.position.set(Math.cos(sunAngle) * 60, Math.sin(sunAngle) * 80, 30); } createBuildings() { const configs = (this.mapConfig?.buildings || [ { x: -45, z: -22, w: 14, h: 20, d: 10, color: 0x8b7355 }, { x: -30, z: -22, w: 10, h: 15, d: 10, color: 0x696969 }, { x: -15, z: -22, w: 12, h: 18, d: 10, color: 0x7b6b55 }, { x: 15, z: -22, w: 12, h: 22, d: 10, color: 0x5b5b6b }, { x: 30, z: -22, w: 14, h: 16, d: 10, color: 0x6b6b55 }, { x: 40, z: -22, w: 10, h: 20, d: 10, color: 0x556b6b }, { x: -40, z: -55, w: 16, h: 28, d: 12, color: 0x556b7b }, { x: -20, z: -55, w: 12, h: 18, d: 10, color: 0x6b6b5b }, { x: 20, z: -55, w: 10, h: 14, d: 10, color: 0x6b7b5b }, { x: 38, z: -55, w: 14, h: 20, d: 12, color: 0x7b5b6b }, { x: -45, z: 25, w: 16, h: 16, d: 10, color: 0x7b5b5b }, { x: -30, z: 25, w: 10, h: 12, d: 10, color: 0x5b7b6b }, { x: 15, z: 25, w: 12, h: 22, d: 10, color: 0x5b6b7b }, { x: 30, z: 25, w: 14, h: 18, d: 10, color: 0x6b5b7b }, { x: 42, z: 25, w: 10, h: 14, d: 10, color: 0x7b7b5b }, { x: -45, z: 65, w: 14, h: 14, d: 10, color: 0x6b5b5b }, { x: 15, z: 65, w: 12, h: 16, d: 10, color: 0x5b6b5b }, ]).map(b => ({ x: b.x, z: b.z, w: b.w, h: b.h, d: b.d, color: typeof b.color === 'string' ? parseInt(b.color.replace('#',''), 16) : b.color })); configs.forEach(cfg => { this.createBuilding(cfg.x, cfg.z, cfg.w, cfg.h, cfg.d, cfg.color); this.buildingRects.push({ x: cfg.x, z: cfg.z, w: cfg.w, d: cfg.d }); }); } createBuilding(x, z, w, h, d, color) { const group = new THREE.Group(); const bodyGeo = new THREE.BoxGeometry(w, h, d); const bodyMat = new THREE.MeshStandardMaterial({ color, roughness: 0.8 }); const body = new THREE.Mesh(bodyGeo, bodyMat); body.position.y = h / 2; body.castShadow = true; body.receiveShadow = true; group.add(body); // Окна (с рандомным свечением — кто-то дома, кто-то нет) const winW = 1.2, winH = 1.8; const floors = Math.floor(h / 4); const cols = Math.floor(w / 3.5); for (let f = 0; f < floors; f++) { for (let c = 0; c < cols; c++) { const lit = Math.random() > 0.4; const winMat = new THREE.MeshStandardMaterial({ color: lit ? 0xffeebb : 0x556677, emissive: lit ? 0x554422 : 0x112233, emissiveIntensity: lit ? 0.4 : 0.1, roughness: 0.1, metalness: 0.8 }); const winGeo = new THREE.PlaneGeometry(winW, winH); const wx = -w / 2 + 2 + c * 3.5; const wy = 3 + f * 4; const win1 = new THREE.Mesh(winGeo, winMat); win1.position.set(wx, wy, d / 2 + 0.01); group.add(win1); const win2 = new THREE.Mesh(winGeo, winMat); win2.position.set(wx, wy, -d / 2 - 0.01); win2.rotation.y = Math.PI; group.add(win2); } } // Крыша const roofGeo = new THREE.BoxGeometry(w + 0.5, 0.5, d + 0.5); const roofMat = new THREE.MeshStandardMaterial({ color: 0x3a3a3a }); const roof = new THREE.Mesh(roofGeo, roofMat); roof.position.y = h; roof.castShadow = true; group.add(roof); // Дверь (на одном случайном фасаде) const doorGeo = new THREE.BoxGeometry(1.5, 2.5, 0.1); const doorMat = new THREE.MeshStandardMaterial({ color: 0x5c3a1e }); const door = new THREE.Mesh(doorGeo, doorMat); door.position.set(0, 1.25, d / 2 + 0.06); group.add(door); group.position.set(x, 0, z); this.scene.add(group); this.colliders.push(new THREE.Box3( new THREE.Vector3(x - w / 2, 0, z - d / 2), new THREE.Vector3(x + w / 2, h, z + d / 2) )); } createPark() { const parkCfg = this.mapConfig?.structures?.park || { x: -30, z: 25, radius: 18 }; const parkX = parkCfg.x; const parkZ = parkCfg.z; const parkRadius = parkCfg.radius; const parkGeo = new THREE.CircleGeometry(parkRadius, 32); const parkMat = new THREE.MeshStandardMaterial({ color: 0x3a7d3a, roughness: 0.9 }); const park = new THREE.Mesh(parkGeo, parkMat); park.rotation.x = -Math.PI / 2; park.position.set(parkX, 0.02, parkZ); park.receiveShadow = true; this.scene.add(park); // Деревья (больше) const treePositions = [ [parkX + 8, parkZ - 5], [parkX - 8, parkZ - 5], [parkX, parkZ + 10], [parkX + 10, parkZ + 7], [parkX - 10, parkZ + 5], [parkX + 5, parkZ - 7], [parkX - 5, parkZ + 13], [parkX - 12, parkZ - 3], [parkX + 12, parkZ + 3], [parkX - 8, parkZ - 10], ]; treePositions.forEach(([tx, tz]) => this.createTree(tx, tz)); // Кусты const bushPositions = [ [parkX + 5, parkZ - 5], [parkX - 5, parkZ - 1], [parkX - 10, parkZ + 7], [parkX + 10, parkZ + 10], [parkX + 12, parkZ - 1], [parkX - 2, parkZ + 13], ]; bushPositions.forEach(([bx, bz]) => this.createBush(bx, bz)); // Дорожки парка const pathMat = new THREE.MeshStandardMaterial({ color: 0x8b7355, roughness: 0.9 }); const path1 = new THREE.Mesh(new THREE.PlaneGeometry(2, 30), pathMat); path1.rotation.x = -Math.PI / 2; path1.position.set(parkX, 0.03, parkZ); this.scene.add(path1); const path2 = new THREE.Mesh(new THREE.PlaneGeometry(20, 2), pathMat); path2.rotation.x = -Math.PI / 2; path2.position.set(parkX, 0.03, parkZ); this.scene.add(path2); // Клумба в центре const flowerGeo = new THREE.CircleGeometry(3, 16); const flowerMat = new THREE.MeshStandardMaterial({ color: 0x228822 }); const flowerBed = new THREE.Mesh(flowerGeo, flowerMat); flowerBed.rotation.x = -Math.PI / 2; flowerBed.position.set(parkX, 0.04, parkZ); this.scene.add(flowerBed); // Цветы const colors = [0xff4444, 0xffff44, 0xff88ff, 0xffaa22]; for (let i = 0; i < 12; i++) { const angle = (i / 12) * Math.PI * 2; const r = 1.5 + Math.random(); const fGeo = new THREE.SphereGeometry(0.15, 6, 4); const fMat = new THREE.MeshStandardMaterial({ color: colors[Math.floor(Math.random() * colors.length)], emissive: 0x222200, emissiveIntensity: 0.1 }); const flower = new THREE.Mesh(fGeo, fMat); flower.position.set( parkX + Math.cos(angle) * r, 0.15, parkZ + Math.sin(angle) * r ); this.scene.add(flower); } } createTree(x, z) { const group = new THREE.Group(); const h = 2.5 + Math.random() * 1.5; const crownR = 2 + Math.random() * 1; const trunkGeo = new THREE.CylinderGeometry(0.25, 0.45, h, 8); const trunkMat = new THREE.MeshStandardMaterial({ color: 0x5c3a1e }); const trunk = new THREE.Mesh(trunkGeo, trunkMat); trunk.position.y = h / 2; trunk.castShadow = true; group.add(trunk); const crownGeo = new THREE.SphereGeometry(crownR, 8, 6); const shade = 0x1d5b1d + Math.floor(Math.random() * 0x102010); const crownMat = new THREE.MeshStandardMaterial({ color: shade, roughness: 0.9 }); const crown = new THREE.Mesh(crownGeo, crownMat); crown.position.y = h + crownR * 0.6; crown.castShadow = true; group.add(crown); group.position.set(x, 0, z); this.scene.add(group); this.colliders.push(new THREE.Box3( new THREE.Vector3(x - 0.5, 0, z - 0.5), new THREE.Vector3(x + 0.5, 6, z + 0.5) )); } createBush(x, z) { const bushGeo = new THREE.SphereGeometry(0.8 + Math.random() * 0.4, 6, 5); const bushMat = new THREE.MeshStandardMaterial({ color: 0x2a6b2a, roughness: 0.95 }); const bush = new THREE.Mesh(bushGeo, bushMat); bush.position.set(x, 0.5, z); bush.scale.y = 0.7; bush.castShadow = true; this.scene.add(bush); } createDumpsters() { const positions = this.mapConfig?.interactables?.dumpsters || [ { x: -20, z: -15, rot: 0 }, { x: 25, z: -15, rot: 0.3 }, { x: -45, z: 12, rot: -0.2 }, { x: 35, z: 12, rot: 0.5 }, { x: -25, z: 35, rot: 0.1 }, { x: 20, z: -48, rot: 0 }, { x: -40, z: -48, rot: 0.4 }, { x: 25, z: 35, rot: -0.3 }, ]; positions.forEach(pos => { const dumpster = this.createDumpster(pos.x, pos.z, pos.rot); this.interactables.push({ type: 'dumpster', mesh: dumpster, position: new THREE.Vector3(pos.x, 0, pos.z), radius: 3, label: 'Обыскать мусорку', searched: false, searchCooldown: 0 }); }); } createDumpster(x, z, rot) { const group = new THREE.Group(); const bodyMat = new THREE.MeshStandardMaterial({ color: 0x2e5b2e, roughness: 0.7 }); const body = new THREE.Mesh(new THREE.BoxGeometry(2, 1.4, 1.2), bodyMat); body.position.y = 0.7; body.castShadow = true; group.add(body); const lid = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.1, 1.3), bodyMat); lid.position.set(0, 1.45, 0); group.add(lid); // Немного мусора вокруг const trashMat = new THREE.MeshStandardMaterial({ color: 0x555544 }); for (let i = 0; i < 3; i++) { const t = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.1, 0.15), trashMat); t.position.set((Math.random() - 0.5) * 2.5, 0.05, (Math.random() - 0.5) * 2); t.rotation.y = Math.random() * Math.PI; group.add(t); } group.position.set(x, 0, z); group.rotation.y = rot; this.scene.add(group); this.colliders.push(new THREE.Box3( new THREE.Vector3(x - 1, 0, z - 0.6), new THREE.Vector3(x + 1, 1.5, z + 0.6) )); return group; } createBenches() { const positions = this.mapConfig?.interactables?.benches || [ { x: -30, z: 20, rot: 0 }, { x: -25, z: 32, rot: Math.PI }, { x: 10, z: 12, rot: Math.PI }, { x: -15, z: -12, rot: 0 }, { x: 35, z: 12, rot: Math.PI }, { x: -35, z: 35, rot: Math.PI }, ]; positions.forEach(pos => { const bench = this.createBench(pos.x, pos.z, pos.rot); this.interactables.push({ type: 'bench', mesh: bench, position: new THREE.Vector3(pos.x, 0, pos.z), radius: 2.5, label: 'Отдохнуть на скамейке' }); }); } createBench(x, z, rot) { const group = new THREE.Group(); const woodMat = new THREE.MeshStandardMaterial({ color: 0x8b6914, roughness: 0.85 }); const metalMat = new THREE.MeshStandardMaterial({ color: 0x444444, metalness: 0.6 }); const seat = new THREE.Mesh(new THREE.BoxGeometry(2, 0.1, 0.6), woodMat); seat.position.y = 0.5; seat.castShadow = true; group.add(seat); const back = new THREE.Mesh(new THREE.BoxGeometry(2, 0.6, 0.08), woodMat); back.position.set(0, 0.85, -0.28); back.rotation.x = -0.15; group.add(back); [-0.8, 0.8].forEach(lx => { const leg = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.5, 0.6), metalMat); leg.position.set(lx, 0.25, 0); group.add(leg); }); group.position.set(x, 0, z); group.rotation.y = rot; this.scene.add(group); return group; } createShop() { const group = new THREE.Group(); const shopCfg = this.mapConfig?.structures?.shop || {}; const shopX = shopCfg.x ?? -25, shopZ = shopCfg.z ?? -12; const sw = shopCfg.w ?? 10, sh = shopCfg.h ?? 5, sd = shopCfg.d ?? 8; const hw = sw / 2, hd = sd / 2; const bodyMat = new THREE.MeshStandardMaterial({ color: 0xaa8855, roughness: 0.7 }); const body = new THREE.Mesh(new THREE.BoxGeometry(sw, sh, sd), bodyMat); body.position.y = sh / 2; body.castShadow = true; body.receiveShadow = true; group.add(body); // Вывеска с подсветкой const signMat = new THREE.MeshStandardMaterial({ color: 0x2244aa, emissive: 0x2244aa, emissiveIntensity: 0.6 }); const sign = new THREE.Mesh(new THREE.BoxGeometry(sw * 0.6, 1.2, 0.2), signMat); sign.position.set(0, sh - 0.2, hd + 0.1); group.add(sign); // Свет вывески const signLight = new THREE.PointLight(0x4466ff, 0.5, 8); signLight.position.set(0, sh - 0.5, hd + 1); group.add(signLight); const door = new THREE.Mesh( new THREE.BoxGeometry(1.8, 2.8, 0.15), new THREE.MeshStandardMaterial({ color: 0x6b4226 }) ); door.position.set(0, 1.4, hd + 0.05); group.add(door); // Витрины const winMat = new THREE.MeshStandardMaterial({ color: 0x88ccff, transparent: true, opacity: 0.5, roughness: 0.1 }); [-hw * 0.7, hw * 0.7].forEach(wx => { const win = new THREE.Mesh(new THREE.PlaneGeometry(2.5, 2), winMat); win.position.set(wx, sh / 2, hd + 0.01); group.add(win); }); // Навес const awningMat = new THREE.MeshStandardMaterial({ color: 0xcc3333 }); const awning = new THREE.Mesh(new THREE.BoxGeometry(sw + 0.5, 0.1, 2), awningMat); awning.position.set(0, sh - 1, hd + 1); group.add(awning); group.position.set(shopX, 0, shopZ); this.scene.add(group); this.colliders.push(new THREE.Box3( new THREE.Vector3(shopX - hw, 0, shopZ - hd), new THREE.Vector3(shopX + hw, sh, shopZ + hd) )); this.buildingRects.push({ x: shopX, z: shopZ, w: sw, d: sd }); this.interactables.push({ type: 'shop', mesh: group, position: new THREE.Vector3(shopX, 0, shopZ + hd + 1), radius: 3, label: 'Войти в магазин' }); } createShelter() { const group = new THREE.Group(); const shelterCfg = this.mapConfig?.structures?.shelter || {}; const sx = shelterCfg.x ?? -35, sz = shelterCfg.z ?? 35; const sw = shelterCfg.w ?? 8, sd = shelterCfg.d ?? 6; const hw = sw / 2, hd = sd / 2; const wallH = 3.5; const wallMat = new THREE.MeshStandardMaterial({ color: 0x6b5b4b, roughness: 0.9 }); const backWall = new THREE.Mesh(new THREE.BoxGeometry(sw, wallH, 0.3), wallMat); backWall.position.set(0, wallH / 2, -hd); backWall.castShadow = true; group.add(backWall); [-hw, hw].forEach(sideX => { const sideWall = new THREE.Mesh(new THREE.BoxGeometry(0.3, wallH, sd), wallMat); sideWall.position.set(sideX, wallH / 2, 0); sideWall.castShadow = true; group.add(sideWall); }); const roof = new THREE.Mesh(new THREE.BoxGeometry(sw + 1, 0.2, sd + 1), wallMat); roof.position.y = wallH + 0.1; roof.castShadow = true; group.add(roof); // Матрас + подушка const mat = new THREE.Mesh( new THREE.BoxGeometry(2, 0.2, 3), new THREE.MeshStandardMaterial({ color: 0x555577 }) ); mat.position.set(-1, 0.1, -0.5); group.add(mat); const pillow = new THREE.Mesh( new THREE.BoxGeometry(0.6, 0.15, 0.4), new THREE.MeshStandardMaterial({ color: 0x666688 }) ); pillow.position.set(-1, 0.25, -1.8); group.add(pillow); // Коробки const boxMat = new THREE.MeshStandardMaterial({ color: 0x8b6914 }); const box1 = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.6, 0.6), boxMat); box1.position.set(hw - 1, 0.3, -2); group.add(box1); const box2 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.4, 0.5), boxMat); box2.position.set(hw - 0.7, 0.2, -1.2); group.add(box2); // Костёр + камни const fireGeo = new THREE.ConeGeometry(0.3, 0.6, 6); const fireMat = new THREE.MeshStandardMaterial({ color: 0xff6600, emissive: 0xff4400, emissiveIntensity: 1.5 }); const fire = new THREE.Mesh(fireGeo, fireMat); fire.position.set(hw - 2, 0.3, 0); group.add(fire); // Камни вокруг костра const stoneMat = new THREE.MeshStandardMaterial({ color: 0x666666 }); for (let i = 0; i < 6; i++) { const angle = (i / 6) * Math.PI * 2; const stone = new THREE.Mesh( new THREE.SphereGeometry(0.15, 5, 4), stoneMat ); stone.position.set(hw - 2 + Math.cos(angle) * 0.5, 0.1, Math.sin(angle) * 0.5); stone.scale.y = 0.6; group.add(stone); } // Свет от костра const fireLight = new THREE.PointLight(0xff6600, 2, 12); fireLight.position.set(hw - 2, 1, 0); fireLight.castShadow = true; group.add(fireLight); group.position.set(sx, 0, sz); group.rotation.y = Math.PI; // открыто на юг, к парку this.scene.add(group); this.colliders.push(new THREE.Box3( new THREE.Vector3(sx - hw - 0.2, 0, sz - hd - 0.2), new THREE.Vector3(sx + hw + 0.2, wallH + 0.1, sz + hd + 0.2) )); this.interactables.push({ type: 'shelter', mesh: group, position: new THREE.Vector3(sx, 0, sz - 1), radius: 5, label: 'Поспать в укрытии' }); // Костёр: локальный (hw-2, 0, 0), группа повёрнута на PI → мировая позиция (sx - (hw-2), 0, sz) this.interactables.push({ type: 'campfire', mesh: fire, position: new THREE.Vector3(sx - (hw - 2), 0, sz), radius: 3, label: 'Погреться у костра' }); } createTrashPiles() { const positions = this.mapConfig?.interactables?.trashPiles || [ { x: -40, z: 12 }, { x: 35, z: -15 }, { x: -10, z: -38 }, { x: 25, z: 38 }, ]; positions.forEach(pos => { const group = new THREE.Group(); const mats = [0x555544, 0x444433, 0x554444, 0x445544]; for (let i = 0; i < 8; i++) { const size = 0.2 + Math.random() * 0.4; const geo = new THREE.BoxGeometry(size, size * 0.5, size); const mat = new THREE.MeshStandardMaterial({ color: mats[Math.floor(Math.random() * mats.length)] }); const piece = new THREE.Mesh(geo, mat); piece.position.set( (Math.random() - 0.5) * 1.5, size * 0.25, (Math.random() - 0.5) * 1.5 ); piece.rotation.y = Math.random() * Math.PI; group.add(piece); } group.position.set(pos.x, 0, pos.z); this.scene.add(group); this.interactables.push({ type: 'trashpile', mesh: group, position: new THREE.Vector3(pos.x, 0, pos.z), radius: 2.5, label: 'Порыться в мусоре', searchCooldown: 0 }); }); } createFountain() { const group = new THREE.Group(); const cfg = this.mapConfig?.structures?.fountain || {}; const fx = cfg.x ?? -30, fz = cfg.z ?? 25; // База const baseMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.7 }); const base = new THREE.Mesh(new THREE.CylinderGeometry(1.5, 1.8, 0.5, 16), baseMat); base.position.y = 0.25; group.add(base); // Колонна const column = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.25, 1.2, 8), baseMat); column.position.y = 1.1; group.add(column); // Верхняя чаша const bowlGeo = new THREE.SphereGeometry(0.5, 12, 6, 0, Math.PI * 2, 0, Math.PI / 2); const bowl = new THREE.Mesh(bowlGeo, baseMat); bowl.position.y = 1.7; bowl.rotation.x = Math.PI; group.add(bowl); // Вода const waterMat = new THREE.MeshStandardMaterial({ color: 0x3388bb, transparent: true, opacity: 0.6, roughness: 0.1, metalness: 0.3 }); const water = new THREE.Mesh(new THREE.CircleGeometry(1.3, 16), waterMat); water.rotation.x = -Math.PI / 2; water.position.y = 0.48; group.add(water); group.position.set(fx, 0, fz); this.scene.add(group); this.interactables.push({ type: 'fountain', mesh: group, position: new THREE.Vector3(fx, 0, fz), radius: 2.5, label: 'Попить воды' }); } createPhoneBooth() { const group = new THREE.Group(); const cfg = this.mapConfig?.structures?.phoneBooth || {}; const px = cfg.x ?? 10, pz = cfg.z ?? -8; const pw = cfg.w ?? 1.2, pd = cfg.d ?? 1.2; const hw = pw / 2, hd = pd / 2; const boothH = 2.5; const boothMat = new THREE.MeshStandardMaterial({ color: 0x2244aa }); const booth = new THREE.Mesh(new THREE.BoxGeometry(pw, boothH, pd), boothMat); booth.position.y = boothH / 2; booth.castShadow = true; group.add(booth); const phoneMat = new THREE.MeshStandardMaterial({ color: 0x333333 }); const phone = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.3, 0.1), phoneMat); phone.position.set(0, 1.5, hd - 0.04); group.add(phone); group.position.set(px, 0, pz); this.scene.add(group); this.colliders.push(new THREE.Box3( new THREE.Vector3(px - hw, 0, pz - hd), new THREE.Vector3(px + hw, boothH, pz + hd) )); this.interactables.push({ type: 'phone', mesh: group, position: new THREE.Vector3(px, 0, pz + 1), radius: 2, label: 'Таксофон' }); } createParkingLot() { const cfg = this.mapConfig?.structures?.parking || {}; const lotX = cfg.x ?? 35, lotZ = cfg.z ?? 12; const lotW = cfg.w ?? 20, lotD = cfg.d ?? 15; const lot = new THREE.Mesh( new THREE.PlaneGeometry(lotW, lotD), new THREE.MeshStandardMaterial({ color: 0x2a2a2a }) ); lot.rotation.x = -Math.PI / 2; lot.position.set(lotX, 0.01, lotZ); lot.receiveShadow = true; this.scene.add(lot); // Разметка парковки (относительно центра) const lineMat = new THREE.MeshStandardMaterial({ color: 0xeeeeee }); const slotW = 4; const lineCount = Math.floor(lotW / slotW) + 1; for (let i = 0; i < lineCount; i++) { const line = new THREE.Mesh(new THREE.PlaneGeometry(0.1, lotD * 0.35), lineMat); line.rotation.x = -Math.PI / 2; line.position.set(lotX - lotW / 2 + i * slotW, 0.02, lotZ); this.scene.add(line); } // Машины на парковке (в центрах слотов) const carConfigs = [ { x: lotX - lotW / 2 + slotW / 2, z: lotZ, color: 0xcc2222 }, { x: lotX, z: lotZ, color: 0x2255cc }, { x: lotX + lotW / 2 - slotW / 2, z: lotZ, color: 0x888888 }, ]; carConfigs.forEach(cfg => { const car = this.carModel ? this.carModel.clone() : this.createCarMeshFallback(cfg.color); car.position.set(cfg.x, 0, cfg.z); if (this.carModel) car.rotation.y = this.carModelBaseRotation; this.scene.add(car); this.parkedCars.push({ mesh: car, x: cfg.x, z: cfg.z, rotY: 0 }); this.colliders.push(new THREE.Box3( new THREE.Vector3(cfg.x - 1, 0, cfg.z - 2), new THREE.Vector3(cfg.x + 1, 2, cfg.z + 2) )); }); } createBusStop() { const group = new THREE.Group(); const cfg = this.mapConfig?.structures?.busStop || {}; const bx = cfg.x ?? -20, bz = cfg.z ?? 7; const bw = cfg.w ?? 5, bd = cfg.d ?? 2; const bhw = bw / 2, bhd = bd / 2; // Крыша const roofMat = new THREE.MeshStandardMaterial({ color: 0x336699, transparent: true, opacity: 0.7 }); const roof = new THREE.Mesh(new THREE.BoxGeometry(bw, 0.1, bd), roofMat); roof.position.y = 2.8; group.add(roof); // Столбы const poleMat = new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.5 }); [-(bhw - 0.2), bhw - 0.2].forEach(px => { const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 2.8, 6), poleMat); pole.position.set(px, 1.4, -(bhd - 0.1)); group.add(pole); }); // Задняя стенка const backMat = new THREE.MeshStandardMaterial({ color: 0x336699, transparent: true, opacity: 0.4 }); const back = new THREE.Mesh(new THREE.BoxGeometry(bw, 2.5, 0.05), backMat); back.position.set(0, 1.35, -(bhd - 0.05)); group.add(back); // Скамейка внутри const benchMat = new THREE.MeshStandardMaterial({ color: 0x888888 }); const bench = new THREE.Mesh(new THREE.BoxGeometry(bw * 0.6, 0.08, 0.5), benchMat); bench.position.set(0, 0.5, -0.5); group.add(bench); group.position.set(bx, 0, bz); group.rotation.y = Math.PI; // лицом к дороге this.scene.add(group); this.interactables.push({ type: 'bench', mesh: group, position: new THREE.Vector3(bx, 0, bz), radius: 3, label: 'Посидеть на остановке' }); } createStreetLamps() { const positions = this.mapConfig?.decorations?.lamps || [ [-20, -7], [0, -7], [20, -7], [-40, -7], [40, -7], [-20, 7], [0, 7], [20, 7], [40, 7], [-20, -44], [0, -44], [20, -44], [-40, -44], [-20, 48], [0, 48], [20, 48], [48, -20], [48, 20], [-58, -20], [-58, 20], ]; // Дороги для определения направления фонарей const lampRoads = this.mapConfig?.roads || []; positions.forEach(([x, z]) => { const group = new THREE.Group(); const poleMat = new THREE.MeshStandardMaterial({ color: 0x444444, metalness: 0.6 }); const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.12, 5, 8), poleMat); pole.position.y = 2.5; group.add(pole); // Горизонтальный кронштейн const arm = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.06, 0.06), poleMat); arm.position.set(0.75, 5, 0); group.add(arm); const lampGeo = new THREE.BoxGeometry(0.5, 0.15, 0.3); const lampMat = new THREE.MeshStandardMaterial({ color: 0xffffcc, emissive: 0xffdd88, emissiveIntensity: 0.5 }); const lamp = new THREE.Mesh(lampGeo, lampMat); lamp.position.set(1.5, 4.9, 0); group.add(lamp); const light = new THREE.PointLight(0xffdd88, 0.6, 18); light.position.set(1.5, 4.8, 0); group.add(light); // Поворот кронштейна к ближайшей дороге let minDist = Infinity; let rotY = 0; for (const rd of lampRoads) { const isEW = Math.abs(rd.rotation) < 0.1; let dist; if (isEW) { const xMin = rd.x - rd.width / 2; const xMax = rd.x + rd.width / 2; if (x < xMin - 5 || x > xMax + 5) continue; dist = Math.abs(z - rd.z); } else { const zMin = rd.z - rd.height / 2; const zMax = rd.z + rd.height / 2; if (z < zMin - 5 || z > zMax + 5) continue; dist = Math.abs(x - rd.x); } if (dist < minDist) { minDist = dist; const dx = isEW ? 0 : rd.x - x; const dz = isEW ? rd.z - z : 0; rotY = Math.atan2(dz, dx); } } group.position.set(x, 0, z); group.rotation.y = rotY; this.scene.add(group); }); } createDecorations() { // Мусор на земле (больше) const trashGeo = new THREE.BoxGeometry(0.3, 0.08, 0.2); const trashColors = [0x666666, 0x555544, 0x665544, 0x444455]; for (let i = 0; i < 60; i++) { const trashMat = new THREE.MeshStandardMaterial({ color: trashColors[Math.floor(Math.random() * trashColors.length)] }); const trash = new THREE.Mesh(trashGeo, trashMat); trash.position.set( (Math.random() - 0.5) * 120, 0.04, (Math.random() - 0.5) * 120 ); trash.rotation.y = Math.random() * Math.PI; trash.scale.set(0.5 + Math.random(), 1, 0.5 + Math.random()); this.scene.add(trash); } // Забор парка const parkCfg = this.mapConfig?.structures?.park || { x: -30, z: 25, radius: 18 }; const fenceMat = new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.5 }); for (let i = 0; i < 24; i++) { const angle = (i / 24) * Math.PI * 2; if (i >= 9 && i <= 12) continue; // Проход const fx = parkCfg.x + Math.cos(angle) * (parkCfg.radius + 1); const fz = parkCfg.z + Math.sin(angle) * (parkCfg.radius + 1); const post = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1, 6), fenceMat); post.position.set(fx, 0.5, fz); this.scene.add(post); // Горизонтальные перекладины if (i < 23) { const nextAngle = ((i + 1) / 24) * Math.PI * 2; const nx = parkCfg.x + Math.cos(nextAngle) * (parkCfg.radius + 1); const nz = parkCfg.z + Math.sin(nextAngle) * (parkCfg.radius + 1); const len = Math.sqrt((nx - fx) ** 2 + (nz - fz) ** 2); const bar = new THREE.Mesh(new THREE.BoxGeometry(len, 0.03, 0.03), fenceMat); bar.position.set((fx + nx) / 2, 0.7, (fz + nz) / 2); bar.rotation.y = Math.atan2(nx - fx, nz - fz) + Math.PI / 2; this.scene.add(bar); } } // Пожарные гидранты const hydrants = this.mapConfig?.decorations?.hydrants || [[-18,-9],[25,-9],[35,8]]; const hydrantMat = new THREE.MeshStandardMaterial({ color: 0xcc2222 }); hydrants.forEach(([hx, hz]) => { const hydrant = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.2, 0.7, 8), hydrantMat); hydrant.position.set(hx, 0.35, hz); this.scene.add(hydrant); }); // Урны const bins = this.mapConfig?.decorations?.bins || [[-8,-9],[8,-9],[25,9],[-25,9]]; const binMat = new THREE.MeshStandardMaterial({ color: 0x444444 }); bins.forEach(([bx, bz]) => { const bin = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.2, 0.8, 8), binMat); bin.position.set(bx, 0.4, bz); this.scene.add(bin); }); } createChurch() { const group = new THREE.Group(); const cfg = this.mapConfig?.structures?.church || {}; const cx = cfg.x ?? 30, cz = cfg.z ?? 60; const cw = cfg.w ?? 10, ch = cfg.h ?? 8, cd = cfg.d ?? 14; const hw = cw / 2, hd = cd / 2; const wallMat = new THREE.MeshStandardMaterial({ color: 0xccbb99, roughness: 0.8 }); // Основное здание const body = new THREE.Mesh(new THREE.BoxGeometry(cw, ch, cd), wallMat); body.position.y = ch / 2; body.castShadow = true; body.receiveShadow = true; group.add(body); // Крыша (двускатная) const roofGeo = new THREE.ConeGeometry(Math.max(hw, hd), 3, 4); const roofMat = new THREE.MeshStandardMaterial({ color: 0x554433 }); const roof = new THREE.Mesh(roofGeo, roofMat); roof.position.y = ch + 1.5; roof.rotation.y = Math.PI / 4; roof.castShadow = true; group.add(roof); // Колокольня const tower = new THREE.Mesh(new THREE.BoxGeometry(3, 6, 3), wallMat); tower.position.set(0, ch + 3, -hd + 3); tower.castShadow = true; group.add(tower); const towerRoof = new THREE.Mesh(new THREE.ConeGeometry(2.5, 3, 4), roofMat); towerRoof.position.set(0, ch + 7.5, -hd + 3); towerRoof.rotation.y = Math.PI / 4; group.add(towerRoof); // Крест const crossMat = new THREE.MeshStandardMaterial({ color: 0xffd700, metalness: 0.6 }); const crossV = new THREE.Mesh(new THREE.BoxGeometry(0.15, 1.2, 0.15), crossMat); crossV.position.set(0, ch + 9.6, -hd + 3); group.add(crossV); const crossH = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.15, 0.15), crossMat); crossH.position.set(0, ch + 9.9, -hd + 3); group.add(crossH); // Дверь const doorMat = new THREE.MeshStandardMaterial({ color: 0x5c3a1e }); const door = new THREE.Mesh(new THREE.BoxGeometry(2, 3.5, 0.15), doorMat); door.position.set(0, 1.75, hd + 0.05); group.add(door); // Окна-витражи const vitrageMat = new THREE.MeshStandardMaterial({ color: 0x4488cc, emissive: 0x223344, emissiveIntensity: 0.3, transparent: true, opacity: 0.7 }); [-hw * 0.6, hw * 0.6].forEach(wx => { const win = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 3), vitrageMat); win.position.set(wx, ch / 2, hd + 0.02); group.add(win); }); // Тёплый свет из окон const churchLight = new THREE.PointLight(0xffddaa, 0.4, 15); churchLight.position.set(0, 3, hd + 1); group.add(churchLight); group.position.set(cx, 0, cz); this.scene.add(group); this.colliders.push(new THREE.Box3( new THREE.Vector3(cx - hw, 0, cz - hd), new THREE.Vector3(cx + hw, ch + 6, cz + hd) )); this.buildingRects.push({ x: cx, z: cz, w: cw, d: cd }); this.interactables.push({ type: 'church', mesh: group, position: new THREE.Vector3(cx, 0, cz + hd + 1), radius: 3, label: 'Войти в церковь' }); } createConstructionSite() { const group = new THREE.Group(); const cfg = this.mapConfig?.structures?.construction || {}; const sx = cfg.x ?? 70, sz = cfg.z ?? 60; const siteRadius = cfg.radius ?? 12; // Забор строительной площадки const fenceMat = new THREE.MeshStandardMaterial({ color: 0xcc8833 }); for (let i = 0; i < 20; i++) { const angle = (i / 20) * Math.PI * 2; const fx = Math.cos(angle) * siteRadius; const fz = Math.sin(angle) * siteRadius; const post = new THREE.Mesh(new THREE.BoxGeometry(0.1, 2, 0.1), fenceMat); post.position.set(fx, 1, fz); group.add(post); if (i < 19 && !(i >= 15 && i <= 16)) { // Проход const next = ((i + 1) / 20) * Math.PI * 2; const nx = Math.cos(next) * 12; const nz = Math.sin(next) * 12; const len = Math.sqrt((nx - fx) ** 2 + (nz - fz) ** 2); const plank = new THREE.Mesh(new THREE.BoxGeometry(len, 1.5, 0.05), fenceMat); plank.position.set((fx + nx) / 2, 1, (fz + nz) / 2); plank.rotation.y = Math.atan2(nx - fx, nz - fz) + Math.PI / 2; group.add(plank); } } // Недостроенное здание (каркас) const concreteMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.9 }); // Колонны [[-4, -4], [4, -4], [-4, 4], [4, 4]].forEach(([px, pz]) => { const column = new THREE.Mesh(new THREE.BoxGeometry(0.6, 8, 0.6), concreteMat); column.position.set(px, 4, pz); column.castShadow = true; group.add(column); }); // Перекрытия const slab = new THREE.Mesh(new THREE.BoxGeometry(9, 0.3, 9), concreteMat); slab.position.y = 4; slab.castShadow = true; slab.receiveShadow = true; group.add(slab); const slab2 = new THREE.Mesh(new THREE.BoxGeometry(9, 0.3, 9), concreteMat); slab2.position.y = 8; slab2.castShadow = true; group.add(slab2); // Кран (упрощённый) const craneMat = new THREE.MeshStandardMaterial({ color: 0xddaa22 }); const craneBase = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.5, 18, 8), craneMat); craneBase.position.set(8, 9, 0); craneBase.castShadow = true; group.add(craneBase); const craneArm = new THREE.Mesh(new THREE.BoxGeometry(16, 0.4, 0.4), craneMat); craneArm.position.set(2, 18, 0); group.add(craneArm); // Строительные материалы const brickMat = new THREE.MeshStandardMaterial({ color: 0xaa5533 }); for (let i = 0; i < 3; i++) { const pile = new THREE.Mesh( new THREE.BoxGeometry(1.5, 0.6, 0.8), brickMat ); pile.position.set(-6 + i * 2.5, 0.3, -7); group.add(pile); } // Песчаная куча const sandMat = new THREE.MeshStandardMaterial({ color: 0xccaa66 }); const sand = new THREE.Mesh(new THREE.ConeGeometry(2, 1.5, 8), sandMat); sand.position.set(6, 0.75, -6); group.add(sand); group.position.set(sx, 0, sz); this.scene.add(group); this.colliders.push( new THREE.Box3(new THREE.Vector3(sx - 4.3, 0, sz - 4.3), new THREE.Vector3(sx - 3.7, 8, sz - 3.7)), new THREE.Box3(new THREE.Vector3(sx + 3.7, 0, sz - 4.3), new THREE.Vector3(sx + 4.3, 8, sz - 3.7)), new THREE.Box3(new THREE.Vector3(sx - 4.3, 0, sz + 3.7), new THREE.Vector3(sx - 3.7, 8, sz + 4.3)), new THREE.Box3(new THREE.Vector3(sx + 3.7, 0, sz + 3.7), new THREE.Vector3(sx + 4.3, 8, sz + 4.3)), ); this.buildingRects.push({ x: sx, z: sz, w: 9, d: 9 }); this.interactables.push({ type: 'dumpster', mesh: group, position: new THREE.Vector3(sx - 5, 0, sz - 7), radius: 3, label: 'Обыскать стройматериалы', searched: false, searchCooldown: 0 }); this.interactables.push({ type: 'shelter', mesh: group, position: new THREE.Vector3(sx, 0, sz), radius: 4, label: 'Поспать на стройке' }); } createJobBoard() { const cfg = this.mapConfig?.structures?.jobBoard || {}; const x = cfg.x ?? 20, z = cfg.z ?? -8; const group = new THREE.Group(); // Столб const poleMat = new THREE.MeshStandardMaterial({ color: 0x654321 }); const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2, 6), poleMat); pole.position.y = 1; pole.castShadow = true; group.add(pole); // Доска const boardMat = new THREE.MeshStandardMaterial({ color: 0xd4a060 }); const board = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.8, 0.05), boardMat); board.position.y = 1.6; board.castShadow = true; group.add(board); // Листки на доске const paperMat = new THREE.MeshStandardMaterial({ color: 0xeeeecc }); for (let i = 0; i < 3; i++) { const paper = new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.3, 0.01), paperMat); paper.position.set(-0.35 + i * 0.35, 1.6, 0.03); group.add(paper); } // Надпись (маленький квадрат сверху) const signMat = new THREE.MeshStandardMaterial({ color: 0x2e7d32 }); const sign = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.15, 0.06), signMat); sign.position.set(0, 2.05, 0); group.add(sign); group.position.set(x, 0, z); this.scene.add(group); this.interactables.push({ type: 'jobboard', mesh: group, position: new THREE.Vector3(x, 0, z), radius: 3, label: 'Доска объявлений (работа)' }); } createHospital() { const group = new THREE.Group(); const cfg = this.mapConfig?.structures?.hospital || {}; const hx = cfg.x ?? -45, hz = cfg.z ?? -55; const hw = cfg.w ?? 12, hh = cfg.h ?? 7, hd = cfg.d ?? 10; const halfW = hw / 2, halfD = hd / 2; const wallMat = new THREE.MeshStandardMaterial({ color: 0xdddddd, roughness: 0.7 }); const body = new THREE.Mesh(new THREE.BoxGeometry(hw, hh, hd), wallMat); body.position.y = hh / 2; body.castShadow = true; body.receiveShadow = true; group.add(body); // Крыша const roofMat = new THREE.MeshStandardMaterial({ color: 0x555555 }); const roof = new THREE.Mesh(new THREE.BoxGeometry(hw + 0.5, 0.3, hd + 0.5), roofMat); roof.position.y = hh; group.add(roof); // Красный крест const crossMat = new THREE.MeshStandardMaterial({ color: 0xcc2222, emissive: 0x881111, emissiveIntensity: 0.3 }); const crossV = new THREE.Mesh(new THREE.BoxGeometry(0.6, 2.5, 0.1), crossMat); crossV.position.set(0, hh - 1.5, halfD + 0.06); group.add(crossV); const crossH = new THREE.Mesh(new THREE.BoxGeometry(2, 0.6, 0.1), crossMat); crossH.position.set(0, hh - 1.5, halfD + 0.06); group.add(crossH); // Дверь const doorMat = new THREE.MeshStandardMaterial({ color: 0x88ccff, transparent: true, opacity: 0.6 }); const door = new THREE.Mesh(new THREE.BoxGeometry(2.5, 3, 0.1), doorMat); door.position.set(0, 1.5, halfD + 0.05); group.add(door); // Свет у входа const light = new THREE.PointLight(0xffffff, 0.5, 10); light.position.set(0, 4, halfD + 1); group.add(light); group.position.set(hx, 0, hz); this.scene.add(group); this.colliders.push(new THREE.Box3( new THREE.Vector3(hx - halfW, 0, hz - halfD), new THREE.Vector3(hx + halfW, hh, hz + halfD) )); this.buildingRects.push({ x: hx, z: hz, w: hw, d: hd }); this.interactables.push({ type: 'hospital', mesh: group, position: new THREE.Vector3(hx, 0, hz + halfD + 1), radius: 3, label: 'Войти в больницу' }); } createMarket() { const group = new THREE.Group(); const cfg = this.mapConfig?.structures?.market || {}; const mx = cfg.x ?? 35, mz = cfg.z ?? -55; const mw = cfg.w ?? 14, mh = cfg.h ?? 4, md = cfg.d ?? 10; const hw = mw / 2, hd = md / 2; // Основное здание рынка const wallMat = new THREE.MeshStandardMaterial({ color: 0x8b6914, roughness: 0.8 }); const body = new THREE.Mesh(new THREE.BoxGeometry(mw, mh, md), wallMat); body.position.y = mh / 2; body.castShadow = true; group.add(body); // Навес const awningMat = new THREE.MeshStandardMaterial({ color: 0xcc6633 }); const awning = new THREE.Mesh(new THREE.BoxGeometry(mw + 2, 0.1, 3), awningMat); awning.position.set(0, mh - 0.5, hd + 1); group.add(awning); // Столбы навеса const poleMat = new THREE.MeshStandardMaterial({ color: 0x654321 }); [-hw, 0, hw].forEach(px => { const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, mh - 0.5, 6), poleMat); pole.position.set(px, (mh - 0.5) / 2, hd + 2.2); group.add(pole); }); // Прилавки const counterMat = new THREE.MeshStandardMaterial({ color: 0xaa8855 }); const counterSpacing = mw / 3; [-counterSpacing, 0, counterSpacing].forEach(cx => { const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1, 1.5), counterMat); counter.position.set(cx, 0.5, hd + 0.5); group.add(counter); }); // Товары на прилавках (цветные кубики) const colors = [0xff4444, 0x44ff44, 0xffff44, 0xff8844, 0x44aaff]; for (let i = 0; i < 15; i++) { const item = new THREE.Mesh( new THREE.BoxGeometry(0.3, 0.3, 0.3), new THREE.MeshStandardMaterial({ color: colors[Math.floor(Math.random() * colors.length)] }) ); item.position.set( -hw * 0.7 + Math.random() * mw * 0.7, 1.15, hd + 0.2 + Math.random() * 0.5 ); group.add(item); } // Вывеска const signMat = new THREE.MeshStandardMaterial({ color: 0xdd8833, emissive: 0x553311, emissiveIntensity: 0.4 }); const sign = new THREE.Mesh(new THREE.BoxGeometry(mw * 0.4, 1, 0.15), signMat); sign.position.set(0, mh + 0.2, hd + 0.1); group.add(sign); group.position.set(mx, 0, mz); this.scene.add(group); this.colliders.push(new THREE.Box3( new THREE.Vector3(mx - hw, 0, mz - hd), new THREE.Vector3(mx + hw, mh, mz + hd) )); this.buildingRects.push({ x: mx, z: mz, w: mw, d: md }); this.interactables.push({ type: 'market', mesh: group, position: new THREE.Vector3(mx, 0, mz + hd + 1), radius: 4, label: 'Рынок — купить экипировку' }); } createCampSpot() { // Место для лагеря игрока (если ещё не построен) const cfg = this.mapConfig?.structures?.campSpot || {}; const cx = cfg.x ?? -20, cz = cfg.z ?? 38; this.interactables.push({ type: 'camp_spot', position: new THREE.Vector3(cx, 0, cz), radius: 3, label: 'Место для лагеря' }); } // Движущиеся машины на дорогах // Загрузка 3D-модели машины loadCarModel() { const loader = new GLTFLoader(); loader.load('textures/Mercedes E-Class W210 (1997).glb', (gltf) => { const model = gltf.scene; // Вычисляем размеры модели const box = new THREE.Box3().setFromObject(model); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); // Определяем самую длинную ось (длина машины) const maxDim = Math.max(size.x, size.y, size.z); const targetLength = 4.2; // длина машины в игровых единицах const s = targetLength / maxDim; // Создаём обёртку для центрирования const wrapper = new THREE.Group(); model.scale.set(s, s, s); // Центрируем модель: X/Z по центру, Y — дно на уровне 0 model.position.set( -center.x * s, -box.min.y * s, -center.z * s ); // Тени model.traverse((child) => { if (child.isMesh) { child.castShadow = true; child.receiveShadow = true; } }); wrapper.add(model); // Определяем ориентацию: если модель длиннее по X, поворачиваем на 90° // чтобы «нос» смотрел в +Z (по умолчанию в нашей системе) if (size.x > size.z) { this.carModelBaseRotation = Math.PI / 2; } this.carModel = wrapper; console.log(`Car model loaded: ${size.x.toFixed(1)}x${size.y.toFixed(1)}x${size.z.toFixed(1)} → scaled ${s.toFixed(3)}`); // Заменяем все процедурные машины на модель this.replaceVehicleMeshes(); }, (progress) => { if (progress.total > 0) { const pct = Math.round(progress.loaded / progress.total * 100); if (pct % 25 === 0) console.log(`Loading car model: ${pct}%`); } }, (error) => { console.warn('Car model failed to load, keeping procedural cars:', error); }); } // Заменить все машины на 3D-модель replaceVehicleMeshes() { if (!this.carModel) return; // Движущиеся машины if (this.vehicles) { this.vehicles.forEach(v => { const old = v.mesh; const pos = old.position.clone(); const visible = old.visible; // Пересчитываем rotation с учётом базового поворота модели const r = v.route; let rotY; if (r.axis === 'x') { rotY = (r.dir > 0 ? Math.PI / 2 : -Math.PI / 2) + this.carModelBaseRotation; } else { rotY = (r.dir > 0 ? 0 : Math.PI) + this.carModelBaseRotation; } this.scene.remove(old); const newCar = this.carModel.clone(); newCar.position.copy(pos); newCar.rotation.y = rotY; newCar.visible = visible; this.scene.add(newCar); v.mesh = newCar; v.baseRotation = this.carModelBaseRotation; }); } // Припаркованные машины if (this.parkedCars) { this.parkedCars.forEach(p => { this.scene.remove(p.mesh); const newCar = this.carModel.clone(); newCar.position.set(p.x, 0, p.z); newCar.rotation.y = p.rotY + this.carModelBaseRotation; this.scene.add(newCar); p.mesh = newCar; }); } } createVehicles() { this.vehicles = []; // Маршруты строго по дорогам const routes = this.mapConfig?.vehicles?.routes || [ { axis: 'x', lane: -3, start: -130, end: 130, dir: 1 }, { axis: 'x', lane: 3, start: 130, end: -130, dir: -1 }, { axis: 'x', lane: -42, start: -130, end: 130, dir: 1 }, { axis: 'x', lane: -38, start: 130, end: -130, dir: -1 }, { axis: 'z', lane: -3, start: -130, end: 130, dir: 1 }, { axis: 'z', lane: 3, start: 130, end: -130, dir: -1 }, { axis: 'z', lane: 47, start: -45, end: 45, dir: 1 }, { axis: 'z', lane: 53, start: 45, end: -45, dir: -1 }, { axis: 'z', lane: -63, start: -45, end: 45, dir: 1 }, { axis: 'z', lane: -57, start: 45, end: -45, dir: -1 }, ]; const carColors = [0xcc2222, 0x2255cc, 0x22cc22, 0x888888, 0xdddd22, 0xcc8822, 0xffffff, 0x222222]; for (let i = 0; i < 12; i++) { const route = routes[i % routes.length]; const color = carColors[Math.floor(Math.random() * carColors.length)]; // Используем загруженную модель или процедурный fallback const car = this.carModel ? this.carModel.clone() : this.createCarMeshFallback(color); // Начальная позиция const offset = -i * 35 - Math.random() * 20; const baseRot = this.carModel ? this.carModelBaseRotation : 0; if (route.axis === 'x') { const startX = route.dir > 0 ? route.start + offset : route.end - offset; car.position.set(startX, 0, route.lane); car.rotation.y = (route.dir > 0 ? Math.PI / 2 : -Math.PI / 2) + baseRot; } else { const startZ = route.dir > 0 ? route.start + offset : route.end - offset; car.position.set(route.lane, 0, startZ); car.rotation.y = (route.dir > 0 ? 0 : Math.PI) + baseRot; } this.scene.add(car); // Динамический коллайдер для движущейся машины const collider = new THREE.Box3(); this.vehicleColliders.push(collider); this.vehicles.push({ mesh: car, route, speed: 8 + Math.random() * 6, delay: i * 4 + Math.random() * 8, baseRotation: baseRot, collider, }); } // Если модель ещё не загружена, начинаем загрузку if (!this.carModel) { this.loadCarModel(); } } // Процедурная машина (fallback пока модель грузится) createCarMeshFallback(color) { const car = new THREE.Group(); const carBody = new THREE.Mesh( new THREE.BoxGeometry(2, 1, 3.5), new THREE.MeshStandardMaterial({ color, metalness: 0.4 }) ); carBody.position.y = 0.7; carBody.castShadow = true; car.add(carBody); const cabin = new THREE.Mesh( new THREE.BoxGeometry(1.6, 0.7, 1.8), new THREE.MeshStandardMaterial({ color: 0x88bbcc, transparent: true, opacity: 0.5 }) ); cabin.position.y = 1.45; car.add(cabin); const wheelMat = new THREE.MeshStandardMaterial({ color: 0x222222 }); [[-0.9, -1.1], [-0.9, 1.1], [0.9, -1.1], [0.9, 1.1]].forEach(([wx, wz]) => { const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.25, 0.15, 8), wheelMat); wheel.rotation.z = Math.PI / 2; wheel.position.set(wx, 0.25, wz); car.add(wheel); }); const headlightMat = new THREE.MeshStandardMaterial({ color: 0xffffcc, emissive: 0xffffcc, emissiveIntensity: 0.5 }); [-0.6, 0.6].forEach(lx => { const headlight = new THREE.Mesh(new THREE.SphereGeometry(0.1, 6, 4), headlightMat); headlight.position.set(lx, 0.6, 1.8); car.add(headlight); }); return car; } updateVehicles(dt) { if (!this.vehicles) return; // Половинные размеры машины для коллайдера const hw = 1.0, hh = 1.5, hl = 2.0; // width/2, height, length/2 this.vehicles.forEach(v => { if (v.delay > 0) { v.delay -= dt; v.mesh.visible = false; // Убираем коллайдер пока машина невидима v.collider.makeEmpty(); return; } v.mesh.visible = true; const pos = v.mesh.position; const r = v.route; if (r.axis === 'x') { pos.x += r.dir * v.speed * dt; if (r.dir > 0 && pos.x > 135) { pos.x = -135; v.delay = 3 + Math.random() * 8; } else if (r.dir < 0 && pos.x < -135) { pos.x = 135; v.delay = 3 + Math.random() * 8; } // Коллайдер: машина едет по X — длинная сторона вдоль X v.collider.set( new THREE.Vector3(pos.x - hl, 0, pos.z - hw), new THREE.Vector3(pos.x + hl, hh, pos.z + hw) ); } else { pos.z += r.dir * v.speed * dt; if (r.dir > 0 && pos.z > 135) { pos.z = -135; v.delay = 3 + Math.random() * 8; } else if (r.dir < 0 && pos.z < -135) { pos.z = 135; v.delay = 3 + Math.random() * 8; } // Коллайдер: машина едет по Z — длинная сторона вдоль Z v.collider.set( new THREE.Vector3(pos.x - hw, 0, pos.z - hl), new THREE.Vector3(pos.x + hw, hh, pos.z + hl) ); } }); } }