Initial commit: 3D Hommie RPG game

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-02-25 01:04:09 +03:00
commit fb5f09212b
34 changed files with 14550 additions and 0 deletions

509
js/game/NPC.js Normal file
View File

@@ -0,0 +1,509 @@
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');
}
});
}
}