1792 lines
70 KiB
JavaScript
1792 lines
70 KiB
JavaScript
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)
|
||
);
|
||
}
|
||
});
|
||
}
|
||
}
|