510 lines
23 KiB
JavaScript
510 lines
23 KiB
JavaScript
import * as THREE from 'three';
|
||
|
||
class NPC {
|
||
constructor(name, position, type, color, dialogues) {
|
||
this.name = name;
|
||
this.position = position.clone();
|
||
this.type = type;
|
||
this.color = color;
|
||
this.dialogues = dialogues;
|
||
this.mesh = null;
|
||
this.dialogIndex = 0;
|
||
this.talked = false;
|
||
|
||
// Патрулирование
|
||
this.waypoints = [];
|
||
this.currentWaypoint = 0;
|
||
this.speed = 1.5;
|
||
this.waitTimer = 0;
|
||
this.isWaiting = false;
|
||
}
|
||
|
||
createMesh(scene) {
|
||
const group = new THREE.Group();
|
||
|
||
// Тело
|
||
const bodyGeo = new THREE.CylinderGeometry(0.3, 0.35, 1.1, 8);
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color: this.color });
|
||
const body = new THREE.Mesh(bodyGeo, bodyMat);
|
||
body.position.y = 0.85;
|
||
body.castShadow = true;
|
||
group.add(body);
|
||
|
||
// Голова
|
||
const headGeo = new THREE.SphereGeometry(0.22, 8, 6);
|
||
const headMat = new THREE.MeshStandardMaterial({ color: 0xd4a574 });
|
||
const head = new THREE.Mesh(headGeo, headMat);
|
||
head.position.y = 1.55;
|
||
head.castShadow = true;
|
||
group.add(head);
|
||
|
||
// Руки
|
||
const armMat = new THREE.MeshStandardMaterial({ color: this.color });
|
||
[-0.4, 0.4].forEach(side => {
|
||
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 0.7, 6), armMat);
|
||
arm.position.set(side, 0.75, 0);
|
||
arm.rotation.z = side > 0 ? -0.15 : 0.15;
|
||
arm.castShadow = true;
|
||
group.add(arm);
|
||
});
|
||
|
||
// Ноги
|
||
const legMat = new THREE.MeshStandardMaterial({ color: 0x333344 });
|
||
[-0.14, 0.14].forEach(side => {
|
||
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.09, 0.55, 6), legMat);
|
||
leg.position.set(side, 0.28, 0);
|
||
leg.castShadow = true;
|
||
group.add(leg);
|
||
});
|
||
|
||
// Доп. элемент по типу NPC
|
||
if (this.type === 'hobo') {
|
||
const hatGeo = new THREE.CylinderGeometry(0.2, 0.26, 0.15, 8);
|
||
const hatMat = new THREE.MeshStandardMaterial({ color: 0x444433 });
|
||
const hat = new THREE.Mesh(hatGeo, hatMat);
|
||
hat.position.y = 1.75;
|
||
group.add(hat);
|
||
} else if (this.type === 'citizen') {
|
||
const bagGeo = new THREE.BoxGeometry(0.25, 0.35, 0.15);
|
||
const bagMat = new THREE.MeshStandardMaterial({ color: 0x222222 });
|
||
const bag = new THREE.Mesh(bagGeo, bagMat);
|
||
bag.position.set(0.35, 0.8, 0);
|
||
group.add(bag);
|
||
}
|
||
|
||
group.position.copy(this.position);
|
||
scene.add(group);
|
||
this.mesh = group;
|
||
}
|
||
|
||
setPatrol(points) {
|
||
this.waypoints = points.map(p => new THREE.Vector3(p[0], 0, p[1]));
|
||
}
|
||
|
||
update(dt) {
|
||
if (this.waypoints.length === 0) return;
|
||
|
||
if (this.isWaiting) {
|
||
this.waitTimer -= dt;
|
||
if (this.waitTimer <= 0) this.isWaiting = false;
|
||
return;
|
||
}
|
||
|
||
const target = this.waypoints[this.currentWaypoint];
|
||
const dir = new THREE.Vector3().subVectors(target, this.position);
|
||
dir.y = 0;
|
||
const dist = dir.length();
|
||
|
||
if (dist < 0.5) {
|
||
this.currentWaypoint = (this.currentWaypoint + 1) % this.waypoints.length;
|
||
this.isWaiting = true;
|
||
this.waitTimer = 2 + Math.random() * 3;
|
||
return;
|
||
}
|
||
|
||
dir.normalize();
|
||
this.position.add(dir.multiplyScalar(this.speed * dt));
|
||
this.mesh.position.copy(this.position);
|
||
|
||
// Поворот к цели
|
||
this.mesh.rotation.y = Math.atan2(dir.x, dir.z);
|
||
}
|
||
|
||
getDialogue() {
|
||
const dialog = this.dialogues[this.dialogIndex];
|
||
if (this.dialogIndex < this.dialogues.length - 1) {
|
||
this.dialogIndex++;
|
||
}
|
||
this.talked = true;
|
||
return dialog;
|
||
}
|
||
}
|
||
|
||
export class NPCManager {
|
||
constructor(game) {
|
||
this.game = game;
|
||
this.npcs = [];
|
||
}
|
||
|
||
spawnNPCs() {
|
||
this.npcs = [];
|
||
|
||
const configNPCs = this.game.world.mapConfig?.npcs;
|
||
|
||
// Бомж-друг Серёга
|
||
const seregaCfg = configNPCs?.find(n => n.name === 'Серёга') || {};
|
||
const serega = new NPC(
|
||
'Серёга',
|
||
new THREE.Vector3(seregaCfg.x ?? -25, 0, seregaCfg.z ?? 30),
|
||
'hobo',
|
||
parseInt((seregaCfg.color || '#5a4a3a').replace('#',''), 16),
|
||
[
|
||
{
|
||
text: 'Здарова, братан! Ты новенький тут? Я Серёга. Слушай, совет: обыскивай мусорки — там можно найти бутылки. Сдашь их в магазине за деньги.',
|
||
choices: [
|
||
{ text: 'Спасибо за совет!', effect: () => this.game.player.stats.mood += 5 },
|
||
{ text: 'А где магазин?', effect: () => this.game.notify('Серёга показывает в сторону магазина на востоке.') }
|
||
]
|
||
},
|
||
{
|
||
text: 'Ночью тут холодно, бро. Грейся у костра в укрытии, а то замёрзнешь. И не забывай есть — голод тут настоящий убийца.',
|
||
choices: [
|
||
{ text: 'Понял, буду осторожен.', effect: () => {} },
|
||
{ text: 'Может, вместе пойдём?', effect: () => this.game.notify('Серёга: "Нет, бро, я тут присмотрю за укрытием."') }
|
||
]
|
||
},
|
||
{
|
||
text: 'Как дела, братан? Держись! Главное — не сдаваться. Я вот уже 3 года тут живу, и ничего, справляюсь.',
|
||
choices: [
|
||
{ text: 'И ты держись, Серёга!', effect: () => { this.game.player.stats.mood += 3; } }
|
||
]
|
||
}
|
||
]
|
||
);
|
||
serega.setPatrol(seregaCfg.patrol || [[-25, 30], [-30, 28], [-25, 22], [-20, 28]]);
|
||
|
||
// Прохожий
|
||
const passerbyCfg = configNPCs?.find(n => n.name === 'Прохожий') || {};
|
||
const passerby = new NPC(
|
||
'Прохожий',
|
||
new THREE.Vector3(passerbyCfg.x ?? 5, 0, passerbyCfg.z ?? -8),
|
||
'citizen',
|
||
parseInt((passerbyCfg.color || '#3355aa').replace('#',''), 16),
|
||
[
|
||
{
|
||
text: 'Чего тебе? А, бездомный... Ладно, вот тебе мелочь. Не пропей.',
|
||
choices: [
|
||
{ text: 'Спасибо, добрый человек!', effect: () => { this.game.player.stats.money += 15; this.game.player.stats.mood += 5; this.game.notify('+15 ₽'); } },
|
||
{ text: 'Мне не нужна подачка!', effect: () => { this.game.player.stats.mood += 2; this.game.notify('Вы сохранили достоинство.'); } }
|
||
]
|
||
},
|
||
{
|
||
text: 'Опять ты... Слушай, мне некогда. Иди уже.',
|
||
choices: [
|
||
{ text: '[Уйти]', effect: () => {} }
|
||
]
|
||
}
|
||
]
|
||
);
|
||
passerby.setPatrol(passerbyCfg.patrol || [[5, -8], [15, -8], [25, -8], [15, -8]]);
|
||
|
||
// Бабушка
|
||
const grannyCfg = configNPCs?.find(n => n.name === 'Бабушка Зина') || {};
|
||
const granny = new NPC(
|
||
'Бабушка Зина',
|
||
new THREE.Vector3(grannyCfg.x ?? -28, 0, grannyCfg.z ?? 22),
|
||
'citizen',
|
||
parseInt((grannyCfg.color || '#886655').replace('#',''), 16),
|
||
[
|
||
{
|
||
text: 'Ох, сынок, жалко тебя... На вот, возьми хлебушек. Кушай, не стесняйся.',
|
||
choices: [
|
||
{ text: 'Спасибо огромное!', effect: () => { this.game.inventory.addItem('bread', 2); this.game.player.stats.mood += 10; this.game.notify('Получено: Хлеб x2'); } },
|
||
{ text: 'Благодарю, бабушка!', effect: () => { this.game.inventory.addItem('bread', 2); this.game.player.stats.mood += 10; this.game.notify('Получено: Хлеб x2'); } }
|
||
]
|
||
},
|
||
{
|
||
text: 'А я вот каждый день в парке гуляю. Свежий воздух полезен. Ты тоже береги здоровье, сынок.',
|
||
choices: [
|
||
{ text: 'Буду стараться, спасибо!', effect: () => { this.game.player.stats.mood += 3; } }
|
||
]
|
||
},
|
||
{
|
||
text: 'Ты ещё тут, сынок? Держись. Вот, возьми ещё покушать.',
|
||
choices: [
|
||
{ text: 'Спасибо, бабушка Зина!', effect: () => { this.game.inventory.addItem('can', 1); this.game.notify('Получено: Консервы'); } }
|
||
]
|
||
}
|
||
]
|
||
);
|
||
granny.setPatrol(grannyCfg.patrol || [[-28, 22], [-32, 26], [-28, 30], [-24, 26]]);
|
||
|
||
// Охранник магазина
|
||
const guardCfg = configNPCs?.find(n => n.name === 'Охранник') || {};
|
||
const guard = new NPC(
|
||
'Охранник',
|
||
new THREE.Vector3(guardCfg.x ?? -20, 0, guardCfg.z ?? -14),
|
||
'citizen',
|
||
parseInt((guardCfg.color || '#222222').replace('#',''), 16),
|
||
[
|
||
{
|
||
text: 'Стой! В магазин можно, но только без фокусов. Украдёшь что — пожалеешь.',
|
||
choices: [
|
||
{ text: 'Я просто хочу купить.', effect: () => {} },
|
||
{ text: '[Кивнуть и пройти]', effect: () => {} }
|
||
]
|
||
},
|
||
{
|
||
text: 'Ну что, опять ты. Давай без проблем.',
|
||
choices: [
|
||
{ text: '[Кивнуть]', effect: () => {} }
|
||
]
|
||
}
|
||
]
|
||
);
|
||
|
||
// Батюшка у церкви
|
||
const priestCfg = configNPCs?.find(n => n.name === 'Отец Михаил') || {};
|
||
const priest = new NPC(
|
||
'Отец Михаил',
|
||
new THREE.Vector3(priestCfg.x ?? 30, 0, priestCfg.z ?? 58),
|
||
'citizen',
|
||
parseInt((priestCfg.color || '#222244').replace('#',''), 16),
|
||
[
|
||
{
|
||
text: 'Мир тебе, сын мой. Ты выглядишь усталым. Заходи в церковь — отдохнёшь в тепле. У нас всегда найдётся горячий чай и хлеб.',
|
||
choices: [
|
||
{ text: 'Спасибо, отец Михаил.', effect: () => { this.game.player.stats.mood += 10; this.game.inventory.addItem('tea', 1); this.game.notify('Получено: Чай', 'good'); } },
|
||
{ text: 'Мне ничего не нужно.', effect: () => {} }
|
||
]
|
||
},
|
||
{
|
||
text: 'Каждый человек заслуживает второй шанс. Не теряй надежды. Приходи, когда будет тяжело — двери всегда открыты.',
|
||
choices: [
|
||
{ text: 'Я буду помнить.', effect: () => { this.game.player.stats.mood += 8; } }
|
||
]
|
||
},
|
||
{
|
||
text: 'Я вижу в тебе силу духа. Держись, и всё наладится. Вот, возьми — это поможет.',
|
||
choices: [
|
||
{ text: 'Спасибо огромное!', effect: () => { this.game.inventory.addItem('bandage', 1); this.game.inventory.addItem('bread', 2); this.game.player.stats.mood += 15; this.game.notify('Получено: Бинт, Хлеб x2', 'good'); } }
|
||
]
|
||
}
|
||
]
|
||
);
|
||
priest.setPatrol(priestCfg.patrol || [[30, 58], [32, 55], [28, 55], [30, 58]]);
|
||
|
||
// Бомж на стройке
|
||
const builderCfg = configNPCs?.find(n => n.name === 'Михалыч') || {};
|
||
const builder = new NPC(
|
||
'Михалыч',
|
||
new THREE.Vector3(builderCfg.x ?? 70, 0, builderCfg.z ?? 58),
|
||
'hobo',
|
||
parseInt((builderCfg.color || '#5a5a3a').replace('#',''), 16),
|
||
[
|
||
{
|
||
text: 'О, живой человек! Я тут на стройке обосновался — тепло, сухо, и никто не гонит. Ты тоже можешь переночевать тут.',
|
||
choices: [
|
||
{ text: 'Спасибо за совет!', effect: () => { this.game.player.stats.mood += 5; } },
|
||
{ text: 'А тут безопасно?', effect: () => { this.game.notify('Михалыч: "Ну... потолок не обвалится, думаю."'); } }
|
||
]
|
||
},
|
||
{
|
||
text: 'Знаешь, я раньше строителем работал. Ирония, да? Теперь на чужой стройке живу. Но навыки пригодились — я себе тут угол обустроил.',
|
||
choices: [
|
||
{ text: 'Жизнь бывает несправедлива.', effect: () => { this.game.player.stats.mood += 3; } },
|
||
{ text: 'Может, ещё устроишься?', effect: () => { this.game.notify('Михалыч вздыхает: "Может быть..."'); this.game.player.stats.mood += 2; } }
|
||
]
|
||
},
|
||
{
|
||
text: 'На вот, нашёл тут кое-что полезное. Бери, мне не нужно.',
|
||
choices: [
|
||
{ text: 'Спасибо, Михалыч!', effect: () => { this.game.inventory.addItem('scrap', 2); this.game.inventory.addItem('rope', 1); this.game.notify('Получено: Хлам x2, Верёвка', 'good'); } }
|
||
]
|
||
}
|
||
]
|
||
);
|
||
builder.setPatrol(builderCfg.patrol || [[70, 58], [73, 60], [67, 62], [70, 58]]);
|
||
|
||
this.npcs = [serega, passerby, granny, guard, priest, builder];
|
||
|
||
this.npcs.forEach(npc => {
|
||
npc.createMesh(this.game.scene);
|
||
});
|
||
}
|
||
|
||
spawnPassersby() {
|
||
this.passersby = [];
|
||
const colors = [0x3355aa, 0xaa3355, 0x33aa55, 0x555555, 0x8855aa, 0xaa8833, 0x338888, 0x885533];
|
||
|
||
// Маршруты по тротуарам всех дорог
|
||
const configRoutes = this.game.world.mapConfig?.passerbyRoutes;
|
||
const sidewalkRoutes = configRoutes || [
|
||
// EW Главная — северный тротуар (z=8) — на восток
|
||
{ waypoints: [[-80, 8], [-40, 8], [0, 8], [30, 8], [60, 8], [80, 8]] },
|
||
// EW Главная — южный тротуар (z=-8) — на запад
|
||
{ waypoints: [[80, -8], [40, -8], [0, -8], [-30, -8], [-60, -8], [-80, -8]] },
|
||
// EW Южная — тротуар z=-35 — на восток
|
||
{ waypoints: [[-80, -35], [-30, -35], [20, -35], [60, -35], [80, -35]] },
|
||
// EW Южная — тротуар z=-45 — на запад
|
||
{ waypoints: [[80, -45], [40, -45], [0, -45], [-40, -45], [-80, -45]] },
|
||
// EW Северная — тротуар z=46 — на восток
|
||
{ waypoints: [[-70, 46], [-30, 46], [10, 46], [40, 46], [70, 46]] },
|
||
// EW Северная — тротуар z=54 — на запад
|
||
{ waypoints: [[70, 54], [30, 54], [0, 54], [-30, 54], [-70, 54]] },
|
||
// NS Главная — тротуар x=8 — на север
|
||
{ waypoints: [[8, -60], [8, -35], [8, -8], [8, 8], [8, 30], [8, 60]] },
|
||
// NS Главная — тротуар x=-8 — на юг
|
||
{ waypoints: [[-8, 60], [-8, 30], [-8, 8], [-8, -8], [-8, -35], [-8, -60]] },
|
||
// NS Восточная — тротуар x=46 — на север
|
||
{ waypoints: [[46, -45], [46, -20], [46, 0], [46, 25], [46, 45]] },
|
||
// NS Западная — тротуар x=-56 — на юг
|
||
{ waypoints: [[-56, 45], [-56, 25], [-56, 0], [-56, -20], [-56, -45]] },
|
||
];
|
||
|
||
for (let i = 0; i < 10; i++) {
|
||
const routeData = sidewalkRoutes[i % sidewalkRoutes.length];
|
||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||
const wps = routeData.waypoints;
|
||
const pb = {
|
||
mesh: null,
|
||
position: new THREE.Vector3(wps[0][0], 0, wps[0][1]),
|
||
waypoints: wps.map(w => new THREE.Vector3(w[0], 0, w[1])),
|
||
currentWP: 0,
|
||
speed: 1.5 + Math.random() * 1.0,
|
||
color,
|
||
delay: i * 6 + Math.random() * 10,
|
||
waitTimer: 0,
|
||
};
|
||
|
||
const group = new THREE.Group();
|
||
const bodyMat = new THREE.MeshStandardMaterial({ color });
|
||
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.3, 1, 8), bodyMat);
|
||
body.position.y = 0.8;
|
||
body.castShadow = true;
|
||
group.add(body);
|
||
|
||
const head = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.18, 8, 6),
|
||
new THREE.MeshStandardMaterial({ color: 0xd4a574 })
|
||
);
|
||
head.position.y = 1.45;
|
||
group.add(head);
|
||
|
||
// Руки
|
||
const armMat = new THREE.MeshStandardMaterial({ color });
|
||
[-0.35, 0.35].forEach(side => {
|
||
const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.6, 6), armMat);
|
||
arm.position.set(side, 0.65, 0);
|
||
arm.rotation.z = side > 0 ? -0.15 : 0.15;
|
||
arm.castShadow = true;
|
||
group.add(arm);
|
||
});
|
||
|
||
// Ноги
|
||
const legMat = new THREE.MeshStandardMaterial({ color: 0x333344 });
|
||
[-0.12, 0.12].forEach(side => {
|
||
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, 0.5, 6), legMat);
|
||
leg.position.set(side, 0.25, 0);
|
||
leg.castShadow = true;
|
||
group.add(leg);
|
||
});
|
||
|
||
group.position.copy(pb.position);
|
||
group.visible = false;
|
||
this.game.scene.add(group);
|
||
pb.mesh = group;
|
||
|
||
this.passersby.push(pb);
|
||
}
|
||
}
|
||
|
||
update(dt) {
|
||
this.npcs.forEach(npc => npc.update(dt));
|
||
|
||
// Прохожие — ходят по путевым точкам вдоль тротуаров
|
||
if (this.passersby) {
|
||
this.passersby.forEach(pb => {
|
||
if (pb.delay > 0) {
|
||
pb.delay -= dt;
|
||
return;
|
||
}
|
||
pb.mesh.visible = true;
|
||
|
||
// Ожидание на точке
|
||
if (pb.waitTimer > 0) {
|
||
pb.waitTimer -= dt;
|
||
return;
|
||
}
|
||
|
||
const target = pb.waypoints[pb.currentWP];
|
||
const dir = new THREE.Vector3().subVectors(target, pb.position);
|
||
dir.y = 0;
|
||
const dist = dir.length();
|
||
|
||
if (dist < 1) {
|
||
pb.currentWP++;
|
||
// Иногда останавливаемся на точке
|
||
if (Math.random() < 0.3) {
|
||
pb.waitTimer = 1 + Math.random() * 3;
|
||
}
|
||
if (pb.currentWP >= pb.waypoints.length) {
|
||
// Прошёл маршрут — телепортируем на старт
|
||
pb.currentWP = 0;
|
||
pb.position.copy(pb.waypoints[0]);
|
||
pb.mesh.position.copy(pb.position);
|
||
pb.delay = 8 + Math.random() * 15;
|
||
pb.mesh.visible = false;
|
||
}
|
||
return;
|
||
}
|
||
|
||
dir.normalize();
|
||
pb.position.add(dir.multiplyScalar(pb.speed * dt));
|
||
pb.mesh.position.copy(pb.position);
|
||
pb.mesh.rotation.y = Math.atan2(dir.x, dir.z);
|
||
});
|
||
}
|
||
|
||
// Кулдауны мусорок
|
||
this.game.world.interactables.forEach(obj => {
|
||
if (obj.searchCooldown > 0) {
|
||
obj.searchCooldown -= this.game.timeSpeed * dt;
|
||
}
|
||
});
|
||
}
|
||
|
||
talkTo(npc) {
|
||
const dialog = npc.getDialogue();
|
||
const rep = this.game.reputation.value;
|
||
|
||
// Репутация влияет на текст
|
||
let extraText = '';
|
||
if (rep >= 50 && npc.type === 'citizen') {
|
||
extraText = '\n(Относится к вам с уважением)';
|
||
} else if (rep <= -30 && npc.type === 'citizen') {
|
||
extraText = '\n(Смотрит с подозрением)';
|
||
}
|
||
|
||
const choices = dialog.choices.map(c => c.text);
|
||
|
||
// Бонусная опция при высокой репутации
|
||
if (rep >= 40 && npc.type === 'citizen' && !npc._repBonusGiven) {
|
||
choices.push('[Репутация] Попросить помощь');
|
||
}
|
||
|
||
this.game.sound.playDialogOpen();
|
||
this.game.ui.showDialog(npc.name, dialog.text + extraText, choices, (index) => {
|
||
if (index < dialog.choices.length) {
|
||
dialog.choices[index].effect();
|
||
} else if (rep >= 40 && !npc._repBonusGiven) {
|
||
npc._repBonusGiven = true;
|
||
const roll = Math.random();
|
||
if (roll < 0.5) {
|
||
const amount = 30 + Math.floor(Math.random() * 40);
|
||
this.game.player.stats.money += amount;
|
||
this.game.sound.playCoin();
|
||
this.game.notify(`${npc.name} дал вам ${amount}₽!`, 'good');
|
||
} else {
|
||
this.game.inventory.addItem('bread', 1);
|
||
this.game.inventory.addItem('tea', 1);
|
||
this.game.sound.playPickup();
|
||
this.game.notify(`${npc.name} дал вам еду!`, 'good');
|
||
}
|
||
}
|
||
this.game.ui.hideDialog();
|
||
this.game.questSystem.onEvent('talk_npc', npc.name);
|
||
this.game.reputation.change(1);
|
||
|
||
// Трекинг для достижений
|
||
this.game.talkedNPCs.add(npc.name);
|
||
if (this.game.talkedNPCs.size >= 3) {
|
||
this.game.achievements.check('first_talk');
|
||
}
|
||
if (this.game.talkedNPCs.size >= this.npcs.length) {
|
||
this.game.achievements.check('all_npcs');
|
||
}
|
||
});
|
||
}
|
||
}
|