commit fb5f09212b1bd9c03d4acaffdff531e9f1b8c4cc Author: Maxim Dolgolyov Date: Wed Feb 25 01:04:09 2026 +0300 Initial commit: 3D Hommie RPG game Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..276f3f6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.rar +*.zip +node_modules/ diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..e13538e --- /dev/null +++ b/css/style.css @@ -0,0 +1,1496 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + overflow: hidden; + background: #0a0a12; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + color: #eee; + cursor: default; +} + +.hidden { display: none !important; } + +/* === Custom Scrollbar === */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: rgba(255,255,255,0.03); + border-radius: 3px; +} +::-webkit-scrollbar-thumb { + background: rgba(240,160,64,0.3); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(240,160,64,0.5); +} + +/* === Canvas === */ +#game-canvas { + position: fixed; + top: 0; left: 0; + width: 100%; height: 100%; + z-index: 0; +} + +/* === Crosshair === */ +#crosshair { + position: fixed; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + width: 8px; height: 8px; + z-index: 5; + pointer-events: none; +} +#crosshair::before, +#crosshair::after { + content: ''; + position: absolute; + background: rgba(255,255,255,0.7); + border-radius: 1px; +} +#crosshair::before { + top: 50%; left: -3px; right: -3px; + height: 2px; + transform: translateY(-50%); +} +#crosshair::after { + left: 50%; top: -3px; bottom: -3px; + width: 2px; + transform: translateX(-50%); +} + +/* === Screen Effects === */ +#screen-effects { + position: fixed; + inset: 0; + z-index: 2; + pointer-events: none; +} + +#effect-damage { + position: absolute; + inset: 0; + border: 0px solid rgba(255, 0, 0, 0); + transition: border 0.15s; +} +#effect-damage.active { + border: 12px solid rgba(200, 0, 0, 0.5); + animation: damagePulse 0.4s ease-out; +} +@keyframes damagePulse { + 0% { border-width: 20px; border-color: rgba(255,0,0,0.7); } + 100% { border-width: 0px; border-color: rgba(255,0,0,0); } +} + +#effect-cold { + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 50%, rgba(100,150,255,0) 100%); + transition: background 1s; +} +#effect-cold.active { + background: radial-gradient(ellipse at center, transparent 30%, rgba(100,150,255,0.3) 100%); +} + +#effect-hunger { + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,0) 100%); + transition: background 1s; +} +#effect-hunger.active { + background: radial-gradient(ellipse at center, transparent 30%, rgba(0,0,0,0.5) 100%); +} + +#effect-sleep { + position: absolute; + inset: 0; + background: rgba(0,0,0,0); + transition: background 0.8s; +} +#effect-sleep.active { + background: rgba(0,0,0,0.85); +} + +#effect-disease { + position: absolute; + inset: 0; + background: radial-gradient(ellipse at center, transparent 60%, rgba(0,80,0,0) 100%); + transition: background 1s; +} +#effect-disease.active { + background: radial-gradient(ellipse at center, transparent 20%, rgba(0,80,0,0.25) 100%); + animation: disease-pulse 3s ease-in-out infinite; +} +@keyframes disease-pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} + +/* === Menu === */ +#menu-screen { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #0a0a18 0%, #111133 40%, #1a0a2e 70%, #0f1a3e 100%); + overflow: hidden; +} +#menu-screen::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(ellipse at 20% 50%, rgba(240,160,64,0.06) 0%, transparent 50%), + radial-gradient(ellipse at 80% 20%, rgba(100,80,200,0.05) 0%, transparent 40%); + pointer-events: none; +} + +.menu-content, #controls-panel { + text-align: center; + position: relative; + z-index: 1; +} + +.menu-content h1 { + font-size: 4.5rem; + font-weight: 900; + letter-spacing: 0.15em; + background: linear-gradient(135deg, #f0a040 0%, #ffd740 40%, #ff8800 80%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + filter: drop-shadow(0 0 30px rgba(255,150,50,0.4)); + margin-bottom: 0.1em; + animation: titleGlow 3s ease-in-out infinite; +} + +@keyframes titleGlow { + 0%, 100% { filter: drop-shadow(0 0 30px rgba(255,150,50,0.4)); } + 50% { filter: drop-shadow(0 0 50px rgba(255,150,50,0.7)) drop-shadow(0 0 80px rgba(255,100,20,0.3)); } +} + +.subtitle { + font-size: 1.2rem; + color: #6a6a8a; + letter-spacing: 0.3em; + text-transform: uppercase; + margin-bottom: 0.5em; +} + +.menu-version { + font-size: 0.7rem; + color: #444; + margin-bottom: 2.5em; +} + +.menu-btn { + display: block; + margin: 0.7em auto; + padding: 0.85em 2.8em; + font-size: 1.05rem; + font-weight: 600; + border: 1px solid rgba(240,160,64,0.4); + background: rgba(240,160,64,0.06); + color: #e8a848; + cursor: pointer; + border-radius: 6px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + font-family: inherit; + letter-spacing: 0.05em; + backdrop-filter: blur(4px); +} + +.menu-btn:hover { + background: rgba(240,160,64,0.18); + border-color: #f0a040; + color: #fff; + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(240,160,64,0.2); +} + +.menu-btn:active { + transform: translateY(0); +} + +#controls-panel h2 { + font-size: 1.8rem; + color: #f0a040; + margin-bottom: 1em; +} + +#controls-panel ul { + list-style: none; + text-align: left; + display: inline-block; + margin-bottom: 1.5em; +} + +#controls-panel li { + padding: 0.35em 0; + font-size: 1rem; + color: #aaa; +} + +kbd { + background: linear-gradient(180deg, #3a3a4a, #2a2a3a); + border: 1px solid #555; + border-bottom-width: 2px; + border-radius: 4px; + padding: 0.2em 0.6em; + font-family: 'Segoe UI', monospace; + font-size: 0.85em; + color: #ddd; + box-shadow: 0 1px 2px rgba(0,0,0,0.3); +} + +/* === HUD === */ +#hud { + position: fixed; + inset: 0; + z-index: 10; + pointer-events: none; +} + +#stats-panel { + position: absolute; + top: 15px; left: 15px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.stat-bar { + display: flex; + align-items: center; + gap: 6px; + background: linear-gradient(135deg, rgba(10,10,20,0.85), rgba(20,20,35,0.75)); + padding: 5px 12px; + border-radius: 8px; + min-width: 230px; + backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,0.05); + transition: all 0.3s; +} + +.stat-bar.critical { + animation: criticalPulse 1s infinite; + border-color: rgba(200,0,0,0.3); +} + +@keyframes criticalPulse { + 0%, 100% { background: linear-gradient(135deg, rgba(80,0,0,0.7), rgba(60,0,0,0.6)); } + 50% { background: linear-gradient(135deg, rgba(130,0,0,0.8), rgba(100,0,0,0.7)); } +} + +.stat-icon { + font-size: 0.9rem; + width: 1.3em; + text-align: center; +} + +.stat-label { + font-size: 0.7rem; + width: 60px; + color: #777; + font-weight: 500; + letter-spacing: 0.02em; +} + +.bar-bg { + flex: 1; + height: 8px; + background: rgba(255,255,255,0.06); + border-radius: 4px; + overflow: hidden; + position: relative; +} + +.bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.4s ease; + position: relative; +} + +.bar-fill::after { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 50%; + background: linear-gradient(180deg, rgba(255,255,255,0.2), transparent); + border-radius: 4px 4px 0 0; +} + +.bar-fill.health { background: linear-gradient(90deg, #c62828, #ef5350, #ff7043); } +.bar-fill.hunger { background: linear-gradient(90deg, #e65100, #ff9800, #ffb74d); } +.bar-fill.warmth { background: linear-gradient(90deg, #e65100, #f9a825, #ffee58); } +.bar-fill.mood { background: linear-gradient(90deg, #1565c0, #42a5f5, #64b5f6); } +.bar-fill.hygiene { background: linear-gradient(90deg, #2e7d32, #66bb6a, #a5d6a7); } + +.stat-value { + font-size: 0.7rem; + font-weight: 700; + width: 28px; + text-align: right; + font-variant-numeric: tabular-nums; + color: #ccc; +} + +/* === Top Right HUD === */ +#top-right-hud { + position: absolute; + top: 15px; + right: 15px; + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-end; +} + +#money-display { + background: linear-gradient(135deg, rgba(10,10,20,0.85), rgba(30,25,10,0.75)); + padding: 8px 16px; + border-radius: 8px; + font-size: 1.3rem; + font-weight: 800; + color: #ffd740; + backdrop-filter: blur(8px); + border: 1px solid rgba(255,215,64,0.15); + display: flex; + align-items: center; + gap: 4px; + text-shadow: 0 0 15px rgba(255,215,64,0.3); +} + +.money-icon { font-size: 1rem; } +.money-currency { font-size: 0.85rem; color: #aa8830; font-weight: 600; } + +#time-display { + background: linear-gradient(135deg, rgba(10,10,20,0.85), rgba(20,20,35,0.75)); + padding: 6px 14px; + border-radius: 8px; + font-size: 0.9rem; + display: flex; + gap: 8px; + align-items: center; + backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,0.05); +} + +.time-separator { color: #333; font-size: 0.8rem; } +#val-day { color: #777; font-size: 0.8rem; } +#val-time { font-weight: 700; color: #ddd; font-variant-numeric: tabular-nums; } + +#temp-display { + background: linear-gradient(135deg, rgba(10,10,20,0.85), rgba(20,20,35,0.75)); + padding: 4px 14px; + border-radius: 8px; + font-size: 0.8rem; + color: #88bbff; + backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,0.05); + display: flex; + gap: 10px; + align-items: center; +} + +#val-season { color: #c8a040; } + +#protection-display { + background: linear-gradient(135deg, rgba(10,10,20,0.85), rgba(20,20,35,0.75)); + padding: 4px 14px; + border-radius: 8px; + font-size: 0.75rem; + backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,0.05); + color: #aaa; + display: flex; + gap: 6px; + align-items: center; +} +#protection-display .sep { color: #333; } + +/* === Reputation Display === */ +#reputation-display { + background: linear-gradient(135deg, rgba(10,10,20,0.85), rgba(20,20,35,0.75)); + padding: 5px 14px; + border-radius: 8px; + font-size: 0.75rem; + pointer-events: none; + backdrop-filter: blur(8px); + border: 1px solid rgba(255,255,255,0.05); +} + +/* === Quest Tracker === */ +#quest-tracker { + position: absolute; + top: 205px; + right: 15px; + background: linear-gradient(135deg, rgba(10,10,20,0.8), rgba(20,20,35,0.7)); + padding: 10px 14px; + border-radius: 8px; + min-width: 180px; + max-width: 220px; + backdrop-filter: blur(8px); + border: 1px solid rgba(240,160,64,0.15); + pointer-events: none; +} + +#tracker-title { + font-size: 0.75rem; + font-weight: 700; + color: #f0a040; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#tracker-progress { + font-size: 0.7rem; + color: #888; + margin-bottom: 6px; +} + +#tracker-bar { + height: 3px; + background: rgba(255,255,255,0.06); + border-radius: 2px; + overflow: hidden; +} + +#tracker-fill { + height: 100%; + background: linear-gradient(90deg, #f0a040, #ffd740); + border-radius: 2px; + transition: width 0.4s ease; + width: 0%; +} + +/* === Minimap === */ +#minimap-container { + position: absolute; + bottom: 15px; + left: 15px; +} + +#minimap { + width: 180px; + height: 180px; + border-radius: 50%; + border: 2px solid rgba(240,160,64,0.3); + background: rgba(0,0,0,0.5); + backdrop-filter: blur(2px); + box-shadow: 0 0 20px rgba(0,0,0,0.5), inset 0 0 15px rgba(0,0,0,0.3); +} + +#minimap-label { + text-align: center; + font-size: 0.6rem; + color: #555; + margin-top: 4px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +/* === Compass === */ +#compass { + position: absolute; + top: 50%; + right: 15px; + transform: translateY(-50%); + pointer-events: none; +} + +#compass-ring { + background: linear-gradient(135deg, rgba(10,10,20,0.85), rgba(20,20,35,0.75)); + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(240,160,64,0.25); + backdrop-filter: blur(8px); +} + +#compass-dir { + font-size: 0.85rem; + font-weight: 800; + color: #f0a040; +} + +/* === Hotbar === */ +#hotbar { + position: absolute; + bottom: 15px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 4px; + pointer-events: all; +} + +.hotbar-item { + width: 42px; + height: 42px; + background: linear-gradient(135deg, rgba(10,10,20,0.85), rgba(20,20,35,0.75)); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2rem; + cursor: pointer; + transition: all 0.2s; + backdrop-filter: blur(8px); +} + +.hotbar-item:hover { + border-color: rgba(240,160,64,0.4); + background: linear-gradient(135deg, rgba(240,160,64,0.12), rgba(240,160,64,0.06)); + transform: translateY(-3px); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} + +/* === Tooltip === */ +#tooltip { + position: fixed; + z-index: 35; + background: linear-gradient(135deg, rgba(15,15,28,0.96), rgba(25,25,40,0.94)); + border: 1px solid rgba(240,160,64,0.3); + border-radius: 8px; + padding: 10px 14px; + max-width: 240px; + pointer-events: none; + backdrop-filter: blur(12px); + box-shadow: 0 8px 32px rgba(0,0,0,0.5); +} + +#tooltip-title { + font-weight: 700; + font-size: 0.85rem; + color: #f0a040; + margin-bottom: 4px; +} + +#tooltip-desc { + font-size: 0.75rem; + color: #999; + line-height: 1.4; + margin-bottom: 6px; +} + +#tooltip-stats { + font-size: 0.7rem; + color: #8fc; + line-height: 1.5; +} + +/* === Interaction === */ +#interaction-hint { + position: absolute; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, rgba(10,10,20,0.9), rgba(20,20,35,0.85)); + padding: 10px 24px; + border-radius: 8px; + font-size: 1rem; + pointer-events: none; + animation: hintPulse 2s ease-in-out infinite; + backdrop-filter: blur(8px); + border: 1px solid rgba(240,160,64,0.2); + display: flex; + align-items: center; + gap: 8px; +} + +@keyframes hintPulse { + 0%, 100% { opacity: 0.9; border-color: rgba(240,160,64,0.2); } + 50% { opacity: 1; border-color: rgba(240,160,64,0.5); } +} + +/* === Dialog === */ +#dialog-box { + position: fixed; + bottom: 40px; + left: 50%; + transform: translateX(-50%); + z-index: 20; + width: min(700px, 90vw); + animation: dialogSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes dialogSlideIn { + from { opacity: 0; transform: translateX(-50%) translateY(30px) scale(0.97); } + to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } +} + +.dialog-content { + background: linear-gradient(135deg, rgba(10,10,25,0.95), rgba(15,15,30,0.92)); + border: 1px solid rgba(240,160,64,0.2); + border-radius: 12px; + padding: 22px 26px; + backdrop-filter: blur(12px); + box-shadow: 0 12px 40px rgba(0,0,0,0.5); +} + +#dialog-speaker { + font-weight: 700; + color: #f0a040; + margin-bottom: 8px; + font-size: 1rem; + text-shadow: 0 0 10px rgba(240,160,64,0.2); +} + +#dialog-text { + line-height: 1.6; + font-size: 0.95rem; + margin-bottom: 14px; + color: #ccc; +} + +#dialog-choices { + display: flex; + flex-direction: column; + gap: 6px; +} + +.dialog-choice { + background: rgba(240,160,64,0.05); + border: 1px solid rgba(240,160,64,0.15); + padding: 10px 16px; + border-radius: 8px; + cursor: pointer; + color: #bbb; + text-align: left; + font-family: inherit; + font-size: 0.9rem; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: all; + position: relative; + overflow: hidden; +} + +.dialog-choice::before { + content: '>'; + position: absolute; + left: 8px; + opacity: 0; + color: #f0a040; + font-weight: 700; + transition: all 0.2s; +} + +.dialog-choice:hover { + background: rgba(240,160,64,0.12); + border-color: rgba(240,160,64,0.4); + color: #fff; + padding-left: 24px; +} + +.dialog-choice:hover::before { + opacity: 1; +} + +/* === Panel shared styles (Inventory, Quests, Skills, Achievements) === */ +#inventory-screen, #quest-screen, #skills-screen, #achievements-screen { + position: fixed; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.75); + animation: panelFadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(4px); +} + +@keyframes panelFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.inventory-content, .quest-content, .skills-content, .achievements-content { + background: linear-gradient(160deg, #141428 0%, #1a1a30 40%, #16162a 100%); + border: 1px solid rgba(240,160,64,0.15); + border-radius: 12px; + padding: 0 24px 24px; + min-width: 440px; + max-width: 600px; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 16px 60px rgba(0,0,0,0.6), 0 0 40px rgba(240,160,64,0.05); + animation: panelSlideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +@keyframes panelSlideIn { + from { transform: translateY(20px) scale(0.97); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +/* Panel header with close button */ +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0 16px; + border-bottom: 1px solid rgba(255,255,255,0.06); + margin-bottom: 16px; + position: sticky; + top: 0; + background: linear-gradient(160deg, #141428 0%, #1a1a30 100%); + z-index: 1; +} + +.panel-header h2 { + color: #f0a040; + font-size: 1.2rem; + font-weight: 700; + margin: 0; +} + +.panel-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.4rem; + color: #666; + cursor: pointer; + border-radius: 6px; + transition: all 0.2s; + pointer-events: all; + background: rgba(255,255,255,0.03); +} + +.panel-close:hover { + color: #f44336; + background: rgba(244,67,54,0.1); +} + +/* === Inventory === */ +#inventory-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 6px; + margin-bottom: 16px; +} + +.inv-slot { + aspect-ratio: 1; + background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 1.5rem; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; +} + +.inv-slot:hover { + border-color: rgba(240,160,64,0.4); + background: linear-gradient(135deg, rgba(240,160,64,0.1), rgba(240,160,64,0.05)); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); +} + +.inv-slot .item-count { + position: absolute; + bottom: 3px; right: 5px; + font-size: 0.6rem; + font-weight: 800; + color: #ffd740; + text-shadow: 0 1px 2px rgba(0,0,0,0.5); +} + +.inv-slot .item-name { + font-size: 0.5rem; + color: #777; + margin-top: 1px; + text-align: center; + line-height: 1.1; +} + +.inv-slot.equippable { + border-color: rgba(136,102,204,0.3); +} + +.inv-slot.equippable:hover { + border-color: rgba(136,102,204,0.6); + background: linear-gradient(135deg, rgba(136,102,204,0.1), rgba(136,102,204,0.05)); +} + +/* === Equipment Section in Inventory === */ +.equipment-section { + margin-bottom: 16px; + padding-bottom: 14px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +.eq-title { + font-size: 0.8rem; + color: #f0a040; + font-weight: 700; + margin-bottom: 10px; + text-align: center; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.eq-slots { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} + +.eq-slot { + background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + padding: 10px 4px; + text-align: center; + transition: all 0.2s; + cursor: default; +} + +.eq-slot:not(.empty) { + border-color: rgba(136,102,204,0.3); + background: linear-gradient(135deg, rgba(136,102,204,0.08), rgba(136,102,204,0.03)); + cursor: pointer; +} + +.eq-slot:not(.empty):hover { + border-color: rgba(244,67,54,0.4); + background: linear-gradient(135deg, rgba(244,67,54,0.08), rgba(244,67,54,0.03)); + transform: translateY(-1px); +} + +.eq-slot.empty { + opacity: 0.4; +} + +.eq-icon { + display: block; + font-size: 1.5rem; + margin-bottom: 2px; +} + +.eq-name { + display: block; + font-size: 0.6rem; + color: #bbb; + line-height: 1.2; +} + +.eq-stats { + display: block; + font-size: 0.5rem; + color: #888; + margin-top: 2px; +} + +.eq-totals { + font-size: 0.7rem; + color: #999; + text-align: center; + margin-top: 10px; + padding: 6px 10px; + background: rgba(255,255,255,0.03); + border-radius: 6px; + border: 1px solid rgba(255,255,255,0.04); +} + +/* === Crafting Section === */ +.craft-section h3 { + font-size: 0.8rem !important; + color: #f0a040 !important; + text-transform: uppercase; + letter-spacing: 0.05em; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255,255,255,0.06); +} + +.craft-item { + background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; + padding: 10px 12px; + margin-bottom: 6px; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s; +} + +.craft-item.available { + border-color: rgba(240,160,64,0.25); +} + +.craft-item.available:hover { + border-color: rgba(240,160,64,0.5); + background: linear-gradient(135deg, rgba(240,160,64,0.06), rgba(240,160,64,0.02)); +} + +.craft-btn { + padding: 5px 14px; + border: 1px solid rgba(240,160,64,0.3); + background: rgba(240,160,64,0.08); + color: #f0a040; + border-radius: 6px; + cursor: pointer; + font-size: 0.7rem; + font-family: inherit; + font-weight: 600; + transition: all 0.2s; +} + +.craft-btn:hover { + background: rgba(240,160,64,0.2); + border-color: #f0a040; +} + +.craft-btn:disabled { + border-color: rgba(255,255,255,0.06); + background: transparent; + color: #444; + cursor: default; +} + +.craft-ingredients { + font-size: 0.65rem; + color: #666; + margin-top: 3px; +} + +.craft-ingredients .has { color: #8fc; } +.craft-ingredients .missing { color: #f44336; } + +/* === Quests === */ +.quest-item { + background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; + padding: 14px; + margin-bottom: 8px; + transition: all 0.2s; +} + +.quest-item.active { + border-color: rgba(240,160,64,0.25); + border-left: 3px solid #f0a040; +} + +.quest-item.completed { + opacity: 0.45; + border-color: rgba(76,175,80,0.2); + border-left: 3px solid #4caf50; +} + +.quest-title { + font-weight: 700; + color: #f0a040; + margin-bottom: 4px; + font-size: 0.9rem; +} + +.quest-desc { + font-size: 0.8rem; + color: #888; + margin-bottom: 8px; + line-height: 1.4; +} + +.quest-progress { + font-size: 0.75rem; + color: #ffd740; + font-weight: 600; +} + +.quest-progress-bar { + height: 4px; + background: rgba(255,255,255,0.06); + border-radius: 2px; + margin-top: 6px; + overflow: hidden; +} + +.quest-progress-fill { + height: 100%; + background: linear-gradient(90deg, #f0a040, #ffd740); + border-radius: 2px; + transition: width 0.4s ease; +} + +.quest-reward { + font-size: 0.7rem; + color: #8fc; + margin-top: 6px; +} + +/* === Notifications === */ +#notifications { + position: fixed; + top: 45%; + left: 50%; + transform: translateX(-50%); + z-index: 25; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + pointer-events: none; +} + +.notification { + background: linear-gradient(135deg, rgba(10,10,25,0.92), rgba(20,20,40,0.88)); + border: 1px solid rgba(240,160,64,0.2); + padding: 10px 24px; + border-radius: 8px; + font-size: 0.85rem; + animation: notifSlide 3.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; + white-space: nowrap; + backdrop-filter: blur(8px); + box-shadow: 0 4px 16px rgba(0,0,0,0.4); +} + +.notification.good { + border-color: rgba(76,175,80,0.3); + color: #81c784; + box-shadow: 0 4px 16px rgba(76,175,80,0.1); +} + +.notification.bad { + border-color: rgba(244,67,54,0.3); + color: #ef9a9a; + box-shadow: 0 4px 16px rgba(244,67,54,0.1); +} + +@keyframes notifSlide { + 0% { opacity: 0; transform: translateY(20px) scale(0.95); } + 8% { opacity: 1; transform: translateY(0) scale(1); } + 75% { opacity: 1; transform: translateY(0); } + 100% { opacity: 0; transform: translateY(-15px) scale(0.95); } +} + +/* === Death Screen === */ +#death-screen { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(ellipse at center, rgba(60,0,0,0.9), rgba(20,0,0,0.95)); + animation: panelFadeIn 1.5s; +} + +.death-content { + text-align: center; + max-width: 500px; +} + +.death-icon { + font-size: 4rem; + margin-bottom: 0.3em; + animation: deathIconPulse 2s infinite ease-in-out; +} + +@keyframes deathIconPulse { + 0%, 100% { transform: scale(1); opacity: 0.8; } + 50% { transform: scale(1.1); opacity: 1; } +} + +.death-content h1 { + font-size: 2.5rem; + color: #f44336; + margin-bottom: 0.5em; + text-shadow: 0 0 40px rgba(244,67,54,0.5); + font-weight: 900; +} + +.death-content p { + font-size: 1rem; + color: #ccc; + margin-bottom: 1em; +} + +#death-stats { + margin: 1.5em 0; +} + +.death-stat-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-bottom: 1.5em; +} + +.death-stat-card { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 8px; + padding: 10px 8px; + text-align: center; +} + +.death-stat-card .ds-icon { + font-size: 1.3rem; + display: block; + margin-bottom: 4px; +} + +.death-stat-card .ds-value { + font-size: 1rem; + font-weight: 700; + color: #ffd740; + display: block; +} + +.death-stat-card .ds-label { + font-size: 0.6rem; + color: #666; + display: block; + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +#btn-restart { + border-color: rgba(244,67,54,0.4); + color: #f44336; + margin-top: 1em; +} + +#btn-restart:hover { + background: rgba(244,67,54,0.15); + border-color: #f44336; + color: #fff; + box-shadow: 0 4px 20px rgba(244,67,54,0.2); +} + +/* === Skills Screen === */ +.skill-item { + background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)); + border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; + padding: 14px; + margin-bottom: 8px; + transition: all 0.2s; +} + +.skill-item:hover { + border-color: rgba(240,160,64,0.25); + background: linear-gradient(135deg, rgba(240,160,64,0.04), rgba(240,160,64,0.02)); +} + +.skill-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.skill-name { + font-weight: 700; + color: #f0a040; + font-size: 0.9rem; +} + +.skill-level { + font-size: 0.75rem; + color: #ffd740; + font-weight: 700; + padding: 2px 8px; + background: rgba(255,215,64,0.08); + border-radius: 4px; +} + +.skill-desc { + font-size: 0.75rem; + color: #777; + margin-bottom: 8px; + line-height: 1.3; +} + +.skill-xp-bar { + height: 4px; + background: rgba(255,255,255,0.06); + border-radius: 2px; + overflow: hidden; +} + +.skill-xp-fill { + height: 100%; + background: linear-gradient(90deg, #f0a040, #ffd740); + border-radius: 2px; + transition: width 0.4s ease; +} + +.skill-xp-text { + font-size: 0.6rem; + color: #555; + text-align: right; + margin-top: 3px; + font-variant-numeric: tabular-nums; +} + +/* === Achievements Screen === */ +.achievements-content h2 { + color: #ffd740; +} + +.ach-progress-header { + text-align: center; + margin-bottom: 18px; + padding: 12px; + background: rgba(255,255,255,0.02); + border-radius: 8px; + border: 1px solid rgba(255,255,255,0.04); +} + +.ach-progress-text { + font-size: 0.85rem; + color: #aaa; + margin-bottom: 8px; +} + +.ach-progress-count { + color: #ffd740; + font-weight: 700; + font-size: 1.1rem; +} + +.ach-progress-bar { + height: 4px; + background: rgba(255,255,255,0.06); + border-radius: 2px; + overflow: hidden; +} + +.ach-progress-fill { + height: 100%; + background: linear-gradient(90deg, #f0a040, #ffd740); + border-radius: 2px; + transition: width 0.4s ease; +} + +.ach-category { + margin-bottom: 18px; +} + +.ach-category-title { + font-size: 0.8rem; + font-weight: 700; + color: #f0a040; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255,255,255,0.06); + letter-spacing: 0.03em; +} + +.ach-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + margin-bottom: 4px; + border-radius: 8px; + transition: all 0.2s; +} + +.ach-item.unlocked { + background: linear-gradient(135deg, rgba(255,215,64,0.06), rgba(255,215,64,0.02)); + border: 1px solid rgba(255,215,64,0.15); +} + +.ach-item.locked { + background: rgba(255,255,255,0.015); + border: 1px solid rgba(255,255,255,0.04); + opacity: 0.4; +} + +.ach-item:hover { + opacity: 1; +} + +.ach-icon { + font-size: 1.5rem; + width: 2rem; + text-align: center; + flex-shrink: 0; +} + +.ach-info { + flex: 1; +} + +.ach-title { + font-size: 0.85rem; + font-weight: 700; + color: #ffd740; +} + +.ach-item.locked .ach-title { + color: #555; +} + +.ach-desc { + font-size: 0.7rem; + color: #777; + margin-top: 2px; + line-height: 1.3; +} + +/* === Intro Overlay === */ +#intro-overlay { + position: fixed; + inset: 0; + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.97); +} + +.intro-content { + max-width: 600px; + text-align: center; + padding: 30px; +} + +#intro-text { + font-size: 1.15rem; + line-height: 1.8; + color: #ccc; + margin-bottom: 2em; + min-height: 100px; +} + +#intro-text .intro-line { + opacity: 0; + animation: introFadeIn 1s forwards; + margin-bottom: 0.8em; +} + +@keyframes introFadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* === Begging === */ +#beg-progress { + position: fixed; + bottom: 120px; + left: 50%; + transform: translateX(-50%); + z-index: 12; + width: 200px; + pointer-events: none; +} + +#beg-progress .beg-bar-bg { + height: 6px; + background: rgba(0,0,0,0.6); + border-radius: 3px; + overflow: hidden; +} + +#beg-progress .beg-bar-fill { + height: 100%; + background: linear-gradient(90deg, #f0a040, #ffd740); + border-radius: 3px; + width: 0%; + transition: width 0.1s linear; +} + +#beg-progress .beg-label { + text-align: center; + font-size: 0.8rem; + color: #ccc; + margin-bottom: 4px; +} + +/* === Busking === */ +#busk-progress { + position: fixed; + bottom: 120px; + left: 50%; + transform: translateX(-50%); + z-index: 12; + width: 200px; + pointer-events: none; +} + +#busk-progress .beg-bar-bg { + height: 6px; + background: rgba(0,0,0,0.6); + border-radius: 3px; + overflow: hidden; +} + +#busk-progress .beg-bar-fill { + height: 100%; + background: linear-gradient(90deg, #e8a020, #ffcc00); + border-radius: 3px; + width: 0%; + transition: width 0.1s linear; +} + +#busk-progress .beg-label { + text-align: center; + font-size: 0.8rem; + color: #ffe080; + margin-bottom: 4px; +} + +/* === Stamina Bar === */ +#stamina-bar { + position: absolute; + bottom: 68px; + left: 50%; + transform: translateX(-50%); + width: 120px; + height: 3px; + background: rgba(255,255,255,0.06); + border-radius: 2px; + pointer-events: none; + transition: opacity 0.3s; +} + +#stamina-bar .stamina-fill { + height: 100%; + background: linear-gradient(90deg, #66bb6a, #a5d6a7); + border-radius: 2px; + transition: width 0.15s; +} + +/* === Responsive adjustments === */ +@media (max-width: 768px) { + .stat-bar { min-width: 180px; padding: 3px 8px; } + .stat-label { width: 50px; font-size: 0.65rem; } + #minimap { width: 120px; height: 120px; } + .inventory-content, .quest-content, .skills-content, .achievements-content { + min-width: auto; + width: 95vw; + max-width: 95vw; + } + #inventory-grid { grid-template-columns: repeat(4, 1fr); } + .hotbar-item { width: 36px; height: 36px; font-size: 1rem; } + #quest-tracker { display: none; } +} diff --git a/data/map-config.json b/data/map-config.json new file mode 100644 index 0000000..a69da65 --- /dev/null +++ b/data/map-config.json @@ -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 + ] + ] + } + ] +} \ No newline at end of file diff --git a/editor.html b/editor.html new file mode 100644 index 0000000..e20e7d7 --- /dev/null +++ b/editor.html @@ -0,0 +1,524 @@ + + + + + + Редактор карты — Бомж RPG + + + + +
+ Редактор карты + + + +
+ + + +
+ + + +
+ + + + +
+ + +
+
+
+ +
+
+
Выберите объект на карте
или в списке слева
+
+
+ + +
+ x: 0, z: 0 + Зум: 100% + Объектов: 0 +
+ + +
+
+

