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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.rar
*.zip
node_modules/

1496
css/style.css Normal file

File diff suppressed because it is too large Load Diff

979
data/map-config.json Normal file
View File

@@ -0,0 +1,979 @@
{
"roads": [
{
"id": "ew_main",
"name": "Главная E-W",
"x": 0,
"z": 0,
"width": 300,
"height": 12,
"rotation": 0,
"sidewalkWidth": 3
},
{
"id": "ns_main",
"name": "Главная N-S",
"x": 0,
"z": 0,
"width": 12,
"height": 300,
"rotation": 1.5708,
"sidewalkWidth": 3
},
{
"id": "ew_south",
"name": "Южная улица",
"x": 0,
"z": -45,
"width": 250,
"height": 8,
"rotation": 0,
"sidewalkWidth": 2.5
},
{
"id": "ew_north",
"name": "Северная улица",
"x": 0,
"z": 50,
"width": 220,
"height": 8,
"rotation": 0,
"sidewalkWidth": 2.5
},
{
"id": "ns_west",
"name": "Западная улица",
"x": -55,
"z": 0,
"width": 8,
"height": 110,
"rotation": 1.5708,
"sidewalkWidth": 2.5
},
{
"id": "ns_east",
"name": "Восточная улица",
"x": 55,
"z": 0,
"width": 8,
"height": 110,
"rotation": 1.5708,
"sidewalkWidth": 2.5
}
],
"buildings": [
{
"x": 18,
"z": 18,
"w": 10,
"h": 16,
"d": 8,
"color": "#8b7355"
},
{
"x": 40,
"z": 18,
"w": 10,
"h": 20,
"d": 8,
"color": "#696969"
},
{
"x": 18,
"z": 36,
"w": 8,
"h": 14,
"d": 8,
"color": "#7b6b55"
},
{
"x": 42,
"z": 36,
"w": 10,
"h": 18,
"d": 8,
"color": "#5b5b6b"
},
{
"x": -42,
"z": -18,
"w": 10,
"h": 14,
"d": 8,
"color": "#6b6b55"
},
{
"x": -42,
"z": -33,
"w": 10,
"h": 12,
"d": 8,
"color": "#556b6b"
},
{
"x": -15,
"z": -28,
"w": 12,
"h": 18,
"d": 10,
"color": "#556b7b"
},
{
"x": 15,
"z": -18,
"w": 10,
"h": 16,
"d": 8,
"color": "#7b5b5b"
},
{
"x": 43,
"z": -18,
"w": 8,
"h": 22,
"d": 10,
"color": "#5b7b6b"
},
{
"x": 15,
"z": -33,
"w": 10,
"h": 14,
"d": 8,
"color": "#5b6b7b"
},
{
"x": 43,
"z": -33,
"w": 10,
"h": 12,
"d": 8,
"color": "#6b5b7b"
},
{
"x": -46,
"z": -62,
"w": 12,
"h": 16,
"d": 10,
"color": "#8b7355"
},
{
"x": -16,
"z": -62,
"w": 10,
"h": 14,
"d": 8,
"color": "#696969"
},
{
"x": 14,
"z": -62,
"w": 8,
"h": 18,
"d": 10,
"color": "#7b6b55"
},
{
"x": 46,
"z": -62,
"w": 12,
"h": 14,
"d": 10,
"color": "#6b6b55"
},
{
"x": -40,
"z": 62,
"w": 10,
"h": 12,
"d": 8,
"color": "#556b6b"
},
{
"x": 16,
"z": 62,
"w": 12,
"h": 16,
"d": 8,
"color": "#556b7b"
},
{
"x": 42,
"z": 62,
"w": 10,
"h": 14,
"d": 8,
"color": "#7b5b5b"
}
],
"structures": {
"park": {
"x": -27,
"z": 25,
"radius": 13
},
"shop": {
"x": -28,
"z": -22,
"w": 10,
"h": 5,
"d": 8
},
"shelter": {
"x": -45,
"z": 40,
"w": 8,
"d": 6
},
"hospital": {
"x": -28,
"z": -60,
"w": 12,
"h": 7,
"d": 10
},
"church": {
"x": 28,
"z": 35,
"w": 10,
"h": 8,
"d": 14
},
"market": {
"x": 28,
"z": -60,
"w": 14,
"h": 4,
"d": 10
},
"construction": {
"x": 75,
"z": 73,
"radius": 12
},
"busStop": {
"x": -18,
"z": 8,
"w": 5,
"d": 2
},
"parking": {
"x": 30,
"z": -22,
"w": 20,
"d": 15
},
"fountain": {
"x": -27,
"z": 25,
"radius": 2
},
"phoneBooth": {
"x": 12,
"z": -10,
"w": 1.2,
"d": 1.2
},
"jobBoard": {
"x": 22,
"z": -10,
"w": 1.2,
"d": 0.8
},
"campSpot": {
"x": -32,
"z": 42
}
},
"interactables": {
"dumpsters": [
{
"x": -20,
"z": -10,
"rot": 0
},
{
"x": 25,
"z": -10,
"rot": 0.3
},
{
"x": -48,
"z": 15,
"rot": -0.2
},
{
"x": 38,
"z": 10,
"rot": 0.5
},
{
"x": -30,
"z": 38,
"rot": 0.1
},
{
"x": 18,
"z": -50,
"rot": 0
},
{
"x": -40,
"z": -50,
"rot": 0.4
},
{
"x": 35,
"z": 35,
"rot": -0.3
}
],
"benches": [
{
"x": -32,
"z": 20,
"rot": 0
},
{
"x": -22,
"z": 30,
"rot": 3.14159
},
{
"x": -30,
"z": 15,
"rot": 0
},
{
"x": 10,
"z": 10,
"rot": 3.14159
},
{
"x": -18,
"z": -10,
"rot": 0
},
{
"x": 35,
"z": 10,
"rot": 3.14159
}
],
"trashPiles": [
{
"x": -42,
"z": 12
},
{
"x": 38,
"z": -12
},
{
"x": -12,
"z": -38
},
{
"x": 22,
"z": 40
}
]
},
"decorations": {
"lamps": [
[
-40,
-8
],
[
-20,
-8
],
[
20,
-8
],
[
40,
-8
],
[
-40,
8
],
[
-20,
8
],
[
20,
8
],
[
40,
8
],
[
-8,
-30
],
[
-8,
-15
],
[
-8,
15
],
[
-8,
30
],
[
8,
-30
],
[
8,
15
],
[
8,
30
],
[
-30,
-50
],
[
0,
-50
],
[
30,
-50
],
[
-30,
55
],
[
30,
55
]
],
"hydrants": [
[
-18,
-10
],
[
25,
-10
],
[
35,
8
]
],
"bins": [
[
-10,
-10
],
[
10,
-10
],
[
25,
10
],
[
-25,
10
]
]
},
"npcs": [
{
"name": "Серёга",
"x": -35,
"z": 28,
"type": "hobo",
"color": "#8b6914",
"patrol": [
[
-35,
28
],
[
-30,
22
],
[
-25,
28
],
[
-30,
34
]
]
},
{
"name": "Прохожий",
"x": 8,
"z": -8,
"type": "citizen",
"color": "#4488cc",
"patrol": [
[
8,
-8
],
[
20,
-8
],
[
30,
-8
],
[
20,
-8
]
]
},
{
"name": "Бабушка Зина",
"x": -22,
"z": 22,
"type": "citizen",
"color": "#cc88cc",
"patrol": [
[
-22,
22
],
[
-27,
26
],
[
-22,
30
],
[
-17,
26
]
]
},
{
"name": "Охранник",
"x": -25,
"z": -18,
"type": "citizen",
"color": "#445566"
},
{
"name": "Отец Михаил",
"x": 28,
"z": 44,
"type": "citizen",
"color": "#2a2a2a",
"patrol": [
[
28,
33
],
[
30,
30
],
[
26,
30
],
[
28,
33
]
]
},
{
"name": "Михалыч",
"x": 72,
"z": 58,
"type": "hobo",
"color": "#6b5b4b",
"patrol": [
[
72,
58
],
[
75,
60
],
[
68,
62
],
[
72,
58
]
]
}
],
"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": -47,
"start": -120,
"end": 120,
"dir": 1
},
{
"axis": "x",
"lane": -43,
"start": 120,
"end": -120,
"dir": -1
},
{
"axis": "x",
"lane": 48,
"start": -100,
"end": 100,
"dir": 1
},
{
"axis": "x",
"lane": 52,
"start": 100,
"end": -100,
"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": -57,
"start": -50,
"end": 50,
"dir": 1
},
{
"axis": "z",
"lane": -53,
"start": 50,
"end": -50,
"dir": -1
},
{
"axis": "z",
"lane": 53,
"start": -50,
"end": 50,
"dir": 1
},
{
"axis": "z",
"lane": 57,
"start": 50,
"end": -50,
"dir": -1
}
]
},
"passerbyRoutes": [
{
"waypoints": [
[
-80,
8
],
[
-40,
8
],
[
0,
8
],
[
30,
8
],
[
60,
8
],
[
80,
8
]
]
},
{
"waypoints": [
[
80,
-8
],
[
40,
-8
],
[
0,
-8
],
[
-30,
-8
],
[
-60,
-8
],
[
-80,
-8
]
]
},
{
"waypoints": [
[
-8,
-70
],
[
-8,
-40
],
[
-8,
0
],
[
-8,
30
],
[
-8,
60
],
[
-8,
80
]
]
},
{
"waypoints": [
[
8,
80
],
[
8,
40
],
[
8,
0
],
[
8,
-30
],
[
8,
-60
],
[
8,
-80
]
]
},
{
"waypoints": [
[
-80,
-50
],
[
-40,
-50
],
[
0,
-50
],
[
40,
-50
],
[
80,
-50
]
]
},
{
"waypoints": [
[
80,
55
],
[
40,
55
],
[
0,
55
],
[
-40,
55
],
[
-80,
55
]
]
},
{
"waypoints": [
[
-58,
-40
],
[
-58,
-20
],
[
-58,
0
],
[
-58,
20
],
[
-58,
40
]
]
},
{
"waypoints": [
[
58,
40
],
[
58,
20
],
[
58,
0
],
[
58,
-20
],
[
58,
-40
]
]
},
{
"waypoints": [
[
-50,
8
],
[
-30,
8
],
[
-20,
20
],
[
-20,
30
],
[
-30,
8
]
]
},
{
"waypoints": [
[
20,
-8
],
[
20,
-25
],
[
35,
-25
],
[
35,
-8
],
[
50,
-8
]
]
}
]
}

524
editor.html Normal file
View File

@@ -0,0 +1,524 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Редактор карты — Бомж RPG</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, sans-serif;
background: #1a1a2e;
color: #ccc;
overflow: hidden;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Toolbar */
#toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #16213e;
border-bottom: 1px solid #333;
flex-shrink: 0;
flex-wrap: wrap;
}
#toolbar button {
padding: 5px 14px;
background: #0f3460;
color: #ddd;
border: 1px solid #1a4a8a;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
transition: background 0.2s;
}
#toolbar button:hover { background: #1a5a9a; }
#toolbar button.active { background: #e94560; border-color: #f06; }
.toolbar-sep {
width: 1px;
height: 24px;
background: #333;
margin: 0 4px;
}
#toolbar label {
font-size: 0.75rem;
color: #888;
display: flex;
align-items: center;
gap: 4px;
}
#toolbar input[type="checkbox"] { accent-color: #e94560; }
#toolbar input[type="number"] {
width: 50px;
padding: 2px 4px;
background: #0d1b3e;
color: #ddd;
border: 1px solid #333;
border-radius: 3px;
font-size: 0.75rem;
}
.toolbar-title {
font-size: 0.9rem;
font-weight: 700;
color: #e94560;
margin-right: 12px;
}
/* Main layout */
#main {
display: flex;
flex: 1;
min-height: 0;
}
/* Left panel - object list */
#list-panel {
width: 240px;
background: #16213e;
border-right: 1px solid #333;
overflow-y: auto;
flex-shrink: 0;
font-size: 0.75rem;
}
#list-panel::-webkit-scrollbar { width: 6px; }
#list-panel::-webkit-scrollbar-track { background: #111; }
#list-panel::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
.list-category {
border-bottom: 1px solid #222;
}
.list-category-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: #0f2847;
cursor: pointer;
user-select: none;
font-weight: 600;
font-size: 0.78rem;
}
.list-category-header:hover { background: #133058; }
.list-category-header .cat-icon { margin-right: 6px; }
.list-category-header .cat-count { color: #666; font-weight: 400; font-size: 0.7rem; }
.cat-add-btn {
padding: 1px 8px;
background: #1a5a2a;
color: #8f8;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 0.75rem;
}
.cat-add-btn:hover { background: #2a7a3a; }
.list-items { display: none; }
.list-items.open { display: block; }
.list-item {
padding: 4px 10px 4px 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.list-item:hover { background: rgba(255,255,255,0.05); }
.list-item.selected { background: rgba(233,69,96,0.15); color: #fff; }
.list-item .item-icon {
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.list-item .item-coords {
color: #555;
font-size: 0.65rem;
margin-left: auto;
}
/* Canvas area */
#canvas-wrap {
flex: 1;
position: relative;
min-width: 0;
}
#editor-canvas {
display: block;
width: 100%;
height: 100%;
}
/* Right panel - properties */
#props-panel {
width: 240px;
background: #16213e;
border-left: 1px solid #333;
overflow-y: auto;
flex-shrink: 0;
padding: 10px;
font-size: 0.75rem;
}
#props-panel::-webkit-scrollbar { width: 6px; }
#props-panel::-webkit-scrollbar-track { background: #111; }
#props-panel::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
.props-title {
font-size: 0.85rem;
font-weight: 700;
color: #e94560;
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid #333;
}
.props-empty {
color: #555;
text-align: center;
padding: 30px 10px;
font-size: 0.8rem;
}
.prop-group {
margin-bottom: 8px;
}
.prop-label {
color: #888;
font-size: 0.7rem;
margin-bottom: 2px;
}
.prop-input {
width: 100%;
padding: 4px 6px;
background: #0d1b3e;
color: #ddd;
border: 1px solid #333;
border-radius: 3px;
font-size: 0.75rem;
}
.prop-input:focus {
outline: none;
border-color: #e94560;
}
.prop-row {
display: flex;
gap: 6px;
}
.prop-row .prop-group { flex: 1; }
.prop-color {
width: 100%;
height: 28px;
padding: 0;
border: 1px solid #333;
border-radius: 3px;
cursor: pointer;
}
.btn-delete {
width: 100%;
padding: 6px;
background: #8a1a1a;
color: #faa;
border: 1px solid #a33;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
margin-top: 12px;
}
.btn-delete:hover { background: #a33; }
.patrol-list {
margin-top: 6px;
}
.patrol-point {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 3px;
}
.patrol-point input {
width: 55px;
padding: 2px 4px;
background: #0d1b3e;
color: #ddd;
border: 1px solid #333;
border-radius: 2px;
font-size: 0.7rem;
}
.patrol-remove {
background: none;
border: none;
color: #a55;
cursor: pointer;
font-size: 0.8rem;
padding: 0 4px;
}
.patrol-add {
padding: 2px 8px;
background: #1a3a1a;
color: #8f8;
border: none;
border-radius: 2px;
cursor: pointer;
font-size: 0.7rem;
margin-top: 4px;
}
/* Status bar */
#status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 12px;
background: #0f1a30;
border-top: 1px solid #333;
font-size: 0.7rem;
color: #666;
flex-shrink: 0;
}
/* Help overlay */
#help-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 100;
justify-content: center;
align-items: center;
}
#help-overlay.visible { display: flex; }
.help-box {
background: #1a1a2e;
border: 1px solid #444;
border-radius: 8px;
padding: 24px;
max-width: 500px;
width: 90%;
}
.help-box h2 {
color: #e94560;
margin-bottom: 16px;
font-size: 1.1rem;
}
.help-box table {
width: 100%;
border-collapse: collapse;
}
.help-box td {
padding: 4px 8px;
border-bottom: 1px solid #222;
font-size: 0.8rem;
}
.help-box td:first-child {
color: #f0a040;
font-weight: 600;
width: 140px;
}
.help-box button {
margin-top: 16px;
padding: 6px 20px;
background: #e94560;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
</head>
<body>
<!-- Toolbar -->
<div id="toolbar">
<span class="toolbar-title">Редактор карты</span>
<button id="btn-save" title="Сохранить JSON (скачать)">Сохранить</button>
<button id="btn-load" title="Загрузить JSON из файла">Загрузить</button>
<div class="toolbar-sep"></div>
<label>
<input type="checkbox" id="chk-grid" checked> Сетка
</label>
<label>
Привязка: <input type="number" id="inp-snap" value="1" min="0" max="10" step="1">
</label>
<div class="toolbar-sep"></div>
<label>
<input type="checkbox" id="chk-labels" checked> Подписи
</label>
<label>
<input type="checkbox" id="chk-routes" checked> Маршруты
</label>
<div class="toolbar-sep"></div>
<button id="btn-center" title="Центрировать камеру">Центр</button>
<button id="btn-fit" title="Показать всю карту">Вся карта</button>
<button id="btn-help" title="Показать справку">?</button>
</div>
<!-- Main layout -->
<div id="main">
<div id="list-panel"></div>
<div id="canvas-wrap">
<canvas id="editor-canvas"></canvas>
</div>
<div id="props-panel">
<div class="props-empty">Выберите объект на карте<br>или в списке слева</div>
</div>
</div>
<!-- Status bar -->
<div id="status-bar">
<span id="status-coords">x: 0, z: 0</span>
<span id="status-zoom">Зум: 100%</span>
<span id="status-count">Объектов: 0</span>
</div>
<!-- Help overlay -->
<div id="help-overlay">
<div class="help-box">
<h2>Управление редактором</h2>
<table>
<tr><td>ЛКМ</td><td>Выбрать объект</td></tr>
<tr><td>ЛКМ + тянуть</td><td>Переместить объект</td></tr>
<tr><td>ПКМ + тянуть</td><td>Панорамирование</td></tr>
<tr><td>Колёсико</td><td>Зум (к курсору)</td></tr>
<tr><td>Delete</td><td>Удалить выбранный</td></tr>
<tr><td>Escape</td><td>Снять выделение</td></tr>
<tr><td>Панель слева</td><td>Список объектов, "+" — добавить</td></tr>
<tr><td>Панель справа</td><td>Свойства выбранного объекта</td></tr>
<tr><td>Сохранить</td><td>Скачать map-config.json</td></tr>
<tr><td>Загрузить</td><td>Загрузить JSON из файла</td></tr>
</table>
<button id="btn-close-help">Закрыть</button>
</div>
</div>
<input type="file" id="file-input" accept=".json" style="display:none">
<script type="module">
import { MapEditor } from './js/editor/MapEditor.js';
const canvas = document.getElementById('editor-canvas');
const listPanel = document.getElementById('list-panel');
const propsPanel = document.getElementById('props-panel');
const statusBar = {
coords: document.getElementById('status-coords'),
zoom: document.getElementById('status-zoom'),
count: document.getElementById('status-count')
};
const editor = new MapEditor(canvas, listPanel, propsPanel, statusBar);
// Load config
editor.loadConfigFromFile('data/map-config.json');
// Toolbar buttons
document.getElementById('btn-save').addEventListener('click', () => editor.saveToFile());
document.getElementById('btn-load').addEventListener('click', () => {
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const json = JSON.parse(reader.result);
editor.loadConfig(json);
} catch (err) {
alert('Ошибка чтения файла: ' + err.message);
}
};
reader.readAsText(file);
e.target.value = '';
});
document.getElementById('chk-grid').addEventListener('change', (e) => {
editor.showGrid = e.target.checked;
});
document.getElementById('inp-snap').addEventListener('input', (e) => {
editor.snapStep = parseFloat(e.target.value) || 0;
});
document.getElementById('chk-labels').addEventListener('change', (e) => {
editor.showLabels = e.target.checked;
});
document.getElementById('chk-routes').addEventListener('change', (e) => {
editor.showRoutes = e.target.checked;
});
document.getElementById('btn-center').addEventListener('click', () => {
editor.camera.x = 0;
editor.camera.z = 0;
});
document.getElementById('btn-fit').addEventListener('click', () => {
editor.camera.x = 0;
editor.camera.z = 0;
editor.camera.zoom = 1.2;
});
// Help
document.getElementById('btn-help').addEventListener('click', () => {
document.getElementById('help-overlay').classList.add('visible');
});
document.getElementById('btn-close-help').addEventListener('click', () => {
document.getElementById('help-overlay').classList.remove('visible');
});
document.getElementById('help-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) {
e.currentTarget.classList.remove('visible');
}
});
</script>
</body>
</html>

242
index.html Normal file
View File

