Files
2026-05-24 22:24:39 +07:00

3868 lines
193 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terraria Clone - Multiplayer</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect fill='%234CAF50' width='32' height='8'/><rect fill='%238B5A2B' y='8' width='32' height='24'/></svg>">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { overflow: hidden; background: #000; font-family: 'Courier New', monospace; }
#gameCanvas { display: none; image-rendering: pixelated; }
#ui { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 100; display: none; }
#healthBar, #manaBar { position: absolute; left: 20px; display: flex; gap: 5px; align-items: center; }
#healthBar { top: 20px; }
#manaBar { top: 55px; }
.heart, .mana { width: 28px; height: 28px; position: relative; }
.heart {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 50%, #cc4444 100%);
clip-path: path('M14 24 C14 24 2 16 2 9 C2 4 6 2 9 2 C11 2 13 3 14 5 C15 3 17 2 19 2 C22 2 26 4 26 9 C26 16 14 24 14 24 Z');
box-shadow: 0 2px 4px rgba(0,0,0,0.3), inset 0 -2px 4px rgba(0,0,0,0.2), inset 0 2px 4px rgba(255,255,255,0.3);
border: 1px solid #aa3333;
}
.heart::after {
content: '';
position: absolute;
top: 4px;
left: 6px;
width: 6px;
height: 6px;
background: rgba(255,255,255,0.5);
border-radius: 50%;
}
.heart.empty {
background: linear-gradient(135deg, #4a2020 0%, #3a1515 50%, #2a1010 100%);
border-color: #2a1010;
box-shadow: 0 2px 4px rgba(0,0,0,0.3), inset 0 2px 4px rgba(0,0,0,0.5);
}
.heart.empty::after { display: none; }
.mana {
background: linear-gradient(135deg, #6b9bff 0%, #4169E1 50%, #3355cc 100%);
clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
box-shadow: 0 2px 4px rgba(0,0,0,0.3), inset 0 -2px 4px rgba(0,0,0,0.2), inset 0 2px 4px rgba(255,255,255,0.3);
border: 1px solid #2244aa;
}
.mana.empty {
background: linear-gradient(135deg, #1a1a4a 0%, #151540 50%, #101035 100%);
border-color: #101035;
}
#hotbar { position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%); display: flex; gap: 5px; background: rgba(0, 0, 0, 0.9); padding: 10px; border-radius: 8px; pointer-events: all; z-index: 1000; border: 2px solid #4a4a6a; }
.hotbarSlot { width: 50px; height: 50px; background: #2a2a3a; border: 2px solid #4a4a6a; display: flex; align-items: center; justify-content: center; font-size: 24px; cursor: pointer; position: relative; }
.hotbarSlot.selected { border-color: #ffd700; box-shadow: 0 0 10px #ffd700; background: #3a3a4a; }
.slot-icon { width: 32px; height: 32px; image-rendering: pixelated; object-fit: contain; }
.itemCount { position: absolute; bottom: 2px; right: 2px; font-size: 11px; color: #fff; background: rgba(0,0,0,0.8); padding: 2px 4px; border-radius: 3px; z-index: 2; }
#inventoryMenu { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(20, 20, 30, 0.95); padding: 20px; border-radius: 10px; color: white; pointer-events: all; display: none; border: 3px solid #6a6a8a; }
#inventoryMenu.active { display: block; }
.inventoryGrid { display: grid; grid-template-columns: repeat(10, 50px); gap: 5px; margin-top: 10px; }
.invSlot { width: 50px; height: 50px; background: #2a2a3a; border: 2px solid #4a4a6a; display: flex; align-items: center; justify-content: center; font-size: 24px; cursor: pointer; position: relative; }
.invSlot:hover { background: #3a3a4a; border-color: #6a6a8a; }
.invSlot.selectedItem { border-color: #ffd700; box-shadow: 0 0 10px #ffd700; }
/* Тултип для предметов */
#itemTooltip { position: fixed; background: rgba(20, 20, 40, 0.95); color: white; padding: 8px 12px; border-radius: 6px; font-size: 13px; pointer-events: none; z-index: 2000; border: 2px solid #6a6a8a; display: none; max-width: 200px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); }
#itemTooltip .tooltip-name { font-weight: bold; color: #ffd700; font-size: 14px; }
#itemTooltip .tooltip-count { color: #aaa; font-size: 12px; margin-top: 4px; }
#itemTooltip .tooltip-type { color: #888; font-size: 11px; margin-top: 2px; font-style: italic; }
#craftingMenu { position: fixed; top: 50%; right: 20px; transform: translateY(-50%); background: rgba(20, 20, 30, 0.95); padding: 15px; border-radius: 10px; color: white; max-height: 80vh; overflow-y: auto; pointer-events: all; display: none; border: 3px solid #6a6a8a; min-width: 320px; }
#craftingMenu.active { display: block; }
/* Меню сундука */
#chestMenu { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(20, 20, 30, 0.95); padding: 20px; border-radius: 10px; color: white; pointer-events: all; display: none; border: 3px solid #8B4513; min-width: 400px; z-index: 1500; }
#chestMenu.active { display: block; }
#chestMenu h3 { color: #FFD700; margin-bottom: 15px; text-align: center; }
.chestGrid { display: grid; grid-template-columns: repeat(9, 45px); gap: 4px; margin-bottom: 15px; }
.chestSlot { width: 45px; height: 45px; background: #3a3020; border: 2px solid #8B4513; display: flex; align-items: center; justify-content: center; font-size: 20px; cursor: pointer; position: relative; }
.chestSlot:hover { background: #4a4030; border-color: #CD853F; }
.chestSlot .slot-icon { width: 28px; height: 28px; }
.chestSlot .itemCount { font-size: 10px; }
#chestMenu .inventorySection { border-top: 2px solid #4a4a6a; padding-top: 15px; margin-top: 10px; }
#chestMenu .inventorySection h4 { color: #aaa; margin-bottom: 10px; font-size: 12px; }
#chestCloseBtn { position: absolute; top: 10px; right: 10px; background: #8B0000; color: white; border: none; width: 25px; height: 25px; border-radius: 5px; cursor: pointer; font-size: 14px; }
#chestCloseBtn:hover { background: #aa0000; }
#recipes { display: flex; flex-direction: column; gap: 5px; }
.craftingRecipe { padding: 10px; margin: 5px 0; background: #2a2a3a; border-radius: 5px; cursor: pointer; border: 2px solid #4a4a6a; display: block; }
.craftingRecipe:hover { border-color: #ffd700; background: #3a3a4a; }
.craftingRecipe.cantCraft { opacity: 0.5; cursor: not-allowed; }
.recipe-ingredients { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; align-items: center; }
.ingredient { display: flex; align-items: center; gap: 4px; background: rgba(0,0,0,0.3); padding: 4px 8px; border-radius: 4px; font-size: 12px; }
.ingredient img { width: 20px; height: 20px; image-rendering: pixelated; }
.ingredient .emoji { font-size: 16px; }
.ingredient .count { color: #ffd700; font-weight: bold; }
.ingredient .name { color: #aaa; }
.ingredient.have { border: 1px solid #4CAF50; }
.ingredient.missing { border: 1px solid #f44336; }
#timeDisplay { position: absolute; top: 20px; right: 20px; color: white; font-size: 16px; text-shadow: 2px 2px 4px rgba(0,0,0,0.8); background: rgba(0,0,0,0.6); padding: 8px 12px; border-radius: 5px; min-width: 120px; text-align: center; }
#dayCounter { font-size: 14px; color: #ffd700; margin-top: 4px; }
#debugInfo { position: absolute; top: 100px; right: 20px; color: #00ff00; font-size: 12px; font-family: monospace; background: rgba(0,0,0,0.6); padding: 5px; border-radius: 5px; text-align: right; }
#chatContainer { position: fixed; bottom: 120px; left: 20px; width: 350px; height: 200px; display: flex; flex-direction: column; justify-content: flex-end; pointer-events: none; }
#chatMessages { max-height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 5px; text-shadow: 1px 1px 2px black; pointer-events: all; padding-right: 5px; scrollbar-width: thin; scrollbar-color: #4a4a6a rgba(0,0,0,0.5); }
#chatMessages::-webkit-scrollbar { width: 8px; }
#chatMessages::-webkit-scrollbar-track { background: rgba(0,0,0,0.5); }
#chatMessages::-webkit-scrollbar-thumb { background-color: #4a4a6a; border-radius: 4px; }
.chatMsg { background: rgba(0,0,0,0.5); padding: 4px 8px; border-radius: 4px; color: white; font-size: 14px; align-self: flex-start; word-wrap: break-word; max-width: 100%; transition: opacity 0.5s ease-out; }
.chatMsg.fading { opacity: 0; }
#chatContainer.hidden { opacity: 0; pointer-events: none; }
#chatContainer { transition: opacity 0.5s ease-out; }
#chatInputContainer { pointer-events: all; margin-top: 5px; display: none; }
#chatInput { width: 100%; background: rgba(0,0,0,0.7); border: 1px solid #4a4a6a; color: white; padding: 5px; border-radius: 4px; font-family: inherit; }
#lobbyMenu { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #1a1a2a; display: flex; flex-direction: column; align-items: center; justify-content: center; color: white; z-index: 200; }
#lobbyMenu h1 { font-size: 48px; margin-bottom: 30px; color: #ffd700; text-shadow: 0 0 10px rgba(255, 215, 0, 0.5); }
.lobby-controls { background: rgba(0,0,0,0.5); padding: 30px; border-radius: 15px; border: 2px solid #4a4a6a; text-align: center; display: flex; flex-direction: column; gap: 10px; }
input { padding: 10px; font-size: 16px; border-radius: 5px; border: none; font-family: inherit; width: 250px; }
button { padding: 10px 20px; font-size: 16px; border-radius: 5px; border: none; cursor: pointer; background: #4169E1; color: white; font-family: inherit; transition: 0.2s; }
button:hover { background: #3159d1; }
#lobbyList { margin-top: 20px; width: 100%; max-width: 400px; max-height: 200px; overflow-y: auto; }
.lobby-item { background: #2a2a3a; padding: 10px; margin: 5px 0; border-radius: 5px; display: flex; justify-content: space-between; align-items: center; }
/* Стили для новостей */
.update-news { background: rgba(0,0,0,0.6); border: 2px solid #ffd700; border-radius: 15px; padding: 20px; margin-bottom: 20px; max-width: 500px; max-height: 300px; overflow-y: auto; }
.update-news h3 { color: #ffd700; margin-bottom: 15px; text-align: center; font-size: 18px; }
.news-content { display: flex; flex-direction: column; gap: 12px; }
.news-item { display: flex; align-items: flex-start; gap: 12px; background: rgba(255,255,255,0.05); padding: 10px; border-radius: 8px; border-left: 3px solid #4169E1; }
.news-icon { font-size: 24px; flex-shrink: 0; }
.news-text { flex: 1; }
.news-text strong { color: #fff; font-size: 14px; display: block; margin-bottom: 4px; }
.news-text p { color: #aaa; font-size: 12px; margin: 0; line-height: 1.4; }
.update-news::-webkit-scrollbar { width: 8px; }
.update-news::-webkit-scrollbar-track { background: rgba(0,0,0,0.3); border-radius: 4px; }
.update-news::-webkit-scrollbar-thumb { background: #4a4a6a; border-radius: 4px; }
.update-news::-webkit-scrollbar-thumb:hover { background: #6a6a8a; }
.lobby-item button { padding: 5px 10px; font-size: 14px; }
.lobby-item button:hover { opacity: 0.9; }
#gameOver { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0, 0, 0, 0.95); padding: 40px; border-radius: 10px; color: #ff0000; font-size: 32px; display: none; text-align: center; pointer-events: all; border: 3px solid #ff0000; z-index: 200; }
#gameOver button { margin-top: 20px; padding: 10px 20px; font-size: 16px; cursor: pointer; background: #ff0000; color: white; border: none; border-radius: 5px; }
#gameOver button:hover { background: #cc0000; }
/* Эффект урона */
#damageOverlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 150; opacity: 0; transition: opacity 0.1s; }
#damageOverlay.active { opacity: 1; }
/* Индикатор урона */
.damageIndicator { position: fixed; color: #ff0000; font-size: 24px; font-weight: bold; pointer-events: none; z-index: 160; animation: floatUp 1s ease-out forwards; text-shadow: 2px 2px 4px black; }
@keyframes floatUp { 0% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(-50px); } }
/* Панель команд */
#teamPanel { position: fixed; bottom: 20px; right: 20px; background: rgba(20, 20, 30, 0.9); padding: 10px; border-radius: 8px; border: 2px solid #4a4a6a; pointer-events: all; z-index: 1000; }
#teamPanel h4 { color: #ffd700; margin-bottom: 8px; font-size: 12px; text-align: center; }
.teamButtons { display: flex; gap: 5px; flex-wrap: wrap; justify-content: center; }
.teamBtn { width: 32px; height: 32px; border: 2px solid #333; border-radius: 5px; cursor: pointer; transition: all 0.2s; }
.teamBtn:hover { transform: scale(1.1); box-shadow: 0 0 10px currentColor; }
.teamBtn.selected { border-color: #fff; box-shadow: 0 0 15px currentColor; transform: scale(1.15); }
.teamBtn.red { background: linear-gradient(135deg, #ff6b6b, #cc3333); }
.teamBtn.blue { background: linear-gradient(135deg, #6b9bff, #3355cc); }
.teamBtn.green { background: linear-gradient(135deg, #6bff6b, #33cc33); }
.teamBtn.yellow { background: linear-gradient(135deg, #ffff6b, #cccc33); }
.teamBtn.none { background: linear-gradient(135deg, #888, #555); }
/* Версия и выход */
#versionLabel { position: fixed; bottom: 5px; left: 10px; color: rgba(255,255,255,0.4); font-size: 11px; pointer-events: none; z-index: 1000; }
#chatContainer.active { opacity: 1 !important; }
#chatContainer.active { opacity: 1 !important; }
#exitBtn { position: fixed; top: 60px; left: 20px; background: rgba(100, 100, 100, 0.7); color: white; border: 1px solid #666; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 11px; pointer-events: all; z-index: 1000; transition: 0.2s; }
#exitBtn:hover { background: rgba(255, 50, 50, 0.9); border-color: #f55; }
</style>
</head>
<body>
<div id="lobbyMenu">
<h1>🎮 Terraria Clone</h1>
<div class="update-news">
<h3>📰 Обновление alpha 1.0.3</h3>
<div class="news-content">
<div class="news-item">
<span class="news-icon">🎒</span>
<div class="news-text">
<strong>Улучшенный интерфейс хотбара</strong>
<p>Иконки предметов теперь исчезают когда заканчиваются. Чистый и понятный интерфейс!</p>
</div>
</div>
<div class="news-item">
<span class="news-icon">💧</span>
<div class="news-text">
<strong>Вода нельзя ломать</strong>
<p>Воду теперь нельзя удалить - она настоящий элемент мира. Стройте мосты чтобы пересечь озеро!</p>
</div>
</div>
<div class="news-item">
<span class="news-icon">🏊</span>
<div class="news-text">
<strong>Улучшенное плавание</strong>
<p>Теперь при выходе из воды персонаж подпрыгивает, чтобы легче выбраться на берег.</p>
</div>
</div>
<div class="news-item">
<span class="news-icon">⏱️</span>
<div class="news-text">
<strong>Таймер подбора на сервере</strong>
<p>Исправлена проблема когда выброшенные предметы сразу подбирались обратно в мультиплеере.</p>
</div>
</div>
</div>
</div>
<div class="lobby-controls">
<input type="text" id="nicknameInput" placeholder="Ваш Никнейм" value="Player">
<button onclick="startLocalGame()" style="background: #2ecc71;">🎮 Локальная игра (оффлайн)</button>
<hr style="border-color: #4a4a6a; margin: 10px 0;">
<input type="text" id="lobbyNameInput" placeholder="Название лобби (или новое)" value="world1">
<button onclick="createLobby()">Создать / Загрузить мир (онлайн)</button>
<button onclick="joinLobbyManual()">Присоединиться</button>
</div>
<div id="lobbyList"></div>
</div>
<canvas id="gameCanvas"></canvas>
<div id="ui">
<button id="exitBtn" onclick="exitToMenu()">✕ Выход</button>
<div id="healthBar"></div>
<div id="manaBar"></div>
<div id="timeDisplay">Загрузка...<div id="dayCounter">День 1</div></div>
<div id="debugInfo">FPS: 0<br>Ping: 0ms</div>
<div id="teamPanel">
<h4>⚔️ КОМАНДА</h4>
<div class="teamButtons">
<div class="teamBtn none selected" onclick="setTeam(null)" title="Без команды"></div>
<div class="teamBtn red" onclick="setTeam('red')" title="Красная команда"></div>
<div class="teamBtn blue" onclick="setTeam('blue')" title="Синяя команда"></div>
<div class="teamBtn green" onclick="setTeam('green')" title="Зелёная команда"></div>
<div class="teamBtn yellow" onclick="setTeam('yellow')" title="Жёлтая команда"></div>
</div>
</div>
<div id="versionLabel">alpha 1.0.3</div>
<div id="chatContainer">
<div id="chatMessages"></div>
<div id="chatInputContainer">
<input type="text" id="chatInput" placeholder="Нажмите Enter для чата...">
</div>
</div>
<div id="hotbar"></div>
<div id="itemTooltip"></div>
<div id="inventoryMenu">
<h3 style="margin-bottom: 10px;">📦 ИНВЕНТАРЬ</h3>
<div class="inventoryGrid" id="inventoryGrid"></div>
</div>
<div id="craftingMenu">
<h3 style="margin-bottom: 10px;">🔨 КРАФТ</h3>
<div id="recipes"></div>
</div>
<div id="chestMenu">
<button id="chestCloseBtn" onclick="closeChest()"></button>
<h3>📦 СУНДУК</h3>
<div class="chestGrid" id="chestGrid"></div>
<div class="inventorySection">
<h4>Ваш инвентарь:</h4>
<div class="chestGrid" id="chestInventoryGrid"></div>
</div>
</div>
</div>
<div id="damageOverlay"></div>
<div id="gameOver">
<div>💀 ВЫ ПОГИБЛИ! 💀</div>
<button onclick="respawnPlayer()">Возродиться</button>
</div>
<script src="/socket.io/socket.io.js" onerror="console.log('Socket.io не доступен - только локальная игра')"></script>
<script>
// Проверка доступности socket.io
let socket = null;
let isLocalGame = false;
try {
if (typeof io !== 'undefined') {
socket = io();
}
} catch(e) {
console.log('Socket.io не доступен');
}
// Заглушка для socket если он недоступен
const socketStub = {
emit: () => {},
on: () => {},
connected: false
};
if (!socket) socket = socketStub;
let gameStarted = false;
let myId = null;
let myNickname = "Player";
let otherPlayers = {};
let mobs = [];
const BLOCKS = { AIR: 0, DIRT: 1, STONE: 2, GRASS: 3, WOOD: 4, LEAVES: 5, WORKBENCH: 6, FURNACE: 7, SAND: 8, COAL_ORE: 9, IRON_ORE: 10, GOLD_ORE: 11, COPPER_ORE: 12, GLASS: 13, BRICK: 14, TORCH: 15, WATER: 16, CHEST: 17 };
const ITEMS = { WOODEN_SWORD: 100, WOODEN_PICKAXE: 101, WOODEN_AXE: 102, STONE_SWORD: 103, STONE_PICKAXE: 104, STONE_AXE: 105, IRON_SWORD: 106, IRON_PICKAXE: 107, IRON_AXE: 108, COAL: 109, IRON_BAR: 110, GOLD_BAR: 111, COPPER_BAR: 112, HEALTH_POTION: 113, MANA_POTION: 114 };
// Система стаков
const MAX_STACK = 99;
const textures = {};
const textureFiles = {}; // Больше не используем внешние файлы
// Генерация текстур программно через Canvas
function generateTextures() {
const size = 32;
// Функция создания canvas текстуры
function createTexture(drawFunc) {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
drawFunc(ctx, size);
const img = new Image();
img.src = canvas.toDataURL();
return img;
}
// Добавляем шум/зернистость
function addNoise(ctx, size, intensity = 0.1) {
const imageData = ctx.getImageData(0, 0, size, size);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const noise = (Math.random() - 0.5) * intensity * 255;
data[i] = Math.max(0, Math.min(255, data[i] + noise));
data[i+1] = Math.max(0, Math.min(255, data[i+1] + noise));
data[i+2] = Math.max(0, Math.min(255, data[i+2] + noise));
}
ctx.putImageData(imageData, 0, 0);
}
// === ТРАВА ===
textures[BLOCKS.GRASS] = createTexture((ctx, s) => {
// Земля снизу
ctx.fillStyle = '#8B5A2B';
ctx.fillRect(0, 6, s, s - 6);
// Трава сверху
const grassGrad = ctx.createLinearGradient(0, 0, 0, 8);
grassGrad.addColorStop(0, '#4CAF50');
grassGrad.addColorStop(1, '#388E3C');
ctx.fillStyle = grassGrad;
ctx.fillRect(0, 0, s, 8);
// Травинки
ctx.fillStyle = '#66BB6A';
for (let i = 0; i < 8; i++) {
const x = i * 4 + Math.random() * 2;
ctx.fillRect(x, 0, 1, 3 + Math.random() * 3);
}
addNoise(ctx, s, 0.08);
});
// === ЗЕМЛЯ (с вариациями) ===
// Создаём несколько вариантов текстуры земли
const dirtVariants = [];
for (let v = 0; v < 4; v++) {
dirtVariants.push(createTexture((ctx, s) => {
// Базовый цвет с небольшими вариациями
const baseColors = ['#8B5A2B', '#7A4F26', '#9B6A3B', '#8A5530'];
ctx.fillStyle = baseColors[v];
ctx.fillRect(0, 0, s, s);
// Темные пятна (разное количество)
ctx.fillStyle = '#6B4423';
for (let i = 0; i < 3 + v; i++) {
ctx.beginPath();
ctx.arc(Math.random() * s, Math.random() * s, 2 + Math.random() * 3, 0, Math.PI * 2);
ctx.fill();
}
// Светлые пятна
ctx.fillStyle = '#A0724B';
for (let i = 0; i < 2 + v; i++) {
ctx.beginPath();
ctx.arc(Math.random() * s, Math.random() * s, 1 + Math.random() * 2, 0, Math.PI * 2);
ctx.fill();
}
// Камешки (в некоторых вариантах)
if (v === 0 || v === 2) {
ctx.fillStyle = '#666';
ctx.fillRect(4 + v * 3, 8 + v * 2, 3, 3);
ctx.fillRect(20 + v * 2, 18 + v, 4, 3);
}
// Мелкие точки для текстуры
ctx.fillStyle = '#725035';
for (let i = 0; i < 5; i++) {
ctx.fillRect((v * 7 + i * 6) % s, (v * 5 + i * 5) % s, 2, 2);
}
addNoise(ctx, s, 0.08);
}));
}
textures[BLOCKS.DIRT] = dirtVariants[0];
textures.dirtVariants = dirtVariants;
// === КАМЕНЬ (с вариациями) ===
const stoneVariants = [];
for (let v = 0; v < 4; v++) {
stoneVariants.push(createTexture((ctx, s) => {
// Базовый цвет с вариациями
const baseColors = ['#808080', '#757575', '#8a8a8a', '#707070'];
ctx.fillStyle = baseColors[v];
ctx.fillRect(0, 0, s, s);
// Трещины (разные паттерны)
ctx.fillStyle = '#666';
if (v === 0) {
ctx.fillRect(4, 8, 12, 2);
ctx.fillRect(18, 20, 10, 2);
ctx.fillRect(2, 24, 8, 2);
} else if (v === 1) {
ctx.fillRect(2, 6, 8, 2);
ctx.fillRect(14, 14, 14, 2);
ctx.fillRect(6, 26, 10, 2);
} else if (v === 2) {
ctx.fillRect(8, 4, 10, 2);
ctx.fillRect(4, 16, 6, 2);
ctx.fillRect(20, 22, 8, 2);
} else {
ctx.fillRect(0, 10, 16, 2);
ctx.fillRect(12, 20, 18, 2);
}
// Светлые участки (разные формы)
ctx.fillStyle = '#999';
const lightX = (v * 7) % s;
const lightY = (v * 5) % s;
ctx.fillRect(lightX, lightY, 6 + v, 4 + v);
ctx.fillRect((lightX + 14) % s, (lightY + 12) % s, 5 + v, 5);
// Темные участки
ctx.fillStyle = '#555';
ctx.fillRect((v * 3) % s, (v * 4) % s, 4 + v, 5 + v);
// Мелкие детали
ctx.fillStyle = '#5a5a5a';
for (let i = 0; i < 3 + v; i++) {
ctx.fillRect(Math.random() * s, Math.random() * s, 3, 3);
}
addNoise(ctx, s, 0.12);
}));
}
textures[BLOCKS.STONE] = stoneVariants[0];
textures.stoneVariants = stoneVariants;
// === ДЕРЕВО ===
textures[BLOCKS.WOOD] = createTexture((ctx, s) => {
ctx.fillStyle = '#8B4513';
ctx.fillRect(0, 0, s, s);
// Вертикальные полосы коры
ctx.fillStyle = '#6B3510';
ctx.fillRect(4, 0, 3, s);
ctx.fillRect(12, 0, 2, s);
ctx.fillRect(20, 0, 4, s);
ctx.fillRect(28, 0, 2, s);
// Светлые полосы
ctx.fillStyle = '#A05520';
ctx.fillRect(8, 0, 2, s);
ctx.fillRect(16, 0, 2, s);
ctx.fillRect(25, 0, 2, s);
// Сучки
ctx.fillStyle = '#5B2500';
ctx.beginPath();
ctx.arc(10, 10, 3, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(22, 24, 2, 0, Math.PI * 2);
ctx.fill();
addNoise(ctx, s, 0.08);
});
// === ЛИСТВА ===
textures[BLOCKS.LEAVES] = createTexture((ctx, s) => {
ctx.fillStyle = '#228B22';
ctx.fillRect(0, 0, s, s);
// Светлые листики
ctx.fillStyle = '#32CD32';
for (let i = 0; i < 12; i++) {
const x = Math.random() * s;
const y = Math.random() * s;
ctx.beginPath();
ctx.ellipse(x, y, 3, 2, Math.random() * Math.PI, 0, Math.PI * 2);
ctx.fill();
}
// Темные участки
ctx.fillStyle = '#1B6B1B';
for (let i = 0; i < 8; i++) {
ctx.beginPath();
ctx.arc(Math.random() * s, Math.random() * s, 2 + Math.random() * 2, 0, Math.PI * 2);
ctx.fill();
}
// Просветы
ctx.fillStyle = 'rgba(100, 200, 100, 0.3)';
for (let i = 0; i < 5; i++) {
ctx.fillRect(Math.random() * s, Math.random() * s, 2, 2);
}
addNoise(ctx, s, 0.15);
});
// === УГОЛЬНАЯ РУДА ===
textures[BLOCKS.COAL_ORE] = createTexture((ctx, s) => {
// Базовый камень
ctx.fillStyle = '#707070';
ctx.fillRect(0, 0, s, s);
ctx.fillStyle = '#606060';
ctx.fillRect(8, 4, 6, 4);
ctx.fillRect(20, 20, 8, 6);
// Угольные вкрапления
ctx.fillStyle = '#1a1a1a';
ctx.beginPath();
ctx.moveTo(6, 8);
ctx.lineTo(14, 6);
ctx.lineTo(16, 12);
ctx.lineTo(10, 16);
ctx.lineTo(4, 12);
ctx.fill();
ctx.beginPath();
ctx.moveTo(20, 18);
ctx.lineTo(28, 16);
ctx.lineTo(30, 24);
ctx.lineTo(24, 28);
ctx.lineTo(18, 24);
ctx.fill();
ctx.fillRect(2, 24, 6, 5);
// Блики на угле
ctx.fillStyle = '#333';
ctx.fillRect(8, 9, 2, 2);
ctx.fillRect(22, 20, 2, 2);
addNoise(ctx, s, 0.1);
});
// === ЖЕЛЕЗНАЯ РУДА ===
textures[BLOCKS.IRON_ORE] = createTexture((ctx, s) => {
// Базовый камень
ctx.fillStyle = '#808080';
ctx.fillRect(0, 0, s, s);
ctx.fillStyle = '#666';
ctx.fillRect(4, 8, 8, 2);
ctx.fillRect(20, 24, 6, 2);
// Железные вкрапления
ctx.fillStyle = '#CD853F';
ctx.fillRect(4, 4, 8, 6);
ctx.fillRect(18, 8, 10, 8);
ctx.fillRect(6, 20, 12, 8);
ctx.fillRect(24, 22, 6, 6);
// Светлые блики
ctx.fillStyle = '#DEB887';
ctx.fillRect(6, 5, 3, 2);
ctx.fillRect(20, 10, 4, 2);
ctx.fillRect(8, 22, 4, 2);
addNoise(ctx, s, 0.1);
});
// === ЗОЛОТАЯ РУДА ===
textures[BLOCKS.GOLD_ORE] = createTexture((ctx, s) => {
// Базовый камень
ctx.fillStyle = '#808080';
ctx.fillRect(0, 0, s, s);
ctx.fillStyle = '#666';
ctx.fillRect(12, 6, 6, 2);
ctx.fillRect(4, 26, 8, 2);
// Золотые вкрапления
ctx.fillStyle = '#FFD700';
ctx.fillRect(6, 6, 6, 6);
ctx.fillRect(20, 4, 8, 8);
ctx.fillRect(4, 18, 10, 8);
ctx.fillRect(22, 20, 6, 8);
// Яркие блики
ctx.fillStyle = '#FFEC8B';
ctx.fillRect(8, 7, 2, 2);
ctx.fillRect(22, 6, 3, 2);
ctx.fillRect(6, 20, 3, 2);
ctx.fillRect(24, 22, 2, 2);
addNoise(ctx, s, 0.08);
});
// === МЕДНАЯ РУДА ===
textures[BLOCKS.COPPER_ORE] = createTexture((ctx, s) => {
// Базовый камень
ctx.fillStyle = '#808080';
ctx.fillRect(0, 0, s, s);
// Медные вкрапления
ctx.fillStyle = '#B87333';
ctx.fillRect(4, 4, 8, 8);
ctx.fillRect(20, 6, 8, 6);
ctx.fillRect(8, 20, 10, 8);
ctx.fillRect(24, 22, 6, 6);
// Блики
ctx.fillStyle = '#CD8944';
ctx.fillRect(6, 6, 3, 2);
ctx.fillRect(22, 8, 3, 2);
addNoise(ctx, s, 0.1);
});
// === ПЕСОК ===
textures[BLOCKS.SAND] = createTexture((ctx, s) => {
ctx.fillStyle = '#F4D03F';
ctx.fillRect(0, 0, s, s);
// Тёмные песчинки
ctx.fillStyle = '#D4B82F';
for (let i = 0; i < 20; i++) {
ctx.fillRect(Math.random() * s, Math.random() * s, 2, 2);
}
// Светлые песчинки
ctx.fillStyle = '#FFF8B0';
for (let i = 0; i < 15; i++) {
ctx.fillRect(Math.random() * s, Math.random() * s, 1, 1);
}
addNoise(ctx, s, 0.12);
});
// === ВЕРСТАК ===
textures[BLOCKS.WORKBENCH] = createTexture((ctx, s) => {
// Боковая часть
ctx.fillStyle = '#8B4513';
ctx.fillRect(0, 0, s, s);
// Столешница
ctx.fillStyle = '#CD853F';
ctx.fillRect(0, 0, s, 8);
// Полосы на столешнице
ctx.fillStyle = '#A0522D';
ctx.fillRect(0, 2, s, 2);
ctx.fillRect(0, 6, s, 2);
// Инструменты
ctx.fillStyle = '#666';
ctx.fillRect(6, 12, 2, 10); // молоток
ctx.fillRect(4, 10, 6, 4);
ctx.fillStyle = '#888';
ctx.fillRect(20, 14, 8, 2); // пила
ctx.fillRect(22, 16, 6, 8);
addNoise(ctx, s, 0.08);
});
// === ПЕЧЬ ===
textures[BLOCKS.FURNACE] = createTexture((ctx, s) => {
// Каменная основа
ctx.fillStyle = '#696969';
ctx.fillRect(0, 0, s, s);
ctx.fillStyle = '#555';
ctx.fillRect(4, 4, 6, 4);
ctx.fillRect(22, 20, 6, 8);
// Отверстие печи
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(8, 10, 16, 14);
// Огонь внутри
ctx.fillStyle = '#FF4500';
ctx.fillRect(10, 16, 12, 6);
ctx.fillStyle = '#FF6600';
ctx.fillRect(12, 14, 8, 4);
ctx.fillStyle = '#FFCC00';
ctx.fillRect(14, 12, 4, 4);
addNoise(ctx, s, 0.08);
});
// === СТЕКЛО ===
textures[BLOCKS.GLASS] = createTexture((ctx, s) => {
ctx.fillStyle = 'rgba(200, 230, 255, 0.4)';
ctx.fillRect(0, 0, s, s);
// Рамка
ctx.strokeStyle = 'rgba(150, 200, 230, 0.8)';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, s-2, s-2);
// Блики
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fillRect(4, 4, 8, 2);
ctx.fillRect(4, 4, 2, 8);
});
// === КИРПИЧ ===
textures[BLOCKS.BRICK] = createTexture((ctx, s) => {
// Цемент
ctx.fillStyle = '#9E9E9E';
ctx.fillRect(0, 0, s, s);
// Кирпичи
ctx.fillStyle = '#8B0000';
ctx.fillRect(1, 1, 14, 6);
ctx.fillRect(17, 1, 14, 6);
ctx.fillRect(9, 9, 14, 6);
ctx.fillRect(1, 17, 14, 6);
ctx.fillRect(17, 17, 14, 6);
ctx.fillRect(9, 25, 14, 6);
// Тени на кирпичах
ctx.fillStyle = '#6B0000';
ctx.fillRect(1, 5, 14, 2);
ctx.fillRect(17, 5, 14, 2);
ctx.fillRect(9, 13, 14, 2);
addNoise(ctx, s, 0.06);
});
// === ФАКЕЛ ===
textures[BLOCKS.TORCH] = createTexture((ctx, s) => {
ctx.clearRect(0, 0, s, s);
// Палка
ctx.fillStyle = '#8B4513';
ctx.fillRect(14, 12, 4, 18);
// Огонь
const fireGrad = ctx.createRadialGradient(16, 8, 0, 16, 8, 10);
fireGrad.addColorStop(0, '#FFFF00');
fireGrad.addColorStop(0.4, '#FFA500');
fireGrad.addColorStop(1, 'rgba(255, 69, 0, 0)');
ctx.fillStyle = fireGrad;
ctx.beginPath();
ctx.ellipse(16, 8, 8, 10, 0, 0, Math.PI * 2);
ctx.fill();
});
// === ВОДА ===
textures[BLOCKS.WATER] = createTexture((ctx, s) => {
const waterGrad = ctx.createLinearGradient(0, 0, 0, s);
waterGrad.addColorStop(0, 'rgba(30, 144, 255, 0.6)');
waterGrad.addColorStop(0.5, 'rgba(0, 100, 200, 0.7)');
waterGrad.addColorStop(1, 'rgba(0, 50, 150, 0.8)');
ctx.fillStyle = waterGrad;
ctx.fillRect(0, 0, s, s);
// Волны
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 2;
for (let i = 0; i < 3; i++) {
ctx.beginPath();
ctx.moveTo(0, 8 + i * 10);
ctx.quadraticCurveTo(8, 5 + i * 10, 16, 8 + i * 10);
ctx.quadraticCurveTo(24, 11 + i * 10, 32, 8 + i * 10);
ctx.stroke();
}
});
// === СУНДУК ===
textures[BLOCKS.CHEST] = createTexture((ctx, s) => {
// Основа сундука
ctx.fillStyle = '#8B4513';
ctx.fillRect(2, 10, 28, 20);
// Крышка
ctx.fillStyle = '#A0522D';
ctx.fillRect(2, 4, 28, 8);
// Тёмные полосы
ctx.fillStyle = '#5D3A1A';
ctx.fillRect(2, 10, 28, 2);
ctx.fillRect(2, 20, 28, 2);
// Металлические углы
ctx.fillStyle = '#FFD700';
ctx.fillRect(2, 4, 4, 4);
ctx.fillRect(26, 4, 4, 4);
ctx.fillRect(2, 26, 4, 4);
ctx.fillRect(26, 26, 4, 4);
// Замок
ctx.fillStyle = '#FFD700';
ctx.fillRect(13, 8, 6, 8);
ctx.fillStyle = '#8B4513';
ctx.fillRect(14, 10, 4, 4);
// Ключевое отверстие
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(16, 14, 2, 0, Math.PI * 2);
ctx.fill();
addNoise(ctx, s, 0.05);
});
}
// Генерируем текстуры при загрузке
generateTextures();
const EMOJIS = {
[BLOCKS.DIRT]: '🟫', [BLOCKS.STONE]: '🪨', [BLOCKS.GRASS]: '🌿', [BLOCKS.WOOD]: '🪵', [BLOCKS.LEAVES]: '🍃',
[BLOCKS.WORKBENCH]: '🔨', [BLOCKS.FURNACE]: '🔥', [BLOCKS.SAND]: '🟨', [BLOCKS.COPPER_ORE]: '🟠',
[BLOCKS.COAL_ORE]: '⬛', [BLOCKS.IRON_ORE]: '🔶', [BLOCKS.GOLD_ORE]: '🥇',
[BLOCKS.GLASS]: '💎', [BLOCKS.BRICK]: '🧱', [BLOCKS.TORCH]: '🔦',
[BLOCKS.WATER]: '💧', [BLOCKS.CHEST]: '📦',
[ITEMS.WOODEN_SWORD]: '🗡️', [ITEMS.WOODEN_PICKAXE]: '⛏️', [ITEMS.WOODEN_AXE]: '🪓',
[ITEMS.STONE_SWORD]: '⚔️', [ITEMS.STONE_PICKAXE]: '⛏️', [ITEMS.STONE_AXE]: '🪓',
[ITEMS.IRON_SWORD]: '⚔️', [ITEMS.IRON_PICKAXE]: '⛏️', [ITEMS.IRON_AXE]: '🪓',
[ITEMS.COAL]: '⬛', [ITEMS.IRON_BAR]: '🔩', [ITEMS.GOLD_BAR]: '🪙',
[ITEMS.COPPER_BAR]: '🔸', [ITEMS.HEALTH_POTION]: '❤️', [ITEMS.MANA_POTION]: '💙'
};
// Названия всех предметов и блоков
const ITEM_NAMES = {
[BLOCKS.AIR]: 'Воздух', [BLOCKS.DIRT]: 'Земля', [BLOCKS.STONE]: 'Камень', [BLOCKS.GRASS]: 'Трава',
[BLOCKS.WOOD]: 'Дерево', [BLOCKS.LEAVES]: 'Листва', [BLOCKS.WORKBENCH]: 'Верстак', [BLOCKS.FURNACE]: 'Печь',
[BLOCKS.SAND]: 'Песок', [BLOCKS.COAL_ORE]: 'Угольная руда', [BLOCKS.IRON_ORE]: 'Железная руда',
[BLOCKS.GOLD_ORE]: 'Золотая руда', [BLOCKS.COPPER_ORE]: 'Медная руда', [BLOCKS.GLASS]: 'Стекло',
[BLOCKS.BRICK]: 'Кирпич', [BLOCKS.TORCH]: 'Факел', [BLOCKS.WATER]: 'Вода', [BLOCKS.CHEST]: 'Сундук',
[ITEMS.WOODEN_SWORD]: 'Деревянный меч', [ITEMS.WOODEN_PICKAXE]: 'Деревянная кирка', [ITEMS.WOODEN_AXE]: 'Деревянный топор',
[ITEMS.STONE_SWORD]: 'Каменный меч', [ITEMS.STONE_PICKAXE]: 'Каменная кирка', [ITEMS.STONE_AXE]: 'Каменный топор',
[ITEMS.IRON_SWORD]: 'Железный меч', [ITEMS.IRON_PICKAXE]: 'Железная кирка', [ITEMS.IRON_AXE]: 'Железный топор',
[ITEMS.COAL]: 'Уголь', [ITEMS.IRON_BAR]: 'Железный слиток', [ITEMS.GOLD_BAR]: 'Золотой слиток',
[ITEMS.COPPER_BAR]: 'Медный слиток', [ITEMS.HEALTH_POTION]: 'Зелье здоровья', [ITEMS.MANA_POTION]: 'Зелье маны'
};
const COLORS = {
[BLOCKS.DIRT]: '#8B4513', [BLOCKS.STONE]: '#808080', [BLOCKS.GRASS]: '#7CFC00',
[BLOCKS.WOOD]: '#A0522D', [BLOCKS.LEAVES]: '#228B22', [BLOCKS.WORKBENCH]: '#CD853F',
[BLOCKS.FURNACE]: '#696969', [BLOCKS.SAND]: '#F4A460', [BLOCKS.COAL_ORE]: '#2F2F2F',
[BLOCKS.IRON_ORE]: '#CD7F32', [BLOCKS.GOLD_ORE]: '#FFD700', [BLOCKS.COPPER_ORE]: '#B87333',
[BLOCKS.GLASS]: '#E0FFFF', [BLOCKS.BRICK]: '#8B0000', [BLOCKS.TORCH]: '#FFA500',
[BLOCKS.WATER]: '#1E90FF', [BLOCKS.CHEST]: '#8B4513'
};
// Добавляем воду в проходимые блоки
// (PASSABLE_BLOCKS уже определён ниже)
function getCommonData() { return { nickname: document.getElementById('nicknameInput').value, lobbyId: document.getElementById('lobbyNameInput').value }; }
function createLobby() {
if (!socket || !socket.connected) {
alert('Сервер недоступен! Используйте локальную игру.');
return;
}
const data = getCommonData();
if(data.lobbyId && data.nickname) socket.emit('createLobby', data);
}
function joinLobbyManual() {
if (!socket || !socket.connected) {
alert('Сервер недоступен! Используйте локальную игру.');
return;
}
const data = getCommonData();
if(data.lobbyId && data.nickname) socket.emit('joinLobby', data);
}
function joinLobbyById(lobbyId) {
if (!socket || !socket.connected) {
alert('Сервер недоступен! Используйте локальную игру.');
return;
}
const nickname = document.getElementById('nicknameInput').value;
document.getElementById('lobbyNameInput').value = lobbyId;
if(nickname) socket.emit('joinLobby', { lobbyId, nickname });
}
// === ЛОКАЛЬНАЯ ИГРА ===
function generateLocalWorld() {
let localWorld = Array(WORLD_HEIGHT).fill().map(() => Array(WORLD_WIDTH).fill(BLOCKS.AIR));
const surfaceHeight = [];
// Базовый уровень поверхности
const baseLevel = 50;
// Уровень воды (Y координата, где будет поверхность воды)
// Чем больше число, тем ниже вода на экране
const waterLevel = baseLevel + 12;
// Генерация поверхности с более разнообразным рельефом
for (let x = 0; x < WORLD_WIDTH; x++) {
let height = baseLevel;
height += Math.sin(x * 0.02) * 8;
height += Math.sin(x * 0.05) * 4;
height += Math.cos(x * 0.1) * 2;
// Озеро слева (x: 15-70) - делаем глубокую впадину
if (x >= 15 && x <= 70) {
const lakeCenter = 42;
const distFromCenter = Math.abs(x - lakeCenter);
const lakeHalfWidth = 28;
if (distFromCenter < lakeHalfWidth) {
// Глубокая параболическая впадина - дно озера НИЖЕ уровня воды
const depthFactor = 1 - (distFromCenter / lakeHalfWidth);
const maxDepth = 20; // Глубина озера
// Дно озера ниже уровня воды (большее Y = ниже)
height = waterLevel + 3 + Math.floor(maxDepth * depthFactor * depthFactor);
}
}
surfaceHeight[x] = Math.floor(height);
}
// Заполнение блоками
for (let x = 0; x < WORLD_WIDTH; x++) {
const surface = surfaceHeight[x];
for (let y = surface; y < WORLD_HEIGHT; y++) {
if (y === surface) {
// Песок вокруг озера и на дне
if (x >= 10 && x <= 75) {
localWorld[y][x] = BLOCKS.SAND;
} else {
localWorld[y][x] = Math.random() < 0.1 ? BLOCKS.SAND : BLOCKS.GRASS;
}
} else if (y < surface + 4 + Math.floor(Math.random() * 2)) {
// Песок под озером, земля в других местах
if (x >= 10 && x <= 75) {
localWorld[y][x] = BLOCKS.SAND;
} else {
localWorld[y][x] = BLOCKS.DIRT;
}
} else {
localWorld[y][x] = BLOCKS.STONE;
// Руды с разной глубиной
const depth = y - surface;
if (Math.random() < 0.025 && depth > 8) localWorld[y][x] = BLOCKS.COAL_ORE;
else if (Math.random() < 0.012 && depth > 15) localWorld[y][x] = BLOCKS.COPPER_ORE;
else if (Math.random() < 0.010 && depth > 25) localWorld[y][x] = BLOCKS.IRON_ORE;
else if (Math.random() < 0.006 && depth > 40) localWorld[y][x] = BLOCKS.GOLD_ORE;
}
}
}
// === ГЛУБОКОЕ ОЗЕРО СЛЕВА ===
// Заливаем воду от уровня воды вниз до дна озера
for (let x = 15; x <= 70; x++) {
const bottomY = surfaceHeight[x]; // Дно озера (поверхность земли в этой точке)
// Заливаем воду от уровня воды (waterLevel) до дна (bottomY)
// Только если дно ниже уровня воды
if (bottomY > waterLevel) {
for (let y = waterLevel; y < bottomY; y++) {
if (y >= 0 && y < WORLD_HEIGHT) {
localWorld[y][x] = BLOCKS.WATER;
}
}
}
}
// === ПЕЩЕРЫ ===
for (let i = 0; i < 30; i++) {
let cx = Math.floor(Math.random() * (WORLD_WIDTH - 40)) + 20;
let cy = Math.floor(Math.random() * 50) + 70;
const caveLength = Math.floor(Math.random() * 40) + 20;
for (let j = 0; j < caveLength; j++) {
const radius = Math.floor(Math.random() * 3) + 2;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx*dx + dy*dy <= radius*radius) {
const nx = cx + dx;
const ny = cy + dy;
if (ny >= 60 && ny < WORLD_HEIGHT && nx >= 0 && nx < WORLD_WIDTH) {
if (localWorld[ny][nx] !== BLOCKS.WATER) {
localWorld[ny][nx] = BLOCKS.AIR;
}
}
}
}
}
cx += Math.floor(Math.random() * 5) - 2;
cy += Math.floor(Math.random() * 5) - 2;
cx = Math.max(5, Math.min(WORLD_WIDTH - 5, cx));
cy = Math.max(60, Math.min(WORLD_HEIGHT - 5, cy));
}
}
// === ДЕРЕВЬЯ ===
// Генерируем деревья справа от озера (после x=80)
for (let x = 85; x < WORLD_WIDTH - 10; x += Math.floor(Math.random() * 8) + 6) {
const surface = surfaceHeight[x];
// Только на траве, не на песке и не в воде
if (!localWorld[surface] || localWorld[surface][x] !== BLOCKS.GRASS) continue;
if (surface < WORLD_HEIGHT - 1 && surface > 10) {
const treeHeight = Math.floor(Math.random() * 4) + 5;
// Ствол
for (let y = surface - treeHeight; y < surface; y++) {
if (y >= 0) localWorld[y][x] = BLOCKS.WOOD;
}
// Крона (овальная форма - широкая и высокая)
for (let dy = -4; dy <= 1; dy++) {
for (let dx = -3; dx <= 3; dx++) {
const ty = surface - treeHeight + dy;
const tx = x + dx;
// Овальная форма: шире по X, выше по Y
const distX = dx / 3.5; // Ширина ~7 блоков
const distY = (dy + 1.5) / 2.5; // Высота ~5 блоков
const dist = Math.sqrt(distX * distX + distY * distY);
if (dist > 1) continue;
if (ty >= 0 && ty < WORLD_HEIGHT && tx >= 0 && tx < WORLD_WIDTH) {
if (localWorld[ty][tx] === BLOCKS.AIR) {
localWorld[ty][tx] = BLOCKS.LEAVES;
}
}
}
}
}
}
// Несколько деревьев слева от озера (до x=10, подальше от воды)
for (let x = 3; x < 10; x += Math.floor(Math.random() * 4) + 3) {
const surface = surfaceHeight[x];
// Только на траве
if (!localWorld[surface] || localWorld[surface][x] !== BLOCKS.GRASS) continue;
if (surface < WORLD_HEIGHT - 1 && surface > 10) {
const treeHeight = Math.floor(Math.random() * 3) + 4;
for (let y = surface - treeHeight; y < surface; y++) {
if (y >= 0) localWorld[y][x] = BLOCKS.WOOD;
}
// Крона (овальная форма)
for (let dy = -3; dy <= 1; dy++) {
for (let dx = -2; dx <= 2; dx++) {
const ty = surface - treeHeight + dy;
const tx = x + dx;
// Овальная форма
const distX = dx / 2.5;
const distY = (dy + 1) / 2;
const dist = Math.sqrt(distX * distX + distY * distY);
if (dist > 1) continue;
if (ty >= 0 && ty < WORLD_HEIGHT && tx >= 0 && tx < WORLD_WIDTH) {
if (localWorld[ty][tx] === BLOCKS.AIR) {
localWorld[ty][tx] = BLOCKS.LEAVES;
}
}
}
}
}
}
return localWorld;
}
// Характеристики мобов для локальной игры
const LOCAL_MOB_STATS = {
slime: { hp: 20, damage: 1, speed: 1.5, jumpForce: 6, attackRange: 30, aggroRange: 150, attackCooldown: 1000, width: 32, height: 32 },
zombie: { hp: 40, damage: 2, speed: 1.2, jumpForce: 8, attackRange: 35, aggroRange: 200, attackCooldown: 800, width: 32, height: 48 },
skeleton: { hp: 30, damage: 2, speed: 1.8, jumpForce: 7, attackRange: 40, aggroRange: 250, attackCooldown: 1200, width: 32, height: 48 },
demon: { hp: 60, damage: 3, speed: 2.0, jumpForce: 10, attackRange: 45, aggroRange: 300, attackCooldown: 600, width: 32, height: 36 }
};
let localGameInterval = null;
function startLocalGame() {
isLocalGame = true;
myNickname = document.getElementById('nicknameInput').value || "Player";
// Генерируем мир
world = generateLocalWorld();
// Находим спавн
const spawnX = Math.floor(WORLD_WIDTH / 2);
let spawnY = 0;
for (let y = 0; y < WORLD_HEIGHT; y++) {
if (world[y] && world[y][spawnX] !== BLOCKS.AIR) {
spawnY = (y - 2) * BLOCK_SIZE;
break;
}
}
spawnPoint = { x: spawnX * BLOCK_SIZE, y: spawnY };
player.x = spawnPoint.x;
player.y = spawnPoint.y;
player.health = player.maxHealth;
// Скрываем меню, показываем игру
document.getElementById('lobbyMenu').style.display = 'none';
document.getElementById('gameCanvas').style.display = 'block';
document.getElementById('ui').style.display = 'block';
updateUI();
updateHealthBar();
updateCraftingMenu();
gameStarted = true;
resize();
// Запускаем локальный игровой цикл
startLocalGameLoop();
gameLoop();
addChatMessage('🎮 Локальная игра запущена!', true);
}
function startLocalGameLoop() {
const DAY_DURATION = 300000; // 5 минут полный цикл (быстрее для тестов)
localGameInterval = setInterval(() => {
const dt = 33;
const now = Date.now();
// Обновляем время суток
const prevTimeOfDay = timeOfDay;
timeOfDay += dt / DAY_DURATION;
if (timeOfDay >= 1) {
timeOfDay = 0;
dayCount++; // Увеличиваем счётчик дней
}
updateTimeDisplay();
// Спавн мобов
const isNight = timeOfDay > 0.40 && timeOfDay < 0.70;
// Днём очень мало мобов (1-2 слизня), ночью больше с прогрессией
let maxMobs = isNight ? Math.min(3 + dayCount, 12) : 2;
let spawnChance = isNight ? 0.015 : 0.002;
if (mobs.length < maxMobs && Math.random() < spawnChance) {
// Спавним за пределами экрана (500-800 пикселей от игрока)
const minDistance = 500;
const maxDistance = 800;
const spawnDistance = minDistance + Math.random() * (maxDistance - minDistance);
const spawnDirection = Math.random() > 0.5 ? 1 : -1;
let mobSpawnX = player.x + spawnDirection * spawnDistance;
// Ограничиваем границами мира
mobSpawnX = Math.max(BLOCK_SIZE * 5, Math.min(mobSpawnX, (WORLD_WIDTH - 5) * BLOCK_SIZE));
// Проверяем что спавн далеко от игрока
const distToPlayer = Math.abs(player.x - mobSpawnX);
if (distToPlayer < 450) return; // Слишком близко, не спавним
// Проверяем что не слишком близко к другим мобам
let tooCloseToMob = false;
for (let mob of mobs) {
if (Math.abs(mob.x - mobSpawnX) < 150) {
tooCloseToMob = true;
break;
}
}
if (tooCloseToMob) return;
// Находим землю для спавна
let mobSpawnY = 0;
let foundGround = false;
const bx = Math.floor(mobSpawnX / BLOCK_SIZE);
for(let y = 0; y < WORLD_HEIGHT; y++) {
const block = world[y] && world[y][bx];
if (block && !isPassable(block) && block !== BLOCKS.WATER) {
mobSpawnY = (y - 1) * BLOCK_SIZE;
foundGround = true;
break;
}
}
if (!foundGround) return; // Не спавним если нет земли
// Выбор типа моба с прогрессией по дням
// День 1-4: только слизни
// День 5-9: слизни + зомби
// День 10+: слизни + зомби + скелеты
// Демоны не спавнятся обычным способом
let type = 'slime';
if (isNight) {
const rand = Math.random();
if (dayCount >= 10) {
// День 10+: слизни, зомби, скелеты
if (rand < 0.35) type = 'zombie';
else if (rand < 0.55) type = 'skeleton';
else type = 'slime';
} else if (dayCount >= 5) {
// День 5-9: слизни и зомби
if (rand < 0.45) type = 'zombie';
else type = 'slime';
} else {
// День 1-4: только слизни
type = 'slime';
}
} else {
// Днём только слизни (очень редко)
type = 'slime';
}
const stats = LOCAL_MOB_STATS[type];
mobs.push({
id: Date.now() + Math.random(),
x: mobSpawnX,
y: mobSpawnY - stats.height + BLOCK_SIZE,
vx: 0,
vy: 0,
width: stats.width,
height: stats.height,
health: stats.hp,
maxHealth: stats.hp,
type: type,
direction: 1,
lastAttack: 0,
onGround: true
});
}
// ИИ и физика мобов
mobs.forEach(mob => {
const stats = LOCAL_MOB_STATS[mob.type] || LOCAL_MOB_STATS.slime;
// Расстояние до игрока
const dx = (player.x + player.width/2) - (mob.x + mob.width/2);
const dy = (player.y + player.height/2) - (mob.y + mob.height/2);
const dist = Math.sqrt(dx*dx + dy*dy);
if (dist < stats.aggroRange && player.health > 0) {
// Преследование игрока
if (Math.abs(dx) > stats.attackRange) {
mob.vx = dx > 0 ? stats.speed : -stats.speed;
mob.direction = dx > 0 ? 1 : -1;
} else {
mob.vx *= 0.8;
// Атака
if (now - mob.lastAttack > stats.attackCooldown && dist < stats.attackRange + 20) {
mob.lastAttack = now;
if (!isInvulnerable) {
player.health -= stats.damage;
updateHealthBar();
// Отталкивание
player.vx += dx > 0 ? -8 : 8;
player.vy -= 5;
player.onGround = false;
showDamageEffect(stats.damage);
isInvulnerable = true;
setTimeout(() => { isInvulnerable = false; }, INVULNERABILITY_TIME);
if (player.health <= 0) {
document.getElementById('gameOver').style.display = 'block';
}
}
}
}
// Прыжок
if (mob.onGround) {
const playerAbove = player.y < mob.y - 32;
const bx = Math.floor((mob.x + mob.width/2 + mob.direction * 20) / BLOCK_SIZE);
const by = Math.floor((mob.y + mob.height/2) / BLOCK_SIZE);
const blockAhead = by >= 0 && by < WORLD_HEIGHT && bx >= 0 && bx < WORLD_WIDTH &&
world[by] && world[by][bx];
const obstacleAhead = blockAhead && !isPassable(blockAhead);
if (playerAbove || obstacleAhead) {
mob.vy = -stats.jumpForce;
mob.onGround = false;
}
}
} else {
// Случайное блуждание
if (Math.random() < 0.02) {
mob.vx = (Math.random() - 0.5) * stats.speed * 2;
mob.direction = mob.vx > 0 ? 1 : -1;
}
if (mob.type === 'slime' && mob.onGround && Math.random() < 0.03) {
mob.vy = -stats.jumpForce;
mob.onGround = false;
}
}
// Гравитация
mob.vy += GRAVITY;
if (mob.vy > 15) mob.vy = 15;
mob.x += mob.vx;
mob.y += mob.vy;
mob.vx *= 0.95;
// Коллизия с землёй (игнорируем фоновые блоки)
const groundBx = Math.floor((mob.x + mob.width/2) / BLOCK_SIZE);
const groundBy = Math.floor((mob.y + mob.height) / BLOCK_SIZE);
mob.onGround = false;
if (groundBy >= 0 && groundBy < WORLD_HEIGHT && groundBx >= 0 && groundBx < WORLD_WIDTH) {
const groundBlock = world[groundBy] && world[groundBy][groundBx];
if (groundBlock && !isPassable(groundBlock)) {
mob.y = groundBy * BLOCK_SIZE - mob.height;
mob.vy = 0;
mob.onGround = true;
}
}
// Коллизия со стенами (игнорируем фоновые блоки)
const wallBx = Math.floor((mob.x + mob.width/2 + mob.direction * mob.width/2) / BLOCK_SIZE);
const wallBy = Math.floor((mob.y + mob.height/2) / BLOCK_SIZE);
if (wallBy >= 0 && wallBy < WORLD_HEIGHT && wallBx >= 0 && wallBx < WORLD_WIDTH) {
const wallBlock = world[wallBy] && world[wallBy][wallBx];
if (wallBlock && !isPassable(wallBlock)) {
mob.vx = 0;
mob.x -= mob.direction * 2;
}
}
// Границы мира
if (mob.x < 0) { mob.x = 0; mob.vx *= -1; }
if (mob.x > (WORLD_WIDTH - 1) * BLOCK_SIZE) { mob.x = (WORLD_WIDTH - 1) * BLOCK_SIZE; mob.vx *= -1; }
if (mob.y > (WORLD_HEIGHT + 10) * BLOCK_SIZE) mob.health = 0;
});
// Удаляем мёртвых мобов
mobs = mobs.filter(m => m.health > 0);
}, 33);
}
function localDamageMob(mobId) {
const mobIndex = mobs.findIndex(m => m.id === mobId);
if (mobIndex !== -1) {
const mob = mobs[mobIndex];
let damage = 1;
const heldItem = hotbarSlots[selectedSlot];
if (heldItem === ITEMS.WOODEN_SWORD) damage = 3;
if (heldItem === ITEMS.STONE_SWORD) damage = 5;
if (heldItem === ITEMS.IRON_SWORD) damage = 7;
mob.health -= damage;
mob.vx = (mob.x - player.x > 0 ? 1 : -1) * 8;
mob.vy = -6;
if (mob.health <= 0) {
mobs.splice(mobIndex, 1);
}
}
}
function localUpdateBlock(x, y, type) {
if (y >= 0 && y < WORLD_HEIGHT && x >= 0 && x < WORLD_WIDTH) {
if (world[y]) {
world[y][x] = type;
}
}
}
// === СИСТЕМА ТЕКУЧЕЙ ВОДЫ ===
let lastWaterUpdate = 0;
const WATER_UPDATE_INTERVAL = 100; // Обновление воды каждые 100мс
const WATER_MAX_SPREAD = 7; // Максимальное распространение в стороны
function updateWater() {
if (!isLocalGame) return; // Только для локальной игры
const now = Date.now();
if (now - lastWaterUpdate < WATER_UPDATE_INTERVAL) return;
lastWaterUpdate = now;
// Функция подсчёта расстояния до ближайшего источника
function getDistanceToSource(x, y) {
// Ищем воду с источником сверху
for (let dist = 0; dist <= WATER_MAX_SPREAD; dist++) {
// Проверяем слева
if (x - dist >= 0 && world[y] && world[y][x - dist] === BLOCKS.WATER) {
if (y > 0 && world[y - 1] && world[y - 1][x - dist] === BLOCKS.WATER) {
return dist;
}
}
// Проверяем справа
if (x + dist < WORLD_WIDTH && world[y] && world[y][x + dist] === BLOCKS.WATER) {
if (y > 0 && world[y - 1] && world[y - 1][x + dist] === BLOCKS.WATER) {
return dist;
}
}
}
return WATER_MAX_SPREAD + 1;
}
// Список блоков воды для обновления
const waterUpdates = [];
// Сканируем видимую область + небольшой буфер
const scanStartX = Math.max(0, Math.floor(camera.x / BLOCK_SIZE) - 15);
const scanEndX = Math.min(WORLD_WIDTH, Math.ceil((camera.x + canvas.width) / BLOCK_SIZE) + 15);
const scanStartY = Math.max(0, Math.floor(camera.y / BLOCK_SIZE) - 10);
const scanEndY = Math.min(WORLD_HEIGHT, Math.ceil((camera.y + canvas.height) / BLOCK_SIZE) + 15);
// Проходим снизу вверх, чтобы вода текла правильно
for (let y = scanEndY - 1; y >= scanStartY; y--) {
if (!world[y]) continue;
for (let x = scanStartX; x < scanEndX; x++) {
if (world[y][x] === BLOCKS.WATER) {
// Проверяем блок снизу - вода всегда течёт вниз
if (y + 1 < WORLD_HEIGHT && world[y + 1]) {
const blockBelow = world[y + 1][x];
if (blockBelow === BLOCKS.AIR) {
waterUpdates.push({ x, y: y + 1, type: BLOCKS.WATER });
}
}
// Проверяем блоки по бокам (только если снизу твёрдый блок)
if (y + 1 < WORLD_HEIGHT && world[y + 1]) {
const blockBelow = world[y + 1][x];
if (blockBelow !== BLOCKS.AIR && blockBelow !== BLOCKS.WATER) {
// Проверяем расстояние до источника
const distToSource = getDistanceToSource(x, y);
// Распространяемся только если не достигли максимума
if (distToSource < WATER_MAX_SPREAD) {
// Течём влево
if (x - 1 >= 0 && world[y][x - 1] === BLOCKS.AIR) {
waterUpdates.push({ x: x - 1, y, type: BLOCKS.WATER });
}
// Течём вправо
if (x + 1 < WORLD_WIDTH && world[y][x + 1] === BLOCKS.AIR) {
waterUpdates.push({ x: x + 1, y, type: BLOCKS.WATER });
}
}
}
}
}
}
}
// Применяем обновления (больше обновлений за тик для быстрого распространения)
const maxUpdatesPerTick = 40;
for (let i = 0; i < Math.min(waterUpdates.length, maxUpdatesPerTick); i++) {
const update = waterUpdates[i];
if (world[update.y] && world[update.y][update.x] === BLOCKS.AIR) {
world[update.y][update.x] = update.type;
}
}
}
socket.on('lobbyList', (list) => {
const container = document.getElementById('lobbyList'); container.innerHTML = '<h3>Доступные миры:</h3>';
list.forEach(l => {
const div = document.createElement('div');
div.className = 'lobby-item';
div.innerHTML = `
<span>${l.id} (${l.players} игр.) [${l.status}]</span>
<div style="display: flex; gap: 5px;">
<button onclick="joinLobbyById('${l.id}')">Вход</button>
<button onclick="deleteWorld('${l.id}')" style="background: #e74c3c;" title="Удалить мир">🗑️</button>
</div>
`;
container.appendChild(div);
});
});
socket.on('worldDeleted', (lobbyId) => {
addChatMessage(`Мир "${lobbyId}" удалён`, true);
socket.emit('getLobbies');
});
socket.on('lobbyListUpdated', () => {
socket.emit('getLobbies');
});
function deleteWorld(lobbyId) {
if (confirm(`Вы уверены, что хотите удалить мир "${lobbyId}"? Это действие необратимо!`)) {
socket.emit('deleteWorld', lobbyId);
}
}
socket.on('errorMsg', (msg) => alert(msg));
socket.emit('getLobbies');
let pingStart = 0; let currentPing = 0; setInterval(() => { pingStart = Date.now(); socket.emit('pingCheck'); }, 1000);
socket.on('pongCheck', () => { currentPing = Date.now() - pingStart; updateDebugInfo(); });
const chatInputDiv = document.getElementById('chatInputContainer');
const chatInput = document.getElementById('chatInput');
const chatMessages = document.getElementById('chatMessages');
const chatContainer = document.getElementById('chatContainer');
let isChatOpen = false;
let chatHideTimeout = null;
let messageTimeouts = [];
const CHAT_FADE_DELAY = 5000; // 5 секунд до начала исчезновения
const CHAT_HIDE_DELAY = 8000; // 8 секунд до полного скрытия чата
function showChat() {
chatContainer.classList.remove('hidden');
chatContainer.classList.add('active');
// Сбрасываем таймер скрытия
if (chatHideTimeout) {
clearTimeout(chatHideTimeout);
chatHideTimeout = null;
}
}
function scheduleChatHide() {
if (isChatOpen) return; // Не скрываем если чат открыт для ввода
if (chatHideTimeout) {
clearTimeout(chatHideTimeout);
}
chatHideTimeout = setTimeout(() => {
if (!isChatOpen) {
chatContainer.classList.remove('active');
chatContainer.classList.add('hidden');
}
}, CHAT_HIDE_DELAY);
}
function addChatMessage(text, isSystem = false) {
const msg = document.createElement('div');
msg.className = 'chatMsg';
msg.style.color = isSystem ? '#ffd700' : 'white';
msg.innerHTML = text;
chatMessages.appendChild(msg);
if (chatMessages.children.length > 50) {
chatMessages.removeChild(chatMessages.firstChild);
}
chatMessages.scrollTop = chatMessages.scrollHeight;
// Показываем чат при новом сообщении
showChat();
// Таймер для исчезновения отдельного сообщения
const fadeTimeout = setTimeout(() => {
msg.classList.add('fading');
}, CHAT_FADE_DELAY);
messageTimeouts.push(fadeTimeout);
// Планируем скрытие всего чата
scheduleChatHide();
}
socket.on('chatMessage', (data) => addChatMessage(`<strong>${data.nickname}:</strong> ${data.text}`));
function updateDebugInfo() { document.getElementById('debugInfo').innerHTML = `FPS: ${currentFps}<br>Ping: ${currentPing}ms`; }
let spawnPoint = { x: 0, y: 0 };
socket.on('gameStart', (data) => {
document.getElementById('lobbyMenu').style.display = 'none'; document.getElementById('gameCanvas').style.display = 'block'; document.getElementById('ui').style.display = 'block';
world = data.world; myId = data.selfId; myNickname = data.nickname; otherPlayers = data.players; delete otherPlayers[myId]; spawnPoint = data.spawn;
player.x = data.spawn.x; player.y = data.spawn.y; player.health = player.maxHealth;
if(data.inventory) inventory = data.inventory; if(data.hotbarSlots) hotbarSlots = data.hotbarSlots;
// Загружаем сундуки
if(data.chests) chests = data.chests;
for(let id in otherPlayers) {
otherPlayers[id].targetX = otherPlayers[id].x;
otherPlayers[id].targetY = otherPlayers[id].y;
otherPlayers[id].swordSwingAngle = 0;
}
updateUI(); updateHealthBar();
updateCraftingMenu();
gameStarted = true; resize(); gameLoop();
});
socket.on('gameStateUpdate', (data) => {
timeOfDay = data.timeOfDay;
mobs = data.mobs;
if (data.dayCount) dayCount = data.dayCount;
updateTimeDisplay();
});
socket.on('playerJoined', (data) => {
const { id, ...pData } = data;
otherPlayers[id] = pData;
otherPlayers[id].targetX = pData.x;
otherPlayers[id].targetY = pData.y;
otherPlayers[id].swordSwingAngle = 0;
addChatMessage(`Игрок ${pData.nickname} подключился`, true);
});
socket.on('playerMoved', (data) => {
if(otherPlayers[data.id]) {
const p = otherPlayers[data.id];
p.targetX = data.x;
p.targetY = data.y;
p.direction = data.direction;
p.walkCycle = data.walkCycle;
p.heldItem = data.heldItem;
p.team = data.team;
}
});
// Обновление команды другого игрока
socket.on('playerTeamChanged', (data) => {
if (otherPlayers[data.id]) {
otherPlayers[data.id].team = data.team;
}
});
// Получение урона от другого игрока (PvP)
socket.on('pvpDamage', (data) => {
if (isInvulnerable) return;
player.health -= data.damage;
if (player.health < 0) player.health = 0;
updateHealthBar();
// Отталкивание
player.vx += data.knockbackX;
player.vy += data.knockbackY;
player.onGround = false;
// Эффект урона
showDamageEffect(data.damage);
// Неуязвимость
isInvulnerable = true;
setTimeout(() => { isInvulnerable = false; }, INVULNERABILITY_TIME);
// Сообщение в чат
addChatMessage(`⚔️ ${data.attackerName} нанёс вам ${data.damage} урона!`, true);
if (player.health <= 0) {
addChatMessage(`💀 Вы были убиты игроком ${data.attackerName}!`, true);
document.getElementById('gameOver').style.display = 'block';
}
});
socket.on('playerDisconnected', (data) => { delete otherPlayers[data.id]; addChatMessage(`Игрок ${data.nickname} отключился`, true); });
socket.on('blockUpdated', (data) => {
world[data.y][data.x] = data.type;
// Если сундук был уничтожен, удаляем его данные
if (data.type === BLOCKS.AIR) {
const key = getChestKey(data.x, data.y);
if (chests[key]) {
delete chests[key];
}
// Закрываем меню если этот сундук был открыт
if (openChestPos && openChestPos.x === data.x && openChestPos.y === data.y) {
closeChest();
}
}
});
// Синхронизация сундуков
socket.on('chestUpdated', (data) => {
const key = getChestKey(data.x, data.y);
chests[key] = { items: data.items };
// Обновляем UI если этот сундук открыт
if (openChestPos && openChestPos.x === data.x && openChestPos.y === data.y) {
updateChestUI();
}
});
socket.on('chestData', (data) => {
// Загружаем данные всех сундуков при входе в мир
if (data.chests) {
chests = data.chests;
}
});
// Получение обновлённого инвентаря после ломания сундука
socket.on('inventoryUpdated', (data) => {
if (data.inventory) {
inventory = data.inventory;
updateUI();
}
});
// Сундук удалён другим игроком
socket.on('chestRemoved', (data) => {
const key = getChestKey(data.x, data.y);
delete chests[key];
if (openChestPos && openChestPos.x === data.x && openChestPos.y === data.y) {
closeChest();
addChatMessage('⚠️ Сундук был уничтожен!', true);
}
});
// Синхронизация выпавших предметов
socket.on('droppedItemsSync', (items) => {
// Добавляем недостающие поля к дропам с сервера
droppedItems = items.map(drop => ({
...drop,
bobOffset: Math.random() * Math.PI * 2,
lifetime: 0,
maxLifetime: 60000,
pickupDelay: 0, // Сразу можно подбирать
onGround: false
}));
});
socket.on('itemDroppedSync', (drop) => {
// Добавляем дроп от другого игрока
drop.bobOffset = Math.random() * Math.PI * 2;
drop.lifetime = 0;
drop.maxLifetime = 60000;
drop.pickupDelay = 0; // Для синхронизированных дропов можно подбирать сразу
drop.onGround = false;
drop.vx = drop.vx || 0;
drop.vy = drop.vy || 0;
droppedItems.push(drop);
});
socket.on('itemRemovedSync', (dropId) => {
const index = droppedItems.findIndex(d => d.id === dropId);
if (index !== -1) {
droppedItems.splice(index, 1);
}
});
// Обработка урона от мобов
socket.on('playerDamaged', (data) => {
if (isInvulnerable) return; // Игнорируем урон если неуязвим
player.health = data.health;
updateHealthBar();
// Применяем отталкивание
player.vx += data.knockbackX;
player.vy += data.knockbackY;
player.onGround = false;
// Эффект урона - красная вспышка
showDamageEffect(data.damage);
// Временная неуязвимость
isInvulnerable = true;
lastDamageTime = Date.now();
setTimeout(() => { isInvulnerable = false; }, INVULNERABILITY_TIME);
// Синхронизируем здоровье с сервером
socket.emit('syncHealth', player.health);
if (player.health <= 0) {
document.getElementById('gameOver').style.display = 'block';
}
});
socket.on('playerDied', () => {
player.health = 0;
updateHealthBar();
document.getElementById('gameOver').style.display = 'block';
});
// Функция показа эффекта урона
function showDamageEffect(damage) {
// Красная вспышка экрана
const overlay = document.getElementById('damageOverlay');
overlay.style.background = `radial-gradient(ellipse at center, transparent 0%, rgba(255,0,0,0.4) 100%)`;
overlay.classList.add('active');
setTimeout(() => overlay.classList.remove('active'), 200);
// Плавающий текст урона
const indicator = document.createElement('div');
indicator.className = 'damageIndicator';
indicator.textContent = `-${damage}`;
indicator.style.left = (window.innerWidth / 2 - 20) + 'px';
indicator.style.top = (window.innerHeight / 2 - 50) + 'px';
document.body.appendChild(indicator);
setTimeout(() => indicator.remove(), 1000);
}
const canvas = document.getElementById('gameCanvas'); const ctx = canvas.getContext('2d');
const BLOCK_SIZE = 32; const WORLD_WIDTH = 400; const WORLD_HEIGHT = 150;
// Физические константы (нормализованы для 60 FPS как базы)
const TARGET_FPS = 60;
const BASE_DT = 1000 / TARGET_FPS; // ~16.67ms
const GRAVITY = 0.5;
const JUMP_FORCE = -13;
const MOVE_SPEED = 5;
const RECIPES = [
{ name: '🔨 Верстак', result: BLOCKS.WORKBENCH, count: 1, ing: {[BLOCKS.WOOD]: 10} },
{ name: '🗡️ Деревянный меч', result: ITEMS.WOODEN_SWORD, count: 1, ing: {[BLOCKS.WOOD]: 7}, wb: true },
{ name: '⛏️ Деревянная кирка', result: ITEMS.WOODEN_PICKAXE, count: 1, ing: {[BLOCKS.WOOD]: 10}, wb: true },
{ name: '🪓 Деревянный топор', result: ITEMS.WOODEN_AXE, count: 1, ing: {[BLOCKS.WOOD]: 8}, wb: true },
{ name: '🔥 Печь', result: BLOCKS.FURNACE, count: 1, ing: {[BLOCKS.STONE]: 20}, wb: true },
{ name: '⚔️ Каменный меч', result: ITEMS.STONE_SWORD, count: 1, ing: {[BLOCKS.WOOD]: 5, [BLOCKS.STONE]: 10}, wb: true },
{ name: '⛏️ Каменная кирка', result: ITEMS.STONE_PICKAXE, count: 1, ing: {[BLOCKS.WOOD]: 5, [BLOCKS.STONE]: 15}, wb: true },
{ name: '🪓 Каменный топор', result: ITEMS.STONE_AXE, count: 1, ing: {[BLOCKS.WOOD]: 5, [BLOCKS.STONE]: 12}, wb: true },
{ name: '🔦 Факел', result: BLOCKS.TORCH, count: 3, ing: {[BLOCKS.WOOD]: 1, [ITEMS.COAL]: 1} },
{ name: '🔩 Железный слиток', result: ITEMS.IRON_BAR, count: 1, ing: {[BLOCKS.IRON_ORE]: 3, [ITEMS.COAL]: 1}, fn: true },
{ name: '📏 Золотой слиток', result: ITEMS.GOLD_BAR, count: 1, ing: {[BLOCKS.GOLD_ORE]: 4, [ITEMS.COAL]: 1}, fn: true },
{ name: '🔸 Медный слиток', result: ITEMS.COPPER_BAR, count: 1, ing: {[BLOCKS.COPPER_ORE]: 3, [ITEMS.COAL]: 1}, fn: true },
{ name: '💎 Стекло', result: BLOCKS.GLASS, count: 1, ing: {[BLOCKS.SAND]: 2}, fn: true },
{ name: '🧱 Кирпич', result: BLOCKS.BRICK, count: 1, ing: {[BLOCKS.DIRT]: 3}, fn: true },
{ name: '⚔️ Железный меч', result: ITEMS.IRON_SWORD, count: 1, ing: {[ITEMS.IRON_BAR]: 8, [BLOCKS.WOOD]: 3}, wb: true },
{ name: '⛏️ Железная кирка', result: ITEMS.IRON_PICKAXE, count: 1, ing: {[ITEMS.IRON_BAR]: 12, [BLOCKS.WOOD]: 3}, wb: true },
{ name: '🪓 Железный топор', result: ITEMS.IRON_AXE, count: 1, ing: {[ITEMS.IRON_BAR]: 10, [BLOCKS.WOOD]: 3}, wb: true },
{ name: '❤️ Зелье здоровья', result: ITEMS.HEALTH_POTION, count: 1, ing: {[BLOCKS.LEAVES]: 5, [BLOCKS.GLASS]: 1}, wb: true },
{ name: '💙 Зелье маны', result: ITEMS.MANA_POTION, count: 1, ing: {[BLOCKS.LEAVES]: 3, [ITEMS.COPPER_BAR]: 1, [BLOCKS.GLASS]: 1}, wb: true },
{ name: '📦 Сундук', result: BLOCKS.CHEST, count: 1, ing: {[BLOCKS.WOOD]: 8, [ITEMS.IRON_BAR]: 2}, wb: true }
];
let world = []; let camera = { x: 0, y: 0 };
let player = { x: 0, y: 0, width: 24, height: 48, vx: 0, vy: 0, onGround: false, health: 20, maxHealth: 20, direction: 1, walkCycle: 0 };
let inventory = {}; [...Object.values(BLOCKS), ...Object.values(ITEMS)].forEach(id => inventory[id] = 0);
let selectedSlot = 0; let hotbarSlots = [BLOCKS.AIR, BLOCKS.AIR, BLOCKS.AIR, BLOCKS.AIR, BLOCKS.AIR, BLOCKS.AIR, BLOCKS.AIR, BLOCKS.AIR, BLOCKS.AIR];
let keys = {}; let mouseDown = { left: false, right: false }; let mouseX = 0, mouseY = 0; let selectedInventoryItem = null; let miningTarget = null; let miningProgress = 0; let lastPosUpdate = 0; let timeOfDay = 0; let dayCount = 1;
let lastAttackTime = 0; let swordSwingAngle = 0;
let lastDamageTime = 0; let damageFlashAlpha = 0; let isInvulnerable = false;
const INVULNERABILITY_TIME = 1000; // 1 секунда неуязвимости после получения урона
// === СИСТЕМА ВЫПАДЕНИЯ ПРЕДМЕТОВ ===
let droppedItems = []; // Массив выпавших предметов
let nextDropId = 1;
function createDrop(x, y, itemId, count = 1, noPickupDelay = false) {
// В онлайн режиме отправляем на сервер, он синхронизирует
if (!isLocalGame) {
socket.emit('itemDropped', { x, y, itemId, count, vx: (Math.random() - 0.5) * 4, vy: -3 - Math.random() * 2 });
return null; // Дроп будет добавлен через событие itemDroppedSync
}
// Локальный режим - создаём дроп напрямую
const drop = {
id: nextDropId++,
x: x,
y: y,
vx: (Math.random() - 0.5) * 4,
vy: -3 - Math.random() * 2,
itemId: itemId,
count: count,
lifetime: 0,
maxLifetime: 60000, // 60 секунд до исчезновения
bobOffset: Math.random() * Math.PI * 2,
onGround: false,
pickupDelay: noPickupDelay ? 0 : 500 // 0.5 секунды задержка перед подбором
};
droppedItems.push(drop);
return drop;
}
// === СИСТЕМА РАЗРУШЕНИЯ ДЕРЕВЬЕВ ===
function destroyTree(startX, startY) {
if (!world[startY] || world[startY][startX] !== BLOCKS.WOOD) return [];
const destroyedBlocks = [];
const visited = new Set();
const toCheck = [{x: startX, y: startY}];
// Находим основание дерева (идём вниз)
let baseY = startY;
while (baseY + 1 < WORLD_HEIGHT && world[baseY + 1] && world[baseY + 1][startX] === BLOCKS.WOOD) {
baseY++;
}
// Проверяем что под основанием есть твёрдый блок (земля, трава, камень)
const blockBelow = baseY + 1 < WORLD_HEIGHT && world[baseY + 1] ? world[baseY + 1][startX] : BLOCKS.AIR;
const isTreeBase = [BLOCKS.DIRT, BLOCKS.GRASS, BLOCKS.STONE].includes(blockBelow);
// Если это не основание дерева, просто ломаем один блок
if (!isTreeBase && startY !== baseY) {
return [{x: startX, y: startY, type: BLOCKS.WOOD}];
}
// BFS для поиска всего дерева
while (toCheck.length > 0) {
const {x, y} = toCheck.shift();
const key = `${x},${y}`;
if (visited.has(key)) continue;
if (x < 0 || x >= WORLD_WIDTH || y < 0 || y >= WORLD_HEIGHT) continue;
if (!world[y]) continue;
const block = world[y][x];
// Собираем дерево и листву
if (block === BLOCKS.WOOD || block === BLOCKS.LEAVES) {
visited.add(key);
destroyedBlocks.push({x, y, type: block});
// Проверяем соседей
// Для дерева - только вверх и в стороны (не вниз, чтобы не ломать другие деревья)
if (block === BLOCKS.WOOD) {
toCheck.push({x: x, y: y - 1}); // вверх
toCheck.push({x: x - 1, y: y}); // влево
toCheck.push({x: x + 1, y: y}); // вправо
// Также проверяем листву вокруг верхушки
toCheck.push({x: x - 1, y: y - 1});
toCheck.push({x: x + 1, y: y - 1});
}
// Для листвы - все направления
if (block === BLOCKS.LEAVES) {
for (let dx = -1; dx <= 1; dx++) {
for (let dy = -1; dy <= 1; dy++) {
if (dx !== 0 || dy !== 0) {
toCheck.push({x: x + dx, y: y + dy});
}
}
}
}
}
}
return destroyedBlocks;
}
function updateDroppedItems(dt) {
const pickupRadius = 40;
const magnetRadius = 80;
for (let i = droppedItems.length - 1; i >= 0; i--) {
const drop = droppedItems[i];
// Увеличиваем время жизни
drop.lifetime += dt;
if (drop.lifetime > drop.maxLifetime) {
droppedItems.splice(i, 1);
continue;
}
// Уменьшаем задержку подбора
if (drop.pickupDelay > 0) {
drop.pickupDelay -= dt;
}
// Расстояние до игрока
const dx = (player.x + player.width / 2) - drop.x;
const dy = (player.y + player.height / 2) - drop.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Магнит - притягиваем к игроку (только если прошла задержка)
if (drop.pickupDelay <= 0 && dist < magnetRadius && dist > pickupRadius) {
const speed = 0.15;
drop.vx += (dx / dist) * speed;
drop.vy += (dy / dist) * speed;
}
// Подбор предмета (только если прошла задержка)
if (drop.pickupDelay <= 0 && dist < pickupRadius) {
const currentCount = inventory[drop.itemId] || 0;
const canPickup = Math.min(drop.count, MAX_STACK - currentCount);
if (canPickup > 0) {
inventory[drop.itemId] = currentCount + canPickup;
drop.count -= canPickup;
updateUI();
if (drop.count <= 0) {
// Удаляем дроп локально
droppedItems.splice(i, 1);
// Синхронизируем с сервером
if (!isLocalGame) {
socket.emit('itemPickedUp', drop.id);
}
continue;
}
}
}
// Физика
if (!drop.onGround) {
drop.vy += GRAVITY * 0.5;
if (drop.vy > 10) drop.vy = 10;
}
// Проверка коллизии перед движением
const dropSize = 8;
const newX = drop.x + drop.vx;
const newY = drop.y + drop.vy;
// Коллизия по горизонтали
const bxNew = Math.floor(newX / BLOCK_SIZE);
const byCenter = Math.floor(drop.y / BLOCK_SIZE);
if (bxNew >= 0 && bxNew < WORLD_WIDTH && byCenter >= 0 && byCenter < WORLD_HEIGHT) {
const blockH = world[byCenter] && world[byCenter][bxNew];
if (blockH && !isPassable(blockH)) {
drop.vx = 0;
} else {
drop.x = newX;
}
} else {
drop.x = newX;
}
// Коллизия по вертикали (вверх)
if (drop.vy < 0) {
const bxCenter = Math.floor(drop.x / BLOCK_SIZE);
const byTop = Math.floor((newY - dropSize) / BLOCK_SIZE);
if (byTop >= 0 && byTop < WORLD_HEIGHT && bxCenter >= 0 && bxCenter < WORLD_WIDTH) {
const blockTop = world[byTop] && world[byTop][bxCenter];
if (blockTop && !isPassable(blockTop)) {
drop.vy = 0;
drop.y = (byTop + 1) * BLOCK_SIZE + dropSize;
} else {
drop.y = newY;
}
} else {
drop.y = newY;
}
} else {
drop.y = newY;
}
// Трение
drop.vx *= 0.95;
if (drop.onGround) {
drop.vx *= 0.8;
}
// Коллизия с землёй (вниз)
const bx = Math.floor(drop.x / BLOCK_SIZE);
const by = Math.floor((drop.y + dropSize) / BLOCK_SIZE);
drop.onGround = false;
if (by >= 0 && by < WORLD_HEIGHT && bx >= 0 && bx < WORLD_WIDTH) {
const block = world[by] && world[by][bx];
if (block && !isPassable(block)) {
drop.y = by * BLOCK_SIZE - dropSize;
drop.vy = 0;
drop.onGround = true;
}
}
// Границы мира
if (drop.x < 0) drop.x = 0;
if (drop.x > WORLD_WIDTH * BLOCK_SIZE) drop.x = WORLD_WIDTH * BLOCK_SIZE;
if (drop.y > WORLD_HEIGHT * BLOCK_SIZE) {
droppedItems.splice(i, 1);
}
}
}
function renderDroppedItems(ctx) {
const time = Date.now() * 0.003;
droppedItems.forEach(drop => {
const scrX = drop.x - camera.x;
const scrY = drop.y - camera.y;
// Проверка видимости
if (scrX < -32 || scrX > canvas.width + 32 || scrY < -32 || scrY > canvas.height + 32) return;
// Покачивание
const bob = Math.sin(time + drop.bobOffset) * 2;
const drawY = scrY + bob;
// Мерцание перед исчезновением (последние 10 секунд)
const timeLeft = drop.maxLifetime - drop.lifetime;
if (timeLeft < 10000 && Math.floor(timeLeft / 200) % 2 === 0) {
ctx.globalAlpha = 0.5;
}
// Тень
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(scrX, scrY + 10, 8, 3, 0, 0, Math.PI * 2);
ctx.fill();
// Рисуем предмет (маленький размер)
const itemSize = 16;
if (textures[drop.itemId] && textures[drop.itemId].src) {
ctx.drawImage(textures[drop.itemId], scrX - itemSize/2, drawY - itemSize/2, itemSize, itemSize);
} else if (COLORS[drop.itemId]) {
ctx.fillStyle = COLORS[drop.itemId];
ctx.fillRect(scrX - itemSize/2, drawY - itemSize/2, itemSize, itemSize);
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.strokeRect(scrX - itemSize/2, drawY - itemSize/2, itemSize, itemSize);
} else if (EMOJIS[drop.itemId]) {
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(EMOJIS[drop.itemId], scrX, drawY);
}
// Количество (если больше 1)
if (drop.count > 1) {
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.strokeText(drop.count.toString(), scrX, drawY + 12);
ctx.fillText(drop.count.toString(), scrX, drawY + 12);
}
ctx.globalAlpha = 1;
});
}
// Выбросить предмет (клавиша Q)
function dropItem() {
const heldItem = hotbarSlots[selectedSlot];
if (!heldItem || heldItem === BLOCKS.AIR || inventory[heldItem] <= 0) return;
// Выбрасываем 1 предмет в направлении взгляда
const dropX = player.x + player.width / 2 + player.direction * 20;
const dropY = player.y + player.height / 2;
// В онлайн режиме отправляем на сервер
if (!isLocalGame) {
socket.emit('itemDropped', {
x: dropX,
y: dropY,
itemId: heldItem,
count: 1,
vx: player.direction * 4,
vy: -3
});
} else {
createDrop(dropX, dropY, heldItem, 1);
}
inventory[heldItem]--;
updateUI();
}
// Система команд
let myTeam = null; // null, 'red', 'blue', 'green', 'yellow'
const TEAM_COLORS = {
red: '#ff6b6b',
blue: '#6b9bff',
green: '#6bff6b',
yellow: '#ffff6b'
};
function setTeam(team) {
myTeam = team;
// Обновляем UI кнопок
document.querySelectorAll('.teamBtn').forEach(btn => btn.classList.remove('selected'));
if (team === null) {
document.querySelector('.teamBtn.none').classList.add('selected');
} else {
document.querySelector(`.teamBtn.${team}`).classList.add('selected');
}
// Отправляем на сервер
if (!isLocalGame) {
socket.emit('setTeam', team);
}
addChatMessage(`Вы ${team ? 'присоединились к ' + getTeamName(team) + ' команде' : 'вышли из команды'}`, true);
}
function getTeamName(team) {
const names = { red: 'красной', blue: 'синей', green: 'зелёной', yellow: 'жёлтой' };
return names[team] || '';
}
// === СИСТЕМА СУНДУКОВ ===
const CHEST_SIZE = 27; // 27 слотов (3 ряда по 9)
let chests = {}; // { "x,y": { items: {itemId: count, ...} } }
let openChestPos = null; // { x, y } - позиция открытого сундука
let selectedChestItem = null; // Выбранный предмет для перемещения
function getChestKey(x, y) {
return `${x},${y}`;
}
function openChest(bx, by) {
const key = getChestKey(bx, by);
if (!chests[key]) {
// Создаём пустой сундук
chests[key] = { items: {} };
}
openChestPos = { x: bx, y: by };
selectedChestItem = null;
updateChestUI();
document.getElementById('chestMenu').classList.add('active');
// Закрываем другие меню
document.getElementById('inventoryMenu').classList.remove('active');
document.getElementById('craftingMenu').classList.remove('active');
}
function closeChest() {
openChestPos = null;
selectedChestItem = null;
document.getElementById('chestMenu').classList.remove('active');
}
function updateChestUI() {
if (!openChestPos) return;
const key = getChestKey(openChestPos.x, openChestPos.y);
const chest = chests[key];
if (!chest) return;
// Сетка сундука
const chestGrid = document.getElementById('chestGrid');
chestGrid.innerHTML = '';
// Получаем все предметы в сундуке
const chestItems = Object.entries(chest.items).filter(([id, count]) => count > 0);
for (let i = 0; i < CHEST_SIZE; i++) {
const slot = document.createElement('div');
slot.className = 'chestSlot';
if (chestItems[i]) {
const [itemId, count] = chestItems[i];
slot.innerHTML = `${getIconHTML(parseInt(itemId))}<div class="itemCount">${count}</div>`;
slot.onclick = () => takeFromChest(parseInt(itemId));
addTooltipEvents(slot, parseInt(itemId), count);
} else {
slot.onclick = () => putInChest(i);
}
chestGrid.appendChild(slot);
}
// Сетка инвентаря игрока
const invGrid = document.getElementById('chestInventoryGrid');
invGrid.innerHTML = '';
[...Object.values(BLOCKS), ...Object.values(ITEMS)].forEach(itemId => {
if (inventory[itemId] > 0) {
const slot = document.createElement('div');
slot.className = 'chestSlot';
slot.innerHTML = `${getIconHTML(itemId)}<div class="itemCount">${inventory[itemId]}</div>`;
slot.onclick = () => putItemInChest(itemId);
addTooltipEvents(slot, itemId, inventory[itemId]);
invGrid.appendChild(slot);
}
});
}
function putItemInChest(itemId) {
if (!openChestPos || inventory[itemId] <= 0) return;
const key = getChestKey(openChestPos.x, openChestPos.y);
const chest = chests[key];
if (!chest) return;
// Проверяем лимит стака в сундуке
const currentInChest = chest.items[itemId] || 0;
if (currentInChest >= MAX_STACK) {
addChatMessage('⚠️ В сундуке уже максимум этого предмета!', true);
return;
}
// Перемещаем 1 предмет (Shift для всех)
const amount = 1;
chest.items[itemId] = currentInChest + amount;
inventory[itemId] -= amount;
updateChestUI();
updateUI();
// Синхронизация с сервером
if (!isLocalGame) {
socket.emit('updateChest', { x: openChestPos.x, y: openChestPos.y, items: chest.items });
}
}
function takeFromChest(itemId) {
if (!openChestPos) return;
const key = getChestKey(openChestPos.x, openChestPos.y);
const chest = chests[key];
if (!chest || !chest.items[itemId] || chest.items[itemId] <= 0) return;
// Проверяем лимит стака в инвентаре
if (inventory[itemId] >= MAX_STACK) {
addChatMessage('⚠️ Инвентарь полон для этого предмета!', true);
return;
}
// Берём 1 предмет
chest.items[itemId]--;
inventory[itemId]++;
if (chest.items[itemId] <= 0) {
delete chest.items[itemId];
}
updateChestUI();
updateUI();
// Синхронизация с сервером
if (!isLocalGame) {
socket.emit('updateChest', { x: openChestPos.x, y: openChestPos.y, items: chest.items });
}
}
function putInChest(slotIndex) {
// Пустой слот - ничего не делаем пока
}
function exitToMenu() {
if (confirm('Вы уверены, что хотите выйти в меню?')) {
// Остановка игры
gameStarted = false;
if (localGameInterval) {
clearInterval(localGameInterval);
localGameInterval = null;
}
// Сброс состояния
mobs = [];
otherPlayers = {};
world = [];
myTeam = null;
isLocalGame = false;
chests = {};
droppedItems = [];
closeChest();
// Сброс UI команды
document.querySelectorAll('.teamBtn').forEach(btn => btn.classList.remove('selected'));
document.querySelector('.teamBtn.none').classList.add('selected');
// Показываем меню
document.getElementById('lobbyMenu').style.display = 'flex';
document.getElementById('gameCanvas').style.display = 'none';
document.getElementById('ui').style.display = 'none';
document.getElementById('gameOver').style.display = 'none';
// Переподключаемся к серверу
if (socket && socket.connected) {
socket.disconnect();
socket.connect();
}
}
}
// Блоки, через которые можно проходить (фоновые)
const PASSABLE_BLOCKS = [BLOCKS.AIR, BLOCKS.WOOD, BLOCKS.LEAVES, BLOCKS.TORCH, BLOCKS.WATER];
function isPassable(blockType) {
return PASSABLE_BLOCKS.includes(blockType);
}
function checkCollision(x, y, width, height) {
const left = Math.floor(x / BLOCK_SIZE); const right = Math.floor((x + width - 1) / BLOCK_SIZE);
const top = Math.floor(y / BLOCK_SIZE); const bottom = Math.floor((y + height - 1) / BLOCK_SIZE);
if (bottom >= WORLD_HEIGHT || top < 0) return false;
for (let by = top; by <= bottom; by++) {
for (let bx = left; bx <= right; bx++) {
if (by >= 0 && by < WORLD_HEIGHT && bx >= 0 && bx < WORLD_WIDTH) {
const block = world[by][bx];
// Проверяем только непроходимые блоки
if (!isPassable(block)) return true;
}
}
}
return false;
}
function respawnPlayer() {
player.x = spawnPoint.x;
player.y = spawnPoint.y;
player.vx = 0;
player.vy = 0;
player.health = player.maxHealth;
isInvulnerable = true;
setTimeout(() => { isInvulnerable = false; }, INVULNERABILITY_TIME * 2);
document.getElementById('gameOver').style.display = 'none';
updateHealthBar();
if (!isLocalGame) {
socket.emit('playerRespawn');
socket.emit('syncHealth', player.health);
}
}
// Проверка нахождения в воде
function isInWater(x, y, width, height) {
const centerX = Math.floor((x + width / 2) / BLOCK_SIZE);
const centerY = Math.floor((y + height / 2) / BLOCK_SIZE);
if (centerY >= 0 && centerY < WORLD_HEIGHT && centerX >= 0 && centerX < WORLD_WIDTH) {
return world[centerY] && world[centerY][centerX] === BLOCKS.WATER;
}
return false;
}
function updatePlayer(dt) {
if (player.health <= 0) return;
if (isChatOpen) { player.vx = 0; return; }
// Ограничиваем dt для предотвращения проблем при лагах
if (dt > 100) dt = 100;
// Коэффициент нормализации времени (делает физику независимой от FPS)
const timeScale = dt / BASE_DT;
// Проверяем находится ли игрок в воде
const inWater = isInWater(player.x, player.y, player.width, player.height);
// Скорость в воде уменьшена
const moveSpeedMultiplier = inWater ? 0.6 : 1.0;
player.vx = 0;
if (keys['a'] || keys['A'] || keys['ф'] || keys['Ф']) {
player.vx = -MOVE_SPEED * timeScale * moveSpeedMultiplier;
player.direction = -1;
if(player.onGround) player.walkCycle += dt * 0.015;
}
else if (keys['d'] || keys['D'] || keys['в'] || keys['В']) {
player.vx = MOVE_SPEED * timeScale * moveSpeedMultiplier;
player.direction = 1;
if(player.onGround) player.walkCycle += dt * 0.015;
}
else player.walkCycle = 0;
// Запоминаем было ли в воде на прошлом кадре
const wasInWater = player.wasInWater || false;
player.wasInWater = inWater;
// Плавание в воде или прыжок на суше
if (inWater) {
// В воде можно плавать вверх
if (keys['w'] || keys['W'] || keys[' '] || keys['ц'] || keys['Ц']) {
player.vy = -4 * timeScale; // Плывём вверх
}
// Можно нырять вниз
if (keys['s'] || keys['S'] || keys['ы'] || keys['Ы']) {
player.vy = 3 * timeScale; // Плывём вниз
}
} else {
// Если только что вышли из воды - подпрыгиваем
if (wasInWater && player.vy < 0) {
player.vy = JUMP_FORCE * 0.6; // Небольшой прыжок при выходе из воды
player.onGround = false;
}
if ((keys['w'] || keys['W'] || keys[' '] || keys['ц'] || keys['Ц']) && player.onGround) {
player.vy = JUMP_FORCE;
player.onGround = false;
}
}
// Гравитация (в воде сильно уменьшена - плавучесть)
if (inWater) {
player.vy += GRAVITY * timeScale * 0.3; // Слабая гравитация в воде
// Максимальная скорость падения в воде
if (player.vy > 3) player.vy = 3;
// Сопротивление воды
player.vy *= 0.95;
} else {
player.vy += GRAVITY * timeScale;
if (player.vy > 20) player.vy = 20;
}
// Горизонтальное движение
let newX = player.x + player.vx;
if (!checkCollision(newX, player.y, player.width, player.height)) {
player.x = newX;
}
// Вертикальное движение с учетом timeScale
player.y += player.vy * timeScale;
if (checkCollision(player.x, player.y, player.width, player.height)) {
if (player.vy > 0) {
player.y = Math.floor((player.y + player.height) / BLOCK_SIZE) * BLOCK_SIZE - player.height;
player.vy = 0;
player.onGround = true;
}
else if (player.vy < 0) {
player.y = Math.ceil(player.y / BLOCK_SIZE) * BLOCK_SIZE;
player.vy = 0;
}
} else {
if (checkCollision(player.x, player.y + 1, player.width, player.height)) {
player.onGround = true;
player.vy = 0;
player.y = Math.floor((player.y + player.height) / BLOCK_SIZE) * BLOCK_SIZE - player.height;
}
else player.onGround = false;
}
// Границы мира
if (player.x < 0) player.x = 0;
if (player.x > WORLD_WIDTH * BLOCK_SIZE - player.width) player.x = WORLD_WIDTH * BLOCK_SIZE - player.width;
if (player.y > WORLD_HEIGHT * BLOCK_SIZE + 200) {
player.health = 0;
updateHealthBar();
document.getElementById('gameOver').style.display = 'block';
}
// Плавная анимация меча с учетом timeScale
if (swordSwingAngle > 0) {
swordSwingAngle -= dt * 0.01;
if (swordSwingAngle < 0) swordSwingAngle = 0;
}
camera.x = Math.round(player.x + player.width / 2 - canvas.width / 2);
camera.y = Math.round(player.y + player.height / 2 - canvas.height / 2);
const now = Date.now();
if (!isLocalGame && now - lastPosUpdate > 30 && (Math.abs(player.vx) > 0 || Math.abs(player.vy) > 0)) {
socket.emit('playerMove', { x: player.x, y: player.y, vx: player.vx, vy: player.vy, direction: player.direction, walkCycle: player.walkCycle, heldItem: hotbarSlots[selectedSlot], team: myTeam });
lastPosUpdate = now;
}
}
// Цвета неба для разных времён суток
const SKY_COLORS = {
day: { r: 135, g: 206, b: 235 }, // #87CEEB - голубое небо
sunset: { r: 255, g: 99, b: 71 }, // #FF6347 - красный закат
night: { r: 25, g: 25, b: 112 }, // #191970 - тёмно-синяя ночь
sunrise: { r: 255, g: 140, b: 0 } // #FF8C00 - оранжевый рассвет
};
// Функция линейной интерполяции цветов
function lerpColor(c1, c2, t) {
return {
r: Math.round(c1.r + (c2.r - c1.r) * t),
g: Math.round(c1.g + (c2.g - c1.g) * t),
b: Math.round(c1.b + (c2.b - c1.b) * t)
};
}
// Функция получения цвета неба в зависимости от времени
function getSkyColor(time) {
// Время суток: 0.0-0.35 день, 0.35-0.45 закат, 0.45-0.65 ночь, 0.65-1.0 рассвет
if (time < 0.30) {
// Полный день
return SKY_COLORS.day;
} else if (time < 0.40) {
// Переход день -> закат
const t = (time - 0.30) / 0.10;
return lerpColor(SKY_COLORS.day, SKY_COLORS.sunset, t);
} else if (time < 0.45) {
// Переход закат -> ночь
const t = (time - 0.40) / 0.05;
return lerpColor(SKY_COLORS.sunset, SKY_COLORS.night, t);
} else if (time < 0.60) {
// Полная ночь
return SKY_COLORS.night;
} else if (time < 0.70) {
// Переход ночь -> рассвет
const t = (time - 0.60) / 0.10;
return lerpColor(SKY_COLORS.night, SKY_COLORS.sunrise, t);
} else if (time < 0.80) {
// Рассвет
return SKY_COLORS.sunrise;
} else {
// Переход рассвет -> день
const t = (time - 0.80) / 0.20;
return lerpColor(SKY_COLORS.sunrise, SKY_COLORS.day, t);
}
}
// Функция получения уровня затемнения для блоков
function getDarkness(time) {
if (time < 0.35) return 0;
if (time < 0.45) return (time - 0.35) / 0.10 * 0.5;
if (time < 0.60) return 0.5;
if (time < 0.75) return 0.5 - ((time - 0.60) / 0.15 * 0.5);
return 0;
}
// Генерация статичных позиций звёзд (один раз)
let stars = [];
function generateStars() {
stars = [];
for (let i = 0; i < 150; i++) {
stars.push({
x: Math.random(),
y: Math.random() * 0.6,
size: Math.random() * 2 + 1,
twinkle: Math.random() * Math.PI * 2
});
}
}
generateStars();
// Типы мобов с их характеристиками
const MOB_TYPES = {
slime: { bodyColor: '#32CD32', eyeColor: '#fff', name: 'Слизень' },
zombie: { bodyColor: '#556B2F', skinColor: '#8FBC8F', name: 'Зомби' },
skeleton: { bodyColor: '#F5F5DC', boneColor: '#FFFAF0', name: 'Скелет' },
demon: { bodyColor: '#8B0000', eyeColor: '#FF4500', name: 'Демон' }
};
// Функция отрисовки моба
function drawMob(ctx, mob) {
const mx = mob.x - camera.x;
const my = mob.y - camera.y;
// Проверка видимости
if (mx < -50 || mx > canvas.width + 50 || my < -50 || my > canvas.height + 50) return;
const mobType = mob.type || 'slime';
const mobWidth = mob.width || 32;
const mobHeight = mob.height || 32;
const direction = mob.direction || 1;
const animTime = Date.now() * 0.005;
ctx.save();
if (mobType === 'slime') {
// === СЛИЗЕНЬ ===
const bounceOffset = Math.sin(animTime * 2) * 3;
const squash = 1 + Math.sin(animTime * 2) * 0.1;
// Тень
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(mx + mobWidth/2, my + mobHeight + 2, mobWidth/2 * squash, 4, 0, 0, Math.PI * 2);
ctx.fill();
// Тело слизня (желе)
const gradient = ctx.createRadialGradient(
mx + mobWidth/2, my + mobHeight/2 + bounceOffset,
0,
mx + mobWidth/2, my + mobHeight/2 + bounceOffset,
mobWidth/2
);
gradient.addColorStop(0, '#7CFC00');
gradient.addColorStop(0.5, '#32CD32');
gradient.addColorStop(1, '#228B22');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.ellipse(
mx + mobWidth/2,
my + mobHeight/2 + bounceOffset,
mobWidth/2 * squash,
mobHeight/2 / squash,
0, 0, Math.PI * 2
);
ctx.fill();
// Блик
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.beginPath();
ctx.ellipse(mx + mobWidth/3, my + mobHeight/3 + bounceOffset, 6, 4, -0.5, 0, Math.PI * 2);
ctx.fill();
// Глаза
const eyeY = my + mobHeight/2 - 2 + bounceOffset;
// Левый глаз
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.ellipse(mx + mobWidth/3, eyeY, 5, 6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(mx + mobWidth/3 + direction * 1, eyeY + 1, 2, 0, Math.PI * 2);
ctx.fill();
// Правый глаз
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.ellipse(mx + mobWidth * 2/3, eyeY, 5, 6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(mx + mobWidth * 2/3 + direction * 1, eyeY + 1, 2, 0, Math.PI * 2);
ctx.fill();
// Рот
ctx.strokeStyle = '#006400';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(mx + mobWidth/2, eyeY + 8, 4, 0, Math.PI);
ctx.stroke();
} else if (mobType === 'zombie') {
// === ЗОМБИ ===
// Смещение вниз чтобы ноги касались земли (height: 48, но графика ~36)
const yOff = 12;
const walkOffset = Math.sin(animTime * 3) * 2;
const armSwing = Math.sin(animTime * 3) * 0.4;
// Ноги
ctx.fillStyle = '#4a4a6a';
ctx.fillRect(mx + 8, my + yOff + 24 + walkOffset, 6, 10);
ctx.fillRect(mx + 18, my + yOff + 24 - walkOffset, 6, 10);
// Ботинки
ctx.fillStyle = '#2a2a3a';
ctx.fillRect(mx + 7, my + yOff + 32 + walkOffset, 8, 4);
ctx.fillRect(mx + 17, my + yOff + 32 - walkOffset, 8, 4);
// Туловище (рваная одежда)
ctx.fillStyle = '#556B2F';
ctx.fillRect(mx + 6, my + yOff + 12, 20, 14);
ctx.fillStyle = '#4a5f2a';
ctx.fillRect(mx + 8, my + yOff + 14, 4, 8);
ctx.fillRect(mx + 18, my + yOff + 16, 6, 6);
// Руки
ctx.save();
ctx.translate(mx + 4, my + yOff + 14);
ctx.rotate(armSwing + 0.8);
ctx.fillStyle = '#8FBC8F';
ctx.fillRect(-2, 0, 5, 14);
ctx.restore();
ctx.save();
ctx.translate(mx + 28, my + yOff + 14);
ctx.rotate(-armSwing + 0.8);
ctx.fillStyle = '#8FBC8F';
ctx.fillRect(-3, 0, 5, 14);
ctx.restore();
// Голова
ctx.fillStyle = '#8FBC8F';
ctx.fillRect(mx + 6, my + yOff - 2, 20, 16);
// Волосы (растрёпанные)
ctx.fillStyle = '#2F4F2F';
ctx.fillRect(mx + 4, my + yOff - 4, 6, 6);
ctx.fillRect(mx + 12, my + yOff - 5, 5, 4);
ctx.fillRect(mx + 20, my + yOff - 3, 6, 5);
// Глаза (пустые)
ctx.fillStyle = '#fff';
ctx.fillRect(mx + 9, my + yOff + 4, 5, 4);
ctx.fillRect(mx + 18, my + yOff + 4, 5, 4);
ctx.fillStyle = '#8B0000';
ctx.fillRect(mx + 11, my + yOff + 5, 2, 2);
ctx.fillRect(mx + 20, my + yOff + 5, 2, 2);
// Рот (ужасный)
ctx.fillStyle = '#2F4F2F';
ctx.fillRect(mx + 10, my + yOff + 10, 12, 3);
ctx.fillStyle = '#fff';
ctx.fillRect(mx + 11, my + yOff + 10, 2, 2);
ctx.fillRect(mx + 15, my + yOff + 10, 2, 2);
ctx.fillRect(mx + 19, my + yOff + 10, 2, 2);
} else if (mobType === 'skeleton') {
// === СКЕЛЕТ ===
// Смещение вниз чтобы ноги касались земли (height: 48, но графика ~38)
const yOff = 10;
const rattle = Math.sin(animTime * 5) * 1;
// Ноги (кости)
ctx.fillStyle = '#FFFAF0';
ctx.fillRect(mx + 10, my + yOff + 22, 4, 14);
ctx.fillRect(mx + 18, my + yOff + 22, 4, 14);
// Ступни
ctx.fillRect(mx + 8, my + yOff + 34, 8, 4);
ctx.fillRect(mx + 16, my + yOff + 34, 8, 4);
// Суставы
ctx.fillStyle = '#DDD';
ctx.beginPath();
ctx.arc(mx + 12, my + yOff + 28, 3, 0, Math.PI * 2);
ctx.arc(mx + 20, my + yOff + 28, 3, 0, Math.PI * 2);
ctx.fill();
// Таз
ctx.fillStyle = '#FFFAF0';
ctx.fillRect(mx + 8, my + yOff + 18, 16, 6);
// Позвоночник
ctx.fillRect(mx + 14, my + yOff + 6, 4, 14);
// Рёбра
ctx.fillStyle = '#F5F5DC';
for (let i = 0; i < 3; i++) {
ctx.fillRect(mx + 8, my + yOff + 8 + i * 4, 16, 2);
}
// Руки (кости)
ctx.save();
ctx.translate(mx + 6 + rattle, my + yOff + 8);
ctx.rotate(0.3);
ctx.fillStyle = '#FFFAF0';
ctx.fillRect(0, 0, 4, 14);
ctx.beginPath();
ctx.arc(2, 14, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.save();
ctx.translate(mx + 26 - rattle, my + yOff + 8);
ctx.rotate(-0.3);
ctx.fillStyle = '#FFFAF0';
ctx.fillRect(-4, 0, 4, 14);
ctx.beginPath();
ctx.arc(-2, 14, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Череп
ctx.fillStyle = '#FFFAF0';
ctx.beginPath();
ctx.arc(mx + 16, my + yOff + 2, 10, 0, Math.PI * 2);
ctx.fill();
// Челюсть
ctx.fillRect(mx + 10, my + yOff + 6, 12, 6);
// Глазницы
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(mx + 12, my + yOff, 3, 0, Math.PI * 2);
ctx.arc(mx + 20, my + yOff, 3, 0, Math.PI * 2);
ctx.fill();
// Нос
ctx.fillStyle = '#333';
ctx.beginPath();
ctx.moveTo(mx + 16, my + yOff + 2);
ctx.lineTo(mx + 14, my + yOff + 5);
ctx.lineTo(mx + 18, my + yOff + 5);
ctx.fill();
// Зубы
ctx.fillStyle = '#fff';
ctx.fillRect(mx + 11, my + yOff + 8, 2, 3);
ctx.fillRect(mx + 14, my + yOff + 8, 2, 3);
ctx.fillRect(mx + 17, my + yOff + 8, 2, 3);
ctx.fillRect(mx + 20, my + yOff + 8, 2, 3);
} else if (mobType === 'demon') {
// === ДЕМОН ===
const floatOffset = Math.sin(animTime) * 4;
const wingFlap = Math.sin(animTime * 4) * 0.3;
// Крылья
ctx.save();
ctx.translate(mx + 4, my + 8 + floatOffset);
ctx.rotate(-wingFlap - 0.5);
ctx.fillStyle = '#4a0000';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(-15, -10);
ctx.lineTo(-12, 5);
ctx.lineTo(-5, 10);
ctx.closePath();
ctx.fill();
ctx.restore();
ctx.save();
ctx.translate(mx + 28, my + 8 + floatOffset);
ctx.rotate(wingFlap + 0.5);
ctx.fillStyle = '#4a0000';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(15, -10);
ctx.lineTo(12, 5);
ctx.lineTo(5, 10);
ctx.closePath();
ctx.fill();
ctx.restore();
// Тело
const bodyGradient = ctx.createRadialGradient(
mx + 16, my + 16 + floatOffset, 0,
mx + 16, my + 16 + floatOffset, 16
);
bodyGradient.addColorStop(0, '#CD0000');
bodyGradient.addColorStop(1, '#8B0000');
ctx.fillStyle = bodyGradient;
ctx.beginPath();
ctx.ellipse(mx + 16, my + 18 + floatOffset, 12, 14, 0, 0, Math.PI * 2);
ctx.fill();
// Рога
ctx.fillStyle = '#2a0000';
ctx.beginPath();
ctx.moveTo(mx + 8, my + 4 + floatOffset);
ctx.lineTo(mx + 4, my - 8 + floatOffset);
ctx.lineTo(mx + 12, my + 2 + floatOffset);
ctx.fill();
ctx.beginPath();
ctx.moveTo(mx + 24, my + 4 + floatOffset);
ctx.lineTo(mx + 28, my - 8 + floatOffset);
ctx.lineTo(mx + 20, my + 2 + floatOffset);
ctx.fill();
// Глаза (светящиеся)
ctx.fillStyle = '#FF4500';
ctx.shadowColor = '#FF4500';
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.arc(mx + 11, my + 12 + floatOffset, 4, 0, Math.PI * 2);
ctx.arc(mx + 21, my + 12 + floatOffset, 4, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
// Зрачки
ctx.fillStyle = '#000';
ctx.beginPath();
ctx.arc(mx + 11 + direction, my + 12 + floatOffset, 2, 0, Math.PI * 2);
ctx.arc(mx + 21 + direction, my + 12 + floatOffset, 2, 0, Math.PI * 2);
ctx.fill();
// Рот (злобная ухмылка)
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(mx + 10, my + 22 + floatOffset);
ctx.quadraticCurveTo(mx + 16, my + 26 + floatOffset, mx + 22, my + 22 + floatOffset);
ctx.stroke();
// Хвост
ctx.strokeStyle = '#8B0000';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(mx + 16, my + 30 + floatOffset);
ctx.quadraticCurveTo(mx + 30, my + 35 + floatOffset, mx + 35, my + 28 + floatOffset);
ctx.stroke();
// Кончик хвоста (стрелка)
ctx.fillStyle = '#8B0000';
ctx.beginPath();
ctx.moveTo(mx + 35, my + 28 + floatOffset);
ctx.lineTo(mx + 40, my + 25 + floatOffset);
ctx.lineTo(mx + 38, my + 32 + floatOffset);
ctx.fill();
} else {
// Дефолтный моб (простой квадрат)
ctx.fillStyle = '#00ff00';
ctx.fillRect(mx, my, mobWidth, mobHeight);
ctx.fillStyle = '#fff';
ctx.fillRect(mx + 5, my + 8, 5, 5);
ctx.fillRect(mx + 22, my + 8, 5, 5);
}
ctx.restore();
// === ПОЛОСКА ЗДОРОВЬЯ ===
const healthBarWidth = mobWidth + 10;
const healthBarHeight = 6;
const healthBarX = mx + (mobWidth - healthBarWidth) / 2;
const healthBarY = my - 14;
// Фон полоски
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(healthBarX - 1, healthBarY - 1, healthBarWidth + 2, healthBarHeight + 2);
// Пустая полоска
ctx.fillStyle = '#333';
ctx.fillRect(healthBarX, healthBarY, healthBarWidth, healthBarHeight);
// Заполненная часть
const hp = mob.health / mob.maxHealth;
let healthColor;
if (hp > 0.6) healthColor = '#2ecc71';
else if (hp > 0.3) healthColor = '#f1c40f';
else healthColor = '#e74c3c';
ctx.fillStyle = healthColor;
ctx.fillRect(healthBarX, healthBarY, healthBarWidth * hp, healthBarHeight);
// Блик на полоске здоровья
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.fillRect(healthBarX, healthBarY, healthBarWidth * hp, healthBarHeight / 2);
// Имя моба (опционально)
if (MOB_TYPES[mobType]) {
ctx.fillStyle = '#fff';
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.font = 'bold 10px monospace';
ctx.textAlign = 'center';
ctx.strokeText(MOB_TYPES[mobType].name, mx + mobWidth/2, healthBarY - 4);
ctx.fillText(MOB_TYPES[mobType].name, mx + mobWidth/2, healthBarY - 4);
}
}
function render() {
ctx.clearRect(0,0,canvas.width,canvas.height);
// Получаем плавный цвет неба
const skyColor = getSkyColor(timeOfDay);
ctx.fillStyle = `rgb(${skyColor.r}, ${skyColor.g}, ${skyColor.b})`;
ctx.fillRect(0,0,canvas.width,canvas.height);
// Рисуем звёзды ночью (с плавным появлением/исчезновением)
let starAlpha = 0;
if (timeOfDay >= 0.40 && timeOfDay < 0.70) {
if (timeOfDay < 0.50) {
starAlpha = (timeOfDay - 0.40) / 0.10;
} else if (timeOfDay < 0.60) {
starAlpha = 1;
} else {
starAlpha = 1 - (timeOfDay - 0.60) / 0.10;
}
}
if (starAlpha > 0) {
const time = Date.now() * 0.001;
stars.forEach(star => {
const twinkle = Math.sin(time * 2 + star.twinkle) * 0.3 + 0.7;
ctx.fillStyle = `rgba(255, 255, 255, ${starAlpha * twinkle})`;
ctx.beginPath();
ctx.arc(star.x * canvas.width, star.y * canvas.height, star.size, 0, Math.PI * 2);
ctx.fill();
});
}
// Рисуем луну ночью
if (timeOfDay >= 0.42 && timeOfDay < 0.68) {
let moonAlpha = 1;
if (timeOfDay < 0.47) moonAlpha = (timeOfDay - 0.42) / 0.05;
if (timeOfDay > 0.63) moonAlpha = 1 - (timeOfDay - 0.63) / 0.05;
// Прогресс движения луны по небу (0 -> 1)
const moonProgress = (timeOfDay - 0.42) / (0.68 - 0.42);
// Позиция луны - движется слева направо по дуге
const moonX = canvas.width * (0.1 + moonProgress * 0.8);
const moonY = canvas.height * 0.12 + Math.sin(moonProgress * Math.PI) * -30;
const moonRadius = 40;
// Свечение луны
const moonGlow = ctx.createRadialGradient(moonX, moonY, moonRadius * 0.5, moonX, moonY, moonRadius * 1.5);
moonGlow.addColorStop(0, `rgba(255, 255, 230, ${moonAlpha * 0.3})`);
moonGlow.addColorStop(1, 'rgba(255, 255, 230, 0)');
ctx.fillStyle = moonGlow;
ctx.beginPath();
ctx.arc(moonX, moonY, moonRadius * 1.5, 0, Math.PI * 2);
ctx.fill();
// Луна
ctx.fillStyle = `rgba(255, 255, 230, ${moonAlpha})`;
ctx.beginPath();
ctx.arc(moonX, moonY, moonRadius, 0, Math.PI * 2);
ctx.fill();
// Кратеры на луне
ctx.fillStyle = `rgba(200, 200, 180, ${moonAlpha * 0.5})`;
ctx.beginPath();
ctx.arc(moonX - 10, moonY - 5, 8, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(moonX + 12, moonY + 8, 6, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(moonX - 5, moonY + 12, 5, 0, Math.PI * 2);
ctx.fill();
}
// Рисуем солнце днём
if (timeOfDay < 0.38 || timeOfDay > 0.72) {
let sunAlpha = 1;
if (timeOfDay >= 0.32 && timeOfDay < 0.38) sunAlpha = 1 - (timeOfDay - 0.32) / 0.06;
if (timeOfDay >= 0.72 && timeOfDay < 0.78) sunAlpha = (timeOfDay - 0.72) / 0.06;
if (timeOfDay >= 0.78) sunAlpha = 1;
// Позиция солнца в зависимости от времени
let sunProgress;
if (timeOfDay < 0.38) {
sunProgress = timeOfDay / 0.38;
} else {
sunProgress = (timeOfDay - 0.72) / 0.28;
}
const sunX = canvas.width * (0.1 + sunProgress * 0.8);
const sunY = canvas.height * 0.12 + Math.sin(sunProgress * Math.PI) * -30;
const sunRadius = 35;
// Свечение солнца
const gradient = ctx.createRadialGradient(sunX, sunY, sunRadius * 0.5, sunX, sunY, sunRadius * 2);
gradient.addColorStop(0, `rgba(255, 255, 100, ${sunAlpha})`);
gradient.addColorStop(0.5, `rgba(255, 200, 50, ${sunAlpha * 0.3})`);
gradient.addColorStop(1, 'rgba(255, 150, 0, 0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(sunX, sunY, sunRadius * 2, 0, Math.PI * 2);
ctx.fill();
// Солнце
ctx.fillStyle = `rgba(255, 255, 100, ${sunAlpha})`;
ctx.beginPath();
ctx.arc(sunX, sunY, sunRadius, 0, Math.PI * 2);
ctx.fill();
}
// Получаем уровень затемнения для блоков
const darkness = getDarkness(timeOfDay);
const sx=Math.max(0,Math.floor(camera.x/32)); const ex=Math.min(WORLD_WIDTH,Math.ceil((camera.x+canvas.width)/32));
const sy=Math.max(0,Math.floor(camera.y/32)); const ey=Math.min(WORLD_HEIGHT,Math.ceil((camera.y+canvas.height)/32));
// === ПЕРВЫЙ ПРОХОД: Рисуем фоновые блоки (деревья, листва) ===
for(let y=sy;y<ey;y++){
if (!world[y]) continue;
for(let x=sx;x<ex;x++){
const b=world[y][x];
// Рисуем только фоновые блоки (дерево, листва, факелы)
if(b === BLOCKS.WOOD || b === BLOCKS.LEAVES || b === BLOCKS.TORCH){
const scrX=x*32-camera.x, scrY=y*32-camera.y;
// Сохраняем контекст для затемнения фоновых блоков
ctx.save();
ctx.globalAlpha = 0.7; // Фоновые блоки немного прозрачнее
if(textures[b] && textures[b].complete && textures[b].src) {
ctx.drawImage(textures[b], scrX, scrY, 32, 32);
} else {
ctx.fillStyle = COLORS[b] || '#fff';
ctx.fillRect(scrX, scrY, 32, 32);
}
ctx.restore();
// Дополнительное затемнение для эффекта "фона"
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.fillRect(scrX, scrY, 32, 32);
// Плавное затемнение блоков ночью
if (darkness > 0) {
ctx.fillStyle = `rgba(0,0,0,${darkness})`;
ctx.fillRect(scrX,scrY,32,32);
}
if(miningTarget?.x===x&&miningTarget?.y===y){
const mineSpeed = getMiningSpeed(b, hotbarSlots[selectedSlot]);
const progress = Math.min(miningProgress / mineSpeed, 1);
// Рамка полоски
ctx.fillStyle='rgba(0,0,0,0.7)';
ctx.fillRect(scrX+2, scrY+22, 28, 8);
// Заполнение полоски
ctx.fillStyle='rgba(100,255,100,0.9)';
ctx.fillRect(scrX+3, scrY+23, 26 * progress, 6);
}
}
}
}
// === ВТОРОЙ ПРОХОД: Рисуем обычные (твёрдые) блоки ===
for(let y=sy;y<ey;y++){
if (!world[y]) continue;
for(let x=sx;x<ex;x++){
const b=world[y][x];
// Пропускаем воздух и фоновые блоки
if(b!==0 && b !== undefined && b !== BLOCKS.WOOD && b !== BLOCKS.LEAVES && b !== BLOCKS.TORCH){
const scrX=x*32-camera.x, scrY=y*32-camera.y;
// Используем вариации текстур для земли и камня
let textureToUse = textures[b];
if (b === BLOCKS.DIRT && textures.dirtVariants) {
const variantIndex = ((x * 7) + (y * 13)) % textures.dirtVariants.length;
textureToUse = textures.dirtVariants[variantIndex];
} else if (b === BLOCKS.STONE && textures.stoneVariants) {
const variantIndex = ((x * 11) + (y * 17)) % textures.stoneVariants.length;
textureToUse = textures.stoneVariants[variantIndex];
}
if(textureToUse && textureToUse.complete && textureToUse.src) {
ctx.drawImage(textureToUse, scrX, scrY, 32, 32);
} else {
ctx.fillStyle = COLORS[b] || '#fff';
ctx.fillRect(scrX, scrY, 32, 32);
}
// Плавное затемнение блоков ночью
if (darkness > 0) {
ctx.fillStyle = `rgba(0,0,0,${darkness})`;
ctx.fillRect(scrX,scrY,32,32);
}
if(miningTarget?.x===x&&miningTarget?.y===y){
const mineSpeed = getMiningSpeed(b, hotbarSlots[selectedSlot]);
const progress = Math.min(miningProgress / mineSpeed, 1);
// Рамка полоски
ctx.fillStyle='rgba(0,0,0,0.7)';
ctx.fillRect(scrX+2, scrY+22, 28, 8);
// Заполнение полоски
ctx.fillStyle='rgba(100,255,100,0.9)';
ctx.fillRect(scrX+3, scrY+23, 26 * progress, 6);
}
}
}
}
mobs.forEach(m => drawMob(ctx, m));
// Рисуем выпавшие предметы
renderDroppedItems(ctx);
Object.values(otherPlayers).forEach(p => {
if(p.targetX !== undefined) {
p.x += (p.targetX - p.x) * 0.2;
p.y += (p.targetY - p.y) * 0.2;
}
if (p.swordSwingAngle > 0) p.swordSwingAngle -= 0.005;
drawCharacter(ctx, p, false);
});
drawCharacter(ctx, player, true);
const tx=mouseX+camera.x, ty=mouseY+camera.y, tbx=Math.floor(tx/32), tby=Math.floor(ty/32);
if(Math.sqrt((tx-(player.x+12))**2+(ty-(player.y+24))**2)<150){ctx.strokeStyle='rgba(255,255,255,0.5)';ctx.lineWidth=2;ctx.strokeRect(tbx*32-camera.x,tby*32-camera.y,32,32);}
}
function updateTimeDisplay() {
const t = document.getElementById('timeDisplay');
const d = document.getElementById('dayCounter');
if (timeOfDay < 0.30) t.innerHTML = `☀️ День<div id="dayCounter">День ${dayCount}</div>`;
else if (timeOfDay < 0.40) t.innerHTML = `🌅 Закат<div id="dayCounter">День ${dayCount}</div>`;
else if (timeOfDay < 0.60) t.innerHTML = `🌙 Ночь<div id="dayCounter">Ночь ${dayCount}</div>`;
else if (timeOfDay < 0.80) t.innerHTML = `🌄 Рассвет<div id="dayCounter">День ${dayCount}</div>`;
else t.innerHTML = `☀️ День<div id="dayCounter">День ${dayCount}</div>`;
}
function updateHealthBar() { const bar = document.getElementById('healthBar'); bar.innerHTML = ''; for (let i = 0; i < player.maxHealth; i++) { const heart = document.createElement('div'); heart.className = 'heart' + (i >= player.health ? ' empty' : ''); bar.appendChild(heart); } }
function getMiningSpeed(b,t){
let bs=2000;
if([2,9,10,11].includes(b)){
bs=3000;
if(t===101)return 1500;
if(t===104)return 800;
if(t===107)return 500; // Железная кирка
return 999999;
}
if(b===4)return 1500;
if(b===1||b===3)return 500;
return 800;
}
function mineBlock(x,y,dt){
const bx=Math.floor(x/32),by=Math.floor(y/32);
if(by<0||by>=WORLD_HEIGHT||bx<0||bx>=WORLD_WIDTH)return;
const b=world[by][bx];
if(b===0)return;
// Запрет на ломание воды
if(b===BLOCKS.WATER)return;
if(miningTarget?.x!==bx||miningTarget?.y!==by){
miningTarget={x:bx,y:by};
miningProgress=0;
}
// Прогресс добычи нормализован к 60 FPS
const timeScale = dt / BASE_DT;
miningProgress += BASE_DT * timeScale;
if(miningProgress>=getMiningSpeed(b,hotbarSlots[selectedSlot])){
// Если это сундук - выбрасываем вещи
if (b === BLOCKS.CHEST) {
const chestKey = getChestKey(bx, by);
const chest = chests[chestKey];
if (chest && chest.items) {
for (let itemId in chest.items) {
const count = chest.items[itemId];
if (count > 0) {
createDrop(bx * BLOCK_SIZE + 16, by * BLOCK_SIZE, parseInt(itemId), count);
}
}
}
delete chests[chestKey];
closeChest();
if (!isLocalGame) {
socket.emit('breakChest', { x: bx, y: by });
}
}
// Проверяем, это дерево или листва - разрушаем всё дерево
if (b === BLOCKS.WOOD || b === BLOCKS.LEAVES) {
const treeBlocks = destroyTree(bx, by);
// Уничтожаем все блоки дерева и создаём дропы
treeBlocks.forEach((block, index) => {
if (isLocalGame) {
localUpdateBlock(block.x, block.y, 0);
} else {
socket.emit('updateBlock', {x: block.x, y: block.y, type: 0});
}
// Создаём дроп с небольшой задержкой для эффекта "падения"
const dropX = block.x * BLOCK_SIZE + 16;
const dropY = block.y * BLOCK_SIZE + 16;
// Дропаем только дерево, не листву
if (block.type === BLOCKS.WOOD) {
setTimeout(() => {
createDrop(dropX, dropY, block.type, 1);
}, index * 30); // Задержка между дропами
}
});
} else {
// Обычный блок
if (isLocalGame) {
localUpdateBlock(bx, by, 0);
} else {
socket.emit('updateBlock',{x:bx,y:by,type:0});
}
// Создаём дроп предмета
const itemToAdd = (b === BLOCKS.COAL_ORE) ? ITEMS.COAL : b;
const dropX = bx * BLOCK_SIZE + 16;
const dropY = by * BLOCK_SIZE + 16;
createDrop(dropX, dropY, itemToAdd, 1);
}
miningTarget=null;
miningProgress=0;
updateUI();
}
}
function placeBlock(x,y){
const bx=Math.floor(x/32),by=Math.floor(y/32);
if(by>=0&&by<WORLD_HEIGHT&&bx>=0&&bx<WORLD_WIDTH){
const currentBlock = world[by] && world[by][bx];
// Проверяем, кликнули ли на сундук - открываем его
if (currentBlock === BLOCKS.CHEST) {
openChest(bx, by);
return;
}
const blockToPlace = hotbarSlots[selectedSlot];
// Запрет на размещение воды
if (blockToPlace === BLOCKS.WATER) return;
// Можно ставить на воздух или воду
if((currentBlock === BLOCKS.AIR || currentBlock === BLOCKS.WATER) && inventory[blockToPlace]>0 && blockToPlace<50){
// Проверка что не в игроке
if(!(bx*32<player.x+player.width&&bx*32+32>player.x&&by*32<player.y+player.height&&by*32+32>player.y)){
// Проверка что рядом есть блок (не воздух и не вода)
let hasNeighbor = false;
const neighbors = [
[bx-1, by], [bx+1, by], [bx, by-1], [bx, by+1]
];
for (const [nx, ny] of neighbors) {
if (ny >= 0 && ny < WORLD_HEIGHT && nx >= 0 && nx < WORLD_WIDTH) {
const neighborBlock = world[ny] && world[ny][nx];
if (neighborBlock && neighborBlock !== BLOCKS.AIR && neighborBlock !== BLOCKS.WATER) {
hasNeighbor = true;
break;
}
}
}
if (hasNeighbor) {
if (isLocalGame) {
localUpdateBlock(bx, by, blockToPlace);
} else {
socket.emit('updateBlock',{x:bx,y:by,type:blockToPlace});
}
inventory[blockToPlace]--;
updateUI();
}
}
}
}
}
// Полностью переработанная функция отрисовки персонажа
function drawCharacter(ctx, p, isLocal) {
const px = p.x - camera.x;
const py = p.y - camera.y;
const dir = p.direction || 1;
const walkCycle = p.walkCycle || 0;
const swingAngle = isLocal ? swordSwingAngle : (p.swordSwingAngle || 0);
const heldItem = isLocal ? hotbarSlots[selectedSlot] : p.heldItem;
// Мигание при неуязвимости
if (isLocal && isInvulnerable) {
if (Math.floor(Date.now() / 100) % 2 === 0) {
ctx.globalAlpha = 0.3;
}
}
// Эффект под водой
const playerInWater = isInWater(p.x, p.y, p.width || 24, p.height || 48);
if (playerInWater) {
ctx.globalAlpha *= 0.8; // Немного прозрачнее под водой
}
// Цвета персонажа
const bodyColor = isLocal ? '#4169E1' : '#FF4500';
const bodyColorDark = isLocal ? '#3158C0' : '#D03800';
const skinColor = '#FFE4B5';
const skinColorDark = '#DFC49A';
const hairColor = '#8B4513';
ctx.save();
// Анимация ног
const legSwing = Math.sin(walkCycle) * 8;
// === ЛЕВАЯ НОГА (задняя) ===
ctx.fillStyle = bodyColorDark;
ctx.fillRect(px + 6, py + 32 + legSwing, 5, 14);
// Ботинок
ctx.fillStyle = '#4a3728';
ctx.fillRect(px + 5, py + 44 + legSwing, 7, 4);
// === ПРАВАЯ НОГА (передняя) ===
ctx.fillStyle = bodyColor;
ctx.fillRect(px + 13, py + 32 - legSwing, 5, 14);
// Ботинок
ctx.fillStyle = '#5a4738';
ctx.fillRect(px + 12, py + 44 - legSwing, 7, 4);
// === ЗАДНЯЯ РУКА ===
const backArmX = dir > 0 ? px + 2 : px + 18;
ctx.save();
ctx.translate(backArmX + 3, py + 18);
// Небольшое покачивание руки при ходьбе
ctx.rotate(Math.sin(walkCycle) * 0.3 * -dir);
ctx.fillStyle = bodyColorDark;
ctx.fillRect(-3, 0, 5, 12);
// Кисть
ctx.fillStyle = skinColorDark;
ctx.fillRect(-2, 11, 4, 4);
ctx.restore();
// === ТУЛОВИЩЕ ===
ctx.fillStyle = bodyColor;
ctx.fillRect(px + 5, py + 16, 14, 18);
// Детали туловища - воротник
ctx.fillStyle = bodyColorDark;
ctx.fillRect(px + 7, py + 16, 10, 3);
// Пояс
ctx.fillStyle = '#3a3020';
ctx.fillRect(px + 5, py + 31, 14, 3);
// Пряжка
ctx.fillStyle = '#FFD700';
ctx.fillRect(px + 10, py + 31, 4, 3);
// === ГОЛОВА ===
// Лицо
ctx.fillStyle = skinColor;
ctx.fillRect(px + 4, py + 2, 16, 14);
// Волосы
ctx.fillStyle = hairColor;
ctx.fillRect(px + 4, py, 16, 6);
// Боковые волосы
if (dir > 0) {
ctx.fillRect(px + 2, py + 2, 4, 8);
} else {
ctx.fillRect(px + 18, py + 2, 4, 8);
}
// Глаз
ctx.fillStyle = '#fff';
const eyeX = dir > 0 ? px + 13 : px + 7;
ctx.fillRect(eyeX, py + 7, 5, 4);
// Зрачок
ctx.fillStyle = '#000';
ctx.fillRect(dir > 0 ? eyeX + 2 : eyeX, py + 8, 2, 2);
// Рот
ctx.fillStyle = '#c77';
ctx.fillRect(dir > 0 ? px + 14 : px + 6, py + 12, 3, 1);
// === ПЕРЕДНЯЯ РУКА С ПРЕДМЕТОМ ===
const frontArmX = dir > 0 ? px + 17 : px + 3;
ctx.save();
ctx.translate(frontArmX, py + 18);
// Если есть предмет и это меч - применяем анимацию удара
const isSword = [ITEMS.WOODEN_SWORD, ITEMS.STONE_SWORD, ITEMS.IRON_SWORD].includes(heldItem);
const isPickaxe = [ITEMS.WOODEN_PICKAXE, ITEMS.STONE_PICKAXE, ITEMS.IRON_PICKAXE].includes(heldItem);
const isAxe = [ITEMS.WOODEN_AXE, ITEMS.STONE_AXE, ITEMS.IRON_AXE].includes(heldItem);
const isTool = isSword || isPickaxe || isAxe;
let armRotation = Math.sin(walkCycle) * 0.3 * dir;
if (isTool && swingAngle > 0) {
armRotation = -swingAngle * dir * 1.2;
}
ctx.rotate(armRotation);
// Рука
ctx.fillStyle = bodyColor;
ctx.fillRect(-3, 0, 6, 12);
// Кисть
ctx.fillStyle = skinColor;
ctx.fillRect(-2, 11, 5, 5);
// === ПРЕДМЕТ В РУКЕ ===
if (heldItem && heldItem !== BLOCKS.AIR) {
ctx.save();
ctx.translate(0, 14);
if (isTool) {
// Инструменты рисуем повернутыми
ctx.rotate(dir > 0 ? 0.3 : -0.3);
// Рукоять
ctx.fillStyle = '#8B4513';
ctx.fillRect(-2, 0, 4, 16);
// Головка инструмента
if (isSword) {
// Клинок меча
let bladeColor = '#aaa';
if (heldItem === ITEMS.STONE_SWORD) bladeColor = '#888';
if (heldItem === ITEMS.IRON_SWORD) bladeColor = '#ccc';
ctx.fillStyle = bladeColor;
ctx.fillRect(-1, -10, 2, 12);
ctx.fillRect(-2, -8, 4, 8);
// Острие
ctx.beginPath();
ctx.moveTo(-2, -10);
ctx.lineTo(0, -14);
ctx.lineTo(2, -10);
ctx.fill();
// Гарда
ctx.fillStyle = '#FFD700';
ctx.fillRect(-4, 0, 8, 2);
} else if (isPickaxe) {
// Головка кирки
let pickColor = '#aaa';
if (heldItem === ITEMS.STONE_PICKAXE) pickColor = '#888';
if (heldItem === ITEMS.IRON_PICKAXE) pickColor = '#ccc';
ctx.fillStyle = pickColor;
ctx.fillRect(-8, -2, 16, 4);
// Заострения
ctx.beginPath();
ctx.moveTo(-8, -2);
ctx.lineTo(-10, 0);
ctx.lineTo(-8, 2);
ctx.fill();
ctx.beginPath();
ctx.moveTo(8, -2);
ctx.lineTo(10, 0);
ctx.lineTo(8, 2);
ctx.fill();
} else if (isAxe) {
// Головка топора
let axeColor = '#aaa';
if (heldItem === ITEMS.STONE_AXE) axeColor = '#888';
if (heldItem === ITEMS.IRON_AXE) axeColor = '#ccc';
ctx.fillStyle = axeColor;
ctx.beginPath();
ctx.moveTo(-2, -4);
ctx.lineTo(-10, -8);
ctx.lineTo(-10, 4);
ctx.lineTo(-2, 2);
ctx.fill();
}
} else {
// Блоки и другие предметы
const itemSize = 14;
if (textures[heldItem] && textures[heldItem].complete && textures[heldItem].src) {
ctx.drawImage(textures[heldItem], -itemSize/2, -itemSize/2, itemSize, itemSize);
} else if (COLORS[heldItem]) {
ctx.fillStyle = COLORS[heldItem];
ctx.fillRect(-itemSize/2, -itemSize/2, itemSize, itemSize);
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.strokeRect(-itemSize/2, -itemSize/2, itemSize, itemSize);
} else if (EMOJIS[heldItem]) {
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(EMOJIS[heldItem], 0, 0);
}
}
ctx.restore();
}
ctx.restore();
ctx.restore();
// === НИКНЕЙМ ===
ctx.globalAlpha = 1; // Сбрасываем прозрачность для никнейма
// Определяем цвет команды
const playerTeam = isLocal ? myTeam : (p.team || null);
let nicknameColor = 'white';
if (playerTeam && TEAM_COLORS[playerTeam]) {
nicknameColor = TEAM_COLORS[playerTeam];
}
ctx.fillStyle = nicknameColor;
ctx.strokeStyle = 'black';
ctx.lineWidth = 3;
ctx.font = 'bold 12px monospace';
ctx.textAlign = 'center';
const nickname = isLocal ? myNickname : (p.nickname || "Player");
ctx.strokeText(nickname, px + 12, py - 8);
ctx.fillText(nickname, px + 12, py - 8);
// Индикатор команды (маленький квадрат)
if (playerTeam) {
ctx.fillStyle = TEAM_COLORS[playerTeam];
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(px + 12, py - 18, 5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// Пузырьки при плавании
if (playerInWater && isLocal) {
const time = Date.now() * 0.003;
for (let i = 0; i < 3; i++) {
const bubbleX = px + 12 + Math.sin(time + i * 2) * 8;
const bubbleY = py - 5 - ((time * 20 + i * 15) % 30);
const bubbleSize = 2 + Math.sin(time + i) * 1;
ctx.fillStyle = 'rgba(200, 230, 255, 0.6)';
ctx.beginPath();
ctx.arc(bubbleX, bubbleY, bubbleSize, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.globalAlpha = 1; // Восстанавливаем полную непрозрачность
}
function getIconHTML(i){
if(textures[i] && textures[i].src) {
return `<img src="${textures[i].src}" class="slot-icon">`;
}
return EMOJIS[i] || '';
}
// Тултип для предметов
const tooltip = document.getElementById('itemTooltip');
let tooltipTimeout = null;
function getItemType(itemId) {
if (itemId >= 100 && itemId <= 102) return 'Деревянный инструмент';
if (itemId >= 103 && itemId <= 105) return 'Каменный инструмент';
if (itemId >= 106 && itemId <= 108) return 'Железный инструмент';
if (itemId === ITEMS.COAL) return 'Ресурс';
if (itemId >= 110 && itemId <= 112) return 'Слиток';
if (itemId === ITEMS.HEALTH_POTION || itemId === ITEMS.MANA_POTION) return 'Зелье';
if (itemId === BLOCKS.WORKBENCH || itemId === BLOCKS.FURNACE) return 'Станция крафта';
if (itemId <= 15) return 'Блок';
return 'Предмет';
}
function showTooltip(itemId, count, x, y) {
const name = ITEM_NAMES[itemId] || 'Неизвестно';
const type = getItemType(itemId);
tooltip.innerHTML = `
<div class="tooltip-name">${EMOJIS[itemId] || ''} ${name}</div>
<div class="tooltip-count">Количество: ${count}</div>
<div class="tooltip-type">${type}</div>
`;
tooltip.style.display = 'block';
tooltip.style.left = (x + 15) + 'px';
tooltip.style.top = (y + 15) + 'px';
// Проверяем, чтобы тултип не выходил за границы экрана
const rect = tooltip.getBoundingClientRect();
if (rect.right > window.innerWidth) {
tooltip.style.left = (x - rect.width - 10) + 'px';
}
if (rect.bottom > window.innerHeight) {
tooltip.style.top = (y - rect.height - 10) + 'px';
}
}
function hideTooltip() {
tooltip.style.display = 'none';
}
function addTooltipEvents(element, itemId, count) {
element.addEventListener('mouseenter', (e) => {
tooltipTimeout = setTimeout(() => {
showTooltip(itemId, count, e.clientX, e.clientY);
}, 200);
});
element.addEventListener('mousemove', (e) => {
if (tooltip.style.display === 'block') {
tooltip.style.left = (e.clientX + 15) + 'px';
tooltip.style.top = (e.clientY + 15) + 'px';
}
});
element.addEventListener('mouseleave', () => {
clearTimeout(tooltipTimeout);
hideTooltip();
});
}
function updateUI(){
if (!isLocalGame) socket.emit('syncInventory',{inventory,hotbarSlots});
const hb=document.getElementById('hotbar');
hb.innerHTML='';
hotbarSlots.forEach((i,idx)=>{
const d=document.createElement('div');
d.className='hotbarSlot'+(idx===selectedSlot?' selected':'');
// Показываем иконку только если есть предметы в инвентаре
const hasItem = i && i !== BLOCKS.AIR && inventory[i] > 0;
d.innerHTML=`${hasItem ? getIconHTML(i) : ''}${hasItem ? `<div class="itemCount">${inventory[i]}</div>` : ''}`;
d.onclick=()=>{
if(selectedInventoryItem!==null){
hotbarSlots[idx]=selectedInventoryItem;
selectedInventoryItem=null;
updateUI();
}else{
selectedSlot=idx;
updateUI();
}
};
// Добавляем тултип для хотбара (только если есть предметы)
if (hasItem) {
addTooltipEvents(d, i, inventory[i]);
}
hb.appendChild(d);
});
const g=document.getElementById('inventoryGrid');
g.innerHTML='';
[...Object.values(BLOCKS),...Object.values(ITEMS)].forEach(i=>{
if(inventory[i]>0){
const d=document.createElement('div');
d.className='invSlot'+(selectedInventoryItem===i?' selectedItem':'');
d.innerHTML=`${getIconHTML(i)}<div class="itemCount">${inventory[i]}</div>`;
d.onclick=()=>{selectedInventoryItem=i;updateUI();};
// Добавляем тултип для инвентаря
addTooltipEvents(d, i, inventory[i]);
g.appendChild(d);
}
});
}
function canCraft(r){for(let i in r.ing)if(inventory[i]<r.ing[i])return false;if(r.wb||r.fn){const px=Math.floor(player.x/32),py=Math.floor(player.y/32);let f=false,rb=r.wb?6:7;for(let dy=-3;dy<=3;dy++)for(let dx=-3;dx<=3;dx++){const by=py+dy,bx=px+dx;if(by>=0&&by<WORLD_HEIGHT&&bx>=0&&bx<WORLD_WIDTH&&world[by][bx]===rb)f=true;}if(!f)return false;}return true;}
function craft(r){
if(canCraft(r)){
// Проверяем лимит стака
if (inventory[r.result] + r.count > MAX_STACK) {
addChatMessage('⚠️ Невозможно скрафтить - превышен лимит стака (' + MAX_STACK + ')', true);
return;
}
for(let i in r.ing) inventory[i] -= r.ing[i];
inventory[r.result] += r.count;
updateUI();
updateCraftingMenu();
}
}
// Функция для получения иконки ингредиента в крафте
function getIngredientIcon(itemId) {
if (textures[itemId] && textures[itemId].src) {
return `<img src="${textures[itemId].src}" alt="">`;
} else if (EMOJIS[itemId]) {
return `<span class="emoji">${EMOJIS[itemId]}</span>`;
} else if (COLORS[itemId]) {
return `<span style="display:inline-block;width:16px;height:16px;background:${COLORS[itemId]};border-radius:2px;"></span>`;
}
return '❓';
}
function updateCraftingMenu(){
const rd=document.getElementById('recipes');
rd.innerHTML='';
RECIPES.forEach(r=>{
const d=document.createElement('div');
const c=canCraft(r);
d.className='craftingRecipe'+(c?'':' cantCraft');
// Собираем HTML для ингредиентов
let ingredientsHTML = '<div class="recipe-ingredients">';
for(let itemId in r.ing) {
const need = r.ing[itemId];
const have = inventory[itemId] || 0;
const hasEnough = have >= need;
const itemName = ITEM_NAMES[itemId] || 'Неизвестно';
ingredientsHTML += `
<div class="ingredient ${hasEnough ? 'have' : 'missing'}">
${getIngredientIcon(itemId)}
<span class="count">${have}/${need}</span>
<span class="name">${itemName}</span>
</div>`;
}
ingredientsHTML += '</div>';
// Требования к станции
let stationReq = '';
if (r.wb) stationReq = '<div style="margin-top:5px;color:#CD853F;font-size:12px;">🔨 Требуется верстак</div>';
if (r.fn) stationReq = '<div style="margin-top:5px;color:#FF6347;font-size:12px;">🔥 Требуется печь</div>';
d.innerHTML=`<strong>${r.name}</strong> <span style="color:#888;font-size:12px;">(x${r.count})</span>${ingredientsHTML}${stationReq}`;
if(c) d.onclick=()=>craft(r);
rd.appendChild(d);
});
}
function attackMob() {
const now = Date.now();
if (now - lastAttackTime < 500) return;
lastAttackTime = now;
swordSwingAngle = Math.PI * 0.8;
const targetX = mouseX + camera.x;
const targetY = mouseY + camera.y;
let hitSomething = false;
// PvP - атака других игроков (проверяем ПЕРВЫМИ)
if (!isLocalGame) {
Object.entries(otherPlayers).forEach(([playerId, p]) => {
// Проверяем команды - нельзя бить союзников
if (myTeam && p.team === myTeam) return;
const playerCenterX = p.x + 12;
const playerCenterY = p.y + 24;
const dist = Math.sqrt(Math.pow(targetX - playerCenterX, 2) + Math.pow(targetY - playerCenterY, 2));
if (dist < 60) {
hitSomething = true;
let damage = 1;
const heldItem = hotbarSlots[selectedSlot];
if (heldItem === ITEMS.WOODEN_SWORD) damage = 3;
if (heldItem === ITEMS.STONE_SWORD) damage = 5;
if (heldItem === ITEMS.IRON_SWORD) damage = 7;
socket.emit('attackPlayer', {
targetId: playerId,
damage: damage,
knockbackX: (p.x - player.x) > 0 ? 8 : -8,
knockbackY: -5
});
addChatMessage(`⚔️ Вы атаковали ${p.nickname || 'игрока'}!`, true);
}
});
}
// Атака мобов
mobs.forEach(mob => {
const dist = Math.sqrt(Math.pow(targetX - (mob.x + mob.width/2), 2) + Math.pow(targetY - (mob.y + mob.height/2), 2));
if (dist < 60) {
hitSomething = true;
if (isLocalGame) {
localDamageMob(mob.id);
} else {
socket.emit('damageMob', mob.id);
}
}
});
}
let lastTime = Date.now(); let frameCount = 0; let lastFpsTime = Date.now(); let currentFps = 0;
function gameLoop() {
if (!gameStarted) return;
const now = Date.now(); const dt = now - lastTime; lastTime = now;
frameCount++; if (now - lastFpsTime >= 1000) { currentFps = frameCount; frameCount = 0; lastFpsTime = now; updateDebugInfo(); }
updatePlayer(dt);
updateWater(); // Обновляем текучесть воды
updateDroppedItems(dt); // Обновляем выпавшие предметы
render();
const targetWorldX = mouseX + camera.x; const targetWorldY = mouseY + camera.y;
const dist = Math.sqrt(Math.pow(targetWorldX - (player.x + 12), 2) + Math.pow(targetWorldY - (player.y + 24), 2));
if (!isChatOpen && dist < 150) {
if (mouseDown.left) {
let hitTarget = false;
// Проверка клика по мобам
mobs.forEach(mob => {
const d = Math.sqrt(Math.pow(targetWorldX - (mob.x + mob.width/2), 2) + Math.pow(targetWorldY - (mob.y + mob.height/2), 2));
if(d < 40) hitTarget = true;
});
// Проверка клика по другим игрокам (PvP)
if (!isLocalGame) {
Object.values(otherPlayers).forEach(p => {
const playerCenterX = p.x + 12;
const playerCenterY = p.y + 24;
const d = Math.sqrt(Math.pow(targetWorldX - playerCenterX, 2) + Math.pow(targetWorldY - playerCenterY, 2));
if(d < 50) hitTarget = true;
});
}
if(hitTarget) attackMob();
else mineBlock(mouseX+camera.x, mouseY+camera.y, dt);
} else { miningTarget = null; miningProgress = 0; }
if (mouseDown.right && !mouseDown.prevRight) placeBlock(mouseX+camera.x, mouseY+camera.y);
}
mouseDown.prevRight = mouseDown.right;
requestAnimationFrame(gameLoop);
}
window.addEventListener('keydown', e => {
if (e.key === 'Enter') {
if (isChatOpen) {
const text = chatInput.value.trim();
if (text) {
if (isLocalGame) {
addChatMessage(`<strong>${myNickname}:</strong> ${text}`);
} else {
socket.emit('chatMessage', text);
}
}
chatInput.value = '';
chatInputDiv.style.display = 'none';
isChatOpen = false;
canvas.focus();
// Планируем скрытие чата после закрытия ввода
scheduleChatHide();
} else {
// Показываем чат и открываем ввод
showChat();
chatInputDiv.style.display = 'block';
chatInput.focus();
isChatOpen = true;
keys = {};
// Убираем класс fading со всех сообщений когда чат открыт
document.querySelectorAll('.chatMsg.fading').forEach(msg => {
msg.classList.remove('fading');
});
}
return;
}
if(!isChatOpen){
keys[e.key]=true;
if(e.key>='1'&&e.key<='9'){selectedSlot=parseInt(e.key)-1;updateUI();}
if(['e','E','у','У'].includes(e.key)) {
// Закрываем сундук если открыт
if (document.getElementById('chestMenu').classList.contains('active')) {
closeChest();
} else {
document.getElementById('inventoryMenu').classList.toggle('active');
}
}
if(['c','C','с','С'].includes(e.key)){
closeChest(); // Закрываем сундук
const m=document.getElementById('craftingMenu');
m.classList.toggle('active');
if(m.classList.contains('active')) updateCraftingMenu();
}
if(e.key === 'Escape') {
closeChest();
document.getElementById('inventoryMenu').classList.remove('active');
document.getElementById('craftingMenu').classList.remove('active');
}
// Выбросить предмет по Q
if(['q','Q','й','Й'].includes(e.key)) {
dropItem();
}
}
});
window.addEventListener('keyup', e => keys[e.key]=false);
window.addEventListener('blur', () => { keys={}; mouseDown={left:false,right:false}; });
canvas.addEventListener('mousedown', e => { if(e.button===0)mouseDown.left=true; if(e.button===2)mouseDown.right=true; });
canvas.addEventListener('mouseup', e => { if(e.button===0){mouseDown.left=false;miningTarget=null;miningProgress=0;} if(e.button===2)mouseDown.right=false; });
canvas.addEventListener('mousemove', e => { mouseX=e.clientX; mouseY=e.clientY; });
canvas.addEventListener('contextmenu', e => e.preventDefault());
function resize() { canvas.width=window.innerWidth; canvas.height=window.innerHeight; }
window.addEventListener('resize', resize);
window.onload = updateUI;
</script>
</body>
</html>