3868 lines
193 KiB
HTML
3868 lines
193 KiB
HTML
<!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> |