@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Бомж RPG — Выживание в городе</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<!-- Главный экран -->
<div id="menu-screen">
<div class="menu-content">
<h1>БОМЖ RPG</h1>
<p class="subtitle">Выживание в большом городе</p>
<div class="menu-version">v2.0</div>
<button id="btn-start" class="menu-btn">Начать игру</button>
<button id="btn-continue" class="menu-btn hidden">Продолжить</button>
<button id="btn-controls" class="menu-btn">Управление</button>
</div>
<div id="controls-panel" class="hidden">
<h2>Управление</h2>
<ul>
<li><kbd>W A S D</kbd> — Движение</li>
<li><kbd>Мышь</kbd> — Обзор камеры</li>
<li><kbd>E</kbd> — Взаимодействие</li>
<li><kbd>I</kbd> — Инвентарь / Экипировка</li>
<li><kbd>Q</kbd> — Журнал квестов</li>
<li><kbd>Shift</kbd> — Бег</li>
<li><kbd>F</kbd> — Попросить милостыню</li>
<li><kbd>G</kbd> — Играть на гармошке (бускинг)</li>
<li><kbd>J</kbd> — Навыки</li>
<li><kbd>U</kbd> — Достижения</li>
<li><kbd>Space</kbd> — Дать отпор</li>
<li><kbd>H</kbd> — Отменить работу</li>
<li><kbd>F5</kbd> — Сохранить</li>
<li><kbd>M</kbd> — Звук вкл/выкл</li>
</ul>
<button id="btn-back" class="menu-btn">Назад</button>
</div>
</div>
<!-- Игровой Canvas -->
<canvas id="game-canvas"></canvas>
<!-- Прицел -->
<div id="crosshair"></div>
<!-- Экранные эффекты -->
<div id="screen-effects">
<div id="effect-damage"></div>
<div id="effect-cold"></div>
<div id="effect-hunger"></div>
<div id="effect-sleep"></div>
<div id="effect-disease"></div>
</div>
<!-- HUD -->
<div id="hud" class="hidden">
<div id="stats-panel">
<div class="stat-bar" data-stat="health">
<span class="stat-icon">❤️</span>
<span class="stat-label">Здоровье</span>
<div class="bar-bg"><div class="bar-fill health" id="bar-health"></div></div>
<span class="stat-value" id="val-health">100</span>
</div>
<div class="stat-bar" data-stat="hunger">
<span class="stat-icon">🍖</span>
<span class="stat-label">Сытость</span>
<div class="bar-bg"><div class="bar-fill hunger" id="bar-hunger"></div></div>
<span class="stat-value" id="val-hunger">100</span>
</div>
<div class="stat-bar" data-stat="warmth">
<span class="stat-icon">🔥</span>
<span class="stat-label">Тепло</span>
<div class="bar-bg"><div class="bar-fill warmth" id="bar-warmth"></div></div>
<span class="stat-value" id="val-warmth">100</span>
</div>
<div class="stat-bar" data-stat="mood">
<span class="stat-icon">😊</span>
<span class="stat-label">Настроение</span>
<div class="bar-bg"><div class="bar-fill mood" id="bar-mood"></div></div>
<span class="stat-value" id="val-mood">50</span>
</div>
<div class="stat-bar" data-stat="hygiene">
<span class="stat-icon">🧼</span>
<span class="stat-label">Гигиена</span>
<div class="bar-bg"><div class="bar-fill hygiene" id="bar-hygiene"></div></div>
<span class="stat-value" id="val-hygiene">100</span>
</div>
</div>
<!-- Правый верхний блок -->
<div id="top-right-hud">
<div id="money-display">
<span class="money-icon">💰</span>
<span id="val-money">0</span>
<span class="money-currency"></span>
</div>
<div id="time-display">
<span id="val-weather">☀️</span>
<span id="val-time">08:00</span>
<span class="time-separator">|</span>
<span id="val-day">День 1</span>
</div>
<div id="temp-display">
<span id="val-temp">15°C</span>
<span id="val-season">🍂 Осень</span>
</div>
<div id="protection-display">
<span>🛡️</span> <span id="val-protection">0</span>%
<span class="sep">|</span>
<span>🔥</span> <span id="val-warmth-bonus">0</span>
</div>
<div id="reputation-display"></div>
</div>
<!-- Трекер квеста -->
<div id="quest-tracker">
<div id="tracker-title"></div>
<div id="tracker-progress"></div>
<div id="tracker-bar"><div id="tracker-fill"></div></div>
</div>
<!-- Миникарта -->
<div id="minimap-container">
<canvas id="minimap" width="180" height="180"></canvas>
<div id="minimap-label">Миникарта</div>
</div>
<div id="interaction-hint" class="hidden">
<kbd>E</kbd> <span id="hint-text">Взаимодействовать</span>
</div>
<!-- Компас -->
<div id="compass">
<div id="compass-ring">
<span id="compass-dir">С</span>
</div>
</div>
<!-- Быстрые кнопки -->
<div id="hotbar">
<div class="hotbar-item" title="Инвентарь (I)">🎒</div>
<div class="hotbar-item" title="Квесты (Q)">📋</div>
<div class="hotbar-item" title="Навыки (J)"></div>
<div class="hotbar-item" title="Достижения (U)">🏆</div>
</div>
</div>
<!-- Тултип -->
<div id="tooltip" class="hidden">
<div id="tooltip-title"></div>
<div id="tooltip-desc"></div>
<div id="tooltip-stats"></div>
</div>
<!-- Диалоговое окно -->
<div id="dialog-box" class="hidden">
<div class="dialog-content">
<div id="dialog-speaker"></div>
<div id="dialog-text"></div>
<div id="dialog-choices"></div>
</div>
</div>
<!-- Инвентарь -->
<div id="inventory-screen" class="hidden">
<div class="inventory-content">
<div class="panel-header">
<h2>🎒 Инвентарь</h2>
<span class="panel-close" id="btn-close-inv">&times;</span>
</div>
<div id="inventory-grid"></div>
</div>
</div>
<!-- Журнал квестов -->
<div id="quest-screen" class="hidden">
<div class="quest-content">
<div class="panel-header">
<h2>📋 Квесты</h2>
<span class="panel-close" id="btn-close-quest">&times;</span>
</div>
<div id="quest-list"></div>
</div>
</div>
<!-- Навыки -->
<div id="skills-screen" class="hidden">
<div class="skills-content">
<div class="panel-header">
<h2>⚡ Навыки</h2>
<span class="panel-close" id="btn-close-skills">&times;</span>
</div>
<div id="skills-list"></div>
</div>
</div>
<!-- Достижения -->
<div id="achievements-screen" class="hidden">
<div class="achievements-content">
<div class="panel-header">
<h2>🏆 Достижения</h2>
<span class="panel-close" id="btn-close-achievements">&times;</span>
</div>
<div id="achievements-list"></div>
</div>
</div>
<!-- Интро -->
<div id="intro-overlay" class="hidden">
<div class="intro-content">
<div id="intro-text"></div>
<button id="btn-skip-intro" class="menu-btn">Пропустить</button>
</div>
</div>
<!-- Уведомления -->
<div id="notifications"></div>
<!-- Экран смерти -->
<div id="death-screen" class="hidden">
<div class="death-content">
<div class="death-icon">💀</div>
<h1>Вы не выжили...</h1>
<p id="death-reason"></p>
<div id="death-stats"></div>
<button id="btn-restart" class="menu-btn">Начать заново</button>
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module" src="js/main.js"></script>
</body>
</html>

2075
js/editor/MapEditor.js Normal file

File diff suppressed because it is too large Load Diff

163
js/game/Achievements.js Normal file
View File

@@ -0,0 +1,163 @@
export class Achievements {
constructor(game) {
this.game = game;
this.unlocked = new Set();
this.list = [
// Выживание
{ id: 'first_day', title: 'Новая жизнь', desc: 'Прожить 1 день', icon: '🌅', category: 'survival' },
{ id: 'week_survivor', title: 'Недельный марафон', desc: 'Прожить 7 дней', icon: '📅', category: 'survival' },
{ id: 'month_survivor', title: 'Бывалый', desc: 'Прожить 28 дней', icon: '🏆', category: 'survival' },
{ id: 'winter_survivor', title: 'Морозоустойчивый', desc: 'Пережить зиму', icon: '❄️', category: 'survival' },
{ id: 'full_year', title: 'Годовщина', desc: 'Прожить все 4 сезона', icon: '🎊', category: 'survival' },
{ id: 'near_death', title: 'На грани', desc: 'Выжить при здоровье < 5', icon: '💀', category: 'survival' },
{ id: 'well_fed', title: 'Сытый', desc: 'Держать сытость на 100 в течение 5 минут', icon: '🍽️', category: 'survival' },
// Социальные
{ id: 'first_talk', title: 'Общительный', desc: 'Поговорить с 3 NPC', icon: '💬', category: 'social' },
{ id: 'all_npcs', title: 'Душа компании', desc: 'Поговорить со всеми NPC', icon: '🤝', category: 'social' },
{ id: 'best_friend', title: 'Лучший друг', desc: 'Приручить пса', icon: '🐕', category: 'social' },
{ id: 'respected', title: 'Уважаемый', desc: 'Достичь репутации 50+', icon: '⭐', category: 'social' },
{ id: 'beloved', title: 'Свой человек', desc: 'Достичь репутации 80+', icon: '💛', category: 'social' },
// Экономика
{ id: 'first_money', title: 'Первый рубль', desc: 'Заработать первые деньги', icon: '💰', category: 'economy' },
{ id: 'rich_100', title: 'Копилка', desc: 'Накопить 100₽', icon: '🪙', category: 'economy' },
{ id: 'rich_500', title: 'Состояние', desc: 'Накопить 500₽', icon: '💎', category: 'economy' },
{ id: 'rich_1000', title: 'Богач', desc: 'Накопить 1000₽', icon: '👑', category: 'economy' },
{ id: 'first_job_done', title: 'Трудяга', desc: 'Выполнить первую подработку', icon: '🔧', category: 'economy' },
{ id: 'jobs_10', title: 'Работяга', desc: 'Выполнить 10 подработок', icon: '🏗️', category: 'economy' },
{ id: 'bottle_king', title: 'Король бутылок', desc: 'Сдать 20 бутылок', icon: '🍾', category: 'economy' },
// Боевые
{ id: 'first_fight', title: 'Боец', desc: 'Отбиться от врага', icon: '👊', category: 'combat' },
{ id: 'fighter_5', title: 'Бывалый боец', desc: 'Победить 5 врагов', icon: '🥊', category: 'combat' },
{ id: 'survivor_combat', title: 'Несгибаемый', desc: 'Отбиться от 3 врагов подряд', icon: '🛡️', category: 'combat' },
// Исследование
{ id: 'explorer', title: 'Исследователь', desc: 'Посетить все локации', icon: '🗺️', category: 'explore' },
{ id: 'crafter', title: 'Мастер', desc: 'Создать 5 предметов', icon: '🔨', category: 'explore' },
{ id: 'skill_max', title: 'Эксперт', desc: 'Достичь 5 уровня любого навыка', icon: '📚', category: 'explore' },
{ id: 'fully_equipped', title: 'Экипированный', desc: 'Заполнить все слоты экипировки', icon: '🛡️', category: 'explore' },
{ id: 'shelter_built', title: 'Свой угол', desc: 'Построить укрытие', icon: '🏠', category: 'explore' },
];
}
// Вызывается из разных мест кода
check(id) {
if (this.unlocked.has(id)) return;
const achievement = this.list.find(a => a.id === id);
if (!achievement) return;
this.unlocked.add(id);
this.showPopup(achievement);
this.game.sound.playQuestComplete();
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 5);
}
// Проверки, вызываемые каждый тик
updateChecks() {
const player = this.game.player;
const day = this.game.gameDay;
// Выживание
if (day >= 2) this.check('first_day');
if (day >= 8) this.check('week_survivor');
if (day >= 29) this.check('month_survivor');
// Здоровье
if (player.stats.health > 0 && player.stats.health < 5) {
this.check('near_death');
}
// Деньги
if (player.stats.money >= 1) this.check('first_money');
if (player.stats.money >= 100) this.check('rich_100');
if (player.stats.money >= 500) this.check('rich_500');
if (player.stats.money >= 1000) this.check('rich_1000');
// Репутация
if (this.game.reputation.value >= 50) this.check('respected');
if (this.game.reputation.value >= 80) this.check('beloved');
// Пёс
if (this.game.dog.adopted) this.check('best_friend');
// Навыки
const skills = this.game.skills.skills;
for (const skill of Object.values(skills)) {
if (skill.level >= 5) {
this.check('skill_max');
break;
}
}
// Экипировка
if (this.game.equipment.getFilledSlots() === 4) {
this.check('fully_equipped');
}
}
showPopup(achievement) {
let popup = document.getElementById('achievement-popup');
if (!popup) {
popup = document.createElement('div');
popup.id = 'achievement-popup';
popup.style.cssText = `
position: fixed;
top: -80px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, rgba(20,20,40,0.95), rgba(40,30,60,0.95));
border: 2px solid #ffd740;
border-radius: 8px;
padding: 12px 24px;
z-index: 60;
display: flex;
align-items: center;
gap: 12px;
transition: top 0.5s ease;
backdrop-filter: blur(8px);
box-shadow: 0 4px 20px rgba(255,215,64,0.3);
pointer-events: none;
`;
document.body.appendChild(popup);
}
popup.innerHTML = `
<span style="font-size:2rem;">${achievement.icon}</span>
<div>
<div style="font-size:0.7rem;color:#ffd740;font-weight:700;text-transform:uppercase;letter-spacing:0.1em;">Достижение разблокировано!</div>
<div style="font-size:1rem;font-weight:700;color:#fff;margin-top:2px;">${achievement.title}</div>
<div style="font-size:0.75rem;color:#aaa;">${achievement.desc}</div>
</div>
`;
// Анимация
setTimeout(() => popup.style.top = '20px', 50);
setTimeout(() => popup.style.top = '-80px', 4000);
}
getByCategory(category) {
return this.list.filter(a => a.category === category);
}
getProgress() {
return { unlocked: this.unlocked.size, total: this.list.length };
}
getSaveData() {
return { unlocked: [...this.unlocked] };
}
loadSaveData(data) {
if (data && data.unlocked) {
this.unlocked = new Set(data.unlocked);
}
}
reset() {
this.unlocked = new Set();
}
}

45
js/game/Camera.js Normal file
View File

@@ -0,0 +1,45 @@
import * as THREE from 'three';
export class CameraController {
constructor(game) {
this.game = game;
this.camera = game.camera;
this.yaw = 0;
this.pitch = 0;
this.sensitivity = 0.002;
this.maxPitch = Math.PI / 2 - 0.1;
this.isLocked = false;
this.setupPointerLock();
}
setupPointerLock() {
const canvas = this.game.canvas;
canvas.addEventListener('click', () => {
if (!this.isLocked && this.game.running) {
canvas.requestPointerLock();
}
});
document.addEventListener('pointerlockchange', () => {
this.isLocked = document.pointerLockElement === canvas;
});
document.addEventListener('mousemove', (e) => {
if (!this.isLocked) return;
this.yaw -= e.movementX * this.sensitivity;
this.pitch -= e.movementY * this.sensitivity;
this.pitch = THREE.MathUtils.clamp(this.pitch, -this.maxPitch, this.maxPitch);
});
}
update(dt) {
// Применяем вращение камеры
const euler = new THREE.Euler(this.pitch, this.yaw, 0, 'YXZ');
this.camera.quaternion.setFromEuler(euler);
}
}

324
js/game/Dangers.js Normal file
View File

@@ -0,0 +1,324 @@
import * as THREE from 'three';
export class Dangers {
constructor(game) {
this.game = game;
this.enemies = [];
this.spawnTimer = 60 + Math.random() * 60;
this.maxEnemies = 2;
}
update(dt) {
this.spawnTimer -= dt;
if (this.spawnTimer <= 0 && this.enemies.length < this.maxEnemies) {
this.trySpawn();
this.spawnTimer = 80 + Math.random() * 120;
}
this.updateEnemies(dt);
}
trySpawn() {
const hour = this.game.gameTime / 60;
const isNight = hour < 6 || hour > 21;
const baseChance = isNight ? 0.7 : 0.15;
const dangerMod = this.game.reputation.getDangerModifier();
if (Math.random() > baseChance * dangerMod) return;
// Проверяем безопасную зону (укрытие с дверью)
if (this.game.housing.isSafeZone(this.game.player.position)) return;
this.spawnEnemy();
}
spawnEnemy() {
const player = this.game.player;
const angle = Math.random() * Math.PI * 2;
const dist = 30 + Math.random() * 20;
const x = THREE.MathUtils.clamp(player.position.x + Math.cos(angle) * dist, -90, 90);
const z = THREE.MathUtils.clamp(player.position.z + Math.sin(angle) * dist, -90, 90);
const types = [
{ type: 'thug', name: 'Хулиган', color: 0x992222, speed: 4.2, damage: 15, moneySteal: 0.2, hp: 3 },
{ type: 'thief', name: 'Вор', color: 0x444466, speed: 5.2, damage: 5, moneySteal: 0.5, hp: 2 },
{ type: 'drunk', name: 'Пьяница', color: 0x886633, speed: 3.0, damage: 10, moneySteal: 0.1, hp: 2 },
{ type: 'gang', name: 'Гопник', color: 0x993366, speed: 4.5, damage: 20, moneySteal: 0.3, hp: 4 },
];
// Гопники только ночью
const available = this.game.isNight() ? types : types.filter(t => t.type !== 'gang');
const template = available[Math.floor(Math.random() * available.length)];
const enemy = {
...template,
position: new THREE.Vector3(x, 0, z),
mesh: null,
state: 'approach',
lifetime: 60,
attackCooldown: 0,
detectionRange: 25,
attackRange: 2,
stunTimer: 0,
maxHp: template.hp,
};
// Создаём меш
const group = new THREE.Group();
const bodyMat = new THREE.MeshStandardMaterial({ color: template.color });
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.37, 1.15, 8), bodyMat);
body.position.y = 0.85;
body.castShadow = true;
group.add(body);
const head = new THREE.Mesh(
new THREE.SphereGeometry(0.23, 8, 6),
new THREE.MeshStandardMaterial({ color: 0xc49070 })
);
head.position.y = 1.57;
group.add(head);
if (template.type === 'thug' || template.type === 'gang') {
const hood = new THREE.Mesh(
new THREE.CylinderGeometry(0.24, 0.27, 0.18, 8),
new THREE.MeshStandardMaterial({ color: template.type === 'gang' ? 0x333333 : 0x222222 })
);
hood.position.y = 1.72;
group.add(hood);
} else if (template.type === 'thief') {
const mask = new THREE.Mesh(
new THREE.BoxGeometry(0.2, 0.1, 0.25),
new THREE.MeshStandardMaterial({ color: 0x111111 })
);
mask.position.set(0, 1.47, 0.1);
group.add(mask);
} else {
// Пьяница — красный нос
const nose = new THREE.Mesh(
new THREE.SphereGeometry(0.06, 6, 4),
new THREE.MeshStandardMaterial({ color: 0xcc3333 })
);
nose.position.set(0, 1.5, 0.2);
group.add(nose);
}
// Полоска здоровья
const hpBarBg = new THREE.Mesh(
new THREE.PlaneGeometry(0.8, 0.08),
new THREE.MeshBasicMaterial({ color: 0x333333, side: THREE.DoubleSide })
);
hpBarBg.position.y = 2.0;
group.add(hpBarBg);
const hpBarFill = new THREE.Mesh(
new THREE.PlaneGeometry(0.78, 0.06),
new THREE.MeshBasicMaterial({ color: 0xcc2222, side: THREE.DoubleSide })
);
hpBarFill.position.y = 2.0;
hpBarFill.position.z = 0.001;
group.add(hpBarFill);
enemy._hpBar = hpBarFill;
group.position.copy(enemy.position);
this.game.scene.add(group);
enemy.mesh = group;
this.enemies.push(enemy);
this.game.notify(`${enemy.name} замечен поблизости!`, 'bad');
this.game.sound.playHurt();
}
updateEnemies(dt) {
for (let i = this.enemies.length - 1; i >= 0; i--) {
const enemy = this.enemies[i];
enemy.lifetime -= dt;
if (enemy.lifetime <= 0) {
this.removeEnemy(i);
continue;
}
if (enemy.stunTimer > 0) {
enemy.stunTimer -= dt;
continue;
}
const player = this.game.player;
const dir = new THREE.Vector3().subVectors(player.position, enemy.position);
dir.y = 0;
const dist = dir.length();
// Проверка безопасной зоны
if (this.game.housing.isSafeZone(player.position)) {
enemy.state = 'flee';
}
// Полоска здоровья поворот к камере
if (enemy._hpBar) {
const scale = enemy.hp / enemy.maxHp;
enemy._hpBar.scale.x = Math.max(0.01, scale);
enemy._hpBar.material.color.setHex(scale > 0.5 ? 0xcc2222 : 0xff4444);
enemy.mesh.children.forEach(child => {
if (child === enemy._hpBar || child === enemy._hpBar) {
child.lookAt(this.game.camera.position);
}
});
}
// Убегает
if (enemy.state === 'flee') {
const fleeDir = dir.clone().normalize().negate();
enemy.position.add(fleeDir.multiplyScalar(enemy.speed * dt));
enemy.mesh.position.copy(enemy.position);
enemy.mesh.rotation.y = Math.atan2(-dir.x, -dir.z);
if (dist > 50) {
this.removeEnemy(i);
}
continue;
}
// Обнаружение
if (dist < enemy.detectionRange) {
enemy.state = 'chase';
}
if (enemy.state === 'chase') {
if (dist > enemy.attackRange) {
dir.normalize();
enemy.position.add(dir.multiplyScalar(enemy.speed * dt));
enemy.mesh.position.copy(enemy.position);
enemy.mesh.rotation.y = Math.atan2(dir.x, dir.z);
} else {
if (enemy.attackCooldown <= 0) {
this.attackPlayer(enemy);
enemy.attackCooldown = 3;
}
}
}
if (enemy.attackCooldown > 0) {
enemy.attackCooldown -= dt;
}
enemy.position.x = THREE.MathUtils.clamp(enemy.position.x, -95, 95);
enemy.position.z = THREE.MathUtils.clamp(enemy.position.z, -95, 95);
}
}
attackPlayer(enemy) {
const player = this.game.player;
// Защита от экипировки
const protection = this.game.equipment.getProtectionBonus();
const damage = Math.max(1, Math.floor(enemy.damage * (1 - protection / 100)));
player.stats.health = Math.max(0, player.stats.health - damage);
this.game.sound.playHurt();
if (enemy.type === 'thief') {
const stolen = Math.floor(player.stats.money * enemy.moneySteal);
if (stolen > 0) {
player.stats.money -= stolen;
this.game.notify(`${enemy.name} украл у вас ${stolen}₽!`, 'bad');
} else {
this.game.notify(`${enemy.name} толкнул вас! -${damage} Здоровье`, 'bad');
}
enemy.state = 'flee';
} else {
this.game.notify(`${enemy.name} ударил вас! -${damage} Здоровье`, 'bad');
}
player.stats.mood = Math.max(0, player.stats.mood - 10);
}
playerFightBack() {
const player = this.game.player;
let hitEnemy = null;
let minDist = 4;
for (const enemy of this.enemies) {
const dist = player.position.distanceTo(enemy.position);
if (dist < minDist) {
minDist = dist;
hitEnemy = enemy;
}
}
if (!hitEnemy) return false;
const combatLevel = this.game.skills.getLevel('survival');
let hitChance = 0.4 + combatLevel * 0.06;
// Бонус от оружия
if (this.game.inventory.getCount('eq_pipe') > 0) {
hitChance += 0.25;
} else if (this.game.inventory.getCount('eq_stick') > 0) {
hitChance += 0.15;
}
if (Math.random() < hitChance) {
hitEnemy.stunTimer = 2;
hitEnemy.hp--;
this.game.particles.createSparks(hitEnemy.position.clone().add(new THREE.Vector3(0, 1, 0)));
if (hitEnemy.hp <= 0) {
this.game.notify(`${hitEnemy.name} повержен!`, 'good');
this.game.reputation.change(3);
this.game.enemiesDefeated++;
this.game.consecutiveFights++;
this.game.achievements.check('first_fight');
if (this.game.enemiesDefeated >= 5) {
this.game.achievements.check('fighter_5');
}
if (this.game.consecutiveFights >= 3) {
this.game.achievements.check('survivor_combat');
}
this.game.questSystem.onEvent('defeat_enemy');
const idx = this.enemies.indexOf(hitEnemy);
if (idx >= 0) {
hitEnemy.state = 'flee';
hitEnemy.lifetime = 5;
}
} else {
this.game.notify('Вы дали отпор!', 'good');
if (Math.random() < 0.3) {
hitEnemy.state = 'flee';
this.game.notify(`${hitEnemy.name} убегает!`, 'good');
}
}
this.game.skills.addXP('survival', 2);
} else {
this.game.notify('Промах!', 'bad');
this.game.consecutiveFights = 0;
}
return true;
}
hasNearbyDanger() {
const player = this.game.player;
for (const enemy of this.enemies) {
if (player.position.distanceTo(enemy.position) < 15) return true;
}
return false;
}
removeEnemy(index) {
const enemy = this.enemies[index];
if (enemy.mesh) {
this.game.scene.remove(enemy.mesh);
}
this.enemies.splice(index, 1);
}
reset() {
for (let i = this.enemies.length - 1; i >= 0; i--) {
this.removeEnemy(i);
}
this.spawnTimer = 60 + Math.random() * 60;
}
}

