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 = `
${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');
}
}