Files
Hommie_RPG_Game/js/game/UI.js
Maxim Dolgolyov fb5f09212b Initial commit: 3D Hommie RPG game
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 01:04:09 +03:00

1134 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `
<div class="beg-label">Попрошайничество...</div>
<div class="beg-bar-bg"><div class="beg-bar-fill"></div></div>
`;
document.body.appendChild(el);
this.begProgressEl = el;
}
createBuskProgress() {
const el = document.createElement('div');
el.id = 'busk-progress';
el.classList.add('hidden');
el.innerHTML = `
<div class="beg-label">Играю на гармошке...</div>
<div class="beg-bar-bg"><div class="beg-bar-fill" style="background:#e8a020"></div></div>
`;
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 = '<div class="stamina-fill"></div>';
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 += `<span style="opacity:${opacity};font-size:1.1rem;transition:opacity 0.3s;" title="${item ? item.name : 'Пусто'}">${icon}</span>`;
}
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 = `
<div class="job-label"></div>
<div class="job-bar-bg"><div class="job-bar-fill"></div></div>
<div class="job-cancel">[H] — отмена</div>
`;
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 = `
<span>${item.icon}</span>
<span class="item-name">${item.name}</span>
${item.count > 1 ? `<span class="item-count">x${item.count}</span>` : ''}
`;
// 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} Тепло<br>🛡️ +${eqData.protection} Защита<br>😊 +${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 = '<div class="eq-title">Экипировка</div><div class="eq-slots">';
for (const [slot, info] of Object.entries(slotNames)) {
const item = eq.getEquipped(slot);
const isEmpty = !item;
html += `
<div class="eq-slot ${isEmpty ? 'empty' : ''}" data-slot="${slot}">
<span class="eq-icon">${item ? item.icon : info.icon}</span>
<span class="eq-name">${item ? item.name : info.label}</span>
${item ? `<span class="eq-stats">+${item.warmth}🔥 +${item.protection}🛡️</span>` : ''}
</div>
`;
}
html += '</div>';
// Total bonuses
const warmth = eq.getWarmthBonus();
const protection = eq.getProtectionBonus();
const mood = eq.getMoodBonus();
if (warmth > 0 || protection > 0 || mood > 0) {
html += `<div class="eq-totals">`;
if (warmth > 0) html += `+${warmth} 🔥 `;
if (protection > 0) html += `+${protection}% 🛡️ `;
if (mood > 0) html += `+${mood} 😊`;
html += '</div>';
}
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 = '<h3 style="color:#f0a040;margin:14px 0 10px;font-size:0.8rem;text-transform:uppercase;letter-spacing:0.05em;padding-bottom:8px;border-bottom:1px solid rgba(255,255,255,0.06);">Крафт</h3>';
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(`<span class="${cls}">${name}: ${have}/${need}</span>`);
}
ingredientsHtml = `<div class="craft-ingredients">${parts.join(' &nbsp; ')}</div>`;
}
el.innerHTML = `
<div style="flex:1;">
<div style="font-size:0.8rem;color:${canCraft ? '#ddd' : '#555'};font-weight:600;">${inv.itemData[recipe.result]?.icon || ''} ${recipe.name}</div>
<div style="font-size:0.65rem;color:#666;margin-top:2px;">${recipe.desc}</div>
${ingredientsHtml}
</div>
<button class="craft-btn" ${canCraft ? '' : 'disabled'}>Создать</button>
`;
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 = '<p style="color:#555;text-align:center;padding:20px;">Нет активных квестов</p>';
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 = `
<div class="quest-title">📌 ${quest.title}</div>
<div class="quest-desc">${quest.description}</div>
<div class="quest-progress">${Math.min(quest.progress, quest.target)} / ${quest.target}</div>
<div class="quest-progress-bar"><div class="quest-progress-fill" style="width:${pct}%"></div></div>
`;
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 = `
<div class="quest-title">✅ ${quest.title}</div>
<div class="quest-desc">${quest.description}</div>
`;
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 = `
<div class="skill-header">
<span class="skill-name">${icons[key] || ''} ${skill.name}</span>
<span class="skill-level">${maxed ? '✨ MAX' : `Ур. ${skill.level}`}</span>
</div>
<div class="skill-desc">${skill.desc || descs[key] || ''}</div>
${!maxed ? `
<div class="skill-xp-bar"><div class="skill-xp-fill" style="width:${xpPct}%"></div></div>
<div class="skill-xp-text">${skill.xp} / ${skill.xpNeeded} XP</div>
` : ''}
`;
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 = `
<div class="ach-progress-text">Разблокировано: <span class="ach-progress-count">${progress.unlocked} / ${progress.total}</span></div>
<div class="ach-progress-bar">
<div class="ach-progress-fill" style="width:${(progress.unlocked / progress.total * 100)}%;"></div>
</div>
`;
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 = `<div class="ach-category-title">${catInfo.icon} ${catInfo.name} <span style="float:right;color:#555;font-weight:400;">${unlockedCount}/${catAchievements.length}</span></div>`;
catAchievements.forEach(ach => {
const unlocked = achievements.unlocked.has(ach.id);
const achEl = document.createElement('div');
achEl.className = `ach-item ${unlocked ? 'unlocked' : 'locked'}`;
achEl.innerHTML = `
<span class="ach-icon">${unlocked ? ach.icon : '🔒'}</span>
<div class="ach-info">
<div class="ach-title">${unlocked ? ach.title : '???'}</div>
<div class="ach-desc">${ach.desc}</div>
</div>
`;
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 = `
<div class="death-stat-grid">
<div class="death-stat-card">
<span class="ds-icon">📅</span>
<span class="ds-value">${day}</span>
<span class="ds-label">Дней</span>
</div>
<div class="death-stat-card">
<span class="ds-icon">💰</span>
<span class="ds-value">${money}₽</span>
<span class="ds-label">Денег</span>
</div>
<div class="death-stat-card">
<span class="ds-icon">📋</span>
<span class="ds-value">${completedQuests}/${totalQuests}</span>
<span class="ds-label">Квесты</span>
</div>
<div class="death-stat-card">
<span class="ds-icon">⚡</span>
<span class="ds-value">${totalLevels}</span>
<span class="ds-label">Навыки</span>
</div>
<div class="death-stat-card">
<span class="ds-icon">⭐</span>
<span class="ds-value">${repLevel}</span>
<span class="ds-label">Репутация</span>
</div>
<div class="death-stat-card">
<span class="ds-icon">🏆</span>
<span class="ds-value">${achProgress.unlocked}/${achProgress.total}</span>
<span class="ds-label">Достижения</span>
</div>
<div class="death-stat-card">
<span class="ds-icon">🛡️</span>
<span class="ds-value">${eqSlots}/4</span>
<span class="ds-label">Экипировка</span>
</div>
<div class="death-stat-card">
<span class="ds-icon">🏠</span>
<span class="ds-value">${this.game.housing.built ? 'Да' : 'Нет'}</span>
<span class="ds-label">Укрытие</span>
</div>
<div class="death-stat-card">
<span class="ds-icon">🐕</span>
<span class="ds-value">${hasDog ? 'Да' : 'Нет'}</span>
<span class="ds-label">Пёс</span>
</div>
</div>
`;
screen.classList.remove('hidden');
document.getElementById('btn-restart').onclick = () => {
screen.classList.add('hidden');
this.game.restart();
};
}
hideDeathScreen() {
document.getElementById('death-screen').classList.add('hidden');
}
}