194
js/game/Dog.js Normal file
View File

@@ -0,0 +1,194 @@
import * as THREE from 'three';
export class Dog {
constructor(game) {
this.game = game;
this.mesh = null;
this.position = new THREE.Vector3(-25, 0, 30); // default, обновляется из конфига в spawn()
this.adopted = false;
this.followDistance = 3;
this.speed = 4;
this.moodTimer = 0;
this.tailWag = 0;
this.tail = null;
}
spawn() {
// Позиция из конфига (рядом с парком)
const parkCfg = this.game.world.mapConfig?.structures?.park || {};
this.position.set(
(parkCfg.x ?? -30) + 5,
0,
(parkCfg.z ?? 25) + 5
);
const group = new THREE.Group();
// Тело
const bodyGeo = new THREE.BoxGeometry(0.8, 0.45, 0.4);
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x8B6914 });
const body = new THREE.Mesh(bodyGeo, bodyMat);
body.position.y = 0.45;
body.castShadow = true;
group.add(body);
// Голова
const headGeo = new THREE.BoxGeometry(0.3, 0.3, 0.35);
const head = new THREE.Mesh(headGeo, bodyMat);
head.position.set(0.45, 0.6, 0);
head.castShadow = true;
group.add(head);
// Морда
const noseGeo = new THREE.BoxGeometry(0.12, 0.12, 0.2);
const noseMat = new THREE.MeshStandardMaterial({ color: 0x4a3010 });
const nose = new THREE.Mesh(noseGeo, noseMat);
nose.position.set(0.6, 0.55, 0);
group.add(nose);
// Нос
const noseTip = new THREE.Mesh(
new THREE.SphereGeometry(0.04, 6, 4),
new THREE.MeshStandardMaterial({ color: 0x222222 })
);
noseTip.position.set(0.67, 0.56, 0);
group.add(noseTip);
// Уши
[-0.14, 0.14].forEach(side => {
const earGeo = new THREE.BoxGeometry(0.08, 0.15, 0.06);
const ear = new THREE.Mesh(earGeo, bodyMat);
ear.position.set(0.4, 0.8, side);
ear.rotation.z = side > 0 ? 0.3 : -0.3;
group.add(ear);
});
// Глаза
const eyeMat = new THREE.MeshStandardMaterial({ color: 0x222222 });
[-0.1, 0.1].forEach(side => {
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.03, 6, 4), eyeMat);
eye.position.set(0.55, 0.65, side);
group.add(eye);
});
// Ноги
const legGeo = new THREE.CylinderGeometry(0.05, 0.05, 0.3, 6);
const legMat = new THREE.MeshStandardMaterial({ color: 0x7a5a10 });
[[-0.25, -0.14], [-0.25, 0.14], [0.25, -0.14], [0.25, 0.14]].forEach(([lx, lz]) => {
const leg = new THREE.Mesh(legGeo, legMat);
leg.position.set(lx, 0.15, lz);
group.add(leg);
});
// Хвост
const tailGeo = new THREE.CylinderGeometry(0.03, 0.02, 0.3, 6);
const tail = new THREE.Mesh(tailGeo, bodyMat);
tail.position.set(-0.5, 0.6, 0);
tail.rotation.z = Math.PI / 4;
group.add(tail);
this.tail = tail;
group.position.copy(this.position);
this.mesh = group;
this.game.scene.add(group);
}
adopt() {
this.adopted = true;
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 20);
}
update(dt) {
if (!this.mesh) return;
// Виляние хвостом
this.tailWag += dt * 8;
if (this.tail) {
this.tail.rotation.x = Math.sin(this.tailWag) * 0.4;
}
if (this.adopted) {
this.followPlayer(dt);
this.applyMoodBoost(dt);
} else {
this.wander(dt);
}
this.mesh.position.copy(this.position);
}
followPlayer(dt) {
const playerPos = this.game.player.position;
const dir = new THREE.Vector3().subVectors(playerPos, this.position);
dir.y = 0;
const dist = dir.length();
if (dist > this.followDistance) {
dir.normalize();
const speed = dist > 8 ? this.speed * 2 : this.speed;
this.position.add(dir.multiplyScalar(speed * dt));
// Поворот к игроку
this.mesh.rotation.y = Math.atan2(dir.x, dir.z);
}
// Телепортация если слишком далеко
if (dist > 25) {
const behind = new THREE.Vector3();
this.game.camera.getWorldDirection(behind);
behind.y = 0;
behind.normalize().multiplyScalar(-3);
this.position.copy(playerPos).add(behind);
}
}
wander(dt) {
// Бродит рядом с парком
if (!this._wanderTarget || this.position.distanceTo(this._wanderTarget) < 1) {
const parkCfg = this.game.world.mapConfig?.structures?.park || {};
const cx = parkCfg.x ?? -30;
const cz = parkCfg.z ?? 25;
this._wanderTarget = new THREE.Vector3(
cx + (Math.random() - 0.5) * 20,
0,
cz + (Math.random() - 0.5) * 16
);
this._wanderWait = 2 + Math.random() * 3;
}
if (this._wanderWait > 0) {
this._wanderWait -= dt;
return;
}
const dir = new THREE.Vector3().subVectors(this._wanderTarget, this.position);
dir.y = 0;
if (dir.length() > 0.5) {
dir.normalize();
this.position.add(dir.multiplyScalar(1.5 * dt));
this.mesh.rotation.y = Math.atan2(dir.x, dir.z);
}
}
applyMoodBoost(dt) {
this.moodTimer += dt;
if (this.moodTimer >= 10) {
this.moodTimer = 0;
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 1);
}
}
reset() {
if (this.mesh) {
this.game.scene.remove(this.mesh);
this.mesh = null;
}
this.adopted = false;
const parkCfg = this.game.world.mapConfig?.structures?.park || {};
this.position.set(
(parkCfg.x ?? -30) + 5,
0,
(parkCfg.z ?? 25) + 5
);
}
}

129
js/game/Equipment.js Normal file
View File

@@ -0,0 +1,129 @@
export class Equipment {
constructor(game) {
this.game = game;
// Слоты экипировки
this.slots = {
head: null,
body: null,
feet: null,
hands: null
};
// Все предметы экипировки
this.allItems = {
// Голова
old_hat: { slot: 'head', name: 'Старая шапка', icon: '🧢', warmth: 5, protection: 0, mood: 0, tier: 1 },
hood: { slot: 'head', name: 'Капюшон', icon: '🪖', warmth: 8, protection: 2, mood: 0, tier: 2 },
warm_hat: { slot: 'head', name: 'Тёплая шапка', icon: '🎩', warmth: 15, protection: 0, mood: 3, tier: 3 },
helmet: { slot: 'head', name: 'Каска', icon: '⛑️', warmth: 3, protection: 8, mood: -2, tier: 3 },
// Тело
old_jacket: { slot: 'body', name: 'Драная куртка', icon: '🧥', warmth: 10, protection: 2, mood: 0, tier: 1 },
coat: { slot: 'body', name: 'Пальто', icon: '🧥', warmth: 18, protection: 3, mood: 2, tier: 2 },
warm_jacket:{ slot: 'body', name: 'Тёплая куртка', icon: '🧥', warmth: 25, protection: 5, mood: 3, tier: 3 },
vest: { slot: 'body', name: 'Жилетка', icon: '🦺', warmth: 8, protection: 10, mood: 0, tier: 3 },
// Ноги
old_boots: { slot: 'feet', name: 'Рваные ботинки', icon: '👞', warmth: 5, protection: 1, mood: 0, tier: 1 },
boots: { slot: 'feet', name: 'Ботинки', icon: '🥾', warmth: 10, protection: 3, mood: 1, tier: 2 },
warm_boots: { slot: 'feet', name: 'Тёплые сапоги', icon: '🥾', warmth: 18, protection: 4, mood: 2, tier: 3 },
// Руки
old_gloves: { slot: 'hands', name: 'Дырявые перчатки', icon: '🧤', warmth: 3, protection: 0, mood: 0, tier: 1 },
gloves: { slot: 'hands', name: 'Перчатки', icon: '🧤', warmth: 8, protection: 2, mood: 1, tier: 2 },
warm_gloves:{ slot: 'hands', name: 'Тёплые перчатки', icon: '🧤', warmth: 14, protection: 3, mood: 2, tier: 3 },
};
}
equip(itemKey) {
const item = this.allItems[itemKey];
if (!item) return false;
const prevItem = this.slots[item.slot];
this.slots[item.slot] = itemKey;
if (prevItem) {
this.game.notify(`Снято: ${this.allItems[prevItem].name}`);
// Возвращаем в инвентарь
this.game.inventory.addItem('eq_' + prevItem, 1);
}
this.game.notify(`Экипировано: ${item.name}`, 'good');
this.game.sound.playPickup();
// Проверка полной экипировки
if (this.getFilledSlots() === 4) {
this.game.questSystem.onEvent('full_equipment');
}
return true;
}
unequip(slot) {
const itemKey = this.slots[slot];
if (!itemKey) return false;
this.slots[slot] = null;
this.game.inventory.addItem('eq_' + itemKey, 1);
this.game.notify(`Снято: ${this.allItems[itemKey].name}`);
return true;
}
getEquipped(slot) {
const key = this.slots[slot];
if (!key) return null;
return { key, ...this.allItems[key] };
}
// Общий бонус тепла от экипировки
getWarmthBonus() {
let total = 0;
for (const key of Object.values(this.slots)) {
if (key && this.allItems[key]) {
total += this.allItems[key].warmth;
}
}
return total;
}
// Общий бонус защиты (снижение урона в %)
getProtectionBonus() {
let total = 0;
for (const key of Object.values(this.slots)) {
if (key && this.allItems[key]) {
total += this.allItems[key].protection;
}
}
return Math.min(50, total); // Максимум 50% снижения
}
// Общий бонус настроения
getMoodBonus() {
let total = 0;
for (const key of Object.values(this.slots)) {
if (key && this.allItems[key]) {
total += this.allItems[key].mood;
}
}
return total;
}
// Количество слотов заполнено
getFilledSlots() {
return Object.values(this.slots).filter(v => v !== null).length;
}
getSaveData() {
return { slots: { ...this.slots } };
}
loadSaveData(data) {
if (data && data.slots) {
this.slots = { ...data.slots };
}
}
reset() {
this.slots = { head: null, body: null, feet: null, hands: null };
}
}

230
js/game/Events.js Normal file
View File

@@ -0,0 +1,230 @@
export class EventSystem {
constructor(game) {
this.game = game;
this.timer = 30 + Math.random() * 60;
this.eventCooldown = 0;
this.activeEvent = null;
}
update(dt) {
if (this.eventCooldown > 0) {
this.eventCooldown -= dt;
return;
}
this.timer -= dt;
if (this.timer <= 0) {
this.triggerRandom();
this.timer = 40 + Math.random() * 80;
}
}
triggerRandom() {
const hour = this.game.gameTime / 60;
const isNight = hour < 6 || hour > 21;
const player = this.game.player;
const events = [
{ weight: 10, fn: () => this.eventFoundWallet() },
{ weight: 8, fn: () => this.eventStrayDogFood() },
{ weight: 7, fn: () => this.eventOldFriend() },
{ weight: 6, fn: () => this.eventRainOfCoins() },
{ weight: 8, fn: () => this.eventKindStranger() },
{ weight: 5, fn: () => this.eventFoodTruck() },
{ weight: isNight ? 10 : 3, fn: () => this.eventColdWind() },
{ weight: isNight ? 8 : 2, fn: () => this.eventScaryNoise() },
{ weight: player.stats.mood < 30 ? 10 : 3, fn: () => this.eventMemory() },
{ weight: 4, fn: () => this.eventStreetMusician() },
{ weight: player.stats.health < 40 ? 8 : 2, fn: () => this.eventAmbulance() },
{ weight: 5, fn: () => this.eventNewspaper() },
{ weight: 6, fn: () => this.eventPigeons() },
{ weight: isNight ? 2 : 6, fn: () => this.eventSunshine() },
];
const totalWeight = events.reduce((s, e) => s + e.weight, 0);
let roll = Math.random() * totalWeight;
for (const event of events) {
roll -= event.weight;
if (roll <= 0) {
event.fn();
this.eventCooldown = 20;
return;
}
}
}
eventFoundWallet() {
const amount = 20 + Math.floor(Math.random() * 80);
this.game.ui.showDialog('Находка', `Вы нашли на земле кошелёк! Внутри ${amount}₽. Что делать?`, [
`Забрать деньги (+${amount}₽)`,
'Оставить на месте (может кто-то вернётся)',
], (i) => {
if (i === 0) {
this.game.player.stats.money += amount;
this.game.sound.playCoin();
this.game.notify(`+${amount}`, 'good');
this.game.player.stats.mood = Math.max(0, this.game.player.stats.mood - 2);
this.game.reputation.change(-5);
} else {
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 8);
this.game.notify('+8 Настроение. Вы поступили честно.', 'good');
this.game.reputation.change(10);
}
this.game.ui.hideDialog();
});
}
eventStrayDogFood() {
if (this.game.dog && this.game.dog.adopted) return;
const hasBread = this.game.inventory.getCount('bread') > 0;
const choices = hasBread
? ['Покормить хлебом', 'Прогнать']
: ['Погладить', 'Прогнать'];
this.game.ui.showDialog('Событие', 'Бездомный пёс подошёл к вам и смотрит голодными глазами...', choices, (i) => {
if (i === 0) {
if (hasBread) {
this.game.inventory.removeItem('bread', 1);
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 12);
this.game.notify('Пёс виляет хвостом! +12 Настроение', 'good');
// Приручить если ещё не приручен
if (this.game.dog && !this.game.dog.adopted) {
this.game.dog.adopt();
this.game.notify('Пёс решил остаться с вами!', 'good');
this.game.questSystem.onEvent('adopt_dog');
}
} else {
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 5);
this.game.notify('Пёс благодарно лизнул руку.', 'good');
}
} else {
this.game.notify('Пёс убежал с поджатым хвостом...');
this.game.player.stats.mood = Math.max(0, this.game.player.stats.mood - 3);
}
this.game.ui.hideDialog();
});
}
eventOldFriend() {
this.game.ui.showDialog('Событие', 'Вы встретили старого знакомого. Он узнал вас и выглядит смущённым...', [
'"Привет, давно не виделись..."',
'Отвернуться',
], (i) => {
if (i === 0) {
const roll = Math.random();
if (roll < 0.5) {
const amount = 50 + Math.floor(Math.random() * 100);
this.game.player.stats.money += amount;
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 10);
this.game.sound.playCoin();
this.game.notify(`Он дал вам ${amount}₽ и пожелал удачи.`, 'good');
} else {
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 5);
this.game.notify('Он обнял вас и пожелал сил. +5 Настроение', 'good');
}
} else {
this.game.player.stats.mood = Math.max(0, this.game.player.stats.mood - 5);
this.game.notify('Вы отвернулись. Стало грустно.', 'bad');
}
this.game.ui.hideDialog();
});
}
eventRainOfCoins() {
const amount = 5 + Math.floor(Math.random() * 15);
this.game.player.stats.money += amount;
this.game.sound.playCoin();
this.game.notify(`Кто-то обронил мелочь! +${amount}`, 'good');
}
eventKindStranger() {
this.game.ui.showDialog('Событие', 'К вам подошёл человек в дорогом пальто. "Я из благотворительной организации. Хотите горячий обед?"', [
'Да, спасибо!',
'Нет, обойдусь',
], (i) => {
if (i === 0) {
this.game.player.stats.hunger = Math.min(100, this.game.player.stats.hunger + 50);
this.game.player.stats.warmth = Math.min(100, this.game.player.stats.warmth + 15);
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 15);
this.game.sound.playEat();
this.game.notify('Вы сытно поели! +50 Сытость, +15 Тепло, +15 Настроение', 'good');
}
this.game.ui.hideDialog();
});
}
eventFoodTruck() {
this.game.notify('Фудтрак раздаёт бесплатную еду рядом!');
this.game.inventory.addItem('can', 1);
this.game.inventory.addItem('tea', 1);
this.game.sound.playPickup();
this.game.notify('Получено: Консервы, Чай', 'good');
}
eventColdWind() {
this.game.player.stats.warmth = Math.max(0, this.game.player.stats.warmth - 15);
this.game.player.stats.mood = Math.max(0, this.game.player.stats.mood - 5);
this.game.notify('Порыв ледяного ветра! -15 Тепло', 'bad');
}
eventScaryNoise() {
this.game.player.stats.mood = Math.max(0, this.game.player.stats.mood - 8);
this.game.notify('Странный шум в темноте... -8 Настроение', 'bad');
this.game.sound.playHurt();
}
eventMemory() {
const memories = [
'Вы вспомнили детство... тёплый дом, мамин суп...',
'В голове всплыло лицо старого друга... Где он сейчас?',
'Вы нашли в кармане старую фотографию. Сердце защемило.',
'Знакомая мелодия из окна... Вы когда-то танцевали под неё.',
];
const text = memories[Math.floor(Math.random() * memories.length)];
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 3);
this.game.notify(text);
}
eventStreetMusician() {
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 10);
this.game.notify('Уличный музыкант играет красивую мелодию. +10 Настроение', 'good');
}
eventAmbulance() {
this.game.ui.showDialog('Событие', 'Медработник-волонтёр заметил вас. "Давайте я вас осмотрю?"', [
'Да, пожалуйста',
'Не нужно',
], (i) => {
if (i === 0) {
this.game.player.stats.health = Math.min(100, this.game.player.stats.health + 30);
this.game.inventory.addItem('bandage', 1);
this.game.notify('+30 Здоровье. Получен: Бинт', 'good');
}
this.game.ui.hideDialog();
});
}
eventNewspaper() {
this.game.inventory.addItem('newspaper', 1);
this.game.sound.playPickup();
this.game.notify('Ветер принёс газету. Можно почитать.', 'good');
}
eventPigeons() {
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 3);
this.game.notify('Стая голубей приземлилась рядом. Маленькая радость.');
}
eventSunshine() {
this.game.player.stats.warmth = Math.min(100, this.game.player.stats.warmth + 8);
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 5);
this.game.notify('Тёплый луч солнца согрел лицо. +8 Тепло', 'good');
}
reset() {
this.timer = 30 + Math.random() * 60;
this.eventCooldown = 0;
this.activeEvent = null;
}
}

385
js/game/Game.js Normal file
View File