Управление редактором

+ + + + + + + + + + + +
ЛКМВыбрать объект
ЛКМ + тянутьПереместить объект
ПКМ + тянутьПанорамирование
КолёсикоЗум (к курсору)
DeleteУдалить выбранный
EscapeСнять выделение
Панель слеваСписок объектов, "+" — добавить
Панель справаСвойства выбранного объекта
СохранитьСкачать map-config.json
ЗагрузитьЗагрузить JSON из файла
+ +
+
+ + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..16ef33f --- /dev/null +++ b/index.html @@ -0,0 +1,242 @@ + + + + + + Бомж RPG — Выживание в городе + + + + + + + + + + +
+ + +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + diff --git a/js/editor/MapEditor.js b/js/editor/MapEditor.js new file mode 100644 index 0000000..a88ff74 --- /dev/null +++ b/js/editor/MapEditor.js @@ -0,0 +1,2075 @@ +/** + * MapEditor.js - 2D canvas-based map editor for a Three.js RPG game. + * Renders all map objects as 2D shapes (top-down view) and provides + * interactive editing: select, move, add, delete, and modify properties. + * + * Coordinate system: X = horizontal (left-right), Z = vertical on canvas + * (positive Z = up/north in the game world, which maps to negative Y on canvas). + */ + +export class MapEditor { + constructor(canvas, listPanel, propsPanel, statusBar) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + this.listPanel = listPanel; + this.propsPanel = propsPanel; + this.statusBar = statusBar; + + // Camera state + this.camera = { x: 0, z: 0, zoom: 2.5 }; + this.minZoom = 0.5; + this.maxZoom = 10; + + // Object storage + this.objects = []; + this.selectedObject = null; + this.nextId = 1; + + // Interaction state + this.isDragging = false; + this.isPanning = false; + this.dragStartWorld = null; + this.dragStartObjPos = null; + this.panStart = null; + this.panCameraStart = null; + this.mouseWorld = { x: 0, z: 0 }; + + // Settings + this.snapStep = 1; + this.showGrid = true; + this.showLabels = true; + this.showRoutes = true; + + // Category collapse state + this.collapsedCategories = {}; + + // Structure display names (Russian) + this.structureLabels = { + park: 'Парк', + shop: 'Магазин', + shelter: 'Приют', + hospital: 'Больница', + church: 'Церковь', + market: 'Рынок', + construction: 'Стройка', + busStop: 'Остановка', + parking: 'Парковка', + fountain: 'Фонтан', + phoneBooth: 'Телефон', + jobBoard: 'Доска вакансий', + campSpot: 'Лагерь' + }; + + // Category display names (Russian) + this.categoryLabels = { + roads: 'Дороги', + buildings: 'Здания', + structures: 'Структуры', + interactables: 'Интерактивные', + decorations: 'Декорации', + npcs: 'NPC', + vehicles: 'Транспорт' + }; + + // Category icons + this.categoryIcons = { + roads: '\u2550', + buildings: '\u2302', + structures: '\u2736', + interactables: '\u2699', + decorations: '\u2600', + npcs: '\u263A', + vehicles: '\u2708' + }; + + this._setupEventListeners(); + this._resizeCanvas(); + this._startRenderLoop(); + } + + // ========================================================================= + // COORDINATE CONVERSION + // ========================================================================= + + screenToWorld(sx, sy) { + const cx = this.canvas.width / 2; + const cy = this.canvas.height / 2; + const wx = (sx - cx) / this.camera.zoom + this.camera.x; + const wz = -(sy - cy) / this.camera.zoom + this.camera.z; + return { x: wx, z: wz }; + } + + worldToScreen(wx, wz) { + const cx = this.canvas.width / 2; + const cy = this.canvas.height / 2; + const sx = (wx - this.camera.x) * this.camera.zoom + cx; + const sy = -(wz - this.camera.z) * this.camera.zoom + cy; + return { x: sx, y: sy }; + } + + // ========================================================================= + // EVENT LISTENERS + // ========================================================================= + + _setupEventListeners() { + this.canvas.addEventListener('mousedown', (e) => this._onMouseDown(e)); + this.canvas.addEventListener('mousemove', (e) => this._onMouseMove(e)); + this.canvas.addEventListener('mouseup', (e) => this._onMouseUp(e)); + this.canvas.addEventListener('wheel', (e) => this._onWheel(e)); + this.canvas.addEventListener('contextmenu', (e) => e.preventDefault()); + + window.addEventListener('keydown', (e) => this._onKeyDown(e)); + window.addEventListener('resize', () => this._resizeCanvas()); + } + + _resizeCanvas() { + this.canvas.width = this.canvas.clientWidth; + this.canvas.height = this.canvas.clientHeight; + } + + _onMouseDown(e) { + const rect = this.canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + const world = this.screenToWorld(sx, sy); + + if (e.button === 2) { + // Right click: start panning + this.isPanning = true; + this.panStart = { x: sx, y: sy }; + this.panCameraStart = { x: this.camera.x, z: this.camera.z }; + return; + } + + if (e.button === 0) { + // Left click: select or start drag + const hit = this.hitTest(world.x, world.z); + if (hit) { + this.selectedObject = hit; + this.isDragging = true; + this.dragStartWorld = { x: world.x, z: world.z }; + this.dragStartObjPos = { x: hit.x, z: hit.z }; + } else { + this.selectedObject = null; + } + this.updateObjectList(); + this.updatePropsPanel(); + } + } + + _onMouseMove(e) { + const rect = this.canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + const world = this.screenToWorld(sx, sy); + this.mouseWorld = world; + + if (this.isPanning && this.panStart) { + const dx = (sx - this.panStart.x) / this.camera.zoom; + const dy = (sy - this.panStart.y) / this.camera.zoom; + this.camera.x = this.panCameraStart.x - dx; + this.camera.z = this.panCameraStart.z + dy; + } + + if (this.isDragging && this.selectedObject && this.dragStartWorld) { + const dx = world.x - this.dragStartWorld.x; + const dz = world.z - this.dragStartWorld.z; + let newX = this.dragStartObjPos.x + dx; + let newZ = this.dragStartObjPos.z + dz; + if (this.snapStep > 0) { + newX = Math.round(newX / this.snapStep) * this.snapStep; + newZ = Math.round(newZ / this.snapStep) * this.snapStep; + } + this.selectedObject.x = newX; + this.selectedObject.z = newZ; + this.updatePropsPanel(); + } + + this._updateStatusBar(); + } + + _onMouseUp(e) { + if (e.button === 2) { + this.isPanning = false; + this.panStart = null; + this.panCameraStart = null; + } + if (e.button === 0) { + this.isDragging = false; + this.dragStartWorld = null; + this.dragStartObjPos = null; + } + } + + _onWheel(e) { + e.preventDefault(); + const rect = this.canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + + const worldBefore = this.screenToWorld(sx, sy); + + const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; + this.camera.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.camera.zoom * zoomFactor)); + + const worldAfter = this.screenToWorld(sx, sy); + this.camera.x += worldBefore.x - worldAfter.x; + this.camera.z += worldBefore.z - worldAfter.z; + } + + _onKeyDown(e) { + if (e.key === 'Delete' && this.selectedObject) { + this.deleteObject(this.selectedObject); + } + } + + // ========================================================================= + // HIT TESTING + // ========================================================================= + + hitTest(worldX, worldZ) { + // Check in reverse order so top-rendered objects are hit first + for (let i = this.objects.length - 1; i >= 0; i--) { + const obj = this.objects[i]; + if (this._hitTestObject(obj, worldX, worldZ)) { + return obj; + } + } + return null; + } + + _hitTestObject(obj, wx, wz) { + switch (obj.type) { + case 'road': { + let rw, rh; + if (obj.rotation && Math.abs(obj.rotation) > 0.1) { + rw = obj.height; + rh = obj.width; + } else { + rw = obj.width; + rh = obj.height; + } + return Math.abs(wx - obj.x) <= rw / 2 && Math.abs(wz - obj.z) <= rh / 2; + } + case 'building': { + const bw = obj.w || 10; + const bd = obj.d || 8; + return Math.abs(wx - obj.x) <= bw / 2 && Math.abs(wz - obj.z) <= bd / 2; + } + case 'structure': + return this._hitTestStructure(obj, wx, wz); + case 'dumpster': + return Math.abs(wx - obj.x) <= 1.5 && Math.abs(wz - obj.z) <= 1; + case 'bench': + return Math.abs(wx - obj.x) <= 2 && Math.abs(wz - obj.z) <= 1; + case 'trashPile': + return Math.hypot(wx - obj.x, wz - obj.z) <= 1.5; + case 'lamp': + return Math.hypot(wx - obj.x, wz - obj.z) <= 1.5; + case 'hydrant': + return Math.hypot(wx - obj.x, wz - obj.z) <= 1; + case 'bin': + return Math.hypot(wx - obj.x, wz - obj.z) <= 1; + case 'npc': + return Math.hypot(wx - obj.x, wz - obj.z) <= 2; + case 'vehicleRoute': { + const route = obj; + if (route.axis === 'x') { + const minX = Math.min(route.start, route.end); + const maxX = Math.max(route.start, route.end); + return wx >= minX && wx <= maxX && Math.abs(wz - route.lane) <= 3; + } else { + const minZ = Math.min(route.start, route.end); + const maxZ = Math.max(route.start, route.end); + return wz >= minZ && wz <= maxZ && Math.abs(wx - route.lane) <= 3; + } + } + case 'passerbyRoute': { + if (!obj.waypoints || obj.waypoints.length < 2) return false; + for (let j = 0; j < obj.waypoints.length - 1; j++) { + const ax = obj.waypoints[j][0], az = obj.waypoints[j][1]; + const bx = obj.waypoints[j + 1][0], bz = obj.waypoints[j + 1][1]; + const dist = this._pointToSegmentDist(wx, wz, ax, az, bx, bz); + if (dist < 3) return true; + } + return false; + } + default: + return Math.abs(wx - obj.x) <= 3 && Math.abs(wz - obj.z) <= 3; + } + } + + _hitTestStructure(obj, wx, wz) { + const st = obj.structureType; + // For structures with radius (park, construction, fountain) + if (obj.radius) { + return Math.hypot(wx - obj.x, wz - obj.z) <= obj.radius; + } + // For structures with w/d dimensions + if (obj.w && obj.d) { + return Math.abs(wx - obj.x) <= obj.w / 2 && Math.abs(wz - obj.z) <= obj.d / 2; + } + // Fallback defaults per type + switch (st) { + case 'park': + return Math.hypot(wx - obj.x, wz - obj.z) <= 13; + case 'construction': + return Math.hypot(wx - obj.x, wz - obj.z) <= 12; + case 'fountain': + return Math.hypot(wx - obj.x, wz - obj.z) <= 2; + case 'campSpot': + return Math.abs(wx - obj.x) + Math.abs(wz - obj.z) <= 3; + default: + return Math.abs(wx - obj.x) <= 3 && Math.abs(wz - obj.z) <= 3; + } + } + + _pointToSegmentDist(px, pz, ax, az, bx, bz) { + const dx = bx - ax; + const dz = bz - az; + const lenSq = dx * dx + dz * dz; + if (lenSq === 0) return Math.hypot(px - ax, pz - az); + let t = ((px - ax) * dx + (pz - az) * dz) / lenSq; + t = Math.max(0, Math.min(1, t)); + const cx = ax + t * dx; + const cz = az + t * dz; + return Math.hypot(px - cx, pz - cz); + } + + // ========================================================================= + // RENDERING + // ========================================================================= + + _startRenderLoop() { + const loop = () => { + this.render(); + requestAnimationFrame(loop); + }; + requestAnimationFrame(loop); + } + + render() { + const ctx = this.ctx; + const w = this.canvas.width; + const h = this.canvas.height; + + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = '#1a1a2e'; + ctx.fillRect(0, 0, w, h); + + // Draw ground + this._drawGround(); + + // Draw grid + if (this.showGrid) this._drawGrid(); + + // Draw coordinate axes + this._drawAxes(); + + // Draw objects in layers + this._drawRoads(); + this._drawBuildings(); + this._drawStructures(); + this._drawInteractables(); + this._drawDecorations(); + if (this.showRoutes) { + this._drawVehicleRoutes(); + this._drawPasserbyRoutes(); + } + this._drawNPCs(); + + // Draw selection highlight + if (this.selectedObject) { + this._drawSelectionHighlight(this.selectedObject); + } + } + + _drawGround() { + const ctx = this.ctx; + const topLeft = this.worldToScreen(-100, 100); + const botRight = this.worldToScreen(100, -100); + ctx.fillStyle = '#2d5a2d'; + ctx.fillRect(topLeft.x, topLeft.y, botRight.x - topLeft.x, botRight.y - topLeft.y); + } + + _drawGrid() { + const ctx = this.ctx; + const w = this.canvas.width; + const h = this.canvas.height; + + const topLeftWorld = this.screenToWorld(0, 0); + const botRightWorld = this.screenToWorld(w, h); + + const minX = Math.floor(topLeftWorld.x / 10) * 10 - 10; + const maxX = Math.ceil(botRightWorld.x / 10) * 10 + 10; + const minZ = Math.floor(botRightWorld.z / 10) * 10 - 10; + const maxZ = Math.ceil(topLeftWorld.z / 10) * 10 + 10; + + // Light grid every 10 units + ctx.lineWidth = 0.5; + for (let x = minX; x <= maxX; x += 10) { + const isMajor = x % 50 === 0; + ctx.strokeStyle = isMajor ? 'rgba(255,255,255,0.15)' : 'rgba(255,255,255,0.05)'; + ctx.lineWidth = isMajor ? 1 : 0.5; + const s1 = this.worldToScreen(x, minZ); + const s2 = this.worldToScreen(x, maxZ); + ctx.beginPath(); + ctx.moveTo(s1.x, s1.y); + ctx.lineTo(s2.x, s2.y); + ctx.stroke(); + } + for (let z = minZ; z <= maxZ; z += 10) { + const isMajor = z % 50 === 0; + ctx.strokeStyle = isMajor ? 'rgba(255,255,255,0.15)' : 'rgba(255,255,255,0.05)'; + ctx.lineWidth = isMajor ? 1 : 0.5; + const s1 = this.worldToScreen(minX, z); + const s2 = this.worldToScreen(maxX, z); + ctx.beginPath(); + ctx.moveTo(s1.x, s1.y); + ctx.lineTo(s2.x, s2.y); + ctx.stroke(); + } + } + + _drawAxes() { + const ctx = this.ctx; + const w = this.canvas.width; + const h = this.canvas.height; + + // X axis (red) + const xStart = this.worldToScreen(-200, 0); + const xEnd = this.worldToScreen(200, 0); + ctx.strokeStyle = 'rgba(255, 80, 80, 0.6)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(xStart.x, xStart.y); + ctx.lineTo(xEnd.x, xEnd.y); + ctx.stroke(); + + // Z axis (blue) + const zStart = this.worldToScreen(0, -200); + const zEnd = this.worldToScreen(0, 200); + ctx.strokeStyle = 'rgba(80, 80, 255, 0.6)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(zStart.x, zStart.y); + ctx.lineTo(zEnd.x, zEnd.y); + ctx.stroke(); + + // Axis labels + ctx.font = '12px monospace'; + ctx.fillStyle = '#ff5050'; + const xLabel = this.worldToScreen(195, 2); + ctx.fillText('X', xLabel.x, xLabel.y); + ctx.fillStyle = '#5050ff'; + const zLabel = this.worldToScreen(2, 195); + ctx.fillText('Z', zLabel.x, zLabel.y); + } + + _drawRoads() { + const ctx = this.ctx; + const roads = this.objects.filter(o => o.type === 'road'); + for (const road of roads) { + const isNS = road.rotation && Math.abs(road.rotation) > 0.1; + let rw, rh; + if (isNS) { + rw = road.height; + rh = road.width; + } else { + rw = road.width; + rh = road.height; + } + + // Sidewalks + const sw = road.sidewalkWidth ?? 3; + if (sw > 0) { + ctx.fillStyle = 'rgba(120, 120, 120, 0.4)'; + if (isNS) { + // N-S road: sidewalks east/west + const halfW = road.width / 2; + const halfLen = road.height / 2; + // East sidewalk + const eTl = this.worldToScreen(road.x + halfW, road.z + halfLen); + const eBr = this.worldToScreen(road.x + halfW + sw, road.z - halfLen); + ctx.fillRect(eTl.x, eTl.y, eBr.x - eTl.x, eBr.y - eTl.y); + // West sidewalk + const wTl = this.worldToScreen(road.x - halfW - sw, road.z + halfLen); + const wBr = this.worldToScreen(road.x - halfW, road.z - halfLen); + ctx.fillRect(wTl.x, wTl.y, wBr.x - wTl.x, wBr.y - wTl.y); + } else { + // E-W road: sidewalks north/south + const halfH = road.height / 2; + const halfLen = road.width / 2; + // North sidewalk + const nTl = this.worldToScreen(road.x - halfLen, road.z + halfH + sw); + const nBr = this.worldToScreen(road.x + halfLen, road.z + halfH); + ctx.fillRect(nTl.x, nTl.y, nBr.x - nTl.x, nBr.y - nTl.y); + // South sidewalk + const sTl = this.worldToScreen(road.x - halfLen, road.z - halfH); + const sBr = this.worldToScreen(road.x + halfLen, road.z - halfH - sw); + ctx.fillRect(sTl.x, sTl.y, sBr.x - sTl.x, sBr.y - sTl.y); + } + } + + // Road surface + const tl = this.worldToScreen(road.x - rw / 2, road.z + rh / 2); + const br = this.worldToScreen(road.x + rw / 2, road.z - rh / 2); + ctx.fillStyle = 'rgba(80, 80, 80, 0.8)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + + // Road center line + ctx.strokeStyle = 'rgba(200, 200, 50, 0.3)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 6]); + if (isNS) { + const cv1 = this.worldToScreen(road.x, road.z - rh / 2); + const cv2 = this.worldToScreen(road.x, road.z + rh / 2); + ctx.beginPath(); + ctx.moveTo(cv1.x, cv1.y); + ctx.lineTo(cv2.x, cv2.y); + ctx.stroke(); + } else { + const c1 = this.worldToScreen(road.x - rw / 2, road.z); + const c2 = this.worldToScreen(road.x + rw / 2, road.z); + ctx.beginPath(); + ctx.moveTo(c1.x, c1.y); + ctx.lineTo(c2.x, c2.y); + ctx.stroke(); + } + ctx.setLineDash([]); + + // Label + if (this.camera.zoom > 1.5 && road.name) { + const center = this.worldToScreen(road.x, road.z); + ctx.font = `${Math.max(9, 11 * this.camera.zoom / 2.5)}px sans-serif`; + ctx.fillStyle = 'rgba(255, 255, 200, 0.7)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(road.name, center.x, center.y); + } + } + } + + _drawBuildings() { + const ctx = this.ctx; + const buildings = this.objects.filter(o => o.type === 'building'); + for (const bld of buildings) { + const bw = bld.w || 10; + const bd = bld.d || 8; + const tl = this.worldToScreen(bld.x - bw / 2, bld.z + bd / 2); + const br = this.worldToScreen(bld.x + bw / 2, bld.z - bd / 2); + ctx.fillStyle = bld.color || '#888888'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = 'rgba(0,0,0,0.5)'; + ctx.lineWidth = 1; + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + } + } + + _drawStructures() { + const ctx = this.ctx; + const structures = this.objects.filter(o => o.type === 'structure'); + for (const st of structures) { + this._drawStructure(st); + } + } + + _drawStructure(obj) { + const ctx = this.ctx; + const st = obj.structureType; + const sc = this.worldToScreen(obj.x, obj.z); + const zoom = this.camera.zoom; + const label = this.structureLabels[st] || st; + + switch (st) { + case 'park': { + const r = (obj.radius || 18) * zoom; + ctx.fillStyle = 'rgba(50, 150, 50, 0.4)'; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(30, 120, 30, 0.6)'; + ctx.lineWidth = 2; + ctx.stroke(); + break; + } + case 'shop': { + const hw = (obj.w || 10) / 2, hd = (obj.d || 8) / 2; + const tl = this.worldToScreen(obj.x - hw, obj.z + hd); + const br = this.worldToScreen(obj.x + hw, obj.z - hd); + ctx.fillStyle = 'rgba(60, 100, 200, 0.7)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = '#3355aa'; + ctx.lineWidth = 1.5; + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + break; + } + case 'shelter': { + const hw = (obj.w || 8) / 2, hd = (obj.d || 6) / 2; + const tl = this.worldToScreen(obj.x - hw, obj.z + hd); + const br = this.worldToScreen(obj.x + hw, obj.z - hd); + ctx.fillStyle = 'rgba(90, 60, 30, 0.7)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = '#5a3a1a'; + ctx.lineWidth = 1.5; + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + break; + } + case 'hospital': { + const hw = (obj.w || 12) / 2, hd = (obj.d || 10) / 2; + const tl = this.worldToScreen(obj.x - hw, obj.z + hd); + const br = this.worldToScreen(obj.x + hw, obj.z - hd); + ctx.fillStyle = 'rgba(240, 240, 240, 0.8)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = '#cccccc'; + ctx.lineWidth = 1.5; + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + // Red cross + const center = this.worldToScreen(obj.x, obj.z); + const crossSize = 3 * zoom; + ctx.strokeStyle = '#cc0000'; + ctx.lineWidth = Math.max(2, zoom); + ctx.beginPath(); + ctx.moveTo(center.x - crossSize, center.y); + ctx.lineTo(center.x + crossSize, center.y); + ctx.moveTo(center.x, center.y - crossSize); + ctx.lineTo(center.x, center.y + crossSize); + ctx.stroke(); + break; + } + case 'church': { + const hw = (obj.w || 10) / 2, hd = (obj.d || 14) / 2; + const tl = this.worldToScreen(obj.x - hw, obj.z + hd); + const br = this.worldToScreen(obj.x + hw, obj.z - hd); + ctx.fillStyle = 'rgba(120, 60, 160, 0.7)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = '#6a2a9a'; + ctx.lineWidth = 1.5; + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + break; + } + case 'market': { + const hw = (obj.w || 14) / 2, hd = (obj.d || 10) / 2; + const tl = this.worldToScreen(obj.x - hw, obj.z + hd); + const br = this.worldToScreen(obj.x + hw, obj.z - hd); + ctx.fillStyle = 'rgba(220, 140, 40, 0.7)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = '#cc8820'; + ctx.lineWidth = 1.5; + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + break; + } + case 'construction': { + const r = (obj.radius || 12) * zoom; + ctx.strokeStyle = 'rgba(220, 200, 40, 0.7)'; + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + break; + } + case 'busStop': { + const hw = (obj.w || 5) / 2, hd = (obj.d || 2) / 2; + const tl = this.worldToScreen(obj.x - hw, obj.z + hd); + const br = this.worldToScreen(obj.x + hw, obj.z - hd); + ctx.fillStyle = 'rgba(0, 200, 220, 0.7)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = '#00aabb'; + ctx.lineWidth = 1; + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + break; + } + case 'parking': { + const hw = (obj.w || 20) / 2, hd = (obj.d || 15) / 2; + const tl = this.worldToScreen(obj.x - hw, obj.z + hd); + const br = this.worldToScreen(obj.x + hw, obj.z - hd); + ctx.fillStyle = 'rgba(100, 100, 100, 0.5)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = 'rgba(150, 150, 150, 0.7)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 3]); + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.setLineDash([]); + break; + } + case 'fountain': { + const r = (obj.radius || 2) * zoom; + ctx.fillStyle = 'rgba(80, 140, 255, 0.7)'; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#4488dd'; + ctx.lineWidth = 1.5; + ctx.stroke(); + break; + } + case 'phoneBooth': { + const hw = ((obj.w || 1.2) / 2) * zoom; + const hd = ((obj.d || 1.2) / 2) * zoom; + ctx.fillStyle = 'rgba(0, 160, 140, 0.8)'; + ctx.fillRect(sc.x - hw, sc.y - hd, hw * 2, hd * 2); + ctx.strokeStyle = '#009988'; + ctx.lineWidth = 1; + ctx.strokeRect(sc.x - hw, sc.y - hd, hw * 2, hd * 2); + break; + } + case 'jobBoard': { + const hw = ((obj.w || 1.2) / 2) * zoom; + const hd = ((obj.d || 0.8) / 2) * zoom; + ctx.fillStyle = 'rgba(60, 160, 60, 0.8)'; + ctx.fillRect(sc.x - hw, sc.y - hd, hw * 2, hd * 2); + ctx.strokeStyle = '#338833'; + ctx.lineWidth = 1; + ctx.strokeRect(sc.x - hw, sc.y - hd, hw * 2, hd * 2); + break; + } + case 'campSpot': { + const size = 3 * zoom; + ctx.fillStyle = 'rgba(50, 180, 50, 0.6)'; + ctx.beginPath(); + ctx.moveTo(sc.x, sc.y - size); + ctx.lineTo(sc.x + size, sc.y); + ctx.lineTo(sc.x, sc.y + size); + ctx.lineTo(sc.x - size, sc.y); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = '#228822'; + ctx.lineWidth = 1.5; + ctx.stroke(); + break; + } + } + + // Label + if (this.camera.zoom > 1.5) { + const fontSize = Math.max(9, 11 * zoom / 2.5); + ctx.font = `bold ${fontSize}px sans-serif`; + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + const labelPos = this.worldToScreen(obj.x, obj.z); + let offsetY = -6; + if (st === 'park') offsetY = -(obj.radius || 18) * zoom - 4; + if (st === 'construction') offsetY = -(obj.radius || 12) * zoom - 4; + ctx.fillText(label, labelPos.x, labelPos.y + offsetY); + } + } + + _drawInteractables() { + const ctx = this.ctx; + + // Dumpsters + const dumpsters = this.objects.filter(o => o.type === 'dumpster'); + for (const d of dumpsters) { + const tl = this.worldToScreen(d.x - 0.75, d.z + 0.5); + const br = this.worldToScreen(d.x + 0.75, d.z - 0.5); + ctx.fillStyle = 'rgba(40, 100, 40, 0.8)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = '#1a5a1a'; + ctx.lineWidth = 1; + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + } + + // Benches + const benches = this.objects.filter(o => o.type === 'bench'); + for (const b of benches) { + const isRotated = b.rot && Math.abs(b.rot) > 0.5; + const bw = isRotated ? 0.5 : 2; + const bh = isRotated ? 2 : 0.5; + const tl = this.worldToScreen(b.x - bw / 2, b.z + bh / 2); + const br = this.worldToScreen(b.x + bw / 2, b.z - bh / 2); + ctx.fillStyle = 'rgba(140, 90, 40, 0.8)'; + ctx.fillRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + ctx.strokeStyle = '#6a4020'; + ctx.lineWidth = 1; + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + } + + // Trash piles + const trashPiles = this.objects.filter(o => o.type === 'trashPile'); + for (const t of trashPiles) { + const sc = this.worldToScreen(t.x, t.z); + const r = 1.5 * this.camera.zoom; + ctx.fillStyle = 'rgba(100, 110, 50, 0.7)'; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#5a5a30'; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + + _drawDecorations() { + const ctx = this.ctx; + + // Lamps + const lamps = this.objects.filter(o => o.type === 'lamp'); + for (const l of lamps) { + const sc = this.worldToScreen(l.x, l.z); + const r = 0.8 * this.camera.zoom; + ctx.fillStyle = 'rgba(255, 230, 80, 0.8)'; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.fill(); + // Glow effect + ctx.fillStyle = 'rgba(255, 230, 80, 0.15)'; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r * 3, 0, Math.PI * 2); + ctx.fill(); + } + + // Hydrants + const hydrants = this.objects.filter(o => o.type === 'hydrant'); + for (const h of hydrants) { + const sc = this.worldToScreen(h.x, h.z); + const r = 0.5 * this.camera.zoom; + ctx.fillStyle = 'rgba(220, 50, 50, 0.8)'; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.fill(); + } + + // Bins + const bins = this.objects.filter(o => o.type === 'bin'); + for (const b of bins) { + const sc = this.worldToScreen(b.x, b.z); + const r = 0.4 * this.camera.zoom; + ctx.fillStyle = 'rgba(150, 150, 150, 0.8)'; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.fill(); + } + } + + _drawNPCs() { + const ctx = this.ctx; + const npcs = this.objects.filter(o => o.type === 'npc'); + for (const npc of npcs) { + // Draw patrol route + if (npc.patrol && npc.patrol.length > 1) { + ctx.strokeStyle = 'rgba(80, 120, 255, 0.4)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + for (let i = 0; i < npc.patrol.length; i++) { + const ps = this.worldToScreen(npc.patrol[i][0], npc.patrol[i][1]); + if (i === 0) ctx.moveTo(ps.x, ps.y); + else ctx.lineTo(ps.x, ps.y); + } + // Close loop + const first = this.worldToScreen(npc.patrol[0][0], npc.patrol[0][1]); + ctx.lineTo(first.x, first.y); + ctx.stroke(); + ctx.setLineDash([]); + + // Patrol waypoints + for (const wp of npc.patrol) { + const wps = this.worldToScreen(wp[0], wp[1]); + ctx.fillStyle = 'rgba(80, 120, 255, 0.5)'; + ctx.beginPath(); + ctx.arc(wps.x, wps.y, 2, 0, Math.PI * 2); + ctx.fill(); + } + } + + // NPC circle + const sc = this.worldToScreen(npc.x, npc.z); + const r = 1.5 * this.camera.zoom; + ctx.fillStyle = npc.color || '#3355aa'; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Name label + if (this.camera.zoom > 1.0) { + const fontSize = Math.max(9, 11 * this.camera.zoom / 2.5); + ctx.font = `bold ${fontSize}px sans-serif`; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(npc.name || 'NPC', sc.x, sc.y - r - 3); + } + } + } + + _drawVehicleRoutes() { + const ctx = this.ctx; + const routes = this.objects.filter(o => o.type === 'vehicleRoute'); + for (const route of routes) { + ctx.strokeStyle = 'rgba(220, 140, 40, 0.5)'; + ctx.lineWidth = 2; + + let sx1, sy1, sx2, sy2; + if (route.axis === 'x') { + const s1 = this.worldToScreen(route.start, route.lane); + const s2 = this.worldToScreen(route.end, route.lane); + sx1 = s1.x; sy1 = s1.y; + sx2 = s2.x; sy2 = s2.y; + } else { + const s1 = this.worldToScreen(route.lane, route.start); + const s2 = this.worldToScreen(route.lane, route.end); + sx1 = s1.x; sy1 = s1.y; + sx2 = s2.x; sy2 = s2.y; + } + + ctx.beginPath(); + ctx.moveTo(sx1, sy1); + ctx.lineTo(sx2, sy2); + ctx.stroke(); + + // Arrow at end + const angle = Math.atan2(sy2 - sy1, sx2 - sx1); + const arrowSize = 8; + ctx.fillStyle = 'rgba(220, 140, 40, 0.7)'; + ctx.beginPath(); + ctx.moveTo(sx2, sy2); + ctx.lineTo(sx2 - arrowSize * Math.cos(angle - 0.4), sy2 - arrowSize * Math.sin(angle - 0.4)); + ctx.lineTo(sx2 - arrowSize * Math.cos(angle + 0.4), sy2 - arrowSize * Math.sin(angle + 0.4)); + ctx.closePath(); + ctx.fill(); + } + } + + _drawPasserbyRoutes() { + const ctx = this.ctx; + const routes = this.objects.filter(o => o.type === 'passerbyRoute'); + for (const route of routes) { + if (!route.waypoints || route.waypoints.length < 2) continue; + + ctx.strokeStyle = 'rgba(100, 200, 100, 0.3)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([3, 5]); + ctx.beginPath(); + for (let i = 0; i < route.waypoints.length; i++) { + const wp = route.waypoints[i]; + const sc = this.worldToScreen(wp[0], wp[1]); + if (i === 0) ctx.moveTo(sc.x, sc.y); + else ctx.lineTo(sc.x, sc.y); + } + ctx.stroke(); + ctx.setLineDash([]); + + // Arrow at end + const last = route.waypoints[route.waypoints.length - 1]; + const prev = route.waypoints[route.waypoints.length - 2]; + const sLast = this.worldToScreen(last[0], last[1]); + const sPrev = this.worldToScreen(prev[0], prev[1]); + const angle = Math.atan2(sLast.y - sPrev.y, sLast.x - sPrev.x); + const arrowSize = 6; + ctx.fillStyle = 'rgba(100, 200, 100, 0.5)'; + ctx.beginPath(); + ctx.moveTo(sLast.x, sLast.y); + ctx.lineTo(sLast.x - arrowSize * Math.cos(angle - 0.4), sLast.y - arrowSize * Math.sin(angle - 0.4)); + ctx.lineTo(sLast.x - arrowSize * Math.cos(angle + 0.4), sLast.y - arrowSize * Math.sin(angle + 0.4)); + ctx.closePath(); + ctx.fill(); + } + } + + _drawSelectionHighlight(obj) { + const ctx = this.ctx; + ctx.strokeStyle = '#ffff00'; + ctx.lineWidth = 2.5; + ctx.setLineDash([5, 3]); + + switch (obj.type) { + case 'road': { + let rw, rh; + if (obj.rotation && Math.abs(obj.rotation) > 0.1) { + rw = obj.height; + rh = obj.width; + } else { + rw = obj.width; + rh = obj.height; + } + const pad = 1; + const tl = this.worldToScreen(obj.x - rw / 2 - pad, obj.z + rh / 2 + pad); + const br = this.worldToScreen(obj.x + rw / 2 + pad, obj.z - rh / 2 - pad); + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + break; + } + case 'building': { + const bw = obj.w || 10; + const bd = obj.d || 8; + const pad = 1; + const tl = this.worldToScreen(obj.x - bw / 2 - pad, obj.z + bd / 2 + pad); + const br = this.worldToScreen(obj.x + bw / 2 + pad, obj.z - bd / 2 - pad); + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + break; + } + case 'structure': + this._drawStructureSelectionHighlight(obj); + break; + case 'npc': { + const sc = this.worldToScreen(obj.x, obj.z); + const r = (1.5 + 0.5) * this.camera.zoom; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.stroke(); + break; + } + case 'vehicleRoute': { + if (obj.axis === 'x') { + const pad = 2; + const tl = this.worldToScreen(Math.min(obj.start, obj.end) - pad, obj.lane + pad); + const br = this.worldToScreen(Math.max(obj.start, obj.end) + pad, obj.lane - pad); + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + } else { + const pad = 2; + const tl = this.worldToScreen(obj.lane - pad, Math.max(obj.start, obj.end) + pad); + const br = this.worldToScreen(obj.lane + pad, Math.min(obj.start, obj.end) - pad); + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + } + break; + } + case 'passerbyRoute': { + if (obj.waypoints && obj.waypoints.length > 1) { + ctx.strokeStyle = 'rgba(255, 255, 0, 0.8)'; + ctx.lineWidth = 3; + ctx.beginPath(); + for (let i = 0; i < obj.waypoints.length; i++) { + const sc = this.worldToScreen(obj.waypoints[i][0], obj.waypoints[i][1]); + if (i === 0) ctx.moveTo(sc.x, sc.y); + else ctx.lineTo(sc.x, sc.y); + } + ctx.stroke(); + } + break; + } + default: { + const sc = this.worldToScreen(obj.x, obj.z); + const r = 3 * this.camera.zoom; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.stroke(); + break; + } + } + ctx.setLineDash([]); + } + + _drawStructureSelectionHighlight(obj) { + const ctx = this.ctx; + const pad = 1; + + // Круглые структуры (park, construction, fountain) + if (obj.radius) { + const sc = this.worldToScreen(obj.x, obj.z); + const r = (obj.radius + pad) * this.camera.zoom; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.stroke(); + return; + } + + // Прямоугольные структуры с размерами из конфига + if (obj.w && obj.d) { + const hw = obj.w / 2, hd = obj.d / 2; + const tl = this.worldToScreen(obj.x - hw - pad, obj.z + hd + pad); + const br = this.worldToScreen(obj.x + hw + pad, obj.z - hd - pad); + ctx.strokeRect(tl.x, tl.y, br.x - tl.x, br.y - tl.y); + return; + } + + // campSpot — ромб + if (obj.structureType === 'campSpot') { + const sc = this.worldToScreen(obj.x, obj.z); + const size = (3 + pad) * this.camera.zoom; + ctx.beginPath(); + ctx.moveTo(sc.x, sc.y - size); + ctx.lineTo(sc.x + size, sc.y); + ctx.lineTo(sc.x, sc.y + size); + ctx.lineTo(sc.x - size, sc.y); + ctx.closePath(); + ctx.stroke(); + return; + } + + // Fallback — круг + const sc = this.worldToScreen(obj.x, obj.z); + const r = 4 * this.camera.zoom; + ctx.beginPath(); + ctx.arc(sc.x, sc.y, r, 0, Math.PI * 2); + ctx.stroke(); + } + + // ========================================================================= + // CONFIG LOADING / EXPORTING + // ========================================================================= + + async loadConfigFromFile(path) { + try { + const response = await fetch(path); + const json = await response.json(); + this.loadConfig(json); + } catch (err) { + console.error('Failed to load map config:', err); + } + } + + loadConfig(json) { + this.objects = []; + this.nextId = 1; + this.selectedObject = null; + + // Roads + if (json.roads) { + for (const road of json.roads) { + this.objects.push({ + type: 'road', + category: 'roads', + id: this.nextId++, + roadId: road.id || '', + name: road.name || '', + x: road.x, + z: road.z, + width: road.width, + height: road.height, + rotation: road.rotation || 0, + sidewalkWidth: road.sidewalkWidth ?? 3 + }); + } + } + + // Buildings + if (json.buildings) { + for (let i = 0; i < json.buildings.length; i++) { + const bld = json.buildings[i]; + this.objects.push({ + type: 'building', + category: 'buildings', + id: this.nextId++, + x: bld.x, + z: bld.z, + w: bld.w, + h: bld.h, + d: bld.d, + color: bld.color || '#888888' + }); + } + } + + // Structures + if (json.structures) { + for (const [key, data] of Object.entries(json.structures)) { + this.objects.push({ + type: 'structure', + category: 'structures', + id: this.nextId++, + structureType: key, + x: data.x, + z: data.z, + radius: data.radius, + w: data.w, + h: data.h, + d: data.d, + rotation: data.rotation + }); + } + } + + // Interactables - dumpsters + if (json.interactables && json.interactables.dumpsters) { + for (const d of json.interactables.dumpsters) { + this.objects.push({ + type: 'dumpster', + category: 'interactables', + id: this.nextId++, + x: d.x, + z: d.z, + rot: d.rot || 0 + }); + } + } + + // Interactables - benches + if (json.interactables && json.interactables.benches) { + for (const b of json.interactables.benches) { + this.objects.push({ + type: 'bench', + category: 'interactables', + id: this.nextId++, + x: b.x, + z: b.z, + rot: b.rot || 0 + }); + } + } + + // Interactables - trash piles + if (json.interactables && json.interactables.trashPiles) { + for (const t of json.interactables.trashPiles) { + this.objects.push({ + type: 'trashPile', + category: 'interactables', + id: this.nextId++, + x: t.x, + z: t.z + }); + } + } + + // Decorations + if (json.decorations) { + if (json.decorations.lamps) { + for (const l of json.decorations.lamps) { + this.objects.push({ + type: 'lamp', + category: 'decorations', + id: this.nextId++, + x: l[0], + z: l[1] + }); + } + } + if (json.decorations.hydrants) { + for (const h of json.decorations.hydrants) { + this.objects.push({ + type: 'hydrant', + category: 'decorations', + id: this.nextId++, + x: h[0], + z: h[1] + }); + } + } + if (json.decorations.bins) { + for (const b of json.decorations.bins) { + this.objects.push({ + type: 'bin', + category: 'decorations', + id: this.nextId++, + x: b[0], + z: b[1] + }); + } + } + } + + // NPCs + if (json.npcs) { + for (const npc of json.npcs) { + this.objects.push({ + type: 'npc', + category: 'npcs', + id: this.nextId++, + name: npc.name, + x: npc.x, + z: npc.z, + npcType: npc.type, + color: npc.color || '#3355aa', + patrol: npc.patrol ? npc.patrol.map(p => [...p]) : null + }); + } + } + + // Vehicle routes + if (json.vehicles && json.vehicles.routes) { + for (const route of json.vehicles.routes) { + // For vehicle routes, x/z represent center for dragging purposes + let cx, cz; + if (route.axis === 'x') { + cx = (route.start + route.end) / 2; + cz = route.lane; + } else { + cx = route.lane; + cz = (route.start + route.end) / 2; + } + this.objects.push({ + type: 'vehicleRoute', + category: 'vehicles', + id: this.nextId++, + x: cx, + z: cz, + axis: route.axis, + lane: route.lane, + start: route.start, + end: route.end, + dir: route.dir + }); + } + } + + // Passerby routes + if (json.passerbyRoutes) { + for (const route of json.passerbyRoutes) { + const wps = route.waypoints || []; + let cx = 0, cz = 0; + if (wps.length > 0) { + for (const wp of wps) { cx += wp[0]; cz += wp[1]; } + cx /= wps.length; + cz /= wps.length; + } + this.objects.push({ + type: 'passerbyRoute', + category: 'vehicles', + id: this.nextId++, + x: cx, + z: cz, + waypoints: wps.map(w => [...w]) + }); + } + } + + this.updateObjectList(); + this.updatePropsPanel(); + } + + exportConfig() { + const config = { + roads: [], + buildings: [], + structures: {}, + interactables: { dumpsters: [], benches: [], trashPiles: [] }, + decorations: { lamps: [], hydrants: [], bins: [] }, + npcs: [], + vehicles: { routes: [] }, + passerbyRoutes: [] + }; + + for (const obj of this.objects) { + switch (obj.type) { + case 'road': + config.roads.push({ + id: obj.roadId || obj.id.toString(), + name: obj.name || '', + x: obj.x, + z: obj.z, + width: obj.width, + height: obj.height, + rotation: obj.rotation || 0, + sidewalkWidth: obj.sidewalkWidth ?? 3 + }); + break; + case 'building': + config.buildings.push({ + x: obj.x, + z: obj.z, + w: obj.w, + h: obj.h, + d: obj.d, + color: obj.color + }); + break; + case 'structure': { + const data = { x: obj.x, z: obj.z }; + if (obj.radius !== undefined && obj.radius !== null) data.radius = obj.radius; + if (obj.w !== undefined && obj.w !== null) data.w = obj.w; + if (obj.h !== undefined && obj.h !== null) data.h = obj.h; + if (obj.d !== undefined && obj.d !== null) data.d = obj.d; + if (obj.rotation !== undefined && obj.rotation !== null) data.rotation = obj.rotation; + config.structures[obj.structureType] = data; + break; + } + case 'dumpster': + config.interactables.dumpsters.push({ + x: obj.x, + z: obj.z, + rot: obj.rot || 0 + }); + break; + case 'bench': + config.interactables.benches.push({ + x: obj.x, + z: obj.z, + rot: obj.rot || 0 + }); + break; + case 'trashPile': + config.interactables.trashPiles.push({ + x: obj.x, + z: obj.z + }); + break; + case 'lamp': + config.decorations.lamps.push([obj.x, obj.z]); + break; + case 'hydrant': + config.decorations.hydrants.push([obj.x, obj.z]); + break; + case 'bin': + config.decorations.bins.push([obj.x, obj.z]); + break; + case 'npc': { + const npcData = { + name: obj.name, + x: obj.x, + z: obj.z, + type: obj.npcType || 'citizen', + color: obj.color + }; + if (obj.patrol) npcData.patrol = obj.patrol.map(p => [...p]); + config.npcs.push(npcData); + break; + } + case 'vehicleRoute': + config.vehicles.routes.push({ + axis: obj.axis, + lane: obj.lane, + start: obj.start, + end: obj.end, + dir: obj.dir + }); + break; + case 'passerbyRoute': + config.passerbyRoutes.push({ + waypoints: obj.waypoints ? obj.waypoints.map(w => [...w]) : [] + }); + break; + } + } + + return config; + } + + saveToFile() { + const config = this.exportConfig(); + const json = JSON.stringify(config, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'map-config.json'; + a.click(); + URL.revokeObjectURL(url); + } + + loadFromFile() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => { + const file = e.target.files[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + const json = JSON.parse(ev.target.result); + this.loadConfig(json); + } catch (err) { + console.error('Failed to parse JSON:', err); + alert('Ошибка при загрузке файла: ' + err.message); + } + }; + reader.readAsText(file); + }; + input.click(); + } + + // ========================================================================= + // OBJECT MANAGEMENT + // ========================================================================= + + addObject(type, props = {}) { + const centerWorld = this.screenToWorld(this.canvas.width / 2, this.canvas.height / 2); + const baseX = props.x !== undefined ? props.x : Math.round(centerWorld.x); + const baseZ = props.z !== undefined ? props.z : Math.round(centerWorld.z); + + let obj = null; + + switch (type) { + case 'road': + obj = { + type: 'road', + category: 'roads', + id: this.nextId++, + roadId: props.roadId || 'road_' + this.nextId, + name: props.name || 'Новая дорога', + x: baseX, + z: baseZ, + width: props.width || 100, + height: props.height || 8, + rotation: props.rotation || 0, + sidewalkWidth: props.sidewalkWidth ?? 3 + }; + break; + case 'building': + obj = { + type: 'building', + category: 'buildings', + id: this.nextId++, + x: baseX, + z: baseZ, + w: props.w || 10, + h: props.h || 15, + d: props.d || 8, + color: props.color || '#888888' + }; + break; + case 'structure': + obj = { + type: 'structure', + category: 'structures', + id: this.nextId++, + structureType: props.structureType || 'shop', + x: baseX, + z: baseZ, + radius: props.radius, + w: props.w, + h: props.h, + d: props.d, + rotation: props.rotation + }; + break; + case 'dumpster': + obj = { + type: 'dumpster', + category: 'interactables', + id: this.nextId++, + x: baseX, + z: baseZ, + rot: props.rot || 0 + }; + break; + case 'bench': + obj = { + type: 'bench', + category: 'interactables', + id: this.nextId++, + x: baseX, + z: baseZ, + rot: props.rot || 0 + }; + break; + case 'trashPile': + obj = { + type: 'trashPile', + category: 'interactables', + id: this.nextId++, + x: baseX, + z: baseZ + }; + break; + case 'lamp': + obj = { + type: 'lamp', + category: 'decorations', + id: this.nextId++, + x: baseX, + z: baseZ + }; + break; + case 'hydrant': + obj = { + type: 'hydrant', + category: 'decorations', + id: this.nextId++, + x: baseX, + z: baseZ + }; + break; + case 'bin': + obj = { + type: 'bin', + category: 'decorations', + id: this.nextId++, + x: baseX, + z: baseZ + }; + break; + case 'npc': + obj = { + type: 'npc', + category: 'npcs', + id: this.nextId++, + name: props.name || 'Новый NPC', + x: baseX, + z: baseZ, + npcType: props.npcType || 'citizen', + color: props.color || '#3355aa', + patrol: props.patrol || null + }; + break; + case 'vehicleRoute': + obj = { + type: 'vehicleRoute', + category: 'vehicles', + id: this.nextId++, + x: baseX, + z: baseZ, + axis: props.axis || 'x', + lane: props.lane || baseZ, + start: props.start || (baseX - 50), + end: props.end || (baseX + 50), + dir: props.dir || 1 + }; + break; + case 'passerbyRoute': + obj = { + type: 'passerbyRoute', + category: 'vehicles', + id: this.nextId++, + x: baseX, + z: baseZ, + waypoints: props.waypoints || [ + [baseX - 30, baseZ], + [baseX, baseZ], + [baseX + 30, baseZ] + ] + }; + break; + default: + console.warn('Unknown object type:', type); + return null; + } + + if (obj) { + this.objects.push(obj); + this.selectedObject = obj; + this.updateObjectList(); + this.updatePropsPanel(); + } + + return obj; + } + + deleteObject(obj) { + const idx = this.objects.indexOf(obj); + if (idx !== -1) { + this.objects.splice(idx, 1); + if (this.selectedObject === obj) { + this.selectedObject = null; + } + this.updateObjectList(); + this.updatePropsPanel(); + } + } + + // ========================================================================= + // OBJECT LIST PANEL (left sidebar) + // ========================================================================= + + updateObjectList() { + if (!this.listPanel) return; + + const categories = [ + { key: 'roads', types: ['road'] }, + { key: 'buildings', types: ['building'] }, + { key: 'structures', types: ['structure'] }, + { key: 'interactables', types: ['dumpster', 'bench', 'trashPile'] }, + { key: 'decorations', types: ['lamp', 'hydrant', 'bin'] }, + { key: 'npcs', types: ['npc'] }, + { key: 'vehicles', types: ['vehicleRoute', 'passerbyRoute'] } + ]; + + let html = ''; + + for (const cat of categories) { + const items = this.objects.filter(o => cat.types.includes(o.type)); + const isCollapsed = this.collapsedCategories[cat.key] || false; + const icon = this.categoryIcons[cat.key] || ''; + const label = this.categoryLabels[cat.key] || cat.key; + + html += `
`; + html += `
`; + html += `${isCollapsed ? '\u25B6' : '\u25BC'}`; + html += `${icon}`; + html += `${label}`; + html += `(${items.length})`; + html += ``; + html += `
`; + + if (!isCollapsed) { + html += `
`; + for (const item of items) { + const isSelected = this.selectedObject && this.selectedObject.id === item.id; + const itemName = this._getObjectDisplayName(item); + const coords = `(${Math.round(item.x)}, ${Math.round(item.z)})`; + html += `
`; + html += `${this._getTypeIcon(item.type)}`; + html += `${itemName}`; + html += `${coords}`; + html += `
`; + } + html += `
`; + } + + html += `
`; + } + + this.listPanel.innerHTML = html; + + // Attach event listeners + const headers = this.listPanel.querySelectorAll('.editor-category-header'); + for (const header of headers) { + header.addEventListener('click', (e) => { + // Don't toggle on add button click + if (e.target.classList.contains('editor-add-btn')) return; + const catKey = header.getAttribute('data-category'); + this.collapsedCategories[catKey] = !this.collapsedCategories[catKey]; + this.updateObjectList(); + }); + } + + const addButtons = this.listPanel.querySelectorAll('.editor-add-btn'); + for (const btn of addButtons) { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const addType = btn.getAttribute('data-add-type'); + this.addObject(addType); + }); + } + + const itemElements = this.listPanel.querySelectorAll('.editor-item'); + for (const el of itemElements) { + el.addEventListener('click', () => { + const id = parseInt(el.getAttribute('data-id')); + const obj = this.objects.find(o => o.id === id); + if (obj) { + this.selectedObject = obj; + this.camera.x = obj.x; + this.camera.z = obj.z; + this.updateObjectList(); + this.updatePropsPanel(); + } + }); + } + } + + _getObjectDisplayName(obj) { + switch (obj.type) { + case 'road': return obj.name || obj.roadId || 'Дорога'; + case 'building': return `Здание ${obj.color || ''}`; + case 'structure': return this.structureLabels[obj.structureType] || obj.structureType; + case 'dumpster': return 'Мусорный бак'; + case 'bench': return 'Скамейка'; + case 'trashPile': return 'Куча мусора'; + case 'lamp': return 'Фонарь'; + case 'hydrant': return 'Гидрант'; + case 'bin': return 'Урна'; + case 'npc': return obj.name || 'NPC'; + case 'vehicleRoute': return `Маршрут ${obj.axis}:${obj.lane}`; + case 'passerbyRoute': return 'Маршрут прохожих'; + default: return obj.type; + } + } + + _getTypeIcon(type) { + switch (type) { + case 'road': return '\u2550'; + case 'building': return '\u2302'; + case 'structure': return '\u2736'; + case 'dumpster': return '\u2612'; + case 'bench': return '\u2587'; + case 'trashPile': return '\u2622'; + case 'lamp': return '\u2600'; + case 'hydrant': return '\u2666'; + case 'bin': return '\u25CF'; + case 'npc': return '\u263A'; + case 'vehicleRoute': return '\u2192'; + case 'passerbyRoute': return '\u2026'; + default: return '\u25A0'; + } + } + + // ========================================================================= + // PROPERTIES PANEL (right sidebar) + // ========================================================================= + + updatePropsPanel() { + if (!this.propsPanel) return; + + if (!this.selectedObject) { + this.propsPanel.innerHTML = '
Объект не выбран
'; + return; + } + + const obj = this.selectedObject; + let html = ''; + + html += `
${this._getObjectDisplayName(obj)}
`; + html += `
Тип: ${obj.type}
`; + + html += `
`; + html += ``; + html += this._propNumberField('x', 'X', obj.x); + html += this._propNumberField('z', 'Z', obj.z); + html += `
`; + + switch (obj.type) { + case 'road': + html += `
`; + html += ``; + html += this._propTextField('name', 'Название', obj.name || ''); + html += this._propTextField('roadId', 'ID', obj.roadId || ''); + html += this._propNumberField('width', 'Ширина', obj.width); + html += this._propNumberField('height', 'Толщина', obj.height); + html += this._propNumberField('rotation', 'Поворот', obj.rotation || 0, 0.01); + html += this._propNumberField('sidewalkWidth', 'Тротуар', obj.sidewalkWidth ?? 3, 0.5); + html += `
`; + break; + + case 'building': + html += `
`; + html += ``; + html += this._propNumberField('w', 'Ширина (W)', obj.w); + html += this._propNumberField('h', 'Высота (H)', obj.h); + html += this._propNumberField('d', 'Глубина (D)', obj.d); + html += this._propColorField('color', 'Цвет', obj.color || '#888888'); + html += `
`; + break; + + case 'structure': + html += `
`; + html += ``; + if (obj.radius !== undefined && obj.radius !== null) { + html += this._propNumberField('radius', 'Радиус', obj.radius); + } + if (obj.w !== undefined && obj.w !== null) { + html += this._propNumberField('w', 'Ширина (W)', obj.w); + } + if (obj.h !== undefined && obj.h !== null) { + html += this._propNumberField('h', 'Высота (H)', obj.h); + } + if (obj.d !== undefined && obj.d !== null) { + html += this._propNumberField('d', 'Глубина (D)', obj.d); + } + if (obj.rotation !== undefined && obj.rotation !== null) { + html += this._propNumberField('rotation', 'Поворот', obj.rotation, 0.01); + } + html += `
`; + break; + + case 'dumpster': + case 'bench': + html += `
`; + html += ``; + html += this._propNumberField('rot', 'Поворот', obj.rot || 0, 0.1); + html += `
`; + break; + + case 'npc': + html += `
`; + html += ``; + html += this._propTextField('name', 'Имя', obj.name || ''); + html += this._propTextField('npcType', 'Тип', obj.npcType || 'citizen'); + html += this._propColorField('color', 'Цвет', obj.color || '#3355aa'); + html += `
`; + + // Patrol points editor + html += `
`; + html += ``; + if (obj.patrol && obj.patrol.length > 0) { + for (let i = 0; i < obj.patrol.length; i++) { + html += `
`; + html += `${i + 1}.`; + html += ``; + html += ``; + html += ``; + html += `
`; + } + } else { + html += `
Нет маршрута
`; + } + html += ``; + html += `
`; + break; + + case 'vehicleRoute': + html += `
`; + html += ``; + html += this._propTextField('axis', 'Ось', obj.axis); + html += this._propNumberField('lane', 'Полоса', obj.lane); + html += this._propNumberField('start', 'Начало', obj.start); + html += this._propNumberField('end', 'Конец', obj.end); + html += this._propNumberField('dir', 'Направление', obj.dir, 1); + html += `
`; + break; + + case 'passerbyRoute': + html += `
`; + html += ``; + if (obj.waypoints && obj.waypoints.length > 0) { + for (let i = 0; i < obj.waypoints.length; i++) { + html += `
`; + html += `${i + 1}.`; + html += ``; + html += ``; + html += ``; + html += `
`; + } + } + html += ``; + html += `
`; + break; + } + + // Delete button + html += `
`; + html += ``; + html += `
`; + + this.propsPanel.innerHTML = html; + + // Attach event listeners + this._attachPropsListeners(); + } + + _propNumberField(propName, label, value, step = 1) { + return `
+ ${label}: + +
`; + } + + _propTextField(propName, label, value) { + return `
+ ${label}: + +
`; + } + + _propColorField(propName, label, value) { + return `
+ ${label}: + +
`; + } + + _attachPropsListeners() { + if (!this.propsPanel || !this.selectedObject) return; + + // Number and text inputs + const inputs = this.propsPanel.querySelectorAll('.editor-prop-input'); + for (const input of inputs) { + input.addEventListener('change', () => { + const prop = input.getAttribute('data-prop'); + if (input.type === 'number') { + this.selectedObject[prop] = parseFloat(input.value); + } else { + this.selectedObject[prop] = input.value; + } + this.updateObjectList(); + }); + } + + // Color inputs + const colorInputs = this.propsPanel.querySelectorAll('.editor-prop-color'); + for (const input of colorInputs) { + input.addEventListener('input', () => { + const prop = input.getAttribute('data-prop'); + this.selectedObject[prop] = input.value; + }); + } + + // Delete button + const deleteBtn = this.propsPanel.querySelector('.editor-delete-btn'); + if (deleteBtn) { + deleteBtn.addEventListener('click', () => { + if (this.selectedObject) { + this.deleteObject(this.selectedObject); + } + }); + } + + // Patrol point inputs (NPC) + const patrolInputs = this.propsPanel.querySelectorAll('.editor-patrol-input'); + for (const input of patrolInputs) { + input.addEventListener('change', () => { + const idx = parseInt(input.getAttribute('data-patrol-idx')); + const axis = input.getAttribute('data-patrol-axis'); + if (this.selectedObject.patrol && this.selectedObject.patrol[idx]) { + if (axis === 'x') { + this.selectedObject.patrol[idx][0] = parseFloat(input.value); + } else { + this.selectedObject.patrol[idx][1] = parseFloat(input.value); + } + } + }); + } + + // Remove patrol point buttons + const patrolRemoveBtns = this.propsPanel.querySelectorAll('.editor-patrol-remove'); + for (const btn of patrolRemoveBtns) { + btn.addEventListener('click', () => { + const idx = parseInt(btn.getAttribute('data-patrol-idx')); + if (this.selectedObject.patrol) { + this.selectedObject.patrol.splice(idx, 1); + if (this.selectedObject.patrol.length === 0) { + this.selectedObject.patrol = null; + } + this.updatePropsPanel(); + } + }); + } + + // Add patrol point button + const patrolAddBtn = this.propsPanel.querySelector('.editor-patrol-add'); + if (patrolAddBtn) { + patrolAddBtn.addEventListener('click', () => { + if (!this.selectedObject.patrol) { + this.selectedObject.patrol = []; + } + this.selectedObject.patrol.push([ + Math.round(this.selectedObject.x), + Math.round(this.selectedObject.z) + ]); + this.updatePropsPanel(); + }); + } + + // Waypoint inputs (passerby route) + const wpInputs = this.propsPanel.querySelectorAll('.editor-waypoint-input'); + for (const input of wpInputs) { + input.addEventListener('change', () => { + const idx = parseInt(input.getAttribute('data-wp-idx')); + const axis = input.getAttribute('data-wp-axis'); + if (this.selectedObject.waypoints && this.selectedObject.waypoints[idx]) { + if (axis === 'x') { + this.selectedObject.waypoints[idx][0] = parseFloat(input.value); + } else { + this.selectedObject.waypoints[idx][1] = parseFloat(input.value); + } + } + }); + } + + // Remove waypoint buttons + const wpRemoveBtns = this.propsPanel.querySelectorAll('.editor-waypoint-remove'); + for (const btn of wpRemoveBtns) { + btn.addEventListener('click', () => { + const idx = parseInt(btn.getAttribute('data-wp-idx')); + if (this.selectedObject.waypoints) { + this.selectedObject.waypoints.splice(idx, 1); + this.updatePropsPanel(); + } + }); + } + + // Add waypoint button + const wpAddBtn = this.propsPanel.querySelector('.editor-waypoint-add'); + if (wpAddBtn) { + wpAddBtn.addEventListener('click', () => { + if (!this.selectedObject.waypoints) { + this.selectedObject.waypoints = []; + } + this.selectedObject.waypoints.push([ + Math.round(this.selectedObject.x), + Math.round(this.selectedObject.z) + ]); + this.updatePropsPanel(); + }); + } + } + + // ========================================================================= + // STATUS BAR + // ========================================================================= + + _updateStatusBar() { + if (!this.statusBar) return; + const mx = this.mouseWorld.x.toFixed(1); + const mz = this.mouseWorld.z.toFixed(1); + const count = this.objects.length; + const zoom = this.camera.zoom.toFixed(1); + this.statusBar.textContent = `Координаты: X=${mx} Z=${mz} | Масштаб: ${zoom}x | Объектов: ${count}`; + } +} diff --git a/js/game/Achievements.js b/js/game/Achievements.js new file mode 100644 index 0000000..f19762b --- /dev/null +++ b/js/game/Achievements.js @@ -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 = ` + ${achievement.icon} +
+
Достижение разблокировано!
+
${achievement.title}
+
${achievement.desc}
+
+ `; + + // Анимация + 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(); + } +} diff --git a/js/game/Camera.js b/js/game/Camera.js new file mode 100644 index 0000000..d455ba3 --- /dev/null +++ b/js/game/Camera.js @@ -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); + } +} diff --git a/js/game/Dangers.js b/js/game/Dangers.js new file mode 100644 index 0000000..90b4d8a --- /dev/null +++ b/js/game/Dangers.js @@ -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; + } +} diff --git a/js/game/Dog.js b/js/game/Dog.js new file mode 100644 index 0000000..39c060b --- /dev/null +++ b/js/game/Dog.js @@ -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 + ); + } +} diff --git a/js/game/Equipment.js b/js/game/Equipment.js new file mode 100644 index 0000000..459aa0e --- /dev/null +++ b/js/game/Equipment.js @@ -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 }; + } +} diff --git a/js/game/Events.js b/js/game/Events.js new file mode 100644 index 0000000..41a99de --- /dev/null +++ b/js/game/Events.js @@ -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; + } +} diff --git a/js/game/Game.js b/js/game/Game.js new file mode 100644 index 0000000..42b3913 --- /dev/null +++ b/js/game/Game.js @@ -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(); + } +} diff --git a/js/game/Housing.js b/js/game/Housing.js new file mode 100644 index 0000000..2459079 --- /dev/null +++ b/js/game/Housing.js @@ -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; + } + } +} diff --git a/js/game/Interiors.js b/js/game/Interiors.js new file mode 100644 index 0000000..f92d63b --- /dev/null +++ b/js/game/Interiors.js @@ -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; + } +} diff --git a/js/game/Inventory.js b/js/game/Inventory.js new file mode 100644 index 0000000..9988ae8 --- /dev/null +++ b/js/game/Inventory.js @@ -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; + } +} diff --git a/js/game/JobSystem.js b/js/game/JobSystem.js new file mode 100644 index 0000000..ebf0a09 --- /dev/null +++ b/js/game/JobSystem.js @@ -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(); + } +} diff --git a/js/game/NPC.js b/js/game/NPC.js new file mode 100644 index 0000000..a2eeec8 --- /dev/null +++ b/js/game/NPC.js @@ -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'); + } + }); + } +} diff --git a/js/game/Particles.js b/js/game/Particles.js new file mode 100644 index 0000000..1d0bbca --- /dev/null +++ b/js/game/Particles.js @@ -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 = []; + } +} diff --git a/js/game/Player.js b/js/game/Player.js new file mode 100644 index 0000000..079a258 --- /dev/null +++ b/js/game/Player.js @@ -0,0 +1,1174 @@ +import * as THREE from 'three'; + +export class Player { + constructor(game) { + this.game = game; + + // Характеристики + this.stats = { + health: 100, + hunger: 100, + warmth: 100, + mood: 50, + money: 0, + hygiene: 100 + }; + + // Физика + this.position = new THREE.Vector3(0, 0, 15); + this.velocity = new THREE.Vector3(); + this.direction = new THREE.Vector3(); + this.speed = 5; + this.sprintMultiplier = 1.7; + this.height = 1.7; + this.radius = 0.4; + + // Mesh + this.mesh = null; + this.nearestInteractable = null; + + // Таймеры + this.statTimer = 0; + this.isSleeping = false; + this.sleepTimer = 0; + this.isWarming = false; + this._shelterSleep = false; + + // Begging + this.isBegging = false; + this.begTimer = 0; + this.begCooldown = 0; + this.begDuration = 3; + + // Busking + this.isBusking = false; + this.buskTimer = 0; + this.buskCooldown = 0; + this.buskDuration = 5; + + // Hygiene & Disease + this.isDiseased = false; + this.diseaseTimer = 0; + this.diseaseCheckTimer = 0; + this.washCooldown = 0; + + // Addiction + this.addictionLevel = 0; + this.withdrawalTimer = 0; + this.lastDrinkTime = 0; + + // Шаги + this.stepTimer = 0; + this.stepInterval = 0.45; + + // Предыдущее здоровье + this._prevHealth = 100; + + // Head bob + this.bobPhase = 0; + this.bobAmount = 0; + + // Stamina + this.stamina = 100; + this.maxStamina = 100; + this.isSprinting = false; + + // Сытость 100 таймер (для ачивки) + this._wellFedTimer = 0; + } + + spawn() { + this.createMesh(); + this.position.set(0, 0, 15); + this.mesh.position.copy(this.position); + this.game.camera.position.set(0, this.height, 15); + } + + createMesh() { + const group = new THREE.Group(); + + const bodyGeo = new THREE.CylinderGeometry(0.3, 0.35, 1.0, 8); + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x5c4033 }); + const body = new THREE.Mesh(bodyGeo, bodyMat); + body.position.y = 0.8; + 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.5; + head.castShadow = true; + group.add(head); + + const hatGeo = new THREE.CylinderGeometry(0.25, 0.28, 0.2, 8); + const hatMat = new THREE.MeshStandardMaterial({ color: 0x333355 }); + const hat = new THREE.Mesh(hatGeo, hatMat); + hat.position.y = 1.7; + group.add(hat); + + const armMat = new THREE.MeshStandardMaterial({ color: 0x5c4033 }); + [-0.4, 0.4].forEach(side => { + const armGeo = new THREE.CylinderGeometry(0.08, 0.08, 0.7, 6); + const arm = new THREE.Mesh(armGeo, armMat); + arm.position.set(side, 0.7, 0); + arm.rotation.z = side > 0 ? -0.15 : 0.15; + arm.castShadow = true; + group.add(arm); + }); + + const legMat = new THREE.MeshStandardMaterial({ color: 0x333344 }); + [-0.15, 0.15].forEach(side => { + const legGeo = new THREE.CylinderGeometry(0.1, 0.1, 0.6, 6); + const leg = new THREE.Mesh(legGeo, legMat); + leg.position.set(side, 0.3, 0); + leg.castShadow = true; + group.add(leg); + }); + + this.mesh = group; + group.traverse(child => { + if (child.isMesh) { + child.castShadow = true; + child.layers.set(1); + } + }); + this.game.scene.add(group); + } + + reset() { + if (this.mesh) { + this.game.scene.remove(this.mesh); + } + this.stats = { health: 100, hunger: 100, warmth: 100, mood: 50, money: 0, hygiene: 100 }; + this.stamina = 100; + this.isSleeping = false; + this.isWarming = false; + this.isBegging = false; + this.begTimer = 0; + this.begCooldown = 0; + this.isBusking = false; + this.buskTimer = 0; + this.buskCooldown = 0; + this.isDiseased = false; + this.diseaseTimer = 0; + this.diseaseCheckTimer = 0; + this.washCooldown = 0; + this.addictionLevel = 0; + this.withdrawalTimer = 0; + this.lastDrinkTime = 0; + this.nearestInteractable = null; + this._prevHealth = 100; + this._shelterSleep = false; + this._wellFedTimer = 0; + } + + update(dt) { + if (this.isSleeping) { + this.updateSleep(dt); + this.updateScreenEffects(); + return; + } + + this.updateMovement(dt); + this.updateStats(dt); + this.updateBegging(dt); + this.updateBusking(dt); + this.checkInteractables(); + this.updateScreenEffects(); + this.checkDeath(); + this.trackLocations(); + + if (this.begCooldown > 0) this.begCooldown -= dt; + if (this.buskCooldown > 0) this.buskCooldown -= dt; + } + + updateMovement(dt) { + const keys = this.game.keys; + const camera = this.game.camera; + + if (this.isBegging || this.isBusking) return; + + const forward = new THREE.Vector3(); + camera.getWorldDirection(forward); + forward.y = 0; + forward.normalize(); + + const right = new THREE.Vector3(); + right.crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); + + this.direction.set(0, 0, 0); + if (keys['KeyW']) this.direction.add(forward); + if (keys['KeyS']) this.direction.sub(forward); + if (keys['KeyD']) this.direction.add(right); + if (keys['KeyA']) this.direction.sub(right); + + if (this.direction.length() > 0) { + this.direction.normalize(); + + const wantSprint = keys['ShiftLeft'] || keys['ShiftRight']; + const sprint = wantSprint && this.stamina > 0; + this.isSprinting = sprint; + + if (sprint) { + this.stamina = Math.max(0, this.stamina - 20 * dt); + } else { + this.stamina = Math.min(this.maxStamina, this.stamina + 10 * dt); + } + + const moveSpeed = this.speed * (sprint ? this.sprintMultiplier : 1); + + let speedMod = 1; + if (this.stats.hunger < 20) speedMod *= 0.7; + if (this.stats.warmth < 15) speedMod *= 0.8; + if (this.stats.health < 25) speedMod *= 0.6; + if (this.isDiseased) speedMod *= 0.8; + + const newPos = this.position.clone().add( + this.direction.clone().multiplyScalar(moveSpeed * speedMod * dt) + ); + + if (!this.checkCollision(newPos)) { + this.position.copy(newPos); + } + + if (this.game.interiors && this.game.interiors.isInside) { + this.position.x = THREE.MathUtils.clamp(this.position.x, 490, 510); + this.position.z = THREE.MathUtils.clamp(this.position.z, -10, 120); + } else { + this.position.x = THREE.MathUtils.clamp(this.position.x, -95, 95); + this.position.z = THREE.MathUtils.clamp(this.position.z, -95, 95); + } + + this.stepTimer += dt * (sprint ? 1.5 : 1); + if (this.stepTimer >= this.stepInterval) { + this.stepTimer = 0; + this.game.sound.playStep(); + } + + this.bobPhase += dt * (sprint ? 14 : 10); + this.bobAmount = THREE.MathUtils.lerp(this.bobAmount, 0.04, dt * 5); + } else { + this.bobAmount = THREE.MathUtils.lerp(this.bobAmount, 0, dt * 8); + this.isSprinting = false; + this.stamina = Math.min(this.maxStamina, this.stamina + 15 * dt); + } + + this.mesh.position.copy(this.position); + const bobY = Math.sin(this.bobPhase) * this.bobAmount; + + // Withdrawal tremor + let tremorX = 0, tremorY = 0; + if (this.addictionLevel > 40 && this.withdrawalTimer > 0) { + const intensity = Math.min(0.02, this.addictionLevel * 0.0003); + tremorX = (Math.random() - 0.5) * intensity; + tremorY = (Math.random() - 0.5) * intensity; + } + + this.game.camera.position.set( + this.position.x + tremorX, + this.position.y + this.height + bobY + tremorY, + this.position.z + ); + + const camDir = new THREE.Vector3(); + camera.getWorldDirection(camDir); + this.mesh.rotation.y = Math.atan2(camDir.x, camDir.z); + } + + checkCollision(newPos) { + const playerBox = new THREE.Box3( + new THREE.Vector3(newPos.x - this.radius, 0, newPos.z - this.radius), + new THREE.Vector3(newPos.x + this.radius, this.height, newPos.z + this.radius) + ); + + // Статические коллайдеры (здания, мебель и т.д.) + for (const collider of this.game.world.colliders) { + if (playerBox.intersectsBox(collider)) return true; + } + + // Динамические коллайдеры движущихся машин + for (const collider of this.game.world.vehicleColliders) { + if (!collider.isEmpty() && playerBox.intersectsBox(collider)) return true; + } + + return false; + } + + updateStats(dt) { + this.statTimer += dt; + if (this.statTimer < 1) return; + this.statTimer = 0; + + this._prevHealth = this.stats.health; + + // Голод + const survMod = this.game.skills.getSurvivalModifier(); + const hungerDrain = this.game.seasons.getHungerDrain(); + const diseaseMult = this.isDiseased ? 1.5 : 1; + this.stats.hunger = Math.max(0, this.stats.hunger - 0.15 * survMod * hungerDrain * diseaseMult); + + // Тепло (с бонусом экипировки) + const warmthDrain = this.game.seasons.getWarmthDrain(); + const eqWarmth = this.game.equipment.getWarmthBonus(); + const warmthReduction = Math.min(0.8, eqWarmth * 0.01); // Макс 80% снижение потери + + if (this.game.isNight()) { + const drain = this.isWarming ? 0 : 0.3 * warmthDrain * (1 - warmthReduction); + this.stats.warmth = Math.max(0, this.stats.warmth - drain); + + // В своём укрытии с крышей — меньше потеря + if (this.game.housing.isPlayerInShelter() && this.game.housing.hasRoof()) { + this.stats.warmth = Math.min(100, this.stats.warmth + 0.1); + } + } else { + const warmthRegen = this.game.seasons.current === 'summer' ? 0.1 : 0.05; + this.stats.warmth = Math.min(100, this.stats.warmth + warmthRegen); + } + + if (this.isWarming) { + this.stats.warmth = Math.min(100, this.stats.warmth + 0.5); + } + + // Настроение от экипировки + const eqMood = this.game.equipment.getMoodBonus(); + if (eqMood > 0) { + this.stats.mood = Math.min(100, this.stats.mood + eqMood * 0.02); + } + + // Здоровье + if (this.stats.hunger <= 0) { + this.stats.health = Math.max(0, this.stats.health - 0.5); + } + if (this.stats.warmth <= 10) { + this.stats.health = Math.max(0, this.stats.health - 0.3); + } + if (this.stats.hunger > 50 && this.stats.warmth > 50) { + this.stats.health = Math.min(100, this.stats.health + 0.1); + } + + // Гигиена + let hygieneDrain = 0.03; + if (this.game.weather.current === 'rain') hygieneDrain += 0.02; + this.stats.hygiene = Math.max(0, this.stats.hygiene - hygieneDrain); + + // Болезнь + if (this.isDiseased) { + this.stats.health = Math.max(0, this.stats.health - 0.1); + this.diseaseTimer--; + if (this.diseaseTimer <= 0) { + this.isDiseased = false; + this.game.notify('Вы выздоровели!', 'good'); + } + } else if (this.stats.hygiene < 20) { + this.diseaseCheckTimer++; + if (this.diseaseCheckTimer >= 60) { + this.diseaseCheckTimer = 0; + if (Math.random() < 0.3) { + this.isDiseased = true; + this.diseaseTimer = 180; + this.game.notify('Вы заболели! Нужна аптечка или больница.', 'bad'); + } + } + } + + if (this.washCooldown > 0) this.washCooldown--; + + // Зависимость + if (this.addictionLevel > 0) { + this.addictionLevel = Math.max(0, this.addictionLevel - 0.008); + } + if (this.addictionLevel > 40) { + const currentTime = this.game.gameTime + (this.game.gameDay - 1) * 24 * 60; + const timeSinceDrink = currentTime - this.lastDrinkTime; + if (timeSinceDrink > 30) { + // Ломка + this.withdrawalTimer++; + if (this.withdrawalTimer % 60 === 0) { + this.stats.mood = Math.max(0, this.stats.mood - 5); + this.game.notify('Хочется выпить...', 'bad'); + } + } + } + // Квест: снизить зависимость до 0 + if (this.addictionLevel <= 0 && this._hadAddiction) { + this.game.questSystem.onEvent('sobriety'); + this._hadAddiction = false; + } + if (this.addictionLevel >= 50) this._hadAddiction = true; + + // Настроение + if (this.stats.hunger < 20) this.stats.mood = Math.max(0, this.stats.mood - 0.2); + if (this.stats.warmth < 20) this.stats.mood = Math.max(0, this.stats.mood - 0.15); + if (this.stats.health < 30) this.stats.mood = Math.max(0, this.stats.mood - 0.1); + if (this.stats.hygiene < 30) this.stats.mood = Math.max(0, this.stats.mood - 0.1); + + // Звук урона + if (this.stats.health < this._prevHealth - 1) { + this.game.sound.playHurt(); + } + + // Ачивка: сытость 100 + if (this.stats.hunger >= 99) { + this._wellFedTimer++; + if (this._wellFedTimer >= 300) { + this.game.achievements.check('well_fed'); + } + } else { + this._wellFedTimer = 0; + } + } + + updateScreenEffects() { + const dmg = document.getElementById('effect-damage'); + const cold = document.getElementById('effect-cold'); + const hunger = document.getElementById('effect-hunger'); + const sleep = document.getElementById('effect-sleep'); + + if (this.stats.health < this._prevHealth - 1) { + dmg.classList.add('active'); + setTimeout(() => dmg.classList.remove('active'), 400); + } + + if (this.stats.warmth < 25) { + cold.classList.add('active'); + } else { + cold.classList.remove('active'); + } + + if (this.stats.hunger < 15) { + hunger.classList.add('active'); + } else { + hunger.classList.remove('active'); + } + + if (this.isSleeping) { + sleep.classList.add('active'); + } else { + sleep.classList.remove('active'); + } + + const disease = document.getElementById('effect-disease'); + if (disease) { + if (this.isDiseased) { + disease.classList.add('active'); + } else { + disease.classList.remove('active'); + } + } + } + + checkInteractables() { + let nearest = null; + let nearestDist = Infinity; + + for (const obj of this.game.world.interactables) { + const dist = this.position.distanceTo(obj.position); + if (dist < obj.radius && dist < nearestDist) { + nearest = obj; + nearestDist = dist; + } + } + + // NPC + for (const npc of this.game.npcManager.npcs) { + const dist = this.position.distanceTo(npc.position); + if (dist < 3 && dist < nearestDist) { + nearest = { + type: 'npc', + npc: npc, + position: npc.position, + label: `Поговорить: ${npc.name}` + }; + nearestDist = dist; + } + } + + // Костёр + this.isWarming = false; + for (const obj of this.game.world.interactables) { + if (obj.type === 'campfire') { + const dist = this.position.distanceTo(obj.position); + if (dist < obj.radius) { + this.isWarming = true; + } + } + } + + // Печка в укрытии + if (this.game.housing.built && this.game.housing.upgrades.stove.built) { + if (this.position.distanceTo(this.game.housing.position) < 5) { + this.isWarming = true; + } + } + + this.nearestInteractable = nearest; + this.game.ui.updateInteractionHint(nearest); + } + + interact() { + if (!this.nearestInteractable) return; + const obj = this.nearestInteractable; + + switch (obj.type) { + case 'dumpster': + this.searchDumpster(obj); + break; + case 'bench': + this.restOnBench(); + break; + case 'shop': + this.game.interiors.enterBuilding('shop'); + break; + case 'shelter': + this.sleep(); + break; + case 'campfire': + this.game.notify('Вы греетесь у костра...'); + break; + case 'npc': + this.game.npcManager.talkTo(obj.npc); + break; + case 'fountain': + this.drinkFountain(); + break; + case 'trashpile': + this.searchTrashPile(obj); + break; + case 'phone': + this.usePhone(); + break; + case 'church': + this.game.interiors.enterBuilding('church'); + break; + case 'jobboard': + this.game.jobSystem.showJobBoard(); + break; + case 'player_shelter': + this.game.housing.showMenu(); + break; + case 'camp_spot': + this.game.housing.showMenu(); + break; + case 'hospital': + this.game.interiors.enterBuilding('hospital'); + break; + case 'market': + this.enterMarket(); + break; + // Интерьерные объекты + case 'shop_counter': + this.enterShop(); + break; + case 'hospital_desk': + this.enterHospital(); + break; + case 'church_altar': + this.enterChurch(); + break; + case 'exit_door': + this.game.interiors.exitBuilding(); + break; + } + } + + // === Попрошайничество === + startBegging() { + if (this.isBegging || this.begCooldown > 0) return; + + let nearNPC = false; + for (const npc of this.game.npcManager.npcs) { + if (this.position.distanceTo(npc.position) < 8) { + nearNPC = true; + break; + } + } + + if (!nearNPC) { + this.game.notify('Рядом никого нет...'); + return; + } + + this.isBegging = true; + this.begTimer = 0; + this.game.ui.showBegProgress(true); + } + + stopBegging() { + if (!this.isBegging) return; + this.isBegging = false; + this.game.ui.showBegProgress(false); + } + + updateBegging(dt) { + if (!this.isBegging) return; + + this.begTimer += dt; + this.game.ui.updateBegProgress(this.begTimer / this.begDuration); + + if (this.begTimer >= this.begDuration) { + this.isBegging = false; + this.begCooldown = 15; + this.game.ui.showBegProgress(false); + + const begBonus = this.game.skills.getBegBonus() * this.game.reputation.getBegModifier(); + this.game.skills.addXP('begging', 2); + this.game.reputation.change(-1); + const roll = Math.random(); + if (roll < 0.35) { + const amount = Math.floor((Math.random() * 20 + 5) * begBonus); + this.stats.money += amount; + this.game.sound.playCoin(); + this.game.notify(`Вам дали ${amount} ₽!`, 'good'); + this.game.questSystem.onEvent('find_money', amount); + this.game.questSystem.onEvent('beg'); + } else if (roll < 0.55) { + this.game.inventory.addItem('bread', 1); + this.game.sound.playPickup(); + this.game.notify('Вам дали хлеб!', 'good'); + this.game.questSystem.onEvent('find_food'); + } else if (roll < 0.7) { + this.game.notify('Прохожие игнорируют вас...'); + this.stats.mood = Math.max(0, this.stats.mood - 3); + } else if (roll < 0.85) { + this.game.notify('"Иди работай!" — грубо ответили вам.'); + this.stats.mood = Math.max(0, this.stats.mood - 5); + } else { + const amount = Math.floor(Math.random() * 50) + 20; + this.stats.money += amount; + this.game.sound.playCoin(); + this.game.notify(`Щедрый прохожий дал ${amount} ₽!`, 'good'); + this.game.questSystem.onEvent('find_money', amount); + this.stats.mood = Math.min(100, this.stats.mood + 5); + } + } + } + + // === Бускинг === + startBusking() { + if (this.isBusking || this.buskCooldown > 0 || this.isBegging) return; + + if (this.game.inventory.getCount('harmonica') <= 0) { + this.game.notify('У вас нет гармошки!'); + return; + } + + // Ищем прохожих и NPC в радиусе 12 + let nearCount = 0; + for (const npc of this.game.npcManager.npcs) { + if (this.position.distanceTo(npc.position) < 12) nearCount++; + } + if (this.game.npcManager.passersby) { + for (const pb of this.game.npcManager.passersby) { + if (pb.mesh) { + const dist = this.position.distanceTo(pb.mesh.position); + if (dist < 12) nearCount++; + } + } + } + + if (nearCount === 0) { + this.game.notify('Рядом некому играть...'); + return; + } + + this.isBusking = true; + this.buskTimer = 0; + this._buskNearCount = nearCount; + this.game.ui.showBuskProgress(true); + } + + stopBusking() { + if (!this.isBusking) return; + this.isBusking = false; + this.game.ui.showBuskProgress(false); + } + + updateBusking(dt) { + if (!this.isBusking) return; + + this.buskTimer += dt; + this.game.ui.updateBuskProgress(this.buskTimer / this.buskDuration); + + if (this.buskTimer >= this.buskDuration) { + this.isBusking = false; + this.buskCooldown = 20; + this.game.ui.showBuskProgress(false); + + const begBonus = this.game.skills.getBegBonus(); + const crowdMult = Math.min(2, 1 + (this._buskNearCount - 1) * 0.3); + this.game.skills.addXP('begging', 1); + + const roll = Math.random(); + if (roll < 0.50) { + // Дали денег + const amount = Math.floor((Math.random() * 20 + 10) * begBonus * crowdMult); + this.stats.money += amount; + this.game.sound.playCoin(); + this.game.notify(`Прохожие оценили игру! +${amount} ₽`, 'good'); + this.game.questSystem.onEvent('find_money', amount); + this.game.questSystem.onEvent('busking'); + } else if (roll < 0.75) { + // Хорошие деньги + const amount = Math.floor((Math.random() * 30 + 20) * begBonus * crowdMult); + this.stats.money += amount; + this.game.sound.playCoin(); + this.game.notify(`Отличное выступление! +${amount} ₽`, 'good'); + this.game.questSystem.onEvent('find_money', amount); + this.game.questSystem.onEvent('busking'); + this.stats.mood = Math.min(100, this.stats.mood + 5); + } else if (roll < 0.90) { + // Никто не обратил внимания + this.game.notify('Прохожие не обратили внимания...'); + } else { + // Бонус к настроению + this.stats.mood = Math.min(100, this.stats.mood + 10); + const amount = Math.floor((Math.random() * 15 + 5) * begBonus * crowdMult); + this.stats.money += amount; + this.game.sound.playCoin(); + this.game.notify(`Музыка подняла настроение! +${amount} ₽, +10 Настроение`, 'good'); + this.game.questSystem.onEvent('find_money', amount); + this.game.questSystem.onEvent('busking'); + } + } + } + + // === Взаимодействия === + searchDumpster(dumpster) { + if (dumpster.searchCooldown > 0) { + this.game.notify('Тут уже нечего искать. Подождите...'); + return; + } + + const scavBonus = this.game.skills.getScavengeBonus(); + dumpster.searchCooldown = Math.floor(120 / scavBonus); + this.game.sound.playPickup(); + this.game.skills.addXP('scavenging', 1); + this.stats.hygiene = Math.max(0, this.stats.hygiene - 5); + + const roll = Math.random(); + if (roll < 0.18) { + const count = Math.random() < 0.3 + scavBonus * 0.05 ? 2 : 1; + this.game.inventory.addItem('bottle', count); + this.game.notify(`Вы нашли бутылк${count > 1 ? 'и' : 'у'}!`, 'good'); + this.game.questSystem.onEvent('collect_bottle'); + if (count > 1) this.game.questSystem.onEvent('collect_bottle'); + } else if (roll < 0.29) { + this.game.inventory.addItem('bread', 1); + this.game.notify('Вы нашли кусок хлеба!', 'good'); + this.game.questSystem.onEvent('find_food'); + } else if (roll < 0.38) { + this.game.inventory.addItem('can', 1); + this.game.notify('Вы нашли консервную банку!', 'good'); + this.game.questSystem.onEvent('find_food'); + } else if (roll < 0.47) { + this.game.inventory.addItem('clothing', 1); + this.game.notify('Вы нашли старую одежду!', 'good'); + } else if (roll < 0.54) { + this.game.inventory.addItem('newspaper', 1); + this.game.notify('Вы нашли газету!'); + } else if (roll < 0.62) { + const coins = Math.floor(Math.random() * 15) + 1; + this.stats.money += coins; + this.game.sound.playCoin(); + this.game.notify(`Вы нашли ${coins} ₽!`, 'good'); + this.game.questSystem.onEvent('find_money', coins); + } else if (roll < 0.70) { + this.game.inventory.addItem('scrap', 1); + this.game.notify('Вы нашли полезный хлам!', 'good'); + } else if (roll < 0.77) { + this.game.inventory.addItem('rope', 1); + this.game.notify('Вы нашли верёвку!', 'good'); + } else if (roll < 0.82) { + // Шанс найти экипировку + const eqItems = ['eq_old_hat', 'eq_old_jacket', 'eq_old_boots', 'eq_old_gloves']; + const eqItem = eqItems[Math.floor(Math.random() * eqItems.length)]; + this.game.inventory.addItem(eqItem, 1); + this.game.notify(`Вы нашли ${this.game.inventory.itemData[eqItem].name}!`, 'good'); + } else if (roll < 0.87) { + this.game.inventory.addItem('apple', 1); + this.game.notify('Вы нашли яблоко!', 'good'); + } else { + this.game.notify('Ничего полезного...'); + this.stats.mood = Math.max(0, this.stats.mood - 3); + } + } + + restOnBench() { + this.stats.mood = Math.min(100, this.stats.mood + 5); + this.stats.health = Math.min(100, this.stats.health + 2); + this.game.notify('Вы отдохнули на скамейке. +5 Настроение, +2 Здоровье.', 'good'); + } + + drinkFountain() { + this.game.ui.showDialog('Фонтанчик', 'Что хотите сделать?', [ + 'Попить воды', + 'Помыться', + 'Уйти' + ], (index) => { + if (index === 0) { + this.stats.hunger = Math.min(100, this.stats.hunger + 5); + this.stats.mood = Math.min(100, this.stats.mood + 2); + this.game.notify('Вы попили воды. +5 Сытость, +2 Настроение', 'good'); + } else if (index === 1) { + if (this.washCooldown > 0) { + this.game.notify('Вы недавно мылись. Подождите...'); + } else { + this.stats.hygiene = Math.min(100, this.stats.hygiene + 40); + this.washCooldown = 300; + this.game.notify('+40 Гигиена', 'good'); + this.game.questSystem.onEvent('wash'); + } + } + this.game.ui.hideDialog(); + }); + } + + searchTrashPile(obj) { + if (obj.searchCooldown > 0) { + this.game.notify('Здесь уже нечего искать...'); + return; + } + obj.searchCooldown = 90; + this.game.skills.addXP('scavenging', 1); + this.stats.hygiene = Math.max(0, this.stats.hygiene - 3); + + const roll = Math.random(); + if (roll < 0.25) { + this.game.inventory.addItem('bottle', 1); + this.game.sound.playPickup(); + this.game.notify('Нашли бутылку в мусоре!', 'good'); + this.game.questSystem.onEvent('collect_bottle'); + } else if (roll < 0.38) { + const coins = Math.floor(Math.random() * 5) + 1; + this.stats.money += coins; + this.game.sound.playCoin(); + this.game.notify(`Нашли ${coins} ₽!`, 'good'); + } else if (roll < 0.50) { + this.game.inventory.addItem('scrap', 1); + this.game.sound.playPickup(); + this.game.notify('Нашли полезный хлам!', 'good'); + } else if (roll < 0.60) { + this.game.inventory.addItem('candle', 1); + this.game.sound.playPickup(); + this.game.notify('Нашли свечу!', 'good'); + } else if (roll < 0.68) { + this.game.inventory.addItem('rope', 1); + this.game.sound.playPickup(); + this.game.notify('Нашли верёвку!', 'good'); + } else { + this.game.notify('Мусор как мусор...'); + } + } + + usePhone() { + this.game.ui.showDialog('Таксофон', 'Позвонить на горячую линию помощи?', [ + 'Позвонить (бесплатно)', + 'Не нужно' + ], (index) => { + if (index === 0) { + this.stats.mood = Math.min(100, this.stats.mood + 10); + this.game.notify('Вам дали полезные советы. +10 Настроение', 'good'); + } + this.game.ui.hideDialog(); + }); + } + + enterShop() { + const items = [ + { name: 'Хлеб', key: 'bread', price: 30, desc: '+20 Сытость' }, + { name: 'Консервы', key: 'can', price: 50, desc: '+35 Сытость' }, + { name: 'Чай', key: 'tea', price: 20, desc: '+20 Тепло, +5 Настроение' }, + { name: 'Бинт', key: 'bandage', price: 40, desc: '+25 Здоровье' }, + { name: 'Свеча', key: 'candle', price: 15, desc: 'Для крафта' }, + { name: 'Верёвка', key: 'rope', price: 25, desc: 'Для крафта' }, + { name: 'Яблоко', key: 'apple', price: 15, desc: '+10 Сытость, +5 Здоровье' }, + { name: 'Мыло', key: 'soap', price: 20, desc: '+30 Гигиена' }, + { name: 'Перчатки', key: 'eq_gloves', price: 60, desc: 'Экипировка: +8 Тепло' }, + { name: 'Ботинки', key: 'eq_boots', price: 80, desc: 'Экипировка: +10 Тепло' }, + ]; + + const discount = this.game.skills.getTradeDiscount() * this.game.reputation.getShopModifier(); + const choices = items.map(item => { + const finalPrice = Math.floor(item.price * discount); + return `${item.name} — ${finalPrice} ₽ (${item.desc})`; + }); + choices.push('Сдать бутылки (5₽/шт)'); + choices.push('Уйти'); + + this.game.sound.playDialogOpen(); + + this.game.ui.showDialog('Продавец', 'Чего желаете?', choices, (index) => { + if (index < items.length) { + const item = items[index]; + const finalPrice = Math.floor(item.price * discount); + if (this.stats.money >= finalPrice) { + this.stats.money -= finalPrice; + this.game.skills.addXP('trading', 1); + this.game.inventory.addItem(item.key, 1); + this.game.sound.playCoin(); + this.game.notify(`Куплено: ${item.name}`, 'good'); + } else { + this.game.notify('Не хватает денег!', 'bad'); + } + } else if (index === items.length) { + const bottles = this.game.inventory.getCount('bottle'); + if (bottles > 0) { + const earnings = bottles * 5; + this.stats.money += earnings; + this.game.inventory.removeItem('bottle', bottles); + this.game.sound.playCoin(); + this.game.notify(`Сдали ${bottles} бутылок за ${earnings} ₽!`, 'good'); + this.game.questSystem.onEvent('sell_bottles', bottles); + this.game.totalBottlesSold += bottles; + if (this.game.totalBottlesSold >= 20) { + this.game.achievements.check('bottle_king'); + } + } else { + this.game.notify('У вас нет бутылок.'); + } + } + this.game.ui.hideDialog(); + }); + } + + enterHospital() { + const choices = ['Осмотр (бесплатно)', 'Лечение (50₽)', 'Уйти']; + + this.game.sound.playDialogOpen(); + this.game.ui.showDialog('Больница', 'Добро пожаловать. Чем помочь?', choices, (index) => { + if (index === 0) { + this.stats.health = Math.min(100, this.stats.health + 15); + if (this.isDiseased) { + this.isDiseased = false; + this.diseaseTimer = 0; + this.game.notify('+15 Здоровье. Болезнь вылечена!', 'good'); + } else { + this.game.notify('+15 Здоровье. Вас осмотрели.', 'good'); + } + } else if (index === 1) { + if (this.stats.money >= 50) { + this.stats.money -= 50; + this.stats.health = 100; + this.isDiseased = false; + this.diseaseTimer = 0; + this.game.sound.playCoin(); + this.game.notify('Полное здоровье восстановлено! Болезнь вылечена!', 'good'); + } else { + this.game.notify('Не хватает денег!', 'bad'); + } + } + this.game.ui.hideDialog(); + this.game.questSystem.onEvent('visit_hospital'); + }); + } + + enterMarket() { + const items = [ + { name: 'Рыба', key: 'fish', price: 25, desc: '+25 Сытость' }, + { name: 'Пальто', key: 'eq_coat', price: 120, desc: 'Экипировка: +18 Тепло' }, + { name: 'Тёплые сапоги', key: 'eq_warm_boots', price: 100, desc: 'Экипировка: +18 Тепло' }, + { name: 'Жилетка', key: 'eq_vest', price: 150, desc: 'Экипировка: +10 Защита' }, + { name: 'Каска', key: 'eq_helmet', price: 90, desc: 'Экипировка: +8 Защита' }, + { name: 'Водка', key: 'vodka', price: 40, desc: '+30 Тепло, -10 Здоровье' }, + ]; + + const discount = this.game.skills.getTradeDiscount() * this.game.reputation.getShopModifier(); + const choices = items.map(item => { + const finalPrice = Math.floor(item.price * discount); + return `${item.name} — ${finalPrice} ₽ (${item.desc})`; + }); + choices.push('Уйти'); + + this.game.sound.playDialogOpen(); + this.game.ui.showDialog('Рынок', 'Здесь можно найти полезные вещи.', choices, (index) => { + if (index < items.length) { + const item = items[index]; + const finalPrice = Math.floor(item.price * discount); + if (this.stats.money >= finalPrice) { + this.stats.money -= finalPrice; + this.game.skills.addXP('trading', 2); + this.game.inventory.addItem(item.key, 1); + this.game.sound.playCoin(); + this.game.notify(`Куплено: ${item.name}`, 'good'); + } else { + this.game.notify('Не хватает денег!', 'bad'); + } + } + this.game.ui.hideDialog(); + }); + } + + sleep() { + if (this.stats.hunger < 10) { + this.game.notify('Слишком голодно, не уснуть...', 'bad'); + return; + } + this.isSleeping = true; + this.sleepTimer = 0; + this._shelterSleep = false; + this.game.notify('Вы легли спать...'); + } + + updateSleep(dt) { + this.sleepTimer += dt; + if (this.sleepTimer >= 5) { + this.isSleeping = false; + + let bonus; + if (this._shelterSleep) { + bonus = this.game.housing.getSleepBonus(); + } else { + bonus = { health: 15, mood: 10, warmth: 20 }; + } + + this.stats.health = Math.min(100, this.stats.health + bonus.health); + this.stats.mood = Math.min(100, this.stats.mood + bonus.mood); + this.stats.warmth = Math.min(100, this.stats.warmth + bonus.warmth); + this.stats.hunger = Math.max(0, this.stats.hunger - 15); + this.game.gameTime += 360; + this._shelterSleep = false; + + const bonusText = this._shelterSleep ? ' (улучшенный отдых)' : ''; + this.game.notify(`Вы проснулись отдохнувшим.${bonusText}`, 'good'); + this.game.questSystem.onEvent('sleep'); + } + } + + checkDeath() { + if (this.stats.health <= 0) { + let reason = 'Вы не выдержали тяжёлой жизни на улице.'; + if (this.stats.hunger <= 0) reason = 'Вы умерли от голода.'; + if (this.stats.warmth <= 0) reason = 'Вы замёрзли насмерть.'; + this.game.gameOver(reason); + } + } + + useItem(itemKey) { + const effects = { + bread: () => { this.stats.hunger = Math.min(100, this.stats.hunger + 20); this.game.sound.playEat(); this.game.notify('+20 Сытость', 'good'); }, + can: () => { this.stats.hunger = Math.min(100, this.stats.hunger + 35); this.game.sound.playEat(); this.game.notify('+35 Сытость', 'good'); }, + tea: () => { this.stats.warmth = Math.min(100, this.stats.warmth + 20); this.stats.mood += 5; this.game.sound.playEat(); this.game.notify('+20 Тепло, +5 Настроение', 'good'); }, + bandage: () => { this.stats.health = Math.min(100, this.stats.health + 25); this.game.notify('+25 Здоровье', 'good'); }, + clothing: () => { this.stats.warmth = Math.min(100, this.stats.warmth + 30); this.game.notify('+30 Тепло', 'good'); }, + newspaper: () => { this.stats.mood = Math.min(100, this.stats.mood + 5); this.game.notify('Вы почитали газету. +5 Настроение', 'good'); }, + medkit: () => { this.stats.health = Math.min(100, this.stats.health + 50); if (this.isDiseased) { this.isDiseased = false; this.diseaseTimer = 0; this.game.notify('+50 Здоровье. Болезнь вылечена!', 'good'); } else { this.game.notify('+50 Здоровье', 'good'); } }, + stew: () => { this.stats.hunger = Math.min(100, this.stats.hunger + 50); this.stats.warmth = Math.min(100, this.stats.warmth + 15); this.game.sound.playEat(); this.game.notify('+50 Сытость, +15 Тепло', 'good'); }, + blanket: () => { this.stats.warmth = Math.min(100, this.stats.warmth + 50); this.stats.mood = Math.min(100, this.stats.mood + 10); this.game.notify('+50 Тепло, +10 Настроение', 'good'); }, + soap: () => { this.stats.hygiene = Math.min(100, this.stats.hygiene + 30); this.game.notify('+30 Гигиена', 'good'); }, + harmonica: () => { + // Если рядом есть люди — начать бускинг, иначе просто играть для себя + let hasNearby = false; + for (const npc of this.game.npcManager.npcs) { + if (this.position.distanceTo(npc.position) < 12) { hasNearby = true; break; } + } + if (!hasNearby && this.game.npcManager.passersby) { + for (const pb of this.game.npcManager.passersby) { + if (pb.mesh && this.position.distanceTo(pb.mesh.position) < 12) { hasNearby = true; break; } + } + } + if (hasNearby && this.buskCooldown <= 0 && !this.isBusking) { + this.startBusking(); + } else { + this.stats.mood = Math.min(100, this.stats.mood + 20); + this.game.notify('Вы сыграли мелодию. +20 Настроение', 'good'); + } + }, + fish: () => { this.stats.hunger = Math.min(100, this.stats.hunger + 25); this.game.sound.playEat(); this.game.notify('+25 Сытость', 'good'); }, + apple: () => { this.stats.hunger = Math.min(100, this.stats.hunger + 10); this.stats.health = Math.min(100, this.stats.health + 5); this.game.sound.playEat(); this.game.notify('+10 Сытость, +5 Здоровье', 'good'); }, + vodka: () => { + this.stats.warmth = Math.min(100, this.stats.warmth + (this.addictionLevel > 60 ? 40 : 30)); + this.stats.health = Math.max(0, this.stats.health - 10); + const moodGain = this.addictionLevel > 80 ? 0 : 15; + if (moodGain > 0) this.stats.mood = Math.min(100, this.stats.mood + moodGain); + this.addictionLevel = Math.min(100, this.addictionLevel + 20); + this.lastDrinkTime = this.game.gameTime + (this.game.gameDay - 1) * 24 * 60; + this.withdrawalTimer = 0; + const msg = this.addictionLevel > 80 + ? '+40 Тепло, -10 Здоровье (привычка...)' + : this.addictionLevel > 60 + ? '+40 Тепло, -10 Здоровье, +15 Настроение (толерантность)' + : '+30 Тепло, -10 Здоровье, +15 Настроение'; + this.game.notify(msg, 'good'); + }, + vitamins: () => { this.stats.health = Math.min(100, this.stats.health + 15); this.stats.mood = Math.min(100, this.stats.mood + 10); this.game.notify('+15 Здоровье, +10 Настроение', 'good'); }, + torch: () => { + this.stats.warmth = Math.min(100, this.stats.warmth + 20); + if (this.game.isNight()) { + this.stats.mood = Math.min(100, this.stats.mood + 10); + this.game.notify('+20 Тепло, +10 Настроение', 'good'); + } else { + this.game.notify('+20 Тепло', 'good'); + } + }, + }; + + if (effects[itemKey]) { + effects[itemKey](); + if (itemKey !== 'harmonica') { + this.game.inventory.removeItem(itemKey, 1); + } + return true; + } + return false; + } + + enterChurch() { + this.game.ui.showDialog('Церковь', 'Тёплое помещение. Тихо играет органная музыка. Добрая женщина предлагает помощь.', [ + 'Попросить еду', + 'Попросить одежду', + 'Помолиться', + 'Уйти' + ], (index) => { + if (index === 0) { + this.game.inventory.addItem('bread', 2); + this.game.inventory.addItem('tea', 1); + this.stats.warmth = Math.min(100, this.stats.warmth + 10); + this.game.sound.playPickup(); + this.game.notify('Вам дали хлеб и чай. +10 Тепло', 'good'); + } else if (index === 1) { + this.game.inventory.addItem('clothing', 1); + this.stats.warmth = Math.min(100, this.stats.warmth + 10); + this.game.sound.playPickup(); + this.game.notify('Вам дали тёплую одежду. +10 Тепло', 'good'); + } else if (index === 2) { + this.stats.mood = Math.min(100, this.stats.mood + 20); + this.stats.health = Math.min(100, this.stats.health + 5); + this.game.notify('Вы чувствуете покой. +20 Настроение, +5 Здоровье', 'good'); + } + this.game.ui.hideDialog(); + this.game.questSystem.onEvent('visit_church'); + this.game.reputation.change(2); + }); + } + + trackLocations() { + const cfg = this.game.world.mapConfig?.structures || {}; + const locs = { + shop: { x: cfg.shop?.x ?? -25, z: cfg.shop?.z ?? -12, r: 8 }, + church: { x: cfg.church?.x ?? 30, z: cfg.church?.z ?? 60, r: 10 }, + park: { x: cfg.park?.x ?? -30, z: cfg.park?.z ?? 25, r: cfg.park?.radius ?? 18 }, + shelter: { x: cfg.shelter?.x ?? -35, z: cfg.shelter?.z ?? 35, r: 6 }, + construction: { x: cfg.construction?.x ?? 70, z: cfg.construction?.z ?? 60, r: cfg.construction?.radius ?? 8 }, + hospital: { x: cfg.hospital?.x ?? -45, z: cfg.hospital?.z ?? -55, r: 8 }, + market: { x: cfg.market?.x ?? 35, z: cfg.market?.z ?? -55, r: 8 }, + busstop: { x: cfg.busStop?.x ?? -20, z: cfg.busStop?.z ?? 7, r: 5 }, + }; + + for (const [name, loc] of Object.entries(locs)) { + const dx = this.position.x - loc.x; + const dz = this.position.z - loc.z; + if (Math.sqrt(dx * dx + dz * dz) < loc.r) { + this.game.visitedLocations.add(name); + } + } + + if (this.game.visitedLocations.size >= 8) { + this.game.achievements.check('explorer'); + } + } + + checkDanger() { + return this.game.dangers.hasNearbyDanger(); + } +} diff --git a/js/game/Police.js b/js/game/Police.js new file mode 100644 index 0000000..77c0fed --- /dev/null +++ b/js/game/Police.js @@ -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; + } +} diff --git a/js/game/QuestSystem.js b/js/game/QuestSystem.js new file mode 100644 index 0000000..f718138 --- /dev/null +++ b/js/game/QuestSystem.js @@ -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 = []; + } +} diff --git a/js/game/Reputation.js b/js/game/Reputation.js new file mode 100644 index 0000000..dddda31 --- /dev/null +++ b/js/game/Reputation.js @@ -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; + } +} diff --git a/js/game/SaveSystem.js b/js/game/SaveSystem.js new file mode 100644 index 0000000..36ea771 --- /dev/null +++ b/js/game/SaveSystem.js @@ -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); + } +} diff --git a/js/game/Seasons.js b/js/game/Seasons.js new file mode 100644 index 0000000..89cfddd --- /dev/null +++ b/js/game/Seasons.js @@ -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: '❄️' }; diff --git a/js/game/Skills.js b/js/game/Skills.js new file mode 100644 index 0000000..b05de71 --- /dev/null +++ b/js/game/Skills.js @@ -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; + } + } +} diff --git a/js/game/SoundManager.js b/js/game/SoundManager.js new file mode 100644 index 0000000..c849bb3 --- /dev/null +++ b/js/game/SoundManager.js @@ -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; + } +} diff --git a/js/game/UI.js b/js/game/UI.js new file mode 100644 index 0000000..8e887ac --- /dev/null +++ b/js/game/UI.js @@ -0,0 +1,1133 @@ +export class UI { + constructor(game) { + this.game = game; + this.dialogCallback = null; + this.inventoryOpen = false; + this.questsOpen = false; + this.skillsOpen = false; + this.achievementsOpen = false; + this.minimapCtx = null; + this.begProgressEl = null; + this.buskProgressEl = null; + this.tooltipEl = null; + } + + init() { + document.getElementById('hud').classList.remove('hidden'); + + // Close buttons + document.getElementById('btn-close-inv').addEventListener('click', () => this.toggleInventory()); + document.getElementById('btn-close-quest').addEventListener('click', () => this.toggleQuests()); + document.getElementById('btn-close-skills').addEventListener('click', () => this.toggleSkills()); + document.getElementById('btn-close-achievements').addEventListener('click', () => this.toggleAchievements()); + + // Hotbar clicks + const hotbarItems = document.querySelectorAll('.hotbar-item'); + if (hotbarItems[0]) hotbarItems[0].addEventListener('click', () => this.toggleInventory()); + if (hotbarItems[1]) hotbarItems[1].addEventListener('click', () => this.toggleQuests()); + if (hotbarItems[2]) hotbarItems[2].addEventListener('click', () => this.toggleSkills()); + if (hotbarItems[3]) hotbarItems[3].addEventListener('click', () => this.toggleAchievements()); + + // Minimap + const minimap = document.getElementById('minimap'); + this.minimapCtx = minimap.getContext('2d'); + + // Tooltip + this.tooltipEl = document.getElementById('tooltip'); + + // Beg progress bar + this.createBegProgress(); + this.createBuskProgress(); + } + + createBegProgress() { + const el = document.createElement('div'); + el.id = 'beg-progress'; + el.classList.add('hidden'); + el.innerHTML = ` +
Попрошайничество...
+
+ `; + document.body.appendChild(el); + this.begProgressEl = el; + } + + createBuskProgress() { + const el = document.createElement('div'); + el.id = 'busk-progress'; + el.classList.add('hidden'); + el.innerHTML = ` +
Играю на гармошке...
+
+ `; + document.body.appendChild(el); + this.buskProgressEl = el; + } + + update(dt) { + const stats = this.game.player.stats; + + // Stat bars + this.updateBar('health', stats.health); + this.updateBar('hunger', stats.hunger); + this.updateBar('warmth', stats.warmth); + this.updateBar('mood', stats.mood); + this.updateBar('hygiene', stats.hygiene); + + // Critical indicators + this.setCritical('health', stats.health < 20); + this.setCritical('hunger', stats.hunger < 15); + this.setCritical('warmth', stats.warmth < 20); + this.setCritical('mood', stats.mood < 10); + this.setCritical('hygiene', stats.hygiene < 20); + + // Money + document.getElementById('val-money').textContent = Math.floor(stats.money); + + // Time + document.getElementById('val-time').textContent = this.game.getTimeString(); + document.getElementById('val-day').textContent = `День ${this.game.gameDay}`; + + // Weather + season + document.getElementById('val-weather').textContent = this.game.weather.getIcon(); + document.getElementById('val-temp').textContent = `${Math.round(this.game.weather.temperature)}°C`; + document.getElementById('val-season').textContent = `${this.game.seasons.getIcon()} ${this.game.seasons.getName()}`; + + // Protection & warmth bonuses + const protEl = document.getElementById('val-protection'); + const warmthBonusEl = document.getElementById('val-warmth-bonus'); + if (protEl) protEl.textContent = this.game.equipment.getProtectionBonus(); + if (warmthBonusEl) warmthBonusEl.textContent = this.game.equipment.getWarmthBonus(); + + // Stamina + this.updateStaminaBar(); + + // Reputation + this.updateReputationDisplay(); + + // Dog + this.updateDogIndicator(); + + // Danger + this.updateDangerWarning(); + + // Compass + this.updateCompass(); + + // Equipment HUD + this.updateEquipmentHUD(); + + // Quest Tracker + this.updateQuestTracker(); + + // Minimap (скрыть внутри зданий) + const minimapCanvas = document.getElementById('minimap'); + if (minimapCanvas) { + minimapCanvas.style.display = this.game.interiors?.isInside ? 'none' : 'block'; + } + if (!this.game.interiors?.isInside) { + this.renderMinimap(); + } + } + + updateStaminaBar() { + let bar = document.getElementById('stamina-bar'); + if (!bar) { + bar = document.createElement('div'); + bar.id = 'stamina-bar'; + bar.innerHTML = '
'; + document.getElementById('hud').appendChild(bar); + } + const fill = bar.querySelector('.stamina-fill'); + const pct = this.game.player.stamina / this.game.player.maxStamina * 100; + fill.style.width = pct + '%'; + bar.style.opacity = pct < 100 ? '1' : '0'; + } + + updateDogIndicator() { + if (!this.game.dog.adopted) return; + let el = document.getElementById('dog-indicator'); + if (!el) { + el = document.createElement('div'); + el.id = 'dog-indicator'; + el.style.cssText = 'position:absolute;bottom:68px;right:15px;background:linear-gradient(135deg,rgba(10,10,20,0.85),rgba(20,20,35,0.75));padding:4px 12px;border-radius:8px;font-size:0.75rem;color:#c8a040;pointer-events:none;backdrop-filter:blur(8px);border:1px solid rgba(200,160,64,0.15);'; + document.getElementById('hud').appendChild(el); + } + el.textContent = '🐕 Шарик рядом'; + } + + updateReputationDisplay() { + const el = document.getElementById('reputation-display'); + if (!el) return; + const rep = this.game.reputation; + el.style.color = rep.getColor(); + el.textContent = `⭐ ${rep.getLevel()} (${rep.value > 0 ? '+' : ''}${rep.value})`; + } + + updateDangerWarning() { + let el = document.getElementById('danger-warning'); + if (this.game.dangers.hasNearbyDanger()) { + if (!el) { + el = document.createElement('div'); + el.id = 'danger-warning'; + el.style.cssText = 'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) translateY(-80px);color:#f44336;font-size:0.9rem;font-weight:700;pointer-events:none;text-shadow:0 0 15px rgba(244,67,54,0.5);animation:criticalPulse 1s infinite;padding:8px 20px;background:linear-gradient(135deg,rgba(60,0,0,0.8),rgba(40,0,0,0.7));border-radius:8px;border:1px solid rgba(244,67,54,0.3);backdrop-filter:blur(8px);'; + document.getElementById('hud').appendChild(el); + } + el.textContent = '⚠ ОПАСНОСТЬ! [Space] — отбиться'; + el.style.display = 'block'; + } else if (el) { + el.style.display = 'none'; + } + } + + updateEquipmentHUD() { + let el = document.getElementById('equipment-hud'); + if (!el) { + el = document.createElement('div'); + el.id = 'equipment-hud'; + el.style.cssText = 'position:absolute;bottom:15px;right:15px;background:linear-gradient(135deg,rgba(10,10,20,0.85),rgba(20,20,35,0.75));padding:6px 10px;border-radius:8px;font-size:0.7rem;pointer-events:none;backdrop-filter:blur(8px);display:flex;gap:8px;border:1px solid rgba(255,255,255,0.05);'; + document.getElementById('hud').appendChild(el); + } + + const eq = this.game.equipment; + const slotIcons = { head: '🧢', body: '🧥', feet: '🥾', hands: '🧤' }; + let html = ''; + for (const [slot, defaultIcon] of Object.entries(slotIcons)) { + const item = eq.getEquipped(slot); + const icon = item ? item.icon : defaultIcon; + const opacity = item ? '1' : '0.25'; + html += `${icon}`; + } + el.innerHTML = html; + } + + updateQuestTracker() { + const tracker = document.getElementById('quest-tracker'); + if (!tracker) return; + + const active = this.game.questSystem.getActiveQuests(); + if (active.length === 0) { + tracker.style.display = 'none'; + return; + } + + tracker.style.display = 'block'; + const quest = active[0]; // Show first active quest + const pct = Math.min(100, (quest.progress / quest.target) * 100); + + document.getElementById('tracker-title').textContent = quest.title; + document.getElementById('tracker-progress').textContent = `${Math.min(quest.progress, quest.target)} / ${quest.target}`; + document.getElementById('tracker-fill').style.width = `${pct}%`; + } + + // === Job Progress === + updateJobProgress(progress, name) { + let el = document.getElementById('job-progress'); + if (!el) { + el = document.createElement('div'); + el.id = 'job-progress'; + el.innerHTML = ` +
+
+
[H] — отмена
+ `; + el.style.cssText = 'position:fixed;bottom:140px;left:50%;transform:translateX(-50%);z-index:12;width:220px;pointer-events:none;text-align:center;'; + el.querySelector('.job-label').style.cssText = 'font-size:0.8rem;color:#8fc;margin-bottom:4px;font-weight:600;'; + el.querySelector('.job-bar-bg').style.cssText = 'height:5px;background:rgba(255,255,255,0.06);border-radius:3px;overflow:hidden;'; + el.querySelector('.job-bar-fill').style.cssText = 'height:100%;background:linear-gradient(90deg,#43a047,#66bb6a);border-radius:3px;transition:width 0.15s;'; + el.querySelector('.job-cancel').style.cssText = 'font-size:0.6rem;color:#666;margin-top:4px;'; + document.body.appendChild(el); + } + el.style.display = 'block'; + el.querySelector('.job-label').textContent = `🔧 ${name}`; + el.querySelector('.job-bar-fill').style.width = `${Math.min(100, progress * 100)}%`; + } + + hideJobProgress() { + const el = document.getElementById('job-progress'); + if (el) el.style.display = 'none'; + } + + updateBar(name, value) { + const bar = document.getElementById(`bar-${name}`); + const val = document.getElementById(`val-${name}`); + if (bar) bar.style.width = `${Math.max(0, Math.min(100, value))}%`; + if (val) val.textContent = Math.floor(value); + } + + setCritical(name, isCritical) { + const bars = document.querySelectorAll('.stat-bar'); + const names = ['health', 'hunger', 'warmth', 'mood']; + const idx = names.indexOf(name); + if (idx >= 0 && bars[idx]) { + if (isCritical) { + bars[idx].classList.add('critical'); + } else { + bars[idx].classList.remove('critical'); + } + } + } + + updateCompass() { + const dir = document.getElementById('compass-dir'); + if (!dir) return; + + const yaw = this.game.cameraController.yaw; + const deg = ((yaw * 180 / Math.PI) % 360 + 360) % 360; + + let label; + if (deg > 315 || deg <= 45) label = 'С'; + else if (deg > 45 && deg <= 135) label = 'З'; + else if (deg > 135 && deg <= 225) label = 'Ю'; + else label = 'В'; + + dir.textContent = label; + } + + updateInteractionHint(interactable) { + const hint = document.getElementById('interaction-hint'); + const text = document.getElementById('hint-text'); + + if (interactable) { + hint.classList.remove('hidden'); + text.textContent = interactable.label; + } else { + hint.classList.add('hidden'); + } + } + + // === Tooltip === + showTooltip(x, y, title, desc, statsHtml) { + const tt = this.tooltipEl; + if (!tt) return; + document.getElementById('tooltip-title').textContent = title; + document.getElementById('tooltip-desc').textContent = desc; + document.getElementById('tooltip-stats').innerHTML = statsHtml || ''; + + tt.classList.remove('hidden'); + tt.style.left = Math.min(x + 12, window.innerWidth - 260) + 'px'; + tt.style.top = Math.min(y - 10, window.innerHeight - 150) + 'px'; + } + + hideTooltip() { + if (this.tooltipEl) this.tooltipEl.classList.add('hidden'); + } + + // === Minimap === + renderMinimap() { + const ctx = this.minimapCtx; + if (!ctx) return; + + const w = 180, h = 180; + const scale = 0.9; // Уменьшили масштаб чтобы больше карты видно + const player = this.game.player; + + ctx.clearRect(0, 0, w, h); + + // Background + const bgGrad = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, w/2); + bgGrad.addColorStop(0, '#1a1a24'); + bgGrad.addColorStop(1, '#111118'); + ctx.fillStyle = bgGrad; + ctx.beginPath(); + ctx.arc(w / 2, h / 2, w / 2, 0, Math.PI * 2); + ctx.fill(); + + ctx.save(); + ctx.beginPath(); + ctx.arc(w / 2, h / 2, w / 2 - 1, 0, Math.PI * 2); + ctx.clip(); + + const cx = w / 2; + const cy = h / 2; + const px = player.position.x; + const pz = player.position.z; + + // Дороги из конфига + const roads = this.game.world.mapConfig?.roads || []; + ctx.fillStyle = '#2a2a34'; + roads.forEach(road => { + const rx = road.x ?? 0; + const rz = road.z ?? 0; + const rw = road.width ?? 100; + const rh = road.height ?? 8; + const isRotated = (road.rotation || 0) > 0.5; // NS road + if (isRotated) { + // NS road: width becomes vertical extent, height becomes horizontal width + const roadX = cx + (rx - px) * scale - (rh / 2) * scale; + const roadY = cy + (rz - pz) * scale - (rw / 2) * scale; + ctx.fillRect(roadX, roadY, rh * scale, rw * scale); + } else { + // EW road: normal + const roadX = cx + (rx - px) * scale - (rw / 2) * scale; + const roadY = cy + (rz - pz) * scale - (rh / 2) * scale; + ctx.fillRect(roadX, roadY, rw * scale, rh * scale); + } + }); + + // Здания + ctx.fillStyle = '#3a3a48'; + this.game.world.buildingRects.forEach(b => { + const bx = cx + (b.x - px) * scale - (b.w / 2) * scale; + const by = cy + (b.z - pz) * scale - (b.d / 2) * scale; + ctx.fillRect(bx, by, b.w * scale, b.d * scale); + }); + + // Парк + const parkCfg = this.game.world.mapConfig?.structures?.park || {}; + ctx.fillStyle = '#1a4a1a'; + ctx.beginPath(); + ctx.arc(cx + ((parkCfg.x ?? -30) - px) * scale, cy + ((parkCfg.z ?? 25) - pz) * scale, (parkCfg.radius ?? 18) * scale, 0, Math.PI * 2); + ctx.fill(); + + // Укрытие + const shelCfg = this.game.world.mapConfig?.structures?.shelter || {}; + ctx.fillStyle = '#5a4a3a'; + const shelX = cx + ((shelCfg.x ?? -35) - px) * scale - 4 * scale; + const shelZ = cy + ((shelCfg.z ?? 35) - pz) * scale - 3 * scale; + this.roundRect(ctx, shelX, shelZ, 8 * scale, 6 * scale, 2); + + // Больница (белый квадрат с красной точкой, БЕЗ креста) + const hospCfg = this.game.world.mapConfig?.structures?.hospital || {}; + ctx.fillStyle = '#dddddd'; + const hospX = cx + ((hospCfg.x ?? -45) - px) * scale - 6 * scale; + const hospZ = cy + ((hospCfg.z ?? -55) - pz) * scale - 5 * scale; + this.roundRect(ctx, hospX, hospZ, 12 * scale, 10 * scale, 2); + ctx.fillStyle = '#ee3333'; + ctx.beginPath(); + ctx.arc(cx + ((hospCfg.x ?? -45) - px) * scale, cy + ((hospCfg.z ?? -55) - pz) * scale, 3, 0, Math.PI * 2); + ctx.fill(); + + // Стройка (контур) + const conCfg = this.game.world.mapConfig?.structures?.construction || {}; + ctx.strokeStyle = '#aa7020'; + ctx.lineWidth = 1.5; + const csX = cx + ((conCfg.x ?? 70) - px) * scale - 4.5 * scale; + const csZ = cy + ((conCfg.z ?? 60) - pz) * scale - 4.5 * scale; + ctx.strokeRect(csX, csZ, 9 * scale, 9 * scale); + + // Рынок + const mktCfg = this.game.world.mapConfig?.structures?.market || {}; + ctx.fillStyle = '#cc7020'; + const mkX = cx + ((mktCfg.x ?? 35) - px) * scale - 7 * scale; + const mkZ = cy + ((mktCfg.z ?? -55) - pz) * scale - 5 * scale; + this.roundRect(ctx, mkX, mkZ, 14 * scale, 10 * scale, 2); + + // Лагерь игрока + if (this.game.housing.built) { + ctx.fillStyle = '#33aa33'; + const campX = cx + (this.game.housing.position.x - px) * scale; + const campZ = cy + (this.game.housing.position.z - pz) * scale; + ctx.beginPath(); + ctx.arc(campX, campZ, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = '#55cc55'; + ctx.lineWidth = 1; + ctx.stroke(); + } + + // NPC + this.game.npcManager.npcs.forEach(npc => { + const nx = cx + (npc.position.x - px) * scale; + const ny = cy + (npc.position.z - pz) * scale; + ctx.fillStyle = '#4488ee'; + ctx.beginPath(); + ctx.arc(nx, ny, 3, 0, Math.PI * 2); + ctx.fill(); + }); + + // Мусорки + ctx.fillStyle = '#4a7a4a'; + this.game.world.interactables.forEach(obj => { + if (obj.type === 'dumpster' || obj.type === 'trashpile') { + const dx = cx + (obj.position.x - px) * scale; + const dy = cy + (obj.position.z - pz) * scale; + ctx.fillRect(dx - 1.5, dy - 1.5, 3, 3); + } + }); + + // Пёс + if (this.game.dog.adopted && this.game.dog.mesh) { + ctx.fillStyle = '#c8a040'; + const dogX = cx + (this.game.dog.position.x - px) * scale; + const dogY = cy + (this.game.dog.position.z - pz) * scale; + ctx.beginPath(); + ctx.arc(dogX, dogY, 2.5, 0, Math.PI * 2); + ctx.fill(); + } + + // Враги + this.game.dangers.enemies.forEach(enemy => { + const ex = cx + (enemy.position.x - px) * scale; + const ey = cy + (enemy.position.z - pz) * scale; + ctx.fillStyle = '#f44336'; + ctx.beginPath(); + ctx.arc(ex, ey, 3.5, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(244,67,54,0.4)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(ex, ey, 5, 0, Math.PI * 2); + ctx.stroke(); + }); + + // Полиция + this.game.police.officers.forEach(officer => { + if (!officer.mesh || !officer.mesh.visible) return; + const ox = cx + (officer.position.x - px) * scale; + const oy = cy + (officer.position.z - pz) * scale; + ctx.fillStyle = '#4488ff'; + ctx.beginPath(); + ctx.arc(ox, oy, 3, 0, Math.PI * 2); + ctx.fill(); + if (officer.state === 'chase') { + ctx.strokeStyle = 'rgba(68,136,255,0.5)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.arc(ox, oy, 5, 0, Math.PI * 2); + ctx.stroke(); + } + }); + + // Доска объявлений + const jbCfg = this.game.world.mapConfig?.structures?.jobBoard || {}; + ctx.fillStyle = '#4caf50'; + const jbX = cx + ((jbCfg.x ?? 20) - px) * scale; + const jbZ = cy + ((jbCfg.z ?? -8) - pz) * scale; + ctx.fillRect(jbX - 2.5, jbZ - 2.5, 5, 5); + + // Маркер активного квеста + const activeQuests = this.game.questSystem.quests.filter(q => !q.completed && q.location); + if (activeQuests.length > 0) { + const quest = activeQuests[0]; + const qx = cx + (quest.location.x - px) * scale; + const qy = cy + (quest.location.z - pz) * scale; + const pulse = 0.8 + Math.sin(Date.now() / 300) * 0.3; + const qs = 5 * pulse; + ctx.fillStyle = '#ffdd00'; + ctx.beginPath(); + ctx.moveTo(qx, qy - qs); + ctx.lineTo(qx + qs * 0.6, qy); + ctx.lineTo(qx, qy + qs); + ctx.lineTo(qx - qs * 0.6, qy); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = '#aa8800'; + ctx.lineWidth = 1; + ctx.stroke(); + } + + // Игрок (стрелка) + ctx.save(); + ctx.translate(cx, cy); + const yaw = this.game.cameraController.yaw; + ctx.rotate(-yaw); + + ctx.fillStyle = 'rgba(240,160,64,0.15)'; + ctx.beginPath(); + ctx.arc(0, 0, 10, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#f0a040'; + ctx.beginPath(); + ctx.moveTo(0, -7); + ctx.lineTo(-4.5, 5); + ctx.lineTo(0, 3); + ctx.lineTo(4.5, 5); + ctx.closePath(); + ctx.fill(); + + ctx.restore(); + ctx.restore(); + + // Рамка + ctx.strokeStyle = 'rgba(240,160,64,0.3)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(w / 2, h / 2, w / 2 - 1, 0, Math.PI * 2); + ctx.stroke(); + + // Стороны света + ctx.fillStyle = 'rgba(240,160,64,0.5)'; + ctx.font = '8px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('С', w/2, 12); + ctx.fillText('Ю', w/2, h - 5); + ctx.fillText('З', 8, h/2 + 3); + ctx.fillText('В', w - 8, h/2 + 3); + } + + roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + ctx.fill(); + } + + // === Beg progress === + showBegProgress(show) { + if (this.begProgressEl) { + if (show) { + this.begProgressEl.classList.remove('hidden'); + } else { + this.begProgressEl.classList.add('hidden'); + } + } + } + + updateBegProgress(ratio) { + if (!this.begProgressEl) return; + const fill = this.begProgressEl.querySelector('.beg-bar-fill'); + if (fill) fill.style.width = `${Math.min(100, ratio * 100)}%`; + } + + // === Busk progress === + showBuskProgress(show) { + if (this.buskProgressEl) { + if (show) { + this.buskProgressEl.classList.remove('hidden'); + } else { + this.buskProgressEl.classList.add('hidden'); + } + } + } + + updateBuskProgress(ratio) { + if (!this.buskProgressEl) return; + const fill = this.buskProgressEl.querySelector('.beg-bar-fill'); + if (fill) fill.style.width = `${Math.min(100, ratio * 100)}%`; + } + + // === Dialogs === + showDialog(speaker, text, choices, callback) { + const box = document.getElementById('dialog-box'); + document.getElementById('dialog-speaker').textContent = speaker; + document.getElementById('dialog-text').textContent = text; + + const choicesEl = document.getElementById('dialog-choices'); + choicesEl.innerHTML = ''; + + this.game.sound.playDialogOpen(); + + choices.forEach((choice, i) => { + const btn = document.createElement('button'); + btn.className = 'dialog-choice'; + btn.textContent = choice; + btn.addEventListener('click', () => callback(i)); + choicesEl.appendChild(btn); + }); + + box.classList.remove('hidden'); + this.dialogCallback = callback; + document.exitPointerLock(); + } + + hideDialog() { + document.getElementById('dialog-box').classList.add('hidden'); + this.dialogCallback = null; + } + + // === Inventory === + toggleInventory() { + this.inventoryOpen = !this.inventoryOpen; + const screen = document.getElementById('inventory-screen'); + + if (this.inventoryOpen) { + this.renderInventory(); + screen.classList.remove('hidden'); + document.exitPointerLock(); + } else { + screen.classList.add('hidden'); + this.hideTooltip(); + } + } + + renderInventory() { + const container = document.querySelector('.inventory-content'); + + // Equipment section + this.renderEquipmentSection(container); + + const grid = document.getElementById('inventory-grid'); + grid.innerHTML = ''; + + const items = this.game.inventory.getAll(); + + items.forEach(item => { + const slot = document.createElement('div'); + slot.className = 'inv-slot'; + + const isEquippable = item.equippable; + const isUsable = item.usable; + + if (isEquippable) slot.classList.add('equippable'); + + slot.innerHTML = ` + ${item.icon} + ${item.name} + ${item.count > 1 ? `x${item.count}` : ''} + `; + + // Tooltip on hover + slot.addEventListener('mouseenter', (e) => { + let statsHtml = ''; + if (item.equippable) { + const eqData = this.game.equipment.allItems[item.key.replace('eq_', '')]; + if (eqData) { + statsHtml = `🔥 +${eqData.warmth} Тепло
🛡️ +${eqData.protection} Защита
😊 +${eqData.mood} Настроение`; + } + } + if (item.usable && !item.equippable) { + statsHtml = 'Нажмите чтобы использовать'; + } + this.showTooltip(e.clientX, e.clientY, item.name, item.desc || '', statsHtml); + }); + slot.addEventListener('mouseleave', () => this.hideTooltip()); + slot.addEventListener('mousemove', (e) => { + if (this.tooltipEl && !this.tooltipEl.classList.contains('hidden')) { + this.tooltipEl.style.left = Math.min(e.clientX + 12, window.innerWidth - 260) + 'px'; + this.tooltipEl.style.top = Math.min(e.clientY - 10, window.innerHeight - 150) + 'px'; + } + }); + + if (isEquippable || isUsable) { + slot.style.cursor = 'pointer'; + slot.addEventListener('click', () => { + this.game.inventory.useItem(item.key); + this.renderInventory(); + }); + } + + grid.appendChild(slot); + }); + + const empty = this.game.inventory.maxSlots - items.length; + for (let i = 0; i < Math.max(0, empty); i++) { + const slot = document.createElement('div'); + slot.className = 'inv-slot'; + slot.style.opacity = '0.3'; + grid.appendChild(slot); + } + + // Crafting section + this.renderCrafting(container); + } + + renderEquipmentSection(container) { + let eqSection = container.querySelector('.equipment-section'); + if (!eqSection) { + eqSection = document.createElement('div'); + eqSection.className = 'equipment-section'; + container.insertBefore(eqSection, document.getElementById('inventory-grid')); + } + + const eq = this.game.equipment; + const slotNames = { + head: { label: 'Голова', icon: '🧢' }, + body: { label: 'Тело', icon: '🧥' }, + feet: { label: 'Ноги', icon: '🥾' }, + hands: { label: 'Руки', icon: '🧤' } + }; + + let html = '
Экипировка
'; + + for (const [slot, info] of Object.entries(slotNames)) { + const item = eq.getEquipped(slot); + const isEmpty = !item; + html += ` +
+ ${item ? item.icon : info.icon} + ${item ? item.name : info.label} + ${item ? `+${item.warmth}🔥 +${item.protection}🛡️` : ''} +
+ `; + } + + html += '
'; + + // Total bonuses + const warmth = eq.getWarmthBonus(); + const protection = eq.getProtectionBonus(); + const mood = eq.getMoodBonus(); + if (warmth > 0 || protection > 0 || mood > 0) { + html += `
`; + if (warmth > 0) html += `+${warmth} 🔥 `; + if (protection > 0) html += `+${protection}% 🛡️ `; + if (mood > 0) html += `+${mood} 😊`; + html += '
'; + } + + eqSection.innerHTML = html; + + // Unequip handlers + eqSection.querySelectorAll('.eq-slot:not(.empty)').forEach(slotEl => { + slotEl.addEventListener('click', () => { + const slotKey = slotEl.dataset.slot; + eq.unequip(slotKey); + this.renderInventory(); + }); + }); + } + + renderCrafting(container) { + let craftSection = container.querySelector('.craft-section'); + if (craftSection) craftSection.remove(); + + craftSection = document.createElement('div'); + craftSection.className = 'craft-section'; + craftSection.innerHTML = '

Крафт

'; + + const recipes = this.game.inventory.recipes; + const inv = this.game.inventory; + + recipes.forEach(recipe => { + const canCraft = inv.canCraft(recipe); + const el = document.createElement('div'); + el.className = `craft-item ${canCraft ? 'available' : ''}`; + + // Build ingredients line with "has/needs" info + let ingredientsHtml = ''; + if (recipe.ingredients) { + const parts = []; + for (const [key, need] of Object.entries(recipe.ingredients)) { + const have = inv.getCount(key); + const itemData = inv.itemData[key]; + const name = itemData ? itemData.name : key; + const cls = have >= need ? 'has' : 'missing'; + parts.push(`${name}: ${have}/${need}`); + } + ingredientsHtml = `
${parts.join('   ')}
`; + } + + el.innerHTML = ` +
+
${inv.itemData[recipe.result]?.icon || ''} ${recipe.name}
+
${recipe.desc}
+ ${ingredientsHtml} +
+ + `; + + if (canCraft) { + el.querySelector('.craft-btn').addEventListener('click', () => { + inv.craft(recipe); + this.renderInventory(); + }); + } + + craftSection.appendChild(el); + }); + + const closeBtn = container.querySelector('.panel-close'); + container.appendChild(craftSection); + } + + // === Quests === + toggleQuests() { + this.questsOpen = !this.questsOpen; + const screen = document.getElementById('quest-screen'); + + if (this.questsOpen) { + this.renderQuests(); + screen.classList.remove('hidden'); + document.exitPointerLock(); + } else { + screen.classList.add('hidden'); + } + } + + renderQuests() { + const list = document.getElementById('quest-list'); + list.innerHTML = ''; + + const active = this.game.questSystem.getActiveQuests(); + const completed = this.game.questSystem.getCompletedQuests(); + + if (active.length === 0 && completed.length === 0) { + list.innerHTML = '

Нет активных квестов

'; + return; + } + + active.forEach(quest => { + const pct = Math.min(100, (quest.progress / quest.target) * 100); + const el = document.createElement('div'); + el.className = 'quest-item active'; + el.innerHTML = ` +
📌 ${quest.title}
+
${quest.description}
+
${Math.min(quest.progress, quest.target)} / ${quest.target}
+
+ `; + list.appendChild(el); + }); + + if (completed.length > 0) { + const divider = document.createElement('div'); + divider.style.cssText = 'font-size:0.7rem;color:#444;text-transform:uppercase;letter-spacing:0.1em;margin:16px 0 8px;padding-bottom:6px;border-bottom:1px solid rgba(255,255,255,0.04);'; + divider.textContent = 'Выполнено'; + list.appendChild(divider); + } + + completed.forEach(quest => { + const el = document.createElement('div'); + el.className = 'quest-item completed'; + el.innerHTML = ` +
✅ ${quest.title}
+
${quest.description}
+ `; + list.appendChild(el); + }); + } + + // === Skills === + toggleSkills() { + this.skillsOpen = !this.skillsOpen; + const screen = document.getElementById('skills-screen'); + + if (this.skillsOpen) { + this.renderSkills(); + screen.classList.remove('hidden'); + document.exitPointerLock(); + } else { + screen.classList.add('hidden'); + } + } + + renderSkills() { + const list = document.getElementById('skills-list'); + list.innerHTML = ''; + + const icons = { + scavenging: '🔍', + begging: '🗣️', + survival: '🏕️', + trading: '💰' + }; + + const descs = { + scavenging: 'Больше находок при обыске', + begging: 'Больше денег от милостыни', + survival: 'Медленнее теряете тепло и сытость', + trading: 'Лучшие цены при работе' + }; + + const skills = this.game.skills.skills; + for (const [key, skill] of Object.entries(skills)) { + const xpPct = Math.min(100, (skill.xp / skill.xpNeeded) * 100); + const maxed = skill.level >= this.game.skills.maxLevel; + const el = document.createElement('div'); + el.className = 'skill-item'; + el.innerHTML = ` +
+ ${icons[key] || ''} ${skill.name} + ${maxed ? '✨ MAX' : `Ур. ${skill.level}`} +
+
${skill.desc || descs[key] || ''}
+ ${!maxed ? ` +
+
${skill.xp} / ${skill.xpNeeded} XP
+ ` : ''} + `; + list.appendChild(el); + } + } + + // === Achievements === + toggleAchievements() { + this.achievementsOpen = !this.achievementsOpen; + const screen = document.getElementById('achievements-screen'); + + if (this.achievementsOpen) { + this.renderAchievements(); + screen.classList.remove('hidden'); + document.exitPointerLock(); + } else { + screen.classList.add('hidden'); + } + } + + renderAchievements() { + const list = document.getElementById('achievements-list'); + list.innerHTML = ''; + + const achievements = this.game.achievements; + const progress = achievements.getProgress(); + + // Progress header + const headerEl = document.createElement('div'); + headerEl.className = 'ach-progress-header'; + headerEl.innerHTML = ` +
Разблокировано: ${progress.unlocked} / ${progress.total}
+
+
+
+ `; + list.appendChild(headerEl); + + const categories = { + survival: { name: 'Выживание', icon: '🏕️' }, + social: { name: 'Социальные', icon: '🤝' }, + economy: { name: 'Экономика', icon: '💰' }, + combat: { name: 'Боевые', icon: '⚔️' }, + explore: { name: 'Исследование', icon: '🗺️' } + }; + + for (const [catKey, catInfo] of Object.entries(categories)) { + const catAchievements = achievements.getByCategory(catKey); + if (catAchievements.length === 0) continue; + + const catEl = document.createElement('div'); + catEl.className = 'ach-category'; + + const unlockedCount = catAchievements.filter(a => achievements.unlocked.has(a.id)).length; + catEl.innerHTML = `
${catInfo.icon} ${catInfo.name} ${unlockedCount}/${catAchievements.length}
`; + + catAchievements.forEach(ach => { + const unlocked = achievements.unlocked.has(ach.id); + const achEl = document.createElement('div'); + achEl.className = `ach-item ${unlocked ? 'unlocked' : 'locked'}`; + achEl.innerHTML = ` + ${unlocked ? ach.icon : '🔒'} +
+
${unlocked ? ach.title : '???'}
+
${ach.desc}
+
+ `; + catEl.appendChild(achEl); + }); + + list.appendChild(catEl); + } + } + + // === Intro === + showIntro(callback) { + const overlay = document.getElementById('intro-overlay'); + const textEl = document.getElementById('intro-text'); + overlay.classList.remove('hidden'); + + const lines = [ + 'Ещё вчера у вас было всё — работа, квартира, друзья...', + 'Одна ошибка, и жизнь перевернулась.', + 'Теперь ваш дом — улица, а главная цель — дожить до завтра.', + 'Ищите еду, собирайте бутылки, заводите знакомства.', + 'Не сдавайтесь. Каждый день — это шанс.' + ]; + + textEl.innerHTML = ''; + lines.forEach((line, i) => { + const p = document.createElement('p'); + p.className = 'intro-line'; + p.textContent = line; + p.style.animationDelay = `${i * 1.2}s`; + textEl.appendChild(p); + }); + + const skipBtn = document.getElementById('btn-skip-intro'); + const closeIntro = () => { + overlay.classList.add('hidden'); + skipBtn.removeEventListener('click', closeIntro); + if (callback) callback(); + }; + + skipBtn.addEventListener('click', closeIntro); + setTimeout(closeIntro, lines.length * 1200 + 2000); + } + + // === Notifications === + showNotification(text, type) { + const container = document.getElementById('notifications'); + const notif = document.createElement('div'); + notif.className = 'notification'; + if (type === 'good') notif.classList.add('good'); + if (type === 'bad') notif.classList.add('bad'); + notif.textContent = text; + container.appendChild(notif); + setTimeout(() => notif.remove(), 3600); + } + + // === Death === + showDeathScreen(reason, day) { + const screen = document.getElementById('death-screen'); + document.getElementById('death-reason').textContent = reason; + + const money = Math.floor(this.game.player.stats.money); + const completedQuests = this.game.questSystem.getCompletedQuests().length; + const totalQuests = this.game.questSystem.quests.length; + const hasDog = this.game.dog.adopted; + const skills = this.game.skills.skills; + const totalLevels = Object.values(skills).reduce((s, sk) => s + sk.level, 0); + const repLevel = this.game.reputation.getLevel(); + const achProgress = this.game.achievements.getProgress(); + const eqSlots = this.game.equipment.getFilledSlots(); + + document.getElementById('death-stats').innerHTML = ` +
+
+ 📅 + ${day} + Дней +
+
+ 💰 + ${money}₽ + Денег +
+
+ 📋 + ${completedQuests}/${totalQuests} + Квесты +
+
+ + ${totalLevels} + Навыки +
+
+ + ${repLevel} + Репутация +
+
+ 🏆 + ${achProgress.unlocked}/${achProgress.total} + Достижения +
+
+ 🛡️ + ${eqSlots}/4 + Экипировка +
+
+ 🏠 + ${this.game.housing.built ? 'Да' : 'Нет'} + Укрытие +
+
+ 🐕 + ${hasDog ? 'Да' : 'Нет'} + Пёс +
+
+ `; + + screen.classList.remove('hidden'); + + document.getElementById('btn-restart').onclick = () => { + screen.classList.add('hidden'); + this.game.restart(); + }; + } + + hideDeathScreen() { + document.getElementById('death-screen').classList.add('hidden'); + } +} diff --git a/js/game/Weather.js b/js/game/Weather.js new file mode 100644 index 0000000..54d88cf --- /dev/null +++ b/js/game/Weather.js @@ -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(); + } +} diff --git a/js/game/World.js b/js/game/World.js new file mode 100644 index 0000000..2ac7e72 --- /dev/null +++ b/js/game/World.js @@ -0,0 +1,1791 @@ +import * as THREE from 'three'; +import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; + +export class World { + constructor(game) { + this.game = game; + this.scene = game.scene; + this.interactables = []; + this.colliders = []; + this.sunLight = null; + this.ambientLight = null; + // Для миникарты + this.buildingRects = []; + // 3D модель машины + this.carModel = null; + this.carModelBaseRotation = 0; // корректировка ориентации модели + this.parkedCars = []; // для замены припаркованных машин + this.vehicleColliders = []; // динамические коллайдеры машин + this.mapConfig = null; + } + + async loadMapConfig() { + try { + const resp = await fetch('data/map-config.json'); + this.mapConfig = await resp.json(); + } catch(e) { + console.warn('Map config not found, using defaults'); + this.mapConfig = null; + } + } + + async build() { + this.interactables = []; + this.colliders = []; + this.buildingRects = []; + this.vehicleColliders = []; + await this.loadMapConfig(); + this.createGround(); + this.createLighting(); + this.createBuildings(); + this.createPark(); + this.createDumpsters(); + this.createBenches(); + this.createShop(); + this.createShelter(); + this.createStreetLamps(); + this.createTrashPiles(); + this.createFountain(); + this.createPhoneBooth(); + this.createParkingLot(); + this.createBusStop(); + this.createDecorations(); + this.createChurch(); + this.createConstructionSite(); + this.createJobBoard(); + this.createHospital(); + this.createMarket(); + this.createCampSpot(); + } + + createGround() { + const groundGeo = new THREE.PlaneGeometry(300, 300); + const groundMat = new THREE.MeshStandardMaterial({ color: 0x555555, roughness: 0.9 }); + const ground = new THREE.Mesh(groundGeo, groundMat); + ground.rotation.x = -Math.PI / 2; + ground.receiveShadow = true; + this.scene.add(ground); + + // === ДОРОЖНАЯ СЕТКА (из конфига) === + const roads = this.mapConfig?.roads || [ + { x: 0, z: 0, width: 300, height: 12, rotation: 0, sidewalkWidth: 3 }, + { x: 0, z: 0, width: 12, height: 300, rotation: Math.PI / 2, sidewalkWidth: 3 }, + ]; + for (const road of roads) { + this.createRoad(road.x, 0.01, road.z, road.width, road.height, road.rotation); + } + + // === АВТОГЕНЕРАЦИЯ ТРОТУАРОВ И БОРДЮРОВ (с вырезами на перекрёстках) === + const sidewalkMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8 }); + const curbMat = new THREE.MeshStandardMaterial({ color: 0x999999 }); + + const ewRoads = roads.filter(r => Math.abs(r.rotation) < 0.1); + const nsRoads = roads.filter(r => Math.abs(r.rotation) >= 0.1); + + for (const road of roads) { + const sw = road.sidewalkWidth ?? 3; + if (sw <= 0) continue; + + const isEW = Math.abs(road.rotation) < 0.1; + + if (isEW) { + const halfH = road.height / 2; + const roadStart = road.x - road.width / 2; + const roadEnd = road.x + road.width / 2; + + // Находим пересечения с N-S дорогами → вырезы по X + const gaps = []; + for (const ns of nsRoads) { + const nsHalfW = ns.width / 2; + const nsSW = ns.sidewalkWidth ?? 3; + const nsZMin = ns.z - ns.height / 2; + const nsZMax = ns.z + ns.height / 2; + if (road.z >= nsZMin && road.z <= nsZMax && + ns.x >= roadStart && ns.x <= roadEnd) { + gaps.push({ start: ns.x - nsHalfW - nsSW, end: ns.x + nsHalfW + nsSW }); + } + } + gaps.sort((a, b) => a.start - b.start); + + // Сегменты тротуара между вырезами + const segments = []; + let pos = roadStart; + for (const g of gaps) { + if (g.start > pos) segments.push({ start: pos, end: g.start }); + pos = Math.max(pos, g.end); + } + if (pos < roadEnd) segments.push({ start: pos, end: roadEnd }); + + for (const seg of segments) { + const len = seg.end - seg.start; + const cx = (seg.start + seg.end) / 2; + + const swGeo = new THREE.BoxGeometry(len, 0.15, sw); + [1, -1].forEach(side => { + const swMesh = new THREE.Mesh(swGeo, sidewalkMat); + swMesh.position.set(cx, 0.075, road.z + side * (halfH + sw / 2)); + swMesh.receiveShadow = true; + this.scene.add(swMesh); + }); + + const curbGeo = new THREE.BoxGeometry(len, 0.2, 0.15); + [halfH, halfH + sw, -halfH, -(halfH + sw)].forEach(offset => { + const curb = new THREE.Mesh(curbGeo, curbMat); + curb.position.set(cx, 0.1, road.z + offset); + this.scene.add(curb); + }); + } + } else { + const halfW = road.width / 2; + const roadStart = road.z - road.height / 2; + const roadEnd = road.z + road.height / 2; + + // Находим пересечения с E-W дорогами → вырезы по Z + const gaps = []; + for (const ew of ewRoads) { + const ewHalfH = ew.height / 2; + const ewSW = ew.sidewalkWidth ?? 3; + const ewXMin = ew.x - ew.width / 2; + const ewXMax = ew.x + ew.width / 2; + if (road.x >= ewXMin && road.x <= ewXMax && + ew.z >= roadStart && ew.z <= roadEnd) { + gaps.push({ start: ew.z - ewHalfH - ewSW, end: ew.z + ewHalfH + ewSW }); + } + } + gaps.sort((a, b) => a.start - b.start); + + const segments = []; + let pos = roadStart; + for (const g of gaps) { + if (g.start > pos) segments.push({ start: pos, end: g.start }); + pos = Math.max(pos, g.end); + } + if (pos < roadEnd) segments.push({ start: pos, end: roadEnd }); + + for (const seg of segments) { + const len = seg.end - seg.start; + const cz = (seg.start + seg.end) / 2; + + const swGeo = new THREE.BoxGeometry(sw, 0.15, len); + [1, -1].forEach(side => { + const swMesh = new THREE.Mesh(swGeo, sidewalkMat); + swMesh.position.set(road.x + side * (halfW + sw / 2), 0.075, cz); + swMesh.receiveShadow = true; + this.scene.add(swMesh); + }); + + const curbGeo = new THREE.BoxGeometry(0.15, 0.2, len); + [halfW, halfW + sw, -halfW, -(halfW + sw)].forEach(offset => { + const curb = new THREE.Mesh(curbGeo, curbMat); + curb.position.set(road.x + offset, 0.1, cz); + this.scene.add(curb); + }); + } + } + } + } + + createRoad(x, y, z, w, h, rot) { + const roadGeo = new THREE.PlaneGeometry(w, h); + const roadMat = new THREE.MeshStandardMaterial({ color: 0x2a2a2a, roughness: 0.85 }); + const road = new THREE.Mesh(roadGeo, roadMat); + road.rotation.x = -Math.PI / 2; + road.rotation.z = rot; + road.position.set(x, y, z); + road.receiveShadow = true; + this.scene.add(road); + + // Пунктирная разметка по центру дороги + const lineW = 1.5; + const lineGap = 3; + const roadLen = rot === 0 ? w : h; + const step = lineW + lineGap; + const count = Math.floor(roadLen / step); + const totalLen = count * step; + const lineMat = new THREE.MeshStandardMaterial({ color: 0xffffcc }); + + for (let i = 0; i < count; i++) { + const lineGeo = new THREE.PlaneGeometry(lineW, 0.15); + const line = new THREE.Mesh(lineGeo, lineMat); + line.rotation.x = -Math.PI / 2; + line.rotation.z = rot; + const offset = -totalLen / 2 + i * step + lineW / 2; + if (rot === 0) { + line.position.set(x + offset, y + 0.01, z); + } else { + line.position.set(x, y + 0.01, z + offset); + } + this.scene.add(line); + } + } + + createLighting() { + this.ambientLight = new THREE.AmbientLight(0xffffff, 0.4); + this.scene.add(this.ambientLight); + + this.sunLight = new THREE.DirectionalLight(0xffffff, 1.0); + this.sunLight.position.set(50, 80, 30); + this.sunLight.castShadow = true; + this.sunLight.shadow.mapSize.set(2048, 2048); + this.sunLight.shadow.camera.left = -120; + this.sunLight.shadow.camera.right = 120; + this.sunLight.shadow.camera.top = 120; + this.sunLight.shadow.camera.bottom = -120; + this.sunLight.shadow.camera.near = 1; + this.sunLight.shadow.camera.far = 350; + this.scene.add(this.sunLight); + + const hemiLight = new THREE.HemisphereLight(0x87CEEB, 0x444444, 0.3); + this.scene.add(hemiLight); + } + + updateLighting(gameTime) { + const hour = gameTime / 60; + let sunIntensity, ambientIntensity, exposure; + let skyColor; + + if (hour >= 5 && hour < 7) { + const t = (hour - 5) / 2; + sunIntensity = THREE.MathUtils.lerp(0.05, 0.8, t); + ambientIntensity = THREE.MathUtils.lerp(0.08, 0.35, t); + exposure = THREE.MathUtils.lerp(0.25, 0.9, t); + skyColor = new THREE.Color().lerpColors(new THREE.Color(0x1a1a3e), new THREE.Color(0xff9966), t); + } else if (hour >= 7 && hour < 9) { + const t = (hour - 7) / 2; + sunIntensity = THREE.MathUtils.lerp(0.8, 1.0, t); + ambientIntensity = THREE.MathUtils.lerp(0.35, 0.4, t); + exposure = THREE.MathUtils.lerp(0.9, 1.0, t); + skyColor = new THREE.Color().lerpColors(new THREE.Color(0xff9966), new THREE.Color(0x87CEEB), t); + } else if (hour >= 9 && hour < 17) { + sunIntensity = 1.0; + ambientIntensity = 0.4; + exposure = 1.0; + skyColor = new THREE.Color(0x87CEEB); + } else if (hour >= 17 && hour < 19) { + const t = (hour - 17) / 2; + sunIntensity = THREE.MathUtils.lerp(1.0, 0.5, t); + ambientIntensity = THREE.MathUtils.lerp(0.4, 0.2, t); + exposure = THREE.MathUtils.lerp(1.0, 0.6, t); + skyColor = new THREE.Color().lerpColors(new THREE.Color(0x87CEEB), new THREE.Color(0xff6633), t); + } else if (hour >= 19 && hour < 21) { + const t = (hour - 19) / 2; + sunIntensity = THREE.MathUtils.lerp(0.5, 0.05, t); + ambientIntensity = THREE.MathUtils.lerp(0.2, 0.08, t); + exposure = THREE.MathUtils.lerp(0.6, 0.25, t); + skyColor = new THREE.Color().lerpColors(new THREE.Color(0xff6633), new THREE.Color(0x0a0a1e), t); + } else { + sunIntensity = 0.05; + ambientIntensity = 0.08; + exposure = 0.25; + skyColor = new THREE.Color(0x0a0a1e); + } + + this.sunLight.intensity = sunIntensity; + this.ambientLight.intensity = ambientIntensity; + this.game.renderer.toneMappingExposure = exposure; + this.scene.background = skyColor; + if (this.scene.fog) this.scene.fog.color = skyColor; + + const sunAngle = ((hour - 6) / 12) * Math.PI; + this.sunLight.position.set(Math.cos(sunAngle) * 60, Math.sin(sunAngle) * 80, 30); + } + + createBuildings() { + const configs = (this.mapConfig?.buildings || [ + { x: -45, z: -22, w: 14, h: 20, d: 10, color: 0x8b7355 }, + { x: -30, z: -22, w: 10, h: 15, d: 10, color: 0x696969 }, + { x: -15, z: -22, w: 12, h: 18, d: 10, color: 0x7b6b55 }, + { x: 15, z: -22, w: 12, h: 22, d: 10, color: 0x5b5b6b }, + { x: 30, z: -22, w: 14, h: 16, d: 10, color: 0x6b6b55 }, + { x: 40, z: -22, w: 10, h: 20, d: 10, color: 0x556b6b }, + { x: -40, z: -55, w: 16, h: 28, d: 12, color: 0x556b7b }, + { x: -20, z: -55, w: 12, h: 18, d: 10, color: 0x6b6b5b }, + { x: 20, z: -55, w: 10, h: 14, d: 10, color: 0x6b7b5b }, + { x: 38, z: -55, w: 14, h: 20, d: 12, color: 0x7b5b6b }, + { x: -45, z: 25, w: 16, h: 16, d: 10, color: 0x7b5b5b }, + { x: -30, z: 25, w: 10, h: 12, d: 10, color: 0x5b7b6b }, + { x: 15, z: 25, w: 12, h: 22, d: 10, color: 0x5b6b7b }, + { x: 30, z: 25, w: 14, h: 18, d: 10, color: 0x6b5b7b }, + { x: 42, z: 25, w: 10, h: 14, d: 10, color: 0x7b7b5b }, + { x: -45, z: 65, w: 14, h: 14, d: 10, color: 0x6b5b5b }, + { x: 15, z: 65, w: 12, h: 16, d: 10, color: 0x5b6b5b }, + ]).map(b => ({ + x: b.x, z: b.z, w: b.w, h: b.h, d: b.d, + color: typeof b.color === 'string' ? parseInt(b.color.replace('#',''), 16) : b.color + })); + + configs.forEach(cfg => { + this.createBuilding(cfg.x, cfg.z, cfg.w, cfg.h, cfg.d, cfg.color); + this.buildingRects.push({ x: cfg.x, z: cfg.z, w: cfg.w, d: cfg.d }); + }); + } + + createBuilding(x, z, w, h, d, color) { + const group = new THREE.Group(); + + const bodyGeo = new THREE.BoxGeometry(w, h, d); + const bodyMat = new THREE.MeshStandardMaterial({ color, roughness: 0.8 }); + const body = new THREE.Mesh(bodyGeo, bodyMat); + body.position.y = h / 2; + body.castShadow = true; + body.receiveShadow = true; + group.add(body); + + // Окна (с рандомным свечением — кто-то дома, кто-то нет) + const winW = 1.2, winH = 1.8; + const floors = Math.floor(h / 4); + const cols = Math.floor(w / 3.5); + + for (let f = 0; f < floors; f++) { + for (let c = 0; c < cols; c++) { + const lit = Math.random() > 0.4; + const winMat = new THREE.MeshStandardMaterial({ + color: lit ? 0xffeebb : 0x556677, + emissive: lit ? 0x554422 : 0x112233, + emissiveIntensity: lit ? 0.4 : 0.1, + roughness: 0.1, + metalness: 0.8 + }); + + const winGeo = new THREE.PlaneGeometry(winW, winH); + const wx = -w / 2 + 2 + c * 3.5; + const wy = 3 + f * 4; + + const win1 = new THREE.Mesh(winGeo, winMat); + win1.position.set(wx, wy, d / 2 + 0.01); + group.add(win1); + + const win2 = new THREE.Mesh(winGeo, winMat); + win2.position.set(wx, wy, -d / 2 - 0.01); + win2.rotation.y = Math.PI; + group.add(win2); + } + } + + // Крыша + const roofGeo = new THREE.BoxGeometry(w + 0.5, 0.5, d + 0.5); + const roofMat = new THREE.MeshStandardMaterial({ color: 0x3a3a3a }); + const roof = new THREE.Mesh(roofGeo, roofMat); + roof.position.y = h; + roof.castShadow = true; + group.add(roof); + + // Дверь (на одном случайном фасаде) + const doorGeo = new THREE.BoxGeometry(1.5, 2.5, 0.1); + const doorMat = new THREE.MeshStandardMaterial({ color: 0x5c3a1e }); + const door = new THREE.Mesh(doorGeo, doorMat); + door.position.set(0, 1.25, d / 2 + 0.06); + group.add(door); + + group.position.set(x, 0, z); + this.scene.add(group); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(x - w / 2, 0, z - d / 2), + new THREE.Vector3(x + w / 2, h, z + d / 2) + )); + + } + + createPark() { + const parkCfg = this.mapConfig?.structures?.park || { x: -30, z: 25, radius: 18 }; + const parkX = parkCfg.x; + const parkZ = parkCfg.z; + const parkRadius = parkCfg.radius; + + const parkGeo = new THREE.CircleGeometry(parkRadius, 32); + const parkMat = new THREE.MeshStandardMaterial({ color: 0x3a7d3a, roughness: 0.9 }); + const park = new THREE.Mesh(parkGeo, parkMat); + park.rotation.x = -Math.PI / 2; + park.position.set(parkX, 0.02, parkZ); + park.receiveShadow = true; + this.scene.add(park); + + // Деревья (больше) + const treePositions = [ + [parkX + 8, parkZ - 5], [parkX - 8, parkZ - 5], [parkX, parkZ + 10], [parkX + 10, parkZ + 7], + [parkX - 10, parkZ + 5], [parkX + 5, parkZ - 7], [parkX - 5, parkZ + 13], [parkX - 12, parkZ - 3], + [parkX + 12, parkZ + 3], [parkX - 8, parkZ - 10], + ]; + treePositions.forEach(([tx, tz]) => this.createTree(tx, tz)); + + // Кусты + const bushPositions = [ + [parkX + 5, parkZ - 5], [parkX - 5, parkZ - 1], [parkX - 10, parkZ + 7], [parkX + 10, parkZ + 10], + [parkX + 12, parkZ - 1], [parkX - 2, parkZ + 13], + ]; + bushPositions.forEach(([bx, bz]) => this.createBush(bx, bz)); + + // Дорожки парка + const pathMat = new THREE.MeshStandardMaterial({ color: 0x8b7355, roughness: 0.9 }); + + const path1 = new THREE.Mesh(new THREE.PlaneGeometry(2, 30), pathMat); + path1.rotation.x = -Math.PI / 2; + path1.position.set(parkX, 0.03, parkZ); + this.scene.add(path1); + + const path2 = new THREE.Mesh(new THREE.PlaneGeometry(20, 2), pathMat); + path2.rotation.x = -Math.PI / 2; + path2.position.set(parkX, 0.03, parkZ); + this.scene.add(path2); + + // Клумба в центре + const flowerGeo = new THREE.CircleGeometry(3, 16); + const flowerMat = new THREE.MeshStandardMaterial({ color: 0x228822 }); + const flowerBed = new THREE.Mesh(flowerGeo, flowerMat); + flowerBed.rotation.x = -Math.PI / 2; + flowerBed.position.set(parkX, 0.04, parkZ); + this.scene.add(flowerBed); + + // Цветы + const colors = [0xff4444, 0xffff44, 0xff88ff, 0xffaa22]; + for (let i = 0; i < 12; i++) { + const angle = (i / 12) * Math.PI * 2; + const r = 1.5 + Math.random(); + const fGeo = new THREE.SphereGeometry(0.15, 6, 4); + const fMat = new THREE.MeshStandardMaterial({ + color: colors[Math.floor(Math.random() * colors.length)], + emissive: 0x222200, + emissiveIntensity: 0.1 + }); + const flower = new THREE.Mesh(fGeo, fMat); + flower.position.set( + parkX + Math.cos(angle) * r, + 0.15, + parkZ + Math.sin(angle) * r + ); + this.scene.add(flower); + } + } + + createTree(x, z) { + const group = new THREE.Group(); + const h = 2.5 + Math.random() * 1.5; + const crownR = 2 + Math.random() * 1; + + const trunkGeo = new THREE.CylinderGeometry(0.25, 0.45, h, 8); + const trunkMat = new THREE.MeshStandardMaterial({ color: 0x5c3a1e }); + const trunk = new THREE.Mesh(trunkGeo, trunkMat); + trunk.position.y = h / 2; + trunk.castShadow = true; + group.add(trunk); + + const crownGeo = new THREE.SphereGeometry(crownR, 8, 6); + const shade = 0x1d5b1d + Math.floor(Math.random() * 0x102010); + const crownMat = new THREE.MeshStandardMaterial({ color: shade, roughness: 0.9 }); + const crown = new THREE.Mesh(crownGeo, crownMat); + crown.position.y = h + crownR * 0.6; + crown.castShadow = true; + group.add(crown); + + group.position.set(x, 0, z); + this.scene.add(group); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(x - 0.5, 0, z - 0.5), + new THREE.Vector3(x + 0.5, 6, z + 0.5) + )); + } + + createBush(x, z) { + const bushGeo = new THREE.SphereGeometry(0.8 + Math.random() * 0.4, 6, 5); + const bushMat = new THREE.MeshStandardMaterial({ color: 0x2a6b2a, roughness: 0.95 }); + const bush = new THREE.Mesh(bushGeo, bushMat); + bush.position.set(x, 0.5, z); + bush.scale.y = 0.7; + bush.castShadow = true; + this.scene.add(bush); + } + + createDumpsters() { + const positions = this.mapConfig?.interactables?.dumpsters || [ + { x: -20, z: -15, rot: 0 }, + { x: 25, z: -15, rot: 0.3 }, + { x: -45, z: 12, rot: -0.2 }, + { x: 35, z: 12, rot: 0.5 }, + { x: -25, z: 35, rot: 0.1 }, + { x: 20, z: -48, rot: 0 }, + { x: -40, z: -48, rot: 0.4 }, + { x: 25, z: 35, rot: -0.3 }, + ]; + + positions.forEach(pos => { + const dumpster = this.createDumpster(pos.x, pos.z, pos.rot); + this.interactables.push({ + type: 'dumpster', + mesh: dumpster, + position: new THREE.Vector3(pos.x, 0, pos.z), + radius: 3, + label: 'Обыскать мусорку', + searched: false, + searchCooldown: 0 + }); + }); + } + + createDumpster(x, z, rot) { + const group = new THREE.Group(); + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x2e5b2e, roughness: 0.7 }); + + const body = new THREE.Mesh(new THREE.BoxGeometry(2, 1.4, 1.2), bodyMat); + body.position.y = 0.7; + body.castShadow = true; + group.add(body); + + const lid = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.1, 1.3), bodyMat); + lid.position.set(0, 1.45, 0); + group.add(lid); + + // Немного мусора вокруг + const trashMat = new THREE.MeshStandardMaterial({ color: 0x555544 }); + for (let i = 0; i < 3; i++) { + const t = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.1, 0.15), trashMat); + t.position.set((Math.random() - 0.5) * 2.5, 0.05, (Math.random() - 0.5) * 2); + t.rotation.y = Math.random() * Math.PI; + group.add(t); + } + + group.position.set(x, 0, z); + group.rotation.y = rot; + this.scene.add(group); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(x - 1, 0, z - 0.6), + new THREE.Vector3(x + 1, 1.5, z + 0.6) + )); + + return group; + } + + createBenches() { + const positions = this.mapConfig?.interactables?.benches || [ + { x: -30, z: 20, rot: 0 }, + { x: -25, z: 32, rot: Math.PI }, + { x: 10, z: 12, rot: Math.PI }, + { x: -15, z: -12, rot: 0 }, + { x: 35, z: 12, rot: Math.PI }, + { x: -35, z: 35, rot: Math.PI }, + ]; + + positions.forEach(pos => { + const bench = this.createBench(pos.x, pos.z, pos.rot); + this.interactables.push({ + type: 'bench', + mesh: bench, + position: new THREE.Vector3(pos.x, 0, pos.z), + radius: 2.5, + label: 'Отдохнуть на скамейке' + }); + }); + } + + createBench(x, z, rot) { + const group = new THREE.Group(); + const woodMat = new THREE.MeshStandardMaterial({ color: 0x8b6914, roughness: 0.85 }); + const metalMat = new THREE.MeshStandardMaterial({ color: 0x444444, metalness: 0.6 }); + + const seat = new THREE.Mesh(new THREE.BoxGeometry(2, 0.1, 0.6), woodMat); + seat.position.y = 0.5; + seat.castShadow = true; + group.add(seat); + + const back = new THREE.Mesh(new THREE.BoxGeometry(2, 0.6, 0.08), woodMat); + back.position.set(0, 0.85, -0.28); + back.rotation.x = -0.15; + group.add(back); + + [-0.8, 0.8].forEach(lx => { + const leg = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.5, 0.6), metalMat); + leg.position.set(lx, 0.25, 0); + group.add(leg); + }); + + group.position.set(x, 0, z); + group.rotation.y = rot; + this.scene.add(group); + return group; + } + + createShop() { + const group = new THREE.Group(); + const shopCfg = this.mapConfig?.structures?.shop || {}; + const shopX = shopCfg.x ?? -25, shopZ = shopCfg.z ?? -12; + const sw = shopCfg.w ?? 10, sh = shopCfg.h ?? 5, sd = shopCfg.d ?? 8; + const hw = sw / 2, hd = sd / 2; + + const bodyMat = new THREE.MeshStandardMaterial({ color: 0xaa8855, roughness: 0.7 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(sw, sh, sd), bodyMat); + body.position.y = sh / 2; + body.castShadow = true; + body.receiveShadow = true; + group.add(body); + + // Вывеска с подсветкой + const signMat = new THREE.MeshStandardMaterial({ + color: 0x2244aa, + emissive: 0x2244aa, + emissiveIntensity: 0.6 + }); + const sign = new THREE.Mesh(new THREE.BoxGeometry(sw * 0.6, 1.2, 0.2), signMat); + sign.position.set(0, sh - 0.2, hd + 0.1); + group.add(sign); + + // Свет вывески + const signLight = new THREE.PointLight(0x4466ff, 0.5, 8); + signLight.position.set(0, sh - 0.5, hd + 1); + group.add(signLight); + + const door = new THREE.Mesh( + new THREE.BoxGeometry(1.8, 2.8, 0.15), + new THREE.MeshStandardMaterial({ color: 0x6b4226 }) + ); + door.position.set(0, 1.4, hd + 0.05); + group.add(door); + + // Витрины + const winMat = new THREE.MeshStandardMaterial({ + color: 0x88ccff, transparent: true, opacity: 0.5, roughness: 0.1 + }); + [-hw * 0.7, hw * 0.7].forEach(wx => { + const win = new THREE.Mesh(new THREE.PlaneGeometry(2.5, 2), winMat); + win.position.set(wx, sh / 2, hd + 0.01); + group.add(win); + }); + + // Навес + const awningMat = new THREE.MeshStandardMaterial({ color: 0xcc3333 }); + const awning = new THREE.Mesh(new THREE.BoxGeometry(sw + 0.5, 0.1, 2), awningMat); + awning.position.set(0, sh - 1, hd + 1); + group.add(awning); + + group.position.set(shopX, 0, shopZ); + this.scene.add(group); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(shopX - hw, 0, shopZ - hd), + new THREE.Vector3(shopX + hw, sh, shopZ + hd) + )); + + this.buildingRects.push({ x: shopX, z: shopZ, w: sw, d: sd }); + + this.interactables.push({ + type: 'shop', + mesh: group, + position: new THREE.Vector3(shopX, 0, shopZ + hd + 1), + radius: 3, + label: 'Войти в магазин' + }); + } + + createShelter() { + const group = new THREE.Group(); + const shelterCfg = this.mapConfig?.structures?.shelter || {}; + const sx = shelterCfg.x ?? -35, sz = shelterCfg.z ?? 35; + const sw = shelterCfg.w ?? 8, sd = shelterCfg.d ?? 6; + const hw = sw / 2, hd = sd / 2; + const wallH = 3.5; + + const wallMat = new THREE.MeshStandardMaterial({ color: 0x6b5b4b, roughness: 0.9 }); + + const backWall = new THREE.Mesh(new THREE.BoxGeometry(sw, wallH, 0.3), wallMat); + backWall.position.set(0, wallH / 2, -hd); + backWall.castShadow = true; + group.add(backWall); + + [-hw, hw].forEach(sideX => { + const sideWall = new THREE.Mesh(new THREE.BoxGeometry(0.3, wallH, sd), wallMat); + sideWall.position.set(sideX, wallH / 2, 0); + sideWall.castShadow = true; + group.add(sideWall); + }); + + const roof = new THREE.Mesh(new THREE.BoxGeometry(sw + 1, 0.2, sd + 1), wallMat); + roof.position.y = wallH + 0.1; + roof.castShadow = true; + group.add(roof); + + // Матрас + подушка + const mat = new THREE.Mesh( + new THREE.BoxGeometry(2, 0.2, 3), + new THREE.MeshStandardMaterial({ color: 0x555577 }) + ); + mat.position.set(-1, 0.1, -0.5); + group.add(mat); + + const pillow = new THREE.Mesh( + new THREE.BoxGeometry(0.6, 0.15, 0.4), + new THREE.MeshStandardMaterial({ color: 0x666688 }) + ); + pillow.position.set(-1, 0.25, -1.8); + group.add(pillow); + + // Коробки + const boxMat = new THREE.MeshStandardMaterial({ color: 0x8b6914 }); + const box1 = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.6, 0.6), boxMat); + box1.position.set(hw - 1, 0.3, -2); + group.add(box1); + + const box2 = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.4, 0.5), boxMat); + box2.position.set(hw - 0.7, 0.2, -1.2); + group.add(box2); + + // Костёр + камни + const fireGeo = new THREE.ConeGeometry(0.3, 0.6, 6); + const fireMat = new THREE.MeshStandardMaterial({ + color: 0xff6600, + emissive: 0xff4400, + emissiveIntensity: 1.5 + }); + const fire = new THREE.Mesh(fireGeo, fireMat); + fire.position.set(hw - 2, 0.3, 0); + group.add(fire); + + // Камни вокруг костра + const stoneMat = new THREE.MeshStandardMaterial({ color: 0x666666 }); + for (let i = 0; i < 6; i++) { + const angle = (i / 6) * Math.PI * 2; + const stone = new THREE.Mesh( + new THREE.SphereGeometry(0.15, 5, 4), + stoneMat + ); + stone.position.set(hw - 2 + Math.cos(angle) * 0.5, 0.1, Math.sin(angle) * 0.5); + stone.scale.y = 0.6; + group.add(stone); + } + + // Свет от костра + const fireLight = new THREE.PointLight(0xff6600, 2, 12); + fireLight.position.set(hw - 2, 1, 0); + fireLight.castShadow = true; + group.add(fireLight); + + group.position.set(sx, 0, sz); + group.rotation.y = Math.PI; // открыто на юг, к парку + this.scene.add(group); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(sx - hw - 0.2, 0, sz - hd - 0.2), + new THREE.Vector3(sx + hw + 0.2, wallH + 0.1, sz + hd + 0.2) + )); + + this.interactables.push({ + type: 'shelter', + mesh: group, + position: new THREE.Vector3(sx, 0, sz - 1), + radius: 5, + label: 'Поспать в укрытии' + }); + + // Костёр: локальный (hw-2, 0, 0), группа повёрнута на PI → мировая позиция (sx - (hw-2), 0, sz) + this.interactables.push({ + type: 'campfire', + mesh: fire, + position: new THREE.Vector3(sx - (hw - 2), 0, sz), + radius: 3, + label: 'Погреться у костра' + }); + } + + createTrashPiles() { + const positions = this.mapConfig?.interactables?.trashPiles || [ + { x: -40, z: 12 }, + { x: 35, z: -15 }, + { x: -10, z: -38 }, + { x: 25, z: 38 }, + ]; + + positions.forEach(pos => { + const group = new THREE.Group(); + const mats = [0x555544, 0x444433, 0x554444, 0x445544]; + + for (let i = 0; i < 8; i++) { + const size = 0.2 + Math.random() * 0.4; + const geo = new THREE.BoxGeometry(size, size * 0.5, size); + const mat = new THREE.MeshStandardMaterial({ + color: mats[Math.floor(Math.random() * mats.length)] + }); + const piece = new THREE.Mesh(geo, mat); + piece.position.set( + (Math.random() - 0.5) * 1.5, + size * 0.25, + (Math.random() - 0.5) * 1.5 + ); + piece.rotation.y = Math.random() * Math.PI; + group.add(piece); + } + + group.position.set(pos.x, 0, pos.z); + this.scene.add(group); + + this.interactables.push({ + type: 'trashpile', + mesh: group, + position: new THREE.Vector3(pos.x, 0, pos.z), + radius: 2.5, + label: 'Порыться в мусоре', + searchCooldown: 0 + }); + }); + } + + createFountain() { + const group = new THREE.Group(); + const cfg = this.mapConfig?.structures?.fountain || {}; + const fx = cfg.x ?? -30, fz = cfg.z ?? 25; + + // База + const baseMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.7 }); + const base = new THREE.Mesh(new THREE.CylinderGeometry(1.5, 1.8, 0.5, 16), baseMat); + base.position.y = 0.25; + group.add(base); + + // Колонна + const column = new THREE.Mesh(new THREE.CylinderGeometry(0.2, 0.25, 1.2, 8), baseMat); + column.position.y = 1.1; + group.add(column); + + // Верхняя чаша + const bowlGeo = new THREE.SphereGeometry(0.5, 12, 6, 0, Math.PI * 2, 0, Math.PI / 2); + const bowl = new THREE.Mesh(bowlGeo, baseMat); + bowl.position.y = 1.7; + bowl.rotation.x = Math.PI; + group.add(bowl); + + // Вода + const waterMat = new THREE.MeshStandardMaterial({ + color: 0x3388bb, + transparent: true, + opacity: 0.6, + roughness: 0.1, + metalness: 0.3 + }); + const water = new THREE.Mesh(new THREE.CircleGeometry(1.3, 16), waterMat); + water.rotation.x = -Math.PI / 2; + water.position.y = 0.48; + group.add(water); + + group.position.set(fx, 0, fz); + this.scene.add(group); + + this.interactables.push({ + type: 'fountain', + mesh: group, + position: new THREE.Vector3(fx, 0, fz), + radius: 2.5, + label: 'Попить воды' + }); + } + + createPhoneBooth() { + const group = new THREE.Group(); + const cfg = this.mapConfig?.structures?.phoneBooth || {}; + const px = cfg.x ?? 10, pz = cfg.z ?? -8; + const pw = cfg.w ?? 1.2, pd = cfg.d ?? 1.2; + const hw = pw / 2, hd = pd / 2; + const boothH = 2.5; + + const boothMat = new THREE.MeshStandardMaterial({ color: 0x2244aa }); + const booth = new THREE.Mesh(new THREE.BoxGeometry(pw, boothH, pd), boothMat); + booth.position.y = boothH / 2; + booth.castShadow = true; + group.add(booth); + + const phoneMat = new THREE.MeshStandardMaterial({ color: 0x333333 }); + const phone = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.3, 0.1), phoneMat); + phone.position.set(0, 1.5, hd - 0.04); + group.add(phone); + + group.position.set(px, 0, pz); + this.scene.add(group); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(px - hw, 0, pz - hd), + new THREE.Vector3(px + hw, boothH, pz + hd) + )); + + this.interactables.push({ + type: 'phone', + mesh: group, + position: new THREE.Vector3(px, 0, pz + 1), + radius: 2, + label: 'Таксофон' + }); + } + + createParkingLot() { + const cfg = this.mapConfig?.structures?.parking || {}; + const lotX = cfg.x ?? 35, lotZ = cfg.z ?? 12; + const lotW = cfg.w ?? 20, lotD = cfg.d ?? 15; + const lot = new THREE.Mesh( + new THREE.PlaneGeometry(lotW, lotD), + new THREE.MeshStandardMaterial({ color: 0x2a2a2a }) + ); + lot.rotation.x = -Math.PI / 2; + lot.position.set(lotX, 0.01, lotZ); + lot.receiveShadow = true; + this.scene.add(lot); + + // Разметка парковки (относительно центра) + const lineMat = new THREE.MeshStandardMaterial({ color: 0xeeeeee }); + const slotW = 4; + const lineCount = Math.floor(lotW / slotW) + 1; + for (let i = 0; i < lineCount; i++) { + const line = new THREE.Mesh(new THREE.PlaneGeometry(0.1, lotD * 0.35), lineMat); + line.rotation.x = -Math.PI / 2; + line.position.set(lotX - lotW / 2 + i * slotW, 0.02, lotZ); + this.scene.add(line); + } + + // Машины на парковке (в центрах слотов) + const carConfigs = [ + { x: lotX - lotW / 2 + slotW / 2, z: lotZ, color: 0xcc2222 }, + { x: lotX, z: lotZ, color: 0x2255cc }, + { x: lotX + lotW / 2 - slotW / 2, z: lotZ, color: 0x888888 }, + ]; + + carConfigs.forEach(cfg => { + const car = this.carModel + ? this.carModel.clone() + : this.createCarMeshFallback(cfg.color); + + car.position.set(cfg.x, 0, cfg.z); + if (this.carModel) car.rotation.y = this.carModelBaseRotation; + this.scene.add(car); + + this.parkedCars.push({ mesh: car, x: cfg.x, z: cfg.z, rotY: 0 }); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(cfg.x - 1, 0, cfg.z - 2), + new THREE.Vector3(cfg.x + 1, 2, cfg.z + 2) + )); + }); + } + + createBusStop() { + const group = new THREE.Group(); + const cfg = this.mapConfig?.structures?.busStop || {}; + const bx = cfg.x ?? -20, bz = cfg.z ?? 7; + const bw = cfg.w ?? 5, bd = cfg.d ?? 2; + const bhw = bw / 2, bhd = bd / 2; + + // Крыша + const roofMat = new THREE.MeshStandardMaterial({ color: 0x336699, transparent: true, opacity: 0.7 }); + const roof = new THREE.Mesh(new THREE.BoxGeometry(bw, 0.1, bd), roofMat); + roof.position.y = 2.8; + group.add(roof); + + // Столбы + const poleMat = new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.5 }); + [-(bhw - 0.2), bhw - 0.2].forEach(px => { + const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 2.8, 6), poleMat); + pole.position.set(px, 1.4, -(bhd - 0.1)); + group.add(pole); + }); + + // Задняя стенка + const backMat = new THREE.MeshStandardMaterial({ color: 0x336699, transparent: true, opacity: 0.4 }); + const back = new THREE.Mesh(new THREE.BoxGeometry(bw, 2.5, 0.05), backMat); + back.position.set(0, 1.35, -(bhd - 0.05)); + group.add(back); + + // Скамейка внутри + const benchMat = new THREE.MeshStandardMaterial({ color: 0x888888 }); + const bench = new THREE.Mesh(new THREE.BoxGeometry(bw * 0.6, 0.08, 0.5), benchMat); + bench.position.set(0, 0.5, -0.5); + group.add(bench); + + group.position.set(bx, 0, bz); + group.rotation.y = Math.PI; // лицом к дороге + this.scene.add(group); + + this.interactables.push({ + type: 'bench', + mesh: group, + position: new THREE.Vector3(bx, 0, bz), + radius: 3, + label: 'Посидеть на остановке' + }); + } + + createStreetLamps() { + const positions = this.mapConfig?.decorations?.lamps || [ + [-20, -7], [0, -7], [20, -7], [-40, -7], [40, -7], + [-20, 7], [0, 7], [20, 7], [40, 7], + [-20, -44], [0, -44], [20, -44], [-40, -44], + [-20, 48], [0, 48], [20, 48], + [48, -20], [48, 20], + [-58, -20], [-58, 20], + ]; + + // Дороги для определения направления фонарей + const lampRoads = this.mapConfig?.roads || []; + + positions.forEach(([x, z]) => { + const group = new THREE.Group(); + + const poleMat = new THREE.MeshStandardMaterial({ color: 0x444444, metalness: 0.6 }); + const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.12, 5, 8), poleMat); + pole.position.y = 2.5; + group.add(pole); + + // Горизонтальный кронштейн + const arm = new THREE.Mesh(new THREE.BoxGeometry(1.5, 0.06, 0.06), poleMat); + arm.position.set(0.75, 5, 0); + group.add(arm); + + const lampGeo = new THREE.BoxGeometry(0.5, 0.15, 0.3); + const lampMat = new THREE.MeshStandardMaterial({ + color: 0xffffcc, + emissive: 0xffdd88, + emissiveIntensity: 0.5 + }); + const lamp = new THREE.Mesh(lampGeo, lampMat); + lamp.position.set(1.5, 4.9, 0); + group.add(lamp); + + const light = new THREE.PointLight(0xffdd88, 0.6, 18); + light.position.set(1.5, 4.8, 0); + group.add(light); + + // Поворот кронштейна к ближайшей дороге + let minDist = Infinity; + let rotY = 0; + for (const rd of lampRoads) { + const isEW = Math.abs(rd.rotation) < 0.1; + let dist; + if (isEW) { + const xMin = rd.x - rd.width / 2; + const xMax = rd.x + rd.width / 2; + if (x < xMin - 5 || x > xMax + 5) continue; + dist = Math.abs(z - rd.z); + } else { + const zMin = rd.z - rd.height / 2; + const zMax = rd.z + rd.height / 2; + if (z < zMin - 5 || z > zMax + 5) continue; + dist = Math.abs(x - rd.x); + } + if (dist < minDist) { + minDist = dist; + const dx = isEW ? 0 : rd.x - x; + const dz = isEW ? rd.z - z : 0; + rotY = Math.atan2(dz, dx); + } + } + + group.position.set(x, 0, z); + group.rotation.y = rotY; + this.scene.add(group); + }); + } + + createDecorations() { + // Мусор на земле (больше) + const trashGeo = new THREE.BoxGeometry(0.3, 0.08, 0.2); + const trashColors = [0x666666, 0x555544, 0x665544, 0x444455]; + for (let i = 0; i < 60; i++) { + const trashMat = new THREE.MeshStandardMaterial({ + color: trashColors[Math.floor(Math.random() * trashColors.length)] + }); + const trash = new THREE.Mesh(trashGeo, trashMat); + trash.position.set( + (Math.random() - 0.5) * 120, + 0.04, + (Math.random() - 0.5) * 120 + ); + trash.rotation.y = Math.random() * Math.PI; + trash.scale.set(0.5 + Math.random(), 1, 0.5 + Math.random()); + this.scene.add(trash); + } + + // Забор парка + const parkCfg = this.mapConfig?.structures?.park || { x: -30, z: 25, radius: 18 }; + const fenceMat = new THREE.MeshStandardMaterial({ color: 0x333333, metalness: 0.5 }); + for (let i = 0; i < 24; i++) { + const angle = (i / 24) * Math.PI * 2; + if (i >= 9 && i <= 12) continue; // Проход + const fx = parkCfg.x + Math.cos(angle) * (parkCfg.radius + 1); + const fz = parkCfg.z + Math.sin(angle) * (parkCfg.radius + 1); + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1, 6), fenceMat); + post.position.set(fx, 0.5, fz); + this.scene.add(post); + + // Горизонтальные перекладины + if (i < 23) { + const nextAngle = ((i + 1) / 24) * Math.PI * 2; + const nx = parkCfg.x + Math.cos(nextAngle) * (parkCfg.radius + 1); + const nz = parkCfg.z + Math.sin(nextAngle) * (parkCfg.radius + 1); + const len = Math.sqrt((nx - fx) ** 2 + (nz - fz) ** 2); + const bar = new THREE.Mesh(new THREE.BoxGeometry(len, 0.03, 0.03), fenceMat); + bar.position.set((fx + nx) / 2, 0.7, (fz + nz) / 2); + bar.rotation.y = Math.atan2(nx - fx, nz - fz) + Math.PI / 2; + this.scene.add(bar); + } + } + + // Пожарные гидранты + const hydrants = this.mapConfig?.decorations?.hydrants || [[-18,-9],[25,-9],[35,8]]; + const hydrantMat = new THREE.MeshStandardMaterial({ color: 0xcc2222 }); + hydrants.forEach(([hx, hz]) => { + const hydrant = new THREE.Mesh(new THREE.CylinderGeometry(0.15, 0.2, 0.7, 8), hydrantMat); + hydrant.position.set(hx, 0.35, hz); + this.scene.add(hydrant); + }); + + // Урны + const bins = this.mapConfig?.decorations?.bins || [[-8,-9],[8,-9],[25,9],[-25,9]]; + const binMat = new THREE.MeshStandardMaterial({ color: 0x444444 }); + bins.forEach(([bx, bz]) => { + const bin = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.2, 0.8, 8), binMat); + bin.position.set(bx, 0.4, bz); + this.scene.add(bin); + }); + } + + createChurch() { + const group = new THREE.Group(); + const cfg = this.mapConfig?.structures?.church || {}; + const cx = cfg.x ?? 30, cz = cfg.z ?? 60; + const cw = cfg.w ?? 10, ch = cfg.h ?? 8, cd = cfg.d ?? 14; + const hw = cw / 2, hd = cd / 2; + + const wallMat = new THREE.MeshStandardMaterial({ color: 0xccbb99, roughness: 0.8 }); + + // Основное здание + const body = new THREE.Mesh(new THREE.BoxGeometry(cw, ch, cd), wallMat); + body.position.y = ch / 2; + body.castShadow = true; + body.receiveShadow = true; + group.add(body); + + // Крыша (двускатная) + const roofGeo = new THREE.ConeGeometry(Math.max(hw, hd), 3, 4); + const roofMat = new THREE.MeshStandardMaterial({ color: 0x554433 }); + const roof = new THREE.Mesh(roofGeo, roofMat); + roof.position.y = ch + 1.5; + roof.rotation.y = Math.PI / 4; + roof.castShadow = true; + group.add(roof); + + // Колокольня + const tower = new THREE.Mesh(new THREE.BoxGeometry(3, 6, 3), wallMat); + tower.position.set(0, ch + 3, -hd + 3); + tower.castShadow = true; + group.add(tower); + + const towerRoof = new THREE.Mesh(new THREE.ConeGeometry(2.5, 3, 4), roofMat); + towerRoof.position.set(0, ch + 7.5, -hd + 3); + towerRoof.rotation.y = Math.PI / 4; + group.add(towerRoof); + + // Крест + const crossMat = new THREE.MeshStandardMaterial({ color: 0xffd700, metalness: 0.6 }); + const crossV = new THREE.Mesh(new THREE.BoxGeometry(0.15, 1.2, 0.15), crossMat); + crossV.position.set(0, ch + 9.6, -hd + 3); + group.add(crossV); + const crossH = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.15, 0.15), crossMat); + crossH.position.set(0, ch + 9.9, -hd + 3); + group.add(crossH); + + // Дверь + const doorMat = new THREE.MeshStandardMaterial({ color: 0x5c3a1e }); + const door = new THREE.Mesh(new THREE.BoxGeometry(2, 3.5, 0.15), doorMat); + door.position.set(0, 1.75, hd + 0.05); + group.add(door); + + // Окна-витражи + const vitrageMat = new THREE.MeshStandardMaterial({ + color: 0x4488cc, emissive: 0x223344, emissiveIntensity: 0.3, transparent: true, opacity: 0.7 + }); + [-hw * 0.6, hw * 0.6].forEach(wx => { + const win = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 3), vitrageMat); + win.position.set(wx, ch / 2, hd + 0.02); + group.add(win); + }); + + // Тёплый свет из окон + const churchLight = new THREE.PointLight(0xffddaa, 0.4, 15); + churchLight.position.set(0, 3, hd + 1); + group.add(churchLight); + + group.position.set(cx, 0, cz); + this.scene.add(group); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(cx - hw, 0, cz - hd), + new THREE.Vector3(cx + hw, ch + 6, cz + hd) + )); + + this.buildingRects.push({ x: cx, z: cz, w: cw, d: cd }); + + this.interactables.push({ + type: 'church', + mesh: group, + position: new THREE.Vector3(cx, 0, cz + hd + 1), + radius: 3, + label: 'Войти в церковь' + }); + } + + createConstructionSite() { + const group = new THREE.Group(); + const cfg = this.mapConfig?.structures?.construction || {}; + const sx = cfg.x ?? 70, sz = cfg.z ?? 60; + const siteRadius = cfg.radius ?? 12; + + // Забор строительной площадки + const fenceMat = new THREE.MeshStandardMaterial({ color: 0xcc8833 }); + for (let i = 0; i < 20; i++) { + const angle = (i / 20) * Math.PI * 2; + const fx = Math.cos(angle) * siteRadius; + const fz = Math.sin(angle) * siteRadius; + const post = new THREE.Mesh(new THREE.BoxGeometry(0.1, 2, 0.1), fenceMat); + post.position.set(fx, 1, fz); + group.add(post); + + if (i < 19 && !(i >= 15 && i <= 16)) { // Проход + const next = ((i + 1) / 20) * Math.PI * 2; + const nx = Math.cos(next) * 12; + const nz = Math.sin(next) * 12; + const len = Math.sqrt((nx - fx) ** 2 + (nz - fz) ** 2); + const plank = new THREE.Mesh(new THREE.BoxGeometry(len, 1.5, 0.05), fenceMat); + plank.position.set((fx + nx) / 2, 1, (fz + nz) / 2); + plank.rotation.y = Math.atan2(nx - fx, nz - fz) + Math.PI / 2; + group.add(plank); + } + } + + // Недостроенное здание (каркас) + const concreteMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.9 }); + // Колонны + [[-4, -4], [4, -4], [-4, 4], [4, 4]].forEach(([px, pz]) => { + const column = new THREE.Mesh(new THREE.BoxGeometry(0.6, 8, 0.6), concreteMat); + column.position.set(px, 4, pz); + column.castShadow = true; + group.add(column); + }); + + // Перекрытия + const slab = new THREE.Mesh(new THREE.BoxGeometry(9, 0.3, 9), concreteMat); + slab.position.y = 4; + slab.castShadow = true; + slab.receiveShadow = true; + group.add(slab); + + const slab2 = new THREE.Mesh(new THREE.BoxGeometry(9, 0.3, 9), concreteMat); + slab2.position.y = 8; + slab2.castShadow = true; + group.add(slab2); + + // Кран (упрощённый) + const craneMat = new THREE.MeshStandardMaterial({ color: 0xddaa22 }); + const craneBase = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.5, 18, 8), craneMat); + craneBase.position.set(8, 9, 0); + craneBase.castShadow = true; + group.add(craneBase); + + const craneArm = new THREE.Mesh(new THREE.BoxGeometry(16, 0.4, 0.4), craneMat); + craneArm.position.set(2, 18, 0); + group.add(craneArm); + + // Строительные материалы + const brickMat = new THREE.MeshStandardMaterial({ color: 0xaa5533 }); + for (let i = 0; i < 3; i++) { + const pile = new THREE.Mesh( + new THREE.BoxGeometry(1.5, 0.6, 0.8), + brickMat + ); + pile.position.set(-6 + i * 2.5, 0.3, -7); + group.add(pile); + } + + // Песчаная куча + const sandMat = new THREE.MeshStandardMaterial({ color: 0xccaa66 }); + const sand = new THREE.Mesh(new THREE.ConeGeometry(2, 1.5, 8), sandMat); + sand.position.set(6, 0.75, -6); + group.add(sand); + + group.position.set(sx, 0, sz); + this.scene.add(group); + + this.colliders.push( + new THREE.Box3(new THREE.Vector3(sx - 4.3, 0, sz - 4.3), new THREE.Vector3(sx - 3.7, 8, sz - 3.7)), + new THREE.Box3(new THREE.Vector3(sx + 3.7, 0, sz - 4.3), new THREE.Vector3(sx + 4.3, 8, sz - 3.7)), + new THREE.Box3(new THREE.Vector3(sx - 4.3, 0, sz + 3.7), new THREE.Vector3(sx - 3.7, 8, sz + 4.3)), + new THREE.Box3(new THREE.Vector3(sx + 3.7, 0, sz + 3.7), new THREE.Vector3(sx + 4.3, 8, sz + 4.3)), + ); + + this.buildingRects.push({ x: sx, z: sz, w: 9, d: 9 }); + + this.interactables.push({ + type: 'dumpster', + mesh: group, + position: new THREE.Vector3(sx - 5, 0, sz - 7), + radius: 3, + label: 'Обыскать стройматериалы', + searched: false, + searchCooldown: 0 + }); + + this.interactables.push({ + type: 'shelter', + mesh: group, + position: new THREE.Vector3(sx, 0, sz), + radius: 4, + label: 'Поспать на стройке' + }); + } + + createJobBoard() { + const cfg = this.mapConfig?.structures?.jobBoard || {}; + const x = cfg.x ?? 20, z = cfg.z ?? -8; + const group = new THREE.Group(); + + // Столб + const poleMat = new THREE.MeshStandardMaterial({ color: 0x654321 }); + const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.05, 2, 6), poleMat); + pole.position.y = 1; + pole.castShadow = true; + group.add(pole); + + // Доска + const boardMat = new THREE.MeshStandardMaterial({ color: 0xd4a060 }); + const board = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.8, 0.05), boardMat); + board.position.y = 1.6; + board.castShadow = true; + group.add(board); + + // Листки на доске + const paperMat = new THREE.MeshStandardMaterial({ color: 0xeeeecc }); + for (let i = 0; i < 3; i++) { + const paper = new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.3, 0.01), paperMat); + paper.position.set(-0.35 + i * 0.35, 1.6, 0.03); + group.add(paper); + } + + // Надпись (маленький квадрат сверху) + const signMat = new THREE.MeshStandardMaterial({ color: 0x2e7d32 }); + const sign = new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.15, 0.06), signMat); + sign.position.set(0, 2.05, 0); + group.add(sign); + + group.position.set(x, 0, z); + this.scene.add(group); + + this.interactables.push({ + type: 'jobboard', + mesh: group, + position: new THREE.Vector3(x, 0, z), + radius: 3, + label: 'Доска объявлений (работа)' + }); + } + + createHospital() { + const group = new THREE.Group(); + const cfg = this.mapConfig?.structures?.hospital || {}; + const hx = cfg.x ?? -45, hz = cfg.z ?? -55; + const hw = cfg.w ?? 12, hh = cfg.h ?? 7, hd = cfg.d ?? 10; + const halfW = hw / 2, halfD = hd / 2; + + const wallMat = new THREE.MeshStandardMaterial({ color: 0xdddddd, roughness: 0.7 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(hw, hh, hd), wallMat); + body.position.y = hh / 2; + body.castShadow = true; + body.receiveShadow = true; + group.add(body); + + // Крыша + const roofMat = new THREE.MeshStandardMaterial({ color: 0x555555 }); + const roof = new THREE.Mesh(new THREE.BoxGeometry(hw + 0.5, 0.3, hd + 0.5), roofMat); + roof.position.y = hh; + group.add(roof); + + // Красный крест + const crossMat = new THREE.MeshStandardMaterial({ color: 0xcc2222, emissive: 0x881111, emissiveIntensity: 0.3 }); + const crossV = new THREE.Mesh(new THREE.BoxGeometry(0.6, 2.5, 0.1), crossMat); + crossV.position.set(0, hh - 1.5, halfD + 0.06); + group.add(crossV); + const crossH = new THREE.Mesh(new THREE.BoxGeometry(2, 0.6, 0.1), crossMat); + crossH.position.set(0, hh - 1.5, halfD + 0.06); + group.add(crossH); + + // Дверь + const doorMat = new THREE.MeshStandardMaterial({ color: 0x88ccff, transparent: true, opacity: 0.6 }); + const door = new THREE.Mesh(new THREE.BoxGeometry(2.5, 3, 0.1), doorMat); + door.position.set(0, 1.5, halfD + 0.05); + group.add(door); + + // Свет у входа + const light = new THREE.PointLight(0xffffff, 0.5, 10); + light.position.set(0, 4, halfD + 1); + group.add(light); + + group.position.set(hx, 0, hz); + this.scene.add(group); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(hx - halfW, 0, hz - halfD), + new THREE.Vector3(hx + halfW, hh, hz + halfD) + )); + + this.buildingRects.push({ x: hx, z: hz, w: hw, d: hd }); + + this.interactables.push({ + type: 'hospital', + mesh: group, + position: new THREE.Vector3(hx, 0, hz + halfD + 1), + radius: 3, + label: 'Войти в больницу' + }); + } + + createMarket() { + const group = new THREE.Group(); + const cfg = this.mapConfig?.structures?.market || {}; + const mx = cfg.x ?? 35, mz = cfg.z ?? -55; + const mw = cfg.w ?? 14, mh = cfg.h ?? 4, md = cfg.d ?? 10; + const hw = mw / 2, hd = md / 2; + + // Основное здание рынка + const wallMat = new THREE.MeshStandardMaterial({ color: 0x8b6914, roughness: 0.8 }); + const body = new THREE.Mesh(new THREE.BoxGeometry(mw, mh, md), wallMat); + body.position.y = mh / 2; + body.castShadow = true; + group.add(body); + + // Навес + const awningMat = new THREE.MeshStandardMaterial({ color: 0xcc6633 }); + const awning = new THREE.Mesh(new THREE.BoxGeometry(mw + 2, 0.1, 3), awningMat); + awning.position.set(0, mh - 0.5, hd + 1); + group.add(awning); + + // Столбы навеса + const poleMat = new THREE.MeshStandardMaterial({ color: 0x654321 }); + [-hw, 0, hw].forEach(px => { + const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.08, mh - 0.5, 6), poleMat); + pole.position.set(px, (mh - 0.5) / 2, hd + 2.2); + group.add(pole); + }); + + // Прилавки + const counterMat = new THREE.MeshStandardMaterial({ color: 0xaa8855 }); + const counterSpacing = mw / 3; + [-counterSpacing, 0, counterSpacing].forEach(cx => { + const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1, 1.5), counterMat); + counter.position.set(cx, 0.5, hd + 0.5); + group.add(counter); + }); + + // Товары на прилавках (цветные кубики) + const colors = [0xff4444, 0x44ff44, 0xffff44, 0xff8844, 0x44aaff]; + for (let i = 0; i < 15; i++) { + const item = new THREE.Mesh( + new THREE.BoxGeometry(0.3, 0.3, 0.3), + new THREE.MeshStandardMaterial({ color: colors[Math.floor(Math.random() * colors.length)] }) + ); + item.position.set( + -hw * 0.7 + Math.random() * mw * 0.7, + 1.15, + hd + 0.2 + Math.random() * 0.5 + ); + group.add(item); + } + + // Вывеска + const signMat = new THREE.MeshStandardMaterial({ color: 0xdd8833, emissive: 0x553311, emissiveIntensity: 0.4 }); + const sign = new THREE.Mesh(new THREE.BoxGeometry(mw * 0.4, 1, 0.15), signMat); + sign.position.set(0, mh + 0.2, hd + 0.1); + group.add(sign); + + group.position.set(mx, 0, mz); + this.scene.add(group); + + this.colliders.push(new THREE.Box3( + new THREE.Vector3(mx - hw, 0, mz - hd), + new THREE.Vector3(mx + hw, mh, mz + hd) + )); + + this.buildingRects.push({ x: mx, z: mz, w: mw, d: md }); + + this.interactables.push({ + type: 'market', + mesh: group, + position: new THREE.Vector3(mx, 0, mz + hd + 1), + radius: 4, + label: 'Рынок — купить экипировку' + }); + } + + createCampSpot() { + // Место для лагеря игрока (если ещё не построен) + const cfg = this.mapConfig?.structures?.campSpot || {}; + const cx = cfg.x ?? -20, cz = cfg.z ?? 38; + this.interactables.push({ + type: 'camp_spot', + position: new THREE.Vector3(cx, 0, cz), + radius: 3, + label: 'Место для лагеря' + }); + } + + // Движущиеся машины на дорогах + // Загрузка 3D-модели машины + loadCarModel() { + const loader = new GLTFLoader(); + loader.load('textures/Mercedes E-Class W210 (1997).glb', (gltf) => { + const model = gltf.scene; + + // Вычисляем размеры модели + const box = new THREE.Box3().setFromObject(model); + const size = box.getSize(new THREE.Vector3()); + const center = box.getCenter(new THREE.Vector3()); + + // Определяем самую длинную ось (длина машины) + const maxDim = Math.max(size.x, size.y, size.z); + const targetLength = 4.2; // длина машины в игровых единицах + const s = targetLength / maxDim; + + // Создаём обёртку для центрирования + const wrapper = new THREE.Group(); + model.scale.set(s, s, s); + + // Центрируем модель: X/Z по центру, Y — дно на уровне 0 + model.position.set( + -center.x * s, + -box.min.y * s, + -center.z * s + ); + + // Тени + model.traverse((child) => { + if (child.isMesh) { + child.castShadow = true; + child.receiveShadow = true; + } + }); + + wrapper.add(model); + + // Определяем ориентацию: если модель длиннее по X, поворачиваем на 90° + // чтобы «нос» смотрел в +Z (по умолчанию в нашей системе) + if (size.x > size.z) { + this.carModelBaseRotation = Math.PI / 2; + } + + this.carModel = wrapper; + console.log(`Car model loaded: ${size.x.toFixed(1)}x${size.y.toFixed(1)}x${size.z.toFixed(1)} → scaled ${s.toFixed(3)}`); + + // Заменяем все процедурные машины на модель + this.replaceVehicleMeshes(); + + }, (progress) => { + if (progress.total > 0) { + const pct = Math.round(progress.loaded / progress.total * 100); + if (pct % 25 === 0) console.log(`Loading car model: ${pct}%`); + } + }, (error) => { + console.warn('Car model failed to load, keeping procedural cars:', error); + }); + } + + // Заменить все машины на 3D-модель + replaceVehicleMeshes() { + if (!this.carModel) return; + + // Движущиеся машины + if (this.vehicles) { + this.vehicles.forEach(v => { + const old = v.mesh; + const pos = old.position.clone(); + const visible = old.visible; + + // Пересчитываем rotation с учётом базового поворота модели + const r = v.route; + let rotY; + if (r.axis === 'x') { + rotY = (r.dir > 0 ? Math.PI / 2 : -Math.PI / 2) + this.carModelBaseRotation; + } else { + rotY = (r.dir > 0 ? 0 : Math.PI) + this.carModelBaseRotation; + } + + this.scene.remove(old); + + const newCar = this.carModel.clone(); + newCar.position.copy(pos); + newCar.rotation.y = rotY; + newCar.visible = visible; + + this.scene.add(newCar); + v.mesh = newCar; + v.baseRotation = this.carModelBaseRotation; + }); + } + + // Припаркованные машины + if (this.parkedCars) { + this.parkedCars.forEach(p => { + this.scene.remove(p.mesh); + + const newCar = this.carModel.clone(); + newCar.position.set(p.x, 0, p.z); + newCar.rotation.y = p.rotY + this.carModelBaseRotation; + + this.scene.add(newCar); + p.mesh = newCar; + }); + } + } + + createVehicles() { + this.vehicles = []; + + // Маршруты строго по дорогам + const routes = this.mapConfig?.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: -42, start: -130, end: 130, dir: 1 }, + { axis: 'x', lane: -38, start: 130, end: -130, 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: 47, start: -45, end: 45, dir: 1 }, + { axis: 'z', lane: 53, start: 45, end: -45, dir: -1 }, + { axis: 'z', lane: -63, start: -45, end: 45, dir: 1 }, + { axis: 'z', lane: -57, start: 45, end: -45, dir: -1 }, + ]; + + const carColors = [0xcc2222, 0x2255cc, 0x22cc22, 0x888888, 0xdddd22, 0xcc8822, 0xffffff, 0x222222]; + + for (let i = 0; i < 12; i++) { + const route = routes[i % routes.length]; + const color = carColors[Math.floor(Math.random() * carColors.length)]; + + // Используем загруженную модель или процедурный fallback + const car = this.carModel + ? this.carModel.clone() + : this.createCarMeshFallback(color); + + // Начальная позиция + const offset = -i * 35 - Math.random() * 20; + const baseRot = this.carModel ? this.carModelBaseRotation : 0; + + if (route.axis === 'x') { + const startX = route.dir > 0 ? route.start + offset : route.end - offset; + car.position.set(startX, 0, route.lane); + car.rotation.y = (route.dir > 0 ? Math.PI / 2 : -Math.PI / 2) + baseRot; + } else { + const startZ = route.dir > 0 ? route.start + offset : route.end - offset; + car.position.set(route.lane, 0, startZ); + car.rotation.y = (route.dir > 0 ? 0 : Math.PI) + baseRot; + } + + this.scene.add(car); + + // Динамический коллайдер для движущейся машины + const collider = new THREE.Box3(); + this.vehicleColliders.push(collider); + + this.vehicles.push({ + mesh: car, + route, + speed: 8 + Math.random() * 6, + delay: i * 4 + Math.random() * 8, + baseRotation: baseRot, + collider, + }); + } + + // Если модель ещё не загружена, начинаем загрузку + if (!this.carModel) { + this.loadCarModel(); + } + } + + // Процедурная машина (fallback пока модель грузится) + createCarMeshFallback(color) { + const car = new THREE.Group(); + const carBody = new THREE.Mesh( + new THREE.BoxGeometry(2, 1, 3.5), + new THREE.MeshStandardMaterial({ color, metalness: 0.4 }) + ); + carBody.position.y = 0.7; + carBody.castShadow = true; + car.add(carBody); + + const cabin = new THREE.Mesh( + new THREE.BoxGeometry(1.6, 0.7, 1.8), + new THREE.MeshStandardMaterial({ color: 0x88bbcc, transparent: true, opacity: 0.5 }) + ); + cabin.position.y = 1.45; + car.add(cabin); + + const wheelMat = new THREE.MeshStandardMaterial({ color: 0x222222 }); + [[-0.9, -1.1], [-0.9, 1.1], [0.9, -1.1], [0.9, 1.1]].forEach(([wx, wz]) => { + const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.25, 0.25, 0.15, 8), wheelMat); + wheel.rotation.z = Math.PI / 2; + wheel.position.set(wx, 0.25, wz); + car.add(wheel); + }); + + const headlightMat = new THREE.MeshStandardMaterial({ + color: 0xffffcc, emissive: 0xffffcc, emissiveIntensity: 0.5 + }); + [-0.6, 0.6].forEach(lx => { + const headlight = new THREE.Mesh(new THREE.SphereGeometry(0.1, 6, 4), headlightMat); + headlight.position.set(lx, 0.6, 1.8); + car.add(headlight); + }); + + return car; + } + + updateVehicles(dt) { + if (!this.vehicles) return; + + // Половинные размеры машины для коллайдера + const hw = 1.0, hh = 1.5, hl = 2.0; // width/2, height, length/2 + + this.vehicles.forEach(v => { + if (v.delay > 0) { + v.delay -= dt; + v.mesh.visible = false; + // Убираем коллайдер пока машина невидима + v.collider.makeEmpty(); + return; + } + v.mesh.visible = true; + + const pos = v.mesh.position; + const r = v.route; + + if (r.axis === 'x') { + pos.x += r.dir * v.speed * dt; + if (r.dir > 0 && pos.x > 135) { + pos.x = -135; + v.delay = 3 + Math.random() * 8; + } else if (r.dir < 0 && pos.x < -135) { + pos.x = 135; + v.delay = 3 + Math.random() * 8; + } + // Коллайдер: машина едет по X — длинная сторона вдоль X + v.collider.set( + new THREE.Vector3(pos.x - hl, 0, pos.z - hw), + new THREE.Vector3(pos.x + hl, hh, pos.z + hw) + ); + } else { + pos.z += r.dir * v.speed * dt; + if (r.dir > 0 && pos.z > 135) { + pos.z = -135; + v.delay = 3 + Math.random() * 8; + } else if (r.dir < 0 && pos.z < -135) { + pos.z = 135; + v.delay = 3 + Math.random() * 8; + } + // Коллайдер: машина едет по Z — длинная сторона вдоль Z + v.collider.set( + new THREE.Vector3(pos.x - hw, 0, pos.z - hl), + new THREE.Vector3(pos.x + hw, hh, pos.z + hl) + ); + } + }); + } +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..78e1f2f --- /dev/null +++ b/js/main.js @@ -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'); + }); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..85be60c --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e550d73 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/textures/Mercedes E-Class W210 (1997).glb b/textures/Mercedes E-Class W210 (1997).glb new file mode 100644 index 0000000..e83eed9 Binary files /dev/null and b/textures/Mercedes E-Class W210 (1997).glb differ