Files
Hommie_RPG_Game/js/game/World.js
Maxim Dolgolyov fb5f09212b Initial commit: 3D Hommie RPG game
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:04:09 +03:00

1792 lines
70 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
);
}
});
}
}