@@ -0,0 +1,385 @@
import * as THREE from 'three';
import { World } from './World.js';
import { Player } from './Player.js';
import { CameraController } from './Camera.js';
import { NPCManager } from './NPC.js';
import { Inventory } from './Inventory.js';
import { QuestSystem } from './QuestSystem.js';
import { UI } from './UI.js';
import { ParticleSystem } from './Particles.js';
import { Weather } from './Weather.js';
import { SaveSystem } from './SaveSystem.js';
import { SoundManager } from './SoundManager.js';
import { EventSystem } from './Events.js';
import { Dog } from './Dog.js';
import { Skills } from './Skills.js';
import { Reputation } from './Reputation.js';
import { JobSystem } from './JobSystem.js';
import { Seasons } from './Seasons.js';
import { Dangers } from './Dangers.js';
import { Equipment } from './Equipment.js';
import { Achievements } from './Achievements.js';
import { Housing } from './Housing.js';
import { Police } from './Police.js';
import { Interiors } from './Interiors.js';
export class Game {
constructor(canvas) {
this.canvas = canvas;
this.clock = new THREE.Clock();
this.running = false;
this.paused = false;
// Время
this.gameTime = 8 * 60;
this.gameDay = 1;
this.timeSpeed = 1.5;
// Статистика
this.totalJobsCompleted = 0;
this.totalBottlesSold = 0;
this.totalCrafted = 0;
this.talkedNPCs = new Set();
this.visitedLocations = new Set();
this.enemiesDefeated = 0;
this.consecutiveFights = 0;
// Renderer
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.0;
// Scene
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x87CEEB);
this.scene.fog = new THREE.Fog(0x87CEEB, 80, 200);
// Camera
this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 300);
this.camera.layers.enable(0);
this.camera.layers.disable(1);
// Core systems
this.world = new World(this);
this.player = new Player(this);
this.cameraController = new CameraController(this);
this.npcManager = new NPCManager(this);
this.inventory = new Inventory(this);
this.questSystem = new QuestSystem(this);
this.ui = new UI(this);
// Extended systems
this.particles = new ParticleSystem(this);
this.weather = new Weather(this);
this.saveSystem = new SaveSystem(this);
this.sound = new SoundManager(this);
this.events = new EventSystem(this);
this.dog = new Dog(this);
this.skills = new Skills(this);
this.reputation = new Reputation(this);
this.jobSystem = new JobSystem(this);
this.seasons = new Seasons(this);
this.dangers = new Dangers(this);
// New systems
this.equipment = new Equipment(this);
this.achievements = new Achievements(this);
this.housing = new Housing(this);
this.police = new Police(this);
this.interiors = new Interiors(this);
// Night sky
this.stars = null;
this.moon = null;
// Input
this.keys = {};
this.setupInput();
window.addEventListener('resize', () => this.onResize());
}
async start() {
this.running = true;
this.paused = true;
this.sound.init();
await this.world.build();
this.housing.initFromConfig();
this.player.spawn();
this.npcManager.spawnNPCs();
this.questSystem.initQuests();
this.weather.init();
this.ui.init();
this.initParticles();
this.createNightSky();
this.dog.spawn();
this.npcManager.spawnPassersby();
this.jobSystem.init();
this.world.createVehicles();
this.police.spawnPatrols();
this.animate();
// Показать интро
this.ui.showIntro(() => {
this.paused = false;
this.canvas.requestPointerLock();
});
}
async startFromSave() {
this.running = true;
this.sound.init();
await this.world.build();
this.housing.initFromConfig();
this.player.spawn();
this.npcManager.spawnNPCs();
this.questSystem.initQuests();
this.weather.init();
this.ui.init();
this.initParticles();
this.createNightSky();
this.dog.spawn();
this.npcManager.spawnPassersby();
this.jobSystem.init();
this.world.createVehicles();
this.police.spawnPatrols();
this.saveSystem.load();
this.animate();
}
initParticles() {
const shelterCfg = this.world.mapConfig?.structures?.shelter || {};
const fireX = (shelterCfg.x ?? -35) - 2;
const fireZ = shelterCfg.z ?? 35;
this.particles.createFire(new THREE.Vector3(fireX, 0.3, fireZ));
this.particles.createRain();
this.particles.createSnow();
}
createNightSky() {
// Звёзды
const starCount = 800;
const starGeo = new THREE.BufferGeometry();
const starPos = new Float32Array(starCount * 3);
for (let i = 0; i < starCount; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.random() * Math.PI * 0.5;
const r = 250;
starPos[i * 3] = Math.cos(theta) * Math.sin(phi) * r;
starPos[i * 3 + 1] = Math.cos(phi) * r;
starPos[i * 3 + 2] = Math.sin(theta) * Math.sin(phi) * r;
}
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
const starMat = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.8,
transparent: true,
opacity: 0,
depthWrite: false
});
this.stars = new THREE.Points(starGeo, starMat);
this.scene.add(this.stars);
// Луна
const moonGeo = new THREE.SphereGeometry(5, 16, 16);
const moonMat = new THREE.MeshBasicMaterial({
color: 0xffffee,
transparent: true,
opacity: 0
});
this.moon = new THREE.Mesh(moonGeo, moonMat);
this.moon.position.set(-100, 120, -80);
this.scene.add(this.moon);
}
animate() {
if (!this.running) return;
requestAnimationFrame(() => this.animate());
const dt = Math.min(this.clock.getDelta(), 0.1);
if (this.paused) return;
// Игровое время
this.gameTime += this.timeSpeed * dt;
if (this.gameTime >= 24 * 60) {
this.gameTime -= 24 * 60;
this.gameDay++;
}
// Все системы
this.world.updateLighting(this.gameTime);
this.world.updateVehicles(dt);
this.player.update(dt);
this.cameraController.update(dt);
this.npcManager.update(dt);
this.questSystem.update(dt);
this.weather.update(dt);
this.particles.update(dt);
this.events.update(dt);
this.dog.update(dt);
this.seasons.update();
this.jobSystem.update(dt);
this.dangers.update(dt);
this.police.update(dt);
this.achievements.updateChecks();
this.ui.update(dt);
// Ночное небо
this.updateNightSky();
// Амбиент
this.updateAmbientSound();
// Рендер
this.renderer.render(this.scene, this.camera);
}
updateNightSky() {
const hour = this.gameTime / 60;
let nightFactor = 0;
if (hour < 5) nightFactor = 1;
else if (hour < 7) nightFactor = 1 - (hour - 5) / 2;
else if (hour > 19 && hour < 21) nightFactor = (hour - 19) / 2;
else if (hour >= 21) nightFactor = 1;
if (this.stars) {
this.stars.material.opacity = nightFactor * 0.8;
this.stars.rotation.y += 0.00005;
}
if (this.moon) {
this.moon.material.opacity = nightFactor * 0.9;
const moonAngle = ((hour - 18) / 12) * Math.PI;
this.moon.position.set(
Math.cos(moonAngle) * 100,
Math.sin(moonAngle) * 100 + 40,
-80
);
}
}
updateAmbientSound() {
if (this.weather.current === 'rain') {
this.sound.playAmbient('rain');
} else if (this.isNight()) {
this.sound.playAmbient('night');
} else {
this.sound.stopAmbient();
}
}
setupInput() {
document.addEventListener('keydown', (e) => {
if (!this.running) return;
this.keys[e.code] = true;
if (e.code === 'KeyE') this.player.interact();
if (e.code === 'KeyI') this.ui.toggleInventory();
if (e.code === 'KeyQ') this.ui.toggleQuests();
if (e.code === 'KeyJ') this.ui.toggleSkills();
if (e.code === 'KeyU') this.ui.toggleAchievements();
if (e.code === 'KeyF') this.player.startBegging();
if (e.code === 'KeyG') this.player.startBusking();
if (e.code === 'KeyM') {
const on = this.sound.toggle();
this.notify(on ? 'Звук включён' : 'Звук выключен');
}
if (e.code === 'Space') {
this.dangers.playerFightBack();
}
if (e.code === 'KeyH') {
this.jobSystem.cancelJob();
}
if (e.code === 'F5') {
e.preventDefault();
this.saveSystem.save();
}
});
document.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
if (e.code === 'KeyF') this.player.stopBegging();
if (e.code === 'KeyG') this.player.stopBusking();
});
}
onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
getTimeString() {
const h = Math.floor(this.gameTime / 60) % 24;
const m = Math.floor(this.gameTime % 60);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
isNight() {
const h = this.gameTime / 60;
return h < 6 || h > 21;
}
notify(text, type) {
this.ui.showNotification(text, type);
}
gameOver(reason) {
this.running = false;
this.sound.stopAmbient();
this.sound.playHurt();
this.dangers.reset();
this.ui.showDeathScreen(reason, this.gameDay);
document.exitPointerLock();
}
async restart() {
this.scene.clear();
this.particles.clear();
this.gameTime = 8 * 60;
this.gameDay = 1;
this.totalJobsCompleted = 0;
this.totalBottlesSold = 0;
this.totalCrafted = 0;
this.talkedNPCs = new Set();
this.visitedLocations = new Set();
this.enemiesDefeated = 0;
this.consecutiveFights = 0;
this.player.reset();
this.inventory.reset();
this.questSystem.reset();
this.weather.reset();
this.events.reset();
this.dog.reset();
this.skills.reset();
this.reputation.reset();
this.jobSystem.reset();
this.seasons.reset();
this.dangers.reset();
this.police.reset();
this.interiors.reset();
this.equipment.reset();
this.achievements.reset();
this.housing.reset();
await this.world.build();
this.housing.initFromConfig();
this.world.createVehicles();
this.player.spawn();
this.npcManager.spawnNPCs();
this.npcManager.spawnPassersby();
this.questSystem.initQuests();
this.initParticles();
this.createNightSky();
this.dog.spawn();
this.police.spawnPatrols();
this.ui.hideDeathScreen();
this.running = true;
this.clock.getDelta();
this.animate();
}
}

352
js/game/Housing.js Normal file
View File

@@ -0,0 +1,352 @@
import * as THREE from 'three';
export class Housing {
constructor(game) {
this.game = game;
this.built = false;
this.mesh = null;
this.position = new THREE.Vector3(-20, 0, 38);
// Улучшения
this.upgrades = {
roof: { built: false, name: 'Крыша', icon: '🏚️', desc: 'Защита от дождя и снега', cost: { scrap: 3, rope: 1 }, effect: 'weather_protection' },
bed: { built: false, name: 'Лежанка', icon: '🛏️', desc: 'Лучший отдых при сне (+10 здоровье)', cost: { clothing: 2, newspaper: 3 }, effect: 'better_sleep' },
stove: { built: false, name: 'Печка', icon: '🔥', desc: 'Обогрев и готовка', cost: { scrap: 4, candle: 2 }, effect: 'heating' },
door: { built: false, name: 'Дверь', icon: '🚪', desc: 'Защита от врагов в лагере', cost: { scrap: 2, rope: 2 }, effect: 'safety' },
storage: { built: false, name: 'Тайник', icon: '📦', desc: '+10 слотов инвентаря', cost: { scrap: 3, rope: 1 }, effect: 'extra_storage' },
};
}
initFromConfig() {
const cfg = this.game.world.mapConfig?.structures?.campSpot || {};
this.position.set(cfg.x ?? -20, 0, cfg.z ?? 38);
}
buildShelter() {
if (this.built) return;
// Нужно 5 хлама и 2 верёвки
const inv = this.game.inventory;
if (inv.getCount('scrap') < 5 || inv.getCount('rope') < 2) {
this.game.notify('Нужно: 5x Хлам, 2x Верёвка', 'bad');
return;
}
inv.removeItem('scrap', 5);
inv.removeItem('rope', 2);
this.built = true;
this.createMesh();
this.addInteractable();
this.game.notify('Вы построили своё укрытие!', 'good');
this.game.sound.playQuestComplete();
this.game.questSystem.onEvent('build_shelter');
this.game.achievements.check('shelter_built');
this.game.skills.addXP('survival', 5);
}
createMesh() {
if (this.mesh) {
this.game.scene.remove(this.mesh);
}
const group = new THREE.Group();
// Базовый каркас — палатка из палок
const frameMat = new THREE.MeshStandardMaterial({ color: 0x5c3a1e });
// Основание
const base = new THREE.Mesh(
new THREE.BoxGeometry(4, 0.15, 3),
new THREE.MeshStandardMaterial({ color: 0x4a3a2a })
);
base.position.y = 0.075;
base.receiveShadow = true;
group.add(base);
// Стойки
const stickGeo = new THREE.CylinderGeometry(0.06, 0.06, 2.2, 6);
[[-1.8, 1.1, -1.3], [1.8, 1.1, -1.3], [-1.8, 1.1, 1.3], [1.8, 1.1, 1.3]].forEach(([x, y, z]) => {
const stick = new THREE.Mesh(stickGeo, frameMat);
stick.position.set(x, y, z);
stick.castShadow = true;
group.add(stick);
});
// Перекладины
const barGeo = new THREE.CylinderGeometry(0.05, 0.05, 3.8, 6);
const bar1 = new THREE.Mesh(barGeo, frameMat);
bar1.position.set(0, 2.2, -1.3);
bar1.rotation.z = Math.PI / 2;
group.add(bar1);
const bar2 = new THREE.Mesh(barGeo, frameMat);
bar2.position.set(0, 2.2, 1.3);
bar2.rotation.z = Math.PI / 2;
group.add(bar2);
// Крыша (если построена)
if (this.upgrades.roof.built) {
const roofMat = new THREE.MeshStandardMaterial({ color: 0x3a5a3a, side: THREE.DoubleSide });
const roofGeo = new THREE.PlaneGeometry(4.2, 3.2);
const roof = new THREE.Mesh(roofGeo, roofMat);
roof.position.set(0, 2.3, 0);
roof.rotation.x = -Math.PI / 2;
roof.castShadow = true;
roof.receiveShadow = true;
group.add(roof);
}
// Лежанка
if (this.upgrades.bed.built) {
const bedMat = new THREE.MeshStandardMaterial({ color: 0x6b5b4b });
const bed = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.2, 2.2), bedMat);
bed.position.set(-0.5, 0.25, 0);
group.add(bed);
const pillowMat = new THREE.MeshStandardMaterial({ color: 0x7b6b5b });
const pillow = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.15, 0.4), pillowMat);
pillow.position.set(-0.5, 0.4, -0.8);
group.add(pillow);
}
// Печка
if (this.upgrades.stove.built) {
const stoveMat = new THREE.MeshStandardMaterial({ color: 0x555555 });
const stove = new THREE.Mesh(new THREE.CylinderGeometry(0.35, 0.4, 0.5, 8), stoveMat);
stove.position.set(1.2, 0.25, 0.5);
stove.castShadow = true;
group.add(stove);
// Огонь
const fireMat = new THREE.MeshStandardMaterial({
color: 0xff6600,
emissive: 0xff4400,
emissiveIntensity: 0.8
});
const fire = new THREE.Mesh(new THREE.SphereGeometry(0.15, 6, 4), fireMat);
fire.position.set(1.2, 0.55, 0.5);
group.add(fire);
}
// Дверь
if (this.upgrades.door.built) {
const doorMat = new THREE.MeshStandardMaterial({ color: 0x5c4a3a });
const door = new THREE.Mesh(new THREE.BoxGeometry(1.2, 2, 0.1), doorMat);
door.position.set(0, 1, 1.35);
door.castShadow = true;
group.add(door);
}
// Тайник
if (this.upgrades.storage.built) {
const boxMat = new THREE.MeshStandardMaterial({ color: 0x4a4a3a });
const box = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.6, 0.6), boxMat);
box.position.set(1.2, 0.3, -0.7);
box.castShadow = true;
group.add(box);
}
group.position.copy(this.position);
this.game.scene.add(group);
this.mesh = group;
}
addInteractable() {
// Убираем старое если есть
this.game.world.interactables = this.game.world.interactables.filter(o => o.type !== 'player_shelter');
this.game.world.interactables.push({
position: this.position.clone(),
radius: 4,
type: 'player_shelter',
label: 'Ваше укрытие'
});
}
showMenu() {
if (!this.built) {
// Предложить построить
const hasRes = this.game.inventory.getCount('scrap') >= 5 && this.game.inventory.getCount('rope') >= 2;
this.game.ui.showDialog('Место для лагеря', 'Здесь можно поставить укрытие. Нужно: 5x Хлам, 2x Верёвка.', [
hasRes ? 'Построить укрытие' : 'Не хватает материалов',
'Уйти'
], (i) => {
if (i === 0 && hasRes) {
this.buildShelter();
}
this.game.ui.hideDialog();
});
return;
}
const choices = [];
const actions = [];
// Спать (если есть лежанка — лучше)
choices.push(this.upgrades.bed.built ? 'Поспать (улучшенный отдых)' : 'Поспать');
actions.push('sleep');
// Погреться (если есть печка)
if (this.upgrades.stove.built) {
choices.push('Погреться у печки');
actions.push('warm');
}
// Доступные улучшения
for (const [key, upg] of Object.entries(this.upgrades)) {
if (upg.built) continue;
const canBuild = this.canBuildUpgrade(key);
const costStr = Object.entries(upg.cost).map(([item, count]) => {
const name = this.game.inventory.itemData[item]?.name || item;
return `${count}x ${name}`;
}).join(', ');
choices.push(`${upg.icon} ${upg.name} (${costStr})${canBuild ? '' : ' [не хватает]'}`);
actions.push('upgrade_' + key);
}
choices.push('Уйти');
actions.push('leave');
this.game.sound.playDialogOpen();
this.game.ui.showDialog('Ваше укрытие', 'Что хотите сделать?', choices, (index) => {
const action = actions[index];
if (action === 'sleep') {
this.sleepHere();
} else if (action === 'warm') {
this.warmHere();
} else if (action && action.startsWith('upgrade_')) {
const key = action.replace('upgrade_', '');
this.buildUpgrade(key);
}
this.game.ui.hideDialog();
});
}
canBuildUpgrade(key) {
const upg = this.upgrades[key];
if (!upg || upg.built) return false;
for (const [item, count] of Object.entries(upg.cost)) {
if (this.game.inventory.getCount(item) < count) return false;
}
return true;
}
buildUpgrade(key) {
if (!this.canBuildUpgrade(key)) {
this.game.notify('Не хватает материалов!', 'bad');
return;
}
const upg = this.upgrades[key];
for (const [item, count] of Object.entries(upg.cost)) {
this.game.inventory.removeItem(item, count);
}
upg.built = true;
this.game.notify(`Построено: ${upg.name}!`, 'good');
this.game.sound.playQuestComplete();
this.game.skills.addXP('survival', 3);
// Применить эффект тайника
if (key === 'storage') {
this.game.inventory.maxSlots += 10;
this.game.notify('+10 слотов инвентаря!', 'good');
}
// Перестроить меш
this.createMesh();
}
sleepHere() {
const player = this.game.player;
if (player.stats.hunger < 10) {
this.game.notify('Слишком голодно, не уснуть...', 'bad');
return;
}
player.isSleeping = true;
player.sleepTimer = 0;
player._shelterSleep = true; // Помечаем что сон в укрытии
this.game.notify('Вы легли спать в укрытии...');
}
warmHere() {
this.game.player.stats.warmth = Math.min(100, this.game.player.stats.warmth + 30);
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 5);
this.game.notify('+30 Тепло, +5 Настроение', 'good');
}
// Модификатор сна в укрытии
getSleepBonus() {
let bonus = { health: 15, mood: 10, warmth: 20 };
if (this.upgrades.bed.built) {
bonus.health += 10;
bonus.mood += 5;
}
if (this.upgrades.roof.built) {
bonus.warmth += 15;
}
if (this.upgrades.stove.built) {
bonus.warmth += 10;
}
return bonus;
}
// Враги не атакуют рядом с укрытием если есть дверь
isSafeZone(pos) {
if (!this.built || !this.upgrades.door.built) return false;
return pos.distanceTo(this.position) < 6;
}
// Крыша защищает от погоды
hasRoof() {
return this.built && this.upgrades.roof.built;
}
isPlayerInShelter() {
if (!this.built) return false;
return this.game.player.position.distanceTo(this.position) < 5;
}
getSaveData() {
const upgradeData = {};
for (const [key, upg] of Object.entries(this.upgrades)) {
upgradeData[key] = upg.built;
}
return {
built: this.built,
upgrades: upgradeData
};
}
loadSaveData(data) {
if (!data) return;
this.built = data.built || false;
if (data.upgrades) {
for (const [key, built] of Object.entries(data.upgrades)) {
if (this.upgrades[key]) {
this.upgrades[key].built = built;
}
}
}
if (this.built) {
this.createMesh();
this.addInteractable();
if (this.upgrades.storage.built) {
this.game.inventory.maxSlots = 30;
}
}
}
reset() {
if (this.mesh) {
this.game.scene.remove(this.mesh);
this.mesh = null;
}
this.built = false;
for (const upg of Object.values(this.upgrades)) {
upg.built = false;
}
}
}

523
js/game/Interiors.js Normal file
View File

