Files
Hommie_RPG_Game/js/game/NPC.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

510 lines
23 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';
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');
}
});
}
}