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