@@ -0,0 +1,523 @@
import * as THREE from 'three';
export class Interiors {
constructor(game) {
this.game = game;
this.isInside = false;
this.currentBuilding = null;
this.savedPosition = null;
this.interiorObjects = [];
this.interiorColliders = [];
this.interiorInteractables = [];
this.built = false;
}
buildInteriors() {
if (this.built) return;
this.built = true;
this.buildShop();
this.buildHospital();
this.buildChurch();
}
createInteriorNPC(group, x, y, z, bodyColor, hasApron) {
// Тело
const bodyMat = new THREE.MeshStandardMaterial({ color: bodyColor });
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.28, 0.32, 1.0, 8), bodyMat);
body.position.set(x, 0.8, z);
body.castShadow = true;
group.add(body);
// Голова
const head = new THREE.Mesh(
new THREE.SphereGeometry(0.2, 8, 6),
new THREE.MeshStandardMaterial({ color: 0xd4a574 })
);
head.position.set(x, 1.45, z);
group.add(head);
// Ноги
const legMat = new THREE.MeshStandardMaterial({ color: 0x333344 });
[-0.1, 0.1].forEach(side => {
const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 0.4, 6), legMat);
leg.position.set(x + side, 0.2, z);
group.add(leg);
});
// Фартук (для продавца)
if (hasApron) {
const apron = new THREE.Mesh(
new THREE.BoxGeometry(0.5, 0.6, 0.05),
new THREE.MeshStandardMaterial({ color: 0xeeeeee })
);
apron.position.set(x, 0.7, z + 0.28);
group.add(apron);
}
}
addDoorFrame(group, ox, oz, d) {
const doorMat = new THREE.MeshStandardMaterial({ color: 0x6b3a1f });
// Дверная рама (ширина проёма 2.4)
const frameLeft = new THREE.Mesh(new THREE.BoxGeometry(0.12, 2.4, 0.25), doorMat);
frameLeft.position.set(ox - 1.2, 1.2, oz + d / 2);
group.add(frameLeft);
const frameRight = new THREE.Mesh(new THREE.BoxGeometry(0.12, 2.4, 0.25), doorMat);
frameRight.position.set(ox + 1.2, 1.2, oz + d / 2);
group.add(frameRight);
const frameTop = new THREE.Mesh(new THREE.BoxGeometry(2.5, 0.12, 0.25), doorMat);
frameTop.position.set(ox, 2.4, oz + d / 2);
group.add(frameTop);
// Табличка "ВЫХОД" (зелёная, светящаяся)
const signMat = new THREE.MeshStandardMaterial({ color: 0x22cc44, emissive: 0x22cc44, emissiveIntensity: 0.8 });
const sign = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.3, 0.05), signMat);
sign.position.set(ox, 2.7, oz + d / 2 - 0.05);
group.add(sign);
// Подсветка у двери
const doorLight = new THREE.PointLight(0x44ff66, 0.3, 4);
doorLight.position.set(ox, 2.5, oz + d / 2 - 0.5);
group.add(doorLight);
// Коврик у двери
const matFloor = new THREE.Mesh(
new THREE.PlaneGeometry(2, 1),
new THREE.MeshStandardMaterial({ color: 0x886644 })
);
matFloor.rotation.x = -Math.PI / 2;
matFloor.position.set(ox, 0.02, oz + d / 2 - 0.5);
group.add(matFloor);
}
buildShop() {
const ox = 500, oz = 0;
const w = 10, d = 8, h = 3.5;
const group = new THREE.Group();
// Пол
const floorMat = new THREE.MeshStandardMaterial({ color: 0xddccaa });
const floor = new THREE.Mesh(new THREE.PlaneGeometry(w, d), floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.set(ox, 0.01, oz);
floor.receiveShadow = true;
group.add(floor);
// Потолок
const ceilMat = new THREE.MeshStandardMaterial({ color: 0xeeeeee, side: THREE.BackSide });
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(w, d), ceilMat);
ceil.rotation.x = -Math.PI / 2;
ceil.position.set(ox, h, oz);
group.add(ceil);
// Стены
const wallMat = new THREE.MeshStandardMaterial({ color: 0xf5e6cc });
// Задняя стена
const backWall = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.2), wallMat);
backWall.position.set(ox, h / 2, oz - d / 2);
backWall.castShadow = true;
group.add(backWall);
// Левая стена
const leftWall = new THREE.Mesh(new THREE.BoxGeometry(0.2, h, d), wallMat);
leftWall.position.set(ox - w / 2, h / 2, oz);
group.add(leftWall);
// Правая стена
const rightWall = new THREE.Mesh(new THREE.BoxGeometry(0.2, h, d), wallMat);
rightWall.position.set(ox + w / 2, h / 2, oz);
group.add(rightWall);
// Передняя стена с проёмом (дверь)
const frontLeft = new THREE.Mesh(new THREE.BoxGeometry(w / 2 - 1.4, h, 0.2), wallMat);
frontLeft.position.set(ox - w / 4 - 0.7, h / 2, oz + d / 2);
group.add(frontLeft);
const frontRight = new THREE.Mesh(new THREE.BoxGeometry(w / 2 - 1.4, h, 0.2), wallMat);
frontRight.position.set(ox + w / 4 + 0.7, h / 2, oz + d / 2);
group.add(frontRight);
// Прилавок
const counterMat = new THREE.MeshStandardMaterial({ color: 0x8b6914 });
const counter = new THREE.Mesh(new THREE.BoxGeometry(5, 1, 0.6), counterMat);
counter.position.set(ox, 0.5, oz - 1.5);
counter.castShadow = true;
group.add(counter);
// Продавец за прилавком
this.createInteriorNPC(group, ox + 1, 0, oz - 2.2, 0x336633, true);
// Полки на стене
const shelfMat = new THREE.MeshStandardMaterial({ color: 0x9b7b3c });
for (let i = 0; i < 3; i++) {
const shelf = new THREE.Mesh(new THREE.BoxGeometry(2.5, 0.08, 0.5), shelfMat);
shelf.position.set(ox - 3 + i * 3, 1.5, oz - d / 2 + 0.35);
group.add(shelf);
// Товары на полках
for (let j = 0; j < 3; j++) {
const item = new THREE.Mesh(
new THREE.BoxGeometry(0.3, 0.4, 0.3),
new THREE.MeshStandardMaterial({ color: [0xe8a030, 0x4488cc, 0xcc4444][j] })
);
item.position.set(ox - 3.5 + i * 3 + j * 0.5, 1.75, oz - d / 2 + 0.35);
group.add(item);
}
}
// Дверная рама и табличка
this.addDoorFrame(group, ox, oz, d);
// Свет
const light = new THREE.PointLight(0xffe8c0, 1, 15);
light.position.set(ox, h - 0.3, oz);
group.add(light);
this.game.scene.add(group);
this.interiorObjects.push(group);
// Коллайдеры
this.interiorColliders.push(
new THREE.Box3(new THREE.Vector3(ox - w / 2 - 0.1, 0, oz - d / 2 - 0.2), new THREE.Vector3(ox + w / 2 + 0.1, h, oz - d / 2)),
new THREE.Box3(new THREE.Vector3(ox - w / 2 - 0.2, 0, oz - d / 2), new THREE.Vector3(ox - w / 2, h, oz + d / 2)),
new THREE.Box3(new THREE.Vector3(ox + w / 2, 0, oz - d / 2), new THREE.Vector3(ox + w / 2 + 0.2, h, oz + d / 2)),
// Передняя стена левая часть
new THREE.Box3(new THREE.Vector3(ox - w / 2, 0, oz + d / 2 - 0.1), new THREE.Vector3(ox - 1.3, h, oz + d / 2 + 0.1)),
new THREE.Box3(new THREE.Vector3(ox + 1.3, 0, oz + d / 2 - 0.1), new THREE.Vector3(ox + w / 2, h, oz + d / 2 + 0.1)),
// Прилавок коллайдер
new THREE.Box3(new THREE.Vector3(ox - 2.5, 0, oz - 1.8), new THREE.Vector3(ox + 2.5, 1, oz - 1.2)),
);
// Интерактивные объекты внутри
this.interiorInteractables.push(
{
position: new THREE.Vector3(ox, 0, oz - 1.5),
radius: 2.5,
type: 'shop_counter',
label: 'Купить / Продать',
building: 'shop'
},
{
position: new THREE.Vector3(ox, 0, oz + d / 2 - 0.5),
radius: 2,
type: 'exit_door',
label: 'Выйти на улицу',
building: 'shop'
}
);
}
buildHospital() {
const ox = 500, oz = 50;
const w = 12, d = 10, h = 3.5;
const group = new THREE.Group();
// Пол
const floorMat = new THREE.MeshStandardMaterial({ color: 0xe8e8e8 });
const floor = new THREE.Mesh(new THREE.PlaneGeometry(w, d), floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.set(ox, 0.01, oz);
floor.receiveShadow = true;
group.add(floor);
// Потолок
const ceilMat = new THREE.MeshStandardMaterial({ color: 0xffffff, side: THREE.BackSide });
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(w, d), ceilMat);
ceil.rotation.x = -Math.PI / 2;
ceil.position.set(ox, h, oz);
group.add(ceil);
// Стены (белые)
const wallMat = new THREE.MeshStandardMaterial({ color: 0xf0f0f0 });
const backWall = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.2), wallMat);
backWall.position.set(ox, h / 2, oz - d / 2);
group.add(backWall);
const leftWall = new THREE.Mesh(new THREE.BoxGeometry(0.2, h, d), wallMat);
leftWall.position.set(ox - w / 2, h / 2, oz);
group.add(leftWall);
const rightWall = new THREE.Mesh(new THREE.BoxGeometry(0.2, h, d), wallMat);
rightWall.position.set(ox + w / 2, h / 2, oz);
group.add(rightWall);
const frontLeft = new THREE.Mesh(new THREE.BoxGeometry(w / 2 - 1.4, h, 0.2), wallMat);
frontLeft.position.set(ox - w / 4 - 0.7, h / 2, oz + d / 2);
group.add(frontLeft);
const frontRight = new THREE.Mesh(new THREE.BoxGeometry(w / 2 - 1.4, h, 0.2), wallMat);
frontRight.position.set(ox + w / 4 + 0.7, h / 2, oz + d / 2);
group.add(frontRight);
// Кровати
const bedMat = new THREE.MeshStandardMaterial({ color: 0xffffff });
const frameMat = new THREE.MeshStandardMaterial({ color: 0xaabbcc });
for (let i = 0; i < 3; i++) {
const frame = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.4, 0.9), frameMat);
frame.position.set(ox - 4 + i * 3.5, 0.25, oz - 3);
frame.castShadow = true;
group.add(frame);
const mattress = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.1, 0.7), bedMat);
mattress.position.set(ox - 4 + i * 3.5, 0.5, oz - 3);
group.add(mattress);
}
// Стол врача
const desk = new THREE.Mesh(
new THREE.BoxGeometry(2, 0.8, 1),
new THREE.MeshStandardMaterial({ color: 0xccccdd })
);
desk.position.set(ox + 3, 0.4, oz + 2);
desk.castShadow = true;
group.add(desk);
// Врач за столом
this.createInteriorNPC(group, ox + 3, 0, oz + 1.2, 0xeeeeee, false);
// Красный крест на стене
const crossMat = new THREE.MeshStandardMaterial({ color: 0xff3333 });
const crossH = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.2, 0.05), crossMat);
crossH.position.set(ox, 2.5, oz - d / 2 + 0.15);
group.add(crossH);
const crossV = new THREE.Mesh(new THREE.BoxGeometry(0.2, 1.2, 0.05), crossMat);
crossV.position.set(ox, 2.5, oz - d / 2 + 0.15);
group.add(crossV);
// Дверная рама и табличка
this.addDoorFrame(group, ox, oz, d);
// Свет
const light = new THREE.PointLight(0xffffff, 1.2, 18);
light.position.set(ox, h - 0.3, oz);
group.add(light);
this.game.scene.add(group);
this.interiorObjects.push(group);
this.interiorColliders.push(
new THREE.Box3(new THREE.Vector3(ox - w / 2 - 0.1, 0, oz - d / 2 - 0.2), new THREE.Vector3(ox + w / 2 + 0.1, h, oz - d / 2)),
new THREE.Box3(new THREE.Vector3(ox - w / 2 - 0.2, 0, oz - d / 2), new THREE.Vector3(ox - w / 2, h, oz + d / 2)),
new THREE.Box3(new THREE.Vector3(ox + w / 2, 0, oz - d / 2), new THREE.Vector3(ox + w / 2 + 0.2, h, oz + d / 2)),
new THREE.Box3(new THREE.Vector3(ox - w / 2, 0, oz + d / 2 - 0.1), new THREE.Vector3(ox - 0.8, h, oz + d / 2 + 0.1)),
new THREE.Box3(new THREE.Vector3(ox + 0.8, 0, oz + d / 2 - 0.1), new THREE.Vector3(ox + w / 2, h, oz + d / 2 + 0.1)),
new THREE.Box3(new THREE.Vector3(ox + 2, 0, oz + 1.5), new THREE.Vector3(ox + 4, 0.8, oz + 2.5)),
);
this.interiorInteractables.push(
{
position: new THREE.Vector3(ox + 3, 0, oz + 2),
radius: 2.5,
type: 'hospital_desk',
label: 'Лечение',
building: 'hospital'
},
{
position: new THREE.Vector3(ox, 0, oz + d / 2 - 0.5),
radius: 2,
type: 'exit_door',
label: 'Выйти на улицу',
building: 'hospital'
}
);
}
buildChurch() {
const ox = 500, oz = 100;
const w = 10, d = 14, h = 5;
const group = new THREE.Group();
// Пол (деревянный)
const floorMat = new THREE.MeshStandardMaterial({ color: 0xb89060 });
const floor = new THREE.Mesh(new THREE.PlaneGeometry(w, d), floorMat);
floor.rotation.x = -Math.PI / 2;
floor.position.set(ox, 0.01, oz);
floor.receiveShadow = true;
group.add(floor);
// Потолок
const ceilMat = new THREE.MeshStandardMaterial({ color: 0xddd8c0, side: THREE.BackSide });
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(w, d), ceilMat);
ceil.rotation.x = -Math.PI / 2;
ceil.position.set(ox, h, oz);
group.add(ceil);
// Стены
const wallMat = new THREE.MeshStandardMaterial({ color: 0xf0e8d0 });
const backWall = new THREE.Mesh(new THREE.BoxGeometry(w, h, 0.2), wallMat);
backWall.position.set(ox, h / 2, oz - d / 2);
group.add(backWall);
const leftWall = new THREE.Mesh(new THREE.BoxGeometry(0.2, h, d), wallMat);
leftWall.position.set(ox - w / 2, h / 2, oz);
group.add(leftWall);
const rightWall = new THREE.Mesh(new THREE.BoxGeometry(0.2, h, d), wallMat);
rightWall.position.set(ox + w / 2, h / 2, oz);
group.add(rightWall);
const frontLeft = new THREE.Mesh(new THREE.BoxGeometry(w / 2 - 1.4, h, 0.2), wallMat);
frontLeft.position.set(ox - w / 4 - 0.7, h / 2, oz + d / 2);
group.add(frontLeft);
const frontRight = new THREE.Mesh(new THREE.BoxGeometry(w / 2 - 1.4, h, 0.2), wallMat);
frontRight.position.set(ox + w / 4 + 0.7, h / 2, oz + d / 2);
group.add(frontRight);
// Скамьи (ряды)
const benchMat = new THREE.MeshStandardMaterial({ color: 0x8b6914 });
for (let row = 0; row < 4; row++) {
for (let side = -1; side <= 1; side += 2) {
const bench = new THREE.Mesh(new THREE.BoxGeometry(3, 0.4, 0.5), benchMat);
bench.position.set(ox + side * 2, 0.25, oz - 3 + row * 2.5);
bench.castShadow = true;
group.add(bench);
}
}
// Алтарь
const altarMat = new THREE.MeshStandardMaterial({ color: 0xf5e6cc });
const altar = new THREE.Mesh(new THREE.BoxGeometry(2, 1, 0.8), altarMat);
altar.position.set(ox, 0.5, oz - d / 2 + 1.5);
altar.castShadow = true;
group.add(altar);
// Крест на стене
const crossMat = new THREE.MeshStandardMaterial({ color: 0xdaa520 });
const crossH = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.15, 0.05), crossMat);
crossH.position.set(ox, 3.5, oz - d / 2 + 0.15);
group.add(crossH);
const crossV = new THREE.Mesh(new THREE.BoxGeometry(0.15, 2, 0.05), crossMat);
crossV.position.set(ox, 3.5, oz - d / 2 + 0.15);
group.add(crossV);
// Свечи
const candleMat = new THREE.MeshStandardMaterial({ color: 0xfff8dc });
for (let i = -1; i <= 1; i++) {
const candle = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.2, 6), candleMat);
candle.position.set(ox + i * 0.4, 1.15, oz - d / 2 + 1.5);
group.add(candle);
}
// Отец Михаил у алтаря
this.createInteriorNPC(group, ox + 1.5, 0, oz - d / 2 + 2, 0x222244, false);
// Борода
const beard = new THREE.Mesh(
new THREE.BoxGeometry(0.22, 0.2, 0.12),
new THREE.MeshStandardMaterial({ color: 0x555555 })
);
beard.position.set(ox + 1.5, 1.3, oz - d / 2 + 2 + 0.15);
group.add(beard);
// Нагрудный крест
const priestCrossH = new THREE.Mesh(
new THREE.BoxGeometry(0.12, 0.03, 0.03),
new THREE.MeshStandardMaterial({ color: 0xdaa520 })
);
priestCrossH.position.set(ox + 1.5, 1.0, oz - d / 2 + 2 + 0.3);
group.add(priestCrossH);
const priestCrossV = new THREE.Mesh(
new THREE.BoxGeometry(0.03, 0.18, 0.03),
new THREE.MeshStandardMaterial({ color: 0xdaa520 })
);
priestCrossV.position.set(ox + 1.5, 1.0, oz - d / 2 + 2 + 0.3);
group.add(priestCrossV);
// Дверная рама и табличка
this.addDoorFrame(group, ox, oz, d);
// Тёплый свет
const light = new THREE.PointLight(0xffe0a0, 0.8, 20);
light.position.set(ox, h - 0.5, oz);
group.add(light);
const altarLight = new THREE.PointLight(0xffcc66, 0.5, 8);
altarLight.position.set(ox, 1.5, oz - d / 2 + 1.5);
group.add(altarLight);
this.game.scene.add(group);
this.interiorObjects.push(group);
this.interiorColliders.push(
new THREE.Box3(new THREE.Vector3(ox - w / 2 - 0.1, 0, oz - d / 2 - 0.2), new THREE.Vector3(ox + w / 2 + 0.1, h, oz - d / 2)),
new THREE.Box3(new THREE.Vector3(ox - w / 2 - 0.2, 0, oz - d / 2), new THREE.Vector3(ox - w / 2, h, oz + d / 2)),
new THREE.Box3(new THREE.Vector3(ox + w / 2, 0, oz - d / 2), new THREE.Vector3(ox + w / 2 + 0.2, h, oz + d / 2)),
new THREE.Box3(new THREE.Vector3(ox - w / 2, 0, oz + d / 2 - 0.1), new THREE.Vector3(ox - 0.8, h, oz + d / 2 + 0.1)),
new THREE.Box3(new THREE.Vector3(ox + 0.8, 0, oz + d / 2 - 0.1), new THREE.Vector3(ox + w / 2, h, oz + d / 2 + 0.1)),
new THREE.Box3(new THREE.Vector3(ox - 1, 0, oz - d / 2 + 1.1), new THREE.Vector3(ox + 1, 1, oz - d / 2 + 1.9)),
);
this.interiorInteractables.push(
{
position: new THREE.Vector3(ox, 0, oz - d / 2 + 1.5),
radius: 2.5,
type: 'church_altar',
label: 'Помолиться / Еда',
building: 'church'
},
{
position: new THREE.Vector3(ox, 0, oz + d / 2 - 0.5),
radius: 2,
type: 'exit_door',
label: 'Выйти на улицу',
building: 'church'
}
);
}
enterBuilding(type) {
if (this.isInside) return;
if (!this.built) this.buildInteriors();
this.savedPosition = this.game.player.position.clone();
this.currentBuilding = type;
this.isInside = true;
// Позиция входа в интерьер
const entries = {
shop: new THREE.Vector3(500, 0, 3),
hospital: new THREE.Vector3(500, 0, 53),
church: new THREE.Vector3(500, 0, 106),
};
const entry = entries[type];
if (entry) {
this.game.player.position.copy(entry);
this.game.player.mesh.position.copy(entry);
}
// Зафиксировать погоду/освещение
this.game.scene.fog = null;
// Подключить интерьерные коллайдеры и интерактивные объекты
this._origColliders = this.game.world.colliders;
this._origInteractables = this.game.world.interactables;
this.game.world.colliders = this.interiorColliders;
this.game.world.interactables = this.interiorInteractables.filter(i => i.building === type);
// Скрыть наружные объекты для производительности
this.game.player.position.x = THREE.MathUtils.clamp(this.game.player.position.x, 490, 510);
this.game.player.position.z = THREE.MathUtils.clamp(this.game.player.position.z, -10, 120);
const names = { shop: 'Магазин', hospital: 'Больница', church: 'Церковь' };
this.game.notify(`Вы вошли: ${names[type] || type}`);
}
exitBuilding() {
if (!this.isInside) return;
this.isInside = false;
// Восстановить позицию
if (this.savedPosition) {
this.game.player.position.copy(this.savedPosition);
this.game.player.mesh.position.copy(this.savedPosition);
}
// Восстановить туман
this.game.scene.fog = new THREE.Fog(0x87CEEB, 80, 200);
// Восстановить коллайдеры
if (this._origColliders) this.game.world.colliders = this._origColliders;
if (this._origInteractables) this.game.world.interactables = this._origInteractables;
this.currentBuilding = null;
this.game.notify('Вы вышли на улицу.');
}
reset() {
if (this.isInside) {
this.exitBuilding();
}
this.interiorObjects.forEach(obj => this.game.scene.remove(obj));
this.interiorObjects = [];
this.interiorColliders = [];
this.interiorInteractables = [];
this.built = false;
}
}

201
js/game/Inventory.js Normal file
View File

@@ -0,0 +1,201 @@
export class Inventory {
constructor(game) {
this.game = game;
this.items = {};
this.maxSlots = 20;
this.itemData = {
bottle: { name: 'Бутылка', icon: '🍾', usable: false, desc: 'Пустая бутылка. Можно сдать в магазине за 5₽.' },
bread: { name: 'Хлеб', icon: '🍞', usable: true, desc: 'Кусок хлеба. +20 Сытость.' },
can: { name: 'Консервы', icon: '🥫', usable: true, desc: '+35 Сытость.' },
tea: { name: 'Чай', icon: '🍵', usable: true, desc: '+20 Тепло, +5 Настроение.' },
bandage: { name: 'Бинт', icon: '🩹', usable: true, desc: '+25 Здоровье.' },
clothing: { name: 'Одежда', icon: '🧥', usable: true, desc: '+30 Тепло.' },
newspaper: { name: 'Газета', icon: '📰', usable: true, desc: '+5 Настроение.' },
scrap: { name: 'Хлам', icon: '🔩', usable: false, desc: 'Для крафта и строительства.' },
rope: { name: 'Верёвка', icon: '🪢', usable: false, desc: 'Для крафта и строительства.' },
medkit: { name: 'Аптечка', icon: '💊', usable: true, desc: '+50 Здоровье.' },
stew: { name: 'Похлёбка', icon: '🍲', usable: true, desc: '+50 Сытость, +15 Тепло.' },
blanket: { name: 'Одеяло', icon: '🛏️', usable: true, desc: '+50 Тепло, +10 Настроение.' },
harmonica: { name: 'Губная гармошка', icon: '🎵', usable: true, desc: '+20 Настроение (многоразовое).' },
candle: { name: 'Свеча', icon: '🕯️', usable: false, desc: 'Для крафта.' },
// Новые предметы
fish: { name: 'Рыба', icon: '🐟', usable: true, desc: '+25 Сытость. Свежая рыба.' },
apple: { name: 'Яблоко', icon: '🍎', usable: true, desc: '+10 Сытость, +5 Здоровье.' },
vodka: { name: 'Водка', icon: '🍺', usable: true, desc: '+30 Тепло, -10 Здоровье, +15 Настроение.' },
vitamins: { name: 'Витамины', icon: '💊', usable: true, desc: '+15 Здоровье, +10 Настроение.' },
torch: { name: 'Факел', icon: '🔦', usable: true, desc: '+20 Тепло, +10 Настроение ночью.' },
soap: { name: 'Мыло', icon: '🧼', usable: true, desc: '+30 Гигиена.' },
// Экипировка (в инвентаре до экипировки)
eq_old_hat: { name: 'Старая шапка', icon: '🧢', usable: false, equippable: true, eqKey: 'old_hat', desc: 'Экипировка: +5 Тепло' },
eq_hood: { name: 'Капюшон', icon: '🪖', usable: false, equippable: true, eqKey: 'hood', desc: 'Экипировка: +8 Тепло, +2 Защита' },
eq_warm_hat: { name: 'Тёплая шапка', icon: '🎩', usable: false, equippable: true, eqKey: 'warm_hat', desc: 'Экипировка: +15 Тепло, +3 Настроение' },
eq_helmet: { name: 'Каска', icon: '⛑️', usable: false, equippable: true, eqKey: 'helmet', desc: 'Экипировка: +8 Защита' },
eq_old_jacket: { name: 'Драная куртка', icon: '🧥', usable: false, equippable: true, eqKey: 'old_jacket', desc: 'Экипировка: +10 Тепло' },
eq_coat: { name: 'Пальто', icon: '🧥', usable: false, equippable: true, eqKey: 'coat', desc: 'Экипировка: +18 Тепло, +3 Защита' },
eq_warm_jacket: { name: 'Тёплая куртка', icon: '🧥', usable: false, equippable: true, eqKey: 'warm_jacket', desc: 'Экипировка: +25 Тепло, +5 Защита' },
eq_vest: { name: 'Жилетка', icon: '🦺', usable: false, equippable: true, eqKey: 'vest', desc: 'Экипировка: +10 Защита' },
eq_old_boots: { name: 'Рваные ботинки', icon: '👞', usable: false, equippable: true, eqKey: 'old_boots', desc: 'Экипировка: +5 Тепло' },
eq_boots: { name: 'Ботинки', icon: '🥾', usable: false, equippable: true, eqKey: 'boots', desc: 'Экипировка: +10 Тепло, +3 Защита' },
eq_warm_boots: { name: 'Тёплые сапоги', icon: '🥾', usable: false, equippable: true, eqKey: 'warm_boots', desc: 'Экипировка: +18 Тепло' },
eq_old_gloves: { name: 'Дырявые перчатки', icon: '🧤', usable: false, equippable: true, eqKey: 'old_gloves', desc: 'Экипировка: +3 Тепло' },
eq_gloves: { name: 'Перчатки', icon: '🧤', usable: false, equippable: true, eqKey: 'gloves', desc: 'Экипировка: +8 Тепло, +2 Защита' },
eq_warm_gloves: { name: 'Тёплые перчатки', icon: '🧤', usable: false, equippable: true, eqKey: 'warm_gloves', desc: 'Экипировка: +14 Тепло' },
// Оружие
eq_stick: { name: 'Палка', icon: '🏑', usable: false, equippable: false, desc: 'Оружие: +15% шанс отбиться.' },
eq_pipe: { name: 'Труба', icon: '🔧', usable: false, equippable: false, desc: 'Оружие: +25% шанс отбиться.' },
};
// Рецепты крафта
this.recipes = [
{
name: 'Аптечка',
result: 'medkit',
ingredients: { bandage: 2, bottle: 1 },
desc: '2x Бинт + 1x Бутылка'
},
{
name: 'Похлёбка',
result: 'stew',
ingredients: { can: 1, bread: 1 },
desc: '1x Консервы + 1x Хлеб'
},
{
name: 'Одеяло',
result: 'blanket',
ingredients: { clothing: 2, newspaper: 2 },
desc: '2x Одежда + 2x Газета'
},
{
name: 'Губная гармошка',
result: 'harmonica',
ingredients: { scrap: 3 },
desc: '3x Хлам'
},
{
name: 'Факел',
result: 'torch',
ingredients: { scrap: 1, rope: 1, candle: 1 },
desc: '1x Хлам + 1x Верёвка + 1x Свеча'
},
{
name: 'Витамины',
result: 'vitamins',
ingredients: { apple: 2, tea: 1 },
desc: '2x Яблоко + 1x Чай'
},
{
name: 'Мыло',
result: 'soap',
ingredients: { bottle: 1, scrap: 1 },
desc: '1x Бутылка + 1x Хлам'
},
// Экипировка
{
name: 'Капюшон',
result: 'eq_hood',
ingredients: { clothing: 2, rope: 1 },
desc: '2x Одежда + 1x Верёвка'
},
{
name: 'Тёплая шапка',
result: 'eq_warm_hat',
ingredients: { clothing: 3, rope: 1 },
desc: '3x Одежда + 1x Верёвка'
},
{
name: 'Тёплая куртка',
result: 'eq_warm_jacket',
ingredients: { clothing: 4, rope: 2 },
desc: '4x Одежда + 2x Верёвка'
},
{
name: 'Тёплые перчатки',
result: 'eq_warm_gloves',
ingredients: { clothing: 2 },
desc: '2x Одежда'
},
{
name: 'Палка',
result: 'eq_stick',
ingredients: { scrap: 2, rope: 1 },
desc: '2x Хлам + 1x Верёвка'
},
{
name: 'Труба',
result: 'eq_pipe',
ingredients: { scrap: 4, rope: 1 },
desc: '4x Хлам + 1x Верёвка'
},
];
}
addItem(key, count = 1) {
if (!this.items[key]) this.items[key] = 0;
this.items[key] += count;
}
removeItem(key, count = 1) {
if (!this.items[key]) return;
this.items[key] -= count;
if (this.items[key] <= 0) delete this.items[key];
}
getCount(key) {
return this.items[key] || 0;
}
getAll() {
return Object.entries(this.items).map(([key, count]) => ({
key,
count,
...this.itemData[key]
}));
}
useItem(key) {
const data = this.itemData[key];
if (!data) return false;
// Экипировка
if (data.equippable) {
this.removeItem(key, 1);
this.game.equipment.equip(data.eqKey);
return true;
}
if (!data.usable) return false;
if (this.getCount(key) <= 0) return false;
return this.game.player.useItem(key);
}
canCraft(recipe) {
for (const [key, needed] of Object.entries(recipe.ingredients)) {
if (this.getCount(key) < needed) return false;
}
return true;
}
craft(recipe) {
if (!this.canCraft(recipe)) return false;
for (const [key, needed] of Object.entries(recipe.ingredients)) {
this.removeItem(key, needed);
}
this.addItem(recipe.result, 1);
this.game.sound.playPickup();
this.game.notify(`Создано: ${recipe.name}`, 'good');
this.game.skills.addXP('scavenging', 3);
this.game.questSystem.onEvent('craft_item');
this.game.totalCrafted++;
if (this.game.totalCrafted >= 5) {
this.game.achievements.check('crafter');
}
return true;
}
reset() {
this.items = {};
this.maxSlots = 20;
}
}

188
js/game/JobSystem.js Normal file
View File

@@ -0,0 +1,188 @@
export class JobSystem {
constructor(game) {
this.game = game;
this.jobs = [];
this.activeJob = null;
this.jobTimer = 0;
this.completedToday = 0;
this.lastDay = 0;
}
init() {
this.generateJobs();
}
generateJobs() {
const allJobs = [
{ id: 'wash_car', name: 'Помыть машину', pay: 40, duration: 15, desc: 'Помыть машину на парковке', location: 'parking', skill: 'trading' },
{ id: 'unload', name: 'Разгрузить товар', pay: 80, duration: 25, desc: 'Разгрузить товар у магазина', location: 'shop', skill: 'survival' },
{ id: 'flyers', name: 'Раздать листовки', pay: 30, duration: 12, desc: 'Раздавать листовки на дороге', location: 'road', skill: 'begging' },
{ id: 'sweep', name: 'Подмести двор', pay: 25, duration: 10, desc: 'Навести порядок у церкви', location: 'church', skill: 'survival' },
{ id: 'help_granny', name: 'Помочь с сумками', pay: 35, duration: 15, desc: 'Донести сумки в парк', location: 'park', skill: 'survival' },
{ id: 'collect_trash', name: 'Собрать мусор', pay: 20, duration: 8, desc: 'Собрать мусор в парке', location: 'park', skill: 'scavenging' },
{ id: 'guard_stuff', name: 'Посторожить багаж', pay: 45, duration: 20, desc: 'Посторожить вещи на остановке', location: 'busstop', skill: 'survival' },
];
// Выбираем 3 случайных
const shuffled = allJobs.sort(() => Math.random() - 0.5);
this.jobs = shuffled.slice(0, 3).map(j => ({ ...j, available: true }));
}
getLocationPosition(loc) {
const positions = {
parking: { x: 40, z: -55 },
shop: { x: 20, z: -20 },
road: { x: 0, z: 0 },
church: { x: 70, z: 50 },
park: { x: -30, z: 40 },
busstop: { x: -38, z: -10 },
};
return positions[loc] || { x: 0, z: 0 };
}
getLocationName(loc) {
const names = {
parking: 'парковке',
shop: 'магазину',
road: 'дороге',
church: 'церкви',
park: 'парку',
busstop: 'остановке',
};
return names[loc] || 'месту';
}
showJobBoard() {
if (this.activeJob) {
this.game.notify('Вы уже выполняете работу!');
return;
}
const availableJobs = this.jobs.filter(j => j.available);
if (availableJobs.length === 0) {
this.game.ui.showDialog('Доска объявлений', 'Сейчас нет доступных подработок. Загляните завтра.', [
'Ладно'
], () => this.game.ui.hideDialog());
return;
}
const payMod = this.game.reputation.getJobPayModifier();
const choices = availableJobs.map(j => {
const pay = Math.floor(j.pay * payMod);
return `${j.name}${pay}₽ (${j.desc})`;
});
choices.push('Уйти');
this.game.sound.playDialogOpen();
this.game.ui.showDialog('Доска объявлений', 'Доступные подработки:', choices, (index) => {
if (index < availableJobs.length) {
this.startJob(availableJobs[index]);
}
this.game.ui.hideDialog();
});
}
startJob(job) {
this.activeJob = { ...job };
this.jobTimer = 0;
this.activeJob.isWorking = false;
this.activeJob.targetPos = this.getLocationPosition(job.location);
const locName = this.getLocationName(job.location);
this.game.notify(`Работа принята: ${job.name}. Идите к ${locName}!`);
}
update(dt) {
// Обновляем список работ при новом дне
if (this.game.gameDay !== this.lastDay) {
this.lastDay = this.game.gameDay;
this.completedToday = 0;
this.generateJobs();
}
if (!this.activeJob) return;
const player = this.game.player;
const pos = this.activeJob.targetPos;
const dx = player.position.x - pos.x;
const dz = player.position.z - pos.z;
const dist = Math.sqrt(dx * dx + dz * dz);
if (dist < 10) {
if (!this.activeJob.isWorking) {
this.activeJob.isWorking = true;
this.game.notify('Вы на месте. Работаете... (оставайтесь рядом)');
}
this.jobTimer += dt;
const progress = Math.min(1, this.jobTimer / this.activeJob.duration);
this.game.ui.updateJobProgress(progress, this.activeJob.name);
if (this.jobTimer >= this.activeJob.duration) {
this.completeJob();
}
} else if (this.activeJob.isWorking) {
this.activeJob.isWorking = false;
this.game.notify('Вы ушли с рабочего места! Вернитесь!', 'bad');
}
}
completeJob() {
const payMod = this.game.reputation.getJobPayModifier();
const pay = Math.floor(this.activeJob.pay * payMod);
this.game.player.stats.money += pay;
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 8);
this.game.sound.playCoin();
this.game.reputation.change(3);
this.game.skills.addXP(this.activeJob.skill || 'survival', 3);
this.game.notify(`Работа выполнена! +${pay}₽, +8 Настроение`, 'good');
this.game.questSystem.onEvent('complete_job');
this.game.totalJobsCompleted++;
// Ачивки
this.game.achievements.check('first_job_done');
if (this.game.totalJobsCompleted >= 10) {
this.game.achievements.check('jobs_10');
}
const idx = this.jobs.findIndex(j => j.id === this.activeJob.id);
if (idx >= 0) this.jobs[idx].available = false;
this.activeJob = null;
this.jobTimer = 0;
this.completedToday++;
this.game.ui.hideJobProgress();
}
cancelJob() {
if (this.activeJob) {
this.game.notify('Работа отменена.', 'bad');
this.game.reputation.change(-2);
this.activeJob = null;
this.jobTimer = 0;
this.game.ui.hideJobProgress();
}
}
getSaveData() {
return {
completedToday: this.completedToday,
lastDay: this.lastDay
};
}
loadSaveData(data) {
if (data) {
this.completedToday = data.completedToday || 0;
this.lastDay = data.lastDay || 0;
}
}
reset() {
this.activeJob = null;
this.jobTimer = 0;
this.completedToday = 0;
this.lastDay = 0;
this.generateJobs();
}
}

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

302
js/game/Particles.js Normal file
View File

@@ -0,0 +1,302 @@
import * as THREE from 'three';
export class ParticleSystem {
constructor(game) {
this.game = game;
this.systems = [];
}
createFire(position, opts = {}) {
const count = opts.count || 60;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
const lifetimes = new Float32Array(count);
const sizes = new Float32Array(count);
for (let i = 0; i < count; i++) {
this.resetFireParticle(positions, velocities, lifetimes, sizes, i, position);
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geo.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const mat = new THREE.PointsMaterial({
color: 0xff6622,
size: 0.25,
transparent: true,
opacity: 0.8,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true
});
const mesh = new THREE.Points(geo, mat);
this.game.scene.add(mesh);
this.systems.push({
type: 'fire',
mesh, geo, positions, velocities, lifetimes, sizes,
count, origin: position.clone(),
resetFn: (i) => this.resetFireParticle(positions, velocities, lifetimes, sizes, i, position)
});
return mesh;
}
resetFireParticle(positions, velocities, lifetimes, sizes, i, origin) {
const i3 = i * 3;
positions[i3] = origin.x + (Math.random() - 0.5) * 0.4;
positions[i3 + 1] = origin.y + Math.random() * 0.2;
positions[i3 + 2] = origin.z + (Math.random() - 0.5) * 0.4;
velocities[i3] = (Math.random() - 0.5) * 0.3;
velocities[i3 + 1] = 1.0 + Math.random() * 2.0;
velocities[i3 + 2] = (Math.random() - 0.5) * 0.3;
lifetimes[i] = Math.random() * 1.5;
sizes[i] = 0.15 + Math.random() * 0.2;
}
createRain() {
const count = 3000;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
positions[i3] = (Math.random() - 0.5) * 120;
positions[i3 + 1] = Math.random() * 40;
positions[i3 + 2] = (Math.random() - 0.5) * 120;
velocities[i3] = -0.5;
velocities[i3 + 1] = -15 - Math.random() * 10;
velocities[i3 + 2] = -1;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0xaaccff,
size: 0.08,
transparent: true,
opacity: 0.5,
depthWrite: false
});
const mesh = new THREE.Points(geo, mat);
mesh.visible = false;
this.game.scene.add(mesh);
this.systems.push({
type: 'rain',
mesh, geo, positions, velocities, count
});
return mesh;
}
createSnow() {
const count = 2000;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
const phases = new Float32Array(count);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
positions[i3] = (Math.random() - 0.5) * 120;
positions[i3 + 1] = Math.random() * 40;
positions[i3 + 2] = (Math.random() - 0.5) * 120;
velocities[i3] = 0;
velocities[i3 + 1] = -1.5 - Math.random() * 1.5;
velocities[i3 + 2] = 0;
phases[i] = Math.random() * Math.PI * 2;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0xffffff,
size: 0.15,
transparent: true,
opacity: 0.8,
depthWrite: false
});
const mesh = new THREE.Points(geo, mat);
mesh.visible = false;
this.game.scene.add(mesh);
this.systems.push({
type: 'snow',
mesh, geo, positions, velocities, count, phases,
time: 0
});
return mesh;
}
createSparks(position) {
const count = 20;
const geo = new THREE.BufferGeometry();
const positions = new Float32Array(count * 3);
const velocities = new Float32Array(count * 3);
const lifetimes = new Float32Array(count);
for (let i = 0; i < count; i++) {
const i3 = i * 3;
positions[i3] = position.x;
positions[i3 + 1] = position.y + 0.5;
positions[i3 + 2] = position.z;
const angle = Math.random() * Math.PI * 2;
const speed = 1 + Math.random() * 3;
velocities[i3] = Math.cos(angle) * speed * 0.3;
velocities[i3 + 1] = 2 + Math.random() * 4;
velocities[i3 + 2] = Math.sin(angle) * speed * 0.3;
lifetimes[i] = 0.5 + Math.random() * 1.0;
}
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
color: 0xffaa00,
size: 0.08,
transparent: true,
opacity: 1,
blending: THREE.AdditiveBlending,
depthWrite: false
});
const mesh = new THREE.Points(geo, mat);
this.game.scene.add(mesh);
this.systems.push({
type: 'sparks',
mesh, geo, positions, velocities, lifetimes, count,
origin: position.clone(), age: 0, maxAge: 1.5
});
}
update(dt) {
const playerPos = this.game.player.position;
for (let s = this.systems.length - 1; s >= 0; s--) {
const sys = this.systems[s];
if (sys.type === 'fire') {
this.updateFire(sys, dt);
} else if (sys.type === 'rain') {
this.updateWeatherParticles(sys, dt, playerPos);
} else if (sys.type === 'snow') {
this.updateSnow(sys, dt, playerPos);
} else if (sys.type === 'sparks') {
sys.age += dt;
if (sys.age > sys.maxAge) {
this.game.scene.remove(sys.mesh);
sys.geo.dispose();
sys.mesh.material.dispose();
this.systems.splice(s, 1);
continue;
}
this.updateSparks(sys, dt);
}
}
}
updateFire(sys, dt) {
const pos = sys.positions;
const vel = sys.velocities;
const life = sys.lifetimes;
for (let i = 0; i < sys.count; i++) {
const i3 = i * 3;
life[i] -= dt;
if (life[i] <= 0) {
sys.resetFn(i);
continue;
}
pos[i3] += vel[i3] * dt;
pos[i3 + 1] += vel[i3 + 1] * dt;
pos[i3 + 2] += vel[i3 + 2] * dt;
vel[i3 + 1] += dt * 0.5; // подъём
}
sys.geo.attributes.position.needsUpdate = true;
sys.mesh.material.opacity = 0.5 + Math.sin(Date.now() * 0.01) * 0.3;
}
updateWeatherParticles(sys, dt, playerPos) {
const pos = sys.positions;
const vel = sys.velocities;
for (let i = 0; i < sys.count; i++) {
const i3 = i * 3;
pos[i3] += vel[i3] * dt;
pos[i3 + 1] += vel[i3 + 1] * dt;
pos[i3 + 2] += vel[i3 + 2] * dt;
if (pos[i3 + 1] < 0) {
pos[i3] = playerPos.x + (Math.random() - 0.5) * 120;
pos[i3 + 1] = 30 + Math.random() * 10;
pos[i3 + 2] = playerPos.z + (Math.random() - 0.5) * 120;
}
}
sys.geo.attributes.position.needsUpdate = true;
}
updateSnow(sys, dt, playerPos) {
const pos = sys.positions;
const vel = sys.velocities;
sys.time += dt;
for (let i = 0; i < sys.count; i++) {
const i3 = i * 3;
pos[i3] += vel[i3] * dt + Math.sin(sys.time * 2 + sys.phases[i]) * 0.3 * dt;
pos[i3 + 1] += vel[i3 + 1] * dt;
pos[i3 + 2] += vel[i3 + 2] * dt + Math.cos(sys.time * 1.5 + sys.phases[i]) * 0.3 * dt;
if (pos[i3 + 1] < 0) {
pos[i3] = playerPos.x + (Math.random() - 0.5) * 120;
pos[i3 + 1] = 30 + Math.random() * 10;
pos[i3 + 2] = playerPos.z + (Math.random() - 0.5) * 120;
}
}
sys.geo.attributes.position.needsUpdate = true;
}
updateSparks(sys, dt) {
const pos = sys.positions;
const vel = sys.velocities;
for (let i = 0; i < sys.count; i++) {
const i3 = i * 3;
pos[i3] += vel[i3] * dt;
pos[i3 + 1] += vel[i3 + 1] * dt;
pos[i3 + 2] += vel[i3 + 2] * dt;
vel[i3 + 1] -= 9.8 * dt; // гравитация
}
sys.geo.attributes.position.needsUpdate = true;
sys.mesh.material.opacity = 1 - (sys.age / sys.maxAge);
}
setWeather(type) {
this.systems.forEach(sys => {
if (sys.type === 'rain') sys.mesh.visible = (type === 'rain');
if (sys.type === 'snow') sys.mesh.visible = (type === 'snow');
});
}
clear() {
this.systems.forEach(sys => {
this.game.scene.remove(sys.mesh);
sys.geo.dispose();
sys.mesh.material.dispose();
});
this.systems = [];
}
}

1174
js/game/Player.js Normal file

File diff suppressed because it is too large Load Diff

274
js/game/Police.js Normal file
View File

@@ -0,0 +1,274 @@
import * as THREE from 'three';
export class Police {
constructor(game) {
this.game = game;
this.officers = [];
this.warningCooldown = 0;
}
spawnPatrols() {
this.officers = [];
// Два патруля по дорогам
const routes = [
// Патруль 1: вдоль главной E-W дороги (тротуар z=9)
[
{ x: -50, z: 9 },
{ x: -10, z: 9 },
{ x: 25, z: 9 },
{ x: 50, z: 9 },
{ x: 25, z: 9 },
{ x: -10, z: 9 },
],
// Патруль 2: вдоль N-S (тротуар x=9) и южной
[
{ x: 9, z: -35 },
{ x: 9, z: -5 },
{ x: 9, z: 15 },
{ x: 9, z: 40 },
{ x: 9, z: 15 },
{ x: 9, z: -5 },
],
];
routes.forEach((waypoints, i) => {
const officer = {
position: new THREE.Vector3(waypoints[0].x, 0, waypoints[0].z),
waypoints,
currentWP: 0,
state: 'patrol', // patrol, warning, chase, fine
speed: 3,
chaseSpeed: 5.5,
detectRange: 20,
warnTimer: 0,
mesh: null,
warningGiven: false,
};
officer.mesh = this.createOfficerMesh();
officer.mesh.position.copy(officer.position);
this.game.scene.add(officer.mesh);
this.officers.push(officer);
});
}
createOfficerMesh() {
const group = new THREE.Group();
// Тело (синяя форма)
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x1a3366 });
const body = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.37, 1.15, 8), bodyMat);
body.position.y = 0.85;
body.castShadow = true;
group.add(body);
// Голова
const head = new THREE.Mesh(
new THREE.SphereGeometry(0.23, 8, 6),
new THREE.MeshStandardMaterial({ color: 0xc49070 })
);
head.position.y = 1.57;
group.add(head);
// Фуражка
const capBrim = new THREE.Mesh(
new THREE.CylinderGeometry(0.28, 0.28, 0.04, 8),
new THREE.MeshStandardMaterial({ color: 0x1a2244 })
);
capBrim.position.y = 1.72;
group.add(capBrim);
const capTop = new THREE.Mesh(
new THREE.CylinderGeometry(0.18, 0.22, 0.14, 8),
new THREE.MeshStandardMaterial({ color: 0x1a2244 })
);
capTop.position.y = 1.80;
group.add(capTop);
// Ноги
const legMat = new THREE.MeshStandardMaterial({ color: 0x1a2244 });
[-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);
});
return group;
}
update(dt) {
if (this.warningCooldown > 0) this.warningCooldown -= dt;
// Ночью полиция не патрулирует
const hour = this.game.gameTime / 60;
const isNightTime = hour < 6 || hour > 22;
this.officers.forEach(officer => {
if (isNightTime) {
// Уходят за карту ночью
if (officer.mesh) officer.mesh.visible = false;
officer.state = 'patrol';
return;
}
if (officer.mesh) officer.mesh.visible = true;
switch (officer.state) {
case 'patrol':
this.updatePatrol(officer, dt);
this.checkViolation(officer);
break;
case 'warning':
this.updateWarning(officer, dt);
break;
case 'chase':
this.updateChase(officer, dt);
break;
case 'fine':
// Dialog in progress, do nothing
break;
}
});
}
updatePatrol(officer, dt) {
const wp = officer.waypoints[officer.currentWP];
const target = new THREE.Vector3(wp.x, 0, wp.z);
const dir = target.clone().sub(officer.position);
const dist = dir.length();
if (dist < 1) {
officer.currentWP = (officer.currentWP + 1) % officer.waypoints.length;
} else {
dir.normalize();
officer.position.add(dir.multiplyScalar(officer.speed * dt));
officer.mesh.position.copy(officer.position);
officer.mesh.rotation.y = Math.atan2(dir.x, dir.z);
}
}
checkViolation(officer) {
const playerPos = this.game.player.position;
const dist = officer.position.distanceTo(playerPos);
const detectRange = this.getDetectRange();
if (dist > detectRange) return;
// Попрошайничество в зоне видимости
if (this.game.player.isBegging) {
officer.state = 'warning';
officer.warnTimer = 3;
officer.warningGiven = true;
this.game.notify('Полиция: "Прекратите попрошайничать!"', 'bad');
return;
}
// Очень низкая репутация
if (this.game.reputation.value < -30 && dist < 15) {
officer.state = 'chase';
this.game.notify('Полиция: "Стоять! Проверка документов!"', 'bad');
}
}
updateWarning(officer, dt) {
officer.warnTimer -= dt;
// Смотреть на игрока
const dir = this.game.player.position.clone().sub(officer.position);
officer.mesh.rotation.y = Math.atan2(dir.x, dir.z);
if (officer.warnTimer <= 0) {
// Если всё ещё попрошайничает — переход в chase
if (this.game.player.isBegging) {
officer.state = 'chase';
this.game.notify('Полиция: "Я предупреждал!"', 'bad');
} else {
officer.state = 'patrol';
officer.warningGiven = false;
}
}
}
updateChase(officer, dt) {
const playerPos = this.game.player.position;
const dir = playerPos.clone().sub(officer.position);
const dist = dir.length();
if (dist < 2) {
// Поймал
officer.state = 'fine';
this.showFineDialog(officer);
return;
}
if (dist > 50) {
// Потерял
officer.state = 'patrol';
this.game.notify('Полиция потеряла вас из виду.');
return;
}
dir.normalize();
officer.position.add(dir.multiplyScalar(officer.chaseSpeed * dt));
officer.mesh.position.copy(officer.position);
officer.mesh.rotation.y = Math.atan2(dir.x, dir.z);
}
showFineDialog(officer) {
this.game.player.stopBegging();
this.game.player.stopBusking();
const fineAmount = 50 + Math.floor(Math.random() * 50);
this.game.ui.showDialog('Полицейский', `Нарушение общественного порядка. Штраф ${fineAmount} ₽.`, [
`Заплатить штраф (${fineAmount} ₽)`,
'Нет денег...'
], (index) => {
if (index === 0) {
if (this.game.player.stats.money >= fineAmount) {
this.game.player.stats.money -= fineAmount;
this.game.notify(`Оплачен штраф ${fineAmount} ₽.`, 'bad');
} else {
this.game.notify('Не хватает денег. Конфискация предметов.', 'bad');
this.confiscateItem();
}
} else {
this.game.notify('Конфискация предметов.', 'bad');
this.confiscateItem();
}
this.game.reputation.change(-10);
officer.state = 'patrol';
this.game.ui.hideDialog();
});
}
confiscateItem() {
const inv = this.game.inventory;
const items = Object.keys(inv.items).filter(k => inv.items[k] > 0);
if (items.length > 0) {
const item = items[Math.floor(Math.random() * items.length)];
const name = inv.itemData[item]?.name || item;
inv.removeItem(item, 1);
this.game.notify(`Конфисковано: ${name}`, 'bad');
}
}
getDetectRange() {
const rep = this.game.reputation.value;
if (rep >= 50) return 0; // Уважаемых не трогают
if (rep >= 20) return 10;
if (rep <= -50) return 25;
if (rep <= -20) return 22;
return 20;
}
reset() {
this.officers.forEach(o => {
if (o.mesh) this.game.scene.remove(o.mesh);
});
this.officers = [];
this.warningCooldown = 0;
}
}

440
js/game/QuestSystem.js Normal file
View File

@@ -0,0 +1,440 @@
export class QuestSystem {
constructor(game) {
this.game = game;
this.quests = [];
this.completedQuests = [];
}
initQuests() {
this.quests = [];
this.completedQuests = [];
this.addQuest({
id: 'first_search',
title: 'Первые поиски',
description: 'Обыщите мусорку и найдите что-нибудь полезное.',
target: 1,
progress: 0,
event: 'collect_bottle',
altEvents: ['find_food', 'find_money'],
location: { x: -20, z: -10 },
reward: () => {
this.game.player.stats.mood += 10;
this.game.notify('Квест выполнен! +10 Настроение');
}
});
this.addQuest({
id: 'bottle_collector',
title: 'Собиратель бутылок',
description: 'Соберите 5 пустых бутылок.',
target: 5,
progress: 0,
event: 'collect_bottle',
reward: () => {
this.game.player.stats.money += 30;
this.game.notify('Квест выполнен! +30 ₽');
}
});
this.addQuest({
id: 'meet_serega',
title: 'Новые знакомства',
description: 'Поговорите с Серёгой в укрытии.',
target: 1,
progress: 0,
event: 'talk_npc',
eventFilter: 'Серёга',
location: { x: -35, z: 28 },
reward: () => {
this.game.player.stats.mood += 15;
this.game.notify('Квест выполнен! +15 Настроение. Серёга теперь ваш друг.');
}
});
this.addQuest({
id: 'first_night',
title: 'Первая ночь',
description: 'Переживите первую ночь — поспите в укрытии.',
target: 1,
progress: 0,
event: 'sleep',
location: { x: -45, z: 40 },
reward: () => {
this.game.player.stats.health = Math.min(100, this.game.player.stats.health + 10);
this.game.notify('Квест выполнен! +10 Здоровье');
}
});
this.addQuest({
id: 'earn_money',
title: 'Первый заработок',
description: 'Заработайте 50 ₽ любым способом.',
target: 50,
progress: 0,
event: 'find_money',
cumulative: true,
reward: () => {
this.game.player.stats.mood += 20;
this.game.notify('Квест выполнен! +20 Настроение. Вы учитесь выживать!');
}
});
this.addQuest({
id: 'talk_granny',
title: 'Добрая душа',
description: 'Поговорите с бабушкой Зиной в парке.',
target: 1,
progress: 0,
event: 'talk_npc',
eventFilter: 'Бабушка Зина',
location: { x: -22, z: 22 },
reward: () => {
this.game.player.stats.mood += 10;
this.game.notify('Квест выполнен! Бабушка Зина вас не забудет.');
}
});
this.addQuest({
id: 'survive_3days',
title: 'Стойкий',
description: 'Проживите 3 дня.',
target: 3,
progress: 0,
event: 'new_day',
reward: () => {
this.game.player.stats.mood += 25;
this.game.player.stats.health = 100;
this.game.notify('Квест выполнен! Вы стали крепче. Полное здоровье!');
}
});
// === Новые квесты ===
this.addQuest({
id: 'adopt_dog',
title: 'Верный друг',
description: 'Приручите бездомного пса.',
target: 1,
progress: 0,
event: 'adopt_dog',
reward: () => {
this.game.player.stats.mood += 25;
this.game.notify('Квест выполнен! Теперь у вас есть верный друг! +25 Настроение', 'good');
}
});
this.addQuest({
id: 'first_beg',
title: 'Просящему дастся',
description: 'Успешно попросите милостыню 3 раза.',
target: 3,
progress: 0,
event: 'beg',
reward: () => {
this.game.player.stats.money += 50;
this.game.sound.playCoin();
this.game.notify('Квест выполнен! +50 ₽ бонус', 'good');
}
});
this.addQuest({
id: 'visit_church',
title: 'Утешение',
description: 'Посетите церковь.',
target: 1,
progress: 0,
event: 'visit_church',
location: { x: 28, z: 35 },
reward: () => {
this.game.player.stats.mood = Math.min(100, this.game.player.stats.mood + 15);
this.game.player.stats.health = Math.min(100, this.game.player.stats.health + 10);
this.game.notify('Квест выполнен! +15 Настроение, +10 Здоровье', 'good');
}
});
this.addQuest({
id: 'sobriety',
title: 'Трезвость',
description: 'Преодолейте зависимость от алкоголя.',
target: 1,
progress: 0,
event: 'sobriety',
reward: () => {
this.game.player.stats.mood += 30;
this.game.player.stats.health = Math.min(100, this.game.player.stats.health + 20);
this.game.notify('Квест выполнен! Вы завязали! +30 Настроение, +20 Здоровье', 'good');
}
});
this.addQuest({
id: 'wash_fountain',
title: 'Чистота — залог здоровья',
description: 'Помойтесь у фонтанчика.',
target: 1,
progress: 0,
event: 'wash',
reward: () => {
this.game.player.stats.mood += 15;
this.game.player.stats.health = Math.min(100, this.game.player.stats.health + 10);
this.game.notify('Квест выполнен! +15 Настроение, +10 Здоровье', 'good');
}
});
this.addQuest({
id: 'street_musician',
title: 'Уличный музыкант',
description: 'Заработайте бускингом 3 раза.',
target: 3,
progress: 0,
event: 'busking',
reward: () => {
this.game.player.stats.mood += 20;
this.game.player.stats.money += 50;
this.game.sound.playCoin();
this.game.notify('Квест выполнен! +20 Настроение, +50₽ бонус', 'good');
}
});
this.addQuest({
id: 'first_craft',
title: 'Мастер на все руки',
description: 'Создайте любой предмет в крафте.',
target: 1,
progress: 0,
event: 'craft_item',
reward: () => {
this.game.player.stats.mood += 15;
this.game.inventory.addItem('scrap', 3);
this.game.notify('Квест выполнен! +15 Настроение, +3 Хлам', 'good');
}
});
this.addQuest({
id: 'survive_7days',
title: 'Закалённый',
description: 'Проживите 7 дней на улице.',
target: 7,
progress: 0,
event: 'new_day',
reward: () => {
this.game.player.stats.health = 100;
this.game.player.stats.hunger = 100;
this.game.player.stats.warmth = 100;
this.game.player.stats.mood = 100;
this.game.notify('Квест выполнен! Все статы восстановлены до максимума!', 'good');
}
});
this.addQuest({
id: 'rich',
title: 'Первый капитал',
description: 'Накопите 300 ₽.',
target: 300,
progress: 0,
event: 'find_money',
cumulative: true,
reward: () => {
this.game.player.stats.mood += 30;
this.game.notify('Квест выполнен! +30 Настроение. Вы на верном пути!', 'good');
}
});
// === Квесты новых систем ===
this.addQuest({
id: 'first_job',
title: 'Рабочий человек',
description: 'Выполните первую подработку.',
target: 1,
progress: 0,
event: 'complete_job',
location: { x: 22, z: -10 },
reward: () => {
this.game.player.stats.mood += 15;
this.game.player.stats.money += 30;
this.game.sound.playCoin();
this.game.notify('Квест выполнен! +15 Настроение, +30₽ бонус', 'good');
}
});
this.addQuest({
id: 'hard_worker',
title: 'Трудяга',
description: 'Выполните 5 подработок.',
target: 5,
progress: 0,
event: 'complete_job',
reward: () => {
this.game.player.stats.mood += 25;
this.game.player.stats.money += 100;
this.game.sound.playCoin();
this.game.notify('Квест выполнен! +25 Настроение, +100₽! Вас ценят!', 'good');
this.game.reputation.change(10);
}
});
this.addQuest({
id: 'survive_winter',
title: 'Зимовка',
description: 'Переживите зиму.',
target: 1,
progress: 0,
event: 'survive_winter',
reward: () => {
this.game.player.stats.health = 100;
this.game.player.stats.warmth = 100;
this.game.notify('Квест выполнен! Вы пережили зиму! Здоровье и Тепло восстановлены!', 'good');
}
});
this.addQuest({
id: 'respected',
title: 'Уважаемый человек',
description: 'Достигните репутации "Уважаемый".',
target: 1,
progress: 0,
event: 'reputation_level',
reward: () => {
this.game.player.stats.mood = 100;
this.game.player.stats.money += 200;
this.game.sound.playCoin();
this.game.notify('Квест выполнен! Вас уважают! +200₽, Настроение MAX', 'good');
}
});
// === Квесты для новых систем ===
this.addQuest({
id: 'build_shelter',
title: 'Свой угол',
description: 'Постройте собственное укрытие.',
target: 1,
progress: 0,
event: 'build_shelter',
location: { x: -32, z: 42 },
reward: () => {
this.game.player.stats.mood += 30;
this.game.player.stats.health = Math.min(100, this.game.player.stats.health + 20);
this.game.notify('Квест выполнен! Теперь у вас есть дом! +30 Настроение, +20 Здоровье', 'good');
}
});
this.addQuest({
id: 'full_equipment',
title: 'Полная экипировка',
description: 'Экипируйте предметы во все 4 слота.',
target: 1,
progress: 0,
event: 'full_equipment',
reward: () => {
this.game.player.stats.mood += 20;
this.game.player.stats.warmth = Math.min(100, this.game.player.stats.warmth + 30);
this.game.notify('Квест выполнен! Вы полностью одеты! +20 Настроение, +30 Тепло', 'good');
}
});
this.addQuest({
id: 'visit_hospital',
title: 'На поправку',
description: 'Посетите больницу.',
target: 1,
progress: 0,
event: 'visit_hospital',
location: { x: -28, z: -60 },
reward: () => {
this.game.player.stats.health = Math.min(100, this.game.player.stats.health + 25);
this.game.notify('Квест выполнен! +25 Здоровье', 'good');
}
});
this.addQuest({
id: 'defeat_enemy',
title: 'Самозащита',
description: 'Отбейтесь от врага.',
target: 1,
progress: 0,
event: 'defeat_enemy',
reward: () => {
this.game.player.stats.mood += 15;
this.game.reputation.change(5);
this.game.notify('Квест выполнен! +15 Настроение, +5 Репутация', 'good');
}
});
this.addQuest({
id: 'craft_5',
title: 'Умелые руки',
description: 'Создайте 5 предметов в крафте.',
target: 5,
progress: 0,
event: 'craft_item',
reward: () => {
this.game.player.stats.mood += 20;
this.game.inventory.addItem('scrap', 5);
this.game.inventory.addItem('rope', 3);
this.game.notify('Квест выполнен! +20 Настроение, +5 Хлам, +3 Верёвка', 'good');
}
});
}
addQuest(quest) {
this.quests.push(quest);
}
onEvent(eventName, data) {
this.quests.forEach(quest => {
if (quest.completed) return;
let matches = quest.event === eventName;
if (!matches && quest.altEvents) {
matches = quest.altEvents.includes(eventName);
}
if (matches) {
if (quest.eventFilter && data !== quest.eventFilter) return;
if (quest.cumulative && typeof data === 'number') {
quest.progress += data;
} else {
quest.progress++;
}
if (quest.progress >= quest.target) {
this.completeQuest(quest);
}
}
});
}
completeQuest(quest) {
quest.completed = true;
quest.reward();
this.completedQuests.push(quest.id);
this.game.sound.playQuestComplete();
}
update(dt) {
// Проверка дней
const prevDay = this._lastDay || 1;
if (this.game.gameDay > prevDay) {
for (let d = prevDay + 1; d <= this.game.gameDay; d++) {
this.onEvent('new_day');
}
}
this._lastDay = this.game.gameDay;
}
getActiveQuests() {
return this.quests.filter(q => !q.completed);
}
getCompletedQuests() {
return this.quests.filter(q => q.completed);
}
reset() {
this.quests = [];
this.completedQuests = [];
}
}

94
js/game/Reputation.js Normal file
View File

@@ -0,0 +1,94 @@
export class Reputation {
constructor(game) {
this.game = game;
this.value = 0; // -100 to +100
}
change(amount) {
const prevLevel = this.getLevel();
this.value = Math.max(-100, Math.min(100, this.value + amount));
const newLevel = this.getLevel();
if (newLevel !== prevLevel) {
if (amount > 0) {
this.game.notify(`Репутация: ${newLevel}`, 'good');
} else {
this.game.notify(`Репутация: ${newLevel}`, 'bad');
}
// Квест на репутацию
if (this.value >= 50) {
this.game.questSystem.onEvent('reputation_level');
}
}
}
getLevel() {
if (this.value <= -50) return 'Изгой';
if (this.value <= -20) return 'Подозрительный';
if (this.value < 20) return 'Незнакомец';
if (this.value < 50) return 'Знакомый';
if (this.value < 80) return 'Уважаемый';
return 'Свой человек';
}
getColor() {
if (this.value <= -50) return '#f44336';
if (this.value <= -20) return '#ff9800';
if (this.value < 20) return '#aaa';
if (this.value < 50) return '#8bc34a';
if (this.value < 80) return '#4caf50';
return '#ffd740';
}
// Модификатор для попрошайничества
getBegModifier() {
let mod = 1;
if (this.value >= 50) mod = 1.5;
else if (this.value >= 20) mod = 1.2;
else if (this.value <= -50) mod = 0.5;
else if (this.value <= -20) mod = 0.7;
// Низкая гигиена — прохожие брезгуют
if (this.game.player && this.game.player.stats.hygiene < 30) {
mod *= 0.7;
}
return mod;
}
// Модификатор оплаты за работу
getJobPayModifier() {
if (this.value >= 80) return 1.3;
if (this.value >= 50) return 1.15;
if (this.value >= 20) return 1.05;
return 1;
}
// Модификатор цен в магазине
getShopModifier() {
if (this.value >= 80) return 0.85;
if (this.value >= 50) return 0.92;
if (this.value <= -50) return 1.2;
return 1;
}
// Модификатор опасности (чаще нападают при низкой репутации)
getDangerModifier() {
if (this.value >= 50) return 0.5;
if (this.value >= 20) return 0.8;
if (this.value <= -50) return 1.5;
if (this.value <= -20) return 1.3;
return 1;
}
getSaveData() {
return { value: this.value };
}
loadSaveData(data) {
if (data) this.value = data.value || 0;
}
reset() {
this.value = 0;
}
}

184
js/game/SaveSystem.js Normal file
View File

@@ -0,0 +1,184 @@
export class SaveSystem {
constructor(game) {
this.game = game;
this.key = 'bomzh_rpg_save';
}
save() {
const data = {
version: 5,
timestamp: Date.now(),
gameTime: this.game.gameTime,
gameDay: this.game.gameDay,
player: {
stats: { ...this.game.player.stats },
position: {
x: this.game.player.position.x,
y: this.game.player.position.y,
z: this.game.player.position.z
},
stamina: this.game.player.stamina,
isDiseased: this.game.player.isDiseased,
diseaseTimer: this.game.player.diseaseTimer,
addictionLevel: this.game.player.addictionLevel,
lastDrinkTime: this.game.player.lastDrinkTime
},
inventory: { ...this.game.inventory.items },
inventoryMaxSlots: this.game.inventory.maxSlots,
quests: this.game.questSystem.quests.map(q => ({
id: q.id,
progress: q.progress,
completed: q.completed || false
})),
weather: this.game.weather.current,
temperature: this.game.weather.temperature,
skills: this.game.skills.getSaveData(),
dog: {
adopted: this.game.dog.adopted
},
reputation: this.game.reputation.getSaveData(),
seasons: this.game.seasons.getSaveData(),
jobSystem: this.game.jobSystem.getSaveData(),
equipment: this.game.equipment.getSaveData(),
achievements: this.game.achievements.getSaveData(),
housing: this.game.housing.getSaveData(),
gameStats: {
totalJobsCompleted: this.game.totalJobsCompleted,
totalBottlesSold: this.game.totalBottlesSold,
totalCrafted: this.game.totalCrafted,
talkedNPCs: [...this.game.talkedNPCs],
visitedLocations: [...this.game.visitedLocations],
enemiesDefeated: this.game.enemiesDefeated,
consecutiveFights: this.game.consecutiveFights
}
};
try {
localStorage.setItem(this.key, JSON.stringify(data));
this.game.notify('Игра сохранена!');
return true;
} catch (e) {
this.game.notify('Ошибка сохранения!');
return false;
}
}
load() {
try {
const raw = localStorage.getItem(this.key);
if (!raw) return false;
const data = JSON.parse(raw);
if (!data || data.version < 2) return false;
this.game.gameTime = data.gameTime;
this.game.gameDay = data.gameDay;
// Восстановление игрока
Object.assign(this.game.player.stats, data.player.stats);
this.game.player.position.set(
data.player.position.x,
data.player.position.y,
data.player.position.z
);
if (data.player.stamina !== undefined) {
this.game.player.stamina = data.player.stamina;
}
if (data.player.isDiseased) {
this.game.player.isDiseased = data.player.isDiseased;
this.game.player.diseaseTimer = data.player.diseaseTimer || 0;
}
if (data.player.addictionLevel) {
this.game.player.addictionLevel = data.player.addictionLevel;
this.game.player.lastDrinkTime = data.player.lastDrinkTime || 0;
}
// Инвентарь
this.game.inventory.items = { ...data.inventory };
if (data.inventoryMaxSlots) {
this.game.inventory.maxSlots = data.inventoryMaxSlots;
}
// Квесты
data.quests.forEach(saved => {
const quest = this.game.questSystem.quests.find(q => q.id === saved.id);
if (quest) {
quest.progress = saved.progress;
quest.completed = saved.completed;
}
});
// Погода
if (data.weather) {
this.game.weather.current = data.weather;
this.game.weather.temperature = data.temperature || 15;
this.game.particles.setWeather(data.weather);
}
// Навыки
if (data.skills) {
this.game.skills.loadSaveData(data.skills);
}
// Пёс
if (data.dog && data.dog.adopted) {
this.game.dog.adopt();
}
// Репутация
if (data.reputation) {
this.game.reputation.loadSaveData(data.reputation);
}
// Сезоны
if (data.seasons) {
this.game.seasons.loadSaveData(data.seasons);
}
// Работа
if (data.jobSystem) {
this.game.jobSystem.loadSaveData(data.jobSystem);
}
// Экипировка
if (data.equipment) {
this.game.equipment.loadSaveData(data.equipment);
}
// Достижения
if (data.achievements) {
this.game.achievements.loadSaveData(data.achievements);
}
// Укрытие
if (data.housing) {
this.game.housing.loadSaveData(data.housing);
}
// Статистика
if (data.gameStats) {
this.game.totalJobsCompleted = data.gameStats.totalJobsCompleted || 0;
this.game.totalBottlesSold = data.gameStats.totalBottlesSold || 0;
this.game.totalCrafted = data.gameStats.totalCrafted || 0;
this.game.talkedNPCs = new Set(data.gameStats.talkedNPCs || []);
this.game.visitedLocations = new Set(data.gameStats.visitedLocations || []);
this.game.enemiesDefeated = data.gameStats.enemiesDefeated || 0;
this.game.consecutiveFights = data.gameStats.consecutiveFights || 0;
}
this.game.notify('Игра загружена!');
return true;
} catch (e) {
this.game.notify('Ошибка загрузки!');
return false;
}
}
hasSave() {
return !!localStorage.getItem(this.key);
}
deleteSave() {
localStorage.removeItem(this.key);
}
}

113
js/game/Seasons.js Normal file
View File

@@ -0,0 +1,113 @@
export class Seasons {
constructor(game) {
this.game = game;
this.current = 'autumn';
this.dayInSeason = 0;
this.daysPerSeason = 7;
this.seasonIndex = 2; // autumn
this.lastDay = 0;
}
update() {
if (this.game.gameDay !== this.lastDay) {
this.lastDay = this.game.gameDay;
this.dayInSeason++;
if (this.dayInSeason > this.daysPerSeason) {
const prevSeason = this.current;
this.dayInSeason = 1;
this.seasonIndex = (this.seasonIndex + 1) % 4;
this.current = Seasons.SEASONS[this.seasonIndex];
this.game.notify(`Наступила ${this.getName()}! ${this.getIcon()}`, 'good');
// Пережили зиму
if (prevSeason === 'winter') {
this.game.questSystem.onEvent('survive_winter');
}
}
}
}
// Модификатор базовой температуры
getTemperatureModifier() {
switch (this.current) {
case 'winter': return -15;
case 'spring': return -2;
case 'summer': return 10;
case 'autumn': return -5;
default: return 0;
}
}
// Множитель потери тепла
getWarmthDrain() {
switch (this.current) {
case 'winter': return 1.5;
case 'spring': return 0.8;
case 'summer': return 0.3;
case 'autumn': return 1.0;
default: return 1;
}
}
// Множитель потери голода
getHungerDrain() {
switch (this.current) {
case 'winter': return 1.3;
case 'summer': return 0.8;
default: return 1;
}
}
// Веса погодных типов для каждого сезона
getWeatherWeights() {
switch (this.current) {
case 'winter': return { clear: 1, rain: 1, snow: 5, fog: 3 };
case 'spring': return { clear: 3, rain: 4, snow: 0, fog: 2 };
case 'summer': return { clear: 5, rain: 2, snow: 0, fog: 1 };
case 'autumn': return { clear: 2, rain: 4, snow: 1, fog: 3 };
default: return { clear: 3, rain: 2, snow: 1, fog: 1 };
}
}
getName() {
return Seasons.NAMES[this.current];
}
getIcon() {
return Seasons.ICONS[this.current];
}
getDaysLeft() {
return this.daysPerSeason - this.dayInSeason;
}
getSaveData() {
return {
current: this.current,
dayInSeason: this.dayInSeason,
seasonIndex: this.seasonIndex,
lastDay: this.lastDay
};
}
loadSaveData(data) {
if (data) {
this.current = data.current || 'autumn';
this.dayInSeason = data.dayInSeason || 0;
this.seasonIndex = data.seasonIndex || 2;
this.lastDay = data.lastDay || 0;
}
}
reset() {
this.current = 'autumn';
this.dayInSeason = 0;
this.seasonIndex = 2;
this.lastDay = 0;
}
}
Seasons.SEASONS = ['spring', 'summer', 'autumn', 'winter'];
Seasons.NAMES = { spring: 'Весна', summer: 'Лето', autumn: 'Осень', winter: 'Зима' };
Seasons.ICONS = { spring: '🌱', summer: '☀️', autumn: '🍂', winter: '❄️' };

78
js/game/Skills.js Normal file
View File

@@ -0,0 +1,78 @@
export class Skills {
constructor(game) {
this.game = game;
this.skills = {
scavenging: { name: 'Поиск', level: 1, xp: 0, xpNeeded: 10, desc: 'Шанс найти лучший лут' },
begging: { name: 'Убеждение', level: 1, xp: 0, xpNeeded: 8, desc: 'Шанс получить больше при попрошайничестве' },
survival: { name: 'Выживание', level: 1, xp: 0, xpNeeded: 15, desc: 'Медленнее теряете статы' },
trading: { name: 'Торговля', level: 1, xp: 0, xpNeeded: 12, desc: 'Лучшие цены в магазине' },
};
this.maxLevel = 10;
}
addXP(skillKey, amount) {
const skill = this.skills[skillKey];
if (!skill || skill.level >= this.maxLevel) return;
skill.xp += amount;
if (skill.xp >= skill.xpNeeded) {
skill.xp -= skill.xpNeeded;
skill.level++;
skill.xpNeeded = Math.floor(skill.xpNeeded * 1.5);
this.game.notify(`${skill.name} повышен до уровня ${skill.level}!`, 'good');
this.game.sound.playQuestComplete();
}
}
getLevel(skillKey) {
return this.skills[skillKey]?.level || 1;
}
// Модификаторы
getScavengeBonus() {
return 1 + (this.getLevel('scavenging') - 1) * 0.1;
}
getBegBonus() {
return 1 + (this.getLevel('begging') - 1) * 0.15;
}
getSurvivalModifier() {
return 1 - (this.getLevel('survival') - 1) * 0.05;
}
getTradeDiscount() {
return Math.max(0.6, 1 - (this.getLevel('trading') - 1) * 0.05);
}
getSaveData() {
const data = {};
for (const [key, skill] of Object.entries(this.skills)) {
data[key] = { level: skill.level, xp: skill.xp, xpNeeded: skill.xpNeeded };
}
return data;
}
loadSaveData(data) {
if (!data) return;
for (const [key, saved] of Object.entries(data)) {
if (this.skills[key]) {
this.skills[key].level = saved.level;
this.skills[key].xp = saved.xp;
this.skills[key].xpNeeded = saved.xpNeeded;
}
}
}
reset() {
for (const skill of Object.values(this.skills)) {
skill.level = 1;
skill.xp = 0;
skill.xpNeeded = skill === this.skills.scavenging ? 10 :
skill === this.skills.begging ? 8 :
skill === this.skills.survival ? 15 : 12;
}
}
}

209
js/game/SoundManager.js Normal file
View File

@@ -0,0 +1,209 @@
export class SoundManager {
constructor(game) {
this.game = game;
this.ctx = null;
this.masterGain = null;
this.enabled = true;
this.initialized = false;
this.ambientSource = null;
this.currentAmbient = null;
}
init() {
if (this.initialized) return;
try {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.gain.value = 0.3;
this.masterGain.connect(this.ctx.destination);
this.initialized = true;
} catch (e) {
this.enabled = false;
}
}
resume() {
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
// Шаги
playStep() {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = 80 + Math.random() * 40;
gain.gain.setValueAtTime(0.05, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.1);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.1);
}
// Подбор предмета
playPickup() {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(400, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(800, this.ctx.currentTime + 0.15);
gain.gain.setValueAtTime(0.1, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.2);
}
// Квест выполнен
playQuestComplete() {
if (!this.enabled || !this.ctx) return;
const notes = [523, 659, 784]; // C5, E5, G5
notes.forEach((freq, i) => {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
gain.gain.setValueAtTime(0.08, this.ctx.currentTime + i * 0.12);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + i * 0.12 + 0.4);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(this.ctx.currentTime + i * 0.12);
osc.stop(this.ctx.currentTime + i * 0.12 + 0.4);
});
}
// Урон
playHurt() {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(200, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(80, this.ctx.currentTime + 0.3);
gain.gain.setValueAtTime(0.1, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.3);
}
// Монеты
playCoin() {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(1200, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1800, this.ctx.currentTime + 0.08);
gain.gain.setValueAtTime(0.04, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.15);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.15);
}
// Еда
playEat() {
if (!this.enabled || !this.ctx) return;
for (let i = 0; i < 3; i++) {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.value = 300 + Math.random() * 100;
const t = this.ctx.currentTime + i * 0.1;
gain.gain.setValueAtTime(0.06, t);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.08);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start(t);
osc.stop(t + 0.08);
}
}
// Диалог
playDialogOpen() {
if (!this.enabled || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 600;
gain.gain.setValueAtTime(0.06, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + 0.2);
}
// Амбиент ночной — тихий гул
playAmbient(type) {
if (!this.enabled || !this.ctx) return;
if (this.currentAmbient === type) return;
this.stopAmbient();
this.currentAmbient = type;
if (type === 'night') {
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
osc.type = 'sawtooth';
osc.frequency.value = 60;
filter.type = 'lowpass';
filter.frequency.value = 100;
gain.gain.value = 0.02;
osc.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
osc.start();
this.ambientSource = { osc, gain };
} else if (type === 'rain') {
// Белый шум через буфер
const bufferSize = this.ctx.sampleRate * 2;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * 0.5;
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
src.loop = true;
const gain = this.ctx.createGain();
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 800;
gain.gain.value = 0.04;
src.connect(filter);
filter.connect(gain);
gain.connect(this.masterGain);
src.start();
this.ambientSource = { osc: src, gain };
}
}
stopAmbient() {
if (this.ambientSource) {
try { this.ambientSource.osc.stop(); } catch (e) {}
this.ambientSource = null;
this.currentAmbient = null;
}
}
setVolume(v) {
if (this.masterGain) {
this.masterGain.gain.value = Math.max(0, Math.min(1, v));
}
}
toggle() {
this.enabled = !this.enabled;
if (!this.enabled) this.stopAmbient();
return this.enabled;
}
}

1133
js/game/UI.js Normal file

File diff suppressed because it is too large Load Diff

121
js/game/Weather.js Normal file
View File

@@ -0,0 +1,121 @@
export class Weather {
constructor(game) {
this.game = game;
this.current = 'clear'; // clear | rain | snow | fog
this.temperature = 15; // °C
this.changeTimer = 0;
this.changeCooldown = 180; // секунд реальных между сменами погоды
this.windStrength = 0;
}
init() {
this.current = 'clear';
this.temperature = 15;
this.changeTimer = 60 + Math.random() * 120;
}
update(dt) {
this.changeTimer -= dt;
if (this.changeTimer <= 0) {
this.changeWeather();
this.changeTimer = this.changeCooldown + Math.random() * 120;
}
// Температура зависит от времени суток, погоды и сезона
const hour = this.game.gameTime / 60;
let baseTemp;
if (hour >= 10 && hour < 16) {
baseTemp = 18;
} else if (hour >= 6 && hour < 10) {
baseTemp = 10;
} else if (hour >= 16 && hour < 21) {
baseTemp = 12;
} else {
baseTemp = 3;
}
// Сезонный модификатор температуры
baseTemp += this.game.seasons.getTemperatureModifier();
if (this.current === 'rain') baseTemp -= 5;
if (this.current === 'snow') baseTemp -= 15;
if (this.current === 'fog') baseTemp -= 2;
this.temperature += (baseTemp - this.temperature) * dt * 0.1;
// Влияние на тепло игрока
this.applyEffects(dt);
// Ветер
this.windStrength = (this.current === 'rain' || this.current === 'snow') ?
0.3 + Math.sin(Date.now() * 0.001) * 0.2 : 0.05;
}
changeWeather() {
// Веса погоды зависят от сезона
const weights = this.game.seasons.getWeatherWeights();
const hour = this.game.gameTime / 60;
// Ночью больше тумана и снега
if (hour < 6 || hour > 21) {
weights.fog += 2;
weights.snow += 1;
}
// Строим массив с весами
const pool = [];
for (const [type, w] of Object.entries(weights)) {
for (let i = 0; i < w; i++) pool.push(type);
}
let newWeather;
do {
newWeather = pool[Math.floor(Math.random() * pool.length)];
} while (newWeather === this.current && pool.length > 1);
this.current = newWeather;
this.game.particles.setWeather(newWeather);
// Визуальные эффекты тумана
if (newWeather === 'fog') {
this.game.scene.fog.near = 10;
this.game.scene.fog.far = 50;
} else {
this.game.scene.fog.near = 80;
this.game.scene.fog.far = 200;
}
const names = { clear: 'Ясно', rain: 'Дождь', snow: 'Снег', fog: 'Туман' };
this.game.notify(`Погода: ${names[newWeather]} (${Math.round(this.temperature)}°C)`);
}
applyEffects(dt) {
const player = this.game.player;
const seasonDrain = this.game.seasons.getWarmthDrain();
if (this.current === 'rain') {
player.stats.warmth = Math.max(0, player.stats.warmth - 0.08 * seasonDrain * dt);
player.stats.mood = Math.max(0, player.stats.mood - 0.03 * dt);
}
if (this.current === 'snow') {
player.stats.warmth = Math.max(0, player.stats.warmth - 0.15 * seasonDrain * dt);
player.stats.mood = Math.max(0, player.stats.mood - 0.05 * dt);
}
// Холодная температура
if (this.temperature < 5) {
player.stats.warmth = Math.max(0, player.stats.warmth - 0.1 * seasonDrain * dt);
}
}
getIcon() {
const icons = { clear: '☀️', rain: '🌧️', snow: '❄️', fog: '🌫️' };
return icons[this.current] || '☀️';
}
reset() {
this.init();
}
}

1791
js/game/World.js Normal file

File diff suppressed because it is too large Load Diff

49
js/main.js Normal file
View File

@@ -0,0 +1,49 @@
import { Game } from './game/Game.js';
document.addEventListener('DOMContentLoaded', () => {
const canvas = document.getElementById('game-canvas');
const game = new Game(canvas);
const menuScreen = document.getElementById('menu-screen');
const controlsPanel = document.getElementById('controls-panel');
const menuContent = document.querySelector('.menu-content');
// Показать кнопку "Продолжить" если есть сохранение
const btnContinue = document.getElementById('btn-continue');
if (game.saveSystem.hasSave()) {
btnContinue.classList.remove('hidden');
}
function startGame(fromSave) {
menuScreen.classList.add('hidden');
// Crosshair
if (!document.getElementById('crosshair')) {
const crosshair = document.createElement('div');
crosshair.id = 'crosshair';
document.body.appendChild(crosshair);
}
if (fromSave) {
game.startFromSave();
} else {
game.start();
}
// Инициализация звука по клику
game.sound.resume();
}
document.getElementById('btn-start').addEventListener('click', () => startGame(false));
btnContinue.addEventListener('click', () => startGame(true));
document.getElementById('btn-controls').addEventListener('click', () => {
menuContent.classList.add('hidden');
controlsPanel.classList.remove('hidden');
});
document.getElementById('btn-back').addEventListener('click', () => {
controlsPanel.classList.add('hidden');
menuContent.classList.remove('hidden');
});
});

13
package-lock.json generated Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "3d-hommie-rpg",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "3d-hommie-rpg",
"version": "1.0.0",
"license": "MIT"
}
}
}

13
package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "3d-hommie-rpg",
"version": "1.0.0",
"description": "3D RPG про выживание бомжа в большом городе",
"main": "js/main.js",
"scripts": {
"dev": "npx serve .",
"start": "npx serve ."
},
"keywords": ["3d", "rpg", "threejs", "game", "survival"],
"author": "",
"license": "MIT"
}

Binary file not shown.